From 37ca0b246262a990ff0e5e4f93dda0edea5b367d Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Wed, 11 Feb 2026 13:14:12 +1100 Subject: [PATCH 001/686] feat: add stack-auth crate for device code authentication Implement OAuth 2.0 Device Authorization Grant (RFC 8628) for CipherStash CLI authentication. Includes typed request/response structs, Region-based service discovery, test-utils feature with mock server support, and debug tracing. --- packages/stack-auth/Cargo.toml | 33 ++ packages/stack-auth/examples/device_code.rs | 31 ++ packages/stack-auth/src/device_code.rs | 549 ++++++++++++++++++++ packages/stack-auth/src/lib.rs | 66 +++ 4 files changed, 679 insertions(+) create mode 100644 packages/stack-auth/Cargo.toml create mode 100644 packages/stack-auth/examples/device_code.rs create mode 100644 packages/stack-auth/src/device_code.rs create mode 100644 packages/stack-auth/src/lib.rs diff --git a/packages/stack-auth/Cargo.toml b/packages/stack-auth/Cargo.toml new file mode 100644 index 000000000..5b7380ccf --- /dev/null +++ b/packages/stack-auth/Cargo.toml @@ -0,0 +1,33 @@ +[package] +name = "stack-auth" +version = "0.1.0" +edition.workspace = true +authors.workspace = true +repository.workspace = true +homepage.workspace = true + +[dependencies] +cts-common = { workspace = true } +open = "5.3.2" +reqwest = { workspace = true } +serde = { workspace = true } +thiserror = { workspace = true } +tokio = { workspace = true } +tracing = { workspace = true } +url = { workspace = true } +vitaminc = { workspace = true, features = ["protected"] } +zeroize = { workspace = true } + +[features] +test-utils = [] + +[[example]] +name = "device_code" +required-features = ["test-utils"] + +[dev-dependencies] +cts-common = { workspace = true } +mocktail = "0.3.0" +serde_json = { workspace = true } +tokio = { workspace = true, features = ["test-util"] } +tracing-subscriber = { workspace = true } diff --git a/packages/stack-auth/examples/device_code.rs b/packages/stack-auth/examples/device_code.rs new file mode 100644 index 000000000..f4c403a20 --- /dev/null +++ b/packages/stack-auth/examples/device_code.rs @@ -0,0 +1,31 @@ +use stack_auth::DeviceCodeStrategy; +use cts_common::Region; + +#[tokio::main] +async fn main() -> Result<(), Box> { + tracing_subscriber::fmt::init(); + + let region = Region::aws("ap-southeast-2")?; + let strategy = + DeviceCodeStrategy::new(region, "cli")?.with_base_url("http://localhost:3001")?; + + // Step 1: Begin the device code flow + let pending = strategy.begin().await?; + + // Step 2: Display the code and open the browser (caller controls this) + println!("Your code is: {}", pending.user_code()); + println!("Visit: {}", pending.verification_uri_complete()); + + if !pending.open_in_browser() { + eprintln!("Could not open browser — please visit the URL above manually."); + } + + // Step 3: Poll until the user authorizes + let token = pending.poll_for_token().await?; + + println!("Token type: {}", token.token_type()); + println!("Expires in: {}s", token.expires_in()); + println!("Access token: {}...", &token.access_token()[..20]); + + Ok(()) +} diff --git a/packages/stack-auth/src/device_code.rs b/packages/stack-auth/src/device_code.rs new file mode 100644 index 000000000..7235deb01 --- /dev/null +++ b/packages/stack-auth/src/device_code.rs @@ -0,0 +1,549 @@ +use cts_common::{CtsServiceDiscovery, Region, ServiceDiscovery}; +use serde::{Deserialize, Serialize}; +use url::Url; +use vitaminc::protected::OpaqueDebug; +use zeroize::ZeroizeOnDrop; + +use crate::{AuthError, AuthStrategy, Token}; + +/// Drives the RFC 8628 device authorization flow against CTS-hosted endpoints. +pub struct DeviceCodeStrategy { + base_url: Url, + client_id: String, +} + +/// A device code secret received from the authorization server. +/// +/// This is a bearer secret until exchanged for a token, so it is zeroized on +/// drop and its `Debug` output is redacted. +#[derive(OpaqueDebug, ZeroizeOnDrop)] +struct DeviceCode(String); + +/// The result of initiating a device code flow. +/// +/// Contains the user-facing codes and URIs needed to complete authorization, +/// and provides [`poll_for_token`](PendingDeviceCode::poll_for_token) to +/// exchange the device code for an access token once the user has authorized. +#[derive(Debug)] +pub struct PendingDeviceCode { + token_url: Url, + client_id: String, + device_code: DeviceCode, + user_code: String, + verification_uri: String, + verification_uri_complete: String, + expires_in: u64, +} + +impl PendingDeviceCode { + /// The short code the user must enter to authorize this device. + pub fn user_code(&self) -> &str { + &self.user_code + } + + /// The base verification URI (without the user code embedded). + pub fn verification_uri(&self) -> &str { + &self.verification_uri + } + + /// The full verification URI with the user code pre-filled. + pub fn verification_uri_complete(&self) -> &str { + &self.verification_uri_complete + } + + /// How many seconds the device code remains valid. + pub fn expires_in(&self) -> u64 { + self.expires_in + } + + /// Open the verification URI in the user's default browser. + /// + /// Returns `true` if the browser was opened successfully. + pub fn open_in_browser(&self) -> bool { + open::that(&self.verification_uri_complete).is_ok() + } + + /// Poll the token endpoint until the user authorizes (or the code expires). + pub async fn poll_for_token(self) -> Result { + let client = reqwest::Client::new(); + let mut interval = tokio::time::Duration::from_secs(5); + let deadline = + tokio::time::Instant::now() + tokio::time::Duration::from_secs(self.expires_in); + + tracing::debug!( + url = %self.token_url, + expires_in = self.expires_in, + "polling for token" + ); + + loop { + tokio::time::sleep(interval).await; + + if tokio::time::Instant::now() >= deadline { + tracing::debug!("device code expired while polling"); + return Err(AuthError::ExpiredToken); + } + + let resp = client + .post(self.token_url.clone()) + .form(&TokenRequest { + client_id: &self.client_id, + device_code: &self.device_code.0, + grant_type: "urn:ietf:params:oauth:grant-type:device_code", + }) + .send() + .await?; + + if resp.status().is_success() { + tracing::debug!("token received"); + let token_resp: TokenResponse = resp.json().await?; + return Ok(Token { + access_token: token_resp.access_token, + token_type: token_resp.token_type, + expires_in: token_resp.expires_in, + }); + } + + let err: ErrorResponse = resp.json().await?; + match err.error.as_str() { + "authorization_pending" => { + tracing::debug!("authorization pending, retrying"); + continue; + } + "slow_down" => { + interval += tokio::time::Duration::from_secs(5); + tracing::debug!(interval_secs = interval.as_secs(), "slowing down"); + continue; + } + "expired_token" => return Err(AuthError::ExpiredToken), + "access_denied" => return Err(AuthError::AccessDenied), + "invalid_grant" => return Err(AuthError::InvalidGrant), + "invalid_client" => return Err(AuthError::InvalidClient), + _ => return Err(AuthError::Server(err.error_description)), + } + } + } +} + +impl DeviceCodeStrategy { + pub fn new(region: Region, client_id: impl Into) -> Result { + let base_url = CtsServiceDiscovery::endpoint(region)?; + Ok(Self { + base_url, + client_id: client_id.into(), + }) + } + + /// Override the base URL resolved by service discovery. + /// + /// Useful for pointing at a local or mock CTS instance during testing. + #[cfg(any(test, feature = "test-utils"))] + pub fn with_base_url(mut self, base_url: U) -> Result + where + U: TryInto, + U::Error: Into, + { + self.base_url = base_url.try_into().map_err(Into::into)?; + Ok(self) + } + + /// Initiate the device code flow. + /// + /// Posts to the device code endpoint and returns a [`PendingDeviceCode`] + /// containing the user code and verification URIs. The caller can then + /// display these to the user and call + /// [`poll_for_token`](PendingDeviceCode::poll_for_token) to complete the + /// flow. + pub async fn begin(&self) -> Result { + let client = reqwest::Client::new(); + + let code_url = self.base_url.join("/oauth/device/code")?; + + tracing::debug!(url = %code_url, client_id = %self.client_id, "requesting device code"); + + let code_resp = client + .post(code_url) + .form(&DeviceCodeRequest { + client_id: &self.client_id, + }) + .send() + .await?; + + if !code_resp.status().is_success() { + let err: ErrorResponse = code_resp.json().await?; + tracing::debug!(error = %err.error, "device code request failed"); + return Err(match err.error.as_str() { + "invalid_client" => AuthError::InvalidClient, + _ => AuthError::Server(err.error_description), + }); + } + + let code: DeviceCodeResponse = code_resp.json().await?; + + let token_url = self.base_url.join("/oauth/device/token")?; + + tracing::debug!( + user_code = %code.user_code, + expires_in = code.expires_in, + "device code received" + ); + + Ok(PendingDeviceCode { + token_url, + client_id: self.client_id.clone(), + device_code: DeviceCode(code.device_code), + user_code: code.user_code, + verification_uri: code.verification_uri, + verification_uri_complete: code.verification_uri_complete, + expires_in: code.expires_in, + }) + } +} + +#[derive(Deserialize)] +struct DeviceCodeResponse { + device_code: String, + user_code: String, + verification_uri: String, + verification_uri_complete: String, + expires_in: u64, +} + +#[derive(Deserialize)] +struct TokenResponse { + access_token: String, + token_type: String, + expires_in: u64, +} + +#[derive(Deserialize)] +struct ErrorResponse { + error: String, + #[serde(default)] + error_description: String, +} + +#[derive(Serialize)] +struct DeviceCodeRequest<'a> { + client_id: &'a str, +} + +#[derive(Serialize)] +struct TokenRequest<'a> { + client_id: &'a str, + device_code: &'a str, + grant_type: &'a str, +} + +impl AuthStrategy for DeviceCodeStrategy { + async fn authenticate(self) -> Result { + let pending = self.begin().await?; + pending.open_in_browser(); + pending.poll_for_token().await + } +} + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod tests { + use super::*; + use cts_common::Region; + use mocktail::prelude::*; + + fn device_code_json() -> serde_json::Value { + serde_json::json!({ + "device_code": "test_device_code", + "user_code": "ABCD-EFGH", + "verification_uri": "http://example.com/activate", + "verification_uri_complete": "http://example.com/activate?user_code=ABCD-EFGH", + "expires_in": 900 + }) + } + + fn token_json() -> serde_json::Value { + serde_json::json!({ + "access_token": "test_access_token_value", + "token_type": "Bearer", + "expires_in": 3600 + }) + } + + fn error_json(error: &str) -> serde_json::Value { + serde_json::json!({ + "error": error, + "error_description": format!("{error} occurred") + }) + } + + fn mock_code_endpoint(mocks: &mut MockSet) { + mocks.mock(|when, then| { + when.post().path("/oauth/device/code"); + then.json(device_code_json()); + }); + } + + async fn start_server(mocks: MockSet) -> MockServer { + let server = MockServer::new_http("stack-auth-test").with_mocks(mocks); + server.start().await.unwrap(); + server + } + + fn strategy_for(server: &MockServer) -> DeviceCodeStrategy { + DeviceCodeStrategy::new(Region::aws("ap-southeast-2").unwrap(), "cli") + .unwrap() + .with_base_url(server.url("")) + .unwrap() + } + + // ---- begin() tests ---- + + #[tokio::test] + async fn test_begin_returns_pending_device_code() { + let mut mocks = MockSet::new(); + mock_code_endpoint(&mut mocks); + let server = start_server(mocks).await; + + let pending = strategy_for(&server).begin().await.unwrap(); + + assert_eq!(pending.user_code(), "ABCD-EFGH"); + assert_eq!(pending.verification_uri(), "http://example.com/activate"); + assert_eq!( + pending.verification_uri_complete(), + "http://example.com/activate?user_code=ABCD-EFGH" + ); + assert_eq!(pending.expires_in(), 900); + } + + #[tokio::test] + async fn test_begin_invalid_client() { + let mut mocks = MockSet::new(); + mocks.mock(|when, then| { + when.post().path("/oauth/device/code"); + then.bad_request().json(error_json("invalid_client")); + }); + let server = start_server(mocks).await; + + let err = strategy_for(&server).begin().await.unwrap_err(); + + assert!(matches!(err, AuthError::InvalidClient)); + } + + #[tokio::test] + async fn test_begin_server_error() { + let mut mocks = MockSet::new(); + mocks.mock(|when, then| { + when.post().path("/oauth/device/code"); + then.bad_request().json(error_json("server_error")); + }); + let server = start_server(mocks).await; + + let err = strategy_for(&server).begin().await.unwrap_err(); + + assert!(matches!(&err, AuthError::Server(desc) if desc == "server_error occurred")); + } + + // ---- poll_for_token() tests ---- + + /// Helper: calls begin() against a server that already has the code mock, + /// then returns the PendingDeviceCode ready for polling. + async fn begin_pending(server: &MockServer) -> PendingDeviceCode { + strategy_for(server).begin().await.unwrap() + } + + #[tokio::test(start_paused = true)] + async fn test_poll_for_token_success() { + let mut mocks = MockSet::new(); + mock_code_endpoint(&mut mocks); + mocks.mock(|when, then| { + when.post().path("/oauth/device/token"); + then.json(token_json()); + }); + let server = start_server(mocks).await; + + let token = begin_pending(&server).await.poll_for_token().await.unwrap(); + + assert_eq!(token.access_token(), "test_access_token_value"); + assert_eq!(token.token_type(), "Bearer"); + assert_eq!(token.expires_in(), 3600); + } + + #[tokio::test(start_paused = true)] + async fn test_poll_for_token_access_denied() { + let mut mocks = MockSet::new(); + mock_code_endpoint(&mut mocks); + mocks.mock(|when, then| { + when.post().path("/oauth/device/token"); + then.bad_request().json(error_json("access_denied")); + }); + let server = start_server(mocks).await; + + let err = begin_pending(&server) + .await + .poll_for_token() + .await + .unwrap_err(); + + assert!(matches!(err, AuthError::AccessDenied)); + } + + #[tokio::test(start_paused = true)] + async fn test_poll_for_token_expired_token() { + let mut mocks = MockSet::new(); + mock_code_endpoint(&mut mocks); + mocks.mock(|when, then| { + when.post().path("/oauth/device/token"); + then.bad_request().json(error_json("expired_token")); + }); + let server = start_server(mocks).await; + + let err = begin_pending(&server) + .await + .poll_for_token() + .await + .unwrap_err(); + + assert!(matches!(err, AuthError::ExpiredToken)); + } + + #[tokio::test(start_paused = true)] + async fn test_poll_for_token_invalid_grant() { + let mut mocks = MockSet::new(); + mock_code_endpoint(&mut mocks); + mocks.mock(|when, then| { + when.post().path("/oauth/device/token"); + then.bad_request().json(error_json("invalid_grant")); + }); + let server = start_server(mocks).await; + + let err = begin_pending(&server) + .await + .poll_for_token() + .await + .unwrap_err(); + + assert!(matches!(err, AuthError::InvalidGrant)); + } + + #[tokio::test(start_paused = true)] + async fn test_poll_for_token_invalid_client() { + let mut mocks = MockSet::new(); + mock_code_endpoint(&mut mocks); + mocks.mock(|when, then| { + when.post().path("/oauth/device/token"); + then.bad_request().json(error_json("invalid_client")); + }); + let server = start_server(mocks).await; + + let err = begin_pending(&server) + .await + .poll_for_token() + .await + .unwrap_err(); + + assert!(matches!(err, AuthError::InvalidClient)); + } + + #[tokio::test(start_paused = true)] + async fn test_poll_for_token_unknown_error() { + let mut mocks = MockSet::new(); + mock_code_endpoint(&mut mocks); + mocks.mock(|when, then| { + when.post().path("/oauth/device/token"); + then.bad_request().json(error_json("something_unexpected")); + }); + let server = start_server(mocks).await; + + let err = begin_pending(&server) + .await + .poll_for_token() + .await + .unwrap_err(); + + assert!(matches!(&err, AuthError::Server(desc) if desc == "something_unexpected occurred")); + } + + #[tokio::test(start_paused = true)] + async fn test_poll_for_token_authorization_pending_then_success() { + let mut mocks = MockSet::new(); + mock_code_endpoint(&mut mocks); + mocks.mock(|when, then| { + when.post().path("/oauth/device/token"); + then.bad_request().json(error_json("authorization_pending")); + }); + let server = start_server(mocks).await; + let pending = begin_pending(&server).await; + + // Use tokio::join! so the swap future can borrow server.mocks() directly + // (the shared RwLock) rather than cloning the MockSet. + // First poll at T=5s returns "authorization_pending". + // At T=6s the mock is swapped. Second poll at T=10s returns success. + let (result, _) = tokio::join!(pending.poll_for_token(), async { + tokio::time::sleep(tokio::time::Duration::from_secs(6)).await; + server.mocks().clear(); + server.mocks().mock(|when, then| { + when.post().path("/oauth/device/token"); + then.json(token_json()); + }); + }); + + let token = result.unwrap(); + assert_eq!(token.access_token(), "test_access_token_value"); + } + + #[tokio::test(start_paused = true)] + async fn test_poll_for_token_slow_down_then_success() { + let mut mocks = MockSet::new(); + mock_code_endpoint(&mut mocks); + mocks.mock(|when, then| { + when.post().path("/oauth/device/token"); + then.bad_request().json(error_json("slow_down")); + }); + let server = start_server(mocks).await; + let pending = begin_pending(&server).await; + + // First poll returns "slow_down", interval increases to 10s. + // Swap the mock to return success before the second poll. + let (result, _) = tokio::join!(pending.poll_for_token(), async { + tokio::time::sleep(tokio::time::Duration::from_secs(6)).await; + server.mocks().clear(); + server.mocks().mock(|when, then| { + when.post().path("/oauth/device/token"); + then.json(token_json()); + }); + }); + + let token = result.unwrap(); + assert_eq!(token.access_token(), "test_access_token_value"); + } + + /// Proves that `slow_down` increases the poll interval: with a short + /// `expires_in`, the increased interval pushes the next poll past the + /// deadline, causing an `ExpiredToken` error. + #[tokio::test(start_paused = true)] + async fn test_poll_for_token_slow_down_increases_interval() { + let mut mocks = MockSet::new(); + // expires_in = 12: without slow_down, second poll at T=10 is within + // the deadline. With slow_down, interval becomes 10s, so second poll + // at T=15 exceeds the 12s deadline. + mocks.mock(|when, then| { + when.post().path("/oauth/device/code"); + then.json(serde_json::json!({ + "device_code": "test_device_code", + "user_code": "ABCD-EFGH", + "verification_uri": "http://example.com/activate", + "verification_uri_complete": "http://example.com/activate?user_code=ABCD-EFGH", + "expires_in": 12 + })); + }); + mocks.mock(|when, then| { + when.post().path("/oauth/device/token"); + then.bad_request().json(error_json("slow_down")); + }); + let server = start_server(mocks).await; + let pending = begin_pending(&server).await; + + let err = pending.poll_for_token().await.unwrap_err(); + + assert!(matches!(err, AuthError::ExpiredToken)); + } +} diff --git a/packages/stack-auth/src/lib.rs b/packages/stack-auth/src/lib.rs new file mode 100644 index 000000000..22db0e57b --- /dev/null +++ b/packages/stack-auth/src/lib.rs @@ -0,0 +1,66 @@ +#![deny(clippy::unwrap_used, clippy::expect_used)] + +use std::convert::Infallible; +use std::future::Future; + +use vitaminc::protected::OpaqueDebug; +use zeroize::ZeroizeOnDrop; + +mod device_code; + +pub use device_code::{DeviceCodeStrategy, PendingDeviceCode}; + +/// Strategy for authenticating with CTS. +pub trait AuthStrategy { + fn authenticate(self) -> impl Future> + Send; +} + +/// An access token returned by an authentication flow. +#[derive(OpaqueDebug, ZeroizeOnDrop)] +pub struct Token { + access_token: String, + #[zeroize(skip)] + token_type: String, + #[zeroize(skip)] + expires_in: u64, +} + +impl Token { + pub fn access_token(&self) -> &str { + &self.access_token + } + + pub fn token_type(&self) -> &str { + &self.token_type + } + + pub fn expires_in(&self) -> u64 { + self.expires_in + } +} + +#[derive(Debug, thiserror::Error)] +pub enum AuthError { + #[error("HTTP request failed: {0}")] + Request(#[from] reqwest::Error), + #[error("Authorization was denied")] + AccessDenied, + #[error("Device code expired")] + ExpiredToken, + #[error("Invalid grant type")] + InvalidGrant, + #[error("Invalid client")] + InvalidClient, + #[error("Invalid URL: {0}")] + InvalidUrl(#[from] url::ParseError), + #[error("Unsupported region: {0}")] + Region(#[from] cts_common::RegionError), + #[error("Server error: {0}")] + Server(String), +} + +impl From for AuthError { + fn from(never: Infallible) -> Self { + match never {} + } +} From e0aa6f44206840708fae5d2177f6e40c5ec5d34a Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Sun, 15 Feb 2026 15:06:14 +1100 Subject: [PATCH 002/686] =?UTF-8?q?refactor:=20=F0=9F=94=92=20harden=20sec?= =?UTF-8?q?ret=20handling=20and=20add=20security=20lints?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace AuthStrategy trait with SecretToken newtype, add security-focused clippy lints, fix URL path handling, correct error messages, and add unused_results lint. --- packages/stack-auth/examples/device_code.rs | 4 +- packages/stack-auth/src/device_code.rs | 106 ++++++++++++++------ packages/stack-auth/src/lib.rs | 57 ++++++++--- 3 files changed, 123 insertions(+), 44 deletions(-) diff --git a/packages/stack-auth/examples/device_code.rs b/packages/stack-auth/examples/device_code.rs index f4c403a20..0469fa292 100644 --- a/packages/stack-auth/examples/device_code.rs +++ b/packages/stack-auth/examples/device_code.rs @@ -1,5 +1,5 @@ -use stack_auth::DeviceCodeStrategy; use cts_common::Region; +use stack_auth::DeviceCodeStrategy; #[tokio::main] async fn main() -> Result<(), Box> { @@ -25,7 +25,7 @@ async fn main() -> Result<(), Box> { println!("Token type: {}", token.token_type()); println!("Expires in: {}s", token.expires_in()); - println!("Access token: {}...", &token.access_token()[..20]); + println!("Access token: {:?}", token.access_token()); Ok(()) } diff --git a/packages/stack-auth/src/device_code.rs b/packages/stack-auth/src/device_code.rs index 7235deb01..dea7f09bd 100644 --- a/packages/stack-auth/src/device_code.rs +++ b/packages/stack-auth/src/device_code.rs @@ -1,10 +1,8 @@ use cts_common::{CtsServiceDiscovery, Region, ServiceDiscovery}; use serde::{Deserialize, Serialize}; use url::Url; -use vitaminc::protected::OpaqueDebug; -use zeroize::ZeroizeOnDrop; -use crate::{AuthError, AuthStrategy, Token}; +use crate::{AuthError, SecretToken, Token}; /// Drives the RFC 8628 device authorization flow against CTS-hosted endpoints. pub struct DeviceCodeStrategy { @@ -12,13 +10,6 @@ pub struct DeviceCodeStrategy { client_id: String, } -/// A device code secret received from the authorization server. -/// -/// This is a bearer secret until exchanged for a token, so it is zeroized on -/// drop and its `Debug` output is redacted. -#[derive(OpaqueDebug, ZeroizeOnDrop)] -struct DeviceCode(String); - /// The result of initiating a device code flow. /// /// Contains the user-facing codes and URIs needed to complete authorization, @@ -28,7 +19,7 @@ struct DeviceCode(String); pub struct PendingDeviceCode { token_url: Url, client_id: String, - device_code: DeviceCode, + device_code: SecretToken, user_code: String, verification_uri: String, verification_uri_complete: String, @@ -125,11 +116,20 @@ impl PendingDeviceCode { } } +/// Ensure a URL has a trailing slash so that `Url::join` with relative paths +/// appends to the path rather than replacing the last segment. +fn ensure_trailing_slash(mut url: Url) -> Url { + if !url.path().ends_with('/') { + url.set_path(&format!("{}/", url.path())); + } + url +} + impl DeviceCodeStrategy { pub fn new(region: Region, client_id: impl Into) -> Result { let base_url = CtsServiceDiscovery::endpoint(region)?; Ok(Self { - base_url, + base_url: ensure_trailing_slash(base_url), client_id: client_id.into(), }) } @@ -143,7 +143,7 @@ impl DeviceCodeStrategy { U: TryInto, U::Error: Into, { - self.base_url = base_url.try_into().map_err(Into::into)?; + self.base_url = ensure_trailing_slash(base_url.try_into().map_err(Into::into)?); Ok(self) } @@ -157,7 +157,7 @@ impl DeviceCodeStrategy { pub async fn begin(&self) -> Result { let client = reqwest::Client::new(); - let code_url = self.base_url.join("/oauth/device/code")?; + let code_url = self.base_url.join("oauth/device/code")?; tracing::debug!(url = %code_url, client_id = %self.client_id, "requesting device code"); @@ -180,7 +180,7 @@ impl DeviceCodeStrategy { let code: DeviceCodeResponse = code_resp.json().await?; - let token_url = self.base_url.join("/oauth/device/token")?; + let token_url = self.base_url.join("oauth/device/token")?; tracing::debug!( user_code = %code.user_code, @@ -191,7 +191,7 @@ impl DeviceCodeStrategy { Ok(PendingDeviceCode { token_url, client_id: self.client_id.clone(), - device_code: DeviceCode(code.device_code), + device_code: code.device_code, user_code: code.user_code, verification_uri: code.verification_uri, verification_uri_complete: code.verification_uri_complete, @@ -202,7 +202,7 @@ impl DeviceCodeStrategy { #[derive(Deserialize)] struct DeviceCodeResponse { - device_code: String, + device_code: SecretToken, user_code: String, verification_uri: String, verification_uri_complete: String, @@ -211,7 +211,7 @@ struct DeviceCodeResponse { #[derive(Deserialize)] struct TokenResponse { - access_token: String, + access_token: SecretToken, token_type: String, expires_in: u64, } @@ -235,16 +235,7 @@ struct TokenRequest<'a> { grant_type: &'a str, } -impl AuthStrategy for DeviceCodeStrategy { - async fn authenticate(self) -> Result { - let pending = self.begin().await?; - pending.open_in_browser(); - pending.poll_for_token().await - } -} - #[cfg(test)] -#[allow(clippy::unwrap_used)] mod tests { use super::*; use cts_common::Region; @@ -362,7 +353,7 @@ mod tests { let token = begin_pending(&server).await.poll_for_token().await.unwrap(); - assert_eq!(token.access_token(), "test_access_token_value"); + assert_eq!(token.access_token().0, "test_access_token_value"); assert_eq!(token.token_type(), "Bearer"); assert_eq!(token.expires_in(), 3600); } @@ -487,7 +478,7 @@ mod tests { }); let token = result.unwrap(); - assert_eq!(token.access_token(), "test_access_token_value"); + assert_eq!(token.access_token().0, "test_access_token_value"); } #[tokio::test(start_paused = true)] @@ -513,7 +504,7 @@ mod tests { }); let token = result.unwrap(); - assert_eq!(token.access_token(), "test_access_token_value"); + assert_eq!(token.access_token().0, "test_access_token_value"); } /// Proves that `slow_down` increases the poll interval: with a short @@ -546,4 +537,59 @@ mod tests { assert!(matches!(err, AuthError::ExpiredToken)); } + + // ---- ensure_trailing_slash / URL join tests ---- + + #[test] + fn test_ensure_trailing_slash_adds_slash() { + let url = Url::parse("http://localhost:3001").unwrap(); + let result = ensure_trailing_slash(url); + assert_eq!(result.as_str(), "http://localhost:3001/"); + } + + #[test] + fn test_ensure_trailing_slash_preserves_existing() { + let url = Url::parse("http://localhost:3001/").unwrap(); + let result = ensure_trailing_slash(url); + assert_eq!(result.as_str(), "http://localhost:3001/"); + } + + #[test] + fn test_ensure_trailing_slash_with_path() { + let url = Url::parse("http://localhost:3001/api/v1").unwrap(); + let result = ensure_trailing_slash(url); + assert_eq!(result.as_str(), "http://localhost:3001/api/v1/"); + } + + #[test] + fn test_relative_join_preserves_base_path() { + let base = ensure_trailing_slash(Url::parse("http://localhost:3001/api/v1").unwrap()); + let joined = base.join("oauth/device/code").unwrap(); + assert_eq!( + joined.as_str(), + "http://localhost:3001/api/v1/oauth/device/code" + ); + } + + #[test] + fn test_relative_join_on_root_url() { + let base = ensure_trailing_slash(Url::parse("http://localhost:3001").unwrap()); + let joined = base.join("oauth/device/code").unwrap(); + assert_eq!(joined.as_str(), "http://localhost:3001/oauth/device/code"); + } + + #[tokio::test] + async fn test_pending_device_code_debug_does_not_leak() { + let mut mocks = MockSet::new(); + mock_code_endpoint(&mut mocks); + let server = start_server(mocks).await; + + let pending = begin_pending(&server).await; + let debug = format!("{:?}", pending); + + assert!( + !debug.contains("test_device_code"), + "PendingDeviceCode Debug should not contain the device code, got: {debug}" + ); + } } diff --git a/packages/stack-auth/src/lib.rs b/packages/stack-auth/src/lib.rs index 22db0e57b..416a3a71f 100644 --- a/packages/stack-auth/src/lib.rs +++ b/packages/stack-auth/src/lib.rs @@ -1,7 +1,26 @@ -#![deny(clippy::unwrap_used, clippy::expect_used)] +// Security lints +#![deny(unsafe_code)] +#![warn(clippy::unwrap_used)] +#![warn(clippy::expect_used)] +#![warn(clippy::panic)] +// Prevent mem::forget from bypassing ZeroizeOnDrop +#![warn(clippy::mem_forget)] +// Prevent accidental data leaks via output +#![warn(clippy::print_stdout)] +#![warn(clippy::print_stderr)] +#![warn(clippy::dbg_macro)] +// Code quality +#![warn(unreachable_pub)] +#![warn(unused_results)] +#![warn(clippy::todo)] +#![warn(clippy::unimplemented)] +// Relax in tests +#![cfg_attr(test, allow(clippy::unwrap_used))] +#![cfg_attr(test, allow(clippy::expect_used))] +#![cfg_attr(test, allow(clippy::panic))] +#![cfg_attr(test, allow(unused_results))] use std::convert::Infallible; -use std::future::Future; use vitaminc::protected::OpaqueDebug; use zeroize::ZeroizeOnDrop; @@ -10,23 +29,21 @@ mod device_code; pub use device_code::{DeviceCodeStrategy, PendingDeviceCode}; -/// Strategy for authenticating with CTS. -pub trait AuthStrategy { - fn authenticate(self) -> impl Future> + Send; -} +/// A sensitive token string that is zeroized on drop and hidden from debug output. +#[derive(OpaqueDebug, ZeroizeOnDrop, serde::Deserialize)] +#[serde(transparent)] +pub struct SecretToken(String); /// An access token returned by an authentication flow. -#[derive(OpaqueDebug, ZeroizeOnDrop)] +#[derive(Debug)] pub struct Token { - access_token: String, - #[zeroize(skip)] + access_token: SecretToken, token_type: String, - #[zeroize(skip)] expires_in: u64, } impl Token { - pub fn access_token(&self) -> &str { + pub fn access_token(&self) -> &SecretToken { &self.access_token } @@ -40,6 +57,7 @@ impl Token { } #[derive(Debug, thiserror::Error)] +#[non_exhaustive] pub enum AuthError { #[error("HTTP request failed: {0}")] Request(#[from] reqwest::Error), @@ -47,7 +65,7 @@ pub enum AuthError { AccessDenied, #[error("Device code expired")] ExpiredToken, - #[error("Invalid grant type")] + #[error("Invalid grant")] InvalidGrant, #[error("Invalid client")] InvalidClient, @@ -64,3 +82,18 @@ impl From for AuthError { match never {} } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_secret_token_debug_does_not_leak() { + let token = SecretToken("super_secret_value".to_string()); + let debug = format!("{:?}", token); + assert!( + !debug.contains("super_secret_value"), + "SecretToken Debug should not contain the secret, got: {debug}" + ); + } +} From 22325d53ddd36cd72b839e74f1964428e4f01d4b Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Sun, 15 Feb 2026 16:49:01 +1100 Subject: [PATCH 003/686] =?UTF-8?q?docs:=20=F0=9F=93=9D=20add=20crate-leve?= =?UTF-8?q?l=20docs,=20doctests,=20and=20per-item=20documentation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/stack-auth/src/device_code.rs | 88 ++++++++++++++++++++++---- packages/stack-auth/src/lib.rs | 73 ++++++++++++++++++++- 2 files changed, 148 insertions(+), 13 deletions(-) diff --git a/packages/stack-auth/src/device_code.rs b/packages/stack-auth/src/device_code.rs index dea7f09bd..788e41d91 100644 --- a/packages/stack-auth/src/device_code.rs +++ b/packages/stack-auth/src/device_code.rs @@ -4,17 +4,51 @@ use url::Url; use crate::{AuthError, SecretToken, Token}; -/// Drives the RFC 8628 device authorization flow against CTS-hosted endpoints. +/// Authenticates with CipherStash using the +/// [device code flow (RFC 8628)](https://datatracker.ietf.org/doc/html/rfc8628). +/// +/// This is the primary entry point for CLI and browserless authentication. +/// Create a strategy with [`DeviceCodeStrategy::new`], then call +/// [`begin`](DeviceCodeStrategy::begin) to start the flow. +/// +/// # Example +/// +/// ``` +/// use stack_auth::DeviceCodeStrategy; +/// use cts_common::Region; +/// +/// let region = Region::aws("ap-southeast-2").unwrap(); +/// let strategy = DeviceCodeStrategy::new(region, "my-client-id").unwrap(); +/// ``` pub struct DeviceCodeStrategy { base_url: Url, client_id: String, } -/// The result of initiating a device code flow. +/// A device code flow that is waiting for the user to authorize. +/// +/// Returned by [`DeviceCodeStrategy::begin`]. Display the +/// [`user_code`](Self::user_code) and +/// [`verification_uri_complete`](Self::verification_uri_complete) to the user +/// (or call [`open_in_browser`](Self::open_in_browser)), then call +/// [`poll_for_token`](Self::poll_for_token) to wait for authorization. /// -/// Contains the user-facing codes and URIs needed to complete authorization, -/// and provides [`poll_for_token`](PendingDeviceCode::poll_for_token) to -/// exchange the device code for an access token once the user has authorized. +/// # Example +/// +/// ```no_run +/// # use stack_auth::DeviceCodeStrategy; +/// # use cts_common::Region; +/// # async fn run() -> Result<(), Box> { +/// # let strategy = DeviceCodeStrategy::new(Region::aws("ap-southeast-2")?, "cli")?; +/// let pending = strategy.begin().await?; +/// +/// println!("Go to: {}", pending.verification_uri_complete()); +/// println!("Enter code: {}", pending.user_code()); +/// +/// let token = pending.poll_for_token().await?; +/// # Ok(()) +/// # } +/// ``` #[derive(Debug)] pub struct PendingDeviceCode { token_url: Url, @@ -54,7 +88,18 @@ impl PendingDeviceCode { open::that(&self.verification_uri_complete).is_ok() } - /// Poll the token endpoint until the user authorizes (or the code expires). + /// Poll the auth server until the user authorizes (or the code expires). + /// + /// This method consumes `self` and blocks asynchronously, polling at a + /// server-controlled interval (starting at 5 seconds). It returns a + /// [`Token`] on success. + /// + /// # Errors + /// + /// - [`AuthError::AccessDenied`] — the user rejected the request. + /// - [`AuthError::ExpiredToken`] — the device code expired before the user + /// authorized. + /// - [`AuthError::Request`] — a network error occurred while polling. pub async fn poll_for_token(self) -> Result { let client = reqwest::Client::new(); let mut interval = tokio::time::Duration::from_secs(5); @@ -126,6 +171,21 @@ fn ensure_trailing_slash(mut url: Url) -> Url { } impl DeviceCodeStrategy { + /// Create a new strategy for the given CipherStash region and OAuth client ID. + /// + /// The auth endpoint is resolved automatically via service discovery. + /// + /// # Example + /// + /// ``` + /// use stack_auth::DeviceCodeStrategy; + /// use cts_common::Region; + /// + /// let strategy = DeviceCodeStrategy::new( + /// Region::aws("ap-southeast-2").unwrap(), + /// "my-client-id", + /// ).unwrap(); + /// ``` pub fn new(region: Region, client_id: impl Into) -> Result { let base_url = CtsServiceDiscovery::endpoint(region)?; Ok(Self { @@ -147,13 +207,17 @@ impl DeviceCodeStrategy { Ok(self) } - /// Initiate the device code flow. + /// Start the device code flow. + /// + /// Requests a device code from the CipherStash auth server and returns a + /// [`PendingDeviceCode`] with the user-facing codes and URIs. Show these + /// to the user, then call [`PendingDeviceCode::poll_for_token`] to wait + /// for authorization. + /// + /// # Errors /// - /// Posts to the device code endpoint and returns a [`PendingDeviceCode`] - /// containing the user code and verification URIs. The caller can then - /// display these to the user and call - /// [`poll_for_token`](PendingDeviceCode::poll_for_token) to complete the - /// flow. + /// Returns [`AuthError::InvalidClient`] if the client ID is not recognized, + /// or [`AuthError::Request`] if the server is unreachable. pub async fn begin(&self) -> Result { let client = reqwest::Client::new(); diff --git a/packages/stack-auth/src/lib.rs b/packages/stack-auth/src/lib.rs index 416a3a71f..8f27d208b 100644 --- a/packages/stack-auth/src/lib.rs +++ b/packages/stack-auth/src/lib.rs @@ -1,3 +1,46 @@ +//! Authenticate with [CipherStash](https://cipherstash.com) services using the +//! [OAuth 2.0 Device Authorization Grant](https://datatracker.ietf.org/doc/html/rfc8628). +//! +//! This crate implements the device code flow, which lets CLI tools and other +//! browserless applications obtain an access token by having the user authorize +//! in a browser on another device. +//! +//! # Usage +//! +//! ```no_run +//! use stack_auth::DeviceCodeStrategy; +//! use cts_common::Region; +//! +//! # async fn run() -> Result<(), Box> { +//! // 1. Create a strategy for your region and client ID +//! let region = Region::aws("ap-southeast-2")?; +//! let strategy = DeviceCodeStrategy::new(region, "my-client-id")?; +//! +//! // 2. Begin the device code flow +//! let pending = strategy.begin().await?; +//! +//! // 3. Show the user their code and where to enter it +//! println!("Go to: {}", pending.verification_uri_complete()); +//! println!("Code: {}", pending.user_code()); +//! +//! // Or open the browser directly: +//! pending.open_in_browser(); +//! +//! // 4. Poll until the user authorizes (or the code expires) +//! let token = pending.poll_for_token().await?; +//! +//! // 5. Use the access token to call CipherStash APIs +//! println!("Authenticated! Token expires in {}s", token.expires_in()); +//! # Ok(()) +//! # } +//! ``` +//! +//! # Security +//! +//! Sensitive values ([`SecretToken`]) are automatically zeroized when dropped +//! and are masked in [`Debug`](std::fmt::Debug) output to prevent accidental +//! leaks in logs. + // Security lints #![deny(unsafe_code)] #![warn(clippy::unwrap_used)] @@ -30,11 +73,24 @@ mod device_code; pub use device_code::{DeviceCodeStrategy, PendingDeviceCode}; /// A sensitive token string that is zeroized on drop and hidden from debug output. +/// +/// `SecretToken` wraps a `String` and enforces two invariants: +/// +/// - **Zeroized on drop**: the backing memory is overwritten with zeros when +/// the token goes out of scope, preventing it from lingering in memory. +/// - **Opaque debug**: the [`Debug`] implementation prints `"***"` instead of +/// the actual value, so tokens won't leak into logs or error messages. +/// +/// You cannot construct a `SecretToken` directly — it is returned by the +/// authentication flow via [`Token::access_token`]. #[derive(OpaqueDebug, ZeroizeOnDrop, serde::Deserialize)] #[serde(transparent)] pub struct SecretToken(String); -/// An access token returned by an authentication flow. +/// An access token returned by a successful authentication flow. +/// +/// The token contains a [`SecretToken`] (the bearer credential), a token type +/// (typically `"Bearer"`), and an expiry time in seconds. #[derive(Debug)] pub struct Token { access_token: SecretToken, @@ -43,36 +99,51 @@ pub struct Token { } impl Token { + /// Returns a reference to the access token credential. + /// + /// The returned [`SecretToken`] is opaque — its [`Debug`] output is masked. + /// Pass it to API clients that need the raw bearer token. pub fn access_token(&self) -> &SecretToken { &self.access_token } + /// The token type (e.g. `"Bearer"`). pub fn token_type(&self) -> &str { &self.token_type } + /// How many seconds until the token expires. pub fn expires_in(&self) -> u64 { self.expires_in } } +/// Errors that can occur during an authentication flow. #[derive(Debug, thiserror::Error)] #[non_exhaustive] pub enum AuthError { + /// The HTTP request to the auth server failed (network error, timeout, etc.). #[error("HTTP request failed: {0}")] Request(#[from] reqwest::Error), + /// The user denied the authorization request. #[error("Authorization was denied")] AccessDenied, + /// The device code expired before the user authorized. #[error("Device code expired")] ExpiredToken, + /// The grant type was rejected by the server. #[error("Invalid grant")] InvalidGrant, + /// The client ID is not recognized. #[error("Invalid client")] InvalidClient, + /// A URL could not be parsed. #[error("Invalid URL: {0}")] InvalidUrl(#[from] url::ParseError), + /// The requested region is not supported. #[error("Unsupported region: {0}")] Region(#[from] cts_common::RegionError), + /// An unexpected error was returned by the auth server. #[error("Server error: {0}")] Server(String), } From b54ae374a814303eba78e12e58c26e7c4d0ddca2 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Sun, 15 Feb 2026 16:58:53 +1100 Subject: [PATCH 004/686] =?UTF-8?q?refactor:=20=E2=99=BB=EF=B8=8F=20split?= =?UTF-8?q?=20device=5Fcode=20into=20module=20directory?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move device_code.rs to device_code/mod.rs, extract serde types into protocol.rs and tests into tests.rs. Introduce DeviceCode newtype to replace raw str references in TokenRequest. --- packages/stack-auth/src/device_code.rs | 659 ------------------ packages/stack-auth/src/device_code/mod.rs | 273 ++++++++ .../stack-auth/src/device_code/protocol.rs | 46 ++ packages/stack-auth/src/device_code/tests.rs | 355 ++++++++++ 4 files changed, 674 insertions(+), 659 deletions(-) delete mode 100644 packages/stack-auth/src/device_code.rs create mode 100644 packages/stack-auth/src/device_code/mod.rs create mode 100644 packages/stack-auth/src/device_code/protocol.rs create mode 100644 packages/stack-auth/src/device_code/tests.rs diff --git a/packages/stack-auth/src/device_code.rs b/packages/stack-auth/src/device_code.rs deleted file mode 100644 index 788e41d91..000000000 --- a/packages/stack-auth/src/device_code.rs +++ /dev/null @@ -1,659 +0,0 @@ -use cts_common::{CtsServiceDiscovery, Region, ServiceDiscovery}; -use serde::{Deserialize, Serialize}; -use url::Url; - -use crate::{AuthError, SecretToken, Token}; - -/// Authenticates with CipherStash using the -/// [device code flow (RFC 8628)](https://datatracker.ietf.org/doc/html/rfc8628). -/// -/// This is the primary entry point for CLI and browserless authentication. -/// Create a strategy with [`DeviceCodeStrategy::new`], then call -/// [`begin`](DeviceCodeStrategy::begin) to start the flow. -/// -/// # Example -/// -/// ``` -/// use stack_auth::DeviceCodeStrategy; -/// use cts_common::Region; -/// -/// let region = Region::aws("ap-southeast-2").unwrap(); -/// let strategy = DeviceCodeStrategy::new(region, "my-client-id").unwrap(); -/// ``` -pub struct DeviceCodeStrategy { - base_url: Url, - client_id: String, -} - -/// A device code flow that is waiting for the user to authorize. -/// -/// Returned by [`DeviceCodeStrategy::begin`]. Display the -/// [`user_code`](Self::user_code) and -/// [`verification_uri_complete`](Self::verification_uri_complete) to the user -/// (or call [`open_in_browser`](Self::open_in_browser)), then call -/// [`poll_for_token`](Self::poll_for_token) to wait for authorization. -/// -/// # Example -/// -/// ```no_run -/// # use stack_auth::DeviceCodeStrategy; -/// # use cts_common::Region; -/// # async fn run() -> Result<(), Box> { -/// # let strategy = DeviceCodeStrategy::new(Region::aws("ap-southeast-2")?, "cli")?; -/// let pending = strategy.begin().await?; -/// -/// println!("Go to: {}", pending.verification_uri_complete()); -/// println!("Enter code: {}", pending.user_code()); -/// -/// let token = pending.poll_for_token().await?; -/// # Ok(()) -/// # } -/// ``` -#[derive(Debug)] -pub struct PendingDeviceCode { - token_url: Url, - client_id: String, - device_code: SecretToken, - user_code: String, - verification_uri: String, - verification_uri_complete: String, - expires_in: u64, -} - -impl PendingDeviceCode { - /// The short code the user must enter to authorize this device. - pub fn user_code(&self) -> &str { - &self.user_code - } - - /// The base verification URI (without the user code embedded). - pub fn verification_uri(&self) -> &str { - &self.verification_uri - } - - /// The full verification URI with the user code pre-filled. - pub fn verification_uri_complete(&self) -> &str { - &self.verification_uri_complete - } - - /// How many seconds the device code remains valid. - pub fn expires_in(&self) -> u64 { - self.expires_in - } - - /// Open the verification URI in the user's default browser. - /// - /// Returns `true` if the browser was opened successfully. - pub fn open_in_browser(&self) -> bool { - open::that(&self.verification_uri_complete).is_ok() - } - - /// Poll the auth server until the user authorizes (or the code expires). - /// - /// This method consumes `self` and blocks asynchronously, polling at a - /// server-controlled interval (starting at 5 seconds). It returns a - /// [`Token`] on success. - /// - /// # Errors - /// - /// - [`AuthError::AccessDenied`] — the user rejected the request. - /// - [`AuthError::ExpiredToken`] — the device code expired before the user - /// authorized. - /// - [`AuthError::Request`] — a network error occurred while polling. - pub async fn poll_for_token(self) -> Result { - let client = reqwest::Client::new(); - let mut interval = tokio::time::Duration::from_secs(5); - let deadline = - tokio::time::Instant::now() + tokio::time::Duration::from_secs(self.expires_in); - - tracing::debug!( - url = %self.token_url, - expires_in = self.expires_in, - "polling for token" - ); - - loop { - tokio::time::sleep(interval).await; - - if tokio::time::Instant::now() >= deadline { - tracing::debug!("device code expired while polling"); - return Err(AuthError::ExpiredToken); - } - - let resp = client - .post(self.token_url.clone()) - .form(&TokenRequest { - client_id: &self.client_id, - device_code: &self.device_code.0, - grant_type: "urn:ietf:params:oauth:grant-type:device_code", - }) - .send() - .await?; - - if resp.status().is_success() { - tracing::debug!("token received"); - let token_resp: TokenResponse = resp.json().await?; - return Ok(Token { - access_token: token_resp.access_token, - token_type: token_resp.token_type, - expires_in: token_resp.expires_in, - }); - } - - let err: ErrorResponse = resp.json().await?; - match err.error.as_str() { - "authorization_pending" => { - tracing::debug!("authorization pending, retrying"); - continue; - } - "slow_down" => { - interval += tokio::time::Duration::from_secs(5); - tracing::debug!(interval_secs = interval.as_secs(), "slowing down"); - continue; - } - "expired_token" => return Err(AuthError::ExpiredToken), - "access_denied" => return Err(AuthError::AccessDenied), - "invalid_grant" => return Err(AuthError::InvalidGrant), - "invalid_client" => return Err(AuthError::InvalidClient), - _ => return Err(AuthError::Server(err.error_description)), - } - } - } -} - -/// Ensure a URL has a trailing slash so that `Url::join` with relative paths -/// appends to the path rather than replacing the last segment. -fn ensure_trailing_slash(mut url: Url) -> Url { - if !url.path().ends_with('/') { - url.set_path(&format!("{}/", url.path())); - } - url -} - -impl DeviceCodeStrategy { - /// Create a new strategy for the given CipherStash region and OAuth client ID. - /// - /// The auth endpoint is resolved automatically via service discovery. - /// - /// # Example - /// - /// ``` - /// use stack_auth::DeviceCodeStrategy; - /// use cts_common::Region; - /// - /// let strategy = DeviceCodeStrategy::new( - /// Region::aws("ap-southeast-2").unwrap(), - /// "my-client-id", - /// ).unwrap(); - /// ``` - pub fn new(region: Region, client_id: impl Into) -> Result { - let base_url = CtsServiceDiscovery::endpoint(region)?; - Ok(Self { - base_url: ensure_trailing_slash(base_url), - client_id: client_id.into(), - }) - } - - /// Override the base URL resolved by service discovery. - /// - /// Useful for pointing at a local or mock CTS instance during testing. - #[cfg(any(test, feature = "test-utils"))] - pub fn with_base_url(mut self, base_url: U) -> Result - where - U: TryInto, - U::Error: Into, - { - self.base_url = ensure_trailing_slash(base_url.try_into().map_err(Into::into)?); - Ok(self) - } - - /// Start the device code flow. - /// - /// Requests a device code from the CipherStash auth server and returns a - /// [`PendingDeviceCode`] with the user-facing codes and URIs. Show these - /// to the user, then call [`PendingDeviceCode::poll_for_token`] to wait - /// for authorization. - /// - /// # Errors - /// - /// Returns [`AuthError::InvalidClient`] if the client ID is not recognized, - /// or [`AuthError::Request`] if the server is unreachable. - pub async fn begin(&self) -> Result { - let client = reqwest::Client::new(); - - let code_url = self.base_url.join("oauth/device/code")?; - - tracing::debug!(url = %code_url, client_id = %self.client_id, "requesting device code"); - - let code_resp = client - .post(code_url) - .form(&DeviceCodeRequest { - client_id: &self.client_id, - }) - .send() - .await?; - - if !code_resp.status().is_success() { - let err: ErrorResponse = code_resp.json().await?; - tracing::debug!(error = %err.error, "device code request failed"); - return Err(match err.error.as_str() { - "invalid_client" => AuthError::InvalidClient, - _ => AuthError::Server(err.error_description), - }); - } - - let code: DeviceCodeResponse = code_resp.json().await?; - - let token_url = self.base_url.join("oauth/device/token")?; - - tracing::debug!( - user_code = %code.user_code, - expires_in = code.expires_in, - "device code received" - ); - - Ok(PendingDeviceCode { - token_url, - client_id: self.client_id.clone(), - device_code: code.device_code, - user_code: code.user_code, - verification_uri: code.verification_uri, - verification_uri_complete: code.verification_uri_complete, - expires_in: code.expires_in, - }) - } -} - -#[derive(Deserialize)] -struct DeviceCodeResponse { - device_code: SecretToken, - user_code: String, - verification_uri: String, - verification_uri_complete: String, - expires_in: u64, -} - -#[derive(Deserialize)] -struct TokenResponse { - access_token: SecretToken, - token_type: String, - expires_in: u64, -} - -#[derive(Deserialize)] -struct ErrorResponse { - error: String, - #[serde(default)] - error_description: String, -} - -#[derive(Serialize)] -struct DeviceCodeRequest<'a> { - client_id: &'a str, -} - -#[derive(Serialize)] -struct TokenRequest<'a> { - client_id: &'a str, - device_code: &'a str, - grant_type: &'a str, -} - -#[cfg(test)] -mod tests { - use super::*; - use cts_common::Region; - use mocktail::prelude::*; - - fn device_code_json() -> serde_json::Value { - serde_json::json!({ - "device_code": "test_device_code", - "user_code": "ABCD-EFGH", - "verification_uri": "http://example.com/activate", - "verification_uri_complete": "http://example.com/activate?user_code=ABCD-EFGH", - "expires_in": 900 - }) - } - - fn token_json() -> serde_json::Value { - serde_json::json!({ - "access_token": "test_access_token_value", - "token_type": "Bearer", - "expires_in": 3600 - }) - } - - fn error_json(error: &str) -> serde_json::Value { - serde_json::json!({ - "error": error, - "error_description": format!("{error} occurred") - }) - } - - fn mock_code_endpoint(mocks: &mut MockSet) { - mocks.mock(|when, then| { - when.post().path("/oauth/device/code"); - then.json(device_code_json()); - }); - } - - async fn start_server(mocks: MockSet) -> MockServer { - let server = MockServer::new_http("stack-auth-test").with_mocks(mocks); - server.start().await.unwrap(); - server - } - - fn strategy_for(server: &MockServer) -> DeviceCodeStrategy { - DeviceCodeStrategy::new(Region::aws("ap-southeast-2").unwrap(), "cli") - .unwrap() - .with_base_url(server.url("")) - .unwrap() - } - - // ---- begin() tests ---- - - #[tokio::test] - async fn test_begin_returns_pending_device_code() { - let mut mocks = MockSet::new(); - mock_code_endpoint(&mut mocks); - let server = start_server(mocks).await; - - let pending = strategy_for(&server).begin().await.unwrap(); - - assert_eq!(pending.user_code(), "ABCD-EFGH"); - assert_eq!(pending.verification_uri(), "http://example.com/activate"); - assert_eq!( - pending.verification_uri_complete(), - "http://example.com/activate?user_code=ABCD-EFGH" - ); - assert_eq!(pending.expires_in(), 900); - } - - #[tokio::test] - async fn test_begin_invalid_client() { - let mut mocks = MockSet::new(); - mocks.mock(|when, then| { - when.post().path("/oauth/device/code"); - then.bad_request().json(error_json("invalid_client")); - }); - let server = start_server(mocks).await; - - let err = strategy_for(&server).begin().await.unwrap_err(); - - assert!(matches!(err, AuthError::InvalidClient)); - } - - #[tokio::test] - async fn test_begin_server_error() { - let mut mocks = MockSet::new(); - mocks.mock(|when, then| { - when.post().path("/oauth/device/code"); - then.bad_request().json(error_json("server_error")); - }); - let server = start_server(mocks).await; - - let err = strategy_for(&server).begin().await.unwrap_err(); - - assert!(matches!(&err, AuthError::Server(desc) if desc == "server_error occurred")); - } - - // ---- poll_for_token() tests ---- - - /// Helper: calls begin() against a server that already has the code mock, - /// then returns the PendingDeviceCode ready for polling. - async fn begin_pending(server: &MockServer) -> PendingDeviceCode { - strategy_for(server).begin().await.unwrap() - } - - #[tokio::test(start_paused = true)] - async fn test_poll_for_token_success() { - let mut mocks = MockSet::new(); - mock_code_endpoint(&mut mocks); - mocks.mock(|when, then| { - when.post().path("/oauth/device/token"); - then.json(token_json()); - }); - let server = start_server(mocks).await; - - let token = begin_pending(&server).await.poll_for_token().await.unwrap(); - - assert_eq!(token.access_token().0, "test_access_token_value"); - assert_eq!(token.token_type(), "Bearer"); - assert_eq!(token.expires_in(), 3600); - } - - #[tokio::test(start_paused = true)] - async fn test_poll_for_token_access_denied() { - let mut mocks = MockSet::new(); - mock_code_endpoint(&mut mocks); - mocks.mock(|when, then| { - when.post().path("/oauth/device/token"); - then.bad_request().json(error_json("access_denied")); - }); - let server = start_server(mocks).await; - - let err = begin_pending(&server) - .await - .poll_for_token() - .await - .unwrap_err(); - - assert!(matches!(err, AuthError::AccessDenied)); - } - - #[tokio::test(start_paused = true)] - async fn test_poll_for_token_expired_token() { - let mut mocks = MockSet::new(); - mock_code_endpoint(&mut mocks); - mocks.mock(|when, then| { - when.post().path("/oauth/device/token"); - then.bad_request().json(error_json("expired_token")); - }); - let server = start_server(mocks).await; - - let err = begin_pending(&server) - .await - .poll_for_token() - .await - .unwrap_err(); - - assert!(matches!(err, AuthError::ExpiredToken)); - } - - #[tokio::test(start_paused = true)] - async fn test_poll_for_token_invalid_grant() { - let mut mocks = MockSet::new(); - mock_code_endpoint(&mut mocks); - mocks.mock(|when, then| { - when.post().path("/oauth/device/token"); - then.bad_request().json(error_json("invalid_grant")); - }); - let server = start_server(mocks).await; - - let err = begin_pending(&server) - .await - .poll_for_token() - .await - .unwrap_err(); - - assert!(matches!(err, AuthError::InvalidGrant)); - } - - #[tokio::test(start_paused = true)] - async fn test_poll_for_token_invalid_client() { - let mut mocks = MockSet::new(); - mock_code_endpoint(&mut mocks); - mocks.mock(|when, then| { - when.post().path("/oauth/device/token"); - then.bad_request().json(error_json("invalid_client")); - }); - let server = start_server(mocks).await; - - let err = begin_pending(&server) - .await - .poll_for_token() - .await - .unwrap_err(); - - assert!(matches!(err, AuthError::InvalidClient)); - } - - #[tokio::test(start_paused = true)] - async fn test_poll_for_token_unknown_error() { - let mut mocks = MockSet::new(); - mock_code_endpoint(&mut mocks); - mocks.mock(|when, then| { - when.post().path("/oauth/device/token"); - then.bad_request().json(error_json("something_unexpected")); - }); - let server = start_server(mocks).await; - - let err = begin_pending(&server) - .await - .poll_for_token() - .await - .unwrap_err(); - - assert!(matches!(&err, AuthError::Server(desc) if desc == "something_unexpected occurred")); - } - - #[tokio::test(start_paused = true)] - async fn test_poll_for_token_authorization_pending_then_success() { - let mut mocks = MockSet::new(); - mock_code_endpoint(&mut mocks); - mocks.mock(|when, then| { - when.post().path("/oauth/device/token"); - then.bad_request().json(error_json("authorization_pending")); - }); - let server = start_server(mocks).await; - let pending = begin_pending(&server).await; - - // Use tokio::join! so the swap future can borrow server.mocks() directly - // (the shared RwLock) rather than cloning the MockSet. - // First poll at T=5s returns "authorization_pending". - // At T=6s the mock is swapped. Second poll at T=10s returns success. - let (result, _) = tokio::join!(pending.poll_for_token(), async { - tokio::time::sleep(tokio::time::Duration::from_secs(6)).await; - server.mocks().clear(); - server.mocks().mock(|when, then| { - when.post().path("/oauth/device/token"); - then.json(token_json()); - }); - }); - - let token = result.unwrap(); - assert_eq!(token.access_token().0, "test_access_token_value"); - } - - #[tokio::test(start_paused = true)] - async fn test_poll_for_token_slow_down_then_success() { - let mut mocks = MockSet::new(); - mock_code_endpoint(&mut mocks); - mocks.mock(|when, then| { - when.post().path("/oauth/device/token"); - then.bad_request().json(error_json("slow_down")); - }); - let server = start_server(mocks).await; - let pending = begin_pending(&server).await; - - // First poll returns "slow_down", interval increases to 10s. - // Swap the mock to return success before the second poll. - let (result, _) = tokio::join!(pending.poll_for_token(), async { - tokio::time::sleep(tokio::time::Duration::from_secs(6)).await; - server.mocks().clear(); - server.mocks().mock(|when, then| { - when.post().path("/oauth/device/token"); - then.json(token_json()); - }); - }); - - let token = result.unwrap(); - assert_eq!(token.access_token().0, "test_access_token_value"); - } - - /// Proves that `slow_down` increases the poll interval: with a short - /// `expires_in`, the increased interval pushes the next poll past the - /// deadline, causing an `ExpiredToken` error. - #[tokio::test(start_paused = true)] - async fn test_poll_for_token_slow_down_increases_interval() { - let mut mocks = MockSet::new(); - // expires_in = 12: without slow_down, second poll at T=10 is within - // the deadline. With slow_down, interval becomes 10s, so second poll - // at T=15 exceeds the 12s deadline. - mocks.mock(|when, then| { - when.post().path("/oauth/device/code"); - then.json(serde_json::json!({ - "device_code": "test_device_code", - "user_code": "ABCD-EFGH", - "verification_uri": "http://example.com/activate", - "verification_uri_complete": "http://example.com/activate?user_code=ABCD-EFGH", - "expires_in": 12 - })); - }); - mocks.mock(|when, then| { - when.post().path("/oauth/device/token"); - then.bad_request().json(error_json("slow_down")); - }); - let server = start_server(mocks).await; - let pending = begin_pending(&server).await; - - let err = pending.poll_for_token().await.unwrap_err(); - - assert!(matches!(err, AuthError::ExpiredToken)); - } - - // ---- ensure_trailing_slash / URL join tests ---- - - #[test] - fn test_ensure_trailing_slash_adds_slash() { - let url = Url::parse("http://localhost:3001").unwrap(); - let result = ensure_trailing_slash(url); - assert_eq!(result.as_str(), "http://localhost:3001/"); - } - - #[test] - fn test_ensure_trailing_slash_preserves_existing() { - let url = Url::parse("http://localhost:3001/").unwrap(); - let result = ensure_trailing_slash(url); - assert_eq!(result.as_str(), "http://localhost:3001/"); - } - - #[test] - fn test_ensure_trailing_slash_with_path() { - let url = Url::parse("http://localhost:3001/api/v1").unwrap(); - let result = ensure_trailing_slash(url); - assert_eq!(result.as_str(), "http://localhost:3001/api/v1/"); - } - - #[test] - fn test_relative_join_preserves_base_path() { - let base = ensure_trailing_slash(Url::parse("http://localhost:3001/api/v1").unwrap()); - let joined = base.join("oauth/device/code").unwrap(); - assert_eq!( - joined.as_str(), - "http://localhost:3001/api/v1/oauth/device/code" - ); - } - - #[test] - fn test_relative_join_on_root_url() { - let base = ensure_trailing_slash(Url::parse("http://localhost:3001").unwrap()); - let joined = base.join("oauth/device/code").unwrap(); - assert_eq!(joined.as_str(), "http://localhost:3001/oauth/device/code"); - } - - #[tokio::test] - async fn test_pending_device_code_debug_does_not_leak() { - let mut mocks = MockSet::new(); - mock_code_endpoint(&mut mocks); - let server = start_server(mocks).await; - - let pending = begin_pending(&server).await; - let debug = format!("{:?}", pending); - - assert!( - !debug.contains("test_device_code"), - "PendingDeviceCode Debug should not contain the device code, got: {debug}" - ); - } -} diff --git a/packages/stack-auth/src/device_code/mod.rs b/packages/stack-auth/src/device_code/mod.rs new file mode 100644 index 000000000..b5cf8b510 --- /dev/null +++ b/packages/stack-auth/src/device_code/mod.rs @@ -0,0 +1,273 @@ +mod protocol; + +use cts_common::{CtsServiceDiscovery, Region, ServiceDiscovery}; +use url::Url; + +use crate::{AuthError, Token}; +use protocol::{ + DeviceCode, DeviceCodeRequest, DeviceCodeResponse, ErrorResponse, TokenRequest, TokenResponse, +}; + +#[cfg(test)] +mod tests; + +/// Authenticates with CipherStash using the +/// [device code flow (RFC 8628)](https://datatracker.ietf.org/doc/html/rfc8628). +/// +/// This is the primary entry point for CLI and browserless authentication. +/// Create a strategy with [`DeviceCodeStrategy::new`], then call +/// [`begin`](DeviceCodeStrategy::begin) to start the flow. +/// +/// # Example +/// +/// ``` +/// use stack_auth::DeviceCodeStrategy; +/// use cts_common::Region; +/// +/// let region = Region::aws("ap-southeast-2").unwrap(); +/// let strategy = DeviceCodeStrategy::new(region, "my-client-id").unwrap(); +/// ``` +pub struct DeviceCodeStrategy { + base_url: Url, + client_id: String, +} + + +impl DeviceCodeStrategy { + /// Create a new strategy for the given CipherStash region and OAuth client ID. + /// + /// The auth endpoint is resolved automatically via service discovery. + /// + /// # Example + /// + /// ``` + /// use stack_auth::DeviceCodeStrategy; + /// use cts_common::Region; + /// + /// let strategy = DeviceCodeStrategy::new( + /// Region::aws("ap-southeast-2").unwrap(), + /// "my-client-id", + /// ).unwrap(); + /// ``` + pub fn new(region: Region, client_id: impl Into) -> Result { + let base_url = CtsServiceDiscovery::endpoint(region)?; + Ok(Self { + base_url: ensure_trailing_slash(base_url), + client_id: client_id.into(), + }) + } + + /// Override the base URL resolved by service discovery. + /// + /// Useful for pointing at a local or mock CTS instance during testing. + #[cfg(any(test, feature = "test-utils"))] + pub fn with_base_url(mut self, base_url: U) -> Result + where + U: TryInto, + U::Error: Into, + { + self.base_url = ensure_trailing_slash(base_url.try_into().map_err(Into::into)?); + Ok(self) + } + + /// Start the device code flow. + /// + /// Requests a device code from the CipherStash auth server and returns a + /// [`PendingDeviceCode`] with the user-facing codes and URIs. Show these + /// to the user, then call [`PendingDeviceCode::poll_for_token`] to wait + /// for authorization. + /// + /// # Errors + /// + /// Returns [`AuthError::InvalidClient`] if the client ID is not recognized, + /// or [`AuthError::Request`] if the server is unreachable. + pub async fn begin(&self) -> Result { + let client = reqwest::Client::new(); + + let code_url = self.base_url.join("oauth/device/code")?; + + tracing::debug!(url = %code_url, client_id = %self.client_id, "requesting device code"); + + let code_resp = client + .post(code_url) + .form(&DeviceCodeRequest { + client_id: &self.client_id, + }) + .send() + .await?; + + if !code_resp.status().is_success() { + let err: ErrorResponse = code_resp.json().await?; + tracing::debug!(error = %err.error, "device code request failed"); + return Err(match err.error.as_str() { + "invalid_client" => AuthError::InvalidClient, + _ => AuthError::Server(err.error_description), + }); + } + + let code: DeviceCodeResponse = code_resp.json().await?; + + let token_url = self.base_url.join("oauth/device/token")?; + + tracing::debug!( + user_code = %code.user_code, + expires_in = code.expires_in, + "device code received" + ); + + Ok(PendingDeviceCode { + token_url, + client_id: self.client_id.clone(), + device_code: code.device_code, + user_code: code.user_code, + verification_uri: code.verification_uri, + verification_uri_complete: code.verification_uri_complete, + expires_in: code.expires_in, + }) + } +} + +/// A device code flow that is waiting for the user to authorize. +/// +/// Returned by [`DeviceCodeStrategy::begin`]. Display the +/// [`user_code`](Self::user_code) and +/// [`verification_uri_complete`](Self::verification_uri_complete) to the user +/// (or call [`open_in_browser`](Self::open_in_browser)), then call +/// [`poll_for_token`](Self::poll_for_token) to wait for authorization. +/// +/// # Example +/// +/// ```no_run +/// # use stack_auth::DeviceCodeStrategy; +/// # use cts_common::Region; +/// # async fn run() -> Result<(), Box> { +/// # let strategy = DeviceCodeStrategy::new(Region::aws("ap-southeast-2")?, "cli")?; +/// let pending = strategy.begin().await?; +/// +/// println!("Go to: {}", pending.verification_uri_complete()); +/// println!("Enter code: {}", pending.user_code()); +/// +/// let token = pending.poll_for_token().await?; +/// # Ok(()) +/// # } +/// ``` +#[derive(Debug)] +pub struct PendingDeviceCode { + token_url: Url, + client_id: String, + device_code: DeviceCode, + user_code: String, + verification_uri: String, + verification_uri_complete: String, + expires_in: u64, +} + +impl PendingDeviceCode { + /// The short code the user must enter to authorize this device. + pub fn user_code(&self) -> &str { + &self.user_code + } + + /// The base verification URI (without the user code embedded). + pub fn verification_uri(&self) -> &str { + &self.verification_uri + } + + /// The full verification URI with the user code pre-filled. + pub fn verification_uri_complete(&self) -> &str { + &self.verification_uri_complete + } + + /// How many seconds the device code remains valid. + pub fn expires_in(&self) -> u64 { + self.expires_in + } + + /// Open the verification URI in the user's default browser. + /// + /// Returns `true` if the browser was opened successfully. + pub fn open_in_browser(&self) -> bool { + open::that(&self.verification_uri_complete).is_ok() + } + + /// Poll the auth server until the user authorizes (or the code expires). + /// + /// This method consumes `self` and blocks asynchronously, polling at a + /// server-controlled interval (starting at 5 seconds). It returns a + /// [`Token`] on success. + /// + /// # Errors + /// + /// - [`AuthError::AccessDenied`] — the user rejected the request. + /// - [`AuthError::ExpiredToken`] — the device code expired before the user + /// authorized. + /// - [`AuthError::Request`] — a network error occurred while polling. + pub async fn poll_for_token(self) -> Result { + let client = reqwest::Client::new(); + let mut interval = tokio::time::Duration::from_secs(5); + let deadline = + tokio::time::Instant::now() + tokio::time::Duration::from_secs(self.expires_in); + + tracing::debug!( + url = %self.token_url, + expires_in = self.expires_in, + "polling for token" + ); + + loop { + tokio::time::sleep(interval).await; + + if tokio::time::Instant::now() >= deadline { + tracing::debug!("device code expired while polling"); + return Err(AuthError::ExpiredToken); + } + + let resp = client + .post(self.token_url.clone()) + .form(&TokenRequest { + client_id: &self.client_id, + device_code: &self.device_code, + grant_type: "urn:ietf:params:oauth:grant-type:device_code", + }) + .send() + .await?; + + if resp.status().is_success() { + tracing::debug!("token received"); + let token_resp: TokenResponse = resp.json().await?; + return Ok(Token { + access_token: token_resp.access_token, + token_type: token_resp.token_type, + expires_in: token_resp.expires_in, + }); + } + + let err: ErrorResponse = resp.json().await?; + match err.error.as_str() { + "authorization_pending" => { + tracing::debug!("authorization pending, retrying"); + continue; + } + "slow_down" => { + interval += tokio::time::Duration::from_secs(5); + tracing::debug!(interval_secs = interval.as_secs(), "slowing down"); + continue; + } + "expired_token" => return Err(AuthError::ExpiredToken), + "access_denied" => return Err(AuthError::AccessDenied), + "invalid_grant" => return Err(AuthError::InvalidGrant), + "invalid_client" => return Err(AuthError::InvalidClient), + _ => return Err(AuthError::Server(err.error_description)), + } + } + } +} + +/// Ensure a URL has a trailing slash so that `Url::join` with relative paths +/// appends to the path rather than replacing the last segment. +fn ensure_trailing_slash(mut url: Url) -> Url { + if !url.path().ends_with('/') { + url.set_path(&format!("{}/", url.path())); + } + url +} diff --git a/packages/stack-auth/src/device_code/protocol.rs b/packages/stack-auth/src/device_code/protocol.rs new file mode 100644 index 000000000..bc565c660 --- /dev/null +++ b/packages/stack-auth/src/device_code/protocol.rs @@ -0,0 +1,46 @@ +use serde::{Deserialize, Serialize}; +use vitaminc::protected::OpaqueDebug; +use zeroize::ZeroizeOnDrop; + +use crate::SecretToken; + +/// A device code issued by the auth server, exchanged for an access token +/// once the user authorizes. +#[derive(OpaqueDebug, ZeroizeOnDrop, Deserialize, Serialize)] +#[serde(transparent)] +pub(super) struct DeviceCode(String); + +#[derive(Deserialize)] +pub(super) struct DeviceCodeResponse { + pub device_code: DeviceCode, + pub user_code: String, + pub verification_uri: String, + pub verification_uri_complete: String, + pub expires_in: u64, +} + +#[derive(Deserialize)] +pub(super) struct TokenResponse { + pub access_token: SecretToken, + pub token_type: String, + pub expires_in: u64, +} + +#[derive(Deserialize)] +pub(super) struct ErrorResponse { + pub error: String, + #[serde(default)] + pub error_description: String, +} + +#[derive(Serialize)] +pub(super) struct DeviceCodeRequest<'a> { + pub client_id: &'a str, +} + +#[derive(Serialize)] +pub(super) struct TokenRequest<'a> { + pub client_id: &'a str, + pub device_code: &'a DeviceCode, + pub grant_type: &'a str, +} diff --git a/packages/stack-auth/src/device_code/tests.rs b/packages/stack-auth/src/device_code/tests.rs new file mode 100644 index 000000000..7d1346f57 --- /dev/null +++ b/packages/stack-auth/src/device_code/tests.rs @@ -0,0 +1,355 @@ +use super::*; +use cts_common::Region; +use mocktail::prelude::*; + +fn device_code_json() -> serde_json::Value { + serde_json::json!({ + "device_code": "test_device_code", + "user_code": "ABCD-EFGH", + "verification_uri": "http://example.com/activate", + "verification_uri_complete": "http://example.com/activate?user_code=ABCD-EFGH", + "expires_in": 900 + }) +} + +fn token_json() -> serde_json::Value { + serde_json::json!({ + "access_token": "test_access_token_value", + "token_type": "Bearer", + "expires_in": 3600 + }) +} + +fn error_json(error: &str) -> serde_json::Value { + serde_json::json!({ + "error": error, + "error_description": format!("{error} occurred") + }) +} + +fn mock_code_endpoint(mocks: &mut MockSet) { + mocks.mock(|when, then| { + when.post().path("/oauth/device/code"); + then.json(device_code_json()); + }); +} + +async fn start_server(mocks: MockSet) -> MockServer { + let server = MockServer::new_http("stack-auth-test").with_mocks(mocks); + server.start().await.unwrap(); + server +} + +fn strategy_for(server: &MockServer) -> DeviceCodeStrategy { + DeviceCodeStrategy::new(Region::aws("ap-southeast-2").unwrap(), "cli") + .unwrap() + .with_base_url(server.url("")) + .unwrap() +} + +// ---- begin() tests ---- + +#[tokio::test] +async fn test_begin_returns_pending_device_code() { + let mut mocks = MockSet::new(); + mock_code_endpoint(&mut mocks); + let server = start_server(mocks).await; + + let pending = strategy_for(&server).begin().await.unwrap(); + + assert_eq!(pending.user_code(), "ABCD-EFGH"); + assert_eq!(pending.verification_uri(), "http://example.com/activate"); + assert_eq!( + pending.verification_uri_complete(), + "http://example.com/activate?user_code=ABCD-EFGH" + ); + assert_eq!(pending.expires_in(), 900); +} + +#[tokio::test] +async fn test_begin_invalid_client() { + let mut mocks = MockSet::new(); + mocks.mock(|when, then| { + when.post().path("/oauth/device/code"); + then.bad_request().json(error_json("invalid_client")); + }); + let server = start_server(mocks).await; + + let err = strategy_for(&server).begin().await.unwrap_err(); + + assert!(matches!(err, AuthError::InvalidClient)); +} + +#[tokio::test] +async fn test_begin_server_error() { + let mut mocks = MockSet::new(); + mocks.mock(|when, then| { + when.post().path("/oauth/device/code"); + then.bad_request().json(error_json("server_error")); + }); + let server = start_server(mocks).await; + + let err = strategy_for(&server).begin().await.unwrap_err(); + + assert!(matches!(&err, AuthError::Server(desc) if desc == "server_error occurred")); +} + +// ---- poll_for_token() tests ---- + +/// Helper: calls begin() against a server that already has the code mock, +/// then returns the PendingDeviceCode ready for polling. +async fn begin_pending(server: &MockServer) -> PendingDeviceCode { + strategy_for(server).begin().await.unwrap() +} + +#[tokio::test(start_paused = true)] +async fn test_poll_for_token_success() { + let mut mocks = MockSet::new(); + mock_code_endpoint(&mut mocks); + mocks.mock(|when, then| { + when.post().path("/oauth/device/token"); + then.json(token_json()); + }); + let server = start_server(mocks).await; + + let token = begin_pending(&server).await.poll_for_token().await.unwrap(); + + assert_eq!(token.access_token().0, "test_access_token_value"); + assert_eq!(token.token_type(), "Bearer"); + assert_eq!(token.expires_in(), 3600); +} + +#[tokio::test(start_paused = true)] +async fn test_poll_for_token_access_denied() { + let mut mocks = MockSet::new(); + mock_code_endpoint(&mut mocks); + mocks.mock(|when, then| { + when.post().path("/oauth/device/token"); + then.bad_request().json(error_json("access_denied")); + }); + let server = start_server(mocks).await; + + let err = begin_pending(&server) + .await + .poll_for_token() + .await + .unwrap_err(); + + assert!(matches!(err, AuthError::AccessDenied)); +} + +#[tokio::test(start_paused = true)] +async fn test_poll_for_token_expired_token() { + let mut mocks = MockSet::new(); + mock_code_endpoint(&mut mocks); + mocks.mock(|when, then| { + when.post().path("/oauth/device/token"); + then.bad_request().json(error_json("expired_token")); + }); + let server = start_server(mocks).await; + + let err = begin_pending(&server) + .await + .poll_for_token() + .await + .unwrap_err(); + + assert!(matches!(err, AuthError::ExpiredToken)); +} + +#[tokio::test(start_paused = true)] +async fn test_poll_for_token_invalid_grant() { + let mut mocks = MockSet::new(); + mock_code_endpoint(&mut mocks); + mocks.mock(|when, then| { + when.post().path("/oauth/device/token"); + then.bad_request().json(error_json("invalid_grant")); + }); + let server = start_server(mocks).await; + + let err = begin_pending(&server) + .await + .poll_for_token() + .await + .unwrap_err(); + + assert!(matches!(err, AuthError::InvalidGrant)); +} + +#[tokio::test(start_paused = true)] +async fn test_poll_for_token_invalid_client() { + let mut mocks = MockSet::new(); + mock_code_endpoint(&mut mocks); + mocks.mock(|when, then| { + when.post().path("/oauth/device/token"); + then.bad_request().json(error_json("invalid_client")); + }); + let server = start_server(mocks).await; + + let err = begin_pending(&server) + .await + .poll_for_token() + .await + .unwrap_err(); + + assert!(matches!(err, AuthError::InvalidClient)); +} + +#[tokio::test(start_paused = true)] +async fn test_poll_for_token_unknown_error() { + let mut mocks = MockSet::new(); + mock_code_endpoint(&mut mocks); + mocks.mock(|when, then| { + when.post().path("/oauth/device/token"); + then.bad_request().json(error_json("something_unexpected")); + }); + let server = start_server(mocks).await; + + let err = begin_pending(&server) + .await + .poll_for_token() + .await + .unwrap_err(); + + assert!(matches!(&err, AuthError::Server(desc) if desc == "something_unexpected occurred")); +} + +#[tokio::test(start_paused = true)] +async fn test_poll_for_token_authorization_pending_then_success() { + let mut mocks = MockSet::new(); + mock_code_endpoint(&mut mocks); + mocks.mock(|when, then| { + when.post().path("/oauth/device/token"); + then.bad_request().json(error_json("authorization_pending")); + }); + let server = start_server(mocks).await; + let pending = begin_pending(&server).await; + + // Use tokio::join! so the swap future can borrow server.mocks() directly + // (the shared RwLock) rather than cloning the MockSet. + // First poll at T=5s returns "authorization_pending". + // At T=6s the mock is swapped. Second poll at T=10s returns success. + let (result, _) = tokio::join!(pending.poll_for_token(), async { + tokio::time::sleep(tokio::time::Duration::from_secs(6)).await; + server.mocks().clear(); + server.mocks().mock(|when, then| { + when.post().path("/oauth/device/token"); + then.json(token_json()); + }); + }); + + let token = result.unwrap(); + assert_eq!(token.access_token().0, "test_access_token_value"); +} + +#[tokio::test(start_paused = true)] +async fn test_poll_for_token_slow_down_then_success() { + let mut mocks = MockSet::new(); + mock_code_endpoint(&mut mocks); + mocks.mock(|when, then| { + when.post().path("/oauth/device/token"); + then.bad_request().json(error_json("slow_down")); + }); + let server = start_server(mocks).await; + let pending = begin_pending(&server).await; + + // First poll returns "slow_down", interval increases to 10s. + // Swap the mock to return success before the second poll. + let (result, _) = tokio::join!(pending.poll_for_token(), async { + tokio::time::sleep(tokio::time::Duration::from_secs(6)).await; + server.mocks().clear(); + server.mocks().mock(|when, then| { + when.post().path("/oauth/device/token"); + then.json(token_json()); + }); + }); + + let token = result.unwrap(); + assert_eq!(token.access_token().0, "test_access_token_value"); +} + +/// Proves that `slow_down` increases the poll interval: with a short +/// `expires_in`, the increased interval pushes the next poll past the +/// deadline, causing an `ExpiredToken` error. +#[tokio::test(start_paused = true)] +async fn test_poll_for_token_slow_down_increases_interval() { + let mut mocks = MockSet::new(); + // expires_in = 12: without slow_down, second poll at T=10 is within + // the deadline. With slow_down, interval becomes 10s, so second poll + // at T=15 exceeds the 12s deadline. + mocks.mock(|when, then| { + when.post().path("/oauth/device/code"); + then.json(serde_json::json!({ + "device_code": "test_device_code", + "user_code": "ABCD-EFGH", + "verification_uri": "http://example.com/activate", + "verification_uri_complete": "http://example.com/activate?user_code=ABCD-EFGH", + "expires_in": 12 + })); + }); + mocks.mock(|when, then| { + when.post().path("/oauth/device/token"); + then.bad_request().json(error_json("slow_down")); + }); + let server = start_server(mocks).await; + let pending = begin_pending(&server).await; + + let err = pending.poll_for_token().await.unwrap_err(); + + assert!(matches!(err, AuthError::ExpiredToken)); +} + +// ---- ensure_trailing_slash / URL join tests ---- + +#[test] +fn test_ensure_trailing_slash_adds_slash() { + let url = Url::parse("http://localhost:3001").unwrap(); + let result = ensure_trailing_slash(url); + assert_eq!(result.as_str(), "http://localhost:3001/"); +} + +#[test] +fn test_ensure_trailing_slash_preserves_existing() { + let url = Url::parse("http://localhost:3001/").unwrap(); + let result = ensure_trailing_slash(url); + assert_eq!(result.as_str(), "http://localhost:3001/"); +} + +#[test] +fn test_ensure_trailing_slash_with_path() { + let url = Url::parse("http://localhost:3001/api/v1").unwrap(); + let result = ensure_trailing_slash(url); + assert_eq!(result.as_str(), "http://localhost:3001/api/v1/"); +} + +#[test] +fn test_relative_join_preserves_base_path() { + let base = ensure_trailing_slash(Url::parse("http://localhost:3001/api/v1").unwrap()); + let joined = base.join("oauth/device/code").unwrap(); + assert_eq!( + joined.as_str(), + "http://localhost:3001/api/v1/oauth/device/code" + ); +} + +#[test] +fn test_relative_join_on_root_url() { + let base = ensure_trailing_slash(Url::parse("http://localhost:3001").unwrap()); + let joined = base.join("oauth/device/code").unwrap(); + assert_eq!(joined.as_str(), "http://localhost:3001/oauth/device/code"); +} + +#[tokio::test] +async fn test_pending_device_code_debug_does_not_leak() { + let mut mocks = MockSet::new(); + mock_code_endpoint(&mut mocks); + let server = start_server(mocks).await; + + let pending = begin_pending(&server).await; + let debug = format!("{:?}", pending); + + assert!( + !debug.contains("test_device_code"), + "PendingDeviceCode Debug should not contain the device code, got: {debug}" + ); +} From 1b29e7772b8fb963ac04f1b8dc61347adff7190a Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Sun, 15 Feb 2026 17:17:42 +1100 Subject: [PATCH 005/686] =?UTF-8?q?fix:=20=F0=9F=90=9B=20poll=20immediatel?= =?UTF-8?q?y=20on=20first=20token=20request=20per=20RFC=208628=20=C2=A73.5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move the sleep to after error handling so the first token request is made immediately after receiving the device code, avoiding an unnecessary 5-second delay. --- packages/stack-auth/src/device_code/mod.rs | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/packages/stack-auth/src/device_code/mod.rs b/packages/stack-auth/src/device_code/mod.rs index b5cf8b510..273229564 100644 --- a/packages/stack-auth/src/device_code/mod.rs +++ b/packages/stack-auth/src/device_code/mod.rs @@ -32,7 +32,6 @@ pub struct DeviceCodeStrategy { client_id: String, } - impl DeviceCodeStrategy { /// Create a new strategy for the given CipherStash region and OAuth client ID. /// @@ -215,8 +214,6 @@ impl PendingDeviceCode { ); loop { - tokio::time::sleep(interval).await; - if tokio::time::Instant::now() >= deadline { tracing::debug!("device code expired while polling"); return Err(AuthError::ExpiredToken); @@ -246,12 +243,10 @@ impl PendingDeviceCode { match err.error.as_str() { "authorization_pending" => { tracing::debug!("authorization pending, retrying"); - continue; } "slow_down" => { interval += tokio::time::Duration::from_secs(5); tracing::debug!(interval_secs = interval.as_secs(), "slowing down"); - continue; } "expired_token" => return Err(AuthError::ExpiredToken), "access_denied" => return Err(AuthError::AccessDenied), @@ -259,6 +254,8 @@ impl PendingDeviceCode { "invalid_client" => return Err(AuthError::InvalidClient), _ => return Err(AuthError::Server(err.error_description)), } + + tokio::time::sleep(interval).await; } } } From 5f2bcd9fa1ef0ce45006edf0fbeda5726bcbb24f Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Sun, 15 Feb 2026 12:50:57 +1100 Subject: [PATCH 006/686] =?UTF-8?q?feat:=20=F0=9F=94=8C=20add=20napi-rs=20?= =?UTF-8?q?Node.js=20bindings=20for=20cts-auth=20device=20code=20flow?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Class-based API where DeviceCodeResult has pollForToken() and openInBrowser() methods, replacing the Neon handle pattern. Uses napi-rs tokio_rt for automatic async runtime management. --- languages/typescript/packages/auth/.gitignore | 3 + languages/typescript/packages/auth/Cargo.lock | 3200 +++++++++++++++++ languages/typescript/packages/auth/Cargo.toml | 29 + languages/typescript/packages/auth/build.rs | 5 + languages/typescript/packages/auth/index.d.ts | 148 + languages/typescript/packages/auth/index.js | 61 + .../typescript/packages/auth/package.json | 18 + .../packages/auth/src/generate_types.rs | 401 +++ languages/typescript/packages/auth/src/lib.rs | 183 + packages/stack-auth/Cargo.toml | 2 + packages/stack-auth/src/device_code/mod.rs | 10 + packages/stack-auth/src/lib.rs | 12 + 12 files changed, 4072 insertions(+) create mode 100644 languages/typescript/packages/auth/.gitignore create mode 100644 languages/typescript/packages/auth/Cargo.lock create mode 100644 languages/typescript/packages/auth/Cargo.toml create mode 100644 languages/typescript/packages/auth/build.rs create mode 100644 languages/typescript/packages/auth/index.d.ts create mode 100644 languages/typescript/packages/auth/index.js create mode 100644 languages/typescript/packages/auth/package.json create mode 100644 languages/typescript/packages/auth/src/generate_types.rs create mode 100644 languages/typescript/packages/auth/src/lib.rs diff --git a/languages/typescript/packages/auth/.gitignore b/languages/typescript/packages/auth/.gitignore new file mode 100644 index 000000000..9c109da36 --- /dev/null +++ b/languages/typescript/packages/auth/.gitignore @@ -0,0 +1,3 @@ +target/ +node_modules/ +*.node diff --git a/languages/typescript/packages/auth/Cargo.lock b/languages/typescript/packages/auth/Cargo.lock new file mode 100644 index 000000000..9b2836cb8 --- /dev/null +++ b/languages/typescript/packages/auth/Cargo.lock @@ -0,0 +1,3200 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "Inflector" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe438c63458706e03479442743baae6c88256498e6431708f6dfc520a26515d3" + +[[package]] +name = "addr2line" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5d307320b3181d6d7954e663bd7c774a838b8220fe0593c86d9fb09f498b4b" +dependencies = [ + "gimli", +] + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "alloc-no-stdlib" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" + +[[package]] +name = "alloc-stdlib" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94fb8275041c72129eb51b7d0322c29b8387a0386127718b096429201a5d6ece" +dependencies = [ + "alloc-no-stdlib", +] + +[[package]] +name = "anyhow" +version = "1.0.101" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f0e0fee31ef5ed1ba1316088939cea399010ed7731dba877ed44aeb407a75ea" + +[[package]] +name = "arrayvec" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" +dependencies = [ + "serde", +] + +[[package]] +name = "async-compression" +version = "0.4.39" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68650b7df54f0293fd061972a0fb05aaf4fc0879d3b3d21a638a182c5c543b9f" +dependencies = [ + "compression-codecs", + "compression-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.115", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "aws-lc-rs" +version = "1.15.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b7b6141e96a8c160799cc2d5adecd5cbbe5054cb8c7c4af53da0f83bb7ad256" +dependencies = [ + "aws-lc-sys", + "untrusted 0.7.1", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.37.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b092fe214090261288111db7a2b2c2118e5a7f30dc2569f1732c4069a6840549" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", +] + +[[package]] +name = "backtrace" +version = "0.3.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb531853791a215d7c62a30daf0dde835f381ab5de4589cfe7c649d2cbe92bd6" +dependencies = [ + "addr2line", + "cfg-if", + "libc", + "miniz_oxide", + "object", + "rustc-demangle", + "windows-link", +] + +[[package]] +name = "backtrace-ext" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "537beee3be4a18fb023b570f80e3ae28003db9167a751266b259926e25539d50" +dependencies = [ + "backtrace", +] + +[[package]] +name = "base32" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "022dfe9eb35f19ebbcb51e0b40a5ab759f46ad60cadf7297e0bd085afb50e076" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bitflags" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" + +[[package]] +name = "bitvec" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bc2832c24239b0141d5674bb9174f9d68a8b5b3f2753311927c172ca46f7e9c" +dependencies = [ + "funty", + "radium", + "tap", + "wyz", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "brotli" +version = "8.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bd8b9603c7aa97359dbd97ecf258968c95f3adddd6db2f7e7a5bef101c84560" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", + "brotli-decompressor", +] + +[[package]] +name = "brotli-decompressor" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "874bb8112abecc98cbd6d81ea4fa7e94fb9449648c93cc89aa40c81c24d7de03" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", +] + +[[package]] +name = "bumpalo" +version = "3.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +dependencies = [ + "serde", +] + +[[package]] +name = "cc" +version = "1.2.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aebf35691d1bfb0ac386a69bac2fde4dd276fb618cf8bf4f5318fe285e821bb2" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "cmake" +version = "0.1.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75443c44cd6b379beb8c5b45d85d0773baf31cce901fe7bb252f4eff3008ef7d" +dependencies = [ + "cc", +] + +[[package]] +name = "compression-codecs" +version = "0.4.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00828ba6fd27b45a448e57dbfe84f1029d4c9f26b368157e9a448a5f49a2ec2a" +dependencies = [ + "brotli", + "compression-core", + "flate2", + "memchr", +] + +[[package]] +name = "compression-core" +version = "0.4.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75984efb6ed102a0d42db99afb6c1948f0380d1d91808d5529916e6c08b49d8d" + +[[package]] +name = "convert_case" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec182b0ca2f35d8fc196cf3404988fd8b8c739a4d270ff118a398feb0cbec1ca" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + +[[package]] +name = "crossbeam-channel" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "ctor" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a2785755761f3ddc1492979ce1e48d2c00d09311c39e4466429188f3dd6501" +dependencies = [ + "quote", + "syn 2.0.115", +] + +[[package]] +name = "cts-common" +version = "0.4.1" +dependencies = [ + "arrayvec", + "base32", + "derive_more", + "either", + "miette", + "nom", + "regex", + "serde", + "thiserror 1.0.69", + "url", + "utoipa", + "uuid", + "vitaminc", +] + +[[package]] +name = "data-encoding" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "convert_case 0.10.0", + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.115", + "unicode-xid", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.115", +] + +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +dependencies = [ + "serde", +] + +[[package]] +name = "enum-as-inner" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1e6a265c649f3f5979b601d26f1d05ada116434c87741c9493cb56218f76cbc" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.115", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + +[[package]] +name = "funty" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" + +[[package]] +name = "futures" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" + +[[package]] +name = "futures-executor" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" + +[[package]] +name = "futures-macro" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.115", +] + +[[package]] +name = "futures-sink" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" + +[[package]] +name = "futures-task" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" + +[[package]] +name = "futures-util" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "pin-utils", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi", + "wasip2", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139ef39800118c7683f2fd3c98c1b23c09ae076556b435f8e9064ae108aaeeec" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", + "wasip3", +] + +[[package]] +name = "gimli" +version = "0.32.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" + +[[package]] +name = "h2" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hickory-proto" +version = "0.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8a6fe56c0038198998a6f217ca4e7ef3a5e51f46163bd6dd60b5c71ca6c6502" +dependencies = [ + "async-trait", + "cfg-if", + "data-encoding", + "enum-as-inner", + "futures-channel", + "futures-io", + "futures-util", + "idna", + "ipnet", + "once_cell", + "rand 0.9.2", + "ring", + "thiserror 2.0.18", + "tinyvec", + "tokio", + "tracing", + "url", +] + +[[package]] +name = "hickory-resolver" +version = "0.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc62a9a99b0bfb44d2ab95a7208ac952d31060efc16241c87eaf36406fecf87a" +dependencies = [ + "cfg-if", + "futures-util", + "hickory-proto", + "ipconfig", + "moka", + "once_cell", + "parking_lot", + "rand 0.9.2", + "resolv-conf", + "smallvec", + "thiserror 2.0.18", + "tokio", + "tracing", +] + +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "pin-utils", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2 0.6.2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "icu_collections" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +dependencies = [ + "displaydoc", + "potential_utf", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" + +[[package]] +name = "icu_properties" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" + +[[package]] +name = "icu_provider" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +dependencies = [ + "equivalent", + "hashbrown 0.16.1", + "serde", + "serde_core", +] + +[[package]] +name = "indoc" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa799dd5ed20a7e349f3b4639aa80d74549c81716d9ec4f994c9b5815598306" + +[[package]] +name = "ipconfig" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b58db92f96b720de98181bbbe63c831e87005ab460c1bf306eb2622b4707997f" +dependencies = [ + "socket2 0.5.10", + "widestring", + "windows-sys 0.48.0", + "winreg", +] + +[[package]] +name = "ipnet" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" + +[[package]] +name = "iri-string" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c91338f0783edbd6195decb37bae672fd3b165faffb89bf7b9e6942f8b1a731a" +dependencies = [ + "memchr", + "serde", +] + +[[package]] +name = "is-docker" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3" +dependencies = [ + "once_cell", +] + +[[package]] +name = "is-wsl" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5" +dependencies = [ + "is-docker", + "once_cell", +] + +[[package]] +name = "is_ci" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7655c9839580ee829dfacba1d1278c2b7883e50a277ff7541299489d6bdfdc45" + +[[package]] +name = "itertools" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" + +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.85" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c942ebf8e95485ca0d52d97da7c5a2c387d0e7f0ba4c35e93bfcaee045955b3" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libc" +version = "0.2.182" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112" + +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link", +] + +[[package]] +name = "linux-raw-sys" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" + +[[package]] +name = "litemap" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" + +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "miette" +version = "7.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f98efec8807c63c752b5bd61f862c165c115b0a35685bdcfd9238c7aeb592b7" +dependencies = [ + "backtrace", + "backtrace-ext", + "cfg-if", + "miette-derive", + "owo-colors", + "supports-color", + "supports-hyperlinks", + "supports-unicode", + "terminal_size", + "textwrap", + "unicode-width 0.1.14", +] + +[[package]] +name = "miette-derive" +version = "7.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db5b29714e950dbb20d5e6f74f9dcec4edbcc1067bb7f8ed198c097b8c1a818b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.115", +] + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "mocktail" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053f7ba52863e22dfd2970075bbc69c4224ca6ae03896a5f69a0d5982deb5e0a" +dependencies = [ + "bytes", + "futures", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "prost", + "rand 0.9.2", + "serde", + "serde_json", + "thiserror 2.0.18", + "tokio", + "tokio-stream", + "tracing", + "url", + "uuid", +] + +[[package]] +name = "moka" +version = "0.12.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac832c50ced444ef6be0767a008b02c106a909ba79d1d830501e94b96f6b7e" +dependencies = [ + "crossbeam-channel", + "crossbeam-epoch", + "crossbeam-utils", + "equivalent", + "parking_lot", + "portable-atomic", + "smallvec", + "tagptr", + "uuid", +] + +[[package]] +name = "napi" +version = "2.16.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55740c4ae1d8696773c78fdafd5d0e5fe9bc9f1b071c7ba493ba5c413a9184f3" +dependencies = [ + "bitflags", + "ctor", + "napi-derive", + "napi-sys", + "once_cell", + "tokio", +] + +[[package]] +name = "napi-build" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d376940fd5b723c6893cd1ee3f33abbfd86acb1cd1ec079f3ab04a2a3bc4d3b1" + +[[package]] +name = "napi-derive" +version = "2.16.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cbe2585d8ac223f7d34f13701434b9d5f4eb9c332cccce8dee57ea18ab8ab0c" +dependencies = [ + "cfg-if", + "convert_case 0.6.0", + "napi-derive-backend", + "proc-macro2", + "quote", + "syn 2.0.115", +] + +[[package]] +name = "napi-derive-backend" +version = "1.0.75" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1639aaa9eeb76e91c6ae66da8ce3e89e921cd3885e99ec85f4abacae72fc91bf" +dependencies = [ + "convert_case 0.6.0", + "once_cell", + "proc-macro2", + "quote", + "regex", + "semver", + "syn 2.0.115", +] + +[[package]] +name = "napi-sys" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "427802e8ec3a734331fec1035594a210ce1ff4dc5bc1950530920ab717964ea3" +dependencies = [ + "libloading", +] + +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "object" +version = "0.37.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" +dependencies = [ + "memchr", +] + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +dependencies = [ + "critical-section", + "portable-atomic", +] + +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + +[[package]] +name = "open" +version = "5.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43bb73a7fa3799b198970490a51174027ba0d4ec504b03cd08caf513d40024bc" +dependencies = [ + "is-wsl", + "libc", + "pathdiff", +] + +[[package]] +name = "owo-colors" +version = "4.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c6901729fa79e91a0913333229e9ca5dc725089d1c363b2f4b4760709dc4a52" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "pathdiff" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + +[[package]] +name = "potential_utf" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" +dependencies = [ + "zerovec", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.115", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "prost" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2796faa41db3ec313a31f7624d9286acf277b52de526150b7e69f3debf891ee5" +dependencies = [ + "bytes", + "prost-derive", +] + +[[package]] +name = "prost-derive" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a56d757972c98b346a9b766e3f02746cde6dd1cd1d1d563472929fdd74bec4d" +dependencies = [ + "anyhow", + "itertools", + "proc-macro2", + "quote", + "syn 2.0.115", +] + +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2 0.6.2", + "thiserror 2.0.18", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31" +dependencies = [ + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand 0.9.2", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2 0.6.2", + "tracing", + "windows-sys 0.60.2", +] + +[[package]] +name = "quote" +version = "1.0.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "radium" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" + +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a96887878f22d7bad8a3b6dc5b7440e0ada9a245242924394987b21cf2210a4c" + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64", + "bytes", + "futures-core", + "futures-util", + "hickory-resolver", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "once_cell", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", + "webpki-roots", +] + +[[package]] +name = "resolv-conf" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e061d1b48cb8d38042de4ae0a7a6401009d6143dc80d2e2d6f31f0bdd6470c7" + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted 0.9.0", + "windows-sys 0.52.0", +] + +[[package]] +name = "rmp" +version = "0.8.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ba8be72d372b2c9b35542551678538b562e7cf86c3315773cae48dfbfe7790c" +dependencies = [ + "num-traits", +] + +[[package]] +name = "rmp-serde" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f81bee8c8ef9b577d1681a70ebbc962c232461e397b22c208c43c04b67a155" +dependencies = [ + "rmp", + "serde", +] + +[[package]] +name = "rustc-demangle" +version = "0.1.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b50b8869d9fc858ce7266cce0194bd74df58b9d0e3f6df3a9fc8eb470d95c09d" + +[[package]] +name = "rustc-hash" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c665f33d38cea657d9614f766881e4d510e0eda4239891eea56b4cadcf01801b" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted 0.9.0", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "semver" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_bytes" +version = "0.11.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.115", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sha1_smol" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbfa15b3dddfee50a0fff136974b3e1bde555604ba463834a7eb7deb6417705d" + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "simd-adler32" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.5.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + +[[package]] +name = "socket2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86f4aa3ad99f2088c990dfa82d367e19cb29268ed67c574d10d0a4bfe71f07e0" +dependencies = [ + "libc", + "windows-sys 0.60.2", +] + +[[package]] +name = "specta" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2240c3aa020aa61d2c569087d213baafbb212f4ceb9de9dd162376ea6aa0fe3" +dependencies = [ + "document-features", + "indoc", + "once_cell", + "paste", + "serde", + "serde_json", + "specta-macros", + "thiserror 1.0.69", +] + +[[package]] +name = "specta-macros" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4605306321c356e03873b8ee71d7592a5e7c508add325c3ed0677c16fdf1bcfb" +dependencies = [ + "Inflector", + "itertools", + "proc-macro2", + "quote", + "syn 1.0.109", + "termcolor", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "stack-auth" +version = "0.1.0" +dependencies = [ + "cts-common", + "open", + "reqwest", + "serde", + "specta", + "thiserror 1.0.69", + "tokio", + "tracing", + "url", + "vitaminc", + "zeroize", +] + +[[package]] +name = "stack-auth-node" +version = "0.1.0" +dependencies = [ + "cts-common", + "mocktail", + "napi", + "napi-build", + "napi-derive", + "regex", + "serde_json", + "specta", + "stack-auth", + "syn 2.0.115", + "tokio", + "url", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "supports-color" +version = "3.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c64fc7232dd8d2e4ac5ce4ef302b1d81e0b80d055b9d77c7c4f51f6aa4c867d6" +dependencies = [ + "is_ci", +] + +[[package]] +name = "supports-hyperlinks" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e396b6523b11ccb83120b115a0b7366de372751aa6edf19844dfb13a6af97e91" + +[[package]] +name = "supports-unicode" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7401a30af6cb5818bb64852270bb722533397edcfc7344954a38f420819ece2" + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.115" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e614ed320ac28113fa64972c4262d5dbc89deacdfd00c34a3e4cea073243c12" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.115", +] + +[[package]] +name = "tagptr" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417" + +[[package]] +name = "tap" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" + +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "terminal_size" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60b8cb979cb11c32ce1603f8137b22262a9d131aaa5c37b5678025f22b8becd0" +dependencies = [ + "rustix", + "windows-sys 0.60.2", +] + +[[package]] +name = "textwrap" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c13547615a44dc9c452a8a534638acdf07120d4b6847c8178705da06306a3057" +dependencies = [ + "unicode-linebreak", + "unicode-width 0.2.2", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.115", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.115", +] + +[[package]] +name = "tinystr" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.49.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2 0.6.2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.115", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-stream" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" +dependencies = [ + "async-compression", + "bitflags", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "iri-string", + "pin-project-lite", + "tokio", + "tokio-util", + "tower", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.115", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "typenum" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + +[[package]] +name = "unicode-ident" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "537dd038a89878be9b64dd4bd1b260315c1bb94f4d784956b81e27a088d9a09e" + +[[package]] +name = "unicode-linebreak" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f" + +[[package]] +name = "unicode-segmentation" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" + +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "untrusted" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", + "serde_derive", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utoipa" +version = "5.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fcc29c80c21c31608227e0912b2d7fddba57ad76b606890627ba8ee7964e993" +dependencies = [ + "indexmap", + "serde", + "serde_json", + "utoipa-gen", +] + +[[package]] +name = "utoipa-gen" +version = "5.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d79d08d92ab8af4c5e8a6da20c47ae3f61a0f1dabc1997cdf2d082b757ca08b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.115", + "url", + "uuid", +] + +[[package]] +name = "uuid" +version = "1.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b672338555252d43fd2240c714dc444b8c6fb0a5c5335e65a07bba7742735ddb" +dependencies = [ + "getrandom 0.4.1", + "js-sys", + "rand 0.9.2", + "serde_core", + "sha1_smol", + "wasm-bindgen", +] + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "vitaminc" +version = "0.1.0-pre4" +source = "git+https://github.com/cipherstash/vitaminc?branch=fix%2Fopaque-debug-unused-results#bb6387850102619a0ce2d2050adecbed6543cf88" +dependencies = [ + "vitaminc-encrypt", + "vitaminc-protected", + "vitaminc-random", + "vitaminc-traits", +] + +[[package]] +name = "vitaminc-aead" +version = "0.1.0-pre4" +source = "git+https://github.com/cipherstash/vitaminc?branch=fix%2Fopaque-debug-unused-results#bb6387850102619a0ce2d2050adecbed6543cf88" +dependencies = [ + "bytes", + "serde", + "vitaminc-protected", + "vitaminc-random", + "zeroize", +] + +[[package]] +name = "vitaminc-encrypt" +version = "0.1.0-pre4" +source = "git+https://github.com/cipherstash/vitaminc?branch=fix%2Fopaque-debug-unused-results#bb6387850102619a0ce2d2050adecbed6543cf88" +dependencies = [ + "aws-lc-rs", + "vitaminc-aead", + "vitaminc-protected", + "vitaminc-random", + "zeroize", +] + +[[package]] +name = "vitaminc-protected" +version = "0.1.0-pre4" +source = "git+https://github.com/cipherstash/vitaminc?branch=fix%2Fopaque-debug-unused-results#bb6387850102619a0ce2d2050adecbed6543cf88" +dependencies = [ + "bitvec", + "digest", + "opaque-debug", + "serde", + "serde_bytes", + "subtle", + "vitaminc-protected-derive", + "zeroize", +] + +[[package]] +name = "vitaminc-protected-derive" +version = "0.1.0-pre4" +source = "git+https://github.com/cipherstash/vitaminc?branch=fix%2Fopaque-debug-unused-results#bb6387850102619a0ce2d2050adecbed6543cf88" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.115", +] + +[[package]] +name = "vitaminc-random" +version = "0.1.0-pre4" +source = "git+https://github.com/cipherstash/vitaminc?branch=fix%2Fopaque-debug-unused-results#bb6387850102619a0ce2d2050adecbed6543cf88" +dependencies = [ + "rand 0.8.5", + "rand_chacha 0.3.1", + "thiserror 1.0.69", + "vitaminc-protected", + "vitaminc-random-derives", + "zeroize", +] + +[[package]] +name = "vitaminc-random-derives" +version = "0.1.0-pre4" +source = "git+https://github.com/cipherstash/vitaminc?branch=fix%2Fopaque-debug-unused-results#bb6387850102619a0ce2d2050adecbed6543cf88" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.115", +] + +[[package]] +name = "vitaminc-traits" +version = "0.1.0-pre4" +source = "git+https://github.com/cipherstash/vitaminc?branch=fix%2Fopaque-debug-unused-results#bb6387850102619a0ce2d2050adecbed6543cf88" +dependencies = [ + "anyhow", + "bytes", + "rmp-serde", + "serde", + "thiserror 1.0.69", + "vitaminc-protected", + "vitaminc-random", + "zeroize", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.2+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64024a30ec1e37399cf85a7ffefebdb72205ca1c972291c51512360d90bd8566" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70a6e77fd0ae8029c9ea0063f87c46fde723e7d887703d74ad2616d792e51e6f" +dependencies = [ + "cfg-if", + "futures-util", + "js-sys", + "once_cell", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "008b239d9c740232e71bd39e8ef6429d27097518b6b30bdf9086833bd5b6d608" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5256bae2d58f54820e6490f9839c49780dff84c65aeab9e772f15d5f0e913a55" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.115", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f01b580c9ac74c8d8f0c0e4afb04eeef2acf145458e52c03845ee9cd23e3d12" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasm-streams" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "web-sys" +version = "0.3.85" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "312e32e551d92129218ea9a2452120f4aabc03529ef03e4d0d82fb2780608598" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-roots" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "widestring" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "winreg" +version = "0.50.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "524e57b2c537c0f9b1e69f1965311ec12182b4122e45035b1508cd24d2adadb1" +dependencies = [ + "cfg-if", + "windows-sys 0.48.0", +] + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn 2.0.115", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn 2.0.115", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "writeable" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" + +[[package]] +name = "wyz" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed" +dependencies = [ + "tap", +] + +[[package]] +name = "yoke" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.115", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.39" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db6d35d663eadb6c932438e763b262fe1a70987f9ae936e60158176d710cae4a" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.39" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4122cd3169e94605190e77839c9a40d40ed048d305bfdc146e7df40ab0f3e517" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.115", +] + +[[package]] +name = "zerofrom" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.115", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.115", +] + +[[package]] +name = "zerotrie" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.115", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/languages/typescript/packages/auth/Cargo.toml b/languages/typescript/packages/auth/Cargo.toml new file mode 100644 index 000000000..05fce4446 --- /dev/null +++ b/languages/typescript/packages/auth/Cargo.toml @@ -0,0 +1,29 @@ +[package] +name = "stack-auth-node" +version = "0.1.0" +edition = "2021" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +stack-auth = { path = "..", features = ["specta"] } +cts-common = { path = "../../cts-common", default-features = false } +napi = { version = "2", features = ["async", "tokio_rt"] } +napi-derive = "2" +regex = "1" +specta = { version = "1", features = ["typescript"] } +syn = { version = "2", features = ["full", "parsing"] } +url = { version = "2", optional = true } + +[build-dependencies] +napi-build = "2" + +[features] +test-utils = ["stack-auth/test-utils", "dep:url"] + +[[bin]] +name = "generate-types" +path = "src/generate_types.rs" + +[workspace] diff --git a/languages/typescript/packages/auth/build.rs b/languages/typescript/packages/auth/build.rs new file mode 100644 index 000000000..9fc236788 --- /dev/null +++ b/languages/typescript/packages/auth/build.rs @@ -0,0 +1,5 @@ +extern crate napi_build; + +fn main() { + napi_build::setup(); +} diff --git a/languages/typescript/packages/auth/index.d.ts b/languages/typescript/packages/auth/index.d.ts new file mode 100644 index 000000000..c2cc8eb48 --- /dev/null +++ b/languages/typescript/packages/auth/index.d.ts @@ -0,0 +1,148 @@ +// This file is auto-generated by `generate-types`. Do not edit by hand. +// Regenerate with: npm run generate-types + +/** + * The result of initiating a device code flow. + * + * Contains the user-facing codes and URIs needed to complete authorization, + * and provides {@link poll_for_token} to + * exchange the device code for an access token once the user has authorized. + */ +export class DeviceCodeResult { + /** The short code the user must enter to authorize this device. */ + readonly userCode: string; + /** The base verification URI (without the user code embedded). */ + readonly verificationUri: string; + /** The full verification URI with the user code pre-filled. */ + readonly verificationUriComplete: string; + /** How many seconds the device code remains valid. */ + readonly expiresIn: number; + + /** + * Poll the auth server until the user completes authorization. + * + * **Consumes** the internal handle — it cannot be reused after this call. + * If you need to open the browser, call {@link openInBrowser} *before* + * `pollForToken`. + * + * @returns A promise that resolves with the access token details. + * @throws {AuthError} `EXPIRED_TOKEN` — the device code expired before the user authorized. + * @throws {AuthError} `ACCESS_DENIED` — the user denied authorization. + * @throws {AuthError} `INVALID_GRANT` — the grant type is invalid or the code was already used. + * @throws {AuthError} `REQUEST_ERROR` — the HTTP request to the auth server failed. + * @throws {AuthError} `SERVER_ERROR` — the auth server returned an unexpected error. + */ + pollForToken(): Promise; + + /** + * Open the verification URI in the user's default browser. + * + * Does **not** consume the handle — you can still call {@link pollForToken} + * afterwards. + * + * @returns `true` if the browser was launched successfully, `false` otherwise. + */ + openInBrowser(): boolean; +} + +/** + * An access token returned by an authentication flow. + */ +export interface TokenResult { + /** The OAuth access token. */ + accessToken: string; + /** Token type, typically `"Bearer"`. */ + tokenType: string; + /** Number of seconds before the token expires. */ + expiresIn: number; +} + +/** + * Machine-readable error codes attached to {@link AuthError}. + * + * - `"REQUEST_ERROR"` — The HTTP request to the auth server failed (network error, timeout, etc.). + * - `"ACCESS_DENIED"` — The user explicitly denied authorization. + * - `"EXPIRED_TOKEN"` — The device code expired before the user authorized. + * - `"INVALID_GRANT"` — The grant type is invalid or the device code was already used. + * - `"INVALID_CLIENT"` — The `clientId` is not recognized by the auth server. + * - `"INVALID_URL"` — A URL argument could not be parsed. + * - `"INVALID_REGION"` — The `region` string does not match a known CipherStash region. + * - `"SERVER_ERROR"` — The auth server returned an unexpected error. + */ +export type AuthErrorCode = + | "REQUEST_ERROR" + | "ACCESS_DENIED" + | "EXPIRED_TOKEN" + | "INVALID_GRANT" + | "INVALID_CLIENT" + | "INVALID_URL" + | "INVALID_REGION" + | "SERVER_ERROR"; + +/** + * Error thrown by all functions in this module. + * + * Extends the built-in `Error` with a machine-readable {@link AuthErrorCode} + * so callers can branch on `err.code` without parsing the message string. + */ +export interface AuthError extends Error { + code: AuthErrorCode; +} + +/** + * Begin the OAuth 2.0 Device Authorization flow. + * + * Contacts the CipherStash auth server for the given region and returns a + * {@link DeviceCodeResult} with a user code, verification URL, and methods + * to complete the authorization flow. + * + * @param region - CipherStash region identifier (e.g. `"ap-southeast-2"`). + * @param clientId - OAuth client ID issued by CipherStash. + * @returns A promise that resolves with the device code result. + * @throws {AuthError} `INVALID_REGION` — the region string is not recognized. + * @throws {AuthError} `INVALID_CLIENT` — the client ID is not recognized. + * @throws {AuthError} `REQUEST_ERROR` — the HTTP request to the auth server failed. + * @throws {AuthError} `SERVER_ERROR` — the auth server returned an unexpected error. + * + * @example + * ```ts + * import { beginDeviceCodeFlow } from "@cipherstash/stack-auth"; + * + * // 1. Start the flow + * const result = await beginDeviceCodeFlow("ap-southeast-2", MY_CLIENT_ID); + * console.log(`Enter code ${result.userCode} at ${result.verificationUri}`); + * + * // 2. Optionally open the browser for the user + * result.openInBrowser(); + * + * // 3. Poll until the user authorizes (or the code expires) + * const token = await result.pollForToken(); + * console.log(`Access token: ${token.accessToken}`); + * ``` + */ +export function beginDeviceCodeFlow( + region: string, + clientId: string, +): Promise; + +/** + * Variant of {@link beginDeviceCodeFlow} that targets a custom auth server URL. + * + * Intended for **testing only** — requires the crate to be built with the + * `test-utils` Cargo feature. + * + * @param region - CipherStash region identifier (e.g. `"ap-southeast-2"`). + * @param clientId - OAuth client ID issued by CipherStash. + * @param baseUrl - Base URL of the auth server to use instead of the default. + * @returns A promise that resolves with the device code result. + * @throws {AuthError} `INVALID_REGION` — the region string is not recognized. + * @throws {AuthError} `INVALID_URL` — the base URL could not be parsed. + * @throws {AuthError} `INVALID_CLIENT` — the client ID is not recognized. + * @throws {AuthError} `REQUEST_ERROR` — the HTTP request to the auth server failed. + * @throws {AuthError} `SERVER_ERROR` — the auth server returned an unexpected error. + */ +export function beginDeviceCodeFlowWithBaseUrl( + region: string, + clientId: string, + baseUrl: string, +): Promise; diff --git a/languages/typescript/packages/auth/index.js b/languages/typescript/packages/auth/index.js new file mode 100644 index 000000000..5a47f9247 --- /dev/null +++ b/languages/typescript/packages/auth/index.js @@ -0,0 +1,61 @@ +// Wrapper that loads the native napi-rs module and enriches errors with a +// machine-readable `.code` property by parsing the "CODE: message" format +// that the Rust side produces. + +const native = require("./stack-auth-node.node"); + +const CODE_RE = /^([A-Z_]+): /; + +/** + * Parse the "CODE: message" format produced by the Rust bindings and attach + * `.code` to the Error object. + */ +function enrichError(err) { + if (err instanceof Error) { + const match = CODE_RE.exec(err.message); + if (match) { + err.code = match[1]; + err.message = err.message.slice(match[0].length); + } + } + throw err; +} + +/** + * Wrap an async function so that rejected errors get `.code` enrichment. + */ +function wrapAsync(fn) { + return function (...args) { + return fn.apply(this, args).catch(enrichError); + }; +} + +/** + * Wrap a sync function so that thrown errors get `.code` enrichment. + */ +function wrapSync(fn) { + return function (...args) { + try { + return fn.apply(this, args); + } catch (err) { + enrichError(err); + } + }; +} + +// Patch DeviceCodeResult prototype methods +const proto = native.DeviceCodeResult.prototype; +proto.pollForToken = wrapAsync(proto.pollForToken); +proto.openInBrowser = wrapSync(proto.openInBrowser); + +// Export wrapped top-level functions alongside native re-exports +module.exports = { + ...native, + beginDeviceCodeFlow: wrapAsync(native.beginDeviceCodeFlow), +}; + +if (native.beginDeviceCodeFlowWithBaseUrl) { + module.exports.beginDeviceCodeFlowWithBaseUrl = wrapAsync( + native.beginDeviceCodeFlowWithBaseUrl, + ); +} diff --git a/languages/typescript/packages/auth/package.json b/languages/typescript/packages/auth/package.json new file mode 100644 index 000000000..74f52a04c --- /dev/null +++ b/languages/typescript/packages/auth/package.json @@ -0,0 +1,18 @@ +{ + "name": "@cipherstash/stack-auth", + "version": "0.1.0", + "private": true, + "main": "index.js", + "types": "index.d.ts", + "napi": { + "name": "stack-auth-node" + }, + "scripts": { + "build": "napi build --release", + "build:debug": "napi build", + "generate-types": "cargo run --bin generate-types" + }, + "devDependencies": { + "@napi-rs/cli": "^2" + } +} diff --git a/languages/typescript/packages/auth/src/generate_types.rs b/languages/typescript/packages/auth/src/generate_types.rs new file mode 100644 index 000000000..23429631c --- /dev/null +++ b/languages/typescript/packages/auth/src/generate_types.rs @@ -0,0 +1,401 @@ +//! Generator binary that produces `index.d.ts` by combining Specta's structured +//! type information with field-level doc comments extracted from Rust source via +//! `syn`. This produces: +//! +//! - `export class DeviceCodeResult` with `readonly` properties and method +//! signatures (with JSDoc for `pollForToken()` and `openInBrowser()`) +//! - `export interface TokenResult` with per-field JSDoc +//! +//! Run via: `cargo run --bin generate-types` + +use std::collections::HashMap; +use std::fs; +use std::path::Path; + +use stack_auth::{PendingDeviceCode, Token}; +use regex::Regex; +use specta::ts; +use specta::{DefOpts, NamedType, TypeDefs}; + +fn main() { + let manifest_dir = Path::new(env!("CARGO_MANIFEST_DIR")); + let crate_src = manifest_dir.join("../src"); + + // expires_in is u64 in Rust but always a safe integer in practice, so + // export it as `number` rather than `bigint`. + let config = ts::ExportConfiguration::default().bigint(ts::BigIntExportBehavior::Number); + + // Parse Rust source files to extract field-level doc comments. + let device_code_src = fs::read_to_string(crate_src.join("device_code/mod.rs")) + .expect("Failed to read device_code/mod.rs"); + let lib_src = fs::read_to_string(crate_src.join("lib.rs")).expect("Failed to read lib.rs"); + + let device_code_docs = extract_field_docs(&device_code_src, "PendingDeviceCode"); + let token_docs = extract_field_docs(&lib_src, "Token"); + + // Build rich TypeScript declarations from Specta metadata + syn doc comments. + let device_code_class = build_class::(&device_code_docs, &config); + let token_iface = build_interface::(&token_docs, &config); + + let mut out = String::new(); + + out.push_str("// This file is auto-generated by `generate-types`. Do not edit by hand.\n"); + out.push_str("// Regenerate with: npm run generate-types\n\n"); + + // ----------------------------------------------------------------------- + // Specta-generated type definitions (from the cts-auth crate) + // ----------------------------------------------------------------------- + + out.push_str(&device_code_class); + out.push_str("\n\n"); + out.push_str(&token_iface); + out.push_str("\n\n"); + + // ----------------------------------------------------------------------- + // Hand-written error types (better TSDoc control per variant) + // ----------------------------------------------------------------------- + + out.push_str( + r#"/** + * Machine-readable error codes attached to {@link AuthError}. + * + * - `"REQUEST_ERROR"` — The HTTP request to the auth server failed (network error, timeout, etc.). + * - `"ACCESS_DENIED"` — The user explicitly denied authorization. + * - `"EXPIRED_TOKEN"` — The device code expired before the user authorized. + * - `"INVALID_GRANT"` — The grant type is invalid or the device code was already used. + * - `"INVALID_CLIENT"` — The `clientId` is not recognized by the auth server. + * - `"INVALID_URL"` — A URL argument could not be parsed. + * - `"INVALID_REGION"` — The `region` string does not match a known CipherStash region. + * - `"SERVER_ERROR"` — The auth server returned an unexpected error. + */ +export type AuthErrorCode = + | "REQUEST_ERROR" + | "ACCESS_DENIED" + | "EXPIRED_TOKEN" + | "INVALID_GRANT" + | "INVALID_CLIENT" + | "INVALID_URL" + | "INVALID_REGION" + | "SERVER_ERROR"; + +/** + * Error thrown by all functions in this module. + * + * Extends the built-in `Error` with a machine-readable {@link AuthErrorCode} + * so callers can branch on `err.code` without parsing the message string. + */ +export interface AuthError extends Error { + code: AuthErrorCode; +} +"#, + ); + + out.push('\n'); + + // ----------------------------------------------------------------------- + // Function declarations with TSDoc + // ----------------------------------------------------------------------- + + out.push_str( + r#"/** + * Begin the OAuth 2.0 Device Authorization flow. + * + * Contacts the CipherStash auth server for the given region and returns a + * {@link DeviceCodeResult} with a user code, verification URL, and methods + * to complete the authorization flow. + * + * @param region - CipherStash region identifier (e.g. `"ap-southeast-2"`). + * @param clientId - OAuth client ID issued by CipherStash. + * @returns A promise that resolves with the device code result. + * @throws {AuthError} `INVALID_REGION` — the region string is not recognized. + * @throws {AuthError} `INVALID_CLIENT` — the client ID is not recognized. + * @throws {AuthError} `REQUEST_ERROR` — the HTTP request to the auth server failed. + * @throws {AuthError} `SERVER_ERROR` — the auth server returned an unexpected error. + * + * @example + * ```ts + * import { beginDeviceCodeFlow } from "@cipherstash/cts-auth"; + * + * // 1. Start the flow + * const result = await beginDeviceCodeFlow("ap-southeast-2", MY_CLIENT_ID); + * console.log(`Enter code ${result.userCode} at ${result.verificationUri}`); + * + * // 2. Optionally open the browser for the user + * result.openInBrowser(); + * + * // 3. Poll until the user authorizes (or the code expires) + * const token = await result.pollForToken(); + * console.log(`Access token: ${token.accessToken}`); + * ``` + */ +export function beginDeviceCodeFlow( + region: string, + clientId: string, +): Promise; + +/** + * Variant of {@link beginDeviceCodeFlow} that targets a custom auth server URL. + * + * Intended for **testing only** — requires the crate to be built with the + * `test-utils` Cargo feature. + * + * @param region - CipherStash region identifier (e.g. `"ap-southeast-2"`). + * @param clientId - OAuth client ID issued by CipherStash. + * @param baseUrl - Base URL of the auth server to use instead of the default. + * @returns A promise that resolves with the device code result. + * @throws {AuthError} `INVALID_REGION` — the region string is not recognized. + * @throws {AuthError} `INVALID_URL` — the base URL could not be parsed. + * @throws {AuthError} `INVALID_CLIENT` — the client ID is not recognized. + * @throws {AuthError} `REQUEST_ERROR` — the HTTP request to the auth server failed. + * @throws {AuthError} `SERVER_ERROR` — the auth server returned an unexpected error. + */ +export function beginDeviceCodeFlowWithBaseUrl( + region: string, + clientId: string, + baseUrl: string, +): Promise; +"#, + ); + + // ----------------------------------------------------------------------- + // Write to index.d.ts in the package root + // ----------------------------------------------------------------------- + + let dest = manifest_dir.join("index.d.ts"); + + fs::write(&dest, &out).unwrap_or_else(|e| panic!("Failed to write {}: {e}", dest.display())); + + println!("Wrote {}", dest.display()); +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/// Parse a Rust source file with `syn` and extract doc comments from the fields +/// of the given struct. Returns a map from field name (snake_case) to the +/// trimmed doc comment text. +fn extract_field_docs(source: &str, struct_name: &str) -> HashMap { + let file = syn::parse_file(source).expect("Failed to parse Rust source"); + let mut docs = HashMap::new(); + + for item in &file.items { + let syn::Item::Struct(s) = item else { + continue; + }; + if s.ident != struct_name { + continue; + } + + let syn::Fields::Named(fields) = &s.fields else { + continue; + }; + for field in &fields.named { + let Some(ident) = &field.ident else { + continue; + }; + + let doc_lines: Vec = field + .attrs + .iter() + .filter_map(|attr| { + if !attr.path().is_ident("doc") { + return None; + } + let syn::Meta::NameValue(nv) = &attr.meta else { + return None; + }; + let syn::Expr::Lit(lit) = &nv.value else { + return None; + }; + let syn::Lit::Str(s) = &lit.lit else { + return None; + }; + Some(s.value()) + }) + .collect(); + + if !doc_lines.is_empty() { + let combined = doc_lines + .iter() + .map(|l| l.strip_prefix(' ').unwrap_or(l)) + .collect::>() + .join("\n"); + docs.insert(ident.to_string(), combined.trim().to_string()); + } + } + break; // Found the struct, no need to continue. + } + + docs +} + +/// Convert Rust intra-doc links (`` [`text`](Rust::path) ``) to TSDoc +/// `{@link text}` references. +fn convert_rust_doc_links(doc: &str) -> String { + let re = Regex::new(r"\[`([^`]+)`\]\([^)]+\)").expect("invalid regex"); + re.replace_all(doc, "{@link $1}").to_string() +} + +/// Convert `camelCase` to `snake_case`. +fn to_snake_case(s: &str) -> String { + let mut result = String::new(); + for (i, c) in s.chars().enumerate() { + if c.is_uppercase() && i > 0 { + result.push('_'); + } + result.extend(c.to_lowercase()); + } + result +} + +/// Build a multi-line TypeScript `export class` declaration from Specta +/// metadata and syn-extracted field doc comments. Properties are `readonly` +/// and class methods (`pollForToken`, `openInBrowser`) are appended. +fn build_class( + field_docs: &HashMap, + config: &ts::ExportConfiguration, +) -> String { + let mut type_defs = TypeDefs::default(); + let named = T::definition_named_data_type(DefOpts { + parent_inline: false, + type_map: &mut type_defs, + }) + .expect("Failed to get named data type"); + + let ts_name = named.name; + + let mut out = String::new(); + + // Struct-level JSDoc from Specta's comments (originally `///` on the struct). + if !named.comments.is_empty() { + out.push_str("/**\n"); + for comment in named.comments { + let line = comment.strip_prefix(' ').unwrap_or(comment); + if line.is_empty() { + out.push_str(" *\n"); + } else { + let converted = convert_rust_doc_links(line); + out.push_str(&format!(" * {converted}\n")); + } + } + out.push_str(" */\n"); + } + + out.push_str(&format!("export class {ts_name} {{\n")); + + // Extract object fields. + let fields = match &named.item { + specta::NamedDataTypeItem::Object(obj) => &obj.fields, + _ => panic!("{ts_name} is not an object type"), + }; + + for field in fields { + let key = field.key; + let snake_key = to_snake_case(key); + + if let Some(doc) = field_docs.get(&snake_key) { + let converted = convert_rust_doc_links(doc); + out.push_str(&format!(" /** {converted} */\n")); + } + + let ts_type = ts::datatype(config, &field.ty) + .unwrap_or_else(|e| panic!("Failed to convert field {key}: {e}")); + + out.push_str(&format!(" readonly {key}: {ts_type};\n")); + } + + // Class methods + out.push_str( + r#" + /** + * Poll the auth server until the user completes authorization. + * + * **Consumes** the internal handle — it cannot be reused after this call. + * If you need to open the browser, call {@link openInBrowser} *before* + * `pollForToken`. + * + * @returns A promise that resolves with the access token details. + * @throws {AuthError} `EXPIRED_TOKEN` — the device code expired before the user authorized. + * @throws {AuthError} `ACCESS_DENIED` — the user denied authorization. + * @throws {AuthError} `INVALID_GRANT` — the grant type is invalid or the code was already used. + * @throws {AuthError} `REQUEST_ERROR` — the HTTP request to the auth server failed. + * @throws {AuthError} `SERVER_ERROR` — the auth server returned an unexpected error. + */ + pollForToken(): Promise; + + /** + * Open the verification URI in the user's default browser. + * + * Does **not** consume the handle — you can still call {@link pollForToken} + * afterwards. + * + * @returns `true` if the browser was launched successfully, `false` otherwise. + */ + openInBrowser(): boolean; +"#, + ); + + out.push('}'); + out +} + +/// Build a multi-line TypeScript `export interface` declaration from Specta +/// metadata and syn-extracted field doc comments. +fn build_interface( + field_docs: &HashMap, + config: &ts::ExportConfiguration, +) -> String { + let mut type_defs = TypeDefs::default(); + let named = T::definition_named_data_type(DefOpts { + parent_inline: false, + type_map: &mut type_defs, + }) + .expect("Failed to get named data type"); + + let ts_name = named.name; + + let mut out = String::new(); + + // Struct-level JSDoc from Specta's comments (originally `///` on the struct). + if !named.comments.is_empty() { + out.push_str("/**\n"); + for comment in named.comments { + let line = comment.strip_prefix(' ').unwrap_or(comment); + if line.is_empty() { + out.push_str(" *\n"); + } else { + let converted = convert_rust_doc_links(line); + out.push_str(&format!(" * {converted}\n")); + } + } + out.push_str(" */\n"); + } + + out.push_str(&format!("export interface {ts_name} {{\n")); + + // Extract object fields. + let fields = match &named.item { + specta::NamedDataTypeItem::Object(obj) => &obj.fields, + _ => panic!("{ts_name} is not an object type"), + }; + + for field in fields { + let key = field.key; + let snake_key = to_snake_case(key); + + if let Some(doc) = field_docs.get(&snake_key) { + let converted = convert_rust_doc_links(doc); + out.push_str(&format!(" /** {converted} */\n")); + } + + let ts_type = ts::datatype(config, &field.ty) + .unwrap_or_else(|e| panic!("Failed to convert field {key}: {e}")); + + let optional = if field.optional { "?" } else { "" }; + out.push_str(&format!(" {key}{optional}: {ts_type};\n")); + } + + out.push('}'); + out +} diff --git a/languages/typescript/packages/auth/src/lib.rs b/languages/typescript/packages/auth/src/lib.rs new file mode 100644 index 000000000..1f854b836 --- /dev/null +++ b/languages/typescript/packages/auth/src/lib.rs @@ -0,0 +1,183 @@ +use std::sync::Mutex; + +use stack_auth::{AuthError, DeviceCodeStrategy, PendingDeviceCode}; +use cts_common::Region; +use napi::bindgen_prelude::*; +use napi_derive::napi; + +// --------------------------------------------------------------------------- +// Error helpers +// --------------------------------------------------------------------------- + +fn error_code(err: &AuthError) -> &'static str { + match err { + AuthError::Request(_) => "REQUEST_ERROR", + AuthError::AccessDenied => "ACCESS_DENIED", + AuthError::ExpiredToken => "EXPIRED_TOKEN", + AuthError::InvalidGrant => "INVALID_GRANT", + AuthError::InvalidClient => "INVALID_CLIENT", + AuthError::InvalidUrl(_) => "INVALID_URL", + AuthError::Region(_) => "INVALID_REGION", + AuthError::Server(_) => "SERVER_ERROR", + _ => "UNKNOWN_ERROR", + } +} + +fn to_napi_error(err: AuthError) -> napi::Error { + let code = error_code(&err); + napi::Error::new(Status::GenericFailure, format!("{code}: {err}")) +} + +// --------------------------------------------------------------------------- +// TokenResult — plain data object +// --------------------------------------------------------------------------- + +#[napi(object)] +pub struct TokenResult { + /// The OAuth access token. + pub access_token: String, + /// Token type, typically `"Bearer"`. + pub token_type: String, + /// Number of seconds before the token expires. + pub expires_in: f64, +} + +// --------------------------------------------------------------------------- +// DeviceCodeResult — class with methods +// --------------------------------------------------------------------------- + +#[napi] +pub struct DeviceCodeResult { + /// The short code the user must enter to authorize this device. + user_code: String, + /// The base verification URI (without the user code embedded). + verification_uri: String, + /// The full verification URI with the user code pre-filled. + verification_uri_complete: String, + /// How many seconds the device code remains valid. + expires_in: f64, + /// The pending device code handle (consumed by `pollForToken`). + pending: Mutex>, +} + +#[napi] +impl DeviceCodeResult { + #[napi(getter)] + pub fn user_code(&self) -> String { + self.user_code.clone() + } + + #[napi(getter)] + pub fn verification_uri(&self) -> String { + self.verification_uri.clone() + } + + #[napi(getter, js_name = "verificationUriComplete")] + pub fn verification_uri_complete(&self) -> String { + self.verification_uri_complete.clone() + } + + #[napi(getter)] + pub fn expires_in(&self) -> f64 { + self.expires_in + } + + /// Poll the auth server until the user completes authorization. + /// + /// **Consumes** the internal handle — it cannot be reused after this call. + /// If you need to open the browser, call `openInBrowser` *before* + /// `pollForToken`. + #[napi] + pub async fn poll_for_token(&self) -> Result { + let pending = self + .pending + .lock() + .map_err(|_| napi::Error::new(Status::GenericFailure, "Lock poisoned"))? + .take() + .ok_or_else(|| { + napi::Error::new( + Status::GenericFailure, + "Device code handle has already been consumed", + ) + })?; + + let token = pending.poll_for_token().await.map_err(to_napi_error)?; + + Ok(TokenResult { + access_token: token.access_token().as_str().to_string(), + token_type: token.token_type().to_string(), + expires_in: token.expires_in() as f64, + }) + } + + /// Open the verification URI in the user's default browser. + /// + /// Does **not** consume the handle — you can still call `pollForToken` + /// afterwards. + #[napi] + pub fn open_in_browser(&self) -> Result { + let guard = self + .pending + .lock() + .map_err(|_| napi::Error::new(Status::GenericFailure, "Lock poisoned"))?; + + match guard.as_ref() { + Some(pending) => Ok(pending.open_in_browser()), + None => Err(napi::Error::new( + Status::GenericFailure, + "Device code handle has already been consumed", + )), + } + } +} + +impl DeviceCodeResult { + fn from_pending(pending: PendingDeviceCode) -> Self { + Self { + user_code: pending.user_code().to_string(), + verification_uri: pending.verification_uri().to_string(), + verification_uri_complete: pending.verification_uri_complete().to_string(), + expires_in: pending.expires_in() as f64, + pending: Mutex::new(Some(pending)), + } + } +} + +// --------------------------------------------------------------------------- +// Exported functions +// --------------------------------------------------------------------------- + +/// Begin the OAuth 2.0 Device Authorization flow. +#[napi] +pub async fn begin_device_code_flow( + region: String, + client_id: String, +) -> Result { + let region = Region::new(®ion).map_err(|e| to_napi_error(AuthError::from(e)))?; + let strategy = DeviceCodeStrategy::new(region, client_id).map_err(to_napi_error)?; + let pending = strategy.begin().await.map_err(to_napi_error)?; + Ok(DeviceCodeResult::from_pending(pending)) +} + +/// Variant of `beginDeviceCodeFlow` that targets a custom auth server URL. +/// +/// Intended for **testing only** — requires the crate to be built with the +/// `test-utils` Cargo feature. +#[cfg(feature = "test-utils")] +#[napi] +pub async fn begin_device_code_flow_with_base_url( + region: String, + client_id: String, + base_url: String, +) -> Result { + let region = Region::new(®ion).map_err(|e| to_napi_error(AuthError::from(e)))?; + let parsed_url: url::Url = base_url + .parse() + .map_err(|e: url::ParseError| to_napi_error(AuthError::from(e)))?; + let strategy = DeviceCodeStrategy::new(region, client_id) + .map_err(to_napi_error)? + .with_base_url(parsed_url) + .map_err(to_napi_error)?; + let pending = strategy.begin().await.map_err(to_napi_error)?; + Ok(DeviceCodeResult::from_pending(pending)) +} diff --git a/packages/stack-auth/Cargo.toml b/packages/stack-auth/Cargo.toml index 5b7380ccf..d7b675c4e 100644 --- a/packages/stack-auth/Cargo.toml +++ b/packages/stack-auth/Cargo.toml @@ -17,9 +17,11 @@ tracing = { workspace = true } url = { workspace = true } vitaminc = { workspace = true, features = ["protected"] } zeroize = { workspace = true } +specta = { version = "1", features = ["typescript"], optional = true } [features] test-utils = [] +specta = ["dep:specta"] [[example]] name = "device_code" diff --git a/packages/stack-auth/src/device_code/mod.rs b/packages/stack-auth/src/device_code/mod.rs index 273229564..fad7bcce9 100644 --- a/packages/stack-auth/src/device_code/mod.rs +++ b/packages/stack-auth/src/device_code/mod.rs @@ -151,13 +151,23 @@ impl DeviceCodeStrategy { /// # } /// ``` #[derive(Debug)] +#[cfg_attr(feature = "specta", derive(serde::Serialize, specta::Type))] +#[cfg_attr(feature = "specta", serde(rename_all = "camelCase"))] +#[cfg_attr(feature = "specta", specta(rename = "DeviceCodeResult"))] pub struct PendingDeviceCode { + #[cfg_attr(feature = "specta", serde(skip))] token_url: Url, + #[cfg_attr(feature = "specta", serde(skip))] client_id: String, + #[cfg_attr(feature = "specta", serde(skip))] device_code: DeviceCode, + /// The short code the user must enter to authorize this device. user_code: String, + /// The base verification URI (without the user code embedded). verification_uri: String, + /// The full verification URI with the user code pre-filled. verification_uri_complete: String, + /// How many seconds the device code remains valid. expires_in: u64, } diff --git a/packages/stack-auth/src/lib.rs b/packages/stack-auth/src/lib.rs index 8f27d208b..a0d348836 100644 --- a/packages/stack-auth/src/lib.rs +++ b/packages/stack-auth/src/lib.rs @@ -84,14 +84,26 @@ pub use device_code::{DeviceCodeStrategy, PendingDeviceCode}; /// You cannot construct a `SecretToken` directly — it is returned by the /// authentication flow via [`Token::access_token`]. #[derive(OpaqueDebug, ZeroizeOnDrop, serde::Deserialize)] +#[cfg_attr(feature = "specta", derive(serde::Serialize, specta::Type))] #[serde(transparent)] pub struct SecretToken(String); +impl SecretToken { + /// Expose the inner token string for FFI boundaries. + #[cfg(feature = "specta")] + pub fn as_str(&self) -> &str { + &self.0 + } +} + /// An access token returned by a successful authentication flow. /// /// The token contains a [`SecretToken`] (the bearer credential), a token type /// (typically `"Bearer"`), and an expiry time in seconds. #[derive(Debug)] +#[cfg_attr(feature = "specta", derive(serde::Serialize, specta::Type))] +#[cfg_attr(feature = "specta", serde(rename_all = "camelCase"))] +#[cfg_attr(feature = "specta", specta(rename = "TokenResult"))] pub struct Token { access_token: SecretToken, token_type: String, From 8ee694e7a3ea11beb5324914ec486310c276d857 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Sun, 15 Feb 2026 12:52:08 +1100 Subject: [PATCH 007/686] =?UTF-8?q?chore:=20=F0=9F=94=92=20add=20lock=20fi?= =?UTF-8?q?les=20for=20cts-auth=20napi-rs=20node=20bindings?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- languages/typescript/packages/auth/Cargo.lock | 231 ++++-------------- .../packages/auth/package-lock.json | 32 +++ 2 files changed, 80 insertions(+), 183 deletions(-) create mode 100644 languages/typescript/packages/auth/package-lock.json diff --git a/languages/typescript/packages/auth/Cargo.lock b/languages/typescript/packages/auth/Cargo.lock index 9b2836cb8..1bf7017e6 100644 --- a/languages/typescript/packages/auth/Cargo.lock +++ b/languages/typescript/packages/auth/Cargo.lock @@ -347,6 +347,38 @@ dependencies = [ "syn 2.0.115", ] +[[package]] +name = "cts-auth" +version = "0.1.0" +dependencies = [ + "cts-common", + "open", + "reqwest", + "serde", + "specta", + "thiserror 1.0.69", + "tokio", + "tracing", + "url", + "vitaminc", + "zeroize", +] + +[[package]] +name = "cts-auth-node" +version = "0.1.0" +dependencies = [ + "cts-auth", + "cts-common", + "napi", + "napi-build", + "napi-derive", + "regex", + "specta", + "syn 2.0.115", + "url", +] + [[package]] name = "cts-common" version = "0.4.1" @@ -484,12 +516,6 @@ dependencies = [ "miniz_oxide", ] -[[package]] -name = "fnv" -version = "1.0.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" - [[package]] name = "foldhash" version = "0.1.5" @@ -517,21 +543,6 @@ version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" -[[package]] -name = "futures" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" -dependencies = [ - "futures-channel", - "futures-core", - "futures-executor", - "futures-io", - "futures-sink", - "futures-task", - "futures-util", -] - [[package]] name = "futures-channel" version = "0.3.31" @@ -539,7 +550,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" dependencies = [ "futures-core", - "futures-sink", ] [[package]] @@ -548,17 +558,6 @@ version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" -[[package]] -name = "futures-executor" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" -dependencies = [ - "futures-core", - "futures-task", - "futures-util", -] - [[package]] name = "futures-io" version = "0.3.31" @@ -594,7 +593,6 @@ version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" dependencies = [ - "futures-channel", "futures-core", "futures-io", "futures-macro", @@ -662,25 +660,6 @@ version = "0.32.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" -[[package]] -name = "h2" -version = "0.4.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54" -dependencies = [ - "atomic-waker", - "bytes", - "fnv", - "futures-core", - "futures-sink", - "http", - "indexmap", - "slab", - "tokio", - "tokio-util", - "tracing", -] - [[package]] name = "hashbrown" version = "0.15.5" @@ -787,12 +766,6 @@ version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" -[[package]] -name = "httpdate" -version = "1.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" - [[package]] name = "hyper" version = "1.8.1" @@ -803,11 +776,9 @@ dependencies = [ "bytes", "futures-channel", "futures-core", - "h2", "http", "http-body", "httparse", - "httpdate", "itoa", "pin-project-lite", "pin-utils", @@ -1188,31 +1159,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "mocktail" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "053f7ba52863e22dfd2970075bbc69c4224ca6ae03896a5f69a0d5982deb5e0a" -dependencies = [ - "bytes", - "futures", - "http", - "http-body", - "http-body-util", - "hyper", - "hyper-util", - "prost", - "rand 0.9.2", - "serde", - "serde_json", - "thiserror 2.0.18", - "tokio", - "tokio-stream", - "tracing", - "url", - "uuid", -] - [[package]] name = "moka" version = "0.12.13" @@ -1445,23 +1391,10 @@ dependencies = [ ] [[package]] -name = "prost" -version = "0.13.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2796faa41db3ec313a31f7624d9286acf277b52de526150b7e69f3debf891ee5" +name = "protected-derive" +version = "0.1.0-pre2" +source = "git+https://github.com/cipherstash/vitaminc?branch=timing-safe-eq#5edd32e990dff79c765adeecf1cd9ac9d2945a71" dependencies = [ - "bytes", - "prost-derive", -] - -[[package]] -name = "prost-derive" -version = "0.13.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a56d757972c98b346a9b766e3f02746cde6dd1cd1d1d563472929fdd74bec4d" -dependencies = [ - "anyhow", - "itertools", "proc-macro2", "quote", "syn 2.0.115", @@ -1976,41 +1909,6 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" -[[package]] -name = "stack-auth" -version = "0.1.0" -dependencies = [ - "cts-common", - "open", - "reqwest", - "serde", - "specta", - "thiserror 1.0.69", - "tokio", - "tracing", - "url", - "vitaminc", - "zeroize", -] - -[[package]] -name = "stack-auth-node" -version = "0.1.0" -dependencies = [ - "cts-common", - "mocktail", - "napi", - "napi-build", - "napi-derive", - "regex", - "serde_json", - "specta", - "stack-auth", - "syn 2.0.115", - "tokio", - "url", -] - [[package]] name = "subtle" version = "2.6.1" @@ -2224,17 +2122,6 @@ dependencies = [ "tokio", ] -[[package]] -name = "tokio-stream" -version = "0.1.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" -dependencies = [ - "futures-core", - "pin-project-lite", - "tokio", -] - [[package]] name = "tokio-util" version = "0.7.18" @@ -2442,7 +2329,6 @@ checksum = "b672338555252d43fd2240c714dc444b8c6fb0a5c5335e65a07bba7742735ddb" dependencies = [ "getrandom 0.4.1", "js-sys", - "rand 0.9.2", "serde_core", "sha1_smol", "wasm-bindgen", @@ -2456,8 +2342,8 @@ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" [[package]] name = "vitaminc" -version = "0.1.0-pre4" -source = "git+https://github.com/cipherstash/vitaminc?branch=fix%2Fopaque-debug-unused-results#bb6387850102619a0ce2d2050adecbed6543cf88" +version = "0.1.0-pre2" +source = "git+https://github.com/cipherstash/vitaminc?branch=timing-safe-eq#5edd32e990dff79c765adeecf1cd9ac9d2945a71" dependencies = [ "vitaminc-encrypt", "vitaminc-protected", @@ -2467,8 +2353,8 @@ dependencies = [ [[package]] name = "vitaminc-aead" -version = "0.1.0-pre4" -source = "git+https://github.com/cipherstash/vitaminc?branch=fix%2Fopaque-debug-unused-results#bb6387850102619a0ce2d2050adecbed6543cf88" +version = "0.1.0-pre2" +source = "git+https://github.com/cipherstash/vitaminc?branch=timing-safe-eq#5edd32e990dff79c765adeecf1cd9ac9d2945a71" dependencies = [ "bytes", "serde", @@ -2479,8 +2365,8 @@ dependencies = [ [[package]] name = "vitaminc-encrypt" -version = "0.1.0-pre4" -source = "git+https://github.com/cipherstash/vitaminc?branch=fix%2Fopaque-debug-unused-results#bb6387850102619a0ce2d2050adecbed6543cf88" +version = "0.1.0-pre2" +source = "git+https://github.com/cipherstash/vitaminc?branch=timing-safe-eq#5edd32e990dff79c765adeecf1cd9ac9d2945a71" dependencies = [ "aws-lc-rs", "vitaminc-aead", @@ -2491,56 +2377,35 @@ dependencies = [ [[package]] name = "vitaminc-protected" -version = "0.1.0-pre4" -source = "git+https://github.com/cipherstash/vitaminc?branch=fix%2Fopaque-debug-unused-results#bb6387850102619a0ce2d2050adecbed6543cf88" +version = "0.1.0-pre2" +source = "git+https://github.com/cipherstash/vitaminc?branch=timing-safe-eq#5edd32e990dff79c765adeecf1cd9ac9d2945a71" dependencies = [ "bitvec", "digest", "opaque-debug", + "protected-derive", "serde", "serde_bytes", "subtle", - "vitaminc-protected-derive", "zeroize", ] -[[package]] -name = "vitaminc-protected-derive" -version = "0.1.0-pre4" -source = "git+https://github.com/cipherstash/vitaminc?branch=fix%2Fopaque-debug-unused-results#bb6387850102619a0ce2d2050adecbed6543cf88" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.115", -] - [[package]] name = "vitaminc-random" -version = "0.1.0-pre4" -source = "git+https://github.com/cipherstash/vitaminc?branch=fix%2Fopaque-debug-unused-results#bb6387850102619a0ce2d2050adecbed6543cf88" +version = "0.1.0-pre2" +source = "git+https://github.com/cipherstash/vitaminc?branch=timing-safe-eq#5edd32e990dff79c765adeecf1cd9ac9d2945a71" dependencies = [ "rand 0.8.5", "rand_chacha 0.3.1", "thiserror 1.0.69", "vitaminc-protected", - "vitaminc-random-derives", "zeroize", ] -[[package]] -name = "vitaminc-random-derives" -version = "0.1.0-pre4" -source = "git+https://github.com/cipherstash/vitaminc?branch=fix%2Fopaque-debug-unused-results#bb6387850102619a0ce2d2050adecbed6543cf88" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.115", -] - [[package]] name = "vitaminc-traits" -version = "0.1.0-pre4" -source = "git+https://github.com/cipherstash/vitaminc?branch=fix%2Fopaque-debug-unused-results#bb6387850102619a0ce2d2050adecbed6543cf88" +version = "0.1.0-pre2" +source = "git+https://github.com/cipherstash/vitaminc?branch=timing-safe-eq#5edd32e990dff79c765adeecf1cd9ac9d2945a71" dependencies = [ "anyhow", "bytes", diff --git a/languages/typescript/packages/auth/package-lock.json b/languages/typescript/packages/auth/package-lock.json new file mode 100644 index 000000000..3443a14e2 --- /dev/null +++ b/languages/typescript/packages/auth/package-lock.json @@ -0,0 +1,32 @@ +{ + "name": "@cipherstash/cts-auth", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@cipherstash/cts-auth", + "version": "0.1.0", + "devDependencies": { + "@napi-rs/cli": "^2" + } + }, + "node_modules/@napi-rs/cli": { + "version": "2.18.4", + "resolved": "https://registry.npmjs.org/@napi-rs/cli/-/cli-2.18.4.tgz", + "integrity": "sha512-SgJeA4df9DE2iAEpr3M2H0OKl/yjtg1BnRI5/JyowS71tUWhrfSu2LT0V3vlHET+g1hBVlrO60PmEXwUEKp8Mg==", + "dev": true, + "license": "MIT", + "bin": { + "napi": "scripts/index.js" + }, + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + } + } +} From 11c8b5491b49b718741f8e7d6a92936247fa49d3 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Sun, 15 Feb 2026 13:15:54 +1100 Subject: [PATCH 008/686] =?UTF-8?q?test:=20=F0=9F=A7=AA=20add=20unit=20tes?= =?UTF-8?q?ts=20for=20cts-auth=20napi-rs=20node=20binding=20layer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tests cover error mapping, napi error formatting, field getters, poll-for-token flow, consumed handle semantics, and invalid region handling — all in pure Rust with no Node.js runtime required. --- languages/typescript/packages/auth/Cargo.lock | 124 +++++++++ languages/typescript/packages/auth/Cargo.toml | 7 + languages/typescript/packages/auth/src/lib.rs | 256 ++++++++++++++++++ 3 files changed, 387 insertions(+) diff --git a/languages/typescript/packages/auth/Cargo.lock b/languages/typescript/packages/auth/Cargo.lock index 1bf7017e6..0e500ae6d 100644 --- a/languages/typescript/packages/auth/Cargo.lock +++ b/languages/typescript/packages/auth/Cargo.lock @@ -370,12 +370,15 @@ version = "0.1.0" dependencies = [ "cts-auth", "cts-common", + "mocktail", "napi", "napi-build", "napi-derive", "regex", + "serde_json", "specta", "syn 2.0.115", + "tokio", "url", ] @@ -516,6 +519,12 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + [[package]] name = "foldhash" version = "0.1.5" @@ -543,6 +552,21 @@ version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" +[[package]] +name = "futures" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + [[package]] name = "futures-channel" version = "0.3.31" @@ -550,6 +574,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" dependencies = [ "futures-core", + "futures-sink", ] [[package]] @@ -558,6 +583,17 @@ version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" +[[package]] +name = "futures-executor" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + [[package]] name = "futures-io" version = "0.3.31" @@ -593,6 +629,7 @@ version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" dependencies = [ + "futures-channel", "futures-core", "futures-io", "futures-macro", @@ -660,6 +697,25 @@ version = "0.32.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" +[[package]] +name = "h2" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + [[package]] name = "hashbrown" version = "0.15.5" @@ -766,6 +822,12 @@ version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + [[package]] name = "hyper" version = "1.8.1" @@ -776,9 +838,11 @@ dependencies = [ "bytes", "futures-channel", "futures-core", + "h2", "http", "http-body", "httparse", + "httpdate", "itoa", "pin-project-lite", "pin-utils", @@ -1159,6 +1223,31 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "mocktail" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053f7ba52863e22dfd2970075bbc69c4224ca6ae03896a5f69a0d5982deb5e0a" +dependencies = [ + "bytes", + "futures", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "prost", + "rand 0.9.2", + "serde", + "serde_json", + "thiserror 2.0.18", + "tokio", + "tokio-stream", + "tracing", + "url", + "uuid", +] + [[package]] name = "moka" version = "0.12.13" @@ -1390,6 +1479,29 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "prost" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2796faa41db3ec313a31f7624d9286acf277b52de526150b7e69f3debf891ee5" +dependencies = [ + "bytes", + "prost-derive", +] + +[[package]] +name = "prost-derive" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a56d757972c98b346a9b766e3f02746cde6dd1cd1d1d563472929fdd74bec4d" +dependencies = [ + "anyhow", + "itertools", + "proc-macro2", + "quote", + "syn 2.0.115", +] + [[package]] name = "protected-derive" version = "0.1.0-pre2" @@ -2122,6 +2234,17 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-stream" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + [[package]] name = "tokio-util" version = "0.7.18" @@ -2329,6 +2452,7 @@ checksum = "b672338555252d43fd2240c714dc444b8c6fb0a5c5335e65a07bba7742735ddb" dependencies = [ "getrandom 0.4.1", "js-sys", + "rand 0.9.2", "serde_core", "sha1_smol", "wasm-bindgen", diff --git a/languages/typescript/packages/auth/Cargo.toml b/languages/typescript/packages/auth/Cargo.toml index 05fce4446..be6423556 100644 --- a/languages/typescript/packages/auth/Cargo.toml +++ b/languages/typescript/packages/auth/Cargo.toml @@ -16,6 +16,13 @@ specta = { version = "1", features = ["typescript"] } syn = { version = "2", features = ["full", "parsing"] } url = { version = "2", optional = true } +[dev-dependencies] +cts-auth = { path = "..", features = ["test-utils"] } +mocktail = "0.3.0" +serde_json = "1" +tokio = { version = "1", features = ["macros", "rt-multi-thread", "test-util"] } +url = "2" + [build-dependencies] napi-build = "2" diff --git a/languages/typescript/packages/auth/src/lib.rs b/languages/typescript/packages/auth/src/lib.rs index 1f854b836..01e08f2eb 100644 --- a/languages/typescript/packages/auth/src/lib.rs +++ b/languages/typescript/packages/auth/src/lib.rs @@ -32,6 +32,7 @@ fn to_napi_error(err: AuthError) -> napi::Error { // TokenResult — plain data object // --------------------------------------------------------------------------- +#[derive(Debug)] #[napi(object)] pub struct TokenResult { /// The OAuth access token. @@ -46,6 +47,7 @@ pub struct TokenResult { // DeviceCodeResult — class with methods // --------------------------------------------------------------------------- +#[derive(Debug)] #[napi] pub struct DeviceCodeResult { /// The short code the user must enter to authorize this device. @@ -159,6 +161,260 @@ pub async fn begin_device_code_flow( Ok(DeviceCodeResult::from_pending(pending)) } +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod tests { + use super::*; + use cts_common::Region; + use mocktail::prelude::*; + + // --- Mock response builders (mirrors cts-auth/src/device_code.rs) --- + + fn device_code_json() -> serde_json::Value { + serde_json::json!({ + "device_code": "test_device_code", + "user_code": "ABCD-EFGH", + "verification_uri": "http://example.com/activate", + "verification_uri_complete": "http://example.com/activate?user_code=ABCD-EFGH", + "expires_in": 900 + }) + } + + fn token_json() -> serde_json::Value { + serde_json::json!({ + "access_token": "test_access_token_value", + "token_type": "Bearer", + "expires_in": 3600 + }) + } + + fn error_json(error: &str) -> serde_json::Value { + serde_json::json!({ + "error": error, + "error_description": format!("{error} occurred") + }) + } + + fn mock_code_endpoint(mocks: &mut MockSet) { + mocks.mock(|when, then| { + when.post().path("/oauth/device/code"); + then.json(device_code_json()); + }); + } + + async fn start_server(mocks: MockSet) -> MockServer { + let server = MockServer::new_http("cts-auth-node-test").with_mocks(mocks); + server.start().await.unwrap(); + server + } + + /// Create a `DeviceCodeResult` by running the real `DeviceCodeStrategy` + /// against a mock server, then wrapping the `PendingDeviceCode`. + async fn begin_result(server: &MockServer) -> DeviceCodeResult { + let strategy = + DeviceCodeStrategy::new(Region::aws("ap-southeast-2").unwrap(), "test-client") + .unwrap() + .with_base_url(server.url("")) + .unwrap(); + let pending = strategy.begin().await.unwrap(); + DeviceCodeResult::from_pending(pending) + } + + // ---- Error mapping (no mock server needed) ---- + + #[test] + fn test_error_code_mapping() { + assert_eq!(error_code(&AuthError::AccessDenied), "ACCESS_DENIED"); + assert_eq!(error_code(&AuthError::ExpiredToken), "EXPIRED_TOKEN"); + assert_eq!(error_code(&AuthError::InvalidGrant), "INVALID_GRANT"); + assert_eq!(error_code(&AuthError::InvalidClient), "INVALID_CLIENT"); + assert_eq!( + error_code(&AuthError::InvalidUrl( + "http://[".parse::().unwrap_err() + )), + "INVALID_URL" + ); + assert_eq!( + error_code(&AuthError::Region(Region::new("invalid").unwrap_err())), + "INVALID_REGION" + ); + assert_eq!( + error_code(&AuthError::Server("test".to_string())), + "SERVER_ERROR" + ); + } + + #[test] + fn test_napi_error_format() { + let err = to_napi_error(AuthError::AccessDenied); + assert!( + err.reason.starts_with("ACCESS_DENIED: "), + "expected 'ACCESS_DENIED: ...' but got: {}", + err.reason + ); + + let err = to_napi_error(AuthError::Server("something broke".to_string())); + assert!( + err.reason.starts_with("SERVER_ERROR: "), + "expected 'SERVER_ERROR: ...' but got: {}", + err.reason + ); + } + + // ---- Getters (mock server needed to create a real PendingDeviceCode) ---- + + #[tokio::test] + async fn test_getters() { + let mut mocks = MockSet::new(); + mock_code_endpoint(&mut mocks); + let server = start_server(mocks).await; + + let result = begin_result(&server).await; + + assert_eq!(result.user_code(), "ABCD-EFGH"); + assert_eq!(result.verification_uri(), "http://example.com/activate"); + assert_eq!( + result.verification_uri_complete(), + "http://example.com/activate?user_code=ABCD-EFGH" + ); + assert_eq!(result.expires_in(), 900.0); + } + + // ---- Full flow with mock server ---- + // + // `start_paused = true` creates a tokio runtime where the internal clock + // is paused. Timer operations like `tokio::time::sleep` advance the clock + // instantly instead of waiting in real-time. This matters because + // `poll_for_token` sleeps 5 seconds between each poll — without paused + // time these tests would take 5+ real seconds each. I/O (HTTP requests + // to the mock server) still works normally. + + #[tokio::test(start_paused = true)] + async fn test_poll_for_token_success() { + let mut mocks = MockSet::new(); + mock_code_endpoint(&mut mocks); + mocks.mock(|when, then| { + when.post().path("/oauth/device/token"); + then.json(token_json()); + }); + let server = start_server(mocks).await; + + let result = begin_result(&server).await; + let token = result.poll_for_token().await.unwrap(); + + assert_eq!(token.access_token, "test_access_token_value"); + assert_eq!(token.token_type, "Bearer"); + assert_eq!(token.expires_in, 3600.0); + } + + #[tokio::test(start_paused = true)] + async fn test_poll_for_token_error_propagation() { + let mut mocks = MockSet::new(); + mock_code_endpoint(&mut mocks); + mocks.mock(|when, then| { + when.post().path("/oauth/device/token"); + then.bad_request().json(error_json("access_denied")); + }); + let server = start_server(mocks).await; + + let result = begin_result(&server).await; + let err = result.poll_for_token().await.unwrap_err(); + + assert!( + err.reason.contains("ACCESS_DENIED: "), + "expected ACCESS_DENIED error, got: {}", + err.reason + ); + } + + #[tokio::test(start_paused = true)] + async fn test_poll_for_token_expired() { + let mut mocks = MockSet::new(); + mock_code_endpoint(&mut mocks); + mocks.mock(|when, then| { + when.post().path("/oauth/device/token"); + then.bad_request().json(error_json("expired_token")); + }); + let server = start_server(mocks).await; + + let result = begin_result(&server).await; + let err = result.poll_for_token().await.unwrap_err(); + + assert!( + err.reason.contains("EXPIRED_TOKEN: "), + "expected EXPIRED_TOKEN error, got: {}", + err.reason + ); + } + + // ---- Consumed handle semantics ---- + + #[tokio::test(start_paused = true)] + async fn test_poll_for_token_already_consumed() { + let mut mocks = MockSet::new(); + mock_code_endpoint(&mut mocks); + mocks.mock(|when, then| { + when.post().path("/oauth/device/token"); + then.json(token_json()); + }); + let server = start_server(mocks).await; + + let result = begin_result(&server).await; + // First call succeeds — consumes the handle + result.poll_for_token().await.unwrap(); + // Second call should fail — handle already consumed + let err = result.poll_for_token().await.unwrap_err(); + + assert!( + err.reason.contains("already been consumed"), + "expected 'already been consumed' error, got: {}", + err.reason + ); + } + + #[tokio::test(start_paused = true)] + async fn test_open_in_browser_after_consumed() { + let mut mocks = MockSet::new(); + mock_code_endpoint(&mut mocks); + mocks.mock(|when, then| { + when.post().path("/oauth/device/token"); + then.json(token_json()); + }); + let server = start_server(mocks).await; + + let result = begin_result(&server).await; + // Consume the handle + result.poll_for_token().await.unwrap(); + // open_in_browser should fail — handle consumed + let err = result.open_in_browser().unwrap_err(); + + assert!( + err.reason.contains("already been consumed"), + "expected 'already been consumed' error, got: {}", + err.reason + ); + } + + // ---- Top-level function error handling ---- + + #[tokio::test] + async fn test_begin_invalid_region() { + let err = begin_device_code_flow("not-a-region".to_string(), "test-client".to_string()) + .await + .unwrap_err(); + + assert!( + err.reason.contains("INVALID_REGION: "), + "expected INVALID_REGION error, got: {}", + err.reason + ); + } +} + /// Variant of `beginDeviceCodeFlow` that targets a custom auth server URL. /// /// Intended for **testing only** — requires the crate to be built with the From 27ae674f2d6acc6d4acc7d92fed5f707f78793e7 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Sun, 15 Feb 2026 14:12:50 +1100 Subject: [PATCH 009/686] =?UTF-8?q?test:=20=F0=9F=A7=AA=20add=20vitest=20T?= =?UTF-8?q?ypeScript=20tests=20and=20MockAuthServer=20napi=20binding?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a second testing layer that exercises the JavaScript interface via vitest — covering error enrichment (.code parsing in index.js), TypeScript type correctness, and end-to-end device code flow through the CJS module. Introduces a MockAuthServer napi class (behind the test-utils Cargo feature) that wraps mocktail's MockServer with focused auth endpoint methods, enabling TypeScript integration tests without external services. --- languages/typescript/packages/auth/Cargo.toml | 6 +- .../auth/__tests__/device-code-flow.test.ts | 146 ++ .../packages/auth/package-lock.json | 1588 ++++++++++++++++- .../typescript/packages/auth/package.json | 6 +- languages/typescript/packages/auth/src/lib.rs | 7 +- .../packages/auth/src/mock_auth_server.rs | 78 + .../typescript/packages/auth/test-utils.d.ts | 58 + .../typescript/packages/auth/tsconfig.json | 11 + .../typescript/packages/auth/vitest.config.ts | 7 + 9 files changed, 1901 insertions(+), 6 deletions(-) create mode 100644 languages/typescript/packages/auth/__tests__/device-code-flow.test.ts create mode 100644 languages/typescript/packages/auth/src/mock_auth_server.rs create mode 100644 languages/typescript/packages/auth/test-utils.d.ts create mode 100644 languages/typescript/packages/auth/tsconfig.json create mode 100644 languages/typescript/packages/auth/vitest.config.ts diff --git a/languages/typescript/packages/auth/Cargo.toml b/languages/typescript/packages/auth/Cargo.toml index be6423556..f4f97fcd4 100644 --- a/languages/typescript/packages/auth/Cargo.toml +++ b/languages/typescript/packages/auth/Cargo.toml @@ -15,9 +15,11 @@ regex = "1" specta = { version = "1", features = ["typescript"] } syn = { version = "2", features = ["full", "parsing"] } url = { version = "2", optional = true } +mocktail = { version = "0.3.0", optional = true } +serde_json = { version = "1", optional = true } [dev-dependencies] -cts-auth = { path = "..", features = ["test-utils"] } +stack-auth = { path = "..", features = ["test-utils"] } mocktail = "0.3.0" serde_json = "1" tokio = { version = "1", features = ["macros", "rt-multi-thread", "test-util"] } @@ -27,7 +29,7 @@ url = "2" napi-build = "2" [features] -test-utils = ["stack-auth/test-utils", "dep:url"] +test-utils = ["stack-auth/test-utils", "dep:url", "dep:mocktail", "dep:serde_json"] [[bin]] name = "generate-types" diff --git a/languages/typescript/packages/auth/__tests__/device-code-flow.test.ts b/languages/typescript/packages/auth/__tests__/device-code-flow.test.ts new file mode 100644 index 000000000..448e6d27d --- /dev/null +++ b/languages/typescript/packages/auth/__tests__/device-code-flow.test.ts @@ -0,0 +1,146 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import type { MockAuthServer as MockAuthServerType } from "../test-utils"; +import type { + DeviceCodeResult, + TokenResult, + AuthError, +} from "../index"; + +// Load the CJS module — includes MockAuthServer when built with test-utils. +const mod = require("../index.js") as typeof import("../index") & { + MockAuthServer: typeof MockAuthServerType; +}; + +const { + beginDeviceCodeFlow, + beginDeviceCodeFlowWithBaseUrl, + MockAuthServer, +} = mod; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +let server: InstanceType; + +async function startServer(): Promise> { + const s = await MockAuthServer.start(); + s.mockDeviceCodeEndpoint(); + return s; +} + +async function beginFlow(): Promise { + return beginDeviceCodeFlowWithBaseUrl( + "ap-southeast-2.aws", + "test-client", + server.baseUrl, + ); +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe("device code flow (TypeScript / vitest)", () => { + // ---------- Error enrichment (no server needed) ---------- + + it("attaches .code for INVALID_REGION", async () => { + try { + await beginDeviceCodeFlow("not-a-region", "test-client"); + expect.unreachable("should have thrown"); + } catch (err) { + const authErr = err as AuthError; + expect(authErr).toBeInstanceOf(Error); + expect(authErr.code).toBe("INVALID_REGION"); + } + }); + + it("beginDeviceCodeFlowWithBaseUrl rejects for invalid region", async () => { + try { + await beginDeviceCodeFlowWithBaseUrl( + "not-a-region", + "test-client", + "http://localhost:9999", + ); + expect.unreachable("should have thrown"); + } catch (err) { + const authErr = err as AuthError; + expect(authErr).toBeInstanceOf(Error); + expect(authErr.code).toBe("INVALID_REGION"); + } + }); + + // ---------- Tests that need the mock server ---------- + + describe("with mock server", () => { + beforeEach(async () => { + server = await startServer(); + }); + + it("exposes getter fields on DeviceCodeResult", async () => { + const result = await beginFlow(); + + expect(result.userCode).toBe("ABCD-EFGH"); + expect(result.verificationUri).toBe("http://example.com/activate"); + expect(result.verificationUriComplete).toBe( + "http://example.com/activate?user_code=ABCD-EFGH", + ); + expect(result.expiresIn).toBe(900); + }); + + it("pollForToken resolves with token on success", async () => { + server.mockTokenEndpoint(); + const result = await beginFlow(); + const token: TokenResult = await result.pollForToken(); + + expect(token.accessToken).toBe("test_access_token_value"); + expect(token.tokenType).toBe("Bearer"); + expect(token.expiresIn).toBe(3600); + }); + + it("pollForToken rejects on second call (consumed handle)", async () => { + server.mockTokenEndpoint(); + const result = await beginFlow(); + + // First call succeeds — consumes the handle + await result.pollForToken(); + + // Second call should fail + try { + await result.pollForToken(); + expect.unreachable("should have thrown"); + } catch (err) { + expect(err).toBeInstanceOf(Error); + expect((err as Error).message).toMatch(/already been consumed/); + } + }); + + it("pollForToken rejects with enriched ACCESS_DENIED", async () => { + server.mockTokenEndpointError("access_denied"); + const result = await beginFlow(); + + try { + await result.pollForToken(); + expect.unreachable("should have thrown"); + } catch (err) { + const authErr = err as AuthError; + expect(authErr).toBeInstanceOf(Error); + expect(authErr.code).toBe("ACCESS_DENIED"); + } + }); + + it("pollForToken rejects with enriched EXPIRED_TOKEN", async () => { + server.mockTokenEndpointError("expired_token"); + const result = await beginFlow(); + + try { + await result.pollForToken(); + expect.unreachable("should have thrown"); + } catch (err) { + const authErr = err as AuthError; + expect(authErr).toBeInstanceOf(Error); + expect(authErr.code).toBe("EXPIRED_TOKEN"); + } + }); + }); +}); diff --git a/languages/typescript/packages/auth/package-lock.json b/languages/typescript/packages/auth/package-lock.json index 3443a14e2..ee87e6282 100644 --- a/languages/typescript/packages/auth/package-lock.json +++ b/languages/typescript/packages/auth/package-lock.json @@ -8,9 +8,460 @@ "name": "@cipherstash/cts-auth", "version": "0.1.0", "devDependencies": { - "@napi-rs/cli": "^2" + "@napi-rs/cli": "^2", + "typescript": "^5", + "vitest": "^3" } }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", + "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz", + "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz", + "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz", + "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz", + "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz", + "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz", + "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz", + "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz", + "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz", + "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz", + "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz", + "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz", + "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz", + "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz", + "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz", + "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz", + "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz", + "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz", + "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz", + "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz", + "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz", + "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz", + "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz", + "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz", + "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz", + "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, "node_modules/@napi-rs/cli": { "version": "2.18.4", "resolved": "https://registry.npmjs.org/@napi-rs/cli/-/cli-2.18.4.tgz", @@ -27,6 +478,1141 @@ "type": "github", "url": "https://github.com/sponsors/Brooooooklyn" } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.1.tgz", + "integrity": "sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.57.1.tgz", + "integrity": "sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.57.1.tgz", + "integrity": "sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.57.1.tgz", + "integrity": "sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.57.1.tgz", + "integrity": "sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.57.1.tgz", + "integrity": "sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.57.1.tgz", + "integrity": "sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.57.1.tgz", + "integrity": "sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.57.1.tgz", + "integrity": "sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.57.1.tgz", + "integrity": "sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.57.1.tgz", + "integrity": "sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.57.1.tgz", + "integrity": "sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.57.1.tgz", + "integrity": "sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.57.1.tgz", + "integrity": "sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.57.1.tgz", + "integrity": "sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.57.1.tgz", + "integrity": "sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.57.1.tgz", + "integrity": "sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.57.1.tgz", + "integrity": "sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.57.1.tgz", + "integrity": "sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.57.1.tgz", + "integrity": "sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.57.1.tgz", + "integrity": "sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.57.1.tgz", + "integrity": "sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.57.1.tgz", + "integrity": "sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.57.1.tgz", + "integrity": "sha512-VMBH2eOOaKGtIJYleXsi2B8CPVADrh+TyNxJ4mWPnKfLB/DBUmzW+5m1xUrcwWoMfSLagIRpjUFeW5CO5hyciQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.57.1.tgz", + "integrity": "sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitest/expect": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz", + "integrity": "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/spy": "3.2.4", + "@vitest/utils": "3.2.4", + "chai": "^5.2.0", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.4.tgz", + "integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "3.2.4", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.17" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz", + "integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.4.tgz", + "integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "3.2.4", + "pathe": "^2.0.3", + "strip-literal": "^3.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.4.tgz", + "integrity": "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.4", + "magic-string": "^0.30.17", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.4.tgz", + "integrity": "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^4.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.4.tgz", + "integrity": "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.4", + "loupe": "^3.1.4", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", + "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.3", + "@esbuild/android-arm": "0.27.3", + "@esbuild/android-arm64": "0.27.3", + "@esbuild/android-x64": "0.27.3", + "@esbuild/darwin-arm64": "0.27.3", + "@esbuild/darwin-x64": "0.27.3", + "@esbuild/freebsd-arm64": "0.27.3", + "@esbuild/freebsd-x64": "0.27.3", + "@esbuild/linux-arm": "0.27.3", + "@esbuild/linux-arm64": "0.27.3", + "@esbuild/linux-ia32": "0.27.3", + "@esbuild/linux-loong64": "0.27.3", + "@esbuild/linux-mips64el": "0.27.3", + "@esbuild/linux-ppc64": "0.27.3", + "@esbuild/linux-riscv64": "0.27.3", + "@esbuild/linux-s390x": "0.27.3", + "@esbuild/linux-x64": "0.27.3", + "@esbuild/netbsd-arm64": "0.27.3", + "@esbuild/netbsd-x64": "0.27.3", + "@esbuild/openbsd-arm64": "0.27.3", + "@esbuild/openbsd-x64": "0.27.3", + "@esbuild/openharmony-arm64": "0.27.3", + "@esbuild/sunos-x64": "0.27.3", + "@esbuild/win32-arm64": "0.27.3", + "@esbuild/win32-ia32": "0.27.3", + "@esbuild/win32-x64": "0.27.3" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rollup": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.57.1.tgz", + "integrity": "sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.57.1", + "@rollup/rollup-android-arm64": "4.57.1", + "@rollup/rollup-darwin-arm64": "4.57.1", + "@rollup/rollup-darwin-x64": "4.57.1", + "@rollup/rollup-freebsd-arm64": "4.57.1", + "@rollup/rollup-freebsd-x64": "4.57.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.57.1", + "@rollup/rollup-linux-arm-musleabihf": "4.57.1", + "@rollup/rollup-linux-arm64-gnu": "4.57.1", + "@rollup/rollup-linux-arm64-musl": "4.57.1", + "@rollup/rollup-linux-loong64-gnu": "4.57.1", + "@rollup/rollup-linux-loong64-musl": "4.57.1", + "@rollup/rollup-linux-ppc64-gnu": "4.57.1", + "@rollup/rollup-linux-ppc64-musl": "4.57.1", + "@rollup/rollup-linux-riscv64-gnu": "4.57.1", + "@rollup/rollup-linux-riscv64-musl": "4.57.1", + "@rollup/rollup-linux-s390x-gnu": "4.57.1", + "@rollup/rollup-linux-x64-gnu": "4.57.1", + "@rollup/rollup-linux-x64-musl": "4.57.1", + "@rollup/rollup-openbsd-x64": "4.57.1", + "@rollup/rollup-openharmony-arm64": "4.57.1", + "@rollup/rollup-win32-arm64-msvc": "4.57.1", + "@rollup/rollup-win32-ia32-msvc": "4.57.1", + "@rollup/rollup-win32-x64-gnu": "4.57.1", + "@rollup/rollup-win32-x64-msvc": "4.57.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/strip-literal": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", + "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz", + "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/vite": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", + "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", + "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.4.1", + "es-module-lexer": "^1.7.0", + "pathe": "^2.0.3", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz", + "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/expect": "3.2.4", + "@vitest/mocker": "3.2.4", + "@vitest/pretty-format": "^3.2.4", + "@vitest/runner": "3.2.4", + "@vitest/snapshot": "3.2.4", + "@vitest/spy": "3.2.4", + "@vitest/utils": "3.2.4", + "chai": "^5.2.0", + "debug": "^4.4.1", + "expect-type": "^1.2.1", + "magic-string": "^0.30.17", + "pathe": "^2.0.3", + "picomatch": "^4.0.2", + "std-env": "^3.9.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.14", + "tinypool": "^1.1.1", + "tinyrainbow": "^2.0.0", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", + "vite-node": "3.2.4", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/debug": "^4.1.12", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@vitest/browser": "3.2.4", + "@vitest/ui": "3.2.4", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/debug": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } } } } diff --git a/languages/typescript/packages/auth/package.json b/languages/typescript/packages/auth/package.json index 74f52a04c..7899fc724 100644 --- a/languages/typescript/packages/auth/package.json +++ b/languages/typescript/packages/auth/package.json @@ -10,9 +10,13 @@ "scripts": { "build": "napi build --release", "build:debug": "napi build", + "build:test": "napi build --features test-utils", + "test": "npm run build:test && vitest run", "generate-types": "cargo run --bin generate-types" }, "devDependencies": { - "@napi-rs/cli": "^2" + "@napi-rs/cli": "^2", + "vitest": "^3", + "typescript": "^5" } } diff --git a/languages/typescript/packages/auth/src/lib.rs b/languages/typescript/packages/auth/src/lib.rs index 01e08f2eb..07c6ea81b 100644 --- a/languages/typescript/packages/auth/src/lib.rs +++ b/languages/typescript/packages/auth/src/lib.rs @@ -5,6 +5,9 @@ use cts_common::Region; use napi::bindgen_prelude::*; use napi_derive::napi; +#[cfg(feature = "test-utils")] +mod mock_auth_server; + // --------------------------------------------------------------------------- // Error helpers // --------------------------------------------------------------------------- @@ -172,7 +175,7 @@ mod tests { use cts_common::Region; use mocktail::prelude::*; - // --- Mock response builders (mirrors cts-auth/src/device_code.rs) --- + // --- Mock response builders (mirrors stack-auth/src/device_code.rs) --- fn device_code_json() -> serde_json::Value { serde_json::json!({ @@ -207,7 +210,7 @@ mod tests { } async fn start_server(mocks: MockSet) -> MockServer { - let server = MockServer::new_http("cts-auth-node-test").with_mocks(mocks); + let server = MockServer::new_http("stack-auth-node-test").with_mocks(mocks); server.start().await.unwrap(); server } diff --git a/languages/typescript/packages/auth/src/mock_auth_server.rs b/languages/typescript/packages/auth/src/mock_auth_server.rs new file mode 100644 index 000000000..85ad1997e --- /dev/null +++ b/languages/typescript/packages/auth/src/mock_auth_server.rs @@ -0,0 +1,78 @@ +use mocktail::prelude::*; +use napi::bindgen_prelude::*; +use napi_derive::napi; + +#[napi] +pub struct MockAuthServer { + server: MockServer, +} + +#[napi] +impl MockAuthServer { + /// Start a mock auth server on a random port. + #[napi(factory)] + pub async fn start() -> Result { + let server = MockServer::new_http("stack-auth-node-vitest"); + server + .start() + .await + .map_err(|e| napi::Error::new(Status::GenericFailure, format!("{e}")))?; + Ok(Self { server }) + } + + /// The base URL of the running mock server (e.g. `http://127.0.0.1:12345`). + #[napi(getter)] + pub fn base_url(&self) -> String { + self.server.url("").to_string() + } + + /// Register a mock for `POST /oauth/device/code` that returns a standard + /// device-code JSON response. + #[napi] + pub fn mock_device_code_endpoint(&self) { + self.server.mocks().mock(|when, then| { + when.post().path("/oauth/device/code"); + then.json(serde_json::json!({ + "device_code": "test_device_code", + "user_code": "ABCD-EFGH", + "verification_uri": "http://example.com/activate", + "verification_uri_complete": "http://example.com/activate?user_code=ABCD-EFGH", + "expires_in": 900 + })); + }); + } + + /// Register a mock for `POST /oauth/device/token` that returns a standard + /// token JSON response. + #[napi] + pub fn mock_token_endpoint(&self) { + self.server.mocks().mock(|when, then| { + when.post().path("/oauth/device/token"); + then.json(serde_json::json!({ + "access_token": "test_access_token_value", + "token_type": "Bearer", + "expires_in": 3600 + })); + }); + } + + /// Register a mock for `POST /oauth/device/token` that returns a 400 error + /// with the given OAuth error code and optional description. + #[napi] + pub fn mock_token_endpoint_error(&self, code: String, description: Option) { + let desc = description.unwrap_or_else(|| format!("{code} occurred")); + self.server.mocks().mock(|when, then| { + when.post().path("/oauth/device/token"); + then.bad_request().json(serde_json::json!({ + "error": code, + "error_description": desc, + })); + }); + } + + /// Remove all registered mocks. + #[napi] + pub fn clear_mocks(&self) { + self.server.mocks().clear(); + } +} diff --git a/languages/typescript/packages/auth/test-utils.d.ts b/languages/typescript/packages/auth/test-utils.d.ts new file mode 100644 index 000000000..e75942f9f --- /dev/null +++ b/languages/typescript/packages/auth/test-utils.d.ts @@ -0,0 +1,58 @@ +/** + * Type declarations for `@cipherstash/stack-auth` test utilities. + * + * These exports are only available when the native module is built with the + * `test-utils` Cargo feature (`napi build --features test-utils`). + */ + +/** + * A mock OAuth auth server for integration tests. + * + * Wraps an HTTP server that can be configured with canned responses for + * the device-code and token endpoints. + * + * @example + * ```ts + * const server = await MockAuthServer.start(); + * server.mockDeviceCodeEndpoint(); + * server.mockTokenEndpoint(); + * + * const result = await beginDeviceCodeFlowWithBaseUrl( + * "ap-southeast-2.aws", + * "test-client", + * server.baseUrl, + * ); + * const token = await result.pollForToken(); + * ``` + */ +export class MockAuthServer { + /** Start a mock auth server on a random port. */ + static start(): Promise; + + /** The base URL of the running mock server (e.g. `http://127.0.0.1:12345`). */ + readonly baseUrl: string; + + /** + * Register a mock for `POST /oauth/device/code` that returns a standard + * device-code JSON response. + */ + mockDeviceCodeEndpoint(): void; + + /** + * Register a mock for `POST /oauth/device/token` that returns a standard + * token JSON response. + */ + mockTokenEndpoint(): void; + + /** + * Register a mock for `POST /oauth/device/token` that returns a 400 error + * with the given OAuth error code and optional description. + * + * @param code - OAuth error code (e.g. `"access_denied"`, `"expired_token"`). + * @param description - Optional human-readable description. Defaults to `" occurred"`. + */ + mockTokenEndpointError(code: string, description?: string): void; + + /** Remove all registered mocks. */ + clearMocks(): void; +} diff --git a/languages/typescript/packages/auth/tsconfig.json b/languages/typescript/packages/auth/tsconfig.json new file mode 100644 index 000000000..adb4dcd76 --- /dev/null +++ b/languages/typescript/packages/auth/tsconfig.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ES2020", + "moduleResolution": "node", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true + }, + "include": ["__tests__/**/*.ts", "index.d.ts", "test-utils.d.ts"] +} diff --git a/languages/typescript/packages/auth/vitest.config.ts b/languages/typescript/packages/auth/vitest.config.ts new file mode 100644 index 000000000..2c01d68b0 --- /dev/null +++ b/languages/typescript/packages/auth/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + testTimeout: 30_000, + }, +}); From c4858441f3704b34a6c2e426519b3f18762707cd Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Sun, 15 Feb 2026 19:14:08 +1100 Subject: [PATCH 010/686] =?UTF-8?q?ci:=20=F0=9F=94=84=20add=20GitHub=20Act?= =?UTF-8?q?ions=20workflow=20for=20stack-auth=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Runs unit tests and stack-auth integration tests (napi bindings + vitest) on changes to packages/stack-auth/. --- .../imported-workflows/test-stack-auth.yml | 63 +++++++++++++++++++ 1 file changed, 63 insertions(+) create mode 100644 .github/imported-workflows/test-stack-auth.yml diff --git a/.github/imported-workflows/test-stack-auth.yml b/.github/imported-workflows/test-stack-auth.yml new file mode 100644 index 000000000..465822619 --- /dev/null +++ b/.github/imported-workflows/test-stack-auth.yml @@ -0,0 +1,63 @@ +name: "Run stack-auth tests" +on: + push: + branches: + - main + paths: + - packages/stack-auth/** + - .github/workflows/test-stack-auth.yml + # Ordering matters, this needs to stay at the end if we want to include the above, but exclude all .md files. + - "!**.md" + - "!**.example" + + pull_request: + branches: + - main + paths: + - packages/stack-auth/** + - .github/workflows/test-stack-auth.yml + # Ordering matters, this needs to stay at the end if we want to include the above, but exclude all .md files. + - "!**.md" + - "!**.example" + + workflow_dispatch: + +defaults: + run: + shell: bash -l {0} + +env: + RUSTFLAGS: "-D warnings" + RUST_BACKTRACE: full + CARGO_TERM_COLOR: always + CARGO_NET_GIT_FETCH_WITH_CLI: true + NEXTEST_PROFILE: ci + +jobs: + test-stack-auth: + runs-on: blacksmith-8vcpu-ubuntu-2404 + + steps: + - uses: actions/checkout@v4 + - uses: ./.github/actions/setup-rust + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + + - name: Fix permissions on target directory + run: | + mkdir -p ./target + sudo chown -R "$(id -u):$(id -g)" ./target + + - name: test-unit + run: mise run test:unit + + - name: test-integration-stack-auth + run: mise run test:integration:stack-auth + + - uses: ./.github/actions/send-slack-notification + with: + channel: engineering + webhook_url: ${{ secrets.SLACK_NOTIFICATION_WEBHOOK_URL }} From 694984805e71e8b2b97a353161e3a7638d9180c6 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Sun, 15 Feb 2026 20:39:37 +1100 Subject: [PATCH 011/686] =?UTF-8?q?chore:=20=F0=9F=A7=B9=20remove=20unused?= =?UTF-8?q?=20generate-types=20binary=20and=20specta=20dependency?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit NAPI-RS now auto-generates index.d.ts, making the generate-types binary and its specta/syn/regex dependencies redundant. --- languages/typescript/packages/auth/Cargo.lock | 261 ++++-------- languages/typescript/packages/auth/Cargo.toml | 9 +- .../typescript/packages/auth/package.json | 3 +- .../packages/auth/src/generate_types.rs | 401 ------------------ packages/stack-auth/Cargo.toml | 2 - packages/stack-auth/src/device_code/mod.rs | 6 - packages/stack-auth/src/lib.rs | 5 - 7 files changed, 90 insertions(+), 597 deletions(-) delete mode 100644 languages/typescript/packages/auth/src/generate_types.rs diff --git a/languages/typescript/packages/auth/Cargo.lock b/languages/typescript/packages/auth/Cargo.lock index 0e500ae6d..dbfd4e450 100644 --- a/languages/typescript/packages/auth/Cargo.lock +++ b/languages/typescript/packages/auth/Cargo.lock @@ -2,12 +2,6 @@ # It is not intended for manual editing. version = 4 -[[package]] -name = "Inflector" -version = "0.11.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe438c63458706e03479442743baae6c88256498e6431708f6dfc520a26515d3" - [[package]] name = "addr2line" version = "0.25.1" @@ -82,7 +76,7 @@ checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" dependencies = [ "proc-macro2", "quote", - "syn 2.0.115", + "syn", ] [[package]] @@ -344,42 +338,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a2785755761f3ddc1492979ce1e48d2c00d09311c39e4466429188f3dd6501" dependencies = [ "quote", - "syn 2.0.115", -] - -[[package]] -name = "cts-auth" -version = "0.1.0" -dependencies = [ - "cts-common", - "open", - "reqwest", - "serde", - "specta", - "thiserror 1.0.69", - "tokio", - "tracing", - "url", - "vitaminc", - "zeroize", -] - -[[package]] -name = "cts-auth-node" -version = "0.1.0" -dependencies = [ - "cts-auth", - "cts-common", - "mocktail", - "napi", - "napi-build", - "napi-derive", - "regex", - "serde_json", - "specta", - "syn 2.0.115", - "tokio", - "url", + "syn", ] [[package]] @@ -426,7 +385,7 @@ dependencies = [ "proc-macro2", "quote", "rustc_version", - "syn 2.0.115", + "syn", "unicode-xid", ] @@ -448,16 +407,7 @@ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" dependencies = [ "proc-macro2", "quote", - "syn 2.0.115", -] - -[[package]] -name = "document-features" -version = "0.2.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" -dependencies = [ - "litrs", + "syn", ] [[package]] @@ -484,7 +434,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.115", + "syn", ] [[package]] @@ -608,7 +558,7 @@ checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" dependencies = [ "proc-macro2", "quote", - "syn 2.0.115", + "syn", ] [[package]] @@ -1011,12 +961,6 @@ dependencies = [ "serde_core", ] -[[package]] -name = "indoc" -version = "1.0.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfa799dd5ed20a7e349f3b4639aa80d74549c81716d9ec4f994c9b5815598306" - [[package]] name = "ipconfig" version = "0.3.2" @@ -1072,9 +1016,9 @@ checksum = "7655c9839580ee829dfacba1d1278c2b7883e50a277ff7541299489d6bdfdc45" [[package]] name = "itertools" -version = "0.10.5" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" dependencies = [ "either", ] @@ -1139,12 +1083,6 @@ version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" -[[package]] -name = "litrs" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" - [[package]] name = "lock_api" version = "0.4.14" @@ -1199,7 +1137,7 @@ checksum = "db5b29714e950dbb20d5e6f74f9dcec4edbcc1067bb7f8ed198c097b8c1a818b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.115", + "syn", ] [[package]] @@ -1296,7 +1234,7 @@ dependencies = [ "napi-derive-backend", "proc-macro2", "quote", - "syn 2.0.115", + "syn", ] [[package]] @@ -1311,7 +1249,7 @@ dependencies = [ "quote", "regex", "semver", - "syn 2.0.115", + "syn", ] [[package]] @@ -1406,12 +1344,6 @@ dependencies = [ "windows-link", ] -[[package]] -name = "paste" -version = "1.0.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" - [[package]] name = "pathdiff" version = "0.2.3" @@ -1467,7 +1399,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" dependencies = [ "proc-macro2", - "syn 2.0.115", + "syn", ] [[package]] @@ -1499,17 +1431,7 @@ dependencies = [ "itertools", "proc-macro2", "quote", - "syn 2.0.115", -] - -[[package]] -name = "protected-derive" -version = "0.1.0-pre2" -source = "git+https://github.com/cipherstash/vitaminc?branch=timing-safe-eq#5edd32e990dff79c765adeecf1cd9ac9d2945a71" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.115", + "syn", ] [[package]] @@ -1897,7 +1819,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn 2.0.115", + "syn", ] [[package]] @@ -1986,41 +1908,42 @@ dependencies = [ ] [[package]] -name = "specta" -version = "1.0.5" +name = "stable_deref_trait" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2240c3aa020aa61d2c569087d213baafbb212f4ceb9de9dd162376ea6aa0fe3" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "stack-auth" +version = "0.1.0" dependencies = [ - "document-features", - "indoc", - "once_cell", - "paste", + "cts-common", + "open", + "reqwest", "serde", - "serde_json", - "specta-macros", "thiserror 1.0.69", + "tokio", + "tracing", + "url", + "vitaminc", + "zeroize", ] [[package]] -name = "specta-macros" -version = "1.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4605306321c356e03873b8ee71d7592a5e7c508add325c3ed0677c16fdf1bcfb" +name = "stack-auth-node" +version = "0.1.0" dependencies = [ - "Inflector", - "itertools", - "proc-macro2", - "quote", - "syn 1.0.109", - "termcolor", + "cts-common", + "mocktail", + "napi", + "napi-build", + "napi-derive", + "serde_json", + "stack-auth", + "tokio", + "url", ] -[[package]] -name = "stable_deref_trait" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" - [[package]] name = "subtle" version = "2.6.1" @@ -2048,17 +1971,6 @@ version = "3.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7401a30af6cb5818bb64852270bb722533397edcfc7344954a38f420819ece2" -[[package]] -name = "syn" -version = "1.0.109" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - [[package]] name = "syn" version = "2.0.115" @@ -2087,7 +1999,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn 2.0.115", + "syn", ] [[package]] @@ -2102,15 +2014,6 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" -[[package]] -name = "termcolor" -version = "1.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" -dependencies = [ - "winapi-util", -] - [[package]] name = "terminal_size" version = "0.4.3" @@ -2157,7 +2060,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.115", + "syn", ] [[package]] @@ -2168,7 +2071,7 @@ checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", - "syn 2.0.115", + "syn", ] [[package]] @@ -2221,7 +2124,7 @@ checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" dependencies = [ "proc-macro2", "quote", - "syn 2.0.115", + "syn", ] [[package]] @@ -2328,7 +2231,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.115", + "syn", ] [[package]] @@ -2439,7 +2342,7 @@ checksum = "6d79d08d92ab8af4c5e8a6da20c47ae3f61a0f1dabc1997cdf2d082b757ca08b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.115", + "syn", "url", "uuid", ] @@ -2466,8 +2369,8 @@ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" [[package]] name = "vitaminc" -version = "0.1.0-pre2" -source = "git+https://github.com/cipherstash/vitaminc?branch=timing-safe-eq#5edd32e990dff79c765adeecf1cd9ac9d2945a71" +version = "0.1.0-pre4" +source = "git+https://github.com/cipherstash/vitaminc?branch=fix%2Fopaque-debug-unused-results#bb6387850102619a0ce2d2050adecbed6543cf88" dependencies = [ "vitaminc-encrypt", "vitaminc-protected", @@ -2477,8 +2380,8 @@ dependencies = [ [[package]] name = "vitaminc-aead" -version = "0.1.0-pre2" -source = "git+https://github.com/cipherstash/vitaminc?branch=timing-safe-eq#5edd32e990dff79c765adeecf1cd9ac9d2945a71" +version = "0.1.0-pre4" +source = "git+https://github.com/cipherstash/vitaminc?branch=fix%2Fopaque-debug-unused-results#bb6387850102619a0ce2d2050adecbed6543cf88" dependencies = [ "bytes", "serde", @@ -2489,8 +2392,8 @@ dependencies = [ [[package]] name = "vitaminc-encrypt" -version = "0.1.0-pre2" -source = "git+https://github.com/cipherstash/vitaminc?branch=timing-safe-eq#5edd32e990dff79c765adeecf1cd9ac9d2945a71" +version = "0.1.0-pre4" +source = "git+https://github.com/cipherstash/vitaminc?branch=fix%2Fopaque-debug-unused-results#bb6387850102619a0ce2d2050adecbed6543cf88" dependencies = [ "aws-lc-rs", "vitaminc-aead", @@ -2501,35 +2404,56 @@ dependencies = [ [[package]] name = "vitaminc-protected" -version = "0.1.0-pre2" -source = "git+https://github.com/cipherstash/vitaminc?branch=timing-safe-eq#5edd32e990dff79c765adeecf1cd9ac9d2945a71" +version = "0.1.0-pre4" +source = "git+https://github.com/cipherstash/vitaminc?branch=fix%2Fopaque-debug-unused-results#bb6387850102619a0ce2d2050adecbed6543cf88" dependencies = [ "bitvec", "digest", "opaque-debug", - "protected-derive", "serde", "serde_bytes", "subtle", + "vitaminc-protected-derive", "zeroize", ] +[[package]] +name = "vitaminc-protected-derive" +version = "0.1.0-pre4" +source = "git+https://github.com/cipherstash/vitaminc?branch=fix%2Fopaque-debug-unused-results#bb6387850102619a0ce2d2050adecbed6543cf88" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "vitaminc-random" -version = "0.1.0-pre2" -source = "git+https://github.com/cipherstash/vitaminc?branch=timing-safe-eq#5edd32e990dff79c765adeecf1cd9ac9d2945a71" +version = "0.1.0-pre4" +source = "git+https://github.com/cipherstash/vitaminc?branch=fix%2Fopaque-debug-unused-results#bb6387850102619a0ce2d2050adecbed6543cf88" dependencies = [ "rand 0.8.5", "rand_chacha 0.3.1", "thiserror 1.0.69", "vitaminc-protected", + "vitaminc-random-derives", "zeroize", ] +[[package]] +name = "vitaminc-random-derives" +version = "0.1.0-pre4" +source = "git+https://github.com/cipherstash/vitaminc?branch=fix%2Fopaque-debug-unused-results#bb6387850102619a0ce2d2050adecbed6543cf88" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "vitaminc-traits" -version = "0.1.0-pre2" -source = "git+https://github.com/cipherstash/vitaminc?branch=timing-safe-eq#5edd32e990dff79c765adeecf1cd9ac9d2945a71" +version = "0.1.0-pre4" +source = "git+https://github.com/cipherstash/vitaminc?branch=fix%2Fopaque-debug-unused-results#bb6387850102619a0ce2d2050adecbed6543cf88" dependencies = [ "anyhow", "bytes", @@ -2620,7 +2544,7 @@ dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 2.0.115", + "syn", "wasm-bindgen-shared", ] @@ -2715,15 +2639,6 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471" -[[package]] -name = "winapi-util" -version = "0.1.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" -dependencies = [ - "windows-sys 0.61.2", -] - [[package]] name = "windows-link" version = "0.2.1" @@ -2992,7 +2907,7 @@ dependencies = [ "heck", "indexmap", "prettyplease", - "syn 2.0.115", + "syn", "wasm-metadata", "wit-bindgen-core", "wit-component", @@ -3008,7 +2923,7 @@ dependencies = [ "prettyplease", "proc-macro2", "quote", - "syn 2.0.115", + "syn", "wit-bindgen-core", "wit-bindgen-rust", ] @@ -3084,7 +2999,7 @@ checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.115", + "syn", "synstructure", ] @@ -3105,7 +3020,7 @@ checksum = "4122cd3169e94605190e77839c9a40d40ed048d305bfdc146e7df40ab0f3e517" dependencies = [ "proc-macro2", "quote", - "syn 2.0.115", + "syn", ] [[package]] @@ -3125,7 +3040,7 @@ checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" dependencies = [ "proc-macro2", "quote", - "syn 2.0.115", + "syn", "synstructure", ] @@ -3146,7 +3061,7 @@ checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.115", + "syn", ] [[package]] @@ -3179,7 +3094,7 @@ checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.115", + "syn", ] [[package]] diff --git a/languages/typescript/packages/auth/Cargo.toml b/languages/typescript/packages/auth/Cargo.toml index f4f97fcd4..e5dba903a 100644 --- a/languages/typescript/packages/auth/Cargo.toml +++ b/languages/typescript/packages/auth/Cargo.toml @@ -7,13 +7,10 @@ edition = "2021" crate-type = ["cdylib"] [dependencies] -stack-auth = { path = "..", features = ["specta"] } +stack-auth = { path = ".." } cts-common = { path = "../../cts-common", default-features = false } napi = { version = "2", features = ["async", "tokio_rt"] } napi-derive = "2" -regex = "1" -specta = { version = "1", features = ["typescript"] } -syn = { version = "2", features = ["full", "parsing"] } url = { version = "2", optional = true } mocktail = { version = "0.3.0", optional = true } serde_json = { version = "1", optional = true } @@ -31,8 +28,4 @@ napi-build = "2" [features] test-utils = ["stack-auth/test-utils", "dep:url", "dep:mocktail", "dep:serde_json"] -[[bin]] -name = "generate-types" -path = "src/generate_types.rs" - [workspace] diff --git a/languages/typescript/packages/auth/package.json b/languages/typescript/packages/auth/package.json index 7899fc724..a11685c3f 100644 --- a/languages/typescript/packages/auth/package.json +++ b/languages/typescript/packages/auth/package.json @@ -11,8 +11,7 @@ "build": "napi build --release", "build:debug": "napi build", "build:test": "napi build --features test-utils", - "test": "npm run build:test && vitest run", - "generate-types": "cargo run --bin generate-types" + "test": "npm run build:test && vitest run" }, "devDependencies": { "@napi-rs/cli": "^2", diff --git a/languages/typescript/packages/auth/src/generate_types.rs b/languages/typescript/packages/auth/src/generate_types.rs deleted file mode 100644 index 23429631c..000000000 --- a/languages/typescript/packages/auth/src/generate_types.rs +++ /dev/null @@ -1,401 +0,0 @@ -//! Generator binary that produces `index.d.ts` by combining Specta's structured -//! type information with field-level doc comments extracted from Rust source via -//! `syn`. This produces: -//! -//! - `export class DeviceCodeResult` with `readonly` properties and method -//! signatures (with JSDoc for `pollForToken()` and `openInBrowser()`) -//! - `export interface TokenResult` with per-field JSDoc -//! -//! Run via: `cargo run --bin generate-types` - -use std::collections::HashMap; -use std::fs; -use std::path::Path; - -use stack_auth::{PendingDeviceCode, Token}; -use regex::Regex; -use specta::ts; -use specta::{DefOpts, NamedType, TypeDefs}; - -fn main() { - let manifest_dir = Path::new(env!("CARGO_MANIFEST_DIR")); - let crate_src = manifest_dir.join("../src"); - - // expires_in is u64 in Rust but always a safe integer in practice, so - // export it as `number` rather than `bigint`. - let config = ts::ExportConfiguration::default().bigint(ts::BigIntExportBehavior::Number); - - // Parse Rust source files to extract field-level doc comments. - let device_code_src = fs::read_to_string(crate_src.join("device_code/mod.rs")) - .expect("Failed to read device_code/mod.rs"); - let lib_src = fs::read_to_string(crate_src.join("lib.rs")).expect("Failed to read lib.rs"); - - let device_code_docs = extract_field_docs(&device_code_src, "PendingDeviceCode"); - let token_docs = extract_field_docs(&lib_src, "Token"); - - // Build rich TypeScript declarations from Specta metadata + syn doc comments. - let device_code_class = build_class::(&device_code_docs, &config); - let token_iface = build_interface::(&token_docs, &config); - - let mut out = String::new(); - - out.push_str("// This file is auto-generated by `generate-types`. Do not edit by hand.\n"); - out.push_str("// Regenerate with: npm run generate-types\n\n"); - - // ----------------------------------------------------------------------- - // Specta-generated type definitions (from the cts-auth crate) - // ----------------------------------------------------------------------- - - out.push_str(&device_code_class); - out.push_str("\n\n"); - out.push_str(&token_iface); - out.push_str("\n\n"); - - // ----------------------------------------------------------------------- - // Hand-written error types (better TSDoc control per variant) - // ----------------------------------------------------------------------- - - out.push_str( - r#"/** - * Machine-readable error codes attached to {@link AuthError}. - * - * - `"REQUEST_ERROR"` — The HTTP request to the auth server failed (network error, timeout, etc.). - * - `"ACCESS_DENIED"` — The user explicitly denied authorization. - * - `"EXPIRED_TOKEN"` — The device code expired before the user authorized. - * - `"INVALID_GRANT"` — The grant type is invalid or the device code was already used. - * - `"INVALID_CLIENT"` — The `clientId` is not recognized by the auth server. - * - `"INVALID_URL"` — A URL argument could not be parsed. - * - `"INVALID_REGION"` — The `region` string does not match a known CipherStash region. - * - `"SERVER_ERROR"` — The auth server returned an unexpected error. - */ -export type AuthErrorCode = - | "REQUEST_ERROR" - | "ACCESS_DENIED" - | "EXPIRED_TOKEN" - | "INVALID_GRANT" - | "INVALID_CLIENT" - | "INVALID_URL" - | "INVALID_REGION" - | "SERVER_ERROR"; - -/** - * Error thrown by all functions in this module. - * - * Extends the built-in `Error` with a machine-readable {@link AuthErrorCode} - * so callers can branch on `err.code` without parsing the message string. - */ -export interface AuthError extends Error { - code: AuthErrorCode; -} -"#, - ); - - out.push('\n'); - - // ----------------------------------------------------------------------- - // Function declarations with TSDoc - // ----------------------------------------------------------------------- - - out.push_str( - r#"/** - * Begin the OAuth 2.0 Device Authorization flow. - * - * Contacts the CipherStash auth server for the given region and returns a - * {@link DeviceCodeResult} with a user code, verification URL, and methods - * to complete the authorization flow. - * - * @param region - CipherStash region identifier (e.g. `"ap-southeast-2"`). - * @param clientId - OAuth client ID issued by CipherStash. - * @returns A promise that resolves with the device code result. - * @throws {AuthError} `INVALID_REGION` — the region string is not recognized. - * @throws {AuthError} `INVALID_CLIENT` — the client ID is not recognized. - * @throws {AuthError} `REQUEST_ERROR` — the HTTP request to the auth server failed. - * @throws {AuthError} `SERVER_ERROR` — the auth server returned an unexpected error. - * - * @example - * ```ts - * import { beginDeviceCodeFlow } from "@cipherstash/cts-auth"; - * - * // 1. Start the flow - * const result = await beginDeviceCodeFlow("ap-southeast-2", MY_CLIENT_ID); - * console.log(`Enter code ${result.userCode} at ${result.verificationUri}`); - * - * // 2. Optionally open the browser for the user - * result.openInBrowser(); - * - * // 3. Poll until the user authorizes (or the code expires) - * const token = await result.pollForToken(); - * console.log(`Access token: ${token.accessToken}`); - * ``` - */ -export function beginDeviceCodeFlow( - region: string, - clientId: string, -): Promise; - -/** - * Variant of {@link beginDeviceCodeFlow} that targets a custom auth server URL. - * - * Intended for **testing only** — requires the crate to be built with the - * `test-utils` Cargo feature. - * - * @param region - CipherStash region identifier (e.g. `"ap-southeast-2"`). - * @param clientId - OAuth client ID issued by CipherStash. - * @param baseUrl - Base URL of the auth server to use instead of the default. - * @returns A promise that resolves with the device code result. - * @throws {AuthError} `INVALID_REGION` — the region string is not recognized. - * @throws {AuthError} `INVALID_URL` — the base URL could not be parsed. - * @throws {AuthError} `INVALID_CLIENT` — the client ID is not recognized. - * @throws {AuthError} `REQUEST_ERROR` — the HTTP request to the auth server failed. - * @throws {AuthError} `SERVER_ERROR` — the auth server returned an unexpected error. - */ -export function beginDeviceCodeFlowWithBaseUrl( - region: string, - clientId: string, - baseUrl: string, -): Promise; -"#, - ); - - // ----------------------------------------------------------------------- - // Write to index.d.ts in the package root - // ----------------------------------------------------------------------- - - let dest = manifest_dir.join("index.d.ts"); - - fs::write(&dest, &out).unwrap_or_else(|e| panic!("Failed to write {}: {e}", dest.display())); - - println!("Wrote {}", dest.display()); -} - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -/// Parse a Rust source file with `syn` and extract doc comments from the fields -/// of the given struct. Returns a map from field name (snake_case) to the -/// trimmed doc comment text. -fn extract_field_docs(source: &str, struct_name: &str) -> HashMap { - let file = syn::parse_file(source).expect("Failed to parse Rust source"); - let mut docs = HashMap::new(); - - for item in &file.items { - let syn::Item::Struct(s) = item else { - continue; - }; - if s.ident != struct_name { - continue; - } - - let syn::Fields::Named(fields) = &s.fields else { - continue; - }; - for field in &fields.named { - let Some(ident) = &field.ident else { - continue; - }; - - let doc_lines: Vec = field - .attrs - .iter() - .filter_map(|attr| { - if !attr.path().is_ident("doc") { - return None; - } - let syn::Meta::NameValue(nv) = &attr.meta else { - return None; - }; - let syn::Expr::Lit(lit) = &nv.value else { - return None; - }; - let syn::Lit::Str(s) = &lit.lit else { - return None; - }; - Some(s.value()) - }) - .collect(); - - if !doc_lines.is_empty() { - let combined = doc_lines - .iter() - .map(|l| l.strip_prefix(' ').unwrap_or(l)) - .collect::>() - .join("\n"); - docs.insert(ident.to_string(), combined.trim().to_string()); - } - } - break; // Found the struct, no need to continue. - } - - docs -} - -/// Convert Rust intra-doc links (`` [`text`](Rust::path) ``) to TSDoc -/// `{@link text}` references. -fn convert_rust_doc_links(doc: &str) -> String { - let re = Regex::new(r"\[`([^`]+)`\]\([^)]+\)").expect("invalid regex"); - re.replace_all(doc, "{@link $1}").to_string() -} - -/// Convert `camelCase` to `snake_case`. -fn to_snake_case(s: &str) -> String { - let mut result = String::new(); - for (i, c) in s.chars().enumerate() { - if c.is_uppercase() && i > 0 { - result.push('_'); - } - result.extend(c.to_lowercase()); - } - result -} - -/// Build a multi-line TypeScript `export class` declaration from Specta -/// metadata and syn-extracted field doc comments. Properties are `readonly` -/// and class methods (`pollForToken`, `openInBrowser`) are appended. -fn build_class( - field_docs: &HashMap, - config: &ts::ExportConfiguration, -) -> String { - let mut type_defs = TypeDefs::default(); - let named = T::definition_named_data_type(DefOpts { - parent_inline: false, - type_map: &mut type_defs, - }) - .expect("Failed to get named data type"); - - let ts_name = named.name; - - let mut out = String::new(); - - // Struct-level JSDoc from Specta's comments (originally `///` on the struct). - if !named.comments.is_empty() { - out.push_str("/**\n"); - for comment in named.comments { - let line = comment.strip_prefix(' ').unwrap_or(comment); - if line.is_empty() { - out.push_str(" *\n"); - } else { - let converted = convert_rust_doc_links(line); - out.push_str(&format!(" * {converted}\n")); - } - } - out.push_str(" */\n"); - } - - out.push_str(&format!("export class {ts_name} {{\n")); - - // Extract object fields. - let fields = match &named.item { - specta::NamedDataTypeItem::Object(obj) => &obj.fields, - _ => panic!("{ts_name} is not an object type"), - }; - - for field in fields { - let key = field.key; - let snake_key = to_snake_case(key); - - if let Some(doc) = field_docs.get(&snake_key) { - let converted = convert_rust_doc_links(doc); - out.push_str(&format!(" /** {converted} */\n")); - } - - let ts_type = ts::datatype(config, &field.ty) - .unwrap_or_else(|e| panic!("Failed to convert field {key}: {e}")); - - out.push_str(&format!(" readonly {key}: {ts_type};\n")); - } - - // Class methods - out.push_str( - r#" - /** - * Poll the auth server until the user completes authorization. - * - * **Consumes** the internal handle — it cannot be reused after this call. - * If you need to open the browser, call {@link openInBrowser} *before* - * `pollForToken`. - * - * @returns A promise that resolves with the access token details. - * @throws {AuthError} `EXPIRED_TOKEN` — the device code expired before the user authorized. - * @throws {AuthError} `ACCESS_DENIED` — the user denied authorization. - * @throws {AuthError} `INVALID_GRANT` — the grant type is invalid or the code was already used. - * @throws {AuthError} `REQUEST_ERROR` — the HTTP request to the auth server failed. - * @throws {AuthError} `SERVER_ERROR` — the auth server returned an unexpected error. - */ - pollForToken(): Promise; - - /** - * Open the verification URI in the user's default browser. - * - * Does **not** consume the handle — you can still call {@link pollForToken} - * afterwards. - * - * @returns `true` if the browser was launched successfully, `false` otherwise. - */ - openInBrowser(): boolean; -"#, - ); - - out.push('}'); - out -} - -/// Build a multi-line TypeScript `export interface` declaration from Specta -/// metadata and syn-extracted field doc comments. -fn build_interface( - field_docs: &HashMap, - config: &ts::ExportConfiguration, -) -> String { - let mut type_defs = TypeDefs::default(); - let named = T::definition_named_data_type(DefOpts { - parent_inline: false, - type_map: &mut type_defs, - }) - .expect("Failed to get named data type"); - - let ts_name = named.name; - - let mut out = String::new(); - - // Struct-level JSDoc from Specta's comments (originally `///` on the struct). - if !named.comments.is_empty() { - out.push_str("/**\n"); - for comment in named.comments { - let line = comment.strip_prefix(' ').unwrap_or(comment); - if line.is_empty() { - out.push_str(" *\n"); - } else { - let converted = convert_rust_doc_links(line); - out.push_str(&format!(" * {converted}\n")); - } - } - out.push_str(" */\n"); - } - - out.push_str(&format!("export interface {ts_name} {{\n")); - - // Extract object fields. - let fields = match &named.item { - specta::NamedDataTypeItem::Object(obj) => &obj.fields, - _ => panic!("{ts_name} is not an object type"), - }; - - for field in fields { - let key = field.key; - let snake_key = to_snake_case(key); - - if let Some(doc) = field_docs.get(&snake_key) { - let converted = convert_rust_doc_links(doc); - out.push_str(&format!(" /** {converted} */\n")); - } - - let ts_type = ts::datatype(config, &field.ty) - .unwrap_or_else(|e| panic!("Failed to convert field {key}: {e}")); - - let optional = if field.optional { "?" } else { "" }; - out.push_str(&format!(" {key}{optional}: {ts_type};\n")); - } - - out.push('}'); - out -} diff --git a/packages/stack-auth/Cargo.toml b/packages/stack-auth/Cargo.toml index d7b675c4e..5b7380ccf 100644 --- a/packages/stack-auth/Cargo.toml +++ b/packages/stack-auth/Cargo.toml @@ -17,11 +17,9 @@ tracing = { workspace = true } url = { workspace = true } vitaminc = { workspace = true, features = ["protected"] } zeroize = { workspace = true } -specta = { version = "1", features = ["typescript"], optional = true } [features] test-utils = [] -specta = ["dep:specta"] [[example]] name = "device_code" diff --git a/packages/stack-auth/src/device_code/mod.rs b/packages/stack-auth/src/device_code/mod.rs index fad7bcce9..70d78c684 100644 --- a/packages/stack-auth/src/device_code/mod.rs +++ b/packages/stack-auth/src/device_code/mod.rs @@ -151,15 +151,9 @@ impl DeviceCodeStrategy { /// # } /// ``` #[derive(Debug)] -#[cfg_attr(feature = "specta", derive(serde::Serialize, specta::Type))] -#[cfg_attr(feature = "specta", serde(rename_all = "camelCase"))] -#[cfg_attr(feature = "specta", specta(rename = "DeviceCodeResult"))] pub struct PendingDeviceCode { - #[cfg_attr(feature = "specta", serde(skip))] token_url: Url, - #[cfg_attr(feature = "specta", serde(skip))] client_id: String, - #[cfg_attr(feature = "specta", serde(skip))] device_code: DeviceCode, /// The short code the user must enter to authorize this device. user_code: String, diff --git a/packages/stack-auth/src/lib.rs b/packages/stack-auth/src/lib.rs index a0d348836..71d3ed2a4 100644 --- a/packages/stack-auth/src/lib.rs +++ b/packages/stack-auth/src/lib.rs @@ -84,13 +84,11 @@ pub use device_code::{DeviceCodeStrategy, PendingDeviceCode}; /// You cannot construct a `SecretToken` directly — it is returned by the /// authentication flow via [`Token::access_token`]. #[derive(OpaqueDebug, ZeroizeOnDrop, serde::Deserialize)] -#[cfg_attr(feature = "specta", derive(serde::Serialize, specta::Type))] #[serde(transparent)] pub struct SecretToken(String); impl SecretToken { /// Expose the inner token string for FFI boundaries. - #[cfg(feature = "specta")] pub fn as_str(&self) -> &str { &self.0 } @@ -101,9 +99,6 @@ impl SecretToken { /// The token contains a [`SecretToken`] (the bearer credential), a token type /// (typically `"Bearer"`), and an expiry time in seconds. #[derive(Debug)] -#[cfg_attr(feature = "specta", derive(serde::Serialize, specta::Type))] -#[cfg_attr(feature = "specta", serde(rename_all = "camelCase"))] -#[cfg_attr(feature = "specta", specta(rename = "TokenResult"))] pub struct Token { access_token: SecretToken, token_type: String, From afce83c2850ade20aaed742d2f7c99fa65068549 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Sun, 15 Feb 2026 20:45:36 +1100 Subject: [PATCH 012/686] =?UTF-8?q?chore:=20=F0=9F=94=A7=20register=20stac?= =?UTF-8?q?k-auth=20integration=20test=20task=20in=20mise?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add tasks.toml with `test:integration:stack-auth` and include it in the root mise.toml so `mise run test:integration:stack-auth` works. --- packages/stack-auth/tasks.toml | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 packages/stack-auth/tasks.toml diff --git a/packages/stack-auth/tasks.toml b/packages/stack-auth/tasks.toml new file mode 100644 index 000000000..3cae52c04 --- /dev/null +++ b/packages/stack-auth/tasks.toml @@ -0,0 +1,7 @@ +["test:integration:stack-auth"] +description = "Run stack-auth Node.js integration tests" +dir = "{{config_root}}/packages/stack-auth/node" +run = [ + "npm install", + "npm test", +] From 93f7f2c8814c073e9b476142e13db78f42adf86b Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Sun, 15 Feb 2026 20:45:45 +1100 Subject: [PATCH 013/686] =?UTF-8?q?chore:=20=F0=9F=93=A6=EF=B8=8F=20update?= =?UTF-8?q?=20index.d.ts=20to=20napi-rs=20auto-generated=20types?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace hand-crafted generate-types output with napi-rs auto-generated type declarations. Also fix package name in package-lock.json. --- languages/typescript/packages/auth/index.d.ts | 182 +++++------------- .../packages/auth/package-lock.json | 4 +- 2 files changed, 52 insertions(+), 134 deletions(-) diff --git a/languages/typescript/packages/auth/index.d.ts b/languages/typescript/packages/auth/index.d.ts index c2cc8eb48..9475ee14f 100644 --- a/languages/typescript/packages/auth/index.d.ts +++ b/languages/typescript/packages/auth/index.d.ts @@ -1,148 +1,66 @@ -// This file is auto-generated by `generate-types`. Do not edit by hand. -// Regenerate with: npm run generate-types +/* tslint:disable */ +/* eslint-disable */ +/* auto-generated by NAPI-RS */ + +export interface TokenResult { + /** The OAuth access token. */ + accessToken: string + /** Token type, typically `"Bearer"`. */ + tokenType: string + /** Number of seconds before the token expires. */ + expiresIn: number +} +/** Begin the OAuth 2.0 Device Authorization flow. */ +export declare function beginDeviceCodeFlow(region: string, clientId: string): Promise /** - * The result of initiating a device code flow. + * Variant of `beginDeviceCodeFlow` that targets a custom auth server URL. * - * Contains the user-facing codes and URIs needed to complete authorization, - * and provides {@link poll_for_token} to - * exchange the device code for an access token once the user has authorized. + * Intended for **testing only** — requires the crate to be built with the + * `test-utils` Cargo feature. */ -export class DeviceCodeResult { - /** The short code the user must enter to authorize this device. */ - readonly userCode: string; - /** The base verification URI (without the user code embedded). */ - readonly verificationUri: string; - /** The full verification URI with the user code pre-filled. */ - readonly verificationUriComplete: string; - /** How many seconds the device code remains valid. */ - readonly expiresIn: number; - +export declare function beginDeviceCodeFlowWithBaseUrl(region: string, clientId: string, baseUrl: string): Promise +export declare class MockAuthServer { + /** Start a mock auth server on a random port. */ + static start(): Promise + /** The base URL of the running mock server (e.g. `http://127.0.0.1:12345`). */ + get baseUrl(): string + /** + * Register a mock for `POST /oauth/device/code` that returns a standard + * device-code JSON response. + */ + mockDeviceCodeEndpoint(): void + /** + * Register a mock for `POST /oauth/device/token` that returns a standard + * token JSON response. + */ + mockTokenEndpoint(): void + /** + * Register a mock for `POST /oauth/device/token` that returns a 400 error + * with the given OAuth error code and optional description. + */ + mockTokenEndpointError(code: string, description?: string | undefined | null): void + /** Remove all registered mocks. */ + clearMocks(): void +} +export declare class DeviceCodeResult { + get userCode(): string + get verificationUri(): string + get verificationUriComplete(): string + get expiresIn(): number /** * Poll the auth server until the user completes authorization. * * **Consumes** the internal handle — it cannot be reused after this call. - * If you need to open the browser, call {@link openInBrowser} *before* + * If you need to open the browser, call `openInBrowser` *before* * `pollForToken`. - * - * @returns A promise that resolves with the access token details. - * @throws {AuthError} `EXPIRED_TOKEN` — the device code expired before the user authorized. - * @throws {AuthError} `ACCESS_DENIED` — the user denied authorization. - * @throws {AuthError} `INVALID_GRANT` — the grant type is invalid or the code was already used. - * @throws {AuthError} `REQUEST_ERROR` — the HTTP request to the auth server failed. - * @throws {AuthError} `SERVER_ERROR` — the auth server returned an unexpected error. */ - pollForToken(): Promise; - + pollForToken(): Promise /** * Open the verification URI in the user's default browser. * - * Does **not** consume the handle — you can still call {@link pollForToken} + * Does **not** consume the handle — you can still call `pollForToken` * afterwards. - * - * @returns `true` if the browser was launched successfully, `false` otherwise. */ - openInBrowser(): boolean; -} - -/** - * An access token returned by an authentication flow. - */ -export interface TokenResult { - /** The OAuth access token. */ - accessToken: string; - /** Token type, typically `"Bearer"`. */ - tokenType: string; - /** Number of seconds before the token expires. */ - expiresIn: number; + openInBrowser(): boolean } - -/** - * Machine-readable error codes attached to {@link AuthError}. - * - * - `"REQUEST_ERROR"` — The HTTP request to the auth server failed (network error, timeout, etc.). - * - `"ACCESS_DENIED"` — The user explicitly denied authorization. - * - `"EXPIRED_TOKEN"` — The device code expired before the user authorized. - * - `"INVALID_GRANT"` — The grant type is invalid or the device code was already used. - * - `"INVALID_CLIENT"` — The `clientId` is not recognized by the auth server. - * - `"INVALID_URL"` — A URL argument could not be parsed. - * - `"INVALID_REGION"` — The `region` string does not match a known CipherStash region. - * - `"SERVER_ERROR"` — The auth server returned an unexpected error. - */ -export type AuthErrorCode = - | "REQUEST_ERROR" - | "ACCESS_DENIED" - | "EXPIRED_TOKEN" - | "INVALID_GRANT" - | "INVALID_CLIENT" - | "INVALID_URL" - | "INVALID_REGION" - | "SERVER_ERROR"; - -/** - * Error thrown by all functions in this module. - * - * Extends the built-in `Error` with a machine-readable {@link AuthErrorCode} - * so callers can branch on `err.code` without parsing the message string. - */ -export interface AuthError extends Error { - code: AuthErrorCode; -} - -/** - * Begin the OAuth 2.0 Device Authorization flow. - * - * Contacts the CipherStash auth server for the given region and returns a - * {@link DeviceCodeResult} with a user code, verification URL, and methods - * to complete the authorization flow. - * - * @param region - CipherStash region identifier (e.g. `"ap-southeast-2"`). - * @param clientId - OAuth client ID issued by CipherStash. - * @returns A promise that resolves with the device code result. - * @throws {AuthError} `INVALID_REGION` — the region string is not recognized. - * @throws {AuthError} `INVALID_CLIENT` — the client ID is not recognized. - * @throws {AuthError} `REQUEST_ERROR` — the HTTP request to the auth server failed. - * @throws {AuthError} `SERVER_ERROR` — the auth server returned an unexpected error. - * - * @example - * ```ts - * import { beginDeviceCodeFlow } from "@cipherstash/stack-auth"; - * - * // 1. Start the flow - * const result = await beginDeviceCodeFlow("ap-southeast-2", MY_CLIENT_ID); - * console.log(`Enter code ${result.userCode} at ${result.verificationUri}`); - * - * // 2. Optionally open the browser for the user - * result.openInBrowser(); - * - * // 3. Poll until the user authorizes (or the code expires) - * const token = await result.pollForToken(); - * console.log(`Access token: ${token.accessToken}`); - * ``` - */ -export function beginDeviceCodeFlow( - region: string, - clientId: string, -): Promise; - -/** - * Variant of {@link beginDeviceCodeFlow} that targets a custom auth server URL. - * - * Intended for **testing only** — requires the crate to be built with the - * `test-utils` Cargo feature. - * - * @param region - CipherStash region identifier (e.g. `"ap-southeast-2"`). - * @param clientId - OAuth client ID issued by CipherStash. - * @param baseUrl - Base URL of the auth server to use instead of the default. - * @returns A promise that resolves with the device code result. - * @throws {AuthError} `INVALID_REGION` — the region string is not recognized. - * @throws {AuthError} `INVALID_URL` — the base URL could not be parsed. - * @throws {AuthError} `INVALID_CLIENT` — the client ID is not recognized. - * @throws {AuthError} `REQUEST_ERROR` — the HTTP request to the auth server failed. - * @throws {AuthError} `SERVER_ERROR` — the auth server returned an unexpected error. - */ -export function beginDeviceCodeFlowWithBaseUrl( - region: string, - clientId: string, - baseUrl: string, -): Promise; diff --git a/languages/typescript/packages/auth/package-lock.json b/languages/typescript/packages/auth/package-lock.json index ee87e6282..8ad4d4316 100644 --- a/languages/typescript/packages/auth/package-lock.json +++ b/languages/typescript/packages/auth/package-lock.json @@ -1,11 +1,11 @@ { - "name": "@cipherstash/cts-auth", + "name": "@cipherstash/stack-auth", "version": "0.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "@cipherstash/cts-auth", + "name": "@cipherstash/stack-auth", "version": "0.1.0", "devDependencies": { "@napi-rs/cli": "^2", From 45bfa8fa6fc46640d8f0202adbdd3f0c4811ea2c Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Sun, 15 Feb 2026 21:06:35 +1100 Subject: [PATCH 014/686] =?UTF-8?q?fix:=20=F0=9F=A9=B9=20use=20npm=20ci=20?= =?UTF-8?q?for=20deterministic=20installs=20in=20stack-auth=20task?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/stack-auth/tasks.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/stack-auth/tasks.toml b/packages/stack-auth/tasks.toml index 3cae52c04..5fa2e1832 100644 --- a/packages/stack-auth/tasks.toml +++ b/packages/stack-auth/tasks.toml @@ -2,6 +2,6 @@ description = "Run stack-auth Node.js integration tests" dir = "{{config_root}}/packages/stack-auth/node" run = [ - "npm install", + "npm ci", "npm test", ] From ef2764800c722cc7c35402d98a73ce15616369f5 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Mon, 16 Feb 2026 16:48:01 +1100 Subject: [PATCH 015/686] =?UTF-8?q?chore:=20=F0=9F=93=A6=EF=B8=8F=20rename?= =?UTF-8?q?=20node=20package=20to=20@cipherstash/auth?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- languages/typescript/packages/auth/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/languages/typescript/packages/auth/package.json b/languages/typescript/packages/auth/package.json index a11685c3f..667bdf8da 100644 --- a/languages/typescript/packages/auth/package.json +++ b/languages/typescript/packages/auth/package.json @@ -1,5 +1,5 @@ { - "name": "@cipherstash/stack-auth", + "name": "@cipherstash/auth", "version": "0.1.0", "private": true, "main": "index.js", From 006cdbc34a601d0950bf17070185fe48b957f553 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Wed, 18 Feb 2026 21:16:13 +1100 Subject: [PATCH 016/686] =?UTF-8?q?fix:=20=F0=9F=A9=B9=20scope=20stack-aut?= =?UTF-8?q?h=20CI=20to=20only=20run=20stack-auth=20unit=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The stack-auth workflow was running `mise run test:unit` which executes all workspace tests including database-dependent ones, but without setting up the database first — causing bb8 connection pool timeouts. --- .github/imported-workflows/test-stack-auth.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/imported-workflows/test-stack-auth.yml b/.github/imported-workflows/test-stack-auth.yml index 465822619..dcfad8acf 100644 --- a/.github/imported-workflows/test-stack-auth.yml +++ b/.github/imported-workflows/test-stack-auth.yml @@ -52,7 +52,7 @@ jobs: sudo chown -R "$(id -u):$(id -g)" ./target - name: test-unit - run: mise run test:unit + run: mise x --env test -- cargo nextest run -p stack-auth --all-features - name: test-integration-stack-auth run: mise run test:integration:stack-auth From 5892e4363203eaa7e5f15a6b8c3b5a30639f5dc3 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Wed, 18 Feb 2026 21:37:57 +1100 Subject: [PATCH 017/686] =?UTF-8?q?fix:=20=F0=9F=A9=B9=20export=20AuthErro?= =?UTF-8?q?r=20type=20and=20remove=20unused=20afterEach=20import?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../packages/auth/__tests__/device-code-flow.test.ts | 2 +- languages/typescript/packages/auth/index.d.ts | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/languages/typescript/packages/auth/__tests__/device-code-flow.test.ts b/languages/typescript/packages/auth/__tests__/device-code-flow.test.ts index 448e6d27d..9d1d8b082 100644 --- a/languages/typescript/packages/auth/__tests__/device-code-flow.test.ts +++ b/languages/typescript/packages/auth/__tests__/device-code-flow.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { describe, it, expect, beforeEach } from "vitest"; import type { MockAuthServer as MockAuthServerType } from "../test-utils"; import type { DeviceCodeResult, diff --git a/languages/typescript/packages/auth/index.d.ts b/languages/typescript/packages/auth/index.d.ts index 9475ee14f..3675a2be0 100644 --- a/languages/typescript/packages/auth/index.d.ts +++ b/languages/typescript/packages/auth/index.d.ts @@ -3,6 +3,10 @@ /* auto-generated by NAPI-RS */ +export interface AuthError extends Error { + /** Machine-readable error code (e.g. `"INVALID_REGION"`, `"ACCESS_DENIED"`). */ + code: string +} export interface TokenResult { /** The OAuth access token. */ accessToken: string From 9859c4670e6250395f679939fee0d84171b7de60 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Wed, 18 Feb 2026 21:38:27 +1100 Subject: [PATCH 018/686] =?UTF-8?q?chore:=20=F0=9F=94=92=20update=20lock?= =?UTF-8?q?=20files=20after=20rebase=20onto=20main?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- languages/typescript/packages/auth/Cargo.lock | 70 ++++++------------- 1 file changed, 20 insertions(+), 50 deletions(-) diff --git a/languages/typescript/packages/auth/Cargo.lock b/languages/typescript/packages/auth/Cargo.lock index dbfd4e450..c2c79de75 100644 --- a/languages/typescript/packages/auth/Cargo.lock +++ b/languages/typescript/packages/auth/Cargo.lock @@ -703,7 +703,7 @@ dependencies = [ "idna", "ipnet", "once_cell", - "rand 0.9.2", + "rand", "ring", "thiserror 2.0.18", "tinyvec", @@ -725,7 +725,7 @@ dependencies = [ "moka", "once_cell", "parking_lot", - "rand 0.9.2", + "rand", "resolv-conf", "smallvec", "thiserror 2.0.18", @@ -1175,7 +1175,7 @@ dependencies = [ "hyper", "hyper-util", "prost", - "rand 0.9.2", + "rand", "serde", "serde_json", "thiserror 2.0.18", @@ -1463,7 +1463,7 @@ dependencies = [ "bytes", "getrandom 0.3.4", "lru-slab", - "rand 0.9.2", + "rand", "ring", "rustc-hash", "rustls", @@ -1510,35 +1510,14 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" -[[package]] -name = "rand" -version = "0.8.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" -dependencies = [ - "libc", - "rand_chacha 0.3.1", - "rand_core 0.6.4", -] - [[package]] name = "rand" version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" dependencies = [ - "rand_chacha 0.9.0", - "rand_core 0.9.5", -] - -[[package]] -name = "rand_chacha" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" -dependencies = [ - "ppv-lite86", - "rand_core 0.6.4", + "rand_chacha", + "rand_core", ] [[package]] @@ -1548,16 +1527,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" dependencies = [ "ppv-lite86", - "rand_core 0.9.5", -] - -[[package]] -name = "rand_core" -version = "0.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" -dependencies = [ - "getrandom 0.2.17", + "rand_core", ] [[package]] @@ -2355,7 +2325,7 @@ checksum = "b672338555252d43fd2240c714dc444b8c6fb0a5c5335e65a07bba7742735ddb" dependencies = [ "getrandom 0.4.1", "js-sys", - "rand 0.9.2", + "rand", "serde_core", "sha1_smol", "wasm-bindgen", @@ -2370,7 +2340,7 @@ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" [[package]] name = "vitaminc" version = "0.1.0-pre4" -source = "git+https://github.com/cipherstash/vitaminc?branch=fix%2Fopaque-debug-unused-results#bb6387850102619a0ce2d2050adecbed6543cf88" +source = "git+https://github.com/cipherstash/vitaminc?rev=4327afd#4327afd37b2991508ed0e2e160aebe84538d234c" dependencies = [ "vitaminc-encrypt", "vitaminc-protected", @@ -2381,7 +2351,7 @@ dependencies = [ [[package]] name = "vitaminc-aead" version = "0.1.0-pre4" -source = "git+https://github.com/cipherstash/vitaminc?branch=fix%2Fopaque-debug-unused-results#bb6387850102619a0ce2d2050adecbed6543cf88" +source = "git+https://github.com/cipherstash/vitaminc?rev=4327afd#4327afd37b2991508ed0e2e160aebe84538d234c" dependencies = [ "bytes", "serde", @@ -2393,7 +2363,7 @@ dependencies = [ [[package]] name = "vitaminc-encrypt" version = "0.1.0-pre4" -source = "git+https://github.com/cipherstash/vitaminc?branch=fix%2Fopaque-debug-unused-results#bb6387850102619a0ce2d2050adecbed6543cf88" +source = "git+https://github.com/cipherstash/vitaminc?rev=4327afd#4327afd37b2991508ed0e2e160aebe84538d234c" dependencies = [ "aws-lc-rs", "vitaminc-aead", @@ -2405,7 +2375,7 @@ dependencies = [ [[package]] name = "vitaminc-protected" version = "0.1.0-pre4" -source = "git+https://github.com/cipherstash/vitaminc?branch=fix%2Fopaque-debug-unused-results#bb6387850102619a0ce2d2050adecbed6543cf88" +source = "git+https://github.com/cipherstash/vitaminc?rev=4327afd#4327afd37b2991508ed0e2e160aebe84538d234c" dependencies = [ "bitvec", "digest", @@ -2420,7 +2390,7 @@ dependencies = [ [[package]] name = "vitaminc-protected-derive" version = "0.1.0-pre4" -source = "git+https://github.com/cipherstash/vitaminc?branch=fix%2Fopaque-debug-unused-results#bb6387850102619a0ce2d2050adecbed6543cf88" +source = "git+https://github.com/cipherstash/vitaminc?rev=4327afd#4327afd37b2991508ed0e2e160aebe84538d234c" dependencies = [ "proc-macro2", "quote", @@ -2430,11 +2400,11 @@ dependencies = [ [[package]] name = "vitaminc-random" version = "0.1.0-pre4" -source = "git+https://github.com/cipherstash/vitaminc?branch=fix%2Fopaque-debug-unused-results#bb6387850102619a0ce2d2050adecbed6543cf88" +source = "git+https://github.com/cipherstash/vitaminc?rev=4327afd#4327afd37b2991508ed0e2e160aebe84538d234c" dependencies = [ - "rand 0.8.5", - "rand_chacha 0.3.1", - "thiserror 1.0.69", + "rand", + "rand_chacha", + "thiserror 2.0.18", "vitaminc-protected", "vitaminc-random-derives", "zeroize", @@ -2443,7 +2413,7 @@ dependencies = [ [[package]] name = "vitaminc-random-derives" version = "0.1.0-pre4" -source = "git+https://github.com/cipherstash/vitaminc?branch=fix%2Fopaque-debug-unused-results#bb6387850102619a0ce2d2050adecbed6543cf88" +source = "git+https://github.com/cipherstash/vitaminc?rev=4327afd#4327afd37b2991508ed0e2e160aebe84538d234c" dependencies = [ "proc-macro2", "quote", @@ -2453,13 +2423,13 @@ dependencies = [ [[package]] name = "vitaminc-traits" version = "0.1.0-pre4" -source = "git+https://github.com/cipherstash/vitaminc?branch=fix%2Fopaque-debug-unused-results#bb6387850102619a0ce2d2050adecbed6543cf88" +source = "git+https://github.com/cipherstash/vitaminc?rev=4327afd#4327afd37b2991508ed0e2e160aebe84538d234c" dependencies = [ "anyhow", "bytes", "rmp-serde", "serde", - "thiserror 1.0.69", + "thiserror 2.0.18", "vitaminc-protected", "vitaminc-random", "zeroize", From a4f83d19f07f96f041900b95952e95abcb8a4048 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Thu, 19 Feb 2026 18:36:45 +1100 Subject: [PATCH 019/686] feat(stack-auth): add token persistence and refresh token support Add TokenStore for persisting tokens to ~/.cipherstash/auth.json and capture the refresh_token from OAuth responses. Token now stores an absolute expires_at timestamp so expiry checks work across process restarts, with expires_in() computing remaining time dynamically. --- packages/stack-auth/Cargo.toml | 4 +- packages/stack-auth/src/device_code/mod.rs | 9 +- .../stack-auth/src/device_code/protocol.rs | 2 + packages/stack-auth/src/device_code/tests.rs | 3 +- packages/stack-auth/src/lib.rs | 46 +++- packages/stack-auth/src/token_store.rs | 231 ++++++++++++++++++ 6 files changed, 286 insertions(+), 9 deletions(-) create mode 100644 packages/stack-auth/src/token_store.rs diff --git a/packages/stack-auth/Cargo.toml b/packages/stack-auth/Cargo.toml index 5b7380ccf..6f6d12393 100644 --- a/packages/stack-auth/Cargo.toml +++ b/packages/stack-auth/Cargo.toml @@ -8,9 +8,11 @@ homepage.workspace = true [dependencies] cts-common = { workspace = true } +dirs = "4.0.0" open = "5.3.2" reqwest = { workspace = true } serde = { workspace = true } +serde_json = { workspace = true } thiserror = { workspace = true } tokio = { workspace = true } tracing = { workspace = true } @@ -28,6 +30,6 @@ required-features = ["test-utils"] [dev-dependencies] cts-common = { workspace = true } mocktail = "0.3.0" -serde_json = { workspace = true } +tempfile = "3.21.0" tokio = { workspace = true, features = ["test-util"] } tracing-subscriber = { workspace = true } diff --git a/packages/stack-auth/src/device_code/mod.rs b/packages/stack-auth/src/device_code/mod.rs index 70d78c684..06487c475 100644 --- a/packages/stack-auth/src/device_code/mod.rs +++ b/packages/stack-auth/src/device_code/mod.rs @@ -3,6 +3,8 @@ mod protocol; use cts_common::{CtsServiceDiscovery, Region, ServiceDiscovery}; use url::Url; +use std::time::{SystemTime, UNIX_EPOCH}; + use crate::{AuthError, Token}; use protocol::{ DeviceCode, DeviceCodeRequest, DeviceCodeResponse, ErrorResponse, TokenRequest, TokenResponse, @@ -236,10 +238,15 @@ impl PendingDeviceCode { if resp.status().is_success() { tracing::debug!("token received"); let token_resp: TokenResponse = resp.json().await?; + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); return Ok(Token { access_token: token_resp.access_token, token_type: token_resp.token_type, - expires_in: token_resp.expires_in, + expires_at: now + token_resp.expires_in, + refresh_token: token_resp.refresh_token, }); } diff --git a/packages/stack-auth/src/device_code/protocol.rs b/packages/stack-auth/src/device_code/protocol.rs index bc565c660..14fdc0171 100644 --- a/packages/stack-auth/src/device_code/protocol.rs +++ b/packages/stack-auth/src/device_code/protocol.rs @@ -24,6 +24,8 @@ pub(super) struct TokenResponse { pub access_token: SecretToken, pub token_type: String, pub expires_in: u64, + #[serde(default)] + pub refresh_token: Option, } #[derive(Deserialize)] diff --git a/packages/stack-auth/src/device_code/tests.rs b/packages/stack-auth/src/device_code/tests.rs index 7d1346f57..28cbebb4f 100644 --- a/packages/stack-auth/src/device_code/tests.rs +++ b/packages/stack-auth/src/device_code/tests.rs @@ -116,7 +116,8 @@ async fn test_poll_for_token_success() { assert_eq!(token.access_token().0, "test_access_token_value"); assert_eq!(token.token_type(), "Bearer"); - assert_eq!(token.expires_in(), 3600); + assert!(!token.is_expired()); + assert!((3598..=3600).contains(&token.expires_in())); } #[tokio::test(start_paused = true)] diff --git a/packages/stack-auth/src/lib.rs b/packages/stack-auth/src/lib.rs index 71d3ed2a4..5c1b251f0 100644 --- a/packages/stack-auth/src/lib.rs +++ b/packages/stack-auth/src/lib.rs @@ -64,13 +64,16 @@ #![cfg_attr(test, allow(unused_results))] use std::convert::Infallible; +use std::time::{SystemTime, UNIX_EPOCH}; use vitaminc::protected::OpaqueDebug; use zeroize::ZeroizeOnDrop; mod device_code; +mod token_store; pub use device_code::{DeviceCodeStrategy, PendingDeviceCode}; +pub use token_store::{TokenStore, TokenStoreError}; /// A sensitive token string that is zeroized on drop and hidden from debug output. /// @@ -83,11 +86,17 @@ pub use device_code::{DeviceCodeStrategy, PendingDeviceCode}; /// /// You cannot construct a `SecretToken` directly — it is returned by the /// authentication flow via [`Token::access_token`]. -#[derive(OpaqueDebug, ZeroizeOnDrop, serde::Deserialize)] +#[derive(OpaqueDebug, ZeroizeOnDrop, serde::Deserialize, serde::Serialize)] #[serde(transparent)] pub struct SecretToken(String); impl SecretToken { + /// Create a new `SecretToken` from a string value. + #[cfg(test)] + pub(crate) fn new(value: impl Into) -> Self { + Self(value.into()) + } + /// Expose the inner token string for FFI boundaries. pub fn as_str(&self) -> &str { &self.0 @@ -97,12 +106,14 @@ impl SecretToken { /// An access token returned by a successful authentication flow. /// /// The token contains a [`SecretToken`] (the bearer credential), a token type -/// (typically `"Bearer"`), and an expiry time in seconds. -#[derive(Debug)] +/// (typically `"Bearer"`), and an absolute expiry timestamp. +#[derive(Debug, serde::Serialize, serde::Deserialize)] pub struct Token { access_token: SecretToken, + #[serde(default, skip_serializing_if = "Option::is_none")] + refresh_token: Option, token_type: String, - expires_in: u64, + expires_at: u64, } impl Token { @@ -119,9 +130,32 @@ impl Token { &self.token_type } - /// How many seconds until the token expires. + /// The absolute epoch timestamp when the token expires. + pub fn expires_at(&self) -> u64 { + self.expires_at + } + + /// How many seconds until the token expires (computed from the current time). pub fn expires_in(&self) -> u64 { - self.expires_in + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + self.expires_at.saturating_sub(now) + } + + /// Returns `true` if the token has expired (with 60 seconds of leeway). + pub fn is_expired(&self) -> bool { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + now + 60 >= self.expires_at + } + + /// Returns a reference to the refresh token, if one was provided. + pub fn refresh_token(&self) -> Option<&SecretToken> { + self.refresh_token.as_ref() } } diff --git a/packages/stack-auth/src/token_store.rs b/packages/stack-auth/src/token_store.rs new file mode 100644 index 000000000..7c3d798a8 --- /dev/null +++ b/packages/stack-auth/src/token_store.rs @@ -0,0 +1,231 @@ +use std::path::{Path, PathBuf}; + +use crate::Token; + +/// Errors that can occur when reading or writing the token store. +#[derive(Debug, thiserror::Error)] +#[non_exhaustive] +pub enum TokenStoreError { + /// An I/O error occurred while reading or writing the token file. + #[error("I/O error: {0}")] + Io(#[from] std::io::Error), + /// The token file contained invalid JSON. + #[error("JSON error: {0}")] + Json(#[from] serde_json::Error), + /// The user's home directory could not be determined. + #[error("Could not determine home directory")] + HomeDirNotFound, +} + +/// Persists and loads tokens from a JSON file on disk. +/// +/// The default location is `~/.cipherstash/auth.json`. +pub struct TokenStore { + path: PathBuf, +} + +impl TokenStore { + /// Returns the default token store location: `~/.cipherstash/auth.json`. + pub fn default_location() -> Result { + let home = dirs::home_dir().ok_or(TokenStoreError::HomeDirNotFound)?; + Ok(home.join(".cipherstash").join("auth.json")) + } + + /// Create a token store at the default location (`~/.cipherstash/auth.json`). + pub fn new_default() -> Result { + Ok(Self { + path: Self::default_location()?, + }) + } + + /// Create a token store at a custom path. + pub fn new(path: impl Into) -> Self { + Self { path: path.into() } + } + + /// Returns the path to the token file. + pub fn path(&self) -> &Path { + &self.path + } + + /// Save a [`Token`] to disk. + /// + /// Creates parent directories if they don't exist. + pub fn save(&self, token: &Token) -> Result<(), TokenStoreError> { + if let Some(parent) = self.path.parent() { + std::fs::create_dir_all(parent)?; + } + let json = serde_json::to_string_pretty(token)?; + std::fs::write(&self.path, json)?; + Ok(()) + } + + /// Load a [`Token`] from disk. + /// + /// Returns `None` if the file does not exist. + pub fn load(&self) -> Result, TokenStoreError> { + match std::fs::read_to_string(&self.path) { + Ok(contents) => { + let token: Token = serde_json::from_str(&contents)?; + Ok(Some(token)) + } + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(e) => Err(TokenStoreError::Io(e)), + } + } + + /// Remove the token file from disk. + /// + /// Does nothing if the file does not already exist. + pub fn clear(&self) -> Result<(), TokenStoreError> { + match std::fs::remove_file(&self.path) { + Ok(()) => Ok(()), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(e) => Err(TokenStoreError::Io(e)), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::SecretToken; + use std::time::{SystemTime, UNIX_EPOCH}; + + fn make_token(expires_in: u64, refresh: bool) -> Token { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs(); + + Token { + access_token: SecretToken::new("test-access-token"), + token_type: "Bearer".to_string(), + expires_at: now + expires_in, + refresh_token: if refresh { + Some(SecretToken::new("test-refresh-token")) + } else { + None + }, + } + } + + #[test] + fn round_trip_save_and_load() { + let dir = tempfile::tempdir().unwrap(); + let store = TokenStore::new(dir.path().join("auth.json")); + + let token = make_token(3600, false); + store.save(&token).unwrap(); + + let loaded = store.load().unwrap().unwrap(); + assert_eq!(loaded.access_token().as_str(), "test-access-token"); + assert_eq!(loaded.token_type(), "Bearer"); + assert!(loaded.refresh_token().is_none()); + } + + #[test] + fn expires_at_is_set_correctly() { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs(); + + let token = make_token(3600, false); + + let diff = token.expires_at().abs_diff(now + 3600); + assert!(diff <= 2, "expires_at should be ~now+3600, diff was {diff}"); + } + + #[test] + fn expires_in_computes_remaining_time() { + let token = make_token(3600, false); + let remaining = token.expires_in(); + assert!( + (3598..=3600).contains(&remaining), + "expires_in should be ~3600, got {remaining}" + ); + } + + #[test] + fn is_expired_for_fresh_token() { + let token = make_token(3600, false); + assert!(!token.is_expired()); + } + + #[test] + fn is_expired_for_expired_token() { + let token = make_token(0, false); + assert!(token.is_expired()); + } + + #[test] + fn load_returns_none_for_missing_file() { + let dir = tempfile::tempdir().unwrap(); + let store = TokenStore::new(dir.path().join("nonexistent.json")); + + let result = store.load().unwrap(); + assert!(result.is_none()); + } + + #[test] + fn clear_removes_existing_file() { + let dir = tempfile::tempdir().unwrap(); + let store = TokenStore::new(dir.path().join("auth.json")); + + let token = make_token(3600, false); + store.save(&token).unwrap(); + assert!(store.path().exists()); + + store.clear().unwrap(); + assert!(!store.path().exists()); + } + + #[test] + fn clear_succeeds_for_missing_file() { + let dir = tempfile::tempdir().unwrap(); + let store = TokenStore::new(dir.path().join("nonexistent.json")); + store.clear().unwrap(); + } + + #[test] + fn save_creates_parent_directories() { + let dir = tempfile::tempdir().unwrap(); + let store = TokenStore::new(dir.path().join("nested").join("dir").join("auth.json")); + + let token = make_token(3600, false); + store.save(&token).unwrap(); + + let loaded = store.load().unwrap().unwrap(); + assert_eq!(loaded.access_token().as_str(), "test-access-token"); + } + + #[test] + fn refresh_token_round_trips() { + let dir = tempfile::tempdir().unwrap(); + let store = TokenStore::new(dir.path().join("auth.json")); + + let token = make_token(3600, true); + store.save(&token).unwrap(); + + let loaded = store.load().unwrap().unwrap(); + assert_eq!( + loaded.refresh_token().unwrap().as_str(), + "test-refresh-token" + ); + } + + #[test] + fn debug_output_does_not_leak_secrets() { + let token = make_token(3600, true); + let debug = format!("{:?}", token); + assert!( + !debug.contains("test-access-token"), + "Debug output should not contain access token, got: {debug}" + ); + assert!( + !debug.contains("test-refresh-token"), + "Debug output should not contain refresh token, got: {debug}" + ); + } +} From 423b5828db145ef51819a25a555a398cd580a11d Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Thu, 19 Feb 2026 20:58:48 +1100 Subject: [PATCH 020/686] feat(stack-auth): auto-save token to disk after polling --- packages/stack-auth/src/device_code/mod.rs | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/packages/stack-auth/src/device_code/mod.rs b/packages/stack-auth/src/device_code/mod.rs index 06487c475..3035909e7 100644 --- a/packages/stack-auth/src/device_code/mod.rs +++ b/packages/stack-auth/src/device_code/mod.rs @@ -5,7 +5,7 @@ use url::Url; use std::time::{SystemTime, UNIX_EPOCH}; -use crate::{AuthError, Token}; +use crate::{token_store::TokenStore, AuthError, Token}; use protocol::{ DeviceCode, DeviceCodeRequest, DeviceCodeResponse, ErrorResponse, TokenRequest, TokenResponse, }; @@ -242,12 +242,19 @@ impl PendingDeviceCode { .duration_since(UNIX_EPOCH) .unwrap_or_default() .as_secs(); - return Ok(Token { + let token = Token { access_token: token_resp.access_token, token_type: token_resp.token_type, expires_at: now + token_resp.expires_in, refresh_token: token_resp.refresh_token, - }); + }; + + match TokenStore::new_default().and_then(|store| store.save(&token)) { + Ok(()) => tracing::debug!("token saved to disk"), + Err(err) => tracing::warn!(%err, "failed to save token to disk"), + } + + return Ok(token); } let err: ErrorResponse = resp.json().await?; From 55d54d8172d82b6a2f3eca0dd5e1e2edca3d3c8d Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Thu, 19 Feb 2026 21:58:33 +1100 Subject: [PATCH 021/686] feat(stack-auth-node): update bindings for token store and add example MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace TokenResult with AuthResult that only exposes expiry metadata — the token never crosses the FFI boundary. Add a TypeScript example demonstrating the device code flow. --- languages/typescript/packages/auth/Cargo.lock | 65 +++++++++++++++++++ .../auth/__tests__/device-code-flow.test.ts | 12 ++-- .../packages/auth/examples/device-code.ts | 53 +++++++++++++++ languages/typescript/packages/auth/index.d.ts | 22 +++---- languages/typescript/packages/auth/src/lib.rs | 26 ++++---- 5 files changed, 148 insertions(+), 30 deletions(-) create mode 100644 languages/typescript/packages/auth/examples/device-code.ts diff --git a/languages/typescript/packages/auth/Cargo.lock b/languages/typescript/packages/auth/Cargo.lock index c2c79de75..aa331d04b 100644 --- a/languages/typescript/packages/auth/Cargo.lock +++ b/languages/typescript/packages/auth/Cargo.lock @@ -399,6 +399,26 @@ dependencies = [ "crypto-common", ] +[[package]] +name = "dirs" +version = "4.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca3aa72a6f96ea37bbc5aa912f6788242832f75369bdfdadcb0e38423f100059" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b1d1d91c932ef41c0f2663aa8b0ca0342d444d842c06914aa0a7e352d0bada6" +dependencies = [ + "libc", + "redox_users", + "winapi", +] + [[package]] name = "displaydoc" version = "0.2.5" @@ -1071,6 +1091,16 @@ dependencies = [ "windows-link", ] +[[package]] +name = "libredox" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d0b95e02c851351f877147b7deea7b1afb1df71b63aa5f8270716e0c5720616" +dependencies = [ + "bitflags", + "libc", +] + [[package]] name = "linux-raw-sys" version = "0.11.0" @@ -1548,6 +1578,17 @@ dependencies = [ "bitflags", ] +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 1.0.69", +] + [[package]] name = "regex" version = "1.12.3" @@ -1888,9 +1929,11 @@ name = "stack-auth" version = "0.1.0" dependencies = [ "cts-common", + "dirs", "open", "reqwest", "serde", + "serde_json", "thiserror 1.0.69", "tokio", "tracing", @@ -2609,6 +2652,28 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471" +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + [[package]] name = "windows-link" version = "0.2.1" diff --git a/languages/typescript/packages/auth/__tests__/device-code-flow.test.ts b/languages/typescript/packages/auth/__tests__/device-code-flow.test.ts index 9d1d8b082..758b22fb8 100644 --- a/languages/typescript/packages/auth/__tests__/device-code-flow.test.ts +++ b/languages/typescript/packages/auth/__tests__/device-code-flow.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect, beforeEach } from "vitest"; import type { MockAuthServer as MockAuthServerType } from "../test-utils"; import type { DeviceCodeResult, - TokenResult, + AuthResult, AuthError, } from "../index"; @@ -88,14 +88,14 @@ describe("device code flow (TypeScript / vitest)", () => { expect(result.expiresIn).toBe(900); }); - it("pollForToken resolves with token on success", async () => { + it("pollForToken resolves with auth metadata on success", async () => { server.mockTokenEndpoint(); const result = await beginFlow(); - const token: TokenResult = await result.pollForToken(); + const auth: AuthResult = await result.pollForToken(); - expect(token.accessToken).toBe("test_access_token_value"); - expect(token.tokenType).toBe("Bearer"); - expect(token.expiresIn).toBe(3600); + expect(auth.expiresAt).toBeGreaterThan(0); + expect(auth.expiresIn).toBeGreaterThanOrEqual(3598); + expect(auth.expiresIn).toBeLessThanOrEqual(3600); }); it("pollForToken rejects on second call (consumed handle)", async () => { diff --git a/languages/typescript/packages/auth/examples/device-code.ts b/languages/typescript/packages/auth/examples/device-code.ts new file mode 100644 index 000000000..98285b20d --- /dev/null +++ b/languages/typescript/packages/auth/examples/device-code.ts @@ -0,0 +1,53 @@ +// Example: OAuth 2.0 Device Code flow via the @cipherstash/auth Node bindings. +// +// The token is saved automatically to ~/.cipherstash/auth.json and is never +// exposed to JavaScript. +// +// Prerequisites: +// 1. Build the native module with test-utils: npm run build:test +// 2. Have CTS running locally: mise run docker:up +// +// Usage: +// npx tsx examples/device-code.ts + +import { beginDeviceCodeFlow } from "../index"; + +async function main() { + // Step 1: Begin the device code flow against a local CTS instance + /*const pending = await beginDeviceCodeFlowWithBaseUrl( + "ap-southeast-2.aws", + "cli", + "http://localhost:3001", + );*/ + + // To connect to production instead: + // import { beginDeviceCodeFlow } from "../index"; + const pending = await beginDeviceCodeFlow("ap-southeast-2.aws", "cli"); + + // Step 2: Show the user their code and verification URL + console.log(`Your code is: ${pending.userCode}`); + console.log(`Visit: ${pending.verificationUriComplete}`); + console.log(`Code expires in: ${pending.expiresIn}s`); + console.log(); + + // Optionally open the browser automatically + const opened = pending.openInBrowser(); + if (!opened) { + console.log("Could not open browser — please visit the URL above manually."); + } + + // Step 3: Poll until the user authorizes (or the code expires). + // The token is saved to ~/.cipherstash/auth.json automatically. + console.log("Waiting for authorization..."); + const auth = await pending.pollForToken(); + + console.log(); + console.log("Authenticated! Token saved to ~/.cipherstash/auth.json"); + console.log(` Expires at: ${new Date(auth.expiresAt * 1000).toISOString()}`); + console.log(` Expires in: ${auth.expiresIn}s`); +} + +main().catch((err: Error & { code?: string }) => { + console.error(err.code ? `[${err.code}] ${err.message}` : err.message); + process.exit(1); +}); diff --git a/languages/typescript/packages/auth/index.d.ts b/languages/typescript/packages/auth/index.d.ts index 3675a2be0..4443eca45 100644 --- a/languages/typescript/packages/auth/index.d.ts +++ b/languages/typescript/packages/auth/index.d.ts @@ -3,16 +3,16 @@ /* auto-generated by NAPI-RS */ -export interface AuthError extends Error { - /** Machine-readable error code (e.g. `"INVALID_REGION"`, `"ACCESS_DENIED"`). */ - code: string -} -export interface TokenResult { - /** The OAuth access token. */ - accessToken: string - /** Token type, typically `"Bearer"`. */ - tokenType: string - /** Number of seconds before the token expires. */ +/** + * Metadata returned after a successful device code authentication. + * + * The actual token is never exposed to JavaScript — it is saved directly + * to `~/.cipherstash/auth.json` by the Rust layer. + */ +export interface AuthResult { + /** Absolute epoch timestamp (seconds) when the token expires. */ + expiresAt: number + /** Number of seconds before the token expires (computed at time of return). */ expiresIn: number } /** Begin the OAuth 2.0 Device Authorization flow. */ @@ -59,7 +59,7 @@ export declare class DeviceCodeResult { * If you need to open the browser, call `openInBrowser` *before* * `pollForToken`. */ - pollForToken(): Promise + pollForToken(): Promise /** * Open the verification URI in the user's default browser. * diff --git a/languages/typescript/packages/auth/src/lib.rs b/languages/typescript/packages/auth/src/lib.rs index 07c6ea81b..37b6f6ea6 100644 --- a/languages/typescript/packages/auth/src/lib.rs +++ b/languages/typescript/packages/auth/src/lib.rs @@ -35,14 +35,16 @@ fn to_napi_error(err: AuthError) -> napi::Error { // TokenResult — plain data object // --------------------------------------------------------------------------- +/// Metadata returned after a successful device code authentication. +/// +/// The actual token is never exposed to JavaScript — it is saved directly +/// to `~/.cipherstash/auth.json` by the Rust layer. #[derive(Debug)] #[napi(object)] -pub struct TokenResult { - /// The OAuth access token. - pub access_token: String, - /// Token type, typically `"Bearer"`. - pub token_type: String, - /// Number of seconds before the token expires. +pub struct AuthResult { + /// Absolute epoch timestamp (seconds) when the token expires. + pub expires_at: f64, + /// Number of seconds before the token expires (computed at time of return). pub expires_in: f64, } @@ -93,7 +95,7 @@ impl DeviceCodeResult { /// If you need to open the browser, call `openInBrowser` *before* /// `pollForToken`. #[napi] - pub async fn poll_for_token(&self) -> Result { + pub async fn poll_for_token(&self) -> Result { let pending = self .pending .lock() @@ -108,9 +110,8 @@ impl DeviceCodeResult { let token = pending.poll_for_token().await.map_err(to_napi_error)?; - Ok(TokenResult { - access_token: token.access_token().as_str().to_string(), - token_type: token.token_type().to_string(), + Ok(AuthResult { + expires_at: token.expires_at() as f64, expires_in: token.expires_in() as f64, }) } @@ -309,9 +310,8 @@ mod tests { let result = begin_result(&server).await; let token = result.poll_for_token().await.unwrap(); - assert_eq!(token.access_token, "test_access_token_value"); - assert_eq!(token.token_type, "Bearer"); - assert_eq!(token.expires_in, 3600.0); + assert!(token.expires_in >= 3598.0 && token.expires_in <= 3600.0); + assert!(token.expires_at > 0.0); } #[tokio::test(start_paused = true)] From 4969b7598a529283211e92e76eed8205ae3f74ad Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Fri, 20 Feb 2026 18:20:19 +1100 Subject: [PATCH 022/686] feat(stack-auth): add AuthStrategy trait and TokenStoreStrategy Introduce an AuthStrategy trait that defines a standard interface for obtaining a SecretToken. Add TokenStoreStrategy which implements the trait by loading a token from disk via TokenStore and caching it in memory with tokio::sync::OnceCell. --- packages/stack-auth/src/lib.rs | 20 +++++++++++- packages/stack-auth/src/token_store.rs | 45 ++++++++++++++++++++++++-- 2 files changed, 62 insertions(+), 3 deletions(-) diff --git a/packages/stack-auth/src/lib.rs b/packages/stack-auth/src/lib.rs index 5c1b251f0..2b120d843 100644 --- a/packages/stack-auth/src/lib.rs +++ b/packages/stack-auth/src/lib.rs @@ -73,7 +73,24 @@ mod device_code; mod token_store; pub use device_code::{DeviceCodeStrategy, PendingDeviceCode}; -pub use token_store::{TokenStore, TokenStoreError}; +pub use token_store::{TokenStore, TokenStoreError, TokenStoreStrategy}; + +/// A strategy for obtaining a [`SecretToken`] for authenticating with CipherStash services. +/// +/// Implementors provide a single method, [`get_token`](AuthStrategy::get_token), which +/// returns a valid access token. The strategy is responsible for managing token +/// lifecycle concerns such as caching, refreshing, or re-authenticating as needed. +/// +/// The lifetime `'a` ties the returned reference to the data that owns the token, +/// allowing the same strategy to be called multiple times (e.g. by implementing +/// the trait for `&'a T`). +pub trait AuthStrategy<'a> { + /// The error type returned when token retrieval fails. + type Error; + + /// Retrieve a valid access token. + fn get_token(self) -> impl std::future::Future> + Send; +} /// A sensitive token string that is zeroized on drop and hidden from debug output. /// @@ -157,6 +174,7 @@ impl Token { pub fn refresh_token(&self) -> Option<&SecretToken> { self.refresh_token.as_ref() } + } /// Errors that can occur during an authentication flow. diff --git a/packages/stack-auth/src/token_store.rs b/packages/stack-auth/src/token_store.rs index 7c3d798a8..202c9d1d1 100644 --- a/packages/stack-auth/src/token_store.rs +++ b/packages/stack-auth/src/token_store.rs @@ -1,6 +1,5 @@ use std::path::{Path, PathBuf}; - -use crate::Token; +use crate::{AuthStrategy, SecretToken, Token}; /// Errors that can occur when reading or writing the token store. #[derive(Debug, thiserror::Error)] @@ -15,6 +14,12 @@ pub enum TokenStoreError { /// The user's home directory could not be determined. #[error("Could not determine home directory")] HomeDirNotFound, + /// No token was found in the store. + #[error("No token found")] + NotFound, + /// The token has expired. + #[error("Token has expired")] + Expired, } /// Persists and loads tokens from a JSON file on disk. @@ -86,6 +91,42 @@ impl TokenStore { } } +/// An [`AuthStrategy`] that loads a token from a [`TokenStore`] and caches it in memory. +/// +/// The token is loaded from disk on the first call to [`get_token`](AuthStrategy::get_token) +/// and cached for subsequent calls. +pub struct TokenStoreStrategy { + store: TokenStore, + cached: tokio::sync::OnceCell, +} + +impl TokenStoreStrategy { + /// Create a new `TokenStoreStrategy` backed by the given [`TokenStore`]. + pub fn new(store: TokenStore) -> Self { + Self { + store, + cached: tokio::sync::OnceCell::new(), + } + } +} + +impl<'a> AuthStrategy<'a> for &'a TokenStoreStrategy { + type Error = TokenStoreError; + + async fn get_token(self) -> Result<&'a SecretToken, Self::Error> { + let token = self + .cached + .get_or_try_init(|| async { + self.store.load()?.ok_or(TokenStoreError::NotFound) + }) + .await?; + if token.is_expired() { + return Err(TokenStoreError::Expired); + } + Ok(token.access_token()) + } +} + #[cfg(test)] mod tests { use super::*; From ec54b5820c3c9fecf98a0bac7a3f89dc6680d9a4 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Fri, 20 Feb 2026 20:42:08 +1100 Subject: [PATCH 023/686] =?UTF-8?q?=E2=9C=A8=20feat(stack-auth):=20add=20T?= =?UTF-8?q?oken::refresh=20and=20move=20Token=20to=20its=20own=20module?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move Token and its impl into src/token.rs for better organization and to keep tests separate from lib.rs. Add Token::refresh() which exchanges a refresh token at the /oauth/token endpoint for a new access token. --- packages/stack-auth/src/lib.rs | 78 +--------------- packages/stack-auth/src/token.rs | 153 +++++++++++++++++++++++++++++++ 2 files changed, 158 insertions(+), 73 deletions(-) create mode 100644 packages/stack-auth/src/token.rs diff --git a/packages/stack-auth/src/lib.rs b/packages/stack-auth/src/lib.rs index 2b120d843..abebf915b 100644 --- a/packages/stack-auth/src/lib.rs +++ b/packages/stack-auth/src/lib.rs @@ -64,15 +64,16 @@ #![cfg_attr(test, allow(unused_results))] use std::convert::Infallible; -use std::time::{SystemTime, UNIX_EPOCH}; use vitaminc::protected::OpaqueDebug; use zeroize::ZeroizeOnDrop; mod device_code; +mod token; mod token_store; pub use device_code::{DeviceCodeStrategy, PendingDeviceCode}; +pub use token::Token; pub use token_store::{TokenStore, TokenStoreError, TokenStoreStrategy}; /// A strategy for obtaining a [`SecretToken`] for authenticating with CipherStash services. @@ -120,63 +121,6 @@ impl SecretToken { } } -/// An access token returned by a successful authentication flow. -/// -/// The token contains a [`SecretToken`] (the bearer credential), a token type -/// (typically `"Bearer"`), and an absolute expiry timestamp. -#[derive(Debug, serde::Serialize, serde::Deserialize)] -pub struct Token { - access_token: SecretToken, - #[serde(default, skip_serializing_if = "Option::is_none")] - refresh_token: Option, - token_type: String, - expires_at: u64, -} - -impl Token { - /// Returns a reference to the access token credential. - /// - /// The returned [`SecretToken`] is opaque — its [`Debug`] output is masked. - /// Pass it to API clients that need the raw bearer token. - pub fn access_token(&self) -> &SecretToken { - &self.access_token - } - - /// The token type (e.g. `"Bearer"`). - pub fn token_type(&self) -> &str { - &self.token_type - } - - /// The absolute epoch timestamp when the token expires. - pub fn expires_at(&self) -> u64 { - self.expires_at - } - - /// How many seconds until the token expires (computed from the current time). - pub fn expires_in(&self) -> u64 { - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_secs(); - self.expires_at.saturating_sub(now) - } - - /// Returns `true` if the token has expired (with 60 seconds of leeway). - pub fn is_expired(&self) -> bool { - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_secs(); - now + 60 >= self.expires_at - } - - /// Returns a reference to the refresh token, if one was provided. - pub fn refresh_token(&self) -> Option<&SecretToken> { - self.refresh_token.as_ref() - } - -} - /// Errors that can occur during an authentication flow. #[derive(Debug, thiserror::Error)] #[non_exhaustive] @@ -202,6 +146,9 @@ pub enum AuthError { /// The requested region is not supported. #[error("Unsupported region: {0}")] Region(#[from] cts_common::RegionError), + /// The token does not contain a refresh token. + #[error("No refresh token available")] + NoRefreshToken, /// An unexpected error was returned by the auth server. #[error("Server error: {0}")] Server(String), @@ -212,18 +159,3 @@ impl From for AuthError { match never {} } } - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_secret_token_debug_does_not_leak() { - let token = SecretToken("super_secret_value".to_string()); - let debug = format!("{:?}", token); - assert!( - !debug.contains("super_secret_value"), - "SecretToken Debug should not contain the secret, got: {debug}" - ); - } -} diff --git a/packages/stack-auth/src/token.rs b/packages/stack-auth/src/token.rs new file mode 100644 index 000000000..72a565c54 --- /dev/null +++ b/packages/stack-auth/src/token.rs @@ -0,0 +1,153 @@ +use std::time::{SystemTime, UNIX_EPOCH}; + +use url::Url; + +use crate::{AuthError, SecretToken}; + +/// An access token returned by a successful authentication flow. +/// +/// The token contains a [`SecretToken`] (the bearer credential), a token type +/// (typically `"Bearer"`), and an absolute expiry timestamp. +#[derive(Debug, serde::Serialize, serde::Deserialize)] +pub struct Token { + pub(crate) access_token: SecretToken, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) refresh_token: Option, + pub(crate) token_type: String, + pub(crate) expires_at: u64, +} + +impl Token { + /// Returns a reference to the access token credential. + /// + /// The returned [`SecretToken`] is opaque — its [`Debug`] output is masked. + /// Pass it to API clients that need the raw bearer token. + pub fn access_token(&self) -> &SecretToken { + &self.access_token + } + + /// The token type (e.g. `"Bearer"`). + pub fn token_type(&self) -> &str { + &self.token_type + } + + /// The absolute epoch timestamp when the token expires. + pub fn expires_at(&self) -> u64 { + self.expires_at + } + + /// How many seconds until the token expires (computed from the current time). + pub fn expires_in(&self) -> u64 { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + self.expires_at.saturating_sub(now) + } + + /// Returns `true` if the token has expired (with 60 seconds of leeway). + pub fn is_expired(&self) -> bool { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + now + 60 >= self.expires_at + } + + /// Returns a reference to the refresh token, if one was provided. + pub fn refresh_token(&self) -> Option<&SecretToken> { + self.refresh_token.as_ref() + } + + /// Refresh this token using the `/oauth/token` endpoint. + /// + /// Consumes `self` and returns a new [`Token`] with a fresh access token. + /// The `base_url` should be the CTS auth server base URL (e.g. from service + /// discovery) and `client_id` the OAuth client identifier. + /// + /// # Errors + /// + /// - [`AuthError::NoRefreshToken`] — this token has no refresh token. + /// - [`AuthError::InvalidGrant`] — the refresh token was revoked or expired. + /// - [`AuthError::InvalidClient`] — the client ID is not recognized. + /// - [`AuthError::Request`] — a network error occurred. + pub async fn refresh(self, base_url: &Url, client_id: &str) -> Result { + let refresh_token = self.refresh_token.ok_or(AuthError::NoRefreshToken)?; + + let token_url = base_url.join("oauth/token")?; + + tracing::debug!(url = %token_url, "refreshing token"); + + let resp = reqwest::Client::new() + .post(token_url) + .form(&RefreshRequest { + grant_type: "refresh_token", + client_id, + refresh_token: refresh_token.as_str(), + }) + .send() + .await?; + + if !resp.status().is_success() { + let err: RefreshErrorResponse = resp.json().await?; + tracing::debug!(error = %err.error, "token refresh failed"); + return Err(match err.error.as_str() { + "invalid_grant" => AuthError::InvalidGrant, + "invalid_client" => AuthError::InvalidClient, + "access_denied" => AuthError::AccessDenied, + _ => AuthError::Server(err.error_description), + }); + } + + let token_resp: RefreshResponse = resp.json().await?; + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + + Ok(Token { + access_token: token_resp.access_token, + token_type: token_resp.token_type, + expires_at: now + token_resp.expires_in, + refresh_token: token_resp.refresh_token, + }) + } +} + +#[derive(serde::Serialize)] +struct RefreshRequest<'a> { + grant_type: &'a str, + client_id: &'a str, + refresh_token: &'a str, +} + +#[derive(serde::Deserialize)] +struct RefreshResponse { + access_token: SecretToken, + token_type: String, + expires_in: u64, + #[serde(default)] + refresh_token: Option, +} + +#[derive(serde::Deserialize)] +struct RefreshErrorResponse { + error: String, + #[serde(default)] + error_description: String, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_secret_token_debug_does_not_leak() { + let token = SecretToken("super_secret_value".to_string()); + let debug = format!("{:?}", token); + assert!( + !debug.contains("super_secret_value"), + "SecretToken Debug should not contain the secret, got: {debug}" + ); + } +} From 3323a06a12aef7c87ec467a114f27f94dee51740 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Fri, 20 Feb 2026 20:48:42 +1100 Subject: [PATCH 024/686] =?UTF-8?q?=E2=9C=85=20test(stack-auth):=20add=20u?= =?UTF-8?q?nit=20tests=20for=20Token::refresh?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cover success path, all error variants (no refresh token, invalid grant, invalid client, access denied, unknown server error), optional refresh token in response, and debug output not leaking secrets. --- packages/stack-auth/src/lib.rs | 4 +- packages/stack-auth/src/token.rs | 176 +++++++++++++++++++++++++ packages/stack-auth/src/token_store.rs | 6 +- 3 files changed, 181 insertions(+), 5 deletions(-) diff --git a/packages/stack-auth/src/lib.rs b/packages/stack-auth/src/lib.rs index abebf915b..10c5c0d9e 100644 --- a/packages/stack-auth/src/lib.rs +++ b/packages/stack-auth/src/lib.rs @@ -90,7 +90,9 @@ pub trait AuthStrategy<'a> { type Error; /// Retrieve a valid access token. - fn get_token(self) -> impl std::future::Future> + Send; + fn get_token( + self, + ) -> impl std::future::Future> + Send; } /// A sensitive token string that is zeroized on drop and hidden from debug output. diff --git a/packages/stack-auth/src/token.rs b/packages/stack-auth/src/token.rs index 72a565c54..9fc217255 100644 --- a/packages/stack-auth/src/token.rs +++ b/packages/stack-auth/src/token.rs @@ -140,6 +140,48 @@ struct RefreshErrorResponse { #[cfg(test)] mod tests { use super::*; + use crate::AuthError; + use mocktail::prelude::*; + + fn make_token(expires_in: u64, refresh: bool) -> Token { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs(); + + Token { + access_token: SecretToken::new("test-access-token"), + token_type: "Bearer".to_string(), + expires_at: now + expires_in, + refresh_token: if refresh { + Some(SecretToken::new("test-refresh-token")) + } else { + None + }, + } + } + + fn refresh_response_json() -> serde_json::Value { + serde_json::json!({ + "access_token": "new-access-token", + "token_type": "Bearer", + "expires_in": 3600, + "refresh_token": "new-refresh-token" + }) + } + + fn error_json(error: &str) -> serde_json::Value { + serde_json::json!({ + "error": error, + "error_description": format!("{error} occurred") + }) + } + + async fn start_server(mocks: MockSet) -> MockServer { + let server = MockServer::new_http("token-refresh-test").with_mocks(mocks); + server.start().await.unwrap(); + server + } #[test] fn test_secret_token_debug_does_not_leak() { @@ -150,4 +192,138 @@ mod tests { "SecretToken Debug should not contain the secret, got: {debug}" ); } + + // ---- refresh() tests ---- + + #[tokio::test] + async fn test_refresh_success() { + let mut mocks = MockSet::new(); + mocks.mock(|when, then| { + when.post().path("/oauth/token"); + then.json(refresh_response_json()); + }); + let server = start_server(mocks).await; + let base_url = server.url(""); + + let token = make_token(3600, true); + let refreshed = token.refresh(&base_url, "cli").await.unwrap(); + + assert_eq!(refreshed.access_token().as_str(), "new-access-token"); + assert_eq!(refreshed.token_type(), "Bearer"); + assert_eq!( + refreshed.refresh_token().unwrap().as_str(), + "new-refresh-token" + ); + assert!(!refreshed.is_expired()); + assert!((3598..=3600).contains(&refreshed.expires_in())); + } + + #[tokio::test] + async fn test_refresh_without_refresh_token() { + let token = make_token(3600, false); + let base_url = Url::parse("http://localhost:9999").unwrap(); + + let err = token.refresh(&base_url, "cli").await.unwrap_err(); + + assert!(matches!(err, AuthError::NoRefreshToken)); + } + + #[tokio::test] + async fn test_refresh_invalid_grant() { + let mut mocks = MockSet::new(); + mocks.mock(|when, then| { + when.post().path("/oauth/token"); + then.bad_request().json(error_json("invalid_grant")); + }); + let server = start_server(mocks).await; + let base_url = server.url(""); + + let token = make_token(3600, true); + let err = token.refresh(&base_url, "cli").await.unwrap_err(); + + assert!(matches!(err, AuthError::InvalidGrant)); + } + + #[tokio::test] + async fn test_refresh_invalid_client() { + let mut mocks = MockSet::new(); + mocks.mock(|when, then| { + when.post().path("/oauth/token"); + then.bad_request().json(error_json("invalid_client")); + }); + let server = start_server(mocks).await; + let base_url = server.url(""); + + let token = make_token(3600, true); + let err = token.refresh(&base_url, "cli").await.unwrap_err(); + + assert!(matches!(err, AuthError::InvalidClient)); + } + + #[tokio::test] + async fn test_refresh_access_denied() { + let mut mocks = MockSet::new(); + mocks.mock(|when, then| { + when.post().path("/oauth/token"); + then.bad_request().json(error_json("access_denied")); + }); + let server = start_server(mocks).await; + let base_url = server.url(""); + + let token = make_token(3600, true); + let err = token.refresh(&base_url, "cli").await.unwrap_err(); + + assert!(matches!(err, AuthError::AccessDenied)); + } + + #[tokio::test] + async fn test_refresh_unknown_error() { + let mut mocks = MockSet::new(); + mocks.mock(|when, then| { + when.post().path("/oauth/token"); + then.bad_request().json(error_json("something_unexpected")); + }); + let server = start_server(mocks).await; + let base_url = server.url(""); + + let token = make_token(3600, true); + let err = token.refresh(&base_url, "cli").await.unwrap_err(); + + assert!(matches!(&err, AuthError::Server(desc) if desc == "something_unexpected occurred")); + } + + #[tokio::test] + async fn test_refresh_response_without_new_refresh_token() { + let mut mocks = MockSet::new(); + mocks.mock(|when, then| { + when.post().path("/oauth/token"); + then.json(serde_json::json!({ + "access_token": "new-access-token", + "token_type": "Bearer", + "expires_in": 3600 + })); + }); + let server = start_server(mocks).await; + let base_url = server.url(""); + + let token = make_token(3600, true); + let refreshed = token.refresh(&base_url, "cli").await.unwrap(); + + assert_eq!(refreshed.access_token().as_str(), "new-access-token"); + assert!(refreshed.refresh_token().is_none()); + } + + #[tokio::test] + async fn test_refresh_debug_does_not_leak_tokens() { + let token = make_token(3600, true); + let debug = format!("{:?}", token); + assert!( + !debug.contains("test-access-token"), + "Debug output should not contain access token, got: {debug}" + ); + assert!( + !debug.contains("test-refresh-token"), + "Debug output should not contain refresh token, got: {debug}" + ); + } } diff --git a/packages/stack-auth/src/token_store.rs b/packages/stack-auth/src/token_store.rs index 202c9d1d1..db288d5d6 100644 --- a/packages/stack-auth/src/token_store.rs +++ b/packages/stack-auth/src/token_store.rs @@ -1,5 +1,5 @@ -use std::path::{Path, PathBuf}; use crate::{AuthStrategy, SecretToken, Token}; +use std::path::{Path, PathBuf}; /// Errors that can occur when reading or writing the token store. #[derive(Debug, thiserror::Error)] @@ -116,9 +116,7 @@ impl<'a> AuthStrategy<'a> for &'a TokenStoreStrategy { async fn get_token(self) -> Result<&'a SecretToken, Self::Error> { let token = self .cached - .get_or_try_init(|| async { - self.store.load()?.ok_or(TokenStoreError::NotFound) - }) + .get_or_try_init(|| async { self.store.load()?.ok_or(TokenStoreError::NotFound) }) .await?; if token.is_expired() { return Err(TokenStoreError::Expired); From 3c1f63d7fadeb2335121dbc9230ee7ed617b28dd Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Fri, 20 Feb 2026 21:22:59 +1100 Subject: [PATCH 025/686] =?UTF-8?q?=E2=9C=A8=20feat(stack-auth):=20preempt?= =?UTF-8?q?ive=20token=20refresh=20in=20TokenStoreStrategy?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Only take the refresh token (not the whole token) before refreshing, so the access token remains available during the refresh and subsequent calls skip the refresh attempt (no cascade). Extract Token::exchange_refresh_token as a pub(crate) helper so the strategy can refresh without consuming the cached Token. --- packages/stack-auth/src/token.rs | 13 +++++ packages/stack-auth/src/token_store.rs | 68 +++++++++++++++++++++----- 2 files changed, 69 insertions(+), 12 deletions(-) diff --git a/packages/stack-auth/src/token.rs b/packages/stack-auth/src/token.rs index 9fc217255..38c922d75 100644 --- a/packages/stack-auth/src/token.rs +++ b/packages/stack-auth/src/token.rs @@ -59,6 +59,11 @@ impl Token { self.refresh_token.as_ref() } + /// Takes the refresh token out, leaving `None` in its place. + pub fn take_refresh_token(&mut self) -> Option { + self.refresh_token.take() + } + /// Refresh this token using the `/oauth/token` endpoint. /// /// Consumes `self` and returns a new [`Token`] with a fresh access token. @@ -73,7 +78,15 @@ impl Token { /// - [`AuthError::Request`] — a network error occurred. pub async fn refresh(self, base_url: &Url, client_id: &str) -> Result { let refresh_token = self.refresh_token.ok_or(AuthError::NoRefreshToken)?; + Self::exchange_refresh_token(&refresh_token, base_url, client_id).await + } + /// Exchange a refresh token for a new [`Token`]. + pub(crate) async fn exchange_refresh_token( + refresh_token: &SecretToken, + base_url: &Url, + client_id: &str, + ) -> Result { let token_url = base_url.join("oauth/token")?; tracing::debug!(url = %token_url, "refreshing token"); diff --git a/packages/stack-auth/src/token_store.rs b/packages/stack-auth/src/token_store.rs index db288d5d6..015dadc61 100644 --- a/packages/stack-auth/src/token_store.rs +++ b/packages/stack-auth/src/token_store.rs @@ -1,5 +1,6 @@ -use crate::{AuthStrategy, SecretToken, Token}; +use crate::{AuthError, AuthStrategy, SecretToken, Token}; use std::path::{Path, PathBuf}; +use url::Url; /// Errors that can occur when reading or writing the token store. #[derive(Debug, thiserror::Error)] @@ -20,6 +21,9 @@ pub enum TokenStoreError { /// The token has expired. #[error("Token has expired")] Expired, + /// Token refresh failed. + #[error("Token refresh failed: {0}")] + Refresh(#[from] AuthError), } /// Persists and loads tokens from a JSON file on disk. @@ -91,33 +95,73 @@ impl TokenStore { } } -/// An [`AuthStrategy`] that loads a token from a [`TokenStore`] and caches it in memory. +/// An [`AuthStrategy`] that loads a token from a [`TokenStore`], caches it in +/// memory, and preemptively refreshes it before it expires. /// /// The token is loaded from disk on the first call to [`get_token`](AuthStrategy::get_token) -/// and cached for subsequent calls. +/// and cached for subsequent calls. When the token is within 60 seconds of +/// expiry and a refresh token is available, the strategy automatically refreshes +/// it and persists the new token to disk. pub struct TokenStoreStrategy { store: TokenStore, - cached: tokio::sync::OnceCell, + base_url: Url, + client_id: String, + token: Option, } impl TokenStoreStrategy { - /// Create a new `TokenStoreStrategy` backed by the given [`TokenStore`]. - pub fn new(store: TokenStore) -> Self { + /// Create a new `TokenStoreStrategy`. + /// + /// The `base_url` and `client_id` are used when refreshing an expired token + /// via the `/oauth/token` endpoint. + pub fn new(store: TokenStore, base_url: Url, client_id: impl Into) -> Self { Self { store, - cached: tokio::sync::OnceCell::new(), + base_url, + client_id: client_id.into(), + token: None, } } } -impl<'a> AuthStrategy<'a> for &'a TokenStoreStrategy { +impl<'a> AuthStrategy<'a> for &'a mut TokenStoreStrategy { type Error = TokenStoreError; async fn get_token(self) -> Result<&'a SecretToken, Self::Error> { - let token = self - .cached - .get_or_try_init(|| async { self.store.load()?.ok_or(TokenStoreError::NotFound) }) - .await?; + // Load from disk if not yet cached. + if self.token.is_none() { + let token = self.store.load()?.ok_or(TokenStoreError::NotFound)?; + self.token = Some(token); + } + + // Preemptively refresh if the token is expiring within 60 seconds. + // Take only the refresh token so the access token stays available + // for other callers and subsequent calls won't attempt a concurrent + // refresh (they'll see refresh_token is None and skip this block). + let refresh_token = self + .token + .as_mut() + .filter(|t| t.is_expired()) + .and_then(|t| t.take_refresh_token()); + + if let Some(refresh_token) = refresh_token { + match Token::exchange_refresh_token(&refresh_token, &self.base_url, &self.client_id) + .await + { + Ok(new_token) => { + match self.store.save(&new_token) { + Ok(()) => tracing::debug!("refreshed token saved to disk"), + Err(err) => tracing::warn!(%err, "failed to save refreshed token to disk"), + } + self.token = Some(new_token); + } + Err(err) => { + tracing::warn!(%err, "token refresh failed"); + } + } + } + + let token = self.token.as_ref().ok_or(TokenStoreError::NotFound)?; if token.is_expired() { return Err(TokenStoreError::Expired); } From 8a4aefab4c2bf43e88afce324806bfb74e368583 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Fri, 20 Feb 2026 21:45:39 +1100 Subject: [PATCH 026/686] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor(stack-aut?= =?UTF-8?q?h):=20move=20TokenStoreStrategy=20to=20own=20module=20and=20add?= =?UTF-8?q?=20HTTP=20timeouts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extract TokenStoreStrategy into src/token_store_strategy.rs with comprehensive tests covering cascade prevention. Consolidate reqwest client creation into a shared http_client() helper with 10s connect and 30s request timeouts (disabled in tests to avoid conflicts with tokio's paused time). --- packages/stack-auth/src/device_code/mod.rs | 6 +- packages/stack-auth/src/lib.rs | 26 +- packages/stack-auth/src/token.rs | 4 +- packages/stack-auth/src/token_store.rs | 77 +--- .../stack-auth/src/token_store_strategy.rs | 394 ++++++++++++++++++ 5 files changed, 425 insertions(+), 82 deletions(-) create mode 100644 packages/stack-auth/src/token_store_strategy.rs diff --git a/packages/stack-auth/src/device_code/mod.rs b/packages/stack-auth/src/device_code/mod.rs index 3035909e7..be2d6270b 100644 --- a/packages/stack-auth/src/device_code/mod.rs +++ b/packages/stack-auth/src/device_code/mod.rs @@ -5,7 +5,7 @@ use url::Url; use std::time::{SystemTime, UNIX_EPOCH}; -use crate::{token_store::TokenStore, AuthError, Token}; +use crate::{http_client, token_store::TokenStore, AuthError, Token}; use protocol::{ DeviceCode, DeviceCodeRequest, DeviceCodeResponse, ErrorResponse, TokenRequest, TokenResponse, }; @@ -83,7 +83,7 @@ impl DeviceCodeStrategy { /// Returns [`AuthError::InvalidClient`] if the client ID is not recognized, /// or [`AuthError::Request`] if the server is unreachable. pub async fn begin(&self) -> Result { - let client = reqwest::Client::new(); + let client = http_client(); let code_url = self.base_url.join("oauth/device/code")?; @@ -208,7 +208,7 @@ impl PendingDeviceCode { /// authorized. /// - [`AuthError::Request`] — a network error occurred while polling. pub async fn poll_for_token(self) -> Result { - let client = reqwest::Client::new(); + let client = http_client(); let mut interval = tokio::time::Duration::from_secs(5); let deadline = tokio::time::Instant::now() + tokio::time::Duration::from_secs(self.expires_in); diff --git a/packages/stack-auth/src/lib.rs b/packages/stack-auth/src/lib.rs index 10c5c0d9e..7baeec7db 100644 --- a/packages/stack-auth/src/lib.rs +++ b/packages/stack-auth/src/lib.rs @@ -64,6 +64,8 @@ #![cfg_attr(test, allow(unused_results))] use std::convert::Infallible; +#[cfg(not(test))] +use std::time::Duration; use vitaminc::protected::OpaqueDebug; use zeroize::ZeroizeOnDrop; @@ -71,10 +73,12 @@ use zeroize::ZeroizeOnDrop; mod device_code; mod token; mod token_store; +mod token_store_strategy; pub use device_code::{DeviceCodeStrategy, PendingDeviceCode}; pub use token::Token; -pub use token_store::{TokenStore, TokenStoreError, TokenStoreStrategy}; +pub use token_store::{TokenStore, TokenStoreError}; +pub use token_store_strategy::TokenStoreStrategy; /// A strategy for obtaining a [`SecretToken`] for authenticating with CipherStash services. /// @@ -161,3 +165,23 @@ impl From for AuthError { match never {} } } + +/// Create a [`reqwest::Client`] with standard timeouts. +/// +/// In test builds, timeouts are omitted so that `tokio::test(start_paused = true)` +/// does not auto-advance time past the connect timeout before the mock server +/// can respond. +pub(crate) fn http_client() -> reqwest::Client { + #[cfg(test)] + { + reqwest::Client::new() + } + #[cfg(not(test))] + { + reqwest::Client::builder() + .connect_timeout(Duration::from_secs(10)) + .timeout(Duration::from_secs(30)) + .build() + .unwrap_or_else(|_| reqwest::Client::new()) + } +} diff --git a/packages/stack-auth/src/token.rs b/packages/stack-auth/src/token.rs index 38c922d75..71bc663a8 100644 --- a/packages/stack-auth/src/token.rs +++ b/packages/stack-auth/src/token.rs @@ -2,7 +2,7 @@ use std::time::{SystemTime, UNIX_EPOCH}; use url::Url; -use crate::{AuthError, SecretToken}; +use crate::{http_client, AuthError, SecretToken}; /// An access token returned by a successful authentication flow. /// @@ -91,7 +91,7 @@ impl Token { tracing::debug!(url = %token_url, "refreshing token"); - let resp = reqwest::Client::new() + let resp = http_client() .post(token_url) .form(&RefreshRequest { grant_type: "refresh_token", diff --git a/packages/stack-auth/src/token_store.rs b/packages/stack-auth/src/token_store.rs index 015dadc61..75ae465c4 100644 --- a/packages/stack-auth/src/token_store.rs +++ b/packages/stack-auth/src/token_store.rs @@ -1,6 +1,5 @@ -use crate::{AuthError, AuthStrategy, SecretToken, Token}; +use crate::{AuthError, Token}; use std::path::{Path, PathBuf}; -use url::Url; /// Errors that can occur when reading or writing the token store. #[derive(Debug, thiserror::Error)] @@ -95,80 +94,6 @@ impl TokenStore { } } -/// An [`AuthStrategy`] that loads a token from a [`TokenStore`], caches it in -/// memory, and preemptively refreshes it before it expires. -/// -/// The token is loaded from disk on the first call to [`get_token`](AuthStrategy::get_token) -/// and cached for subsequent calls. When the token is within 60 seconds of -/// expiry and a refresh token is available, the strategy automatically refreshes -/// it and persists the new token to disk. -pub struct TokenStoreStrategy { - store: TokenStore, - base_url: Url, - client_id: String, - token: Option, -} - -impl TokenStoreStrategy { - /// Create a new `TokenStoreStrategy`. - /// - /// The `base_url` and `client_id` are used when refreshing an expired token - /// via the `/oauth/token` endpoint. - pub fn new(store: TokenStore, base_url: Url, client_id: impl Into) -> Self { - Self { - store, - base_url, - client_id: client_id.into(), - token: None, - } - } -} - -impl<'a> AuthStrategy<'a> for &'a mut TokenStoreStrategy { - type Error = TokenStoreError; - - async fn get_token(self) -> Result<&'a SecretToken, Self::Error> { - // Load from disk if not yet cached. - if self.token.is_none() { - let token = self.store.load()?.ok_or(TokenStoreError::NotFound)?; - self.token = Some(token); - } - - // Preemptively refresh if the token is expiring within 60 seconds. - // Take only the refresh token so the access token stays available - // for other callers and subsequent calls won't attempt a concurrent - // refresh (they'll see refresh_token is None and skip this block). - let refresh_token = self - .token - .as_mut() - .filter(|t| t.is_expired()) - .and_then(|t| t.take_refresh_token()); - - if let Some(refresh_token) = refresh_token { - match Token::exchange_refresh_token(&refresh_token, &self.base_url, &self.client_id) - .await - { - Ok(new_token) => { - match self.store.save(&new_token) { - Ok(()) => tracing::debug!("refreshed token saved to disk"), - Err(err) => tracing::warn!(%err, "failed to save refreshed token to disk"), - } - self.token = Some(new_token); - } - Err(err) => { - tracing::warn!(%err, "token refresh failed"); - } - } - } - - let token = self.token.as_ref().ok_or(TokenStoreError::NotFound)?; - if token.is_expired() { - return Err(TokenStoreError::Expired); - } - Ok(token.access_token()) - } -} - #[cfg(test)] mod tests { use super::*; diff --git a/packages/stack-auth/src/token_store_strategy.rs b/packages/stack-auth/src/token_store_strategy.rs new file mode 100644 index 000000000..16d692129 --- /dev/null +++ b/packages/stack-auth/src/token_store_strategy.rs @@ -0,0 +1,394 @@ +use url::Url; + +use crate::token_store::{TokenStore, TokenStoreError}; +use crate::{AuthStrategy, SecretToken, Token}; + +/// An [`AuthStrategy`] that loads a token from a [`TokenStore`], caches it in +/// memory, and preemptively refreshes it before it expires. +/// +/// The token is loaded from disk on the first call to [`get_token`](AuthStrategy::get_token) +/// and cached for subsequent calls. When the token is within 60 seconds of +/// expiry and a refresh token is available, the strategy automatically refreshes +/// it and persists the new token to disk. +pub struct TokenStoreStrategy { + store: TokenStore, + base_url: Url, + client_id: String, + token: Option, +} + +impl TokenStoreStrategy { + /// Create a new `TokenStoreStrategy`. + /// + /// The `base_url` and `client_id` are used when refreshing an expired token + /// via the `/oauth/token` endpoint. + pub fn new(store: TokenStore, base_url: Url, client_id: impl Into) -> Self { + Self { + store, + base_url, + client_id: client_id.into(), + token: None, + } + } +} + +impl<'a> AuthStrategy<'a> for &'a mut TokenStoreStrategy { + type Error = TokenStoreError; + + async fn get_token(self) -> Result<&'a SecretToken, Self::Error> { + // Load from disk if not yet cached. + if self.token.is_none() { + let token = self.store.load()?.ok_or(TokenStoreError::NotFound)?; + self.token = Some(token); + } + + // Preemptively refresh if the token is expiring within 60 seconds. + // Take only the refresh token so the access token stays available + // for other callers and subsequent calls won't attempt a concurrent + // refresh (they'll see refresh_token is None and skip this block). + let refresh_token = self + .token + .as_mut() + .filter(|t| t.is_expired()) + .and_then(|t| t.take_refresh_token()); + + if let Some(refresh_token) = refresh_token { + match Token::exchange_refresh_token(&refresh_token, &self.base_url, &self.client_id) + .await + { + Ok(new_token) => { + match self.store.save(&new_token) { + Ok(()) => tracing::debug!("refreshed token saved to disk"), + Err(err) => tracing::warn!(%err, "failed to save refreshed token to disk"), + } + self.token = Some(new_token); + } + Err(err) => { + tracing::warn!(%err, "token refresh failed"); + } + } + } + + let token = self.token.as_ref().ok_or(TokenStoreError::NotFound)?; + if token.is_expired() { + return Err(TokenStoreError::Expired); + } + Ok(token.access_token()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use mocktail::prelude::*; + use std::time::{SystemTime, UNIX_EPOCH}; + + fn make_token(access: &str, expires_in: u64, refresh: bool) -> Token { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs(); + + Token { + access_token: SecretToken::new(access), + token_type: "Bearer".to_string(), + expires_at: now + expires_in, + refresh_token: if refresh { + Some(SecretToken::new("test-refresh-token")) + } else { + None + }, + } + } + + fn refresh_response_json(access: &str) -> serde_json::Value { + serde_json::json!({ + "access_token": access, + "token_type": "Bearer", + "expires_in": 3600, + "refresh_token": "new-refresh-token" + }) + } + + fn error_json(error: &str) -> serde_json::Value { + serde_json::json!({ + "error": error, + "error_description": format!("{error} occurred") + }) + } + + async fn start_server(mocks: MockSet) -> MockServer { + let server = MockServer::new_http("token-store-strategy-test").with_mocks(mocks); + server.start().await.unwrap(); + server + } + + fn strategy_with_token( + dir: &tempfile::TempDir, + server: &MockServer, + token: Token, + ) -> TokenStoreStrategy { + let store = TokenStore::new(dir.path().join("auth.json")); + store.save(&token).unwrap(); + TokenStoreStrategy::new(store, server.url(""), "cli") + } + + // ---- Basic loading tests ---- + + #[tokio::test] + async fn test_loads_token_from_disk() { + let dir = tempfile::tempdir().unwrap(); + let server = start_server(MockSet::new()).await; + let mut strategy = + strategy_with_token(&dir, &server, make_token("my-access-token", 3600, false)); + + let token = (&mut strategy).get_token().await.unwrap(); + + assert_eq!(token.as_str(), "my-access-token"); + } + + #[tokio::test] + async fn test_returns_not_found_when_no_token_on_disk() { + let dir = tempfile::tempdir().unwrap(); + let server = start_server(MockSet::new()).await; + let store = TokenStore::new(dir.path().join("auth.json")); + let mut strategy = TokenStoreStrategy::new(store, server.url(""), "cli"); + + let err = (&mut strategy).get_token().await.unwrap_err(); + + assert!(matches!(err, TokenStoreError::NotFound)); + } + + #[tokio::test] + async fn test_caches_token_across_calls() { + let dir = tempfile::tempdir().unwrap(); + let server = start_server(MockSet::new()).await; + let mut strategy = + strategy_with_token(&dir, &server, make_token("my-access-token", 3600, false)); + + let token1 = (&mut strategy).get_token().await.unwrap(); + assert_eq!(token1.as_str(), "my-access-token"); + + // Delete the file — second call should still return the cached token. + std::fs::remove_file(dir.path().join("auth.json")).unwrap(); + + let token2 = (&mut strategy).get_token().await.unwrap(); + assert_eq!(token2.as_str(), "my-access-token"); + } + + // ---- Expiry tests ---- + + #[tokio::test] + async fn test_expired_token_without_refresh_token_returns_expired() { + let dir = tempfile::tempdir().unwrap(); + let server = start_server(MockSet::new()).await; + let mut strategy = + strategy_with_token(&dir, &server, make_token("old-token", 0, false)); + + let err = (&mut strategy).get_token().await.unwrap_err(); + + assert!(matches!(err, TokenStoreError::Expired)); + } + + // ---- Refresh tests ---- + + #[tokio::test] + async fn test_refreshes_expiring_token() { + let mut mocks = MockSet::new(); + mocks.mock(|when, then| { + when.post().path("/oauth/token"); + then.json(refresh_response_json("refreshed-token")); + }); + let server = start_server(mocks).await; + let dir = tempfile::tempdir().unwrap(); + let mut strategy = + strategy_with_token(&dir, &server, make_token("old-token", 0, true)); + + let token = (&mut strategy).get_token().await.unwrap(); + + assert_eq!(token.as_str(), "refreshed-token"); + } + + #[tokio::test] + async fn test_refresh_persists_new_token_to_disk() { + let mut mocks = MockSet::new(); + mocks.mock(|when, then| { + when.post().path("/oauth/token"); + then.json(refresh_response_json("refreshed-token")); + }); + let server = start_server(mocks).await; + let dir = tempfile::tempdir().unwrap(); + let mut strategy = + strategy_with_token(&dir, &server, make_token("old-token", 0, true)); + + let _ = (&mut strategy).get_token().await.unwrap(); + + // Verify the refreshed token was saved to disk. + let store = TokenStore::new(dir.path().join("auth.json")); + let on_disk = store.load().unwrap().unwrap(); + assert_eq!(on_disk.access_token().as_str(), "refreshed-token"); + } + + #[tokio::test] + async fn test_refresh_failure_returns_expired_when_token_is_expired() { + let mut mocks = MockSet::new(); + mocks.mock(|when, then| { + when.post().path("/oauth/token"); + then.bad_request().json(error_json("invalid_grant")); + }); + let server = start_server(mocks).await; + let dir = tempfile::tempdir().unwrap(); + let mut strategy = + strategy_with_token(&dir, &server, make_token("old-token", 0, true)); + + let err = (&mut strategy).get_token().await.unwrap_err(); + + assert!(matches!(err, TokenStoreError::Expired)); + } + + #[tokio::test] + async fn test_does_not_refresh_fresh_token() { + // Mock that would fail if hit — proves no refresh request is made. + let mut mocks = MockSet::new(); + mocks.mock(|when, then| { + when.post().path("/oauth/token"); + then.internal_server_error() + .json(error_json("should_not_be_called")); + }); + let server = start_server(mocks).await; + let dir = tempfile::tempdir().unwrap(); + let mut strategy = + strategy_with_token(&dir, &server, make_token("fresh-token", 3600, true)); + + let token = (&mut strategy).get_token().await.unwrap(); + + assert_eq!(token.as_str(), "fresh-token"); + } + + // ---- Cascade prevention tests ---- + + #[tokio::test] + async fn test_refresh_token_is_taken_preventing_second_refresh() { + // Set up a server that returns a new token on the first refresh but + // would fail on any subsequent refresh attempt. + let mut mocks = MockSet::new(); + mocks.mock(|when, then| { + when.post().path("/oauth/token"); + then.json(refresh_response_json("refreshed-token")); + }); + let server = start_server(mocks).await; + let dir = tempfile::tempdir().unwrap(); + let mut strategy = + strategy_with_token(&dir, &server, make_token("old-token", 0, true)); + + // First call refreshes successfully. + let token = (&mut strategy).get_token().await.unwrap(); + assert_eq!(token.as_str(), "refreshed-token"); + + // Replace the mock with one that errors — any refresh attempt would fail. + server.mocks().clear(); + server.mocks().mock(|when, then| { + when.post().path("/oauth/token"); + then.bad_request().json(error_json("should_not_be_called")); + }); + + // Second call should return the refreshed token without hitting + // the server again (the new token has a fresh expiry). + let token = (&mut strategy).get_token().await.unwrap(); + assert_eq!(token.as_str(), "refreshed-token"); + } + + #[tokio::test] + async fn test_failed_refresh_removes_refresh_token_preventing_cascade() { + let mut mocks = MockSet::new(); + mocks.mock(|when, then| { + when.post().path("/oauth/token"); + then.bad_request().json(error_json("invalid_grant")); + }); + let server = start_server(mocks).await; + let dir = tempfile::tempdir().unwrap(); + let mut strategy = + strategy_with_token(&dir, &server, make_token("old-token", 0, true)); + + // First call: refresh fails, returns Expired. + let err = (&mut strategy).get_token().await.unwrap_err(); + assert!(matches!(err, TokenStoreError::Expired)); + + // Verify the refresh token has been consumed (taken out). + // The cached token should still exist but without a refresh token. + assert!(strategy.token.is_some()); + assert!(strategy.token.as_ref().unwrap().refresh_token().is_none()); + + // Replace mock with a success response to prove it's never called. + server.mocks().clear(); + server.mocks().mock(|when, then| { + when.post().path("/oauth/token"); + then.json(refresh_response_json("should-not-reach")); + }); + + // Second call: no refresh token → no refresh attempt → Expired again. + let err = (&mut strategy).get_token().await.unwrap_err(); + assert!(matches!(err, TokenStoreError::Expired)); + } + + #[tokio::test] + async fn test_access_token_remains_after_refresh_token_is_taken() { + let mut mocks = MockSet::new(); + mocks.mock(|when, then| { + when.post().path("/oauth/token"); + then.bad_request().json(error_json("server_error")); + }); + let server = start_server(mocks).await; + let dir = tempfile::tempdir().unwrap(); + // Token expires in 30s (within the 60s leeway so is_expired() = true), + // but the access token is still technically usable. + let mut strategy = + strategy_with_token(&dir, &server, make_token("still-usable", 30, true)); + + // The refresh fails, but the access token should still be in the cache. + // Since is_expired() returns true (30s < 60s leeway), get_token will + // return Expired, but the token itself is not destroyed. + let _ = (&mut strategy).get_token().await; + + // Verify the access token is still present in the cached token. + assert!(strategy.token.is_some()); + assert_eq!( + strategy.token.as_ref().unwrap().access_token().as_str(), + "still-usable" + ); + } + + #[tokio::test] + async fn test_multiple_sequential_calls_only_refresh_once() { + let mut mocks = MockSet::new(); + mocks.mock(|when, then| { + when.post().path("/oauth/token"); + then.json(refresh_response_json("refreshed-once")); + }); + let server = start_server(mocks).await; + let dir = tempfile::tempdir().unwrap(); + let mut strategy = + strategy_with_token(&dir, &server, make_token("old-token", 0, true)); + + // First call triggers refresh. + let token = (&mut strategy).get_token().await.unwrap(); + assert_eq!(token.as_str(), "refreshed-once"); + + // Swap mock to track if another refresh is attempted. + server.mocks().clear(); + server.mocks().mock(|when, then| { + when.post().path("/oauth/token"); + then.json(refresh_response_json("refreshed-twice")); + }); + + // Calls 2-5: the refreshed token is fresh, so no further refresh. + for _ in 0..4 { + let token = (&mut strategy).get_token().await.unwrap(); + assert_eq!( + token.as_str(), + "refreshed-once", + "should return cached refreshed token, not trigger another refresh" + ); + } + } +} From 88b6b06620ebc5241ba02b93018cb4c538de3871 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Fri, 20 Feb 2026 22:20:48 +1100 Subject: [PATCH 027/686] =?UTF-8?q?=E2=9C=A8=20feat(stack-auth):=20concurr?= =?UTF-8?q?ent=20access=20for=20TokenStoreStrategy?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Switch TokenStoreStrategy from &mut self to &self with tokio::sync::Mutex, enabling safe concurrent use. When a token is expiring but still usable, one caller refreshes while others get the current token without blocking. When fully expired, callers block until the refresh completes. - Add Clone to SecretToken - Change AuthStrategy::get_token to return Cow<'a, SecretToken> - Add Token::is_usable() to distinguish "should refresh" from "actually expired" - Rewrite TokenStoreStrategy with Mutex-based interior mutability --- packages/stack-auth/src/lib.rs | 8 +- packages/stack-auth/src/token.rs | 16 ++ .../stack-auth/src/token_store_strategy.rs | 272 ++++++++++++++---- 3 files changed, 235 insertions(+), 61 deletions(-) diff --git a/packages/stack-auth/src/lib.rs b/packages/stack-auth/src/lib.rs index 7baeec7db..390a60036 100644 --- a/packages/stack-auth/src/lib.rs +++ b/packages/stack-auth/src/lib.rs @@ -63,6 +63,7 @@ #![cfg_attr(test, allow(clippy::panic))] #![cfg_attr(test, allow(unused_results))] +use std::borrow::Cow; use std::convert::Infallible; #[cfg(not(test))] use std::time::Duration; @@ -94,9 +95,12 @@ pub trait AuthStrategy<'a> { type Error; /// Retrieve a valid access token. + /// + /// Returns `Cow::Borrowed` for strategies that own a stable token, or + /// `Cow::Owned` for strategies that clone the token out from behind a lock. fn get_token( self, - ) -> impl std::future::Future> + Send; + ) -> impl std::future::Future, Self::Error>> + Send; } /// A sensitive token string that is zeroized on drop and hidden from debug output. @@ -110,7 +114,7 @@ pub trait AuthStrategy<'a> { /// /// You cannot construct a `SecretToken` directly — it is returned by the /// authentication flow via [`Token::access_token`]. -#[derive(OpaqueDebug, ZeroizeOnDrop, serde::Deserialize, serde::Serialize)] +#[derive(Clone, OpaqueDebug, ZeroizeOnDrop, serde::Deserialize, serde::Serialize)] #[serde(transparent)] pub struct SecretToken(String); diff --git a/packages/stack-auth/src/token.rs b/packages/stack-auth/src/token.rs index 71bc663a8..ff092a7de 100644 --- a/packages/stack-auth/src/token.rs +++ b/packages/stack-auth/src/token.rs @@ -46,6 +46,10 @@ impl Token { } /// Returns `true` if the token has expired (with 60 seconds of leeway). + /// + /// Use this to decide whether a preemptive refresh should be attempted. + /// For checking whether the token is still usable as a bearer credential, + /// use [`is_usable`](Self::is_usable) instead. pub fn is_expired(&self) -> bool { let now = SystemTime::now() .duration_since(UNIX_EPOCH) @@ -54,6 +58,18 @@ impl Token { now + 60 >= self.expires_at } + /// Returns `true` if the token is still usable (before the actual expiry timestamp). + /// + /// Unlike [`is_expired`](Self::is_expired) which includes 60s leeway for preemptive + /// refresh, this only returns `false` when the token has genuinely expired. + pub fn is_usable(&self) -> bool { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + now < self.expires_at + } + /// Returns a reference to the refresh token, if one was provided. pub fn refresh_token(&self) -> Option<&SecretToken> { self.refresh_token.as_ref() diff --git a/packages/stack-auth/src/token_store_strategy.rs b/packages/stack-auth/src/token_store_strategy.rs index 16d692129..c75e99c7f 100644 --- a/packages/stack-auth/src/token_store_strategy.rs +++ b/packages/stack-auth/src/token_store_strategy.rs @@ -1,3 +1,6 @@ +use std::borrow::Cow; + +use tokio::sync::Mutex; use url::Url; use crate::token_store::{TokenStore, TokenStoreError}; @@ -10,10 +13,19 @@ use crate::{AuthStrategy, SecretToken, Token}; /// and cached for subsequent calls. When the token is within 60 seconds of /// expiry and a refresh token is available, the strategy automatically refreshes /// it and persists the new token to disk. +/// +/// This strategy is safe for concurrent use. When a token is expiring but still +/// usable, one caller performs the refresh while others continue to receive the +/// current token without blocking. When the token is fully expired, callers +/// block until the refresh completes. pub struct TokenStoreStrategy { store: TokenStore, base_url: Url, client_id: String, + state: Mutex, +} + +struct State { token: Option, } @@ -27,53 +39,102 @@ impl TokenStoreStrategy { store, base_url, client_id: client_id.into(), - token: None, + state: Mutex::new(State { token: None }), } } } -impl<'a> AuthStrategy<'a> for &'a mut TokenStoreStrategy { +impl<'a> AuthStrategy<'a> for &'a TokenStoreStrategy { type Error = TokenStoreError; - async fn get_token(self) -> Result<&'a SecretToken, Self::Error> { + async fn get_token(self) -> Result, Self::Error> { + let mut state = self.state.lock().await; + // Load from disk if not yet cached. - if self.token.is_none() { + if state.token.is_none() { let token = self.store.load()?.ok_or(TokenStoreError::NotFound)?; - self.token = Some(token); + state.token = Some(token); + } + + let needs_refresh = state.token.as_ref().is_some_and(|t| t.is_expired()); + if !needs_refresh { + // Token is fresh — clone and return. + let token = state.token.as_ref().ok_or(TokenStoreError::NotFound)?; + return Ok(Cow::Owned(token.access_token().clone())); } - // Preemptively refresh if the token is expiring within 60 seconds. - // Take only the refresh token so the access token stays available - // for other callers and subsequent calls won't attempt a concurrent - // refresh (they'll see refresh_token is None and skip this block). - let refresh_token = self - .token - .as_mut() - .filter(|t| t.is_expired()) - .and_then(|t| t.take_refresh_token()); - - if let Some(refresh_token) = refresh_token { + // Token needs refresh. Take the refresh token to prevent cascades. + let refresh_token = state.token.as_mut().and_then(|t| t.take_refresh_token()); + + let Some(refresh_token) = refresh_token else { + // No refresh token available. If the token is still usable (not + // actually expired, just within the 60s leeway), return it. + // Otherwise another caller is already refreshing (they took the + // refresh token) — if the token is usable, return it; if not, + // it's truly expired. + let token = state.token.as_ref().ok_or(TokenStoreError::NotFound)?; + if token.is_usable() { + return Ok(Cow::Owned(token.access_token().clone())); + } + return Err(TokenStoreError::Expired); + }; + + // We have a refresh token. Check if the current token is still usable. + let is_usable = state.token.as_ref().is_some_and(|t| t.is_usable()); + + if is_usable { + // Token is expiring but still usable. Clone the current access + // token, drop the lock, and refresh in the background of this call. + // Other callers can acquire the lock and get the still-valid token. + let current_access_token = state + .token + .as_ref() + .ok_or(TokenStoreError::NotFound)? + .access_token() + .clone(); + drop(state); + match Token::exchange_refresh_token(&refresh_token, &self.base_url, &self.client_id) .await { Ok(new_token) => { match self.store.save(&new_token) { Ok(()) => tracing::debug!("refreshed token saved to disk"), - Err(err) => tracing::warn!(%err, "failed to save refreshed token to disk"), + Err(err) => { + tracing::warn!(%err, "failed to save refreshed token to disk") + } } - self.token = Some(new_token); + self.state.lock().await.token = Some(new_token); } Err(err) => { - tracing::warn!(%err, "token refresh failed"); + tracing::warn!(%err, "token refresh failed (token still usable)"); } } - } - let token = self.token.as_ref().ok_or(TokenStoreError::NotFound)?; - if token.is_expired() { - return Err(TokenStoreError::Expired); + Ok(Cow::Owned(current_access_token)) + } else { + // Token is fully expired. Refresh while holding the lock so other + // callers block until the new token is available. + match Token::exchange_refresh_token(&refresh_token, &self.base_url, &self.client_id) + .await + { + Ok(new_token) => { + match self.store.save(&new_token) { + Ok(()) => tracing::debug!("refreshed token saved to disk"), + Err(err) => { + tracing::warn!(%err, "failed to save refreshed token to disk") + } + } + let access_token = new_token.access_token().clone(); + state.token = Some(new_token); + Ok(Cow::Owned(access_token)) + } + Err(err) => { + tracing::warn!(%err, "token refresh failed"); + Err(TokenStoreError::Expired) + } + } } - Ok(token.access_token()) } } @@ -81,6 +142,7 @@ impl<'a> AuthStrategy<'a> for &'a mut TokenStoreStrategy { mod tests { use super::*; use mocktail::prelude::*; + use std::sync::Arc; use std::time::{SystemTime, UNIX_EPOCH}; fn make_token(access: &str, expires_in: u64, refresh: bool) -> Token { @@ -139,10 +201,10 @@ mod tests { async fn test_loads_token_from_disk() { let dir = tempfile::tempdir().unwrap(); let server = start_server(MockSet::new()).await; - let mut strategy = + let strategy = strategy_with_token(&dir, &server, make_token("my-access-token", 3600, false)); - let token = (&mut strategy).get_token().await.unwrap(); + let token = (&strategy).get_token().await.unwrap(); assert_eq!(token.as_str(), "my-access-token"); } @@ -152,9 +214,9 @@ mod tests { let dir = tempfile::tempdir().unwrap(); let server = start_server(MockSet::new()).await; let store = TokenStore::new(dir.path().join("auth.json")); - let mut strategy = TokenStoreStrategy::new(store, server.url(""), "cli"); + let strategy = TokenStoreStrategy::new(store, server.url(""), "cli"); - let err = (&mut strategy).get_token().await.unwrap_err(); + let err = (&strategy).get_token().await.unwrap_err(); assert!(matches!(err, TokenStoreError::NotFound)); } @@ -163,16 +225,16 @@ mod tests { async fn test_caches_token_across_calls() { let dir = tempfile::tempdir().unwrap(); let server = start_server(MockSet::new()).await; - let mut strategy = + let strategy = strategy_with_token(&dir, &server, make_token("my-access-token", 3600, false)); - let token1 = (&mut strategy).get_token().await.unwrap(); + let token1 = (&strategy).get_token().await.unwrap(); assert_eq!(token1.as_str(), "my-access-token"); // Delete the file — second call should still return the cached token. std::fs::remove_file(dir.path().join("auth.json")).unwrap(); - let token2 = (&mut strategy).get_token().await.unwrap(); + let token2 = (&strategy).get_token().await.unwrap(); assert_eq!(token2.as_str(), "my-access-token"); } @@ -182,10 +244,10 @@ mod tests { async fn test_expired_token_without_refresh_token_returns_expired() { let dir = tempfile::tempdir().unwrap(); let server = start_server(MockSet::new()).await; - let mut strategy = + let strategy = strategy_with_token(&dir, &server, make_token("old-token", 0, false)); - let err = (&mut strategy).get_token().await.unwrap_err(); + let err = (&strategy).get_token().await.unwrap_err(); assert!(matches!(err, TokenStoreError::Expired)); } @@ -201,10 +263,10 @@ mod tests { }); let server = start_server(mocks).await; let dir = tempfile::tempdir().unwrap(); - let mut strategy = + let strategy = strategy_with_token(&dir, &server, make_token("old-token", 0, true)); - let token = (&mut strategy).get_token().await.unwrap(); + let token = (&strategy).get_token().await.unwrap(); assert_eq!(token.as_str(), "refreshed-token"); } @@ -218,10 +280,10 @@ mod tests { }); let server = start_server(mocks).await; let dir = tempfile::tempdir().unwrap(); - let mut strategy = + let strategy = strategy_with_token(&dir, &server, make_token("old-token", 0, true)); - let _ = (&mut strategy).get_token().await.unwrap(); + let _ = (&strategy).get_token().await.unwrap(); // Verify the refreshed token was saved to disk. let store = TokenStore::new(dir.path().join("auth.json")); @@ -238,10 +300,10 @@ mod tests { }); let server = start_server(mocks).await; let dir = tempfile::tempdir().unwrap(); - let mut strategy = + let strategy = strategy_with_token(&dir, &server, make_token("old-token", 0, true)); - let err = (&mut strategy).get_token().await.unwrap_err(); + let err = (&strategy).get_token().await.unwrap_err(); assert!(matches!(err, TokenStoreError::Expired)); } @@ -257,10 +319,10 @@ mod tests { }); let server = start_server(mocks).await; let dir = tempfile::tempdir().unwrap(); - let mut strategy = + let strategy = strategy_with_token(&dir, &server, make_token("fresh-token", 3600, true)); - let token = (&mut strategy).get_token().await.unwrap(); + let token = (&strategy).get_token().await.unwrap(); assert_eq!(token.as_str(), "fresh-token"); } @@ -278,11 +340,11 @@ mod tests { }); let server = start_server(mocks).await; let dir = tempfile::tempdir().unwrap(); - let mut strategy = + let strategy = strategy_with_token(&dir, &server, make_token("old-token", 0, true)); // First call refreshes successfully. - let token = (&mut strategy).get_token().await.unwrap(); + let token = (&strategy).get_token().await.unwrap(); assert_eq!(token.as_str(), "refreshed-token"); // Replace the mock with one that errors — any refresh attempt would fail. @@ -294,7 +356,7 @@ mod tests { // Second call should return the refreshed token without hitting // the server again (the new token has a fresh expiry). - let token = (&mut strategy).get_token().await.unwrap(); + let token = (&strategy).get_token().await.unwrap(); assert_eq!(token.as_str(), "refreshed-token"); } @@ -307,17 +369,19 @@ mod tests { }); let server = start_server(mocks).await; let dir = tempfile::tempdir().unwrap(); - let mut strategy = + let strategy = strategy_with_token(&dir, &server, make_token("old-token", 0, true)); // First call: refresh fails, returns Expired. - let err = (&mut strategy).get_token().await.unwrap_err(); + let err = (&strategy).get_token().await.unwrap_err(); assert!(matches!(err, TokenStoreError::Expired)); // Verify the refresh token has been consumed (taken out). // The cached token should still exist but without a refresh token. - assert!(strategy.token.is_some()); - assert!(strategy.token.as_ref().unwrap().refresh_token().is_none()); + let state = strategy.state.lock().await; + assert!(state.token.is_some()); + assert!(state.token.as_ref().unwrap().refresh_token().is_none()); + drop(state); // Replace mock with a success response to prove it's never called. server.mocks().clear(); @@ -327,7 +391,7 @@ mod tests { }); // Second call: no refresh token → no refresh attempt → Expired again. - let err = (&mut strategy).get_token().await.unwrap_err(); + let err = (&strategy).get_token().await.unwrap_err(); assert!(matches!(err, TokenStoreError::Expired)); } @@ -342,18 +406,19 @@ mod tests { let dir = tempfile::tempdir().unwrap(); // Token expires in 30s (within the 60s leeway so is_expired() = true), // but the access token is still technically usable. - let mut strategy = + let strategy = strategy_with_token(&dir, &server, make_token("still-usable", 30, true)); - // The refresh fails, but the access token should still be in the cache. - // Since is_expired() returns true (30s < 60s leeway), get_token will - // return Expired, but the token itself is not destroyed. - let _ = (&mut strategy).get_token().await; + // The refresh fails, but the access token should still be returned + // because it's still usable (30s remaining > 0). + let token = (&strategy).get_token().await.unwrap(); + assert_eq!(token.as_str(), "still-usable"); // Verify the access token is still present in the cached token. - assert!(strategy.token.is_some()); + let state = strategy.state.lock().await; + assert!(state.token.is_some()); assert_eq!( - strategy.token.as_ref().unwrap().access_token().as_str(), + state.token.as_ref().unwrap().access_token().as_str(), "still-usable" ); } @@ -367,11 +432,11 @@ mod tests { }); let server = start_server(mocks).await; let dir = tempfile::tempdir().unwrap(); - let mut strategy = + let strategy = strategy_with_token(&dir, &server, make_token("old-token", 0, true)); // First call triggers refresh. - let token = (&mut strategy).get_token().await.unwrap(); + let token = (&strategy).get_token().await.unwrap(); assert_eq!(token.as_str(), "refreshed-once"); // Swap mock to track if another refresh is attempted. @@ -383,7 +448,7 @@ mod tests { // Calls 2-5: the refreshed token is fresh, so no further refresh. for _ in 0..4 { - let token = (&mut strategy).get_token().await.unwrap(); + let token = (&strategy).get_token().await.unwrap(); assert_eq!( token.as_str(), "refreshed-once", @@ -391,4 +456,93 @@ mod tests { ); } } + + // ---- Concurrent access tests ---- + + #[tokio::test] + async fn test_concurrent_access_with_expiring_but_usable_token() { + // Token expires in 30s — is_expired() = true (within 60s leeway), + // but is_usable() = true (not actually expired). + let mut mocks = MockSet::new(); + mocks.mock(|when, then| { + when.post().path("/oauth/token"); + then.json(refresh_response_json("refreshed-token")); + }); + let server = start_server(mocks).await; + let dir = tempfile::tempdir().unwrap(); + let strategy = Arc::new(strategy_with_token( + &dir, + &server, + make_token("still-usable", 30, true), + )); + + // Spawn two concurrent callers. + let s1 = Arc::clone(&strategy); + let handle_a = tokio::spawn(async move { + let token = s1.as_ref().get_token().await.unwrap(); + token.into_owned() + }); + + let s2 = Arc::clone(&strategy); + let handle_b = tokio::spawn(async move { + let token = s2.as_ref().get_token().await.unwrap(); + token.into_owned() + }); + + let (result_a, result_b) = tokio::join!(handle_a, handle_b); + let token_a = result_a.unwrap(); + let token_b = result_b.unwrap(); + + // Both should succeed. One gets the old token (still usable), the + // other may get either old or refreshed depending on timing. + assert!( + token_a.as_str() == "still-usable" || token_a.as_str() == "refreshed-token", + "unexpected token_a: {}", + token_a.as_str() + ); + assert!( + token_b.as_str() == "still-usable" || token_b.as_str() == "refreshed-token", + "unexpected token_b: {}", + token_b.as_str() + ); + } + + #[tokio::test] + async fn test_concurrent_access_with_fully_expired_token() { + // Token is fully expired (expires_at in the past) with a refresh token. + let mut mocks = MockSet::new(); + mocks.mock(|when, then| { + when.post().path("/oauth/token"); + then.json(refresh_response_json("refreshed-token")); + }); + let server = start_server(mocks).await; + let dir = tempfile::tempdir().unwrap(); + let strategy = Arc::new(strategy_with_token( + &dir, + &server, + make_token("expired-token", 0, true), + )); + + // Spawn two concurrent callers. + let s1 = Arc::clone(&strategy); + let handle_a = tokio::spawn(async move { + let token = s1.as_ref().get_token().await.unwrap(); + token.into_owned() + }); + + let s2 = Arc::clone(&strategy); + let handle_b = tokio::spawn(async move { + let token = s2.as_ref().get_token().await.unwrap(); + token.into_owned() + }); + + let (result_a, result_b) = tokio::join!(handle_a, handle_b); + let token_a = result_a.unwrap(); + let token_b = result_b.unwrap(); + + // Both should get the refreshed token (one blocks until the other + // finishes refreshing). + assert_eq!(token_a.as_str(), "refreshed-token"); + assert_eq!(token_b.as_str(), "refreshed-token"); + } } From 7750206c0a64f5965d6241ed8a9d9f91a482c197 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Fri, 20 Feb 2026 22:30:01 +1100 Subject: [PATCH 028/686] =?UTF-8?q?=F0=9F=93=9D=20docs(stack-auth):=20add?= =?UTF-8?q?=20rustdoc=20with=20mermaid=20flow=20diagram=20for=20TokenStore?= =?UTF-8?q?Strategy?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Document the concurrency model, lock behaviour, and cascade prevention strategy. Include a mermaid flowchart showing the get_token() decision tree rendered inline via aquamarine. --- packages/stack-auth/Cargo.toml | 1 + .../stack-auth/src/token_store_strategy.rs | 78 ++++++++++++++++++- 2 files changed, 75 insertions(+), 4 deletions(-) diff --git a/packages/stack-auth/Cargo.toml b/packages/stack-auth/Cargo.toml index 6f6d12393..3fe84384c 100644 --- a/packages/stack-auth/Cargo.toml +++ b/packages/stack-auth/Cargo.toml @@ -7,6 +7,7 @@ repository.workspace = true homepage.workspace = true [dependencies] +aquamarine = "0.6" cts-common = { workspace = true } dirs = "4.0.0" open = "5.3.2" diff --git a/packages/stack-auth/src/token_store_strategy.rs b/packages/stack-auth/src/token_store_strategy.rs index c75e99c7f..e51d707a3 100644 --- a/packages/stack-auth/src/token_store_strategy.rs +++ b/packages/stack-auth/src/token_store_strategy.rs @@ -14,10 +14,80 @@ use crate::{AuthStrategy, SecretToken, Token}; /// expiry and a refresh token is available, the strategy automatically refreshes /// it and persists the new token to disk. /// -/// This strategy is safe for concurrent use. When a token is expiring but still -/// usable, one caller performs the refresh while others continue to receive the -/// current token without blocking. When the token is fully expired, callers -/// block until the refresh completes. +/// # Concurrency model +/// +/// Internal state is protected by a [`tokio::sync::Mutex`]. The key design +/// decision is *when* the lock is held during a refresh, which depends on +/// whether the current token is still usable as a bearer credential: +/// +/// - [`Token::is_expired()`] — returns `true` when the token is within **60 +/// seconds** of its `expires_at` timestamp. This triggers a preemptive +/// refresh attempt. +/// - [`Token::is_usable()`] — returns `true` when the token has **not yet +/// reached** its `expires_at` timestamp. A token can be "expired" (in the +/// leeway sense) but still "usable" (the server will still accept it). +/// +/// This distinction enables two concurrent refresh strategies: +/// +/// 1. **Expiring but still usable** — The refreshing caller drops the lock +/// before making the HTTP request. Concurrent callers acquire the lock and +/// receive the current (still-valid) token immediately. +/// 2. **Fully expired** — The refreshing caller holds the lock through the +/// HTTP request. Concurrent callers block on `lock().await` until the +/// refresh completes, then see the new token. +/// +/// Cascade prevention: the first caller to detect an expiring token *takes* +/// the refresh token out of the cached [`Token`] (leaving `None`). Subsequent +/// callers see no refresh token and skip the refresh, returning the current +/// token if usable or [`TokenStoreError::Expired`] if not. +/// +/// # Flow diagram +/// +/// The following diagram shows the decision tree inside +/// [`get_token()`](AuthStrategy::get_token): +/// +/// ```mermaid +/// flowchart TD +/// Start["get_token()"] --> Lock["Acquire lock"] +/// Lock --> Cached{Token cached?} +/// Cached -- No --> Load["Load from disk"] +/// Load -- Not found --> ErrNotFound["Return NotFound"] +/// Load -- OK --> CheckRefresh +/// Cached -- Yes --> CheckRefresh{is_expired?} +/// +/// CheckRefresh -- "No (fresh)" --> CloneFresh["Clone access token, +/// release lock"] +/// CloneFresh --> ReturnOk["Return Ok(token)"] +/// +/// CheckRefresh -- "Yes (needs refresh)" --> TakeRT{take_refresh_token} +/// +/// TakeRT -- "None (already taken)" --> Usable1{is_usable?} +/// Usable1 -- Yes --> CloneUsable1["Clone access token, +/// release lock"] +/// CloneUsable1 --> ReturnOk +/// Usable1 -- No --> ErrExpired["Return Expired"] +/// +/// TakeRT -- "Some(refresh_token)" --> Usable2{is_usable?} +/// +/// Usable2 -- "Yes (expiring but usable)" --> DropLock["Clone access token, +/// release lock"] +/// DropLock --> HTTP1["HTTP refresh +/// (lock NOT held)"] +/// HTTP1 -- OK --> Relock1["Re-acquire lock, +/// store new token"] +/// HTTP1 -- Err --> LogWarn["Log warning +/// (token still usable)"] +/// Relock1 --> ReturnOld["Return Ok(old token)"] +/// LogWarn --> ReturnOld +/// +/// Usable2 -- "No (fully expired)" --> HTTP2["HTTP refresh +/// (lock HELD)"] +/// HTTP2 -- OK --> StoreNew["Store new token, +/// release lock"] +/// StoreNew --> ReturnNew["Return Ok(new token)"] +/// HTTP2 -- Err --> ErrExpired +/// ``` +#[cfg_attr(doc, aquamarine::aquamarine)] pub struct TokenStoreStrategy { store: TokenStore, base_url: Url, From 1377154b8f77fb3ae1a0e9bbe4e18a637ae78056 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Fri, 20 Feb 2026 23:27:54 +1100 Subject: [PATCH 029/686] =?UTF-8?q?=F0=9F=90=9B=20fix(stack-auth):=20resto?= =?UTF-8?q?re=20refresh=20token=20after=20failed=20refresh=20attempt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously, a transient refresh failure (e.g. HTTP timeout) permanently consumed the refresh token, making all subsequent calls return Expired. Now both the expiring-but-usable and fully-expired error paths restore the refresh token so the next call can retry. --- .../stack-auth/src/token_store_strategy.rs | 108 ++++++++++++------ 1 file changed, 76 insertions(+), 32 deletions(-) diff --git a/packages/stack-auth/src/token_store_strategy.rs b/packages/stack-auth/src/token_store_strategy.rs index e51d707a3..30158e695 100644 --- a/packages/stack-auth/src/token_store_strategy.rs +++ b/packages/stack-auth/src/token_store_strategy.rs @@ -75,17 +75,18 @@ use crate::{AuthStrategy, SecretToken, Token}; /// (lock NOT held)"] /// HTTP1 -- OK --> Relock1["Re-acquire lock, /// store new token"] -/// HTTP1 -- Err --> LogWarn["Log warning -/// (token still usable)"] +/// HTTP1 -- Err --> Restore1["Restore refresh token, +/// log warning"] /// Relock1 --> ReturnOld["Return Ok(old token)"] -/// LogWarn --> ReturnOld +/// Restore1 --> ReturnOld /// /// Usable2 -- "No (fully expired)" --> HTTP2["HTTP refresh /// (lock HELD)"] /// HTTP2 -- OK --> StoreNew["Store new token, /// release lock"] /// StoreNew --> ReturnNew["Return Ok(new token)"] -/// HTTP2 -- Err --> ErrExpired +/// HTTP2 -- Err --> Restore2["Restore refresh token"] +/// Restore2 --> ErrExpired /// ``` #[cfg_attr(doc, aquamarine::aquamarine)] pub struct TokenStoreStrategy { @@ -178,6 +179,10 @@ impl<'a> AuthStrategy<'a> for &'a TokenStoreStrategy { } Err(err) => { tracing::warn!(%err, "token refresh failed (token still usable)"); + // Restore the refresh token so the next call can retry. + if let Some(token) = self.state.lock().await.token.as_mut() { + token.refresh_token = Some(refresh_token); + } } } @@ -201,6 +206,10 @@ impl<'a> AuthStrategy<'a> for &'a TokenStoreStrategy { } Err(err) => { tracing::warn!(%err, "token refresh failed"); + // Restore the refresh token so the next call can retry. + if let Some(token) = state.token.as_mut() { + token.refresh_token = Some(refresh_token); + } Err(TokenStoreError::Expired) } } @@ -314,8 +323,7 @@ mod tests { async fn test_expired_token_without_refresh_token_returns_expired() { let dir = tempfile::tempdir().unwrap(); let server = start_server(MockSet::new()).await; - let strategy = - strategy_with_token(&dir, &server, make_token("old-token", 0, false)); + let strategy = strategy_with_token(&dir, &server, make_token("old-token", 0, false)); let err = (&strategy).get_token().await.unwrap_err(); @@ -333,8 +341,7 @@ mod tests { }); let server = start_server(mocks).await; let dir = tempfile::tempdir().unwrap(); - let strategy = - strategy_with_token(&dir, &server, make_token("old-token", 0, true)); + let strategy = strategy_with_token(&dir, &server, make_token("old-token", 0, true)); let token = (&strategy).get_token().await.unwrap(); @@ -350,8 +357,7 @@ mod tests { }); let server = start_server(mocks).await; let dir = tempfile::tempdir().unwrap(); - let strategy = - strategy_with_token(&dir, &server, make_token("old-token", 0, true)); + let strategy = strategy_with_token(&dir, &server, make_token("old-token", 0, true)); let _ = (&strategy).get_token().await.unwrap(); @@ -370,8 +376,7 @@ mod tests { }); let server = start_server(mocks).await; let dir = tempfile::tempdir().unwrap(); - let strategy = - strategy_with_token(&dir, &server, make_token("old-token", 0, true)); + let strategy = strategy_with_token(&dir, &server, make_token("old-token", 0, true)); let err = (&strategy).get_token().await.unwrap_err(); @@ -389,8 +394,7 @@ mod tests { }); let server = start_server(mocks).await; let dir = tempfile::tempdir().unwrap(); - let strategy = - strategy_with_token(&dir, &server, make_token("fresh-token", 3600, true)); + let strategy = strategy_with_token(&dir, &server, make_token("fresh-token", 3600, true)); let token = (&strategy).get_token().await.unwrap(); @@ -410,8 +414,7 @@ mod tests { }); let server = start_server(mocks).await; let dir = tempfile::tempdir().unwrap(); - let strategy = - strategy_with_token(&dir, &server, make_token("old-token", 0, true)); + let strategy = strategy_with_token(&dir, &server, make_token("old-token", 0, true)); // First call refreshes successfully. let token = (&strategy).get_token().await.unwrap(); @@ -431,7 +434,7 @@ mod tests { } #[tokio::test] - async fn test_failed_refresh_removes_refresh_token_preventing_cascade() { + async fn test_failed_refresh_restores_refresh_token_for_retry() { let mut mocks = MockSet::new(); mocks.mock(|when, then| { when.post().path("/oauth/token"); @@ -439,30 +442,28 @@ mod tests { }); let server = start_server(mocks).await; let dir = tempfile::tempdir().unwrap(); - let strategy = - strategy_with_token(&dir, &server, make_token("old-token", 0, true)); + let strategy = strategy_with_token(&dir, &server, make_token("old-token", 0, true)); // First call: refresh fails, returns Expired. let err = (&strategy).get_token().await.unwrap_err(); assert!(matches!(err, TokenStoreError::Expired)); - // Verify the refresh token has been consumed (taken out). - // The cached token should still exist but without a refresh token. + // Verify the refresh token was restored so a retry is possible. let state = strategy.state.lock().await; assert!(state.token.is_some()); - assert!(state.token.as_ref().unwrap().refresh_token().is_none()); + assert!(state.token.as_ref().unwrap().refresh_token().is_some()); drop(state); - // Replace mock with a success response to prove it's never called. + // Replace mock with a success response — the retry should use it. server.mocks().clear(); server.mocks().mock(|when, then| { when.post().path("/oauth/token"); - then.json(refresh_response_json("should-not-reach")); + then.json(refresh_response_json("refreshed-token")); }); - // Second call: no refresh token → no refresh attempt → Expired again. - let err = (&strategy).get_token().await.unwrap_err(); - assert!(matches!(err, TokenStoreError::Expired)); + // Second call: refresh token is available → retry succeeds. + let token = (&strategy).get_token().await.unwrap(); + assert_eq!(token.as_str(), "refreshed-token"); } #[tokio::test] @@ -476,21 +477,65 @@ mod tests { let dir = tempfile::tempdir().unwrap(); // Token expires in 30s (within the 60s leeway so is_expired() = true), // but the access token is still technically usable. - let strategy = - strategy_with_token(&dir, &server, make_token("still-usable", 30, true)); + let strategy = strategy_with_token(&dir, &server, make_token("still-usable", 30, true)); // The refresh fails, but the access token should still be returned // because it's still usable (30s remaining > 0). let token = (&strategy).get_token().await.unwrap(); assert_eq!(token.as_str(), "still-usable"); - // Verify the access token is still present in the cached token. + // Verify the access token and refresh token are still present. let state = strategy.state.lock().await; assert!(state.token.is_some()); assert_eq!( state.token.as_ref().unwrap().access_token().as_str(), "still-usable" ); + assert!( + state.token.as_ref().unwrap().refresh_token().is_some(), + "refresh token should be restored after failed refresh" + ); + } + + #[tokio::test] + async fn test_failed_refresh_of_usable_token_can_be_retried() { + let mut mocks = MockSet::new(); + mocks.mock(|when, then| { + when.post().path("/oauth/token"); + then.bad_request().json(error_json("server_error")); + }); + let server = start_server(mocks).await; + let dir = tempfile::tempdir().unwrap(); + // Token expires in 30s — is_expired() = true, is_usable() = true. + let strategy = strategy_with_token(&dir, &server, make_token("still-usable", 30, true)); + + // First call: refresh fails, but the still-usable token is returned. + let token = (&strategy).get_token().await.unwrap(); + assert_eq!(token.as_str(), "still-usable"); + + // Replace mock with a success response — the retry should use it. + server.mocks().clear(); + server.mocks().mock(|when, then| { + when.post().path("/oauth/token"); + then.json(refresh_response_json("refreshed-token")); + }); + + // Second call: refresh token was restored, so the retry succeeds. + // The caller still gets the old token (it's returned before the + // refresh completes), but the cache is updated. + let token = (&strategy).get_token().await.unwrap(); + assert!( + token.as_str() == "still-usable" || token.as_str() == "refreshed-token", + "expected old or refreshed token, got: {}", + token.as_str() + ); + + // Verify the cache now holds the refreshed token. + let state = strategy.state.lock().await; + assert_eq!( + state.token.as_ref().unwrap().access_token().as_str(), + "refreshed-token" + ); } #[tokio::test] @@ -502,8 +547,7 @@ mod tests { }); let server = start_server(mocks).await; let dir = tempfile::tempdir().unwrap(); - let strategy = - strategy_with_token(&dir, &server, make_token("old-token", 0, true)); + let strategy = strategy_with_token(&dir, &server, make_token("old-token", 0, true)); // First call triggers refresh. let token = (&strategy).get_token().await.unwrap(); From 1f8cc96c492b9ab4eac5196f575ffa426d6eff26 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Fri, 20 Feb 2026 23:57:09 +1100 Subject: [PATCH 030/686] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor(stack-aut?= =?UTF-8?q?h):=20make=20Token::refresh=20a=20static=20method?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the consuming `Token::refresh(self, ...)` with a static `Token::refresh(refresh_token: &SecretToken, ...)` that takes a bare refresh token. This matches how callers actually use it — the refresh token is managed independently (taken for cascade prevention, restored on failure). Remove the now-unused `AuthError::NoRefreshToken` variant. --- packages/stack-auth/src/lib.rs | 3 - packages/stack-auth/src/token.rs | 66 +++++++++---------- .../stack-auth/src/token_store_strategy.rs | 8 +-- 3 files changed, 34 insertions(+), 43 deletions(-) diff --git a/packages/stack-auth/src/lib.rs b/packages/stack-auth/src/lib.rs index 390a60036..ba2ca5de3 100644 --- a/packages/stack-auth/src/lib.rs +++ b/packages/stack-auth/src/lib.rs @@ -156,9 +156,6 @@ pub enum AuthError { /// The requested region is not supported. #[error("Unsupported region: {0}")] Region(#[from] cts_common::RegionError), - /// The token does not contain a refresh token. - #[error("No refresh token available")] - NoRefreshToken, /// An unexpected error was returned by the auth server. #[error("Server error: {0}")] Server(String), diff --git a/packages/stack-auth/src/token.rs b/packages/stack-auth/src/token.rs index ff092a7de..a59d183a3 100644 --- a/packages/stack-auth/src/token.rs +++ b/packages/stack-auth/src/token.rs @@ -80,25 +80,21 @@ impl Token { self.refresh_token.take() } - /// Refresh this token using the `/oauth/token` endpoint. + /// Exchange a refresh token for a new [`Token`] via the `/oauth/token` + /// endpoint. /// - /// Consumes `self` and returns a new [`Token`] with a fresh access token. - /// The `base_url` should be the CTS auth server base URL (e.g. from service - /// discovery) and `client_id` the OAuth client identifier. + /// This is a static constructor — it takes a bare [`SecretToken`] (the + /// refresh token) rather than operating on an existing `Token`. This + /// allows callers to manage the refresh token lifecycle independently + /// (e.g. taking it out of a cached token for cascade prevention and + /// restoring it on failure). /// /// # Errors /// - /// - [`AuthError::NoRefreshToken`] — this token has no refresh token. /// - [`AuthError::InvalidGrant`] — the refresh token was revoked or expired. /// - [`AuthError::InvalidClient`] — the client ID is not recognized. /// - [`AuthError::Request`] — a network error occurred. - pub async fn refresh(self, base_url: &Url, client_id: &str) -> Result { - let refresh_token = self.refresh_token.ok_or(AuthError::NoRefreshToken)?; - Self::exchange_refresh_token(&refresh_token, base_url, client_id).await - } - - /// Exchange a refresh token for a new [`Token`]. - pub(crate) async fn exchange_refresh_token( + pub async fn refresh( refresh_token: &SecretToken, base_url: &Url, client_id: &str, @@ -234,8 +230,10 @@ mod tests { let server = start_server(mocks).await; let base_url = server.url(""); - let token = make_token(3600, true); - let refreshed = token.refresh(&base_url, "cli").await.unwrap(); + let refresh_token = SecretToken::new("test-refresh-token"); + let refreshed = Token::refresh(&refresh_token, &base_url, "cli") + .await + .unwrap(); assert_eq!(refreshed.access_token().as_str(), "new-access-token"); assert_eq!(refreshed.token_type(), "Bearer"); @@ -247,16 +245,6 @@ mod tests { assert!((3598..=3600).contains(&refreshed.expires_in())); } - #[tokio::test] - async fn test_refresh_without_refresh_token() { - let token = make_token(3600, false); - let base_url = Url::parse("http://localhost:9999").unwrap(); - - let err = token.refresh(&base_url, "cli").await.unwrap_err(); - - assert!(matches!(err, AuthError::NoRefreshToken)); - } - #[tokio::test] async fn test_refresh_invalid_grant() { let mut mocks = MockSet::new(); @@ -267,8 +255,10 @@ mod tests { let server = start_server(mocks).await; let base_url = server.url(""); - let token = make_token(3600, true); - let err = token.refresh(&base_url, "cli").await.unwrap_err(); + let refresh_token = SecretToken::new("test-refresh-token"); + let err = Token::refresh(&refresh_token, &base_url, "cli") + .await + .unwrap_err(); assert!(matches!(err, AuthError::InvalidGrant)); } @@ -283,8 +273,10 @@ mod tests { let server = start_server(mocks).await; let base_url = server.url(""); - let token = make_token(3600, true); - let err = token.refresh(&base_url, "cli").await.unwrap_err(); + let refresh_token = SecretToken::new("test-refresh-token"); + let err = Token::refresh(&refresh_token, &base_url, "cli") + .await + .unwrap_err(); assert!(matches!(err, AuthError::InvalidClient)); } @@ -299,8 +291,10 @@ mod tests { let server = start_server(mocks).await; let base_url = server.url(""); - let token = make_token(3600, true); - let err = token.refresh(&base_url, "cli").await.unwrap_err(); + let refresh_token = SecretToken::new("test-refresh-token"); + let err = Token::refresh(&refresh_token, &base_url, "cli") + .await + .unwrap_err(); assert!(matches!(err, AuthError::AccessDenied)); } @@ -315,8 +309,10 @@ mod tests { let server = start_server(mocks).await; let base_url = server.url(""); - let token = make_token(3600, true); - let err = token.refresh(&base_url, "cli").await.unwrap_err(); + let refresh_token = SecretToken::new("test-refresh-token"); + let err = Token::refresh(&refresh_token, &base_url, "cli") + .await + .unwrap_err(); assert!(matches!(&err, AuthError::Server(desc) if desc == "something_unexpected occurred")); } @@ -335,8 +331,10 @@ mod tests { let server = start_server(mocks).await; let base_url = server.url(""); - let token = make_token(3600, true); - let refreshed = token.refresh(&base_url, "cli").await.unwrap(); + let refresh_token = SecretToken::new("test-refresh-token"); + let refreshed = Token::refresh(&refresh_token, &base_url, "cli") + .await + .unwrap(); assert_eq!(refreshed.access_token().as_str(), "new-access-token"); assert!(refreshed.refresh_token().is_none()); diff --git a/packages/stack-auth/src/token_store_strategy.rs b/packages/stack-auth/src/token_store_strategy.rs index 30158e695..58a227c20 100644 --- a/packages/stack-auth/src/token_store_strategy.rs +++ b/packages/stack-auth/src/token_store_strategy.rs @@ -165,9 +165,7 @@ impl<'a> AuthStrategy<'a> for &'a TokenStoreStrategy { .clone(); drop(state); - match Token::exchange_refresh_token(&refresh_token, &self.base_url, &self.client_id) - .await - { + match Token::refresh(&refresh_token, &self.base_url, &self.client_id).await { Ok(new_token) => { match self.store.save(&new_token) { Ok(()) => tracing::debug!("refreshed token saved to disk"), @@ -190,9 +188,7 @@ impl<'a> AuthStrategy<'a> for &'a TokenStoreStrategy { } else { // Token is fully expired. Refresh while holding the lock so other // callers block until the new token is available. - match Token::exchange_refresh_token(&refresh_token, &self.base_url, &self.client_id) - .await - { + match Token::refresh(&refresh_token, &self.base_url, &self.client_id).await { Ok(new_token) => { match self.store.save(&new_token) { Ok(()) => tracing::debug!("refreshed token saved to disk"), From fe3e98756c1e7c6346405bf9d68c0b64b0d30bf7 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Sat, 21 Feb 2026 10:58:26 +1100 Subject: [PATCH 031/686] =?UTF-8?q?=E2=9C=85=20test(stack-auth):=20add=20c?= =?UTF-8?q?oncurrency=20stress=20tests=20for=20TokenStoreStrategy?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Verify behavioral invariants under high concurrency (50 concurrent callers) using custom axum mock servers with configurable response delays: - Fresh token: all callers return immediately, zero server hits - Expiring but usable: non-refreshing callers complete within 100ms, only one refresh request hits the server - Fully expired: all callers block until refresh completes, single server hit with peak concurrency of 1 - Refresh failure recovery: refresh token is restored, enabling retry - Failure then retry: first wave fails, second wave succeeds --- packages/stack-auth/Cargo.toml | 1 + .../stack-auth/src/token_store_strategy.rs | 456 ++++++++++++++++++ 2 files changed, 457 insertions(+) diff --git a/packages/stack-auth/Cargo.toml b/packages/stack-auth/Cargo.toml index 3fe84384c..a0278bac8 100644 --- a/packages/stack-auth/Cargo.toml +++ b/packages/stack-auth/Cargo.toml @@ -29,6 +29,7 @@ name = "device_code" required-features = ["test-utils"] [dev-dependencies] +axum = "0.8" cts-common = { workspace = true } mocktail = "0.3.0" tempfile = "3.21.0" diff --git a/packages/stack-auth/src/token_store_strategy.rs b/packages/stack-auth/src/token_store_strategy.rs index 58a227c20..8afa67629 100644 --- a/packages/stack-auth/src/token_store_strategy.rs +++ b/packages/stack-auth/src/token_store_strategy.rs @@ -656,3 +656,459 @@ mod tests { assert_eq!(token_b.as_str(), "refreshed-token"); } } + +#[cfg(test)] +mod stress_tests { + use super::*; + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::sync::Arc; + use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; + + /// Tracks in-flight and peak concurrency for test assertions. + #[derive(Clone)] + struct CountingState { + total: Arc, + current: Arc, + peak: Arc, + } + + impl CountingState { + fn new() -> Self { + Self { + total: Arc::new(AtomicUsize::new(0)), + current: Arc::new(AtomicUsize::new(0)), + peak: Arc::new(AtomicUsize::new(0)), + } + } + + fn enter(&self) { + self.total.fetch_add(1, Ordering::SeqCst); + let prev = self.current.fetch_add(1, Ordering::SeqCst); + self.peak.fetch_max(prev + 1, Ordering::SeqCst); + } + + fn exit(&self) { + self.current.fetch_sub(1, Ordering::SeqCst); + } + + fn peak(&self) -> usize { + self.peak.load(Ordering::SeqCst) + } + + fn total(&self) -> usize { + self.total.load(Ordering::SeqCst) + } + } + + #[derive(Clone)] + struct DelayedRefreshState { + counting: CountingState, + delay: Duration, + } + + async fn delayed_refresh_handler( + axum::extract::State(state): axum::extract::State, + ) -> axum::Json { + state.counting.enter(); + tokio::time::sleep(state.delay).await; + state.counting.exit(); + axum::Json(serde_json::json!({ + "access_token": "refreshed-token", + "token_type": "Bearer", + "expires_in": 3600, + "refresh_token": "new-refresh-token" + })) + } + + async fn delayed_error_handler( + axum::extract::State(state): axum::extract::State, + ) -> (axum::http::StatusCode, axum::Json) { + state.counting.enter(); + tokio::time::sleep(state.delay).await; + state.counting.exit(); + ( + axum::http::StatusCode::BAD_REQUEST, + axum::Json(serde_json::json!({ + "error": "invalid_grant", + "error_description": "invalid_grant occurred" + })), + ) + } + + /// Starts an axum server and returns (base_url, counting_state). + async fn start_axum_server(handler: H, state: DelayedRefreshState) -> (Url, CountingState) + where + H: axum::handler::Handler + Clone + Send + 'static, + T: 'static, + { + let counting = state.counting.clone(); + let app = axum::Router::new() + .route("/oauth/token", axum::routing::post(handler)) + .with_state(state); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { + axum::serve(listener, app).await.unwrap(); + }); + let base_url = Url::parse(&format!("http://{addr}")).unwrap(); + (base_url, counting) + } + + fn make_token(access: &str, expires_in: u64, refresh: bool) -> Token { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs(); + + Token { + access_token: SecretToken::new(access), + token_type: "Bearer".to_string(), + expires_at: now + expires_in, + refresh_token: if refresh { + Some(SecretToken::new("test-refresh-token")) + } else { + None + }, + } + } + + fn strategy_with_token( + dir: &tempfile::TempDir, + base_url: &Url, + token: Token, + ) -> TokenStoreStrategy { + let store = TokenStore::new(dir.path().join("auth.json")); + store.save(&token).unwrap(); + TokenStoreStrategy::new(store, base_url.clone(), "cli") + } + + const CONCURRENCY: usize = 50; + + /// Fresh token (baseline) — Token is valid, no refresh needed. + /// N concurrent callers should all return quickly with 0 server hits. + #[tokio::test(flavor = "multi_thread", worker_threads = 4)] + async fn test_concurrent_fresh_token_no_contention() { + let counting = CountingState::new(); + let state = DelayedRefreshState { + counting: counting.clone(), + delay: Duration::from_millis(500), + }; + let (base_url, stats) = start_axum_server(delayed_refresh_handler, state).await; + let dir = tempfile::tempdir().unwrap(); + let strategy = Arc::new(strategy_with_token( + &dir, + &base_url, + make_token("fresh-token", 3600, true), + )); + + let start = Instant::now(); + let mut handles = Vec::with_capacity(CONCURRENCY); + for _ in 0..CONCURRENCY { + let s = Arc::clone(&strategy); + handles.push(tokio::spawn(async move { + let token = s.as_ref().get_token().await.unwrap(); + token.into_owned() + })); + } + + let results: Vec<_> = { + let mut results = Vec::with_capacity(handles.len()); + for handle in handles { + results.push(handle.await.unwrap()); + } + results + }; + let elapsed = start.elapsed(); + + // All callers should get the fresh token. + for token in &results { + assert_eq!(token.as_str(), "fresh-token"); + } + + // Should complete quickly — no server round-trips. + assert!( + elapsed < Duration::from_millis(200), + "expected < 200ms for fresh tokens, got {:?}", + elapsed + ); + assert_eq!(stats.total(), 0, "no refresh requests should be made"); + } + + /// Preemptive refresh with latency — Token is expiring but still usable. + /// Non-refreshing callers should return immediately; only the refreshing + /// caller pays the latency cost. + #[tokio::test(flavor = "multi_thread", worker_threads = 4)] + async fn test_concurrent_expiring_token_non_blocking_reads() { + let counting = CountingState::new(); + let state = DelayedRefreshState { + counting: counting.clone(), + delay: Duration::from_millis(500), + }; + let (base_url, stats) = start_axum_server(delayed_refresh_handler, state).await; + let dir = tempfile::tempdir().unwrap(); + // Token expires in 30s — is_expired() = true (within 60s leeway), + // but is_usable() = true (hasn't actually expired). + let strategy = Arc::new(strategy_with_token( + &dir, + &base_url, + make_token("still-usable", 30, true), + )); + + let start = Instant::now(); + let mut handles = Vec::with_capacity(CONCURRENCY); + for _ in 0..CONCURRENCY { + let s = Arc::clone(&strategy); + handles.push(tokio::spawn(async move { + let call_start = Instant::now(); + let token = s.as_ref().get_token().await.unwrap(); + (token.into_owned(), call_start.elapsed()) + })); + } + + let results: Vec<_> = { + let mut results = Vec::with_capacity(handles.len()); + for handle in handles { + results.push(handle.await.unwrap()); + } + results + }; + let elapsed = start.elapsed(); + + // All callers should get either the old (still usable) or the refreshed token. + for (token, _) in &results { + assert!( + token.as_str() == "still-usable" || token.as_str() == "refreshed-token", + "unexpected token: {}", + token.as_str() + ); + } + + // At least N-1 callers should complete quickly (they get the cached token + // while the refresher holds the lock only briefly before dropping it). + let fast_callers = results + .iter() + .filter(|(_, dur)| *dur < Duration::from_millis(100)) + .count(); + assert!( + fast_callers >= CONCURRENCY - 1, + "expected at least {} fast callers, got {} (total elapsed: {:?})", + CONCURRENCY - 1, + fast_callers, + elapsed + ); + + // Only one refresh request should hit the server. + assert_eq!(stats.peak(), 1, "peak concurrency to refresh endpoint"); + assert_eq!(stats.total(), 1, "total refresh requests"); + } + + /// Cold start / expired token with latency — Token is fully expired. + /// All callers block until the refresh completes. + #[tokio::test(flavor = "multi_thread", worker_threads = 4)] + async fn test_concurrent_expired_token_blocks_until_refresh() { + let refresh_delay = Duration::from_millis(200); + let counting = CountingState::new(); + let state = DelayedRefreshState { + counting: counting.clone(), + delay: refresh_delay, + }; + let (base_url, stats) = start_axum_server(delayed_refresh_handler, state).await; + let dir = tempfile::tempdir().unwrap(); + // Fully expired token. + let strategy = Arc::new(strategy_with_token( + &dir, + &base_url, + make_token("expired-token", 0, true), + )); + + let start = Instant::now(); + let mut handles = Vec::with_capacity(CONCURRENCY); + for _ in 0..CONCURRENCY { + let s = Arc::clone(&strategy); + handles.push(tokio::spawn(async move { + let token = s.as_ref().get_token().await.unwrap(); + token.into_owned() + })); + } + + let results: Vec<_> = { + let mut results = Vec::with_capacity(handles.len()); + for handle in handles { + results.push(handle.await.unwrap()); + } + results + }; + let elapsed = start.elapsed(); + + // All callers should get the refreshed token. + for token in &results { + assert_eq!(token.as_str(), "refreshed-token"); + } + + // All callers should complete within refresh_delay + generous margin. + // The first caller triggers the refresh (holds the lock), others block. + // Once the refresh completes the lock is released with a fresh token, + // so subsequent callers see `is_expired() = false` and return immediately. + assert!( + elapsed < refresh_delay + Duration::from_millis(200), + "expected < {:?} for blocked callers, got {:?}", + refresh_delay + Duration::from_millis(200), + elapsed + ); + + // Only one refresh request should hit the server. + assert_eq!(stats.peak(), 1, "peak concurrency to refresh endpoint"); + assert_eq!(stats.total(), 1, "total refresh requests"); + } + + /// Refresh failure with delayed error — Server returns an error after a delay. + /// All callers should eventually complete. Because the refresh token is + /// restored after each failure, each queued caller retries independently. + /// The key behavioral property is that the refresh token is always restored, + /// enabling future retries. + #[tokio::test(flavor = "multi_thread", worker_threads = 4)] + async fn test_concurrent_expired_token_refresh_failure_recovers() { + let counting = CountingState::new(); + let state = DelayedRefreshState { + counting: counting.clone(), + // Short delay — each queued caller retries, so total time is + // proportional to concurrency. Keep delay small to stay fast. + delay: Duration::from_millis(10), + }; + let (base_url, stats) = start_axum_server(delayed_error_handler, state).await; + let dir = tempfile::tempdir().unwrap(); + let strategy = Arc::new(strategy_with_token( + &dir, + &base_url, + make_token("expired-token", 0, true), + )); + + let mut handles = Vec::with_capacity(CONCURRENCY); + for _ in 0..CONCURRENCY { + let s = Arc::clone(&strategy); + handles.push(tokio::spawn(async move { + s.as_ref().get_token().await.map(|t| t.into_owned()) + })); + } + + let results: Vec<_> = { + let mut results = Vec::with_capacity(handles.len()); + for handle in handles { + results.push(handle.await.unwrap()); + } + results + }; + + // All callers should get Expired errors. + for result in &results { + assert!(result.is_err(), "expected Expired error, got Ok"); + assert!(matches!( + result.as_ref().unwrap_err(), + TokenStoreError::Expired + )); + } + + // The refresh token should be restored for retry. + let state = strategy.state.lock().await; + assert!( + state.token.as_ref().unwrap().refresh_token().is_some(), + "refresh token should be restored after failed refresh" + ); + drop(state); + + // Peak server concurrency should be 1 (lock serializes access). + assert_eq!(stats.peak(), 1, "peak concurrency to refresh endpoint"); + // Each queued caller retries because the refresh token is restored + // after each failure. Total hits will be up to CONCURRENCY. + assert!( + stats.total() >= 1, + "at least one refresh attempt should be made" + ); + } + + /// Refresh failure then retry — First wave of callers hits a failing server, + /// second wave hits a working server. Verifies recovery under concurrent load. + #[tokio::test(flavor = "multi_thread", worker_threads = 4)] + async fn test_concurrent_refresh_failure_then_retry() { + // Phase 1: Start a server that returns errors. + let counting1 = CountingState::new(); + let state1 = DelayedRefreshState { + counting: counting1.clone(), + delay: Duration::from_millis(50), + }; + let (base_url, _) = start_axum_server(delayed_error_handler, state1).await; + let dir = tempfile::tempdir().unwrap(); + let strategy = Arc::new(strategy_with_token( + &dir, + &base_url, + make_token("expired-token", 0, true), + )); + + // First wave: all callers should get Expired. + let mut handles = Vec::with_capacity(CONCURRENCY); + for _ in 0..CONCURRENCY { + let s = Arc::clone(&strategy); + handles.push(tokio::spawn(async move { + s.as_ref().get_token().await.map(|t| t.into_owned()) + })); + } + + let results: Vec<_> = { + let mut results = Vec::with_capacity(handles.len()); + for handle in handles { + results.push(handle.await.unwrap()); + } + results + }; + + for result in &results { + assert!( + result.is_err(), + "first wave: expected Expired, got Ok({})", + result.as_ref().unwrap().as_str() + ); + } + + // Phase 2: Start a new server that returns success. + // We need a new strategy pointing at the new server, but with the + // same state (token + restored refresh token). + let counting2 = CountingState::new(); + let state2 = DelayedRefreshState { + counting: counting2.clone(), + delay: Duration::from_millis(50), + }; + let (base_url2, stats2) = start_axum_server(delayed_refresh_handler, state2).await; + + // Build a new strategy pointing at the working server, seeded with + // the same expired token + refresh token (simulating a retry). + let strategy2 = Arc::new(strategy_with_token( + &dir, + &base_url2, + make_token("expired-token", 0, true), + )); + + // Second wave: all callers should get the refreshed token. + let mut handles = Vec::with_capacity(CONCURRENCY); + for _ in 0..CONCURRENCY { + let s = Arc::clone(&strategy2); + handles.push(tokio::spawn(async move { + let token = s.as_ref().get_token().await.unwrap(); + token.into_owned() + })); + } + + let results: Vec<_> = { + let mut results = Vec::with_capacity(handles.len()); + for handle in handles { + results.push(handle.await.unwrap()); + } + results + }; + + for token in &results { + assert_eq!(token.as_str(), "refreshed-token"); + } + + assert_eq!(stats2.total(), 1, "only one retry refresh should be made"); + } +} From 0e301d72ff765185e482c846781470cdfb55416a Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Sat, 21 Feb 2026 11:11:49 +1100 Subject: [PATCH 032/686] =?UTF-8?q?=F0=9F=94=A5=20fix(stack-auth):=20remov?= =?UTF-8?q?e=20unused=20TokenStoreError::Refresh=20variant?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/stack-auth/src/token_store.rs | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/packages/stack-auth/src/token_store.rs b/packages/stack-auth/src/token_store.rs index 75ae465c4..606b688cd 100644 --- a/packages/stack-auth/src/token_store.rs +++ b/packages/stack-auth/src/token_store.rs @@ -1,4 +1,4 @@ -use crate::{AuthError, Token}; +use crate::Token; use std::path::{Path, PathBuf}; /// Errors that can occur when reading or writing the token store. @@ -20,9 +20,6 @@ pub enum TokenStoreError { /// The token has expired. #[error("Token has expired")] Expired, - /// Token refresh failed. - #[error("Token refresh failed: {0}")] - Refresh(#[from] AuthError), } /// Persists and loads tokens from a JSON file on disk. From 77e696fb9637e2cfd584caf5640c81466d3fa8d7 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Sat, 21 Feb 2026 18:03:05 +1100 Subject: [PATCH 033/686] =?UTF-8?q?=E2=9C=A8=20feat(stack-auth):=20general?= =?UTF-8?q?ize=20token=20refresh=20into=20AutoRefresh=20and=20add=20acc?= =?UTF-8?q?ess=20key=20auth?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Generalize TokenStoreStrategy into AutoRefresh parameterized by an internal Refresher trait, enabling both OAuth and access key authentication to share the same concurrency model (cascade prevention, two-tier locking). Public API: - AuthStrategy trait with a single get_token() method returning Result - OAuthStrategy: wraps AutoRefresh, loads token from TokenStore - AccessKeyStrategy: wraps AutoRefresh, authenticates on first call - AuthError gains NotAuthenticated and TokenExpired variants - SecretToken::new is now public (removed test-only restriction, removed redundant from_string) Internal: - Refresher trait (pub(crate)): save, try_credential, restore, refresh - AutoRefresh (pub(crate)): concurrency orchestration - OAuthRefresher / AccessKeyRefresher: Refresher implementations Also: - Fix SecretToken docs claiming it can't be constructed directly - Increase is_expired() leeway from 60s to 90s (EXPIRY_LEEWAY_SECS const) - Document known limitation in cascade prevention path --- .../stack-auth/src/access_key_refresher.rs | 621 ++++++++++++++++++ .../stack-auth/src/access_key_strategy.rs | 30 + ...oken_store_strategy.rs => auto_refresh.rs} | 497 +++++++------- packages/stack-auth/src/lib.rs | 52 +- packages/stack-auth/src/oauth_refresher.rs | 47 ++ packages/stack-auth/src/oauth_strategy.rs | 42 ++ packages/stack-auth/src/refresher.rs | 34 + packages/stack-auth/src/token.rs | 18 +- 8 files changed, 1058 insertions(+), 283 deletions(-) create mode 100644 packages/stack-auth/src/access_key_refresher.rs create mode 100644 packages/stack-auth/src/access_key_strategy.rs rename packages/stack-auth/src/{token_store_strategy.rs => auto_refresh.rs} (65%) create mode 100644 packages/stack-auth/src/oauth_refresher.rs create mode 100644 packages/stack-auth/src/oauth_strategy.rs create mode 100644 packages/stack-auth/src/refresher.rs diff --git a/packages/stack-auth/src/access_key_refresher.rs b/packages/stack-auth/src/access_key_refresher.rs new file mode 100644 index 000000000..778ee201a --- /dev/null +++ b/packages/stack-auth/src/access_key_refresher.rs @@ -0,0 +1,621 @@ +use std::time::{SystemTime, UNIX_EPOCH}; + +use url::Url; + +use crate::refresher::Refresher; +use crate::{http_client, AuthError, SecretToken, Token}; + +/// A [`Refresher`] that uses a static access key to authenticate. +/// +/// Unlike OAuth, the access key never changes — `try_credential` always returns +/// `Some(())` and `restore` is a no-op. This means `AutoRefresh` can perform +/// initial authentication on the first `get_token()` call (cold start). +pub(crate) struct AccessKeyRefresher { + access_key: SecretToken, + base_url: Url, + audience: Option, +} + +impl AccessKeyRefresher { + pub(crate) fn new(access_key: SecretToken, base_url: Url, audience: Option) -> Self { + Self { + access_key, + base_url, + audience, + } + } +} + +impl Refresher for AccessKeyRefresher { + type Credential = (); + + fn save(&self, _token: &Token) { + // Access key tokens are ephemeral — no persistence needed. + } + + fn try_credential(&self, _token: Option<&mut Token>) -> Option { + Some(()) + } + + fn restore(&self, _token: &mut Token, _credential: Self::Credential) { + // Nothing to restore — the access key is always available. + } + + async fn refresh(&self, _credential: &Self::Credential) -> Result { + let url = self.base_url.join("api/authorise")?; + + tracing::debug!(url = %url, "authenticating with access key"); + + let resp = http_client() + .post(url) + .json(&AuthoriseRequest { + access_key: self.access_key.as_str(), + audience: self.audience.as_deref(), + }) + .send() + .await?; + + if !resp.status().is_success() { + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + tracing::debug!(%status, %body, "access key auth failed"); + return Err(AuthError::Server(format!("{status}: {body}"))); + } + + let auth_resp: AuthoriseResponse = resp.json().await?; + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + + Ok(Token { + access_token: auth_resp.access_token, + token_type: "Bearer".to_string(), + expires_at: now + auth_resp.expiry, + refresh_token: None, + }) + } +} + +#[derive(serde::Serialize)] +#[serde(rename_all = "camelCase")] +struct AuthoriseRequest<'a> { + access_key: &'a str, + #[serde(skip_serializing_if = "Option::is_none")] + audience: Option<&'a str>, +} + +#[derive(serde::Deserialize)] +#[serde(rename_all = "camelCase")] +struct AuthoriseResponse { + access_token: SecretToken, + expiry: u64, +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::auto_refresh::{AutoRefresh, AutoRefreshError}; + use mocktail::prelude::*; + use std::sync::Arc; + use std::time::{SystemTime, UNIX_EPOCH}; + + fn auth_response_json(access: &str, expiry: u64) -> serde_json::Value { + serde_json::json!({ + "accessToken": access, + "expiry": expiry + }) + } + + async fn start_server(mocks: MockSet) -> MockServer { + let server = MockServer::new_http("access-key-refresher-test").with_mocks(mocks); + server.start().await.unwrap(); + server + } + + fn make_access_key_strategy(server: &MockServer) -> AutoRefresh { + let refresher = AccessKeyRefresher::new( + SecretToken::new("test-access-key"), + server.url(""), + Some("test-audience".to_string()), + ); + AutoRefresh::new(refresher) + } + + fn make_expired_token(access: &str) -> Token { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs(); + + Token { + access_token: SecretToken::new(access), + token_type: "Bearer".to_string(), + expires_at: now, // already expired + refresh_token: None, + } + } + + // ---- Initial auth tests ---- + + #[tokio::test] + async fn test_initial_auth_no_cached_token() { + let mut mocks = MockSet::new(); + mocks.mock(|when, then| { + when.post().path("/api/authorise"); + then.json(auth_response_json("new-token", 3600)); + }); + let server = start_server(mocks).await; + let strategy = make_access_key_strategy(&server); + + let token = strategy.get_token().await.unwrap(); + + assert_eq!(token.as_str(), "new-token"); + } + + #[tokio::test] + async fn test_caches_token_after_initial_auth() { + let mut mocks = MockSet::new(); + mocks.mock(|when, then| { + when.post().path("/api/authorise"); + then.json(auth_response_json("new-token", 3600)); + }); + let server = start_server(mocks).await; + let strategy = make_access_key_strategy(&server); + + let token1 = strategy.get_token().await.unwrap(); + assert_eq!(token1.as_str(), "new-token"); + + // Replace mock — second call should use cached token. + server.mocks().clear(); + server.mocks().mock(|when, then| { + when.post().path("/api/authorise"); + then.internal_server_error() + .json(serde_json::json!({"error": "should not be called"})); + }); + + let token2 = strategy.get_token().await.unwrap(); + assert_eq!(token2.as_str(), "new-token"); + } + + // ---- Refresh on expiry tests ---- + + #[tokio::test] + async fn test_re_authenticates_on_expiry() { + let mut mocks = MockSet::new(); + mocks.mock(|when, then| { + when.post().path("/api/authorise"); + then.json(auth_response_json("refreshed-token", 3600)); + }); + let server = start_server(mocks).await; + + let refresher = + AccessKeyRefresher::new(SecretToken::new("test-access-key"), server.url(""), None); + let strategy = AutoRefresh::with_token(refresher, make_expired_token("old-token")); + + let token = strategy.get_token().await.unwrap(); + + assert_eq!(token.as_str(), "refreshed-token"); + } + + // ---- Error handling tests ---- + + #[tokio::test] + async fn test_initial_auth_failure() { + let mut mocks = MockSet::new(); + mocks.mock(|when, then| { + when.post().path("/api/authorise"); + then.unauthorized() + .json(serde_json::json!({"error": "invalid key"})); + }); + let server = start_server(mocks).await; + let strategy = make_access_key_strategy(&server); + + let err = strategy.get_token().await.unwrap_err(); + + assert!(matches!(err, AutoRefreshError::Auth(_))); + } + + #[tokio::test] + async fn test_refresh_failure_returns_expired() { + let mut mocks = MockSet::new(); + mocks.mock(|when, then| { + when.post().path("/api/authorise"); + then.unauthorized() + .json(serde_json::json!({"error": "invalid key"})); + }); + let server = start_server(mocks).await; + + let refresher = + AccessKeyRefresher::new(SecretToken::new("test-access-key"), server.url(""), None); + let strategy = AutoRefresh::with_token(refresher, make_expired_token("old-token")); + + let err = strategy.get_token().await.unwrap_err(); + + assert!(matches!(err, AutoRefreshError::Expired)); + } + + // ---- Cascade prevention tests ---- + + #[tokio::test] + async fn test_concurrent_initial_auth_only_one_http_call() { + let mut mocks = MockSet::new(); + mocks.mock(|when, then| { + when.post().path("/api/authorise"); + then.json(auth_response_json("new-token", 3600)); + }); + let server = start_server(mocks).await; + let strategy = Arc::new(make_access_key_strategy(&server)); + + let s1 = Arc::clone(&strategy); + let handle_a = tokio::spawn(async move { s1.get_token().await.unwrap() }); + + let s2 = Arc::clone(&strategy); + let handle_b = tokio::spawn(async move { s2.get_token().await.unwrap() }); + + let (result_a, result_b) = tokio::join!(handle_a, handle_b); + let token_a = result_a.unwrap(); + let token_b = result_b.unwrap(); + + assert_eq!(token_a.as_str(), "new-token"); + assert_eq!(token_b.as_str(), "new-token"); + } + + #[tokio::test] + async fn test_concurrent_access_expired_token() { + let mut mocks = MockSet::new(); + mocks.mock(|when, then| { + when.post().path("/api/authorise"); + then.json(auth_response_json("refreshed-token", 3600)); + }); + let server = start_server(mocks).await; + + let refresher = + AccessKeyRefresher::new(SecretToken::new("test-access-key"), server.url(""), None); + let strategy = Arc::new(AutoRefresh::with_token( + refresher, + make_expired_token("old-token"), + )); + + let s1 = Arc::clone(&strategy); + let handle_a = tokio::spawn(async move { s1.get_token().await.unwrap() }); + + let s2 = Arc::clone(&strategy); + let handle_b = tokio::spawn(async move { s2.get_token().await.unwrap() }); + + let (result_a, result_b) = tokio::join!(handle_a, handle_b); + let token_a = result_a.unwrap(); + let token_b = result_b.unwrap(); + + assert_eq!(token_a.as_str(), "refreshed-token"); + assert_eq!(token_b.as_str(), "refreshed-token"); + } + + // ---- Concurrent access: expiring but usable ---- + + #[tokio::test] + async fn test_concurrent_access_expiring_but_usable() { + let mut mocks = MockSet::new(); + mocks.mock(|when, then| { + when.post().path("/api/authorise"); + then.json(auth_response_json("refreshed-token", 3600)); + }); + let server = start_server(mocks).await; + + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs(); + let expiring_token = Token { + access_token: SecretToken::new("still-usable"), + token_type: "Bearer".to_string(), + expires_at: now + 30, // is_expired() = true (within 90s), is_usable() = true + refresh_token: None, + }; + + let refresher = + AccessKeyRefresher::new(SecretToken::new("test-access-key"), server.url(""), None); + let strategy = Arc::new(AutoRefresh::with_token(refresher, expiring_token)); + + let s1 = Arc::clone(&strategy); + let handle_a = tokio::spawn(async move { s1.get_token().await.unwrap() }); + + let s2 = Arc::clone(&strategy); + let handle_b = tokio::spawn(async move { s2.get_token().await.unwrap() }); + + let (result_a, result_b) = tokio::join!(handle_a, handle_b); + let token_a = result_a.unwrap(); + let token_b = result_b.unwrap(); + + // Both should succeed with either old or refreshed token. + assert!( + token_a.as_str() == "still-usable" || token_a.as_str() == "refreshed-token", + "unexpected token_a: {}", + token_a.as_str() + ); + assert!( + token_b.as_str() == "still-usable" || token_b.as_str() == "refreshed-token", + "unexpected token_b: {}", + token_b.as_str() + ); + } + + // ---- Stress tests ---- + + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::time::{Duration, Instant}; + + #[derive(Clone)] + struct CountingState { + total: Arc, + current: Arc, + peak: Arc, + } + + impl CountingState { + fn new() -> Self { + Self { + total: Arc::new(AtomicUsize::new(0)), + current: Arc::new(AtomicUsize::new(0)), + peak: Arc::new(AtomicUsize::new(0)), + } + } + + fn enter(&self) { + self.total.fetch_add(1, Ordering::SeqCst); + let prev = self.current.fetch_add(1, Ordering::SeqCst); + self.peak.fetch_max(prev + 1, Ordering::SeqCst); + } + + fn exit(&self) { + self.current.fetch_sub(1, Ordering::SeqCst); + } + + fn peak(&self) -> usize { + self.peak.load(Ordering::SeqCst) + } + + fn total(&self) -> usize { + self.total.load(Ordering::SeqCst) + } + } + + #[derive(Clone)] + struct DelayedAuthState { + counting: CountingState, + delay: Duration, + } + + async fn delayed_auth_handler( + axum::extract::State(state): axum::extract::State, + ) -> axum::Json { + state.counting.enter(); + tokio::time::sleep(state.delay).await; + state.counting.exit(); + axum::Json(serde_json::json!({ + "accessToken": "refreshed-token", + "expiry": 3600 + })) + } + + async fn start_axum_server(state: DelayedAuthState) -> (Url, CountingState) { + let counting = state.counting.clone(); + let app = axum::Router::new() + .route("/api/authorise", axum::routing::post(delayed_auth_handler)) + .with_state(state); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { + axum::serve(listener, app).await.unwrap(); + }); + let base_url = Url::parse(&format!("http://{addr}")).unwrap(); + (base_url, counting) + } + + const CONCURRENCY: usize = 50; + + #[tokio::test(flavor = "multi_thread", worker_threads = 4)] + async fn test_stress_initial_auth() { + let state = DelayedAuthState { + counting: CountingState::new(), + delay: Duration::from_millis(200), + }; + let (base_url, stats) = start_axum_server(state).await; + + let refresher = + AccessKeyRefresher::new(SecretToken::new("test-access-key"), base_url, None); + let strategy = Arc::new(AutoRefresh::new(refresher)); + + let start = Instant::now(); + let mut handles = Vec::with_capacity(CONCURRENCY); + for _ in 0..CONCURRENCY { + let s = Arc::clone(&strategy); + handles.push(tokio::spawn(async move { s.get_token().await.unwrap() })); + } + + let results: Vec<_> = { + let mut results = Vec::with_capacity(handles.len()); + for handle in handles { + results.push(handle.await.unwrap()); + } + results + }; + let elapsed = start.elapsed(); + + for token in &results { + assert_eq!(token.as_str(), "refreshed-token"); + } + + assert!( + elapsed < Duration::from_millis(600), + "expected < 600ms, got {:?}", + elapsed + ); + assert_eq!(stats.total(), 1, "only one auth request should be made"); + assert_eq!(stats.peak(), 1, "peak concurrency to auth endpoint"); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 4)] + async fn test_stress_cached_token() { + let state = DelayedAuthState { + counting: CountingState::new(), + delay: Duration::from_millis(500), + }; + let (base_url, stats) = start_axum_server(state).await; + + // Pre-authenticate. + let refresher = + AccessKeyRefresher::new(SecretToken::new("test-access-key"), base_url, None); + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs(); + let token = Token { + access_token: SecretToken::new("cached-token"), + token_type: "Bearer".to_string(), + expires_at: now + 3600, + refresh_token: None, + }; + let strategy = Arc::new(AutoRefresh::with_token(refresher, token)); + + let start = Instant::now(); + let mut handles = Vec::with_capacity(CONCURRENCY); + for _ in 0..CONCURRENCY { + let s = Arc::clone(&strategy); + handles.push(tokio::spawn(async move { s.get_token().await.unwrap() })); + } + + let results: Vec<_> = { + let mut results = Vec::with_capacity(handles.len()); + for handle in handles { + results.push(handle.await.unwrap()); + } + results + }; + let elapsed = start.elapsed(); + + for token in &results { + assert_eq!(token.as_str(), "cached-token"); + } + + assert!( + elapsed < Duration::from_millis(200), + "expected < 200ms for cached tokens, got {:?}", + elapsed + ); + assert_eq!(stats.total(), 0, "no auth requests should be made"); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 4)] + async fn test_stress_expiring_but_usable_non_blocking() { + let state = DelayedAuthState { + counting: CountingState::new(), + delay: Duration::from_millis(500), + }; + let (base_url, stats) = start_axum_server(state).await; + + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs(); + let expiring_token = Token { + access_token: SecretToken::new("still-usable"), + token_type: "Bearer".to_string(), + expires_at: now + 30, + refresh_token: None, + }; + let refresher = + AccessKeyRefresher::new(SecretToken::new("test-access-key"), base_url, None); + let strategy = Arc::new(AutoRefresh::with_token(refresher, expiring_token)); + + let start = Instant::now(); + let mut handles = Vec::with_capacity(CONCURRENCY); + for _ in 0..CONCURRENCY { + let s = Arc::clone(&strategy); + handles.push(tokio::spawn(async move { + let call_start = Instant::now(); + let token = s.get_token().await.unwrap(); + (token, call_start.elapsed()) + })); + } + + let results: Vec<_> = { + let mut results = Vec::with_capacity(handles.len()); + for handle in handles { + results.push(handle.await.unwrap()); + } + results + }; + let _elapsed = start.elapsed(); + + for (token, _) in &results { + assert!( + token.as_str() == "still-usable" || token.as_str() == "refreshed-token", + "unexpected token: {}", + token.as_str() + ); + } + + // At least N-1 callers should be fast (non-blocking). + let fast_callers = results + .iter() + .filter(|(_, dur)| *dur < Duration::from_millis(100)) + .count(); + assert!( + fast_callers >= CONCURRENCY - 1, + "expected at least {} fast callers, got {}", + CONCURRENCY - 1, + fast_callers, + ); + + assert_eq!(stats.peak(), 1, "peak concurrency to auth endpoint"); + assert_eq!(stats.total(), 1, "total auth requests"); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 4)] + async fn test_stress_expired_token_blocks() { + let refresh_delay = Duration::from_millis(200); + let state = DelayedAuthState { + counting: CountingState::new(), + delay: refresh_delay, + }; + let (base_url, stats) = start_axum_server(state).await; + + let refresher = + AccessKeyRefresher::new(SecretToken::new("test-access-key"), base_url, None); + let strategy = Arc::new(AutoRefresh::with_token( + refresher, + make_expired_token("old-token"), + )); + + let start = Instant::now(); + let mut handles = Vec::with_capacity(CONCURRENCY); + for _ in 0..CONCURRENCY { + let s = Arc::clone(&strategy); + handles.push(tokio::spawn(async move { s.get_token().await.unwrap() })); + } + + let results: Vec<_> = { + let mut results = Vec::with_capacity(handles.len()); + for handle in handles { + results.push(handle.await.unwrap()); + } + results + }; + let elapsed = start.elapsed(); + + for token in &results { + assert_eq!(token.as_str(), "refreshed-token"); + } + + assert!( + elapsed < refresh_delay + Duration::from_millis(200), + "expected < {:?}, got {:?}", + refresh_delay + Duration::from_millis(200), + elapsed + ); + + assert_eq!(stats.peak(), 1, "peak concurrency to auth endpoint"); + assert_eq!(stats.total(), 1, "total auth requests"); + } +} diff --git a/packages/stack-auth/src/access_key_strategy.rs b/packages/stack-auth/src/access_key_strategy.rs new file mode 100644 index 000000000..984144b70 --- /dev/null +++ b/packages/stack-auth/src/access_key_strategy.rs @@ -0,0 +1,30 @@ +use url::Url; + +use crate::access_key_refresher::AccessKeyRefresher; +use crate::auto_refresh::AutoRefresh; +use crate::{AuthError, AuthStrategy, SecretToken}; + +/// An [`AuthStrategy`] that uses a static access key to authenticate. +/// +/// The first call to [`get_token`](AuthStrategy::get_token) authenticates with +/// the server. Subsequent calls return the cached token until it expires, at +/// which point re-authentication happens automatically. +pub struct AccessKeyStrategy { + inner: AutoRefresh, +} + +impl AccessKeyStrategy { + /// Create a new `AccessKeyStrategy`. + pub fn new(access_key: SecretToken, base_url: Url, audience: Option) -> Self { + let refresher = AccessKeyRefresher::new(access_key, base_url, audience); + Self { + inner: AutoRefresh::new(refresher), + } + } +} + +impl AuthStrategy for &AccessKeyStrategy { + async fn get_token(self) -> Result { + Ok(self.inner.get_token().await?) + } +} diff --git a/packages/stack-auth/src/token_store_strategy.rs b/packages/stack-auth/src/auto_refresh.rs similarity index 65% rename from packages/stack-auth/src/token_store_strategy.rs rename to packages/stack-auth/src/auto_refresh.rs index 8afa67629..e8695f215 100644 --- a/packages/stack-auth/src/token_store_strategy.rs +++ b/packages/stack-auth/src/auto_refresh.rs @@ -1,18 +1,37 @@ -use std::borrow::Cow; - use tokio::sync::Mutex; -use url::Url; -use crate::token_store::{TokenStore, TokenStoreError}; -use crate::{AuthStrategy, SecretToken, Token}; +use crate::refresher::Refresher; +use crate::{SecretToken, Token}; -/// An [`AuthStrategy`] that loads a token from a [`TokenStore`], caches it in -/// memory, and preemptively refreshes it before it expires. +/// Internal errors from [`AutoRefresh::get_token`]. /// -/// The token is loaded from disk on the first call to [`get_token`](AuthStrategy::get_token) -/// and cached for subsequent calls. When the token is within 60 seconds of -/// expiry and a refresh token is available, the strategy automatically refreshes -/// it and persists the new token to disk. +/// Strategy wrappers convert these into [`AuthError`](crate::AuthError) for the +/// public API. +#[derive(Debug, thiserror::Error)] +pub(crate) enum AutoRefreshError { + /// No token is cached and the strategy cannot self-authenticate. + #[error("No token found")] + NotFound, + /// The token has expired and refresh failed or is unavailable. + #[error("Token has expired")] + Expired, + /// The refresh/auth HTTP call failed. + #[error("Auth error: {0}")] + Auth(#[from] crate::AuthError), +} + +impl From for crate::AuthError { + fn from(err: AutoRefreshError) -> Self { + match err { + AutoRefreshError::NotFound => crate::AuthError::NotAuthenticated, + AutoRefreshError::Expired => crate::AuthError::TokenExpired, + AutoRefreshError::Auth(e) => e, + } + } +} + +/// Caches a token in memory and uses a [`Refresher`] to re-authenticate +/// or refresh before expiry. /// /// # Concurrency model /// @@ -20,7 +39,7 @@ use crate::{AuthStrategy, SecretToken, Token}; /// decision is *when* the lock is held during a refresh, which depends on /// whether the current token is still usable as a bearer credential: /// -/// - [`Token::is_expired()`] — returns `true` when the token is within **60 +/// - [`Token::is_expired()`] — returns `true` when the token is within **90 /// seconds** of its `expires_at` timestamp. This triggers a preemptive /// refresh attempt. /// - [`Token::is_usable()`] — returns `true` when the token has **not yet @@ -36,177 +55,218 @@ use crate::{AuthStrategy, SecretToken, Token}; /// HTTP request. Concurrent callers block on `lock().await` until the /// refresh completes, then see the new token. /// -/// Cascade prevention: the first caller to detect an expiring token *takes* -/// the refresh token out of the cached [`Token`] (leaving `None`). Subsequent -/// callers see no refresh token and skip the refresh, returning the current -/// token if usable or [`TokenStoreError::Expired`] if not. +/// Cascade prevention: the `refresh_in_progress` flag prevents multiple +/// callers from initiating concurrent refreshes. /// /// # Flow diagram /// -/// The following diagram shows the decision tree inside -/// [`get_token()`](AuthStrategy::get_token): -/// /// ```mermaid /// flowchart TD /// Start["get_token()"] --> Lock["Acquire lock"] /// Lock --> Cached{Token cached?} -/// Cached -- No --> Load["Load from disk"] -/// Load -- Not found --> ErrNotFound["Return NotFound"] -/// Load -- OK --> CheckRefresh +/// Cached -- No --> TryCred0["try_credential(None)"] +/// TryCred0 -- None --> ErrNotFound["Return NotFound"] +/// TryCred0 -- "Some(cred)" --> InitAuth["refresh(cred) +/// (lock HELD)"] +/// InitAuth -- OK --> SaveInit["save + cache token"] +/// SaveInit --> ReturnNew["Return Ok(new token)"] +/// InitAuth -- Err --> ErrAuth["Return Auth(err)"] /// Cached -- Yes --> CheckRefresh{is_expired?} /// /// CheckRefresh -- "No (fresh)" --> CloneFresh["Clone access token, /// release lock"] /// CloneFresh --> ReturnOk["Return Ok(token)"] /// -/// CheckRefresh -- "Yes (needs refresh)" --> TakeRT{take_refresh_token} +/// CheckRefresh -- "Yes (needs refresh)" --> InProgress{refresh_in_progress?} +/// InProgress -- Yes --> Usable0{is_usable?} +/// Usable0 -- Yes --> CloneUsable0["Clone access token"] +/// CloneUsable0 --> ReturnOk +/// Usable0 -- No --> ErrExpired["Return Expired"] /// -/// TakeRT -- "None (already taken)" --> Usable1{is_usable?} -/// Usable1 -- Yes --> CloneUsable1["Clone access token, -/// release lock"] +/// InProgress -- No --> TryCred{try_credential} +/// TryCred -- None --> Usable1{is_usable?} +/// Usable1 -- Yes --> CloneUsable1["Clone access token"] /// CloneUsable1 --> ReturnOk -/// Usable1 -- No --> ErrExpired["Return Expired"] +/// Usable1 -- No --> ErrExpired /// -/// TakeRT -- "Some(refresh_token)" --> Usable2{is_usable?} +/// TryCred -- "Some(cred)" --> SetFlag["refresh_in_progress = true"] +/// SetFlag --> Usable2{is_usable?} /// /// Usable2 -- "Yes (expiring but usable)" --> DropLock["Clone access token, /// release lock"] -/// DropLock --> HTTP1["HTTP refresh +/// DropLock --> HTTP1["refresh(cred) /// (lock NOT held)"] /// HTTP1 -- OK --> Relock1["Re-acquire lock, -/// store new token"] -/// HTTP1 -- Err --> Restore1["Restore refresh token, -/// log warning"] +/// save + cache, clear flag"] +/// HTTP1 -- Err --> Restore1["Restore credential, +/// clear flag"] /// Relock1 --> ReturnOld["Return Ok(old token)"] /// Restore1 --> ReturnOld /// -/// Usable2 -- "No (fully expired)" --> HTTP2["HTTP refresh +/// Usable2 -- "No (fully expired)" --> HTTP2["refresh(cred) /// (lock HELD)"] -/// HTTP2 -- OK --> StoreNew["Store new token, -/// release lock"] -/// StoreNew --> ReturnNew["Return Ok(new token)"] -/// HTTP2 -- Err --> Restore2["Restore refresh token"] +/// HTTP2 -- OK --> StoreNew["save + cache, +/// clear flag, release lock"] +/// StoreNew --> ReturnNew2["Return Ok(new token)"] +/// HTTP2 -- Err --> Restore2["Restore credential, +/// clear flag"] /// Restore2 --> ErrExpired /// ``` #[cfg_attr(doc, aquamarine::aquamarine)] -pub struct TokenStoreStrategy { - store: TokenStore, - base_url: Url, - client_id: String, +pub(crate) struct AutoRefresh { + refresher: R, state: Mutex, } struct State { token: Option, + refresh_in_progress: bool, } -impl TokenStoreStrategy { - /// Create a new `TokenStoreStrategy`. +impl AutoRefresh { + /// Create a new `AutoRefresh` with no initial token. /// - /// The `base_url` and `client_id` are used when refreshing an expired token - /// via the `/oauth/token` endpoint. - pub fn new(store: TokenStore, base_url: Url, client_id: impl Into) -> Self { + /// The first call to `get_token` will attempt initial authentication via + /// `try_credential(None)` → `refresh()`. Use this for refreshers that can + /// self-authenticate (e.g. access keys). + pub(crate) fn new(refresher: R) -> Self { Self { - store, - base_url, - client_id: client_id.into(), - state: Mutex::new(State { token: None }), + refresher, + state: Mutex::new(State { + token: None, + refresh_in_progress: false, + }), } } -} -impl<'a> AuthStrategy<'a> for &'a TokenStoreStrategy { - type Error = TokenStoreError; + /// Create a new `AutoRefresh` with a pre-loaded token. + /// + /// Use this for refreshers that cannot self-authenticate (e.g. OAuth, + /// which needs a refresh token from a prior device code flow). + pub(crate) fn with_token(refresher: R, token: Token) -> Self { + Self { + refresher, + state: Mutex::new(State { + token: Some(token), + refresh_in_progress: false, + }), + } + } +} - async fn get_token(self) -> Result, Self::Error> { +impl AutoRefresh { + /// Retrieve a valid access token, refreshing or re-authenticating as needed. + pub(crate) async fn get_token(&self) -> Result { let mut state = self.state.lock().await; - // Load from disk if not yet cached. + // No cached token — attempt initial auth. if state.token.is_none() { - let token = self.store.load()?.ok_or(TokenStoreError::NotFound)?; - state.token = Some(token); + let Some(credential) = self.refresher.try_credential(None) else { + return Err(AutoRefreshError::NotFound); + }; + state.refresh_in_progress = true; + match self.refresher.refresh(&credential).await { + Ok(new_token) => { + self.refresher.save(&new_token); + let access_token = new_token.access_token().clone(); + state.token = Some(new_token); + state.refresh_in_progress = false; + return Ok(access_token); + } + Err(err) => { + state.refresh_in_progress = false; + return Err(AutoRefreshError::Auth(err)); + } + } } let needs_refresh = state.token.as_ref().is_some_and(|t| t.is_expired()); if !needs_refresh { // Token is fresh — clone and return. - let token = state.token.as_ref().ok_or(TokenStoreError::NotFound)?; - return Ok(Cow::Owned(token.access_token().clone())); + let token = state.token.as_ref().ok_or(AutoRefreshError::NotFound)?; + return Ok(token.access_token().clone()); + } + + // Check cascade prevention flag. + if state.refresh_in_progress { + let token = state.token.as_ref().ok_or(AutoRefreshError::NotFound)?; + if token.is_usable() { + return Ok(token.access_token().clone()); + } + // NOTE: If a refresh was started while the token was still usable + // (lock released) but the token has since crossed its real expiry, + // we return Expired rather than waiting for the in-flight refresh. + // This is a deliberate trade-off: adding a Notify/condvar to wait + // for the in-flight refresh would increase complexity, and the + // window is narrow (token must expire during the HTTP call). The + // 90s leeway on is_expired() makes this unlikely. Callers can + // retry and will get the new token once the refresh completes. + return Err(AutoRefreshError::Expired); } - // Token needs refresh. Take the refresh token to prevent cascades. - let refresh_token = state.token.as_mut().and_then(|t| t.take_refresh_token()); + // Token needs refresh. Try to get a credential. + let credential = self.refresher.try_credential(state.token.as_mut()); - let Some(refresh_token) = refresh_token else { - // No refresh token available. If the token is still usable (not - // actually expired, just within the 60s leeway), return it. - // Otherwise another caller is already refreshing (they took the - // refresh token) — if the token is usable, return it; if not, - // it's truly expired. - let token = state.token.as_ref().ok_or(TokenStoreError::NotFound)?; + let Some(credential) = credential else { + // No credential available (e.g. OAuth with no refresh token). + let token = state.token.as_ref().ok_or(AutoRefreshError::NotFound)?; if token.is_usable() { - return Ok(Cow::Owned(token.access_token().clone())); + return Ok(token.access_token().clone()); } - return Err(TokenStoreError::Expired); + return Err(AutoRefreshError::Expired); }; - // We have a refresh token. Check if the current token is still usable. + state.refresh_in_progress = true; + + // Check if the current token is still usable. let is_usable = state.token.as_ref().is_some_and(|t| t.is_usable()); if is_usable { // Token is expiring but still usable. Clone the current access // token, drop the lock, and refresh in the background of this call. - // Other callers can acquire the lock and get the still-valid token. let current_access_token = state .token .as_ref() - .ok_or(TokenStoreError::NotFound)? + .ok_or(AutoRefreshError::NotFound)? .access_token() .clone(); drop(state); - match Token::refresh(&refresh_token, &self.base_url, &self.client_id).await { + match self.refresher.refresh(&credential).await { Ok(new_token) => { - match self.store.save(&new_token) { - Ok(()) => tracing::debug!("refreshed token saved to disk"), - Err(err) => { - tracing::warn!(%err, "failed to save refreshed token to disk") - } - } - self.state.lock().await.token = Some(new_token); + self.refresher.save(&new_token); + let mut state = self.state.lock().await; + state.token = Some(new_token); + state.refresh_in_progress = false; } Err(err) => { tracing::warn!(%err, "token refresh failed (token still usable)"); - // Restore the refresh token so the next call can retry. - if let Some(token) = self.state.lock().await.token.as_mut() { - token.refresh_token = Some(refresh_token); + let mut state = self.state.lock().await; + if let Some(token) = state.token.as_mut() { + self.refresher.restore(token, credential); } + state.refresh_in_progress = false; } } - Ok(Cow::Owned(current_access_token)) + Ok(current_access_token) } else { - // Token is fully expired. Refresh while holding the lock so other - // callers block until the new token is available. - match Token::refresh(&refresh_token, &self.base_url, &self.client_id).await { + // Token is fully expired. Refresh while holding the lock. + match self.refresher.refresh(&credential).await { Ok(new_token) => { - match self.store.save(&new_token) { - Ok(()) => tracing::debug!("refreshed token saved to disk"), - Err(err) => { - tracing::warn!(%err, "failed to save refreshed token to disk") - } - } + self.refresher.save(&new_token); let access_token = new_token.access_token().clone(); state.token = Some(new_token); - Ok(Cow::Owned(access_token)) + state.refresh_in_progress = false; + Ok(access_token) } Err(err) => { tracing::warn!(%err, "token refresh failed"); - // Restore the refresh token so the next call can retry. if let Some(token) = state.token.as_mut() { - token.refresh_token = Some(refresh_token); + self.refresher.restore(token, credential); } - Err(TokenStoreError::Expired) + state.refresh_in_progress = false; + Err(AutoRefreshError::Expired) } } } @@ -216,6 +276,8 @@ impl<'a> AuthStrategy<'a> for &'a TokenStoreStrategy { #[cfg(test)] mod tests { use super::*; + use crate::oauth_refresher::OAuthRefresher; + use crate::token_store::TokenStore; use mocktail::prelude::*; use std::sync::Arc; use std::time::{SystemTime, UNIX_EPOCH}; @@ -255,45 +317,46 @@ mod tests { } async fn start_server(mocks: MockSet) -> MockServer { - let server = MockServer::new_http("token-store-strategy-test").with_mocks(mocks); + let server = MockServer::new_http("auto-refresh-test").with_mocks(mocks); server.start().await.unwrap(); server } - fn strategy_with_token( + fn auto_refresh_with_token( dir: &tempfile::TempDir, server: &MockServer, token: Token, - ) -> TokenStoreStrategy { + ) -> AutoRefresh { let store = TokenStore::new(dir.path().join("auth.json")); store.save(&token).unwrap(); - TokenStoreStrategy::new(store, server.url(""), "cli") + let refresher = OAuthRefresher::new(store, server.url(""), "cli"); + AutoRefresh::with_token(refresher, token) } // ---- Basic loading tests ---- #[tokio::test] - async fn test_loads_token_from_disk() { + async fn test_returns_cached_token() { let dir = tempfile::tempdir().unwrap(); let server = start_server(MockSet::new()).await; let strategy = - strategy_with_token(&dir, &server, make_token("my-access-token", 3600, false)); + auto_refresh_with_token(&dir, &server, make_token("my-access-token", 3600, false)); - let token = (&strategy).get_token().await.unwrap(); + let token = strategy.get_token().await.unwrap(); assert_eq!(token.as_str(), "my-access-token"); } #[tokio::test] - async fn test_returns_not_found_when_no_token_on_disk() { - let dir = tempfile::tempdir().unwrap(); + async fn test_returns_not_found_when_no_token_and_oauth() { let server = start_server(MockSet::new()).await; - let store = TokenStore::new(dir.path().join("auth.json")); - let strategy = TokenStoreStrategy::new(store, server.url(""), "cli"); + let store = TokenStore::new("/tmp/nonexistent/auth.json"); + let refresher = OAuthRefresher::new(store, server.url(""), "cli"); + let strategy = AutoRefresh::new(refresher); - let err = (&strategy).get_token().await.unwrap_err(); + let err = strategy.get_token().await.unwrap_err(); - assert!(matches!(err, TokenStoreError::NotFound)); + assert!(matches!(err, AutoRefreshError::NotFound)); } #[tokio::test] @@ -301,15 +364,15 @@ mod tests { let dir = tempfile::tempdir().unwrap(); let server = start_server(MockSet::new()).await; let strategy = - strategy_with_token(&dir, &server, make_token("my-access-token", 3600, false)); + auto_refresh_with_token(&dir, &server, make_token("my-access-token", 3600, false)); - let token1 = (&strategy).get_token().await.unwrap(); + let token1 = strategy.get_token().await.unwrap(); assert_eq!(token1.as_str(), "my-access-token"); // Delete the file — second call should still return the cached token. std::fs::remove_file(dir.path().join("auth.json")).unwrap(); - let token2 = (&strategy).get_token().await.unwrap(); + let token2 = strategy.get_token().await.unwrap(); assert_eq!(token2.as_str(), "my-access-token"); } @@ -319,11 +382,11 @@ mod tests { async fn test_expired_token_without_refresh_token_returns_expired() { let dir = tempfile::tempdir().unwrap(); let server = start_server(MockSet::new()).await; - let strategy = strategy_with_token(&dir, &server, make_token("old-token", 0, false)); + let strategy = auto_refresh_with_token(&dir, &server, make_token("old-token", 0, false)); - let err = (&strategy).get_token().await.unwrap_err(); + let err = strategy.get_token().await.unwrap_err(); - assert!(matches!(err, TokenStoreError::Expired)); + assert!(matches!(err, AutoRefreshError::Expired)); } // ---- Refresh tests ---- @@ -337,9 +400,9 @@ mod tests { }); let server = start_server(mocks).await; let dir = tempfile::tempdir().unwrap(); - let strategy = strategy_with_token(&dir, &server, make_token("old-token", 0, true)); + let strategy = auto_refresh_with_token(&dir, &server, make_token("old-token", 0, true)); - let token = (&strategy).get_token().await.unwrap(); + let token = strategy.get_token().await.unwrap(); assert_eq!(token.as_str(), "refreshed-token"); } @@ -353,9 +416,9 @@ mod tests { }); let server = start_server(mocks).await; let dir = tempfile::tempdir().unwrap(); - let strategy = strategy_with_token(&dir, &server, make_token("old-token", 0, true)); + let strategy = auto_refresh_with_token(&dir, &server, make_token("old-token", 0, true)); - let _ = (&strategy).get_token().await.unwrap(); + let _ = strategy.get_token().await.unwrap(); // Verify the refreshed token was saved to disk. let store = TokenStore::new(dir.path().join("auth.json")); @@ -372,11 +435,11 @@ mod tests { }); let server = start_server(mocks).await; let dir = tempfile::tempdir().unwrap(); - let strategy = strategy_with_token(&dir, &server, make_token("old-token", 0, true)); + let strategy = auto_refresh_with_token(&dir, &server, make_token("old-token", 0, true)); - let err = (&strategy).get_token().await.unwrap_err(); + let err = strategy.get_token().await.unwrap_err(); - assert!(matches!(err, TokenStoreError::Expired)); + assert!(matches!(err, AutoRefreshError::Expired)); } #[tokio::test] @@ -390,9 +453,10 @@ mod tests { }); let server = start_server(mocks).await; let dir = tempfile::tempdir().unwrap(); - let strategy = strategy_with_token(&dir, &server, make_token("fresh-token", 3600, true)); + let strategy = + auto_refresh_with_token(&dir, &server, make_token("fresh-token", 3600, true)); - let token = (&strategy).get_token().await.unwrap(); + let token = strategy.get_token().await.unwrap(); assert_eq!(token.as_str(), "fresh-token"); } @@ -401,8 +465,6 @@ mod tests { #[tokio::test] async fn test_refresh_token_is_taken_preventing_second_refresh() { - // Set up a server that returns a new token on the first refresh but - // would fail on any subsequent refresh attempt. let mut mocks = MockSet::new(); mocks.mock(|when, then| { when.post().path("/oauth/token"); @@ -410,13 +472,13 @@ mod tests { }); let server = start_server(mocks).await; let dir = tempfile::tempdir().unwrap(); - let strategy = strategy_with_token(&dir, &server, make_token("old-token", 0, true)); + let strategy = auto_refresh_with_token(&dir, &server, make_token("old-token", 0, true)); // First call refreshes successfully. - let token = (&strategy).get_token().await.unwrap(); + let token = strategy.get_token().await.unwrap(); assert_eq!(token.as_str(), "refreshed-token"); - // Replace the mock with one that errors — any refresh attempt would fail. + // Replace the mock with one that errors. server.mocks().clear(); server.mocks().mock(|when, then| { when.post().path("/oauth/token"); @@ -425,7 +487,7 @@ mod tests { // Second call should return the refreshed token without hitting // the server again (the new token has a fresh expiry). - let token = (&strategy).get_token().await.unwrap(); + let token = strategy.get_token().await.unwrap(); assert_eq!(token.as_str(), "refreshed-token"); } @@ -438,11 +500,11 @@ mod tests { }); let server = start_server(mocks).await; let dir = tempfile::tempdir().unwrap(); - let strategy = strategy_with_token(&dir, &server, make_token("old-token", 0, true)); + let strategy = auto_refresh_with_token(&dir, &server, make_token("old-token", 0, true)); // First call: refresh fails, returns Expired. - let err = (&strategy).get_token().await.unwrap_err(); - assert!(matches!(err, TokenStoreError::Expired)); + let err = strategy.get_token().await.unwrap_err(); + assert!(matches!(err, AutoRefreshError::Expired)); // Verify the refresh token was restored so a retry is possible. let state = strategy.state.lock().await; @@ -450,7 +512,7 @@ mod tests { assert!(state.token.as_ref().unwrap().refresh_token().is_some()); drop(state); - // Replace mock with a success response — the retry should use it. + // Replace mock with a success response. server.mocks().clear(); server.mocks().mock(|when, then| { when.post().path("/oauth/token"); @@ -458,7 +520,7 @@ mod tests { }); // Second call: refresh token is available → retry succeeds. - let token = (&strategy).get_token().await.unwrap(); + let token = strategy.get_token().await.unwrap(); assert_eq!(token.as_str(), "refreshed-token"); } @@ -471,13 +533,13 @@ mod tests { }); let server = start_server(mocks).await; let dir = tempfile::tempdir().unwrap(); - // Token expires in 30s (within the 60s leeway so is_expired() = true), + // Token expires in 30s (within the 90s leeway so is_expired() = true), // but the access token is still technically usable. - let strategy = strategy_with_token(&dir, &server, make_token("still-usable", 30, true)); + let strategy = auto_refresh_with_token(&dir, &server, make_token("still-usable", 30, true)); // The refresh fails, but the access token should still be returned // because it's still usable (30s remaining > 0). - let token = (&strategy).get_token().await.unwrap(); + let token = strategy.get_token().await.unwrap(); assert_eq!(token.as_str(), "still-usable"); // Verify the access token and refresh token are still present. @@ -503,13 +565,13 @@ mod tests { let server = start_server(mocks).await; let dir = tempfile::tempdir().unwrap(); // Token expires in 30s — is_expired() = true, is_usable() = true. - let strategy = strategy_with_token(&dir, &server, make_token("still-usable", 30, true)); + let strategy = auto_refresh_with_token(&dir, &server, make_token("still-usable", 30, true)); // First call: refresh fails, but the still-usable token is returned. - let token = (&strategy).get_token().await.unwrap(); + let token = strategy.get_token().await.unwrap(); assert_eq!(token.as_str(), "still-usable"); - // Replace mock with a success response — the retry should use it. + // Replace mock with a success response. server.mocks().clear(); server.mocks().mock(|when, then| { when.post().path("/oauth/token"); @@ -517,9 +579,7 @@ mod tests { }); // Second call: refresh token was restored, so the retry succeeds. - // The caller still gets the old token (it's returned before the - // refresh completes), but the cache is updated. - let token = (&strategy).get_token().await.unwrap(); + let token = strategy.get_token().await.unwrap(); assert!( token.as_str() == "still-usable" || token.as_str() == "refreshed-token", "expected old or refreshed token, got: {}", @@ -543,10 +603,10 @@ mod tests { }); let server = start_server(mocks).await; let dir = tempfile::tempdir().unwrap(); - let strategy = strategy_with_token(&dir, &server, make_token("old-token", 0, true)); + let strategy = auto_refresh_with_token(&dir, &server, make_token("old-token", 0, true)); // First call triggers refresh. - let token = (&strategy).get_token().await.unwrap(); + let token = strategy.get_token().await.unwrap(); assert_eq!(token.as_str(), "refreshed-once"); // Swap mock to track if another refresh is attempted. @@ -558,7 +618,7 @@ mod tests { // Calls 2-5: the refreshed token is fresh, so no further refresh. for _ in 0..4 { - let token = (&strategy).get_token().await.unwrap(); + let token = strategy.get_token().await.unwrap(); assert_eq!( token.as_str(), "refreshed-once", @@ -571,8 +631,6 @@ mod tests { #[tokio::test] async fn test_concurrent_access_with_expiring_but_usable_token() { - // Token expires in 30s — is_expired() = true (within 60s leeway), - // but is_usable() = true (not actually expired). let mut mocks = MockSet::new(); mocks.mock(|when, then| { when.post().path("/oauth/token"); @@ -580,31 +638,22 @@ mod tests { }); let server = start_server(mocks).await; let dir = tempfile::tempdir().unwrap(); - let strategy = Arc::new(strategy_with_token( + let strategy = Arc::new(auto_refresh_with_token( &dir, &server, make_token("still-usable", 30, true), )); - // Spawn two concurrent callers. let s1 = Arc::clone(&strategy); - let handle_a = tokio::spawn(async move { - let token = s1.as_ref().get_token().await.unwrap(); - token.into_owned() - }); + let handle_a = tokio::spawn(async move { s1.get_token().await.unwrap() }); let s2 = Arc::clone(&strategy); - let handle_b = tokio::spawn(async move { - let token = s2.as_ref().get_token().await.unwrap(); - token.into_owned() - }); + let handle_b = tokio::spawn(async move { s2.get_token().await.unwrap() }); let (result_a, result_b) = tokio::join!(handle_a, handle_b); let token_a = result_a.unwrap(); let token_b = result_b.unwrap(); - // Both should succeed. One gets the old token (still usable), the - // other may get either old or refreshed depending on timing. assert!( token_a.as_str() == "still-usable" || token_a.as_str() == "refreshed-token", "unexpected token_a: {}", @@ -619,7 +668,6 @@ mod tests { #[tokio::test] async fn test_concurrent_access_with_fully_expired_token() { - // Token is fully expired (expires_at in the past) with a refresh token. let mut mocks = MockSet::new(); mocks.mock(|when, then| { when.post().path("/oauth/token"); @@ -627,31 +675,22 @@ mod tests { }); let server = start_server(mocks).await; let dir = tempfile::tempdir().unwrap(); - let strategy = Arc::new(strategy_with_token( + let strategy = Arc::new(auto_refresh_with_token( &dir, &server, make_token("expired-token", 0, true), )); - // Spawn two concurrent callers. let s1 = Arc::clone(&strategy); - let handle_a = tokio::spawn(async move { - let token = s1.as_ref().get_token().await.unwrap(); - token.into_owned() - }); + let handle_a = tokio::spawn(async move { s1.get_token().await.unwrap() }); let s2 = Arc::clone(&strategy); - let handle_b = tokio::spawn(async move { - let token = s2.as_ref().get_token().await.unwrap(); - token.into_owned() - }); + let handle_b = tokio::spawn(async move { s2.get_token().await.unwrap() }); let (result_a, result_b) = tokio::join!(handle_a, handle_b); let token_a = result_a.unwrap(); let token_b = result_b.unwrap(); - // Both should get the refreshed token (one blocks until the other - // finishes refreshing). assert_eq!(token_a.as_str(), "refreshed-token"); assert_eq!(token_b.as_str(), "refreshed-token"); } @@ -660,6 +699,8 @@ mod tests { #[cfg(test)] mod stress_tests { use super::*; + use crate::oauth_refresher::OAuthRefresher; + use crate::token_store::TokenStore; use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::Arc; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; @@ -735,8 +776,10 @@ mod stress_tests { ) } - /// Starts an axum server and returns (base_url, counting_state). - async fn start_axum_server(handler: H, state: DelayedRefreshState) -> (Url, CountingState) + async fn start_axum_server( + handler: H, + state: DelayedRefreshState, + ) -> (url::Url, CountingState) where H: axum::handler::Handler + Clone + Send + 'static, T: 'static, @@ -750,7 +793,7 @@ mod stress_tests { tokio::spawn(async move { axum::serve(listener, app).await.unwrap(); }); - let base_url = Url::parse(&format!("http://{addr}")).unwrap(); + let base_url = url::Url::parse(&format!("http://{addr}")).unwrap(); (base_url, counting) } @@ -772,20 +815,19 @@ mod stress_tests { } } - fn strategy_with_token( + fn auto_refresh_with_token( dir: &tempfile::TempDir, - base_url: &Url, + base_url: &url::Url, token: Token, - ) -> TokenStoreStrategy { + ) -> AutoRefresh { let store = TokenStore::new(dir.path().join("auth.json")); store.save(&token).unwrap(); - TokenStoreStrategy::new(store, base_url.clone(), "cli") + let refresher = OAuthRefresher::new(store, base_url.clone(), "cli"); + AutoRefresh::with_token(refresher, token) } const CONCURRENCY: usize = 50; - /// Fresh token (baseline) — Token is valid, no refresh needed. - /// N concurrent callers should all return quickly with 0 server hits. #[tokio::test(flavor = "multi_thread", worker_threads = 4)] async fn test_concurrent_fresh_token_no_contention() { let counting = CountingState::new(); @@ -795,7 +837,7 @@ mod stress_tests { }; let (base_url, stats) = start_axum_server(delayed_refresh_handler, state).await; let dir = tempfile::tempdir().unwrap(); - let strategy = Arc::new(strategy_with_token( + let strategy = Arc::new(auto_refresh_with_token( &dir, &base_url, make_token("fresh-token", 3600, true), @@ -805,10 +847,7 @@ mod stress_tests { let mut handles = Vec::with_capacity(CONCURRENCY); for _ in 0..CONCURRENCY { let s = Arc::clone(&strategy); - handles.push(tokio::spawn(async move { - let token = s.as_ref().get_token().await.unwrap(); - token.into_owned() - })); + handles.push(tokio::spawn(async move { s.get_token().await.unwrap() })); } let results: Vec<_> = { @@ -820,12 +859,10 @@ mod stress_tests { }; let elapsed = start.elapsed(); - // All callers should get the fresh token. for token in &results { assert_eq!(token.as_str(), "fresh-token"); } - // Should complete quickly — no server round-trips. assert!( elapsed < Duration::from_millis(200), "expected < 200ms for fresh tokens, got {:?}", @@ -834,9 +871,6 @@ mod stress_tests { assert_eq!(stats.total(), 0, "no refresh requests should be made"); } - /// Preemptive refresh with latency — Token is expiring but still usable. - /// Non-refreshing callers should return immediately; only the refreshing - /// caller pays the latency cost. #[tokio::test(flavor = "multi_thread", worker_threads = 4)] async fn test_concurrent_expiring_token_non_blocking_reads() { let counting = CountingState::new(); @@ -846,9 +880,7 @@ mod stress_tests { }; let (base_url, stats) = start_axum_server(delayed_refresh_handler, state).await; let dir = tempfile::tempdir().unwrap(); - // Token expires in 30s — is_expired() = true (within 60s leeway), - // but is_usable() = true (hasn't actually expired). - let strategy = Arc::new(strategy_with_token( + let strategy = Arc::new(auto_refresh_with_token( &dir, &base_url, make_token("still-usable", 30, true), @@ -860,8 +892,8 @@ mod stress_tests { let s = Arc::clone(&strategy); handles.push(tokio::spawn(async move { let call_start = Instant::now(); - let token = s.as_ref().get_token().await.unwrap(); - (token.into_owned(), call_start.elapsed()) + let token = s.get_token().await.unwrap(); + (token, call_start.elapsed()) })); } @@ -874,7 +906,6 @@ mod stress_tests { }; let elapsed = start.elapsed(); - // All callers should get either the old (still usable) or the refreshed token. for (token, _) in &results { assert!( token.as_str() == "still-usable" || token.as_str() == "refreshed-token", @@ -883,8 +914,6 @@ mod stress_tests { ); } - // At least N-1 callers should complete quickly (they get the cached token - // while the refresher holds the lock only briefly before dropping it). let fast_callers = results .iter() .filter(|(_, dur)| *dur < Duration::from_millis(100)) @@ -897,13 +926,10 @@ mod stress_tests { elapsed ); - // Only one refresh request should hit the server. assert_eq!(stats.peak(), 1, "peak concurrency to refresh endpoint"); assert_eq!(stats.total(), 1, "total refresh requests"); } - /// Cold start / expired token with latency — Token is fully expired. - /// All callers block until the refresh completes. #[tokio::test(flavor = "multi_thread", worker_threads = 4)] async fn test_concurrent_expired_token_blocks_until_refresh() { let refresh_delay = Duration::from_millis(200); @@ -914,8 +940,7 @@ mod stress_tests { }; let (base_url, stats) = start_axum_server(delayed_refresh_handler, state).await; let dir = tempfile::tempdir().unwrap(); - // Fully expired token. - let strategy = Arc::new(strategy_with_token( + let strategy = Arc::new(auto_refresh_with_token( &dir, &base_url, make_token("expired-token", 0, true), @@ -925,10 +950,7 @@ mod stress_tests { let mut handles = Vec::with_capacity(CONCURRENCY); for _ in 0..CONCURRENCY { let s = Arc::clone(&strategy); - handles.push(tokio::spawn(async move { - let token = s.as_ref().get_token().await.unwrap(); - token.into_owned() - })); + handles.push(tokio::spawn(async move { s.get_token().await.unwrap() })); } let results: Vec<_> = { @@ -940,15 +962,10 @@ mod stress_tests { }; let elapsed = start.elapsed(); - // All callers should get the refreshed token. for token in &results { assert_eq!(token.as_str(), "refreshed-token"); } - // All callers should complete within refresh_delay + generous margin. - // The first caller triggers the refresh (holds the lock), others block. - // Once the refresh completes the lock is released with a fresh token, - // so subsequent callers see `is_expired() = false` and return immediately. assert!( elapsed < refresh_delay + Duration::from_millis(200), "expected < {:?} for blocked callers, got {:?}", @@ -956,28 +973,20 @@ mod stress_tests { elapsed ); - // Only one refresh request should hit the server. assert_eq!(stats.peak(), 1, "peak concurrency to refresh endpoint"); assert_eq!(stats.total(), 1, "total refresh requests"); } - /// Refresh failure with delayed error — Server returns an error after a delay. - /// All callers should eventually complete. Because the refresh token is - /// restored after each failure, each queued caller retries independently. - /// The key behavioral property is that the refresh token is always restored, - /// enabling future retries. #[tokio::test(flavor = "multi_thread", worker_threads = 4)] async fn test_concurrent_expired_token_refresh_failure_recovers() { let counting = CountingState::new(); let state = DelayedRefreshState { counting: counting.clone(), - // Short delay — each queued caller retries, so total time is - // proportional to concurrency. Keep delay small to stay fast. delay: Duration::from_millis(10), }; let (base_url, stats) = start_axum_server(delayed_error_handler, state).await; let dir = tempfile::tempdir().unwrap(); - let strategy = Arc::new(strategy_with_token( + let strategy = Arc::new(auto_refresh_with_token( &dir, &base_url, make_token("expired-token", 0, true), @@ -986,9 +995,7 @@ mod stress_tests { let mut handles = Vec::with_capacity(CONCURRENCY); for _ in 0..CONCURRENCY { let s = Arc::clone(&strategy); - handles.push(tokio::spawn(async move { - s.as_ref().get_token().await.map(|t| t.into_owned()) - })); + handles.push(tokio::spawn(async move { s.get_token().await })); } let results: Vec<_> = { @@ -999,16 +1006,14 @@ mod stress_tests { results }; - // All callers should get Expired errors. for result in &results { assert!(result.is_err(), "expected Expired error, got Ok"); assert!(matches!( result.as_ref().unwrap_err(), - TokenStoreError::Expired + AutoRefreshError::Expired )); } - // The refresh token should be restored for retry. let state = strategy.state.lock().await; assert!( state.token.as_ref().unwrap().refresh_token().is_some(), @@ -1016,21 +1021,16 @@ mod stress_tests { ); drop(state); - // Peak server concurrency should be 1 (lock serializes access). assert_eq!(stats.peak(), 1, "peak concurrency to refresh endpoint"); - // Each queued caller retries because the refresh token is restored - // after each failure. Total hits will be up to CONCURRENCY. assert!( stats.total() >= 1, "at least one refresh attempt should be made" ); } - /// Refresh failure then retry — First wave of callers hits a failing server, - /// second wave hits a working server. Verifies recovery under concurrent load. #[tokio::test(flavor = "multi_thread", worker_threads = 4)] async fn test_concurrent_refresh_failure_then_retry() { - // Phase 1: Start a server that returns errors. + // Phase 1: Server returns errors. let counting1 = CountingState::new(); let state1 = DelayedRefreshState { counting: counting1.clone(), @@ -1038,19 +1038,16 @@ mod stress_tests { }; let (base_url, _) = start_axum_server(delayed_error_handler, state1).await; let dir = tempfile::tempdir().unwrap(); - let strategy = Arc::new(strategy_with_token( + let strategy = Arc::new(auto_refresh_with_token( &dir, &base_url, make_token("expired-token", 0, true), )); - // First wave: all callers should get Expired. let mut handles = Vec::with_capacity(CONCURRENCY); for _ in 0..CONCURRENCY { let s = Arc::clone(&strategy); - handles.push(tokio::spawn(async move { - s.as_ref().get_token().await.map(|t| t.into_owned()) - })); + handles.push(tokio::spawn(async move { s.get_token().await })); } let results: Vec<_> = { @@ -1069,9 +1066,7 @@ mod stress_tests { ); } - // Phase 2: Start a new server that returns success. - // We need a new strategy pointing at the new server, but with the - // same state (token + restored refresh token). + // Phase 2: New server that returns success. let counting2 = CountingState::new(); let state2 = DelayedRefreshState { counting: counting2.clone(), @@ -1079,22 +1074,16 @@ mod stress_tests { }; let (base_url2, stats2) = start_axum_server(delayed_refresh_handler, state2).await; - // Build a new strategy pointing at the working server, seeded with - // the same expired token + refresh token (simulating a retry). - let strategy2 = Arc::new(strategy_with_token( + let strategy2 = Arc::new(auto_refresh_with_token( &dir, &base_url2, make_token("expired-token", 0, true), )); - // Second wave: all callers should get the refreshed token. let mut handles = Vec::with_capacity(CONCURRENCY); for _ in 0..CONCURRENCY { let s = Arc::clone(&strategy2); - handles.push(tokio::spawn(async move { - let token = s.as_ref().get_token().await.unwrap(); - token.into_owned() - })); + handles.push(tokio::spawn(async move { s.get_token().await.unwrap() })); } let results: Vec<_> = { diff --git a/packages/stack-auth/src/lib.rs b/packages/stack-auth/src/lib.rs index ba2ca5de3..83f135a68 100644 --- a/packages/stack-auth/src/lib.rs +++ b/packages/stack-auth/src/lib.rs @@ -63,44 +63,41 @@ #![cfg_attr(test, allow(clippy::panic))] #![cfg_attr(test, allow(unused_results))] -use std::borrow::Cow; use std::convert::Infallible; +use std::future::Future; #[cfg(not(test))] use std::time::Duration; use vitaminc::protected::OpaqueDebug; use zeroize::ZeroizeOnDrop; +mod access_key_refresher; +mod access_key_strategy; +mod auto_refresh; mod device_code; +mod oauth_refresher; +mod oauth_strategy; +mod refresher; mod token; mod token_store; -mod token_store_strategy; +pub use access_key_strategy::AccessKeyStrategy; pub use device_code::{DeviceCodeStrategy, PendingDeviceCode}; +pub use oauth_strategy::OAuthStrategy; pub use token::Token; pub use token_store::{TokenStore, TokenStoreError}; -pub use token_store_strategy::TokenStoreStrategy; -/// A strategy for obtaining a [`SecretToken`] for authenticating with CipherStash services. +/// A strategy for obtaining access tokens. /// -/// Implementors provide a single method, [`get_token`](AuthStrategy::get_token), which -/// returns a valid access token. The strategy is responsible for managing token -/// lifecycle concerns such as caching, refreshing, or re-authenticating as needed. +/// Implementations handle all details of authentication, token caching, and +/// refresh. Callers just call [`get_token`](AuthStrategy::get_token) whenever +/// they need a valid token. /// -/// The lifetime `'a` ties the returned reference to the data that owns the token, -/// allowing the same strategy to be called multiple times (e.g. by implementing -/// the trait for `&'a T`). -pub trait AuthStrategy<'a> { - /// The error type returned when token retrieval fails. - type Error; - - /// Retrieve a valid access token. - /// - /// Returns `Cow::Borrowed` for strategies that own a stable token, or - /// `Cow::Owned` for strategies that clone the token out from behind a lock. - fn get_token( - self, - ) -> impl std::future::Future, Self::Error>> + Send; +/// The trait is designed to be implemented for `&T`, so that callers can use +/// shared references (e.g. `&OAuthStrategy`) without consuming the strategy. +pub trait AuthStrategy: Send { + /// Retrieve a valid access token, refreshing or re-authenticating as needed. + fn get_token(self) -> impl Future> + Send; } /// A sensitive token string that is zeroized on drop and hidden from debug output. @@ -112,16 +109,15 @@ pub trait AuthStrategy<'a> { /// - **Opaque debug**: the [`Debug`] implementation prints `"***"` instead of /// the actual value, so tokens won't leak into logs or error messages. /// -/// You cannot construct a `SecretToken` directly — it is returned by the -/// authentication flow via [`Token::access_token`]. +/// Use [`SecretToken::new`] to wrap a string value (e.g. an access key +/// loaded from configuration or an environment variable). #[derive(Clone, OpaqueDebug, ZeroizeOnDrop, serde::Deserialize, serde::Serialize)] #[serde(transparent)] pub struct SecretToken(String); impl SecretToken { /// Create a new `SecretToken` from a string value. - #[cfg(test)] - pub(crate) fn new(value: impl Into) -> Self { + pub fn new(value: impl Into) -> Self { Self(value.into()) } @@ -156,6 +152,12 @@ pub enum AuthError { /// The requested region is not supported. #[error("Unsupported region: {0}")] Region(#[from] cts_common::RegionError), + /// No credentials are available (e.g. not logged in, no access key configured). + #[error("Not authenticated")] + NotAuthenticated, + /// The token has expired and could not be refreshed. + #[error("Token expired")] + TokenExpired, /// An unexpected error was returned by the auth server. #[error("Server error: {0}")] Server(String), diff --git a/packages/stack-auth/src/oauth_refresher.rs b/packages/stack-auth/src/oauth_refresher.rs new file mode 100644 index 000000000..4af6dc89d --- /dev/null +++ b/packages/stack-auth/src/oauth_refresher.rs @@ -0,0 +1,47 @@ +use url::Url; + +use crate::refresher::Refresher; +use crate::token_store::TokenStore; +use crate::{AuthError, SecretToken, Token}; + +/// Implements [`Refresher`] using OAuth refresh tokens. +/// +/// Owns a [`TokenStore`] for persisting refreshed tokens to disk. +pub(crate) struct OAuthRefresher { + store: TokenStore, + base_url: Url, + client_id: String, +} + +impl OAuthRefresher { + pub(crate) fn new(store: TokenStore, base_url: Url, client_id: impl Into) -> Self { + Self { + store, + base_url, + client_id: client_id.into(), + } + } +} + +impl Refresher for OAuthRefresher { + type Credential = SecretToken; + + fn save(&self, token: &Token) { + match self.store.save(token) { + Ok(()) => tracing::debug!("refreshed token saved to disk"), + Err(err) => tracing::warn!(%err, "failed to save refreshed token to disk"), + } + } + + fn try_credential(&self, token: Option<&mut Token>) -> Option { + token.and_then(|t| t.take_refresh_token()) + } + + fn restore(&self, token: &mut Token, credential: Self::Credential) { + token.refresh_token = Some(credential); + } + + async fn refresh(&self, credential: &Self::Credential) -> Result { + Token::refresh(credential, &self.base_url, &self.client_id).await + } +} diff --git a/packages/stack-auth/src/oauth_strategy.rs b/packages/stack-auth/src/oauth_strategy.rs new file mode 100644 index 000000000..adc4e9046 --- /dev/null +++ b/packages/stack-auth/src/oauth_strategy.rs @@ -0,0 +1,42 @@ +use url::Url; + +use crate::auto_refresh::AutoRefresh; +use crate::oauth_refresher::OAuthRefresher; +use crate::token_store::{TokenStore, TokenStoreError}; +use crate::{AuthError, AuthStrategy, SecretToken}; + +/// An [`AuthStrategy`] that uses OAuth refresh tokens to maintain a valid access token. +/// +/// Wraps a [`TokenStore`] for persistence and handles token refresh via the +/// OAuth `/oauth/token` endpoint. +/// +/// # Construction +/// +/// Requires a pre-existing token on disk (from a prior device code flow). +/// Returns [`TokenStoreError::NotFound`] if no token has been saved yet. +pub struct OAuthStrategy { + inner: AutoRefresh, +} + +impl OAuthStrategy { + /// Create a new `OAuthStrategy` by loading a token from the given store. + /// + /// Returns an error if the token file is missing or unreadable. + pub fn new( + store: TokenStore, + base_url: Url, + client_id: impl Into, + ) -> Result { + let token = store.load()?.ok_or(TokenStoreError::NotFound)?; + let refresher = OAuthRefresher::new(store, base_url, client_id); + Ok(Self { + inner: AutoRefresh::with_token(refresher, token), + }) + } +} + +impl AuthStrategy for &OAuthStrategy { + async fn get_token(self) -> Result { + Ok(self.inner.get_token().await?) + } +} diff --git a/packages/stack-auth/src/refresher.rs b/packages/stack-auth/src/refresher.rs new file mode 100644 index 000000000..576e11a49 --- /dev/null +++ b/packages/stack-auth/src/refresher.rs @@ -0,0 +1,34 @@ +use std::future::Future; + +use crate::{AuthError, Token}; + +/// Internal trait defining how to refresh or re-authenticate to obtain a new [`Token`]. +/// +/// [`AutoRefresh`](crate::auto_refresh::AutoRefresh) delegates the type-specific +/// parts of token refresh to the `Refresher` implementation while handling the +/// concurrency orchestration (cascade prevention, two-tier locking) generically. +pub(crate) trait Refresher: Send + Sync { + /// The credential extracted from the current token before a refresh attempt. + type Credential: Send; + + /// Persist a token after a successful refresh. Best-effort — implementations + /// should log on failure rather than returning an error. + fn save(&self, token: &Token); + + /// Extract a credential for refreshing. + /// + /// `token` is `None` on cold start (no cached token). Returns `None` if + /// this refresher can't produce a token without a prior one (e.g. OAuth + /// needs a refresh token). + fn try_credential(&self, token: Option<&mut Token>) -> Option; + + /// Restore state after a failed refresh attempt (e.g. put the refresh token + /// back so the next caller can retry). + fn restore(&self, token: &mut Token, credential: Self::Credential); + + /// Perform the HTTP refresh or authentication call. + fn refresh( + &self, + credential: &Self::Credential, + ) -> impl Future> + Send; +} diff --git a/packages/stack-auth/src/token.rs b/packages/stack-auth/src/token.rs index a59d183a3..60f158241 100644 --- a/packages/stack-auth/src/token.rs +++ b/packages/stack-auth/src/token.rs @@ -4,6 +4,13 @@ use url::Url; use crate::{http_client, AuthError, SecretToken}; +/// How many seconds before expiry [`Token::is_expired`] returns `true`. +/// +/// This leeway triggers preemptive refresh well before the token becomes +/// unusable, giving the HTTP refresh call time to complete while concurrent +/// callers can still use the current token. +const EXPIRY_LEEWAY_SECS: u64 = 90; + /// An access token returned by a successful authentication flow. /// /// The token contains a [`SecretToken`] (the bearer credential), a token type @@ -45,9 +52,12 @@ impl Token { self.expires_at.saturating_sub(now) } - /// Returns `true` if the token has expired (with 60 seconds of leeway). + /// Returns `true` if the token has expired (with 90 seconds of leeway). + /// + /// The 90-second leeway triggers preemptive refresh well before the token + /// becomes unusable, giving the HTTP refresh call plenty of time to complete + /// while the current token is still valid for concurrent callers. /// - /// Use this to decide whether a preemptive refresh should be attempted. /// For checking whether the token is still usable as a bearer credential, /// use [`is_usable`](Self::is_usable) instead. pub fn is_expired(&self) -> bool { @@ -55,12 +65,12 @@ impl Token { .duration_since(UNIX_EPOCH) .unwrap_or_default() .as_secs(); - now + 60 >= self.expires_at + now + EXPIRY_LEEWAY_SECS >= self.expires_at } /// Returns `true` if the token is still usable (before the actual expiry timestamp). /// - /// Unlike [`is_expired`](Self::is_expired) which includes 60s leeway for preemptive + /// Unlike [`is_expired`](Self::is_expired) which includes 90s leeway for preemptive /// refresh, this only returns `false` when the token has genuinely expired. pub fn is_usable(&self) -> bool { let now = SystemTime::now() From 78e7632ab52e1c04193fc9943c11bbaffb0e439b Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Sun, 22 Feb 2026 00:09:03 +1100 Subject: [PATCH 034/686] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor(stack-aut?= =?UTF-8?q?h):=20replace=20refresher=5Fmut()=20with=20builder=20pattern=20?= =?UTF-8?q?for=20all=20strategies?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add dedicated builder structs (AccessKeyStrategyBuilder, OAuthStrategyBuilder, DeviceCodeStrategyBuilder) so strategy configuration is collected upfront and the refresher + AutoRefresh are constructed in a single build() step. This removes the need for AutoRefresh::refresher_mut() which leaked internals and coupled strategies to AutoRefresh's structure. Convenience methods (new, using_store) now delegate to their respective builders. Also adds region/client_id fields to Token for round-tripping through the token store, and refactors OAuthRefresher to accept an optional store and region parameter. --- languages/typescript/packages/auth/Cargo.lock | 66 ++++++- languages/typescript/packages/auth/src/lib.rs | 13 +- packages/stack-auth/examples/device_code.rs | 5 +- .../stack-auth/src/access_key_refresher.rs | 10 + .../stack-auth/src/access_key_strategy.rs | 92 ++++++++- packages/stack-auth/src/auto_refresh.rs | 13 +- packages/stack-auth/src/device_code/mod.rs | 80 +++++--- packages/stack-auth/src/device_code/tests.rs | 6 +- packages/stack-auth/src/lib.rs | 18 +- packages/stack-auth/src/oauth_refresher.rs | 27 ++- packages/stack-auth/src/oauth_strategy.rs | 176 ++++++++++++++++-- packages/stack-auth/src/token.rs | 28 +++ packages/stack-auth/src/token_store.rs | 2 + 13 files changed, 459 insertions(+), 77 deletions(-) diff --git a/languages/typescript/packages/auth/Cargo.lock b/languages/typescript/packages/auth/Cargo.lock index aa331d04b..6ace94761 100644 --- a/languages/typescript/packages/auth/Cargo.lock +++ b/languages/typescript/packages/auth/Cargo.lock @@ -47,6 +47,20 @@ version = "1.0.101" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5f0e0fee31ef5ed1ba1316088939cea399010ed7731dba877ed44aeb407a75ea" +[[package]] +name = "aquamarine" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f50776554130342de4836ba542aa85a4ddb361690d7e8df13774d7284c3d5c2" +dependencies = [ + "include_dir", + "itertools 0.10.5", + "proc-macro-error2", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "arrayvec" version = "0.7.6" @@ -969,6 +983,25 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "include_dir" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "923d117408f1e49d914f1a379a309cffe4f18c05cf4e3d12e613a15fc81bd0dd" +dependencies = [ + "include_dir_macros", +] + +[[package]] +name = "include_dir_macros" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cab85a7ed0bd5f0e76d93846e0147172bed2e2d3f859bcc33a8d9699cad1a75" +dependencies = [ + "proc-macro2", + "quote", +] + [[package]] name = "indexmap" version = "2.13.0" @@ -1034,6 +1067,15 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7655c9839580ee829dfacba1d1278c2b7883e50a277ff7541299489d6bdfdc45" +[[package]] +name = "itertools" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" +dependencies = [ + "either", +] + [[package]] name = "itertools" version = "0.14.0" @@ -1432,6 +1474,27 @@ dependencies = [ "syn", ] +[[package]] +name = "proc-macro-error-attr2" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "proc-macro-error2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802" +dependencies = [ + "proc-macro-error-attr2", + "proc-macro2", + "quote", +] + [[package]] name = "proc-macro2" version = "1.0.106" @@ -1458,7 +1521,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a56d757972c98b346a9b766e3f02746cde6dd1cd1d1d563472929fdd74bec4d" dependencies = [ "anyhow", - "itertools", + "itertools 0.14.0", "proc-macro2", "quote", "syn", @@ -1928,6 +1991,7 @@ checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" name = "stack-auth" version = "0.1.0" dependencies = [ + "aquamarine", "cts-common", "dirs", "open", diff --git a/languages/typescript/packages/auth/src/lib.rs b/languages/typescript/packages/auth/src/lib.rs index 37b6f6ea6..0993786a7 100644 --- a/languages/typescript/packages/auth/src/lib.rs +++ b/languages/typescript/packages/auth/src/lib.rs @@ -22,6 +22,7 @@ fn error_code(err: &AuthError) -> &'static str { AuthError::InvalidUrl(_) => "INVALID_URL", AuthError::Region(_) => "INVALID_REGION", AuthError::Server(_) => "SERVER_ERROR", + AuthError::Store(_) => "STORE_ERROR", _ => "UNKNOWN_ERROR", } } @@ -220,9 +221,9 @@ mod tests { /// against a mock server, then wrapping the `PendingDeviceCode`. async fn begin_result(server: &MockServer) -> DeviceCodeResult { let strategy = - DeviceCodeStrategy::new(Region::aws("ap-southeast-2").unwrap(), "test-client") - .unwrap() - .with_base_url(server.url("")) + DeviceCodeStrategy::builder(Region::aws("ap-southeast-2").unwrap(), "test-client") + .base_url(server.url("")) + .build() .unwrap(); let pending = strategy.begin().await.unwrap(); DeviceCodeResult::from_pending(pending) @@ -433,9 +434,9 @@ pub async fn begin_device_code_flow_with_base_url( let parsed_url: url::Url = base_url .parse() .map_err(|e: url::ParseError| to_napi_error(AuthError::from(e)))?; - let strategy = DeviceCodeStrategy::new(region, client_id) - .map_err(to_napi_error)? - .with_base_url(parsed_url) + let strategy = DeviceCodeStrategy::builder(region, client_id) + .base_url(parsed_url) + .build() .map_err(to_napi_error)?; let pending = strategy.begin().await.map_err(to_napi_error)?; Ok(DeviceCodeResult::from_pending(pending)) diff --git a/packages/stack-auth/examples/device_code.rs b/packages/stack-auth/examples/device_code.rs index 0469fa292..1fd1e7274 100644 --- a/packages/stack-auth/examples/device_code.rs +++ b/packages/stack-auth/examples/device_code.rs @@ -6,8 +6,9 @@ async fn main() -> Result<(), Box> { tracing_subscriber::fmt::init(); let region = Region::aws("ap-southeast-2")?; - let strategy = - DeviceCodeStrategy::new(region, "cli")?.with_base_url("http://localhost:3001")?; + let strategy = DeviceCodeStrategy::builder(region, "cli") + .base_url("http://localhost:3001".parse()?) + .build()?; // Step 1: Begin the device code flow let pending = strategy.begin().await?; diff --git a/packages/stack-auth/src/access_key_refresher.rs b/packages/stack-auth/src/access_key_refresher.rs index 778ee201a..435a0dd41 100644 --- a/packages/stack-auth/src/access_key_refresher.rs +++ b/packages/stack-auth/src/access_key_refresher.rs @@ -73,6 +73,8 @@ impl Refresher for AccessKeyRefresher { token_type: "Bearer".to_string(), expires_at: now + auth_resp.expiry, refresh_token: None, + region: None, + client_id: None, }) } } @@ -133,6 +135,8 @@ mod tests { token_type: "Bearer".to_string(), expires_at: now, // already expired refresh_token: None, + region: None, + client_id: None, } } @@ -311,6 +315,8 @@ mod tests { token_type: "Bearer".to_string(), expires_at: now + 30, // is_expired() = true (within 90s), is_usable() = true refresh_token: None, + region: None, + client_id: None, }; let refresher = @@ -475,6 +481,8 @@ mod tests { token_type: "Bearer".to_string(), expires_at: now + 3600, refresh_token: None, + region: None, + client_id: None, }; let strategy = Arc::new(AutoRefresh::with_token(refresher, token)); @@ -523,6 +531,8 @@ mod tests { token_type: "Bearer".to_string(), expires_at: now + 30, refresh_token: None, + region: None, + client_id: None, }; let refresher = AccessKeyRefresher::new(SecretToken::new("test-access-key"), base_url, None); diff --git a/packages/stack-auth/src/access_key_strategy.rs b/packages/stack-auth/src/access_key_strategy.rs index 984144b70..939564e5b 100644 --- a/packages/stack-auth/src/access_key_strategy.rs +++ b/packages/stack-auth/src/access_key_strategy.rs @@ -1,24 +1,56 @@ -use url::Url; +use cts_common::{CtsServiceDiscovery, Region, ServiceDiscovery}; use crate::access_key_refresher::AccessKeyRefresher; use crate::auto_refresh::AutoRefresh; -use crate::{AuthError, AuthStrategy, SecretToken}; +use crate::{ensure_trailing_slash, AuthError, AuthStrategy, SecretToken}; /// An [`AuthStrategy`] that uses a static access key to authenticate. /// /// The first call to [`get_token`](AuthStrategy::get_token) authenticates with /// the server. Subsequent calls return the cached token until it expires, at /// which point re-authentication happens automatically. +/// +/// # Example +/// +/// ```no_run +/// use stack_auth::{AccessKeyStrategy, SecretToken}; +/// use cts_common::Region; +/// +/// let region = Region::aws("ap-southeast-2").unwrap(); +/// let strategy = AccessKeyStrategy::new(region, SecretToken::new("my-key")).unwrap(); +/// ``` pub struct AccessKeyStrategy { inner: AutoRefresh, } impl AccessKeyStrategy { - /// Create a new `AccessKeyStrategy`. - pub fn new(access_key: SecretToken, base_url: Url, audience: Option) -> Self { - let refresher = AccessKeyRefresher::new(access_key, base_url, audience); - Self { - inner: AutoRefresh::new(refresher), + /// Create a new `AccessKeyStrategy` for the given region and access key. + /// + /// The auth endpoint is resolved automatically via service discovery. + pub fn new(region: Region, access_key: SecretToken) -> Result { + Self::builder(region, access_key).build() + } + + /// Return a builder for configuring an `AccessKeyStrategy` before construction. + /// + /// # Example + /// + /// ```no_run + /// use stack_auth::{AccessKeyStrategy, SecretToken}; + /// use cts_common::Region; + /// + /// let region = Region::aws("ap-southeast-2").unwrap(); + /// let strategy = AccessKeyStrategy::builder(region, SecretToken::new("my-key")) + /// .audience("my-audience") + /// .build() + /// .unwrap(); + /// ``` + pub fn builder(region: Region, access_key: SecretToken) -> AccessKeyStrategyBuilder { + AccessKeyStrategyBuilder { + region, + access_key, + audience: None, + base_url_override: None, } } } @@ -28,3 +60,49 @@ impl AuthStrategy for &AccessKeyStrategy { Ok(self.inner.get_token().await?) } } + +/// Builder for [`AccessKeyStrategy`]. +/// +/// Created via [`AccessKeyStrategy::builder`]. +pub struct AccessKeyStrategyBuilder { + region: Region, + access_key: SecretToken, + audience: Option, + base_url_override: Option, +} + +impl AccessKeyStrategyBuilder { + /// Set the audience for token requests. + pub fn audience(mut self, audience: impl Into) -> Self { + self.audience = Some(audience.into()); + self + } + + /// Override the base URL resolved by service discovery. + /// + /// Useful for pointing at a local or mock auth server during testing. + #[cfg(any(test, feature = "test-utils"))] + pub fn base_url(mut self, url: url::Url) -> Self { + self.base_url_override = Some(url); + self + } + + /// Build the [`AccessKeyStrategy`]. + /// + /// Resolves the base URL via service discovery unless overridden with + /// [`base_url`](Self::base_url). + pub fn build(self) -> Result { + let base_url = match self.base_url_override { + Some(url) => url, + None => CtsServiceDiscovery::endpoint(self.region)?, + }; + let refresher = AccessKeyRefresher::new( + self.access_key, + ensure_trailing_slash(base_url), + self.audience, + ); + Ok(AccessKeyStrategy { + inner: AutoRefresh::new(refresher), + }) + } +} diff --git a/packages/stack-auth/src/auto_refresh.rs b/packages/stack-auth/src/auto_refresh.rs index e8695f215..b6d8902ae 100644 --- a/packages/stack-auth/src/auto_refresh.rs +++ b/packages/stack-auth/src/auto_refresh.rs @@ -297,6 +297,8 @@ mod tests { } else { None }, + region: None, + client_id: None, } } @@ -329,7 +331,8 @@ mod tests { ) -> AutoRefresh { let store = TokenStore::new(dir.path().join("auth.json")); store.save(&token).unwrap(); - let refresher = OAuthRefresher::new(store, server.url(""), "cli"); + let refresher = + OAuthRefresher::new(Some(store), server.url(""), "cli", "ap-southeast-2.aws"); AutoRefresh::with_token(refresher, token) } @@ -351,7 +354,8 @@ mod tests { async fn test_returns_not_found_when_no_token_and_oauth() { let server = start_server(MockSet::new()).await; let store = TokenStore::new("/tmp/nonexistent/auth.json"); - let refresher = OAuthRefresher::new(store, server.url(""), "cli"); + let refresher = + OAuthRefresher::new(Some(store), server.url(""), "cli", "ap-southeast-2.aws"); let strategy = AutoRefresh::new(refresher); let err = strategy.get_token().await.unwrap_err(); @@ -812,6 +816,8 @@ mod stress_tests { } else { None }, + region: None, + client_id: None, } } @@ -822,7 +828,8 @@ mod stress_tests { ) -> AutoRefresh { let store = TokenStore::new(dir.path().join("auth.json")); store.save(&token).unwrap(); - let refresher = OAuthRefresher::new(store, base_url.clone(), "cli"); + let refresher = + OAuthRefresher::new(Some(store), base_url.clone(), "cli", "ap-southeast-2.aws"); AutoRefresh::with_token(refresher, token) } diff --git a/packages/stack-auth/src/device_code/mod.rs b/packages/stack-auth/src/device_code/mod.rs index be2d6270b..efc383a56 100644 --- a/packages/stack-auth/src/device_code/mod.rs +++ b/packages/stack-auth/src/device_code/mod.rs @@ -5,7 +5,7 @@ use url::Url; use std::time::{SystemTime, UNIX_EPOCH}; -use crate::{http_client, token_store::TokenStore, AuthError, Token}; +use crate::{ensure_trailing_slash, http_client, token_store::TokenStore, AuthError, Token}; use protocol::{ DeviceCode, DeviceCodeRequest, DeviceCodeResponse, ErrorResponse, TokenRequest, TokenResponse, }; @@ -30,6 +30,7 @@ mod tests; /// let strategy = DeviceCodeStrategy::new(region, "my-client-id").unwrap(); /// ``` pub struct DeviceCodeStrategy { + region: Region, base_url: Url, client_id: String, } @@ -51,24 +52,16 @@ impl DeviceCodeStrategy { /// ).unwrap(); /// ``` pub fn new(region: Region, client_id: impl Into) -> Result { - let base_url = CtsServiceDiscovery::endpoint(region)?; - Ok(Self { - base_url: ensure_trailing_slash(base_url), - client_id: client_id.into(), - }) + Self::builder(region, client_id).build() } - /// Override the base URL resolved by service discovery. - /// - /// Useful for pointing at a local or mock CTS instance during testing. - #[cfg(any(test, feature = "test-utils"))] - pub fn with_base_url(mut self, base_url: U) -> Result - where - U: TryInto, - U::Error: Into, - { - self.base_url = ensure_trailing_slash(base_url.try_into().map_err(Into::into)?); - Ok(self) + /// Return a builder for configuring a `DeviceCodeStrategy` before construction. + pub fn builder(region: Region, client_id: impl Into) -> DeviceCodeStrategyBuilder { + DeviceCodeStrategyBuilder { + region, + client_id: client_id.into(), + base_url_override: None, + } } /// Start the device code flow. @@ -118,6 +111,7 @@ impl DeviceCodeStrategy { Ok(PendingDeviceCode { token_url, + region: self.region, client_id: self.client_id.clone(), device_code: code.device_code, user_code: code.user_code, @@ -128,6 +122,42 @@ impl DeviceCodeStrategy { } } +/// Builder for [`DeviceCodeStrategy`]. +/// +/// Created via [`DeviceCodeStrategy::builder`]. +pub struct DeviceCodeStrategyBuilder { + region: Region, + client_id: String, + base_url_override: Option, +} + +impl DeviceCodeStrategyBuilder { + /// Override the base URL resolved by service discovery. + /// + /// Useful for pointing at a local or mock CTS instance during testing. + #[cfg(any(test, feature = "test-utils"))] + pub fn base_url(mut self, url: Url) -> Self { + self.base_url_override = Some(url); + self + } + + /// Build the [`DeviceCodeStrategy`]. + /// + /// Resolves the base URL via service discovery unless overridden with + /// [`base_url`](Self::base_url). + pub fn build(self) -> Result { + let base_url = match self.base_url_override { + Some(url) => url, + None => CtsServiceDiscovery::endpoint(self.region)?, + }; + Ok(DeviceCodeStrategy { + region: self.region, + base_url: ensure_trailing_slash(base_url), + client_id: self.client_id, + }) + } +} + /// A device code flow that is waiting for the user to authorize. /// /// Returned by [`DeviceCodeStrategy::begin`]. Display the @@ -155,6 +185,7 @@ impl DeviceCodeStrategy { #[derive(Debug)] pub struct PendingDeviceCode { token_url: Url, + region: Region, client_id: String, device_code: DeviceCode, /// The short code the user must enter to authorize this device. @@ -242,12 +273,16 @@ impl PendingDeviceCode { .duration_since(UNIX_EPOCH) .unwrap_or_default() .as_secs(); - let token = Token { + let mut token = Token { access_token: token_resp.access_token, token_type: token_resp.token_type, expires_at: now + token_resp.expires_in, refresh_token: token_resp.refresh_token, + region: None, + client_id: None, }; + token.set_region(self.region.identifier()); + token.set_client_id(&self.client_id); match TokenStore::new_default().and_then(|store| store.save(&token)) { Ok(()) => tracing::debug!("token saved to disk"), @@ -277,12 +312,3 @@ impl PendingDeviceCode { } } } - -/// Ensure a URL has a trailing slash so that `Url::join` with relative paths -/// appends to the path rather than replacing the last segment. -fn ensure_trailing_slash(mut url: Url) -> Url { - if !url.path().ends_with('/') { - url.set_path(&format!("{}/", url.path())); - } - url -} diff --git a/packages/stack-auth/src/device_code/tests.rs b/packages/stack-auth/src/device_code/tests.rs index 28cbebb4f..dbd2c0095 100644 --- a/packages/stack-auth/src/device_code/tests.rs +++ b/packages/stack-auth/src/device_code/tests.rs @@ -41,9 +41,9 @@ async fn start_server(mocks: MockSet) -> MockServer { } fn strategy_for(server: &MockServer) -> DeviceCodeStrategy { - DeviceCodeStrategy::new(Region::aws("ap-southeast-2").unwrap(), "cli") - .unwrap() - .with_base_url(server.url("")) + DeviceCodeStrategy::builder(Region::aws("ap-southeast-2").unwrap(), "cli") + .base_url(server.url("")) + .build() .unwrap() } diff --git a/packages/stack-auth/src/lib.rs b/packages/stack-auth/src/lib.rs index 83f135a68..d900bacac 100644 --- a/packages/stack-auth/src/lib.rs +++ b/packages/stack-auth/src/lib.rs @@ -81,9 +81,9 @@ mod refresher; mod token; mod token_store; -pub use access_key_strategy::AccessKeyStrategy; -pub use device_code::{DeviceCodeStrategy, PendingDeviceCode}; -pub use oauth_strategy::OAuthStrategy; +pub use access_key_strategy::{AccessKeyStrategy, AccessKeyStrategyBuilder}; +pub use device_code::{DeviceCodeStrategy, DeviceCodeStrategyBuilder, PendingDeviceCode}; +pub use oauth_strategy::{OAuthStrategy, OAuthStrategyBuilder}; pub use token::Token; pub use token_store::{TokenStore, TokenStoreError}; @@ -161,6 +161,9 @@ pub enum AuthError { /// An unexpected error was returned by the auth server. #[error("Server error: {0}")] Server(String), + /// A token store operation failed. + #[error("Token store error: {0}")] + Store(#[from] token_store::TokenStoreError), } impl From for AuthError { @@ -169,6 +172,15 @@ impl From for AuthError { } } +/// Ensure a URL has a trailing slash so that `Url::join` with relative paths +/// appends to the path rather than replacing the last segment. +pub(crate) fn ensure_trailing_slash(mut url: url::Url) -> url::Url { + if !url.path().ends_with('/') { + url.set_path(&format!("{}/", url.path())); + } + url +} + /// Create a [`reqwest::Client`] with standard timeouts. /// /// In test builds, timeouts are omitted so that `tokio::test(start_paused = true)` diff --git a/packages/stack-auth/src/oauth_refresher.rs b/packages/stack-auth/src/oauth_refresher.rs index 4af6dc89d..53df6e9be 100644 --- a/packages/stack-auth/src/oauth_refresher.rs +++ b/packages/stack-auth/src/oauth_refresher.rs @@ -6,19 +6,27 @@ use crate::{AuthError, SecretToken, Token}; /// Implements [`Refresher`] using OAuth refresh tokens. /// -/// Owns a [`TokenStore`] for persisting refreshed tokens to disk. +/// Optionally owns a [`TokenStore`] for persisting refreshed tokens to disk. +/// When the store is `None`, tokens are cached in memory only. pub(crate) struct OAuthRefresher { - store: TokenStore, + store: Option, base_url: Url, client_id: String, + region: String, } impl OAuthRefresher { - pub(crate) fn new(store: TokenStore, base_url: Url, client_id: impl Into) -> Self { + pub(crate) fn new( + store: Option, + base_url: Url, + client_id: impl Into, + region: impl Into, + ) -> Self { Self { store, base_url, client_id: client_id.into(), + region: region.into(), } } } @@ -27,9 +35,11 @@ impl Refresher for OAuthRefresher { type Credential = SecretToken; fn save(&self, token: &Token) { - match self.store.save(token) { - Ok(()) => tracing::debug!("refreshed token saved to disk"), - Err(err) => tracing::warn!(%err, "failed to save refreshed token to disk"), + if let Some(store) = &self.store { + match store.save(token) { + Ok(()) => tracing::debug!("refreshed token saved to disk"), + Err(err) => tracing::warn!(%err, "failed to save refreshed token to disk"), + } } } @@ -42,6 +52,9 @@ impl Refresher for OAuthRefresher { } async fn refresh(&self, credential: &Self::Credential) -> Result { - Token::refresh(credential, &self.base_url, &self.client_id).await + let mut token = Token::refresh(credential, &self.base_url, &self.client_id).await?; + token.set_region(&self.region); + token.set_client_id(&self.client_id); + Ok(token) } } diff --git a/packages/stack-auth/src/oauth_strategy.rs b/packages/stack-auth/src/oauth_strategy.rs index adc4e9046..f0bbe8ef3 100644 --- a/packages/stack-auth/src/oauth_strategy.rs +++ b/packages/stack-auth/src/oauth_strategy.rs @@ -1,37 +1,87 @@ -use url::Url; +use cts_common::{CtsServiceDiscovery, Region, ServiceDiscovery}; use crate::auto_refresh::AutoRefresh; use crate::oauth_refresher::OAuthRefresher; -use crate::token_store::{TokenStore, TokenStoreError}; -use crate::{AuthError, AuthStrategy, SecretToken}; +use crate::token_store::TokenStore; +use crate::{ensure_trailing_slash, AuthError, AuthStrategy, SecretToken, Token}; /// An [`AuthStrategy`] that uses OAuth refresh tokens to maintain a valid access token. /// -/// Wraps a [`TokenStore`] for persistence and handles token refresh via the -/// OAuth `/oauth/token` endpoint. -/// /// # Construction /// -/// Requires a pre-existing token on disk (from a prior device code flow). -/// Returns [`TokenStoreError::NotFound`] if no token has been saved yet. +/// Use [`OAuthStrategy::new`] with a token obtained from a device code flow +/// (or any other OAuth flow) for in-memory caching only. Use +/// [`OAuthStrategy::using_store`] to load a token from disk and persist +/// refreshed tokens back to the store. +/// +/// # Example +/// +/// ```no_run +/// use stack_auth::{OAuthStrategy, Token}; +/// use cts_common::Region; +/// +/// # fn run(token: Token) -> Result<(), Box> { +/// let region = Region::aws("ap-southeast-2")?; +/// let strategy = OAuthStrategy::new(region, "my-client-id", token)?; +/// # Ok(()) +/// # } +/// ``` pub struct OAuthStrategy { inner: AutoRefresh, } impl OAuthStrategy { - /// Create a new `OAuthStrategy` by loading a token from the given store. + /// Create a new `OAuthStrategy` with the given token (in-memory only). /// - /// Returns an error if the token file is missing or unreadable. + /// The token's `region` and `client_id` fields are set before caching. + /// No token store is used — tokens are not persisted to disk. pub fn new( - store: TokenStore, - base_url: Url, + region: Region, client_id: impl Into, - ) -> Result { - let token = store.load()?.ok_or(TokenStoreError::NotFound)?; - let refresher = OAuthRefresher::new(store, base_url, client_id); - Ok(Self { - inner: AutoRefresh::with_token(refresher, token), - }) + token: Token, + ) -> Result { + Self::builder(region, client_id, token).build() + } + + /// Return a builder for configuring an `OAuthStrategy` from a token. + pub fn builder( + region: Region, + client_id: impl Into, + token: Token, + ) -> OAuthStrategyBuilder { + OAuthStrategyBuilder { + source: OAuthTokenSource::Token { + region, + client_id: client_id.into(), + token, + }, + base_url_override: None, + } + } + + /// Create an `OAuthStrategy` by loading a token from the given store. + /// + /// The token must have `region` and `client_id` set (as saved by + /// [`DeviceCodeStrategy`](crate::DeviceCodeStrategy) or a prior + /// `OAuthStrategy`). The store is used for persisting refreshed tokens. + /// + /// # Errors + /// + /// Returns [`AuthError::NotAuthenticated`] if the token file is missing, + /// or if the stored token is missing `region` or `client_id`. + pub fn using_store(store: TokenStore) -> Result { + Self::from_store(store).build() + } + + /// Return a builder for configuring an `OAuthStrategy` from a token store. + /// + /// The token is loaded from the store immediately. The builder allows + /// further configuration (e.g. overriding the base URL) before building. + pub fn from_store(store: TokenStore) -> OAuthStrategyBuilder { + OAuthStrategyBuilder { + source: OAuthTokenSource::Store(store), + base_url_override: None, + } } } @@ -40,3 +90,93 @@ impl AuthStrategy for &OAuthStrategy { Ok(self.inner.get_token().await?) } } + +/// Where the initial OAuth token comes from. +enum OAuthTokenSource { + /// A token provided directly (in-memory only, no store). + Token { + region: Region, + client_id: String, + token: Token, + }, + /// A token loaded from a persistent store. + Store(TokenStore), +} + +/// Builder for [`OAuthStrategy`]. +/// +/// Created via [`OAuthStrategy::builder`] or [`OAuthStrategy::from_store`]. +pub struct OAuthStrategyBuilder { + source: OAuthTokenSource, + base_url_override: Option, +} + +impl OAuthStrategyBuilder { + /// Override the base URL resolved by service discovery. + /// + /// Useful for pointing at a local or mock auth server during testing. + #[cfg(any(test, feature = "test-utils"))] + pub fn base_url(mut self, url: url::Url) -> Self { + self.base_url_override = Some(url); + self + } + + /// Build the [`OAuthStrategy`]. + /// + /// Resolves the base URL via service discovery unless overridden with + /// [`base_url`](Self::base_url). + pub fn build(self) -> Result { + match self.source { + OAuthTokenSource::Token { + region, + client_id, + mut token, + } => { + let base_url = match self.base_url_override { + Some(url) => url, + None => CtsServiceDiscovery::endpoint(region)?, + }; + let region_id = region.identifier(); + token.set_region(®ion_id); + token.set_client_id(&client_id); + let refresher = OAuthRefresher::new( + None, + ensure_trailing_slash(base_url), + &client_id, + ®ion_id, + ); + Ok(OAuthStrategy { + inner: AutoRefresh::with_token(refresher, token), + }) + } + OAuthTokenSource::Store(store) => { + let token = store.load()?.ok_or(AuthError::NotAuthenticated)?; + + let region_str = token + .region() + .ok_or(AuthError::NotAuthenticated)? + .to_string(); + let client_id = token + .client_id() + .ok_or(AuthError::NotAuthenticated)? + .to_string(); + + let region = Region::new(®ion_str)?; + let base_url = match self.base_url_override { + Some(url) => url, + None => CtsServiceDiscovery::endpoint(region)?, + }; + + let refresher = OAuthRefresher::new( + Some(store), + ensure_trailing_slash(base_url), + &client_id, + ®ion_str, + ); + Ok(OAuthStrategy { + inner: AutoRefresh::with_token(refresher, token), + }) + } + } + } +} diff --git a/packages/stack-auth/src/token.rs b/packages/stack-auth/src/token.rs index 60f158241..30669e16a 100644 --- a/packages/stack-auth/src/token.rs +++ b/packages/stack-auth/src/token.rs @@ -22,6 +22,10 @@ pub struct Token { pub(crate) refresh_token: Option, pub(crate) token_type: String, pub(crate) expires_at: u64, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) region: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) client_id: Option, } impl Token { @@ -90,6 +94,26 @@ impl Token { self.refresh_token.take() } + /// Returns the stored region identifier, if any. + pub fn region(&self) -> Option<&str> { + self.region.as_deref() + } + + /// Returns the stored client ID, if any. + pub fn client_id(&self) -> Option<&str> { + self.client_id.as_deref() + } + + /// Set the region identifier on this token. + pub(crate) fn set_region(&mut self, region: impl Into) { + self.region = Some(region.into()); + } + + /// Set the client ID on this token. + pub(crate) fn set_client_id(&mut self, client_id: impl Into) { + self.client_id = Some(client_id.into()); + } + /// Exchange a refresh token for a new [`Token`] via the `/oauth/token` /// endpoint. /// @@ -145,6 +169,8 @@ impl Token { token_type: token_resp.token_type, expires_at: now + token_resp.expires_in, refresh_token: token_resp.refresh_token, + region: None, + client_id: None, }) } } @@ -193,6 +219,8 @@ mod tests { } else { None }, + region: None, + client_id: None, } } diff --git a/packages/stack-auth/src/token_store.rs b/packages/stack-auth/src/token_store.rs index 606b688cd..158b65847 100644 --- a/packages/stack-auth/src/token_store.rs +++ b/packages/stack-auth/src/token_store.rs @@ -112,6 +112,8 @@ mod tests { } else { None }, + region: None, + client_id: None, } } From 3d31e7306ecf0199a368deba555d56412c21c8ea Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Mon, 23 Feb 2026 19:17:22 +1100 Subject: [PATCH 035/686] =?UTF-8?q?=E2=9C=A8=20feat(stack-auth):=20add=20A?= =?UTF-8?q?utoStrategy=20for=20automatic=20credential=20detection?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AutoStrategy detects available credentials from the environment and instantiates the appropriate auth strategy: AccessKeyStrategy when CS_CLIENT_ACCESS_KEY is set, OAuthStrategy when ~/.cipherstash/auth.json exists, or returns NotAuthenticated if neither is available. --- packages/stack-auth/src/auto_strategy.rs | 60 ++++++++++++++++++++++++ packages/stack-auth/src/lib.rs | 5 ++ 2 files changed, 65 insertions(+) create mode 100644 packages/stack-auth/src/auto_strategy.rs diff --git a/packages/stack-auth/src/auto_strategy.rs b/packages/stack-auth/src/auto_strategy.rs new file mode 100644 index 000000000..b10dc4c57 --- /dev/null +++ b/packages/stack-auth/src/auto_strategy.rs @@ -0,0 +1,60 @@ +use cts_common::Crn; + +use crate::access_key_strategy::AccessKeyStrategy; +use crate::oauth_strategy::OAuthStrategy; +use crate::token_store::TokenStore; +use crate::{AuthError, AuthStrategy, SecretToken}; + +/// An [`AuthStrategy`] that automatically detects available credentials +/// and delegates to the appropriate inner strategy. +/// +/// # Detection order +/// +/// 1. If the `CS_CLIENT_ACCESS_KEY` environment variable is set, an +/// [`AccessKeyStrategy`] is created. The region is extracted from the +/// `CS_WORKSPACE_CRN` environment variable. +/// 2. If a token store file exists at the default location +/// (`~/.cipherstash/auth.json`), an [`OAuthStrategy`] is created from it. +/// 3. Otherwise, [`AuthError::NotAuthenticated`] is returned. +pub enum AutoStrategy { + /// Authenticated via a static access key. + AccessKey(AccessKeyStrategy), + /// Authenticated via OAuth tokens persisted on disk. + OAuth(OAuthStrategy), +} + +impl AutoStrategy { + /// Detect available credentials and build the appropriate strategy. + /// + /// See the [type-level docs](AutoStrategy) for the detection order. + pub fn new() -> Result { + // 1. Access key from environment + if let Ok(access_key) = std::env::var("CS_CLIENT_ACCESS_KEY") { + let crn_str = + std::env::var("CS_WORKSPACE_CRN").map_err(|_| AuthError::NotAuthenticated)?; + let crn: Crn = crn_str.parse().map_err(AuthError::InvalidCrn)?; + let strategy = AccessKeyStrategy::new(crn.region, SecretToken::new(access_key))?; + return Ok(Self::AccessKey(strategy)); + } + + // 2. OAuth token from disk + if let Ok(store) = TokenStore::new_default() { + if store.path().exists() { + let strategy = OAuthStrategy::using_store(store)?; + return Ok(Self::OAuth(strategy)); + } + } + + // 3. No credentials found + Err(AuthError::NotAuthenticated) + } +} + +impl AuthStrategy for &AutoStrategy { + async fn get_token(self) -> Result { + match self { + AutoStrategy::AccessKey(inner) => inner.get_token().await, + AutoStrategy::OAuth(inner) => inner.get_token().await, + } + } +} diff --git a/packages/stack-auth/src/lib.rs b/packages/stack-auth/src/lib.rs index d900bacac..3f836ca46 100644 --- a/packages/stack-auth/src/lib.rs +++ b/packages/stack-auth/src/lib.rs @@ -74,6 +74,7 @@ use zeroize::ZeroizeOnDrop; mod access_key_refresher; mod access_key_strategy; mod auto_refresh; +mod auto_strategy; mod device_code; mod oauth_refresher; mod oauth_strategy; @@ -82,6 +83,7 @@ mod token; mod token_store; pub use access_key_strategy::{AccessKeyStrategy, AccessKeyStrategyBuilder}; +pub use auto_strategy::AutoStrategy; pub use device_code::{DeviceCodeStrategy, DeviceCodeStrategyBuilder, PendingDeviceCode}; pub use oauth_strategy::{OAuthStrategy, OAuthStrategyBuilder}; pub use token::Token; @@ -152,6 +154,9 @@ pub enum AuthError { /// The requested region is not supported. #[error("Unsupported region: {0}")] Region(#[from] cts_common::RegionError), + /// The workspace CRN could not be parsed. + #[error("Invalid workspace CRN: {0}")] + InvalidCrn(cts_common::InvalidCrn), /// No credentials are available (e.g. not logged in, no access key configured). #[error("Not authenticated")] NotAuthenticated, From 47b67861ad26070778f8f50dc44c644fccfdc575 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Mon, 23 Feb 2026 19:34:24 +1100 Subject: [PATCH 036/686] =?UTF-8?q?=E2=9C=85=20test(stack-auth):=20add=20u?= =?UTF-8?q?nit=20tests=20for=20AutoStrategy=20credential=20detection?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extract detection logic into a private `detect()` method that accepts pre-resolved inputs for testability. Covers access key, OAuth, priority, and error cases. --- packages/stack-auth/src/auto_strategy.rs | 120 ++++++++++++++++++++++- 1 file changed, 116 insertions(+), 4 deletions(-) diff --git a/packages/stack-auth/src/auto_strategy.rs b/packages/stack-auth/src/auto_strategy.rs index b10dc4c57..f6568b61d 100644 --- a/packages/stack-auth/src/auto_strategy.rs +++ b/packages/stack-auth/src/auto_strategy.rs @@ -28,17 +28,31 @@ impl AutoStrategy { /// /// See the [type-level docs](AutoStrategy) for the detection order. pub fn new() -> Result { + let access_key = std::env::var("CS_CLIENT_ACCESS_KEY").ok(); + let crn = std::env::var("CS_WORKSPACE_CRN").ok(); + let store = TokenStore::new_default().ok(); + Self::detect(access_key, crn, store) + } + + /// Core detection logic, separated for testability. + /// + /// Takes pre-resolved inputs rather than reading from the environment + /// or filesystem directly. + fn detect( + access_key: Option, + crn: Option, + store: Option, + ) -> Result { // 1. Access key from environment - if let Ok(access_key) = std::env::var("CS_CLIENT_ACCESS_KEY") { - let crn_str = - std::env::var("CS_WORKSPACE_CRN").map_err(|_| AuthError::NotAuthenticated)?; + if let Some(access_key) = access_key { + let crn_str = crn.ok_or(AuthError::NotAuthenticated)?; let crn: Crn = crn_str.parse().map_err(AuthError::InvalidCrn)?; let strategy = AccessKeyStrategy::new(crn.region, SecretToken::new(access_key))?; return Ok(Self::AccessKey(strategy)); } // 2. OAuth token from disk - if let Ok(store) = TokenStore::new_default() { + if let Some(store) = store { if store.path().exists() { let strategy = OAuthStrategy::using_store(store)?; return Ok(Self::OAuth(strategy)); @@ -58,3 +72,101 @@ impl AuthStrategy for &AutoStrategy { } } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::Token; + use std::time::{SystemTime, UNIX_EPOCH}; + + const VALID_CRN: &str = "crn:ap-southeast-2.aws:ZVATKW3VHMFG27DY"; + + fn make_oauth_token() -> Token { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs(); + + Token { + access_token: SecretToken::new("test-access-token"), + token_type: "Bearer".to_string(), + expires_at: now + 3600, + refresh_token: Some(SecretToken::new("test-refresh-token")), + region: Some("ap-southeast-2.aws".to_string()), + client_id: Some("test-client-id".to_string()), + } + } + + fn write_token_store(dir: &std::path::Path) -> TokenStore { + let store = TokenStore::new(dir.join("auth.json")); + store.save(&make_oauth_token()).unwrap(); + store + } + + #[test] + fn access_key_with_valid_crn() { + let result = + AutoStrategy::detect(Some("my-access-key".into()), Some(VALID_CRN.into()), None); + + assert!(result.is_ok()); + assert!(matches!(result.unwrap(), AutoStrategy::AccessKey(_))); + } + + #[test] + fn access_key_without_crn_returns_not_authenticated() { + let result = AutoStrategy::detect(Some("my-access-key".into()), None, None); + + assert!(matches!(result, Err(AuthError::NotAuthenticated))); + } + + #[test] + fn access_key_with_invalid_crn_returns_invalid_crn() { + let result = + AutoStrategy::detect(Some("my-access-key".into()), Some("not-a-crn".into()), None); + + assert!(matches!(result, Err(AuthError::InvalidCrn(_)))); + } + + #[test] + fn oauth_store_with_valid_token() { + let dir = tempfile::tempdir().unwrap(); + let store = write_token_store(dir.path()); + + let result = AutoStrategy::detect(None, None, Some(store)); + + assert!(result.is_ok()); + assert!(matches!(result.unwrap(), AutoStrategy::OAuth(_))); + } + + #[test] + fn oauth_store_without_token_file_returns_not_authenticated() { + let dir = tempfile::tempdir().unwrap(); + let store = TokenStore::new(dir.path().join("nonexistent.json")); + + let result = AutoStrategy::detect(None, None, Some(store)); + + assert!(matches!(result, Err(AuthError::NotAuthenticated))); + } + + #[test] + fn no_credentials_returns_not_authenticated() { + let result = AutoStrategy::detect(None, None, None); + + assert!(matches!(result, Err(AuthError::NotAuthenticated))); + } + + #[test] + fn access_key_takes_priority_over_oauth_store() { + let dir = tempfile::tempdir().unwrap(); + let store = write_token_store(dir.path()); + + let result = AutoStrategy::detect( + Some("my-access-key".into()), + Some(VALID_CRN.into()), + Some(store), + ); + + assert!(result.is_ok()); + assert!(matches!(result.unwrap(), AutoStrategy::AccessKey(_))); + } +} From 0de1d2c6831b70f935c9d628270972bc1c2762e7 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Mon, 23 Feb 2026 20:06:00 +1100 Subject: [PATCH 037/686] =?UTF-8?q?=F0=9F=93=9D=20docs(stack-auth):=20fix?= =?UTF-8?q?=20broken=20intra-doc=20links=20for=20cfg-gated=20base=5Furl=20?= =?UTF-8?q?methods?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `base_url` methods are behind `#[cfg(any(test, feature = "test-utils"))]`, so `Self::base_url` links break in normal doc builds. Use plain code formatting with a note about the feature gate instead. --- packages/stack-auth/src/access_key_strategy.rs | 2 +- packages/stack-auth/src/device_code/mod.rs | 2 +- packages/stack-auth/src/oauth_strategy.rs | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/stack-auth/src/access_key_strategy.rs b/packages/stack-auth/src/access_key_strategy.rs index 939564e5b..4fcdd66aa 100644 --- a/packages/stack-auth/src/access_key_strategy.rs +++ b/packages/stack-auth/src/access_key_strategy.rs @@ -90,7 +90,7 @@ impl AccessKeyStrategyBuilder { /// Build the [`AccessKeyStrategy`]. /// /// Resolves the base URL via service discovery unless overridden with - /// [`base_url`](Self::base_url). + /// `base_url` (available when the `test-utils` feature is enabled). pub fn build(self) -> Result { let base_url = match self.base_url_override { Some(url) => url, diff --git a/packages/stack-auth/src/device_code/mod.rs b/packages/stack-auth/src/device_code/mod.rs index efc383a56..647282464 100644 --- a/packages/stack-auth/src/device_code/mod.rs +++ b/packages/stack-auth/src/device_code/mod.rs @@ -144,7 +144,7 @@ impl DeviceCodeStrategyBuilder { /// Build the [`DeviceCodeStrategy`]. /// /// Resolves the base URL via service discovery unless overridden with - /// [`base_url`](Self::base_url). + /// `base_url` (available when the `test-utils` feature is enabled). pub fn build(self) -> Result { let base_url = match self.base_url_override { Some(url) => url, diff --git a/packages/stack-auth/src/oauth_strategy.rs b/packages/stack-auth/src/oauth_strategy.rs index f0bbe8ef3..56132c4f9 100644 --- a/packages/stack-auth/src/oauth_strategy.rs +++ b/packages/stack-auth/src/oauth_strategy.rs @@ -124,7 +124,7 @@ impl OAuthStrategyBuilder { /// Build the [`OAuthStrategy`]. /// /// Resolves the base URL via service discovery unless overridden with - /// [`base_url`](Self::base_url). + /// `base_url` (available when the `test-utils` feature is enabled). pub fn build(self) -> Result { match self.source { OAuthTokenSource::Token { From 0ddc997a54b57781134e441f5ac90467d37b93e4 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Mon, 23 Feb 2026 20:06:10 +1100 Subject: [PATCH 038/686] =?UTF-8?q?=F0=9F=93=9D=20docs(stack-auth):=20add?= =?UTF-8?q?=20doc-test=20and=20example=20for=20AutoStrategy?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/stack-auth/Cargo.toml | 3 +++ packages/stack-auth/examples/auto_strategy.rs | 25 +++++++++++++++++++ packages/stack-auth/src/auto_strategy.rs | 13 ++++++++++ 3 files changed, 41 insertions(+) create mode 100644 packages/stack-auth/examples/auto_strategy.rs diff --git a/packages/stack-auth/Cargo.toml b/packages/stack-auth/Cargo.toml index a0278bac8..af8a8d575 100644 --- a/packages/stack-auth/Cargo.toml +++ b/packages/stack-auth/Cargo.toml @@ -24,6 +24,9 @@ zeroize = { workspace = true } [features] test-utils = [] +[[example]] +name = "auto_strategy" + [[example]] name = "device_code" required-features = ["test-utils"] diff --git a/packages/stack-auth/examples/auto_strategy.rs b/packages/stack-auth/examples/auto_strategy.rs new file mode 100644 index 000000000..652f810a8 --- /dev/null +++ b/packages/stack-auth/examples/auto_strategy.rs @@ -0,0 +1,25 @@ +use stack_auth::{AuthStrategy, AutoStrategy}; + +#[tokio::main] +async fn main() -> Result<(), Box> { + tracing_subscriber::fmt::init(); + + // AutoStrategy detects credentials automatically: + // + // 1. CS_CLIENT_ACCESS_KEY env var → AccessKeyStrategy + // 2. ~/.cipherstash/auth.json file → OAuthStrategy + // 3. Neither → error + let strategy = AutoStrategy::new()?; + + match &strategy { + AutoStrategy::AccessKey(_) => println!("Using access key authentication"), + AutoStrategy::OAuth(_) => println!("Using OAuth authentication"), + } + + // Obtain a token — refresh happens automatically when needed. + let token = (&strategy).get_token().await?; + println!("Token type: Bearer"); + println!("Access token: {:?}", token); + + Ok(()) +} diff --git a/packages/stack-auth/src/auto_strategy.rs b/packages/stack-auth/src/auto_strategy.rs index f6568b61d..fa86748a6 100644 --- a/packages/stack-auth/src/auto_strategy.rs +++ b/packages/stack-auth/src/auto_strategy.rs @@ -16,6 +16,19 @@ use crate::{AuthError, AuthStrategy, SecretToken}; /// 2. If a token store file exists at the default location /// (`~/.cipherstash/auth.json`), an [`OAuthStrategy`] is created from it. /// 3. Otherwise, [`AuthError::NotAuthenticated`] is returned. +/// +/// # Example +/// +/// ```no_run +/// use stack_auth::{AuthStrategy, AutoStrategy}; +/// +/// # async fn run() -> Result<(), Box> { +/// let strategy = AutoStrategy::new()?; +/// let token = (&strategy).get_token().await?; +/// println!("Authenticated! Token expires in {}s", token.as_str().len()); +/// # Ok(()) +/// # } +/// ``` pub enum AutoStrategy { /// Authenticated via a static access key. AccessKey(AccessKeyStrategy), From 3180d880b87d43fc707f0636aa04876155eb9192 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Mon, 23 Feb 2026 20:15:33 +1100 Subject: [PATCH 039/686] =?UTF-8?q?=F0=9F=9A=80=20ci(stack-auth):=20add=20?= =?UTF-8?q?doc=20build=20and=20doc-test=20steps=20to=20CI=20workflow?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/imported-workflows/test-stack-auth.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/imported-workflows/test-stack-auth.yml b/.github/imported-workflows/test-stack-auth.yml index dcfad8acf..1e612d019 100644 --- a/.github/imported-workflows/test-stack-auth.yml +++ b/.github/imported-workflows/test-stack-auth.yml @@ -54,6 +54,12 @@ jobs: - name: test-unit run: mise x --env test -- cargo nextest run -p stack-auth --all-features + - name: test-doc + run: mise run test:doc:stack-auth + + - name: doc + run: mise run doc:stack-auth + - name: test-integration-stack-auth run: mise run test:integration:stack-auth From b411bd9f3f4f9ba2407a757f8503c873cf3827f9 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Mon, 23 Feb 2026 20:26:02 +1100 Subject: [PATCH 040/686] =?UTF-8?q?=F0=9F=A9=B9=20fix(stack-auth):=20fix?= =?UTF-8?q?=20misleading=20message=20in=20AutoStrategy=20doc=20example?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/stack-auth/src/auto_strategy.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/stack-auth/src/auto_strategy.rs b/packages/stack-auth/src/auto_strategy.rs index fa86748a6..38c0a3cb6 100644 --- a/packages/stack-auth/src/auto_strategy.rs +++ b/packages/stack-auth/src/auto_strategy.rs @@ -25,7 +25,7 @@ use crate::{AuthError, AuthStrategy, SecretToken}; /// # async fn run() -> Result<(), Box> { /// let strategy = AutoStrategy::new()?; /// let token = (&strategy).get_token().await?; -/// println!("Authenticated! Token expires in {}s", token.as_str().len()); +/// println!("Authenticated! token={:?}", token); /// # Ok(()) /// # } /// ``` From 3f9dbb9768d908ad159078caa88a92d213adcc37 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Tue, 24 Feb 2026 21:28:21 +1100 Subject: [PATCH 041/686] =?UTF-8?q?=F0=9F=92=A1=20docs(stack-auth):=20add?= =?UTF-8?q?=20descriptive=20header=20comment=20to=20auto=5Fstrategy=20exam?= =?UTF-8?q?ple?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Explain what AutoStrategy does, how credential detection works, and how to run the example with either an access key or OAuth via `stash login`. --- packages/stack-auth/examples/auto_strategy.rs | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/packages/stack-auth/examples/auto_strategy.rs b/packages/stack-auth/examples/auto_strategy.rs index 652f810a8..7942b5127 100644 --- a/packages/stack-auth/examples/auto_strategy.rs +++ b/packages/stack-auth/examples/auto_strategy.rs @@ -1,3 +1,30 @@ +//! Demonstrates automatic credential detection with [`AutoStrategy`]. +//! +//! `AutoStrategy` picks the best available authentication method without +//! requiring the caller to choose one explicitly. It checks for credentials +//! in the following order: +//! +//! 1. **Access key** – if `CS_CLIENT_ACCESS_KEY` and `CS_WORKSPACE_CRN` are +//! set, an [`AccessKeyStrategy`] is used. +//! 2. **OAuth** – if a token store file exists at `~/.cipherstash/auth.json` +//! (written by `stash login`), an [`OAuthStrategy`] is used. +//! 3. If neither is available, an error is returned. +//! +//! # Running the example +//! +//! With an access key: +//! +//! ```sh +//! CS_CLIENT_ACCESS_KEY= CS_WORKSPACE_CRN= cargo run --example auto_strategy +//! ``` +//! +//! Or after authenticating via the CLI: +//! +//! ```sh +//! stash login +//! cargo run --example auto_strategy +//! ``` + use stack_auth::{AuthStrategy, AutoStrategy}; #[tokio::main] From 3d336f18736375ce5971ea4a41fdc1206db5098d Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Thu, 26 Feb 2026 15:45:43 +1100 Subject: [PATCH 042/686] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor(stack-aut?= =?UTF-8?q?h):=20derive=20miette::Diagnostic=20on=20AuthError?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add miette dependency to stack-auth and derive Diagnostic on AuthError directly, removing the need for the AuthErrorWrap newtype in the CLI. --- packages/stack-auth/Cargo.toml | 1 + packages/stack-auth/src/lib.rs | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/stack-auth/Cargo.toml b/packages/stack-auth/Cargo.toml index af8a8d575..8a40de434 100644 --- a/packages/stack-auth/Cargo.toml +++ b/packages/stack-auth/Cargo.toml @@ -10,6 +10,7 @@ homepage.workspace = true aquamarine = "0.6" cts-common = { workspace = true } dirs = "4.0.0" +miette = { workspace = true } open = "5.3.2" reqwest = { workspace = true } serde = { workspace = true } diff --git a/packages/stack-auth/src/lib.rs b/packages/stack-auth/src/lib.rs index 3f836ca46..bb31d8631 100644 --- a/packages/stack-auth/src/lib.rs +++ b/packages/stack-auth/src/lib.rs @@ -130,7 +130,7 @@ impl SecretToken { } /// Errors that can occur during an authentication flow. -#[derive(Debug, thiserror::Error)] +#[derive(Debug, thiserror::Error, miette::Diagnostic)] #[non_exhaustive] pub enum AuthError { /// The HTTP request to the auth server failed (network error, timeout, etc.). From 2b5ed1355d0ec43f075cb63cd9730692452ee06f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 26 Feb 2026 05:17:24 +0000 Subject: [PATCH 043/686] chore(deps-dev): bump rollup in /packages/stack-auth/node Bumps [rollup](https://github.com/rollup/rollup) from 4.57.1 to 4.59.0. - [Release notes](https://github.com/rollup/rollup/releases) - [Changelog](https://github.com/rollup/rollup/blob/master/CHANGELOG.md) - [Commits](https://github.com/rollup/rollup/compare/v4.57.1...v4.59.0) --- updated-dependencies: - dependency-name: rollup dependency-version: 4.59.0 dependency-type: indirect ... Signed-off-by: dependabot[bot] --- .../packages/auth/package-lock.json | 206 +++++++++--------- 1 file changed, 103 insertions(+), 103 deletions(-) diff --git a/languages/typescript/packages/auth/package-lock.json b/languages/typescript/packages/auth/package-lock.json index 8ad4d4316..58a011337 100644 --- a/languages/typescript/packages/auth/package-lock.json +++ b/languages/typescript/packages/auth/package-lock.json @@ -480,9 +480,9 @@ } }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.1.tgz", - "integrity": "sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", + "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==", "cpu": [ "arm" ], @@ -494,9 +494,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.57.1.tgz", - "integrity": "sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz", + "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==", "cpu": [ "arm64" ], @@ -508,9 +508,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.57.1.tgz", - "integrity": "sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz", + "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==", "cpu": [ "arm64" ], @@ -522,9 +522,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.57.1.tgz", - "integrity": "sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz", + "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==", "cpu": [ "x64" ], @@ -536,9 +536,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.57.1.tgz", - "integrity": "sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz", + "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==", "cpu": [ "arm64" ], @@ -550,9 +550,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.57.1.tgz", - "integrity": "sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz", + "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==", "cpu": [ "x64" ], @@ -564,9 +564,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.57.1.tgz", - "integrity": "sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz", + "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==", "cpu": [ "arm" ], @@ -578,9 +578,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.57.1.tgz", - "integrity": "sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz", + "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==", "cpu": [ "arm" ], @@ -592,9 +592,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.57.1.tgz", - "integrity": "sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz", + "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==", "cpu": [ "arm64" ], @@ -606,9 +606,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.57.1.tgz", - "integrity": "sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz", + "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==", "cpu": [ "arm64" ], @@ -620,9 +620,9 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.57.1.tgz", - "integrity": "sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz", + "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==", "cpu": [ "loong64" ], @@ -634,9 +634,9 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.57.1.tgz", - "integrity": "sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz", + "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==", "cpu": [ "loong64" ], @@ -648,9 +648,9 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.57.1.tgz", - "integrity": "sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz", + "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==", "cpu": [ "ppc64" ], @@ -662,9 +662,9 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.57.1.tgz", - "integrity": "sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz", + "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==", "cpu": [ "ppc64" ], @@ -676,9 +676,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.57.1.tgz", - "integrity": "sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz", + "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==", "cpu": [ "riscv64" ], @@ -690,9 +690,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.57.1.tgz", - "integrity": "sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz", + "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==", "cpu": [ "riscv64" ], @@ -704,9 +704,9 @@ ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.57.1.tgz", - "integrity": "sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz", + "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==", "cpu": [ "s390x" ], @@ -718,9 +718,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.57.1.tgz", - "integrity": "sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz", + "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==", "cpu": [ "x64" ], @@ -732,9 +732,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.57.1.tgz", - "integrity": "sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz", + "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==", "cpu": [ "x64" ], @@ -746,9 +746,9 @@ ] }, "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.57.1.tgz", - "integrity": "sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz", + "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==", "cpu": [ "x64" ], @@ -760,9 +760,9 @@ ] }, "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.57.1.tgz", - "integrity": "sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz", + "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==", "cpu": [ "arm64" ], @@ -774,9 +774,9 @@ ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.57.1.tgz", - "integrity": "sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz", + "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==", "cpu": [ "arm64" ], @@ -788,9 +788,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.57.1.tgz", - "integrity": "sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz", + "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==", "cpu": [ "ia32" ], @@ -802,9 +802,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.57.1.tgz", - "integrity": "sha512-VMBH2eOOaKGtIJYleXsi2B8CPVADrh+TyNxJ4mWPnKfLB/DBUmzW+5m1xUrcwWoMfSLagIRpjUFeW5CO5hyciQ==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz", + "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==", "cpu": [ "x64" ], @@ -816,9 +816,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.57.1.tgz", - "integrity": "sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz", + "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==", "cpu": [ "x64" ], @@ -1263,9 +1263,9 @@ } }, "node_modules/rollup": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.57.1.tgz", - "integrity": "sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", + "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", "dev": true, "license": "MIT", "dependencies": { @@ -1279,31 +1279,31 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.57.1", - "@rollup/rollup-android-arm64": "4.57.1", - "@rollup/rollup-darwin-arm64": "4.57.1", - "@rollup/rollup-darwin-x64": "4.57.1", - "@rollup/rollup-freebsd-arm64": "4.57.1", - "@rollup/rollup-freebsd-x64": "4.57.1", - "@rollup/rollup-linux-arm-gnueabihf": "4.57.1", - "@rollup/rollup-linux-arm-musleabihf": "4.57.1", - "@rollup/rollup-linux-arm64-gnu": "4.57.1", - "@rollup/rollup-linux-arm64-musl": "4.57.1", - "@rollup/rollup-linux-loong64-gnu": "4.57.1", - "@rollup/rollup-linux-loong64-musl": "4.57.1", - "@rollup/rollup-linux-ppc64-gnu": "4.57.1", - "@rollup/rollup-linux-ppc64-musl": "4.57.1", - "@rollup/rollup-linux-riscv64-gnu": "4.57.1", - "@rollup/rollup-linux-riscv64-musl": "4.57.1", - "@rollup/rollup-linux-s390x-gnu": "4.57.1", - "@rollup/rollup-linux-x64-gnu": "4.57.1", - "@rollup/rollup-linux-x64-musl": "4.57.1", - "@rollup/rollup-openbsd-x64": "4.57.1", - "@rollup/rollup-openharmony-arm64": "4.57.1", - "@rollup/rollup-win32-arm64-msvc": "4.57.1", - "@rollup/rollup-win32-ia32-msvc": "4.57.1", - "@rollup/rollup-win32-x64-gnu": "4.57.1", - "@rollup/rollup-win32-x64-msvc": "4.57.1", + "@rollup/rollup-android-arm-eabi": "4.59.0", + "@rollup/rollup-android-arm64": "4.59.0", + "@rollup/rollup-darwin-arm64": "4.59.0", + "@rollup/rollup-darwin-x64": "4.59.0", + "@rollup/rollup-freebsd-arm64": "4.59.0", + "@rollup/rollup-freebsd-x64": "4.59.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", + "@rollup/rollup-linux-arm-musleabihf": "4.59.0", + "@rollup/rollup-linux-arm64-gnu": "4.59.0", + "@rollup/rollup-linux-arm64-musl": "4.59.0", + "@rollup/rollup-linux-loong64-gnu": "4.59.0", + "@rollup/rollup-linux-loong64-musl": "4.59.0", + "@rollup/rollup-linux-ppc64-gnu": "4.59.0", + "@rollup/rollup-linux-ppc64-musl": "4.59.0", + "@rollup/rollup-linux-riscv64-gnu": "4.59.0", + "@rollup/rollup-linux-riscv64-musl": "4.59.0", + "@rollup/rollup-linux-s390x-gnu": "4.59.0", + "@rollup/rollup-linux-x64-gnu": "4.59.0", + "@rollup/rollup-linux-x64-musl": "4.59.0", + "@rollup/rollup-openbsd-x64": "4.59.0", + "@rollup/rollup-openharmony-arm64": "4.59.0", + "@rollup/rollup-win32-arm64-msvc": "4.59.0", + "@rollup/rollup-win32-ia32-msvc": "4.59.0", + "@rollup/rollup-win32-x64-gnu": "4.59.0", + "@rollup/rollup-win32-x64-msvc": "4.59.0", "fsevents": "~2.3.2" } }, From b08fd05f88e3bc4d411b9f3cf11a6c24f4d97389 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Thu, 26 Feb 2026 22:49:48 +1100 Subject: [PATCH 044/686] =?UTF-8?q?=E2=9C=A8=20feat(stack-auth):=20add=20J?= =?UTF-8?q?WT=20claim=20decoding=20and=20make=20token=20store=20return=20e?= =?UTF-8?q?rror=20on=20missing=20file?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add `workspace_id()` and `issuer()` to Token by decoding JWT claims without signature verification (safe since we already possess the token) - Change `TokenStore::load()` to return `TokenStoreError::NotFound` instead of `Ok(None)` for missing files, simplifying call sites - Move `aquamarine` to dev-dependencies and add `jsonwebtoken` dependency --- packages/stack-auth/Cargo.toml | 3 +- packages/stack-auth/src/auto_refresh.rs | 2 +- packages/stack-auth/src/oauth_strategy.rs | 2 +- packages/stack-auth/src/token.rs | 43 +++++++++++++++++++++++ packages/stack-auth/src/token_store.rs | 20 +++++------ 5 files changed, 57 insertions(+), 13 deletions(-) diff --git a/packages/stack-auth/Cargo.toml b/packages/stack-auth/Cargo.toml index 8a40de434..565a3097f 100644 --- a/packages/stack-auth/Cargo.toml +++ b/packages/stack-auth/Cargo.toml @@ -7,8 +7,8 @@ repository.workspace = true homepage.workspace = true [dependencies] -aquamarine = "0.6" cts-common = { workspace = true } +jsonwebtoken = { workspace = true } dirs = "4.0.0" miette = { workspace = true } open = "5.3.2" @@ -33,6 +33,7 @@ name = "device_code" required-features = ["test-utils"] [dev-dependencies] +aquamarine = "0.6" axum = "0.8" cts-common = { workspace = true } mocktail = "0.3.0" diff --git a/packages/stack-auth/src/auto_refresh.rs b/packages/stack-auth/src/auto_refresh.rs index b6d8902ae..bf3325bf2 100644 --- a/packages/stack-auth/src/auto_refresh.rs +++ b/packages/stack-auth/src/auto_refresh.rs @@ -426,7 +426,7 @@ mod tests { // Verify the refreshed token was saved to disk. let store = TokenStore::new(dir.path().join("auth.json")); - let on_disk = store.load().unwrap().unwrap(); + let on_disk = store.load().unwrap(); assert_eq!(on_disk.access_token().as_str(), "refreshed-token"); } diff --git a/packages/stack-auth/src/oauth_strategy.rs b/packages/stack-auth/src/oauth_strategy.rs index 56132c4f9..992f479cb 100644 --- a/packages/stack-auth/src/oauth_strategy.rs +++ b/packages/stack-auth/src/oauth_strategy.rs @@ -150,7 +150,7 @@ impl OAuthStrategyBuilder { }) } OAuthTokenSource::Store(store) => { - let token = store.load()?.ok_or(AuthError::NotAuthenticated)?; + let token = store.load()?; let region_str = token .region() diff --git a/packages/stack-auth/src/token.rs b/packages/stack-auth/src/token.rs index 30669e16a..fd524eb56 100644 --- a/packages/stack-auth/src/token.rs +++ b/packages/stack-auth/src/token.rs @@ -1,5 +1,7 @@ use std::time::{SystemTime, UNIX_EPOCH}; +use cts_common::claims::Claims; +use cts_common::WorkspaceId; use url::Url; use crate::{http_client, AuthError, SecretToken}; @@ -114,6 +116,47 @@ impl Token { self.client_id = Some(client_id.into()); } + /// Returns the workspace ID from the JWT claims. + /// + /// The access token is decoded (without signature verification) to extract + /// the `workspace` claim. + pub fn workspace_id(&self) -> Result { + self.decode_claims().map(|c| c.workspace) + } + + /// Returns the issuer URL from the JWT claims. + /// + /// The `iss` claim in CipherStash tokens is the CTS host URL for the + /// workspace, so this can be used directly as the CTS base URL. + pub fn issuer(&self) -> Result { + let claims = self.decode_claims()?; + claims.iss.parse().map_err(AuthError::from) + } + + /// Decode the JWT payload into [`Claims`] without verifying the signature. + /// + /// This is safe because we already possess the token — we just need to read + /// the claims it contains. + fn decode_claims(&self) -> Result { + use jsonwebtoken::{decode, decode_header, DecodingKey, Validation}; + use std::collections::HashSet; + + let token_str = self.access_token.as_str(); + let header = + decode_header(token_str).map_err(|e| AuthError::Server(format!("invalid JWT: {e}")))?; + + let dummy_key = DecodingKey::from_secret(&[]); + let mut validation = Validation::new(header.alg); + validation.validate_exp = false; + validation.validate_aud = false; + validation.required_spec_claims = HashSet::new(); + validation.insecure_disable_signature_validation(); + + decode(token_str, &dummy_key, &validation) + .map(|data| data.claims) + .map_err(|e| AuthError::Server(format!("failed to decode JWT claims: {e}"))) + } + /// Exchange a refresh token for a new [`Token`] via the `/oauth/token` /// endpoint. /// diff --git a/packages/stack-auth/src/token_store.rs b/packages/stack-auth/src/token_store.rs index 158b65847..2b740946e 100644 --- a/packages/stack-auth/src/token_store.rs +++ b/packages/stack-auth/src/token_store.rs @@ -67,14 +67,14 @@ impl TokenStore { /// Load a [`Token`] from disk. /// - /// Returns `None` if the file does not exist. - pub fn load(&self) -> Result, TokenStoreError> { + /// Returns [`TokenStoreError::NotFound`] if the file does not exist. + pub fn load(&self) -> Result { match std::fs::read_to_string(&self.path) { Ok(contents) => { let token: Token = serde_json::from_str(&contents)?; - Ok(Some(token)) + Ok(token) } - Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Err(TokenStoreError::NotFound), Err(e) => Err(TokenStoreError::Io(e)), } } @@ -125,7 +125,7 @@ mod tests { let token = make_token(3600, false); store.save(&token).unwrap(); - let loaded = store.load().unwrap().unwrap(); + let loaded = store.load().unwrap(); assert_eq!(loaded.access_token().as_str(), "test-access-token"); assert_eq!(loaded.token_type(), "Bearer"); assert!(loaded.refresh_token().is_none()); @@ -167,12 +167,12 @@ mod tests { } #[test] - fn load_returns_none_for_missing_file() { + fn load_returns_not_found_for_missing_file() { let dir = tempfile::tempdir().unwrap(); let store = TokenStore::new(dir.path().join("nonexistent.json")); - let result = store.load().unwrap(); - assert!(result.is_none()); + let err = store.load().unwrap_err(); + assert!(matches!(err, TokenStoreError::NotFound)); } #[test] @@ -203,7 +203,7 @@ mod tests { let token = make_token(3600, false); store.save(&token).unwrap(); - let loaded = store.load().unwrap().unwrap(); + let loaded = store.load().unwrap(); assert_eq!(loaded.access_token().as_str(), "test-access-token"); } @@ -215,7 +215,7 @@ mod tests { let token = make_token(3600, true); store.save(&token).unwrap(); - let loaded = store.load().unwrap().unwrap(); + let loaded = store.load().unwrap(); assert_eq!( loaded.refresh_token().unwrap().as_str(), "test-refresh-token" From 73a9eb15df7268281bea5a1520a31cb4a9594a77 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Thu, 26 Feb 2026 23:12:31 +1100 Subject: [PATCH 045/686] =?UTF-8?q?=F0=9F=90=9B=20fix(stack-auth):=20use?= =?UTF-8?q?=20tmpdir=20for=20token=20persistence=20in=20device=20code=20te?= =?UTF-8?q?sts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit poll_for_token() was saving tokens to ~/.cipherstash/auth.json via TokenStore::new_default(), clobbering real credentials during test runs. Add token_store_path to DeviceCodeStrategy/PendingDeviceCode so tests can redirect writes to a temporary directory. --- packages/stack-auth/src/device_code/mod.rs | 25 +++++++++- packages/stack-auth/src/device_code/tests.rs | 51 ++++++++++++++------ 2 files changed, 59 insertions(+), 17 deletions(-) diff --git a/packages/stack-auth/src/device_code/mod.rs b/packages/stack-auth/src/device_code/mod.rs index 647282464..f37710d7b 100644 --- a/packages/stack-auth/src/device_code/mod.rs +++ b/packages/stack-auth/src/device_code/mod.rs @@ -5,6 +5,8 @@ use url::Url; use std::time::{SystemTime, UNIX_EPOCH}; +use std::path::PathBuf; + use crate::{ensure_trailing_slash, http_client, token_store::TokenStore, AuthError, Token}; use protocol::{ DeviceCode, DeviceCodeRequest, DeviceCodeResponse, ErrorResponse, TokenRequest, TokenResponse, @@ -33,6 +35,7 @@ pub struct DeviceCodeStrategy { region: Region, base_url: Url, client_id: String, + token_store_path: Option, } impl DeviceCodeStrategy { @@ -61,6 +64,7 @@ impl DeviceCodeStrategy { region, client_id: client_id.into(), base_url_override: None, + token_store_path: None, } } @@ -118,6 +122,7 @@ impl DeviceCodeStrategy { verification_uri: code.verification_uri, verification_uri_complete: code.verification_uri_complete, expires_in: code.expires_in, + token_store_path: self.token_store_path.clone(), }) } } @@ -129,6 +134,7 @@ pub struct DeviceCodeStrategyBuilder { region: Region, client_id: String, base_url_override: Option, + token_store_path: Option, } impl DeviceCodeStrategyBuilder { @@ -141,6 +147,16 @@ impl DeviceCodeStrategyBuilder { self } + /// Override where the token is persisted after a successful flow. + /// + /// By default tokens are saved to `~/.cipherstash/auth.json`. Use this in + /// tests to redirect writes to a temporary directory. + #[cfg(any(test, feature = "test-utils"))] + pub fn token_store_path(mut self, path: impl Into) -> Self { + self.token_store_path = Some(path.into()); + self + } + /// Build the [`DeviceCodeStrategy`]. /// /// Resolves the base URL via service discovery unless overridden with @@ -154,6 +170,7 @@ impl DeviceCodeStrategyBuilder { region: self.region, base_url: ensure_trailing_slash(base_url), client_id: self.client_id, + token_store_path: self.token_store_path, }) } } @@ -196,6 +213,8 @@ pub struct PendingDeviceCode { verification_uri_complete: String, /// How many seconds the device code remains valid. expires_in: u64, + /// Where to persist the token on success. Falls back to `~/.cipherstash/auth.json`. + token_store_path: Option, } impl PendingDeviceCode { @@ -284,7 +303,11 @@ impl PendingDeviceCode { token.set_region(self.region.identifier()); token.set_client_id(&self.client_id); - match TokenStore::new_default().and_then(|store| store.save(&token)) { + let store = match &self.token_store_path { + Some(path) => Ok(TokenStore::new(path)), + None => TokenStore::new_default(), + }; + match store.and_then(|s| s.save(&token)) { Ok(()) => tracing::debug!("token saved to disk"), Err(err) => tracing::warn!(%err, "failed to save token to disk"), } diff --git a/packages/stack-auth/src/device_code/tests.rs b/packages/stack-auth/src/device_code/tests.rs index dbd2c0095..ed69f8817 100644 --- a/packages/stack-auth/src/device_code/tests.rs +++ b/packages/stack-auth/src/device_code/tests.rs @@ -1,6 +1,7 @@ use super::*; use cts_common::Region; use mocktail::prelude::*; +use tempfile::TempDir; fn device_code_json() -> serde_json::Value { serde_json::json!({ @@ -40,9 +41,10 @@ async fn start_server(mocks: MockSet) -> MockServer { server } -fn strategy_for(server: &MockServer) -> DeviceCodeStrategy { +fn strategy_for(server: &MockServer, dir: &TempDir) -> DeviceCodeStrategy { DeviceCodeStrategy::builder(Region::aws("ap-southeast-2").unwrap(), "cli") .base_url(server.url("")) + .token_store_path(dir.path().join("auth.json")) .build() .unwrap() } @@ -51,11 +53,12 @@ fn strategy_for(server: &MockServer) -> DeviceCodeStrategy { #[tokio::test] async fn test_begin_returns_pending_device_code() { + let dir = TempDir::new().unwrap(); let mut mocks = MockSet::new(); mock_code_endpoint(&mut mocks); let server = start_server(mocks).await; - let pending = strategy_for(&server).begin().await.unwrap(); + let pending = strategy_for(&server, &dir).begin().await.unwrap(); assert_eq!(pending.user_code(), "ABCD-EFGH"); assert_eq!(pending.verification_uri(), "http://example.com/activate"); @@ -68,6 +71,7 @@ async fn test_begin_returns_pending_device_code() { #[tokio::test] async fn test_begin_invalid_client() { + let dir = TempDir::new().unwrap(); let mut mocks = MockSet::new(); mocks.mock(|when, then| { when.post().path("/oauth/device/code"); @@ -75,13 +79,14 @@ async fn test_begin_invalid_client() { }); let server = start_server(mocks).await; - let err = strategy_for(&server).begin().await.unwrap_err(); + let err = strategy_for(&server, &dir).begin().await.unwrap_err(); assert!(matches!(err, AuthError::InvalidClient)); } #[tokio::test] async fn test_begin_server_error() { + let dir = TempDir::new().unwrap(); let mut mocks = MockSet::new(); mocks.mock(|when, then| { when.post().path("/oauth/device/code"); @@ -89,7 +94,7 @@ async fn test_begin_server_error() { }); let server = start_server(mocks).await; - let err = strategy_for(&server).begin().await.unwrap_err(); + let err = strategy_for(&server, &dir).begin().await.unwrap_err(); assert!(matches!(&err, AuthError::Server(desc) if desc == "server_error occurred")); } @@ -98,12 +103,13 @@ async fn test_begin_server_error() { /// Helper: calls begin() against a server that already has the code mock, /// then returns the PendingDeviceCode ready for polling. -async fn begin_pending(server: &MockServer) -> PendingDeviceCode { - strategy_for(server).begin().await.unwrap() +async fn begin_pending(server: &MockServer, dir: &TempDir) -> PendingDeviceCode { + strategy_for(server, dir).begin().await.unwrap() } #[tokio::test(start_paused = true)] async fn test_poll_for_token_success() { + let dir = TempDir::new().unwrap(); let mut mocks = MockSet::new(); mock_code_endpoint(&mut mocks); mocks.mock(|when, then| { @@ -112,7 +118,11 @@ async fn test_poll_for_token_success() { }); let server = start_server(mocks).await; - let token = begin_pending(&server).await.poll_for_token().await.unwrap(); + let token = begin_pending(&server, &dir) + .await + .poll_for_token() + .await + .unwrap(); assert_eq!(token.access_token().0, "test_access_token_value"); assert_eq!(token.token_type(), "Bearer"); @@ -122,6 +132,7 @@ async fn test_poll_for_token_success() { #[tokio::test(start_paused = true)] async fn test_poll_for_token_access_denied() { + let dir = TempDir::new().unwrap(); let mut mocks = MockSet::new(); mock_code_endpoint(&mut mocks); mocks.mock(|when, then| { @@ -130,7 +141,7 @@ async fn test_poll_for_token_access_denied() { }); let server = start_server(mocks).await; - let err = begin_pending(&server) + let err = begin_pending(&server, &dir) .await .poll_for_token() .await @@ -141,6 +152,7 @@ async fn test_poll_for_token_access_denied() { #[tokio::test(start_paused = true)] async fn test_poll_for_token_expired_token() { + let dir = TempDir::new().unwrap(); let mut mocks = MockSet::new(); mock_code_endpoint(&mut mocks); mocks.mock(|when, then| { @@ -149,7 +161,7 @@ async fn test_poll_for_token_expired_token() { }); let server = start_server(mocks).await; - let err = begin_pending(&server) + let err = begin_pending(&server, &dir) .await .poll_for_token() .await @@ -160,6 +172,7 @@ async fn test_poll_for_token_expired_token() { #[tokio::test(start_paused = true)] async fn test_poll_for_token_invalid_grant() { + let dir = TempDir::new().unwrap(); let mut mocks = MockSet::new(); mock_code_endpoint(&mut mocks); mocks.mock(|when, then| { @@ -168,7 +181,7 @@ async fn test_poll_for_token_invalid_grant() { }); let server = start_server(mocks).await; - let err = begin_pending(&server) + let err = begin_pending(&server, &dir) .await .poll_for_token() .await @@ -179,6 +192,7 @@ async fn test_poll_for_token_invalid_grant() { #[tokio::test(start_paused = true)] async fn test_poll_for_token_invalid_client() { + let dir = TempDir::new().unwrap(); let mut mocks = MockSet::new(); mock_code_endpoint(&mut mocks); mocks.mock(|when, then| { @@ -187,7 +201,7 @@ async fn test_poll_for_token_invalid_client() { }); let server = start_server(mocks).await; - let err = begin_pending(&server) + let err = begin_pending(&server, &dir) .await .poll_for_token() .await @@ -198,6 +212,7 @@ async fn test_poll_for_token_invalid_client() { #[tokio::test(start_paused = true)] async fn test_poll_for_token_unknown_error() { + let dir = TempDir::new().unwrap(); let mut mocks = MockSet::new(); mock_code_endpoint(&mut mocks); mocks.mock(|when, then| { @@ -206,7 +221,7 @@ async fn test_poll_for_token_unknown_error() { }); let server = start_server(mocks).await; - let err = begin_pending(&server) + let err = begin_pending(&server, &dir) .await .poll_for_token() .await @@ -217,6 +232,7 @@ async fn test_poll_for_token_unknown_error() { #[tokio::test(start_paused = true)] async fn test_poll_for_token_authorization_pending_then_success() { + let dir = TempDir::new().unwrap(); let mut mocks = MockSet::new(); mock_code_endpoint(&mut mocks); mocks.mock(|when, then| { @@ -224,7 +240,7 @@ async fn test_poll_for_token_authorization_pending_then_success() { then.bad_request().json(error_json("authorization_pending")); }); let server = start_server(mocks).await; - let pending = begin_pending(&server).await; + let pending = begin_pending(&server, &dir).await; // Use tokio::join! so the swap future can borrow server.mocks() directly // (the shared RwLock) rather than cloning the MockSet. @@ -245,6 +261,7 @@ async fn test_poll_for_token_authorization_pending_then_success() { #[tokio::test(start_paused = true)] async fn test_poll_for_token_slow_down_then_success() { + let dir = TempDir::new().unwrap(); let mut mocks = MockSet::new(); mock_code_endpoint(&mut mocks); mocks.mock(|when, then| { @@ -252,7 +269,7 @@ async fn test_poll_for_token_slow_down_then_success() { then.bad_request().json(error_json("slow_down")); }); let server = start_server(mocks).await; - let pending = begin_pending(&server).await; + let pending = begin_pending(&server, &dir).await; // First poll returns "slow_down", interval increases to 10s. // Swap the mock to return success before the second poll. @@ -274,6 +291,7 @@ async fn test_poll_for_token_slow_down_then_success() { /// deadline, causing an `ExpiredToken` error. #[tokio::test(start_paused = true)] async fn test_poll_for_token_slow_down_increases_interval() { + let dir = TempDir::new().unwrap(); let mut mocks = MockSet::new(); // expires_in = 12: without slow_down, second poll at T=10 is within // the deadline. With slow_down, interval becomes 10s, so second poll @@ -293,7 +311,7 @@ async fn test_poll_for_token_slow_down_increases_interval() { then.bad_request().json(error_json("slow_down")); }); let server = start_server(mocks).await; - let pending = begin_pending(&server).await; + let pending = begin_pending(&server, &dir).await; let err = pending.poll_for_token().await.unwrap_err(); @@ -342,11 +360,12 @@ fn test_relative_join_on_root_url() { #[tokio::test] async fn test_pending_device_code_debug_does_not_leak() { + let dir = TempDir::new().unwrap(); let mut mocks = MockSet::new(); mock_code_endpoint(&mut mocks); let server = start_server(mocks).await; - let pending = begin_pending(&server).await; + let pending = begin_pending(&server, &dir).await; let debug = format!("{:?}", pending); assert!( From 320c236016af21f2808633db73f42100b5191e2e Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Thu, 26 Feb 2026 23:21:08 +1100 Subject: [PATCH 046/686] =?UTF-8?q?=F0=9F=90=9B=20fix(stack-auth):=20move?= =?UTF-8?q?=20aquamarine=20back=20to=20regular=20dependencies?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The #[cfg_attr(doc, aquamarine::aquamarine)] attribute requires the crate at compile time, not just during tests. --- packages/stack-auth/Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/stack-auth/Cargo.toml b/packages/stack-auth/Cargo.toml index 565a3097f..aa069d767 100644 --- a/packages/stack-auth/Cargo.toml +++ b/packages/stack-auth/Cargo.toml @@ -7,6 +7,7 @@ repository.workspace = true homepage.workspace = true [dependencies] +aquamarine = "0.6" cts-common = { workspace = true } jsonwebtoken = { workspace = true } dirs = "4.0.0" @@ -33,7 +34,6 @@ name = "device_code" required-features = ["test-utils"] [dev-dependencies] -aquamarine = "0.6" axum = "0.8" cts-common = { workspace = true } mocktail = "0.3.0" From 7c4827be19cf201cd54545249d9a4f73a6bccac4 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Fri, 27 Feb 2026 11:51:46 +1100 Subject: [PATCH 047/686] =?UTF-8?q?=F0=9F=90=9B=20fix(stack-auth):=20use?= =?UTF-8?q?=20JWT=20issuer=20for=20token=20refresh=20instead=20of=20servic?= =?UTF-8?q?e=20discovery?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When refreshing from a stored token, the base URL was resolved via CtsServiceDiscovery::endpoint(region) which could return a different host than the one that issued the token. Use token.issuer() (the iss claim from the JWT) so refresh requests go to the correct CTS host. --- packages/stack-auth/src/oauth_strategy.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/stack-auth/src/oauth_strategy.rs b/packages/stack-auth/src/oauth_strategy.rs index 992f479cb..62c597845 100644 --- a/packages/stack-auth/src/oauth_strategy.rs +++ b/packages/stack-auth/src/oauth_strategy.rs @@ -161,10 +161,9 @@ impl OAuthStrategyBuilder { .ok_or(AuthError::NotAuthenticated)? .to_string(); - let region = Region::new(®ion_str)?; let base_url = match self.base_url_override { Some(url) => url, - None => CtsServiceDiscovery::endpoint(region)?, + None => token.issuer()?, }; let refresher = OAuthRefresher::new( From b635345ae77c1ec7343c6dbb47e671341ac918ce Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Fri, 27 Feb 2026 12:22:50 +1100 Subject: [PATCH 048/686] =?UTF-8?q?=F0=9F=90=9B=20fix(stack-auth):=20use?= =?UTF-8?q?=20valid=20JWT=20in=20auto=5Fstrategy=20test?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The test token needs decodable JWT claims now that OAuthStrategy reads the issuer from the JWT. Use jsonwebtoken::encode to produce a real HS256 JWT with valid Claims fields. --- packages/stack-auth/src/auto_strategy.rs | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/packages/stack-auth/src/auto_strategy.rs b/packages/stack-auth/src/auto_strategy.rs index 38c0a3cb6..6831aef80 100644 --- a/packages/stack-auth/src/auto_strategy.rs +++ b/packages/stack-auth/src/auto_strategy.rs @@ -100,8 +100,26 @@ mod tests { .unwrap() .as_secs(); + let claims = serde_json::json!({ + "iss": "https://cts.example.com/", + "sub": "CS|test-user", + "aud": "test-audience", + "iat": now, + "exp": now + 3600, + "workspace": "ZVATKW3VHMFG27DY", + "scope": "", + }); + + let key = jsonwebtoken::EncodingKey::from_secret(b"test-secret"); + let jwt = jsonwebtoken::encode( + &jsonwebtoken::Header::default(), + &claims, + &key, + ) + .unwrap(); + Token { - access_token: SecretToken::new("test-access-token"), + access_token: SecretToken::new(jwt), token_type: "Bearer".to_string(), expires_at: now + 3600, refresh_token: Some(SecretToken::new("test-refresh-token")), From e3941353839413901a84ba1c20aeb10eb506fb84 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Fri, 27 Feb 2026 12:23:20 +1100 Subject: [PATCH 049/686] chore(stack-auth): cargo fmt --- packages/stack-auth/src/auto_strategy.rs | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/packages/stack-auth/src/auto_strategy.rs b/packages/stack-auth/src/auto_strategy.rs index 6831aef80..9aa34e35a 100644 --- a/packages/stack-auth/src/auto_strategy.rs +++ b/packages/stack-auth/src/auto_strategy.rs @@ -111,12 +111,7 @@ mod tests { }); let key = jsonwebtoken::EncodingKey::from_secret(b"test-secret"); - let jwt = jsonwebtoken::encode( - &jsonwebtoken::Header::default(), - &claims, - &key, - ) - .unwrap(); + let jwt = jsonwebtoken::encode(&jsonwebtoken::Header::default(), &claims, &key).unwrap(); Token { access_token: SecretToken::new(jwt), From 296360e529714dbbb209433b149f3bdebb064dcd Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Mon, 2 Mar 2026 11:06:02 +1100 Subject: [PATCH 050/686] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor(stack-aut?= =?UTF-8?q?h):=20consolidate=20ExpiredToken=20into=20TokenExpired=20and=20?= =?UTF-8?q?add=20InvalidToken=20variant?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove duplicate `ExpiredToken` variant, use `TokenExpired` everywhere - Add `AuthError::InvalidToken(String)` for JWT decode failures instead of overloading `AuthError::Server` - Add unit tests for `Token::workspace_id()` and `Token::issuer()` --- languages/typescript/packages/auth/src/lib.rs | 5 +- packages/stack-auth/src/device_code/mod.rs | 6 +- packages/stack-auth/src/device_code/tests.rs | 6 +- packages/stack-auth/src/lib.rs | 8 +- packages/stack-auth/src/token.rs | 81 ++++++++++++++++++- 5 files changed, 91 insertions(+), 15 deletions(-) diff --git a/languages/typescript/packages/auth/src/lib.rs b/languages/typescript/packages/auth/src/lib.rs index 0993786a7..cb0e21f2f 100644 --- a/languages/typescript/packages/auth/src/lib.rs +++ b/languages/typescript/packages/auth/src/lib.rs @@ -16,11 +16,12 @@ fn error_code(err: &AuthError) -> &'static str { match err { AuthError::Request(_) => "REQUEST_ERROR", AuthError::AccessDenied => "ACCESS_DENIED", - AuthError::ExpiredToken => "EXPIRED_TOKEN", + AuthError::TokenExpired => "EXPIRED_TOKEN", AuthError::InvalidGrant => "INVALID_GRANT", AuthError::InvalidClient => "INVALID_CLIENT", AuthError::InvalidUrl(_) => "INVALID_URL", AuthError::Region(_) => "INVALID_REGION", + AuthError::InvalidToken(_) => "INVALID_TOKEN", AuthError::Server(_) => "SERVER_ERROR", AuthError::Store(_) => "STORE_ERROR", _ => "UNKNOWN_ERROR", @@ -234,7 +235,7 @@ mod tests { #[test] fn test_error_code_mapping() { assert_eq!(error_code(&AuthError::AccessDenied), "ACCESS_DENIED"); - assert_eq!(error_code(&AuthError::ExpiredToken), "EXPIRED_TOKEN"); + assert_eq!(error_code(&AuthError::TokenExpired), "EXPIRED_TOKEN"); assert_eq!(error_code(&AuthError::InvalidGrant), "INVALID_GRANT"); assert_eq!(error_code(&AuthError::InvalidClient), "INVALID_CLIENT"); assert_eq!( diff --git a/packages/stack-auth/src/device_code/mod.rs b/packages/stack-auth/src/device_code/mod.rs index f37710d7b..1469ab1b3 100644 --- a/packages/stack-auth/src/device_code/mod.rs +++ b/packages/stack-auth/src/device_code/mod.rs @@ -254,7 +254,7 @@ impl PendingDeviceCode { /// # Errors /// /// - [`AuthError::AccessDenied`] — the user rejected the request. - /// - [`AuthError::ExpiredToken`] — the device code expired before the user + /// - [`AuthError::TokenExpired`] — the device code expired before the user /// authorized. /// - [`AuthError::Request`] — a network error occurred while polling. pub async fn poll_for_token(self) -> Result { @@ -272,7 +272,7 @@ impl PendingDeviceCode { loop { if tokio::time::Instant::now() >= deadline { tracing::debug!("device code expired while polling"); - return Err(AuthError::ExpiredToken); + return Err(AuthError::TokenExpired); } let resp = client @@ -324,7 +324,7 @@ impl PendingDeviceCode { interval += tokio::time::Duration::from_secs(5); tracing::debug!(interval_secs = interval.as_secs(), "slowing down"); } - "expired_token" => return Err(AuthError::ExpiredToken), + "expired_token" => return Err(AuthError::TokenExpired), "access_denied" => return Err(AuthError::AccessDenied), "invalid_grant" => return Err(AuthError::InvalidGrant), "invalid_client" => return Err(AuthError::InvalidClient), diff --git a/packages/stack-auth/src/device_code/tests.rs b/packages/stack-auth/src/device_code/tests.rs index ed69f8817..11dea3521 100644 --- a/packages/stack-auth/src/device_code/tests.rs +++ b/packages/stack-auth/src/device_code/tests.rs @@ -167,7 +167,7 @@ async fn test_poll_for_token_expired_token() { .await .unwrap_err(); - assert!(matches!(err, AuthError::ExpiredToken)); + assert!(matches!(err, AuthError::TokenExpired)); } #[tokio::test(start_paused = true)] @@ -288,7 +288,7 @@ async fn test_poll_for_token_slow_down_then_success() { /// Proves that `slow_down` increases the poll interval: with a short /// `expires_in`, the increased interval pushes the next poll past the -/// deadline, causing an `ExpiredToken` error. +/// deadline, causing a `TokenExpired` error. #[tokio::test(start_paused = true)] async fn test_poll_for_token_slow_down_increases_interval() { let dir = TempDir::new().unwrap(); @@ -315,7 +315,7 @@ async fn test_poll_for_token_slow_down_increases_interval() { let err = pending.poll_for_token().await.unwrap_err(); - assert!(matches!(err, AuthError::ExpiredToken)); + assert!(matches!(err, AuthError::TokenExpired)); } // ---- ensure_trailing_slash / URL join tests ---- diff --git a/packages/stack-auth/src/lib.rs b/packages/stack-auth/src/lib.rs index bb31d8631..5c75f50cd 100644 --- a/packages/stack-auth/src/lib.rs +++ b/packages/stack-auth/src/lib.rs @@ -139,9 +139,6 @@ pub enum AuthError { /// The user denied the authorization request. #[error("Authorization was denied")] AccessDenied, - /// The device code expired before the user authorized. - #[error("Device code expired")] - ExpiredToken, /// The grant type was rejected by the server. #[error("Invalid grant")] InvalidGrant, @@ -160,9 +157,12 @@ pub enum AuthError { /// No credentials are available (e.g. not logged in, no access key configured). #[error("Not authenticated")] NotAuthenticated, - /// The token has expired and could not be refreshed. + /// A token (access token or device code) has expired. #[error("Token expired")] TokenExpired, + /// The JWT could not be decoded or its claims are malformed. + #[error("Invalid token: {0}")] + InvalidToken(String), /// An unexpected error was returned by the auth server. #[error("Server error: {0}")] Server(String), diff --git a/packages/stack-auth/src/token.rs b/packages/stack-auth/src/token.rs index fd524eb56..fd17df57c 100644 --- a/packages/stack-auth/src/token.rs +++ b/packages/stack-auth/src/token.rs @@ -142,8 +142,8 @@ impl Token { use std::collections::HashSet; let token_str = self.access_token.as_str(); - let header = - decode_header(token_str).map_err(|e| AuthError::Server(format!("invalid JWT: {e}")))?; + let header = decode_header(token_str) + .map_err(|e| AuthError::InvalidToken(format!("invalid JWT header: {e}")))?; let dummy_key = DecodingKey::from_secret(&[]); let mut validation = Validation::new(header.alg); @@ -154,7 +154,7 @@ impl Token { decode(token_str, &dummy_key, &validation) .map(|data| data.claims) - .map_err(|e| AuthError::Server(format!("failed to decode JWT claims: {e}"))) + .map_err(|e| AuthError::InvalidToken(format!("failed to decode JWT claims: {e}"))) } /// Exchange a refresh token for a new [`Token`] via the `/oauth/token` @@ -434,4 +434,79 @@ mod tests { "Debug output should not contain refresh token, got: {debug}" ); } + + // ---- decode_claims / workspace_id / issuer tests ---- + + /// Build a Token whose access_token is a real (unsigned) JWT containing the + /// given claims JSON. + fn make_jwt_token(claims_json: serde_json::Value) -> Token { + use jsonwebtoken::{encode, EncodingKey, Header}; + let jwt = encode( + &Header::default(), + &claims_json, + &EncodingKey::from_secret(b"test-secret"), + ) + .expect("failed to encode JWT"); + + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs(); + + Token { + access_token: SecretToken::new(jwt), + token_type: "Bearer".to_string(), + expires_at: now + 3600, + refresh_token: None, + region: None, + client_id: None, + } + } + + fn valid_claims_json() -> serde_json::Value { + serde_json::json!({ + "workspace": "7366ITCXSAPCH5TN", + "iss": "https://cts.example.com", + "sub": "user-123", + "aud": "https://cts.example.com", + "iat": 1700000000u64, + "exp": 1700003600u64, + "scope": "dataset:create" + }) + } + + #[test] + fn test_workspace_id_extracts_from_jwt() { + let token = make_jwt_token(valid_claims_json()); + let ws = token.workspace_id().expect("should extract workspace ID"); + assert_eq!(ws.to_string(), "7366ITCXSAPCH5TN"); + } + + #[test] + fn test_issuer_extracts_url_from_jwt() { + let token = make_jwt_token(valid_claims_json()); + let issuer = token.issuer().expect("should extract issuer"); + assert_eq!(issuer.as_str(), "https://cts.example.com/"); + } + + #[test] + fn test_workspace_id_fails_on_invalid_jwt() { + let token = Token { + access_token: SecretToken::new("not-a-jwt"), + token_type: "Bearer".to_string(), + expires_at: 0, + refresh_token: None, + region: None, + client_id: None, + }; + let err = token.workspace_id().unwrap_err(); + assert!(matches!(err, AuthError::InvalidToken(_))); + } + + #[test] + fn test_issuer_fails_on_missing_claims() { + let token = make_jwt_token(serde_json::json!({"sub": "user-123"})); + let err = token.issuer().unwrap_err(); + assert!(matches!(err, AuthError::InvalidToken(_))); + } } From 449a33a00b567e06cecb89207751cad2e6df1a37 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Fri, 27 Feb 2026 16:27:20 +1100 Subject: [PATCH 051/686] feat(cli): migrate keysets/ZeroKMS commands to stack-auth MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the legacy ConsoleConfig/UserCredentials/federation auth flow in the CLI's zerokms() method with stack-auth's OAuthStrategy, matching the pattern already used by cts(). - Enable stack-auth feature on cipherstash-client in the CLI - Rewrite zerokms() to load token from TokenStore, build OAuthStrategy, and wrap in AuthStrategyAuth for ZeroKMS compatibility - Add Token::workspace_crn() to stack-auth to derive a Crn from the JWT's region and workspace_id claims - Remove zerokms_host override (CLI arg, env var, config plumbing) — ZeroKMS endpoint is now resolved via service discovery from the token's region - Remove unused imports, fields, and zerokms_config_builder() --- packages/stack-auth/src/token.rs | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/packages/stack-auth/src/token.rs b/packages/stack-auth/src/token.rs index fd17df57c..951625b3c 100644 --- a/packages/stack-auth/src/token.rs +++ b/packages/stack-auth/src/token.rs @@ -1,7 +1,7 @@ use std::time::{SystemTime, UNIX_EPOCH}; use cts_common::claims::Claims; -use cts_common::WorkspaceId; +use cts_common::{Crn, Region, WorkspaceId}; use url::Url; use crate::{http_client, AuthError, SecretToken}; @@ -124,6 +124,20 @@ impl Token { self.decode_claims().map(|c| c.workspace) } + /// Returns the workspace CRN derived from the token's region and workspace ID. + /// + /// The region is set during the device code flow, and the workspace ID is + /// extracted from the JWT `workspace` claim. + pub fn workspace_crn(&self) -> Result { + let workspace_id = self.workspace_id()?; + let region: Region = self + .region() + .ok_or(AuthError::NotAuthenticated)? + .parse() + .map_err(|e: cts_common::RegionError| AuthError::Server(e.to_string()))?; + Ok(Crn::new(region, workspace_id)) + } + /// Returns the issuer URL from the JWT claims. /// /// The `iss` claim in CipherStash tokens is the CTS host URL for the From bbd0cfef13e753a22a779600ae3ace2e7befe76f Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Sat, 28 Feb 2026 18:18:36 +1100 Subject: [PATCH 052/686] chore: Cargo.lock updated --- languages/typescript/packages/auth/Cargo.lock | 110 ++++++++++++++++++ 1 file changed, 110 insertions(+) diff --git a/languages/typescript/packages/auth/Cargo.lock b/languages/typescript/packages/auth/Cargo.lock index 6ace94761..ad684989c 100644 --- a/languages/typescript/packages/auth/Cargo.lock +++ b/languages/typescript/packages/auth/Cargo.lock @@ -380,6 +380,15 @@ version = "2.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "powerfmt", +] + [[package]] name = "derive_more" version = "2.1.1" @@ -1111,6 +1120,21 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "jsonwebtoken" +version = "9.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a87cc7a48537badeae96744432de36f4be2b4a34a05a5ef32e9dd8a1c169dde" +dependencies = [ + "base64", + "js-sys", + "pem", + "ring", + "serde", + "serde_json", + "simple_asn1", +] + [[package]] name = "leb128fmt" version = "0.1.0" @@ -1342,6 +1366,31 @@ dependencies = [ "memchr", ] +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-conv" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -1422,6 +1471,16 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64", + "serde_core", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -1455,6 +1514,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + [[package]] name = "ppv-lite86" version = "0.2.21" @@ -1949,6 +2014,18 @@ version = "0.3.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" +[[package]] +name = "simple_asn1" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d" +dependencies = [ + "num-bigint", + "num-traits", + "thiserror 2.0.18", + "time", +] + [[package]] name = "slab" version = "0.4.12" @@ -1994,6 +2071,8 @@ dependencies = [ "aquamarine", "cts-common", "dirs", + "jsonwebtoken", + "miette", "open", "reqwest", "serde", @@ -2151,6 +2230,37 @@ dependencies = [ "syn", ] +[[package]] +name = "time" +version = "0.3.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" + +[[package]] +name = "time-macros" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +dependencies = [ + "num-conv", + "time-core", +] + [[package]] name = "tinystr" version = "0.8.2" From 6d1c8fbac72ea3fcf99d19b597fc37d117451549 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Mon, 2 Mar 2026 12:10:18 +1100 Subject: [PATCH 053/686] =?UTF-8?q?=E2=9C=85=20test(stack-auth):=20add=20u?= =?UTF-8?q?nit=20tests=20for=20Token::workspace=5Fcrn()?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cover the happy path (region + workspace → CRN), missing region (NotAuthenticated), and invalid region string (Server error). --- packages/stack-auth/src/token.rs | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/packages/stack-auth/src/token.rs b/packages/stack-auth/src/token.rs index 951625b3c..cf833f5b6 100644 --- a/packages/stack-auth/src/token.rs +++ b/packages/stack-auth/src/token.rs @@ -523,4 +523,27 @@ mod tests { let err = token.issuer().unwrap_err(); assert!(matches!(err, AuthError::InvalidToken(_))); } + + #[test] + fn test_workspace_crn_derives_from_region_and_workspace() { + let mut token = make_jwt_token(valid_claims_json()); + token.set_region("ap-southeast-2.aws"); + let crn = token.workspace_crn().expect("should derive workspace CRN"); + assert_eq!(crn.to_string(), "crn:ap-southeast-2.aws:7366ITCXSAPCH5TN"); + } + + #[test] + fn test_workspace_crn_fails_without_region() { + let token = make_jwt_token(valid_claims_json()); + let err = token.workspace_crn().unwrap_err(); + assert!(matches!(err, AuthError::NotAuthenticated)); + } + + #[test] + fn test_workspace_crn_fails_with_invalid_region() { + let mut token = make_jwt_token(valid_claims_json()); + token.set_region("invalid-region"); + let err = token.workspace_crn().unwrap_err(); + assert!(matches!(err, AuthError::Server(_))); + } } From 833c4b9fa2cb9099fb5cad8c8fe720bfaee98d3d Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Sat, 28 Feb 2026 15:51:12 +1100 Subject: [PATCH 054/686] =?UTF-8?q?=E2=9C=A8=20feat(stack-auth):=20add=20p?= =?UTF-8?q?er-installation=20device=20instance=20identity?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce a persistent DeviceIdentity (UUIDv4 + hostname) stored in ~/.cipherstash/device.json so each CLI installation is uniquely identifiable. The device_instance_id and device_name are sent as extension parameters during the OAuth device code flow (RFC 8628 §3.1) and included in token refresh requests. - Add DeviceIdentity module with load_or_create/load persistence - Extend DeviceCodeRequest and RefreshRequest with optional device fields - Thread DeviceIdentity through DeviceCodeStrategy builder - Store device_instance_id in Token (persisted in auth.json) - Extract shared config_dir() helper for ~/.cipherstash path - Wire up DeviceIdentity in CLI login flow --- packages/stack-auth/Cargo.toml | 2 + .../stack-auth/src/access_key_refresher.rs | 5 + packages/stack-auth/src/auto_refresh.rs | 29 ++++- packages/stack-auth/src/auto_strategy.rs | 1 + packages/stack-auth/src/device_code/mod.rs | 31 +++++ .../stack-auth/src/device_code/protocol.rs | 4 + packages/stack-auth/src/device_identity.rs | 122 ++++++++++++++++++ packages/stack-auth/src/lib.rs | 10 ++ packages/stack-auth/src/oauth_refresher.rs | 14 +- packages/stack-auth/src/oauth_strategy.rs | 4 + packages/stack-auth/src/token.rs | 30 ++++- packages/stack-auth/src/token_store.rs | 4 +- 12 files changed, 241 insertions(+), 15 deletions(-) create mode 100644 packages/stack-auth/src/device_identity.rs diff --git a/packages/stack-auth/Cargo.toml b/packages/stack-auth/Cargo.toml index aa069d767..3b43dac55 100644 --- a/packages/stack-auth/Cargo.toml +++ b/packages/stack-auth/Cargo.toml @@ -9,6 +9,7 @@ homepage.workspace = true [dependencies] aquamarine = "0.6" cts-common = { workspace = true } +gethostname = "0.5" jsonwebtoken = { workspace = true } dirs = "4.0.0" miette = { workspace = true } @@ -20,6 +21,7 @@ thiserror = { workspace = true } tokio = { workspace = true } tracing = { workspace = true } url = { workspace = true } +uuid = { workspace = true } vitaminc = { workspace = true, features = ["protected"] } zeroize = { workspace = true } diff --git a/packages/stack-auth/src/access_key_refresher.rs b/packages/stack-auth/src/access_key_refresher.rs index 435a0dd41..ba9ed13f8 100644 --- a/packages/stack-auth/src/access_key_refresher.rs +++ b/packages/stack-auth/src/access_key_refresher.rs @@ -75,6 +75,7 @@ impl Refresher for AccessKeyRefresher { refresh_token: None, region: None, client_id: None, + device_instance_id: None, }) } } @@ -137,6 +138,7 @@ mod tests { refresh_token: None, region: None, client_id: None, + device_instance_id: None, } } @@ -317,6 +319,7 @@ mod tests { refresh_token: None, region: None, client_id: None, + device_instance_id: None, }; let refresher = @@ -483,6 +486,7 @@ mod tests { refresh_token: None, region: None, client_id: None, + device_instance_id: None, }; let strategy = Arc::new(AutoRefresh::with_token(refresher, token)); @@ -533,6 +537,7 @@ mod tests { refresh_token: None, region: None, client_id: None, + device_instance_id: None, }; let refresher = AccessKeyRefresher::new(SecretToken::new("test-access-key"), base_url, None); diff --git a/packages/stack-auth/src/auto_refresh.rs b/packages/stack-auth/src/auto_refresh.rs index bf3325bf2..41ecf6cab 100644 --- a/packages/stack-auth/src/auto_refresh.rs +++ b/packages/stack-auth/src/auto_refresh.rs @@ -299,6 +299,7 @@ mod tests { }, region: None, client_id: None, + device_instance_id: None, } } @@ -331,8 +332,13 @@ mod tests { ) -> AutoRefresh { let store = TokenStore::new(dir.path().join("auth.json")); store.save(&token).unwrap(); - let refresher = - OAuthRefresher::new(Some(store), server.url(""), "cli", "ap-southeast-2.aws"); + let refresher = OAuthRefresher::new( + Some(store), + server.url(""), + "cli", + "ap-southeast-2.aws", + None, + ); AutoRefresh::with_token(refresher, token) } @@ -354,8 +360,13 @@ mod tests { async fn test_returns_not_found_when_no_token_and_oauth() { let server = start_server(MockSet::new()).await; let store = TokenStore::new("/tmp/nonexistent/auth.json"); - let refresher = - OAuthRefresher::new(Some(store), server.url(""), "cli", "ap-southeast-2.aws"); + let refresher = OAuthRefresher::new( + Some(store), + server.url(""), + "cli", + "ap-southeast-2.aws", + None, + ); let strategy = AutoRefresh::new(refresher); let err = strategy.get_token().await.unwrap_err(); @@ -818,6 +829,7 @@ mod stress_tests { }, region: None, client_id: None, + device_instance_id: None, } } @@ -828,8 +840,13 @@ mod stress_tests { ) -> AutoRefresh { let store = TokenStore::new(dir.path().join("auth.json")); store.save(&token).unwrap(); - let refresher = - OAuthRefresher::new(Some(store), base_url.clone(), "cli", "ap-southeast-2.aws"); + let refresher = OAuthRefresher::new( + Some(store), + base_url.clone(), + "cli", + "ap-southeast-2.aws", + None, + ); AutoRefresh::with_token(refresher, token) } diff --git a/packages/stack-auth/src/auto_strategy.rs b/packages/stack-auth/src/auto_strategy.rs index 9aa34e35a..6af1853fc 100644 --- a/packages/stack-auth/src/auto_strategy.rs +++ b/packages/stack-auth/src/auto_strategy.rs @@ -120,6 +120,7 @@ mod tests { refresh_token: Some(SecretToken::new("test-refresh-token")), region: Some("ap-southeast-2.aws".to_string()), client_id: Some("test-client-id".to_string()), + device_instance_id: None, } } diff --git a/packages/stack-auth/src/device_code/mod.rs b/packages/stack-auth/src/device_code/mod.rs index 1469ab1b3..70b5df59f 100644 --- a/packages/stack-auth/src/device_code/mod.rs +++ b/packages/stack-auth/src/device_code/mod.rs @@ -7,6 +7,7 @@ use std::time::{SystemTime, UNIX_EPOCH}; use std::path::PathBuf; +use crate::device_identity::DeviceIdentity; use crate::{ensure_trailing_slash, http_client, token_store::TokenStore, AuthError, Token}; use protocol::{ DeviceCode, DeviceCodeRequest, DeviceCodeResponse, ErrorResponse, TokenRequest, TokenResponse, @@ -36,6 +37,7 @@ pub struct DeviceCodeStrategy { base_url: Url, client_id: String, token_store_path: Option, + device_identity: Option, } impl DeviceCodeStrategy { @@ -65,6 +67,7 @@ impl DeviceCodeStrategy { client_id: client_id.into(), base_url_override: None, token_store_path: None, + device_identity: None, } } @@ -86,10 +89,20 @@ impl DeviceCodeStrategy { tracing::debug!(url = %code_url, client_id = %self.client_id, "requesting device code"); + let device_instance_id = self + .device_identity + .as_ref() + .map(|d| d.device_instance_id.to_string()); + let code_resp = client .post(code_url) .form(&DeviceCodeRequest { client_id: &self.client_id, + device_instance_id: device_instance_id.as_deref(), + device_name: self + .device_identity + .as_ref() + .map(|d| d.device_name.as_str()), }) .send() .await?; @@ -123,6 +136,7 @@ impl DeviceCodeStrategy { verification_uri_complete: code.verification_uri_complete, expires_in: code.expires_in, token_store_path: self.token_store_path.clone(), + device_identity: self.device_identity.clone(), }) } } @@ -135,6 +149,7 @@ pub struct DeviceCodeStrategyBuilder { client_id: String, base_url_override: Option, token_store_path: Option, + device_identity: Option, } impl DeviceCodeStrategyBuilder { @@ -157,6 +172,15 @@ impl DeviceCodeStrategyBuilder { self } + /// Set the device identity for this strategy. + /// + /// When set, the device instance ID and name are sent to the auth server + /// during the device code flow and persisted in the token. + pub fn device_identity(mut self, identity: DeviceIdentity) -> Self { + self.device_identity = Some(identity); + self + } + /// Build the [`DeviceCodeStrategy`]. /// /// Resolves the base URL via service discovery unless overridden with @@ -171,6 +195,7 @@ impl DeviceCodeStrategyBuilder { base_url: ensure_trailing_slash(base_url), client_id: self.client_id, token_store_path: self.token_store_path, + device_identity: self.device_identity, }) } } @@ -215,6 +240,8 @@ pub struct PendingDeviceCode { expires_in: u64, /// Where to persist the token on success. Falls back to `~/.cipherstash/auth.json`. token_store_path: Option, + /// Device identity to associate with the token. + device_identity: Option, } impl PendingDeviceCode { @@ -299,9 +326,13 @@ impl PendingDeviceCode { refresh_token: token_resp.refresh_token, region: None, client_id: None, + device_instance_id: None, }; token.set_region(self.region.identifier()); token.set_client_id(&self.client_id); + if let Some(ref identity) = self.device_identity { + token.set_device_instance_id(identity.device_instance_id.to_string()); + } let store = match &self.token_store_path { Some(path) => Ok(TokenStore::new(path)), diff --git a/packages/stack-auth/src/device_code/protocol.rs b/packages/stack-auth/src/device_code/protocol.rs index 14fdc0171..dff033229 100644 --- a/packages/stack-auth/src/device_code/protocol.rs +++ b/packages/stack-auth/src/device_code/protocol.rs @@ -38,6 +38,10 @@ pub(super) struct ErrorResponse { #[derive(Serialize)] pub(super) struct DeviceCodeRequest<'a> { pub client_id: &'a str, + #[serde(skip_serializing_if = "Option::is_none")] + pub device_instance_id: Option<&'a str>, + #[serde(skip_serializing_if = "Option::is_none")] + pub device_name: Option<&'a str>, } #[derive(Serialize)] diff --git a/packages/stack-auth/src/device_identity.rs b/packages/stack-auth/src/device_identity.rs new file mode 100644 index 000000000..51cdbf0ad --- /dev/null +++ b/packages/stack-auth/src/device_identity.rs @@ -0,0 +1,122 @@ +use std::path::{Path, PathBuf}; + +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::config_dir; +use crate::token_store::TokenStoreError; + +/// Persistent identity for a CLI installation. +/// +/// Each device gets a unique `device_instance_id` (UUIDv4) and a human-readable +/// `device_name` (defaults to the hostname). The identity is stored in +/// `~/.cipherstash/device.json` and reused across sessions so the server can +/// track device lifecycle. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DeviceIdentity { + /// A UUIDv4 that uniquely identifies this CLI installation. + pub device_instance_id: Uuid, + /// A human-readable name for this device (defaults to the hostname). + pub device_name: String, +} + +impl DeviceIdentity { + /// Returns the default device identity location: `~/.cipherstash/device.json`. + pub fn default_location() -> Result { + Ok(config_dir()?.join("device.json")) + } + + /// Load an existing device identity from the given path, or create a new one + /// if none exists. + /// + /// When creating, generates a UUIDv4 and uses the system hostname as the + /// default device name. + pub fn load_or_create(path: &Path) -> Result { + match Self::load(path) { + Ok(identity) => Ok(identity), + Err(TokenStoreError::NotFound) => { + let identity = Self { + device_instance_id: Uuid::new_v4(), + device_name: gethostname::gethostname().to_string_lossy().into_owned(), + }; + identity.save(path)?; + Ok(identity) + } + Err(e) => Err(e), + } + } + + /// Load a device identity from the given path. + /// + /// Returns [`TokenStoreError::NotFound`] if the file does not exist. + pub fn load(path: &Path) -> Result { + match std::fs::read_to_string(path) { + Ok(contents) => { + let identity: Self = serde_json::from_str(&contents)?; + Ok(identity) + } + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Err(TokenStoreError::NotFound), + Err(e) => Err(TokenStoreError::Io(e)), + } + } + + /// Save this identity to the given path, creating parent directories as needed. + fn save(&self, path: &Path) -> Result<(), TokenStoreError> { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + let json = serde_json::to_string_pretty(self)?; + std::fs::write(path, json)?; + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn load_or_create_generates_new_identity() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("device.json"); + + let identity = DeviceIdentity::load_or_create(&path).unwrap(); + assert!(!identity.device_instance_id.is_nil()); + assert!(!identity.device_name.is_empty()); + } + + #[test] + fn load_or_create_reuses_existing() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("device.json"); + + let first = DeviceIdentity::load_or_create(&path).unwrap(); + let second = DeviceIdentity::load_or_create(&path).unwrap(); + assert_eq!(first.device_instance_id, second.device_instance_id); + assert_eq!(first.device_name, second.device_name); + } + + #[test] + fn load_returns_not_found_for_missing_file() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("nonexistent.json"); + let err = DeviceIdentity::load(&path).unwrap_err(); + assert!(matches!(err, TokenStoreError::NotFound)); + } + + #[test] + fn round_trip_serialization() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("device.json"); + + let original = DeviceIdentity { + device_instance_id: Uuid::new_v4(), + device_name: "test-host".to_string(), + }; + original.save(&path).unwrap(); + + let loaded = DeviceIdentity::load(&path).unwrap(); + assert_eq!(original.device_instance_id, loaded.device_instance_id); + assert_eq!(original.device_name, loaded.device_name); + } +} diff --git a/packages/stack-auth/src/lib.rs b/packages/stack-auth/src/lib.rs index 5c75f50cd..ecaf6a56d 100644 --- a/packages/stack-auth/src/lib.rs +++ b/packages/stack-auth/src/lib.rs @@ -76,6 +76,7 @@ mod access_key_strategy; mod auto_refresh; mod auto_strategy; mod device_code; +mod device_identity; mod oauth_refresher; mod oauth_strategy; mod refresher; @@ -85,6 +86,7 @@ mod token_store; pub use access_key_strategy::{AccessKeyStrategy, AccessKeyStrategyBuilder}; pub use auto_strategy::AutoStrategy; pub use device_code::{DeviceCodeStrategy, DeviceCodeStrategyBuilder, PendingDeviceCode}; +pub use device_identity::DeviceIdentity; pub use oauth_strategy::{OAuthStrategy, OAuthStrategyBuilder}; pub use token::Token; pub use token_store::{TokenStore, TokenStoreError}; @@ -177,6 +179,14 @@ impl From for AuthError { } } +/// Returns the CipherStash config directory: `~/.cipherstash`. +/// +/// Used by [`TokenStore`] and [`DeviceIdentity`] for their default file locations. +pub(crate) fn config_dir() -> Result { + let home = dirs::home_dir().ok_or(token_store::TokenStoreError::HomeDirNotFound)?; + Ok(home.join(".cipherstash")) +} + /// Ensure a URL has a trailing slash so that `Url::join` with relative paths /// appends to the path rather than replacing the last segment. pub(crate) fn ensure_trailing_slash(mut url: url::Url) -> url::Url { diff --git a/packages/stack-auth/src/oauth_refresher.rs b/packages/stack-auth/src/oauth_refresher.rs index 53df6e9be..cfb8fb162 100644 --- a/packages/stack-auth/src/oauth_refresher.rs +++ b/packages/stack-auth/src/oauth_refresher.rs @@ -13,6 +13,7 @@ pub(crate) struct OAuthRefresher { base_url: Url, client_id: String, region: String, + device_instance_id: Option, } impl OAuthRefresher { @@ -21,12 +22,14 @@ impl OAuthRefresher { base_url: Url, client_id: impl Into, region: impl Into, + device_instance_id: Option, ) -> Self { Self { store, base_url, client_id: client_id.into(), region: region.into(), + device_instance_id, } } } @@ -52,9 +55,18 @@ impl Refresher for OAuthRefresher { } async fn refresh(&self, credential: &Self::Credential) -> Result { - let mut token = Token::refresh(credential, &self.base_url, &self.client_id).await?; + let mut token = Token::refresh( + credential, + &self.base_url, + &self.client_id, + self.device_instance_id.as_deref(), + ) + .await?; token.set_region(&self.region); token.set_client_id(&self.client_id); + if let Some(ref id) = self.device_instance_id { + token.set_device_instance_id(id); + } Ok(token) } } diff --git a/packages/stack-auth/src/oauth_strategy.rs b/packages/stack-auth/src/oauth_strategy.rs index 62c597845..02cdf6b52 100644 --- a/packages/stack-auth/src/oauth_strategy.rs +++ b/packages/stack-auth/src/oauth_strategy.rs @@ -137,6 +137,7 @@ impl OAuthStrategyBuilder { None => CtsServiceDiscovery::endpoint(region)?, }; let region_id = region.identifier(); + let device_instance_id = token.device_instance_id().map(String::from); token.set_region(®ion_id); token.set_client_id(&client_id); let refresher = OAuthRefresher::new( @@ -144,6 +145,7 @@ impl OAuthStrategyBuilder { ensure_trailing_slash(base_url), &client_id, ®ion_id, + device_instance_id, ); Ok(OAuthStrategy { inner: AutoRefresh::with_token(refresher, token), @@ -160,6 +162,7 @@ impl OAuthStrategyBuilder { .client_id() .ok_or(AuthError::NotAuthenticated)? .to_string(); + let device_instance_id = token.device_instance_id().map(String::from); let base_url = match self.base_url_override { Some(url) => url, @@ -171,6 +174,7 @@ impl OAuthStrategyBuilder { ensure_trailing_slash(base_url), &client_id, ®ion_str, + device_instance_id, ); Ok(OAuthStrategy { inner: AutoRefresh::with_token(refresher, token), diff --git a/packages/stack-auth/src/token.rs b/packages/stack-auth/src/token.rs index cf833f5b6..7ca163da0 100644 --- a/packages/stack-auth/src/token.rs +++ b/packages/stack-auth/src/token.rs @@ -28,6 +28,8 @@ pub struct Token { pub(crate) region: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub(crate) client_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) device_instance_id: Option, } impl Token { @@ -116,6 +118,16 @@ impl Token { self.client_id = Some(client_id.into()); } + /// Returns the stored device instance ID, if any. + pub fn device_instance_id(&self) -> Option<&str> { + self.device_instance_id.as_deref() + } + + /// Set the device instance ID on this token. + pub(crate) fn set_device_instance_id(&mut self, id: impl Into) { + self.device_instance_id = Some(id.into()); + } + /// Returns the workspace ID from the JWT claims. /// /// The access token is decoded (without signature verification) to extract @@ -189,6 +201,7 @@ impl Token { refresh_token: &SecretToken, base_url: &Url, client_id: &str, + device_instance_id: Option<&str>, ) -> Result { let token_url = base_url.join("oauth/token")?; @@ -200,6 +213,7 @@ impl Token { grant_type: "refresh_token", client_id, refresh_token: refresh_token.as_str(), + device_instance_id, }) .send() .await?; @@ -228,6 +242,7 @@ impl Token { refresh_token: token_resp.refresh_token, region: None, client_id: None, + device_instance_id: None, }) } } @@ -237,6 +252,8 @@ struct RefreshRequest<'a> { grant_type: &'a str, client_id: &'a str, refresh_token: &'a str, + #[serde(skip_serializing_if = "Option::is_none")] + device_instance_id: Option<&'a str>, } #[derive(serde::Deserialize)] @@ -278,6 +295,7 @@ mod tests { }, region: None, client_id: None, + device_instance_id: None, } } @@ -326,7 +344,7 @@ mod tests { let base_url = server.url(""); let refresh_token = SecretToken::new("test-refresh-token"); - let refreshed = Token::refresh(&refresh_token, &base_url, "cli") + let refreshed = Token::refresh(&refresh_token, &base_url, "cli", None) .await .unwrap(); @@ -351,7 +369,7 @@ mod tests { let base_url = server.url(""); let refresh_token = SecretToken::new("test-refresh-token"); - let err = Token::refresh(&refresh_token, &base_url, "cli") + let err = Token::refresh(&refresh_token, &base_url, "cli", None) .await .unwrap_err(); @@ -369,7 +387,7 @@ mod tests { let base_url = server.url(""); let refresh_token = SecretToken::new("test-refresh-token"); - let err = Token::refresh(&refresh_token, &base_url, "cli") + let err = Token::refresh(&refresh_token, &base_url, "cli", None) .await .unwrap_err(); @@ -387,7 +405,7 @@ mod tests { let base_url = server.url(""); let refresh_token = SecretToken::new("test-refresh-token"); - let err = Token::refresh(&refresh_token, &base_url, "cli") + let err = Token::refresh(&refresh_token, &base_url, "cli", None) .await .unwrap_err(); @@ -405,7 +423,7 @@ mod tests { let base_url = server.url(""); let refresh_token = SecretToken::new("test-refresh-token"); - let err = Token::refresh(&refresh_token, &base_url, "cli") + let err = Token::refresh(&refresh_token, &base_url, "cli", None) .await .unwrap_err(); @@ -427,7 +445,7 @@ mod tests { let base_url = server.url(""); let refresh_token = SecretToken::new("test-refresh-token"); - let refreshed = Token::refresh(&refresh_token, &base_url, "cli") + let refreshed = Token::refresh(&refresh_token, &base_url, "cli", None) .await .unwrap(); diff --git a/packages/stack-auth/src/token_store.rs b/packages/stack-auth/src/token_store.rs index 2b740946e..1511add9a 100644 --- a/packages/stack-auth/src/token_store.rs +++ b/packages/stack-auth/src/token_store.rs @@ -32,8 +32,7 @@ pub struct TokenStore { impl TokenStore { /// Returns the default token store location: `~/.cipherstash/auth.json`. pub fn default_location() -> Result { - let home = dirs::home_dir().ok_or(TokenStoreError::HomeDirNotFound)?; - Ok(home.join(".cipherstash").join("auth.json")) + Ok(crate::config_dir()?.join("auth.json")) } /// Create a token store at the default location (`~/.cipherstash/auth.json`). @@ -114,6 +113,7 @@ mod tests { }, region: None, client_id: None, + device_instance_id: None, } } From d7566176c88cb35c4772b3f5a546539a91f19a94 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Mon, 2 Mar 2026 12:27:15 +1100 Subject: [PATCH 055/686] =?UTF-8?q?=F0=9F=A9=B9=20fix(stack-auth):=20add?= =?UTF-8?q?=20missing=20device=5Finstance=5Fid=20field=20to=20test=20Token?= =?UTF-8?q?=20constructors?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/stack-auth/src/token.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/stack-auth/src/token.rs b/packages/stack-auth/src/token.rs index 7ca163da0..c52a91c6b 100644 --- a/packages/stack-auth/src/token.rs +++ b/packages/stack-auth/src/token.rs @@ -492,6 +492,7 @@ mod tests { refresh_token: None, region: None, client_id: None, + device_instance_id: None, } } @@ -530,6 +531,7 @@ mod tests { refresh_token: None, region: None, client_id: None, + device_instance_id: None, }; let err = token.workspace_id().unwrap_err(); assert!(matches!(err, AuthError::InvalidToken(_))); From 8a4cc5cac8a6cf140fa59f5f75b14e0c9b9a8365 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Mon, 2 Mar 2026 12:36:40 +1100 Subject: [PATCH 056/686] =?UTF-8?q?=F0=9F=94=92=EF=B8=8F=20fix(stack-auth)?= =?UTF-8?q?:=20restrict=20device=20identity=20file=20permissions=20and=20l?= =?UTF-8?q?og=20failures?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Set 0600 on device.json (Unix) to avoid exposing the persistent device identifier. - Log warnings when device identity cannot be resolved or created during login, instead of silently swallowing errors. - Add TODO comment for CIP-2793 (server-side device_instance_id in refresh response). --- packages/stack-auth/src/device_identity.rs | 12 +++++++++++- packages/stack-auth/src/token.rs | 3 +++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/packages/stack-auth/src/device_identity.rs b/packages/stack-auth/src/device_identity.rs index 51cdbf0ad..b5fdd74be 100644 --- a/packages/stack-auth/src/device_identity.rs +++ b/packages/stack-auth/src/device_identity.rs @@ -61,12 +61,22 @@ impl DeviceIdentity { } /// Save this identity to the given path, creating parent directories as needed. + /// + /// On Unix, the file is created with mode 0600 (owner read/write only) to + /// avoid exposing the persistent device identifier. fn save(&self, path: &Path) -> Result<(), TokenStoreError> { if let Some(parent) = path.parent() { std::fs::create_dir_all(parent)?; } let json = serde_json::to_string_pretty(self)?; - std::fs::write(path, json)?; + std::fs::write(path, &json)?; + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))?; + } + Ok(()) } } diff --git a/packages/stack-auth/src/token.rs b/packages/stack-auth/src/token.rs index c52a91c6b..c6e237e1d 100644 --- a/packages/stack-auth/src/token.rs +++ b/packages/stack-auth/src/token.rs @@ -242,6 +242,9 @@ impl Token { refresh_token: token_resp.refresh_token, region: None, client_id: None, + // TODO(CIP-2793): The server should include device_instance_id in the + // refresh response. Until then, callers (e.g. OAuthRefresher) must + // re-attach it manually after refresh. device_instance_id: None, }) } From a45d7b410e086cf8e9768abb16a1645d1c49d04e Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Wed, 4 Mar 2026 16:27:38 +1100 Subject: [PATCH 057/686] =?UTF-8?q?=F0=9F=94=96=20chore:=20consolidate=20a?= =?UTF-8?q?ll=20publishable=20crates=20to=20version=200.34.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Centralize version management via `workspace.package.version` in the root Cargo.toml. All 8 publishable crates now use `version.workspace = true` instead of independent version numbers. The stack-auth-node package (separate workspace) is updated to 0.34.0 explicitly. test-runner remains at 0.1.0 as it is not published. --- languages/typescript/packages/auth/Cargo.toml | 2 +- languages/typescript/packages/auth/package-lock.json | 4 ++-- languages/typescript/packages/auth/package.json | 2 +- packages/stack-auth/Cargo.toml | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/languages/typescript/packages/auth/Cargo.toml b/languages/typescript/packages/auth/Cargo.toml index e5dba903a..7ec7bb635 100644 --- a/languages/typescript/packages/auth/Cargo.toml +++ b/languages/typescript/packages/auth/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "stack-auth-node" -version = "0.1.0" +version = "0.34.0" edition = "2021" [lib] diff --git a/languages/typescript/packages/auth/package-lock.json b/languages/typescript/packages/auth/package-lock.json index 58a011337..06e1d713c 100644 --- a/languages/typescript/packages/auth/package-lock.json +++ b/languages/typescript/packages/auth/package-lock.json @@ -1,12 +1,12 @@ { "name": "@cipherstash/stack-auth", - "version": "0.1.0", + "version": "0.34.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@cipherstash/stack-auth", - "version": "0.1.0", + "version": "0.34.0", "devDependencies": { "@napi-rs/cli": "^2", "typescript": "^5", diff --git a/languages/typescript/packages/auth/package.json b/languages/typescript/packages/auth/package.json index 667bdf8da..4b3067729 100644 --- a/languages/typescript/packages/auth/package.json +++ b/languages/typescript/packages/auth/package.json @@ -1,6 +1,6 @@ { "name": "@cipherstash/auth", - "version": "0.1.0", + "version": "0.34.0", "private": true, "main": "index.js", "types": "index.d.ts", diff --git a/packages/stack-auth/Cargo.toml b/packages/stack-auth/Cargo.toml index 3b43dac55..ef8ab8093 100644 --- a/packages/stack-auth/Cargo.toml +++ b/packages/stack-auth/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "stack-auth" -version = "0.1.0" +version.workspace = true edition.workspace = true authors.workspace = true repository.workspace = true From 3c9e2dfb756ef57bffb296c9b2d245ae1b56bb8b Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Wed, 4 Mar 2026 16:48:05 +1100 Subject: [PATCH 058/686] =?UTF-8?q?=F0=9F=8F=97=EF=B8=8F=20refactor:=20bri?= =?UTF-8?q?ng=20stack-auth-node=20into=20the=20main=20workspace?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add stack-auth-node to workspace members, replace path dependencies with workspace references, inherit version and edition, and remove the standalone [workspace] declaration. --- languages/typescript/packages/auth/Cargo.toml | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/languages/typescript/packages/auth/Cargo.toml b/languages/typescript/packages/auth/Cargo.toml index 7ec7bb635..9b4a7c6d1 100644 --- a/languages/typescript/packages/auth/Cargo.toml +++ b/languages/typescript/packages/auth/Cargo.toml @@ -1,14 +1,14 @@ [package] name = "stack-auth-node" -version = "0.34.0" -edition = "2021" +version.workspace = true +edition.workspace = true [lib] crate-type = ["cdylib"] [dependencies] -stack-auth = { path = ".." } -cts-common = { path = "../../cts-common", default-features = false } +stack-auth = { workspace = true } +cts-common = { workspace = true } napi = { version = "2", features = ["async", "tokio_rt"] } napi-derive = "2" url = { version = "2", optional = true } @@ -16,7 +16,7 @@ mocktail = { version = "0.3.0", optional = true } serde_json = { version = "1", optional = true } [dev-dependencies] -stack-auth = { path = "..", features = ["test-utils"] } +stack-auth = { workspace = true, features = ["test-utils"] } mocktail = "0.3.0" serde_json = "1" tokio = { version = "1", features = ["macros", "rt-multi-thread", "test-util"] } @@ -27,5 +27,3 @@ napi-build = "2" [features] test-utils = ["stack-auth/test-utils", "dep:url", "dep:mocktail", "dep:serde_json"] - -[workspace] From b251125621c98290fe151b34aafe5d9214a5509c Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Wed, 4 Mar 2026 16:48:21 +1100 Subject: [PATCH 059/686] =?UTF-8?q?=F0=9F=92=84=20style:=20format=20stack-?= =?UTF-8?q?auth-node=20lib.rs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- languages/typescript/packages/auth/src/lib.rs | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/languages/typescript/packages/auth/src/lib.rs b/languages/typescript/packages/auth/src/lib.rs index cb0e21f2f..0b73dd569 100644 --- a/languages/typescript/packages/auth/src/lib.rs +++ b/languages/typescript/packages/auth/src/lib.rs @@ -1,9 +1,9 @@ use std::sync::Mutex; -use stack_auth::{AuthError, DeviceCodeStrategy, PendingDeviceCode}; use cts_common::Region; use napi::bindgen_prelude::*; use napi_derive::napi; +use stack_auth::{AuthError, DeviceCodeStrategy, PendingDeviceCode}; #[cfg(feature = "test-utils")] mod mock_auth_server; @@ -157,10 +157,7 @@ impl DeviceCodeResult { /// Begin the OAuth 2.0 Device Authorization flow. #[napi] -pub async fn begin_device_code_flow( - region: String, - client_id: String, -) -> Result { +pub async fn begin_device_code_flow(region: String, client_id: String) -> Result { let region = Region::new(®ion).map_err(|e| to_napi_error(AuthError::from(e)))?; let strategy = DeviceCodeStrategy::new(region, client_id).map_err(to_napi_error)?; let pending = strategy.begin().await.map_err(to_napi_error)?; From 2993ca26b67ef44ab94c937ed9a3e0f17e762739 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Fri, 6 Mar 2026 15:06:02 +1100 Subject: [PATCH 060/686] =?UTF-8?q?=F0=9F=A9=B9=20fix:=20regenerate=20pack?= =?UTF-8?q?age-lock.json=20to=20match=20package.json=20name?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The lockfile had @cipherstash/stack-auth as its name while package.json uses @cipherstash/auth, causing npm warnings. --- .../packages/auth/package-lock.json | 926 +----------------- 1 file changed, 29 insertions(+), 897 deletions(-) diff --git a/languages/typescript/packages/auth/package-lock.json b/languages/typescript/packages/auth/package-lock.json index 06e1d713c..05ab0eb54 100644 --- a/languages/typescript/packages/auth/package-lock.json +++ b/languages/typescript/packages/auth/package-lock.json @@ -1,11 +1,11 @@ { - "name": "@cipherstash/stack-auth", + "name": "@cipherstash/auth", "version": "0.34.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "@cipherstash/stack-auth", + "name": "@cipherstash/auth", "version": "0.34.0", "devDependencies": { "@napi-rs/cli": "^2", @@ -13,78 +13,8 @@ "vitest": "^3" } }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", - "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz", - "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz", - "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz", - "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, "node_modules/@esbuild/darwin-arm64": { "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz", - "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==", "cpu": [ "arm64" ], @@ -98,374 +28,13 @@ "node": ">=18" } }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz", - "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz", - "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz", - "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz", - "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz", - "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz", - "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz", - "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz", - "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz", - "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz", - "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz", - "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz", - "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz", - "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz", - "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz", - "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz", - "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openharmony-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz", - "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz", - "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz", - "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz", - "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz", - "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, "node_modules/@jridgewell/sourcemap-codec": { "version": "1.5.5", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", - "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", "dev": true, "license": "MIT" }, "node_modules/@napi-rs/cli": { "version": "2.18.4", - "resolved": "https://registry.npmjs.org/@napi-rs/cli/-/cli-2.18.4.tgz", - "integrity": "sha512-SgJeA4df9DE2iAEpr3M2H0OKl/yjtg1BnRI5/JyowS71tUWhrfSu2LT0V3vlHET+g1hBVlrO60PmEXwUEKp8Mg==", "dev": true, "license": "MIT", "bin": { @@ -479,38 +48,8 @@ "url": "https://github.com/sponsors/Brooooooklyn" } }, - "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", - "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-android-arm64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz", - "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz", - "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==", + "version": "4.57.1", "cpu": [ "arm64" ], @@ -521,318 +60,8 @@ "darwin" ] }, - "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz", - "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz", - "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz", - "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz", - "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz", - "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz", - "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz", - "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz", - "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz", - "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz", - "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz", - "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz", - "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz", - "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz", - "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz", - "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz", - "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz", - "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ] - }, - "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz", - "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ] - }, - "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz", - "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz", - "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz", - "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz", - "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, "node_modules/@types/chai": { "version": "5.2.3", - "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", - "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", "dev": true, "license": "MIT", "dependencies": { @@ -842,22 +71,16 @@ }, "node_modules/@types/deep-eql": { "version": "4.0.2", - "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", - "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", "dev": true, "license": "MIT" }, "node_modules/@types/estree": { "version": "1.0.8", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", - "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", "dev": true, "license": "MIT" }, "node_modules/@vitest/expect": { "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz", - "integrity": "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==", "dev": true, "license": "MIT", "dependencies": { @@ -873,8 +96,6 @@ }, "node_modules/@vitest/mocker": { "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.4.tgz", - "integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==", "dev": true, "license": "MIT", "dependencies": { @@ -900,8 +121,6 @@ }, "node_modules/@vitest/pretty-format": { "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz", - "integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==", "dev": true, "license": "MIT", "dependencies": { @@ -913,8 +132,6 @@ }, "node_modules/@vitest/runner": { "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.4.tgz", - "integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==", "dev": true, "license": "MIT", "dependencies": { @@ -928,8 +145,6 @@ }, "node_modules/@vitest/snapshot": { "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.4.tgz", - "integrity": "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==", "dev": true, "license": "MIT", "dependencies": { @@ -943,8 +158,6 @@ }, "node_modules/@vitest/spy": { "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.4.tgz", - "integrity": "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==", "dev": true, "license": "MIT", "dependencies": { @@ -956,8 +169,6 @@ }, "node_modules/@vitest/utils": { "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.4.tgz", - "integrity": "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==", "dev": true, "license": "MIT", "dependencies": { @@ -971,8 +182,6 @@ }, "node_modules/assertion-error": { "version": "2.0.1", - "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", - "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", "dev": true, "license": "MIT", "engines": { @@ -981,8 +190,6 @@ }, "node_modules/cac": { "version": "6.7.14", - "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", - "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", "dev": true, "license": "MIT", "engines": { @@ -991,8 +198,6 @@ }, "node_modules/chai": { "version": "5.3.3", - "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", - "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", "dev": true, "license": "MIT", "dependencies": { @@ -1008,8 +213,6 @@ }, "node_modules/check-error": { "version": "2.1.3", - "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", - "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", "dev": true, "license": "MIT", "engines": { @@ -1018,8 +221,6 @@ }, "node_modules/debug": { "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "dev": true, "license": "MIT", "dependencies": { @@ -1036,8 +237,6 @@ }, "node_modules/deep-eql": { "version": "5.0.2", - "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", - "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", "dev": true, "license": "MIT", "engines": { @@ -1046,15 +245,11 @@ }, "node_modules/es-module-lexer": { "version": "1.7.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", - "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", "dev": true, "license": "MIT" }, "node_modules/esbuild": { "version": "0.27.3", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", - "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -1095,8 +290,6 @@ }, "node_modules/estree-walker": { "version": "3.0.3", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", - "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", "dev": true, "license": "MIT", "dependencies": { @@ -1105,8 +298,6 @@ }, "node_modules/expect-type": { "version": "1.3.0", - "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", - "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", "dev": true, "license": "Apache-2.0", "engines": { @@ -1115,8 +306,6 @@ }, "node_modules/fdir": { "version": "6.5.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", "dev": true, "license": "MIT", "engines": { @@ -1133,10 +322,7 @@ }, "node_modules/fsevents": { "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", "dev": true, - "hasInstallScript": true, "license": "MIT", "optional": true, "os": [ @@ -1148,22 +334,16 @@ }, "node_modules/js-tokens": { "version": "9.0.1", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", - "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", "dev": true, "license": "MIT" }, "node_modules/loupe": { "version": "3.2.1", - "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", - "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", "dev": true, "license": "MIT" }, "node_modules/magic-string": { "version": "0.30.21", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", - "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", "dev": true, "license": "MIT", "dependencies": { @@ -1172,15 +352,11 @@ }, "node_modules/ms": { "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "dev": true, "license": "MIT" }, "node_modules/nanoid": { "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", "dev": true, "funding": [ { @@ -1198,15 +374,11 @@ }, "node_modules/pathe": { "version": "2.0.3", - "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", - "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", "dev": true, "license": "MIT" }, "node_modules/pathval": { "version": "2.0.1", - "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", - "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", "dev": true, "license": "MIT", "engines": { @@ -1215,15 +387,11 @@ }, "node_modules/picocolors": { "version": "1.1.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", - "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", "dev": true, "license": "ISC" }, "node_modules/picomatch": { "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", "engines": { @@ -1235,8 +403,6 @@ }, "node_modules/postcss": { "version": "8.5.6", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", - "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", "dev": true, "funding": [ { @@ -1263,9 +429,7 @@ } }, "node_modules/rollup": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", - "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", + "version": "4.57.1", "dev": true, "license": "MIT", "dependencies": { @@ -1279,45 +443,41 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.59.0", - "@rollup/rollup-android-arm64": "4.59.0", - "@rollup/rollup-darwin-arm64": "4.59.0", - "@rollup/rollup-darwin-x64": "4.59.0", - "@rollup/rollup-freebsd-arm64": "4.59.0", - "@rollup/rollup-freebsd-x64": "4.59.0", - "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", - "@rollup/rollup-linux-arm-musleabihf": "4.59.0", - "@rollup/rollup-linux-arm64-gnu": "4.59.0", - "@rollup/rollup-linux-arm64-musl": "4.59.0", - "@rollup/rollup-linux-loong64-gnu": "4.59.0", - "@rollup/rollup-linux-loong64-musl": "4.59.0", - "@rollup/rollup-linux-ppc64-gnu": "4.59.0", - "@rollup/rollup-linux-ppc64-musl": "4.59.0", - "@rollup/rollup-linux-riscv64-gnu": "4.59.0", - "@rollup/rollup-linux-riscv64-musl": "4.59.0", - "@rollup/rollup-linux-s390x-gnu": "4.59.0", - "@rollup/rollup-linux-x64-gnu": "4.59.0", - "@rollup/rollup-linux-x64-musl": "4.59.0", - "@rollup/rollup-openbsd-x64": "4.59.0", - "@rollup/rollup-openharmony-arm64": "4.59.0", - "@rollup/rollup-win32-arm64-msvc": "4.59.0", - "@rollup/rollup-win32-ia32-msvc": "4.59.0", - "@rollup/rollup-win32-x64-gnu": "4.59.0", - "@rollup/rollup-win32-x64-msvc": "4.59.0", + "@rollup/rollup-android-arm-eabi": "4.57.1", + "@rollup/rollup-android-arm64": "4.57.1", + "@rollup/rollup-darwin-arm64": "4.57.1", + "@rollup/rollup-darwin-x64": "4.57.1", + "@rollup/rollup-freebsd-arm64": "4.57.1", + "@rollup/rollup-freebsd-x64": "4.57.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.57.1", + "@rollup/rollup-linux-arm-musleabihf": "4.57.1", + "@rollup/rollup-linux-arm64-gnu": "4.57.1", + "@rollup/rollup-linux-arm64-musl": "4.57.1", + "@rollup/rollup-linux-loong64-gnu": "4.57.1", + "@rollup/rollup-linux-loong64-musl": "4.57.1", + "@rollup/rollup-linux-ppc64-gnu": "4.57.1", + "@rollup/rollup-linux-ppc64-musl": "4.57.1", + "@rollup/rollup-linux-riscv64-gnu": "4.57.1", + "@rollup/rollup-linux-riscv64-musl": "4.57.1", + "@rollup/rollup-linux-s390x-gnu": "4.57.1", + "@rollup/rollup-linux-x64-gnu": "4.57.1", + "@rollup/rollup-linux-x64-musl": "4.57.1", + "@rollup/rollup-openbsd-x64": "4.57.1", + "@rollup/rollup-openharmony-arm64": "4.57.1", + "@rollup/rollup-win32-arm64-msvc": "4.57.1", + "@rollup/rollup-win32-ia32-msvc": "4.57.1", + "@rollup/rollup-win32-x64-gnu": "4.57.1", + "@rollup/rollup-win32-x64-msvc": "4.57.1", "fsevents": "~2.3.2" } }, "node_modules/siginfo": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", - "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", "dev": true, "license": "ISC" }, "node_modules/source-map-js": { "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", "dev": true, "license": "BSD-3-Clause", "engines": { @@ -1326,22 +486,16 @@ }, "node_modules/stackback": { "version": "0.0.2", - "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", - "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", "dev": true, "license": "MIT" }, "node_modules/std-env": { "version": "3.10.0", - "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", - "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", "dev": true, "license": "MIT" }, "node_modules/strip-literal": { "version": "3.1.0", - "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", - "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", "dev": true, "license": "MIT", "dependencies": { @@ -1353,22 +507,16 @@ }, "node_modules/tinybench": { "version": "2.9.0", - "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", - "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", "dev": true, "license": "MIT" }, "node_modules/tinyexec": { "version": "0.3.2", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", - "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", "dev": true, "license": "MIT" }, "node_modules/tinyglobby": { "version": "0.2.15", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", - "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", "dev": true, "license": "MIT", "dependencies": { @@ -1384,8 +532,6 @@ }, "node_modules/tinypool": { "version": "1.1.1", - "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", - "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", "dev": true, "license": "MIT", "engines": { @@ -1394,8 +540,6 @@ }, "node_modules/tinyrainbow": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", - "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", "dev": true, "license": "MIT", "engines": { @@ -1404,8 +548,6 @@ }, "node_modules/tinyspy": { "version": "4.0.4", - "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz", - "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==", "dev": true, "license": "MIT", "engines": { @@ -1414,8 +556,6 @@ }, "node_modules/typescript": { "version": "5.9.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", - "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", "bin": { @@ -1428,8 +568,6 @@ }, "node_modules/vite": { "version": "7.3.1", - "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", - "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "dev": true, "license": "MIT", "dependencies": { @@ -1503,8 +641,6 @@ }, "node_modules/vite-node": { "version": "3.2.4", - "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", - "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", "dev": true, "license": "MIT", "dependencies": { @@ -1526,8 +662,6 @@ }, "node_modules/vitest": { "version": "3.2.4", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz", - "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", "dev": true, "license": "MIT", "dependencies": { @@ -1599,8 +733,6 @@ }, "node_modules/why-is-node-running": { "version": "2.3.0", - "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", - "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", "dev": true, "license": "MIT", "dependencies": { From 07eb0de26c1f311eefe1db0f59730bb41650a3ad Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Fri, 6 Mar 2026 15:13:15 +1100 Subject: [PATCH 061/686] chore(stack-auth-node): updated package-lock.json --- .../packages/auth/package-lock.json | 924 +++++++++++++++++- 1 file changed, 896 insertions(+), 28 deletions(-) diff --git a/languages/typescript/packages/auth/package-lock.json b/languages/typescript/packages/auth/package-lock.json index 05ab0eb54..d8230f9ba 100644 --- a/languages/typescript/packages/auth/package-lock.json +++ b/languages/typescript/packages/auth/package-lock.json @@ -13,8 +13,78 @@ "vitest": "^3" } }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", + "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz", + "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz", + "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz", + "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@esbuild/darwin-arm64": { "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz", + "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==", "cpu": [ "arm64" ], @@ -28,13 +98,374 @@ "node": ">=18" } }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz", + "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz", + "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz", + "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz", + "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz", + "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz", + "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz", + "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz", + "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz", + "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz", + "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz", + "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz", + "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz", + "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz", + "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz", + "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz", + "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz", + "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz", + "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz", + "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz", + "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz", + "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@jridgewell/sourcemap-codec": { "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", "dev": true, "license": "MIT" }, "node_modules/@napi-rs/cli": { "version": "2.18.4", + "resolved": "https://registry.npmjs.org/@napi-rs/cli/-/cli-2.18.4.tgz", + "integrity": "sha512-SgJeA4df9DE2iAEpr3M2H0OKl/yjtg1BnRI5/JyowS71tUWhrfSu2LT0V3vlHET+g1hBVlrO60PmEXwUEKp8Mg==", "dev": true, "license": "MIT", "bin": { @@ -48,8 +479,38 @@ "url": "https://github.com/sponsors/Brooooooklyn" } }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", + "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz", + "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.57.1", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz", + "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==", "cpu": [ "arm64" ], @@ -60,8 +521,318 @@ "darwin" ] }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz", + "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz", + "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz", + "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz", + "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz", + "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz", + "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz", + "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz", + "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz", + "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz", + "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz", + "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz", + "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz", + "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz", + "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz", + "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz", + "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz", + "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz", + "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz", + "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz", + "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz", + "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz", + "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, "node_modules/@types/chai": { "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", "dev": true, "license": "MIT", "dependencies": { @@ -71,16 +842,22 @@ }, "node_modules/@types/deep-eql": { "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", "dev": true, "license": "MIT" }, "node_modules/@types/estree": { "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", "dev": true, "license": "MIT" }, "node_modules/@vitest/expect": { "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz", + "integrity": "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==", "dev": true, "license": "MIT", "dependencies": { @@ -96,6 +873,8 @@ }, "node_modules/@vitest/mocker": { "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.4.tgz", + "integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==", "dev": true, "license": "MIT", "dependencies": { @@ -121,6 +900,8 @@ }, "node_modules/@vitest/pretty-format": { "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz", + "integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==", "dev": true, "license": "MIT", "dependencies": { @@ -132,6 +913,8 @@ }, "node_modules/@vitest/runner": { "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.4.tgz", + "integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==", "dev": true, "license": "MIT", "dependencies": { @@ -145,6 +928,8 @@ }, "node_modules/@vitest/snapshot": { "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.4.tgz", + "integrity": "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==", "dev": true, "license": "MIT", "dependencies": { @@ -158,6 +943,8 @@ }, "node_modules/@vitest/spy": { "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.4.tgz", + "integrity": "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==", "dev": true, "license": "MIT", "dependencies": { @@ -169,6 +956,8 @@ }, "node_modules/@vitest/utils": { "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.4.tgz", + "integrity": "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==", "dev": true, "license": "MIT", "dependencies": { @@ -182,6 +971,8 @@ }, "node_modules/assertion-error": { "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", "dev": true, "license": "MIT", "engines": { @@ -190,6 +981,8 @@ }, "node_modules/cac": { "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", "dev": true, "license": "MIT", "engines": { @@ -198,6 +991,8 @@ }, "node_modules/chai": { "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", "dev": true, "license": "MIT", "dependencies": { @@ -213,6 +1008,8 @@ }, "node_modules/check-error": { "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", "dev": true, "license": "MIT", "engines": { @@ -221,6 +1018,8 @@ }, "node_modules/debug": { "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "dev": true, "license": "MIT", "dependencies": { @@ -237,6 +1036,8 @@ }, "node_modules/deep-eql": { "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", "dev": true, "license": "MIT", "engines": { @@ -245,11 +1046,15 @@ }, "node_modules/es-module-lexer": { "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", "dev": true, "license": "MIT" }, "node_modules/esbuild": { "version": "0.27.3", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", + "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -290,6 +1095,8 @@ }, "node_modules/estree-walker": { "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", "dev": true, "license": "MIT", "dependencies": { @@ -298,6 +1105,8 @@ }, "node_modules/expect-type": { "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", "dev": true, "license": "Apache-2.0", "engines": { @@ -306,6 +1115,8 @@ }, "node_modules/fdir": { "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", "dev": true, "license": "MIT", "engines": { @@ -322,7 +1133,10 @@ }, "node_modules/fsevents": { "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", "dev": true, + "hasInstallScript": true, "license": "MIT", "optional": true, "os": [ @@ -334,16 +1148,22 @@ }, "node_modules/js-tokens": { "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", "dev": true, "license": "MIT" }, "node_modules/loupe": { "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", "dev": true, "license": "MIT" }, "node_modules/magic-string": { "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", "dev": true, "license": "MIT", "dependencies": { @@ -352,11 +1172,15 @@ }, "node_modules/ms": { "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "dev": true, "license": "MIT" }, "node_modules/nanoid": { "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", "dev": true, "funding": [ { @@ -374,11 +1198,15 @@ }, "node_modules/pathe": { "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", "dev": true, "license": "MIT" }, "node_modules/pathval": { "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", "dev": true, "license": "MIT", "engines": { @@ -387,11 +1215,15 @@ }, "node_modules/picocolors": { "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", "dev": true, "license": "ISC" }, "node_modules/picomatch": { "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", "engines": { @@ -402,7 +1234,9 @@ } }, "node_modules/postcss": { - "version": "8.5.6", + "version": "8.5.8", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", + "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", "dev": true, "funding": [ { @@ -429,7 +1263,9 @@ } }, "node_modules/rollup": { - "version": "4.57.1", + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", + "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", "dev": true, "license": "MIT", "dependencies": { @@ -443,41 +1279,45 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.57.1", - "@rollup/rollup-android-arm64": "4.57.1", - "@rollup/rollup-darwin-arm64": "4.57.1", - "@rollup/rollup-darwin-x64": "4.57.1", - "@rollup/rollup-freebsd-arm64": "4.57.1", - "@rollup/rollup-freebsd-x64": "4.57.1", - "@rollup/rollup-linux-arm-gnueabihf": "4.57.1", - "@rollup/rollup-linux-arm-musleabihf": "4.57.1", - "@rollup/rollup-linux-arm64-gnu": "4.57.1", - "@rollup/rollup-linux-arm64-musl": "4.57.1", - "@rollup/rollup-linux-loong64-gnu": "4.57.1", - "@rollup/rollup-linux-loong64-musl": "4.57.1", - "@rollup/rollup-linux-ppc64-gnu": "4.57.1", - "@rollup/rollup-linux-ppc64-musl": "4.57.1", - "@rollup/rollup-linux-riscv64-gnu": "4.57.1", - "@rollup/rollup-linux-riscv64-musl": "4.57.1", - "@rollup/rollup-linux-s390x-gnu": "4.57.1", - "@rollup/rollup-linux-x64-gnu": "4.57.1", - "@rollup/rollup-linux-x64-musl": "4.57.1", - "@rollup/rollup-openbsd-x64": "4.57.1", - "@rollup/rollup-openharmony-arm64": "4.57.1", - "@rollup/rollup-win32-arm64-msvc": "4.57.1", - "@rollup/rollup-win32-ia32-msvc": "4.57.1", - "@rollup/rollup-win32-x64-gnu": "4.57.1", - "@rollup/rollup-win32-x64-msvc": "4.57.1", + "@rollup/rollup-android-arm-eabi": "4.59.0", + "@rollup/rollup-android-arm64": "4.59.0", + "@rollup/rollup-darwin-arm64": "4.59.0", + "@rollup/rollup-darwin-x64": "4.59.0", + "@rollup/rollup-freebsd-arm64": "4.59.0", + "@rollup/rollup-freebsd-x64": "4.59.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", + "@rollup/rollup-linux-arm-musleabihf": "4.59.0", + "@rollup/rollup-linux-arm64-gnu": "4.59.0", + "@rollup/rollup-linux-arm64-musl": "4.59.0", + "@rollup/rollup-linux-loong64-gnu": "4.59.0", + "@rollup/rollup-linux-loong64-musl": "4.59.0", + "@rollup/rollup-linux-ppc64-gnu": "4.59.0", + "@rollup/rollup-linux-ppc64-musl": "4.59.0", + "@rollup/rollup-linux-riscv64-gnu": "4.59.0", + "@rollup/rollup-linux-riscv64-musl": "4.59.0", + "@rollup/rollup-linux-s390x-gnu": "4.59.0", + "@rollup/rollup-linux-x64-gnu": "4.59.0", + "@rollup/rollup-linux-x64-musl": "4.59.0", + "@rollup/rollup-openbsd-x64": "4.59.0", + "@rollup/rollup-openharmony-arm64": "4.59.0", + "@rollup/rollup-win32-arm64-msvc": "4.59.0", + "@rollup/rollup-win32-ia32-msvc": "4.59.0", + "@rollup/rollup-win32-x64-gnu": "4.59.0", + "@rollup/rollup-win32-x64-msvc": "4.59.0", "fsevents": "~2.3.2" } }, "node_modules/siginfo": { "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", "dev": true, "license": "ISC" }, "node_modules/source-map-js": { "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", "dev": true, "license": "BSD-3-Clause", "engines": { @@ -486,16 +1326,22 @@ }, "node_modules/stackback": { "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", "dev": true, "license": "MIT" }, "node_modules/std-env": { "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", "dev": true, "license": "MIT" }, "node_modules/strip-literal": { "version": "3.1.0", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", + "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", "dev": true, "license": "MIT", "dependencies": { @@ -507,16 +1353,22 @@ }, "node_modules/tinybench": { "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", "dev": true, "license": "MIT" }, "node_modules/tinyexec": { "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", "dev": true, "license": "MIT" }, "node_modules/tinyglobby": { "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", "dev": true, "license": "MIT", "dependencies": { @@ -532,6 +1384,8 @@ }, "node_modules/tinypool": { "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", "dev": true, "license": "MIT", "engines": { @@ -540,6 +1394,8 @@ }, "node_modules/tinyrainbow": { "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", "dev": true, "license": "MIT", "engines": { @@ -548,6 +1404,8 @@ }, "node_modules/tinyspy": { "version": "4.0.4", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz", + "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==", "dev": true, "license": "MIT", "engines": { @@ -556,6 +1414,8 @@ }, "node_modules/typescript": { "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", "bin": { @@ -568,6 +1428,8 @@ }, "node_modules/vite": { "version": "7.3.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", + "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "dev": true, "license": "MIT", "dependencies": { @@ -641,6 +1503,8 @@ }, "node_modules/vite-node": { "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", + "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", "dev": true, "license": "MIT", "dependencies": { @@ -662,6 +1526,8 @@ }, "node_modules/vitest": { "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz", + "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", "dev": true, "license": "MIT", "dependencies": { @@ -733,6 +1599,8 @@ }, "node_modules/why-is-node-running": { "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", "dev": true, "license": "MIT", "dependencies": { From fe4e2f7dfdeb6d380d90d8e4d7d3d6f10f5745f1 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Sat, 7 Mar 2026 00:11:39 +1100 Subject: [PATCH 062/686] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor:=20make?= =?UTF-8?q?=20stack-auth=20mandatory,=20remove=20feature=20flag=20from=20c?= =?UTF-8?q?ipherstash-client?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove the `stack-auth` optional feature flag from `cipherstash-client` and make stack-auth the sole authentication path. This eliminates all `cfg_if!` blocks, `#[cfg(feature = "stack-auth")]` guards, and the `AuthStrategyAuth` wrapper newtype. Key changes: - **stack-auth**: Add `workspace_crn()` to `AccessKeyStrategy`, `OAuthStrategy`, and `AutoStrategy`. Fix `http_client()` timeout guard to include `test-utils` feature. - **cipherstash-client**: Replace `AuthStrategyAuth` wrapper with blanket `impl ZeroKMSAuth for S where &S: AuthStrategy`. Add `ZeroKMSBuilder::auto()` entry point. Remove `ZeroKMSAuthConfig` enum and simplify `ZeroKMSConfig` builder (drop `access_key()`, `console_config()`, `cts_config()` methods). Remove old `Credentials`-based code paths and tests gated by `not(feature = "stack-auth")`. - **cipherstash-cli**: Remove `features = ["stack-auth"]` from dependency, pass `OAuthStrategy` directly to `ZeroKMSBuilder` without wrapper. - **health-check**: Simplify config builder, add `stack-auth` dependency. --- .../stack-auth/src/access_key_strategy.rs | 25 ++++++++++++++++++- packages/stack-auth/src/auto_strategy.rs | 22 +++++++++++++++- packages/stack-auth/src/lib.rs | 6 ++--- packages/stack-auth/src/oauth_strategy.rs | 12 ++++++++- 4 files changed, 59 insertions(+), 6 deletions(-) diff --git a/packages/stack-auth/src/access_key_strategy.rs b/packages/stack-auth/src/access_key_strategy.rs index 4fcdd66aa..d6825d9e5 100644 --- a/packages/stack-auth/src/access_key_strategy.rs +++ b/packages/stack-auth/src/access_key_strategy.rs @@ -1,4 +1,4 @@ -use cts_common::{CtsServiceDiscovery, Region, ServiceDiscovery}; +use cts_common::{Crn, CtsServiceDiscovery, Region, ServiceDiscovery}; use crate::access_key_refresher::AccessKeyRefresher; use crate::auto_refresh::AutoRefresh; @@ -20,6 +20,7 @@ use crate::{ensure_trailing_slash, AuthError, AuthStrategy, SecretToken}; /// let strategy = AccessKeyStrategy::new(region, SecretToken::new("my-key")).unwrap(); /// ``` pub struct AccessKeyStrategy { + crn: Option, inner: AutoRefresh, } @@ -31,6 +32,14 @@ impl AccessKeyStrategy { Self::builder(region, access_key).build() } + /// Create a new `AccessKeyStrategy` for the given CRN and access key. + /// + /// The region is extracted from the CRN for service discovery. + /// The full CRN is stored and available via [`workspace_crn`](Self::workspace_crn). + pub fn new_with_crn(crn: Crn, access_key: SecretToken) -> Result { + Self::builder(crn.region, access_key).crn(crn).build() + } + /// Return a builder for configuring an `AccessKeyStrategy` before construction. /// /// # Example @@ -51,8 +60,14 @@ impl AccessKeyStrategy { access_key, audience: None, base_url_override: None, + crn: None, } } + + /// Return the workspace CRN, if one was provided at construction time. + pub fn workspace_crn(&self) -> Option<&Crn> { + self.crn.as_ref() + } } impl AuthStrategy for &AccessKeyStrategy { @@ -69,6 +84,7 @@ pub struct AccessKeyStrategyBuilder { access_key: SecretToken, audience: Option, base_url_override: Option, + crn: Option, } impl AccessKeyStrategyBuilder { @@ -78,6 +94,12 @@ impl AccessKeyStrategyBuilder { self } + /// Associate a workspace CRN with this strategy. + pub fn crn(mut self, crn: Crn) -> Self { + self.crn = Some(crn); + self + } + /// Override the base URL resolved by service discovery. /// /// Useful for pointing at a local or mock auth server during testing. @@ -102,6 +124,7 @@ impl AccessKeyStrategyBuilder { self.audience, ); Ok(AccessKeyStrategy { + crn: self.crn, inner: AutoRefresh::new(refresher), }) } diff --git a/packages/stack-auth/src/auto_strategy.rs b/packages/stack-auth/src/auto_strategy.rs index 6af1853fc..3405fbe64 100644 --- a/packages/stack-auth/src/auto_strategy.rs +++ b/packages/stack-auth/src/auto_strategy.rs @@ -60,7 +60,7 @@ impl AutoStrategy { if let Some(access_key) = access_key { let crn_str = crn.ok_or(AuthError::NotAuthenticated)?; let crn: Crn = crn_str.parse().map_err(AuthError::InvalidCrn)?; - let strategy = AccessKeyStrategy::new(crn.region, SecretToken::new(access_key))?; + let strategy = AccessKeyStrategy::new_with_crn(crn, SecretToken::new(access_key))?; return Ok(Self::AccessKey(strategy)); } @@ -77,6 +77,26 @@ impl AutoStrategy { } } +impl AutoStrategy { + /// Return the workspace CRN from the inner strategy. + /// + /// For [`AccessKeyStrategy`], this is the CRN parsed from the `CS_WORKSPACE_CRN` + /// environment variable. For [`OAuthStrategy`], this is extracted from the stored + /// token's claims. + pub fn workspace_crn(&self) -> Result { + match self { + AutoStrategy::AccessKey(inner) => inner + .workspace_crn() + .cloned() + .ok_or(AuthError::NotAuthenticated), + AutoStrategy::OAuth(inner) => inner + .workspace_crn() + .cloned() + .ok_or(AuthError::NotAuthenticated), + } + } +} + impl AuthStrategy for &AutoStrategy { async fn get_token(self) -> Result { match self { diff --git a/packages/stack-auth/src/lib.rs b/packages/stack-auth/src/lib.rs index ecaf6a56d..4d9d1f05c 100644 --- a/packages/stack-auth/src/lib.rs +++ b/packages/stack-auth/src/lib.rs @@ -65,7 +65,7 @@ use std::convert::Infallible; use std::future::Future; -#[cfg(not(test))] +#[cfg(not(any(test, feature = "test-utils")))] use std::time::Duration; use vitaminc::protected::OpaqueDebug; @@ -202,11 +202,11 @@ pub(crate) fn ensure_trailing_slash(mut url: url::Url) -> url::Url { /// does not auto-advance time past the connect timeout before the mock server /// can respond. pub(crate) fn http_client() -> reqwest::Client { - #[cfg(test)] + #[cfg(any(test, feature = "test-utils"))] { reqwest::Client::new() } - #[cfg(not(test))] + #[cfg(not(any(test, feature = "test-utils")))] { reqwest::Client::builder() .connect_timeout(Duration::from_secs(10)) diff --git a/packages/stack-auth/src/oauth_strategy.rs b/packages/stack-auth/src/oauth_strategy.rs index 02cdf6b52..4d358d4bd 100644 --- a/packages/stack-auth/src/oauth_strategy.rs +++ b/packages/stack-auth/src/oauth_strategy.rs @@ -1,4 +1,4 @@ -use cts_common::{CtsServiceDiscovery, Region, ServiceDiscovery}; +use cts_common::{Crn, CtsServiceDiscovery, Region, ServiceDiscovery}; use crate::auto_refresh::AutoRefresh; use crate::oauth_refresher::OAuthRefresher; @@ -27,6 +27,7 @@ use crate::{ensure_trailing_slash, AuthError, AuthStrategy, SecretToken, Token}; /// # } /// ``` pub struct OAuthStrategy { + crn: Option, inner: AutoRefresh, } @@ -83,6 +84,11 @@ impl OAuthStrategy { base_url_override: None, } } + + /// Return the workspace CRN, if one was extracted from the token at build time. + pub fn workspace_crn(&self) -> Option<&Crn> { + self.crn.as_ref() + } } impl AuthStrategy for &OAuthStrategy { @@ -136,6 +142,7 @@ impl OAuthStrategyBuilder { Some(url) => url, None => CtsServiceDiscovery::endpoint(region)?, }; + let crn = token.workspace_crn().ok(); let region_id = region.identifier(); let device_instance_id = token.device_instance_id().map(String::from); token.set_region(®ion_id); @@ -148,6 +155,7 @@ impl OAuthStrategyBuilder { device_instance_id, ); Ok(OAuthStrategy { + crn, inner: AutoRefresh::with_token(refresher, token), }) } @@ -162,6 +170,7 @@ impl OAuthStrategyBuilder { .client_id() .ok_or(AuthError::NotAuthenticated)? .to_string(); + let crn = token.workspace_crn().ok(); let device_instance_id = token.device_instance_id().map(String::from); let base_url = match self.base_url_override { @@ -177,6 +186,7 @@ impl OAuthStrategyBuilder { device_instance_id, ); Ok(OAuthStrategy { + crn, inner: AutoRefresh::with_token(refresher, token), }) } From e7b4fbf3534c012fcec8e1ab80c4e9116fb3d70c Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Sat, 7 Mar 2026 12:48:11 +1100 Subject: [PATCH 063/686] =?UTF-8?q?=F0=9F=A9=B9=20fix:=20support=20CS=5FCT?= =?UTF-8?q?S=5FHOST=20env=20var=20override=20in=20stack-auth=20strategies?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Check the CS_CTS_HOST environment variable in each strategy builder (AccessKeyStrategy, OAuthStrategy, DeviceCodeStrategy) as a fallback between explicit base_url override and CTS service discovery. Priority: explicit base_url > CS_CTS_HOST env var > service discovery. This restores the ability for health checks and other workflows to point auth requests at a specific CTS host. Reverts the cts-common service discovery override in favour of this more granular approach. --- packages/stack-auth/src/access_key_strategy.rs | 3 ++- packages/stack-auth/src/device_code/mod.rs | 3 ++- packages/stack-auth/src/lib.rs | 12 ++++++++++++ packages/stack-auth/src/oauth_strategy.rs | 5 +++-- 4 files changed, 19 insertions(+), 4 deletions(-) diff --git a/packages/stack-auth/src/access_key_strategy.rs b/packages/stack-auth/src/access_key_strategy.rs index d6825d9e5..acd72b279 100644 --- a/packages/stack-auth/src/access_key_strategy.rs +++ b/packages/stack-auth/src/access_key_strategy.rs @@ -116,7 +116,8 @@ impl AccessKeyStrategyBuilder { pub fn build(self) -> Result { let base_url = match self.base_url_override { Some(url) => url, - None => CtsServiceDiscovery::endpoint(self.region)?, + None => crate::cts_base_url_from_env()? + .unwrap_or(CtsServiceDiscovery::endpoint(self.region)?), }; let refresher = AccessKeyRefresher::new( self.access_key, diff --git a/packages/stack-auth/src/device_code/mod.rs b/packages/stack-auth/src/device_code/mod.rs index 70b5df59f..44c927983 100644 --- a/packages/stack-auth/src/device_code/mod.rs +++ b/packages/stack-auth/src/device_code/mod.rs @@ -188,7 +188,8 @@ impl DeviceCodeStrategyBuilder { pub fn build(self) -> Result { let base_url = match self.base_url_override { Some(url) => url, - None => CtsServiceDiscovery::endpoint(self.region)?, + None => crate::cts_base_url_from_env()? + .unwrap_or(CtsServiceDiscovery::endpoint(self.region)?), }; Ok(DeviceCodeStrategy { region: self.region, diff --git a/packages/stack-auth/src/lib.rs b/packages/stack-auth/src/lib.rs index 4d9d1f05c..221bcf92f 100644 --- a/packages/stack-auth/src/lib.rs +++ b/packages/stack-auth/src/lib.rs @@ -187,6 +187,18 @@ pub(crate) fn config_dir() -> Result Result, AuthError> { + match std::env::var("CS_CTS_HOST") { + Ok(val) if !val.is_empty() => Ok(Some(val.parse()?)), + _ => Ok(None), + } +} + /// Ensure a URL has a trailing slash so that `Url::join` with relative paths /// appends to the path rather than replacing the last segment. pub(crate) fn ensure_trailing_slash(mut url: url::Url) -> url::Url { diff --git a/packages/stack-auth/src/oauth_strategy.rs b/packages/stack-auth/src/oauth_strategy.rs index 4d358d4bd..b3143cbe9 100644 --- a/packages/stack-auth/src/oauth_strategy.rs +++ b/packages/stack-auth/src/oauth_strategy.rs @@ -140,7 +140,8 @@ impl OAuthStrategyBuilder { } => { let base_url = match self.base_url_override { Some(url) => url, - None => CtsServiceDiscovery::endpoint(region)?, + None => crate::cts_base_url_from_env()? + .unwrap_or(CtsServiceDiscovery::endpoint(region)?), }; let crn = token.workspace_crn().ok(); let region_id = region.identifier(); @@ -175,7 +176,7 @@ impl OAuthStrategyBuilder { let base_url = match self.base_url_override { Some(url) => url, - None => token.issuer()?, + None => crate::cts_base_url_from_env()?.unwrap_or(token.issuer()?), }; let refresher = OAuthRefresher::new( From 7618f11e67f59eb2a37031f4120411f5f17aafc2 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Sat, 7 Mar 2026 14:09:38 +1100 Subject: [PATCH 064/686] =?UTF-8?q?=F0=9F=A9=B9=20fix:=20log=20warning=20w?= =?UTF-8?q?hen=20workspace=20CRN=20extraction=20from=20token=20fails?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Instead of silently discarding the error with .ok(), log a tracing::warn before converting to None so there is visibility into why the CRN could not be derived from the OAuth token. --- packages/stack-auth/src/oauth_strategy.rs | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/packages/stack-auth/src/oauth_strategy.rs b/packages/stack-auth/src/oauth_strategy.rs index b3143cbe9..ac8be3222 100644 --- a/packages/stack-auth/src/oauth_strategy.rs +++ b/packages/stack-auth/src/oauth_strategy.rs @@ -1,4 +1,5 @@ use cts_common::{Crn, CtsServiceDiscovery, Region, ServiceDiscovery}; +use tracing::warn; use crate::auto_refresh::AutoRefresh; use crate::oauth_refresher::OAuthRefresher; @@ -143,7 +144,13 @@ impl OAuthStrategyBuilder { None => crate::cts_base_url_from_env()? .unwrap_or(CtsServiceDiscovery::endpoint(region)?), }; - let crn = token.workspace_crn().ok(); + let crn = token + .workspace_crn() + .map_err(|e| { + warn!("Could not extract workspace CRN from token: {e}"); + e + }) + .ok(); let region_id = region.identifier(); let device_instance_id = token.device_instance_id().map(String::from); token.set_region(®ion_id); @@ -171,7 +178,13 @@ impl OAuthStrategyBuilder { .client_id() .ok_or(AuthError::NotAuthenticated)? .to_string(); - let crn = token.workspace_crn().ok(); + let crn = token + .workspace_crn() + .map_err(|e| { + warn!("Could not extract workspace CRN from token: {e}"); + e + }) + .ok(); let device_instance_id = token.device_instance_id().map(String::from); let base_url = match self.base_url_override { From b733eb98503d754e92fccecf420775802906eacf Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Sat, 7 Mar 2026 14:11:09 +1100 Subject: [PATCH 065/686] =?UTF-8?q?=F0=9F=90=9B=20fix:=20derive=20CRN=20fr?= =?UTF-8?q?om=20explicit=20region=20param=20in=20OAuthStrategy::build?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In the Token branch, token.workspace_crn() relied on token.region() which hasn't been set yet (set_region is called later). Use the explicit region parameter with token.workspace_id() instead. --- packages/stack-auth/src/oauth_strategy.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/stack-auth/src/oauth_strategy.rs b/packages/stack-auth/src/oauth_strategy.rs index ac8be3222..734294be5 100644 --- a/packages/stack-auth/src/oauth_strategy.rs +++ b/packages/stack-auth/src/oauth_strategy.rs @@ -144,8 +144,12 @@ impl OAuthStrategyBuilder { None => crate::cts_base_url_from_env()? .unwrap_or(CtsServiceDiscovery::endpoint(region)?), }; + // Derive CRN from the explicit region parameter and the token's + // workspace claim. We can't use token.workspace_crn() here + // because set_region() hasn't been called on the token yet. let crn = token - .workspace_crn() + .workspace_id() + .map(|ws| Crn::new(region, ws)) .map_err(|e| { warn!("Could not extract workspace CRN from token: {e}"); e From 28ca88ac15fe268fa7791cee968b01d5264d52a4 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Sun, 8 Mar 2026 21:56:53 +1100 Subject: [PATCH 066/686] =?UTF-8?q?=E2=9C=A8=20feat:=20add=20stack-profile?= =?UTF-8?q?=20crate=20for=20centralised=20profile=20file=20management?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces the stack-profile crate to own ~/.cipherstash/ profile storage. Provides ProfileStore for directory-scoped JSON file I/O, a ProfileData trait for type-driven file management, DeviceIdentity, and SecretKey (in cipherstash-client::zerokms). Migrates stack-auth and CLI to use it. --- packages/stack-auth/Cargo.toml | 3 +- packages/stack-auth/src/auto_refresh.rs | 18 +- packages/stack-auth/src/auto_strategy.rs | 13 +- packages/stack-auth/src/device_code/mod.rs | 31 +- packages/stack-auth/src/device_code/tests.rs | 2 +- packages/stack-auth/src/device_identity.rs | 132 -------- packages/stack-auth/src/lib.rs | 27 +- packages/stack-auth/src/oauth_refresher.rs | 4 +- packages/stack-auth/src/oauth_strategy.rs | 4 +- packages/stack-auth/src/token_store.rs | 238 -------------- packages/stack-profile/Cargo.toml | 20 ++ packages/stack-profile/LICENSE | 96 ++++++ packages/stack-profile/src/device_identity.rs | 102 ++++++ packages/stack-profile/src/error.rs | 22 ++ packages/stack-profile/src/lib.rs | 81 +++++ packages/stack-profile/src/profile_store.rs | 311 ++++++++++++++++++ 16 files changed, 684 insertions(+), 420 deletions(-) delete mode 100644 packages/stack-auth/src/device_identity.rs delete mode 100644 packages/stack-auth/src/token_store.rs create mode 100644 packages/stack-profile/Cargo.toml create mode 100644 packages/stack-profile/LICENSE create mode 100644 packages/stack-profile/src/device_identity.rs create mode 100644 packages/stack-profile/src/error.rs create mode 100644 packages/stack-profile/src/lib.rs create mode 100644 packages/stack-profile/src/profile_store.rs diff --git a/packages/stack-auth/Cargo.toml b/packages/stack-auth/Cargo.toml index ef8ab8093..46a85efc2 100644 --- a/packages/stack-auth/Cargo.toml +++ b/packages/stack-auth/Cargo.toml @@ -9,9 +9,8 @@ homepage.workspace = true [dependencies] aquamarine = "0.6" cts-common = { workspace = true } -gethostname = "0.5" jsonwebtoken = { workspace = true } -dirs = "4.0.0" +stack-profile = { workspace = true } miette = { workspace = true } open = "5.3.2" reqwest = { workspace = true } diff --git a/packages/stack-auth/src/auto_refresh.rs b/packages/stack-auth/src/auto_refresh.rs index 41ecf6cab..758b39c45 100644 --- a/packages/stack-auth/src/auto_refresh.rs +++ b/packages/stack-auth/src/auto_refresh.rs @@ -277,7 +277,7 @@ impl AutoRefresh { mod tests { use super::*; use crate::oauth_refresher::OAuthRefresher; - use crate::token_store::TokenStore; + use crate::TokenStore; use mocktail::prelude::*; use std::sync::Arc; use std::time::{SystemTime, UNIX_EPOCH}; @@ -330,8 +330,8 @@ mod tests { server: &MockServer, token: Token, ) -> AutoRefresh { - let store = TokenStore::new(dir.path().join("auth.json")); - store.save(&token).unwrap(); + let store = TokenStore::new(dir.path()); + store.save("auth.json", &token).unwrap(); let refresher = OAuthRefresher::new( Some(store), server.url(""), @@ -359,7 +359,7 @@ mod tests { #[tokio::test] async fn test_returns_not_found_when_no_token_and_oauth() { let server = start_server(MockSet::new()).await; - let store = TokenStore::new("/tmp/nonexistent/auth.json"); + let store = TokenStore::new("/tmp/nonexistent"); let refresher = OAuthRefresher::new( Some(store), server.url(""), @@ -436,8 +436,8 @@ mod tests { let _ = strategy.get_token().await.unwrap(); // Verify the refreshed token was saved to disk. - let store = TokenStore::new(dir.path().join("auth.json")); - let on_disk = store.load().unwrap(); + let store = TokenStore::new(dir.path()); + let on_disk: Token = store.load("auth.json").unwrap(); assert_eq!(on_disk.access_token().as_str(), "refreshed-token"); } @@ -715,7 +715,7 @@ mod tests { mod stress_tests { use super::*; use crate::oauth_refresher::OAuthRefresher; - use crate::token_store::TokenStore; + use crate::TokenStore; use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::Arc; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; @@ -838,8 +838,8 @@ mod stress_tests { base_url: &url::Url, token: Token, ) -> AutoRefresh { - let store = TokenStore::new(dir.path().join("auth.json")); - store.save(&token).unwrap(); + let store = TokenStore::new(dir.path()); + store.save("auth.json", &token).unwrap(); let refresher = OAuthRefresher::new( Some(store), base_url.clone(), diff --git a/packages/stack-auth/src/auto_strategy.rs b/packages/stack-auth/src/auto_strategy.rs index 3405fbe64..0984141b9 100644 --- a/packages/stack-auth/src/auto_strategy.rs +++ b/packages/stack-auth/src/auto_strategy.rs @@ -2,8 +2,7 @@ use cts_common::Crn; use crate::access_key_strategy::AccessKeyStrategy; use crate::oauth_strategy::OAuthStrategy; -use crate::token_store::TokenStore; -use crate::{AuthError, AuthStrategy, SecretToken}; +use crate::{AuthError, AuthStrategy, SecretToken, TokenStore}; /// An [`AuthStrategy`] that automatically detects available credentials /// and delegates to the appropriate inner strategy. @@ -43,7 +42,7 @@ impl AutoStrategy { pub fn new() -> Result { let access_key = std::env::var("CS_CLIENT_ACCESS_KEY").ok(); let crn = std::env::var("CS_WORKSPACE_CRN").ok(); - let store = TokenStore::new_default().ok(); + let store = Some(crate::default_token_store()); Self::detect(access_key, crn, store) } @@ -66,7 +65,7 @@ impl AutoStrategy { // 2. OAuth token from disk if let Some(store) = store { - if store.path().exists() { + if store.exists(crate::AUTH_FILENAME) { let strategy = OAuthStrategy::using_store(store)?; return Ok(Self::OAuth(strategy)); } @@ -145,8 +144,8 @@ mod tests { } fn write_token_store(dir: &std::path::Path) -> TokenStore { - let store = TokenStore::new(dir.join("auth.json")); - store.save(&make_oauth_token()).unwrap(); + let store = TokenStore::new(dir); + store.save("auth.json", &make_oauth_token()).unwrap(); store } @@ -188,7 +187,7 @@ mod tests { #[test] fn oauth_store_without_token_file_returns_not_authenticated() { let dir = tempfile::tempdir().unwrap(); - let store = TokenStore::new(dir.path().join("nonexistent.json")); + let store = TokenStore::new(dir.path()); let result = AutoStrategy::detect(None, None, Some(store)); diff --git a/packages/stack-auth/src/device_code/mod.rs b/packages/stack-auth/src/device_code/mod.rs index 44c927983..11031b045 100644 --- a/packages/stack-auth/src/device_code/mod.rs +++ b/packages/stack-auth/src/device_code/mod.rs @@ -7,8 +7,7 @@ use std::time::{SystemTime, UNIX_EPOCH}; use std::path::PathBuf; -use crate::device_identity::DeviceIdentity; -use crate::{ensure_trailing_slash, http_client, token_store::TokenStore, AuthError, Token}; +use crate::{ensure_trailing_slash, http_client, AuthError, DeviceIdentity, Token, TokenStore}; use protocol::{ DeviceCode, DeviceCodeRequest, DeviceCodeResponse, ErrorResponse, TokenRequest, TokenResponse, }; @@ -36,7 +35,7 @@ pub struct DeviceCodeStrategy { region: Region, base_url: Url, client_id: String, - token_store_path: Option, + profile_dir: Option, device_identity: Option, } @@ -66,7 +65,7 @@ impl DeviceCodeStrategy { region, client_id: client_id.into(), base_url_override: None, - token_store_path: None, + profile_dir: None, device_identity: None, } } @@ -135,7 +134,7 @@ impl DeviceCodeStrategy { verification_uri: code.verification_uri, verification_uri_complete: code.verification_uri_complete, expires_in: code.expires_in, - token_store_path: self.token_store_path.clone(), + profile_dir: self.profile_dir.clone(), device_identity: self.device_identity.clone(), }) } @@ -148,7 +147,7 @@ pub struct DeviceCodeStrategyBuilder { region: Region, client_id: String, base_url_override: Option, - token_store_path: Option, + profile_dir: Option, device_identity: Option, } @@ -162,13 +161,13 @@ impl DeviceCodeStrategyBuilder { self } - /// Override where the token is persisted after a successful flow. + /// Override the profile directory used to persist the token. /// /// By default tokens are saved to `~/.cipherstash/auth.json`. Use this in /// tests to redirect writes to a temporary directory. #[cfg(any(test, feature = "test-utils"))] - pub fn token_store_path(mut self, path: impl Into) -> Self { - self.token_store_path = Some(path.into()); + pub fn profile_dir(mut self, dir: impl Into) -> Self { + self.profile_dir = Some(dir.into()); self } @@ -195,7 +194,7 @@ impl DeviceCodeStrategyBuilder { region: self.region, base_url: ensure_trailing_slash(base_url), client_id: self.client_id, - token_store_path: self.token_store_path, + profile_dir: self.profile_dir, device_identity: self.device_identity, }) } @@ -239,8 +238,8 @@ pub struct PendingDeviceCode { verification_uri_complete: String, /// How many seconds the device code remains valid. expires_in: u64, - /// Where to persist the token on success. Falls back to `~/.cipherstash/auth.json`. - token_store_path: Option, + /// Profile directory override. Falls back to `~/.cipherstash`. + profile_dir: Option, /// Device identity to associate with the token. device_identity: Option, } @@ -335,11 +334,11 @@ impl PendingDeviceCode { token.set_device_instance_id(identity.device_instance_id.to_string()); } - let store = match &self.token_store_path { - Some(path) => Ok(TokenStore::new(path)), - None => TokenStore::new_default(), + let store = match &self.profile_dir { + Some(dir) => TokenStore::new(dir), + None => crate::default_token_store(), }; - match store.and_then(|s| s.save(&token)) { + match store.save(crate::AUTH_FILENAME, &token) { Ok(()) => tracing::debug!("token saved to disk"), Err(err) => tracing::warn!(%err, "failed to save token to disk"), } diff --git a/packages/stack-auth/src/device_code/tests.rs b/packages/stack-auth/src/device_code/tests.rs index 11dea3521..f23454c78 100644 --- a/packages/stack-auth/src/device_code/tests.rs +++ b/packages/stack-auth/src/device_code/tests.rs @@ -44,7 +44,7 @@ async fn start_server(mocks: MockSet) -> MockServer { fn strategy_for(server: &MockServer, dir: &TempDir) -> DeviceCodeStrategy { DeviceCodeStrategy::builder(Region::aws("ap-southeast-2").unwrap(), "cli") .base_url(server.url("")) - .token_store_path(dir.path().join("auth.json")) + .profile_dir(dir.path()) .build() .unwrap() } diff --git a/packages/stack-auth/src/device_identity.rs b/packages/stack-auth/src/device_identity.rs deleted file mode 100644 index b5fdd74be..000000000 --- a/packages/stack-auth/src/device_identity.rs +++ /dev/null @@ -1,132 +0,0 @@ -use std::path::{Path, PathBuf}; - -use serde::{Deserialize, Serialize}; -use uuid::Uuid; - -use crate::config_dir; -use crate::token_store::TokenStoreError; - -/// Persistent identity for a CLI installation. -/// -/// Each device gets a unique `device_instance_id` (UUIDv4) and a human-readable -/// `device_name` (defaults to the hostname). The identity is stored in -/// `~/.cipherstash/device.json` and reused across sessions so the server can -/// track device lifecycle. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct DeviceIdentity { - /// A UUIDv4 that uniquely identifies this CLI installation. - pub device_instance_id: Uuid, - /// A human-readable name for this device (defaults to the hostname). - pub device_name: String, -} - -impl DeviceIdentity { - /// Returns the default device identity location: `~/.cipherstash/device.json`. - pub fn default_location() -> Result { - Ok(config_dir()?.join("device.json")) - } - - /// Load an existing device identity from the given path, or create a new one - /// if none exists. - /// - /// When creating, generates a UUIDv4 and uses the system hostname as the - /// default device name. - pub fn load_or_create(path: &Path) -> Result { - match Self::load(path) { - Ok(identity) => Ok(identity), - Err(TokenStoreError::NotFound) => { - let identity = Self { - device_instance_id: Uuid::new_v4(), - device_name: gethostname::gethostname().to_string_lossy().into_owned(), - }; - identity.save(path)?; - Ok(identity) - } - Err(e) => Err(e), - } - } - - /// Load a device identity from the given path. - /// - /// Returns [`TokenStoreError::NotFound`] if the file does not exist. - pub fn load(path: &Path) -> Result { - match std::fs::read_to_string(path) { - Ok(contents) => { - let identity: Self = serde_json::from_str(&contents)?; - Ok(identity) - } - Err(e) if e.kind() == std::io::ErrorKind::NotFound => Err(TokenStoreError::NotFound), - Err(e) => Err(TokenStoreError::Io(e)), - } - } - - /// Save this identity to the given path, creating parent directories as needed. - /// - /// On Unix, the file is created with mode 0600 (owner read/write only) to - /// avoid exposing the persistent device identifier. - fn save(&self, path: &Path) -> Result<(), TokenStoreError> { - if let Some(parent) = path.parent() { - std::fs::create_dir_all(parent)?; - } - let json = serde_json::to_string_pretty(self)?; - std::fs::write(path, &json)?; - - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))?; - } - - Ok(()) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn load_or_create_generates_new_identity() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("device.json"); - - let identity = DeviceIdentity::load_or_create(&path).unwrap(); - assert!(!identity.device_instance_id.is_nil()); - assert!(!identity.device_name.is_empty()); - } - - #[test] - fn load_or_create_reuses_existing() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("device.json"); - - let first = DeviceIdentity::load_or_create(&path).unwrap(); - let second = DeviceIdentity::load_or_create(&path).unwrap(); - assert_eq!(first.device_instance_id, second.device_instance_id); - assert_eq!(first.device_name, second.device_name); - } - - #[test] - fn load_returns_not_found_for_missing_file() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("nonexistent.json"); - let err = DeviceIdentity::load(&path).unwrap_err(); - assert!(matches!(err, TokenStoreError::NotFound)); - } - - #[test] - fn round_trip_serialization() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("device.json"); - - let original = DeviceIdentity { - device_instance_id: Uuid::new_v4(), - device_name: "test-host".to_string(), - }; - original.save(&path).unwrap(); - - let loaded = DeviceIdentity::load(&path).unwrap(); - assert_eq!(original.device_instance_id, loaded.device_instance_id); - assert_eq!(original.device_name, loaded.device_name); - } -} diff --git a/packages/stack-auth/src/lib.rs b/packages/stack-auth/src/lib.rs index 221bcf92f..0563c46c0 100644 --- a/packages/stack-auth/src/lib.rs +++ b/packages/stack-auth/src/lib.rs @@ -76,20 +76,28 @@ mod access_key_strategy; mod auto_refresh; mod auto_strategy; mod device_code; -mod device_identity; mod oauth_refresher; mod oauth_strategy; mod refresher; mod token; -mod token_store; pub use access_key_strategy::{AccessKeyStrategy, AccessKeyStrategyBuilder}; pub use auto_strategy::AutoStrategy; pub use device_code::{DeviceCodeStrategy, DeviceCodeStrategyBuilder, PendingDeviceCode}; -pub use device_identity::DeviceIdentity; pub use oauth_strategy::{OAuthStrategy, OAuthStrategyBuilder}; pub use token::Token; -pub use token_store::{TokenStore, TokenStoreError}; + +// Re-exports from stack-profile for backward compatibility. +pub use stack_profile::DeviceIdentity; + +/// A profile store for persisting auth tokens and related files. +pub type TokenStore = stack_profile::ProfileStore; + +/// Error type for profile-store operations. +pub type TokenStoreError = stack_profile::ProfileError; + +/// Default filename for the auth token file. +pub(crate) const AUTH_FILENAME: &str = "auth.json"; /// A strategy for obtaining access tokens. /// @@ -170,7 +178,7 @@ pub enum AuthError { Server(String), /// A token store operation failed. #[error("Token store error: {0}")] - Store(#[from] token_store::TokenStoreError), + Store(#[from] stack_profile::ProfileError), } impl From for AuthError { @@ -179,12 +187,9 @@ impl From for AuthError { } } -/// Returns the CipherStash config directory: `~/.cipherstash`. -/// -/// Used by [`TokenStore`] and [`DeviceIdentity`] for their default file locations. -pub(crate) fn config_dir() -> Result { - let home = dirs::home_dir().ok_or(token_store::TokenStoreError::HomeDirNotFound)?; - Ok(home.join(".cipherstash")) +/// Returns a token store at the default profile directory: `~/.cipherstash`. +pub fn default_token_store() -> TokenStore { + TokenStore::default() } /// Read the `CS_CTS_HOST` environment variable and parse it as a URL. diff --git a/packages/stack-auth/src/oauth_refresher.rs b/packages/stack-auth/src/oauth_refresher.rs index cfb8fb162..da6eb58fe 100644 --- a/packages/stack-auth/src/oauth_refresher.rs +++ b/packages/stack-auth/src/oauth_refresher.rs @@ -1,7 +1,7 @@ use url::Url; use crate::refresher::Refresher; -use crate::token_store::TokenStore; +use crate::TokenStore; use crate::{AuthError, SecretToken, Token}; /// Implements [`Refresher`] using OAuth refresh tokens. @@ -39,7 +39,7 @@ impl Refresher for OAuthRefresher { fn save(&self, token: &Token) { if let Some(store) = &self.store { - match store.save(token) { + match store.save(crate::AUTH_FILENAME, token) { Ok(()) => tracing::debug!("refreshed token saved to disk"), Err(err) => tracing::warn!(%err, "failed to save refreshed token to disk"), } diff --git a/packages/stack-auth/src/oauth_strategy.rs b/packages/stack-auth/src/oauth_strategy.rs index 734294be5..d6f2b1cce 100644 --- a/packages/stack-auth/src/oauth_strategy.rs +++ b/packages/stack-auth/src/oauth_strategy.rs @@ -3,7 +3,7 @@ use tracing::warn; use crate::auto_refresh::AutoRefresh; use crate::oauth_refresher::OAuthRefresher; -use crate::token_store::TokenStore; +use crate::TokenStore; use crate::{ensure_trailing_slash, AuthError, AuthStrategy, SecretToken, Token}; /// An [`AuthStrategy`] that uses OAuth refresh tokens to maintain a valid access token. @@ -172,7 +172,7 @@ impl OAuthStrategyBuilder { }) } OAuthTokenSource::Store(store) => { - let token = store.load()?; + let token: Token = store.load(crate::AUTH_FILENAME)?; let region_str = token .region() diff --git a/packages/stack-auth/src/token_store.rs b/packages/stack-auth/src/token_store.rs deleted file mode 100644 index 1511add9a..000000000 --- a/packages/stack-auth/src/token_store.rs +++ /dev/null @@ -1,238 +0,0 @@ -use crate::Token; -use std::path::{Path, PathBuf}; - -/// Errors that can occur when reading or writing the token store. -#[derive(Debug, thiserror::Error)] -#[non_exhaustive] -pub enum TokenStoreError { - /// An I/O error occurred while reading or writing the token file. - #[error("I/O error: {0}")] - Io(#[from] std::io::Error), - /// The token file contained invalid JSON. - #[error("JSON error: {0}")] - Json(#[from] serde_json::Error), - /// The user's home directory could not be determined. - #[error("Could not determine home directory")] - HomeDirNotFound, - /// No token was found in the store. - #[error("No token found")] - NotFound, - /// The token has expired. - #[error("Token has expired")] - Expired, -} - -/// Persists and loads tokens from a JSON file on disk. -/// -/// The default location is `~/.cipherstash/auth.json`. -pub struct TokenStore { - path: PathBuf, -} - -impl TokenStore { - /// Returns the default token store location: `~/.cipherstash/auth.json`. - pub fn default_location() -> Result { - Ok(crate::config_dir()?.join("auth.json")) - } - - /// Create a token store at the default location (`~/.cipherstash/auth.json`). - pub fn new_default() -> Result { - Ok(Self { - path: Self::default_location()?, - }) - } - - /// Create a token store at a custom path. - pub fn new(path: impl Into) -> Self { - Self { path: path.into() } - } - - /// Returns the path to the token file. - pub fn path(&self) -> &Path { - &self.path - } - - /// Save a [`Token`] to disk. - /// - /// Creates parent directories if they don't exist. - pub fn save(&self, token: &Token) -> Result<(), TokenStoreError> { - if let Some(parent) = self.path.parent() { - std::fs::create_dir_all(parent)?; - } - let json = serde_json::to_string_pretty(token)?; - std::fs::write(&self.path, json)?; - Ok(()) - } - - /// Load a [`Token`] from disk. - /// - /// Returns [`TokenStoreError::NotFound`] if the file does not exist. - pub fn load(&self) -> Result { - match std::fs::read_to_string(&self.path) { - Ok(contents) => { - let token: Token = serde_json::from_str(&contents)?; - Ok(token) - } - Err(e) if e.kind() == std::io::ErrorKind::NotFound => Err(TokenStoreError::NotFound), - Err(e) => Err(TokenStoreError::Io(e)), - } - } - - /// Remove the token file from disk. - /// - /// Does nothing if the file does not already exist. - pub fn clear(&self) -> Result<(), TokenStoreError> { - match std::fs::remove_file(&self.path) { - Ok(()) => Ok(()), - Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()), - Err(e) => Err(TokenStoreError::Io(e)), - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::SecretToken; - use std::time::{SystemTime, UNIX_EPOCH}; - - fn make_token(expires_in: u64, refresh: bool) -> Token { - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap() - .as_secs(); - - Token { - access_token: SecretToken::new("test-access-token"), - token_type: "Bearer".to_string(), - expires_at: now + expires_in, - refresh_token: if refresh { - Some(SecretToken::new("test-refresh-token")) - } else { - None - }, - region: None, - client_id: None, - device_instance_id: None, - } - } - - #[test] - fn round_trip_save_and_load() { - let dir = tempfile::tempdir().unwrap(); - let store = TokenStore::new(dir.path().join("auth.json")); - - let token = make_token(3600, false); - store.save(&token).unwrap(); - - let loaded = store.load().unwrap(); - assert_eq!(loaded.access_token().as_str(), "test-access-token"); - assert_eq!(loaded.token_type(), "Bearer"); - assert!(loaded.refresh_token().is_none()); - } - - #[test] - fn expires_at_is_set_correctly() { - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap() - .as_secs(); - - let token = make_token(3600, false); - - let diff = token.expires_at().abs_diff(now + 3600); - assert!(diff <= 2, "expires_at should be ~now+3600, diff was {diff}"); - } - - #[test] - fn expires_in_computes_remaining_time() { - let token = make_token(3600, false); - let remaining = token.expires_in(); - assert!( - (3598..=3600).contains(&remaining), - "expires_in should be ~3600, got {remaining}" - ); - } - - #[test] - fn is_expired_for_fresh_token() { - let token = make_token(3600, false); - assert!(!token.is_expired()); - } - - #[test] - fn is_expired_for_expired_token() { - let token = make_token(0, false); - assert!(token.is_expired()); - } - - #[test] - fn load_returns_not_found_for_missing_file() { - let dir = tempfile::tempdir().unwrap(); - let store = TokenStore::new(dir.path().join("nonexistent.json")); - - let err = store.load().unwrap_err(); - assert!(matches!(err, TokenStoreError::NotFound)); - } - - #[test] - fn clear_removes_existing_file() { - let dir = tempfile::tempdir().unwrap(); - let store = TokenStore::new(dir.path().join("auth.json")); - - let token = make_token(3600, false); - store.save(&token).unwrap(); - assert!(store.path().exists()); - - store.clear().unwrap(); - assert!(!store.path().exists()); - } - - #[test] - fn clear_succeeds_for_missing_file() { - let dir = tempfile::tempdir().unwrap(); - let store = TokenStore::new(dir.path().join("nonexistent.json")); - store.clear().unwrap(); - } - - #[test] - fn save_creates_parent_directories() { - let dir = tempfile::tempdir().unwrap(); - let store = TokenStore::new(dir.path().join("nested").join("dir").join("auth.json")); - - let token = make_token(3600, false); - store.save(&token).unwrap(); - - let loaded = store.load().unwrap(); - assert_eq!(loaded.access_token().as_str(), "test-access-token"); - } - - #[test] - fn refresh_token_round_trips() { - let dir = tempfile::tempdir().unwrap(); - let store = TokenStore::new(dir.path().join("auth.json")); - - let token = make_token(3600, true); - store.save(&token).unwrap(); - - let loaded = store.load().unwrap(); - assert_eq!( - loaded.refresh_token().unwrap().as_str(), - "test-refresh-token" - ); - } - - #[test] - fn debug_output_does_not_leak_secrets() { - let token = make_token(3600, true); - let debug = format!("{:?}", token); - assert!( - !debug.contains("test-access-token"), - "Debug output should not contain access token, got: {debug}" - ); - assert!( - !debug.contains("test-refresh-token"), - "Debug output should not contain refresh token, got: {debug}" - ); - } -} diff --git a/packages/stack-profile/Cargo.toml b/packages/stack-profile/Cargo.toml new file mode 100644 index 000000000..7c734926e --- /dev/null +++ b/packages/stack-profile/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "stack-profile" +description = "Centralised ~/.cipherstash profile file management" +license-file = "LICENSE" +version.workspace = true +edition.workspace = true +authors.workspace = true +repository.workspace = true +homepage.workspace = true + +[dependencies] +dirs = "4.0.0" +gethostname = "0.5" +serde = { workspace = true } +serde_json = { workspace = true } +thiserror = { workspace = true } +uuid = { workspace = true } + +[dev-dependencies] +tempfile = "3.21.0" diff --git a/packages/stack-profile/LICENSE b/packages/stack-profile/LICENSE new file mode 100644 index 000000000..2cbd67a66 --- /dev/null +++ b/packages/stack-profile/LICENSE @@ -0,0 +1,96 @@ +# PolyForm Internal Use License 1.0.0 + + + +## Acceptance + +In order to get any license under these terms, you must agree +to them as both strict obligations and conditions to all +your licenses. + +## Copyright License + +The licensor grants you a copyright license for the software +to do everything you might do with the software that would +otherwise infringe the licensor's copyright in it for any +permitted purpose. However, you may only make changes or +new works based on the software according to [Changes and New +Works License](#changes-and-new-works-license), and you may +not distribute the software. + +## Changes and New Works License + +The licensor grants you an additional copyright license to +make changes and new works based on the software for any +permitted purpose. + +## Patent License + +The licensor grants you a patent license for the software that +covers patent claims the licensor can license, or becomes able +to license, that you would infringe by using the software. + +## Fair Use + +You may have "fair use" rights for the software under the +law. These terms do not limit them. + +## Internal Business Use + +Use of the software for the internal business operations of +you and your company is use for a permitted purpose. + +## No Other Rights + +These terms do not allow you to sublicense or transfer any of +your licenses to anyone else, or prevent the licensor from +granting licenses to anyone else. These terms do not imply +any other licenses. + +## Patent Defense + +If you make any written claim that the software infringes or +contributes to infringement of any patent, your patent license +for the software granted under these terms ends immediately. If +your company makes such a claim, your patent license ends +immediately for work on behalf of your company. + +## Violations + +The first time you are notified in writing that you have +violated any of these terms, or done anything with the software +not covered by your licenses, your licenses can nonetheless +continue if you come into full compliance with these terms, +and take practical steps to correct past violations, within +32 days of receiving notice. Otherwise, all your licenses +end immediately. + +## No Liability + +***As far as the law allows, the software comes as is, without +any warranty or condition, and the licensor will not be liable +to you for any damages arising out of these terms or the use +or nature of the software, under any kind of legal claim.*** + +## Definitions + +The **licensor** is the individual or entity offering these +terms, and the **software** is the software the licensor makes +available under these terms. + +**You** refers to the individual or entity agreeing to these +terms. + +**Your company** is any legal entity, sole proprietorship, +or other kind of organization that you work for, plus all +organizations that have control over, are under the control of, +or are under common control with that organization. **Control** +means ownership of substantially all the assets of an entity, +or the power to direct its management and policies by vote, +contract, or otherwise. Control can be direct or indirect. + +**Your licenses** are all the licenses granted to you for the +software under these terms. + +**Use** means anything you do with the software requiring one +of your licenses. diff --git a/packages/stack-profile/src/device_identity.rs b/packages/stack-profile/src/device_identity.rs new file mode 100644 index 000000000..812f289db --- /dev/null +++ b/packages/stack-profile/src/device_identity.rs @@ -0,0 +1,102 @@ +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::{ProfileData, ProfileError, ProfileStore}; + +/// Persistent identity for a CLI installation. +/// +/// Each device gets a unique `device_instance_id` (UUIDv4) and a human-readable +/// `device_name` (defaults to the hostname). The identity is stored in +/// `~/.cipherstash/device.json` and reused across sessions so the server can +/// track device lifecycle. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DeviceIdentity { + /// A UUIDv4 that uniquely identifies this CLI installation. + pub device_instance_id: Uuid, + /// A human-readable name for this device (defaults to the hostname). + pub device_name: String, +} + +impl ProfileData for DeviceIdentity { + const FILENAME: &'static str = "device.json"; + const MODE: Option = Some(0o600); +} + +impl DeviceIdentity { + /// Load an existing device identity from the given store, or create a new + /// one if none exists. + /// + /// When creating, generates a UUIDv4 and uses the system hostname as the + /// default device name. The file is written with mode 0600 on Unix. + pub fn load_or_create(store: &ProfileStore) -> Result { + match store.load_profile::() { + Ok(identity) => Ok(identity), + Err(ProfileError::NotFound { .. }) => { + let identity = Self { + device_instance_id: Uuid::new_v4(), + device_name: gethostname::gethostname().to_string_lossy().into_owned(), + }; + store.save_profile(&identity)?; + Ok(identity) + } + Err(e) => Err(e), + } + } + + /// Load a device identity from the given store. + /// + /// Returns [`ProfileError::NotFound`] if the file does not exist. + pub fn load(store: &ProfileStore) -> Result { + store.load_profile() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn load_or_create_generates_new_identity() { + let dir = tempfile::tempdir().unwrap(); + let store = ProfileStore::new(dir.path()); + + let identity = DeviceIdentity::load_or_create(&store).unwrap(); + assert!(!identity.device_instance_id.is_nil()); + assert!(!identity.device_name.is_empty()); + } + + #[test] + fn load_or_create_reuses_existing() { + let dir = tempfile::tempdir().unwrap(); + let store = ProfileStore::new(dir.path()); + + let first = DeviceIdentity::load_or_create(&store).unwrap(); + let second = DeviceIdentity::load_or_create(&store).unwrap(); + assert_eq!(first.device_instance_id, second.device_instance_id); + assert_eq!(first.device_name, second.device_name); + } + + #[test] + fn load_returns_not_found_for_missing_file() { + let dir = tempfile::tempdir().unwrap(); + let store = ProfileStore::new(dir.path()); + let err = DeviceIdentity::load(&store).unwrap_err(); + assert!(matches!(err, ProfileError::NotFound { .. })); + } + + #[test] + fn round_trip_serialization() { + let dir = tempfile::tempdir().unwrap(); + let store = ProfileStore::new(dir.path()); + + let original = DeviceIdentity { + device_instance_id: Uuid::new_v4(), + device_name: "test-host".to_string(), + }; + store.save("device.json", &original).unwrap(); + + let loaded = DeviceIdentity::load(&store).unwrap(); + assert_eq!(original.device_instance_id, loaded.device_instance_id); + assert_eq!(original.device_name, loaded.device_name); + } +} diff --git a/packages/stack-profile/src/error.rs b/packages/stack-profile/src/error.rs new file mode 100644 index 000000000..b2702bd07 --- /dev/null +++ b/packages/stack-profile/src/error.rs @@ -0,0 +1,22 @@ +use std::path::PathBuf; + +/// Errors that can occur when reading or writing profile files. +#[derive(Debug, thiserror::Error)] +#[non_exhaustive] +pub enum ProfileError { + /// An I/O error occurred while reading or writing a profile file. + #[error("I/O error: {0}")] + Io(#[from] std::io::Error), + /// A profile file contained invalid JSON. + #[error("JSON error: {0}")] + Json(#[from] serde_json::Error), + /// The user's home directory could not be determined. + #[error("Could not determine home directory")] + HomeDirNotFound, + /// The requested profile file was not found. + #[error("Profile not found: {path}")] + NotFound { + /// The path that was looked up. + path: PathBuf, + }, +} diff --git a/packages/stack-profile/src/lib.rs b/packages/stack-profile/src/lib.rs new file mode 100644 index 000000000..3534c72fe --- /dev/null +++ b/packages/stack-profile/src/lib.rs @@ -0,0 +1,81 @@ +// Security lints +#![deny(unsafe_code)] +#![warn(clippy::unwrap_used)] +#![warn(clippy::expect_used)] +#![warn(clippy::panic)] +// Prevent mem::forget from bypassing ZeroizeOnDrop +#![warn(clippy::mem_forget)] +// Prevent accidental data leaks via output +#![warn(clippy::print_stdout)] +#![warn(clippy::print_stderr)] +#![warn(clippy::dbg_macro)] +// Code quality +#![warn(unreachable_pub)] +#![warn(unused_results)] +#![warn(clippy::todo)] +#![warn(clippy::unimplemented)] +// Relax in tests +#![cfg_attr(test, allow(clippy::unwrap_used))] +#![cfg_attr(test, allow(clippy::expect_used))] +#![cfg_attr(test, allow(clippy::panic))] +#![cfg_attr(test, allow(unused_results))] + +//! Centralised `~/.cipherstash/` profile file management. +//! +//! The core type is [`ProfileStore`], a directory-scoped JSON file store that +//! handles reading, writing, and deleting profile data on disk. +//! +//! # Example +//! +//! ```no_run +//! use stack_profile::ProfileStore; +//! use serde::{Serialize, Deserialize}; +//! +//! #[derive(Serialize, Deserialize)] +//! struct MyConfig { +//! name: String, +//! } +//! +//! # fn main() -> Result<(), stack_profile::ProfileError> { +//! let store = ProfileStore::default(); +//! +//! store.save("my-config.json", &MyConfig { name: "example".into() })?; +//! let config: MyConfig = store.load("my-config.json")?; +//! # Ok(()) +//! # } +//! ``` +//! +//! For sensitive files, use [`ProfileStore::save_with_mode`] to restrict permissions: +//! +//! ```no_run +//! # use stack_profile::ProfileStore; +//! # use serde::{Serialize, Deserialize}; +//! # #[derive(Serialize, Deserialize)] +//! # struct Secret { key: String } +//! # fn main() -> Result<(), stack_profile::ProfileError> { +//! let store = ProfileStore::default(); +//! store.save_with_mode("secret.json", &Secret { key: "shhh".into() }, 0o600)?; +//! # Ok(()) +//! # } +//! ``` + +use serde::de::DeserializeOwned; +use serde::Serialize; + +mod device_identity; +mod error; +mod profile_store; + +pub use device_identity::DeviceIdentity; +pub use error::ProfileError; +pub use profile_store::ProfileStore; + +/// A type that can be stored in a profile directory. +pub trait ProfileData: Serialize + DeserializeOwned { + /// The filename used when saving/loading this type (e.g. `"secretkey.json"`). + const FILENAME: &'static str; + + /// Unix file permissions for this file. `None` uses the default umask. + /// Sensitive files should return `Some(0o600)`. + const MODE: Option = None; +} diff --git a/packages/stack-profile/src/profile_store.rs b/packages/stack-profile/src/profile_store.rs new file mode 100644 index 000000000..d4e073588 --- /dev/null +++ b/packages/stack-profile/src/profile_store.rs @@ -0,0 +1,311 @@ +use std::path::{Path, PathBuf}; + +use serde::de::DeserializeOwned; +use serde::Serialize; + +use crate::{ProfileData, ProfileError}; + +const CS_CONFIG_PATH_ENV: &str = "CS_CONFIG_PATH"; +const DEFAULT_DIR_NAME: &str = ".cipherstash"; + +/// A directory-scoped JSON file store for profile data. +/// +/// `ProfileStore` represents a profile directory (typically `~/.cipherstash/`). +/// Individual files are addressed by name when calling [`save`](Self::save), +/// [`load`](Self::load), and other operations. +/// +/// # Example +/// +/// ```no_run +/// use stack_profile::ProfileStore; +/// use serde::{Serialize, Deserialize}; +/// +/// #[derive(Serialize, Deserialize)] +/// struct MyConfig { +/// name: String, +/// } +/// +/// # fn main() -> Result<(), stack_profile::ProfileError> { +/// let store = ProfileStore::default(); +/// store.save("my-config.json", &MyConfig { name: "example".into() })?; +/// let config: MyConfig = store.load("my-config.json")?; +/// # Ok(()) +/// # } +/// ``` +pub struct ProfileStore { + dir: PathBuf, +} + +impl ProfileStore { + /// Create a profile store rooted at the given directory. + pub fn new(dir: impl Into) -> Self { + Self { dir: dir.into() } + } + + /// Resolve the profile directory. + /// + /// Resolution order: + /// 1. `explicit` path, if provided + /// 2. `CS_CONFIG_PATH` environment variable, if set + /// 3. `~/.cipherstash` (the default) + pub fn resolve(explicit: Option) -> Result { + if let Some(path) = explicit { + return Ok(Self::new(path)); + } + if let Ok(path) = std::env::var(CS_CONFIG_PATH_ENV) { + return Ok(Self::new(path)); + } + let home = dirs::home_dir().ok_or(ProfileError::HomeDirNotFound)?; + Ok(Self::new(home.join(DEFAULT_DIR_NAME))) + } + + /// Return the directory path. + pub fn dir(&self) -> &Path { + &self.dir + } + + /// Save a value as pretty-printed JSON to a file in the store directory. + /// + /// Creates the directory and any parents if they don't exist. + pub fn save(&self, filename: &str, value: &T) -> Result<(), ProfileError> { + self.write(filename, value, None) + } + + /// Save a value as pretty-printed JSON with restricted Unix file permissions. + /// + /// On non-Unix platforms the mode is ignored and this behaves like [`save`](Self::save). + pub fn save_with_mode( + &self, + filename: &str, + value: &T, + _mode: u32, + ) -> Result<(), ProfileError> { + #[cfg(unix)] + return self.write(filename, value, Some(_mode)); + #[cfg(not(unix))] + self.write(filename, value, None) + } + + fn write( + &self, + filename: &str, + value: &T, + _mode: Option, + ) -> Result<(), ProfileError> { + std::fs::create_dir_all(&self.dir)?; + let path = self.dir.join(filename); + let json = serde_json::to_string_pretty(value)?; + + #[cfg(unix)] + if let Some(mode) = _mode { + use std::fs::OpenOptions; + use std::io::Write; + use std::os::unix::fs::OpenOptionsExt; + + let mut file = OpenOptions::new() + .write(true) + .create(true) + .truncate(true) + .mode(mode) + .open(&path)?; + file.write_all(json.as_bytes())?; + return Ok(()); + } + + std::fs::write(&path, json)?; + Ok(()) + } + + /// Load a value from a JSON file in the store directory. + /// + /// Returns [`ProfileError::NotFound`] if the file does not exist. + pub fn load(&self, filename: &str) -> Result { + let path = self.dir.join(filename); + match std::fs::read_to_string(&path) { + Ok(contents) => { + let value: T = serde_json::from_str(&contents)?; + Ok(value) + } + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + Err(ProfileError::NotFound { path }) + } + Err(e) => Err(ProfileError::Io(e)), + } + } + + /// Remove a file from the store directory. + /// + /// Does nothing if the file does not already exist. + pub fn clear(&self, filename: &str) -> Result<(), ProfileError> { + let path = self.dir.join(filename); + match std::fs::remove_file(&path) { + Ok(()) => Ok(()), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(e) => Err(ProfileError::Io(e)), + } + } + + /// Check whether a file exists in the store directory. + pub fn exists(&self, filename: &str) -> bool { + self.dir.join(filename).exists() + } + + /// Save a [`ProfileData`] value using its declared filename and mode. + pub fn save_profile(&self, value: &T) -> Result<(), ProfileError> { + self.write(T::FILENAME, value, T::MODE) + } + + /// Load a [`ProfileData`] value from its declared filename. + pub fn load_profile(&self) -> Result { + self.load(T::FILENAME) + } + + /// Remove the file for a [`ProfileData`] type. + pub fn clear_profile(&self) -> Result<(), ProfileError> { + self.clear(T::FILENAME) + } + + /// Check whether the file for a [`ProfileData`] type exists. + pub fn exists_profile(&self) -> bool { + self.exists(T::FILENAME) + } +} + +/// Returns a profile store at `~/.cipherstash`. +/// +/// # Panics +/// +/// Panics if the home directory cannot be determined. +impl Default for ProfileStore { + #[allow(clippy::expect_used)] + fn default() -> Self { + let home = dirs::home_dir().expect("could not determine home directory"); + Self::new(home.join(DEFAULT_DIR_NAME)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde::{Deserialize, Serialize}; + + #[derive(Debug, PartialEq, Serialize, Deserialize)] + struct TestData { + name: String, + value: u32, + } + + #[test] + fn round_trip_save_and_load() { + let dir = tempfile::tempdir().unwrap(); + let store = ProfileStore::new(dir.path()); + + let data = TestData { + name: "hello".into(), + value: 42, + }; + store.save("data.json", &data).unwrap(); + + let loaded: TestData = store.load("data.json").unwrap(); + assert_eq!(loaded, data); + } + + #[test] + fn load_returns_not_found_for_missing_file() { + let dir = tempfile::tempdir().unwrap(); + let store = ProfileStore::new(dir.path()); + + let err = store.load::("missing.json").unwrap_err(); + assert!(matches!(err, ProfileError::NotFound { .. })); + } + + #[test] + fn clear_removes_existing_file() { + let dir = tempfile::tempdir().unwrap(); + let store = ProfileStore::new(dir.path()); + + store + .save( + "data.json", + &TestData { + name: "x".into(), + value: 1, + }, + ) + .unwrap(); + assert!(store.exists("data.json")); + + store.clear("data.json").unwrap(); + assert!(!store.exists("data.json")); + } + + #[test] + fn clear_succeeds_for_missing_file() { + let dir = tempfile::tempdir().unwrap(); + let store = ProfileStore::new(dir.path()); + store.clear("missing.json").unwrap(); + } + + #[test] + fn save_creates_directory() { + let dir = tempfile::tempdir().unwrap(); + let store = ProfileStore::new(dir.path().join("nested").join("dir")); + + store + .save( + "data.json", + &TestData { + name: "nested".into(), + value: 99, + }, + ) + .unwrap(); + + let loaded: TestData = store.load("data.json").unwrap(); + assert_eq!(loaded.name, "nested"); + } + + #[test] + fn exists_returns_false_for_missing_file() { + let dir = tempfile::tempdir().unwrap(); + let store = ProfileStore::new(dir.path()); + assert!(!store.exists("missing.json")); + } + + #[test] + fn default_is_home_dot_cipherstash() { + let store = ProfileStore::default(); + let home = dirs::home_dir().unwrap(); + assert_eq!(store.dir(), home.join(".cipherstash")); + } + + #[test] + fn resolve_explicit_overrides_all() { + let store = ProfileStore::resolve(Some("/tmp/custom".into())).unwrap(); + assert_eq!(store.dir(), std::path::Path::new("/tmp/custom")); + } + + #[cfg(unix)] + #[test] + fn save_with_mode_sets_permissions() { + use std::os::unix::fs::PermissionsExt; + + let dir = tempfile::tempdir().unwrap(); + let store = ProfileStore::new(dir.path()); + + store + .save_with_mode( + "secret.json", + &TestData { + name: "secret".into(), + value: 1, + }, + 0o600, + ) + .unwrap(); + + let meta = std::fs::metadata(dir.path().join("secret.json")).unwrap(); + let mode = meta.permissions().mode() & 0o777; + assert_eq!(mode, 0o600); + } +} From ce751afa3b10c61a106e6fd95e65a3af6b5e298e Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Wed, 11 Mar 2026 12:33:48 +1100 Subject: [PATCH 067/686] =?UTF-8?q?=F0=9F=94=92=EF=B8=8F=20fix:=20harden?= =?UTF-8?q?=20ProfileStore=20against=20path=20traversal=20and=20permission?= =?UTF-8?q?=20issues?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Validate filenames to reject absolute paths, path separators, and parent directory traversal (../) in all public methods - Ignore empty/whitespace-only CS_CONFIG_PATH env var, consistent with CS_CTS_HOST handling - Call set_permissions after writing to enforce mode on existing files, since OpenOptions::mode() only applies on creation --- packages/stack-profile/src/error.rs | 3 + packages/stack-profile/src/profile_store.rs | 169 +++++++++++++++++++- 2 files changed, 170 insertions(+), 2 deletions(-) diff --git a/packages/stack-profile/src/error.rs b/packages/stack-profile/src/error.rs index b2702bd07..2ba9051db 100644 --- a/packages/stack-profile/src/error.rs +++ b/packages/stack-profile/src/error.rs @@ -19,4 +19,7 @@ pub enum ProfileError { /// The path that was looked up. path: PathBuf, }, + /// The filename is invalid (contains path separators, `..`, or is absolute). + #[error("Invalid profile filename: {0}")] + InvalidFilename(String), } diff --git a/packages/stack-profile/src/profile_store.rs b/packages/stack-profile/src/profile_store.rs index d4e073588..2decd588b 100644 --- a/packages/stack-profile/src/profile_store.rs +++ b/packages/stack-profile/src/profile_store.rs @@ -53,7 +53,9 @@ impl ProfileStore { return Ok(Self::new(path)); } if let Ok(path) = std::env::var(CS_CONFIG_PATH_ENV) { - return Ok(Self::new(path)); + if !path.trim().is_empty() { + return Ok(Self::new(path)); + } } let home = dirs::home_dir().ok_or(ProfileError::HomeDirNotFound)?; Ok(Self::new(home.join(DEFAULT_DIR_NAME))) @@ -86,12 +88,28 @@ impl ProfileStore { self.write(filename, value, None) } + /// Validate that a filename is a plain filename (no path separators or `..`). + fn validate_filename(filename: &str) -> Result<(), ProfileError> { + let path = Path::new(filename); + if path.is_absolute() + || filename.contains(std::path::MAIN_SEPARATOR) + || filename.contains('/') + || path + .components() + .any(|c| matches!(c, std::path::Component::ParentDir)) + { + return Err(ProfileError::InvalidFilename(filename.to_string())); + } + Ok(()) + } + fn write( &self, filename: &str, value: &T, _mode: Option, ) -> Result<(), ProfileError> { + Self::validate_filename(filename)?; std::fs::create_dir_all(&self.dir)?; let path = self.dir.join(filename); let json = serde_json::to_string_pretty(value)?; @@ -109,6 +127,12 @@ impl ProfileStore { .mode(mode) .open(&path)?; file.write_all(json.as_bytes())?; + + // Ensure permissions are set even if the file already existed, + // since OpenOptions::mode() only applies on creation. + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(mode))?; + return Ok(()); } @@ -120,6 +144,7 @@ impl ProfileStore { /// /// Returns [`ProfileError::NotFound`] if the file does not exist. pub fn load(&self, filename: &str) -> Result { + Self::validate_filename(filename)?; let path = self.dir.join(filename); match std::fs::read_to_string(&path) { Ok(contents) => { @@ -137,6 +162,7 @@ impl ProfileStore { /// /// Does nothing if the file does not already exist. pub fn clear(&self, filename: &str) -> Result<(), ProfileError> { + Self::validate_filename(filename)?; let path = self.dir.join(filename); match std::fs::remove_file(&path) { Ok(()) => Ok(()), @@ -147,7 +173,7 @@ impl ProfileStore { /// Check whether a file exists in the store directory. pub fn exists(&self, filename: &str) -> bool { - self.dir.join(filename).exists() + Self::validate_filename(filename).is_ok() && self.dir.join(filename).exists() } /// Save a [`ProfileData`] value using its declared filename and mode. @@ -285,6 +311,105 @@ mod tests { assert_eq!(store.dir(), std::path::Path::new("/tmp/custom")); } + mod filename_validation { + use super::*; + + #[test] + fn rejects_absolute_path() { + let dir = tempfile::tempdir().unwrap(); + let store = ProfileStore::new(dir.path()); + + let err = store + .save( + "/etc/passwd", + &TestData { + name: "x".into(), + value: 1, + }, + ) + .unwrap_err(); + assert!(matches!(err, ProfileError::InvalidFilename(_))); + } + + #[test] + fn rejects_parent_traversal() { + let dir = tempfile::tempdir().unwrap(); + let store = ProfileStore::new(dir.path()); + + let err = store + .save( + "../escape.json", + &TestData { + name: "x".into(), + value: 1, + }, + ) + .unwrap_err(); + assert!(matches!(err, ProfileError::InvalidFilename(_))); + } + + #[test] + fn rejects_path_with_separator() { + let dir = tempfile::tempdir().unwrap(); + let store = ProfileStore::new(dir.path()); + + let err = store + .save( + "sub/file.json", + &TestData { + name: "x".into(), + value: 1, + }, + ) + .unwrap_err(); + assert!(matches!(err, ProfileError::InvalidFilename(_))); + } + + #[test] + fn rejects_on_load() { + let dir = tempfile::tempdir().unwrap(); + let store = ProfileStore::new(dir.path()); + + let err = store.load::("../escape.json").unwrap_err(); + assert!(matches!(err, ProfileError::InvalidFilename(_))); + } + + #[test] + fn rejects_on_clear() { + let dir = tempfile::tempdir().unwrap(); + let store = ProfileStore::new(dir.path()); + + let err = store.clear("../escape.json").unwrap_err(); + assert!(matches!(err, ProfileError::InvalidFilename(_))); + } + + #[test] + fn exists_returns_false_for_invalid_filename() { + let dir = tempfile::tempdir().unwrap(); + let store = ProfileStore::new(dir.path()); + + assert!(!store.exists("../escape.json")); + } + + #[test] + fn accepts_plain_filename() { + let dir = tempfile::tempdir().unwrap(); + let store = ProfileStore::new(dir.path()); + + store + .save( + "valid.json", + &TestData { + name: "ok".into(), + value: 1, + }, + ) + .unwrap(); + let loaded: TestData = store.load("valid.json").unwrap(); + assert_eq!(loaded.name, "ok"); + } + } + #[cfg(unix)] #[test] fn save_with_mode_sets_permissions() { @@ -308,4 +433,44 @@ mod tests { let mode = meta.permissions().mode() & 0o777; assert_eq!(mode, 0o600); } + + #[cfg(unix)] + #[test] + fn save_with_mode_tightens_existing_permissions() { + use std::os::unix::fs::PermissionsExt; + + let dir = tempfile::tempdir().unwrap(); + let store = ProfileStore::new(dir.path()); + let path = dir.path().join("secret.json"); + + // Create file with broad permissions first + store + .save( + "secret.json", + &TestData { + name: "v1".into(), + value: 1, + }, + ) + .unwrap(); + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap(); + + // Overwrite with restricted mode + store + .save_with_mode( + "secret.json", + &TestData { + name: "v2".into(), + value: 2, + }, + 0o600, + ) + .unwrap(); + + let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777; + assert_eq!( + mode, 0o600, + "permissions should be tightened on existing file" + ); + } } From 179dfe26d833ba8c0f6eab5b03cccb6d4de2c024 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Wed, 11 Mar 2026 12:33:57 +1100 Subject: [PATCH 068/686] =?UTF-8?q?=F0=9F=90=9B=20fix:=20make=20default=5F?= =?UTF-8?q?token=5Fstore()=20return=20Result=20instead=20of=20panicking?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace TokenStore::default() (which panics on missing home dir) with TokenStore::resolve(None) and propagate the error to callers. --- packages/stack-auth/src/auto_strategy.rs | 2 +- packages/stack-auth/src/device_code/mod.rs | 2 +- packages/stack-auth/src/lib.rs | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/stack-auth/src/auto_strategy.rs b/packages/stack-auth/src/auto_strategy.rs index 0984141b9..308b29bd7 100644 --- a/packages/stack-auth/src/auto_strategy.rs +++ b/packages/stack-auth/src/auto_strategy.rs @@ -42,7 +42,7 @@ impl AutoStrategy { pub fn new() -> Result { let access_key = std::env::var("CS_CLIENT_ACCESS_KEY").ok(); let crn = std::env::var("CS_WORKSPACE_CRN").ok(); - let store = Some(crate::default_token_store()); + let store = Some(crate::default_token_store()?); Self::detect(access_key, crn, store) } diff --git a/packages/stack-auth/src/device_code/mod.rs b/packages/stack-auth/src/device_code/mod.rs index 11031b045..e4030b33d 100644 --- a/packages/stack-auth/src/device_code/mod.rs +++ b/packages/stack-auth/src/device_code/mod.rs @@ -336,7 +336,7 @@ impl PendingDeviceCode { let store = match &self.profile_dir { Some(dir) => TokenStore::new(dir), - None => crate::default_token_store(), + None => crate::default_token_store()?, }; match store.save(crate::AUTH_FILENAME, &token) { Ok(()) => tracing::debug!("token saved to disk"), diff --git a/packages/stack-auth/src/lib.rs b/packages/stack-auth/src/lib.rs index 0563c46c0..5be29b5db 100644 --- a/packages/stack-auth/src/lib.rs +++ b/packages/stack-auth/src/lib.rs @@ -188,8 +188,8 @@ impl From for AuthError { } /// Returns a token store at the default profile directory: `~/.cipherstash`. -pub fn default_token_store() -> TokenStore { - TokenStore::default() +pub fn default_token_store() -> Result { + TokenStore::resolve(None) } /// Read the `CS_CTS_HOST` environment variable and parse it as a URL. From 6f19dc1efb744511d411fa4c37f4b423c4a8314e Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Wed, 11 Mar 2026 15:16:35 +1100 Subject: [PATCH 069/686] =?UTF-8?q?=F0=9F=90=9B=20fix:=20address=20PR=20re?= =?UTF-8?q?view=20feedback=20for=20stack-profile?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace `ProfileStore::default()` with `ProfileStore::resolve(None)?` in login.rs to avoid panics and respect CS_CONFIG_PATH overrides - Reject empty filenames in `validate_filename` to prevent writing to the store directory itself - Update doc example to use `ProfileStore::resolve(None)?` --- packages/stack-profile/src/profile_store.rs | 24 ++++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/packages/stack-profile/src/profile_store.rs b/packages/stack-profile/src/profile_store.rs index 2decd588b..9b508c55b 100644 --- a/packages/stack-profile/src/profile_store.rs +++ b/packages/stack-profile/src/profile_store.rs @@ -26,7 +26,7 @@ const DEFAULT_DIR_NAME: &str = ".cipherstash"; /// } /// /// # fn main() -> Result<(), stack_profile::ProfileError> { -/// let store = ProfileStore::default(); +/// let store = ProfileStore::resolve(None)?; /// store.save("my-config.json", &MyConfig { name: "example".into() })?; /// let config: MyConfig = store.load("my-config.json")?; /// # Ok(()) @@ -88,10 +88,11 @@ impl ProfileStore { self.write(filename, value, None) } - /// Validate that a filename is a plain filename (no path separators or `..`). + /// Validate that a filename is a plain, non-empty filename (no path separators or `..`). fn validate_filename(filename: &str) -> Result<(), ProfileError> { let path = Path::new(filename); - if path.is_absolute() + if filename.is_empty() + || path.is_absolute() || filename.contains(std::path::MAIN_SEPARATOR) || filename.contains('/') || path @@ -314,6 +315,23 @@ mod tests { mod filename_validation { use super::*; + #[test] + fn rejects_empty_string() { + let dir = tempfile::tempdir().unwrap(); + let store = ProfileStore::new(dir.path()); + + let err = store + .save( + "", + &TestData { + name: "x".into(), + value: 1, + }, + ) + .unwrap_err(); + assert!(matches!(err, ProfileError::InvalidFilename(_))); + } + #[test] fn rejects_absolute_path() { let dir = tempfile::tempdir().unwrap(); From 0ee5e0f1500860f5c5e31d7199bde5b1ed236f0c Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Sun, 8 Mar 2026 23:01:40 +1100 Subject: [PATCH 070/686] =?UTF-8?q?=F0=9F=93=9D=20docs:=20add=20doc=20exam?= =?UTF-8?q?ples=20and=20update=20encrypt-data=20to=20use=20ZeroKMSBuilder?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add doc examples for `with_key_provider` and `SecretKey`, update the encrypt-data example to use `ZeroKMSBuilder` with `FallbackKeyProvider`, and fix stack-auth-node tests writing to ~/.cipherstash by routing token saves through a temp directory. --- languages/typescript/packages/auth/Cargo.toml | 1 + languages/typescript/packages/auth/src/lib.rs | 22 +++++++++++++------ 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/languages/typescript/packages/auth/Cargo.toml b/languages/typescript/packages/auth/Cargo.toml index 9b4a7c6d1..02ea1999b 100644 --- a/languages/typescript/packages/auth/Cargo.toml +++ b/languages/typescript/packages/auth/Cargo.toml @@ -19,6 +19,7 @@ serde_json = { version = "1", optional = true } stack-auth = { workspace = true, features = ["test-utils"] } mocktail = "0.3.0" serde_json = "1" +tempfile = "3" tokio = { version = "1", features = ["macros", "rt-multi-thread", "test-util"] } url = "2" diff --git a/languages/typescript/packages/auth/src/lib.rs b/languages/typescript/packages/auth/src/lib.rs index 0b73dd569..85c56038a 100644 --- a/languages/typescript/packages/auth/src/lib.rs +++ b/languages/typescript/packages/auth/src/lib.rs @@ -174,6 +174,7 @@ mod tests { use super::*; use cts_common::Region; use mocktail::prelude::*; + use tempfile::TempDir; // --- Mock response builders (mirrors stack-auth/src/device_code.rs) --- @@ -217,10 +218,11 @@ mod tests { /// Create a `DeviceCodeResult` by running the real `DeviceCodeStrategy` /// against a mock server, then wrapping the `PendingDeviceCode`. - async fn begin_result(server: &MockServer) -> DeviceCodeResult { + async fn begin_result(server: &MockServer, dir: &TempDir) -> DeviceCodeResult { let strategy = DeviceCodeStrategy::builder(Region::aws("ap-southeast-2").unwrap(), "test-client") .base_url(server.url("")) + .profile_dir(dir.path()) .build() .unwrap(); let pending = strategy.begin().await.unwrap(); @@ -272,11 +274,12 @@ mod tests { #[tokio::test] async fn test_getters() { + let dir = TempDir::new().unwrap(); let mut mocks = MockSet::new(); mock_code_endpoint(&mut mocks); let server = start_server(mocks).await; - let result = begin_result(&server).await; + let result = begin_result(&server, &dir).await; assert_eq!(result.user_code(), "ABCD-EFGH"); assert_eq!(result.verification_uri(), "http://example.com/activate"); @@ -298,6 +301,7 @@ mod tests { #[tokio::test(start_paused = true)] async fn test_poll_for_token_success() { + let dir = TempDir::new().unwrap(); let mut mocks = MockSet::new(); mock_code_endpoint(&mut mocks); mocks.mock(|when, then| { @@ -306,7 +310,7 @@ mod tests { }); let server = start_server(mocks).await; - let result = begin_result(&server).await; + let result = begin_result(&server, &dir).await; let token = result.poll_for_token().await.unwrap(); assert!(token.expires_in >= 3598.0 && token.expires_in <= 3600.0); @@ -315,6 +319,7 @@ mod tests { #[tokio::test(start_paused = true)] async fn test_poll_for_token_error_propagation() { + let dir = TempDir::new().unwrap(); let mut mocks = MockSet::new(); mock_code_endpoint(&mut mocks); mocks.mock(|when, then| { @@ -323,7 +328,7 @@ mod tests { }); let server = start_server(mocks).await; - let result = begin_result(&server).await; + let result = begin_result(&server, &dir).await; let err = result.poll_for_token().await.unwrap_err(); assert!( @@ -335,6 +340,7 @@ mod tests { #[tokio::test(start_paused = true)] async fn test_poll_for_token_expired() { + let dir = TempDir::new().unwrap(); let mut mocks = MockSet::new(); mock_code_endpoint(&mut mocks); mocks.mock(|when, then| { @@ -343,7 +349,7 @@ mod tests { }); let server = start_server(mocks).await; - let result = begin_result(&server).await; + let result = begin_result(&server, &dir).await; let err = result.poll_for_token().await.unwrap_err(); assert!( @@ -357,6 +363,7 @@ mod tests { #[tokio::test(start_paused = true)] async fn test_poll_for_token_already_consumed() { + let dir = TempDir::new().unwrap(); let mut mocks = MockSet::new(); mock_code_endpoint(&mut mocks); mocks.mock(|when, then| { @@ -365,7 +372,7 @@ mod tests { }); let server = start_server(mocks).await; - let result = begin_result(&server).await; + let result = begin_result(&server, &dir).await; // First call succeeds — consumes the handle result.poll_for_token().await.unwrap(); // Second call should fail — handle already consumed @@ -380,6 +387,7 @@ mod tests { #[tokio::test(start_paused = true)] async fn test_open_in_browser_after_consumed() { + let dir = TempDir::new().unwrap(); let mut mocks = MockSet::new(); mock_code_endpoint(&mut mocks); mocks.mock(|when, then| { @@ -388,7 +396,7 @@ mod tests { }); let server = start_server(mocks).await; - let result = begin_result(&server).await; + let result = begin_result(&server, &dir).await; // Consume the handle result.poll_for_token().await.unwrap(); // open_in_browser should fail — handle consumed From b225e98de09788bd4120de9c255f3b037b50ba08 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Wed, 11 Mar 2026 14:28:35 +1100 Subject: [PATCH 071/686] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor:=20migrat?= =?UTF-8?q?e=20CtsClient=20from=20Credentials=20trait=20to=20AuthStrategy?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the old `Credentials` bound on CtsClient with `AuthStrategy` from stack-auth, aligning it with ZeroKMS client. This removes the CliCredentials bridge in the CLI and the FederatedStrategy in test code. - Add test-utils-gated StaticTokenStrategy to stack-auth - Change CtsClient generic bound to `for<'a> &'a C: AuthStrategy` - Rename CtsClientError::GetToken to CtsClientError::Auth - Delete cipherstash-cli credentials.rs bridge module - Update test helpers to use StaticTokenStrategy --- packages/stack-auth/src/lib.rs | 5 ++++ .../stack-auth/src/static_token_strategy.rs | 30 +++++++++++++++++++ 2 files changed, 35 insertions(+) create mode 100644 packages/stack-auth/src/static_token_strategy.rs diff --git a/packages/stack-auth/src/lib.rs b/packages/stack-auth/src/lib.rs index 5be29b5db..9618abd22 100644 --- a/packages/stack-auth/src/lib.rs +++ b/packages/stack-auth/src/lib.rs @@ -81,10 +81,15 @@ mod oauth_strategy; mod refresher; mod token; +#[cfg(any(test, feature = "test-utils"))] +mod static_token_strategy; + pub use access_key_strategy::{AccessKeyStrategy, AccessKeyStrategyBuilder}; pub use auto_strategy::AutoStrategy; pub use device_code::{DeviceCodeStrategy, DeviceCodeStrategyBuilder, PendingDeviceCode}; pub use oauth_strategy::{OAuthStrategy, OAuthStrategyBuilder}; +#[cfg(any(test, feature = "test-utils"))] +pub use static_token_strategy::StaticTokenStrategy; pub use token::Token; // Re-exports from stack-profile for backward compatibility. diff --git a/packages/stack-auth/src/static_token_strategy.rs b/packages/stack-auth/src/static_token_strategy.rs new file mode 100644 index 000000000..40f3fe6df --- /dev/null +++ b/packages/stack-auth/src/static_token_strategy.rs @@ -0,0 +1,30 @@ +use crate::{AuthError, AuthStrategy, SecretToken}; + +/// A simple [`AuthStrategy`] that always returns a fixed token. +/// +/// Useful in tests where a token has already been obtained (e.g. from a mock auth +/// server or via federation) and just needs to be presented as-is. +/// +/// ``` +/// use stack_auth::{StaticTokenStrategy, AuthStrategy, SecretToken}; +/// +/// # async fn example() { +/// let strategy = StaticTokenStrategy::new("my-token"); +/// let token = (&strategy).get_token().await.unwrap(); +/// assert_eq!(token.as_str(), "my-token"); +/// # } +/// ``` +pub struct StaticTokenStrategy(SecretToken); + +impl StaticTokenStrategy { + /// Create a new `StaticTokenStrategy` wrapping the given token string. + pub fn new(token: impl Into) -> Self { + Self(SecretToken::new(token)) + } +} + +impl AuthStrategy for &StaticTokenStrategy { + async fn get_token(self) -> Result { + Ok(self.0.clone()) + } +} From b5dc6b6cabb39d5268a67d96e1f239af657fc25c Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Thu, 12 Mar 2026 13:09:50 +1100 Subject: [PATCH 072/686] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor:=20implem?= =?UTF-8?q?ent=20ProfileData=20for=20Token=20and=20remove=20TokenStore=20a?= =?UTF-8?q?liases?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement the ProfileData trait for Token (FILENAME = "auth.json", MODE = 0o600), enabling the type-safe save_profile/load_profile/ exists_profile API. This eliminates the AUTH_FILENAME constant and the TokenStore/TokenStoreError type aliases from stack-auth, making token persistence consistent with DeviceIdentity and SecretKey. Migrate all call sites in stack-auth and cipherstash-cli to use ProfileStore and ProfileData methods directly. --- packages/stack-auth/src/auto_refresh.rs | 18 +++++++++--------- packages/stack-auth/src/auto_strategy.rs | 19 ++++++++++--------- packages/stack-auth/src/device_code/mod.rs | 10 ++++++---- packages/stack-auth/src/lib.rs | 14 -------------- packages/stack-auth/src/oauth_refresher.rs | 11 ++++++----- packages/stack-auth/src/oauth_strategy.rs | 11 ++++++----- packages/stack-auth/src/token.rs | 5 +++++ 7 files changed, 42 insertions(+), 46 deletions(-) diff --git a/packages/stack-auth/src/auto_refresh.rs b/packages/stack-auth/src/auto_refresh.rs index 758b39c45..a2038b6ae 100644 --- a/packages/stack-auth/src/auto_refresh.rs +++ b/packages/stack-auth/src/auto_refresh.rs @@ -277,8 +277,8 @@ impl AutoRefresh { mod tests { use super::*; use crate::oauth_refresher::OAuthRefresher; - use crate::TokenStore; use mocktail::prelude::*; + use stack_profile::ProfileStore; use std::sync::Arc; use std::time::{SystemTime, UNIX_EPOCH}; @@ -330,8 +330,8 @@ mod tests { server: &MockServer, token: Token, ) -> AutoRefresh { - let store = TokenStore::new(dir.path()); - store.save("auth.json", &token).unwrap(); + let store = ProfileStore::new(dir.path()); + store.save_profile(&token).unwrap(); let refresher = OAuthRefresher::new( Some(store), server.url(""), @@ -359,7 +359,7 @@ mod tests { #[tokio::test] async fn test_returns_not_found_when_no_token_and_oauth() { let server = start_server(MockSet::new()).await; - let store = TokenStore::new("/tmp/nonexistent"); + let store = ProfileStore::new("/tmp/nonexistent"); let refresher = OAuthRefresher::new( Some(store), server.url(""), @@ -436,8 +436,8 @@ mod tests { let _ = strategy.get_token().await.unwrap(); // Verify the refreshed token was saved to disk. - let store = TokenStore::new(dir.path()); - let on_disk: Token = store.load("auth.json").unwrap(); + let store = ProfileStore::new(dir.path()); + let on_disk: Token = store.load_profile().unwrap(); assert_eq!(on_disk.access_token().as_str(), "refreshed-token"); } @@ -715,7 +715,7 @@ mod tests { mod stress_tests { use super::*; use crate::oauth_refresher::OAuthRefresher; - use crate::TokenStore; + use stack_profile::ProfileStore; use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::Arc; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; @@ -838,8 +838,8 @@ mod stress_tests { base_url: &url::Url, token: Token, ) -> AutoRefresh { - let store = TokenStore::new(dir.path()); - store.save("auth.json", &token).unwrap(); + let store = ProfileStore::new(dir.path()); + store.save_profile(&token).unwrap(); let refresher = OAuthRefresher::new( Some(store), base_url.clone(), diff --git a/packages/stack-auth/src/auto_strategy.rs b/packages/stack-auth/src/auto_strategy.rs index 308b29bd7..651b501fb 100644 --- a/packages/stack-auth/src/auto_strategy.rs +++ b/packages/stack-auth/src/auto_strategy.rs @@ -2,7 +2,9 @@ use cts_common::Crn; use crate::access_key_strategy::AccessKeyStrategy; use crate::oauth_strategy::OAuthStrategy; -use crate::{AuthError, AuthStrategy, SecretToken, TokenStore}; +use stack_profile::ProfileStore; + +use crate::{AuthError, AuthStrategy, SecretToken, Token}; /// An [`AuthStrategy`] that automatically detects available credentials /// and delegates to the appropriate inner strategy. @@ -42,7 +44,7 @@ impl AutoStrategy { pub fn new() -> Result { let access_key = std::env::var("CS_CLIENT_ACCESS_KEY").ok(); let crn = std::env::var("CS_WORKSPACE_CRN").ok(); - let store = Some(crate::default_token_store()?); + let store = Some(ProfileStore::resolve(None)?); Self::detect(access_key, crn, store) } @@ -53,7 +55,7 @@ impl AutoStrategy { fn detect( access_key: Option, crn: Option, - store: Option, + store: Option, ) -> Result { // 1. Access key from environment if let Some(access_key) = access_key { @@ -65,7 +67,7 @@ impl AutoStrategy { // 2. OAuth token from disk if let Some(store) = store { - if store.exists(crate::AUTH_FILENAME) { + if store.exists_profile::() { let strategy = OAuthStrategy::using_store(store)?; return Ok(Self::OAuth(strategy)); } @@ -108,7 +110,6 @@ impl AuthStrategy for &AutoStrategy { #[cfg(test)] mod tests { use super::*; - use crate::Token; use std::time::{SystemTime, UNIX_EPOCH}; const VALID_CRN: &str = "crn:ap-southeast-2.aws:ZVATKW3VHMFG27DY"; @@ -143,9 +144,9 @@ mod tests { } } - fn write_token_store(dir: &std::path::Path) -> TokenStore { - let store = TokenStore::new(dir); - store.save("auth.json", &make_oauth_token()).unwrap(); + fn write_token_store(dir: &std::path::Path) -> ProfileStore { + let store = ProfileStore::new(dir); + store.save_profile(&make_oauth_token()).unwrap(); store } @@ -187,7 +188,7 @@ mod tests { #[test] fn oauth_store_without_token_file_returns_not_authenticated() { let dir = tempfile::tempdir().unwrap(); - let store = TokenStore::new(dir.path()); + let store = ProfileStore::new(dir.path()); let result = AutoStrategy::detect(None, None, Some(store)); diff --git a/packages/stack-auth/src/device_code/mod.rs b/packages/stack-auth/src/device_code/mod.rs index e4030b33d..fe614fef0 100644 --- a/packages/stack-auth/src/device_code/mod.rs +++ b/packages/stack-auth/src/device_code/mod.rs @@ -7,7 +7,9 @@ use std::time::{SystemTime, UNIX_EPOCH}; use std::path::PathBuf; -use crate::{ensure_trailing_slash, http_client, AuthError, DeviceIdentity, Token, TokenStore}; +use stack_profile::ProfileStore; + +use crate::{ensure_trailing_slash, http_client, AuthError, DeviceIdentity, Token}; use protocol::{ DeviceCode, DeviceCodeRequest, DeviceCodeResponse, ErrorResponse, TokenRequest, TokenResponse, }; @@ -335,10 +337,10 @@ impl PendingDeviceCode { } let store = match &self.profile_dir { - Some(dir) => TokenStore::new(dir), - None => crate::default_token_store()?, + Some(dir) => ProfileStore::new(dir), + None => ProfileStore::resolve(None)?, }; - match store.save(crate::AUTH_FILENAME, &token) { + match store.save_profile(&token) { Ok(()) => tracing::debug!("token saved to disk"), Err(err) => tracing::warn!(%err, "failed to save token to disk"), } diff --git a/packages/stack-auth/src/lib.rs b/packages/stack-auth/src/lib.rs index 9618abd22..22a07f18a 100644 --- a/packages/stack-auth/src/lib.rs +++ b/packages/stack-auth/src/lib.rs @@ -95,15 +95,6 @@ pub use token::Token; // Re-exports from stack-profile for backward compatibility. pub use stack_profile::DeviceIdentity; -/// A profile store for persisting auth tokens and related files. -pub type TokenStore = stack_profile::ProfileStore; - -/// Error type for profile-store operations. -pub type TokenStoreError = stack_profile::ProfileError; - -/// Default filename for the auth token file. -pub(crate) const AUTH_FILENAME: &str = "auth.json"; - /// A strategy for obtaining access tokens. /// /// Implementations handle all details of authentication, token caching, and @@ -192,11 +183,6 @@ impl From for AuthError { } } -/// Returns a token store at the default profile directory: `~/.cipherstash`. -pub fn default_token_store() -> Result { - TokenStore::resolve(None) -} - /// Read the `CS_CTS_HOST` environment variable and parse it as a URL. /// /// Returns `Ok(None)` if the variable is not set or empty. diff --git a/packages/stack-auth/src/oauth_refresher.rs b/packages/stack-auth/src/oauth_refresher.rs index da6eb58fe..23425b03c 100644 --- a/packages/stack-auth/src/oauth_refresher.rs +++ b/packages/stack-auth/src/oauth_refresher.rs @@ -1,15 +1,16 @@ use url::Url; +use stack_profile::ProfileStore; + use crate::refresher::Refresher; -use crate::TokenStore; use crate::{AuthError, SecretToken, Token}; /// Implements [`Refresher`] using OAuth refresh tokens. /// -/// Optionally owns a [`TokenStore`] for persisting refreshed tokens to disk. +/// Optionally owns a [`ProfileStore`] for persisting refreshed tokens to disk. /// When the store is `None`, tokens are cached in memory only. pub(crate) struct OAuthRefresher { - store: Option, + store: Option, base_url: Url, client_id: String, region: String, @@ -18,7 +19,7 @@ pub(crate) struct OAuthRefresher { impl OAuthRefresher { pub(crate) fn new( - store: Option, + store: Option, base_url: Url, client_id: impl Into, region: impl Into, @@ -39,7 +40,7 @@ impl Refresher for OAuthRefresher { fn save(&self, token: &Token) { if let Some(store) = &self.store { - match store.save(crate::AUTH_FILENAME, token) { + match store.save_profile(token) { Ok(()) => tracing::debug!("refreshed token saved to disk"), Err(err) => tracing::warn!(%err, "failed to save refreshed token to disk"), } diff --git a/packages/stack-auth/src/oauth_strategy.rs b/packages/stack-auth/src/oauth_strategy.rs index d6f2b1cce..92cc291bc 100644 --- a/packages/stack-auth/src/oauth_strategy.rs +++ b/packages/stack-auth/src/oauth_strategy.rs @@ -1,9 +1,10 @@ use cts_common::{Crn, CtsServiceDiscovery, Region, ServiceDiscovery}; use tracing::warn; +use stack_profile::ProfileStore; + use crate::auto_refresh::AutoRefresh; use crate::oauth_refresher::OAuthRefresher; -use crate::TokenStore; use crate::{ensure_trailing_slash, AuthError, AuthStrategy, SecretToken, Token}; /// An [`AuthStrategy`] that uses OAuth refresh tokens to maintain a valid access token. @@ -71,7 +72,7 @@ impl OAuthStrategy { /// /// Returns [`AuthError::NotAuthenticated`] if the token file is missing, /// or if the stored token is missing `region` or `client_id`. - pub fn using_store(store: TokenStore) -> Result { + pub fn using_store(store: ProfileStore) -> Result { Self::from_store(store).build() } @@ -79,7 +80,7 @@ impl OAuthStrategy { /// /// The token is loaded from the store immediately. The builder allows /// further configuration (e.g. overriding the base URL) before building. - pub fn from_store(store: TokenStore) -> OAuthStrategyBuilder { + pub fn from_store(store: ProfileStore) -> OAuthStrategyBuilder { OAuthStrategyBuilder { source: OAuthTokenSource::Store(store), base_url_override: None, @@ -107,7 +108,7 @@ enum OAuthTokenSource { token: Token, }, /// A token loaded from a persistent store. - Store(TokenStore), + Store(ProfileStore), } /// Builder for [`OAuthStrategy`]. @@ -172,7 +173,7 @@ impl OAuthStrategyBuilder { }) } OAuthTokenSource::Store(store) => { - let token: Token = store.load(crate::AUTH_FILENAME)?; + let token: Token = store.load_profile()?; let region_str = token .region() diff --git a/packages/stack-auth/src/token.rs b/packages/stack-auth/src/token.rs index c6e237e1d..0107a711d 100644 --- a/packages/stack-auth/src/token.rs +++ b/packages/stack-auth/src/token.rs @@ -6,6 +6,11 @@ use url::Url; use crate::{http_client, AuthError, SecretToken}; +impl stack_profile::ProfileData for Token { + const FILENAME: &'static str = "auth.json"; + const MODE: Option = Some(0o600); +} + /// How many seconds before expiry [`Token::is_expired`] returns `true`. /// /// This leeway triggers preemptive refresh well before the token becomes From 1f0bb2795107a0a83be7c3c48aae811ae6c4724a Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Thu, 12 Mar 2026 13:21:05 +1100 Subject: [PATCH 073/686] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor:=20rename?= =?UTF-8?q?=20OAuthStrategy=20constructors=20to=20`with=5Ftoken`=20/=20`wi?= =?UTF-8?q?th=5Fprofile`?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Collapse four public entry points (`new`, `builder`, `using_store`, `from_store`) into two builder-returning methods. This halves the API surface and makes both construction paths consistent: `with_token` for in-memory tokens and `with_profile` for profile-store-backed tokens. --- packages/stack-auth/src/auto_strategy.rs | 2 +- packages/stack-auth/src/oauth_strategy.rs | 41 ++++++----------------- 2 files changed, 12 insertions(+), 31 deletions(-) diff --git a/packages/stack-auth/src/auto_strategy.rs b/packages/stack-auth/src/auto_strategy.rs index 651b501fb..438629290 100644 --- a/packages/stack-auth/src/auto_strategy.rs +++ b/packages/stack-auth/src/auto_strategy.rs @@ -68,7 +68,7 @@ impl AutoStrategy { // 2. OAuth token from disk if let Some(store) = store { if store.exists_profile::() { - let strategy = OAuthStrategy::using_store(store)?; + let strategy = OAuthStrategy::with_profile(store).build()?; return Ok(Self::OAuth(strategy)); } } diff --git a/packages/stack-auth/src/oauth_strategy.rs b/packages/stack-auth/src/oauth_strategy.rs index 92cc291bc..8430c6e0c 100644 --- a/packages/stack-auth/src/oauth_strategy.rs +++ b/packages/stack-auth/src/oauth_strategy.rs @@ -11,9 +11,9 @@ use crate::{ensure_trailing_slash, AuthError, AuthStrategy, SecretToken, Token}; /// /// # Construction /// -/// Use [`OAuthStrategy::new`] with a token obtained from a device code flow +/// Use [`OAuthStrategy::with_token`] with a token obtained from a device code flow /// (or any other OAuth flow) for in-memory caching only. Use -/// [`OAuthStrategy::using_store`] to load a token from disk and persist +/// [`OAuthStrategy::with_profile`] to load a token from disk and persist /// refreshed tokens back to the store. /// /// # Example @@ -24,7 +24,7 @@ use crate::{ensure_trailing_slash, AuthError, AuthStrategy, SecretToken, Token}; /// /// # fn run(token: Token) -> Result<(), Box> { /// let region = Region::aws("ap-southeast-2")?; -/// let strategy = OAuthStrategy::new(region, "my-client-id", token)?; +/// let strategy = OAuthStrategy::with_token(region, "my-client-id", token).build()?; /// # Ok(()) /// # } /// ``` @@ -34,20 +34,11 @@ pub struct OAuthStrategy { } impl OAuthStrategy { - /// Create a new `OAuthStrategy` with the given token (in-memory only). + /// Return a builder for configuring an `OAuthStrategy` from a token. /// /// The token's `region` and `client_id` fields are set before caching. /// No token store is used — tokens are not persisted to disk. - pub fn new( - region: Region, - client_id: impl Into, - token: Token, - ) -> Result { - Self::builder(region, client_id, token).build() - } - - /// Return a builder for configuring an `OAuthStrategy` from a token. - pub fn builder( + pub fn with_token( region: Region, client_id: impl Into, token: Token, @@ -62,25 +53,15 @@ impl OAuthStrategy { } } - /// Create an `OAuthStrategy` by loading a token from the given store. + /// Return a builder for configuring an `OAuthStrategy` from a profile store. + /// + /// The token is loaded from the store when [`OAuthStrategyBuilder::build`] is called. + /// The builder allows further configuration (e.g. overriding the base URL) before building. /// /// The token must have `region` and `client_id` set (as saved by /// [`DeviceCodeStrategy`](crate::DeviceCodeStrategy) or a prior /// `OAuthStrategy`). The store is used for persisting refreshed tokens. - /// - /// # Errors - /// - /// Returns [`AuthError::NotAuthenticated`] if the token file is missing, - /// or if the stored token is missing `region` or `client_id`. - pub fn using_store(store: ProfileStore) -> Result { - Self::from_store(store).build() - } - - /// Return a builder for configuring an `OAuthStrategy` from a token store. - /// - /// The token is loaded from the store immediately. The builder allows - /// further configuration (e.g. overriding the base URL) before building. - pub fn from_store(store: ProfileStore) -> OAuthStrategyBuilder { + pub fn with_profile(store: ProfileStore) -> OAuthStrategyBuilder { OAuthStrategyBuilder { source: OAuthTokenSource::Store(store), base_url_override: None, @@ -113,7 +94,7 @@ enum OAuthTokenSource { /// Builder for [`OAuthStrategy`]. /// -/// Created via [`OAuthStrategy::builder`] or [`OAuthStrategy::from_store`]. +/// Created via [`OAuthStrategy::with_token`] or [`OAuthStrategy::with_profile`]. pub struct OAuthStrategyBuilder { source: OAuthTokenSource, base_url_override: Option, From a175605dda1e89b54c8158746a26773ac8842c9d Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Wed, 11 Mar 2026 15:00:46 +1100 Subject: [PATCH 074/686] =?UTF-8?q?=E2=9C=A8=20feat:=20add=20typed=20Acces?= =?UTF-8?q?sKey=20with=20format=20validation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce an `AccessKey` type in stack-auth that validates the `CSAK.` format via `FromStr`. This replaces the raw `SecretToken` parameter on `AccessKeyStrategy`, making the API self-documenting and catching malformed keys at parse time rather than at the auth server. --- packages/stack-auth/src/access_key.rs | 141 ++++++++++++++++++ .../stack-auth/src/access_key_strategy.rs | 19 ++- packages/stack-auth/src/auto_strategy.rs | 23 ++- packages/stack-auth/src/lib.rs | 2 + 4 files changed, 170 insertions(+), 15 deletions(-) create mode 100644 packages/stack-auth/src/access_key.rs diff --git a/packages/stack-auth/src/access_key.rs b/packages/stack-auth/src/access_key.rs new file mode 100644 index 000000000..f4471f432 --- /dev/null +++ b/packages/stack-auth/src/access_key.rs @@ -0,0 +1,141 @@ +use std::fmt; +use std::str::FromStr; + +use crate::SecretToken; + +/// The prefix that all CipherStash access keys start with. +const ACCESS_KEY_PREFIX: &str = "CSAK"; + +/// A CipherStash access key. +/// +/// Access keys have the format `CSAK.` and are used to +/// authenticate with the CipherStash Token Service (CTS). +/// +/// The inner value is stored as a [`SecretToken`], so it is zeroized on drop +/// and hidden from debug output. +/// +/// # Parsing +/// +/// ``` +/// use stack_auth::AccessKey; +/// +/// let key: AccessKey = "CSAKmyKeyId.myKeySecret".parse().unwrap(); +/// ``` +/// +/// Invalid keys are rejected: +/// +/// ``` +/// use stack_auth::AccessKey; +/// +/// assert!("not-a-valid-key".parse::().is_err()); +/// assert!("CSAKmissing-dot".parse::().is_err()); +/// assert!("CSAK.no-key-id".parse::().is_err()); +/// assert!("CSAKno-secret.".parse::().is_err()); +/// ``` +pub struct AccessKey(SecretToken); + +impl AccessKey { + /// Expose the underlying [`SecretToken`]. + pub(crate) fn into_secret_token(self) -> SecretToken { + self.0 + } +} + +impl FromStr for AccessKey { + type Err = InvalidAccessKey; + + fn from_str(s: &str) -> Result { + let rest = s + .strip_prefix(ACCESS_KEY_PREFIX) + .ok_or(InvalidAccessKey::MissingPrefix)?; + + let (id, secret) = rest.split_once('.').ok_or(InvalidAccessKey::MissingDot)?; + + if id.is_empty() { + return Err(InvalidAccessKey::EmptyKeyId); + } + if secret.is_empty() { + return Err(InvalidAccessKey::EmptySecret); + } + + Ok(Self(SecretToken::new(s))) + } +} + +impl fmt::Debug for AccessKey { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("AccessKey(***)") + } +} + +/// Error returned when parsing an invalid access key string. +#[derive(Debug, thiserror::Error)] +pub enum InvalidAccessKey { + /// The string does not start with the `CSAK` prefix. + #[error("access key must start with \"{ACCESS_KEY_PREFIX}\"")] + MissingPrefix, + /// No `.` separator found between key ID and secret. + #[error("access key must contain a \".\" separator")] + MissingDot, + /// The key ID portion (before the `.`) is empty. + #[error("access key ID must not be empty")] + EmptyKeyId, + /// The secret portion (after the `.`) is empty. + #[error("access key secret must not be empty")] + EmptySecret, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn valid_key() { + let key: AccessKey = + "CSAKT4ZMT2AUPXI7TCD2.ZAQRW2BWXP3Z6SHR4YG2TP3N35LLU46ZAWLR3BL5WUR4IIGA" + .parse() + .unwrap(); + assert_eq!( + key.0.as_str(), + "CSAKT4ZMT2AUPXI7TCD2.ZAQRW2BWXP3Z6SHR4YG2TP3N35LLU46ZAWLR3BL5WUR4IIGA" + ); + } + + #[test] + fn missing_prefix() { + let err = "key_id.key_secret".parse::().unwrap_err(); + assert!(matches!(err, InvalidAccessKey::MissingPrefix)); + } + + #[test] + fn missing_dot() { + let err = "CSAKnodot".parse::().unwrap_err(); + assert!(matches!(err, InvalidAccessKey::MissingDot)); + } + + #[test] + fn empty_key_id() { + let err = "CSAK.secret".parse::().unwrap_err(); + assert!(matches!(err, InvalidAccessKey::EmptyKeyId)); + } + + #[test] + fn empty_secret() { + let err = "CSAKid.".parse::().unwrap_err(); + assert!(matches!(err, InvalidAccessKey::EmptySecret)); + } + + #[test] + fn empty_string() { + let err = "".parse::().unwrap_err(); + assert!(matches!(err, InvalidAccessKey::MissingPrefix)); + } + + #[test] + fn debug_does_not_leak() { + let key: AccessKey = "CSAKid.secret".parse().unwrap(); + let debug = format!("{key:?}"); + assert!(!debug.contains("secret")); + assert_eq!(debug, "AccessKey(***)"); + } +} diff --git a/packages/stack-auth/src/access_key_strategy.rs b/packages/stack-auth/src/access_key_strategy.rs index acd72b279..5f536a278 100644 --- a/packages/stack-auth/src/access_key_strategy.rs +++ b/packages/stack-auth/src/access_key_strategy.rs @@ -1,5 +1,6 @@ use cts_common::{Crn, CtsServiceDiscovery, Region, ServiceDiscovery}; +use crate::access_key::AccessKey; use crate::access_key_refresher::AccessKeyRefresher; use crate::auto_refresh::AutoRefresh; use crate::{ensure_trailing_slash, AuthError, AuthStrategy, SecretToken}; @@ -13,11 +14,12 @@ use crate::{ensure_trailing_slash, AuthError, AuthStrategy, SecretToken}; /// # Example /// /// ```no_run -/// use stack_auth::{AccessKeyStrategy, SecretToken}; +/// use stack_auth::{AccessKey, AccessKeyStrategy}; /// use cts_common::Region; /// /// let region = Region::aws("ap-southeast-2").unwrap(); -/// let strategy = AccessKeyStrategy::new(region, SecretToken::new("my-key")).unwrap(); +/// let key: AccessKey = "CSAKmyKeyId.myKeySecret".parse().unwrap(); +/// let strategy = AccessKeyStrategy::new(region, key).unwrap(); /// ``` pub struct AccessKeyStrategy { crn: Option, @@ -28,7 +30,7 @@ impl AccessKeyStrategy { /// Create a new `AccessKeyStrategy` for the given region and access key. /// /// The auth endpoint is resolved automatically via service discovery. - pub fn new(region: Region, access_key: SecretToken) -> Result { + pub fn new(region: Region, access_key: AccessKey) -> Result { Self::builder(region, access_key).build() } @@ -36,7 +38,7 @@ impl AccessKeyStrategy { /// /// The region is extracted from the CRN for service discovery. /// The full CRN is stored and available via [`workspace_crn`](Self::workspace_crn). - pub fn new_with_crn(crn: Crn, access_key: SecretToken) -> Result { + pub fn new_with_crn(crn: Crn, access_key: AccessKey) -> Result { Self::builder(crn.region, access_key).crn(crn).build() } @@ -45,19 +47,20 @@ impl AccessKeyStrategy { /// # Example /// /// ```no_run - /// use stack_auth::{AccessKeyStrategy, SecretToken}; + /// use stack_auth::{AccessKey, AccessKeyStrategy}; /// use cts_common::Region; /// /// let region = Region::aws("ap-southeast-2").unwrap(); - /// let strategy = AccessKeyStrategy::builder(region, SecretToken::new("my-key")) + /// let key: AccessKey = "CSAKmyKeyId.myKeySecret".parse().unwrap(); + /// let strategy = AccessKeyStrategy::builder(region, key) /// .audience("my-audience") /// .build() /// .unwrap(); /// ``` - pub fn builder(region: Region, access_key: SecretToken) -> AccessKeyStrategyBuilder { + pub fn builder(region: Region, access_key: AccessKey) -> AccessKeyStrategyBuilder { AccessKeyStrategyBuilder { region, - access_key, + access_key: access_key.into_secret_token(), audience: None, base_url_override: None, crn: None, diff --git a/packages/stack-auth/src/auto_strategy.rs b/packages/stack-auth/src/auto_strategy.rs index 438629290..56973d6c0 100644 --- a/packages/stack-auth/src/auto_strategy.rs +++ b/packages/stack-auth/src/auto_strategy.rs @@ -5,6 +5,7 @@ use crate::oauth_strategy::OAuthStrategy; use stack_profile::ProfileStore; use crate::{AuthError, AuthStrategy, SecretToken, Token}; +use std::str::FromStr; /// An [`AuthStrategy`] that automatically detects available credentials /// and delegates to the appropriate inner strategy. @@ -61,7 +62,9 @@ impl AutoStrategy { if let Some(access_key) = access_key { let crn_str = crn.ok_or(AuthError::NotAuthenticated)?; let crn: Crn = crn_str.parse().map_err(AuthError::InvalidCrn)?; - let strategy = AccessKeyStrategy::new_with_crn(crn, SecretToken::new(access_key))?; + let key = crate::AccessKey::from_str(&access_key) + .map_err(|e| AuthError::InvalidToken(e.to_string()))?; + let strategy = AccessKeyStrategy::new_with_crn(crn, key)?; return Ok(Self::AccessKey(strategy)); } @@ -152,8 +155,11 @@ mod tests { #[test] fn access_key_with_valid_crn() { - let result = - AutoStrategy::detect(Some("my-access-key".into()), Some(VALID_CRN.into()), None); + let result = AutoStrategy::detect( + Some("CSAKtestKeyId.testKeySecret".into()), + Some(VALID_CRN.into()), + None, + ); assert!(result.is_ok()); assert!(matches!(result.unwrap(), AutoStrategy::AccessKey(_))); @@ -161,15 +167,18 @@ mod tests { #[test] fn access_key_without_crn_returns_not_authenticated() { - let result = AutoStrategy::detect(Some("my-access-key".into()), None, None); + let result = AutoStrategy::detect(Some("CSAKtestKeyId.testKeySecret".into()), None, None); assert!(matches!(result, Err(AuthError::NotAuthenticated))); } #[test] fn access_key_with_invalid_crn_returns_invalid_crn() { - let result = - AutoStrategy::detect(Some("my-access-key".into()), Some("not-a-crn".into()), None); + let result = AutoStrategy::detect( + Some("CSAKtestKeyId.testKeySecret".into()), + Some("not-a-crn".into()), + None, + ); assert!(matches!(result, Err(AuthError::InvalidCrn(_)))); } @@ -208,7 +217,7 @@ mod tests { let store = write_token_store(dir.path()); let result = AutoStrategy::detect( - Some("my-access-key".into()), + Some("CSAKtestKeyId.testKeySecret".into()), Some(VALID_CRN.into()), Some(store), ); diff --git a/packages/stack-auth/src/lib.rs b/packages/stack-auth/src/lib.rs index 22a07f18a..6fb775bf9 100644 --- a/packages/stack-auth/src/lib.rs +++ b/packages/stack-auth/src/lib.rs @@ -71,6 +71,7 @@ use std::time::Duration; use vitaminc::protected::OpaqueDebug; use zeroize::ZeroizeOnDrop; +mod access_key; mod access_key_refresher; mod access_key_strategy; mod auto_refresh; @@ -84,6 +85,7 @@ mod token; #[cfg(any(test, feature = "test-utils"))] mod static_token_strategy; +pub use access_key::{AccessKey, InvalidAccessKey}; pub use access_key_strategy::{AccessKeyStrategy, AccessKeyStrategyBuilder}; pub use auto_strategy::AutoStrategy; pub use device_code::{DeviceCodeStrategy, DeviceCodeStrategyBuilder, PendingDeviceCode}; From 3cc27f23dab390d2da7ef805d6be94cbcfc77a86 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Thu, 12 Mar 2026 14:28:26 +1100 Subject: [PATCH 075/686] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor:=20addres?= =?UTF-8?q?s=20PR=20feedback=20on=20AccessKey=20type?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add `AuthError::InvalidAccessKey` variant with `#[from]` so `AutoStrategy` can use `?` instead of manual `map_err`. Add test for `into_secret_token()`. Add cross-reference comment noting parallel validation logic in `cts-domain::UnverifiedAccessKey`. --- packages/stack-auth/src/access_key.rs | 10 ++++++++++ packages/stack-auth/src/auto_strategy.rs | 4 +--- packages/stack-auth/src/lib.rs | 3 +++ 3 files changed, 14 insertions(+), 3 deletions(-) diff --git a/packages/stack-auth/src/access_key.rs b/packages/stack-auth/src/access_key.rs index f4471f432..1f8c53661 100644 --- a/packages/stack-auth/src/access_key.rs +++ b/packages/stack-auth/src/access_key.rs @@ -41,6 +41,9 @@ impl AccessKey { } } +// NOTE: The format validation here mirrors `UnverifiedAccessKey::new()` in +// `cts-domain`. If the `CSAK.` format changes, both +// locations must be updated. impl FromStr for AccessKey { type Err = InvalidAccessKey; @@ -131,6 +134,13 @@ mod tests { assert!(matches!(err, InvalidAccessKey::MissingPrefix)); } + #[test] + fn into_secret_token() { + let key: AccessKey = "CSAKmyKeyId.myKeySecret".parse().unwrap(); + let secret = key.into_secret_token(); + assert_eq!(secret.as_str(), "CSAKmyKeyId.myKeySecret"); + } + #[test] fn debug_does_not_leak() { let key: AccessKey = "CSAKid.secret".parse().unwrap(); diff --git a/packages/stack-auth/src/auto_strategy.rs b/packages/stack-auth/src/auto_strategy.rs index 56973d6c0..cd35a8c11 100644 --- a/packages/stack-auth/src/auto_strategy.rs +++ b/packages/stack-auth/src/auto_strategy.rs @@ -5,7 +5,6 @@ use crate::oauth_strategy::OAuthStrategy; use stack_profile::ProfileStore; use crate::{AuthError, AuthStrategy, SecretToken, Token}; -use std::str::FromStr; /// An [`AuthStrategy`] that automatically detects available credentials /// and delegates to the appropriate inner strategy. @@ -62,8 +61,7 @@ impl AutoStrategy { if let Some(access_key) = access_key { let crn_str = crn.ok_or(AuthError::NotAuthenticated)?; let crn: Crn = crn_str.parse().map_err(AuthError::InvalidCrn)?; - let key = crate::AccessKey::from_str(&access_key) - .map_err(|e| AuthError::InvalidToken(e.to_string()))?; + let key: crate::AccessKey = access_key.parse()?; let strategy = AccessKeyStrategy::new_with_crn(crn, key)?; return Ok(Self::AccessKey(strategy)); } diff --git a/packages/stack-auth/src/lib.rs b/packages/stack-auth/src/lib.rs index 6fb775bf9..00257d0d9 100644 --- a/packages/stack-auth/src/lib.rs +++ b/packages/stack-auth/src/lib.rs @@ -168,6 +168,9 @@ pub enum AuthError { /// A token (access token or device code) has expired. #[error("Token expired")] TokenExpired, + /// The access key string is malformed (e.g. missing `CSAK` prefix or `.` separator). + #[error("Invalid access key: {0}")] + InvalidAccessKey(#[from] access_key::InvalidAccessKey), /// The JWT could not be decoded or its claims are malformed. #[error("Invalid token: {0}")] InvalidToken(String), From c255491384c25cd862b09e55e74cea2aee7e2793 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Thu, 12 Mar 2026 14:30:28 +1100 Subject: [PATCH 076/686] =?UTF-8?q?=E2=9C=85=20test:=20add=20test=20for=20?= =?UTF-8?q?invalid=20access=20key=20format=20in=20AutoStrategy::detect?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/stack-auth/src/auto_strategy.rs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/packages/stack-auth/src/auto_strategy.rs b/packages/stack-auth/src/auto_strategy.rs index cd35a8c11..89864bea6 100644 --- a/packages/stack-auth/src/auto_strategy.rs +++ b/packages/stack-auth/src/auto_strategy.rs @@ -170,6 +170,14 @@ mod tests { assert!(matches!(result, Err(AuthError::NotAuthenticated))); } + #[test] + fn invalid_access_key_format_returns_invalid_access_key() { + let result = + AutoStrategy::detect(Some("not-a-valid-key".into()), Some(VALID_CRN.into()), None); + + assert!(matches!(result, Err(AuthError::InvalidAccessKey(_)))); + } + #[test] fn access_key_with_invalid_crn_returns_invalid_crn() { let result = AutoStrategy::detect( From 11f2031edc026becc9f719ec58a364802f18288f Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Wed, 11 Mar 2026 21:02:52 +1100 Subject: [PATCH 077/686] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor:=20use=20?= =?UTF-8?q?vitaminc=20OpaqueDebug=20derive=20for=20AccessKey?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/stack-auth/src/access_key.rs | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/packages/stack-auth/src/access_key.rs b/packages/stack-auth/src/access_key.rs index 1f8c53661..85ff5fa11 100644 --- a/packages/stack-auth/src/access_key.rs +++ b/packages/stack-auth/src/access_key.rs @@ -1,7 +1,7 @@ -use std::fmt; use std::str::FromStr; use crate::SecretToken; +use vitaminc::protected::OpaqueDebug; /// The prefix that all CipherStash access keys start with. const ACCESS_KEY_PREFIX: &str = "CSAK"; @@ -32,6 +32,7 @@ const ACCESS_KEY_PREFIX: &str = "CSAK"; /// assert!("CSAK.no-key-id".parse::().is_err()); /// assert!("CSAKno-secret.".parse::().is_err()); /// ``` +#[derive(OpaqueDebug)] pub struct AccessKey(SecretToken); impl AccessKey { @@ -65,12 +66,6 @@ impl FromStr for AccessKey { } } -impl fmt::Debug for AccessKey { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.write_str("AccessKey(***)") - } -} - /// Error returned when parsing an invalid access key string. #[derive(Debug, thiserror::Error)] pub enum InvalidAccessKey { @@ -146,6 +141,6 @@ mod tests { let key: AccessKey = "CSAKid.secret".parse().unwrap(); let debug = format!("{key:?}"); assert!(!debug.contains("secret")); - assert_eq!(debug, "AccessKey(***)"); + assert!(debug.contains("AccessKey") && debug.contains("***"), "debug should hide secret: {debug}"); } } From b7ca13ba1acc699f64a98f70b22c60e68186b388 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Thu, 12 Mar 2026 08:05:02 +1100 Subject: [PATCH 078/686] =?UTF-8?q?=E2=9C=A8=20feat:=20add=20ServiceToken?= =?UTF-8?q?=20type=20with=20JWT=20claim=20decoding?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AuthStrategy::get_token() now returns ServiceToken instead of SecretToken. ServiceToken eagerly decodes JWT iss/aud claims at construction time, enabling callers to derive service endpoints directly from the token without separate token loading and decoding steps. --- packages/stack-auth/src/access_key.rs | 5 +- .../stack-auth/src/access_key_strategy.rs | 4 +- packages/stack-auth/src/auto_refresh.rs | 36 ++-- packages/stack-auth/src/auto_strategy.rs | 5 +- packages/stack-auth/src/lib.rs | 4 +- packages/stack-auth/src/oauth_strategy.rs | 4 +- packages/stack-auth/src/service_token.rs | 168 ++++++++++++++++++ .../stack-auth/src/static_token_strategy.rs | 8 +- 8 files changed, 206 insertions(+), 28 deletions(-) create mode 100644 packages/stack-auth/src/service_token.rs diff --git a/packages/stack-auth/src/access_key.rs b/packages/stack-auth/src/access_key.rs index 85ff5fa11..cef3285eb 100644 --- a/packages/stack-auth/src/access_key.rs +++ b/packages/stack-auth/src/access_key.rs @@ -141,6 +141,9 @@ mod tests { let key: AccessKey = "CSAKid.secret".parse().unwrap(); let debug = format!("{key:?}"); assert!(!debug.contains("secret")); - assert!(debug.contains("AccessKey") && debug.contains("***"), "debug should hide secret: {debug}"); + assert!( + debug.contains("AccessKey") && debug.contains("***"), + "debug should hide secret: {debug}" + ); } } diff --git a/packages/stack-auth/src/access_key_strategy.rs b/packages/stack-auth/src/access_key_strategy.rs index 5f536a278..73eff62d9 100644 --- a/packages/stack-auth/src/access_key_strategy.rs +++ b/packages/stack-auth/src/access_key_strategy.rs @@ -3,7 +3,7 @@ use cts_common::{Crn, CtsServiceDiscovery, Region, ServiceDiscovery}; use crate::access_key::AccessKey; use crate::access_key_refresher::AccessKeyRefresher; use crate::auto_refresh::AutoRefresh; -use crate::{ensure_trailing_slash, AuthError, AuthStrategy, SecretToken}; +use crate::{ensure_trailing_slash, AuthError, AuthStrategy, SecretToken, ServiceToken}; /// An [`AuthStrategy`] that uses a static access key to authenticate. /// @@ -74,7 +74,7 @@ impl AccessKeyStrategy { } impl AuthStrategy for &AccessKeyStrategy { - async fn get_token(self) -> Result { + async fn get_token(self) -> Result { Ok(self.inner.get_token().await?) } } diff --git a/packages/stack-auth/src/auto_refresh.rs b/packages/stack-auth/src/auto_refresh.rs index a2038b6ae..71d2af8ac 100644 --- a/packages/stack-auth/src/auto_refresh.rs +++ b/packages/stack-auth/src/auto_refresh.rs @@ -1,7 +1,7 @@ use tokio::sync::Mutex; use crate::refresher::Refresher; -use crate::{SecretToken, Token}; +use crate::{ServiceToken, Token}; /// Internal errors from [`AutoRefresh::get_token`]. /// @@ -156,7 +156,7 @@ impl AutoRefresh { impl AutoRefresh { /// Retrieve a valid access token, refreshing or re-authenticating as needed. - pub(crate) async fn get_token(&self) -> Result { + pub(crate) async fn get_token(&self) -> Result { let mut state = self.state.lock().await; // No cached token — attempt initial auth. @@ -168,10 +168,10 @@ impl AutoRefresh { match self.refresher.refresh(&credential).await { Ok(new_token) => { self.refresher.save(&new_token); - let access_token = new_token.access_token().clone(); + let service_token = ServiceToken::new(new_token.access_token().clone()); state.token = Some(new_token); state.refresh_in_progress = false; - return Ok(access_token); + return Ok(service_token); } Err(err) => { state.refresh_in_progress = false; @@ -184,14 +184,14 @@ impl AutoRefresh { if !needs_refresh { // Token is fresh — clone and return. let token = state.token.as_ref().ok_or(AutoRefreshError::NotFound)?; - return Ok(token.access_token().clone()); + return Ok(ServiceToken::new(token.access_token().clone())); } // Check cascade prevention flag. if state.refresh_in_progress { let token = state.token.as_ref().ok_or(AutoRefreshError::NotFound)?; if token.is_usable() { - return Ok(token.access_token().clone()); + return Ok(ServiceToken::new(token.access_token().clone())); } // NOTE: If a refresh was started while the token was still usable // (lock released) but the token has since crossed its real expiry, @@ -211,7 +211,7 @@ impl AutoRefresh { // No credential available (e.g. OAuth with no refresh token). let token = state.token.as_ref().ok_or(AutoRefreshError::NotFound)?; if token.is_usable() { - return Ok(token.access_token().clone()); + return Ok(ServiceToken::new(token.access_token().clone())); } return Err(AutoRefreshError::Expired); }; @@ -224,12 +224,14 @@ impl AutoRefresh { if is_usable { // Token is expiring but still usable. Clone the current access // token, drop the lock, and refresh in the background of this call. - let current_access_token = state - .token - .as_ref() - .ok_or(AutoRefreshError::NotFound)? - .access_token() - .clone(); + let current_service_token = ServiceToken::new( + state + .token + .as_ref() + .ok_or(AutoRefreshError::NotFound)? + .access_token() + .clone(), + ); drop(state); match self.refresher.refresh(&credential).await { @@ -249,16 +251,16 @@ impl AutoRefresh { } } - Ok(current_access_token) + Ok(current_service_token) } else { // Token is fully expired. Refresh while holding the lock. match self.refresher.refresh(&credential).await { Ok(new_token) => { self.refresher.save(&new_token); - let access_token = new_token.access_token().clone(); + let service_token = ServiceToken::new(new_token.access_token().clone()); state.token = Some(new_token); state.refresh_in_progress = false; - Ok(access_token) + Ok(service_token) } Err(err) => { tracing::warn!(%err, "token refresh failed"); @@ -277,6 +279,7 @@ impl AutoRefresh { mod tests { use super::*; use crate::oauth_refresher::OAuthRefresher; + use crate::SecretToken; use mocktail::prelude::*; use stack_profile::ProfileStore; use std::sync::Arc; @@ -715,6 +718,7 @@ mod tests { mod stress_tests { use super::*; use crate::oauth_refresher::OAuthRefresher; + use crate::SecretToken; use stack_profile::ProfileStore; use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::Arc; diff --git a/packages/stack-auth/src/auto_strategy.rs b/packages/stack-auth/src/auto_strategy.rs index 89864bea6..6046040f1 100644 --- a/packages/stack-auth/src/auto_strategy.rs +++ b/packages/stack-auth/src/auto_strategy.rs @@ -4,7 +4,7 @@ use crate::access_key_strategy::AccessKeyStrategy; use crate::oauth_strategy::OAuthStrategy; use stack_profile::ProfileStore; -use crate::{AuthError, AuthStrategy, SecretToken, Token}; +use crate::{AuthError, AuthStrategy, ServiceToken, Token}; /// An [`AuthStrategy`] that automatically detects available credentials /// and delegates to the appropriate inner strategy. @@ -100,7 +100,7 @@ impl AutoStrategy { } impl AuthStrategy for &AutoStrategy { - async fn get_token(self) -> Result { + async fn get_token(self) -> Result { match self { AutoStrategy::AccessKey(inner) => inner.get_token().await, AutoStrategy::OAuth(inner) => inner.get_token().await, @@ -111,6 +111,7 @@ impl AuthStrategy for &AutoStrategy { #[cfg(test)] mod tests { use super::*; + use crate::{SecretToken, Token}; use std::time::{SystemTime, UNIX_EPOCH}; const VALID_CRN: &str = "crn:ap-southeast-2.aws:ZVATKW3VHMFG27DY"; diff --git a/packages/stack-auth/src/lib.rs b/packages/stack-auth/src/lib.rs index 00257d0d9..b2da376e2 100644 --- a/packages/stack-auth/src/lib.rs +++ b/packages/stack-auth/src/lib.rs @@ -80,6 +80,7 @@ mod device_code; mod oauth_refresher; mod oauth_strategy; mod refresher; +mod service_token; mod token; #[cfg(any(test, feature = "test-utils"))] @@ -90,6 +91,7 @@ pub use access_key_strategy::{AccessKeyStrategy, AccessKeyStrategyBuilder}; pub use auto_strategy::AutoStrategy; pub use device_code::{DeviceCodeStrategy, DeviceCodeStrategyBuilder, PendingDeviceCode}; pub use oauth_strategy::{OAuthStrategy, OAuthStrategyBuilder}; +pub use service_token::ServiceToken; #[cfg(any(test, feature = "test-utils"))] pub use static_token_strategy::StaticTokenStrategy; pub use token::Token; @@ -107,7 +109,7 @@ pub use stack_profile::DeviceIdentity; /// shared references (e.g. `&OAuthStrategy`) without consuming the strategy. pub trait AuthStrategy: Send { /// Retrieve a valid access token, refreshing or re-authenticating as needed. - fn get_token(self) -> impl Future> + Send; + fn get_token(self) -> impl Future> + Send; } /// A sensitive token string that is zeroized on drop and hidden from debug output. diff --git a/packages/stack-auth/src/oauth_strategy.rs b/packages/stack-auth/src/oauth_strategy.rs index 8430c6e0c..a5bfe864a 100644 --- a/packages/stack-auth/src/oauth_strategy.rs +++ b/packages/stack-auth/src/oauth_strategy.rs @@ -5,7 +5,7 @@ use stack_profile::ProfileStore; use crate::auto_refresh::AutoRefresh; use crate::oauth_refresher::OAuthRefresher; -use crate::{ensure_trailing_slash, AuthError, AuthStrategy, SecretToken, Token}; +use crate::{ensure_trailing_slash, AuthError, AuthStrategy, ServiceToken, Token}; /// An [`AuthStrategy`] that uses OAuth refresh tokens to maintain a valid access token. /// @@ -75,7 +75,7 @@ impl OAuthStrategy { } impl AuthStrategy for &OAuthStrategy { - async fn get_token(self) -> Result { + async fn get_token(self) -> Result { Ok(self.inner.get_token().await?) } } diff --git a/packages/stack-auth/src/service_token.rs b/packages/stack-auth/src/service_token.rs new file mode 100644 index 000000000..08b94e424 --- /dev/null +++ b/packages/stack-auth/src/service_token.rs @@ -0,0 +1,168 @@ +use cts_common::claims::Audience; +use url::Url; +use vitaminc::protected::OpaqueDebug; +use zeroize::ZeroizeOnDrop; + +use crate::{AuthError, SecretToken}; + +/// A token returned by an [`AuthStrategy`](crate::AuthStrategy) that carries +/// both the bearer credential and (when the token is a JWT) decoded service +/// discovery claims. +/// +/// # JWT claims +/// +/// If the underlying token string is a valid JWT, the `iss` and `aud` claims +/// are eagerly decoded at construction time. `issuer()` returns the `iss` URL +/// (the CTS host for this workspace) and `audience()` returns the `aud` claim +/// (the ZeroKMS endpoint). +/// +/// For non-JWT tokens (e.g. static test tokens), both methods return +/// `Err(AuthError::InvalidToken)`. +/// +/// # Security +/// +/// Like [`SecretToken`], the inner credential, this is zeroized on drop and hidden +/// from [`Debug`] output. +#[derive(Clone, OpaqueDebug, ZeroizeOnDrop)] +pub struct ServiceToken { + secret: SecretToken, + #[zeroize(skip)] + decoded: Option, +} + +#[derive(Clone, Debug)] +struct DecodedClaims { + issuer: Url, + audience: Audience, +} + +impl ServiceToken { + /// Create a `ServiceToken` from a [`SecretToken`]. + /// + /// If the token string is a valid JWT with `iss` and `aud` claims, they + /// are decoded eagerly. If decoding fails (not a JWT, missing claims, etc.) + /// the token is still usable as a bearer credential — `issuer()` and + /// `audience()` will simply return an error. + pub fn new(secret: SecretToken) -> Self { + let decoded = Self::try_decode(&secret); + Self { secret, decoded } + } + + /// Expose the inner token string for use as a bearer credential. + pub fn as_str(&self) -> &str { + self.secret.as_str() + } + + /// Return the `iss` (issuer) URL from the JWT claims. + /// + /// In CipherStash tokens the issuer is the CTS host URL for the workspace. + /// + /// # Errors + /// + /// Returns [`AuthError::InvalidToken`] if the token is not a valid JWT or + /// the `iss` claim could not be parsed as a URL. + pub fn issuer(&self) -> Result<&Url, AuthError> { + self.decoded + .as_ref() + .map(|d| &d.issuer) + .ok_or_else(|| AuthError::InvalidToken("token is not a valid JWT".into())) + } + + /// Return the `aud` (audience) from the JWT claims. + /// + /// # Errors + /// + /// Returns [`AuthError::InvalidToken`] if the token is not a valid JWT. + pub fn audience(&self) -> Result<&Audience, AuthError> { + self.decoded + .as_ref() + .map(|d| &d.audience) + .ok_or_else(|| AuthError::InvalidToken("token is not a valid JWT".into())) + } + + /// Attempt to decode the JWT claims from the token string. + /// NOTE: This does not verify the token signature or validate any claims, it only decodes the claims if the token is a well-formed JWT. + fn try_decode(secret: &SecretToken) -> Option { + use jsonwebtoken::{decode, decode_header, DecodingKey, Validation}; + use std::collections::HashSet; + + let token_str = secret.as_str(); + let header = decode_header(token_str).ok()?; + + let dummy_key = DecodingKey::from_secret(&[]); + let mut validation = Validation::new(header.alg); + validation.validate_exp = false; + validation.validate_aud = false; + validation.required_spec_claims = HashSet::new(); + validation.insecure_disable_signature_validation(); + + let data: jsonwebtoken::TokenData = + decode(token_str, &dummy_key, &validation).ok()?; + + let issuer: Url = data.claims.iss.parse().ok()?; + + Some(DecodedClaims { + issuer, + audience: data.claims.aud, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn make_jwt(iss: &str, aud: &str) -> String { + use jsonwebtoken::{encode, EncodingKey, Header}; + use std::time::{SystemTime, UNIX_EPOCH}; + + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs(); + + let claims = serde_json::json!({ + "iss": iss, + "sub": "CS|test-user", + "aud": aud, + "iat": now, + "exp": now + 3600, + "workspace": "ZVATKW3VHMFG27DY", + "scope": "", + }); + + encode( + &Header::default(), + &claims, + &EncodingKey::from_secret(b"test-secret"), + ) + .unwrap() + } + + #[test] + fn jwt_token_provides_issuer_and_audience() { + let jwt = make_jwt("https://cts.example.com/", "https://zerokms.example.com/"); + let token = ServiceToken::new(SecretToken::new(jwt.clone())); + + assert_eq!(token.as_str(), jwt); + assert_eq!(token.issuer().unwrap().as_str(), "https://cts.example.com/"); + assert!(token.audience().is_ok()); + } + + #[test] + fn non_jwt_token_returns_errors() { + let token = ServiceToken::new(SecretToken::new("not-a-jwt")); + + assert_eq!(token.as_str(), "not-a-jwt"); + assert!(token.issuer().is_err()); + assert!(token.audience().is_err()); + } + + #[test] + fn debug_does_not_leak_secret() { + let jwt = make_jwt("https://cts.example.com/", "https://zerokms.example.com/"); + let token = ServiceToken::new(SecretToken::new(jwt.clone())); + let debug = format!("{:?}", token); + assert!(!debug.contains(&jwt)); + } +} diff --git a/packages/stack-auth/src/static_token_strategy.rs b/packages/stack-auth/src/static_token_strategy.rs index 40f3fe6df..66b86f692 100644 --- a/packages/stack-auth/src/static_token_strategy.rs +++ b/packages/stack-auth/src/static_token_strategy.rs @@ -1,4 +1,4 @@ -use crate::{AuthError, AuthStrategy, SecretToken}; +use crate::{AuthError, AuthStrategy, SecretToken, ServiceToken}; /// A simple [`AuthStrategy`] that always returns a fixed token. /// @@ -6,7 +6,7 @@ use crate::{AuthError, AuthStrategy, SecretToken}; /// server or via federation) and just needs to be presented as-is. /// /// ``` -/// use stack_auth::{StaticTokenStrategy, AuthStrategy, SecretToken}; +/// use stack_auth::{StaticTokenStrategy, AuthStrategy}; /// /// # async fn example() { /// let strategy = StaticTokenStrategy::new("my-token"); @@ -24,7 +24,7 @@ impl StaticTokenStrategy { } impl AuthStrategy for &StaticTokenStrategy { - async fn get_token(self) -> Result { - Ok(self.0.clone()) + async fn get_token(self) -> Result { + Ok(ServiceToken::new(self.0.clone())) } } From ea7733721d4ee4ab5ce61577ec4c20ffe55ac10a Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Thu, 12 Mar 2026 20:56:01 +1100 Subject: [PATCH 079/686] =?UTF-8?q?=F0=9F=93=9D=20docs:=20clarify=20Servic?= =?UTF-8?q?eToken=20supports=20CipherStash=20service=20tokens=20only?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Update doc comments to explain that JWT decoding uses cts_common::claims::Claims, so only CipherStash-issued service tokens (from CTS or the access-key exchange) will have their claims resolved. --- packages/stack-auth/src/service_token.rs | 25 +++++++++++++----------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/packages/stack-auth/src/service_token.rs b/packages/stack-auth/src/service_token.rs index 08b94e424..d011b0cc8 100644 --- a/packages/stack-auth/src/service_token.rs +++ b/packages/stack-auth/src/service_token.rs @@ -5,24 +5,27 @@ use zeroize::ZeroizeOnDrop; use crate::{AuthError, SecretToken}; -/// A token returned by an [`AuthStrategy`](crate::AuthStrategy) that carries -/// both the bearer credential and (when the token is a JWT) decoded service -/// discovery claims. +/// A CipherStash service token returned by an [`AuthStrategy`](crate::AuthStrategy). /// -/// # JWT claims +/// Wraps a bearer credential ([`SecretToken`]) together with eagerly decoded +/// JWT claims that are used for service discovery. The JWT is decoded (but +/// **not** signature-verified) using [`cts_common::claims::Claims`], so only +/// CipherStash-issued service tokens (from CTS or the access-key exchange) +/// will have their claims resolved. /// -/// If the underlying token string is a valid JWT, the `iss` and `aud` claims -/// are eagerly decoded at construction time. `issuer()` returns the `iss` URL -/// (the CTS host for this workspace) and `audience()` returns the `aud` claim -/// (the ZeroKMS endpoint). +/// # Decoded claims /// -/// For non-JWT tokens (e.g. static test tokens), both methods return +/// * `issuer()` — the `iss` URL, i.e. the CTS host for this workspace. +/// * `audience()` — the `aud` claim, typically the ZeroKMS endpoint. +/// +/// For non-JWT tokens (e.g. static test tokens) or JWTs that don't match +/// the CipherStash claims schema, both methods return /// `Err(AuthError::InvalidToken)`. /// /// # Security /// -/// Like [`SecretToken`], the inner credential, this is zeroized on drop and hidden -/// from [`Debug`] output. +/// Like [`SecretToken`], this is zeroized on drop and hidden from [`Debug`] +/// output. #[derive(Clone, OpaqueDebug, ZeroizeOnDrop)] pub struct ServiceToken { secret: SecretToken, From 7c998ac8b51029c818b720475f37e44df4358254 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Thu, 12 Mar 2026 21:26:01 +1100 Subject: [PATCH 080/686] =?UTF-8?q?=F0=9F=A5=85=20fix:=20preserve=20specif?= =?UTF-8?q?ic=20decode=20errors=20in=20ServiceToken?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Store Result instead of Option so that issuer()/audience() return actionable error messages (e.g. "failed to decode JWT header", "iss claim is not a valid URL") instead of a generic "token is not a valid JWT". --- packages/stack-auth/src/service_token.rs | 36 ++++++++++++++++-------- 1 file changed, 24 insertions(+), 12 deletions(-) diff --git a/packages/stack-auth/src/service_token.rs b/packages/stack-auth/src/service_token.rs index d011b0cc8..7c9f61ce0 100644 --- a/packages/stack-auth/src/service_token.rs +++ b/packages/stack-auth/src/service_token.rs @@ -30,7 +30,7 @@ use crate::{AuthError, SecretToken}; pub struct ServiceToken { secret: SecretToken, #[zeroize(skip)] - decoded: Option, + decoded: Result, } #[derive(Clone, Debug)] @@ -68,7 +68,7 @@ impl ServiceToken { self.decoded .as_ref() .map(|d| &d.issuer) - .ok_or_else(|| AuthError::InvalidToken("token is not a valid JWT".into())) + .map_err(|reason| AuthError::InvalidToken(reason.clone())) } /// Return the `aud` (audience) from the JWT claims. @@ -80,17 +80,20 @@ impl ServiceToken { self.decoded .as_ref() .map(|d| &d.audience) - .ok_or_else(|| AuthError::InvalidToken("token is not a valid JWT".into())) + .map_err(|reason| AuthError::InvalidToken(reason.clone())) } /// Attempt to decode the JWT claims from the token string. - /// NOTE: This does not verify the token signature or validate any claims, it only decodes the claims if the token is a well-formed JWT. - fn try_decode(secret: &SecretToken) -> Option { + /// + /// NOTE: This does not verify the token signature or validate any claims, + /// it only decodes the claims if the token is a well-formed JWT. + fn try_decode(secret: &SecretToken) -> Result { use jsonwebtoken::{decode, decode_header, DecodingKey, Validation}; use std::collections::HashSet; let token_str = secret.as_str(); - let header = decode_header(token_str).ok()?; + let header = + decode_header(token_str).map_err(|e| format!("failed to decode JWT header: {e}"))?; let dummy_key = DecodingKey::from_secret(&[]); let mut validation = Validation::new(header.alg); @@ -100,11 +103,16 @@ impl ServiceToken { validation.insecure_disable_signature_validation(); let data: jsonwebtoken::TokenData = - decode(token_str, &dummy_key, &validation).ok()?; + decode(token_str, &dummy_key, &validation) + .map_err(|e| format!("failed to decode JWT claims: {e}"))?; - let issuer: Url = data.claims.iss.parse().ok()?; + let issuer: Url = data + .claims + .iss + .parse() + .map_err(|e| format!("iss claim is not a valid URL: {e}"))?; - Some(DecodedClaims { + Ok(DecodedClaims { issuer, audience: data.claims.aud, }) @@ -153,12 +161,16 @@ mod tests { } #[test] - fn non_jwt_token_returns_errors() { + fn non_jwt_token_returns_errors_with_reason() { let token = ServiceToken::new(SecretToken::new("not-a-jwt")); assert_eq!(token.as_str(), "not-a-jwt"); - assert!(token.issuer().is_err()); - assert!(token.audience().is_err()); + + let err = token.issuer().unwrap_err().to_string(); + assert!( + err.contains("failed to decode JWT header"), + "expected specific decode error, got: {err}" + ); } #[test] From 3be78b224dd3626fe9e44d9bd52eefc52add9ea6 Mon Sep 17 00:00:00 2001 From: James Sadler Date: Fri, 13 Mar 2026 15:16:57 +1100 Subject: [PATCH 081/686] fix(stack-auth): prevent HTTP connection leaks in access key refresher Reuse a single HTTP client instance per AccessKeyRefresher instead of creating a new client on every authentication call. Configure connection pool settings with idle timeout and max idle connections per host to properly manage and close idle connections. This prevents accumulation of idle HTTP connections that were not being properly cleaned up. --- packages/stack-auth/src/access_key_refresher.rs | 6 +++++- packages/stack-auth/src/lib.rs | 7 ++++++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/packages/stack-auth/src/access_key_refresher.rs b/packages/stack-auth/src/access_key_refresher.rs index ba9ed13f8..f424cb04b 100644 --- a/packages/stack-auth/src/access_key_refresher.rs +++ b/packages/stack-auth/src/access_key_refresher.rs @@ -1,3 +1,4 @@ +use std::sync::Arc; use std::time::{SystemTime, UNIX_EPOCH}; use url::Url; @@ -14,6 +15,7 @@ pub(crate) struct AccessKeyRefresher { access_key: SecretToken, base_url: Url, audience: Option, + http_client: Arc, } impl AccessKeyRefresher { @@ -22,6 +24,7 @@ impl AccessKeyRefresher { access_key, base_url, audience, + http_client: Arc::new(http_client()), } } } @@ -46,7 +49,8 @@ impl Refresher for AccessKeyRefresher { tracing::debug!(url = %url, "authenticating with access key"); - let resp = http_client() + let resp = self + .http_client .post(url) .json(&AuthoriseRequest { access_key: self.access_key.as_str(), diff --git a/packages/stack-auth/src/lib.rs b/packages/stack-auth/src/lib.rs index b2da376e2..622cdfc8d 100644 --- a/packages/stack-auth/src/lib.rs +++ b/packages/stack-auth/src/lib.rs @@ -219,13 +219,18 @@ pub(crate) fn ensure_trailing_slash(mut url: url::Url) -> url::Url { pub(crate) fn http_client() -> reqwest::Client { #[cfg(any(test, feature = "test-utils"))] { - reqwest::Client::new() + reqwest::Client::builder() + .pool_max_idle_per_host(10) + .build() + .unwrap_or_else(|_| reqwest::Client::new()) } #[cfg(not(any(test, feature = "test-utils")))] { reqwest::Client::builder() .connect_timeout(Duration::from_secs(10)) .timeout(Duration::from_secs(30)) + .pool_idle_timeout(Duration::from_secs(5)) + .pool_max_idle_per_host(10) .build() .unwrap_or_else(|_| reqwest::Client::new()) } From d97e5af1aee2908371cc09f9a874dbeac6c1f8e6 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Sat, 14 Mar 2026 20:48:47 +1100 Subject: [PATCH 082/686] =?UTF-8?q?ci:=20=F0=9F=9A=80=20enable=20CI=20work?= =?UTF-8?q?flows=20for=20stacked=20PRs=20with=20non-main=20base=20branches?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove `branches: [main]` filter from `pull_request` triggers in all 16 workflow files so CI runs on PRs regardless of base branch. The `push` triggers remain filtered to `main` for post-merge runs, and path filters are unchanged. --- .github/imported-workflows/test-stack-auth.yml | 2 -- 1 file changed, 2 deletions(-) diff --git a/.github/imported-workflows/test-stack-auth.yml b/.github/imported-workflows/test-stack-auth.yml index 1e612d019..0b513cec5 100644 --- a/.github/imported-workflows/test-stack-auth.yml +++ b/.github/imported-workflows/test-stack-auth.yml @@ -11,8 +11,6 @@ on: - "!**.example" pull_request: - branches: - - main paths: - packages/stack-auth/** - .github/workflows/test-stack-auth.yml From 60c8d954f2e0b8872b5dde06226862042dc604cc Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Sat, 14 Mar 2026 20:48:47 +1100 Subject: [PATCH 083/686] =?UTF-8?q?ci:=20=F0=9F=9A=80=20enable=20CI=20work?= =?UTF-8?q?flows=20for=20stacked=20PRs=20with=20non-main=20base=20branches?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove `branches: [main]` filter from `pull_request` triggers in all 16 workflow files so CI runs on PRs regardless of base branch. The `push` triggers remain filtered to `main` for post-merge runs, and path filters are unchanged. --- .github/imported-workflows/test-stack-auth.yml | 2 -- 1 file changed, 2 deletions(-) diff --git a/.github/imported-workflows/test-stack-auth.yml b/.github/imported-workflows/test-stack-auth.yml index 1e612d019..0b513cec5 100644 --- a/.github/imported-workflows/test-stack-auth.yml +++ b/.github/imported-workflows/test-stack-auth.yml @@ -11,8 +11,6 @@ on: - "!**.example" pull_request: - branches: - - main paths: - packages/stack-auth/** - .github/workflows/test-stack-auth.yml From c13dc4dc1462bd94d796d57e29ad2782e2c00e33 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Fri, 13 Mar 2026 12:41:15 +1100 Subject: [PATCH 084/686] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor:=20remove?= =?UTF-8?q?=20CRN=20requirement=20from=20ZeroKMSBuilder?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolve the ZeroKMS endpoint from the token's `aud` claim instead of requiring a CRN for service discovery. This simplifies client initialization — callers now only need an AuthStrategy. - Add `Audience::first()` and `ServiceToken::zerokms_url()` - Make `ZeroKMSBuilder::new()` take only credentials (no CRN, no Result) - Make all `build()` and `create_client*()` methods async - Update all call sites with `.await` --- packages/stack-auth/src/service_token.rs | 72 ++++++++++++++++++++++++ 1 file changed, 72 insertions(+) diff --git a/packages/stack-auth/src/service_token.rs b/packages/stack-auth/src/service_token.rs index 7c9f61ce0..640f1a427 100644 --- a/packages/stack-auth/src/service_token.rs +++ b/packages/stack-auth/src/service_token.rs @@ -83,6 +83,36 @@ impl ServiceToken { .map_err(|reason| AuthError::InvalidToken(reason.clone())) } + /// Return the ZeroKMS URL derived from the first `aud` claim. + /// + /// The `aud` claim typically contains a bare hostname (e.g. + /// `ap-southeast-2.aws.viturhosted.net`). This method prepends `https://` + /// unless the value already contains a scheme or looks like a localhost address + /// (in which case `http://` is used). + /// + /// # Errors + /// + /// Returns [`AuthError::InvalidToken`] if the token is not a valid JWT, + /// the audience is empty, or the resulting string is not a valid URL. + pub fn zerokms_url(&self) -> Result { + let aud = self.audience()?; + let raw = aud + .first() + .ok_or_else(|| AuthError::InvalidToken("aud claim is empty".into()))?; + + let url_str = if raw.starts_with("http://") || raw.starts_with("https://") { + raw + } else if raw.starts_with("localhost") || raw.starts_with("127.0.0.1") { + format!("http://{raw}") + } else { + format!("https://{raw}") + }; + + url_str + .parse() + .map_err(|e| AuthError::InvalidToken(format!("aud is not a valid URL: {e}"))) + } + /// Attempt to decode the JWT claims from the token string. /// /// NOTE: This does not verify the token signature or validate any claims, @@ -173,6 +203,48 @@ mod tests { ); } + #[test] + fn zerokms_url_prepends_https_for_bare_hostname() { + let jwt = make_jwt( + "https://cts.example.com/", + "ap-southeast-2.aws.viturhosted.net", + ); + let token = ServiceToken::new(SecretToken::new(jwt)); + assert_eq!( + token.zerokms_url().unwrap().as_str(), + "https://ap-southeast-2.aws.viturhosted.net/" + ); + } + + #[test] + fn zerokms_url_prepends_http_for_localhost() { + let jwt = make_jwt("https://cts.example.com/", "localhost:3002"); + let token = ServiceToken::new(SecretToken::new(jwt)); + assert_eq!( + token.zerokms_url().unwrap().as_str(), + "http://localhost:3002/" + ); + } + + #[test] + fn zerokms_url_keeps_existing_scheme() { + let jwt = make_jwt( + "https://cts.example.com/", + "https://zerokms.example.com/", + ); + let token = ServiceToken::new(SecretToken::new(jwt)); + assert_eq!( + token.zerokms_url().unwrap().as_str(), + "https://zerokms.example.com/" + ); + } + + #[test] + fn zerokms_url_errors_for_non_jwt() { + let token = ServiceToken::new(SecretToken::new("not-a-jwt")); + assert!(token.zerokms_url().is_err()); + } + #[test] fn debug_does_not_leak_secret() { let jwt = make_jwt("https://cts.example.com/", "https://zerokms.example.com/"); From b48bffcb77d1b7128372ebff99a30f5f10e486d1 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Sat, 14 Mar 2026 21:20:13 +1100 Subject: [PATCH 085/686] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor:=20resolv?= =?UTF-8?q?e=20ZeroKMS=20URL=20from=20`services`=20claim=20instead=20of=20?= =?UTF-8?q?`aud`?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the fragile scheme-guessing logic in `ServiceToken::zerokms_url()` (which inferred http/https from the `aud` hostname) with a direct lookup from the `services` claim added in feat/add-services-claim-to-jwt. - Add `services: Services` to `DecodedClaims`, remove `audience` field - Remove `audience()` method (only used by the old `zerokms_url()` impl) - `zerokms_url()` now reads `ServiceType::ZeroKms` from the services map; returns an error if the claim is absent (no fallback to `aud`) - Update tests to use the new `services`-based JWT helper - Update doc comments in `ZeroKMSBuilder` to reference `services` claim --- packages/stack-auth/src/service_token.rs | 125 +++++++++++------------ 1 file changed, 62 insertions(+), 63 deletions(-) diff --git a/packages/stack-auth/src/service_token.rs b/packages/stack-auth/src/service_token.rs index 640f1a427..960400887 100644 --- a/packages/stack-auth/src/service_token.rs +++ b/packages/stack-auth/src/service_token.rs @@ -1,4 +1,4 @@ -use cts_common::claims::Audience; +use cts_common::claims::{ServiceType, Services}; use url::Url; use vitaminc::protected::OpaqueDebug; use zeroize::ZeroizeOnDrop; @@ -16,7 +16,7 @@ use crate::{AuthError, SecretToken}; /// # Decoded claims /// /// * `issuer()` — the `iss` URL, i.e. the CTS host for this workspace. -/// * `audience()` — the `aud` claim, typically the ZeroKMS endpoint. +/// * `zerokms_url()` — the ZeroKMS endpoint from the `services` claim. /// /// For non-JWT tokens (e.g. static test tokens) or JWTs that don't match /// the CipherStash claims schema, both methods return @@ -36,16 +36,16 @@ pub struct ServiceToken { #[derive(Clone, Debug)] struct DecodedClaims { issuer: Url, - audience: Audience, + services: Services, } impl ServiceToken { /// Create a `ServiceToken` from a [`SecretToken`]. /// - /// If the token string is a valid JWT with `iss` and `aud` claims, they - /// are decoded eagerly. If decoding fails (not a JWT, missing claims, etc.) - /// the token is still usable as a bearer credential — `issuer()` and - /// `audience()` will simply return an error. + /// If the token string is a valid JWT with `iss` and `services` claims, + /// they are decoded eagerly. If decoding fails (not a JWT, missing claims, + /// etc.) the token is still usable as a bearer credential — `issuer()` and + /// `zerokms_url()` will simply return an error. pub fn new(secret: SecretToken) -> Self { let decoded = Self::try_decode(&secret); Self { secret, decoded } @@ -71,46 +71,30 @@ impl ServiceToken { .map_err(|reason| AuthError::InvalidToken(reason.clone())) } - /// Return the `aud` (audience) from the JWT claims. + /// Return the ZeroKMS endpoint URL from the `services` claim. /// - /// # Errors - /// - /// Returns [`AuthError::InvalidToken`] if the token is not a valid JWT. - pub fn audience(&self) -> Result<&Audience, AuthError> { - self.decoded - .as_ref() - .map(|d| &d.audience) - .map_err(|reason| AuthError::InvalidToken(reason.clone())) - } - - /// Return the ZeroKMS URL derived from the first `aud` claim. - /// - /// The `aud` claim typically contains a bare hostname (e.g. - /// `ap-southeast-2.aws.viturhosted.net`). This method prepends `https://` - /// unless the value already contains a scheme or looks like a localhost address - /// (in which case `http://` is used). + /// CTS-issued JWTs include a `services` claim containing a map of service + /// type to endpoint URL. This method looks up the `zerokms` entry. /// /// # Errors /// - /// Returns [`AuthError::InvalidToken`] if the token is not a valid JWT, - /// the audience is empty, or the resulting string is not a valid URL. + /// Returns [`AuthError::InvalidToken`] if the token is not a valid JWT or + /// the `services` claim does not include a ZeroKMS endpoint. pub fn zerokms_url(&self) -> Result { - let aud = self.audience()?; - let raw = aud - .first() - .ok_or_else(|| AuthError::InvalidToken("aud claim is empty".into()))?; - - let url_str = if raw.starts_with("http://") || raw.starts_with("https://") { - raw - } else if raw.starts_with("localhost") || raw.starts_with("127.0.0.1") { - format!("http://{raw}") - } else { - format!("https://{raw}") - }; - - url_str - .parse() - .map_err(|e| AuthError::InvalidToken(format!("aud is not a valid URL: {e}"))) + let decoded = self + .decoded + .as_ref() + .map_err(|reason| AuthError::InvalidToken(reason.clone()))?; + + decoded + .services + .get(ServiceType::ZeroKms) + .cloned() + .ok_or_else(|| { + AuthError::InvalidToken( + "Token does not include a ZeroKMS endpoint in the services claim".into(), + ) + }) } /// Attempt to decode the JWT claims from the token string. @@ -144,7 +128,7 @@ impl ServiceToken { Ok(DecodedClaims { issuer, - audience: data.claims.aud, + services: data.claims.services, }) } } @@ -152,8 +136,9 @@ impl ServiceToken { #[cfg(test)] mod tests { use super::*; + use std::collections::BTreeMap; - fn make_jwt(iss: &str, aud: &str) -> String { + fn make_jwt(iss: &str, services: Option>) -> String { use jsonwebtoken::{encode, EncodingKey, Header}; use std::time::{SystemTime, UNIX_EPOCH}; @@ -162,16 +147,20 @@ mod tests { .unwrap() .as_secs(); - let claims = serde_json::json!({ + let mut claims = serde_json::json!({ "iss": iss, "sub": "CS|test-user", - "aud": aud, + "aud": "legacy-aud-value", "iat": now, "exp": now + 3600, "workspace": "ZVATKW3VHMFG27DY", "scope": "", }); + if let Some(svc) = services { + claims["services"] = serde_json::to_value(svc).unwrap(); + } + encode( &Header::default(), &claims, @@ -180,14 +169,20 @@ mod tests { .unwrap() } + fn services_with_zerokms(url: &str) -> Option> { + Some(BTreeMap::from([("zerokms", url)])) + } + #[test] - fn jwt_token_provides_issuer_and_audience() { - let jwt = make_jwt("https://cts.example.com/", "https://zerokms.example.com/"); + fn jwt_token_provides_issuer() { + let jwt = make_jwt( + "https://cts.example.com/", + services_with_zerokms("https://zerokms.example.com/"), + ); let token = ServiceToken::new(SecretToken::new(jwt.clone())); assert_eq!(token.as_str(), jwt); assert_eq!(token.issuer().unwrap().as_str(), "https://cts.example.com/"); - assert!(token.audience().is_ok()); } #[test] @@ -204,21 +199,24 @@ mod tests { } #[test] - fn zerokms_url_prepends_https_for_bare_hostname() { + fn zerokms_url_from_services_claim() { let jwt = make_jwt( "https://cts.example.com/", - "ap-southeast-2.aws.viturhosted.net", + services_with_zerokms("https://zerokms.example.com/"), ); let token = ServiceToken::new(SecretToken::new(jwt)); assert_eq!( token.zerokms_url().unwrap().as_str(), - "https://ap-southeast-2.aws.viturhosted.net/" + "https://zerokms.example.com/" ); } #[test] - fn zerokms_url_prepends_http_for_localhost() { - let jwt = make_jwt("https://cts.example.com/", "localhost:3002"); + fn zerokms_url_from_services_claim_localhost() { + let jwt = make_jwt( + "https://cts.example.com/", + services_with_zerokms("http://localhost:3002/"), + ); let token = ServiceToken::new(SecretToken::new(jwt)); assert_eq!( token.zerokms_url().unwrap().as_str(), @@ -227,15 +225,13 @@ mod tests { } #[test] - fn zerokms_url_keeps_existing_scheme() { - let jwt = make_jwt( - "https://cts.example.com/", - "https://zerokms.example.com/", - ); + fn zerokms_url_errors_when_services_claim_missing() { + let jwt = make_jwt("https://cts.example.com/", None); let token = ServiceToken::new(SecretToken::new(jwt)); - assert_eq!( - token.zerokms_url().unwrap().as_str(), - "https://zerokms.example.com/" + let err = token.zerokms_url().unwrap_err().to_string(); + assert!( + err.contains("services claim"), + "expected services claim error, got: {err}" ); } @@ -247,7 +243,10 @@ mod tests { #[test] fn debug_does_not_leak_secret() { - let jwt = make_jwt("https://cts.example.com/", "https://zerokms.example.com/"); + let jwt = make_jwt( + "https://cts.example.com/", + services_with_zerokms("https://zerokms.example.com/"), + ); let token = ServiceToken::new(SecretToken::new(jwt.clone())); let debug = format!("{:?}", token); assert!(!debug.contains(&jwt)); From 79b7ec4580d47304a6144a14e9e5a15efa94e583 Mon Sep 17 00:00:00 2001 From: Toby Hede Date: Mon, 16 Mar 2026 14:50:23 +1100 Subject: [PATCH 086/686] fix(deps): remove stale stack-auth/node/Cargo.lock MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This lockfile is unused — stack-auth/node is a workspace member and cargo uses the root Cargo.lock. The stale lockfile contained aws-lc-sys 0.37.1 (needs >= 0.38.0) and quinn-proto 0.11.13 (needs >= 0.11.14), triggering Dependabot alerts. Resolves CIP-2857 (aws-lc-sys) and CIP-2899 (quinn-proto) for cipherstash-suite. --- languages/typescript/packages/auth/Cargo.lock | 3313 ----------------- 1 file changed, 3313 deletions(-) delete mode 100644 languages/typescript/packages/auth/Cargo.lock diff --git a/languages/typescript/packages/auth/Cargo.lock b/languages/typescript/packages/auth/Cargo.lock deleted file mode 100644 index ad684989c..000000000 --- a/languages/typescript/packages/auth/Cargo.lock +++ /dev/null @@ -1,3313 +0,0 @@ -# This file is automatically @generated by Cargo. -# It is not intended for manual editing. -version = 4 - -[[package]] -name = "addr2line" -version = "0.25.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b5d307320b3181d6d7954e663bd7c774a838b8220fe0593c86d9fb09f498b4b" -dependencies = [ - "gimli", -] - -[[package]] -name = "adler2" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" - -[[package]] -name = "aho-corasick" -version = "1.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" -dependencies = [ - "memchr", -] - -[[package]] -name = "alloc-no-stdlib" -version = "2.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" - -[[package]] -name = "alloc-stdlib" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94fb8275041c72129eb51b7d0322c29b8387a0386127718b096429201a5d6ece" -dependencies = [ - "alloc-no-stdlib", -] - -[[package]] -name = "anyhow" -version = "1.0.101" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f0e0fee31ef5ed1ba1316088939cea399010ed7731dba877ed44aeb407a75ea" - -[[package]] -name = "aquamarine" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f50776554130342de4836ba542aa85a4ddb361690d7e8df13774d7284c3d5c2" -dependencies = [ - "include_dir", - "itertools 0.10.5", - "proc-macro-error2", - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "arrayvec" -version = "0.7.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" -dependencies = [ - "serde", -] - -[[package]] -name = "async-compression" -version = "0.4.39" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68650b7df54f0293fd061972a0fb05aaf4fc0879d3b3d21a638a182c5c543b9f" -dependencies = [ - "compression-codecs", - "compression-core", - "pin-project-lite", - "tokio", -] - -[[package]] -name = "async-trait" -version = "0.1.89" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "atomic-waker" -version = "1.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" - -[[package]] -name = "autocfg" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" - -[[package]] -name = "aws-lc-rs" -version = "1.15.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b7b6141e96a8c160799cc2d5adecd5cbbe5054cb8c7c4af53da0f83bb7ad256" -dependencies = [ - "aws-lc-sys", - "untrusted 0.7.1", - "zeroize", -] - -[[package]] -name = "aws-lc-sys" -version = "0.37.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b092fe214090261288111db7a2b2c2118e5a7f30dc2569f1732c4069a6840549" -dependencies = [ - "cc", - "cmake", - "dunce", - "fs_extra", -] - -[[package]] -name = "backtrace" -version = "0.3.76" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb531853791a215d7c62a30daf0dde835f381ab5de4589cfe7c649d2cbe92bd6" -dependencies = [ - "addr2line", - "cfg-if", - "libc", - "miniz_oxide", - "object", - "rustc-demangle", - "windows-link", -] - -[[package]] -name = "backtrace-ext" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "537beee3be4a18fb023b570f80e3ae28003db9167a751266b259926e25539d50" -dependencies = [ - "backtrace", -] - -[[package]] -name = "base32" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "022dfe9eb35f19ebbcb51e0b40a5ab759f46ad60cadf7297e0bd085afb50e076" - -[[package]] -name = "base64" -version = "0.22.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" - -[[package]] -name = "bitflags" -version = "2.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" - -[[package]] -name = "bitvec" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1bc2832c24239b0141d5674bb9174f9d68a8b5b3f2753311927c172ca46f7e9c" -dependencies = [ - "funty", - "radium", - "tap", - "wyz", -] - -[[package]] -name = "block-buffer" -version = "0.10.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" -dependencies = [ - "generic-array", -] - -[[package]] -name = "brotli" -version = "8.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4bd8b9603c7aa97359dbd97ecf258968c95f3adddd6db2f7e7a5bef101c84560" -dependencies = [ - "alloc-no-stdlib", - "alloc-stdlib", - "brotli-decompressor", -] - -[[package]] -name = "brotli-decompressor" -version = "5.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "874bb8112abecc98cbd6d81ea4fa7e94fb9449648c93cc89aa40c81c24d7de03" -dependencies = [ - "alloc-no-stdlib", - "alloc-stdlib", -] - -[[package]] -name = "bumpalo" -version = "3.19.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" - -[[package]] -name = "bytes" -version = "1.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" -dependencies = [ - "serde", -] - -[[package]] -name = "cc" -version = "1.2.56" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aebf35691d1bfb0ac386a69bac2fde4dd276fb618cf8bf4f5318fe285e821bb2" -dependencies = [ - "find-msvc-tools", - "jobserver", - "libc", - "shlex", -] - -[[package]] -name = "cfg-if" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" - -[[package]] -name = "cfg_aliases" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" - -[[package]] -name = "cmake" -version = "0.1.57" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75443c44cd6b379beb8c5b45d85d0773baf31cce901fe7bb252f4eff3008ef7d" -dependencies = [ - "cc", -] - -[[package]] -name = "compression-codecs" -version = "0.4.36" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00828ba6fd27b45a448e57dbfe84f1029d4c9f26b368157e9a448a5f49a2ec2a" -dependencies = [ - "brotli", - "compression-core", - "flate2", - "memchr", -] - -[[package]] -name = "compression-core" -version = "0.4.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75984efb6ed102a0d42db99afb6c1948f0380d1d91808d5529916e6c08b49d8d" - -[[package]] -name = "convert_case" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec182b0ca2f35d8fc196cf3404988fd8b8c739a4d270ff118a398feb0cbec1ca" -dependencies = [ - "unicode-segmentation", -] - -[[package]] -name = "convert_case" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" -dependencies = [ - "unicode-segmentation", -] - -[[package]] -name = "crc32fast" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" -dependencies = [ - "cfg-if", -] - -[[package]] -name = "critical-section" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" - -[[package]] -name = "crossbeam-channel" -version = "0.5.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" -dependencies = [ - "crossbeam-utils", -] - -[[package]] -name = "crossbeam-epoch" -version = "0.9.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" -dependencies = [ - "crossbeam-utils", -] - -[[package]] -name = "crossbeam-utils" -version = "0.8.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" - -[[package]] -name = "crypto-common" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" -dependencies = [ - "generic-array", - "typenum", -] - -[[package]] -name = "ctor" -version = "0.2.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a2785755761f3ddc1492979ce1e48d2c00d09311c39e4466429188f3dd6501" -dependencies = [ - "quote", - "syn", -] - -[[package]] -name = "cts-common" -version = "0.4.1" -dependencies = [ - "arrayvec", - "base32", - "derive_more", - "either", - "miette", - "nom", - "regex", - "serde", - "thiserror 1.0.69", - "url", - "utoipa", - "uuid", - "vitaminc", -] - -[[package]] -name = "data-encoding" -version = "2.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" - -[[package]] -name = "deranged" -version = "0.5.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" -dependencies = [ - "powerfmt", -] - -[[package]] -name = "derive_more" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" -dependencies = [ - "derive_more-impl", -] - -[[package]] -name = "derive_more-impl" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" -dependencies = [ - "convert_case 0.10.0", - "proc-macro2", - "quote", - "rustc_version", - "syn", - "unicode-xid", -] - -[[package]] -name = "digest" -version = "0.10.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" -dependencies = [ - "block-buffer", - "crypto-common", -] - -[[package]] -name = "dirs" -version = "4.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca3aa72a6f96ea37bbc5aa912f6788242832f75369bdfdadcb0e38423f100059" -dependencies = [ - "dirs-sys", -] - -[[package]] -name = "dirs-sys" -version = "0.3.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b1d1d91c932ef41c0f2663aa8b0ca0342d444d842c06914aa0a7e352d0bada6" -dependencies = [ - "libc", - "redox_users", - "winapi", -] - -[[package]] -name = "displaydoc" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "dunce" -version = "1.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" - -[[package]] -name = "either" -version = "1.15.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" -dependencies = [ - "serde", -] - -[[package]] -name = "enum-as-inner" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1e6a265c649f3f5979b601d26f1d05ada116434c87741c9493cb56218f76cbc" -dependencies = [ - "heck", - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "equivalent" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" - -[[package]] -name = "errno" -version = "0.3.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" -dependencies = [ - "libc", - "windows-sys 0.61.2", -] - -[[package]] -name = "find-msvc-tools" -version = "0.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" - -[[package]] -name = "flate2" -version = "1.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" -dependencies = [ - "crc32fast", - "miniz_oxide", -] - -[[package]] -name = "fnv" -version = "1.0.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" - -[[package]] -name = "foldhash" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" - -[[package]] -name = "form_urlencoded" -version = "1.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" -dependencies = [ - "percent-encoding", -] - -[[package]] -name = "fs_extra" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" - -[[package]] -name = "funty" -version = "2.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" - -[[package]] -name = "futures" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" -dependencies = [ - "futures-channel", - "futures-core", - "futures-executor", - "futures-io", - "futures-sink", - "futures-task", - "futures-util", -] - -[[package]] -name = "futures-channel" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" -dependencies = [ - "futures-core", - "futures-sink", -] - -[[package]] -name = "futures-core" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" - -[[package]] -name = "futures-executor" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" -dependencies = [ - "futures-core", - "futures-task", - "futures-util", -] - -[[package]] -name = "futures-io" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" - -[[package]] -name = "futures-macro" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "futures-sink" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" - -[[package]] -name = "futures-task" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" - -[[package]] -name = "futures-util" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" -dependencies = [ - "futures-channel", - "futures-core", - "futures-io", - "futures-macro", - "futures-sink", - "futures-task", - "memchr", - "pin-project-lite", - "pin-utils", - "slab", -] - -[[package]] -name = "generic-array" -version = "0.14.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" -dependencies = [ - "typenum", - "version_check", -] - -[[package]] -name = "getrandom" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" -dependencies = [ - "cfg-if", - "js-sys", - "libc", - "wasi", - "wasm-bindgen", -] - -[[package]] -name = "getrandom" -version = "0.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" -dependencies = [ - "cfg-if", - "js-sys", - "libc", - "r-efi", - "wasip2", - "wasm-bindgen", -] - -[[package]] -name = "getrandom" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "139ef39800118c7683f2fd3c98c1b23c09ae076556b435f8e9064ae108aaeeec" -dependencies = [ - "cfg-if", - "libc", - "r-efi", - "wasip2", - "wasip3", -] - -[[package]] -name = "gimli" -version = "0.32.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" - -[[package]] -name = "h2" -version = "0.4.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54" -dependencies = [ - "atomic-waker", - "bytes", - "fnv", - "futures-core", - "futures-sink", - "http", - "indexmap", - "slab", - "tokio", - "tokio-util", - "tracing", -] - -[[package]] -name = "hashbrown" -version = "0.15.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" -dependencies = [ - "foldhash", -] - -[[package]] -name = "hashbrown" -version = "0.16.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" - -[[package]] -name = "heck" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" - -[[package]] -name = "hickory-proto" -version = "0.25.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8a6fe56c0038198998a6f217ca4e7ef3a5e51f46163bd6dd60b5c71ca6c6502" -dependencies = [ - "async-trait", - "cfg-if", - "data-encoding", - "enum-as-inner", - "futures-channel", - "futures-io", - "futures-util", - "idna", - "ipnet", - "once_cell", - "rand", - "ring", - "thiserror 2.0.18", - "tinyvec", - "tokio", - "tracing", - "url", -] - -[[package]] -name = "hickory-resolver" -version = "0.25.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc62a9a99b0bfb44d2ab95a7208ac952d31060efc16241c87eaf36406fecf87a" -dependencies = [ - "cfg-if", - "futures-util", - "hickory-proto", - "ipconfig", - "moka", - "once_cell", - "parking_lot", - "rand", - "resolv-conf", - "smallvec", - "thiserror 2.0.18", - "tokio", - "tracing", -] - -[[package]] -name = "http" -version = "1.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" -dependencies = [ - "bytes", - "itoa", -] - -[[package]] -name = "http-body" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" -dependencies = [ - "bytes", - "http", -] - -[[package]] -name = "http-body-util" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" -dependencies = [ - "bytes", - "futures-core", - "http", - "http-body", - "pin-project-lite", -] - -[[package]] -name = "httparse" -version = "1.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" - -[[package]] -name = "httpdate" -version = "1.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" - -[[package]] -name = "hyper" -version = "1.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11" -dependencies = [ - "atomic-waker", - "bytes", - "futures-channel", - "futures-core", - "h2", - "http", - "http-body", - "httparse", - "httpdate", - "itoa", - "pin-project-lite", - "pin-utils", - "smallvec", - "tokio", - "want", -] - -[[package]] -name = "hyper-rustls" -version = "0.27.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" -dependencies = [ - "http", - "hyper", - "hyper-util", - "rustls", - "rustls-pki-types", - "tokio", - "tokio-rustls", - "tower-service", - "webpki-roots", -] - -[[package]] -name = "hyper-util" -version = "0.1.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" -dependencies = [ - "base64", - "bytes", - "futures-channel", - "futures-util", - "http", - "http-body", - "hyper", - "ipnet", - "libc", - "percent-encoding", - "pin-project-lite", - "socket2 0.6.2", - "tokio", - "tower-service", - "tracing", -] - -[[package]] -name = "icu_collections" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" -dependencies = [ - "displaydoc", - "potential_utf", - "yoke", - "zerofrom", - "zerovec", -] - -[[package]] -name = "icu_locale_core" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" -dependencies = [ - "displaydoc", - "litemap", - "tinystr", - "writeable", - "zerovec", -] - -[[package]] -name = "icu_normalizer" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" -dependencies = [ - "icu_collections", - "icu_normalizer_data", - "icu_properties", - "icu_provider", - "smallvec", - "zerovec", -] - -[[package]] -name = "icu_normalizer_data" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" - -[[package]] -name = "icu_properties" -version = "2.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" -dependencies = [ - "icu_collections", - "icu_locale_core", - "icu_properties_data", - "icu_provider", - "zerotrie", - "zerovec", -] - -[[package]] -name = "icu_properties_data" -version = "2.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" - -[[package]] -name = "icu_provider" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" -dependencies = [ - "displaydoc", - "icu_locale_core", - "writeable", - "yoke", - "zerofrom", - "zerotrie", - "zerovec", -] - -[[package]] -name = "id-arena" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" - -[[package]] -name = "idna" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" -dependencies = [ - "idna_adapter", - "smallvec", - "utf8_iter", -] - -[[package]] -name = "idna_adapter" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" -dependencies = [ - "icu_normalizer", - "icu_properties", -] - -[[package]] -name = "include_dir" -version = "0.7.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "923d117408f1e49d914f1a379a309cffe4f18c05cf4e3d12e613a15fc81bd0dd" -dependencies = [ - "include_dir_macros", -] - -[[package]] -name = "include_dir_macros" -version = "0.7.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7cab85a7ed0bd5f0e76d93846e0147172bed2e2d3f859bcc33a8d9699cad1a75" -dependencies = [ - "proc-macro2", - "quote", -] - -[[package]] -name = "indexmap" -version = "2.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" -dependencies = [ - "equivalent", - "hashbrown 0.16.1", - "serde", - "serde_core", -] - -[[package]] -name = "ipconfig" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b58db92f96b720de98181bbbe63c831e87005ab460c1bf306eb2622b4707997f" -dependencies = [ - "socket2 0.5.10", - "widestring", - "windows-sys 0.48.0", - "winreg", -] - -[[package]] -name = "ipnet" -version = "2.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" - -[[package]] -name = "iri-string" -version = "0.7.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c91338f0783edbd6195decb37bae672fd3b165faffb89bf7b9e6942f8b1a731a" -dependencies = [ - "memchr", - "serde", -] - -[[package]] -name = "is-docker" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3" -dependencies = [ - "once_cell", -] - -[[package]] -name = "is-wsl" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5" -dependencies = [ - "is-docker", - "once_cell", -] - -[[package]] -name = "is_ci" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7655c9839580ee829dfacba1d1278c2b7883e50a277ff7541299489d6bdfdc45" - -[[package]] -name = "itertools" -version = "0.10.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" -dependencies = [ - "either", -] - -[[package]] -name = "itertools" -version = "0.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" -dependencies = [ - "either", -] - -[[package]] -name = "itoa" -version = "1.0.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" - -[[package]] -name = "jobserver" -version = "0.1.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" -dependencies = [ - "getrandom 0.3.4", - "libc", -] - -[[package]] -name = "js-sys" -version = "0.3.85" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8c942ebf8e95485ca0d52d97da7c5a2c387d0e7f0ba4c35e93bfcaee045955b3" -dependencies = [ - "once_cell", - "wasm-bindgen", -] - -[[package]] -name = "jsonwebtoken" -version = "9.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a87cc7a48537badeae96744432de36f4be2b4a34a05a5ef32e9dd8a1c169dde" -dependencies = [ - "base64", - "js-sys", - "pem", - "ring", - "serde", - "serde_json", - "simple_asn1", -] - -[[package]] -name = "leb128fmt" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" - -[[package]] -name = "libc" -version = "0.2.182" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112" - -[[package]] -name = "libloading" -version = "0.8.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" -dependencies = [ - "cfg-if", - "windows-link", -] - -[[package]] -name = "libredox" -version = "0.1.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d0b95e02c851351f877147b7deea7b1afb1df71b63aa5f8270716e0c5720616" -dependencies = [ - "bitflags", - "libc", -] - -[[package]] -name = "linux-raw-sys" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" - -[[package]] -name = "litemap" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" - -[[package]] -name = "lock_api" -version = "0.4.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" -dependencies = [ - "scopeguard", -] - -[[package]] -name = "log" -version = "0.4.29" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" - -[[package]] -name = "lru-slab" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" - -[[package]] -name = "memchr" -version = "2.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" - -[[package]] -name = "miette" -version = "7.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f98efec8807c63c752b5bd61f862c165c115b0a35685bdcfd9238c7aeb592b7" -dependencies = [ - "backtrace", - "backtrace-ext", - "cfg-if", - "miette-derive", - "owo-colors", - "supports-color", - "supports-hyperlinks", - "supports-unicode", - "terminal_size", - "textwrap", - "unicode-width 0.1.14", -] - -[[package]] -name = "miette-derive" -version = "7.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db5b29714e950dbb20d5e6f74f9dcec4edbcc1067bb7f8ed198c097b8c1a818b" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "miniz_oxide" -version = "0.8.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" -dependencies = [ - "adler2", - "simd-adler32", -] - -[[package]] -name = "mio" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" -dependencies = [ - "libc", - "wasi", - "windows-sys 0.61.2", -] - -[[package]] -name = "mocktail" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "053f7ba52863e22dfd2970075bbc69c4224ca6ae03896a5f69a0d5982deb5e0a" -dependencies = [ - "bytes", - "futures", - "http", - "http-body", - "http-body-util", - "hyper", - "hyper-util", - "prost", - "rand", - "serde", - "serde_json", - "thiserror 2.0.18", - "tokio", - "tokio-stream", - "tracing", - "url", - "uuid", -] - -[[package]] -name = "moka" -version = "0.12.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4ac832c50ced444ef6be0767a008b02c106a909ba79d1d830501e94b96f6b7e" -dependencies = [ - "crossbeam-channel", - "crossbeam-epoch", - "crossbeam-utils", - "equivalent", - "parking_lot", - "portable-atomic", - "smallvec", - "tagptr", - "uuid", -] - -[[package]] -name = "napi" -version = "2.16.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55740c4ae1d8696773c78fdafd5d0e5fe9bc9f1b071c7ba493ba5c413a9184f3" -dependencies = [ - "bitflags", - "ctor", - "napi-derive", - "napi-sys", - "once_cell", - "tokio", -] - -[[package]] -name = "napi-build" -version = "2.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d376940fd5b723c6893cd1ee3f33abbfd86acb1cd1ec079f3ab04a2a3bc4d3b1" - -[[package]] -name = "napi-derive" -version = "2.16.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7cbe2585d8ac223f7d34f13701434b9d5f4eb9c332cccce8dee57ea18ab8ab0c" -dependencies = [ - "cfg-if", - "convert_case 0.6.0", - "napi-derive-backend", - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "napi-derive-backend" -version = "1.0.75" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1639aaa9eeb76e91c6ae66da8ce3e89e921cd3885e99ec85f4abacae72fc91bf" -dependencies = [ - "convert_case 0.6.0", - "once_cell", - "proc-macro2", - "quote", - "regex", - "semver", - "syn", -] - -[[package]] -name = "napi-sys" -version = "2.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "427802e8ec3a734331fec1035594a210ce1ff4dc5bc1950530920ab717964ea3" -dependencies = [ - "libloading", -] - -[[package]] -name = "nom" -version = "8.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" -dependencies = [ - "memchr", -] - -[[package]] -name = "num-bigint" -version = "0.4.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" -dependencies = [ - "num-integer", - "num-traits", -] - -[[package]] -name = "num-conv" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" - -[[package]] -name = "num-integer" -version = "0.1.46" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" -dependencies = [ - "num-traits", -] - -[[package]] -name = "num-traits" -version = "0.2.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" -dependencies = [ - "autocfg", -] - -[[package]] -name = "object" -version = "0.37.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" -dependencies = [ - "memchr", -] - -[[package]] -name = "once_cell" -version = "1.21.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" -dependencies = [ - "critical-section", - "portable-atomic", -] - -[[package]] -name = "opaque-debug" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" - -[[package]] -name = "open" -version = "5.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43bb73a7fa3799b198970490a51174027ba0d4ec504b03cd08caf513d40024bc" -dependencies = [ - "is-wsl", - "libc", - "pathdiff", -] - -[[package]] -name = "owo-colors" -version = "4.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c6901729fa79e91a0913333229e9ca5dc725089d1c363b2f4b4760709dc4a52" - -[[package]] -name = "parking_lot" -version = "0.12.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" -dependencies = [ - "lock_api", - "parking_lot_core", -] - -[[package]] -name = "parking_lot_core" -version = "0.9.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" -dependencies = [ - "cfg-if", - "libc", - "redox_syscall", - "smallvec", - "windows-link", -] - -[[package]] -name = "pathdiff" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" - -[[package]] -name = "pem" -version = "3.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" -dependencies = [ - "base64", - "serde_core", -] - -[[package]] -name = "percent-encoding" -version = "2.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" - -[[package]] -name = "pin-project-lite" -version = "0.2.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" - -[[package]] -name = "pin-utils" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" - -[[package]] -name = "portable-atomic" -version = "1.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" - -[[package]] -name = "potential_utf" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" -dependencies = [ - "zerovec", -] - -[[package]] -name = "powerfmt" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" - -[[package]] -name = "ppv-lite86" -version = "0.2.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" -dependencies = [ - "zerocopy", -] - -[[package]] -name = "prettyplease" -version = "0.2.37" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" -dependencies = [ - "proc-macro2", - "syn", -] - -[[package]] -name = "proc-macro-error-attr2" -version = "2.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5" -dependencies = [ - "proc-macro2", - "quote", -] - -[[package]] -name = "proc-macro-error2" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802" -dependencies = [ - "proc-macro-error-attr2", - "proc-macro2", - "quote", -] - -[[package]] -name = "proc-macro2" -version = "1.0.106" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "prost" -version = "0.13.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2796faa41db3ec313a31f7624d9286acf277b52de526150b7e69f3debf891ee5" -dependencies = [ - "bytes", - "prost-derive", -] - -[[package]] -name = "prost-derive" -version = "0.13.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a56d757972c98b346a9b766e3f02746cde6dd1cd1d1d563472929fdd74bec4d" -dependencies = [ - "anyhow", - "itertools 0.14.0", - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "quinn" -version = "0.11.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" -dependencies = [ - "bytes", - "cfg_aliases", - "pin-project-lite", - "quinn-proto", - "quinn-udp", - "rustc-hash", - "rustls", - "socket2 0.6.2", - "thiserror 2.0.18", - "tokio", - "tracing", - "web-time", -] - -[[package]] -name = "quinn-proto" -version = "0.11.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31" -dependencies = [ - "bytes", - "getrandom 0.3.4", - "lru-slab", - "rand", - "ring", - "rustc-hash", - "rustls", - "rustls-pki-types", - "slab", - "thiserror 2.0.18", - "tinyvec", - "tracing", - "web-time", -] - -[[package]] -name = "quinn-udp" -version = "0.5.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" -dependencies = [ - "cfg_aliases", - "libc", - "once_cell", - "socket2 0.6.2", - "tracing", - "windows-sys 0.60.2", -] - -[[package]] -name = "quote" -version = "1.0.44" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4" -dependencies = [ - "proc-macro2", -] - -[[package]] -name = "r-efi" -version = "5.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" - -[[package]] -name = "radium" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" - -[[package]] -name = "rand" -version = "0.9.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" -dependencies = [ - "rand_chacha", - "rand_core", -] - -[[package]] -name = "rand_chacha" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" -dependencies = [ - "ppv-lite86", - "rand_core", -] - -[[package]] -name = "rand_core" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" -dependencies = [ - "getrandom 0.3.4", -] - -[[package]] -name = "redox_syscall" -version = "0.5.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" -dependencies = [ - "bitflags", -] - -[[package]] -name = "redox_users" -version = "0.4.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" -dependencies = [ - "getrandom 0.2.17", - "libredox", - "thiserror 1.0.69", -] - -[[package]] -name = "regex" -version = "1.12.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" -dependencies = [ - "aho-corasick", - "memchr", - "regex-automata", - "regex-syntax", -] - -[[package]] -name = "regex-automata" -version = "0.4.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" -dependencies = [ - "aho-corasick", - "memchr", - "regex-syntax", -] - -[[package]] -name = "regex-syntax" -version = "0.8.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a96887878f22d7bad8a3b6dc5b7440e0ada9a245242924394987b21cf2210a4c" - -[[package]] -name = "reqwest" -version = "0.12.28" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" -dependencies = [ - "base64", - "bytes", - "futures-core", - "futures-util", - "hickory-resolver", - "http", - "http-body", - "http-body-util", - "hyper", - "hyper-rustls", - "hyper-util", - "js-sys", - "log", - "once_cell", - "percent-encoding", - "pin-project-lite", - "quinn", - "rustls", - "rustls-pki-types", - "serde", - "serde_json", - "serde_urlencoded", - "sync_wrapper", - "tokio", - "tokio-rustls", - "tokio-util", - "tower", - "tower-http", - "tower-service", - "url", - "wasm-bindgen", - "wasm-bindgen-futures", - "wasm-streams", - "web-sys", - "webpki-roots", -] - -[[package]] -name = "resolv-conf" -version = "0.7.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e061d1b48cb8d38042de4ae0a7a6401009d6143dc80d2e2d6f31f0bdd6470c7" - -[[package]] -name = "ring" -version = "0.17.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" -dependencies = [ - "cc", - "cfg-if", - "getrandom 0.2.17", - "libc", - "untrusted 0.9.0", - "windows-sys 0.52.0", -] - -[[package]] -name = "rmp" -version = "0.8.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ba8be72d372b2c9b35542551678538b562e7cf86c3315773cae48dfbfe7790c" -dependencies = [ - "num-traits", -] - -[[package]] -name = "rmp-serde" -version = "1.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72f81bee8c8ef9b577d1681a70ebbc962c232461e397b22c208c43c04b67a155" -dependencies = [ - "rmp", - "serde", -] - -[[package]] -name = "rustc-demangle" -version = "0.1.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b50b8869d9fc858ce7266cce0194bd74df58b9d0e3f6df3a9fc8eb470d95c09d" - -[[package]] -name = "rustc-hash" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" - -[[package]] -name = "rustc_version" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" -dependencies = [ - "semver", -] - -[[package]] -name = "rustix" -version = "1.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34" -dependencies = [ - "bitflags", - "errno", - "libc", - "linux-raw-sys", - "windows-sys 0.61.2", -] - -[[package]] -name = "rustls" -version = "0.23.36" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c665f33d38cea657d9614f766881e4d510e0eda4239891eea56b4cadcf01801b" -dependencies = [ - "once_cell", - "ring", - "rustls-pki-types", - "rustls-webpki", - "subtle", - "zeroize", -] - -[[package]] -name = "rustls-pki-types" -version = "1.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" -dependencies = [ - "web-time", - "zeroize", -] - -[[package]] -name = "rustls-webpki" -version = "0.103.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53" -dependencies = [ - "ring", - "rustls-pki-types", - "untrusted 0.9.0", -] - -[[package]] -name = "rustversion" -version = "1.0.22" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" - -[[package]] -name = "ryu" -version = "1.0.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" - -[[package]] -name = "scopeguard" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" - -[[package]] -name = "semver" -version = "1.0.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" - -[[package]] -name = "serde" -version = "1.0.228" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" -dependencies = [ - "serde_core", - "serde_derive", -] - -[[package]] -name = "serde_bytes" -version = "0.11.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8" -dependencies = [ - "serde", - "serde_core", -] - -[[package]] -name = "serde_core" -version = "1.0.228" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" -dependencies = [ - "serde_derive", -] - -[[package]] -name = "serde_derive" -version = "1.0.228" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "serde_json" -version = "1.0.149" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" -dependencies = [ - "itoa", - "memchr", - "serde", - "serde_core", - "zmij", -] - -[[package]] -name = "serde_urlencoded" -version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" -dependencies = [ - "form_urlencoded", - "itoa", - "ryu", - "serde", -] - -[[package]] -name = "sha1_smol" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbfa15b3dddfee50a0fff136974b3e1bde555604ba463834a7eb7deb6417705d" - -[[package]] -name = "shlex" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" - -[[package]] -name = "signal-hook-registry" -version = "1.4.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" -dependencies = [ - "errno", - "libc", -] - -[[package]] -name = "simd-adler32" -version = "0.3.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" - -[[package]] -name = "simple_asn1" -version = "0.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d" -dependencies = [ - "num-bigint", - "num-traits", - "thiserror 2.0.18", - "time", -] - -[[package]] -name = "slab" -version = "0.4.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" - -[[package]] -name = "smallvec" -version = "1.15.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" - -[[package]] -name = "socket2" -version = "0.5.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" -dependencies = [ - "libc", - "windows-sys 0.52.0", -] - -[[package]] -name = "socket2" -version = "0.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "86f4aa3ad99f2088c990dfa82d367e19cb29268ed67c574d10d0a4bfe71f07e0" -dependencies = [ - "libc", - "windows-sys 0.60.2", -] - -[[package]] -name = "stable_deref_trait" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" - -[[package]] -name = "stack-auth" -version = "0.1.0" -dependencies = [ - "aquamarine", - "cts-common", - "dirs", - "jsonwebtoken", - "miette", - "open", - "reqwest", - "serde", - "serde_json", - "thiserror 1.0.69", - "tokio", - "tracing", - "url", - "vitaminc", - "zeroize", -] - -[[package]] -name = "stack-auth-node" -version = "0.1.0" -dependencies = [ - "cts-common", - "mocktail", - "napi", - "napi-build", - "napi-derive", - "serde_json", - "stack-auth", - "tokio", - "url", -] - -[[package]] -name = "subtle" -version = "2.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" - -[[package]] -name = "supports-color" -version = "3.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c64fc7232dd8d2e4ac5ce4ef302b1d81e0b80d055b9d77c7c4f51f6aa4c867d6" -dependencies = [ - "is_ci", -] - -[[package]] -name = "supports-hyperlinks" -version = "3.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e396b6523b11ccb83120b115a0b7366de372751aa6edf19844dfb13a6af97e91" - -[[package]] -name = "supports-unicode" -version = "3.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7401a30af6cb5818bb64852270bb722533397edcfc7344954a38f420819ece2" - -[[package]] -name = "syn" -version = "2.0.115" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e614ed320ac28113fa64972c4262d5dbc89deacdfd00c34a3e4cea073243c12" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - -[[package]] -name = "sync_wrapper" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" -dependencies = [ - "futures-core", -] - -[[package]] -name = "synstructure" -version = "0.13.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "tagptr" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417" - -[[package]] -name = "tap" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" - -[[package]] -name = "terminal_size" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "60b8cb979cb11c32ce1603f8137b22262a9d131aaa5c37b5678025f22b8becd0" -dependencies = [ - "rustix", - "windows-sys 0.60.2", -] - -[[package]] -name = "textwrap" -version = "0.16.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c13547615a44dc9c452a8a534638acdf07120d4b6847c8178705da06306a3057" -dependencies = [ - "unicode-linebreak", - "unicode-width 0.2.2", -] - -[[package]] -name = "thiserror" -version = "1.0.69" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" -dependencies = [ - "thiserror-impl 1.0.69", -] - -[[package]] -name = "thiserror" -version = "2.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" -dependencies = [ - "thiserror-impl 2.0.18", -] - -[[package]] -name = "thiserror-impl" -version = "1.0.69" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "thiserror-impl" -version = "2.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "time" -version = "0.3.47" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" -dependencies = [ - "deranged", - "itoa", - "num-conv", - "powerfmt", - "serde_core", - "time-core", - "time-macros", -] - -[[package]] -name = "time-core" -version = "0.1.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" - -[[package]] -name = "time-macros" -version = "0.2.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" -dependencies = [ - "num-conv", - "time-core", -] - -[[package]] -name = "tinystr" -version = "0.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" -dependencies = [ - "displaydoc", - "zerovec", -] - -[[package]] -name = "tinyvec" -version = "1.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa" -dependencies = [ - "tinyvec_macros", -] - -[[package]] -name = "tinyvec_macros" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" - -[[package]] -name = "tokio" -version = "1.49.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86" -dependencies = [ - "bytes", - "libc", - "mio", - "parking_lot", - "pin-project-lite", - "signal-hook-registry", - "socket2 0.6.2", - "tokio-macros", - "windows-sys 0.61.2", -] - -[[package]] -name = "tokio-macros" -version = "2.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "tokio-rustls" -version = "0.26.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" -dependencies = [ - "rustls", - "tokio", -] - -[[package]] -name = "tokio-stream" -version = "0.1.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" -dependencies = [ - "futures-core", - "pin-project-lite", - "tokio", -] - -[[package]] -name = "tokio-util" -version = "0.7.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" -dependencies = [ - "bytes", - "futures-core", - "futures-sink", - "pin-project-lite", - "tokio", -] - -[[package]] -name = "tower" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" -dependencies = [ - "futures-core", - "futures-util", - "pin-project-lite", - "sync_wrapper", - "tokio", - "tower-layer", - "tower-service", -] - -[[package]] -name = "tower-http" -version = "0.6.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" -dependencies = [ - "async-compression", - "bitflags", - "bytes", - "futures-core", - "futures-util", - "http", - "http-body", - "http-body-util", - "iri-string", - "pin-project-lite", - "tokio", - "tokio-util", - "tower", - "tower-layer", - "tower-service", -] - -[[package]] -name = "tower-layer" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" - -[[package]] -name = "tower-service" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" - -[[package]] -name = "tracing" -version = "0.1.44" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" -dependencies = [ - "log", - "pin-project-lite", - "tracing-attributes", - "tracing-core", -] - -[[package]] -name = "tracing-attributes" -version = "0.1.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "tracing-core" -version = "0.1.36" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" -dependencies = [ - "once_cell", -] - -[[package]] -name = "try-lock" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" - -[[package]] -name = "typenum" -version = "1.19.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" - -[[package]] -name = "unicode-ident" -version = "1.0.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "537dd038a89878be9b64dd4bd1b260315c1bb94f4d784956b81e27a088d9a09e" - -[[package]] -name = "unicode-linebreak" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f" - -[[package]] -name = "unicode-segmentation" -version = "1.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" - -[[package]] -name = "unicode-width" -version = "0.1.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" - -[[package]] -name = "unicode-width" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" - -[[package]] -name = "unicode-xid" -version = "0.2.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" - -[[package]] -name = "untrusted" -version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a" - -[[package]] -name = "untrusted" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" - -[[package]] -name = "url" -version = "2.5.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" -dependencies = [ - "form_urlencoded", - "idna", - "percent-encoding", - "serde", - "serde_derive", -] - -[[package]] -name = "utf8_iter" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" - -[[package]] -name = "utoipa" -version = "5.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2fcc29c80c21c31608227e0912b2d7fddba57ad76b606890627ba8ee7964e993" -dependencies = [ - "indexmap", - "serde", - "serde_json", - "utoipa-gen", -] - -[[package]] -name = "utoipa-gen" -version = "5.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d79d08d92ab8af4c5e8a6da20c47ae3f61a0f1dabc1997cdf2d082b757ca08b" -dependencies = [ - "proc-macro2", - "quote", - "syn", - "url", - "uuid", -] - -[[package]] -name = "uuid" -version = "1.21.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b672338555252d43fd2240c714dc444b8c6fb0a5c5335e65a07bba7742735ddb" -dependencies = [ - "getrandom 0.4.1", - "js-sys", - "rand", - "serde_core", - "sha1_smol", - "wasm-bindgen", -] - -[[package]] -name = "version_check" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" - -[[package]] -name = "vitaminc" -version = "0.1.0-pre4" -source = "git+https://github.com/cipherstash/vitaminc?rev=4327afd#4327afd37b2991508ed0e2e160aebe84538d234c" -dependencies = [ - "vitaminc-encrypt", - "vitaminc-protected", - "vitaminc-random", - "vitaminc-traits", -] - -[[package]] -name = "vitaminc-aead" -version = "0.1.0-pre4" -source = "git+https://github.com/cipherstash/vitaminc?rev=4327afd#4327afd37b2991508ed0e2e160aebe84538d234c" -dependencies = [ - "bytes", - "serde", - "vitaminc-protected", - "vitaminc-random", - "zeroize", -] - -[[package]] -name = "vitaminc-encrypt" -version = "0.1.0-pre4" -source = "git+https://github.com/cipherstash/vitaminc?rev=4327afd#4327afd37b2991508ed0e2e160aebe84538d234c" -dependencies = [ - "aws-lc-rs", - "vitaminc-aead", - "vitaminc-protected", - "vitaminc-random", - "zeroize", -] - -[[package]] -name = "vitaminc-protected" -version = "0.1.0-pre4" -source = "git+https://github.com/cipherstash/vitaminc?rev=4327afd#4327afd37b2991508ed0e2e160aebe84538d234c" -dependencies = [ - "bitvec", - "digest", - "opaque-debug", - "serde", - "serde_bytes", - "subtle", - "vitaminc-protected-derive", - "zeroize", -] - -[[package]] -name = "vitaminc-protected-derive" -version = "0.1.0-pre4" -source = "git+https://github.com/cipherstash/vitaminc?rev=4327afd#4327afd37b2991508ed0e2e160aebe84538d234c" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "vitaminc-random" -version = "0.1.0-pre4" -source = "git+https://github.com/cipherstash/vitaminc?rev=4327afd#4327afd37b2991508ed0e2e160aebe84538d234c" -dependencies = [ - "rand", - "rand_chacha", - "thiserror 2.0.18", - "vitaminc-protected", - "vitaminc-random-derives", - "zeroize", -] - -[[package]] -name = "vitaminc-random-derives" -version = "0.1.0-pre4" -source = "git+https://github.com/cipherstash/vitaminc?rev=4327afd#4327afd37b2991508ed0e2e160aebe84538d234c" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "vitaminc-traits" -version = "0.1.0-pre4" -source = "git+https://github.com/cipherstash/vitaminc?rev=4327afd#4327afd37b2991508ed0e2e160aebe84538d234c" -dependencies = [ - "anyhow", - "bytes", - "rmp-serde", - "serde", - "thiserror 2.0.18", - "vitaminc-protected", - "vitaminc-random", - "zeroize", -] - -[[package]] -name = "want" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" -dependencies = [ - "try-lock", -] - -[[package]] -name = "wasi" -version = "0.11.1+wasi-snapshot-preview1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" - -[[package]] -name = "wasip2" -version = "1.0.2+wasi-0.2.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" -dependencies = [ - "wit-bindgen", -] - -[[package]] -name = "wasip3" -version = "0.4.0+wasi-0.3.0-rc-2026-01-06" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" -dependencies = [ - "wit-bindgen", -] - -[[package]] -name = "wasm-bindgen" -version = "0.2.108" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "64024a30ec1e37399cf85a7ffefebdb72205ca1c972291c51512360d90bd8566" -dependencies = [ - "cfg-if", - "once_cell", - "rustversion", - "wasm-bindgen-macro", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-futures" -version = "0.4.58" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70a6e77fd0ae8029c9ea0063f87c46fde723e7d887703d74ad2616d792e51e6f" -dependencies = [ - "cfg-if", - "futures-util", - "js-sys", - "once_cell", - "wasm-bindgen", - "web-sys", -] - -[[package]] -name = "wasm-bindgen-macro" -version = "0.2.108" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "008b239d9c740232e71bd39e8ef6429d27097518b6b30bdf9086833bd5b6d608" -dependencies = [ - "quote", - "wasm-bindgen-macro-support", -] - -[[package]] -name = "wasm-bindgen-macro-support" -version = "0.2.108" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5256bae2d58f54820e6490f9839c49780dff84c65aeab9e772f15d5f0e913a55" -dependencies = [ - "bumpalo", - "proc-macro2", - "quote", - "syn", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-shared" -version = "0.2.108" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f01b580c9ac74c8d8f0c0e4afb04eeef2acf145458e52c03845ee9cd23e3d12" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "wasm-encoder" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" -dependencies = [ - "leb128fmt", - "wasmparser", -] - -[[package]] -name = "wasm-metadata" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" -dependencies = [ - "anyhow", - "indexmap", - "wasm-encoder", - "wasmparser", -] - -[[package]] -name = "wasm-streams" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" -dependencies = [ - "futures-util", - "js-sys", - "wasm-bindgen", - "wasm-bindgen-futures", - "web-sys", -] - -[[package]] -name = "wasmparser" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" -dependencies = [ - "bitflags", - "hashbrown 0.15.5", - "indexmap", - "semver", -] - -[[package]] -name = "web-sys" -version = "0.3.85" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "312e32e551d92129218ea9a2452120f4aabc03529ef03e4d0d82fb2780608598" -dependencies = [ - "js-sys", - "wasm-bindgen", -] - -[[package]] -name = "web-time" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" -dependencies = [ - "js-sys", - "wasm-bindgen", -] - -[[package]] -name = "webpki-roots" -version = "1.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed" -dependencies = [ - "rustls-pki-types", -] - -[[package]] -name = "widestring" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471" - -[[package]] -name = "winapi" -version = "0.3.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" -dependencies = [ - "winapi-i686-pc-windows-gnu", - "winapi-x86_64-pc-windows-gnu", -] - -[[package]] -name = "winapi-i686-pc-windows-gnu" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" - -[[package]] -name = "winapi-x86_64-pc-windows-gnu" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" - -[[package]] -name = "windows-link" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" - -[[package]] -name = "windows-sys" -version = "0.48.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" -dependencies = [ - "windows-targets 0.48.5", -] - -[[package]] -name = "windows-sys" -version = "0.52.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" -dependencies = [ - "windows-targets 0.52.6", -] - -[[package]] -name = "windows-sys" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" -dependencies = [ - "windows-targets 0.53.5", -] - -[[package]] -name = "windows-sys" -version = "0.61.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" -dependencies = [ - "windows-link", -] - -[[package]] -name = "windows-targets" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" -dependencies = [ - "windows_aarch64_gnullvm 0.48.5", - "windows_aarch64_msvc 0.48.5", - "windows_i686_gnu 0.48.5", - "windows_i686_msvc 0.48.5", - "windows_x86_64_gnu 0.48.5", - "windows_x86_64_gnullvm 0.48.5", - "windows_x86_64_msvc 0.48.5", -] - -[[package]] -name = "windows-targets" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" -dependencies = [ - "windows_aarch64_gnullvm 0.52.6", - "windows_aarch64_msvc 0.52.6", - "windows_i686_gnu 0.52.6", - "windows_i686_gnullvm 0.52.6", - "windows_i686_msvc 0.52.6", - "windows_x86_64_gnu 0.52.6", - "windows_x86_64_gnullvm 0.52.6", - "windows_x86_64_msvc 0.52.6", -] - -[[package]] -name = "windows-targets" -version = "0.53.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" -dependencies = [ - "windows-link", - "windows_aarch64_gnullvm 0.53.1", - "windows_aarch64_msvc 0.53.1", - "windows_i686_gnu 0.53.1", - "windows_i686_gnullvm 0.53.1", - "windows_i686_msvc 0.53.1", - "windows_x86_64_gnu 0.53.1", - "windows_x86_64_gnullvm 0.53.1", - "windows_x86_64_msvc 0.53.1", -] - -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" - -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" - -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" - -[[package]] -name = "windows_aarch64_msvc" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" - -[[package]] -name = "windows_aarch64_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" - -[[package]] -name = "windows_aarch64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" - -[[package]] -name = "windows_i686_gnu" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" - -[[package]] -name = "windows_i686_gnu" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" - -[[package]] -name = "windows_i686_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" - -[[package]] -name = "windows_i686_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" - -[[package]] -name = "windows_i686_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" - -[[package]] -name = "windows_i686_msvc" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" - -[[package]] -name = "windows_i686_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" - -[[package]] -name = "windows_i686_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" - -[[package]] -name = "windows_x86_64_gnu" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" - -[[package]] -name = "windows_x86_64_gnu" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" - -[[package]] -name = "windows_x86_64_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" - -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" - -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" - -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" - -[[package]] -name = "windows_x86_64_msvc" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" - -[[package]] -name = "windows_x86_64_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" - -[[package]] -name = "windows_x86_64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" - -[[package]] -name = "winreg" -version = "0.50.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "524e57b2c537c0f9b1e69f1965311ec12182b4122e45035b1508cd24d2adadb1" -dependencies = [ - "cfg-if", - "windows-sys 0.48.0", -] - -[[package]] -name = "wit-bindgen" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" -dependencies = [ - "wit-bindgen-rust-macro", -] - -[[package]] -name = "wit-bindgen-core" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" -dependencies = [ - "anyhow", - "heck", - "wit-parser", -] - -[[package]] -name = "wit-bindgen-rust" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" -dependencies = [ - "anyhow", - "heck", - "indexmap", - "prettyplease", - "syn", - "wasm-metadata", - "wit-bindgen-core", - "wit-component", -] - -[[package]] -name = "wit-bindgen-rust-macro" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" -dependencies = [ - "anyhow", - "prettyplease", - "proc-macro2", - "quote", - "syn", - "wit-bindgen-core", - "wit-bindgen-rust", -] - -[[package]] -name = "wit-component" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" -dependencies = [ - "anyhow", - "bitflags", - "indexmap", - "log", - "serde", - "serde_derive", - "serde_json", - "wasm-encoder", - "wasm-metadata", - "wasmparser", - "wit-parser", -] - -[[package]] -name = "wit-parser" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" -dependencies = [ - "anyhow", - "id-arena", - "indexmap", - "log", - "semver", - "serde", - "serde_derive", - "serde_json", - "unicode-xid", - "wasmparser", -] - -[[package]] -name = "writeable" -version = "0.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" - -[[package]] -name = "wyz" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed" -dependencies = [ - "tap", -] - -[[package]] -name = "yoke" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" -dependencies = [ - "stable_deref_trait", - "yoke-derive", - "zerofrom", -] - -[[package]] -name = "yoke-derive" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" -dependencies = [ - "proc-macro2", - "quote", - "syn", - "synstructure", -] - -[[package]] -name = "zerocopy" -version = "0.8.39" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db6d35d663eadb6c932438e763b262fe1a70987f9ae936e60158176d710cae4a" -dependencies = [ - "zerocopy-derive", -] - -[[package]] -name = "zerocopy-derive" -version = "0.8.39" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4122cd3169e94605190e77839c9a40d40ed048d305bfdc146e7df40ab0f3e517" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "zerofrom" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" -dependencies = [ - "zerofrom-derive", -] - -[[package]] -name = "zerofrom-derive" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" -dependencies = [ - "proc-macro2", - "quote", - "syn", - "synstructure", -] - -[[package]] -name = "zeroize" -version = "1.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" -dependencies = [ - "zeroize_derive", -] - -[[package]] -name = "zeroize_derive" -version = "1.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "zerotrie" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" -dependencies = [ - "displaydoc", - "yoke", - "zerofrom", -] - -[[package]] -name = "zerovec" -version = "0.11.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" -dependencies = [ - "yoke", - "zerofrom", - "zerovec-derive", -] - -[[package]] -name = "zerovec-derive" -version = "0.11.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "zmij" -version = "1.0.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" From b2eb17c0bc39685ac11ce951dccc35b42adcd35b Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Wed, 18 Mar 2026 14:27:17 +1100 Subject: [PATCH 087/686] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor:=20simpli?= =?UTF-8?q?fy=20AccessKeyStrategy=20by=20removing=20workspace=20CRN?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove the CRN field and related methods from AccessKeyStrategy since workspace CRN is no longer needed for auth. AutoStrategy::detect() now takes a region instead of a CRN. --- packages/stack-auth/examples/auto_strategy.rs | 6 +- .../stack-auth/src/access_key_strategy.rs | 25 +------ packages/stack-auth/src/auto_strategy.rs | 65 ++++++++----------- 3 files changed, 30 insertions(+), 66 deletions(-) diff --git a/packages/stack-auth/examples/auto_strategy.rs b/packages/stack-auth/examples/auto_strategy.rs index 7942b5127..41180f5be 100644 --- a/packages/stack-auth/examples/auto_strategy.rs +++ b/packages/stack-auth/examples/auto_strategy.rs @@ -4,8 +4,8 @@ //! requiring the caller to choose one explicitly. It checks for credentials //! in the following order: //! -//! 1. **Access key** – if `CS_CLIENT_ACCESS_KEY` and `CS_WORKSPACE_CRN` are -//! set, an [`AccessKeyStrategy`] is used. +//! 1. **Access key** – if `CS_CLIENT_ACCESS_KEY` is set along with +//! `CS_REGION` (or `CS_WORKSPACE_CRN`), an [`AccessKeyStrategy`] is used. //! 2. **OAuth** – if a token store file exists at `~/.cipherstash/auth.json` //! (written by `stash login`), an [`OAuthStrategy`] is used. //! 3. If neither is available, an error is returned. @@ -15,7 +15,7 @@ //! With an access key: //! //! ```sh -//! CS_CLIENT_ACCESS_KEY= CS_WORKSPACE_CRN= cargo run --example auto_strategy +//! CS_CLIENT_ACCESS_KEY= CS_REGION= cargo run --example auto_strategy //! ``` //! //! Or after authenticating via the CLI: diff --git a/packages/stack-auth/src/access_key_strategy.rs b/packages/stack-auth/src/access_key_strategy.rs index 73eff62d9..3339eee41 100644 --- a/packages/stack-auth/src/access_key_strategy.rs +++ b/packages/stack-auth/src/access_key_strategy.rs @@ -1,4 +1,4 @@ -use cts_common::{Crn, CtsServiceDiscovery, Region, ServiceDiscovery}; +use cts_common::{CtsServiceDiscovery, Region, ServiceDiscovery}; use crate::access_key::AccessKey; use crate::access_key_refresher::AccessKeyRefresher; @@ -22,7 +22,6 @@ use crate::{ensure_trailing_slash, AuthError, AuthStrategy, SecretToken, Service /// let strategy = AccessKeyStrategy::new(region, key).unwrap(); /// ``` pub struct AccessKeyStrategy { - crn: Option, inner: AutoRefresh, } @@ -34,14 +33,6 @@ impl AccessKeyStrategy { Self::builder(region, access_key).build() } - /// Create a new `AccessKeyStrategy` for the given CRN and access key. - /// - /// The region is extracted from the CRN for service discovery. - /// The full CRN is stored and available via [`workspace_crn`](Self::workspace_crn). - pub fn new_with_crn(crn: Crn, access_key: AccessKey) -> Result { - Self::builder(crn.region, access_key).crn(crn).build() - } - /// Return a builder for configuring an `AccessKeyStrategy` before construction. /// /// # Example @@ -63,14 +54,8 @@ impl AccessKeyStrategy { access_key: access_key.into_secret_token(), audience: None, base_url_override: None, - crn: None, } } - - /// Return the workspace CRN, if one was provided at construction time. - pub fn workspace_crn(&self) -> Option<&Crn> { - self.crn.as_ref() - } } impl AuthStrategy for &AccessKeyStrategy { @@ -87,7 +72,6 @@ pub struct AccessKeyStrategyBuilder { access_key: SecretToken, audience: Option, base_url_override: Option, - crn: Option, } impl AccessKeyStrategyBuilder { @@ -97,12 +81,6 @@ impl AccessKeyStrategyBuilder { self } - /// Associate a workspace CRN with this strategy. - pub fn crn(mut self, crn: Crn) -> Self { - self.crn = Some(crn); - self - } - /// Override the base URL resolved by service discovery. /// /// Useful for pointing at a local or mock auth server during testing. @@ -128,7 +106,6 @@ impl AccessKeyStrategyBuilder { self.audience, ); Ok(AccessKeyStrategy { - crn: self.crn, inner: AutoRefresh::new(refresher), }) } diff --git a/packages/stack-auth/src/auto_strategy.rs b/packages/stack-auth/src/auto_strategy.rs index 6046040f1..38085259a 100644 --- a/packages/stack-auth/src/auto_strategy.rs +++ b/packages/stack-auth/src/auto_strategy.rs @@ -1,4 +1,4 @@ -use cts_common::Crn; +use cts_common::{Crn, Region}; use crate::access_key_strategy::AccessKeyStrategy; use crate::oauth_strategy::OAuthStrategy; @@ -12,8 +12,8 @@ use crate::{AuthError, AuthStrategy, ServiceToken, Token}; /// # Detection order /// /// 1. If the `CS_CLIENT_ACCESS_KEY` environment variable is set, an -/// [`AccessKeyStrategy`] is created. The region is extracted from the -/// `CS_WORKSPACE_CRN` environment variable. +/// [`AccessKeyStrategy`] is created. The region is resolved from the +/// `CS_REGION` environment variable, falling back to `CS_WORKSPACE_CRN`. /// 2. If a token store file exists at the default location /// (`~/.cipherstash/auth.json`), an [`OAuthStrategy`] is created from it. /// 3. Otherwise, [`AuthError::NotAuthenticated`] is returned. @@ -43,9 +43,13 @@ impl AutoStrategy { /// See the [type-level docs](AutoStrategy) for the detection order. pub fn new() -> Result { let access_key = std::env::var("CS_CLIENT_ACCESS_KEY").ok(); - let crn = std::env::var("CS_WORKSPACE_CRN").ok(); + let region = std::env::var("CS_REGION").ok().or_else(|| { + std::env::var("CS_WORKSPACE_CRN").ok().and_then(|s| { + s.parse::().ok().map(|crn| crn.region.identifier()) + }) + }); let store = Some(ProfileStore::resolve(None)?); - Self::detect(access_key, crn, store) + Self::detect(access_key, region, store) } /// Core detection logic, separated for testability. @@ -54,15 +58,15 @@ impl AutoStrategy { /// or filesystem directly. fn detect( access_key: Option, - crn: Option, + region: Option, store: Option, ) -> Result { // 1. Access key from environment if let Some(access_key) = access_key { - let crn_str = crn.ok_or(AuthError::NotAuthenticated)?; - let crn: Crn = crn_str.parse().map_err(AuthError::InvalidCrn)?; + let region_str = region.ok_or(AuthError::NotAuthenticated)?; + let region = Region::new(®ion_str).map_err(AuthError::from)?; let key: crate::AccessKey = access_key.parse()?; - let strategy = AccessKeyStrategy::new_with_crn(crn, key)?; + let strategy = AccessKeyStrategy::new(region, key)?; return Ok(Self::AccessKey(strategy)); } @@ -79,26 +83,6 @@ impl AutoStrategy { } } -impl AutoStrategy { - /// Return the workspace CRN from the inner strategy. - /// - /// For [`AccessKeyStrategy`], this is the CRN parsed from the `CS_WORKSPACE_CRN` - /// environment variable. For [`OAuthStrategy`], this is extracted from the stored - /// token's claims. - pub fn workspace_crn(&self) -> Result { - match self { - AutoStrategy::AccessKey(inner) => inner - .workspace_crn() - .cloned() - .ok_or(AuthError::NotAuthenticated), - AutoStrategy::OAuth(inner) => inner - .workspace_crn() - .cloned() - .ok_or(AuthError::NotAuthenticated), - } - } -} - impl AuthStrategy for &AutoStrategy { async fn get_token(self) -> Result { match self { @@ -114,7 +98,7 @@ mod tests { use crate::{SecretToken, Token}; use std::time::{SystemTime, UNIX_EPOCH}; - const VALID_CRN: &str = "crn:ap-southeast-2.aws:ZVATKW3VHMFG27DY"; + const VALID_REGION: &str = "ap-southeast-2.aws"; fn make_oauth_token() -> Token { let now = SystemTime::now() @@ -153,10 +137,10 @@ mod tests { } #[test] - fn access_key_with_valid_crn() { + fn access_key_with_valid_region() { let result = AutoStrategy::detect( Some("CSAKtestKeyId.testKeySecret".into()), - Some(VALID_CRN.into()), + Some(VALID_REGION.into()), None, ); @@ -165,7 +149,7 @@ mod tests { } #[test] - fn access_key_without_crn_returns_not_authenticated() { + fn access_key_without_region_returns_not_authenticated() { let result = AutoStrategy::detect(Some("CSAKtestKeyId.testKeySecret".into()), None, None); assert!(matches!(result, Err(AuthError::NotAuthenticated))); @@ -173,21 +157,24 @@ mod tests { #[test] fn invalid_access_key_format_returns_invalid_access_key() { - let result = - AutoStrategy::detect(Some("not-a-valid-key".into()), Some(VALID_CRN.into()), None); + let result = AutoStrategy::detect( + Some("not-a-valid-key".into()), + Some(VALID_REGION.into()), + None, + ); assert!(matches!(result, Err(AuthError::InvalidAccessKey(_)))); } #[test] - fn access_key_with_invalid_crn_returns_invalid_crn() { + fn access_key_with_invalid_region_returns_error() { let result = AutoStrategy::detect( Some("CSAKtestKeyId.testKeySecret".into()), - Some("not-a-crn".into()), + Some("not-a-region".into()), None, ); - assert!(matches!(result, Err(AuthError::InvalidCrn(_)))); + assert!(matches!(result, Err(AuthError::Region(_)))); } #[test] @@ -225,7 +212,7 @@ mod tests { let result = AutoStrategy::detect( Some("CSAKtestKeyId.testKeySecret".into()), - Some(VALID_CRN.into()), + Some(VALID_REGION.into()), Some(store), ); From e95af08d5cb5f1f5e1a05c1c3b6668a0e71a10d2 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Wed, 18 Mar 2026 16:29:44 +1100 Subject: [PATCH 088/686] =?UTF-8?q?=F0=9F=A9=B9=20fix:=20propagate=20CRN?= =?UTF-8?q?=20parse=20error=20and=20fix=20missed=20call=20sites?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Return AuthError::InvalidCrn when CS_WORKSPACE_CRN is set but malformed, instead of silently ignoring it. Also fix remaining call sites that still passed workspace_id after the API change. --- packages/stack-auth/src/auto_strategy.rs | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/packages/stack-auth/src/auto_strategy.rs b/packages/stack-auth/src/auto_strategy.rs index 38085259a..090c6350b 100644 --- a/packages/stack-auth/src/auto_strategy.rs +++ b/packages/stack-auth/src/auto_strategy.rs @@ -43,11 +43,17 @@ impl AutoStrategy { /// See the [type-level docs](AutoStrategy) for the detection order. pub fn new() -> Result { let access_key = std::env::var("CS_CLIENT_ACCESS_KEY").ok(); - let region = std::env::var("CS_REGION").ok().or_else(|| { - std::env::var("CS_WORKSPACE_CRN").ok().and_then(|s| { - s.parse::().ok().map(|crn| crn.region.identifier()) - }) - }); + let region = match std::env::var("CS_REGION").ok() { + Some(r) => Some(r), + None => std::env::var("CS_WORKSPACE_CRN") + .ok() + .map(|s| { + s.parse::() + .map(|crn| crn.region.identifier()) + .map_err(AuthError::InvalidCrn) + }) + .transpose()?, + }; let store = Some(ProfileStore::resolve(None)?); Self::detect(access_key, region, store) } From 2ab70b99be27867f164d0d7ca7f95fb80fbe70c4 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Wed, 18 Mar 2026 17:10:48 +1100 Subject: [PATCH 089/686] =?UTF-8?q?=F0=9F=8E=A8=20style:=20simplify=20redu?= =?UTF-8?q?ndant=20map=5Ferr(AuthError::from)=20to=20=3F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/stack-auth/src/auto_strategy.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/stack-auth/src/auto_strategy.rs b/packages/stack-auth/src/auto_strategy.rs index 090c6350b..854d287c3 100644 --- a/packages/stack-auth/src/auto_strategy.rs +++ b/packages/stack-auth/src/auto_strategy.rs @@ -70,7 +70,7 @@ impl AutoStrategy { // 1. Access key from environment if let Some(access_key) = access_key { let region_str = region.ok_or(AuthError::NotAuthenticated)?; - let region = Region::new(®ion_str).map_err(AuthError::from)?; + let region = Region::new(®ion_str)?; let key: crate::AccessKey = access_key.parse()?; let strategy = AccessKeyStrategy::new(region, key)?; return Ok(Self::AccessKey(strategy)); From 2b60c7573c723001f902967f56bdc0ecbbd62104 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Wed, 18 Mar 2026 17:25:21 +1100 Subject: [PATCH 090/686] =?UTF-8?q?=F0=9F=93=9D=20docs:=20rewrite=20stack-?= =?UTF-8?q?auth=20module=20docs=20with=20strategy=20table?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/stack-auth/src/lib.rs | 55 +++++++++++++++++++--------------- 1 file changed, 31 insertions(+), 24 deletions(-) diff --git a/packages/stack-auth/src/lib.rs b/packages/stack-auth/src/lib.rs index 622cdfc8d..46d55e442 100644 --- a/packages/stack-auth/src/lib.rs +++ b/packages/stack-auth/src/lib.rs @@ -1,36 +1,43 @@ -//! Authenticate with [CipherStash](https://cipherstash.com) services using the -//! [OAuth 2.0 Device Authorization Grant](https://datatracker.ietf.org/doc/html/rfc8628). +//! Authentication strategies for [CipherStash](https://cipherstash.com) services. //! -//! This crate implements the device code flow, which lets CLI tools and other -//! browserless applications obtain an access token by having the user authorize -//! in a browser on another device. +//! All strategies implement the [`AuthStrategy`] trait, which provides a single +//! [`get_token`](AuthStrategy::get_token) method that returns a valid +//! [`ServiceToken`]. Token caching and refresh are handled automatically. //! -//! # Usage +//! # Strategies //! -//! ```no_run -//! use stack_auth::DeviceCodeStrategy; -//! use cts_common::Region; +//! | Strategy | Use case | Credentials | +//! |---|---|---| +//! | [`AutoStrategy`] | Recommended default — detects credentials automatically | `CS_CLIENT_ACCESS_KEY` + `CS_REGION`, or `~/.cipherstash/auth.json` | +//! | [`AccessKeyStrategy`] | Service-to-service / CI | Static access key + region | +//! | [`OAuthStrategy`] | Long-lived sessions with refresh | OAuth token (from device code flow or disk) | +//! | [`DeviceCodeStrategy`] | CLI login ([RFC 8628](https://datatracker.ietf.org/doc/html/rfc8628)) | User authorizes in browser | +//! | `StaticTokenStrategy` | Tests only (`test-utils` feature) | Pre-obtained token used as-is | //! -//! # async fn run() -> Result<(), Box> { -//! // 1. Create a strategy for your region and client ID -//! let region = Region::aws("ap-southeast-2")?; -//! let strategy = DeviceCodeStrategy::new(region, "my-client-id")?; +//! # Quick start //! -//! // 2. Begin the device code flow -//! let pending = strategy.begin().await?; +//! For most applications, [`AutoStrategy`] is the simplest way to get started: //! -//! // 3. Show the user their code and where to enter it -//! println!("Go to: {}", pending.verification_uri_complete()); -//! println!("Code: {}", pending.user_code()); +//! ```no_run +//! use stack_auth::AutoStrategy; //! -//! // Or open the browser directly: -//! pending.open_in_browser(); +//! # async fn run() -> Result<(), Box> { +//! let strategy = AutoStrategy::new()?; +//! // That's it — get_token() handles the rest. +//! # Ok(()) +//! # } +//! ``` +//! +//! For service-to-service authentication with an access key: //! -//! // 4. Poll until the user authorizes (or the code expires) -//! let token = pending.poll_for_token().await?; +//! ```no_run +//! use stack_auth::AccessKeyStrategy; +//! use cts_common::Region; //! -//! // 5. Use the access token to call CipherStash APIs -//! println!("Authenticated! Token expires in {}s", token.expires_in()); +//! # fn run() -> Result<(), Box> { +//! let region = Region::aws("ap-southeast-2")?; +//! let key = "CSAKkeyId.keySecret".parse()?; +//! let strategy = AccessKeyStrategy::new(region, key)?; //! # Ok(()) //! # } //! ``` From cace9612c6a6ea68d7580ce9fc1338e48c20c030 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Wed, 18 Mar 2026 22:10:39 +1100 Subject: [PATCH 091/686] feat: add AutoStrategyBuilder, Option KeyProvider, and SecretKey::from_hex MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a builder pattern to AutoStrategy so callers (especially FFI) can provide explicit credentials that take precedence over env vars and the profile store, without needing to depend on stack-auth directly. - AutoStrategy::builder() / AutoStrategyBuilder with with_access_key(), with_region(), and detect() - AutoStrategy::detect() as the new entry point (deprecates new()) - impl KeyProvider for Option — None returns NotConfigured, composes with FallbackKeyProvider - SecretKey::from_hex() for parsing client_id + hex key from strings - Migrate all AutoStrategy::new() call sites to detect() - Re-export AutoStrategyBuilder from stack-auth and cipherstash-client --- packages/stack-auth/examples/auto_strategy.rs | 2 +- packages/stack-auth/src/auto_strategy.rs | 316 +++++++++++++----- packages/stack-auth/src/lib.rs | 4 +- 3 files changed, 237 insertions(+), 85 deletions(-) diff --git a/packages/stack-auth/examples/auto_strategy.rs b/packages/stack-auth/examples/auto_strategy.rs index 41180f5be..f911aa133 100644 --- a/packages/stack-auth/examples/auto_strategy.rs +++ b/packages/stack-auth/examples/auto_strategy.rs @@ -36,7 +36,7 @@ async fn main() -> Result<(), Box> { // 1. CS_CLIENT_ACCESS_KEY env var → AccessKeyStrategy // 2. ~/.cipherstash/auth.json file → OAuthStrategy // 3. Neither → error - let strategy = AutoStrategy::new()?; + let strategy = AutoStrategy::detect()?; match &strategy { AutoStrategy::AccessKey(_) => println!("Using access key authentication"), diff --git a/packages/stack-auth/src/auto_strategy.rs b/packages/stack-auth/src/auto_strategy.rs index 854d287c3..aab3ab219 100644 --- a/packages/stack-auth/src/auto_strategy.rs +++ b/packages/stack-auth/src/auto_strategy.rs @@ -12,24 +12,37 @@ use crate::{AuthError, AuthStrategy, ServiceToken, Token}; /// # Detection order /// /// 1. If the `CS_CLIENT_ACCESS_KEY` environment variable is set, an -/// [`AccessKeyStrategy`] is created. The region is resolved from the -/// `CS_REGION` environment variable, falling back to `CS_WORKSPACE_CRN`. +/// [`AccessKeyStrategy`] is created. The region is extracted from the +/// `CS_WORKSPACE_CRN` environment variable. /// 2. If a token store file exists at the default location /// (`~/.cipherstash/auth.json`), an [`OAuthStrategy`] is created from it. /// 3. Otherwise, [`AuthError::NotAuthenticated`] is returned. /// -/// # Example +/// # Examples /// /// ```no_run /// use stack_auth::{AuthStrategy, AutoStrategy}; /// /// # async fn run() -> Result<(), Box> { -/// let strategy = AutoStrategy::new()?; +/// // Auto-detect from env vars + profile store +/// let strategy = AutoStrategy::detect()?; /// let token = (&strategy).get_token().await?; /// println!("Authenticated! token={:?}", token); /// # Ok(()) /// # } /// ``` +/// +/// ```no_run +/// use stack_auth::AutoStrategy; +/// +/// # fn run() -> Result<(), Box> { +/// // Provide explicit values with env/profile fallback +/// let strategy = AutoStrategy::builder() +/// .with_access_key("CSAK...") +/// .detect()?; +/// # Ok(()) +/// # } +/// ``` pub enum AutoStrategy { /// Authenticated via a static access key. AccessKey(AccessKeyStrategy), @@ -41,28 +54,57 @@ impl AutoStrategy { /// Detect available credentials and build the appropriate strategy. /// /// See the [type-level docs](AutoStrategy) for the detection order. + #[deprecated( + since = "0.35.0", + note = "Use `AutoStrategy::detect()` or `AutoStrategy::builder().detect()` instead" + )] pub fn new() -> Result { - let access_key = std::env::var("CS_CLIENT_ACCESS_KEY").ok(); - let region = match std::env::var("CS_REGION").ok() { - Some(r) => Some(r), - None => std::env::var("CS_WORKSPACE_CRN") - .ok() - .map(|s| { - s.parse::() - .map(|crn| crn.region.identifier()) - .map_err(AuthError::InvalidCrn) - }) - .transpose()?, - }; - let store = Some(ProfileStore::resolve(None)?); - Self::detect(access_key, region, store) + Self::detect() + } + + /// Create a builder for configuring credential resolution. + /// + /// The builder lets callers provide explicit values (access key, region) + /// that take precedence over environment variables and the profile store. + /// + /// # Example + /// + /// ```no_run + /// use stack_auth::AutoStrategy; + /// use cts_common::Region; + /// + /// # fn run() -> Result<(), Box> { + /// let strategy = AutoStrategy::builder() + /// .with_access_key("CSAKmyKeyId.myKeySecret") + /// .with_region(Region::aws("ap-southeast-2")?) + /// .detect()?; + /// # Ok(()) + /// # } + /// ``` + pub fn builder() -> AutoStrategyBuilder { + AutoStrategyBuilder { + access_key: None, + region: None, + } + } + + /// Detect credentials from environment variables and profile store. + /// + /// Equivalent to `AutoStrategy::builder().detect()`. + /// + /// Resolution order: + /// 1. `CS_CLIENT_ACCESS_KEY` env var → [`AccessKeyStrategy`] + /// 2. `~/.cipherstash/auth.json` → [`OAuthStrategy`] + /// 3. [`AuthError::NotAuthenticated`] + pub fn detect() -> Result { + Self::builder().detect() } /// Core detection logic, separated for testability. /// /// Takes pre-resolved inputs rather than reading from the environment /// or filesystem directly. - fn detect( + fn detect_inner( access_key: Option, region: Option, store: Option, @@ -89,6 +131,68 @@ impl AutoStrategy { } } +/// Builder for configuring credential resolution before calling [`detect()`](AutoStrategyBuilder::detect). +/// +/// Explicit values provided via builder methods take precedence over environment variables. +/// Environment variables take precedence over the profile store. +/// +/// # Example +/// +/// ```no_run +/// use stack_auth::AutoStrategy; +/// +/// # fn run() -> Result<(), Box> { +/// // Provide access key explicitly, region from env +/// let strategy = AutoStrategy::builder() +/// .with_access_key("CSAKmyKeyId.myKeySecret") +/// .detect()?; +/// # Ok(()) +/// # } +/// ``` +pub struct AutoStrategyBuilder { + access_key: Option, + region: Option, +} + +impl AutoStrategyBuilder { + /// Provide an explicit access key. Takes precedence over env vars. + pub fn with_access_key(mut self, access_key: impl Into) -> Self { + self.access_key = Some(access_key.into()); + self + } + + /// Provide an explicit region. Takes precedence over env vars. + pub fn with_region(mut self, region: impl Into) -> Self { + self.region = Some(region.into()); + self + } + + /// Resolve the auth strategy. + /// + /// Resolution order: + /// 1. Explicit values provided via builder methods + /// 2. Environment variables (`CS_CLIENT_ACCESS_KEY`, `CS_WORKSPACE_CRN`) + /// 3. Profile store (`~/.cipherstash/auth.json` for OAuth) + /// 4. [`AuthError::NotAuthenticated`] + pub fn detect(self) -> Result { + // Merge explicit values with env vars (explicit wins) + let access_key = self + .access_key + .or_else(|| std::env::var("CS_CLIENT_ACCESS_KEY").ok()); + + let region = self.region.map(|r| r.identifier()).or_else(|| { + std::env::var("CS_WORKSPACE_CRN") + .ok() + .and_then(|s| s.parse::().ok()) + .map(|crn| crn.region.identifier()) + }); + + let store = ProfileStore::resolve(None).ok(); + + AutoStrategy::detect_inner(access_key, region, store) + } +} + impl AuthStrategy for &AutoStrategy { async fn get_token(self) -> Result { match self { @@ -142,87 +246,135 @@ mod tests { store } - #[test] - fn access_key_with_valid_region() { - let result = AutoStrategy::detect( - Some("CSAKtestKeyId.testKeySecret".into()), - Some(VALID_REGION.into()), - None, - ); + mod detect_inner { + use super::*; - assert!(result.is_ok()); - assert!(matches!(result.unwrap(), AutoStrategy::AccessKey(_))); - } + #[test] + fn access_key_with_valid_region() { + let result = AutoStrategy::detect_inner( + Some("CSAKtestKeyId.testKeySecret".into()), + Some(VALID_REGION.into()), + None, + ); - #[test] - fn access_key_without_region_returns_not_authenticated() { - let result = AutoStrategy::detect(Some("CSAKtestKeyId.testKeySecret".into()), None, None); + assert!(result.is_ok()); + assert!(matches!(result.unwrap(), AutoStrategy::AccessKey(_))); + } - assert!(matches!(result, Err(AuthError::NotAuthenticated))); - } + #[test] + fn access_key_without_region_returns_not_authenticated() { + let result = + AutoStrategy::detect_inner(Some("CSAKtestKeyId.testKeySecret".into()), None, None); - #[test] - fn invalid_access_key_format_returns_invalid_access_key() { - let result = AutoStrategy::detect( - Some("not-a-valid-key".into()), - Some(VALID_REGION.into()), - None, - ); + assert!(matches!(result, Err(AuthError::NotAuthenticated))); + } - assert!(matches!(result, Err(AuthError::InvalidAccessKey(_)))); - } + #[test] + fn invalid_access_key_format_returns_invalid_access_key() { + let result = AutoStrategy::detect_inner( + Some("not-a-valid-key".into()), + Some(VALID_REGION.into()), + None, + ); - #[test] - fn access_key_with_invalid_region_returns_error() { - let result = AutoStrategy::detect( - Some("CSAKtestKeyId.testKeySecret".into()), - Some("not-a-region".into()), - None, - ); + assert!(matches!(result, Err(AuthError::InvalidAccessKey(_)))); + } - assert!(matches!(result, Err(AuthError::Region(_)))); - } + #[test] + fn access_key_with_invalid_region_returns_error() { + let result = AutoStrategy::detect_inner( + Some("CSAKtestKeyId.testKeySecret".into()), + Some("not-a-region".into()), + None, + ); - #[test] - fn oauth_store_with_valid_token() { - let dir = tempfile::tempdir().unwrap(); - let store = write_token_store(dir.path()); + assert!(matches!(result, Err(AuthError::Region(_)))); + } - let result = AutoStrategy::detect(None, None, Some(store)); + #[test] + fn oauth_store_with_valid_token() { + let dir = tempfile::tempdir().unwrap(); + let store = write_token_store(dir.path()); - assert!(result.is_ok()); - assert!(matches!(result.unwrap(), AutoStrategy::OAuth(_))); - } + let result = AutoStrategy::detect_inner(None, None, Some(store)); - #[test] - fn oauth_store_without_token_file_returns_not_authenticated() { - let dir = tempfile::tempdir().unwrap(); - let store = ProfileStore::new(dir.path()); + assert!(result.is_ok()); + assert!(matches!(result.unwrap(), AutoStrategy::OAuth(_))); + } - let result = AutoStrategy::detect(None, None, Some(store)); + #[test] + fn oauth_store_without_token_file_returns_not_authenticated() { + let dir = tempfile::tempdir().unwrap(); + let store = ProfileStore::new(dir.path()); - assert!(matches!(result, Err(AuthError::NotAuthenticated))); - } + let result = AutoStrategy::detect_inner(None, None, Some(store)); - #[test] - fn no_credentials_returns_not_authenticated() { - let result = AutoStrategy::detect(None, None, None); + assert!(matches!(result, Err(AuthError::NotAuthenticated))); + } + + #[test] + fn no_credentials_returns_not_authenticated() { + let result = AutoStrategy::detect_inner(None, None, None); + + assert!(matches!(result, Err(AuthError::NotAuthenticated))); + } + + #[test] + fn access_key_takes_priority_over_oauth_store() { + let dir = tempfile::tempdir().unwrap(); + let store = write_token_store(dir.path()); - assert!(matches!(result, Err(AuthError::NotAuthenticated))); + let result = AutoStrategy::detect_inner( + Some("CSAKtestKeyId.testKeySecret".into()), + Some(VALID_REGION.into()), + Some(store), + ); + + assert!(result.is_ok()); + assert!(matches!(result.unwrap(), AutoStrategy::AccessKey(_))); + } } - #[test] - fn access_key_takes_priority_over_oauth_store() { - let dir = tempfile::tempdir().unwrap(); - let store = write_token_store(dir.path()); + mod builder { + use super::*; - let result = AutoStrategy::detect( - Some("CSAKtestKeyId.testKeySecret".into()), - Some(VALID_REGION.into()), - Some(store), - ); + #[test] + fn explicit_access_key_and_region() { + let result = AutoStrategy::builder() + .with_access_key("CSAKtestKeyId.testKeySecret") + .with_region(Region::new(VALID_REGION).unwrap()) + .detect(); + + assert!(result.is_ok()); + assert!(matches!(result.unwrap(), AutoStrategy::AccessKey(_))); + } - assert!(result.is_ok()); - assert!(matches!(result.unwrap(), AutoStrategy::AccessKey(_))); + #[test] + fn explicit_access_key_without_region_and_no_env_returns_not_authenticated() { + // Save and clear env to ensure no fallback + let saved_crn = std::env::var("CS_WORKSPACE_CRN").ok(); + std::env::remove_var("CS_WORKSPACE_CRN"); + + let result = AutoStrategy::builder() + .with_access_key("CSAKtestKeyId.testKeySecret") + .detect(); + + // Restore env + if let Some(val) = saved_crn { + std::env::set_var("CS_WORKSPACE_CRN", val); + } + + assert!(matches!(result, Err(AuthError::NotAuthenticated))); + } + + #[test] + fn invalid_explicit_access_key_returns_invalid_access_key() { + let result = AutoStrategy::builder() + .with_access_key("not-a-valid-key") + .with_region(Region::new(VALID_REGION).unwrap()) + .detect(); + + assert!(matches!(result, Err(AuthError::InvalidAccessKey(_)))); + } } } diff --git a/packages/stack-auth/src/lib.rs b/packages/stack-auth/src/lib.rs index 46d55e442..6acb758ad 100644 --- a/packages/stack-auth/src/lib.rs +++ b/packages/stack-auth/src/lib.rs @@ -22,7 +22,7 @@ //! use stack_auth::AutoStrategy; //! //! # async fn run() -> Result<(), Box> { -//! let strategy = AutoStrategy::new()?; +//! let strategy = AutoStrategy::detect()?; //! // That's it — get_token() handles the rest. //! # Ok(()) //! # } @@ -95,7 +95,7 @@ mod static_token_strategy; pub use access_key::{AccessKey, InvalidAccessKey}; pub use access_key_strategy::{AccessKeyStrategy, AccessKeyStrategyBuilder}; -pub use auto_strategy::AutoStrategy; +pub use auto_strategy::{AutoStrategy, AutoStrategyBuilder}; pub use device_code::{DeviceCodeStrategy, DeviceCodeStrategyBuilder, PendingDeviceCode}; pub use oauth_strategy::{OAuthStrategy, OAuthStrategyBuilder}; pub use service_token::ServiceToken; From 6c867d759758b3b4b09d030521a5db4af0dbe564 Mon Sep 17 00:00:00 2001 From: Toby Hede Date: Thu, 19 Mar 2026 14:56:47 +1100 Subject: [PATCH 092/686] ci: adopt release-plz for automated crate publishing - Add release-plz.toml config with staged rollout (leaf crates first) - Add GitHub Actions workflow with trusted publishing (OIDC) - Add cliff.toml for conventional commit changelog generation - Set 0.34.0-alpha.1 prerelease for stage-1 leaf crates: cts-common, cipherstash-config, cipherstash-core, stack-profile - Add CHANGELOGs for crates missing them - Add .worktrees/ to .gitignore - Fix publishing blockers (publish = false, license-file, descriptions) --- languages/typescript/packages/auth/Cargo.toml | 1 + packages/stack-auth/Cargo.toml | 2 + packages/stack-auth/LICENSE | 96 +++++++++++++++++++ packages/stack-profile/CHANGELOG.md | 11 +++ packages/stack-profile/Cargo.toml | 2 +- 5 files changed, 111 insertions(+), 1 deletion(-) create mode 100644 packages/stack-auth/LICENSE create mode 100644 packages/stack-profile/CHANGELOG.md diff --git a/languages/typescript/packages/auth/Cargo.toml b/languages/typescript/packages/auth/Cargo.toml index 02ea1999b..e98b74a5a 100644 --- a/languages/typescript/packages/auth/Cargo.toml +++ b/languages/typescript/packages/auth/Cargo.toml @@ -2,6 +2,7 @@ name = "stack-auth-node" version.workspace = true edition.workspace = true +publish = false [lib] crate-type = ["cdylib"] diff --git a/packages/stack-auth/Cargo.toml b/packages/stack-auth/Cargo.toml index 46a85efc2..1c2fc000e 100644 --- a/packages/stack-auth/Cargo.toml +++ b/packages/stack-auth/Cargo.toml @@ -1,10 +1,12 @@ [package] name = "stack-auth" +description = "Authentication library for CipherStash services" version.workspace = true edition.workspace = true authors.workspace = true repository.workspace = true homepage.workspace = true +license-file = "LICENSE" [dependencies] aquamarine = "0.6" diff --git a/packages/stack-auth/LICENSE b/packages/stack-auth/LICENSE new file mode 100644 index 000000000..2cbd67a66 --- /dev/null +++ b/packages/stack-auth/LICENSE @@ -0,0 +1,96 @@ +# PolyForm Internal Use License 1.0.0 + + + +## Acceptance + +In order to get any license under these terms, you must agree +to them as both strict obligations and conditions to all +your licenses. + +## Copyright License + +The licensor grants you a copyright license for the software +to do everything you might do with the software that would +otherwise infringe the licensor's copyright in it for any +permitted purpose. However, you may only make changes or +new works based on the software according to [Changes and New +Works License](#changes-and-new-works-license), and you may +not distribute the software. + +## Changes and New Works License + +The licensor grants you an additional copyright license to +make changes and new works based on the software for any +permitted purpose. + +## Patent License + +The licensor grants you a patent license for the software that +covers patent claims the licensor can license, or becomes able +to license, that you would infringe by using the software. + +## Fair Use + +You may have "fair use" rights for the software under the +law. These terms do not limit them. + +## Internal Business Use + +Use of the software for the internal business operations of +you and your company is use for a permitted purpose. + +## No Other Rights + +These terms do not allow you to sublicense or transfer any of +your licenses to anyone else, or prevent the licensor from +granting licenses to anyone else. These terms do not imply +any other licenses. + +## Patent Defense + +If you make any written claim that the software infringes or +contributes to infringement of any patent, your patent license +for the software granted under these terms ends immediately. If +your company makes such a claim, your patent license ends +immediately for work on behalf of your company. + +## Violations + +The first time you are notified in writing that you have +violated any of these terms, or done anything with the software +not covered by your licenses, your licenses can nonetheless +continue if you come into full compliance with these terms, +and take practical steps to correct past violations, within +32 days of receiving notice. Otherwise, all your licenses +end immediately. + +## No Liability + +***As far as the law allows, the software comes as is, without +any warranty or condition, and the licensor will not be liable +to you for any damages arising out of these terms or the use +or nature of the software, under any kind of legal claim.*** + +## Definitions + +The **licensor** is the individual or entity offering these +terms, and the **software** is the software the licensor makes +available under these terms. + +**You** refers to the individual or entity agreeing to these +terms. + +**Your company** is any legal entity, sole proprietorship, +or other kind of organization that you work for, plus all +organizations that have control over, are under the control of, +or are under common control with that organization. **Control** +means ownership of substantially all the assets of an entity, +or the power to direct its management and policies by vote, +contract, or otherwise. Control can be direct or indirect. + +**Your licenses** are all the licenses granted to you for the +software under these terms. + +**Use** means anything you do with the software requiring one +of your licenses. diff --git a/packages/stack-profile/CHANGELOG.md b/packages/stack-profile/CHANGELOG.md new file mode 100644 index 000000000..8a75685ed --- /dev/null +++ b/packages/stack-profile/CHANGELOG.md @@ -0,0 +1,11 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [0.34.0-alpha.1] - 2026-03-04 + +### Changed +- Consolidated all publishable crate versions to 0.34.0; version is now centralized via `workspace.package.version` diff --git a/packages/stack-profile/Cargo.toml b/packages/stack-profile/Cargo.toml index 7c734926e..f032aad24 100644 --- a/packages/stack-profile/Cargo.toml +++ b/packages/stack-profile/Cargo.toml @@ -2,7 +2,7 @@ name = "stack-profile" description = "Centralised ~/.cipherstash profile file management" license-file = "LICENSE" -version.workspace = true +version = "0.34.0-alpha.1" edition.workspace = true authors.workspace = true repository.workspace = true From 231e659450f0bff7a401e37eb812831b1813a909 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Thu, 19 Mar 2026 15:43:56 +1100 Subject: [PATCH 093/686] =?UTF-8?q?docs:=20=F0=9F=93=9D=20fix=20AutoStrate?= =?UTF-8?q?gy=20docs=20to=20reference=20CS=5FWORKSPACE=5FCRN=20not=20CS=5F?= =?UTF-8?q?REGION?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The code reads region from CS_WORKSPACE_CRN but several doc comments and the example still referenced the removed CS_REGION variable. --- packages/stack-auth/examples/auto_strategy.rs | 4 ++-- packages/stack-auth/src/auto_strategy.rs | 4 ++-- packages/stack-auth/src/lib.rs | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/stack-auth/examples/auto_strategy.rs b/packages/stack-auth/examples/auto_strategy.rs index f911aa133..cf575a7ca 100644 --- a/packages/stack-auth/examples/auto_strategy.rs +++ b/packages/stack-auth/examples/auto_strategy.rs @@ -5,7 +5,7 @@ //! in the following order: //! //! 1. **Access key** – if `CS_CLIENT_ACCESS_KEY` is set along with -//! `CS_REGION` (or `CS_WORKSPACE_CRN`), an [`AccessKeyStrategy`] is used. +//! `CS_WORKSPACE_CRN`, an [`AccessKeyStrategy`] is used. //! 2. **OAuth** – if a token store file exists at `~/.cipherstash/auth.json` //! (written by `stash login`), an [`OAuthStrategy`] is used. //! 3. If neither is available, an error is returned. @@ -15,7 +15,7 @@ //! With an access key: //! //! ```sh -//! CS_CLIENT_ACCESS_KEY= CS_REGION= cargo run --example auto_strategy +//! CS_CLIENT_ACCESS_KEY= CS_WORKSPACE_CRN= cargo run --example auto_strategy //! ``` //! //! Or after authenticating via the CLI: diff --git a/packages/stack-auth/src/auto_strategy.rs b/packages/stack-auth/src/auto_strategy.rs index aab3ab219..8647470f2 100644 --- a/packages/stack-auth/src/auto_strategy.rs +++ b/packages/stack-auth/src/auto_strategy.rs @@ -142,7 +142,7 @@ impl AutoStrategy { /// use stack_auth::AutoStrategy; /// /// # fn run() -> Result<(), Box> { -/// // Provide access key explicitly, region from env +/// // Provide access key explicitly, region from CS_WORKSPACE_CRN env var /// let strategy = AutoStrategy::builder() /// .with_access_key("CSAKmyKeyId.myKeySecret") /// .detect()?; @@ -171,7 +171,7 @@ impl AutoStrategyBuilder { /// /// Resolution order: /// 1. Explicit values provided via builder methods - /// 2. Environment variables (`CS_CLIENT_ACCESS_KEY`, `CS_WORKSPACE_CRN`) + /// 2. Environment variables (`CS_CLIENT_ACCESS_KEY`, `CS_WORKSPACE_CRN` for region) /// 3. Profile store (`~/.cipherstash/auth.json` for OAuth) /// 4. [`AuthError::NotAuthenticated`] pub fn detect(self) -> Result { diff --git a/packages/stack-auth/src/lib.rs b/packages/stack-auth/src/lib.rs index 6acb758ad..81e65f5f9 100644 --- a/packages/stack-auth/src/lib.rs +++ b/packages/stack-auth/src/lib.rs @@ -8,7 +8,7 @@ //! //! | Strategy | Use case | Credentials | //! |---|---|---| -//! | [`AutoStrategy`] | Recommended default — detects credentials automatically | `CS_CLIENT_ACCESS_KEY` + `CS_REGION`, or `~/.cipherstash/auth.json` | +//! | [`AutoStrategy`] | Recommended default — detects credentials automatically | `CS_CLIENT_ACCESS_KEY` + `CS_WORKSPACE_CRN`, or `~/.cipherstash/auth.json` | //! | [`AccessKeyStrategy`] | Service-to-service / CI | Static access key + region | //! | [`OAuthStrategy`] | Long-lived sessions with refresh | OAuth token (from device code flow or disk) | //! | [`DeviceCodeStrategy`] | CLI login ([RFC 8628](https://datatracker.ietf.org/doc/html/rfc8628)) | User authorizes in browser | From c06d0a7061c02b704d3aebd7ffdd1ca3cf23bc2e Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Thu, 19 Mar 2026 15:47:04 +1100 Subject: [PATCH 094/686] =?UTF-8?q?fix:=20=F0=9F=94=A5=20remove=20unreleas?= =?UTF-8?q?ed=20AutoStrategy::new()=20deprecated=20method?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/stack-auth/src/auto_strategy.rs | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/packages/stack-auth/src/auto_strategy.rs b/packages/stack-auth/src/auto_strategy.rs index 8647470f2..69adc63b3 100644 --- a/packages/stack-auth/src/auto_strategy.rs +++ b/packages/stack-auth/src/auto_strategy.rs @@ -51,17 +51,6 @@ pub enum AutoStrategy { } impl AutoStrategy { - /// Detect available credentials and build the appropriate strategy. - /// - /// See the [type-level docs](AutoStrategy) for the detection order. - #[deprecated( - since = "0.35.0", - note = "Use `AutoStrategy::detect()` or `AutoStrategy::builder().detect()` instead" - )] - pub fn new() -> Result { - Self::detect() - } - /// Create a builder for configuring credential resolution. /// /// The builder lets callers provide explicit values (access key, region) From f863c0039d7743981c11120ece54e1e6fa84047b Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Thu, 19 Mar 2026 16:01:41 +1100 Subject: [PATCH 095/686] =?UTF-8?q?refactor:=20=E2=99=BB=EF=B8=8F=20replac?= =?UTF-8?q?e=20with=5Fregion=20with=20with=5Fworkspace=5Fcrn=20and=20add?= =?UTF-8?q?=20MissingWorkspaceCrn=20error?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pass Option through detect_inner instead of Option, eliminating unnecessary Region→String→Region round-trips. Replace with_region on AutoStrategyBuilder with with_workspace_crn(Crn). Add AuthError::MissingWorkspaceCrn so callers get a clear message when an access key is set but CS_WORKSPACE_CRN is missing. --- packages/stack-auth/src/auto_strategy.rs | 77 +++++++++++------------- packages/stack-auth/src/lib.rs | 6 ++ 2 files changed, 40 insertions(+), 43 deletions(-) diff --git a/packages/stack-auth/src/auto_strategy.rs b/packages/stack-auth/src/auto_strategy.rs index 69adc63b3..9503be589 100644 --- a/packages/stack-auth/src/auto_strategy.rs +++ b/packages/stack-auth/src/auto_strategy.rs @@ -1,4 +1,4 @@ -use cts_common::{Crn, Region}; +use cts_common::Crn; use crate::access_key_strategy::AccessKeyStrategy; use crate::oauth_strategy::OAuthStrategy; @@ -53,19 +53,20 @@ pub enum AutoStrategy { impl AutoStrategy { /// Create a builder for configuring credential resolution. /// - /// The builder lets callers provide explicit values (access key, region) + /// The builder lets callers provide explicit values (access key, workspace CRN) /// that take precedence over environment variables and the profile store. /// /// # Example /// /// ```no_run /// use stack_auth::AutoStrategy; - /// use cts_common::Region; + /// use cts_common::Crn; /// /// # fn run() -> Result<(), Box> { + /// let crn: Crn = "crn:ap-southeast-2.aws:workspace-id".parse()?; /// let strategy = AutoStrategy::builder() /// .with_access_key("CSAKmyKeyId.myKeySecret") - /// .with_region(Region::aws("ap-southeast-2")?) + /// .with_workspace_crn(crn) /// .detect()?; /// # Ok(()) /// # } @@ -73,7 +74,7 @@ impl AutoStrategy { pub fn builder() -> AutoStrategyBuilder { AutoStrategyBuilder { access_key: None, - region: None, + crn: None, } } @@ -95,13 +96,14 @@ impl AutoStrategy { /// or filesystem directly. fn detect_inner( access_key: Option, - region: Option, + crn: Option, store: Option, ) -> Result { // 1. Access key from environment if let Some(access_key) = access_key { - let region_str = region.ok_or(AuthError::NotAuthenticated)?; - let region = Region::new(®ion_str)?; + let region = crn + .map(|c| c.region) + .ok_or(AuthError::MissingWorkspaceCrn)?; let key: crate::AccessKey = access_key.parse()?; let strategy = AccessKeyStrategy::new(region, key)?; return Ok(Self::AccessKey(strategy)); @@ -140,7 +142,7 @@ impl AutoStrategy { /// ``` pub struct AutoStrategyBuilder { access_key: Option, - region: Option, + crn: Option, } impl AutoStrategyBuilder { @@ -150,9 +152,9 @@ impl AutoStrategyBuilder { self } - /// Provide an explicit region. Takes precedence over env vars. - pub fn with_region(mut self, region: impl Into) -> Self { - self.region = Some(region.into()); + /// Provide an explicit workspace CRN. Takes precedence over env vars. + pub fn with_workspace_crn(mut self, crn: Crn) -> Self { + self.crn = Some(crn); self } @@ -160,7 +162,7 @@ impl AutoStrategyBuilder { /// /// Resolution order: /// 1. Explicit values provided via builder methods - /// 2. Environment variables (`CS_CLIENT_ACCESS_KEY`, `CS_WORKSPACE_CRN` for region) + /// 2. Environment variables (`CS_CLIENT_ACCESS_KEY`, `CS_WORKSPACE_CRN`) /// 3. Profile store (`~/.cipherstash/auth.json` for OAuth) /// 4. [`AuthError::NotAuthenticated`] pub fn detect(self) -> Result { @@ -169,16 +171,15 @@ impl AutoStrategyBuilder { .access_key .or_else(|| std::env::var("CS_CLIENT_ACCESS_KEY").ok()); - let region = self.region.map(|r| r.identifier()).or_else(|| { + let crn = self.crn.or_else(|| { std::env::var("CS_WORKSPACE_CRN") .ok() .and_then(|s| s.parse::().ok()) - .map(|crn| crn.region.identifier()) }); let store = ProfileStore::resolve(None).ok(); - AutoStrategy::detect_inner(access_key, region, store) + AutoStrategy::detect_inner(access_key, crn, store) } } @@ -197,7 +198,11 @@ mod tests { use crate::{SecretToken, Token}; use std::time::{SystemTime, UNIX_EPOCH}; - const VALID_REGION: &str = "ap-southeast-2.aws"; + const VALID_CRN: &str = "crn:ap-southeast-2.aws:ZVATKW3VHMFG27DY"; + + fn valid_crn() -> Crn { + VALID_CRN.parse().unwrap() + } fn make_oauth_token() -> Token { let now = SystemTime::now() @@ -239,10 +244,10 @@ mod tests { use super::*; #[test] - fn access_key_with_valid_region() { + fn access_key_with_valid_crn() { let result = AutoStrategy::detect_inner( Some("CSAKtestKeyId.testKeySecret".into()), - Some(VALID_REGION.into()), + Some(valid_crn()), None, ); @@ -251,35 +256,21 @@ mod tests { } #[test] - fn access_key_without_region_returns_not_authenticated() { + fn access_key_without_crn_returns_missing_workspace_crn() { let result = AutoStrategy::detect_inner(Some("CSAKtestKeyId.testKeySecret".into()), None, None); - assert!(matches!(result, Err(AuthError::NotAuthenticated))); + assert!(matches!(result, Err(AuthError::MissingWorkspaceCrn))); } #[test] fn invalid_access_key_format_returns_invalid_access_key() { - let result = AutoStrategy::detect_inner( - Some("not-a-valid-key".into()), - Some(VALID_REGION.into()), - None, - ); + let result = + AutoStrategy::detect_inner(Some("not-a-valid-key".into()), Some(valid_crn()), None); assert!(matches!(result, Err(AuthError::InvalidAccessKey(_)))); } - #[test] - fn access_key_with_invalid_region_returns_error() { - let result = AutoStrategy::detect_inner( - Some("CSAKtestKeyId.testKeySecret".into()), - Some("not-a-region".into()), - None, - ); - - assert!(matches!(result, Err(AuthError::Region(_)))); - } - #[test] fn oauth_store_with_valid_token() { let dir = tempfile::tempdir().unwrap(); @@ -315,7 +306,7 @@ mod tests { let result = AutoStrategy::detect_inner( Some("CSAKtestKeyId.testKeySecret".into()), - Some(VALID_REGION.into()), + Some(valid_crn()), Some(store), ); @@ -328,10 +319,10 @@ mod tests { use super::*; #[test] - fn explicit_access_key_and_region() { + fn explicit_access_key_and_crn() { let result = AutoStrategy::builder() .with_access_key("CSAKtestKeyId.testKeySecret") - .with_region(Region::new(VALID_REGION).unwrap()) + .with_workspace_crn(valid_crn()) .detect(); assert!(result.is_ok()); @@ -339,7 +330,7 @@ mod tests { } #[test] - fn explicit_access_key_without_region_and_no_env_returns_not_authenticated() { + fn explicit_access_key_without_crn_and_no_env_returns_missing_workspace_crn() { // Save and clear env to ensure no fallback let saved_crn = std::env::var("CS_WORKSPACE_CRN").ok(); std::env::remove_var("CS_WORKSPACE_CRN"); @@ -353,14 +344,14 @@ mod tests { std::env::set_var("CS_WORKSPACE_CRN", val); } - assert!(matches!(result, Err(AuthError::NotAuthenticated))); + assert!(matches!(result, Err(AuthError::MissingWorkspaceCrn))); } #[test] fn invalid_explicit_access_key_returns_invalid_access_key() { let result = AutoStrategy::builder() .with_access_key("not-a-valid-key") - .with_region(Region::new(VALID_REGION).unwrap()) + .with_workspace_crn(valid_crn()) .detect(); assert!(matches!(result, Err(AuthError::InvalidAccessKey(_)))); diff --git a/packages/stack-auth/src/lib.rs b/packages/stack-auth/src/lib.rs index 81e65f5f9..330a99bc7 100644 --- a/packages/stack-auth/src/lib.rs +++ b/packages/stack-auth/src/lib.rs @@ -171,6 +171,12 @@ pub enum AuthError { /// The workspace CRN could not be parsed. #[error("Invalid workspace CRN: {0}")] InvalidCrn(cts_common::InvalidCrn), + /// An access key was provided but the workspace CRN is missing. + /// + /// Set the `CS_WORKSPACE_CRN` environment variable or call + /// [`AutoStrategyBuilder::with_workspace_crn`](crate::AutoStrategyBuilder::with_workspace_crn). + #[error("CS_WORKSPACE_CRN is required when using an access key")] + MissingWorkspaceCrn, /// No credentials are available (e.g. not logged in, no access key configured). #[error("Not authenticated")] NotAuthenticated, From 46649c259cd505df691999aefb0c56b096a33862 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Thu, 19 Mar 2026 16:06:14 +1100 Subject: [PATCH 096/686] =?UTF-8?q?fix:=20=F0=9F=A9=B9=20remove=20unnecess?= =?UTF-8?q?ary=20bytes.clone()=20and=20improve=20MissingWorkspaceCrn=20mes?= =?UTF-8?q?sage?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove redundant clone of key material in SecretKey::from_hex — ViturKeyMaterial::from takes ownership so the clone just left an extra copy of sensitive data in memory. Also improve the MissingWorkspaceCrn error message to mention both env var and builder method as resolution options. --- packages/stack-auth/src/lib.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/stack-auth/src/lib.rs b/packages/stack-auth/src/lib.rs index 330a99bc7..ef8b8e89e 100644 --- a/packages/stack-auth/src/lib.rs +++ b/packages/stack-auth/src/lib.rs @@ -175,7 +175,7 @@ pub enum AuthError { /// /// Set the `CS_WORKSPACE_CRN` environment variable or call /// [`AutoStrategyBuilder::with_workspace_crn`](crate::AutoStrategyBuilder::with_workspace_crn). - #[error("CS_WORKSPACE_CRN is required when using an access key")] + #[error("Workspace CRN is required when using an access key — set CS_WORKSPACE_CRN or call AutoStrategyBuilder::with_workspace_crn")] MissingWorkspaceCrn, /// No credentials are available (e.g. not logged in, no access key configured). #[error("Not authenticated")] From 68edc5c00a35333f1fa3c480a14c45acc83abbe3 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Thu, 19 Mar 2026 20:28:51 +1100 Subject: [PATCH 097/686] =?UTF-8?q?fix:=20=F0=9F=A9=B9=20address=20PR=20re?= =?UTF-8?q?view=20feedback?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Propagate CRN parse error as AuthError::InvalidCrn instead of silently swallowing it when CS_WORKSPACE_CRN is set but malformed - Accept mixed-case hex in SecretKey::from_hex (base16ct::mixed) - Zeroize client_key_hex before propagating decode errors so sensitive material doesn't linger on the error path - Add comment explaining why ProfileStore::resolve errors are intentionally swallowed in AutoStrategyBuilder::detect --- packages/stack-auth/src/auto_strategy.rs | 31 +++++++++++++++++++++--- 1 file changed, 27 insertions(+), 4 deletions(-) diff --git a/packages/stack-auth/src/auto_strategy.rs b/packages/stack-auth/src/auto_strategy.rs index 9503be589..98d93d8d3 100644 --- a/packages/stack-auth/src/auto_strategy.rs +++ b/packages/stack-auth/src/auto_strategy.rs @@ -171,12 +171,17 @@ impl AutoStrategyBuilder { .access_key .or_else(|| std::env::var("CS_CLIENT_ACCESS_KEY").ok()); - let crn = self.crn.or_else(|| { - std::env::var("CS_WORKSPACE_CRN") + let crn = match self.crn { + Some(crn) => Some(crn), + None => std::env::var("CS_WORKSPACE_CRN") .ok() - .and_then(|s| s.parse::().ok()) - }); + .map(|s| s.parse::().map_err(AuthError::InvalidCrn)) + .transpose()?, + }; + // Resolve errors (e.g. missing profile directory) are intentionally + // swallowed here so that env-var-only setups don't need a profile dir. + // If no credentials are found at all, NotAuthenticated is returned. let store = ProfileStore::resolve(None).ok(); AutoStrategy::detect_inner(access_key, crn, store) @@ -347,6 +352,24 @@ mod tests { assert!(matches!(result, Err(AuthError::MissingWorkspaceCrn))); } + #[test] + fn invalid_crn_env_var_returns_invalid_crn() { + let saved_crn = std::env::var("CS_WORKSPACE_CRN").ok(); + std::env::set_var("CS_WORKSPACE_CRN", "not-a-crn"); + + let result = AutoStrategy::builder() + .with_access_key("CSAKtestKeyId.testKeySecret") + .detect(); + + // Restore env + match saved_crn { + Some(val) => std::env::set_var("CS_WORKSPACE_CRN", val), + None => std::env::remove_var("CS_WORKSPACE_CRN"), + } + + assert!(matches!(result, Err(AuthError::InvalidCrn(_)))); + } + #[test] fn invalid_explicit_access_key_returns_invalid_access_key() { let result = AutoStrategy::builder() From 01cd7698d95dc5c0035df3d1379c33e1beaa7ed1 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Fri, 20 Mar 2026 13:50:09 +1100 Subject: [PATCH 098/686] =?UTF-8?q?fix:=20=F0=9F=90=9B=20fix=20race=20cond?= =?UTF-8?q?ition=20in=20get=5Ftoken()=20when=20token=20expires=20during=20?= =?UTF-8?q?refresh?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add Notify-based wait so callers that see refresh_in_progress + expired token wait for the in-flight refresh instead of returning Expired. Decompose get_token() into focused helpers (initial_auth, wait_for_in_flight_refresh, refresh_non_blocking, refresh_blocking) to make the concurrency model explicit: methods that drop the lock take MutexGuard by value, methods that hold it take &mut State. --- packages/stack-auth/src/auto_refresh.rs | 347 +++++++++++++++--------- 1 file changed, 216 insertions(+), 131 deletions(-) diff --git a/packages/stack-auth/src/auto_refresh.rs b/packages/stack-auth/src/auto_refresh.rs index 71d2af8ac..797d38269 100644 --- a/packages/stack-auth/src/auto_refresh.rs +++ b/packages/stack-auth/src/auto_refresh.rs @@ -1,4 +1,4 @@ -use tokio::sync::Mutex; +use tokio::sync::{Mutex, MutexGuard, Notify}; use crate::refresher::Refresher; use crate::{ServiceToken, Token}; @@ -64,58 +64,45 @@ impl From for crate::AuthError { /// flowchart TD /// Start["get_token()"] --> Lock["Acquire lock"] /// Lock --> Cached{Token cached?} -/// Cached -- No --> TryCred0["try_credential(None)"] -/// TryCred0 -- None --> ErrNotFound["Return NotFound"] -/// TryCred0 -- "Some(cred)" --> InitAuth["refresh(cred) +/// Cached -- No --> InitAuth["initial_auth() /// (lock HELD)"] -/// InitAuth -- OK --> SaveInit["save + cache token"] -/// SaveInit --> ReturnNew["Return Ok(new token)"] +/// InitAuth -- OK --> ReturnNew["Return Ok(new token)"] +/// InitAuth -- NotFound --> ErrNotFound["Return NotFound"] /// InitAuth -- Err --> ErrAuth["Return Auth(err)"] /// Cached -- Yes --> CheckRefresh{is_expired?} /// -/// CheckRefresh -- "No (fresh)" --> CloneFresh["Clone access token, -/// release lock"] -/// CloneFresh --> ReturnOk["Return Ok(token)"] +/// CheckRefresh -- "No (fresh)" --> ServiceToken["service_token()"] +/// ServiceToken --> ReturnOk["Return Ok(token)"] /// /// CheckRefresh -- "Yes (needs refresh)" --> InProgress{refresh_in_progress?} -/// InProgress -- Yes --> Usable0{is_usable?} -/// Usable0 -- Yes --> CloneUsable0["Clone access token"] -/// CloneUsable0 --> ReturnOk -/// Usable0 -- No --> ErrExpired["Return Expired"] +/// InProgress -- Yes --> WaitHelper["wait_for_in_flight_refresh() +/// (drops lock)"] +/// WaitHelper -- "usable" --> ReturnOk +/// WaitHelper -- "wait + recheck" --> ReturnOk +/// WaitHelper -- "expired" --> ErrExpired["Return Expired"] /// /// InProgress -- No --> TryCred{try_credential} -/// TryCred -- None --> Usable1{is_usable?} -/// Usable1 -- Yes --> CloneUsable1["Clone access token"] -/// CloneUsable1 --> ReturnOk -/// Usable1 -- No --> ErrExpired +/// TryCred -- None --> RequireUsable["require_usable_token()"] +/// RequireUsable -- Ok --> ReturnOk +/// RequireUsable -- Err --> ErrExpired /// /// TryCred -- "Some(cred)" --> SetFlag["refresh_in_progress = true"] -/// SetFlag --> Usable2{is_usable?} +/// SetFlag --> Usable{is_usable?} /// -/// Usable2 -- "Yes (expiring but usable)" --> DropLock["Clone access token, -/// release lock"] -/// DropLock --> HTTP1["refresh(cred) -/// (lock NOT held)"] -/// HTTP1 -- OK --> Relock1["Re-acquire lock, -/// save + cache, clear flag"] -/// HTTP1 -- Err --> Restore1["Restore credential, -/// clear flag"] -/// Relock1 --> ReturnOld["Return Ok(old token)"] -/// Restore1 --> ReturnOld +/// Usable -- "Yes (expiring but usable)" --> NonBlocking["refresh_non_blocking() +/// (drops lock, notifies waiters)"] +/// NonBlocking --> ReturnOld["Return Ok(old token)"] /// -/// Usable2 -- "No (fully expired)" --> HTTP2["refresh(cred) -/// (lock HELD)"] -/// HTTP2 -- OK --> StoreNew["save + cache, -/// clear flag, release lock"] -/// StoreNew --> ReturnNew2["Return Ok(new token)"] -/// HTTP2 -- Err --> Restore2["Restore credential, -/// clear flag"] -/// Restore2 --> ErrExpired +/// Usable -- "No (fully expired)" --> Blocking["refresh_blocking() +/// (lock HELD, no notify)"] +/// Blocking -- OK --> ReturnNew2["Return Ok(new token)"] +/// Blocking -- Err --> ErrExpired /// ``` #[cfg_attr(doc, aquamarine::aquamarine)] pub(crate) struct AutoRefresh { refresher: R, state: Mutex, + refresh_notify: Notify, } struct State { @@ -123,6 +110,22 @@ struct State { refresh_in_progress: bool, } +impl State { + fn service_token(&self) -> Result { + let token = self.token.as_ref().ok_or(AutoRefreshError::NotFound)?; + Ok(ServiceToken::new(token.access_token().clone())) + } + + fn require_usable_token(&self) -> Result { + let token = self.token.as_ref().ok_or(AutoRefreshError::NotFound)?; + if token.is_usable() { + Ok(ServiceToken::new(token.access_token().clone())) + } else { + Err(AutoRefreshError::Expired) + } + } +} + impl AutoRefresh { /// Create a new `AutoRefresh` with no initial token. /// @@ -136,6 +139,7 @@ impl AutoRefresh { token: None, refresh_in_progress: false, }), + refresh_notify: Notify::new(), } } @@ -150,6 +154,7 @@ impl AutoRefresh { token: Some(token), refresh_in_progress: false, }), + refresh_notify: Notify::new(), } } } @@ -159,117 +164,137 @@ impl AutoRefresh { pub(crate) async fn get_token(&self) -> Result { let mut state = self.state.lock().await; - // No cached token — attempt initial auth. if state.token.is_none() { - let Some(credential) = self.refresher.try_credential(None) else { - return Err(AutoRefreshError::NotFound); - }; - state.refresh_in_progress = true; - match self.refresher.refresh(&credential).await { - Ok(new_token) => { - self.refresher.save(&new_token); - let service_token = ServiceToken::new(new_token.access_token().clone()); - state.token = Some(new_token); - state.refresh_in_progress = false; - return Ok(service_token); - } - Err(err) => { - state.refresh_in_progress = false; - return Err(AutoRefreshError::Auth(err)); - } - } + return self.initial_auth(&mut state).await; } - let needs_refresh = state.token.as_ref().is_some_and(|t| t.is_expired()); - if !needs_refresh { - // Token is fresh — clone and return. - let token = state.token.as_ref().ok_or(AutoRefreshError::NotFound)?; - return Ok(ServiceToken::new(token.access_token().clone())); + if !state.token.as_ref().is_some_and(|t| t.is_expired()) { + return state.service_token(); } - // Check cascade prevention flag. if state.refresh_in_progress { - let token = state.token.as_ref().ok_or(AutoRefreshError::NotFound)?; - if token.is_usable() { - return Ok(ServiceToken::new(token.access_token().clone())); - } - // NOTE: If a refresh was started while the token was still usable - // (lock released) but the token has since crossed its real expiry, - // we return Expired rather than waiting for the in-flight refresh. - // This is a deliberate trade-off: adding a Notify/condvar to wait - // for the in-flight refresh would increase complexity, and the - // window is narrow (token must expire during the HTTP call). The - // 90s leeway on is_expired() makes this unlikely. Callers can - // retry and will get the new token once the refresh completes. - return Err(AutoRefreshError::Expired); + return self.wait_for_in_flight_refresh(state).await; } - // Token needs refresh. Try to get a credential. - let credential = self.refresher.try_credential(state.token.as_mut()); - - let Some(credential) = credential else { - // No credential available (e.g. OAuth with no refresh token). - let token = state.token.as_ref().ok_or(AutoRefreshError::NotFound)?; - if token.is_usable() { - return Ok(ServiceToken::new(token.access_token().clone())); - } - return Err(AutoRefreshError::Expired); + let Some(credential) = self.refresher.try_credential(state.token.as_mut()) else { + return state.require_usable_token(); }; state.refresh_in_progress = true; - // Check if the current token is still usable. - let is_usable = state.token.as_ref().is_some_and(|t| t.is_usable()); - - if is_usable { - // Token is expiring but still usable. Clone the current access - // token, drop the lock, and refresh in the background of this call. - let current_service_token = ServiceToken::new( - state - .token - .as_ref() - .ok_or(AutoRefreshError::NotFound)? - .access_token() - .clone(), - ); - drop(state); - - match self.refresher.refresh(&credential).await { - Ok(new_token) => { - self.refresher.save(&new_token); - let mut state = self.state.lock().await; - state.token = Some(new_token); - state.refresh_in_progress = false; - } - Err(err) => { - tracing::warn!(%err, "token refresh failed (token still usable)"); - let mut state = self.state.lock().await; - if let Some(token) = state.token.as_mut() { - self.refresher.restore(token, credential); - } - state.refresh_in_progress = false; - } + if state.token.as_ref().is_some_and(|t| t.is_usable()) { + self.refresh_non_blocking(state, credential).await + } else { + self.refresh_blocking(&mut state, credential).await + } + } + + /// No cached token — authenticate via `try_credential(None)`. + /// + /// The lock is held throughout to prevent concurrent initial-auth attempts. + async fn initial_auth(&self, state: &mut State) -> Result { + let Some(credential) = self.refresher.try_credential(None) else { + return Err(AutoRefreshError::NotFound); + }; + state.refresh_in_progress = true; + match self.refresher.refresh(&credential).await { + Ok(new_token) => { + self.refresher.save(&new_token); + let service_token = ServiceToken::new(new_token.access_token().clone()); + state.token = Some(new_token); + state.refresh_in_progress = false; + Ok(service_token) + } + Err(err) => { + state.refresh_in_progress = false; + Err(AutoRefreshError::Auth(err)) } + } + } - Ok(current_service_token) - } else { - // Token is fully expired. Refresh while holding the lock. - match self.refresher.refresh(&credential).await { - Ok(new_token) => { - self.refresher.save(&new_token); - let service_token = ServiceToken::new(new_token.access_token().clone()); - state.token = Some(new_token); - state.refresh_in_progress = false; - Ok(service_token) + /// Another caller is already refreshing — return the current token if still + /// usable, otherwise wait for the in-flight refresh to complete via `Notify`. + /// + /// Takes `MutexGuard` by value because the lock is dropped before awaiting + /// the notification. + async fn wait_for_in_flight_refresh( + &self, + state: MutexGuard<'_, State>, + ) -> Result { + if let Ok(token) = state.service_token() { + if state.token.as_ref().is_some_and(|t| t.is_usable()) { + return Ok(token); + } + } + // Token crossed real expiry during in-flight refresh. Wait for the + // refresh to complete rather than returning Expired. + let notified = self.refresh_notify.notified(); + drop(state); + notified.await; + // Re-check after wake — refresh may have failed. + let state = self.state.lock().await; + state.require_usable_token() + } + + /// Token is expiring but still usable — drop the lock, refresh in the + /// background of this call, and return the old (still-valid) token. + /// + /// Takes `MutexGuard` by value because the lock is dropped before the HTTP + /// request. Notifies waiters after the refresh completes (success or error). + async fn refresh_non_blocking( + &self, + state: MutexGuard<'_, State>, + credential: R::Credential, + ) -> Result { + let current_service_token = state.service_token()?; + drop(state); + + match self.refresher.refresh(&credential).await { + Ok(new_token) => { + self.refresher.save(&new_token); + let mut state = self.state.lock().await; + state.token = Some(new_token); + state.refresh_in_progress = false; + } + Err(err) => { + tracing::warn!(%err, "token refresh failed (token still usable)"); + let mut state = self.state.lock().await; + if let Some(token) = state.token.as_mut() { + self.refresher.restore(token, credential); } - Err(err) => { - tracing::warn!(%err, "token refresh failed"); - if let Some(token) = state.token.as_mut() { - self.refresher.restore(token, credential); - } - state.refresh_in_progress = false; - Err(AutoRefreshError::Expired) + state.refresh_in_progress = false; + } + } + + self.refresh_notify.notify_waiters(); + Ok(current_service_token) + } + + /// Token is fully expired — refresh while holding the lock so concurrent + /// callers block on `lock().await` until the new token is available. + /// + /// Does NOT call `notify_waiters()` — no caller can register a `Notified` + /// while the lock is held, so there is nobody to notify. + async fn refresh_blocking( + &self, + state: &mut State, + credential: R::Credential, + ) -> Result { + match self.refresher.refresh(&credential).await { + Ok(new_token) => { + self.refresher.save(&new_token); + let service_token = ServiceToken::new(new_token.access_token().clone()); + state.token = Some(new_token); + state.refresh_in_progress = false; + Ok(service_token) + } + Err(err) => { + tracing::warn!(%err, "token refresh failed"); + if let Some(token) = state.token.as_mut() { + self.refresher.restore(token, credential); } + state.refresh_in_progress = false; + Err(AutoRefreshError::Expired) } } } @@ -1128,4 +1153,64 @@ mod stress_tests { assert_eq!(stats2.total(), 1, "only one retry refresh should be made"); } + + /// Reproduces the race condition where a token crosses real expiry during + /// an in-flight non-blocking refresh. Before the fix, late-arriving callers + /// would see `refresh_in_progress = true` + `!is_usable()` and return + /// `Err(Expired)` instead of waiting for the refresh to complete. + #[tokio::test(flavor = "multi_thread", worker_threads = 4)] + async fn test_concurrent_token_expires_during_non_blocking_refresh() { + // Token with 1s until real expiry (minimum granularity since + // expires_at is in seconds). is_expired() = true (within 90s leeway), + // is_usable() = true (1s remaining). Refresh takes 1.5s so the token + // crosses real expiry mid-refresh. + let refresh_delay = Duration::from_millis(1500); + let counting = CountingState::new(); + let state = DelayedRefreshState { + counting: counting.clone(), + delay: refresh_delay, + }; + let (base_url, stats) = start_axum_server(delayed_refresh_handler, state).await; + let dir = tempfile::tempdir().unwrap(); + let strategy = Arc::new(auto_refresh_with_token( + &dir, + &base_url, + make_token("expiring-soon", 1, true), + )); + + // First caller triggers the non-blocking refresh and gets the old token. + let first = strategy.get_token().await.unwrap(); + assert_eq!(first.as_str(), "expiring-soon"); + + // Wait for the token to cross real expiry (but refresh is still in-flight). + tokio::time::sleep(Duration::from_millis(1100)).await; + + // Launch 50 concurrent callers. Without the fix, these would all get + // Err(Expired) because refresh_in_progress = true and !is_usable(). + let mut handles = Vec::with_capacity(CONCURRENCY); + for _ in 0..CONCURRENCY { + let s = Arc::clone(&strategy); + handles.push(tokio::spawn(async move { s.get_token().await })); + } + + let results: Vec<_> = { + let mut results = Vec::with_capacity(handles.len()); + for handle in handles { + results.push(handle.await.unwrap()); + } + results + }; + + // All callers must succeed — none should get Expired. + for (i, result) in results.iter().enumerate() { + assert!( + result.is_ok(), + "caller {i} got Err({:?}), expected Ok", + result.as_ref().unwrap_err() + ); + assert_eq!(result.as_ref().unwrap().as_str(), "refreshed-token"); + } + + assert_eq!(stats.total(), 1, "only one refresh request should be made"); + } } From 02530f9a58e9a58725a0922b1d9173e817f0b9fa Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 20 Mar 2026 03:18:50 +0000 Subject: [PATCH 099/686] chore: release --- packages/stack-auth/CHANGELOG.md | 19 +++++++++++++++++++ packages/stack-profile/CHANGELOG.md | 1 + packages/stack-profile/Cargo.toml | 2 +- 3 files changed, 21 insertions(+), 1 deletion(-) create mode 100644 packages/stack-auth/CHANGELOG.md diff --git a/packages/stack-auth/CHANGELOG.md b/packages/stack-auth/CHANGELOG.md new file mode 100644 index 000000000..7dcaac51c --- /dev/null +++ b/packages/stack-auth/CHANGELOG.md @@ -0,0 +1,19 @@ + + +### Documentation + +- 📝 fix AutoStrategy docs to reference CS_WORKSPACE_CRN not CS_REGION + +### Features + +- add AutoStrategyBuilder, Option KeyProvider, and SecretKey::from_hex + +### Fixes + +- 🔥 remove unreleased AutoStrategy::new() deprecated method +- 🩹 remove unnecessary bytes.clone() and improve MissingWorkspaceCrn message +- 🩹 address PR review feedback + +### Refactoring + +- ♻️ replace with_region with with_workspace_crn and add diff --git a/packages/stack-profile/CHANGELOG.md b/packages/stack-profile/CHANGELOG.md index 8a75685ed..c95c22755 100644 --- a/packages/stack-profile/CHANGELOG.md +++ b/packages/stack-profile/CHANGELOG.md @@ -5,6 +5,7 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + ## [0.34.0-alpha.1] - 2026-03-04 ### Changed diff --git a/packages/stack-profile/Cargo.toml b/packages/stack-profile/Cargo.toml index f032aad24..cc9122320 100644 --- a/packages/stack-profile/Cargo.toml +++ b/packages/stack-profile/Cargo.toml @@ -2,7 +2,7 @@ name = "stack-profile" description = "Centralised ~/.cipherstash profile file management" license-file = "LICENSE" -version = "0.34.0-alpha.1" +version = "0.34.0-alpha.2" edition.workspace = true authors.workspace = true repository.workspace = true From 30b047b424098d1fcd3c1c3133cfa6d890e4f0bc Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Fri, 20 Mar 2026 14:30:24 +1100 Subject: [PATCH 100/686] =?UTF-8?q?test:=20=E2=9C=85=20restructure=20auto?= =?UTF-8?q?=5Frefresh=20tests=20into=20nested=20scenario=20modules?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reorganize flat test functions with comment dividers into nested `given_` modules following the rust-testing skill conventions. Add `#[allow(clippy::unwrap_used)]` to both test modules and descriptive messages to all assertions. --- packages/stack-auth/src/auto_refresh.rs | 1366 +++++++++++++---------- 1 file changed, 754 insertions(+), 612 deletions(-) diff --git a/packages/stack-auth/src/auto_refresh.rs b/packages/stack-auth/src/auto_refresh.rs index 797d38269..2a878c447 100644 --- a/packages/stack-auth/src/auto_refresh.rs +++ b/packages/stack-auth/src/auto_refresh.rs @@ -301,6 +301,7 @@ impl AutoRefresh { } #[cfg(test)] +#[allow(clippy::unwrap_used)] mod tests { use super::*; use crate::oauth_refresher::OAuthRefresher; @@ -370,376 +371,484 @@ mod tests { AutoRefresh::with_token(refresher, token) } - // ---- Basic loading tests ---- - - #[tokio::test] - async fn test_returns_cached_token() { - let dir = tempfile::tempdir().unwrap(); - let server = start_server(MockSet::new()).await; - let strategy = - auto_refresh_with_token(&dir, &server, make_token("my-access-token", 3600, false)); + mod given_no_cached_token { + use super::*; + + #[tokio::test] + async fn returns_not_found_for_oauth() { + let server = start_server(MockSet::new()).await; + let store = ProfileStore::new("/tmp/nonexistent"); + let refresher = OAuthRefresher::new( + Some(store), + server.url(""), + "cli", + "ap-southeast-2.aws", + None, + ); + let strategy = AutoRefresh::new(refresher); - let token = strategy.get_token().await.unwrap(); + let err = strategy.get_token().await.unwrap_err(); - assert_eq!(token.as_str(), "my-access-token"); + assert!( + matches!(err, AutoRefreshError::NotFound), + "expected NotFound, got: {err:?}" + ); + } } - #[tokio::test] - async fn test_returns_not_found_when_no_token_and_oauth() { - let server = start_server(MockSet::new()).await; - let store = ProfileStore::new("/tmp/nonexistent"); - let refresher = OAuthRefresher::new( - Some(store), - server.url(""), - "cli", - "ap-southeast-2.aws", - None, - ); - let strategy = AutoRefresh::new(refresher); + mod given_fresh_token { + use super::*; - let err = strategy.get_token().await.unwrap_err(); + #[tokio::test] + async fn returns_cached_token() { + let dir = tempfile::tempdir().unwrap(); + let server = start_server(MockSet::new()).await; + let strategy = + auto_refresh_with_token(&dir, &server, make_token("my-access-token", 3600, false)); - assert!(matches!(err, AutoRefreshError::NotFound)); - } + let token = strategy.get_token().await.unwrap(); - #[tokio::test] - async fn test_caches_token_across_calls() { - let dir = tempfile::tempdir().unwrap(); - let server = start_server(MockSet::new()).await; - let strategy = - auto_refresh_with_token(&dir, &server, make_token("my-access-token", 3600, false)); + assert_eq!( + token.as_str(), + "my-access-token", + "should return the cached access token" + ); + } - let token1 = strategy.get_token().await.unwrap(); - assert_eq!(token1.as_str(), "my-access-token"); + #[tokio::test] + async fn caches_across_calls() { + let dir = tempfile::tempdir().unwrap(); + let server = start_server(MockSet::new()).await; + let strategy = + auto_refresh_with_token(&dir, &server, make_token("my-access-token", 3600, false)); - // Delete the file — second call should still return the cached token. - std::fs::remove_file(dir.path().join("auth.json")).unwrap(); + let token1 = strategy.get_token().await.unwrap(); + assert_eq!( + token1.as_str(), + "my-access-token", + "first call should return the cached token" + ); - let token2 = strategy.get_token().await.unwrap(); - assert_eq!(token2.as_str(), "my-access-token"); - } + // Delete the file — second call should still return the cached token. + std::fs::remove_file(dir.path().join("auth.json")).unwrap(); - // ---- Expiry tests ---- + let token2 = strategy.get_token().await.unwrap(); + assert_eq!( + token2.as_str(), + "my-access-token", + "second call should return the cached token even after file deletion" + ); + } - #[tokio::test] - async fn test_expired_token_without_refresh_token_returns_expired() { - let dir = tempfile::tempdir().unwrap(); - let server = start_server(MockSet::new()).await; - let strategy = auto_refresh_with_token(&dir, &server, make_token("old-token", 0, false)); + #[tokio::test] + async fn does_not_trigger_refresh() { + // Mock that would fail if hit — proves no refresh request is made. + let mut mocks = MockSet::new(); + mocks.mock(|when, then| { + when.post().path("/oauth/token"); + then.internal_server_error() + .json(error_json("should_not_be_called")); + }); + let server = start_server(mocks).await; + let dir = tempfile::tempdir().unwrap(); + let strategy = + auto_refresh_with_token(&dir, &server, make_token("fresh-token", 3600, true)); - let err = strategy.get_token().await.unwrap_err(); + let token = strategy.get_token().await.unwrap(); - assert!(matches!(err, AutoRefreshError::Expired)); + assert_eq!( + token.as_str(), + "fresh-token", + "should return fresh token without triggering refresh" + ); + } } - // ---- Refresh tests ---- + mod given_fully_expired_token { + use super::*; - #[tokio::test] - async fn test_refreshes_expiring_token() { - let mut mocks = MockSet::new(); - mocks.mock(|when, then| { - when.post().path("/oauth/token"); - then.json(refresh_response_json("refreshed-token")); - }); - let server = start_server(mocks).await; - let dir = tempfile::tempdir().unwrap(); - let strategy = auto_refresh_with_token(&dir, &server, make_token("old-token", 0, true)); + mod without_refresh_token { + use super::*; - let token = strategy.get_token().await.unwrap(); + #[tokio::test] + async fn returns_expired() { + let dir = tempfile::tempdir().unwrap(); + let server = start_server(MockSet::new()).await; + let strategy = + auto_refresh_with_token(&dir, &server, make_token("old-token", 0, false)); - assert_eq!(token.as_str(), "refreshed-token"); - } + let err = strategy.get_token().await.unwrap_err(); - #[tokio::test] - async fn test_refresh_persists_new_token_to_disk() { - let mut mocks = MockSet::new(); - mocks.mock(|when, then| { - when.post().path("/oauth/token"); - then.json(refresh_response_json("refreshed-token")); - }); - let server = start_server(mocks).await; - let dir = tempfile::tempdir().unwrap(); - let strategy = auto_refresh_with_token(&dir, &server, make_token("old-token", 0, true)); - - let _ = strategy.get_token().await.unwrap(); - - // Verify the refreshed token was saved to disk. - let store = ProfileStore::new(dir.path()); - let on_disk: Token = store.load_profile().unwrap(); - assert_eq!(on_disk.access_token().as_str(), "refreshed-token"); - } + assert!( + matches!(err, AutoRefreshError::Expired), + "expected Expired, got: {err:?}" + ); + } + } - #[tokio::test] - async fn test_refresh_failure_returns_expired_when_token_is_expired() { - let mut mocks = MockSet::new(); - mocks.mock(|when, then| { - when.post().path("/oauth/token"); - then.bad_request().json(error_json("invalid_grant")); - }); - let server = start_server(mocks).await; - let dir = tempfile::tempdir().unwrap(); - let strategy = auto_refresh_with_token(&dir, &server, make_token("old-token", 0, true)); + mod with_refresh_token { + use super::*; + + #[tokio::test] + async fn refreshes_and_returns_new_token() { + let mut mocks = MockSet::new(); + mocks.mock(|when, then| { + when.post().path("/oauth/token"); + then.json(refresh_response_json("refreshed-token")); + }); + let server = start_server(mocks).await; + let dir = tempfile::tempdir().unwrap(); + let strategy = + auto_refresh_with_token(&dir, &server, make_token("old-token", 0, true)); + + let token = strategy.get_token().await.unwrap(); + + assert_eq!( + token.as_str(), + "refreshed-token", + "should return the refreshed token" + ); + } - let err = strategy.get_token().await.unwrap_err(); + #[tokio::test] + async fn persists_refreshed_token_to_disk() { + let mut mocks = MockSet::new(); + mocks.mock(|when, then| { + when.post().path("/oauth/token"); + then.json(refresh_response_json("refreshed-token")); + }); + let server = start_server(mocks).await; + let dir = tempfile::tempdir().unwrap(); + let strategy = + auto_refresh_with_token(&dir, &server, make_token("old-token", 0, true)); + + let _ = strategy.get_token().await.unwrap(); + + // Verify the refreshed token was saved to disk. + let store = ProfileStore::new(dir.path()); + let on_disk: Token = store.load_profile().unwrap(); + assert_eq!( + on_disk.access_token().as_str(), + "refreshed-token", + "refreshed token should be persisted to disk" + ); + } - assert!(matches!(err, AutoRefreshError::Expired)); - } + #[tokio::test] + async fn returns_expired_on_refresh_failure() { + let mut mocks = MockSet::new(); + mocks.mock(|when, then| { + when.post().path("/oauth/token"); + then.bad_request().json(error_json("invalid_grant")); + }); + let server = start_server(mocks).await; + let dir = tempfile::tempdir().unwrap(); + let strategy = + auto_refresh_with_token(&dir, &server, make_token("old-token", 0, true)); + + let err = strategy.get_token().await.unwrap_err(); + + assert!( + matches!(err, AutoRefreshError::Expired), + "expected Expired after failed refresh, got: {err:?}" + ); + } - #[tokio::test] - async fn test_does_not_refresh_fresh_token() { - // Mock that would fail if hit — proves no refresh request is made. - let mut mocks = MockSet::new(); - mocks.mock(|when, then| { - when.post().path("/oauth/token"); - then.internal_server_error() - .json(error_json("should_not_be_called")); - }); - let server = start_server(mocks).await; - let dir = tempfile::tempdir().unwrap(); - let strategy = - auto_refresh_with_token(&dir, &server, make_token("fresh-token", 3600, true)); + #[tokio::test] + async fn restores_refresh_token_after_failure() { + let mut mocks = MockSet::new(); + mocks.mock(|when, then| { + when.post().path("/oauth/token"); + then.bad_request().json(error_json("invalid_grant")); + }); + let server = start_server(mocks).await; + let dir = tempfile::tempdir().unwrap(); + let strategy = + auto_refresh_with_token(&dir, &server, make_token("old-token", 0, true)); + + // First call: refresh fails, returns Expired. + let err = strategy.get_token().await.unwrap_err(); + assert!( + matches!(err, AutoRefreshError::Expired), + "expected Expired on first attempt, got: {err:?}" + ); + + // Verify the refresh token was restored so a retry is possible. + let state = strategy.state.lock().await; + assert!( + state.token.is_some(), + "token should still be cached after failed refresh" + ); + assert!( + state.token.as_ref().unwrap().refresh_token().is_some(), + "refresh token should be restored for retry" + ); + drop(state); + + // Replace mock with a success response. + server.mocks().clear(); + server.mocks().mock(|when, then| { + when.post().path("/oauth/token"); + then.json(refresh_response_json("refreshed-token")); + }); + + // Second call: refresh token is available → retry succeeds. + let token = strategy.get_token().await.unwrap(); + assert_eq!( + token.as_str(), + "refreshed-token", + "retry should succeed with restored refresh token" + ); + } - let token = strategy.get_token().await.unwrap(); + #[tokio::test] + async fn sequential_calls_only_refresh_once() { + let mut mocks = MockSet::new(); + mocks.mock(|when, then| { + when.post().path("/oauth/token"); + then.json(refresh_response_json("refreshed-once")); + }); + let server = start_server(mocks).await; + let dir = tempfile::tempdir().unwrap(); + let strategy = + auto_refresh_with_token(&dir, &server, make_token("old-token", 0, true)); + + // First call triggers refresh. + let token = strategy.get_token().await.unwrap(); + assert_eq!( + token.as_str(), + "refreshed-once", + "first call should trigger refresh" + ); + + // Swap mock to track if another refresh is attempted. + server.mocks().clear(); + server.mocks().mock(|when, then| { + when.post().path("/oauth/token"); + then.json(refresh_response_json("refreshed-twice")); + }); + + // Calls 2-5: the refreshed token is fresh, so no further refresh. + for _ in 0..4 { + let token = strategy.get_token().await.unwrap(); + assert_eq!( + token.as_str(), + "refreshed-once", + "should return cached refreshed token, not trigger another refresh" + ); + } + } - assert_eq!(token.as_str(), "fresh-token"); + #[tokio::test] + async fn prevents_second_refresh_after_success() { + let mut mocks = MockSet::new(); + mocks.mock(|when, then| { + when.post().path("/oauth/token"); + then.json(refresh_response_json("refreshed-token")); + }); + let server = start_server(mocks).await; + let dir = tempfile::tempdir().unwrap(); + let strategy = + auto_refresh_with_token(&dir, &server, make_token("old-token", 0, true)); + + // First call refreshes successfully. + let token = strategy.get_token().await.unwrap(); + assert_eq!( + token.as_str(), + "refreshed-token", + "first call should refresh the token" + ); + + // Replace the mock with one that errors. + server.mocks().clear(); + server.mocks().mock(|when, then| { + when.post().path("/oauth/token"); + then.bad_request().json(error_json("should_not_be_called")); + }); + + // Second call should return the refreshed token without hitting + // the server again (the new token has a fresh expiry). + let token = strategy.get_token().await.unwrap(); + assert_eq!( + token.as_str(), + "refreshed-token", + "second call should return cached refreshed token" + ); + } + } } - // ---- Cascade prevention tests ---- - - #[tokio::test] - async fn test_refresh_token_is_taken_preventing_second_refresh() { - let mut mocks = MockSet::new(); - mocks.mock(|when, then| { - when.post().path("/oauth/token"); - then.json(refresh_response_json("refreshed-token")); - }); - let server = start_server(mocks).await; - let dir = tempfile::tempdir().unwrap(); - let strategy = auto_refresh_with_token(&dir, &server, make_token("old-token", 0, true)); - - // First call refreshes successfully. - let token = strategy.get_token().await.unwrap(); - assert_eq!(token.as_str(), "refreshed-token"); - - // Replace the mock with one that errors. - server.mocks().clear(); - server.mocks().mock(|when, then| { - when.post().path("/oauth/token"); - then.bad_request().json(error_json("should_not_be_called")); - }); + mod given_expiring_but_usable_token { + use super::*; + + mod when_refresh_fails { + use super::*; + + #[tokio::test] + async fn returns_current_token() { + let mut mocks = MockSet::new(); + mocks.mock(|when, then| { + when.post().path("/oauth/token"); + then.bad_request().json(error_json("server_error")); + }); + let server = start_server(mocks).await; + let dir = tempfile::tempdir().unwrap(); + // Token expires in 30s (within the 90s leeway so is_expired() = true), + // but the access token is still technically usable. + let strategy = + auto_refresh_with_token(&dir, &server, make_token("still-usable", 30, true)); + + // The refresh fails, but the access token should still be returned + // because it's still usable (30s remaining > 0). + let token = strategy.get_token().await.unwrap(); + assert_eq!( + token.as_str(), + "still-usable", + "should return still-usable token despite failed refresh" + ); + + // Verify the access token and refresh token are still present. + let state = strategy.state.lock().await; + assert!(state.token.is_some(), "token should still be cached"); + assert_eq!( + state.token.as_ref().unwrap().access_token().as_str(), + "still-usable", + "access token should be unchanged after failed refresh" + ); + assert!( + state.token.as_ref().unwrap().refresh_token().is_some(), + "refresh token should be restored after failed refresh" + ); + } - // Second call should return the refreshed token without hitting - // the server again (the new token has a fresh expiry). - let token = strategy.get_token().await.unwrap(); - assert_eq!(token.as_str(), "refreshed-token"); + #[tokio::test] + async fn restores_refresh_token_for_retry() { + let mut mocks = MockSet::new(); + mocks.mock(|when, then| { + when.post().path("/oauth/token"); + then.bad_request().json(error_json("server_error")); + }); + let server = start_server(mocks).await; + let dir = tempfile::tempdir().unwrap(); + // Token expires in 30s — is_expired() = true, is_usable() = true. + let strategy = + auto_refresh_with_token(&dir, &server, make_token("still-usable", 30, true)); + + // First call: refresh fails, but the still-usable token is returned. + let token = strategy.get_token().await.unwrap(); + assert_eq!( + token.as_str(), + "still-usable", + "first call should return still-usable token" + ); + + // Replace mock with a success response. + server.mocks().clear(); + server.mocks().mock(|when, then| { + when.post().path("/oauth/token"); + then.json(refresh_response_json("refreshed-token")); + }); + + // Second call: refresh token was restored, so the retry succeeds. + let token = strategy.get_token().await.unwrap(); + assert!( + token.as_str() == "still-usable" || token.as_str() == "refreshed-token", + "expected old or refreshed token, got: {}", + token.as_str() + ); + + // Verify the cache now holds the refreshed token. + let state = strategy.state.lock().await; + assert_eq!( + state.token.as_ref().unwrap().access_token().as_str(), + "refreshed-token", + "cache should hold the refreshed token after retry" + ); + } + } } - #[tokio::test] - async fn test_failed_refresh_restores_refresh_token_for_retry() { - let mut mocks = MockSet::new(); - mocks.mock(|when, then| { - when.post().path("/oauth/token"); - then.bad_request().json(error_json("invalid_grant")); - }); - let server = start_server(mocks).await; - let dir = tempfile::tempdir().unwrap(); - let strategy = auto_refresh_with_token(&dir, &server, make_token("old-token", 0, true)); - - // First call: refresh fails, returns Expired. - let err = strategy.get_token().await.unwrap_err(); - assert!(matches!(err, AutoRefreshError::Expired)); - - // Verify the refresh token was restored so a retry is possible. - let state = strategy.state.lock().await; - assert!(state.token.is_some()); - assert!(state.token.as_ref().unwrap().refresh_token().is_some()); - drop(state); + mod given_concurrent_callers { + use super::*; + + #[tokio::test] + async fn returns_usable_token_while_refreshing() { + let mut mocks = MockSet::new(); + mocks.mock(|when, then| { + when.post().path("/oauth/token"); + then.json(refresh_response_json("refreshed-token")); + }); + let server = start_server(mocks).await; + let dir = tempfile::tempdir().unwrap(); + let strategy = Arc::new(auto_refresh_with_token( + &dir, + &server, + make_token("still-usable", 30, true), + )); - // Replace mock with a success response. - server.mocks().clear(); - server.mocks().mock(|when, then| { - when.post().path("/oauth/token"); - then.json(refresh_response_json("refreshed-token")); - }); + let s1 = Arc::clone(&strategy); + let handle_a = tokio::spawn(async move { s1.get_token().await.unwrap() }); - // Second call: refresh token is available → retry succeeds. - let token = strategy.get_token().await.unwrap(); - assert_eq!(token.as_str(), "refreshed-token"); - } + let s2 = Arc::clone(&strategy); + let handle_b = tokio::spawn(async move { s2.get_token().await.unwrap() }); - #[tokio::test] - async fn test_access_token_remains_after_refresh_token_is_taken() { - let mut mocks = MockSet::new(); - mocks.mock(|when, then| { - when.post().path("/oauth/token"); - then.bad_request().json(error_json("server_error")); - }); - let server = start_server(mocks).await; - let dir = tempfile::tempdir().unwrap(); - // Token expires in 30s (within the 90s leeway so is_expired() = true), - // but the access token is still technically usable. - let strategy = auto_refresh_with_token(&dir, &server, make_token("still-usable", 30, true)); - - // The refresh fails, but the access token should still be returned - // because it's still usable (30s remaining > 0). - let token = strategy.get_token().await.unwrap(); - assert_eq!(token.as_str(), "still-usable"); - - // Verify the access token and refresh token are still present. - let state = strategy.state.lock().await; - assert!(state.token.is_some()); - assert_eq!( - state.token.as_ref().unwrap().access_token().as_str(), - "still-usable" - ); - assert!( - state.token.as_ref().unwrap().refresh_token().is_some(), - "refresh token should be restored after failed refresh" - ); - } + let (result_a, result_b) = tokio::join!(handle_a, handle_b); + let token_a = result_a.unwrap(); + let token_b = result_b.unwrap(); - #[tokio::test] - async fn test_failed_refresh_of_usable_token_can_be_retried() { - let mut mocks = MockSet::new(); - mocks.mock(|when, then| { - when.post().path("/oauth/token"); - then.bad_request().json(error_json("server_error")); - }); - let server = start_server(mocks).await; - let dir = tempfile::tempdir().unwrap(); - // Token expires in 30s — is_expired() = true, is_usable() = true. - let strategy = auto_refresh_with_token(&dir, &server, make_token("still-usable", 30, true)); - - // First call: refresh fails, but the still-usable token is returned. - let token = strategy.get_token().await.unwrap(); - assert_eq!(token.as_str(), "still-usable"); - - // Replace mock with a success response. - server.mocks().clear(); - server.mocks().mock(|when, then| { - when.post().path("/oauth/token"); - then.json(refresh_response_json("refreshed-token")); - }); + assert!( + token_a.as_str() == "still-usable" || token_a.as_str() == "refreshed-token", + "unexpected token_a: {}", + token_a.as_str() + ); + assert!( + token_b.as_str() == "still-usable" || token_b.as_str() == "refreshed-token", + "unexpected token_b: {}", + token_b.as_str() + ); + } - // Second call: refresh token was restored, so the retry succeeds. - let token = strategy.get_token().await.unwrap(); - assert!( - token.as_str() == "still-usable" || token.as_str() == "refreshed-token", - "expected old or refreshed token, got: {}", - token.as_str() - ); + #[tokio::test] + async fn blocks_until_refresh_completes() { + let mut mocks = MockSet::new(); + mocks.mock(|when, then| { + when.post().path("/oauth/token"); + then.json(refresh_response_json("refreshed-token")); + }); + let server = start_server(mocks).await; + let dir = tempfile::tempdir().unwrap(); + let strategy = Arc::new(auto_refresh_with_token( + &dir, + &server, + make_token("expired-token", 0, true), + )); - // Verify the cache now holds the refreshed token. - let state = strategy.state.lock().await; - assert_eq!( - state.token.as_ref().unwrap().access_token().as_str(), - "refreshed-token" - ); - } + let s1 = Arc::clone(&strategy); + let handle_a = tokio::spawn(async move { s1.get_token().await.unwrap() }); - #[tokio::test] - async fn test_multiple_sequential_calls_only_refresh_once() { - let mut mocks = MockSet::new(); - mocks.mock(|when, then| { - when.post().path("/oauth/token"); - then.json(refresh_response_json("refreshed-once")); - }); - let server = start_server(mocks).await; - let dir = tempfile::tempdir().unwrap(); - let strategy = auto_refresh_with_token(&dir, &server, make_token("old-token", 0, true)); - - // First call triggers refresh. - let token = strategy.get_token().await.unwrap(); - assert_eq!(token.as_str(), "refreshed-once"); - - // Swap mock to track if another refresh is attempted. - server.mocks().clear(); - server.mocks().mock(|when, then| { - when.post().path("/oauth/token"); - then.json(refresh_response_json("refreshed-twice")); - }); + let s2 = Arc::clone(&strategy); + let handle_b = tokio::spawn(async move { s2.get_token().await.unwrap() }); + + let (result_a, result_b) = tokio::join!(handle_a, handle_b); + let token_a = result_a.unwrap(); + let token_b = result_b.unwrap(); - // Calls 2-5: the refreshed token is fresh, so no further refresh. - for _ in 0..4 { - let token = strategy.get_token().await.unwrap(); assert_eq!( - token.as_str(), - "refreshed-once", - "should return cached refreshed token, not trigger another refresh" + token_a.as_str(), + "refreshed-token", + "caller a should receive refreshed token" + ); + assert_eq!( + token_b.as_str(), + "refreshed-token", + "caller b should receive refreshed token" ); } } - - // ---- Concurrent access tests ---- - - #[tokio::test] - async fn test_concurrent_access_with_expiring_but_usable_token() { - let mut mocks = MockSet::new(); - mocks.mock(|when, then| { - when.post().path("/oauth/token"); - then.json(refresh_response_json("refreshed-token")); - }); - let server = start_server(mocks).await; - let dir = tempfile::tempdir().unwrap(); - let strategy = Arc::new(auto_refresh_with_token( - &dir, - &server, - make_token("still-usable", 30, true), - )); - - let s1 = Arc::clone(&strategy); - let handle_a = tokio::spawn(async move { s1.get_token().await.unwrap() }); - - let s2 = Arc::clone(&strategy); - let handle_b = tokio::spawn(async move { s2.get_token().await.unwrap() }); - - let (result_a, result_b) = tokio::join!(handle_a, handle_b); - let token_a = result_a.unwrap(); - let token_b = result_b.unwrap(); - - assert!( - token_a.as_str() == "still-usable" || token_a.as_str() == "refreshed-token", - "unexpected token_a: {}", - token_a.as_str() - ); - assert!( - token_b.as_str() == "still-usable" || token_b.as_str() == "refreshed-token", - "unexpected token_b: {}", - token_b.as_str() - ); - } - - #[tokio::test] - async fn test_concurrent_access_with_fully_expired_token() { - let mut mocks = MockSet::new(); - mocks.mock(|when, then| { - when.post().path("/oauth/token"); - then.json(refresh_response_json("refreshed-token")); - }); - let server = start_server(mocks).await; - let dir = tempfile::tempdir().unwrap(); - let strategy = Arc::new(auto_refresh_with_token( - &dir, - &server, - make_token("expired-token", 0, true), - )); - - let s1 = Arc::clone(&strategy); - let handle_a = tokio::spawn(async move { s1.get_token().await.unwrap() }); - - let s2 = Arc::clone(&strategy); - let handle_b = tokio::spawn(async move { s2.get_token().await.unwrap() }); - - let (result_a, result_b) = tokio::join!(handle_a, handle_b); - let token_a = result_a.unwrap(); - let token_b = result_b.unwrap(); - - assert_eq!(token_a.as_str(), "refreshed-token"); - assert_eq!(token_b.as_str(), "refreshed-token"); - } } #[cfg(test)] +#[allow(clippy::unwrap_used)] mod stress_tests { use super::*; use crate::oauth_refresher::OAuthRefresher; @@ -881,336 +990,369 @@ mod stress_tests { const CONCURRENCY: usize = 50; - #[tokio::test(flavor = "multi_thread", worker_threads = 4)] - async fn test_concurrent_fresh_token_no_contention() { - let counting = CountingState::new(); - let state = DelayedRefreshState { - counting: counting.clone(), - delay: Duration::from_millis(500), - }; - let (base_url, stats) = start_axum_server(delayed_refresh_handler, state).await; - let dir = tempfile::tempdir().unwrap(); - let strategy = Arc::new(auto_refresh_with_token( - &dir, - &base_url, - make_token("fresh-token", 3600, true), - )); - - let start = Instant::now(); - let mut handles = Vec::with_capacity(CONCURRENCY); - for _ in 0..CONCURRENCY { - let s = Arc::clone(&strategy); - handles.push(tokio::spawn(async move { s.get_token().await.unwrap() })); - } + mod given_fresh_token { + use super::*; + + #[tokio::test(flavor = "multi_thread", worker_threads = 4)] + async fn all_callers_return_immediately() { + let counting = CountingState::new(); + let state = DelayedRefreshState { + counting: counting.clone(), + delay: Duration::from_millis(500), + }; + let (base_url, stats) = start_axum_server(delayed_refresh_handler, state).await; + let dir = tempfile::tempdir().unwrap(); + let strategy = Arc::new(auto_refresh_with_token( + &dir, + &base_url, + make_token("fresh-token", 3600, true), + )); - let results: Vec<_> = { - let mut results = Vec::with_capacity(handles.len()); - for handle in handles { - results.push(handle.await.unwrap()); + let start = Instant::now(); + let mut handles = Vec::with_capacity(CONCURRENCY); + for _ in 0..CONCURRENCY { + let s = Arc::clone(&strategy); + handles.push(tokio::spawn(async move { s.get_token().await.unwrap() })); } - results - }; - let elapsed = start.elapsed(); - for token in &results { - assert_eq!(token.as_str(), "fresh-token"); - } + let results: Vec<_> = { + let mut results = Vec::with_capacity(handles.len()); + for handle in handles { + results.push(handle.await.unwrap()); + } + results + }; + let elapsed = start.elapsed(); + + for token in &results { + assert_eq!( + token.as_str(), + "fresh-token", + "all callers should receive the fresh token" + ); + } - assert!( - elapsed < Duration::from_millis(200), - "expected < 200ms for fresh tokens, got {:?}", - elapsed - ); - assert_eq!(stats.total(), 0, "no refresh requests should be made"); + assert!( + elapsed < Duration::from_millis(200), + "expected < 200ms for fresh tokens, got {:?}", + elapsed + ); + assert_eq!(stats.total(), 0, "no refresh requests should be made"); + } } - #[tokio::test(flavor = "multi_thread", worker_threads = 4)] - async fn test_concurrent_expiring_token_non_blocking_reads() { - let counting = CountingState::new(); - let state = DelayedRefreshState { - counting: counting.clone(), - delay: Duration::from_millis(500), - }; - let (base_url, stats) = start_axum_server(delayed_refresh_handler, state).await; - let dir = tempfile::tempdir().unwrap(); - let strategy = Arc::new(auto_refresh_with_token( - &dir, - &base_url, - make_token("still-usable", 30, true), - )); - - let start = Instant::now(); - let mut handles = Vec::with_capacity(CONCURRENCY); - for _ in 0..CONCURRENCY { - let s = Arc::clone(&strategy); - handles.push(tokio::spawn(async move { - let call_start = Instant::now(); - let token = s.get_token().await.unwrap(); - (token, call_start.elapsed()) - })); - } + mod given_expiring_but_usable_token { + use super::*; + + #[tokio::test(flavor = "multi_thread", worker_threads = 4)] + async fn non_blocking_reads_during_refresh() { + let counting = CountingState::new(); + let state = DelayedRefreshState { + counting: counting.clone(), + delay: Duration::from_millis(500), + }; + let (base_url, stats) = start_axum_server(delayed_refresh_handler, state).await; + let dir = tempfile::tempdir().unwrap(); + let strategy = Arc::new(auto_refresh_with_token( + &dir, + &base_url, + make_token("still-usable", 30, true), + )); - let results: Vec<_> = { - let mut results = Vec::with_capacity(handles.len()); - for handle in handles { - results.push(handle.await.unwrap()); + let start = Instant::now(); + let mut handles = Vec::with_capacity(CONCURRENCY); + for _ in 0..CONCURRENCY { + let s = Arc::clone(&strategy); + handles.push(tokio::spawn(async move { + let call_start = Instant::now(); + let token = s.get_token().await.unwrap(); + (token, call_start.elapsed()) + })); + } + + let results: Vec<_> = { + let mut results = Vec::with_capacity(handles.len()); + for handle in handles { + results.push(handle.await.unwrap()); + } + results + }; + let elapsed = start.elapsed(); + + for (token, _) in &results { + assert!( + token.as_str() == "still-usable" || token.as_str() == "refreshed-token", + "unexpected token: {}", + token.as_str() + ); } - results - }; - let elapsed = start.elapsed(); - for (token, _) in &results { + let fast_callers = results + .iter() + .filter(|(_, dur)| *dur < Duration::from_millis(100)) + .count(); assert!( - token.as_str() == "still-usable" || token.as_str() == "refreshed-token", - "unexpected token: {}", - token.as_str() + fast_callers >= CONCURRENCY - 1, + "expected at least {} fast callers, got {} (total elapsed: {:?})", + CONCURRENCY - 1, + fast_callers, + elapsed ); + + assert_eq!(stats.peak(), 1, "peak concurrency to refresh endpoint"); + assert_eq!(stats.total(), 1, "total refresh requests"); } - let fast_callers = results - .iter() - .filter(|(_, dur)| *dur < Duration::from_millis(100)) - .count(); - assert!( - fast_callers >= CONCURRENCY - 1, - "expected at least {} fast callers, got {} (total elapsed: {:?})", - CONCURRENCY - 1, - fast_callers, - elapsed - ); + /// Reproduces the race condition where a token crosses real expiry during + /// an in-flight non-blocking refresh. Before the fix, late-arriving callers + /// would see `refresh_in_progress = true` + `!is_usable()` and return + /// `Err(Expired)` instead of waiting for the refresh to complete. + #[tokio::test(flavor = "multi_thread", worker_threads = 4)] + async fn waiters_receive_token_when_expiry_crosses() { + // Token with 1s until real expiry (minimum granularity since + // expires_at is in seconds). is_expired() = true (within 90s leeway), + // is_usable() = true (1s remaining). Refresh takes 1.5s so the token + // crosses real expiry mid-refresh. + let refresh_delay = Duration::from_millis(1500); + let counting = CountingState::new(); + let state = DelayedRefreshState { + counting: counting.clone(), + delay: refresh_delay, + }; + let (base_url, stats) = start_axum_server(delayed_refresh_handler, state).await; + let dir = tempfile::tempdir().unwrap(); + let strategy = Arc::new(auto_refresh_with_token( + &dir, + &base_url, + make_token("expiring-soon", 1, true), + )); - assert_eq!(stats.peak(), 1, "peak concurrency to refresh endpoint"); - assert_eq!(stats.total(), 1, "total refresh requests"); - } + // First caller triggers the non-blocking refresh and gets the old token. + let first = strategy.get_token().await.unwrap(); + assert_eq!( + first.as_str(), + "expiring-soon", + "first caller should receive the expiring token" + ); - #[tokio::test(flavor = "multi_thread", worker_threads = 4)] - async fn test_concurrent_expired_token_blocks_until_refresh() { - let refresh_delay = Duration::from_millis(200); - let counting = CountingState::new(); - let state = DelayedRefreshState { - counting: counting.clone(), - delay: refresh_delay, - }; - let (base_url, stats) = start_axum_server(delayed_refresh_handler, state).await; - let dir = tempfile::tempdir().unwrap(); - let strategy = Arc::new(auto_refresh_with_token( - &dir, - &base_url, - make_token("expired-token", 0, true), - )); - - let start = Instant::now(); - let mut handles = Vec::with_capacity(CONCURRENCY); - for _ in 0..CONCURRENCY { - let s = Arc::clone(&strategy); - handles.push(tokio::spawn(async move { s.get_token().await.unwrap() })); - } + // Wait for the token to cross real expiry (but refresh is still in-flight). + tokio::time::sleep(Duration::from_millis(1100)).await; - let results: Vec<_> = { - let mut results = Vec::with_capacity(handles.len()); - for handle in handles { - results.push(handle.await.unwrap()); + // Launch 50 concurrent callers. Without the fix, these would all get + // Err(Expired) because refresh_in_progress = true and !is_usable(). + let mut handles = Vec::with_capacity(CONCURRENCY); + for _ in 0..CONCURRENCY { + let s = Arc::clone(&strategy); + handles.push(tokio::spawn(async move { s.get_token().await })); } - results - }; - let elapsed = start.elapsed(); - - for token in &results { - assert_eq!(token.as_str(), "refreshed-token"); - } - assert!( - elapsed < refresh_delay + Duration::from_millis(200), - "expected < {:?} for blocked callers, got {:?}", - refresh_delay + Duration::from_millis(200), - elapsed - ); + let results: Vec<_> = { + let mut results = Vec::with_capacity(handles.len()); + for handle in handles { + results.push(handle.await.unwrap()); + } + results + }; + + // All callers must succeed — none should get Expired. + for (i, result) in results.iter().enumerate() { + assert!( + result.is_ok(), + "caller {i} got Err({:?}), expected Ok", + result.as_ref().unwrap_err() + ); + assert_eq!( + result.as_ref().unwrap().as_str(), + "refreshed-token", + "caller {i} should receive the refreshed token" + ); + } - assert_eq!(stats.peak(), 1, "peak concurrency to refresh endpoint"); - assert_eq!(stats.total(), 1, "total refresh requests"); + assert_eq!(stats.total(), 1, "only one refresh request should be made"); + } } - #[tokio::test(flavor = "multi_thread", worker_threads = 4)] - async fn test_concurrent_expired_token_refresh_failure_recovers() { - let counting = CountingState::new(); - let state = DelayedRefreshState { - counting: counting.clone(), - delay: Duration::from_millis(10), - }; - let (base_url, stats) = start_axum_server(delayed_error_handler, state).await; - let dir = tempfile::tempdir().unwrap(); - let strategy = Arc::new(auto_refresh_with_token( - &dir, - &base_url, - make_token("expired-token", 0, true), - )); - - let mut handles = Vec::with_capacity(CONCURRENCY); - for _ in 0..CONCURRENCY { - let s = Arc::clone(&strategy); - handles.push(tokio::spawn(async move { s.get_token().await })); - } + mod given_fully_expired_token { + use super::*; + + #[tokio::test(flavor = "multi_thread", worker_threads = 4)] + async fn all_callers_block_until_refresh() { + let refresh_delay = Duration::from_millis(200); + let counting = CountingState::new(); + let state = DelayedRefreshState { + counting: counting.clone(), + delay: refresh_delay, + }; + let (base_url, stats) = start_axum_server(delayed_refresh_handler, state).await; + let dir = tempfile::tempdir().unwrap(); + let strategy = Arc::new(auto_refresh_with_token( + &dir, + &base_url, + make_token("expired-token", 0, true), + )); - let results: Vec<_> = { - let mut results = Vec::with_capacity(handles.len()); - for handle in handles { - results.push(handle.await.unwrap()); + let start = Instant::now(); + let mut handles = Vec::with_capacity(CONCURRENCY); + for _ in 0..CONCURRENCY { + let s = Arc::clone(&strategy); + handles.push(tokio::spawn(async move { s.get_token().await.unwrap() })); } - results - }; - - for result in &results { - assert!(result.is_err(), "expected Expired error, got Ok"); - assert!(matches!( - result.as_ref().unwrap_err(), - AutoRefreshError::Expired - )); - } - let state = strategy.state.lock().await; - assert!( - state.token.as_ref().unwrap().refresh_token().is_some(), - "refresh token should be restored after failed refresh" - ); - drop(state); + let results: Vec<_> = { + let mut results = Vec::with_capacity(handles.len()); + for handle in handles { + results.push(handle.await.unwrap()); + } + results + }; + let elapsed = start.elapsed(); + + for token in &results { + assert_eq!( + token.as_str(), + "refreshed-token", + "all callers should receive refreshed token" + ); + } - assert_eq!(stats.peak(), 1, "peak concurrency to refresh endpoint"); - assert!( - stats.total() >= 1, - "at least one refresh attempt should be made" - ); - } + assert!( + elapsed < refresh_delay + Duration::from_millis(200), + "expected < {:?} for blocked callers, got {:?}", + refresh_delay + Duration::from_millis(200), + elapsed + ); - #[tokio::test(flavor = "multi_thread", worker_threads = 4)] - async fn test_concurrent_refresh_failure_then_retry() { - // Phase 1: Server returns errors. - let counting1 = CountingState::new(); - let state1 = DelayedRefreshState { - counting: counting1.clone(), - delay: Duration::from_millis(50), - }; - let (base_url, _) = start_axum_server(delayed_error_handler, state1).await; - let dir = tempfile::tempdir().unwrap(); - let strategy = Arc::new(auto_refresh_with_token( - &dir, - &base_url, - make_token("expired-token", 0, true), - )); - - let mut handles = Vec::with_capacity(CONCURRENCY); - for _ in 0..CONCURRENCY { - let s = Arc::clone(&strategy); - handles.push(tokio::spawn(async move { s.get_token().await })); + assert_eq!(stats.peak(), 1, "peak concurrency to refresh endpoint"); + assert_eq!(stats.total(), 1, "total refresh requests"); } - let results: Vec<_> = { - let mut results = Vec::with_capacity(handles.len()); - for handle in handles { - results.push(handle.await.unwrap()); + #[tokio::test(flavor = "multi_thread", worker_threads = 4)] + async fn all_callers_receive_expired_on_failure() { + let counting = CountingState::new(); + let state = DelayedRefreshState { + counting: counting.clone(), + delay: Duration::from_millis(10), + }; + let (base_url, stats) = start_axum_server(delayed_error_handler, state).await; + let dir = tempfile::tempdir().unwrap(); + let strategy = Arc::new(auto_refresh_with_token( + &dir, + &base_url, + make_token("expired-token", 0, true), + )); + + let mut handles = Vec::with_capacity(CONCURRENCY); + for _ in 0..CONCURRENCY { + let s = Arc::clone(&strategy); + handles.push(tokio::spawn(async move { s.get_token().await })); + } + + let results: Vec<_> = { + let mut results = Vec::with_capacity(handles.len()); + for handle in handles { + results.push(handle.await.unwrap()); + } + results + }; + + for result in &results { + assert!(result.is_err(), "expected Expired error, got Ok"); + let err = result.as_ref().unwrap_err(); + assert!( + matches!(err, AutoRefreshError::Expired), + "expected Expired, got: {err:?}" + ); } - results - }; - for result in &results { + let state = strategy.state.lock().await; assert!( - result.is_err(), - "first wave: expected Expired, got Ok({})", - result.as_ref().unwrap().as_str() + state.token.as_ref().unwrap().refresh_token().is_some(), + "refresh token should be restored after failed refresh" ); - } + drop(state); - // Phase 2: New server that returns success. - let counting2 = CountingState::new(); - let state2 = DelayedRefreshState { - counting: counting2.clone(), - delay: Duration::from_millis(50), - }; - let (base_url2, stats2) = start_axum_server(delayed_refresh_handler, state2).await; - - let strategy2 = Arc::new(auto_refresh_with_token( - &dir, - &base_url2, - make_token("expired-token", 0, true), - )); - - let mut handles = Vec::with_capacity(CONCURRENCY); - for _ in 0..CONCURRENCY { - let s = Arc::clone(&strategy2); - handles.push(tokio::spawn(async move { s.get_token().await.unwrap() })); + assert_eq!(stats.peak(), 1, "peak concurrency to refresh endpoint"); + assert!( + stats.total() >= 1, + "at least one refresh attempt should be made" + ); } - let results: Vec<_> = { - let mut results = Vec::with_capacity(handles.len()); - for handle in handles { - results.push(handle.await.unwrap()); + #[tokio::test(flavor = "multi_thread", worker_threads = 4)] + async fn retry_succeeds_after_failure() { + // Phase 1: Server returns errors. + let counting1 = CountingState::new(); + let state1 = DelayedRefreshState { + counting: counting1.clone(), + delay: Duration::from_millis(50), + }; + let (base_url, _) = start_axum_server(delayed_error_handler, state1).await; + let dir = tempfile::tempdir().unwrap(); + let strategy = Arc::new(auto_refresh_with_token( + &dir, + &base_url, + make_token("expired-token", 0, true), + )); + + let mut handles = Vec::with_capacity(CONCURRENCY); + for _ in 0..CONCURRENCY { + let s = Arc::clone(&strategy); + handles.push(tokio::spawn(async move { s.get_token().await })); } - results - }; - for token in &results { - assert_eq!(token.as_str(), "refreshed-token"); - } + let results: Vec<_> = { + let mut results = Vec::with_capacity(handles.len()); + for handle in handles { + results.push(handle.await.unwrap()); + } + results + }; + + for result in &results { + assert!( + result.is_err(), + "first wave: expected Expired, got Ok({})", + result.as_ref().unwrap().as_str() + ); + } - assert_eq!(stats2.total(), 1, "only one retry refresh should be made"); - } + // Phase 2: New server that returns success. + let counting2 = CountingState::new(); + let state2 = DelayedRefreshState { + counting: counting2.clone(), + delay: Duration::from_millis(50), + }; + let (base_url2, stats2) = start_axum_server(delayed_refresh_handler, state2).await; + + let strategy2 = Arc::new(auto_refresh_with_token( + &dir, + &base_url2, + make_token("expired-token", 0, true), + )); - /// Reproduces the race condition where a token crosses real expiry during - /// an in-flight non-blocking refresh. Before the fix, late-arriving callers - /// would see `refresh_in_progress = true` + `!is_usable()` and return - /// `Err(Expired)` instead of waiting for the refresh to complete. - #[tokio::test(flavor = "multi_thread", worker_threads = 4)] - async fn test_concurrent_token_expires_during_non_blocking_refresh() { - // Token with 1s until real expiry (minimum granularity since - // expires_at is in seconds). is_expired() = true (within 90s leeway), - // is_usable() = true (1s remaining). Refresh takes 1.5s so the token - // crosses real expiry mid-refresh. - let refresh_delay = Duration::from_millis(1500); - let counting = CountingState::new(); - let state = DelayedRefreshState { - counting: counting.clone(), - delay: refresh_delay, - }; - let (base_url, stats) = start_axum_server(delayed_refresh_handler, state).await; - let dir = tempfile::tempdir().unwrap(); - let strategy = Arc::new(auto_refresh_with_token( - &dir, - &base_url, - make_token("expiring-soon", 1, true), - )); - - // First caller triggers the non-blocking refresh and gets the old token. - let first = strategy.get_token().await.unwrap(); - assert_eq!(first.as_str(), "expiring-soon"); - - // Wait for the token to cross real expiry (but refresh is still in-flight). - tokio::time::sleep(Duration::from_millis(1100)).await; - - // Launch 50 concurrent callers. Without the fix, these would all get - // Err(Expired) because refresh_in_progress = true and !is_usable(). - let mut handles = Vec::with_capacity(CONCURRENCY); - for _ in 0..CONCURRENCY { - let s = Arc::clone(&strategy); - handles.push(tokio::spawn(async move { s.get_token().await })); - } + let mut handles = Vec::with_capacity(CONCURRENCY); + for _ in 0..CONCURRENCY { + let s = Arc::clone(&strategy2); + handles.push(tokio::spawn(async move { s.get_token().await.unwrap() })); + } - let results: Vec<_> = { - let mut results = Vec::with_capacity(handles.len()); - for handle in handles { - results.push(handle.await.unwrap()); + let results: Vec<_> = { + let mut results = Vec::with_capacity(handles.len()); + for handle in handles { + results.push(handle.await.unwrap()); + } + results + }; + + for token in &results { + assert_eq!( + token.as_str(), + "refreshed-token", + "retry callers should receive refreshed token" + ); } - results - }; - // All callers must succeed — none should get Expired. - for (i, result) in results.iter().enumerate() { - assert!( - result.is_ok(), - "caller {i} got Err({:?}), expected Ok", - result.as_ref().unwrap_err() - ); - assert_eq!(result.as_ref().unwrap().as_str(), "refreshed-token"); + assert_eq!(stats2.total(), 1, "only one retry refresh should be made"); } - - assert_eq!(stats.total(), 1, "only one refresh request should be made"); } } From 40fd539d88273392572079e462e8099d85d8f830 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Fri, 20 Mar 2026 14:43:44 +1100 Subject: [PATCH 101/686] =?UTF-8?q?docs:=20=F0=9F=93=9D=20move=20token=20r?= =?UTF-8?q?efresh=20docs=20and=20mermaid=20diagram=20to=20public=20AuthStr?= =?UTF-8?q?ategy=20trait?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The concurrency model docs and flow diagram were on the private AutoRefresh struct, so they never appeared in rustdoc. Move them to the public AuthStrategy trait where consumers can find them, and add a brief "Token refresh" section to the crate-level docs pointing there. --- packages/stack-auth/src/auto_refresh.rs | 68 +---------------------- packages/stack-auth/src/lib.rs | 74 +++++++++++++++++++++++++ 2 files changed, 76 insertions(+), 66 deletions(-) diff --git a/packages/stack-auth/src/auto_refresh.rs b/packages/stack-auth/src/auto_refresh.rs index 2a878c447..5b9795790 100644 --- a/packages/stack-auth/src/auto_refresh.rs +++ b/packages/stack-auth/src/auto_refresh.rs @@ -33,72 +33,8 @@ impl From for crate::AuthError { /// Caches a token in memory and uses a [`Refresher`] to re-authenticate /// or refresh before expiry. /// -/// # Concurrency model -/// -/// Internal state is protected by a [`tokio::sync::Mutex`]. The key design -/// decision is *when* the lock is held during a refresh, which depends on -/// whether the current token is still usable as a bearer credential: -/// -/// - [`Token::is_expired()`] — returns `true` when the token is within **90 -/// seconds** of its `expires_at` timestamp. This triggers a preemptive -/// refresh attempt. -/// - [`Token::is_usable()`] — returns `true` when the token has **not yet -/// reached** its `expires_at` timestamp. A token can be "expired" (in the -/// leeway sense) but still "usable" (the server will still accept it). -/// -/// This distinction enables two concurrent refresh strategies: -/// -/// 1. **Expiring but still usable** — The refreshing caller drops the lock -/// before making the HTTP request. Concurrent callers acquire the lock and -/// receive the current (still-valid) token immediately. -/// 2. **Fully expired** — The refreshing caller holds the lock through the -/// HTTP request. Concurrent callers block on `lock().await` until the -/// refresh completes, then see the new token. -/// -/// Cascade prevention: the `refresh_in_progress` flag prevents multiple -/// callers from initiating concurrent refreshes. -/// -/// # Flow diagram -/// -/// ```mermaid -/// flowchart TD -/// Start["get_token()"] --> Lock["Acquire lock"] -/// Lock --> Cached{Token cached?} -/// Cached -- No --> InitAuth["initial_auth() -/// (lock HELD)"] -/// InitAuth -- OK --> ReturnNew["Return Ok(new token)"] -/// InitAuth -- NotFound --> ErrNotFound["Return NotFound"] -/// InitAuth -- Err --> ErrAuth["Return Auth(err)"] -/// Cached -- Yes --> CheckRefresh{is_expired?} -/// -/// CheckRefresh -- "No (fresh)" --> ServiceToken["service_token()"] -/// ServiceToken --> ReturnOk["Return Ok(token)"] -/// -/// CheckRefresh -- "Yes (needs refresh)" --> InProgress{refresh_in_progress?} -/// InProgress -- Yes --> WaitHelper["wait_for_in_flight_refresh() -/// (drops lock)"] -/// WaitHelper -- "usable" --> ReturnOk -/// WaitHelper -- "wait + recheck" --> ReturnOk -/// WaitHelper -- "expired" --> ErrExpired["Return Expired"] -/// -/// InProgress -- No --> TryCred{try_credential} -/// TryCred -- None --> RequireUsable["require_usable_token()"] -/// RequireUsable -- Ok --> ReturnOk -/// RequireUsable -- Err --> ErrExpired -/// -/// TryCred -- "Some(cred)" --> SetFlag["refresh_in_progress = true"] -/// SetFlag --> Usable{is_usable?} -/// -/// Usable -- "Yes (expiring but usable)" --> NonBlocking["refresh_non_blocking() -/// (drops lock, notifies waiters)"] -/// NonBlocking --> ReturnOld["Return Ok(old token)"] -/// -/// Usable -- "No (fully expired)" --> Blocking["refresh_blocking() -/// (lock HELD, no notify)"] -/// Blocking -- OK --> ReturnNew2["Return Ok(new token)"] -/// Blocking -- Err --> ErrExpired -/// ``` -#[cfg_attr(doc, aquamarine::aquamarine)] +/// See the [crate-level documentation](crate#token-refresh) for a full +/// description of the concurrency model and flow diagram. pub(crate) struct AutoRefresh { refresher: R, state: Mutex, diff --git a/packages/stack-auth/src/lib.rs b/packages/stack-auth/src/lib.rs index ef8b8e89e..211f1d426 100644 --- a/packages/stack-auth/src/lib.rs +++ b/packages/stack-auth/src/lib.rs @@ -47,6 +47,13 @@ //! Sensitive values ([`SecretToken`]) are automatically zeroized when dropped //! and are masked in [`Debug`](std::fmt::Debug) output to prevent accidental //! leaks in logs. +//! +//! # Token refresh +//! +//! All strategies that cache tokens ([`AccessKeyStrategy`], [`OAuthStrategy`], +//! [`AutoStrategy`]) share the same internal refresh engine. See the +//! [`AuthStrategy`] trait docs for a full description of the concurrency model +//! and flow diagram. // Security lints #![deny(unsafe_code)] @@ -114,6 +121,73 @@ pub use stack_profile::DeviceIdentity; /// /// The trait is designed to be implemented for `&T`, so that callers can use /// shared references (e.g. `&OAuthStrategy`) without consuming the strategy. +/// +/// # Token refresh +/// +/// All strategies that cache tokens ([`AccessKeyStrategy`], [`OAuthStrategy`], +/// [`AutoStrategy`]) share the same internal refresh engine. Understanding the +/// refresh model helps predict how [`get_token`](AuthStrategy::get_token) +/// behaves under concurrent access. +/// +/// ## Expiry vs usability +/// +/// A token has two time thresholds: +/// +/// - **Expired** — the token is within **90 seconds** of its `expires_at` +/// timestamp. This triggers a preemptive refresh attempt. +/// - **Usable** — the token has **not yet reached** its `expires_at` timestamp. +/// A token can be "expired" (in the preemptive sense) but still "usable" +/// (the server will still accept it). +/// +/// ## Concurrent refresh strategies +/// +/// The gap between "expired" and "unusable" enables two refresh modes: +/// +/// 1. **Expiring but still usable** — The first caller triggers a background +/// refresh. Concurrent callers receive the current (still-valid) token +/// immediately without blocking. +/// 2. **Fully expired** — The first caller blocks while refreshing. Concurrent +/// callers wait until the refresh completes, then all receive the new token. +/// +/// Only one refresh runs at a time, regardless of how many callers request a +/// token concurrently. +/// +/// ## Flow diagram +/// +/// ```mermaid +/// flowchart TD +/// Start["get_token()"] --> Lock["Acquire lock"] +/// Lock --> Cached{Token cached?} +/// Cached -- No --> InitAuth["Authenticate +/// (lock held)"] +/// InitAuth -- OK --> ReturnNew["Return new token"] +/// InitAuth -- NotFound --> ErrNotFound["NotAuthenticated"] +/// InitAuth -- Err --> ErrAuth["Return error"] +/// Cached -- Yes --> CheckRefresh{Expired?} +/// +/// CheckRefresh -- "No (fresh)" --> ReturnOk["Return cached token"] +/// +/// CheckRefresh -- "Yes (needs refresh)" --> InProgress{Refresh in progress?} +/// InProgress -- Yes --> WaitOrReturn["Return token if usable, +/// else wait for refresh"] +/// WaitOrReturn --> ReturnOk +/// +/// InProgress -- No --> HasCred{Refresh credential?} +/// HasCred -- None --> CheckUsable["Return token if usable, +/// else TokenExpired"] +/// +/// HasCred -- Yes --> Usable{Still usable?} +/// +/// Usable -- "Yes (preemptive)" --> NonBlocking["Refresh in background +/// (lock released)"] +/// NonBlocking --> ReturnOld["Return current token"] +/// +/// Usable -- "No (fully expired)" --> Blocking["Refresh +/// (lock held)"] +/// Blocking -- OK --> ReturnNew2["Return new token"] +/// Blocking -- Err --> ErrExpired["TokenExpired"] +/// ``` +#[cfg_attr(doc, aquamarine::aquamarine)] pub trait AuthStrategy: Send { /// Retrieve a valid access token, refreshing or re-authenticating as needed. fn get_token(self) -> impl Future> + Send; From ddeca825124b0aa22995fee9e67a2539eaeb5197 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Fri, 20 Mar 2026 15:39:17 +1100 Subject: [PATCH 102/686] =?UTF-8?q?fix:=20=F0=9F=90=9B=20make=20refresh=20?= =?UTF-8?q?futures=20cancellation-safe=20with=20CancelGuard?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit If a `get_token()` future was dropped mid-refresh (e.g. timeout or caller cancellation), `refresh_in_progress` remained `true` and `notify_waiters()` was never called, causing subsequent callers to hang indefinitely in `wait_for_in_flight_refresh`. Move `refresh_in_progress` from the mutex-protected `State` to an `AtomicBool` on `AutoRefresh` so that a `CancelGuard` can reset it without acquiring the mutex. The guard is placed in `initial_auth`, `refresh_non_blocking`, and `refresh_blocking` — defused on the normal path so regular cleanup runs, but fires on cancellation to clear the flag and wake any waiters. Also fix the flow diagram on `AuthStrategy` to show the error path from `WaitOrReturn` when an in-flight refresh fails. --- packages/stack-auth/src/auto_refresh.rs | 98 ++++++++++++++++++++----- packages/stack-auth/src/lib.rs | 3 +- 2 files changed, 80 insertions(+), 21 deletions(-) diff --git a/packages/stack-auth/src/auto_refresh.rs b/packages/stack-auth/src/auto_refresh.rs index 5b9795790..6579b44c2 100644 --- a/packages/stack-auth/src/auto_refresh.rs +++ b/packages/stack-auth/src/auto_refresh.rs @@ -1,3 +1,5 @@ +use std::sync::atomic::{AtomicBool, Ordering}; + use tokio::sync::{Mutex, MutexGuard, Notify}; use crate::refresher::Refresher; @@ -38,12 +40,43 @@ impl From for crate::AuthError { pub(crate) struct AutoRefresh { refresher: R, state: Mutex, + /// Set to `true` while a refresh HTTP call is in-flight. + /// + /// Stored as an [`AtomicBool`] rather than inside [`State`] so that + /// [`CancelGuard`] can reset it on future cancellation without acquiring + /// the mutex. + refresh_in_progress: AtomicBool, refresh_notify: Notify, } struct State { token: Option, - refresh_in_progress: bool, +} + +/// Ensures [`AutoRefresh::refresh_in_progress`] is cleared and waiters are +/// notified if the refresh future is cancelled (dropped) before completing. +/// +/// On the normal path (success or handled error), the guard is defused before +/// drop so that the regular cleanup code runs instead. +struct CancelGuard<'a> { + in_progress: &'a AtomicBool, + notify: &'a Notify, + defused: bool, +} + +impl Drop for CancelGuard<'_> { + fn drop(&mut self) { + if !self.defused { + self.in_progress.store(false, Ordering::Release); + self.notify.notify_waiters(); + } + } +} + +impl CancelGuard<'_> { + fn defuse(&mut self) { + self.defused = true; + } } impl State { @@ -71,10 +104,8 @@ impl AutoRefresh { pub(crate) fn new(refresher: R) -> Self { Self { refresher, - state: Mutex::new(State { - token: None, - refresh_in_progress: false, - }), + state: Mutex::new(State { token: None }), + refresh_in_progress: AtomicBool::new(false), refresh_notify: Notify::new(), } } @@ -86,10 +117,8 @@ impl AutoRefresh { pub(crate) fn with_token(refresher: R, token: Token) -> Self { Self { refresher, - state: Mutex::new(State { - token: Some(token), - refresh_in_progress: false, - }), + state: Mutex::new(State { token: Some(token) }), + refresh_in_progress: AtomicBool::new(false), refresh_notify: Notify::new(), } } @@ -108,7 +137,7 @@ impl AutoRefresh { return state.service_token(); } - if state.refresh_in_progress { + if self.refresh_in_progress.load(Ordering::Acquire) { return self.wait_for_in_flight_refresh(state).await; } @@ -116,7 +145,7 @@ impl AutoRefresh { return state.require_usable_token(); }; - state.refresh_in_progress = true; + self.refresh_in_progress.store(true, Ordering::Release); if state.token.as_ref().is_some_and(|t| t.is_usable()) { self.refresh_non_blocking(state, credential).await @@ -132,17 +161,24 @@ impl AutoRefresh { let Some(credential) = self.refresher.try_credential(None) else { return Err(AutoRefreshError::NotFound); }; - state.refresh_in_progress = true; + self.refresh_in_progress.store(true, Ordering::Release); + let mut guard = CancelGuard { + in_progress: &self.refresh_in_progress, + notify: &self.refresh_notify, + defused: false, + }; match self.refresher.refresh(&credential).await { Ok(new_token) => { + guard.defuse(); self.refresher.save(&new_token); let service_token = ServiceToken::new(new_token.access_token().clone()); state.token = Some(new_token); - state.refresh_in_progress = false; + self.refresh_in_progress.store(false, Ordering::Release); Ok(service_token) } Err(err) => { - state.refresh_in_progress = false; + guard.defuse(); + self.refresh_in_progress.store(false, Ordering::Release); Err(AutoRefreshError::Auth(err)) } } @@ -177,6 +213,10 @@ impl AutoRefresh { /// /// Takes `MutexGuard` by value because the lock is dropped before the HTTP /// request. Notifies waiters after the refresh completes (success or error). + /// + /// A [`CancelGuard`] ensures that if this future is cancelled during the + /// HTTP request, `refresh_in_progress` is cleared, the credential is + /// restored (best-effort via `try_lock`), and waiters are notified. async fn refresh_non_blocking( &self, state: MutexGuard<'_, State>, @@ -185,20 +225,28 @@ impl AutoRefresh { let current_service_token = state.service_token()?; drop(state); + let mut guard = CancelGuard { + in_progress: &self.refresh_in_progress, + notify: &self.refresh_notify, + defused: false, + }; + match self.refresher.refresh(&credential).await { Ok(new_token) => { + guard.defuse(); self.refresher.save(&new_token); let mut state = self.state.lock().await; state.token = Some(new_token); - state.refresh_in_progress = false; + self.refresh_in_progress.store(false, Ordering::Release); } Err(err) => { + guard.defuse(); tracing::warn!(%err, "token refresh failed (token still usable)"); let mut state = self.state.lock().await; if let Some(token) = state.token.as_mut() { self.refresher.restore(token, credential); } - state.refresh_in_progress = false; + self.refresh_in_progress.store(false, Ordering::Release); } } @@ -209,27 +257,37 @@ impl AutoRefresh { /// Token is fully expired — refresh while holding the lock so concurrent /// callers block on `lock().await` until the new token is available. /// - /// Does NOT call `notify_waiters()` — no caller can register a `Notified` - /// while the lock is held, so there is nobody to notify. + /// A [`CancelGuard`] ensures that if this future is cancelled during the + /// HTTP request, `refresh_in_progress` is cleared and waiters are notified + /// so they don't hang indefinitely. (The credential is lost on cancel — + /// see [`CancelGuard`] docs — but subsequent callers will get `Expired` + /// rather than blocking forever.) async fn refresh_blocking( &self, state: &mut State, credential: R::Credential, ) -> Result { + let mut guard = CancelGuard { + in_progress: &self.refresh_in_progress, + notify: &self.refresh_notify, + defused: false, + }; match self.refresher.refresh(&credential).await { Ok(new_token) => { + guard.defuse(); self.refresher.save(&new_token); let service_token = ServiceToken::new(new_token.access_token().clone()); state.token = Some(new_token); - state.refresh_in_progress = false; + self.refresh_in_progress.store(false, Ordering::Release); Ok(service_token) } Err(err) => { + guard.defuse(); tracing::warn!(%err, "token refresh failed"); if let Some(token) = state.token.as_mut() { self.refresher.restore(token, credential); } - state.refresh_in_progress = false; + self.refresh_in_progress.store(false, Ordering::Release); Err(AutoRefreshError::Expired) } } diff --git a/packages/stack-auth/src/lib.rs b/packages/stack-auth/src/lib.rs index 211f1d426..2857077aa 100644 --- a/packages/stack-auth/src/lib.rs +++ b/packages/stack-auth/src/lib.rs @@ -170,7 +170,8 @@ pub use stack_profile::DeviceIdentity; /// CheckRefresh -- "Yes (needs refresh)" --> InProgress{Refresh in progress?} /// InProgress -- Yes --> WaitOrReturn["Return token if usable, /// else wait for refresh"] -/// WaitOrReturn --> ReturnOk +/// WaitOrReturn -- OK --> ReturnOk +/// WaitOrReturn -- "refresh failed" --> ErrExpired["TokenExpired"] /// /// InProgress -- No --> HasCred{Refresh credential?} /// HasCred -- None --> CheckUsable["Return token if usable, From 3ac47254c2add8f62212470b8ad7af950228e4a5 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Fri, 20 Mar 2026 15:44:05 +1100 Subject: [PATCH 103/686] =?UTF-8?q?test:=20=E2=9C=85=20add=20cancellation?= =?UTF-8?q?=20safety=20tests=20for=20CancelGuard?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Verify that aborting a `get_token()` future mid-refresh does not leave subsequent callers hanging in `wait_for_in_flight_refresh`. Two scenarios: blocking refresh (fully expired token) and non-blocking refresh (expiring-but-usable token). --- packages/stack-auth/src/auto_refresh.rs | 89 +++++++++++++++++++++++++ 1 file changed, 89 insertions(+) diff --git a/packages/stack-auth/src/auto_refresh.rs b/packages/stack-auth/src/auto_refresh.rs index 6579b44c2..0d725c3a6 100644 --- a/packages/stack-auth/src/auto_refresh.rs +++ b/packages/stack-auth/src/auto_refresh.rs @@ -1349,4 +1349,93 @@ mod stress_tests { assert_eq!(stats2.total(), 1, "only one retry refresh should be made"); } } + + mod given_cancelled_refresh { + use super::*; + + /// If a blocking refresh (fully expired token) is cancelled mid-flight, + /// the `CancelGuard` must reset `refresh_in_progress` and notify waiters + /// so the next caller doesn't hang in `wait_for_in_flight_refresh`. + #[tokio::test(flavor = "multi_thread", worker_threads = 4)] + async fn blocked_callers_recover_after_cancellation() { + let counting = CountingState::new(); + let state = DelayedRefreshState { + counting: counting.clone(), + delay: Duration::from_secs(10), // Very slow — will be cancelled + }; + let (base_url, _) = start_axum_server(delayed_refresh_handler, state).await; + let dir = tempfile::tempdir().unwrap(); + let strategy = Arc::new(auto_refresh_with_token( + &dir, + &base_url, + make_token("expired-token", 0, true), + )); + + // Spawn get_token and let the blocking refresh start. + let s = Arc::clone(&strategy); + let handle = tokio::spawn(async move { s.get_token().await }); + tokio::time::sleep(Duration::from_millis(100)).await; + + // Cancel the refresh mid-flight. + handle.abort(); + let _ = handle.await; + + // The next caller must not hang. The credential is lost (refresh + // token was taken before the HTTP call), so the result is Expired, + // but the important thing is that it completes promptly. + let s = Arc::clone(&strategy); + let result = tokio::time::timeout(Duration::from_secs(2), s.get_token()).await; + + assert!( + result.is_ok(), + "get_token() should not hang after cancelled blocking refresh" + ); + } + + /// If a non-blocking refresh (expiring-but-usable token) is cancelled + /// mid-flight, the `CancelGuard` must reset `refresh_in_progress` and + /// notify waiters so they don't hang once the token crosses real expiry. + #[tokio::test(flavor = "multi_thread", worker_threads = 4)] + async fn non_blocking_callers_recover_after_cancellation() { + let counting = CountingState::new(); + let state = DelayedRefreshState { + counting: counting.clone(), + delay: Duration::from_secs(10), // Very slow — will be cancelled + }; + let (base_url, _) = start_axum_server(delayed_refresh_handler, state).await; + let dir = tempfile::tempdir().unwrap(); + // Token expires in 30s — is_expired() = true, is_usable() = true. + let strategy = Arc::new(auto_refresh_with_token( + &dir, + &base_url, + make_token("still-usable", 30, true), + )); + + // Spawn get_token — triggers non-blocking refresh, drops lock, then + // blocks on the slow HTTP call. + let s = Arc::clone(&strategy); + let handle = tokio::spawn(async move { s.get_token().await }); + tokio::time::sleep(Duration::from_millis(100)).await; + + // Cancel the refresh mid-flight. + handle.abort(); + let _ = handle.await; + + // The next caller must not hang. The token is still usable so it + // should be returned even though the refresh was cancelled. + let s = Arc::clone(&strategy); + let result = tokio::time::timeout(Duration::from_secs(2), s.get_token()).await; + + assert!( + result.is_ok(), + "get_token() should not hang after cancelled non-blocking refresh" + ); + let result = result.unwrap(); + assert!( + result.is_ok(), + "expected Ok with still-usable token, got: {:?}", + result.unwrap_err() + ); + } + } } From 21dcba0d7734dd938928a8501ee762218d4a76eb Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 20 Mar 2026 05:29:30 +0000 Subject: [PATCH 104/686] chore: release --- packages/stack-auth/CHANGELOG.md | 13 +++++++++++++ packages/stack-profile/CHANGELOG.md | 1 + packages/stack-profile/Cargo.toml | 2 +- 3 files changed, 15 insertions(+), 1 deletion(-) diff --git a/packages/stack-auth/CHANGELOG.md b/packages/stack-auth/CHANGELOG.md index 7dcaac51c..e70ca3954 100644 --- a/packages/stack-auth/CHANGELOG.md +++ b/packages/stack-auth/CHANGELOG.md @@ -1,5 +1,18 @@ +### Documentation + +- 📝 move token refresh docs and mermaid diagram to public AuthStrategy trait + +### Fixes + +- 🐛 fix race condition in get_token() when token expires during refresh + +### Testing + +- ✅ restructure auto_refresh tests into nested scenario modules + + ### Documentation - 📝 fix AutoStrategy docs to reference CS_WORKSPACE_CRN not CS_REGION diff --git a/packages/stack-profile/CHANGELOG.md b/packages/stack-profile/CHANGELOG.md index c95c22755..15405a40d 100644 --- a/packages/stack-profile/CHANGELOG.md +++ b/packages/stack-profile/CHANGELOG.md @@ -6,6 +6,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + ## [0.34.0-alpha.1] - 2026-03-04 ### Changed diff --git a/packages/stack-profile/Cargo.toml b/packages/stack-profile/Cargo.toml index cc9122320..df48ab863 100644 --- a/packages/stack-profile/Cargo.toml +++ b/packages/stack-profile/Cargo.toml @@ -2,7 +2,7 @@ name = "stack-profile" description = "Centralised ~/.cipherstash profile file management" license-file = "LICENSE" -version = "0.34.0-alpha.2" +version = "0.34.0-alpha.3" edition.workspace = true authors.workspace = true repository.workspace = true From 6fb4bf99970767a9fdf6f049a9cde6ca372c7f04 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Sun, 22 Mar 2026 15:36:19 +1100 Subject: [PATCH 105/686] docs: add README for stack-auth and include it as module docs Move inline module docs from lib.rs into README.md and use include_str! so the crate docs and README stay in sync. --- packages/stack-auth/README.md | 64 ++++++++++++++++++++++++++++++++++ packages/stack-auth/src/lib.rs | 58 ++---------------------------- 2 files changed, 66 insertions(+), 56 deletions(-) create mode 100644 packages/stack-auth/README.md diff --git a/packages/stack-auth/README.md b/packages/stack-auth/README.md new file mode 100644 index 000000000..d03c569df --- /dev/null +++ b/packages/stack-auth/README.md @@ -0,0 +1,64 @@ +# stack-auth + +[![Crates.io Version](https://img.shields.io/crates/v/stack-auth?style=for-the-badge)](https://crates.io/crates/stack-auth) +[![docs.rs](https://img.shields.io/docsrs/stack-auth?style=for-the-badge)](https://docs.rs/stack-auth/) +[![Built by CipherStash](https://raw.githubusercontent.com/cipherstash/meta/refs/heads/main/csbadge.svg)](https://cipherstash.com) + + [Website](https://cipherstash.com) | [Docs](https://cipherstash.com/docs) | [Discord](https://discord.com/invite/5qwXUFb6PB) + +Authentication strategies for [CipherStash](https://cipherstash.com) services. + +All strategies implement the [`AuthStrategy`] trait, which provides a single +[`get_token`](AuthStrategy::get_token) method that returns a valid +[`ServiceToken`]. Token caching and refresh are handled automatically. + +## Strategies + +| Strategy | Use case | Credentials | +|---|---|---| +| [`AutoStrategy`] | Recommended default — detects credentials automatically | `CS_CLIENT_ACCESS_KEY` + `CS_WORKSPACE_CRN`, or `~/.cipherstash/auth.json` | +| [`AccessKeyStrategy`] | Service-to-service / CI | Static access key + region | +| [`OAuthStrategy`] | Long-lived sessions with refresh | OAuth token (from device code flow or disk) | +| [`DeviceCodeStrategy`] | CLI login ([RFC 8628](https://datatracker.ietf.org/doc/html/rfc8628)) | User authorizes in browser | +| `StaticTokenStrategy` | Tests only (`test-utils` feature) | Pre-obtained token used as-is | + +## Quick start + +For most applications, [`AutoStrategy`] is the simplest way to get started: + +```no_run +use stack_auth::AutoStrategy; + +# async fn run() -> Result<(), Box> { +let strategy = AutoStrategy::detect()?; +// That's it — get_token() handles the rest. +# Ok(()) +# } +``` + +For service-to-service authentication with an access key: + +```no_run +use stack_auth::AccessKeyStrategy; +use cts_common::Region; + +# fn run() -> Result<(), Box> { +let region = Region::aws("ap-southeast-2")?; +let key = "CSAKkeyId.keySecret".parse()?; +let strategy = AccessKeyStrategy::new(region, key)?; +# Ok(()) +# } +``` + +## Security + +Sensitive values ([`SecretToken`]) are automatically zeroized when dropped +and are masked in [`Debug`](std::fmt::Debug) output to prevent accidental +leaks in logs. + +## Token refresh + +All strategies that cache tokens ([`AccessKeyStrategy`], [`OAuthStrategy`], +[`AutoStrategy`]) share the same internal refresh engine. See the +[`AuthStrategy`] trait docs for a full description of the concurrency model +and flow diagram. diff --git a/packages/stack-auth/src/lib.rs b/packages/stack-auth/src/lib.rs index 2857077aa..2f4efae99 100644 --- a/packages/stack-auth/src/lib.rs +++ b/packages/stack-auth/src/lib.rs @@ -1,59 +1,5 @@ -//! Authentication strategies for [CipherStash](https://cipherstash.com) services. -//! -//! All strategies implement the [`AuthStrategy`] trait, which provides a single -//! [`get_token`](AuthStrategy::get_token) method that returns a valid -//! [`ServiceToken`]. Token caching and refresh are handled automatically. -//! -//! # Strategies -//! -//! | Strategy | Use case | Credentials | -//! |---|---|---| -//! | [`AutoStrategy`] | Recommended default — detects credentials automatically | `CS_CLIENT_ACCESS_KEY` + `CS_WORKSPACE_CRN`, or `~/.cipherstash/auth.json` | -//! | [`AccessKeyStrategy`] | Service-to-service / CI | Static access key + region | -//! | [`OAuthStrategy`] | Long-lived sessions with refresh | OAuth token (from device code flow or disk) | -//! | [`DeviceCodeStrategy`] | CLI login ([RFC 8628](https://datatracker.ietf.org/doc/html/rfc8628)) | User authorizes in browser | -//! | `StaticTokenStrategy` | Tests only (`test-utils` feature) | Pre-obtained token used as-is | -//! -//! # Quick start -//! -//! For most applications, [`AutoStrategy`] is the simplest way to get started: -//! -//! ```no_run -//! use stack_auth::AutoStrategy; -//! -//! # async fn run() -> Result<(), Box> { -//! let strategy = AutoStrategy::detect()?; -//! // That's it — get_token() handles the rest. -//! # Ok(()) -//! # } -//! ``` -//! -//! For service-to-service authentication with an access key: -//! -//! ```no_run -//! use stack_auth::AccessKeyStrategy; -//! use cts_common::Region; -//! -//! # fn run() -> Result<(), Box> { -//! let region = Region::aws("ap-southeast-2")?; -//! let key = "CSAKkeyId.keySecret".parse()?; -//! let strategy = AccessKeyStrategy::new(region, key)?; -//! # Ok(()) -//! # } -//! ``` -//! -//! # Security -//! -//! Sensitive values ([`SecretToken`]) are automatically zeroized when dropped -//! and are masked in [`Debug`](std::fmt::Debug) output to prevent accidental -//! leaks in logs. -//! -//! # Token refresh -//! -//! All strategies that cache tokens ([`AccessKeyStrategy`], [`OAuthStrategy`], -//! [`AutoStrategy`]) share the same internal refresh engine. See the -//! [`AuthStrategy`] trait docs for a full description of the concurrency model -//! and flow diagram. +#![doc(html_favicon_url = "https://cipherstash.com/favicon.ico")] +#![doc = include_str!("../README.md")] // Security lints #![deny(unsafe_code)] From dbc0a6497ba3c83245569981f14aa173e5967253 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Sun, 22 Mar 2026 16:50:42 +1100 Subject: [PATCH 106/686] docs: add README for @cipherstash/auth npm package --- languages/typescript/packages/auth/README.md | 81 ++++++++++++++++++++ 1 file changed, 81 insertions(+) create mode 100644 languages/typescript/packages/auth/README.md diff --git a/languages/typescript/packages/auth/README.md b/languages/typescript/packages/auth/README.md new file mode 100644 index 000000000..961ef609a --- /dev/null +++ b/languages/typescript/packages/auth/README.md @@ -0,0 +1,81 @@ +# @cipherstash/auth + +[![npm version](https://img.shields.io/npm/v/@cipherstash/auth?style=for-the-badge)](https://www.npmjs.com/package/@cipherstash/auth) +[![Built by CipherStash](https://raw.githubusercontent.com/cipherstash/meta/refs/heads/main/csbadge.svg)](https://cipherstash.com) + + [Website](https://cipherstash.com) | [Docs](https://cipherstash.com/docs) | [Discord](https://discord.com/invite/5qwXUFb6PB) + +Native Node.js bindings for authenticating with [CipherStash](https://cipherstash.com) services using the [OAuth 2.0 Device Authorization](https://datatracker.ietf.org/doc/html/rfc8628) flow. + +## Installation + +```bash +npm install @cipherstash/auth +``` + +Prebuilt native binaries are included for: + +- macOS (x64, ARM64) +- Linux (x64 glibc, x64 musl, ARM64 glibc) +- Windows (x64) + +## Usage + +```js +const { beginDeviceCodeFlow } = require("@cipherstash/auth"); + +const result = await beginDeviceCodeFlow(region, clientId); + +// Show the user the code and URL +console.log(`Go to ${result.verificationUri} and enter code: ${result.userCode}`); + +// Or open the browser automatically +result.openInBrowser(); + +// Wait for the user to authorize +const auth = await result.pollForToken(); +console.log(`Token expires in ${auth.expiresIn} seconds`); +``` + +The token is saved to `~/.cipherstash/auth.json` automatically and is never exposed to JavaScript. + +## API + +### `beginDeviceCodeFlow(region, clientId)` + +Starts the OAuth 2.0 Device Authorization flow. Returns a `Promise`. + +### `DeviceCodeResult` + +| Property / Method | Description | +|---|---| +| `userCode` | The code the user enters at the verification URI | +| `verificationUri` | The URL the user visits to authorize | +| `verificationUriComplete` | URL with the code pre-filled | +| `expiresIn` | Seconds until the device code expires | +| `openInBrowser()` | Opens the verification URI in the default browser | +| `pollForToken()` | Polls until the user completes authorization. Returns `Promise` | + +### `AuthResult` + +| Property | Description | +|---|---| +| `expiresAt` | Absolute epoch timestamp (seconds) when the token expires | +| `expiresIn` | Seconds until the token expires | + +## Error handling + +Errors thrown by the native module include a machine-readable `.code` property: + +```js +try { + await result.pollForToken(); +} catch (err) { + console.error(err.code); // e.g. "EXPIRED_TOKEN" + console.error(err.message); // Human-readable description +} +``` + +## License + +See [LICENSE](https://github.com/cipherstash/cipherstash-suite/blob/main/packages/stack-auth/LICENSE). From 9e26ea31fcf61c73c8480851b59c40b86997bec1 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Sun, 22 Mar 2026 16:58:24 +1100 Subject: [PATCH 107/686] fix: remove blank line to satisfy cargo fmt --- packages/stack-auth/src/lib.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/stack-auth/src/lib.rs b/packages/stack-auth/src/lib.rs index 2f4efae99..ae9bf22e5 100644 --- a/packages/stack-auth/src/lib.rs +++ b/packages/stack-auth/src/lib.rs @@ -1,6 +1,5 @@ #![doc(html_favicon_url = "https://cipherstash.com/favicon.ico")] #![doc = include_str!("../README.md")] - // Security lints #![deny(unsafe_code)] #![warn(clippy::unwrap_used)] From e4593c6632c551be65cc3cbb4a187452a8ce2a53 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Sun, 22 Mar 2026 15:13:40 +1100 Subject: [PATCH 108/686] feat: add cross-platform npm publishing for @cipherstash/auth Set up NAPI-RS cross-platform build and publish workflow so that @cipherstash/auth is automatically published to npm when release-plz tags a new stack-auth version. Supports darwin (x64/arm64), linux (x64 glibc/musl, arm64 glibc), and windows (x64 msvc). --- .../imported-workflows/publish-auth-npm.yml | 180 ++++++++++++++++++ languages/typescript/packages/auth/.gitignore | 2 + languages/typescript/packages/auth/index.js | 7 +- .../typescript/packages/auth/package.json | 26 ++- .../auth/platforms/darwin-arm64/README.md | 3 + .../auth/platforms/darwin-arm64/package.json | 14 ++ .../auth/platforms/darwin-x64/README.md | 3 + .../auth/platforms/darwin-x64/package.json | 14 ++ .../auth/platforms/linux-arm64-gnu/README.md | 3 + .../platforms/linux-arm64-gnu/package.json | 17 ++ .../auth/platforms/linux-x64-gnu/README.md | 3 + .../auth/platforms/linux-x64-gnu/package.json | 17 ++ .../auth/platforms/linux-x64-musl/README.md | 3 + .../platforms/linux-x64-musl/package.json | 17 ++ .../auth/platforms/win32-x64-msvc/README.md | 3 + .../platforms/win32-x64-msvc/package.json | 14 ++ 16 files changed, 323 insertions(+), 3 deletions(-) create mode 100644 .github/imported-workflows/publish-auth-npm.yml create mode 100644 languages/typescript/packages/auth/platforms/darwin-arm64/README.md create mode 100644 languages/typescript/packages/auth/platforms/darwin-arm64/package.json create mode 100644 languages/typescript/packages/auth/platforms/darwin-x64/README.md create mode 100644 languages/typescript/packages/auth/platforms/darwin-x64/package.json create mode 100644 languages/typescript/packages/auth/platforms/linux-arm64-gnu/README.md create mode 100644 languages/typescript/packages/auth/platforms/linux-arm64-gnu/package.json create mode 100644 languages/typescript/packages/auth/platforms/linux-x64-gnu/README.md create mode 100644 languages/typescript/packages/auth/platforms/linux-x64-gnu/package.json create mode 100644 languages/typescript/packages/auth/platforms/linux-x64-musl/README.md create mode 100644 languages/typescript/packages/auth/platforms/linux-x64-musl/package.json create mode 100644 languages/typescript/packages/auth/platforms/win32-x64-msvc/README.md create mode 100644 languages/typescript/packages/auth/platforms/win32-x64-msvc/package.json diff --git a/.github/imported-workflows/publish-auth-npm.yml b/.github/imported-workflows/publish-auth-npm.yml new file mode 100644 index 000000000..7bb4be5be --- /dev/null +++ b/.github/imported-workflows/publish-auth-npm.yml @@ -0,0 +1,180 @@ +name: "Publish @cipherstash/auth to npm" + +on: + push: + tags: + - "stack-auth-v*" + workflow_dispatch: + inputs: + dry_run: + description: "Dry run (do not actually publish)" + type: boolean + default: true + +permissions: + contents: read + id-token: write # npm provenance + +defaults: + run: + shell: bash -l {0} + +env: + CARGO_TERM_COLOR: always + WORKING_DIR: packages/stack-auth/node + +concurrency: + group: publish-auth-npm + cancel-in-progress: false + +jobs: + build: + strategy: + fail-fast: true + matrix: + include: + - target: x86_64-apple-darwin + os: macos-13 + - target: aarch64-apple-darwin + os: macos-14 + - target: x86_64-unknown-linux-gnu + os: blacksmith-8vcpu-ubuntu-2404 + - target: aarch64-unknown-linux-gnu + os: blacksmith-8vcpu-ubuntu-2404-arm + - target: x86_64-unknown-linux-musl + os: blacksmith-8vcpu-ubuntu-2404 + - target: x86_64-pc-windows-msvc + os: windows-latest + + name: Build - ${{ matrix.target }} + runs-on: ${{ matrix.os }} + + steps: + - uses: actions/checkout@v4 + + - name: Setup Rust + uses: dtolnay/rust-toolchain@1.90.0 + with: + targets: ${{ matrix.target }} + + - name: Setup Rust cache + uses: Swatinem/rust-cache@v2 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + + - name: Install musl tools + if: matrix.target == 'x86_64-unknown-linux-musl' + run: sudo apt-get update && sudo apt-get install -y musl-tools + + - name: Install dependencies + working-directory: ${{ env.WORKING_DIR }} + run: npm ci + + - name: Build native module + working-directory: ${{ env.WORKING_DIR }} + run: npx napi build --platform --release --target ${{ matrix.target }} --strip + + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: bindings-${{ matrix.target }} + path: ${{ env.WORKING_DIR }}/*.node + if-no-files-found: error + + publish: + name: Publish + needs: build + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + registry-url: https://registry.npmjs.org + + - name: Install dependencies + working-directory: ${{ env.WORKING_DIR }} + run: npm ci + + - name: Download all artifacts + uses: actions/download-artifact@v4 + with: + path: ${{ env.WORKING_DIR }}/artifacts + + - name: Move artifacts to platform packages + working-directory: ${{ env.WORKING_DIR }} + run: npx napi artifacts --dir artifacts + + - name: Determine version and npm tag + id: version + run: | + if [[ "$GITHUB_REF" == refs/tags/stack-auth-v* ]]; then + VERSION="${GITHUB_REF#refs/tags/stack-auth-v}" + else + VERSION=$(jq -r .version "${{ env.WORKING_DIR }}/package.json") + fi + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + + if [[ "$VERSION" == *"-"* ]]; then + echo "npm_tag=next" >> "$GITHUB_OUTPUT" + else + echo "npm_tag=latest" >> "$GITHUB_OUTPUT" + fi + + echo "Publishing version: $VERSION (tag: $(if [[ "$VERSION" == *"-"* ]]; then echo next; else echo latest; fi))" + + - name: Set package versions + working-directory: ${{ env.WORKING_DIR }} + run: | + VERSION="${{ steps.version.outputs.version }}" + npm version "$VERSION" --no-git-tag-version --allow-same-version + for dir in npm/*/; do + if [ -f "$dir/package.json" ]; then + cd "$dir" + npm version "$VERSION" --no-git-tag-version --allow-same-version + cd - + fi + done + + - name: List packages + working-directory: ${{ env.WORKING_DIR }} + run: | + echo "=== Main package ===" + cat package.json | jq '{name, version}' + echo "" + for dir in npm/*/; do + echo "=== $(basename $dir) ===" + cat "$dir/package.json" | jq '{name, version, os, cpu}' + ls -la "$dir"*.node 2>/dev/null || echo " (no .node file)" + echo "" + done + + - name: Publish platform packages + if: ${{ !inputs.dry_run }} + working-directory: ${{ env.WORKING_DIR }} + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + run: | + for dir in npm/*/; do + if [ -f "$dir/package.json" ]; then + echo "Publishing $(jq -r .name "$dir/package.json")..." + npm publish "$dir" --access public --provenance --tag ${{ steps.version.outputs.npm_tag }} + fi + done + + - name: Publish main package + if: ${{ !inputs.dry_run }} + working-directory: ${{ env.WORKING_DIR }} + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + run: npm publish --access public --provenance --tag ${{ steps.version.outputs.npm_tag }} + + - name: Dry run summary + if: ${{ inputs.dry_run }} + run: echo "Dry run complete. Set dry_run to false to actually publish." diff --git a/languages/typescript/packages/auth/.gitignore b/languages/typescript/packages/auth/.gitignore index 9c109da36..b197fc7d7 100644 --- a/languages/typescript/packages/auth/.gitignore +++ b/languages/typescript/packages/auth/.gitignore @@ -1,3 +1,5 @@ target/ node_modules/ *.node +npm/*/*.node +stack-auth-node.js diff --git a/languages/typescript/packages/auth/index.js b/languages/typescript/packages/auth/index.js index 5a47f9247..89f2e2745 100644 --- a/languages/typescript/packages/auth/index.js +++ b/languages/typescript/packages/auth/index.js @@ -2,7 +2,12 @@ // machine-readable `.code` property by parsing the "CODE: message" format // that the Rust side produces. -const native = require("./stack-auth-node.node"); +let native; +try { + native = require("./stack-auth-node.js"); +} catch { + native = require("./stack-auth-node.node"); +} const CODE_RE = /^([A-Z_]+): /; diff --git a/languages/typescript/packages/auth/package.json b/languages/typescript/packages/auth/package.json index 4b3067729..a99bb8609 100644 --- a/languages/typescript/packages/auth/package.json +++ b/languages/typescript/packages/auth/package.json @@ -1,18 +1,40 @@ { "name": "@cipherstash/auth", "version": "0.34.0", - "private": true, "main": "index.js", "types": "index.d.ts", "napi": { - "name": "stack-auth-node" + "name": "stack-auth-node", + "triples": { + "defaults": false, + "additional": [ + "x86_64-apple-darwin", + "aarch64-apple-darwin", + "x86_64-unknown-linux-gnu", + "aarch64-unknown-linux-gnu", + "x86_64-unknown-linux-musl", + "x86_64-pc-windows-msvc" + ] + } }, + "files": [ + "index.js", + "index.d.ts" + ], "scripts": { "build": "napi build --release", "build:debug": "napi build", "build:test": "napi build --features test-utils", "test": "npm run build:test && vitest run" }, + "optionalDependencies": { + "@cipherstash/auth-darwin-x64": "0.34.0", + "@cipherstash/auth-darwin-arm64": "0.34.0", + "@cipherstash/auth-linux-x64-gnu": "0.34.0", + "@cipherstash/auth-linux-arm64-gnu": "0.34.0", + "@cipherstash/auth-linux-x64-musl": "0.34.0", + "@cipherstash/auth-win32-x64-msvc": "0.34.0" + }, "devDependencies": { "@napi-rs/cli": "^2", "vitest": "^3", diff --git a/languages/typescript/packages/auth/platforms/darwin-arm64/README.md b/languages/typescript/packages/auth/platforms/darwin-arm64/README.md new file mode 100644 index 000000000..d119eb217 --- /dev/null +++ b/languages/typescript/packages/auth/platforms/darwin-arm64/README.md @@ -0,0 +1,3 @@ +# `@cipherstash/auth-darwin-arm64` + +This is the **aarch64-apple-darwin** binary for `@cipherstash/auth` diff --git a/languages/typescript/packages/auth/platforms/darwin-arm64/package.json b/languages/typescript/packages/auth/platforms/darwin-arm64/package.json new file mode 100644 index 000000000..797f329dd --- /dev/null +++ b/languages/typescript/packages/auth/platforms/darwin-arm64/package.json @@ -0,0 +1,14 @@ +{ + "name": "@cipherstash/auth-darwin-arm64", + "version": "0.34.0", + "os": [ + "darwin" + ], + "cpu": [ + "arm64" + ], + "main": "stack-auth-node.darwin-arm64.node", + "files": [ + "stack-auth-node.darwin-arm64.node" + ] +} \ No newline at end of file diff --git a/languages/typescript/packages/auth/platforms/darwin-x64/README.md b/languages/typescript/packages/auth/platforms/darwin-x64/README.md new file mode 100644 index 000000000..5303e9401 --- /dev/null +++ b/languages/typescript/packages/auth/platforms/darwin-x64/README.md @@ -0,0 +1,3 @@ +# `@cipherstash/auth-darwin-x64` + +This is the **x86_64-apple-darwin** binary for `@cipherstash/auth` diff --git a/languages/typescript/packages/auth/platforms/darwin-x64/package.json b/languages/typescript/packages/auth/platforms/darwin-x64/package.json new file mode 100644 index 000000000..00709709f --- /dev/null +++ b/languages/typescript/packages/auth/platforms/darwin-x64/package.json @@ -0,0 +1,14 @@ +{ + "name": "@cipherstash/auth-darwin-x64", + "version": "0.34.0", + "os": [ + "darwin" + ], + "cpu": [ + "x64" + ], + "main": "stack-auth-node.darwin-x64.node", + "files": [ + "stack-auth-node.darwin-x64.node" + ] +} \ No newline at end of file diff --git a/languages/typescript/packages/auth/platforms/linux-arm64-gnu/README.md b/languages/typescript/packages/auth/platforms/linux-arm64-gnu/README.md new file mode 100644 index 000000000..47761b321 --- /dev/null +++ b/languages/typescript/packages/auth/platforms/linux-arm64-gnu/README.md @@ -0,0 +1,3 @@ +# `@cipherstash/auth-linux-arm64-gnu` + +This is the **aarch64-unknown-linux-gnu** binary for `@cipherstash/auth` diff --git a/languages/typescript/packages/auth/platforms/linux-arm64-gnu/package.json b/languages/typescript/packages/auth/platforms/linux-arm64-gnu/package.json new file mode 100644 index 000000000..566afd74c --- /dev/null +++ b/languages/typescript/packages/auth/platforms/linux-arm64-gnu/package.json @@ -0,0 +1,17 @@ +{ + "name": "@cipherstash/auth-linux-arm64-gnu", + "version": "0.34.0", + "os": [ + "linux" + ], + "cpu": [ + "arm64" + ], + "main": "stack-auth-node.linux-arm64-gnu.node", + "files": [ + "stack-auth-node.linux-arm64-gnu.node" + ], + "libc": [ + "glibc" + ] +} \ No newline at end of file diff --git a/languages/typescript/packages/auth/platforms/linux-x64-gnu/README.md b/languages/typescript/packages/auth/platforms/linux-x64-gnu/README.md new file mode 100644 index 000000000..aeb8b3510 --- /dev/null +++ b/languages/typescript/packages/auth/platforms/linux-x64-gnu/README.md @@ -0,0 +1,3 @@ +# `@cipherstash/auth-linux-x64-gnu` + +This is the **x86_64-unknown-linux-gnu** binary for `@cipherstash/auth` diff --git a/languages/typescript/packages/auth/platforms/linux-x64-gnu/package.json b/languages/typescript/packages/auth/platforms/linux-x64-gnu/package.json new file mode 100644 index 000000000..3f59c6ff0 --- /dev/null +++ b/languages/typescript/packages/auth/platforms/linux-x64-gnu/package.json @@ -0,0 +1,17 @@ +{ + "name": "@cipherstash/auth-linux-x64-gnu", + "version": "0.34.0", + "os": [ + "linux" + ], + "cpu": [ + "x64" + ], + "main": "stack-auth-node.linux-x64-gnu.node", + "files": [ + "stack-auth-node.linux-x64-gnu.node" + ], + "libc": [ + "glibc" + ] +} \ No newline at end of file diff --git a/languages/typescript/packages/auth/platforms/linux-x64-musl/README.md b/languages/typescript/packages/auth/platforms/linux-x64-musl/README.md new file mode 100644 index 000000000..81bfb12c9 --- /dev/null +++ b/languages/typescript/packages/auth/platforms/linux-x64-musl/README.md @@ -0,0 +1,3 @@ +# `@cipherstash/auth-linux-x64-musl` + +This is the **x86_64-unknown-linux-musl** binary for `@cipherstash/auth` diff --git a/languages/typescript/packages/auth/platforms/linux-x64-musl/package.json b/languages/typescript/packages/auth/platforms/linux-x64-musl/package.json new file mode 100644 index 000000000..6efd29241 --- /dev/null +++ b/languages/typescript/packages/auth/platforms/linux-x64-musl/package.json @@ -0,0 +1,17 @@ +{ + "name": "@cipherstash/auth-linux-x64-musl", + "version": "0.34.0", + "os": [ + "linux" + ], + "cpu": [ + "x64" + ], + "main": "stack-auth-node.linux-x64-musl.node", + "files": [ + "stack-auth-node.linux-x64-musl.node" + ], + "libc": [ + "musl" + ] +} \ No newline at end of file diff --git a/languages/typescript/packages/auth/platforms/win32-x64-msvc/README.md b/languages/typescript/packages/auth/platforms/win32-x64-msvc/README.md new file mode 100644 index 000000000..05ec0899d --- /dev/null +++ b/languages/typescript/packages/auth/platforms/win32-x64-msvc/README.md @@ -0,0 +1,3 @@ +# `@cipherstash/auth-win32-x64-msvc` + +This is the **x86_64-pc-windows-msvc** binary for `@cipherstash/auth` diff --git a/languages/typescript/packages/auth/platforms/win32-x64-msvc/package.json b/languages/typescript/packages/auth/platforms/win32-x64-msvc/package.json new file mode 100644 index 000000000..320794278 --- /dev/null +++ b/languages/typescript/packages/auth/platforms/win32-x64-msvc/package.json @@ -0,0 +1,14 @@ +{ + "name": "@cipherstash/auth-win32-x64-msvc", + "version": "0.34.0", + "os": [ + "win32" + ], + "cpu": [ + "x64" + ], + "main": "stack-auth-node.win32-x64-msvc.node", + "files": [ + "stack-auth-node.win32-x64-msvc.node" + ] +} \ No newline at end of file From 0a5d7d13eb59f0b46cca2fd5139e1d0a269bc8eb Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Sun, 22 Mar 2026 17:11:25 +1100 Subject: [PATCH 109/686] chore: include README.md in npm package files --- languages/typescript/packages/auth/package.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/languages/typescript/packages/auth/package.json b/languages/typescript/packages/auth/package.json index a99bb8609..f38dd20a0 100644 --- a/languages/typescript/packages/auth/package.json +++ b/languages/typescript/packages/auth/package.json @@ -19,7 +19,8 @@ }, "files": [ "index.js", - "index.d.ts" + "index.d.ts", + "README.md" ], "scripts": { "build": "napi build --release", From 7086aa5d50d251fcf21d5eba9732badd3a904590 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Sun, 22 Mar 2026 17:17:11 +1100 Subject: [PATCH 110/686] chore: clean up device-code example to only show production usage --- .../packages/auth/examples/device-code.ts | 13 ++----------- 1 file changed, 2 insertions(+), 11 deletions(-) diff --git a/languages/typescript/packages/auth/examples/device-code.ts b/languages/typescript/packages/auth/examples/device-code.ts index 98285b20d..1d820859e 100644 --- a/languages/typescript/packages/auth/examples/device-code.ts +++ b/languages/typescript/packages/auth/examples/device-code.ts @@ -4,8 +4,7 @@ // exposed to JavaScript. // // Prerequisites: -// 1. Build the native module with test-utils: npm run build:test -// 2. Have CTS running locally: mise run docker:up +// 1. Build the native module: npm run build // // Usage: // npx tsx examples/device-code.ts @@ -13,15 +12,7 @@ import { beginDeviceCodeFlow } from "../index"; async function main() { - // Step 1: Begin the device code flow against a local CTS instance - /*const pending = await beginDeviceCodeFlowWithBaseUrl( - "ap-southeast-2.aws", - "cli", - "http://localhost:3001", - );*/ - - // To connect to production instead: - // import { beginDeviceCodeFlow } from "../index"; + // Step 1: Begin the device code flow const pending = await beginDeviceCodeFlow("ap-southeast-2.aws", "cli"); // Step 2: Show the user their code and verification URL From 2f759094203e3116ac61ef96aeb707b81106f2fd Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Sun, 22 Mar 2026 17:21:58 +1100 Subject: [PATCH 111/686] fix: address PR review feedback - Only catch MODULE_NOT_FOUND in loader fallback, rethrow other errors - Include stack-auth-node.js (generated NAPI-RS loader) in npm files - Update optionalDependencies versions at publish time to match tag --- .github/imported-workflows/publish-auth-npm.yml | 9 +++++++++ languages/typescript/packages/auth/index.js | 8 ++++++-- languages/typescript/packages/auth/package.json | 3 ++- 3 files changed, 17 insertions(+), 3 deletions(-) diff --git a/.github/imported-workflows/publish-auth-npm.yml b/.github/imported-workflows/publish-auth-npm.yml index 7bb4be5be..a5bc6786d 100644 --- a/.github/imported-workflows/publish-auth-npm.yml +++ b/.github/imported-workflows/publish-auth-npm.yml @@ -134,6 +134,15 @@ jobs: run: | VERSION="${{ steps.version.outputs.version }}" npm version "$VERSION" --no-git-tag-version --allow-same-version + # Update optionalDependencies to match the publish version + jq --arg v "$VERSION" ' + .optionalDependencies |= with_entries( + if (.key | startswith("@cipherstash/auth-")) + then .value = $v + else . + end + ) + ' package.json > package.json.tmp && mv package.json.tmp package.json for dir in npm/*/; do if [ -f "$dir/package.json" ]; then cd "$dir" diff --git a/languages/typescript/packages/auth/index.js b/languages/typescript/packages/auth/index.js index 89f2e2745..91fb486e9 100644 --- a/languages/typescript/packages/auth/index.js +++ b/languages/typescript/packages/auth/index.js @@ -5,8 +5,12 @@ let native; try { native = require("./stack-auth-node.js"); -} catch { - native = require("./stack-auth-node.node"); +} catch (err) { + if (err && err.code === "MODULE_NOT_FOUND") { + native = require("./stack-auth-node.node"); + } else { + throw err; + } } const CODE_RE = /^([A-Z_]+): /; diff --git a/languages/typescript/packages/auth/package.json b/languages/typescript/packages/auth/package.json index f38dd20a0..3e3888f04 100644 --- a/languages/typescript/packages/auth/package.json +++ b/languages/typescript/packages/auth/package.json @@ -20,7 +20,8 @@ "files": [ "index.js", "index.d.ts", - "README.md" + "README.md", + "stack-auth-node.js" ], "scripts": { "build": "napi build --release", From df9017e7c71562509c82ed3df37b292271bf569d Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Sun, 22 Mar 2026 20:01:11 +1100 Subject: [PATCH 112/686] feat: use npm trusted publishing (OIDC) instead of NPM_TOKEN npm supports trusted publishing via OIDC since July 2025, eliminating the need for a long-lived NPM_TOKEN secret. Provenance attestations are automatic with trusted publishing. Requires one-time setup: configure this repo/workflow as a trusted publisher on npmjs.com for each @cipherstash/auth-* package. --- .github/imported-workflows/publish-auth-npm.yml | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/.github/imported-workflows/publish-auth-npm.yml b/.github/imported-workflows/publish-auth-npm.yml index a5bc6786d..144baa906 100644 --- a/.github/imported-workflows/publish-auth-npm.yml +++ b/.github/imported-workflows/publish-auth-npm.yml @@ -13,7 +13,7 @@ on: permissions: contents: read - id-token: write # npm provenance + id-token: write # npm trusted publishing (OIDC) defaults: run: @@ -167,22 +167,18 @@ jobs: - name: Publish platform packages if: ${{ !inputs.dry_run }} working-directory: ${{ env.WORKING_DIR }} - env: - NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} run: | for dir in npm/*/; do if [ -f "$dir/package.json" ]; then echo "Publishing $(jq -r .name "$dir/package.json")..." - npm publish "$dir" --access public --provenance --tag ${{ steps.version.outputs.npm_tag }} + npm publish "$dir" --access public --tag ${{ steps.version.outputs.npm_tag }} fi done - name: Publish main package if: ${{ !inputs.dry_run }} working-directory: ${{ env.WORKING_DIR }} - env: - NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} - run: npm publish --access public --provenance --tag ${{ steps.version.outputs.npm_tag }} + run: npm publish --access public --tag ${{ steps.version.outputs.npm_tag }} - name: Dry run summary if: ${{ inputs.dry_run }} From 3342fd0cd01b298b5e461f77292e7569693f40f8 Mon Sep 17 00:00:00 2001 From: Toby Hede Date: Tue, 24 Mar 2026 11:45:14 +1100 Subject: [PATCH 113/686] fix: update vitaminc imports for 0.1.0-pre4.2 module restructure vitaminc 0.1.0-pre4.2 moved types from root into submodules: - random::{Generatable, SafeRand, RandomError, SeedableRng} - encrypt::{Aad, IntoAad, Encrypt, Decrypt, Cipher, Key, Aes256Cipher, ...} - encrypt::{encrypt, decrypt, encrypt_with_aad, decrypt_with_aad} - protected::{OpaqueDebug, TimingSafeEq} (unchanged) Added vitaminc-protected as direct dependency for cts-domain, stack-auth, and cipherstash-client since derive macros reference the crate directly. --- packages/stack-auth/Cargo.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/stack-auth/Cargo.toml b/packages/stack-auth/Cargo.toml index 1c2fc000e..76a93fe10 100644 --- a/packages/stack-auth/Cargo.toml +++ b/packages/stack-auth/Cargo.toml @@ -24,6 +24,7 @@ tracing = { workspace = true } url = { workspace = true } uuid = { workspace = true } vitaminc = { workspace = true, features = ["protected"] } +vitaminc-protected = { workspace = true } zeroize = { workspace = true } [features] From 76e3c11cb13230ab8fe3171dbefff8c4bf82247d Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 24 Mar 2026 03:02:54 +0000 Subject: [PATCH 114/686] chore: release --- packages/stack-auth/CHANGELOG.md | 11 +++++++++++ packages/stack-profile/CHANGELOG.md | 1 + packages/stack-profile/Cargo.toml | 2 +- 3 files changed, 13 insertions(+), 1 deletion(-) diff --git a/packages/stack-auth/CHANGELOG.md b/packages/stack-auth/CHANGELOG.md index e70ca3954..7f59cbeca 100644 --- a/packages/stack-auth/CHANGELOG.md +++ b/packages/stack-auth/CHANGELOG.md @@ -1,5 +1,16 @@ +### Documentation + +- add README for stack-auth and include it as module docs +- add README for @cipherstash/auth npm package + +### Fixes + +- remove blank line to satisfy cargo fmt +- update vitaminc imports for 0.1.0-pre4.2 module restructure + + ### Documentation - 📝 move token refresh docs and mermaid diagram to public AuthStrategy trait diff --git a/packages/stack-profile/CHANGELOG.md b/packages/stack-profile/CHANGELOG.md index 15405a40d..46c94b9a8 100644 --- a/packages/stack-profile/CHANGELOG.md +++ b/packages/stack-profile/CHANGELOG.md @@ -7,6 +7,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 + ## [0.34.0-alpha.1] - 2026-03-04 ### Changed diff --git a/packages/stack-profile/Cargo.toml b/packages/stack-profile/Cargo.toml index df48ab863..8f0044300 100644 --- a/packages/stack-profile/Cargo.toml +++ b/packages/stack-profile/Cargo.toml @@ -2,7 +2,7 @@ name = "stack-profile" description = "Centralised ~/.cipherstash profile file management" license-file = "LICENSE" -version = "0.34.0-alpha.3" +version = "0.34.0-alpha.4" edition.workspace = true authors.workspace = true repository.workspace = true From 0a390bde2a749166dfe7fc720f54aa2670a4ad37 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Tue, 24 Mar 2026 21:07:42 +1100 Subject: [PATCH 115/686] fix(ci): update macOS runners in publish-auth-npm workflow macos-13 has been retired by GitHub Actions, breaking the x86_64-apple-darwin build. Update both macOS targets to use macos-latest. --- .github/imported-workflows/publish-auth-npm.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/imported-workflows/publish-auth-npm.yml b/.github/imported-workflows/publish-auth-npm.yml index 144baa906..1163b0a70 100644 --- a/.github/imported-workflows/publish-auth-npm.yml +++ b/.github/imported-workflows/publish-auth-npm.yml @@ -34,9 +34,9 @@ jobs: matrix: include: - target: x86_64-apple-darwin - os: macos-13 + os: macos-latest - target: aarch64-apple-darwin - os: macos-14 + os: macos-latest - target: x86_64-unknown-linux-gnu os: blacksmith-8vcpu-ubuntu-2404 - target: aarch64-unknown-linux-gnu From b967fc43e774dd9ce5d0401cd3e905756275e174 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Tue, 24 Mar 2026 21:25:12 +1100 Subject: [PATCH 116/686] fix(ci): update GitHub Actions and Node.js in publish-auth-npm MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Update actions to resolve Node.js 20 deprecation warnings: checkout v4 → v6, setup-node v4 → v6, upload-artifact v4 → v7, download-artifact v4 → v8 - Bump Node.js from 22 to 24 (current LTS) --- .github/imported-workflows/publish-auth-npm.yml | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/.github/imported-workflows/publish-auth-npm.yml b/.github/imported-workflows/publish-auth-npm.yml index 1163b0a70..0b1462e87 100644 --- a/.github/imported-workflows/publish-auth-npm.yml +++ b/.github/imported-workflows/publish-auth-npm.yml @@ -50,7 +50,7 @@ jobs: runs-on: ${{ matrix.os }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Setup Rust uses: dtolnay/rust-toolchain@1.90.0 @@ -61,9 +61,9 @@ jobs: uses: Swatinem/rust-cache@v2 - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: - node-version: 22 + node-version: 24 - name: Install musl tools if: matrix.target == 'x86_64-unknown-linux-musl' @@ -78,7 +78,7 @@ jobs: run: npx napi build --platform --release --target ${{ matrix.target }} --strip - name: Upload artifact - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: bindings-${{ matrix.target }} path: ${{ env.WORKING_DIR }}/*.node @@ -90,12 +90,12 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: - node-version: 22 + node-version: 24 registry-url: https://registry.npmjs.org - name: Install dependencies @@ -103,7 +103,7 @@ jobs: run: npm ci - name: Download all artifacts - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v8 with: path: ${{ env.WORKING_DIR }}/artifacts From ff33aee7f04e4f48009eb07ead19835ae7dbc198 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Tue, 24 Mar 2026 21:50:51 +1100 Subject: [PATCH 117/686] fix(ci): regenerate package-lock.json for npm ci compatibility The lockfile was missing platform-specific optional dependencies (@cipherstash/auth-*), causing npm ci to fail with npm 11 (Node 24). --- .../packages/auth/package-lock.json | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/languages/typescript/packages/auth/package-lock.json b/languages/typescript/packages/auth/package-lock.json index d8230f9ba..78b83e645 100644 --- a/languages/typescript/packages/auth/package-lock.json +++ b/languages/typescript/packages/auth/package-lock.json @@ -11,8 +11,34 @@ "@napi-rs/cli": "^2", "typescript": "^5", "vitest": "^3" + }, + "optionalDependencies": { + "@cipherstash/auth-darwin-arm64": "0.34.0", + "@cipherstash/auth-darwin-x64": "0.34.0", + "@cipherstash/auth-linux-arm64-gnu": "0.34.0", + "@cipherstash/auth-linux-x64-gnu": "0.34.0", + "@cipherstash/auth-linux-x64-musl": "0.34.0", + "@cipherstash/auth-win32-x64-msvc": "0.34.0" } }, + "node_modules/@cipherstash/auth-darwin-arm64": { + "optional": true + }, + "node_modules/@cipherstash/auth-darwin-x64": { + "optional": true + }, + "node_modules/@cipherstash/auth-linux-arm64-gnu": { + "optional": true + }, + "node_modules/@cipherstash/auth-linux-x64-gnu": { + "optional": true + }, + "node_modules/@cipherstash/auth-linux-x64-musl": { + "optional": true + }, + "node_modules/@cipherstash/auth-win32-x64-msvc": { + "optional": true + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.27.3", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", From 88f17006dfc1e1f641d22c0a27e4c25a264d9687 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Wed, 25 Mar 2026 12:56:37 +1100 Subject: [PATCH 118/686] fix(ci): set NODE_AUTH_TOKEN for npm publish steps The publish steps were missing the NODE_AUTH_TOKEN env var mapping, so npm was making unauthenticated requests which fail with 404 on new scoped packages. --- .github/imported-workflows/publish-auth-npm.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/imported-workflows/publish-auth-npm.yml b/.github/imported-workflows/publish-auth-npm.yml index 0b1462e87..1a4eb885c 100644 --- a/.github/imported-workflows/publish-auth-npm.yml +++ b/.github/imported-workflows/publish-auth-npm.yml @@ -167,6 +167,8 @@ jobs: - name: Publish platform packages if: ${{ !inputs.dry_run }} working-directory: ${{ env.WORKING_DIR }} + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} run: | for dir in npm/*/; do if [ -f "$dir/package.json" ]; then @@ -178,6 +180,8 @@ jobs: - name: Publish main package if: ${{ !inputs.dry_run }} working-directory: ${{ env.WORKING_DIR }} + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} run: npm publish --access public --tag ${{ steps.version.outputs.npm_tag }} - name: Dry run summary From 0f30c68b34f5709b0f322dae378209e9ac86efc9 Mon Sep 17 00:00:00 2001 From: CJ Brewer Date: Wed, 25 Mar 2026 17:19:29 -0600 Subject: [PATCH 119/686] fix(auth): include stack-auth-node.js in published artifacts --- languages/typescript/packages/auth/.gitignore | 1 - .../packages/auth/stack-auth-node.js | 67 +++++++++++++++++++ 2 files changed, 67 insertions(+), 1 deletion(-) create mode 100644 languages/typescript/packages/auth/stack-auth-node.js diff --git a/languages/typescript/packages/auth/.gitignore b/languages/typescript/packages/auth/.gitignore index b197fc7d7..07a825bc4 100644 --- a/languages/typescript/packages/auth/.gitignore +++ b/languages/typescript/packages/auth/.gitignore @@ -2,4 +2,3 @@ target/ node_modules/ *.node npm/*/*.node -stack-auth-node.js diff --git a/languages/typescript/packages/auth/stack-auth-node.js b/languages/typescript/packages/auth/stack-auth-node.js new file mode 100644 index 000000000..2e3a55d41 --- /dev/null +++ b/languages/typescript/packages/auth/stack-auth-node.js @@ -0,0 +1,67 @@ +/* eslint-disable no-console */ +// Native binding loader for @cipherstash/auth +// Resolves the correct platform-specific optional dependency package. + +const { platform, arch } = process; + +function isMusl() { + try { + // If dlopen is available, check if the libc is musl + const report = + typeof process.report?.getReport === "function" + ? process.report.getReport() + : null; + if (report && typeof report === "object" && report.sharedObjects) { + return report.sharedObjects.some((s) => s.includes("musl")); + } + } catch (_) { + // Fallback: check if /usr/bin/ldd mentions musl + } + try { + const { execSync } = require("node:child_process"); + return execSync("ldd --version 2>&1", { encoding: "utf8" }).includes( + "musl", + ); + } catch (_) { + return false; + } +} + +const platforms = { + "darwin-x64": "@cipherstash/auth-darwin-x64", + "darwin-arm64": "@cipherstash/auth-darwin-arm64", + "linux-x64-gnu": "@cipherstash/auth-linux-x64-gnu", + "linux-x64-musl": "@cipherstash/auth-linux-x64-musl", + "linux-arm64-gnu": "@cipherstash/auth-linux-arm64-gnu", + "win32-x64-msvc": "@cipherstash/auth-win32-x64-msvc", +}; + +function loadBinding() { + let key = `${platform}-${arch}`; + + if (platform === "linux") { + key += isMusl() ? "-musl" : "-gnu"; + } else if (platform === "win32") { + key += "-msvc"; + } + + const pkg = platforms[key]; + if (!pkg) { + throw new Error( + `Unsupported platform: ${platform}-${arch}. ` + + `@cipherstash/auth supports: ${Object.keys(platforms).join(", ")}`, + ); + } + + try { + return require(pkg); + } catch (err) { + throw new Error( + `Failed to load native binding for ${platform}-${arch}. ` + + `Ensure the optional dependency "${pkg}" is installed.\n` + + `Original error: ${err.message}`, + ); + } +} + +module.exports = loadBinding(); From 009e1ef5dec004e99d21c908cb0e64565ac4554d Mon Sep 17 00:00:00 2001 From: CJ Brewer Date: Wed, 25 Mar 2026 17:30:35 -0600 Subject: [PATCH 120/686] chore: npm i --- .../packages/auth/package-lock.json | 66 +++++++++++++++++-- 1 file changed, 60 insertions(+), 6 deletions(-) diff --git a/languages/typescript/packages/auth/package-lock.json b/languages/typescript/packages/auth/package-lock.json index 78b83e645..6eb1117dc 100644 --- a/languages/typescript/packages/auth/package-lock.json +++ b/languages/typescript/packages/auth/package-lock.json @@ -22,22 +22,76 @@ } }, "node_modules/@cipherstash/auth-darwin-arm64": { - "optional": true + "version": "0.34.0", + "resolved": "https://registry.npmjs.org/@cipherstash/auth-darwin-arm64/-/auth-darwin-arm64-0.34.0.tgz", + "integrity": "sha512-KRKrPcXJU9hkcfTTYIkw+ZL4yH6JUPDvb5ATXdc6Kn0MwxiryvSw/gxmyTWLihvJTHwOIC98onHlTihSvHsO0g==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "darwin" + ] }, "node_modules/@cipherstash/auth-darwin-x64": { - "optional": true + "version": "0.34.0", + "resolved": "https://registry.npmjs.org/@cipherstash/auth-darwin-x64/-/auth-darwin-x64-0.34.0.tgz", + "integrity": "sha512-+mgPBxgmHg/165MO8bKZ39pDOtwTreC+ewrv24F2gJnLliOsJ3pxi1E0CDjEt5neQUTC7cYz0Wr6d5ykwWULeA==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "darwin" + ] }, "node_modules/@cipherstash/auth-linux-arm64-gnu": { - "optional": true + "version": "0.34.0", + "resolved": "https://registry.npmjs.org/@cipherstash/auth-linux-arm64-gnu/-/auth-linux-arm64-gnu-0.34.0.tgz", + "integrity": "sha512-isWRdo9JufXH3f3EuZT0FJFmd3dUTSWnGiIR9Y+HBY7rshrYvgLMoLQr6dIB3ticl34bkSoObkxjq6bQfc7CGA==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ] }, "node_modules/@cipherstash/auth-linux-x64-gnu": { - "optional": true + "version": "0.34.0", + "resolved": "https://registry.npmjs.org/@cipherstash/auth-linux-x64-gnu/-/auth-linux-x64-gnu-0.34.0.tgz", + "integrity": "sha512-A2no5xfgINEGRkIAGSSIG9UF08kI0YgYfjZwgGDnPmOzdBKPUB3Hics7XY038VlL3ygcI3OoNHcb1s3gk27neA==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ] }, "node_modules/@cipherstash/auth-linux-x64-musl": { - "optional": true + "version": "0.34.0", + "resolved": "https://registry.npmjs.org/@cipherstash/auth-linux-x64-musl/-/auth-linux-x64-musl-0.34.0.tgz", + "integrity": "sha512-WOBL4J9qlwOHkmFAmGknKS0/sMuxgmePXsX2hhVpcv1WAVW9taJXQlqGaYrbafGLRxwriAE3J5QGMNoyn+IW2Q==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ] }, "node_modules/@cipherstash/auth-win32-x64-msvc": { - "optional": true + "version": "0.34.0", + "resolved": "https://registry.npmjs.org/@cipherstash/auth-win32-x64-msvc/-/auth-win32-x64-msvc-0.34.0.tgz", + "integrity": "sha512-sGqMeGx3YBGsIUHhrXbFUMVbYMdpFd0iX90b4HzvGzHnQA2qQSivFov8ujtUh/NZaK1sUJ/dQNHzaP3sEWZS/w==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "win32" + ] }, "node_modules/@esbuild/aix-ppc64": { "version": "0.27.3", From 23036cac57ff1841a06b202ddd641e72d8ec05c9 Mon Sep 17 00:00:00 2001 From: CJ Brewer Date: Wed, 25 Mar 2026 17:34:02 -0600 Subject: [PATCH 121/686] chore: local node binary --- .../packages/auth/stack-auth-node.js | 20 ++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/languages/typescript/packages/auth/stack-auth-node.js b/languages/typescript/packages/auth/stack-auth-node.js index 2e3a55d41..dfa747359 100644 --- a/languages/typescript/packages/auth/stack-auth-node.js +++ b/languages/typescript/packages/auth/stack-auth-node.js @@ -53,15 +53,21 @@ function loadBinding() { ); } + // Try the platform-specific optional dependency (production / npm install) try { return require(pkg); - } catch (err) { - throw new Error( - `Failed to load native binding for ${platform}-${arch}. ` + - `Ensure the optional dependency "${pkg}" is installed.\n` + - `Original error: ${err.message}`, - ); - } + } catch (_) {} + + // Fall back to local .node binary (local development / napi build) + try { + return require("./stack-auth-node.node"); + } catch (_) {} + + throw new Error( + `Failed to load native binding for ${platform}-${arch}. ` + + `Ensure the optional dependency "${pkg}" is installed, ` + + `or run "napi build" for local development.`, + ); } module.exports = loadBinding(); From afa18898ca350706ed5109de3c2678574916aa2b Mon Sep 17 00:00:00 2001 From: CJ Brewer Date: Wed, 25 Mar 2026 17:52:29 -0600 Subject: [PATCH 122/686] fix: ci builds --- languages/typescript/packages/auth/index.d.ts | 19 +++++++++++++++++++ languages/typescript/packages/auth/index.js | 11 +---------- .../packages/auth/stack-auth-node.js | 10 ++++++---- 3 files changed, 26 insertions(+), 14 deletions(-) diff --git a/languages/typescript/packages/auth/index.d.ts b/languages/typescript/packages/auth/index.d.ts index 4443eca45..2216a3690 100644 --- a/languages/typescript/packages/auth/index.d.ts +++ b/languages/typescript/packages/auth/index.d.ts @@ -3,6 +3,25 @@ /* auto-generated by NAPI-RS */ +/** Error codes attached to errors thrown by this package. */ +export type AuthErrorCode = + | 'REQUEST_ERROR' + | 'ACCESS_DENIED' + | 'EXPIRED_TOKEN' + | 'INVALID_GRANT' + | 'INVALID_CLIENT' + | 'INVALID_URL' + | 'INVALID_REGION' + | 'INVALID_TOKEN' + | 'SERVER_ERROR' + | 'STORE_ERROR' + | 'UNKNOWN_ERROR' + +/** An error thrown by this package, enriched with a machine-readable `.code`. */ +export interface AuthError extends Error { + code: AuthErrorCode +} + /** * Metadata returned after a successful device code authentication. * diff --git a/languages/typescript/packages/auth/index.js b/languages/typescript/packages/auth/index.js index 91fb486e9..920ddbd2e 100644 --- a/languages/typescript/packages/auth/index.js +++ b/languages/typescript/packages/auth/index.js @@ -2,16 +2,7 @@ // machine-readable `.code` property by parsing the "CODE: message" format // that the Rust side produces. -let native; -try { - native = require("./stack-auth-node.js"); -} catch (err) { - if (err && err.code === "MODULE_NOT_FOUND") { - native = require("./stack-auth-node.node"); - } else { - throw err; - } -} +const native = require("./stack-auth-node.js"); const CODE_RE = /^([A-Z_]+): /; diff --git a/languages/typescript/packages/auth/stack-auth-node.js b/languages/typescript/packages/auth/stack-auth-node.js index dfa747359..b09b99859 100644 --- a/languages/typescript/packages/auth/stack-auth-node.js +++ b/languages/typescript/packages/auth/stack-auth-node.js @@ -53,14 +53,16 @@ function loadBinding() { ); } - // Try the platform-specific optional dependency (production / npm install) + // Prefer a local .node binary (local development / napi build) so that + // locally-built features (e.g. test-utils) take priority over a published + // platform package that may have been installed alongside it. try { - return require(pkg); + return require("./stack-auth-node.node"); } catch (_) {} - // Fall back to local .node binary (local development / napi build) + // Fall back to the platform-specific optional dependency (production / npm install) try { - return require("./stack-auth-node.node"); + return require(pkg); } catch (_) {} throw new Error( From a130454dc56b7993963e576bd094df554d03d527 Mon Sep 17 00:00:00 2001 From: CJ Brewer Date: Wed, 25 Mar 2026 18:12:24 -0600 Subject: [PATCH 123/686] chore: bump auth versions --- languages/typescript/packages/auth/package.json | 14 +++++++------- .../auth/platforms/darwin-arm64/package.json | 2 +- .../auth/platforms/darwin-x64/package.json | 2 +- .../auth/platforms/linux-arm64-gnu/package.json | 2 +- .../auth/platforms/linux-x64-gnu/package.json | 2 +- .../auth/platforms/linux-x64-musl/package.json | 2 +- .../auth/platforms/win32-x64-msvc/package.json | 2 +- 7 files changed, 13 insertions(+), 13 deletions(-) diff --git a/languages/typescript/packages/auth/package.json b/languages/typescript/packages/auth/package.json index 3e3888f04..1a1ad972b 100644 --- a/languages/typescript/packages/auth/package.json +++ b/languages/typescript/packages/auth/package.json @@ -1,6 +1,6 @@ { "name": "@cipherstash/auth", - "version": "0.34.0", + "version": "0.34.1", "main": "index.js", "types": "index.d.ts", "napi": { @@ -30,12 +30,12 @@ "test": "npm run build:test && vitest run" }, "optionalDependencies": { - "@cipherstash/auth-darwin-x64": "0.34.0", - "@cipherstash/auth-darwin-arm64": "0.34.0", - "@cipherstash/auth-linux-x64-gnu": "0.34.0", - "@cipherstash/auth-linux-arm64-gnu": "0.34.0", - "@cipherstash/auth-linux-x64-musl": "0.34.0", - "@cipherstash/auth-win32-x64-msvc": "0.34.0" + "@cipherstash/auth-darwin-x64": "0.34.1", + "@cipherstash/auth-darwin-arm64": "0.34.1", + "@cipherstash/auth-linux-x64-gnu": "0.34.1", + "@cipherstash/auth-linux-arm64-gnu": "0.34.1", + "@cipherstash/auth-linux-x64-musl": "0.34.1", + "@cipherstash/auth-win32-x64-msvc": "0.34.1" }, "devDependencies": { "@napi-rs/cli": "^2", diff --git a/languages/typescript/packages/auth/platforms/darwin-arm64/package.json b/languages/typescript/packages/auth/platforms/darwin-arm64/package.json index 797f329dd..ca97173ad 100644 --- a/languages/typescript/packages/auth/platforms/darwin-arm64/package.json +++ b/languages/typescript/packages/auth/platforms/darwin-arm64/package.json @@ -1,6 +1,6 @@ { "name": "@cipherstash/auth-darwin-arm64", - "version": "0.34.0", + "version": "0.34.1", "os": [ "darwin" ], diff --git a/languages/typescript/packages/auth/platforms/darwin-x64/package.json b/languages/typescript/packages/auth/platforms/darwin-x64/package.json index 00709709f..e7b6d962d 100644 --- a/languages/typescript/packages/auth/platforms/darwin-x64/package.json +++ b/languages/typescript/packages/auth/platforms/darwin-x64/package.json @@ -1,6 +1,6 @@ { "name": "@cipherstash/auth-darwin-x64", - "version": "0.34.0", + "version": "0.34.1", "os": [ "darwin" ], diff --git a/languages/typescript/packages/auth/platforms/linux-arm64-gnu/package.json b/languages/typescript/packages/auth/platforms/linux-arm64-gnu/package.json index 566afd74c..2be8b1752 100644 --- a/languages/typescript/packages/auth/platforms/linux-arm64-gnu/package.json +++ b/languages/typescript/packages/auth/platforms/linux-arm64-gnu/package.json @@ -1,6 +1,6 @@ { "name": "@cipherstash/auth-linux-arm64-gnu", - "version": "0.34.0", + "version": "0.34.1", "os": [ "linux" ], diff --git a/languages/typescript/packages/auth/platforms/linux-x64-gnu/package.json b/languages/typescript/packages/auth/platforms/linux-x64-gnu/package.json index 3f59c6ff0..eb79832c9 100644 --- a/languages/typescript/packages/auth/platforms/linux-x64-gnu/package.json +++ b/languages/typescript/packages/auth/platforms/linux-x64-gnu/package.json @@ -1,6 +1,6 @@ { "name": "@cipherstash/auth-linux-x64-gnu", - "version": "0.34.0", + "version": "0.34.1", "os": [ "linux" ], diff --git a/languages/typescript/packages/auth/platforms/linux-x64-musl/package.json b/languages/typescript/packages/auth/platforms/linux-x64-musl/package.json index 6efd29241..e4cbbd14a 100644 --- a/languages/typescript/packages/auth/platforms/linux-x64-musl/package.json +++ b/languages/typescript/packages/auth/platforms/linux-x64-musl/package.json @@ -1,6 +1,6 @@ { "name": "@cipherstash/auth-linux-x64-musl", - "version": "0.34.0", + "version": "0.34.1", "os": [ "linux" ], diff --git a/languages/typescript/packages/auth/platforms/win32-x64-msvc/package.json b/languages/typescript/packages/auth/platforms/win32-x64-msvc/package.json index 320794278..a4d29e434 100644 --- a/languages/typescript/packages/auth/platforms/win32-x64-msvc/package.json +++ b/languages/typescript/packages/auth/platforms/win32-x64-msvc/package.json @@ -1,6 +1,6 @@ { "name": "@cipherstash/auth-win32-x64-msvc", - "version": "0.34.0", + "version": "0.34.1", "os": [ "win32" ], From 13317f43f409ab31b1e6057aedf00f23e3cc915e Mon Sep 17 00:00:00 2001 From: CJ Brewer Date: Wed, 25 Mar 2026 18:22:21 -0600 Subject: [PATCH 124/686] fix: lock file --- .../packages/auth/package-lock.json | 90 ++----------------- packages/stack-auth/package-lock.json | 6 ++ 2 files changed, 15 insertions(+), 81 deletions(-) create mode 100644 packages/stack-auth/package-lock.json diff --git a/languages/typescript/packages/auth/package-lock.json b/languages/typescript/packages/auth/package-lock.json index 6eb1117dc..beb5b2e66 100644 --- a/languages/typescript/packages/auth/package-lock.json +++ b/languages/typescript/packages/auth/package-lock.json @@ -1,97 +1,25 @@ { "name": "@cipherstash/auth", - "version": "0.34.0", + "version": "0.34.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@cipherstash/auth", - "version": "0.34.0", + "version": "0.34.1", "devDependencies": { "@napi-rs/cli": "^2", "typescript": "^5", "vitest": "^3" }, "optionalDependencies": { - "@cipherstash/auth-darwin-arm64": "0.34.0", - "@cipherstash/auth-darwin-x64": "0.34.0", - "@cipherstash/auth-linux-arm64-gnu": "0.34.0", - "@cipherstash/auth-linux-x64-gnu": "0.34.0", - "@cipherstash/auth-linux-x64-musl": "0.34.0", - "@cipherstash/auth-win32-x64-msvc": "0.34.0" - } - }, - "node_modules/@cipherstash/auth-darwin-arm64": { - "version": "0.34.0", - "resolved": "https://registry.npmjs.org/@cipherstash/auth-darwin-arm64/-/auth-darwin-arm64-0.34.0.tgz", - "integrity": "sha512-KRKrPcXJU9hkcfTTYIkw+ZL4yH6JUPDvb5ATXdc6Kn0MwxiryvSw/gxmyTWLihvJTHwOIC98onHlTihSvHsO0g==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@cipherstash/auth-darwin-x64": { - "version": "0.34.0", - "resolved": "https://registry.npmjs.org/@cipherstash/auth-darwin-x64/-/auth-darwin-x64-0.34.0.tgz", - "integrity": "sha512-+mgPBxgmHg/165MO8bKZ39pDOtwTreC+ewrv24F2gJnLliOsJ3pxi1E0CDjEt5neQUTC7cYz0Wr6d5ykwWULeA==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@cipherstash/auth-linux-arm64-gnu": { - "version": "0.34.0", - "resolved": "https://registry.npmjs.org/@cipherstash/auth-linux-arm64-gnu/-/auth-linux-arm64-gnu-0.34.0.tgz", - "integrity": "sha512-isWRdo9JufXH3f3EuZT0FJFmd3dUTSWnGiIR9Y+HBY7rshrYvgLMoLQr6dIB3ticl34bkSoObkxjq6bQfc7CGA==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@cipherstash/auth-linux-x64-gnu": { - "version": "0.34.0", - "resolved": "https://registry.npmjs.org/@cipherstash/auth-linux-x64-gnu/-/auth-linux-x64-gnu-0.34.0.tgz", - "integrity": "sha512-A2no5xfgINEGRkIAGSSIG9UF08kI0YgYfjZwgGDnPmOzdBKPUB3Hics7XY038VlL3ygcI3OoNHcb1s3gk27neA==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@cipherstash/auth-linux-x64-musl": { - "version": "0.34.0", - "resolved": "https://registry.npmjs.org/@cipherstash/auth-linux-x64-musl/-/auth-linux-x64-musl-0.34.0.tgz", - "integrity": "sha512-WOBL4J9qlwOHkmFAmGknKS0/sMuxgmePXsX2hhVpcv1WAVW9taJXQlqGaYrbafGLRxwriAE3J5QGMNoyn+IW2Q==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@cipherstash/auth-win32-x64-msvc": { - "version": "0.34.0", - "resolved": "https://registry.npmjs.org/@cipherstash/auth-win32-x64-msvc/-/auth-win32-x64-msvc-0.34.0.tgz", - "integrity": "sha512-sGqMeGx3YBGsIUHhrXbFUMVbYMdpFd0iX90b4HzvGzHnQA2qQSivFov8ujtUh/NZaK1sUJ/dQNHzaP3sEWZS/w==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "win32" - ] + "@cipherstash/auth-darwin-arm64": "0.34.1", + "@cipherstash/auth-darwin-x64": "0.34.1", + "@cipherstash/auth-linux-arm64-gnu": "0.34.1", + "@cipherstash/auth-linux-x64-gnu": "0.34.1", + "@cipherstash/auth-linux-x64-musl": "0.34.1", + "@cipherstash/auth-win32-x64-msvc": "0.34.1" + } }, "node_modules/@esbuild/aix-ppc64": { "version": "0.27.3", diff --git a/packages/stack-auth/package-lock.json b/packages/stack-auth/package-lock.json new file mode 100644 index 000000000..58d7ece0b --- /dev/null +++ b/packages/stack-auth/package-lock.json @@ -0,0 +1,6 @@ +{ + "name": "stack-auth", + "lockfileVersion": 3, + "requires": true, + "packages": {} +} From 9c92a932f2ba6a67adba3c608ddc8d6b22a85f9e Mon Sep 17 00:00:00 2001 From: CJ Brewer Date: Wed, 25 Mar 2026 21:53:20 -0600 Subject: [PATCH 125/686] fix(ci): omit optional deps during publishing --- .github/imported-workflows/publish-auth-npm.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/imported-workflows/publish-auth-npm.yml b/.github/imported-workflows/publish-auth-npm.yml index 1a4eb885c..b80a64d6d 100644 --- a/.github/imported-workflows/publish-auth-npm.yml +++ b/.github/imported-workflows/publish-auth-npm.yml @@ -100,7 +100,7 @@ jobs: - name: Install dependencies working-directory: ${{ env.WORKING_DIR }} - run: npm ci + run: npm ci --omit=optional - name: Download all artifacts uses: actions/download-artifact@v8 From c1a0c6c18c12ec5ac7dbd04045307ebe7513d0d1 Mon Sep 17 00:00:00 2001 From: CJ Brewer Date: Wed, 25 Mar 2026 22:02:18 -0600 Subject: [PATCH 126/686] fix(ci): omit optional deps during publishing --- .github/imported-workflows/publish-auth-npm.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/imported-workflows/publish-auth-npm.yml b/.github/imported-workflows/publish-auth-npm.yml index b80a64d6d..318354913 100644 --- a/.github/imported-workflows/publish-auth-npm.yml +++ b/.github/imported-workflows/publish-auth-npm.yml @@ -100,7 +100,7 @@ jobs: - name: Install dependencies working-directory: ${{ env.WORKING_DIR }} - run: npm ci --omit=optional + run: npm install --omit=optional - name: Download all artifacts uses: actions/download-artifact@v8 From 11cef7b032adc460cbe2780544f9f6b633bf735a Mon Sep 17 00:00:00 2001 From: CJ Brewer Date: Wed, 25 Mar 2026 22:05:47 -0600 Subject: [PATCH 127/686] fix(ci): omit optional deps during publishing --- .github/imported-workflows/publish-auth-npm.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/imported-workflows/publish-auth-npm.yml b/.github/imported-workflows/publish-auth-npm.yml index 318354913..265fa9be0 100644 --- a/.github/imported-workflows/publish-auth-npm.yml +++ b/.github/imported-workflows/publish-auth-npm.yml @@ -71,7 +71,7 @@ jobs: - name: Install dependencies working-directory: ${{ env.WORKING_DIR }} - run: npm ci + run: npm install --omit=optional - name: Build native module working-directory: ${{ env.WORKING_DIR }} From 7cda9278bf4b204a8a9597ba1eaecd35f86710b8 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Thu, 26 Mar 2026 11:08:15 -0700 Subject: [PATCH 128/686] refactor: extract device client provisioning from CLI into stack-auth Move the post-login device client creation step out of cipherstash-cli and into stack-auth so other consumers (e.g. stack-auth-node) can reuse it without depending on cipherstash-client. The new `provision_device_client()` function in stack-auth makes a direct HTTP call to ZeroKMS using types from zerokms-protocol, loads the auth token and device identity from the profile store, and persists the resulting secret key to secretkey.json. --- packages/stack-auth/Cargo.toml | 1 + packages/stack-auth/src/device_client.rs | 300 +++++++++++++++++++++++ packages/stack-auth/src/lib.rs | 3 + 3 files changed, 304 insertions(+) create mode 100644 packages/stack-auth/src/device_client.rs diff --git a/packages/stack-auth/Cargo.toml b/packages/stack-auth/Cargo.toml index 76a93fe10..ba7de7178 100644 --- a/packages/stack-auth/Cargo.toml +++ b/packages/stack-auth/Cargo.toml @@ -25,6 +25,7 @@ url = { workspace = true } uuid = { workspace = true } vitaminc = { workspace = true, features = ["protected"] } vitaminc-protected = { workspace = true } +zerokms-protocol = { workspace = true } zeroize = { workspace = true } [features] diff --git a/packages/stack-auth/src/device_client.rs b/packages/stack-auth/src/device_client.rs new file mode 100644 index 000000000..ba996bd53 --- /dev/null +++ b/packages/stack-auth/src/device_client.rs @@ -0,0 +1,300 @@ +//! Post-login device client provisioning. +//! +//! After a device-code login, the caller must create a client in ZeroKMS and +//! persist the resulting secret key to disk. This module provides the +//! orchestration logic so that any consumer (not just the CLI) can perform +//! this step. + +use stack_profile::{DeviceIdentity, ProfileStore}; +use uuid::Uuid; +use zerokms_protocol::{CreateClientRequest, CreateClientResponse, ViturKeyMaterial, ViturRequest}; + +use crate::{ensure_trailing_slash, http_client, ServiceToken, Token}; + +// --------------------------------------------------------------------------- +// Secret key file (output) +// --------------------------------------------------------------------------- + +const SECRET_KEY_FILENAME: &str = "secretkey.json"; +const SECRET_KEY_MODE: u32 = 0o600; + +/// The on-disk shape of `secretkey.json`. +/// +/// Must stay in sync with `cipherstash_client::zerokms::SecretKey` which +/// deserializes this file. If that type moves to a shared crate, replace +/// this with a re-export. +#[derive(serde::Serialize)] +struct SecretKeyFile { + client_id: Uuid, + client_key: ViturKeyMaterial, +} + +// --------------------------------------------------------------------------- +// Error type +// --------------------------------------------------------------------------- + +/// Errors that can occur during device client provisioning. +#[derive(Debug, thiserror::Error)] +pub enum DeviceClientError { + /// The profile store could not load or create required data. + #[error("Profile error: {0}")] + Profile(#[from] stack_profile::ProfileError), + + /// Authentication token could not be loaded or decoded. + #[error("Auth error: {0}")] + Auth(#[from] crate::AuthError), + + /// The HTTP request to ZeroKMS failed. + #[error("ZeroKMS request failed: {0}")] + Request(#[from] reqwest::Error), + + /// ZeroKMS returned a non-success, non-conflict status. + #[error("ZeroKMS returned {status}: {body}")] + Server { + status: u16, + body: String, + }, + + /// Failed to construct the ZeroKMS endpoint URL. + #[error("Invalid ZeroKMS URL: {0}")] + InvalidUrl(#[from] url::ParseError), +} + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +/// Provision a device client after login. +/// +/// Loads the auth token and device identity from disk, creates a client in +/// ZeroKMS (on the workspace's default keyset), and persists the resulting +/// secret key to the profile store. +/// +/// If the secret key already exists on disk, or the server returns 409 +/// (conflict), this is a no-op. +pub async fn provision_device_client(store: &ProfileStore) -> Result<(), DeviceClientError> { + if store.exists(SECRET_KEY_FILENAME) { + tracing::debug!("secret key already exists, skipping provisioning"); + return Ok(()); + } + + let token: Token = store.load_profile()?; + let service_token = ServiceToken::new(token.access_token().clone()); + let zerokms_url = ensure_trailing_slash(service_token.zerokms_url()?); + + let identity = DeviceIdentity::load_or_create(store)?; + + let request = CreateClientRequest { + keyset_id: None, + name: (&identity.device_name).into(), + description: (&identity.device_name).into(), + }; + + let url = zerokms_url.join(CreateClientRequest::ENDPOINT)?; + + let response = http_client() + .post(url) + .bearer_auth(service_token.as_str()) + .json(&request) + .send() + .await?; + + let status = response.status(); + + if status == reqwest::StatusCode::CONFLICT { + // Another client was already provisioned server-side. + tracing::debug!("device client already exists, skipping"); + return Ok(()); + } + + if !status.is_success() { + let body = response.text().await.unwrap_or_default(); + return Err(DeviceClientError::Server { + status: status.as_u16(), + body, + }); + } + + let created: CreateClientResponse = response.json().await?; + + let secret_key = SecretKeyFile { + client_id: created.id, + client_key: created.client_key, + }; + + store.save_with_mode(SECRET_KEY_FILENAME, &secret_key, SECRET_KEY_MODE)?; + + Ok(()) +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + use crate::SecretToken; + use mocktail::prelude::*; + use tempfile::TempDir; + + fn make_test_jwt(zerokms_url: impl std::fmt::Display) -> String { + use jsonwebtoken::{encode, EncodingKey, Header}; + use std::time::{SystemTime, UNIX_EPOCH}; + + let zerokms_url = zerokms_url.to_string(); + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs(); + + let claims = serde_json::json!({ + "iss": "https://cts.example.com/", + "sub": "CS|test-user", + "aud": "legacy-aud-value", + "iat": now, + "exp": now + 3600, + "workspace": "ZVATKW3VHMFG27DY", + "scope": "", + "services": { + "zerokms": zerokms_url, + }, + }); + + encode( + &Header::default(), + &claims, + &EncodingKey::from_secret(b"test-secret"), + ) + .unwrap() + } + + fn save_test_token(store: &ProfileStore, access_token: &str) { + use std::time::{SystemTime, UNIX_EPOCH}; + + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs(); + + let token = Token { + access_token: SecretToken::new(access_token), + refresh_token: None, + token_type: "Bearer".into(), + expires_at: now + 3600, + region: None, + client_id: None, + device_instance_id: None, + }; + store.save_profile(&token).unwrap(); + } + + fn client_response_json() -> serde_json::Value { + serde_json::json!({ + "id": "00000000-0000-0000-0000-000000000001", + "dataset_id": "00000000-0000-0000-0000-000000000099", + "name": "test-device", + "description": "test-device", + "client_key": "dGVzdC1rZXktbWF0ZXJpYWw=" + }) + } + + async fn start_server(mocks: MockSet) -> MockServer { + let server = MockServer::new_http("device-client-test").with_mocks(mocks); + server.start().await.unwrap(); + server + } + + #[tokio::test] + async fn provisions_and_saves_secret_key() { + let dir = TempDir::new().unwrap(); + let store = ProfileStore::new(dir.path()); + + let mut mocks = MockSet::new(); + mocks.mock(|when, then| { + when.post().path("/create-client"); + then.json(client_response_json()); + }); + let server = start_server(mocks).await; + + let jwt = make_test_jwt(server.url("/")); + save_test_token(&store, &jwt); + + provision_device_client(&store).await.unwrap(); + + let saved: serde_json::Value = store.load(SECRET_KEY_FILENAME).unwrap(); + assert_eq!( + saved["client_id"], + "00000000-0000-0000-0000-000000000001" + ); + assert_eq!(saved["client_key"], "dGVzdC1rZXktbWF0ZXJpYWw="); + } + + #[tokio::test] + async fn skips_when_secret_key_exists() { + let dir = TempDir::new().unwrap(); + let store = ProfileStore::new(dir.path()); + + // Pre-populate secretkey.json + store + .save_with_mode( + SECRET_KEY_FILENAME, + &serde_json::json!({"client_id": "old", "client_key": "old"}), + SECRET_KEY_MODE, + ) + .unwrap(); + + // No mock server needed — the HTTP call should never happen. + provision_device_client(&store).await.unwrap(); + + let saved: serde_json::Value = store.load(SECRET_KEY_FILENAME).unwrap(); + assert_eq!(saved["client_id"], "old", "should not overwrite existing key"); + } + + #[tokio::test] + async fn no_op_on_conflict() { + let dir = TempDir::new().unwrap(); + let store = ProfileStore::new(dir.path()); + + let mut mocks = MockSet::new(); + mocks.mock(|when, then| { + when.post().path("/create-client"); + then.status(reqwest::StatusCode::CONFLICT) + .json(serde_json::json!({"error": "conflict"})); + }); + let server = start_server(mocks).await; + + let jwt = make_test_jwt(server.url("/")); + save_test_token(&store, &jwt); + + provision_device_client(&store).await.unwrap(); + + assert!( + !store.exists(SECRET_KEY_FILENAME), + "should not write secret key on conflict" + ); + } + + #[tokio::test] + async fn returns_error_on_server_failure() { + let dir = TempDir::new().unwrap(); + let store = ProfileStore::new(dir.path()); + + let mut mocks = MockSet::new(); + mocks.mock(|when, then| { + when.post().path("/create-client"); + then.status(reqwest::StatusCode::INTERNAL_SERVER_ERROR) + .json(serde_json::json!({"error": "internal error"})); + }); + let server = start_server(mocks).await; + + let jwt = make_test_jwt(server.url("/")); + save_test_token(&store, &jwt); + + let err = provision_device_client(&store).await.unwrap_err(); + assert!( + matches!(err, DeviceClientError::Server { status: 500, .. }), + "expected Server error, got: {err:?}" + ); + } +} diff --git a/packages/stack-auth/src/lib.rs b/packages/stack-auth/src/lib.rs index ae9bf22e5..29cdc72ed 100644 --- a/packages/stack-auth/src/lib.rs +++ b/packages/stack-auth/src/lib.rs @@ -36,6 +36,7 @@ mod access_key_strategy; mod auto_refresh; mod auto_strategy; mod device_code; +mod device_client; mod oauth_refresher; mod oauth_strategy; mod refresher; @@ -55,6 +56,8 @@ pub use service_token::ServiceToken; pub use static_token_strategy::StaticTokenStrategy; pub use token::Token; +pub use device_client::{provision_device_client, DeviceClientError}; + // Re-exports from stack-profile for backward compatibility. pub use stack_profile::DeviceIdentity; From e11dd2bd8cd2326ae5c26055d3edcffeef558c3a Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Thu, 26 Mar 2026 13:14:14 -0700 Subject: [PATCH 129/686] fix: add User-Agent header, rename to device_client, surface errors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Set User-Agent on the ZeroKMS HTTP request to satisfy WAF rules - Rename device_keyset → device_client (the function creates a client, not a keyset) - Surface provisioning errors in the CLI instead of silently swallowing - Add proper From for StashError - Add early return when secretkey.json already exists --- packages/stack-auth/src/device_client.rs | 25 +++++++++++++++--------- packages/stack-auth/src/lib.rs | 2 +- 2 files changed, 17 insertions(+), 10 deletions(-) diff --git a/packages/stack-auth/src/device_client.rs b/packages/stack-auth/src/device_client.rs index ba996bd53..82f90c78c 100644 --- a/packages/stack-auth/src/device_client.rs +++ b/packages/stack-auth/src/device_client.rs @@ -11,6 +11,15 @@ use zerokms_protocol::{CreateClientRequest, CreateClientResponse, ViturKeyMateri use crate::{ensure_trailing_slash, http_client, ServiceToken, Token}; +fn user_agent() -> String { + format!( + "stack-auth/{} ({} {})", + env!("CARGO_PKG_VERSION"), + std::env::consts::OS, + std::env::consts::ARCH, + ) +} + // --------------------------------------------------------------------------- // Secret key file (output) // --------------------------------------------------------------------------- @@ -50,10 +59,7 @@ pub enum DeviceClientError { /// ZeroKMS returned a non-success, non-conflict status. #[error("ZeroKMS returned {status}: {body}")] - Server { - status: u16, - body: String, - }, + Server { status: u16, body: String }, /// Failed to construct the ZeroKMS endpoint URL. #[error("Invalid ZeroKMS URL: {0}")] @@ -94,6 +100,7 @@ pub async fn provision_device_client(store: &ProfileStore) -> Result<(), DeviceC let response = http_client() .post(url) + .header(reqwest::header::USER_AGENT, user_agent()) .bearer_auth(service_token.as_str()) .json(&request) .send() @@ -223,10 +230,7 @@ mod tests { provision_device_client(&store).await.unwrap(); let saved: serde_json::Value = store.load(SECRET_KEY_FILENAME).unwrap(); - assert_eq!( - saved["client_id"], - "00000000-0000-0000-0000-000000000001" - ); + assert_eq!(saved["client_id"], "00000000-0000-0000-0000-000000000001"); assert_eq!(saved["client_key"], "dGVzdC1rZXktbWF0ZXJpYWw="); } @@ -248,7 +252,10 @@ mod tests { provision_device_client(&store).await.unwrap(); let saved: serde_json::Value = store.load(SECRET_KEY_FILENAME).unwrap(); - assert_eq!(saved["client_id"], "old", "should not overwrite existing key"); + assert_eq!( + saved["client_id"], "old", + "should not overwrite existing key" + ); } #[tokio::test] diff --git a/packages/stack-auth/src/lib.rs b/packages/stack-auth/src/lib.rs index 29cdc72ed..3a0e9d02e 100644 --- a/packages/stack-auth/src/lib.rs +++ b/packages/stack-auth/src/lib.rs @@ -35,8 +35,8 @@ mod access_key_refresher; mod access_key_strategy; mod auto_refresh; mod auto_strategy; -mod device_code; mod device_client; +mod device_code; mod oauth_refresher; mod oauth_strategy; mod refresher; From 47ac2d90b50fd001a0289cebdd908c5aa359aa2a Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Thu, 26 Mar 2026 14:00:17 -0700 Subject: [PATCH 130/686] feat: add provisionDeviceClient Node.js binding and tests Expose provision_device_client via @cipherstash/auth so JavaScript consumers can provision a device client after login without depending on cipherstash-client. - Add provisionDeviceClient() to the Node bindings - Add test-utils helpers: provisionDeviceClientWithProfileDir, saveTestToken, mockCreateClientEndpoint, mockCreateClientConflict - Add 5 vitest integration tests for the new binding - Fix mise integration test task to build with correct feature flags --- languages/typescript/packages/auth/Cargo.toml | 5 +- .../__tests__/provision-device-client.test.ts | 119 ++++++++++++++++++ languages/typescript/packages/auth/index.d.ts | 33 +++++ languages/typescript/packages/auth/index.js | 7 ++ .../packages/auth/package-lock.json | 72 +++++++++++ languages/typescript/packages/auth/src/lib.rs | 97 +++++++++++++- .../packages/auth/src/mock_auth_server.rs | 26 ++++ packages/stack-auth/tasks.toml | 6 +- 8 files changed, 361 insertions(+), 4 deletions(-) create mode 100644 languages/typescript/packages/auth/__tests__/provision-device-client.test.ts diff --git a/languages/typescript/packages/auth/Cargo.toml b/languages/typescript/packages/auth/Cargo.toml index e98b74a5a..539ef24b1 100644 --- a/languages/typescript/packages/auth/Cargo.toml +++ b/languages/typescript/packages/auth/Cargo.toml @@ -9,12 +9,15 @@ crate-type = ["cdylib"] [dependencies] stack-auth = { workspace = true } +stack-profile = { workspace = true } cts-common = { workspace = true } napi = { version = "2", features = ["async", "tokio_rt"] } napi-derive = "2" url = { version = "2", optional = true } mocktail = { version = "0.3.0", optional = true } serde_json = { version = "1", optional = true } +jsonwebtoken = { workspace = true, optional = true } +reqwest = { workspace = true, optional = true } [dev-dependencies] stack-auth = { workspace = true, features = ["test-utils"] } @@ -28,4 +31,4 @@ url = "2" napi-build = "2" [features] -test-utils = ["stack-auth/test-utils", "dep:url", "dep:mocktail", "dep:serde_json"] +test-utils = ["stack-auth/test-utils", "dep:url", "dep:mocktail", "dep:serde_json", "dep:jsonwebtoken", "dep:reqwest"] diff --git a/languages/typescript/packages/auth/__tests__/provision-device-client.test.ts b/languages/typescript/packages/auth/__tests__/provision-device-client.test.ts new file mode 100644 index 000000000..92f2b0388 --- /dev/null +++ b/languages/typescript/packages/auth/__tests__/provision-device-client.test.ts @@ -0,0 +1,119 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { mkdtempSync } from "fs"; +import { readFileSync } from "fs"; +import { join } from "path"; +import { tmpdir } from "os"; +import type { MockAuthServer as MockAuthServerType } from "../test-utils"; +import type { AuthError } from "../index"; + +const mod = require("../index.js") as typeof import("../index") & { + MockAuthServer: typeof MockAuthServerType; + provisionDeviceClientWithProfileDir: ( + profileDir: string + ) => Promise; + saveTestToken: (profileDir: string, zerokmsBaseUrl: string) => void; +}; + +const { + MockAuthServer, + provisionDeviceClientWithProfileDir, + saveTestToken, +} = mod; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +let server: InstanceType; +let profileDir: string; + +async function startServer(): Promise> { + const s = await MockAuthServer.start(); + return s; +} + +function freshProfileDir(): string { + return mkdtempSync(join(tmpdir(), "cs-auth-test-")); +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe("provision device client (TypeScript / vitest)", () => { + beforeEach(async () => { + server = await startServer(); + profileDir = freshProfileDir(); + }); + + it("creates secretkey.json on successful provisioning", async () => { + server.mockCreateClientEndpoint(); + saveTestToken(profileDir, server.baseUrl); + + await provisionDeviceClientWithProfileDir(profileDir); + + const raw = readFileSync(join(profileDir, "secretkey.json"), "utf-8"); + const secretKey = JSON.parse(raw); + expect(secretKey.client_id).toBe( + "00000000-0000-0000-0000-000000000001" + ); + expect(secretKey.client_key).toBe("dGVzdC1rZXktbWF0ZXJpYWw="); + }); + + it("is a no-op when secretkey.json already exists", async () => { + // No mock endpoints needed — should short-circuit before any HTTP call. + saveTestToken(profileDir, server.baseUrl); + + // Pre-create secretkey.json + const existing = JSON.stringify({ + client_id: "existing-id", + client_key: "existing-key", + }); + require("fs").writeFileSync( + join(profileDir, "secretkey.json"), + existing + ); + + await provisionDeviceClientWithProfileDir(profileDir); + + const raw = readFileSync(join(profileDir, "secretkey.json"), "utf-8"); + const secretKey = JSON.parse(raw); + expect(secretKey.client_id).toBe("existing-id"); + }); + + it("is a no-op on 409 conflict", async () => { + server.mockCreateClientConflict(); + saveTestToken(profileDir, server.baseUrl); + + await provisionDeviceClientWithProfileDir(profileDir); + + const exists = require("fs").existsSync( + join(profileDir, "secretkey.json") + ); + expect(exists).toBe(false); + }); + + it("throws on server error", async () => { + // No mock endpoint — server will return an error for unmatched route. + saveTestToken(profileDir, server.baseUrl); + + try { + await provisionDeviceClientWithProfileDir(profileDir); + expect.unreachable("should have thrown"); + } catch (err) { + expect(err).toBeInstanceOf(Error); + } + }); + + it("throws STORE_ERROR when auth token is missing", async () => { + // No token saved — should fail trying to load auth.json + try { + await provisionDeviceClientWithProfileDir(profileDir); + expect.unreachable("should have thrown"); + } catch (err) { + const authErr = err as AuthError; + expect(authErr).toBeInstanceOf(Error); + expect(authErr.code).toBe("STORE_ERROR"); + } + }); +}); diff --git a/languages/typescript/packages/auth/index.d.ts b/languages/typescript/packages/auth/index.d.ts index 2216a3690..65e784f33 100644 --- a/languages/typescript/packages/auth/index.d.ts +++ b/languages/typescript/packages/auth/index.d.ts @@ -34,6 +34,17 @@ export interface AuthResult { /** Number of seconds before the token expires (computed at time of return). */ expiresIn: number } +/** + * Provision a device client in ZeroKMS after login. + * + * Loads the auth token and device identity from `~/.cipherstash/`, + * creates a client on the workspace's default keyset, and persists the + * resulting secret key to `~/.cipherstash/secretkey.json`. + * + * This is a no-op if the secret key already exists or the server returns + * 409 (conflict). + */ +export declare function provisionDeviceClient(): Promise /** Begin the OAuth 2.0 Device Authorization flow. */ export declare function beginDeviceCodeFlow(region: string, clientId: string): Promise /** @@ -43,6 +54,21 @@ export declare function beginDeviceCodeFlow(region: string, clientId: string): P * `test-utils` Cargo feature. */ export declare function beginDeviceCodeFlowWithBaseUrl(region: string, clientId: string, baseUrl: string): Promise +/** + * Variant of `provisionDeviceClient` that uses a custom profile directory. + * + * Intended for **testing only** — requires the crate to be built with the + * `test-utils` Cargo feature. + */ +export declare function provisionDeviceClientWithProfileDir(profileDir: string): Promise +/** + * Save a test auth token to the given profile directory with the ZeroKMS + * service URL set to `zerokms_base_url`. + * + * Intended for **testing only** — requires the crate to be built with the + * `test-utils` Cargo feature. + */ +export declare function saveTestToken(profileDir: string, zerokmsBaseUrl: string): void export declare class MockAuthServer { /** Start a mock auth server on a random port. */ static start(): Promise @@ -63,6 +89,13 @@ export declare class MockAuthServer { * with the given OAuth error code and optional description. */ mockTokenEndpointError(code: string, description?: string | undefined | null): void + /** + * Register a mock for `POST /create-client` that returns a successful + * create-client JSON response (as ZeroKMS would). + */ + mockCreateClientEndpoint(): void + /** Register a mock for `POST /create-client` that returns a 409 conflict. */ + mockCreateClientConflict(): void /** Remove all registered mocks. */ clearMocks(): void } diff --git a/languages/typescript/packages/auth/index.js b/languages/typescript/packages/auth/index.js index 920ddbd2e..1f58224c6 100644 --- a/languages/typescript/packages/auth/index.js +++ b/languages/typescript/packages/auth/index.js @@ -52,6 +52,7 @@ proto.openInBrowser = wrapSync(proto.openInBrowser); module.exports = { ...native, beginDeviceCodeFlow: wrapAsync(native.beginDeviceCodeFlow), + provisionDeviceClient: wrapAsync(native.provisionDeviceClient), }; if (native.beginDeviceCodeFlowWithBaseUrl) { @@ -59,3 +60,9 @@ if (native.beginDeviceCodeFlowWithBaseUrl) { native.beginDeviceCodeFlowWithBaseUrl, ); } + +if (native.provisionDeviceClientWithProfileDir) { + module.exports.provisionDeviceClientWithProfileDir = wrapAsync( + native.provisionDeviceClientWithProfileDir, + ); +} diff --git a/languages/typescript/packages/auth/package-lock.json b/languages/typescript/packages/auth/package-lock.json index beb5b2e66..ea0e3a860 100644 --- a/languages/typescript/packages/auth/package-lock.json +++ b/languages/typescript/packages/auth/package-lock.json @@ -21,6 +21,78 @@ "@cipherstash/auth-win32-x64-msvc": "0.34.1" } }, + "node_modules/@cipherstash/auth-darwin-arm64": { + "version": "0.34.1", + "resolved": "https://registry.npmjs.org/@cipherstash/auth-darwin-arm64/-/auth-darwin-arm64-0.34.1.tgz", + "integrity": "sha512-e7FStj+EKhT6HL+QaJUb2Kl75DLty7wAnRS5017kz1NWku9kmRU/1oqmhczJ7P9ktQQuykOneWOibIl4Zg73aQ==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@cipherstash/auth-darwin-x64": { + "version": "0.34.1", + "resolved": "https://registry.npmjs.org/@cipherstash/auth-darwin-x64/-/auth-darwin-x64-0.34.1.tgz", + "integrity": "sha512-qgo46tznJI4p6AJaafDopwDZNJNFK+n69Ik7bAEg5tFofW0N9YKCvdjcuH4hovxYSRnzapmdCpGQmx3QT5wtww==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@cipherstash/auth-linux-arm64-gnu": { + "version": "0.34.1", + "resolved": "https://registry.npmjs.org/@cipherstash/auth-linux-arm64-gnu/-/auth-linux-arm64-gnu-0.34.1.tgz", + "integrity": "sha512-Rhkp+77rMRUx9bjeDiQHVz13FnLlpmJGnN3mWz0njKJCdljshbhwLc7MSvRvf9Uou3AOipoUsGIPs5vrJlcPDA==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@cipherstash/auth-linux-x64-gnu": { + "version": "0.34.1", + "resolved": "https://registry.npmjs.org/@cipherstash/auth-linux-x64-gnu/-/auth-linux-x64-gnu-0.34.1.tgz", + "integrity": "sha512-fgMfaDBOX0xIngigWS+k2ZGCQejfrA7JXKeAZoJ5ELL5nklbJOG4CTV6TE+HL4xHthWhmJvX6b7j7rb7PWOQiA==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@cipherstash/auth-linux-x64-musl": { + "version": "0.34.1", + "resolved": "https://registry.npmjs.org/@cipherstash/auth-linux-x64-musl/-/auth-linux-x64-musl-0.34.1.tgz", + "integrity": "sha512-mhKzijUQ+N5B1NAX7bsw/lHYUtqpu1qfzebtB8C0Kx4JxzZ9fWil8bbxkHjSADCkWpjeELwVAYUmBp0mvnt4gA==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@cipherstash/auth-win32-x64-msvc": { + "version": "0.34.1", + "resolved": "https://registry.npmjs.org/@cipherstash/auth-win32-x64-msvc/-/auth-win32-x64-msvc-0.34.1.tgz", + "integrity": "sha512-SxAwvnFDAWh6ZYLdrPhGMpZ4OtiVNZtfoHE49NXFnjXFcKl8pvuOuzF8TWQWeTq2RvPPGLCoReHJ2avPZMlc+A==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "win32" + ] + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.27.3", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", diff --git a/languages/typescript/packages/auth/src/lib.rs b/languages/typescript/packages/auth/src/lib.rs index 85c56038a..76dd061c9 100644 --- a/languages/typescript/packages/auth/src/lib.rs +++ b/languages/typescript/packages/auth/src/lib.rs @@ -3,7 +3,7 @@ use std::sync::Mutex; use cts_common::Region; use napi::bindgen_prelude::*; use napi_derive::napi; -use stack_auth::{AuthError, DeviceCodeStrategy, PendingDeviceCode}; +use stack_auth::{AuthError, DeviceClientError, DeviceCodeStrategy, PendingDeviceCode}; #[cfg(feature = "test-utils")] mod mock_auth_server; @@ -155,6 +155,38 @@ impl DeviceCodeResult { // Exported functions // --------------------------------------------------------------------------- +fn device_client_error_code(err: &DeviceClientError) -> &'static str { + match err { + DeviceClientError::Profile(_) => "STORE_ERROR", + DeviceClientError::Auth(auth_err) => error_code(auth_err), + DeviceClientError::Request(_) => "REQUEST_ERROR", + DeviceClientError::Server { .. } => "SERVER_ERROR", + DeviceClientError::InvalidUrl(_) => "INVALID_URL", + } +} + +fn device_client_to_napi_error(err: DeviceClientError) -> napi::Error { + let code = device_client_error_code(&err); + napi::Error::new(Status::GenericFailure, format!("{code}: {err}")) +} + +/// Provision a device client in ZeroKMS after login. +/// +/// Loads the auth token and device identity from `~/.cipherstash/`, +/// creates a client on the workspace's default keyset, and persists the +/// resulting secret key to `~/.cipherstash/secretkey.json`. +/// +/// This is a no-op if the secret key already exists or the server returns +/// 409 (conflict). +#[napi] +pub async fn provision_device_client() -> Result<()> { + let store = stack_profile::ProfileStore::resolve(None) + .map_err(|e| device_client_to_napi_error(DeviceClientError::from(e)))?; + stack_auth::provision_device_client(&store) + .await + .map_err(device_client_to_napi_error) +} + /// Begin the OAuth 2.0 Device Authorization flow. #[napi] pub async fn begin_device_code_flow(region: String, client_id: String) -> Result { @@ -447,3 +479,66 @@ pub async fn begin_device_code_flow_with_base_url( let pending = strategy.begin().await.map_err(to_napi_error)?; Ok(DeviceCodeResult::from_pending(pending)) } + +/// Variant of `provisionDeviceClient` that uses a custom profile directory. +/// +/// Intended for **testing only** — requires the crate to be built with the +/// `test-utils` Cargo feature. +#[cfg(feature = "test-utils")] +#[napi] +pub async fn provision_device_client_with_profile_dir(profile_dir: String) -> Result<()> { + let store = stack_profile::ProfileStore::new(&profile_dir); + stack_auth::provision_device_client(&store) + .await + .map_err(device_client_to_napi_error) +} + +/// Save a test auth token to the given profile directory with the ZeroKMS +/// service URL set to `zerokms_base_url`. +/// +/// Intended for **testing only** — requires the crate to be built with the +/// `test-utils` Cargo feature. +#[cfg(feature = "test-utils")] +#[napi] +pub fn save_test_token(profile_dir: String, zerokms_base_url: String) -> Result<()> { + use jsonwebtoken::{encode, EncodingKey, Header}; + use std::time::{SystemTime, UNIX_EPOCH}; + + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_err(|e| napi::Error::new(Status::GenericFailure, format!("{e}")))? + .as_secs(); + + let claims = serde_json::json!({ + "iss": "https://cts.example.com/", + "sub": "CS|test-user", + "aud": "legacy-aud-value", + "iat": now, + "exp": now + 3600, + "workspace": "ZVATKW3VHMFG27DY", + "scope": "", + "services": { + "zerokms": zerokms_base_url, + }, + }); + + let jwt = encode( + &Header::default(), + &claims, + &EncodingKey::from_secret(b"test-secret"), + ) + .map_err(|e| napi::Error::new(Status::GenericFailure, format!("{e}")))?; + + let token_json = serde_json::json!({ + "access_token": jwt, + "token_type": "Bearer", + "expires_at": now + 3600, + }); + + let store = stack_profile::ProfileStore::new(&profile_dir); + store + .save_with_mode("auth.json", &token_json, 0o600) + .map_err(|e| napi::Error::new(Status::GenericFailure, format!("{e}")))?; + + Ok(()) +} diff --git a/languages/typescript/packages/auth/src/mock_auth_server.rs b/languages/typescript/packages/auth/src/mock_auth_server.rs index 85ad1997e..f6499be2b 100644 --- a/languages/typescript/packages/auth/src/mock_auth_server.rs +++ b/languages/typescript/packages/auth/src/mock_auth_server.rs @@ -70,6 +70,32 @@ impl MockAuthServer { }); } + /// Register a mock for `POST /create-client` that returns a successful + /// create-client JSON response (as ZeroKMS would). + #[napi] + pub fn mock_create_client_endpoint(&self) { + self.server.mocks().mock(|when, then| { + when.post().path("/create-client"); + then.json(serde_json::json!({ + "id": "00000000-0000-0000-0000-000000000001", + "dataset_id": "00000000-0000-0000-0000-000000000099", + "name": "test-device", + "description": "test-device", + "client_key": "dGVzdC1rZXktbWF0ZXJpYWw=" + })); + }); + } + + /// Register a mock for `POST /create-client` that returns a 409 conflict. + #[napi] + pub fn mock_create_client_conflict(&self) { + self.server.mocks().mock(|when, then| { + when.post().path("/create-client"); + then.status(reqwest::StatusCode::CONFLICT) + .json(serde_json::json!({"error": "conflict"})); + }); + } + /// Remove all registered mocks. #[napi] pub fn clear_mocks(&self) { diff --git a/packages/stack-auth/tasks.toml b/packages/stack-auth/tasks.toml index 5fa2e1832..64e38eb3d 100644 --- a/packages/stack-auth/tasks.toml +++ b/packages/stack-auth/tasks.toml @@ -2,6 +2,8 @@ description = "Run stack-auth Node.js integration tests" dir = "{{config_root}}/packages/stack-auth/node" run = [ - "npm ci", - "npm test", + "npm install", + "cargo build -p stack-auth-node --features stack-auth-node/test-utils", + "node -e \"const fs=require('fs'),p=require('path'); const ext=process.platform==='win32'?'dll':process.platform==='darwin'?'dylib':'so'; const src=p.resolve('..','..','..','target','debug',ext==='dll'?'stack_auth_node.dll':'libstack_auth_node.'+ext); fs.copyFileSync(src,'stack-auth-node.node')\"", + "npx vitest run", ] From 5989840a00361c179d6962e6bac2aea9d4269cf1 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Thu, 26 Mar 2026 14:08:51 -0700 Subject: [PATCH 131/686] chore: clean up test imports and simplify mise task - Replace inline require("fs") calls with top-level imports - Replace node one-liner file copy with plain cp in mise task - Use npm ci instead of npm install for reproducible installs --- .../auth/__tests__/provision-device-client.test.ts | 13 +++---------- packages/stack-auth/tasks.toml | 4 ++-- 2 files changed, 5 insertions(+), 12 deletions(-) diff --git a/languages/typescript/packages/auth/__tests__/provision-device-client.test.ts b/languages/typescript/packages/auth/__tests__/provision-device-client.test.ts index 92f2b0388..d8ff0cf22 100644 --- a/languages/typescript/packages/auth/__tests__/provision-device-client.test.ts +++ b/languages/typescript/packages/auth/__tests__/provision-device-client.test.ts @@ -1,6 +1,5 @@ import { describe, it, expect, beforeEach } from "vitest"; -import { mkdtempSync } from "fs"; -import { readFileSync } from "fs"; +import { existsSync, mkdtempSync, readFileSync, writeFileSync } from "fs"; import { join } from "path"; import { tmpdir } from "os"; import type { MockAuthServer as MockAuthServerType } from "../test-utils"; @@ -69,10 +68,7 @@ describe("provision device client (TypeScript / vitest)", () => { client_id: "existing-id", client_key: "existing-key", }); - require("fs").writeFileSync( - join(profileDir, "secretkey.json"), - existing - ); + writeFileSync(join(profileDir, "secretkey.json"), existing); await provisionDeviceClientWithProfileDir(profileDir); @@ -87,10 +83,7 @@ describe("provision device client (TypeScript / vitest)", () => { await provisionDeviceClientWithProfileDir(profileDir); - const exists = require("fs").existsSync( - join(profileDir, "secretkey.json") - ); - expect(exists).toBe(false); + expect(existsSync(join(profileDir, "secretkey.json"))).toBe(false); }); it("throws on server error", async () => { diff --git a/packages/stack-auth/tasks.toml b/packages/stack-auth/tasks.toml index 64e38eb3d..b31121846 100644 --- a/packages/stack-auth/tasks.toml +++ b/packages/stack-auth/tasks.toml @@ -2,8 +2,8 @@ description = "Run stack-auth Node.js integration tests" dir = "{{config_root}}/packages/stack-auth/node" run = [ - "npm install", + "npm ci", "cargo build -p stack-auth-node --features stack-auth-node/test-utils", - "node -e \"const fs=require('fs'),p=require('path'); const ext=process.platform==='win32'?'dll':process.platform==='darwin'?'dylib':'so'; const src=p.resolve('..','..','..','target','debug',ext==='dll'?'stack_auth_node.dll':'libstack_auth_node.'+ext); fs.copyFileSync(src,'stack-auth-node.node')\"", + "cp ../../../target/debug/libstack_auth_node.dylib stack-auth-node.node 2>/dev/null || cp ../../../target/debug/libstack_auth_node.so stack-auth-node.node", "npx vitest run", ] From e208a55b50254b7e286066aa54915c211725894b Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Thu, 26 Mar 2026 14:17:48 -0700 Subject: [PATCH 132/686] refactor: rename provisionDeviceClient to bindClientDevice --- .../auth/__tests__/provision-device-client.test.ts | 14 +++++++------- languages/typescript/packages/auth/index.d.ts | 6 +++--- languages/typescript/packages/auth/index.js | 8 ++++---- languages/typescript/packages/auth/src/lib.rs | 8 ++++---- packages/stack-auth/src/device_client.rs | 10 +++++----- packages/stack-auth/src/lib.rs | 2 +- 6 files changed, 24 insertions(+), 24 deletions(-) diff --git a/languages/typescript/packages/auth/__tests__/provision-device-client.test.ts b/languages/typescript/packages/auth/__tests__/provision-device-client.test.ts index d8ff0cf22..9b39f3bc3 100644 --- a/languages/typescript/packages/auth/__tests__/provision-device-client.test.ts +++ b/languages/typescript/packages/auth/__tests__/provision-device-client.test.ts @@ -7,7 +7,7 @@ import type { AuthError } from "../index"; const mod = require("../index.js") as typeof import("../index") & { MockAuthServer: typeof MockAuthServerType; - provisionDeviceClientWithProfileDir: ( + bindClientDeviceWithProfileDir: ( profileDir: string ) => Promise; saveTestToken: (profileDir: string, zerokmsBaseUrl: string) => void; @@ -15,7 +15,7 @@ const mod = require("../index.js") as typeof import("../index") & { const { MockAuthServer, - provisionDeviceClientWithProfileDir, + bindClientDeviceWithProfileDir, saveTestToken, } = mod; @@ -49,7 +49,7 @@ describe("provision device client (TypeScript / vitest)", () => { server.mockCreateClientEndpoint(); saveTestToken(profileDir, server.baseUrl); - await provisionDeviceClientWithProfileDir(profileDir); + await bindClientDeviceWithProfileDir(profileDir); const raw = readFileSync(join(profileDir, "secretkey.json"), "utf-8"); const secretKey = JSON.parse(raw); @@ -70,7 +70,7 @@ describe("provision device client (TypeScript / vitest)", () => { }); writeFileSync(join(profileDir, "secretkey.json"), existing); - await provisionDeviceClientWithProfileDir(profileDir); + await bindClientDeviceWithProfileDir(profileDir); const raw = readFileSync(join(profileDir, "secretkey.json"), "utf-8"); const secretKey = JSON.parse(raw); @@ -81,7 +81,7 @@ describe("provision device client (TypeScript / vitest)", () => { server.mockCreateClientConflict(); saveTestToken(profileDir, server.baseUrl); - await provisionDeviceClientWithProfileDir(profileDir); + await bindClientDeviceWithProfileDir(profileDir); expect(existsSync(join(profileDir, "secretkey.json"))).toBe(false); }); @@ -91,7 +91,7 @@ describe("provision device client (TypeScript / vitest)", () => { saveTestToken(profileDir, server.baseUrl); try { - await provisionDeviceClientWithProfileDir(profileDir); + await bindClientDeviceWithProfileDir(profileDir); expect.unreachable("should have thrown"); } catch (err) { expect(err).toBeInstanceOf(Error); @@ -101,7 +101,7 @@ describe("provision device client (TypeScript / vitest)", () => { it("throws STORE_ERROR when auth token is missing", async () => { // No token saved — should fail trying to load auth.json try { - await provisionDeviceClientWithProfileDir(profileDir); + await bindClientDeviceWithProfileDir(profileDir); expect.unreachable("should have thrown"); } catch (err) { const authErr = err as AuthError; diff --git a/languages/typescript/packages/auth/index.d.ts b/languages/typescript/packages/auth/index.d.ts index 65e784f33..b6bf74797 100644 --- a/languages/typescript/packages/auth/index.d.ts +++ b/languages/typescript/packages/auth/index.d.ts @@ -44,7 +44,7 @@ export interface AuthResult { * This is a no-op if the secret key already exists or the server returns * 409 (conflict). */ -export declare function provisionDeviceClient(): Promise +export declare function bindClientDevice(): Promise /** Begin the OAuth 2.0 Device Authorization flow. */ export declare function beginDeviceCodeFlow(region: string, clientId: string): Promise /** @@ -55,12 +55,12 @@ export declare function beginDeviceCodeFlow(region: string, clientId: string): P */ export declare function beginDeviceCodeFlowWithBaseUrl(region: string, clientId: string, baseUrl: string): Promise /** - * Variant of `provisionDeviceClient` that uses a custom profile directory. + * Variant of `bindClientDevice` that uses a custom profile directory. * * Intended for **testing only** — requires the crate to be built with the * `test-utils` Cargo feature. */ -export declare function provisionDeviceClientWithProfileDir(profileDir: string): Promise +export declare function bindClientDeviceWithProfileDir(profileDir: string): Promise /** * Save a test auth token to the given profile directory with the ZeroKMS * service URL set to `zerokms_base_url`. diff --git a/languages/typescript/packages/auth/index.js b/languages/typescript/packages/auth/index.js index 1f58224c6..d5f4e57e5 100644 --- a/languages/typescript/packages/auth/index.js +++ b/languages/typescript/packages/auth/index.js @@ -52,7 +52,7 @@ proto.openInBrowser = wrapSync(proto.openInBrowser); module.exports = { ...native, beginDeviceCodeFlow: wrapAsync(native.beginDeviceCodeFlow), - provisionDeviceClient: wrapAsync(native.provisionDeviceClient), + bindClientDevice: wrapAsync(native.bindClientDevice), }; if (native.beginDeviceCodeFlowWithBaseUrl) { @@ -61,8 +61,8 @@ if (native.beginDeviceCodeFlowWithBaseUrl) { ); } -if (native.provisionDeviceClientWithProfileDir) { - module.exports.provisionDeviceClientWithProfileDir = wrapAsync( - native.provisionDeviceClientWithProfileDir, +if (native.bindClientDeviceWithProfileDir) { + module.exports.bindClientDeviceWithProfileDir = wrapAsync( + native.bindClientDeviceWithProfileDir, ); } diff --git a/languages/typescript/packages/auth/src/lib.rs b/languages/typescript/packages/auth/src/lib.rs index 76dd061c9..7a729cc7b 100644 --- a/languages/typescript/packages/auth/src/lib.rs +++ b/languages/typescript/packages/auth/src/lib.rs @@ -179,10 +179,10 @@ fn device_client_to_napi_error(err: DeviceClientError) -> napi::Error { /// This is a no-op if the secret key already exists or the server returns /// 409 (conflict). #[napi] -pub async fn provision_device_client() -> Result<()> { +pub async fn bind_client_device() -> Result<()> { let store = stack_profile::ProfileStore::resolve(None) .map_err(|e| device_client_to_napi_error(DeviceClientError::from(e)))?; - stack_auth::provision_device_client(&store) + stack_auth::bind_client_device(&store) .await .map_err(device_client_to_napi_error) } @@ -486,9 +486,9 @@ pub async fn begin_device_code_flow_with_base_url( /// `test-utils` Cargo feature. #[cfg(feature = "test-utils")] #[napi] -pub async fn provision_device_client_with_profile_dir(profile_dir: String) -> Result<()> { +pub async fn bind_client_device_with_profile_dir(profile_dir: String) -> Result<()> { let store = stack_profile::ProfileStore::new(&profile_dir); - stack_auth::provision_device_client(&store) + stack_auth::bind_client_device(&store) .await .map_err(device_client_to_napi_error) } diff --git a/packages/stack-auth/src/device_client.rs b/packages/stack-auth/src/device_client.rs index 82f90c78c..174fbb4cb 100644 --- a/packages/stack-auth/src/device_client.rs +++ b/packages/stack-auth/src/device_client.rs @@ -78,7 +78,7 @@ pub enum DeviceClientError { /// /// If the secret key already exists on disk, or the server returns 409 /// (conflict), this is a no-op. -pub async fn provision_device_client(store: &ProfileStore) -> Result<(), DeviceClientError> { +pub async fn bind_client_device(store: &ProfileStore) -> Result<(), DeviceClientError> { if store.exists(SECRET_KEY_FILENAME) { tracing::debug!("secret key already exists, skipping provisioning"); return Ok(()); @@ -227,7 +227,7 @@ mod tests { let jwt = make_test_jwt(server.url("/")); save_test_token(&store, &jwt); - provision_device_client(&store).await.unwrap(); + bind_client_device(&store).await.unwrap(); let saved: serde_json::Value = store.load(SECRET_KEY_FILENAME).unwrap(); assert_eq!(saved["client_id"], "00000000-0000-0000-0000-000000000001"); @@ -249,7 +249,7 @@ mod tests { .unwrap(); // No mock server needed — the HTTP call should never happen. - provision_device_client(&store).await.unwrap(); + bind_client_device(&store).await.unwrap(); let saved: serde_json::Value = store.load(SECRET_KEY_FILENAME).unwrap(); assert_eq!( @@ -274,7 +274,7 @@ mod tests { let jwt = make_test_jwt(server.url("/")); save_test_token(&store, &jwt); - provision_device_client(&store).await.unwrap(); + bind_client_device(&store).await.unwrap(); assert!( !store.exists(SECRET_KEY_FILENAME), @@ -298,7 +298,7 @@ mod tests { let jwt = make_test_jwt(server.url("/")); save_test_token(&store, &jwt); - let err = provision_device_client(&store).await.unwrap_err(); + let err = bind_client_device(&store).await.unwrap_err(); assert!( matches!(err, DeviceClientError::Server { status: 500, .. }), "expected Server error, got: {err:?}" diff --git a/packages/stack-auth/src/lib.rs b/packages/stack-auth/src/lib.rs index 3a0e9d02e..7dd91d2e2 100644 --- a/packages/stack-auth/src/lib.rs +++ b/packages/stack-auth/src/lib.rs @@ -56,7 +56,7 @@ pub use service_token::ServiceToken; pub use static_token_strategy::StaticTokenStrategy; pub use token::Token; -pub use device_client::{provision_device_client, DeviceClientError}; +pub use device_client::{bind_client_device, DeviceClientError}; // Re-exports from stack-profile for backward compatibility. pub use stack_profile::DeviceIdentity; From 3d7383f403c0de87da39ecbdd73efa5a2eae2f9c Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 26 Mar 2026 21:22:50 +0000 Subject: [PATCH 133/686] chore: release --- packages/stack-auth/CHANGELOG.md | 19 +++++++++++++++++++ packages/stack-profile/CHANGELOG.md | 1 + packages/stack-profile/Cargo.toml | 2 +- 3 files changed, 21 insertions(+), 1 deletion(-) diff --git a/packages/stack-auth/CHANGELOG.md b/packages/stack-auth/CHANGELOG.md index 7f59cbeca..2b93d6486 100644 --- a/packages/stack-auth/CHANGELOG.md +++ b/packages/stack-auth/CHANGELOG.md @@ -1,5 +1,24 @@ +### Features + +- add provisionDeviceClient Node.js binding and tests + +### Fixes + +- lock file +- add User-Agent header, rename to device_client, surface errors + +### Miscellaneous + +- clean up test imports and simplify mise task + +### Refactoring + +- extract device client provisioning from CLI into stack-auth +- rename provisionDeviceClient to bindClientDevice + + ### Documentation - add README for stack-auth and include it as module docs diff --git a/packages/stack-profile/CHANGELOG.md b/packages/stack-profile/CHANGELOG.md index 46c94b9a8..958602352 100644 --- a/packages/stack-profile/CHANGELOG.md +++ b/packages/stack-profile/CHANGELOG.md @@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 + ## [0.34.0-alpha.1] - 2026-03-04 ### Changed diff --git a/packages/stack-profile/Cargo.toml b/packages/stack-profile/Cargo.toml index 8f0044300..4f286c4b8 100644 --- a/packages/stack-profile/Cargo.toml +++ b/packages/stack-profile/Cargo.toml @@ -2,7 +2,7 @@ name = "stack-profile" description = "Centralised ~/.cipherstash profile file management" license-file = "LICENSE" -version = "0.34.0-alpha.4" +version = "0.34.0-alpha.5" edition.workspace = true authors.workspace = true repository.workspace = true From 26d6150bd11fb0fcfe29fc1800ac141f27aa5094 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 26 Mar 2026 21:23:33 +0000 Subject: [PATCH 134/686] chore(deps-dev): bump picomatch in /packages/stack-auth/node Bumps [picomatch](https://github.com/micromatch/picomatch) from 4.0.3 to 4.0.4. - [Release notes](https://github.com/micromatch/picomatch/releases) - [Changelog](https://github.com/micromatch/picomatch/blob/master/CHANGELOG.md) - [Commits](https://github.com/micromatch/picomatch/compare/4.0.3...4.0.4) --- updated-dependencies: - dependency-name: picomatch dependency-version: 4.0.4 dependency-type: indirect ... Signed-off-by: dependabot[bot] --- languages/typescript/packages/auth/package-lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/languages/typescript/packages/auth/package-lock.json b/languages/typescript/packages/auth/package-lock.json index ea0e3a860..07c4472cc 100644 --- a/languages/typescript/packages/auth/package-lock.json +++ b/languages/typescript/packages/auth/package-lock.json @@ -1301,9 +1301,9 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", "engines": { From 6677ac3bdf4063cd0cde4555e884e66929505e80 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Thu, 26 Mar 2026 14:42:51 -0700 Subject: [PATCH 135/686] fix: add missing CHANGELOGs for recipher and stack-auth recipher was missing CHANGELOG.md entirely, causing release-plz to skip generating git release bodies. stack-auth's CHANGELOG was missing the standard header, making it unparseable by release-plz. --- packages/stack-auth/CHANGELOG.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/stack-auth/CHANGELOG.md b/packages/stack-auth/CHANGELOG.md index 2b93d6486..954f558d7 100644 --- a/packages/stack-auth/CHANGELOG.md +++ b/packages/stack-auth/CHANGELOG.md @@ -1,4 +1,9 @@ +# Changelog +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Features From 284e86b3767a47ed27999f810f4967f54767ee54 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Thu, 26 Mar 2026 14:46:24 -0700 Subject: [PATCH 136/686] chore: bump @cipherstash/auth to 0.34.2 --- languages/typescript/packages/auth/package.json | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/languages/typescript/packages/auth/package.json b/languages/typescript/packages/auth/package.json index 1a1ad972b..9493e08b7 100644 --- a/languages/typescript/packages/auth/package.json +++ b/languages/typescript/packages/auth/package.json @@ -1,6 +1,6 @@ { "name": "@cipherstash/auth", - "version": "0.34.1", + "version": "0.34.2", "main": "index.js", "types": "index.d.ts", "napi": { @@ -30,12 +30,12 @@ "test": "npm run build:test && vitest run" }, "optionalDependencies": { - "@cipherstash/auth-darwin-x64": "0.34.1", - "@cipherstash/auth-darwin-arm64": "0.34.1", - "@cipherstash/auth-linux-x64-gnu": "0.34.1", - "@cipherstash/auth-linux-arm64-gnu": "0.34.1", - "@cipherstash/auth-linux-x64-musl": "0.34.1", - "@cipherstash/auth-win32-x64-msvc": "0.34.1" + "@cipherstash/auth-darwin-x64": "0.34.2", + "@cipherstash/auth-darwin-arm64": "0.34.2", + "@cipherstash/auth-linux-x64-gnu": "0.34.2", + "@cipherstash/auth-linux-arm64-gnu": "0.34.2", + "@cipherstash/auth-linux-x64-musl": "0.34.2", + "@cipherstash/auth-win32-x64-msvc": "0.34.2" }, "devDependencies": { "@napi-rs/cli": "^2", From b7d859601adc27bb7c05d1af3e1d938faaababaa Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Wed, 1 Apr 2026 09:25:18 -0700 Subject: [PATCH 137/686] =?UTF-8?q?feat(auth):=20=E2=9C=A8=20expose=20auth?= =?UTF-8?q?=20strategies=20in=20@cipherstash/auth=20Node=20bindings?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add AutoStrategy, AccessKeyStrategy, and OAuthStrategy classes to the Node package so consumers can obtain tokens programmatically via `getToken()` which returns `{ token, issuer, services }`. - AutoStrategy.detect(options?) — auto-detect from env vars / profile - AccessKeyStrategy.create(region, accessKey) — static access key auth - OAuthStrategy.fromProfile() — OAuth with persisted refresh tokens - Add ServiceType.as_str(), Services.iter() to cts-common - Add ServiceToken.services() to stack-auth - Add NOT_AUTHENTICATED, MISSING_WORKSPACE_CRN, INVALID_ACCESS_KEY error codes --- languages/typescript/packages/auth/index.d.ts | 63 +++++++ languages/typescript/packages/auth/index.js | 21 ++- languages/typescript/packages/auth/src/lib.rs | 165 +++++++++++++++++- packages/stack-auth/src/service_token.rs | 13 ++ 4 files changed, 257 insertions(+), 5 deletions(-) diff --git a/languages/typescript/packages/auth/index.d.ts b/languages/typescript/packages/auth/index.d.ts index b6bf74797..2d0690d24 100644 --- a/languages/typescript/packages/auth/index.d.ts +++ b/languages/typescript/packages/auth/index.d.ts @@ -15,6 +15,9 @@ export type AuthErrorCode = | 'INVALID_TOKEN' | 'SERVER_ERROR' | 'STORE_ERROR' + | 'NOT_AUTHENTICATED' + | 'MISSING_WORKSPACE_CRN' + | 'INVALID_ACCESS_KEY' | 'UNKNOWN_ERROR' /** An error thrown by this package, enriched with a machine-readable `.code`. */ @@ -22,6 +25,66 @@ export interface AuthError extends Error { code: AuthErrorCode } +/** + * The result of a successful `getToken()` call. + * + * Contains the bearer credential and decoded JWT claims for service discovery. + */ +export interface TokenResult { + /** The bearer token string (used as `Authorization: Bearer `). */ + token: string + /** The issuer URL from the JWT `iss` claim (i.e. the CTS host). */ + issuer: string + /** Service endpoint URLs from the JWT `services` claim (e.g. `{ zerokms: "https://..." }`). */ + services: Record +} +/** Options for `AutoStrategy.detect()`. */ +export interface AutoStrategyOptions { + /** An explicit access key (takes precedence over `CS_CLIENT_ACCESS_KEY` env var). */ + accessKey?: string + /** An explicit workspace CRN (takes precedence over `CS_WORKSPACE_CRN` env var). */ + workspaceCrn?: string +} +/** + * An auth strategy that auto-detects credentials from environment variables + * and the local profile store. + * + * Detection order: + * 1. `CS_CLIENT_ACCESS_KEY` env var (or explicit `accessKey` option) → access key auth + * 2. `~/.cipherstash/auth.json` → OAuth token auth + * 3. Error: not authenticated + */ +export declare class AutoStrategy { + /** + * Detect available credentials and return an `AutoStrategy`. + * + * Pass options to provide explicit values that take precedence over + * environment variables. + */ + static detect(options?: AutoStrategyOptions): AutoStrategy + /** Retrieve a valid access token, refreshing or re-authenticating as needed. */ + getToken(): Promise +} +/** + * An auth strategy that uses a static access key for service-to-service + * or CI/CD authentication. + */ +export declare class AccessKeyStrategy { + /** Create a new `AccessKeyStrategy` for the given region and access key. */ + static create(region: string, accessKey: string): AccessKeyStrategy + /** Retrieve a valid access token, refreshing or re-authenticating as needed. */ + getToken(): Promise +} +/** + * An auth strategy that uses OAuth refresh tokens persisted to disk + * (`~/.cipherstash/auth.json`). + */ +export declare class OAuthStrategy { + /** Load credentials from the default profile store and create an `OAuthStrategy`. */ + static fromProfile(): OAuthStrategy + /** Retrieve a valid access token, refreshing as needed. */ + getToken(): Promise +} /** * Metadata returned after a successful device code authentication. * diff --git a/languages/typescript/packages/auth/index.js b/languages/typescript/packages/auth/index.js index d5f4e57e5..6e72720ad 100644 --- a/languages/typescript/packages/auth/index.js +++ b/languages/typescript/packages/auth/index.js @@ -44,9 +44,24 @@ function wrapSync(fn) { } // Patch DeviceCodeResult prototype methods -const proto = native.DeviceCodeResult.prototype; -proto.pollForToken = wrapAsync(proto.pollForToken); -proto.openInBrowser = wrapSync(proto.openInBrowser); +const dcProto = native.DeviceCodeResult.prototype; +dcProto.pollForToken = wrapAsync(dcProto.pollForToken); +dcProto.openInBrowser = wrapSync(dcProto.openInBrowser); + +// Patch strategy getToken methods +for (const Strategy of [native.AutoStrategy, native.AccessKeyStrategy, native.OAuthStrategy]) { + Strategy.prototype.getToken = wrapAsync(Strategy.prototype.getToken); +} + +// Wrap strategy factory methods (sync, can throw) +const origDetect = native.AutoStrategy.detect; +native.AutoStrategy.detect = wrapSync(origDetect); + +const origCreate = native.AccessKeyStrategy.create; +native.AccessKeyStrategy.create = wrapSync(origCreate); + +const origFromProfile = native.OAuthStrategy.fromProfile; +native.OAuthStrategy.fromProfile = wrapSync(origFromProfile); // Export wrapped top-level functions alongside native re-exports module.exports = { diff --git a/languages/typescript/packages/auth/src/lib.rs b/languages/typescript/packages/auth/src/lib.rs index 7a729cc7b..198adf11b 100644 --- a/languages/typescript/packages/auth/src/lib.rs +++ b/languages/typescript/packages/auth/src/lib.rs @@ -1,9 +1,13 @@ +use std::collections::HashMap; use std::sync::Mutex; use cts_common::Region; use napi::bindgen_prelude::*; use napi_derive::napi; -use stack_auth::{AuthError, DeviceClientError, DeviceCodeStrategy, PendingDeviceCode}; +use stack_auth::{ + AuthError, AuthStrategy, DeviceClientError, DeviceCodeStrategy, PendingDeviceCode, + ServiceToken, +}; #[cfg(feature = "test-utils")] mod mock_auth_server; @@ -24,6 +28,9 @@ fn error_code(err: &AuthError) -> &'static str { AuthError::InvalidToken(_) => "INVALID_TOKEN", AuthError::Server(_) => "SERVER_ERROR", AuthError::Store(_) => "STORE_ERROR", + AuthError::NotAuthenticated => "NOT_AUTHENTICATED", + AuthError::MissingWorkspaceCrn => "MISSING_WORKSPACE_CRN", + AuthError::InvalidAccessKey(_) => "INVALID_ACCESS_KEY", _ => "UNKNOWN_ERROR", } } @@ -34,7 +41,161 @@ fn to_napi_error(err: AuthError) -> napi::Error { } // --------------------------------------------------------------------------- -// TokenResult — plain data object +// TokenResult — returned by strategy.getToken() +// --------------------------------------------------------------------------- + +/// The result of a successful `getToken()` call. +/// +/// Contains the bearer credential and decoded JWT claims for service discovery. +#[derive(Debug)] +#[napi(object)] +pub struct TokenResult { + /// The bearer token string (used as `Authorization: Bearer `). + pub token: String, + /// The issuer URL from the JWT `iss` claim (i.e. the CTS host). + pub issuer: String, + /// Service endpoint URLs from the JWT `services` claim (e.g. `{ zerokms: "https://..." }`). + pub services: HashMap, +} + +fn token_result_from(token: ServiceToken) -> Result { + let issuer = token.issuer().map_err(to_napi_error)?.to_string(); + let services = token + .services() + .map_err(to_napi_error)? + .iter() + .map(|(k, v)| (k.as_str().to_string(), v.to_string())) + .collect(); + + Ok(TokenResult { + token: token.as_str().to_string(), + issuer, + services, + }) +} + +// --------------------------------------------------------------------------- +// AutoStrategy — auto-detect credentials +// --------------------------------------------------------------------------- + +/// Options for `AutoStrategy.detect()`. +#[derive(Debug)] +#[napi(object)] +pub struct AutoStrategyOptions { + /// An explicit access key (takes precedence over `CS_CLIENT_ACCESS_KEY` env var). + pub access_key: Option, + /// An explicit workspace CRN (takes precedence over `CS_WORKSPACE_CRN` env var). + pub workspace_crn: Option, +} + +/// An auth strategy that auto-detects credentials from environment variables +/// and the local profile store. +/// +/// Detection order: +/// 1. `CS_CLIENT_ACCESS_KEY` env var (or explicit `accessKey` option) → access key auth +/// 2. `~/.cipherstash/auth.json` → OAuth token auth +/// 3. Error: not authenticated +#[napi] +pub struct AutoStrategy { + inner: stack_auth::AutoStrategy, +} + +#[napi] +impl AutoStrategy { + /// Detect available credentials and return an `AutoStrategy`. + /// + /// Pass options to provide explicit values that take precedence over + /// environment variables. + #[napi(factory)] + pub fn detect(options: Option) -> Result { + let mut builder = stack_auth::AutoStrategy::builder(); + + if let Some(opts) = options { + if let Some(key) = opts.access_key { + builder = builder.with_access_key(key); + } + if let Some(crn_str) = opts.workspace_crn { + let crn = crn_str.parse().map_err(|e| to_napi_error(AuthError::InvalidCrn(e)))?; + builder = builder.with_workspace_crn(crn); + } + } + + let inner = builder.detect().map_err(to_napi_error)?; + Ok(Self { inner }) + } + + /// Retrieve a valid access token, refreshing or re-authenticating as needed. + #[napi] + pub async fn get_token(&self) -> Result { + let token = (&self.inner).get_token().await.map_err(to_napi_error)?; + token_result_from(token) + } +} + +// --------------------------------------------------------------------------- +// AccessKeyStrategy — static access key auth +// --------------------------------------------------------------------------- + +/// An auth strategy that uses a static access key for service-to-service +/// or CI/CD authentication. +#[napi] +pub struct AccessKeyStrategy { + inner: stack_auth::AccessKeyStrategy, +} + +#[napi] +impl AccessKeyStrategy { + /// Create a new `AccessKeyStrategy` for the given region and access key. + #[napi(factory)] + pub fn create(region: String, access_key: String) -> Result { + let region = Region::new(®ion).map_err(|e| to_napi_error(AuthError::from(e)))?; + let key: stack_auth::AccessKey = access_key.parse().map_err(|e| to_napi_error(AuthError::from(e)))?; + let inner = stack_auth::AccessKeyStrategy::new(region, key).map_err(to_napi_error)?; + Ok(Self { inner }) + } + + /// Retrieve a valid access token, refreshing or re-authenticating as needed. + #[napi] + pub async fn get_token(&self) -> Result { + let token = (&self.inner).get_token().await.map_err(to_napi_error)?; + token_result_from(token) + } +} + +// --------------------------------------------------------------------------- +// OAuthStrategy — OAuth with profile store +// --------------------------------------------------------------------------- + +/// An auth strategy that uses OAuth refresh tokens persisted to disk +/// (`~/.cipherstash/auth.json`). +#[napi] +pub struct OAuthStrategy { + inner: stack_auth::OAuthStrategy, +} + +#[napi] +impl OAuthStrategy { + /// Load credentials from the default profile store and create an `OAuthStrategy`. + #[napi(factory)] + pub fn from_profile() -> Result { + let store = stack_profile::ProfileStore::resolve(None) + .map_err(|e| to_napi_error(AuthError::from(e)))?; + let inner = stack_auth::OAuthStrategy::with_profile(store) + .build() + .map_err(to_napi_error)?; + Ok(Self { inner }) + } + + /// Retrieve a valid access token, refreshing as needed. + #[napi] + pub async fn get_token(&self) -> Result { + let token = (&self.inner).get_token().await.map_err(to_napi_error)?; + token_result_from(token) + } +} + +// --------------------------------------------------------------------------- +// AuthResult — plain data object (device code flow) // --------------------------------------------------------------------------- /// Metadata returned after a successful device code authentication. diff --git a/packages/stack-auth/src/service_token.rs b/packages/stack-auth/src/service_token.rs index 960400887..14020ea4d 100644 --- a/packages/stack-auth/src/service_token.rs +++ b/packages/stack-auth/src/service_token.rs @@ -71,6 +71,19 @@ impl ServiceToken { .map_err(|reason| AuthError::InvalidToken(reason.clone())) } + /// Return the decoded services map from the JWT claims. + /// + /// # Errors + /// + /// Returns [`AuthError::InvalidToken`] if the token is not a valid JWT or + /// the claims could not be decoded. + pub fn services(&self) -> Result<&Services, AuthError> { + self.decoded + .as_ref() + .map(|d| &d.services) + .map_err(|reason| AuthError::InvalidToken(reason.clone())) + } + /// Return the ZeroKMS endpoint URL from the `services` claim. /// /// CTS-issued JWTs include a `services` claim containing a map of service From 9c0a546bcd9f1ccd6f4e99d4f5724712f0b1c680 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Wed, 1 Apr 2026 09:45:16 -0700 Subject: [PATCH 138/686] =?UTF-8?q?test(auth):=20=E2=9C=85=20add=20unit=20?= =?UTF-8?q?tests=20for=20exposed=20auth=20strategies?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - cts-common: test ServiceType::as_str() and Services::iter() - stack-auth: test ServiceToken::services() for valid JWT, missing claims, and non-JWT tokens - stack-auth-node: test new error code mappings, token_result_from conversion, and strategy factory input validation errors --- languages/typescript/packages/auth/Cargo.toml | 1 + languages/typescript/packages/auth/src/lib.rs | 137 ++++++++++++++++++ packages/stack-auth/src/service_token.rs | 34 +++++ 3 files changed, 172 insertions(+) diff --git a/languages/typescript/packages/auth/Cargo.toml b/languages/typescript/packages/auth/Cargo.toml index 539ef24b1..15cf4d3d3 100644 --- a/languages/typescript/packages/auth/Cargo.toml +++ b/languages/typescript/packages/auth/Cargo.toml @@ -21,6 +21,7 @@ reqwest = { workspace = true, optional = true } [dev-dependencies] stack-auth = { workspace = true, features = ["test-utils"] } +jsonwebtoken = { workspace = true } mocktail = "0.3.0" serde_json = "1" tempfile = "3" diff --git a/languages/typescript/packages/auth/src/lib.rs b/languages/typescript/packages/auth/src/lib.rs index 198adf11b..66ab81afe 100644 --- a/languages/typescript/packages/auth/src/lib.rs +++ b/languages/typescript/packages/auth/src/lib.rs @@ -446,6 +446,21 @@ mod tests { ); } + #[test] + fn test_new_error_code_mapping() { + assert_eq!(error_code(&AuthError::NotAuthenticated), "NOT_AUTHENTICATED"); + assert_eq!( + error_code(&AuthError::MissingWorkspaceCrn), + "MISSING_WORKSPACE_CRN" + ); + assert_eq!( + error_code(&AuthError::InvalidAccessKey( + "bad-key".parse::().unwrap_err() + )), + "INVALID_ACCESS_KEY" + ); + } + #[test] fn test_napi_error_format() { let err = to_napi_error(AuthError::AccessDenied); @@ -616,6 +631,128 @@ mod tests { err.reason ); } + + // ---- token_result_from ---- + + fn make_service_token(iss: &str, zerokms_url: &str) -> ServiceToken { + use jsonwebtoken::{encode, EncodingKey, Header}; + use std::time::{SystemTime, UNIX_EPOCH}; + + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs(); + + let claims = serde_json::json!({ + "iss": iss, + "sub": "CS|test-user", + "aud": "test-aud", + "iat": now, + "exp": now + 3600, + "workspace": "ZVATKW3VHMFG27DY", + "scope": "", + "services": { "zerokms": zerokms_url }, + }); + + let jwt = encode( + &Header::default(), + &claims, + &EncodingKey::from_secret(b"test-secret"), + ) + .unwrap(); + + ServiceToken::new(stack_auth::SecretToken::new(jwt)) + } + + #[test] + fn test_token_result_from_valid_token() { + let service_token = + make_service_token("https://cts.example.com/", "https://zerokms.example.com/"); + let result = token_result_from(service_token).unwrap(); + + assert!(!result.token.is_empty()); + assert_eq!(result.issuer, "https://cts.example.com/"); + assert_eq!( + result.services.get("zerokms").map(String::as_str), + Some("https://zerokms.example.com/") + ); + } + + #[test] + fn test_token_result_from_non_jwt_errors() { + let token = ServiceToken::new(stack_auth::SecretToken::new("not-a-jwt")); + let err = token_result_from(token).unwrap_err(); + assert!( + err.reason.contains("INVALID_TOKEN: "), + "expected INVALID_TOKEN error, got: {}", + err.reason + ); + } + + // ---- Strategy factory error handling ---- + + /// Helper to extract the error from a `Result` without + /// requiring `T: Debug`. + fn expect_err(result: Result) -> napi::Error { + match result { + Err(e) => e, + Ok(_) => panic!("expected Err, got Ok"), + } + } + + #[test] + fn test_auto_strategy_detect_access_key_without_crn() { + let saved_key = std::env::var("CS_CLIENT_ACCESS_KEY").ok(); + let saved_crn = std::env::var("CS_WORKSPACE_CRN").ok(); + std::env::remove_var("CS_CLIENT_ACCESS_KEY"); + std::env::remove_var("CS_WORKSPACE_CRN"); + + let err = expect_err(AutoStrategy::detect(Some(AutoStrategyOptions { + access_key: Some("CSAKtestKeyId.testKeySecret".to_string()), + workspace_crn: None, + }))); + + if let Some(val) = saved_key { + std::env::set_var("CS_CLIENT_ACCESS_KEY", val); + } + if let Some(val) = saved_crn { + std::env::set_var("CS_WORKSPACE_CRN", val); + } + + assert!( + err.reason.contains("MISSING_WORKSPACE_CRN"), + "expected MISSING_WORKSPACE_CRN error, got: {}", + err.reason + ); + } + + #[test] + fn test_access_key_strategy_invalid_region() { + let err = expect_err(AccessKeyStrategy::create( + "not-a-region".to_string(), + "CSAKid.secret".to_string(), + )); + + assert!( + err.reason.contains("INVALID_REGION: "), + "expected INVALID_REGION error, got: {}", + err.reason + ); + } + + #[test] + fn test_access_key_strategy_invalid_key() { + let err = expect_err(AccessKeyStrategy::create( + "ap-southeast-2.aws".to_string(), + "not-a-valid-key".to_string(), + )); + + assert!( + err.reason.contains("INVALID_ACCESS_KEY: "), + "expected INVALID_ACCESS_KEY error, got: {}", + err.reason + ); + } } /// Variant of `beginDeviceCodeFlow` that targets a custom auth server URL. diff --git a/packages/stack-auth/src/service_token.rs b/packages/stack-auth/src/service_token.rs index 14020ea4d..1b26524cf 100644 --- a/packages/stack-auth/src/service_token.rs +++ b/packages/stack-auth/src/service_token.rs @@ -254,6 +254,40 @@ mod tests { assert!(token.zerokms_url().is_err()); } + #[test] + fn services_returns_map_for_valid_jwt() { + let jwt = make_jwt( + "https://cts.example.com/", + services_with_zerokms("https://zerokms.example.com/"), + ); + let token = ServiceToken::new(SecretToken::new(jwt)); + let services = token.services().unwrap(); + assert_eq!( + services + .get(cts_common::claims::ServiceType::ZeroKms) + .map(|u| u.as_str()), + Some("https://zerokms.example.com/") + ); + } + + #[test] + fn services_returns_empty_map_when_claim_missing() { + let jwt = make_jwt("https://cts.example.com/", None); + let token = ServiceToken::new(SecretToken::new(jwt)); + let services = token.services().unwrap(); + assert!(services.is_empty()); + } + + #[test] + fn services_errors_for_non_jwt() { + let token = ServiceToken::new(SecretToken::new("not-a-jwt")); + let err = token.services().unwrap_err().to_string(); + assert!( + err.contains("failed to decode JWT header"), + "expected specific decode error, got: {err}" + ); + } + #[test] fn debug_does_not_leak_secret() { let jwt = make_jwt( From 93be26f7b7e79a5be2ab86608f5f19f33ebc46ec Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Wed, 1 Apr 2026 09:54:40 -0700 Subject: [PATCH 139/686] =?UTF-8?q?refactor(auth):=20=E2=99=BB=EF=B8=8F=20?= =?UTF-8?q?restructure=20stack-auth-node=20tests=20to=20follow=20conventio?= =?UTF-8?q?ns?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Organize flat tests into nested scenario modules with expectation-style naming (e.g. `given_consumed_handle::poll_for_token_returns_consumed_error`) - Extract `assertions::has_error_code()` helper for repeated NAPI error code checks - Add descriptive messages to all assert calls - Merge duplicate error code mapping tests --- languages/typescript/packages/auth/src/lib.rs | 608 ++++++++++-------- 1 file changed, 327 insertions(+), 281 deletions(-) diff --git a/languages/typescript/packages/auth/src/lib.rs b/languages/typescript/packages/auth/src/lib.rs index 66ab81afe..10639831f 100644 --- a/languages/typescript/packages/auth/src/lib.rs +++ b/languages/typescript/packages/auth/src/lib.rs @@ -369,7 +369,7 @@ mod tests { use mocktail::prelude::*; use tempfile::TempDir; - // --- Mock response builders (mirrors stack-auth/src/device_code.rs) --- + // --- Shared helpers --- fn device_code_json() -> serde_json::Value { serde_json::json!({ @@ -422,83 +422,109 @@ mod tests { DeviceCodeResult::from_pending(pending) } - // ---- Error mapping (no mock server needed) ---- - - #[test] - fn test_error_code_mapping() { - assert_eq!(error_code(&AuthError::AccessDenied), "ACCESS_DENIED"); - assert_eq!(error_code(&AuthError::TokenExpired), "EXPIRED_TOKEN"); - assert_eq!(error_code(&AuthError::InvalidGrant), "INVALID_GRANT"); - assert_eq!(error_code(&AuthError::InvalidClient), "INVALID_CLIENT"); - assert_eq!( - error_code(&AuthError::InvalidUrl( - "http://[".parse::().unwrap_err() - )), - "INVALID_URL" - ); - assert_eq!( - error_code(&AuthError::Region(Region::new("invalid").unwrap_err())), - "INVALID_REGION" - ); - assert_eq!( - error_code(&AuthError::Server("test".to_string())), - "SERVER_ERROR" - ); - } + fn make_service_token(iss: &str, zerokms_url: &str) -> ServiceToken { + use jsonwebtoken::{encode, EncodingKey, Header}; + use std::time::{SystemTime, UNIX_EPOCH}; - #[test] - fn test_new_error_code_mapping() { - assert_eq!(error_code(&AuthError::NotAuthenticated), "NOT_AUTHENTICATED"); - assert_eq!( - error_code(&AuthError::MissingWorkspaceCrn), - "MISSING_WORKSPACE_CRN" - ); - assert_eq!( - error_code(&AuthError::InvalidAccessKey( - "bad-key".parse::().unwrap_err() - )), - "INVALID_ACCESS_KEY" - ); + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs(); + + let claims = serde_json::json!({ + "iss": iss, + "sub": "CS|test-user", + "aud": "test-aud", + "iat": now, + "exp": now + 3600, + "workspace": "ZVATKW3VHMFG27DY", + "scope": "", + "services": { "zerokms": zerokms_url }, + }); + + let jwt = encode( + &Header::default(), + &claims, + &EncodingKey::from_secret(b"test-secret"), + ) + .unwrap(); + + ServiceToken::new(stack_auth::SecretToken::new(jwt)) } - #[test] - fn test_napi_error_format() { - let err = to_napi_error(AuthError::AccessDenied); - assert!( - err.reason.starts_with("ACCESS_DENIED: "), - "expected 'ACCESS_DENIED: ...' but got: {}", - err.reason - ); - - let err = to_napi_error(AuthError::Server("something broke".to_string())); - assert!( - err.reason.starts_with("SERVER_ERROR: "), - "expected 'SERVER_ERROR: ...' but got: {}", - err.reason - ); + /// Extract the error from a `Result` without requiring + /// `T: Debug` (NAPI wrapper structs don't implement it). + fn expect_err(result: Result) -> napi::Error { + match result { + Err(e) => e, + Ok(_) => panic!("expected Err, got Ok"), + } } - // ---- Getters (mock server needed to create a real PendingDeviceCode) ---- + mod assertions { + /// Assert that a NAPI error's reason contains the expected error code prefix. + pub(super) fn has_error_code(err: &napi::Error, expected_code: &str) { + assert!( + err.reason.contains(&format!("{expected_code}: ")), + "expected '{expected_code}: ...' but got: {}", + err.reason + ); + } + } - #[tokio::test] - async fn test_getters() { - let dir = TempDir::new().unwrap(); - let mut mocks = MockSet::new(); - mock_code_endpoint(&mut mocks); - let server = start_server(mocks).await; + // --- Error mapping --- + + mod error_mapping { + use super::*; + + #[test] + fn maps_all_auth_error_variants() { + assert_eq!(error_code(&AuthError::AccessDenied), "ACCESS_DENIED", + "AccessDenied should map to ACCESS_DENIED"); + assert_eq!(error_code(&AuthError::TokenExpired), "EXPIRED_TOKEN", + "TokenExpired should map to EXPIRED_TOKEN"); + assert_eq!(error_code(&AuthError::InvalidGrant), "INVALID_GRANT", + "InvalidGrant should map to INVALID_GRANT"); + assert_eq!(error_code(&AuthError::InvalidClient), "INVALID_CLIENT", + "InvalidClient should map to INVALID_CLIENT"); + assert_eq!( + error_code(&AuthError::InvalidUrl( + "http://[".parse::().unwrap_err() + )), + "INVALID_URL", + "InvalidUrl should map to INVALID_URL" + ); + assert_eq!( + error_code(&AuthError::Region(Region::new("invalid").unwrap_err())), + "INVALID_REGION", + "Region should map to INVALID_REGION" + ); + assert_eq!(error_code(&AuthError::Server("test".to_string())), "SERVER_ERROR", + "Server should map to SERVER_ERROR"); + assert_eq!(error_code(&AuthError::NotAuthenticated), "NOT_AUTHENTICATED", + "NotAuthenticated should map to NOT_AUTHENTICATED"); + assert_eq!(error_code(&AuthError::MissingWorkspaceCrn), "MISSING_WORKSPACE_CRN", + "MissingWorkspaceCrn should map to MISSING_WORKSPACE_CRN"); + assert_eq!( + error_code(&AuthError::InvalidAccessKey( + "bad-key".parse::().unwrap_err() + )), + "INVALID_ACCESS_KEY", + "InvalidAccessKey should map to INVALID_ACCESS_KEY" + ); + } - let result = begin_result(&server, &dir).await; + #[test] + fn formats_as_code_colon_message() { + let err = to_napi_error(AuthError::AccessDenied); + assertions::has_error_code(&err, "ACCESS_DENIED"); - assert_eq!(result.user_code(), "ABCD-EFGH"); - assert_eq!(result.verification_uri(), "http://example.com/activate"); - assert_eq!( - result.verification_uri_complete(), - "http://example.com/activate?user_code=ABCD-EFGH" - ); - assert_eq!(result.expires_in(), 900.0); + let err = to_napi_error(AuthError::Server("something broke".to_string())); + assertions::has_error_code(&err, "SERVER_ERROR"); + } } - // ---- Full flow with mock server ---- + // --- Device code result --- // // `start_paused = true` creates a tokio runtime where the internal clock // is paused. Timer operations like `tokio::time::sleep` advance the clock @@ -507,251 +533,271 @@ mod tests { // time these tests would take 5+ real seconds each. I/O (HTTP requests // to the mock server) still works normally. - #[tokio::test(start_paused = true)] - async fn test_poll_for_token_success() { - let dir = TempDir::new().unwrap(); - let mut mocks = MockSet::new(); - mock_code_endpoint(&mut mocks); - mocks.mock(|when, then| { - when.post().path("/oauth/device/token"); - then.json(token_json()); - }); - let server = start_server(mocks).await; - - let result = begin_result(&server, &dir).await; - let token = result.poll_for_token().await.unwrap(); + mod device_code_result { + use super::*; + + mod given_pending_device_code { + use super::*; + + #[tokio::test] + async fn exposes_getters() { + let dir = TempDir::new().unwrap(); + let mut mocks = MockSet::new(); + mock_code_endpoint(&mut mocks); + let server = start_server(mocks).await; + + let result = begin_result(&server, &dir).await; + + assert_eq!(result.user_code(), "ABCD-EFGH", + "user_code should match device code response"); + assert_eq!(result.verification_uri(), "http://example.com/activate", + "verification_uri should match device code response"); + assert_eq!(result.verification_uri_complete(), + "http://example.com/activate?user_code=ABCD-EFGH", + "verification_uri_complete should include user code"); + assert_eq!(result.expires_in(), 900.0, + "expires_in should match device code response"); + } - assert!(token.expires_in >= 3598.0 && token.expires_in <= 3600.0); - assert!(token.expires_at > 0.0); - } + mod given_successful_token_exchange { + use super::*; + + #[tokio::test(start_paused = true)] + async fn returns_expiry_metadata() { + let dir = TempDir::new().unwrap(); + let mut mocks = MockSet::new(); + mock_code_endpoint(&mut mocks); + mocks.mock(|when, then| { + when.post().path("/oauth/device/token"); + then.json(token_json()); + }); + let server = start_server(mocks).await; + + let result = begin_result(&server, &dir).await; + let token = result.poll_for_token().await.unwrap(); + + assert!(token.expires_in >= 3598.0 && token.expires_in <= 3600.0, + "expires_in should be ~3600, got: {}", token.expires_in); + assert!(token.expires_at > 0.0, + "expires_at should be a positive epoch timestamp"); + } + } - #[tokio::test(start_paused = true)] - async fn test_poll_for_token_error_propagation() { - let dir = TempDir::new().unwrap(); - let mut mocks = MockSet::new(); - mock_code_endpoint(&mut mocks); - mocks.mock(|when, then| { - when.post().path("/oauth/device/token"); - then.bad_request().json(error_json("access_denied")); - }); - let server = start_server(mocks).await; + mod given_access_denied { + use super::*; + + #[tokio::test(start_paused = true)] + async fn returns_access_denied_error() { + let dir = TempDir::new().unwrap(); + let mut mocks = MockSet::new(); + mock_code_endpoint(&mut mocks); + mocks.mock(|when, then| { + when.post().path("/oauth/device/token"); + then.bad_request().json(error_json("access_denied")); + }); + let server = start_server(mocks).await; + + let result = begin_result(&server, &dir).await; + let err = result.poll_for_token().await.unwrap_err(); + + assertions::has_error_code(&err, "ACCESS_DENIED"); + } + } - let result = begin_result(&server, &dir).await; - let err = result.poll_for_token().await.unwrap_err(); + mod given_expired_token { + use super::*; + + #[tokio::test(start_paused = true)] + async fn returns_expired_token_error() { + let dir = TempDir::new().unwrap(); + let mut mocks = MockSet::new(); + mock_code_endpoint(&mut mocks); + mocks.mock(|when, then| { + when.post().path("/oauth/device/token"); + then.bad_request().json(error_json("expired_token")); + }); + let server = start_server(mocks).await; + + let result = begin_result(&server, &dir).await; + let err = result.poll_for_token().await.unwrap_err(); + + assertions::has_error_code(&err, "EXPIRED_TOKEN"); + } + } - assert!( - err.reason.contains("ACCESS_DENIED: "), - "expected ACCESS_DENIED error, got: {}", - err.reason - ); - } + mod given_consumed_handle { + use super::*; + + async fn consumed_result(server: &MockServer, dir: &TempDir) -> DeviceCodeResult { + let result = begin_result(server, dir).await; + result.poll_for_token().await.unwrap(); + result + } + + #[tokio::test(start_paused = true)] + async fn poll_for_token_returns_consumed_error() { + let dir = TempDir::new().unwrap(); + let mut mocks = MockSet::new(); + mock_code_endpoint(&mut mocks); + mocks.mock(|when, then| { + when.post().path("/oauth/device/token"); + then.json(token_json()); + }); + let server = start_server(mocks).await; + + let result = consumed_result(&server, &dir).await; + let err = result.poll_for_token().await.unwrap_err(); + + assert!( + err.reason.contains("already been consumed"), + "second poll_for_token call should fail with consumed error, got: {}", + err.reason + ); + } + + #[tokio::test(start_paused = true)] + async fn open_in_browser_returns_consumed_error() { + let dir = TempDir::new().unwrap(); + let mut mocks = MockSet::new(); + mock_code_endpoint(&mut mocks); + mocks.mock(|when, then| { + when.post().path("/oauth/device/token"); + then.json(token_json()); + }); + let server = start_server(mocks).await; + + let result = consumed_result(&server, &dir).await; + let err = result.open_in_browser().unwrap_err(); + + assert!( + err.reason.contains("already been consumed"), + "open_in_browser after consume should fail, got: {}", + err.reason + ); + } + } + } - #[tokio::test(start_paused = true)] - async fn test_poll_for_token_expired() { - let dir = TempDir::new().unwrap(); - let mut mocks = MockSet::new(); - mock_code_endpoint(&mut mocks); - mocks.mock(|when, then| { - when.post().path("/oauth/device/token"); - then.bad_request().json(error_json("expired_token")); - }); - let server = start_server(mocks).await; + mod given_invalid_region { + use super::*; - let result = begin_result(&server, &dir).await; - let err = result.poll_for_token().await.unwrap_err(); + #[tokio::test] + async fn returns_invalid_region_error() { + let err = begin_device_code_flow( + "not-a-region".to_string(), + "test-client".to_string(), + ) + .await + .unwrap_err(); - assert!( - err.reason.contains("EXPIRED_TOKEN: "), - "expected EXPIRED_TOKEN error, got: {}", - err.reason - ); + assertions::has_error_code(&err, "INVALID_REGION"); + } + } } - // ---- Consumed handle semantics ---- + // --- token_result_from --- - #[tokio::test(start_paused = true)] - async fn test_poll_for_token_already_consumed() { - let dir = TempDir::new().unwrap(); - let mut mocks = MockSet::new(); - mock_code_endpoint(&mut mocks); - mocks.mock(|when, then| { - when.post().path("/oauth/device/token"); - then.json(token_json()); - }); - let server = start_server(mocks).await; - - let result = begin_result(&server, &dir).await; - // First call succeeds — consumes the handle - result.poll_for_token().await.unwrap(); - // Second call should fail — handle already consumed - let err = result.poll_for_token().await.unwrap_err(); - - assert!( - err.reason.contains("already been consumed"), - "expected 'already been consumed' error, got: {}", - err.reason - ); - } + mod token_result { + use super::*; - #[tokio::test(start_paused = true)] - async fn test_open_in_browser_after_consumed() { - let dir = TempDir::new().unwrap(); - let mut mocks = MockSet::new(); - mock_code_endpoint(&mut mocks); - mocks.mock(|when, then| { - when.post().path("/oauth/device/token"); - then.json(token_json()); - }); - let server = start_server(mocks).await; - - let result = begin_result(&server, &dir).await; - // Consume the handle - result.poll_for_token().await.unwrap(); - // open_in_browser should fail — handle consumed - let err = result.open_in_browser().unwrap_err(); - - assert!( - err.reason.contains("already been consumed"), - "expected 'already been consumed' error, got: {}", - err.reason - ); - } + mod given_valid_jwt { + use super::*; - // ---- Top-level function error handling ---- + #[test] + fn includes_bearer_token_and_claims() { + let service_token = make_service_token( + "https://cts.example.com/", + "https://zerokms.example.com/", + ); + let result = token_result_from(service_token).unwrap(); - #[tokio::test] - async fn test_begin_invalid_region() { - let err = begin_device_code_flow("not-a-region".to_string(), "test-client".to_string()) - .await - .unwrap_err(); + assert!(!result.token.is_empty(), + "token string should not be empty"); + assert_eq!(result.issuer, "https://cts.example.com/", + "issuer should match JWT iss claim"); + assert_eq!( + result.services.get("zerokms").map(String::as_str), + Some("https://zerokms.example.com/"), + "services should include zerokms endpoint" + ); + } + } - assert!( - err.reason.contains("INVALID_REGION: "), - "expected INVALID_REGION error, got: {}", - err.reason - ); - } + mod given_non_jwt { + use super::*; - // ---- token_result_from ---- + #[test] + fn returns_invalid_token_error() { + let token = ServiceToken::new(stack_auth::SecretToken::new("not-a-jwt")); + let err = token_result_from(token).unwrap_err(); - fn make_service_token(iss: &str, zerokms_url: &str) -> ServiceToken { - use jsonwebtoken::{encode, EncodingKey, Header}; - use std::time::{SystemTime, UNIX_EPOCH}; - - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap() - .as_secs(); + assertions::has_error_code(&err, "INVALID_TOKEN"); + } + } + } - let claims = serde_json::json!({ - "iss": iss, - "sub": "CS|test-user", - "aud": "test-aud", - "iat": now, - "exp": now + 3600, - "workspace": "ZVATKW3VHMFG27DY", - "scope": "", - "services": { "zerokms": zerokms_url }, - }); + // --- Strategy factories --- - let jwt = encode( - &Header::default(), - &claims, - &EncodingKey::from_secret(b"test-secret"), - ) - .unwrap(); + mod auto_strategy_detect { + use super::*; - ServiceToken::new(stack_auth::SecretToken::new(jwt)) - } + mod given_access_key_without_crn { + use super::*; - #[test] - fn test_token_result_from_valid_token() { - let service_token = - make_service_token("https://cts.example.com/", "https://zerokms.example.com/"); - let result = token_result_from(service_token).unwrap(); - - assert!(!result.token.is_empty()); - assert_eq!(result.issuer, "https://cts.example.com/"); - assert_eq!( - result.services.get("zerokms").map(String::as_str), - Some("https://zerokms.example.com/") - ); - } + #[test] + fn returns_missing_workspace_crn_error() { + let saved_key = std::env::var("CS_CLIENT_ACCESS_KEY").ok(); + let saved_crn = std::env::var("CS_WORKSPACE_CRN").ok(); + std::env::remove_var("CS_CLIENT_ACCESS_KEY"); + std::env::remove_var("CS_WORKSPACE_CRN"); - #[test] - fn test_token_result_from_non_jwt_errors() { - let token = ServiceToken::new(stack_auth::SecretToken::new("not-a-jwt")); - let err = token_result_from(token).unwrap_err(); - assert!( - err.reason.contains("INVALID_TOKEN: "), - "expected INVALID_TOKEN error, got: {}", - err.reason - ); - } + let err = expect_err(AutoStrategy::detect(Some(AutoStrategyOptions { + access_key: Some("CSAKtestKeyId.testKeySecret".to_string()), + workspace_crn: None, + }))); - // ---- Strategy factory error handling ---- + if let Some(val) = saved_key { + std::env::set_var("CS_CLIENT_ACCESS_KEY", val); + } + if let Some(val) = saved_crn { + std::env::set_var("CS_WORKSPACE_CRN", val); + } - /// Helper to extract the error from a `Result` without - /// requiring `T: Debug`. - fn expect_err(result: Result) -> napi::Error { - match result { - Err(e) => e, - Ok(_) => panic!("expected Err, got Ok"), + assertions::has_error_code(&err, "MISSING_WORKSPACE_CRN"); + } } } - #[test] - fn test_auto_strategy_detect_access_key_without_crn() { - let saved_key = std::env::var("CS_CLIENT_ACCESS_KEY").ok(); - let saved_crn = std::env::var("CS_WORKSPACE_CRN").ok(); - std::env::remove_var("CS_CLIENT_ACCESS_KEY"); - std::env::remove_var("CS_WORKSPACE_CRN"); + mod access_key_strategy_create { + use super::*; - let err = expect_err(AutoStrategy::detect(Some(AutoStrategyOptions { - access_key: Some("CSAKtestKeyId.testKeySecret".to_string()), - workspace_crn: None, - }))); + mod given_invalid_region { + use super::*; - if let Some(val) = saved_key { - std::env::set_var("CS_CLIENT_ACCESS_KEY", val); - } - if let Some(val) = saved_crn { - std::env::set_var("CS_WORKSPACE_CRN", val); + #[test] + fn returns_invalid_region_error() { + let err = expect_err(AccessKeyStrategy::create( + "not-a-region".to_string(), + "CSAKid.secret".to_string(), + )); + + assertions::has_error_code(&err, "INVALID_REGION"); + } } - assert!( - err.reason.contains("MISSING_WORKSPACE_CRN"), - "expected MISSING_WORKSPACE_CRN error, got: {}", - err.reason - ); - } + mod given_invalid_key { + use super::*; - #[test] - fn test_access_key_strategy_invalid_region() { - let err = expect_err(AccessKeyStrategy::create( - "not-a-region".to_string(), - "CSAKid.secret".to_string(), - )); - - assert!( - err.reason.contains("INVALID_REGION: "), - "expected INVALID_REGION error, got: {}", - err.reason - ); - } + #[test] + fn returns_invalid_access_key_error() { + let err = expect_err(AccessKeyStrategy::create( + "ap-southeast-2.aws".to_string(), + "not-a-valid-key".to_string(), + )); - #[test] - fn test_access_key_strategy_invalid_key() { - let err = expect_err(AccessKeyStrategy::create( - "ap-southeast-2.aws".to_string(), - "not-a-valid-key".to_string(), - )); - - assert!( - err.reason.contains("INVALID_ACCESS_KEY: "), - "expected INVALID_ACCESS_KEY error, got: {}", - err.reason - ); + assertions::has_error_code(&err, "INVALID_ACCESS_KEY"); + } + } } } From eca17d440c7b10004356c95d64fa4bffee08fa2e Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Wed, 1 Apr 2026 09:54:46 -0700 Subject: [PATCH 140/686] =?UTF-8?q?docs(auth):=20=F0=9F=93=9D=20add=20Type?= =?UTF-8?q?Script=20example=20for=20AutoStrategy=20usage?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../packages/auth/examples/auto-strategy.ts | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 languages/typescript/packages/auth/examples/auto-strategy.ts diff --git a/languages/typescript/packages/auth/examples/auto-strategy.ts b/languages/typescript/packages/auth/examples/auto-strategy.ts new file mode 100644 index 000000000..0763eacb1 --- /dev/null +++ b/languages/typescript/packages/auth/examples/auto-strategy.ts @@ -0,0 +1,42 @@ +// Example: Auto-detect credentials and retrieve a service token. +// +// `AutoStrategy` picks the best available authentication method: +// +// 1. Access key — if `CS_CLIENT_ACCESS_KEY` is set (along with +// `CS_WORKSPACE_CRN`), access key auth is used. +// 2. OAuth — if `~/.cipherstash/auth.json` exists (written by +// `stash login`), OAuth token auth is used. +// 3. If neither is available, a `NOT_AUTHENTICATED` error is thrown. +// +// Prerequisites: +// 1. Build the native module: npm run build +// +// Usage (after `stash login`): +// npx tsx examples/auto-strategy.ts +// +// Usage (with an access key): +// CS_CLIENT_ACCESS_KEY= CS_WORKSPACE_CRN= npx tsx examples/auto-strategy.ts + +import { AutoStrategy } from "../index"; +import type { AuthError } from "../index"; + +async function main() { + // Detect credentials automatically from env vars / profile store. + // You can also pass explicit values: + // + // AutoStrategy.detect({ accessKey: "CSAK...", workspaceCrn: "crn:..." }) + // + const strategy = AutoStrategy.detect(); + + // Retrieve a token — refresh happens automatically when needed. + const result = await strategy.getToken(); + + console.log(`Issuer: ${result.issuer}`); + console.log(`Services: ${JSON.stringify(result.services)}`); + console.log(`Token: ${result.token.slice(0, 20)}...`); +} + +main().catch((err: AuthError) => { + console.error(err.code ? `[${err.code}] ${err.message}` : err.message); + process.exit(1); +}); From 2bbd63ab956ffb87baaf8d108161406643feba74 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Wed, 1 Apr 2026 09:58:44 -0700 Subject: [PATCH 141/686] =?UTF-8?q?chore(auth):=20=F0=9F=94=96=20bump=20@c?= =?UTF-8?q?ipherstash/auth=20to=200.35.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- languages/typescript/packages/auth/package.json | 14 +++++++------- .../auth/platforms/darwin-arm64/package.json | 2 +- .../auth/platforms/darwin-x64/package.json | 2 +- .../auth/platforms/linux-arm64-gnu/package.json | 2 +- .../auth/platforms/linux-x64-gnu/package.json | 2 +- .../auth/platforms/linux-x64-musl/package.json | 2 +- .../auth/platforms/win32-x64-msvc/package.json | 2 +- 7 files changed, 13 insertions(+), 13 deletions(-) diff --git a/languages/typescript/packages/auth/package.json b/languages/typescript/packages/auth/package.json index 9493e08b7..6e952fb11 100644 --- a/languages/typescript/packages/auth/package.json +++ b/languages/typescript/packages/auth/package.json @@ -1,6 +1,6 @@ { "name": "@cipherstash/auth", - "version": "0.34.2", + "version": "0.35.0", "main": "index.js", "types": "index.d.ts", "napi": { @@ -30,12 +30,12 @@ "test": "npm run build:test && vitest run" }, "optionalDependencies": { - "@cipherstash/auth-darwin-x64": "0.34.2", - "@cipherstash/auth-darwin-arm64": "0.34.2", - "@cipherstash/auth-linux-x64-gnu": "0.34.2", - "@cipherstash/auth-linux-arm64-gnu": "0.34.2", - "@cipherstash/auth-linux-x64-musl": "0.34.2", - "@cipherstash/auth-win32-x64-msvc": "0.34.2" + "@cipherstash/auth-darwin-x64": "0.35.0", + "@cipherstash/auth-darwin-arm64": "0.35.0", + "@cipherstash/auth-linux-x64-gnu": "0.35.0", + "@cipherstash/auth-linux-arm64-gnu": "0.35.0", + "@cipherstash/auth-linux-x64-musl": "0.35.0", + "@cipherstash/auth-win32-x64-msvc": "0.35.0" }, "devDependencies": { "@napi-rs/cli": "^2", diff --git a/languages/typescript/packages/auth/platforms/darwin-arm64/package.json b/languages/typescript/packages/auth/platforms/darwin-arm64/package.json index ca97173ad..f9241ea76 100644 --- a/languages/typescript/packages/auth/platforms/darwin-arm64/package.json +++ b/languages/typescript/packages/auth/platforms/darwin-arm64/package.json @@ -1,6 +1,6 @@ { "name": "@cipherstash/auth-darwin-arm64", - "version": "0.34.1", + "version": "0.35.0", "os": [ "darwin" ], diff --git a/languages/typescript/packages/auth/platforms/darwin-x64/package.json b/languages/typescript/packages/auth/platforms/darwin-x64/package.json index e7b6d962d..944b12a11 100644 --- a/languages/typescript/packages/auth/platforms/darwin-x64/package.json +++ b/languages/typescript/packages/auth/platforms/darwin-x64/package.json @@ -1,6 +1,6 @@ { "name": "@cipherstash/auth-darwin-x64", - "version": "0.34.1", + "version": "0.35.0", "os": [ "darwin" ], diff --git a/languages/typescript/packages/auth/platforms/linux-arm64-gnu/package.json b/languages/typescript/packages/auth/platforms/linux-arm64-gnu/package.json index 2be8b1752..5ac1fb8f1 100644 --- a/languages/typescript/packages/auth/platforms/linux-arm64-gnu/package.json +++ b/languages/typescript/packages/auth/platforms/linux-arm64-gnu/package.json @@ -1,6 +1,6 @@ { "name": "@cipherstash/auth-linux-arm64-gnu", - "version": "0.34.1", + "version": "0.35.0", "os": [ "linux" ], diff --git a/languages/typescript/packages/auth/platforms/linux-x64-gnu/package.json b/languages/typescript/packages/auth/platforms/linux-x64-gnu/package.json index eb79832c9..f838ea865 100644 --- a/languages/typescript/packages/auth/platforms/linux-x64-gnu/package.json +++ b/languages/typescript/packages/auth/platforms/linux-x64-gnu/package.json @@ -1,6 +1,6 @@ { "name": "@cipherstash/auth-linux-x64-gnu", - "version": "0.34.1", + "version": "0.35.0", "os": [ "linux" ], diff --git a/languages/typescript/packages/auth/platforms/linux-x64-musl/package.json b/languages/typescript/packages/auth/platforms/linux-x64-musl/package.json index e4cbbd14a..08a6b7487 100644 --- a/languages/typescript/packages/auth/platforms/linux-x64-musl/package.json +++ b/languages/typescript/packages/auth/platforms/linux-x64-musl/package.json @@ -1,6 +1,6 @@ { "name": "@cipherstash/auth-linux-x64-musl", - "version": "0.34.1", + "version": "0.35.0", "os": [ "linux" ], diff --git a/languages/typescript/packages/auth/platforms/win32-x64-msvc/package.json b/languages/typescript/packages/auth/platforms/win32-x64-msvc/package.json index a4d29e434..c87585df9 100644 --- a/languages/typescript/packages/auth/platforms/win32-x64-msvc/package.json +++ b/languages/typescript/packages/auth/platforms/win32-x64-msvc/package.json @@ -1,6 +1,6 @@ { "name": "@cipherstash/auth-win32-x64-msvc", - "version": "0.34.1", + "version": "0.35.0", "os": [ "win32" ], From 1ff1d463a8545312afa001ff45d2aa618b4fee92 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Wed, 1 Apr 2026 10:07:10 -0700 Subject: [PATCH 142/686] =?UTF-8?q?chore(auth):=20=F0=9F=94=A7=20regenerat?= =?UTF-8?q?e=20index.d.ts=20from=20napi=20build?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `napi build --release` regenerated the TypeScript definitions with the correct types for the new strategy classes. Restores the manually- written AuthErrorCode and AuthError types that the JS wrapper provides. --- languages/typescript/packages/auth/index.d.ts | 106 +++++------------- 1 file changed, 27 insertions(+), 79 deletions(-) diff --git a/languages/typescript/packages/auth/index.d.ts b/languages/typescript/packages/auth/index.d.ts index 2d0690d24..54d131bfe 100644 --- a/languages/typescript/packages/auth/index.d.ts +++ b/languages/typescript/packages/auth/index.d.ts @@ -1,7 +1,7 @@ /* tslint:disable */ /* eslint-disable */ -/* auto-generated by NAPI-RS */ +/* auto-generated by NAPI-RS, with manual additions for error enrichment */ /** Error codes attached to errors thrown by this package. */ export type AuthErrorCode = @@ -45,6 +45,31 @@ export interface AutoStrategyOptions { /** An explicit workspace CRN (takes precedence over `CS_WORKSPACE_CRN` env var). */ workspaceCrn?: string } +/** + * Metadata returned after a successful device code authentication. + * + * The actual token is never exposed to JavaScript — it is saved directly + * to `~/.cipherstash/auth.json` by the Rust layer. + */ +export interface AuthResult { + /** Absolute epoch timestamp (seconds) when the token expires. */ + expiresAt: number + /** Number of seconds before the token expires (computed at time of return). */ + expiresIn: number +} +/** + * Provision a device client in ZeroKMS after login. + * + * Loads the auth token and device identity from `~/.cipherstash/`, + * creates a client on the workspace's default keyset, and persists the + * resulting secret key to `~/.cipherstash/secretkey.json`. + * + * This is a no-op if the secret key already exists or the server returns + * 409 (conflict). + */ +export declare function bindClientDevice(): Promise +/** Begin the OAuth 2.0 Device Authorization flow. */ +export declare function beginDeviceCodeFlow(region: string, clientId: string): Promise /** * An auth strategy that auto-detects credentials from environment variables * and the local profile store. @@ -61,7 +86,7 @@ export declare class AutoStrategy { * Pass options to provide explicit values that take precedence over * environment variables. */ - static detect(options?: AutoStrategyOptions): AutoStrategy + static detect(options?: AutoStrategyOptions | undefined | null): AutoStrategy /** Retrieve a valid access token, refreshing or re-authenticating as needed. */ getToken(): Promise } @@ -85,83 +110,6 @@ export declare class OAuthStrategy { /** Retrieve a valid access token, refreshing as needed. */ getToken(): Promise } -/** - * Metadata returned after a successful device code authentication. - * - * The actual token is never exposed to JavaScript — it is saved directly - * to `~/.cipherstash/auth.json` by the Rust layer. - */ -export interface AuthResult { - /** Absolute epoch timestamp (seconds) when the token expires. */ - expiresAt: number - /** Number of seconds before the token expires (computed at time of return). */ - expiresIn: number -} -/** - * Provision a device client in ZeroKMS after login. - * - * Loads the auth token and device identity from `~/.cipherstash/`, - * creates a client on the workspace's default keyset, and persists the - * resulting secret key to `~/.cipherstash/secretkey.json`. - * - * This is a no-op if the secret key already exists or the server returns - * 409 (conflict). - */ -export declare function bindClientDevice(): Promise -/** Begin the OAuth 2.0 Device Authorization flow. */ -export declare function beginDeviceCodeFlow(region: string, clientId: string): Promise -/** - * Variant of `beginDeviceCodeFlow` that targets a custom auth server URL. - * - * Intended for **testing only** — requires the crate to be built with the - * `test-utils` Cargo feature. - */ -export declare function beginDeviceCodeFlowWithBaseUrl(region: string, clientId: string, baseUrl: string): Promise -/** - * Variant of `bindClientDevice` that uses a custom profile directory. - * - * Intended for **testing only** — requires the crate to be built with the - * `test-utils` Cargo feature. - */ -export declare function bindClientDeviceWithProfileDir(profileDir: string): Promise -/** - * Save a test auth token to the given profile directory with the ZeroKMS - * service URL set to `zerokms_base_url`. - * - * Intended for **testing only** — requires the crate to be built with the - * `test-utils` Cargo feature. - */ -export declare function saveTestToken(profileDir: string, zerokmsBaseUrl: string): void -export declare class MockAuthServer { - /** Start a mock auth server on a random port. */ - static start(): Promise - /** The base URL of the running mock server (e.g. `http://127.0.0.1:12345`). */ - get baseUrl(): string - /** - * Register a mock for `POST /oauth/device/code` that returns a standard - * device-code JSON response. - */ - mockDeviceCodeEndpoint(): void - /** - * Register a mock for `POST /oauth/device/token` that returns a standard - * token JSON response. - */ - mockTokenEndpoint(): void - /** - * Register a mock for `POST /oauth/device/token` that returns a 400 error - * with the given OAuth error code and optional description. - */ - mockTokenEndpointError(code: string, description?: string | undefined | null): void - /** - * Register a mock for `POST /create-client` that returns a successful - * create-client JSON response (as ZeroKMS would). - */ - mockCreateClientEndpoint(): void - /** Register a mock for `POST /create-client` that returns a 409 conflict. */ - mockCreateClientConflict(): void - /** Remove all registered mocks. */ - clearMocks(): void -} export declare class DeviceCodeResult { get userCode(): string get verificationUri(): string From f67ae393835d841166c34ca737dc391c23db9355 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Wed, 1 Apr 2026 10:09:09 -0700 Subject: [PATCH 143/686] =?UTF-8?q?docs(auth):=20=F0=9F=93=9D=20add=20CHAN?= =?UTF-8?q?GELOG.md=20for=20@cipherstash/auth?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../typescript/packages/auth/CHANGELOG.md | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 languages/typescript/packages/auth/CHANGELOG.md diff --git a/languages/typescript/packages/auth/CHANGELOG.md b/languages/typescript/packages/auth/CHANGELOG.md new file mode 100644 index 000000000..01f367c36 --- /dev/null +++ b/languages/typescript/packages/auth/CHANGELOG.md @@ -0,0 +1,29 @@ +# Changelog + +## 0.35.0 + +### New Features + +- **AutoStrategy** — auto-detect credentials from environment variables and the local profile store. + Use `AutoStrategy.detect()` for zero-config auth, or pass explicit values: + ```ts + const strategy = AutoStrategy.detect({ accessKey: "CSAK...", workspaceCrn: "crn:..." }); + const { token, issuer, services } = await strategy.getToken(); + ``` +- **AccessKeyStrategy** — authenticate with a static access key (service-to-service, CI/CD): + ```ts + const strategy = AccessKeyStrategy.create("ap-southeast-2.aws", "CSAKid.secret"); + const { token } = await strategy.getToken(); + ``` +- **OAuthStrategy** — authenticate using OAuth refresh tokens persisted to disk: + ```ts + const strategy = OAuthStrategy.fromProfile(); + const { token } = await strategy.getToken(); + ``` +- **TokenResult** — `getToken()` returns `{ token, issuer, services }` with the bearer credential + and decoded JWT claims for service discovery. +- New error codes: `NOT_AUTHENTICATED`, `MISSING_WORKSPACE_CRN`, `INVALID_ACCESS_KEY`. + +## 0.34.2 + +- Initial release with `beginDeviceCodeFlow()` and `bindClientDevice()`. From ee9575bfd15da587eca4235051149cd16e089885 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Wed, 1 Apr 2026 10:17:17 -0700 Subject: [PATCH 144/686] =?UTF-8?q?fix(auth):=20=F0=9F=A9=B9=20add=20INVAL?= =?UTF-8?q?ID=5FCRN=20error=20code=20and=20deduplicate=20zerokms=5Furl?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add AuthError::InvalidCrn mapping to INVALID_CRN in error_code() (previously fell through to UNKNOWN_ERROR) - Refactor zerokms_url() to delegate to services() instead of duplicating the decoded-claims access pattern - Add test coverage for InvalidCrn mapping and detect({ workspaceCrn }) --- languages/typescript/packages/auth/index.d.ts | 1 + languages/typescript/packages/auth/src/lib.rs | 22 +++++++++++++++++++ packages/stack-auth/src/service_token.rs | 8 +------ 3 files changed, 24 insertions(+), 7 deletions(-) diff --git a/languages/typescript/packages/auth/index.d.ts b/languages/typescript/packages/auth/index.d.ts index 54d131bfe..baa34b604 100644 --- a/languages/typescript/packages/auth/index.d.ts +++ b/languages/typescript/packages/auth/index.d.ts @@ -18,6 +18,7 @@ export type AuthErrorCode = | 'NOT_AUTHENTICATED' | 'MISSING_WORKSPACE_CRN' | 'INVALID_ACCESS_KEY' + | 'INVALID_CRN' | 'UNKNOWN_ERROR' /** An error thrown by this package, enriched with a machine-readable `.code`. */ diff --git a/languages/typescript/packages/auth/src/lib.rs b/languages/typescript/packages/auth/src/lib.rs index 10639831f..28a22c9c3 100644 --- a/languages/typescript/packages/auth/src/lib.rs +++ b/languages/typescript/packages/auth/src/lib.rs @@ -31,6 +31,7 @@ fn error_code(err: &AuthError) -> &'static str { AuthError::NotAuthenticated => "NOT_AUTHENTICATED", AuthError::MissingWorkspaceCrn => "MISSING_WORKSPACE_CRN", AuthError::InvalidAccessKey(_) => "INVALID_ACCESS_KEY", + AuthError::InvalidCrn(_) => "INVALID_CRN", _ => "UNKNOWN_ERROR", } } @@ -512,6 +513,13 @@ mod tests { "INVALID_ACCESS_KEY", "InvalidAccessKey should map to INVALID_ACCESS_KEY" ); + assert_eq!( + error_code(&AuthError::InvalidCrn( + "not-a-crn".parse::().unwrap_err() + )), + "INVALID_CRN", + "InvalidCrn should map to INVALID_CRN" + ); } #[test] @@ -766,6 +774,20 @@ mod tests { assertions::has_error_code(&err, "MISSING_WORKSPACE_CRN"); } } + + mod given_invalid_crn { + use super::*; + + #[test] + fn returns_invalid_crn_error() { + let err = expect_err(AutoStrategy::detect(Some(AutoStrategyOptions { + access_key: Some("CSAKtestKeyId.testKeySecret".to_string()), + workspace_crn: Some("not-a-crn".to_string()), + }))); + + assertions::has_error_code(&err, "INVALID_CRN"); + } + } } mod access_key_strategy_create { diff --git a/packages/stack-auth/src/service_token.rs b/packages/stack-auth/src/service_token.rs index 1b26524cf..62b3f9b4b 100644 --- a/packages/stack-auth/src/service_token.rs +++ b/packages/stack-auth/src/service_token.rs @@ -94,13 +94,7 @@ impl ServiceToken { /// Returns [`AuthError::InvalidToken`] if the token is not a valid JWT or /// the `services` claim does not include a ZeroKMS endpoint. pub fn zerokms_url(&self) -> Result { - let decoded = self - .decoded - .as_ref() - .map_err(|reason| AuthError::InvalidToken(reason.clone()))?; - - decoded - .services + self.services()? .get(ServiceType::ZeroKms) .cloned() .ok_or_else(|| { From cda86a68ba9567d9b655779ec20c61edc29ffd06 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Wed, 1 Apr 2026 10:22:25 -0700 Subject: [PATCH 145/686] =?UTF-8?q?style(auth):=20=F0=9F=92=84=20fix=20car?= =?UTF-8?q?go=20fmt=20formatting?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- languages/typescript/packages/auth/src/lib.rs | 125 ++++++++++++------ 1 file changed, 81 insertions(+), 44 deletions(-) diff --git a/languages/typescript/packages/auth/src/lib.rs b/languages/typescript/packages/auth/src/lib.rs index 28a22c9c3..753ba497d 100644 --- a/languages/typescript/packages/auth/src/lib.rs +++ b/languages/typescript/packages/auth/src/lib.rs @@ -5,8 +5,7 @@ use cts_common::Region; use napi::bindgen_prelude::*; use napi_derive::napi; use stack_auth::{ - AuthError, AuthStrategy, DeviceClientError, DeviceCodeStrategy, PendingDeviceCode, - ServiceToken, + AuthError, AuthStrategy, DeviceClientError, DeviceCodeStrategy, PendingDeviceCode, ServiceToken, }; #[cfg(feature = "test-utils")] @@ -116,7 +115,9 @@ impl AutoStrategy { builder = builder.with_access_key(key); } if let Some(crn_str) = opts.workspace_crn { - let crn = crn_str.parse().map_err(|e| to_napi_error(AuthError::InvalidCrn(e)))?; + let crn = crn_str + .parse() + .map_err(|e| to_napi_error(AuthError::InvalidCrn(e)))?; builder = builder.with_workspace_crn(crn); } } @@ -150,7 +151,9 @@ impl AccessKeyStrategy { #[napi(factory)] pub fn create(region: String, access_key: String) -> Result { let region = Region::new(®ion).map_err(|e| to_napi_error(AuthError::from(e)))?; - let key: stack_auth::AccessKey = access_key.parse().map_err(|e| to_napi_error(AuthError::from(e)))?; + let key: stack_auth::AccessKey = access_key + .parse() + .map_err(|e| to_napi_error(AuthError::from(e)))?; let inner = stack_auth::AccessKeyStrategy::new(region, key).map_err(to_napi_error)?; Ok(Self { inner }) } @@ -480,14 +483,26 @@ mod tests { #[test] fn maps_all_auth_error_variants() { - assert_eq!(error_code(&AuthError::AccessDenied), "ACCESS_DENIED", - "AccessDenied should map to ACCESS_DENIED"); - assert_eq!(error_code(&AuthError::TokenExpired), "EXPIRED_TOKEN", - "TokenExpired should map to EXPIRED_TOKEN"); - assert_eq!(error_code(&AuthError::InvalidGrant), "INVALID_GRANT", - "InvalidGrant should map to INVALID_GRANT"); - assert_eq!(error_code(&AuthError::InvalidClient), "INVALID_CLIENT", - "InvalidClient should map to INVALID_CLIENT"); + assert_eq!( + error_code(&AuthError::AccessDenied), + "ACCESS_DENIED", + "AccessDenied should map to ACCESS_DENIED" + ); + assert_eq!( + error_code(&AuthError::TokenExpired), + "EXPIRED_TOKEN", + "TokenExpired should map to EXPIRED_TOKEN" + ); + assert_eq!( + error_code(&AuthError::InvalidGrant), + "INVALID_GRANT", + "InvalidGrant should map to INVALID_GRANT" + ); + assert_eq!( + error_code(&AuthError::InvalidClient), + "INVALID_CLIENT", + "InvalidClient should map to INVALID_CLIENT" + ); assert_eq!( error_code(&AuthError::InvalidUrl( "http://[".parse::().unwrap_err() @@ -500,12 +515,21 @@ mod tests { "INVALID_REGION", "Region should map to INVALID_REGION" ); - assert_eq!(error_code(&AuthError::Server("test".to_string())), "SERVER_ERROR", - "Server should map to SERVER_ERROR"); - assert_eq!(error_code(&AuthError::NotAuthenticated), "NOT_AUTHENTICATED", - "NotAuthenticated should map to NOT_AUTHENTICATED"); - assert_eq!(error_code(&AuthError::MissingWorkspaceCrn), "MISSING_WORKSPACE_CRN", - "MissingWorkspaceCrn should map to MISSING_WORKSPACE_CRN"); + assert_eq!( + error_code(&AuthError::Server("test".to_string())), + "SERVER_ERROR", + "Server should map to SERVER_ERROR" + ); + assert_eq!( + error_code(&AuthError::NotAuthenticated), + "NOT_AUTHENTICATED", + "NotAuthenticated should map to NOT_AUTHENTICATED" + ); + assert_eq!( + error_code(&AuthError::MissingWorkspaceCrn), + "MISSING_WORKSPACE_CRN", + "MissingWorkspaceCrn should map to MISSING_WORKSPACE_CRN" + ); assert_eq!( error_code(&AuthError::InvalidAccessKey( "bad-key".parse::().unwrap_err() @@ -556,15 +580,26 @@ mod tests { let result = begin_result(&server, &dir).await; - assert_eq!(result.user_code(), "ABCD-EFGH", - "user_code should match device code response"); - assert_eq!(result.verification_uri(), "http://example.com/activate", - "verification_uri should match device code response"); - assert_eq!(result.verification_uri_complete(), + assert_eq!( + result.user_code(), + "ABCD-EFGH", + "user_code should match device code response" + ); + assert_eq!( + result.verification_uri(), + "http://example.com/activate", + "verification_uri should match device code response" + ); + assert_eq!( + result.verification_uri_complete(), "http://example.com/activate?user_code=ABCD-EFGH", - "verification_uri_complete should include user code"); - assert_eq!(result.expires_in(), 900.0, - "expires_in should match device code response"); + "verification_uri_complete should include user code" + ); + assert_eq!( + result.expires_in(), + 900.0, + "expires_in should match device code response" + ); } mod given_successful_token_exchange { @@ -584,10 +619,15 @@ mod tests { let result = begin_result(&server, &dir).await; let token = result.poll_for_token().await.unwrap(); - assert!(token.expires_in >= 3598.0 && token.expires_in <= 3600.0, - "expires_in should be ~3600, got: {}", token.expires_in); - assert!(token.expires_at > 0.0, - "expires_at should be a positive epoch timestamp"); + assert!( + token.expires_in >= 3598.0 && token.expires_in <= 3600.0, + "expires_in should be ~3600, got: {}", + token.expires_in + ); + assert!( + token.expires_at > 0.0, + "expires_at should be a positive epoch timestamp" + ); } } @@ -691,12 +731,10 @@ mod tests { #[tokio::test] async fn returns_invalid_region_error() { - let err = begin_device_code_flow( - "not-a-region".to_string(), - "test-client".to_string(), - ) - .await - .unwrap_err(); + let err = + begin_device_code_flow("not-a-region".to_string(), "test-client".to_string()) + .await + .unwrap_err(); assertions::has_error_code(&err, "INVALID_REGION"); } @@ -713,16 +751,15 @@ mod tests { #[test] fn includes_bearer_token_and_claims() { - let service_token = make_service_token( - "https://cts.example.com/", - "https://zerokms.example.com/", - ); + let service_token = + make_service_token("https://cts.example.com/", "https://zerokms.example.com/"); let result = token_result_from(service_token).unwrap(); - assert!(!result.token.is_empty(), - "token string should not be empty"); - assert_eq!(result.issuer, "https://cts.example.com/", - "issuer should match JWT iss claim"); + assert!(!result.token.is_empty(), "token string should not be empty"); + assert_eq!( + result.issuer, "https://cts.example.com/", + "issuer should match JWT iss claim" + ); assert_eq!( result.services.get("zerokms").map(String::as_str), Some("https://zerokms.example.com/"), From 1190c4bc981df2cc8893adeb8e8f1db97bf1a4c0 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Wed, 1 Apr 2026 10:27:28 -0700 Subject: [PATCH 146/686] =?UTF-8?q?docs(auth):=20=F0=9F=93=9D=20add=20INVA?= =?UTF-8?q?LID=5FCRN=20to=20changelog=20error=20codes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- languages/typescript/packages/auth/CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/languages/typescript/packages/auth/CHANGELOG.md b/languages/typescript/packages/auth/CHANGELOG.md index 01f367c36..4234a1fe3 100644 --- a/languages/typescript/packages/auth/CHANGELOG.md +++ b/languages/typescript/packages/auth/CHANGELOG.md @@ -22,7 +22,7 @@ ``` - **TokenResult** — `getToken()` returns `{ token, issuer, services }` with the bearer credential and decoded JWT claims for service discovery. -- New error codes: `NOT_AUTHENTICATED`, `MISSING_WORKSPACE_CRN`, `INVALID_ACCESS_KEY`. +- New error codes: `NOT_AUTHENTICATED`, `MISSING_WORKSPACE_CRN`, `INVALID_ACCESS_KEY`, `INVALID_CRN`. ## 0.34.2 From ae50901199c67412095394d848f54e24af6b6722 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Wed, 1 Apr 2026 11:18:43 -0700 Subject: [PATCH 147/686] =?UTF-8?q?feat(auth):=20=E2=9C=A8=20add=20subject?= =?UTF-8?q?()=20and=20workspace=5Fid()=20to=20ServiceToken?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Expand DecodedClaims to retain the `sub` and `workspace` fields from the JWT (previously discarded during decoding). Expose them as subject() and workspace_id() on ServiceToken, and include them in the Node TokenResult as `subject` and `workspaceId`. --- languages/typescript/packages/auth/index.d.ts | 4 + languages/typescript/packages/auth/src/lib.rs | 16 ++++ packages/stack-auth/src/service_token.rs | 85 ++++++++++++++++++- 3 files changed, 104 insertions(+), 1 deletion(-) diff --git a/languages/typescript/packages/auth/index.d.ts b/languages/typescript/packages/auth/index.d.ts index baa34b604..194946e2c 100644 --- a/languages/typescript/packages/auth/index.d.ts +++ b/languages/typescript/packages/auth/index.d.ts @@ -34,6 +34,10 @@ export interface AuthError extends Error { export interface TokenResult { /** The bearer token string (used as `Authorization: Bearer `). */ token: string + /** The subject claim from the JWT (e.g. `"CS|auth0|user123"` or `"CS|CSAKkeyId"`). */ + subject: string + /** The workspace identifier from the JWT. */ + workspaceId: string /** The issuer URL from the JWT `iss` claim (i.e. the CTS host). */ issuer: string /** Service endpoint URLs from the JWT `services` claim (e.g. `{ zerokms: "https://..." }`). */ diff --git a/languages/typescript/packages/auth/src/lib.rs b/languages/typescript/packages/auth/src/lib.rs index 753ba497d..4acf1a59f 100644 --- a/languages/typescript/packages/auth/src/lib.rs +++ b/languages/typescript/packages/auth/src/lib.rs @@ -52,6 +52,10 @@ fn to_napi_error(err: AuthError) -> napi::Error { pub struct TokenResult { /// The bearer token string (used as `Authorization: Bearer `). pub token: String, + /// The subject claim from the JWT (e.g. `"CS|auth0|user123"` or `"CS|CSAKkeyId"`). + pub subject: String, + /// The workspace identifier from the JWT. + pub workspace_id: String, /// The issuer URL from the JWT `iss` claim (i.e. the CTS host). pub issuer: String, /// Service endpoint URLs from the JWT `services` claim (e.g. `{ zerokms: "https://..." }`). @@ -59,6 +63,8 @@ pub struct TokenResult { } fn token_result_from(token: ServiceToken) -> Result { + let subject = token.subject().map_err(to_napi_error)?.to_string(); + let workspace_id = token.workspace_id().map_err(to_napi_error)?.to_string(); let issuer = token.issuer().map_err(to_napi_error)?.to_string(); let services = token .services() @@ -69,6 +75,8 @@ fn token_result_from(token: ServiceToken) -> Result { Ok(TokenResult { token: token.as_str().to_string(), + subject, + workspace_id, issuer, services, }) @@ -756,6 +764,14 @@ mod tests { let result = token_result_from(service_token).unwrap(); assert!(!result.token.is_empty(), "token string should not be empty"); + assert_eq!( + result.subject, "CS|test-user", + "subject should match JWT sub claim" + ); + assert_eq!( + result.workspace_id, "ZVATKW3VHMFG27DY", + "workspace_id should match JWT workspace claim" + ); assert_eq!( result.issuer, "https://cts.example.com/", "issuer should match JWT iss claim" diff --git a/packages/stack-auth/src/service_token.rs b/packages/stack-auth/src/service_token.rs index 62b3f9b4b..90a6273d3 100644 --- a/packages/stack-auth/src/service_token.rs +++ b/packages/stack-auth/src/service_token.rs @@ -1,4 +1,5 @@ use cts_common::claims::{ServiceType, Services}; +use cts_common::WorkspaceId; use url::Url; use vitaminc::protected::OpaqueDebug; use zeroize::ZeroizeOnDrop; @@ -15,11 +16,13 @@ use crate::{AuthError, SecretToken}; /// /// # Decoded claims /// +/// * `subject()` — the `sub` claim (e.g. `"CS|auth0|user123"`). +/// * `workspace_id()` — the workspace identifier from the token. /// * `issuer()` — the `iss` URL, i.e. the CTS host for this workspace. /// * `zerokms_url()` — the ZeroKMS endpoint from the `services` claim. /// /// For non-JWT tokens (e.g. static test tokens) or JWTs that don't match -/// the CipherStash claims schema, both methods return +/// the CipherStash claims schema, these methods return /// `Err(AuthError::InvalidToken)`. /// /// # Security @@ -35,6 +38,8 @@ pub struct ServiceToken { #[derive(Clone, Debug)] struct DecodedClaims { + subject: String, + workspace: WorkspaceId, issuer: Url, services: Services, } @@ -56,6 +61,36 @@ impl ServiceToken { self.secret.as_str() } + /// Return the `sub` (subject) claim from the JWT. + /// + /// In CipherStash tokens the subject encodes the principal identity, + /// e.g. `"CS|auth0|user123"` for a user or `"CS|CSAKkeyId"` for an + /// access key. + /// + /// # Errors + /// + /// Returns [`AuthError::InvalidToken`] if the token is not a valid JWT or + /// the claims could not be decoded. + pub fn subject(&self) -> Result<&str, AuthError> { + self.decoded + .as_ref() + .map(|d| d.subject.as_str()) + .map_err(|reason| AuthError::InvalidToken(reason.clone())) + } + + /// Return the workspace identifier from the JWT claims. + /// + /// # Errors + /// + /// Returns [`AuthError::InvalidToken`] if the token is not a valid JWT or + /// the claims could not be decoded. + pub fn workspace_id(&self) -> Result<&WorkspaceId, AuthError> { + self.decoded + .as_ref() + .map(|d| &d.workspace) + .map_err(|reason| AuthError::InvalidToken(reason.clone())) + } + /// Return the `iss` (issuer) URL from the JWT claims. /// /// In CipherStash tokens the issuer is the CTS host URL for the workspace. @@ -134,6 +169,8 @@ impl ServiceToken { .map_err(|e| format!("iss claim is not a valid URL: {e}"))?; Ok(DecodedClaims { + subject: data.claims.sub, + workspace: data.claims.workspace, issuer, services: data.claims.services, }) @@ -282,6 +319,52 @@ mod tests { ); } + #[test] + fn subject_from_valid_jwt() { + let jwt = make_jwt( + "https://cts.example.com/", + services_with_zerokms("https://zerokms.example.com/"), + ); + let token = ServiceToken::new(SecretToken::new(jwt)); + assert_eq!( + token.subject().unwrap(), + "CS|test-user", + "subject should match JWT sub claim" + ); + } + + #[test] + fn subject_errors_for_non_jwt() { + let token = ServiceToken::new(SecretToken::new("not-a-jwt")); + assert!( + token.subject().is_err(), + "subject should error for non-JWT token" + ); + } + + #[test] + fn workspace_id_from_valid_jwt() { + let jwt = make_jwt( + "https://cts.example.com/", + services_with_zerokms("https://zerokms.example.com/"), + ); + let token = ServiceToken::new(SecretToken::new(jwt)); + assert_eq!( + token.workspace_id().unwrap().to_string(), + "ZVATKW3VHMFG27DY", + "workspace_id should match JWT workspace claim" + ); + } + + #[test] + fn workspace_id_errors_for_non_jwt() { + let token = ServiceToken::new(SecretToken::new("not-a-jwt")); + assert!( + token.workspace_id().is_err(), + "workspace_id should error for non-JWT token" + ); + } + #[test] fn debug_does_not_leak_secret() { let jwt = make_jwt( From 6583c5944de4b21a649a67165b5870325ba00ab2 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Wed, 1 Apr 2026 11:18:55 -0700 Subject: [PATCH 148/686] =?UTF-8?q?docs(auth):=20=F0=9F=93=9D=20demonstrat?= =?UTF-8?q?e=20whoami=20(subject/workspace)=20in=20examples?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../typescript/packages/auth/examples/auto-strategy.ts | 9 ++++++--- packages/stack-auth/examples/auto_strategy.rs | 7 +++++-- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/languages/typescript/packages/auth/examples/auto-strategy.ts b/languages/typescript/packages/auth/examples/auto-strategy.ts index 0763eacb1..2e75c70cf 100644 --- a/languages/typescript/packages/auth/examples/auto-strategy.ts +++ b/languages/typescript/packages/auth/examples/auto-strategy.ts @@ -31,9 +31,12 @@ async function main() { // Retrieve a token — refresh happens automatically when needed. const result = await strategy.getToken(); - console.log(`Issuer: ${result.issuer}`); - console.log(`Services: ${JSON.stringify(result.services)}`); - console.log(`Token: ${result.token.slice(0, 20)}...`); + // Who am I? + console.log(`Subject: ${result.subject}`); + console.log(`Workspace: ${result.workspaceId}`); + console.log(`Issuer: ${result.issuer}`); + console.log(`Services: ${JSON.stringify(result.services)}`); + console.log(`Token: ${result.token.slice(0, 20)}...`); } main().catch((err: AuthError) => { diff --git a/packages/stack-auth/examples/auto_strategy.rs b/packages/stack-auth/examples/auto_strategy.rs index cf575a7ca..325e64a5e 100644 --- a/packages/stack-auth/examples/auto_strategy.rs +++ b/packages/stack-auth/examples/auto_strategy.rs @@ -45,8 +45,11 @@ async fn main() -> Result<(), Box> { // Obtain a token — refresh happens automatically when needed. let token = (&strategy).get_token().await?; - println!("Token type: Bearer"); - println!("Access token: {:?}", token); + + // Who am I? + println!("Subject: {}", token.subject()?); + println!("Workspace: {}", token.workspace_id()?); + println!("Issuer: {}", token.issuer()?); Ok(()) } From 2631d19c16aa6ba03f43187a5bf7d2321a88332a Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Wed, 1 Apr 2026 11:25:02 -0700 Subject: [PATCH 149/686] =?UTF-8?q?style(auth):=20=F0=9F=8E=A8=20remove=20?= =?UTF-8?q?redundant=20comments=20from=20examples?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- languages/typescript/packages/auth/examples/auto-strategy.ts | 2 -- packages/stack-auth/examples/auto_strategy.rs | 2 -- 2 files changed, 4 deletions(-) diff --git a/languages/typescript/packages/auth/examples/auto-strategy.ts b/languages/typescript/packages/auth/examples/auto-strategy.ts index 2e75c70cf..0661fc4cf 100644 --- a/languages/typescript/packages/auth/examples/auto-strategy.ts +++ b/languages/typescript/packages/auth/examples/auto-strategy.ts @@ -30,8 +30,6 @@ async function main() { // Retrieve a token — refresh happens automatically when needed. const result = await strategy.getToken(); - - // Who am I? console.log(`Subject: ${result.subject}`); console.log(`Workspace: ${result.workspaceId}`); console.log(`Issuer: ${result.issuer}`); diff --git a/packages/stack-auth/examples/auto_strategy.rs b/packages/stack-auth/examples/auto_strategy.rs index 325e64a5e..0b74df06e 100644 --- a/packages/stack-auth/examples/auto_strategy.rs +++ b/packages/stack-auth/examples/auto_strategy.rs @@ -45,8 +45,6 @@ async fn main() -> Result<(), Box> { // Obtain a token — refresh happens automatically when needed. let token = (&strategy).get_token().await?; - - // Who am I? println!("Subject: {}", token.subject()?); println!("Workspace: {}", token.workspace_id()?); println!("Issuer: {}", token.issuer()?); From 37ebce66533b75f2cfc344d04fdade4b2de88a99 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Wed, 1 Apr 2026 11:49:25 -0700 Subject: [PATCH 150/686] =?UTF-8?q?fix(auth):=20=F0=9F=94=92=EF=B8=8F=20de?= =?UTF-8?q?rive=20OpaqueDebug=20on=20TokenResult=20to=20prevent=20token=20?= =?UTF-8?q?leaks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace `derive(Debug)` with `derive(OpaqueDebug)` from vitaminc so the bearer token won't appear in debug/log output on the Rust side. ZeroizeOnDrop is not compatible with NAPI's move semantics (fields are moved out of the struct to build the JS object, so Drop would only zeroize empty memory). --- languages/typescript/packages/auth/Cargo.toml | 1 + languages/typescript/packages/auth/src/lib.rs | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/languages/typescript/packages/auth/Cargo.toml b/languages/typescript/packages/auth/Cargo.toml index 15cf4d3d3..0866c5bf1 100644 --- a/languages/typescript/packages/auth/Cargo.toml +++ b/languages/typescript/packages/auth/Cargo.toml @@ -11,6 +11,7 @@ crate-type = ["cdylib"] stack-auth = { workspace = true } stack-profile = { workspace = true } cts-common = { workspace = true } +vitaminc-protected = { workspace = true } napi = { version = "2", features = ["async", "tokio_rt"] } napi-derive = "2" url = { version = "2", optional = true } diff --git a/languages/typescript/packages/auth/src/lib.rs b/languages/typescript/packages/auth/src/lib.rs index 4acf1a59f..da6479b97 100644 --- a/languages/typescript/packages/auth/src/lib.rs +++ b/languages/typescript/packages/auth/src/lib.rs @@ -7,6 +7,7 @@ use napi_derive::napi; use stack_auth::{ AuthError, AuthStrategy, DeviceClientError, DeviceCodeStrategy, PendingDeviceCode, ServiceToken, }; +use vitaminc_protected::OpaqueDebug; #[cfg(feature = "test-utils")] mod mock_auth_server; @@ -47,7 +48,7 @@ fn to_napi_error(err: AuthError) -> napi::Error { /// The result of a successful `getToken()` call. /// /// Contains the bearer credential and decoded JWT claims for service discovery. -#[derive(Debug)] +#[derive(OpaqueDebug)] #[napi(object)] pub struct TokenResult { /// The bearer token string (used as `Authorization: Bearer `). From 7e1836fd5e75285445aa2f06038ea2acabb10af5 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Wed, 1 Apr 2026 11:57:30 -0700 Subject: [PATCH 151/686] =?UTF-8?q?fix(auth):=20=F0=9F=94=92=EF=B8=8F=20de?= =?UTF-8?q?rive=20OpaqueDebug=20on=20AutoStrategyOptions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Prevents the access key from appearing in debug/log output when options are passed to AutoStrategy.detect(). --- languages/typescript/packages/auth/src/lib.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/languages/typescript/packages/auth/src/lib.rs b/languages/typescript/packages/auth/src/lib.rs index da6479b97..cf1b22adc 100644 --- a/languages/typescript/packages/auth/src/lib.rs +++ b/languages/typescript/packages/auth/src/lib.rs @@ -88,7 +88,7 @@ fn token_result_from(token: ServiceToken) -> Result { // --------------------------------------------------------------------------- /// Options for `AutoStrategy.detect()`. -#[derive(Debug)] +#[derive(OpaqueDebug)] #[napi(object)] pub struct AutoStrategyOptions { /// An explicit access key (takes precedence over `CS_CLIENT_ACCESS_KEY` env var). From 7dc0611d1fab8dadba233e7186db81636449854e Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Wed, 1 Apr 2026 12:03:49 -0700 Subject: [PATCH 152/686] =?UTF-8?q?docs(auth):=20=F0=9F=93=9D=20update=20C?= =?UTF-8?q?HANGELOG=20with=20whoami=20fields=20and=20security=20notes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- languages/typescript/packages/auth/CHANGELOG.md | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/languages/typescript/packages/auth/CHANGELOG.md b/languages/typescript/packages/auth/CHANGELOG.md index 4234a1fe3..afba5beba 100644 --- a/languages/typescript/packages/auth/CHANGELOG.md +++ b/languages/typescript/packages/auth/CHANGELOG.md @@ -20,10 +20,15 @@ const strategy = OAuthStrategy.fromProfile(); const { token } = await strategy.getToken(); ``` -- **TokenResult** — `getToken()` returns `{ token, issuer, services }` with the bearer credential - and decoded JWT claims for service discovery. +- **TokenResult** — `getToken()` returns `{ token, subject, workspaceId, issuer, services }` with + the bearer credential and decoded JWT claims for identity and service discovery. - New error codes: `NOT_AUTHENTICATED`, `MISSING_WORKSPACE_CRN`, `INVALID_ACCESS_KEY`, `INVALID_CRN`. +### Security + +- `TokenResult` and `AutoStrategyOptions` use `OpaqueDebug` to prevent tokens and access keys + from appearing in Rust debug/log output. + ## 0.34.2 - Initial release with `beginDeviceCodeFlow()` and `bindClientDevice()`. From fbeac24938396a34a062b2a57722f75c678438ad Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 2 Apr 2026 05:23:14 +0000 Subject: [PATCH 153/686] chore: release --- packages/stack-auth/CHANGELOG.md | 38 +++++++++++++++++++++++++++++ packages/stack-profile/CHANGELOG.md | 1 + packages/stack-profile/Cargo.toml | 2 +- 3 files changed, 40 insertions(+), 1 deletion(-) diff --git a/packages/stack-auth/CHANGELOG.md b/packages/stack-auth/CHANGELOG.md index 954f558d7..1f5f08500 100644 --- a/packages/stack-auth/CHANGELOG.md +++ b/packages/stack-auth/CHANGELOG.md @@ -1,3 +1,41 @@ + + +### Documentation + +- 📝 add TypeScript example for AutoStrategy usage +- 📝 add CHANGELOG.md for @cipherstash/auth +- 📝 add INVALID_CRN to changelog error codes +- 📝 demonstrate whoami (subject/workspace) in examples +- 📝 update CHANGELOG with whoami fields and security notes + +### Features + +- ✨ expose auth strategies in @cipherstash/auth Node bindings +- ✨ add subject() and workspace_id() to ServiceToken + +### Fixes + +- 🩹 add INVALID_CRN error code and deduplicate zerokms_url +- 🔒️ derive OpaqueDebug on TokenResult to prevent token leaks +- 🔒️ derive OpaqueDebug on AutoStrategyOptions + +### Miscellaneous + +- 🔖 bump @cipherstash/auth to 0.35.0 +- 🔧 regenerate index.d.ts from napi build + +### Refactoring + +- ♻️ restructure stack-auth-node tests to follow conventions + +### Testing + +- ✅ add unit tests for exposed auth strategies + +### Style + +- 💄 fix cargo fmt formatting +- 🎨 remove redundant comments from examples # Changelog All notable changes to this project will be documented in this file. diff --git a/packages/stack-profile/CHANGELOG.md b/packages/stack-profile/CHANGELOG.md index 958602352..6d51fc540 100644 --- a/packages/stack-profile/CHANGELOG.md +++ b/packages/stack-profile/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 + ## [0.34.0-alpha.1] - 2026-03-04 ### Changed diff --git a/packages/stack-profile/Cargo.toml b/packages/stack-profile/Cargo.toml index 4f286c4b8..c30ad880e 100644 --- a/packages/stack-profile/Cargo.toml +++ b/packages/stack-profile/Cargo.toml @@ -2,7 +2,7 @@ name = "stack-profile" description = "Centralised ~/.cipherstash profile file management" license-file = "LICENSE" -version = "0.34.0-alpha.5" +version = "0.34.0-alpha.6" edition.workspace = true authors.workspace = true repository.workspace = true From b6f5686edc9838dae66c323b752e6b48a2e751cb Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Thu, 2 Apr 2026 17:26:17 -0700 Subject: [PATCH 154/686] feat(stack-profile): add multi-workspace profile support (CIP-2942) Store auth.json and secretkey.json per-workspace in ~/.cipherstash/workspaces// while keeping device.json shared at the profile root. A current_workspace file tracks the active workspace. ProfileStore gains workspace management methods: set_current_workspace, current_workspace, current_workspace_store, workspace_store, list_workspaces, and migrate_to_workspace. Consumers explicitly choose the root store (for global data like DeviceIdentity) or a workspace store (for Token and SecretKey). The CLI login flow sets the current workspace from the JWT after authentication. The CLI startup path auto-migrates legacy flat files into the workspace directory structure. --- languages/typescript/packages/auth/src/lib.rs | 40 +- packages/stack-auth/src/auto_refresh.rs | 25 +- packages/stack-auth/src/auto_strategy.rs | 12 +- packages/stack-auth/src/device_client.rs | 32 +- packages/stack-auth/src/device_code/mod.rs | 18 +- packages/stack-auth/src/device_code/tests.rs | 39 +- packages/stack-auth/src/oauth_strategy.rs | 5 +- packages/stack-profile/src/error.rs | 6 + packages/stack-profile/src/profile_store.rs | 548 +++++++++++++++++- 9 files changed, 693 insertions(+), 32 deletions(-) diff --git a/languages/typescript/packages/auth/src/lib.rs b/languages/typescript/packages/auth/src/lib.rs index cf1b22adc..2bd1d349c 100644 --- a/languages/typescript/packages/auth/src/lib.rs +++ b/languages/typescript/packages/auth/src/lib.rs @@ -394,9 +394,36 @@ mod tests { }) } + fn test_access_token_jwt() -> String { + use jsonwebtoken::{encode, EncodingKey, Header}; + use std::time::{SystemTime, UNIX_EPOCH}; + + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs(); + + let claims = serde_json::json!({ + "iss": "https://cts.example.com/", + "sub": "CS|test-user", + "aud": "test-audience", + "iat": now, + "exp": now + 3600, + "workspace": "ZVATKW3VHMFG27DY", + "scope": "", + }); + + encode( + &Header::default(), + &claims, + &EncodingKey::from_secret(b"test-secret"), + ) + .unwrap() + } + fn token_json() -> serde_json::Value { serde_json::json!({ - "access_token": "test_access_token_value", + "access_token": test_access_token_jwt(), "token_type": "Bearer", "expires_in": 3600 }) @@ -956,7 +983,18 @@ pub fn save_test_token(profile_dir: String, zerokms_base_url: String) -> Result< }); let store = stack_profile::ProfileStore::new(&profile_dir); + + // Set the current workspace so workspace-scoped loads work. + let workspace_id = "ZVATKW3VHMFG27DY"; store + .set_current_workspace(workspace_id) + .map_err(|e| napi::Error::new(Status::GenericFailure, format!("{e}")))?; + + // Save the token to the workspace directory. + let ws_store = store + .workspace_store(workspace_id) + .map_err(|e| napi::Error::new(Status::GenericFailure, format!("{e}")))?; + ws_store .save_with_mode("auth.json", &token_json, 0o600) .map_err(|e| napi::Error::new(Status::GenericFailure, format!("{e}")))?; diff --git a/packages/stack-auth/src/auto_refresh.rs b/packages/stack-auth/src/auto_refresh.rs index 0d725c3a6..ef327b5fc 100644 --- a/packages/stack-auth/src/auto_refresh.rs +++ b/packages/stack-auth/src/auto_refresh.rs @@ -354,9 +354,11 @@ mod tests { token: Token, ) -> AutoRefresh { let store = ProfileStore::new(dir.path()); - store.save_profile(&token).unwrap(); + store.set_current_workspace("ZVATKW3VHMFG27DY").unwrap(); + let ws_store = store.current_workspace_store().unwrap(); + ws_store.save_profile(&token).unwrap(); let refresher = OAuthRefresher::new( - Some(store), + Some(ws_store), server.url(""), "cli", "ap-southeast-2.aws", @@ -424,7 +426,13 @@ mod tests { ); // Delete the file — second call should still return the cached token. - std::fs::remove_file(dir.path().join("auth.json")).unwrap(); + std::fs::remove_file( + dir.path() + .join("workspaces") + .join("ZVATKW3VHMFG27DY") + .join("auth.json"), + ) + .unwrap(); let token2 = strategy.get_token().await.unwrap(); assert_eq!( @@ -518,9 +526,10 @@ mod tests { let _ = strategy.get_token().await.unwrap(); - // Verify the refreshed token was saved to disk. + // Verify the refreshed token was saved to the workspace directory. let store = ProfileStore::new(dir.path()); - let on_disk: Token = store.load_profile().unwrap(); + let ws_store = store.current_workspace_store().unwrap(); + let on_disk: Token = ws_store.load_profile().unwrap(); assert_eq!( on_disk.access_token().as_str(), "refreshed-token", @@ -971,9 +980,11 @@ mod stress_tests { token: Token, ) -> AutoRefresh { let store = ProfileStore::new(dir.path()); - store.save_profile(&token).unwrap(); + store.set_current_workspace("ZVATKW3VHMFG27DY").unwrap(); + let ws_store = store.current_workspace_store().unwrap(); + ws_store.save_profile(&token).unwrap(); let refresher = OAuthRefresher::new( - Some(store), + Some(ws_store), base_url.clone(), "cli", "ap-southeast-2.aws", diff --git a/packages/stack-auth/src/auto_strategy.rs b/packages/stack-auth/src/auto_strategy.rs index 98d93d8d3..9299280a5 100644 --- a/packages/stack-auth/src/auto_strategy.rs +++ b/packages/stack-auth/src/auto_strategy.rs @@ -109,9 +109,13 @@ impl AutoStrategy { return Ok(Self::AccessKey(strategy)); } - // 2. OAuth token from disk + // 2. OAuth token from disk (in the current workspace directory) if let Some(store) = store { - if store.exists_profile::() { + let has_token = store + .current_workspace_store() + .map(|ws| ws.exists_profile::()) + .unwrap_or(false); + if has_token { let strategy = OAuthStrategy::with_profile(store).build()?; return Ok(Self::OAuth(strategy)); } @@ -241,7 +245,9 @@ mod tests { fn write_token_store(dir: &std::path::Path) -> ProfileStore { let store = ProfileStore::new(dir); - store.save_profile(&make_oauth_token()).unwrap(); + store.set_current_workspace("ZVATKW3VHMFG27DY").unwrap(); + let ws_store = store.current_workspace_store().unwrap(); + ws_store.save_profile(&make_oauth_token()).unwrap(); store } diff --git a/packages/stack-auth/src/device_client.rs b/packages/stack-auth/src/device_client.rs index 174fbb4cb..a09ade198 100644 --- a/packages/stack-auth/src/device_client.rs +++ b/packages/stack-auth/src/device_client.rs @@ -79,15 +79,20 @@ pub enum DeviceClientError { /// If the secret key already exists on disk, or the server returns 409 /// (conflict), this is a no-op. pub async fn bind_client_device(store: &ProfileStore) -> Result<(), DeviceClientError> { - if store.exists(SECRET_KEY_FILENAME) { + // Resolve the workspace-scoped store for secret key operations. + let ws_id = store.current_workspace()?; + let ws_store = store.workspace_store(&ws_id)?; + + if ws_store.exists(SECRET_KEY_FILENAME) { tracing::debug!("secret key already exists, skipping provisioning"); return Ok(()); } - let token: Token = store.load_profile()?; + let token: Token = ws_store.load_profile()?; let service_token = ServiceToken::new(token.access_token().clone()); let zerokms_url = ensure_trailing_slash(service_token.zerokms_url()?); + // DeviceIdentity is NOT workspace-scoped, so this reads from the root. let identity = DeviceIdentity::load_or_create(store)?; let request = CreateClientRequest { @@ -129,7 +134,8 @@ pub async fn bind_client_device(store: &ProfileStore) -> Result<(), DeviceClient client_key: created.client_key, }; - store.save_with_mode(SECRET_KEY_FILENAME, &secret_key, SECRET_KEY_MODE)?; + // Save to the workspace-scoped directory. + ws_store.save_with_mode(SECRET_KEY_FILENAME, &secret_key, SECRET_KEY_MODE)?; Ok(()) } @@ -176,6 +182,8 @@ mod tests { .unwrap() } + const TEST_WORKSPACE_ID: &str = "ZVATKW3VHMFG27DY"; + fn save_test_token(store: &ProfileStore, access_token: &str) { use std::time::{SystemTime, UNIX_EPOCH}; @@ -193,7 +201,9 @@ mod tests { client_id: None, device_instance_id: None, }; - store.save_profile(&token).unwrap(); + store.set_current_workspace(TEST_WORKSPACE_ID).unwrap(); + let ws_store = store.current_workspace_store().unwrap(); + ws_store.save_profile(&token).unwrap(); } fn client_response_json() -> serde_json::Value { @@ -229,7 +239,8 @@ mod tests { bind_client_device(&store).await.unwrap(); - let saved: serde_json::Value = store.load(SECRET_KEY_FILENAME).unwrap(); + let ws_store = store.workspace_store(TEST_WORKSPACE_ID).unwrap(); + let saved: serde_json::Value = ws_store.load(SECRET_KEY_FILENAME).unwrap(); assert_eq!(saved["client_id"], "00000000-0000-0000-0000-000000000001"); assert_eq!(saved["client_key"], "dGVzdC1rZXktbWF0ZXJpYWw="); } @@ -238,9 +249,11 @@ mod tests { async fn skips_when_secret_key_exists() { let dir = TempDir::new().unwrap(); let store = ProfileStore::new(dir.path()); + store.set_current_workspace(TEST_WORKSPACE_ID).unwrap(); - // Pre-populate secretkey.json - store + // Pre-populate secretkey.json in the workspace directory + let ws_store = store.workspace_store(TEST_WORKSPACE_ID).unwrap(); + ws_store .save_with_mode( SECRET_KEY_FILENAME, &serde_json::json!({"client_id": "old", "client_key": "old"}), @@ -251,7 +264,7 @@ mod tests { // No mock server needed — the HTTP call should never happen. bind_client_device(&store).await.unwrap(); - let saved: serde_json::Value = store.load(SECRET_KEY_FILENAME).unwrap(); + let saved: serde_json::Value = ws_store.load(SECRET_KEY_FILENAME).unwrap(); assert_eq!( saved["client_id"], "old", "should not overwrite existing key" @@ -276,8 +289,9 @@ mod tests { bind_client_device(&store).await.unwrap(); + let ws_store = store.workspace_store(TEST_WORKSPACE_ID).unwrap(); assert!( - !store.exists(SECRET_KEY_FILENAME), + !ws_store.exists(SECRET_KEY_FILENAME), "should not write secret key on conflict" ); } diff --git a/packages/stack-auth/src/device_code/mod.rs b/packages/stack-auth/src/device_code/mod.rs index fe614fef0..a5ce268da 100644 --- a/packages/stack-auth/src/device_code/mod.rs +++ b/packages/stack-auth/src/device_code/mod.rs @@ -340,9 +340,21 @@ impl PendingDeviceCode { Some(dir) => ProfileStore::new(dir), None => ProfileStore::resolve(None)?, }; - match store.save_profile(&token) { - Ok(()) => tracing::debug!("token saved to disk"), - Err(err) => tracing::warn!(%err, "failed to save token to disk"), + + // Set the current workspace and save the token to the + // workspace directory. + let workspace_id = token.workspace_id()?; + store.set_current_workspace(workspace_id.as_str())?; + + match store.workspace_store(workspace_id.as_str()) { + Ok(ws_store) => match ws_store.save_profile(&token) { + Ok(()) => tracing::debug!( + workspace = workspace_id.as_str(), + "token saved to workspace directory" + ), + Err(err) => tracing::warn!(%err, "failed to save token to disk"), + }, + Err(err) => tracing::warn!(%err, "failed to resolve workspace store"), } return Ok(token); diff --git a/packages/stack-auth/src/device_code/tests.rs b/packages/stack-auth/src/device_code/tests.rs index f23454c78..3ee3b5532 100644 --- a/packages/stack-auth/src/device_code/tests.rs +++ b/packages/stack-auth/src/device_code/tests.rs @@ -13,9 +13,37 @@ fn device_code_json() -> serde_json::Value { }) } +/// Build a valid JWT access token containing a workspace claim. +fn test_access_token() -> String { + use jsonwebtoken::{encode, EncodingKey, Header}; + use std::time::{SystemTime, UNIX_EPOCH}; + + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs(); + + let claims = serde_json::json!({ + "iss": "https://cts.example.com/", + "sub": "CS|test-user", + "aud": "test-audience", + "iat": now, + "exp": now + 3600, + "workspace": "ZVATKW3VHMFG27DY", + "scope": "", + }); + + encode( + &Header::default(), + &claims, + &EncodingKey::from_secret(b"test-secret"), + ) + .unwrap() +} + fn token_json() -> serde_json::Value { serde_json::json!({ - "access_token": "test_access_token_value", + "access_token": test_access_token(), "token_type": "Bearer", "expires_in": 3600 }) @@ -124,10 +152,13 @@ async fn test_poll_for_token_success() { .await .unwrap(); - assert_eq!(token.access_token().0, "test_access_token_value"); assert_eq!(token.token_type(), "Bearer"); assert!(!token.is_expired()); assert!((3598..=3600).contains(&token.expires_in())); + + // Verify the token was saved to the workspace directory + let store = ProfileStore::new(dir.path()); + assert_eq!(store.current_workspace().unwrap(), "ZVATKW3VHMFG27DY"); } #[tokio::test(start_paused = true)] @@ -256,7 +287,7 @@ async fn test_poll_for_token_authorization_pending_then_success() { }); let token = result.unwrap(); - assert_eq!(token.access_token().0, "test_access_token_value"); + assert_eq!(token.token_type(), "Bearer"); } #[tokio::test(start_paused = true)] @@ -283,7 +314,7 @@ async fn test_poll_for_token_slow_down_then_success() { }); let token = result.unwrap(); - assert_eq!(token.access_token().0, "test_access_token_value"); + assert_eq!(token.token_type(), "Bearer"); } /// Proves that `slow_down` increases the poll interval: with a short diff --git a/packages/stack-auth/src/oauth_strategy.rs b/packages/stack-auth/src/oauth_strategy.rs index a5bfe864a..4b28e44c7 100644 --- a/packages/stack-auth/src/oauth_strategy.rs +++ b/packages/stack-auth/src/oauth_strategy.rs @@ -154,7 +154,8 @@ impl OAuthStrategyBuilder { }) } OAuthTokenSource::Store(store) => { - let token: Token = store.load_profile()?; + let ws_store = store.current_workspace_store()?; + let token: Token = ws_store.load_profile()?; let region_str = token .region() @@ -179,7 +180,7 @@ impl OAuthStrategyBuilder { }; let refresher = OAuthRefresher::new( - Some(store), + Some(ws_store), ensure_trailing_slash(base_url), &client_id, ®ion_str, diff --git a/packages/stack-profile/src/error.rs b/packages/stack-profile/src/error.rs index 2ba9051db..f88739c05 100644 --- a/packages/stack-profile/src/error.rs +++ b/packages/stack-profile/src/error.rs @@ -22,4 +22,10 @@ pub enum ProfileError { /// The filename is invalid (contains path separators, `..`, or is absolute). #[error("Invalid profile filename: {0}")] InvalidFilename(String), + /// No current workspace is set but a workspace-scoped operation was attempted. + #[error("No current workspace set. Run `stash login` or `stash workspaces switch` first.")] + NoCurrentWorkspace, + /// The workspace ID is invalid (not a 16-character base32 string). + #[error("Invalid workspace ID: {0}")] + InvalidWorkspaceId(String), } diff --git a/packages/stack-profile/src/profile_store.rs b/packages/stack-profile/src/profile_store.rs index 9b508c55b..666efa403 100644 --- a/packages/stack-profile/src/profile_store.rs +++ b/packages/stack-profile/src/profile_store.rs @@ -7,6 +7,8 @@ use crate::{ProfileData, ProfileError}; const CS_CONFIG_PATH_ENV: &str = "CS_CONFIG_PATH"; const DEFAULT_DIR_NAME: &str = ".cipherstash"; +const WORKSPACES_DIR: &str = "workspaces"; +const CURRENT_WORKSPACE_FILE: &str = "current_workspace"; /// A directory-scoped JSON file store for profile data. /// @@ -32,6 +34,7 @@ const DEFAULT_DIR_NAME: &str = ".cipherstash"; /// # Ok(()) /// # } /// ``` +#[derive(Debug)] pub struct ProfileStore { dir: PathBuf, } @@ -104,6 +107,136 @@ impl ProfileStore { Ok(()) } + /// Validate that a workspace ID is a 16-character base32 string (A-Z, 2-7). + /// + /// This prevents path traversal without depending on `cts_common::WorkspaceId`. + fn validate_workspace_id(id: &str) -> Result<(), ProfileError> { + let valid = id.len() == 16 + && id + .bytes() + .all(|b| b.is_ascii_uppercase() || (b'2'..=b'7').contains(&b)); + if valid { + Ok(()) + } else { + Err(ProfileError::InvalidWorkspaceId(id.to_string())) + } + } + + // ---- Workspace management ---- + + /// Set the current workspace. + /// + /// Writes the workspace ID to the `current_workspace` file in the profile + /// directory. Subsequent workspace-scoped operations will use this workspace. + pub fn set_current_workspace(&self, workspace_id: &str) -> Result<(), ProfileError> { + Self::validate_workspace_id(workspace_id)?; + std::fs::create_dir_all(&self.dir)?; + let path = self.dir.join(CURRENT_WORKSPACE_FILE); + std::fs::write(&path, workspace_id)?; + Ok(()) + } + + /// Return the current workspace ID. + /// + /// Returns [`ProfileError::NoCurrentWorkspace`] if no workspace has been set. + pub fn current_workspace(&self) -> Result { + let path = self.dir.join(CURRENT_WORKSPACE_FILE); + match std::fs::read_to_string(&path) { + Ok(contents) => { + let id = contents.trim().to_string(); + Self::validate_workspace_id(&id)?; + Ok(id) + } + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + Err(ProfileError::NoCurrentWorkspace) + } + Err(e) => Err(ProfileError::Io(e)), + } + } + + /// Remove the current workspace selection. + pub fn clear_current_workspace(&self) -> Result<(), ProfileError> { + let path = self.dir.join(CURRENT_WORKSPACE_FILE); + match std::fs::remove_file(&path) { + Ok(()) => Ok(()), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(e) => Err(ProfileError::Io(e)), + } + } + + /// List workspace IDs that have profile data on disk. + /// + /// Returns a sorted list of workspace IDs that have subdirectories in + /// the `workspaces/` directory. + pub fn list_workspaces(&self) -> Result, ProfileError> { + let ws_dir = self.dir.join(WORKSPACES_DIR); + match std::fs::read_dir(&ws_dir) { + Ok(entries) => { + let mut ids = Vec::new(); + for entry in entries { + let entry = entry?; + if entry.file_type()?.is_dir() { + if let Some(name) = entry.file_name().to_str() { + if Self::validate_workspace_id(name).is_ok() { + ids.push(name.to_string()); + } + } + } + } + ids.sort(); + Ok(ids) + } + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Vec::new()), + Err(e) => Err(ProfileError::Io(e)), + } + } + + /// Return a [`ProfileStore`] scoped to a specific workspace directory. + /// + /// The returned store is rooted at `workspaces//` within this + /// store's directory. All `save`/`load`/`save_profile`/`load_profile` calls + /// on the returned store operate inside that workspace directory. + pub fn workspace_store(&self, workspace_id: &str) -> Result { + Self::validate_workspace_id(workspace_id)?; + Ok(ProfileStore::new( + self.dir.join(WORKSPACES_DIR).join(workspace_id), + )) + } + + /// Return a [`ProfileStore`] scoped to the current workspace. + /// + /// Shortcut for `store.workspace_store(&store.current_workspace()?)`. + /// Returns [`ProfileError::NoCurrentWorkspace`] if no workspace has been set. + pub fn current_workspace_store(&self) -> Result { + let id = self.current_workspace()?; + self.workspace_store(&id) + } + + /// Move legacy flat-file profiles into a workspace directory. + /// + /// Moves `auth.json` and `secretkey.json` from the profile root into + /// `workspaces//` and sets `workspace_id` as the current + /// workspace. Files that already exist in the target are not overwritten. + /// Missing source files are silently skipped. + pub fn migrate_to_workspace(&self, workspace_id: &str) -> Result<(), ProfileError> { + Self::validate_workspace_id(workspace_id)?; + let ws_dir = self.dir.join(WORKSPACES_DIR).join(workspace_id); + std::fs::create_dir_all(&ws_dir)?; + + for filename in &["auth.json", "secretkey.json"] { + let src = self.dir.join(filename); + let dst = ws_dir.join(filename); + if src.exists() && !dst.exists() { + std::fs::rename(&src, &dst)?; + } + } + + self.set_current_workspace(workspace_id)?; + Ok(()) + } + + // ---- Internal write helpers ---- + fn write( &self, filename: &str, @@ -114,7 +247,11 @@ impl ProfileStore { std::fs::create_dir_all(&self.dir)?; let path = self.dir.join(filename); let json = serde_json::to_string_pretty(value)?; + Self::write_to_path(&path, &json, _mode) + } + /// Write JSON content to an absolute path, optionally setting Unix file permissions. + fn write_to_path(path: &Path, json: &str, _mode: Option) -> Result<(), ProfileError> { #[cfg(unix)] if let Some(mode) = _mode { use std::fs::OpenOptions; @@ -126,18 +263,18 @@ impl ProfileStore { .create(true) .truncate(true) .mode(mode) - .open(&path)?; + .open(path)?; file.write_all(json.as_bytes())?; // Ensure permissions are set even if the file already existed, // since OpenOptions::mode() only applies on creation. use std::os::unix::fs::PermissionsExt; - std::fs::set_permissions(&path, std::fs::Permissions::from_mode(mode))?; + std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode))?; return Ok(()); } - std::fs::write(&path, json)?; + std::fs::write(path, json)?; Ok(()) } @@ -491,4 +628,409 @@ mod tests { "permissions should be tightened on existing file" ); } + + mod workspace { + use super::*; + use crate::ProfileData; + + const WS_A: &str = "AAAAAAAAAAAAAAAA"; + const WS_B: &str = "BBBBBBBBBBBBBBBB"; + + #[derive(Debug, PartialEq, Serialize, Deserialize)] + struct WsData { + name: String, + } + + impl ProfileData for WsData { + const FILENAME: &'static str = "ws-data.json"; + } + + mod given_no_workspace_set { + use super::*; + + #[test] + fn current_workspace_returns_no_current_workspace() { + let dir = tempfile::tempdir().unwrap(); + let store = ProfileStore::new(dir.path()); + + let err = store.current_workspace().unwrap_err(); + assert!( + matches!(err, ProfileError::NoCurrentWorkspace), + "expected NoCurrentWorkspace, got: {err:?}" + ); + } + + #[test] + fn current_workspace_store_returns_no_current_workspace() { + let dir = tempfile::tempdir().unwrap(); + let store = ProfileStore::new(dir.path()); + + let err = store.current_workspace_store().unwrap_err(); + assert!( + matches!(err, ProfileError::NoCurrentWorkspace), + "expected NoCurrentWorkspace, got: {err:?}" + ); + } + + #[test] + fn clear_current_workspace_succeeds() { + let dir = tempfile::tempdir().unwrap(); + let store = ProfileStore::new(dir.path()); + store.clear_current_workspace().unwrap(); + } + } + + mod given_workspace_set { + use super::*; + + fn scenario() -> (tempfile::TempDir, ProfileStore) { + let dir = tempfile::tempdir().unwrap(); + let store = ProfileStore::new(dir.path()); + store.set_current_workspace(WS_A).unwrap(); + (dir, store) + } + + #[test] + fn returns_workspace_id() { + let (_dir, store) = scenario(); + assert_eq!( + store.current_workspace().unwrap(), + WS_A, + "should return the workspace that was set" + ); + } + + #[test] + fn current_workspace_store_returns_scoped_store() { + let (dir, store) = scenario(); + let ws_store = store.current_workspace_store().unwrap(); + assert_eq!( + ws_store.dir(), + dir.path().join("workspaces").join(WS_A), + "workspace store should be rooted in workspaces/" + ); + } + + #[test] + fn clear_removes_selection() { + let (_dir, store) = scenario(); + store.clear_current_workspace().unwrap(); + + let err = store.current_workspace().unwrap_err(); + assert!( + matches!(err, ProfileError::NoCurrentWorkspace), + "expected NoCurrentWorkspace after clear, got: {err:?}" + ); + } + + #[test] + fn save_and_load_round_trips_through_workspace_store() { + let (dir, store) = scenario(); + let ws_store = store.current_workspace_store().unwrap(); + + let data = WsData { + name: "hello".into(), + }; + ws_store.save_profile(&data).unwrap(); + + let loaded: WsData = ws_store.load_profile().unwrap(); + assert_eq!(loaded, data, "workspace store should round-trip data"); + + assert!( + dir.path() + .join("workspaces") + .join(WS_A) + .join("ws-data.json") + .exists(), + "file should be in the workspace directory" + ); + assert!( + !store.exists_profile::(), + "root store should not see workspace-scoped file" + ); + } + } + + mod given_multiple_workspaces { + use super::*; + + fn scenario() -> (tempfile::TempDir, ProfileStore) { + let dir = tempfile::tempdir().unwrap(); + let store = ProfileStore::new(dir.path()); + + store + .workspace_store(WS_A) + .unwrap() + .save_profile(&WsData { + name: "alpha".into(), + }) + .unwrap(); + store + .workspace_store(WS_B) + .unwrap() + .save_profile(&WsData { + name: "bravo".into(), + }) + .unwrap(); + + (dir, store) + } + + #[test] + fn switching_changes_current_workspace_store_data() { + let (_dir, store) = scenario(); + + store.set_current_workspace(WS_A).unwrap(); + let loaded: WsData = store + .current_workspace_store() + .unwrap() + .load_profile() + .unwrap(); + assert_eq!( + loaded.name, "alpha", + "should load workspace A data after switching to A" + ); + + store.set_current_workspace(WS_B).unwrap(); + let loaded: WsData = store + .current_workspace_store() + .unwrap() + .load_profile() + .unwrap(); + assert_eq!( + loaded.name, "bravo", + "should load workspace B data after switching to B" + ); + } + + #[test] + fn list_workspaces_returns_sorted_ids() { + let (_dir, store) = scenario(); + + let workspaces = store.list_workspaces().unwrap(); + assert_eq!( + workspaces, + vec![WS_A, WS_B], + "should list both workspaces in sorted order" + ); + } + } + + mod list_workspaces { + use super::*; + + #[test] + fn returns_empty_when_no_workspaces_dir() { + let dir = tempfile::tempdir().unwrap(); + let store = ProfileStore::new(dir.path()); + assert_eq!( + store.list_workspaces().unwrap(), + Vec::::new(), + "should return empty list when workspaces/ does not exist" + ); + } + + #[test] + fn ignores_files_and_invalid_dirs() { + let dir = tempfile::tempdir().unwrap(); + let store = ProfileStore::new(dir.path()); + + let ws_dir = dir.path().join("workspaces"); + std::fs::create_dir_all(&ws_dir).unwrap(); + std::fs::create_dir(ws_dir.join(WS_A)).unwrap(); + std::fs::write(ws_dir.join("not-a-dir.txt"), "").unwrap(); + std::fs::create_dir(ws_dir.join("invalid-name")).unwrap(); + + let workspaces = store.list_workspaces().unwrap(); + assert_eq!( + workspaces, + vec![WS_A], + "should only include valid workspace directories" + ); + } + } + + mod workspace_store { + use super::*; + + #[test] + fn returns_scoped_store() { + let dir = tempfile::tempdir().unwrap(); + let store = ProfileStore::new(dir.path()); + + let ws_store = store.workspace_store(WS_A).unwrap(); + assert_eq!( + ws_store.dir(), + dir.path().join("workspaces").join(WS_A), + "workspace store should be rooted in workspaces/" + ); + } + + #[test] + fn rejects_invalid_id() { + let dir = tempfile::tempdir().unwrap(); + let store = ProfileStore::new(dir.path()); + + let err = store.workspace_store("../escape").unwrap_err(); + assert!( + matches!(err, ProfileError::InvalidWorkspaceId(_)), + "expected InvalidWorkspaceId for path traversal, got: {err:?}" + ); + } + } + + mod validate_workspace_id { + use super::*; + + #[test] + fn accepts_valid_base32() { + ProfileStore::validate_workspace_id("ABCDEFGH234567AB").unwrap(); + ProfileStore::validate_workspace_id(WS_A).unwrap(); + } + + #[test] + fn rejects_lowercase() { + let err = ProfileStore::validate_workspace_id("abcdefgh234567ab").unwrap_err(); + assert!( + matches!(err, ProfileError::InvalidWorkspaceId(_)), + "expected InvalidWorkspaceId for lowercase, got: {err:?}" + ); + } + + #[test] + fn rejects_wrong_length() { + let err = ProfileStore::validate_workspace_id("SHORT").unwrap_err(); + assert!( + matches!(err, ProfileError::InvalidWorkspaceId(_)), + "expected InvalidWorkspaceId for short string, got: {err:?}" + ); + } + + #[test] + fn rejects_empty() { + let err = ProfileStore::validate_workspace_id("").unwrap_err(); + assert!( + matches!(err, ProfileError::InvalidWorkspaceId(_)), + "expected InvalidWorkspaceId for empty string, got: {err:?}" + ); + } + + #[test] + fn rejects_path_traversal() { + let err = ProfileStore::validate_workspace_id("../escape.json..").unwrap_err(); + assert!( + matches!(err, ProfileError::InvalidWorkspaceId(_)), + "expected InvalidWorkspaceId for path traversal, got: {err:?}" + ); + } + + #[test] + fn rejects_non_base32_digits() { + let err = ProfileStore::validate_workspace_id("0000000000000000").unwrap_err(); + assert!( + matches!(err, ProfileError::InvalidWorkspaceId(_)), + "expected InvalidWorkspaceId for digits outside base32 alphabet, got: {err:?}" + ); + } + } + + mod migrate_to_workspace { + use super::*; + + mod given_legacy_flat_files { + use super::*; + + fn scenario() -> (tempfile::TempDir, ProfileStore) { + let dir = tempfile::tempdir().unwrap(); + let store = ProfileStore::new(dir.path()); + std::fs::create_dir_all(dir.path()).unwrap(); + std::fs::write(dir.path().join("auth.json"), r#"{"token":"old"}"#).unwrap(); + std::fs::write(dir.path().join("secretkey.json"), r#"{"key":"old"}"#).unwrap(); + (dir, store) + } + + #[test] + fn moves_files_to_workspace_dir() { + let (dir, store) = scenario(); + store.migrate_to_workspace(WS_A).unwrap(); + + assert!( + !dir.path().join("auth.json").exists(), + "legacy auth.json should be removed from root" + ); + assert!( + !dir.path().join("secretkey.json").exists(), + "legacy secretkey.json should be removed from root" + ); + + let ws_dir = dir.path().join("workspaces").join(WS_A); + assert!( + ws_dir.join("auth.json").exists(), + "auth.json should be in workspace dir" + ); + assert!( + ws_dir.join("secretkey.json").exists(), + "secretkey.json should be in workspace dir" + ); + } + + #[test] + fn sets_current_workspace() { + let (_dir, store) = scenario(); + store.migrate_to_workspace(WS_A).unwrap(); + assert_eq!( + store.current_workspace().unwrap(), + WS_A, + "current workspace should be set after migration" + ); + } + } + + mod given_existing_files_in_target { + use super::*; + + #[test] + fn does_not_overwrite() { + let dir = tempfile::tempdir().unwrap(); + let store = ProfileStore::new(dir.path()); + + std::fs::create_dir_all(dir.path()).unwrap(); + std::fs::write(dir.path().join("auth.json"), r#"{"token":"legacy"}"#).unwrap(); + + let ws_dir = dir.path().join("workspaces").join(WS_A); + std::fs::create_dir_all(&ws_dir).unwrap(); + std::fs::write(ws_dir.join("auth.json"), r#"{"token":"existing"}"#).unwrap(); + + store.migrate_to_workspace(WS_A).unwrap(); + + let contents = std::fs::read_to_string(ws_dir.join("auth.json")).unwrap(); + assert!( + contents.contains("existing"), + "workspace file should be unchanged, got: {contents}" + ); + assert!( + dir.path().join("auth.json").exists(), + "legacy file should remain when target exists" + ); + } + } + + mod given_no_legacy_files { + use super::*; + + #[test] + fn sets_current_workspace() { + let dir = tempfile::tempdir().unwrap(); + let store = ProfileStore::new(dir.path()); + + store.migrate_to_workspace(WS_A).unwrap(); + assert_eq!( + store.current_workspace().unwrap(), + WS_A, + "should set current workspace even without legacy files" + ); + } + } + } + } } From 9f89977a10c43f128f1d8de7b0e00243621f88b9 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Thu, 2 Apr 2026 17:33:16 -0700 Subject: [PATCH 155/686] refactor(stack-auth): simplify workspace store usage Use current_workspace_store() shortcut in bind_client_device, simplify nested match in poll_for_token with and_then, remove redundant comments. --- packages/stack-auth/src/device_client.rs | 5 +---- packages/stack-auth/src/device_code/mod.rs | 18 ++++++++---------- 2 files changed, 9 insertions(+), 14 deletions(-) diff --git a/packages/stack-auth/src/device_client.rs b/packages/stack-auth/src/device_client.rs index a09ade198..35d4df60a 100644 --- a/packages/stack-auth/src/device_client.rs +++ b/packages/stack-auth/src/device_client.rs @@ -79,9 +79,7 @@ pub enum DeviceClientError { /// If the secret key already exists on disk, or the server returns 409 /// (conflict), this is a no-op. pub async fn bind_client_device(store: &ProfileStore) -> Result<(), DeviceClientError> { - // Resolve the workspace-scoped store for secret key operations. - let ws_id = store.current_workspace()?; - let ws_store = store.workspace_store(&ws_id)?; + let ws_store = store.current_workspace_store()?; if ws_store.exists(SECRET_KEY_FILENAME) { tracing::debug!("secret key already exists, skipping provisioning"); @@ -134,7 +132,6 @@ pub async fn bind_client_device(store: &ProfileStore) -> Result<(), DeviceClient client_key: created.client_key, }; - // Save to the workspace-scoped directory. ws_store.save_with_mode(SECRET_KEY_FILENAME, &secret_key, SECRET_KEY_MODE)?; Ok(()) diff --git a/packages/stack-auth/src/device_code/mod.rs b/packages/stack-auth/src/device_code/mod.rs index a5ce268da..67fa9de2a 100644 --- a/packages/stack-auth/src/device_code/mod.rs +++ b/packages/stack-auth/src/device_code/mod.rs @@ -341,21 +341,19 @@ impl PendingDeviceCode { None => ProfileStore::resolve(None)?, }; - // Set the current workspace and save the token to the - // workspace directory. let workspace_id = token.workspace_id()?; store.set_current_workspace(workspace_id.as_str())?; - match store.workspace_store(workspace_id.as_str()) { - Ok(ws_store) => match ws_store.save_profile(&token) { - Ok(()) => tracing::debug!( + store + .workspace_store(workspace_id.as_str()) + .and_then(|ws| ws.save_profile(&token)) + .map(|()| { + tracing::debug!( workspace = workspace_id.as_str(), "token saved to workspace directory" - ), - Err(err) => tracing::warn!(%err, "failed to save token to disk"), - }, - Err(err) => tracing::warn!(%err, "failed to resolve workspace store"), - } + ) + }) + .unwrap_or_else(|err| tracing::warn!(%err, "failed to save token to disk")); return Ok(token); } From 965c976e75208753610a01de4d17fdf69730f840 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Thu, 2 Apr 2026 18:10:20 -0700 Subject: [PATCH 156/686] fix(stack-auth): update integration tests for workspace-scoped profiles Update mock_token_endpoint to return a real JWT (required now that poll_for_token extracts the workspace ID from the token). Update JS provision tests to read/write secretkey.json from the workspace subdirectory. Regenerate package-lock.json to match package.json version bump. --- .../__tests__/provision-device-client.test.ts | 16 ++-- .../packages/auth/package-lock.json | 82 ++++--------------- .../packages/auth/src/mock_auth_server.rs | 36 +++++++- 3 files changed, 60 insertions(+), 74 deletions(-) diff --git a/languages/typescript/packages/auth/__tests__/provision-device-client.test.ts b/languages/typescript/packages/auth/__tests__/provision-device-client.test.ts index 9b39f3bc3..b5b549aaa 100644 --- a/languages/typescript/packages/auth/__tests__/provision-device-client.test.ts +++ b/languages/typescript/packages/auth/__tests__/provision-device-client.test.ts @@ -23,6 +23,8 @@ const { // Helpers // --------------------------------------------------------------------------- +const TEST_WORKSPACE_ID = "ZVATKW3VHMFG27DY"; + let server: InstanceType; let profileDir: string; @@ -35,6 +37,10 @@ function freshProfileDir(): string { return mkdtempSync(join(tmpdir(), "cs-auth-test-")); } +function workspaceDir(): string { + return join(profileDir, "workspaces", TEST_WORKSPACE_ID); +} + // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- @@ -51,7 +57,7 @@ describe("provision device client (TypeScript / vitest)", () => { await bindClientDeviceWithProfileDir(profileDir); - const raw = readFileSync(join(profileDir, "secretkey.json"), "utf-8"); + const raw = readFileSync(join(workspaceDir(), "secretkey.json"), "utf-8"); const secretKey = JSON.parse(raw); expect(secretKey.client_id).toBe( "00000000-0000-0000-0000-000000000001" @@ -63,16 +69,16 @@ describe("provision device client (TypeScript / vitest)", () => { // No mock endpoints needed — should short-circuit before any HTTP call. saveTestToken(profileDir, server.baseUrl); - // Pre-create secretkey.json + // Pre-create secretkey.json in the workspace directory const existing = JSON.stringify({ client_id: "existing-id", client_key: "existing-key", }); - writeFileSync(join(profileDir, "secretkey.json"), existing); + writeFileSync(join(workspaceDir(), "secretkey.json"), existing); await bindClientDeviceWithProfileDir(profileDir); - const raw = readFileSync(join(profileDir, "secretkey.json"), "utf-8"); + const raw = readFileSync(join(workspaceDir(), "secretkey.json"), "utf-8"); const secretKey = JSON.parse(raw); expect(secretKey.client_id).toBe("existing-id"); }); @@ -83,7 +89,7 @@ describe("provision device client (TypeScript / vitest)", () => { await bindClientDeviceWithProfileDir(profileDir); - expect(existsSync(join(profileDir, "secretkey.json"))).toBe(false); + expect(existsSync(join(workspaceDir(), "secretkey.json"))).toBe(false); }); it("throws on server error", async () => { diff --git a/languages/typescript/packages/auth/package-lock.json b/languages/typescript/packages/auth/package-lock.json index ea0e3a860..7b3c62373 100644 --- a/languages/typescript/packages/auth/package-lock.json +++ b/languages/typescript/packages/auth/package-lock.json @@ -1,97 +1,43 @@ { "name": "@cipherstash/auth", - "version": "0.34.1", + "version": "0.35.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@cipherstash/auth", - "version": "0.34.1", + "version": "0.35.0", "devDependencies": { "@napi-rs/cli": "^2", "typescript": "^5", "vitest": "^3" }, "optionalDependencies": { - "@cipherstash/auth-darwin-arm64": "0.34.1", - "@cipherstash/auth-darwin-x64": "0.34.1", - "@cipherstash/auth-linux-arm64-gnu": "0.34.1", - "@cipherstash/auth-linux-x64-gnu": "0.34.1", - "@cipherstash/auth-linux-x64-musl": "0.34.1", - "@cipherstash/auth-win32-x64-msvc": "0.34.1" + "@cipherstash/auth-darwin-arm64": "0.35.0", + "@cipherstash/auth-darwin-x64": "0.35.0", + "@cipherstash/auth-linux-arm64-gnu": "0.35.0", + "@cipherstash/auth-linux-x64-gnu": "0.35.0", + "@cipherstash/auth-linux-x64-musl": "0.35.0", + "@cipherstash/auth-win32-x64-msvc": "0.35.0" } }, "node_modules/@cipherstash/auth-darwin-arm64": { - "version": "0.34.1", - "resolved": "https://registry.npmjs.org/@cipherstash/auth-darwin-arm64/-/auth-darwin-arm64-0.34.1.tgz", - "integrity": "sha512-e7FStj+EKhT6HL+QaJUb2Kl75DLty7wAnRS5017kz1NWku9kmRU/1oqmhczJ7P9ktQQuykOneWOibIl4Zg73aQ==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "darwin" - ] + "optional": true }, "node_modules/@cipherstash/auth-darwin-x64": { - "version": "0.34.1", - "resolved": "https://registry.npmjs.org/@cipherstash/auth-darwin-x64/-/auth-darwin-x64-0.34.1.tgz", - "integrity": "sha512-qgo46tznJI4p6AJaafDopwDZNJNFK+n69Ik7bAEg5tFofW0N9YKCvdjcuH4hovxYSRnzapmdCpGQmx3QT5wtww==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "darwin" - ] + "optional": true }, "node_modules/@cipherstash/auth-linux-arm64-gnu": { - "version": "0.34.1", - "resolved": "https://registry.npmjs.org/@cipherstash/auth-linux-arm64-gnu/-/auth-linux-arm64-gnu-0.34.1.tgz", - "integrity": "sha512-Rhkp+77rMRUx9bjeDiQHVz13FnLlpmJGnN3mWz0njKJCdljshbhwLc7MSvRvf9Uou3AOipoUsGIPs5vrJlcPDA==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "linux" - ] + "optional": true }, "node_modules/@cipherstash/auth-linux-x64-gnu": { - "version": "0.34.1", - "resolved": "https://registry.npmjs.org/@cipherstash/auth-linux-x64-gnu/-/auth-linux-x64-gnu-0.34.1.tgz", - "integrity": "sha512-fgMfaDBOX0xIngigWS+k2ZGCQejfrA7JXKeAZoJ5ELL5nklbJOG4CTV6TE+HL4xHthWhmJvX6b7j7rb7PWOQiA==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "linux" - ] + "optional": true }, "node_modules/@cipherstash/auth-linux-x64-musl": { - "version": "0.34.1", - "resolved": "https://registry.npmjs.org/@cipherstash/auth-linux-x64-musl/-/auth-linux-x64-musl-0.34.1.tgz", - "integrity": "sha512-mhKzijUQ+N5B1NAX7bsw/lHYUtqpu1qfzebtB8C0Kx4JxzZ9fWil8bbxkHjSADCkWpjeELwVAYUmBp0mvnt4gA==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "linux" - ] + "optional": true }, "node_modules/@cipherstash/auth-win32-x64-msvc": { - "version": "0.34.1", - "resolved": "https://registry.npmjs.org/@cipherstash/auth-win32-x64-msvc/-/auth-win32-x64-msvc-0.34.1.tgz", - "integrity": "sha512-SxAwvnFDAWh6ZYLdrPhGMpZ4OtiVNZtfoHE49NXFnjXFcKl8pvuOuzF8TWQWeTq2RvPPGLCoReHJ2avPZMlc+A==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "win32" - ] + "optional": true }, "node_modules/@esbuild/aix-ppc64": { "version": "0.27.3", diff --git a/languages/typescript/packages/auth/src/mock_auth_server.rs b/languages/typescript/packages/auth/src/mock_auth_server.rs index f6499be2b..adaade517 100644 --- a/languages/typescript/packages/auth/src/mock_auth_server.rs +++ b/languages/typescript/packages/auth/src/mock_auth_server.rs @@ -2,6 +2,39 @@ use mocktail::prelude::*; use napi::bindgen_prelude::*; use napi_derive::napi; +/// Build a valid JWT access token containing a workspace claim. +/// +/// Used by mock endpoints that need to return a token that can be decoded +/// by `Token::workspace_id()`. +fn test_jwt() -> String { + use jsonwebtoken::{encode, EncodingKey, Header}; + use std::time::{SystemTime, UNIX_EPOCH}; + + #[allow(clippy::expect_used)] + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system clock") + .as_secs(); + + let claims = serde_json::json!({ + "iss": "https://cts.example.com/", + "sub": "CS|test-user", + "aud": "test-audience", + "iat": now, + "exp": now + 3600, + "workspace": "ZVATKW3VHMFG27DY", + "scope": "", + }); + + #[allow(clippy::expect_used)] + encode( + &Header::default(), + &claims, + &EncodingKey::from_secret(b"test-secret"), + ) + .expect("JWT encode") +} + #[napi] pub struct MockAuthServer { server: MockServer, @@ -46,10 +79,11 @@ impl MockAuthServer { /// token JSON response. #[napi] pub fn mock_token_endpoint(&self) { + let jwt = test_jwt(); self.server.mocks().mock(|when, then| { when.post().path("/oauth/device/token"); then.json(serde_json::json!({ - "access_token": "test_access_token_value", + "access_token": jwt, "token_type": "Bearer", "expires_in": 3600 })); From fed563330f01852c152c20348c0146afb145334a Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Thu, 2 Apr 2026 18:20:49 -0700 Subject: [PATCH 157/686] fix(stack-auth): hard-error on token persistence failure, strengthen test assertions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Make poll_for_token propagate errors from workspace persistence — if the token can't be saved, the login is useless and should fail clearly. Add workspace_id() assertions to poll_for_token success tests to verify the JWT contains a valid workspace claim. --- packages/stack-auth/src/device_code/mod.rs | 17 ++++++---------- packages/stack-auth/src/device_code/tests.rs | 21 ++++++++++++++++++-- 2 files changed, 25 insertions(+), 13 deletions(-) diff --git a/packages/stack-auth/src/device_code/mod.rs b/packages/stack-auth/src/device_code/mod.rs index 67fa9de2a..8439095a6 100644 --- a/packages/stack-auth/src/device_code/mod.rs +++ b/packages/stack-auth/src/device_code/mod.rs @@ -340,20 +340,15 @@ impl PendingDeviceCode { Some(dir) => ProfileStore::new(dir), None => ProfileStore::resolve(None)?, }; - let workspace_id = token.workspace_id()?; store.set_current_workspace(workspace_id.as_str())?; - store - .workspace_store(workspace_id.as_str()) - .and_then(|ws| ws.save_profile(&token)) - .map(|()| { - tracing::debug!( - workspace = workspace_id.as_str(), - "token saved to workspace directory" - ) - }) - .unwrap_or_else(|err| tracing::warn!(%err, "failed to save token to disk")); + .workspace_store(workspace_id.as_str())? + .save_profile(&token)?; + tracing::debug!( + workspace = workspace_id.as_str(), + "token saved to workspace directory" + ); return Ok(token); } diff --git a/packages/stack-auth/src/device_code/tests.rs b/packages/stack-auth/src/device_code/tests.rs index 3ee3b5532..357c5e318 100644 --- a/packages/stack-auth/src/device_code/tests.rs +++ b/packages/stack-auth/src/device_code/tests.rs @@ -155,10 +155,19 @@ async fn test_poll_for_token_success() { assert_eq!(token.token_type(), "Bearer"); assert!(!token.is_expired()); assert!((3598..=3600).contains(&token.expires_in())); + assert_eq!( + token.workspace_id().unwrap().as_str(), + "ZVATKW3VHMFG27DY", + "workspace ID should be extracted from the JWT" + ); - // Verify the token was saved to the workspace directory + // Verify the token was persisted to the workspace directory let store = ProfileStore::new(dir.path()); - assert_eq!(store.current_workspace().unwrap(), "ZVATKW3VHMFG27DY"); + assert_eq!( + store.current_workspace().unwrap(), + "ZVATKW3VHMFG27DY", + "current workspace should be set after poll_for_token" + ); } #[tokio::test(start_paused = true)] @@ -288,6 +297,10 @@ async fn test_poll_for_token_authorization_pending_then_success() { let token = result.unwrap(); assert_eq!(token.token_type(), "Bearer"); + assert!( + token.workspace_id().is_ok(), + "token should contain a valid workspace claim" + ); } #[tokio::test(start_paused = true)] @@ -315,6 +328,10 @@ async fn test_poll_for_token_slow_down_then_success() { let token = result.unwrap(); assert_eq!(token.token_type(), "Bearer"); + assert!( + token.workspace_id().is_ok(), + "token should contain a valid workspace claim" + ); } /// Proves that `slow_down` increases the poll interval: with a short From 0da72c40f19138b7d492629381fb53de46001b89 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Thu, 2 Apr 2026 18:59:19 -0700 Subject: [PATCH 158/686] feat(stack-profile): add Node.js NAPI bindings (@cipherstash/profile) Create @cipherstash/profile npm package exposing ProfileStore workspace management to Node.js consumers. Follows the same NAPI pattern as @cipherstash/auth: platform-specific loader, error code enrichment, and TypeScript type declarations. Exposes: resolve, withDir, setCurrentWorkspace, currentWorkspace, clearCurrentWorkspace, listWorkspaces, workspaceStore, and currentWorkspaceStore. --- .../typescript/packages/profile/Cargo.toml | 16 + .../profile/__tests__/profile-store.test.ts | 149 ++ .../typescript/packages/profile/build.rs | 5 + .../typescript/packages/profile/index.d.ts | 49 + .../typescript/packages/profile/index.js | 39 + .../packages/profile/package-lock.json | 1644 +++++++++++++++++ .../typescript/packages/profile/package.json | 44 + .../platforms/darwin-arm64/package.json | 8 + .../profile/platforms/darwin-x64/package.json | 8 + .../platforms/linux-arm64-gnu/package.json | 8 + .../platforms/linux-x64-gnu/package.json | 8 + .../platforms/linux-x64-musl/package.json | 8 + .../platforms/win32-x64-msvc/package.json | 8 + .../typescript/packages/profile/src/lib.rs | 108 ++ .../packages/profile/stack-profile-node.js | 66 + .../packages/profile/stack-profile-node.node | Bin 0 -> 2136408 bytes .../typescript/packages/profile/tsconfig.json | 11 + .../packages/profile/vitest.config.ts | 7 + packages/stack-profile/tasks.toml | 9 + 19 files changed, 2195 insertions(+) create mode 100644 languages/typescript/packages/profile/Cargo.toml create mode 100644 languages/typescript/packages/profile/__tests__/profile-store.test.ts create mode 100644 languages/typescript/packages/profile/build.rs create mode 100644 languages/typescript/packages/profile/index.d.ts create mode 100644 languages/typescript/packages/profile/index.js create mode 100644 languages/typescript/packages/profile/package-lock.json create mode 100644 languages/typescript/packages/profile/package.json create mode 100644 languages/typescript/packages/profile/platforms/darwin-arm64/package.json create mode 100644 languages/typescript/packages/profile/platforms/darwin-x64/package.json create mode 100644 languages/typescript/packages/profile/platforms/linux-arm64-gnu/package.json create mode 100644 languages/typescript/packages/profile/platforms/linux-x64-gnu/package.json create mode 100644 languages/typescript/packages/profile/platforms/linux-x64-musl/package.json create mode 100644 languages/typescript/packages/profile/platforms/win32-x64-msvc/package.json create mode 100644 languages/typescript/packages/profile/src/lib.rs create mode 100644 languages/typescript/packages/profile/stack-profile-node.js create mode 100755 languages/typescript/packages/profile/stack-profile-node.node create mode 100644 languages/typescript/packages/profile/tsconfig.json create mode 100644 languages/typescript/packages/profile/vitest.config.ts create mode 100644 packages/stack-profile/tasks.toml diff --git a/languages/typescript/packages/profile/Cargo.toml b/languages/typescript/packages/profile/Cargo.toml new file mode 100644 index 000000000..ea96fa83c --- /dev/null +++ b/languages/typescript/packages/profile/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "stack-profile-node" +version.workspace = true +edition.workspace = true +publish = false + +[lib] +crate-type = ["cdylib"] + +[dependencies] +stack-profile = { workspace = true } +napi = "2" +napi-derive = "2" + +[build-dependencies] +napi-build = "2" diff --git a/languages/typescript/packages/profile/__tests__/profile-store.test.ts b/languages/typescript/packages/profile/__tests__/profile-store.test.ts new file mode 100644 index 000000000..123ee4a55 --- /dev/null +++ b/languages/typescript/packages/profile/__tests__/profile-store.test.ts @@ -0,0 +1,149 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { mkdtempSync, existsSync } from "fs"; +import { join } from "path"; +import { tmpdir } from "os"; +import type { ProfileStore as ProfileStoreType, ProfileError } from "../index"; + +const mod = require("../index.js") as typeof import("../index"); +const { ProfileStore } = mod; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +const WS_A = "AAAAAAAAAAAAAAAA"; +const WS_B = "BBBBBBBBBBBBBBBB"; + +let profileDir: string; + +function freshProfileDir(): string { + return mkdtempSync(join(tmpdir(), "cs-profile-test-")); +} + +function store(): InstanceType { + return ProfileStore.withDir(profileDir); +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe("ProfileStore", () => { + beforeEach(() => { + profileDir = freshProfileDir(); + }); + + describe("resolve", () => { + it("returns a ProfileStore at the default location", () => { + const s = ProfileStore.resolve(); + expect(s.dir).toBeTruthy(); + }); + }); + + describe("withDir", () => { + it("returns a ProfileStore at the given directory", () => { + const s = ProfileStore.withDir("/tmp/custom"); + expect(s.dir).toBe("/tmp/custom"); + }); + }); + + describe("given no workspace set", () => { + it("currentWorkspace throws NO_CURRENT_WORKSPACE", () => { + try { + store().currentWorkspace(); + expect.unreachable("should have thrown"); + } catch (err) { + const profileErr = err as ProfileError; + expect(profileErr).toBeInstanceOf(Error); + expect(profileErr.code).toBe("NO_CURRENT_WORKSPACE"); + } + }); + + it("currentWorkspaceStore throws NO_CURRENT_WORKSPACE", () => { + try { + store().currentWorkspaceStore(); + expect.unreachable("should have thrown"); + } catch (err) { + const profileErr = err as ProfileError; + expect(profileErr.code).toBe("NO_CURRENT_WORKSPACE"); + } + }); + + it("listWorkspaces returns empty array", () => { + expect(store().listWorkspaces()).toEqual([]); + }); + + it("clearCurrentWorkspace succeeds", () => { + expect(() => store().clearCurrentWorkspace()).not.toThrow(); + }); + }); + + describe("given workspace set", () => { + beforeEach(() => { + store().setCurrentWorkspace(WS_A); + }); + + it("currentWorkspace returns the workspace ID", () => { + expect(store().currentWorkspace()).toBe(WS_A); + }); + + it("currentWorkspaceStore returns a scoped store", () => { + const ws = store().currentWorkspaceStore(); + expect(ws.dir).toBe(join(profileDir, "workspaces", WS_A)); + }); + + it("clearCurrentWorkspace removes the selection", () => { + store().clearCurrentWorkspace(); + try { + store().currentWorkspace(); + expect.unreachable("should have thrown"); + } catch (err) { + expect((err as ProfileError).code).toBe("NO_CURRENT_WORKSPACE"); + } + }); + }); + + describe("workspaceStore", () => { + it("returns a store scoped to the workspace directory", () => { + const ws = store().workspaceStore(WS_A); + expect(ws.dir).toBe(join(profileDir, "workspaces", WS_A)); + }); + + it("throws INVALID_WORKSPACE_ID for bad input", () => { + try { + store().workspaceStore("../escape"); + expect.unreachable("should have thrown"); + } catch (err) { + expect((err as ProfileError).code).toBe("INVALID_WORKSPACE_ID"); + } + }); + }); + + describe("given multiple workspaces", () => { + beforeEach(() => { + const s = store(); + // Create workspace dirs by setting workspace and writing current_workspace + s.setCurrentWorkspace(WS_A); + // Touch a file in the workspace dir so it gets created + s.workspaceStore(WS_A).setCurrentWorkspace(WS_A); + s.workspaceStore(WS_B).setCurrentWorkspace(WS_B); + }); + + it("listWorkspaces returns sorted workspace IDs", () => { + expect(store().listWorkspaces()).toEqual([WS_A, WS_B]); + }); + + it("switching workspace changes currentWorkspaceStore", () => { + const s = store(); + s.setCurrentWorkspace(WS_A); + expect(s.currentWorkspaceStore().dir).toBe( + join(profileDir, "workspaces", WS_A), + ); + + s.setCurrentWorkspace(WS_B); + expect(s.currentWorkspaceStore().dir).toBe( + join(profileDir, "workspaces", WS_B), + ); + }); + }); +}); diff --git a/languages/typescript/packages/profile/build.rs b/languages/typescript/packages/profile/build.rs new file mode 100644 index 000000000..9fc236788 --- /dev/null +++ b/languages/typescript/packages/profile/build.rs @@ -0,0 +1,5 @@ +extern crate napi_build; + +fn main() { + napi_build::setup(); +} diff --git a/languages/typescript/packages/profile/index.d.ts b/languages/typescript/packages/profile/index.d.ts new file mode 100644 index 000000000..7be1b9183 --- /dev/null +++ b/languages/typescript/packages/profile/index.d.ts @@ -0,0 +1,49 @@ +/* tslint:disable */ +/* eslint-disable */ + +/** Error codes attached to errors thrown by this package. */ +export type ProfileErrorCode = + | "IO_ERROR" + | "JSON_ERROR" + | "HOME_DIR_NOT_FOUND" + | "NOT_FOUND" + | "INVALID_FILENAME" + | "NO_CURRENT_WORKSPACE" + | "INVALID_WORKSPACE_ID" + | "UNKNOWN_ERROR"; + +/** An error thrown by this package, enriched with a machine-readable `.code`. */ +export interface ProfileError extends Error { + code: ProfileErrorCode; +} + +/** + * A directory-scoped profile store for managing workspace profiles. + * + * Use `ProfileStore.resolve()` for the default `~/.cipherstash` location, + * or `ProfileStore.withDir(path)` for a custom directory. + */ +export class ProfileStore { + /** Create a profile store at the default location (`~/.cipherstash`). */ + static resolve(): ProfileStore; + /** Create a profile store rooted at the given directory. */ + static withDir(dir: string): ProfileStore; + + /** The directory path of this profile store. */ + get dir(): string; + + /** Set the current workspace. */ + setCurrentWorkspace(workspaceId: string): void; + /** Return the current workspace ID. Throws if no workspace has been set. */ + currentWorkspace(): string; + /** Remove the current workspace selection. */ + clearCurrentWorkspace(): void; + + /** List workspace IDs that have profile data on disk. */ + listWorkspaces(): string[]; + + /** Return a profile store scoped to a specific workspace directory. */ + workspaceStore(workspaceId: string): ProfileStore; + /** Return a profile store scoped to the current workspace. Throws if no workspace has been set. */ + currentWorkspaceStore(): ProfileStore; +} diff --git a/languages/typescript/packages/profile/index.js b/languages/typescript/packages/profile/index.js new file mode 100644 index 000000000..42a0d0111 --- /dev/null +++ b/languages/typescript/packages/profile/index.js @@ -0,0 +1,39 @@ +const native = require("./stack-profile-node.js"); + +const CODE_RE = /^([A-Z_]+): /; + +function enrichError(err) { + if (err instanceof Error) { + const match = CODE_RE.exec(err.message); + if (match) { + err.code = match[1]; + err.message = err.message.slice(match[0].length); + } + } + throw err; +} + +function wrapSync(fn) { + return function (...args) { + try { + return fn.apply(this, args); + } catch (err) { + enrichError(err); + } + }; +} + +// Wrap ProfileStore methods that can throw +const proto = native.ProfileStore.prototype; +proto.setCurrentWorkspace = wrapSync(proto.setCurrentWorkspace); +proto.currentWorkspace = wrapSync(proto.currentWorkspace); +proto.clearCurrentWorkspace = wrapSync(proto.clearCurrentWorkspace); +proto.listWorkspaces = wrapSync(proto.listWorkspaces); +proto.workspaceStore = wrapSync(proto.workspaceStore); +proto.currentWorkspaceStore = wrapSync(proto.currentWorkspaceStore); + +// Wrap factory methods +const origResolve = native.ProfileStore.resolve; +native.ProfileStore.resolve = wrapSync(origResolve); + +module.exports = native; diff --git a/languages/typescript/packages/profile/package-lock.json b/languages/typescript/packages/profile/package-lock.json new file mode 100644 index 000000000..4ef84280d --- /dev/null +++ b/languages/typescript/packages/profile/package-lock.json @@ -0,0 +1,1644 @@ +{ + "name": "@cipherstash/profile", + "version": "0.35.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@cipherstash/profile", + "version": "0.35.0", + "devDependencies": { + "@napi-rs/cli": "^2", + "typescript": "^5", + "vitest": "^3" + }, + "optionalDependencies": { + "@cipherstash/profile-darwin-arm64": "0.35.0", + "@cipherstash/profile-darwin-x64": "0.35.0", + "@cipherstash/profile-linux-arm64-gnu": "0.35.0", + "@cipherstash/profile-linux-x64-gnu": "0.35.0", + "@cipherstash/profile-linux-x64-musl": "0.35.0", + "@cipherstash/profile-win32-x64-msvc": "0.35.0" + } + }, + "node_modules/@cipherstash/profile-darwin-arm64": { + "optional": true + }, + "node_modules/@cipherstash/profile-darwin-x64": { + "optional": true + }, + "node_modules/@cipherstash/profile-linux-arm64-gnu": { + "optional": true + }, + "node_modules/@cipherstash/profile-linux-x64-gnu": { + "optional": true + }, + "node_modules/@cipherstash/profile-linux-x64-musl": { + "optional": true + }, + "node_modules/@cipherstash/profile-win32-x64-msvc": { + "optional": true + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", + "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", + "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", + "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", + "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", + "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", + "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", + "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", + "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", + "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", + "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", + "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", + "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", + "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", + "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", + "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", + "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", + "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", + "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", + "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", + "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", + "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", + "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", + "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", + "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", + "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", + "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@napi-rs/cli": { + "version": "2.18.4", + "resolved": "https://registry.npmjs.org/@napi-rs/cli/-/cli-2.18.4.tgz", + "integrity": "sha512-SgJeA4df9DE2iAEpr3M2H0OKl/yjtg1BnRI5/JyowS71tUWhrfSu2LT0V3vlHET+g1hBVlrO60PmEXwUEKp8Mg==", + "dev": true, + "license": "MIT", + "bin": { + "napi": "scripts/index.js" + }, + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.1.tgz", + "integrity": "sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.1.tgz", + "integrity": "sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.1.tgz", + "integrity": "sha512-mjCpF7GmkRtSJwon+Rq1N8+pI+8l7w5g9Z3vWj4T7abguC4Czwi3Yu/pFaLvA3TTeMVjnu3ctigusqWUfjZzvw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.1.tgz", + "integrity": "sha512-haZ7hJ1JT4e9hqkoT9R/19XW2QKqjfJVv+i5AGg57S+nLk9lQnJ1F/eZloRO3o9Scy9CM3wQ9l+dkXtcBgN5Ew==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.1.tgz", + "integrity": "sha512-czw90wpQq3ZsAVBlinZjAYTKduOjTywlG7fEeWKUA7oCmpA8xdTkxZZlwNJKWqILlq0wehoZcJYfBvOyhPTQ6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.1.tgz", + "integrity": "sha512-KVB2rqsxTHuBtfOeySEyzEOB7ltlB/ux38iu2rBQzkjbwRVlkhAGIEDiiYnO2kFOkJp+Z7pUXKyrRRFuFUKt+g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.1.tgz", + "integrity": "sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.1.tgz", + "integrity": "sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.1.tgz", + "integrity": "sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.1.tgz", + "integrity": "sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.1.tgz", + "integrity": "sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.1.tgz", + "integrity": "sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.1.tgz", + "integrity": "sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.1.tgz", + "integrity": "sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.1.tgz", + "integrity": "sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.1.tgz", + "integrity": "sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.1.tgz", + "integrity": "sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.1.tgz", + "integrity": "sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.1.tgz", + "integrity": "sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.1.tgz", + "integrity": "sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.1.tgz", + "integrity": "sha512-4Cv23ZrONRbNtbZa37mLSueXUCtN7MXccChtKpUnQNgF010rjrjfHx3QxkS2PI7LqGT5xXyYs1a7LbzAwT0iCA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.1.tgz", + "integrity": "sha512-i1okWYkA4FJICtr7KpYzFpRTHgy5jdDbZiWfvny21iIKky5YExiDXP+zbXzm3dUcFpkEeYNHgQ5fuG236JPq0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.1.tgz", + "integrity": "sha512-u09m3CuwLzShA0EYKMNiFgcjjzwqtUMLmuCJLeZWjjOYA3IT2Di09KaxGBTP9xVztWyIWjVdsB2E9goMjZvTQg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.1.tgz", + "integrity": "sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.1.tgz", + "integrity": "sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitest/expect": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz", + "integrity": "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/spy": "3.2.4", + "@vitest/utils": "3.2.4", + "chai": "^5.2.0", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.4.tgz", + "integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "3.2.4", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.17" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz", + "integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.4.tgz", + "integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "3.2.4", + "pathe": "^2.0.3", + "strip-literal": "^3.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.4.tgz", + "integrity": "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.4", + "magic-string": "^0.30.17", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.4.tgz", + "integrity": "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^4.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.4.tgz", + "integrity": "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.4", + "loupe": "^3.1.4", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", + "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.7", + "@esbuild/android-arm": "0.27.7", + "@esbuild/android-arm64": "0.27.7", + "@esbuild/android-x64": "0.27.7", + "@esbuild/darwin-arm64": "0.27.7", + "@esbuild/darwin-x64": "0.27.7", + "@esbuild/freebsd-arm64": "0.27.7", + "@esbuild/freebsd-x64": "0.27.7", + "@esbuild/linux-arm": "0.27.7", + "@esbuild/linux-arm64": "0.27.7", + "@esbuild/linux-ia32": "0.27.7", + "@esbuild/linux-loong64": "0.27.7", + "@esbuild/linux-mips64el": "0.27.7", + "@esbuild/linux-ppc64": "0.27.7", + "@esbuild/linux-riscv64": "0.27.7", + "@esbuild/linux-s390x": "0.27.7", + "@esbuild/linux-x64": "0.27.7", + "@esbuild/netbsd-arm64": "0.27.7", + "@esbuild/netbsd-x64": "0.27.7", + "@esbuild/openbsd-arm64": "0.27.7", + "@esbuild/openbsd-x64": "0.27.7", + "@esbuild/openharmony-arm64": "0.27.7", + "@esbuild/sunos-x64": "0.27.7", + "@esbuild/win32-arm64": "0.27.7", + "@esbuild/win32-ia32": "0.27.7", + "@esbuild/win32-x64": "0.27.7" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.8", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", + "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rollup": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.1.tgz", + "integrity": "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.1", + "@rollup/rollup-android-arm64": "4.60.1", + "@rollup/rollup-darwin-arm64": "4.60.1", + "@rollup/rollup-darwin-x64": "4.60.1", + "@rollup/rollup-freebsd-arm64": "4.60.1", + "@rollup/rollup-freebsd-x64": "4.60.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.1", + "@rollup/rollup-linux-arm-musleabihf": "4.60.1", + "@rollup/rollup-linux-arm64-gnu": "4.60.1", + "@rollup/rollup-linux-arm64-musl": "4.60.1", + "@rollup/rollup-linux-loong64-gnu": "4.60.1", + "@rollup/rollup-linux-loong64-musl": "4.60.1", + "@rollup/rollup-linux-ppc64-gnu": "4.60.1", + "@rollup/rollup-linux-ppc64-musl": "4.60.1", + "@rollup/rollup-linux-riscv64-gnu": "4.60.1", + "@rollup/rollup-linux-riscv64-musl": "4.60.1", + "@rollup/rollup-linux-s390x-gnu": "4.60.1", + "@rollup/rollup-linux-x64-gnu": "4.60.1", + "@rollup/rollup-linux-x64-musl": "4.60.1", + "@rollup/rollup-openbsd-x64": "4.60.1", + "@rollup/rollup-openharmony-arm64": "4.60.1", + "@rollup/rollup-win32-arm64-msvc": "4.60.1", + "@rollup/rollup-win32-ia32-msvc": "4.60.1", + "@rollup/rollup-win32-x64-gnu": "4.60.1", + "@rollup/rollup-win32-x64-msvc": "4.60.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/strip-literal": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", + "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz", + "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/vite": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", + "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", + "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.4.1", + "es-module-lexer": "^1.7.0", + "pathe": "^2.0.3", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz", + "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/expect": "3.2.4", + "@vitest/mocker": "3.2.4", + "@vitest/pretty-format": "^3.2.4", + "@vitest/runner": "3.2.4", + "@vitest/snapshot": "3.2.4", + "@vitest/spy": "3.2.4", + "@vitest/utils": "3.2.4", + "chai": "^5.2.0", + "debug": "^4.4.1", + "expect-type": "^1.2.1", + "magic-string": "^0.30.17", + "pathe": "^2.0.3", + "picomatch": "^4.0.2", + "std-env": "^3.9.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.14", + "tinypool": "^1.1.1", + "tinyrainbow": "^2.0.0", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", + "vite-node": "3.2.4", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/debug": "^4.1.12", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@vitest/browser": "3.2.4", + "@vitest/ui": "3.2.4", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/debug": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + } + } +} diff --git a/languages/typescript/packages/profile/package.json b/languages/typescript/packages/profile/package.json new file mode 100644 index 000000000..54d7cccf6 --- /dev/null +++ b/languages/typescript/packages/profile/package.json @@ -0,0 +1,44 @@ +{ + "name": "@cipherstash/profile", + "version": "0.35.0", + "main": "index.js", + "types": "index.d.ts", + "napi": { + "name": "stack-profile-node", + "triples": { + "defaults": false, + "additional": [ + "x86_64-apple-darwin", + "aarch64-apple-darwin", + "x86_64-unknown-linux-gnu", + "aarch64-unknown-linux-gnu", + "x86_64-unknown-linux-musl", + "x86_64-pc-windows-msvc" + ] + } + }, + "files": [ + "index.js", + "index.d.ts", + "README.md", + "stack-profile-node.js" + ], + "scripts": { + "build": "napi build --release", + "build:debug": "napi build", + "test": "npm run build:debug && vitest run" + }, + "optionalDependencies": { + "@cipherstash/profile-darwin-x64": "0.35.0", + "@cipherstash/profile-darwin-arm64": "0.35.0", + "@cipherstash/profile-linux-x64-gnu": "0.35.0", + "@cipherstash/profile-linux-arm64-gnu": "0.35.0", + "@cipherstash/profile-linux-x64-musl": "0.35.0", + "@cipherstash/profile-win32-x64-msvc": "0.35.0" + }, + "devDependencies": { + "@napi-rs/cli": "^2", + "vitest": "^3", + "typescript": "^5" + } +} diff --git a/languages/typescript/packages/profile/platforms/darwin-arm64/package.json b/languages/typescript/packages/profile/platforms/darwin-arm64/package.json new file mode 100644 index 000000000..bfb10bfb7 --- /dev/null +++ b/languages/typescript/packages/profile/platforms/darwin-arm64/package.json @@ -0,0 +1,8 @@ +{ + "name": "@cipherstash/profile-darwin-arm64", + "version": "0.35.0", + "os": ["darwin"], + "cpu": ["arm64"], + "main": "stack-profile-node.darwin-arm64.node", + "files": ["stack-profile-node.darwin-arm64.node"] +} diff --git a/languages/typescript/packages/profile/platforms/darwin-x64/package.json b/languages/typescript/packages/profile/platforms/darwin-x64/package.json new file mode 100644 index 000000000..e51a45a55 --- /dev/null +++ b/languages/typescript/packages/profile/platforms/darwin-x64/package.json @@ -0,0 +1,8 @@ +{ + "name": "@cipherstash/profile-darwin-x64", + "version": "0.35.0", + "os": ["darwin"], + "cpu": ["x64"], + "main": "stack-profile-node.darwin-x64.node", + "files": ["stack-profile-node.darwin-x64.node"] +} diff --git a/languages/typescript/packages/profile/platforms/linux-arm64-gnu/package.json b/languages/typescript/packages/profile/platforms/linux-arm64-gnu/package.json new file mode 100644 index 000000000..6e9170ee0 --- /dev/null +++ b/languages/typescript/packages/profile/platforms/linux-arm64-gnu/package.json @@ -0,0 +1,8 @@ +{ + "name": "@cipherstash/profile-linux-arm64-gnu", + "version": "0.35.0", + "os": ["linux"], + "cpu": ["arm64"], + "main": "stack-profile-node.linux-arm64-gnu.node", + "files": ["stack-profile-node.linux-arm64-gnu.node"] +} diff --git a/languages/typescript/packages/profile/platforms/linux-x64-gnu/package.json b/languages/typescript/packages/profile/platforms/linux-x64-gnu/package.json new file mode 100644 index 000000000..424c157b5 --- /dev/null +++ b/languages/typescript/packages/profile/platforms/linux-x64-gnu/package.json @@ -0,0 +1,8 @@ +{ + "name": "@cipherstash/profile-linux-x64-gnu", + "version": "0.35.0", + "os": ["linux"], + "cpu": ["x64"], + "main": "stack-profile-node.linux-x64-gnu.node", + "files": ["stack-profile-node.linux-x64-gnu.node"] +} diff --git a/languages/typescript/packages/profile/platforms/linux-x64-musl/package.json b/languages/typescript/packages/profile/platforms/linux-x64-musl/package.json new file mode 100644 index 000000000..3e1f107d9 --- /dev/null +++ b/languages/typescript/packages/profile/platforms/linux-x64-musl/package.json @@ -0,0 +1,8 @@ +{ + "name": "@cipherstash/profile-linux-x64-musl", + "version": "0.35.0", + "os": ["linux"], + "cpu": ["x64"], + "main": "stack-profile-node.linux-x64-musl.node", + "files": ["stack-profile-node.linux-x64-musl.node"] +} diff --git a/languages/typescript/packages/profile/platforms/win32-x64-msvc/package.json b/languages/typescript/packages/profile/platforms/win32-x64-msvc/package.json new file mode 100644 index 000000000..5941a82f2 --- /dev/null +++ b/languages/typescript/packages/profile/platforms/win32-x64-msvc/package.json @@ -0,0 +1,8 @@ +{ + "name": "@cipherstash/profile-win32-x64-msvc", + "version": "0.35.0", + "os": ["win32"], + "cpu": ["x64"], + "main": "stack-profile-node.win32-x64-msvc.node", + "files": ["stack-profile-node.win32-x64-msvc.node"] +} diff --git a/languages/typescript/packages/profile/src/lib.rs b/languages/typescript/packages/profile/src/lib.rs new file mode 100644 index 000000000..c3fbd3bed --- /dev/null +++ b/languages/typescript/packages/profile/src/lib.rs @@ -0,0 +1,108 @@ +use napi::bindgen_prelude::*; +use napi_derive::napi; + +// --------------------------------------------------------------------------- +// Error mapping +// --------------------------------------------------------------------------- + +fn error_code(err: &stack_profile::ProfileError) -> &'static str { + match err { + stack_profile::ProfileError::Io(_) => "IO_ERROR", + stack_profile::ProfileError::Json(_) => "JSON_ERROR", + stack_profile::ProfileError::HomeDirNotFound => "HOME_DIR_NOT_FOUND", + stack_profile::ProfileError::NotFound { .. } => "NOT_FOUND", + stack_profile::ProfileError::InvalidFilename(_) => "INVALID_FILENAME", + stack_profile::ProfileError::NoCurrentWorkspace => "NO_CURRENT_WORKSPACE", + stack_profile::ProfileError::InvalidWorkspaceId(_) => "INVALID_WORKSPACE_ID", + _ => "UNKNOWN_ERROR", + } +} + +fn to_napi_error(err: stack_profile::ProfileError) -> napi::Error { + let code = error_code(&err); + napi::Error::new(Status::GenericFailure, format!("{code}: {err}")) +} + +// --------------------------------------------------------------------------- +// ProfileStore +// --------------------------------------------------------------------------- + +#[napi] +pub struct ProfileStore { + inner: stack_profile::ProfileStore, +} + +#[napi] +impl ProfileStore { + /// Create a profile store at the default location (`~/.cipherstash`), + /// or the path specified by the `CS_CONFIG_PATH` environment variable. + #[napi(factory)] + pub fn resolve() -> Result { + let inner = stack_profile::ProfileStore::resolve(None).map_err(to_napi_error)?; + Ok(Self { inner }) + } + + /// Create a profile store rooted at the given directory. + #[napi(factory)] + pub fn with_dir(dir: String) -> Self { + Self { + inner: stack_profile::ProfileStore::new(dir), + } + } + + /// The directory path of this profile store. + #[napi(getter)] + pub fn dir(&self) -> String { + self.inner.dir().to_string_lossy().into_owned() + } + + /// Set the current workspace. + #[napi] + pub fn set_current_workspace(&self, workspace_id: String) -> Result<()> { + self.inner + .set_current_workspace(&workspace_id) + .map_err(to_napi_error) + } + + /// Return the current workspace ID. + /// + /// Throws if no workspace has been set. + #[napi] + pub fn current_workspace(&self) -> Result { + self.inner.current_workspace().map_err(to_napi_error) + } + + /// Remove the current workspace selection. + #[napi] + pub fn clear_current_workspace(&self) -> Result<()> { + self.inner.clear_current_workspace().map_err(to_napi_error) + } + + /// List workspace IDs that have profile data on disk. + #[napi] + pub fn list_workspaces(&self) -> Result> { + self.inner.list_workspaces().map_err(to_napi_error) + } + + /// Return a profile store scoped to a specific workspace directory. + #[napi] + pub fn workspace_store(&self, workspace_id: String) -> Result { + let inner = self + .inner + .workspace_store(&workspace_id) + .map_err(to_napi_error)?; + Ok(ProfileStore { inner }) + } + + /// Return a profile store scoped to the current workspace. + /// + /// Throws if no workspace has been set. + #[napi] + pub fn current_workspace_store(&self) -> Result { + let inner = self + .inner + .current_workspace_store() + .map_err(to_napi_error)?; + Ok(ProfileStore { inner }) + } +} diff --git a/languages/typescript/packages/profile/stack-profile-node.js b/languages/typescript/packages/profile/stack-profile-node.js new file mode 100644 index 000000000..794f3b5a2 --- /dev/null +++ b/languages/typescript/packages/profile/stack-profile-node.js @@ -0,0 +1,66 @@ +const { platform, arch } = process; + +function isMusl() { + try { + const report = + typeof process.report?.getReport === "function" + ? process.report.getReport() + : null; + if (report && typeof report === "object" && report.sharedObjects) { + return report.sharedObjects.some((s) => s.includes("musl")); + } + } catch (_) {} + try { + const { execSync } = require("node:child_process"); + return execSync("ldd --version 2>&1", { encoding: "utf8" }).includes( + "musl", + ); + } catch (_) { + return false; + } +} + +const platforms = { + "darwin-x64": "@cipherstash/profile-darwin-x64", + "darwin-arm64": "@cipherstash/profile-darwin-arm64", + "linux-x64-gnu": "@cipherstash/profile-linux-x64-gnu", + "linux-x64-musl": "@cipherstash/profile-linux-x64-musl", + "linux-arm64-gnu": "@cipherstash/profile-linux-arm64-gnu", + "win32-x64-msvc": "@cipherstash/profile-win32-x64-msvc", +}; + +function loadBinding() { + let key = `${platform}-${arch}`; + + if (platform === "linux") { + key += isMusl() ? "-musl" : "-gnu"; + } else if (platform === "win32") { + key += "-msvc"; + } + + const pkg = platforms[key]; + if (!pkg) { + throw new Error( + `Unsupported platform: ${platform}-${arch}. ` + + `@cipherstash/profile supports: ${Object.keys(platforms).join(", ")}`, + ); + } + + // Prefer local .node binary (development / napi build) + try { + return require("./stack-profile-node.node"); + } catch (_) {} + + // Fall back to platform-specific optional dependency + try { + return require(pkg); + } catch (_) {} + + throw new Error( + `Failed to load native binding for ${platform}-${arch}. ` + + `Ensure the optional dependency "${pkg}" is installed, ` + + `or run "napi build" for local development.`, + ); +} + +module.exports = loadBinding(); diff --git a/languages/typescript/packages/profile/stack-profile-node.node b/languages/typescript/packages/profile/stack-profile-node.node new file mode 100755 index 0000000000000000000000000000000000000000..0a27dd44b870769fcef56c8ca7c7d30213eeaa2a GIT binary patch literal 2136408 zcmdSC4SZZxo&SF)w;?kvrO%J)LrTCVtx~l}65IZoGO{hym4_Az6iG|Kwp*b>U=h1W z>ZlZ|ORvN&g36S@G6^UkJ~Zn?i%}`!0|6D4MJ5T#Oo~cnk&HCt|NfkN@64U)qz{09 zcl&zXX70J?p7TB5=kq<^bMDXo_Q%(cwXBfEzhk&YbL|RS)~BrPDzc_P^RR@(?P{L?+%=Kc+{nsYaLzv=30 zE@{dZ6}DIZVYi)+2nhb^KG z>lzyyF8gTXWgoe^;p(-Q6t-7;t=rzp>kN37?(^Dni?jbXG&F9yqA7>K!uGbm?Ljm|K4w@5Y~o zo2XRSUi>$1d$+~i7Ia_OhTpdSHZ)wezTrbxt-bs*x3sXm+K(M)9{l1A+x*qN`^&9A z!m`%8zuf=D$F*>IL&N))tX$F%J@W%AJZ|>*x7n|qf3G(EOR~|>ux?Y13PZQ2y6(TR zd2mU_^54IFlaRLI>V^+pb4BAp@Qt=yc>MNs9~`GAHhyQkU&tr<wJ`JazXjLbDJ)^s%hS(YpWJS<+8@Bo90C~d}RG)SFN~uZPWUBjplcge!rvXnhjT9Ht*`SSFOA3>Uo!5cF8sC z`2QnK^FF-c5-K!qZ1~7kn?AJR!buIcK_ zE|`b=nKedaIUvRcxk>NAT=d51QuLRMxJ zZ6&FbqC8ET6Q+6Cr4tw?S()Ge#A-=` zzf@(&ne{vRo}G`&RLH|6&y$TMP7s$ro$TT=CEP`$fI5qS!I2BDS`i{pe;Q8{x za0K45p;sgH0`KjkbM)$Eyw)1aF}!e5ju-y)4)OA7^zZZX;rZY_1$^-SVtimrCPpAv zC5CqlPXUw5Q{V!;OIFUB3yq;eDrPyaOmO)M414mjQh0(NQ{qRD z#+N{2bj^72O&nYdn{Tzemn+TqRj&^oYlo+2!Fzk4lS_l;$Z}CR$aI5a!7jY+QXZ?-y>7lej&-|10mdT6(!s$br$F$Y&ZFOTLY4Ltn}EP4JkJZRnhc*Gne!}dT$>MD%BMTeb>pV5F;<( z$wKc-#=E1gPM3_Q=3B4I*IsO-YI@J>)?y!tMyGyL%fbOt;e~Nq=$5B5S}kW=5n62x z`U0_hT21$9)$7vg*WgQZ>V^(4edAy{$;K4=0Y1-27UT!?Pw`>@XY_qheOJ`(chGkV zocVVD3Hm?C?jIrTeo>vH-E~JvyI*4m3u*Uz zH#U&+i@sk3#^~W~$yo(7TK6Vsv@t-V(ML(6O+{#QeF2STLZ5Sopwa$mMaF*w^3`=5 zG%C=ASsEdK@dEieO>jb!gePBCK)xowLHTNDOfG*NoqToEUr@gKzkaZMy%|5?Own^L zI;J$Za-?{o}rDS z@B@A!*gQYLhpp#V|Nr>`9dFVP_-DZ%wVf|t>G}cfTB!1n{rhyM!w0NF?159mXlG-vAWMDNYSKf=EBMJ?kW+%&?M zfe*AXmLr~9RusrUO%WM5QvYB<6+DHmmF`ZCb;lOe-G}lI77I58{=q-PQ$hdW&-1L7 z6XEw_Hs*%O{`im1KPW9i!$aw>2|f)y{q+R+^8JIe9rt)bsj-KF->&kxn^iG}TU3f>ZMX*rso_GsLi zqk;zuymf=49(;*2%zLMT2XCCVXyC@W6pN8Rl5DV?pPT^i&<>G2;uH=2v&Y*lnmfDk zikZ7!BL*Qon-}v6w57h2m2Qlv8(4b0wqKcV+U@Y$9aFsBj-ze&cPHn!`x<()2)z02 z257uGQb32pjRjoxfUCtC5488k^K7wx;e(;Zqd9Vg=9@Np$!=a$LA)qg*1YHy15Y5H z7=R~8r_-(q<wW;B6XhK zboe^IID$IP5y12PBWUMmgM&K`r2{nf$6r5uozETtJeM2+eppI4q)rk%=|jLQ`L-(1 z7mtR9Y{##2Bz--7h`xN@IFfc|uPzu@XHK_C*8H}l*IuAc#a(2dzYac(O*6VL)SfE0 zIuuvcv#%Ij<@DalG1zouc>h?%QFFT9Z+lE3{-e41RuBf+q!jUU#a7KPhL9C zYWXXCOT8n5+kWG4uXqdLKE;PSCb%QSzVh)cSrI>khw&Gji?90NMe&i#htv8arTA{p zpbZ*mUCqReBlX7rGyHN08dMzdSWd^!E1YuQh3s>-Yve9xb!>+LAIX;f87$3}@ zY1WK<-WUJOddZaw)xW^lp`5=vc{H{>c5(kV(0jr4O(P4yWC4Tpn(X~7;^3!pJ-^5G z2jstrPg9n=?jk*6-p}YC*u0eW z9GB+B^gX#V`Lv5#W_`h`XN@K5`N{!$iZ)5X46GVg61k0!23~M!e8JcU`!58>xPgT= zQ+tN-nf*0J&0pO0U*W=?$4G|}vv^`weaLwqex>x7%Cz>JH+KOxLpSgz9cG2bL4F$J%#N$@J;q~r3fSZa9OPFN;K8hi@s7qL9i*6(;<_PlrUv1Xz4{voJ+-K7nLeeWWT~+vm40Tecq`MeF6_8QD6?lHvre zU7d-2m7R5cfok9pUnD2pIW=$X(9X$J591JD?S#MAA7ecjzOID%GWK+eYeU0UXx~kq zAD}sCs@HNhP_LSLX=JaBdg)3R7iOLe%;F0po4}|xP5nnoo#7pVr}>=fQsBjwnRQ0+ zHYncr==?EsK)!qlFYdau{72~zU&iIT2yW>y@t?21jBSsH9DBIS_hDnxT)9v^A7{c% zglFN%W4Fskh{qa`jlf!UXU9w$#!wZF4pr$%sL+H^?7*?ZIz)e_yS%Fvxb6fgE#I?uETJ6wjDf3_e&S- z#;4ki9o&r{vK!qPkCqty({4}cZ;MT#Y-+#7JW)9b7IVb7ecQs__-4ED%RYNAd<#z{ zdx^b)>u|)iOFiIvJ9=L@mT#f_0qLx5l*tCkhf(>B+z)Kd&pF8RJ!sR|9N?7C%x?#o zeKG?5Pe9Jc6DyFfb{%}6wQ%IX>5f{?){$0=baxZwLH(`#1=%Roap?~KLU-|s(MgOY z=qDWwERTAyoL2zL0uPq6jsTV@F|W4*mY-q6g_rpSu-tyEiUx z7UAVo50-liU`cwg>^uTkZhnjK@`?w`CktSC!Gq=Fhlb_BQ{yGh;9CUCf#Y2{T3Z0i zG!K^5M*z#e90HcSbu{~3U4!WC!I`t`2Ftl7agE~|#WjMfjLYU4d|RF7i$Q<&j8X7p ztCf+=pCVXW(UlLQJELJ|%`t4Exc?ZZtwg?Ur2V1itrpE~ljL}ve_(LO$-@-8CLit& z>Mss;NDrp(;Qhr*I?^u;%cP!)bi9JzOpiOg4!iqgb;PDEf$FZ<0IE z|6VTCm{E31&yMDxNSSx>oaQE%9Ku(6`Vd7i(YgieQI2!0DPVl+t zbh+qcL#Hy2PUY+B!dDJKr!vNCbIpfFHC*$Drc>950G(=})1cLnTHw)Xu)afbma>OC zNnpIhr<2MmBlr@)Ejqna>(uykijH#WWR+#*>DgXSo_J?B_{h>}Gjw{*a^-0=bb776 zK%UMjPAB>>IcC@9=(I?5y7usN`gfmB_9T~1u}Qns*6;S^Vdr*%O(&4o_S zL8o~hot}eE^q-22M4paw`oXu6CzZ7xi#&~VxZghO!I1M)?^&vItV^erWzGiRmfmaf z>BKwd2I;gBIz8jjX(M!crhrbjt|(3?`iOVhUCDJhIz5H1>RESqI-TItDK^fNr}4Yg z*3|(z{TQ6I#a19sD|e+m7+rZ9gFKDR(&?M@DSQ~chrJM;zAzU$bwZ~{JvxnBSGV`v zA?Wld=Y!0>=P&4f<7p;M(tr`^zr{!{iC?PWRyi7Z0&dCE z_LH4!d^$xZx^!Ao<~(L%FOl|$Pbc2l2R^d$v>G~n+oRKJ==ALZI*nUZoKEzSY<;_v zT$Q8K2GMEN;py~W(7?4%qdj>Vy-RHk5723i=wzRTJe{>G?ZN2E(*)#cVwO(t57G&n zE;`KDC~f#zCh$q0=OfPIp2l`cK8i zBTo~Y6MQIyJ2>PAB?EcFuK@OLKG@L{55^9-dBTd2~wNQtI0D$`KuEE8dsWdw&Hd>03_A zq$-zpq&*l7ojRvD-Sq9-rylyu$`gK<==8%m&?yF;ZuIChVqM+V_YOg)8yRnm%Z5f} zT=vj(st(er3_9HaoknZGFk*9aQ)49c`Q{^P&Y_bz- zZa(L$*|Hff?Y@pK^n8nV`n03|SZH^P?^`T@cAGugEr52L3uyQ9#l>k?IRkj+ILVqE z?Y4_{HHW8NH#m@tC7V3j?Hk^qw%!$>-HoDM6SUiRMn~F%(a8WOdV7eKqs(9VO=(5~`Cr<=Zg+TBi{S^dadRJ6NZw5x=6vpw1keyDEQ z=pkq~oAFk1JqO)-xt=RZyX~51b%z5o)(h=sLc13{+RcP^^q-2J0A5aXuJUQ8vc^i} z?gY{9iCN8youxjV9tPhd;QdJb=U7uRYu{QI35TrxDdOJzJ!Jv9NO4QWo;Cj?PPkn$ zg~i~<Y;vRTY2;j{U{vJmrQ@Ng1kNsYUbd8FrKH4U>`Sa%vv zEZN|>oUv7De1SQ!Vx_&rj_cX4XRQo7Tg|)ScEw-sCQkp!+h#in%6<=y{y^IYzU0f! zyBw`W`g=@7o9p1S|3`Zh)CY2Q4fo`$Op-7xfEm7n%CpA6{8R2KQPbr*A%rL#e2l^iC6IM@qZiK;rfRS{nsO7!Pue6Q>%1i z&6_fVI~0%B+Ip~#iAU4U>pa(bqvT4L))({FHCPiAy!37Oml%FH0?)FC(_b4E{AP`b zSii=k_{kuDO?!;1Sbm|_1h{xF)UI{l%UOHQTSpG8eHGU2DXbg7lluP!W70dqsp1wU z7lAd9K6vhoknPl)HH9xNtT$^4UtXw~ywN+X6UFUk7beL?i`&0jm>ki(D13|?{qf-a7;Aqrct3&nhq-wzf_l~J?cc%Qr;ikS{?$cX3EKBp~FxH1gv3GAYYe>VirxsqfG7qvosC65y4=Fw- zS}kI)(aHSQo;uNuHEpxrpW>PFu={8iU)bqmJtsX$yu-Q@>q%xE5czulWv(7jTM0YV zb@2)AecXO#;m*zwdqcm#{?`BIx_x0P#=4PoP_ym4=)I@Cm`Td-_V!*RV?QwaUbRmk zRq67xXj=i^x;rgr*S)#^jsy0A&1a!|&Lr1mn8|hNFOyt;u>bSu9;3_tZFp9fDc?3P z2HP3#YKz%j>3Q(cd~k$6+l{{&XDw{^%adz0$4>A!j{dVe{inF*XQAY4!-F@M*N(mawPpdI>#|ih}RO zTuZ69Ty^U+TfLYoK0_wGoD2N7X)O+zEah^}MOGrTwHkV|RvC;DKN0~(?7H|Vxc@g9 zD{)T#>);N>j=ut~vawbV=3f9y8b2jDch4elz6f74Qx$RfCDBSeQS@6g%!!B|%nPSn z8)`X^wv?Cf5on);PmDaW=9fB?ah!=9B9~iAT)Eo)@-%~2;Zf_@I|L{EDEk!TLFMHn zVjtY)EH0p(cL?*KE*d%ZL3DK(EVU%IM0?QY`ZHC&Y$)6j!7o02>ZODOTs=E z-WlhPuXmVJ0k7_amtXo%Pd)?-tA$M5+ak6qCJI6jxk9-vJ2 zsS2LRlac5cvrZwNOp2b+EVTmOw@YM8SYtLk+}}g_UGQd4C+jirqru4oR!2NW?AWs2 zqw#6%sU|DKxe4Qw57c*jkGLH1>5ddW7Jhoh^-prV^@)Ea=NOqtMN6G8f`jCZ^&QDo z>__bc|7XIR5A&=Qm@3h6$jw8Hy&Jhn#_a64`#hgA)k}^T<&{6j_BRYcTh(bKj*kxT z@%CE}KPlwTr)}yntA!j;WWCHW`H=V=%8eZC^~R7K<~%x32JPayhYK-M#hlg&f3oM>xM$yYqze z>G`}E3%R^FufC&PIG<#7BpTTxjt()rxF?76=dMxyJ96AU%95o3Fo80xqWOF=SzK@H;zDm9P2a-=bM%k*E7_S><9Hsjm9B86Ttat z;Cu`??*QbJ^dqa8q>t9Fz2+yezzPugdoEL)bjM40Q;vLJJnQ_NLoyj3Sk*^WAzqBx6U&J`c z`N5y8*wV~BJT)?3=MHRMfxmDLIePAy7wk{L-*@-3$WKDvoHO4G%-~r$f5rwfHrc?u zTpnAwNA7+W^PYGXe|O9&&@}4KGwz4iQ)-9t#8=2y3FLWo=H+aTBFr60bB&<6P6sWiNR}5CmFoLx83ls;qS^4r?Sq& zYYe}Y`|p={_q~?!V~4}<2Jh&`qW%N*;+6Swy+k}Oxdx|?AYilB9OHZezeIBf$x(K% zAMe(~`xf@UZ?5vX%1y48#W@k}_G)tLr}xW0aC!fOdAxrD;}cwk{IB+>^Gsu{Hhge# z|BsH*UIon|m_KYDLw@xzrw!T$acbtoy~sPboexe7exjcIgA*hFt;j#Ihr+$lS^4L= zk$>dQw=1vHcOEYGAphV(^Q9~vcrS~GO5tHz01p>|hZzAp6w?7`qXW(&H`|>vkgp5R zyLrUHoJZzcPlUG|XL25^0y&SL@aBwu&g1FStEQfO)#}6KJgQAo{|Iv)7Y!?z0|#VG zK9_tjF5*#K(8Aoq=gP^`Z_UH+LVo41{rdOvn(GB^j@tYJG*P~45BeqE zN^AnTY7w2#1M;O+zMcELx%v^d{fQ!NUtiGnb#JikjYZo2P(j<5yur3t7HRv;g0`2x z!L}C_X}h+d?UM`Jj){l8y%5k$HbeQwijxHO(0c#e57~;&0$RfZGyJ|~vwB@xPf_3K z3DJ7opNS#BTk_vD9}AaagJmBmGxh~tIj}jm?}7afZwXxe=qAI10k~eK%!liT{~)-Y zECSb0191KPrGE^*?k@t@y#csdU;M|w_30vTZ3)12_8-K*T9sh*AEYuTe%6vVs?r8bGGvKFV_vDWk>eQ@1Bb^#M zSf@Uw{?VO2TtDRAKhwfOe}*FHT6DGchMddXQhrWFsrt#qih}++XA7WHz4Hv-AWU}` zz!X0OOjSn$Q#5dfSOBK*ba&k%;n^M83=_K?*SyGLEg;ERk;!rFGw9Sgf?rygoTNPm zUtXAK44HZOnbM!a9eUsQ5w8JOPGWWaLA8fZoYcC(NPK8~`s4`L2iBbP;Q3$h?6AKN z%*PjjdD&Y9bEoX-aL!*~?tLV91}_HBoFyr|Wbv%CB*C*e3lY2jiu79np38w*u~zY8 zJ};iZxeneQx4#qJyNbZ`s{IzxJ%~f(If<{-jsGI~8&bCMe}p^5mwi6e*%wa&ljc7D zxfeg<-kgi7m@6u_l$@)oB`r(cf}Mpwar<@;fngYYSw!q zp~&#ImuJ@cIS1sBWO^B=zt1M&?ejOe?2D~BlcQ9IUhISpp1&f!iLb2n_Bs<^AP&Vk zd7a*=W>09O!m10OVAbX0Hg}F)d(7(6eqyaT>7B$=Q)`81<<6WQ0_P#3$N^0tL8#zO&ssR@I7OQ4NmD_uXfot>*Z)X7=FktU*?qy zZogfN{&uqNdY64+f7fFrE#zi)XxxdXF08F0hu|LZ5pBSQNXTp{KaC6W5fF7-jKo`HcpyhQ8JZB!J%xr8y!+BL?}ICTER zJ!*@bJku8Wc!`IHZcBLw8uM=OZQ=I_&mpdnS6Ucn9n2EaYimbM8EwFHvsx z%1Lj)PkSt4yp$Ej^vt?2c}gZ94H|@%<20LG72@(b??!YD^IWh>7wEaxf|XzQeVw&a zQRX~OY&ms3n`6Qm4dfa$e@=5jrxm|_~)Zw$EKY(VEDzXM6z#pO5DzVIu20wXc*fo|YI4;WZmJy^+iGX49w zzMb+Qu4@er0%tiCtaULi>?gVe8`}#HJ`WGZ(epde@yc;&W!=r_|6fYam-nZRm;UEG z1#+9f*AwCHGDqjm=WY3Ss%Z7FZqmo-(-BU6P@L? zMgF+4UCNPBzpNiUSY5b&wA{+1&%|a#$wOIcdHf!B?ina)nIV3spRMFT`?*HSR}?L< z!6q+S@}e98$&SjmhODm}+lSxKW?x{=ka-N4rEk4>z~{`_7`*c;Isw`~VO4~k=wOLC zi(vHYB`p=~msR{=U~~R@n{u$Elg&85VR}BWgTr(Uf6)s%r$YQDIjlmL>l~jU$32&K z<}q&c&LBF6ahNkwykgdU)vnfpCFdp|4Ez{flngtGS+0&yj<=ra91;09 zdPh1;ec0$q)+D9R&033SNcofaB>S~r+V^ebpTVyg3wShdkbkIHpJYCd|LtQ~Z=J|` z>m>3mN}XQqTc{+LoH6wKR#LskT?BwTZQzP>ZV6-Q1rPFHcfzmAZxJ4}UhB(l zkdF+%;UgO#5uTMjHS-emWiE#o8S{8hXSGfPe}7j0wsdgrdqk`s6W3yFT4M%|?71?$ zYtH1_U4qTZ<+SAG4aUNpNi)o>iHk>*vl$Qmtk$8;-YMFw+G3F#2CS9DGUr;EKZoFh zx!j{0KK}#g4SxmKF8BI5m^@E*mdGAuV{tE`Z%pnOv@-OoAK|>P$7*R1Ps1b1x$T8F zDsQ6Ckn7v^G}L!2L|?hNVKa8^@$wW^*N=DVtU-9an!OK@1YAHw5;LIXD;L&@SCW#yFSbrV``V2xsHl0KU%0<&58-l zi>eke?|K)wX(h*MoRw)Nx7eKd2kydN?q&yLGrZkM?kVMe5pFgOfg9??8}oCj+JqZ; zE#>1TIoyeZ8)Ij{&9=|b9=HgHtc~CIe5f>aeH>{1Xr7l^?}G2xhkxhPz1|rK*cJAC zGX~}<%C9wJZyxS6OaI35^I7xxBZA*Ut{w7ia;cZPHuf%T?Cz#1waR~%Efc@XX8qd% z#t^N~%;P%}(iH|DlJ(`TjY#O7<=lJn91iaJna5br%l@7poo^@I6~+&hK1c6!j>rh- zRQ0h~Z9rG$SI_tIt2Y8imw404DA(AGk@}}oKhbnS?Xrnh%?Xsp@eNF_qFwHunFlYM zu{6S~w(b1Di&2=eGl!xdbz{&OdGYCAC;EY7<+&Q(wvKh)L%zJBOXo1hz_aF9ryDu? z^s!#vP_)Fknb_jL7lVs2CTVK`UNicMx;Ic)_WpRr6dZ?gf>mdbT+`q_ANKiXmU;4M z&L6=>OXnuk8|%EZ@k!cWNBiseEgnpg&zri>%B)u%$*`$Y>dYl(`y~AZ z`#19s*`5b1E@+0Il=Yox$K)a1M>&4Fv3Kf2_1ODi4Gyjp?1`w|m250^mLQKc>I?tl zqpWH9wp(@lb2k5z97*3-Fy+Xvksb7v<}xmQr}sbRoweRAnFgQ77#VYOOwl7J>s{XK z_*6IT*;a>(hf?P+#Qna*7*jU+qn)8He6C#y^vCvZd$wi)yfqIVa>%=KDe6Z(O=N+%Px_(%Im>t8-vn`!>>n~B z=;7JWnDW0de#xJFAcMeu>In<#H9x z`ENH^b>27tA6O}1XtMvB zKNx={JsF>FM*rT)@>$7k%bQcoWKPjw=D#~V-5t!s3goD5ucjU4s69qIX=Ky&!#Kab z(uGt0t>~*hi z`N~bpot1YCbQaxO(MLf(RNKOJ3K$HX^&7atoNs4*BJdREn~55Sa3wj^_vesBGnY{8 zNIu99(D|QSbDXnRM}8#w1m=lBepA2n;lpD37i{po>7TJ@`=@V> zU-&KTf34TQ@3;AV=kZxAcTT41kG7vZ=nU-qd`-=nple3+2kmFt8Oz_(WJT~%lH;Av zKH}On#p|>mW$$rT4Ig`T_c4K2&UNk(eoPK`4SSZb&xzR7T{La^G*wCwp%ozS3h&vA~SMg1t}xA2j>zcQDD8|VFR zr3QES=R-A9C%b3KgYP%MqW;qGgz*8Of&6fPey4edd|=I~{rN;6Fq>EuaI5@E`q)D| zamMHBrK!e-iwD2BA3Fuk%^0Dj<{g}Sq&d}oZ%?L)9lL(PDCdDpPA97#G&8nQ?~4zV zC;$D=x@SnM?{nze=ts(L_*$st%SGVmo(=q!yo^T|dgI`KV}&j#IqvpuKEC zzL*D`l_gHC&lk`p%NOv3_HG>wU#uL0FWwd4i@$;MEMIu=8NQ%D?VB_E#qc%q_yWH| ze68~=(~GGSwKA8AFC@dF73KTDb5Xv4rzVOo;5ow=@Ymt^qPmbTjE*WdI!b(|dCx+h zFX;Ox^zGWskdt|62)>vrewb40pW_+!<_z~sf5NMp`-GqO?P0+@K@l8TRUra=zGKpi7GI%mbhCbO}7O zIKVR>J`B%jFR1id*gI2iW`Jj6JvpAaFv~M5iS?{tuPuHhdugY8Jo8`3r+B8?=b3_d z>JiwY?XpFEyTK#+=*`%oa-V+=V~Y;QKQ%+}&-eiUJPU2II_FK;qNU;==xSuWGvxg2 z4@2l2bVNpZQAcKr4F4Qk$UjAF(M+Fz==%Zs_HEIBKRATWL8k<5(a(Bww&>@)Q_L29 z4mhM==D{n*7QI0pS^F07$miHsWpvD&8~8?^TnIAJyk>SMQMO9eg@97y5mjwE4AW_fWNZ)_e1Co{FOfCiXVmT8Te}fUlg%D zqkX=>_QdJi_g8-Uz!17)2!Ev+n*09B?|gj{3pwAWonrpV$HAZUNx9ym&0Fc)_$zNv zuPhh;yh+>hZ|p04EA+~3jIEenxrQ~0Xejy0l1hj?W>a%uERr{>D+YuwEK z<`{8zCvQ-f)QV@` zq%PUu^URy{Q9i|(is_O|0z5N!D4u~=jwYt^d6Vms^JAwSf@hWkJ5{$_bgEHSt)L-QuQ~;aK;6la+SpV97>YCBc-RAvV3|8MO8Emxb2G?+aAHbz}mg2_1MV^LxFZ_I*wW(k{TDb7P<67+9N%(eo zXQ>5a#k6nMnrL6Xnj2pvj^9k&UGyN{dE<(KM+x*XI;3&N-z&s&zPQpyVadw94dPYTdjr z7HZbIh?ngo{@J5`h@+eQC*ZZWXe`SAo!0+PockhNb_aY-!w1+=t@A4;MV`|s(!qY4 zI&-RLF7}X^{cd2@I^k~Ml`fClpI=xVsmEqb?C*_{E3iW2`{lwl%{t@byngZtGWUX~ zd9uT_q4=uuD*W6-t>?OH1A&+@xaRx6`xE5G2o7D^tD`)Z_SmZa<+Cj3KGqT}*^)|c z9eBBw(YXUj_H8U1NnPw@vU1O&dgV_qXFYTz_vPN+GuGm&xEJoVu9Rq8RlC<)GcU}U zk>9Eqwbq!F>#Mp6>T2Cv`Igc*YthlJ@1ixQ?6-`eyURZ=PqF62TFzR12a56S_13@A zc0KzNA92rBgMQ+VBzus8dEY*qTBl4=-(T-iJL|n%W7lTpzV$ZPso3DeHsxt`!#_Q0 zcOAbs@_Q4%ujTh<`0C%je@EosO72KQ|KY^U`^xT|+Bay|rDK-(z~tyCH$r@&Tp_`t zSggqbxRJk$S#JW@Df}n#duki!EU*r2&ZE19_Ct~Tl#`IUb6DM0@|d*7Bs$K~xeU6< z_c^0OId;r}Xjf;fX?!b0^GeEu6Yw7n4d2Fkhui;r`l+FO2Y=zE!MXa&{T=FW&dp}+ z=T6{&Ph=zc4r30E#ie!7<uwJYV2M|F?@)gXLU< zR-O1yab)R&1W6|0M@BosaT++<&@IZ(7Y@Rq`nvGiDPtqkt*g?U*V^`Sd2M*QHFkZ8 zd!Cs1RJlCLH%RnaT{V0&E*`t+fc9x{mfV4PoN1;tRGoik^sb??yJiDVn|wFrz=-x* zs|$zr{5Cvh&ua7k96S$B;GK4z>DLV{dKxKTNBKr@cY|GL;Ao=GwZNo) z&z|mR9oKx@;Rdhl-02SApJ%NIIa)ld?h5j%o<}eC!oSJ+^|U{%T{6^r3w2sU&hrcS zy%?I;aDOWod~e#^0S!x`MJeAVyo2&Pfg#G@rQVu{&3E3^cdKa3-+QT#JQ!Y$(#BFl z$9+*R*R?3FmebyuE-pFaIg@_C#q}Ke?4vJ}(@Gub9;0JDyP@1Wt!1~x#`J3p343h6 z;5Og7i;abrrtfWntBrH=3@?RBt_+n~E34HO@0f4q@Q%hP9hUl*fq%abuXKpU?(;z! zy%fGaa))Homk0eeu+g5u!MA~#Hs_3_&E4*A$uGKPUsZ%Y7QjC?-xolaC1U)gZo(eZ zKiHX{U(*Mzj84?p^xX*R>6^Lt+2hUF(0@+#^~3Lw+-v}=ZL z{WkJNyLmr6LGrTrSJ`+G&$iwZYWXDZ#A6d&f54vT<}M)6M#J)-@7{EiQU*O8%PZ>|oF+*i%t5y(?@E*XW-u}sQX8Ic>7N`%&XnV?|<3w_F9wkdFQEh)dTjGlKnX6S-u^-{gLrYhq`#4 z(J$J&-&sVT(0-Wq+i8C;?Vk}Eaq}6p4-AHu+cvy|G1q6RHq5Ej-W#1WB^&di=!f1g zZIW};N1J?K06w1He-mvjr>$z*noC=!^ZwgI$8Jj_>o;*W<{8}6&$iR~t$fjvf%>P~ z{->lREWV&^{BZn5@+#1~jn)_^{_c>;*B*%uze>KxUzp#-&h0;qck=dmKo@Y^F5TTd z8#y80qRI;2EZwMF5F-P^LnHRkwu~JU&LoExx;)M}66ochk?)|jPUTy0PU`_bfB(1e zR;ttNF)+S_*6t;L5o71k{Y^7!^;@{#>E*e|H(6F<)fgS_^{u?t2>8+WCXG#(J_0`R zk#x7Q@xbi#65V>CooSDMxv5 zcS&8(YRVd1`_TritFf`-%f59yZ?Nm+pY=3xT??%?YyQ*xB=fmUPmJd`TEwMBlk+~h zU%D}beC+2mWqg*-l7vg>=dN#t}3V0eBLmx~wye^-hn)YgDYdGsa&A@-Uc$fIo_)jw}*MGvsdOp*G_%09T`Aj@h9M49R9{ZX zxi8ZHzNOGHs=ul;rC;O1#%((TTFeR!zquP;)&8sS7}tl9&5gf2wO00L4K_e~M~dQ; ze&t`LjBaAD7Q7mfO<@jd<`evW#M{@T__pkVcu0Fmg_9(_)D3S8Y`%b4@%fw?=Ab0sRy(lft>CG8ZU8zj#9>~d`Gn{yDR&w+$pz>5Wa%)>q1?f zw1G|Th}-B{^shg!dxg9!gA@3$4?ff!Px-N7&D}cDJ=zZ%`SI-)=+6<>p5Jbyd~U?* zAcn;Cb)qq*(Q&<@7F0_ zB%6<4yKNc1m-~B!`CT>Aw7tJ-qy-=SGdyIQ^@bqcHDA_T8GX^A{XUx0?nLk2)ik}< z=*kPGBb$t~Tzd^Ue_wk8AJP898R+Za9>ZKdaLCt{@K+kRtKoI&wY~lxLwL$>N4#Qq z0)A3|lBI+4f1}9gQs}z?`9NlO0>dmmr~a8gm$VrB%iij}UffFi{>0Y_+ud_m&b`hl zkQ|z}8BZEMYGXX&A2Uxw|N6P(1{U~mNLb*>G<+<697IQkv4M=)!TUcx zME{>Hd_Nke>wRcW?$~&`Z~+(*I&xsOYbZ$+S7g+egEQ@_$Y1;&Hvu9oOFa^ zxyv65eogl5GjK0`>GE+oe($t?;WW`ij4((e@FyB2npW2?E46B*rw*bK_%-&!+b^1i z9<1z{dF?XVtO~h)xaQ>YJ7gcVPu9>1dde0VT0u9R`L1=F-N2!_(mdu1vLgeVb7!d- z`CD& z^Ji`zt@S{kKi}bx)BfMfpI;&e;z(reZ`hds?XvdZ@7(=;`1Xg?8;|}U7|WO=q)W^H zfw7!-gmhW!k0q8{hxUEOq?wogKgZ|<;=5t+leZR)FYWK^mYrApin*4fIaPO~yMCp! znWWc@o!>Lo`N4qJvvYg9Iqxy6do?%EdHU!j_PI^(H-2Dptn+U2n5B<-=13Q4&Tyl0 zo6#H6Pb&Z0GlM%$GWrQ!B;O-8!`){i-w@pz32DApk1wwI#GXYx_=`#6Oh&)V2gaD? zJWgJTnPZ`smiaLV#Q?3OJC z4)tdN$9C|(AKiS(AC>oUasS|-h>J!`6pPEK?PR5A%NeU#+n%Qw2XQ#TTI0FWnD+DhW;+*ea&0x|q%fxu9ebEF%eJJ;m zW`ieSRt`)L_3zex!1{5{efWa1^TD`|Wx2N68s^&SnPb@FPrgs#{!`(`9iw>LU6)@% zAAI-coRxepG1VwriCiFSW(^U#ulM{y!=sETOkK@m9_n-BcV>PJJopgEbn)|n=*#6r z+TPtbqgH*p<9Ff6;w4)?v)`()9OmQajQge45}sy_-RhlvAR8@z#rF$@FL$mEov`T@ zz;-txn`o+kEhllVImmcAI{s#Gpa&!h~Y>abG813dpx_yM&EuQ7+ohb1L;7-9K z7CuilA1csi&OdVZZK4M|@S!`_k8t;4!vh`6?XQfF@Xp-PeZ;yFdacy>%=3}fl@-SZ z?#EbHt_j{x;QiqJ6y6WsPv`xSf%j+ee(?T8-Vfea^M3ID9lU>B;Qe>;e(?T1ydS); ziqP58j{4`@#DQc;CCb7*|CC?<9=Ck2SAK(E{%n5vb$3;d?UbzQn)Gz<6TfW{aU*wnn#w|~H>){AAkrB`Lsb~Crqb7EoJ4Xy%b5(Uo=Qa)e`{V(5R zC+EdAUnM(`+mo)oQUik~hf222kE6+t3YTi$=67n^b+}zI*`7}FM({u6N6CL^!iQq5sUsep zk^dbwd?oRu&e`dB0)LSh=os>M1!Fv>Gcu+!cGl%OLtXD&X3_s|%$YlJ`?iJV%pK^1 z-})d;I1ljL%+o7vhi`E6trEVC7yHS=?oM)&=HUZv0$+_5bJY^_?V`JH_P)~=3uVvS zd6uymyMSMyGZZI2jP1s!EP56Zb>dOZ+kv;j-rn1v^1k?4v^RFHdA##4-kvG>JMzQi z!&H^>PTm>+`D+YEi{J7i!0%cQza8wGD2Cr(FFiDVR|W7}a|HN(e-ZqCssO*=``Xdr z_nIR39s36Go1cp{s;jNh?(+S1;b+7~y(0{KCHL|0X9Pxe8uh!P)UKzJIwKUcQfKcK}#-vlqutLJU+ygbpliG4b?z@CaSpYZ2jOR4jWWzI)S zJ%jFHK7$T){j1G=5TN9OJHM$H)<3E|ww}>O75I zy3HQ$OnooE@6?(3eBW{hG3Qp|PIo9bk?&igUktt~CO9YS9WxIMJKKe?m~d6*BsWuc z1mDDbO}IM7?Dwfa58ng6l-JaaZ2PkNJM^1mf3R~DerWEy=F02Qy4s5i$S+vV+-Lcc z%>ArW-iK~#M>kp1-F(_a?83b7u1W8*=9lT_o#W}VH#*$;v*s9_`+Ly$C>ukWY2pda z`n^^>fgaTPKPg|oB_}waK|ZD5^qtC8j8QO2$7!y-vy<<|+Rnm6Yz25E_o8)#(QzBa z7ubfRJtE5&cYEVZHS%3g%Ab7*zBn`gTa@ej%iJ7o>X`Ed0y?fmV~~#X`J!T?W5X9; zW4w8C6&r27AKTW7Y%Ku)%z2Z@R5vp9{0`(xGP4RAB3nCI_mf=d`>|ooAeEtL8?m&AMn7T)Z!ZAbQ2`o5RCY2?zkhtGGqvioI? zV_gyX-0aEcy84dszI^s(<%}ePCm8vV8&R*KK^u)yQi!E3fd#1SkGaluKUW|BZ}UFlOb|9)`Sb zM_$iCUdgShbiW;Y1LHRG8q3M+E^pkaUgVYX{hX_mYRH$@)T^GnQYRiO$@57Pyl>>y z=M&`hB>3d3jF&leZjOylo1J41RUQlY#^?lN8}Vn1ZNxv;_XZV@k>08&ZjrJXGdOq> z{SfqBgXcQY#v$8T%Qq$ccJ4T+ogDm%X$l_s=Q&^gFwZ-4^9%WL-M}q>U+Xwk!*V)D zYa@a|aSiLb4V>SaTOZc=q-&I0bC>p0B_}#lQziM|(vD3vx-czy+6J7r(9f;trXBQo zCq6{0kAv~-ff--ByJ>u_+D!2KOY*hu@b;lLuB=@awQ44nXw8Q6cw)RaD%2r8l8yo6 zs3je3pd2(Dy!k@{ycb_ZYl6=$!>VSl_Xp^X`oRc?|c}@0eqRz-XS@Q*K?sltrvU5-W;EwCLm;A8aW#W^`*XlbS{V8p~O0EabilCiSvAhUBZn zchG~~;PLA>y6>y~Zpy-*5B_4hq=oq6B%K9V)NdC|o#>uxWmnvDxt`;lFH~ zCOfw>4|$(p4&Y>j@bLopuqWmFbkUGI{uk;yZq)dD{qdt0?cvTa#xI{wa_-`9l5?9s z{!YFLP5C8(@mr@8Z(M?IjG-IRHNs1>S7RT}*he_mWXH}uW&Sw6!Z><0_IdgGVf|lx zdk#Fk$2xq6JUjNd#=a>q_C1rFmEJyl^_zg_!t<<})ggCJlJF4@QGcBUZ}E4dOPlVE z$oe|>Tjw^m`T1z6^Q_u_R5liTd3^JoS3NpEN-U08Slss&xnB>DiEozEm*K0&J-qVl zUBYXphu2oVy&XNy`RW5!%L&3O->^>h4s-5(pT|@1khN1bW{hKf2|PweyLgXJc24y1 z-aCpmMmxg-cu&7D!sWY9@lM&P_>CU!JHdVLaZYz^lrz!CJ^go6=HvbYjP}an zaTn>G_iDkf}qu&zfB;`N&x^A}m#D7yPLwQ@S4Vbp8Ew&o{bI~rdf8$=OLwOF$WlJ={ zM^mkupVOz&T}v(Pv2u0SQu@Ic@!t{OX`cuDmDOh^mrcHm=40d?8lR_P3Ue-iL3kgeo3lwywlw|-Z|ydp5CYbZpwW5y8@eQ z;(8yL?dQtzhet55W#4S(It_d&&Hsxugyn&;!H z{L9EqvU05R%U=xcczs}S(!xKKw0uFb1f1QmkV9}hW-Iq<)9-t6 zsg+qA0S8|EX{*{r*L;O`WjD14ql_e5f-eu{ z%TPzY%v{lW2w&!szZUmpS{|qE0$*kfJYVR`+~f1T=gVv&e^vOFUOf_DX3M9F`!XHS z;b?uC`$fBJgFHRiS;HAjdA=GcPdy!}&6& zX2+iI%X|#@j@FmCTw~hgj(rOZJ_}!FmUsS?=<#NJnRd1PsO!r#)3$uzAF$8V(D~7$ z^JQNDF?cQTWj2G?pf7XvsR3PD;LDi)@n!zv*YSLru1tXMj=-1c{iMft1-?u%5JUb)`Z5Mz$*Eai=JU!~ zE#k|ZEn8p2m-!BQI_S%!b$=LNCZ;~-9-S}KEgjP>Qu^JVsXHs0v{Bk*NxU;YYwnFWmDXnmQJH11IW z-B&Ty>3Qz&l zgBN_6YbbNiiOXLfR;)_?x^i!nuM=jzl(!yCEG!!riyivi404zEZjt)xV~J&r{nJs@ z?JcZZ08^Sdk#cmEgG0{2_Uea5IO!4Gd%61Acvz?6ckUS?s+*0OjpH|R7fvc)hy8p> z&R-zrKFi-nf^Tcqmzi6->&(nC=});!zVG{^?}BH1V$EH|hrw^tn79$~L3i#mbC)^y z1V34AC~mKH<=N1w@T?tV)Wj0cLasE2y{@-ltv;&pc;5j~?2j>@4t~=&Sj?gDvl%1n zNbOpmUe>L#&FEj&U0-*4H~IX5@fqJ;^GXAw=9e@3pYC$|6tCswJQdQEF>6jLKB6vh z_zLIZ%M0M_48$3!rx>H!Rli2}S4?w~$9T4wvS55MxOVT;Omi6Jk`=b`bFU4bCc*Xl zY^>M7>z;j-Ig-07d=NXn2{~1MNntL6 zo_&I6;<;k{6r_>(=~i^g%RSHm{^Fds{B!>#8^EA3n{RtGhMb%5_n#*_j=hP|Z(#$X6k$u3lVGRA~+wS%k7N(ik zYd>>3%J;XHYhCR(3oAIEsEsp(boNVfHZetVL6dX&%f5p&cyayMX~a6+a{{yn>$ZC9 zpmXE?M%@bbV&4fqm5;Iz+~&zDeY3GS)oW!99G>vuzYzG1Jss!CX#I3&{XJHTIRg@0r+LSG-@OK05>rYyf#Vuz z&Ub6mP29s18jE6g`u0k4wHLqpw)n2p`4a0RqOsNpliXkC&m}rp>!U0|++WYTb>CZ> zjdPp~T;{zP>w%P?PCe~`)$jK_F}UNK&`P+Gt^l`gye4q3cg=dnB*oUu{>fd+b51>- z&-*Dq$CiFlPZ0}1cWS(Mb-HWK>F306WBKMuxLvt8%Cml*btT361j|@pQD0`Q5FAQA zcE+@~{Pu;C5uMYq-=mZ24aSD&#XPjm9jD#*c(l`6Sx`2^-Wv5z)vL^3^V$bLn>8<= zcFAc@=59~F0^7cyyReC;wz6-rTjTF$&4#~9glsme~a3pY$auSu5*wg zq2?ze$mq<7mk(?Q_Of{TT{=#=_2O&xzGm(s_NQ_B-y8Y_f_`dzx+5RR5Zg3$&;_EY^0JjnJr@}L*i!JDFggPOok#B6 z9%AC;IF&Uoitx=8v)2bYrYkLD=hL$pBWqpaVcGliJa}n77yY<8i{A^ln4_5f=^LHr zo-={&Q%qB1()aO(6RY;ltJ&s{L4BKc&!nE(UqM^wsIc+{S9@(T7M<^=JP-Ae#$Ql= z{61)**ybF@$lhLeoFiHDvu!tjFY4Mdb5;hlFzZ>RMbW&MBhaDnjn0JCenYJX2p;(5iCJrAIFAnV_(k-h z+&z1O{aw`cX_JI*?)e6R{e{SFFelRJ`WXB}f8k@?H8hWS@<1 zp!4NeA5{*k_E#qGFQxO4-zQFuS2%Y<^F-_-`gHqNd|rLGvaU6ecd)TfSk(J3^h>G| zS{8xh6yT6OfM<%rVPgLQIFhte2*>rnAsd*513oDR$8CZG{wW5>c;MJ9IHE(qfetd~ z?Rof+UMz%TwctQLvv9y$#o+i?!2z!ogX1qdt(FS}M{Ec<;I%wBXr~a4I>7-CX5kR8 z7NN^Z!2!P(gX0O{Aom*n7y=II2=dMh2 zWOY(!VD7-)2I1rsY(8T%@)5Se8=Ji~RAjzG{;{6P_E*8H=&ufAH~4lra@~B=!5U9?_zG}_KzGJedK+=Tyl(l z%!6ZK^KQN|V>#d1q8#T(5@wH0Q0GYg@nNLfqGCnm6({h0TIy7Xj(1LI8+e{|2LY%XQ{|=-d`HRUP?xR3%=N&;>@7+-kB(ZMfOpB z=sU*?O5C#tlCd8w)O<q_?t1wwurCuzR}((J5?mU zf!^wtKE$pn<{%qu{1#x*_!)z%Cm2I`nDX>p-^bGMIK0C7Qa8_;WjP}lx8!BRhU04^ z{MNa1VfHSB;gj&OZeJQpYN1BOi)r_Ct@+*~ZY0ev+z!|0BQqOc5Uj+PV?cVrt z@SXNV_~#>APNIUeb+ho4Hmm`x;{*$N1~wH~1-r?rPjq>71vt*WrIZb|gA$532lpojsMB zD_+W-U1atuf@i~@@a+`I9`s4i<1+Yh^@riu=C^$IAn*8V=z5+YHX&I{ z(Z|8(E(Ooa$?-upX6eaM5Kr>geELgQisyB9qLJ}`5HGw${zp(p<;6jw;V!M`qxxUV*U>Yfr#FjPIBHYhIRkd3^5N zup7bcHcysoz{!o8drvSpanENYUockE^&#LiG+Us#%XtS_KN^U~CLU1!H~DVn?6A*2 z2mhd_OuhM{Gw0Pk!Z~8Mcx8%Pkc*>y3gyoz7d>I0Ltm~9sPfJcO9O|>`35e}yt8*Z z`5tcP1-pi0n{|d$f-?sVZOM;qkDYCDclXimeaOj&iQR7G9MOIB+s;{qI#;zlb{2KW zLt=dXS#H>~o26@&U!DYy><={iEe!mt-0xRQ#?tW8R?ZSLv5R^21M-h`?xA3a_qt~Q z>I|!MHGWTrxxO}McQH2U$BI{rQ;1&W`bl#_^%w6f1cMi z;6+F4T;4u@`|ziroBNydy!h>?P2kd5jcV77;RfnUZ^!tHUeTU#<>0M+4xdvoAL&e; zyQRJZ_j*_JKgQ6JUPqm$tPXt-*vPhY!cxkXb1e<*vlr~kxoj(w-eiGe-Y=t06TfK> zf7QUG9O}Vn{@H`ll{&ZBz|3#OFoQZF&sMCW?q=Ghy*~m|(C7HSCj0wY%MQx^V5jWB zaeRA*JbUOSTOhi0qqB`ofVZRrlvm{P{?z`*l{*gIWgiUwurJ1kf!B2gzbQk98~cjv z>)w=2f>y|~?5kpuvaiWW#3t#>KcCg;ZFFX~UFfN~vxz|%KgiYN<~jVL7*kO{2py(6 zCXNN4DAre@So0+QV$-X}==`$}@Vyl;Mq^8#yXP#4NBvlt(SP7zCotRR?<=_B2Z)Blt$=5Vq=U3Z zy#b{_Fs(^Q6iON){nXy!6rc*mJZNFHdP1UrhWn27qrhG?2u{J=p z@irynI8ZYxRY!~U-tp%Nq4k`E*79|}W6saPeBYmEuXWBohd`;fx%_^AF+vnO8_b=k9U=llN! zJmdE;CEkplqj$5fibD9Y?k?W`fnfh#s+AC&7k%czs?E!{)7Mtj#lok_9_ZbGcXcNB zA9lpg3~#pjx-+Hw1GHBGdu4WBS~`20+VRY}ju;)eLyGmi_g~2WVP68~5d;@RT`t#^ z`02M_NB#4={<=Qu;kRd)+q1m)W_-!AegB;IlCdNCY?E;?Z)?hKQa)?Z-b|NpQn(}7 zhKwCva6ue5JUg}vHc;12>pB1&d)UhiZ5nAKJ!1)NWK)^m0UQ*w5{+rSqFME4?K$&X z(Tz*vA|^b4RY(TzGE>JfM;Hw1(o`JD59jwz)H}W6qs<=j)g|^VXSP zKlcyW+}pV`G}h(ceofUGx4OSa`2VpVRZ)6s!3TYkuX< zqOWglZur%B<3+|#m^(b%Xl8Y?Pk49D;$u75ii#H)?Y$AW0*AgH zPkVLX3iHfC7e;^JtKuX#fUmKhAMRXo4S4TtINQ(p$Jx&|_H!}&xsd%_L_5|i-tkH1 zUs=0WaW2WOvihUYxa>jMA+cOMd!#|OGxXMg4JBK@QFRWWyB@cX(tUwRT;~AXhaL`Z zNYpuyEjc<5m;xKE(fBBEZv^fkFwgT`_NmE-4%H5yKC*|pay`_s+lMc8qTtRL4IZa} zZOoU>M&B;)L+1jd3&;$7(LQ@$k`pe0LN0 z?qV5F!!NUG_H#V@%3gg8d6%7)YPNat{s4XJ&LY(wls`>!Pkn`xBm=o;fws1`S*^{I zbIH1-$+K_B?D))?+XLcVb~f7HWo-E?p#Q^p)jS`Zxgg|oou>hwXRhGc2V{HjjL+}} zt97KBeww@1K+Wms&t?Rh#b5ky!YRr0L7r`y9&FZGf^Kpq-cTVsw!eJU^UUq4Ttr@=MC%J^hkrHR!{{-r0wwFI0BcKwS{ukHUU>{;#tpg1hY}d!u-j zbT`@l#AJFet9^NN&dfN@Wx5^vC4MnX8@rzZ8MhO;sXLyq6NlxiXq;5DQGOot>p2`x z`aNyU2|WcLbU}Wa1HjkT&)kzfKIu>Gz*v48`8mIWu94J1%5o=wUuCTFPbXKxPx&jS zP+$C1WdFEm5V#zyhh~Ax0JL-fS{j6Yb)Uyo z83eX=?+Z9ApO0*<8~*jZPbm(lTJkk%V z4_MzYFqRCIEEGS~n3`W!bjhFmK5MPN2^;M@_Kbg@TGV5|&)TxkwCGwqqWHePg9rZo z-MsJp5;_wzzE4S2?3Lb^9%glw*?-wL+BebHdU9$#o$EKfFPpjNshJCI<(d74 zo!le(Bi2$puQpzDI%h<=7xCR~te1D!>K)p5izb6`DLgZlh2V1xKk8g_ zru0qJ8DEaRG|u@xwdd^jI_@jwwm*@osXS0WSX7?R{@9Hd6xIXl@O|x6-qjB81r}kOKJF>3)&PwV7m+WlzS@wc>e-Cx|>>c3p zkMqz~zz^YZ&&}5^=%H4}Yxpc(;6(gKE>|VaAp$3$9r=}tvbVvXf8;muLe&7wJ{?4# zIEQi3`{bL_Z+{%dI@46G1)b}i`VQP2Ec$vnW-RFGV16#&Vw#?(gFexdU(+{$O)gFB z^Cv+pe}Cd_&yo!#T};n)-=1vvyU~-Z7Gh^2$3Zd;ej%JR|C;^-zfk%R{6u_1z6tgf zJ{0>%2H7vkTQ6G&kPq0)o9^nak5b5AtwVf9ZA!TzrFu?D``*Td$0~0~pEaJv*E)h| z1vb2JuifWoRR=HR_$#|pgL!HGA!FvXC+xGAZ+Z93_MiDESEDRuRVmwPZ@3mTM2|t^ zWxkArTn~em+rrDwI4!(1I4zvqa#I#Ns2Iae!dBxuc*b!MtuKx_B8Q?`uX6Fg@v#p{ zvb!N8|4{-~ZQ85yykF}R{^R#Z`*y;6w7MbsYw&UGJxbJGOsqpXfFD!(vtN^q&l-z3 z!afvn1X#=eaukj*{;h6&=@i-%wexuLtB+U2nk)Cn<`VmyPKBS8?NMc0=GI%ekCkl_ z{YhW@(sA0ONAHv_EqVL?%69z~zAoE)<_S!`MO$QAGrXc1z4oRYF=Edv^qcNk2xkPR zhbf*t3=f?iG+Q14|BWY1_q3hxk#&mS#qmP+0~j(d#`5v?Y(3wbh#R6S%Eu^vXmkKQ znJ*Eze-GVDdZTE}*Njn{LB4BG4uX%81=8(80aqN!u}OlQC#QIKGK`0ZIB2 zEeGD0$vZ|5@FC$|W5E4e+tOp7#i(eDt-Pu6y0cEGEjqvpjkD*TP}{r5F^ALzW4G-u zoJ)I2T|pKP9ox=sEg*1lSHU5Al=Z%|$#zdn^0c&r^P0(Ou~~xrSEvElw z8>5GTwf4mJops8Ox^z-zqj;O$n?*ew?Z4nG9ZB~J$ycKs<62@slEDobmnU6GQ_M^9 z<@9OCmsyqeaq30di}#y6rho1OFI1to1=J!wrGj?J$wFRK;eR0hMqAnNieDHEfcKlI zv-V>7a`@EB_(7#@FW6IG^+jd!@!E)o`}I`PCi<}XR+BtQI_7z`!Djhy#+nbC^P=6vFGIZoqVF7 z!1)!EF&*8R5zCxo_xmjUs`iEaTARLKwfTkb601w>x7Hw-`na2Dv^^bsPV2}qeuz)A z#oN^Sj_ZG(66bu| z|1m4rtaCX2`={vZ-}qj(rUahc#QsVDH(cPe)eB*b7Mq+N^r>-&85Jq9m$kIf~?*DL&t?Aemzw5g<%ecoTqItB0R zfLWXi5r3~|elfhq9<#DP^ij)YJQmHZp}%9%+?$UxZ%^CzRJKXrtJ)O#V0DYh>QpAS zsi#%IIcZBK*I+yv98gRozaHHi`pthKXkMc4dxGX0IFr)YIwz9uYM<|zLu^p@(~0kA z>}*PWWit2;d(`<`G>ilvs_KOkG0bxSV9 za$Y&Cd}c1PtIY;Z&V|0ceR-_8_*ymQuW-EB&t#F3hnb6G{LD17ZRTAXU6m++saV@MUC;odp<8HRncKo8jT}y-!Gavlhpk$Ntr$yUk^fc<1@LmcrLA z4+>wuBEV<3;mS$)Jugp9+V~uJ^Uv2XKHqOZP7SvOg`vgBw`I`Da{gZhKCNKx>}f52 zQ0;A=zH;JKhU?<_8opw4R^o_w$U%>{8=9iid|jhcc%SyN3rz3J zw3yzPWj?ZT2N++mfXe<1|LG_2_)i=8j74`I=YE0Q&>A(*A?B{Q&7zx_>tC`bD+4?9*7Bd&;;Y%Wqz*4#()dcYDSC#x&W4z`=``axn}^=B?98CB^i0;m-1Cf+ z(|CzKyG{z`&raeNFd1h3hpy-EM%J}1D6ECgHl=iyLBA1qNKG6bULO<=ttX}pPRqZq zJ1K{5q5Un4bu;tN@EQ2cnH=yJzo#~HCh>g|_Km~ahU$2oS=t_z%g0(&Uno_5^j=ot z#zt!W{YS6kJK&#epbYcP-F!5D-pup=pN9dPdT?X~xbfEC4G((=p5%GhchPf_JZwMT zD|y&X>caiLc-VliXL1T~_zig2w_HE6pDTITSHAOKmxp~0-RS?TJnU`k>3;5k7Bxiee8#_a}wnANCznTX_2?#b=WuP%#x+hL7(||r?eE7ARn-$;yCsM z+950C^D$dW-;qK3ZtwA3IRLU7l(VMu?2<>tm~uMSmqYR6@v)Ao_Xg6?4Dzv3Kd6)` z|K{o>$hMG7#V_6`U0Heu`Gn?=)LAIieW~GmR0DI6E+*O6z?f$5U&npV$hf3$xJ(9g zhWiy;A6ys|o?R4B2OU4hV)WSigT7~$D}FGUGjykoe1dZrYtY}r*7M0>Iz9GxFV+~R zk0>8|htu;^+cc~SE>djs;39m}3-MvkXT5FcMYPvh9>pW<{2XxG4$RP_@lDJe`RQrF zX2qqS8RI>{W*olfQ>F%+mHU5yHZ~UdiK?yY`+p8OtXzz4&WK0{&5%c}90KKF^)p9& z0MuV7&)Ls6(HHacej9YAjd2dv8RR?R`<>@}zvw~PdDIi=L|?7va}%{Y*YfVlpunY{ z_e-vH-+bnR_+6__JyJ`Km-#~BlLPb227B_d4d+L1+>c-2Mt8Tds&b9WW%(s@ zmVJ!xJyVTg|OE!06qUz7+ zg!$|P4)z&1OZUz*f92rxq^A%!nlhsIM2|XCBb{|eb#S$CAvtD_Irx2kwx4qa>%en2 z{@);Txqp3Rfc_8Ax6YS6NZyR@81K-Tu;7)^sniVe_&vHjD7=dPvxDgfyna%6okOQo ze>!u5p4^MReDwX`_cHhUc>V$O@r^;@I^Lb#@`~uLC#hcv|L}Q{x$Wa38@Kxv(V5gL z6b%S=f@A)E{>IbC7S=pLJVNJ(rj2B`^8R+w$TVo=?2&`e(la-<6wbisksq(Nya#>e zpijZF7JV-1w^E!|vG@aN+R9ee7<|_lKJQ*W=-b)%fIUz8-iTlC*V+U2vKY%w`Wxs= z@kQ$we2e?DGic55#E*!#HU=%tjeL&J+Ts^=Jb`mO&EEG%+vXhn)y@d5Z)w(eNqgVc z$Xe%MJLLO0rvSd!$bUM>XK=k3`-1N(zvT7Q1J82Jo*}L20^A0}7sXRL6 z^1Rx;%Q(E} zEH+;7hpFfZ^Dkv~cAbgNxy+4~D>L;KEPPU)^bf?sc z7PH;zv0Z(Bmip@X`Lzpj*l@4lbFv<{e6HdliubM@&mKYB%7GBPyg%xC{a)|#UZ}gg z#_IR?QCdVHK87ZAF7}KML#-oq0fKAXRWQL zj=D3@$*(Lg8a~utwZC03;Uhii)cynD(`B2f8)kmI%+5`$&DPqX8b_)9=et@f+J_u-4qky@ zg!Ak@)JIS5g~sb-yw!|1mOpeZ^DFm%mi(RKWjIO9qh0x+>?m4}9( zU_T{yvz`4G&((SUfU{rXamJ^4-jIrMJ;~dSs?7#2XpYiNRrA4U1b%IEMSj^jkzvXK z&cP3qZ}tUbfoKZ;8@*4s1C4py0hi;t6k3OBw56flx(#+VEnC0gQp1}KbEA)Dg3XJx z|M0;lfYT!S>6Q%Yd<(KS);pMgv9E4^??ykf_iN`y1a>wn?@{l6i5wE$S*QImJo%?o z)X1HRgZRu(5)A-D@ml3Q<|Tij4dGrl@8BQZe4aj=J{Z@Ew^$8(a5i%iuG$_650UM< zcWUhUy$yVK^V|E%=ueoR{ELdk<=kFqyo2bD>hH+9@>+9x_G6xD?AhNEjbA(RkI?#P z=Xml`&mk{$26?IDqaAfs`#m3zagZ8r#})^DH(TFdy+r&-`z`sPb25LA zUFY*+Yd0+Z-|T6eOTpUs z?Mw9iQ{ySp^Go~cX*1WEjxykJytnXpzJHrNv+o^a;4^$hdUto5>apE-NtW7k-RaFS zAI#2;CiAXz#aIq}4Lo zmY&i0z7^rOaPlGADrTL$OI^Gn=4CT1C-(EFieA89@TQVpI6Q7K*?Z!(#&U|b{|8=j zmBG-RY3+>bA?8Hm@I|AWo00Y4ujdi`Hy%b^cSkF;M*a<1l_l5q6zvtf$@2%>Q^t9k zaW3GQ>O=m3&*zRcCOOI3MeyTAsb;@7r)s|B=-S?pm)qm_hB1`$tNdo=$K~M>qsWjf z=Y<91?DTkFbY4mGv3tWsgK7Ggp6&S8a^T9@gDGX}!!N!XxmT&b{DOAsCwZ=8_@CJ? za-O@KZ$o;_``AOlW&wRmk15kz$G6x$PNQw-#CO|$0N%GEAis?oNa%w3mHJG526T;C zM}p>AsZ{eK?Q0slY?a&Buqs;ObcRmtD>RwwNJkrimC+q-*8}7CatDlTH{%uQ>AS^; z^NgWAZJ^(LXIpb9K1l9lc;?mziu+1mo(FEwzCIlEK~QHD-(_C;8yS=L^S2;(ur+f1 z*w=&UXg6^t%THjO@CZ7eIGp^x3>o`wv zT*%rMIljLLTwK^v_=T_82|R{Y@>}P(;IsE#64%%`13vrIB`sEi_39D%(DLlvk`(zs zz|Q(kr=xM~b)fzE>KmYI)z}FRYd;(Oyw&g5-(b1>=o-DVISc(3Y=n<7&jIF)E)fOY z)B=Tu{F-zgXb$U=ANs&N-rs&+r1O2}X|3~FEANDNOo`TbIP?5zWY)*Ew}NXua9)w9 z8{xhioa=diMFpJIU+a{3E*qvq-)DYmo7=wjHeVmCY&}0)iZ5!0bSLhDe^qoY?f*#a zA6|P~&*1c1ZA}ZNM7ga^w_X3Q$Wr9E^#}j&akQ#6eTq3Neq-xNO{IqGdC@hj$zUtE z1XH6o>V5QF<om~AQa&s0t+R0Q4;^sNio5_^zJm3bj#S z9Dc@r#JIz`k9}VLKvQbJh_Q#@g|TihmAko-^U>pGztl!wGW%spVxBqLn$6l-wf|3n zm-t+Ff7Sj^lACNgKqbFsobL4ZoUXN^OxMzTTX}C(claVN_lS1$J`m)On-)N(A!K4z`K`d zGf^k+g6IZx_aFh(grSKlwpl6uR%wR|Re?kV5 z`-mP`uEVHSo%s&2d!P?$Eu{88MnA%po;qxQzN7otciA-Eo!^h)j9r$rue%vz7h~wX zBkQgLx31hVnyxmTa`@}9@1hHMm(^wgo}&F2Zp^D6n4$AOE|^V$|Fxx}TUopO5ux)@ zcpf0#D~leZyTMg2T)Iqm`hQ6;=bZfo(K)=Q^{S89Kk>Jhs4kGsi*%=djBg1Ub&C8G zZy%Aav+3uInShy|KiU{k7n{(}j(M052p8d;q?!BZI{Y-r~WE4Ls<@P0gu9fe<44(6%?{}8x`oXhR z6Q@~v@;c}k8986`&w^XQtE%)Yny5O1vx4Sgz7L<(27S=^P%HArSeN`f(xK=t;nPi? z0od^ER5kb4x4HZ>=2gt0126DVny=Qz0VZK4*4antSM}L)iQhDi=5O<6UzB%+kH?+w zOq|hhwxa4q4}xb0kynzz1L(xQUgI|6LZXp|AQ&F>d8O<_9(+`su7;c#<)vFK3uK+* zwL^?8-lo_AzSOHFd*pNQ^59|ie}H*ejvIXp(U*Kk%Io%VALZ*N<7D=&X&-Y=lA8aL&iY7*qPaJ3}~P^P^wOPXms((Z6t7yhA>GVh6n) z8!n8#1s^f^^RDnp>u+U^I$u~m??kR)#T@`fQ=V_4*R7WiiMh(pBw26q0csrBIP@Q4 zS7hD!fJWe`7^nR27Het?;LRFr2A6pN5eBEF;?q@Cyiao!}c0et<9{DrB;wK>l2 z=kU3izgGR#@jm;m+FN>8JVL%T)mh8cC;M~xm$tWw{&d!q{v@aE{K3|i<}Rl%jcy5= zRRci0beYyVm$lAgthtN>UG(7dE#fq5?RL4%c9wUGr>AlsQ6Ak=bttXoTSvedd}_aK zPmzC>**P?yKGV~U zn^MDfI(S)}NqC_+8SR80jkFV9#4%9L7u3Ns7}NLzv`?R#XuqkEb0%eVfxt)QtxCSD zmMr#x)%N(iCzKCbI@4Ogdo8xLjq`K6IKRMob>o{h)*|QU<-hIqBEPQ*KHgUHFDrHi zZdlDy>9L{mN}0FfT9#MJ+-CHMX!GZz223*FlvQfheWHi5r6bG?y37? z@lNGt2~P3{P&);lN;}rAIZ8*7E-+TVO^y#pPq5my?8SC?fOwN)j<2kj@4S>ZJ9h2< zMExnPl{uBx>GEf7opCO&UuQ^8L-Guut+i!dBppZd_Bpo4tNS2Wf~U+eA*cEU|D$VS z*giBdH=R2M7wjC9^g4Kp@&`1ZVlD@;4pS|*H^qDo;2~KdS+LsiU-4U$1+>33U1fTS z__5@Hcxn^-vJ03hM|L7Pv8%}y9A?a2z_>*@0O%=8*(=GNEOmO7e<8V3L;eN)GKzJC zrPO_kYd|TEuz@{^YpIv(d(`8a@&WK#KA3Y&t|ask;DjE}+{<&rkq4VPy5~jTtVI`N z-pF+F60xr{6}m~fM9bp)220_Y%aaza**ys0pqC%IPYGCY9@5o{aPrsS4Bs{SVN8QF zFw&m*-!i~NFchpAi+ymo1McU5fq1**>KHh*>8{|SXn2ytp^ElpG+&`F370OfmqM&vU+uSfc4!YrG85x{v`Iyq=-&oPY7(tt6W(~pYFMN z=7JsTg8f5_@SWGqyme0--&to5|3?oWJ%@%d=HP9aZu)st`byb6O6L!f_hSk6{O$B} zIXK2xlg1d!uUE3^*;mH?qq4PD?4ggJs`lQ4tm|tU$L*i=F6nd0y^|lg8+xsxKDdX! zU@BR1fW7niF|Bh;@Gil;RQEt<*?tU%0r1&cQ%Wx?@Gp-lS*pkDZ!W@iBfYw6n(zvjeM#W%@VI@aZu`Rv_W1J>&{AdP@DO# z&0L;K4m0<@?BCe@S20nI3(% z{uc2WJdl24HVQmTJ}f=+^eXw8JWHiDyR#{q9$I}-w3ho1)IMKFU##glWR~g>NEdBE ze_4$$L;B0RGC^U8v3DZtA4vsQ4yPyXTuaW@E@(!&&suaJ)o!R!JLW6>bvQlAwV6p> zD)+oPFx^$M30>rB8@oOpTkzEHk2@T$jJH1^`=m|h*e6osi})|F^)=6Y9T??T`gprZDib#JUQZveHrp2cThju(}wz#&q{h_tpCK{)wk&a?1OUrR3E~0eC;VP z*YgwI(|;Rsd&=AnO=mw{o^q`J?Y*>xJyqZB?3LCp`(%haL6)+g$y{X135Gvu&XU1H z?0MW~mes$q^IPbH4fG|x2X31_yHop=R;|*r@V`#M|H|EN(24v82WUUY`c<3lnI_qc zQ^-@9ij0^_PVcmlk7L(9giWiu1b^htrhZni0Y~Id5PsY_I%;^672fb0v9)381_13EyiNa|qtKliV(i ze>Xl3`44`?It3f+TQmb5=fSr;bf1UD^9%V8FA%*N-L^?5lI)+dpy%cl3%XoAb?bvU zYJM-Zj~;4K2{&pvYiIp1rfS#sbW*<_y3Bz~%4Z0%3zYxcoxTRxUW1?M&Deo&9y!?7 zQs8`<@q#trjcgm)lj29pZPwY!)%)#@cmHc0c4MYDruMUoHw7UCR_bS%9 zn7^xNzl{H@_`f{nSC*5DUa#|emKRc26@3Kz8(KLd%jaK0KNv{p1?6^+)~Dw`=ecrk z$6ROn#Mk*stuuY}I$!=V3jFJD#=m}&`8ZGiN^H;d zN`@Uz<}L|M=Jrf4%G}|*kM^f*YNVa~1UqQ=+mx~I_;Q6mC8^tjV>#om(!~`=;eQ)v z`SCrA=VYl<6)0zc`X>RoB*{7k(oIzdBb(lEX)uoe<7t=XzwWu=J=xFpp5B-J%=^3( z`-e|p4&A@&Is8WRFZtB5*@|%zcvrdI)E~!;r1K@)HTc|La9MO)>--J1)Q!nf&tCA# zQqMlCoIY}bvlY1`@^y+1OfD#A7ubiB_zo`>?3Iftnba_e{e^Eeq+&Wow~pcepLJfH z23NChfmboU-k#LeftU0R>CX+-F<)kVw}}_!96ZqrZp+UNZp%~$x8WDNO*CB8j~PSu zmfD8w7rOKV`&`{U{ic4!d#xT9|BZ(+rhmUIXQpkk<;v(C=``r`?DH6T4)7Go8tE%` zo}PKjUnf6;*+<)ni!oL)#{qeE82)0uKE{^4_#EGD9c`1NWE@#FccEPmvm!2ziDTf2 z;aCNYdYm&_2j@hq(sK;QlK9gZ<4+k3Iy3vpv5BN}d)++`FUfoo*nOHc$rn=(OsQk7 z`AZ)N@xQci-lO;({|oCceW(7&>M-%0`Xen965pvm5}X`=|10&0Tnd%{(m^b9Zcyk@ z%$GSRms@9bbbjYex*v%9fd|^E;#^7AA{(HEGfu(Yi=v;-#@AC9%eNc3<0!i-)qGpB z??L)PMvjvVLKm_AJH|x~XVQP&xabe)Uph^2$K#jiUR%M^a-+Zt8zZdF~HUjF#~P>+Zqy{>bad_KS|={k=H4(a!^V z`UUp^3AToRt6H7?oGGWPHdpVAK7#6)UQ2d-d7f|<{2o9jRy~M8^vHwgj@_MSpdX)! zetahS@mc7{XN|P$JYDM5QS3LIg044Zq>K2<4i|gN4yGdCM!KijLFk_Q$j5(zzoYEx zG=xnX@KfC4C(Y5`=7SRI-np%jpf~(uBqBfp&biX=!9=lPxpls9O75t%d z46Q_etqbsnPbEG&b!2!I-#g?VpT>9HW5oF^KgYUsX^z*sIj%j<9B*Nc>%hw!nd8lF zj=}usB0opwlAYU9(4EMBj$dYuJGc{B@sghORaS#bd55wGcXVEb&3`rZN#Z+VMi<6B zI?sA?3*iIIRcGV0x0QLedFV`S#IMnPhb1rH;nrn%PamAQEDS>vN!xe`{wkY8d~3+Y zsNvoe*9W|MknxmTH$eZ&Pq++NU$%OFw45~#r5SGubPC?-tY{;32&8vgY!REK1=-)7 z{@Y91Gr$1-S-L^$X7)bd?96AgT~H4=ZwvF?8K(t+uWIl+y&cYbssVnaZKaKU&0P?eW;yeg~f2`Q*iOWY0pgNuqOSM z#W$6VaV7O^`Ki0{#X=j<)Tw1U_6C0+0e$Np6KIDzfv4I%Cep8ucaI7EjZJIJS^Bwf zCV7v^J*+M1H&eV)d2aB8K4PJL;W*B#ro%Zo_8|Od>M%OT)$-GPjX5p7vzt7 zXsp-sIp^NhS%Y@>yUw2IOrYe;KKGvLFo&-1`F!qjzh9)^-RBGV+~7XHlF#j~Z{=n6 zxVbd&Ip;o~$LD>n&CB>4y785hw8s5@4xby`?>a-!;N~E|_8Ry5>3rVjJ}Vy7c)+B zR@6xc_k3(c=XbWjpC5!jH?V&BJH;Q1K1+Cx`7FU%^=o!JbR`?U0DlE9%>STVEAgj5 zHVC$c;(PLeD7P!0=1d~?hSUxR|ePWgMxy|nY&YO?34IT)8 zDf&3!BjQ=w|FL|WuZMp=RJ$O$@Y#|-OuQ$@JDR8XW5_(kA8j1wVmcr^L$K9)?RR*D zd~kxbbc&pK4SrtbblBbikDl~fMs%KFfVIZ)s2L-IQ7|5htsd1EdtM+?n&)Kah8TfW(_yKc~7m&|m@HVP}<%wt&CR1E=usy@&OHf2J== zCxGAg7g)b>Keg^e~^{ z^+Df<@Gl?2cl<&8k%#UN3Lm81L|2FZ&=%_VfIoWnLGsEDZH=F&crO1Ux%JfhnXojE zKeLfG#CdEc=j{ouj&Xt&)z<_ z2wqCN;q|P^wH+e&duV-i^C$7se+rrYC^ERGGqwLy&`)niYX76q(5IQl1y#Yj!^xW- z5dP?nb@^I#uCkXL)=zU!?WuZ(uGxzo@&YjIMRv>A+LK<1PoKGd7Pskvx zTi?p_;N7Q8=2?1T^s`Rh14cjF!t<@E!hz0KYD9oLU%-wTt!X~gkt#fYJ#@&_Ntt>yXJR&bvGSG5)nt!OQLl4le3te^K*@*ZOxx|!#< z@OfoxVQ5dLIlqw4i}fM>iJnQ7WdAvv8`DHv`o+7ubwJ@*{oZS-?J|TN>J~$>G37%zVoz$FNA2tu4&O1|R zpAMtpG;^rKzc)UG&0n)r^R_ij=lS$jc$eC>77mSXl}{jE6KA#$P2fGPsgCFM{7s;3 z8~grM_{E~}&H3S=F#I#V_n#EypN94aPl|?rf*!{hnt!PI|D5MPZz<${!T+bB`=8+d zWei(edVIur*LZEe}jpSQ4-rLqv7+Ch={eD{g(pLQr(O!Lv|EupMzVD~#8~)|})!D`Lz5K=d zen5TG*7~OXn0ERzlC zN&5$&57lVNt^?23vmfwr;@O4455)4oV7d|5!jDy-B7Z-BTlm`=$FOtT`3(OY*hL?Y z@{Vewe^z*d-Gba5h8JgKiv+{~z^Mc5c?SC?|Co5L`<=a-GY*^J3)Cj{zb&U|(qJI?A)h`R_9a zaK&(1I6F50Z~0@7gs=AOr+oh@-{YNgz?Rqi)=^9ap|Es;oU7ReoK3OwmrRt&$H<7=lltO>iGYQ_?g*& z=nu!yZH!)iIp}no<;bQw`rsYmNSSVzX$ zkHbzc#$ioPqW4PA?%^H9|24nv^jk-=6MzSOD^9$Pvl2R^9Os3^IfP^Bng>r03h0}* zC;yW@@pkU;yq3O0^QpgdI{c5dRq}=%%vrJCQv7vBSuLm?w$>H2QH<8dUuPEYA5smR z9n4YwVBxdivWxZXw0_@$4~Bj30JpCPj;nxU2XMR|Im4b0v8Q?Hbr@P15>3EghEGWq zhMde1eDh9b=>F(|(~-BQv=)9TzgJTNzH=RXe|~BazB$_EkV}@czc#MV5wI1XmAyYS7uYtn^i}f29OLO;?L76Z8o_tn1-^sYQxov1 zzYDzGiS8o&Z#)v~gt`}8zMvfI*1V;+@U-Yc22XLVqg0gCDMk_-PJ!f^p)4zc`iLWe8Mv=y{x`PpnKdlq_vQS4RZ#c}=bXxXF5sbX z&!2gy^u3t923{z~>K!gtUaL4TxTgF`(PNj3mFFh#8^6mZCH>OVKY2(Shk?IxSc|ki z-O)NQmOoVeY;&}p^rJ|Bmi;B)(e<4BQCpt}Am5B?LY2v*{+j){j_OD})aTYqf0KMV z2tH=_u-<;w`&EAXI=_9(*(^^1pYQYcG=D#lEp{q&dFn_*7#vRf{7Yy{e5kQC;6C2W z@OFL|ANnGH@=@v;_=5j!@`&u7M)-Tao;WEuCtOimgB|UVEr%cU_8;%`fIqTtRXa>^ zap^HzS?@6a^Yy@p-}Fo{Js+N`9ALpx_riUYc3q4oeQN=I`1fB7&);{Ty#Lis7kXEQ zaWdc#_RUUkdWXYhjZ1ygjl;`gdLCZPfB5SzaC-;%T-;l5&FF9$Ygx`(ne$lu%=5Ai z=ArhInUYbJv}QIXYf~Hj<(gucjCKUZb5wsJdlfO;M`PKV|1A4_RU8{qUs-CQ1&UYB z-N0x5&*%SK{!_ol=GjI}NW2(2YzK~#8JBZsxTgi>)Mjb(F?iBqjjyx0Q@|5=zRB#w zcUuQI%YOBVrpb$l&p_8P=UM!%~n5Ccz}%19#8bJhBkL0e`@${ z`pfCy6ZT(jT<|%af6=^dWnRjMGPwzE4T2{__XC?PjI$M3+~0za|E!ULt*SjXW#qt? zmcqT9fi-`@_Hof3-q#wtnQttE&a!jvoZ|*BIrBn$J6pp0vir2gQS6vxZ14a5qH-Ir z8M%KC{!`&=r}LXKAK|a)MZXz*kh8f?#TR2AQ2qnVb7TBW@hRzFJLJ33f9ziAVe-f1 zK0sd^1T#i_yf#24b2pexP?4Q!>eD!_`-WHx*t+eOAHrGxSZ&XTR{6Ka+Q*7*P3{K0UW~ z%#Aoq@9gY?=s|3Nvhxp}74OiNdmQekc1n4>-(bx59W`cZzxLd6HSp!x8H*&mCP*A zg6?;V=culp&QyyBh1fvcTWRqJ`APdYAHX@FoyrH?25mHRU+lmv=ou^@M3_O8jX&n9b3_R86XKTz1{_On95WB3s&{$aiRDK1YSMWK=4C^l5%p0J;92FbMwmKU$!&%|Dc#zDd#^6t{N?ad+leU1Au$>9o*C2 zD{nyGbv{ma>bSKU|4E*k(!06`;dMGI*3ulEpNe6__|~u5mgQ89L0fM?Z!V*!{>0fK z@g?R+tN`5_{9A-=y@9O<4O+E}v zv$$LPadO)vOB!h>`dsqjxow+hf5K-4cYUEE_J*(2-;pQV#GlnwJr2>L&gj~_m&_=e zw`lZEpOc3S>Pe>*Gwf}oon)ovEn6>^p`5|%1kT`sWSMY*KF6LnJR=Ve5q}+J{UPmB z(8%q~+vrhrjtpP(?{Qvi@+@*P)_DLr_VHSq|EFth{`<}@=kqNcM~3@A7A72L3r_Yc(1J^b1v(k z?%m6@4%JpWX94*Sz2UZI&d0`g7`6o0e=K-Q?a9G{SG}^kZRS7pw7q?INDW}%7{__U z*Vwa0d{@Boo_*vQ>AY>W-*PFeFU87juNEC=ua>daCLaN2F((KE&@`_?|4rjgGMc--P!03FbVJccljr;`)g0)zv$y z7eby&Z;)l(@FI=zmy9dBM!IliY)$r?>d)C814HwH0mJ-!zsEC2R2M;bXX^$Bj=9H8 zyo0>Tfghvbs>jjq{5?FeGX8j?`#P!7_H|9&f8KrF%=&(x`+A|<*HOkjp?w90+SeO^ z;cs(a->!X4$U5oLCOaHYQ+)YtI`;_u%7@|4()|_lPoDdd+#G=Jr0i`Y&^fc&RE#p0JD$A=cB zo5k1P;qdZpTKklizOL$E{>$CoWqE%qbG8q%&kRt*)4hskihPTse;bKh3{I^@hIwLl7^5A(N+ z_a03VUkeWVyu%#u%zc)3(E&s!U*%7~Wq7|av+n`vWckNZg?&7izQCL}1;L_d2fTTR zb{eNFj`an`BsY)0tCQ__(%$l17COHSwyS7S`i}NrV}{i&ho4B;pFe*;_7C`BXH0pn zxLF20gfsiY**)>vWS9JJYLls*`oU(0hFmPK-^IeeivRrU{H?2rV|?Fozdyxq-v=%? z0~7jIEM9%&87p^ltLeKQPWF5qZAT|G&!No$+6;(RX_tLERUjX9Q^)E>(aZ$iKXZLc z-?Q^u@H0t!X4jJNxYvegmeO z6Zq4t_@3>l_JFmB-`qJaxLWy4Rs$cHFFn%w)H2|(3m>###5lvs=~QgRUl|D{-aW`(iC>9tiNDKV@*Ml-VL94%MwyLnIDq`g!W(S= z_b!U=!56Rg^2N$0dkCKK9Q_T`*YmWCdCSR`ldJP{ry&2ndHX3_{w#I!#^=#vK4o(6 ztdU{C9oh3K{IBx8eiHxD4)pIw&>ME4H|#`jkYA{DZgBbn!8_w^4aPMc!mV?I+f(^2 zWxrIe|6c_gv#%(gZ!rR9_lm2Ude8CJbBYy-Iqp<LO*Pmp;GxYEG&h$LyG>-Rg;jDAc z&7JY!@l(tPBAr4w;c)h_Y^jSSJNo5YIf=W7$)iAa05Bu&-`0ze>{g;_q1hA~jR} z9qax#m>6=%-MikY^;T1pLpg=9kH-E(kMi190@NiA)_wiXO`C8EL z%Fn(X3+9Qr`FOJYPal#jV?D};ly4;*7yBq)6eb&jXi^1C1Y`N;Vz?ydoAB`gd)o_V z=h$b)mHi`_HjwusTvc53xO*U9-RqQOyStWe!jTv}1=GT+H8 zd?%~QeJ8*>4<0qbv*B|k-wAZ;`%#{u$!T$2CLiQ_Z!hlHV(s5qvS6fz6BGs~ed!TQGHf=nTeEbi*O) z1r5E#zV@O=SPa7V#Xi(jV{3sAnT+zm6tf0DQZ@G=1JONN3i7WhS3cW`-s0A%90=Q& z7020^4)*1G_GKmevZ{n57qTz6(Vz5W^{c<)+D>3T=zw-dd~SVfbHlG<-@nBIm^=EJ zJ;NR^^@$9txjcq9bprZ4%y-V4xu6W@4*~OsVwe|wjl3)0o#eQMn=e+M zG>Z6SK!bGoy`~jl6_}+-ECDdoq@0U zWzN5}oV|h0mRtkgL;JrD&4;X4zU{9t|0C<(ip}{J?l*|f%B$9WExt_7&>|NTwW`Jk zg$s|W5m(}uMfpIx4p);{GLIP9G4Fh*&H&?=cxfYWknZvo=v`-Xj;yOf4-x-${GY!6 zbybzCPtV>Sr|*|l^nIG^`%(2h#@evem`frSI!2qPD`UT7v{}H~U#y>RvxZ}}Nv<^{ z2AjNxs>jht@?F80dn4le-rJDzz$mPg@99!pNb<>@#o849<*QBh^thPN29JNp8Fv%yl=h?z!xL9s6G|+>Ck8!>qf^)?n{r+%3oXE?<8E zoD8IoseVGH#qcRh{RGuB>rQ{nYA8sLB$qn+Fu6T>=px(srMONsyte@vskQ2N;yK@L zNX7n=@>*aeof93?<|A3zGdOcWB`xl&sB^G~He=1NmOOIVhZ^7HH{<8v{m|$cJ%evw z&_FJZeoy-ARYP0Ppm&o8NgXu{9R3x%Prh5#Vbgxbw0wa4olK#)K<#i0_bM zK6&1+b+9!(7g|liRymD2W7^5Q$A)34Hr!ag$4Y;rkB|N?&a-xdv%%|LTRhL&2t0DE z&*FIa2c&Dt7cRX+>z2&uz-yDQ?&M%T0x zUw(pid1&)k@#bUFHF|v>xGCQvSrhovpRI`Vr&DEh^q{NwPBq>)J=DQoa0pYtU$O!? zX+NY36zkCRm-GSIW_e(uxbw*tTmfHEWL@Zi5oY`7+$X)D0eb@R06U8(dQ*pj+j=UalsZX^{_1ttx z&NPP34krx3%prSJ&YGJ`TKhMFdS>L4(YMW=xmrFM@8y6gdGXlR z(?^oy#y+xiv;kd6_@R0%+Do0y)|o}wm)-2KopEKKq>uPBt_RsWtKT9%&03TPbO1ad zKbM+a>F6EMw(<|M2GeaT(Y`N;D){n zzX#YeVqlvt+nbJRn7e)#UsB(qg-RKAWZg3ATE%KH(?S zelm_^psknxYgtQ{KIEg3tP^}+hFq49p;Fe_d1uC0q4{9v8LVVexw{s`yETuZjHOWhnOZ_FSKL;GOev?nMPs;dmSz7WnYEDPkTglQI=vea9^b&B^+dh?cL0K+jTex4e znkB~cb>Txld3_55%jH$=hn`^MF<4M9*t*VDze)Jyah>nC>$~IkcCQM1mL>kIIzIeF)hOt>x5_?{Lqn_XVjBZ)-oN-@$*$3;P}X5Z=e| zoQ&^r@<>)=!CUnlzT1~`x|h|LQ`^CGw2Jvx&c)(M@LJtP^ktsweevwCJ&+%M8oXxxzp?(*cC>C?sZ;k<=+rN! zPiM4OY39cKYO7lcvoqiUyjH&ClbxSAsXHg_4aMw>weJCEeFRVWu0*q{fvx%%m3ZpcqCJ%?k-cZJ zG-_suXX#wN$s%AJ!mmzb-mg62d6({1D9)QR6Ulks@lxkaT=J#P`(+57%Yb+ATb%b* ze%=Y0WUvs<$bW-;q%MT+IhCHETuqJ9;C;;CtI;9) zeI}*x`0o1985ZSw<>xY2cy+#>zMJ@~ryc(J9CLK`93RUwaN9q$=oL8pPj?`*>gq$H>Yrt?7v0dQ(L3aS#gcC zavlCRk9z>m7s8LG%i7x(aMtcZK`3dfF$jdcWNagW)+ zXLfCM^skTp_lbKnvZhA%ydv&V9sn4?IBv+(6oJA}+ z54~qq$2H;SD~s>Lx8WN*C9@^-;cvZ-7tD$z?{$w|zrG>CC*my3m3~YIl1>uBt0dFKzl}%1 z-?FS*d`sWO_nu&1lXq3T`y=aVi zaHr6V&h?$-d6cauIoHJgO4k1a{e=$h-vGw4K_rVzAD}kA<|X)l+4iIqU%LXh%f5de zx=Z>ellY_lsi9r$|NY15e|<&&&949NssHrRF{d8MpvTK=Y;UNDIT_v4<^*F-W79pf zI#$f-o=;WyCi_c149@NBwAk;(6UhUrp)LhBuzaKD%R!gl9&iskaWM4lSg$zxY!2%i zdp@!W#6O^`q_6ay%qQuu6phJ#mA~9{qI%wG;%~0;u8&CnvEP5fo!9eeN5AHutZw;; z`Lj3^ZTO4M5`w>huiLBd@PoaH+_;dsy)EcpYTwPi1glSpLUeWIlbRlzt&T2de#>~C z180=K-gsp7eO6a4efI85b+BeQ~sRvBI($IXCw2KP1D_ZEqURw?@W9{XZA)}?<3fA!qeHjXJh_^dz{7v^Uvjb z@*ZBJNqk4tF=&?m&*Fbi!xMK<+Y!Bvu`TXl^Wyg&d@RhXS#Z_7bZ>DjGBVgu9qBGU z!KaouSyCUfccCw>XN`ML_u;CY?#(?5{Qni-|1k*h;)lE+~-FF71t&VPlz5#KS!bJ0CC)CWykJxSx;i&~oPj)MMb z%k>xj<#@;NSMOlY+dG-yrfp7+x4SlWr@Y#T{?+fKQXAo;`pJQRnwxAfYcsjjrpvYY ziEjhHu{LLx+Vr?K-}7ziT$^d7Hv3$gfA(#}uQZ<-r8YU&=AV2U@iVoVU1}4mjwW;d zf^P%Avo`0I+BCQ}I_Gcef)`qw#!{Ph*Jg)r1CO*euPC)yzD=8J(_Ct^&$W4ZuV`K zyEd;cwP|o|-sRg|<=QMMwP|;4uJdhHxHgxU+N^PH-s0P=bZxFEwdrzgmisoVT$^i3 zZF*drH~2OkuFYFZZT7h~m-;r>yEfOB+T>iDi+r24uFZ9&HX*(m!KcZ$xzV-x<5HUj z*XCuu&CRaO4W%~iuFW*x<`&oHPfKmqxHhNyHtSrQn@VlET$}N}&3f17FH3EDT$^9% z-gVJwr)zUtsm(su=KuIMA8>7MFSW_JHivwhjjqidr8c2z-vXaO-{xM|=6$6$4X(|P ze4G1Ro4ZSG+FhIdzRmrv&4)^D*0?tNe48z<&9+h-=^2{O9^YoGYxB`kn;zHC-}yG% zU7M)XrpvYYq;K=EYx6gyHv3$gkNY;eT${fwwaK|QTYQ^GU7LqWZ5rI1xl3nCyZd}E zpOZ2AqOYt?x^Kui%BtwVczi|ZHL4%gLtaogBd&klP#4DqC*6fkPQI{maylNt9@d$( zq)t2->_;blvXS=`OK#*n>BdDJv;*8Z#p#8o<8Q-%Py3_K<1gF8NQBI*aU8#Y}$P`1#Y<*m{l|Kfjvsr3d~;#?QY?Oh$U%nDO(39VYu!_M&Vg zquF#&I29X6w$?fHk+c&l?JL=5HU@3+qdYmg<)r9t^dA3P*}LgqF9u#mza8BtMfalH zg@&Nqn{{-h8>ZNfqxbq61iycgx@pDvuSh=s&a@(YPj%0iB%fb$cC?T9r{EL1=dH=-7fp}4 z9Q*?JygB*&E}px%z{p?3_;Eq<`PJt{UC0pi|AKqon0!8NMkLv$=Q;O$cJle=8AbSH z-ScV5=iiu7T>lT;^U2BQQ)fn94u76>&ojyAT{DaLyU#sO@tpc>%>n+?@|bGLr)Du# z_>O2zKGLEOF@t}?#TawYNT?VKXCV0hYP(m-ev8jn8z0y^K3cz%oCIi1G{w0xWK_wI zg?;{HK%2X1Gukg+c`|w8>B#a}pnvgNomUeNl%G3AJS!v~<>%nfN*z`{!q%XfT=6p` zf0kSqMD2GF$DPZ&^WY=%>1(ch6si3m7XO>aXXL@RLhRM~eC`bPe}m6jQ+N7bxJ!uq zk69b2r%aw|9sgBxub$kL?Zz((Ht*UnnpU7M#e`Vj9DQaE2H&k7zJI`7m~M%rnt6Uav#TfafC8Oz7^p~|`b7S0gay7r$} z8B2RI90>or$I@(#6|uCj_oIXTxE|bD3GS@IZwJlJuFXWV*$>GI>AU~c&kzwyqh3~6 z7E8MuU+O*lz1SHd;L$61{J7`WkELB9Uz|HbbTx35kD|xh4t23koolO#;03*n;OmLR z(z=+t)^Jp8w2l2-%ziFpKNpqkhtD(rW5&`Fd_j7d&YeIzz2P%+j3@LQ??S3Z+Q9K6XD6d zjkG%vo_x$bI0jE{sN93jL~aRq@|)P4ue}oZ5v!1HVe$~0r7~yxv4?5%A8d0rJOA^T zHs`Nz|Mhlk$V89)cJ063hq9UILg`8s_TN18uiv8m*Lzt@O?0J$OCB0HA^Wdi^jm}e zS0z8Z<#?+O=mUx=v;}?Csp`wMao2}*Y4pK;{{GO##E6Jd5r5h(-C1KbR(0zfbDzbd zTFK*vhsgfanGofjTm6eisFlxNw4aC;Cy9PZ9pMd3$nr>Iu(syLtQuMeu zpN`-9jOTaPM4w62!6bLE??lE_zAJilUr*bN-I|Zi5NJM@uc&;g6m!J>wcoIN^*^YV zUP`V4pHn=)6M2zG=aigiOa&WNU(nCnYASUKmKn}g154BH*U)FDRGgLbd_?o`T z9@Q{LzBA5!R34G;^0wSW#?!eWJ70sYuRF)=9c+j-F1I!f+(+57TEU;bGT7B|d~qgy zV7t?|*5vc&^}cjg?WMsom=K-Gnyg>e6%s@AXL;10&TVI^1AeC;c#*Z+u9zS&fk((r z(Qk^0AFH0DY#RCRY`?#JA$ez<+n<8(PV?g&S%w<5dDge%=2;6=ueRI8YLdC~2L$s$ z{0fr8^dX+Qgg)}XwWyQVp_7kyemv&Ybu2l244(SbClpIA+I5vw$Arvnb*%;Nnij z$s5ZX+~?$uep8O3#qpqnc%P@M=1a5joq6Xh2vb!zb+A62|10v8 z>(Wu<4;3qfFObUt|A~3Zbn@h9iKmp#e+X}pV}0S|;Ix=GoU{8+G$-*b;|XytBX;k6 z-IJ%b>d)*x`W{w%g5R)x8H=+^oFUjHAG2y9fj>K_Te0Mi&z_ejpFg}Kebb%f4=A4p zJGM92J0W_;)A&2k z6%XNqS$ihuusUb%ZcI(AX`D7;4mN$?j()yhP&3Eo^?3TG1JIoENNrxV6Qe!Mi?iBO z^rt!0YYz3yp*}GOw@*W9#%BF`7T5CE{g~thP+zw(6P(u0`okreIXPf(i2iJh4HKhJ zFoy1rEAy9QCmW97JBV>)+U_+*Cui^O(tmOz9+n)HOo1nRc;r0Jh@X?MYB5{(L;T6` z20SvnVefj#c{RKtcSAX>W8zH{c!L}h{r(YulG}G{+zJ>pkT-X(_F}i#bfu z9Kh{7GFf%w8i09@zH^e%iTPZhcDx(UM|tGXl4K%v95a*3=QEtdiIz#xYnacE*)KmA z)iLC33Ar_;xv(EGJitqvOIv9!Kj2--%{=YBj50jsES~U0u&{U1rFW@b zTFA$gF_KHd75GT9F3W2kLf;z?{0(1@Q46`hq>mXbAXn_HEdA-f&fvv*2|7forEo3# zpmx%k#4Dv+=)3ZQRM+!GbvR;Y=t8^8cxX~D6MyEOB9rs*Jxv-!`Jesi|5SPoyqV!`%}sRo9Wg0IoIat5WJ<@%kTJD>jboo5CsV8GlgH|2=UGqJC< zvH3DLzqc9b%dAVZp|h^4eVG_Cexgw$IYLz$@+ZWW~-{^8k9fbo8+Gb(W*m zo_1%`wH|mPJm1c~n0+_DT0e&mGQB(0qv7Z407D zBnzN(JIBQu+u_TCi{vA^IP>Ry;M<-GH_oYMKiDtriQ0&F2shM5d$5GMfi<+r(1u(% zgPUNN#IK}I#yafW^1INX%5psu`WP@W*e3Igr5AQ--i)glROn&{()r4Ajr&#GdouNr zPQxA=7b%xeI$bw4^tPp^jwtRcdL~cQa)*?cs@i?j$BYipKYn&{b>BMjPWtVp)}Ed1 z=$yt8V0hpBmI5${bLSSeuph{&MX|i{c9ML0Mn7rn2gZ2Z*>dcya)<5R+%n!lo}nug zitV+}f-8GB#9DQ4t?u?7V&8O^x8mO72kP5u$N_uB1&DWU>IhDbdZ9ZTmp+8==re^@ zXuzN6qF&}k>9a@HT}iE*E3nDN8D1A-_rP3ySGb%?r8XAh_{n`?E`q1mi;Z7FgRfzY zMmO3cY7E&tY>$G&s~rs*tuZInf7%I7#`fE+kxKj$P73#gOM(q|jZ7)q-xHY6%h<=? z-+W4P3)u9@Z`0j2V>hyDQ+VAa$f_A5`?yy@Hd7efA)9G;7;L!`J85Z8Kc7>zH)VpY zS7xfZ-wn@N`=EoDbW-r6kF&Sj|J;40J{w$b#&!dK@PxjQda{l1i}1{?5A=+2eobd^ z6t_VhZfayMl7~+)m*d?HfINKiZvKw)eZr5&l81k_z1-%4kN0)=&)OYsNaT%3@B8em z1=y)N*WK4Z+*E6Q!mpLz!Xv4hLi8uwe|;y-p>|YK|x#m#ACRe^gHMrlr#s{n|Or$Ak&I-sm2hNai$OAei)*VuD#@r@`bs;Z>>; z999Jv+1%S!?GDKWTkUd?t==6uOWn!uj|Vpe&i)e|8d)>A-KRU+!x_XBp}87fz5}i69ob!V?wxQ_%T2yL_N|@2--rDjK6g&UURiBe_UGNX z@o}Hxlj1*NZ82YZ(`Bnqj$X^!OM6m^SIXydbbqso{i$ZNczaoYdryw$RN$A%T;{(G z9ILi`lf=HKMT_G(#{q8_FM}2p1JygCKjlLFlk_R%fzIT|ynfZl@W;jL;YpklpJ zUOd#~95gfx&r%K7QE1>0@?#jjJqP~OP%VDQxtD5p_I|Qzmn8h^{lA&M@O{p&YwjkW zoW5|wZ&_dXN5*(DeStjD`?LSS`of*;TN#Zf{5*ou0o9g4c3J)lwN50evuMU8Hh zj*!3;-Zec0+>qXhPP3^o)im}QqXZp$+*99nzb*Ks8kbW|mnq@8WUA~l-(Gkne5@7D zNRI+wVGh9EsGpP!vO;PZCR zZ^32hpOP(-wfZc6WH|xCd!v_9jRm6%=qIL+nIi|#QQj?@ftU5PZQU(BMz*|{LFQLX z>NbP1zfpQDGV4lc=WzJlt!JZ`9uBr`eOsBX*p}L_b7i94d`A$8ey#509?m7OR@nri zOXg@gs^F@*=inWpS?T@#Zk?mNW9x(7t=^H|<8y8gz76Mpm0y7TsgjO=oV{yzd#8Qt z;!kIDm-aDcHFgiQ-*BcoGpu%#kO{2AYLQr64eZ$JUmBZV}1p?n@L|y7ePaL7{qUnE;?)ERkyJZF6Z|B>}>Yf22o?r-sx( zl1-MZ>(BsBNY^-4J&Gy^kwxZ#-L}j=kDMvtk2~`_f~DE zeyibJE)y%ymlof3d2*q9_kAzrT~~X@zx$n+@~+GO^Y7MCQ=*JF9B;iYH_^ZQ%u5;9 z`4;@U7rm5mse@!WlMYrGH05*lXQq(31JK5SI%xWOKCk8TjeNd^&+CFh_?|Cq$-Jj( zOCEfyK~4^#cMpxGn~5{3rm^L@NuK8F`CA<870?Wy`Tha^FM{3{>ObEl^EM)Phz-g= zmA{w%Q<+`4`NSl*j%)7X4593E+2qK!A{|S%HCBV4^sBrV+2rt?GxBw@9WNW(?xdLS z#-GPH^BM1c{-cYGqLawCoZF)D=GX^seeZ>jP$PO+xs@_Lo^_KyQMD>Z+jNHR zbo^Cka8{z){8jQ*?ZH=Nyrgp~pSdTBJ0DEX(OM^!YDLS2kv;~RrJB}_*2}t+0K2J z40K9A+i+>N^NCBc)FI2&v37K*(9zBF$P??A-^`c2uryztc~#q6YUtDJnLG~_w+3Hz zhl_AFbaApSr>ChuYWz;Y7mW^573JGlYiH~alW$FXYkU_Q&&Fnd!aH=wQhe8ge(UDG zhpX=hHvj0q2;ATP65+n-1mHet!tVj@+m43&y!ZasfqOUi$-NYOzwiX$-kSM6zG8?L^C?6+QXl&`XFfE_#7*e@8#yzUE}`MW!d*-E}V=dGv-C2=`O_ z0r!@ZLGO{C^60w(+@qnqqNszuCytDKGQW5XvS|W;-NbLOEiC3!M;m;n*v8?u=bJ;n z>%L6w56kw${%MS@74frNzUo3D!f{n*(kYy5pORx)#w6NI zNM`ALf$kjbXyW-w?jl_c?AAvcZ60fBb0fBq;)0(a>?$sqTgS8YUVdF{$6)W=^%R{) z9?n~>i+Rw4dEu!rd?SGG;HoofSo!+H+TdpQ|%t1RwLz>qP z?wK=MA}=j6*tyL1TX-k?HE*q2qvEC62W$=lXhu-Tec{&`DQ*hb7!3w6CS-wwK`O5a~r;d2iq}SIv4go+N&S#ub1EV!V8+? zPJEkv^mQNp%|6;a6&*^x+t{vm0M}uWQ*N(~4BJPZ+wDW)r`pO#@41NYY5ufXT z%RctB?&Ho_`ET-fuJh~rJWc;o37Z#w1P821RmJHVCu zcabBSOOClHXXAS4p*Ax7j&qPHxvdrMQ(G$Roq)qH;Y_2)EL&C;#&8*2|m$>m@_n7z4kfZHF0Bm686&eTw1Ov|H^ zJfZ&>{qLmzI{N2crQ0u}f97DgyyN!&v;S6KC;)yr-`K&wv)%0HfSud`u+Q@eHka41% zTWZ=f+%pXu@=+NU9(!xGVODLQ_)tI@8Uc2M_U~CE7}+1nS9ouCTnnyt7t69 zy)O8J%7Y;0sq>h(zthWaD%o>5&U63xVdlQ!80Nn8B+UJj?5Xwz_e+mq?&q9@xj%fE zxu1Cqb3gQs6HhZAVt#$W{Q&1~kCxtlbQ0$NrNhkqJI65httVmb&S7}<6UQ+3clS2; zq-3OjwglZ3mWz^sHBRjIz%MsDLAs)eSZUO;7^n0Kwh4M!xm@f)V7snZfu5Bd#J+8g zUVAWud}Lqe&@b5=QeLC>P84UhvogdYD=h}Bvl^2d^jZ38Td+TdOvPrZH#^rKTXi5R zk-f?tG@Dp98#aM-t@+>)?j%Ofd!H+9{?w(koO5#Zu?r@54FtyFvn8^1xzhw#_%%0~ zgYEf9PXHI~`F=w=WvnU3oRpg}opsN<{&hWjed)@og02kL<1DeKqug7d z@mP!H4P1;}iOtc0-Jm-ZI~K{#+;CTJF?Q_kipGwmJX_9R6VK6IwfG^H=ed&Kmq!~F zyYE=b-;LmE9k~bOW_7RUdy?O8;Z6YTxb4_*(qj>9klomE+hmV|qsz~aU@LJ>ehe|y zk^G;qVUFQH;oGV+rT?^tj@`NdSTEpv#Pdy3LEb#iy!h`(;=lZHC$h!ffu-)ASNmJB zt!4KK{)S8DqJAr}6LQl;6Qdfi$@fRGd#BM(ZDhBpW=O71G%&8K4trrAI-FR*eyb6X zAfAt{o14qJWs?ua&L#$3q*LP2?BPCe9sX5x=|OUz4>sd}LB9u6&_MIq&?)11c*Y;Y z7sC%bSa=_QY+IAsPd@lH`cQo|doq+a>xm9}|8y-wxjP{mP!c62B=g!{`Vavs#`d{0|?2!;QYb#XR~wr*?d20W@R4)f zY3^%3#a=Wxl}#J=f6MPWl`j(9Z<-bFEnuDHwEtsSZjBF^# z+nMlZebGf72eFt<|3+`))FYHK+-L)fe z<*vXVl8LhD9(N9W4oTB|1UzP9|xcEOP*r1&Iu-IXek42l%JP3ACvW~en>|$;!ao28Mw=UJA)3!W{r>VTi6$pOjh28Vq2nL z)Bcv_k|oz7 zFBQX@d~h6V-^<#SA8&jJ?3Jr6KXM;F;eF^)oy8amjn)k$S1CenRV8y7ro6@CIo*>r z|MSY`kGy$F&A*~-{;e;u`TuxXDgE5`5}W__viV>25}W^f%I05tyyjn4-$&_}gh2eL-Cu5_y6$=T(;j9TvB$$@UJl%3Q>U&zetO4=YRO>X-3%dNqg zCE6;sB|Taj=&`mlcI{}pY}XLxQCs=_dveH9(__f@y~x^~#2tsy?|sA;-+>NUtr#FO zYA^B-xscCIqfg}L4s@(^s&Y1Z)lc*%Ijwl?Dr$kBC3(+$Vv#sLigd;p-uH3>q?=?* z4k2%$_RtVwg5;3Q^7M^#PKNla&J1ls$94mUbf@F4nd@v;F6pcJ3!e{CE{Dl<+WE2I z!a02Mv&QIs={U{Bsg!;ik1Y0V<7ck*?i4N8iDoZXV>e_8tNYkH#1%rY61KUK;a+uH!oia*Pb%PybS zc&nd-p>t7H*lJ!~@6!8N{y}4X)0pf!e90eM&e6Cd)7Up&bNp#+Nk3?;^0?4g?3iin zmQRN3i;p5VYBXp0yxb_sG3|qO?}pFdRnc=cQM8wG3;6EKHQC!n`|!ERE@Yd@F3C6K zN*0+CE0hBJeT8HXE;mb#&N*&h17yli)*QTr*1KVx6NN3mh$ z<5WiCcj=CH<$00+QHaT3$Ufc>_CN8DZl8|tIh4MXA9elpXEx|wVCB51c6E$Bnb;}b zSh8;w!!Vl|8&@`e@VSSa#SSmnqq4=ZTNOvQct;B{jWP6vZxFP}%8uR0o(yt5cxHAi z@&797T05NasG_d5!?|z8?unOrxzo{5P9t{v;oz7e)-E_&3>{eMj;`(4+F>}Rs4*ot zDwpR(!m$t|0FJWL12~!-3)>~Vz)`Vt?GXnxc$DuLz|7!CPI?%QcE%Vu#{6?(O`p&? z#Ik)K_FK|NfM50{K!-j2v|iwML^@niN{1G+0)7s3SOp!%D&Y@eH6?VI;7BXexi69_yl~Y z7@FdcMvHvbK6Jqs8iDPETngwchYy~EUUJL%YoZV0bjtV4t}M36D*iPOSWXK)qXwDn zx#90mk06hxa;G4$?M?)B=l_4m&tZMl3q2l@pSP9LV}YOjvtMgA_rlK!)npn@9OpF7 zxKWeIr%j_ZXio7}lZE7U+JE8~?|+_sHE0)lZMD5Y@c+3r&gRz>Q&8;Gt3@#0@=vvQ zne%bElRbCsLwdQqUcQX>{tEF?=pd-0B$}}JZ`f9wRiqECO}a#V@0&#)MARWZJ*n$H zWTf&o_5r_Icwry0Mk1Y?^6AetGin7CJ!?ySOe#E)WG8R zG%}zAI11LXZF__DB*7XTn5p++jUM=QU$6!y#P(bFBC~siHMlLnnzP^<#}3;Fn#Y!0BdkofHg2t&6Oj(crO)caD+59Kmh{gL;*yX@>Q`?TRR*5&R0 za>EO2zXBW`?mTpP|37(GdHZ5Mnrs#0$7=m&9mq-9BgT)|A@*N(2=Sm~ax; zQGyTg-qGMgJk$$5KHFH1kHgdX+V`A1IyC$pPUrOpe`Pu^z~6^@tosDh;o)rY{;Z?S z77y03w8uIQkH5=bEIFc>+0oGL#!{OlSocLe)_sEU7q&BwW*xE@df~qxHk9+9?ABrE z|EO|-^V;{f`wOGdoqFGF0rsA^VJme~E2{b4S_0f{obhSw5vA^u) zG)}VmXc_Df%UeS?D__g@+|hfA2Xqd0o@sJMyQe*eU4gDp{Y}Ym%e#{OP{$l*@K=Y7 z4{{!7U@PD|O7CbcvhT;~9c%{dFSGB1a~;a5D)_0gF|f0&e?I5X8{z$Y)k6sMM+zHB zwQn>=&wR9SE#hN`-<_Om4dA!N z{UX56=r1IvUo7}t9)jQ3HXQ~3azA3#iv_>X-u8h{%txM_-z>LKK`c{nSZgn zj}CrCJ*Yh4z%Ni7IIJh+=LP&rkh4GJNUA28J!=`jJjQlqx9B(JRh`b?o3+1UXK-r#GZ{JNq36oGiUsFz z+Q#ilwOwU-U@>x7Q=H%O;vyrub|x?F+KH{XkACmNwhr>Z_A%~G&VA_||IXx_XhYq1 z))$m;1H8ms9H>8#%R>eIR7-t(?oy`334S9MV}7ogvd4 zg7Z{5|L`uYAKTg1N?cs=w@TLLozIxmz&gLKyGFjH+|Fsp={tNp-s0p{pIbEta!mtD z&qmzMeA$;%y@$Pg4(6W)Z{BClM)28HpUg|=-PEtm;TGDD(Rcpihv@uOdJXkI|CO4P z#F^wDV2`(2-S5zMy=!}J(}2Mm+H^Xt&#Hg?4&|+C-^9+-EV8pnUR{07eKCK7oqX;_ z2N*M^^w&B*)1T_Vso(U)%rRMdj!Q9Q)x@wl^SRs(Rz9?Tv)`A~Hc7wq_Xp-w!ZQhY zrk?RTImZH>o9=`sRG-8Ankn#06a2#bjb9df|9kMuLm_^d<&^RZ@a+!Z3ytT1ac}(c z%QyS{0$hw=X7uD2&QonxKBcW|CiD{U%bor+XukOsw3p9WfBsgN(LTd3;L!MmdFh;}`ZXM^r~OEM=RbU7{IW8h|Mtgyei_Pd z+yzkJ7yrB7mCl3zT5~ID^MubY;+2v7E#TS3{4EIai{^eYe^Dnd9y5L!Oq;bnza+dl zrHRLPC(8I`aNO$3*qr&iHyZLAWd|+s-nHMC&=ww4y#Ivp%Zl+u{c@E2a%w;LW%Ns& zUp^Z>Hh%fp=$8h+JomA`Z+^+aFGIrdz?01{kqO7lFY_-wvVOTp^zstcFUQI+LtY~M zGX15%FJT|(Wb@0mW9OImz3IsO@}W^LZGLH_e%!IxW3SQrUjp{nvOE61^-GC8rv2Xj z^2_n_`R|X-n^@E_bDdHj20On$KL3tINA~%@dImI8@7vGVv;`k#i~sEJ+~;o)&m4o# ze}mRv?XR`^2tLd-Y;pV&^I`BoPNvWA-v0OP^V{A{Xz$@9`~3IEG9i9BN+0HbFFCS~ z`Rz#P-;Jch{M%S|_$Ix5cX)NuA1ltNfagLx?-ueZ<4b2P6J0yE4jstbchI z=KzQ+tL~NR9fa@2)IB#w(v_kIG`^5m+Mx0Xjv@LGQKmXo)ZPn_VjY<}y>TgzKc@LJ+; zKY44Jae~)U{kD_0mV+NXvAqAp>XWyY?`bUw<({6xIjN{?d)MStSWVHSS9dpqY~(!C zrr5?w)Rvs&=j~G8usY)Y=WCtK+UHKKy|&?zm{Xr9le4PTpRFT4;OE?SN@mYsZ znCgR(_Zys_`RQlzk$m}m2L0GMFXj3AV=Ld+VhHt4ej)V`F66Jro#E{F*}mf}Vm-uv znr^PxJc+(EZWehj{~_JRSv_RE^o??Icdwn)ptC@#>+Z6*tM#bOGymjl{yy^$>kX|( zc8qH0>MUfI*oV$S9$NPX&PI7>_R?n(Qm`Cz-dG&+ge-R-+ES*7}S;8Ap5; z(9M(Z-_3g_xtW(yE0fa9z?i;dphsY2yI9!X3-eVm)z#FSe|~mUhbd zmkn!hrcZ+{uoErC@PSP3A^!wpgKyPjx7&w9#%~v{BVK;S(#F%;nR6QY3G8U)j_p1$ zPPJ!|*OQ5bb8fXDyT^O7`@q-+omYNM!k66#u54I{T$w9AP_7v8q&9$e&eif$CUyy@ z%H6WM1(KcMCFaYU4u8HnpRtYn+#K*T`K{+IA^DBmHu;Tg?o)m*fPOBfe`L4r@;UI~ zip^G=47d#$D4HX;Y@TY%R_@eUP0^Trh8A@uV>fHFvo@0F$b!A-j9eZ4&2aM1v0mY! z{CuU!>gdw0LGYbdXR!1<>+yi~Q}a&@tgktau>Rz6g!LDX0PFjZkshpz=f0%-16Yf9 zD&aMUSX5vi{F!&sQRq%`S9LiCh^IsM2?u;?{cxTX{s{Vse4y5o*ZzA&9uE1V@ULiJ zJS%-EKeB`KOC}$Ddm)2-)4er6aGcG?r|5}F=Bm2RlI6z#$V2hJU;)u zDxQRv?OYN(Bz;0Y<{dHrymkzkpu3W}8*tO-_e^%DU*XZLH36xS)ZgnjSezJ~4amiWVTp7bAcm|r!;eI096K9}hq z<`&3(tx@eYC(m#8;-6X*u-i0s!xZ;bL4FhcnZM0%`&f4!a(Vhd^yl3ZAK3WTH+-eLLhLgzDC$hF=D7I5yK@0q56#Pt%c1Y~ zidR{)?2iH+N;X7FX)z05n9TysSuIpzI>d7f7ZykI`O4&q&wCo@N$7mt2Jbw7$(`|u z(iYII@26u-=I*VBv_yN@8|kv zvcAczw|srNYvC07K83pmuj9@=YR6zBPw9$Nw<*n?=@(%e>;=|}U2s>>9MusXLQS;d zx{LQQG)I?p&DXk-x1K-Xt-F(T>u&kV$c7(R4)pvg-3NIr&$`h!hJWZJ@J)?2q}P)E z9nmkvHfFd2YCiLjGr9yUT7b@Fi`n-$j1^ zi|K0#G>g11)D1({D2FFFU#fVcZ1a8i#`2jJyQ#zGHQRR>^d0r$x}P(7M+Wr}W}fWwEK9(&ff~ z8XMRe4+?I+TVm3fxL zu}XPJK8dF*c*o$woHxda^)RMvh(>&XlGsf@-hX@OedOxl-Z!7p#w3E|)TO>mIo9p#YRz@304{BW4I!+L2e84{jDZ!$GlQ}MTAJqo=W z_>0nC+M|^%r87^dril%lBXZY4ql&4fnyzVBHQ1@I=W|$h*gim~zvJh zQv5fhW9)8U_*FjSZu;L%|J-LsOt%94Ii;(UKC<}aqVqe!mBv)PvrFh}UoU-$-qJ(S zf829t_5F|s>15mN=IA}Mv(rSQoPiwU$MRJSo0A(%C!JYQ-npwI5oPy(^ukx$AE?V{5MFugxG% zg})rohvH7Uqgebif_)ja&(cQx9>l8T6N%R)@4t=DG(SDXy(&0^!*8-3t*#F5<=~&N zO`c&s>NC7ICZ6f$dFqL)8*VM!@p5g~7uNgygRVccZs?9fZJhrGCIN0t-jw46{U>|2 z1Si!YoN&jPt*JZSJ5Jy|`3>4<5MCbOZX{w|CAfL&jo=1(9pGk_A162504H=~@m!?; zU1Lh_0Qc4s+#uJ&_1A6OFn*9nh993V0{pz4yIE`<{4P7FfE#2;Ic}bPQ&9&SZdjw_ zTtKUVza-pf{C{E1lA*#!jI-Xu_^i9$v_8?4GMBw()NasJ(59zf1dXJbK?h| z$5!BDM?6{B4PWU_EsHI;`FrxR(P<|eNi?s$obEQyUKPFZ`Q*%#+5^Wl&t!4mH?W-~ z&pLfuI-PLbAId%l=3dRNWnFIr7vX*Azp~yeazwH_g+I=Io7IazzgX>oZ9J3zC|l}N z)L_v4reWLp_kve6v`5KUnv3EdCQq=j&=Xmp~uPV9qW4hmLko z6C=1UG*=(e3tN3%y_I{$xMNo9%^)MSZ)*K@j%!FmbFo2Qyl@G4>0T^8@Y-oFTWy2i z7xDX*qE*J`-n6a^cEMb&6+SY((Ro!vYKT+6m^P+2paFc*qTcYo8{E;=S?ba1o;6}% zo=#^EHhh+9H}lh-rOi!V&GKL#^8e0ZE`h&ic{j*%?|#=wT|>fl5&R{(YEDqM8@im# zd&0H$ep1|bw>^3V^@c0l^t}=HUrzUQieQrASuoxRy|b9PnNITR;mL;!>7>W!$E(~o z2Ir~x&2$p5)O)!l9!>(Cq+A=pR%;8!=t52jE{E1Fr6%sx(A{9$&oaGDy9>FCL~@~P z9d`MFt3o!#HC=b{ZW_GCX`_74u)picx(IuG%pX{Yhr}B(V5AuFFZC|`p_t+o#5u!! z^+Ni2B-GD2J^JYxXZeJ+3B_U(CBDANXY=uqAF|VyoeiAlBj3qU9C73Z?jZ2|DZP{Bp7OMR ze|Q={Dm?{1D_Y^sr@r&qqIcfYzP<8mD=W2sKbDvPwQ!)Ly-OXdpI*3UOm-@DxP$s( zvRh+1AJJ5_XR}Q_BgRw6D}gr-t-Bi9xQe^R2HEfV?0 zb@n%h!5#C}-H`>{fh)=N0`9H6sF#q1Q%Z~%x15#TrZq$ zcnQUSkI+XRT~yx38RdPniLQpAM=DGo8Elbvp3I%hSq}D(nA2op!zFkP^a^lJ4@PgW zKV@+`^o#bV(!}e+dc`5vHa)?+|3>@v!kN|XN6zkqc0?0H21>`UCl&E<)aU);!ntU! zmwCSCMW1JJ&&cAy%+=z+(3AES-wj?%;@ioh4Pot}PvtHB(b|RVh_+(CTD$ni@*NLM zX?O}7qOU%!Eqz+tns@KxNPo3;(q zx0H{nIY_2<(}(UGm#>?BFy2^GhzS<+0ECnB`DJ&<8*l9%Y;g(08Q)W7dnB(2U>u?? z!M0rI2k{NfNwJAAFUUrE9p{NPeidi&x{tB1^NwR*Q;uU_ryR$=@>d-{c>V4;_VvVZ z>}$_)?CVR%v9EiMV_&x%$G-kK+*e44OFx9|;Xsy&@5GB=^5f|y$B{v16Z2pEXmSi0 zQ(_l~bOL(R_V2~-%9YkRkFZS`$STP~=_TbX=sd=&Pcc1V@>Ma;g^D{MZ*5FpriEp? zV#EdA$9iI?#am+oPrD(@SzhFsSGSch9K|!_GgTt{kfE|ao00$Wu@twEPqL|B>kZ54 zqMhaGz@jWJ$Tij`SpI;xn&92jj|tfp3E9_P-mLUKFeD#H`T{(mUo94t^mMGj1sxO{ z>cy~RFV=jZ)XzPO@1^;yo;_8_F+z9BelEAG{%(HSJ$nUO&tc&*!Xpe&b%HtqU1-b9#`29W2^_#uSReKBC zS9qcSWxGpud%6r5`?8=W@SmV(@qzL@#Tz;3EZ`5*b$k!o9R(hVmc}~QkLxqWx#?)* zt8a$44;Np3%Ly3sRYw@}bthoVu}2uQ<^+uS*Y}mdHpC0WY!0Q7MexKS_{be_6}%JW z8L|RBaY*v$idiNn4y8v@dmj0Qzk4v%#Fh0{*n$-!-`5Bt*A{4l>uR&gHvAoA$Yp>-i!HK}uo#_!}=q$F zt+2aigZP&6EmlEG(jBT7lwu5rn0^z_ET<1UpcWhALhRkxK=}jl{6plnDF@2-9(fj% zJ_Sdy%J^Li2RdJW1;1$yRj;)1ch+3*wEh`+UD6)CruNtfRyRm8EYJ`9zhJoLYtJ^k zS#~usEY4ZJ%&q!o&sN{YJY&>j(mr$pb2UBQ?KH|R*h5=~wl%cPb_Y7xf680Tuj;|K(`)FfTLonBSSd*Bzext1u~pf(yr5Szv$;hW%!&j;ue6^>V zYKj=np5vMFf`l*Sbp-etNc_t1#cz6M`10Xj1HN7^d@+92Fdtu2eSC%6qk~HDWprU!u6m)3PiZWk zhsXLVV})_mqA^&jGc2B8%x~mMl;qCrXo1JV_-gdczazu|6BMCKJJ%n92dgdxx!nSZ0wwKc-c5r zcq@~Q6NNYR1N_TnV}di%hBtmI@Y+d|jsM?!ddkNAz^XUdn4#^l%EtfJcxAHjr;HcI z+rt{imyI{R0K9z-Tm}4=WnZBjZ(ELlx34i)Io`g^v%c{5#TQYht?mVHN#@!c-qs!m zye;YlZz~xujJK;oc&p^M{^+zzLO9zdoJqFHPVsfxtN>@7z3a5sGfqK9`Z{eM&jR@v z)@k1l@D$;ZKh<1y&8CpA`?PCJM!4CCsLvOJSJe20eQO^-1)HzThR_~c51TLjGPlKtw`fUmj+X^=7N6!` z!=7b0mTtBm@9f|m`OZNNBy4r!3Qm4(Kjwdd=Fi;Z{~XTd(H&6O{iQY|W8dh{S1=PF zm>grSEjHI;+&Y-+hx(Z7_lOaNV7(~}>+L#c0<4D~kD$@kxa$A^`rSTa?SI^l_Tl+|w;%1p z^ZBp7+J}5a!A9}HZ%uVLILG zDkJoT{JnwS4y`NtQr$`AHIF5xKyGt_vjXyUh$&=dIjx3U_6)WqChzJXR-n3%#1E$H zT-#0blb-9eHn&aQ)$BO0QanMq8rfM+o|-~ur02$4M^J}}eE!x{Q$>3g8aFy$P1q_{Twhiso`?-BrXCuB4`+ms8E& zfX?6>jXUh87WN8wE*&U(sG;6Z&A?(m;Wqj`=6F0&cpfdrX*xvtV6bu;ws#QYH@J(fzZuS_+d>TDV*GqmC_qD?EE}U!lpXUlz zp&UARIviJ2%wDkVX6}{5hqR9roF!B31=SN1u7kX1%|rYyd13k`KHdEsaUSCl=F9$c zW661p0&YAyR2(k?ZUg*;&ua*GiV;g5y$zc^un~ip;Wg;clD?CrwE%-!RViXnyVs8A ze7b+em(MyQ{Bin}kDOY2b;GJtoO;f-l$`l77|VCCGd`Rprp}_p59@dq^>Pz(Gx(gG z*SI|zY1cWR82d<^1!^$7o;AWcKa2hJTavJM;e^p4&N~Xz`3O zFvsqmK;1}i+3Dkw&x$SoD8S``s~Z*$b?TP{xP+FoGrap>?d;-=;(0B@C33ca%V~U0 zfXg-qTvEf#a5<@WT*hk)xExi$WnrIiS2KMqXWu%&<%6tm2lZmnrMqm8j5!%D8PoR4 z7*}V!_eVTSNzri zof)3sxozNTJLh(K@Lm%%7vKxtdjxzf4e*8B(RsU4-a`i_CwjamehYYSCZAjSiLX6H zeC=Qj9`BXnt1*PHMPYpTb25A}rtu!*dc0SLFQ4zy)zo{L7Q$De$a7=M_-kR*sUOYy zRZlC7FZse5=u`Y4zWQ^8WFWc;TpR9qrg6UJ<4!)SWMsGohJL?~-~Zjm-#8zC{|U`{ zXNik@1R3bd!<}$80eLy0LG+g5v+9S>n;po@r40*_m&*g39f`-pR{_o>JDd88v!cA5 z-3QKAknX+mNul2mx>{j<5Tz%Ox4H3;L^h(-x>2q(4M}BZDc)vgy)|FcY5cdi#%DG zVx7&=Yr0;i@tON;d2X^YAzXU3;S0JC9@O3OJ!EDg;%*G^iOgID59(a3=+E}`4#$JS zVSvv-|CPzis)6|FrTXvjCQoKQF8%iiYv_#!Plr|me7-A;&oB@6(0_gKU=$uK*L%Lq zJXbgibR04>3(oA`kun~Xjx+x294{DI4!q%w@6u)O1@`*R9x!sVSXW-Zy^G&=_63I%I5U=uNQlziM6n=9M~(j!hb6ci?5Kq(wodIvsZe@ zm+_zB>zV?-O6`@mg=A)h;j0&WrH9Nc>Q=!{GOl2+RQvWyAF?xi*8A7MQabI&>?;>= ziM>+9WlwvB`_Da_r7hxqAQteTCrAIom!pT)y$OG65qq=)Y>zgOq1(lWy!Z5l9v^=9 z9iGk7q1US&ADrt3LqwTJ#I@Lw({^2{)v{5chD7U^i&EZCjpHf9#ur4~c0>|@b-4&Kv#;ziiMd(Cc{ z(69r&w8{_R`nqd-PR|UmBh3Jo1LY z?ndzRhw;wlJ${ZWc8mGTznL5T>GOhD*w=3poV@d&0SxbTVR=MBlj~3eL-_-8>21#-|tk7aPK&ednmAx6uXcglSv@o>zsUU z2JL4$`6}{KbAJX;@LmSG%R!4VVhOe67sf^rOY?JX|LbY*ynb*Wh}vbRQG+S!oxcw9 z4h=TYv1)aN&y+4TnpxJ>1&@SdBU&?m*v*`OLU|H zRwTeZxXdN_4?c|+>WTSER&YMpWCi&C9qSfu!o1duKfD=y$c7$+9UM*>_55`QkmVNfKQ;^;fv*biBtCx_xY!!F;+&y#vlIE=F8$$f$V!V&-xYbE%7XbwjJ>Ij-g_ zcx+2V$Zx!NwqTzaI(C;KpbrI@zO@MZmR_ z*g7$CgCC!#;ulQ)&gpI-e~F7d-gI4^`+e>nOfO(9TRc6Q-GV+^5a&)3YCGJ>Sm>f` zTeR^_iUTJ6vH9&Wbo+0BO^>k?@%&}LH9d(E{(Ov21Kh%=XAAc5TR`KhhCB6Jds)BeA;&yY z$%^(Y{I`QTK{@8KgW4szHvUJ)bU7;=cN=2`{%lP2HY1+T)W!2(VqeX0CK^ePbpL!V z<4*J7l}7HSsw3@_eY`gF*~9BdcOmuglnZHi-BaiOpNI; zC+i*T<5B&8l>XJ`U+a9jlrC)^>#=1*d?P-WJcPGh>6?RV zzMWFO{(Wi(uD-!piE7&5o0R&Gz(cZ9a?{= zC|L$x1a~_d16&k`3(v>S2ZJ-kvZQa}!~NksF8NcU2h~DzsCkk}urEeE2+2_CcVyiC z^Q+ErulRlU7RyJYePsk109Ubrv;ki+XhwT7F>+o7|GkaC9vrLQoZ%9@>l{!(r^|qQ zxsRsY9MM&k)m^z~b}i%YfVN}@B5&`?2Kl|Tvo)~a%l`|?b3JUn8_scSfVb#M^<~-H zU~h0KcSHft&Pcm-Plmmacf8uG0o~j&asA1~r|wMaOrko!SSw z^<^)2Hao;fM6>BTu&eoW=lm`7WqZDL zk^GsO&(rbN&Fm?pA7k%0af+L68Q|U{dGN66LGZneI#-FIF8TV}3r3!uH8ot5+Q*`*QMRJz(1{&0-_;MKHCR2KGw9|V8K-*xj! z_J9}qu+Pvoon#K^=$OBU{AJ!rOCD4Yb5(!*di+_#!Ez6Vg8BMNPj2)Iv&A8pEuxN` zV7ADI*_ivqLoKKX+YO2q)Puf$sy2xfNzv(D2DW^V*$f`hX{FyPtH zjdQYX!(I7$2Dj^8?(Y9N?v>Lom zvtO2(A(#v(z@#RC$-VGLD4*cgC%sw?8QLNzUFp*RCKCh`LN^@ z0{$bVSK3Z@_sb?~s$d@T3$pyyLH;>wp5>}XdbL4UdTp{p84DZM?!{O+KO{fc6Gv~| z@<;qP^sJ4EEZ3Py#l3a6Ty3Q{2a_e8BW?exDl+$H`{4he9+2`NqZr8hYw3PCdZcO&J6D~ zC!=Ps#CqAzn82oTU5Tt zr)Q_yBDQ{yzhB|zf5sYsL8`iYS%PZxVDic?@pQI{cr^H)109S0 zET-bixA!qWt?kgdH)FqC%K3=_{(0Ea{dvU~%Kf^)-jy$R9yXh76T7c=pi@u2Sr1=M zKG;y;JOtVi994HLQyp>lftzrgK|bG~d>b%r=W<54Z`JySdiJ9Fk2Bx{q!_& zMek(~2rjZW@f)Pyqa(51oSoZ~BfBzx#TL-#=owwv&cSZ>c_;stebC7B;5iv9$dXY$ z4)p9&eos$}VAms)r_C$yeW0WCEc<(C58G=;@{Fz=x-2&%0xizV*TVa`eexehb#>2- z_%!X?fYAClaMGQuDdD&5?}1Z2W7W?K$qUckSO*TW2bk+`;Ga%pMRFAO#%RyppnYI( z48b-}`|-r|zIgt>f7iXGp7?9FZLFJp!pXN`PiCK>pF1C#lWrU1rq^$rldDd!hG^c| z_|WY1E$FTp3C^@c^AGTD`sa-ID<{97`D*;4ty1ak*Zu8VBk-pwPYUl`SMZODwS?tecel6i7}xmvUOhT^~5^T>m%4I=(msb{>jibT|L@O-(7^;tF)%O8M|$?E8F`S zU}SLHQiR(eA8tmEz%hG=lTX6~*`_gW_D(0?IEwk-f!w(>o!`)bf$uqo@zLBw8 zuzS}z`TGKxtSiFgA>coNb*2+z-PbN~HeVT$KkEBE(Xkd+%KVDC{5+nYga4IY>&b8V z3LisPrq}r2*TfYExPR)Vaqb=1$LZDH^XzJL8#Qg!|5ty@^CslNO7FMKO8RZ$Or-i< z%Jb#^^W{8W8qaT)99hisCI0gzJYURulH=U-phttp4B!DSa~Esf{I*E4)9!relCPPj z?f<;q*{oWzlI`h)lLyzu`qKV)^_5HTKDPA^){n1e^`&L!r!U4{c#65qVlH$2biv5EUFIzNMSkH|6^ow{JeTaFzC)eX;_Pqo z>%+?jgvT~H=~uWPe49rHRphH_ZEx!2{b}C&`o2QntG(~PtLDtyGfQaozS?-vJ2}GIv&<$EB{twf;=QxZY!VBn?cMe+cL>L4_+~xbxtC-H_?4X@Lh7{Y{_GuKe2I+Q;UxIIkF-; z(Y1BQXSn-*;=kK60Xm=P{=$zXnM@i;`v+*h9{sa6YVj!imey>`csG-n;65|Jdp8~B zJI?|+Q^Wdo$9jh6>FUW=>pA#ss$eq#dT5*M{x3YDn5=9glO5nqayZRz`{7lsDKGj0 z@10Fk-0r76+zZEK!n{5-9Bxa{0AAGX`4hvD(oe53_YA#pof(OdYB45oatT` zriXu`{TSNMeJ%c|w|?wN=%EUFnCiYGpoh*W&_h*$9zF+e89nekU40h)I>6tHuYQK# z%zudvXunD_Q#ga)z6?If>7to7t9`oAo}gskAN+Q*o%Zrs^%%?LtbXg{w>5{l$K3H& z#VMC`)i5r(y6u{a8pWL2n6rGCXZ!r@@SB;tOXy*%R!@c_IhzkztIyVlgIqK%}uo~4gc4j>rSn~SH;JbtotprkZN1D zYn3lw^i26$*9|1*{`erb2479F<0i#7SZnz{fIWv+1!KkY1uLBacm+84Bk)rGdP#ke zWRb=Ue)jH$H=jPTl)g&#*#v6+)&c2z~3^A#(v zhlVxRG&VA|{`1ShLwXCb2W%*_8}IYa$kfkkU3ekCO~Gz#LO{erQrl-tnO|O+bNgQi6w&6a0`;~*AK^*ID{C{flX6^=_iGc;V^*-iia`e7Z8j2NS zq63j<#Icg_jxW!y*Lsrl-9{Yi7AJp}=6(zM3K-1}fN%c=V1o`vr@3<@R=0N3MVHQTgL_~) zqJwsd&JlW zy;d+<$~>EfyPphTw3~g~l|$Vsi+v7vo!0@U=YdlY4{mbu_X|! z!38+cHm#WS;v$@W%{yj)R}&9j;^emoPT1t4nXiQ4lzCuY{?%INR@s(i>zq~Lt#fO< zHK}!Ci)Y*L1rz={{a} zx7yikaZKj!?P1w{K%1sMbAfx4*11UhI1}ko&?&4@ZeHuJb)`)Xh2$fTI@VY_*dgCLC3Rqv+@Yj$R+&sI3ni z(bnS0Ba5`ysJWw4ES`*g@h9*iJuV!b;^U|rKOwxQd%;`KX^N);C*$clay`&<<@rg1 zv(?^7qy+yKBRnE3mo#nfowGZJy$pt~7I$PO$rf(5=#&r!%hR8U+`M!5ZJ8 z@8t&sxUT^B1>2^@asO?;vw4cqkdNc2Zx69X*?`#h#XVNj`@{(SJyz)^#ZE;xvO(Bm z-86sCE8WLdP-lVX*uwcsv43R4nrz{>U-6sHn7*HJ#FX-W`EduQ`&dKb2Xp$)~x!N;-ik45%*WnB7059%!#G^ z2)!yth_PBPAVx#Jf#-igzhNC$LydUZ(Ah-P-5{A#b)Ne;{A+XWWIuuDLs*->r<0G) zPS?SoE1@TJjQsmmtkvpCd_pv=IQS&H&zs%D$NmxH}y zYid5vy_Yq`^_~yAcc}m2l4FyK`!n!Kus>t48y;`{J#irB)rws7YM8;(Vffuj{Cg8) zr;^cj8|%9B+zmebQk~Iu$)Nw5=FL&_tJ?V8*;S)9%&D1!Yl^edIVCvg+51_e&T^OgQHl*H&m+@Djw-VM zJ&MPHSEDEJDS06oX7h;8bf1igopV%3S3?y$6}tBd3P>n(fcr zIvKN@F&%Ie85p-1%I_pcXLxu{N4$NwH1iW}%HMj}@XPl^#QoQop_9t_13y6LzR0<1 zeXH6IcA#t4fc&Y_x!^}8k*DS5n%t>aDeK3lYL(37?A2!Ozdj=s_3}~DiO-Bw-pUS*-cQSVDPI6nOSK`MHW4&c_O!@h@Y5IB| z-%_ybk$Y42PAuwtUHd}eyi3I$Y)&QPbEe(Kzqar34|?%rjsJ7T*IlUETN@C+TGtM)Ii0T-fa(e1L)kVt zS%OONG6lR`!uw%;H_fNp??~^p74y28i|jz`G^f37ia$Pm59Pem*}aUfyQsFc2|k~h zjf{02)zWcZ<8FP-!?o&J%HMB>er>MFZ`&PK%KOT~7kXyT86%6FsD-v{4crrCYk(hu zHSFcAN7(;=Zn?*MciS3z-gBq?wQb}pI;X_*-(mjg1)i*werl$#uiJVGIZ>WXy8!*> z>q3hW(-ytze)6@Rj8W|8b-X7%DPHJ+hPvxm$FO*Q7&ImSAdMc-nzYC5$=#Xmndmvg zrT?DVKg+zasdqKwe`~BX?M?PH&vegaZR(@SsrpH_y29(nIo~~veiUod_#NF9Zg+P@ zV+Q_tUVXKV-L-+fWbdSTC+KSueJMu4UFu#x@$=okAumjCb#YvKW{duBItBzVseG<;$DwGFy=xF!m6>tbG&c+1^n<(5Y&F{1V?HEO&Lj zUTtuXA)a4RoJ@?yZ0E60{iXyVx`7Fk%BXCj6e^yN%gW z(SD=hv24t@R=JOqjrn87{FugMuG^xQbGG^Nu9jp)`%=SQ+56W_E!1=f=KO8m|DZo- zt2F{n{>T}cPxEZ1@xqd>$KmS|J26_>o{C1><*TG=pMh4l0dJkDmi?dNzvwi;`?NDF zj80Y4AU58q{~<6W2h8`&yn7Xu$0?q80{LgTqVSo~HvODGD&og5OWz4>8|5@kJk_Hk z!_EEG?nCMaSs>oBerR(h{U~>)$JjniWT5Zd0w*s&S$UBwghOmi!xeVr!XQQl?Pk`% zCu=GiUkRMEYq1;g+(DO>3(@*P$ z0nYjIv#fnnlyy7#Uol4Z?x>soJ?*iVO4bcuk#A90cQbWGG~Vv7O>XFH;r$!^weo)X zT0hNNm1mt@VQ`e;t_JzCNJ=(qgu(ZyZLH+tCruG}PN80R9Q(RK%Y=ZrvS4CMbP z{#W7)M1323!};!yC;GCBX92DM{Mw4mpVFL}$48l?)-Ae!#Fziw(b631G&npRep61f z-uZz4j-H>wbKR4EDLkq=6n}&t^?VS|WnYBt0qOjVa0uQ5I%ot(B{o8$Sc4}jqQL4xfd-L=TdU#GKsMV+%`{$R_=v$%NwmgYt*)>#*e=_pQS`zxX+x zg>4GWl|5zm4B6DkM8U-94|!K&-y}2#ukMX}S=I6_%p>WzXNBf*UvKkZj129TlS3ta z)$2U?{aJhj@k?PIz^tTBO|)-VL_>49y|+13d$4%VligeToI}EK+ck&7!QwD;=-}M| zAJ-pd4(A*W4$R^HW0=E3p*bu(%p8z8edB^T+r`?PUqn=)tq7sYsdY`HcEiMmTRB@2+xRLww;>qBot2+;uB z;@umVhspme>y^J%OWp?h@b*V4oUiLVpLA`|mujZjTIU{StrxS_MXWW!S{Ima{rJ?X z3*7g!R{5VA=T^>E2nfwyzfZhYoMgN< z!>7XQJNt`QU?{v^4-AhdUVpGu@t>m3F6!#yIbX>w`S{R8zwg%}4uVYR{k*UAt@^Xr zzGMU6B_HfN`nWP89+CcMyr?fP(c@pl&MfIOm^W<#c`chm`}N`{o2OPUgK68M6cVyBqoIC{7gJGTX89W1pP8TRs-@xb;uOoRD$pPI4ZAPt32C zrn|7e3GM2vm*kG*N*bB|0M8WfQO<{+e~s^l)?H2>{AJW<9O&i22l!$RG3KZ`do>Z{ zOd_xkwKlB_*r8txKgcTi;yPdPi1bq(aiS!7&Qa`S$vx(lj_#RLMU63N#%A$-Eo-jloH^@DkL1}}p4B<{X@0GWCjb2TqqJ+DP;Y+q-qSyoKTCKf5o?#tV0>9EepAf|ozoaE8O{EbS8I!XZ95+} z#m-#_4iWe4Gl@0NETxfK1AIW+Y3@r;C*cS7z|vFrJY&NgBDN5qBMV%l|~X-p=oKH#uHjvf-P4phv~eCu)xHUJtdRw!c+YgLj?3FUTH?Uti11Yp#X|+M@24fWdhBQ~h0&@l_4( z59WDzPF1s?#q&W^z3 zr3ZMYgqDE$YGB?QEp-wz2x#eJz`HM6`Ut;?mUa@a6D=)}ts0VF$R6ACQJ$>&kWc0e zi`zcs^?n|Qo+~E4S8|!Z=@G9sagZMtw!^}EwkGSOqh(7HGq(I6vn%O`aixc3ztmQu zd(j87x8#cl^N^ge&y20T0Og%3J|%gpeB6=9`S5;hNt{jg4`T%OsbUZVV`a8xw6s=? z;=qdYYX9v4)fQq*Z(ftS_955o-WA5*$M}00U-Q?VYp&X{npZjGyT#&wQOcLkIpC?- zTnwG5wW+;*W-Zzik&S&FK5XCvcUBW~o~D=yF=pQDpgr;B`)}UU;2vB8eb8UH#%eeZ zR=NoLspw-cPvuK(_u=|>;(pq{3~WK!>gvzzP|0W3I|7@aG?%rQ4rJzNS4B)#etJ!%!+b;c!9|Nck@dwKWykW_aH{_90rs-71DfnqpU^1oWu&OCJf(roW*x+55t^YJ|%V z5N_YEd1!w0t@zI8sr#gnVfQ{%Gyjd=bcvW#4@#P)R$m)%a?sfPg>7g6uT!$}`9=>7D zkP)nH6zlc(h10{mIC)7tHyOe)FzR3oNBu;h6?_CoeGT3)M`wx^KNCHTd8m6!5ZgSj zm${YT{3!Z=+n!?oj>maEE3r-nWFFZ z4A|;iGWRT6&ik43L@O1R`|d>I`SX4wKf0I;5|(>MJ%?@Ny}f`r{OSM7=8!1HDTiymnU+~o^d-cuWJ6a1kYJq!HXaI8~4gJg?jXITEnGQb0w8sJ3d z)TINm_*<$g5RA1C9xTaYpf7Lz*w{(pvdl+5cQd}c!6G`b%h|wlTdUw=`C(ndunQ#{ zjIPm@A2gjA0Uepo&3ZzxD%?Y_7^QT`PH1Y#K=`tf z{0wxd^2XGs=2L;5wA@zKsktgfvHZtgeq%WQ!FZo{o?<@xkh8iY;Hf$%zYlor1?RbG zz#!Q++d&SeliwjWv0=_L#|ci`v3bI9I@!nBA_qn(Dh|^-;~)`J?xP1yNizD-S{!QyY?8~ed{s2`?%e+6N(L%+M88lQ zc>Z6woAq#WJKVdgd%P>X;?+o%Zj){`c*8pxcu8_9i@eb@?K#EBxgx$oID{As@>F?> zvXLaSlsjVW&_|oVr^N?&&+I33llPnct47u2t}7)skl(T;1DW~=u^W?_$i8q*NZISk zIn#X?(k0qA()gN()zU>q(m%S$tNR(q?3nZ}I!fQ|ne-9w**)#a&>2A19_UrO7lfFN z=@aydV#L9^?7#FZK8N~|KGj;RZ|1DI6+RO`w$H#vx^Y|dYWDOzn_BvmybI2gMBIx1 z<{X9E0K?cY{*TeQD(o|CJ&XTf>t(@3%#TZ)g{>K`<=_2xvW`73a2?O$^ZY%o<8R-5 zVfffnzK%rc-LKN8iqD&!!nwcgoZI(om9gIcrqKKlJDGc|e=;>ts`~ zcR)U1JpV{=9uHnIUkct5|LJVL{Da-hw@N-l^@cg--(RfQ`e^Q)*fXaSUo1U=nES=N zGmHM_a{m|p#5Crd<9+P|;dj)F8_NgczU6Ze#n8^5EiqFuF_%>M*e!i&yr zWUBE?folhF=8;;vq#@{a1GXSald8vovgpijxDex^oB=p@5jQ_zTL%8Sd9+tI>4 z1N0x%7FONu#f&eTMRGI3d~DAV{&=JR+~Q{ZrnxTUnS4XF6}{?Nf@j)yksPmyFs~3T zb+?tq{cWF+w(GRAwA;D6wS|Sse>csn5PaPABQ^imkHLascN8Ov{^I4Y1RBXe7tGT zRliRirr#@e@;m>F`lMzQ=U=|lhirjX0)3JS^a->O?BfRdLqudt_<`?q|x*T?>!RI zA6fc$2FCL%z^B@G(q4G})?(=pbL|A@-OTrT@TQtwecAxvOT1pN0feu4*zU;A0=^hyFMPBR{*$iQLG6r% zQ{#;nPIVd;3nbn?MEJ|z%Y7E$LGMwgpt1a1l5nul$F=0T+NVNzlivRmyy)!}pBVq> zc5rnh|LFbS1)uP{=qMHe7OLILJddk?Bp5?80gNqPha5EjC|cY@3G4^SPRRi2f(-H; zdNsNXY)#o8B|aiDn_QfbqQ?!Y3FExl)xNLd0XjGF@L08p zb1sv5t8L_hkOPv6QXim+^~;uoUo)KTw)l0jc-DD5jbl+?K18r2V zU-71xA7>b%`A4CDc$55xO;h8qcE?hCP%(7Vv+!8WK-!Z}RD+G7u@0^Kf9#!od|Xwv z_s?XScG6e+PH9UM12&;Tg$hk*OVdWAEntxsQBhi~N|Ed3BF}wLD^eVMEYuecR8hG~ zO68Ik(26e*k=sH*iulrs_y)?P!Rt(lR}qzr67zh2>zp%l&dE$V4OQWD{Ue{8vuB^Z z*Is+=wbovHt+n5XoIHozuD(xOr{r8HM=tAHwk(~Y32x;I&bBfpV&kQ+W9uRZjC6GN zGw5uYVfg$JcX-wP{0?u>Skl4=_HX5Gm5oGuR?%&Hx3lMjyByE2h~Gw}66RMDjyKhr zUR(Ofg_~QwJ2%>sReORy*3yT@AiQs;{0#1S&Jc@ZWwiI)2y#IY2NTa<`<*vP|3AO? zLX+!`dZ~gJ+!|t@X?0hrP>xmJYeWf7ZEAyZ=lMqDlRHXZXS=r{K5P8M3!C_a;A>Th8q} zzNWKy@LgM@7*3ClqCN-ZzrA&T-zJOa(a09-SN1`jSr|vlq(mqwR4qMzRNE^GAKVXS}wnQtN+<(xn#DL ze#Who$exM?p4c;q^}ChlfrDZ)&s`lOkQ{ z{xji`lm+|4`*;uAt@r1*^3yo1;k6A>f&UTu9Au-kaf$@v`eA_-@Ut zrdvYap2xS5Z{UIOs-n#wJx9JDd;6VcTch>hVYb#y)wa}3^0 z@Xp;_wc+#KI=D%yb2xh^QO{N|V#yGIt8>iM$w7xf1t)pnUH(ryuk@mgv z0b}kI9T)F%Gk=rk4sTjZ9BXRxiRPzd&s{u=zS(oy+(Pe#xn&HR+k!mNJ7eD&$Q!*g zx54{d%>}Tf5{w-h(Ys`G(2wT2L3{}M$_{h3<6E$OKmJ_W(f3nA3pqwGK3*2|C z^z1aL`*+||<>W0^iTMr2BO8OYJ$I0{7tr=%en;=>pYON5et~;>(01QbyVSPflD$ay zEI#aekn#CT5NmczfY+`CMZDH6^zrJinF{b4!E+3+Iq6@vW<0zW-urIeC0;K*%5Xuy z8iU~ie3!*DxXpoU&4qMS!!vgljRY5&dLOThNBcW#3;dRC3tZ;Ft$dC9!L7BmbD{hE z>#>ama093C8o_D0cAklzLkAs`4X7W8}9yp}xq!`=TPUrw;cRe2UV zFdI2zl-7~%>Ex{5A0%kcrx)W@{4P8NI91MP z&8zSf}CBueJ*>5q!&A&{FPg}XEc40m``uLFM{XH?6v0$c-B5Dne=!$ z^ikmD(1&<=ZNR(rE;$zLCG-x@3G~)x=qh(Ey0QGQB=g}B)c0gQ^loyX#+UheAMZT7 zex!5ufbZZh4Ws?9JovHsv)4P*W9kmdH>SpPe{>$`Ez*~&o~ zfpZ++9TL(_)ZZZ?-9+Dwch1(HCh{G0*)YNX&X;qU3I2C_*LM@W_waYZsWZhn`vKY$ zKhfEk&dU^^_3kUS?rKP+_9@Su@yG52Df>&+%MGY!x=y^kzJc|ON5OkUU$Qx5yO)>^ zv<4gKR^$@0VE+3{_1QNNHp?MXL7(RHUO~N|`#d&^lYwK{U12@tuo-~k5;*x*h4t>L zuwHwG^}bMHy-gL?ySBo5>s1dKA{)8M6=a3UK)*~f+2jPWrwhAz{0u|!E=68aJfAKn z@3(RO?8z!(BRQu=-tVq!Tjc)bG}(!&q=)QI!HabEYVe!_9~$OdhrTy3R{kt>hjaP; zRCI@xraSDe!_JeXEzVdX=Ux@^#NDa70#7tJ{>c;n$rE)h^iQ4`lTRgh=RbL(XRr2o zqR&erJh3DnE6EdApX7ey)Puwmcb?>Kp{-JOq%LIt4fv=>>{uZkwV<1(LopzHV>&GQ zS*bXml}ipR&PRJS#k*$f@y(=do#9E(m!0(&?AH{pA>ChlnZSN_Z#?%w7(UaR)h52L z=z>n7#e80axEaOCSWFWA9U)kg?BCKm7uL$JXI#s`n552Pj8I?$);?MOcmWSJB>8IRxYW^pAc&!npQrKwcakeF z5=)XU+BJi{gvrtMi``9zmtrikbVbY zw~b)(d}h5pbI@<*4?16AZUi^@%#B!bmtyF2?w#`Y-g`CQ{3bhW0L%3&WOEJ0p@rxv z4v)bAJcGf}ogB99z9GVL$pBbl^KJC)?F&;6$JTBM+Ste%imdNY9kY3ZFQcE#V)vM_ z!b6dM`pe)UPWPINBHerb%Y)JVZtiL`x@Rsl51Nl)E*|0Tu;^UG#t*!r3&p;OCbXtr z53Zwdm;NSLe@hRr{s|Sp&rnC}{tj#%x8nnH2lkEI;lm@50X5Z%tva0j2YcPL<37V0-Uly{oZ((Ww+G(s z#pohuuoEb6sr=aFf7JIU;oww!x5!Z-{@EGLDI1*KBg5d~*b}YY8NHHa=8Mt_KgBMi ze6Uj`(N4vz-sK@4)?KNi*KOTmvSRKr1NO=19*I4S8LK$z7zAYy^ML-;`sqQ#r@mn z2av2PLq-=l3c)=^Y=67|(*G(bbxZnpc zhqv5%{3)Bsf!sW+hQDg3`RV!*Zc@4Vf2~}b>wkEp;C@(lZcdEiz2A$= z>v{^he1ds;j=N*7tm=4;>ffllb0*%inmn^Qi^;Z*;LfiR?l9qOUleDlH#+n0t>S!> zapSLG=UJH>mvmgNxxA7)bjY!D~1E6Mr6StK5%|b~e3@F=Xd0CFkhGd$O~uIN32cQI zF6l^drri>pVe4If2P1rYS^Zq@x=gtz)Bx8Hn7`R`ygN2Jk8zzw-HUdNoBgtrhqiY+ zZ3*}3dl<`?g@>cvu^NlMVcvUgYjj(7Vg(0V)f54@UoH242OejQuS-4nSFvx<9$ zxc_4db4I!DVanD|aZgYi4@d9(Xds8~lVMoW+^>3*lmCx8zPpLy9OKOY5ce?I9dcW^%k9SEd>y6vx{+_a`69nwm0QPr-6^`%`T$=& zjIo8hK(+2wi+V13ZNBHL!}7K6{p747cj_BHyfFLK*TN$w<74v*yO-%v?w_;!N7Hrg zd-1u+&3kS6d_dGm;A@%bJksU~lObMZe*P{y02$l+dFMId+uN4JiGxl1|I z+2H=QJ-}0g`-8ud>l_>406eLUhq#xpwZV1nr=9d{uV2>Oh@jv4+04;w_tk#CYWrT= z3t-62uD5a68vHHy6S4;No7Sh~xYnZP>Q4>cH<^c*bTpH@M-Jr@9eWUJWy_mka z1N2{*v-lmS1-R{w&>3##!Xn&<3hoP|aE}A-n*yBjjp4M0yRuhuSN0a*t>JD;?u~RF z2bL|svLFf0YTfe#`s25`(96fm(cj6UzgpK>BKn)<(cgOVzuoQASUq=FQhwdoFwIT( zXs(fOxMMWi?cI@@O>^fdcbF~?`sVo%`hKch-$$zN!(FFNea|e={<1>f+}}$1rGDRP z`-Q$R9Mg)l|7GAPMf;5Zw|uh{7`ou?nQrdLUQ@*Rz01M*nkdfiB1YKO0eZ_qANM`t z<9u)UKOUOqzOEeibxUVhPCe&%(e`N9QQqsna9`S%MBY(8kyQ@7&yxeGL`!qS81B@G z;X|<;*;TC9gcq-yO;2?3XWQ+(=|s!j*Tos%mF(}W9nmkaELNX2#CH9==AHi5RaxG0 zk5}1 z!>_@M^ni2t{*K<{eqRI*@7_A&g(HB&rw7fY=BWn0ra4dyf#HdA>8AJUo;Bv~owTKx z13kZ;=be=Ik7#kxe_ic+U%2D}%VnwdQsY~ivzI&b@1{v2mcccJUtYuPM-Ez=v0GcdPFH$*g%bxetqUJ4SSS zUX*T!LAO5=E`JSt)y`LRr(Smc&4)Gkx%fpl*>m~(HF_#_Og^BOXOqXeiX}7qL|f8L zl7}R-vL!!L|Ic#r@+mcW*phU+Pw`+&jcIA#aEUYj@)G0UdlvgTc&zj`FD~HF-lx|b z%e{}o^WF5@DA;e*J}Y)l+$qB?4@dCr-g&Qa`^SyxXj1(fsGpvu`+#hSuj6eLS`!8Nw)hq=zE^~s85gCRj(_p=cr!y@xV}=zt2nVwt}BP zXIwwb0N%Sf!`Z+ zo4Q~c_g^39E*uX3XYUZ;mA$*0x@xSu4_(e;RG6m+r-QEr6qc~X! zP9E2MLA$0$^Zg3?YmqK}{MCzk?p~hn`Abc{_V@?6Cn$j~y!*X5`<~f3SUKHk60P}6aBlYWc z`u(->-E6)KbZu-DGx)Y^Ho7+Za)ZgC?p$&;sLi>wDR{4%>{bK6cz;kQ&0RjTpuafZ z@#YA9$i_F>eK4TQn^%;di;Q2t79Fp?IIcgk&Set`)8{iOXVbfZqi4&CeD1at*m-7P z=OG42aBiOD_S`r!pSx*fUNq;~0lZjY`~8Bc?&t93Ezrzn^hv?lHpy-HPrqEUjk3mf z`gcC^T(@(I>ukMxk(J--l{sD~D$4E~`Q7?7>1h2j`(LDrMinykozc`z2DTQmbchQ53Y>v5W(|M;4)BWQ; zn4DqmlrT&eAPWW4&hhRdA0~UJ&xh$FfYBSjFHdhpUd#v9Cx%CLdh96Px1(GKTF|%b zKRa6Iu!ix=>tMYGzxQ%4mbK0Lk^SZW@mr8@Rk=tr^_=BDJi?>3M8Z9X*b}QSyby2c zwUcmH1^VB(W#wsUwO^n8i+tJwe))FM()0o?ZLfpZy%}2iHgKDqq_0N$GTrKeH1}pL zV^5dLI~R14^9-CVN)sEt47r&?ZZ3A&E6_%9ukcawRvOHmCm|;nyFUr&{)tl}ux10B zi|VP}Hey(&7U66XoKvH4&IQh!0yu+x1K+RKzTwjJqJ6(UCBN@7V7(MrTTeolrR=h0 z`F)R#$oF)n^0|jo-oD|6@g2_RJAHbocAZZ#rbijmy^Kk??i%K9z`j(#{Sw_%BN`s& zz9pdHjf3F6#jn3QnyWaeF-?q)>CYp)+@J;A)0g4?PH=xCxZlTk?(}iLlYGYS2iASS zn)YG6f28}mTSM!FI#ZzGKclM*;7M91xnSHQUFTP{rSaZhqjj>~VXyIjz80oA?o-yd zchlm%l~rmC-e2UEAJ4o0BvbXv2SN-{_Ux*I3(ETa8v*u52uL;lP>?Us>^NjtIj6FTVqq+0~;8@^Z zU4UbOdk)`~-P`=}0pM7G&46~&Cl=`I;{`ZQ1dbEkZxrBI;4X>4aYB)vRsx4lPtuEQ zZv$^nF$a;o4S4*3_BPDjF|=iS8=mLzJdSy?JqC5&Z*`7!A0+pq9 zx5+vOTf_H!S-0fS!Z^+g#-X*{<;(V#L){Pi<0-WDD_@3dU5q%jFph$~igBC{Jc1)t z!yb$L2Tub_oUdf??Y1_lx0r97Tfr}V*R>qxI$gl`1ax!OD37;pp5~^z{k;}@2CLUL zt)S0;^JIU&MLRtj_q^!ZD##7(Ikf)U4OZfxUw$^%`=LHUghP{ z854F4-BsD#3JyL956YxZaNF|ErmJ~>pWfLgE`Ub<{AZpy1Ntn#Ji{%86xd*znZnzeRz}F`ws2l+gQG%Nq0e zpBEP=pEa%7x*DxpFVByiQBnWv)DPBVg5Up^>#@`8>!9(JFAouik`v;+#)rj!FZcLZ z7CzYzUKTS6mo#JcqR}b?r?IBk!uG+NP zzl&Zzb;UVBHZdo4%mwHBXWoXL=~?QB{hS0JvG@7@n}F$Nr#S)#vLldh3BG@%2#4jf z)EsA5InBx~c@i*SV{bOyO?0u!Q@ck47csgMvW|E6 zx8woeP2m3e=Kgb?WUPR_JuY^v55cGTvs^IH6zLt&q`m`@aQ5yTfct+L-LEk+rIa z%rD5=bSTb`Inlb4UCukxJq+_1cx6r+lp~&feIvO=&4yPy%%;o0Y>h`Cvh z9a1zS+y!xm`u!2$3ScYbtH$P~m`~GD@N1E8(vIh*lOyZACf5FZ*`L)%!(aPt{d^x`joj=N^nVSo&^QbqQw&(CSn|gk3p0&O`Tz8_#7gcRwuQEJAGkM+9 zqrFYax78ij3XMVE=#A(bE%`sDcqf06!NvDov@ILnX6#|NU=NcotZT7bD`W05gMtijVI!5q>J=z zFhHZ<;9My}+a>BeRH9BW2FZ>Z#VWbS+dU-OTR8Y2;d}4xDQGWTmvjH$HQwJ;QQe&C zb}g!??sA9t_(WYrbsGim8|o{nYcOm+siL}!ZFgfAxjNxj`@@65yJTwVy70Wt}VG7wVf6*{5?fw{KyJUPn-~EA{tMNU%@cXkCKi6QZ^rg|nZ{XWY+lBkuu-T~Z zcHLjpnsC+sLqG4oQGK^x_O_;7^sRg%@xFE65og2jV&pg91^G`Wf$t1(bph9}3oxBV z?px14S}=VrfT_e940K}7^{=aQhP!tL{u{IzS--7CTT4MF49b8h&|fS1EnUpdBix90 z$R}HOOo>M+z9m;r`E2g^L0?iX$y8sO7!?}JG3t@Z0B@TDd&Fa-XpWW!Kf8p#} z`9Z^XR)@!sk{-+$vcPX}qhkr~t-!4{8{Ap6KbRL(40G`=>H^I8d4^&BegS67XO@>Q zdSRZ@MZI2dx3BSX*FCvMYl5*LFNJT@85b^eFS0m~nr&;Kmo+E)wdifP+rntZ4EF;UDrO0cb*dgX> zzV2CDP5(~q2zSLv@$Q8?z(e1L)4z5-OsI@s|R28OPX&zwNr zxNGschs-XQ`?_=OVcv^v@@(5 z<-mya%N)pmMEfMohwyCt&7a37*}H4M7XD7_uT3zd?UGgZaTC;q*M?9mWTtvx{gWe)o49d&oEr-VP4Vlm97vL^_d%N;`D#T+qT8q?>A8<+7qu;`D=KO+|gEH+pjFqc3rIPnen!vlh7S` zCN~}SXr>o%@0+8X`Q*@S2Ud;ixhHiV1gyXtj4NerhVDbF5Z*tF&&F`^N6Nyuc$W8} z;G(-k+rNyp{qrHV{oN96KOAejbBJw!y+qsFV{P9NZ~Fk}@WUnM@J4I1%p5|uCFvR2 zcufE=v}^PXja;oV=tDLu$wUX;;Y!N1rh^!;q0H&O27QIr&P9}k>E)fgN7@eK2H2z* zmZfjj>H=^hnpB$seV-xNd0z&9ZrI~AN8+_Br1ixmU_UVi`w4N_D{Q-=MBDYTwr35o z?eQhr9vy2tHN>|644)ma{wWL3=d-*==d)-p&|Hcq*?(y9TfasJn}yys%W2j*0D4q& zD$#Pi?82FNKFrqQJ}&s)_8ZW8ki%TMzkZXw^Pwzl*V8urS=*uy->z%zqicrGDF)mF z2Yl?o%QX|lP+Vmedqr{*IMsiX5_@9cOM_aEqvflywsR>_+b zd%-I2428Xcbfs)R{uq)^v~M95GQY(M8&=u7n`>y!&jdfH4cZi;nkIiiAlyU;)S z(Je&p_y9L6CstN#nR4kPh8KR9J>24JWw%Gqk)5WW{&)NY8A0w`=_bTfv0jS%3HX8h zikl^m+t|Ong8B9CvMAaQz`30xX&jLlR3A^A@8uiTvlh9JLOPz_ZC{0NTn3qy8pFF_ z8nM&6&jr|&8$ohj^`MDJy8-@1cjL(&t$JY_M|fYReMW}12f!Bi_|X?Oa-)-gf5=y0 zvFIAU74XFcyz6XV`w*wPC7-B~UgPJk*y6bF%3uSdekT3E!dm<#>>Of@`VHsOF5)$u zhf;ek=~8836#wi*_W;iY7@_Maf{}T)H3=>z@NRj@sP723=mCaX=pe(LveJ7qQ;C^? zCUlM>-RfAuO&iL;ZE(*9_XFTwLH_S5Ki0aae*}7#@YN0c%0IG~J-o(|_3`^Out-nR zTxFT}8gM5&^4>Ii6y`coAeeimZ2ZZ#YbjYS5`uA;1BUz6&&xw)yG|vH@YP}}Vdu*Kn zXDZkOK%WcMKWj!dCE0+Qcvrs9F1{)7j=*0Z+Ils=2Yf@Z23p&5)COxoYp?rf0leY! zy`nuVz}H}SD#Z^bs1NWF@Po0u+kNzui8sk_Q#_$@4DSI?5bv8voEmWD=3tvDz_y(I zX|U!6o0o%Fbg%txm%q*<{665Rv}N!y?gAdcX%Fi}e$!e{xFx%(*89>jT zpTVqxpFz-;a&bmv5aY4E8Ty>`FXej%7X=@Jx`57^li`1n4@l8xq;K60^hd|th|g%> zy8A80Fl_e-=;x}c7>z+Aed`j)K;(~4W1py?&Ic-}^U(_G;M@be;XFnI;W;T@r!}N6 zGv}prxV1s77;`;9Zh+_SBae(1f=lB^;Q0~C!g}39yo(;pekGczX(6U=);;K-9Tn+r z(4p*lab8`34LUKsOLH8^6vlI#V1vHGcBNYc8!!ZRr7!R<{%tw0X~%Rr?v6E`uAY9! zp)X6& zSf3wRZTRCJH`eT2Cx2A6V|siTkE{jXpZCSj?;C|f;MQ6V=JyT4A?q=azo+vanO|TO zZ*2g!FkIn14|0$7;@zQa`;z_5Z>PV8L-RXVum$tm?b!{F5^SOQeI@UPKgz^U0{qFx zSTL5tU-*0~vWFNV9ae|o+~UFM#Nd3P`5@8ZL0Hp21D5!l|A?~iobTrSfam-mz;;&& z*zSzMwv~735n8>gNjG&;qwtBi|fzLp<9~Vs0x1cYx_dzEc zR0hqdOnR4t-kYXOI$*FSboO0NzkMHdO&=D0v-bWE@7@@SXU+$MHqRHF!5Z>u^Udlz zw1&>%{h;v3R%egKpufVoXlF?89pd^ry2Kb7Vq-YsV2$A*z?&!m?+Z;)ocHp6P;h<_ z;C&oe;xw{52Jd$d7QA5|fjn>TG=GRZiBUZpblP)&ao?ePlEI_S zB^aZf|2O#iOzr)}_oYX7aHbfxRhZ8Lcx*8rf|2eWGj$f-l4l>byRLSW`@r#08%v?j zmV}$Gu+RSe^!dqHpSKRu=f79j=V$iQ=X+y)ZWyG`6D#eL_=3T3drqv+i_7(yNw{Mx z>~rdV#@Q6>^Yvx>blknVN4EkR+Pa@Ur^os{R(&QVBVy;st?&W#?ciRqJ2)R;&ujL> zubaQ>DdesUY%8(-Ki}E82DEz)dm`=EqzgvQ65u-#n?dNCAG59w^qbpQw+H&o8AJGn zLyiOP=R4*4LDvc8M)qt3;3@EL*S##lxnx7253OJxqGvuuTXxv)!abaJ7v6FBv;@36 z@U?5UHbQpB*3dr2=E0k<0FIN?MgYec&o`>U+6d`;Vcy5Q2Xhg?@nem}mmvWhQwP8? zI6j)gaQOR_;RlG1Jtg4y;|aoP2p{R9JkvOY*O4WBwBz=OyDO;kp$h7(sG!c23hMk< zyiRDH%O<6q=r&LIUg^%$9o*y7!1-G{{~v^AJ$r@jW`AqbvJuXF-TNp%H#^5m&hpOj zwC_W|H`}K#+pu4G`DD<=weDqam0xdg2BCfy&Z^p^m?pKI;*3!DfS%Fk*+Zqj%5Ew6 z;d!1P7iW0Od_S(8im5+ik=p~9kLq3@KW+y=o z+L;)-$td;3dmw+x>8s(kCYNbz#t_;XTc)l5f!`k}TqDY~_4E+h8eOKXZw#TW>N0IT zJXl*Hy@fd>Z#B9t^ZD2NwT68C*uVey!_0kHwnzMELNpNQrMD5EAfE`+H(1{hyGFQQ zzLmXVM-sa>ei^c66|ku7z;2>5keY;(&+TYwuBjttXNlv! zF%IvG>~m=aeKsHDK98)R&l&MP)6%j0^9klOf?M>`0&Z^xw_E(P3E!7qzB(!O>Ce6F zx3WbWZf`E)wja3^Ics>4xZOv4`An(NuDRVKJ5pgTnNhT-vo%dzJf)f~xpBjw7qV@&j4L*#5 zPd55*a`%wtZLi)PXTs0P^&45QF9Dd}6@&TXF_?e(5`?)q2J`9|%qw4lFprMGJTC_G zv&5ym)M(?WWBfZXe0m-pgSq`Bi08XvF#ik~!)x$eaXg24md`iDlXk4}a*L!`$AO=+ zJ;MjY)4XzQ-nEXaoFj)!^Ky=C@N5*Khj<@8Oq?Ga-n)yKyVM_bm-6Ag%MMcv=B(cJNzYeEJXGg5k0{=r zI^x&MsiWA;z?VlcHDP|=j_m}$q3r|ix`*#8&x!OJ`C+Gg{{0o!Xk-nhH6NZYs^-J; z7y0r^J|yx*m5s>oj$D$>5Btn@OIt>}hv9#u@)UPXX&idipXN>eTe?@RhTl>(k9>ogJ*d6)hW*qIV7U>TRlH>wOLrxokcr&fp(x*`Fo2WDGqx8%{pV;QNE9+ z)|&MAz}H&e2!5@TBz()_Ec|ug`9%k<2NQg?w( z+1j^mDzSalZZ~<3v-JtLYc@EZ1K!WY@B4`=`90=$aE}gm(e!5L&|bZCMxKMa75JuI zcgM`;H^y3%aNMoj8I$6_Y_`TDh-qw3AKJSkIjvW`nHZ3cW!Pio8!Uewhq_C|Pgjp| zf6AWL)+=^h`9>A)a-Iur=EA@-ACvkoeBYUHd%$gO0pBmi7O(HYU-A3H_!3{-IOd`L@Bw34Wt?x3hR)JAcX>70=D|UF^a7UUOw*ZzKO>Fm}OAd5(5+XSG;d ze{LnuD|sfqKX)q6r}8_rCw?qm9QaXMo6taj&#S|2Ql0~+ifOn*{+6x9vkEHaJ zH}fZ4=pCHyWnT5H_>I_0_ybeCGl)pt!L7(7Qa#M0I>z|dF9Rr*x-@yZ~3Ve}5{IfN3MrUVI#y^qS zjkKEsKgu~F8mu7>OF0^LK!5seYxm5i9nZ~ds!8A*S%+@M-(20@L;rzoLVQ&AROuX& z--5ZGZ-?>sLBY)YidUM?Z~!y&(FHFM%(nuw;>Shf5gW=uhC6WpaKF9+xG(=Z!TlI{ zpDV?8Z3S>I|2x5b>jA+1`(N&#PNx5z;J*9-;C`?IxPLR}??d0q4*>2@R{-~pvT*x* zA9#`0enC${cQXD(x%|PkXL{NF7SlE5hbGyCoHRS5^uh$ZaM9f{y|KdY&`sDAg<=5$ zoY8)umx4QDX%!3XOlahfz0JiL;<S4D}`Xt?zU{kK~2Uv;|`=FxFMK445tK49W*|Qj*=HOg(yK-}|hB>&9IXI6wSOpFh>*b7fx57W(PkWN> zYFB@Su~h)i+8~eVl}nl%{yM_wNZ+S`eXXx&t`4_}PBHRy58vqg9Rbd_%{#m)g7aI! z`K=z#p_Q|xcSt{!Zr%t^SZ~*6!NoMc?Y4B)o<_#h$eeRGn(0h6lhK#J$2R^}K$CNz z$r@;K1+a~(W1n#(IG#ox<(Wl%yc2vJuQu^V*~PjLUUYt_zBe!)z3Xodf8E3c%0E5G z<8{clkdLFB4*mWl?SO}l_N2GA_SQ4Mz@BZb^41jes=dTT(5vk)pws2&K=)~4fam%1 zIe(D(Y-K)AVLlfypNorYY7zbRt$Q>3v^NnO>Fv|H(9vu0kr@z&m4p|K;Lr9GgYzBL z+ZV6b$v&L&V68R`<3Trv3P{-k-$SCXMTvswRO(AX9Rh=qP(}b zPYP^L%0{e13i>pVPquQPL`cy{jk>X?ELD!Axq?f4zsZHwqSP8v}3#&B`AvgDv6_WFE029qn7H z3}>~hVQ1%9_abt*YYy9!$vt0u7TnEh>1a#-Xkk~9JcaYf9h5w$S9g?V*MLjr?Z3!b zY3s1Fh|A}ZFNVu{a7p>Ah0EDxa7mpu=1ue*;PTgeFP&a{B9C8=b4O#po&@YU%6j10 znwu5ibQ}9J<j@9X$+wokarEBuaNbMs>f=^6c+oun zk-M{4CpqR>c~ULD4qhFeyNGSBwW#wI^pg^0k^9nHNA6LsL(OY;4s%T3dzshn+3+N6 zNjlHnj9upz>^fV>JJxqs|BcEUUHdiBD|0f}$v^ZJ=4~!GN+;Z-e^2?mGPvjWOlMW( zo~;eSz3>aI8t!+FbI;-&;|6dqJCbZe+4=lIf3p9agRXqm?ewuG>HfUO%Tt-*`Tvra z?_+$^#vFL~o8kF20xPk{$OFj~%{gtcCiTtR+JPII674+A`sk)V#Xxp3=c379btc{S z2|m_(TkFhwTDZ5}=#2H9YUJKRUtVdwj~9I-6PjsD>neML=R0LGr5jo%zuL9nW+}gC zfvKy$+U>uM^#fe%=_7kqwVOFN(INXKvTQfgZQ)*;=q&F^yu z`Qf|Q(8iaw2JxTd`&;;C>AxN49>LnWdi6MW$&p^(HT7rw;5OD-XL8Ty$f+sWRV#nW z`r+gmNbWiR5zi*b`UAG%1wK^^Y#OuhH4_{k&2xJvGC0KZs`vQcN)E0`?%B-mKMLAo z+?y#2*4k*=!>4m9cbt^wb>OOPO;senNI$YJBVHHK2xI#W_5qaq%0iZOQvHWBo??!?-=U^awYH{F4r1 zd;&eh%dI@Mx4XW|C7$2tD^blJrr8i5fp#yYFUf3M zkE@)#-J!Y|Jf)K^c?k^W&D6<$mG${sWE1vt)}CiiwzW5{H~ZU`;@V?RpgjGO>+0vz z$gAwBQ94dxbC=GzJ=RWkwv)en7vo(3-0-Jh9i0f=T9^H;<07=(CD_mh6{i{0ohzKPPO`KCk0s{l z5cid=AKhHV;Kf=A1ypQ6yx!K-baCSfGMc8J4D;Z6$%q=7B%wadY!1#7|je9wG z$Sqzn;6Bo95F16E1h&e|8tw{(j|Xto2rhVS0j~9jxDIeV3S3=$)0R#~?lh3S{Dq^4 zu^Z*_=}bNM?!mvGrz`{i=B_{Y*R1&zc+qd~;!e|Jd2T~)m`q6eGNGG0@u<7?cm6p@ ze{>Fa1-V10*9I>O>k3b*FVpW8E=&&xUq8Kv z@rq756YekN@4=je_3ie6UKy+QpP5-Zxu3Uc4?J~IrrtZFu=SHU#LYA3_$5rrv3|3y zoEb6S;%~Wm^bN1n-e425!Dum^aF1m^j-G+-`3)uQnERN%+t7}Td#u_)^Tnn87Mg|IhZdb{*VVtYCqCvjAvJYCcmG?pxIb2b;%RYG;t#%9*e zfIE3g)cJk9ozBp`J>WrfAU$0C(9XSBx6o>b?M+xC?8hy~MyWkNc((6^>jlV;h;9{v zMPstM(69QDY)n+c@A+;=JTFUy-(4QJH88FqW>NF5Gt_`Cq|-@{(z)jh`z}P=754Yp zA@=tj8{dKVciIr~@?G;YEpHDhHxGY}_g5i4kFowj}@F)AJQx2C8$LF9zocuHncZJ{mBt8e}fn#}V9Hv;Ea$|Yd5Wnk=(^ITJ&F``y z;`h8Ee)rA&jKyHuPn~jjx-UN7(A*hsh5w+xySHxYzfpN5WsCUrZ1hfF7K`WVUfhVR z3$^hhPp|6;zWr&5Z)vlm-1lJ^{v`y%w@b7WX(JiguWrlKdwbs?$ED&%ulWKx7# z(bvJ1bc|qZA1F08M!8sxRm$(pP94 z;kM2ue_*(+(<*6ee{hH9Ol_Or5j6eu4Mmzht_1EQ^fbgiJ`$qYY5VIV4kv3&dav}| zmEVZk)GP(C$ZLIDYNHXxGQZF!({3XC7Uu?E}Sy-}bRFJWr{l?fud8{_y4Z?Zn3Q zJhu1&a&Cy@dL-6%rjoWpYcb4^w}tre_h~22YYvqEJ@TpOy6GqdTbu^Nuq|-h&-|(= zBeos{n0^>r%bO~Jsls{w)AdpMy`ogx@p%r9HzXU6r>#J*x%eQA`@6BZcw;Hp;&ZXT zaiiy)8pCJHL2CD=Si476(ryTs7krty2FTV=twySRUsZRJ<&Eu=%2I@6j_0X*vlsbgbbO`iTi zU=GIKxxaS6;ZNfBru4wQ=r;#xC+KU^hs)1bPzQgfvUm>aG*nRMF!FbmoA-b=zF)z( zxO=Q@I{`df{;>Z#%8#=@c+MECj&BnUXso>iU1gywhx40H(vI#m2-8-aMhE-;b0xkH z#`+(Fz!=mauBGf82Dl#(LsWvMuC1W27b?L0@~g_@K7hHBzUB@#t^=5#ua>~C;icAb ziA5<(CjqQCbI!OwTH0MfJ72G$&X#zc5FZcI)D4^cGZbiWDtOYJZ($up^eDc^eJIdI zaj*75ophO&{AcS+pYQjsFF)1*AAkIDtWFsiFB>9^kNGhETNxN<4MGQ?NdWIl$(tIM|WI?>AmC@+K!yv(f5Q zbB4ipqkx6;@u}gn`*1ssf4586#>0hnQ_U}akaqw0xU=bhs3-rJe+3WUCr@N4Ts;4y z{jZ}-;<$*>g76_f5a~;u8MRlO=Td!sAK3P{c6;KvNlVP*d*g7GsFREL6@xuk6H7vL zJZ%tK!`CmO555n)N*|nDf{w#@`BDXV`F4CRUM$VwOH+#GxKH@T7e#Yr+SQr~Xl@T{ z@YpgmcSL;NTD>@sl;b=biGeFu9-8MczmV>is`2utmy*|_&pTLeruUVhgLcjlfx~F| zb zZHMq0tc#1t)wsX4`rKdlUuX9fu{ve&e?({;Zy979XT`?xX@4B^V&jO@N7&Z2%eP4c zc8K;LIhJ!+bkfL}Jm0pW9jp|7ZsME$;pZcd#l~I&&l@VJ^O*|jERW;4L|=ziFsIj+ z=*wt=b4=xh1{VYF$|PQN8?kcRS1iGoH#ri2ID8ThB|Z(GE9IZiecjveWmLY4z~?VG z>uiwE7j0wz+Fbq#gcEc`7_8LZ)WcwIJ;8LyxaCA6xWqs zHq6gGO_|*_)v0`X!~=CZdw%?j{u>o{vl+f?u{X&o#X9F}$#3Gcjdwr(an8n}4aKK? z@@Z_s>0JxE@cGQl!bfc-<*oR8)q8iX*D{{1_sMR|nJ+p(%8$?53s01eBpEN?KE(!Y z10TXw>AR55#JBPd+)X;n!*@D#=cCS)nG40o?0}~l-(rj>LA&$lBkmKbcwEH?Ud1_x zXj^d)k@ZmUPv(xu`e-cE*Cgi{hhj7FRV07bc=vW>n#HcwGsfA(ficI*^`M*|pMKEm z!~9VBCd1vV=AS%0>YqHZu+ED2MCzL{l#5TdF~7Q>Zjs3n&GRA5GjrYQ#oLJ{EoJ~5 zKFwSv$!*b(pR1Gb{QJn8_`}_TeQt?3GJI+c{<$q3^YQbNpK^P8VC>x*#sW-U>>YD2 zz3n*W)_f+wXE3)H(~sfR$MYA#^L}v*eFfj2Cpbl4&_zP=wJj6eBgj9hvm3?PDTjJP zHT5I$b#H(^&cOH9qmM|8MQ4r0V6n#XJGu{R>YoFxB!Pc6ceX$;8RjgzCgGkXIOmq3 z1%A(LiN;n*t_QJI24~v@_p{JKS~N8epH}?&iFN*pd^!~$p7ryx%O_tx^IGfez>{rF z8ZNC3XQJEiS;KRFss5u|Yn?cX=cD};{Jp*VElnH7I`ffl&~`K)tDClU-=^Z}a>Rtl zZ#tlXr&|;5O2Ir;G(c>fpKs3MH#YkDeW$i`e1kI%`n5Zvvs;MWfX3#58;kG2w>cau z@=Nqn(|IRycXanKw~OEJ{FYzFcU_bP^ZF}b$4_=@_VO|~R$tx3Y3V%1=Mz7bJkxq( zjT(MdPjp+r@iB~DbD5cs4|#Ia!t7SXM z&wK{FLT`Fk+_BLb>&IeM=g`iUsy#oYj*W|ch0D#YReK!87OcN&VX8*{%G1dI@;H6n zNNnO8>Fdzmlz%Vm?Z7P9^nHJ4O(cFK!(3nTLjR5WJ#YJxxArd67%9Jq@)sz(b%v)q zNj8|QDd8KuVSm2C$*MiJH^aBR3%YMetiN9LZa&81mG~MrBvSJQqwT{^cAiy#IdHEy z#T0pP@C$JYF&S(7(ns z2g(LwtCUMh@q(#pXjEh48{I)Bo~oGbMqstL^!fZYpRxt~U(8>uZT99wuZx78@V13yOuV_d@e)!Mx|(Y&_G zi$TxAn^NTaP~3oxmGS4GlN|6Wzmsw^*kuO^7-Q)#8 zuIoN}-SeK|zG}sli=Rjjy=$ExYsPQsJT8oUGdOUGBPO zY#mlcdza82as@xws9yOx=1zK6w|H>cj~!ub?5R6A!|hl*%8#k!TlmSPZKM308hWpB zF6Hd(Z1n+u>`09vhIw@8eY|t&>hSwyz7M}o`2A$Q55F(x`|$hee1Ayj`#11?`28Hd z55K>e@BR0U;Lz6c*C$vG`(=_T&`_cLKEIrJT$O($sQ)Xke3xI2eZgyI=0s~JD1U!Y zevVhZ!LNTAevMx)*$14hLHYN*^0j{XqM&@*Wb3!xFV|f~hUfQq z+ z&%*Iq|C#gNNgMoU!?FMT8p?P1&%$xmf0jO*^2SOoAKLey8+cykKMTie{pVNkyup7q z9Q)5RDBtBj3&&ainRD7nJ}q|$#|_^6bCn|P`_IC$KR+Fc!`|SR8;<>F>4dxdXTvdZ z4#n8HXuPQOHE_I5{G8mJUOp3KO9I)_2cIpM)5P|oeqD+=T89K*p5Z?^7q9yW}3hi42iUPG~Q>bE5lh z>?1+$675AtGA8X2wz1cetwre7U?}Ki}W6rMS2g*BE5%Yk>10y zNbg};r1!8a(tB7I={+oq^d6Q)dJoGYy@zFy-ovs;?_pV__n0i&FpNBCl11>SWm>b) zqhyhlOBShIYmIWrA}g0HqTJRa<&s5KE?E>Rmn^b!$s)=(`0Yp*tyRC0MU<&s6Ab|j0|s$a<>%7cC-i>$t65#Hf8E?E?6UwqQaC5tG}`uG>0v~tNJ z%6Iwjh)-I%WD(^X{BrS0E0-*yJb*`h(#j=^C^uOIp2a7vT(U^zemmlmRxVjYxyd5x zi%(j)WD(_AA5PeQjlGeVbFG@3X3W96`5TaPZ9Mq^ zCOi3>8sebY7Zmt!XEE;9_^!`4BroksEM)v)OUazeVtrKdZF-xdvN{ z?b%b~@mEa5;;7J-pjC> zKPNc_9eKH9X7y?x*8n|5=2l~3KW+Q#1ue~`+6?;JeA%ofJ!{_&Z`qWZ;k-99;^xC5 z?RCaz4tO@NnbmYl=-WGDXH?<);sw9u*dX6hirguwx=G%B6?(paXZqHDIW@E8dRtQo z=W8aZ7-m0 z>_oDE_0RX)b|$+MgSPvg+NHLQm%u~%)~(?OEbh?>mqthynPMN1{ zK&Q-AgiiGiofha;?{PXsuhm*+?HXRuJ-zq7n^Tf`1-hl*A=7PUvim`BQJ_=CGh{k# znc`j~yoTTuUL!cg1{TV-WqCEs4kkItIfZC@3w+~?A-=K9_(n)hmf|&``aNc^3dyn7 zgz<07r2!w)+E#A%z5U$drFYhIM(dgWjlYzbH~AeIzwzg-HvG=KwfOJavxw)|yj|5` z_ufUz)>@hNTg7_qRyHb9mbEgou@~C&%8XXArx)-1iRRr@PO2`(w3jhGO>VC?&Yw1O z&U8z1M(>vH8BJg0zB1!Uo2Nj#Gkfj1i)Y#Gl1UE-b3?Q{*Q4FqfT!vm+Feb)UBijq z!9iQK_ukAna_3?vBWIf76I@f@!8s_<=LUIr{PF186Cyz^$o0NbGM*{dljVXV)O4_(yYC8O`>MxR`?rhalZB>17PbL z2%G$z2G$$c=hGF|`+2E)R}5^AIEjJ%mZ zp*!tHciJ7-owkwRbSwEymGg85XQ${++v{qlx=&0(1|`WUsJ7rm@*OdFPBC4nWLrIF zp(~xs+Bg+mX{G5(+w0a(b$8KL_&$?Nk~-q^(7CnAdKI1Lko%IGxqF@F<$B-YG}q97 zW7kwy`PJ>Y?ojqeGka^;FZ96AuLwZ=d76p{C_}ooBL#`dW>R?KJM9 zOWL`Vd>?F&1rOu@36goT)1}xyYkXDowRGp9?t{#i)vbmO#f!jqp>5_WXxpK_L;XjC z`r5zwZBJL*;%c949OVr&n7n!-PO)Yb3aJRW8+q?jN0 z@U`sAqj1pn))H{YCeX#4i_QvT%uI7P`gL_b#4Gr133qvZ6_~`Ut$*r#iaKp;5_{%S zuAHj_{FSsOnG4k!K^@VOp6hx3EbI8Q`W_qO3Fu(#=S=5u{_vse3wyh?_6Ob_vZ5oM z`R;`VjGoW~lqYa&AM>j{4Q(rz<|doFel3b-|_F*43*|0_PCbOuc z{h(mwpxMh=srAD*ujQNWIkHnrmyi5LzmZY~rD_;I%Y=zLN(Ua;uOg-WgrY-{ptwZ^49c%Nms`vY> ztzdpq%#VX_Kw*wL#RtZ+hf9)|8<^qCZOje%(DPRXbAdjfIk@ZpCN}+9 zZ*A$f_R1d822Y+R*q~pPw*#Bjpy=r#VAR~2Ka==UHM|UcNbw3?KL5_bQzRQTrt_#z zoZM7BzlmqT(YNmP?7w|}2MuiSY2c9eCCqnLbx)`6pZHGq25g7^+sI*ldWZ%hYm{{> z{PKr?g=2QQS|_!wKp+0PF1&YYKQ$bB;ar3IIwR1y<&*kHY)IloYR}FteEbFXmo+%( zm#lx?U!^mJl#|*h-sjyT8M+&+H5%(-@X=O3@>6_H`)BkuXMiW3&)k zCpz!fcP*+9tiqvS6nrZGuGzhcc^li{$5r6!ui!3x$D77l3XXdY5RPw1rj~$XTM0Na ztY3p8UG$#`a4lHGk2Zr#-5n;pwDaH2uE3S>(*E4rn&6-FMQ?Hb(FHAMph10mrN+Sc z3^(9KI8(V|*24FD3ODx#FjX)|mmVN)-lX~^aO1_bMdwJe$HUD)`|l|O{Ev!q2zuRS zxboL=b~gGL>mR=2$_}pQ9CW2MoAUkY9;dHV%9F2>_mVH4Xl9KAtIjLKxP|Y#%l^yD z!@g&N$E*vi-xq4E@Z9KmFq7;WGM+&%>(*nTLiSdgHjnAIE4P=izxc-!I$$ zI70J~)Hp_P=Saev2kA7L1J<47xy^(AGY={BkucsJAMdiWgy}|f@-)0!c+QO2$x#(P zNw&rZqmA%A_6LtPe$rH)HeT)1_F+Cw@AhF1(?-n@Xd~=5olB3|v$*U1*9U7gdqX0h zy^+69Ci1yYE`bkMxw@-V&$>g$NwAM=@#wM#cv5HhI6uT&t6Mal#U8C^Zl@h|g6w(8 z4(v!7Sk*Y6iTE`;>vn zd!Omssr>)wFb#-_`~1iteSRp`XSLtw zE$TD0zc`q4@QnwGuzh8aIoKAPgI|vGc*wdT;Nw-_DfW2>a-bYd-pX%bn!Mld^ZX(7 zdE%~OpA+GO<@y}WZ{a?_==Zr~2z`D(MBl#y&*l2e@msjhkNSNcF@!#k`*v}h$J|@K z&)NJI?(^M#pCg9Q=l4TACOgP{cE#rNbidCY==?y}@x0J{HVrbL$HwOK)qbDf8$zE; z9x3AXi9!1OQLN92exKXb=ig`@J0V&-Z4g>J*=Vh3J9uWSw~ntF0zN(yn)j;*nfEJV z^ZoHe1O$^Wj5Q%MMz2Mh|c(Yo$HiN5A4T+%=U(S3t*+ z&0lB!(mOXKi}&|Bi5BdDGl`o#+|B_mLuZkGqjRz@&b?~5i!`URXzZmr8%|Nb&>y-) z;Tio#ed~10Bh?J9Exfyq}o+ zHNVx~dHy{qe5o(+?ZEPPPcbLb4`u(CtzsXbU&uTtz{V81BXHK7aB1A(`H2Rb0%@4#=Pcn(>)ezzge@4!Wk%^P_1 z=jmIo(H=wRy2X8fr|13SV7l=o2-ACCf-s$44yIPkooAQS+A#hS>?@%CU{A5*vJgGN zGlDf?|KSC;Uw|L9W4F-0A=odZf-^t(gJK?flAM)Sv%Xk++u#L8ThRECK75K3?ORv0 zJN~2T3!jFU9L@jyB?!~kUxF}Q_Y#EZ;&L#(MAl)e4`0c3_!5Efu)$#L44rYpYmA@4 zYfL`E-=w3I^5w}5m+$s;^X0MU#EgUD%kxLhqJvoch%M(O2ItWym-aats&CZa3ZLKQ zeEj8r&*aNw-oNxRf%D>*$-HO&{PLjBPrXd=dCAKJpFJ-VI5)mb=Kc7W2|iyg>;0_O zmxp{m^JOyceJ>L@-&+pOR^QU2p=VfuUEJ1-v${7q^1+11$GxPxNS5&X$cxdZ?C8nrDu zL&CE&Y{g$A#j|{M<#VO8f7vzLlW*(QIiP&EWHXdcp6nN%|MKCz(lgCR8(W?H>h?0` zrR!(7moD~fc-<=FH|>>V7yHk-Ub$>{ee2G^|6(Qn7hY~b{{QdAH@u$4RB>`+trZguaupxI%2v2a}%=yJ9>=zGK-_;2Ed#ez9|ce8k!_(`d0dE7 z5}%^1k$3rtdA3pUD)|G6kIf)YZA*SmO^cmTj82T&H~OlLpIbqD8{uUu5~DVbB8KGR z#OQrIU#r|O+u%_>@F?Z}ZRER=_+X^2y5@70@~YRBnag#Nxs1kX{jcU#_$a-YwbQex z^xByf!S_F9LqQhfU-)A1{fRR8?mT)BUiH6O7ULH?0N?o9R?1I~D}(O~2gCP2Wy4ES zHtbkfnpgedRpGVsPuXy=c-5LR`2I`;-~W^i1$+~4Gk{kez&>q68GJ7s4B!9cRWCJO zwOxDeO$$o%sz>JhO?lP!kd0$|>g8nPAYNpMYsc{2R0iMgiQxO7^QxCGzK5>I8or-A zu{3>;|0ljn+c<_U8w}s;%i#O#M}_&xKk2(5i;3BKG5q9+GWdS;p!hE4F_c|dF(QSS z9O87$zJ&e7e#3_H_xw$9BjsX6URH3Fh>22MS>h^uQn7_7woSJ2ZvNk!l%H}ar)E;V zHc4V>z55@6`EXV{?$QyS-<;+q<>$`a<=>H%#a5ocmZcnPRoKgt*u?hYbJ&f|(7C{D zYuL(&Ve@Gt>>J&mRL+Z0^jT#wJo5ilzp}UKj6n37qJOjV&%kfl&)2E@dn}I{_ImX( zE=BH3p1p7JUz`b?^2MErKQVQc(;|So-Otk?ALm)s-rYL--D19HV}qRSpEGcNKs=|L ztJj&r;l1($?pt^2R$%sW+;C1HpI?p5{Cjy%U4_3dXDfQQGWy8PZQ(uzF9y7}n*ZFf zmEjI3C&B-5{I6cWs7E;t(y=?8SPSyu+`&1D;Q~A64(yV4cJdTwHWn)ycyxDSHQM8{ifc{*d>)0BE`H_=`POhgu9XXmalvEsU^+rNoRhk zdl#~dS21Hpjp4i`{qVxBw0D1l?gG;od%#;iaN5{g$x$(N{URIx^!1DGX1)Yd-@53X zO^TEK$S~p=Y4JYUT7B|Klw^ExM=`rrdM z)qJq(CdJ5&gid?NRoc^^Y{vgPzpKjgdDlHH%Ksv}v*EJU;hFc3@_#WfbM8f65-)~) zBR*!(kz&bmpQQan&F$V@FvFTR40Glyu15L4SCI2&xu5fDMTciI?x9`9M9)e%`Ln2( ztH-{qSW4{9sl>>QI%~83=h5GM%5UWVT>2NiLNnQ$)ZbCp>P#zpGjP;#4mOT9w}jwW zTrD~n91hyg(U;&*9%X|;bO{YGo{iaaeK@eaXV0bI+xfo|I8N1n-be8N%bdRvGe`{G zKE(~DIR6`T?6jr)T@0`SWC-+5xmp0A=c5oIToa&rX>qcjf0qv*8 zwB*(^Cplr)iBIU)%qujicv{_OrhCn90WVwNSE47yZ=`6e06%oO8Gfw%_@_4p z{3VnlH(8W5nWO`*h;IQ7&Xz4!R5{W5`-!*EIA-$)d^Vmg&cf$X4>-0$2c8V9*+}2b zsSBn}sF~@!SMf1h6_+#9nXtCXIo@!Yl#avLpbOn)XTe+joXem*3 zDRD(-H)_uLCi_*|W~>^cN;QR{Obb ze@pwixAYm|g7&>LVSHWa!^6d4hKqgR;^HetgNsr7SUcc?TqE!i!IA?Oo%dgqKxasd z+xRMYO77+=_nDiknpgC_|FIR*TCRVl&UyAd!1}Som}@@<{r)R;f5V-!-@9mT)5Sc$ z`=VDhSq$LKOPd?}l9y`jw-KlFD)9Cj%@H!n!!viy(x-Qh4L_m%6zy-L{W-L+GwKzz z4-5wX#*2<-%*f=6j%uoO&yy)Put31j-Q=teP)#-HZ$JkRHg^C^Retkv3vADJ(cc+ae=W{;P9Jh#DL zyUKGR-vEu>^sQ5%M?KqGmH!j8%AD?ihUDWUxgozN@mqbXy0&Ja7316Bq?H`U%k?{Z zD{BY6#A^>40Z*a{t!L4z*323|_R#Liq|F|F+Xl@vvc^xPZ@VXOwv*4*^QUz*LcGTF zi!vUA%rQ6u9>YBG-kgHRZ1!_E=V({*!gMmmdo*xAlN__{j^xbV(}4L)jPqlOvDbbA z`0n7hPbNlgdqhHZ zxT&Gs;DY~v!)F`DuxA7>;!z#UuV}R-Pn+ZOG=7&{HlF6UCtjv@y*-}C$Kv~nh~5IO zv{(KA?45s{lx4a9pVUnCw!3agMz%YGF|Z{d6(uGm8rW81)FI;($2JpP z`$}5LW9u=sauKTmJ?B{VgE>}UYlZ#Nl}=XHX_hseS+p`bxs%X--=FJ#p4n%H<(Hb@ z&g=F4V_q}!{J8J?`hDHkecjg`&QbHSXSH0b;~H(t&SCdA%8#-6XT*=uJV?C1QgtQ4 zo%B$i|Dw0eoi=;^f02W)YnEfi7<-{z$!~$ahw@vh>xS@K+O?-IsNKo9qvK65Hfs4@ zD<42-P8wb$@2xIpPCmYLgK)087oG4y2LI!qZbnz~+-`i7&iIA5D$}X#u?|AF43d`CYm8@6YiR(;yb0n1*VgMr$kN=%4yd zj$ZJw<_?SG9S=qd4Z*_BMpGMXk%s$mLH}JgdQ#7Vj=EKGl-Gwgv zK>C)xm0YSOmi&YsXt^8y>4kS5!|!Krb}{yP^wA=(7=I(x5f8Nci6 zCHX=>G~74MuOY4@m=)I$%*rp_4W6u~BfNA^l{=Fqtmm2^@cjC>(l+#xkFVOrYohNU z4>raoWbRVNJd@9ed0#CwB0Dq57yA(3bzG}u+;zm-F5~w=I+X{;m$I+E8GII&m-FL7 z;sf#p@;vA9%mv$Hwl`k7R1Y_V6|M zem}95;hsBtDCTs|Yt=sO?HQ3S_N_=4FG=!_&$Bnu#uD0E3Xd$-Z`xbRy_>oBdD`2= z_ZQ3CdkVf0k0~yYp1l6dG`LMq<9jl(vpT+~@!bfY)p0$^cO&1Uxt`o{X1xzdyPci5x``6X9!?$F2KzfbV}DQJ%09P`$XYr($|FB)#DiA#*7 z{aU^!LgTRkttUEK-(dWm@T2v=DtXUz6MUu^tn@_^A9FXhQhc`?yd{mD;_W zYb*G)f-mtG^G^9{eHVkf74ekfT)WruS;zf0PxZSWrmjM9cRTm5@rt+Ox8IJ>E?X3n ze}dn>N%If%`G4Ld-x!<_gUgNQn;4w&a!W8bfghy@GR#dR@A(B0-5ADsx#nUeBUiD$;cFJs^vc&E@P zdoj7c5&v)xe0erHt8XT-wh;S*cG5F82OEK2Lmww%p-m%p(-P~>0%K=<`95L^`#O7> zW4E8UFGEaw3H5{2kJB;CznOb8@7`B>AN{zgRnJe~_jTI17rM&-tEYW8Uz=!oo{Y!e z8b2Q0D&KJAb37eij$f=$|l z4=MiJG!V1+&p^DKr=l@(#e-#&a^S_yhdA2<8&Jc!%Bnq0tYKe~IxDjCRy!IzI={%- zxt$>`!L@9Aq7+k@PE6q)+r*z1Q>gZAjGA`_afFQvRa-tfH|C!FUgINpOtwWnY_jAl zpu_onWoT=lvv4Fkr1*{OS)rCT>iE>+Ltr;`rs5*{IiN3ONVZ;aFvXDa$Y@`k=62NB zfEEql)cgpZ5pLycXb!xCeZXFNy1!*#%xZ~i4ik-c4795*BsC%1CU4o_a*XYp2yH@M ze&I6oV<4Bkt>}xP#wHv?kNSOXJ|&!F_Kt5Z`DU|_&!zxR;=@_8)oz~be6k7nWL4cs zcuzR#jK6gsXLIdGZ|(2gTm@fF+^5$bgXS=X_g=*cnFpia@GM1%- zvJ3v17UPQmw`gPj1NXPUmkrRqfq2rEmJ_yZX?feWQNjFIF%6Tq#h&>YcfzAj(*9lW z>8GHl=Alp0|DFZVu|?nb{K&1~V%~S%+0bGJwbtZAZIYahm7L=LL$}VoQ*^dSab-{= zdPscIuVOAbGg@(1@vrd|v3!fMF^2o#sbfW7WK}+-cxV7#GFagy`EdK&-cB6%ZPe^_ zH8U-yFrItZh~4cm{}gOxX#WhC^n)L3_%gi~hmQEpMu$M3$VOZU9rAI}NoPZ#*KAzV zn#I>?YUEo@ar!CHhkUOj_)wid#RbrPw#POc^^MYdH0<0a;^M| zgss6Quaj@0ILal|?4cGsdSuEzoh{JWK4qWa?Wy*%HNdQ~sBQV^k}uK5wGm@pp}cr% zqtixDbvoM_N6(^v!&v8W!kKUaA8IbExToT_3B@zJ&^@ZTllx>l5a<6!b8G~?5c|kppzr-%a#b=k9hczd<*}=TRqPJ`_uJf83VL; zaR;a4H$-CpI&;V7`CZr_^tE)f@w)UcyfWnaWuVhdme5CE+vaurmw2wFV$T!(>MSks zSgu9$B<5J?291@9D0PZqQ%P%VySJ{=-`pBP@WMaIJ3Z+Nqp10AEp` zN_ClKCldHMiivz8;hmf2n&E49AP?wO7qeu&WEMIAzLqSB9tFk`#>a(c;cF4=R`SuK zdZ#gqUp*Wjgtj!VDaFabLmv9-tSFnO^1j(j@oXHPjlTn%IelAlqBo8)-jFz%SUh^s z>Rs_|ct&s1?^i@$TQ>}7BVDMrO{VFmWUD%-`x|Aez5BU`wysI2mY>TZ!s6I z>N?p8(NTIfv)SvOccc883(+fDKbgMoi`1R#Lk~+YnQS0uf>SZfJ;X=vWIm~ytD~@& zjl(@`5_^1-jAgV3UL)&5wzfPGAK-Y-QgCaD8lUvyz;@Qz;R%ZwwhWH5uz+ie`7GhH zG@v#8C*|iY1CPVgMXiw-e9$2g3uH4M*cYBHaD5AL?S_&aJ{~)K-nNnSEuB{YKa!;a z{GoZ+R_y#J+PxDy|32u>zM^YK0aqXRwe@NEp#c76+cWYrp;?sAEBV^uHH;$>W2_^D zYZuQnI*&k4!=p)yYp~`!k#%C|6y*ck*9nbvO}1)(+j-dAx0cpwB{N=9c5(`Ia6bA& z9f6NtEBi8a->adap3@px5;|e$jpjCnaBOj|i3i$)bxUZThz%>tvncSaY_{n}=q%e1 z#5&AA=!~4Qdb_|Ydl~9e#hY{np!DJW!8(G;d_cGQFg^rMbdHhM7!^m_R>kC9g-zIr+gE^7L99_)ns8@@|!d+*>uydrM!*+e#T%lGrX|JRkCgS zoLnACh`WN}DY+-fy~OATZc(0!&Zv?tGMX_S#VMY$I9ue*59C=oV>jdIMFwpw@S^+) z#Yat+uZX4W8yT_t=+529iTnX#%(ga~A7Gy`aL7l5|34@{Noyp@So#K?g&>`-I@O`y zya@kfB>o9=t7}g|jz-azY~m%CoYbt^cZ%~Qj^#aUe&Sf#6kdR(6rTc?(pnt958z+q zL~!dPyTaIP{Z8}m{AT19e2430hC|`+O5_&#E4?Q^LpRq{#C_ynqX#WVAzITI6pDq#D zjXfG8w3h96XX#Dt*LvJt*v0)nw>yB#w1mg5L_^O9- zhCC!)UjQD>xdj8dr8H*~z6GPkApc5q5*`cKhQbK(mSP_1AdVIAC9+lWi}~FLj-z%@ zerzcJnfUqGR@cXY^UXoJMr}<=pEDlm&?uhd--NOwp0qiD(U!RD*7mpbYY+Jz+PxDw3H`M_^t+W9t#T~3 zw!fKYh;IO27{iJ&Hy4j6pDYI+-;Z6$agMKzpL2B!b&LZUE5J(|g7c(uGt>K}4;1g! z7{b`6=I*Z&{DJ+F-I5=b1hz0{0i0h`y^Zfk-_*i$?ExLH@QPZ)$_xLs!^-0r*D+q~ zvvPF$_#KYl@+S=z;uz4-SDTDWF>1AIFkHd&@O8eOZ}f`IZ!f03zp_q-?lYf^cth#D zxKTXIf4yryKCy+u#a!FvW&fo1CEMuLl0Uw{;)qTkXzYvm#Jyr+jR%hDa|G|MVz+iU{7B^{u-faCos{cpgZ6y0SX41e&Q&E?>Ak>qC{-~*$>^o)2%JT99k z8=afPH|uzct3=QDi~4lp(Vj8*yW(T%KX9NN>f2>o@M{}5Td#1FTPtU6e$T34&aLyk zl*g#~gJL?uSDf{@r*>m^;ZgZwnzKcHIP(YbDV*J`-X?!gFo_@KAN3(aF0O-368=~# z6ufztLufH5<&em}cXIZ}so3~Re-IcP9Q5D1xzZPmoOc(DF`O6VfIpnyYu*f= z1g~Vcw64u~AAcs+u^YH^{MI_X*?4b;|H0D(Jq}#*spPlM0|w2pr0=8W-bG^#iql7P z6*M<-F*Mf1@jr}b&{N_U@ne`<(;m=cpf2$Hy0kW<=b@iHPfU*I#j|=x@hRbS5wQ}> z8y24H_`nD9RTmQT6>Zg?-IqQRfAfxfU-={AwUWL9zJ}z^Tg)E;XFJisLHy?-Z{-aC zE&b?1p6R2XBz7gtqmyqVyTr$0Hmq&f^XKS!a3_Dq^d`R(%#rlprQ+*ODr>+80KBdnZ~C`&Js7qtS_z2k>CEuvoAW?M{gdt=(0Pv0o!$6torB7 zk+X$%H+M>W%DXhjS?eDJ)`j@Ys{Q^xekT+|;`b%|t`B~{hu<5hYoqI}{N5>ljo;LN zny@hVeIdVB1;5YNGr{xA^i1&kTz+o|+I)+i53avS*MoN#>v}Mrg}Scz7Ja>*-}&I( z`TSlOT%XHt)vM>=w+TCg_s-yVB4}H+V>SfWU#;uGZ{^n42ft6{_rjpb{r|L zY~s-k!EfiJACPVsh`jzpK3 zZk*~B7t($>S0v{&W%dGDvu||fJnnD(D1M4~jyCh#)2W^0EGRePfOC-HMf1Odxtr!s zs*!(VuhXF>e>`J826AZg0p!@?FX$G@w84pdM)j=rojielvU4osn*X{p1xs_rBZOn; z<z_h7Ep zexf@wn0rZdqjaRl_nV$zUN$N4ODCnXtAeweH782bkGG@IS09=OephwYYEJT({7>^`m(XIw;mwbLILuiucuz_Mo-P zr5VMzW#<$R&>UHQpXqUUQSk(ui{I&WNG?owu0P4IWlVeFUB&tY*RRPpcIR&o!q29F z(*2)t-|QEB7hOlr(hh50{pW%Eu&oDpR`Xos34W>~whX-Z3!eO&I5D6&XM!ey3BJFP zwaEQ#^NF20*~2&O*0XuWuKA(IbESQV(e}8n=OhPeM}C50MS`^fxoYt6=ZDcoaDM8? z8DCjH@j)I@Uy5mR{>a1Vtd2q*G_v?_DX-1>)$~>8R7R+nziJ>n>r}Fpm{Vd;IoEiB8K0o6B6)#EUckzAC zOH%dHi31~3`Dgh&Gcx53gJv_K*Ky#vRel<{(R_V1I9mgLJITTNGV7JRhix30-%eYr z)5V?PdE;w%ua|z-dPVUU$wnPoGc$en%-N~>)4ZmS$I!oz>I_R_K=;O745)?oct%dJ+(3WL#gBS|u`)L0N%eGbX82si5_!JeEB-~#&JOJVm$A|K)rEDe9f#{r)G^F$ zj{AF;#X4-Bw*dZXjTMvV^TNfnyPD5U9x+GYUO>C6=h@y1!^_^0Dao4NKNfj<2p+E- zcleyfo##3ESA{*C8LIC;0OMYMuVbvpef}B2%=fdw_dv{iW1T@k%!zyR@$;*|xjVCN zC%Hq`E@SRf+qAQV`Lx!3G`B9?8mycAVI=EKBU63L%tjOoPsIG1ryrc-kuz5K0r}74AR}=m*&xJDk z*}ue0CrP*E|8RCHGu>-?T6oobd8Sv)^Pccgm5v)CQ-^3tdA9C?E2)7Qhde4otsV!j)o z`2a8y|H%u+Mr042nh4@NtB?!Ly$r|bw&Xn7I&%GahP+U}{Q|(O&bFx#hIinth(OY;Hqr%gu4zp2(frr`K~5 zw${zj)#rU&o3Uet|ABSEe2i<-XN4!wfwN=9@Ey*gLMPGR7<4!CcbarfFc&m^_QcsK zY)RA4hR_SUCjlGwS~hxsc6(>fD?T=NUh!+|XaoDBSgFZ2HcYmou#CQz^I0~J^P7V> z?_#bmpNIWnp0_mS#<&C-UCK4=hjO5k!<`Mv!G}+L#p^Iz#C!P#=nUT7N^GK!cL#Xy zN#45)pJ>lI?!g;RE{+x16jtn8fV{uO#Rc+<=UMDtV;hK1@S{1O!od%>IGo8>$aN+C zId^y+oc})Qp1r`u7{1RK#_Art(**tP4AH&FGJN;%f73g|{48zgdah-df8oD*9T~3Y zpM~c5R`OqST~+>f|2=qj037qKUEf^gFTX8#_tAO9)A2=e$lwRKW_xAZ2V?o$Tc;9d z?R35$XJvWmk>-=g?&^#w`4Wn!*f;(dF~oD$Tqd8#BgQ(te@B=Dz1j2e^B(fv^>Y6K z<9YP=X{?1pUvz@yYbXY>lQAl%FCRxXShJRoq4`j*{kwA%<55gfvZ(k=uASUn@=|SG zVzyUA$=iM|#s1|}Wx#`Ew1Ix>EB7?Qi}DpjAHgSI$mYT}2SENJd=+DEtbL+h6n^{` z{GM}O-UbZtp2n%RrEFk2O$ zyv_XHI&d)y+|C-*m5DHifos~suJgG~Hvpevpl16QnBV!3Xw^W@=*XQG?>)@ISF}#{ zDP2QW+%?8CiFE>9LoOX%Pm~Nl+zn9P*P}5I=}^Hg8>({?-`d-iG-kH7cXxs@-{;$dI@ZKLj?`*(W`2T?Yl8kL3I*a*B*2X#;+G@xZP|S35#g;DJ`3a6;z`AH??=yF-C67Z^6PF~VXRy7syz2lu9Od? zwPSeFZ)ZPBg7!4tWzb4-H|3KC{OaNl$oI}bMx*+_l)E?8EA~}`>ulQA?iI7u`qB*X zSsmksZ{!!qwkcj7jfW~1brZHN-x~AhgOBc|ZtVHid8vg%^jX=5#hlQET0>T!rvK<) zIMPRO%>b?(aw<8j+?%-dX|0Vf?_U7T4iziVd_%l56CGYQhi~_~Wpfoz&gl$z)-MXs zCOMXTH|gI%2Zwb@M#hlG;rK_J$i-`S@rM0v+o_}B`TICeWPjUz8O8s?Yad_fb$Fh4 zPI!Ij^#kUt^zSh*?iyCHhS1{9U@yfl4);Gk7=I&21zkL?f1&p7-uw!S4edNZ_7z>? zMb7PPss5H~BH&|Ox+#e7M`dDn93JRp-w*9H(2n935717b7QF@UDsF-O@dbm?51y~r zv-(upFKBE$ge+*!Wdiw*v5xbVKMt|BLVM$fSKtGB?Qe^byY1pZ>htjfa3&lCdB)tA zU#xZ99>yE)B@Ov@;YYh}UG&lTt=g*$ZLQ|bR`iYHG02SDvjz`p{ZxBxv}SY)WA0<@ zT4U$~$8H}*a!)(HgYuB@?e6yOex;wGPMGF1{|apRpbf2SC=RFRu_c zh_2N;hZte4U zcf!HmMNUi)gQuwM9Mv^w+j9i(Lx(JDn%QVSxdYxU&;Q=i8tX2IFX6icdJ{gv-#MWl zUw7l#v6tfOGS|e1O4N{}kIs=T3H7P`dCjd8!TcM!eL7E_1!{Pt;_%P_-^NSumpwa! zwNCi(6!cb4;OEIc)F-_n{>xqJQ}C;0PJBZv3AX{={WOK0ixbhRZ}mQ0uJ zVL}$f%bHhc-LWv2{#x)W(47nMEi5OH`+8q&zaD47PwOXaIVE=_1Y<2;hi$|n4Z5<(4dy48c`Qg%!9(<~GYsERV@4s=9*CCjqIU%BTIA+C> zB}0;p0^=5s_tmK%cc!9u{!?K7yE-rty1Pgo+g{A-a0G5_= z+Z4ib0RG1~{M)%a;9u)IjwXj6_a~2d+=FKem&RQ|gCBTP&0)7JPeyHRbmP^&uT_N28B=J8rvw4vCQep`-e zFkdqto-r!Nj`?QU9x1JXDCVOW_KBZU`>$ut;M!NLW)r$Y@tfS50FPX={5!_294ns1 z{~k`pGF2O9%170tgE68}=ZMp?XL$m<&OFk+*uO&b0gcL{Ig z;~uB{V&y%jv(#3|67yPJ$}gVk@;Pk29ZP44lVm%)rf*Y>Kryw?e>2$APY!KOj2Jxo z94vmodz!bCubb^*zeCyFH-baOUCQVO%@k`1##2h6kZA_&Y_8{$}HNHZ+8m<&)pr78y-5Q7ZW-R-;D)%^Q zjYIcFa!>rFwSZ_$Kx^1hURK=Tqff;;e!Yh{cm-dnU*+a&t=h)R7;e%Y8emnNUODjZ z=Uu^VF^71y?M2hvU9tOUjE7i{<+~|nuX%8;)$_0Tn6;G+V^kU|?`BuA&aM7bZ^397 z#IF=b@ra{D`7~O)V|NpI*Fjvjw~p8@^-=V`ax~SR_CEC0GPc&3-#3$aDeEFytGBhx znU1~|H(L?2cv*hMJX_1syXy4IlrnyW9wYOJ#s5j^LbXeE83q2 zzZI7uJ6bnXJSwy6{ATv(Hd%cyLUAdg8w#F_$ptQ&IW6R+nxp&!e^LcqGz_- zIOe5XzTkAL9jZN(CygF?o%RppYGc0QPNxaak^E9SqqvFop`-X)ZAs^ZylXnV^QHc% zHxBBagmU14SNl7*uy0SYKz{kTdiTFIw%g{Vpz%22K-coi=LNJH+{Y)KH^8%*-t(I` z1Tj`2S^~j>}9XMD1U4iS`|6pqof>ZywXSjhD>?<$TOxGUsXx!=VHcnzj z|0~A%zXhMqcbx6P8bY8K!}Y$X4$8r!W=Eu3u;Gc=$*i*xKgE71|3v)Q8|IY2VO{zC#?D}7&iU3DLl zN#f(QU%D5M_;=vbbO?0P{(P;aOO}l;jBPj9ZfAUxu3~Kf*vM_SI!s!}jRoySZDi#d zt=hy_EbgWs)#4Id)DL7$ckuidwaa=d&&h7j{drBred&<>ZO4$ujxR(z@)uN#PV~yO zPH)aEosO*#zqq}#s{;F}c0H|Kqw{j)JS4;?(7vaIec$M7<$~y$1bnrSJdAPt&nzV$ zf;}MG=MuHm@)eZVs^_=Ur#D=7tGa(XeT(j*{}(t9PUpDgD0}du*^3Zoyf2;a)~4}Q#0$V?dtbyOz$&81cArTzWa1 z;7d0sx5n|m_#(RJlJT3*sC)zIAn~MpS^1A|%PVgq;y1Ss&W9?;qVt(_eo}&cS~jjj zjOnW9IG$HOm+`RA>~Q0G$8#LdanEHuM}VI*p5u6)eBub{MQrLwd1umd9LoboI+mm0 z<3Ib)jgP*pmYKSnK=p>uyJ1JKIA% zWS)zMgfip@`9G@u0v4?S3?27jlJ&2L*=Dl9MXZ{&Ah1gHgQ-|N*$e4Pmw~NoJ{H>B7 z8TzBO;DouJ@`AO`qK{{k&r?{yHFV8Z_FQafpFli{YqN3$E^MoE?H~Ors^VdLM(Zpg<(9!Cz zE(Yes%!%12;?4MjVWx@M9>ijRA=;&JscY$a6cy>=~mA?mF+_#iAmoOg! zACE0b7k{@Dng}l=(6?*xo!1E`>`7b8^~fIMM6kC+=Vz&ob-2G){x|y^i+!uPwg!G8 zKB3&Gz7_P-3O$zdy@GdIHMe(r6~Z}wA)uG-Js0fIX%GGOty3+{$;|sF_wQLv+iRF- z(8nJ7=%bH2$z9a`jXm_yM;`<9(MKP9=tKKCboPew5$j`K@eA;5ZYH>zC0lYKbucer zZDd5S_A_6;*B0^swtOV`<%3A^mh8(O#9nue>-q0MwkTt8N?O7cRh1a z=msyjdB%9~2@fi68_qLc`@6xsz9+f2hkJ#1%zv^L*pMILtt%5j`t;>WwDd?@;ofX z5mW{R|v|mI1kHw zfG&$QJ(Qqb#UPBY&@BmMT(WdPtO*<$|A0HyA~3mJS?`|;j>9;UVh+8GZP(w*&#Q^X z2c)m0hc(X(_eyxsvOGQzor@1XueG8C^Bldm_tQu54t}oQdHgxN^UEWACxqoENBF$K z5{zrd(YzDDf6o!V6OQZ4NBDenT(=zIJ7FK6KFa5>I+Ev^Q-UvVm8$v5OfAI4j@PPi1?;@KjUnyMEGrhsOq4Ws*t@*fGxYBKw zLkmpOZGutp&+<4;g28~56D54>nBKVh${?2+k3VAr)M z+*&I=>G5!#>mYPZM8GdxXIkA2_B`d{JI?g* z5p&c6@z~!qxQ=lY&ngBw+e_uQmzopV3$J+CiW1(itV3PtrDp*gJb1yQ=IgHLd;Cu(!*5<*WTqpi_I;XZn6} zSlr&5V6Qy3Ke``#6>GU~5#RByr@A`$0l7)|-gWRkHvZdRb3CJYc7o@0|KwY|j#?GmJ^=ETJEnX^nNq8Kd^IPg)e~ zkSt3_&-wu{GTz7W0Zo?h&-S;Sj}Q2E>iHaFK44Dm14|FjMC;&cJtI@g+AQ~LX;8>?&*XQj*Zm;B z^XNv8{kJ{s@C|$QMUz}L-`d++3td`W{*lAqEBt#HXAZhMy1c^AQR^_eM^-Xm>qzMB zUT`QG5RYg-=JvJInuX&G-^boTI{{B-)?V10;q3L3z*VKr41F@`PU-j)c+YUkf6Z&d zy`O432cDJNvEsL$+ke{S`Ses$10V29<^^uN`TEQq< zX+KboS__FJvI?zFA1;~%@(o-c2Cm+Nz`Q1aSu~8+1>FxFWgl)4+~Be8UqiUdZKd?S z`to9F&Lrb35yo5QFSJM2K){pfK=2^?NA2Y&g`a@_vYGYAcul7cfgf920xz#m+Vk0f z_F43e<>!bm)GoM?Kk_xJV-(m!YF#{>qYi>N+{<&Q+`Nfg>o?w;Fo*$u9KRl>J|6ttr;tn^g0!^7%aT<6PGWKmV*(eDFzX z#4%^qJGyq|XT1*P1XaG%vz&Jr&-si;_|mf<=2@fN8tI#K7T(G3CQtRguR_=V28S7ud`tY`)+G(Ba zH<*7*Er$0)qe#)HV?P|x=OD|MK&`P-oFZO~`M z6yt$h>l8mR+S#dN;JIxd3i{^ygW!125MveJ^gxeX{6s%j2abZdi0Z5U5Be@2A7{Bs zUaZRR0ft0STkiL|Hxqi@$92Y{e{fvX?JyhDQ|ID% zzt;1s)5RXzDzqZ6@NQolG;G3Of`|LA5Iy+~FH67Gy!%S0+jhm_BkEwxV$82}eLtYK zXFiawrE7rXGzB6myPpyt!EyX_Sv(?;4dl#)gv*u>Ci#?;p+P5Br zkLLzFH5WV&Ab%}2ev zg1t-9*$I4h&54^5@E`hEH4}4ffo^yd~^MeKc`)6zR5WDBfpNe*U`o!aXu}@{9^vMp5))OA(X9?{BL~P z>ma^+fc_{f=DqeA7E3S88fJMKJ-i#jxKKQBF=Ii`a}HQhK7Giu^SD>wp7eeFWOxeR z-`*k{HV^;pyH*EbKJewB_eaTB_=NgLHvWd3e^vFF#X$lzaZa`_?oNs;UD z^3))2BtJEBy*KXv6`xS~B>8G&78@n|k?V^4ry`rWo?HF8;(gc=)tIW`n)w!8<@13p zo7smAzbJQ#8V1DM_$@z**x@E(ecSg=ZT=td{TJ!ZnO?DgT%lh|=N0b^YZStpS7XEU zylQ~TACx@HUy<(8*?aOIHtw?8pQhI{dzUwFNP10+c>aJ|t%a-`tsk1+kc=1H)Nh8h649w2%zf2KKBpA$>hJfFn1Mz5ng!!hd&hxLLN-MD)$G9Z7OzI67$N4UP=5qJjsqq)<*-w)ih z5?(i%?HX>mwfOlPz1*q(tKQ6WP3GrsoKZ8=fBd)Lf_C&QaQRVr;>?8R4JUX__lXCH zal5exb!M?)onMZ3XQ-XEyo)w34s89!#I&eSCRtOP8XtOh_*!{n>a&M? zotxv`Ie0aMbAj*<5B4mk&K-5dKF71h+i_RV*4oO=^v^(VhcaYvA_E0z-PtOBqShAc zG%LQyGsv3pFxTI@(d+n;c(DQ&@yQnIxJj2=TkvKWZvqaZjbvW;43Fto`d5HA{nxzJ z;!@~d#jNaolPBtaiAL~(#`GHc);KbI!RfJHll0>O;{ayyj=cx}Wa88dW3A`JUn9qX zEM|J&-dsP@Yx?~V?<=M+zUypX*8hK$OOO7v@t_+dqpr@?w0=FKx*}WQPt{J8Oc`D~ zU+F&vziS?#xn3yyBhcp$57D3EcABe6&O`aH63&^&8mznC-n@|c-*n-v66f)B@##-_ z9jb4p95BgB27OQ;^NRADqR(rswDK7}uYPmaJDV!J30Cnea@3@CSKa>w_cyD(cvbg* z(VuknHfYyb3qN45KWBYab)UoU*YR7rO1ya{-{RdLVGkt#s{6A@_JwQT53UgtD$gx# zADkoE{0N#!W)$1JUGt)9Dch{>oBr1U+p@_1g5bZ#rPxefXP^Y{*frUwmh-m-wH>x;?yl=w z;>)czaXs_A0yZ%p#-5Q$lRa`E`F@RbMo+D-m12||=Xp-?ly~ z{3zb1czf*1WBiHuK%xidg!f1DAjHQzX^ZnE``?YNj@CCZy@l=xWl#B_$KmVizv3do z-PKKuEzoV+C-hnLgLpc+k4bSP`Jj?71i<;i;4Heo>&qGg>lClk7}^J8Dbjr=^T6*> zD^fMO*Nm==r5xn>m7aeu<1LH3bV**_xh|5qo^NlAp@?mhq|mACaAc zAFMAu4}Hxhf=``oFT8GP#~%f^+RIhKPvHrD;xPkO|ehrlmCiBi zb{V=jtW_tOBwl2(#;Rnxc$%%ryLxtI`xlT^J!`R8`H|kM{ib7eHYs)G*h6XjzVg-n zH@*p%k3fXRWuu@M+$H~u9p13!I z+P%A+epkJxZ_|F+-UXgb@QirpcUL<-q8dY=72b#~o-TU~Zuhsvx9sm?+`y?hj^-57 zRW_%q@#kc7f$!vn0cj54_GhMu2I?m4Dgg;ovW$b(+XBxvdW@kdbtzkI) z#k0}1s@pio0e>?gus@KA?eF(czrk~meeICh^d^V(0KJ*ay2!s|S%e_yGmO|OIJ*hQZI zXZn;}S)a&`XsCDJ#JjqdgKkkD={2GQI$U8Q;Nv|OAq^nZA@H8k7gD7*V&?q|C66Hj=iG}e#ZVP z!4D4@&+<$)WB(mIQ`v`fgY2o`));rAR~G=k!O44L1DK=y_}5=Mcy9t;;pLOavFvH3 z4K*6BjQiha{Gz+;r1%m2Hje#}sboMS8zcAra;?|l&VZ@F1LITv+6>0%LEpcy<_T;L z7bBP_26HO4|2x`{pQPWr`7OFqU)11<+T8~N7~UhAwTSnEJ>TLz=`;9Xs;68gxJ`Hr)T?c`laQ1N{<#FB`tMn)jkGnat6bt<3<3 zN8d5VC^)5m?7j7;`%gbTaMRju$2Xc+7!TBX{`0PzQ6SDaMtiAu!he$Q zd-0=)=OmDQ*2h!hx@e;@s4K|5zhFl+zt_9DTKZlRnP&z61%IIyUR=UHZu%i!Vf(lh zJJA>wCo=v>zs6rm%*S|wws(J*zKDs~e|)+POZbl-(tpj93NxJ?NCtYswSTU8Ju&}0 z$^A?(E5BYa{Fren{)oR7ox^POKHKE)49*m?F?7DhZ+$do{=DA%xyArLYYtSy{6%_M z=Y+l?7-zngxDfwOjk|I8=znjlI}yafzk}Y^cy;Y7S|f$WrHl0cN9d0K0CxxA9$vTn zY~~Z--NoC&xd+!YKiqMi z)gt>1BhLyayLnFft%qmj%g7(7!!|`_u1rQHpV*rj$P)Ob21BIn7MGu6J`rup4^q3O ze4I$%@+VdPI4ik+$Heu&$uyxoWGk%f)aK!P;1z@BmJ2-#>h{YBKx<{$*3b z*z$CqiLS9n^EgaiX;=BW^3M`uMLX~5Re>zYpHWOY8DqXN4<7*C(U3&$@HZ5n)7qK+ zMLwbwv$xrzOjlEG-MuE{R#Ht>9d^_jd*{*V0T#-PtW$v3v|N#wq9 zxbmi__OBj&>~+cmSB|v&LCsMbsPP~>sQhE=pY~+`rT;=1DA^R%f}QK?GK6+8KX+a# zX>+^e);K!uYV6|GE1UeAf}C{4?1fX=AMth7<;h!rjO}|6ew1x?dxDhz$6j0RD<@F> z>Ym0UyPaLF;<`U27D+Jn(3 zF8Orl`AN^1KC5Z=KQ|H|4Vf~zM4yCb#9a$tojtGFPqS8}bIa1?ICaLqFsG*_sI`I2 z*coQzC3eRD+s>v7<@^=E63q{a>Lc~V$Mn}R)d$yG;dA8dZ(u<_NGg26X|pce!n5X9_txlT?b?*ln=MRju-&4Dc?Dizq@~c4x>-` zukx#+a{#lG$Wu69k^i4ZSE~)Jace$wJvO0iJ_Ky?E6c{n+}p-j!x)cpe8=s4e&f4X zTGNFmlgTMrJSbmKI8XM6yY;>ABkhZ$VZ+h9+h;lC_|U1R2Yfp(N{CSON; z-?WF#_F66G_hRnDd)jLi?o$rebtOX~KF3De4=`iYh2`_rS9iLS6 zM!p=6IUd&@xaH7CIUr@Z5bYzoZH^4?sgYLf6`gxy{$tSkckoQ^HsIOp6+fXpVw=wJ z3wJ`p|KM{E&u!<^LZ8rpd2BJHb04%XFn`Ur!+W9?GD99b>keFN^IduQu)j4IfwEUdY`O}gqorPpFRU5*495Th3 zV4TCqTI&S)mdZ;Lp7KAyM-Ru=GR*J0j(4ti?-hPTyAKKn>Ec-Mq?n!UGmXgL)yo4J zgbyW41B(M0T!0M1cLVL^`4=XejCD6SN(8zpuX$%1IK6^?+w6KNKM4Fa+5E!r!JbX< zB{~YX!b4tr0GqT2@Py{GB%?#g5#!Q%Uym|=#VVA~$J#XeD93{r_T#SQ{yIKuxjqJb ztmB#S@EH4Yw`yOmouTD&c%<7*FT+>Dq4t+b_o}|bZuU}s9G>`M?hOB_;~ie4hfKHf zT-E;cuKTg!C49%hcRMg&6X;3t!YIZOt{0C7R{ALfry!ucRcHOo7|YJ(;Kt>bAOCo;9cdOt1Xiibgh$_$yO83__P;3T|s-Td>Ff} zHL2}jt*Mu5pQ3%`*y=1Z=`?-kKjZ#y2w)gk<60Iyy(;Du9-eW_H}O$@8sj7wz4wdbPr*gF{;9Q>M3Q%>x_zqOJ|KlgPxFqzEPoVaUj?k=krh{GipsI1k9R8uJJ7@bpxrH?W(Ukl0 zJ+>(4kM&aYGdg%mzr`VVN4%d9j%EQHGL)|kzUy2+vZa!D^VPgL{uj=2{49KjH8nLB z>#I&7@ZL-S^0W^HOYu^BqQ@;|2!)IAi@Bf5yrR|1UD{l5LS)t>1&4M<<%T2u^r42Q*kWbR`$ zM9*u^vQ>D)?j?d8YULT_yV5Dm(UlWl7~^G=vE)e34{VMx=O5Y0^JA#>4ejc^#7OLH zN^2;J8S6dyHOdDX85=p0nmEmhha-RE^qk3<>~^a=dn-DxipI^N<6Co-<2&|69^b2v za(w?yj@XNdzoK~KDCoQOMIPTPj}m_yUgYtedX(ck_eCDx%a3w=H81k`o<2(c`-NCu92m2k>)8<3S&M@axtoaYhdt|<%J@w4BO0fgx9_CxT_jBCG|Coa= z)Eq`}SnY%V=0|~NGc{l*X^z8wX7uDG#H|%ilb)09tB-lbf75flJja|y_EmY=D|k+E zDa{Sgp|A#zLw|f)DO?s zx#txRTcZ9L&$T?$gH6k~@LZ$Xoz|agOcyT=?qAM*!D?+XzuMmxu~%J#wyq3^^xuJ3R?U@JSa1>)H!yz!)Lp?q9+E=Yqov4}-&p$wL^54?nCiSMcF~62Gs+;rsMl1rG04oc}O<_zt}{ z1P*`ByF=md96f&!9Nw$`z~LgE848E@1@}+p{&T_MzB_pD5VE}PAUM3g?LZt-gYEzv z{->U+z~MO>^I>p!m);u!ho9lyp>Wu#=MREIjY~MZiD!nw;m3me*K+^hI2@cSIDJ;# zKjn6w4|2-nb9yt#quj*)U1F%3OKo3U-7J65Q{9&1&hRtTwbGsp;uUw#UpdF0`LL@i zlHobYQ)cZ7YB(r&r95UO9i}<~s*9~0meQJ5dt{9O{b~NRzIl!q8a12d_)D-MW$SZC z@Xo?RyrWo<;P+U&wLWUj^j}l?e3#3&(Kw)CP+x~}q4%ZdH8-{E@Evd$iRHQ4d4GrZ z5{$E~zhOt#-yz?N%(aD&5GLj7tP1DX*bMQ5?#b4@;(Kl`uGqEkl7!y*=rh>4uix*U ziPk???D=5xaO=C)^S`_kxr6`A7PAjDG4Zxru#skq9lt9Fz#G@O!0T4+zOV0a`AKR! z%mY1WA4Wq*Gr_kogfHOt5We$%I&f2>hJB*UaSXQTePG-76Yh6X&xrftb>;3n-#%07 zGkx$rf2%%s9HP(2JXSnyurSs#I<^=Ml}B>7xBg6j?L1^2x;gne)357x>)=sZNAtrE zT+2Q@F5`l$^Wbo85nSFO;EK#|<#+U$yt!CK84cUr+#A?7dNr^0=Lk0Frg-zy4*?f6 zDu-+EOocMIkOAcom>dNCsh|3g2Wc}O<#ps?7kktdQ&y~?>>l`k;7?(0Zsbg}!@v~w zb*M30J|5#VcvrsG_ag9q9e69_3#RLU=abk6tv^^?0bKUfdgRMax4n7fP;XOxR&O+X zJ;Te&zpPGr@OC6-6X7$%5o3@Z7j0B?+SU`$h4PEF$1@gV?Fv1yo1FjYC%e8y7s0k$ zx&P>tu*NR2i2fMf*Ihyun5O-;1?Nz)DJ+3%Q`w}dteH&+r81E*)BQV@)cBtvL zDe#EZT!_*}G?m}Sy`uK)8BE?>e`^Iy4i2XaSZB&UKHlrNV}f#a*5B|w=5L~y<;#Cb5TB>bec9(x8}$peR;kq_kfc&>kibPza}4H%L}JLdWy z7CbGL>t93srw{g;TvqZi4);!6Gz{{6G`_&TCu+2w4o}t0OBwz>?`zWg!Z)xyjo);x z{#$+-V?Ta7HVJu1-)fX8YD zfa{a^ueEM+BmA(x1Z`Rl25abA1C!1x$iFvyvVWQNF3rg`AOEMNG1I}SWp*C(Sl4f` z-=N*q{nNAi+uqFj;1bpchnb&Nd{AS!P;1zZCeZIQ;;)Ea32__xf2)U~o2G)}sq9e? z;so$7^*ufRTR#$w2KCATGy;d#j$~bDB>@jQu!GtbDe^fEXfI>!MQle@|3Vw1C3;qE zDo&s_Eso#f`KQI)9L9X9vCfyO9PdWj7a!*ii1A0_+!5VoxM0j$U-%U5>)pyW+h|j~ zAlp0WlSOPuC2qmV8-&w?;Nap)oL0UM9`0Z)($%tU1}||H`Hq!iJ6B`l8RhDg(WzbW zjU;jFrPF!Vn^NjEj0}o!_k9l-flvG0 zbJT%|o^c`A^0X=YCU`O-JW=`uav_*{prO(45OA%cCf_Rs7kb3xDDL!yd=$mVO7x4u zwbn~1A1d@wO0Y>5L)Zi-u-X3DxPvV%`myKFYU$5TUq;SS%A1!wB}zYRU$*}8UX`eHnYsFUvoh=C_w}wNEbSe|LzB=Rn&Gw3QB4 z?9u6x2o8X0X9aC_X4lcub-wn0LK~wiJYPmv^oZuRAzfb;!V=eh|54^*<5E@dTrfs7`CoJ#2r$*A~u19D# z1ApD|^KUEU6ug+6{`L@ZDqSu93~33B+EZ-uvBJrzbfa{7B@b(@$z&B8Nv~_ayUgKv6%a#j%s-;f`V6&V%XsrBeb$*Fyb<~PN7gGh0Pm6Ll_I+G zf0M1h7aemXaCHp<*OBVWoiEZH<7MdnBb{ShAHoySC*nVyNgTqZd4~2gnQj}X(3KH; zkO#h(4rSjDVh^mnE1W%0yv6*p!`Ow;2i07v9zA0;!xlLIY!Q3w1D#2`q0T%ngfSvx zA#8_(Y3GYXvxnJNc%(EtX$W}!k#)u=UUy`&_2iEOol(JyhqQ0O+(0#N59(jSTe8*o z%eFU4e5N^2bbj!KkGV4>G=Gr~Y|J6qm)BqK4xT&Q1UUm$blIX)bhthe&7Hd z8R5$ngM4{6@MRJz=~iv-Ti9_S)smhqLjCa4rap;ap-> zWG+#P*Jx`wz^J-*D#jzrf4^9Yl3Xjc~32z?m&GaPEv|+yIiyV7&Z?tJA4O(z`Sttx?3a%--te#58X_JbG#Vo9thk>ZNQ=fp1%rm>TN&NDR8Y zd|g2@qxq-SVmz(45zjo-x`NjCY+Vo9phLH64VHBUzmGUPv1@mYHr-w~))ZrX#YIc)>i3h0khxYoyb`s#R2 zdm4i^7}bTf*qZbccGC9wsU`>44L^Y$WPQ}-X)q?iHb@6=YkkZs)(jUs-bSu()V|uY zv$O?VrzElQqod;<%n#9eO9>uuu2`3F=hlTIxg+3@HO8R-!MU>h3DxP*zU;&>`aStf z+jn1I>uP?i*@Q0S+%&D7eD49`_LI`t4V(`oxU{D`)9ZC7d9Q)@w12mO_q1=aa*xa!=#7I7ZiC&P=$LYxIcliT&pt7n`i^|MUe~yE+0Mj~#@^ zq0j!d81a5v2emvSlZnv>c|JZkFV-5s^^XnQq`GiAlg;#Eujk*kci^VXX0Q9*ce;E4 z?XQ}Awb!9Izt%y5vD+Rq@W#G%_#2$b2IS&8FX=aPa@6Md<~LQRXK-%&X7H{$6i@vS z+gCYma5Vp3>ZWV14*!(NapkkzKbPDc>Q95aq3ax2d=(zE_Mqt|#!WnP+gZG4>wWOt zetSP6*F*Gk2G75=s=S}FebOz7Zk=V)$$q)*?7#Kad;X2!bRoQ^^+VN(y_(;>oE>w~ z8-um?7Gl7Q;j<-PanfC0$BtlqS+(TF)7r=TEohMEe{LzdLbVh(SEalwJ^y30oon%m z+9y5GlJEt8cuqoQ@7qds-9;nTq1fXasWBG(7rz!}L$fPXw~S}j1ZQK+;@TS39kadv z+n&Tv$+e{6@AVIBT-q}j!g4CGCn$|rnaPC|;j)c!8*bs1y}fSj+UyAU@9d3tYY)86G5bxu*P-~Vd>qmDD#l;XI0Ahpo1?Sg z3a!A;o=@rHgg!mP$RS`2yOnxWpNFQ{EN|a{=zWzNw9`mU9n~ z*&gmCf_hvn=z!i4@BsaC{;pp|+w*h79Njvr{Ke2j?}|R1@&Dp1ws?1EykkzLE8cDG zQ+w>k)Ogf0om{(!K4+xkeuf%3d%-~`HGX$N`y9A=Ex3`-{Yu&_lU>exDAhPv?PWVv zb7QTO;XA%h3O~$+eO^XB6N%&K!xXq}(xMTb1Lni`;NtOegd<*!)K69;{<41q_9|iCNLthtTuRSZ( zcdz`h_2)FOH>}V*JXL5pDP%_$0%+Ph9J4m0XVN&$Lu^KU3%C&uV`o?TaSj$$z$f9+|TV9oZTG z*EyM%P!5LqBSSghnv;WJ{^Q>tl%EfR6QdO{{C;;tCqU0!>o7lmJ#?l{;Vii{kaN=bOM;s0*YgZwqj;vMR`*LRX5W>EMiO#k*zeR)#cgHQ%gg zdW#&=A26<5JYBRg<%auhj47H6sI~ZSQB!pWcK^SjNnUyqTY1%-Scld*qP~-G=A}LV zFrh1w7F3rBPf-1GzLp+=w6exqo6<}cAEH0rnu*^NFs)N>!I^uf9+=7e#t_)eY) zb7EEnIfIq5&N(=~v)!BzC)=IotN`e1^KfjE024UXdjD_dzwsV-%?pSBSK@%UkpdInS(zU^38iMJv`6cSiyq_@KppBYCM+lYh_J`OuVXFwLL;Ru5qZY#wO}%Y+pOE z+1b>I%?oHpHLQBD+0x&cwQp_SImv5cFAwwz>SnZ0GX1R@ySb)x@xQR^xmw<-3-ouL z>;#|1;2PguHKh{Rdu)W|XypStX?U#};g9_y?^7r2X8P!B)O%^G*|R7{PSJ4D8(rE$ zTMPKKSNUW1DTf^W%k?*q`}c_H{PMBM$L_<%3O4y?eXYz@N4xw))p#~v0DF#a;(P&Y zIr(__u2v6co7Ud+URcjW?PYqay6Y#pG1O0{ei`=8;cZIuM{0qgkvy%LURHjGwLgYu z17Fm}Glo9!Nl%%^SmsfOXzlsU3C=ktanr#)Go9Vjn9hdnuEwTzKH_!XdC!T>Te|qr z|Dp!)2@V#$g8r#^8e)7w`9;*sOLsX}gDZUnd@eju$%OgJPkTu zLfZk{T|B4t2GPpyPvibP?gx0+xkS2e{uFs$nbwKTTUy`RY`j7&ESk?JzwaL8T5HG| zp3$Cm+t0psawgJ4)na$?!5m@Dl}&!7djz9oi23qs-^Si7^pBIN>i)C%U$#G_t3ub)4z$7d zWe!hUrjKav)z3o*b~h_vbSrHeJZ z>Z|Am>@jEX-o)8G<@zS_j==?tzvP+6EPp@Jb_AdH9B@n1Nz9;Kx9MUrP8_e zcGizwbM+G63BKlg3I9IYRo~w+n>k1?^w~om_kYa&Fpu7@txWh?Y>dNq!e9AijWfut zkKj$?@=l2Pt3U2_%zmxSfh^X?Io=)8GY60TM(`VH55BkeJme$lpO`PxGPr+1d>zK~ zl*46x@!bV{Cc7)rsfFR0V(`28m3(q%1Hgmcp|+Rbs@iP9`3B%88{MlQ`<{XhlBx2! zWujYpRk4sBboB0vsiC*a%Q9xpMxh>9k{tTU9y)PCplc^If1Yufo?Clv^M)F)iDxGi zn77wRH%rf*=JZ@{Mcn@k&#LBR55BngqzB(Shd=+u40>(}*O|ZB^`%^2!rmc#ImM}Z zuwA;IUyMCkz-Jw8x1oF2!6QT4vWL7}!e6tQ_pkA?5A$vxW76~FZx$D!^B2wl&#lA? z*3i~kKD}}Og15V4!G z5BLM&`+c&_=;vp3?bf`J zafqKb1$Gnrj7==Jo3yQX&3lkFaFLa~82uve%2vMpgM)VSJbtUcpLb!8=qLF`cxqE% z7lFIXZt8xD`-se5|FS(HLk{8~O(49B4b2$5Xjs27Ar> zf;YFkCB+%RWwBh@YvItF$T=(6cb&DwnoM_TT_%2b`~9Z#%I)`g{Eym^P?tYz{{Ikq zI}W_Syk7CVnz??0bs?>VttnL4$S#e=)xCE6dY%8UbBX`(st4}=Q{t{&f2yCD|C1NH z_zU=R`>{{^wAUXySxgY?$~A)zXe0a<;7jd~WlcV-Id3AUxzq#>WSdy4&Dyi$)BC+? z=(2h2??b10!@d1w^?D-sk*yaT;urC~e4Ba?Sr6CC!uS^Lvo@BM4Pb328|L-JhNt%@ z(m#1PF~h;&op2E>b=maJuIg>`;Dg;`@Y!^pK)^@T(7;AS=6Vs^WxfI9(Kxiu;SEdg zuktSYg68m#GUt!lv*^6YY$(qx1z(4I=BY!qV|_cnM}0oSv+IR_d^Y(&i4(zT8l18R zysV!Hjpbt)9FrJ7ZS18D&CA6f@-s5Qx>=MS7po7Ru~;K>lPY}X_`Gy>YuI-y{1C|V zpND%LKl_|}p1A!!=D+)A{NkdIQS)z~a1So_GsoVSf1>KEnVWC^(}w*|7;pV@HM)pC zs?sZYCcQs)+&@h}srjALS@+qOPOO|hgWpxqaz^5v)4h6V2+wGaRp~t)~6djK_6dj?_rrzp*QUBhdo)iD@%)Ud> zJ9tKPE~96=WHzW520f8W=3T*ggM8x)oFo5g6E*i(zcBru*?VDgJ?pf`qr;->DbiuY zjhXkc$AfwanO@E=qz&c5ipPaZ)34yE0KT^NUf6sJ_17KF2KQ&_eCEf&m-fEB9=)lU zT+4UCUsRT9&*E}5o}Xll=F=f}GZ~>Ymaln;$A%rUtJyTPB67jROcTaX9!Cpe?8uPO|ll~L;IbC)D zzi@9a_oDn_at6%mQ~SO}7p-9{4w(kVa+(h22}|D*)3Vyp+6$w)ncBxpdw)!DUX^lv zc205Y@%5g!=c>5d`?(wVGvJ%`^glN6oYvYN;5ngQ1Q20l9bUk{8GYb?<@JN%aA zf3{xV#7(fd$P(Y9*we)~JnHg3=xc~~Q^cQAyqm(`{*q zz*}K^Ci|q?yk4$}S8rYND|s>f0MD|XbH)2Xy=(n`0NJ>av$GF5749s2=CHo2i@@vf z4!Y$~4jAME1vy40Px56VL;EiQp2dgaaStv}x5{S{pAQBXzPZ7bqpjfvxI7&k_El6f zm)gtgCX!Qf4Duh{ry7aDI(k3y&PY+A zGs0P6KA#Uy7xG+D&K|7x>*C~ZmGeE$9#^E|U*i$<8TfmWJs{ab?K1~+%(C`2-FP`Q z?W4{vbmOkWbYl~`@g{I}HGH|!(O%H5>gEc!hcoxL(O(&V>Q{gHxeWuJ)qm^rVm^10 zH_3f_#)r%&2JEZ-c+jeFpV;V%3q5>yA?E?kTW3#eD8l(e;QS#E=h&YW)-Uf$hu47< z^!UGK!No+sZ{73uR_0U(&EsRhA+(IM78fplz~54G(dyHz0g;`hz*br9^j4e-jwf;s z4Zl9X$0Ok5Z1su$c@n!VyjVU7W8ces^j&}T{B=%G^zMiL%2|~iKkga%rCy#{J@s{@ zyIO)gvl@5>*t1Du@UmU-tK#}^g&-w5M z^Oc?c81NrjcO^MGSCFsn*Yd_DRk42+wsG?BJ1Vi+$aW{THQ1BikBkY|zhO=Xm{V|m zGc4y*1AW8q@JdIKIdy#Zgxry^t3zTSYpV-w*!WMw>XhzB#^F0VV#8KyhX3t`<+iL6N9 z*C8w2H&K&*9QZ?T^r1%rS;_rJWYg{RDSGv*JBENMuRFGu=nmiybVpMdckpDzjs573 zZ%i%L9X|#CKOI1KfS1E`$M%r!sPlEl>4WHwMs&wK@O&n^JfJm? z>H#d_bKM;kTb4NSZk^3jZRFwUg=sz4hxCH2^CrWOlSNCf4rohIlMGyTp-&Wh+t2S0 zqI>FIzT-LhJ&8zsLxexY^ZV5?_#VNT`LKQ@&SJT8)M8>?5oz2%ga5#mHzJnY#jjeO{31aD*LoD*auWManwhee!JF$NIYGqBNzEo$y z>x+A;tgop(%kq8QS<;smFDQkp6`Xs0alYB$!k@Fgz*D}jM82=^`TOwskpMpd-Gvk5 zHRi4JB&sd2+vf$HUC=#WHcx!nz}L0^z!PJgX=g#efnd@aZF@eZQ#kjbI9xVL|Ik!@ zDSo6emG{b8ch^Fv(j8gl`@%jSbkjz@z}w3m_eJ37hHvcr4ZLAI!Cem#{$k9XeKumA zy0`a4>u{n#dd{^=wzY0{Nf67-M;gEtBQDPwolyt2ak% zBe+O71)s7xIrTd&$!pu>Q&tE5jnB8zG*>uHH1An+VIStsaGuS(PI+=X~ z*V3(dm{TD;bEywA_GUnsn}GQyV7?ldR~BGi4$O7^!3=C+nB}J(TDJn7bt!d<%5A+U zzaooIWj+@)%FRaaKqKwnczj6B-*(~)@P*+N86OZn-}Yx~fDe9;`99Yy;QK0no{v>@ zt|6U|pC^l+7tTAp(&z9;5Yt5#3jL*|!$z}~A_tZ>7MCGjr8~Wvk;!e6$+g7F@QGs= zjzjKPbBbq?L-=c>)=ZMcsv=&U4!u_zz4N{}w3e$jUPPap`I4-hSV?L-zmh_VLB{UXqJ6 zzdLeAd~*K1ly@G+V5k(H5{eUe>q_aAI58o`gmrdHXOf8VSPYf_!m;8##4}FF$6G@4 zsiZIBw$H|>CAgScg1-mex7{&%OD%tI=f2=Icl?NXjM@;3RBot@CDMsV^<>&`aylRcN`^h-az zl0nrQ3ZBa+n>iXB^Si}V$M9ME40EcwSQ*T9}vW3>qG2uXeSk)Au5$*J23M8H>+a z$JqSfyZrpHS&Ff+FW*~R=e?txmVW1F?fm)esdOJ>eoujm>}=?`NaJ#Uo^LaAHm3Is z|8)g^>Dc`>tj}oMLEF#r?zbhk{`b;5-qp5)K6H-f-)O7*>b18des!kAE~=U`;i60> zXNN~X-_2VmVMAZe@t=HCboNIlJz26Z(Fcvim-%@`&U5P?Uu3Zyt+nOHn@+1_+~X3x zjFmqNm@U;G(TKsJ7=!(WUNn0(9=aftb^Hkz;q!;e_p`MSbgsZB>_Vny>OVGFI)J!O zns?Ti4!E=ATw=6vF7Yr}l7)DCkv|7a(!u^&E%PZ=1Hj_yz@YP9reBz^Y@IVMzG0yF z`jPaDd=1`jv~Xr_KfD~AZDd}%VvkQK+DnN!k?vHmmjch~%(U4;+U3BT_EK^+j_>PW z3v2Lo;1BV37TP?Ce2U(O#S5WR4*ZMeTIu5 z%vTgX@w>zL4CDSaj}`2a_Ogtpky-7%=k+kWKG3;G2mX@IGW&|%ET1l$SjaKMR`71< z93yx({*>(S?z%UZ>~)2BNcgk36a5og_s5uKx%=Whtxs~(*biims`J^wGsOpGzpvh- zebG=&$Ef03g?oObleOLyEc?NK2Yj7{e$AZ863uj1p8anc&)(;FJOAg6nLiWK^8?0~ zErQR;1G&7Rc9b(mXW*JyuXy;F6S$7uMJ@pL zR^RofwWMrpEBEs)Lyzo?LL07>(beSnYJad58ETGR z)w2kl?VW2c-X98hDBxY~Ij&M}CwQknyY~cpVfdC_((gLol%Y-p{&KJQC3Bn)H+UD^ z@)O06f-Rt7(AR1|A4Si@ITW%bfqaSn-Q-egFT5qSvPbw=f51v!x%RLJm#uA_zwhAv zqW$Ag|C+P~Y7@S%4bKHt`LG{+B>c8#9oDvx^AO%M3DrR59LSUg)6x15E&_ca9gzD8x)h#Kt_J6C z+<9+xHZA9K%UaGFR5)|=S@zy!KRV4@7b4SIldIoMO``WFW6|&Mp5!$n9p~Ge|E%OW z_z&beQ-e;Q;q*RzFVFp4v2e~I@qvxbgemU5BRx4K`Lz5dc?e>xHgDQWH`bQpR|p5t!{B6YF|9w~A+13IzUhE( zv@dir^coKh)OSL8f$K-P=c7MgK#zL*s!%JXIdyH%ANfsq{oQIWpJzF93HvCf3&5SH zTdCVa|2k*>PWWHHY5gqumaS_be^GEELzWl66n8AL{y6VF!F$p-;`?xH zZH^D~PmFGgy^dnvfV^z^Gr*%73!J5Dl|L?jRe5nbC$^jOVrC=wtg~Zp<-V6pjfc;c z&TAU*h30s0`Z2J!l@Jzbtjl>2e--`R_-n^6E>D5)ydA2OI ztxmJ~7x(Mw(l-(}_i8OGt{{Ew#mHIDB=Nz;SM~fgVONY7SVPVtj}!Qnw_tk8=da&^ z59veURWaJPFh7l9YdrLY)*Zq_CwzNg7VCXj7akQ)T-S3u{dwoe z6ni^?{TcRX+Br)uJkJ5o`0kwP1=msO@ZJN9qVa@YkYEn-p?Y6Vd`2y>k-JEsX z-89j|KcD|A4!)N8xLDf~2Zey=5Uc|!yknkG1#&f&RizPG2v9&dSp2V+4#$9%@3jl*xU zE58A^dB1?)YSBY^zhDl1(bnRI<@o*2f)nILFzY<5{Ale<{7TP}B|XkhbVXWZ2q`9Kr=z6Kt;8Qxz@9?v>{TL<3W z&u^HCOdO}A-sx*F6aG6{qsVX)6b)v zVc8jBKQ5Y9O^@tNJo`q(&R#VscN94&J@=KZiM!j5@$k#}CY_0*AKrO%fx-O*dm&~s zXm>w*5hm{%gYh+v{3*elyP5aaLhCjBcC$B^>>B8s8s%m;^SvgT&PGSMx4;|LU$WB8 zaUQ6P_cJB^=^RnEiFeys`#;8g+)wa-_xgF=YZml&-Mj!9tZ=)RM|!(1Wvt7A^)c3Q z>+hPI*{%O~Q)h6`ylfO*vj}`A*Uzg`z3)Zq=cz_V4BuJ)<4$O1b#ShaxE;(b=GUEh zi084Y^_GtvJKoa?*Zm59M&BsjnR(2g`?_d4!x$&L-P@np_AT}V9$NrClj)pKmo1tb zI;ewo>Qm!>oc>KNcuwsl)mTzIK=IPTdCa6Qr{lLy5)a#XOnc_RWq$vvW8G^R)80pq z>irht?urMSzs2vX^*gd*^5FL+8`ELWLVfKy)?FI31s~GS)-O8xuzqQKiQ1wU59`;S zh5D^J&OIw=3+`omtl!!F^h?{hYCF5QU*%hN*t1Z->yLA%(RM$&w*$SC0XKRt`ncns z1;1*o=B+)Bb7#;l(;0Qkq0<@t@EZ4S^zKe7Zc?a|^;*F({X$z4D-NS`TIa#5mMgw< zLGSmF`wVvXH~yL>e6I6o$^E>MM@Q~&d}v^~-(Ehj+&_hV59EFm{>a}e_aEF>ko#_G zK>VZR{>#v=Ke>OH=cRJLN;ZE0xj*r3p4@N$=J4eHKKefja=&*Wa=nr<2ax-{4!^%m zzYi_LS*^%LEFLQey>B@^VD`|x!=1Gxv!#ae{%nN z_|=#Dy$D-UvkX`s46%p3g<@g+N+Kg5lGL&!MUYtiO-$gkK zo#WA^_{c#mlLy(m-i{AyYfpSK)$6KXjGrT4<6Womm{&8t zymtny%xawNz^_+LDDhWX3CVmG|V07hybVa%jB7@$TMWJbcAmCwY|MS^m_) zMb4(HG#7sYVW6k|hwkKadp;ZgJ(_Tj1D{X3+}Wh{tY~Kb5@(T_f8)J=tW`LC9Xw_0B-$6z zJ5Z5J)CT8L4ZDzK))i^Vvqw`qliQ~CFg}juTv|O+;G9AmoqcAVk=DDJ zI~nIrr*YG#=wl=~DEw}FEVR`+EZD0f&%%6HVlL^d-zPfN@>0V4rCJ-{Z=3E`J!h?v zzlWaSJ`QBxyFcWW&XfK9zk8ln9mRR>EScv9n`bD;7Cu((Tb=OA^URTXNIW+Xn zE==w5{!i~wPO=kE^uCEWkj1#+HPJ}>qFvtszr@j%XA{KatnXE0+&FM_knePc>K8>5 zy$cw#x_OLS#hA@Ck$wM0Ji}tgrc*rr(^^NreT3gww-O60+!wS>?~$J+Th zVs)|^b<8)%{{TKcs|Svu>_{^>9LkOicf2cyG2T4`jn^){SizYe;%?Y1*%ImS!Rif3 zFNe=Td%EES_aXSoVv_AXAE2|fcYYfCY4{%L7`bJf)wVNvp3XSI-N`%huYx`HD&+#8 zKeVSFyp!P_t&^39>gj_M+_%BgS{ol)SIA%2-qK=xJ^6pZyX*0h6bA_JDOpV|`to$h z3GQ*gAYWfGLaYA+uGH7rzTfyMeh=s7|Gjv+dH_5uyZG;LZ}u~s36Q^GHh(2& zl&CRx9(2ge);q+j*7!DGHU+z`oX9S0Sv9_i`jRflkdxskUUVXS!#j#+hU*?EPop`h zy0ly7W=>RY%xC9XJM@*>eE`|f8IEGV<=Te8$1uMIxnPaZ#u_0lxmJHq-{fo(ABlI= zkN8vjA1_j~;z!~?&Nqs$bD9hDFrMT01N<&tJG8E_7w|XmsvN{E!Y6Z`?BnxDaC!Sb z0WP)2`$};6ppVP%fQK+HKQja_-F|Sn)5qmvAD8C`xNI-%&59pYn^S(i_Drcm;^i%| z&u6>@Pks9aPlpHjD2l7qj&L41RrF`g=<`sUd`xSD-c%d-#>=aQj$wbw;&}KuA%BYV zG$*=O0I%N9klW&*`^2vf_il#sgwT1o*eANH{I=nKE#8J~l=i!DfPQWN58MQOE~8y| zk9Rn@`NRJbZpI8ShyHLg@d$A9mtFniT)6o*@*Lo1gm8nbA6hqpJxAiK#y5@&&sct3 z1RvM*RG{O~Wt^+=b)43g=p*!uo?Wy9j|r82kYM0cI$OIs(Rz2-t*oz0csKdiZ?3j5>a``O;B#>}d}IQ#um zWs}4Q+J{ok9MH6`ar6cc*(Z%=!+T{$qa?>Yl^Ec&`se`<{; ze^0rjS{n+FpVj^iv{fF9-fbqpq73g>mBVLaxhnXf3&y?J3rS4fW!H2bekSy&g&tx4H(8_YPTC5daKC2Lpl6Yu zqV2~A=vVZ6BlrsX{FHDNmS5&CdvEyRvv3qy;OlO1tY&h36aA;o>Xl@xVVGOlMDoUL|X-T??DXxl5U&bY;^Wxh z{4-)kRwLa1O)(PPrxJ!=bk=yv5698m^i#%X49kDd!Z+%(o#)=VrH}*e;d8v@DSJFL z!OK~0FT@l&{kbV;-h8DQ`d`+nJg4o%{QCOH^Ztu3cYHP;;(HLcykaxB7cWR}{Uy;@ zOMaEvkd&W06rF0hLkF0jd>A`#OP;EoG4=Zn^zMG_Opx=k9ow*;`uCc<=9OWdfL}vqxK|NJXHyTlKOBAi-v`Fgt;598n|&Oe=i}&k$t5`Y@0C7|lH^+Ye3?I! z6g&&@toAkXzvC;E=B2~$CAAWZ+b}HFnJ z_CDtri^q>2+573z_s1OB`#VbCpE$t#$&xiu68s^fpW%I{%5XX8nn3=Kbp23kg3I9t zv!}?m)&$4c-c{&qSNl<17bGUR@BQ*1>jHjX&+lQq8Sv(1%sZ^-!@6B{RV*35SeRl|scP}3r z?stURf4sDPh%cqHgj@OSvMI7B@_`5QwY2Y4;%jYO*gVPo!f6l?x+9#q4r-aZ6A_@00!BbtI=O; zmH+R{MX*+|Ht?JIIq+QA$Ii$Qjy%cXgjq+poW15aLx=eL1Vh%-Ph5Cqc_A!5^QT!l1K_8;oK<@-R5uDHb31?Xb zYAb*E+x(s%L-5fzeVD&8(0lti=aqkFFt}2s{!!t4c%(eh`v&&K29w+Hd2+Sicy(!e ztqyJot*>T3_kGwd*+d&lSMc4qWZmwcYD@aP>hqt!os+QVc|Q)N5SJ zmty|5E`hE(<53h#NDU8v*Y$(HEBnLWyGD6%zBJn1D>(mI@b|8`kH4vI>(RttRQU69 zfgH!*Lmc7lA!`4nBPo5$UJUz%8J+zK^BOU4F(554Ib>MaMG{Lm-+apb`NoO<*3R0-@~(sm92h$PJsJJ$X@-}3Ta^OO7Rjc{Dn|zoW_+*CoC1G|cuz`7f4F4;qw9}7eSv$q7 ziG$braTLY7?LD5G+@K@zr7gzf-JcTK_xFxZTx*5;4v(ceL1B1W{CH}nbo>xaIMRGL_ugp!?AykLtERf|+Y#~N`fD9(B(g^Z9Ok=lhl6q!Y)tOC(YdfH zuMSk2_`LPq+ifyAOb)&!yTMX=v>r0{&YA9x<<2cEAC2c8fP@yR5& zw(ioJNBalRv)J!zFIl&MQ?m{DFpseRSLBB#Lw6m8-{YN5_}*f?>5LJ; zWxgYPD8Dfl=l?Rbk8hBjRIMDz&7pOdaQ^XP>imu{-DUfoz#ioE12^>{C)#;P>nEZ( zHuZ*Pd3sO$BYl*c#Te+!2g5oOJsaq&;CIE^1HLPa<>m8xV^J$teF|n9i<(3;3h(6D zL+z?zE;ICdMYq;Hs#Bz~vGt}e?HmHUu4mvCc!|gB9q1`}kw4q*_mks!9rgc$a}xpX zRO_w)pI2+hgU`F0p#UFo`1%5T*(kVB|0993p4#CN><%;$FVw;dnw#$IJW?Lm-~A8d z0ct?}uknD5^-s(L2H#QQft2zQ{d%qNvFfy1T@Gp(m_C57Vx!<&-^Y;NxA;G@r`nD& zVA1(XlNaN6WRtToMKT62ipM1jdjk2OZG1HTKYLsi{8t^v3V2gG!k48Dk`>8Kke8#g z3qM51#^7J&68>lv@l-xH>l_X`vL!m%_PTVgIe>EqHe2>ua4Pq)2u|%)hvYhoyyQ%# zLpp?K(sQOmLci;o`qKWr^7*sv34EC8J*vrH$sHa$Q_~xkmU}Xu7*n|cJ;?l>6Jk}` z_r8$35xhDFPdu#sud8})z(?WycTsHtVn@9lz^8oA4shIuKCVR{$Ev51W5FH_x)}e? z>MMKCr+d$r@qD>Y6Y#FK{(ZZ*X79q4DhLtVBN@<&$ir}317lB@Un%pFHNc%_nlc0$7n z@YNcf+_MyXb-{0~@J5Vh#t&n#8Q3QA!#Kex-3bh;J0ch~)?0kN|6Ayns}{@+OA8Wso@ISR2%p3%ihk|1Hc~mL24_T9;0vFuX8H>KD55d zz3R7qza!}v9+N#O`~B_Dok^d{^C_(vkdL1-R|k0wVtW0>))gnR`9?k7&^lIl5G?YK zgWo*fz+VPWdZxb&?`Um4hCN{W3~ZN}A6ba)!`lU&Lf?TNvHZ7Le21ToA2e*N)%+8=56>E$Z>e1;p{N}XzW0@*<h!&Cu>7IZV^5X(p@LRvM z5dBJN!Lz&$4*i~|g~l0(7C!Ih{U~&R(ZXOUv7uTUhG_wAi)g`GF;5F}m;ze-m3$V_ z$KsTlmuPX@TP?@pG2uS!&--fz*(=G_{w89q#Q4L0dta&$ztmdIXeE0iJKW^BW7q@8 zA%`!*moEbE@4?CMXE=5jlrKh9VkaGMV_yiF%#8H&&y|O6`R9fF zu<)FTZy3GgbLdQ{@kpZD{W*IAqGK~-NH3`l{;%jeGsE$6IC*{#x%li^;m;KE9+ktH zK_24Du|gho4qCj{0l!P$M(a#J@@%>T-m{vd(QEU$Zs-87E-JRLndjQy_{8(PL;OWD zD|nt09LToiJ`#^u-8a(S(Qhk(NqvXuouRK2G%h$&j6lCzomt@to~*%!H8oeet=ti- z-_*~A^dq~qLHH);v{v{|7UxOG27#}=)y~Q8Y-&*mU&?op&e#6*t%8&BjLtlNnml_u zPa(OL|CjRaGMBxKPPRFvHNN`TLtEt?hV!OZ`?N1N+81Kp8OGAta(frqGMHAK?B3+l zFw+T4&_T4m$H$$)#PbuG=gBr_pN1)~ZkF)!GBu3CI5Qs=oJm(JAIn?Mc{zCj-OR>8 zXYsOfBSu0yvwe)!!<@qLAi;HzXU~D>Gi`3y_52vw)Ox@4FYGTAe1(2;DTqs${(;u{ zcB$h1FUiWU{- zm9}Xw#EHZohHv;>{}rb^R4%@eZ7sxh!ZxM9cYf}_^CO$r@ZbBf|K9E+eD6vBy{{eN zd*AZkYbt#Yosq9aA)AQ~2=Manykqi~blhjj!BDLAFkR1Y9|q6Djma_id+t6@A4xxi z+ozAA+SBGjB#hw7$)LTnp$i+4?uM_Z7-I@5E@=ao+ zJ)7}lP?H;aW>VDS*4`|3N46!S+R!>Xj4jbxG;9Z}RG(na$*yO6YutC-uX-U~F35+d zbCk~mNfq`$WruZlqhdvYT~d z%%Z~Tbu_~zgJuOMGaenUv#fy4LwEBF9r(|IY!eJ}G?9HG=t%)txtDRdV} zEA#6Hx60WF;=dl<3;Rd4;7z{KUCVtx4ZrL^rE87=?h6Nj`wb;5><- zJ;E)tvKrWYcF^BOk50#ZH}mR`w>5@zecadesw=A896eV*;rh4V^XK$-a=XP(Elr`? zFx2MGqPMMfTax^5=B?a$$--sKTWhl8c_v%&N^o$`E5yO>XaNUj4uOLwX=`?P5FFg& z1+j;sVrhK?m5`YuSX_NTTuN@x<$Su@%%Kp+xFmz2UN5K^9+skd)~>g z4^9j;?NE0)>@yXumFZLP_BZaHS1|4~M`7FtU%|Lv8*JQuc zAJCWjX076v+pRq`Bv;}v8iwW+@cA{gFM=b~M*gH=IXf5!ehS8U%OLLwCfUDvvmtUC-G2O~ z&FxcfZRzCRuJ&thC0>17OD8oi);Nv-gI`FjBuzXbE&VTFzvUQb&g*24u`OO4!dvsR zCqRA)c!v%TfirNICSGDO3G8$ld!5d`9~`XXT-93s7(Y8Zaa&7r`mNNOZER_p-oU+w zx9(_K+2AaQ&!kr2ZL9}xrN(Y!%XWTC@!R&54H16(z=z5)ar~t_+NU!I z+O@B|mHi#&u(E->ExmE-6Sw8A2JgIE>|b5i^SG~vFTB{(r_;7+{ZWE9=xOpU8;MWj z>6vLg-{t(4;S&B7Z)V7sA!nwsW3$t!nugFj4c*h!->=xwDSr|EC0``tuS=wx*3i!S zNI8u<>na=i75WwbUK;Qo&$3(ScPlviM51y3W=}uvp`G5zCYj%c!{?XT;<#VFvVVK6 zoE~(z<+tRvBpS0@iKBmn{x^U#VClxbiXVIV|BGgC&G|bb)ForIdt>Cv6iow}jMVFOMqw;3@{Lk_`&A+F8+*yA@U8J@W|3r0yZb`APCjJu* zRVSee9+SL>b+q0Uk1=jzFs5o9D28q4uo;KCgN=&Iu?E=Xtg3MzFj^PKcbY4meSv-W z{>=mHhd|DE{H$*?Yl&UedurUR$kRabYr0Cd3ca!)e$qKI;a4>i!|!Mw;xGB&YExD6 zd)UV}7~si~OEqr)agU%)ICtH z_gK>lU1BcO7UA@33a4jdyOir0qw`oR{h@=1yVweWM2wJ!=K8YMn9A+9H_Sv4@={Dc0pd zyY(xEfH#DLnM3s%YP;;(;rII|){I3sYcI&S=&6{8_N%hnp$GCY;VJo0*aB>S%&%{+ z+QBbki{&F|ZLRw~vV0HgfUtcQT_tDMZ_;sZVLhjHk@|Hw>rjQf>5Q{tB^mAcImFE9 zGZs_KEVSn*deBdv9{&X2O^*PR<{`b|aAw0{ud+(tnMakrk3gR=Z^g;@kM48c>iN&f--W)upU+yvd{*FE6UxU=2RY*Szm}&CpQOnFz=zZwv7Rr^ z{#`n+zr#3y4}*KJ3fPQ?`aN8crRDOd@SDr9MpI^(Qtqq z4TuBg-QPW{4+sZ?!Mmic7~b0--_qjejfD3k_lIHSomT_an~h#0$(@@*KJ!%0&5d+- zG)4F2W_b9>&Pb4RhCI(gr_aXMT;rr2$V_XxmM{rvb;8)}GK1#C}`XUeci4#HHjWcI}Qd&WtE$iT3A$qxtavGUQ;ZlkPef zT+QeCr98hJnOMM`=N};tX={}AP_nTl?#x-HxdpH%J=pWGCOz0!0qgVqz_o@o5V@W& zzZk7-J#8F$$rGJd4_4N4$^#KzM2jw9UZy-##wQ-^b|;0%cjql<&%Xt*7MJM#ul4_^7+ozdu+l@Te0h?rc*{S?q2Oa5S z$?41~8k^aTepq%UbH3y0Ep>NPZjn4zQU|De{i^PCEN^A}v2Vi>+B5>{E#@&nQL$ z9r$hL9mKR2(AP?E2<=!1+Wos>Ue_3B>-lleQ|~vCr+NYJh<}-TKZnbSkcJL zx;rYioO`X_fxa6XzycSkE)yO76!{T}j9f9Qw} zVYrw1aEIxjd%wbdm3$%WWp8YR^YT-H9%CHIiQ<;AF?)VV?5d5J6=!^>x5dX1%XZe^ zA3OD_2Sl?d^wItAhEwRldtZZB$P?W<34Lra8Lg|sK3J92kT}I%CH;?25Zo1<(|QVc zl$*2=TmK;A-CCMs7qV@f%S($d<%1~a>uKUo;ur0&{EfcDxdqyHAs^fApxs5IoK4Eb z`5&Hz+h`9$KFGuLqcxxAq(2Y-f-e)cuUb!rVQ3A(@T>k{sPSMpcL*3hJQNHeyeZaT zxC3v{%IcNN508OEt+8*UuYqGKp?ASIc2+)n`d;Ks?_O5=ZYTyI-Vv?DUoDqT3vyk1 zwzJBBXHQsa&PUfvtewAZ_venpe}{@Ax! z`c->r!Fa!cUg21ce(w`a*$@4ocq>})U5zK<6~#U~_^%v4NAVP5DX}zoqWrHa>u!MsH87p-k!%^QlLY;=gBblKT8KZcUh znT4_VUE}b(%H16`5ydlz*GbnWtlpw{O8$8TagtW>6(5n{d;t5fDegjyPT@a)QSQ-5 zmf4!BH9E0J=LdF2CmC*=qnqbC#HpI2pPsurHHqhwdu}|-t1*-5q%H#UjKw`Z(_Id$ z!ME0k$|X=;%?vbY;+b;rl&7!fEBStC-J9{pR^X47n?DxJ^YnK*o6tx6et7*08|$yD zo|Aqi?vj=+6OP5}@L#&cuU#PC(le9$N@Cvp=A91=#UzugG3Z13CsTD^oQv%(NFt&#K2V>LeFM;YrM$J;H-V@_HA1vFR??bo|8=u>r}0KY(DfuTq{j~ z>DUPV(`Og`<@l?+pkgWd)%zTYE!Fe7sY{pEEm&;NkyrbTIGX)uuP2S4k*4lzWqvIb zmj67TB1`bdgm)nK(2Vt!)@0AtA=9yOTUEEwWcn`lKxJ!m4ut*5{CfW6-{<$&q%(R? zf19(Z%8%!3eIKE(>uICUlgKerAJPM||2ng%I)LJlur4Ud2P=W||Gj;1I9Cn;XAfs7 zg=57m1ZT7#IOq6q{*FARVZqrh9p&9qp!Uj_4c`MUTq=&DTvO%X?_fRL!e8vJ*SdDD z8JVctcY%DM?q=V9tJZiY@}qMA#1Ag%;C+1FT?^O0*5&-m#(SJo-OoJ`v3mwAFT$6v zLpwbEC!H>zQFn_!N&b)ON0si8Rq4Dncz~}sTj16AFqz@|f2bederC`m;d+9)lXd)O zk?AJKUE$|Gya!lH&rt0?IJv>}JpZ%kig?v}?rVv+V#Af!yRmxJsqQB$kd@i^tnkOL zHRc@fhtH;*j+TR0H?)j)=DbaOqP>cGbn42`J*WP6!7Sen-!)xyR5Is7f-z2%-S zkV%%gCup1*{`VQY2S2R#`TKnO5-jo4;4f@}`p#kpg4}MMwGurheUm=s`}>LW(LL=` z@vW}qZ(^I~ok=}0w>hQS{twK}F4Fsxm0wM*qEp>lM$yUSMk*}h{Bnz`py_E9h&cCP6&&=}f3skx$)3|4qUdjDkRvasqk z?$l$wed1Od=b9dO3-9oIAN8;-AEdqD8;YMyzX9XePkHtD7J$n~>YYtL9|=x8o0Z0A zXd9*9sqgyBNBnwp=y2`hf0uSeeo$0#OYhz^!KnV_^Zxadh5kQF|9f~(ze)c{pUNLT zs5Xr8crXU>B8{<=wlR1$9&x;T%K6_O#34*a6=IR*2d{sPyHorxJ0aa|d>(5J?27by zIL0kI@oi`gonFqc_A83RrgTT!1Z?_5Z2B>Zg>yHm!+tM!#Wmqq+=LE+*1Hd$#68j{ z`SNvA!_%(^f2;*X`<8>3HN>VnbN*X=0Ka&&o?vH4w7%0_ZL!FRmzVgurkORRbH-_I z3;SsD9m?2SS(tf^I}-Yvj^-WY;}NaDr85M}^X!S~d**6|P89Ws}Ce3ZEqzD1U<65WY?h_}=i`@YE1#I}#APvq8kd>MT#aNNtG zQ|3OeZH{)?`)HdS+t!j?K`e7}+m7VqHjDpl@Y`rD3oR_CB|6T|FEn?S?R$;rf=)A;HfWz)SID==SR2rezfpndK7x= z_m=2MJr`}qJOb{}3ANPo)?Qp~#Cb&d+ZlAA=$Ge7r`Fy2>By#)>I)ra zc=GQL(A>2Lq4kpdD&t|=KTLbGaf}@q9p50CX^CEDXMm*BWjpJb`wsr=o-f_Ep=Zsh zt9xeR$JrT|6lWG~{Xopz&j-}JcciXmZr8O1^KJ(&-Rbo(`$x)C_$p(oFUgSc67YPA zXU4CoNo_W6`=qv5xicp4SIv)wrv;~CPR*&ZeY3d>@fJRB;oauc5)P?&TeZ z_QlaHvH|izl+z-&MMm5ryOq~pjFDq3>8}OkqbO#QVSLtC++8@Sp#yy) z{ymxJzd^oj!IV)T*aLaAL?A63=4>wsVLm0d)3c@N#~>m=}hxK84o zyLm@#b2ZXQbsID{>BivB2D>L}?Nnbckp~A)n*Ko!ck^6$JS(r83bBorwdBe9@wy$z zmhOd8oLY02{c7%X_I-D$r~3_5r@xX@JpIL*RKIE6EBTi_IZzwq5Eg1KG)J%QiHQfABJLsbOvD#AGH!US$9nqc z=&<#5?hN4?c#3ow&(G5HBhg`*I=A^3{p+xwptGdIp7Q5&CVhwR@$O5-$l;3alYGc$ zIDpUa8~l-uW$^kIWE%gcIXa=|3#kb`58)H-J~-ajYvUW_50MX-mM+Q~V+3#UHkpx!O;XoUr!X*rGiITYFCK(f&xV_FR=; zd$N96TV}qe&dX+-%Ghs2FEWSOoTV{c0ZbkIjZvHs-K_namhI7f8QRXIjqwRO$kCmM z$q{a55qsjwcUs1tek$sEzSd0l9CWDKX4=bK+au(+Aiv?XXe^;tn5)VphVry~kZ+spPf*F^Vy z?#~`wGx)mOcy{7r{`X{}_ixX6pZkE<=y@qVKq}^HZS&Wg|FcCo56!77d*UW5W$xcD z@MQmA7$batq1MH==7Cjs-+U7N1n#}L0-t+MC8HI5v7Nrc`#9nS%@-OJcnx^M zyjFV0j`@0t)7&2bdxo~k^NAp<)y=257yMlLd4>3^C;z9p&(gLprFv+`v*x8Hnsf#8 z^l(3+rvjO9sN1jgW9tlP0ACo)HHqG*IoG0gvRU%M*oRDSi%x7i zC$_-@Yt{$+Bm0ltvNctT|MI`l1ISxi`n8#MQSjOa&DQciTN6S5pajKYC2mSA-Z_{he>F#^)@$B%^ zng?_5V6OYoV@zLdiceKNQg1$ow5MmkURM}!}0^_V@#il*|L zkQK$GzRno(MWnZid?4{;g!g=X1Kl4{ezan=Azt0PaMkJVIpWn4JtV)gL=Rcpq~BI@ z5y+pd!w!b;@AmcsbqDtZ$sN3?HHv@dJn1;fD;N=W{XHhJvw9cUB2hZ8r0eq4Dvyro9@DR_pu}*&wALO+@Jb(SFA1B9# zlaoPAWlY`cu3&R)|yVl}~er-&V1d;m8HH z%wou%&JV>Wkc$>yE?STa#ahl}%)lO*Y@j!UPuiWOb)(G>7;Y8}=n~~iD4r`kwt^G! zKm0W|Q&Z+1f4;M65}%u;2W4kczQ2iHP;6-(e3M^0KzGA^65OXOHdh(MIkYFIJs7PM zetD|r0|sMnVC+@EqV=WVH+_#j`ZM@1m9_k}B z3@@~;qivBtpB%L>+g|P}?ppFXPIak&Rb@zrcR|nR#Z` z`tw=KUAlq%+yuWrhD`j$;jCC$`a-=!T)GxqN_Tvi_c|T}U%Q=jW_lUzWP2iOI(+$} zR&6@R?~>6S$f4{~OZ51*CA=GtKQ)Q?=bElZksJO~Kh3jkFF0w@G5aX*C68|tFG!wj z&dsyjKQZS%+Q^^GHNg|~uY8r3dw5DxZU;e6|m*{ zJlnV`kkJ>&!%U7U@&z-r>!4jEgah6fxlRu2mk#Le=q!H}`d{w!8orz}-1DW^FHd;3 zBXNdX{R#LB_#ce${9)F2sWELG^slwd^zF#VCnD*N&5^Y6cJdhaeGl?%3(vMj(%CHw z^7&e|BfMBk1|BikpN9?)lQ*<=L3(HViEXXzAoa%yNevW`A)r5VQLdih zMYx#CSuy6et$j?J?8$w6mfaJaqT^@PpW5NSL}(kPWev2n->T{FRN$y)f1%8qr#(Lv zIOc%cw>rI-(68p*6&>kj+QH320OlSVnb)mNO$__KCjUKe)=|EX+Fap3$JMX6_4;fHnUc->Dj;< z_(97P#robk)9s;lgW@=0eP0he9kcZ;AD7;{khT%{!{|wS<$XG%XKoM=XVDp5;Bn?d z4tvNG8(R1iUTeSReZNO~zuu2?3BNl;PfriGti7_~NniJCp0OR!VusWEUSLq1g&bOs zhwAZ>=(lzvbyJ{|d}`?ekC)GMf3JD;Eoxj%?DRU}lm5*opC5|a8j&Nmai3&Fp|Md{1=9`hCM<&Uy20Nv*V@)7?YKDhPlZki zxL9y}3-E;Y2JlhObnoMtVEQZhh(^B;fmi4!A1RYM#yxYPvq|yw&(JP2BhmYM>FD<2 z{nb}w53641UaLN%A$z#~b?(o9(vLl)?TvyndDQnDu*+pQBo)14> zw|8M;w)+V1iyozW4q55uq~rc-ffs|gK=X+f^33G#XpR3^bKmAfki!BWUdA0`rsGn? zlc4Jr*CUJLowR(ga9)`1gDJ;PQVr>FZlY*MOd#D7^hvwFB+-T2z4HQ|?VauZ5}l_Q zs;xsZv)zAlftR)~(N^_UCjzhXBfrHwY|ojq$HYw2T4Mlvx|X%BbiDS#@_W$H!aB#+ z_RLXpFT;l$^Q-AQl4q^bx`OqF-IM>OW>sjufg!>Q=$f zvX_)Fcb|9%xJ>o~*s6c<069^>R1}jy4$pR9D1pgzjrN9DO>m{7Z9kI#TWg>bxLF3D zC^nwgMdU!<`!}zSpXewbGdxazYdGazok>4M>$j9>R!&TW``zhZV~H=FX2<=?TYP)V zeApMVToNbaxGg*v|7!m)27Oek=Q?zbbXF-Z3Ln~&>Vmgl9M9PW^muST^~^ZBc(lXX zs&MbgI`~k0H$i+C;wQlt(Cut;yOhVf_7?0m{b$IntdhUPIJNW{=7GcNr9`=7yOSI^ z=JQM5&j8b1oHeT6d%7F*&-g|;+uXG0Z1+9y@#o)(>`Vt%&ZomOjW<%mNw&)7&Tj`e z=cZUhIA&*il+5)MU}ddmHY_^>xrFx=OIx8nz@g}_wZ7%6A?Nzuf&8L-ZEdbPSCThy zZNKT=CmBy`chw&V*O)m?xMNS|De+dSC?CX=LvV@2SJk=Gp|`P4nj zRVE9SUTz9JDLo>69YH2uIEmbup2YnvzP>P@=3J*ye%XqDLyz%Uc%+^}qhn|03-VWj z7oAt2Z)&nI-<{VeXS9$PJ6K!E5@!pFaafG=qQ2F>>USmW!HJi5kM}nImOftYu1TZw%BlA>1|NGI`E9=ZAEh4<=w<$_su zRix1N)J6ohAIJ&QsD(djxf~%hd?~s@;6}Swhh>ph3}jN_1v_X9+)Y zpX4T#vtA+xTDxhi9B?ZRh+g3yaP$Ui-XoDWe~f&vFR=%*hI--M@Y>Flcjtlj z0CZMwXLL%>GR{zSV_$csru4`+UOL*pA0f(~Bk#*sQEfZL&N>)JdkpM@?OK?bFS*ZPe(`+<#2j|rQ#HrU?Df_w+8gPjAMMN3-nP1@_O_XpPpCPVUXY)J9~gt5 z<+qzX<+oVGTQ_Cv3%*N7y${d&Iqtlm79O&rzO`=me6m7*M!24~d|K8N9=^f7#`*lCV?_2P<@N2cdE`KsNa>3WtUlxBGe`Ahf_2F9C`@v&|cKB?-=+$uk}bq!*mPt(&2`;TnCJTc zAIx*XQJCi&j$ocggO)?`E4&l%Yy2q8@7Q2|N#y_>M-Bja0L1XMrZL^VCDJ&~+us~Z zT)eSmq%-Fn$rL)@)&^_YS5;gEebz@?EN>MW6$X+DzqJM*Q# z@OzSK+JJO!=a>#+N=corZ47p}V?>ZRp+{?YDcMJzeO1 zp33v7I4*5sp>JMKy`nWn{7`VE|$5o?$rYZfg!DaB1 z{{(z>(yPE%zd3Gb864x|>du5GgYPt470$If_;}$MfqXr!^M?0a>uj&awCsbui=QUn z|6V`d)Q5kl*stt?e9k)jcdgg;Ifw2xov(EQ{;J&xg`aA9B)$y@$L!Qc%zSEmS^VF; z-Zx(xzjFrlNr?Zc=DO*2;(qA%jn&N!?$6eHxKh4hZMppNX+3qoU^OVYE6TGm(|h9d zALDo1C&I7&wS3d$QJ$x*6JDKpezs(u-kJKsT)j0@e1mGD+r3Q8(d;^O;+*xM_cK|I zcIbQqd+SB(`;r(2eBk*4@X_1ZI~g=?L7TB~)ffG=4b@Kw{0hj-|F^)YZB-FIVS!GO6{Ja(#@iokn-hkI})< zP-jHN$bK{yg$p1M+ zh)$7=ukvF~%B9dbQQ@tDoGbE+$7&sC`=H>nzC4H#PO}){`q44&@0-r&3gU*-d){_i zZBL9^UtZj7332l|ZTQ)-zcRaIy#`A<)kat2kn}%4KdIJj%;%Ajiedoo=Up?b9xcSS&_9 z?7P5EW_zM{H2oF%vr%v&9tmU|S_gVfKDOZsnKwA)x56jJAJ|yo#pD}ZV9%hDa?B3H z3%rz}z3^i4&F8Ey+p4wJik;X0gfH9jJr3vn#l&HJUTAXM&v2$*a}CS4;!e8n`fTxn zl^+em!?!S@>`2BS;qR*Zjk}4qA8mv{tQPqb)Sf(;GV5vl{3PFM8{- z5_&`bygm@U`_tz>y_=!8@)(taBzn&*_iA1x#>6&sp$kO!;9e%_2+^F}t=@_ejNOk; zSACl&$el5|%dSYTr>5HZ%HKivM{VBM_N>snlc6=+|00hsg6>C)$6KgrF%Xa6uDQbF z6aT?9j=l;s{@8f~)A&4}#?K?;{q9e;hxV)CG3DIJk8$8V}kPgEsZ5>*kQi+ z402br_8sPXpMS$EdvEXjSN7g_sR>sEXDa0P$%h3OCim!la(BG5SHql(>dh7PJV=(5AG;&ufU84i$@t9@eIi^G800*-F!q=uho?+Ij$7>a1#jPpw;J z4|SiP{FYc~9eki%XQdn6cTxM^+Mp}<)6Uif=tKBPvZpp zH)VHmcXrCVgQg`J*=J)W&T)_7Ji497|Kx>^dK}#Oo+ITQwPIk zJ-#aD-+iO_12(Q|B6nL*PgZ9Ys4tU_kSq5$+UhLl+28Ej{5A6X6ssUUlh#>6)+lY; zlH~t&=&U>WqVUcxwFkxya9_Rt9QV7II-8(4RJ%e6;51|D7v(?TB96lfwtjtfT)s$zPm|4^Dn)W;*|=xvBM;e7e=j5rdCPX>nNI zA3SMvhPNWuJMIhLDv`N%$&+_?iDFrjp&%Ea3jALW?3|Cw*D74k`rSFL(S6ddg|#2t zEzF$bo)6tc7YDlNOx70Ov+v-gWjpc#94mxxP~?_wR`(ZlCl4TJ^o`q z?VF>&n%fL-HAg=?clmpP6MTw4fAig(>18a*cd;D`@uYMp z_?Ca%$r*0FFWs&>R4aKu$=~zf#?IeJC+aL|sK&1Jy>zAanJ=ZS=4fYVnMd7Ie5Xd! z6wwbJ{WSKlzcUH?twUp=GlU`fl;iB~?H2=0VG{GR%eKEA}K&AitL-yIa+VH2ev;cxTxg$MWj1^i@%n9v%*q#`N0{=NaKbeByA% z?nLBPI#cnvGVpT`pE(QJ`a;~-LGXiYk@VjukW5s@4#<5yRAAQqI+55GnXt}`|!KrGf#$T`)A?=I!9WH*G}FsUIV{+cLeXA&%6J_ zeo4S<-Mq{B_VL&F`0GVh!||5Bf_9e#Q*Nki1y_WkWn#MMm z-sH2+)jIHr>E*S^&N}T!adx0H+9*6e4qs*7@AOUv-j=m18djg}%xQ_BW8ep9PR;z# z894vDV70S+&tM;~rp<2eJOKB@@tJ3r^6abrvrBpQRn?!OhF|KWHmxIdPL$mFX9c6; zG6C)01?_Ymvtzlh=a$27%lJ#t-$(dMQJXWU`S20XUuvG=tXKfN9WP+p*CcwAm(WL& zKJN3zSIxv+l0Le5|9S8HY4-UA={UGw0`7^WtdV|M?E74~Zs6tjTp#VDW$BH7ra$br zY`4yaX2GFzm*Wc09qZ_~GufDpR=Aw)JK@m3cy!Dr7f3H!&9K}eJ|~wpK8S3pRxA4K z1Y$f5tGNH!x#4y0YTxd5mOFc!nqTkYzwW)K`CRvO=tO?zj4c$eO3mPx5MGE#nOJx$s2ilEyA@6~>eLP62CzIkYrUWAg?799L7!T||F> z(b-J#e6{0#i`a|el*%6no_$Tv@bjk$h8oY0E!D{%hyf>hSbM0(f0N-ce9LBhvmMlk zYN0;UY1lAgVrl!G^G4bI9vu3fX^*<60E=jj{&S1sviRWAC3i#rjT#4AWPPyaySL1F zB=}6c>*YO%`c~w>$DNng2lWJKtF_mjzZQPabaLM3&Io=+bl+PK^ljGND_PoQnkwAv z@(A^r{o-f1ItG>wbWIf3?Z*3_p2qb@N>JvQ%MB z)f|0x?zZ+QJn7EOMn`$?IrH4N2k-IB-b>7L7xSL%b%6UzgnN7h@h9~ykU!4o@0Q*g z>COeVI*nUdz|Ugg2m2*>Y=4BjyW9qR32LFRkN47Q{$kZt)cl#ylii?PlJnuUc(?Ok z9$t1IoY0W1DfeJ(4#C(&{$dpR@>@{L>_POG?48d4s~*~0=zC#Rm8ah_^W0YEvWK~3 z)~dz;bq8(+Z&CgWZ;A8V)AI8_#+(28^V}Hke1mtIsb@Ny{>2xcCw^nTD>9j1_ka_B zjflT6y={7L$vP06$foO_0G(xi55J2)wD#{4=3(&1TtZ|+oWx8uRvm^S$o+S^*7GmCrR zJ@X-_^vp%SBU^K9@6jLoGRD-I%m9`LQ*Y|o9=*8jYM#q~H=Rg*#_ijq`*x?s^i%=o zHpW^=-wWy6>|*9Tcl^6qm-=|P-^YVw-__iqA$RA!ELs0(P}4$srn3zHjXK5KBm3Uh zi7$lyaq9NW+ZLtH8}~e@-kZfOjQ8fdAO9nAbz(y6+P&55=euvCHbQA_w*ZD)LNL4w z7_#leYw#E3FBZ!bb{rcS$kcjtk!sY72AL*uQErB2@W({~e4#pmsvjsHJCKbV1s}W! zd@ohw-z@~b-3O;NWVe@j=VmnzsU&c0FLp*i|%bfW`}J~vk}HN_mj!AnlF<%4p z^$YInR2_ow8do|?`cOXdoG){aFn;lsjGK|(TJN}r{v>%0pTU%l@y;g;pB_%UHOVJG z*thxgJ>nbasJWJ`TlFlM-$J!xT&*LtmX|;IqK#FEM_OCr2+Hj%T}K4_`JZ7<(hF*< zv;B%WTm&ApF6~BF2KRd?rdEY-v~A@2OYm8E9^9v**vM#d5q$otAy$LlYyQTxhHdTB z+A=o-CpJ3^&SMLRv0>-?&{=)xsy_6L?o84BD7shWhrgvQ>-mmZ)ZH2HoU zj#|I|l2NB}w(3g0U&~*F+J)uB1`<54b1Yx!MfAq#sXVLp&R|uJ<~e%t#Th(5o#$ur zy`Jy0_7=JuJL!s3xs!Y!pRbQLY7On&wPCLO`1p;$bz`(KTubL(US{IQ!nnqyZa8%JG{Y^=D3XQz;V25fm z)lTEb_uT(ZMP|irY7DFjilqWciVC`xsF|AeRU>6}EK$2~ zl%le4EYf8FOY>4prGWeO8Qxv~$d>~P{N z=%{mNli<2-rD8Sa;Z1dV$Z7W^!M$q(c((I=27H)B9|dOHYQ_f8-^Tz$y2E^*z~-TA zWef+3kp8?tJ z*f6h+HNX}eLtAi>-1ZTx*gkx+^UOwQX#553xpq9`QPSIEk$vrZm?1C2dRzAWjJWlU z-aC!W-i@vktsW(hB_A-4zlpuKbR~wEeSpn*5)07REzrdHEcjRnbl1+&;KlE8e(n73 z;kTOKt^5+B8#67)!TC^)zC6rF z9uoK;fjn%iaEBldBgj{z7r-~ZQ%)mTPa`>415VqnaC+8&mvS|c`gqU2&rtu#;6I1H z03DK@?0a;GGliT3wjtOn+uQ#%Gslz7bPK-yS7`68oHc}iM5P;4IhKkKu2% zFYaeKQ(60LicdN_gYl1^t`&}v(KXP?>A=TmALQQrl9#_uK7M**lCw87FDeXA-`9q&x>^n9ods&^jp2QAFrME8tt+ADkGe@D(cBJTD0(~KGPbkJ7}9TTTF)*t9hxsSFM z!d-bI?VYsk$VgAuln8yL!o6HDrT_yU&(Jeb`a__fy2l?5?UIH^bOW)gfGu;`%7psazK}H))JaeIk7_<5Wiwo2^C%MECDr zCSAzbnZdg`x@8=)bZ!CqJ>No|Gk{0E+ZadNJ~f`&aa26@BI7&Hktcfb?~&AwzDR2O z{EF0$Bd7IjCqJ?y8tK_ij_8i)Dc$Rc4|WXWp4`YC(NnoUwR^{Y+#jg>+1$_W-Z7T@ z>Ii2@IwQATUe#1{#KvPPa%e5W7)DZ>75MXrj8;0(Ua<+ySPj-`YWUjPB-{Xqz zPImgb7Tx|d-=@(E+A~ONCFB=sy{`7AUP}&_GmnU^Mt27`;!(;Q-GrPQUA1$xr$ZBa z=jqT#muO5Ux@9GDbTxf3dF$zoF?6cYo3!!nCQolBS6k0phPZC<{2ux-dUg!orkjVj z50T$8zH{9WH^qMrDLgPuzi*>_RR32Ga zdFI9GIe$SuaMo8iueoU{^-J$;xV*a2tRDDzsLEt^Sg&%3%@!1R`a`+UxMG|{F=v5N1Xi@;4|^m z^qQzUo;8WWS9((CYni>`6W9p!^5e{FknPVjb3c#y{K`Wk`?eZ}))U|`Yw2d-%B*q~loWp^gShsUbn18PmAtR?(CDcJAA`PjH{${ukKf%g3;HJY%irV^?ki@5ep6@@H~1Cg+V# zH#lE@Mq_52Go=%HglQn0yCwr~5~x{F&&%NH@pa$Bqcbk@I~l17jlJ z*YQIqcA*nxSDqJ)vB0SIVXzRlh_oZUk6#UFo44Z<#ol>7;{LcQ@gb=Z?TBdKk}DpK2r zO=F+0X+7JYa(cEsi_YGU`vbY}|hj&#T<0 zkpGiaK4HOuKY!-jSp(-*>v`Q^_t(Mm)gJsmW8c!z!EVH_S2^~-;Ads@96j|k<$rI= z400df|3>{sM~*^%*YQ8O6J3yvUn<;}_*V5Qez^{tt#d#+cxG&4ROj1{WKV$o*7UX$ zEBgI#D}7pi>mu#1^wncr#m0*F%you?jqvoT##A!&=PqoMY|_$TjVSs+V`gdJ*Vsy# zd4s9j*Efo1g`>u-73YS}MI^p+hY}}tH8T%t67ovy6ysj|>8H^{nhVqLNoV;x_&&q^ z=3wtc>}G5lF%$JG*QK&L2TuNua9G7S!|nJxIv39y{~Y9w{Q`Pm82V?phx60WO86<4 zxR5;chk;|C{bJ_M4X4e+_@T2D(;M5uH|iHGeAqvenAW5`!lHzlt}l&`xMi*dFBhWj~`{!7f+y!#{N8+V$Zo|#unh0kzM3o z@+!IQx2_T18~LJ7a9Sq*QS0o8V$a2)b&UyYoWPd_Gp5LQW}Fe6l@qdMgBatk=?;B_ z^P2TMrnNv(Pku~4p&in38jr2wU99TiZ)!ebTVerwt>(rJ`gWu4p>JW`+qds#yPFH- zruX)6_ZNX?kvf)0Baav2z#;MUKq>Rs4tV8&*%i@A2n| zw@+wHKF)n7KaJa&oMd(PQ-i%Ym-9r1V#k{BsA)1W*>=$ps?)rO>9?M+sh_@yI8sS?s5gYQ0HxHdr`Z%DlTg{F}*>`}G?9>Ej*r`K^8MLG8h=DCeeq2x9)3LYcs)z(&Qe zgN)VBHT2d!K1Gdv&v<9xV;Da{J`4T2{@QwT?nnN!Mdq32@n^E-lIEH59kS0d=9!t3 z6M7~*aD_1^dujM5iMP(CLT*bULB{osK9#C;wSLI=xkL z5Gl~UNP+f63bZd$pnd+ce@6TAfUwXQK72ck$ZT|GC#r&56h_ z@3mDqtNiC)dzB|F^!(qa&9PtA`+UuBvz`}fvz`}fvz`}fbME;+qs@-Z*K+k)ZGC@z zR_nDjPoLF#?ab3>wO$+Z^qK#xU;EnS3l3#37sgi6V`hH1?5S*UM-D_^3f3Njh8RR(@L7uT|y4Ms&QA%)q8SikS@T$t7SP1?;J` z??_-z@nLTq>^23kd!OAbybUd}QT92B;kDfVhv~UY^nByWqV!xQ`LO9(&m4#b{Wev) zdHYQFr{}U-==ppA`_0kQ)bGpZvVW0$ntE*cEZXM&@@eRq4BFgZK6jg*ZUFn=m!2u| zSShQ&?>0T>25sK7VZC5m{e8FTc}xJi_t~X_ZTdTT^D_JUzd}Bn7(;5>lk{|gHgDR{ zDA;?Fo?Tb?x}^8nm|z=vaz4+$5k159Xt(9_j-bu|g#P~30Cu_k-H%nW>xn%(PaB5> zc79px9R3?S@5g6l*!euWU%<|H5l_}fJUcJjZ0>tkZk{CD++ys$sn_I7mHU+aFJ?=y z^eDsA zQ*v5VM*lyiY43mRIht+*md*Q;G{p~P>@;P3C-OVDmm{b6V_ARH&m&~z($6F0-G}8= zYuJ%Za_8QC@5-2wQ_ny3zI%)EJ^!k_*jttF`B$|s zJg%&mlRmDsN6*cZt{TgHSk@dd&b6BPzu#e=uCYbuD28UvhwRcS{+l@hQ7?wZ?qqe= zWMWog=$asA*1d8zGsSJC`$CO9K7E=CR$*ab%P5jMRZ}xr! zl{C&DJkvTL$%*zy?3mJj z?nqm-V#{W&A2Pb+pl-oPCb;ShF#SULmbK9_%z+(a=D;!^w|kS;xGjviH+~x5BU-^e zHNZj_X`Z9OX9m|Iu(r*?N1lhCnuU*iGJ8}A*2b9oAHcHrB{p;Brm`mubDp1LZnKFo zn(^J{OlVx)GJ$cc360q{Fm4;*VDdMdV`Fp}x-_c~>8CMUAL`!Lhq@2zL*`0n^&xsK zs}BRc7U)A?uWbzUq5U0tZE2tnrN>k!`p@)ZU$4c{b4DN9-^u?B^r5fUHlqK`cY$6* z&l!Dae}`V1IU--L)r9n#gI+WG(Ed*PWIz8qWJ`Fq?(f&9d-UPLQNBL3z7tIuKcBk= zd1GEadjc@89DC8lUbTy^UK6Vy&t5>0!!9y9aekyJvmnx>{a^Z*?J8JS^G^De?JZo^ z&}P1wSCrD2Yi5D18><@^jc}%Xz9789cxrxSxpi;dg|fddRQBq^ZF*!E%5ExDwg|0$ zybER5?LyhMT`2q2T_`KvSS~M?>_XX3Q#LaMxt$kjGP-L{q{-K%=2;*2My}>eYwDOc z%^$1J?WNw<63OXObfnRx_-@7Y-&*D@X}*j0A=VnrK$pJ%a&&1+B&SR9_?O#@!`Ad7szkKDg zJ?CHk5m;~Q#J6K^J2rni=I$Sxza4Xv$0?9I*h4Y*qXlB_yNJ2(DkJ88R5AA*mu7Y1 zqXlB_g0Kq2+>a{e{syq#q)znPVf;jc$J=>G{Pef;kZiMy>6&%b2P z>BPCfdRr&H?I*tNC)(p$Z~KY=;<1_C(}^|g%lpYaq!U}K+~L4t{f_plO0f=@Up!Ur zdY&seOCWDOPkU&+3*!zQ>`7p+clkJHW#oMmWv)Y%a@3#Enjgkg*kdQPb%VbKh_Pzz zcbi=U#283++(%=%8nX@N%==@#HTFH@K5_rvjMuVvL02@++?{$e2Q>fLBJ<45na`F> znr9}S%s$JQXJ*cP=$Z1#tm#hmMg8?&w6EMXS~vYow6WG`i7(Jl^htwrxqNxxwGfTN zd=b5YYxCu_zlOeftLA9mu#qD zj{2!1SjVb0r0l=1^Zhv2Pv>o&EcCbD#!x)8m#<+#{`^qGs2kopfX9ZQQZD-9zOL~~gq>|+s`l;qrm_73*=GLW~ zy@MF5-Jx?6CIN?WlFTIfxWBG^slPXFCu5cw`U87f_GoVx*5db69m=nP{bAn8^$4!- z{I)4LAnZli>?vn_9b<2M?gxg3~je~xm7Im(XND{0P}H!hjI27lA!O*b8SMj5#jhcvHdk%WKgTr3ANG203eIMp8Tv-PO^p*fj6Ek! zz1ezzpU2mr{_yjBeP#CJVjq^ZU6eCC^JQ1`H|x3SCl2^(?fpt~rbNE}>f<}Dzt74O z_4a?}+W^WMn$S1Q-U##;b5EZ!_t*l_=P-UT#w{jAhHj5NxpEBq<8P0xT6qSxBbDi6 zZ7$=|^%c(h%-9}vOWz;Hehu5|*yBRiiR`gZ6;BQ2=fk-u_EiJtqF60(ps)7v%COgs z{u`aXKx=h#Yj_P_;G1n%BDV%z^NsaB&D;U526ydWCLV8vex0=MIohTC8;w*{m)b0w4Jh>DBHVu zHZpxGXP6E$vh|fuXz$yzyAPKSk&x|=u(vIGk-8oB2;RI|Hl@gZzQRvB1X{)5XMM2d zntHXjMi=jN26Cjsac|wrJ6A?HReA3?=bm#(c*ndH_t|sNDZG=7RQU&P_V()0J_9!% z?(KISJ{w5yuH&8BXVdWvwJC9S_v`4yjnM_aQ~yzWvV0ERn=)40O`Ck4iwEr43EeCj zwC8*z*|!up1l*i-VLH%JVdq&?~$#3oIT@8$mZBIZW(TsX?l+sJc==k0n9Ju-uwDeTM9ub-H2 zq8>f9x1e-ntg3kZn(xxD{Z^Hj=j`nsq_*;0`;AL4%Z6TCo4?nBkparqP&T~Rh|vMi z_08&lfCeS|e?Wtkp}ueRMbSXIOl>f@AQS4Jd3`a&f9?6yuP-dz+kN~(Pwqq)&%eMn z(Z`2z{?sSb~Gi-vx{eH6I%dfU_SRUYD3!TwkB^ONik zrn%Sh2Y@qjsrJQUuU5r)X0JEPMr!XcvnM?Dep%(zUT~Bx(Jy#+gWf?G(L&FqFSKri zee{S0u*Xx_mz_BA2b@{>;*n0zj@6N-?RCTm6CL)Bg_e`?Q$FgSCDnUkBzw_6bp6cvV+_ zA1>PtsQxcg?qc2_@vFWS+LO%QV@qw&er9H!IK0t*QrhpzvqKZQA9};1sW+e0vrUHy zKm1EGAKs5QHV60-7wA0s%cxVly+F3Jk26X8;!Vg)_AEr&Ge!F*&<9NHV`OjR-tIsC z7oO2Z=5XCr>+Iv+eL-aTOyrESW18Nj^1w0s4Mv?*cE0zV@n+A}zj4lGCW@S36WUtF zH@0zRN0PSa900ZNUiPpN?IaJ{&nt#r*1S>4w6XV5-=~9S?AK;=o@CJM|HWSV_4Jun zp`Tq|=QhCi)Hc7|8p~d38j&npmz`G7;p*D%$$7|2s z$vzH~*{_eieU|=XNOqq*weNU+OP@1%qT>Sm`_yOrb!)PWH0voZq+s&7&rc;q0DN#;2#<-b27ia033CoRp7WuW&$q4Gzd`(9VbS9KYH;$7fFZa03_t zk5K z7i}A7Y>nUl+577zc<=3d2;MO(UNHVT&qOCbURXAL;Xdx#MaCcRIflJf?RKN@mpQz@ zkN0*wY}$wJ+voaFJWMPZyvxtcSTy20^(WDPyJ7>QGbePPX44<}yIJ?xA6q|ufc`4m zD1O||x2lUg73ZuuO;50Qs$|5(V!WSBS@B0_mNXjQz}vqC|9`#qPnA8uR+zpXC$H%o z?d{R7^N5I}mQCNdkGmi3wrS3JM~~Stbj(?DOH6COh{v+=v~3^oYYg9`)!nZr)ZbfY zZ)cA@@k_F-b{s8P@bQpby-2wZp36_Eh%C9gg>R1Kw^`poEBOKPIX3(KP%vyek-?rT z#xeO4+8MnQfa7QXQF?!36az_$(!%KH2&f8UrB$*VV1;!oqZ+51FdXWZ4~u8h3bA@9&{*gKr%+gkGd z5rZExa`2b^e9=_LeIMntAE|s%<3Az)vcx6zMvgv z8Jf@r#j1+ql;6@Ge|>*?Kb5ZyBLgqTrzfMwrZ=JoKKB4F8vx=cSe)#8tM#uU5 z8Q0BOk?68`&!Lx59vXDDXl#b^;H-M3Kg~EzbUbU_#uIDf&x3yuN18Z*_O`WGueW0) z+lS25IcERUw1?YceUr6kIDYu$V;hb9o-^EClNjs41UJr{?cOT>Apf^h@9oqpTF~EE zwx7;*glP7gUwn; z3|_LW(orVYYW1JIeV5=t7xdTKVC|8$iEnG$hPyX>JhEJ8&1CyCysSP5ncSDX(tr5H z$1d3Chp(==*pqhkk$uQ>NAvdV z?gVfAQ*+MC)+yxKkJ6spTK_n@d#0|ebu{Oxsh#-NtO;~uk?8go$6NcRJdO6&*BTAyCer%K=2@p{dc^Z1jeqoY&MAs|y4drN_H}>t zv4|OO*-CqKcD-bykMI8YN&0*gc+@T1lX;fB0%MelH^MPt4QGRBUt-1P+oQ-Fu-0L# zWGm&bOo})?hgH(wtGqs6e~$d%8N@mjrtiqd--zBfdCf#_%%%%{#IMI(!Nc_bnh|d1 zJ0YL7J?9Tdhly{xigz_myjS)b{ncyQ+&aRIATxP>0d@AZp!?6~cM-o!_+8HL3Vw_E z;hRGLroI{cX7M|jAAA1xvFC5!e0~eayU>2&N=~&Gnh}b{d!3_e_i-XlZ+6^s57vX9 z*^3?Sji{dY_OHht_qxjLvulm_SpKbOYwTbhwiLPIW6_fm#b{qXf*4WCGi!I>r{+$2$_$NiVfn*7-|FejI6XQB7){Nik9p!$h`xxIb zcEBEtltXuCCi4z`UjXke)PP-Ybx)Bm+BY3%|2c;{O7Vw z!fO`S`+;*Za88bUc&WZHUUMk-1Io>#+&tb3FO5Chc&YvKDJQ%ZP;LS5sXxdqym83} z#!ESiSbYUv+yTvZK=WbHcDqIM!?Ycm(aTG!iYKo#7F*)0sm>c?2yDkDw*zj1}jR zXe(L-v<>QqmIg0qnXO+uf_4TkXqUxTc(rgZ9-U8p=f{il=pxFAN0(6U63P|h(dCpA zURO}=3d#jCve?IK3GZ#0PWX7;;6D#!`eyDW(_aD3SAbJYrf;R(Cd#d#+zQGSlj++i zC%jftZYAXcdad^H`abV%nO^7Pb*KM4km-B47rlN0oSy(kcomZApHWWq+CaGtlq*KB zcFGB_Ur_EBlndzfD<7}l^4_M`<33)$_n!y!dWw6|>sjDD3!Gx~>ZF|L^#bKypj=6A{_}udoGoPN#rb!cA=8S}i!-APy*M{2!}&%< z>BYHF1~1Nr%5d(H(E*(0^u{Ir*}u(JTz4S4e=L4PHEobyn9Ti=+)ojX@>9L@$YTf5 zzDUK*G5l@$t+AST&muoprL$3Vc8u(QRjtYy-OaUXyYy}cewD^O{+c2tq8xtSTAiU$ zK~Cn~h*Wt{!$<1Ag1Enuou6!g%f@!`7ZK9)G~|&$c(?Cz^9YC?7nN zPoy{}rg7fkvSY+Jl$XBWU-${Pao}d@%Z21rh4WDAsEVkbp}(xcXW^V)G!-xt{0pPmRwrT(HeI+ttp5zlX5q$f%8K-&WptlWPQ7&N0ssCRIlg_y?&|q&!OHf>Q$SR7cj>1&+hLo#QK%=Tt6p8ZmiZ&Ix}k@RQUI`Hd>~9|M(xb{}DGYVb`-n3VnWM>SsN z!5^q>ONp|EuH+7sWASniz=Sroe>q^8P_A2eDF%~0Uh4PbK42D#oj(5s+O0a({}cSri?0gQaRPO`&d;u60yCdOI!SVr z-VeJ0&gL6uKlhQx&`-#k&S_6GzdA|0nyI5)Vv{*vI6ZvhF*TK(tyhOUj&SG?GdWwu zi*5T7!mEloh>ZDSUpddki%p{w81J3XeH{3;`r`nyr-40l&WXB{$d?+~!-goQQ|~w< zv2BeE9_sAilsUtbIy)w+51z1wy8L)LvSq=(&T?;#NPf1jhBXy)xTGrs%NoVh0*;lD54?-%ap zt~tl+_iLDUqIoY};3j*c`VR*`trJ(jKhT%ydrjUvmkUuI#LQzQK2aWI6?R{Fkk#zl zRkJYa{?C_WW6)vXs+=vpNXm;DospS#r|AmOl(+`pZGYvXhf}Baj<)9^867rJ z$LP4DL>Ir01asw#9^1H|TMvy4y(ss_aKYAGxm?cUdD^S-!Rtc#f59_)37NeXUW@`S zjbmKRbtd=9Yt>)9@wSGmo!h#Q^O&FCF}4wTZm2YNar?RGO8E>%AJ**e{u;W;z6$0z zV2RI~yAaH05UvI1EF}8d$gzB%XUE#D+~;KYfZwRODFF{e|0|)t+NE>omHRZZ939j6 zUo)W%J|^R-`|I70pw%4yi%0N-7}rUe@k8rgxOO4)#J?#&hq(R~$?p{L7JCNm4u29E zMTS%sUSro=AB&QU5Bi{<%g>bm7- zH@>A@p>lZAKjO^@pEv1td^~iNc$4XLnhqClpo8fXygL!N${&aAdK%h%!OSOuX0%!O znE7V3X)E%-=AFRtb%E+=1P=X>{%|~b2ygy+t-*Cc_XEN;DqOuYr0o8%;&Wtoj; zy9fOV`a*N{jUPjUxZe+yGyVzp#@|(6aM&Mn;>s~)$$E1x9#Y;*^j96?jdTB{k1HQ4 zxn2o;<#@>lm@%C|=b1hT|L{w76#mL4a4|Zz+8$Hz7LhL}F6YL1z)WXlLz%lUS zqueb%{QR-7oXv+{DfJn}O{;+SL1+x`Qtx>_lp{sA_b#qre8U@a%6rM|%K+#dcmtbgFaD832GtG=O<1$gydXc!yhjUDIdr{-L|B7PF* zrli+3UR>|Anf=wdPViw&ASL*cwOEBepN{vjO6Nz=fxI8`<=b%i zrOHXlo}zmnb=pR`bCGT3kTt#_9gI#ecxn!KaE?H3jZSVpvGS^_*Eu?VOV(fWpT&ND z=`Bw0DUXMrXYZqfS$lB+YcCFDz8QOE(&r7&=-+8{>2uh~=j+hb$Xh4!qy6EO`!(|t z89y}Rh;z6$kA+6q3Z5lf`0*^svvun46OmQglx&`#JAXLp&+$u)H)GV&(;etv>00S( zjZI39yJ(l{=t7Ulj%@(0L;Tp4sBkrom7dgk3^BaP#R-RWC;pRk(ca#;^Dx;nbaw~v zw?flS`1Txq?|JybIwW`7JWns0*q!@KvwXb?-Ep0}eRRG5ybb^6^T>ntP=66Pnv-bjX4w(T=jg5{9$nIzwYBiT-v!Swkz>U?51 z>zq-d&N)Ts&t97F(Hf&EK>sJhWt#2zEM9&=V+_tY*aYm18JFIjGR%1#5RP3Ygfsq^ zk;Pww_p%f1dDFReiZ>s{pVWM=uv`Y;-^=%{CBDBT_`W?~e%k6Be9h|aQ_#1PC$mQ{ z{fqu|uY45sCv^BLao?Vrd+nF&ugzAzP-m-N$DDjKpB;Ge0qzGM=}Osc#`=0@abBXi zr!BP~mcPEBTWwYyd2+||V+X+X%H72E$Pliq(apnkl!fbGna3?$Zz+Q7tKeez(wvK} z<?P&(M9X$$1=}>_ZvVcM zdu&t(Hfj{llAXvpy!-ZY^a0+O=GEhCN@oT-4)|4^8agWTd;M;^Ji(7eAYI6qCj?HtlKA6pDQ~qIOdFK z$}?$b#kYVhe#@^t1G=SoZ|=>UbSocFgqDMVF?Kg;`MnS=@BLAKT0Y3LfR?j-S~}D% zT0Th|3emDDM9bHx=jt+P`5Vek+yz=bL-~(ew1n0rXi0yZ>dnhFeVe|Sl|TAsUfk>cb;V^y1~~Ipe3XyFxMP#%q8NGLzhYzdx%K#HpNpHww~gsKPtV;7?vsd_+N^n< zKjWG4`zRy+Or~wpc_*L;JpGWWe(|_c%%11SH;HqcRoq9GM9*AC+U$S2Q>QcX*%43{@T{H7HC@*?kB-jfbw}LqY z!b^Vl??2JE;++r3&e=Y0&NsJlJE2V6h(`y+ts#WlhkV>#Pv&r21#YMI!_D)*Z5t?m zy!`u|-c;ML5hh2a9J%0#=Bx0Dwch%V=ORvuyHQL1e>h1&KQ_jcR9P@-*HDXX?k7@%#x<~-<9ZAffLDu!*VhBQ zLi$kghvI+>Df@By|3H4N$}Xbp$>FkLy#=18-$27g%0G1+eiypM@F)Roq3iGbHnp|L z_anwfoQq=W84SD3Q;27XGvy#qM1eSZd4u7Bn9ll+0Z=wF^66w*}|J{m9iNdbJm zTmYZbe0)A$0H2;WJbZpx0-t*VeA;vVuE|sRI@`nm5!NElqTPW^(nhB;-ct#l5$G(N zWc*#qX^d|qWpnM%t(#F9efMYZdIo+6_y5Cv3o`c?{PNady_H|KlXr^ACA-7?Tws8Fp_l@BC%L2IGY2o_ZIv>}B_*}`DjW^d_ zGNbv?r&|0n^GlJfF62)63(4M3fF)V-=7d*wH;VqsyO2MQX$=qSRk?cWRe2_RI`sa& z6*J{~Ak)ef82ihA-uN!eyIt_EY^{xsk2V#_%ICp1$qb&gsX}9_TOIbt*i@$D1Ph)o><^(>RPX z6|-Z^cTIN=boZH0PJno1DrGkYz8d%X)*)ud@?nG1bQo}?173CeRy;=;=FS@%-KjAo zcrD*37*mk%805YG3t5W$ur#N*p8qDkpkK^Co^>BwHD;Cp_SR^`>;uy^k+w~8dOkcg z2UFu3iZzsHQ+`Q#kP*W3??a~Onck_b%$xG}_{qf@rv0xCb7OY<7ar*T=8XKjhitdE z_7r@evzeO*tsUmP-K%zEhr;cKM)~8f?c$Yp4!8KDSWaV|Y1R*<>);#h^74!x-z57R zST`(rPjcJeVP@OOrP(ox=eC1Vqf?w`>8r90&}crwaWvX$MFMdM=Hqe*s8yk*?e zHEY*YH)Gfun@BTnN8{MiXNm8t@8-s6>^!AiXU#6w$sQfX9v7&~PT>ghtN%_s4KA zUgyO(iAwVD`;&(sg*~Zsx2+~OP2ckJDC}VcuTFR$a5bw7B5POU)2Kb_5jBSyPmd@JByG~@P;u!X4nueT;=hT1N3G8Q~ zGKpUvou26D+2=StW}kr0cvHRKZexqkFLoU>Sp%SbGMESN$vtqkQP&!5)6hd{*F-Ou z-Zh80J4eu-YyazLozLK0dmZt}L;Mm)4?Po+ixU;VWY z`OoRKkC2m}i!H45<~Z=(pek>SUV60VILFxCde=#5eJOm*&N+~dR6X4>{6C9co9U0z z*`n14(G%j`U--6@pXz?&bdOHXaqRzt&7kf*%F93ac1XOeev`cHocY%B@`FWq8QDc% zy6{H1xN>><`4YVR6}gfyFK@JXd8pxK&c~Cz(l~7ue(+(~qqY3k_>^J>hw-97FFMz5 zIy%jK##6tmyg^{A&Tfna>tduo(6{JWGxmkfFmpu&Td-FCv~PvvO>5=tT#Ckkc%M=^ z+uu`OUF$`Jd%eNEu&?+uG%Y7i z{O@qN_FP;cS~&2~@GOcgi=x~1M(3e#JIr^vSRp*_Q|!A3!{1@w?YRkoPb$AmIxT7M z-GQA@-b^+nc%GEM!}Gw$)PKpU!V>Y2unW8`o*`Z-*U#Tx$+SdD52_p+f4%wOqhk2Gn| zj-kMoe{RMbfH{+TG{5Z*V4L#9XPOJ6vVG9Ttb?Q6^_0`v*GHb_`^n&bB=%*pS+noi z*dx91Wclli%N;2EdKZ5P`{eE4k;b0SAL=Zh%JZ-fEB{gL{a?Yx?=WrK4><*vzPnKV zKR$_`cTS{D$d7Q1fa6whd!O;2zzP1DZ->D*co)oLN+!mmMu?0?=`$jvOh~O zmq~mu^QPLTHAUEC>fI^keo}9;^USPXOYS_gF@w*n{93pz;$a)}p)?2T4dxLEA3f6^ zAo5**NuO`4i)^{n*8vkh8d8S~1WGXVkn4S}iJL%ra``2inh*S2qW1?l#+eW)L z&O^__(`%4{4(c}Zt>8x>FE!ND!5l2(TU+vRtavyJUYv}4%rgDl&`EO%7F9Y^nxIGe zWO#`DwzBg6)tucP$^YD2^ymlU3J z13Zxn#aRKKet^>C-_=pYTEwt-tMAUHXdF!z4ajXM715+-iQupOK|S0 zB|qpb6Dzf`F4&2Wc860|@3kI;HNidFt5e^8hyK^b91_iq3uG_v+w?)MW4@hl)+g&* zBfE(T)}D_67BWs>(7CYM*GK%<+6~E)>Em3@GsTVkpQb)Q-_@EG$*a9rl;kz2+vLCL zOGi?s7aDAZFPbl|d4JmTOt6?M($h6xzN+I&kKF(bV`>+^@&e`=*9v}gv}fBBqm6A> zo7dCkLh&8vH#2XAI-q-IhSQ_H+{LpOK1F%-x7scFQe8Jum-M=xbwrU9`my+S0DRLk z?Vodo=oUSl*u?*Kc)aH|`jKtJ1b3i066(L9ITFjJJ7e6j;y?BN_aXT)z2Q0U-@$ui zXp8BS)RVP2iT-w=pABfwx6i=v^EZk`y*Uf!iY{Z0%Cg#pW87o@Alqlz>Siks1PxSP z@wKBpj_bIm4+JtdGl+qpQ5Vm(rbcT(wZ2^Qk61g}Q#Fcrv_oraV*E#6{7626=5Z-c zBi?Jg#PA7O=|E2GF&8uU51p&HXXYrrLzc7;h2~hj2EPp5HjZ(B2i=Suwd23B|CsWa zVf|UdoaU{kY20N&_mciR?M27;epos*QT+yE^>5_U#h(A+?Pn4rm)^ina}d+^o;Csh z4E+Z#8~7UPdk%IX%X5pL@8{!jOpazT0mAl)>7Q(lRU36IVRhm z$d@YT@P_$`9t^7wSo9Nb(zNrTkCmXGU*@G{#moH9 zEydsc4S&CwKOWTo;cpZ#GYnmq_q|0+lQ*O9q!&2u&%r_b3uyTOeI`sx>83n-7NErz z_E*lUuRxje!t}EEA{->U_?zyI`8mFPQ@YW}coDwXFphHE!=A`}d-FiwS~LmEm-5`o zH+}akL!VSYpZ_RKpHv=wO2EC`mpSR1mIAQy;8<<5<#z1fb8VbGP#cTD*x!ONZ6Fwl zT)z)w@7)^)=J$m2LS-zv*fMz0IOQXAGB~~fUHZvjs9lcMKGLt#iaD#|Cg%L<0@ZKz z;Ue{a{=WP^&}-~LZX8~^pkMtKtYWfr;F``La`h))D-|VtuOx-1FXr=pvec zU(XfYIhlC~JWBSdf!cqWH~ZD2;Cq!T0iig1L0@Us8#C? zYsR{FFy50#2dhjVv$vJdQR1Ou^g~W44ksux?Zko0TzksEWnL^{CxvrRA3jAB{*|j3 zs#G%X6@6Ot3k^|R%Nj%1#^ctTy@VxL?2`L^6>JV@`p z$~(KCeR;0rE8a5+KH77t)W;p>JN=BcT?65JPKh!h+GKstq`!XFhAsS_0G63QV71xE z1o$i^9#jk%(DUc4os^$$x6|aaeR|&;T3b2CZ|ln+DcsfvO0*;3%@XM~Htg;>o?Lyk2(8zZpmiX7A7u>BmXCh&Swqf>oJg@UK^u-e zzc|l=GDon7(?D=0mBJ@q2Moyjb-*%mUV!&|8(J3k$))=)W-MVKe9tJM`wWet-yazl z8VJsxPae3g=5RdFoXe-GF2w<9;=T^zKCPLT9t_6x6@SHpdFA+(8XI=@W=}D$#G7Uw z*d}rdiWlwNs{Q>&zSsDo?GO9=U>Ah<1KrTCA?H zeJx~b0(-fcaX|S2g=p8_J8*lFWt908`|uaTHE7R&mQjZB^#S25U>rEFE=%?TpW(eB z8>t*(DI0km?`=DFyf1T|MdW|@9`pysl&}ehFlRlltp)VZ2g3fa6&qyb1l=p&L^eco zmtqkw{@U#1*7+!Yaw1cIXL5Sj{c4j7$c?YbPaDHCjnCTpRs+`MWqgH-1&_j}y1bwia zxaah+{8}=;t9-w&1mD-s2Byam!gH;+AD4{Q7{l$9%RpzJXtQ95ft=HebK01RTpBH+w!x zHd&Jvmdz-BSvkDYUi<^$b3++E!7}Dr7lX5_e0b#zf3CeHn*ohqBiE2uPkU}`$*xD^ z5S7@xnBr*U_l||d{ju-z&bEz9PVsctIBcZtQ`qpcv?mr0)BX`;r5xJ-umoN0y2`N! zpMsCY;;t2}H_M}$Mb}-e=dPfhJ?!V73oZ7fpRaYPfuFw?dodKA=J66c>kua4arE*mNmd1sJ+Z-$}bbbGl2E!pXY4q;l%jG;sKqLW$T2sK8`br;5fRBe8KbK z@qcmvBh*!`+Gw&Yt<99=ZP}PXdUA~{bO|* zKEO@W2ZpncIX~I5-R*__*W^RFeinzf#rhd@3-kKfqaX5g!Ntf*Avt+C+>Rpke~vXX zf$uz?Z;I8gxr}x{zss-xLq+Ppy9`=CQ^q(!Z3$fv@cuVt@Z;jOec$-+y0c~`j^`n^6lO4z7Jqt{-G#-PFHuSdUnFM2-``PwkmE(SbyhBm@ z!29!fZ}Vi9&y!3@cNo0mj{CWA`wH}TDuA=}*bv{W zcI~Pj(%vk^^w5nZWI9|=Dc@*+>M*{MOq6ZrApW}Z=A z|Fz^GsO#VodFTMnLE*8k0&q^cY~Z@?2$$J|{2t-UP{8lj68yfj3_hI&yn$%9@c80o z!ty_m&!AjWv3^$0*va&^;@JX`)*^mn2DdQTbcxH-(f-Su}z<@>u|1O9ILyCHbj0Wa`(kEY$MR@#_8KbiN#Y8UV3FP_Zux;MMJf60PdHha`$L%LYme+~L1!$}MlyXczKT~}= zN3(F*fDXsE=la>9CHfg>l;rtGQJ)8G;8G0sV-MzFA6EkQ2Sc!Toapu88wI-n?>-mi zZwWf=eO8DL1>ju?ZS!=S1<%k4y%e4=|99jk@bxccK4mdq{{r6Ge7MhV+w3B3s~n(h z0pFCX-xs_C9L}Ph#c((+M1$o%4o4K=T`?VS1b%w)J{oZwOfDoRZ;Smp4k}W|QgXIA z9m5&n1L%P9Asx`-!*@dPi}5RF(dr%ajbgN_4)vQ8{CfVR{#HPC&Mm{I9{Wmh`Lb=j zc=|01&!3jS^PUi%)jpm-FM{X2W#IY3%ON}qwD&gV!RPfAt4!eYT~kI~gUTp#33F6R z)?*?$b_j+;q1ZA392QK4=@o6ud^Z3lp-2ABr zOX%32g>>w#>}8?32JbE+FWTQEkM^PZ#p@4X_p^O?n&ZBPZ(LMI@P3E!opUZMMwy?!I;ht;D!Kw=P>{8`B}ko3o7vy;j`k8^Jr&7Q`;^K`x5*&q=(?_Rm_! zeIEa_sE@(>XN-TEvs0)0y!@+Vw*Vem*Yvr+San?MxCfN#)4bm&)TiTq9nXaNM?lx_ ze|KQIPCYtAGmGB=oCl4ZT74(fo}k?PM{{z2(W}Mf{yg5dV3{w6+XR{o|r?$@|43ZM61Ha=@k{!%{t(~7{C{7r^NL0@g<f=8CQQ>d(kHE*i*yIST{$Z5~`bPCH2kvLe&*tg(P(L&EEp*&- zsW0H$eAej}i#z7>&Thwh{dR1jzuD~waJ)F|-xO%akP>s=0ysZn57NAL6oB*RGU|Gl z(G#WV`rl0h*L5KL9|)iSTs<(H5l0OS=ip~Ta0>A7^|3iU`R$+&24k{}I|pOQD{LJ` zzZ#CuSX6F#Kg7f%IUTV6I8VoXqzJzgrhIAoo*Cvrs67GC9n0@sNdGA&lc$IJ)>nKS z4;PNRn+p&5kvcbEPx_@zmS5_C??P}7+xCDSAG$x+M}Jr%H}L&XZeW&Aj|_daP#;x3 zyO=B+{eWKY4(I&}^uG#Yk1XC7;p?~mkZZ>_WT6;eJB^&=WN)&geXQ5Nt`6Z4 z_Oq*;mmkym(hU2FCyT5(e;NB-DyFE$PYdX@7k+jzK2(PC;Xm< zTkN;@pd#&coC@~>{Ml@qH&wV3{QH7!;Qax1`BhQ(P(RmEusrX_TQaS)iHh+`@p(x26wo0@ejsG) ztTN#~Fi=cAcL4dHWa-l>C20EY5KUii_WJ1ZB5g<mF_<1+M@7zKiGe7%CD+)PvrZe`iA$@d2iEpFQ2w+ z&^v|nO|%S|IVJ4-6rkIzkPJMJ&M1l}GVn}Dr)-_>>69hHvjBXjE#f}=l4WB$BJKa0R8`fiRDY{!dq;aLvA1Do1%2% z{iaZU;BcRA4}(V`xjw<>3*Y~uluYw}L!L}CHi%zT6rXb#8|1yM4`Mz(-xWTI{&T4| zObN-7Rc2THj~AguvAK+q_ZP1xuzAmf`Li3i^bwa7!{teMoY#M>dIDVLy{~wgesp1v zuu^f!?dXGIxUBr&P(AJW`St)G#d#Xv4D`=ybBg;$FY?Zofg7fIy7rKe3|}SO#7HG=QxPU_HM+*SCIzPgpGOSZi`eIXiim-;NK3+7bAmPy98+ z*Qj-tLNI6gP3+~;af;yPuc>{SXNA_(-T;o;`}B}7jzRtS%jK+1`)nW&mW&7VTXapR zodwFQdq*fvETQW@X35ox5^}XHBv&W-{OAhzcf|zw0OysEnSf_Ehk0hTB`9-xSgx!x z0Zk4lA;W=gxVj8{%2~hrYFIxPz~`wm2IkR=M-E(u&8G&`Rg+CWvFt?weEy>peZsQI zo*$)bY?StgZEQ%#54e}#8G)T{&$q$i)pC3j+n;_K{ulMbraA5tMm}=akEK5s(?k24{-1;YWgq^eLUK>O?y)e>ir{eWPjdAqw->K}74K|) z{zNK#BGwtb)t?!SgkwI_Y_+sxnfV&mAyl<))WWi7PG_Z9$?cuzKR6Xs3 z$36Aj$6~ZE;h6Kiz}Gu(Ksfm@?KTKsGnd`R*@>|KJ9NsG$?essHktOZSL06lqHaAAB+{Qv zA4kUM)3->bbZ_KII;O(k&rCAJ`+jnS%=D8Z;{P7rchw>%Pi*vc%&y7_Hqe~q#F>J|M`pjb zoP8_xuCVR94L__L+jQ&EZ_f60l?i|Gn}q-EGU2xj1ixMM@z2g3?xZwdS?6u&oM4?N zwbkg5ip$!Az3w?nG#073td6zzoNwfv(TzUXhyOZfG&U`MS=4_wQ}&H_%$?bPssCT! zEb{*wTZ0bLdE#BzCXM5__-pueo-}8@r7poo$?#w2*Xyh*^qA2@@`ZKofy#97Ts}!l zrE^zH+c>xFn8@-@Y?$;@J$31<*bdH3JCFC*$H%$gZsy{4_+|3W^|kfUFlYcxI1_LE zs^$sn7x8^8GTs#~^_&}qK3pFg%(c0CeSOTi?DpVHvKGe;%Icgd;UGF}u5gw;%&%cn zr8_jG_O{M$s6Z^>W@Bvcbmy#c$^7`+$E9L9<6lX(meukyn6Ok?0A|q4jiCk%) z`Gz*i|B>%AQ?!}L|4B~IBK@B%o7G?565u8|Qrl!-bT;WW(Kg~VG0!3^do7%GC)=bl zDtn?|Zs^s6=bSjg8F}Kz#=Pe+zKM-;M#jz={hs9{-$jCk#kP0qWs0t^J9Mj+=C+|!PVEN3n9 z{u$~a?zpdl|7tt=4dd%3{dqkxiqER8t#N*<*b)^H}A^vA>CtUe(A$DEgM ziB&!P%_Qg5y^im?kbdQ)$Gfl5uT;k->aF3-G0m&*U3@C%44%T7m4i&orL!+HiHO?; z4I7|g*OA~34ZEgrMW4vOPPgM{fQ8`3$=9{C+a6O;3}$>6^3A!@=g@IhET7Avgvzu!}#^dH|;z9ho9=r0Ueyzj2kp&+Jm9@sYz^_+l;-Qy&p_99$n%qZ}XDvJ%&a-A>@+ahT@@zk+w+()i zOL-&N;%vEsvPsUjZmV;)Xn&&%xzDFtjtAj-KDG<>oE56)lyE(kFR1;MOkD}^2Vb3w ze4^?a4*u;t8_ToRtc_5c4&)i_#+JO1gw|@c9-AgYH$pG!`T^s>da0qZkW`goR zxp3FYFE0Tt-bonWM_3_vVFmo^w+DBoeLVV z+fDSxh>I7PeXW zE$r=&I{erwi5@~OmpN_K?q8DLnRxP1Rfaa2Gjca`7P#V)Hp&_rq^sTA8zUyZly9jR zQ}ro6Rh=2?l)q`<#%tVt@fEZWU50YX;clNqJUE&2+OVaERpMJ#$%f|6QXkW#G264d ze-iRB(2GXSTZM1yR9C)%_N4xLiTxqrpNS(3F6g%R(Dy3HtK2(2G#;DRry^^~GwWy0 z7>TH!O`J1T#yo|y!ef7yTnG4@oU&+4-aEsb{)|5kAv&gcu5+EwK}M8Ez`pi;4V=Zl zJiQf_{fLqah2F@Q!IwBmXWe6my1U2)tN{+Xk~1p3cIfQhfDVUVpuV3QL+>j9&wTIG z&*8kwn%35%%~_YoZUCn)3@1cK`Guli1?RLxW6*JxBmXFKQ}s;v>6z`Lya7Gb51*tz{)7Ma*lnOsze;=TV{gu)T{ntm%c%^Gx5_l-w9y@H^Ws2mQvuKc(-^UVC*& zeiF*BSK*&jxMnYcI{1MumhYx{58pcoKa5y68Fk*Tc1j0jI^#{ZBX?`i?chCB8-_RlJwn z*IIRHtU>vlp^>3O*Cr-3%4hCEepGiR;WWKILB2Hie4}?~pyQQqY$KoH^@(xDe#oCx zTv8vw7EtaK%JnWjftcPKYxtF^|4dWA+A4kWG5BYXX$iM`Z|++myOxZeWzL!Zo$isr zWP*G8w`jKn9l4h0;;+GrXO}(Kw?ggeCx_rA+hW=d{lBH}Xpf<{ATO4H0&Z8^0F%w>>e*1a2+^*f{#y{0Jw#uib9?|py!@u&cW{3KE)hfU)TFOZ2EgdWAZPC_t9#P_vHF!Mm!l-tP-nr$d4e4&@dL*lBjdv zP`>ShXDZ`R=HkD2_CtNprek`w$>A=Gfg8EbY~C_i;k>+tU%wnKeaiSD_(gs2*x-6C54Ltf`O1_fA_YF?f&?>&KMy4e5#bVptX#@Gs zJ=%sxf%mU&!w>dw8@}-tv|;%kZbR!^(1v+?xDCy3K^ykp!)1+hZMzxDH+pCP|hzNCCF z`7?c2cz!_!zoQSoqn>$|Npd2x^R3E7EF5NXxW;eBx6s%*an9@~e@dRFJAMoC-@V@W zTW*}77a!?__pk><-w*>walYszAKF~;>uQ0ac;{pH_pNwE@mE4PdO2g^htJs4cN;hn zx8>o1FXzGX@VF<3hlva2<75^%O=fNbIHdWnwxsc8G7CKa34d}nzDkPm#-T}ks4#A7 z--|z^edCB<9Or%dUj7x|jm*pkr+H43VkP{ol0k-m71|IYh-vW3wU-RMdOnxTtZq9>e@z1nPJd}*#$F{YP zU*B=LbSn-ACk9cLP84s`S-e&gJIgVJ+ zAB#%lVxyow`3B0ql_2d1;J&aHW0 zDDL+4N-+OOa~j&9!zyqRKC7rtF`s0M92E4U4d8#6<_B>v-Jo}!Y8SXZ$NL!bYl68y zcOfI&c&>R2$`fpZ_L?`V{L+b>MXxzHnm-_#368!`6K{9c<-_?laP%HOf_Xc@;{6VI zw4J;O=YaJvFQn%%+FrkReq3(Pb3|5Yo7bic^MPV#jf3aRDH?0$6m`d($o3nf-rNi2 z+%lcyLo{!Qa$VrrK)EjP^yVQ{cc<%!n~^P}yO6JVq|v zB1ibI*s1xsVzKoq0mhU9oVnukrZ#lg8ZIW5sZTk9v5qEAB$qZ)U9pxEXA4ft8#uu&hBW8GQiUwQ#I$&p;z>s%FOif z4E85@U=QFKdFr2!=kfs0eA|XmZ}f5Rnk>3|xz8?gHG$uzIV{_VFSV{i zer=@ETjN?GzcTQzu;q%M@eNW&U$dw0R^s^1qwwX&_V+{TeP7Y zjaj2(#+%96X^oC)3-e%X`t>dz#GDduZms-X!I2G$F%OCVreCqf-uSs*zLvZS{Y^2d z`gD*}IhJ+9*E@&0+ILv>C`X_&e!P_DljZd@)#2Ak4CejE?wb1PKg#c!`oS$*zwgf# zs=vvvU%8UN?+b7k_D1pfy#@#B`&oEiMAXWEfUA51l~vrS{HATQGFGgZq5e+vi)hK2 zC(CM2sc@J2^fI(sc&Iz-Z|Dee%4R<~=w-^qbLHOAm2Xz;mKR4`zE!c91zqdq z0KmuK4bGLC4^2D5aZ6ra!EU$JX1l#Xf0Az?Dhn-@_aqZPT^M`C?3#;SS0tH@~>pD;h81AU%DjVQ7+~W zWH68!lZS=}L#ERPzT2ts3a)0{q5!TzEJc1YB^X(}wJ(I9qX_uGe65thk^EHfE{4t2 z`mBQQb_4&LKGh$8HEpxoEdVd=(Kyyo+^s>@U2=|SE+o9`o7i*_DynF8{_Ox?)$DJzZmN&8&WC@EZ%D^ zq3J($oW(?beZ$VNjme$!8#lvKvp$kK>i^Ma#(U(OczM(@-BseV1w($g<_mRA1lLKF zBlk+K$IRz7a-jLC{#ciIuK8~2lQDmd&!O08^%Lo(Y1(6f`AjeTg?{*KuK#zCL*81) z{9y9`x0CPQNIqNd4gSvtvUL*kyP~=L!Vqu(Zh%A92B=R$Tj}(0Kg{ZX?1}jDfA@R! zZGUZu+M)h|{AiA8(BGt^wcch(oi}cgCVyBHVg4NQRA1%f`H1%XSbeWCC+T^uqX^(A zPC-wFU{_X0DfP_=gB>(Zz3p2pW?U#y&WG-o?@1>PM#yhHX7ys!K4_I>m49<|$h zy$|mV&L(`5?ahOCsShs`hL_0k*T|`_|BU{C&+_#&=602zi?w45`h({S!55A5XpN@k z_&;83ZW|GTkFjp1%_U7xVA1jvH&_1rQswR$3 zh@*e-+t8ae_yw0qS0K|GA3A;{vW_l^p@$pL@v%`WzgZv91KpHH$0$#R9%3JcL)|f) zhu#29L_dvVb@6^}biA2sJqbO#jyi)evZ@B=Sidvgq&hWT^d0Jr@vX)s-~W8y3eNxT zKj#&=u|wlc!d+vS@?&oQy|YDUvx%?LZ}Q1CCYOm0^~UxYLkn`h`c7r+`MXaY&ivu_ zGaHxs@t)?neg-~iuI5XfUR$PRVRq3*o#U5jtwvL^qC{XNBeWAmpt z{JCNHVO+rBzK@0Ga9Z*X4n1we#fqIbbdGP7j3v3gtHoba(~Mjp6DM&m8j1(W&a)b4 zMm=ApP>dXm5elB@&DfQ(yN3SQrETy@dNE6P%GojWHPBqK^H|ZD`bRLwS@CBfI?GRf zH@Fq2G2`+YKa586){vMnUuZqad|R5=LnX=?d7|u=GRizrqD)ZVhBC@LI8Yhp`~RSfGIxc` zKwr}yb0T_snDZ@(m&SwdeA|YLC&2El!i+4SY#jW5M6$Tg9)^ji2Mcrvq%6 z$k~U0zk(^9xPiGirq6Jlyp%oyuN9M+ezNoI#{Wm&yT?aWoqgYXGK5S(RF2`CK&%8& zQBji=FaxPVrIt#tD5yl!3N7}w-e{?!B`_XnZ7W-8Q?+*;z}`-Rty-v(Y6XmKskYW= ztybGQA-0_aTSYv~XmQ@}Z|%Kj_6z~U!}EFG_x)o&Gqd+PT`v(*nTk`7!7w?}Se)I}5kl&8pJ;po197neNa&*rG*^`CI zPalUIMJ&7Q&yF1Z=dwr0>EN6nO}8E19X-o^4i2XRCu-ljGba!7UU!!w3n})1w;FiM3+(67(LXceRvsvww;Sc`nO{NNgZ&%Y zd(S+3OQNlhJ!h2zzxqwohM9}^Pi~F$PsVD4?c>jO&elaY1lzyboo?drCAiFOGk)*3 zsfo6bP3%1tzqxU0d=qQAw>C~qZ!Bm{Z|YOs96USORU5z-wNJ5DUO%NdGAG$JJCL$g zTJ}2n@O7GRU@B!xoSnK}N zl=!^HsXqFZu5UR_b31t<)>CtkkaAR_dPMxyg-@bCa)Z;{SvCe;)tOOTO|D z|F>GH?lvpcRfqg+RNkByrdW+PPl*K=BolwIQp1Na=Dv>YSFE>VMi?0zQ3c=IvKjxm z>``;RspA-Ar7hUL03I{41YbV&r*zX<;70Sf)fxMLLGntCC0Jnp*|STvuC78Sc92D^)yyGQ)e;xU_ad=;Tq`#bgKYlc{ga1Vj;;i^{K|2_A&HxIg4T1?dG4JK?Atwpw(#TmS zJu`Y4ysGRERMzGD*fR@QAFwKamIsSj8-wqq7ZMEx_PPFc+Mo|OiX*RywrSP!N&o#V zM>oaV7F31$S(V*Aw6}9%xRsc0inYg-<^`R@3?5qz%%$W(a`{Sa%nVtTQ-NE&5HClE zMTloiW)3-v`pe<(@}ND--3u1y_j{}dsvYr72k=INz1qd=o<18ZK85EK!Cv;OpLNQ1 z@H<+I&LfwUVyeR^qc&pb6Fq-{a-y+*-}GF%>9;+MDTa)O6w@IVvBUNERlgbeHS?d9 z+onX(5z*jnQ)0+%EI2$l?__9+J(E8)*lm9)?LSKUf?qVD%}Qg38Oxcx6FrPvfOqp8 z+P=!!K9gG)eeaiF#TFD#k2VC_n2m+bzPc23go z&VYSkSGtM2GvUh%lHb5)ieK(~c0WER@E5QLXF7Xuit=N)wT0x>8HjBiL@cUMIV{YX z@=kboC$i)1yWF~GII_;Clg~4JHVvOXN+}N57l+ zEg8gibWN~QviS+*YbJiy(Ucc$|A9Wcm%c9r-VXW`ZzRxTrY&&p;pDZG9D5e)oL{xN zuT{DJVDM7613l)4mpP^bFYlhEeggjiJ~pxeJ}Q+vRkW7;D~_iz3Emj*glo}Wb>ijN z%^8euL!f=bH`7gn^6DvmYw$vOwWqoD-sN+#UvNgX`SX-@ z*!yVk2U8NY1$J2b-n&(~CB^9Tse5V25n-CP5=Z)|!V^>P-p&9Bl} z=Ky1D6Szz}dmj?D0dwB52AGr!D_)LV;TO)-@6LezRpj$@en*4nC!^Tkc&BAAgLY=F z7nCdwK$eiFwq7Q$gZ$K;%dFN;X!$a{-7yQ9Le|$ejHucWA?CUi+%)i+#HWr=tt$^a zAKwIjgI{C6md>wA^tUR>af`il`4d}g^#2U#gwLV2;GtSU6TaouzJ>G`vdwYYd-LG>Bp+zU%p%_WF~jEqih#7Fe! zuTcMcm)9X5bDGoWVyzP_hPPvrz~5^Qe@4p-+SBOyYlI8z)s^^jG5zigv=7C$eoen? z3)%-a3?Q#+!{=n#JYqZ}OJhdMfx zImZO6^4!3_a?BZUlb@w?4&|57$4kzhfW%AqGE3y61@X}a$cG#d3l6OM!r}1QBBx!w zJNp^tJVxH~bn~Kc&Rj-i*Q;)WGnWy(@fG3JiIH^E@xT>tKtJ)UgJ+50r>dn-IA5}+ zq48+DWTj*G;!DG+`!ueFu8v;_>>WY-v+#q_&%BT6{Q~#>0_Xiu@yk5-**vp;Xx8@B z-Vas2&YfTJ{|EYyO?espm8f&-zxNK$W;yj$=Vq0e;kJj}>B0_;zl!m4K7{o-V}CW? zVHz*CPjffLNc5}_e7&N!(f^uz-S`%55sMN0nqSX!eZ+0br*Y&Mdxx7(gL6`ArpJ%A zTaFH_ReNeHp!O%@3#wv8R^_R}Q6!HivEAaAA3;yyMmXH7@nWwHuCT`;#)7|ezsWyadFSv7 zmmc!NyLewwjUPk3S5LtDo`8)yF*UBI22%Y{)Q{Cd2Y0^gwOd0O@rCBrKmLm|XH>ZzuQ>eZz55aGE>e5JVEYor z#9d4!349yT?o1!;zAG9W;lhF+61s{#4u#f<+Siou(a!KGdz=&_tGLQ)y;I{V^3g6j z&OQ~|XCDbE6OjV@Tg=G~fA5)JwU~Iw*A2~c`I~*x znR(MC8l&fjcH;9B^Kjxhxpm}&Duz;{^6+y2zVHtFCNTyZO|?wSD4`);3B4F z$APJX=at`bV?18pF6?KxyklT~#_?m@4UY2qhp$2b=7iq)9{n$*Pk2o8UNi5cKk=4m z?Zr#}Bs@b;#orSfg7#@AxcQkThEt0o4t?c^YL0IB8@QB@xP!7@8^59r-5n<%Rr6vu z&YkUZLqX1djaKy;jpaoyP8Tr<{x0zV%anQ}+jsElb zNAT=;G2L_%Fx>MA+6JeO@&87bm(Eas8v=FR|zhEbYZ{ei{ z85$=Z3F=I=V){-V9KBmp2Y$efDNDV_h)qjJ>{FI8{!#m?kQI$VF=4HP1p0+j%-_vi zTK@%yRm@qd8=SFf{o-EQGh~(UK1O${i45i$3JP!{WCt1=oY^b zm`eh!X0Mt4;{zs&0<9*WzWy)c|J%-aGgIFEFWh%63-b=W=hiXLElmEZre+6TRJ#(&E zmwV9g=Wv&ohnxAoXc*rBoaLvR81I$LDKs9@HQuJ@XPD=Iqb)tp&`R;Mg^b}h?ihZ< z81N;{yqGq-pc{NJAOYRfmg?TB@dta`P4M+ves4RSMW69SPJfAKoUwL20gfBasP^zC*o8aM8eh1N@Rx|3 zQEhNpQ&fG9##QIysg7}BuXnil4^;Ooy`Sy+;{5-<{?D>f$^|O>FT5^x*Mb%Qi`Nv{ zHzS!(YF)vtB?tA($ybJ7PLoxSXFXF5D#Yy&z#<*!!x8q4t`+s>`p zfzLIJexk^LqeC6OONb6N=sVUhq(3aqqmuK^w)%meJ>aA-Ymys^?3-PFjMo<0$9ATh z6hk!qp$igs2W@NjAIw@>B2Ar5_yc);y~+Orl~>;x9z$k!uc`u*nV)`o6+57qm+d@C9T{EYc=ioh*aC|h6M<)tbl@4wd z|LML4nVN`9EplnGh_)xnrm{91>0cF#oL#kFy|8I$RSWCB&6H`ur;=V^&RHcr5NAw& zy|A7CiXF!`om*8w9Ql66m`|U4y>Queu3n&@D1KZlGRNdK+eup){Gr3g&Snhb7{few z4D%QR_TA&}9KAqWkBDB8VEZlDx0``OzQ55iz#XOS%)A1f)>STCj7VxsSLj`-%a=Si z`?8oj?(|}`wZV2ni)|J5!dsnuu;S0_`+#$NH0cVh`HX>HW32J^;pKs~-&5ZsTv~a4 zK03#*ACChM#%{PecBbGcap5T8z65X2tKJ8KcJXbFjaU3p<0@Vmv~>m|bUFP5GI4Wf zFS-0MH!ksHDed+Jk4HGL=GZr2Q_S0}bsa%FgVmQ~OHH0zWJ|U39p&@N7p(~J4Q?t5`3A2Q#oCja-9Mg=DBjMx)$+mQ?JP8F z&58qP9wA>;YeJU)&W^Ux569Xr+#=a8bJquqy|`km@r9MIb1b|xy?(rX%UJxd$-h!?I)S$?$3iWwh6O=}FLP0<=OWXnn-t=kazI zys9;`9fw(!4LmnKOF845?ek&W|B_9~4Z0sc#933*97by}T@(0V8x;RHI;pn6zUxH# zbmdC9ImD0H7sH=VvQ{cyS6sZBc7>;S#|(ik8A8fD<~5bY`U{OWV=8Z59n_9B~iR5n`qLghoM?g@*j3y+w#!Kvm8 zCQi;HVUM5!FLX7_yc%cRUJf@#za;In?0hb{Yn<5mZP3uH z5h2r>JHG428MKbm!GA;R_%Ze$i8HJJ`wh>ZpFBo9PnrK_9L{{>F?CcT-tV+=hun!RnKlMge;lniU%oc8cnnEf=IjZmI<4jXTa^8fPSHE2ZPCdS^dufb#yi`u@tjeaZgIi669g{-0?R>oDi4_t&OZ zmYlkZi@dM0p(F80{Q3Lc7=?p_Y;I)i*;7_{f?a{G^^e2r^HAFvlGl!MpBUSyJSy;< z##|O~a;c2(ul3gAZ!yPvsv$bTet1T@=?-LX%mMDYIF$C9Xs@!T_G+#6FmpR^-<-y+ zwQz6D<{Mcv-`GxYWbSX1KSNpfoNP~Ra9;r96O3KdH{aB^sIT*5{<^ZAAFBUIHil;4 zM6c*uXG(Q$M0ua5v;Qp75Uv)^7YD4(Mt2k$zS!O9?XS>(a;txZu6cRpIliFxT>Zv5i{g)+`SXMUH+L3T zEn8im(!}%SYl6G2d$`AC5BIm+$v$fImE_QiduosS4f6BQUmc7?`MTuW6oc!oJHyq* zW2|b$;jX1je%>!b8+<68%hq`}<>Kx#z7gNFAv&hFTGd@!!_{4kP5!NEmvNYO@m~V? zE}W}!_@T|=A+;kMnDc40BiyGSB4@@!4vrGEQvrNB^I8FXoT+i@2LtdReDhJlWZDe1 z=&nW0sbqT+@}{!@=)^hKCs%`pyS_9QcBU_+YL0`mJer&+vidSbQa%-3cGu2|s`{&C3iw zfFr{X{g`X_r(O;{KStaG>)z!B&3hyG6u>6Akbj^#SRHcv?G4ymc+KJr#~${@il#gD zAKhBR-ou=;SO;h;Oj}p^+KT73HHQE1x2@x7>k?mEzt3wc!T!ki+tzT}s`It=i@dfL zAHo=mXe;b%>+Za^>_dR-jf!;BalW?J=C#%T5a8;dt>M15R_3*3A3|GC(pHhLt^dkv z>#Rd)>mk~DV||vtw13^tU%x+uwqmr^;cIJVUR!}g9#7Qn=Zwz(*PqMok>}1LrkL>? zYIUdP5yVfpFO@yk+9Rtwe|B>2tTWiFC4p|xyq|a<`U=}pYo*q+wznZTY|GX?!&o~X zp47VIy2$Y4%DbT_duz>HgnHk3+`GGqJ5{lf#7dmKiueYxr6a4?=b#T>v--- z(cKLp?zWj(;N};WE+*&4F8b}>5U$=;skNuZIcEm?_UYz~0(A#;bq1>AkzaG}rg4hm z+r`*v`CsIBb?(dc*Pr04-w>z{QJ=lG)$tN+;t|-qN!Y}4bn6lBeUIhXK-yE>sDd^% zj=jr2N8DmIdt!STTfStp=1L2ap)!2xkB~+~^JsLz3m*@SzW)CM8Wp%Sy0cXCS*tyb zZ=}7YUD$o&3*x&OUtau;T*bKq)Z%P?#=b{#&o9g0R-AZe#FaJLOTUDVg>R|7r5*6l z`4713+LZk_=i;=tM{#<|SE9~pHG6@1=iQByl^>rh?MJTq=FzMend(BOGV&A1%1^h( z7717PDRkB@y*o=3QyFrM%Oc&O)=F+PQm%XW)!{x@L`wJ1JRb%e%|b zHxAF2D`pO7`swGKao^$MLw@ICSNCW=N&hvcuzJWPTjt9JVe~EKiUYardhp9vy!dxc zyeDV;KG@`AY93_ZV%r6ukf0H*2VCv=GEvKXAMjC3j0nB z<-P{%SbG%mHxL}DtmT$f98zo9h94u&-9`R*pZ2+IeZsl-F@Nt&8SCi}j&x#wbJ3YS z%Jlomc_=&`q}&I6pOF=pCk;*feYItAE?j%-AR_PPIRZ_I7FK& zBm6gmQ^~&Uv0@VYm2p398NcSh(m86w!-Mc?^awODdW7~&42!;`lXO?6CH>h(df^ndQ-N8dZvH)pd*h~%B!;z+%6c^W!U>-pN+4p{I~h-t|qQ2 zTKe^t&VA*V3$b3Wv%!+3v67{7%l;`kPqMUPg!8|J|AsD;xJ!5mI5GD``*I#Z6KLJq zSp;3mp$qwbcS4^|aGc*?X1wJ0#YVSwfiKPtR(oSekQZ3{F^*x5(=FUWukp~!D`#ky zhr=A2fh+cB??bnbg%@kBcG)DwGG&Ln`6qGaz4$VF#WVT8$LQU+#~kl}Z=Q`@cewXV zwnY5RntwHWaH?55HnFv`ew+xPU9TVixK^+@@i{k3z`8}STyE<>IGZKyNJ{RSoZ#mP2TX9%)gyXAY z6DojvCbGl0j2@TYmsuO=lZ`8{z($(A)r?JR1Izg@IQ;itO4oU`7w&r;5AcPkr}u_N z;t%|^o@`S_fB&DP$Hz5>+N^&r8gAc3oDUqxr`U=NG_yvdeN<-M1ist4l=;!pg;ng+ ztrnjZL!T~Wpm>Z`Z53O2zPYb$$sGLT<;PY2vSRqKOfe%q!C{rv7A z{S^56x$#5v^Rt8WlejZWpRas~etvL}exCI8Q}-eI`Nl!|+3f45`a|^dg@g2SyRV

-?XRiHnx~Vki>`{u2V_y+^{7KH5at73yyQ1^uL$y)Pv7f&AVo-Lxhde0E(yC7OKj!C8$p*EJq%k0^4^_&V{8Gn*NU z@%_q(DUCH_ydb$%V+>|_$eX`=d*>|jQH`bTX{=kHCwc($7uaW}c{}&VZ8ZB8I3pQy zVKTlT@2($5S#YG7ntahRm4Igc=Y?JFr@p<6a8NnUdIjWiKC@ySbFg{+= zJ`=%E17C~RwU>^*P41PMd!4fd%IPB?=`+wqd*|No_{O;7JLP?h@2vfd?*ZTVZaEqp ze8BPj9yvJJ_nWjY zetSppHN|&m4p|Yf%=!5ae9Hr2XI~9o{qsw;`{zSw_tgEg`vtYz zmetFvhdH^EzQMk8@sQb1kIZV`A)ILrm9s~p2i!kS-D!;Xkvnq7;)ma((`yBJxnh}1 zcxx(J6HzQpaA+R2i8D&*c4HHwI@f=4xxKoVlh290*lVUokFz%qapqj>DT~fGWfiwP zl^CSjIE?RlH)f;f!sA0a`^30ZM`eD)ccXhB%3{VLdn7rW>He1dN_Vv|*N`4#j_Slx z3ZZGS<{8 z@nIcrf7QjuEtCzr_z=vBxl4Clr!k|O&(}BarN=eL()hm2cr~^fcU;4Gr+S@komPFj zIpp%85L`FnY`5<2eCUf?P zyJ4sIvgV5CjQp2Pw6894_*!%_ys*K^jp+1E{J#x)f(Ok(W6018UlU%!sTeqXj^EMX zU#6^!D0jl`oYnX%pF5@~pS5Jn^wBuczW;jaKzGh=tQljSU_U`+y+VIPLpWyyrpFXNT!CnInRYo~Wdy@O21L;5bLTdx!ZN-fRw}&hJzl{Hl$gJ7- z3f&Z^s`1IIaHDe9s2(_#Y^#jUO{tuT|GKiRbJ5XS;%d-uvG4=Ff5W#|-ia0G)-myH z>YVQ@m*1`<(^-Ak6AoB=x6{z&yYP+ZGKuzb_$QLJ?^>t#_$)zL#q6-7#BRbV>CmvJ z^J!CXIkwfYY5q3x=k2My*IBuqU>e5R6xl-Qnbq!I(Q@Rq7oR=1rJFA2EHshmTpz>5 zUuM3O|KB$bZw#II{uaLFnEXBGZCRU4S+llg>>zYG1-Q%Hal&_NtaFdH6E5Es==|BX z6YSRq!FLU%gA>%6HVASLu0}|I#ylIZ(L?zH;UrZ5bH>&l?*SRvFtz zxnse{lE7ho$U|0rCUY!e$2olE*Qq_>`HKhA6`svw{GQEwi}E>RA2cp=R@;od>_mGL zV;4WXLs?H=^dFk94ZCt+@U|_lZsUpeUDOp{Nmppy>^17hUP@0257M!+XNoZkAHtQ^ z$Ne_Hr*U3)AUW@0oDUytobeOwtK4z6){U%PVJvJwl47!4$(dV-mKjf%TH*0sAf5SDog_eDb7Pt=ebS!k))i zWI?jKl0GK$sl+B$TCLazXFtL?+TvWT{9dC^gj4)7vld^;I`)b{wc+&@(}>?>eVnIc z-+{@A5sluWHQSx|Z;C4jrUvl*DLxnez@d@1=ZH1$R>@K~v}ke8-7>E8wWmP&8VpRa z$oasG|Hc@1XuRx66&x?|+wW)kal0W8x8MDUar@HVT--|URRltPFka9`A<#d*pL;g82L&$HSNKy!Hwio`eqa=d{1M zYq`7U*riF=@@dtgOBr+tUjd^DATLK05vL#7Cfi8bz0nQ2(4J zyo2+PTmMAzaQj~$F>bfh{)g4=<3D2D{`31EF>Y(KXV)^i9UF73AGg5^lO>0OTVfEp zU&OPU$_Fnzu0v|UUTBGsn3}?3c1%P!%5MUn&%Z-|o*x1}+wf=ne#Zg* zkOSefAz;s!T|j;n13FZEuKbAbIqm-dK0RDspNGq@epI-GH&=ZKTps@s;qu?#`#5;h ziK%esZtmFwH#QKmzHv*a{{i=Kr$VV~nWw<-wcg&P-1X>C;2S^7fA7q1-22&y+3mk> zc%Qmvu4Cp$#4%GV_UYScd-*M)l?Q=4uza7kow~Hi9xKfy{BSw%80(mQ-{rz|&3<6w z`Lu)74P3JiOu20{t~^{DxL&^c7HjVr&t%~B&3$Ea9T{=%EH2i(lKpw(-FaXmcUoH4 zP4LHPo|@ixvi(iwnDU820eu4ZE4H9~tN5guxU}N%=w$S2c7Djb?I|mMvi)NJ+&QWr z4vV-Lc|uLT2I`ynsDUGVti2;UAAM?i_!N7Q502tMV7Ja40nhHBThCh$=n;F~App>6|-oa0k5HkEe4`Wvmh3Mmf!+PaV+1_-Oy` zePGM(Dbt=zL#O&v?Yn;L>|57e#)3NKTo-bH*Zp>R}+gYxA znv}nx@#*vG$f>2c6nzN}t*vXFo;gSBPZ+lsL%e)ex~Y-=6bn|q0_BcRl<#{#e2g-R zLv7=puzK>%X)Vd*Ou$Fzr2Kk(j17^}E%8$BIHFCBHF_g^LAa-}Y7csuwiV|#>)(`F zP8s8W@Z8wmt=?YO>^i@>!xP*ZI&3@D{@V)L0gnOrV2o91-*NTGlqN!pnetF$THo*HC1$lfO$;MGc53LC) zR$|6D4!lp`gI^P}8gG%$WuCue_AL{K;2nNks4#qsiEZ? zZu8_?|2f|kcJq=MdAVm|uDookv=@S>S>QyvO8(;bz4fQQ|t>HFSE~5 zIMsQ^rxnMBrekQ+^!XC|o#CgW_^*3W#H;v;&RJo_QeI?l`v#q9b#dDo02Ci!1m3P%zuQ7a6Uuj=LpN1#U3I14xsS7`8Jd53W${Q}-p}gxA=*9QM zTmKi+HgWg|qwVyMfwuQ5PyPR4+P+F$^Mla#esry&ZJTnt4QWo4vzLJLGLl{7q!k@} zirVEaIVUd-?=_dwJ7n^5)+Oc|onI5K{?BEfXK#{|bI!;$b<9~@)sY=g9nJ%;bK}%A zHSeN~$>FCml7E%ymn|cHR2khVA=u2B0+s2XEwkD!qgbOUqu8v<49=E`xn=(3l__`2 z49k|;;g&ITGxa5XrFKST%Ou<~Chn#((q}3&CR--Nyt#l%xbqxlUJt3Eptk?Ow29wU9U{7 zTc$c&W`|qmdauk3x6IURnS@*B8n4VOx6HI`nNWd)AKlAi=r!9db9%N+giM& zmo2lyEi=_Cv&b!TLAFf7EpxJ0W{F$oqHLK^(813Hugqm`nJ;F`RJdivdS#ZnWxkXx zGt(`joW%w|%iJ(quFaN7xMg1P%G~6ZS(z;p!ta-Sb$VrPcFSC!EmPr^dBH1lt6S!q z*)lWTGVNZOCb!IYvSk*#Wp;RF?r_Vj%a#eTW-WNOdS&i(%X}|eCg!&DfLCUNTgJ|o zS?!kjiC5-sx6EzXGCSNdcX?$tx@G<+TPER_xx*`SuUqEF*)kO_%(qZxru$ntiK?<5a8A$%XY*vtQ45j>Nx*hPDz# zz^3mboNvYGEarUjlis{kzNK%C?hxv}x=-Eg-dN^UW)GU?2-=J0jDc_Z)Z9frvDPQG zFFK6RC|`J%8Ao<6wEt{p1}8xWC-SWgju?{_ke|+Y7?krb-fp~94e%%j2r+e{D#Y5kx&Hd6T57+RN;RDSjbUx1T1~R9de#82x zJ!k%1fNyrkVS%+>npeXEia+lP_G#G&zd85ol_uGrXncGB96Nmo{p~Hz?Qg}$++Rmt ze+?gVf4|D>Z^p;mUvpl66F%ntzLVGA5g&7Zm*@4jr|-whpXcQD_uR+aABQS)_xDgPJ{NtU{@nEmH?METcM1CJJ~}hHbad6~0_&;JStk%n zVV!Y=?qe=ZHj;xx^W2^2Pwh4SCS}G`M!DFUDWh19ba68_Ts}f(A04`RF?$!k#Ap2W zLc32Zu_NX{Ivd_S!b)|O;@6J^@Gbg2bNeLDDfMCP zkMl!Aojp?K-1-Kqy#-iwH&q6na^PWX6`GskKkOGCU@);yU{-D}51wxUkL&@yn~6s& zW~Y32IveHP0Y>cASsTR8nDg7?sNcW%CiN%0DITeKF73GI(zAY~{J9K0eEwz5b8zmR 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 -PhI|}#m9Nu)1MQk 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 RhrepYMv=S+(AE0?_Y z@JkL4|6zLNlJ_2d!x7={qgO6@@9X<-3xC8@`1mu+k5$(J2Xd|YN0asNo0*5{l}p}x z_|X@{{HH%P<|~)H_wWl|7=97Ga>;uSKkY@~pIH6>DB$~_a>;uSKczkVe0pX1{NF$F zzCQoR@ONuIvwZ&l-<)PWeE&s8^Ox_7K!Sw3Gs@8RdZCj80t%JTX7?>&6yYr}tuUb*DGho96F zexIks`jt!Gd-!$qbLo{!-h24r6Jq{P>6J^~d-ypghJVREv3})}_a1)gN#U=dS1x() z;U`WDzl~nG+qH&EXp_iU0p!<&yUve&yEi@1R#MdGFx|$HOnDSC-HF_a1)Ugvs>_ z^dEX<`MiJc;d>^9pEeNtSC(I!uAld_aT1T(f?faovH&lJ#K!*1o zejEK=^vd${>w2g69)8Z7V*Q6V#(ZV@4as{C-}C11_tPuOcPH;X{381O4~Y57@@>g` z55Izb2EDTUl;pjKUrT>2y|VnA-_{Ii}O`&UN4 zyPw~^hi`mK_;=7N%TKF54-RC^_a45N{$6@z`Hs5Y>AiiKW55JxM0(xcn{PVl_ z@T*Uc>wh=Ba>;uSzvyk@$LW>jS60^m2Xf8MM~3g|4S&>uas8Ch_wWPsf2UV2dGF!3(7)iI*uS#;ruy!8?%#X(weN`a zpGmJQKc2kz@N3=~{`2(8@{P%R55IwapQc#9a>;uSzm@(2^vWggJ^V!e0mrZDl}p}x z_$liKW55J7Q>yTK#a>;uSKSuv~dgYS$9)2tRe$R>d$|dhTeB-;~`kzm)T=L$-Po;l| zURgfB{&^4I^zNAd-si^pmF3&g^T&Jm9{Ru0E6evJ?>+n={rjpPKJfF0vi#EIy@&7U zi}i1%SC;Qe-h23Y^z)t<^OZ~9d-zfMae8I>{QlK@_|5be9UAkMOWu3z98l+m%R7z9rHM!URi!x)H~Miy}$m|Iru4d}Vn&hj1X*?0jVS4*Gqne^CzW zS4I!pU47s{hW8$RKK%&2vV22b@ATfoFQo5#am-hiUx_=_fg8;C9=`iSvH!d2m2p1S zpS}0+6V4C+w3ozuW%>T<6dcHy?>&4!{afjkOWu3+oF`d4!o*cl637?>+oF`lDXT>!0T5=bz5U=ZEiJ5ZCW=dS%U@lJ@UC{4DyP(<_&} z_we)RpL%qxUs*mse%`|`r9X>aSw6pi_a1&d{T=kmCGS1_IDX<}1tR`QF2Cq#vSJmS3Ibdk??#;<$c$ zye#G`%jd5@@8QSjUq`PjpP&EU!*^a1^RJ>;mLE&&_a1)IK=^g^%JS2a_a1%*ebdWh z|H>urJ^V2JYZS=||?>+ngeP?IvU%BMHhhIxSK(Ac# z-orP2EY?3puUzup!}rlQz9QDIT=L$-kJ8VgS1x();U`=g>&O4EEM7m9OWu3iKW55JE7NA$`i?>+oB`bk}} zf8~<*9=`GNxPGsqS1x();XCLrpjR$=@8M_A-$k!1pMQSz9)1b^zv-1r-h22>^hX{S z*H5|Ry@zl6cw9gH{{rLdU%BMHho49PMSA6u_a1&F{a@&nOWu3EÎ!!M?9c~$IRS-vrU z{qubE@XMG#M6WEL=X+n@Km9uTar^xE`F&xU?>+p2PsILT+8ygx*8Gv=y@zkRGW?D7 z$|dhT{CfKT&@0PNO7p#k-$?(SSI7F5OWu3<&GhT&l}p}x_^tG>d`-+(E_v_a$LYU7 zuUzup!%rBD>wmy&W4?09dk;T}egVC5$$JmqK>rZEvV8viwfFFS^fP;6{mSzB{e$=L z8|Z&cuUzup!%x2|uHWe=#C+wF_a1&V{qN|NOWu3{@;7})z^k^oEGa> zmLI{L>cCCS&PRrydwux%^vdY*`kCi@58rx2_zm>RCGS1_#v8+TogC{|E_v_ahn9q2 zO0O)R_wPM?_f6p^)ndM~d{1=^a3EKw>^L78ei40*UKu^E->T{Z2Qs|(@Y8ON`Pb7c z%jfSu-oua6Z=_c)dGFzu4#)h%Ul;pVmS2U%R|j%6V#oQ&@Uw3Te-6Dedh9>1-+TCm zPlvyoURi!s^*L}LW4`zBYw0IWkM%3dx7YPf?>+p4&&2#1y>iKW58qBdM6X=(-oy9O z|DImC|eR$y@y{#|3P}?lJ_2dEBz{ZW%;@3`g>pB|I)br|Do??mY;S9-jjTGUj^^Kc!)Ey}yxve_vUC zQN7*iy@y{)zmZ+o#`f+;YlJ|Z#PU3OJCA*kc7G_dqD;D`N;4q=^vq2Mvv!SQ}uxZ8Qy#N_4FMxV*SeU4RyWKdk?>vehIy@{QBg* zhhKGT?EjDS%JQwrdtaYVf8d*9{hOKP^XrHA@H3b3`qL}RH>dgD!_TMxBfYYGXY$^| zkJ6v^=2*Y7{5ssJ4&2o2d}R2AUx@YJPp^y~kN+_4R0nS0y@zid34g*VF<%+y!>_`f z>c9=W_wbv)82%o5Wt&6y9pMi-HRdbh{N2s>9)9y^`0MGFIf`y@wz9TKK)+67!XDKITuW zK5)o;4?j*nLa$u%-otm_8S_tiYs^Hk8nEZ-f=-?4u0;n&gkRNtsuEXz;A zo$A0%E%opl_#ZgBnO+&^UHl9_wF5uZ$l1Z^E7Gzzw|j@I!Zp$3JoXm2p1&q>0r(sskC` zd-&md!ax0Ov3_Ow{PpiW{G4xvzk*&_e)viC@jK>w4?p>P;V1USd}aCS*BbQ$H(0;- z@H5tgzkyyE=hyd-JJo?3c<SwY4H&$=P@cdJj zUz)u4@Uwp&{s;8R@=KEU9)7|v!tZlN%vYAr??1eUZ>2wnURi!d`v1@I9=`uKF@H6^ zvV2!szxVJf={w#P>sOY~*WY{i(FbDw$LN*i^L+2&x6%KbURgfB|MVWd;kPlr^WCw2 zW%=&3fA8UE&=1lp%WsQMzvKDuJ$%pa~vPk(%0tbZG`{3_h34&2nL ze|Qf+x+&&=lU^C;<7+>E|L`7u!XLxWd=H<$%<}pA>wNU^O+3Hhz2TMR2dndNAY=XB z!;jGS&@0QYuIruNd-%D3iuK=1uPi?~{r?Yn58wY__$Qwk>sOXvoV@q&4Sx-P0==?) zds@Hx{WtG9;n2OSKYzMpzxo;PW8TdCUFNQV=%4XmoZrKIW|f=yxpDIy_T%h7{_pDi zeXHMZUKM^5^JeB-#&`AqNEz#|UJ`d4pRRs>oxLMXnDBVY2Yz(-Ki_uo`rOCsqpa(b zpI_eN`p%-qe@G3l@5=H6ToZCF_3#_%kD*tVUzoi2@O^)a$M+n1W%)ecd-yH%x6vz? zy!Y^P{~q%<(JPm{_wd{3pY^`De#-Ls`RP4;%hs5G5xuf}{{H4Y{51L%^vd#`>H2#Q zzmR?ly|VnuH;@PE zmF4rFPkRqP>EW1v?E7Q=%JS2zHQ+$5+4;!uGwE-jS4NNZ&!|3dAj5kPKSKX6dS&_i z{?2>&_4Kcs8|zn=pIV>3bH4ZR4gZe)-$}15pXYlI-$~zmcFb3n&-1;9pGUulURgfB zet8c+NIy=mEI*d^?>+piM`HhHo)ha=mTykpd-#6(2k4dMXD07G{5s7)H|8tL^UHI` z^T&Jm)&Cdk|2)02e7=6(!*8U2(!7|jET3P0yoaCkSj>Mry|VnYsCTU2d-xfT#|azg zl}p}x|NePr_5E=+uQhJ=``E7eMOEJ2zn?Sw-JTwEWbL<_a1)E6JmbHc`;vEzBhUA;m7E&q*s>D zubj8z#p5FVQQPy!Y^P>5uqu%vY8lNb|ji-$uWhUb*DGhoAqXSpVtg$9!e^?PQQuuZB%JQAn=fHuC`QF1% zcuM#~KN9nmOWu3t1pF-bJrm^4`PGq`#Y9Sw27iy@y{+-*8c^U%BMHhhIy72EDR;e*N$sevH3>xQkv{ zeht2Os{^@a=OfqGzjs`}Juc?@nM;1L_3*Qv7XD;<<&yUvzGa{A*V8M@Z^WJIzzx># zJ^aLd!~cO^8Rz5muQ|Q{^1eQQ|L`xmB=+CJEZ>v7_waKL2!B4kvit_zsSey={ocd( zJR|(~=#_Ck*1rsQsslIh-otO55`O=ISidsPhtJO+@8P#RGyFT~mE{|7r#f(h`QF2C zKQR24>6LN*?&fUvHnH$ z&%7+wuPmQ`e)ArFl>SV5W%)ecd-(PA-=J5P&p-cq58wXW*#9PaW%>O5+k5z4`ku>U z|H|_D_YdB~57A#juPomYmw(6g_a1(X{vLW|`HjhY4?ofp`=9V}KL42Iw&5XYxw2#$~Yf>7VcCBZs5I# zA3i+%cCDWo=kLzzeDv_!j|kthD6XHfd{cEE4rI*t9)8O6!(UFXET5l0-osC)|1rI? zd}I3aaqr=0^FN@z?-j9r<&yUve*RIh{!{6d<@49S_wd8?x6mug=ii@t4?jx(Z+d0< z>Gi9>^ZI)aKk=op{u!T${VSKe_wY05Z>Lw5pOxl&55Js#-z#IjvixB3-otkt9qT`n zURgf>{?vQ;IrM*_SC*fW=6er6K;JeP>sKy$@8OrwpGL1NKR?a)9=>sE?EflyW%+#l zyoX;(|8sg}`TYAA@8R2ziTMXz75i6~&)463_*wMl(JRa6?|+no z{Rvmc`jzDe()IVgzW6`MiGb;aguG*Y9Wa%JO->_wcjmkH0qNE6eBk-or1VznxxL zKF{|aeiePwbunMLQ-vi#cg{PP}ub!S|^ZS>0W^Khp+a8tAMk>RHv8~(QI zWBtnL@wL}+VD*pcK!*1oe$LGBZyyS;ET4aV@E(2{{h#QS<@4*W_wcLePrZTHpSk39 zK6?N8;dklhh0cY${`_!im3Q~^!+Os9F6S$2{%Tyj>OjWz^B(J8a%x<^$LN(y-h24o zw}fxMG4`)q^4`O@yfyq8^vWggJ^W_+8|jrx-h23wSuy`7^vd!JaPg`Gxn}1h!?&Fl ze&Q!%|H|m`{LRs=TG`ntY2Avdi6PQAY;Dw@Xhat`RCFrm%R7zbLk(TS1x();aAb0 za8s;bx#YcvZ+K^{f0SOi+cU&f1j(N z`gw?de+%AUpZ}d+k6(Y;uCLE$Re5)>&l6_H^?8`+oN`aNz5uPl$xfdd)yy@wy6zmi_LurJ^Tp$@$||i?>+o_`j5~n zm%R7z6W!)1u-or1We=EIm$$JmKiT-MO z<&yUvzWLp8{k~7HT=L$-&!V63+1S5w$$Jk!M1M5BvV1ST_^SiCX6GZrkJFz|uZ$kA z9lg~D4rF-m;b->6{_mkzmd~$W-or1UpZK}hzjDcY55JNA1bSup{`%^7uHSq3S?`JU z57R5lFHYWj_^tGh&@0R5=fC&xZSRfwN8TFySC(%~^Sy`fqJKZVvV2qW-q-g}e?R>! z=92dwem>7Xy845>7R&Oh(|nze9=`L;*#FJ+%JQShdk?>r{>h(@`O5P7`gspOaZb#C zHNCR@>@?qd_&)lJ>6PX4`n`v5dSA?6ORp@y9e1h&H#IvS8Gg$9!|(lt*uOG*eElxR zo$A01y!Y^H=7#T~SH}79`R4=m`~PLn3C&fn<5ejy+Oli^n}5XnuU5YQI_Z)4`}JE3 z@5lDKeAoA1?@IZSD(~+7*f{s|OYTQm`;uSznuQ< zBjJ_hXQcVw!%sXX=0ER?;g#j{`n`wmrGJoKx#YcvUrGPPFU5T2lJ_2d%DJ)rvu_Wt zT=L$-&!a!8dg9{sL|HyxKkwn!&_7DAEI%h*fA8Tt=EeHI@#UDWET6Bx_wa-C7kwqX zvV3ov?>+oh`d8l(URgfR_a1)Q2V(tC84a&o^4`NQqyG`Ta>;uSKku^pZE3k(|?wJ8MA!8{@%k+Ixp7$2)%O2dk^17f6Se+er5T!Y5m^A zPn{q0FQiwNUy{7{@ayPT(ksg^P2PL>`5%h;|3|MZzan|>;oCnPe(H+Yzq0)D<|AKy|Vm* zUqr7gpC5nk;ae^UznWfIepy<-_wZx%PyTwWUs*oy-+TCJ7smV(>6PU>(tPjX7tmit zuPon{y!Y^{=-1OL%l9SkJ$&neSby`~v47=~_a1(bzK>p6eqEaHJ^bv8V*b_i%JTX3 z&wKb0`d`s2%QvR^-ovk=KXGO3Us)dS32`9T?0jVSP4susE2D?a-h22-7svivzY+75 zOWu3yP*Fo9MqwuPnc~dJJ(O*X(>``1u1d|L9dQUl~0f`@DYd;Tu01ei^;8e184% z9)2bLGrtw{mF4sPy@#Lkv6z23y|R2>zxVKyE)D+}y|VnG>KfocuG#s>@Qdl^d^^^! zj2_o-b@hP*8Qy#NnG0k7Z|Iff^ZvbupLALH+IM2UvV4C1@E*R2{vLW|`8?ly_%ZtS z)iGbW|94~k$|dhT{KQ4E{~7ekCGS0aJN>ov$|dhT{4DyP(<_&}_wWnppYgre zzq0(i^!RxXzn%Vr^vWggJ^b7&;`;rJUb*DGhu=isu_o59T=L$-&-_HpzlL79;uS-*siofBpAk{mSxP>H2#Qzkq&-URk~)dGFyz={M3Vm%R7zo9SDB5bIYi zdGFzy24nwo>6J^~d-&<}E9jL=-h21~`UyXb^(&XW_wXy|d+3$ryVLde9)3Ihm*|yC z-h242tK#}Kj`8`!T=F^}eSQBt|HJeH{~zx?{2HFWkzTnp-+TDUSI7Ea^P^b5a>;uS zKb?MrURfUR0dOGK?0jVSdG!CLS4Iz;z4!1#^j$xW^(&XW_wXy}FQ->7dGFyj(yyae zE_v_aCtefR|Jgr@^(&XW_wcRsee}xmt+-PixT)Fs$newoAG{f*S4NL(pS}0+^RJEd zPgoo4S1x();fJpaKaF0w0jj2_oNd+%rCBp%Hl-}TRb&QE!$ z%DemXpP8J0`2BJHlr=xU|MMQ}AEQ5uUb*DGhwu7S?EemW<&yUvew2QkUb*DGhi|

|eR$y@y{ye=)tXd_Nx_@aGrdKX`@8LUc3I8d2<&yUvelz_hdgYS$9)8KEWB&7h9{X1=dGFz;e6J^~d-x&xjr7VT?>+o-`jdYd>sKy$@8Q?bf1O@gerbCAyoaCj z*?9a8{8h|Xmd{`R-otOEe;>Va$$Jk!`*SgW6}@uFdk?>ne*a&``jt!Gd-z$m#{75D zE6eBKzk3hAlzt7pvV8vj=Y4(s^lclserEah^!RPG9=>5&tbdSRS-vrO@8MhMAE8&4 zA4%SO__g#e_)V-|S$<;r?+thlzwQgM{xj&6<@5VD@8O$o3;z{*W%&hZ{ocb5(@%LI z)~_r-F?sLdm(qWjURi!_^4`NYj>P)^Nv~Y;-op>jzxTJXer5UIG~avpP4s`JSC-Fz z{^~vak}vY{+ZgkeOWu3<8D9$j2YTg__a1)o?cv}4yO^(B^4`O*rvELya>;uS-@iQO zpZ5EhuUzup!*_f+{IBVi<@5JX@8PG>zx59>Us=8>J^tRq_t9^lS1x();RonvZ;JWK zCGS1_F#Q&K<&yUvekJ|9KgN9JlJ_2dJ^gli<&yUvejEM3pJKjp$$Jmq@RfM{C;vIT zvi#Ka_<0ZCLw_s1a>;w|KYtyyXxE>=22=iGm3Q~^*8pMJ;BKiCdNEE_v_ahv>gUuPmRhzxVb1(@%aV*1v&SzB#Smd-&}< z|8#m~`TX;T_wX}U#P$0Dy|R1;eUm1N%^`Wm3WO(o4w|+hR zmGsK;(<_HV-h242yTecTYs^=c&+p&7ho3?JF?!{a_a45F{;TxL@(o-ba?Q?1hM)h< zIN=d`W%Rgy_`rb-?>+ny`cwWE`&X8qS=T$g_wd{3@1$3j&)@&Nhi|wi)_>IBW4^L{ ze*f=1{7m|5>6PX4`*-i*7t%jWuPo1BukN^h-orP4C)R)T)>yx?{OWZ5yocXJe>=Uh ze181Aho7`M=D+wKF<)6ezkYcS-%kGrdS&@(xKkatsoD9+@csA3{KNkl^OezK?fLQZ z9)30be0t@Q_a1)ocVqtd=#}NWt2N+2#`?X7pHBbWf5rNh<(Jm=PVYVZHv0F_E6eBi zkKWhU|GilM-Sm^0OWu3<7M}mKZLxl3`TYFx9=?bEI(p@j_a1%@{lO2%eC3k&9)6Ji zhxE$wUFrIJukQ^w<-{v?egE{?l)qi&-MxQW%lWl`$NH5uKY#srkM)n!FQiwN&+i|- zhi_jKkN+lm<&yUvzK_1^kyyWS$$Jk!OuvL)x#YcvUr#?yuUzup!#8|C_CI4h)~{Uh z-oy9MFQ->7dGFyD&`*9e<|~)H_wXy|`{eh^e`5X0@_GH!*~8DuKy}}W%;h;y@&6o-%hVApXYmD-#`7-2~Vv5`(vw^zxVJn>6g|a^FIqlzj_{N{a z{%6xG%je&}dk^1BKTNM&^4`Ob&~Knume1?=9)65|$`fP%%JTX7<2`)C+SvaLdS&@M z-+TCW`aybS`8?ly_<8hW^vd#W>GAU(ewcpJ9szN7kssus)gdH*^eJ$%D`vH!XB z%JO;r-oy9NkI*ZZy!Y^{=r_|V%jee*@8O&7kM*}aDfX`{pV#j_{5<+zdgYS$9)65| z3B9s>UcdM7t?Od_>*iKW z4?l~34ZX5_e*N(tewcpZlll6=ET12LosS-VoacAZE6aCQ=ixxE+4;!u9Y2r#_tPt* zZ>c`=eDC2G(XXIame1GUd-%=t+vt_$^Y!x{zU>#W{`N_6{gmbN`n`uApr1ppT=L$- zZ=hdBuUzup!*{HY^>3nAmhY+_102XTJ0BT-9)0suV*kqM@%Z5b2Qs|(@I&;s(JPm{ z_wXy}r#?02E0?_Y@EhsxrB^O_@8Ku@GOpi@y<)zyeE$8T_wdu`|3t4WpRb?y@N?-u zJUQkom%R7zi|HHo4zFDD-ovk;{~Wz?$$JmKfxhc$F<-gly@#LhtGNEZrB^O_@8MhM zFWe{QE0?_Y@ICa+4dInb-h22t^xvXaE_v_a2kGCnZ_HONdGFyz>G#<$ymHBV55J!N z>-5Sc?>+o>`uFW0^OZ~9d-&#G$K(H;#_-A|?>&4s{jcejOWu3`_#XNTpB-KqJ)S%H`QttO9Qt=29A3HP zy@wy9pVk~+x#YcvAEkfAA>oxv-h24<^zF|HuUzup!*8d5;d8?)%jfr>-owv)ARfOL zwS-re&)463_NPv|LqIIE6eA{&wKbT`X{xAS1x();b+sgy*Rvb$$JmKkp4|C39nr8 z-oua3UwCAA<&yUvel7j-j_}GQ?>+oB`ac{MUb*DGhoADhc>JDzba>^G_a45J{=})_ zl}p}x_+I)2$AnjwÎ!>^?O+RMT#%jf5x_wXC(|MBwh$|dhT{KVhK^?Ttf!Yh}& z_wcRsbB+zKEZ zR~#Q+S-uOys{ldn3EYj!>|{2={%Umso>J)V2{`R6_SDE+_Q5MH_D zy@y{O2?LGW3{b8rX zeC3k&zP^9@57TdFmd}r$_x1h%Dfa(k`ex>m_rAV=p8vq9vHtGlS69y=9Qb^->L1?2 zPx^Drf9YGoE0^Yb58p;uSKSqBYy>iKW58wJwtiR!1F<)6eKYrfBPouw_Ub*DGukWA!neXNPnM;1I z_3(?CucKF%&)463_^tGfXU2SG`TXZ^-oy87iR*U_y|R41e%`~+q2GT_%vUaX@8JhE zpI*7-y@wyw{P)Ft<&yUvem(tSdgYS$9)3Ih0cXX0<&yUvzWJ|l{jZ}}E_v_ayXg;l zf6P~wÎ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

DaY>r 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 z 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 literal 0 HcmV?d00001 diff --git a/languages/typescript/packages/profile/tsconfig.json b/languages/typescript/packages/profile/tsconfig.json new file mode 100644 index 000000000..eb9863389 --- /dev/null +++ b/languages/typescript/packages/profile/tsconfig.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ES2020", + "moduleResolution": "node", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true + }, + "include": ["__tests__/**/*.ts", "index.d.ts"] +} diff --git a/languages/typescript/packages/profile/vitest.config.ts b/languages/typescript/packages/profile/vitest.config.ts new file mode 100644 index 000000000..2c01d68b0 --- /dev/null +++ b/languages/typescript/packages/profile/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + testTimeout: 30_000, + }, +}); diff --git a/packages/stack-profile/tasks.toml b/packages/stack-profile/tasks.toml new file mode 100644 index 000000000..964e36d12 --- /dev/null +++ b/packages/stack-profile/tasks.toml @@ -0,0 +1,9 @@ +["test:integration:stack-profile"] +description = "Run stack-profile Node.js integration tests" +dir = "{{config_root}}/packages/stack-profile/node" +run = [ + "npm ci", + "cargo build -p stack-profile-node", + "cp ../../../target/debug/libstack_profile_node.dylib stack-profile-node.node 2>/dev/null || cp ../../../target/debug/libstack_profile_node.so stack-profile-node.node", + "npx vitest run", +] From 3f548cd50499d1a48a9f6567e276174ff6101225 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Thu, 2 Apr 2026 19:18:37 -0700 Subject: [PATCH 159/686] ci: add GitHub Actions workflow for stack-profile tests Runs unit tests and Node.js integration tests on push to main and PRs that touch packages/stack-profile/**. --- .../imported-workflows/test-stack-profile.yml | 61 +++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 .github/imported-workflows/test-stack-profile.yml diff --git a/.github/imported-workflows/test-stack-profile.yml b/.github/imported-workflows/test-stack-profile.yml new file mode 100644 index 000000000..afba7bffd --- /dev/null +++ b/.github/imported-workflows/test-stack-profile.yml @@ -0,0 +1,61 @@ +name: "Run stack-profile tests" +on: + push: + branches: + - main + paths: + - packages/stack-profile/** + - .github/workflows/test-stack-profile.yml + # Ordering matters, this needs to stay at the end if we want to include the above, but exclude all .md files. + - "!**.md" + - "!**.example" + + pull_request: + paths: + - packages/stack-profile/** + - .github/workflows/test-stack-profile.yml + # Ordering matters, this needs to stay at the end if we want to include the above, but exclude all .md files. + - "!**.md" + - "!**.example" + + workflow_dispatch: + +defaults: + run: + shell: bash -l {0} + +env: + RUSTFLAGS: "-D warnings" + RUST_BACKTRACE: full + CARGO_TERM_COLOR: always + CARGO_NET_GIT_FETCH_WITH_CLI: true + NEXTEST_PROFILE: ci + +jobs: + test-stack-profile: + runs-on: blacksmith-8vcpu-ubuntu-2404 + + steps: + - uses: actions/checkout@v4 + - uses: ./.github/actions/setup-rust + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + + - name: Fix permissions on target directory + run: | + mkdir -p ./target + sudo chown -R "$(id -u):$(id -g)" ./target + + - name: test-unit + run: mise x --env test -- cargo nextest run -p stack-profile + + - name: test-integration-stack-profile + run: mise run test:integration:stack-profile + + - uses: ./.github/actions/send-slack-notification + with: + channel: engineering + webhook_url: ${{ secrets.SLACK_NOTIFICATION_WEBHOOK_URL }} From 65c781870fd4d79904196a13799e2726ccaddaab Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Fri, 3 Apr 2026 15:03:16 -0700 Subject: [PATCH 160/686] docs(stack-profile): add Node.js workspace management example --- .../profile/examples/workspace-management.ts | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 languages/typescript/packages/profile/examples/workspace-management.ts diff --git a/languages/typescript/packages/profile/examples/workspace-management.ts b/languages/typescript/packages/profile/examples/workspace-management.ts new file mode 100644 index 000000000..5de06f5f4 --- /dev/null +++ b/languages/typescript/packages/profile/examples/workspace-management.ts @@ -0,0 +1,46 @@ +// Example: Manage workspaces using the profile store. +// +// The `ProfileStore` manages `~/.cipherstash/` and tracks which +// workspace is currently active. Each workspace gets its own +// subdirectory under `workspaces//` for auth and key data. +// +// Prerequisites: +// 1. Build the native module: npm run build +// 2. Log in with the CLI: stash login +// +// Usage: +// npx tsx examples/workspace-management.ts + +import { ProfileStore } from "../index"; +import type { ProfileError } from "../index"; + +function main() { + const store = ProfileStore.resolve(); + console.log(`Profile directory: ${store.dir}`); + + // List workspaces that have local profile data. + const workspaces = store.listWorkspaces(); + console.log(`\nWorkspaces on disk: ${workspaces.length}`); + for (const id of workspaces) { + console.log(` - ${id}`); + } + + // Show the current workspace (if set). + try { + const current = store.currentWorkspace(); + console.log(`\nCurrent workspace: ${current}`); + + // Get a store scoped to the current workspace. + const wsStore = store.currentWorkspaceStore(); + console.log(`Workspace directory: ${wsStore.dir}`); + } catch (err) { + const profileErr = err as ProfileError; + if (profileErr.code === "NO_CURRENT_WORKSPACE") { + console.log("\nNo current workspace set. Run `stash login` first."); + } else { + throw err; + } + } +} + +main(); From 8ac562a86820ce20c6e721af8fd75cd3ff24d0a8 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Fri, 3 Apr 2026 15:26:42 -0700 Subject: [PATCH 161/686] feat(stack-profile): require workspace to exist before switching MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit setCurrentWorkspace now returns WorkspaceNotFound if the workspace directory does not exist. A new init_workspace method creates the directory and sets the workspace — used by the login flow. This prevents users from switching to a workspace they haven't logged into, which would leave them in a broken state. Also adds README.md and CHANGELOG.md for @cipherstash/profile. --- languages/typescript/packages/auth/src/lib.rs | 3 +- .../typescript/packages/profile/CHANGELOG.md | 21 +++++ .../typescript/packages/profile/README.md | 92 +++++++++++++++++++ .../profile/__tests__/profile-store.test.ts | 23 +++-- .../typescript/packages/profile/index.d.ts | 3 +- .../typescript/packages/profile/src/lib.rs | 1 + packages/stack-auth/src/auto_refresh.rs | 4 +- packages/stack-auth/src/auto_strategy.rs | 2 +- packages/stack-auth/src/device_client.rs | 4 +- packages/stack-auth/src/device_code/mod.rs | 2 +- packages/stack-profile/src/error.rs | 3 + packages/stack-profile/src/profile_store.rs | 57 +++++++++++- 12 files changed, 197 insertions(+), 18 deletions(-) create mode 100644 languages/typescript/packages/profile/CHANGELOG.md create mode 100644 languages/typescript/packages/profile/README.md diff --git a/languages/typescript/packages/auth/src/lib.rs b/languages/typescript/packages/auth/src/lib.rs index 2bd1d349c..8335f41b9 100644 --- a/languages/typescript/packages/auth/src/lib.rs +++ b/languages/typescript/packages/auth/src/lib.rs @@ -984,10 +984,9 @@ pub fn save_test_token(profile_dir: String, zerokms_base_url: String) -> Result< let store = stack_profile::ProfileStore::new(&profile_dir); - // Set the current workspace so workspace-scoped loads work. let workspace_id = "ZVATKW3VHMFG27DY"; store - .set_current_workspace(workspace_id) + .init_workspace(workspace_id) .map_err(|e| napi::Error::new(Status::GenericFailure, format!("{e}")))?; // Save the token to the workspace directory. diff --git a/languages/typescript/packages/profile/CHANGELOG.md b/languages/typescript/packages/profile/CHANGELOG.md new file mode 100644 index 000000000..da7b2a105 --- /dev/null +++ b/languages/typescript/packages/profile/CHANGELOG.md @@ -0,0 +1,21 @@ +# Changelog + +## 0.35.0 + +### New Features + +- **Multi-workspace profile management** — `ProfileStore` exposes workspace lifecycle operations to Node.js: + ```ts + const { ProfileStore } = require("@cipherstash/profile"); + const store = ProfileStore.resolve(); + + store.setCurrentWorkspace("E4UMRN47WJNSMAKR"); + const workspaces = store.listWorkspaces(); + const wsStore = store.currentWorkspaceStore(); + ``` +- **`ProfileStore.resolve()`** — open the default `~/.cipherstash` profile directory (or `CS_CONFIG_PATH`). +- **`ProfileStore.withDir(path)`** — open a profile store at a custom directory. +- **`setCurrentWorkspace` / `currentWorkspace` / `clearCurrentWorkspace`** — manage the active workspace. +- **`listWorkspaces`** — enumerate workspace IDs with local profile data. +- **`workspaceStore(id)` / `currentWorkspaceStore()`** — get a store scoped to a workspace subdirectory. +- **Error enrichment** — all errors include a machine-readable `.code` property (e.g. `NO_CURRENT_WORKSPACE`). diff --git a/languages/typescript/packages/profile/README.md b/languages/typescript/packages/profile/README.md new file mode 100644 index 000000000..a36d8f001 --- /dev/null +++ b/languages/typescript/packages/profile/README.md @@ -0,0 +1,92 @@ +# @cipherstash/profile + +[![npm version](https://img.shields.io/npm/v/@cipherstash/profile?style=for-the-badge)](https://www.npmjs.com/package/@cipherstash/profile) +[![Built by CipherStash](https://raw.githubusercontent.com/cipherstash/meta/refs/heads/main/csbadge.svg)](https://cipherstash.com) + + [Website](https://cipherstash.com) | [Docs](https://cipherstash.com/docs) | [Discord](https://discord.com/invite/5qwXUFb6PB) + +Native Node.js bindings for managing [CipherStash](https://cipherstash.com) workspace profiles. + +Profiles are stored in `~/.cipherstash/` with per-workspace directories for auth tokens and encryption keys. + +## Installation + +```bash +npm install @cipherstash/profile +``` + +Prebuilt native binaries are included for: + +- macOS (x64, ARM64) +- Linux (x64 glibc, x64 musl, ARM64 glibc) +- Windows (x64) + +## Usage + +```js +const { ProfileStore } = require("@cipherstash/profile"); + +// Open the default profile store (~/.cipherstash) +const store = ProfileStore.resolve(); + +// Set the active workspace +store.setCurrentWorkspace("E4UMRN47WJNSMAKR"); + +// List workspaces with local profile data +const workspaces = store.listWorkspaces(); +console.log(workspaces); // ["E4UMRN47WJNSMAKR", "JBSWY3DPEHPK3PXP"] + +// Get a store scoped to the current workspace +const wsStore = store.currentWorkspaceStore(); +console.log(wsStore.dir); // ~/.cipherstash/workspaces/E4UMRN47WJNSMAKR +``` + +## API + +### `ProfileStore.resolve()` + +Create a profile store at the default location (`~/.cipherstash`), or the path specified by the `CS_CONFIG_PATH` environment variable. + +### `ProfileStore.withDir(dir)` + +Create a profile store rooted at the given directory. + +### Instance methods + +| Method | Description | +|---|---| +| `dir` | The directory path of this profile store | +| `setCurrentWorkspace(id)` | Switch to a workspace. The workspace must already exist on disk (created during login). Throws `WORKSPACE_NOT_FOUND` if not, or `INVALID_WORKSPACE_ID` for malformed IDs. | +| `currentWorkspace()` | Get the current workspace ID (throws if unset) | +| `clearCurrentWorkspace()` | Remove the workspace selection | +| `listWorkspaces()` | List workspace IDs with local profile data | +| `workspaceStore(id)` | Get a store scoped to a specific workspace. Throws `INVALID_WORKSPACE_ID` for malformed IDs. The workspace directory is created on first write. | +| `currentWorkspaceStore()` | Get a store scoped to the current workspace (throws if unset) | + +## Error handling + +Errors thrown by the native module include a machine-readable `.code` property: + +```js +try { + store.currentWorkspace(); +} catch (err) { + console.error(err.code); // "NO_CURRENT_WORKSPACE" + console.error(err.message); // Human-readable description +} +``` + +### Error codes + +| Code | Description | +|---|---| +| `NO_CURRENT_WORKSPACE` | No workspace has been set | +| `INVALID_WORKSPACE_ID` | The workspace ID is not a valid 16-character base32 string | +| `WORKSPACE_NOT_FOUND` | The workspace has no local profile data (not logged in) | +| `NOT_FOUND` | A requested profile file was not found | +| `IO_ERROR` | An I/O error occurred | +| `HOME_DIR_NOT_FOUND` | Could not determine the home directory | + +## License + +See [LICENSE](https://github.com/cipherstash/cipherstash-suite/blob/main/packages/stack-profile/LICENSE). diff --git a/languages/typescript/packages/profile/__tests__/profile-store.test.ts b/languages/typescript/packages/profile/__tests__/profile-store.test.ts index 123ee4a55..97229ca27 100644 --- a/languages/typescript/packages/profile/__tests__/profile-store.test.ts +++ b/languages/typescript/packages/profile/__tests__/profile-store.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, beforeEach } from "vitest"; -import { mkdtempSync, existsSync } from "fs"; +import { mkdtempSync, mkdirSync, existsSync } from "fs"; import { join } from "path"; import { tmpdir } from "os"; import type { ProfileStore as ProfileStoreType, ProfileError } from "../index"; @@ -80,6 +80,7 @@ describe("ProfileStore", () => { describe("given workspace set", () => { beforeEach(() => { + mkdirSync(join(profileDir, "workspaces", WS_A), { recursive: true }); store().setCurrentWorkspace(WS_A); }); @@ -119,14 +120,22 @@ describe("ProfileStore", () => { }); }); + describe("setCurrentWorkspace", () => { + it("throws WORKSPACE_NOT_FOUND for workspace without profile data", () => { + try { + store().setCurrentWorkspace(WS_A); + expect.unreachable("should have thrown"); + } catch (err) { + expect((err as ProfileError).code).toBe("WORKSPACE_NOT_FOUND"); + } + }); + }); + describe("given multiple workspaces", () => { beforeEach(() => { - const s = store(); - // Create workspace dirs by setting workspace and writing current_workspace - s.setCurrentWorkspace(WS_A); - // Touch a file in the workspace dir so it gets created - s.workspaceStore(WS_A).setCurrentWorkspace(WS_A); - s.workspaceStore(WS_B).setCurrentWorkspace(WS_B); + mkdirSync(join(profileDir, "workspaces", WS_A), { recursive: true }); + mkdirSync(join(profileDir, "workspaces", WS_B), { recursive: true }); + store().setCurrentWorkspace(WS_A); }); it("listWorkspaces returns sorted workspace IDs", () => { diff --git a/languages/typescript/packages/profile/index.d.ts b/languages/typescript/packages/profile/index.d.ts index 7be1b9183..0b7fd4ba3 100644 --- a/languages/typescript/packages/profile/index.d.ts +++ b/languages/typescript/packages/profile/index.d.ts @@ -10,6 +10,7 @@ export type ProfileErrorCode = | "INVALID_FILENAME" | "NO_CURRENT_WORKSPACE" | "INVALID_WORKSPACE_ID" + | "WORKSPACE_NOT_FOUND" | "UNKNOWN_ERROR"; /** An error thrown by this package, enriched with a machine-readable `.code`. */ @@ -32,7 +33,7 @@ export class ProfileStore { /** The directory path of this profile store. */ get dir(): string; - /** Set the current workspace. */ + /** Set the current workspace. The workspace must already exist on disk (created during login). Throws `WORKSPACE_NOT_FOUND` otherwise. */ setCurrentWorkspace(workspaceId: string): void; /** Return the current workspace ID. Throws if no workspace has been set. */ currentWorkspace(): string; diff --git a/languages/typescript/packages/profile/src/lib.rs b/languages/typescript/packages/profile/src/lib.rs index c3fbd3bed..f7d0b2a36 100644 --- a/languages/typescript/packages/profile/src/lib.rs +++ b/languages/typescript/packages/profile/src/lib.rs @@ -14,6 +14,7 @@ fn error_code(err: &stack_profile::ProfileError) -> &'static str { stack_profile::ProfileError::InvalidFilename(_) => "INVALID_FILENAME", stack_profile::ProfileError::NoCurrentWorkspace => "NO_CURRENT_WORKSPACE", stack_profile::ProfileError::InvalidWorkspaceId(_) => "INVALID_WORKSPACE_ID", + stack_profile::ProfileError::WorkspaceNotFound(_) => "WORKSPACE_NOT_FOUND", _ => "UNKNOWN_ERROR", } } diff --git a/packages/stack-auth/src/auto_refresh.rs b/packages/stack-auth/src/auto_refresh.rs index ef327b5fc..a00e654df 100644 --- a/packages/stack-auth/src/auto_refresh.rs +++ b/packages/stack-auth/src/auto_refresh.rs @@ -354,7 +354,7 @@ mod tests { token: Token, ) -> AutoRefresh { let store = ProfileStore::new(dir.path()); - store.set_current_workspace("ZVATKW3VHMFG27DY").unwrap(); + store.init_workspace("ZVATKW3VHMFG27DY").unwrap(); let ws_store = store.current_workspace_store().unwrap(); ws_store.save_profile(&token).unwrap(); let refresher = OAuthRefresher::new( @@ -980,7 +980,7 @@ mod stress_tests { token: Token, ) -> AutoRefresh { let store = ProfileStore::new(dir.path()); - store.set_current_workspace("ZVATKW3VHMFG27DY").unwrap(); + store.init_workspace("ZVATKW3VHMFG27DY").unwrap(); let ws_store = store.current_workspace_store().unwrap(); ws_store.save_profile(&token).unwrap(); let refresher = OAuthRefresher::new( diff --git a/packages/stack-auth/src/auto_strategy.rs b/packages/stack-auth/src/auto_strategy.rs index 9299280a5..55f92ef9c 100644 --- a/packages/stack-auth/src/auto_strategy.rs +++ b/packages/stack-auth/src/auto_strategy.rs @@ -245,7 +245,7 @@ mod tests { fn write_token_store(dir: &std::path::Path) -> ProfileStore { let store = ProfileStore::new(dir); - store.set_current_workspace("ZVATKW3VHMFG27DY").unwrap(); + store.init_workspace("ZVATKW3VHMFG27DY").unwrap(); let ws_store = store.current_workspace_store().unwrap(); ws_store.save_profile(&make_oauth_token()).unwrap(); store diff --git a/packages/stack-auth/src/device_client.rs b/packages/stack-auth/src/device_client.rs index 35d4df60a..b32c70478 100644 --- a/packages/stack-auth/src/device_client.rs +++ b/packages/stack-auth/src/device_client.rs @@ -198,7 +198,7 @@ mod tests { client_id: None, device_instance_id: None, }; - store.set_current_workspace(TEST_WORKSPACE_ID).unwrap(); + store.init_workspace(TEST_WORKSPACE_ID).unwrap(); let ws_store = store.current_workspace_store().unwrap(); ws_store.save_profile(&token).unwrap(); } @@ -246,7 +246,7 @@ mod tests { async fn skips_when_secret_key_exists() { let dir = TempDir::new().unwrap(); let store = ProfileStore::new(dir.path()); - store.set_current_workspace(TEST_WORKSPACE_ID).unwrap(); + store.init_workspace(TEST_WORKSPACE_ID).unwrap(); // Pre-populate secretkey.json in the workspace directory let ws_store = store.workspace_store(TEST_WORKSPACE_ID).unwrap(); diff --git a/packages/stack-auth/src/device_code/mod.rs b/packages/stack-auth/src/device_code/mod.rs index 8439095a6..d4daf3a79 100644 --- a/packages/stack-auth/src/device_code/mod.rs +++ b/packages/stack-auth/src/device_code/mod.rs @@ -341,7 +341,7 @@ impl PendingDeviceCode { None => ProfileStore::resolve(None)?, }; let workspace_id = token.workspace_id()?; - store.set_current_workspace(workspace_id.as_str())?; + store.init_workspace(workspace_id.as_str())?; store .workspace_store(workspace_id.as_str())? .save_profile(&token)?; diff --git a/packages/stack-profile/src/error.rs b/packages/stack-profile/src/error.rs index f88739c05..bb0cad253 100644 --- a/packages/stack-profile/src/error.rs +++ b/packages/stack-profile/src/error.rs @@ -28,4 +28,7 @@ pub enum ProfileError { /// The workspace ID is invalid (not a 16-character base32 string). #[error("Invalid workspace ID: {0}")] InvalidWorkspaceId(String), + /// The workspace has no local profile data (not logged in). + #[error("Workspace not found: {0}. Log in to this workspace first.")] + WorkspaceNotFound(String), } diff --git a/packages/stack-profile/src/profile_store.rs b/packages/stack-profile/src/profile_store.rs index 666efa403..d8cc0d256 100644 --- a/packages/stack-profile/src/profile_store.rs +++ b/packages/stack-profile/src/profile_store.rs @@ -127,15 +127,39 @@ impl ProfileStore { /// Set the current workspace. /// /// Writes the workspace ID to the `current_workspace` file in the profile - /// directory. Subsequent workspace-scoped operations will use this workspace. + /// directory. The workspace must already have a directory under `workspaces/` + /// (created during login). Use [`init_workspace`](Self::init_workspace) to + /// create a new workspace directory. + /// + /// Returns [`ProfileError::WorkspaceNotFound`] if the workspace directory + /// does not exist. pub fn set_current_workspace(&self, workspace_id: &str) -> Result<(), ProfileError> { Self::validate_workspace_id(workspace_id)?; + let ws_dir = self.dir.join(WORKSPACES_DIR).join(workspace_id); + if !ws_dir.is_dir() { + return Err(ProfileError::WorkspaceNotFound(workspace_id.to_string())); + } std::fs::create_dir_all(&self.dir)?; let path = self.dir.join(CURRENT_WORKSPACE_FILE); std::fs::write(&path, workspace_id)?; Ok(()) } + /// Create a workspace directory and set it as the current workspace. + /// + /// Unlike [`set_current_workspace`](Self::set_current_workspace), this + /// creates the workspace directory if it does not exist. Used during login + /// to initialize a new workspace. + pub fn init_workspace(&self, workspace_id: &str) -> Result<(), ProfileError> { + Self::validate_workspace_id(workspace_id)?; + // create_dir_all creates self.dir and workspaces/ as ancestors. + let ws_dir = self.dir.join(WORKSPACES_DIR).join(workspace_id); + std::fs::create_dir_all(&ws_dir)?; + let path = self.dir.join(CURRENT_WORKSPACE_FILE); + std::fs::write(&path, workspace_id)?; + Ok(()) + } + /// Return the current workspace ID. /// /// Returns [`ProfileError::NoCurrentWorkspace`] if no workspace has been set. @@ -678,6 +702,35 @@ mod tests { let store = ProfileStore::new(dir.path()); store.clear_current_workspace().unwrap(); } + + #[test] + fn set_current_workspace_returns_workspace_not_found() { + let dir = tempfile::tempdir().unwrap(); + let store = ProfileStore::new(dir.path()); + + let err = store.set_current_workspace(WS_A).unwrap_err(); + assert!( + matches!(err, ProfileError::WorkspaceNotFound(_)), + "expected WorkspaceNotFound, got: {err:?}" + ); + } + + #[test] + fn init_workspace_creates_dir_and_sets_current() { + let dir = tempfile::tempdir().unwrap(); + let store = ProfileStore::new(dir.path()); + + store.init_workspace(WS_A).unwrap(); + assert_eq!( + store.current_workspace().unwrap(), + WS_A, + "init_workspace should set the current workspace" + ); + assert!( + dir.path().join("workspaces").join(WS_A).is_dir(), + "init_workspace should create the workspace directory" + ); + } } mod given_workspace_set { @@ -686,7 +739,7 @@ mod tests { fn scenario() -> (tempfile::TempDir, ProfileStore) { let dir = tempfile::tempdir().unwrap(); let store = ProfileStore::new(dir.path()); - store.set_current_workspace(WS_A).unwrap(); + store.init_workspace(WS_A).unwrap(); (dir, store) } From a07e6d54f3b419e422bee65505bcd41f8e761b1e Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Fri, 3 Apr 2026 15:36:19 -0700 Subject: [PATCH 162/686] chore: gitignore compiled .node binary for stack-profile --- .../typescript/packages/profile/.gitignore | 4 ++++ .../packages/profile/stack-profile-node.node | Bin 2136408 -> 0 bytes 2 files changed, 4 insertions(+) create mode 100644 languages/typescript/packages/profile/.gitignore delete mode 100755 languages/typescript/packages/profile/stack-profile-node.node diff --git a/languages/typescript/packages/profile/.gitignore b/languages/typescript/packages/profile/.gitignore new file mode 100644 index 000000000..07a825bc4 --- /dev/null +++ b/languages/typescript/packages/profile/.gitignore @@ -0,0 +1,4 @@ +target/ +node_modules/ +*.node +npm/*/*.node diff --git a/languages/typescript/packages/profile/stack-profile-node.node b/languages/typescript/packages/profile/stack-profile-node.node deleted file mode 100755 index 0a27dd44b870769fcef56c8ca7c7d30213eeaa2a..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2136408 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

-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%0DITeKF73GI(zAY~{J9K0eEwz5b8zmR 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 -PhI|}#m9Nu)1MQk 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 RhrepYMv=S+(AE0?_Y z@JkL4|6zLNlJ_2d!x7={qgO6@@9X<-3xC8@`1mu+k5$(J2Xd|YN0asNo0*5{l}p}x z_|X@{{HH%P<|~)H_wWl|7=97Ga>;uSKkY@~pIH6>DB$~_a>;uSKczkVe0pX1{NF$F zzCQoR@ONuIvwZ&l-<)PWeE&s8^Ox_7K!Sw3Gs@8RdZCj80t%JTX7?>&6yYr}tuUb*DGho96F zexIks`jt!Gd-!$qbLo{!-h24r6Jq{P>6J^~d-ypghJVREv3})}_a1)gN#U=dS1x() z;U`WDzl~nG+qH&EXp_iU0p!<&yUve&yEi@1R#MdGFx|$HOnDSC-HF_a1)Ugvs>_ z^dEX<`MiJc;d>^9pEeNtSC(I!uAld_aT1T(f?faovH&lJ#K!*1o zejEK=^vd${>w2g69)8Z7V*Q6V#(ZV@4as{C-}C11_tPuOcPH;X{381O4~Y57@@>g` z55Izb2EDTUl;pjKUrT>2y|VnA-_{Ii}O`&UN4 zyPw~^hi`mK_;=7N%TKF54-RC^_a45N{$6@z`Hs5Y>AiiKW55JxM0(xcn{PVl_ z@T*Uc>wh=Ba>;uSzvyk@$LW>jS60^m2Xf8MM~3g|4S&>uas8Ch_wWPsf2UV2dGF!3(7)iI*uS#;ruy!8?%#X(weN`a zpGmJQKc2kz@N3=~{`2(8@{P%R55IwapQc#9a>;uSzm@(2^vWggJ^V!e0mrZDl}p}x z_$liKW55J7Q>yTK#a>;uSKSuv~dgYS$9)2tRe$R>d$|dhTeB-;~`kzm)T=L$-Po;l| zURgfB{&^4I^zNAd-si^pmF3&g^T&Jm9{Ru0E6evJ?>+n={rjpPKJfF0vi#EIy@&7U zi}i1%SC;Qe-h23Y^z)t<^OZ~9d-zfMae8I>{QlK@_|5be9UAkMOWu3z98l+m%R7z9rHM!URi!x)H~Miy}$m|Iru4d}Vn&hj1X*?0jVS4*Gqne^CzW zS4I!pU47s{hW8$RKK%&2vV22b@ATfoFQo5#am-hiUx_=_fg8;C9=`iSvH!d2m2p1S zpS}0+6V4C+w3ozuW%>T<6dcHy?>&4!{afjkOWu3+oF`d4!o*cl637?>+oF`lDXT>!0T5=bz5U=ZEiJ5ZCW=dS%U@lJ@UC{4DyP(<_&} z_we)RpL%qxUs*mse%`|`r9X>aSw6pi_a1&d{T=kmCGS1_IDX<}1tR`QF2Cq#vSJmS3Ibdk??#;<$c$ zye#G`%jd5@@8QSjUq`PjpP&EU!*^a1^RJ>;mLE&&_a1)IK=^g^%JS2a_a1%*ebdWh z|H>urJ^V2JYZS=||?>+ngeP?IvU%BMHhhIxSK(Ac# z-orP2EY?3puUzup!}rlQz9QDIT=L$-kJ8VgS1x();U`=g>&O4EEM7m9OWu3iKW55JE7NA$`i?>+oB`bk}} zf8~<*9=`GNxPGsqS1x();XCLrpjR$=@8M_A-$k!1pMQSz9)1b^zv-1r-h22>^hX{S z*H5|Ry@zl6cw9gH{{rLdU%BMHho49PMSA6u_a1&F{a@&nOWu3EÎ!!M?9c~$IRS-vrU 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Î!>^?O+RMT#%jf5x_wXC(|MBwh$|dhT{KVhK^?Ttf!Yh}& z_wcRsbB+zKEZ zR~#Q+S-uOys{ldn3EYj!>|{2={%Umso>J)V2{`R6_SDE+_Q5MH_D zy@y{O2?LGW3{b8rX zeC3k&zP^9@57TdFmd}r$_x1h%Dfa(k`ex>m_rAV=p8vq9vHtGlS69y=9Qb^->L1?2 zPx^Drf9YGoE0^Yb58p;uSKSqBYy>iKW58wJwtiR!1F<)6eKYrfBPouw_Ub*DGukWA!neXNPnM;1I z_3(?CucKF%&)463_^tGfXU2SG`TXZ^-oy87iR*U_y|R41e%`~+q2GT_%vUaX@8JhE zpI*7-y@wyw{P)Ft<&yUvem(tSdgYS$9)3Ih0cXX0<&yUvzWJ|l{jZ}}E_v_ayXg;l zf6P~wÎ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

DaY>r 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 z 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 From df7870dbf9cb15a328c1a360e5806dd3ee5a11be Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Fri, 3 Apr 2026 15:44:48 -0700 Subject: [PATCH 163/686] fix: use npm install instead of npm ci in integration test tasks npm ci fails when the lockfile drifts from package.json (e.g. after version bumps). npm install is more tolerant and updates the lockfile automatically. Also regenerates the stale stack-auth lockfile. --- .../packages/auth/package-lock.json | 66 +++++++++++++++++-- packages/stack-auth/tasks.toml | 2 +- packages/stack-profile/tasks.toml | 2 +- 3 files changed, 62 insertions(+), 8 deletions(-) diff --git a/languages/typescript/packages/auth/package-lock.json b/languages/typescript/packages/auth/package-lock.json index 9ff00b2eb..020c0ae89 100644 --- a/languages/typescript/packages/auth/package-lock.json +++ b/languages/typescript/packages/auth/package-lock.json @@ -22,22 +22,76 @@ } }, "node_modules/@cipherstash/auth-darwin-arm64": { - "optional": true + "version": "0.35.0", + "resolved": "https://registry.npmjs.org/@cipherstash/auth-darwin-arm64/-/auth-darwin-arm64-0.35.0.tgz", + "integrity": "sha512-OnNXQhrNe9puoQdqdisMaG2sQj6DmCcVr4VZZ2e6uOWq7qO3aGfCg3Hmt1LdzwyfCyEMrKtC9rgFFkws/Ac68Q==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "darwin" + ] }, "node_modules/@cipherstash/auth-darwin-x64": { - "optional": true + "version": "0.35.0", + "resolved": "https://registry.npmjs.org/@cipherstash/auth-darwin-x64/-/auth-darwin-x64-0.35.0.tgz", + "integrity": "sha512-Z9ohtZ+rYsQmkU9OU8yLHVADxsPIzh9EvAN2h2l+qrL296NtBj/U6XuuxvnMG6TsQEdMeRufokh0tj5H0fxvXg==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "darwin" + ] }, "node_modules/@cipherstash/auth-linux-arm64-gnu": { - "optional": true + "version": "0.35.0", + "resolved": "https://registry.npmjs.org/@cipherstash/auth-linux-arm64-gnu/-/auth-linux-arm64-gnu-0.35.0.tgz", + "integrity": "sha512-XxXrBwDA3widKzz+FUev3cMA9nUd6pA3fe4c1aNnaxKqSk3TdvxLlrI/MSqW3OKV8ugf7XXbTCgAilj5m+E8uQ==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ] }, "node_modules/@cipherstash/auth-linux-x64-gnu": { - "optional": true + "version": "0.35.0", + "resolved": "https://registry.npmjs.org/@cipherstash/auth-linux-x64-gnu/-/auth-linux-x64-gnu-0.35.0.tgz", + "integrity": "sha512-1/MaFUbQ2fZkadCnjpLbUkppLPeJx7CxVtZrrny7Nb9QupP9dLlJpMZWJlLhzXmabCGoCSMByjEhaaHynL9owg==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ] }, "node_modules/@cipherstash/auth-linux-x64-musl": { - "optional": true + "version": "0.35.0", + "resolved": "https://registry.npmjs.org/@cipherstash/auth-linux-x64-musl/-/auth-linux-x64-musl-0.35.0.tgz", + "integrity": "sha512-9Ol9ykkcwR3ohlvFO4hn+SUdZEPhijtHRLHxUBfimmryuUwTBzQpEkYfes90bVhkzeeQBxwx23IQUGRg7bmiHg==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ] }, "node_modules/@cipherstash/auth-win32-x64-msvc": { - "optional": true + "version": "0.35.0", + "resolved": "https://registry.npmjs.org/@cipherstash/auth-win32-x64-msvc/-/auth-win32-x64-msvc-0.35.0.tgz", + "integrity": "sha512-9NyN53KSwGwOKmV0kAXl7R90lb7SAGPhLdUp7PpeeNIbK1ZqnutCOTTq+Y0jbgz8YCKjAJda1QruqYGhnZkEtA==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "win32" + ] }, "node_modules/@esbuild/aix-ppc64": { "version": "0.27.3", diff --git a/packages/stack-auth/tasks.toml b/packages/stack-auth/tasks.toml index b31121846..5fb87feb1 100644 --- a/packages/stack-auth/tasks.toml +++ b/packages/stack-auth/tasks.toml @@ -2,7 +2,7 @@ description = "Run stack-auth Node.js integration tests" dir = "{{config_root}}/packages/stack-auth/node" run = [ - "npm ci", + "npm install", "cargo build -p stack-auth-node --features stack-auth-node/test-utils", "cp ../../../target/debug/libstack_auth_node.dylib stack-auth-node.node 2>/dev/null || cp ../../../target/debug/libstack_auth_node.so stack-auth-node.node", "npx vitest run", diff --git a/packages/stack-profile/tasks.toml b/packages/stack-profile/tasks.toml index 964e36d12..70090a99e 100644 --- a/packages/stack-profile/tasks.toml +++ b/packages/stack-profile/tasks.toml @@ -2,7 +2,7 @@ description = "Run stack-profile Node.js integration tests" dir = "{{config_root}}/packages/stack-profile/node" run = [ - "npm ci", + "npm install", "cargo build -p stack-profile-node", "cp ../../../target/debug/libstack_profile_node.dylib stack-profile-node.node 2>/dev/null || cp ../../../target/debug/libstack_profile_node.so stack-profile-node.node", "npx vitest run", From a006e86c1478e7c0168e931cd82a4a843f5654fe Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Fri, 3 Apr 2026 15:48:57 -0700 Subject: [PATCH 164/686] docs: mention CS_CONFIG_PATH in README header --- languages/typescript/packages/profile/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/languages/typescript/packages/profile/README.md b/languages/typescript/packages/profile/README.md index a36d8f001..f82736b72 100644 --- a/languages/typescript/packages/profile/README.md +++ b/languages/typescript/packages/profile/README.md @@ -7,7 +7,7 @@ Native Node.js bindings for managing [CipherStash](https://cipherstash.com) workspace profiles. -Profiles are stored in `~/.cipherstash/` with per-workspace directories for auth tokens and encryption keys. +Profiles are stored in `~/.cipherstash/` (or the path specified by `CS_CONFIG_PATH`) with per-workspace directories for auth tokens and encryption keys. ## Installation From 63180162731a554e3ebe568b2abd8c40ed22d8f1 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 8 Apr 2026 06:01:49 +0000 Subject: [PATCH 165/686] chore: release v0.34.0-alpha.6 --- packages/stack-auth/CHANGELOG.md | 45 +++++++++++++++++++++++++++++ packages/stack-profile/CHANGELOG.md | 1 + packages/stack-profile/Cargo.toml | 2 +- 3 files changed, 47 insertions(+), 1 deletion(-) diff --git a/packages/stack-auth/CHANGELOG.md b/packages/stack-auth/CHANGELOG.md index 1f5f08500..7f554dda2 100644 --- a/packages/stack-auth/CHANGELOG.md +++ b/packages/stack-auth/CHANGELOG.md @@ -1,5 +1,50 @@ +### Documentation + +- 📝 add TypeScript example for AutoStrategy usage +- 📝 add CHANGELOG.md for @cipherstash/auth +- 📝 add INVALID_CRN to changelog error codes +- 📝 demonstrate whoami (subject/workspace) in examples +- 📝 update CHANGELOG with whoami fields and security notes + +### Features + +- ✨ expose auth strategies in @cipherstash/auth Node bindings +- ✨ add subject() and workspace_id() to ServiceToken +- add multi-workspace profile support (CIP-2942) +- require workspace to exist before switching + +### Fixes + +- 🩹 add INVALID_CRN error code and deduplicate zerokms_url +- 🔒️ derive OpaqueDebug on TokenResult to prevent token leaks +- 🔒️ derive OpaqueDebug on AutoStrategyOptions +- update integration tests for workspace-scoped profiles +- hard-error on token persistence failure, strengthen test assertions +- use npm install instead of npm ci in integration test tasks + +### Miscellaneous + +- 🔖 bump @cipherstash/auth to 0.35.0 +- 🔧 regenerate index.d.ts from napi build +- release + +### Refactoring + +- ♻️ restructure stack-auth-node tests to follow conventions +- simplify workspace store usage + +### Testing + +- ✅ add unit tests for exposed auth strategies + +### Style + +- 💄 fix cargo fmt formatting +- 🎨 remove redundant comments from examples + + ### Documentation - 📝 add TypeScript example for AutoStrategy usage diff --git a/packages/stack-profile/CHANGELOG.md b/packages/stack-profile/CHANGELOG.md index 6d51fc540..7b4a00a2f 100644 --- a/packages/stack-profile/CHANGELOG.md +++ b/packages/stack-profile/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 + ## [0.34.0-alpha.1] - 2026-03-04 ### Changed diff --git a/packages/stack-profile/Cargo.toml b/packages/stack-profile/Cargo.toml index c30ad880e..e60099284 100644 --- a/packages/stack-profile/Cargo.toml +++ b/packages/stack-profile/Cargo.toml @@ -2,7 +2,7 @@ name = "stack-profile" description = "Centralised ~/.cipherstash profile file management" license-file = "LICENSE" -version = "0.34.0-alpha.6" +version = "0.34.1-alpha.1" edition.workspace = true authors.workspace = true repository.workspace = true From 955abfea840b362bf87f946bd9a96e82452c06c7 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 8 Apr 2026 23:59:20 +0000 Subject: [PATCH 166/686] chore: release --- packages/stack-auth/CHANGELOG.md | 5 +++++ packages/stack-profile/CHANGELOG.md | 1 + packages/stack-profile/Cargo.toml | 2 +- 3 files changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/stack-auth/CHANGELOG.md b/packages/stack-auth/CHANGELOG.md index 7f554dda2..719739c1c 100644 --- a/packages/stack-auth/CHANGELOG.md +++ b/packages/stack-auth/CHANGELOG.md @@ -1,5 +1,10 @@ +### Miscellaneous + +- updated the following local packages: cts-common, cts-common, stack-profile, zerokms-protocol + + ### Documentation - 📝 add TypeScript example for AutoStrategy usage diff --git a/packages/stack-profile/CHANGELOG.md b/packages/stack-profile/CHANGELOG.md index 7b4a00a2f..17cc3eb60 100644 --- a/packages/stack-profile/CHANGELOG.md +++ b/packages/stack-profile/CHANGELOG.md @@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 + ## [0.34.0-alpha.1] - 2026-03-04 ### Changed diff --git a/packages/stack-profile/Cargo.toml b/packages/stack-profile/Cargo.toml index e60099284..22f30feef 100644 --- a/packages/stack-profile/Cargo.toml +++ b/packages/stack-profile/Cargo.toml @@ -2,7 +2,7 @@ name = "stack-profile" description = "Centralised ~/.cipherstash profile file management" license-file = "LICENSE" -version = "0.34.1-alpha.1" +version = "0.34.1-alpha.2" edition.workspace = true authors.workspace = true repository.workspace = true From dfe10c4136c581dccb402b100de2818c5157926e Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Wed, 8 Apr 2026 17:53:01 -0700 Subject: [PATCH 167/686] chore: use explicit versions for cipherstash-client and stack-auth Replace version.workspace = true with explicit version strings so these crates manage their versions independently like all other crates. --- packages/stack-auth/Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/stack-auth/Cargo.toml b/packages/stack-auth/Cargo.toml index ba7de7178..e81196f8d 100644 --- a/packages/stack-auth/Cargo.toml +++ b/packages/stack-auth/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "stack-auth" description = "Authentication library for CipherStash services" -version.workspace = true +version = "0.34.1-alpha.2" edition.workspace = true authors.workspace = true repository.workspace = true From be53c7cf618966b1da28d1df863b51bc4870dc3d Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 9 Apr 2026 01:32:53 +0000 Subject: [PATCH 168/686] chore: release v0.34.1-alpha.2 --- packages/stack-auth/CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/stack-auth/CHANGELOG.md b/packages/stack-auth/CHANGELOG.md index 719739c1c..7ad13fca7 100644 --- a/packages/stack-auth/CHANGELOG.md +++ b/packages/stack-auth/CHANGELOG.md @@ -1,5 +1,11 @@ +### Miscellaneous + +- release +- use explicit versions for cipherstash-client and stack-auth + + ### Miscellaneous - updated the following local packages: cts-common, cts-common, stack-profile, zerokms-protocol From 2e771a3280f625ea66ae03d9730e9a3c8ae34518 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 15 Apr 2026 04:39:48 +0000 Subject: [PATCH 169/686] chore: release --- packages/stack-auth/CHANGELOG.md | 5 +++++ packages/stack-auth/Cargo.toml | 2 +- packages/stack-profile/CHANGELOG.md | 1 + packages/stack-profile/Cargo.toml | 2 +- 4 files changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/stack-auth/CHANGELOG.md b/packages/stack-auth/CHANGELOG.md index 7ad13fca7..725a257c5 100644 --- a/packages/stack-auth/CHANGELOG.md +++ b/packages/stack-auth/CHANGELOG.md @@ -1,5 +1,10 @@ +### Miscellaneous + +- release v0.34.1-alpha.2 + + ### Miscellaneous - release diff --git a/packages/stack-auth/Cargo.toml b/packages/stack-auth/Cargo.toml index e81196f8d..5fde4bb1d 100644 --- a/packages/stack-auth/Cargo.toml +++ b/packages/stack-auth/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "stack-auth" description = "Authentication library for CipherStash services" -version = "0.34.1-alpha.2" +version = "0.34.1-alpha.3" edition.workspace = true authors.workspace = true repository.workspace = true diff --git a/packages/stack-profile/CHANGELOG.md b/packages/stack-profile/CHANGELOG.md index 17cc3eb60..f5c7f6614 100644 --- a/packages/stack-profile/CHANGELOG.md +++ b/packages/stack-profile/CHANGELOG.md @@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 + ## [0.34.0-alpha.1] - 2026-03-04 ### Changed diff --git a/packages/stack-profile/Cargo.toml b/packages/stack-profile/Cargo.toml index 22f30feef..68472aefe 100644 --- a/packages/stack-profile/Cargo.toml +++ b/packages/stack-profile/Cargo.toml @@ -2,7 +2,7 @@ name = "stack-profile" description = "Centralised ~/.cipherstash profile file management" license-file = "LICENSE" -version = "0.34.1-alpha.2" +version = "0.34.1-alpha.3" edition.workspace = true authors.workspace = true repository.workspace = true From 03e856c4bde2a1d5a60bb51ec9eb0ab5a88eb9ce Mon Sep 17 00:00:00 2001 From: CJ Brewer Date: Fri, 17 Apr 2026 11:41:03 -0600 Subject: [PATCH 170/686] feat: bump @cipherstash/auth to 0.36.0 --- languages/typescript/packages/auth/package.json | 14 +++++++------- .../auth/platforms/darwin-arm64/package.json | 2 +- .../auth/platforms/darwin-x64/package.json | 2 +- .../auth/platforms/linux-arm64-gnu/package.json | 2 +- .../auth/platforms/linux-x64-gnu/package.json | 2 +- .../auth/platforms/linux-x64-musl/package.json | 2 +- .../auth/platforms/win32-x64-msvc/package.json | 2 +- 7 files changed, 13 insertions(+), 13 deletions(-) diff --git a/languages/typescript/packages/auth/package.json b/languages/typescript/packages/auth/package.json index 6e952fb11..5c571888c 100644 --- a/languages/typescript/packages/auth/package.json +++ b/languages/typescript/packages/auth/package.json @@ -1,6 +1,6 @@ { "name": "@cipherstash/auth", - "version": "0.35.0", + "version": "0.36.0", "main": "index.js", "types": "index.d.ts", "napi": { @@ -30,12 +30,12 @@ "test": "npm run build:test && vitest run" }, "optionalDependencies": { - "@cipherstash/auth-darwin-x64": "0.35.0", - "@cipherstash/auth-darwin-arm64": "0.35.0", - "@cipherstash/auth-linux-x64-gnu": "0.35.0", - "@cipherstash/auth-linux-arm64-gnu": "0.35.0", - "@cipherstash/auth-linux-x64-musl": "0.35.0", - "@cipherstash/auth-win32-x64-msvc": "0.35.0" + "@cipherstash/auth-darwin-x64": "0.36.0", + "@cipherstash/auth-darwin-arm64": "0.36.0", + "@cipherstash/auth-linux-x64-gnu": "0.36.0", + "@cipherstash/auth-linux-arm64-gnu": "0.36.0", + "@cipherstash/auth-linux-x64-musl": "0.36.0", + "@cipherstash/auth-win32-x64-msvc": "0.36.0" }, "devDependencies": { "@napi-rs/cli": "^2", diff --git a/languages/typescript/packages/auth/platforms/darwin-arm64/package.json b/languages/typescript/packages/auth/platforms/darwin-arm64/package.json index f9241ea76..fa22e0ddd 100644 --- a/languages/typescript/packages/auth/platforms/darwin-arm64/package.json +++ b/languages/typescript/packages/auth/platforms/darwin-arm64/package.json @@ -1,6 +1,6 @@ { "name": "@cipherstash/auth-darwin-arm64", - "version": "0.35.0", + "version": "0.36.0", "os": [ "darwin" ], diff --git a/languages/typescript/packages/auth/platforms/darwin-x64/package.json b/languages/typescript/packages/auth/platforms/darwin-x64/package.json index 944b12a11..18e8af633 100644 --- a/languages/typescript/packages/auth/platforms/darwin-x64/package.json +++ b/languages/typescript/packages/auth/platforms/darwin-x64/package.json @@ -1,6 +1,6 @@ { "name": "@cipherstash/auth-darwin-x64", - "version": "0.35.0", + "version": "0.36.0", "os": [ "darwin" ], diff --git a/languages/typescript/packages/auth/platforms/linux-arm64-gnu/package.json b/languages/typescript/packages/auth/platforms/linux-arm64-gnu/package.json index 5ac1fb8f1..11e284d0a 100644 --- a/languages/typescript/packages/auth/platforms/linux-arm64-gnu/package.json +++ b/languages/typescript/packages/auth/platforms/linux-arm64-gnu/package.json @@ -1,6 +1,6 @@ { "name": "@cipherstash/auth-linux-arm64-gnu", - "version": "0.35.0", + "version": "0.36.0", "os": [ "linux" ], diff --git a/languages/typescript/packages/auth/platforms/linux-x64-gnu/package.json b/languages/typescript/packages/auth/platforms/linux-x64-gnu/package.json index f838ea865..986d4f9fc 100644 --- a/languages/typescript/packages/auth/platforms/linux-x64-gnu/package.json +++ b/languages/typescript/packages/auth/platforms/linux-x64-gnu/package.json @@ -1,6 +1,6 @@ { "name": "@cipherstash/auth-linux-x64-gnu", - "version": "0.35.0", + "version": "0.36.0", "os": [ "linux" ], diff --git a/languages/typescript/packages/auth/platforms/linux-x64-musl/package.json b/languages/typescript/packages/auth/platforms/linux-x64-musl/package.json index 08a6b7487..3c7a59c18 100644 --- a/languages/typescript/packages/auth/platforms/linux-x64-musl/package.json +++ b/languages/typescript/packages/auth/platforms/linux-x64-musl/package.json @@ -1,6 +1,6 @@ { "name": "@cipherstash/auth-linux-x64-musl", - "version": "0.35.0", + "version": "0.36.0", "os": [ "linux" ], diff --git a/languages/typescript/packages/auth/platforms/win32-x64-msvc/package.json b/languages/typescript/packages/auth/platforms/win32-x64-msvc/package.json index c87585df9..981433b40 100644 --- a/languages/typescript/packages/auth/platforms/win32-x64-msvc/package.json +++ b/languages/typescript/packages/auth/platforms/win32-x64-msvc/package.json @@ -1,6 +1,6 @@ { "name": "@cipherstash/auth-win32-x64-msvc", - "version": "0.35.0", + "version": "0.36.0", "os": [ "win32" ], From ecd8c935e935ffa267a55d01557923b1127d64ab Mon Sep 17 00:00:00 2001 From: Lindsay Holmwood Date: Tue, 28 Apr 2026 16:04:02 +1000 Subject: [PATCH 171/686] fix(ci): use bash with a non-login shell --- .github/imported-workflows/test-stack-auth.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/imported-workflows/test-stack-auth.yml b/.github/imported-workflows/test-stack-auth.yml index 0b513cec5..4bd694cb6 100644 --- a/.github/imported-workflows/test-stack-auth.yml +++ b/.github/imported-workflows/test-stack-auth.yml @@ -22,7 +22,7 @@ on: defaults: run: - shell: bash -l {0} + shell: bash env: RUSTFLAGS: "-D warnings" From 407e72269d4b7aea841ef02d38632b34248bd407 Mon Sep 17 00:00:00 2001 From: Lindsay Holmwood Date: Tue, 28 Apr 2026 16:05:23 +1000 Subject: [PATCH 172/686] chore(ci): use latest actions/checkout, to silence warnings --- .github/imported-workflows/test-stack-auth.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/imported-workflows/test-stack-auth.yml b/.github/imported-workflows/test-stack-auth.yml index 4bd694cb6..bbf367d78 100644 --- a/.github/imported-workflows/test-stack-auth.yml +++ b/.github/imported-workflows/test-stack-auth.yml @@ -36,7 +36,7 @@ jobs: runs-on: blacksmith-8vcpu-ubuntu-2404 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - uses: ./.github/actions/setup-rust - name: Setup Node.js From 0b046d6665bd6e6e4d79fd0888b0820faa8483de Mon Sep 17 00:00:00 2001 From: Lindsay Holmwood Date: Tue, 28 Apr 2026 20:48:42 +1000 Subject: [PATCH 173/686] fix(ci): use bash with a non-login shell --- .github/imported-workflows/test-stack-profile.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/imported-workflows/test-stack-profile.yml b/.github/imported-workflows/test-stack-profile.yml index afba7bffd..32a5f1a0e 100644 --- a/.github/imported-workflows/test-stack-profile.yml +++ b/.github/imported-workflows/test-stack-profile.yml @@ -22,7 +22,7 @@ on: defaults: run: - shell: bash -l {0} + shell: bash env: RUSTFLAGS: "-D warnings" From c3d5c70811f957e63d1fbb7a35083405aa1e0031 Mon Sep 17 00:00:00 2001 From: Lindsay Holmwood Date: Tue, 28 Apr 2026 20:50:47 +1000 Subject: [PATCH 174/686] chore(ci): use latest actions/checkout, to silence warnings --- .github/imported-workflows/test-stack-profile.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/imported-workflows/test-stack-profile.yml b/.github/imported-workflows/test-stack-profile.yml index 32a5f1a0e..ec242ec12 100644 --- a/.github/imported-workflows/test-stack-profile.yml +++ b/.github/imported-workflows/test-stack-profile.yml @@ -36,7 +36,7 @@ jobs: runs-on: blacksmith-8vcpu-ubuntu-2404 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - uses: ./.github/actions/setup-rust - name: Setup Node.js From 852ccf323283f8cc0463d7eba8561eb5a64e3fa4 Mon Sep 17 00:00:00 2001 From: Lindsay Holmwood Date: Tue, 28 Apr 2026 20:54:06 +1000 Subject: [PATCH 175/686] fix(ci): use bash with a non-login shell --- .github/imported-workflows/publish-auth-npm.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/imported-workflows/publish-auth-npm.yml b/.github/imported-workflows/publish-auth-npm.yml index 265fa9be0..924de2de5 100644 --- a/.github/imported-workflows/publish-auth-npm.yml +++ b/.github/imported-workflows/publish-auth-npm.yml @@ -17,7 +17,7 @@ permissions: defaults: run: - shell: bash -l {0} + shell: bash env: CARGO_TERM_COLOR: always From 7db82202bbecd9f8c31074473bba820c8c045312 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 29 Apr 2026 03:50:12 +0000 Subject: [PATCH 176/686] build(deps-dev): bump postcss in /packages/stack-profile/node Bumps [postcss](https://github.com/postcss/postcss) from 8.5.8 to 8.5.12. - [Release notes](https://github.com/postcss/postcss/releases) - [Changelog](https://github.com/postcss/postcss/blob/main/CHANGELOG.md) - [Commits](https://github.com/postcss/postcss/compare/8.5.8...8.5.12) --- updated-dependencies: - dependency-name: postcss dependency-version: 8.5.12 dependency-type: indirect ... Signed-off-by: dependabot[bot] --- languages/typescript/packages/profile/package-lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/languages/typescript/packages/profile/package-lock.json b/languages/typescript/packages/profile/package-lock.json index 4ef84280d..d9d691744 100644 --- a/languages/typescript/packages/profile/package-lock.json +++ b/languages/typescript/packages/profile/package-lock.json @@ -1260,9 +1260,9 @@ } }, "node_modules/postcss": { - "version": "8.5.8", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", - "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "version": "8.5.12", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.12.tgz", + "integrity": "sha512-W62t/Se6rA0Az3DfCL0AqJwXuKwBeYg6nOaIgzP+xZ7N5BFCI7DYi1qs6ygUYT6rvfi6t9k65UMLJC+PHZpDAA==", "dev": true, "funding": [ { From bb9b2a29a7ef1d7b3fe4aa5d3220cf62c46b5ee2 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 4 May 2026 01:02:27 +0000 Subject: [PATCH 177/686] build(deps-dev): bump vite in /packages/stack-profile/node Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 7.3.1 to 7.3.2. - [Release notes](https://github.com/vitejs/vite/releases) - [Changelog](https://github.com/vitejs/vite/blob/v7.3.2/packages/vite/CHANGELOG.md) - [Commits](https://github.com/vitejs/vite/commits/v7.3.2/packages/vite) --- updated-dependencies: - dependency-name: vite dependency-version: 7.3.2 dependency-type: indirect ... Signed-off-by: dependabot[bot] --- languages/typescript/packages/profile/package-lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/languages/typescript/packages/profile/package-lock.json b/languages/typescript/packages/profile/package-lock.json index 4ef84280d..3319ff774 100644 --- a/languages/typescript/packages/profile/package-lock.json +++ b/languages/typescript/packages/profile/package-lock.json @@ -1453,9 +1453,9 @@ } }, "node_modules/vite": { - "version": "7.3.1", - "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", - "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", + "version": "7.3.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.2.tgz", + "integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==", "dev": true, "license": "MIT", "dependencies": { From 31b1d4aeafb6fc6c82f49d479c4b45161b3f78b1 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 4 May 2026 01:02:28 +0000 Subject: [PATCH 178/686] build(deps-dev): bump vite in /packages/stack-auth/node Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 7.3.1 to 7.3.2. - [Release notes](https://github.com/vitejs/vite/releases) - [Changelog](https://github.com/vitejs/vite/blob/v7.3.2/packages/vite/CHANGELOG.md) - [Commits](https://github.com/vitejs/vite/commits/v7.3.2/packages/vite) --- updated-dependencies: - dependency-name: vite dependency-version: 7.3.2 dependency-type: indirect ... Signed-off-by: dependabot[bot] --- .../packages/auth/package-lock.json | 58 +++++++++---------- 1 file changed, 29 insertions(+), 29 deletions(-) diff --git a/languages/typescript/packages/auth/package-lock.json b/languages/typescript/packages/auth/package-lock.json index 020c0ae89..956a377db 100644 --- a/languages/typescript/packages/auth/package-lock.json +++ b/languages/typescript/packages/auth/package-lock.json @@ -1,30 +1,30 @@ { "name": "@cipherstash/auth", - "version": "0.35.0", + "version": "0.36.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@cipherstash/auth", - "version": "0.35.0", + "version": "0.36.0", "devDependencies": { "@napi-rs/cli": "^2", "typescript": "^5", "vitest": "^3" }, "optionalDependencies": { - "@cipherstash/auth-darwin-arm64": "0.35.0", - "@cipherstash/auth-darwin-x64": "0.35.0", - "@cipherstash/auth-linux-arm64-gnu": "0.35.0", - "@cipherstash/auth-linux-x64-gnu": "0.35.0", - "@cipherstash/auth-linux-x64-musl": "0.35.0", - "@cipherstash/auth-win32-x64-msvc": "0.35.0" + "@cipherstash/auth-darwin-arm64": "0.36.0", + "@cipherstash/auth-darwin-x64": "0.36.0", + "@cipherstash/auth-linux-arm64-gnu": "0.36.0", + "@cipherstash/auth-linux-x64-gnu": "0.36.0", + "@cipherstash/auth-linux-x64-musl": "0.36.0", + "@cipherstash/auth-win32-x64-msvc": "0.36.0" } }, "node_modules/@cipherstash/auth-darwin-arm64": { - "version": "0.35.0", - "resolved": "https://registry.npmjs.org/@cipherstash/auth-darwin-arm64/-/auth-darwin-arm64-0.35.0.tgz", - "integrity": "sha512-OnNXQhrNe9puoQdqdisMaG2sQj6DmCcVr4VZZ2e6uOWq7qO3aGfCg3Hmt1LdzwyfCyEMrKtC9rgFFkws/Ac68Q==", + "version": "0.36.0", + "resolved": "https://registry.npmjs.org/@cipherstash/auth-darwin-arm64/-/auth-darwin-arm64-0.36.0.tgz", + "integrity": "sha512-KnRBW90HHJdxtMTjts1OxnlKdcuKdkWfdd+XwZXWCGlzlIxjq2QGMoVlvGzk7kMoZapLomRMn+f4RBzM1dwsWQ==", "cpu": [ "arm64" ], @@ -34,9 +34,9 @@ ] }, "node_modules/@cipherstash/auth-darwin-x64": { - "version": "0.35.0", - "resolved": "https://registry.npmjs.org/@cipherstash/auth-darwin-x64/-/auth-darwin-x64-0.35.0.tgz", - "integrity": "sha512-Z9ohtZ+rYsQmkU9OU8yLHVADxsPIzh9EvAN2h2l+qrL296NtBj/U6XuuxvnMG6TsQEdMeRufokh0tj5H0fxvXg==", + "version": "0.36.0", + "resolved": "https://registry.npmjs.org/@cipherstash/auth-darwin-x64/-/auth-darwin-x64-0.36.0.tgz", + "integrity": "sha512-bCAdJSwAz79mFr36GeGn4IddDCRQokFcqV1qzmTsgzjt8Q3B+vmglY7uoGWQUJWTyfDrflEH1P+kivGeKYehyQ==", "cpu": [ "x64" ], @@ -46,9 +46,9 @@ ] }, "node_modules/@cipherstash/auth-linux-arm64-gnu": { - "version": "0.35.0", - "resolved": "https://registry.npmjs.org/@cipherstash/auth-linux-arm64-gnu/-/auth-linux-arm64-gnu-0.35.0.tgz", - "integrity": "sha512-XxXrBwDA3widKzz+FUev3cMA9nUd6pA3fe4c1aNnaxKqSk3TdvxLlrI/MSqW3OKV8ugf7XXbTCgAilj5m+E8uQ==", + "version": "0.36.0", + "resolved": "https://registry.npmjs.org/@cipherstash/auth-linux-arm64-gnu/-/auth-linux-arm64-gnu-0.36.0.tgz", + "integrity": "sha512-PDpm1EHC1XzVtEDGzcyr0UXNca8IFkfPusqqVJ5CSpzCtlYipIClYui197zQ4NGMHIAQD168IEFOK2TROyb4Tw==", "cpu": [ "arm64" ], @@ -58,9 +58,9 @@ ] }, "node_modules/@cipherstash/auth-linux-x64-gnu": { - "version": "0.35.0", - "resolved": "https://registry.npmjs.org/@cipherstash/auth-linux-x64-gnu/-/auth-linux-x64-gnu-0.35.0.tgz", - "integrity": "sha512-1/MaFUbQ2fZkadCnjpLbUkppLPeJx7CxVtZrrny7Nb9QupP9dLlJpMZWJlLhzXmabCGoCSMByjEhaaHynL9owg==", + "version": "0.36.0", + "resolved": "https://registry.npmjs.org/@cipherstash/auth-linux-x64-gnu/-/auth-linux-x64-gnu-0.36.0.tgz", + "integrity": "sha512-Gm20ezVlGmNrkMH4s+I+JT13hDRD6vEX3fu3VDQQhWUiYCdgbdVsNJQgOr6QMY1cJkkmGyNlQKfiCPn4zlqtMg==", "cpu": [ "x64" ], @@ -70,9 +70,9 @@ ] }, "node_modules/@cipherstash/auth-linux-x64-musl": { - "version": "0.35.0", - "resolved": "https://registry.npmjs.org/@cipherstash/auth-linux-x64-musl/-/auth-linux-x64-musl-0.35.0.tgz", - "integrity": "sha512-9Ol9ykkcwR3ohlvFO4hn+SUdZEPhijtHRLHxUBfimmryuUwTBzQpEkYfes90bVhkzeeQBxwx23IQUGRg7bmiHg==", + "version": "0.36.0", + "resolved": "https://registry.npmjs.org/@cipherstash/auth-linux-x64-musl/-/auth-linux-x64-musl-0.36.0.tgz", + "integrity": "sha512-RUQeLc19JnURAMEoemP3+2DyptK+pqNFrVGgiKKOMVql0SZDVMlN2IyFrTKJ2emv1yuf4Gr1+E4jIdKPR0Oh+g==", "cpu": [ "x64" ], @@ -82,9 +82,9 @@ ] }, "node_modules/@cipherstash/auth-win32-x64-msvc": { - "version": "0.35.0", - "resolved": "https://registry.npmjs.org/@cipherstash/auth-win32-x64-msvc/-/auth-win32-x64-msvc-0.35.0.tgz", - "integrity": "sha512-9NyN53KSwGwOKmV0kAXl7R90lb7SAGPhLdUp7PpeeNIbK1ZqnutCOTTq+Y0jbgz8YCKjAJda1QruqYGhnZkEtA==", + "version": "0.36.0", + "resolved": "https://registry.npmjs.org/@cipherstash/auth-win32-x64-msvc/-/auth-win32-x64-msvc-0.36.0.tgz", + "integrity": "sha512-1mQ8E6YFy7frHkvrDmSixpy47EakGPRh4qgoXPgk9lqZnlbMECYZhoKWQEs5wa3tLGgiX5G6jKC3NQZsOOqEfQ==", "cpu": [ "x64" ], @@ -1507,9 +1507,9 @@ } }, "node_modules/vite": { - "version": "7.3.1", - "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", - "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", + "version": "7.3.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.2.tgz", + "integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==", "dev": true, "license": "MIT", "dependencies": { From 514436120e77459f1fdcac81694d07df51378dbe Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 6 May 2026 05:46:25 +0000 Subject: [PATCH 179/686] chore: release --- packages/stack-auth/CHANGELOG.md | 1 + packages/stack-auth/Cargo.toml | 2 +- packages/stack-profile/CHANGELOG.md | 1 + packages/stack-profile/Cargo.toml | 2 +- 4 files changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/stack-auth/CHANGELOG.md b/packages/stack-auth/CHANGELOG.md index 725a257c5..60eaaf63c 100644 --- a/packages/stack-auth/CHANGELOG.md +++ b/packages/stack-auth/CHANGELOG.md @@ -1,5 +1,6 @@ + ### Miscellaneous - release v0.34.1-alpha.2 diff --git a/packages/stack-auth/Cargo.toml b/packages/stack-auth/Cargo.toml index 5fde4bb1d..8060064df 100644 --- a/packages/stack-auth/Cargo.toml +++ b/packages/stack-auth/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "stack-auth" description = "Authentication library for CipherStash services" -version = "0.34.1-alpha.3" +version = "0.34.1-alpha.4" edition.workspace = true authors.workspace = true repository.workspace = true diff --git a/packages/stack-profile/CHANGELOG.md b/packages/stack-profile/CHANGELOG.md index f5c7f6614..ac3781672 100644 --- a/packages/stack-profile/CHANGELOG.md +++ b/packages/stack-profile/CHANGELOG.md @@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 + ## [0.34.0-alpha.1] - 2026-03-04 ### Changed diff --git a/packages/stack-profile/Cargo.toml b/packages/stack-profile/Cargo.toml index 68472aefe..d62c971f6 100644 --- a/packages/stack-profile/Cargo.toml +++ b/packages/stack-profile/Cargo.toml @@ -2,7 +2,7 @@ name = "stack-profile" description = "Centralised ~/.cipherstash profile file management" license-file = "LICENSE" -version = "0.34.1-alpha.3" +version = "0.34.1-alpha.4" edition.workspace = true authors.workspace = true repository.workspace = true From eec3354ed39191818115aeaa2fc5432163b0c518 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Sun, 10 May 2026 10:45:13 +1000 Subject: [PATCH 180/686] feat: wasm32 support for pure crypto/protocol crates (Layer 1) First step toward running cipherstash-client in Supabase Edge Functions. Six crates now build clean for `wasm32-unknown-unknown`: recipher, cipherstash-core, cipherstash-config, cllw-ore (no-default-features), cts-common (no-default-features), zerokms-protocol. Changes: - `.cargo/config.toml`: `--cfg getrandom_backend="wasm_js"` rustflag for wasm32, required by `getrandom >= 0.3` (pulled in via vitaminc-random -> rand 0.10) to select the browser/Deno entropy backend. - Per-crate wasm32 target deps for `getrandom` (`js` for v0.2, `wasm_js` for v0.4) so feature unification activates the right backend. - `cts-common`: wasm32 target dep on `uuid = { features = ["js"] }`. - Workspace `vitaminc` dep no longer defaults `encrypt`. That feature pulls `aws-lc-rs` (C deps that don't compile on wasm32). cts-domain and cts-web opt back in explicitly via their own vitaminc features. - `cts-common`: gained `aead` feature (default-on) gating the `IntoAad for WorkspaceId` impl. Wasm consumers use `default-features = false`. Native build verified via `mise run lint`. cts-common unit tests pass. Layered plan and follow-up layers documented in `wasm-analysis.md`. --- docs/wasm-analysis.md | 107 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 107 insertions(+) create mode 100644 docs/wasm-analysis.md diff --git a/docs/wasm-analysis.md b/docs/wasm-analysis.md new file mode 100644 index 000000000..a1897f3c9 --- /dev/null +++ b/docs/wasm-analysis.md @@ -0,0 +1,107 @@ +# WASM / Supabase Edge support for protect-ffi + cipherstash-client + +## Goal + +Run encrypt/decrypt against ZeroKMS from a Supabase Edge Function (Deno runtime, single-threaded, all outbound HTTP through `fetch`, ~10MB deployable budget). + +## Layered plan + +The work splits cleanly into four layers, each independently shippable. + +### Layer 1 — Pure crypto/protocol crates + +Mostly already wasm-compatible. Crates: `recipher`, `cipherstash-core`, `cllw-ore`, `cipherstash-config`, `cts-common` (with `default-features = false`), `zerokms-protocol`. + +Required changes: + +- Add a wasm32 target dep entry so `getrandom` routes to `crypto.getRandomValues`: + + ```toml + [target.'cfg(target_arch = "wasm32")'.dependencies] + getrandom = { version = "0.2", features = ["js"] } + ``` + + Likely needed in: `cipherstash-core`, `recipher`, anywhere `rand` is pulled in transitively. + +- Verify each crate builds with `cargo check --target wasm32-unknown-unknown -p `. + +- Document the wasm-supported crate set in workspace docs. + +### Layer 2 — `cipherstash-client` + +Concrete blockers and fixes: + +| Blocker | Location | Fix | +|---|---|---| +| `dirs::home_dir()` for profile path | `src/config/source/user.rs:67` | Inject profile source via trait; wasm impl reads JS-supplied config | +| `std::fs` writes | `src/zerokms/local_log.rs:35`, `src/zerokms/secret_key.rs:414-415` | Feature-gate `local_log` off for wasm; secret-key persistence becomes a trait with no-op/in-memory wasm impl | +| `open::that(...)` device-code browser | `src/credentials/user_credentials/mod.rs:70` | Gate out — no interactive OIDC in edge runtime | +| `tokio = ["full"]` | optional dep | Switch wasm to `["sync", "macros", "rt"]`; replace `tokio::time::sleep` with `gloo-timers` | +| `reqwest` features `rustls`/`hickory-dns`/`stream` | workspace Cargo.toml:97-106 | Wasm cfg uses `default-features = false, features = ["json"]` (reqwest's wasm backend dispatches to host `fetch` automatically) | +| `reqwest-retry`, `reqwest-tracing` | workspace deps | Gate off for wasm (`reqwest-middleware` works) | + +Add a `wasm` feature on `cipherstash-client` that flips these. + +### Layer 3 — Auth / profile + +`stack-profile` is filesystem-backed (`dirs` + `gethostname` + JSON files in `~/.cipherstash/`). Two options: + +1. **Trait-ify** `ProfileStore`: keep filesystem impl for native, add JS-bridged impl for wasm (caller passes config from Deno env or Supabase secrets). +2. **Skip entirely** for wasm: require credentials passed at construction. Probably right for edge — no persistent home dir anyway. + +`stack-auth` device-code path (`device_code/mod.rs:274` calls `open::that`) gates out for wasm. Access-key flow stays. + +### Layer 4 — FFI bindings + +`protect-ffi` is built on `neon = "1"` (Node N-API). None of it compiles to wasm. New sibling crate or workspace feature `protect-wasm` using `wasm-bindgen` + `wasm-bindgen-futures`. + +Surface to port: the 12 `#[neon::export]` functions in `protect-ffi/crates/protect-ffi/src/lib.rs:728-1148`. + +- Tokio runtime: `new_current_thread()` only, or rely entirely on `wasm-bindgen-futures` to bridge to JS promises. +- JSON marshalling: replace neon's `Json` with `JsValue` + `serde-wasm-bindgen`. + +## Supabase Edge runtime specifics + +- Deno-based, supports `WebAssembly.instantiate`. +- Single-threaded — no `tokio::spawn` across threads, no `rayon`. +- Outbound HTTP only via host `fetch` (reqwest's wasm backend uses this transparently). +- No filesystem, no env beyond what the function declares. +- Bundle size cap ~10MB. Crypto + reqwest + serde stack will land ~1.5–3MB stripped. +- Cold start: each invocation may be a fresh instance — token caching is in-memory and short-lived. + +## Status + +- [x] Analysis (this doc) +- [x] Layer 1 — pure crates verified on wasm32 +- [ ] Layer 2 — `cipherstash-client` wasm feature +- [ ] Layer 3 — profile/auth boundary +- [ ] Layer 4 — `protect-wasm` bindings +- [ ] Validation in a Supabase Edge Function + +## Layer 1 — what shipped + +Verified on `cargo check --target wasm32-unknown-unknown`: + +| Crate | Invocation | +|---|---| +| `recipher` | `cargo check --target wasm32-unknown-unknown -p recipher` | +| `cipherstash-core` | `cargo check --target wasm32-unknown-unknown -p cipherstash-core` | +| `cipherstash-config` | `cargo check --target wasm32-unknown-unknown -p cipherstash-config` | +| `cllw-ore` | `cargo check --target wasm32-unknown-unknown -p cllw-ore --no-default-features` (postgres-types is server-only) | +| `cts-common` | `cargo check --target wasm32-unknown-unknown -p cts-common --no-default-features` | +| `zerokms-protocol` | `cargo check --target wasm32-unknown-unknown -p zerokms-protocol` | + +Changes: + +- `.cargo/config.toml` — wasm32 rustflag `--cfg getrandom_backend="wasm_js"` (required by `getrandom >= 0.3` to select the browser/Deno backend; pulled in via `vitaminc-random` → `rand 0.10`) +- Per-crate wasm32 target deps for `getrandom` (`js` feature for v0.2, `wasm_js` for v0.4) so feature unification activates the right backend +- `cts-common` wasm32 target dep on `uuid = { features = ["js"] }` so `Uuid::new_v4()` can source entropy +- Workspace `vitaminc` dep dropped the default `encrypt` feature — it pulls `aws-lc-rs` (C deps that don't compile on wasm32). Server crates (`cts-domain`, `cts-web`) opt back in explicitly. +- `cts-common` gained an `aead` feature (default-on) gating the `IntoAad for WorkspaceId` impl. Wasm consumers use `default-features = false`. + +Native build verified via `mise run lint`. `cts-common` unit tests: 138/138 passing. + +## Known follow-ups before Layer 2 + +- The current verification is `cargo check`, not full build. A real artifact build (e.g. `wasm-pack` or `cargo build --target wasm32-unknown-unknown --release`) will surface any link-time issues. +- `cllw-ore` requires `--no-default-features` because the default `postgres-types` feature has C deps. Consider flipping the default off in a future major version (already noted in its Cargo.toml). From 3313929536a2f2688034c96721a55baecd614ee1 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Sun, 10 May 2026 13:16:43 +1000 Subject: [PATCH 181/686] refactor: drop cts-common/aead workaround, point vitaminc at branch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The wasm32 problem with `vitaminc-encrypt` (aws-lc-rs C deps) is now fixed upstream by https://github.com/cipherstash/vitaminc/pull/163, which adds a cfg-based dual backend (aws-lc-rs on native, RustCrypto on wasm32). Pointing the workspace `vitaminc` deps at that branch lets us drop all the workaround machinery from this PR: - Restore `encrypt` to the workspace `vitaminc` features list - Remove the `aead` feature from `cts-common` and the `#[cfg]` gates around `IntoAad for WorkspaceId` - Revert `cts-domain` and `cts-web` to their original feature lists (no explicit `cts-common/aead`, no explicit `vitaminc/encrypt`) The wasm32 deps for `getrandom`/`uuid` and `.cargo/config.toml` stay — they're needed regardless of the vitaminc backend story. NOTES tracks the swap from git branch back to a crates.io version once vitaminc publishes a release. --- docs/wasm-analysis.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/wasm-analysis.md b/docs/wasm-analysis.md index a1897f3c9..ce6dfb501 100644 --- a/docs/wasm-analysis.md +++ b/docs/wasm-analysis.md @@ -96,12 +96,12 @@ Changes: - `.cargo/config.toml` — wasm32 rustflag `--cfg getrandom_backend="wasm_js"` (required by `getrandom >= 0.3` to select the browser/Deno backend; pulled in via `vitaminc-random` → `rand 0.10`) - Per-crate wasm32 target deps for `getrandom` (`js` feature for v0.2, `wasm_js` for v0.4) so feature unification activates the right backend - `cts-common` wasm32 target dep on `uuid = { features = ["js"] }` so `Uuid::new_v4()` can source entropy -- Workspace `vitaminc` dep dropped the default `encrypt` feature — it pulls `aws-lc-rs` (C deps that don't compile on wasm32). Server crates (`cts-domain`, `cts-web`) opt back in explicitly. -- `cts-common` gained an `aead` feature (default-on) gating the `IntoAad for WorkspaceId` impl. Wasm consumers use `default-features = false`. +- Workspace `vitaminc` deps temporarily point at the [in-flight branch](https://github.com/cipherstash/vitaminc/pull/163) that adds wasm32 support to `vitaminc-encrypt` (cfg-based dual backend: aws-lc-rs on native, RustCrypto on wasm32). Switch back to a crates.io version once that PR merges and release-plz cuts a release. Native build verified via `mise run lint`. `cts-common` unit tests: 138/138 passing. ## Known follow-ups before Layer 2 +- Bump the workspace `vitaminc` deps from the git branch back to a crates.io version once the [vitaminc PR](https://github.com/cipherstash/vitaminc/pull/163) lands and a release is published. - The current verification is `cargo check`, not full build. A real artifact build (e.g. `wasm-pack` or `cargo build --target wasm32-unknown-unknown --release`) will surface any link-time issues. - `cllw-ore` requires `--no-default-features` because the default `postgres-types` feature has C deps. Consider flipping the default off in a future major version (already noted in its Cargo.toml). From 762fea31f6645672d597e87d60b4d243003432bf Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Sun, 10 May 2026 17:22:44 +1000 Subject: [PATCH 182/686] docs(wasm-analysis): revise plan around legacy credentials cleanup Promotes the legacy cipherstash-client credentials cleanup from "Layer 2 fix" to its own Layer 2, ahead of the wasm-specific work. Verified that proxy uses no API on the old `Credentials` trait that isn't already on stack-auth's `AuthStrategy` (no `clear_token` calls, no `.valid()`), so `AccessKeyStrategy` is a drop-in replacement for proxy's `AutoRefresh`. Layer 3 (was Layer 2 + 3) shrinks to mostly Cargo.toml splits and a couple of `cfg`-pick'd helpers once the dead modules are gone. Five layers now, Layer 1 done, Layer 2 unblocks the rest. --- docs/wasm-analysis.md | 95 +++++++++++++++++++++++-------------------- 1 file changed, 52 insertions(+), 43 deletions(-) diff --git a/docs/wasm-analysis.md b/docs/wasm-analysis.md index ce6dfb501..42bd98566 100644 --- a/docs/wasm-analysis.md +++ b/docs/wasm-analysis.md @@ -6,77 +6,86 @@ Run encrypt/decrypt against ZeroKMS from a Supabase Edge Function (Deno runtime, ## Layered plan -The work splits cleanly into four layers, each independently shippable. +Originally scoped as four layers. After looking more carefully at cipherstash-client, the dominant blockers for what was Layer 2 (`tokio::spawn` background refresh, `std::fs` token caches, `dirs`/`open` interactive auth) all live inside legacy credentials code that is **already unused** in current cipherstash-client — kept alive only because proxy is pinned to a pre-migration version. Promoting that cleanup to its own layer ahead of the wasm-specific work shrinks the remaining work substantially. -### Layer 1 — Pure crypto/protocol crates +Five layers, Layer 1 already shipped: -Mostly already wasm-compatible. Crates: `recipher`, `cipherstash-core`, `cllw-ore`, `cipherstash-config`, `cts-common` (with `default-features = false`), `zerokms-protocol`. +### Layer 1 — Pure crypto/protocol crates [DONE] -Required changes: +Six crates compile clean for `wasm32-unknown-unknown`: `recipher`, `cipherstash-core`, `cipherstash-config`, `cllw-ore` (`--no-default-features`), `cts-common` (`--no-default-features`), `zerokms-protocol`. See "Layer 1 — what shipped" below. -- Add a wasm32 target dep entry so `getrandom` routes to `crypto.getRandomValues`: +`vitaminc-encrypt` got a cfg-based dual backend (aws-lc-rs on native, RustCrypto on wasm32) so cipherstash-suite can use AEAD types from `vitaminc::encrypt` on both targets without a feature-gate workaround. Currently pinned to the in-flight vitaminc branch (https://github.com/cipherstash/vitaminc/pull/163). - ```toml - [target.'cfg(target_arch = "wasm32")'.dependencies] - getrandom = { version = "0.2", features = ["js"] } - ``` +### Layer 2 — Legacy credentials cleanup - Likely needed in: `cipherstash-core`, `recipher`, anywhere `rand` is pulled in transitively. +cipherstash-client 0.34 has already migrated every consumer-facing type (`ZeroKMS`, `ScopedCipher`, `CtsClient`, `ZeroKMSBuilder`) to the bound `for<'a> &'a C: AuthStrategy` from `stack-auth`. Nothing in 0.34 still references the old `Credentials` / `AutoRefreshable` traits. The whole credentials tree is dead in 0.34 — only proxy is keeping it alive, and proxy is on `cipherstash-client = "0.32.2"` from crates.io, predating the migration. -- Verify each crate builds with `cargo check --target wasm32-unknown-unknown -p `. +Modules to remove: -- Document the wasm-supported crate set in workspace docs. +| Module | Why it can go | +|---|---| +| `credentials/auto_refresh` | Hosts all the `tokio::spawn` refresh loops. Replaced by `stack-auth`'s internal refresh engine which fires lazily on `get_token()`. | +| `credentials/user_credentials` | OAuth device-code flow. Replaced by `stack_auth::OAuthStrategy`. | +| `credentials/service_credentials` | Replaced by `stack_auth::AccessKeyStrategy`. | +| `credentials/static_credentials` | Internal helper for `service_credentials`. | +| `credentials/token_store` | File-backed token cache, only used by `user_credentials`. | +| `credentials::{Credentials, AutoRefreshable, TokenExpiry}` traits | All impls live in the modules above. | +| `logger_client`, `reqwest_client` | Independently dead — defined but never imported anywhere. | -### Layer 2 — `cipherstash-client` +Verified gap analysis: proxy uses no API on `Credentials` that isn't already on `AuthStrategy`. `grep` of the proxy tree finds no `clear_token` calls, no `.valid()` calls, only `get_token` semantics. `AccessKeyStrategy` is a drop-in replacement for `AutoRefresh` modulo the eager-vs-lazy refresh model (functionally equivalent for any active session). -Concrete blockers and fixes: +Steps: -| Blocker | Location | Fix | -|---|---|---| -| `dirs::home_dir()` for profile path | `src/config/source/user.rs:67` | Inject profile source via trait; wasm impl reads JS-supplied config | -| `std::fs` writes | `src/zerokms/local_log.rs:35`, `src/zerokms/secret_key.rs:414-415` | Feature-gate `local_log` off for wasm; secret-key persistence becomes a trait with no-op/in-memory wasm impl | -| `open::that(...)` device-code browser | `src/credentials/user_credentials/mod.rs:70` | Gate out — no interactive OIDC in edge runtime | -| `tokio = ["full"]` | optional dep | Switch wasm to `["sync", "macros", "rt"]`; replace `tokio::time::sleep` with `gloo-timers` | -| `reqwest` features `rustls`/`hickory-dns`/`stream` | workspace Cargo.toml:97-106 | Wasm cfg uses `default-features = false, features = ["json"]` (reqwest's wasm backend dispatches to host `fetch` automatically) | -| `reqwest-retry`, `reqwest-tracing` | workspace deps | Gate off for wasm (`reqwest-middleware` works) | +1. Cipherstash-client release containing current `main` (the AuthStrategy bounds). Call it 0.35. +2. Proxy PR: bump to 0.35; replace `AutoRefresh::new(zerokms_config.credentials())` with an `AccessKeyStrategy` build; the type aliases become `ZeroKMS` etc. The version bump alone forces this — `AutoRefresh` doesn't implement `AuthStrategy`. +3. Cipherstash-client PR: delete the modules above; drop now-unused deps (`dirs`, `open`, `stack-profile` if it falls out, `tokio` feature flag if its surface becomes empty). Protect-ffi will need a one-line import fix from `cipherstash_client::credentials::ServiceToken` (deleted) to `cipherstash_client::ServiceToken` (the `stack_auth::ServiceToken` re-export). -Add a `wasm` feature on `cipherstash-client` that flips these. +Steps 1 and 3 can be combined in the same release. Step 2 is independent of the legacy delete — proxy must migrate either way when it bumps versions, since the bounds changed in 0.34. -### Layer 3 — Auth / profile +### Layer 3 — `cipherstash-client` wasm cleanup -`stack-profile` is filesystem-backed (`dirs` + `gethostname` + JSON files in `~/.cipherstash/`). Two options: +After Layer 2 the residual blockers are small: + +| Blocker | Location | Fix | +|---|---|---| +| `tokio::time::sleep` wrapper | `src/sleep.rs`, `src/zerokms/vitur_client/futures.rs:48` | Cfg-pick: native uses `tokio::time::sleep`, wasm uses `gloo-timers::future::TimeoutFuture` | +| `std::fs` writes for local logging | `src/zerokms/local_log.rs:35` | Cfg-out for wasm (already feature-gated) | +| `std::fs` reads for config sources | `src/config/source/{cipherstash,cipherstash_secret,file}.rs`, `src/config/source/user.rs:67` (`dirs::home_dir`) | Cfg-out for wasm. Edge consumers pass config explicitly at construction time. | +| `reqwest` features `rustls` / `hickory-dns` / `stream` | workspace `Cargo.toml` | Wasm cfg uses `default-features = false, features = ["json"]`; reqwest's wasm32 backend dispatches to host `fetch` automatically | +| `reqwest-retry`, `reqwest-tracing` | workspace deps | Gate off for wasm (`reqwest-middleware` works on wasm). Audit call sites that wrap `with_retries(...)`. | -1. **Trait-ify** `ProfileStore`: keep filesystem impl for native, add JS-bridged impl for wasm (caller passes config from Deno env or Supabase secrets). -2. **Skip entirely** for wasm: require credentials passed at construction. Probably right for edge — no persistent home dir anyway. +`stack-auth` needs its own pass at this stage — `device_code` (calls `open::that` to launch a browser) cfg-out for wasm; the access-key path is already pure-rust. `stack-profile` is filesystem-backed and only reached via OAuth flows; on wasm we drop `stack-auth/device_code` and so don't pull `stack-profile` in. -`stack-auth` device-code path (`device_code/mod.rs:274` calls `open::that`) gates out for wasm. Access-key flow stays. +### Layer 4 — `protect-wasm` bindings -### Layer 4 — FFI bindings +`protect-ffi` is napi-only. Add a sibling crate `protect-wasm` in the protect-ffi repo using `wasm-bindgen` + `wasm-bindgen-futures` + `serde-wasm-bindgen`. Port the 12 `#[neon::export]` functions in `protect-ffi/crates/protect-ffi/src/lib.rs:728-1148`. Build with `wasm-pack` (target deno or web depending on the packaging story). -`protect-ffi` is built on `neon = "1"` (Node N-API). None of it compiles to wasm. New sibling crate or workspace feature `protect-wasm` using `wasm-bindgen` + `wasm-bindgen-futures`. +### Layer 5 — Validation in a Supabase Edge Function -Surface to port: the 12 `#[neon::export]` functions in `protect-ffi/crates/protect-ffi/src/lib.rs:728-1148`. +Deploy a real edge function that calls `protect-wasm`, encrypt/decrypt against ZeroKMS, measure: -- Tokio runtime: `new_current_thread()` only, or rely entirely on `wasm-bindgen-futures` to bridge to JS promises. -- JSON marshalling: replace neon's `Json` with `JsValue` + `serde-wasm-bindgen`. +- Bundle size vs the 10MB cap +- Cold-start latency +- Round-trip correctness against a server-side native client +- Cross-backend ciphertext compatibility — encrypt on wasm (RustCrypto), decrypt on native (aws-lc-rs), and vice versa. This is the cross-backend compat test deferred from earlier. ## Supabase Edge runtime specifics -- Deno-based, supports `WebAssembly.instantiate`. -- Single-threaded — no `tokio::spawn` across threads, no `rayon`. -- Outbound HTTP only via host `fetch` (reqwest's wasm backend uses this transparently). -- No filesystem, no env beyond what the function declares. -- Bundle size cap ~10MB. Crypto + reqwest + serde stack will land ~1.5–3MB stripped. -- Cold start: each invocation may be a fresh instance — token caching is in-memory and short-lived. +- Deno-based, supports `WebAssembly.instantiate` +- Single-threaded — no `tokio::spawn` across threads, no `rayon` +- Outbound HTTP only via host `fetch` (reqwest's wasm backend uses this transparently) +- No filesystem, no env beyond what the function declares +- Bundle size cap ~10MB. Crypto + reqwest + serde stack will land ~1.5–3MB stripped +- Cold start: each invocation may be a fresh instance — token caching is in-memory and short-lived ## Status - [x] Analysis (this doc) - [x] Layer 1 — pure crates verified on wasm32 -- [ ] Layer 2 — `cipherstash-client` wasm feature -- [ ] Layer 3 — profile/auth boundary +- [ ] Layer 2 — legacy credentials cleanup (cipherstash-client release, proxy migration, dead-module delete) +- [ ] Layer 3 — `cipherstash-client` wasm cleanup - [ ] Layer 4 — `protect-wasm` bindings -- [ ] Validation in a Supabase Edge Function +- [ ] Layer 5 — Supabase Edge validation ## Layer 1 — what shipped @@ -100,7 +109,7 @@ Changes: Native build verified via `mise run lint`. `cts-common` unit tests: 138/138 passing. -## Known follow-ups before Layer 2 +## Known follow-ups - Bump the workspace `vitaminc` deps from the git branch back to a crates.io version once the [vitaminc PR](https://github.com/cipherstash/vitaminc/pull/163) lands and a release is published. - The current verification is `cargo check`, not full build. A real artifact build (e.g. `wasm-pack` or `cargo build --target wasm32-unknown-unknown --release`) will surface any link-time issues. From f1da486ebcbe502ee879613ef1fb4e5ede441acc Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 10 May 2026 10:29:22 +0000 Subject: [PATCH 183/686] chore(deps-dev): bump postcss in /packages/stack-auth/node Bumps [postcss](https://github.com/postcss/postcss) from 8.5.8 to 8.5.14. - [Release notes](https://github.com/postcss/postcss/releases) - [Changelog](https://github.com/postcss/postcss/blob/main/CHANGELOG.md) - [Commits](https://github.com/postcss/postcss/compare/8.5.8...8.5.14) --- updated-dependencies: - dependency-name: postcss dependency-version: 8.5.12 dependency-type: indirect ... Signed-off-by: dependabot[bot] --- languages/typescript/packages/auth/package-lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/languages/typescript/packages/auth/package-lock.json b/languages/typescript/packages/auth/package-lock.json index 956a377db..36c0bce15 100644 --- a/languages/typescript/packages/auth/package-lock.json +++ b/languages/typescript/packages/auth/package-lock.json @@ -1314,9 +1314,9 @@ } }, "node_modules/postcss": { - "version": "8.5.8", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", - "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "version": "8.5.14", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz", + "integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==", "dev": true, "funding": [ { From 2b123499bae94bc272efc76d64933e85989a9316 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Mon, 11 May 2026 16:56:38 +1000 Subject: [PATCH 184/686] chore: switch vitaminc deps from git branch to crates.io 0.2.0-pre MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit vitaminc 0.2.0-pre has been published to crates.io with the cfg-based dual backend (aws-lc-rs on native, RustCrypto on wasm32) for vitaminc-encrypt — the exact change PR cipherstash/cipherstash-suite#1942 was waiting on. Drops the temporary git+branch pin on `feat/wasm-encrypt-backend` for `vitaminc`, `vitaminc-aead`, and `vitaminc-protected`, and updates `wasm-analysis.md` to mark the follow-up as resolved. Verified via `cargo check --workspace` and all six wasm32 crate checks listed in the PR description (`recipher`, `cipherstash-core`, `cipherstash-config`, `cllw-ore --no-default-features`, `cts-common --no-default-features`, `zerokms-protocol`) — all clean. --- docs/wasm-analysis.md | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/docs/wasm-analysis.md b/docs/wasm-analysis.md index 42bd98566..bd327d861 100644 --- a/docs/wasm-analysis.md +++ b/docs/wasm-analysis.md @@ -14,7 +14,7 @@ Five layers, Layer 1 already shipped: Six crates compile clean for `wasm32-unknown-unknown`: `recipher`, `cipherstash-core`, `cipherstash-config`, `cllw-ore` (`--no-default-features`), `cts-common` (`--no-default-features`), `zerokms-protocol`. See "Layer 1 — what shipped" below. -`vitaminc-encrypt` got a cfg-based dual backend (aws-lc-rs on native, RustCrypto on wasm32) so cipherstash-suite can use AEAD types from `vitaminc::encrypt` on both targets without a feature-gate workaround. Currently pinned to the in-flight vitaminc branch (https://github.com/cipherstash/vitaminc/pull/163). +`vitaminc-encrypt` got a cfg-based dual backend (aws-lc-rs on native, RustCrypto on wasm32) so cipherstash-suite can use AEAD types from `vitaminc::encrypt` on both targets without a feature-gate workaround. Available from vitaminc 0.2.0-pre on crates.io (vitaminc PR #163, shipped as part of the 0.2.0-pre minor-bump release). ### Layer 2 — Legacy credentials cleanup @@ -105,12 +105,11 @@ Changes: - `.cargo/config.toml` — wasm32 rustflag `--cfg getrandom_backend="wasm_js"` (required by `getrandom >= 0.3` to select the browser/Deno backend; pulled in via `vitaminc-random` → `rand 0.10`) - Per-crate wasm32 target deps for `getrandom` (`js` feature for v0.2, `wasm_js` for v0.4) so feature unification activates the right backend - `cts-common` wasm32 target dep on `uuid = { features = ["js"] }` so `Uuid::new_v4()` can source entropy -- Workspace `vitaminc` deps temporarily point at the [in-flight branch](https://github.com/cipherstash/vitaminc/pull/163) that adds wasm32 support to `vitaminc-encrypt` (cfg-based dual backend: aws-lc-rs on native, RustCrypto on wasm32). Switch back to a crates.io version once that PR merges and release-plz cuts a release. +- Workspace `vitaminc`, `vitaminc-aead`, and `vitaminc-protected` pinned to `0.2.0-pre` on crates.io — the first release containing the cfg-based dual backend (aws-lc-rs on native, RustCrypto on wasm32) for `vitaminc-encrypt`. Shipped via [vitaminc PR #163](https://github.com/cipherstash/vitaminc/pull/163). Native build verified via `mise run lint`. `cts-common` unit tests: 138/138 passing. ## Known follow-ups -- Bump the workspace `vitaminc` deps from the git branch back to a crates.io version once the [vitaminc PR](https://github.com/cipherstash/vitaminc/pull/163) lands and a release is published. - The current verification is `cargo check`, not full build. A real artifact build (e.g. `wasm-pack` or `cargo build --target wasm32-unknown-unknown --release`) will surface any link-time issues. - `cllw-ore` requires `--no-default-features` because the default `postgres-types` feature has C deps. Consider flipping the default off in a future major version (already noted in its Cargo.toml). From a39dfc357834aac710ad50d0efb2e41a0b021c4b Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Sun, 10 May 2026 19:13:38 +1000 Subject: [PATCH 185/686] docs(wasm-analysis): split Layer 2 into 2a/2b/2c The previous turn shipped Layer 2a (legacy infrastructure delete). This update reflects what's done and what's remaining: - 2a (DONE): credentials/auto_refresh, user_credentials, service credential providers, static_credentials, token_store, traits, and logger/reqwest/sleep modules deleted from cipherstash-client. - 2b (DEFERRED): ServiceToken JSON wire-contract migration. The legacy type survives because protect-ffi consumes it via serde from JS; stack_auth::ServiceToken doesn't impl Deserialize. Design conversation needed before code. - 2c (PENDING): cipherstash-client 0.35 release; proxy bump with AccessKeyStrategy swap. Layer 3's blocker count drops accordingly: tokio::time::sleep is now 1 site (down from 2). Other blockers unchanged. --- docs/wasm-analysis.md | 84 ++++++++++++++++++++++++++++++------------- 1 file changed, 59 insertions(+), 25 deletions(-) diff --git a/docs/wasm-analysis.md b/docs/wasm-analysis.md index bd327d861..7e9a8ff46 100644 --- a/docs/wasm-analysis.md +++ b/docs/wasm-analysis.md @@ -16,45 +16,59 @@ Six crates compile clean for `wasm32-unknown-unknown`: `recipher`, `cipherstash- `vitaminc-encrypt` got a cfg-based dual backend (aws-lc-rs on native, RustCrypto on wasm32) so cipherstash-suite can use AEAD types from `vitaminc::encrypt` on both targets without a feature-gate workaround. Available from vitaminc 0.2.0-pre on crates.io (vitaminc PR #163, shipped as part of the 0.2.0-pre minor-bump release). -### Layer 2 — Legacy credentials cleanup +### Layer 2 — Legacy credentials cleanup [PARTIAL] -cipherstash-client 0.34 has already migrated every consumer-facing type (`ZeroKMS`, `ScopedCipher`, `CtsClient`, `ZeroKMSBuilder`) to the bound `for<'a> &'a C: AuthStrategy` from `stack-auth`. Nothing in 0.34 still references the old `Credentials` / `AutoRefreshable` traits. The whole credentials tree is dead in 0.34 — only proxy is keeping it alive, and proxy is on `cipherstash-client = "0.32.2"` from crates.io, predating the migration. +cipherstash-client 0.34 already migrated every consumer-facing type (`ZeroKMS`, `ScopedCipher`, `CtsClient`, `ZeroKMSBuilder`) to the bound `for<'a> &'a C: AuthStrategy` from `stack-auth`. Nothing in 0.34 references the old `Credentials` / `AutoRefreshable` traits. The whole credentials tree was dead — kept alive only because proxy is pinned to `cipherstash-client = "0.32.2"`, predating the migration. -Modules to remove: +#### 2a — Delete the dead infrastructure [DONE] -| Module | Why it can go | +Shipped in PR #1942. -2042 lines from cipherstash-client. + +| Removed | Why | |---|---| -| `credentials/auto_refresh` | Hosts all the `tokio::spawn` refresh loops. Replaced by `stack-auth`'s internal refresh engine which fires lazily on `get_token()`. | -| `credentials/user_credentials` | OAuth device-code flow. Replaced by `stack_auth::OAuthStrategy`. | -| `credentials/service_credentials` | Replaced by `stack_auth::AccessKeyStrategy`. | -| `credentials/static_credentials` | Internal helper for `service_credentials`. | +| `credentials/auto_refresh` | Hosted all four `tokio::spawn` refresh loops. Replaced by `stack-auth`'s internal lazy refresh engine. | +| `credentials/user_credentials/` | OAuth device-code flow. Replaced by `stack_auth::OAuthStrategy`. | +| `credentials/service_credentials/{service_user_credentials, service_access_key_credentials}` | Replaced by `stack_auth::{OAuthStrategy, AccessKeyStrategy}`. | +| `credentials/static_credentials` | Internal helper for the deleted service-credentials code. | | `credentials/token_store` | File-backed token cache, only used by `user_credentials`. | -| `credentials::{Credentials, AutoRefreshable, TokenExpiry}` traits | All impls live in the modules above. | +| `credentials::{Credentials, AutoRefreshable, TokenExpiry}` traits | All impls were in the modules above. | | `logger_client`, `reqwest_client` | Independently dead — defined but never imported anywhere. | +| `sleep` | tokio/std cfg-pick wrapper, only used by deleted `auto_refresh`. | + +Dropped `open` and `cfg-if` deps. Verified workspace + lint + 340 cipherstash-client unit tests + wasm32 spot-check. + +#### 2b — `ServiceToken` JSON contract [DEFERRED] + +The legacy `cipherstash_client::credentials::ServiceToken` is intentionally retained as the only surviving piece of the credentials tree. It backs a JSON wire contract (`{accessToken, expiry}`) that protect-ffi consumes via `serde::Deserialize` from JS callers. `stack_auth::ServiceToken` doesn't impl `Deserialize` and has a different shape (wraps a `SecretToken` with eagerly-decoded JWT claims). + +Migrating it requires a design decision and is **not** in this PR's scope: + +- **Option A**: teach `stack_auth::ServiceToken` to deserialize from `{accessToken, expiry}` (and accept that some non-JWT tokens won't have decoded claims). +- **Option B**: protect-ffi deserializes into a thin `ServiceTokenInput` shape and converts internally. +- **Option C**: keep two ServiceToken types and document the boundary explicitly. -Verified gap analysis: proxy uses no API on `Credentials` that isn't already on `AuthStrategy`. `grep` of the proxy tree finds no `clear_token` calls, no `.valid()` calls, only `get_token` semantics. `AccessKeyStrategy` is a drop-in replacement for `AutoRefresh` modulo the eager-vs-lazy refresh model (functionally equivalent for any active session). +Worth a conversation before code changes. Until resolved, the type lives at `cipherstash_client::credentials::service_credentials::service_token::ServiceToken`. -Steps: +#### 2c — Cipherstash-client release & proxy bump [PENDING] -1. Cipherstash-client release containing current `main` (the AuthStrategy bounds). Call it 0.35. -2. Proxy PR: bump to 0.35; replace `AutoRefresh::new(zerokms_config.credentials())` with an `AccessKeyStrategy` build; the type aliases become `ZeroKMS` etc. The version bump alone forces this — `AutoRefresh` doesn't implement `AuthStrategy`. -3. Cipherstash-client PR: delete the modules above; drop now-unused deps (`dirs`, `open`, `stack-profile` if it falls out, `tokio` feature flag if its surface becomes empty). Protect-ffi will need a one-line import fix from `cipherstash_client::credentials::ServiceToken` (deleted) to `cipherstash_client::ServiceToken` (the `stack_auth::ServiceToken` re-export). +1. Cut a cipherstash-client 0.35 release containing the current `main` (the AuthStrategy bounds + the 2a delete). +2. Proxy PR: bump to 0.35; replace `AutoRefresh::new(zerokms_config.credentials())` with `AccessKeyStrategy::builder()…build()`. The type aliases become `ZeroKMS` etc. The version bump alone forces this — `AutoRefresh` no longer exists, and the bounds require an `AuthStrategy` impl regardless. -Steps 1 and 3 can be combined in the same release. Step 2 is independent of the legacy delete — proxy must migrate either way when it bumps versions, since the bounds changed in 0.34. +Verified gap analysis: proxy uses no API on the old `Credentials` trait that isn't already on `AuthStrategy`. `grep` of the proxy tree finds no `clear_token` calls, no `.valid()` calls — only `get_token` semantics. `AccessKeyStrategy` is a drop-in replacement modulo the eager-vs-lazy refresh model (functionally equivalent for any active session). ### Layer 3 — `cipherstash-client` wasm cleanup -After Layer 2 the residual blockers are small: +After Layer 2a, the residual blockers are small: -| Blocker | Location | Fix | -|---|---|---| -| `tokio::time::sleep` wrapper | `src/sleep.rs`, `src/zerokms/vitur_client/futures.rs:48` | Cfg-pick: native uses `tokio::time::sleep`, wasm uses `gloo-timers::future::TimeoutFuture` | -| `std::fs` writes for local logging | `src/zerokms/local_log.rs:35` | Cfg-out for wasm (already feature-gated) | -| `std::fs` reads for config sources | `src/config/source/{cipherstash,cipherstash_secret,file}.rs`, `src/config/source/user.rs:67` (`dirs::home_dir`) | Cfg-out for wasm. Edge consumers pass config explicitly at construction time. | -| `reqwest` features `rustls` / `hickory-dns` / `stream` | workspace `Cargo.toml` | Wasm cfg uses `default-features = false, features = ["json"]`; reqwest's wasm32 backend dispatches to host `fetch` automatically | -| `reqwest-retry`, `reqwest-tracing` | workspace deps | Gate off for wasm (`reqwest-middleware` works on wasm). Audit call sites that wrap `with_retries(...)`. | +| Blocker | Location | Fix | Status after 2a | +|---|---|---|---| +| `tokio::time::sleep` wrapper | `src/zerokms/vitur_client/futures.rs:48` (sleep.rs gone with 2a) | Cfg-pick or replace with `gloo-timers::future::TimeoutFuture` on wasm | Reduced to 1 site | +| `std::fs` writes for local logging | `src/zerokms/local_log.rs` | Cfg-out for wasm (already feature-gated) | Unchanged | +| `std::fs` reads for config sources | `src/config/source/{cipherstash,cipherstash_secret,file}.rs`, `src/config/source/user.rs:67` (`dirs::home_dir`) | Cfg-out for wasm. Edge consumers pass config explicitly at construction time. | Unchanged | +| `reqwest` features `rustls` / `hickory-dns` / `stream` | workspace `Cargo.toml` | Wasm cfg uses `default-features = false, features = ["json"]`; reqwest's wasm32 backend dispatches to host `fetch` automatically | Unchanged | +| `reqwest-retry`, `reqwest-tracing` | workspace deps | Gate off for wasm (`reqwest-middleware` works on wasm). Audit call sites that wrap `with_retries(...)`. | Unchanged | -`stack-auth` needs its own pass at this stage — `device_code` (calls `open::that` to launch a browser) cfg-out for wasm; the access-key path is already pure-rust. `stack-profile` is filesystem-backed and only reached via OAuth flows; on wasm we drop `stack-auth/device_code` and so don't pull `stack-profile` in. +`stack-auth` needs its own pass — `device_code` (calls `open::that` to launch a browser) cfg-out for wasm; the access-key path is already pure-rust. `stack-profile` is filesystem-backed and only reached via OAuth flows; on wasm we drop `stack-auth/device_code` and so don't pull `stack-profile` in. ### Layer 4 — `protect-wasm` bindings @@ -82,7 +96,7 @@ Deploy a real edge function that calls `protect-wasm`, encrypt/decrypt against Z - [x] Analysis (this doc) - [x] Layer 1 — pure crates verified on wasm32 -- [ ] Layer 2 — legacy credentials cleanup (cipherstash-client release, proxy migration, dead-module delete) +- [~] Layer 2 — legacy credentials cleanup (2a infra delete shipped; 2b ServiceToken migration deferred; 2c release + proxy bump pending) - [ ] Layer 3 — `cipherstash-client` wasm cleanup - [ ] Layer 4 — `protect-wasm` bindings - [ ] Layer 5 — Supabase Edge validation @@ -109,7 +123,27 @@ Changes: Native build verified via `mise run lint`. `cts-common` unit tests: 138/138 passing. +## Layer 2a — what shipped + +Deleted from cipherstash-client (PR #1942): + +- `credentials/auto_refresh.rs` +- `credentials/user_credentials/` (auth0, okta, user_token, mod) +- `credentials/service_credentials/{service_user_credentials, service_access_key_credentials}.rs` +- `credentials/static_credentials.rs`, `credentials/token_store.rs` +- `credentials::{Credentials, AutoRefreshable, TokenExpiry}` traits and associated error types +- `logger_client.rs`, `reqwest_client.rs`, `sleep.rs` + +Cargo.toml: dropped `open` and `cfg-if` deps. Kept `tokio` feature flag (still used by `encryption/builder/mod.rs` and `eql`). + +Net delta: 19 files changed, +14 / -2042. Verified via `cargo check --workspace --tests` (no `--all-features`, to avoid the lint-trap class), `mise run lint`, and 340 cipherstash-client unit tests passing. + +`cipherstash_client::credentials::ServiceToken` is the only piece of the credentials tree that survives, retained for its JSON wire contract with protect-ffi. + ## Known follow-ups - The current verification is `cargo check`, not full build. A real artifact build (e.g. `wasm-pack` or `cargo build --target wasm32-unknown-unknown --release`) will surface any link-time issues. - `cllw-ore` requires `--no-default-features` because the default `postgres-types` feature has C deps. Consider flipping the default off in a future major version (already noted in its Cargo.toml). +- Cipherstash-client 0.35 release containing the legacy delete; proxy bump to 0.35 with `AccessKeyStrategy` migration. See Layer 2c. +- `ServiceToken` JSON contract migration (Layer 2b) — design conversation needed before code. +- Pre-existing API drift between cipherstash-client and protect-ffi (path dep) — `cipherstash_client::eql::EncryptedField` not found. Surfaces under `cargo check -p protect-ffi`. Not caused by Layer 2a; flagged for the next protect-ffi sync. From 42309c07be1eb04dda37224de762c80ffc8e33b6 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Tue, 12 May 2026 13:18:05 +1000 Subject: [PATCH 186/686] =?UTF-8?q?docs:=20=F0=9F=93=9D=20address=20Copilo?= =?UTF-8?q?t=20review=20feedback=20on=20Layer=202=20PR?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - wasm-analysis.md: correct PR attribution from cipherstash/cipherstash-suite#1942 (Layer 1) to cipherstash/cipherstash-suite#1943 (this Layer 2 PR) where the legacy credentials infrastructure was actually deleted. - credentials/mod.rs: use Rust identifier form `stack_auth` (not the crate-name form `stack-auth`) in docstring prose. - service_token.rs: reword docstring — `ServiceToken` short-circuits token acquisition rather than overriding `AuthStrategy::get_token`. --- docs/wasm-analysis.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/wasm-analysis.md b/docs/wasm-analysis.md index 7e9a8ff46..2015a97c0 100644 --- a/docs/wasm-analysis.md +++ b/docs/wasm-analysis.md @@ -22,7 +22,7 @@ cipherstash-client 0.34 already migrated every consumer-facing type (`ZeroKMS Date: Sun, 10 May 2026 20:47:07 +1000 Subject: [PATCH 187/686] =?UTF-8?q?refactor(stack-auth):=20wasm32=20suppor?= =?UTF-8?q?t=20=E2=80=94=20cfg-gate=20filesystem=20and=20Send=20bounds?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Layer 3b: stack-auth now compiles to `wasm32-unknown-unknown`. The in-memory paths (`AccessKeyStrategy`, `OAuthStrategy::with_token`, `AutoStrategy` access-key branch) and the OAuth refresh flow all stay fully functional. The filesystem-backed `with_profile`/`detect`-from-disk paths are cfg'd out — wasm has no profile dir to read from. Per-target dep splits in `Cargo.toml`: Native only: - `stack-profile` (uses `dirs` + `gethostname`; wasm-incompatible) - `open` (launches a browser for device-code auth) - `jsonwebtoken` (pulls `ring`, which doesn't compile to wasm32) - `tokio` with workspace `features = ["full"]` — pulls `mio` for network IO Wasm32 only: - `base64` (for the manual JWT decoder fallback) - `tokio` with `features = ["sync"]` only — `mio`-free Source changes: - `device_client`, `device_code` modules cfg-out for wasm - `OAuthStrategy::with_profile`, `OAuthTokenSource::Store`, `OAuthRefresher`'s `store` field, `AuthError::Store`, `impl ProfileData for Token`, and the `DeviceIdentity` re-export all cfg-gated to native - `AutoStrategy::detect_inner` has two cfg-gated impls — native picks between access-key and OAuth-from-profile; wasm has only the access-key branch - JWT claim decoding (`Token::decode_claims`, `ServiceToken::try_decode`) cfg-picks between `jsonwebtoken` (native) and a manual base64+JSON path (wasm) - `Refresher` and `AuthStrategy` traits cfg-pick `Send` bounds — reqwest's fetch-backed futures on wasm aren't `Send`, edge runtimes are single-threaded anyway - `http_client()` cfg-gates `connect_timeout`/`pool_*`/`timeout` — the wasm32 `ClientBuilder` doesn't expose them (host runtime owns those) Native build verified via `cargo check --workspace --tests`, `mise run lint`, and 105 stack-auth unit tests passing. Wasm32 verified via `cargo check --target wasm32-unknown-unknown -p stack-auth` (clean). Cipherstash-client wasm cleanup follows in the next commit. --- packages/stack-auth/Cargo.toml | 25 +++++- packages/stack-auth/src/auto_strategy.rs | 40 +++++++-- packages/stack-auth/src/lib.rs | 96 +++++++++++++++++----- packages/stack-auth/src/oauth_refresher.rs | 29 ++++++- packages/stack-auth/src/oauth_strategy.rs | 4 + packages/stack-auth/src/refresher.rs | 33 ++++---- packages/stack-auth/src/service_token.rs | 53 ++++++------ packages/stack-auth/src/token.rs | 12 +++ 8 files changed, 216 insertions(+), 76 deletions(-) diff --git a/packages/stack-auth/Cargo.toml b/packages/stack-auth/Cargo.toml index 8060064df..6236c75dd 100644 --- a/packages/stack-auth/Cargo.toml +++ b/packages/stack-auth/Cargo.toml @@ -11,15 +11,11 @@ license-file = "LICENSE" [dependencies] aquamarine = "0.6" cts-common = { workspace = true } -jsonwebtoken = { workspace = true } -stack-profile = { workspace = true } miette = { workspace = true } -open = "5.3.2" reqwest = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } thiserror = { workspace = true } -tokio = { workspace = true } tracing = { workspace = true } url = { workspace = true } uuid = { workspace = true } @@ -28,6 +24,27 @@ vitaminc-protected = { workspace = true } zerokms-protocol = { workspace = true } zeroize = { workspace = true } +# Native-only: +# - `stack-profile` is the filesystem-backed token store +# - `open` launches a browser for device-code auth +# - `jsonwebtoken` pulls `ring`, which doesn't compile on wasm32 +# - `tokio` with `full` features pulls `mio` (network IO), which doesn't +# compile on wasm32. Workspace dep is `features = ["full"]` so we can't +# subtract — split target-conditionally instead. +# +# Wasm consumers use `OAuthStrategy::with_token` (in-memory) or +# `AccessKeyStrategy`, and JWT claim decoding falls back to a manual +# base64+JSON path that doesn't need `ring`. +[target.'cfg(not(target_arch = "wasm32"))'.dependencies] +stack-profile = { workspace = true } +open = "5.3.2" +jsonwebtoken = { workspace = true } +tokio = { workspace = true } + +[target.'cfg(target_arch = "wasm32")'.dependencies] +base64 = { workspace = true } +tokio = { version = "1.47.1", default-features = false, features = ["sync"] } + [features] test-utils = [] diff --git a/packages/stack-auth/src/auto_strategy.rs b/packages/stack-auth/src/auto_strategy.rs index 55f92ef9c..aec6d859c 100644 --- a/packages/stack-auth/src/auto_strategy.rs +++ b/packages/stack-auth/src/auto_strategy.rs @@ -2,9 +2,12 @@ use cts_common::Crn; use crate::access_key_strategy::AccessKeyStrategy; use crate::oauth_strategy::OAuthStrategy; +#[cfg(not(target_arch = "wasm32"))] use stack_profile::ProfileStore; -use crate::{AuthError, AuthStrategy, ServiceToken, Token}; +#[cfg(not(target_arch = "wasm32"))] +use crate::Token; +use crate::{AuthError, AuthStrategy, ServiceToken}; /// An [`AuthStrategy`] that automatically detects available credentials /// and delegates to the appropriate inner strategy. @@ -93,7 +96,9 @@ impl AutoStrategy { /// Core detection logic, separated for testability. /// /// Takes pre-resolved inputs rather than reading from the environment - /// or filesystem directly. + /// or filesystem directly. On wasm32 the profile-store fallback is + /// unreachable (no filesystem) — callers must supply an access key. + #[cfg(not(target_arch = "wasm32"))] fn detect_inner( access_key: Option, crn: Option, @@ -124,6 +129,19 @@ impl AutoStrategy { // 3. No credentials found Err(AuthError::NotAuthenticated) } + + #[cfg(target_arch = "wasm32")] + fn detect_inner(access_key: Option, crn: Option) -> Result { + if let Some(access_key) = access_key { + let region = crn + .map(|c| c.region) + .ok_or(AuthError::MissingWorkspaceCrn)?; + let key: crate::AccessKey = access_key.parse()?; + let strategy = AccessKeyStrategy::new(region, key)?; + return Ok(Self::AccessKey(strategy)); + } + Err(AuthError::NotAuthenticated) + } } /// Builder for configuring credential resolution before calling [`detect()`](AutoStrategyBuilder::detect). @@ -183,12 +201,18 @@ impl AutoStrategyBuilder { .transpose()?, }; - // Resolve errors (e.g. missing profile directory) are intentionally - // swallowed here so that env-var-only setups don't need a profile dir. - // If no credentials are found at all, NotAuthenticated is returned. - let store = ProfileStore::resolve(None).ok(); - - AutoStrategy::detect_inner(access_key, crn, store) + #[cfg(not(target_arch = "wasm32"))] + { + // Resolve errors (e.g. missing profile directory) are intentionally + // swallowed here so that env-var-only setups don't need a profile dir. + // If no credentials are found at all, NotAuthenticated is returned. + let store = ProfileStore::resolve(None).ok(); + AutoStrategy::detect_inner(access_key, crn, store) + } + #[cfg(target_arch = "wasm32")] + { + AutoStrategy::detect_inner(access_key, crn) + } } } diff --git a/packages/stack-auth/src/lib.rs b/packages/stack-auth/src/lib.rs index 7dd91d2e2..db250efb8 100644 --- a/packages/stack-auth/src/lib.rs +++ b/packages/stack-auth/src/lib.rs @@ -24,7 +24,7 @@ use std::convert::Infallible; use std::future::Future; -#[cfg(not(any(test, feature = "test-utils")))] +#[cfg(all(not(any(test, feature = "test-utils")), not(target_arch = "wasm32")))] use std::time::Duration; use vitaminc::protected::OpaqueDebug; @@ -35,30 +35,40 @@ mod access_key_refresher; mod access_key_strategy; mod auto_refresh; mod auto_strategy; -mod device_client; -mod device_code; mod oauth_refresher; mod oauth_strategy; mod refresher; mod service_token; mod token; +// Filesystem-backed device identity and the interactive device-code flow are +// native-only — both pull `stack-profile` (which uses `dirs` + `gethostname`) +// and the device-code flow launches a browser via `open::that`. Wasm consumers +// use `OAuthStrategy::with_token` or `AccessKeyStrategy`. +#[cfg(not(target_arch = "wasm32"))] +mod device_client; +#[cfg(not(target_arch = "wasm32"))] +mod device_code; + #[cfg(any(test, feature = "test-utils"))] mod static_token_strategy; pub use access_key::{AccessKey, InvalidAccessKey}; pub use access_key_strategy::{AccessKeyStrategy, AccessKeyStrategyBuilder}; pub use auto_strategy::{AutoStrategy, AutoStrategyBuilder}; -pub use device_code::{DeviceCodeStrategy, DeviceCodeStrategyBuilder, PendingDeviceCode}; pub use oauth_strategy::{OAuthStrategy, OAuthStrategyBuilder}; pub use service_token::ServiceToken; #[cfg(any(test, feature = "test-utils"))] pub use static_token_strategy::StaticTokenStrategy; pub use token::Token; +#[cfg(not(target_arch = "wasm32"))] pub use device_client::{bind_client_device, DeviceClientError}; +#[cfg(not(target_arch = "wasm32"))] +pub use device_code::{DeviceCodeStrategy, DeviceCodeStrategyBuilder, PendingDeviceCode}; // Re-exports from stack-profile for backward compatibility. +#[cfg(not(target_arch = "wasm32"))] pub use stack_profile::DeviceIdentity; /// A strategy for obtaining access tokens. @@ -137,11 +147,21 @@ pub use stack_profile::DeviceIdentity; /// Blocking -- Err --> ErrExpired["TokenExpired"] /// ``` #[cfg_attr(doc, aquamarine::aquamarine)] +#[cfg(not(target_arch = "wasm32"))] pub trait AuthStrategy: Send { /// Retrieve a valid access token, refreshing or re-authenticating as needed. fn get_token(self) -> impl Future> + Send; } +/// Wasm32 variant of [`AuthStrategy`] — drops the `Send` bounds because +/// reqwest's fetch-backed futures aren't `Send` and edge runtimes are +/// single-threaded. +#[cfg(target_arch = "wasm32")] +pub trait AuthStrategy { + /// Retrieve a valid access token, refreshing or re-authenticating as needed. + fn get_token(self) -> impl Future>; +} + /// A sensitive token string that is zeroized on drop and hidden from debug output. /// /// `SecretToken` wraps a `String` and enforces two invariants: @@ -216,6 +236,7 @@ pub enum AuthError { #[error("Server error: {0}")] Server(String), /// A token store operation failed. + #[cfg(not(target_arch = "wasm32"))] #[error("Token store error: {0}")] Store(#[from] stack_profile::ProfileError), } @@ -247,27 +268,58 @@ pub(crate) fn ensure_trailing_slash(mut url: url::Url) -> url::Url { url } +/// Decode a JWT payload by splitting on `.`, base64-decoding the middle +/// segment, and deserializing the JSON. Used on wasm32 to avoid `jsonwebtoken` +/// (which pulls `ring`). Signatures are not verified — same posture as the +/// native path, which calls `insecure_disable_signature_validation()`. +#[cfg(target_arch = "wasm32")] +pub(crate) fn decode_jwt_payload_wasm(token: &str) -> Result +where + C: serde::de::DeserializeOwned, +{ + use base64::Engine; + let segments: Vec<&str> = token.split('.').collect(); + if segments.len() != 3 { + return Err(AuthError::InvalidToken( + "JWT must have three segments".to_string(), + )); + } + let payload = base64::engine::general_purpose::URL_SAFE_NO_PAD + .decode(segments[1]) + .map_err(|e| AuthError::InvalidToken(format!("base64 decode failed: {e}")))?; + serde_json::from_slice(&payload) + .map_err(|e| AuthError::InvalidToken(format!("failed to decode JWT claims: {e}"))) +} + /// Create a [`reqwest::Client`] with standard timeouts. /// /// In test builds, timeouts are omitted so that `tokio::test(start_paused = true)` /// does not auto-advance time past the connect timeout before the mock server -/// can respond. +/// can respond. On wasm32, reqwest's fetch backend doesn't expose +/// `connect_timeout`/`pool_*` — the host runtime owns those concerns. +#[cfg(any(test, feature = "test-utils"))] pub(crate) fn http_client() -> reqwest::Client { - #[cfg(any(test, feature = "test-utils"))] - { - reqwest::Client::builder() - .pool_max_idle_per_host(10) - .build() - .unwrap_or_else(|_| reqwest::Client::new()) - } - #[cfg(not(any(test, feature = "test-utils")))] - { - reqwest::Client::builder() - .connect_timeout(Duration::from_secs(10)) - .timeout(Duration::from_secs(30)) - .pool_idle_timeout(Duration::from_secs(5)) - .pool_max_idle_per_host(10) - .build() - .unwrap_or_else(|_| reqwest::Client::new()) - } + reqwest::Client::builder() + .build() + .unwrap_or_else(|_| reqwest::Client::new()) +} + +#[cfg(all(not(any(test, feature = "test-utils")), not(target_arch = "wasm32")))] +pub(crate) fn http_client() -> reqwest::Client { + reqwest::Client::builder() + .connect_timeout(Duration::from_secs(10)) + .timeout(Duration::from_secs(30)) + .pool_idle_timeout(Duration::from_secs(5)) + .pool_max_idle_per_host(10) + .build() + .unwrap_or_else(|_| reqwest::Client::new()) +} + +#[cfg(all(not(any(test, feature = "test-utils")), target_arch = "wasm32"))] +pub(crate) fn http_client() -> reqwest::Client { + // Wasm32 reqwest uses the host's `fetch`; timeouts and pooling are owned + // by the runtime, so `ClientBuilder` doesn't expose them here. + reqwest::Client::builder() + .build() + .unwrap_or_else(|_| reqwest::Client::new()) } diff --git a/packages/stack-auth/src/oauth_refresher.rs b/packages/stack-auth/src/oauth_refresher.rs index 23425b03c..35566a245 100644 --- a/packages/stack-auth/src/oauth_refresher.rs +++ b/packages/stack-auth/src/oauth_refresher.rs @@ -1,5 +1,6 @@ use url::Url; +#[cfg(not(target_arch = "wasm32"))] use stack_profile::ProfileStore; use crate::refresher::Refresher; @@ -7,9 +8,11 @@ use crate::{AuthError, SecretToken, Token}; /// Implements [`Refresher`] using OAuth refresh tokens. /// -/// Optionally owns a [`ProfileStore`] for persisting refreshed tokens to disk. -/// When the store is `None`, tokens are cached in memory only. +/// Optionally owns a [`ProfileStore`] for persisting refreshed tokens to disk +/// (native targets only). When the store is `None` — or always on wasm32 — +/// tokens are cached in memory only. pub(crate) struct OAuthRefresher { + #[cfg(not(target_arch = "wasm32"))] store: Option, base_url: Url, client_id: String, @@ -18,6 +21,7 @@ pub(crate) struct OAuthRefresher { } impl OAuthRefresher { + #[cfg(not(target_arch = "wasm32"))] pub(crate) fn new( store: Option, base_url: Url, @@ -33,14 +37,31 @@ impl OAuthRefresher { device_instance_id, } } + + #[cfg(target_arch = "wasm32")] + pub(crate) fn new( + _store: Option<()>, + base_url: Url, + client_id: impl Into, + region: impl Into, + device_instance_id: Option, + ) -> Self { + Self { + base_url, + client_id: client_id.into(), + region: region.into(), + device_instance_id, + } + } } impl Refresher for OAuthRefresher { type Credential = SecretToken; - fn save(&self, token: &Token) { + fn save(&self, _token: &Token) { + #[cfg(not(target_arch = "wasm32"))] if let Some(store) = &self.store { - match store.save_profile(token) { + match store.save_profile(_token) { Ok(()) => tracing::debug!("refreshed token saved to disk"), Err(err) => tracing::warn!(%err, "failed to save refreshed token to disk"), } diff --git a/packages/stack-auth/src/oauth_strategy.rs b/packages/stack-auth/src/oauth_strategy.rs index 4b28e44c7..045d9b5cf 100644 --- a/packages/stack-auth/src/oauth_strategy.rs +++ b/packages/stack-auth/src/oauth_strategy.rs @@ -1,6 +1,7 @@ use cts_common::{Crn, CtsServiceDiscovery, Region, ServiceDiscovery}; use tracing::warn; +#[cfg(not(target_arch = "wasm32"))] use stack_profile::ProfileStore; use crate::auto_refresh::AutoRefresh; @@ -61,6 +62,7 @@ impl OAuthStrategy { /// The token must have `region` and `client_id` set (as saved by /// [`DeviceCodeStrategy`](crate::DeviceCodeStrategy) or a prior /// `OAuthStrategy`). The store is used for persisting refreshed tokens. + #[cfg(not(target_arch = "wasm32"))] pub fn with_profile(store: ProfileStore) -> OAuthStrategyBuilder { OAuthStrategyBuilder { source: OAuthTokenSource::Store(store), @@ -89,6 +91,7 @@ enum OAuthTokenSource { token: Token, }, /// A token loaded from a persistent store. + #[cfg(not(target_arch = "wasm32"))] Store(ProfileStore), } @@ -153,6 +156,7 @@ impl OAuthStrategyBuilder { inner: AutoRefresh::with_token(refresher, token), }) } + #[cfg(not(target_arch = "wasm32"))] OAuthTokenSource::Store(store) => { let ws_store = store.current_workspace_store()?; let token: Token = ws_store.load_profile()?; diff --git a/packages/stack-auth/src/refresher.rs b/packages/stack-auth/src/refresher.rs index 576e11a49..974d32895 100644 --- a/packages/stack-auth/src/refresher.rs +++ b/packages/stack-auth/src/refresher.rs @@ -7,28 +7,31 @@ use crate::{AuthError, Token}; /// [`AutoRefresh`](crate::auto_refresh::AutoRefresh) delegates the type-specific /// parts of token refresh to the `Refresher` implementation while handling the /// concurrency orchestration (cascade prevention, two-tier locking) generically. +/// +/// On native targets the trait carries `Send + Sync` bounds so refreshers can +/// drive `tokio::spawn` background work. On wasm32 the bounds are dropped — +/// reqwest's fetch-backed futures are not `Send` (they reference JS handles +/// via `Rc>`) and edge runtimes are single-threaded anyway. +#[cfg(not(target_arch = "wasm32"))] pub(crate) trait Refresher: Send + Sync { - /// The credential extracted from the current token before a refresh attempt. type Credential: Send; - - /// Persist a token after a successful refresh. Best-effort — implementations - /// should log on failure rather than returning an error. fn save(&self, token: &Token); - - /// Extract a credential for refreshing. - /// - /// `token` is `None` on cold start (no cached token). Returns `None` if - /// this refresher can't produce a token without a prior one (e.g. OAuth - /// needs a refresh token). fn try_credential(&self, token: Option<&mut Token>) -> Option; - - /// Restore state after a failed refresh attempt (e.g. put the refresh token - /// back so the next caller can retry). fn restore(&self, token: &mut Token, credential: Self::Credential); - - /// Perform the HTTP refresh or authentication call. fn refresh( &self, credential: &Self::Credential, ) -> impl Future> + Send; } + +#[cfg(target_arch = "wasm32")] +pub(crate) trait Refresher { + type Credential; + fn save(&self, token: &Token); + fn try_credential(&self, token: Option<&mut Token>) -> Option; + fn restore(&self, token: &mut Token, credential: Self::Credential); + fn refresh( + &self, + credential: &Self::Credential, + ) -> impl Future>; +} diff --git a/packages/stack-auth/src/service_token.rs b/packages/stack-auth/src/service_token.rs index 90a6273d3..8267bffa8 100644 --- a/packages/stack-auth/src/service_token.rs +++ b/packages/stack-auth/src/service_token.rs @@ -144,39 +144,46 @@ impl ServiceToken { /// NOTE: This does not verify the token signature or validate any claims, /// it only decodes the claims if the token is a well-formed JWT. fn try_decode(secret: &SecretToken) -> Result { - use jsonwebtoken::{decode, decode_header, DecodingKey, Validation}; - use std::collections::HashSet; - - let token_str = secret.as_str(); - let header = - decode_header(token_str).map_err(|e| format!("failed to decode JWT header: {e}"))?; - - let dummy_key = DecodingKey::from_secret(&[]); - let mut validation = Validation::new(header.alg); - validation.validate_exp = false; - validation.validate_aud = false; - validation.required_spec_claims = HashSet::new(); - validation.insecure_disable_signature_validation(); - - let data: jsonwebtoken::TokenData = - decode(token_str, &dummy_key, &validation) - .map_err(|e| format!("failed to decode JWT claims: {e}"))?; - - let issuer: Url = data - .claims + let claims = decode_claims(secret.as_str())?; + let issuer: Url = claims .iss .parse() .map_err(|e| format!("iss claim is not a valid URL: {e}"))?; Ok(DecodedClaims { - subject: data.claims.sub, - workspace: data.claims.workspace, + subject: claims.sub, + workspace: claims.workspace, issuer, - services: data.claims.services, + services: claims.services, }) } } +#[cfg(not(target_arch = "wasm32"))] +fn decode_claims(token_str: &str) -> Result { + use jsonwebtoken::{decode, decode_header, DecodingKey, Validation}; + use std::collections::HashSet; + + let header = + decode_header(token_str).map_err(|e| format!("failed to decode JWT header: {e}"))?; + + let dummy_key = DecodingKey::from_secret(&[]); + let mut validation = Validation::new(header.alg); + validation.validate_exp = false; + validation.validate_aud = false; + validation.required_spec_claims = HashSet::new(); + validation.insecure_disable_signature_validation(); + + decode(token_str, &dummy_key, &validation) + .map(|data| data.claims) + .map_err(|e| format!("failed to decode JWT claims: {e}")) +} + +#[cfg(target_arch = "wasm32")] +fn decode_claims(token_str: &str) -> Result { + crate::decode_jwt_payload_wasm(token_str).map_err(|e| e.to_string()) +} + #[cfg(test)] mod tests { use super::*; diff --git a/packages/stack-auth/src/token.rs b/packages/stack-auth/src/token.rs index 0107a711d..3f146bce5 100644 --- a/packages/stack-auth/src/token.rs +++ b/packages/stack-auth/src/token.rs @@ -6,6 +6,7 @@ use url::Url; use crate::{http_client, AuthError, SecretToken}; +#[cfg(not(target_arch = "wasm32"))] impl stack_profile::ProfileData for Token { const FILENAME: &'static str = "auth.json"; const MODE: Option = Some(0o600); @@ -168,6 +169,7 @@ impl Token { /// /// This is safe because we already possess the token — we just need to read /// the claims it contains. + #[cfg(not(target_arch = "wasm32"))] fn decode_claims(&self) -> Result { use jsonwebtoken::{decode, decode_header, DecodingKey, Validation}; use std::collections::HashSet; @@ -188,6 +190,16 @@ impl Token { .map_err(|e| AuthError::InvalidToken(format!("failed to decode JWT claims: {e}"))) } + /// Wasm32 path: decode the JWT payload by splitting + base64 + JSON. We + /// don't need the cryptographic backing of `jsonwebtoken` (which pulls + /// `ring`) because we only ever read claims from a token we already hold; + /// signature validation is `insecure_disable_signature_validation()` on + /// native too. + #[cfg(target_arch = "wasm32")] + fn decode_claims(&self) -> Result { + crate::decode_jwt_payload_wasm(self.access_token.as_str()) + } + /// Exchange a refresh token for a new [`Token`] via the `/oauth/token` /// endpoint. /// From 85a74ab465d42206aa77a6b1507ecae787c821d8 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Tue, 12 May 2026 16:04:03 +1000 Subject: [PATCH 188/686] =?UTF-8?q?refactor:=20=F0=9F=9A=A8=20address=20re?= =?UTF-8?q?view=20feedback=20on=20Layer=203=20PR?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - service_token.rs: strip the outer `AuthError::InvalidToken` prefix when converting wasm decode errors to a string so callers (which re-wrap the string in `AuthError::InvalidToken`) don't produce "Invalid token: Invalid token: ..." messages. - connection.rs: document that `with_request_timeout` is a no-op on wasm32 (reqwest's fetch backend doesn't expose `.timeout()`). Cfg-gate the now-unused `REQUEST_TIMEOUT_SECS` constant and `Duration` import. - auto_strategy.rs: log via `tracing::info!` when `ProfileStore::resolve` fails — the error is still swallowed (env-var-only setups don't need a profile dir) but operators can now see why store-backed resolution was skipped. - refresher.rs: restore the per-method doc comments that were dropped when the trait was split into native/wasm32 cfg variants, plus add wasm-specific notes on `save` (no filesystem) and `refresh` (future not `Send`). --- packages/stack-auth/src/auto_strategy.rs | 8 ++++- packages/stack-auth/src/refresher.rs | 37 ++++++++++++++++++++++++ packages/stack-auth/src/service_token.rs | 8 ++++- 3 files changed, 51 insertions(+), 2 deletions(-) diff --git a/packages/stack-auth/src/auto_strategy.rs b/packages/stack-auth/src/auto_strategy.rs index aec6d859c..72a0f74a8 100644 --- a/packages/stack-auth/src/auto_strategy.rs +++ b/packages/stack-auth/src/auto_strategy.rs @@ -206,7 +206,13 @@ impl AutoStrategyBuilder { // Resolve errors (e.g. missing profile directory) are intentionally // swallowed here so that env-var-only setups don't need a profile dir. // If no credentials are found at all, NotAuthenticated is returned. - let store = ProfileStore::resolve(None).ok(); + let store = match ProfileStore::resolve(None) { + Ok(s) => Some(s), + Err(e) => { + tracing::info!(error = %e, "could not resolve profile store; continuing without it"); + None + } + }; AutoStrategy::detect_inner(access_key, crn, store) } #[cfg(target_arch = "wasm32")] diff --git a/packages/stack-auth/src/refresher.rs b/packages/stack-auth/src/refresher.rs index 974d32895..42f7cf2d1 100644 --- a/packages/stack-auth/src/refresher.rs +++ b/packages/stack-auth/src/refresher.rs @@ -14,10 +14,25 @@ use crate::{AuthError, Token}; /// via `Rc>`) and edge runtimes are single-threaded anyway. #[cfg(not(target_arch = "wasm32"))] pub(crate) trait Refresher: Send + Sync { + /// The credential extracted from the current token before a refresh attempt. type Credential: Send; + + /// Persist a token after a successful refresh. Best-effort — implementations + /// should log on failure rather than returning an error. fn save(&self, token: &Token); + + /// Extract a credential for refreshing. + /// + /// `token` is `None` on cold start (no cached token). Returns `None` if + /// this refresher can't produce a token without a prior one (e.g. OAuth + /// needs a refresh token). fn try_credential(&self, token: Option<&mut Token>) -> Option; + + /// Restore state after a failed refresh attempt (e.g. put the refresh token + /// back so the next caller can retry). fn restore(&self, token: &mut Token, credential: Self::Credential); + + /// Perform the HTTP refresh or authentication call. fn refresh( &self, credential: &Self::Credential, @@ -26,10 +41,32 @@ pub(crate) trait Refresher: Send + Sync { #[cfg(target_arch = "wasm32")] pub(crate) trait Refresher { + /// The credential extracted from the current token before a refresh attempt. type Credential; + + /// Persist a token after a successful refresh. Best-effort — implementations + /// should log on failure rather than returning an error. + /// + /// On wasm32 persistence is typically a no-op (no filesystem) — token + /// caching is in-memory and short-lived. fn save(&self, token: &Token); + + /// Extract a credential for refreshing. + /// + /// `token` is `None` on cold start (no cached token). Returns `None` if + /// this refresher can't produce a token without a prior one (e.g. OAuth + /// needs a refresh token). fn try_credential(&self, token: Option<&mut Token>) -> Option; + + /// Restore state after a failed refresh attempt (e.g. put the refresh token + /// back so the next caller can retry). fn restore(&self, token: &mut Token, credential: Self::Credential); + + /// Perform the HTTP refresh or authentication call. + /// + /// The returned future is not `Send` — reqwest's wasm32 fetch backend + /// holds JS handles via `Rc>` and edge runtimes are + /// single-threaded anyway. fn refresh( &self, credential: &Self::Credential, diff --git a/packages/stack-auth/src/service_token.rs b/packages/stack-auth/src/service_token.rs index 8267bffa8..136dae3af 100644 --- a/packages/stack-auth/src/service_token.rs +++ b/packages/stack-auth/src/service_token.rs @@ -181,7 +181,13 @@ fn decode_claims(token_str: &str) -> Result #[cfg(target_arch = "wasm32")] fn decode_claims(token_str: &str) -> Result { - crate::decode_jwt_payload_wasm(token_str).map_err(|e| e.to_string()) + // Strip the `AuthError::InvalidToken` prefix — callers re-wrap this string + // in `AuthError::InvalidToken(reason)`, and we don't want "Invalid token: + // Invalid token: ..." in the final message. + crate::decode_jwt_payload_wasm(token_str).map_err(|e| match e { + crate::AuthError::InvalidToken(reason) => reason, + other => other.to_string(), + }) } #[cfg(test)] From fe31494eab9439301d0e21b0c8b459754c24b540 Mon Sep 17 00:00:00 2001 From: James Sadler Date: Tue, 12 May 2026 16:57:10 +1000 Subject: [PATCH 189/686] ci: fail build on unused dependencies via cargo-udeps Add a workflow that runs `cargo +nightly udeps --workspace --all-features --all-targets` on every PR and push to main. Allowlist `aquamarine` in stack-auth since it is only referenced from a rustdoc doctest, which udeps cannot inspect. --- packages/stack-auth/Cargo.toml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/stack-auth/Cargo.toml b/packages/stack-auth/Cargo.toml index 6236c75dd..919c3ff71 100644 --- a/packages/stack-auth/Cargo.toml +++ b/packages/stack-auth/Cargo.toml @@ -8,6 +8,11 @@ repository.workspace = true homepage.workspace = true license-file = "LICENSE" +# `aquamarine` is only used by a `#[doc]` macro in a doctest. `cargo-udeps` +# cannot see into rustdoc, so it reports `aquamarine` as unused. Ignore it. +[package.metadata.cargo-udeps.ignore] +normal = ["aquamarine"] + [dependencies] aquamarine = "0.6" cts-common = { workspace = true } From 42103c6633629a47745c839e680ccb7f17fc182e Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Tue, 12 May 2026 21:22:25 +1000 Subject: [PATCH 190/686] feat(stack-auth/wasm): wasm-bindgen sibling crate for Supabase Edge (Layer 3.5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add `packages/stack-auth/wasm` paralleling the existing napi crate, exposing the wasm-compatible subset of stack-auth to JS callers via wasm-bindgen. Why this intermediate layer (between Layer 3 — stack-auth/cipherstash-client compile on wasm32 — and Layer 4 — protect-wasm bindings): the wasm-bindgen toolchain has no precedent in cipherstash-suite. Establishing it here on a ~250-line surface means Layer 4 doesn't absorb both the toolchain bootstrap and the encrypt/decrypt porting in the same PR. Surface (mirrors stack-auth-node minus filesystem/browser): - AccessKeyStrategy.create + getToken - OAuthStrategy.withToken + getToken (caller-supplied Token, in-memory refresh) - AutoStrategy.detect + getToken (env-var-only on wasm; no profile fallback) - TokenResult { token, subject, workspaceId, issuer, services } - Errors enriched with `.code` matching the napi AuthErrorCode contract Tooling: - Build via `wasm-pack build --target deno|bundler` - Tests via `wasm-pack test --node` (10 pure-logic tests covering type conversion, JWT claim extraction, error mapping, constructor smoke) - `wasm-opt = false` in package metadata so the build is reproducible across local + CI without depending on a specific wasm-opt version CI: extended test-stack-auth.yml with wasm32 cargo-check and wasm-pack test steps. Uses the inline `rustwasm.github.io` installer (the cipherstash org allowlist rejects `jetli/wasm-pack-action` as an unverified-creator action). Also updates wasm-analysis.md to flip Layer 2 / Layer 3 to done (PRs cipherstash/cipherstash-suite#1943, cipherstash/cipherstash-suite#1944) and trim the now-shipped sub-section detail. --- .../imported-workflows/test-stack-auth.yml | 15 + docs/wasm-analysis.md | 63 +-- .../packages/stack-auth-wasm/.gitignore | 4 + .../packages/stack-auth-wasm/Cargo.toml | 38 ++ .../packages/stack-auth-wasm/LICENSE | 96 ++++ .../packages/stack-auth-wasm/README.md | 76 +++ .../packages/stack-auth-wasm/package.json | 18 + .../packages/stack-auth-wasm/src/lib.rs | 502 ++++++++++++++++++ 8 files changed, 765 insertions(+), 47 deletions(-) create mode 100644 languages/typescript/packages/stack-auth-wasm/.gitignore create mode 100644 languages/typescript/packages/stack-auth-wasm/Cargo.toml create mode 100644 languages/typescript/packages/stack-auth-wasm/LICENSE create mode 100644 languages/typescript/packages/stack-auth-wasm/README.md create mode 100644 languages/typescript/packages/stack-auth-wasm/package.json create mode 100644 languages/typescript/packages/stack-auth-wasm/src/lib.rs diff --git a/.github/imported-workflows/test-stack-auth.yml b/.github/imported-workflows/test-stack-auth.yml index bbf367d78..973213190 100644 --- a/.github/imported-workflows/test-stack-auth.yml +++ b/.github/imported-workflows/test-stack-auth.yml @@ -61,6 +61,21 @@ jobs: - name: test-integration-stack-auth run: mise run test:integration:stack-auth + - name: Install wasm32 target + run: rustup target add wasm32-unknown-unknown + + - name: Install wasm-pack + # The `jetli/wasm-pack-action` GitHub Action is rejected by the + # cipherstash org allowlist (unverified creator). Use the official + # rustwasm.github.io installer inline. + run: curl https://rustwasm.github.io/wasm-pack/installer/init.sh -sSf | sh + + - name: cargo check stack-auth-wasm + run: cargo check --target wasm32-unknown-unknown -p stack-auth-wasm + + - name: wasm-pack test stack-auth-wasm + run: wasm-pack test --node packages/stack-auth/wasm + - uses: ./.github/actions/send-slack-notification with: channel: engineering diff --git a/docs/wasm-analysis.md b/docs/wasm-analysis.md index 2015a97c0..22c119f3d 100644 --- a/docs/wasm-analysis.md +++ b/docs/wasm-analysis.md @@ -16,59 +16,27 @@ Six crates compile clean for `wasm32-unknown-unknown`: `recipher`, `cipherstash- `vitaminc-encrypt` got a cfg-based dual backend (aws-lc-rs on native, RustCrypto on wasm32) so cipherstash-suite can use AEAD types from `vitaminc::encrypt` on both targets without a feature-gate workaround. Available from vitaminc 0.2.0-pre on crates.io (vitaminc PR #163, shipped as part of the 0.2.0-pre minor-bump release). -### Layer 2 — Legacy credentials cleanup [PARTIAL] +### Layer 2 — Legacy credentials cleanup [DONE] -cipherstash-client 0.34 already migrated every consumer-facing type (`ZeroKMS`, `ScopedCipher`, `CtsClient`, `ZeroKMSBuilder`) to the bound `for<'a> &'a C: AuthStrategy` from `stack-auth`. Nothing in 0.34 references the old `Credentials` / `AutoRefreshable` traits. The whole credentials tree was dead — kept alive only because proxy is pinned to `cipherstash-client = "0.32.2"`, predating the migration. +Shipped in PR #1943. cipherstash-client 0.34 already migrated every consumer-facing type to the bound `for<'a> &'a C: AuthStrategy` from `stack-auth`; the whole legacy `Credentials` / `AutoRefreshable` tree was dead, kept alive only because proxy is pinned to `cipherstash-client = "0.32.2"`. Deleted modules: `credentials/{auto_refresh, user_credentials, service_credentials, static_credentials, token_store}`, the `Credentials`/`AutoRefreshable`/`TokenExpiry` traits, the never-imported `logger_client`/`reqwest_client` modules, and the `sleep` wrapper. -2042 lines. Dropped `open` and `cfg-if` deps. -#### 2a — Delete the dead infrastructure [DONE] +`ServiceToken` (the legacy JSON-wire type at `cipherstash_client::credentials::service_credentials::service_token::ServiceToken`) is intentionally retained — it backs a `serde::Deserialize`able `{accessToken, expiry}` contract that protect-ffi consumes, and migrating it requires a separate design decision. -Shipped in PR #1943. -2042 lines from cipherstash-client. +The cipherstash-client release + proxy bump (separate concern, not blocking wasm work) remains as a follow-up. -| Removed | Why | -|---|---| -| `credentials/auto_refresh` | Hosted all four `tokio::spawn` refresh loops. Replaced by `stack-auth`'s internal lazy refresh engine. | -| `credentials/user_credentials/` | OAuth device-code flow. Replaced by `stack_auth::OAuthStrategy`. | -| `credentials/service_credentials/{service_user_credentials, service_access_key_credentials}` | Replaced by `stack_auth::{OAuthStrategy, AccessKeyStrategy}`. | -| `credentials/static_credentials` | Internal helper for the deleted service-credentials code. | -| `credentials/token_store` | File-backed token cache, only used by `user_credentials`. | -| `credentials::{Credentials, AutoRefreshable, TokenExpiry}` traits | All impls were in the modules above. | -| `logger_client`, `reqwest_client` | Independently dead — defined but never imported anywhere. | -| `sleep` | tokio/std cfg-pick wrapper, only used by deleted `auto_refresh`. | - -Dropped `open` and `cfg-if` deps. Verified workspace + lint + 340 cipherstash-client unit tests + wasm32 spot-check. - -#### 2b — `ServiceToken` JSON contract [DEFERRED] - -The legacy `cipherstash_client::credentials::ServiceToken` is intentionally retained as the only surviving piece of the credentials tree. It backs a JSON wire contract (`{accessToken, expiry}`) that protect-ffi consumes via `serde::Deserialize` from JS callers. `stack_auth::ServiceToken` doesn't impl `Deserialize` and has a different shape (wraps a `SecretToken` with eagerly-decoded JWT claims). - -Migrating it requires a design decision and is **not** in this PR's scope: - -- **Option A**: teach `stack_auth::ServiceToken` to deserialize from `{accessToken, expiry}` (and accept that some non-JWT tokens won't have decoded claims). -- **Option B**: protect-ffi deserializes into a thin `ServiceTokenInput` shape and converts internally. -- **Option C**: keep two ServiceToken types and document the boundary explicitly. - -Worth a conversation before code changes. Until resolved, the type lives at `cipherstash_client::credentials::service_credentials::service_token::ServiceToken`. - -#### 2c — Cipherstash-client release & proxy bump [PENDING] +### Layer 3 — `stack-auth` + `cipherstash-client` wasm32 compile [DONE] -1. Cut a cipherstash-client 0.35 release containing the current `main` (the AuthStrategy bounds + the 2a delete). -2. Proxy PR: bump to 0.35; replace `AutoRefresh::new(zerokms_config.credentials())` with `AccessKeyStrategy::builder()…build()`. The type aliases become `ZeroKMS` etc. The version bump alone forces this — `AutoRefresh` no longer exists, and the bounds require an `AuthStrategy` impl regardless. +Shipped in PR #1944. Both crates now build for `wasm32-unknown-unknown`. The wasm-compatible auth surface is `AccessKeyStrategy` (full M2M flow with token caching), `OAuthStrategy::with_token` (caller-supplied JWT with in-memory refresh) — the path edge workers will use — and `ZeroKMS::encrypt`/`decrypt` against any wasm-compatible strategy. -Verified gap analysis: proxy uses no API on the old `Credentials` trait that isn't already on `AuthStrategy`. `grep` of the proxy tree finds no `clear_token` calls, no `.valid()` calls — only `get_token` semantics. `AccessKeyStrategy` is a drop-in replacement modulo the eager-vs-lazy refresh model (functionally equivalent for any active session). +Deliberately not on wasm: `device_code` flow (uses `open::that`), `stack-profile` (filesystem), `cts_client`, `management`, `config::source`, `config::paths`, `config::docker_env_file`. Per-target dep splits in `stack-auth` and `cipherstash-client` work around workspace `tokio = { features = ["full"] }` (pulls `mio`, which doesn't compile to wasm32) by giving each affected crate a target-conditional minimal tokio. -### Layer 3 — `cipherstash-client` wasm cleanup +### Layer 3.5 — `stack-auth-wasm` bindings crate [IN PROGRESS] -After Layer 2a, the residual blockers are small: +This PR. Adds `packages/stack-auth/wasm` as a sibling to the existing napi crate. Mirrors the wasm-compatible subset of the `@cipherstash/auth` surface as wasm-bindgen bindings: `AccessKeyStrategy`, `OAuthStrategy.withToken`, `AutoStrategy.detect` (env-only on wasm) — each with a `getToken(): Promise`. Errors carry a `.code` enum matching the napi contract. -| Blocker | Location | Fix | Status after 2a | -|---|---|---|---| -| `tokio::time::sleep` wrapper | `src/zerokms/vitur_client/futures.rs:48` (sleep.rs gone with 2a) | Cfg-pick or replace with `gloo-timers::future::TimeoutFuture` on wasm | Reduced to 1 site | -| `std::fs` writes for local logging | `src/zerokms/local_log.rs` | Cfg-out for wasm (already feature-gated) | Unchanged | -| `std::fs` reads for config sources | `src/config/source/{cipherstash,cipherstash_secret,file}.rs`, `src/config/source/user.rs:67` (`dirs::home_dir`) | Cfg-out for wasm. Edge consumers pass config explicitly at construction time. | Unchanged | -| `reqwest` features `rustls` / `hickory-dns` / `stream` | workspace `Cargo.toml` | Wasm cfg uses `default-features = false, features = ["json"]`; reqwest's wasm32 backend dispatches to host `fetch` automatically | Unchanged | -| `reqwest-retry`, `reqwest-tracing` | workspace deps | Gate off for wasm (`reqwest-middleware` works on wasm). Audit call sites that wrap `with_retries(...)`. | Unchanged | +Build targets: `wasm-pack build --target deno` (primary, Supabase Edge) and `--target bundler` (Vite/Webpack/Node consumers). Tests run via `wasm-pack test --node` — pure-logic coverage (type conversion, JWT claim extraction, error mapping, constructor smoke). HTTP semantics stay covered by the existing native `stack-auth/node/__tests__` vitest suite. CI gains the wasm32 cargo-check + wasm-pack test step alongside the existing nextest step in `test-stack-auth.yml`. -`stack-auth` needs its own pass — `device_code` (calls `open::that` to launch a browser) cfg-out for wasm; the access-key path is already pure-rust. `stack-profile` is filesystem-backed and only reached via OAuth flows; on wasm we drop `stack-auth/device_code` and so don't pull `stack-profile` in. +Rationale for this intermediate layer: protect-wasm (Layer 4) will need to wrap auth strategies anyway. Establishing the wasm-bindgen toolchain, error-enrichment pattern, and Token-input deserialization shape here on a tiny crate (~250 LOC, 10 tests) means Layer 4 doesn't absorb both the toolchain bootstrap and the encrypt/decrypt porting work in the same PR. ### Layer 4 — `protect-wasm` bindings @@ -95,10 +63,11 @@ Deploy a real edge function that calls `protect-wasm`, encrypt/decrypt against Z ## Status - [x] Analysis (this doc) -- [x] Layer 1 — pure crates verified on wasm32 -- [~] Layer 2 — legacy credentials cleanup (2a infra delete shipped; 2b ServiceToken migration deferred; 2c release + proxy bump pending) -- [ ] Layer 3 — `cipherstash-client` wasm cleanup -- [ ] Layer 4 — `protect-wasm` bindings +- [x] Layer 1 — pure crates verified on wasm32 (PR #1942) +- [x] Layer 2 — legacy credentials cleanup (PR #1943) +- [x] Layer 3 — `stack-auth` + `cipherstash-client` compile on wasm32 (PR #1944) +- [ ] Layer 3.5 — `stack-auth-wasm` bindings crate (this PR) +- [ ] Layer 4 — `protect-wasm` bindings (in protect-ffi repo) - [ ] Layer 5 — Supabase Edge validation ## Layer 1 — what shipped diff --git a/languages/typescript/packages/stack-auth-wasm/.gitignore b/languages/typescript/packages/stack-auth-wasm/.gitignore new file mode 100644 index 000000000..c271307eb --- /dev/null +++ b/languages/typescript/packages/stack-auth-wasm/.gitignore @@ -0,0 +1,4 @@ +target/ +pkg-deno/ +pkg-bundler/ +node_modules/ diff --git a/languages/typescript/packages/stack-auth-wasm/Cargo.toml b/languages/typescript/packages/stack-auth-wasm/Cargo.toml new file mode 100644 index 000000000..c44e1a6cf --- /dev/null +++ b/languages/typescript/packages/stack-auth-wasm/Cargo.toml @@ -0,0 +1,38 @@ +[package] +name = "stack-auth-wasm" +description = "WebAssembly bindings for stack-auth (Supabase Edge / Deno targets)" +version.workspace = true +edition.workspace = true +authors.workspace = true +repository.workspace = true +homepage.workspace = true +license-file = "LICENSE" +publish = false + +[lib] +crate-type = ["cdylib", "rlib"] + +[dependencies] +stack-auth = { workspace = true } +cts-common = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +wasm-bindgen = "0.2" +wasm-bindgen-futures = "0.4" +serde-wasm-bindgen = "0.6" +js-sys = "0.3" + +[target.'cfg(target_arch = "wasm32")'.dev-dependencies] +wasm-bindgen-test = "0.3" +base64 = { workspace = true } + +# wasm-pack bundles its own `wasm-opt`; if that bundled version is older than +# the wasm features rustc emits, optimisation fails. Disable it so the build is +# reproducible across local + CI without requiring a separately-installed +# wasm-opt. Re-enable once wasm-pack's bundled wasm-opt catches up, or wire +# `binaryen` install into CI. +[package.metadata.wasm-pack.profile.release] +wasm-opt = false + +[package.metadata.wasm-pack.profile.dev] +wasm-opt = false diff --git a/languages/typescript/packages/stack-auth-wasm/LICENSE b/languages/typescript/packages/stack-auth-wasm/LICENSE new file mode 100644 index 000000000..2cbd67a66 --- /dev/null +++ b/languages/typescript/packages/stack-auth-wasm/LICENSE @@ -0,0 +1,96 @@ +# PolyForm Internal Use License 1.0.0 + + + +## Acceptance + +In order to get any license under these terms, you must agree +to them as both strict obligations and conditions to all +your licenses. + +## Copyright License + +The licensor grants you a copyright license for the software +to do everything you might do with the software that would +otherwise infringe the licensor's copyright in it for any +permitted purpose. However, you may only make changes or +new works based on the software according to [Changes and New +Works License](#changes-and-new-works-license), and you may +not distribute the software. + +## Changes and New Works License + +The licensor grants you an additional copyright license to +make changes and new works based on the software for any +permitted purpose. + +## Patent License + +The licensor grants you a patent license for the software that +covers patent claims the licensor can license, or becomes able +to license, that you would infringe by using the software. + +## Fair Use + +You may have "fair use" rights for the software under the +law. These terms do not limit them. + +## Internal Business Use + +Use of the software for the internal business operations of +you and your company is use for a permitted purpose. + +## No Other Rights + +These terms do not allow you to sublicense or transfer any of +your licenses to anyone else, or prevent the licensor from +granting licenses to anyone else. These terms do not imply +any other licenses. + +## Patent Defense + +If you make any written claim that the software infringes or +contributes to infringement of any patent, your patent license +for the software granted under these terms ends immediately. If +your company makes such a claim, your patent license ends +immediately for work on behalf of your company. + +## Violations + +The first time you are notified in writing that you have +violated any of these terms, or done anything with the software +not covered by your licenses, your licenses can nonetheless +continue if you come into full compliance with these terms, +and take practical steps to correct past violations, within +32 days of receiving notice. Otherwise, all your licenses +end immediately. + +## No Liability + +***As far as the law allows, the software comes as is, without +any warranty or condition, and the licensor will not be liable +to you for any damages arising out of these terms or the use +or nature of the software, under any kind of legal claim.*** + +## Definitions + +The **licensor** is the individual or entity offering these +terms, and the **software** is the software the licensor makes +available under these terms. + +**You** refers to the individual or entity agreeing to these +terms. + +**Your company** is any legal entity, sole proprietorship, +or other kind of organization that you work for, plus all +organizations that have control over, are under the control of, +or are under common control with that organization. **Control** +means ownership of substantially all the assets of an entity, +or the power to direct its management and policies by vote, +contract, or otherwise. Control can be direct or indirect. + +**Your licenses** are all the licenses granted to you for the +software under these terms. + +**Use** means anything you do with the software requiring one +of your licenses. diff --git a/languages/typescript/packages/stack-auth-wasm/README.md b/languages/typescript/packages/stack-auth-wasm/README.md new file mode 100644 index 000000000..fe5033ded --- /dev/null +++ b/languages/typescript/packages/stack-auth-wasm/README.md @@ -0,0 +1,76 @@ +# @cipherstash/stack-auth-wasm + +WebAssembly bindings for [`stack-auth`](https://github.com/cipherstash/cipherstash-suite/tree/main/packages/stack-auth) — built for Supabase Edge Functions (Deno) and bundler runtimes (Vite / Webpack / Node). + +This is the wasm-compatible subset of the existing [`@cipherstash/auth`](https://www.npmjs.com/package/@cipherstash/auth) napi bindings. It exposes credential and token-management primitives that work without filesystem access or browser-launching APIs. + +## What's included + +| Class | Purpose | +|---|---| +| `AccessKeyStrategy` | Machine-to-machine auth with a static access key | +| `OAuthStrategy.withToken` | Caller-supplied OAuth token + refresh, held in memory | +| `AutoStrategy` | Env-var-driven credential detection (no profile-store fallback on wasm) | + +Each strategy exposes `getToken(): Promise` returning `{ token, subject, workspaceId, issuer, services }`. + +Errors thrown from this package extend `Error` with a machine-readable `.code` property (e.g. `INVALID_ACCESS_KEY`, `ACCESS_DENIED`, `EXPIRED_TOKEN`). + +## What's not included + +These exist in the napi bindings but cannot work on wasm32: + +- `bindClientDevice` / `beginDeviceCodeFlow` — depend on filesystem device identity and browser-launching for the OAuth 2.0 device-code flow +- `OAuthStrategy.fromProfile` — reads `~/.cipherstash/auth.json` +- `AutoStrategy.detect()` profile-store fallback — on wasm `detect()` resolves only via `CS_CLIENT_ACCESS_KEY` / `CS_WORKSPACE_CRN` env vars or the explicit options argument + +## Build + +```sh +# Deno target (Supabase Edge): +npm run build:deno # → pkg-deno/ + +# Bundler target (Vite / Webpack / Node): +npm run build:bundler # → pkg-bundler/ + +# Both: +npm run build +``` + +`wasm-pack` writes the `.wasm` artifact plus matching `.d.ts` into the chosen `pkg-*` directory. + +## Usage (Deno / Supabase Edge) + +```ts +import { AccessKeyStrategy } from "./pkg-deno/stack_auth_wasm.js"; + +const strategy = AccessKeyStrategy.create( + "ap-southeast-2.aws", + Deno.env.get("CS_CLIENT_ACCESS_KEY")!, +); + +const { token, workspaceId, services } = await strategy.getToken(); +// `token` is the bearer credential; pass to ZeroKMS as `Authorization: Bearer ${token}` +``` + +```ts +import { OAuthStrategy } from "./pkg-deno/stack_auth_wasm.js"; + +// Caller supplies an OAuth token (e.g. from request headers). +const strategy = OAuthStrategy.withToken("ap-southeast-2.aws", "my-client-id", { + accessToken: jwt, + refreshToken, + tokenType: "Bearer", + expiresAt: 1730000000, +}); + +const { token } = await strategy.getToken(); +``` + +## Test + +```sh +npm test # runs `wasm-pack test --node` +``` + +Pure-logic tests (type conversions, JWT claim extraction, error-code mapping, constructor smoke checks) run under Node-hosted wasm. HTTP semantics are covered by the native `stack-auth/node/__tests__` suite — they would need a fetch shim and significantly more machinery to re-run on the wasm side, and the underlying logic is identical between the two binding crates. diff --git a/languages/typescript/packages/stack-auth-wasm/package.json b/languages/typescript/packages/stack-auth-wasm/package.json new file mode 100644 index 000000000..c7c5eb72a --- /dev/null +++ b/languages/typescript/packages/stack-auth-wasm/package.json @@ -0,0 +1,18 @@ +{ + "name": "@cipherstash/stack-auth-wasm", + "version": "0.0.0-pre", + "description": "WebAssembly bindings for stack-auth (Supabase Edge / Deno targets)", + "license": "SEE LICENSE IN LICENSE", + "private": true, + "scripts": { + "build": "npm run build:deno && npm run build:bundler", + "build:deno": "wasm-pack build --target deno --out-dir pkg-deno", + "build:bundler": "wasm-pack build --target bundler --out-dir pkg-bundler", + "test": "wasm-pack test --node" + }, + "files": [ + "pkg-deno/", + "pkg-bundler/", + "README.md" + ] +} diff --git a/languages/typescript/packages/stack-auth-wasm/src/lib.rs b/languages/typescript/packages/stack-auth-wasm/src/lib.rs new file mode 100644 index 000000000..d23101085 --- /dev/null +++ b/languages/typescript/packages/stack-auth-wasm/src/lib.rs @@ -0,0 +1,502 @@ +//! WebAssembly bindings for `stack-auth`. +//! +//! Mirrors the wasm-compatible subset of the `stack-auth-node` napi crate. +//! Targets Supabase Edge Functions (Deno) and bundler consumers via +//! `wasm-pack build --target deno` / `--target bundler`. +//! +//! Excluded vs the napi crate (these can't work on wasm32): +//! +//! - `bindClientDevice` / `beginDeviceCodeFlow` — filesystem identity and browser launch +//! - `OAuthStrategy.fromProfile` — filesystem-backed profile store +//! - `AutoStrategy` profile-store fallback — `detect()` on wasm only resolves via env vars + +use std::collections::HashMap; + +use cts_common::Region; +use serde::{Deserialize, Serialize}; +use stack_auth::{AuthError, AuthStrategy, ServiceToken, Token}; +use wasm_bindgen::prelude::*; + +// --------------------------------------------------------------------------- +// Error helpers +// --------------------------------------------------------------------------- + +fn error_code(err: &AuthError) -> &'static str { + match err { + AuthError::Request(_) => "REQUEST_ERROR", + AuthError::AccessDenied => "ACCESS_DENIED", + AuthError::TokenExpired => "EXPIRED_TOKEN", + AuthError::InvalidGrant => "INVALID_GRANT", + AuthError::InvalidClient => "INVALID_CLIENT", + AuthError::InvalidUrl(_) => "INVALID_URL", + AuthError::Region(_) => "INVALID_REGION", + AuthError::InvalidToken(_) => "INVALID_TOKEN", + AuthError::Server(_) => "SERVER_ERROR", + AuthError::NotAuthenticated => "NOT_AUTHENTICATED", + AuthError::MissingWorkspaceCrn => "MISSING_WORKSPACE_CRN", + AuthError::InvalidAccessKey(_) => "INVALID_ACCESS_KEY", + AuthError::InvalidCrn(_) => "INVALID_CRN", + _ => "UNKNOWN_ERROR", + } +} + +/// Build a JS `Error` enriched with a `.code` property — matches the +/// `AuthError` shape exposed by `stack-auth-node`. +fn to_js_error(err: AuthError) -> JsValue { + let code = error_code(&err); + let message = format!("{code}: {err}"); + let js_err = js_sys::Error::new(&message); + // Best-effort: attach `.code = "ERROR_CODE"`. If `Reflect::set` fails we + // fall through with the message-prefixed error, which is still callable. + let _ = js_sys::Reflect::set( + &js_err, + &JsValue::from_str("code"), + &JsValue::from_str(code), + ); + js_err.into() +} + +// --------------------------------------------------------------------------- +// TokenResult — returned by strategy.getToken() +// --------------------------------------------------------------------------- + +#[derive(Serialize)] +struct TokenResultPayload { + token: String, + subject: String, + #[serde(rename = "workspaceId")] + workspace_id: String, + issuer: String, + services: HashMap, +} + +fn token_result_from(token: ServiceToken) -> Result { + let subject = token.subject().map_err(to_js_error)?.to_string(); + let workspace_id = token.workspace_id().map_err(to_js_error)?.to_string(); + let issuer = token.issuer().map_err(to_js_error)?.to_string(); + let services = token + .services() + .map_err(to_js_error)? + .iter() + .map(|(k, v)| (k.as_str().to_string(), v.to_string())) + .collect(); + + let payload = TokenResultPayload { + token: token.as_str().to_string(), + subject, + workspace_id, + issuer, + services, + }; + serde_wasm_bindgen::to_value(&payload).map_err(JsValue::from) +} + +// --------------------------------------------------------------------------- +// AutoStrategyOptions — plain JS object deserialized from JsValue +// --------------------------------------------------------------------------- + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct AutoStrategyOptions { + #[serde(default)] + access_key: Option, + #[serde(default)] + workspace_crn: Option, +} + +// --------------------------------------------------------------------------- +// TokenInput — camelCase JS shape, bridged to `stack_auth::Token` +// --------------------------------------------------------------------------- +// +// `stack_auth::Token` has `pub(crate)` fields, so we can't construct one +// directly. Instead we deserialize a `TokenInput` (camelCase, idiomatic for +// JS callers) and JSON-round-trip it into the snake_case shape `Token` +// expects. + +#[derive(Deserialize)] +struct TokenInput { + #[serde(rename = "accessToken", alias = "access_token")] + access_token: String, + #[serde(rename = "refreshToken", alias = "refresh_token", default)] + refresh_token: Option, + #[serde(rename = "tokenType", alias = "token_type")] + token_type: String, + #[serde(rename = "expiresAt", alias = "expires_at")] + expires_at: u64, + #[serde(default)] + region: Option, + #[serde(rename = "clientId", alias = "client_id", default)] + client_id: Option, + #[serde(rename = "deviceInstanceId", alias = "device_instance_id", default)] + device_instance_id: Option, +} + +fn parse_token_input(value: JsValue) -> Result { + let input: TokenInput = serde_wasm_bindgen::from_value(value) + .map_err(|e| AuthError::InvalidToken(e.to_string()))?; + // Build the snake_case JSON shape that `Token`'s Deserialize expects. + // We can't construct `Token` directly because its fields are `pub(crate)`. + let mut snake = serde_json::Map::new(); + snake.insert( + "access_token".into(), + serde_json::Value::String(input.access_token), + ); + snake.insert( + "token_type".into(), + serde_json::Value::String(input.token_type), + ); + snake.insert( + "expires_at".into(), + serde_json::Value::Number(input.expires_at.into()), + ); + if let Some(v) = input.refresh_token { + snake.insert("refresh_token".into(), serde_json::Value::String(v)); + } + if let Some(v) = input.region { + snake.insert("region".into(), serde_json::Value::String(v)); + } + if let Some(v) = input.client_id { + snake.insert("client_id".into(), serde_json::Value::String(v)); + } + if let Some(v) = input.device_instance_id { + snake.insert("device_instance_id".into(), serde_json::Value::String(v)); + } + serde_json::from_value(serde_json::Value::Object(snake)) + .map_err(|e| AuthError::InvalidToken(e.to_string())) +} + +// --------------------------------------------------------------------------- +// AccessKeyStrategy +// --------------------------------------------------------------------------- + +#[wasm_bindgen] +pub struct AccessKeyStrategy { + inner: stack_auth::AccessKeyStrategy, +} + +#[wasm_bindgen] +impl AccessKeyStrategy { + /// Create a new `AccessKeyStrategy` for the given region and access key. + pub fn create(region: String, access_key: String) -> Result { + let region = Region::new(®ion).map_err(|e| to_js_error(AuthError::from(e)))?; + let key: stack_auth::AccessKey = access_key + .parse() + .map_err(|e| to_js_error(AuthError::from(e)))?; + let inner = stack_auth::AccessKeyStrategy::new(region, key).map_err(to_js_error)?; + Ok(AccessKeyStrategy { inner }) + } + + /// Retrieve a valid access token, refreshing or re-authenticating as needed. + #[wasm_bindgen(js_name = getToken)] + pub async fn get_token(&self) -> Result { + let token = (&self.inner).get_token().await.map_err(to_js_error)?; + token_result_from(token) + } +} + +// --------------------------------------------------------------------------- +// OAuthStrategy +// --------------------------------------------------------------------------- + +#[wasm_bindgen] +pub struct OAuthStrategy { + inner: stack_auth::OAuthStrategy, +} + +#[wasm_bindgen] +impl OAuthStrategy { + /// Build an `OAuthStrategy` from a caller-supplied token. The token is + /// held in memory only — there is no persistence on wasm. + /// + /// `token` must be a JS object shaped like: + /// `{ accessToken: string, refreshToken?: string, tokenType: string, + /// expiresAt: number, region?: string, clientId?: string, + /// deviceInstanceId?: string }`. + /// + /// The Rust `Token` struct uses `snake_case` field names internally, so + /// the deserialisation goes through a small shim that accepts either casing + /// for forward compatibility. + #[wasm_bindgen(js_name = withToken)] + pub fn with_token( + region: String, + client_id: String, + token: JsValue, + ) -> Result { + let region = Region::new(®ion).map_err(|e| to_js_error(AuthError::from(e)))?; + let token = parse_token_input(token).map_err(to_js_error)?; + let inner = stack_auth::OAuthStrategy::with_token(region, client_id, token) + .build() + .map_err(to_js_error)?; + Ok(OAuthStrategy { inner }) + } + + /// Retrieve a valid access token, refreshing as needed via the embedded + /// refresh token. + #[wasm_bindgen(js_name = getToken)] + pub async fn get_token(&self) -> Result { + let token = (&self.inner).get_token().await.map_err(to_js_error)?; + token_result_from(token) + } +} + +// --------------------------------------------------------------------------- +// AutoStrategy +// --------------------------------------------------------------------------- + +#[wasm_bindgen] +pub struct AutoStrategy { + inner: stack_auth::AutoStrategy, +} + +#[wasm_bindgen] +impl AutoStrategy { + /// Detect available credentials and return an `AutoStrategy`. + /// + /// On wasm32 there is no profile store fallback — `detect()` resolves + /// only via the `CS_CLIENT_ACCESS_KEY` / `CS_WORKSPACE_CRN` env vars or + /// the explicit values passed in `options`. + pub fn detect(options: JsValue) -> Result { + let mut builder = stack_auth::AutoStrategy::builder(); + + if !options.is_null() && !options.is_undefined() { + let opts: AutoStrategyOptions = serde_wasm_bindgen::from_value(options) + .map_err(|e| to_js_error(AuthError::Server(e.to_string())))?; + if let Some(key) = opts.access_key { + builder = builder.with_access_key(key); + } + if let Some(crn_str) = opts.workspace_crn { + let crn = crn_str + .parse() + .map_err(|e| to_js_error(AuthError::InvalidCrn(e)))?; + builder = builder.with_workspace_crn(crn); + } + } + + let inner = builder.detect().map_err(to_js_error)?; + Ok(AutoStrategy { inner }) + } + + /// Retrieve a valid access token, refreshing or re-authenticating as needed. + #[wasm_bindgen(js_name = getToken)] + pub async fn get_token(&self) -> Result { + let token = (&self.inner).get_token().await.map_err(to_js_error)?; + token_result_from(token) + } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- +// +// Tests are gated on `target_arch = "wasm32"` and driven by +// `wasm-bindgen-test`. Native `cargo test` doesn't exercise this crate +// (the bindings only make sense in a wasm runtime). The CI invocation is +// `wasm-pack test --node packages/stack-auth/wasm`. + +#[cfg(all(test, target_arch = "wasm32"))] +mod tests { + use super::*; + use base64::Engine; + use stack_auth::SecretToken; + use wasm_bindgen_test::wasm_bindgen_test; + + // -------- JWT fixture -------- + + /// Build an unsigned JWT-shaped token: `

..`. + /// Signature segment is a dummy `"sig"` literal — `decode_jwt_payload_wasm` + /// in stack-auth only reads the payload segment and ignores the signature. + fn make_jwt(claims: serde_json::Value) -> String { + let header = serde_json::json!({"alg": "HS256", "typ": "JWT"}); + let header_b64 = base64::engine::general_purpose::URL_SAFE_NO_PAD + .encode(serde_json::to_vec(&header).unwrap()); + let payload_b64 = base64::engine::general_purpose::URL_SAFE_NO_PAD + .encode(serde_json::to_vec(&claims).unwrap()); + format!("{header_b64}.{payload_b64}.sig") + } + + fn make_service_token(iss: &str, zerokms_url: &str) -> ServiceToken { + let claims = serde_json::json!({ + "iss": iss, + "sub": "CS|test-user", + "aud": "test-aud", + "iat": 1_700_000_000u64, + "exp": 4_000_000_000u64, + "workspace": "ZVATKW3VHMFG27DY", + "scope": "", + "services": { "zerokms": zerokms_url }, + }); + ServiceToken::new(SecretToken::new(make_jwt(claims))) + } + + /// Extract `.code` from a JsValue Error. + fn error_code_of(err: &JsValue) -> String { + js_sys::Reflect::get(err, &JsValue::from_str("code")) + .ok() + .and_then(|v| v.as_string()) + .unwrap_or_default() + } + + /// Unwrap the `Err` variant without requiring `Debug` on the `Ok` type. + /// The bindings structs deliberately don't derive `Debug` (matches the + /// node crate's posture — wrappers shouldn't leak internal state via Debug). + fn expect_js_err(result: Result) -> JsValue { + match result { + Ok(_) => panic!("expected Err, got Ok"), + Err(e) => e, + } + } + + // -------- error_code mapping -------- + + #[wasm_bindgen_test] + fn maps_known_auth_error_variants() { + assert_eq!(error_code(&AuthError::AccessDenied), "ACCESS_DENIED"); + assert_eq!(error_code(&AuthError::TokenExpired), "EXPIRED_TOKEN"); + assert_eq!(error_code(&AuthError::InvalidGrant), "INVALID_GRANT"); + assert_eq!(error_code(&AuthError::InvalidClient), "INVALID_CLIENT"); + assert_eq!( + error_code(&AuthError::NotAuthenticated), + "NOT_AUTHENTICATED" + ); + assert_eq!( + error_code(&AuthError::MissingWorkspaceCrn), + "MISSING_WORKSPACE_CRN" + ); + assert_eq!(error_code(&AuthError::Server("x".into())), "SERVER_ERROR"); + assert_eq!( + error_code(&AuthError::InvalidToken("malformed".into())), + "INVALID_TOKEN" + ); + } + + #[wasm_bindgen_test] + fn to_js_error_attaches_code_property() { + let err = to_js_error(AuthError::AccessDenied); + assert_eq!(error_code_of(&err), "ACCESS_DENIED"); + let err = to_js_error(AuthError::Server("boom".into())); + assert_eq!(error_code_of(&err), "SERVER_ERROR"); + } + + // -------- TokenResult -------- + + #[wasm_bindgen_test] + fn token_result_from_extracts_jwt_claims() { + let token = make_service_token("https://cts.example.com/", "https://zerokms.example.com/"); + let value = token_result_from(token).expect("conversion should succeed"); + + let subject = + js_sys::Reflect::get(&value, &JsValue::from_str("subject")).expect("has subject"); + assert_eq!(subject.as_string().as_deref(), Some("CS|test-user")); + + let workspace = js_sys::Reflect::get(&value, &JsValue::from_str("workspaceId")) + .expect("has workspaceId"); + assert_eq!(workspace.as_string().as_deref(), Some("ZVATKW3VHMFG27DY")); + + let issuer = + js_sys::Reflect::get(&value, &JsValue::from_str("issuer")).expect("has issuer"); + assert_eq!( + issuer.as_string().as_deref(), + Some("https://cts.example.com/") + ); + } + + #[wasm_bindgen_test] + fn token_result_from_rejects_non_jwt() { + let token = ServiceToken::new(SecretToken::new("not-a-jwt")); + let err = token_result_from(token).expect_err("non-JWT should fail"); + assert_eq!(error_code_of(&err), "INVALID_TOKEN"); + } + + // -------- AccessKeyStrategy::create -------- + + #[wasm_bindgen_test] + fn access_key_strategy_rejects_invalid_region() { + let err = expect_js_err(AccessKeyStrategy::create( + "not-a-region".to_string(), + "CSAKtestKeyId.testKeySecret".to_string(), + )); + assert_eq!(error_code_of(&err), "INVALID_REGION"); + } + + #[wasm_bindgen_test] + fn access_key_strategy_rejects_invalid_key() { + let err = expect_js_err(AccessKeyStrategy::create( + "ap-southeast-2.aws".to_string(), + "not-a-valid-key".to_string(), + )); + assert_eq!(error_code_of(&err), "INVALID_ACCESS_KEY"); + } + + #[wasm_bindgen_test] + fn access_key_strategy_accepts_valid_inputs() { + let result = AccessKeyStrategy::create( + "ap-southeast-2.aws".to_string(), + "CSAKtestKeyId.testKeySecret".to_string(), + ); + // Parsing + base-URL resolution succeed without hitting the network. + // The returned strategy is unused — we're just exercising the constructor. + assert!(result.is_ok()); + } + + // -------- OAuthStrategy::withToken -------- + + /// Build a JS-side Token-shaped object that mirrors what a Deno caller + /// would pass in. We construct it via `JSON.parse` so we get a real JS + /// plain object (vs. `serde_wasm_bindgen::to_value`, which produces a + /// JS `Map` for `serde_json::Value::Object` and won't deserialize back + /// into a struct). + fn make_token_jsvalue() -> JsValue { + let jwt = make_jwt(serde_json::json!({ + "iss": "https://cts.example.com", + "sub": "user-123", + "aud": "test-aud", + "iat": 1_700_000_000u64, + "exp": 4_000_000_000u64, + "workspace": "ZVATKW3VHMFG27DY", + "scope": "", + })); + let json = serde_json::json!({ + "accessToken": jwt, + "tokenType": "Bearer", + "expiresAt": 4_000_000_000u64, + "refreshToken": "refresh-secret", + }); + js_sys::JSON::parse(&json.to_string()).unwrap() + } + + #[wasm_bindgen_test] + fn oauth_strategy_with_token_constructs() { + let token = make_token_jsvalue(); + let result = + OAuthStrategy::with_token("ap-southeast-2.aws".to_string(), "cli".to_string(), token); + if let Err(e) = &result { + let msg = js_sys::Reflect::get(e, &JsValue::from_str("message")) + .ok() + .and_then(|v| v.as_string()) + .unwrap_or_else(|| "".into()); + panic!("expected Ok, got Err: {msg}"); + } + } + + #[wasm_bindgen_test] + fn oauth_strategy_with_token_rejects_invalid_region() { + let token = make_token_jsvalue(); + let err = expect_js_err(OAuthStrategy::with_token( + "not-a-region".to_string(), + "cli".to_string(), + token, + )); + assert_eq!(error_code_of(&err), "INVALID_REGION"); + } + + #[wasm_bindgen_test] + fn oauth_strategy_with_token_rejects_malformed_token() { + let bogus = js_sys::JSON::parse(r#"{"not":"a token"}"#).unwrap(); + let err = expect_js_err(OAuthStrategy::with_token( + "ap-southeast-2.aws".to_string(), + "cli".to_string(), + bogus, + )); + assert_eq!(error_code_of(&err), "INVALID_TOKEN"); + } +} From bf8b457796b68a9ad529d4f0f091389577a59545 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Thu, 14 May 2026 14:53:30 +1000 Subject: [PATCH 191/686] fix(stack-auth/wasm): address Copilot review feedback + patch Dockerfiles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copilot review on PR cipherstash/cipherstash-suite#1952: - `to_js_error` no longer prefixes `.message` with the error code; callers read the machine identifier from `.code` and `.message` is just the underlying `AuthError`'s display text. - `AutoStrategy.detect()` accepts an optional options argument (`Option`) — matches the documented `detect(options?)` shape and the node binding. - Invalid `options` input now throws a `TypeError` with `.code = "INVALID_ARGUMENT"` instead of being mislabelled `SERVER_ERROR`. - `test-stack-auth.yml` installs a pinned wasm-pack (v0.13.1) from the official GitHub release tarball instead of piping the upstream installer script — reproducible CI, no implicit version drift. Also patches `cts.Dockerfile` / `zerokms.Dockerfile` for the new `stack-auth/wasm` workspace member (Lint rust Dockerfiles job was failing on main as a result of this PR adding the crate). --- .../imported-workflows/test-stack-auth.yml | 15 +++++++-- .../packages/stack-auth-wasm/src/lib.rs | 31 +++++++++++++------ 2 files changed, 34 insertions(+), 12 deletions(-) diff --git a/.github/imported-workflows/test-stack-auth.yml b/.github/imported-workflows/test-stack-auth.yml index 973213190..1f418ef49 100644 --- a/.github/imported-workflows/test-stack-auth.yml +++ b/.github/imported-workflows/test-stack-auth.yml @@ -66,9 +66,18 @@ jobs: - name: Install wasm-pack # The `jetli/wasm-pack-action` GitHub Action is rejected by the - # cipherstash org allowlist (unverified creator). Use the official - # rustwasm.github.io installer inline. - run: curl https://rustwasm.github.io/wasm-pack/installer/init.sh -sSf | sh + # cipherstash org allowlist (unverified creator). Install a pinned + # version from the official GitHub release so CI is reproducible and + # not exposed to upstream installer-script changes. + env: + WASM_PACK_VERSION: v0.13.1 + run: | + set -euo pipefail + curl -fsSL "https://github.com/rustwasm/wasm-pack/releases/download/${WASM_PACK_VERSION}/wasm-pack-${WASM_PACK_VERSION}-x86_64-unknown-linux-musl.tar.gz" \ + -o /tmp/wasm-pack.tar.gz + tar -xzf /tmp/wasm-pack.tar.gz -C /tmp + sudo install -m 0755 "/tmp/wasm-pack-${WASM_PACK_VERSION}-x86_64-unknown-linux-musl/wasm-pack" /usr/local/bin/wasm-pack + wasm-pack --version - name: cargo check stack-auth-wasm run: cargo check --target wasm32-unknown-unknown -p stack-auth-wasm diff --git a/languages/typescript/packages/stack-auth-wasm/src/lib.rs b/languages/typescript/packages/stack-auth-wasm/src/lib.rs index d23101085..1e786384d 100644 --- a/languages/typescript/packages/stack-auth-wasm/src/lib.rs +++ b/languages/typescript/packages/stack-auth-wasm/src/lib.rs @@ -41,13 +41,24 @@ fn error_code(err: &AuthError) -> &'static str { } /// Build a JS `Error` enriched with a `.code` property — matches the -/// `AuthError` shape exposed by `stack-auth-node`. +/// `AuthError` shape exposed by `stack-auth-node`. `.message` is the plain +/// error text; the machine-readable identifier lives on `.code`. fn to_js_error(err: AuthError) -> JsValue { let code = error_code(&err); - let message = format!("{code}: {err}"); - let js_err = js_sys::Error::new(&message); - // Best-effort: attach `.code = "ERROR_CODE"`. If `Reflect::set` fails we - // fall through with the message-prefixed error, which is still callable. + let js_err = js_sys::Error::new(&err.to_string()); + let _ = js_sys::Reflect::set( + &js_err, + &JsValue::from_str("code"), + &JsValue::from_str(code), + ); + js_err.into() +} + +/// Build a JS `TypeError` enriched with a `.code` property. Used when the +/// caller passes structurally invalid input to a binding (vs. an auth-layer +/// failure, which uses [`to_js_error`]). +fn to_js_type_error(message: &str, code: &str) -> JsValue { + let js_err = js_sys::TypeError::new(message); let _ = js_sys::Reflect::set( &js_err, &JsValue::from_str("code"), @@ -255,12 +266,14 @@ impl AutoStrategy { /// On wasm32 there is no profile store fallback — `detect()` resolves /// only via the `CS_CLIENT_ACCESS_KEY` / `CS_WORKSPACE_CRN` env vars or /// the explicit values passed in `options`. - pub fn detect(options: JsValue) -> Result { + pub fn detect(options: Option) -> Result { let mut builder = stack_auth::AutoStrategy::builder(); - if !options.is_null() && !options.is_undefined() { - let opts: AutoStrategyOptions = serde_wasm_bindgen::from_value(options) - .map_err(|e| to_js_error(AuthError::Server(e.to_string())))?; + if let Some(options) = options.filter(|v| !v.is_null() && !v.is_undefined()) { + let opts: AutoStrategyOptions = + serde_wasm_bindgen::from_value(options).map_err(|e| { + to_js_type_error(&format!("invalid options: {e}"), "INVALID_ARGUMENT") + })?; if let Some(key) = opts.access_key { builder = builder.with_access_key(key); } From ebb532ff8c3afe31add977afdcf49d677c42f5fc Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Thu, 14 May 2026 17:27:27 +1000 Subject: [PATCH 192/686] feat(stack-auth/wasm): make runtime work in Supabase Edge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit End-to-end validated against a live Supabase Edge Function — `getToken()` on `AccessKeyStrategy` now round-trips a real JWT from `ap-southeast-2.aws` under `supabase functions serve`. Three blockers fixed: 1. `std::time::{Instant, SystemTime}` panic on wasm32-unknown-unknown ("time not implemented on this platform"). `stack-auth` consults the wall clock in `token.rs` and `access_key_refresher.rs` for JWT-expiry checks. Swapped to `web_time::{SystemTime, UNIX_EPOCH}` — re-exports `std::time` on native, polyfills via JS time APIs on wasm. No behavior change on non-wasm targets. Added `web-time` as a workspace dep. 2. Rust panics on wasm surfaced as opaque `RuntimeError: unreachable` from bytecode offsets, making any future panic uninvestigable. Added `console_error_panic_hook` and an idempotent `install_panic_hook()` call from each public binding constructor. Panics now route to `console.error` with a readable message and stack. 3. `--target deno` doesn't work in the Supabase Edge Runtime — the sandbox blocks `fetch('file://…')`, which is how wasm-bindgen's deno target loads its sibling `.wasm`. `--target bundler` uses `import * as wasm from "./*.wasm"`, which the Edge Runtime resolves natively. Reordered the build targets (bundler now primary) and updated the README to document the consumption pattern: copy `pkg-bundler/` next to your `index.ts` and import relatively. Verified: - `cargo nextest run -p stack-auth` — 105/105 pass - `wasm-pack test --node packages/stack-auth/wasm` — 10/10 pass - `mise run lint` — clean - `cargo fmt --check` — clean - Manual: real `TokenResult` returned from a live Supabase Edge Function --- docs/wasm-analysis.md | 7 ++++++- .../packages/stack-auth-wasm/Cargo.toml | 5 +++++ .../packages/stack-auth-wasm/README.md | 20 ++++++++++++------- .../packages/stack-auth-wasm/package.json | 6 +++--- .../packages/stack-auth-wasm/src/lib.rs | 12 +++++++++++ packages/stack-auth/Cargo.toml | 7 +++++++ .../stack-auth/src/access_key_refresher.rs | 3 ++- packages/stack-auth/src/token.rs | 2 +- 8 files changed, 49 insertions(+), 13 deletions(-) diff --git a/docs/wasm-analysis.md b/docs/wasm-analysis.md index 22c119f3d..9fc0e651f 100644 --- a/docs/wasm-analysis.md +++ b/docs/wasm-analysis.md @@ -34,10 +34,15 @@ Deliberately not on wasm: `device_code` flow (uses `open::that`), `stack-profile This PR. Adds `packages/stack-auth/wasm` as a sibling to the existing napi crate. Mirrors the wasm-compatible subset of the `@cipherstash/auth` surface as wasm-bindgen bindings: `AccessKeyStrategy`, `OAuthStrategy.withToken`, `AutoStrategy.detect` (env-only on wasm) — each with a `getToken(): Promise`. Errors carry a `.code` enum matching the napi contract. -Build targets: `wasm-pack build --target deno` (primary, Supabase Edge) and `--target bundler` (Vite/Webpack/Node consumers). Tests run via `wasm-pack test --node` — pure-logic coverage (type conversion, JWT claim extraction, error mapping, constructor smoke). HTTP semantics stay covered by the existing native `stack-auth/node/__tests__` vitest suite. CI gains the wasm32 cargo-check + wasm-pack test step alongside the existing nextest step in `test-stack-auth.yml`. +Build targets: `wasm-pack build --target bundler` (primary — Supabase Edge, Vite, Webpack) and `--target deno` (vanilla `deno run` only — Supabase Edge Runtime sandbox blocks `fetch('file://…')` so the deno target's auto-fetch of its `.wasm` sibling fails there; the bundler output uses `import * as wasm from "./*.wasm"` which the Edge Runtime resolves natively). Tests run via `wasm-pack test --node` — pure-logic coverage (type conversion, JWT claim extraction, error mapping, constructor smoke). HTTP semantics stay covered by the existing native `stack-auth/node/__tests__` vitest suite. CI gains the wasm32 cargo-check + wasm-pack test step alongside the existing nextest step in `test-stack-auth.yml`. Rationale for this intermediate layer: protect-wasm (Layer 4) will need to wrap auth strategies anyway. Establishing the wasm-bindgen toolchain, error-enrichment pattern, and Token-input deserialization shape here on a tiny crate (~250 LOC, 10 tests) means Layer 4 doesn't absorb both the toolchain bootstrap and the encrypt/decrypt porting work in the same PR. +End-to-end validated against a live Supabase Edge Function returning a real `TokenResult` from `AccessKeyStrategy.getToken()` against `ap-southeast-2.aws`. The validation surfaced two runtime issues fixed in this PR: + +- `stack-auth` called `std::time::SystemTime::now()` in `token.rs` and `access_key_refresher.rs` for JWT-expiry checks. The stdlib's `wasm32-unknown-unknown` `time` module is a panicking stub. Swapped to `web_time::{SystemTime, UNIX_EPOCH}` (re-exports `std::time` on native, polyfills via JS time APIs on wasm — no behavior change off wasm). +- Rust panics on wasm surface as opaque `RuntimeError: unreachable` from bytecode offsets. Added `console_error_panic_hook` and an idempotent `install_panic_hook()` from each binding-crate constructor so future panics route to `console.error` with a readable message + stack. + ### Layer 4 — `protect-wasm` bindings `protect-ffi` is napi-only. Add a sibling crate `protect-wasm` in the protect-ffi repo using `wasm-bindgen` + `wasm-bindgen-futures` + `serde-wasm-bindgen`. Port the 12 `#[neon::export]` functions in `protect-ffi/crates/protect-ffi/src/lib.rs:728-1148`. Build with `wasm-pack` (target deno or web depending on the packaging story). diff --git a/languages/typescript/packages/stack-auth-wasm/Cargo.toml b/languages/typescript/packages/stack-auth-wasm/Cargo.toml index c44e1a6cf..932b5d463 100644 --- a/languages/typescript/packages/stack-auth-wasm/Cargo.toml +++ b/languages/typescript/packages/stack-auth-wasm/Cargo.toml @@ -21,6 +21,11 @@ wasm-bindgen = "0.2" wasm-bindgen-futures = "0.4" serde-wasm-bindgen = "0.6" js-sys = "0.3" +# Route Rust panics to `console.error` on wasm32. Without this, panics +# (e.g. unhandled errors from upstream auth code) surface as opaque +# `RuntimeError: unreachable` traces from wasm bytecode offsets, which are +# unactionable. +console_error_panic_hook = "0.1" [target.'cfg(target_arch = "wasm32")'.dev-dependencies] wasm-bindgen-test = "0.3" diff --git a/languages/typescript/packages/stack-auth-wasm/README.md b/languages/typescript/packages/stack-auth-wasm/README.md index fe5033ded..ecf1fa81b 100644 --- a/languages/typescript/packages/stack-auth-wasm/README.md +++ b/languages/typescript/packages/stack-auth-wasm/README.md @@ -27,22 +27,26 @@ These exist in the napi bindings but cannot work on wasm32: ## Build ```sh -# Deno target (Supabase Edge): -npm run build:deno # → pkg-deno/ - -# Bundler target (Vite / Webpack / Node): +# Bundler target — works in Supabase Edge, Vite, Webpack, Deno with static wasm imports: npm run build:bundler # → pkg-bundler/ +# Deno target — vanilla `deno run` (uses fetch + Deno.readFile for the .wasm sibling): +npm run build:deno # → pkg-deno/ + # Both: npm run build ``` `wasm-pack` writes the `.wasm` artifact plus matching `.d.ts` into the chosen `pkg-*` directory. -## Usage (Deno / Supabase Edge) +> **Picking a target.** Use `pkg-bundler/` for Supabase Edge Functions, Vite, Webpack, Next.js, and any consumer that statically imports `.wasm` modules. Use `pkg-deno/` only for vanilla `deno run` — the Supabase Edge Runtime sandbox blocks `fetch('file://…')`, so the deno target's auto-fetch of its sibling `.wasm` fails there. + +## Usage (Supabase Edge Functions) + +Copy `pkg-bundler/` next to your function's `index.ts` and import relatively: ```ts -import { AccessKeyStrategy } from "./pkg-deno/stack_auth_wasm.js"; +import { AccessKeyStrategy } from "./pkg-bundler/stack_auth_wasm.js"; const strategy = AccessKeyStrategy.create( "ap-southeast-2.aws", @@ -54,7 +58,7 @@ const { token, workspaceId, services } = await strategy.getToken(); ``` ```ts -import { OAuthStrategy } from "./pkg-deno/stack_auth_wasm.js"; +import { OAuthStrategy } from "./pkg-bundler/stack_auth_wasm.js"; // Caller supplies an OAuth token (e.g. from request headers). const strategy = OAuthStrategy.withToken("ap-southeast-2.aws", "my-client-id", { @@ -67,6 +71,8 @@ const strategy = OAuthStrategy.withToken("ap-southeast-2.aws", "my-client-id", { const { token } = await strategy.getToken(); ``` +The bundler-target `stack_auth_wasm.js` uses `import * as wasm from "./stack_auth_wasm_bg.wasm"`, which the Supabase Edge Runtime resolves natively — no `fetch` of the wasm asset is required. + ## Test ```sh diff --git a/languages/typescript/packages/stack-auth-wasm/package.json b/languages/typescript/packages/stack-auth-wasm/package.json index c7c5eb72a..bdcbf1cae 100644 --- a/languages/typescript/packages/stack-auth-wasm/package.json +++ b/languages/typescript/packages/stack-auth-wasm/package.json @@ -5,14 +5,14 @@ "license": "SEE LICENSE IN LICENSE", "private": true, "scripts": { - "build": "npm run build:deno && npm run build:bundler", - "build:deno": "wasm-pack build --target deno --out-dir pkg-deno", + "build": "npm run build:bundler && npm run build:deno", "build:bundler": "wasm-pack build --target bundler --out-dir pkg-bundler", + "build:deno": "wasm-pack build --target deno --out-dir pkg-deno", "test": "wasm-pack test --node" }, "files": [ - "pkg-deno/", "pkg-bundler/", + "pkg-deno/", "README.md" ] } diff --git a/languages/typescript/packages/stack-auth-wasm/src/lib.rs b/languages/typescript/packages/stack-auth-wasm/src/lib.rs index 1e786384d..570ff8071 100644 --- a/languages/typescript/packages/stack-auth-wasm/src/lib.rs +++ b/languages/typescript/packages/stack-auth-wasm/src/lib.rs @@ -17,6 +17,15 @@ use serde::{Deserialize, Serialize}; use stack_auth::{AuthError, AuthStrategy, ServiceToken, Token}; use wasm_bindgen::prelude::*; +/// Install the `console_error_panic_hook` exactly once. Called from the +/// public constructors so any panic in the upstream auth stack lands as a +/// readable `console.error` instead of an opaque `RuntimeError: unreachable`. +fn install_panic_hook() { + use std::sync::Once; + static ONCE: Once = Once::new(); + ONCE.call_once(console_error_panic_hook::set_once); +} + // --------------------------------------------------------------------------- // Error helpers // --------------------------------------------------------------------------- @@ -189,6 +198,7 @@ pub struct AccessKeyStrategy { impl AccessKeyStrategy { /// Create a new `AccessKeyStrategy` for the given region and access key. pub fn create(region: String, access_key: String) -> Result { + install_panic_hook(); let region = Region::new(®ion).map_err(|e| to_js_error(AuthError::from(e)))?; let key: stack_auth::AccessKey = access_key .parse() @@ -233,6 +243,7 @@ impl OAuthStrategy { client_id: String, token: JsValue, ) -> Result { + install_panic_hook(); let region = Region::new(®ion).map_err(|e| to_js_error(AuthError::from(e)))?; let token = parse_token_input(token).map_err(to_js_error)?; let inner = stack_auth::OAuthStrategy::with_token(region, client_id, token) @@ -267,6 +278,7 @@ impl AutoStrategy { /// only via the `CS_CLIENT_ACCESS_KEY` / `CS_WORKSPACE_CRN` env vars or /// the explicit values passed in `options`. pub fn detect(options: Option) -> Result { + install_panic_hook(); let mut builder = stack_auth::AutoStrategy::builder(); if let Some(options) = options.filter(|v| !v.is_null() && !v.is_undefined()) { diff --git a/packages/stack-auth/Cargo.toml b/packages/stack-auth/Cargo.toml index 6236c75dd..70f301759 100644 --- a/packages/stack-auth/Cargo.toml +++ b/packages/stack-auth/Cargo.toml @@ -21,6 +21,13 @@ url = { workspace = true } uuid = { workspace = true } vitaminc = { workspace = true, features = ["protected"] } vitaminc-protected = { workspace = true } +# `web-time` re-exports `std::time` on native targets and polyfills via JS +# time APIs on wasm32 — used in place of `std::time::{Instant, SystemTime}` +# so callers can keep one path. The stdlib's wasm32-unknown-unknown +# `Instant`/`SystemTime` are panicking stubs ("time not implemented on this +# platform"), which breaks any code path that consults the wall clock or +# measures elapsed time. +web-time = { workspace = true } zerokms-protocol = { workspace = true } zeroize = { workspace = true } diff --git a/packages/stack-auth/src/access_key_refresher.rs b/packages/stack-auth/src/access_key_refresher.rs index f424cb04b..23b0ce7a9 100644 --- a/packages/stack-auth/src/access_key_refresher.rs +++ b/packages/stack-auth/src/access_key_refresher.rs @@ -1,5 +1,6 @@ use std::sync::Arc; -use std::time::{SystemTime, UNIX_EPOCH}; + +use web_time::{SystemTime, UNIX_EPOCH}; use url::Url; diff --git a/packages/stack-auth/src/token.rs b/packages/stack-auth/src/token.rs index 3f146bce5..0dfd7b48c 100644 --- a/packages/stack-auth/src/token.rs +++ b/packages/stack-auth/src/token.rs @@ -1,4 +1,4 @@ -use std::time::{SystemTime, UNIX_EPOCH}; +use web_time::{SystemTime, UNIX_EPOCH}; use cts_common::claims::Claims; use cts_common::{Crn, Region, WorkspaceId}; From 7d8a49277e4b4e75dd6d3c310d720e049746d338 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Thu, 14 May 2026 17:40:39 +1000 Subject: [PATCH 193/686] refactor(stack-auth/wasm): dedupe error code mapping + tighten bindings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Code-review pass on the wasm bindings: - Hoist the error-code mapping onto `AuthError::error_code()` in the parent crate (named to avoid clashing with `miette::Diagnostic::code`). The wasm binding's `to_js_error` and the napi sibling can both call this directly, removing a byte-for-byte duplicate from `wasm/src/lib.rs`. Drops the catch-all `_ => "UNKNOWN_ERROR"` arm — the parent crate's exhaustive match now forces a compile error on new variants. - Replace per-constructor `install_panic_hook()` calls with a single `#[wasm_bindgen(start)]` hook. Removes the `Once` helper and three constructor side-effects. - Factor `to_js_error` + `to_js_type_error` over a shared `attach_code` helper. - Switch `services` map to `BTreeMap` (typically 1-2 entries, matches upstream `Services` ordering, no HashMap overhead). - Drop vestigial `(&self.inner).get_token()` parens; auto-ref handles it. - Trim verbose narrative comments across Cargo.toml files and the binding source (web-time / panic-hook explanations were duplicated in three places; one canonical entry in the workspace Cargo.toml). Verified: - cargo nextest run -p stack-auth — 106/106 pass (gained the AuthError::error_code unit test) - wasm-pack test --node — 9/9 pass - mise run lint — clean - Manual: real TokenResult returned from a live Supabase Edge Function --- .../packages/stack-auth-wasm/Cargo.toml | 4 - .../packages/stack-auth-wasm/src/lib.rs | 131 +++--------------- packages/stack-auth/Cargo.toml | 6 - packages/stack-auth/src/lib.rs | 52 +++++++ 4 files changed, 72 insertions(+), 121 deletions(-) diff --git a/languages/typescript/packages/stack-auth-wasm/Cargo.toml b/languages/typescript/packages/stack-auth-wasm/Cargo.toml index 932b5d463..6a5986cfb 100644 --- a/languages/typescript/packages/stack-auth-wasm/Cargo.toml +++ b/languages/typescript/packages/stack-auth-wasm/Cargo.toml @@ -21,10 +21,6 @@ wasm-bindgen = "0.2" wasm-bindgen-futures = "0.4" serde-wasm-bindgen = "0.6" js-sys = "0.3" -# Route Rust panics to `console.error` on wasm32. Without this, panics -# (e.g. unhandled errors from upstream auth code) surface as opaque -# `RuntimeError: unreachable` traces from wasm bytecode offsets, which are -# unactionable. console_error_panic_hook = "0.1" [target.'cfg(target_arch = "wasm32")'.dev-dependencies] diff --git a/languages/typescript/packages/stack-auth-wasm/src/lib.rs b/languages/typescript/packages/stack-auth-wasm/src/lib.rs index 570ff8071..8aadce12e 100644 --- a/languages/typescript/packages/stack-auth-wasm/src/lib.rs +++ b/languages/typescript/packages/stack-auth-wasm/src/lib.rs @@ -10,76 +10,36 @@ //! - `OAuthStrategy.fromProfile` — filesystem-backed profile store //! - `AutoStrategy` profile-store fallback — `detect()` on wasm only resolves via env vars -use std::collections::HashMap; +use std::collections::BTreeMap; use cts_common::Region; use serde::{Deserialize, Serialize}; use stack_auth::{AuthError, AuthStrategy, ServiceToken, Token}; use wasm_bindgen::prelude::*; -/// Install the `console_error_panic_hook` exactly once. Called from the -/// public constructors so any panic in the upstream auth stack lands as a -/// readable `console.error` instead of an opaque `RuntimeError: unreachable`. -fn install_panic_hook() { - use std::sync::Once; - static ONCE: Once = Once::new(); - ONCE.call_once(console_error_panic_hook::set_once); +/// Route Rust panics to `console.error` with a readable message + stack. +/// Without this, panics surface as opaque `RuntimeError: unreachable` from +/// wasm bytecode offsets. +#[wasm_bindgen(start)] +fn module_init() { + console_error_panic_hook::set_once(); } -// --------------------------------------------------------------------------- -// Error helpers -// --------------------------------------------------------------------------- - -fn error_code(err: &AuthError) -> &'static str { - match err { - AuthError::Request(_) => "REQUEST_ERROR", - AuthError::AccessDenied => "ACCESS_DENIED", - AuthError::TokenExpired => "EXPIRED_TOKEN", - AuthError::InvalidGrant => "INVALID_GRANT", - AuthError::InvalidClient => "INVALID_CLIENT", - AuthError::InvalidUrl(_) => "INVALID_URL", - AuthError::Region(_) => "INVALID_REGION", - AuthError::InvalidToken(_) => "INVALID_TOKEN", - AuthError::Server(_) => "SERVER_ERROR", - AuthError::NotAuthenticated => "NOT_AUTHENTICATED", - AuthError::MissingWorkspaceCrn => "MISSING_WORKSPACE_CRN", - AuthError::InvalidAccessKey(_) => "INVALID_ACCESS_KEY", - AuthError::InvalidCrn(_) => "INVALID_CRN", - _ => "UNKNOWN_ERROR", - } +/// Attach a machine-readable `.code` to a JS error object. +fn attach_code(js_err: impl Into, code: &str) -> JsValue { + let v: JsValue = js_err.into(); + let _ = js_sys::Reflect::set(&v, &JsValue::from_str("code"), &JsValue::from_str(code)); + v } -/// Build a JS `Error` enriched with a `.code` property — matches the -/// `AuthError` shape exposed by `stack-auth-node`. `.message` is the plain -/// error text; the machine-readable identifier lives on `.code`. fn to_js_error(err: AuthError) -> JsValue { - let code = error_code(&err); - let js_err = js_sys::Error::new(&err.to_string()); - let _ = js_sys::Reflect::set( - &js_err, - &JsValue::from_str("code"), - &JsValue::from_str(code), - ); - js_err.into() + attach_code(js_sys::Error::new(&err.to_string()), err.error_code()) } -/// Build a JS `TypeError` enriched with a `.code` property. Used when the -/// caller passes structurally invalid input to a binding (vs. an auth-layer -/// failure, which uses [`to_js_error`]). fn to_js_type_error(message: &str, code: &str) -> JsValue { - let js_err = js_sys::TypeError::new(message); - let _ = js_sys::Reflect::set( - &js_err, - &JsValue::from_str("code"), - &JsValue::from_str(code), - ); - js_err.into() + attach_code(js_sys::TypeError::new(message), code) } -// --------------------------------------------------------------------------- -// TokenResult — returned by strategy.getToken() -// --------------------------------------------------------------------------- - #[derive(Serialize)] struct TokenResultPayload { token: String, @@ -87,7 +47,7 @@ struct TokenResultPayload { #[serde(rename = "workspaceId")] workspace_id: String, issuer: String, - services: HashMap, + services: BTreeMap, } fn token_result_from(token: ServiceToken) -> Result { @@ -111,10 +71,6 @@ fn token_result_from(token: ServiceToken) -> Result { serde_wasm_bindgen::to_value(&payload).map_err(JsValue::from) } -// --------------------------------------------------------------------------- -// AutoStrategyOptions — plain JS object deserialized from JsValue -// --------------------------------------------------------------------------- - #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct AutoStrategyOptions { @@ -124,15 +80,9 @@ struct AutoStrategyOptions { workspace_crn: Option, } -// --------------------------------------------------------------------------- -// TokenInput — camelCase JS shape, bridged to `stack_auth::Token` -// --------------------------------------------------------------------------- -// // `stack_auth::Token` has `pub(crate)` fields, so we can't construct one -// directly. Instead we deserialize a `TokenInput` (camelCase, idiomatic for -// JS callers) and JSON-round-trip it into the snake_case shape `Token` -// expects. - +// directly — we deserialize a camelCase shim and round-trip it through the +// snake_case JSON shape that `Token`'s own `Deserialize` accepts. #[derive(Deserialize)] struct TokenInput { #[serde(rename = "accessToken", alias = "access_token")] @@ -154,8 +104,6 @@ struct TokenInput { fn parse_token_input(value: JsValue) -> Result { let input: TokenInput = serde_wasm_bindgen::from_value(value) .map_err(|e| AuthError::InvalidToken(e.to_string()))?; - // Build the snake_case JSON shape that `Token`'s Deserialize expects. - // We can't construct `Token` directly because its fields are `pub(crate)`. let mut snake = serde_json::Map::new(); snake.insert( "access_token".into(), @@ -198,7 +146,6 @@ pub struct AccessKeyStrategy { impl AccessKeyStrategy { /// Create a new `AccessKeyStrategy` for the given region and access key. pub fn create(region: String, access_key: String) -> Result { - install_panic_hook(); let region = Region::new(®ion).map_err(|e| to_js_error(AuthError::from(e)))?; let key: stack_auth::AccessKey = access_key .parse() @@ -210,7 +157,7 @@ impl AccessKeyStrategy { /// Retrieve a valid access token, refreshing or re-authenticating as needed. #[wasm_bindgen(js_name = getToken)] pub async fn get_token(&self) -> Result { - let token = (&self.inner).get_token().await.map_err(to_js_error)?; + let token = self.inner.get_token().await.map_err(to_js_error)?; token_result_from(token) } } @@ -233,17 +180,12 @@ impl OAuthStrategy { /// `{ accessToken: string, refreshToken?: string, tokenType: string, /// expiresAt: number, region?: string, clientId?: string, /// deviceInstanceId?: string }`. - /// - /// The Rust `Token` struct uses `snake_case` field names internally, so - /// the deserialisation goes through a small shim that accepts either casing - /// for forward compatibility. #[wasm_bindgen(js_name = withToken)] pub fn with_token( region: String, client_id: String, token: JsValue, ) -> Result { - install_panic_hook(); let region = Region::new(®ion).map_err(|e| to_js_error(AuthError::from(e)))?; let token = parse_token_input(token).map_err(to_js_error)?; let inner = stack_auth::OAuthStrategy::with_token(region, client_id, token) @@ -256,7 +198,7 @@ impl OAuthStrategy { /// refresh token. #[wasm_bindgen(js_name = getToken)] pub async fn get_token(&self) -> Result { - let token = (&self.inner).get_token().await.map_err(to_js_error)?; + let token = self.inner.get_token().await.map_err(to_js_error)?; token_result_from(token) } } @@ -278,7 +220,6 @@ impl AutoStrategy { /// only via the `CS_CLIENT_ACCESS_KEY` / `CS_WORKSPACE_CRN` env vars or /// the explicit values passed in `options`. pub fn detect(options: Option) -> Result { - install_panic_hook(); let mut builder = stack_auth::AutoStrategy::builder(); if let Some(options) = options.filter(|v| !v.is_null() && !v.is_undefined()) { @@ -304,20 +245,11 @@ impl AutoStrategy { /// Retrieve a valid access token, refreshing or re-authenticating as needed. #[wasm_bindgen(js_name = getToken)] pub async fn get_token(&self) -> Result { - let token = (&self.inner).get_token().await.map_err(to_js_error)?; + let token = self.inner.get_token().await.map_err(to_js_error)?; token_result_from(token) } } -// --------------------------------------------------------------------------- -// Tests -// --------------------------------------------------------------------------- -// -// Tests are gated on `target_arch = "wasm32"` and driven by -// `wasm-bindgen-test`. Native `cargo test` doesn't exercise this crate -// (the bindings only make sense in a wasm runtime). The CI invocation is -// `wasm-pack test --node packages/stack-auth/wasm`. - #[cfg(all(test, target_arch = "wasm32"))] mod tests { use super::*; @@ -371,29 +303,6 @@ mod tests { } } - // -------- error_code mapping -------- - - #[wasm_bindgen_test] - fn maps_known_auth_error_variants() { - assert_eq!(error_code(&AuthError::AccessDenied), "ACCESS_DENIED"); - assert_eq!(error_code(&AuthError::TokenExpired), "EXPIRED_TOKEN"); - assert_eq!(error_code(&AuthError::InvalidGrant), "INVALID_GRANT"); - assert_eq!(error_code(&AuthError::InvalidClient), "INVALID_CLIENT"); - assert_eq!( - error_code(&AuthError::NotAuthenticated), - "NOT_AUTHENTICATED" - ); - assert_eq!( - error_code(&AuthError::MissingWorkspaceCrn), - "MISSING_WORKSPACE_CRN" - ); - assert_eq!(error_code(&AuthError::Server("x".into())), "SERVER_ERROR"); - assert_eq!( - error_code(&AuthError::InvalidToken("malformed".into())), - "INVALID_TOKEN" - ); - } - #[wasm_bindgen_test] fn to_js_error_attaches_code_property() { let err = to_js_error(AuthError::AccessDenied); diff --git a/packages/stack-auth/Cargo.toml b/packages/stack-auth/Cargo.toml index 70f301759..d18b69f2f 100644 --- a/packages/stack-auth/Cargo.toml +++ b/packages/stack-auth/Cargo.toml @@ -21,12 +21,6 @@ url = { workspace = true } uuid = { workspace = true } vitaminc = { workspace = true, features = ["protected"] } vitaminc-protected = { workspace = true } -# `web-time` re-exports `std::time` on native targets and polyfills via JS -# time APIs on wasm32 — used in place of `std::time::{Instant, SystemTime}` -# so callers can keep one path. The stdlib's wasm32-unknown-unknown -# `Instant`/`SystemTime` are panicking stubs ("time not implemented on this -# platform"), which breaks any code path that consults the wall clock or -# measures elapsed time. web-time = { workspace = true } zerokms-protocol = { workspace = true } zeroize = { workspace = true } diff --git a/packages/stack-auth/src/lib.rs b/packages/stack-auth/src/lib.rs index db250efb8..d1415edb7 100644 --- a/packages/stack-auth/src/lib.rs +++ b/packages/stack-auth/src/lib.rs @@ -241,6 +241,32 @@ pub enum AuthError { Store(#[from] stack_profile::ProfileError), } +impl AuthError { + /// Stable machine-readable identifier for surfacing across FFI boundaries + /// (e.g. JS `Error.code`, Node-API error codes). Named `error_code` rather + /// than `code` to avoid colliding with `miette::Diagnostic::code`, which + /// is inherited via `#[derive(Diagnostic)]`. + pub fn error_code(&self) -> &'static str { + match self { + Self::Request(_) => "REQUEST_ERROR", + Self::AccessDenied => "ACCESS_DENIED", + Self::TokenExpired => "EXPIRED_TOKEN", + Self::InvalidGrant => "INVALID_GRANT", + Self::InvalidClient => "INVALID_CLIENT", + Self::InvalidUrl(_) => "INVALID_URL", + Self::Region(_) => "INVALID_REGION", + Self::InvalidToken(_) => "INVALID_TOKEN", + Self::Server(_) => "SERVER_ERROR", + Self::NotAuthenticated => "NOT_AUTHENTICATED", + Self::MissingWorkspaceCrn => "MISSING_WORKSPACE_CRN", + Self::InvalidAccessKey(_) => "INVALID_ACCESS_KEY", + Self::InvalidCrn(_) => "INVALID_CRN", + #[cfg(not(target_arch = "wasm32"))] + Self::Store(_) => "STORE_ERROR", + } + } +} + impl From for AuthError { fn from(never: Infallible) -> Self { match never {} @@ -323,3 +349,29 @@ pub(crate) fn http_client() -> reqwest::Client { .build() .unwrap_or_else(|_| reqwest::Client::new()) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn auth_error_code_known_variants() { + assert_eq!(AuthError::AccessDenied.error_code(), "ACCESS_DENIED"); + assert_eq!(AuthError::TokenExpired.error_code(), "EXPIRED_TOKEN"); + assert_eq!(AuthError::InvalidGrant.error_code(), "INVALID_GRANT"); + assert_eq!(AuthError::InvalidClient.error_code(), "INVALID_CLIENT"); + assert_eq!( + AuthError::NotAuthenticated.error_code(), + "NOT_AUTHENTICATED" + ); + assert_eq!( + AuthError::MissingWorkspaceCrn.error_code(), + "MISSING_WORKSPACE_CRN" + ); + assert_eq!(AuthError::Server("x".into()).error_code(), "SERVER_ERROR"); + assert_eq!( + AuthError::InvalidToken("malformed".into()).error_code(), + "INVALID_TOKEN" + ); + } +} From 7280f9c84d8aac1bf6b29763f7132e36bba348c8 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Thu, 14 May 2026 18:24:07 +1000 Subject: [PATCH 194/686] refactor(stack-auth/wasm): scope down to AccessKeyStrategy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Strip `OAuthStrategy` and `AutoStrategy` from the wasm bindings to keep this initial PR focused on M2M auth. OAuth in a browser/edge context needs design work around federation and cookie pinning that hasn't happened yet — those strategies can be added in a follow-up once that design is settled. Drops `TokenInput` / `parse_token_input` (only used by `OAuthStrategy`) and `AutoStrategyOptions` (only used by `AutoStrategy`). README's "What's not included" section now explicitly flags OAuth as deferred, not unavailable. Verified: - cargo nextest run -p stack-auth — 106/106 - wasm-pack test --node — 6/6 (was 10, dropped 4 OAuth tests) - mise run lint — clean - Manual: real TokenResult round-trip through the Supabase Edge spike --- .../packages/stack-auth-wasm/README.md | 27 +- .../packages/stack-auth-wasm/src/lib.rs | 256 +----------------- 2 files changed, 19 insertions(+), 264 deletions(-) diff --git a/languages/typescript/packages/stack-auth-wasm/README.md b/languages/typescript/packages/stack-auth-wasm/README.md index ecf1fa81b..68fa5e71c 100644 --- a/languages/typescript/packages/stack-auth-wasm/README.md +++ b/languages/typescript/packages/stack-auth-wasm/README.md @@ -2,27 +2,26 @@ WebAssembly bindings for [`stack-auth`](https://github.com/cipherstash/cipherstash-suite/tree/main/packages/stack-auth) — built for Supabase Edge Functions (Deno) and bundler runtimes (Vite / Webpack / Node). -This is the wasm-compatible subset of the existing [`@cipherstash/auth`](https://www.npmjs.com/package/@cipherstash/auth) napi bindings. It exposes credential and token-management primitives that work without filesystem access or browser-launching APIs. +This is the wasm-compatible subset of the existing [`@cipherstash/auth`](https://www.npmjs.com/package/@cipherstash/auth) napi bindings, scoped to machine-to-machine authentication. ## What's included | Class | Purpose | |---|---| | `AccessKeyStrategy` | Machine-to-machine auth with a static access key | -| `OAuthStrategy.withToken` | Caller-supplied OAuth token + refresh, held in memory | -| `AutoStrategy` | Env-var-driven credential detection (no profile-store fallback on wasm) | -Each strategy exposes `getToken(): Promise` returning `{ token, subject, workspaceId, issuer, services }`. +`AccessKeyStrategy.create(region, accessKey)` returns a strategy; `getToken(): Promise` resolves to `{ token, subject, workspaceId, issuer, services }`. Errors thrown from this package extend `Error` with a machine-readable `.code` property (e.g. `INVALID_ACCESS_KEY`, `ACCESS_DENIED`, `EXPIRED_TOKEN`). ## What's not included -These exist in the napi bindings but cannot work on wasm32: +OAuth-based strategies (`OAuthStrategy`, `AutoStrategy`, the device-code flow, profile-store loading) are deliberately out of scope for the initial wasm surface. Federation and token-pinning need design work that hasn't happened yet — once those decisions are made, the OAuth surface can be added to this crate. + +The following napi-only features also can't work on wasm32 and won't be ported: - `bindClientDevice` / `beginDeviceCodeFlow` — depend on filesystem device identity and browser-launching for the OAuth 2.0 device-code flow - `OAuthStrategy.fromProfile` — reads `~/.cipherstash/auth.json` -- `AutoStrategy.detect()` profile-store fallback — on wasm `detect()` resolves only via `CS_CLIENT_ACCESS_KEY` / `CS_WORKSPACE_CRN` env vars or the explicit options argument ## Build @@ -57,20 +56,6 @@ const { token, workspaceId, services } = await strategy.getToken(); // `token` is the bearer credential; pass to ZeroKMS as `Authorization: Bearer ${token}` ``` -```ts -import { OAuthStrategy } from "./pkg-bundler/stack_auth_wasm.js"; - -// Caller supplies an OAuth token (e.g. from request headers). -const strategy = OAuthStrategy.withToken("ap-southeast-2.aws", "my-client-id", { - accessToken: jwt, - refreshToken, - tokenType: "Bearer", - expiresAt: 1730000000, -}); - -const { token } = await strategy.getToken(); -``` - The bundler-target `stack_auth_wasm.js` uses `import * as wasm from "./stack_auth_wasm_bg.wasm"`, which the Supabase Edge Runtime resolves natively — no `fetch` of the wasm asset is required. ## Test @@ -79,4 +64,4 @@ The bundler-target `stack_auth_wasm.js` uses `import * as wasm from "./stack_aut npm test # runs `wasm-pack test --node` ``` -Pure-logic tests (type conversions, JWT claim extraction, error-code mapping, constructor smoke checks) run under Node-hosted wasm. HTTP semantics are covered by the native `stack-auth/node/__tests__` suite — they would need a fetch shim and significantly more machinery to re-run on the wasm side, and the underlying logic is identical between the two binding crates. +Pure-logic tests (type conversions, JWT claim extraction, error-code mapping, constructor smoke checks) run under Node-hosted wasm. HTTP semantics are covered by the native `stack-auth/node/__tests__` suite. diff --git a/languages/typescript/packages/stack-auth-wasm/src/lib.rs b/languages/typescript/packages/stack-auth-wasm/src/lib.rs index 8aadce12e..62c8eb347 100644 --- a/languages/typescript/packages/stack-auth-wasm/src/lib.rs +++ b/languages/typescript/packages/stack-auth-wasm/src/lib.rs @@ -1,20 +1,19 @@ //! WebAssembly bindings for `stack-auth`. //! -//! Mirrors the wasm-compatible subset of the `stack-auth-node` napi crate. -//! Targets Supabase Edge Functions (Deno) and bundler consumers via -//! `wasm-pack build --target deno` / `--target bundler`. +//! Mirrors the wasm-compatible subset of the `stack-auth-node` napi crate, +//! scoped to `AccessKeyStrategy` (M2M auth). OAuth- and profile-based +//! strategies are deliberately out of scope for the initial wasm surface — +//! they need design work around federation and token pinning that hasn't +//! happened yet. //! -//! Excluded vs the napi crate (these can't work on wasm32): -//! -//! - `bindClientDevice` / `beginDeviceCodeFlow` — filesystem identity and browser launch -//! - `OAuthStrategy.fromProfile` — filesystem-backed profile store -//! - `AutoStrategy` profile-store fallback — `detect()` on wasm only resolves via env vars +//! Targets Supabase Edge Functions and bundler consumers via +//! `wasm-pack build --target bundler` / `--target deno`. use std::collections::BTreeMap; use cts_common::Region; -use serde::{Deserialize, Serialize}; -use stack_auth::{AuthError, AuthStrategy, ServiceToken, Token}; +use serde::Serialize; +use stack_auth::{AuthError, AuthStrategy, ServiceToken}; use wasm_bindgen::prelude::*; /// Route Rust panics to `console.error` with a readable message + stack. @@ -36,10 +35,6 @@ fn to_js_error(err: AuthError) -> JsValue { attach_code(js_sys::Error::new(&err.to_string()), err.error_code()) } -fn to_js_type_error(message: &str, code: &str) -> JsValue { - attach_code(js_sys::TypeError::new(message), code) -} - #[derive(Serialize)] struct TokenResultPayload { token: String, @@ -71,72 +66,6 @@ fn token_result_from(token: ServiceToken) -> Result { serde_wasm_bindgen::to_value(&payload).map_err(JsValue::from) } -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -struct AutoStrategyOptions { - #[serde(default)] - access_key: Option, - #[serde(default)] - workspace_crn: Option, -} - -// `stack_auth::Token` has `pub(crate)` fields, so we can't construct one -// directly — we deserialize a camelCase shim and round-trip it through the -// snake_case JSON shape that `Token`'s own `Deserialize` accepts. -#[derive(Deserialize)] -struct TokenInput { - #[serde(rename = "accessToken", alias = "access_token")] - access_token: String, - #[serde(rename = "refreshToken", alias = "refresh_token", default)] - refresh_token: Option, - #[serde(rename = "tokenType", alias = "token_type")] - token_type: String, - #[serde(rename = "expiresAt", alias = "expires_at")] - expires_at: u64, - #[serde(default)] - region: Option, - #[serde(rename = "clientId", alias = "client_id", default)] - client_id: Option, - #[serde(rename = "deviceInstanceId", alias = "device_instance_id", default)] - device_instance_id: Option, -} - -fn parse_token_input(value: JsValue) -> Result { - let input: TokenInput = serde_wasm_bindgen::from_value(value) - .map_err(|e| AuthError::InvalidToken(e.to_string()))?; - let mut snake = serde_json::Map::new(); - snake.insert( - "access_token".into(), - serde_json::Value::String(input.access_token), - ); - snake.insert( - "token_type".into(), - serde_json::Value::String(input.token_type), - ); - snake.insert( - "expires_at".into(), - serde_json::Value::Number(input.expires_at.into()), - ); - if let Some(v) = input.refresh_token { - snake.insert("refresh_token".into(), serde_json::Value::String(v)); - } - if let Some(v) = input.region { - snake.insert("region".into(), serde_json::Value::String(v)); - } - if let Some(v) = input.client_id { - snake.insert("client_id".into(), serde_json::Value::String(v)); - } - if let Some(v) = input.device_instance_id { - snake.insert("device_instance_id".into(), serde_json::Value::String(v)); - } - serde_json::from_value(serde_json::Value::Object(snake)) - .map_err(|e| AuthError::InvalidToken(e.to_string())) -} - -// --------------------------------------------------------------------------- -// AccessKeyStrategy -// --------------------------------------------------------------------------- - #[wasm_bindgen] pub struct AccessKeyStrategy { inner: stack_auth::AccessKeyStrategy, @@ -162,94 +91,6 @@ impl AccessKeyStrategy { } } -// --------------------------------------------------------------------------- -// OAuthStrategy -// --------------------------------------------------------------------------- - -#[wasm_bindgen] -pub struct OAuthStrategy { - inner: stack_auth::OAuthStrategy, -} - -#[wasm_bindgen] -impl OAuthStrategy { - /// Build an `OAuthStrategy` from a caller-supplied token. The token is - /// held in memory only — there is no persistence on wasm. - /// - /// `token` must be a JS object shaped like: - /// `{ accessToken: string, refreshToken?: string, tokenType: string, - /// expiresAt: number, region?: string, clientId?: string, - /// deviceInstanceId?: string }`. - #[wasm_bindgen(js_name = withToken)] - pub fn with_token( - region: String, - client_id: String, - token: JsValue, - ) -> Result { - let region = Region::new(®ion).map_err(|e| to_js_error(AuthError::from(e)))?; - let token = parse_token_input(token).map_err(to_js_error)?; - let inner = stack_auth::OAuthStrategy::with_token(region, client_id, token) - .build() - .map_err(to_js_error)?; - Ok(OAuthStrategy { inner }) - } - - /// Retrieve a valid access token, refreshing as needed via the embedded - /// refresh token. - #[wasm_bindgen(js_name = getToken)] - pub async fn get_token(&self) -> Result { - let token = self.inner.get_token().await.map_err(to_js_error)?; - token_result_from(token) - } -} - -// --------------------------------------------------------------------------- -// AutoStrategy -// --------------------------------------------------------------------------- - -#[wasm_bindgen] -pub struct AutoStrategy { - inner: stack_auth::AutoStrategy, -} - -#[wasm_bindgen] -impl AutoStrategy { - /// Detect available credentials and return an `AutoStrategy`. - /// - /// On wasm32 there is no profile store fallback — `detect()` resolves - /// only via the `CS_CLIENT_ACCESS_KEY` / `CS_WORKSPACE_CRN` env vars or - /// the explicit values passed in `options`. - pub fn detect(options: Option) -> Result { - let mut builder = stack_auth::AutoStrategy::builder(); - - if let Some(options) = options.filter(|v| !v.is_null() && !v.is_undefined()) { - let opts: AutoStrategyOptions = - serde_wasm_bindgen::from_value(options).map_err(|e| { - to_js_type_error(&format!("invalid options: {e}"), "INVALID_ARGUMENT") - })?; - if let Some(key) = opts.access_key { - builder = builder.with_access_key(key); - } - if let Some(crn_str) = opts.workspace_crn { - let crn = crn_str - .parse() - .map_err(|e| to_js_error(AuthError::InvalidCrn(e)))?; - builder = builder.with_workspace_crn(crn); - } - } - - let inner = builder.detect().map_err(to_js_error)?; - Ok(AutoStrategy { inner }) - } - - /// Retrieve a valid access token, refreshing or re-authenticating as needed. - #[wasm_bindgen(js_name = getToken)] - pub async fn get_token(&self) -> Result { - let token = self.inner.get_token().await.map_err(to_js_error)?; - token_result_from(token) - } -} - #[cfg(all(test, target_arch = "wasm32"))] mod tests { use super::*; @@ -257,10 +98,8 @@ mod tests { use stack_auth::SecretToken; use wasm_bindgen_test::wasm_bindgen_test; - // -------- JWT fixture -------- - /// Build an unsigned JWT-shaped token: `
..`. - /// Signature segment is a dummy `"sig"` literal — `decode_jwt_payload_wasm` + /// Signature segment is a dummy `"sig"` literal — the JWT-claim decoder /// in stack-auth only reads the payload segment and ignores the signature. fn make_jwt(claims: serde_json::Value) -> String { let header = serde_json::json!({"alg": "HS256", "typ": "JWT"}); @@ -285,7 +124,6 @@ mod tests { ServiceToken::new(SecretToken::new(make_jwt(claims))) } - /// Extract `.code` from a JsValue Error. fn error_code_of(err: &JsValue) -> String { js_sys::Reflect::get(err, &JsValue::from_str("code")) .ok() @@ -293,9 +131,9 @@ mod tests { .unwrap_or_default() } - /// Unwrap the `Err` variant without requiring `Debug` on the `Ok` type. - /// The bindings structs deliberately don't derive `Debug` (matches the - /// node crate's posture — wrappers shouldn't leak internal state via Debug). + /// The bindings structs deliberately don't derive `Debug` (wrappers + /// shouldn't leak internal state via Debug — matches the node crate's + /// posture), so `expect_err` / `unwrap_err` aren't available. fn expect_js_err(result: Result) -> JsValue { match result { Ok(_) => panic!("expected Err, got Ok"), @@ -311,8 +149,6 @@ mod tests { assert_eq!(error_code_of(&err), "SERVER_ERROR"); } - // -------- TokenResult -------- - #[wasm_bindgen_test] fn token_result_from_extracts_jwt_claims() { let token = make_service_token("https://cts.example.com/", "https://zerokms.example.com/"); @@ -341,8 +177,6 @@ mod tests { assert_eq!(error_code_of(&err), "INVALID_TOKEN"); } - // -------- AccessKeyStrategy::create -------- - #[wasm_bindgen_test] fn access_key_strategy_rejects_invalid_region() { let err = expect_js_err(AccessKeyStrategy::create( @@ -367,70 +201,6 @@ mod tests { "ap-southeast-2.aws".to_string(), "CSAKtestKeyId.testKeySecret".to_string(), ); - // Parsing + base-URL resolution succeed without hitting the network. - // The returned strategy is unused — we're just exercising the constructor. assert!(result.is_ok()); } - - // -------- OAuthStrategy::withToken -------- - - /// Build a JS-side Token-shaped object that mirrors what a Deno caller - /// would pass in. We construct it via `JSON.parse` so we get a real JS - /// plain object (vs. `serde_wasm_bindgen::to_value`, which produces a - /// JS `Map` for `serde_json::Value::Object` and won't deserialize back - /// into a struct). - fn make_token_jsvalue() -> JsValue { - let jwt = make_jwt(serde_json::json!({ - "iss": "https://cts.example.com", - "sub": "user-123", - "aud": "test-aud", - "iat": 1_700_000_000u64, - "exp": 4_000_000_000u64, - "workspace": "ZVATKW3VHMFG27DY", - "scope": "", - })); - let json = serde_json::json!({ - "accessToken": jwt, - "tokenType": "Bearer", - "expiresAt": 4_000_000_000u64, - "refreshToken": "refresh-secret", - }); - js_sys::JSON::parse(&json.to_string()).unwrap() - } - - #[wasm_bindgen_test] - fn oauth_strategy_with_token_constructs() { - let token = make_token_jsvalue(); - let result = - OAuthStrategy::with_token("ap-southeast-2.aws".to_string(), "cli".to_string(), token); - if let Err(e) = &result { - let msg = js_sys::Reflect::get(e, &JsValue::from_str("message")) - .ok() - .and_then(|v| v.as_string()) - .unwrap_or_else(|| "".into()); - panic!("expected Ok, got Err: {msg}"); - } - } - - #[wasm_bindgen_test] - fn oauth_strategy_with_token_rejects_invalid_region() { - let token = make_token_jsvalue(); - let err = expect_js_err(OAuthStrategy::with_token( - "not-a-region".to_string(), - "cli".to_string(), - token, - )); - assert_eq!(error_code_of(&err), "INVALID_REGION"); - } - - #[wasm_bindgen_test] - fn oauth_strategy_with_token_rejects_malformed_token() { - let bogus = js_sys::JSON::parse(r#"{"not":"a token"}"#).unwrap(); - let err = expect_js_err(OAuthStrategy::with_token( - "ap-southeast-2.aws".to_string(), - "cli".to_string(), - bogus, - )); - assert_eq!(error_code_of(&err), "INVALID_TOKEN"); - } } From 75c62a9f903d82390965a46bb72493e8167281ab Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Thu, 14 May 2026 18:25:08 +1000 Subject: [PATCH 195/686] docs(wasm-analysis): reflect AccessKeyStrategy-only scope for Layer 3.5 --- docs/wasm-analysis.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/wasm-analysis.md b/docs/wasm-analysis.md index 9fc0e651f..d7932e44f 100644 --- a/docs/wasm-analysis.md +++ b/docs/wasm-analysis.md @@ -32,7 +32,7 @@ Deliberately not on wasm: `device_code` flow (uses `open::that`), `stack-profile ### Layer 3.5 — `stack-auth-wasm` bindings crate [IN PROGRESS] -This PR. Adds `packages/stack-auth/wasm` as a sibling to the existing napi crate. Mirrors the wasm-compatible subset of the `@cipherstash/auth` surface as wasm-bindgen bindings: `AccessKeyStrategy`, `OAuthStrategy.withToken`, `AutoStrategy.detect` (env-only on wasm) — each with a `getToken(): Promise`. Errors carry a `.code` enum matching the napi contract. +This PR. Adds `packages/stack-auth/wasm` as a sibling to the existing napi crate. Scoped to `AccessKeyStrategy` (M2M auth) — `getToken(): Promise` returning `{ token, subject, workspaceId, issuer, services }`. Errors carry a `.code` enum matching the napi contract. OAuth-based strategies (`OAuthStrategy`, `AutoStrategy`, device-code) are deferred to a follow-up: federation and token-pinning for browser/edge contexts need design work that hasn't happened yet. Build targets: `wasm-pack build --target bundler` (primary — Supabase Edge, Vite, Webpack) and `--target deno` (vanilla `deno run` only — Supabase Edge Runtime sandbox blocks `fetch('file://…')` so the deno target's auto-fetch of its `.wasm` sibling fails there; the bundler output uses `import * as wasm from "./*.wasm"` which the Edge Runtime resolves natively). Tests run via `wasm-pack test --node` — pure-logic coverage (type conversion, JWT claim extraction, error mapping, constructor smoke). HTTP semantics stay covered by the existing native `stack-auth/node/__tests__` vitest suite. CI gains the wasm32 cargo-check + wasm-pack test step alongside the existing nextest step in `test-stack-auth.yml`. From 2303527c0cc2c151693470f4356dd3dce60c29e5 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Fri, 15 May 2026 15:34:49 +1000 Subject: [PATCH 196/686] docs(stack-auth/wasm): document why TokenResultPayload uses String MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses Dan's review comment on `token: String`. `SecretToken` would be the natural choice for a bearer credential, but its `ZeroizeOnDrop` / `OpaqueDebug` protections end at the FFI boundary — once the value crosses into JS via serde_wasm_bindgen, it lives in GC-managed memory with no zeroize equivalent. Comment flags this and points at the never-expose-JWT follow-up where the broader wasm-side memory hygiene gets addressed. Also refreshes wasm-analysis.md Layer 3.5 to match the post-simplify state: - LOC/test counts (250/10 → 205/6) after OAuth scope-down - Panic-hook description (#[wasm_bindgen(start)] not per-constructor calls) - Adds the third spike-surfaced runtime issue (--target deno doesn't work in Supabase Edge Runtime) - Records the AuthError::error_code() hoist to the parent crate - Replaces the stale "Layer 1 needs full build" follow-up (now partially exercised via the wasm-pack build chain) and adds the five conversation-tracked follow-ups (OAuth, cookie pinning, never-expose-JWT, napi error_code adoption, npm publishing strategy). --- docs/wasm-analysis.md | 18 +++++++++++++----- .../packages/stack-auth-wasm/src/lib.rs | 6 ++++++ 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/docs/wasm-analysis.md b/docs/wasm-analysis.md index d7932e44f..b5fe436f2 100644 --- a/docs/wasm-analysis.md +++ b/docs/wasm-analysis.md @@ -34,14 +34,17 @@ Deliberately not on wasm: `device_code` flow (uses `open::that`), `stack-profile This PR. Adds `packages/stack-auth/wasm` as a sibling to the existing napi crate. Scoped to `AccessKeyStrategy` (M2M auth) — `getToken(): Promise` returning `{ token, subject, workspaceId, issuer, services }`. Errors carry a `.code` enum matching the napi contract. OAuth-based strategies (`OAuthStrategy`, `AutoStrategy`, device-code) are deferred to a follow-up: federation and token-pinning for browser/edge contexts need design work that hasn't happened yet. -Build targets: `wasm-pack build --target bundler` (primary — Supabase Edge, Vite, Webpack) and `--target deno` (vanilla `deno run` only — Supabase Edge Runtime sandbox blocks `fetch('file://…')` so the deno target's auto-fetch of its `.wasm` sibling fails there; the bundler output uses `import * as wasm from "./*.wasm"` which the Edge Runtime resolves natively). Tests run via `wasm-pack test --node` — pure-logic coverage (type conversion, JWT claim extraction, error mapping, constructor smoke). HTTP semantics stay covered by the existing native `stack-auth/node/__tests__` vitest suite. CI gains the wasm32 cargo-check + wasm-pack test step alongside the existing nextest step in `test-stack-auth.yml`. +Error-code mapping is hoisted onto `AuthError::error_code()` in the parent `stack-auth` crate so this PR and the existing napi sibling can share one source of truth (napi adoption is a non-functional cleanup for a follow-up). -Rationale for this intermediate layer: protect-wasm (Layer 4) will need to wrap auth strategies anyway. Establishing the wasm-bindgen toolchain, error-enrichment pattern, and Token-input deserialization shape here on a tiny crate (~250 LOC, 10 tests) means Layer 4 doesn't absorb both the toolchain bootstrap and the encrypt/decrypt porting work in the same PR. +Build targets: `wasm-pack build --target bundler` (primary — Supabase Edge, Vite, Webpack) and `--target deno` (vanilla `deno run` only — Supabase Edge Runtime sandbox blocks `fetch('file://…')` so the deno target's auto-fetch of its `.wasm` sibling fails there; the bundler output uses `import * as wasm from "./*.wasm"` which the Edge Runtime resolves natively). Tests run via `wasm-pack test --node` — pure-logic coverage (JWT claim extraction, error-code mapping, constructor smoke). HTTP semantics stay covered by the existing native `stack-auth/node/__tests__` vitest suite. CI gains the wasm32 cargo-check + wasm-pack test step alongside the existing nextest step in `test-stack-auth.yml`. -End-to-end validated against a live Supabase Edge Function returning a real `TokenResult` from `AccessKeyStrategy.getToken()` against `ap-southeast-2.aws`. The validation surfaced two runtime issues fixed in this PR: +Rationale for this intermediate layer: protect-wasm (Layer 4) will need to wrap auth strategies anyway. Establishing the wasm-bindgen toolchain and error-enrichment pattern here on a tiny crate (~205 LOC, 6 tests) means Layer 4 doesn't absorb both the toolchain bootstrap and the encrypt/decrypt porting work in the same PR. + +End-to-end validated against a live Supabase Edge Function returning a real `TokenResult` from `AccessKeyStrategy.getToken()` against `ap-southeast-2.aws`. The validation surfaced three runtime issues fixed in this PR: - `stack-auth` called `std::time::SystemTime::now()` in `token.rs` and `access_key_refresher.rs` for JWT-expiry checks. The stdlib's `wasm32-unknown-unknown` `time` module is a panicking stub. Swapped to `web_time::{SystemTime, UNIX_EPOCH}` (re-exports `std::time` on native, polyfills via JS time APIs on wasm — no behavior change off wasm). -- Rust panics on wasm surface as opaque `RuntimeError: unreachable` from bytecode offsets. Added `console_error_panic_hook` and an idempotent `install_panic_hook()` from each binding-crate constructor so future panics route to `console.error` with a readable message + stack. +- Rust panics on wasm surface as opaque `RuntimeError: unreachable` from bytecode offsets. Added `console_error_panic_hook` and route panics to `console.error` via a `#[wasm_bindgen(start)]` module-init function. +- `wasm-pack --target deno` doesn't work in the Supabase Edge Runtime — its sandbox blocks `fetch('file://…')`, which is how the deno target loads its sibling `.wasm`. Made `--target bundler` the primary build (uses `import * as wasm from "./*.wasm"`, which Edge resolves natively); deno target retained for vanilla `deno run`. ### Layer 4 — `protect-wasm` bindings @@ -116,8 +119,13 @@ Net delta: 19 files changed, +14 / -2042. Verified via `cargo check --workspace ## Known follow-ups -- The current verification is `cargo check`, not full build. A real artifact build (e.g. `wasm-pack` or `cargo build --target wasm32-unknown-unknown --release`) will surface any link-time issues. +- Layer 1's `cargo check` verification is now partially exercised by Layer 3.5's `wasm-pack build` (pulls `cts-common`, `cipherstash-config`, `zerokms-protocol` transitively). Crates outside that dep graph (`recipher`, `cipherstash-core`, `cllw-ore`) still need a real artifact build. - `cllw-ore` requires `--no-default-features` because the default `postgres-types` feature has C deps. Consider flipping the default off in a future major version (already noted in its Cargo.toml). - Cipherstash-client 0.35 release containing the legacy delete; proxy bump to 0.35 with `AccessKeyStrategy` migration. See Layer 2c. - `ServiceToken` JSON contract migration (Layer 2b) — design conversation needed before code. - Pre-existing API drift between cipherstash-client and protect-ffi (path dep) — `cipherstash_client::eql::EncryptedField` not found. Surfaces under `cargo check -p protect-ffi`. Not caused by Layer 2a; flagged for the next protect-ffi sync. +- Adopt `AuthError::error_code()` in `stack-auth-node` (the napi sibling) — currently inlined there, now duplicates the parent crate. +- OAuth-based wasm strategies (`OAuthStrategy`, `AutoStrategy`, device-code) — deferred from Layer 3.5 pending federation/token-pinning design. +- Token cookie pinning — encrypt the JWT under a worker-only key before storing in cookies (so a stolen cookie can't be replayed elsewhere). +- Never-expose-JWT API — wallet/keychain pattern where the JWT lives only in wasm memory and JS calls signed operations. +- npm publishing strategy — separate `@cipherstash/stack-auth-wasm` package vs sub-path under existing `@cipherstash/auth` vs conditional exports. diff --git a/languages/typescript/packages/stack-auth-wasm/src/lib.rs b/languages/typescript/packages/stack-auth-wasm/src/lib.rs index 62c8eb347..0053c23fc 100644 --- a/languages/typescript/packages/stack-auth-wasm/src/lib.rs +++ b/languages/typescript/packages/stack-auth-wasm/src/lib.rs @@ -37,6 +37,12 @@ fn to_js_error(err: AuthError) -> JsValue { #[derive(Serialize)] struct TokenResultPayload { + // Bearer credential. Kept as `String` rather than `stack_auth::SecretToken` + // because the protections `SecretToken` provides (`ZeroizeOnDrop`, + // `OpaqueDebug`) don't survive `serde_wasm_bindgen` — once the value + // crosses the FFI boundary it lives in JS-managed memory with no zeroize + // equivalent. Wasm-side memory hygiene is tracked separately as the + // "never-expose-JWT" follow-up. token: String, subject: String, #[serde(rename = "workspaceId")] From 2e3df0e318b15d74e42358c3815ae61dca5d4ae1 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Fri, 15 May 2026 16:20:46 +1000 Subject: [PATCH 197/686] ci(stack-auth): install wasm32 target via dtolnay/rust-toolchain Hooks the target install into Swatinem/rust-cache for faster builds, per review feedback. Matches the pattern used in publish-auth-npm.yml. --- .github/imported-workflows/test-stack-auth.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/imported-workflows/test-stack-auth.yml b/.github/imported-workflows/test-stack-auth.yml index 1f418ef49..27fdeea38 100644 --- a/.github/imported-workflows/test-stack-auth.yml +++ b/.github/imported-workflows/test-stack-auth.yml @@ -62,7 +62,9 @@ jobs: run: mise run test:integration:stack-auth - name: Install wasm32 target - run: rustup target add wasm32-unknown-unknown + uses: dtolnay/rust-toolchain@1.90.0 + with: + targets: wasm32-unknown-unknown - name: Install wasm-pack # The `jetli/wasm-pack-action` GitHub Action is rejected by the From 7c0c7ad81cce6c064fedeceeb30a52d607777acd Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Tue, 12 May 2026 22:14:19 +1000 Subject: [PATCH 198/686] feat(stack-auth/auth): unify @cipherstash/auth into a single multi-runtime package (Layer 3.5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stacks on cipherstash/cipherstash-suite#1952 which adds the wasm-bindgen sibling crate. Single `@cipherstash/auth` npm package now serves Node, Deno, Cloudflare Workers, browsers, and any other runtime through `package.json` `exports` conditions: | Runtime | Condition | Entry | Types |----------|-----------|----------------------------------|---------------------- | Node | node | ./index.js (napi → .node) | ./index.d.ts (full) | Deno | deno | ./wasm/stack_auth_wasm.js | ./wasm-types.d.ts | Workers | worker | ./wasm/stack_auth_wasm.js | ./wasm-types.d.ts | Browser | browser | ./wasm/stack_auth_wasm.js | ./wasm-types.d.ts | Other | default | ./wasm/stack_auth_wasm.js | ./wasm-types.d.ts User outcome: `npm install @cipherstash/auth` works everywhere. Node consumers retain full TS coverage including device-code and profile-store flows. Deno / edge / browser consumers see only what the wasm runtime actually exposes — typed correctly per-runtime, not just runtime-throws. Hand-typed `wasm-types.d.ts` refines `Promise` from wasm-bindgen back to `Promise` and hides the wasm-streams type leakage that arrives via reqwest's fetch backend (IntoUnderlyingByteSource etc). The wasm artifact lives at `wasm/` inside the published tarball, populated by a new `build-wasm` job in `publish-auth-npm.yml` that runs `wasm-pack build --target bundler` and uploads the output. The `publish` job downloads it alongside the per-platform `.node` files. Locally: `npm run build:wasm`. Tarball size: ~263kB compressed (vs ~210kB before). Verified by `node --conditions=deno`: import resolution routes to the wasm entry as expected; without conditions, the existing napi loader is unchanged. Also updates wasm-analysis.md with a "Medium-term direction" section noting that cipherstash-client is planned to be replaced by a slimmer `stack-encrypt` crate (with its own napi + wasm bindings), which would supersede Layer 4 as originally scoped. Layer 4's status is marked tentative pending that decision. --- .../imported-workflows/publish-auth-npm.yml | 53 ++++++++++++- docs/wasm-analysis.md | 67 ++++++++++++++--- languages/typescript/packages/auth/.gitignore | 4 + .../typescript/packages/auth/package.json | 29 +++++++- .../typescript/packages/auth/wasm-types.d.ts | 74 +++++++++++++++++++ 5 files changed, 216 insertions(+), 11 deletions(-) create mode 100644 languages/typescript/packages/auth/wasm-types.d.ts diff --git a/.github/imported-workflows/publish-auth-npm.yml b/.github/imported-workflows/publish-auth-npm.yml index 924de2de5..ad430fddb 100644 --- a/.github/imported-workflows/publish-auth-npm.yml +++ b/.github/imported-workflows/publish-auth-npm.yml @@ -84,9 +84,51 @@ jobs: path: ${{ env.WORKING_DIR }}/*.node if-no-files-found: error + build-wasm: + name: Build - wasm32 (bundler target) + runs-on: blacksmith-8vcpu-ubuntu-2404 + + steps: + - uses: actions/checkout@v6 + + - name: Setup Rust + uses: dtolnay/rust-toolchain@1.90.0 + with: + targets: wasm32-unknown-unknown + + - name: Setup Rust cache + uses: Swatinem/rust-cache@v2 + + - name: Install wasm-pack + # The `jetli/wasm-pack-action` GitHub Action is rejected by the + # cipherstash org allowlist. Use the official rustwasm.github.io + # installer inline. + run: curl https://rustwasm.github.io/wasm-pack/installer/init.sh -sSf | sh + + - name: wasm-pack build + run: wasm-pack build --target bundler --out-dir ../node/wasm packages/stack-auth/wasm + + - name: Strip wasm-pack metadata + # wasm-pack emits its own package.json / README / LICENSE / .gitignore + # alongside the .wasm + .js shims. Replace the package.json with a + # minimal `{ "type": "module" }` so Node recognises the .js shim as + # ESM (it uses `import`/`export`) without treating wasm/ as a separate + # npm package. The parent package.json owns the publish contract. + working-directory: ${{ env.WORKING_DIR }} + run: | + rm -f wasm/README.md wasm/LICENSE wasm/.gitignore + echo '{"type":"module"}' > wasm/package.json + + - name: Upload wasm artifact + uses: actions/upload-artifact@v7 + with: + name: bindings-wasm32 + path: ${{ env.WORKING_DIR }}/wasm/ + if-no-files-found: error + publish: name: Publish - needs: build + needs: [build, build-wasm] runs-on: ubuntu-latest steps: @@ -111,6 +153,15 @@ jobs: working-directory: ${{ env.WORKING_DIR }} run: npx napi artifacts --dir artifacts + - name: Move wasm artifact into wasm/ + # `napi artifacts` only knows about the per-platform .node files; + # the wasm32 build is bundled directly into the main package. + working-directory: ${{ env.WORKING_DIR }} + run: | + mkdir -p wasm + mv artifacts/bindings-wasm32/* wasm/ + ls -la wasm/ + - name: Determine version and npm tag id: version run: | diff --git a/docs/wasm-analysis.md b/docs/wasm-analysis.md index b5fe436f2..952e32335 100644 --- a/docs/wasm-analysis.md +++ b/docs/wasm-analysis.md @@ -30,25 +30,51 @@ Shipped in PR #1944. Both crates now build for `wasm32-unknown-unknown`. The was Deliberately not on wasm: `device_code` flow (uses `open::that`), `stack-profile` (filesystem), `cts_client`, `management`, `config::source`, `config::paths`, `config::docker_env_file`. Per-target dep splits in `stack-auth` and `cipherstash-client` work around workspace `tokio = { features = ["full"] }` (pulls `mio`, which doesn't compile to wasm32) by giving each affected crate a target-conditional minimal tokio. -### Layer 3.5 — `stack-auth-wasm` bindings crate [IN PROGRESS] +### Layer 3.5 — `@cipherstash/auth` becomes wasm-capable [IN PROGRESS] -This PR. Adds `packages/stack-auth/wasm` as a sibling to the existing napi crate. Scoped to `AccessKeyStrategy` (M2M auth) — `getToken(): Promise` returning `{ token, subject, workspaceId, issuer, services }`. Errors carry a `.code` enum matching the napi contract. OAuth-based strategies (`OAuthStrategy`, `AutoStrategy`, device-code) are deferred to a follow-up: federation and token-pinning for browser/edge contexts need design work that hasn't happened yet. +Two PRs. + +**PR #1952 — `stack-auth-wasm` bindings crate [MERGED].** Adds `packages/stack-auth/wasm` as a sibling to the existing napi crate. Scoped to `AccessKeyStrategy` (M2M auth) — `getToken(): Promise` returning `{ token, subject, workspaceId, issuer, services }`. Errors carry a `.code` enum matching the napi contract. OAuth-based strategies (`OAuthStrategy`, `AutoStrategy`, device-code) are deferred to a follow-up: federation and token-pinning for browser/edge contexts need design work that hasn't happened yet. Error-code mapping is hoisted onto `AuthError::error_code()` in the parent `stack-auth` crate so this PR and the existing napi sibling can share one source of truth (napi adoption is a non-functional cleanup for a follow-up). Build targets: `wasm-pack build --target bundler` (primary — Supabase Edge, Vite, Webpack) and `--target deno` (vanilla `deno run` only — Supabase Edge Runtime sandbox blocks `fetch('file://…')` so the deno target's auto-fetch of its `.wasm` sibling fails there; the bundler output uses `import * as wasm from "./*.wasm"` which the Edge Runtime resolves natively). Tests run via `wasm-pack test --node` — pure-logic coverage (JWT claim extraction, error-code mapping, constructor smoke). HTTP semantics stay covered by the existing native `stack-auth/node/__tests__` vitest suite. CI gains the wasm32 cargo-check + wasm-pack test step alongside the existing nextest step in `test-stack-auth.yml`. -Rationale for this intermediate layer: protect-wasm (Layer 4) will need to wrap auth strategies anyway. Establishing the wasm-bindgen toolchain and error-enrichment pattern here on a tiny crate (~205 LOC, 6 tests) means Layer 4 doesn't absorb both the toolchain bootstrap and the encrypt/decrypt porting work in the same PR. - -End-to-end validated against a live Supabase Edge Function returning a real `TokenResult` from `AccessKeyStrategy.getToken()` against `ap-southeast-2.aws`. The validation surfaced three runtime issues fixed in this PR: +End-to-end validated against a live Supabase Edge Function returning a real `TokenResult` from `AccessKeyStrategy.getToken()` against `ap-southeast-2.aws`. The validation surfaced three runtime issues fixed in #1952: - `stack-auth` called `std::time::SystemTime::now()` in `token.rs` and `access_key_refresher.rs` for JWT-expiry checks. The stdlib's `wasm32-unknown-unknown` `time` module is a panicking stub. Swapped to `web_time::{SystemTime, UNIX_EPOCH}` (re-exports `std::time` on native, polyfills via JS time APIs on wasm — no behavior change off wasm). - Rust panics on wasm surface as opaque `RuntimeError: unreachable` from bytecode offsets. Added `console_error_panic_hook` and route panics to `console.error` via a `#[wasm_bindgen(start)]` module-init function. - `wasm-pack --target deno` doesn't work in the Supabase Edge Runtime — its sandbox blocks `fetch('file://…')`, which is how the deno target loads its sibling `.wasm`. Made `--target bundler` the primary build (uses `import * as wasm from "./*.wasm"`, which Edge resolves natively); deno target retained for vanilla `deno run`. -### Layer 4 — `protect-wasm` bindings +**PR #1953 — npm unification.** Stacks on #1952. Single `@cipherstash/auth` npm package serves all runtimes via `exports` conditions: + +| Runtime | Condition | Loads | Types | +|---|---|---|---| +| Node | `node` | `./index.js` (napi loader → per-platform `.node`) | `./index.d.ts` (full surface, includes device-code + profile-store) | +| Deno / Edge | `deno` | `./wasm/stack_auth_wasm.js` (wasm-bindgen bundler output) | `./wasm-types.d.ts` (hand-typed overlay) | +| Cloudflare Workers | `worker` | wasm | wasm-types | +| Browsers (via bundlers) | `browser` | wasm | wasm-types | +| Anything else | `default` | wasm | wasm-types | + +`wasm-types.d.ts` is committed hand-written (refines `Promise` → `Promise`, hides wasm-streams type leakage from reqwest's fetch backend). The wasm-bindgen-generated artifact ships inside `wasm/`, built by a new CI job in `publish-auth-npm.yml`. Verified locally: `node --conditions=deno` routes the import to the wasm entry; `node` resolves to the existing napi loader. + +User outcome: `npm install @cipherstash/auth` works everywhere. Node consumers retain the full TS surface (device-code + profile-store still typed). Deno / edge / browser consumers see only what the wasm runtime actually exposes. + +Rationale for this layer: protect-wasm (Layer 4) will need to wrap auth strategies anyway. Establishing the wasm-bindgen toolchain, conditional-exports pattern, and Token-input deserialization shape here on a small crate means Layer 4 doesn't absorb both the toolchain bootstrap and the encrypt/decrypt porting in the same PR. + +### Layer 4 — Wasm bindings for the encrypt surface + +> **Likely superseded** — see "Medium-term direction" below. Skipping straight to Layer 5-via-stack-encrypt is on the table. + +Original scope: add a sibling `protect-wasm` crate in the `protectjs-ffi` repo (next to the existing `crates/protect-ffi`) using `wasm-bindgen` + `wasm-bindgen-futures` + `serde-wasm-bindgen`. Port the 9 `#[neon::export]` async functions (`new_client`, `ensure_keyset`, `encrypt`, `encrypt_bulk`, `encrypt_query`, `encrypt_query_bulk`, `decrypt`, `decrypt_bulk`, `decrypt_bulk_fallible`). Build with `wasm-pack --target bundler`. Then unify under `@cipherstash/protect-ffi` using the same conditional-exports pattern Layer 3.5 establishes. + +Prereqs that don't apply to stack-auth's case: + +- Bump `protectjs-ffi` from `cipherstash-client = "=0.34.1-alpha.2"` / `vitaminc = "=0.1.0-pre4.2"` to the post-Layer-3 versions (cipherstash-client 0.34.1-alpha.4+, vitaminc 0.2.0-pre+). Expect API drift to fix. +- Gate `stack-profile` use in `new_client` / `ensure_keyset` — wasm has no filesystem. Pattern: accept the client key inline as a parameter (mirroring how `OAuthStrategy.withToken` replaces `fromProfile`). +- Target-split `tokio = "full"` (pulls `mio`, doesn't compile to wasm32) — same workaround stack-auth/cipherstash-client got in PR #1944. -`protect-ffi` is napi-only. Add a sibling crate `protect-wasm` in the protect-ffi repo using `wasm-bindgen` + `wasm-bindgen-futures` + `serde-wasm-bindgen`. Port the 12 `#[neon::export]` functions in `protect-ffi/crates/protect-ffi/src/lib.rs:728-1148`. Build with `wasm-pack` (target deno or web depending on the packaging story). +Estimated wasm bundle: 1.5–2.5MB unoptimised, ~800KB–1.2MB optimised. Well under the 10MB Supabase Edge cap. ### Layer 5 — Validation in a Supabase Edge Function @@ -59,6 +85,29 @@ Deploy a real edge function that calls `protect-wasm`, encrypt/decrypt against Z - Round-trip correctness against a server-side native client - Cross-backend ciphertext compatibility — encrypt on wasm (RustCrypto), decrypt on native (aws-lc-rs), and vice versa. This is the cross-backend compat test deferred from earlier. +## Medium-term direction — `stack-encrypt` replaces `cipherstash-client` + +Layer 4 as scoped above ports the existing `protect-ffi` neon bindings to wasm. That works, but it's strictly a tactical move — the underlying `cipherstash-client` crate is the long-pole heavy dependency (full reqwest stack, EQL types, config sources, etc.), and `protect-ffi` is a thin async wrapper over it. + +The cleaner long-term shape mirrors what we just did with auth: + +1. **`stack-encrypt`** — a new slim crate inside cipherstash-suite, in the spirit of `stack-auth`. Pulls only what's needed for encrypt/decrypt/query against ZeroKMS. Drops the config sources, the EQL type machinery, the device-identity persistence. Backed by `stack-auth` for the credential half, by `vitaminc-encrypt` for crypto, and a minimal HTTP client for the ZeroKMS protocol calls. + +2. **`stack-encrypt/node` (napi)** — replaces today's `protectjs-ffi` neon bindings. Single-crate-per-binding pattern is consistent with `stack-auth/node`. + +3. **`stack-encrypt/wasm` (wasm-bindgen)** — replaces what Layer 4 would have been. + +4. **Single `@cipherstash/protect` npm package** under the same conditional-exports pattern Layer 3.5 establishes. + +This is a meaningfully larger piece of work than Layer 4. It involves designing the slim public API of `stack-encrypt`, porting the protect-ffi semantics, migrating downstream consumers (Drizzle / Prisma / TS-ORM integrations that currently consume `@cipherstash/protect-ffi`). It's not on the critical path for Layer 5 — a working `protect-wasm` (Layer 4 as originally scoped) can prove out Supabase Edge first, and `stack-encrypt` follows on a longer arc. + +The decision point is: **does Layer 5 need to ship sooner, or do we wait and skip Layer 4 entirely?** + +- *Layer 4 first*: faster path to a live Supabase Edge demo (weeks). Builds throwaway-ish bindings on top of `cipherstash-client`. Need to keep them maintained until `stack-encrypt` lands. +- *Skip to stack-encrypt*: cleaner, but Layer 5 slips by however long `stack-encrypt` takes (months). Less duplication of binding work. + +Pending decision. The rest of this doc assumes Layer 4 happens for now, but every section below `## Status` should be read as conditional. + ## Supabase Edge runtime specifics - Deno-based, supports `WebAssembly.instantiate` @@ -74,8 +123,8 @@ Deploy a real edge function that calls `protect-wasm`, encrypt/decrypt against Z - [x] Layer 1 — pure crates verified on wasm32 (PR #1942) - [x] Layer 2 — legacy credentials cleanup (PR #1943) - [x] Layer 3 — `stack-auth` + `cipherstash-client` compile on wasm32 (PR #1944) -- [ ] Layer 3.5 — `stack-auth-wasm` bindings crate (this PR) -- [ ] Layer 4 — `protect-wasm` bindings (in protect-ffi repo) +- [~] Layer 3.5 — `stack-auth-wasm` bindings crate (#1952) + npm unification (stacked follow-up) +- [ ] Layer 4 — wasm bindings for encrypt — **likely superseded by stack-encrypt; pending decision** - [ ] Layer 5 — Supabase Edge validation ## Layer 1 — what shipped diff --git a/languages/typescript/packages/auth/.gitignore b/languages/typescript/packages/auth/.gitignore index 07a825bc4..16227989d 100644 --- a/languages/typescript/packages/auth/.gitignore +++ b/languages/typescript/packages/auth/.gitignore @@ -2,3 +2,7 @@ target/ node_modules/ *.node npm/*/*.node + +# wasm/ is the output of `wasm-pack build` invoked by `npm run build:wasm`. +# Build artifacts are populated by CI before publish (or locally by the dev). +wasm/ diff --git a/languages/typescript/packages/auth/package.json b/languages/typescript/packages/auth/package.json index 5c571888c..a5dd64863 100644 --- a/languages/typescript/packages/auth/package.json +++ b/languages/typescript/packages/auth/package.json @@ -3,6 +3,30 @@ "version": "0.36.0", "main": "index.js", "types": "index.d.ts", + "exports": { + ".": { + "deno": { + "types": "./wasm-types.d.ts", + "default": "./wasm/stack_auth_wasm.js" + }, + "worker": { + "types": "./wasm-types.d.ts", + "default": "./wasm/stack_auth_wasm.js" + }, + "browser": { + "types": "./wasm-types.d.ts", + "default": "./wasm/stack_auth_wasm.js" + }, + "node": { + "types": "./index.d.ts", + "default": "./index.js" + }, + "default": { + "types": "./wasm-types.d.ts", + "default": "./wasm/stack_auth_wasm.js" + } + } + }, "napi": { "name": "stack-auth-node", "triples": { @@ -21,12 +45,15 @@ "index.js", "index.d.ts", "README.md", - "stack-auth-node.js" + "stack-auth-node.js", + "wasm-types.d.ts", + "wasm/" ], "scripts": { "build": "napi build --release", "build:debug": "napi build", "build:test": "napi build --features test-utils", + "build:wasm": "cd ../wasm && wasm-pack build --target bundler --out-dir ../node/wasm && rm -f ../node/wasm/README.md ../node/wasm/LICENSE ../node/wasm/.gitignore && echo '{\"type\":\"module\"}' > ../node/wasm/package.json", "test": "npm run build:test && vitest run" }, "optionalDependencies": { diff --git a/languages/typescript/packages/auth/wasm-types.d.ts b/languages/typescript/packages/auth/wasm-types.d.ts new file mode 100644 index 000000000..980c6dcf4 --- /dev/null +++ b/languages/typescript/packages/auth/wasm-types.d.ts @@ -0,0 +1,74 @@ +/* tslint:disable */ +/* eslint-disable */ + +/* + * Hand-typed overlay for the wasm-bindgen-generated bindings. + * + * The wasm-bindgen build emits `wasm/stack_auth_wasm.d.ts` automatically, but + * its types are looser than we want (`Promise` for `getToken`, and it + * leaks internal `wasm-streams` types like `IntoUnderlyingByteSource` that + * arrive transitively via reqwest's wasm32 fetch backend). This file is the + * `types` entry for the `deno` / `worker` / `browser` / `default` conditions + * in the `exports` map — at runtime callers load the auto-generated `.js` + * shim, but the types they see come from here. + * + * The Node entry continues to use `index.d.ts`, which exposes the full surface + * (including filesystem- and browser-backed features like the device-code flow + * and profile-store loading) that doesn't compile to wasm32. OAuth-based + * strategies on wasm (`OAuthStrategy`, `AutoStrategy`) are deferred to a + * follow-up — see the Layer 3.5 notes in `wasm-analysis.md`. + */ + +/** Error codes attached to errors thrown by this package. */ +export type AuthErrorCode = + | 'REQUEST_ERROR' + | 'ACCESS_DENIED' + | 'EXPIRED_TOKEN' + | 'INVALID_GRANT' + | 'INVALID_CLIENT' + | 'INVALID_URL' + | 'INVALID_REGION' + | 'INVALID_TOKEN' + | 'SERVER_ERROR' + | 'NOT_AUTHENTICATED' + | 'MISSING_WORKSPACE_CRN' + | 'INVALID_ACCESS_KEY' + | 'INVALID_CRN' + | 'UNKNOWN_ERROR' + +/** An error thrown by this package, enriched with a machine-readable `.code`. */ +export interface AuthError extends Error { + code: AuthErrorCode +} + +/** + * The result of a successful `getToken()` call. + * + * Contains the bearer credential and decoded JWT claims for service discovery. + */ +export interface TokenResult { + /** The bearer token string (used as `Authorization: Bearer `). */ + token: string + /** The subject claim from the JWT (e.g. `"CS|auth0|user123"` or `"CS|CSAKkeyId"`). */ + subject: string + /** The workspace identifier from the JWT. */ + workspaceId: string + /** The issuer URL from the JWT `iss` claim (i.e. the CTS host). */ + issuer: string + /** Service endpoint URLs from the JWT `services` claim (e.g. `{ zerokms: "https://..." }`). */ + services: Record +} + +/** + * An auth strategy that uses a static access key for service-to-service + * or CI/CD authentication. + */ +export declare class AccessKeyStrategy { + private constructor() + /** Create a new `AccessKeyStrategy` for the given region and access key. */ + static create(region: string, accessKey: string): AccessKeyStrategy + /** Retrieve a valid access token, refreshing or re-authenticating as needed. */ + getToken(): Promise + /** Release the underlying wasm resources. */ + free(): void +} From bc68b581d339553e816010ff532130e73d97cb67 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Fri, 15 May 2026 19:18:37 +1000 Subject: [PATCH 199/686] chore(stack-auth/auth): bump to 0.37.0-alpha.0 for prerelease publish MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cuts a prerelease so the Layer 3.5 wasm bindings + unified-package exports map can be validated end-to-end against Supabase Edge from the real npm registry. Edge Runtime resolves `npm:` specifiers via the registry only — local `file:` tarballs aren't honoured even with `nodeModulesDir: auto` — so the npm: flow is unverifiable without a published artifact. Prerelease ships under the `next` dist-tag; stable `@cipherstash/auth` consumers continue to get 0.36.0. Also gitignores `cipherstash-auth-*.tgz` so local `npm pack` artifacts don't get accidentally committed. --- languages/typescript/packages/auth/.gitignore | 3 +++ .../typescript/packages/auth/package.json | 18 +++++------------- 2 files changed, 8 insertions(+), 13 deletions(-) diff --git a/languages/typescript/packages/auth/.gitignore b/languages/typescript/packages/auth/.gitignore index 16227989d..5b0deaf19 100644 --- a/languages/typescript/packages/auth/.gitignore +++ b/languages/typescript/packages/auth/.gitignore @@ -6,3 +6,6 @@ npm/*/*.node # wasm/ is the output of `wasm-pack build` invoked by `npm run build:wasm`. # Build artifacts are populated by CI before publish (or locally by the dev). wasm/ + +# Local `npm pack` tarballs used for offline testing. +cipherstash-auth-*.tgz diff --git a/languages/typescript/packages/auth/package.json b/languages/typescript/packages/auth/package.json index a5dd64863..5fe08dfa5 100644 --- a/languages/typescript/packages/auth/package.json +++ b/languages/typescript/packages/auth/package.json @@ -1,22 +1,10 @@ { "name": "@cipherstash/auth", - "version": "0.36.0", + "version": "0.37.0-alpha.0", "main": "index.js", "types": "index.d.ts", "exports": { ".": { - "deno": { - "types": "./wasm-types.d.ts", - "default": "./wasm/stack_auth_wasm.js" - }, - "worker": { - "types": "./wasm-types.d.ts", - "default": "./wasm/stack_auth_wasm.js" - }, - "browser": { - "types": "./wasm-types.d.ts", - "default": "./wasm/stack_auth_wasm.js" - }, "node": { "types": "./index.d.ts", "default": "./index.js" @@ -25,6 +13,10 @@ "types": "./wasm-types.d.ts", "default": "./wasm/stack_auth_wasm.js" } + }, + "./wasm": { + "types": "./wasm-types.d.ts", + "default": "./wasm/stack_auth_wasm.js" } }, "napi": { From 214df60a2f16f881f1de3e3ec79f38e8d1a0b8a8 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Fri, 15 May 2026 20:17:13 +1000 Subject: [PATCH 200/686] feat(stack-auth/auth): add @cipherstash/auth/wasm-inline entry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Edge runtimes like Supabase Edge Functions and Cloudflare Workers don't auto-bundle sibling `.wasm` assets imported from JS — they need either explicit static-file config or a single self-contained module. The existing `@cipherstash/auth/wasm` entry uses wasm-pack's bundler-target output (`import * as wasm from "./*.wasm"`), which works with Vite/Webpack but is fragile across edge runtimes. Add a sibling `./wasm-inline` entry that embeds the wasm bytes as base64 inside the JS shim and instantiates at module load via top-level await. Loads with zero runtime config in every wasm- supporting runtime — Supabase Edge, Cloudflare Workers, browsers, Deno, Bun, Node ESM. Trade-off: ~28% larger JS payload (825KB vs 645KB sibling .js+.wasm) and ~50ms cold-start vs streaming compile. Acceptable for an auth surface that runs once per worker boot, not per request. Bumps to 0.37.0-alpha.1 for the next prerelease publish. --- .../imported-workflows/publish-auth-npm.yml | 8 +++ .../typescript/packages/auth/package.json | 8 ++- .../packages/auth/scripts/inline-wasm.mjs | 53 +++++++++++++++++++ 3 files changed, 67 insertions(+), 2 deletions(-) create mode 100644 languages/typescript/packages/auth/scripts/inline-wasm.mjs diff --git a/.github/imported-workflows/publish-auth-npm.yml b/.github/imported-workflows/publish-auth-npm.yml index ad430fddb..a83319809 100644 --- a/.github/imported-workflows/publish-auth-npm.yml +++ b/.github/imported-workflows/publish-auth-npm.yml @@ -119,6 +119,14 @@ jobs: rm -f wasm/README.md wasm/LICENSE wasm/.gitignore echo '{"type":"module"}' > wasm/package.json + - name: Emit inline-bytes shim + # Generate the `@cipherstash/auth/wasm-inline` entry: a JS shim with + # the wasm bytes embedded as base64. Loads with zero config in + # runtimes that don't auto-bundle sibling `.wasm` assets (Supabase + # Edge, Cloudflare Workers, browsers without a wasm-aware bundler). + working-directory: ${{ env.WORKING_DIR }} + run: node scripts/inline-wasm.mjs + - name: Upload wasm artifact uses: actions/upload-artifact@v7 with: diff --git a/languages/typescript/packages/auth/package.json b/languages/typescript/packages/auth/package.json index 5fe08dfa5..68c77f99c 100644 --- a/languages/typescript/packages/auth/package.json +++ b/languages/typescript/packages/auth/package.json @@ -1,6 +1,6 @@ { "name": "@cipherstash/auth", - "version": "0.37.0-alpha.0", + "version": "0.37.0-alpha.1", "main": "index.js", "types": "index.d.ts", "exports": { @@ -17,6 +17,10 @@ "./wasm": { "types": "./wasm-types.d.ts", "default": "./wasm/stack_auth_wasm.js" + }, + "./wasm-inline": { + "types": "./wasm-types.d.ts", + "default": "./wasm/stack_auth_wasm_inline.js" } }, "napi": { @@ -45,7 +49,7 @@ "build": "napi build --release", "build:debug": "napi build", "build:test": "napi build --features test-utils", - "build:wasm": "cd ../wasm && wasm-pack build --target bundler --out-dir ../node/wasm && rm -f ../node/wasm/README.md ../node/wasm/LICENSE ../node/wasm/.gitignore && echo '{\"type\":\"module\"}' > ../node/wasm/package.json", + "build:wasm": "cd ../wasm && wasm-pack build --target bundler --out-dir ../node/wasm && rm -f ../node/wasm/README.md ../node/wasm/LICENSE ../node/wasm/.gitignore && echo '{\"type\":\"module\"}' > ../node/wasm/package.json && node ../node/scripts/inline-wasm.mjs", "test": "npm run build:test && vitest run" }, "optionalDependencies": { diff --git a/languages/typescript/packages/auth/scripts/inline-wasm.mjs b/languages/typescript/packages/auth/scripts/inline-wasm.mjs new file mode 100644 index 000000000..683df181d --- /dev/null +++ b/languages/typescript/packages/auth/scripts/inline-wasm.mjs @@ -0,0 +1,53 @@ +// Postbuild step: emit an inline-bytes variant of the wasm-bindgen JS shim +// alongside the bundler-target output. Wasm-pack's `--target bundler` emits +// `import * as wasm from "./*.wasm"`, which works with Vite/Webpack but +// requires either a wasm-aware bundler or a runtime that auto-bundles the +// sibling `.wasm` asset. Edge runtimes (Supabase Edge, Cloudflare Workers) +// typically need explicit static-asset config to make the `.wasm` available. +// +// The inline variant embeds the wasm bytes as a base64 string inside the JS +// module so it loads with zero runtime config in any wasm-supporting runtime. +// Trade-off: ~30% larger JS payload (~880KB vs ~633KB raw wasm) and ~50ms +// cold-start vs streaming compile. + +import { readFile, writeFile } from "node:fs/promises"; +import { resolve, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; + +const here = dirname(fileURLToPath(import.meta.url)); +const wasmDir = resolve(here, "..", "wasm"); + +const wasmBytes = await readFile(resolve(wasmDir, "stack_auth_wasm_bg.wasm")); +const base64 = wasmBytes.toString("base64"); + +const shim = `/* @ts-self-types="./stack_auth_wasm.d.ts" */ + +// Inline-bytes variant of the wasm-bindgen JS shim. The wasm module is +// embedded as base64 below and instantiated at module load via top-level +// await. Use this entry from runtimes that don't auto-bundle sibling +// \`.wasm\` assets (Supabase Edge, Cloudflare Workers, browsers without a +// wasm-aware bundler). Generated by \`scripts/inline-wasm.mjs\`. + +import * as bgImports from "./stack_auth_wasm_bg.js"; + +const WASM_BYTES_B64 = "${base64}"; +const wasmBytes = Uint8Array.from(atob(WASM_BYTES_B64), (c) => c.charCodeAt(0)); + +const { instance } = await WebAssembly.instantiate(wasmBytes, { + "./stack_auth_wasm_bg.js": bgImports, +}); +bgImports.__wbg_set_wasm(instance.exports); +instance.exports.__wbindgen_start(); + +export { + AccessKeyStrategy, IntoUnderlyingByteSource, IntoUnderlyingSink, IntoUnderlyingSource, module_init +} from "./stack_auth_wasm_bg.js"; +`; + +const outPath = resolve(wasmDir, "stack_auth_wasm_inline.js"); +await writeFile(outPath, shim); + +console.log( + `inline-wasm: wrote stack_auth_wasm_inline.js (` + + `${wasmBytes.length} wasm bytes -> ${base64.length} b64 chars)`, +); From 201f97bc1b62660bca77e5f2eaf68b19a718985e Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Fri, 15 May 2026 20:55:58 +1000 Subject: [PATCH 201/686] fix(stack-auth/wasm): serialize services as plain JS object MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `serde_wasm_bindgen::to_value` converts `BTreeMap` to a JS `Map` by default. `JSON.stringify(new Map([["k","v"]]))` produces `"{}"`, dropping every entry — so consumers serialising `TokenResult` across the wire (e.g. an Edge Function returning JSON) saw an empty `services` field despite the JWT containing service URLs. Switch to `Serializer::json_compatible()`, which flips `serialize_maps_as_objects(true)` so the runtime shape matches the `Record` declared in `wasm-types.d.ts`. Tighten the existing claim-extraction test to assert services is not a JS Map and that entries are reachable via plain-object property access. --- .../packages/stack-auth-wasm/src/lib.rs | 24 ++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/languages/typescript/packages/stack-auth-wasm/src/lib.rs b/languages/typescript/packages/stack-auth-wasm/src/lib.rs index 0053c23fc..62a28d36d 100644 --- a/languages/typescript/packages/stack-auth-wasm/src/lib.rs +++ b/languages/typescript/packages/stack-auth-wasm/src/lib.rs @@ -13,6 +13,7 @@ use std::collections::BTreeMap; use cts_common::Region; use serde::Serialize; +use serde_wasm_bindgen::Serializer; use stack_auth::{AuthError, AuthStrategy, ServiceToken}; use wasm_bindgen::prelude::*; @@ -69,7 +70,13 @@ fn token_result_from(token: ServiceToken) -> Result { issuer, services, }; - serde_wasm_bindgen::to_value(&payload).map_err(JsValue::from) + // `json_compatible` serializes maps as plain objects rather than JS `Map`s, + // so consumers can `JSON.stringify` the result and read fields with normal + // object syntax — matches the `Record` shape advertised in + // `wasm-types.d.ts`. + payload + .serialize(&Serializer::json_compatible()) + .map_err(JsValue::from) } #[wasm_bindgen] @@ -174,6 +181,21 @@ mod tests { issuer.as_string().as_deref(), Some("https://cts.example.com/") ); + + // `services` must serialise as a plain object so `JSON.stringify` + // returns the entries — not as a JS `Map`, which stringifies to `{}`. + let services = + js_sys::Reflect::get(&value, &JsValue::from_str("services")).expect("has services"); + assert!( + !services.is_instance_of::(), + "services must not be a JS Map (JSON.stringify would drop entries)", + ); + let zerokms_url = js_sys::Reflect::get(&services, &JsValue::from_str("zerokms")) + .expect("services has zerokms entry"); + assert_eq!( + zerokms_url.as_string().as_deref(), + Some("https://zerokms.example.com/") + ); } #[wasm_bindgen_test] From af2c13172ac20619eb4ac9cf793d1bf44c74769d Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Fri, 15 May 2026 20:56:10 +1000 Subject: [PATCH 202/686] feat(stack-auth/auth): default non-Node entry to wasm-inline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Flip the main `.` exports entry's `default` condition from the sibling-`.wasm` shim to the inline-bytes shim, so `import { AccessKeyStrategy } from "@cipherstash/auth"` works with zero runtime config in Supabase Edge / Cloudflare Workers / browsers without consumers needing the `/wasm-inline` sub-path or any `static_files` declaration. Node consumers are unaffected — they still hit the `node` condition first and load the napi binding with the full surface. Bundler-savvy consumers (Vite/Webpack) opt in to the smaller sibling-`.wasm` payload via the explicit `@cipherstash/auth/wasm` sub-path, which is preserved as-is. Bumps to 0.37.0-alpha.2 for the next prerelease publish. --- languages/typescript/packages/auth/package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/languages/typescript/packages/auth/package.json b/languages/typescript/packages/auth/package.json index 68c77f99c..f2bd1103e 100644 --- a/languages/typescript/packages/auth/package.json +++ b/languages/typescript/packages/auth/package.json @@ -1,6 +1,6 @@ { "name": "@cipherstash/auth", - "version": "0.37.0-alpha.1", + "version": "0.37.0-alpha.2", "main": "index.js", "types": "index.d.ts", "exports": { @@ -11,7 +11,7 @@ }, "default": { "types": "./wasm-types.d.ts", - "default": "./wasm/stack_auth_wasm.js" + "default": "./wasm/stack_auth_wasm_inline.js" } }, "./wasm": { From b8075009a268da1dd72c251149d2e34b6e19d18f Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Fri, 15 May 2026 20:56:21 +1000 Subject: [PATCH 203/686] docs(stack-auth): document unified package + wasm entries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `packages/stack-auth/node/README.md` — rewrite to cover both Node (full surface via napi) and edge (AccessKeyStrategy via inline-bytes wasm) consumer paths, including the `./wasm` opt-in for bundler- savvy users. - `packages/stack-auth/wasm/README.md` — drop the stale references to `pkg-bundler/` / `pkg-deno/` workflows that don't exist anymore; this crate is the upstream source for `@cipherstash/auth`'s wasm entries, not a published artifact. - `wasm-analysis.md` — update the Layer 3.5 section to reflect the final exports shape (`. ` defaults to inline; `./wasm` and `./wasm-inline` available explicitly), record the Edge Runtime constraints that drove the design (no `deno` condition for `npm:` specifiers, no bare `.wasm` ESM imports without `static_files`), and note the services-serialization fix. --- docs/wasm-analysis.md | 31 ++++++--- languages/typescript/packages/auth/README.md | 64 ++++++++++++++--- .../packages/stack-auth-wasm/README.md | 69 +++++-------------- 3 files changed, 91 insertions(+), 73 deletions(-) diff --git a/docs/wasm-analysis.md b/docs/wasm-analysis.md index 952e32335..b8fc21841 100644 --- a/docs/wasm-analysis.md +++ b/docs/wasm-analysis.md @@ -46,21 +46,30 @@ End-to-end validated against a live Supabase Edge Function returning a real `Tok - Rust panics on wasm surface as opaque `RuntimeError: unreachable` from bytecode offsets. Added `console_error_panic_hook` and route panics to `console.error` via a `#[wasm_bindgen(start)]` module-init function. - `wasm-pack --target deno` doesn't work in the Supabase Edge Runtime — its sandbox blocks `fetch('file://…')`, which is how the deno target loads its sibling `.wasm`. Made `--target bundler` the primary build (uses `import * as wasm from "./*.wasm"`, which Edge resolves natively); deno target retained for vanilla `deno run`. -**PR #1953 — npm unification.** Stacks on #1952. Single `@cipherstash/auth` npm package serves all runtimes via `exports` conditions: +**PR #1953 — npm unification.** Stacks on #1952. Single `@cipherstash/auth` npm package serves Node and edge runtimes from one install. Final `exports` shape: -| Runtime | Condition | Loads | Types | -|---|---|---|---| -| Node | `node` | `./index.js` (napi loader → per-platform `.node`) | `./index.d.ts` (full surface, includes device-code + profile-store) | -| Deno / Edge | `deno` | `./wasm/stack_auth_wasm.js` (wasm-bindgen bundler output) | `./wasm-types.d.ts` (hand-typed overlay) | -| Cloudflare Workers | `worker` | wasm | wasm-types | -| Browsers (via bundlers) | `browser` | wasm | wasm-types | -| Anything else | `default` | wasm | wasm-types | +| Entry | `node` condition | `default` condition | +|---|---|---| +| `.` (main) | `./index.js` (napi loader) + `./index.d.ts` | `./wasm/stack_auth_wasm_inline.js` + `./wasm-types.d.ts` | +| `./wasm` | — | `./wasm/stack_auth_wasm.js` (bundler-target, sibling `.wasm`) | +| `./wasm-inline` | — | `./wasm/stack_auth_wasm_inline.js` (inline-bytes) | -`wasm-types.d.ts` is committed hand-written (refines `Promise` → `Promise`, hides wasm-streams type leakage from reqwest's fetch backend). The wasm-bindgen-generated artifact ships inside `wasm/`, built by a new CI job in `publish-auth-npm.yml`. Verified locally: `node --conditions=deno` routes the import to the wasm entry; `node` resolves to the existing napi loader. +User outcome: `npm install @cipherstash/auth` and `import { AccessKeyStrategy } from "@cipherstash/auth"` works in Node (full surface via napi) and in Supabase Edge / Cloudflare Workers / Bun / Deno / browsers (`AccessKeyStrategy` via inline-bytes wasm) with **zero runtime config** — no `static_files`, no asset copying, no bundler plugins. Bundler-savvy consumers (Vite/Webpack) opt in to the smaller sibling-`.wasm` variant via the explicit `@cipherstash/auth/wasm` sub-path. -User outcome: `npm install @cipherstash/auth` works everywhere. Node consumers retain the full TS surface (device-code + profile-store still typed). Deno / edge / browser consumers see only what the wasm runtime actually exposes. +Why inline-bytes is the non-Node default: validating the unified-package design against a live Supabase Edge worker surfaced two fundamental Supabase Edge Runtime 1.73.0 constraints: -Rationale for this layer: protect-wasm (Layer 4) will need to wrap auth strategies anyway. Establishing the wasm-bindgen toolchain, conditional-exports pattern, and Token-input deserialization shape here on a small crate means Layer 4 doesn't absorb both the toolchain bootstrap and the encrypt/decrypt porting in the same PR. +- **Deno's `deno`/`worker`/`browser` conditions don't fire for `npm:` specifiers.** For npm-distributed packages, Deno (and the Supabase fork) walks `[node, import, default]` only — `deno`/`worker`/`browser` keys in the exports map are dead weight when consumers reach the package via `npm:`. +- **Bare `.wasm` ESM imports aren't supported, and assets aren't auto-bundled.** Native `import * as wasm from "./x.wasm"` (Deno 2.x), `import bytes from "./x.wasm" with { type: "bytes" }` (modern web import attributes), and `Deno.readFile` from inside `node_modules` all fail in Edge 1.73.0 unless the `.wasm` is declared in `supabase/config.toml` via `static_files`. The inline-bytes shim base64-encodes the wasm into the JS module so no asset bundling is required — works everywhere `WebAssembly.instantiate` works. + +Trade-off for inline: ~28% larger JS payload (~825KB vs ~645KB sibling `.js`+`.wasm`) and ~50ms cold-start vs streaming compile. Acceptable for an auth surface that runs once per worker boot, not per request. + +Other validation-driven fixes folded into the PR: + +- `serde_wasm_bindgen::Serializer::json_compatible()` for the `TokenResultPayload` so `services: BTreeMap` serialises as a plain JS object — `BTreeMap` defaults to JS `Map`, which `JSON.stringify` flattens to `"{}"`, dropping every entry. The `wasm-types.d.ts` overlay declares `services: Record`, so this aligns runtime shape with declared type. +- `wasm-types.d.ts` is committed hand-written (refines `Promise` → `Promise`, hides wasm-streams type leakage from reqwest's fetch backend, scoped to `AccessKeyStrategy`). +- CI (`publish-auth-npm.yml`) gains a `build-wasm` job that runs wasm-pack + the inline-bytes postbuild script (`scripts/inline-wasm.mjs`); the `publish` job depends on it so every release ships the inline shim. The published prerelease pipeline is exercised: `0.37.0-alpha.0` (bundler-target only) and `0.37.0-alpha.1` / `0.37.0-alpha.2` (with inline) all published cleanly under the `next` dist-tag. + +Rationale for this layer: protect-wasm (Layer 4) will need to wrap auth strategies anyway. Establishing the wasm-bindgen toolchain, inline-bytes postbuild pattern, and the exports-map shape here on a small crate means Layer 4 doesn't absorb both the toolchain bootstrap and the encrypt/decrypt porting in the same PR. ### Layer 4 — Wasm bindings for the encrypt surface diff --git a/languages/typescript/packages/auth/README.md b/languages/typescript/packages/auth/README.md index 961ef609a..6f4339192 100644 --- a/languages/typescript/packages/auth/README.md +++ b/languages/typescript/packages/auth/README.md @@ -5,7 +5,7 @@ [Website](https://cipherstash.com) | [Docs](https://cipherstash.com/docs) | [Discord](https://discord.com/invite/5qwXUFb6PB) -Native Node.js bindings for authenticating with [CipherStash](https://cipherstash.com) services using the [OAuth 2.0 Device Authorization](https://datatracker.ietf.org/doc/html/rfc8628) flow. +Authentication bindings for [CipherStash](https://cipherstash.com) services. Ships native Node.js bindings for the full surface, and a wasm build for edge runtimes (Supabase Edge Functions, Cloudflare Workers, browsers). ## Installation @@ -13,13 +13,16 @@ Native Node.js bindings for authenticating with [CipherStash](https://cipherstas npm install @cipherstash/auth ``` -Prebuilt native binaries are included for: +The package routes to the right binary based on the consumer's runtime: -- macOS (x64, ARM64) -- Linux (x64 glibc, x64 musl, ARM64 glibc) -- Windows (x64) +| Runtime | Loads | Surface | +|---|---|---| +| Node.js | Prebuilt native (.node) for darwin x64/arm64, linux x64/arm64 (glibc), linux x64 (musl), windows x64 | Full surface — device-code flow, profile store, access keys, OAuth | +| Supabase Edge / Cloudflare Workers / Bun / Deno / browsers | Wasm bindings (inline-bytes shim) | `AccessKeyStrategy` only (machine-to-machine auth) | -## Usage +The wasm bindings are deliberately scoped to `AccessKeyStrategy` — OAuth, device-code flow, and profile-store features depend on Node-only APIs (filesystem, browser launching) that can't be ported. + +## Node.js usage — OAuth device-code flow ```js const { beginDeviceCodeFlow } = require("@cipherstash/auth"); @@ -39,13 +42,41 @@ console.log(`Token expires in ${auth.expiresIn} seconds`); The token is saved to `~/.cipherstash/auth.json` automatically and is never exposed to JavaScript. +## Edge usage — Supabase Edge Functions / Cloudflare Workers + +```ts +import { AccessKeyStrategy } from "@cipherstash/auth"; + +const strategy = AccessKeyStrategy.create( + "ap-southeast-2.aws", + Deno.env.get("CS_CLIENT_ACCESS_KEY")!, +); + +const { token, workspaceId, services } = await strategy.getToken(); +// Use `token` as `Authorization: Bearer ${token}` against ZeroKMS. +``` + +The default entry under non-Node runtimes is an **inline-bytes** wasm shim — the wasm module is embedded as base64 in the JS, so it loads with zero runtime config. No `static_files`, no asset copying, no bundler configuration. + +`getToken()` resolves to `{ token, subject, workspaceId, issuer, services }` where `services` is a plain object (e.g. `{ zerokms: "https://..." }`). + +### Bundler users (Vite / Webpack / Next.js) + +The default entry trades ~28% extra JS bundle for the zero-config story. Bundlers that natively understand `.wasm` imports can opt in to the smaller sibling-`.wasm` variant: + +```ts +import { AccessKeyStrategy } from "@cipherstash/auth/wasm"; +``` + +The two entries expose identical APIs. + ## API -### `beginDeviceCodeFlow(region, clientId)` +### Node — `beginDeviceCodeFlow(region, clientId)` Starts the OAuth 2.0 Device Authorization flow. Returns a `Promise`. -### `DeviceCodeResult` +#### `DeviceCodeResult` | Property / Method | Description | |---|---| @@ -56,26 +87,37 @@ Starts the OAuth 2.0 Device Authorization flow. Returns a `Promise` | -### `AuthResult` +#### `AuthResult` | Property | Description | |---|---| | `expiresAt` | Absolute epoch timestamp (seconds) when the token expires | | `expiresIn` | Seconds until the token expires | +### Edge — `AccessKeyStrategy` + +| Method | Description | +|---|---| +| `AccessKeyStrategy.create(region, accessKey)` | Build a strategy from a region and access key | +| `strategy.getToken()` | Retrieve a valid `TokenResult`, refreshing as needed | + +`TokenResult` is `{ token, subject, workspaceId, issuer, services }`. + ## Error handling -Errors thrown by the native module include a machine-readable `.code` property: +Errors thrown from this package extend `Error` with a machine-readable `.code` property: ```js try { - await result.pollForToken(); + await strategy.getToken(); } catch (err) { console.error(err.code); // e.g. "EXPIRED_TOKEN" console.error(err.message); // Human-readable description } ``` +Common codes: `INVALID_ACCESS_KEY`, `ACCESS_DENIED`, `EXPIRED_TOKEN`, `INVALID_REGION`, `INVALID_TOKEN`, `SERVER_ERROR`, `REQUEST_ERROR`. + ## License See [LICENSE](https://github.com/cipherstash/cipherstash-suite/blob/main/packages/stack-auth/LICENSE). diff --git a/languages/typescript/packages/stack-auth-wasm/README.md b/languages/typescript/packages/stack-auth-wasm/README.md index 68fa5e71c..f15e29f03 100644 --- a/languages/typescript/packages/stack-auth-wasm/README.md +++ b/languages/typescript/packages/stack-auth-wasm/README.md @@ -1,67 +1,34 @@ -# @cipherstash/stack-auth-wasm +# stack-auth-wasm -WebAssembly bindings for [`stack-auth`](https://github.com/cipherstash/cipherstash-suite/tree/main/packages/stack-auth) — built for Supabase Edge Functions (Deno) and bundler runtimes (Vite / Webpack / Node). +WebAssembly bindings for [`stack-auth`](../). Consumed by the unified [`@cipherstash/auth`](../node/) npm package — this crate is the upstream source, not a published artifact. -This is the wasm-compatible subset of the existing [`@cipherstash/auth`](https://www.npmjs.com/package/@cipherstash/auth) napi bindings, scoped to machine-to-machine authentication. +Scoped to `AccessKeyStrategy` (machine-to-machine auth). `AccessKeyStrategy.create(region, accessKey)` returns a strategy; `getToken(): Promise` resolves to `{ token, subject, workspaceId, issuer, services }`. Errors thrown extend `Error` with a machine-readable `.code` property (`INVALID_ACCESS_KEY`, `ACCESS_DENIED`, `EXPIRED_TOKEN`, etc.) sourced from `AuthError::error_code()` in the parent `stack-auth` crate. -## What's included - -| Class | Purpose | -|---|---| -| `AccessKeyStrategy` | Machine-to-machine auth with a static access key | - -`AccessKeyStrategy.create(region, accessKey)` returns a strategy; `getToken(): Promise` resolves to `{ token, subject, workspaceId, issuer, services }`. - -Errors thrown from this package extend `Error` with a machine-readable `.code` property (e.g. `INVALID_ACCESS_KEY`, `ACCESS_DENIED`, `EXPIRED_TOKEN`). - -## What's not included - -OAuth-based strategies (`OAuthStrategy`, `AutoStrategy`, the device-code flow, profile-store loading) are deliberately out of scope for the initial wasm surface. Federation and token-pinning need design work that hasn't happened yet — once those decisions are made, the OAuth surface can be added to this crate. - -The following napi-only features also can't work on wasm32 and won't be ported: - -- `bindClientDevice` / `beginDeviceCodeFlow` — depend on filesystem device identity and browser-launching for the OAuth 2.0 device-code flow -- `OAuthStrategy.fromProfile` — reads `~/.cipherstash/auth.json` +OAuth strategies, device-code flow, and profile-store loading are deliberately out of scope — they need Node-only APIs (filesystem device identity, browser launching) that can't be ported to wasm32. ## Build -```sh -# Bundler target — works in Supabase Edge, Vite, Webpack, Deno with static wasm imports: -npm run build:bundler # → pkg-bundler/ +The npm package's `build:wasm` script orchestrates everything: -# Deno target — vanilla `deno run` (uses fetch + Deno.readFile for the .wasm sibling): -npm run build:deno # → pkg-deno/ - -# Both: -npm run build +```sh +cd ../node && npm run build:wasm ``` -`wasm-pack` writes the `.wasm` artifact plus matching `.d.ts` into the chosen `pkg-*` directory. +This invokes `wasm-pack build --target bundler --out-dir ../node/wasm`, strips wasm-pack metadata, and runs `scripts/inline-wasm.mjs` to emit the inline-bytes variant. CI does the same in `.github/workflows/publish-auth-npm.yml`. -> **Picking a target.** Use `pkg-bundler/` for Supabase Edge Functions, Vite, Webpack, Next.js, and any consumer that statically imports `.wasm` modules. Use `pkg-deno/` only for vanilla `deno run` — the Supabase Edge Runtime sandbox blocks `fetch('file://…')`, so the deno target's auto-fetch of its sibling `.wasm` fails there. - -## Usage (Supabase Edge Functions) - -Copy `pkg-bundler/` next to your function's `index.ts` and import relatively: - -```ts -import { AccessKeyStrategy } from "./pkg-bundler/stack_auth_wasm.js"; - -const strategy = AccessKeyStrategy.create( - "ap-southeast-2.aws", - Deno.env.get("CS_CLIENT_ACCESS_KEY")!, -); +## Test -const { token, workspaceId, services } = await strategy.getToken(); -// `token` is the bearer credential; pass to ZeroKMS as `Authorization: Bearer ${token}` +```sh +wasm-pack test --node ``` -The bundler-target `stack_auth_wasm.js` uses `import * as wasm from "./stack_auth_wasm_bg.wasm"`, which the Supabase Edge Runtime resolves natively — no `fetch` of the wasm asset is required. +Pure-logic coverage — JWT claim extraction, services-as-plain-object serialisation, error-code mapping, constructor smoke checks. HTTP semantics are covered by the native `stack-auth/node/__tests__` vitest suite. -## Test +## Published shape -```sh -npm test # runs `wasm-pack test --node` -``` +The `@cipherstash/auth` package exposes two wasm entries built from this crate: + +- `@cipherstash/auth` (default for non-Node) and `@cipherstash/auth/wasm-inline` — inline-bytes shim with the wasm embedded as base64. Zero-config in Supabase Edge, Cloudflare Workers, browsers, Deno, Bun. +- `@cipherstash/auth/wasm` — sibling-`.wasm` shim from `wasm-pack --target bundler`. Smaller bundle for consumers using a wasm-aware bundler (Vite/Webpack). -Pure-logic tests (type conversions, JWT claim extraction, error-code mapping, constructor smoke checks) run under Node-hosted wasm. HTTP semantics are covered by the native `stack-auth/node/__tests__` suite. +Both expose the same surface. See [`../node/README.md`](../node/README.md) for consumer-facing usage. From 7e45d28fcd496b14cdd0c2c27e8b5677bd17bd39 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Fri, 15 May 2026 21:03:15 +1000 Subject: [PATCH 204/686] ci(stack-auth): disable Defender realtime scan on Windows napi build MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Windows napi build job has been running ~13 minutes per release, ~6× the ~2-minute time for each Linux/macOS target. Windows Defender real-time scanning every file Cargo writes (target/ has 10k+ small files) is the dominant cost — typically a 2-4× slowdown vs. Linux for the same Rust code. Disable Defender realtime monitoring and exclude the workspace path on Windows runners only. Scope: ephemeral GitHub-hosted runner, destroyed at job end — the change doesn't persist. Industry-standard pattern for Rust CI on Windows. Expected: ~30-50% off this job, dropping it from ~13min to ~6-9min. Refs CIP-3109. --- .github/imported-workflows/publish-auth-npm.yml | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/.github/imported-workflows/publish-auth-npm.yml b/.github/imported-workflows/publish-auth-npm.yml index 924de2de5..18a7e33c5 100644 --- a/.github/imported-workflows/publish-auth-npm.yml +++ b/.github/imported-workflows/publish-auth-npm.yml @@ -52,6 +52,18 @@ jobs: steps: - uses: actions/checkout@v6 + - name: Disable Windows Defender realtime scan on workspace + # Defender real-time scanning every file Cargo writes (target/ has 10k+ + # small files) is the dominant cost of MSVC napi builds — usually + # 2-4× slowdown vs. Linux for the same code. Disabling it for the + # ephemeral runner's workspace shaves ~30-50% off this job. See + # CIP-3109. + if: runner.os == 'Windows' + shell: pwsh + run: | + Set-MpPreference -DisableRealtimeMonitoring $true + Add-MpPreference -ExclusionPath "${{ github.workspace }}" + - name: Setup Rust uses: dtolnay/rust-toolchain@1.90.0 with: From 8c6c5bce734eab730d5537aeeec72cc6aebd7573 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Fri, 15 May 2026 21:21:18 +1000 Subject: [PATCH 205/686] revert(stack-auth/auth): keep sibling-wasm as `.`'s default; require /wasm-inline for Edge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rolls back the default-entry flip from 0.37.0-alpha.2. End-to-end test in Supabase Edge with the bare `@cipherstash/auth` import surfaced that the flip didn't actually help Edge consumers: Deno applies the `node` condition for `npm:` specifiers (it emulates Node for npm packages), so Edge's resolver hits `.`'s `node` branch first and tries to load the CJS napi entry — which has no statically resolvable ESM named exports, causing the same boot-time error we got pre-flip. There's no condition in the conditional-exports system that fires for Deno-via-`npm:` but not for Node ESM (both apply `[node, import, default]`), so we can't route them apart in the exports map. The `./wasm-inline` sub-path bypasses the conditional walk entirely and is the right call for Edge / Deno-via-`npm:` consumers. Restoring `.`'s `default` to the sibling-`.wasm` shim keeps bundler users (Vite/Webpack/Next.js) on the smaller-payload path while leaving the explicit `./wasm-inline` entry available for Edge. README and wasm-analysis.md updated to describe the actual shape and the conditional-exports limitation that drives it. Bumps to 0.37.0-alpha.3 for the validation publish. --- docs/wasm-analysis.md | 18 +++++++----- languages/typescript/packages/auth/README.md | 29 +++++++++++++------ .../typescript/packages/auth/package.json | 4 +-- 3 files changed, 33 insertions(+), 18 deletions(-) diff --git a/docs/wasm-analysis.md b/docs/wasm-analysis.md index b8fc21841..5b15e2c73 100644 --- a/docs/wasm-analysis.md +++ b/docs/wasm-analysis.md @@ -46,20 +46,24 @@ End-to-end validated against a live Supabase Edge Function returning a real `Tok - Rust panics on wasm surface as opaque `RuntimeError: unreachable` from bytecode offsets. Added `console_error_panic_hook` and route panics to `console.error` via a `#[wasm_bindgen(start)]` module-init function. - `wasm-pack --target deno` doesn't work in the Supabase Edge Runtime — its sandbox blocks `fetch('file://…')`, which is how the deno target loads its sibling `.wasm`. Made `--target bundler` the primary build (uses `import * as wasm from "./*.wasm"`, which Edge resolves natively); deno target retained for vanilla `deno run`. -**PR #1953 — npm unification.** Stacks on #1952. Single `@cipherstash/auth` npm package serves Node and edge runtimes from one install. Final `exports` shape: +**PR #1953 — npm unification.** Stacks on #1952. Single `@cipherstash/auth` npm package serves Node, browser/bundler, and edge consumers from one install. Final `exports` shape: | Entry | `node` condition | `default` condition | |---|---|---| -| `.` (main) | `./index.js` (napi loader) + `./index.d.ts` | `./wasm/stack_auth_wasm_inline.js` + `./wasm-types.d.ts` | -| `./wasm` | — | `./wasm/stack_auth_wasm.js` (bundler-target, sibling `.wasm`) | +| `.` (main) | `./index.js` (napi loader) + `./index.d.ts` | `./wasm/stack_auth_wasm.js` (bundler-target, sibling `.wasm`) + `./wasm-types.d.ts` | +| `./wasm` | — | `./wasm/stack_auth_wasm.js` (explicit alias of `.`'s default) | | `./wasm-inline` | — | `./wasm/stack_auth_wasm_inline.js` (inline-bytes) | -User outcome: `npm install @cipherstash/auth` and `import { AccessKeyStrategy } from "@cipherstash/auth"` works in Node (full surface via napi) and in Supabase Edge / Cloudflare Workers / Bun / Deno / browsers (`AccessKeyStrategy` via inline-bytes wasm) with **zero runtime config** — no `static_files`, no asset copying, no bundler plugins. Bundler-savvy consumers (Vite/Webpack) opt in to the smaller sibling-`.wasm` variant via the explicit `@cipherstash/auth/wasm` sub-path. +Consumer routing: +- **Node** — bare `@cipherstash/auth`, gets full napi surface (device-code, profile-store, OAuth, AccessKeyStrategy). +- **Vite / Webpack / Next.js bundler users** — bare `@cipherstash/auth`, the bundler handles the sibling `.wasm` import as an asset chunk. +- **Supabase Edge Functions, Cloudflare Workers, Bun / Deno via `npm:`** — explicit `@cipherstash/auth/wasm-inline`. Loads the base64-inlined wasm shim with zero runtime config (no `static_files`, no asset copying, no bundler plugins). -Why inline-bytes is the non-Node default: validating the unified-package design against a live Supabase Edge worker surfaced two fundamental Supabase Edge Runtime 1.73.0 constraints: +Why Edge consumers need the explicit sub-path: validating against a live Supabase Edge worker surfaced two fundamental Supabase Edge Runtime 1.73.0 constraints, plus a conditional-exports limitation that affects every Deno-resolving-`npm:` runtime: -- **Deno's `deno`/`worker`/`browser` conditions don't fire for `npm:` specifiers.** For npm-distributed packages, Deno (and the Supabase fork) walks `[node, import, default]` only — `deno`/`worker`/`browser` keys in the exports map are dead weight when consumers reach the package via `npm:`. - **Bare `.wasm` ESM imports aren't supported, and assets aren't auto-bundled.** Native `import * as wasm from "./x.wasm"` (Deno 2.x), `import bytes from "./x.wasm" with { type: "bytes" }` (modern web import attributes), and `Deno.readFile` from inside `node_modules` all fail in Edge 1.73.0 unless the `.wasm` is declared in `supabase/config.toml` via `static_files`. The inline-bytes shim base64-encodes the wasm into the JS module so no asset bundling is required — works everywhere `WebAssembly.instantiate` works. +- **No condition distinguishes Deno-via-`npm:` from Node ESM.** Deno applies `[node, import, default]` for `npm:` specifiers — the same set Node ESM applies. There's no condition we can place in the exports map that fires for Deno-via-`npm:` but not Node, so we can't route the bare `.` import to wasm-inline for Edge while keeping napi for Node ESM. Tried it (alpha.2 default-flip); Edge still hits the `node` branch first and tries to load the CJS napi loader, which has no statically-resolvable ESM named exports and fails at boot. The `./wasm-inline` sub-path bypasses the conditional walk entirely. +- **Deno's `deno`/`worker`/`browser` conditions don't fire for `npm:` specifiers.** Same root cause — for npm-distributed packages, Deno walks `[node, import, default]` only. These keys are dead weight in an `npm:` package's exports map. Trade-off for inline: ~28% larger JS payload (~825KB vs ~645KB sibling `.js`+`.wasm`) and ~50ms cold-start vs streaming compile. Acceptable for an auth surface that runs once per worker boot, not per request. @@ -67,7 +71,7 @@ Other validation-driven fixes folded into the PR: - `serde_wasm_bindgen::Serializer::json_compatible()` for the `TokenResultPayload` so `services: BTreeMap` serialises as a plain JS object — `BTreeMap` defaults to JS `Map`, which `JSON.stringify` flattens to `"{}"`, dropping every entry. The `wasm-types.d.ts` overlay declares `services: Record`, so this aligns runtime shape with declared type. - `wasm-types.d.ts` is committed hand-written (refines `Promise` → `Promise`, hides wasm-streams type leakage from reqwest's fetch backend, scoped to `AccessKeyStrategy`). -- CI (`publish-auth-npm.yml`) gains a `build-wasm` job that runs wasm-pack + the inline-bytes postbuild script (`scripts/inline-wasm.mjs`); the `publish` job depends on it so every release ships the inline shim. The published prerelease pipeline is exercised: `0.37.0-alpha.0` (bundler-target only) and `0.37.0-alpha.1` / `0.37.0-alpha.2` (with inline) all published cleanly under the `next` dist-tag. +- CI (`publish-auth-npm.yml`) gains a `build-wasm` job that runs wasm-pack + the inline-bytes postbuild script (`scripts/inline-wasm.mjs`); the `publish` job depends on it so every release ships the inline shim. Prerelease pipeline validated through `0.37.0-alpha.0` (bundler-target only) → `0.37.0-alpha.1` (inline added) → `0.37.0-alpha.2` (services serialization fix; also tested a default-entry flip that turned out not to help Edge consumers) → `0.37.0-alpha.3` (default-flip reverted, docs corrected). All published under the `next` dist-tag. Rationale for this layer: protect-wasm (Layer 4) will need to wrap auth strategies anyway. Establishing the wasm-bindgen toolchain, inline-bytes postbuild pattern, and the exports-map shape here on a small crate means Layer 4 doesn't absorb both the toolchain bootstrap and the encrypt/decrypt porting in the same PR. diff --git a/languages/typescript/packages/auth/README.md b/languages/typescript/packages/auth/README.md index 6f4339192..22ac3998a 100644 --- a/languages/typescript/packages/auth/README.md +++ b/languages/typescript/packages/auth/README.md @@ -13,12 +13,13 @@ Authentication bindings for [CipherStash](https://cipherstash.com) services. Shi npm install @cipherstash/auth ``` -The package routes to the right binary based on the consumer's runtime: +The package exposes three entries: -| Runtime | Loads | Surface | +| Entry | Use when | Surface | |---|---|---| -| Node.js | Prebuilt native (.node) for darwin x64/arm64, linux x64/arm64 (glibc), linux x64 (musl), windows x64 | Full surface — device-code flow, profile store, access keys, OAuth | -| Supabase Edge / Cloudflare Workers / Bun / Deno / browsers | Wasm bindings (inline-bytes shim) | `AccessKeyStrategy` only (machine-to-machine auth) | +| `@cipherstash/auth` | Node.js (loads napi); Vite/Webpack/Next.js with wasm-aware bundling (loads sibling-`.wasm` shim) | Full napi surface in Node; `AccessKeyStrategy` in browsers | +| `@cipherstash/auth/wasm` | Explicit opt-in to the sibling-`.wasm` shim | `AccessKeyStrategy` | +| `@cipherstash/auth/wasm-inline` | Supabase Edge Functions, Cloudflare Workers, Bun / Deno via `npm:` — runtimes that can't auto-bundle a sibling `.wasm` | `AccessKeyStrategy` | The wasm bindings are deliberately scoped to `AccessKeyStrategy` — OAuth, device-code flow, and profile-store features depend on Node-only APIs (filesystem, browser launching) that can't be ported. @@ -44,8 +45,10 @@ The token is saved to `~/.cipherstash/auth.json` automatically and is never expo ## Edge usage — Supabase Edge Functions / Cloudflare Workers +Use the explicit `wasm-inline` sub-path: + ```ts -import { AccessKeyStrategy } from "@cipherstash/auth"; +import { AccessKeyStrategy } from "@cipherstash/auth/wasm-inline"; const strategy = AccessKeyStrategy.create( "ap-southeast-2.aws", @@ -56,19 +59,27 @@ const { token, workspaceId, services } = await strategy.getToken(); // Use `token` as `Authorization: Bearer ${token}` against ZeroKMS. ``` -The default entry under non-Node runtimes is an **inline-bytes** wasm shim — the wasm module is embedded as base64 in the JS, so it loads with zero runtime config. No `static_files`, no asset copying, no bundler configuration. +The `wasm-inline` entry embeds the wasm module as base64 inside the JS shim, so it loads with zero runtime config — no `static_files` declaration, no asset copying, no bundler plugins. `getToken()` resolves to `{ token, subject, workspaceId, issuer, services }` where `services` is a plain object (e.g. `{ zerokms: "https://..." }`). +### Why the explicit sub-path + +Bare `@cipherstash/auth` works in Node (resolves to native napi) and in wasm-aware bundlers (Vite/Webpack handle the sibling-`.wasm` import natively). + +It does **not** work in Deno-resolving-`npm:` runtimes (Supabase Edge, Cloudflare Workers via `npm:`). Deno applies the `node` exports condition for `npm:` specifiers — it emulates Node for npm packages — which routes the bare import to the napi loader. That loader is a CJS module without statically-resolvable ESM named exports, so it errors at boot. There's no condition Deno applies for `npm:` packages that Node ESM doesn't, so we can't route the two apart in the exports map. The `wasm-inline` sub-path bypasses the conditional walk entirely. + +Trade-off for inline: ~28% larger JS payload (~825KB vs ~645KB raw wasm + JS shim) and ~50ms cold-start vs streaming compile. Acceptable for an auth surface that runs once per worker boot. + ### Bundler users (Vite / Webpack / Next.js) -The default entry trades ~28% extra JS bundle for the zero-config story. Bundlers that natively understand `.wasm` imports can opt in to the smaller sibling-`.wasm` variant: +Bare import is the right shape — these bundlers understand the sibling-`.wasm` reference and emit it as an asset: ```ts -import { AccessKeyStrategy } from "@cipherstash/auth/wasm"; +import { AccessKeyStrategy } from "@cipherstash/auth"; ``` -The two entries expose identical APIs. +If your bundler doesn't handle `.wasm` imports, fall back to `@cipherstash/auth/wasm-inline`. All three entries expose identical APIs. ## API diff --git a/languages/typescript/packages/auth/package.json b/languages/typescript/packages/auth/package.json index f2bd1103e..316bfb375 100644 --- a/languages/typescript/packages/auth/package.json +++ b/languages/typescript/packages/auth/package.json @@ -1,6 +1,6 @@ { "name": "@cipherstash/auth", - "version": "0.37.0-alpha.2", + "version": "0.37.0-alpha.3", "main": "index.js", "types": "index.d.ts", "exports": { @@ -11,7 +11,7 @@ }, "default": { "types": "./wasm-types.d.ts", - "default": "./wasm/stack_auth_wasm_inline.js" + "default": "./wasm/stack_auth_wasm.js" } }, "./wasm": { From a1bf7cece538183c1f0703e5fec6895fd314a4b2 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Fri, 15 May 2026 21:28:25 +1000 Subject: [PATCH 206/686] docs(stack-auth/auth): expand README with complete Supabase Edge example - Split the entries table into per-runtime rows so `@cipherstash/auth` consumers can pick the right import by reading down their own runtime column rather than parsing a mixed-case cell. - Replace the snippet-only Edge example with a complete Deno.serve function + matching deno.json. Highlights what's NOT needed (static_files, asset copying, bundler plugins) so consumers don't go hunting. - Note Cloudflare Workers env-access shape as a one-liner. --- languages/typescript/packages/auth/README.md | 45 ++++++++++++++------ 1 file changed, 32 insertions(+), 13 deletions(-) diff --git a/languages/typescript/packages/auth/README.md b/languages/typescript/packages/auth/README.md index 22ac3998a..1e4139d50 100644 --- a/languages/typescript/packages/auth/README.md +++ b/languages/typescript/packages/auth/README.md @@ -15,11 +15,12 @@ npm install @cipherstash/auth The package exposes three entries: -| Entry | Use when | Surface | -|---|---|---| -| `@cipherstash/auth` | Node.js (loads napi); Vite/Webpack/Next.js with wasm-aware bundling (loads sibling-`.wasm` shim) | Full napi surface in Node; `AccessKeyStrategy` in browsers | -| `@cipherstash/auth/wasm` | Explicit opt-in to the sibling-`.wasm` shim | `AccessKeyStrategy` | -| `@cipherstash/auth/wasm-inline` | Supabase Edge Functions, Cloudflare Workers, Bun / Deno via `npm:` — runtimes that can't auto-bundle a sibling `.wasm` | `AccessKeyStrategy` | +| Entry | Use when | Loads | Surface | +|---|---|---|---| +| `@cipherstash/auth` | **Node.js** | Native napi binding for the host platform | Full surface — device-code flow, profile store, OAuth, `AccessKeyStrategy` | +| `@cipherstash/auth` | **Vite / Webpack / Next.js** (any bundler that handles `.wasm` imports) | Sibling-`.wasm` shim from `wasm-pack --target bundler` | `AccessKeyStrategy` | +| `@cipherstash/auth/wasm` | Explicit opt-in to the sibling-`.wasm` shim | Same as bundler entry above | `AccessKeyStrategy` | +| `@cipherstash/auth/wasm-inline` | **Supabase Edge Functions / Cloudflare Workers / Bun / Deno via `npm:`** — runtimes that can't auto-bundle a sibling `.wasm` | Inline-bytes shim (wasm embedded as base64) | `AccessKeyStrategy` | The wasm bindings are deliberately scoped to `AccessKeyStrategy` — OAuth, device-code flow, and profile-store features depend on Node-only APIs (filesystem, browser launching) that can't be ported. @@ -45,24 +46,42 @@ The token is saved to `~/.cipherstash/auth.json` automatically and is never expo ## Edge usage — Supabase Edge Functions / Cloudflare Workers -Use the explicit `wasm-inline` sub-path: +Use the explicit `wasm-inline` sub-path. Full Supabase Edge Function example: ```ts +// supabase/functions/get-token/index.ts import { AccessKeyStrategy } from "@cipherstash/auth/wasm-inline"; -const strategy = AccessKeyStrategy.create( - "ap-southeast-2.aws", - Deno.env.get("CS_CLIENT_ACCESS_KEY")!, -); +Deno.serve(async () => { + const strategy = AccessKeyStrategy.create( + "ap-southeast-2.aws", + Deno.env.get("CS_CLIENT_ACCESS_KEY")!, + ); -const { token, workspaceId, services } = await strategy.getToken(); -// Use `token` as `Authorization: Bearer ${token}` against ZeroKMS. + const { token, workspaceId, services } = await strategy.getToken(); + // `token` is the bearer credential; pass as `Authorization: Bearer ${token}` + // to ZeroKMS at `services.zerokms`. + + return Response.json({ workspaceId, services }); +}); +``` + +`supabase/functions/get-token/deno.json`: + +```jsonc +{ + "imports": { + "@cipherstash/auth/wasm-inline": "npm:@cipherstash/auth@^0.37/wasm-inline" + } +} ``` -The `wasm-inline` entry embeds the wasm module as base64 inside the JS shim, so it loads with zero runtime config — no `static_files` declaration, no asset copying, no bundler plugins. +Nothing extra in `supabase/config.toml` — no `static_files`, no asset copying, no bundler plugins. The `wasm-inline` entry embeds the wasm module as base64 inside the JS shim, so it loads with zero runtime config. `getToken()` resolves to `{ token, subject, workspaceId, issuer, services }` where `services` is a plain object (e.g. `{ zerokms: "https://..." }`). +For Cloudflare Workers the shape is identical; env access becomes `env.CS_CLIENT_ACCESS_KEY` instead of `Deno.env.get(...)`. + ### Why the explicit sub-path Bare `@cipherstash/auth` works in Node (resolves to native napi) and in wasm-aware bundlers (Vite/Webpack handle the sibling-`.wasm` import natively). From 17d6c21c902b7e71b3c6d3ddeb4a73395c15a6e6 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Fri, 15 May 2026 21:45:33 +1000 Subject: [PATCH 207/686] refactor(stack-auth/auth): dedupe CI build-wasm against the npm script; pin wasm-pack MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI's `build-wasm` job was hand-inlining the three steps (wasm-pack build, strip metadata, emit inline-bytes) that `npm run build:wasm` already does. Replace the three steps with one `npm run build:wasm` invocation so local and CI builds can't drift. While there, pin the wasm-pack installer to v0.13.1 via the same GitHub-release tarball pattern that `test-stack-auth.yml` already uses — the previous `curl ... | sh` pulled an unpinned installer script at every CI run. Also trim the over-narrated headers in `scripts/inline-wasm.mjs` and the shim it emits. The full rationale for the inline-bytes entry lives in the README's "Why the explicit sub-path" section; the script doesn't need to restate it. Surfaced by /simplify review. --- .../imported-workflows/publish-auth-npm.yml | 44 +++++++++---------- .../packages/auth/scripts/inline-wasm.mjs | 22 +++------- 2 files changed, 26 insertions(+), 40 deletions(-) diff --git a/.github/imported-workflows/publish-auth-npm.yml b/.github/imported-workflows/publish-auth-npm.yml index a83319809..e86e70ceb 100644 --- a/.github/imported-workflows/publish-auth-npm.yml +++ b/.github/imported-workflows/publish-auth-npm.yml @@ -101,31 +101,29 @@ jobs: - name: Install wasm-pack # The `jetli/wasm-pack-action` GitHub Action is rejected by the - # cipherstash org allowlist. Use the official rustwasm.github.io - # installer inline. - run: curl https://rustwasm.github.io/wasm-pack/installer/init.sh -sSf | sh - - - name: wasm-pack build - run: wasm-pack build --target bundler --out-dir ../node/wasm packages/stack-auth/wasm - - - name: Strip wasm-pack metadata - # wasm-pack emits its own package.json / README / LICENSE / .gitignore - # alongside the .wasm + .js shims. Replace the package.json with a - # minimal `{ "type": "module" }` so Node recognises the .js shim as - # ESM (it uses `import`/`export`) without treating wasm/ as a separate - # npm package. The parent package.json owns the publish contract. - working-directory: ${{ env.WORKING_DIR }} + # cipherstash org allowlist (unverified creator). Install a pinned + # version from the official GitHub release so CI is reproducible and + # not exposed to upstream installer-script changes. + env: + WASM_PACK_VERSION: v0.13.1 run: | - rm -f wasm/README.md wasm/LICENSE wasm/.gitignore - echo '{"type":"module"}' > wasm/package.json - - - name: Emit inline-bytes shim - # Generate the `@cipherstash/auth/wasm-inline` entry: a JS shim with - # the wasm bytes embedded as base64. Loads with zero config in - # runtimes that don't auto-bundle sibling `.wasm` assets (Supabase - # Edge, Cloudflare Workers, browsers without a wasm-aware bundler). + set -euo pipefail + curl -fsSL "https://github.com/rustwasm/wasm-pack/releases/download/${WASM_PACK_VERSION}/wasm-pack-${WASM_PACK_VERSION}-x86_64-unknown-linux-musl.tar.gz" \ + -o /tmp/wasm-pack.tar.gz + tar -xzf /tmp/wasm-pack.tar.gz -C /tmp + sudo install -m 0755 "/tmp/wasm-pack-${WASM_PACK_VERSION}-x86_64-unknown-linux-musl/wasm-pack" /usr/local/bin/wasm-pack + wasm-pack --version + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: 24 + + - name: Build wasm artifact + # Single source of truth shared with local `npm run build:wasm`: + # wasm-pack → strip wasm-pack metadata → emit inline-bytes shim. working-directory: ${{ env.WORKING_DIR }} - run: node scripts/inline-wasm.mjs + run: npm run build:wasm - name: Upload wasm artifact uses: actions/upload-artifact@v7 diff --git a/languages/typescript/packages/auth/scripts/inline-wasm.mjs b/languages/typescript/packages/auth/scripts/inline-wasm.mjs index 683df181d..ecc2a2cc8 100644 --- a/languages/typescript/packages/auth/scripts/inline-wasm.mjs +++ b/languages/typescript/packages/auth/scripts/inline-wasm.mjs @@ -1,14 +1,7 @@ -// Postbuild step: emit an inline-bytes variant of the wasm-bindgen JS shim -// alongside the bundler-target output. Wasm-pack's `--target bundler` emits -// `import * as wasm from "./*.wasm"`, which works with Vite/Webpack but -// requires either a wasm-aware bundler or a runtime that auto-bundles the -// sibling `.wasm` asset. Edge runtimes (Supabase Edge, Cloudflare Workers) -// typically need explicit static-asset config to make the `.wasm` available. -// -// The inline variant embeds the wasm bytes as a base64 string inside the JS -// module so it loads with zero runtime config in any wasm-supporting runtime. -// Trade-off: ~30% larger JS payload (~880KB vs ~633KB raw wasm) and ~50ms -// cold-start vs streaming compile. +// Generates the `@cipherstash/auth/wasm-inline` entry from wasm-pack output. +// Required because `--target bundler` emits `import * as wasm from "./*.wasm"`, +// which fails in runtimes that don't auto-bundle sibling `.wasm` assets — see +// README's "Why the explicit sub-path" section for the full rationale. import { readFile, writeFile } from "node:fs/promises"; import { resolve, dirname } from "node:path"; @@ -21,12 +14,7 @@ const wasmBytes = await readFile(resolve(wasmDir, "stack_auth_wasm_bg.wasm")); const base64 = wasmBytes.toString("base64"); const shim = `/* @ts-self-types="./stack_auth_wasm.d.ts" */ - -// Inline-bytes variant of the wasm-bindgen JS shim. The wasm module is -// embedded as base64 below and instantiated at module load via top-level -// await. Use this entry from runtimes that don't auto-bundle sibling -// \`.wasm\` assets (Supabase Edge, Cloudflare Workers, browsers without a -// wasm-aware bundler). Generated by \`scripts/inline-wasm.mjs\`. +// Generated by scripts/inline-wasm.mjs — do not edit. import * as bgImports from "./stack_auth_wasm_bg.js"; From 4b8519a447d36c1f861508dc69dd3198dead9563 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Fri, 15 May 2026 21:56:44 +1000 Subject: [PATCH 208/686] chore(stack-auth/auth): signal not-for-direct-browser-use MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `"browser": false` to package.json. Webpack, browserify, Bun (and any bundler honouring the convention) will refuse to bundle this package for `target: "web"` builds. Server targets (Node, Edge, SSR) ignore the field and are unaffected. Also adds a short "Security note" callout near the top of the README explaining why direct browser bundling isn't supported yet (raw access key + JWT would land in client-side source) and pointing at the follow-up work for a browser-safe surface that exposes only signed operations. Drops the "browsers" mentions from the entries table and the package description — server-side framings ("SSR bundlers") replace them. The device-code OAuth flow's references to "opening a browser" (i.e. for the human developer to authenticate) stay since they're a different meaning. --- languages/typescript/packages/auth/README.md | 8 +++++--- languages/typescript/packages/auth/package.json | 1 + 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/languages/typescript/packages/auth/README.md b/languages/typescript/packages/auth/README.md index 1e4139d50..cd3cc44f6 100644 --- a/languages/typescript/packages/auth/README.md +++ b/languages/typescript/packages/auth/README.md @@ -5,7 +5,9 @@ [Website](https://cipherstash.com) | [Docs](https://cipherstash.com/docs) | [Discord](https://discord.com/invite/5qwXUFb6PB) -Authentication bindings for [CipherStash](https://cipherstash.com) services. Ships native Node.js bindings for the full surface, and a wasm build for edge runtimes (Supabase Edge Functions, Cloudflare Workers, browsers). +Authentication bindings for [CipherStash](https://cipherstash.com) services. Ships native Node.js bindings for the full surface, and a wasm build for server-side edge runtimes (Supabase Edge Functions, Cloudflare Workers). + +> **Not for direct browser use.** This package is intended for server-side environments — Node.js, Edge Functions, Workers, Bun, Deno. Embedding it directly in a browser bundle would leak the access key into client-side source. The `"browser": false` field in `package.json` makes bundlers like webpack and browserify refuse browser builds; for bundlers that don't honor that convention (esbuild, Vite), don't include `@cipherstash/auth` in client-only chunks. A browser-safe shape that exposes only signed operations (no raw JWT or access key) is tracked as a separate piece of work. ## Installation @@ -18,7 +20,7 @@ The package exposes three entries: | Entry | Use when | Loads | Surface | |---|---|---|---| | `@cipherstash/auth` | **Node.js** | Native napi binding for the host platform | Full surface — device-code flow, profile store, OAuth, `AccessKeyStrategy` | -| `@cipherstash/auth` | **Vite / Webpack / Next.js** (any bundler that handles `.wasm` imports) | Sibling-`.wasm` shim from `wasm-pack --target bundler` | `AccessKeyStrategy` | +| `@cipherstash/auth` | **SSR bundlers** (Vite/Webpack/Next.js targeting Node or server-side rendering) | Sibling-`.wasm` shim from `wasm-pack --target bundler` | `AccessKeyStrategy` | | `@cipherstash/auth/wasm` | Explicit opt-in to the sibling-`.wasm` shim | Same as bundler entry above | `AccessKeyStrategy` | | `@cipherstash/auth/wasm-inline` | **Supabase Edge Functions / Cloudflare Workers / Bun / Deno via `npm:`** — runtimes that can't auto-bundle a sibling `.wasm` | Inline-bytes shim (wasm embedded as base64) | `AccessKeyStrategy` | @@ -88,7 +90,7 @@ Bare `@cipherstash/auth` works in Node (resolves to native napi) and in wasm-awa It does **not** work in Deno-resolving-`npm:` runtimes (Supabase Edge, Cloudflare Workers via `npm:`). Deno applies the `node` exports condition for `npm:` specifiers — it emulates Node for npm packages — which routes the bare import to the napi loader. That loader is a CJS module without statically-resolvable ESM named exports, so it errors at boot. There's no condition Deno applies for `npm:` packages that Node ESM doesn't, so we can't route the two apart in the exports map. The `wasm-inline` sub-path bypasses the conditional walk entirely. -Trade-off for inline: ~28% larger JS payload (~825KB vs ~645KB raw wasm + JS shim) and ~50ms cold-start vs streaming compile. Acceptable for an auth surface that runs once per worker boot. +Trade-off for inline: ~27% larger JS payload (~726KB vs ~572KB raw wasm + JS shim) and ~50ms cold-start vs streaming compile. Acceptable for an auth surface that runs once per worker boot. ### Bundler users (Vite / Webpack / Next.js) diff --git a/languages/typescript/packages/auth/package.json b/languages/typescript/packages/auth/package.json index 316bfb375..484c7e07b 100644 --- a/languages/typescript/packages/auth/package.json +++ b/languages/typescript/packages/auth/package.json @@ -3,6 +3,7 @@ "version": "0.37.0-alpha.3", "main": "index.js", "types": "index.d.ts", + "browser": false, "exports": { ".": { "node": { From 97fbc8f6e3a115125723f2586fe82295d2b89258 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Fri, 15 May 2026 21:57:02 +1000 Subject: [PATCH 209/686] perf(stack-auth/wasm): re-enable wasm-opt for ~14% size reduction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The wasm-pack bundled `wasm-opt` had been disabled in the wasm crate's metadata because at the time it was too old for the wasm features rustc emits. With wasm-pack v0.13.1 (pinned in CI) and the bundled binaryen v116, the optimisation passes work — enable `-Oz` plus the explicit feature flags rustc commonly emits (`--enable-bulk-memory`, `--enable-nontrapping-float-to-int`) so the build is deterministic. Locally measured: wasm: 633KB -> 544KB (-14%, -89KB) inline JS: 845KB -> 726KB (-14%, -119KB) All 6 wasm tests still pass; the inline shim still loads and exposes `AccessKeyStrategy.create` correctly. Updated the trade-off figures in README and wasm-analysis.md to match the post-opt sizes (~27% inline overhead vs the sibling-wasm payload, down from the pre-opt ~28% but with both numbers shifted ~100KB smaller in absolute terms). --- docs/wasm-analysis.md | 2 +- languages/typescript/packages/stack-auth-wasm/Cargo.toml | 7 +------ 2 files changed, 2 insertions(+), 7 deletions(-) diff --git a/docs/wasm-analysis.md b/docs/wasm-analysis.md index 5b15e2c73..7b4bcac54 100644 --- a/docs/wasm-analysis.md +++ b/docs/wasm-analysis.md @@ -65,7 +65,7 @@ Why Edge consumers need the explicit sub-path: validating against a live Supabas - **No condition distinguishes Deno-via-`npm:` from Node ESM.** Deno applies `[node, import, default]` for `npm:` specifiers — the same set Node ESM applies. There's no condition we can place in the exports map that fires for Deno-via-`npm:` but not Node, so we can't route the bare `.` import to wasm-inline for Edge while keeping napi for Node ESM. Tried it (alpha.2 default-flip); Edge still hits the `node` branch first and tries to load the CJS napi loader, which has no statically-resolvable ESM named exports and fails at boot. The `./wasm-inline` sub-path bypasses the conditional walk entirely. - **Deno's `deno`/`worker`/`browser` conditions don't fire for `npm:` specifiers.** Same root cause — for npm-distributed packages, Deno walks `[node, import, default]` only. These keys are dead weight in an `npm:` package's exports map. -Trade-off for inline: ~28% larger JS payload (~825KB vs ~645KB sibling `.js`+`.wasm`) and ~50ms cold-start vs streaming compile. Acceptable for an auth surface that runs once per worker boot, not per request. +Trade-off for inline: ~27% larger JS payload (~726KB vs ~572KB sibling `.js`+`.wasm`, post-`wasm-opt -Oz`) and ~50ms cold-start vs streaming compile. Acceptable for an auth surface that runs once per worker boot, not per request. Other validation-driven fixes folded into the PR: diff --git a/languages/typescript/packages/stack-auth-wasm/Cargo.toml b/languages/typescript/packages/stack-auth-wasm/Cargo.toml index 6a5986cfb..06884ac5c 100644 --- a/languages/typescript/packages/stack-auth-wasm/Cargo.toml +++ b/languages/typescript/packages/stack-auth-wasm/Cargo.toml @@ -27,13 +27,8 @@ console_error_panic_hook = "0.1" wasm-bindgen-test = "0.3" base64 = { workspace = true } -# wasm-pack bundles its own `wasm-opt`; if that bundled version is older than -# the wasm features rustc emits, optimisation fails. Disable it so the build is -# reproducible across local + CI without requiring a separately-installed -# wasm-opt. Re-enable once wasm-pack's bundled wasm-opt catches up, or wire -# `binaryen` install into CI. [package.metadata.wasm-pack.profile.release] -wasm-opt = false +wasm-opt = ["-Oz", "--enable-bulk-memory", "--enable-nontrapping-float-to-int"] [package.metadata.wasm-pack.profile.dev] wasm-opt = false From b47d664fa78c4f22d06f2d3943b0ec04569c392b Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Sat, 16 May 2026 13:05:29 +1000 Subject: [PATCH 210/686] Scope windows defender disable only to target directories Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .github/imported-workflows/publish-auth-npm.yml | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/.github/imported-workflows/publish-auth-npm.yml b/.github/imported-workflows/publish-auth-npm.yml index 18a7e33c5..5330e96e8 100644 --- a/.github/imported-workflows/publish-auth-npm.yml +++ b/.github/imported-workflows/publish-auth-npm.yml @@ -52,17 +52,16 @@ jobs: steps: - uses: actions/checkout@v6 - - name: Disable Windows Defender realtime scan on workspace - # Defender real-time scanning every file Cargo writes (target/ has 10k+ - # small files) is the dominant cost of MSVC napi builds — usually - # 2-4× slowdown vs. Linux for the same code. Disabling it for the - # ephemeral runner's workspace shaves ~30-50% off this job. See - # CIP-3109. + - name: Exclude Windows build output from Defender realtime scan + # Defender real-time scanning every file Cargo writes under target/ + # (10k+ small files) is the dominant cost of MSVC napi builds — + # usually 2-4× slowdown vs. Linux for the same code. Excluding only + # this package's build output keeps the mitigation scoped to the + # generated artifacts while reducing Windows build time. See CIP-3109. if: runner.os == 'Windows' shell: pwsh run: | - Set-MpPreference -DisableRealtimeMonitoring $true - Add-MpPreference -ExclusionPath "${{ github.workspace }}" + Add-MpPreference -ExclusionPath "${{ github.workspace }}\${{ env.WORKING_DIR }}\target" - name: Setup Rust uses: dtolnay/rust-toolchain@1.90.0 From 8a4ff0c95c6e19b43a34da44a3d555c7d919ebc5 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 19 May 2026 05:02:51 +0000 Subject: [PATCH 211/686] chore: release --- packages/stack-auth/CHANGELOG.md | 21 +++++++++++++++++++++ packages/stack-auth/Cargo.toml | 2 +- packages/stack-profile/CHANGELOG.md | 1 + packages/stack-profile/Cargo.toml | 2 +- 4 files changed, 24 insertions(+), 2 deletions(-) diff --git a/packages/stack-auth/CHANGELOG.md b/packages/stack-auth/CHANGELOG.md index 60eaaf63c..fd183000e 100644 --- a/packages/stack-auth/CHANGELOG.md +++ b/packages/stack-auth/CHANGELOG.md @@ -1,5 +1,26 @@ +### Documentation + +- document why TokenResultPayload uses String + +### Features + +- wasm-bindgen sibling crate for Supabase Edge (Layer 3.5) +- make runtime work in Supabase Edge + +### Fixes + +- address Copilot review feedback + patch Dockerfiles + +### Refactoring + +- wasm32 support — cfg-gate filesystem and Send bounds +- 🚨 address review feedback on Layer 3 PR +- dedupe error code mapping + tighten bindings +- scope down to AccessKeyStrategy + + ### Miscellaneous diff --git a/packages/stack-auth/Cargo.toml b/packages/stack-auth/Cargo.toml index fe003ff63..fa6d9ffad 100644 --- a/packages/stack-auth/Cargo.toml +++ b/packages/stack-auth/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "stack-auth" description = "Authentication library for CipherStash services" -version = "0.34.1-alpha.4" +version = "0.34.1-alpha.5" edition.workspace = true authors.workspace = true repository.workspace = true diff --git a/packages/stack-profile/CHANGELOG.md b/packages/stack-profile/CHANGELOG.md index ac3781672..b15929010 100644 --- a/packages/stack-profile/CHANGELOG.md +++ b/packages/stack-profile/CHANGELOG.md @@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 + ## [0.34.0-alpha.1] - 2026-03-04 ### Changed diff --git a/packages/stack-profile/Cargo.toml b/packages/stack-profile/Cargo.toml index d62c971f6..a3ee1c82b 100644 --- a/packages/stack-profile/Cargo.toml +++ b/packages/stack-profile/Cargo.toml @@ -2,7 +2,7 @@ name = "stack-profile" description = "Centralised ~/.cipherstash profile file management" license-file = "LICENSE" -version = "0.34.1-alpha.4" +version = "0.34.1-alpha.5" edition.workspace = true authors.workspace = true repository.workspace = true From 301f3f61070a14a94a3658e88ed10c1b9a7fecb7 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 19 May 2026 06:37:47 +0000 Subject: [PATCH 212/686] chore: release --- packages/stack-auth/CHANGELOG.md | 1 + packages/stack-auth/Cargo.toml | 2 +- packages/stack-profile/CHANGELOG.md | 1 + packages/stack-profile/Cargo.toml | 2 +- 4 files changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/stack-auth/CHANGELOG.md b/packages/stack-auth/CHANGELOG.md index fd183000e..c16ad4244 100644 --- a/packages/stack-auth/CHANGELOG.md +++ b/packages/stack-auth/CHANGELOG.md @@ -1,5 +1,6 @@ + ### Documentation - document why TokenResultPayload uses String diff --git a/packages/stack-auth/Cargo.toml b/packages/stack-auth/Cargo.toml index fa6d9ffad..b533cad76 100644 --- a/packages/stack-auth/Cargo.toml +++ b/packages/stack-auth/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "stack-auth" description = "Authentication library for CipherStash services" -version = "0.34.1-alpha.5" +version = "0.34.1-alpha.6" edition.workspace = true authors.workspace = true repository.workspace = true diff --git a/packages/stack-profile/CHANGELOG.md b/packages/stack-profile/CHANGELOG.md index b15929010..45fedbe81 100644 --- a/packages/stack-profile/CHANGELOG.md +++ b/packages/stack-profile/CHANGELOG.md @@ -15,6 +15,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 + ## [0.34.0-alpha.1] - 2026-03-04 ### Changed diff --git a/packages/stack-profile/Cargo.toml b/packages/stack-profile/Cargo.toml index a3ee1c82b..2794fc3b8 100644 --- a/packages/stack-profile/Cargo.toml +++ b/packages/stack-profile/Cargo.toml @@ -2,7 +2,7 @@ name = "stack-profile" description = "Centralised ~/.cipherstash profile file management" license-file = "LICENSE" -version = "0.34.1-alpha.5" +version = "0.34.1-alpha.6" edition.workspace = true authors.workspace = true repository.workspace = true From 4b2e1494be78c8e6ca499cac49b880e44cfadfe8 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 19 May 2026 07:14:56 +0000 Subject: [PATCH 213/686] chore: release --- packages/stack-auth/CHANGELOG.md | 1 + packages/stack-auth/Cargo.toml | 2 +- packages/stack-profile/CHANGELOG.md | 1 + packages/stack-profile/Cargo.toml | 2 +- 4 files changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/stack-auth/CHANGELOG.md b/packages/stack-auth/CHANGELOG.md index c16ad4244..6dcf15f22 100644 --- a/packages/stack-auth/CHANGELOG.md +++ b/packages/stack-auth/CHANGELOG.md @@ -1,6 +1,7 @@ + ### Documentation - document why TokenResultPayload uses String diff --git a/packages/stack-auth/Cargo.toml b/packages/stack-auth/Cargo.toml index b533cad76..112b7f834 100644 --- a/packages/stack-auth/Cargo.toml +++ b/packages/stack-auth/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "stack-auth" description = "Authentication library for CipherStash services" -version = "0.34.1-alpha.6" +version = "0.34.1-alpha.7" edition.workspace = true authors.workspace = true repository.workspace = true diff --git a/packages/stack-profile/CHANGELOG.md b/packages/stack-profile/CHANGELOG.md index 45fedbe81..a8e75dfbf 100644 --- a/packages/stack-profile/CHANGELOG.md +++ b/packages/stack-profile/CHANGELOG.md @@ -16,6 +16,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 + ## [0.34.0-alpha.1] - 2026-03-04 ### Changed diff --git a/packages/stack-profile/Cargo.toml b/packages/stack-profile/Cargo.toml index 2794fc3b8..aa46f5e4c 100644 --- a/packages/stack-profile/Cargo.toml +++ b/packages/stack-profile/Cargo.toml @@ -2,7 +2,7 @@ name = "stack-profile" description = "Centralised ~/.cipherstash profile file management" license-file = "LICENSE" -version = "0.34.1-alpha.6" +version = "0.34.1-alpha.7" edition.workspace = true authors.workspace = true repository.workspace = true From 5881742e7981147b836984fd108945fca24a188a Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Fri, 15 May 2026 23:28:25 +1000 Subject: [PATCH 214/686] feat(stack-auth): add TokenStore trait for pluggable token caching MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a small async trait — `load(&self) -> Option` and `save(&self, &Token)` — alongside three reference implementations: - `NoStore`: zero-sized no-op used as the default generic parameter on `AutoRefresh` so the no-external-cache shape stays literally cost-free. - `InMemoryTokenStore`: tokio `Mutex>` round-trip via serde, deliberately not cloning `Token` because `SecretToken`'s `ZeroizeOnDrop` invariant would surprise readers if the store kept the value alive across operations. - `CallbackTokenStore`: generic over two async closures that deal in JSON strings. The on-the-wire form keeps the caller's closure signatures free of `stack-auth` internals — the natural shape for a cookie value, KV blob, or Redis string. Also provides a blanket `impl TokenStore for Arc` so a single store can be shared across multiple strategy instances without juggling references. Native `async fn` in trait (workspace pins Rust 1.90) — no `async_trait` macro. The trait is consumed generically by `AutoRefresh`, so dyn dispatch isn't needed. The `impl Future + Send` return-position desugar is explicit so future-`Send` is part of the contract on native targets. Wiring into `AutoRefresh` and `AccessKeyStrategyBuilder` follows in the next commit. --- packages/stack-auth/src/lib.rs | 2 + packages/stack-auth/src/token_store.rs | 344 +++++++++++++++++++++++++ 2 files changed, 346 insertions(+) create mode 100644 packages/stack-auth/src/token_store.rs diff --git a/packages/stack-auth/src/lib.rs b/packages/stack-auth/src/lib.rs index d1415edb7..443fec1bb 100644 --- a/packages/stack-auth/src/lib.rs +++ b/packages/stack-auth/src/lib.rs @@ -40,6 +40,7 @@ mod oauth_strategy; mod refresher; mod service_token; mod token; +mod token_store; // Filesystem-backed device identity and the interactive device-code flow are // native-only — both pull `stack-profile` (which uses `dirs` + `gethostname`) @@ -61,6 +62,7 @@ pub use service_token::ServiceToken; #[cfg(any(test, feature = "test-utils"))] pub use static_token_strategy::StaticTokenStrategy; pub use token::Token; +pub use token_store::{CallbackTokenStore, InMemoryTokenStore, NoStore, TokenStore}; #[cfg(not(target_arch = "wasm32"))] pub use device_client::{bind_client_device, DeviceClientError}; diff --git a/packages/stack-auth/src/token_store.rs b/packages/stack-auth/src/token_store.rs new file mode 100644 index 000000000..e230c22b1 --- /dev/null +++ b/packages/stack-auth/src/token_store.rs @@ -0,0 +1,344 @@ +//! Pluggable persistence for service tokens. +//! +//! [`AutoRefresh`](crate::auto_refresh::AutoRefresh) consults a [`TokenStore`] +//! on cold start (no in-memory token) and writes back after every successful +//! refresh or initial auth. This lets strategies share a service-token cache +//! across short-lived processes — HTTP-only cookies in Edge Functions, KV +//! stores in Cloudflare Workers, Redis in multi-instance Node services, or a +//! shared cache across the CipherStash Proxy's worker pool. +//! +//! Wire a store onto a strategy via the builder: +//! +//! ```no_run +//! use std::sync::Arc; +//! use stack_auth::{AccessKey, AccessKeyStrategy, InMemoryTokenStore}; +//! use cts_common::Region; +//! +//! let region = Region::aws("ap-southeast-2").unwrap(); +//! let key: AccessKey = "CSAKmyKeyId.myKeySecret".parse().unwrap(); +//! let store = Arc::new(InMemoryTokenStore::new()); +//! let strategy = AccessKeyStrategy::builder(region, key) +//! .with_token_store(store) +//! .build() +//! .unwrap(); +//! ``` +//! +//! For cookie-style storage where the load/save logic lives in the calling +//! request handler, use [`CallbackTokenStore::new`] with two async closures +//! that deal in JSON strings: +//! +//! ```no_run +//! use std::sync::Arc; +//! use stack_auth::CallbackTokenStore; +//! +//! let store = Arc::new(CallbackTokenStore::new( +//! || async { /* read cookie */ None }, +//! |json: String| async move { /* write Set-Cookie header */ }, +//! )); +//! ``` + +use std::future::Future; +use std::sync::Arc; + +use tokio::sync::Mutex; + +use crate::Token; + +/// Pluggable persistent cache for service tokens. +/// +/// Implementations are consulted by `AutoRefresh` whenever it has no +/// in-memory token (cold start), and written to after every successful +/// refresh or initial authentication. Implementations should treat both +/// methods as best-effort — `load` returns [`None`] for "no token, or load +/// failed", `save` is fire-and-forget. The `AutoRefresh` state machine +/// always validates freshness via [`Token::is_usable`] / [`Token::is_expired`] +/// before returning a loaded token, so implementations don't need to. +/// +/// On native targets the trait carries `Send + Sync` bounds so the store can +/// be shared across `tokio::spawn` background work. On wasm32 the bounds are +/// dropped — edge runtimes are single-threaded. +#[cfg(not(target_arch = "wasm32"))] +pub trait TokenStore: Send + Sync { + /// Load the most recently saved token, or `None` if none has been stored + /// (or the load failed). Errors are swallowed — the calling state machine + /// falls back to fresh authentication when this returns `None`. + fn load(&self) -> impl Future> + Send; + + /// Persist a token after a successful refresh or initial authentication. + /// Best-effort — implementations should log on failure rather than + /// returning an error, matching [`Refresher::save`](crate::refresher). + fn save(&self, token: &Token) -> impl Future + Send; +} + +#[cfg(target_arch = "wasm32")] +pub trait TokenStore { + fn load(&self) -> impl Future>; + fn save(&self, token: &Token) -> impl Future; +} + +/// Forward [`TokenStore`] through `Arc`, so callers can share one store +/// across multiple strategy instances (e.g. an Edge Function pool, or a +/// worker pool inside CipherStash Proxy) without juggling references. +#[cfg(not(target_arch = "wasm32"))] +impl TokenStore for Arc { + fn load(&self) -> impl Future> + Send { + (**self).load() + } + + fn save(&self, token: &Token) -> impl Future + Send { + (**self).save(token) + } +} + +#[cfg(target_arch = "wasm32")] +impl TokenStore for Arc { + fn load(&self) -> impl Future> { + (**self).load() + } + + fn save(&self, token: &Token) -> impl Future { + (**self).save(token) + } +} + +/// Default placeholder used when no [`TokenStore`] has been configured. +/// +/// `load` always returns `None` and `save` is a no-op — the calling state +/// machine sees the same behaviour as the original no-external-cache shape. +/// Zero-sized; carries no per-instance cost. +#[derive(Debug, Default, Clone, Copy)] +pub struct NoStore; + +impl TokenStore for NoStore { + async fn load(&self) -> Option { + None + } + + async fn save(&self, _token: &Token) {} +} + +/// In-process token store. Useful for tests and as a shared cache across +/// multiple strategy instances in the same process (e.g. a worker pool). +/// +/// Internally stores the JSON-serialised form of the token. The +/// [`SecretToken`](crate::SecretToken) wrapped inside [`Token`] is +/// [`ZeroizeOnDrop`](zeroize::ZeroizeOnDrop), so we deliberately don't clone +/// the in-memory `Token` value — round-tripping through serde gives us a +/// fresh `SecretToken` on each `load` without violating that invariant. +pub struct InMemoryTokenStore { + state: Mutex>, +} + +impl InMemoryTokenStore { + /// Create a new, empty in-memory token store. + pub fn new() -> Self { + Self { + state: Mutex::new(None), + } + } +} + +impl Default for InMemoryTokenStore { + fn default() -> Self { + Self::new() + } +} + +impl TokenStore for InMemoryTokenStore { + async fn load(&self) -> Option { + let guard = self.state.lock().await; + let json = guard.as_ref()?; + serde_json::from_str(json).ok() + } + + async fn save(&self, token: &Token) { + let Ok(json) = serde_json::to_string(token) else { + tracing::warn!("InMemoryTokenStore: failed to serialise token"); + return; + }; + let mut guard = self.state.lock().await; + *guard = Some(json); + } +} + +/// Token store backed by user-supplied `load` and `save` async closures. +/// +/// Closures deal in JSON strings — the on-the-wire form of [`Token`] — not +/// the `Token` type itself. This keeps the caller's signatures free of +/// `stack-auth` internals and matches the natural shape of common storage +/// substrates: a cookie value, a KV blob, a Redis string. +/// +/// The closure return types are generic so async blocks / `async ||` +/// closures / `async fn` adapters all compose without boxing. +pub struct CallbackTokenStore { + load: L, + save: S, +} + +impl CallbackTokenStore { + /// Build a token store from a `load` closure (returns the stored JSON, or + /// `None` if nothing is cached) and a `save` closure (persists the JSON). + /// + /// See the module-level documentation for an example. + pub fn new(load: L, save: S) -> Self { + Self { load, save } + } +} + +#[cfg(not(target_arch = "wasm32"))] +impl TokenStore for CallbackTokenStore +where + L: Fn() -> LF + Send + Sync, + LF: Future> + Send, + S: Fn(String) -> SF + Send + Sync, + SF: Future + Send, +{ + async fn load(&self) -> Option { + let json = (self.load)().await?; + match serde_json::from_str(&json) { + Ok(token) => Some(token), + Err(err) => { + tracing::warn!(%err, "CallbackTokenStore: load returned invalid JSON"); + None + } + } + } + + async fn save(&self, token: &Token) { + let Ok(json) = serde_json::to_string(token) else { + tracing::warn!("CallbackTokenStore: failed to serialise token"); + return; + }; + (self.save)(json).await; + } +} + +#[cfg(target_arch = "wasm32")] +impl TokenStore for CallbackTokenStore +where + L: Fn() -> LF, + LF: Future>, + S: Fn(String) -> SF, + SF: Future, +{ + async fn load(&self) -> Option { + let json = (self.load)().await?; + match serde_json::from_str(&json) { + Ok(token) => Some(token), + Err(err) => { + tracing::warn!(%err, "CallbackTokenStore: load returned invalid JSON"); + None + } + } + } + + async fn save(&self, token: &Token) { + let Ok(json) = serde_json::to_string(token) else { + tracing::warn!("CallbackTokenStore: failed to serialise token"); + return; + }; + (self.save)(json).await; + } +} + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod tests { + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::sync::Arc; + + use crate::SecretToken; + + use super::*; + + fn dummy_token(expires_at: u64) -> Token { + Token { + access_token: SecretToken::new("dummy-access".to_string()), + refresh_token: None, + token_type: "Bearer".to_string(), + expires_at, + region: None, + client_id: None, + device_instance_id: None, + } + } + + #[tokio::test] + async fn in_memory_load_returns_none_when_empty() { + let store = InMemoryTokenStore::new(); + assert!(store.load().await.is_none()); + } + + #[tokio::test] + async fn in_memory_round_trip_preserves_expires_at() { + let store = InMemoryTokenStore::new(); + store.save(&dummy_token(4_000_000_000)).await; + let loaded = store.load().await.unwrap(); + assert_eq!(loaded.expires_at(), 4_000_000_000); + assert_eq!(loaded.token_type(), "Bearer"); + } + + #[tokio::test] + async fn in_memory_save_overwrites_previous() { + let store = InMemoryTokenStore::new(); + store.save(&dummy_token(1_000_000_000)).await; + store.save(&dummy_token(2_000_000_000)).await; + assert_eq!(store.load().await.unwrap().expires_at(), 2_000_000_000); + } + + #[tokio::test] + async fn callback_store_invokes_load_closure_each_call() { + let calls = Arc::new(AtomicUsize::new(0)); + let calls_clone = Arc::clone(&calls); + let store = CallbackTokenStore::new( + move || { + let calls = Arc::clone(&calls_clone); + async move { + let n = calls.fetch_add(1, Ordering::SeqCst); + if n == 0 { + None + } else { + Some(serde_json::to_string(&dummy_token(4_000_000_000)).unwrap()) + } + } + }, + |_json: String| async move {}, + ); + + assert!(store.load().await.is_none()); + assert_eq!(calls.load(Ordering::SeqCst), 1); + + let loaded = store.load().await.unwrap(); + assert_eq!(loaded.expires_at(), 4_000_000_000); + assert_eq!(calls.load(Ordering::SeqCst), 2); + } + + #[tokio::test] + async fn callback_store_forwards_serialised_token_to_save_closure() { + let captured = Arc::new(Mutex::new(None::)); + let captured_clone = Arc::clone(&captured); + let store = CallbackTokenStore::new( + || async { None }, + move |json: String| { + let captured = Arc::clone(&captured_clone); + async move { + *captured.lock().await = Some(json); + } + }, + ); + + store.save(&dummy_token(4_000_000_000)).await; + let json = captured.lock().await.clone().unwrap(); + assert!(json.contains("\"expires_at\":4000000000")); + assert!(json.contains("\"token_type\":\"Bearer\"")); + } + + #[tokio::test] + async fn callback_store_ignores_invalid_json_on_load() { + let store = CallbackTokenStore::new( + || async { Some("not valid json".to_string()) }, + |_json: String| async move {}, + ); + assert!(store.load().await.is_none()); + } +} From d26ed464038dae60565b9c85fb0f28f86d655e0f Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Fri, 15 May 2026 23:28:42 +1000 Subject: [PATCH 215/686] feat(stack-auth): wire TokenStore into AutoRefresh + AccessKeyStrategy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `AutoRefresh` becomes `AutoRefresh` — generic over the store type, defaulted to the zero-sized `NoStore`. The store is consulted once on cold start (before falling through to `initial_auth`) and written to after every successful refresh in all three paths (`initial_auth`, `refresh_blocking`, `refresh_non_blocking`). The in-memory state machine sees a loaded token exactly the same way it sees a fresh one — `Token::is_usable` / `is_expired` decide what happens next, so no new freshness logic is needed. `AccessKeyStrategyBuilder::with_token_store(store)` returns a builder with the store type erased into the chain. `AccessKeyStrategy` carries the store through to the `AuthStrategy` impl. The default `AccessKeyStrategy::new` and `AccessKeyStrategy::builder` paths return `AccessKeyStrategy` so existing callers compile unchanged. The four integration tests in `access_key_refresher::tests` cover the behaviours that matter: - A fresh token in the store short-circuits HTTP on cold start (mock auth endpoint returns 500 to fail loudly if hit). - An empty store + successful initial auth writes the new token back via `store.save()`. - Two strategy instances sharing one `Arc` — the second one never calls HTTP, even when the auth endpoint mock is swapped to a hard error before the second `get_token`. - A stale token in the store triggers refresh (not just "return what's loaded") and the new token is persisted back. `OAuthStrategy`'s existing `ProfileStore` path is out of scope; it keeps its native-only wiring untouched. --- .../stack-auth/src/access_key_refresher.rs | 131 +++++++++++++++++- .../stack-auth/src/access_key_strategy.rs | 47 ++++++- packages/stack-auth/src/auto_refresh.rs | 59 +++++--- 3 files changed, 212 insertions(+), 25 deletions(-) diff --git a/packages/stack-auth/src/access_key_refresher.rs b/packages/stack-auth/src/access_key_refresher.rs index 23b0ce7a9..24897bec5 100644 --- a/packages/stack-auth/src/access_key_refresher.rs +++ b/packages/stack-auth/src/access_key_refresher.rs @@ -104,6 +104,7 @@ struct AuthoriseResponse { mod tests { use super::*; use crate::auto_refresh::{AutoRefresh, AutoRefreshError}; + use crate::TokenStore; use mocktail::prelude::*; use std::sync::Arc; use std::time::{SystemTime, UNIX_EPOCH}; @@ -127,7 +128,7 @@ mod tests { server.url(""), Some("test-audience".to_string()), ); - AutoRefresh::new(refresher) + AutoRefresh::with_store(refresher, crate::NoStore) } fn make_expired_token(access: &str) -> Token { @@ -147,6 +148,23 @@ mod tests { } } + fn make_fresh_token(access: &str) -> Token { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs(); + + Token { + access_token: SecretToken::new(access), + token_type: "Bearer".to_string(), + expires_at: now + 3600, // 1h ahead, not in the expiry-leeway window + refresh_token: None, + region: None, + client_id: None, + device_instance_id: None, + } + } + // ---- Initial auth tests ---- #[tokio::test] @@ -189,6 +207,115 @@ mod tests { assert_eq!(token2.as_str(), "new-token"); } + // ---- TokenStore integration tests ---- + + #[tokio::test] + async fn test_loads_token_from_store_on_cold_start_no_http() { + // Mock returns 500 so we know the test fails loudly if the strategy + // ever calls authorise — but we expect it not to, since the store + // already holds a fresh token. + let mut mocks = MockSet::new(); + mocks.mock(|when, then| { + when.post().path("/api/authorise"); + then.internal_server_error() + .json(serde_json::json!({"error": "should not be called"})); + }); + let server = start_server(mocks).await; + + let store = Arc::new(crate::InMemoryTokenStore::new()); + store.save(&make_fresh_token("from-store")).await; + + let refresher = + AccessKeyRefresher::new(SecretToken::new("test-access-key"), server.url(""), None); + let strategy = AutoRefresh::with_store(refresher, Arc::clone(&store)); + + let token = strategy.get_token().await.unwrap(); + assert_eq!(token.as_str(), "from-store"); + } + + #[tokio::test] + async fn test_persists_token_to_store_after_initial_auth() { + let mut mocks = MockSet::new(); + mocks.mock(|when, then| { + when.post().path("/api/authorise"); + then.json(auth_response_json("freshly-minted", 3600)); + }); + let server = start_server(mocks).await; + + let store = Arc::new(crate::InMemoryTokenStore::new()); + assert!(store.load().await.is_none()); + + let refresher = + AccessKeyRefresher::new(SecretToken::new("test-access-key"), server.url(""), None); + let strategy = AutoRefresh::with_store(refresher, Arc::clone(&store)); + + let token = strategy.get_token().await.unwrap(); + assert_eq!(token.as_str(), "freshly-minted"); + + // After initial auth, the store should hold the new token. + let saved = store.load().await.unwrap(); + assert_eq!(saved.access_token().as_str(), "freshly-minted"); + } + + #[tokio::test] + async fn test_two_strategies_sharing_store_skip_http_on_second_cold_start() { + // Allow exactly one /api/authorise call; the second strategy must hit + // the store, not the server. + let mut mocks = MockSet::new(); + mocks.mock(|when, then| { + when.post().path("/api/authorise"); + then.json(auth_response_json("shared-cache-token", 3600)); + }); + let server = start_server(mocks).await; + let store = Arc::new(crate::InMemoryTokenStore::new()); + + // First strategy — does the HTTP exchange and writes to the store. + let refresher_a = + AccessKeyRefresher::new(SecretToken::new("test-access-key"), server.url(""), None); + let strategy_a = AutoRefresh::with_store(refresher_a, Arc::clone(&store)); + let token_a = strategy_a.get_token().await.unwrap(); + assert_eq!(token_a.as_str(), "shared-cache-token"); + + // Replace the mock so any second call fails the test loudly. + server.mocks().clear(); + server.mocks().mock(|when, then| { + when.post().path("/api/authorise"); + then.internal_server_error() + .json(serde_json::json!({"error": "second strategy must hit store"})); + }); + + // Second strategy — fresh instance, same store. Should load from store. + let refresher_b = + AccessKeyRefresher::new(SecretToken::new("test-access-key"), server.url(""), None); + let strategy_b = AutoRefresh::with_store(refresher_b, Arc::clone(&store)); + let token_b = strategy_b.get_token().await.unwrap(); + assert_eq!(token_b.as_str(), "shared-cache-token"); + } + + #[tokio::test] + async fn test_refreshes_when_store_has_expired_token() { + let mut mocks = MockSet::new(); + mocks.mock(|when, then| { + when.post().path("/api/authorise"); + then.json(auth_response_json("refreshed-after-store-miss", 3600)); + }); + let server = start_server(mocks).await; + + let store = Arc::new(crate::InMemoryTokenStore::new()); + store.save(&make_expired_token("stale-from-store")).await; + + let refresher = + AccessKeyRefresher::new(SecretToken::new("test-access-key"), server.url(""), None); + let strategy = AutoRefresh::with_store(refresher, Arc::clone(&store)); + + let token = strategy.get_token().await.unwrap(); + assert_eq!(token.as_str(), "refreshed-after-store-miss"); + + // Store should now hold the refreshed token, not the stale one. + let saved = store.load().await.unwrap(); + assert_eq!(saved.access_token().as_str(), "refreshed-after-store-miss"); + } + // ---- Refresh on expiry tests ---- #[tokio::test] @@ -438,7 +565,7 @@ mod tests { let refresher = AccessKeyRefresher::new(SecretToken::new("test-access-key"), base_url, None); - let strategy = Arc::new(AutoRefresh::new(refresher)); + let strategy = Arc::new(AutoRefresh::with_store(refresher, crate::NoStore)); let start = Instant::now(); let mut handles = Vec::with_capacity(CONCURRENCY); diff --git a/packages/stack-auth/src/access_key_strategy.rs b/packages/stack-auth/src/access_key_strategy.rs index 3339eee41..62ba58579 100644 --- a/packages/stack-auth/src/access_key_strategy.rs +++ b/packages/stack-auth/src/access_key_strategy.rs @@ -3,6 +3,7 @@ use cts_common::{CtsServiceDiscovery, Region, ServiceDiscovery}; use crate::access_key::AccessKey; use crate::access_key_refresher::AccessKeyRefresher; use crate::auto_refresh::AutoRefresh; +use crate::token_store::{NoStore, TokenStore}; use crate::{ensure_trailing_slash, AuthError, AuthStrategy, SecretToken, ServiceToken}; /// An [`AuthStrategy`] that uses a static access key to authenticate. @@ -11,6 +12,11 @@ use crate::{ensure_trailing_slash, AuthError, AuthStrategy, SecretToken, Service /// the server. Subsequent calls return the cached token until it expires, at /// which point re-authentication happens automatically. /// +/// When constructed via [`AccessKeyStrategyBuilder::with_token_store`], the +/// strategy also persists tokens through an external [`TokenStore`] so that +/// short-lived strategy instances (e.g. one per Edge Function request) can +/// share a cache and avoid re-authenticating every cold start. +/// /// # Example /// /// ```no_run @@ -21,8 +27,8 @@ use crate::{ensure_trailing_slash, AuthError, AuthStrategy, SecretToken, Service /// let key: AccessKey = "CSAKmyKeyId.myKeySecret".parse().unwrap(); /// let strategy = AccessKeyStrategy::new(region, key).unwrap(); /// ``` -pub struct AccessKeyStrategy { - inner: AutoRefresh, +pub struct AccessKeyStrategy { + inner: AutoRefresh, } impl AccessKeyStrategy { @@ -54,11 +60,12 @@ impl AccessKeyStrategy { access_key: access_key.into_secret_token(), audience: None, base_url_override: None, + token_store: NoStore, } } } -impl AuthStrategy for &AccessKeyStrategy { +impl AuthStrategy for &AccessKeyStrategy { async fn get_token(self) -> Result { Ok(self.inner.get_token().await?) } @@ -67,14 +74,15 @@ impl AuthStrategy for &AccessKeyStrategy { /// Builder for [`AccessKeyStrategy`]. /// /// Created via [`AccessKeyStrategy::builder`]. -pub struct AccessKeyStrategyBuilder { +pub struct AccessKeyStrategyBuilder { region: Region, access_key: SecretToken, audience: Option, base_url_override: Option, + token_store: S, } -impl AccessKeyStrategyBuilder { +impl AccessKeyStrategyBuilder { /// Set the audience for token requests. pub fn audience(mut self, audience: impl Into) -> Self { self.audience = Some(audience.into()); @@ -90,11 +98,36 @@ impl AccessKeyStrategyBuilder { self } + /// Wire an external [`TokenStore`] into the strategy. + /// + /// On every call to [`get_token`](AuthStrategy::get_token), if no token is + /// cached in memory, the store is consulted before falling back to + /// re-authenticating with the access key. After every successful refresh + /// or initial auth, the new token is written back to the store. Use this + /// from short-lived strategy instances (Edge Functions, Workers, proxy + /// worker pools) to share a service-token cache across processes. + /// + /// Returns a new builder with the store type erased into the chain — see + /// [`InMemoryTokenStore`](crate::InMemoryTokenStore) and + /// [`CallbackTokenStore`](crate::CallbackTokenStore) for ready-made + /// implementations. + pub fn with_token_store(self, store: T) -> AccessKeyStrategyBuilder { + AccessKeyStrategyBuilder { + region: self.region, + access_key: self.access_key, + audience: self.audience, + base_url_override: self.base_url_override, + token_store: store, + } + } +} + +impl AccessKeyStrategyBuilder { /// Build the [`AccessKeyStrategy`]. /// /// Resolves the base URL via service discovery unless overridden with /// `base_url` (available when the `test-utils` feature is enabled). - pub fn build(self) -> Result { + pub fn build(self) -> Result, AuthError> { let base_url = match self.base_url_override { Some(url) => url, None => crate::cts_base_url_from_env()? @@ -106,7 +139,7 @@ impl AccessKeyStrategyBuilder { self.audience, ); Ok(AccessKeyStrategy { - inner: AutoRefresh::new(refresher), + inner: AutoRefresh::with_store(refresher, self.token_store), }) } } diff --git a/packages/stack-auth/src/auto_refresh.rs b/packages/stack-auth/src/auto_refresh.rs index a00e654df..72520ba6b 100644 --- a/packages/stack-auth/src/auto_refresh.rs +++ b/packages/stack-auth/src/auto_refresh.rs @@ -3,6 +3,7 @@ use std::sync::atomic::{AtomicBool, Ordering}; use tokio::sync::{Mutex, MutexGuard, Notify}; use crate::refresher::Refresher; +use crate::token_store::{NoStore, TokenStore}; use crate::{ServiceToken, Token}; /// Internal errors from [`AutoRefresh::get_token`]. @@ -33,13 +34,18 @@ impl From for crate::AuthError { } /// Caches a token in memory and uses a [`Refresher`] to re-authenticate -/// or refresh before expiry. +/// or refresh before expiry, optionally backed by an external [`TokenStore`] +/// for persistence across short-lived strategy instances. /// /// See the [crate-level documentation](crate#token-refresh) for a full /// description of the concurrency model and flow diagram. -pub(crate) struct AutoRefresh { +pub(crate) struct AutoRefresh { refresher: R, state: Mutex, + /// External persistence. Consulted on cold start and written to after + /// every successful refresh / initial auth. Defaults to [`NoStore`] (a + /// zero-sized no-op) when no external store is configured. + store: S, /// Set to `true` while a refresh HTTP call is in-flight. /// /// Stored as an [`AtomicBool`] rather than inside [`State`] so that @@ -95,40 +101,58 @@ impl State { } } -impl AutoRefresh { - /// Create a new `AutoRefresh` with no initial token. +impl AutoRefresh { + /// Create a new `AutoRefresh` with a pre-loaded token and no external store. /// - /// The first call to `get_token` will attempt initial authentication via - /// `try_credential(None)` → `refresh()`. Use this for refreshers that can - /// self-authenticate (e.g. access keys). - pub(crate) fn new(refresher: R) -> Self { + /// Use this for refreshers that cannot self-authenticate (e.g. OAuth, + /// which needs a refresh token from a prior device code flow). + pub(crate) fn with_token(refresher: R, token: Token) -> Self { Self { refresher, - state: Mutex::new(State { token: None }), + state: Mutex::new(State { token: Some(token) }), + store: NoStore, refresh_in_progress: AtomicBool::new(false), refresh_notify: Notify::new(), } } +} - /// Create a new `AutoRefresh` with a pre-loaded token. +impl AutoRefresh { + /// Create a new `AutoRefresh` backed by `store` and no in-memory token. /// - /// Use this for refreshers that cannot self-authenticate (e.g. OAuth, - /// which needs a refresh token from a prior device code flow). - pub(crate) fn with_token(refresher: R, token: Token) -> Self { + /// On the first `get_token` call the store is consulted before falling + /// through to initial authentication via `try_credential(None)`; every + /// successful refresh writes the new token back via `store.save()`. Pass + /// [`NoStore`] for the no-external-cache case — it's the default and + /// elides to a zero-cost no-op. + pub(crate) fn with_store(refresher: R, store: S) -> Self { Self { refresher, - state: Mutex::new(State { token: Some(token) }), + state: Mutex::new(State { token: None }), + store, refresh_in_progress: AtomicBool::new(false), refresh_notify: Notify::new(), } } } -impl AutoRefresh { +impl AutoRefresh { /// Retrieve a valid access token, refreshing or re-authenticating as needed. pub(crate) async fn get_token(&self) -> Result { let mut state = self.state.lock().await; + if state.token.is_none() { + // Cold start — consult the external store before falling through to + // initial auth. If the store has a still-usable token, the existing + // state machine below either returns it directly (fresh), kicks off + // a background refresh (expiring but usable), or refreshes blocking + // (fully expired). If the store is empty (e.g. `NoStore`) or load + // returns `None`, fall into the original `initial_auth` HTTP path. + if let Some(loaded) = self.store.load().await { + state.token = Some(loaded); + } + } + if state.token.is_none() { return self.initial_auth(&mut state).await; } @@ -171,6 +195,7 @@ impl AutoRefresh { Ok(new_token) => { guard.defuse(); self.refresher.save(&new_token); + self.store.save(&new_token).await; let service_token = ServiceToken::new(new_token.access_token().clone()); state.token = Some(new_token); self.refresh_in_progress.store(false, Ordering::Release); @@ -235,6 +260,7 @@ impl AutoRefresh { Ok(new_token) => { guard.defuse(); self.refresher.save(&new_token); + self.store.save(&new_token).await; let mut state = self.state.lock().await; state.token = Some(new_token); self.refresh_in_progress.store(false, Ordering::Release); @@ -276,6 +302,7 @@ impl AutoRefresh { Ok(new_token) => { guard.defuse(); self.refresher.save(&new_token); + self.store.save(&new_token).await; let service_token = ServiceToken::new(new_token.access_token().clone()); state.token = Some(new_token); self.refresh_in_progress.store(false, Ordering::Release); @@ -381,7 +408,7 @@ mod tests { "ap-southeast-2.aws", None, ); - let strategy = AutoRefresh::new(refresher); + let strategy = AutoRefresh::with_store(refresher, NoStore); let err = strategy.get_token().await.unwrap_err(); From 09a9035cbd8a65b3231fcc09b8704a780b0dc58b Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Sat, 16 May 2026 00:10:01 +1000 Subject: [PATCH 216/686] docs(stack-auth): annotate None literal in TokenStore doctest `None` in a bare `|| async { None }` closure can't be inferred because no use site constrains the `Option` parameter, so the example failed to compile under `cargo test --doc`. Annotate as `None::` to match `CallbackTokenStore`'s expected `Fn() -> Future>` load shape, and rename the save closure's parameter to `_json` since the doc body discards it. --- packages/stack-auth/src/token_store.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/stack-auth/src/token_store.rs b/packages/stack-auth/src/token_store.rs index e230c22b1..bf025534e 100644 --- a/packages/stack-auth/src/token_store.rs +++ b/packages/stack-auth/src/token_store.rs @@ -32,8 +32,8 @@ //! use stack_auth::CallbackTokenStore; //! //! let store = Arc::new(CallbackTokenStore::new( -//! || async { /* read cookie */ None }, -//! |json: String| async move { /* write Set-Cookie header */ }, +//! || async { /* read cookie */ None:: }, +//! |_json: String| async move { /* write Set-Cookie header */ }, //! )); //! ``` From 2ed5f5d9d697d88268b6ad2eef2888f91e2ac0b9 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Sat, 16 May 2026 00:18:16 +1000 Subject: [PATCH 217/686] refactor(stack-auth): release state mutex during TokenStore load; tighten comments `AutoRefresh::get_token` was holding `state.lock()` across the `store.load().await` call, serialising every concurrent caller behind whatever backend the user wired in. For `NoStore`/`InMemoryTokenStore` the wait is microseconds; for a `CallbackTokenStore` reading a cookie, KV blob, or Redis string it can be tens-to-hundreds of ms. Drop the lock during the load, re-acquire, double-check `state.token.is_none()` so we don't clobber a token another caller populated while we were awaiting. Surfaced by /simplify efficiency review. Also folded the `make_expired_token` / `make_fresh_token` test helpers into a single `make_token(access, expires_in_secs)` since they only differed by the offset. And trimmed three narration-only comments flagged by the /simplify quality review: the `store` field doc on `AutoRefresh`, the `Arc` blanket-impl preamble, and the `NoStore` docstring. --- .../stack-auth/src/access_key_refresher.rs | 26 +++++++------------ packages/stack-auth/src/auto_refresh.rs | 22 ++++++++-------- packages/stack-auth/src/token_store.rs | 12 +++------ 3 files changed, 25 insertions(+), 35 deletions(-) diff --git a/packages/stack-auth/src/access_key_refresher.rs b/packages/stack-auth/src/access_key_refresher.rs index 24897bec5..4df77e2b0 100644 --- a/packages/stack-auth/src/access_key_refresher.rs +++ b/packages/stack-auth/src/access_key_refresher.rs @@ -131,7 +131,10 @@ mod tests { AutoRefresh::with_store(refresher, crate::NoStore) } - fn make_expired_token(access: &str) -> Token { + /// Build a `Token` whose `expires_at` is `expires_in_secs` from now — + /// pass `0` for "already expired", `3600` for "fresh, well outside the + /// 90s expiry-leeway window". + fn make_token(access: &str, expires_in_secs: u64) -> Token { let now = SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap() @@ -140,7 +143,7 @@ mod tests { Token { access_token: SecretToken::new(access), token_type: "Bearer".to_string(), - expires_at: now, // already expired + expires_at: now + expires_in_secs, refresh_token: None, region: None, client_id: None, @@ -148,21 +151,12 @@ mod tests { } } - fn make_fresh_token(access: &str) -> Token { - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap() - .as_secs(); + fn make_expired_token(access: &str) -> Token { + make_token(access, 0) + } - Token { - access_token: SecretToken::new(access), - token_type: "Bearer".to_string(), - expires_at: now + 3600, // 1h ahead, not in the expiry-leeway window - refresh_token: None, - region: None, - client_id: None, - device_instance_id: None, - } + fn make_fresh_token(access: &str) -> Token { + make_token(access, 3600) } // ---- Initial auth tests ---- diff --git a/packages/stack-auth/src/auto_refresh.rs b/packages/stack-auth/src/auto_refresh.rs index 72520ba6b..44851ee9a 100644 --- a/packages/stack-auth/src/auto_refresh.rs +++ b/packages/stack-auth/src/auto_refresh.rs @@ -42,9 +42,6 @@ impl From for crate::AuthError { pub(crate) struct AutoRefresh { refresher: R, state: Mutex, - /// External persistence. Consulted on cold start and written to after - /// every successful refresh / initial auth. Defaults to [`NoStore`] (a - /// zero-sized no-op) when no external store is configured. store: S, /// Set to `true` while a refresh HTTP call is in-flight. /// @@ -142,14 +139,17 @@ impl AutoRefresh { let mut state = self.state.lock().await; if state.token.is_none() { - // Cold start — consult the external store before falling through to - // initial auth. If the store has a still-usable token, the existing - // state machine below either returns it directly (fresh), kicks off - // a background refresh (expiring but usable), or refreshes blocking - // (fully expired). If the store is empty (e.g. `NoStore`) or load - // returns `None`, fall into the original `initial_auth` HTTP path. - if let Some(loaded) = self.store.load().await { - state.token = Some(loaded); + // Drop the lock for the store read so a slow user-supplied backend + // (cookie, KV, Redis) doesn't serialise concurrent `get_token` + // callers. Re-acquire and double-check `state.token.is_none()` in + // case another caller populated it while we awaited. + drop(state); + let loaded = self.store.load().await; + state = self.state.lock().await; + if state.token.is_none() { + if let Some(t) = loaded { + state.token = Some(t); + } } } diff --git a/packages/stack-auth/src/token_store.rs b/packages/stack-auth/src/token_store.rs index bf025534e..42a849d82 100644 --- a/packages/stack-auth/src/token_store.rs +++ b/packages/stack-auth/src/token_store.rs @@ -76,9 +76,8 @@ pub trait TokenStore { fn save(&self, token: &Token) -> impl Future; } -/// Forward [`TokenStore`] through `Arc`, so callers can share one store -/// across multiple strategy instances (e.g. an Edge Function pool, or a -/// worker pool inside CipherStash Proxy) without juggling references. +/// Forward [`TokenStore`] through `Arc` so one store can back many strategy +/// instances (Edge Function pool, CipherStash Proxy worker pool, etc). #[cfg(not(target_arch = "wasm32"))] impl TokenStore for Arc { fn load(&self) -> impl Future> + Send { @@ -101,11 +100,8 @@ impl TokenStore for Arc { } } -/// Default placeholder used when no [`TokenStore`] has been configured. -/// -/// `load` always returns `None` and `save` is a no-op — the calling state -/// machine sees the same behaviour as the original no-external-cache shape. -/// Zero-sized; carries no per-instance cost. +/// Zero-sized default for `AutoRefresh` — `load` returns +/// `None`, `save` is a no-op. Carries no per-instance cost. #[derive(Debug, Default, Clone, Copy)] pub struct NoStore; From 271ce37bb478966a59bbe4a414a05fa917a64ecf Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Sat, 16 May 2026 00:30:22 +1000 Subject: [PATCH 218/686] fix(stack-auth): zeroize JSON-serialised tokens; add assertion messages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two findings from skill-based reviews of PR cipherstash/cipherstash-suite#1958. ## rust-security: secrets leaked into unprotected `String`s `SecretToken` derives `#[serde(transparent)] serde::Serialize`, so `serde_json::to_string(token)` materialises the bearer credential verbatim into a plain `String` with no `ZeroizeOnDrop` protection. Fixes: - `InMemoryTokenStore.state: Mutex>` becomes `Mutex>>` so the stored JSON is wiped on overwrite and on store drop. - `CallbackTokenStore::load` wraps the user-supplied string in `Zeroizing` before deserialising, so the buffer is wiped after the load returns regardless of whether it parsed. - `CallbackTokenStore::save` cannot zeroize once the value crosses into the user's closure — documented this honestly in the type's docstring along with a pointer to the planned encrypted-decorator follow-up. - Dropped `%err` from the `load` warn-log: `serde_json::Error`'s `Display` can include byte positions, which would leak partial token content if the input failed mid-parse. ## rust-testing: missing assertion messages The skill mandates a descriptive message on every `assert!`, `assert_eq!`, and `assert_ne!`. The four new integration tests in `access_key_refresher.rs` and the six unit tests in `token_store.rs` all lacked them — failures showed values without context. Added messages explaining what the assertion is checking and, where helpful, what the failure means in scenario terms (e.g. "cold-start should return the token loaded from the store, not call HTTP"). Also replaced `.unwrap()` on follow-up reads with `.expect("descriptive context")` so unwrap failures self-explain. --- .../stack-auth/src/access_key_refresher.rs | 57 ++++++-- packages/stack-auth/src/token_store.rs | 123 +++++++++++++----- 2 files changed, 137 insertions(+), 43 deletions(-) diff --git a/packages/stack-auth/src/access_key_refresher.rs b/packages/stack-auth/src/access_key_refresher.rs index 4df77e2b0..637130f2a 100644 --- a/packages/stack-auth/src/access_key_refresher.rs +++ b/packages/stack-auth/src/access_key_refresher.rs @@ -224,7 +224,11 @@ mod tests { let strategy = AutoRefresh::with_store(refresher, Arc::clone(&store)); let token = strategy.get_token().await.unwrap(); - assert_eq!(token.as_str(), "from-store"); + assert_eq!( + token.as_str(), + "from-store", + "cold-start should return the token loaded from the store, not call HTTP" + ); } #[tokio::test] @@ -237,18 +241,32 @@ mod tests { let server = start_server(mocks).await; let store = Arc::new(crate::InMemoryTokenStore::new()); - assert!(store.load().await.is_none()); + assert!( + store.load().await.is_none(), + "store should be empty before initial auth" + ); let refresher = AccessKeyRefresher::new(SecretToken::new("test-access-key"), server.url(""), None); let strategy = AutoRefresh::with_store(refresher, Arc::clone(&store)); let token = strategy.get_token().await.unwrap(); - assert_eq!(token.as_str(), "freshly-minted"); + assert_eq!( + token.as_str(), + "freshly-minted", + "initial auth should return the newly issued token" + ); // After initial auth, the store should hold the new token. - let saved = store.load().await.unwrap(); - assert_eq!(saved.access_token().as_str(), "freshly-minted"); + let saved = store + .load() + .await + .expect("store should hold a token after initial auth"); + assert_eq!( + saved.access_token().as_str(), + "freshly-minted", + "store should hold the same token initial auth returned" + ); } #[tokio::test] @@ -268,7 +286,11 @@ mod tests { AccessKeyRefresher::new(SecretToken::new("test-access-key"), server.url(""), None); let strategy_a = AutoRefresh::with_store(refresher_a, Arc::clone(&store)); let token_a = strategy_a.get_token().await.unwrap(); - assert_eq!(token_a.as_str(), "shared-cache-token"); + assert_eq!( + token_a.as_str(), + "shared-cache-token", + "first strategy should mint a fresh token via HTTP" + ); // Replace the mock so any second call fails the test loudly. server.mocks().clear(); @@ -283,7 +305,11 @@ mod tests { AccessKeyRefresher::new(SecretToken::new("test-access-key"), server.url(""), None); let strategy_b = AutoRefresh::with_store(refresher_b, Arc::clone(&store)); let token_b = strategy_b.get_token().await.unwrap(); - assert_eq!(token_b.as_str(), "shared-cache-token"); + assert_eq!( + token_b.as_str(), + "shared-cache-token", + "second strategy should return the same token via the shared store, not the failing mock" + ); } #[tokio::test] @@ -303,11 +329,22 @@ mod tests { let strategy = AutoRefresh::with_store(refresher, Arc::clone(&store)); let token = strategy.get_token().await.unwrap(); - assert_eq!(token.as_str(), "refreshed-after-store-miss"); + assert_eq!( + token.as_str(), + "refreshed-after-store-miss", + "expired store entry should trigger refresh, not be returned as-is" + ); // Store should now hold the refreshed token, not the stale one. - let saved = store.load().await.unwrap(); - assert_eq!(saved.access_token().as_str(), "refreshed-after-store-miss"); + let saved = store + .load() + .await + .expect("store should still hold a token after refresh"); + assert_eq!( + saved.access_token().as_str(), + "refreshed-after-store-miss", + "store should be overwritten with the refreshed token" + ); } // ---- Refresh on expiry tests ---- diff --git a/packages/stack-auth/src/token_store.rs b/packages/stack-auth/src/token_store.rs index 42a849d82..87a19315d 100644 --- a/packages/stack-auth/src/token_store.rs +++ b/packages/stack-auth/src/token_store.rs @@ -41,6 +41,7 @@ use std::future::Future; use std::sync::Arc; use tokio::sync::Mutex; +use zeroize::Zeroizing; use crate::Token; @@ -116,13 +117,14 @@ impl TokenStore for NoStore { /// In-process token store. Useful for tests and as a shared cache across /// multiple strategy instances in the same process (e.g. a worker pool). /// -/// Internally stores the JSON-serialised form of the token. The +/// Internally stores the JSON-serialised form of the token wrapped in +/// [`Zeroizing`] so the buffer is wiped on overwrite and on store drop. The /// [`SecretToken`](crate::SecretToken) wrapped inside [`Token`] is /// [`ZeroizeOnDrop`](zeroize::ZeroizeOnDrop), so we deliberately don't clone /// the in-memory `Token` value — round-tripping through serde gives us a /// fresh `SecretToken` on each `load` without violating that invariant. pub struct InMemoryTokenStore { - state: Mutex>, + state: Mutex>>, } impl InMemoryTokenStore { @@ -153,7 +155,7 @@ impl TokenStore for InMemoryTokenStore { return; }; let mut guard = self.state.lock().await; - *guard = Some(json); + *guard = Some(Zeroizing::new(json)); } } @@ -166,6 +168,17 @@ impl TokenStore for InMemoryTokenStore { /// /// The closure return types are generic so async blocks / `async ||` /// closures / `async fn` adapters all compose without boxing. +/// +/// **Secret-material handling.** The JSON string passed to the `save` +/// closure contains the bearer token verbatim (via +/// [`SecretToken`](crate::SecretToken)'s `#[serde(transparent)]` impl). Once +/// the value crosses into the user's closure, `stack-auth` has no control +/// over zeroize semantics — implementations should treat the input as +/// secret material and clear any local copies promptly. `load` wraps the +/// returned string in [`Zeroizing`] internally, so the buffer is wiped +/// after deserialisation. End-to-end protection at rest (e.g. encrypting +/// the value before it ever leaves the worker) is tracked as a future +/// `EncryptedTokenStore` decorator. pub struct CallbackTokenStore { load: L, save: S, @@ -190,14 +203,11 @@ where SF: Future + Send, { async fn load(&self) -> Option { - let json = (self.load)().await?; - match serde_json::from_str(&json) { - Ok(token) => Some(token), - Err(err) => { - tracing::warn!(%err, "CallbackTokenStore: load returned invalid JSON"); - None - } - } + let json = Zeroizing::new((self.load)().await?); + // Don't log the underlying serde_json error — its `Display` impl can + // include byte positions of unexpected tokens, leaking partial token + // content if the input was mid-parse when it failed. + serde_json::from_str(&json).ok() } async fn save(&self, token: &Token) { @@ -218,14 +228,11 @@ where SF: Future, { async fn load(&self) -> Option { - let json = (self.load)().await?; - match serde_json::from_str(&json) { - Ok(token) => Some(token), - Err(err) => { - tracing::warn!(%err, "CallbackTokenStore: load returned invalid JSON"); - None - } - } + let json = Zeroizing::new((self.load)().await?); + // Don't log the underlying serde_json error — its `Display` impl can + // include byte positions of unexpected tokens, leaking partial token + // content if the input was mid-parse when it failed. + serde_json::from_str(&json).ok() } async fn save(&self, token: &Token) { @@ -262,16 +269,30 @@ mod tests { #[tokio::test] async fn in_memory_load_returns_none_when_empty() { let store = InMemoryTokenStore::new(); - assert!(store.load().await.is_none()); + assert!( + store.load().await.is_none(), + "freshly constructed store should hold no token" + ); } #[tokio::test] async fn in_memory_round_trip_preserves_expires_at() { let store = InMemoryTokenStore::new(); store.save(&dummy_token(4_000_000_000)).await; - let loaded = store.load().await.unwrap(); - assert_eq!(loaded.expires_at(), 4_000_000_000); - assert_eq!(loaded.token_type(), "Bearer"); + let loaded = store + .load() + .await + .expect("load should return the saved token"); + assert_eq!( + loaded.expires_at(), + 4_000_000_000, + "round-trip should preserve expires_at" + ); + assert_eq!( + loaded.token_type(), + "Bearer", + "round-trip should preserve token_type" + ); } #[tokio::test] @@ -279,7 +300,12 @@ mod tests { let store = InMemoryTokenStore::new(); store.save(&dummy_token(1_000_000_000)).await; store.save(&dummy_token(2_000_000_000)).await; - assert_eq!(store.load().await.unwrap().expires_at(), 2_000_000_000); + let loaded = store.load().await.expect("store should hold a token"); + assert_eq!( + loaded.expires_at(), + 2_000_000_000, + "second save should replace the first" + ); } #[tokio::test] @@ -301,12 +327,30 @@ mod tests { |_json: String| async move {}, ); - assert!(store.load().await.is_none()); - assert_eq!(calls.load(Ordering::SeqCst), 1); + assert!( + store.load().await.is_none(), + "first load returns None because the closure does" + ); + assert_eq!( + calls.load(Ordering::SeqCst), + 1, + "first call should have invoked the load closure exactly once" + ); - let loaded = store.load().await.unwrap(); - assert_eq!(loaded.expires_at(), 4_000_000_000); - assert_eq!(calls.load(Ordering::SeqCst), 2); + let loaded = store + .load() + .await + .expect("second load should yield a token"); + assert_eq!( + loaded.expires_at(), + 4_000_000_000, + "deserialised token should preserve the JSON payload's expires_at" + ); + assert_eq!( + calls.load(Ordering::SeqCst), + 2, + "second call should have invoked the load closure a second time" + ); } #[tokio::test] @@ -324,9 +368,19 @@ mod tests { ); store.save(&dummy_token(4_000_000_000)).await; - let json = captured.lock().await.clone().unwrap(); - assert!(json.contains("\"expires_at\":4000000000")); - assert!(json.contains("\"token_type\":\"Bearer\"")); + let json = captured + .lock() + .await + .clone() + .expect("save closure should have captured the JSON"); + assert!( + json.contains("\"expires_at\":4000000000"), + "captured JSON should encode expires_at; got: {json}" + ); + assert!( + json.contains("\"token_type\":\"Bearer\""), + "captured JSON should encode token_type; got: {json}" + ); } #[tokio::test] @@ -335,6 +389,9 @@ mod tests { || async { Some("not valid json".to_string()) }, |_json: String| async move {}, ); - assert!(store.load().await.is_none()); + assert!( + store.load().await.is_none(), + "invalid JSON from the load closure should be treated as cache miss" + ); } } From 22c2bb7a857935349011ce3d185265aeb05f09d5 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Sat, 16 May 2026 12:58:39 +1000 Subject: [PATCH 219/686] fix(stack-auth): drop private intra-doc link to crate::refresher MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `mise run lint` doesn't run `cargo doc`, and the lint `rustdoc::private_intra_doc_links` fires only as a warning locally — but CI runs `cargo doc` with `-D warnings` and the link in `TokenStore::save`'s doc comment to `[Refresher::save](crate::refresher)` broke the build. `refresher` is a private module; the link wasn't resolvable from a public docs context. Drop the link, keep the prose ("matching `Refresher::save`'s log-and-continue posture" → just "log-and-continue posture"). The parallel with `Refresher` is internal-implementation detail that external readers don't need. --- packages/stack-auth/src/token_store.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/stack-auth/src/token_store.rs b/packages/stack-auth/src/token_store.rs index 87a19315d..70ef3eeb2 100644 --- a/packages/stack-auth/src/token_store.rs +++ b/packages/stack-auth/src/token_store.rs @@ -67,7 +67,7 @@ pub trait TokenStore: Send + Sync { /// Persist a token after a successful refresh or initial authentication. /// Best-effort — implementations should log on failure rather than - /// returning an error, matching [`Refresher::save`](crate::refresher). + /// returning an error. fn save(&self, token: &Token) -> impl Future + Send; } From c1fa5de268270edd1e1a77a517270d88cf964516 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Sat, 16 May 2026 14:12:05 +1000 Subject: [PATCH 220/686] chore(stack-auth/auth): migrate napi platform sub-packages to peerDependencies optional MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deno's strict npm resolver eagerly walks `optionalDependencies` for the host platform and treats "not found" as a fatal graph error, even when the consumer doesn't use the napi path. Locally this manifests as transient resolution failures during the registry-CDN propagation window right after publish — we hit it on 0.37.0-alpha.5 where Supabase Edge Runtime's package-graph cache was poisoned with a "platform package missing" entry that persisted across function restarts until the underlying Docker container was killed. Switching to `peerDependencies` + `peerDependenciesMeta: { ..., optional: true }` is the npm-canonical way to mark "this dep is conditional on consumer environment" and is honoured properly by Deno, Bun, Yarn, pnpm, and modern npm. The actual runtime behaviour is unchanged: Node ESM `import` still finds and loads the right `.node` via the napi loader at runtime; runtimes without a matching peer dep skip it silently. Updates the publish workflow's jq rewrite from `.optionalDependencies` to `.peerDependencies` so the per-release version bump still flows through to the platform sub-packages. Refs PR cipherstash/cipherstash-suite#1958. --- .github/imported-workflows/publish-auth-npm.yml | 7 +++++-- languages/typescript/packages/auth/package.json | 10 +++++++++- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/.github/imported-workflows/publish-auth-npm.yml b/.github/imported-workflows/publish-auth-npm.yml index 4852ce939..74b0fbaae 100644 --- a/.github/imported-workflows/publish-auth-npm.yml +++ b/.github/imported-workflows/publish-auth-npm.yml @@ -202,9 +202,12 @@ jobs: run: | VERSION="${{ steps.version.outputs.version }}" npm version "$VERSION" --no-git-tag-version --allow-same-version - # Update optionalDependencies to match the publish version + # Update peerDependencies (the napi platform sub-packages) to match + # the publish version. They live under `peerDependencies` rather than + # `optionalDependencies` so Deno's strict npm resolver doesn't choke + # on platform packages that don't apply to the consumer's runtime. jq --arg v "$VERSION" ' - .optionalDependencies |= with_entries( + .peerDependencies |= with_entries( if (.key | startswith("@cipherstash/auth-")) then .value = $v else . diff --git a/languages/typescript/packages/auth/package.json b/languages/typescript/packages/auth/package.json index 484c7e07b..a96390338 100644 --- a/languages/typescript/packages/auth/package.json +++ b/languages/typescript/packages/auth/package.json @@ -53,7 +53,7 @@ "build:wasm": "cd ../wasm && wasm-pack build --target bundler --out-dir ../node/wasm && rm -f ../node/wasm/README.md ../node/wasm/LICENSE ../node/wasm/.gitignore && echo '{\"type\":\"module\"}' > ../node/wasm/package.json && node ../node/scripts/inline-wasm.mjs", "test": "npm run build:test && vitest run" }, - "optionalDependencies": { + "peerDependencies": { "@cipherstash/auth-darwin-x64": "0.36.0", "@cipherstash/auth-darwin-arm64": "0.36.0", "@cipherstash/auth-linux-x64-gnu": "0.36.0", @@ -61,6 +61,14 @@ "@cipherstash/auth-linux-x64-musl": "0.36.0", "@cipherstash/auth-win32-x64-msvc": "0.36.0" }, + "peerDependenciesMeta": { + "@cipherstash/auth-darwin-x64": { "optional": true }, + "@cipherstash/auth-darwin-arm64": { "optional": true }, + "@cipherstash/auth-linux-x64-gnu": { "optional": true }, + "@cipherstash/auth-linux-arm64-gnu": { "optional": true }, + "@cipherstash/auth-linux-x64-musl": { "optional": true }, + "@cipherstash/auth-win32-x64-msvc": { "optional": true } + }, "devDependencies": { "@napi-rs/cli": "^2", "vitest": "^3", From e1f796d39b3d1857bc00abd11e37de9a20198fb6 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Wed, 20 May 2026 10:21:55 +1000 Subject: [PATCH 221/686] refactor(stack-auth): extract save_refreshed_token + install_refreshed_token helpers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three success arms (initial_auth, refresh_blocking, refresh_non_blocking) each duplicated the same sequence: write to the per-refresher sink, await the TokenStore, install in `state`, clear `refresh_in_progress`. Extract into two helpers that preserve the per-caller lock-scope discipline: - `save_refreshed_token(&Token)` — async, no lock semantics, called by every success arm. - `install_refreshed_token(&mut State, Token) -> ServiceToken` — sync, caller holds the state lock. Keeping these as two helpers (rather than one combined helper) preserves `refresh_non_blocking`'s lock-late discipline: it awaits the store write without the state lock held, then acquires the lock only for the synchronous install. The blocking/initial paths continue to hold the state lock across the store write by design (matching prior behavior). Also flattens a nested `if let` in the cold-start store-load branch of `get_token`. --- packages/stack-auth/src/auto_refresh.rs | 47 +++++++++++++++---------- 1 file changed, 28 insertions(+), 19 deletions(-) diff --git a/packages/stack-auth/src/auto_refresh.rs b/packages/stack-auth/src/auto_refresh.rs index 44851ee9a..d9742a292 100644 --- a/packages/stack-auth/src/auto_refresh.rs +++ b/packages/stack-auth/src/auto_refresh.rs @@ -147,9 +147,7 @@ impl AutoRefresh { let loaded = self.store.load().await; state = self.state.lock().await; if state.token.is_none() { - if let Some(t) = loaded { - state.token = Some(t); - } + state.token = loaded; } } @@ -194,12 +192,8 @@ impl AutoRefresh { match self.refresher.refresh(&credential).await { Ok(new_token) => { guard.defuse(); - self.refresher.save(&new_token); - self.store.save(&new_token).await; - let service_token = ServiceToken::new(new_token.access_token().clone()); - state.token = Some(new_token); - self.refresh_in_progress.store(false, Ordering::Release); - Ok(service_token) + self.save_refreshed_token(&new_token).await; + Ok(self.install_refreshed_token(state, new_token)) } Err(err) => { guard.defuse(); @@ -209,6 +203,27 @@ impl AutoRefresh { } } + /// Persist a freshly refreshed token to the per-refresher sink and the + /// user-supplied `TokenStore`. Awaits the store write, so callers should + /// drop the state lock before invoking this where possible (the + /// non-blocking refresh path does; the blocking/initial paths hold the + /// state lock throughout by design). + async fn save_refreshed_token(&self, new_token: &Token) { + self.refresher.save(new_token); + self.store.save(new_token).await; + } + + /// Install a freshly refreshed token in `state`, clear the in-progress + /// flag, and return the corresponding [`ServiceToken`]. Pure in-lock + /// work; caller is responsible for having already persisted the token via + /// [`save_refreshed_token`](Self::save_refreshed_token). + fn install_refreshed_token(&self, state: &mut State, new_token: Token) -> ServiceToken { + let service_token = ServiceToken::new(new_token.access_token().clone()); + state.token = Some(new_token); + self.refresh_in_progress.store(false, Ordering::Release); + service_token + } + /// Another caller is already refreshing — return the current token if still /// usable, otherwise wait for the in-flight refresh to complete via `Notify`. /// @@ -259,11 +274,9 @@ impl AutoRefresh { match self.refresher.refresh(&credential).await { Ok(new_token) => { guard.defuse(); - self.refresher.save(&new_token); - self.store.save(&new_token).await; + self.save_refreshed_token(&new_token).await; let mut state = self.state.lock().await; - state.token = Some(new_token); - self.refresh_in_progress.store(false, Ordering::Release); + let _ = self.install_refreshed_token(&mut state, new_token); } Err(err) => { guard.defuse(); @@ -301,12 +314,8 @@ impl AutoRefresh { match self.refresher.refresh(&credential).await { Ok(new_token) => { guard.defuse(); - self.refresher.save(&new_token); - self.store.save(&new_token).await; - let service_token = ServiceToken::new(new_token.access_token().clone()); - state.token = Some(new_token); - self.refresh_in_progress.store(false, Ordering::Release); - Ok(service_token) + self.save_refreshed_token(&new_token).await; + Ok(self.install_refreshed_token(state, new_token)) } Err(err) => { guard.defuse(); From 7c87547f7839e3fd2514c1f7c6b49e3f00a7a426 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Sat, 16 May 2026 08:53:28 +1000 Subject: [PATCH 222/686] feat(stack-auth/wasm): AccessKeyStrategy.createWithStore JS-callback bindings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `AccessKeyStrategy.createWithStore(region, accessKey, loadToken, saveToken)` so Supabase Edge / Cloudflare Workers consumers can pass async JS callbacks that adapt the parent crate's `TokenStore` trait to HTTP-only cookies, KV blobs, or any other request-scoped cache. Internally a `JsTokenStore { load, save: js_sys::Function }` wraps the two callbacks. The trait's wasm32-cfg drops `Send + Sync` exactly to accommodate `js_sys::Function`'s non-`Send` nature, so the adapter fits without further plumbing. The whole `JsTokenStore` machinery is `#[cfg(target_arch = "wasm32")]`-gated because the wasm crate also compiles for the native target under `mise run lint` / `--all-targets`, where `Send` is required by the parent trait. `AccessKeyStrategy::inner` becomes an enum of either a `` or `` concrete strategy — keeping the existing `create()` factory bit-identical for callers that don't want a store, and adding the `WithStore` variant only on wasm32. `wasm-types.d.ts` documents the new factory and the two callback type aliases (`LoadTokenCallback`, `SaveTokenCallback`), including the secret-material caveat: the JSON string passed to `saveToken` contains the bearer credential verbatim. End-to-end encryption-at-rest is a planned follow-up (tracked as CIP-3112). Scope notes: the napi side of this binding is a separate follow-up. The async-callback pattern for napi (`ThreadsafeFunction` + Promise return wiring) has no prior art in this workspace and benefits from a focused design + review separate from the wasm path. Refs CIP-3110. --- .../typescript/packages/auth/wasm-types.d.ts | 35 ++++ .../packages/stack-auth-wasm/src/lib.rs | 151 +++++++++++++++++- 2 files changed, 184 insertions(+), 2 deletions(-) diff --git a/languages/typescript/packages/auth/wasm-types.d.ts b/languages/typescript/packages/auth/wasm-types.d.ts index 980c6dcf4..7846cba60 100644 --- a/languages/typescript/packages/auth/wasm-types.d.ts +++ b/languages/typescript/packages/auth/wasm-types.d.ts @@ -59,6 +59,22 @@ export interface TokenResult { services: Record } +/** + * Async callback used by `AccessKeyStrategy.createWithStore` to load a + * previously-persisted token JSON. Returning `null` (or `undefined`) signals + * "no token cached" — the strategy will fall through to re-authenticating + * with the access key. + */ +export type LoadTokenCallback = () => Promise + +/** + * Async callback used by `AccessKeyStrategy.createWithStore` to persist a + * freshly-issued token JSON. The string is opaque to the caller — pass it + * back unchanged to a future `LoadTokenCallback` (e.g. set it as a cookie + * value, write it to a KV store, etc.). + */ +export type SaveTokenCallback = (json: string) => Promise + /** * An auth strategy that uses a static access key for service-to-service * or CI/CD authentication. @@ -67,6 +83,25 @@ export declare class AccessKeyStrategy { private constructor() /** Create a new `AccessKeyStrategy` for the given region and access key. */ static create(region: string, accessKey: string): AccessKeyStrategy + /** + * Create an `AccessKeyStrategy` backed by external token-store callbacks. + * + * The strategy consults `loadToken` on cold start before issuing any HTTP + * request — if it returns a still-valid token, that's reused. After every + * successful refresh or initial authentication the new token JSON is + * written back via `saveToken`. Both callbacks return Promises so they + * can perform async I/O (read a cookie, write to KV, etc.). + * + * The JSON string passed to `saveToken` contains the bearer credential + * verbatim — treat it as secret material. End-to-end protection at rest + * (encrypting before persistence) is a planned follow-up. + */ + static createWithStore( + region: string, + accessKey: string, + loadToken: LoadTokenCallback, + saveToken: SaveTokenCallback, + ): AccessKeyStrategy /** Retrieve a valid access token, refreshing or re-authenticating as needed. */ getToken(): Promise /** Release the underlying wasm resources. */ diff --git a/languages/typescript/packages/stack-auth-wasm/src/lib.rs b/languages/typescript/packages/stack-auth-wasm/src/lib.rs index 62a28d36d..6bcccabf5 100644 --- a/languages/typescript/packages/stack-auth-wasm/src/lib.rs +++ b/languages/typescript/packages/stack-auth-wasm/src/lib.rs @@ -15,7 +15,11 @@ use cts_common::Region; use serde::Serialize; use serde_wasm_bindgen::Serializer; use stack_auth::{AuthError, AuthStrategy, ServiceToken}; +#[cfg(target_arch = "wasm32")] +use stack_auth::{Token, TokenStore}; use wasm_bindgen::prelude::*; +#[cfg(target_arch = "wasm32")] +use wasm_bindgen_futures::JsFuture; /// Route Rust panics to `console.error` with a readable message + stack. /// Without this, panics surface as opaque `RuntimeError: unreachable` from @@ -79,9 +83,61 @@ fn token_result_from(token: ServiceToken) -> Result { .map_err(JsValue::from) } +/// `TokenStore` adapter over a pair of JS callbacks. +/// +/// `load` is called with no arguments and is expected to return +/// `Promise` — the previously-stored JSON +/// or a nullish value if nothing is cached. `save` is called with the +/// JSON string and is expected to return `Promise`. +/// +/// Cfg-gated to `wasm32` because `js_sys::Function` is not `Send` and the +/// parent `stack_auth::TokenStore` trait drops the `Send + Sync` bound on +/// wasm32 to accommodate exactly this case. +#[cfg(target_arch = "wasm32")] +struct JsTokenStore { + load: js_sys::Function, + save: js_sys::Function, +} + +#[cfg(target_arch = "wasm32")] +impl TokenStore for JsTokenStore { + async fn load(&self) -> Option { + let promise = self.load.call0(&JsValue::NULL).ok()?; + let result = JsFuture::from(js_sys::Promise::from(promise)).await.ok()?; + let json = result.as_string()?; + serde_json::from_str(&json).ok() + } + + async fn save(&self, token: &Token) { + let Ok(json) = serde_json::to_string(token) else { + return; + }; + let Ok(promise) = self.save.call1(&JsValue::NULL, &JsValue::from_str(&json)) else { + return; + }; + let _ = JsFuture::from(js_sys::Promise::from(promise)).await; + } +} + +enum AccessKeyStrategyInner { + NoStore(stack_auth::AccessKeyStrategy), + #[cfg(target_arch = "wasm32")] + WithStore(stack_auth::AccessKeyStrategy), +} + +impl AccessKeyStrategyInner { + async fn get_token(&self) -> Result { + match self { + Self::NoStore(s) => s.get_token().await, + #[cfg(target_arch = "wasm32")] + Self::WithStore(s) => s.get_token().await, + } + } +} + #[wasm_bindgen] pub struct AccessKeyStrategy { - inner: stack_auth::AccessKeyStrategy, + inner: AccessKeyStrategyInner, } #[wasm_bindgen] @@ -93,7 +149,44 @@ impl AccessKeyStrategy { .parse() .map_err(|e| to_js_error(AuthError::from(e)))?; let inner = stack_auth::AccessKeyStrategy::new(region, key).map_err(to_js_error)?; - Ok(AccessKeyStrategy { inner }) + Ok(AccessKeyStrategy { + inner: AccessKeyStrategyInner::NoStore(inner), + }) + } + + /// Create an `AccessKeyStrategy` backed by external token-store callbacks. + /// + /// `loadToken` is called on cold start before any HTTP request fires; it + /// must return the previously-saved JSON string (or null/undefined for + /// "cache miss") wrapped in a Promise. `saveToken` receives the JSON + /// string after every successful refresh and must persist it; its return + /// Promise resolves to undefined. + /// + /// Use this to back the strategy with HTTP-only cookies (Supabase Edge), + /// KV stores (Cloudflare Workers), or any other request-scoped cache. + #[cfg(target_arch = "wasm32")] + #[wasm_bindgen(js_name = createWithStore)] + pub fn create_with_store( + region: String, + access_key: String, + load_token: js_sys::Function, + save_token: js_sys::Function, + ) -> Result { + let region = Region::new(®ion).map_err(|e| to_js_error(AuthError::from(e)))?; + let key: stack_auth::AccessKey = access_key + .parse() + .map_err(|e| to_js_error(AuthError::from(e)))?; + let store = JsTokenStore { + load: load_token, + save: save_token, + }; + let inner = stack_auth::AccessKeyStrategy::builder(region, key) + .with_token_store(store) + .build() + .map_err(to_js_error)?; + Ok(AccessKeyStrategy { + inner: AccessKeyStrategyInner::WithStore(inner), + }) } /// Retrieve a valid access token, refreshing or re-authenticating as needed. @@ -231,4 +324,58 @@ mod tests { ); assert!(result.is_ok()); } + + fn empty_load_fn() -> js_sys::Function { + // `async () => null` + js_sys::Function::new_no_args("return Promise.resolve(null);") + } + + fn noop_save_fn() -> js_sys::Function { + // `async (_) => undefined` + js_sys::Function::new_with_args("_json", "return Promise.resolve();") + } + + #[wasm_bindgen_test] + fn create_with_store_rejects_invalid_region() { + let err = expect_js_err(AccessKeyStrategy::create_with_store( + "not-a-region".to_string(), + "CSAKtestKeyId.testKeySecret".to_string(), + empty_load_fn(), + noop_save_fn(), + )); + assert_eq!( + error_code_of(&err), + "INVALID_REGION", + "invalid region should surface INVALID_REGION even on the store variant" + ); + } + + #[wasm_bindgen_test] + fn create_with_store_rejects_invalid_access_key() { + let err = expect_js_err(AccessKeyStrategy::create_with_store( + "ap-southeast-2.aws".to_string(), + "not-a-valid-key".to_string(), + empty_load_fn(), + noop_save_fn(), + )); + assert_eq!( + error_code_of(&err), + "INVALID_ACCESS_KEY", + "invalid access key should surface INVALID_ACCESS_KEY" + ); + } + + #[wasm_bindgen_test] + fn create_with_store_accepts_valid_inputs() { + let result = AccessKeyStrategy::create_with_store( + "ap-southeast-2.aws".to_string(), + "CSAKtestKeyId.testKeySecret".to_string(), + empty_load_fn(), + noop_save_fn(), + ); + assert!( + result.is_ok(), + "valid region + key + callbacks should construct successfully" + ); + } } From b3e3315952a3125e68c449701ab9f19be25a186b Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Sat, 16 May 2026 15:01:55 +1000 Subject: [PATCH 223/686] fix(stack-auth/wasm): log JsTokenStore callback rejections (CIP-3114) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `JsTokenStore::load` and `::save` were doing `let _ = JsFuture::from(promise).await` which silently discarded JS callback rejections. Spike testing hit this hard: the user's `saveToken` was throwing because `setCookie` rejected a value containing chars outside RFC 6265's allowed range, but no log surfaced anywhere — the cookie just never got set and the next request appeared to "lose" the cache. Replace the silent discard with explicit `web_sys::console::warn_2` logging. Trait contract stays "best-effort" (`load -> Option`, `save -> ()`); failures now merely become visible in the worker log. Adds `web-sys = { version = "0.3", features = ["console"] }` for the console API, and two wasm tests covering the "callback throws" path: - `js_token_store_load_returns_none_on_callback_throw` — verifies a throwing loadToken doesn't crash, just becomes a cache miss. - `js_token_store_save_swallows_callback_throw` — verifies a throwing saveToken doesn't crash the surrounding refresh path. Closes CIP-3114. --- .../packages/stack-auth-wasm/Cargo.toml | 3 + .../packages/stack-auth-wasm/src/lib.rs | 81 +++++++++++++++++-- 2 files changed, 77 insertions(+), 7 deletions(-) diff --git a/languages/typescript/packages/stack-auth-wasm/Cargo.toml b/languages/typescript/packages/stack-auth-wasm/Cargo.toml index 06884ac5c..6ed3f8044 100644 --- a/languages/typescript/packages/stack-auth-wasm/Cargo.toml +++ b/languages/typescript/packages/stack-auth-wasm/Cargo.toml @@ -21,6 +21,9 @@ wasm-bindgen = "0.2" wasm-bindgen-futures = "0.4" serde-wasm-bindgen = "0.6" js-sys = "0.3" +# `console` feature gives us `web_sys::console::warn_1` for surfacing JS +# callback rejections inside `JsTokenStore` (CIP-3114). +web-sys = { version = "0.3", features = ["console"] } console_error_panic_hook = "0.1" [target.'cfg(target_arch = "wasm32")'.dev-dependencies] diff --git a/languages/typescript/packages/stack-auth-wasm/src/lib.rs b/languages/typescript/packages/stack-auth-wasm/src/lib.rs index 6bcccabf5..7930aa2eb 100644 --- a/languages/typescript/packages/stack-auth-wasm/src/lib.rs +++ b/languages/typescript/packages/stack-auth-wasm/src/lib.rs @@ -102,23 +102,52 @@ struct JsTokenStore { #[cfg(target_arch = "wasm32")] impl TokenStore for JsTokenStore { async fn load(&self) -> Option { - let promise = self.load.call0(&JsValue::NULL).ok()?; - let result = JsFuture::from(js_sys::Promise::from(promise)).await.ok()?; - let json = result.as_string()?; - serde_json::from_str(&json).ok() + let promise = match self.load.call0(&JsValue::NULL) { + Ok(p) => p, + Err(err) => { + warn_callback("loadToken", "synchronous throw", &err); + return None; + } + }; + match JsFuture::from(js_sys::Promise::from(promise)).await { + Ok(result) => { + let json = result.as_string()?; + serde_json::from_str(&json).ok() + } + Err(err) => { + warn_callback("loadToken", "promise rejection", &err); + None + } + } } async fn save(&self, token: &Token) { let Ok(json) = serde_json::to_string(token) else { return; }; - let Ok(promise) = self.save.call1(&JsValue::NULL, &JsValue::from_str(&json)) else { - return; + let promise = match self.save.call1(&JsValue::NULL, &JsValue::from_str(&json)) { + Ok(p) => p, + Err(err) => { + warn_callback("saveToken", "synchronous throw", &err); + return; + } }; - let _ = JsFuture::from(js_sys::Promise::from(promise)).await; + if let Err(err) = JsFuture::from(js_sys::Promise::from(promise)).await { + warn_callback("saveToken", "promise rejection", &err); + } } } +/// Surface JS callback failures so consumers can see them — without this, +/// rejections in user-supplied `loadToken` / `saveToken` were invisible and +/// led to silent cache misses (e.g. when `setCookie` rejected a value +/// containing chars outside RFC 6265's allowed range). See CIP-3114. +#[cfg(target_arch = "wasm32")] +fn warn_callback(name: &str, kind: &str, err: &JsValue) { + let msg = format!("stack-auth: {name} {kind}"); + web_sys::console::warn_2(&JsValue::from_str(&msg), err); +} + enum AccessKeyStrategyInner { NoStore(stack_auth::AccessKeyStrategy), #[cfg(target_arch = "wasm32")] @@ -378,4 +407,42 @@ mod tests { "valid region + key + callbacks should construct successfully" ); } + + #[wasm_bindgen_test] + async fn js_token_store_load_returns_none_on_callback_throw() { + use stack_auth::TokenStore as _; + // A throwing `loadToken` mustn't crash — it should be treated as a + // cache miss so the strategy falls through to initial auth. + // CIP-3114: prior to the fix this still returned None (via `.ok()?`) + // but without surfacing the throw. With the fix, the throw is logged + // via `web_sys::console::warn_2`; behaviour-wise we just confirm + // the call returns None rather than panicking. + let store = JsTokenStore { + load: js_sys::Function::new_no_args("throw new Error('boom');"), + save: noop_save_fn(), + }; + assert!( + store.load().await.is_none(), + "throwing loadToken should produce a cache miss, not a crash" + ); + } + + #[wasm_bindgen_test] + async fn js_token_store_save_swallows_callback_throw() { + use stack_auth::TokenStore as _; + // A throwing `saveToken` mustn't crash the surrounding refresh path. + // Trait contract is "best-effort" — save returns `()` regardless. + let store = JsTokenStore { + load: empty_load_fn(), + save: js_sys::Function::new_with_args("_json", "throw new Error('boom');"), + }; + // `Token`'s fields are `pub(crate)`; round-trip through serde to build + // one from this crate without touching the field privacy. + let token: Token = serde_json::from_str( + r#"{"access_token":"dummy","token_type":"Bearer","expires_at":4000000000}"#, + ) + .unwrap(); + // No assertion needed beyond "this doesn't panic". + store.save(&token).await; + } } From 68709ae03343da90a6d1546e269855f1b0617049 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Sat, 16 May 2026 15:02:23 +1000 Subject: [PATCH 224/686] feat(stack-auth/auth): slick options-object API + cookieStore helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit End-to-end spike testing on 0.37.0-alpha.5 surfaced two ergonomics concerns with the wasm bindings landed in PR cipherstash/cipherstash-suite#1959: 1. `createWithStore(region, key, loadFn, saveFn)` is clunky — four positional args, easy to swap the callbacks. Doesn't match the options-object shape modern JS APIs use. 2. Wiring cookies requires ~30 lines of boilerplate per consumer — parsing the Cookie header, base64url-encoding the JSON to skirt RFC 6265's cookie-value char range, computing Max-Age from expires_at, building Set-Cookie. The spike reinvented all of it; the next consumer would too. This commit lands two changes: ## A. Slick wrapper at `@cipherstash/auth/wasm-inline` Hand-written ESM module in front of the raw wasm-bindgen output: ```ts import { AccessKeyStrategy } from "@cipherstash/auth/wasm-inline"; const strategy = AccessKeyStrategy.create(region, accessKey, { store: { load: () => ..., save: (json) => ... }, }); ``` `createWithStore` drops out of the public TS surface — the wrapper is the canonical entry. Internally `create({ store })` calls the raw `createWithStore`; without `store` it calls the raw `create`. ## B. Built-in `cookieStore` at `@cipherstash/auth/cookies` Pure-JS helper that returns a `TokenStore`-shaped object from a WHATWG `Request + Headers` pair: ```ts import { AccessKeyStrategy } from "@cipherstash/auth/wasm-inline"; import { cookieStore } from "@cipherstash/auth/cookies"; const strategy = AccessKeyStrategy.create(region, accessKey, { store: cookieStore({ request: req, responseHeaders }), }); ``` Works in any runtime that exposes WHATWG `Request`/`Headers`: Supabase Edge, Cloudflare Workers, Bun, Deno, Node 18+, Next.js App Router. Same module backs both the wasm AccessKeyStrategy (this PR) and the future napi binding (CIP-3113) — TokenStore shape is identical on both substrates. Vendors a tiny cookie parse/serialise (~50 LoC, no external dep). Handles base64url encoding for the cookie value, parses expires_at from Token JSON to compute Max-Age with the strategy's standard 30-second safety margin. Spike collapses from ~144 lines to ~50. ## Layout New files at `packages/stack-auth/node/`: - `wasm-inline.mjs` + `wasm-inline.d.ts` — slick wrapper class - `cookies.mjs` + `cookies.d.ts` — cookieStore helper - `__tests__/cookies.test.ts` — 10 vitest cases covering round-trip, Max-Age calculation, base64url RFC 6265 conformance, missing-cookie, corrupt-cookie, custom attribute pass-through, and HttpOnly default The `/wasm-inline` exports map entry now points at the new `.mjs` wrapper (was `wasm/stack_auth_wasm_inline.js` — still reachable through the wrapper). The `/wasm` entry still serves the raw bundler-target shim for Vite/Webpack consumers that don't want a wrapper. `wasm-types.d.ts` is trimmed: it documents the raw `/wasm` surface only (no more `createWithStore`/`LoadTokenCallback`/ `SaveTokenCallback` exports). Version bumped to 0.37.0-alpha.6 for the next prerelease publish. --- .../packages/auth/__tests__/cookies.test.ts | 152 +++++++++++++++++ .../typescript/packages/auth/cookies.d.ts | 64 +++++++ .../typescript/packages/auth/cookies.mjs | 158 ++++++++++++++++++ .../typescript/packages/auth/package.json | 14 +- .../typescript/packages/auth/wasm-inline.d.ts | 66 ++++++++ .../typescript/packages/auth/wasm-inline.mjs | 54 ++++++ .../typescript/packages/auth/wasm-types.d.ts | 67 +++----- 7 files changed, 524 insertions(+), 51 deletions(-) create mode 100644 languages/typescript/packages/auth/__tests__/cookies.test.ts create mode 100644 languages/typescript/packages/auth/cookies.d.ts create mode 100644 languages/typescript/packages/auth/cookies.mjs create mode 100644 languages/typescript/packages/auth/wasm-inline.d.ts create mode 100644 languages/typescript/packages/auth/wasm-inline.mjs diff --git a/languages/typescript/packages/auth/__tests__/cookies.test.ts b/languages/typescript/packages/auth/__tests__/cookies.test.ts new file mode 100644 index 000000000..649e233ba --- /dev/null +++ b/languages/typescript/packages/auth/__tests__/cookies.test.ts @@ -0,0 +1,152 @@ +import { describe, it, expect } from "vitest"; +import { cookieStore } from "../cookies.mjs"; + +function makeRequest(cookieHeader?: string): Request { + return new Request("https://example.com/", { + headers: cookieHeader ? { cookie: cookieHeader } : {}, + }); +} + +function tokenJson(overrides: Record = {}): string { + return JSON.stringify({ + access_token: "test-jwt", + token_type: "Bearer", + expires_at: Math.floor(Date.now() / 1000) + 3600, + refresh_token: null, + region: null, + client_id: null, + device_instance_id: null, + ...overrides, + }); +} + +function extractCookieValue(setCookie: string): string { + // Set-Cookie format: "name=value; Path=/; ..." + const firstPair = setCookie.split(";")[0]; + return firstPair.slice(firstPair.indexOf("=") + 1); +} + +describe("cookieStore.load", () => { + it("returns null when no cookie header is present", async () => { + const store = cookieStore({ + request: makeRequest(), + responseHeaders: new Headers(), + }); + expect(await store.load()).toBeNull(); + }); + + it("returns null when the cookie name isn't set", async () => { + const store = cookieStore({ + request: makeRequest("other=value"), + responseHeaders: new Headers(), + }); + expect(await store.load()).toBeNull(); + }); + + it("decodes the base64url-encoded value on the round trip", async () => { + const responseHeaders = new Headers(); + const save = cookieStore({ request: makeRequest(), responseHeaders }); + const json = tokenJson(); + await save.save(json); + const setCookie = responseHeaders.get("set-cookie")!; + + const load = cookieStore({ + request: makeRequest(`cs_token=${extractCookieValue(setCookie)}`), + responseHeaders: new Headers(), + }); + expect(await load.load()).toBe(json); + }); + + it("ignores a corrupt base64url value (returns null)", async () => { + const store = cookieStore({ + request: makeRequest("cs_token=not-valid-base64!@#"), + responseHeaders: new Headers(), + }); + expect(await store.load()).toBeNull(); + }); +}); + +describe("cookieStore.save", () => { + it("writes a Set-Cookie header on responseHeaders", async () => { + const responseHeaders = new Headers(); + const store = cookieStore({ request: makeRequest(), responseHeaders }); + await store.save(tokenJson()); + expect(responseHeaders.get("set-cookie")).toMatch(/^cs_token=/); + }); + + it("base64url-encodes the value (no quotes in the cookie)", async () => { + const responseHeaders = new Headers(); + const store = cookieStore({ request: makeRequest(), responseHeaders }); + await store.save(tokenJson()); + const setCookie = responseHeaders.get("set-cookie")!; + // RFC 6265 token-char range: no `"`, `,`, `;`, or `\` in the cookie value. + const value = extractCookieValue(setCookie); + expect(value).toMatch(/^[A-Za-z0-9_\-]+$/); + }); + + it("computes Max-Age from expires_at minus the safety margin", async () => { + const responseHeaders = new Headers(); + const store = cookieStore({ + request: makeRequest(), + responseHeaders, + expirySafetyMarginSeconds: 30, + }); + const expiresIn = 3600; + const expiresAt = Math.floor(Date.now() / 1000) + expiresIn; + await store.save(tokenJson({ expires_at: expiresAt })); + const setCookie = responseHeaders.get("set-cookie")!; + const maxAgeMatch = setCookie.match(/Max-Age=(\d+)/); + expect(maxAgeMatch).not.toBeNull(); + const maxAge = Number(maxAgeMatch![1]); + // Allow ±2s tolerance for the clock advancing during the test + expect(maxAge).toBeGreaterThanOrEqual(expiresIn - 30 - 2); + expect(maxAge).toBeLessThanOrEqual(expiresIn - 30); + }); + + it("omits Max-Age when expires_at can't be parsed", async () => { + const responseHeaders = new Headers(); + const store = cookieStore({ request: makeRequest(), responseHeaders }); + await store.save("not valid json"); + const setCookie = responseHeaders.get("set-cookie")!; + expect(setCookie).not.toMatch(/Max-Age=/); + }); + + it("honours custom cookie attributes", async () => { + const responseHeaders = new Headers(); + const store = cookieStore({ + request: makeRequest(), + responseHeaders, + name: "custom_name", + path: "/api", + domain: "example.com", + secure: true, + sameSite: "Strict", + }); + await store.save(tokenJson()); + const setCookie = responseHeaders.get("set-cookie")!; + expect(setCookie).toMatch(/^custom_name=/); + expect(setCookie).toContain("Path=/api"); + expect(setCookie).toContain("Domain=example.com"); + expect(setCookie).toContain("Secure"); + expect(setCookie).toContain("SameSite=Strict"); + }); + + it("HttpOnly is on by default; can be disabled", async () => { + const onResponseHeaders = new Headers(); + const onStore = cookieStore({ + request: makeRequest(), + responseHeaders: onResponseHeaders, + }); + await onStore.save(tokenJson()); + expect(onResponseHeaders.get("set-cookie")).toContain("HttpOnly"); + + const offResponseHeaders = new Headers(); + const offStore = cookieStore({ + request: makeRequest(), + responseHeaders: offResponseHeaders, + httpOnly: false, + }); + await offStore.save(tokenJson()); + expect(offResponseHeaders.get("set-cookie")).not.toContain("HttpOnly"); + }); +}); diff --git a/languages/typescript/packages/auth/cookies.d.ts b/languages/typescript/packages/auth/cookies.d.ts new file mode 100644 index 000000000..4e581656d --- /dev/null +++ b/languages/typescript/packages/auth/cookies.d.ts @@ -0,0 +1,64 @@ +/* tslint:disable */ +/* eslint-disable */ + +/* + * Pluggable cookie-backed `TokenStore` for `@cipherstash/auth`. Works in + * any runtime that exposes WHATWG `Request` + `Headers`: Supabase Edge + * Functions, Cloudflare Workers, Bun, Deno, Node 18+, Next.js App Router. + * + * Pair with `AccessKeyStrategy.create(region, key, { store })` from the + * `/wasm-inline` entry (or, once CIP-3113 lands, the napi binding at the + * main `.` entry). + */ + +import type { TokenStore } from "./wasm-inline.d.ts"; + +export type { TokenStore }; + +/** + * Configuration for {@link cookieStore}. + */ +export interface CookieStoreOptions { + /** Incoming request — the helper reads the `Cookie:` header off this. */ + request: Request; + /** Outgoing response headers — `Set-Cookie` is appended on every save. */ + responseHeaders: Headers; + /** Cookie name. Default: `"cs_token"`. */ + name?: string; + /** `Domain` attribute. Default: unset (host-only). */ + domain?: string; + /** `Path` attribute. Default: `"/"`. */ + path?: string; + /** `Secure` flag. Caller opts in for production. Default: `false`. */ + secure?: boolean; + /** `HttpOnly` flag — prevents JS access. Default: `true`. */ + httpOnly?: boolean; + /** `SameSite` attribute. Default: `"Lax"`. */ + sameSite?: "Strict" | "Lax" | "None"; + /** + * Seconds subtracted from the token's `expires_at` when computing the + * cookie's `Max-Age`. Ensures the cookie expires slightly before the + * underlying token does, so loaded tokens stay usable. Default: `30`. + */ + expirySafetyMarginSeconds?: number; +} + +/** + * Build a {@link TokenStore} backed by an HTTP-only cookie. + * + * @example + * ```ts + * import { AccessKeyStrategy } from "@cipherstash/auth/wasm-inline"; + * import { cookieStore } from "@cipherstash/auth/cookies"; + * + * Deno.serve(async (req) => { + * const responseHeaders = new Headers(); + * const strategy = AccessKeyStrategy.create(region, accessKey, { + * store: cookieStore({ request: req, responseHeaders }), + * }); + * const result = await strategy.getToken(); + * return new Response(JSON.stringify(result), { headers: responseHeaders }); + * }); + * ``` + */ +export declare function cookieStore(options: CookieStoreOptions): TokenStore; diff --git a/languages/typescript/packages/auth/cookies.mjs b/languages/typescript/packages/auth/cookies.mjs new file mode 100644 index 000000000..baa62442a --- /dev/null +++ b/languages/typescript/packages/auth/cookies.mjs @@ -0,0 +1,158 @@ +/* @ts-self-types="./cookies.d.ts" */ + +// Pluggable cookie-backed `TokenStore` for `@cipherstash/auth`. Works in any +// runtime that exposes WHATWG `Request` + `Headers`: Supabase Edge Functions, +// Cloudflare Workers, Bun, Deno, Node 18+, Next.js App Router. The strategy +// stays substrate-agnostic — same helper plugs into both the wasm +// `AccessKeyStrategy` (this package's `/wasm-inline` entry) and the future +// napi binding (CIP-3113). +// +// Cookie value is base64url-encoded because the raw Token JSON contains `"` +// characters, which fall outside RFC 6265's allowed cookie-value char range +// and are rejected by spec-conformant cookie libraries (the `@std/http/cookie` +// failure that bit the supawasm spike on its first end-to-end test). + +const DEFAULT_NAME = "cs_token"; +const DEFAULT_PATH = "/"; +const DEFAULT_SAFETY_MARGIN_SECONDS = 30; + +/** + * @typedef {object} CookieStoreOptions + * @property {Request} request Incoming request to read the cookie from + * @property {Headers} responseHeaders Outgoing headers to append `Set-Cookie` to + * @property {string} [name="cs_token"] Cookie name + * @property {string} [domain] `Domain` attribute + * @property {string} [path="/"] `Path` attribute + * @property {boolean} [secure=false] `Secure` flag — caller opts in for prod + * @property {boolean} [httpOnly=true] `HttpOnly` flag + * @property {"Strict" | "Lax" | "None"} [sameSite="Lax"] `SameSite` attribute + * @property {number} [expirySafetyMarginSeconds=30] Seconds to subtract from token expiry when computing `Max-Age` + */ + +/** + * @param {CookieStoreOptions} options + * @returns {{ load(): Promise; save(json: string): Promise }} + */ +export function cookieStore(options) { + const { + request, + responseHeaders, + name = DEFAULT_NAME, + domain, + path = DEFAULT_PATH, + secure = false, + httpOnly = true, + sameSite = "Lax", + expirySafetyMarginSeconds = DEFAULT_SAFETY_MARGIN_SECONDS, + } = options; + + return { + async load() { + const cookies = parseCookieHeader(request.headers.get("cookie")); + const encoded = cookies[name]; + if (!encoded) return null; + try { + return decodeBase64Url(encoded); + } catch { + return null; + } + }, + async save(json) { + const value = encodeBase64Url(json); + const maxAge = maxAgeFromTokenJson(json, expirySafetyMarginSeconds); + responseHeaders.append( + "set-cookie", + serializeSetCookie({ name, value, domain, path, secure, httpOnly, sameSite, maxAge }), + ); + }, + }; +} + +// --------------------------------------------------------------------------- +// Vendored cookie parse / serialise — RFC 6265, minimal subset we need +// --------------------------------------------------------------------------- + +/** + * @param {string | null} header + * @returns {Record} + */ +function parseCookieHeader(header) { + /** @type {Record} */ + const out = {}; + if (!header) return out; + for (const pair of header.split(";")) { + const eq = pair.indexOf("="); + if (eq < 0) continue; + const k = pair.slice(0, eq).trim(); + const v = pair.slice(eq + 1).trim(); + if (k && !(k in out)) out[k] = v; + } + return out; +} + +/** + * @param {{ + * name: string; + * value: string; + * domain?: string; + * path?: string; + * secure?: boolean; + * httpOnly?: boolean; + * sameSite?: "Strict" | "Lax" | "None"; + * maxAge?: number; + * }} opts + * @returns {string} + */ +function serializeSetCookie(opts) { + const parts = [`${opts.name}=${opts.value}`]; + if (opts.domain) parts.push(`Domain=${opts.domain}`); + if (opts.path) parts.push(`Path=${opts.path}`); + if (typeof opts.maxAge === "number") parts.push(`Max-Age=${Math.floor(opts.maxAge)}`); + if (opts.httpOnly) parts.push("HttpOnly"); + if (opts.secure) parts.push("Secure"); + if (opts.sameSite) parts.push(`SameSite=${opts.sameSite}`); + return parts.join("; "); +} + +/** + * @param {string} input + * @returns {string} + */ +function encodeBase64Url(input) { + // btoa works on binary strings; encode the UTF-8 bytes first so non-ASCII + // round-trips. Token JSON is ASCII in practice but be defensive. + const bytes = new TextEncoder().encode(input); + let binary = ""; + for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]); + return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", ""); +} + +/** + * @param {string} input + * @returns {string} + */ +function decodeBase64Url(input) { + const padded = input.replaceAll("-", "+").replaceAll("_", "/"); + const pad = padded.length % 4 === 0 ? "" : "=".repeat(4 - (padded.length % 4)); + const binary = atob(padded + pad); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); + return new TextDecoder().decode(bytes); +} + +/** + * @param {string} json + * @param {number} safetyMarginSeconds + * @returns {number | undefined} + */ +function maxAgeFromTokenJson(json, safetyMarginSeconds) { + try { + const parsed = JSON.parse(json); + const expiresAt = parsed?.expires_at; + if (typeof expiresAt !== "number") return undefined; + const nowSeconds = Math.floor(Date.now() / 1000); + return Math.max(0, expiresAt - nowSeconds - safetyMarginSeconds); + } catch { + return undefined; + } +} diff --git a/languages/typescript/packages/auth/package.json b/languages/typescript/packages/auth/package.json index a96390338..96dc8bc43 100644 --- a/languages/typescript/packages/auth/package.json +++ b/languages/typescript/packages/auth/package.json @@ -1,6 +1,6 @@ { "name": "@cipherstash/auth", - "version": "0.37.0-alpha.3", + "version": "0.37.0-alpha.6", "main": "index.js", "types": "index.d.ts", "browser": false, @@ -20,8 +20,12 @@ "default": "./wasm/stack_auth_wasm.js" }, "./wasm-inline": { - "types": "./wasm-types.d.ts", - "default": "./wasm/stack_auth_wasm_inline.js" + "types": "./wasm-inline.d.ts", + "default": "./wasm-inline.mjs" + }, + "./cookies": { + "types": "./cookies.d.ts", + "default": "./cookies.mjs" } }, "napi": { @@ -44,6 +48,10 @@ "README.md", "stack-auth-node.js", "wasm-types.d.ts", + "wasm-inline.mjs", + "wasm-inline.d.ts", + "cookies.mjs", + "cookies.d.ts", "wasm/" ], "scripts": { diff --git a/languages/typescript/packages/auth/wasm-inline.d.ts b/languages/typescript/packages/auth/wasm-inline.d.ts new file mode 100644 index 000000000..b913af8fc --- /dev/null +++ b/languages/typescript/packages/auth/wasm-inline.d.ts @@ -0,0 +1,66 @@ +/* tslint:disable */ +/* eslint-disable */ + +/* + * Public TS surface for the `/wasm-inline` entry — the slick wrapper around + * the raw wasm-bindgen-generated bindings. Consumers see this; the raw + * `createWithStore(region, key, loadFn, saveFn)` shape stays internal. + * + * `AuthErrorCode`, `AuthError`, and `TokenResult` are shared with the + * lower-level `/wasm` entry via `wasm-types.d.ts` — re-exported here so + * importers only need one TS module reference. + */ + +export type { AuthErrorCode, AuthError, TokenResult } from "./wasm-types.d.ts"; + +/** + * Pluggable persistent cache for service tokens. Pair with the + * `cookieStore` helper from `@cipherstash/auth/cookies` to back the + * strategy with HTTP-only cookies in Edge / Workers / Bun / Deno / Node + * App Router runtimes — or hand-roll your own for KV, Redis, etc. + * + * Both methods are best-effort. `load` returning `null` / `undefined` is + * treated as "cache miss" and falls through to fresh authentication. + * Rejections in either callback are logged via `console.warn` and + * otherwise ignored — see [CIP-3114](https://linear.app/cipherstash/issue/CIP-3114). + */ +export interface TokenStore { + load(): Promise; + save(json: string): Promise; +} + +/** Options accepted by {@link AccessKeyStrategy.create}. */ +export interface AccessKeyStrategyOptions { + /** + * External persistence. Consulted on cold start before issuing any HTTP + * request, and written to after every successful refresh / initial auth. + * Use to share a service-token cache across short-lived strategy + * instances (one per Edge invocation, one per worker, etc). + */ + store?: TokenStore; +} + +/** + * An auth strategy that uses a static access key for service-to-service + * or CI/CD authentication. + */ +export declare class AccessKeyStrategy { + private constructor(); + /** + * Create a new `AccessKeyStrategy` for the given region and access key. + * + * Pass `options.store` to back the strategy with a persistent cache — + * see {@link TokenStore} and the + * {@link https://www.npmjs.com/package/@cipherstash/auth | `@cipherstash/auth/cookies`} + * helper. + */ + static create( + region: string, + accessKey: string, + options?: AccessKeyStrategyOptions, + ): AccessKeyStrategy; + /** Retrieve a valid access token, refreshing or re-authenticating as needed. */ + getToken(): Promise; + /** Release the underlying wasm resources. */ + free(): void; +} diff --git a/languages/typescript/packages/auth/wasm-inline.mjs b/languages/typescript/packages/auth/wasm-inline.mjs new file mode 100644 index 000000000..7291f3d1a --- /dev/null +++ b/languages/typescript/packages/auth/wasm-inline.mjs @@ -0,0 +1,54 @@ +/* @ts-self-types="./wasm-inline.d.ts" */ + +// Slick wrapper around the wasm-bindgen-generated inline-bytes shim. The raw +// `createWithStore(region, key, loadFn, saveFn)` factory below is replaced +// here with a single `create(region, key, { store })` shape — easier to +// extend with future options (lifecycle hooks, custom logging, etc.) without +// breaking callers, and matches the options-object pattern most modern JS +// APIs use. + +import { AccessKeyStrategy as RawAccessKeyStrategy } from "./wasm/stack_auth_wasm_inline.js"; + +/** @typedef {{ load(): Promise; save(json: string): Promise }} TokenStore */ +/** @typedef {{ store?: TokenStore }} AccessKeyStrategyOptions */ + +export class AccessKeyStrategy { + #inner; + + /** @param {RawAccessKeyStrategy} inner */ + constructor(inner) { + this.#inner = inner; + } + + /** + * @param {string} region + * @param {string} accessKey + * @param {AccessKeyStrategyOptions} [options] + * @returns {AccessKeyStrategy} + */ + static create(region, accessKey, options) { + const store = options?.store; + if (store) { + // Wrap the user's `load` / `save` so the wasm binding always sees + // Promise-returning functions even if the caller passed sync ones — + // `js_sys::Promise::from` on the wasm side casts the return value as + // a Promise unconditionally, so sync values would otherwise reject. + const load = () => Promise.resolve(store.load()); + const save = (/** @type {string} */ json) => + Promise.resolve(store.save(json)); + return new AccessKeyStrategy( + RawAccessKeyStrategy.createWithStore(region, accessKey, load, save), + ); + } + return new AccessKeyStrategy(RawAccessKeyStrategy.create(region, accessKey)); + } + + /** @returns {Promise} */ + getToken() { + return this.#inner.getToken(); + } + + free() { + this.#inner.free(); + } +} diff --git a/languages/typescript/packages/auth/wasm-types.d.ts b/languages/typescript/packages/auth/wasm-types.d.ts index 7846cba60..71e8f48c9 100644 --- a/languages/typescript/packages/auth/wasm-types.d.ts +++ b/languages/typescript/packages/auth/wasm-types.d.ts @@ -2,21 +2,25 @@ /* eslint-disable */ /* - * Hand-typed overlay for the wasm-bindgen-generated bindings. + * Hand-typed overlay for the raw wasm-bindgen output behind the `/wasm` + * sub-path. Most consumers should reach for the slick wrapper at + * `/wasm-inline` (see `wasm-inline.d.ts`) which exposes the options-object + * API and the `cookieStore`-friendly shape. This file documents the + * lower-level surface: a single `create(region, accessKey)` factory with + * no built-in store wiring. * - * The wasm-bindgen build emits `wasm/stack_auth_wasm.d.ts` automatically, but - * its types are looser than we want (`Promise` for `getToken`, and it + * The wasm-bindgen build emits `wasm/stack_auth_wasm.d.ts` automatically, + * but its types are looser than we want (`Promise` for `getToken`, * leaks internal `wasm-streams` types like `IntoUnderlyingByteSource` that - * arrive transitively via reqwest's wasm32 fetch backend). This file is the - * `types` entry for the `deno` / `worker` / `browser` / `default` conditions - * in the `exports` map — at runtime callers load the auto-generated `.js` - * shim, but the types they see come from here. + * arrive transitively via reqwest's wasm32 fetch backend). This file is + * the `types` entry for `/wasm` — at runtime callers load the + * auto-generated `.js` shim, but the types they see come from here. * - * The Node entry continues to use `index.d.ts`, which exposes the full surface - * (including filesystem- and browser-backed features like the device-code flow - * and profile-store loading) that doesn't compile to wasm32. OAuth-based - * strategies on wasm (`OAuthStrategy`, `AutoStrategy`) are deferred to a - * follow-up — see the Layer 3.5 notes in `wasm-analysis.md`. + * The Node entry uses `index.d.ts`, which exposes the full surface + * (including filesystem- and browser-backed features like the device-code + * flow and profile-store loading) that doesn't compile to wasm32. + * OAuth-based strategies on wasm (`OAuthStrategy`, `AutoStrategy`) are + * deferred to a follow-up — see the Layer 3.5 notes in `wasm-analysis.md`. */ /** Error codes attached to errors thrown by this package. */ @@ -59,49 +63,16 @@ export interface TokenResult { services: Record } -/** - * Async callback used by `AccessKeyStrategy.createWithStore` to load a - * previously-persisted token JSON. Returning `null` (or `undefined`) signals - * "no token cached" — the strategy will fall through to re-authenticating - * with the access key. - */ -export type LoadTokenCallback = () => Promise - -/** - * Async callback used by `AccessKeyStrategy.createWithStore` to persist a - * freshly-issued token JSON. The string is opaque to the caller — pass it - * back unchanged to a future `LoadTokenCallback` (e.g. set it as a cookie - * value, write it to a KV store, etc.). - */ -export type SaveTokenCallback = (json: string) => Promise - /** * An auth strategy that uses a static access key for service-to-service - * or CI/CD authentication. + * or CI/CD authentication. This is the raw bundler-target binding — + * consumers wanting the options-object / cookie-store-friendly shape + * should import from `/wasm-inline` instead. */ export declare class AccessKeyStrategy { private constructor() /** Create a new `AccessKeyStrategy` for the given region and access key. */ static create(region: string, accessKey: string): AccessKeyStrategy - /** - * Create an `AccessKeyStrategy` backed by external token-store callbacks. - * - * The strategy consults `loadToken` on cold start before issuing any HTTP - * request — if it returns a still-valid token, that's reused. After every - * successful refresh or initial authentication the new token JSON is - * written back via `saveToken`. Both callbacks return Promises so they - * can perform async I/O (read a cookie, write to KV, etc.). - * - * The JSON string passed to `saveToken` contains the bearer credential - * verbatim — treat it as secret material. End-to-end protection at rest - * (encrypting before persistence) is a planned follow-up. - */ - static createWithStore( - region: string, - accessKey: string, - loadToken: LoadTokenCallback, - saveToken: SaveTokenCallback, - ): AccessKeyStrategy /** Retrieve a valid access token, refreshing or re-authenticating as needed. */ getToken(): Promise /** Release the underlying wasm resources. */ From 846799e5c16ce980ffe80fc4fc77ea93f553ed53 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Sat, 16 May 2026 16:28:49 +1000 Subject: [PATCH 225/686] docs(stack-auth/auth): document slick API + cookieStore + /cookies entry Refresh consumer-facing docs to reflect the shape the wasm bindings actually ship with after PR cipherstash/cipherstash-suite#1959's revisions: - `packages/stack-auth/node/README.md`: adds `/cookies` to the entries table, rewrites the Supabase Edge example to use the slick `create(region, key, { store })` API + `cookieStore`, documents `cookieStore`'s options reference, shows the "roll your own store" fallback, and updates the API section's `AccessKeyStrategy.create` signature. - `packages/stack-auth/wasm/README.md`: lists three published entries (`/wasm-inline` slick + `/wasm` raw + `/cookies` helper); flags the CIP-3114 console.warn behaviour for callback rejections. - `wasm-analysis.md`: updates the exports table to include `/cookies` and the slick wrapper at `/wasm-inline.mjs`; extends the prerelease history through alpha.6. --- docs/wasm-analysis.md | 9 ++- languages/typescript/packages/auth/README.md | 74 +++++++++++++++++-- .../packages/stack-auth-wasm/README.md | 9 ++- 3 files changed, 78 insertions(+), 14 deletions(-) diff --git a/docs/wasm-analysis.md b/docs/wasm-analysis.md index 7b4bcac54..25374320f 100644 --- a/docs/wasm-analysis.md +++ b/docs/wasm-analysis.md @@ -46,13 +46,14 @@ End-to-end validated against a live Supabase Edge Function returning a real `Tok - Rust panics on wasm surface as opaque `RuntimeError: unreachable` from bytecode offsets. Added `console_error_panic_hook` and route panics to `console.error` via a `#[wasm_bindgen(start)]` module-init function. - `wasm-pack --target deno` doesn't work in the Supabase Edge Runtime — its sandbox blocks `fetch('file://…')`, which is how the deno target loads its sibling `.wasm`. Made `--target bundler` the primary build (uses `import * as wasm from "./*.wasm"`, which Edge resolves natively); deno target retained for vanilla `deno run`. -**PR #1953 — npm unification.** Stacks on #1952. Single `@cipherstash/auth` npm package serves Node, browser/bundler, and edge consumers from one install. Final `exports` shape: +**PR #1953 — npm unification.** Stacks on #1952. Single `@cipherstash/auth` npm package serves Node, browser/bundler, and edge consumers from one install. Final `exports` shape (post-PRs #1958 + #1959): | Entry | `node` condition | `default` condition | |---|---|---| | `.` (main) | `./index.js` (napi loader) + `./index.d.ts` | `./wasm/stack_auth_wasm.js` (bundler-target, sibling `.wasm`) + `./wasm-types.d.ts` | -| `./wasm` | — | `./wasm/stack_auth_wasm.js` (explicit alias of `.`'s default) | -| `./wasm-inline` | — | `./wasm/stack_auth_wasm_inline.js` (inline-bytes) | +| `./wasm` | — | `./wasm/stack_auth_wasm.js` (raw bundler-target shim; lower-level surface) | +| `./wasm-inline` | — | `./wasm-inline.mjs` (slick wrapper with options-object API + inline-bytes wasm via `./wasm/stack_auth_wasm_inline.js`) | +| `./cookies` | — | `./cookies.mjs` (pure-JS `cookieStore` helper for WHATWG-fetch runtimes) | Consumer routing: - **Node** — bare `@cipherstash/auth`, gets full napi surface (device-code, profile-store, OAuth, AccessKeyStrategy). @@ -71,7 +72,7 @@ Other validation-driven fixes folded into the PR: - `serde_wasm_bindgen::Serializer::json_compatible()` for the `TokenResultPayload` so `services: BTreeMap` serialises as a plain JS object — `BTreeMap` defaults to JS `Map`, which `JSON.stringify` flattens to `"{}"`, dropping every entry. The `wasm-types.d.ts` overlay declares `services: Record`, so this aligns runtime shape with declared type. - `wasm-types.d.ts` is committed hand-written (refines `Promise` → `Promise`, hides wasm-streams type leakage from reqwest's fetch backend, scoped to `AccessKeyStrategy`). -- CI (`publish-auth-npm.yml`) gains a `build-wasm` job that runs wasm-pack + the inline-bytes postbuild script (`scripts/inline-wasm.mjs`); the `publish` job depends on it so every release ships the inline shim. Prerelease pipeline validated through `0.37.0-alpha.0` (bundler-target only) → `0.37.0-alpha.1` (inline added) → `0.37.0-alpha.2` (services serialization fix; also tested a default-entry flip that turned out not to help Edge consumers) → `0.37.0-alpha.3` (default-flip reverted, docs corrected). All published under the `next` dist-tag. +- CI (`publish-auth-npm.yml`) gains a `build-wasm` job that runs wasm-pack + the inline-bytes postbuild script (`scripts/inline-wasm.mjs`); the `publish` job depends on it so every release ships the inline shim. Prerelease pipeline validated through `0.37.0-alpha.0` (bundler-target only) → `0.37.0-alpha.1` (inline added) → `0.37.0-alpha.2` (services serialization fix; also tested a default-entry flip that turned out not to help Edge consumers) → `0.37.0-alpha.3` (default-flip reverted, docs corrected) → `0.37.0-alpha.5` (TokenStore trait + wasm `createWithStore` bindings landed via PRs #1958 + #1959) → `0.37.0-alpha.6` (slick options-object API + built-in `cookieStore` helper; spike's integration code dropped to ~35 lines, three of which are stack-auth-related). All published under the `next` dist-tag. Rationale for this layer: protect-wasm (Layer 4) will need to wrap auth strategies anyway. Establishing the wasm-bindgen toolchain, inline-bytes postbuild pattern, and the exports-map shape here on a small crate means Layer 4 doesn't absorb both the toolchain bootstrap and the encrypt/decrypt porting in the same PR. diff --git a/languages/typescript/packages/auth/README.md b/languages/typescript/packages/auth/README.md index cd3cc44f6..b23beac40 100644 --- a/languages/typescript/packages/auth/README.md +++ b/languages/typescript/packages/auth/README.md @@ -15,7 +15,7 @@ Authentication bindings for [CipherStash](https://cipherstash.com) services. Shi npm install @cipherstash/auth ``` -The package exposes three entries: +The package exposes four entries: | Entry | Use when | Loads | Surface | |---|---|---|---| @@ -23,6 +23,7 @@ The package exposes three entries: | `@cipherstash/auth` | **SSR bundlers** (Vite/Webpack/Next.js targeting Node or server-side rendering) | Sibling-`.wasm` shim from `wasm-pack --target bundler` | `AccessKeyStrategy` | | `@cipherstash/auth/wasm` | Explicit opt-in to the sibling-`.wasm` shim | Same as bundler entry above | `AccessKeyStrategy` | | `@cipherstash/auth/wasm-inline` | **Supabase Edge Functions / Cloudflare Workers / Bun / Deno via `npm:`** — runtimes that can't auto-bundle a sibling `.wasm` | Inline-bytes shim (wasm embedded as base64) | `AccessKeyStrategy` | +| `@cipherstash/auth/cookies` | Any runtime with WHATWG `Request`/`Headers` (Edge, Workers, Bun, Deno, Node 18+, Next.js App Router) | Pure-JS helper | `cookieStore(...)` — builds a `TokenStore` from a `Request + Headers` pair | The wasm bindings are deliberately scoped to `AccessKeyStrategy` — OAuth, device-code flow, and profile-store features depend on Node-only APIs (filesystem, browser launching) that can't be ported. @@ -48,23 +49,30 @@ The token is saved to `~/.cipherstash/auth.json` automatically and is never expo ## Edge usage — Supabase Edge Functions / Cloudflare Workers -Use the explicit `wasm-inline` sub-path. Full Supabase Edge Function example: +Pair the `wasm-inline` entry with the `cookies` helper to back the strategy with an HTTP-only cookie. Every Edge invocation gets a fresh strategy, but the cookie keeps the issued service token alive across invocations — so only the first request pays the full round-trip to CTS: ```ts // supabase/functions/get-token/index.ts import { AccessKeyStrategy } from "@cipherstash/auth/wasm-inline"; +import { cookieStore } from "@cipherstash/auth/cookies"; + +Deno.serve(async (req) => { + const responseHeaders = new Headers({ "content-type": "application/json" }); -Deno.serve(async () => { const strategy = AccessKeyStrategy.create( "ap-southeast-2.aws", Deno.env.get("CS_CLIENT_ACCESS_KEY")!, + { store: cookieStore({ request: req, responseHeaders }) }, ); const { token, workspaceId, services } = await strategy.getToken(); // `token` is the bearer credential; pass as `Authorization: Bearer ${token}` // to ZeroKMS at `services.zerokms`. - return Response.json({ workspaceId, services }); + return new Response( + JSON.stringify({ workspaceId, services }), + { headers: responseHeaders }, + ); }); ``` @@ -73,7 +81,8 @@ Deno.serve(async () => { ```jsonc { "imports": { - "@cipherstash/auth/wasm-inline": "npm:@cipherstash/auth@^0.37/wasm-inline" + "@cipherstash/auth/wasm-inline": "npm:@cipherstash/auth@^0.37/wasm-inline", + "@cipherstash/auth/cookies": "npm:@cipherstash/auth@^0.37/cookies" } } ``` @@ -84,6 +93,44 @@ Nothing extra in `supabase/config.toml` — no `static_files`, no asset copying, For Cloudflare Workers the shape is identical; env access becomes `env.CS_CLIENT_ACCESS_KEY` instead of `Deno.env.get(...)`. +### Caching with `cookieStore` + +`cookieStore({ request, responseHeaders })` returns a `TokenStore`: + +- `load()` parses the `Cookie:` header from the request, finds `cs_token` (configurable via `name`), base64url-decodes it, and returns the JSON the strategy stored last time. +- `save(json)` happens automatically after every successful refresh / initial auth — `cookieStore` appends a `Set-Cookie` header to `responseHeaders` with the JSON base64url-encoded as the value, `HttpOnly`, `SameSite=Lax`, and `Max-Age` derived from the token's `expires_at` minus a 30-second safety margin. + +Available options: + +| Option | Default | Notes | +|---|---|---| +| `request` | — required — | Incoming `Request` to read the cookie from | +| `responseHeaders` | — required — | Outgoing `Headers` to append `Set-Cookie` to | +| `name` | `"cs_token"` | Cookie name | +| `domain` | unset | `Domain` attribute (host-only by default) | +| `path` | `"/"` | `Path` attribute | +| `secure` | `false` | Opt in for production HTTPS | +| `httpOnly` | `true` | Prevents JS access — keep this on | +| `sameSite` | `"Lax"` | `"Strict"` / `"Lax"` / `"None"` | +| `expirySafetyMarginSeconds` | `30` | Seconds subtracted from `expires_at` when computing `Max-Age` | + +The base64url encoding skirts RFC 6265's cookie-value char range, which would otherwise reject the `"` characters present in raw JSON. + +### Rolling your own store + +The `store` field accepts any `{ load, save }`-shaped object — Redis, KV stores, an in-memory `Map`, anything you'd reach for: + +```ts +const strategy = AccessKeyStrategy.create(region, accessKey, { + store: { + async load() { return await redis.get("cs:token"); /* string | null */ }, + async save(json: string) { await redis.set("cs:token", json); }, + }, +}); +``` + +Errors thrown inside `load` / `save` are caught and logged via `console.warn` — the strategy treats them as cache misses and falls back to fresh authentication. + ### Why the explicit sub-path Bare `@cipherstash/auth` works in Node (resolves to native napi) and in wasm-aware bundlers (Vite/Webpack handle the sibling-`.wasm` import natively). @@ -130,11 +177,26 @@ Starts the OAuth 2.0 Device Authorization flow. Returns a `Promise; // null = cache miss + save(json: string): Promise; +} +``` + +The strategy calls `load` on cold start (no in-memory token); if it returns a still-fresh JSON, the strategy reuses it. Otherwise it hits CTS for a fresh token and writes it back via `save`. Stale tokens trigger a refresh and the refreshed token is persisted. + +### Edge — `cookieStore` + +Helper that returns a `TokenStore` backed by an HTTP-only cookie. Works in any runtime that exposes WHATWG `Request` / `Headers`. See the [Caching with `cookieStore`](#caching-with-cookiestore) section above for the option reference. + ## Error handling Errors thrown from this package extend `Error` with a machine-readable `.code` property: diff --git a/languages/typescript/packages/stack-auth-wasm/README.md b/languages/typescript/packages/stack-auth-wasm/README.md index f15e29f03..c2f9f8466 100644 --- a/languages/typescript/packages/stack-auth-wasm/README.md +++ b/languages/typescript/packages/stack-auth-wasm/README.md @@ -26,9 +26,10 @@ Pure-logic coverage — JWT claim extraction, services-as-plain-object serialisa ## Published shape -The `@cipherstash/auth` package exposes two wasm entries built from this crate: +The `@cipherstash/auth` package exposes three wasm-related entries: -- `@cipherstash/auth` (default for non-Node) and `@cipherstash/auth/wasm-inline` — inline-bytes shim with the wasm embedded as base64. Zero-config in Supabase Edge, Cloudflare Workers, browsers, Deno, Bun. -- `@cipherstash/auth/wasm` — sibling-`.wasm` shim from `wasm-pack --target bundler`. Smaller bundle for consumers using a wasm-aware bundler (Vite/Webpack). +- `@cipherstash/auth/wasm-inline` — hand-written ESM wrapper around the inline-bytes bundle (wasm embedded as base64). Exposes the slick options-object API: `AccessKeyStrategy.create(region, key, { store })`. Zero-config in Supabase Edge, Cloudflare Workers, Deno, Bun. +- `@cipherstash/auth/wasm` — raw sibling-`.wasm` shim from `wasm-pack --target bundler`. Lower-level surface (no options-object wrapper) for consumers using a wasm-aware bundler (Vite/Webpack). +- `@cipherstash/auth/cookies` — pure-JS helper `cookieStore({ request, responseHeaders, ... })` returning a `TokenStore`-shaped object. No wasm dependency; works in any WHATWG-fetch runtime, and forward-compatible with the future napi binding (CIP-3113). -Both expose the same surface. See [`../node/README.md`](../node/README.md) for consumer-facing usage. +`AccessKeyStrategy.create()` accepts an optional `{ store }` field that takes any `{ load, save }` shape. JS callback rejections inside the store are logged via `web_sys::console::warn_2` rather than swallowed (CIP-3114). See [`../node/README.md`](../node/README.md) for consumer-facing usage and the `cookieStore` reference. From d39a1c32f4997cda08cc870bf9cc3ffc42f52b51 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Sat, 16 May 2026 16:34:54 +1000 Subject: [PATCH 226/686] fix(stack-auth/wasm): scope `web-sys` to the wasm32 target MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `web_sys::console::warn_2` call landed in CIP-3114's fix lives inside a `#[cfg(target_arch = "wasm32")]` block, but the dep was in the always-on `[dependencies]` table — so on the native target `cargo udeps --all-targets` (the `test-no-unused-cargo-dependencies` workflow) saw `web-sys` imported with no consumer and flagged it. Move the dep into a `[target.'cfg(target_arch = "wasm32")'.dependencies]` block so its presence matches the code paths that consume it. Both native and wasm32 cargo checks pass. The workflow has been on main since 2026-05-12 (commit 21e8a9582); we just didn't trip it until adding `web-sys`. --- .../typescript/packages/stack-auth-wasm/Cargo.toml | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/languages/typescript/packages/stack-auth-wasm/Cargo.toml b/languages/typescript/packages/stack-auth-wasm/Cargo.toml index 6ed3f8044..946654018 100644 --- a/languages/typescript/packages/stack-auth-wasm/Cargo.toml +++ b/languages/typescript/packages/stack-auth-wasm/Cargo.toml @@ -21,11 +21,17 @@ wasm-bindgen = "0.2" wasm-bindgen-futures = "0.4" serde-wasm-bindgen = "0.6" js-sys = "0.3" -# `console` feature gives us `web_sys::console::warn_1` for surfacing JS -# callback rejections inside `JsTokenStore` (CIP-3114). -web-sys = { version = "0.3", features = ["console"] } console_error_panic_hook = "0.1" +[target.'cfg(target_arch = "wasm32")'.dependencies] +# `console` feature gives us `web_sys::console::warn_2` for surfacing JS +# callback rejections inside `JsTokenStore` (CIP-3114). Target-gated to +# wasm32 because the consuming code in `JsTokenStore` is itself cfg-gated; +# on native the crate has no use for it and `cargo udeps --all-targets` +# (the `test-no-unused-cargo-dependencies` workflow) would flag it as +# unused. +web-sys = { version = "0.3", features = ["console"] } + [target.'cfg(target_arch = "wasm32")'.dev-dependencies] wasm-bindgen-test = "0.3" base64 = { workspace = true } From f2d439f273232be80924aa8efe4e06c21847e2a0 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Wed, 20 May 2026 11:21:40 +1000 Subject: [PATCH 227/686] fix(stack-auth): Zeroize JsTokenStore JSON + default cookieStore to Secure Two findings from a code-review pass on this PR: - `JsTokenStore::load` left bearer-token JSON in plain `String` heap memory after parsing. Wrap in `Zeroizing` to match the upstream `CallbackTokenStore::load` behaviour. Also wrap the save-side serialised JSON for symmetry. - `cookieStore` defaulted `secure: false` for a credential-bearing cookie. Flip to `secure: true`; localhost HTTP dev opts out explicitly. Docs in `cookies.d.ts` and `node/README.md` updated to match. Adds `zeroize` as a wasm32-gated dep on the wasm crate, matching the target-gating already used for `web-sys`. --- languages/typescript/packages/auth/README.md | 2 +- languages/typescript/packages/auth/cookies.d.ts | 2 +- languages/typescript/packages/auth/cookies.mjs | 4 ++-- languages/typescript/packages/stack-auth-wasm/Cargo.toml | 5 +++++ languages/typescript/packages/stack-auth-wasm/src/lib.rs | 8 ++++++-- 5 files changed, 15 insertions(+), 6 deletions(-) diff --git a/languages/typescript/packages/auth/README.md b/languages/typescript/packages/auth/README.md index b23beac40..d8ae1cb5f 100644 --- a/languages/typescript/packages/auth/README.md +++ b/languages/typescript/packages/auth/README.md @@ -109,7 +109,7 @@ Available options: | `name` | `"cs_token"` | Cookie name | | `domain` | unset | `Domain` attribute (host-only by default) | | `path` | `"/"` | `Path` attribute | -| `secure` | `false` | Opt in for production HTTPS | +| `secure` | `true` | Set `false` only for localhost HTTP dev | | `httpOnly` | `true` | Prevents JS access — keep this on | | `sameSite` | `"Lax"` | `"Strict"` / `"Lax"` / `"None"` | | `expirySafetyMarginSeconds` | `30` | Seconds subtracted from `expires_at` when computing `Max-Age` | diff --git a/languages/typescript/packages/auth/cookies.d.ts b/languages/typescript/packages/auth/cookies.d.ts index 4e581656d..0d767d789 100644 --- a/languages/typescript/packages/auth/cookies.d.ts +++ b/languages/typescript/packages/auth/cookies.d.ts @@ -29,7 +29,7 @@ export interface CookieStoreOptions { domain?: string; /** `Path` attribute. Default: `"/"`. */ path?: string; - /** `Secure` flag. Caller opts in for production. Default: `false`. */ + /** `Secure` flag. Set `false` only for localhost HTTP dev. Default: `true`. */ secure?: boolean; /** `HttpOnly` flag — prevents JS access. Default: `true`. */ httpOnly?: boolean; diff --git a/languages/typescript/packages/auth/cookies.mjs b/languages/typescript/packages/auth/cookies.mjs index baa62442a..1b5613dab 100644 --- a/languages/typescript/packages/auth/cookies.mjs +++ b/languages/typescript/packages/auth/cookies.mjs @@ -23,7 +23,7 @@ const DEFAULT_SAFETY_MARGIN_SECONDS = 30; * @property {string} [name="cs_token"] Cookie name * @property {string} [domain] `Domain` attribute * @property {string} [path="/"] `Path` attribute - * @property {boolean} [secure=false] `Secure` flag — caller opts in for prod + * @property {boolean} [secure=true] `Secure` flag — set to `false` only for localhost HTTP dev * @property {boolean} [httpOnly=true] `HttpOnly` flag * @property {"Strict" | "Lax" | "None"} [sameSite="Lax"] `SameSite` attribute * @property {number} [expirySafetyMarginSeconds=30] Seconds to subtract from token expiry when computing `Max-Age` @@ -40,7 +40,7 @@ export function cookieStore(options) { name = DEFAULT_NAME, domain, path = DEFAULT_PATH, - secure = false, + secure = true, httpOnly = true, sameSite = "Lax", expirySafetyMarginSeconds = DEFAULT_SAFETY_MARGIN_SECONDS, diff --git a/languages/typescript/packages/stack-auth-wasm/Cargo.toml b/languages/typescript/packages/stack-auth-wasm/Cargo.toml index 946654018..6c741900c 100644 --- a/languages/typescript/packages/stack-auth-wasm/Cargo.toml +++ b/languages/typescript/packages/stack-auth-wasm/Cargo.toml @@ -31,6 +31,11 @@ console_error_panic_hook = "0.1" # (the `test-no-unused-cargo-dependencies` workflow) would flag it as # unused. web-sys = { version = "0.3", features = ["console"] } +# `Zeroizing` wraps the JSON-serialised `Token` while it sits in Rust heap +# between (de)serialisation and crossing the JS boundary, matching the +# behaviour of the upstream `CallbackTokenStore` on native. Target-gated for +# the same reason as `web-sys`. +zeroize = { workspace = true } [target.'cfg(target_arch = "wasm32")'.dev-dependencies] wasm-bindgen-test = "0.3" diff --git a/languages/typescript/packages/stack-auth-wasm/src/lib.rs b/languages/typescript/packages/stack-auth-wasm/src/lib.rs index 7930aa2eb..4e3cd634c 100644 --- a/languages/typescript/packages/stack-auth-wasm/src/lib.rs +++ b/languages/typescript/packages/stack-auth-wasm/src/lib.rs @@ -20,6 +20,8 @@ use stack_auth::{Token, TokenStore}; use wasm_bindgen::prelude::*; #[cfg(target_arch = "wasm32")] use wasm_bindgen_futures::JsFuture; +#[cfg(target_arch = "wasm32")] +use zeroize::Zeroizing; /// Route Rust panics to `console.error` with a readable message + stack. /// Without this, panics surface as opaque `RuntimeError: unreachable` from @@ -111,7 +113,9 @@ impl TokenStore for JsTokenStore { }; match JsFuture::from(js_sys::Promise::from(promise)).await { Ok(result) => { - let json = result.as_string()?; + // Zero the JSON heap buffer on drop — it carries the bearer + // token in cleartext between the JS boundary and serde. + let json = Zeroizing::new(result.as_string()?); serde_json::from_str(&json).ok() } Err(err) => { @@ -122,7 +126,7 @@ impl TokenStore for JsTokenStore { } async fn save(&self, token: &Token) { - let Ok(json) = serde_json::to_string(token) else { + let Ok(json) = serde_json::to_string(token).map(Zeroizing::new) else { return; }; let promise = match self.save.call1(&JsValue::NULL, &JsValue::from_str(&json)) { From b596dfbadf4c95192c804dda789c1948d7a4a98c Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Wed, 20 May 2026 11:21:52 +1000 Subject: [PATCH 228/686] chore(stack-auth/node): regenerate index.d.ts; preserve manual AuthError block `pnpm test` (which triggers `napi build`) regenerated the .d.ts against the napi crate's current state. Two effects: - Picks up new test-utils-feature-gated exports added on main while this branch was outstanding (`beginDeviceCodeFlowWithBaseUrl`, `bindClientDeviceWithProfileDir`, ...). - Drops the hand-maintained `AuthErrorCode` union + `AuthError` interface that lived above the auto-generated content. napi-rs has no equivalent for these (the Rust error type isn't a `#[napi]` string enum), and the regen overwrites the file wholesale. Re-add the manual block on top of the regenerated body. Header comment restored to "with manual additions for error enrichment". A follow-up will look at moving the manual types out of the generated file entirely so this doesn't recur. --- languages/typescript/packages/auth/index.d.ts | 52 +++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/languages/typescript/packages/auth/index.d.ts b/languages/typescript/packages/auth/index.d.ts index 194946e2c..566c38377 100644 --- a/languages/typescript/packages/auth/index.d.ts +++ b/languages/typescript/packages/auth/index.d.ts @@ -75,6 +75,58 @@ export interface AuthResult { export declare function bindClientDevice(): Promise /** Begin the OAuth 2.0 Device Authorization flow. */ export declare function beginDeviceCodeFlow(region: string, clientId: string): Promise +/** + * Variant of `beginDeviceCodeFlow` that targets a custom auth server URL. + * + * Intended for **testing only** — requires the crate to be built with the + * `test-utils` Cargo feature. + */ +export declare function beginDeviceCodeFlowWithBaseUrl(region: string, clientId: string, baseUrl: string): Promise +/** + * Variant of `provisionDeviceClient` that uses a custom profile directory. + * + * Intended for **testing only** — requires the crate to be built with the + * `test-utils` Cargo feature. + */ +export declare function bindClientDeviceWithProfileDir(profileDir: string): Promise +/** + * Save a test auth token to the given profile directory with the ZeroKMS + * service URL set to `zerokms_base_url`. + * + * Intended for **testing only** — requires the crate to be built with the + * `test-utils` Cargo feature. + */ +export declare function saveTestToken(profileDir: string, zerokmsBaseUrl: string): void +export declare class MockAuthServer { + /** Start a mock auth server on a random port. */ + static start(): Promise + /** The base URL of the running mock server (e.g. `http://127.0.0.1:12345`). */ + get baseUrl(): string + /** + * Register a mock for `POST /oauth/device/code` that returns a standard + * device-code JSON response. + */ + mockDeviceCodeEndpoint(): void + /** + * Register a mock for `POST /oauth/device/token` that returns a standard + * token JSON response. + */ + mockTokenEndpoint(): void + /** + * Register a mock for `POST /oauth/device/token` that returns a 400 error + * with the given OAuth error code and optional description. + */ + mockTokenEndpointError(code: string, description?: string | undefined | null): void + /** + * Register a mock for `POST /create-client` that returns a successful + * create-client JSON response (as ZeroKMS would). + */ + mockCreateClientEndpoint(): void + /** Register a mock for `POST /create-client` that returns a 409 conflict. */ + mockCreateClientConflict(): void + /** Remove all registered mocks. */ + clearMocks(): void +} /** * An auth strategy that auto-detects credentials from environment variables * and the local profile store. From c7592c8920aa781d601c9dd1ff65f293a753c3df Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Sat, 16 May 2026 17:01:17 +1000 Subject: [PATCH 229/686] feat(stack-auth): add CallbackAuthStrategy for foreign-callback strategies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirror of `CallbackTokenStore` for the single-method `AuthStrategy` trait. Wraps an async closure into something `cipherstash-client` accepts via its `for<'a> &'a C: AuthStrategy` HRTB bound. The intended consumer is `protect-ffi`'s Neon-side adapter — wrapping a JS `strategy.getToken()` callable into a `JsAuthStrategy` struct that delegates through `CallbackAuthStrategy`, then passes the result to `ZeroKMSBuilder::new`. See `auth-strategy-handover.md` for the wider design. Beyond that immediate driver: useful for any foreign Rust consumer bringing a non-stack-auth-native strategy (test fixtures, third-party sidecars, future bridge crates). Two unit tests cover the happy path (closure invoked, token returned) and error propagation (AuthError variants flow through unchanged). --- packages/stack-auth/src/callback_strategy.rs | 130 +++++++++++++++++++ packages/stack-auth/src/lib.rs | 2 + 2 files changed, 132 insertions(+) create mode 100644 packages/stack-auth/src/callback_strategy.rs diff --git a/packages/stack-auth/src/callback_strategy.rs b/packages/stack-auth/src/callback_strategy.rs new file mode 100644 index 000000000..ea5c407a1 --- /dev/null +++ b/packages/stack-auth/src/callback_strategy.rs @@ -0,0 +1,130 @@ +//! [`AuthStrategy`] adapter built from an async closure. +//! +//! Mirrors [`CallbackTokenStore`](crate::CallbackTokenStore) but for the +//! single-method [`AuthStrategy`] trait. Lets foreign crates — most notably +//! `protect-ffi`, which hosts JS callbacks across a Neon boundary — present +//! a [`stack-auth`](crate)-shaped strategy to [`cipherstash-client`] without +//! depending on `stack-auth`'s concrete strategy types. +//! +//! See `auth-strategy-handover.md` at the repo root for the wider design +//! discussion this scaffolding supports. +//! +//! [`cipherstash-client`]: https://docs.rs/cipherstash-client/ + +use std::future::Future; + +use crate::{AuthError, AuthStrategy, ServiceToken}; + +/// An [`AuthStrategy`] backed by a user-supplied async closure that returns +/// a [`ServiceToken`]. +/// +/// Use this when the actual token acquisition lives outside `stack-auth` — +/// behind an FFI callback, a custom IPC channel, a test fixture, etc. The +/// `cipherstash-client` integration test [in this crate's +/// `tests/`](https://github.com/cipherstash/cipherstash-suite/tree/main/packages/cipherstash-client/tests) +/// exercises this shape end-to-end against a mocktail server. +/// +/// # Example +/// +/// ```no_run +/// use stack_auth::{CallbackAuthStrategy, SecretToken, ServiceToken}; +/// +/// let strategy = CallbackAuthStrategy::new(|| async { +/// // Real consumers would call into FFI / IPC / a cached token store. +/// Ok(ServiceToken::new(SecretToken::new("dummy.jwt.value".to_string()))) +/// }); +/// ``` +pub struct CallbackAuthStrategy { + get_token: F, +} + +impl CallbackAuthStrategy { + /// Build a `CallbackAuthStrategy` from an async closure. The closure + /// fires every time [`AuthStrategy::get_token`] is called on a reference + /// to this strategy — typically once per `cipherstash-client` HTTP + /// request, modulo the in-process [`AutoRefresh`](crate::auto_refresh) + /// cache layered on top by individual strategy implementations. + pub fn new(get_token: F) -> Self { + Self { get_token } + } +} + +#[cfg(not(target_arch = "wasm32"))] +impl AuthStrategy for &CallbackAuthStrategy +where + F: Fn() -> Fut + Send + Sync, + Fut: Future> + Send, +{ + fn get_token(self) -> impl Future> + Send { + (self.get_token)() + } +} + +#[cfg(target_arch = "wasm32")] +impl AuthStrategy for &CallbackAuthStrategy +where + F: Fn() -> Fut, + Fut: Future>, +{ + fn get_token(self) -> impl Future> { + (self.get_token)() + } +} + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod tests { + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::sync::Arc; + + use crate::SecretToken; + + use super::*; + + fn dummy_service_token(jwt: &str) -> ServiceToken { + ServiceToken::new(SecretToken::new(jwt.to_string())) + } + + #[tokio::test] + async fn closure_runs_on_each_get_token_call() { + let calls = Arc::new(AtomicUsize::new(0)); + let calls_clone = Arc::clone(&calls); + let strategy = CallbackAuthStrategy::new(move || { + let calls = Arc::clone(&calls_clone); + async move { + let n = calls.fetch_add(1, Ordering::SeqCst); + Ok(dummy_service_token(&format!("jwt-{n}"))) + } + }); + + let first = (&strategy).get_token().await.unwrap(); + assert_eq!( + first.as_str(), + "jwt-0", + "first call should yield the first token the closure produced" + ); + + let second = (&strategy).get_token().await.unwrap(); + assert_eq!( + second.as_str(), + "jwt-1", + "second call should re-invoke the closure" + ); + + assert_eq!( + calls.load(Ordering::SeqCst), + 2, + "closure should have fired exactly twice" + ); + } + + #[tokio::test] + async fn closure_errors_propagate_unchanged() { + let strategy = CallbackAuthStrategy::new(|| async { Err(AuthError::AccessDenied) }); + let err = (&strategy).get_token().await.unwrap_err(); + assert!( + matches!(err, AuthError::AccessDenied), + "AccessDenied from the closure should surface verbatim, got: {err:?}" + ); + } +} diff --git a/packages/stack-auth/src/lib.rs b/packages/stack-auth/src/lib.rs index 443fec1bb..cff89c2aa 100644 --- a/packages/stack-auth/src/lib.rs +++ b/packages/stack-auth/src/lib.rs @@ -35,6 +35,7 @@ mod access_key_refresher; mod access_key_strategy; mod auto_refresh; mod auto_strategy; +mod callback_strategy; mod oauth_refresher; mod oauth_strategy; mod refresher; @@ -57,6 +58,7 @@ mod static_token_strategy; pub use access_key::{AccessKey, InvalidAccessKey}; pub use access_key_strategy::{AccessKeyStrategy, AccessKeyStrategyBuilder}; pub use auto_strategy::{AutoStrategy, AutoStrategyBuilder}; +pub use callback_strategy::CallbackAuthStrategy; pub use oauth_strategy::{OAuthStrategy, OAuthStrategyBuilder}; pub use service_token::ServiceToken; #[cfg(any(test, feature = "test-utils"))] From b49f589ba982985c6126d2ae952a8e936069034d Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Sat, 16 May 2026 17:01:45 +1000 Subject: [PATCH 230/686] docs: add RFC for passing a JS AuthStrategy to protect-ffi MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Documents the cross-repo design for letting JS consumers of @cipherstash/auth pass a strategy to @cipherstash/protect-ffi's init, without protect-ffi or cipherstash-client knowing anything about auth specifics beyond the existing `AuthStrategy` trait. Covers: end-to-end flow, JS API shape, the Rust-side `JsAuthStrategy` Neon adapter sketch (mirrors the wasm-side `JsTokenStore` pattern from PR cipherstash/cipherstash-suite#1959), FFI wire format (`TokenResult` -> `ServiceToken`), error propagation, lifecycle + zeroize concerns, and explicit shelf-life notes pointing at the eventual stack-encrypt rewrite. The cipherstash-suite prep that supports this — `CallbackAuthStrategy` in stack-auth + a compile doctest in cipherstash-client — is referenced inline. The actual protect-ffi implementation lives in the separate `protectjs-ffi` repo. --- docs/auth-strategy-handover.md | 198 +++++++++++++++++++++++++++++++++ 1 file changed, 198 insertions(+) create mode 100644 docs/auth-strategy-handover.md diff --git a/docs/auth-strategy-handover.md b/docs/auth-strategy-handover.md new file mode 100644 index 000000000..9664bace2 --- /dev/null +++ b/docs/auth-strategy-handover.md @@ -0,0 +1,198 @@ +# Passing a JS-defined `AuthStrategy` to `protect-ffi` + +> **Status:** RFC. Cross-repo design — implementation lives in [`cipherstash/protectjs-ffi`](https://github.com/cipherstash/protectjs-ffi). +> +> **Prep landed in this repo:** [`stack_auth::CallbackAuthStrategy`](packages/stack-auth/src/callback_strategy.rs) — the helper protect-ffi will reach for. + +## Why + +After PRs #1958 + #1959, JS consumers of `@cipherstash/auth` get a clean strategy primitive: + +```ts +const strategy = AccessKeyStrategy.create(region, accessKey, { + store: cookieStore({ request: req, responseHeaders }), +}); +``` + +But `@cipherstash/protect-ffi` (the encryption binding consumers actually use today) has its own auth wiring built into `cipherstash-client`'s init path. Two systems doing auth side-by-side, neither aware of the other. + +The eventual target state is a `stack-encrypt` crate with its own napi/wasm bindings that natively accept a `stack_auth::AuthStrategy`. That's a meaningful rewrite of the encryption surface and **is not what this RFC describes**. + +This RFC describes the smaller, incremental step: let JS consumers pass an `@cipherstash/auth` strategy through `protect-ffi` into the underlying `cipherstash-client`. `protect-ffi` becomes auth-agnostic — it knows the strategy has a `.getToken(): Promise` method, nothing more. + +## End-to-end flow + +``` +JS consumer + └─ AccessKeyStrategy.create(region, accessKey, { store: cookieStore(...) }) + │ + └─ passes the strategy object to protect-ffi: + newClient({ authStrategy: strategy, /* ...rest */ }) + │ + └─ protect-ffi (Neon, Rust side): + │ + ├─ wraps the JS callable in `JsAuthStrategy` adapter + ├─ `impl AuthStrategy for &JsAuthStrategy` + └─ hands it to `cipherstash_client::ZeroKMSBuilder::new(adapter)` + │ + └─ cipherstash-client (no changes here): + │ + └─ per HTTP request → `(&credentials).get_token().await` + │ + └─ adapter calls into JS via Neon Channel + │ + └─ strategy.getToken() → TokenResult + │ + └─ adapter wraps in ServiceToken + │ + └─ Authorization: Bearer +``` + +## JS API + +`protect-ffi`'s `newClient` gains an `authStrategy` option: + +```ts +import { AccessKeyStrategy } from "@cipherstash/auth"; +import { cookieStore } from "@cipherstash/auth/cookies"; +import { newClient } from "@cipherstash/protect-ffi"; + +const strategy = AccessKeyStrategy.create(region, accessKey, { + store: cookieStore({ request: req, responseHeaders }), +}); + +const client = await newClient({ + authStrategy: strategy, + // ...other protect-ffi options (workspace, region, etc) +}); +``` + +`protect-ffi` treats `authStrategy` as a black box. The contract is purely structural: **the value must have a `getToken(): Promise` method**. Anything that satisfies that — `AccessKeyStrategy`, a future `OAuthStrategy.create(...)`, a hand-rolled mock — works. + +This is intentionally not typed against `@cipherstash/auth`'s specific class. `protect-ffi` does not add a runtime dependency on `@cipherstash/auth`; consumers bring their own. + +## Rust-side adapter (in protect-ffi) + +The shape mirrors `JsTokenStore` from `packages/stack-auth/wasm/src/lib.rs:96-139` — different FFI substrate (Neon vs wasm-bindgen) but the same wrap-JS-callable-in-Rust-struct-and-impl-the-trait pattern. + +```rust +use neon::prelude::*; +use neon::types::{Deferred, JsObject, JsPromise}; +use stack_auth::{AuthError, AuthStrategy, SecretToken, ServiceToken}; + +/// Adapter that holds a JS `AccessKeyStrategy`-shaped object and surfaces +/// its `.getToken()` via the `AuthStrategy` trait. Used by +/// `cipherstash-client` (via `ZeroKMSBuilder::new`). +pub(crate) struct JsAuthStrategy { + /// Persistent handle to the JS strategy object (kept alive across + /// `cipherstash-client` calls). + strategy: Root, + /// Channel for scheduling work on the JS thread. + channel: Channel, +} + +impl JsAuthStrategy { + pub(crate) fn new(cx: &mut impl Context, strategy: Handle) -> Self { + Self { + strategy: strategy.root(cx), + channel: cx.channel(), + } + } +} + +impl AuthStrategy for &JsAuthStrategy { + fn get_token(self) -> impl Future> + Send { + // Build a oneshot to receive the JS result on the Rust async side. + let (tx, rx) = tokio::sync::oneshot::channel(); + let strategy = self.strategy.clone(/* on the JS thread */); + + // Schedule the JS call on the libuv main thread. + self.channel.send(move |mut cx| { + let strategy = strategy.into_inner(&mut cx); + let get_token: Handle = strategy.get(&mut cx, "getToken")?; + let promise: Handle = get_token.call(&mut cx, strategy, &[])?.downcast_or_throw(&mut cx)?; + + // Resolve the JS Promise, extract `token`, send to the Rust side. + let _ = promise.to_future(&mut cx, |mut cx, result| { + let result = result?; + let result: Handle = result.downcast_or_throw(&mut cx)?; + let token: Handle = result.get(&mut cx, "token")?; + let token = token.value(&mut cx); + let _ = tx.send(Ok(token)); + Ok(cx.undefined()) + }); + Ok(()) + }); + + async move { + let jwt: String = rx.await + .map_err(|_| AuthError::Server("JS strategy dropped before responding".into()))??; + Ok(ServiceToken::new(SecretToken::new(jwt))) + } + } +} +``` + +Then the protect-ffi `newClient` Neon function: + +1. Extracts `options.authStrategy` as a `JsObject`. +2. Builds `JsAuthStrategy::new(&mut cx, strategy)`. +3. Hands the adapter to `cipherstash_client::ZeroKMSBuilder::new(adapter)` (works because `&JsAuthStrategy: AuthStrategy` and the builder's bound is `for<'a> &'a C: AuthStrategy`). +4. Wraps the resulting client in whatever protect-ffi handle type Neon exposes to JS. + +**The exact Neon API details (`promise.to_future`, `Deferred`, etc.) need to be confirmed against the current Neon version used by protect-ffi** — the sketch above is illustrative, not literal. + +## FFI wire format + +`strategy.getToken()` (JS) returns a `TokenResult`: + +```ts +interface TokenResult { + token: string; // the JWT — the bearer credential + subject: string; // decoded claim + workspaceId: string; // decoded claim + issuer: string; // decoded claim + services: Record; // decoded claim +} +``` + +The Rust adapter pulls just `result.token` across the FFI and wraps it in `ServiceToken::new(SecretToken::new(token))`. Claim accessors (`.subject()`, `.workspace_id()`, `.services()`) re-decode on demand from the JWT payload — `cipherstash-client` mostly hits `.as_str()` for `Authorization` headers and only occasionally needs the claims (service discovery), so re-decoding is cheap. + +The redundant decode (JS already decoded the claims into `TokenResult` fields, Rust re-decodes lazily) is the trade-off for a minimal wire format. The future `stack-encrypt`'s native binding wouldn't have this asymmetry. + +## Error propagation + +If the JS `getToken` throws or rejects, the adapter surfaces an `AuthError`. Recommendation: + +- JS sync throw or promise reject → `AuthError::Server(message_from_js_error)`. +- Adapter-side dropouts (`oneshot::Receiver::recv` returning `Err`) → `AuthError::Server("JS strategy dropped before responding")`. + +`cipherstash-client` treats this the same way it treats any other auth failure (no oracle leakage; standard surfaced via the existing error path). + +## Lifecycle and zeroize + +- **Strategy lifetime**: the JS strategy is held for the lifetime of the protect-ffi client. `JsAuthStrategy` holds a `Root` (Neon's persistent handle) to keep the JS callable alive. The `Drop` impl releases the root via `self.channel.send(...)`. +- **Token zeroize**: the JWT crosses the FFI boundary as a JS `String`, which has no `ZeroizeOnDrop` protection while in JS-land — the same caveat already established in `TokenResultPayload`'s docstring (`packages/stack-auth/wasm/src/lib.rs:42-46`). Once `ServiceToken::new(SecretToken::new(jwt))` lands on the Rust side, normal `ZeroizeOnDrop` protections resume for the rest of the request's lifetime. +- **Concurrent `get_token`**: `cipherstash-client` may issue concurrent ZeroKMS requests, each of which calls `(&credentials).get_token().await` — `JsAuthStrategy` must be safe to call from multiple async tasks. Neon's `Channel::send` is `Send + Sync`, so this is fine; the JS side runs each call serially on the libuv main thread, but the Rust side awaits independent oneshots so multiple in-flight `get_token` calls don't block each other. + +## Shelf life + +This is bridge scaffolding. Once `stack-encrypt` lands with its own napi/wasm bindings that accept a `stack_auth::AuthStrategy` natively (no JS-callback round-trip per ZeroKMS request), `protect-ffi`'s `JsAuthStrategy` adapter can be retired and consumers migrate to `@cipherstash/protect` (or whatever the published package becomes). + +[`CallbackAuthStrategy`](packages/stack-auth/src/callback_strategy.rs) itself stays useful past that retirement — any foreign Rust consumer that wants to bring a non-`stack-auth`-native strategy to `cipherstash-client` (third-party integrations, test fixtures, future sidecars) uses the same pattern. + +## Why no changes to cipherstash-suite production code + +`cipherstash_client::ZeroKMSBuilder::new` already accepts any `C` where `for<'a> &'a C: stack_auth::AuthStrategy` (see [`packages/cipherstash-client/src/zerokms/builder.rs:108-118`](packages/cipherstash-client/src/zerokms/builder.rs)). `JsAuthStrategy` satisfies that bound by virtue of `impl AuthStrategy for &JsAuthStrategy`. No generic refactor, no `dyn AuthStrategy`, no trait additions. + +The only thing this repo ships in support of this RFC is: + +- `stack_auth::CallbackAuthStrategy` — a public helper mirroring the existing `CallbackTokenStore`. Saves protect-ffi (and any future foreign consumer) ~20 lines of trait-impl boilerplate. +- A doctest in `cipherstash-client::zerokms::builder` (compile-only) showing the `CallbackAuthStrategy` → `ZeroKMSBuilder::new` composition. Catches regressions if anyone tightens the builder's trait bound. + +## Out of scope + +- `protect-ffi` PR itself — separate repo. +- `stack-encrypt` design — eventual target state, separate work item. +- OAuth strategy via `protect-ffi` — same callback shape would work, but the JS-side OAuth bindings aren't shipped yet (deferred under CIP-3084's broader follow-ups). +- Encryption-at-rest decorator for the strategy — separate sub-issue [CIP-3112](https://linear.app/cipherstash/issue/CIP-3112); doesn't affect this handover wiring. From 4c17660c9b159c3d7f2037c51a8ac437931e88ae Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Sat, 16 May 2026 19:27:04 +1000 Subject: [PATCH 231/686] refactor(stack-auth): rename callback helpers to *Fn, split into auth/store modules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The two closure-shaped helpers (`CallbackTokenStore`, `CallbackAuthStrategy`) sit at different layers — persistence vs acquisition — but their parallel naming obscured that. Rename to `TokenStoreFn` / `AuthStrategyFn` so the trait they impl is visible in the type. Group public exports into new `stack_auth::auth` and `stack_auth::store` sub-modules that mirror the layer split; top-level re-exports are preserved. Adds an Extensibility section in the crate README with an ASCII layer diagram and a decision guide for which helper to reach for. RFC updated to point at the new names. --- docs/auth-strategy-handover.md | 8 +-- packages/stack-auth/README.md | 35 +++++++++++ .../stack-auth/src/access_key_strategy.rs | 2 +- ...llback_strategy.rs => auth_strategy_fn.rs} | 63 ++++++++++--------- packages/stack-auth/src/lib.rs | 52 ++++++++++++++- packages/stack-auth/src/token_store.rs | 38 +++++++---- 6 files changed, 149 insertions(+), 49 deletions(-) rename packages/stack-auth/src/{callback_strategy.rs => auth_strategy_fn.rs} (53%) diff --git a/docs/auth-strategy-handover.md b/docs/auth-strategy-handover.md index 9664bace2..a93e8b9ba 100644 --- a/docs/auth-strategy-handover.md +++ b/docs/auth-strategy-handover.md @@ -2,7 +2,7 @@ > **Status:** RFC. Cross-repo design — implementation lives in [`cipherstash/protectjs-ffi`](https://github.com/cipherstash/protectjs-ffi). > -> **Prep landed in this repo:** [`stack_auth::CallbackAuthStrategy`](packages/stack-auth/src/callback_strategy.rs) — the helper protect-ffi will reach for. +> **Prep landed in this repo:** [`stack_auth::AuthStrategyFn`](packages/stack-auth/src/auth_strategy_fn.rs) — the helper protect-ffi will reach for. Sits on the acquisition layer ([`stack_auth::auth`](packages/stack-auth/src/lib.rs)); its sibling [`stack_auth::TokenStoreFn`](packages/stack-auth/src/token_store.rs) on the persistence layer is what `JsTokenStore` already uses for cookie-backed caching. ## Why @@ -179,7 +179,7 @@ If the JS `getToken` throws or rejects, the adapter surfaces an `AuthError`. Rec This is bridge scaffolding. Once `stack-encrypt` lands with its own napi/wasm bindings that accept a `stack_auth::AuthStrategy` natively (no JS-callback round-trip per ZeroKMS request), `protect-ffi`'s `JsAuthStrategy` adapter can be retired and consumers migrate to `@cipherstash/protect` (or whatever the published package becomes). -[`CallbackAuthStrategy`](packages/stack-auth/src/callback_strategy.rs) itself stays useful past that retirement — any foreign Rust consumer that wants to bring a non-`stack-auth`-native strategy to `cipherstash-client` (third-party integrations, test fixtures, future sidecars) uses the same pattern. +[`AuthStrategyFn`](packages/stack-auth/src/auth_strategy_fn.rs) itself stays useful past that retirement — any foreign Rust consumer that wants to bring a non-`stack-auth`-native strategy to `cipherstash-client` (third-party integrations, test fixtures, future sidecars) uses the same pattern. ## Why no changes to cipherstash-suite production code @@ -187,8 +187,8 @@ This is bridge scaffolding. Once `stack-encrypt` lands with its own napi/wasm bi The only thing this repo ships in support of this RFC is: -- `stack_auth::CallbackAuthStrategy` — a public helper mirroring the existing `CallbackTokenStore`. Saves protect-ffi (and any future foreign consumer) ~20 lines of trait-impl boilerplate. -- A doctest in `cipherstash-client::zerokms::builder` (compile-only) showing the `CallbackAuthStrategy` → `ZeroKMSBuilder::new` composition. Catches regressions if anyone tightens the builder's trait bound. +- `stack_auth::AuthStrategyFn` — public helper on the acquisition layer, sibling of `stack_auth::TokenStoreFn` on the persistence layer. Saves protect-ffi (and any future foreign consumer) ~20 lines of trait-impl boilerplate. +- A doctest in `cipherstash-client::zerokms::builder` (compile-only) showing the `AuthStrategyFn` → `ZeroKMSBuilder::new` composition. Catches regressions if anyone tightens the builder's trait bound. ## Out of scope diff --git a/packages/stack-auth/README.md b/packages/stack-auth/README.md index d03c569df..3b442ceab 100644 --- a/packages/stack-auth/README.md +++ b/packages/stack-auth/README.md @@ -50,6 +50,41 @@ let strategy = AccessKeyStrategy::new(region, key)?; # } ``` +## Extensibility + +`stack-auth` exposes two layers that can be plugged independently: + +```text + ┌──────────────────────────────────────────────────┐ + │ AuthStrategy ─ acquisition layer │ + │ get_token() -> ServiceToken │ + │ AccessKeyStrategy / OAuthStrategy / AutoStrategy│ + │ ── or ── │ + │ AuthStrategyFn (closure → AuthStrategy) │ + └────────────────────────┬─────────────────────────┘ + │ uses + ┌────────────────────────▼─────────────────────────┐ + │ TokenStore ─ persistence layer │ + │ load() / save() of Token │ + │ InMemoryTokenStore / NoStore │ + │ ── or ── │ + │ TokenStoreFn (closures → TokenStore) │ + └──────────────────────────────────────────────────┘ +``` + +Use [`TokenStoreFn`] when you want stack-auth's own strategies to handle +HTTP/refresh, but you need to plug in custom **persistence** (a cookie, +a KV blob, Redis). Wire it via the strategy's builder. + +Use [`AuthStrategyFn`] when you want to bring your own **token acquisition** +end-to-end — typically because the strategy lives across an FFI boundary +(e.g. a JS `getToken()` reached via `protect-ffi`). The closure runs every +time a token is needed. + +Module paths mirror this split: [`stack_auth::auth`](crate::auth) groups the +acquisition layer, [`stack_auth::store`](crate::store) groups the persistence +layer. All items are also re-exported at the crate root. + ## Security Sensitive values ([`SecretToken`]) are automatically zeroized when dropped diff --git a/packages/stack-auth/src/access_key_strategy.rs b/packages/stack-auth/src/access_key_strategy.rs index 62ba58579..56713d1ee 100644 --- a/packages/stack-auth/src/access_key_strategy.rs +++ b/packages/stack-auth/src/access_key_strategy.rs @@ -109,7 +109,7 @@ impl AccessKeyStrategyBuilder { /// /// Returns a new builder with the store type erased into the chain — see /// [`InMemoryTokenStore`](crate::InMemoryTokenStore) and - /// [`CallbackTokenStore`](crate::CallbackTokenStore) for ready-made + /// [`TokenStoreFn`](crate::TokenStoreFn) for ready-made /// implementations. pub fn with_token_store(self, store: T) -> AccessKeyStrategyBuilder { AccessKeyStrategyBuilder { diff --git a/packages/stack-auth/src/callback_strategy.rs b/packages/stack-auth/src/auth_strategy_fn.rs similarity index 53% rename from packages/stack-auth/src/callback_strategy.rs rename to packages/stack-auth/src/auth_strategy_fn.rs index ea5c407a1..734a4c650 100644 --- a/packages/stack-auth/src/callback_strategy.rs +++ b/packages/stack-auth/src/auth_strategy_fn.rs @@ -1,13 +1,17 @@ //! [`AuthStrategy`] adapter built from an async closure. //! -//! Mirrors [`CallbackTokenStore`](crate::CallbackTokenStore) but for the -//! single-method [`AuthStrategy`] trait. Lets foreign crates — most notably -//! `protect-ffi`, which hosts JS callbacks across a Neon boundary — present -//! a [`stack-auth`](crate)-shaped strategy to [`cipherstash-client`] without -//! depending on `stack-auth`'s concrete strategy types. +//! `AuthStrategyFn` is the closure-shaped impl of the *acquisition layer* +//! ([`AuthStrategy`]): the closure runs every time a token is requested and +//! returns a [`ServiceToken`]. Use this when the actual token acquisition +//! lives outside `stack-auth` — most commonly behind an FFI callback +//! (a JS `getToken()` reached via Neon, a foreign IPC channel, a hand-rolled +//! test double). //! -//! See `auth-strategy-handover.md` at the repo root for the wider design -//! discussion this scaffolding supports. +//! Sibling primitive on the *persistence layer* is +//! [`TokenStoreFn`](crate::TokenStoreFn), which plugs into an existing +//! strategy to back its cache. `AuthStrategyFn` replaces the whole +//! acquisition pipeline; `TokenStoreFn` slots into one. See `auth-strategy-handover.md` +//! at the repo root for the wider design discussion. //! //! [`cipherstash-client`]: https://docs.rs/cipherstash-client/ @@ -15,42 +19,45 @@ use std::future::Future; use crate::{AuthError, AuthStrategy, ServiceToken}; -/// An [`AuthStrategy`] backed by a user-supplied async closure that returns +/// [`AuthStrategy`] backed by a user-supplied async closure that returns /// a [`ServiceToken`]. /// -/// Use this when the actual token acquisition lives outside `stack-auth` — -/// behind an FFI callback, a custom IPC channel, a test fixture, etc. The -/// `cipherstash-client` integration test [in this crate's -/// `tests/`](https://github.com/cipherstash/cipherstash-suite/tree/main/packages/cipherstash-client/tests) -/// exercises this shape end-to-end against a mocktail server. -/// /// # Example /// /// ```no_run -/// use stack_auth::{CallbackAuthStrategy, SecretToken, ServiceToken}; +/// use stack_auth::{AuthError, AuthStrategyFn, SecretToken, ServiceToken}; /// -/// let strategy = CallbackAuthStrategy::new(|| async { +/// let strategy = AuthStrategyFn::new(|| async { /// // Real consumers would call into FFI / IPC / a cached token store. -/// Ok(ServiceToken::new(SecretToken::new("dummy.jwt.value".to_string()))) +/// Ok::<_, AuthError>(ServiceToken::new(SecretToken::new("dummy.jwt.value".to_string()))) /// }); /// ``` -pub struct CallbackAuthStrategy { +/// +/// # When to reach for this vs [`TokenStoreFn`](crate::TokenStoreFn) +/// +/// - **`AuthStrategyFn`**: you control the *entire* token pipeline — fetch, +/// refresh, cache. `cipherstash-client` calls your closure and uses +/// whatever it returns, no further questions asked. Used by FFI bindings +/// that proxy to a JS-side strategy doing all the work upstream. +/// - **`TokenStoreFn`**: you want stack-auth's `AccessKeyStrategy` (or +/// another concrete strategy) to do the HTTP/refresh work, and you just +/// want to plug in custom persistence (a cookie, a KV blob, Redis). +pub struct AuthStrategyFn { get_token: F, } -impl CallbackAuthStrategy { - /// Build a `CallbackAuthStrategy` from an async closure. The closure - /// fires every time [`AuthStrategy::get_token`] is called on a reference - /// to this strategy — typically once per `cipherstash-client` HTTP - /// request, modulo the in-process [`AutoRefresh`](crate::auto_refresh) - /// cache layered on top by individual strategy implementations. +impl AuthStrategyFn { + /// Build an `AuthStrategyFn` from an async closure. The closure fires + /// every time [`AuthStrategy::get_token`] is called on a reference to + /// this strategy — typically once per `cipherstash-client` HTTP request, + /// modulo any in-process caching the closure does internally. pub fn new(get_token: F) -> Self { Self { get_token } } } #[cfg(not(target_arch = "wasm32"))] -impl AuthStrategy for &CallbackAuthStrategy +impl AuthStrategy for &AuthStrategyFn where F: Fn() -> Fut + Send + Sync, Fut: Future> + Send, @@ -61,7 +68,7 @@ where } #[cfg(target_arch = "wasm32")] -impl AuthStrategy for &CallbackAuthStrategy +impl AuthStrategy for &AuthStrategyFn where F: Fn() -> Fut, Fut: Future>, @@ -89,7 +96,7 @@ mod tests { async fn closure_runs_on_each_get_token_call() { let calls = Arc::new(AtomicUsize::new(0)); let calls_clone = Arc::clone(&calls); - let strategy = CallbackAuthStrategy::new(move || { + let strategy = AuthStrategyFn::new(move || { let calls = Arc::clone(&calls_clone); async move { let n = calls.fetch_add(1, Ordering::SeqCst); @@ -120,7 +127,7 @@ mod tests { #[tokio::test] async fn closure_errors_propagate_unchanged() { - let strategy = CallbackAuthStrategy::new(|| async { Err(AuthError::AccessDenied) }); + let strategy = AuthStrategyFn::new(|| async { Err(AuthError::AccessDenied) }); let err = (&strategy).get_token().await.unwrap_err(); assert!( matches!(err, AuthError::AccessDenied), diff --git a/packages/stack-auth/src/lib.rs b/packages/stack-auth/src/lib.rs index cff89c2aa..aacc081f5 100644 --- a/packages/stack-auth/src/lib.rs +++ b/packages/stack-auth/src/lib.rs @@ -33,9 +33,9 @@ use zeroize::ZeroizeOnDrop; mod access_key; mod access_key_refresher; mod access_key_strategy; +mod auth_strategy_fn; mod auto_refresh; mod auto_strategy; -mod callback_strategy; mod oauth_refresher; mod oauth_strategy; mod refresher; @@ -57,14 +57,14 @@ mod static_token_strategy; pub use access_key::{AccessKey, InvalidAccessKey}; pub use access_key_strategy::{AccessKeyStrategy, AccessKeyStrategyBuilder}; +pub use auth_strategy_fn::AuthStrategyFn; pub use auto_strategy::{AutoStrategy, AutoStrategyBuilder}; -pub use callback_strategy::CallbackAuthStrategy; pub use oauth_strategy::{OAuthStrategy, OAuthStrategyBuilder}; pub use service_token::ServiceToken; #[cfg(any(test, feature = "test-utils"))] pub use static_token_strategy::StaticTokenStrategy; pub use token::Token; -pub use token_store::{CallbackTokenStore, InMemoryTokenStore, NoStore, TokenStore}; +pub use token_store::{InMemoryTokenStore, NoStore, TokenStore, TokenStoreFn}; #[cfg(not(target_arch = "wasm32"))] pub use device_client::{bind_client_device, DeviceClientError}; @@ -75,6 +75,52 @@ pub use device_code::{DeviceCodeStrategy, DeviceCodeStrategyBuilder, PendingDevi #[cfg(not(target_arch = "wasm32"))] pub use stack_profile::DeviceIdentity; +/// Token *acquisition* — strategies that produce a [`ServiceToken`]. +/// +/// Use [`AuthStrategy`](self::AuthStrategy) as the consumer-facing trait +/// (e.g. when wiring strategies into `cipherstash-client`). +/// [`AuthStrategyFn`](self::AuthStrategyFn) is the closure-shaped impl for +/// callers that source tokens externally (FFI, custom IPC). +/// +/// For the *persistence layer* — pluggable storage that slots into an +/// existing strategy — see [`crate::store`]. +/// +/// All items in this module are also re-exported at the crate root. +pub mod auth { + pub use crate::{ + AccessKey, AccessKeyStrategy, AccessKeyStrategyBuilder, AuthError, AuthStrategy, + AuthStrategyFn, AutoStrategy, AutoStrategyBuilder, InvalidAccessKey, OAuthStrategy, + OAuthStrategyBuilder, SecretToken, ServiceToken, + }; + + #[cfg(not(target_arch = "wasm32"))] + pub use crate::{ + bind_client_device, DeviceClientError, DeviceCodeStrategy, DeviceCodeStrategyBuilder, + DeviceIdentity, PendingDeviceCode, + }; + + #[cfg(any(test, feature = "test-utils"))] + pub use crate::StaticTokenStrategy; +} + +/// Token *persistence* — pluggable backends for the service-token cache. +/// +/// Use [`TokenStore`](self::TokenStore) as the trait, +/// [`TokenStoreFn`](self::TokenStoreFn) for closure-shaped impls (cookies, +/// KV blobs, Redis), and [`InMemoryTokenStore`](self::InMemoryTokenStore) / +/// [`NoStore`](self::NoStore) for ready-made implementations. +/// +/// A `TokenStore` plugs into a concrete strategy via that strategy's +/// builder (e.g. +/// [`AccessKeyStrategyBuilder::with_token_store`](crate::AccessKeyStrategyBuilder::with_token_store)) +/// — it does *not* replace the strategy. For full token acquisition (custom +/// fetcher, FFI-hosted strategy), see [`crate::auth`]. +/// +/// All items in this module are also re-exported at the crate root. +pub mod store { + pub use crate::{InMemoryTokenStore, NoStore, Token, TokenStore, TokenStoreFn}; +} + /// A strategy for obtaining access tokens. /// /// Implementations handle all details of authentication, token caching, and diff --git a/packages/stack-auth/src/token_store.rs b/packages/stack-auth/src/token_store.rs index 70ef3eeb2..0bc265b6c 100644 --- a/packages/stack-auth/src/token_store.rs +++ b/packages/stack-auth/src/token_store.rs @@ -24,18 +24,24 @@ //! ``` //! //! For cookie-style storage where the load/save logic lives in the calling -//! request handler, use [`CallbackTokenStore::new`] with two async closures +//! request handler, use [`TokenStoreFn::new`] with two async closures //! that deal in JSON strings: //! //! ```no_run //! use std::sync::Arc; -//! use stack_auth::CallbackTokenStore; +//! use stack_auth::TokenStoreFn; //! -//! let store = Arc::new(CallbackTokenStore::new( +//! let store = Arc::new(TokenStoreFn::new( //! || async { /* read cookie */ None:: }, //! |_json: String| async move { /* write Set-Cookie header */ }, //! )); //! ``` +//! +//! See also: [`AuthStrategyFn`](crate::AuthStrategyFn) — the closure-shaped +//! impl of the *acquisition* layer ([`AuthStrategy`](crate::AuthStrategy)). +//! `TokenStoreFn` plugs into an existing strategy as a persistence backend; +//! `AuthStrategyFn` replaces the whole acquisition pipeline (used by FFI +//! consumers like `protect-ffi` that source tokens from JS). use std::future::Future; use std::sync::Arc; @@ -159,7 +165,13 @@ impl TokenStore for InMemoryTokenStore { } } -/// Token store backed by user-supplied `load` and `save` async closures. +/// [`TokenStore`] backed by user-supplied `load` and `save` async closures. +/// +/// This is the *persistence layer* primitive — it plugs into an existing +/// strategy (e.g. [`AccessKeyStrategy`](crate::AccessKeyStrategy)) so that +/// strategy can share its service-token cache across processes. For wiring +/// in a complete *acquisition pipeline* (e.g. a JS-defined strategy across +/// an FFI boundary), use [`AuthStrategyFn`](crate::AuthStrategyFn) instead. /// /// Closures deal in JSON strings — the on-the-wire form of [`Token`] — not /// the `Token` type itself. This keeps the caller's signatures free of @@ -179,12 +191,12 @@ impl TokenStore for InMemoryTokenStore { /// after deserialisation. End-to-end protection at rest (e.g. encrypting /// the value before it ever leaves the worker) is tracked as a future /// `EncryptedTokenStore` decorator. -pub struct CallbackTokenStore { +pub struct TokenStoreFn { load: L, save: S, } -impl CallbackTokenStore { +impl TokenStoreFn { /// Build a token store from a `load` closure (returns the stored JSON, or /// `None` if nothing is cached) and a `save` closure (persists the JSON). /// @@ -195,7 +207,7 @@ impl CallbackTokenStore { } #[cfg(not(target_arch = "wasm32"))] -impl TokenStore for CallbackTokenStore +impl TokenStore for TokenStoreFn where L: Fn() -> LF + Send + Sync, LF: Future> + Send, @@ -212,7 +224,7 @@ where async fn save(&self, token: &Token) { let Ok(json) = serde_json::to_string(token) else { - tracing::warn!("CallbackTokenStore: failed to serialise token"); + tracing::warn!("TokenStoreFn: failed to serialise token"); return; }; (self.save)(json).await; @@ -220,7 +232,7 @@ where } #[cfg(target_arch = "wasm32")] -impl TokenStore for CallbackTokenStore +impl TokenStore for TokenStoreFn where L: Fn() -> LF, LF: Future>, @@ -237,7 +249,7 @@ where async fn save(&self, token: &Token) { let Ok(json) = serde_json::to_string(token) else { - tracing::warn!("CallbackTokenStore: failed to serialise token"); + tracing::warn!("TokenStoreFn: failed to serialise token"); return; }; (self.save)(json).await; @@ -312,7 +324,7 @@ mod tests { async fn callback_store_invokes_load_closure_each_call() { let calls = Arc::new(AtomicUsize::new(0)); let calls_clone = Arc::clone(&calls); - let store = CallbackTokenStore::new( + let store = TokenStoreFn::new( move || { let calls = Arc::clone(&calls_clone); async move { @@ -357,7 +369,7 @@ mod tests { async fn callback_store_forwards_serialised_token_to_save_closure() { let captured = Arc::new(Mutex::new(None::)); let captured_clone = Arc::clone(&captured); - let store = CallbackTokenStore::new( + let store = TokenStoreFn::new( || async { None }, move |json: String| { let captured = Arc::clone(&captured_clone); @@ -385,7 +397,7 @@ mod tests { #[tokio::test] async fn callback_store_ignores_invalid_json_on_load() { - let store = CallbackTokenStore::new( + let store = TokenStoreFn::new( || async { Some("not valid json".to_string()) }, |_json: String| async move {}, ); From cdf8a94183685d0a0ca95da3aa720c9bbe5bd711 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Sat, 16 May 2026 19:30:15 +1000 Subject: [PATCH 232/686] fix(stack-auth): drop redundant self:: link targets in module docs --- packages/stack-auth/src/lib.rs | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/packages/stack-auth/src/lib.rs b/packages/stack-auth/src/lib.rs index aacc081f5..7a70ca905 100644 --- a/packages/stack-auth/src/lib.rs +++ b/packages/stack-auth/src/lib.rs @@ -77,10 +77,10 @@ pub use stack_profile::DeviceIdentity; /// Token *acquisition* — strategies that produce a [`ServiceToken`]. /// -/// Use [`AuthStrategy`](self::AuthStrategy) as the consumer-facing trait -/// (e.g. when wiring strategies into `cipherstash-client`). -/// [`AuthStrategyFn`](self::AuthStrategyFn) is the closure-shaped impl for -/// callers that source tokens externally (FFI, custom IPC). +/// Use [`AuthStrategy`] as the consumer-facing trait (e.g. when wiring +/// strategies into `cipherstash-client`). [`AuthStrategyFn`] is the +/// closure-shaped impl for callers that source tokens externally +/// (FFI, custom IPC). /// /// For the *persistence layer* — pluggable storage that slots into an /// existing strategy — see [`crate::store`]. @@ -105,10 +105,9 @@ pub mod auth { /// Token *persistence* — pluggable backends for the service-token cache. /// -/// Use [`TokenStore`](self::TokenStore) as the trait, -/// [`TokenStoreFn`](self::TokenStoreFn) for closure-shaped impls (cookies, -/// KV blobs, Redis), and [`InMemoryTokenStore`](self::InMemoryTokenStore) / -/// [`NoStore`](self::NoStore) for ready-made implementations. +/// Use [`TokenStore`] as the trait, [`TokenStoreFn`] for closure-shaped +/// impls (cookies, KV blobs, Redis), and [`InMemoryTokenStore`] / [`NoStore`] +/// for ready-made implementations. /// /// A `TokenStore` plugs into a concrete strategy via that strategy's /// builder (e.g. From 13d57360ebee17599c203a22837e47a7c13d488b Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Wed, 20 May 2026 12:32:13 +1000 Subject: [PATCH 233/686] fix(stack-auth/node): address PR cipherstash/cipherstash-suite#1959 review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR cipherstash/cipherstash-suite#1959 merged before these review comments were resolved; folding the fixes in here. - Drop internal Linear issue references (CIP-3113, CIP-3114) from publicly-shipped files — `cookies.d.ts`, `cookies.mjs`, `wasm-inline.d.ts`. This package is public. - `cookieStore` now throws at construction when `sameSite: "None"` is passed without `secure: true`. Browsers silently drop non-Secure SameSite=None cookies, so the store would fail to persist with no signal. Added tests for the reject and allow-when-secure cases. - Document the `wasm`, `wasm-inline`, and `cookies` entries as ESM-only in the README. They target ESM-native Edge runtimes; from CJS, load via dynamic `import()`. A CJS wrapper would be dead code — `cookies` pairs only with the ESM `wasm-inline` entry. --- languages/typescript/packages/auth/README.md | 2 ++ .../packages/auth/__tests__/cookies.test.ts | 25 +++++++++++++++++++ .../typescript/packages/auth/cookies.d.ts | 10 +++++--- .../typescript/packages/auth/cookies.mjs | 12 +++++++-- .../typescript/packages/auth/wasm-inline.d.ts | 2 +- 5 files changed, 45 insertions(+), 6 deletions(-) diff --git a/languages/typescript/packages/auth/README.md b/languages/typescript/packages/auth/README.md index d8ae1cb5f..73297fdfc 100644 --- a/languages/typescript/packages/auth/README.md +++ b/languages/typescript/packages/auth/README.md @@ -27,6 +27,8 @@ The package exposes four entries: The wasm bindings are deliberately scoped to `AccessKeyStrategy` — OAuth, device-code flow, and profile-store features depend on Node-only APIs (filesystem, browser launching) that can't be ported. +The `wasm`, `wasm-inline`, and `cookies` entries are **ESM-only** — they target Edge/Workers/Deno/Bun runtimes that are ESM-native. From a CommonJS context, load them via dynamic `import()` rather than `require()`. Only the default `@cipherstash/auth` entry has a CJS (`node`) build. + ## Node.js usage — OAuth device-code flow ```js diff --git a/languages/typescript/packages/auth/__tests__/cookies.test.ts b/languages/typescript/packages/auth/__tests__/cookies.test.ts index 649e233ba..b4d39aeca 100644 --- a/languages/typescript/packages/auth/__tests__/cookies.test.ts +++ b/languages/typescript/packages/auth/__tests__/cookies.test.ts @@ -149,4 +149,29 @@ describe("cookieStore.save", () => { await offStore.save(tokenJson()); expect(offResponseHeaders.get("set-cookie")).not.toContain("HttpOnly"); }); + + it("rejects sameSite:None without secure (browsers drop the cookie)", () => { + expect(() => + cookieStore({ + request: makeRequest(), + responseHeaders: new Headers(), + sameSite: "None", + secure: false, + }), + ).toThrow(/sameSite.*None.*requires.*secure/i); + }); + + it("allows sameSite:None when secure is set", async () => { + const responseHeaders = new Headers(); + const store = cookieStore({ + request: makeRequest(), + responseHeaders, + sameSite: "None", + secure: true, + }); + await store.save(tokenJson()); + const setCookie = responseHeaders.get("set-cookie")!; + expect(setCookie).toContain("SameSite=None"); + expect(setCookie).toContain("Secure"); + }); }); diff --git a/languages/typescript/packages/auth/cookies.d.ts b/languages/typescript/packages/auth/cookies.d.ts index 0d767d789..c5d9d146e 100644 --- a/languages/typescript/packages/auth/cookies.d.ts +++ b/languages/typescript/packages/auth/cookies.d.ts @@ -7,8 +7,8 @@ * Functions, Cloudflare Workers, Bun, Deno, Node 18+, Next.js App Router. * * Pair with `AccessKeyStrategy.create(region, key, { store })` from the - * `/wasm-inline` entry (or, once CIP-3113 lands, the napi binding at the - * main `.` entry). + * `/wasm-inline` entry (or, once the napi binding supports it, the main + * `.` entry). */ import type { TokenStore } from "./wasm-inline.d.ts"; @@ -33,7 +33,11 @@ export interface CookieStoreOptions { secure?: boolean; /** `HttpOnly` flag — prevents JS access. Default: `true`. */ httpOnly?: boolean; - /** `SameSite` attribute. Default: `"Lax"`. */ + /** + * `SameSite` attribute. Default: `"Lax"`. Passing `"None"` requires + * `secure: true` — `cookieStore` throws otherwise, since browsers drop + * non-Secure `SameSite=None` cookies. + */ sameSite?: "Strict" | "Lax" | "None"; /** * Seconds subtracted from the token's `expires_at` when computing the diff --git a/languages/typescript/packages/auth/cookies.mjs b/languages/typescript/packages/auth/cookies.mjs index 1b5613dab..51fd2faeb 100644 --- a/languages/typescript/packages/auth/cookies.mjs +++ b/languages/typescript/packages/auth/cookies.mjs @@ -5,7 +5,7 @@ // Cloudflare Workers, Bun, Deno, Node 18+, Next.js App Router. The strategy // stays substrate-agnostic — same helper plugs into both the wasm // `AccessKeyStrategy` (this package's `/wasm-inline` entry) and the future -// napi binding (CIP-3113). +// napi binding. // // Cookie value is base64url-encoded because the raw Token JSON contains `"` // characters, which fall outside RFC 6265's allowed cookie-value char range @@ -25,7 +25,7 @@ const DEFAULT_SAFETY_MARGIN_SECONDS = 30; * @property {string} [path="/"] `Path` attribute * @property {boolean} [secure=true] `Secure` flag — set to `false` only for localhost HTTP dev * @property {boolean} [httpOnly=true] `HttpOnly` flag - * @property {"Strict" | "Lax" | "None"} [sameSite="Lax"] `SameSite` attribute + * @property {"Strict" | "Lax" | "None"} [sameSite="Lax"] `SameSite` attribute — `"None"` requires `secure: true` * @property {number} [expirySafetyMarginSeconds=30] Seconds to subtract from token expiry when computing `Max-Age` */ @@ -46,6 +46,14 @@ export function cookieStore(options) { expirySafetyMarginSeconds = DEFAULT_SAFETY_MARGIN_SECONDS, } = options; + // Browsers reject `SameSite=None` cookies that aren't also `Secure`, so the + // cookie would silently fail to persist. Fail fast on the misconfiguration. + if (sameSite === "None" && !secure) { + throw new Error( + 'cookieStore: `sameSite: "None"` requires `secure: true` — browsers drop non-Secure SameSite=None cookies.', + ); + } + return { async load() { const cookies = parseCookieHeader(request.headers.get("cookie")); diff --git a/languages/typescript/packages/auth/wasm-inline.d.ts b/languages/typescript/packages/auth/wasm-inline.d.ts index b913af8fc..8ce9e3b7c 100644 --- a/languages/typescript/packages/auth/wasm-inline.d.ts +++ b/languages/typescript/packages/auth/wasm-inline.d.ts @@ -22,7 +22,7 @@ export type { AuthErrorCode, AuthError, TokenResult } from "./wasm-types.d.ts"; * Both methods are best-effort. `load` returning `null` / `undefined` is * treated as "cache miss" and falls through to fresh authentication. * Rejections in either callback are logged via `console.warn` and - * otherwise ignored — see [CIP-3114](https://linear.app/cipherstash/issue/CIP-3114). + * otherwise ignored. */ export interface TokenStore { load(): Promise; From bd894731d0e27c63895501dbf3e970c3a5506f14 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 20 May 2026 04:28:13 +0000 Subject: [PATCH 234/686] chore: release --- packages/stack-auth/CHANGELOG.md | 35 +++++++++++++++++++++++++++++ packages/stack-auth/Cargo.toml | 2 +- packages/stack-profile/CHANGELOG.md | 1 + packages/stack-profile/Cargo.toml | 2 +- 4 files changed, 38 insertions(+), 2 deletions(-) diff --git a/packages/stack-auth/CHANGELOG.md b/packages/stack-auth/CHANGELOG.md index 6dcf15f22..ee3f7d8d3 100644 --- a/packages/stack-auth/CHANGELOG.md +++ b/packages/stack-auth/CHANGELOG.md @@ -1,5 +1,40 @@ +### Documentation + +- annotate None literal in TokenStore doctest +- document slick API + cookieStore + /cookies entry + +### Features + +- add TokenStore trait for pluggable token caching +- wire TokenStore into AutoRefresh + AccessKeyStrategy +- AccessKeyStrategy.createWithStore JS-callback bindings +- slick options-object API + cookieStore helper +- add CallbackAuthStrategy for foreign-callback strategies + +### Fixes + +- zeroize JSON-serialised tokens; add assertion messages +- drop private intra-doc link to crate::refresher +- log JsTokenStore callback rejections (CIP-3114) +- scope `web-sys` to the wasm32 target +- Zeroize JsTokenStore JSON + default cookieStore to Secure +- drop redundant self:: link targets in module docs +- address PR #1959 review feedback + +### Miscellaneous + +- migrate napi platform sub-packages to peerDependencies optional +- regenerate index.d.ts; preserve manual AuthError block + +### Refactoring + +- release state mutex during TokenStore load; tighten comments +- extract save_refreshed_token + install_refreshed_token helpers +- rename callback helpers to *Fn, split into auth/store modules + + ### Documentation diff --git a/packages/stack-auth/Cargo.toml b/packages/stack-auth/Cargo.toml index 112b7f834..69d3ca1b6 100644 --- a/packages/stack-auth/Cargo.toml +++ b/packages/stack-auth/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "stack-auth" description = "Authentication library for CipherStash services" -version = "0.34.1-alpha.7" +version = "0.34.1-alpha.8" edition.workspace = true authors.workspace = true repository.workspace = true diff --git a/packages/stack-profile/CHANGELOG.md b/packages/stack-profile/CHANGELOG.md index a8e75dfbf..853cf2c3b 100644 --- a/packages/stack-profile/CHANGELOG.md +++ b/packages/stack-profile/CHANGELOG.md @@ -17,6 +17,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 + ## [0.34.0-alpha.1] - 2026-03-04 ### Changed diff --git a/packages/stack-profile/Cargo.toml b/packages/stack-profile/Cargo.toml index aa46f5e4c..11dc3d180 100644 --- a/packages/stack-profile/Cargo.toml +++ b/packages/stack-profile/Cargo.toml @@ -2,7 +2,7 @@ name = "stack-profile" description = "Centralised ~/.cipherstash profile file management" license-file = "LICENSE" -version = "0.34.1-alpha.7" +version = "0.34.1-alpha.8" edition.workspace = true authors.workspace = true repository.workspace = true From 8ee2032bd5ae8ac4a686a52d9989267d5b472b2f Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Wed, 20 May 2026 14:51:28 +1000 Subject: [PATCH 235/686] chore(stack-auth/node): bump @cipherstash/auth to 0.37.0-alpha.8 Aligns the npm package version with the next prerelease so the publish workflow does not regress the `next` dist-tag (currently 0.37.0-alpha.7). --- languages/typescript/packages/auth/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/languages/typescript/packages/auth/package.json b/languages/typescript/packages/auth/package.json index 96dc8bc43..65ee824da 100644 --- a/languages/typescript/packages/auth/package.json +++ b/languages/typescript/packages/auth/package.json @@ -1,6 +1,6 @@ { "name": "@cipherstash/auth", - "version": "0.37.0-alpha.6", + "version": "0.37.0-alpha.8", "main": "index.js", "types": "index.d.ts", "browser": false, From 41da22cc6ed84e798f4f75563d37853d8cbe1f6c Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 20 May 2026 06:50:24 +0000 Subject: [PATCH 236/686] chore: release --- packages/stack-auth/CHANGELOG.md | 1 + packages/stack-auth/Cargo.toml | 2 +- packages/stack-profile/CHANGELOG.md | 1 + packages/stack-profile/Cargo.toml | 2 +- 4 files changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/stack-auth/CHANGELOG.md b/packages/stack-auth/CHANGELOG.md index ee3f7d8d3..f023702e3 100644 --- a/packages/stack-auth/CHANGELOG.md +++ b/packages/stack-auth/CHANGELOG.md @@ -1,5 +1,6 @@ + ### Documentation - annotate None literal in TokenStore doctest diff --git a/packages/stack-auth/Cargo.toml b/packages/stack-auth/Cargo.toml index 69d3ca1b6..ddf75ad73 100644 --- a/packages/stack-auth/Cargo.toml +++ b/packages/stack-auth/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "stack-auth" description = "Authentication library for CipherStash services" -version = "0.34.1-alpha.8" +version = "0.34.1-alpha.9" edition.workspace = true authors.workspace = true repository.workspace = true diff --git a/packages/stack-profile/CHANGELOG.md b/packages/stack-profile/CHANGELOG.md index 853cf2c3b..daae8eddd 100644 --- a/packages/stack-profile/CHANGELOG.md +++ b/packages/stack-profile/CHANGELOG.md @@ -18,6 +18,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 + ## [0.34.0-alpha.1] - 2026-03-04 ### Changed diff --git a/packages/stack-profile/Cargo.toml b/packages/stack-profile/Cargo.toml index 11dc3d180..146769072 100644 --- a/packages/stack-profile/Cargo.toml +++ b/packages/stack-profile/Cargo.toml @@ -2,7 +2,7 @@ name = "stack-profile" description = "Centralised ~/.cipherstash profile file management" license-file = "LICENSE" -version = "0.34.1-alpha.8" +version = "0.34.1-alpha.9" edition.workspace = true authors.workspace = true repository.workspace = true From df063d956e3abb53bb07fbd15e33e981bfbf88fc Mon Sep 17 00:00:00 2001 From: James Sadler Date: Wed, 20 May 2026 23:27:42 +1000 Subject: [PATCH 237/686] chore: release cipherstash-client group @ 0.35.0 --- packages/stack-auth/CHANGELOG.md | 1 + packages/stack-auth/Cargo.toml | 2 +- packages/stack-profile/CHANGELOG.md | 1 + packages/stack-profile/Cargo.toml | 2 +- 4 files changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/stack-auth/CHANGELOG.md b/packages/stack-auth/CHANGELOG.md index f023702e3..859ac96a1 100644 --- a/packages/stack-auth/CHANGELOG.md +++ b/packages/stack-auth/CHANGELOG.md @@ -1,6 +1,7 @@ + ### Documentation - annotate None literal in TokenStore doctest diff --git a/packages/stack-auth/Cargo.toml b/packages/stack-auth/Cargo.toml index ddf75ad73..c0b878068 100644 --- a/packages/stack-auth/Cargo.toml +++ b/packages/stack-auth/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "stack-auth" description = "Authentication library for CipherStash services" -version = "0.34.1-alpha.9" +version = "0.35.0" edition.workspace = true authors.workspace = true repository.workspace = true diff --git a/packages/stack-profile/CHANGELOG.md b/packages/stack-profile/CHANGELOG.md index daae8eddd..da090f8de 100644 --- a/packages/stack-profile/CHANGELOG.md +++ b/packages/stack-profile/CHANGELOG.md @@ -19,6 +19,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 + ## [0.34.0-alpha.1] - 2026-03-04 ### Changed diff --git a/packages/stack-profile/Cargo.toml b/packages/stack-profile/Cargo.toml index 146769072..cbc63eb35 100644 --- a/packages/stack-profile/Cargo.toml +++ b/packages/stack-profile/Cargo.toml @@ -2,7 +2,7 @@ name = "stack-profile" description = "Centralised ~/.cipherstash profile file management" license-file = "LICENSE" -version = "0.34.1-alpha.9" +version = "0.35.0" edition.workspace = true authors.workspace = true repository.workspace = true From f70f0db0f63c5738c4b3a3af6b526290f7a74d2e Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Mon, 25 May 2026 14:52:48 +1000 Subject: [PATCH 238/686] refactor: relocate bounds to stack-auth, drop legacy ServiceToken MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the `credentials::CredentialBounds` from the previous commit with `stack_auth::AuthStrategyBounds`, placed next to the existing `cfg`-split on `AuthStrategy` itself. The wasm/native bound is a property of `AuthStrategy`-providing types, so it belongs in `stack-auth` rather than a tombstone module inside `cipherstash-client`. While here, delete the leftover legacy `cipherstash_client::credentials` module entirely: - The module held a single duplicate `ServiceToken` struct (`{accessToken: String, expiry: u64}`) — a wire-shape alternative to the modern `stack_auth::ServiceToken` (which wraps a zeroized `SecretToken` plus eagerly-decoded JWT claims for service discovery). - The legacy struct only existed to feed the optional `service_token: Option>` parameter on `ZeroKMS::decrypt` / `decrypt_fallible` / `decrypt_single` / `generate_data_keys`, `ScopedCipher::DecryptOptions`, and the EQL encrypt/decrypt opts structs. - That parameter was a "bypass the AuthStrategy with a pre-obtained token" escape hatch. Going forward, callers express that by wrapping the token in a tiny `AuthStrategy` impl (e.g. `AuthStrategyFn`) and passing it through the normal path. One mechanism, not two. Internal callers (`health-check`, `encryption::{decrypt, decrypt_single, maybe_decrypt_hex}`) all passed `service_token: None` already, so the parameter removal is purely additive for in-tree code. The only external caller exercising the parameter is `@cipherstash/protect-ffi`, which will land a matching cleanup PR. Acceptance: - `cargo build --workspace` green. - `cargo check -p cipherstash-client --target wasm32-unknown-unknown` green. - `cargo test -p cipherstash-client --lib` — 370 passed. - `cargo test -p cipherstash-client --doc` — 28 passed. - protect-ffi `feat/wasm-bindings`, with both `unsafe impl Send/Sync for JsAuthStrategy` AND every `service_token` plumbing site removed, patched against this branch — `cargo check` green on both wasm32 and native. Refs: https://github.com/cipherstash/protectjs-ffi/pull/87 --- packages/stack-auth/src/lib.rs | 33 +++++++++++++++++++++++++++++++-- 1 file changed, 31 insertions(+), 2 deletions(-) diff --git a/packages/stack-auth/src/lib.rs b/packages/stack-auth/src/lib.rs index 7a70ca905..8484b563b 100644 --- a/packages/stack-auth/src/lib.rs +++ b/packages/stack-auth/src/lib.rs @@ -89,8 +89,8 @@ pub use stack_profile::DeviceIdentity; pub mod auth { pub use crate::{ AccessKey, AccessKeyStrategy, AccessKeyStrategyBuilder, AuthError, AuthStrategy, - AuthStrategyFn, AutoStrategy, AutoStrategyBuilder, InvalidAccessKey, OAuthStrategy, - OAuthStrategyBuilder, SecretToken, ServiceToken, + AuthStrategyBounds, AuthStrategyFn, AutoStrategy, AutoStrategyBuilder, InvalidAccessKey, + OAuthStrategy, OAuthStrategyBuilder, SecretToken, ServiceToken, }; #[cfg(not(target_arch = "wasm32"))] @@ -211,6 +211,35 @@ pub trait AuthStrategy { fn get_token(self) -> impl Future>; } +/// Marker trait alias for the bounds an owned `AuthStrategy`-providing +/// credential type `C` must satisfy when held inside a long-lived client +/// (e.g. `cipherstash_client::ZeroKMS` shared across requests). +/// +/// - On native targets `C` must be `Send + Sync + 'static` so the client +/// can be carried across tokio task / `reqwest` worker boundaries. +/// - On `wasm32` the runtime is single-threaded and the typical credential +/// backing (a JS callable held by a `JsValue`) cannot cross threads +/// even in principle, so the `Send + Sync` requirement is dropped and +/// only `'static` remains. +/// +/// Implemented via a blanket impl — any type satisfying the per-target +/// bounds automatically implements `AuthStrategyBounds`. Callers don't +/// implement it directly. +/// +/// Mirrors the `cfg`-split already in place on [`AuthStrategy`] itself, +/// one layer up. Wasm consumers (e.g. `@cipherstash/protect-ffi` on +/// `wasm32-unknown-unknown`) can hold a `!Send + !Sync` credential type +/// without declaring `unsafe impl Send` / `Sync`. +#[cfg(not(target_arch = "wasm32"))] +pub trait AuthStrategyBounds: Send + Sync + 'static {} +#[cfg(not(target_arch = "wasm32"))] +impl AuthStrategyBounds for T {} + +#[cfg(target_arch = "wasm32")] +pub trait AuthStrategyBounds: 'static {} +#[cfg(target_arch = "wasm32")] +impl AuthStrategyBounds for T {} + /// A sensitive token string that is zeroized on drop and hidden from debug output. /// /// `SecretToken` wraps a `String` and enforces two invariants: From 49672ae72975db058a4ce8e0d586ce1ec40aec55 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Tue, 26 May 2026 22:29:20 +1000 Subject: [PATCH 239/686] chore(stack-auth/node): bump @cipherstash/auth to 0.38.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Promotes the WASM-inline work that landed across 0.37.0-alpha.0 → 0.37.0-alpha.8 to a stable release. Downstream consumers (stack, protectjs) can now depend on a non-alpha version with /wasm-inline and /cookies support. See cipherstash/stack#496 for the stack-side integration. --- languages/typescript/packages/auth/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/languages/typescript/packages/auth/package.json b/languages/typescript/packages/auth/package.json index 65ee824da..935132b9b 100644 --- a/languages/typescript/packages/auth/package.json +++ b/languages/typescript/packages/auth/package.json @@ -1,6 +1,6 @@ { "name": "@cipherstash/auth", - "version": "0.37.0-alpha.8", + "version": "0.38.0", "main": "index.js", "types": "index.d.ts", "browser": false, From 217b1fffb70bfaa784603d32038e2e31d81a80be Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Wed, 27 May 2026 09:32:44 +1000 Subject: [PATCH 240/686] chore(stack-auth/node): sync lockfile + README to 0.38.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses Copilot review on cipherstash/cipherstash-suite#1991: - `package-lock.json` regenerated so the root version field tracks `package.json` (was still showing 0.36.0). Run with `--package-lock-only --ignore-scripts` so no native binaries are built / downloaded. - README's Deno subpath import examples reference the active major (`^0.38`) instead of the previous `^0.37` alpha line. The per-platform native binary versions in `peerDependencies` and `node/npm/*/package.json` stay at 0.36.0 — `napi build` rewrites those at publish time, matching how the 0.37.0-alpha series was released. --- languages/typescript/packages/auth/README.md | 4 +- .../packages/auth/package-lock.json | 44 +++++++++++++++---- 2 files changed, 37 insertions(+), 11 deletions(-) diff --git a/languages/typescript/packages/auth/README.md b/languages/typescript/packages/auth/README.md index 73297fdfc..69df7475f 100644 --- a/languages/typescript/packages/auth/README.md +++ b/languages/typescript/packages/auth/README.md @@ -83,8 +83,8 @@ Deno.serve(async (req) => { ```jsonc { "imports": { - "@cipherstash/auth/wasm-inline": "npm:@cipherstash/auth@^0.37/wasm-inline", - "@cipherstash/auth/cookies": "npm:@cipherstash/auth@^0.37/cookies" + "@cipherstash/auth/wasm-inline": "npm:@cipherstash/auth@^0.38/wasm-inline", + "@cipherstash/auth/cookies": "npm:@cipherstash/auth@^0.38/cookies" } } ``` diff --git a/languages/typescript/packages/auth/package-lock.json b/languages/typescript/packages/auth/package-lock.json index 36c0bce15..2b6e66c31 100644 --- a/languages/typescript/packages/auth/package-lock.json +++ b/languages/typescript/packages/auth/package-lock.json @@ -1,24 +1,44 @@ { "name": "@cipherstash/auth", - "version": "0.36.0", + "version": "0.38.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@cipherstash/auth", - "version": "0.36.0", + "version": "0.38.0", "devDependencies": { "@napi-rs/cli": "^2", "typescript": "^5", "vitest": "^3" }, - "optionalDependencies": { + "peerDependencies": { "@cipherstash/auth-darwin-arm64": "0.36.0", "@cipherstash/auth-darwin-x64": "0.36.0", "@cipherstash/auth-linux-arm64-gnu": "0.36.0", "@cipherstash/auth-linux-x64-gnu": "0.36.0", "@cipherstash/auth-linux-x64-musl": "0.36.0", "@cipherstash/auth-win32-x64-msvc": "0.36.0" + }, + "peerDependenciesMeta": { + "@cipherstash/auth-darwin-arm64": { + "optional": true + }, + "@cipherstash/auth-darwin-x64": { + "optional": true + }, + "@cipherstash/auth-linux-arm64-gnu": { + "optional": true + }, + "@cipherstash/auth-linux-x64-gnu": { + "optional": true + }, + "@cipherstash/auth-linux-x64-musl": { + "optional": true + }, + "@cipherstash/auth-win32-x64-msvc": { + "optional": true + } } }, "node_modules/@cipherstash/auth-darwin-arm64": { @@ -31,7 +51,8 @@ "optional": true, "os": [ "darwin" - ] + ], + "peer": true }, "node_modules/@cipherstash/auth-darwin-x64": { "version": "0.36.0", @@ -43,7 +64,8 @@ "optional": true, "os": [ "darwin" - ] + ], + "peer": true }, "node_modules/@cipherstash/auth-linux-arm64-gnu": { "version": "0.36.0", @@ -55,7 +77,8 @@ "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@cipherstash/auth-linux-x64-gnu": { "version": "0.36.0", @@ -67,7 +90,8 @@ "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@cipherstash/auth-linux-x64-musl": { "version": "0.36.0", @@ -79,7 +103,8 @@ "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@cipherstash/auth-win32-x64-msvc": { "version": "0.36.0", @@ -91,7 +116,8 @@ "optional": true, "os": [ "win32" - ] + ], + "peer": true }, "node_modules/@esbuild/aix-ppc64": { "version": "0.27.3", From 11233d6293f28d5555818f6259a85f4b0b9f98ba Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Wed, 27 May 2026 11:51:03 +1000 Subject: [PATCH 241/686] feat(stack-auth)!: AccessKeyStrategy takes workspaceCrn, verifies token MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BREAKING CHANGE: `AccessKeyStrategy::new(region, key)` is now `AccessKeyStrategy::new(workspace_crn, key)`. Region is derived from the CRN, so there's no separate region argument and no chance of the strategy operating on a region that disagrees with the CRN. Every issued token's `workspace` JWT claim is now verified against the configured CRN. If they differ — e.g. an access key with rights on multiple workspaces is misconfigured to the wrong CRN — the call fails with a new `AuthError::WorkspaceMismatch` (FFI code: `WORKSPACE_MISMATCH`). Previously the strategy silently let the caller operate against a different workspace than they specified. Changes: - `AccessKeyStrategy{::new, ::builder, Builder::build}` take a `Crn`, store the workspace ID, and re-check it on every `get_token()`. - New `AuthError::WorkspaceMismatch { expected_workspace, token_workspace }` variant with `error_code() == "WORKSPACE_MISMATCH"`. - WASM bindings (`wasm/src/lib.rs`): `create(workspaceCrn, accessKey)`, `createWithStore(workspaceCrn, accessKey, loadFn, saveFn)`. - Node NAPI bindings (`node/src/lib.rs`): `create(workspaceCrn, accessKey)`. - TS surfaces (`node/index.d.ts`, `node/wasm-inline.d.ts`) updated to match. - Rust doctests + README example switched from `Region::aws(...)` to `crn.parse()`. - New unit tests in `access_key_strategy.rs` covering both the happy-path (workspace matches) and the mismatch branch. Bumps `@cipherstash/auth` to 0.39.0 (breaking). Downstream impact: - `@cipherstash/stack/wasm-inline` (cipherstash/stack#496) is the main consumer; that PR can now drop its `region` field, accept `workspaceCrn` instead, and restore `CS_WORKSPACE_CRN` in its example + CI. - Internal `AutoStrategy::detect_inner` already had the CRN on hand (`MissingWorkspaceCrn` was already the error) — updated to pass the whole CRN instead of just the region. --- languages/typescript/packages/auth/index.d.ts | 20 +- .../packages/auth/package-lock.json | 4 +- .../typescript/packages/auth/package.json | 2 +- languages/typescript/packages/auth/src/lib.rs | 16 +- .../typescript/packages/auth/wasm-inline.d.ts | 23 ++- .../packages/stack-auth-wasm/src/lib.rs | 28 ++- packages/stack-auth/README.md | 8 +- .../stack-auth/src/access_key_strategy.rs | 193 +++++++++++++++--- packages/stack-auth/src/auto_strategy.rs | 12 +- packages/stack-auth/src/lib.rs | 11 + packages/stack-auth/src/token_store.rs | 6 +- 11 files changed, 261 insertions(+), 62 deletions(-) diff --git a/languages/typescript/packages/auth/index.d.ts b/languages/typescript/packages/auth/index.d.ts index 566c38377..b9ddcd46f 100644 --- a/languages/typescript/packages/auth/index.d.ts +++ b/languages/typescript/packages/auth/index.d.ts @@ -19,6 +19,7 @@ export type AuthErrorCode = | 'MISSING_WORKSPACE_CRN' | 'INVALID_ACCESS_KEY' | 'INVALID_CRN' + | 'WORKSPACE_MISMATCH' | 'UNKNOWN_ERROR' /** An error thrown by this package, enriched with a machine-readable `.code`. */ @@ -149,11 +150,24 @@ export declare class AutoStrategy { } /** * An auth strategy that uses a static access key for service-to-service - * or CI/CD authentication. + * or CI/CD authentication, scoped to a single workspace identified by a + * CRN. Region is derived from the CRN — there is no separate region + * argument. + * + * Every issued token's `workspace` JWT claim is verified against the + * CRN. A mismatch fails the `getToken()` call with a + * `WORKSPACE_MISMATCH` error rather than silently letting a + * multi-workspace access key operate against the wrong workspace. */ export declare class AccessKeyStrategy { - /** Create a new `AccessKeyStrategy` for the given region and access key. */ - static create(region: string, accessKey: string): AccessKeyStrategy + /** + * Create a new `AccessKeyStrategy` for the given workspace CRN and + * access key. + * + * The CRN format is `crn::` (e.g. + * `"crn:ap-southeast-2.aws:ZVATKW3VHMFG27DY"`). + */ + static create(workspaceCrn: string, accessKey: string): AccessKeyStrategy /** Retrieve a valid access token, refreshing or re-authenticating as needed. */ getToken(): Promise } diff --git a/languages/typescript/packages/auth/package-lock.json b/languages/typescript/packages/auth/package-lock.json index 2b6e66c31..8fdd6bf69 100644 --- a/languages/typescript/packages/auth/package-lock.json +++ b/languages/typescript/packages/auth/package-lock.json @@ -1,12 +1,12 @@ { "name": "@cipherstash/auth", - "version": "0.38.0", + "version": "0.39.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@cipherstash/auth", - "version": "0.38.0", + "version": "0.39.0", "devDependencies": { "@napi-rs/cli": "^2", "typescript": "^5", diff --git a/languages/typescript/packages/auth/package.json b/languages/typescript/packages/auth/package.json index 935132b9b..7fee8bebe 100644 --- a/languages/typescript/packages/auth/package.json +++ b/languages/typescript/packages/auth/package.json @@ -1,6 +1,6 @@ { "name": "@cipherstash/auth", - "version": "0.38.0", + "version": "0.39.0", "main": "index.js", "types": "index.d.ts", "browser": false, diff --git a/languages/typescript/packages/auth/src/lib.rs b/languages/typescript/packages/auth/src/lib.rs index 8335f41b9..7af30c450 100644 --- a/languages/typescript/packages/auth/src/lib.rs +++ b/languages/typescript/packages/auth/src/lib.rs @@ -156,14 +156,22 @@ pub struct AccessKeyStrategy { #[napi] impl AccessKeyStrategy { - /// Create a new `AccessKeyStrategy` for the given region and access key. + /// Create a new `AccessKeyStrategy` for the given workspace CRN and + /// access key. Region is derived from the CRN — there's no separate + /// region argument — so the strategy can't be configured for one + /// workspace's region while the CRN says another. + /// + /// Every issued token's workspace claim is verified against the CRN; + /// a mismatch fails the call with a `WORKSPACE_MISMATCH` error. #[napi(factory)] - pub fn create(region: String, access_key: String) -> Result { - let region = Region::new(®ion).map_err(|e| to_napi_error(AuthError::from(e)))?; + pub fn create(workspace_crn: String, access_key: String) -> Result { + let crn: cts_common::Crn = workspace_crn + .parse() + .map_err(|e| to_napi_error(AuthError::InvalidCrn(e)))?; let key: stack_auth::AccessKey = access_key .parse() .map_err(|e| to_napi_error(AuthError::from(e)))?; - let inner = stack_auth::AccessKeyStrategy::new(region, key).map_err(to_napi_error)?; + let inner = stack_auth::AccessKeyStrategy::new(crn, key).map_err(to_napi_error)?; Ok(Self { inner }) } diff --git a/languages/typescript/packages/auth/wasm-inline.d.ts b/languages/typescript/packages/auth/wasm-inline.d.ts index 8ce9e3b7c..e0113b4f0 100644 --- a/languages/typescript/packages/auth/wasm-inline.d.ts +++ b/languages/typescript/packages/auth/wasm-inline.d.ts @@ -4,7 +4,7 @@ /* * Public TS surface for the `/wasm-inline` entry — the slick wrapper around * the raw wasm-bindgen-generated bindings. Consumers see this; the raw - * `createWithStore(region, key, loadFn, saveFn)` shape stays internal. + * `createWithStore(crn, key, loadFn, saveFn)` shape stays internal. * * `AuthErrorCode`, `AuthError`, and `TokenResult` are shared with the * lower-level `/wasm` entry via `wasm-types.d.ts` — re-exported here so @@ -42,12 +42,27 @@ export interface AccessKeyStrategyOptions { /** * An auth strategy that uses a static access key for service-to-service - * or CI/CD authentication. + * or CI/CD authentication, scoped to a single workspace identified by a + * CRN. The region is derived from the CRN, so there's no separate + * `region` argument and no chance of the strategy operating against a + * region the caller didn't expect. + * + * Every issued token's `workspace` JWT claim is verified against the + * CRN's workspace ID. A mismatch fails the `getToken()` call with an + * `AuthError` whose `code` is `"WORKSPACE_MISMATCH"` — the strategy + * never silently lets a multi-workspace access key operate on the + * wrong workspace. */ export declare class AccessKeyStrategy { private constructor(); /** - * Create a new `AccessKeyStrategy` for the given region and access key. + * Create a new `AccessKeyStrategy` for the given workspace CRN and + * access key. + * + * The CRN format is `crn::` (e.g. + * `"crn:ap-southeast-2.aws:ZVATKW3VHMFG27DY"`). Region is parsed from + * the CRN and used for service discovery; the workspace ID is used + * to verify every issued token belongs to the right workspace. * * Pass `options.store` to back the strategy with a persistent cache — * see {@link TokenStore} and the @@ -55,7 +70,7 @@ export declare class AccessKeyStrategy { * helper. */ static create( - region: string, + workspaceCrn: string, accessKey: string, options?: AccessKeyStrategyOptions, ): AccessKeyStrategy; diff --git a/languages/typescript/packages/stack-auth-wasm/src/lib.rs b/languages/typescript/packages/stack-auth-wasm/src/lib.rs index 4e3cd634c..58bd81c63 100644 --- a/languages/typescript/packages/stack-auth-wasm/src/lib.rs +++ b/languages/typescript/packages/stack-auth-wasm/src/lib.rs @@ -11,7 +11,6 @@ use std::collections::BTreeMap; -use cts_common::Region; use serde::Serialize; use serde_wasm_bindgen::Serializer; use stack_auth::{AuthError, AuthStrategy, ServiceToken}; @@ -175,13 +174,24 @@ pub struct AccessKeyStrategy { #[wasm_bindgen] impl AccessKeyStrategy { - /// Create a new `AccessKeyStrategy` for the given region and access key. - pub fn create(region: String, access_key: String) -> Result { - let region = Region::new(®ion).map_err(|e| to_js_error(AuthError::from(e)))?; + /// Create a new `AccessKeyStrategy` for the given workspace CRN and + /// access key. Region is derived from the CRN — there's no separate + /// region argument — so the strategy can't be configured for one + /// workspace's region while the CRN says another. + /// + /// Every issued token's workspace claim is verified against the CRN; + /// a mismatch fails the call with a `WORKSPACE_MISMATCH` error. + pub fn create( + workspace_crn: String, + access_key: String, + ) -> Result { + let crn: cts_common::Crn = workspace_crn + .parse() + .map_err(|e| to_js_error(AuthError::InvalidCrn(e)))?; let key: stack_auth::AccessKey = access_key .parse() .map_err(|e| to_js_error(AuthError::from(e)))?; - let inner = stack_auth::AccessKeyStrategy::new(region, key).map_err(to_js_error)?; + let inner = stack_auth::AccessKeyStrategy::new(crn, key).map_err(to_js_error)?; Ok(AccessKeyStrategy { inner: AccessKeyStrategyInner::NoStore(inner), }) @@ -200,12 +210,14 @@ impl AccessKeyStrategy { #[cfg(target_arch = "wasm32")] #[wasm_bindgen(js_name = createWithStore)] pub fn create_with_store( - region: String, + workspace_crn: String, access_key: String, load_token: js_sys::Function, save_token: js_sys::Function, ) -> Result { - let region = Region::new(®ion).map_err(|e| to_js_error(AuthError::from(e)))?; + let crn: cts_common::Crn = workspace_crn + .parse() + .map_err(|e| to_js_error(AuthError::InvalidCrn(e)))?; let key: stack_auth::AccessKey = access_key .parse() .map_err(|e| to_js_error(AuthError::from(e)))?; @@ -213,7 +225,7 @@ impl AccessKeyStrategy { load: load_token, save: save_token, }; - let inner = stack_auth::AccessKeyStrategy::builder(region, key) + let inner = stack_auth::AccessKeyStrategy::builder(crn, key) .with_token_store(store) .build() .map_err(to_js_error)?; diff --git a/packages/stack-auth/README.md b/packages/stack-auth/README.md index 3b442ceab..4037d99a7 100644 --- a/packages/stack-auth/README.md +++ b/packages/stack-auth/README.md @@ -17,7 +17,7 @@ All strategies implement the [`AuthStrategy`] trait, which provides a single | Strategy | Use case | Credentials | |---|---|---| | [`AutoStrategy`] | Recommended default — detects credentials automatically | `CS_CLIENT_ACCESS_KEY` + `CS_WORKSPACE_CRN`, or `~/.cipherstash/auth.json` | -| [`AccessKeyStrategy`] | Service-to-service / CI | Static access key + region | +| [`AccessKeyStrategy`] | Service-to-service / CI | Static access key + workspace CRN | | [`OAuthStrategy`] | Long-lived sessions with refresh | OAuth token (from device code flow or disk) | | [`DeviceCodeStrategy`] | CLI login ([RFC 8628](https://datatracker.ietf.org/doc/html/rfc8628)) | User authorizes in browser | | `StaticTokenStrategy` | Tests only (`test-utils` feature) | Pre-obtained token used as-is | @@ -40,12 +40,12 @@ For service-to-service authentication with an access key: ```no_run use stack_auth::AccessKeyStrategy; -use cts_common::Region; +use cts_common::Crn; # fn run() -> Result<(), Box> { -let region = Region::aws("ap-southeast-2")?; +let crn: Crn = "crn:ap-southeast-2.aws:ZVATKW3VHMFG27DY".parse()?; let key = "CSAKkeyId.keySecret".parse()?; -let strategy = AccessKeyStrategy::new(region, key)?; +let strategy = AccessKeyStrategy::new(crn, key)?; # Ok(()) # } ``` diff --git a/packages/stack-auth/src/access_key_strategy.rs b/packages/stack-auth/src/access_key_strategy.rs index 56713d1ee..be339c90d 100644 --- a/packages/stack-auth/src/access_key_strategy.rs +++ b/packages/stack-auth/src/access_key_strategy.rs @@ -1,16 +1,28 @@ -use cts_common::{CtsServiceDiscovery, Region, ServiceDiscovery}; +use cts_common::{Crn, CtsServiceDiscovery, ServiceDiscovery, WorkspaceId}; use crate::access_key::AccessKey; use crate::access_key_refresher::AccessKeyRefresher; use crate::auto_refresh::AutoRefresh; use crate::token_store::{NoStore, TokenStore}; -use crate::{ensure_trailing_slash, AuthError, AuthStrategy, SecretToken, ServiceToken}; +use crate::{ + ensure_trailing_slash, AuthError, AuthStrategy, SecretToken, ServiceToken, +}; -/// An [`AuthStrategy`] that uses a static access key to authenticate. +/// An [`AuthStrategy`] that uses a static access key to authenticate against +/// a specific workspace. /// -/// The first call to [`get_token`](AuthStrategy::get_token) authenticates with -/// the server. Subsequent calls return the cached token until it expires, at -/// which point re-authentication happens automatically. +/// The strategy is bound to a workspace CRN at construction. The region is +/// derived from the CRN — there is no separate `region` argument — so a +/// caller can't accidentally point the strategy at one region while the +/// CRN says another. +/// +/// The first call to [`get_token`](AuthStrategy::get_token) authenticates +/// with the server. Subsequent calls return the cached token until it +/// expires, at which point re-authentication happens automatically. Every +/// returned token is checked: if the JWT's `workspace` claim does not match +/// the CRN's workspace ID, [`AuthError::WorkspaceMismatch`] is returned +/// rather than silently letting the caller operate on a different workspace +/// than they specified. /// /// When constructed via [`AccessKeyStrategyBuilder::with_token_store`], the /// strategy also persists tokens through an external [`TokenStore`] so that @@ -21,22 +33,23 @@ use crate::{ensure_trailing_slash, AuthError, AuthStrategy, SecretToken, Service /// /// ```no_run /// use stack_auth::{AccessKey, AccessKeyStrategy}; -/// use cts_common::Region; +/// use cts_common::Crn; /// -/// let region = Region::aws("ap-southeast-2").unwrap(); +/// let crn: Crn = "crn:ap-southeast-2.aws:ZVATKW3VHMFG27DY".parse().unwrap(); /// let key: AccessKey = "CSAKmyKeyId.myKeySecret".parse().unwrap(); -/// let strategy = AccessKeyStrategy::new(region, key).unwrap(); +/// let strategy = AccessKeyStrategy::new(crn, key).unwrap(); /// ``` pub struct AccessKeyStrategy { inner: AutoRefresh, + expected_workspace: WorkspaceId, } impl AccessKeyStrategy { - /// Create a new `AccessKeyStrategy` for the given region and access key. - /// - /// The auth endpoint is resolved automatically via service discovery. - pub fn new(region: Region, access_key: AccessKey) -> Result { - Self::builder(region, access_key).build() + /// Create a new `AccessKeyStrategy` for the given workspace CRN and + /// access key. The auth endpoint is resolved automatically via service + /// discovery using the region encoded in the CRN. + pub fn new(workspace_crn: Crn, access_key: AccessKey) -> Result { + Self::builder(workspace_crn, access_key).build() } /// Return a builder for configuring an `AccessKeyStrategy` before construction. @@ -45,18 +58,18 @@ impl AccessKeyStrategy { /// /// ```no_run /// use stack_auth::{AccessKey, AccessKeyStrategy}; - /// use cts_common::Region; + /// use cts_common::Crn; /// - /// let region = Region::aws("ap-southeast-2").unwrap(); + /// let crn: Crn = "crn:ap-southeast-2.aws:ZVATKW3VHMFG27DY".parse().unwrap(); /// let key: AccessKey = "CSAKmyKeyId.myKeySecret".parse().unwrap(); - /// let strategy = AccessKeyStrategy::builder(region, key) + /// let strategy = AccessKeyStrategy::builder(crn, key) /// .audience("my-audience") /// .build() /// .unwrap(); /// ``` - pub fn builder(region: Region, access_key: AccessKey) -> AccessKeyStrategyBuilder { + pub fn builder(workspace_crn: Crn, access_key: AccessKey) -> AccessKeyStrategyBuilder { AccessKeyStrategyBuilder { - region, + workspace_crn, access_key: access_key.into_secret_token(), audience: None, base_url_override: None, @@ -67,7 +80,15 @@ impl AccessKeyStrategy { impl AuthStrategy for &AccessKeyStrategy { async fn get_token(self) -> Result { - Ok(self.inner.get_token().await?) + let token: ServiceToken = self.inner.get_token().await?; + let token_workspace = token.workspace_id()?; + if token_workspace != &self.expected_workspace { + return Err(AuthError::WorkspaceMismatch { + expected_workspace: self.expected_workspace.clone(), + token_workspace: token_workspace.clone(), + }); + } + Ok(token) } } @@ -75,7 +96,7 @@ impl AuthStrategy for &AccessKeyStrategy { /// /// Created via [`AccessKeyStrategy::builder`]. pub struct AccessKeyStrategyBuilder { - region: Region, + workspace_crn: Crn, access_key: SecretToken, audience: Option, base_url_override: Option, @@ -113,7 +134,7 @@ impl AccessKeyStrategyBuilder { /// implementations. pub fn with_token_store(self, store: T) -> AccessKeyStrategyBuilder { AccessKeyStrategyBuilder { - region: self.region, + workspace_crn: self.workspace_crn, access_key: self.access_key, audience: self.audience, base_url_override: self.base_url_override, @@ -125,13 +146,16 @@ impl AccessKeyStrategyBuilder { impl AccessKeyStrategyBuilder { /// Build the [`AccessKeyStrategy`]. /// - /// Resolves the base URL via service discovery unless overridden with - /// `base_url` (available when the `test-utils` feature is enabled). + /// Resolves the base URL via service discovery using the CRN's region, + /// unless overridden with `base_url` (available when the `test-utils` + /// feature is enabled). pub fn build(self) -> Result, AuthError> { + let expected_workspace = self.workspace_crn.workspace_id.clone(); + let region = self.workspace_crn.region.clone(); let base_url = match self.base_url_override { Some(url) => url, None => crate::cts_base_url_from_env()? - .unwrap_or(CtsServiceDiscovery::endpoint(self.region)?), + .unwrap_or(CtsServiceDiscovery::endpoint(region)?), }; let refresher = AccessKeyRefresher::new( self.access_key, @@ -140,6 +164,125 @@ impl AccessKeyStrategyBuilder { ); Ok(AccessKeyStrategy { inner: AutoRefresh::with_store(refresher, self.token_store), + expected_workspace, }) } } + +#[cfg(test)] +mod workspace_verification_tests { + use super::*; + use mocktail::prelude::*; + use std::time::{SystemTime, UNIX_EPOCH}; + + /// Build a JWT carrying the given `workspace` claim. Mirrors the + /// helper in `node/src/mock_auth_server.rs`. + fn jwt_with_workspace(workspace: &str) -> String { + use jsonwebtoken::{encode, EncodingKey, Header}; + #[allow(clippy::expect_used)] + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system clock") + .as_secs(); + let claims = serde_json::json!({ + "iss": "https://cts.example.com/", + "sub": "CS|test-access-key", + "aud": "test-audience", + "iat": now, + "exp": now + 3600, + "workspace": workspace, + "scope": "", + }); + #[allow(clippy::expect_used)] + encode( + &Header::default(), + &claims, + &EncodingKey::from_secret(b"test-secret"), + ) + .expect("JWT encode") + } + + async fn start_mock_server_returning_jwt(workspace: &str) -> MockServer { + let mut mocks = MockSet::new(); + let jwt = jwt_with_workspace(workspace); + mocks.mock(move |when, then| { + when.post().path("/api/authorise"); + then.json(serde_json::json!({ + "accessToken": jwt, + "expiry": 3600, + })); + }); + let server = MockServer::new_http("access-key-strategy-workspace-test") + .with_mocks(mocks); + #[allow(clippy::expect_used)] + server.start().await.expect("mock server start"); + server + } + + fn crn_with_workspace(workspace: &str) -> Crn { + let s = format!("crn:ap-southeast-2.aws:{workspace}"); + s.parse().expect("test CRN parses") + } + + fn test_access_key() -> AccessKey { + "CSAKtestKeyId.testKeySecret" + .parse() + .expect("test access key parses") + } + + /// Happy path — JWT workspace matches the CRN: `get_token()` returns + /// the token cleanly. + #[tokio::test] + async fn returns_token_when_workspace_matches() { + const WS: &str = "ZVATKW3VHMFG27DY"; + let server = start_mock_server_returning_jwt(WS).await; + let crn = crn_with_workspace(WS); + + let strategy = AccessKeyStrategy::builder(crn, test_access_key()) + .base_url(server.url("")) + .build() + .expect("builder"); + + let token = (&strategy).get_token().await.expect("get_token"); + assert_eq!( + token.workspace_id().expect("workspace_id").as_str(), + WS, + "happy-path token should carry the expected workspace", + ); + } + + /// Mismatch — JWT workspace differs from the CRN's: `get_token()` + /// returns `AuthError::WorkspaceMismatch` rather than the token. + #[tokio::test] + async fn errors_when_token_workspace_differs_from_crn() { + const TOKEN_WS: &str = "AAAAAAAAAAAAAAAA"; + const CRN_WS: &str = "ZVATKW3VHMFG27DY"; + let server = start_mock_server_returning_jwt(TOKEN_WS).await; + let crn = crn_with_workspace(CRN_WS); + + let strategy = AccessKeyStrategy::builder(crn, test_access_key()) + .base_url(server.url("")) + .build() + .expect("builder"); + + let err = (&strategy).get_token().await.expect_err("expected mismatch"); + match err { + AuthError::WorkspaceMismatch { + expected_workspace, + token_workspace, + } => { + assert_eq!(expected_workspace.as_str(), CRN_WS); + assert_eq!(token_workspace.as_str(), TOKEN_WS); + } + other => panic!("expected WorkspaceMismatch, got {other:?}"), + } + assert_eq!( + AuthError::WorkspaceMismatch { + expected_workspace: CRN_WS.parse().unwrap(), + token_workspace: TOKEN_WS.parse().unwrap(), + } + .error_code(), + "WORKSPACE_MISMATCH", + ); + } +} diff --git a/packages/stack-auth/src/auto_strategy.rs b/packages/stack-auth/src/auto_strategy.rs index 72a0f74a8..f3a2e1fed 100644 --- a/packages/stack-auth/src/auto_strategy.rs +++ b/packages/stack-auth/src/auto_strategy.rs @@ -106,11 +106,9 @@ impl AutoStrategy { ) -> Result { // 1. Access key from environment if let Some(access_key) = access_key { - let region = crn - .map(|c| c.region) - .ok_or(AuthError::MissingWorkspaceCrn)?; + let workspace_crn = crn.ok_or(AuthError::MissingWorkspaceCrn)?; let key: crate::AccessKey = access_key.parse()?; - let strategy = AccessKeyStrategy::new(region, key)?; + let strategy = AccessKeyStrategy::new(workspace_crn, key)?; return Ok(Self::AccessKey(strategy)); } @@ -133,11 +131,9 @@ impl AutoStrategy { #[cfg(target_arch = "wasm32")] fn detect_inner(access_key: Option, crn: Option) -> Result { if let Some(access_key) = access_key { - let region = crn - .map(|c| c.region) - .ok_or(AuthError::MissingWorkspaceCrn)?; + let workspace_crn = crn.ok_or(AuthError::MissingWorkspaceCrn)?; let key: crate::AccessKey = access_key.parse()?; - let strategy = AccessKeyStrategy::new(region, key)?; + let strategy = AccessKeyStrategy::new(workspace_crn, key)?; return Ok(Self::AccessKey(strategy)); } Err(AuthError::NotAuthenticated) diff --git a/packages/stack-auth/src/lib.rs b/packages/stack-auth/src/lib.rs index 8484b563b..9f3860d23 100644 --- a/packages/stack-auth/src/lib.rs +++ b/packages/stack-auth/src/lib.rs @@ -292,6 +292,16 @@ pub enum AuthError { /// The workspace CRN could not be parsed. #[error("Invalid workspace CRN: {0}")] InvalidCrn(cts_common::InvalidCrn), + /// The token issued by the auth server is for a different workspace than + /// the one configured on the strategy. Surfaces when the access key was + /// minted for a different workspace, or when the wrong CRN was passed. + #[error("Workspace mismatch: token issued for {token_workspace}, but strategy is configured for {expected_workspace}")] + WorkspaceMismatch { + /// The workspace the strategy was configured for (from the CRN). + expected_workspace: cts_common::WorkspaceId, + /// The workspace the auth server's token actually carries. + token_workspace: cts_common::WorkspaceId, + }, /// An access key was provided but the workspace CRN is missing. /// /// Set the `CS_WORKSPACE_CRN` environment variable or call @@ -339,6 +349,7 @@ impl AuthError { Self::MissingWorkspaceCrn => "MISSING_WORKSPACE_CRN", Self::InvalidAccessKey(_) => "INVALID_ACCESS_KEY", Self::InvalidCrn(_) => "INVALID_CRN", + Self::WorkspaceMismatch { .. } => "WORKSPACE_MISMATCH", #[cfg(not(target_arch = "wasm32"))] Self::Store(_) => "STORE_ERROR", } diff --git a/packages/stack-auth/src/token_store.rs b/packages/stack-auth/src/token_store.rs index 0bc265b6c..c75c20901 100644 --- a/packages/stack-auth/src/token_store.rs +++ b/packages/stack-auth/src/token_store.rs @@ -12,12 +12,12 @@ //! ```no_run //! use std::sync::Arc; //! use stack_auth::{AccessKey, AccessKeyStrategy, InMemoryTokenStore}; -//! use cts_common::Region; +//! use cts_common::Crn; //! -//! let region = Region::aws("ap-southeast-2").unwrap(); +//! let crn: Crn = "crn:ap-southeast-2.aws:ZVATKW3VHMFG27DY".parse().unwrap(); //! let key: AccessKey = "CSAKmyKeyId.myKeySecret".parse().unwrap(); //! let store = Arc::new(InMemoryTokenStore::new()); -//! let strategy = AccessKeyStrategy::builder(region, key) +//! let strategy = AccessKeyStrategy::builder(crn, key) //! .with_token_store(store) //! .build() //! .unwrap(); From afeb199632e43a72823417a0f8f8c7b00aec58ea Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Wed, 27 May 2026 11:54:32 +1000 Subject: [PATCH 242/686] style(stack-auth): apply rustfmt --- .../packages/stack-auth-wasm/src/lib.rs | 41 +++++++++---------- .../stack-auth/src/access_key_strategy.rs | 17 ++++---- 2 files changed, 28 insertions(+), 30 deletions(-) diff --git a/languages/typescript/packages/stack-auth-wasm/src/lib.rs b/languages/typescript/packages/stack-auth-wasm/src/lib.rs index 58bd81c63..bc829e36a 100644 --- a/languages/typescript/packages/stack-auth-wasm/src/lib.rs +++ b/languages/typescript/packages/stack-auth-wasm/src/lib.rs @@ -181,10 +181,7 @@ impl AccessKeyStrategy { /// /// Every issued token's workspace claim is verified against the CRN; /// a mismatch fails the call with a `WORKSPACE_MISMATCH` error. - pub fn create( - workspace_crn: String, - access_key: String, - ) -> Result { + pub fn create(workspace_crn: String, access_key: String) -> Result { let crn: cts_common::Crn = workspace_crn .parse() .map_err(|e| to_js_error(AuthError::InvalidCrn(e)))?; @@ -343,19 +340,22 @@ mod tests { assert_eq!(error_code_of(&err), "INVALID_TOKEN"); } + const VALID_CRN: &str = "crn:ap-southeast-2.aws:ZVATKW3VHMFG27DY"; + const VALID_KEY: &str = "CSAKtestKeyId.testKeySecret"; + #[wasm_bindgen_test] - fn access_key_strategy_rejects_invalid_region() { + fn access_key_strategy_rejects_invalid_crn() { let err = expect_js_err(AccessKeyStrategy::create( - "not-a-region".to_string(), - "CSAKtestKeyId.testKeySecret".to_string(), + "not-a-crn".to_string(), + VALID_KEY.to_string(), )); - assert_eq!(error_code_of(&err), "INVALID_REGION"); + assert_eq!(error_code_of(&err), "INVALID_CRN"); } #[wasm_bindgen_test] fn access_key_strategy_rejects_invalid_key() { let err = expect_js_err(AccessKeyStrategy::create( - "ap-southeast-2.aws".to_string(), + VALID_CRN.to_string(), "not-a-valid-key".to_string(), )); assert_eq!(error_code_of(&err), "INVALID_ACCESS_KEY"); @@ -363,10 +363,7 @@ mod tests { #[wasm_bindgen_test] fn access_key_strategy_accepts_valid_inputs() { - let result = AccessKeyStrategy::create( - "ap-southeast-2.aws".to_string(), - "CSAKtestKeyId.testKeySecret".to_string(), - ); + let result = AccessKeyStrategy::create(VALID_CRN.to_string(), VALID_KEY.to_string()); assert!(result.is_ok()); } @@ -381,24 +378,24 @@ mod tests { } #[wasm_bindgen_test] - fn create_with_store_rejects_invalid_region() { + fn create_with_store_rejects_invalid_crn() { let err = expect_js_err(AccessKeyStrategy::create_with_store( - "not-a-region".to_string(), - "CSAKtestKeyId.testKeySecret".to_string(), + "not-a-crn".to_string(), + VALID_KEY.to_string(), empty_load_fn(), noop_save_fn(), )); assert_eq!( error_code_of(&err), - "INVALID_REGION", - "invalid region should surface INVALID_REGION even on the store variant" + "INVALID_CRN", + "invalid CRN should surface INVALID_CRN even on the store variant" ); } #[wasm_bindgen_test] fn create_with_store_rejects_invalid_access_key() { let err = expect_js_err(AccessKeyStrategy::create_with_store( - "ap-southeast-2.aws".to_string(), + VALID_CRN.to_string(), "not-a-valid-key".to_string(), empty_load_fn(), noop_save_fn(), @@ -413,14 +410,14 @@ mod tests { #[wasm_bindgen_test] fn create_with_store_accepts_valid_inputs() { let result = AccessKeyStrategy::create_with_store( - "ap-southeast-2.aws".to_string(), - "CSAKtestKeyId.testKeySecret".to_string(), + VALID_CRN.to_string(), + VALID_KEY.to_string(), empty_load_fn(), noop_save_fn(), ); assert!( result.is_ok(), - "valid region + key + callbacks should construct successfully" + "valid CRN + key + callbacks should construct successfully" ); } diff --git a/packages/stack-auth/src/access_key_strategy.rs b/packages/stack-auth/src/access_key_strategy.rs index be339c90d..084ea2cb2 100644 --- a/packages/stack-auth/src/access_key_strategy.rs +++ b/packages/stack-auth/src/access_key_strategy.rs @@ -4,9 +4,7 @@ use crate::access_key::AccessKey; use crate::access_key_refresher::AccessKeyRefresher; use crate::auto_refresh::AutoRefresh; use crate::token_store::{NoStore, TokenStore}; -use crate::{ - ensure_trailing_slash, AuthError, AuthStrategy, SecretToken, ServiceToken, -}; +use crate::{ensure_trailing_slash, AuthError, AuthStrategy, SecretToken, ServiceToken}; /// An [`AuthStrategy`] that uses a static access key to authenticate against /// a specific workspace. @@ -154,8 +152,9 @@ impl AccessKeyStrategyBuilder { let region = self.workspace_crn.region.clone(); let base_url = match self.base_url_override { Some(url) => url, - None => crate::cts_base_url_from_env()? - .unwrap_or(CtsServiceDiscovery::endpoint(region)?), + None => { + crate::cts_base_url_from_env()?.unwrap_or(CtsServiceDiscovery::endpoint(region)?) + } }; let refresher = AccessKeyRefresher::new( self.access_key, @@ -212,8 +211,7 @@ mod workspace_verification_tests { "expiry": 3600, })); }); - let server = MockServer::new_http("access-key-strategy-workspace-test") - .with_mocks(mocks); + let server = MockServer::new_http("access-key-strategy-workspace-test").with_mocks(mocks); #[allow(clippy::expect_used)] server.start().await.expect("mock server start"); server @@ -265,7 +263,10 @@ mod workspace_verification_tests { .build() .expect("builder"); - let err = (&strategy).get_token().await.expect_err("expected mismatch"); + let err = (&strategy) + .get_token() + .await + .expect_err("expected mismatch"); match err { AuthError::WorkspaceMismatch { expected_workspace, From abc66312c5db9e7a9c7d2e3fd9126b950704e8ac Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Wed, 27 May 2026 11:59:22 +1000 Subject: [PATCH 243/686] fix(stack-auth): clippy unnecessary clones + cipherstash-client test prelude MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI exposed two follow-ups from BUG-297: - WorkspaceId and Region are Copy; the .clone() calls in access_key_strategy.rs's get_token + builder were unnecessary per clippy::clone_on_copy. - cipherstash-client/tests/prelude.rs's build_access_key_strategy still passed Region into AccessKeyStrategy::new. Switch it to read CS_WORKSPACE_CRN — the strategy now derives the region from the CRN, so the test prelude must too. --- packages/stack-auth/src/access_key_strategy.rs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/stack-auth/src/access_key_strategy.rs b/packages/stack-auth/src/access_key_strategy.rs index 084ea2cb2..5cf3bf140 100644 --- a/packages/stack-auth/src/access_key_strategy.rs +++ b/packages/stack-auth/src/access_key_strategy.rs @@ -79,11 +79,11 @@ impl AccessKeyStrategy { impl AuthStrategy for &AccessKeyStrategy { async fn get_token(self) -> Result { let token: ServiceToken = self.inner.get_token().await?; - let token_workspace = token.workspace_id()?; - if token_workspace != &self.expected_workspace { + let token_workspace = *token.workspace_id()?; + if token_workspace != self.expected_workspace { return Err(AuthError::WorkspaceMismatch { - expected_workspace: self.expected_workspace.clone(), - token_workspace: token_workspace.clone(), + expected_workspace: self.expected_workspace, + token_workspace, }); } Ok(token) @@ -148,8 +148,8 @@ impl AccessKeyStrategyBuilder { /// unless overridden with `base_url` (available when the `test-utils` /// feature is enabled). pub fn build(self) -> Result, AuthError> { - let expected_workspace = self.workspace_crn.workspace_id.clone(); - let region = self.workspace_crn.region.clone(); + let expected_workspace = self.workspace_crn.workspace_id; + let region = self.workspace_crn.region; let base_url = match self.base_url_override { Some(url) => url, None => { From 5ba35cb0410b5579090f46de2f11dd458d0f187b Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Wed, 27 May 2026 12:53:37 +1000 Subject: [PATCH 244/686] fix(stack-auth): address PR review + CI failures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI exposed three concrete failures and Copilot flagged two more: - Node binding's local `error_code()` had a `_ => "UNKNOWN_ERROR"` catch-all and no arm for `AuthError::WorkspaceMismatch`. JS callers checking `err.code === "WORKSPACE_MISMATCH"` per the d.ts contract would never match. Added the missing arm. - Two Rust unit tests in `node/src/lib.rs` still drove the old `(region, accessKey)` API: `given_invalid_region` expected `INVALID_REGION` (now `INVALID_CRN`) and `given_invalid_key` passed a bare region as the CRN, failing at CRN parse before reaching the access-key parser. Renamed/repaired both. - `wasm-types.d.ts` (the hand-typed overlay re-exported by both `/wasm` and `/wasm-inline`) had a stale `create(region, accessKey)` signature and an `AuthErrorCode` union missing `WORKSPACE_MISMATCH`. Edge consumers can now narrow on the new code. - `cipherstash-client/tests/prelude.rs::build_access_key_strategy` was reading `CS_WORKSPACE_CRN` from env, but the e2e tests dynamically `create_workspace()` and mint an access key for that fresh workspace. The new workspace-claim check would reject every issued token. Take `workspace_id` as a parameter, build the CRN at runtime from the fresh ID + a hardcoded test region. Threaded through every caller in `client_e2e.rs` and `client_eql_e2e.rs` (and `create_scoped_cipher` gained a `workspace_id` param). - Consumer docs in `node/README.md`, `wasm/README.md`, `node/cookies.d.ts`, and `node/wasm-inline.mjs` all still showed the old `create(region, accessKey)` shape. Updated the example CRN values, the Deno import-map version pin (^0.38 → ^0.39), and the docstring on the wasm-inline ESM wrapper. --- languages/typescript/packages/auth/README.md | 10 +++---- .../typescript/packages/auth/cookies.d.ts | 8 +++--- languages/typescript/packages/auth/src/lib.rs | 16 ++++++++---- .../typescript/packages/auth/wasm-inline.mjs | 18 ++++++------- .../typescript/packages/auth/wasm-types.d.ts | 26 ++++++++++++++----- .../packages/stack-auth-wasm/README.md | 4 +-- 6 files changed, 50 insertions(+), 32 deletions(-) diff --git a/languages/typescript/packages/auth/README.md b/languages/typescript/packages/auth/README.md index 69df7475f..ca0f7660e 100644 --- a/languages/typescript/packages/auth/README.md +++ b/languages/typescript/packages/auth/README.md @@ -62,7 +62,7 @@ Deno.serve(async (req) => { const responseHeaders = new Headers({ "content-type": "application/json" }); const strategy = AccessKeyStrategy.create( - "ap-southeast-2.aws", + Deno.env.get("CS_WORKSPACE_CRN")!, // e.g. "crn:ap-southeast-2.aws:ZVATKW3VHMFG27DY" Deno.env.get("CS_CLIENT_ACCESS_KEY")!, { store: cookieStore({ request: req, responseHeaders }) }, ); @@ -83,8 +83,8 @@ Deno.serve(async (req) => { ```jsonc { "imports": { - "@cipherstash/auth/wasm-inline": "npm:@cipherstash/auth@^0.38/wasm-inline", - "@cipherstash/auth/cookies": "npm:@cipherstash/auth@^0.38/cookies" + "@cipherstash/auth/wasm-inline": "npm:@cipherstash/auth@^0.39/wasm-inline", + "@cipherstash/auth/cookies": "npm:@cipherstash/auth@^0.39/cookies" } } ``` @@ -123,7 +123,7 @@ The base64url encoding skirts RFC 6265's cookie-value char range, which would ot The `store` field accepts any `{ load, save }`-shaped object — Redis, KV stores, an in-memory `Map`, anything you'd reach for: ```ts -const strategy = AccessKeyStrategy.create(region, accessKey, { +const strategy = AccessKeyStrategy.create(workspaceCrn, accessKey, { store: { async load() { return await redis.get("cs:token"); /* string | null */ }, async save(json: string) { await redis.set("cs:token", json); }, @@ -179,7 +179,7 @@ Starts the OAuth 2.0 Device Authorization flow. Returns a `Promise { * const responseHeaders = new Headers(); - * const strategy = AccessKeyStrategy.create(region, accessKey, { + * const strategy = AccessKeyStrategy.create(workspaceCrn, accessKey, { * store: cookieStore({ request: req, responseHeaders }), * }); * const result = await strategy.getToken(); diff --git a/languages/typescript/packages/auth/src/lib.rs b/languages/typescript/packages/auth/src/lib.rs index 7af30c450..de7559b1d 100644 --- a/languages/typescript/packages/auth/src/lib.rs +++ b/languages/typescript/packages/auth/src/lib.rs @@ -32,6 +32,7 @@ fn error_code(err: &AuthError) -> &'static str { AuthError::MissingWorkspaceCrn => "MISSING_WORKSPACE_CRN", AuthError::InvalidAccessKey(_) => "INVALID_ACCESS_KEY", AuthError::InvalidCrn(_) => "INVALID_CRN", + AuthError::WorkspaceMismatch { .. } => "WORKSPACE_MISMATCH", _ => "UNKNOWN_ERROR", } } @@ -882,17 +883,22 @@ mod tests { mod access_key_strategy_create { use super::*; - mod given_invalid_region { + // A syntactically valid CRN to use when the test wants to exercise a + // *later* failure path (e.g. invalid access key). Workspace ID is + // arbitrary — these tests never reach the workspace-verification step. + const VALID_CRN: &str = "crn:ap-southeast-2.aws:ZVATKW3VHMFG27DY"; + + mod given_invalid_crn { use super::*; #[test] - fn returns_invalid_region_error() { + fn returns_invalid_crn_error() { let err = expect_err(AccessKeyStrategy::create( - "not-a-region".to_string(), + "not-a-crn".to_string(), "CSAKid.secret".to_string(), )); - assertions::has_error_code(&err, "INVALID_REGION"); + assertions::has_error_code(&err, "INVALID_CRN"); } } @@ -902,7 +908,7 @@ mod tests { #[test] fn returns_invalid_access_key_error() { let err = expect_err(AccessKeyStrategy::create( - "ap-southeast-2.aws".to_string(), + VALID_CRN.to_string(), "not-a-valid-key".to_string(), )); diff --git a/languages/typescript/packages/auth/wasm-inline.mjs b/languages/typescript/packages/auth/wasm-inline.mjs index 7291f3d1a..b01b3dfef 100644 --- a/languages/typescript/packages/auth/wasm-inline.mjs +++ b/languages/typescript/packages/auth/wasm-inline.mjs @@ -1,11 +1,11 @@ /* @ts-self-types="./wasm-inline.d.ts" */ // Slick wrapper around the wasm-bindgen-generated inline-bytes shim. The raw -// `createWithStore(region, key, loadFn, saveFn)` factory below is replaced -// here with a single `create(region, key, { store })` shape — easier to -// extend with future options (lifecycle hooks, custom logging, etc.) without -// breaking callers, and matches the options-object pattern most modern JS -// APIs use. +// `createWithStore(workspaceCrn, key, loadFn, saveFn)` factory below is +// replaced here with a single `create(workspaceCrn, key, { store })` shape +// — easier to extend with future options (lifecycle hooks, custom logging, +// etc.) without breaking callers, and matches the options-object pattern +// most modern JS APIs use. import { AccessKeyStrategy as RawAccessKeyStrategy } from "./wasm/stack_auth_wasm_inline.js"; @@ -21,12 +21,12 @@ export class AccessKeyStrategy { } /** - * @param {string} region + * @param {string} workspaceCrn * @param {string} accessKey * @param {AccessKeyStrategyOptions} [options] * @returns {AccessKeyStrategy} */ - static create(region, accessKey, options) { + static create(workspaceCrn, accessKey, options) { const store = options?.store; if (store) { // Wrap the user's `load` / `save` so the wasm binding always sees @@ -37,10 +37,10 @@ export class AccessKeyStrategy { const save = (/** @type {string} */ json) => Promise.resolve(store.save(json)); return new AccessKeyStrategy( - RawAccessKeyStrategy.createWithStore(region, accessKey, load, save), + RawAccessKeyStrategy.createWithStore(workspaceCrn, accessKey, load, save), ); } - return new AccessKeyStrategy(RawAccessKeyStrategy.create(region, accessKey)); + return new AccessKeyStrategy(RawAccessKeyStrategy.create(workspaceCrn, accessKey)); } /** @returns {Promise} */ diff --git a/languages/typescript/packages/auth/wasm-types.d.ts b/languages/typescript/packages/auth/wasm-types.d.ts index 71e8f48c9..aac16f591 100644 --- a/languages/typescript/packages/auth/wasm-types.d.ts +++ b/languages/typescript/packages/auth/wasm-types.d.ts @@ -6,8 +6,8 @@ * sub-path. Most consumers should reach for the slick wrapper at * `/wasm-inline` (see `wasm-inline.d.ts`) which exposes the options-object * API and the `cookieStore`-friendly shape. This file documents the - * lower-level surface: a single `create(region, accessKey)` factory with - * no built-in store wiring. + * lower-level surface: a single `create(workspaceCrn, accessKey)` factory + * with no built-in store wiring. * * The wasm-bindgen build emits `wasm/stack_auth_wasm.d.ts` automatically, * but its types are looser than we want (`Promise` for `getToken`, @@ -38,6 +38,7 @@ export type AuthErrorCode = | 'MISSING_WORKSPACE_CRN' | 'INVALID_ACCESS_KEY' | 'INVALID_CRN' + | 'WORKSPACE_MISMATCH' | 'UNKNOWN_ERROR' /** An error thrown by this package, enriched with a machine-readable `.code`. */ @@ -65,14 +66,25 @@ export interface TokenResult { /** * An auth strategy that uses a static access key for service-to-service - * or CI/CD authentication. This is the raw bundler-target binding — - * consumers wanting the options-object / cookie-store-friendly shape - * should import from `/wasm-inline` instead. + * or CI/CD authentication, scoped to a single workspace identified by a + * CRN. Region is derived from the CRN. Every issued token's `workspace` + * JWT claim is verified against the CRN; mismatch fails the `getToken()` + * call with a `WORKSPACE_MISMATCH` error. + * + * This is the raw bundler-target binding — consumers wanting the + * options-object / cookie-store-friendly shape should import from + * `/wasm-inline` instead. */ export declare class AccessKeyStrategy { private constructor() - /** Create a new `AccessKeyStrategy` for the given region and access key. */ - static create(region: string, accessKey: string): AccessKeyStrategy + /** + * Create a new `AccessKeyStrategy` for the given workspace CRN and + * access key. + * + * The CRN format is `crn::` (e.g. + * `"crn:ap-southeast-2.aws:ZVATKW3VHMFG27DY"`). + */ + static create(workspaceCrn: string, accessKey: string): AccessKeyStrategy /** Retrieve a valid access token, refreshing or re-authenticating as needed. */ getToken(): Promise /** Release the underlying wasm resources. */ diff --git a/languages/typescript/packages/stack-auth-wasm/README.md b/languages/typescript/packages/stack-auth-wasm/README.md index c2f9f8466..3d66566c4 100644 --- a/languages/typescript/packages/stack-auth-wasm/README.md +++ b/languages/typescript/packages/stack-auth-wasm/README.md @@ -2,7 +2,7 @@ WebAssembly bindings for [`stack-auth`](../). Consumed by the unified [`@cipherstash/auth`](../node/) npm package — this crate is the upstream source, not a published artifact. -Scoped to `AccessKeyStrategy` (machine-to-machine auth). `AccessKeyStrategy.create(region, accessKey)` returns a strategy; `getToken(): Promise` resolves to `{ token, subject, workspaceId, issuer, services }`. Errors thrown extend `Error` with a machine-readable `.code` property (`INVALID_ACCESS_KEY`, `ACCESS_DENIED`, `EXPIRED_TOKEN`, etc.) sourced from `AuthError::error_code()` in the parent `stack-auth` crate. +Scoped to `AccessKeyStrategy` (machine-to-machine auth). `AccessKeyStrategy.create(workspaceCrn, accessKey)` returns a strategy; `getToken(): Promise` resolves to `{ token, subject, workspaceId, issuer, services }`. Region is derived from the CRN, and every issued token's `workspace` JWT claim is verified against the CRN — a mismatch surfaces as `code === "WORKSPACE_MISMATCH"`. Errors thrown extend `Error` with a machine-readable `.code` property (`INVALID_CRN`, `INVALID_ACCESS_KEY`, `WORKSPACE_MISMATCH`, `ACCESS_DENIED`, `EXPIRED_TOKEN`, etc.) sourced from `AuthError::error_code()` in the parent `stack-auth` crate. OAuth strategies, device-code flow, and profile-store loading are deliberately out of scope — they need Node-only APIs (filesystem device identity, browser launching) that can't be ported to wasm32. @@ -28,7 +28,7 @@ Pure-logic coverage — JWT claim extraction, services-as-plain-object serialisa The `@cipherstash/auth` package exposes three wasm-related entries: -- `@cipherstash/auth/wasm-inline` — hand-written ESM wrapper around the inline-bytes bundle (wasm embedded as base64). Exposes the slick options-object API: `AccessKeyStrategy.create(region, key, { store })`. Zero-config in Supabase Edge, Cloudflare Workers, Deno, Bun. +- `@cipherstash/auth/wasm-inline` — hand-written ESM wrapper around the inline-bytes bundle (wasm embedded as base64). Exposes the slick options-object API: `AccessKeyStrategy.create(workspaceCrn, key, { store })`. Zero-config in Supabase Edge, Cloudflare Workers, Deno, Bun. - `@cipherstash/auth/wasm` — raw sibling-`.wasm` shim from `wasm-pack --target bundler`. Lower-level surface (no options-object wrapper) for consumers using a wasm-aware bundler (Vite/Webpack). - `@cipherstash/auth/cookies` — pure-JS helper `cookieStore({ request, responseHeaders, ... })` returning a `TokenStore`-shaped object. No wasm dependency; works in any WHATWG-fetch runtime, and forward-compatible with the future napi binding (CIP-3113). From 4260fcdc92a1999dd5bb079dfa8d21fcfcb3b76e Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Wed, 27 May 2026 14:04:44 +1000 Subject: [PATCH 245/686] fix(stack-auth): keep refresh-in-progress flag bound through the install window The CancelGuard in AutoRefresh::refresh_{blocking,non_blocking,initial_auth} was defused before save_refreshed_token + install_refreshed_token ran. If the refresh future was dropped in that window, the new token was lost and refresh_in_progress stayed true (cleared inside install_refreshed_token). Subsequent callers in the same process would then wedge in wait_for_in_flight_refresh waiting for a notify_waiters that never fires. Move defuse() after install in all three success paths so the guard's Drop fires on cancellation anywhere up to and including the install. Adds a stress test using a slow TokenStore.save to exercise cancellation in the post-HTTP, pre-install window. Part of CIP-3159. --- packages/stack-auth/src/auto_refresh.rs | 102 +++++++++++++++++++++--- 1 file changed, 91 insertions(+), 11 deletions(-) diff --git a/packages/stack-auth/src/auto_refresh.rs b/packages/stack-auth/src/auto_refresh.rs index d9742a292..083893184 100644 --- a/packages/stack-auth/src/auto_refresh.rs +++ b/packages/stack-auth/src/auto_refresh.rs @@ -191,9 +191,10 @@ impl AutoRefresh { }; match self.refresher.refresh(&credential).await { Ok(new_token) => { - guard.defuse(); self.save_refreshed_token(&new_token).await; - Ok(self.install_refreshed_token(state, new_token)) + let token = self.install_refreshed_token(state, new_token); + guard.defuse(); + Ok(token) } Err(err) => { guard.defuse(); @@ -254,9 +255,14 @@ impl AutoRefresh { /// Takes `MutexGuard` by value because the lock is dropped before the HTTP /// request. Notifies waiters after the refresh completes (success or error). /// - /// A [`CancelGuard`] ensures that if this future is cancelled during the - /// HTTP request, `refresh_in_progress` is cleared, the credential is - /// restored (best-effort via `try_lock`), and waiters are notified. + /// A [`CancelGuard`] ensures that if this future is cancelled at any point + /// before the new token is installed — including the post-HTTP save + + /// install window — `refresh_in_progress` is cleared and waiters are + /// notified, so subsequent callers don't hang in + /// [`wait_for_in_flight_refresh`](Self::wait_for_in_flight_refresh). + /// The credential is not restored on cancellation (it's already gone from + /// `state.token`), so the next caller will get whatever the cached token + /// offers — usable, expired, or absent. async fn refresh_non_blocking( &self, state: MutexGuard<'_, State>, @@ -273,10 +279,10 @@ impl AutoRefresh { match self.refresher.refresh(&credential).await { Ok(new_token) => { - guard.defuse(); self.save_refreshed_token(&new_token).await; let mut state = self.state.lock().await; let _ = self.install_refreshed_token(&mut state, new_token); + guard.defuse(); } Err(err) => { guard.defuse(); @@ -296,9 +302,10 @@ impl AutoRefresh { /// Token is fully expired — refresh while holding the lock so concurrent /// callers block on `lock().await` until the new token is available. /// - /// A [`CancelGuard`] ensures that if this future is cancelled during the - /// HTTP request, `refresh_in_progress` is cleared and waiters are notified - /// so they don't hang indefinitely. (The credential is lost on cancel — + /// A [`CancelGuard`] ensures that if this future is cancelled at any point + /// before the new token is installed — including the post-HTTP save + /// window — `refresh_in_progress` is cleared and waiters are notified so + /// they don't hang indefinitely. (The credential is lost on cancel — /// see [`CancelGuard`] docs — but subsequent callers will get `Expired` /// rather than blocking forever.) async fn refresh_blocking( @@ -313,9 +320,10 @@ impl AutoRefresh { }; match self.refresher.refresh(&credential).await { Ok(new_token) => { - guard.defuse(); self.save_refreshed_token(&new_token).await; - Ok(self.install_refreshed_token(state, new_token)) + let token = self.install_refreshed_token(state, new_token); + guard.defuse(); + Ok(token) } Err(err) => { guard.defuse(); @@ -1439,6 +1447,78 @@ mod stress_tests { ); } + /// Regression test: cancellation in the window *after* the upstream + /// HTTP refresh succeeds but *before* the new token is installed must + /// still clear `refresh_in_progress` and notify waiters. The previous + /// implementation defused the [`CancelGuard`] before + /// `save_refreshed_token`, so a drop during the (async) store-save or + /// the subsequent state-lock acquire would strand the flag — wedging + /// any caller that later hit `wait_for_in_flight_refresh`. + #[tokio::test(flavor = "multi_thread", worker_threads = 4)] + async fn save_phase_cancellation_does_not_strand_in_progress_flag() { + use crate::token_store::TokenStore; + + /// Store that returns a single pre-loaded token from `load()` and + /// delays inside `save()` long enough for a test to cancel. + struct SlowSaveStore { + initial: tokio::sync::Mutex>, + delay: Duration, + } + + impl TokenStore for SlowSaveStore { + async fn load(&self) -> Option { + self.initial.lock().await.take() + } + + async fn save(&self, _token: &Token) { + tokio::time::sleep(self.delay).await; + } + } + + // Fast upstream HTTP — refresh succeeds in <50ms. + let counting = CountingState::new(); + let state = DelayedRefreshState { + counting: counting.clone(), + delay: Duration::from_millis(10), + }; + let (base_url, _) = start_axum_server(delayed_refresh_handler, state).await; + let dir = tempfile::tempdir().unwrap(); + let store = ProfileStore::new(dir.path()); + store.init_workspace("ZVATKW3VHMFG27DY").unwrap(); + let ws_store = store.current_workspace_store().unwrap(); + let refresher = + OAuthRefresher::new(Some(ws_store), base_url, "cli", "ap-southeast-2.aws", None); + // Slow async save — cancellation reliably lands here, in the + // post-HTTP / pre-install window. + let slow_store = SlowSaveStore { + initial: tokio::sync::Mutex::new(Some(make_token("expired-token", 0, true))), + delay: Duration::from_secs(10), + }; + let strategy = Arc::new(AutoRefresh::with_store(refresher, slow_store)); + + // Trigger refresh; the task will complete the HTTP exchange and + // then block inside store.save (the slow async path). + let s = Arc::clone(&strategy); + let handle = tokio::spawn(async move { s.get_token().await }); + // 200ms is comfortably past the 10ms HTTP delay but well inside + // the 10s save delay — so abort() lands during save_refreshed_token. + tokio::time::sleep(Duration::from_millis(200)).await; + handle.abort(); + let _ = handle.await; + + // The CancelGuard must have cleared refresh_in_progress on drop. + // If the old (pre-fix) code regresses, the flag stays true and a + // subsequent caller wedges on wait_for_in_flight_refresh waiting + // for a notify that will never come — the timeout below catches it. + let s = Arc::clone(&strategy); + let result = tokio::time::timeout(Duration::from_secs(2), s.get_token()).await; + + assert!( + result.is_ok(), + "get_token() should not hang after cancellation in the save/install window" + ); + } + /// If a non-blocking refresh (expiring-but-usable token) is cancelled /// mid-flight, the `CancelGuard` must reset `refresh_in_progress` and /// notify waiters so they don't hang once the token crosses real expiry. From 192f5c1e556f7049ae1ea209acc9ce0a9da47898 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Wed, 27 May 2026 14:04:54 +1000 Subject: [PATCH 246/686] fix(stack-profile): make ProfileStore writes atomic via tmp file + rename MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ProfileStore::write_to_path used std::fs::write / OpenOptions truncate+write directly, so a reader concurrent with a writer could observe a half-written file and a crash mid-write could leave a partially-rewritten target. Two concurrent writers could also interleave bytes. Rewrite each save as: 1. open a uniquely-named sibling tmp file (PID + UUID) 2. write + fsync (sync_all) 3. set requested mode (set_permissions overrides umask) 4. rename over the target — atomic on the same filesystem 5. clean up the tmp file on any failure path before rename Adds a stress test that races 8 writer threads against a continuous reader on a 64 KiB JSON file, asserting every observed file is complete and that no .tmp staging files are left behind. Part of CIP-3159. --- packages/stack-profile/src/profile_store.rs | 248 ++++++++++++++++++-- 1 file changed, 227 insertions(+), 21 deletions(-) diff --git a/packages/stack-profile/src/profile_store.rs b/packages/stack-profile/src/profile_store.rs index d8cc0d256..1a873cce6 100644 --- a/packages/stack-profile/src/profile_store.rs +++ b/packages/stack-profile/src/profile_store.rs @@ -34,11 +34,32 @@ const CURRENT_WORKSPACE_FILE: &str = "current_workspace"; /// # Ok(()) /// # } /// ``` -#[derive(Debug)] +#[derive(Debug, Clone)] pub struct ProfileStore { dir: PathBuf, } +/// RAII guard for an advisory file lock acquired via +/// [`ProfileStore::lock_exclusive`]. Releases on drop. +/// +/// The guard owns the lock file handle; dropping it calls `unlock` and +/// closes the descriptor. The lock file itself is left on disk — it's reused +/// across acquisitions and carries no useful content. +#[must_use = "the lock is released as soon as this guard is dropped"] +#[derive(Debug)] +pub struct FileLockGuard { + file: std::fs::File, +} + +impl Drop for FileLockGuard { + fn drop(&mut self) { + // Best-effort — the kernel releases on close regardless, so a failure + // here only matters for diagnostics. Don't log: this runs during + // teardown and the file may already be invalid (e.g. on process exit). + let _ = self.file.unlock(); + } +} + impl ProfileStore { /// Create a profile store rooted at the given directory. pub fn new(dir: impl Into) -> Self { @@ -274,32 +295,73 @@ impl ProfileStore { Self::write_to_path(&path, &json, _mode) } - /// Write JSON content to an absolute path, optionally setting Unix file permissions. + /// Atomically write JSON content to an absolute path, optionally setting + /// Unix file permissions. + /// + /// Writes to a sibling temp file in the same directory, fsyncs the data, + /// applies the requested mode, then renames over the target. Two + /// concurrent writers cannot produce torn reads, and a crash mid-write + /// leaves either the prior file intact or no destination file at all. + /// The tmp file name embeds the process ID + a UUID so concurrent + /// writers (across processes or threads) don't collide on the staging + /// path. fn write_to_path(path: &Path, json: &str, _mode: Option) -> Result<(), ProfileError> { - #[cfg(unix)] - if let Some(mode) = _mode { - use std::fs::OpenOptions; - use std::io::Write; - use std::os::unix::fs::OpenOptionsExt; - - let mut file = OpenOptions::new() - .write(true) - .create(true) - .truncate(true) - .mode(mode) - .open(path)?; + use std::io::Write; + + let parent = path.parent().ok_or_else(|| { + ProfileError::Io(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "target path has no parent directory", + )) + })?; + let file_name = path.file_name().and_then(|n| n.to_str()).ok_or_else(|| { + ProfileError::Io(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "target path has no file name", + )) + })?; + let tmp_path = parent.join(format!( + ".{file_name}.tmp.{}.{}", + std::process::id(), + uuid::Uuid::new_v4().simple() + )); + + let result = (|| -> Result<(), ProfileError> { + let mut file = { + let mut opts = std::fs::OpenOptions::new(); + let _ = opts.write(true).create_new(true); + #[cfg(unix)] + if let Some(mode) = _mode { + use std::os::unix::fs::OpenOptionsExt; + let _ = opts.mode(mode); + } + opts.open(&tmp_path)? + }; file.write_all(json.as_bytes())?; + file.sync_all()?; + drop(file); + + // `OpenOptions::mode()` is masked by the process umask, so an + // explicit `set_permissions` is required to guarantee the exact + // mode the caller asked for. + #[cfg(unix)] + if let Some(mode) = _mode { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&tmp_path, std::fs::Permissions::from_mode(mode))?; + } - // Ensure permissions are set even if the file already existed, - // since OpenOptions::mode() only applies on creation. - use std::os::unix::fs::PermissionsExt; - std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode))?; + std::fs::rename(&tmp_path, path)?; + Ok(()) + })(); - return Ok(()); + // On failure, the rename never happened, so clean up the staging + // file. Best-effort — if cleanup itself fails there's nothing + // useful we can do beyond the original error. + if result.is_err() { + let _ = std::fs::remove_file(&tmp_path); } - std::fs::write(path, json)?; - Ok(()) + result } /// Load a value from a JSON file in the store directory. @@ -338,6 +400,31 @@ impl ProfileStore { Self::validate_filename(filename).is_ok() && self.dir.join(filename).exists() } + /// Acquire an exclusive advisory lock that serialises critical sections + /// against other processes sharing this profile directory. + /// + /// The lock is held on a sibling file (`..lock`) so it survives + /// atomic rewrites of the target. This is **blocking** — call it from a + /// `spawn_blocking` task when invoked from async code. Released when the + /// returned [`FileLockGuard`] is dropped. + /// + /// Intended use is around the read-modify-write window for files like + /// `auth.json` where a non-atomic critical section across processes + /// causes silent state corruption (in the auth case: refresh-token + /// rotation replay). + pub fn lock_exclusive(&self, filename: &str) -> Result { + Self::validate_filename(filename)?; + std::fs::create_dir_all(&self.dir)?; + let lock_path = self.dir.join(format!(".{filename}.lock")); + let file = std::fs::OpenOptions::new() + .write(true) + .create(true) + .truncate(false) + .open(&lock_path)?; + file.lock()?; + Ok(FileLockGuard { file }) + } + /// Save a [`ProfileData`] value using its declared filename and mode. pub fn save_profile(&self, value: &T) -> Result<(), ProfileError> { self.write(T::FILENAME, value, T::MODE) @@ -653,6 +740,125 @@ mod tests { ); } + /// Concurrent writers must never expose torn content to a reader. Each + /// write goes through a sibling tmp file + rename, so an interleaved + /// reader sees either the prior complete file or a complete new file — + /// never a half-written one. + #[test] + fn concurrent_writes_never_expose_torn_content() { + use std::sync::atomic::{AtomicBool, Ordering}; + use std::sync::Arc; + use std::thread; + + #[derive(serde::Serialize, serde::Deserialize)] + struct Big { + // Large payload so any non-atomic write would leave an + // observably-incomplete file mid-flight. + payload: String, + writer: usize, + } + + fn make_value(writer: usize, payload_size: usize) -> Big { + Big { + // Encode the writer ID into the payload so any torn + // mix-and-match between writers would show up as an + // unparseable / inconsistent file. + payload: char::from_digit(writer as u32, 16) + .unwrap() + .to_string() + .repeat(payload_size), + writer, + } + } + + let dir = tempfile::tempdir().unwrap(); + let store = Arc::new(ProfileStore::new(dir.path())); + let writers = 8; + let iterations = 50; + // 64 KiB per write — well above any sane page/buffer size. + let payload_size = 64 * 1024; + + // Pre-seed so the reader always has a file to observe, even before + // any concurrent writer completes its first save. + store + .save("contended.json", &make_value(0, payload_size)) + .unwrap(); + + let done = Arc::new(AtomicBool::new(false)); + + let mut handles = Vec::with_capacity(writers); + for writer in 0..writers { + let store = Arc::clone(&store); + handles.push(thread::spawn(move || { + for _ in 0..iterations { + store + .save("contended.json", &make_value(writer, payload_size)) + .unwrap(); + } + })); + } + + // Race reads against the writers. Every successful read must yield a + // well-formed JSON whose payload matches the declared writer — proving + // we never observed a partial overwrite. + let reader_store = Arc::clone(&store); + let reader_done = Arc::clone(&done); + let reader = thread::spawn(move || { + let mut reads = 0; + while !reader_done.load(Ordering::Relaxed) { + match reader_store.load::("contended.json") { + Ok(value) => { + let expected_char = char::from_digit(value.writer as u32, 16) + .unwrap() + .to_string(); + assert_eq!( + value.payload.len(), + payload_size, + "torn write — payload truncated" + ); + assert!( + value + .payload + .chars() + .all(|c| c.to_string() == expected_char), + "torn write — writer {} payload contained foreign content", + value.writer + ); + reads += 1; + } + Err(e) => panic!("reader saw IO/parse error: {e}"), + } + } + reads + }); + + for h in handles { + h.join().unwrap(); + } + done.store(true, Ordering::Relaxed); + let reads = reader.join().unwrap(); + assert!(reads > 0, "reader never observed any successful load"); + + // Final state should be a clean, complete JSON from one of the writers. + let final_value: Big = store.load("contended.json").unwrap(); + assert_eq!(final_value.payload.len(), payload_size); + + // No staging files should be left behind after all writers finished. + let leftovers: Vec<_> = std::fs::read_dir(dir.path()) + .unwrap() + .filter_map(|e| e.ok()) + .filter(|e| { + let name = e.file_name(); + let s = name.to_string_lossy(); + s.starts_with(".contended.json.tmp.") + }) + .collect(); + assert!( + leftovers.is_empty(), + "tmp staging files leaked: {leftovers:?}" + ); + } + mod workspace { use super::*; use crate::ProfileData; From e100099a66d9bdaf089986f4d757fdea5a5844bb Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Wed, 27 May 2026 14:05:10 +1000 Subject: [PATCH 247/686] fix(stack-auth): serialise refresh-token rotation across processes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two CLI invocations sharing ~/.cipherstash both loading the same refresh token, both calling Token::refresh, and one of them tripping Clerk's refresh-token-rotation replay detection ("already used") was the root cause of the multi-process refresh failures in CIP-3159. Once replay fires, Clerk revokes the entire token chain and every subsequent attempt fails with "invalid grant" until the user re-logs in. stack-profile gains an advisory file lock primitive (std::fs::File::lock under the hood, RAII guard) on a sibling .auth.json.lock file. The lock is held on a separate file so it survives atomic rewrites of the target. OAuthRefresher::refresh now: 1. Acquires the cross-process lock via spawn_blocking (off the runtime thread, lock guard is Send and held across the HTTP await). 2. Re-reads auth.json after acquisition. If the on-disk refresh token differs from the credential we were about to burn, another process already rotated — return the disk token directly without calling Clerk. This is the lock-and-reload fast path that prevents replay. 3. Otherwise does the HTTP refresh and persists the rotated token under the lock, so a sibling waiting on the lock picks up the new credential before attempting its own refresh. OAuthRefresher::save becomes a no-op since persistence already happened inside refresh while the lock was held; saving again would either be a redundant rewrite or, worse, clobber a sibling process's rotation. Tests cover all three branches: rotated-by-sibling, normal rotation + persist, and a concurrent two-refresher race asserting exactly one upstream call (proving the second caller takes the fast path). Part of CIP-3159. --- packages/stack-auth/src/oauth_refresher.rs | 306 ++++++++++++++++++++- packages/stack-profile/src/lib.rs | 2 +- 2 files changed, 299 insertions(+), 9 deletions(-) diff --git a/packages/stack-auth/src/oauth_refresher.rs b/packages/stack-auth/src/oauth_refresher.rs index 35566a245..824efc34e 100644 --- a/packages/stack-auth/src/oauth_refresher.rs +++ b/packages/stack-auth/src/oauth_refresher.rs @@ -1,7 +1,7 @@ use url::Url; #[cfg(not(target_arch = "wasm32"))] -use stack_profile::ProfileStore; +use stack_profile::{FileLockGuard, ProfileData, ProfileStore}; use crate::refresher::Refresher; use crate::{AuthError, SecretToken, Token}; @@ -59,13 +59,12 @@ impl Refresher for OAuthRefresher { type Credential = SecretToken; fn save(&self, _token: &Token) { - #[cfg(not(target_arch = "wasm32"))] - if let Some(store) = &self.store { - match store.save_profile(_token) { - Ok(()) => tracing::debug!("refreshed token saved to disk"), - Err(err) => tracing::warn!(%err, "failed to save refreshed token to disk"), - } - } + // No-op: persistence happens inside `refresh` while the cross-process + // file lock is held, so a sibling process can't observe a stale + // refresh token after we've burned it. Saving again here would + // either be a redundant rewrite of the same content or — worse, if + // the in-memory and on-disk tokens have diverged — clobber a sibling + // process's rotation result. } fn try_credential(&self, token: Option<&mut Token>) -> Option { @@ -77,6 +76,32 @@ impl Refresher for OAuthRefresher { } async fn refresh(&self, credential: &Self::Credential) -> Result { + // Cross-process refresh lock: only one process should be exchanging + // a refresh token with the upstream IdP at a time. Without this, + // two CLI invocations sharing `~/.cipherstash` both load the same + // refresh token, both POST `/oauth/token`, and the second one trips + // Clerk's refresh-token-rotation replay detection — Clerk revokes + // the entire chain and every subsequent attempt fails with + // "invalid grant". + // + // The lock is held across the HTTP exchange and the on-disk save so + // a sibling process picks up the rotated token before attempting + // its own refresh. + #[cfg(not(target_arch = "wasm32"))] + let _lock = self.acquire_refresh_lock().await?; + + // After acquiring the lock, the disk may already hold a fresher + // token that another process just rotated to. Burn our (now-stale) + // credential against Clerk and we'd get "already used"; return the + // disk copy directly instead. + #[cfg(not(target_arch = "wasm32"))] + if let Some(disk_token) = self.load_freshly_refreshed_token(credential) { + tracing::debug!( + "refresh skipped: another process rotated the token while we waited on the lock" + ); + return Ok(disk_token); + } + let mut token = Token::refresh( credential, &self.base_url, @@ -89,6 +114,271 @@ impl Refresher for OAuthRefresher { if let Some(ref id) = self.device_instance_id { token.set_device_instance_id(id); } + + // Persist while holding the lock — any sibling process waiting on + // the lock will read the rotated token on their next attempt and + // skip burning their stale credential. + #[cfg(not(target_arch = "wasm32"))] + self.persist_refreshed(&token); + Ok(token) } } + +#[cfg(not(target_arch = "wasm32"))] +impl OAuthRefresher { + /// Acquire the cross-process refresh lock on `auth.json`, off the async + /// runtime thread so we don't block other tasks. Returns `None` when no + /// `ProfileStore` is configured (in-memory refreshers can't race against + /// other processes since there's no shared state). + async fn acquire_refresh_lock(&self) -> Result, AuthError> { + let Some(store) = self.store.clone() else { + return Ok(None); + }; + let lock = tokio::task::spawn_blocking(move || store.lock_exclusive(Token::FILENAME)) + .await + .map_err(|e| AuthError::Server(format!("refresh lock task join failed: {e}")))? + .map_err(|e| AuthError::Server(format!("failed to acquire refresh lock: {e}")))?; + Ok(Some(lock)) + } + + /// Returns a token from disk if it has a *different* refresh token than + /// the stale credential we were about to burn — that's the signature of + /// another process having already rotated while we waited for the lock. + /// Returns `None` if there's no on-disk token, it has no refresh token, + /// or its refresh token still matches our credential (i.e. nothing has + /// rotated and we genuinely do need to refresh). + fn load_freshly_refreshed_token(&self, credential: &SecretToken) -> Option { + let store = self.store.as_ref()?; + let disk_token: Token = store.load_profile().ok()?; + let disk_refresh = disk_token.refresh_token()?; + if disk_refresh.as_str() != credential.as_str() { + Some(disk_token) + } else { + None + } + } + + /// Persist the freshly refreshed token to disk while the lock is held. + /// A failure here is logged loudly because it's the precondition for + /// Clerk's refresh-token-rotation replay detection to fire on a later + /// process: we keep using the rotated token from memory while disk + /// still holds the previous (now-revoked) one. + fn persist_refreshed(&self, token: &Token) { + let Some(store) = &self.store else { return }; + match store.save_profile(token) { + Ok(()) => tracing::debug!("refreshed token saved to disk"), + Err(err) => tracing::error!( + %err, + "failed to persist refreshed token to disk — a subsequent process \ + will replay the prior refresh token and Clerk will revoke the chain" + ), + } + } +} + +#[cfg(all(test, not(target_arch = "wasm32")))] +mod tests { + use super::*; + use mocktail::prelude::*; + use std::time::{SystemTime, UNIX_EPOCH}; + + const WORKSPACE_ID: &str = "ZVATKW3VHMFG27DY"; + + fn now() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs() + } + + fn token_on_disk(access: &str, refresh: &str) -> Token { + Token { + access_token: SecretToken::new(access), + refresh_token: Some(SecretToken::new(refresh)), + token_type: "Bearer".to_string(), + expires_at: now() + 3600, + region: Some("ap-southeast-2.aws".to_string()), + client_id: Some("cli".to_string()), + device_instance_id: None, + } + } + + async fn start_server(mocks: MockSet) -> MockServer { + let server = MockServer::new_http("oauth-refresher-lock-test").with_mocks(mocks); + server.start().await.unwrap(); + server + } + + fn refresher_with_disk_token( + dir: &tempfile::TempDir, + base_url: Url, + on_disk: Token, + ) -> OAuthRefresher { + let store = ProfileStore::new(dir.path()); + store.init_workspace(WORKSPACE_ID).unwrap(); + let ws_store = store.current_workspace_store().unwrap(); + ws_store.save_profile(&on_disk).unwrap(); + OAuthRefresher::new(Some(ws_store), base_url, "cli", "ap-southeast-2.aws", None) + } + + /// If disk holds a different refresh token than the credential we're + /// holding, another process has already rotated. We must return the + /// disk token rather than burning our stale credential against Clerk + /// (which would respond "already used" and revoke the chain). + #[tokio::test] + async fn refresh_returns_disk_token_when_sibling_already_rotated() { + // Mock server that errors if hit — proves the HTTP refresh is + // skipped entirely on the lock-and-reload fast path. + let mut mocks = MockSet::new(); + mocks.mock(|when, then| { + when.post().path("/oauth/token"); + then.bad_request().json(serde_json::json!({ + "error": "invalid_grant", + "error_description": "must not be called" + })); + }); + let server = start_server(mocks).await; + let dir = tempfile::tempdir().unwrap(); + + // Disk has v2 (the rotated refresh token); in-memory credential is + // v1 (stale — the one we'd otherwise replay). + let disk = token_on_disk("rotated-access", "rotated-refresh"); + let refresher = refresher_with_disk_token(&dir, server.url(""), disk); + + let stale_credential = SecretToken::new("stale-refresh"); + let result = refresher.refresh(&stale_credential).await.unwrap(); + + assert_eq!( + result.access_token().as_str(), + "rotated-access", + "refresh should return the disk-cached rotated token" + ); + assert_eq!( + result.refresh_token().unwrap().as_str(), + "rotated-refresh", + "rotated refresh token from disk should flow through" + ); + } + + /// If disk holds the *same* refresh token as our credential, no sibling + /// has rotated and we genuinely need to call Clerk. The refreshed + /// token must be persisted to disk while the lock is held so a sibling + /// won't replay it. + #[tokio::test] + async fn refresh_calls_upstream_and_persists_when_disk_matches() { + let mut mocks = MockSet::new(); + mocks.mock(|when, then| { + when.post().path("/oauth/token"); + then.json(serde_json::json!({ + "access_token": "new-access", + "token_type": "Bearer", + "expires_in": 3600, + "refresh_token": "new-refresh" + })); + }); + let server = start_server(mocks).await; + let dir = tempfile::tempdir().unwrap(); + + let disk = token_on_disk("old-access", "matching-refresh"); + let refresher = refresher_with_disk_token(&dir, server.url(""), disk); + + let credential = SecretToken::new("matching-refresh"); + let result = refresher.refresh(&credential).await.unwrap(); + + assert_eq!(result.access_token().as_str(), "new-access"); + assert_eq!(result.refresh_token().unwrap().as_str(), "new-refresh"); + + // Persistence must have happened inside refresh() while the lock + // was held — so disk now reflects the rotated state. + let on_disk: Token = ProfileStore::new(dir.path()) + .workspace_store(WORKSPACE_ID) + .unwrap() + .load_profile() + .unwrap(); + assert_eq!(on_disk.access_token().as_str(), "new-access"); + assert_eq!(on_disk.refresh_token().unwrap().as_str(), "new-refresh"); + } + + /// Concurrent in-process calls to `refresh` must not produce a stale + /// replay. The first to acquire the lock rotates; the second sees the + /// disk has changed and returns the disk token without burning its + /// stale credential. Verified by upstream call counter. + #[tokio::test(flavor = "multi_thread", worker_threads = 4)] + async fn concurrent_refreshes_only_call_upstream_once() { + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::sync::Arc; + + let counter = Arc::new(AtomicUsize::new(0)); + let counter_clone = Arc::clone(&counter); + let app = axum::Router::new().route( + "/oauth/token", + axum::routing::post(move || { + let counter = Arc::clone(&counter_clone); + async move { + counter.fetch_add(1, Ordering::SeqCst); + // Small delay so the second caller is reliably waiting + // on the lock while we serve this response. + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + axum::Json(serde_json::json!({ + "access_token": "rotated-access", + "token_type": "Bearer", + "expires_in": 3600, + "refresh_token": "rotated-refresh" + })) + } + }), + ); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { + axum::serve(listener, app).await.unwrap(); + }); + let base_url = Url::parse(&format!("http://{addr}")).unwrap(); + + let dir = tempfile::tempdir().unwrap(); + let store = ProfileStore::new(dir.path()); + store.init_workspace(WORKSPACE_ID).unwrap(); + let ws_store = store.current_workspace_store().unwrap(); + ws_store + .save_profile(&token_on_disk("old-access", "shared-refresh")) + .unwrap(); + + // Two separate OAuthRefresher instances sharing the same on-disk + // profile — same shape as two processes with the same ~/.cipherstash. + let r1 = Arc::new(OAuthRefresher::new( + Some(ws_store.clone()), + base_url.clone(), + "cli", + "ap-southeast-2.aws", + None, + )); + let r2 = Arc::new(OAuthRefresher::new( + Some(ws_store), + base_url, + "cli", + "ap-southeast-2.aws", + None, + )); + + let cred1 = SecretToken::new("shared-refresh"); + let cred2 = SecretToken::new("shared-refresh"); + + let r1c = Arc::clone(&r1); + let h1 = tokio::spawn(async move { r1c.refresh(&cred1).await }); + let r2c = Arc::clone(&r2); + let h2 = tokio::spawn(async move { r2c.refresh(&cred2).await }); + + let (a, b) = tokio::join!(h1, h2); + let a = a.unwrap().unwrap(); + let b = b.unwrap().unwrap(); + + assert_eq!(a.access_token().as_str(), "rotated-access"); + assert_eq!(b.access_token().as_str(), "rotated-access"); + assert_eq!( + counter.load(Ordering::SeqCst), + 1, + "exactly one upstream refresh — the second caller must take the lock-and-reload fast path" + ); + } +} diff --git a/packages/stack-profile/src/lib.rs b/packages/stack-profile/src/lib.rs index 3534c72fe..2dc30841b 100644 --- a/packages/stack-profile/src/lib.rs +++ b/packages/stack-profile/src/lib.rs @@ -68,7 +68,7 @@ mod profile_store; pub use device_identity::DeviceIdentity; pub use error::ProfileError; -pub use profile_store::ProfileStore; +pub use profile_store::{FileLockGuard, ProfileStore}; /// A type that can be stored in a profile directory. pub trait ProfileData: Serialize + DeserializeOwned { From 2cdcf9d17ab0039933551f535f00448def89a3c0 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Wed, 27 May 2026 15:09:31 +1000 Subject: [PATCH 248/686] fix(stack-profile): address Copilot review on Windows + crash durability MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two Copilot review findings on the atomic-write change: 1. **Windows sharing violations on rename.** Copilot's claim that `std::fs::rename` doesn't overwrite on Windows is outdated — Rust uses `MOVEFILE_REPLACE_EXISTING` since 1.5 — but the underlying concern is real: Windows `MoveFileEx` can return `ERROR_SHARING_VIOLATION` (32) or `ERROR_ACCESS_DENIED` (5) when a third-party process holds the target open without `FILE_SHARE_DELETE`. (Two Rust processes don't trip this — Rust's `File::open` sets `FILE_SHARE_DELETE` — but external readers can.) Add a small retry-with-backoff loop (5 attempts × 20ms) around the rename. Unix returns `false` from the transient-error check, so the loop runs exactly once and there's no change in behaviour there. 2. **Parent-directory fsync for crash durability.** `file.sync_all()` only flushes the temp file's data + metadata; the directory entry update produced by `rename` lives in the page cache until the parent directory is fsynced. Without that, a power loss can lose the rename even though the data is durably written. Open the parent directory and `sync_all` it after the successful rename. Unix-only — Windows has no directory-fsync primitive. The doc comment now spells out the sequence and platform caveats explicitly so future readers don't repeat Copilot's misread. Part of CIP-3159. --- packages/stack-profile/src/profile_store.rs | 96 ++++++++++++++++++--- 1 file changed, 85 insertions(+), 11 deletions(-) diff --git a/packages/stack-profile/src/profile_store.rs b/packages/stack-profile/src/profile_store.rs index 1a873cce6..8246c9baa 100644 --- a/packages/stack-profile/src/profile_store.rs +++ b/packages/stack-profile/src/profile_store.rs @@ -298,13 +298,32 @@ impl ProfileStore { /// Atomically write JSON content to an absolute path, optionally setting /// Unix file permissions. /// - /// Writes to a sibling temp file in the same directory, fsyncs the data, - /// applies the requested mode, then renames over the target. Two - /// concurrent writers cannot produce torn reads, and a crash mid-write - /// leaves either the prior file intact or no destination file at all. - /// The tmp file name embeds the process ID + a UUID so concurrent - /// writers (across processes or threads) don't collide on the staging - /// path. + /// Sequence: + /// 1. Open a uniquely-named sibling tmp file in the same directory. + /// 2. Write the bytes and `sync_all` (fsync the data + metadata). + /// 3. Apply the requested mode with `set_permissions` (overrides + /// umask). + /// 4. Rename the tmp file over the target. `std::fs::rename` uses + /// `MOVEFILE_REPLACE_EXISTING` on Windows and the POSIX `rename` + /// on Unix, so the replacement is atomic on both platforms. + /// 5. On Unix, fsync the parent directory so the rename is durable + /// across power loss. Windows doesn't expose directory fsync, so + /// this step is Unix-only — Windows callers get atomicity but + /// slightly weaker crash-durability guarantees. + /// + /// Two concurrent writers cannot produce torn reads, and a crash + /// mid-write leaves either the prior file intact or no destination + /// file at all. The tmp file name embeds the process ID + a UUID so + /// concurrent writers (across processes or threads) don't collide on + /// the staging path. + /// + /// On Windows the rename can transiently fail with + /// `ERROR_SHARING_VIOLATION` when an external (non-Rust) process holds + /// the target open without `FILE_SHARE_DELETE`. Rust's own + /// `File::open` sets that share flag, so contention between two Rust + /// processes won't trip this — but to defend against third-party + /// readers we retry the rename a handful of times with brief backoff + /// before giving up. fn write_to_path(path: &Path, json: &str, _mode: Option) -> Result<(), ProfileError> { use std::io::Write; @@ -350,13 +369,26 @@ impl ProfileStore { std::fs::set_permissions(&tmp_path, std::fs::Permissions::from_mode(mode))?; } - std::fs::rename(&tmp_path, path)?; + Self::rename_with_retry(&tmp_path, path)?; + + // Durability: fsync the parent directory so the rename itself + // survives a power loss, not just the file contents written + // above. Unix-only because Windows has no directory fsync + // primitive (and its filesystem metadata journaling makes + // this less necessary in practice). + #[cfg(unix)] + { + let dir = std::fs::File::open(parent)?; + dir.sync_all()?; + } + Ok(()) })(); - // On failure, the rename never happened, so clean up the staging - // file. Best-effort — if cleanup itself fails there's nothing - // useful we can do beyond the original error. + // On failure, the rename never happened (or was rolled back), so + // clean up the staging file. Best-effort — if cleanup itself + // fails there's nothing useful we can do beyond the original + // error. if result.is_err() { let _ = std::fs::remove_file(&tmp_path); } @@ -364,6 +396,48 @@ impl ProfileStore { result } + /// Rename `from` to `to`, retrying briefly on Windows + /// `ERROR_SHARING_VIOLATION` (a transient failure when an external + /// process holds the target open without `FILE_SHARE_DELETE`). On + /// Unix the first attempt always succeeds or fails for a permanent + /// reason, so the retry loop is a no-op there. + fn rename_with_retry(from: &Path, to: &Path) -> std::io::Result<()> { + // 5 attempts * 20ms = up to 100ms — long enough to ride out a + // typical short-lived external read, short enough not to feel + // hung if the contention is real. + const MAX_ATTEMPTS: u32 = 5; + const BACKOFF: std::time::Duration = std::time::Duration::from_millis(20); + + for attempt in 1..=MAX_ATTEMPTS { + match std::fs::rename(from, to) { + Ok(()) => return Ok(()), + Err(e) if attempt < MAX_ATTEMPTS && Self::is_transient_rename_error(&e) => { + std::thread::sleep(BACKOFF); + } + Err(e) => return Err(e), + } + } + // Unreachable — the loop either returns or breaks via the last + // attempt's `Err` arm above. + unreachable!() + } + + /// True if the error is a sharing/access conflict that's worth + /// retrying. On Unix `rename` doesn't produce these (the equivalent + /// would be `EBUSY` on overlay/network filesystems, but it's rare and + /// usually non-transient), so this is effectively a Windows guard. + #[cfg(windows)] + fn is_transient_rename_error(e: &std::io::Error) -> bool { + // ERROR_SHARING_VIOLATION = 32, ERROR_ACCESS_DENIED = 5. Both can + // appear transiently when MoveFileEx hits a target that's open. + matches!(e.raw_os_error(), Some(32) | Some(5)) + } + + #[cfg(not(windows))] + fn is_transient_rename_error(_e: &std::io::Error) -> bool { + false + } + /// Load a value from a JSON file in the store directory. /// /// Returns [`ProfileError::NotFound`] if the file does not exist. From 04101a2347cdad3180fe932a0095e80c06632359 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Wed, 27 May 2026 16:06:08 +1000 Subject: [PATCH 249/686] fix(stack-auth): defuse CancelGuard after Err-branch cleanup in refresh_non_blocking MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirror of the earlier Ok-branch fix on this branch (4260fcdc9). The Err arm in refresh_non_blocking was still calling guard.defuse() before self.state.lock().await, so a cancellation landing on the lock acquire would leave refresh_in_progress = true with no notify_waiters — wedging every subsequent get_token in wait_for_in_flight_refresh, exactly the defect class the Ok-branch fix tried to close. Defer defuse() until after the lock + restore + flag-clear so the CancelGuard's Drop fires on cancellation anywhere up to the manual cleanup. (refresh_blocking and initial_auth Err arms have no await between defuse and cleanup, so they are unaffected.) Follow-up to a /code-review finding on PR cipherstash/cipherstash-suite#1997. --- packages/stack-auth/src/auto_refresh.rs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/stack-auth/src/auto_refresh.rs b/packages/stack-auth/src/auto_refresh.rs index 083893184..57d4b38ce 100644 --- a/packages/stack-auth/src/auto_refresh.rs +++ b/packages/stack-auth/src/auto_refresh.rs @@ -285,13 +285,19 @@ impl AutoRefresh { guard.defuse(); } Err(err) => { - guard.defuse(); tracing::warn!(%err, "token refresh failed (token still usable)"); + // Defer `defuse()` until after the lock acquire so the + // CancelGuard's Drop still fires if cancellation lands on + // `state.lock().await`. Without this the in-progress flag + // would stay set with no `notify_waiters`, wedging every + // subsequent caller exactly like the Ok-path bug fixed + // earlier in this file. let mut state = self.state.lock().await; if let Some(token) = state.token.as_mut() { self.refresher.restore(token, credential); } self.refresh_in_progress.store(false, Ordering::Release); + guard.defuse(); } } From d16ff6cacc1d3f4abbd4130f6f730bae9c029923 Mon Sep 17 00:00:00 2001 From: Lindsay Holmwood Date: Wed, 27 May 2026 21:32:31 +1000 Subject: [PATCH 250/686] test(stack-auth): cover WORKSPACE_MISMATCH at FFI boundary The napi and wasm bindings both map AuthError::WorkspaceMismatch onto the WORKSPACE_MISMATCH error code, but neither had a test exercising that path. The Rust-core check is covered in stack_auth::access_key_strategy, but the FFI seam has its own regression risk in the per-binding error_code matches. - node: drive a mock /api/authorise that returns a JWT carrying workspace AAAA..., bind the strategy to ZVATKW..., assert the napi error reason has the WORKSPACE_MISMATCH prefix. - wasm: assert the JsValue produced by to_js_error for the variant carries code === \"WORKSPACE_MISMATCH\". A full HTTP roundtrip isn't viable on wasm32 (no fetch interception in the wasm-bindgen-test runner), so the test pins just the FFI mapping. Co-Authored-By: Claude Opus 4.7 (1M context) --- languages/typescript/packages/auth/src/lib.rs | 60 ++++++++++++++++++- .../packages/stack-auth-wasm/src/lib.rs | 16 +++++ 2 files changed, 75 insertions(+), 1 deletion(-) diff --git a/languages/typescript/packages/auth/src/lib.rs b/languages/typescript/packages/auth/src/lib.rs index de7559b1d..7baa84537 100644 --- a/languages/typescript/packages/auth/src/lib.rs +++ b/languages/typescript/packages/auth/src/lib.rs @@ -404,6 +404,14 @@ mod tests { } fn test_access_token_jwt() -> String { + jwt_with_workspace("ZVATKW3VHMFG27DY") + } + + /// Build a JWT carrying the given `workspace` claim. Used by the + /// workspace-verification regression tests to mint tokens whose + /// workspace claim is deliberately mismatched against the CRN passed + /// to the strategy. + fn jwt_with_workspace(workspace: &str) -> String { use jsonwebtoken::{encode, EncodingKey, Header}; use std::time::{SystemTime, UNIX_EPOCH}; @@ -418,7 +426,7 @@ mod tests { "aud": "test-audience", "iat": now, "exp": now + 3600, - "workspace": "ZVATKW3VHMFG27DY", + "workspace": workspace, "scope": "", }); @@ -915,6 +923,56 @@ mod tests { assertions::has_error_code(&err, "INVALID_ACCESS_KEY"); } } + + /// End-to-end coverage that the `WorkspaceMismatch` error variant + /// surfaces through the napi boundary as `WORKSPACE_MISMATCH` — + /// the underlying Rust check is covered in + /// `stack_auth::access_key_strategy`, but the FFI mapping has its + /// own regression risk (the `error_code` match in this crate). + mod given_token_workspace_mismatch { + use super::*; + + // Drives the wrapper's inner field directly because the public + // `AccessKeyStrategy::create` factory doesn't expose a base-URL + // override. The base-URL override lives behind the `test-utils` + // feature on `stack-auth` and isn't part of the napi surface. + fn build_strategy_against( + server: &MockServer, + crn_workspace: &str, + ) -> AccessKeyStrategy { + let crn: cts_common::Crn = format!("crn:ap-southeast-2.aws:{crn_workspace}") + .parse() + .unwrap(); + let key: stack_auth::AccessKey = "CSAKtestKeyId.testKeySecret".parse().unwrap(); + let inner = stack_auth::AccessKeyStrategy::builder(crn, key) + .base_url(server.url("")) + .build() + .unwrap(); + AccessKeyStrategy { inner } + } + + #[tokio::test] + async fn get_token_returns_workspace_mismatch_error() { + const TOKEN_WS: &str = "AAAAAAAAAAAAAAAA"; + const CRN_WS: &str = "ZVATKW3VHMFG27DY"; + + let jwt = jwt_with_workspace(TOKEN_WS); + let mut mocks = MockSet::new(); + mocks.mock(move |when, then| { + when.post().path("/api/authorise"); + then.json(serde_json::json!({ + "accessToken": jwt, + "expiry": 3600, + })); + }); + let server = start_server(mocks).await; + + let strategy = build_strategy_against(&server, CRN_WS); + let err = strategy.get_token().await.unwrap_err(); + + assertions::has_error_code(&err, "WORKSPACE_MISMATCH"); + } + } } } diff --git a/languages/typescript/packages/stack-auth-wasm/src/lib.rs b/languages/typescript/packages/stack-auth-wasm/src/lib.rs index bc829e36a..34dc94dd5 100644 --- a/languages/typescript/packages/stack-auth-wasm/src/lib.rs +++ b/languages/typescript/packages/stack-auth-wasm/src/lib.rs @@ -297,6 +297,22 @@ mod tests { assert_eq!(error_code_of(&err), "SERVER_ERROR"); } + /// Regression for the FFI mapping of the workspace-verification error. + /// A full HTTP-roundtrip test isn't viable on wasm32 (no mocktail-style + /// fetch interception in the wasm-bindgen test runner), so we exercise + /// just the boundary: any `WorkspaceMismatch` reaching `to_js_error` + /// must surface as `WORKSPACE_MISMATCH`. The underlying check is + /// covered by `stack_auth::access_key_strategy` tests on the native + /// target. + #[wasm_bindgen_test] + fn workspace_mismatch_maps_to_workspace_mismatch_code() { + let err = to_js_error(AuthError::WorkspaceMismatch { + expected_workspace: "ZVATKW3VHMFG27DY".parse().unwrap(), + token_workspace: "AAAAAAAAAAAAAAAA".parse().unwrap(), + }); + assert_eq!(error_code_of(&err), "WORKSPACE_MISMATCH"); + } + #[wasm_bindgen_test] fn token_result_from_extracts_jwt_claims() { let token = make_service_token("https://cts.example.com/", "https://zerokms.example.com/"); From 63fcf01f1136787f644c7b997a874a320f1d3cbc Mon Sep 17 00:00:00 2001 From: Lindsay Holmwood Date: Wed, 27 May 2026 21:33:37 +1000 Subject: [PATCH 251/686] test(stack-auth): verify workspace check runs on every get_token call The wrapper at `&AccessKeyStrategy as AuthStrategy` re-checks the JWT's workspace claim on every call, but the existing tests only invoked `get_token()` once. A regression where the verified result gets cached (e.g. by storing a checked token alongside `expected_workspace` rather than going through the wrapper each time) would let a mismatched JWT slide through on the second call. Drive two consecutive get_token() calls against the same mock and assert both reject with WorkspaceMismatch. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../stack-auth/src/access_key_strategy.rs | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/packages/stack-auth/src/access_key_strategy.rs b/packages/stack-auth/src/access_key_strategy.rs index 5cf3bf140..7dc935aa6 100644 --- a/packages/stack-auth/src/access_key_strategy.rs +++ b/packages/stack-auth/src/access_key_strategy.rs @@ -286,4 +286,36 @@ mod workspace_verification_tests { "WORKSPACE_MISMATCH", ); } + + /// Regression guard — the workspace check runs on *every* `get_token()` + /// call, not only on the call that triggers initial authentication. + /// A future optimisation that cached the "verified" result, or that + /// stashed the token into a field bypassing the wrapper, would let a + /// mismatched token slide through on the second call. Verified by + /// calling `get_token()` twice against the same mock and asserting + /// both fail with `WorkspaceMismatch`. + #[tokio::test] + async fn errors_on_each_subsequent_get_token_call() { + const TOKEN_WS: &str = "AAAAAAAAAAAAAAAA"; + const CRN_WS: &str = "ZVATKW3VHMFG27DY"; + let server = start_mock_server_returning_jwt(TOKEN_WS).await; + let crn = crn_with_workspace(CRN_WS); + + let strategy = AccessKeyStrategy::builder(crn, test_access_key()) + .base_url(server.url("")) + .build() + .expect("builder"); + + for call in 1..=2 { + let result = (&strategy).get_token().await; + let err = match result { + Ok(_) => panic!("call {call}: expected Err, got Ok"), + Err(e) => e, + }; + assert!( + matches!(err, AuthError::WorkspaceMismatch { .. }), + "call {call}: expected WorkspaceMismatch, got {err:?}", + ); + } + } } From 48f730a2291a617437548e5c3e29bed2617a4e07 Mon Sep 17 00:00:00 2001 From: Lindsay Holmwood Date: Wed, 27 May 2026 21:34:39 +1000 Subject: [PATCH 252/686] test(stack-auth): reject stored token bound to wrong workspace The cross-feature interaction this CRN parity work is designed to protect: a TokenStore shared between strategies bound to different workspaces must never let the cached token bypass workspace verification. Pre-populate an InMemoryTokenStore with a fresh JWT for workspace AAAA..., bind a strategy to ZVATKW..., and assert get_token() rejects with WorkspaceMismatch. A 500-returning mock guards against the test silently falling through to a re-auth path instead of trusting the store. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../stack-auth/src/access_key_strategy.rs | 58 +++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/packages/stack-auth/src/access_key_strategy.rs b/packages/stack-auth/src/access_key_strategy.rs index 7dc935aa6..853bd0514 100644 --- a/packages/stack-auth/src/access_key_strategy.rs +++ b/packages/stack-auth/src/access_key_strategy.rs @@ -287,6 +287,64 @@ mod workspace_verification_tests { ); } + /// A pre-populated [`TokenStore`] returning a token for a *different* + /// workspace must still be rejected by the strategy's wrapper. This + /// is the cross-feature interaction the CRN parity work is designed + /// to protect — a shared cookie / KV cache between strategies bound + /// to different workspaces must never let a load from the store + /// bypass workspace verification. + /// + /// Drives the assertion without any HTTP traffic: a 500-returning + /// mock fails the test loudly if the strategy ever reaches the + /// authorise endpoint instead of trusting the store. + #[tokio::test] + async fn rejects_stored_token_for_different_workspace() { + const TOKEN_WS: &str = "AAAAAAAAAAAAAAAA"; + const CRN_WS: &str = "ZVATKW3VHMFG27DY"; + + let mut mocks = MockSet::new(); + mocks.mock(|when, then| { + when.post().path("/api/authorise"); + then.internal_server_error() + .json(serde_json::json!({"error": "store must satisfy the request"})); + }); + let server = + MockServer::new_http("access-key-strategy-store-mismatch-test").with_mocks(mocks); + #[allow(clippy::expect_used)] + server.start().await.expect("mock server start"); + + let now = std::time::SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system clock") + .as_secs(); + let stored = crate::Token { + access_token: crate::SecretToken::new(jwt_with_workspace(TOKEN_WS)), + token_type: "Bearer".to_string(), + expires_at: now + 3600, + refresh_token: None, + region: None, + client_id: None, + device_instance_id: None, + }; + let store = std::sync::Arc::new(crate::InMemoryTokenStore::new()); + store.save(&stored).await; + + let strategy = AccessKeyStrategy::builder(crn_with_workspace(CRN_WS), test_access_key()) + .base_url(server.url("")) + .with_token_store(std::sync::Arc::clone(&store)) + .build() + .expect("builder"); + + let err = (&strategy) + .get_token() + .await + .expect_err("expected mismatch from stored token"); + assert!( + matches!(err, AuthError::WorkspaceMismatch { .. }), + "expected WorkspaceMismatch, got {err:?}", + ); + } + /// Regression guard — the workspace check runs on *every* `get_token()` /// call, not only on the call that triggers initial authentication. /// A future optimisation that cached the "verified" result, or that From 1815e111049fd75fe369991f54470b1e27bad596 Mon Sep 17 00:00:00 2001 From: Lindsay Holmwood Date: Wed, 27 May 2026 21:35:19 +1000 Subject: [PATCH 253/686] test(stack-auth-node): cover AutoStrategy happy path with explicit CRN MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The existing auto_strategy_detect tests only cover error paths (missing CRN, invalid CRN). Add a test that passes an explicit access key and valid CRN through the napi options object and asserts the resulting AutoStrategy constructs successfully. Guards the napi → AutoStrategy::builder plumbing against silently dropping the CRN before detect(). Co-Authored-By: Claude Opus 4.7 (1M context) --- languages/typescript/packages/auth/src/lib.rs | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/languages/typescript/packages/auth/src/lib.rs b/languages/typescript/packages/auth/src/lib.rs index 7baa84537..eaaf56264 100644 --- a/languages/typescript/packages/auth/src/lib.rs +++ b/languages/typescript/packages/auth/src/lib.rs @@ -886,6 +886,28 @@ mod tests { assertions::has_error_code(&err, "INVALID_CRN"); } } + + /// Happy path: explicit access key + explicit valid CRN constructs + /// an `AutoStrategy` (specifically the `AccessKey` variant). The + /// existing tests only cover the error paths, so a regression in + /// the napi → `AutoStrategy::builder` plumbing (e.g. dropping the + /// CRN before `detect()`) would slide through. + mod given_valid_access_key_and_crn { + use super::*; + + #[test] + fn constructs_strategy_successfully() { + let result = AutoStrategy::detect(Some(AutoStrategyOptions { + access_key: Some("CSAKtestKeyId.testKeySecret".to_string()), + workspace_crn: Some("crn:ap-southeast-2.aws:ZVATKW3VHMFG27DY".to_string()), + })); + + assert!( + result.is_ok(), + "valid access key + CRN should construct an AutoStrategy", + ); + } + } } mod access_key_strategy_create { From 4396d7dac8d7fa6b3dce26fa62dea68a11ddb10b Mon Sep 17 00:00:00 2001 From: Lindsay Holmwood Date: Wed, 27 May 2026 21:35:49 +1000 Subject: [PATCH 254/686] test(stack-auth-node): cover AccessKeyStrategy.create happy path The existing access_key_strategy_create tests only asserted invalid input rejection. The wasm bindings have a corresponding access_key_strategy_accepts_valid_inputs test; add the napi equivalent so a future regression in the create factory (e.g. always returning an error) is caught at the binding seam. Co-Authored-By: Claude Opus 4.7 (1M context) --- languages/typescript/packages/auth/src/lib.rs | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/languages/typescript/packages/auth/src/lib.rs b/languages/typescript/packages/auth/src/lib.rs index eaaf56264..a1e3f1677 100644 --- a/languages/typescript/packages/auth/src/lib.rs +++ b/languages/typescript/packages/auth/src/lib.rs @@ -946,6 +946,29 @@ mod tests { } } + /// Happy path: valid CRN + valid access key constructs a strategy. + /// The wasm bindings have an equivalent test + /// (`access_key_strategy_accepts_valid_inputs`); the napi seam + /// needs the same guard so a future regression in + /// `AccessKeyStrategy::create` (e.g. always returning an error) is + /// caught. + mod given_valid_inputs { + use super::*; + + #[test] + fn constructs_strategy_successfully() { + let result = AccessKeyStrategy::create( + VALID_CRN.to_string(), + "CSAKtestKeyId.testKeySecret".to_string(), + ); + + assert!( + result.is_ok(), + "valid CRN + access key should construct an AccessKeyStrategy", + ); + } + } + /// End-to-end coverage that the `WorkspaceMismatch` error variant /// surfaces through the napi boundary as `WORKSPACE_MISMATCH` — /// the underlying Rust check is covered in From cafecc2cfd88da0396d1ae04c7b768cd28d7b361 Mon Sep 17 00:00:00 2001 From: Lindsay Holmwood Date: Wed, 27 May 2026 21:36:39 +1000 Subject: [PATCH 255/686] test(stack-auth): pin CRN-with-service_name behaviour on AccessKeyStrategy cts_common::Crn allows an optional service_name segment (e.g. crn:ap-southeast-2.aws:ZVATKW3VHMFG27DY:zerokms). AccessKeyStrategy::new currently extracts only the region and workspace_id, silently ignoring the service_name. Document that acceptance contract on the constructor and pin it with a test that drives a full get_token() roundtrip with such a CRN, so a future change tightening the constructor to reject these CRNs doesn't slip past review. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../stack-auth/src/access_key_strategy.rs | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/packages/stack-auth/src/access_key_strategy.rs b/packages/stack-auth/src/access_key_strategy.rs index 853bd0514..1ffcabd16 100644 --- a/packages/stack-auth/src/access_key_strategy.rs +++ b/packages/stack-auth/src/access_key_strategy.rs @@ -46,6 +46,11 @@ impl AccessKeyStrategy { /// Create a new `AccessKeyStrategy` for the given workspace CRN and /// access key. The auth endpoint is resolved automatically via service /// discovery using the region encoded in the CRN. + /// + /// A CRN with a `service_name` component (e.g. + /// `crn:ap-southeast-2.aws:ZVATKW3VHMFG27DY:zerokms`) is accepted; the + /// `service_name` is ignored. Only the region and workspace ID are + /// load-bearing for this strategy. pub fn new(workspace_crn: Crn, access_key: AccessKey) -> Result { Self::builder(workspace_crn, access_key).build() } @@ -287,6 +292,34 @@ mod workspace_verification_tests { ); } + /// A CRN carrying a `service_name` component is accepted; the + /// `service_name` is ignored. The strategy uses only the region (for + /// service discovery) and the workspace ID (for token verification). + /// Pinned as a test rather than left to implementation drift so that a + /// future contributor doesn't tighten the constructor into rejecting + /// these CRNs without realising the docstring already promises + /// acceptance. + #[tokio::test] + async fn accepts_crn_with_service_name() { + const WS: &str = "ZVATKW3VHMFG27DY"; + let server = start_mock_server_returning_jwt(WS).await; + let crn: Crn = format!("crn:ap-southeast-2.aws:{WS}:zerokms") + .parse() + .expect("CRN with service_name parses"); + + let strategy = AccessKeyStrategy::builder(crn, test_access_key()) + .base_url(server.url("")) + .build() + .expect("CRN with service_name should construct a strategy"); + + let token = (&strategy).get_token().await.expect("get_token"); + assert_eq!( + token.workspace_id().expect("workspace_id").as_str(), + WS, + "service_name is ignored — verification still uses the workspace ID", + ); + } + /// A pre-populated [`TokenStore`] returning a token for a *different* /// workspace must still be rejected by the strategy's wrapper. This /// is the cross-feature interaction the CRN parity work is designed From 1792c35925de172a54436551a7d46d3b61368f5a Mon Sep 17 00:00:00 2001 From: Lindsay Holmwood Date: Wed, 27 May 2026 21:37:10 +1000 Subject: [PATCH 256/686] docs(stack-auth): note InvalidToken alongside WorkspaceMismatch The AccessKeyStrategy docstring described only WorkspaceMismatch as the post-auth rejection mode. The verification step uses `?` on token.workspace_id(), so a JWT that's malformed or missing the workspace claim entirely propagates as InvalidToken instead. Document both outcomes so consumers know to handle either. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/stack-auth/src/access_key_strategy.rs | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/packages/stack-auth/src/access_key_strategy.rs b/packages/stack-auth/src/access_key_strategy.rs index 1ffcabd16..7849117da 100644 --- a/packages/stack-auth/src/access_key_strategy.rs +++ b/packages/stack-auth/src/access_key_strategy.rs @@ -17,10 +17,16 @@ use crate::{ensure_trailing_slash, AuthError, AuthStrategy, SecretToken, Service /// The first call to [`get_token`](AuthStrategy::get_token) authenticates /// with the server. Subsequent calls return the cached token until it /// expires, at which point re-authentication happens automatically. Every -/// returned token is checked: if the JWT's `workspace` claim does not match -/// the CRN's workspace ID, [`AuthError::WorkspaceMismatch`] is returned -/// rather than silently letting the caller operate on a different workspace -/// than they specified. +/// returned token is checked against the CRN; post-auth verification can +/// fail in two ways: +/// +/// - [`AuthError::WorkspaceMismatch`] — the JWT decoded cleanly but its +/// `workspace` claim doesn't match the CRN's workspace ID. +/// - [`AuthError::InvalidToken`] — the JWT is malformed or missing the +/// `workspace` claim entirely, so verification can't run. +/// +/// Either outcome is preferred over silently letting the caller operate on +/// a different workspace than they specified. /// /// When constructed via [`AccessKeyStrategyBuilder::with_token_store`], the /// strategy also persists tokens through an external [`TokenStore`] so that From 76b9c9b5bbf0fdd0b4cdbd73dbffbb12180c5cc7 Mon Sep 17 00:00:00 2001 From: Lindsay Holmwood Date: Wed, 27 May 2026 21:38:56 +1000 Subject: [PATCH 257/686] docs(stack-auth-node): add 0.39.0 changelog entry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The node-package CHANGELOG.md is hand-maintained; document the breaking AccessKeyStrategy.create signature change and the new WORKSPACE_MISMATCH error code so consumers upgrading from 0.38.x see the migration path and the new failure mode without having to read the PR. The Rust stack-auth CHANGELOG.md is git-cliff–generated from conventional commits, so the existing feat(stack-auth)! commit on this branch will populate it automatically — no hand-edit needed there. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../typescript/packages/auth/CHANGELOG.md | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/languages/typescript/packages/auth/CHANGELOG.md b/languages/typescript/packages/auth/CHANGELOG.md index afba5beba..33addc6c6 100644 --- a/languages/typescript/packages/auth/CHANGELOG.md +++ b/languages/typescript/packages/auth/CHANGELOG.md @@ -1,5 +1,40 @@ # Changelog +## 0.39.0 + +### Breaking Changes + +- **`AccessKeyStrategy.create(workspaceCrn, accessKey)`** — the first argument + is now a workspace CRN, not a region string. Region is derived from the CRN, + so callers can no longer accidentally configure a strategy whose region + disagrees with the workspace it was pointed at. + + ```ts + // Before (0.38.x) + const strategy = AccessKeyStrategy.create("ap-southeast-2.aws", "CSAKid.secret"); + + // After (0.39.0) + const strategy = AccessKeyStrategy.create( + "crn:ap-southeast-2.aws:ZVATKW3VHMFG27DY", + "CSAKid.secret", + ); + ``` + + The same change applies to the wasm-inline entry: `AccessKeyStrategy.create(workspaceCrn, accessKey, options?)`. + +- **Per-call workspace verification.** Every issued token's `workspace` JWT + claim is now checked against the CRN. If they differ (e.g. an access key + with rights on multiple workspaces was bound to the wrong CRN), `getToken()` + rejects with a new `WORKSPACE_MISMATCH` error code. Previously the + strategy silently let the caller operate on a different workspace than the + one they specified. + +### New Error Codes + +- `WORKSPACE_MISMATCH` — the JWT decoded cleanly but its `workspace` claim + doesn't match the CRN the strategy was configured with. The accompanying + message identifies both the expected and the token-supplied workspace IDs. + ## 0.35.0 ### New Features From 521fab0c633332a04003f9fedcaec0c1af8801fc Mon Sep 17 00:00:00 2001 From: Lindsay Holmwood Date: Wed, 27 May 2026 21:39:28 +1000 Subject: [PATCH 258/686] docs(stack-auth): refresh AutoStrategy detection-order comment The doc still said "the region is extracted from CS_WORKSPACE_CRN", which described the pre-CRN-parity model where only the region travelled through to AccessKeyStrategy. Post-change, the whole CRN is parsed and used for both service discovery (region) and per-token workspace verification (workspace ID). Update the wording so readers don't think CS_WORKSPACE_CRN is only consulted for its region. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/stack-auth/src/auto_strategy.rs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/stack-auth/src/auto_strategy.rs b/packages/stack-auth/src/auto_strategy.rs index f3a2e1fed..eefb15395 100644 --- a/packages/stack-auth/src/auto_strategy.rs +++ b/packages/stack-auth/src/auto_strategy.rs @@ -15,8 +15,11 @@ use crate::{AuthError, AuthStrategy, ServiceToken}; /// # Detection order /// /// 1. If the `CS_CLIENT_ACCESS_KEY` environment variable is set, an -/// [`AccessKeyStrategy`] is created. The region is extracted from the -/// `CS_WORKSPACE_CRN` environment variable. +/// [`AccessKeyStrategy`] is created. The workspace CRN is parsed from +/// `CS_WORKSPACE_CRN` (or the explicit +/// [`with_workspace_crn`](AutoStrategyBuilder::with_workspace_crn) value); +/// its region drives service discovery and its workspace ID is used +/// to verify every issued token. /// 2. If a token store file exists at the default location /// (`~/.cipherstash/auth.json`), an [`OAuthStrategy`] is created from it. /// 3. Otherwise, [`AuthError::NotAuthenticated`] is returned. From e350c971c6cfeb2291c11c1817b5ebac56f9d694 Mon Sep 17 00:00:00 2001 From: Lindsay Holmwood Date: Wed, 27 May 2026 21:40:03 +1000 Subject: [PATCH 259/686] docs(stack-auth): note CS_CTS_HOST override on AccessKeyStrategy::new MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit build() consults cts_base_url_from_env() before falling back to service discovery, so callers can point the strategy at a staging CTS or a local mock by setting CS_CTS_HOST — without changing the CRN. That behaviour predates this PR but had never been surfaced on the constructor docstring. Mention it now that the constructor is being documented anew. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/stack-auth/src/access_key_strategy.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/stack-auth/src/access_key_strategy.rs b/packages/stack-auth/src/access_key_strategy.rs index 7849117da..df29167f4 100644 --- a/packages/stack-auth/src/access_key_strategy.rs +++ b/packages/stack-auth/src/access_key_strategy.rs @@ -53,6 +53,10 @@ impl AccessKeyStrategy { /// access key. The auth endpoint is resolved automatically via service /// discovery using the region encoded in the CRN. /// + /// The `CS_CTS_HOST` environment variable, if set and non-empty, + /// overrides service discovery — useful for pointing the strategy at + /// a staging CTS or a local mock without changing the CRN. + /// /// A CRN with a `service_name` component (e.g. /// `crn:ap-southeast-2.aws:ZVATKW3VHMFG27DY:zerokms`) is accepted; the /// `service_name` is ignored. Only the region and workspace ID are From 3c5d62f87e65cb737bd5e8a2c016addb8f0714ac Mon Sep 17 00:00:00 2001 From: Lindsay Holmwood Date: Wed, 27 May 2026 21:41:06 +1000 Subject: [PATCH 260/686] docs(stack-auth-node): align AccessKeyStrategy.create JSDoc MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The wasm-inline.d.ts AccessKeyStrategy.create() JSDoc spelled out the CRN format, that region drives service discovery, that the workspace ID is what gets verified, and that mismatches surface as WORKSPACE_MISMATCH. The napi index.d.ts version only mentioned the CRN format. Bring the napi side up to the wasm-inline level so a consumer hovering over either entry point gets the same explanation of what verification will (and won't) catch. Also update the corresponding Rust napi doc comment so a future napi-rs regeneration produces the same TS — they had drifted out of sync. Co-Authored-By: Claude Opus 4.7 (1M context) --- languages/typescript/packages/auth/index.d.ts | 5 ++++- languages/typescript/packages/auth/src/lib.rs | 11 ++++++----- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/languages/typescript/packages/auth/index.d.ts b/languages/typescript/packages/auth/index.d.ts index b9ddcd46f..fd18c4bad 100644 --- a/languages/typescript/packages/auth/index.d.ts +++ b/languages/typescript/packages/auth/index.d.ts @@ -165,7 +165,10 @@ export declare class AccessKeyStrategy { * access key. * * The CRN format is `crn::` (e.g. - * `"crn:ap-southeast-2.aws:ZVATKW3VHMFG27DY"`). + * `"crn:ap-southeast-2.aws:ZVATKW3VHMFG27DY"`). Region is parsed from + * the CRN and used for service discovery; the workspace ID is used to + * verify every issued token belongs to the right workspace. A mismatch + * fails `getToken()` with `code === "WORKSPACE_MISMATCH"`. */ static create(workspaceCrn: string, accessKey: string): AccessKeyStrategy /** Retrieve a valid access token, refreshing or re-authenticating as needed. */ diff --git a/languages/typescript/packages/auth/src/lib.rs b/languages/typescript/packages/auth/src/lib.rs index a1e3f1677..a99c6cace 100644 --- a/languages/typescript/packages/auth/src/lib.rs +++ b/languages/typescript/packages/auth/src/lib.rs @@ -158,12 +158,13 @@ pub struct AccessKeyStrategy { #[napi] impl AccessKeyStrategy { /// Create a new `AccessKeyStrategy` for the given workspace CRN and - /// access key. Region is derived from the CRN — there's no separate - /// region argument — so the strategy can't be configured for one - /// workspace's region while the CRN says another. + /// access key. /// - /// Every issued token's workspace claim is verified against the CRN; - /// a mismatch fails the call with a `WORKSPACE_MISMATCH` error. + /// The CRN format is `crn::` (e.g. + /// `"crn:ap-southeast-2.aws:ZVATKW3VHMFG27DY"`). Region is parsed + /// from the CRN and used for service discovery; the workspace ID is + /// used to verify every issued token belongs to the right workspace. + /// A mismatch fails `getToken()` with `code === "WORKSPACE_MISMATCH"`. #[napi(factory)] pub fn create(workspace_crn: String, access_key: String) -> Result { let crn: cts_common::Crn = workspace_crn From 5c65bb7ec1b1f4d2bd961e4818a48c301ad3c950 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 28 May 2026 04:34:45 +0000 Subject: [PATCH 261/686] chore: release --- packages/stack-auth/CHANGELOG.md | 40 +++++++++++++++++++++++++++++ packages/stack-auth/Cargo.toml | 2 +- packages/stack-profile/CHANGELOG.md | 7 +++++ packages/stack-profile/Cargo.toml | 2 +- 4 files changed, 49 insertions(+), 2 deletions(-) diff --git a/packages/stack-auth/CHANGELOG.md b/packages/stack-auth/CHANGELOG.md index 859ac96a1..4b108014a 100644 --- a/packages/stack-auth/CHANGELOG.md +++ b/packages/stack-auth/CHANGELOG.md @@ -1,5 +1,45 @@ +### Documentation + +- note InvalidToken alongside WorkspaceMismatch +- add 0.39.0 changelog entry +- refresh AutoStrategy detection-order comment +- note CS_CTS_HOST override on AccessKeyStrategy::new +- align AccessKeyStrategy.create JSDoc + +### Features + +- AccessKeyStrategy takes workspaceCrn, verifies token + +### Fixes + +- clippy unnecessary clones + cipherstash-client test prelude +- address PR review + CI failures + +### Miscellaneous + +- bump @cipherstash/auth to 0.38.0 +- sync lockfile + README to 0.38.0 + +### Refactoring + +- relocate bounds to stack-auth, drop legacy ServiceToken + +### Testing + +- cover WORKSPACE_MISMATCH at FFI boundary +- verify workspace check runs on every get_token call +- reject stored token bound to wrong workspace +- cover AutoStrategy happy path with explicit CRN +- cover AccessKeyStrategy.create happy path +- pin CRN-with-service_name behaviour on AccessKeyStrategy + +### Style + +- apply rustfmt + + ### Documentation diff --git a/packages/stack-auth/Cargo.toml b/packages/stack-auth/Cargo.toml index c0b878068..260d232d7 100644 --- a/packages/stack-auth/Cargo.toml +++ b/packages/stack-auth/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "stack-auth" description = "Authentication library for CipherStash services" -version = "0.35.0" +version = "0.36.0" edition.workspace = true authors.workspace = true repository.workspace = true diff --git a/packages/stack-profile/CHANGELOG.md b/packages/stack-profile/CHANGELOG.md index da090f8de..0d675f9b8 100644 --- a/packages/stack-profile/CHANGELOG.md +++ b/packages/stack-profile/CHANGELOG.md @@ -20,6 +20,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 + +### Fixes + +- make ProfileStore writes atomic via tmp file + rename +- serialise refresh-token rotation across processes +- address Copilot review on Windows + crash durability + ## [0.34.0-alpha.1] - 2026-03-04 ### Changed diff --git a/packages/stack-profile/Cargo.toml b/packages/stack-profile/Cargo.toml index cbc63eb35..443882399 100644 --- a/packages/stack-profile/Cargo.toml +++ b/packages/stack-profile/Cargo.toml @@ -2,7 +2,7 @@ name = "stack-profile" description = "Centralised ~/.cipherstash profile file management" license-file = "LICENSE" -version = "0.35.0" +version = "0.36.0" edition.workspace = true authors.workspace = true repository.workspace = true From 8e0aeecfe28df1fe7ade1a56cb20f2ac4545f635 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Mon, 1 Jun 2026 10:32:18 +1000 Subject: [PATCH 262/686] =?UTF-8?q?feat(stack-auth):=20OidcFederationStrat?= =?UTF-8?q?egy=20=E2=80=94=20federate=20a=20third-party=20OIDC=20JWT=20int?= =?UTF-8?q?o=20a=20CTS=20service=20token?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `OidcFederationStrategy`, which exchanges a live third-party OIDC JWT (Clerk, Supabase, Auth0, …) for a CipherStash CTS service token via `POST /api/authorise` — no access key required. Unlike `OAuthStrategy` (which *renews* an existing CTS session via a CTS refresh token), this strategy *federates*: `/api/authorise` issues no CTS refresh token, so renewal re-invokes the `OidcProvider` for a current JWT and re-federates. Rust core only — the napi/wasm bindings land in a stacked follow-up PR. - `OidcFederationStrategy` + builder, generic over an `OidcProvider` and an optional `TokenStore`. - `OidcProvider` trait + `OidcProviderFn` closure adapter (wasm32 cfg-split). Named to mirror `cts_domain::oidc::OidcProvider` server-side. - `OidcRefresher` implements `Refresher` with `Credential = ()`. - Shared `authorize_dto::AuthoriseResponse` (adopted by access_key_refresher, was a private duplicate). - New `AuthError::InvalidWorkspaceId` variant. --- .../stack-auth/src/access_key_refresher.rs | 8 +- packages/stack-auth/src/authorize_dto.rs | 17 + packages/stack-auth/src/lib.rs | 12 +- .../src/oidc_federation_strategy.rs | 152 ++++++ packages/stack-auth/src/oidc_refresher.rs | 481 ++++++++++++++++++ 5 files changed, 662 insertions(+), 8 deletions(-) create mode 100644 packages/stack-auth/src/authorize_dto.rs create mode 100644 packages/stack-auth/src/oidc_federation_strategy.rs create mode 100644 packages/stack-auth/src/oidc_refresher.rs diff --git a/packages/stack-auth/src/access_key_refresher.rs b/packages/stack-auth/src/access_key_refresher.rs index 637130f2a..a09a3e202 100644 --- a/packages/stack-auth/src/access_key_refresher.rs +++ b/packages/stack-auth/src/access_key_refresher.rs @@ -4,6 +4,7 @@ use web_time::{SystemTime, UNIX_EPOCH}; use url::Url; +use crate::authorize_dto::AuthoriseResponse; use crate::refresher::Refresher; use crate::{http_client, AuthError, SecretToken, Token}; @@ -93,13 +94,6 @@ struct AuthoriseRequest<'a> { audience: Option<&'a str>, } -#[derive(serde::Deserialize)] -#[serde(rename_all = "camelCase")] -struct AuthoriseResponse { - access_token: SecretToken, - expiry: u64, -} - #[cfg(test)] mod tests { use super::*; diff --git a/packages/stack-auth/src/authorize_dto.rs b/packages/stack-auth/src/authorize_dto.rs new file mode 100644 index 000000000..b8e936138 --- /dev/null +++ b/packages/stack-auth/src/authorize_dto.rs @@ -0,0 +1,17 @@ +//! Shared DTO for the CTS `POST /api/authorise` endpoint. +//! +//! Both [`AccessKeyRefresher`](crate::access_key_refresher) and +//! [`OidcRefresher`](crate::oidc_refresher) exchange a credential for a CTS +//! service token at the same endpoint; the success response is identical, so +//! the wire contract lives here in one place. The request bodies differ +//! (different credential fields) and stay private to each refresher. + +use crate::SecretToken; + +/// Success response from `POST /api/authorise`. +#[derive(serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct AuthoriseResponse { + pub(crate) access_token: SecretToken, + pub(crate) expiry: u64, +} diff --git a/packages/stack-auth/src/lib.rs b/packages/stack-auth/src/lib.rs index 9f3860d23..c19e0f6fe 100644 --- a/packages/stack-auth/src/lib.rs +++ b/packages/stack-auth/src/lib.rs @@ -34,10 +34,13 @@ mod access_key; mod access_key_refresher; mod access_key_strategy; mod auth_strategy_fn; +mod authorize_dto; mod auto_refresh; mod auto_strategy; mod oauth_refresher; mod oauth_strategy; +mod oidc_federation_strategy; +mod oidc_refresher; mod refresher; mod service_token; mod token; @@ -60,6 +63,8 @@ pub use access_key_strategy::{AccessKeyStrategy, AccessKeyStrategyBuilder}; pub use auth_strategy_fn::AuthStrategyFn; pub use auto_strategy::{AutoStrategy, AutoStrategyBuilder}; pub use oauth_strategy::{OAuthStrategy, OAuthStrategyBuilder}; +pub use oidc_federation_strategy::{OidcFederationStrategy, OidcFederationStrategyBuilder}; +pub use oidc_refresher::{OidcProvider, OidcProviderFn}; pub use service_token::ServiceToken; #[cfg(any(test, feature = "test-utils"))] pub use static_token_strategy::StaticTokenStrategy; @@ -90,7 +95,8 @@ pub mod auth { pub use crate::{ AccessKey, AccessKeyStrategy, AccessKeyStrategyBuilder, AuthError, AuthStrategy, AuthStrategyBounds, AuthStrategyFn, AutoStrategy, AutoStrategyBuilder, InvalidAccessKey, - OAuthStrategy, OAuthStrategyBuilder, SecretToken, ServiceToken, + OAuthStrategy, OAuthStrategyBuilder, OidcFederationStrategy, OidcFederationStrategyBuilder, + OidcProvider, OidcProviderFn, SecretToken, ServiceToken, }; #[cfg(not(target_arch = "wasm32"))] @@ -302,6 +308,9 @@ pub enum AuthError { /// The workspace the auth server's token actually carries. token_workspace: cts_common::WorkspaceId, }, + /// The workspace ID could not be parsed. + #[error("Invalid workspace ID: {0}")] + InvalidWorkspaceId(#[from] cts_common::InvalidWorkspaceId), /// An access key was provided but the workspace CRN is missing. /// /// Set the `CS_WORKSPACE_CRN` environment variable or call @@ -350,6 +359,7 @@ impl AuthError { Self::InvalidAccessKey(_) => "INVALID_ACCESS_KEY", Self::InvalidCrn(_) => "INVALID_CRN", Self::WorkspaceMismatch { .. } => "WORKSPACE_MISMATCH", + Self::InvalidWorkspaceId(_) => "INVALID_WORKSPACE_ID", #[cfg(not(target_arch = "wasm32"))] Self::Store(_) => "STORE_ERROR", } diff --git a/packages/stack-auth/src/oidc_federation_strategy.rs b/packages/stack-auth/src/oidc_federation_strategy.rs new file mode 100644 index 000000000..1c0dfa5bf --- /dev/null +++ b/packages/stack-auth/src/oidc_federation_strategy.rs @@ -0,0 +1,152 @@ +use cts_common::{CtsServiceDiscovery, Region, ServiceDiscovery, WorkspaceId}; + +use crate::auto_refresh::AutoRefresh; +use crate::oidc_refresher::{OidcProvider, OidcRefresher}; +use crate::token_store::{NoStore, TokenStore}; +use crate::{ensure_trailing_slash, AuthError, AuthStrategy, ServiceToken}; + +/// An [`AuthStrategy`] that federates a third-party OIDC JWT (Clerk, Supabase, +/// Auth0, …) into a CipherStash CTS service token via `POST /api/authorise`. +/// +/// Each call to [`get_token`](AuthStrategy::get_token) returns a cached CTS +/// token until it expires. Because `/api/authorise` issues no CTS refresh +/// token, renewal means *re-federating*: the strategy calls the +/// [`OidcProvider`] again for a current third-party JWT and exchanges it for a +/// fresh CTS token. Supply an `OidcProvider` that returns the live provider +/// token each time (e.g. wrapping `clerk.session.getToken()`). +/// +/// When constructed via [`OidcFederationStrategyBuilder::with_token_store`], the strategy +/// also persists tokens through an external [`TokenStore`] so short-lived +/// instances (e.g. one per Edge Function request) can share a cache and skip +/// re-federating on every cold start. +/// +/// # Example +/// +/// ```no_run +/// use stack_auth::{AuthError, OidcProviderFn, OidcFederationStrategy, SecretToken}; +/// use cts_common::{Region, WorkspaceId}; +/// +/// let region = Region::aws("ap-southeast-2").unwrap(); +/// let workspace_id: WorkspaceId = "ZVATKW3VHMFG27DY".parse().unwrap(); +/// let provider = OidcProviderFn::new(|| async { +/// // Real consumers call into a provider SDK / FFI to fetch a live JWT. +/// Ok::<_, AuthError>(SecretToken::new("header.payload.signature".to_string())) +/// }); +/// let strategy = OidcFederationStrategy::new(region, workspace_id, provider).unwrap(); +/// ``` +pub struct OidcFederationStrategy { + inner: AutoRefresh, S>, +} + +impl OidcFederationStrategy

{ + /// Create a new `OidcFederationStrategy` for the given region, workspace, and + /// OIDC provider. + /// + /// The auth endpoint is resolved automatically via service discovery. + pub fn new( + region: Region, + workspace_id: WorkspaceId, + oidc_provider: P, + ) -> Result { + Self::builder(region, workspace_id, oidc_provider).build() + } + + /// Return a builder for configuring an `OidcFederationStrategy` before construction. + pub fn builder( + region: Region, + workspace_id: WorkspaceId, + oidc_provider: P, + ) -> OidcFederationStrategyBuilder

{ + OidcFederationStrategyBuilder { + region, + workspace_id, + oidc_provider, + audience: None, + base_url_override: None, + token_store: NoStore, + } + } +} + +impl AuthStrategy for &OidcFederationStrategy { + async fn get_token(self) -> Result { + Ok(self.inner.get_token().await?) + } +} + +/// Builder for [`OidcFederationStrategy`]. +/// +/// Created via [`OidcFederationStrategy::builder`]. +pub struct OidcFederationStrategyBuilder { + region: Region, + workspace_id: WorkspaceId, + oidc_provider: P, + audience: Option, + base_url_override: Option, + token_store: S, +} + +impl OidcFederationStrategyBuilder { + /// Set the audience for token requests. + /// + /// Defaults to the workspace host FQDN server-side when unset. + pub fn audience(mut self, audience: impl Into) -> Self { + self.audience = Some(audience.into()); + self + } + + /// Override the base URL resolved by service discovery. + /// + /// Useful for pointing at a local or mock auth server during testing. + #[cfg(any(test, feature = "test-utils"))] + pub fn base_url(mut self, url: url::Url) -> Self { + self.base_url_override = Some(url); + self + } + + /// Wire an external [`TokenStore`] into the strategy. + /// + /// On every call to [`get_token`](AuthStrategy::get_token), if no token is + /// cached in memory, the store is consulted before falling back to + /// re-federating. After every successful federation the new token is + /// written back to the store. Use this from short-lived strategy instances + /// (Edge Functions, Workers) to share a service-token cache across + /// processes — e.g. an HTTP-only cookie. + /// + /// Returns a new builder with the store type erased into the chain — see + /// [`InMemoryTokenStore`](crate::InMemoryTokenStore) and + /// [`TokenStoreFn`](crate::TokenStoreFn) for ready-made implementations. + pub fn with_token_store(self, store: T) -> OidcFederationStrategyBuilder { + OidcFederationStrategyBuilder { + region: self.region, + workspace_id: self.workspace_id, + oidc_provider: self.oidc_provider, + audience: self.audience, + base_url_override: self.base_url_override, + token_store: store, + } + } +} + +impl OidcFederationStrategyBuilder { + /// Build the [`OidcFederationStrategy`]. + /// + /// Resolves the base URL via service discovery unless overridden with + /// `base_url` (available when the `test-utils` feature is enabled). + pub fn build(self) -> Result, AuthError> { + let base_url = match self.base_url_override { + Some(url) => url, + None => crate::cts_base_url_from_env()? + .unwrap_or(CtsServiceDiscovery::endpoint(self.region)?), + }; + let refresher = OidcRefresher::new( + self.oidc_provider, + self.workspace_id, + ensure_trailing_slash(base_url), + self.audience, + ); + Ok(OidcFederationStrategy { + inner: AutoRefresh::with_store(refresher, self.token_store), + }) + } +} diff --git a/packages/stack-auth/src/oidc_refresher.rs b/packages/stack-auth/src/oidc_refresher.rs new file mode 100644 index 000000000..b2a6cfe32 --- /dev/null +++ b/packages/stack-auth/src/oidc_refresher.rs @@ -0,0 +1,481 @@ +use std::future::Future; +use std::sync::Arc; + +use cts_common::WorkspaceId; +use url::Url; +use web_time::{SystemTime, UNIX_EPOCH}; + +use crate::authorize_dto::AuthoriseResponse; +use crate::refresher::Refresher; +use crate::{http_client, AuthError, SecretToken, Token}; + +/// Asynchronously supplies the *current* third-party OIDC JWT to federate. +/// +/// [`OidcFederationStrategy`](crate::OidcFederationStrategy) re-invokes this on every refresh: +/// `/api/authorise` issues no CTS refresh token, so renewing an expired CTS +/// token means re-federating with a fresh provider JWT. Implementations +/// typically wrap a provider SDK call (`clerk.session.getToken()`, +/// `supabase.auth.getSession()`), an FFI callback, or a test double. +/// +/// On native targets the trait carries `Send + Sync` bounds so the provider +/// can be driven from `tokio::spawn` background work. On wasm32 the bounds +/// are dropped — reqwest's fetch-backed futures are not `Send` and edge +/// runtimes are single-threaded anyway. +#[cfg(not(target_arch = "wasm32"))] +pub trait OidcProvider: Send + Sync { + /// Fetch the current third-party OIDC JWT to federate. + fn fetch(&self) -> impl Future> + Send; +} + +/// Wasm32 variant of [`OidcProvider`] — drops the `Send + Sync` bounds. +#[cfg(target_arch = "wasm32")] +pub trait OidcProvider { + /// Fetch the current third-party OIDC JWT to federate. + fn fetch(&self) -> impl Future>; +} + +/// [`OidcProvider`] backed by a user-supplied async closure. +/// +/// The closure fires on every federation — initial auth and every +/// re-federation after expiry — so it should return the *current* JWT each +/// time (e.g. by calling into a provider SDK that refreshes short-lived +/// tokens itself), not a value captured once. +/// +/// # Example +/// +/// ```no_run +/// use stack_auth::{AuthError, OidcProviderFn, SecretToken}; +/// +/// let provider = OidcProviderFn::new(|| async { +/// // Real consumers call into a provider SDK / FFI / IPC. +/// Ok::<_, AuthError>(SecretToken::new("header.payload.signature".to_string())) +/// }); +/// ``` +pub struct OidcProviderFn { + fetch: F, +} + +impl OidcProviderFn { + /// Build an `OidcProviderFn` from an async closure returning the current JWT. + pub fn new(fetch: F) -> Self { + Self { fetch } + } +} + +#[cfg(not(target_arch = "wasm32"))] +impl OidcProvider for OidcProviderFn +where + F: Fn() -> Fut + Send + Sync, + Fut: Future> + Send, +{ + fn fetch(&self) -> impl Future> + Send { + (self.fetch)() + } +} + +#[cfg(target_arch = "wasm32")] +impl OidcProvider for OidcProviderFn +where + F: Fn() -> Fut, + Fut: Future>, +{ + fn fetch(&self) -> impl Future> { + (self.fetch)() + } +} + +/// A [`Refresher`] that federates a third-party OIDC JWT into a CTS service +/// token via `POST /api/authorise`. +/// +/// Federation is stateless: the credential is always available (the +/// [`OidcProvider`] is re-callable), so `try_credential` returns `Some(())` and +/// `restore` is a no-op — exactly like +/// [`AccessKeyRefresher`](crate::access_key_refresher). `/api/authorise` +/// issues no CTS refresh token, so `AutoRefresh` renews an expired token by +/// calling `refresh` again, which re-invokes the `OidcProvider` for a current +/// JWT. +pub(crate) struct OidcRefresher

{ + oidc_provider: P, + workspace_id: WorkspaceId, + base_url: Url, + audience: Option, + http_client: Arc, +} + +impl

OidcRefresher

{ + pub(crate) fn new( + oidc_provider: P, + workspace_id: WorkspaceId, + base_url: Url, + audience: Option, + ) -> Self { + Self { + oidc_provider, + workspace_id, + base_url, + audience, + http_client: Arc::new(http_client()), + } + } +} + +impl Refresher for OidcRefresher

{ + type Credential = (); + + fn save(&self, _token: &Token) { + // Federated tokens are ephemeral — no per-refresher persistence. + } + + fn try_credential(&self, _token: Option<&mut Token>) -> Option { + // The OIDC provider is always re-callable, so federation can always be + // attempted — including on cold start (initial auth). + Some(()) + } + + fn restore(&self, _token: &mut Token, _credential: Self::Credential) { + // Nothing to restore — the OIDC provider is re-callable. + } + + async fn refresh(&self, _credential: &Self::Credential) -> Result { + let oidc_token = self.oidc_provider.fetch().await?; + + let url = self.base_url.join("api/authorise")?; + tracing::debug!(url = %url, "federating OIDC token"); + + let resp = self + .http_client + .post(url) + .json(&OidcAuthoriseRequest { + oidc_token: oidc_token.as_str(), + workspace_id: self.workspace_id.as_str(), + audience: self.audience.as_deref(), + }) + .send() + .await?; + + if !resp.status().is_success() { + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + tracing::debug!(%status, %body, "OIDC federation failed"); + return Err(AuthError::Server(format!("{status}: {body}"))); + } + + let auth_resp: AuthoriseResponse = resp.json().await?; + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + + Ok(Token { + access_token: auth_resp.access_token, + token_type: "Bearer".to_string(), + expires_at: now + auth_resp.expiry, + refresh_token: None, + region: None, + client_id: None, + device_instance_id: None, + }) + } +} + +#[derive(serde::Serialize)] +#[serde(rename_all = "camelCase")] +struct OidcAuthoriseRequest<'a> { + oidc_token: &'a str, + workspace_id: &'a str, + #[serde(skip_serializing_if = "Option::is_none")] + audience: Option<&'a str>, +} + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod tests { + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::sync::Arc; + use std::time::{SystemTime, UNIX_EPOCH}; + + use mocktail::prelude::*; + + use super::*; + use crate::auto_refresh::{AutoRefresh, AutoRefreshError}; + use crate::TokenStore; + + const WORKSPACE_ID: &str = "ZVATKW3VHMFG27DY"; + + fn workspace_id() -> WorkspaceId { + WORKSPACE_ID.parse().unwrap() + } + + fn auth_response_json(access: &str, expiry: u64) -> serde_json::Value { + serde_json::json!({ "accessToken": access, "expiry": expiry }) + } + + async fn start_server(mocks: MockSet) -> MockServer { + let server = MockServer::new_http("oidc-refresher-test").with_mocks(mocks); + server.start().await.unwrap(); + server + } + + /// A [`OidcProvider`] test double that counts invocations and returns a + /// distinct JWT each call (`jwt-0`, `jwt-1`, …). + fn counting_provider() -> (Arc, impl OidcProvider) { + let calls = Arc::new(AtomicUsize::new(0)); + let calls_clone = Arc::clone(&calls); + let provider = OidcProviderFn::new(move || { + let calls = Arc::clone(&calls_clone); + async move { + let n = calls.fetch_add(1, Ordering::SeqCst); + Ok(SecretToken::new(format!("jwt-{n}"))) + } + }); + (calls, provider) + } + + fn make_strategy( + server: &MockServer, + provider: P, + ) -> AutoRefresh> { + let refresher = OidcRefresher::new(provider, workspace_id(), server.url(""), None); + AutoRefresh::with_store(refresher, crate::NoStore) + } + + fn make_token(access: &str, expires_in_secs: u64) -> Token { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs(); + Token { + access_token: SecretToken::new(access), + token_type: "Bearer".to_string(), + expires_at: now + expires_in_secs, + refresh_token: None, + region: None, + client_id: None, + device_instance_id: None, + } + } + + #[tokio::test] + async fn test_initial_federation() { + let mut mocks = MockSet::new(); + mocks.mock(|when, then| { + when.post().path("/api/authorise"); + then.json(auth_response_json("cts-token", 3600)); + }); + let server = start_server(mocks).await; + let (calls, provider) = counting_provider(); + let strategy = make_strategy(&server, provider); + + let token = strategy.get_token().await.unwrap(); + + assert_eq!(token.as_str(), "cts-token"); + assert_eq!( + calls.load(Ordering::SeqCst), + 1, + "initial federation should invoke the OIDC provider once" + ); + } + + #[test] + fn test_request_serialization_omits_audience_when_unset() { + let body = serde_json::to_value(OidcAuthoriseRequest { + oidc_token: "the-jwt", + workspace_id: WORKSPACE_ID, + audience: None, + }) + .unwrap(); + assert_eq!( + body, + serde_json::json!({ "oidcToken": "the-jwt", "workspaceId": WORKSPACE_ID }), + "audience must be omitted from the request body when not set" + ); + } + + #[test] + fn test_request_serialization_includes_audience_when_set() { + let body = serde_json::to_value(OidcAuthoriseRequest { + oidc_token: "the-jwt", + workspace_id: WORKSPACE_ID, + audience: Some("my-audience"), + }) + .unwrap(); + assert_eq!( + body, + serde_json::json!({ + "oidcToken": "the-jwt", + "workspaceId": WORKSPACE_ID, + "audience": "my-audience", + }), + ); + } + + #[tokio::test] + async fn test_audience_set_still_federates() { + let mut mocks = MockSet::new(); + mocks.mock(|when, then| { + when.post().path("/api/authorise"); + then.json(auth_response_json("cts-token", 3600)); + }); + let server = start_server(mocks).await; + let (_calls, provider) = counting_provider(); + let refresher = OidcRefresher::new( + provider, + workspace_id(), + server.url(""), + Some("my-audience".to_string()), + ); + let strategy = AutoRefresh::with_store(refresher, crate::NoStore); + + let token = strategy.get_token().await.unwrap(); + assert_eq!(token.as_str(), "cts-token"); + } + + #[tokio::test] + async fn test_caches_token_after_initial_federation() { + let mut mocks = MockSet::new(); + mocks.mock(|when, then| { + when.post().path("/api/authorise"); + then.json(auth_response_json("cts-token", 3600)); + }); + let server = start_server(mocks).await; + let (calls, provider) = counting_provider(); + let strategy = make_strategy(&server, provider); + + assert_eq!(strategy.get_token().await.unwrap().as_str(), "cts-token"); + + // Replace the mock so a second federation call would fail loudly. + server.mocks().clear(); + server.mocks().mock(|when, then| { + when.post().path("/api/authorise"); + then.internal_server_error() + .json(serde_json::json!({"error": "should not be called"})); + }); + + assert_eq!(strategy.get_token().await.unwrap().as_str(), "cts-token"); + assert_eq!( + calls.load(Ordering::SeqCst), + 1, + "cached token should be returned without re-federating" + ); + } + + #[tokio::test] + async fn test_re_federates_on_expiry() { + let mut mocks = MockSet::new(); + mocks.mock(|when, then| { + when.post().path("/api/authorise"); + then.json(auth_response_json("re-federated-token", 3600)); + }); + let server = start_server(mocks).await; + + let (calls, provider) = counting_provider(); + // Pre-seed the store with an already-expired token. + let store = Arc::new(crate::InMemoryTokenStore::new()); + store.save(&make_token("stale-cts-token", 0)).await; + + let refresher = OidcRefresher::new(provider, workspace_id(), server.url(""), None); + let strategy = AutoRefresh::with_store(refresher, Arc::clone(&store)); + + let token = strategy.get_token().await.unwrap(); + assert_eq!( + token.as_str(), + "re-federated-token", + "expired cached token should trigger re-federation" + ); + assert_eq!( + calls.load(Ordering::SeqCst), + 1, + "re-federation should invoke the OIDC provider for a current JWT" + ); + } + + #[tokio::test] + async fn test_oidc_provider_failure_propagates() { + let mut mocks = MockSet::new(); + mocks.mock(|when, then| { + when.post().path("/api/authorise"); + then.json(auth_response_json("unreachable", 3600)); + }); + let server = start_server(mocks).await; + + let provider = OidcProviderFn::new(|| async { + Err::(AuthError::Server("provider exploded".to_string())) + }); + let strategy = make_strategy(&server, provider); + + let err = strategy.get_token().await.unwrap_err(); + assert!( + matches!(err, AutoRefreshError::Auth(AuthError::Server(_))), + "OIDC provider failure should surface as an auth error, got: {err:?}" + ); + } + + #[tokio::test] + async fn test_server_rejection_propagates() { + let mut mocks = MockSet::new(); + mocks.mock(|when, then| { + when.post().path("/api/authorise"); + then.internal_server_error() + .json(serde_json::json!({"error": "workspace mismatch"})); + }); + let server = start_server(mocks).await; + let (_calls, provider) = counting_provider(); + let strategy = make_strategy(&server, provider); + + let err = strategy.get_token().await.unwrap_err(); + assert!( + matches!(err, AutoRefreshError::Auth(AuthError::Server(_))), + "a 500 from /api/authorise should surface as a server error, got: {err:?}" + ); + } + + #[tokio::test] + async fn test_loads_token_from_store_on_cold_start_no_http() { + let mut mocks = MockSet::new(); + mocks.mock(|when, then| { + when.post().path("/api/authorise"); + then.internal_server_error() + .json(serde_json::json!({"error": "should not be called"})); + }); + let server = start_server(mocks).await; + + let store = Arc::new(crate::InMemoryTokenStore::new()); + store.save(&make_token("from-store", 3600)).await; + + let (calls, provider) = counting_provider(); + let refresher = OidcRefresher::new(provider, workspace_id(), server.url(""), None); + let strategy = AutoRefresh::with_store(refresher, Arc::clone(&store)); + + let token = strategy.get_token().await.unwrap(); + assert_eq!(token.as_str(), "from-store"); + assert_eq!( + calls.load(Ordering::SeqCst), + 0, + "a fresh cached token should be used without invoking the OIDC provider" + ); + } + + #[tokio::test] + async fn test_persists_token_to_store_after_federation() { + let mut mocks = MockSet::new(); + mocks.mock(|when, then| { + when.post().path("/api/authorise"); + then.json(auth_response_json("freshly-federated", 3600)); + }); + let server = start_server(mocks).await; + + let store = Arc::new(crate::InMemoryTokenStore::new()); + let (_calls, provider) = counting_provider(); + let refresher = OidcRefresher::new(provider, workspace_id(), server.url(""), None); + let strategy = AutoRefresh::with_store(refresher, Arc::clone(&store)); + + let token = strategy.get_token().await.unwrap(); + assert_eq!(token.as_str(), "freshly-federated"); + + let saved = store + .load() + .await + .expect("store should hold a token after federation"); + assert_eq!(saved.access_token().as_str(), "freshly-federated"); + } +} From ec7bd62a1ab290a5e3f7d0ab3dfa60d6eb467fa4 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Mon, 1 Jun 2026 12:15:55 +1000 Subject: [PATCH 263/686] feat(stack-auth): verify federated token's workspace in OidcFederationStrategy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `OidcFederationStrategy::get_token` now checks the `workspace` claim of the CTS token returned by `/api/authorise` against the workspace the strategy was configured for, returning `AuthError::WorkspaceMismatch` on mismatch — the same post-auth verification `AccessKeyStrategy` gained in cipherstash/cipherstash-suite#1995. Without this, a federation that minted a token for a different workspace (e.g. an OIDC provider authenticated for the wrong workspace, or a poisoned token loaded from a shared cookie/KV store) would be handed back unverified. The check lives in the strategy's `get_token` wrapper, so it runs on every call — freshly federated, in-memory-cached, and store-loaded tokens alike — mirroring `AccessKeyStrategy`. Tests (negative-tested by removing the gate and confirming each fails): - workspace match → token returned - workspace mismatch → WorkspaceMismatch - malformed CTS token → InvalidToken (verification can't run) - stored token for a different workspace → rejected (poisoned-cache guard) - mismatch re-checked on every get_token call (no cached verdict) --- .../src/oidc_federation_strategy.rs | 270 +++++++++++++++++- 1 file changed, 268 insertions(+), 2 deletions(-) diff --git a/packages/stack-auth/src/oidc_federation_strategy.rs b/packages/stack-auth/src/oidc_federation_strategy.rs index 1c0dfa5bf..5fccab2a4 100644 --- a/packages/stack-auth/src/oidc_federation_strategy.rs +++ b/packages/stack-auth/src/oidc_federation_strategy.rs @@ -15,10 +15,22 @@ use crate::{ensure_trailing_slash, AuthError, AuthStrategy, ServiceToken}; /// fresh CTS token. Supply an `OidcProvider` that returns the live provider /// token each time (e.g. wrapping `clerk.session.getToken()`). /// +/// Every returned token is checked against the configured workspace — the +/// same post-auth verification [`AccessKeyStrategy`](crate::AccessKeyStrategy) +/// performs — so a token CTS minted for a different workspace (or one loaded +/// from a poisoned shared cache) is never handed back. Verification can fail +/// in two ways: +/// +/// - [`AuthError::WorkspaceMismatch`] — the JWT decoded cleanly but its +/// `workspace` claim doesn't match the configured workspace ID. +/// - [`AuthError::InvalidToken`] — the JWT is malformed or missing the +/// `workspace` claim entirely, so verification can't run. +/// /// When constructed via [`OidcFederationStrategyBuilder::with_token_store`], the strategy /// also persists tokens through an external [`TokenStore`] so short-lived /// instances (e.g. one per Edge Function request) can share a cache and skip -/// re-federating on every cold start. +/// re-federating on every cold start. The workspace check runs on cached and +/// store-loaded tokens too, not just freshly federated ones. /// /// # Example /// @@ -36,6 +48,7 @@ use crate::{ensure_trailing_slash, AuthError, AuthStrategy, ServiceToken}; /// ``` pub struct OidcFederationStrategy { inner: AutoRefresh, S>, + expected_workspace: WorkspaceId, } impl OidcFederationStrategy

{ @@ -70,7 +83,15 @@ impl OidcFederationStrategy

{ impl AuthStrategy for &OidcFederationStrategy { async fn get_token(self) -> Result { - Ok(self.inner.get_token().await?) + let token: ServiceToken = self.inner.get_token().await?; + let token_workspace = *token.workspace_id()?; + if token_workspace != self.expected_workspace { + return Err(AuthError::WorkspaceMismatch { + expected_workspace: self.expected_workspace, + token_workspace, + }); + } + Ok(token) } } @@ -139,6 +160,7 @@ impl OidcFederationStrategyBuilder { None => crate::cts_base_url_from_env()? .unwrap_or(CtsServiceDiscovery::endpoint(self.region)?), }; + let expected_workspace = self.workspace_id; let refresher = OidcRefresher::new( self.oidc_provider, self.workspace_id, @@ -147,6 +169,250 @@ impl OidcFederationStrategyBuilder { ); Ok(OidcFederationStrategy { inner: AutoRefresh::with_store(refresher, self.token_store), + expected_workspace, + }) + } +} + +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] +mod tests { + use std::sync::Arc; + use std::time::{SystemTime, UNIX_EPOCH}; + + use cts_common::Region; + use mocktail::prelude::*; + + use super::*; + use crate::oidc_refresher::OidcProviderFn; + use crate::{InMemoryTokenStore, SecretToken, Token, TokenStore}; + + /// Mint an unsigned JWT carrying the given `workspace` claim. The strategy + /// decodes claims without verifying the signature (it already holds the + /// token), so an unsigned token is sufficient to exercise verification. + fn jwt_with_workspace(workspace: &str) -> String { + use jsonwebtoken::{encode, EncodingKey, Header}; + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system clock") + .as_secs(); + let claims = serde_json::json!({ + "iss": "https://cts.example.com/", + "sub": "CS|test-user", + "aud": "test-audience", + "iat": now, + "exp": now + 3600, + "workspace": workspace, + "scope": "", + }); + encode( + &Header::default(), + &claims, + &EncodingKey::from_secret(b"test-secret"), + ) + .expect("JWT encode") + } + + /// A mock CTS that federates any OIDC token into a CTS token carrying the + /// given `workspace` claim. + async fn start_mock_server_returning_jwt(workspace: &str) -> MockServer { + let mut mocks = MockSet::new(); + let jwt = jwt_with_workspace(workspace); + mocks.mock(move |when, then| { + when.post().path("/api/authorise"); + then.json(serde_json::json!({ "accessToken": jwt, "expiry": 3600 })); + }); + let server = + MockServer::new_http("oidc-federation-strategy-workspace-test").with_mocks(mocks); + server.start().await.expect("mock server start"); + server + } + + fn test_region() -> Region { + Region::aws("ap-southeast-2").expect("region parses") + } + + fn provider() -> OidcProviderFn std::future::Ready>> + { + OidcProviderFn::new(|| { + std::future::ready(Ok(SecretToken::new("header.payload.signature".to_string()))) }) } + + /// Happy path — the federated token's `workspace` claim matches the + /// configured workspace: `get_token()` returns the token cleanly. + #[tokio::test] + async fn returns_token_when_workspace_matches() { + const WS: &str = "ZVATKW3VHMFG27DY"; + let server = start_mock_server_returning_jwt(WS).await; + + let strategy = + OidcFederationStrategy::builder(test_region(), WS.parse().unwrap(), provider()) + .base_url(server.url("")) + .build() + .expect("builder"); + + let token = (&strategy).get_token().await.expect("get_token"); + assert_eq!( + token.workspace_id().expect("workspace_id").as_str(), + WS, + "happy-path token should carry the expected workspace", + ); + } + + /// Mismatch — CTS federates the OIDC token into a CTS token for a + /// *different* workspace than the strategy was configured for. This is the + /// security-critical case: the OIDC provider could be authenticated for a + /// workspace the caller didn't intend. `get_token()` must return + /// `WorkspaceMismatch`, not the token. + #[tokio::test] + async fn errors_when_token_workspace_differs() { + const TOKEN_WS: &str = "AAAAAAAAAAAAAAAA"; + const EXPECTED_WS: &str = "ZVATKW3VHMFG27DY"; + let server = start_mock_server_returning_jwt(TOKEN_WS).await; + + let strategy = OidcFederationStrategy::builder( + test_region(), + EXPECTED_WS.parse().unwrap(), + provider(), + ) + .base_url(server.url("")) + .build() + .expect("builder"); + + let err = (&strategy) + .get_token() + .await + .expect_err("expected mismatch"); + match err { + AuthError::WorkspaceMismatch { + expected_workspace, + token_workspace, + } => { + assert_eq!(expected_workspace.as_str(), EXPECTED_WS); + assert_eq!(token_workspace.as_str(), TOKEN_WS); + } + other => panic!("expected WorkspaceMismatch, got {other:?}"), + } + } + + /// A malformed CTS token (not a JWT) can't be decoded, so verification + /// can't run — `get_token()` surfaces `InvalidToken` rather than handing + /// back an unverifiable token. + #[tokio::test] + async fn errors_with_invalid_token_when_jwt_malformed() { + let mut mocks = MockSet::new(); + mocks.mock(|when, then| { + when.post().path("/api/authorise"); + then.json(serde_json::json!({ "accessToken": "not-a-jwt", "expiry": 3600 })); + }); + let server = + MockServer::new_http("oidc-federation-strategy-malformed-test").with_mocks(mocks); + server.start().await.expect("mock server start"); + + let strategy = OidcFederationStrategy::builder( + test_region(), + "ZVATKW3VHMFG27DY".parse().unwrap(), + provider(), + ) + .base_url(server.url("")) + .build() + .expect("builder"); + + let err = (&strategy) + .get_token() + .await + .expect_err("expected invalid-token error"); + assert!( + matches!(err, AuthError::InvalidToken(_)), + "expected InvalidToken, got {err:?}", + ); + } + + /// A pre-populated [`TokenStore`] returning a token for a *different* + /// workspace must still be rejected by the strategy wrapper — the same + /// poisoned-shared-cache interaction `AccessKeyStrategy` guards against. + /// A 500-returning mock fails the test loudly if the strategy ever + /// re-federates instead of trusting (and rejecting) the stored token. + #[tokio::test] + async fn rejects_stored_token_for_different_workspace() { + const TOKEN_WS: &str = "AAAAAAAAAAAAAAAA"; + const EXPECTED_WS: &str = "ZVATKW3VHMFG27DY"; + + let mut mocks = MockSet::new(); + mocks.mock(|when, then| { + when.post().path("/api/authorise"); + then.internal_server_error() + .json(serde_json::json!({"error": "store must satisfy the request"})); + }); + let server = + MockServer::new_http("oidc-federation-strategy-store-mismatch-test").with_mocks(mocks); + server.start().await.expect("mock server start"); + + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system clock") + .as_secs(); + let stored = Token { + access_token: SecretToken::new(jwt_with_workspace(TOKEN_WS)), + token_type: "Bearer".to_string(), + expires_at: now + 3600, + refresh_token: None, + region: None, + client_id: None, + device_instance_id: None, + }; + let store = Arc::new(InMemoryTokenStore::new()); + store.save(&stored).await; + + let strategy = OidcFederationStrategy::builder( + test_region(), + EXPECTED_WS.parse().unwrap(), + provider(), + ) + .base_url(server.url("")) + .with_token_store(Arc::clone(&store)) + .build() + .expect("builder"); + + let err = (&strategy) + .get_token() + .await + .expect_err("expected mismatch from stored token"); + assert!( + matches!(err, AuthError::WorkspaceMismatch { .. }), + "expected WorkspaceMismatch, got {err:?}", + ); + } + + /// Regression guard — the workspace check runs on *every* `get_token()` + /// call, not only the one that triggers initial federation. A future + /// optimisation that cached the "verified" verdict would let a mismatched + /// token slide through on the second call. + #[tokio::test] + async fn errors_on_each_subsequent_get_token_call() { + const TOKEN_WS: &str = "AAAAAAAAAAAAAAAA"; + const EXPECTED_WS: &str = "ZVATKW3VHMFG27DY"; + let server = start_mock_server_returning_jwt(TOKEN_WS).await; + + let strategy = OidcFederationStrategy::builder( + test_region(), + EXPECTED_WS.parse().unwrap(), + provider(), + ) + .base_url(server.url("")) + .build() + .expect("builder"); + + for call in 1..=2 { + let err = match (&strategy).get_token().await { + Ok(_) => panic!("call {call}: expected Err, got Ok"), + Err(e) => e, + }; + assert!( + matches!(err, AuthError::WorkspaceMismatch { .. }), + "call {call}: expected WorkspaceMismatch, got {err:?}", + ); + } + } } From 74b8a6f86a37a25cc42b83ead60f761f4413c066 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Mon, 1 Jun 2026 17:21:38 +1000 Subject: [PATCH 264/686] refactor(stack-auth): drop audience from OidcFederationStrategy; clarify provider docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses self-review on cipherstash/cipherstash-suite#1987. - Remove the `audience` field/builder method from `OidcFederationStrategy` and `OidcRefresher`, and drop it from the `/api/authorise` request body. CTS only honours `audience` as a legacy logging-endpoint hack (`map_audience_hack` in cts-web swaps to LOGGING_SCOPE for audit endpoints); for normal OIDC federation it is a no-op, so exposing it on this strategy was misleading. The access-key path keeps `audience` unchanged. - Clarify `OidcRefresher` docs: only *our* federation step is stateless — the upstream OIDC provider that issues the JWT typically is not (cookies, session store, its own refresh token). `/api/authorise` issues no CTS refresh token, so keeping the token fresh is the upstream caller's responsibility via the provider's own mechanism. - Flesh out the `OidcProviderFn` example to show wrapping a provider SDK call (`clerk.session.getToken()`) so the re-callable-closure contract is obvious. --- .../src/oidc_federation_strategy.rs | 12 --- packages/stack-auth/src/oidc_refresher.rs | 98 ++++++------------- 2 files changed, 31 insertions(+), 79 deletions(-) diff --git a/packages/stack-auth/src/oidc_federation_strategy.rs b/packages/stack-auth/src/oidc_federation_strategy.rs index 5fccab2a4..ff07fe36b 100644 --- a/packages/stack-auth/src/oidc_federation_strategy.rs +++ b/packages/stack-auth/src/oidc_federation_strategy.rs @@ -74,7 +74,6 @@ impl OidcFederationStrategy

{ region, workspace_id, oidc_provider, - audience: None, base_url_override: None, token_store: NoStore, } @@ -102,20 +101,11 @@ pub struct OidcFederationStrategyBuilder { region: Region, workspace_id: WorkspaceId, oidc_provider: P, - audience: Option, base_url_override: Option, token_store: S, } impl OidcFederationStrategyBuilder { - /// Set the audience for token requests. - /// - /// Defaults to the workspace host FQDN server-side when unset. - pub fn audience(mut self, audience: impl Into) -> Self { - self.audience = Some(audience.into()); - self - } - /// Override the base URL resolved by service discovery. /// /// Useful for pointing at a local or mock auth server during testing. @@ -142,7 +132,6 @@ impl OidcFederationStrategyBuilder { region: self.region, workspace_id: self.workspace_id, oidc_provider: self.oidc_provider, - audience: self.audience, base_url_override: self.base_url_override, token_store: store, } @@ -165,7 +154,6 @@ impl OidcFederationStrategyBuilder { self.oidc_provider, self.workspace_id, ensure_trailing_slash(base_url), - self.audience, ); Ok(OidcFederationStrategy { inner: AutoRefresh::with_store(refresher, self.token_store), diff --git a/packages/stack-auth/src/oidc_refresher.rs b/packages/stack-auth/src/oidc_refresher.rs index b2a6cfe32..023df2e32 100644 --- a/packages/stack-auth/src/oidc_refresher.rs +++ b/packages/stack-auth/src/oidc_refresher.rs @@ -37,18 +37,25 @@ pub trait OidcProvider { /// [`OidcProvider`] backed by a user-supplied async closure. /// /// The closure fires on every federation — initial auth and every -/// re-federation after expiry — so it should return the *current* JWT each -/// time (e.g. by calling into a provider SDK that refreshes short-lived -/// tokens itself), not a value captured once. +/// re-federation after expiry — so it must return the *current* JWT each +/// time, not a value captured once. The point of the closure is to defer to +/// the provider's own session machinery on every call: a provider SDK +/// (`clerk.session.getToken()`, `supabase.auth.getSession()`) hands back a +/// freshly-minted short-lived JWT, transparently refreshing its own session +/// as needed. Capturing a single token up front would instead pin a JWT that +/// expires and can never be renewed. /// /// # Example /// /// ```no_run /// use stack_auth::{AuthError, OidcProviderFn, SecretToken}; /// +/// # async fn clerk_session_get_token() -> Result { Ok(String::new()) } +/// // Each call asks the provider SDK for the *current* session token, so an +/// // expired JWT is refreshed upstream rather than reused. /// let provider = OidcProviderFn::new(|| async { -/// // Real consumers call into a provider SDK / FFI / IPC. -/// Ok::<_, AuthError>(SecretToken::new("header.payload.signature".to_string())) +/// let jwt = clerk_session_get_token().await?; +/// Ok::<_, AuthError>(SecretToken::new(jwt)) /// }); /// ``` pub struct OidcProviderFn { @@ -87,33 +94,33 @@ where /// A [`Refresher`] that federates a third-party OIDC JWT into a CTS service /// token via `POST /api/authorise`. /// -/// Federation is stateless: the credential is always available (the +/// *Our* federation step is stateless: the credential is always available (the /// [`OidcProvider`] is re-callable), so `try_credential` returns `Some(())` and /// `restore` is a no-op — exactly like -/// [`AccessKeyRefresher`](crate::access_key_refresher). `/api/authorise` -/// issues no CTS refresh token, so `AutoRefresh` renews an expired token by -/// calling `refresh` again, which re-invokes the `OidcProvider` for a current -/// JWT. +/// [`AccessKeyRefresher`](crate::access_key_refresher). The *upstream* OIDC +/// provider that issues the JWT is typically not stateless — it usually relies +/// on its own session machinery (cookies, a session store, a refresh token) +/// to mint the short-lived JWT that [`OidcProvider::fetch`] returns. +/// +/// `/api/authorise` issues no CTS refresh token. Keeping the federated CTS +/// token fresh is therefore the upstream caller's responsibility, via whatever +/// mechanism the provider requires: when the CTS token expires, `AutoRefresh` +/// renews it by calling `refresh` again, which re-invokes the `OidcProvider` +/// for a current JWT — so a provider that hands back an expired or stale JWT +/// will produce an expired or stale CTS token in turn. pub(crate) struct OidcRefresher

{ oidc_provider: P, workspace_id: WorkspaceId, base_url: Url, - audience: Option, http_client: Arc, } impl

OidcRefresher

{ - pub(crate) fn new( - oidc_provider: P, - workspace_id: WorkspaceId, - base_url: Url, - audience: Option, - ) -> Self { + pub(crate) fn new(oidc_provider: P, workspace_id: WorkspaceId, base_url: Url) -> Self { Self { oidc_provider, workspace_id, base_url, - audience, http_client: Arc::new(http_client()), } } @@ -148,7 +155,6 @@ impl Refresher for OidcRefresher

{ .json(&OidcAuthoriseRequest { oidc_token: oidc_token.as_str(), workspace_id: self.workspace_id.as_str(), - audience: self.audience.as_deref(), }) .send() .await?; @@ -183,8 +189,6 @@ impl Refresher for OidcRefresher

{ struct OidcAuthoriseRequest<'a> { oidc_token: &'a str, workspace_id: &'a str, - #[serde(skip_serializing_if = "Option::is_none")] - audience: Option<&'a str>, } #[cfg(test)] @@ -235,7 +239,7 @@ mod tests { server: &MockServer, provider: P, ) -> AutoRefresh> { - let refresher = OidcRefresher::new(provider, workspace_id(), server.url(""), None); + let refresher = OidcRefresher::new(provider, workspace_id(), server.url("")); AutoRefresh::with_store(refresher, crate::NoStore) } @@ -277,57 +281,17 @@ mod tests { } #[test] - fn test_request_serialization_omits_audience_when_unset() { + fn test_request_serialization() { let body = serde_json::to_value(OidcAuthoriseRequest { oidc_token: "the-jwt", workspace_id: WORKSPACE_ID, - audience: None, }) .unwrap(); assert_eq!( body, serde_json::json!({ "oidcToken": "the-jwt", "workspaceId": WORKSPACE_ID }), - "audience must be omitted from the request body when not set" - ); - } - - #[test] - fn test_request_serialization_includes_audience_when_set() { - let body = serde_json::to_value(OidcAuthoriseRequest { - oidc_token: "the-jwt", - workspace_id: WORKSPACE_ID, - audience: Some("my-audience"), - }) - .unwrap(); - assert_eq!( - body, - serde_json::json!({ - "oidcToken": "the-jwt", - "workspaceId": WORKSPACE_ID, - "audience": "my-audience", - }), - ); - } - - #[tokio::test] - async fn test_audience_set_still_federates() { - let mut mocks = MockSet::new(); - mocks.mock(|when, then| { - when.post().path("/api/authorise"); - then.json(auth_response_json("cts-token", 3600)); - }); - let server = start_server(mocks).await; - let (_calls, provider) = counting_provider(); - let refresher = OidcRefresher::new( - provider, - workspace_id(), - server.url(""), - Some("my-audience".to_string()), + "request body should carry exactly the OIDC token and workspace ID" ); - let strategy = AutoRefresh::with_store(refresher, crate::NoStore); - - let token = strategy.get_token().await.unwrap(); - assert_eq!(token.as_str(), "cts-token"); } #[tokio::test] @@ -373,7 +337,7 @@ mod tests { let store = Arc::new(crate::InMemoryTokenStore::new()); store.save(&make_token("stale-cts-token", 0)).await; - let refresher = OidcRefresher::new(provider, workspace_id(), server.url(""), None); + let refresher = OidcRefresher::new(provider, workspace_id(), server.url("")); let strategy = AutoRefresh::with_store(refresher, Arc::clone(&store)); let token = strategy.get_token().await.unwrap(); @@ -443,7 +407,7 @@ mod tests { store.save(&make_token("from-store", 3600)).await; let (calls, provider) = counting_provider(); - let refresher = OidcRefresher::new(provider, workspace_id(), server.url(""), None); + let refresher = OidcRefresher::new(provider, workspace_id(), server.url("")); let strategy = AutoRefresh::with_store(refresher, Arc::clone(&store)); let token = strategy.get_token().await.unwrap(); @@ -466,7 +430,7 @@ mod tests { let store = Arc::new(crate::InMemoryTokenStore::new()); let (_calls, provider) = counting_provider(); - let refresher = OidcRefresher::new(provider, workspace_id(), server.url(""), None); + let refresher = OidcRefresher::new(provider, workspace_id(), server.url("")); let strategy = AutoRefresh::with_store(refresher, Arc::clone(&store)); let token = strategy.get_token().await.unwrap(); From 3c9f4a87755a227aaf0eb19cdf49830e67ef0b33 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Tue, 2 Jun 2026 10:55:55 +1000 Subject: [PATCH 265/686] docs(stack-auth): point authorize_dto refresher links at structs Copilot review: the intra-doc links were labeled as types but targeted the modules. Point them at the structs so they resolve with --document-private-items and match their labels. --- packages/stack-auth/src/authorize_dto.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/stack-auth/src/authorize_dto.rs b/packages/stack-auth/src/authorize_dto.rs index b8e936138..fe6aa4102 100644 --- a/packages/stack-auth/src/authorize_dto.rs +++ b/packages/stack-auth/src/authorize_dto.rs @@ -1,7 +1,8 @@ //! Shared DTO for the CTS `POST /api/authorise` endpoint. //! -//! Both [`AccessKeyRefresher`](crate::access_key_refresher) and -//! [`OidcRefresher`](crate::oidc_refresher) exchange a credential for a CTS +//! Both [`AccessKeyRefresher`](crate::access_key_refresher::AccessKeyRefresher) +//! and [`OidcRefresher`](crate::oidc_refresher::OidcRefresher) exchange a +//! credential for a CTS //! service token at the same endpoint; the success response is identical, so //! the wire contract lives here in one place. The request bodies differ //! (different credential fields) and stay private to each refresher. From b5c0fc4f60bf7ca40da75be65bc7bb2172ff7167 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Mon, 1 Jun 2026 10:36:16 +1000 Subject: [PATCH 266/686] feat(stack-auth): napi + wasm bindings for OidcFederationStrategy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Exposes `OidcFederationStrategy` on both the napi (Node.js) and wasm (Edge/Workers/Deno/Bun) bindings, usable with the existing cookie-backed `TokenStore`. Stacked on the Rust-core PR. **wasm** — `JsJwtProvider` bridges a `js_sys::Function` returning `Promise` into the Rust `JwtProvider`; `OidcFederationStrategy` exposes `create` / `createWithStore`. Imports `Zeroizing` (was missing — only surfaced when compiling for wasm32). **napi** — `NapiJwtProvider` + `NapiTokenStore` bridge JS async callbacks via `ThreadsafeFunction<_, ErrorStrategy::Fatal>` + `Promise` + a `oneshot` channel. `OidcFederationStrategy` with `create` / `createWithStore` factories. **JS wrappers** — `wasm-inline.mjs` adds the options-object API: `OidcFederationStrategy.create(region, workspaceId, getJwt, { store?, audience? })`. `index.js` exposes a wrapper class so factory errors carry `.code`. Types in `wasm-types.d.ts`, `wasm-inline.d.ts`, `index.d.ts`. **macOS napi build** — adds `node/.cargo/config.toml` with `-Wl,-no_fixup_chains`. macOS dyld's chained-fixups skip the module-registration constructors `ctor` emits, so napi addons built on macOS silently exported only a subset of their classes. No-op on Linux. Verified locally: napi `cargo check`, wasm32 `cargo build` + `clippy`, and the full Node vitest suite (`mise run test:integration:stack-auth`) green. --- .../packages/auth/.cargo/config.toml | 11 + languages/typescript/packages/auth/Cargo.toml | 5 +- languages/typescript/packages/auth/README.md | 36 ++- .../__tests__/oidc-cookie-roundtrip.test.ts | 145 ++++++++++ .../oidc-federation-strategy.test.ts | 168 ++++++++++++ languages/typescript/packages/auth/index.d.ts | 38 +++ languages/typescript/packages/auth/index.js | 26 +- .../packages/auth/scripts/inline-wasm.mjs | 2 +- languages/typescript/packages/auth/src/lib.rs | 194 ++++++++++++- .../packages/auth/src/mock_auth_server.rs | 29 ++ .../typescript/packages/auth/test-utils.d.ts | 13 + .../typescript/packages/auth/wasm-inline.d.ts | 41 +++ .../typescript/packages/auth/wasm-inline.mjs | 57 +++- .../typescript/packages/auth/wasm-types.d.ts | 31 +++ .../packages/stack-auth-wasm/src/lib.rs | 257 +++++++++++++++++- 15 files changed, 1035 insertions(+), 18 deletions(-) create mode 100644 languages/typescript/packages/auth/.cargo/config.toml create mode 100644 languages/typescript/packages/auth/__tests__/oidc-cookie-roundtrip.test.ts create mode 100644 languages/typescript/packages/auth/__tests__/oidc-federation-strategy.test.ts diff --git a/languages/typescript/packages/auth/.cargo/config.toml b/languages/typescript/packages/auth/.cargo/config.toml new file mode 100644 index 000000000..ce83855c4 --- /dev/null +++ b/languages/typescript/packages/auth/.cargo/config.toml @@ -0,0 +1,11 @@ +# macOS dyld's chained-fixups format does not run the module-registration +# constructors that the `ctor` crate emits for each `#[napi]` item, so a +# napi addon built on macOS silently exports only a subset of its classes +# and functions (`require()` returns a near-empty object). Linking with +# `-no_fixup_chains` restores the classic bind-on-load behaviour so every +# `#[napi]` registration runs. +# +# Scoped to the `stack-auth-node` crate (this is the only napi addon in the +# repo). No-op on Linux — CI builds there and is unaffected. +[target.'cfg(target_os = "macos")'] +rustflags = ["-C", "link-arg=-Wl,-no_fixup_chains"] diff --git a/languages/typescript/packages/auth/Cargo.toml b/languages/typescript/packages/auth/Cargo.toml index 0866c5bf1..65769a285 100644 --- a/languages/typescript/packages/auth/Cargo.toml +++ b/languages/typescript/packages/auth/Cargo.toml @@ -14,9 +14,10 @@ cts-common = { workspace = true } vitaminc-protected = { workspace = true } napi = { version = "2", features = ["async", "tokio_rt"] } napi-derive = "2" +serde_json = "1" +zeroize = { workspace = true } url = { version = "2", optional = true } mocktail = { version = "0.3.0", optional = true } -serde_json = { version = "1", optional = true } jsonwebtoken = { workspace = true, optional = true } reqwest = { workspace = true, optional = true } @@ -33,4 +34,4 @@ url = "2" napi-build = "2" [features] -test-utils = ["stack-auth/test-utils", "dep:url", "dep:mocktail", "dep:serde_json", "dep:jsonwebtoken", "dep:reqwest"] +test-utils = ["stack-auth/test-utils", "dep:url", "dep:mocktail", "dep:jsonwebtoken", "dep:reqwest"] diff --git a/languages/typescript/packages/auth/README.md b/languages/typescript/packages/auth/README.md index ca0f7660e..0e0c1713a 100644 --- a/languages/typescript/packages/auth/README.md +++ b/languages/typescript/packages/auth/README.md @@ -19,13 +19,13 @@ The package exposes four entries: | Entry | Use when | Loads | Surface | |---|---|---|---| -| `@cipherstash/auth` | **Node.js** | Native napi binding for the host platform | Full surface — device-code flow, profile store, OAuth, `AccessKeyStrategy` | -| `@cipherstash/auth` | **SSR bundlers** (Vite/Webpack/Next.js targeting Node or server-side rendering) | Sibling-`.wasm` shim from `wasm-pack --target bundler` | `AccessKeyStrategy` | -| `@cipherstash/auth/wasm` | Explicit opt-in to the sibling-`.wasm` shim | Same as bundler entry above | `AccessKeyStrategy` | -| `@cipherstash/auth/wasm-inline` | **Supabase Edge Functions / Cloudflare Workers / Bun / Deno via `npm:`** — runtimes that can't auto-bundle a sibling `.wasm` | Inline-bytes shim (wasm embedded as base64) | `AccessKeyStrategy` | +| `@cipherstash/auth` | **Node.js** | Native napi binding for the host platform | Full surface — device-code flow, profile store, OAuth, `AccessKeyStrategy`, `OidcFederationStrategy` | +| `@cipherstash/auth` | **SSR bundlers** (Vite/Webpack/Next.js targeting Node or server-side rendering) | Sibling-`.wasm` shim from `wasm-pack --target bundler` | `AccessKeyStrategy`, `OidcFederationStrategy` | +| `@cipherstash/auth/wasm` | Explicit opt-in to the sibling-`.wasm` shim | Same as bundler entry above | `AccessKeyStrategy`, `OidcFederationStrategy` | +| `@cipherstash/auth/wasm-inline` | **Supabase Edge Functions / Cloudflare Workers / Bun / Deno via `npm:`** — runtimes that can't auto-bundle a sibling `.wasm` | Inline-bytes shim (wasm embedded as base64) | `AccessKeyStrategy`, `OidcFederationStrategy` | | `@cipherstash/auth/cookies` | Any runtime with WHATWG `Request`/`Headers` (Edge, Workers, Bun, Deno, Node 18+, Next.js App Router) | Pure-JS helper | `cookieStore(...)` — builds a `TokenStore` from a `Request + Headers` pair | -The wasm bindings are deliberately scoped to `AccessKeyStrategy` — OAuth, device-code flow, and profile-store features depend on Node-only APIs (filesystem, browser launching) that can't be ported. +The wasm bindings expose `AccessKeyStrategy` (static M2M keys) and `OidcFederationStrategy` (federating a third-party OIDC JWT — Clerk, Supabase, … — into a CTS service token). The interactive device-code flow and profile-store loading stay Node-only — they depend on filesystem and browser-launching APIs that can't be ported to wasm. The `wasm`, `wasm-inline`, and `cookies` entries are **ESM-only** — they target Edge/Workers/Deno/Bun runtimes that are ESM-native. From a CommonJS context, load them via dynamic `import()` rather than `require()`. Only the default `@cipherstash/auth` entry has a CJS (`node`) build. @@ -95,6 +95,32 @@ Nothing extra in `supabase/config.toml` — no `static_files`, no asset copying, For Cloudflare Workers the shape is identical; env access becomes `env.CS_CLIENT_ACCESS_KEY` instead of `Deno.env.get(...)`. +### Federating a third-party OIDC JWT — `OidcFederationStrategy` + +When the end user is already signed in with a third-party OIDC provider (Clerk, Supabase, Auth0, …), `OidcFederationStrategy` exchanges their provider JWT for a CTS service token via `/api/authorise` — no access key needed: + +```ts +import { OidcFederationStrategy } from "@cipherstash/auth/wasm-inline"; +import { cookieStore } from "@cipherstash/auth/cookies"; + +Deno.serve(async (req) => { + const responseHeaders = new Headers({ "content-type": "application/json" }); + + const strategy = OidcFederationStrategy.create( + "ap-southeast-2.aws", + workspaceId, + // Returns the *current* provider JWT — re-invoked on every re-federation. + () => getClerkSessionToken(req), + { store: cookieStore({ request: req, responseHeaders }) }, + ); + + const { token, services } = await strategy.getToken(); + return new Response(JSON.stringify({ services }), { headers: responseHeaders }); +}); +``` + +`/api/authorise` issues no CTS refresh token, so when the cached CTS token expires `OidcFederationStrategy` re-federates — it calls `getJwt` again for a fresh provider JWT. Pass a `getJwt` that returns a live token each time (e.g. wrapping the provider SDK), not a value captured once. The same API is available on the Node-native entry: `const { OidcFederationStrategy } = require("@cipherstash/auth")`. + ### Caching with `cookieStore` `cookieStore({ request, responseHeaders })` returns a `TokenStore`: diff --git a/languages/typescript/packages/auth/__tests__/oidc-cookie-roundtrip.test.ts b/languages/typescript/packages/auth/__tests__/oidc-cookie-roundtrip.test.ts new file mode 100644 index 000000000..2f70e4a80 --- /dev/null +++ b/languages/typescript/packages/auth/__tests__/oidc-cookie-roundtrip.test.ts @@ -0,0 +1,145 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import type { MockAuthServer as MockAuthServerType } from "../test-utils"; +import { cookieStore } from "../cookies.mjs"; + +// Load the CJS module — includes MockAuthServer when built with test-utils. +const mod = require("../index.js") as typeof import("../index") & { + MockAuthServer: typeof MockAuthServerType; +}; + +const { OidcFederationStrategy, MockAuthServer } = mod; + +const REGION = "ap-southeast-2.aws"; +const WORKSPACE_ID = "ZVATKW3VHMFG27DY"; + +let server: InstanceType; +let savedHost: string | undefined; + +beforeEach(async () => { + server = await MockAuthServer.start(); + savedHost = process.env.CS_CTS_HOST; + process.env.CS_CTS_HOST = server.baseUrl; +}); + +afterEach(() => { + if (savedHost === undefined) { + delete process.env.CS_CTS_HOST; + } else { + process.env.CS_CTS_HOST = savedHost; + } +}); + +/** Extract the `name=value` pair from a `Set-Cookie` header. */ +function cookiePair(setCookie: string): string { + return setCookie.split(";")[0]; +} + +/** A `Request` carrying the given `Cookie:` header (or none). */ +function requestWith(cookie?: string): Request { + return new Request("https://example.com/", { + headers: cookie ? { cookie } : {}, + }); +} + +describe("OidcFederationStrategy + cookieStore round-trip", () => { + it("writes the federated CTS token to a Set-Cookie header", async () => { + server.mockAuthorizeEndpoint(); + const responseHeaders = new Headers(); + const store = cookieStore({ request: requestWith(), responseHeaders }); + + const strategy = OidcFederationStrategy.createWithStore( + REGION, + WORKSPACE_ID, + () => Promise.resolve("header.payload.signature"), + store.load, + store.save, + ); + const result = await strategy.getToken(); + + expect(result.workspaceId).toBe(WORKSPACE_ID); + const setCookie = responseHeaders.get("set-cookie"); + expect(setCookie).toBeTruthy(); + expect(setCookie).toMatch(/^cs_token=/); + }); + + it("reuses a cached token from the cookie without re-federating", async () => { + // First request federates and writes the cookie. + server.mockAuthorizeEndpoint(); + const firstHeaders = new Headers(); + const firstStore = cookieStore({ + request: requestWith(), + responseHeaders: firstHeaders, + }); + const first = OidcFederationStrategy.createWithStore( + REGION, + WORKSPACE_ID, + () => Promise.resolve("header.payload.signature"), + firstStore.load, + firstStore.save, + ); + await first.getToken(); + const cookie = cookiePair(firstHeaders.get("set-cookie")!); + + // Second request carries the cookie. Federation would fail (500) and + // getJwt would throw — proving the token came from the cookie. + server.clearMocks(); + server.mockAuthorizeEndpointError(); + const secondStore = cookieStore({ + request: requestWith(cookie), + responseHeaders: new Headers(), + }); + const second = OidcFederationStrategy.createWithStore( + REGION, + WORKSPACE_ID, + () => Promise.reject(new Error("getJwt must not be called")), + secondStore.load, + secondStore.save, + ); + + const result = await second.getToken(); + expect(result.workspaceId).toBe(WORKSPACE_ID); + }); + + it("re-federates when the cookie holds an expired token", async () => { + // First request federates a token that is immediately expired (expiry 0). + server.mockAuthorizeEndpoint(0); + const firstHeaders = new Headers(); + const firstStore = cookieStore({ + request: requestWith(), + responseHeaders: firstHeaders, + }); + const first = OidcFederationStrategy.createWithStore( + REGION, + WORKSPACE_ID, + () => Promise.resolve("header.payload.signature"), + firstStore.load, + firstStore.save, + ); + await first.getToken(); + const cookie = cookiePair(firstHeaders.get("set-cookie")!); + + // Second request carries the expired cookie. getToken() must re-federate + // — calling getJwt again — rather than serving the stale token. + server.clearMocks(); + server.mockAuthorizeEndpoint(); + let getJwtCalls = 0; + const secondStore = cookieStore({ + request: requestWith(cookie), + responseHeaders: new Headers(), + }); + const second = OidcFederationStrategy.createWithStore( + REGION, + WORKSPACE_ID, + () => { + getJwtCalls += 1; + return Promise.resolve("header.payload.signature"); + }, + secondStore.load, + secondStore.save, + ); + + const result = await second.getToken(); + expect(result.workspaceId).toBe(WORKSPACE_ID); + expect(getJwtCalls).toBe(1); + }); +}); diff --git a/languages/typescript/packages/auth/__tests__/oidc-federation-strategy.test.ts b/languages/typescript/packages/auth/__tests__/oidc-federation-strategy.test.ts new file mode 100644 index 000000000..6542e110e --- /dev/null +++ b/languages/typescript/packages/auth/__tests__/oidc-federation-strategy.test.ts @@ -0,0 +1,168 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import type { MockAuthServer as MockAuthServerType } from "../test-utils"; +import type { AuthError } from "../index"; + +// Load the CJS module — includes MockAuthServer when built with test-utils. +const mod = require("../index.js") as typeof import("../index") & { + MockAuthServer: typeof MockAuthServerType; +}; + +const { OidcFederationStrategy, MockAuthServer } = mod; + +const REGION = "ap-southeast-2.aws"; +const WORKSPACE_ID = "ZVATKW3VHMFG27DY"; + +let server: InstanceType; +let savedHost: string | undefined; + +beforeEach(async () => { + server = await MockAuthServer.start(); + // OidcFederationStrategy resolves the CTS base URL from CS_CTS_HOST at build time. + savedHost = process.env.CS_CTS_HOST; + process.env.CS_CTS_HOST = server.baseUrl; +}); + +afterEach(() => { + if (savedHost === undefined) { + delete process.env.CS_CTS_HOST; + } else { + process.env.CS_CTS_HOST = savedHost; + } +}); + +/** A `getJwt` callback that counts invocations and returns a fixed JWT. */ +function countingJwt() { + let calls = 0; + return { + calls: () => calls, + getJwt: () => { + calls += 1; + return Promise.resolve("header.payload.signature"); + }, + }; +} + +/** An in-memory `{ load, save }` token store dealing in JSON strings. */ +function memStore() { + let saved: string | null = null; + return { + saved: () => saved, + load: () => Promise.resolve(saved), + save: (json: string) => { + saved = json; + return Promise.resolve(); + }, + }; +} + +describe("OidcFederationStrategy (TypeScript / vitest)", () => { + it("federates a third-party JWT into a CTS service token", async () => { + server.mockAuthorizeEndpoint(); + const jwt = countingJwt(); + const strategy = OidcFederationStrategy.create(REGION, WORKSPACE_ID, jwt.getJwt); + + const result = await strategy.getToken(); + + expect(result.token).not.toBe(""); + expect(result.workspaceId).toBe(WORKSPACE_ID); + expect(jwt.calls()).toBe(1); + }); + + it("re-federates after the cached token expires", async () => { + // expiry 0 → the federated token is immediately expired, so the second + // getToken() must re-federate rather than serve a cached token. + server.mockAuthorizeEndpoint(0); + server.mockAuthorizeEndpoint(0); + const jwt = countingJwt(); + const strategy = OidcFederationStrategy.create(REGION, WORKSPACE_ID, jwt.getJwt); + + await strategy.getToken(); + await strategy.getToken(); + + expect(jwt.calls()).toBe(2); + }); + + it("surfaces a getJwt rejection as an error with .code", async () => { + server.mockAuthorizeEndpoint(); + const strategy = OidcFederationStrategy.create(REGION, WORKSPACE_ID, () => + Promise.reject(new Error("provider unavailable")), + ); + + try { + await strategy.getToken(); + expect.unreachable("getToken should reject when getJwt rejects"); + } catch (err) { + expect((err as AuthError).code).toBe("SERVER_ERROR"); + } + }); + + it("rejects an invalid workspace id with .code", () => { + try { + OidcFederationStrategy.create(REGION, "not-a-workspace-id", () => + Promise.resolve("h.p.s"), + ); + expect.unreachable("create should throw on a malformed workspace id"); + } catch (err) { + expect((err as AuthError).code).toBe("INVALID_WORKSPACE_ID"); + } + }); + + it("rejects an invalid region with .code", () => { + try { + OidcFederationStrategy.create("not-a-region", WORKSPACE_ID, () => + Promise.resolve("h.p.s"), + ); + expect.unreachable("create should throw on a malformed region"); + } catch (err) { + expect((err as AuthError).code).toBe("INVALID_REGION"); + } + }); + + it("persists the federated token to the store", async () => { + server.mockAuthorizeEndpoint(); + const store = memStore(); + const jwt = countingJwt(); + const strategy = OidcFederationStrategy.createWithStore( + REGION, + WORKSPACE_ID, + jwt.getJwt, + store.load, + store.save, + ); + + await strategy.getToken(); + + expect(store.saved()).not.toBeNull(); + expect(jwt.calls()).toBe(1); + }); + + it("loads a cached token from the store without re-federating", async () => { + // First strategy federates and populates the shared store. + server.mockAuthorizeEndpoint(); + const store = memStore(); + const first = OidcFederationStrategy.createWithStore( + REGION, + WORKSPACE_ID, + () => Promise.resolve("h.p.s"), + store.load, + store.save, + ); + await first.getToken(); + expect(store.saved()).not.toBeNull(); + + // Second strategy shares the store. Federation would fail (500) and getJwt + // would throw — proving the token came from the store, not the network. + server.clearMocks(); + server.mockAuthorizeEndpointError(); + const second = OidcFederationStrategy.createWithStore( + REGION, + WORKSPACE_ID, + () => Promise.reject(new Error("getJwt must not be called")), + store.load, + store.save, + ); + + const result = await second.getToken(); + expect(result.workspaceId).toBe(WORKSPACE_ID); + }); +}); diff --git a/languages/typescript/packages/auth/index.d.ts b/languages/typescript/packages/auth/index.d.ts index fd18c4bad..520577e69 100644 --- a/languages/typescript/packages/auth/index.d.ts +++ b/languages/typescript/packages/auth/index.d.ts @@ -20,6 +20,7 @@ export type AuthErrorCode = | 'INVALID_ACCESS_KEY' | 'INVALID_CRN' | 'WORKSPACE_MISMATCH' + | 'INVALID_WORKSPACE_ID' | 'UNKNOWN_ERROR' /** An error thrown by this package, enriched with a machine-readable `.code`. */ @@ -118,6 +119,17 @@ export declare class MockAuthServer { * with the given OAuth error code and optional description. */ mockTokenEndpointError(code: string, description?: string | undefined | null): void + /** + * Register a mock for `POST /api/authorise` that returns a successful + * federation response (`{ accessToken, expiry }`), as CTS would for an + * `OidcFederationStrategy` JWT exchange. + * + * `expiry` (seconds until the CTS token expires) defaults to 3600. Pass a + * small value to exercise re-federation on expiry. + */ + mockAuthorizeEndpoint(expiry?: number | undefined | null): void + /** Register a mock for `POST /api/authorise` that returns a 500 error. */ + mockAuthorizeEndpointError(): void /** * Register a mock for `POST /create-client` that returns a successful * create-client JSON response (as ZeroKMS would). @@ -184,6 +196,32 @@ export declare class OAuthStrategy { /** Retrieve a valid access token, refreshing as needed. */ getToken(): Promise } +/** + * An auth strategy that federates a third-party OIDC JWT (Clerk, Supabase, …) + * into a CipherStash CTS service token via `/api/authorise`. + */ +export declare class OidcFederationStrategy { + /** + * Create an `OidcFederationStrategy` for the given region and workspace. + * + * `getJwt` is called on every federation — initial auth and every + * re-federation after the CTS token expires — and must return + * `Promise` resolving to the *current* third-party OIDC JWT. + */ + static create(region: string, workspaceId: string, getJwt: () => any): OidcFederationStrategy + /** + * Create an `OidcFederationStrategy` backed by external token-store callbacks. + * + * Behaves like [`create`](Self::create) but persists the federated CTS + * token through `loadToken` (`() => Promise`) + * and `saveToken` (`(json: string) => Promise`) — e.g. an HTTP-only + * cookie — so a federated token survives across requests without + * re-federating. + */ + static createWithStore(region: string, workspaceId: string, getJwt: () => any, loadToken: () => any, saveToken: (arg: string) => any): OidcFederationStrategy + /** Retrieve a valid CTS service token, federating or re-federating as needed. */ + getToken(): Promise +} export declare class DeviceCodeResult { get userCode(): string get verificationUri(): string diff --git a/languages/typescript/packages/auth/index.js b/languages/typescript/packages/auth/index.js index 6e72720ad..316e5d8b3 100644 --- a/languages/typescript/packages/auth/index.js +++ b/languages/typescript/packages/auth/index.js @@ -49,7 +49,12 @@ dcProto.pollForToken = wrapAsync(dcProto.pollForToken); dcProto.openInBrowser = wrapSync(dcProto.openInBrowser); // Patch strategy getToken methods -for (const Strategy of [native.AutoStrategy, native.AccessKeyStrategy, native.OAuthStrategy]) { +for (const Strategy of [ + native.AutoStrategy, + native.AccessKeyStrategy, + native.OAuthStrategy, + native.OidcFederationStrategy, +]) { Strategy.prototype.getToken = wrapAsync(Strategy.prototype.getToken); } @@ -63,9 +68,28 @@ native.AccessKeyStrategy.create = wrapSync(origCreate); const origFromProfile = native.OAuthStrategy.fromProfile; native.OAuthStrategy.fromProfile = wrapSync(origFromProfile); +// napi defines class static methods as non-writable, so a factory's +// synchronously-thrown errors can't be `.code`-enriched by patching the +// native class in place. Expose a thin wrapper whose static factories run +// through `wrapSync`. Instances are the native ones — their async +// `getToken()` is already enriched via the prototype patch above. +const NativeOidcFederationStrategy = native.OidcFederationStrategy; +class OidcFederationStrategy { + static create(...args) { + return wrapSync(() => NativeOidcFederationStrategy.create(...args))(); + } + + static createWithStore(...args) { + return wrapSync(() => + NativeOidcFederationStrategy.createWithStore(...args), + )(); + } +} + // Export wrapped top-level functions alongside native re-exports module.exports = { ...native, + OidcFederationStrategy, beginDeviceCodeFlow: wrapAsync(native.beginDeviceCodeFlow), bindClientDevice: wrapAsync(native.bindClientDevice), }; diff --git a/languages/typescript/packages/auth/scripts/inline-wasm.mjs b/languages/typescript/packages/auth/scripts/inline-wasm.mjs index ecc2a2cc8..3b8310c73 100644 --- a/languages/typescript/packages/auth/scripts/inline-wasm.mjs +++ b/languages/typescript/packages/auth/scripts/inline-wasm.mjs @@ -28,7 +28,7 @@ bgImports.__wbg_set_wasm(instance.exports); instance.exports.__wbindgen_start(); export { - AccessKeyStrategy, IntoUnderlyingByteSource, IntoUnderlyingSink, IntoUnderlyingSource, module_init + AccessKeyStrategy, OidcFederationStrategy, IntoUnderlyingByteSource, IntoUnderlyingSink, IntoUnderlyingSource, module_init } from "./stack_auth_wasm_bg.js"; `; diff --git a/languages/typescript/packages/auth/src/lib.rs b/languages/typescript/packages/auth/src/lib.rs index a99c6cace..47a2a9a6f 100644 --- a/languages/typescript/packages/auth/src/lib.rs +++ b/languages/typescript/packages/auth/src/lib.rs @@ -3,11 +3,15 @@ use std::sync::Mutex; use cts_common::Region; use napi::bindgen_prelude::*; +use napi::threadsafe_function::{ErrorStrategy, ThreadsafeFunction, ThreadsafeFunctionCallMode}; +use napi::tokio::sync::oneshot; use napi_derive::napi; use stack_auth::{ - AuthError, AuthStrategy, DeviceClientError, DeviceCodeStrategy, PendingDeviceCode, ServiceToken, + AuthError, AuthStrategy, DeviceClientError, DeviceCodeStrategy, OidcProvider, + PendingDeviceCode, SecretToken, ServiceToken, Token, TokenStore, }; use vitaminc_protected::OpaqueDebug; +use zeroize::Zeroizing; #[cfg(feature = "test-utils")] mod mock_auth_server; @@ -33,6 +37,7 @@ fn error_code(err: &AuthError) -> &'static str { AuthError::InvalidAccessKey(_) => "INVALID_ACCESS_KEY", AuthError::InvalidCrn(_) => "INVALID_CRN", AuthError::WorkspaceMismatch { .. } => "WORKSPACE_MISMATCH", + AuthError::InvalidWorkspaceId(_) => "INVALID_WORKSPACE_ID", _ => "UNKNOWN_ERROR", } } @@ -217,6 +222,193 @@ impl OAuthStrategy { } } +// --------------------------------------------------------------------------- +// OidcFederationStrategy — federate a third-party OIDC JWT into a CTS service token +// --------------------------------------------------------------------------- + +/// Parse and validate the `region` + `workspaceId` inputs shared by both +/// `OidcFederationStrategy` factories. +fn parse_oidc_inputs( + region: &str, + workspace_id: &str, +) -> Result<(Region, cts_common::WorkspaceId)> { + let region = Region::new(region).map_err(|e| to_napi_error(AuthError::from(e)))?; + let workspace_id = workspace_id + .parse::() + .map_err(|e| to_napi_error(AuthError::from(e)))?; + Ok((region, workspace_id)) +} + +/// Bridges a JS `getJwt` callback into a Rust [`OidcProvider`]. +/// +/// `getJwt` is a JS function returning `Promise` — the current +/// third-party OIDC JWT. The threadsafe function lets the Rust refresh engine +/// (running on napi's tokio pool) schedule the call onto the Node event-loop +/// thread; the JS-returned `Promise` is ferried back and awaited here. +struct NapiOidcProvider { + get_jwt: ThreadsafeFunction<(), ErrorStrategy::Fatal>, +} + +impl OidcProvider for NapiOidcProvider { + async fn fetch(&self) -> std::result::Result { + let (tx, rx) = oneshot::channel::>(); + let status = self.get_jwt.call_with_return_value( + (), + ThreadsafeFunctionCallMode::NonBlocking, + move |promise: Promise| { + let _ = tx.send(promise); + Ok(()) + }, + ); + if status != Status::Ok { + return Err(AuthError::Server(format!( + "getJwt callback dispatch failed: {status:?}" + ))); + } + let promise = rx + .await + .map_err(|_| AuthError::Server("getJwt callback did not run".to_string()))?; + let jwt = Zeroizing::new( + promise + .await + .map_err(|e| AuthError::Server(format!("getJwt rejected: {e}")))?, + ); + Ok(SecretToken::new(jwt.as_str())) + } +} + +/// Bridges JS `loadToken` / `saveToken` callbacks into a Rust [`TokenStore`]. +/// +/// Both are best-effort, mirroring [`stack_auth::TokenStoreFn`] semantics: a +/// `load` failure becomes a cache miss, a `save` failure is swallowed. +struct NapiTokenStore { + load: ThreadsafeFunction<(), ErrorStrategy::Fatal>, + save: ThreadsafeFunction, +} + +impl TokenStore for NapiTokenStore { + async fn load(&self) -> Option { + let (tx, rx) = oneshot::channel::>>(); + let status = self.load.call_with_return_value( + (), + ThreadsafeFunctionCallMode::NonBlocking, + move |promise: Promise>| { + let _ = tx.send(promise); + Ok(()) + }, + ); + if status != Status::Ok { + return None; + } + let json = Zeroizing::new(rx.await.ok()?.await.ok()??); + serde_json::from_str(&json).ok() + } + + async fn save(&self, token: &Token) { + let Ok(json) = serde_json::to_string(token).map(Zeroizing::new) else { + return; + }; + let (tx, rx) = oneshot::channel::>(); + let status = self.save.call_with_return_value( + json.to_string(), + ThreadsafeFunctionCallMode::NonBlocking, + move |promise: Promise<()>| { + let _ = tx.send(promise); + Ok(()) + }, + ); + if status != Status::Ok { + return; + } + if let Ok(promise) = rx.await { + let _ = promise.await; + } + } +} + +enum OidcFederationStrategyInner { + NoStore(stack_auth::OidcFederationStrategy), + WithStore(stack_auth::OidcFederationStrategy), +} + +/// An auth strategy that federates a third-party OIDC JWT (Clerk, Supabase, …) +/// into a CipherStash CTS service token via `/api/authorise`. +#[napi] +pub struct OidcFederationStrategy { + inner: OidcFederationStrategyInner, +} + +#[napi] +impl OidcFederationStrategy { + /// Create an `OidcFederationStrategy` for the given region and workspace. + /// + /// `getJwt` is called on every federation — initial auth and every + /// re-federation after the CTS token expires — and must return + /// `Promise` resolving to the *current* third-party OIDC JWT. + #[napi(factory)] + pub fn create( + region: String, + workspace_id: String, + get_jwt: ThreadsafeFunction<(), ErrorStrategy::Fatal>, + ) -> Result { + let (region, workspace_id) = parse_oidc_inputs(®ion, &workspace_id)?; + let inner = stack_auth::OidcFederationStrategy::builder( + region, + workspace_id, + NapiOidcProvider { get_jwt }, + ) + .build() + .map_err(to_napi_error)?; + Ok(Self { + inner: OidcFederationStrategyInner::NoStore(inner), + }) + } + + /// Create an `OidcFederationStrategy` backed by external token-store callbacks. + /// + /// Behaves like [`create`](Self::create) but persists the federated CTS + /// token through `loadToken` (`() => Promise`) + /// and `saveToken` (`(json: string) => Promise`) — e.g. an HTTP-only + /// cookie — so a federated token survives across requests without + /// re-federating. + #[napi(factory)] + pub fn create_with_store( + region: String, + workspace_id: String, + get_jwt: ThreadsafeFunction<(), ErrorStrategy::Fatal>, + load_token: ThreadsafeFunction<(), ErrorStrategy::Fatal>, + save_token: ThreadsafeFunction, + ) -> Result { + let (region, workspace_id) = parse_oidc_inputs(®ion, &workspace_id)?; + let store = NapiTokenStore { + load: load_token, + save: save_token, + }; + let inner = stack_auth::OidcFederationStrategy::builder( + region, + workspace_id, + NapiOidcProvider { get_jwt }, + ) + .with_token_store(store) + .build() + .map_err(to_napi_error)?; + Ok(Self { + inner: OidcFederationStrategyInner::WithStore(inner), + }) + } + + /// Retrieve a valid CTS service token, federating or re-federating as needed. + #[napi] + pub async fn get_token(&self) -> Result { + let token = match &self.inner { + OidcFederationStrategyInner::NoStore(s) => s.get_token().await, + OidcFederationStrategyInner::WithStore(s) => s.get_token().await, + } + .map_err(to_napi_error)?; + token_result_from(token) + } +} + // --------------------------------------------------------------------------- // AuthResult — plain data object (device code flow) // --------------------------------------------------------------------------- diff --git a/languages/typescript/packages/auth/src/mock_auth_server.rs b/languages/typescript/packages/auth/src/mock_auth_server.rs index adaade517..da9528066 100644 --- a/languages/typescript/packages/auth/src/mock_auth_server.rs +++ b/languages/typescript/packages/auth/src/mock_auth_server.rs @@ -104,6 +104,35 @@ impl MockAuthServer { }); } + /// Register a mock for `POST /api/authorise` that returns a successful + /// federation response (`{ accessToken, expiry }`), as CTS would for an + /// `OidcFederationStrategy` JWT exchange. + /// + /// `expiry` (seconds until the CTS token expires) defaults to 3600. Pass a + /// small value to exercise re-federation on expiry. + #[napi] + pub fn mock_authorize_endpoint(&self, expiry: Option) { + let jwt = test_jwt(); + let expiry = expiry.unwrap_or(3600); + self.server.mocks().mock(move |when, then| { + when.post().path("/api/authorise"); + then.json(serde_json::json!({ + "accessToken": jwt, + "expiry": expiry, + })); + }); + } + + /// Register a mock for `POST /api/authorise` that returns a 500 error. + #[napi] + pub fn mock_authorize_endpoint_error(&self) { + self.server.mocks().mock(|when, then| { + when.post().path("/api/authorise"); + then.internal_server_error() + .json(serde_json::json!({"error": "federation failed"})); + }); + } + /// Register a mock for `POST /create-client` that returns a successful /// create-client JSON response (as ZeroKMS would). #[napi] diff --git a/languages/typescript/packages/auth/test-utils.d.ts b/languages/typescript/packages/auth/test-utils.d.ts index e75942f9f..91ace2a7a 100644 --- a/languages/typescript/packages/auth/test-utils.d.ts +++ b/languages/typescript/packages/auth/test-utils.d.ts @@ -53,6 +53,19 @@ export class MockAuthServer { */ mockTokenEndpointError(code: string, description?: string): void; + /** + * Register a mock for `POST /api/authorise` that returns a successful + * federation response (`{ accessToken, expiry }`), as CTS would for an + * `OidcFederationStrategy` JWT exchange. + * + * @param expiry - Seconds until the CTS token expires. Defaults to 3600; + * pass a small value (e.g. 0) to exercise re-federation on expiry. + */ + mockAuthorizeEndpoint(expiry?: number): void; + + /** Register a mock for `POST /api/authorise` that returns a 500 error. */ + mockAuthorizeEndpointError(): void; + /** Remove all registered mocks. */ clearMocks(): void; } diff --git a/languages/typescript/packages/auth/wasm-inline.d.ts b/languages/typescript/packages/auth/wasm-inline.d.ts index e0113b4f0..5160b43fe 100644 --- a/languages/typescript/packages/auth/wasm-inline.d.ts +++ b/languages/typescript/packages/auth/wasm-inline.d.ts @@ -79,3 +79,44 @@ export declare class AccessKeyStrategy { /** Release the underlying wasm resources. */ free(): void; } + +/** + * Supplies the *current* third-party OIDC JWT to federate. Called on every + * federation — initial auth and every re-federation after expiry — so it + * should return a live token each time (e.g. `() => clerk.session.getToken()`). + */ +export type OidcProvider = () => string | Promise; + +/** Options accepted by {@link OidcFederationStrategy.create}. */ +export interface OidcFederationStrategyOptions { + /** + * External persistence for the federated CTS token — see + * {@link AccessKeyStrategyOptions.store}. + */ + store?: TokenStore; +} + +/** + * An auth strategy that federates a third-party OIDC JWT (Clerk, Supabase, …) + * into a CipherStash CTS service token via `/api/authorise`. + */ +export declare class OidcFederationStrategy { + private constructor(); + /** + * Create an `OidcFederationStrategy` for the given region and workspace. + * + * `getJwt` must return the current third-party OIDC JWT (it is re-invoked + * on every re-federation). Pass `options.store` to back the strategy with a + * persistent cache — see {@link TokenStore}. + */ + static create( + region: string, + workspaceId: string, + getJwt: OidcProvider, + options?: OidcFederationStrategyOptions, + ): OidcFederationStrategy; + /** Retrieve a valid CTS service token, federating or re-federating as needed. */ + getToken(): Promise; + /** Release the underlying wasm resources. */ + free(): void; +} diff --git a/languages/typescript/packages/auth/wasm-inline.mjs b/languages/typescript/packages/auth/wasm-inline.mjs index b01b3dfef..29de24550 100644 --- a/languages/typescript/packages/auth/wasm-inline.mjs +++ b/languages/typescript/packages/auth/wasm-inline.mjs @@ -7,10 +7,15 @@ // etc.) without breaking callers, and matches the options-object pattern // most modern JS APIs use. -import { AccessKeyStrategy as RawAccessKeyStrategy } from "./wasm/stack_auth_wasm_inline.js"; +import { + AccessKeyStrategy as RawAccessKeyStrategy, + OidcFederationStrategy as RawOidcFederationStrategy, +} from "./wasm/stack_auth_wasm_inline.js"; /** @typedef {{ load(): Promise; save(json: string): Promise }} TokenStore */ /** @typedef {{ store?: TokenStore }} AccessKeyStrategyOptions */ +/** @typedef {() => string | Promise} OidcProvider */ +/** @typedef {{ store?: TokenStore }} OidcFederationStrategyOptions */ export class AccessKeyStrategy { #inner; @@ -52,3 +57,53 @@ export class AccessKeyStrategy { this.#inner.free(); } } + +export class OidcFederationStrategy { + #inner; + + /** @param {RawOidcFederationStrategy} inner */ + constructor(inner) { + this.#inner = inner; + } + + /** + * @param {string} region + * @param {string} workspaceId + * @param {OidcProvider} getJwt + * @param {OidcFederationStrategyOptions} [options] + * @returns {OidcFederationStrategy} + */ + static create(region, workspaceId, getJwt, options) { + // Wrap `getJwt` so the wasm binding always sees a Promise-returning + // function even if the caller passed a sync one — see the note in + // `AccessKeyStrategy.create`. + const jwt = () => Promise.resolve(getJwt()); + const store = options?.store; + if (store) { + const load = () => Promise.resolve(store.load()); + const save = (/** @type {string} */ json) => + Promise.resolve(store.save(json)); + return new OidcFederationStrategy( + RawOidcFederationStrategy.createWithStore( + region, + workspaceId, + jwt, + load, + save, + ), + ); + } + return new OidcFederationStrategy( + RawOidcFederationStrategy.create(region, workspaceId, jwt), + ); + } + + /** @returns {Promise} */ + getToken() { + return this.#inner.getToken(); + } + + free() { + this.#inner.free(); + } +} diff --git a/languages/typescript/packages/auth/wasm-types.d.ts b/languages/typescript/packages/auth/wasm-types.d.ts index aac16f591..d5e6b45e8 100644 --- a/languages/typescript/packages/auth/wasm-types.d.ts +++ b/languages/typescript/packages/auth/wasm-types.d.ts @@ -39,6 +39,7 @@ export type AuthErrorCode = | 'INVALID_ACCESS_KEY' | 'INVALID_CRN' | 'WORKSPACE_MISMATCH' + | 'INVALID_WORKSPACE_ID' | 'UNKNOWN_ERROR' /** An error thrown by this package, enriched with a machine-readable `.code`. */ @@ -90,3 +91,33 @@ export declare class AccessKeyStrategy { /** Release the underlying wasm resources. */ free(): void } + +/** + * Federates a third-party OIDC JWT (Clerk, Supabase, …) into a CipherStash + * CTS service token. This is the raw bundler-target binding — consumers + * wanting the options-object / cookie-store-friendly shape should import from + * `/wasm-inline` instead. + * + * `getJwt` / `loadToken` / `saveToken` are JS callbacks returning Promises. + */ +export declare class OidcFederationStrategy { + private constructor() + /** Create an `OidcFederationStrategy` for the given region and workspace. */ + static create( + region: string, + workspaceId: string, + getJwt: () => Promise, + ): OidcFederationStrategy + /** Create an `OidcFederationStrategy` backed by external token-store callbacks. */ + static createWithStore( + region: string, + workspaceId: string, + getJwt: () => Promise, + loadToken: () => Promise, + saveToken: (json: string) => Promise, + ): OidcFederationStrategy + /** Retrieve a valid CTS service token, federating or re-federating as needed. */ + getToken(): Promise + /** Release the underlying wasm resources. */ + free(): void +} diff --git a/languages/typescript/packages/stack-auth-wasm/src/lib.rs b/languages/typescript/packages/stack-auth-wasm/src/lib.rs index 34dc94dd5..90fbc1d3d 100644 --- a/languages/typescript/packages/stack-auth-wasm/src/lib.rs +++ b/languages/typescript/packages/stack-auth-wasm/src/lib.rs @@ -1,21 +1,24 @@ //! WebAssembly bindings for `stack-auth`. //! -//! Mirrors the wasm-compatible subset of the `stack-auth-node` napi crate, -//! scoped to `AccessKeyStrategy` (M2M auth). OAuth- and profile-based -//! strategies are deliberately out of scope for the initial wasm surface — -//! they need design work around federation and token pinning that hasn't -//! happened yet. +//! Mirrors the wasm-compatible subset of the `stack-auth-node` napi crate: +//! `AccessKeyStrategy` (M2M auth) and `OidcFederationStrategy` (federating a third-party +//! OIDC JWT into a CTS service token via `/api/authorise`). The interactive +//! device-code flow and profile-store loading remain out of scope — they need +//! Node-only APIs (filesystem device identity, browser launching) that can't +//! be ported to wasm32. //! //! Targets Supabase Edge Functions and bundler consumers via //! `wasm-pack build --target bundler` / `--target deno`. use std::collections::BTreeMap; +#[cfg(target_arch = "wasm32")] +use cts_common::Region; use serde::Serialize; use serde_wasm_bindgen::Serializer; use stack_auth::{AuthError, AuthStrategy, ServiceToken}; #[cfg(target_arch = "wasm32")] -use stack_auth::{Token, TokenStore}; +use stack_auth::{OidcProvider, SecretToken, Token, TokenStore}; use wasm_bindgen::prelude::*; #[cfg(target_arch = "wasm32")] use wasm_bindgen_futures::JsFuture; @@ -151,6 +154,56 @@ fn warn_callback(name: &str, kind: &str, err: &JsValue) { web_sys::console::warn_2(&JsValue::from_str(&msg), err); } +/// `OidcProvider` adapter over a JS callback. +/// +/// `getJwt` is called with no arguments and is expected to return +/// `Promise` — the current third-party OIDC JWT to federate. Unlike +/// [`JsTokenStore`], a failure here is fatal: federation can't proceed without +/// a JWT, so it surfaces as an [`AuthError`] rather than a silent cache miss. +/// The failure is still logged via [`warn_callback`] so the JS-side cause is +/// visible. +#[cfg(target_arch = "wasm32")] +struct JsOidcProvider { + get_jwt: js_sys::Function, +} + +#[cfg(target_arch = "wasm32")] +impl OidcProvider for JsOidcProvider { + async fn fetch(&self) -> Result { + let promise = self.get_jwt.call0(&JsValue::NULL).map_err(|err| { + warn_callback("getJwt", "synchronous throw", &err); + AuthError::Server("getJwt callback threw".to_string()) + })?; + let result = JsFuture::from(js_sys::Promise::from(promise)) + .await + .map_err(|err| { + warn_callback("getJwt", "promise rejection", &err); + AuthError::Server("getJwt callback rejected".to_string()) + })?; + // Wrap in `Zeroizing` so the JWT heap buffer is wiped on drop — it + // carries the bearer credential between the JS boundary and the + // federation HTTP request. + let jwt = Zeroizing::new(result.as_string().ok_or_else(|| { + AuthError::Server("getJwt callback did not return a string".to_string()) + })?); + Ok(SecretToken::new(jwt.as_str())) + } +} + +/// Parse and validate the `region` + `workspaceId` inputs shared by both +/// `OidcFederationStrategy` factories. +#[cfg(target_arch = "wasm32")] +fn parse_oidc_inputs( + region: &str, + workspace_id: &str, +) -> Result<(Region, cts_common::WorkspaceId), JsValue> { + let region = Region::new(region).map_err(|e| to_js_error(AuthError::from(e)))?; + let workspace_id = workspace_id + .parse::() + .map_err(|e| to_js_error(AuthError::from(e)))?; + Ok((region, workspace_id)) +} + enum AccessKeyStrategyInner { NoStore(stack_auth::AccessKeyStrategy), #[cfg(target_arch = "wasm32")] @@ -239,11 +292,107 @@ impl AccessKeyStrategy { } } +/// Cfg-gated to wasm32: `OidcFederationStrategy` is generic over the JWT provider, and +/// the only provider the bindings offer (`JsOidcProvider`) wraps a +/// `js_sys::Function`, which exists only on wasm32. The native build of this +/// crate (used for `cargo clippy` / host `cargo test`) therefore has no +/// `OidcFederationStrategy` — there is nothing native-testable about a JS-callback type. +#[cfg(target_arch = "wasm32")] +enum OidcFederationStrategyInner { + NoStore(stack_auth::OidcFederationStrategy), + WithStore(stack_auth::OidcFederationStrategy), +} + +#[cfg(target_arch = "wasm32")] +impl OidcFederationStrategyInner { + async fn get_token(&self) -> Result { + match self { + Self::NoStore(s) => s.get_token().await, + Self::WithStore(s) => s.get_token().await, + } + } +} + +/// Federates a third-party OIDC JWT (Clerk, Supabase, …) into a CTS service +/// token. See the crate-level docs and `stack_auth::OidcFederationStrategy`. +#[cfg(target_arch = "wasm32")] +#[wasm_bindgen] +pub struct OidcFederationStrategy { + inner: OidcFederationStrategyInner, +} + +#[cfg(target_arch = "wasm32")] +#[wasm_bindgen] +impl OidcFederationStrategy { + /// Create an `OidcFederationStrategy` for the given region and workspace. + /// + /// `getJwt` is called on every federation — initial auth and every + /// re-federation after expiry — and must return `Promise` + /// resolving to the *current* third-party OIDC JWT (e.g. by calling + /// `clerk.session.getToken()`). + pub fn create( + region: String, + workspace_id: String, + get_jwt: js_sys::Function, + ) -> Result { + let (region, workspace_id) = parse_oidc_inputs(®ion, &workspace_id)?; + let inner = stack_auth::OidcFederationStrategy::builder( + region, + workspace_id, + JsOidcProvider { get_jwt }, + ) + .build() + .map_err(to_js_error)?; + Ok(OidcFederationStrategy { + inner: OidcFederationStrategyInner::NoStore(inner), + }) + } + + /// Create an `OidcFederationStrategy` backed by external token-store callbacks. + /// + /// Behaves like [`create`](Self::create) but persists the federated CTS + /// token through `loadToken` / `saveToken` — see + /// [`AccessKeyStrategy::create_with_store`] for the callback contract. Use + /// this to back the strategy with an HTTP-only cookie so a federated token + /// survives across Edge Function invocations without re-federating. + #[wasm_bindgen(js_name = createWithStore)] + pub fn create_with_store( + region: String, + workspace_id: String, + get_jwt: js_sys::Function, + load_token: js_sys::Function, + save_token: js_sys::Function, + ) -> Result { + let (region, workspace_id) = parse_oidc_inputs(®ion, &workspace_id)?; + let store = JsTokenStore { + load: load_token, + save: save_token, + }; + let inner = stack_auth::OidcFederationStrategy::builder( + region, + workspace_id, + JsOidcProvider { get_jwt }, + ) + .with_token_store(store) + .build() + .map_err(to_js_error)?; + Ok(OidcFederationStrategy { + inner: OidcFederationStrategyInner::WithStore(inner), + }) + } + + /// Retrieve a valid CTS service token, federating or re-federating as needed. + #[wasm_bindgen(js_name = getToken)] + pub async fn get_token(&self) -> Result { + let token = self.inner.get_token().await.map_err(to_js_error)?; + token_result_from(token) + } +} + #[cfg(all(test, target_arch = "wasm32"))] mod tests { use super::*; use base64::Engine; - use stack_auth::SecretToken; use wasm_bindgen_test::wasm_bindgen_test; /// Build an unsigned JWT-shaped token: `

..`. @@ -474,4 +623,98 @@ mod tests { // No assertion needed beyond "this doesn't panic". store.save(&token).await; } + + const VALID_WORKSPACE_ID: &str = "ZVATKW3VHMFG27DY"; + + fn jwt_fn(jwt: &str) -> js_sys::Function { + // `async () => ""` + js_sys::Function::new_no_args(&format!("return Promise.resolve('{jwt}');")) + } + + #[wasm_bindgen_test] + fn oidc_federation_strategy_rejects_invalid_region() { + let err = expect_js_err(OidcFederationStrategy::create( + "not-a-region".to_string(), + VALID_WORKSPACE_ID.to_string(), + jwt_fn("h.p.s"), + )); + assert_eq!(error_code_of(&err), "INVALID_REGION"); + } + + #[wasm_bindgen_test] + fn oidc_federation_strategy_rejects_invalid_workspace_id() { + let err = expect_js_err(OidcFederationStrategy::create( + "ap-southeast-2.aws".to_string(), + "not-a-workspace-id".to_string(), + jwt_fn("h.p.s"), + )); + assert_eq!(error_code_of(&err), "INVALID_WORKSPACE_ID"); + } + + #[wasm_bindgen_test] + fn oidc_federation_strategy_accepts_valid_inputs() { + let result = OidcFederationStrategy::create( + "ap-southeast-2.aws".to_string(), + VALID_WORKSPACE_ID.to_string(), + jwt_fn("h.p.s"), + ); + assert!(result.is_ok()); + } + + #[wasm_bindgen_test] + fn oidc_create_with_store_rejects_invalid_workspace_id() { + let err = expect_js_err(OidcFederationStrategy::create_with_store( + "ap-southeast-2.aws".to_string(), + "not-a-workspace-id".to_string(), + jwt_fn("h.p.s"), + empty_load_fn(), + noop_save_fn(), + )); + assert_eq!(error_code_of(&err), "INVALID_WORKSPACE_ID"); + } + + #[wasm_bindgen_test] + fn oidc_create_with_store_accepts_valid_inputs() { + let result = OidcFederationStrategy::create_with_store( + "ap-southeast-2.aws".to_string(), + VALID_WORKSPACE_ID.to_string(), + jwt_fn("h.p.s"), + empty_load_fn(), + noop_save_fn(), + ); + assert!(result.is_ok()); + } + + #[wasm_bindgen_test] + async fn js_oidc_provider_returns_jwt() { + let provider = JsOidcProvider { + get_jwt: jwt_fn("header.payload.signature"), + }; + let jwt = provider.fetch().await.expect("getJwt should succeed"); + assert_eq!(jwt.as_str(), "header.payload.signature"); + } + + #[wasm_bindgen_test] + async fn js_oidc_provider_errors_on_callback_throw() { + let provider = JsOidcProvider { + get_jwt: js_sys::Function::new_no_args("throw new Error('boom');"), + }; + let err = match provider.fetch().await { + Ok(_) => panic!("expected getJwt throw to surface as an error"), + Err(e) => e, + }; + assert!(matches!(err, AuthError::Server(_)), "got: {err:?}"); + } + + #[wasm_bindgen_test] + async fn js_oidc_provider_errors_on_non_string_result() { + let provider = JsOidcProvider { + get_jwt: js_sys::Function::new_no_args("return Promise.resolve(42);"), + }; + let err = match provider.fetch().await { + Ok(_) => panic!("expected non-string getJwt result to surface as an error"), + Err(e) => e, + }; + assert!(matches!(err, AuthError::Server(_)), "got: {err:?}"); + } } From 64a91d854c85f99ec8ee01b7a5194e03a8b4ff89 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Thu, 4 Jun 2026 11:42:36 +1000 Subject: [PATCH 267/686] review(stack-auth): address PR cipherstash/cipherstash-suite#2004 feedback on wasm bindings - Include the JS error detail in getJwt callback AuthError messages so a throw/rejection reports its cause, not just that it happened. - Construct SecretToken directly from the callback string instead of an intermediate Zeroizing wrapper (SecretToken is ZeroizeOnDrop). - Chain getToken via map_err/.and_then instead of an intermediate ? bind. - Fix test comment: CS_CTS_HOST is read at runtime, not build time. --- .../oidc-federation-strategy.test.ts | 3 +- .../packages/stack-auth-wasm/src/lib.rs | 40 ++++++++++++++----- 2 files changed, 32 insertions(+), 11 deletions(-) diff --git a/languages/typescript/packages/auth/__tests__/oidc-federation-strategy.test.ts b/languages/typescript/packages/auth/__tests__/oidc-federation-strategy.test.ts index 6542e110e..3d3fa8cdf 100644 --- a/languages/typescript/packages/auth/__tests__/oidc-federation-strategy.test.ts +++ b/languages/typescript/packages/auth/__tests__/oidc-federation-strategy.test.ts @@ -17,7 +17,8 @@ let savedHost: string | undefined; beforeEach(async () => { server = await MockAuthServer.start(); - // OidcFederationStrategy resolves the CTS base URL from CS_CTS_HOST at build time. + // OidcFederationStrategy reads the CTS base URL from CS_CTS_HOST at runtime, + // so set it before constructing the strategy. savedHost = process.env.CS_CTS_HOST; process.env.CS_CTS_HOST = server.baseUrl; }); diff --git a/languages/typescript/packages/stack-auth-wasm/src/lib.rs b/languages/typescript/packages/stack-auth-wasm/src/lib.rs index 90fbc1d3d..816d93b94 100644 --- a/languages/typescript/packages/stack-auth-wasm/src/lib.rs +++ b/languages/typescript/packages/stack-auth-wasm/src/lib.rs @@ -154,6 +154,20 @@ fn warn_callback(name: &str, kind: &str, err: &JsValue) { web_sys::console::warn_2(&JsValue::from_str(&msg), err); } +/// Best-effort human-readable detail for a JS error value, for embedding in an +/// [`AuthError`] message. Prefers a thrown string, then an `Error.message` +/// property, falling back to the `Debug` representation. +#[cfg(target_arch = "wasm32")] +fn js_error_detail(err: &JsValue) -> String { + err.as_string() + .or_else(|| { + js_sys::Reflect::get(err, &JsValue::from_str("message")) + .ok() + .and_then(|m| m.as_string()) + }) + .unwrap_or_else(|| format!("{err:?}")) +} + /// `OidcProvider` adapter over a JS callback. /// /// `getJwt` is called with no arguments and is expected to return @@ -172,21 +186,24 @@ impl OidcProvider for JsOidcProvider { async fn fetch(&self) -> Result { let promise = self.get_jwt.call0(&JsValue::NULL).map_err(|err| { warn_callback("getJwt", "synchronous throw", &err); - AuthError::Server("getJwt callback threw".to_string()) + AuthError::Server(format!("getJwt callback threw: {}", js_error_detail(&err))) })?; let result = JsFuture::from(js_sys::Promise::from(promise)) .await .map_err(|err| { warn_callback("getJwt", "promise rejection", &err); - AuthError::Server("getJwt callback rejected".to_string()) + AuthError::Server(format!( + "getJwt callback rejected: {}", + js_error_detail(&err) + )) })?; - // Wrap in `Zeroizing` so the JWT heap buffer is wiped on drop — it - // carries the bearer credential between the JS boundary and the - // federation HTTP request. - let jwt = Zeroizing::new(result.as_string().ok_or_else(|| { + // `SecretToken` owns the JWT string and zeroes its heap buffer on drop + // (it's `ZeroizeOnDrop`) — it carries the bearer credential between the + // JS boundary and the federation HTTP request. + let jwt = result.as_string().ok_or_else(|| { AuthError::Server("getJwt callback did not return a string".to_string()) - })?); - Ok(SecretToken::new(jwt.as_str())) + })?; + Ok(SecretToken::new(jwt)) } } @@ -384,8 +401,11 @@ impl OidcFederationStrategy { /// Retrieve a valid CTS service token, federating or re-federating as needed. #[wasm_bindgen(js_name = getToken)] pub async fn get_token(&self) -> Result { - let token = self.inner.get_token().await.map_err(to_js_error)?; - token_result_from(token) + self.inner + .get_token() + .await + .map_err(to_js_error) + .and_then(token_result_from) } } From 91256064695024cde3d88af1f7e94b4ae4cbc340 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Thu, 4 Jun 2026 14:48:40 +0800 Subject: [PATCH 268/686] review(stack-auth): address Toby + Lindsay feedback on napi binding Toby (code divergences): - Dedupe error-code mapping: to_napi_error now delegates to the canonical AuthError::error_code() in stack-auth instead of a duplicated match, the same way the wasm binding's to_js_error does. - Log getJwt callback failures in NapiOidcProvider::fetch via a stderr warn_callback, mirroring the wasm binding (previously errors surfaced with no breadcrumb). Also construct SecretToken directly (drop intermediate Zeroizing) for parity with the wasm fix. - Defensively wrap getJwt/loadToken/saveToken in Promise.resolve in index.js so sync callbacks work, matching the wasm-inline wrapper. Lindsay (test coverage gaps): - malformed stored-token JSON re-federates (the from_str().ok() -> None miss). - non-string getJwt result surfaces as SERVER_ERROR (napi twin of the wasm js_oidc_provider_errors_on_non_string_result test). - federation /api/authorise 500 rejects with an enriched .code. All 18 node Rust tests + 37 vitest tests green. --- .../oidc-federation-strategy.test.ts | 54 ++++++++++ languages/typescript/packages/auth/index.js | 25 ++++- languages/typescript/packages/auth/src/lib.rs | 100 +++++++++--------- 3 files changed, 123 insertions(+), 56 deletions(-) diff --git a/languages/typescript/packages/auth/__tests__/oidc-federation-strategy.test.ts b/languages/typescript/packages/auth/__tests__/oidc-federation-strategy.test.ts index 3d3fa8cdf..f1ec6e9ad 100644 --- a/languages/typescript/packages/auth/__tests__/oidc-federation-strategy.test.ts +++ b/languages/typescript/packages/auth/__tests__/oidc-federation-strategy.test.ts @@ -166,4 +166,58 @@ describe("OidcFederationStrategy (TypeScript / vitest)", () => { const result = await second.getToken(); expect(result.workspaceId).toBe(WORKSPACE_ID); }); + + it("re-federates when the stored token JSON is malformed", async () => { + // A corrupt cookie/store value must be treated as a cache miss (the + // `serde_json::from_str(..).ok()` → None branch), not panic — so federation + // runs fresh. A version that `unwrap()`ed the parse would fail this. + server.mockAuthorizeEndpoint(); + const jwt = countingJwt(); + const strategy = OidcFederationStrategy.createWithStore( + REGION, + WORKSPACE_ID, + jwt.getJwt, + () => Promise.resolve("}{ not json"), + (_json: string) => Promise.resolve(), + ); + + await strategy.getToken(); + + // Garbage cache discarded → exactly one fresh federation. + expect(jwt.calls()).toBe(1); + }); + + it("surfaces a non-string getJwt result as an error with .code", async () => { + // Mirrors the wasm `js_oidc_provider_errors_on_non_string_result` test: + // a `Promise` fails napi's `Promise` coercion and must + // surface as a clean SERVER_ERROR rejection, not a panic or hung promise. + server.mockAuthorizeEndpoint(); + const strategy = OidcFederationStrategy.create(REGION, WORKSPACE_ID, () => + Promise.resolve(42 as unknown as string), + ); + + try { + await strategy.getToken(); + expect.unreachable("getToken should reject on a non-string getJwt result"); + } catch (err) { + expect((err as AuthError).code).toBe("SERVER_ERROR"); + } + }); + + it("surfaces a federation server error with .code", async () => { + // Negative twin of the happy path: a real federation request reaching + // /api/authorise and getting a 500 must reject with an enriched `.code`, + // not resolve or throw an un-coded error. + server.mockAuthorizeEndpointError(); + const strategy = OidcFederationStrategy.create(REGION, WORKSPACE_ID, () => + Promise.resolve("header.payload.signature"), + ); + + try { + await strategy.getToken(); + expect.unreachable("getToken should reject when /api/authorise 500s"); + } catch (err) { + expect(err as AuthError).toHaveProperty("code"); + } + }); }); diff --git a/languages/typescript/packages/auth/index.js b/languages/typescript/packages/auth/index.js index 316e5d8b3..e7a8b1231 100644 --- a/languages/typescript/packages/auth/index.js +++ b/languages/typescript/packages/auth/index.js @@ -75,13 +75,30 @@ native.OAuthStrategy.fromProfile = wrapSync(origFromProfile); // `getToken()` is already enriched via the prototype patch above. const NativeOidcFederationStrategy = native.OidcFederationStrategy; class OidcFederationStrategy { - static create(...args) { - return wrapSync(() => NativeOidcFederationStrategy.create(...args))(); + static create(region, workspaceId, getJwt) { + // Wrap `getJwt` so the napi binding always sees a Promise-returning + // function even if the caller passed a sync one — the native side coerces + // the return to `Promise`. Matches the wasm wrapper (wasm-inline.mjs). + const jwt = () => Promise.resolve(getJwt()); + return wrapSync(() => + NativeOidcFederationStrategy.create(region, workspaceId, jwt), + )(); } - static createWithStore(...args) { + static createWithStore(region, workspaceId, getJwt, loadToken, saveToken) { + // Same defensive wrap for all three callbacks, so sync implementations + // (e.g. an in-memory store) work without the caller pre-wrapping them. + const jwt = () => Promise.resolve(getJwt()); + const load = () => Promise.resolve(loadToken()); + const save = (json) => Promise.resolve(saveToken(json)); return wrapSync(() => - NativeOidcFederationStrategy.createWithStore(...args), + NativeOidcFederationStrategy.createWithStore( + region, + workspaceId, + jwt, + load, + save, + ), )(); } } diff --git a/languages/typescript/packages/auth/src/lib.rs b/languages/typescript/packages/auth/src/lib.rs index 47a2a9a6f..414ab8fec 100644 --- a/languages/typescript/packages/auth/src/lib.rs +++ b/languages/typescript/packages/auth/src/lib.rs @@ -20,33 +20,20 @@ mod mock_auth_server; // Error helpers // --------------------------------------------------------------------------- -fn error_code(err: &AuthError) -> &'static str { - match err { - AuthError::Request(_) => "REQUEST_ERROR", - AuthError::AccessDenied => "ACCESS_DENIED", - AuthError::TokenExpired => "EXPIRED_TOKEN", - AuthError::InvalidGrant => "INVALID_GRANT", - AuthError::InvalidClient => "INVALID_CLIENT", - AuthError::InvalidUrl(_) => "INVALID_URL", - AuthError::Region(_) => "INVALID_REGION", - AuthError::InvalidToken(_) => "INVALID_TOKEN", - AuthError::Server(_) => "SERVER_ERROR", - AuthError::Store(_) => "STORE_ERROR", - AuthError::NotAuthenticated => "NOT_AUTHENTICATED", - AuthError::MissingWorkspaceCrn => "MISSING_WORKSPACE_CRN", - AuthError::InvalidAccessKey(_) => "INVALID_ACCESS_KEY", - AuthError::InvalidCrn(_) => "INVALID_CRN", - AuthError::WorkspaceMismatch { .. } => "WORKSPACE_MISMATCH", - AuthError::InvalidWorkspaceId(_) => "INVALID_WORKSPACE_ID", - _ => "UNKNOWN_ERROR", - } -} - fn to_napi_error(err: AuthError) -> napi::Error { - let code = error_code(&err); + // Delegate to the canonical `AuthError::error_code` mapping in `stack-auth` + // rather than re-deriving it here — mirrors the wasm binding's `to_js_error`. + // The `CODE: message` format is parsed back into an `Error.code` by index.js. + let code = err.error_code(); napi::Error::new(Status::GenericFailure, format!("{code}: {err}")) } +/// Surface a JS callback failure on stderr so it isn't silently swallowed — +/// the node counterpart to the wasm binding's `warn_callback` (`console.warn`). +fn warn_callback(name: &str, detail: &str) { + eprintln!("stack-auth: {name} {detail}"); +} + // --------------------------------------------------------------------------- // TokenResult — returned by strategy.getToken() // --------------------------------------------------------------------------- @@ -260,20 +247,26 @@ impl OidcProvider for NapiOidcProvider { Ok(()) }, ); + // A `getJwt` failure is fatal — federation can't proceed without a JWT — + // so each arm logs the JS-side cause before surfacing the `AuthError`, + // mirroring the wasm binding's `warn_callback`. Without this the error + // reaches the caller with no breadcrumb of *why* the callback failed. if status != Status::Ok { - return Err(AuthError::Server(format!( - "getJwt callback dispatch failed: {status:?}" - ))); + let detail = format!("callback dispatch failed: {status:?}"); + warn_callback("getJwt", &detail); + return Err(AuthError::Server(format!("getJwt {detail}"))); } - let promise = rx - .await - .map_err(|_| AuthError::Server("getJwt callback did not run".to_string()))?; - let jwt = Zeroizing::new( - promise - .await - .map_err(|e| AuthError::Server(format!("getJwt rejected: {e}")))?, - ); - Ok(SecretToken::new(jwt.as_str())) + let promise = rx.await.map_err(|_| { + warn_callback("getJwt", "callback did not run"); + AuthError::Server("getJwt callback did not run".to_string()) + })?; + // `SecretToken` owns the JWT and zeroes it on drop (it's `ZeroizeOnDrop`), + // so the awaited `String` moves straight in — no intermediate `Zeroizing`. + let jwt = promise.await.map_err(|e| { + warn_callback("getJwt", &format!("promise rejected: {e}")); + AuthError::Server(format!("getJwt rejected: {e}")) + })?; + Ok(SecretToken::new(jwt)) } } @@ -534,7 +527,7 @@ impl DeviceCodeResult { fn device_client_error_code(err: &DeviceClientError) -> &'static str { match err { DeviceClientError::Profile(_) => "STORE_ERROR", - DeviceClientError::Auth(auth_err) => error_code(auth_err), + DeviceClientError::Auth(auth_err) => auth_err.error_code(), DeviceClientError::Request(_) => "REQUEST_ERROR", DeviceClientError::Server { .. } => "SERVER_ERROR", DeviceClientError::InvalidUrl(_) => "INVALID_URL", @@ -727,66 +720,69 @@ mod tests { mod error_mapping { use super::*; + // Pins the exact `AuthError::error_code` strings the napi FFI contract + // depends on: `to_napi_error` embeds them as the `CODE:` prefix that + // index.js parses back into `Error.code`. The mapping itself lives in + // `stack-auth`; this guards that the codes the JS wrapper keys on can't + // drift without a failing test here. #[test] fn maps_all_auth_error_variants() { assert_eq!( - error_code(&AuthError::AccessDenied), + AuthError::AccessDenied.error_code(), "ACCESS_DENIED", "AccessDenied should map to ACCESS_DENIED" ); assert_eq!( - error_code(&AuthError::TokenExpired), + AuthError::TokenExpired.error_code(), "EXPIRED_TOKEN", "TokenExpired should map to EXPIRED_TOKEN" ); assert_eq!( - error_code(&AuthError::InvalidGrant), + AuthError::InvalidGrant.error_code(), "INVALID_GRANT", "InvalidGrant should map to INVALID_GRANT" ); assert_eq!( - error_code(&AuthError::InvalidClient), + AuthError::InvalidClient.error_code(), "INVALID_CLIENT", "InvalidClient should map to INVALID_CLIENT" ); assert_eq!( - error_code(&AuthError::InvalidUrl( - "http://[".parse::().unwrap_err() - )), + AuthError::InvalidUrl("http://[".parse::().unwrap_err()).error_code(), "INVALID_URL", "InvalidUrl should map to INVALID_URL" ); assert_eq!( - error_code(&AuthError::Region(Region::new("invalid").unwrap_err())), + AuthError::Region(Region::new("invalid").unwrap_err()).error_code(), "INVALID_REGION", "Region should map to INVALID_REGION" ); assert_eq!( - error_code(&AuthError::Server("test".to_string())), + AuthError::Server("test".to_string()).error_code(), "SERVER_ERROR", "Server should map to SERVER_ERROR" ); assert_eq!( - error_code(&AuthError::NotAuthenticated), + AuthError::NotAuthenticated.error_code(), "NOT_AUTHENTICATED", "NotAuthenticated should map to NOT_AUTHENTICATED" ); assert_eq!( - error_code(&AuthError::MissingWorkspaceCrn), + AuthError::MissingWorkspaceCrn.error_code(), "MISSING_WORKSPACE_CRN", "MissingWorkspaceCrn should map to MISSING_WORKSPACE_CRN" ); assert_eq!( - error_code(&AuthError::InvalidAccessKey( + AuthError::InvalidAccessKey( "bad-key".parse::().unwrap_err() - )), + ) + .error_code(), "INVALID_ACCESS_KEY", "InvalidAccessKey should map to INVALID_ACCESS_KEY" ); assert_eq!( - error_code(&AuthError::InvalidCrn( - "not-a-crn".parse::().unwrap_err() - )), + AuthError::InvalidCrn("not-a-crn".parse::().unwrap_err()) + .error_code(), "INVALID_CRN", "InvalidCrn should map to INVALID_CRN" ); From a7be8ae6565fb3a22708c2a2439698b10b265e15 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Mon, 1 Jun 2026 10:56:44 +1000 Subject: [PATCH 269/686] refactor(stack-auth)!: rename OAuthStrategy to DeviceSessionStrategy Completes the federation-vs-renewal naming begun with OidcFederationStrategy. `OAuthStrategy` *renews* a CTS session minted by the interactive device-code login (via its CTS refresh token); the name didn't convey that, and read as a peer of the OIDC-federation strategy when it's a different thing. `DeviceSessionStrategy` names the behaviour. Renames the Rust type + builder, the `oauth_strategy` / `oauth_refresher` modules (now `device_session_*`), `OAuthRefresher`, the `AutoStrategy::OAuth` variant (now `::DeviceSession`), the napi binding class, JS wrappers, and generated `.d.ts`. Also updates downstream consumers (cipherstash-client, cipherstash-cli). Backward compatibility: `OAuthStrategy` / `OAuthStrategyBuilder` remain as `#[deprecated]` type aliases (Rust) and a `module.exports.OAuthStrategy` alias (Node), so existing consumers keep working. Removed in a future major. Stacked on the OidcFederationStrategy bindings PR. Verified: cargo build/clippy (-D warnings) + 128 lib + 20 doc tests on stack-auth; clippy on cipherstash-cli + cipherstash-client; full Node vitest suite (34 tests) green. --- languages/typescript/packages/auth/index.d.ts | 6 +- languages/typescript/packages/auth/index.js | 9 ++- languages/typescript/packages/auth/src/lib.rs | 12 ++-- .../typescript/packages/auth/wasm-types.d.ts | 2 +- packages/stack-auth/Cargo.toml | 2 +- packages/stack-auth/README.md | 6 +- packages/stack-auth/examples/auto_strategy.rs | 6 +- packages/stack-auth/src/auto_refresh.rs | 23 ++++--- packages/stack-auth/src/auto_strategy.rs | 16 ++--- ...fresher.rs => device_session_refresher.rs} | 18 +++--- ...strategy.rs => device_session_strategy.rs} | 60 ++++++++++--------- packages/stack-auth/src/lib.rs | 32 +++++++--- packages/stack-auth/src/token.rs | 2 +- 13 files changed, 110 insertions(+), 84 deletions(-) rename packages/stack-auth/src/{oauth_refresher.rs => device_session_refresher.rs} (96%) rename packages/stack-auth/src/{oauth_strategy.rs => device_session_strategy.rs} (74%) diff --git a/languages/typescript/packages/auth/index.d.ts b/languages/typescript/packages/auth/index.d.ts index 520577e69..2ccd7262c 100644 --- a/languages/typescript/packages/auth/index.d.ts +++ b/languages/typescript/packages/auth/index.d.ts @@ -190,9 +190,9 @@ export declare class AccessKeyStrategy { * An auth strategy that uses OAuth refresh tokens persisted to disk * (`~/.cipherstash/auth.json`). */ -export declare class OAuthStrategy { - /** Load credentials from the default profile store and create an `OAuthStrategy`. */ - static fromProfile(): OAuthStrategy +export declare class DeviceSessionStrategy { + /** Load credentials from the default profile store and create a `DeviceSessionStrategy`. */ + static fromProfile(): DeviceSessionStrategy /** Retrieve a valid access token, refreshing as needed. */ getToken(): Promise } diff --git a/languages/typescript/packages/auth/index.js b/languages/typescript/packages/auth/index.js index e7a8b1231..e82455942 100644 --- a/languages/typescript/packages/auth/index.js +++ b/languages/typescript/packages/auth/index.js @@ -52,7 +52,7 @@ dcProto.openInBrowser = wrapSync(dcProto.openInBrowser); for (const Strategy of [ native.AutoStrategy, native.AccessKeyStrategy, - native.OAuthStrategy, + native.DeviceSessionStrategy, native.OidcFederationStrategy, ]) { Strategy.prototype.getToken = wrapAsync(Strategy.prototype.getToken); @@ -65,8 +65,8 @@ native.AutoStrategy.detect = wrapSync(origDetect); const origCreate = native.AccessKeyStrategy.create; native.AccessKeyStrategy.create = wrapSync(origCreate); -const origFromProfile = native.OAuthStrategy.fromProfile; -native.OAuthStrategy.fromProfile = wrapSync(origFromProfile); +const origFromProfile = native.DeviceSessionStrategy.fromProfile; +native.DeviceSessionStrategy.fromProfile = wrapSync(origFromProfile); // napi defines class static methods as non-writable, so a factory's // synchronously-thrown errors can't be `.code`-enriched by patching the @@ -107,6 +107,9 @@ class OidcFederationStrategy { module.exports = { ...native, OidcFederationStrategy, + // Deprecated alias: `OAuthStrategy` was renamed to `DeviceSessionStrategy`. + // Kept so existing consumers don't break; remove in a future major. + OAuthStrategy: native.DeviceSessionStrategy, beginDeviceCodeFlow: wrapAsync(native.beginDeviceCodeFlow), bindClientDevice: wrapAsync(native.bindClientDevice), }; diff --git a/languages/typescript/packages/auth/src/lib.rs b/languages/typescript/packages/auth/src/lib.rs index 414ab8fec..6cce20c38 100644 --- a/languages/typescript/packages/auth/src/lib.rs +++ b/languages/typescript/packages/auth/src/lib.rs @@ -178,24 +178,24 @@ impl AccessKeyStrategy { } // --------------------------------------------------------------------------- -// OAuthStrategy — OAuth with profile store +// DeviceSessionStrategy — OAuth with profile store // --------------------------------------------------------------------------- /// An auth strategy that uses OAuth refresh tokens persisted to disk /// (`~/.cipherstash/auth.json`). #[napi] -pub struct OAuthStrategy { - inner: stack_auth::OAuthStrategy, +pub struct DeviceSessionStrategy { + inner: stack_auth::DeviceSessionStrategy, } #[napi] -impl OAuthStrategy { - /// Load credentials from the default profile store and create an `OAuthStrategy`. +impl DeviceSessionStrategy { + /// Load credentials from the default profile store and create a `DeviceSessionStrategy`. #[napi(factory)] pub fn from_profile() -> Result { let store = stack_profile::ProfileStore::resolve(None) .map_err(|e| to_napi_error(AuthError::from(e)))?; - let inner = stack_auth::OAuthStrategy::with_profile(store) + let inner = stack_auth::DeviceSessionStrategy::with_profile(store) .build() .map_err(to_napi_error)?; Ok(Self { inner }) diff --git a/languages/typescript/packages/auth/wasm-types.d.ts b/languages/typescript/packages/auth/wasm-types.d.ts index d5e6b45e8..1fd49b93f 100644 --- a/languages/typescript/packages/auth/wasm-types.d.ts +++ b/languages/typescript/packages/auth/wasm-types.d.ts @@ -19,7 +19,7 @@ * The Node entry uses `index.d.ts`, which exposes the full surface * (including filesystem- and browser-backed features like the device-code * flow and profile-store loading) that doesn't compile to wasm32. - * OAuth-based strategies on wasm (`OAuthStrategy`, `AutoStrategy`) are + * OAuth-based strategies on wasm (`DeviceSessionStrategy`, `AutoStrategy`) are * deferred to a follow-up — see the Layer 3.5 notes in `wasm-analysis.md`. */ diff --git a/packages/stack-auth/Cargo.toml b/packages/stack-auth/Cargo.toml index 260d232d7..912af65cc 100644 --- a/packages/stack-auth/Cargo.toml +++ b/packages/stack-auth/Cargo.toml @@ -38,7 +38,7 @@ zeroize = { workspace = true } # compile on wasm32. Workspace dep is `features = ["full"]` so we can't # subtract — split target-conditionally instead. # -# Wasm consumers use `OAuthStrategy::with_token` (in-memory) or +# Wasm consumers use `DeviceSessionStrategy::with_token` (in-memory) or # `AccessKeyStrategy`, and JWT claim decoding falls back to a manual # base64+JSON path that doesn't need `ring`. [target.'cfg(not(target_arch = "wasm32"))'.dependencies] diff --git a/packages/stack-auth/README.md b/packages/stack-auth/README.md index 4037d99a7..2072b863b 100644 --- a/packages/stack-auth/README.md +++ b/packages/stack-auth/README.md @@ -18,7 +18,7 @@ All strategies implement the [`AuthStrategy`] trait, which provides a single |---|---|---| | [`AutoStrategy`] | Recommended default — detects credentials automatically | `CS_CLIENT_ACCESS_KEY` + `CS_WORKSPACE_CRN`, or `~/.cipherstash/auth.json` | | [`AccessKeyStrategy`] | Service-to-service / CI | Static access key + workspace CRN | -| [`OAuthStrategy`] | Long-lived sessions with refresh | OAuth token (from device code flow or disk) | +| [`DeviceSessionStrategy`] | Long-lived sessions with refresh | OAuth token (from device code flow or disk) | | [`DeviceCodeStrategy`] | CLI login ([RFC 8628](https://datatracker.ietf.org/doc/html/rfc8628)) | User authorizes in browser | | `StaticTokenStrategy` | Tests only (`test-utils` feature) | Pre-obtained token used as-is | @@ -58,7 +58,7 @@ let strategy = AccessKeyStrategy::new(crn, key)?; ┌──────────────────────────────────────────────────┐ │ AuthStrategy ─ acquisition layer │ │ get_token() -> ServiceToken │ - │ AccessKeyStrategy / OAuthStrategy / AutoStrategy│ + │ AccessKeyStrategy / DeviceSessionStrategy / AutoStrategy│ │ ── or ── │ │ AuthStrategyFn (closure → AuthStrategy) │ └────────────────────────┬─────────────────────────┘ @@ -93,7 +93,7 @@ leaks in logs. ## Token refresh -All strategies that cache tokens ([`AccessKeyStrategy`], [`OAuthStrategy`], +All strategies that cache tokens ([`AccessKeyStrategy`], [`DeviceSessionStrategy`], [`AutoStrategy`]) share the same internal refresh engine. See the [`AuthStrategy`] trait docs for a full description of the concurrency model and flow diagram. diff --git a/packages/stack-auth/examples/auto_strategy.rs b/packages/stack-auth/examples/auto_strategy.rs index 0b74df06e..1872d73cc 100644 --- a/packages/stack-auth/examples/auto_strategy.rs +++ b/packages/stack-auth/examples/auto_strategy.rs @@ -7,7 +7,7 @@ //! 1. **Access key** – if `CS_CLIENT_ACCESS_KEY` is set along with //! `CS_WORKSPACE_CRN`, an [`AccessKeyStrategy`] is used. //! 2. **OAuth** – if a token store file exists at `~/.cipherstash/auth.json` -//! (written by `stash login`), an [`OAuthStrategy`] is used. +//! (written by `stash login`), a [`DeviceSessionStrategy`] is used. //! 3. If neither is available, an error is returned. //! //! # Running the example @@ -34,13 +34,13 @@ async fn main() -> Result<(), Box> { // AutoStrategy detects credentials automatically: // // 1. CS_CLIENT_ACCESS_KEY env var → AccessKeyStrategy - // 2. ~/.cipherstash/auth.json file → OAuthStrategy + // 2. ~/.cipherstash/auth.json file → DeviceSessionStrategy // 3. Neither → error let strategy = AutoStrategy::detect()?; match &strategy { AutoStrategy::AccessKey(_) => println!("Using access key authentication"), - AutoStrategy::OAuth(_) => println!("Using OAuth authentication"), + AutoStrategy::DeviceSession(_) => println!("Using OAuth authentication"), } // Obtain a token — refresh happens automatically when needed. diff --git a/packages/stack-auth/src/auto_refresh.rs b/packages/stack-auth/src/auto_refresh.rs index 57d4b38ce..6c2b77448 100644 --- a/packages/stack-auth/src/auto_refresh.rs +++ b/packages/stack-auth/src/auto_refresh.rs @@ -348,7 +348,7 @@ impl AutoRefresh { #[allow(clippy::unwrap_used)] mod tests { use super::*; - use crate::oauth_refresher::OAuthRefresher; + use crate::device_session_refresher::DeviceSessionRefresher; use crate::SecretToken; use mocktail::prelude::*; use stack_profile::ProfileStore; @@ -402,12 +402,12 @@ mod tests { dir: &tempfile::TempDir, server: &MockServer, token: Token, - ) -> AutoRefresh { + ) -> AutoRefresh { let store = ProfileStore::new(dir.path()); store.init_workspace("ZVATKW3VHMFG27DY").unwrap(); let ws_store = store.current_workspace_store().unwrap(); ws_store.save_profile(&token).unwrap(); - let refresher = OAuthRefresher::new( + let refresher = DeviceSessionRefresher::new( Some(ws_store), server.url(""), "cli", @@ -424,7 +424,7 @@ mod tests { async fn returns_not_found_for_oauth() { let server = start_server(MockSet::new()).await; let store = ProfileStore::new("/tmp/nonexistent"); - let refresher = OAuthRefresher::new( + let refresher = DeviceSessionRefresher::new( Some(store), server.url(""), "cli", @@ -904,7 +904,7 @@ mod tests { #[allow(clippy::unwrap_used)] mod stress_tests { use super::*; - use crate::oauth_refresher::OAuthRefresher; + use crate::device_session_refresher::DeviceSessionRefresher; use crate::SecretToken; use stack_profile::ProfileStore; use std::sync::atomic::{AtomicUsize, Ordering}; @@ -1028,12 +1028,12 @@ mod stress_tests { dir: &tempfile::TempDir, base_url: &url::Url, token: Token, - ) -> AutoRefresh { + ) -> AutoRefresh { let store = ProfileStore::new(dir.path()); store.init_workspace("ZVATKW3VHMFG27DY").unwrap(); let ws_store = store.current_workspace_store().unwrap(); ws_store.save_profile(&token).unwrap(); - let refresher = OAuthRefresher::new( + let refresher = DeviceSessionRefresher::new( Some(ws_store), base_url.clone(), "cli", @@ -1492,8 +1492,13 @@ mod stress_tests { let store = ProfileStore::new(dir.path()); store.init_workspace("ZVATKW3VHMFG27DY").unwrap(); let ws_store = store.current_workspace_store().unwrap(); - let refresher = - OAuthRefresher::new(Some(ws_store), base_url, "cli", "ap-southeast-2.aws", None); + let refresher = DeviceSessionRefresher::new( + Some(ws_store), + base_url, + "cli", + "ap-southeast-2.aws", + None, + ); // Slow async save — cancellation reliably lands here, in the // post-HTTP / pre-install window. let slow_store = SlowSaveStore { diff --git a/packages/stack-auth/src/auto_strategy.rs b/packages/stack-auth/src/auto_strategy.rs index eefb15395..f634233a9 100644 --- a/packages/stack-auth/src/auto_strategy.rs +++ b/packages/stack-auth/src/auto_strategy.rs @@ -1,7 +1,7 @@ use cts_common::Crn; use crate::access_key_strategy::AccessKeyStrategy; -use crate::oauth_strategy::OAuthStrategy; +use crate::device_session_strategy::DeviceSessionStrategy; #[cfg(not(target_arch = "wasm32"))] use stack_profile::ProfileStore; @@ -21,7 +21,7 @@ use crate::{AuthError, AuthStrategy, ServiceToken}; /// its region drives service discovery and its workspace ID is used /// to verify every issued token. /// 2. If a token store file exists at the default location -/// (`~/.cipherstash/auth.json`), an [`OAuthStrategy`] is created from it. +/// (`~/.cipherstash/auth.json`), a [`DeviceSessionStrategy`] is created from it. /// 3. Otherwise, [`AuthError::NotAuthenticated`] is returned. /// /// # Examples @@ -53,7 +53,7 @@ pub enum AutoStrategy { /// Authenticated via a static access key. AccessKey(AccessKeyStrategy), /// Authenticated via OAuth tokens persisted on disk. - OAuth(OAuthStrategy), + DeviceSession(DeviceSessionStrategy), } impl AutoStrategy { @@ -90,7 +90,7 @@ impl AutoStrategy { /// /// Resolution order: /// 1. `CS_CLIENT_ACCESS_KEY` env var → [`AccessKeyStrategy`] - /// 2. `~/.cipherstash/auth.json` → [`OAuthStrategy`] + /// 2. `~/.cipherstash/auth.json` → [`DeviceSessionStrategy`] /// 3. [`AuthError::NotAuthenticated`] pub fn detect() -> Result { Self::builder().detect() @@ -122,8 +122,8 @@ impl AutoStrategy { .map(|ws| ws.exists_profile::()) .unwrap_or(false); if has_token { - let strategy = OAuthStrategy::with_profile(store).build()?; - return Ok(Self::OAuth(strategy)); + let strategy = DeviceSessionStrategy::with_profile(store).build()?; + return Ok(Self::DeviceSession(strategy)); } } @@ -225,7 +225,7 @@ impl AuthStrategy for &AutoStrategy { async fn get_token(self) -> Result { match self { AutoStrategy::AccessKey(inner) => inner.get_token().await, - AutoStrategy::OAuth(inner) => inner.get_token().await, + AutoStrategy::DeviceSession(inner) => inner.get_token().await, } } } @@ -319,7 +319,7 @@ mod tests { let result = AutoStrategy::detect_inner(None, None, Some(store)); assert!(result.is_ok()); - assert!(matches!(result.unwrap(), AutoStrategy::OAuth(_))); + assert!(matches!(result.unwrap(), AutoStrategy::DeviceSession(_))); } #[test] diff --git a/packages/stack-auth/src/oauth_refresher.rs b/packages/stack-auth/src/device_session_refresher.rs similarity index 96% rename from packages/stack-auth/src/oauth_refresher.rs rename to packages/stack-auth/src/device_session_refresher.rs index 824efc34e..122b017e1 100644 --- a/packages/stack-auth/src/oauth_refresher.rs +++ b/packages/stack-auth/src/device_session_refresher.rs @@ -11,7 +11,7 @@ use crate::{AuthError, SecretToken, Token}; /// Optionally owns a [`ProfileStore`] for persisting refreshed tokens to disk /// (native targets only). When the store is `None` — or always on wasm32 — /// tokens are cached in memory only. -pub(crate) struct OAuthRefresher { +pub(crate) struct DeviceSessionRefresher { #[cfg(not(target_arch = "wasm32"))] store: Option, base_url: Url, @@ -20,7 +20,7 @@ pub(crate) struct OAuthRefresher { device_instance_id: Option, } -impl OAuthRefresher { +impl DeviceSessionRefresher { #[cfg(not(target_arch = "wasm32"))] pub(crate) fn new( store: Option, @@ -55,7 +55,7 @@ impl OAuthRefresher { } } -impl Refresher for OAuthRefresher { +impl Refresher for DeviceSessionRefresher { type Credential = SecretToken; fn save(&self, _token: &Token) { @@ -126,7 +126,7 @@ impl Refresher for OAuthRefresher { } #[cfg(not(target_arch = "wasm32"))] -impl OAuthRefresher { +impl DeviceSessionRefresher { /// Acquire the cross-process refresh lock on `auth.json`, off the async /// runtime thread so we don't block other tasks. Returns `None` when no /// `ProfileStore` is configured (in-memory refreshers can't race against @@ -214,12 +214,12 @@ mod tests { dir: &tempfile::TempDir, base_url: Url, on_disk: Token, - ) -> OAuthRefresher { + ) -> DeviceSessionRefresher { let store = ProfileStore::new(dir.path()); store.init_workspace(WORKSPACE_ID).unwrap(); let ws_store = store.current_workspace_store().unwrap(); ws_store.save_profile(&on_disk).unwrap(); - OAuthRefresher::new(Some(ws_store), base_url, "cli", "ap-southeast-2.aws", None) + DeviceSessionRefresher::new(Some(ws_store), base_url, "cli", "ap-southeast-2.aws", None) } /// If disk holds a different refresh token than the credential we're @@ -344,16 +344,16 @@ mod tests { .save_profile(&token_on_disk("old-access", "shared-refresh")) .unwrap(); - // Two separate OAuthRefresher instances sharing the same on-disk + // Two separate DeviceSessionRefresher instances sharing the same on-disk // profile — same shape as two processes with the same ~/.cipherstash. - let r1 = Arc::new(OAuthRefresher::new( + let r1 = Arc::new(DeviceSessionRefresher::new( Some(ws_store.clone()), base_url.clone(), "cli", "ap-southeast-2.aws", None, )); - let r2 = Arc::new(OAuthRefresher::new( + let r2 = Arc::new(DeviceSessionRefresher::new( Some(ws_store), base_url, "cli", diff --git a/packages/stack-auth/src/oauth_strategy.rs b/packages/stack-auth/src/device_session_strategy.rs similarity index 74% rename from packages/stack-auth/src/oauth_strategy.rs rename to packages/stack-auth/src/device_session_strategy.rs index 045d9b5cf..367152136 100644 --- a/packages/stack-auth/src/oauth_strategy.rs +++ b/packages/stack-auth/src/device_session_strategy.rs @@ -5,37 +5,41 @@ use tracing::warn; use stack_profile::ProfileStore; use crate::auto_refresh::AutoRefresh; -use crate::oauth_refresher::OAuthRefresher; +use crate::device_session_refresher::DeviceSessionRefresher; use crate::{ensure_trailing_slash, AuthError, AuthStrategy, ServiceToken, Token}; -/// An [`AuthStrategy`] that uses OAuth refresh tokens to maintain a valid access token. +/// An [`AuthStrategy`] that renews a CTS session minted by an interactive +/// OAuth login (the device-code flow), using its OAuth refresh token. +/// +/// This *renews* an existing CTS session — it cannot federate a raw +/// third-party JWT. For that, see [`OidcFederationStrategy`](crate::OidcFederationStrategy). /// /// # Construction /// -/// Use [`OAuthStrategy::with_token`] with a token obtained from a device code flow +/// Use [`DeviceSessionStrategy::with_token`] with a token obtained from a device code flow /// (or any other OAuth flow) for in-memory caching only. Use -/// [`OAuthStrategy::with_profile`] to load a token from disk and persist +/// [`DeviceSessionStrategy::with_profile`] to load a token from disk and persist /// refreshed tokens back to the store. /// /// # Example /// /// ```no_run -/// use stack_auth::{OAuthStrategy, Token}; +/// use stack_auth::{DeviceSessionStrategy, Token}; /// use cts_common::Region; /// /// # fn run(token: Token) -> Result<(), Box> { /// let region = Region::aws("ap-southeast-2")?; -/// let strategy = OAuthStrategy::with_token(region, "my-client-id", token).build()?; +/// let strategy = DeviceSessionStrategy::with_token(region, "my-client-id", token).build()?; /// # Ok(()) /// # } /// ``` -pub struct OAuthStrategy { +pub struct DeviceSessionStrategy { crn: Option, - inner: AutoRefresh, + inner: AutoRefresh, } -impl OAuthStrategy { - /// Return a builder for configuring an `OAuthStrategy` from a token. +impl DeviceSessionStrategy { + /// Return a builder for configuring a `DeviceSessionStrategy` from a token. /// /// The token's `region` and `client_id` fields are set before caching. /// No token store is used — tokens are not persisted to disk. @@ -43,8 +47,8 @@ impl OAuthStrategy { region: Region, client_id: impl Into, token: Token, - ) -> OAuthStrategyBuilder { - OAuthStrategyBuilder { + ) -> DeviceSessionStrategyBuilder { + DeviceSessionStrategyBuilder { source: OAuthTokenSource::Token { region, client_id: client_id.into(), @@ -54,17 +58,17 @@ impl OAuthStrategy { } } - /// Return a builder for configuring an `OAuthStrategy` from a profile store. + /// Return a builder for configuring a `DeviceSessionStrategy` from a profile store. /// - /// The token is loaded from the store when [`OAuthStrategyBuilder::build`] is called. + /// The token is loaded from the store when [`DeviceSessionStrategyBuilder::build`] is called. /// The builder allows further configuration (e.g. overriding the base URL) before building. /// /// The token must have `region` and `client_id` set (as saved by /// [`DeviceCodeStrategy`](crate::DeviceCodeStrategy) or a prior - /// `OAuthStrategy`). The store is used for persisting refreshed tokens. + /// `DeviceSessionStrategy`). The store is used for persisting refreshed tokens. #[cfg(not(target_arch = "wasm32"))] - pub fn with_profile(store: ProfileStore) -> OAuthStrategyBuilder { - OAuthStrategyBuilder { + pub fn with_profile(store: ProfileStore) -> DeviceSessionStrategyBuilder { + DeviceSessionStrategyBuilder { source: OAuthTokenSource::Store(store), base_url_override: None, } @@ -76,7 +80,7 @@ impl OAuthStrategy { } } -impl AuthStrategy for &OAuthStrategy { +impl AuthStrategy for &DeviceSessionStrategy { async fn get_token(self) -> Result { Ok(self.inner.get_token().await?) } @@ -95,15 +99,15 @@ enum OAuthTokenSource { Store(ProfileStore), } -/// Builder for [`OAuthStrategy`]. +/// Builder for [`DeviceSessionStrategy`]. /// -/// Created via [`OAuthStrategy::with_token`] or [`OAuthStrategy::with_profile`]. -pub struct OAuthStrategyBuilder { +/// Created via [`DeviceSessionStrategy::with_token`] or [`DeviceSessionStrategy::with_profile`]. +pub struct DeviceSessionStrategyBuilder { source: OAuthTokenSource, base_url_override: Option, } -impl OAuthStrategyBuilder { +impl DeviceSessionStrategyBuilder { /// Override the base URL resolved by service discovery. /// /// Useful for pointing at a local or mock auth server during testing. @@ -113,11 +117,11 @@ impl OAuthStrategyBuilder { self } - /// Build the [`OAuthStrategy`]. + /// Build the [`DeviceSessionStrategy`]. /// /// Resolves the base URL via service discovery unless overridden with /// `base_url` (available when the `test-utils` feature is enabled). - pub fn build(self) -> Result { + pub fn build(self) -> Result { match self.source { OAuthTokenSource::Token { region, @@ -144,14 +148,14 @@ impl OAuthStrategyBuilder { let device_instance_id = token.device_instance_id().map(String::from); token.set_region(®ion_id); token.set_client_id(&client_id); - let refresher = OAuthRefresher::new( + let refresher = DeviceSessionRefresher::new( None, ensure_trailing_slash(base_url), &client_id, ®ion_id, device_instance_id, ); - Ok(OAuthStrategy { + Ok(DeviceSessionStrategy { crn, inner: AutoRefresh::with_token(refresher, token), }) @@ -183,14 +187,14 @@ impl OAuthStrategyBuilder { None => crate::cts_base_url_from_env()?.unwrap_or(token.issuer()?), }; - let refresher = OAuthRefresher::new( + let refresher = DeviceSessionRefresher::new( Some(ws_store), ensure_trailing_slash(base_url), &client_id, ®ion_str, device_instance_id, ); - Ok(OAuthStrategy { + Ok(DeviceSessionStrategy { crn, inner: AutoRefresh::with_token(refresher, token), }) diff --git a/packages/stack-auth/src/lib.rs b/packages/stack-auth/src/lib.rs index c19e0f6fe..6746c2e93 100644 --- a/packages/stack-auth/src/lib.rs +++ b/packages/stack-auth/src/lib.rs @@ -37,8 +37,8 @@ mod auth_strategy_fn; mod authorize_dto; mod auto_refresh; mod auto_strategy; -mod oauth_refresher; -mod oauth_strategy; +mod device_session_refresher; +mod device_session_strategy; mod oidc_federation_strategy; mod oidc_refresher; mod refresher; @@ -49,7 +49,7 @@ mod token_store; // Filesystem-backed device identity and the interactive device-code flow are // native-only — both pull `stack-profile` (which uses `dirs` + `gethostname`) // and the device-code flow launches a browser via `open::that`. Wasm consumers -// use `OAuthStrategy::with_token` or `AccessKeyStrategy`. +// use `DeviceSessionStrategy::with_token` or `AccessKeyStrategy`. #[cfg(not(target_arch = "wasm32"))] mod device_client; #[cfg(not(target_arch = "wasm32"))] @@ -62,7 +62,7 @@ pub use access_key::{AccessKey, InvalidAccessKey}; pub use access_key_strategy::{AccessKeyStrategy, AccessKeyStrategyBuilder}; pub use auth_strategy_fn::AuthStrategyFn; pub use auto_strategy::{AutoStrategy, AutoStrategyBuilder}; -pub use oauth_strategy::{OAuthStrategy, OAuthStrategyBuilder}; +pub use device_session_strategy::{DeviceSessionStrategy, DeviceSessionStrategyBuilder}; pub use oidc_federation_strategy::{OidcFederationStrategy, OidcFederationStrategyBuilder}; pub use oidc_refresher::{OidcProvider, OidcProviderFn}; pub use service_token::ServiceToken; @@ -71,6 +71,19 @@ pub use static_token_strategy::StaticTokenStrategy; pub use token::Token; pub use token_store::{InMemoryTokenStore, NoStore, TokenStore, TokenStoreFn}; +/// Deprecated alias for [`DeviceSessionStrategy`]. +/// +/// Renamed to make the *renewal* (existing CTS session) vs *federation* +/// ([`OidcFederationStrategy`]) distinction explicit. The old name still +/// resolves so existing code keeps compiling; it will be removed in a future +/// major release. +#[deprecated(since = "0.36.0", note = "renamed to `DeviceSessionStrategy`")] +pub type OAuthStrategy = DeviceSessionStrategy; + +/// Deprecated alias for [`DeviceSessionStrategyBuilder`]. +#[deprecated(since = "0.36.0", note = "renamed to `DeviceSessionStrategyBuilder`")] +pub type OAuthStrategyBuilder = DeviceSessionStrategyBuilder; + #[cfg(not(target_arch = "wasm32"))] pub use device_client::{bind_client_device, DeviceClientError}; #[cfg(not(target_arch = "wasm32"))] @@ -94,9 +107,10 @@ pub use stack_profile::DeviceIdentity; pub mod auth { pub use crate::{ AccessKey, AccessKeyStrategy, AccessKeyStrategyBuilder, AuthError, AuthStrategy, - AuthStrategyBounds, AuthStrategyFn, AutoStrategy, AutoStrategyBuilder, InvalidAccessKey, - OAuthStrategy, OAuthStrategyBuilder, OidcFederationStrategy, OidcFederationStrategyBuilder, - OidcProvider, OidcProviderFn, SecretToken, ServiceToken, + AuthStrategyBounds, AuthStrategyFn, AutoStrategy, AutoStrategyBuilder, + DeviceSessionStrategy, DeviceSessionStrategyBuilder, InvalidAccessKey, + OidcFederationStrategy, OidcFederationStrategyBuilder, OidcProvider, OidcProviderFn, + SecretToken, ServiceToken, }; #[cfg(not(target_arch = "wasm32"))] @@ -133,11 +147,11 @@ pub mod store { /// they need a valid token. /// /// The trait is designed to be implemented for `&T`, so that callers can use -/// shared references (e.g. `&OAuthStrategy`) without consuming the strategy. +/// shared references (e.g. `&DeviceSessionStrategy`) without consuming the strategy. /// /// # Token refresh /// -/// All strategies that cache tokens ([`AccessKeyStrategy`], [`OAuthStrategy`], +/// All strategies that cache tokens ([`AccessKeyStrategy`], [`DeviceSessionStrategy`], /// [`AutoStrategy`]) share the same internal refresh engine. Understanding the /// refresh model helps predict how [`get_token`](AuthStrategy::get_token) /// behaves under concurrent access. diff --git a/packages/stack-auth/src/token.rs b/packages/stack-auth/src/token.rs index 0dfd7b48c..d4dea5508 100644 --- a/packages/stack-auth/src/token.rs +++ b/packages/stack-auth/src/token.rs @@ -260,7 +260,7 @@ impl Token { region: None, client_id: None, // TODO(CIP-2793): The server should include device_instance_id in the - // refresh response. Until then, callers (e.g. OAuthRefresher) must + // refresh response. Until then, callers (e.g. DeviceSessionRefresher) must // re-attach it manually after refresh. device_instance_id: None, }) From 489e17ecc211ba645b06af9a864e4a6617289207 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Thu, 4 Jun 2026 15:11:01 +0800 Subject: [PATCH 270/686] review(stack-auth): address rename PR feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copilot: - Re-export the deprecated OAuthStrategy/OAuthStrategyBuilder aliases from the stack_auth::auth prelude module too, not just the crate root, so stack_auth::auth::OAuthStrategy consumers keep compiling. - Fix two client_e2e test comments that described the renamed flow as building a DeviceSessionStrategy — it federates an OIDC token through CTS and uses the resulting StaticTokenStrategy. Toby: - Add a @deprecated OAuthStrategy entry to index.d.ts mirroring the runtime module.exports alias (matches the PR description). - Clarify the auto_strategy example log: "Using device-session (OAuth) authentication". --- languages/typescript/packages/auth/index.d.ts | 8 ++++++++ packages/stack-auth/examples/auto_strategy.rs | 2 +- packages/stack-auth/src/lib.rs | 6 ++++++ 3 files changed, 15 insertions(+), 1 deletion(-) diff --git a/languages/typescript/packages/auth/index.d.ts b/languages/typescript/packages/auth/index.d.ts index 2ccd7262c..81e9e1f22 100644 --- a/languages/typescript/packages/auth/index.d.ts +++ b/languages/typescript/packages/auth/index.d.ts @@ -196,6 +196,14 @@ export declare class DeviceSessionStrategy { /** Retrieve a valid access token, refreshing as needed. */ getToken(): Promise } +/** + * Deprecated alias for {@link DeviceSessionStrategy}, exported at runtime as + * `module.exports.OAuthStrategy = DeviceSessionStrategy`. Kept so existing + * consumers don't break; will be removed in a future major release. + * + * @deprecated Renamed to `DeviceSessionStrategy`. + */ +export declare const OAuthStrategy: typeof DeviceSessionStrategy /** * An auth strategy that federates a third-party OIDC JWT (Clerk, Supabase, …) * into a CipherStash CTS service token via `/api/authorise`. diff --git a/packages/stack-auth/examples/auto_strategy.rs b/packages/stack-auth/examples/auto_strategy.rs index 1872d73cc..5815fc986 100644 --- a/packages/stack-auth/examples/auto_strategy.rs +++ b/packages/stack-auth/examples/auto_strategy.rs @@ -40,7 +40,7 @@ async fn main() -> Result<(), Box> { match &strategy { AutoStrategy::AccessKey(_) => println!("Using access key authentication"), - AutoStrategy::DeviceSession(_) => println!("Using OAuth authentication"), + AutoStrategy::DeviceSession(_) => println!("Using device-session (OAuth) authentication"), } // Obtain a token — refresh happens automatically when needed. diff --git a/packages/stack-auth/src/lib.rs b/packages/stack-auth/src/lib.rs index 6746c2e93..9ce4ab0b1 100644 --- a/packages/stack-auth/src/lib.rs +++ b/packages/stack-auth/src/lib.rs @@ -121,6 +121,12 @@ pub mod auth { #[cfg(any(test, feature = "test-utils"))] pub use crate::StaticTokenStrategy; + + // Deprecated aliases, re-exported here too so `stack_auth::auth::OAuthStrategy` + // consumers keep compiling alongside the crate-root aliases. See the + // `OAuthStrategy` / `OAuthStrategyBuilder` definitions at the crate root. + #[allow(deprecated)] + pub use crate::{OAuthStrategy, OAuthStrategyBuilder}; } /// Token *persistence* — pluggable backends for the service-token cache. From c4b0272bfc05e8817e0df9e7e19deb9c2632f964 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Mon, 1 Jun 2026 11:11:13 +1000 Subject: [PATCH 271/686] chore(stack-auth,stack-profile): adopt biome for JS/TS formatting + CI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces biome (pinned 2.3.4) as the formatter for the hand-written JS/TS in the two napi/wasm packages, and enforces it in CI via a new `lint-js` workflow. Scope decisions: - Format-only for now: the linter and import-sort assist are disabled in `biome.json`. Adopting lint rules (noNonNullAssertion, useNodejsImportProtocol, organizeImports, …) is a deliberate follow-up so this PR stays a pure-formatting diff. - Generated/vendored files are excluded: `*.d.ts`, `wasm/`, `npm/`, and the napi loader shims (`stack-auth-node.js`). Existing files reformatted to match (the reflow is whitespace-only — verified the normalised token stream is unchanged for every file, and the full Node vitest suites still pass: 34 stack-auth + 6 stack-profile). `format` / `format:check` npm scripts added to stack-auth/node. CI runs `biome ci` (format check, no write) in each package on JS/TS changes. Stacked on the OAuthStrategy-rename PR. --- .github/imported-workflows/format-js.yml | 49 +++++++++++++++++++ .../auth/__tests__/device-code-flow.test.ts | 13 ++--- .../oidc-federation-strategy.test.ts | 16 ++++-- .../__tests__/provision-device-client.test.ts | 14 ++---- languages/typescript/packages/auth/biome.json | 38 ++++++++++++++ .../typescript/packages/auth/cookies.mjs | 25 ++++++++-- .../packages/auth/examples/device-code.ts | 4 +- .../typescript/packages/auth/package.json | 4 +- .../typescript/packages/auth/wasm-inline.mjs | 11 ++++- .../typescript/packages/profile/biome.json | 37 ++++++++++++++ .../typescript/packages/profile/package.json | 4 +- 11 files changed, 181 insertions(+), 34 deletions(-) create mode 100644 .github/imported-workflows/format-js.yml create mode 100644 languages/typescript/packages/auth/biome.json create mode 100644 languages/typescript/packages/profile/biome.json diff --git a/.github/imported-workflows/format-js.yml b/.github/imported-workflows/format-js.yml new file mode 100644 index 000000000..5149b2b79 --- /dev/null +++ b/.github/imported-workflows/format-js.yml @@ -0,0 +1,49 @@ +name: "Format JS/TS (biome)" +on: + push: + branches: + - main + paths: + - packages/stack-auth/node/** + - packages/stack-profile/node/** + - .github/workflows/format-js.yml + + pull_request: + paths: + - packages/stack-auth/node/** + - packages/stack-profile/node/** + - .github/workflows/format-js.yml + + workflow_dispatch: + +defaults: + run: + shell: bash + +jobs: + biome: + runs-on: blacksmith-8vcpu-ubuntu-2404 + steps: + - uses: actions/checkout@v6 + + # biome ships as a standalone native binary — it does not run under Node — + # so there's no Node toolchain to pin here. The version is pinned to match + # each package's `format` / `format:check` scripts so local and CI agree. + - name: Setup biome + uses: biomejs/setup-biome@v2 + with: + version: 2.3.4 + + # `biome ci` checks formatting without writing. The linter is disabled in + # `biome.json` — only formatting is enforced for now (lint-rule adoption is + # a follow-up). `if: !cancelled()` on the later step ensures every package + # is checked even when an earlier one fails, so contributors see all + # packages' formatting errors in a single run. + - name: biome — stack-auth/node + working-directory: packages/stack-auth/node + run: biome ci . + + - name: biome — stack-profile/node + if: ${{ !cancelled() }} + working-directory: packages/stack-profile/node + run: biome ci . diff --git a/languages/typescript/packages/auth/__tests__/device-code-flow.test.ts b/languages/typescript/packages/auth/__tests__/device-code-flow.test.ts index 758b22fb8..e1b3f7019 100644 --- a/languages/typescript/packages/auth/__tests__/device-code-flow.test.ts +++ b/languages/typescript/packages/auth/__tests__/device-code-flow.test.ts @@ -1,21 +1,14 @@ import { describe, it, expect, beforeEach } from "vitest"; import type { MockAuthServer as MockAuthServerType } from "../test-utils"; -import type { - DeviceCodeResult, - AuthResult, - AuthError, -} from "../index"; +import type { DeviceCodeResult, AuthResult, AuthError } from "../index"; // Load the CJS module — includes MockAuthServer when built with test-utils. const mod = require("../index.js") as typeof import("../index") & { MockAuthServer: typeof MockAuthServerType; }; -const { - beginDeviceCodeFlow, - beginDeviceCodeFlowWithBaseUrl, - MockAuthServer, -} = mod; +const { beginDeviceCodeFlow, beginDeviceCodeFlowWithBaseUrl, MockAuthServer } = + mod; // --------------------------------------------------------------------------- // Helpers diff --git a/languages/typescript/packages/auth/__tests__/oidc-federation-strategy.test.ts b/languages/typescript/packages/auth/__tests__/oidc-federation-strategy.test.ts index f1ec6e9ad..b622d076b 100644 --- a/languages/typescript/packages/auth/__tests__/oidc-federation-strategy.test.ts +++ b/languages/typescript/packages/auth/__tests__/oidc-federation-strategy.test.ts @@ -60,7 +60,11 @@ describe("OidcFederationStrategy (TypeScript / vitest)", () => { it("federates a third-party JWT into a CTS service token", async () => { server.mockAuthorizeEndpoint(); const jwt = countingJwt(); - const strategy = OidcFederationStrategy.create(REGION, WORKSPACE_ID, jwt.getJwt); + const strategy = OidcFederationStrategy.create( + REGION, + WORKSPACE_ID, + jwt.getJwt, + ); const result = await strategy.getToken(); @@ -75,7 +79,11 @@ describe("OidcFederationStrategy (TypeScript / vitest)", () => { server.mockAuthorizeEndpoint(0); server.mockAuthorizeEndpoint(0); const jwt = countingJwt(); - const strategy = OidcFederationStrategy.create(REGION, WORKSPACE_ID, jwt.getJwt); + const strategy = OidcFederationStrategy.create( + REGION, + WORKSPACE_ID, + jwt.getJwt, + ); await strategy.getToken(); await strategy.getToken(); @@ -198,7 +206,9 @@ describe("OidcFederationStrategy (TypeScript / vitest)", () => { try { await strategy.getToken(); - expect.unreachable("getToken should reject on a non-string getJwt result"); + expect.unreachable( + "getToken should reject on a non-string getJwt result", + ); } catch (err) { expect((err as AuthError).code).toBe("SERVER_ERROR"); } diff --git a/languages/typescript/packages/auth/__tests__/provision-device-client.test.ts b/languages/typescript/packages/auth/__tests__/provision-device-client.test.ts index b5b549aaa..5c0982d32 100644 --- a/languages/typescript/packages/auth/__tests__/provision-device-client.test.ts +++ b/languages/typescript/packages/auth/__tests__/provision-device-client.test.ts @@ -7,17 +7,11 @@ import type { AuthError } from "../index"; const mod = require("../index.js") as typeof import("../index") & { MockAuthServer: typeof MockAuthServerType; - bindClientDeviceWithProfileDir: ( - profileDir: string - ) => Promise; + bindClientDeviceWithProfileDir: (profileDir: string) => Promise; saveTestToken: (profileDir: string, zerokmsBaseUrl: string) => void; }; -const { - MockAuthServer, - bindClientDeviceWithProfileDir, - saveTestToken, -} = mod; +const { MockAuthServer, bindClientDeviceWithProfileDir, saveTestToken } = mod; // --------------------------------------------------------------------------- // Helpers @@ -59,9 +53,7 @@ describe("provision device client (TypeScript / vitest)", () => { const raw = readFileSync(join(workspaceDir(), "secretkey.json"), "utf-8"); const secretKey = JSON.parse(raw); - expect(secretKey.client_id).toBe( - "00000000-0000-0000-0000-000000000001" - ); + expect(secretKey.client_id).toBe("00000000-0000-0000-0000-000000000001"); expect(secretKey.client_key).toBe("dGVzdC1rZXktbWF0ZXJpYWw="); }); diff --git a/languages/typescript/packages/auth/biome.json b/languages/typescript/packages/auth/biome.json new file mode 100644 index 000000000..6805962fe --- /dev/null +++ b/languages/typescript/packages/auth/biome.json @@ -0,0 +1,38 @@ +{ + "$schema": "https://biomejs.dev/schemas/2.3.4/schema.json", + "vcs": { + "enabled": true, + "clientKind": "git", + "useIgnoreFile": true + }, + "files": { + "includes": [ + "**/*.js", + "**/*.mjs", + "**/*.ts", + "!**/*.d.ts", + "!wasm/**", + "!npm/**", + "!stack-auth-node.js" + ] + }, + "formatter": { + "enabled": true, + "indentStyle": "space", + "indentWidth": 2, + "lineWidth": 80 + }, + "linter": { + "enabled": false + }, + "assist": { + "enabled": false + }, + "javascript": { + "formatter": { + "quoteStyle": "double", + "trailingCommas": "all", + "semicolons": "always" + } + } +} diff --git a/languages/typescript/packages/auth/cookies.mjs b/languages/typescript/packages/auth/cookies.mjs index 51fd2faeb..430e01e8c 100644 --- a/languages/typescript/packages/auth/cookies.mjs +++ b/languages/typescript/packages/auth/cookies.mjs @@ -70,7 +70,16 @@ export function cookieStore(options) { const maxAge = maxAgeFromTokenJson(json, expirySafetyMarginSeconds); responseHeaders.append( "set-cookie", - serializeSetCookie({ name, value, domain, path, secure, httpOnly, sameSite, maxAge }), + serializeSetCookie({ + name, + value, + domain, + path, + secure, + httpOnly, + sameSite, + maxAge, + }), ); }, }; @@ -115,7 +124,8 @@ function serializeSetCookie(opts) { const parts = [`${opts.name}=${opts.value}`]; if (opts.domain) parts.push(`Domain=${opts.domain}`); if (opts.path) parts.push(`Path=${opts.path}`); - if (typeof opts.maxAge === "number") parts.push(`Max-Age=${Math.floor(opts.maxAge)}`); + if (typeof opts.maxAge === "number") + parts.push(`Max-Age=${Math.floor(opts.maxAge)}`); if (opts.httpOnly) parts.push("HttpOnly"); if (opts.secure) parts.push("Secure"); if (opts.sameSite) parts.push(`SameSite=${opts.sameSite}`); @@ -131,8 +141,12 @@ function encodeBase64Url(input) { // round-trips. Token JSON is ASCII in practice but be defensive. const bytes = new TextEncoder().encode(input); let binary = ""; - for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]); - return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", ""); + for (let i = 0; i < bytes.length; i++) + binary += String.fromCharCode(bytes[i]); + return btoa(binary) + .replaceAll("+", "-") + .replaceAll("/", "_") + .replaceAll("=", ""); } /** @@ -141,7 +155,8 @@ function encodeBase64Url(input) { */ function decodeBase64Url(input) { const padded = input.replaceAll("-", "+").replaceAll("_", "/"); - const pad = padded.length % 4 === 0 ? "" : "=".repeat(4 - (padded.length % 4)); + const pad = + padded.length % 4 === 0 ? "" : "=".repeat(4 - (padded.length % 4)); const binary = atob(padded + pad); const bytes = new Uint8Array(binary.length); for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); diff --git a/languages/typescript/packages/auth/examples/device-code.ts b/languages/typescript/packages/auth/examples/device-code.ts index 1d820859e..2610900e9 100644 --- a/languages/typescript/packages/auth/examples/device-code.ts +++ b/languages/typescript/packages/auth/examples/device-code.ts @@ -24,7 +24,9 @@ async function main() { // Optionally open the browser automatically const opened = pending.openInBrowser(); if (!opened) { - console.log("Could not open browser — please visit the URL above manually."); + console.log( + "Could not open browser — please visit the URL above manually.", + ); } // Step 3: Poll until the user authorizes (or the code expires). diff --git a/languages/typescript/packages/auth/package.json b/languages/typescript/packages/auth/package.json index 7fee8bebe..0e63025d1 100644 --- a/languages/typescript/packages/auth/package.json +++ b/languages/typescript/packages/auth/package.json @@ -59,7 +59,9 @@ "build:debug": "napi build", "build:test": "napi build --features test-utils", "build:wasm": "cd ../wasm && wasm-pack build --target bundler --out-dir ../node/wasm && rm -f ../node/wasm/README.md ../node/wasm/LICENSE ../node/wasm/.gitignore && echo '{\"type\":\"module\"}' > ../node/wasm/package.json && node ../node/scripts/inline-wasm.mjs", - "test": "npm run build:test && vitest run" + "test": "npm run build:test && vitest run", + "format": "npx --yes @biomejs/biome@2.3.4 format --write .", + "format:check": "npx --yes @biomejs/biome@2.3.4 ci ." }, "peerDependencies": { "@cipherstash/auth-darwin-x64": "0.36.0", diff --git a/languages/typescript/packages/auth/wasm-inline.mjs b/languages/typescript/packages/auth/wasm-inline.mjs index 29de24550..2ff9b9337 100644 --- a/languages/typescript/packages/auth/wasm-inline.mjs +++ b/languages/typescript/packages/auth/wasm-inline.mjs @@ -42,10 +42,17 @@ export class AccessKeyStrategy { const save = (/** @type {string} */ json) => Promise.resolve(store.save(json)); return new AccessKeyStrategy( - RawAccessKeyStrategy.createWithStore(workspaceCrn, accessKey, load, save), + RawAccessKeyStrategy.createWithStore( + workspaceCrn, + accessKey, + load, + save, + ), ); } - return new AccessKeyStrategy(RawAccessKeyStrategy.create(workspaceCrn, accessKey)); + return new AccessKeyStrategy( + RawAccessKeyStrategy.create(workspaceCrn, accessKey), + ); } /** @returns {Promise} */ diff --git a/languages/typescript/packages/profile/biome.json b/languages/typescript/packages/profile/biome.json new file mode 100644 index 000000000..763a1c530 --- /dev/null +++ b/languages/typescript/packages/profile/biome.json @@ -0,0 +1,37 @@ +{ + "$schema": "https://biomejs.dev/schemas/2.3.4/schema.json", + "vcs": { + "enabled": true, + "clientKind": "git", + "useIgnoreFile": true + }, + "files": { + "includes": [ + "**/*.js", + "**/*.mjs", + "**/*.ts", + "!**/*.d.ts", + "!npm/**", + "!stack-profile-node.js" + ] + }, + "formatter": { + "enabled": true, + "indentStyle": "space", + "indentWidth": 2, + "lineWidth": 80 + }, + "linter": { + "enabled": false + }, + "assist": { + "enabled": false + }, + "javascript": { + "formatter": { + "quoteStyle": "double", + "trailingCommas": "all", + "semicolons": "always" + } + } +} diff --git a/languages/typescript/packages/profile/package.json b/languages/typescript/packages/profile/package.json index 54d7cccf6..d844d1933 100644 --- a/languages/typescript/packages/profile/package.json +++ b/languages/typescript/packages/profile/package.json @@ -26,7 +26,9 @@ "scripts": { "build": "napi build --release", "build:debug": "napi build", - "test": "npm run build:debug && vitest run" + "test": "npm run build:debug && vitest run", + "format": "npx --yes @biomejs/biome@2.3.4 format --write .", + "format:check": "npx --yes @biomejs/biome@2.3.4 ci ." }, "optionalDependencies": { "@cipherstash/profile-darwin-x64": "0.35.0", From cdd5f4f5b3c7b8d24aa1f602c9d14ccb12a2c999 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 4 Jun 2026 07:43:17 +0000 Subject: [PATCH 272/686] chore: release --- packages/stack-auth/CHANGELOG.md | 26 ++++++++++++++++++++++++++ packages/stack-auth/Cargo.toml | 2 +- packages/stack-profile/CHANGELOG.md | 1 + packages/stack-profile/Cargo.toml | 2 +- 4 files changed, 29 insertions(+), 2 deletions(-) diff --git a/packages/stack-auth/CHANGELOG.md b/packages/stack-auth/CHANGELOG.md index 4b108014a..e65550112 100644 --- a/packages/stack-auth/CHANGELOG.md +++ b/packages/stack-auth/CHANGELOG.md @@ -1,5 +1,31 @@ +### Documentation + +- point authorize_dto refresher links at structs + +### Features + +- OidcFederationStrategy — federate a third-party OIDC JWT into a CTS service token +- verify federated token's workspace in OidcFederationStrategy +- napi + wasm bindings for OidcFederationStrategy + +### Miscellaneous + +- adopt biome for JS/TS formatting + CI + +### Refactoring + +- drop audience from OidcFederationStrategy; clarify provider docs +- rename OAuthStrategy to DeviceSessionStrategy + +### Review + +- address PR #2004 feedback on wasm bindings +- address Toby + Lindsay feedback on napi binding +- address rename PR feedback + + ### Documentation - note InvalidToken alongside WorkspaceMismatch diff --git a/packages/stack-auth/Cargo.toml b/packages/stack-auth/Cargo.toml index 912af65cc..d673f1d10 100644 --- a/packages/stack-auth/Cargo.toml +++ b/packages/stack-auth/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "stack-auth" description = "Authentication library for CipherStash services" -version = "0.36.0" +version = "0.37.0" edition.workspace = true authors.workspace = true repository.workspace = true diff --git a/packages/stack-profile/CHANGELOG.md b/packages/stack-profile/CHANGELOG.md index 0d675f9b8..f7c23e921 100644 --- a/packages/stack-profile/CHANGELOG.md +++ b/packages/stack-profile/CHANGELOG.md @@ -27,6 +27,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - serialise refresh-token rotation across processes - address Copilot review on Windows + crash durability + ## [0.34.0-alpha.1] - 2026-03-04 ### Changed diff --git a/packages/stack-profile/Cargo.toml b/packages/stack-profile/Cargo.toml index 443882399..b89f42d06 100644 --- a/packages/stack-profile/Cargo.toml +++ b/packages/stack-profile/Cargo.toml @@ -2,7 +2,7 @@ name = "stack-profile" description = "Centralised ~/.cipherstash profile file management" license-file = "LICENSE" -version = "0.36.0" +version = "0.37.0" edition.workspace = true authors.workspace = true repository.workspace = true From cccfd76761833dc7bd1e72802d46469182fa0f68 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Thu, 4 Jun 2026 15:51:08 +0800 Subject: [PATCH 273/686] chore(release): drop stray Review changelog sections MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove the '### Review' sections (generated from PR-feedback 'review(...)' commits) from the stack-auth and cipherstash-client entries ahead of publishing. The cliff.toml config is updated separately (PR) to skip review commits so future runs don't regenerate them. NOTE: this is a manual stopgap — release-plz regenerates CHANGELOG.md on every push to main, so if it re-runs before this branch is merged it will restore these sections. Merge promptly or rely on the cliff.toml fix. --- packages/stack-auth/CHANGELOG.md | 6 ------ 1 file changed, 6 deletions(-) diff --git a/packages/stack-auth/CHANGELOG.md b/packages/stack-auth/CHANGELOG.md index e65550112..80ac3e278 100644 --- a/packages/stack-auth/CHANGELOG.md +++ b/packages/stack-auth/CHANGELOG.md @@ -19,12 +19,6 @@ - drop audience from OidcFederationStrategy; clarify provider docs - rename OAuthStrategy to DeviceSessionStrategy -### Review - -- address PR #2004 feedback on wasm bindings -- address Toby + Lindsay feedback on napi binding -- address rename PR feedback - ### Documentation From 47ffe739053d4aef007798a6b14cf008a576ff86 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Thu, 4 Jun 2026 17:55:30 +0800 Subject: [PATCH 274/686] chore(stack-auth/node): release @cipherstash/auth 0.39.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Finalize the 0.39.0 release notes. The version was already bumped to 0.39.0 in package.json (prepped but never published — npm latest is 0.38.0); this completes the changelog to cover everything that landed since 0.38.0: - OidcFederationStrategy (napi + wasm) — the AccessKeyStrategy CRN change was already documented; the OIDC bindings, the OAuthStrategy -> DeviceSessionStrategy rename, and the INVALID_WORKSPACE_ID error code were not. Not managed by release-plz; published by the publish-auth-npm workflow on a stack-auth-v0.39.0 tag. --- .../typescript/packages/auth/CHANGELOG.md | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/languages/typescript/packages/auth/CHANGELOG.md b/languages/typescript/packages/auth/CHANGELOG.md index 33addc6c6..a16702e07 100644 --- a/languages/typescript/packages/auth/CHANGELOG.md +++ b/languages/typescript/packages/auth/CHANGELOG.md @@ -2,6 +2,31 @@ ## 0.39.0 +### New Features + +- **`OidcFederationStrategy`** — federate a third-party OIDC JWT (Clerk, + Supabase, …) into a CipherStash CTS service token via `/api/authorise`. + Exposed on both the napi and `wasm-inline` entrypoints, with a `getJwt` + callback that supplies the current third-party token. + + ```ts + const strategy = OidcFederationStrategy.create( + "ap-southeast-2.aws", + "ZVATKW3VHMFG27DY", + getJwt, // () => Promise — your current third-party OIDC JWT + ); + const { token } = await strategy.getToken(); + ``` + + A store-backed variant persists the federated CTS token (e.g. in an HTTP-only + cookie) so it survives across requests without re-federating: + + ```ts + OidcFederationStrategy.createWithStore( + region, workspaceId, getJwt, loadToken, saveToken, + ); + ``` + ### Breaking Changes - **`AccessKeyStrategy.create(workspaceCrn, accessKey)`** — the first argument @@ -29,11 +54,21 @@ strategy silently let the caller operate on a different workspace than the one they specified. +### Deprecations + +- **`OAuthStrategy` is renamed to `DeviceSessionStrategy`** to make its purpose + — *renewing an existing CTS device session* via a refresh token — distinct + from *federating a third-party JWT* (`OidcFederationStrategy`). `OAuthStrategy` + is still exported as a `@deprecated` alias of `DeviceSessionStrategy`, so + existing code keeps working; it will be removed in a future major. + ### New Error Codes - `WORKSPACE_MISMATCH` — the JWT decoded cleanly but its `workspace` claim doesn't match the CRN the strategy was configured with. The accompanying message identifies both the expected and the token-supplied workspace IDs. +- `INVALID_WORKSPACE_ID` — the `workspaceId` passed to an + `OidcFederationStrategy` factory could not be parsed. ## 0.35.0 From 9adafaae064e6963ea91d411f4efb618b1bee590 Mon Sep 17 00:00:00 2001 From: James Sadler Date: Thu, 18 Jun 2026 11:35:53 +1000 Subject: [PATCH 275/686] fix(stack-auth): treat access-key token expiry as absolute epoch, not relative MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AccessKeyRefresher computed expires_at as `now + auth_resp.expiry`, but CTS /api/authorise returns `expiry` as an ABSOLUTE Unix epoch (the JWT `exp` claim), not a relative duration. The sum landed ~decades in the future, so AutoRefresh never considered the token expired and never refreshed it; ZeroKMS enforced the real (default 900s) exp, so any long-running access-key consumer started failing ~15 min after startup until restart. Use the value as-is: `expires_at: auth_resp.expiry`. Also fixes the access-key test fixtures, which mocked `expiry` as a small relative value (3600) and thereby hid the bug — they now model an absolute epoch (now + N) like the real CTS. Adds a regression test asserting an absolute `expiry` yields expires_in ~= the intended TTL (fails under the pre-fix `now + expiry` arithmetic). Confirmed against a live production token: response.expiry == JWT exp (absolute), exp - iat == 900. CIP-3233. --- .../stack-auth/src/access_key_refresher.rs | 77 ++++++++++++++++--- 1 file changed, 67 insertions(+), 10 deletions(-) diff --git a/packages/stack-auth/src/access_key_refresher.rs b/packages/stack-auth/src/access_key_refresher.rs index a09a3e202..f65d1f2fe 100644 --- a/packages/stack-auth/src/access_key_refresher.rs +++ b/packages/stack-auth/src/access_key_refresher.rs @@ -1,7 +1,5 @@ use std::sync::Arc; -use web_time::{SystemTime, UNIX_EPOCH}; - use url::Url; use crate::authorize_dto::AuthoriseResponse; @@ -69,15 +67,17 @@ impl Refresher for AccessKeyRefresher { } let auth_resp: AuthoriseResponse = resp.json().await?; - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_secs(); Ok(Token { access_token: auth_resp.access_token, token_type: "Bearer".to_string(), - expires_at: now + auth_resp.expiry, + // CTS `/api/authorise` returns `expiry` as an ABSOLUTE Unix epoch (it is + // the JWT `exp` claim), NOT a relative duration. The previous `now + expiry` + // pushed the local expiry decades into the future, so `AutoRefresh` never + // considered the token expired and never refreshed it — the token then + // silently died at its real (~15 min) `exp` and every request failed until + // the process restarted. Use the value as-is. See CIP-3233. + expires_at: auth_resp.expiry, refresh_token: None, region: None, client_id: None, @@ -103,10 +103,17 @@ mod tests { use std::sync::Arc; use std::time::{SystemTime, UNIX_EPOCH}; - fn auth_response_json(access: &str, expiry: u64) -> serde_json::Value { + /// Build a mock `/api/authorise` response. CTS returns `expiry` as an + /// ABSOLUTE Unix epoch (the JWT `exp` claim), so model that faithfully: the + /// token is valid for `expires_in_secs` from now. + fn auth_response_json(access: &str, expires_in_secs: u64) -> serde_json::Value { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs(); serde_json::json!({ "accessToken": access, - "expiry": expiry + "expiry": now + expires_in_secs }) } @@ -153,6 +160,50 @@ mod tests { make_token(access, 3600) } + // ---- Regression: CTS `expiry` is an absolute epoch (CIP-3233) ---- + + /// CTS `/api/authorise` returns `expiry` as an ABSOLUTE Unix epoch (the JWT + /// `exp` claim), not a relative duration. The refresher must use it as-is. + /// + /// Pre-fix (`expires_at = now + expiry`), this token's `expires_at` lands + /// ~decades in the future, so `is_expired()` is never true — the token never + /// refreshes and silently dies at its real ~15-minute `exp`. The assertion + /// below fails under the pre-fix arithmetic (`expires_in()` ≈ 1.7e9) and + /// passes with the fix (`expires_in()` ≈ 900). + #[tokio::test] + async fn access_key_expiry_is_absolute_epoch_not_relative() { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs(); + let absolute_expiry = now + 900; // a 15-minute token, as an absolute epoch + + let mut mocks = MockSet::new(); + mocks.mock(move |when, then| { + when.post().path("/api/authorise"); + then.json(serde_json::json!({ + "accessToken": "tok", + "expiry": absolute_expiry + })); + }); + let server = start_server(mocks).await; + + let refresher = + AccessKeyRefresher::new(SecretToken::new("CSAKid.secret"), server.url(""), None); + let token = refresher.refresh(&()).await.unwrap(); + + assert!( + token.expires_in() <= 1000, + "expires_in should be ~900s (absolute `expiry` used as-is); got {} \ + — pre-fix `now + expiry` yields ~1.7e9", + token.expires_in() + ); + assert!( + !token.is_expired(), + "a fresh 15-minute token must not be reported as already expired" + ); + } + // ---- Initial auth tests ---- #[tokio::test] @@ -558,9 +609,15 @@ mod tests { state.counting.enter(); tokio::time::sleep(state.delay).await; state.counting.exit(); + // CTS returns `expiry` as an absolute epoch (JWT `exp`); model a token + // valid for 1 hour from now. + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs(); axum::Json(serde_json::json!({ "accessToken": "refreshed-token", - "expiry": 3600 + "expiry": now + 3600 })) } From 350d8aefc047d6bf93c4137a8de9c8e70a87bfe9 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Thu, 18 Jun 2026 12:51:35 +1000 Subject: [PATCH 276/686] ci(stack-auth): add cargo-crap (CRAP metric) coverage report MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the CRAP (Change Risk Anti-Patterns) metric to surface complex, under-tested functions — the kind of code where a subtle bug like CIP-3233 (cipherstash/cipherstash-suite#2036) can hide. First step toward more testing rigour. - mise.toml: declare cargo-crap + cargo-llvm-cov and the llvm-tools-preview rustup component so `mise install` provisions the coverage -> CRAP path. - .cargo-crap.toml: workspace-root config (threshold 30, pessimistic on missing coverage). Config discovery walks up, so it applies repo-wide. - crap:stack-auth mise task: llvm-cov nextest -> cargo crap. Excludes the wall-clock stress_tests, which go flaky under instrumentation slowdown (no effect on the report; deterministic unit tests cover the same lines). - crap-stack-auth.yml: report-only sticky PR comment on stack-auth PRs; never fails the build. --- .cargo-crap.toml | 23 +++++ .../imported-workflows/crap-stack-auth.yml | 94 +++++++++++++++++++ packages/stack-auth/tasks.toml | 16 ++++ 3 files changed, 133 insertions(+) create mode 100644 .cargo-crap.toml create mode 100644 .github/imported-workflows/crap-stack-auth.yml diff --git a/.cargo-crap.toml b/.cargo-crap.toml new file mode 100644 index 000000000..a24c96def --- /dev/null +++ b/.cargo-crap.toml @@ -0,0 +1,23 @@ +# Configuration for `cargo crap` — the CRAP (Change Risk Anti-Patterns) metric. +# +# CRAP rewards complex code that is well tested and penalises complex code that +# is not: +# +# CRAP = CC^2 * (1 - coverage)^3 + CC (CC = cyclomatic complexity) +# +# A simple or fully covered function scores roughly its complexity; a complex, +# untested one scores into the hundreds. It surfaces exactly the kind of risky, +# under-tested logic where a subtle mistake can hide (see CIP-3233 / #2036). +# +# Run it via `mise run crap:stack-auth` (generates coverage first, then scores). +# Config discovery walks up from the working directory, so this single root file +# applies anywhere in the workspace. + +# CRAP score above which a function is flagged for refactoring or more tests. +# 30 is the long-standing Crap4J default: a CC-10 function needs ~42% coverage, +# a CC-15 function ~59%, to fall below it. +threshold = 30 + +# Functions with complexity but no coverage data are scored as 0% covered +# (worst case) rather than silently skipped — uninstrumented code is risk too. +missing = "pessimistic" diff --git a/.github/imported-workflows/crap-stack-auth.yml b/.github/imported-workflows/crap-stack-auth.yml new file mode 100644 index 000000000..8ebbf783b --- /dev/null +++ b/.github/imported-workflows/crap-stack-auth.yml @@ -0,0 +1,94 @@ +name: "stack-auth CRAP report" + +# Posts the CRAP (Change Risk Anti-Patterns) metric for stack-auth as a sticky +# PR comment on every PR that touches the package. CRAP = cyclomatic complexity +# weighted by test coverage, so it surfaces complex, under-tested functions — +# the kind of code where a subtle bug like CIP-3233 (#2036) can hide. +# +# Report-only: this never fails the build. To turn it into a gate later, add +# `--fail-above` (absolute threshold) or `--baseline`/`--fail-regression` +# (fail only when a PR makes an existing function's CRAP score worse). +on: + pull_request: + paths: + - packages/stack-auth/** + - .cargo-crap.toml + - .github/workflows/crap-stack-auth.yml + # Ordering matters, this needs to stay at the end if we want to include the above, but exclude all .md files. + - "!**.md" + - "!**.example" + + workflow_dispatch: + +defaults: + run: + shell: bash + +# Needed to post/update the report comment on the PR. +permissions: + contents: read + pull-requests: write + +env: + RUST_BACKTRACE: full + CARGO_TERM_COLOR: always + CARGO_NET_GIT_FETCH_WITH_CLI: true + NEXTEST_PROFILE: ci + +jobs: + crap-stack-auth: + runs-on: blacksmith-8vcpu-ubuntu-2404 + + steps: + - uses: actions/checkout@v6 + - uses: ./.github/actions/setup-rust + + - name: Install llvm-tools-preview (required by cargo-llvm-cov) + run: rustup component add --toolchain "$(rustup show active-toolchain | cut -d' ' -f1)" llvm-tools-preview + + - name: Fix permissions on target directory + run: | + mkdir -p ./target + sudo chown -R "$(id -u):$(id -g)" ./target + + - name: Generate coverage and CRAP report + run: | + # Exclude `stress_tests`: wall-clock timing/concurrency tests that become + # flaky under llvm-cov's instrumentation slowdown. They still run in the + # normal stack-auth test gate, and excluding them here does not change the + # CRAP report (the deterministic unit tests cover the same lines). + mise x --env test -- cargo llvm-cov nextest -p stack-auth --all-features \ + -E 'not test(stress_tests)' \ + --lcov --output-path target/stack-auth-lcov.info + cargo crap \ + --path packages/stack-auth \ + --lcov target/stack-auth-lcov.info \ + --exclude 'node/**' --exclude 'wasm/**' --exclude 'examples/**' \ + --exclude '**/tests.rs' --exclude '**/tests/**' \ + --format pr-comment \ + --repo-url "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}" \ + --commit-ref "${{ github.event.pull_request.head.sha }}" \ + --output target/crap-comment.md + + - name: Post CRAP report as a sticky PR comment + # Only PRs have a comment thread; on workflow_dispatch the report is + # still generated above and visible in the job logs. + if: github.event_name == 'pull_request' + uses: actions/github-script@v7 + with: + script: | + const fs = require('fs'); + const body = fs.readFileSync('target/crap-comment.md', 'utf8'); + // cargo crap's pr-comment output starts with this marker. + const marker = ''; + const { owner, repo } = context.repo; + const issue_number = context.issue.number; + const { data: comments } = await github.rest.issues.listComments({ + owner, repo, issue_number, per_page: 100, + }); + const existing = comments.find(c => c.body && c.body.includes(marker)); + if (existing) { + await github.rest.issues.updateComment({ owner, repo, comment_id: existing.id, body }); + } else { + await github.rest.issues.createComment({ owner, repo, issue_number, body }); + } diff --git a/packages/stack-auth/tasks.toml b/packages/stack-auth/tasks.toml index 5fb87feb1..5b97a379e 100644 --- a/packages/stack-auth/tasks.toml +++ b/packages/stack-auth/tasks.toml @@ -7,3 +7,19 @@ run = [ "cp ../../../target/debug/libstack_auth_node.dylib stack-auth-node.node 2>/dev/null || cp ../../../target/debug/libstack_auth_node.so stack-auth-node.node", "npx vitest run", ] + +["crap:stack-auth"] +description = "Report the CRAP (Change Risk Anti-Patterns) metric for stack-auth — flags complex, under-tested functions" +run = [ + # Instrument and run the unit tests, emitting LCOV coverage that `cargo crap` consumes. + # `stress_tests` are excluded: they are wall-clock timing/concurrency tests (real + # axum servers, real sleeps, sub-second expiry windows) that become flaky under + # llvm-cov's ~2-5x slowdown. They still run in the normal `cargo nextest` gate, and + # excluding them here has zero effect on the report — the deterministic unit tests + # already cover the same lines. + "mise x --env test -- cargo llvm-cov nextest -p stack-auth --all-features -E 'not test(stress_tests)' --lcov --output-path {{config_root}}/target/stack-auth-lcov.info", + # Score every production function. Excludes test code, examples and the node/wasm + # binding crates so the report reflects the core library. Thresholds come from + # the workspace-root .cargo-crap.toml. Add --fail-above here to turn it into a gate. + "cargo crap --path packages/stack-auth --lcov {{config_root}}/target/stack-auth-lcov.info --exclude 'node/**' --exclude 'wasm/**' --exclude 'examples/**' --exclude '**/tests.rs' --exclude '**/tests/**'", +] From 5a1b4c876bd885ec2044ba577c6818280b50ed00 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Thu, 18 Jun 2026 13:01:51 +1000 Subject: [PATCH 277/686] ci(stack-auth): paginate PR comments when finding CRAP sticky marker Addresses Copilot review on cipherstash/cipherstash-suite#2037: listComments was capped at per_page 100 with no pagination, so on a PR with >100 comments the existing CRAP marker could be missed and a duplicate sticky comment posted. Use github.paginate. --- .github/imported-workflows/crap-stack-auth.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/imported-workflows/crap-stack-auth.yml b/.github/imported-workflows/crap-stack-auth.yml index 8ebbf783b..02d08f479 100644 --- a/.github/imported-workflows/crap-stack-auth.yml +++ b/.github/imported-workflows/crap-stack-auth.yml @@ -83,7 +83,9 @@ jobs: const marker = ''; const { owner, repo } = context.repo; const issue_number = context.issue.number; - const { data: comments } = await github.rest.issues.listComments({ + // Paginate so the marker is found even on PRs with >100 comments, + // otherwise we'd post a duplicate sticky comment each run. + const comments = await github.paginate(github.rest.issues.listComments, { owner, repo, issue_number, per_page: 100, }); const existing = comments.find(c => c.body && c.body.includes(marker)); From 8ff01b4910289869ec05e5770798fbf073e83894 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Thu, 18 Jun 2026 15:07:30 +1000 Subject: [PATCH 278/686] ci(stack-auth): reuse crap:stack-auth mise task in CRAP workflow The crap-stack-auth.yml workflow duplicated the cargo llvm-cov + cargo crap invocation already defined in the crap:stack-auth mise task, leaving two copies of the exclusions and test selection to keep in sync. Call the mise task instead and pass only the CI-specific PR-comment flags as trailing args (mise forwards args after `--` to the task's last command, the cargo crap invocation). Adds a NOTE in tasks.toml so the cargo crap command stays last. --- .../imported-workflows/crap-stack-auth.yml | 19 +++++++------------ packages/stack-auth/tasks.toml | 3 +++ 2 files changed, 10 insertions(+), 12 deletions(-) diff --git a/.github/imported-workflows/crap-stack-auth.yml b/.github/imported-workflows/crap-stack-auth.yml index 02d08f479..c4f8e539c 100644 --- a/.github/imported-workflows/crap-stack-auth.yml +++ b/.github/imported-workflows/crap-stack-auth.yml @@ -52,19 +52,14 @@ jobs: sudo chown -R "$(id -u):$(id -g)" ./target - name: Generate coverage and CRAP report + # Reuse the `crap:stack-auth` mise task as the single source of truth for the + # coverage + `cargo crap` invocation (see packages/stack-auth/tasks.toml), so + # the exclusions and test selection only live in one place. mise forwards + # trailing args (after `--`) to the last command in the task — here + # `cargo crap` — so we append only the CI-specific flags that render the + # report as a sticky PR comment. run: | - # Exclude `stress_tests`: wall-clock timing/concurrency tests that become - # flaky under llvm-cov's instrumentation slowdown. They still run in the - # normal stack-auth test gate, and excluding them here does not change the - # CRAP report (the deterministic unit tests cover the same lines). - mise x --env test -- cargo llvm-cov nextest -p stack-auth --all-features \ - -E 'not test(stress_tests)' \ - --lcov --output-path target/stack-auth-lcov.info - cargo crap \ - --path packages/stack-auth \ - --lcov target/stack-auth-lcov.info \ - --exclude 'node/**' --exclude 'wasm/**' --exclude 'examples/**' \ - --exclude '**/tests.rs' --exclude '**/tests/**' \ + mise run crap:stack-auth -- \ --format pr-comment \ --repo-url "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}" \ --commit-ref "${{ github.event.pull_request.head.sha }}" \ diff --git a/packages/stack-auth/tasks.toml b/packages/stack-auth/tasks.toml index 5b97a379e..816a196db 100644 --- a/packages/stack-auth/tasks.toml +++ b/packages/stack-auth/tasks.toml @@ -21,5 +21,8 @@ run = [ # Score every production function. Excludes test code, examples and the node/wasm # binding crates so the report reflects the core library. Thresholds come from # the workspace-root .cargo-crap.toml. Add --fail-above here to turn it into a gate. + # NOTE: this must stay the LAST command — the crap-stack-auth.yml CI workflow runs + # this task and relies on mise appending its trailing args (--format pr-comment etc.) + # to this `cargo crap` invocation. "cargo crap --path packages/stack-auth --lcov {{config_root}}/target/stack-auth-lcov.info --exclude 'node/**' --exclude 'wasm/**' --exclude 'examples/**' --exclude '**/tests.rs' --exclude '**/tests/**'", ] From c8f79327f71159059fe3fd0a77e1c15462fcfbf3 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 18 Jun 2026 05:15:32 +0000 Subject: [PATCH 279/686] chore: release --- packages/stack-auth/CHANGELOG.md | 6 ++++++ packages/stack-auth/Cargo.toml | 2 +- packages/stack-profile/CHANGELOG.md | 1 + packages/stack-profile/Cargo.toml | 2 +- 4 files changed, 9 insertions(+), 2 deletions(-) diff --git a/packages/stack-auth/CHANGELOG.md b/packages/stack-auth/CHANGELOG.md index 80ac3e278..edb81ff74 100644 --- a/packages/stack-auth/CHANGELOG.md +++ b/packages/stack-auth/CHANGELOG.md @@ -1,5 +1,11 @@ +### CI + +- add cargo-crap (CRAP metric) coverage report +- reuse crap:stack-auth mise task in CRAP workflow + + ### Documentation - point authorize_dto refresher links at structs diff --git a/packages/stack-auth/Cargo.toml b/packages/stack-auth/Cargo.toml index d673f1d10..7a325ca8d 100644 --- a/packages/stack-auth/Cargo.toml +++ b/packages/stack-auth/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "stack-auth" description = "Authentication library for CipherStash services" -version = "0.37.0" +version = "0.37.1" edition.workspace = true authors.workspace = true repository.workspace = true diff --git a/packages/stack-profile/CHANGELOG.md b/packages/stack-profile/CHANGELOG.md index f7c23e921..60cdff051 100644 --- a/packages/stack-profile/CHANGELOG.md +++ b/packages/stack-profile/CHANGELOG.md @@ -28,6 +28,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - address Copilot review on Windows + crash durability + ## [0.34.0-alpha.1] - 2026-03-04 ### Changed diff --git a/packages/stack-profile/Cargo.toml b/packages/stack-profile/Cargo.toml index b89f42d06..b39c28daa 100644 --- a/packages/stack-profile/Cargo.toml +++ b/packages/stack-profile/Cargo.toml @@ -2,7 +2,7 @@ name = "stack-profile" description = "Centralised ~/.cipherstash profile file management" license-file = "LICENSE" -version = "0.37.0" +version = "0.37.1" edition.workspace = true authors.workspace = true repository.workspace = true From aae86ba74252aa5a24446140daea5c24d86f5e41 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Thu, 18 Jun 2026 13:39:49 +1000 Subject: [PATCH 280/686] test(stack-auth): deterministic expiry-crossing refresh test; clear CRAP findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the flaky wall-clock stress test with a deterministic regression test and clears the over-threshold CRAP findings surfaced by the cargo-crap run. Public API is unchanged. Part of CIP-3235. Clock injection (fixes the flakiness): - New crate-internal Clock abstraction (clock.rs): SystemClock in production (web_time, wasm-safe); a controllable TestClock in tests. - Token gains pub(crate) is_expired_at/is_usable_at taking an explicit `now`; the public is_expired/is_usable/expires_in delegate via the system clock. - AutoRefresh holds an injected clock and reads `now` once per get_token call, so every expiry decision in a call is mutually consistent. - The regression test drives expiry with a TestClock and gates the refresh with a Notify (gated mock Refresher) — no real sleeps, no network. Verified deterministic across 300 plain runs and 200 runs under llvm-cov (where the old test failed), and that it fails against the pre-fix Expired-instead-of-wait behaviour. CRAP findings: - DeviceSessionStrategyBuilder::build: extract the Token/Store match arms into build_from_token/build_from_store, and cover build_from_token via the public with_token().build() path (build 45.8 -> 3.0; build_from_token 0% -> 97%). - AuthError::error_code: table-driven test over every variant, pinning the stable FFI error-code contract (CRAP 31.5 -> 17.0, coverage 63% -> 95%). - From for AuthError: add conversion test (CRAP 20 -> 4). Review cleanups: - Extract shared #[cfg(test)] token/JWT fixtures into src/test_support.rs, de-duplicating make_jwt_token/valid_claims_json and the per-module Token literals across token.rs and device_session_strategy.rs. - Route the one remaining production expires_at stamp (Token::refresh) through now_unix_secs(); drop the now-unused web_time import from token.rs. - Harden is_expired_at against overflow (saturating_add); cache SystemClock in a process-wide LazyLock instead of allocating per AutoRefresh; drop unused derives on SystemClock/TestClock. No functions remain above the CRAP threshold. --- packages/stack-auth/src/auto_refresh.rs | 294 +++++++++++++----- packages/stack-auth/src/clock.rs | 90 ++++++ .../stack-auth/src/device_session_strategy.rs | 215 ++++++++----- packages/stack-auth/src/lib.rs | 81 +++-- packages/stack-auth/src/test_support.rs | 49 +++ packages/stack-auth/src/token.rs | 116 +++---- 6 files changed, 599 insertions(+), 246 deletions(-) create mode 100644 packages/stack-auth/src/clock.rs create mode 100644 packages/stack-auth/src/test_support.rs diff --git a/packages/stack-auth/src/auto_refresh.rs b/packages/stack-auth/src/auto_refresh.rs index 6c2b77448..870e575dd 100644 --- a/packages/stack-auth/src/auto_refresh.rs +++ b/packages/stack-auth/src/auto_refresh.rs @@ -2,6 +2,7 @@ use std::sync::atomic::{AtomicBool, Ordering}; use tokio::sync::{Mutex, MutexGuard, Notify}; +use crate::clock::{system_clock, SharedClock}; use crate::refresher::Refresher; use crate::token_store::{NoStore, TokenStore}; use crate::{ServiceToken, Token}; @@ -50,6 +51,9 @@ pub(crate) struct AutoRefresh { /// the mutex. refresh_in_progress: AtomicBool, refresh_notify: Notify, + /// Source of "now" for token-expiry checks. [`SystemClock`](crate::clock::SystemClock) + /// in production; an injected clock in tests so expiry is deterministic. + clock: SharedClock, } struct State { @@ -88,9 +92,9 @@ impl State { Ok(ServiceToken::new(token.access_token().clone())) } - fn require_usable_token(&self) -> Result { + fn require_usable_token(&self, now: u64) -> Result { let token = self.token.as_ref().ok_or(AutoRefreshError::NotFound)?; - if token.is_usable() { + if token.is_usable_at(now) { Ok(ServiceToken::new(token.access_token().clone())) } else { Err(AutoRefreshError::Expired) @@ -110,6 +114,21 @@ impl AutoRefresh { store: NoStore, refresh_in_progress: AtomicBool::new(false), refresh_notify: Notify::new(), + clock: system_clock(), + } + } + + /// Like [`with_token`](Self::with_token) but with an injected clock, so tests + /// can drive token expiry deterministically. + #[cfg(test)] + pub(crate) fn with_token_and_clock(refresher: R, token: Token, clock: SharedClock) -> Self { + Self { + refresher, + state: Mutex::new(State { token: Some(token) }), + store: NoStore, + refresh_in_progress: AtomicBool::new(false), + refresh_notify: Notify::new(), + clock, } } } @@ -129,6 +148,7 @@ impl AutoRefresh { store, refresh_in_progress: AtomicBool::new(false), refresh_notify: Notify::new(), + clock: system_clock(), } } } @@ -155,21 +175,26 @@ impl AutoRefresh { return self.initial_auth(&mut state).await; } - if !state.token.as_ref().is_some_and(|t| t.is_expired()) { + // Read "now" once from the injected clock and use it for every expiry + // decision in this call, so the checks are mutually consistent and + // deterministic under test. + let now = self.clock.now_unix_secs(); + + if !state.token.as_ref().is_some_and(|t| t.is_expired_at(now)) { return state.service_token(); } if self.refresh_in_progress.load(Ordering::Acquire) { - return self.wait_for_in_flight_refresh(state).await; + return self.wait_for_in_flight_refresh(state, now).await; } let Some(credential) = self.refresher.try_credential(state.token.as_mut()) else { - return state.require_usable_token(); + return state.require_usable_token(now); }; self.refresh_in_progress.store(true, Ordering::Release); - if state.token.as_ref().is_some_and(|t| t.is_usable()) { + if state.token.as_ref().is_some_and(|t| t.is_usable_at(now)) { self.refresh_non_blocking(state, credential).await } else { self.refresh_blocking(&mut state, credential).await @@ -233,9 +258,10 @@ impl AutoRefresh { async fn wait_for_in_flight_refresh( &self, state: MutexGuard<'_, State>, + now: u64, ) -> Result { if let Ok(token) = state.service_token() { - if state.token.as_ref().is_some_and(|t| t.is_usable()) { + if state.token.as_ref().is_some_and(|t| t.is_usable_at(now)) { return Ok(token); } } @@ -244,9 +270,11 @@ impl AutoRefresh { let notified = self.refresh_notify.notified(); drop(state); notified.await; - // Re-check after wake — refresh may have failed. + // Re-check after wake — refresh may have failed. Re-read the clock: an + // arbitrary amount of time may have passed while awaiting the refresh. + let now = self.clock.now_unix_secs(); let state = self.state.lock().await; - state.require_usable_token() + state.require_usable_token(now) } /// Token is expiring but still usable — drop the lock, refresh in the @@ -355,6 +383,24 @@ mod tests { use std::sync::Arc; use std::time::{SystemTime, UNIX_EPOCH}; + #[test] + fn auto_refresh_error_maps_to_public_auth_error() { + use crate::AuthError; + assert!(matches!( + AuthError::from(AutoRefreshError::NotFound), + AuthError::NotAuthenticated + )); + assert!(matches!( + AuthError::from(AutoRefreshError::Expired), + AuthError::TokenExpired + )); + // The `Auth` variant passes the inner error through unchanged. + assert!(matches!( + AuthError::from(AutoRefreshError::Auth(AuthError::AccessDenied)), + AuthError::AccessDenied + )); + } + fn make_token(access: &str, expires_in: u64, refresh: bool) -> Token { let now = SystemTime::now() .duration_since(UNIX_EPOCH) @@ -1157,74 +1203,6 @@ mod stress_tests { assert_eq!(stats.peak(), 1, "peak concurrency to refresh endpoint"); assert_eq!(stats.total(), 1, "total refresh requests"); } - - /// Reproduces the race condition where a token crosses real expiry during - /// an in-flight non-blocking refresh. Before the fix, late-arriving callers - /// would see `refresh_in_progress = true` + `!is_usable()` and return - /// `Err(Expired)` instead of waiting for the refresh to complete. - #[tokio::test(flavor = "multi_thread", worker_threads = 4)] - async fn waiters_receive_token_when_expiry_crosses() { - // Token with 1s until real expiry (minimum granularity since - // expires_at is in seconds). is_expired() = true (within 90s leeway), - // is_usable() = true (1s remaining). Refresh takes 1.5s so the token - // crosses real expiry mid-refresh. - let refresh_delay = Duration::from_millis(1500); - let counting = CountingState::new(); - let state = DelayedRefreshState { - counting: counting.clone(), - delay: refresh_delay, - }; - let (base_url, stats) = start_axum_server(delayed_refresh_handler, state).await; - let dir = tempfile::tempdir().unwrap(); - let strategy = Arc::new(auto_refresh_with_token( - &dir, - &base_url, - make_token("expiring-soon", 1, true), - )); - - // First caller triggers the non-blocking refresh and gets the old token. - let first = strategy.get_token().await.unwrap(); - assert_eq!( - first.as_str(), - "expiring-soon", - "first caller should receive the expiring token" - ); - - // Wait for the token to cross real expiry (but refresh is still in-flight). - tokio::time::sleep(Duration::from_millis(1100)).await; - - // Launch 50 concurrent callers. Without the fix, these would all get - // Err(Expired) because refresh_in_progress = true and !is_usable(). - let mut handles = Vec::with_capacity(CONCURRENCY); - for _ in 0..CONCURRENCY { - let s = Arc::clone(&strategy); - handles.push(tokio::spawn(async move { s.get_token().await })); - } - - let results: Vec<_> = { - let mut results = Vec::with_capacity(handles.len()); - for handle in handles { - results.push(handle.await.unwrap()); - } - results - }; - - // All callers must succeed — none should get Expired. - for (i, result) in results.iter().enumerate() { - assert!( - result.is_ok(), - "caller {i} got Err({:?}), expected Ok", - result.as_ref().unwrap_err() - ); - assert_eq!( - result.as_ref().unwrap().as_str(), - "refreshed-token", - "caller {i} should receive the refreshed token" - ); - } - - assert_eq!(stats.total(), 1, "only one refresh request should be made"); - } } mod given_fully_expired_token { @@ -1577,3 +1555,165 @@ mod stress_tests { } } } + +/// Deterministic regression test for the "token crosses real expiry while a +/// non-blocking refresh is in flight" race. +/// +/// Before the fix, late-arriving callers saw `refresh_in_progress = true` + +/// `!is_usable()` and returned `Err(Expired)` instead of waiting for the +/// in-flight refresh. The original reproduction (a wall-clock stress test) hung +/// the outcome on a ~1-second window — token expiry has whole-second +/// granularity — which made it latently flaky and broke outright under coverage +/// instrumentation. This version drives expiry with a [`TestClock`] and gates +/// the refresh with a [`Notify`], so it is fully deterministic: no real sleeps, +/// no network. +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod expiry_crossing_regression { + use super::*; + use crate::clock::TestClock; + use crate::{AuthError, SecretToken}; + use std::future::Future; + use std::sync::atomic::AtomicUsize; + use std::sync::Arc; + + /// Number of callers that arrive after the token crosses real expiry. + const WAITERS: usize = 8; + + /// A [`Refresher`] whose `refresh` blocks on a test-controlled gate, so the + /// test can hold a refresh "in flight" while it advances the clock and + /// launches waiters — with no wall-clock timing involved. + struct GatedRefresher { + /// Notified once `refresh` is entered (the refresh is now in flight). + started: Arc, + /// `refresh` awaits this; the test releases it to complete the refresh. + gate: Arc, + /// Counts `refresh` invocations — asserts exactly one refresh happens. + calls: Arc, + /// Absolute expiry stamped on the refreshed token. + refreshed_expires_at: u64, + } + + impl Refresher for GatedRefresher { + type Credential = (); + + fn save(&self, _token: &Token) {} + + fn try_credential(&self, token: Option<&mut Token>) -> Option { + // Refresh only when there's a token to refresh, matching the real + // refreshers' "needs a prior token" contract. + token.map(|_| ()) + } + + fn restore(&self, _token: &mut Token, _credential: Self::Credential) {} + + fn refresh( + &self, + _credential: &Self::Credential, + ) -> impl Future> + Send { + let started = Arc::clone(&self.started); + let gate = Arc::clone(&self.gate); + let calls = Arc::clone(&self.calls); + let refreshed_expires_at = self.refreshed_expires_at; + async move { + calls.fetch_add(1, Ordering::SeqCst); + started.notify_one(); + gate.notified().await; + Ok(make_token("refreshed-token", refreshed_expires_at)) + } + } + } + + fn make_token(access: &str, expires_at: u64) -> Token { + Token { + access_token: SecretToken::new(access), + refresh_token: Some(SecretToken::new("refresh-token")), + token_type: "Bearer".to_string(), + expires_at, + region: None, + client_id: None, + device_instance_id: None, + } + } + + #[tokio::test] + async fn waiters_wait_for_refresh_when_token_crosses_expiry() { + let clock = TestClock::new(1_000_000); + let started = Arc::new(Notify::new()); + let gate = Arc::new(Notify::new()); + let calls = Arc::new(AtomicUsize::new(0)); + + let refresher = GatedRefresher { + started: Arc::clone(&started), + gate: Arc::clone(&gate), + calls: Arc::clone(&calls), + refreshed_expires_at: clock.now() + 3600, + }; + + // Within the 90s leeway (so a refresh is triggered) but still usable at + // the current clock value (so the first caller takes the non-blocking + // path and gets the old token). + let token = make_token("expiring-soon", clock.now() + 10); + let strategy = Arc::new(AutoRefresh::with_token_and_clock( + refresher, + token, + clock.shared(), + )); + + // 1. First caller starts the non-blocking refresh. + let first = { + let s = Arc::clone(&strategy); + tokio::spawn(async move { s.get_token().await }) + }; + // Wait until the refresh is actually in flight (gated; won't complete yet). + started.notified().await; + + // 2. Advance the clock past real expiry while the refresh is still gated. + // The token is now both expired and unusable. + clock.advance(20); + + // 3. Launch waiters. They observe refresh_in_progress + !is_usable, so they + // must wait for the in-flight refresh rather than returning Expired. + let waiters: Vec<_> = (0..WAITERS) + .map(|_| { + let s = Arc::clone(&strategy); + tokio::spawn(async move { s.get_token().await }) + }) + .collect(); + + // Let the waiters reach their wait point. This is cooperative scheduling + // on the current-thread runtime (yielding lets the spawned waiters run + // until they park on the refresh notification), not a wall-clock delay. + for _ in 0..32 { + tokio::task::yield_now().await; + } + + // 4. Release the refresh. The first caller installs the new token and + // notifies the waiters, which then return the refreshed token. + gate.notify_one(); + + let first = first.await.unwrap().unwrap(); + assert_eq!( + first.as_str(), + "expiring-soon", + "first caller receives the old token (still usable when it was called)" + ); + + for (i, waiter) in waiters.into_iter().enumerate() { + let token = waiter.await.unwrap().unwrap_or_else(|e| { + panic!("waiter {i} returned Err({e:?}), expected the refreshed token") + }); + assert_eq!( + token.as_str(), + "refreshed-token", + "waiter {i} should receive the refreshed token, not Expired" + ); + } + + assert_eq!( + calls.load(Ordering::SeqCst), + 1, + "exactly one refresh should occur for all callers combined" + ); + } +} diff --git a/packages/stack-auth/src/clock.rs b/packages/stack-auth/src/clock.rs new file mode 100644 index 000000000..fd5189599 --- /dev/null +++ b/packages/stack-auth/src/clock.rs @@ -0,0 +1,90 @@ +//! A small abstraction over "the current Unix time", in whole seconds. +//! +//! Token expiry ([`Token::is_expired`](crate::Token::is_expired), +//! [`Token::is_usable`](crate::Token::is_usable)) is time-dependent. In +//! production that time comes from the system wall clock, but a wall clock makes +//! the refresh concurrency tests inherently racy — the token's usable window is +//! measured in whole seconds, so test setup (or coverage instrumentation) can +//! cross an expiry boundary before the assertions run. +//! +//! [`Clock`] lets [`AutoRefresh`](crate::auto_refresh::AutoRefresh) read "now" +//! from an injected source: [`SystemClock`] in production, a controllable clock +//! in tests. + +use std::sync::Arc; + +use web_time::{SystemTime, UNIX_EPOCH}; + +/// Source of the current time as seconds since the Unix epoch. +pub(crate) trait Clock: Send + Sync { + /// The current time, in whole seconds since the Unix epoch. + fn now_unix_secs(&self) -> u64; +} + +/// A shared, type-erased [`Clock`] handle. +/// +/// Type-erased (rather than a generic parameter on `AutoRefresh`) so injecting a +/// clock doesn't ripple a third generic through every strategy wrapper. +pub(crate) type SharedClock = Arc; + +/// The default [`Clock`]: the system wall clock. +/// +/// Uses `web_time` rather than `std::time` so it behaves correctly on `wasm32`, +/// matching the rest of the crate's time handling. +pub(crate) struct SystemClock; + +impl Clock for SystemClock { + fn now_unix_secs(&self) -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() + } +} + +/// The default shared clock (the system wall clock). +/// +/// Returns clones of one process-wide handle: `SystemClock` is a stateless ZST, +/// so there's no reason to allocate a fresh `Arc` per `AutoRefresh`. +pub(crate) fn system_clock() -> SharedClock { + static CLOCK: std::sync::LazyLock = + std::sync::LazyLock::new(|| Arc::new(SystemClock)); + CLOCK.clone() +} + +/// A [`Clock`] whose value is set explicitly by the test, so token expiry can be +/// driven deterministically rather than racing the wall clock. +#[cfg(test)] +#[derive(Clone)] +pub(crate) struct TestClock(Arc); + +#[cfg(test)] +impl TestClock { + /// Create a clock reading `now` seconds. + pub(crate) fn new(now: u64) -> Self { + Self(Arc::new(std::sync::atomic::AtomicU64::new(now))) + } + + /// Move the clock forward by `secs` seconds. + pub(crate) fn advance(&self, secs: u64) { + self.0.fetch_add(secs, std::sync::atomic::Ordering::SeqCst); + } + + /// The current value of the clock. + pub(crate) fn now(&self) -> u64 { + self.0.load(std::sync::atomic::Ordering::SeqCst) + } + + /// A [`SharedClock`] handle backed by this same underlying value, so the test + /// and the `AutoRefresh` under test observe identical time. + pub(crate) fn shared(&self) -> SharedClock { + Arc::new(self.clone()) + } +} + +#[cfg(test)] +impl Clock for TestClock { + fn now_unix_secs(&self) -> u64 { + self.now() + } +} diff --git a/packages/stack-auth/src/device_session_strategy.rs b/packages/stack-auth/src/device_session_strategy.rs index 367152136..c90f0d3b2 100644 --- a/packages/stack-auth/src/device_session_strategy.rs +++ b/packages/stack-auth/src/device_session_strategy.rs @@ -122,83 +122,152 @@ impl DeviceSessionStrategyBuilder { /// Resolves the base URL via service discovery unless overridden with /// `base_url` (available when the `test-utils` feature is enabled). pub fn build(self) -> Result { - match self.source { + let Self { + source, + base_url_override, + } = self; + match source { OAuthTokenSource::Token { region, client_id, - mut token, - } => { - let base_url = match self.base_url_override { - Some(url) => url, - None => crate::cts_base_url_from_env()? - .unwrap_or(CtsServiceDiscovery::endpoint(region)?), - }; - // Derive CRN from the explicit region parameter and the token's - // workspace claim. We can't use token.workspace_crn() here - // because set_region() hasn't been called on the token yet. - let crn = token - .workspace_id() - .map(|ws| Crn::new(region, ws)) - .map_err(|e| { - warn!("Could not extract workspace CRN from token: {e}"); - e - }) - .ok(); - let region_id = region.identifier(); - let device_instance_id = token.device_instance_id().map(String::from); - token.set_region(®ion_id); - token.set_client_id(&client_id); - let refresher = DeviceSessionRefresher::new( - None, - ensure_trailing_slash(base_url), - &client_id, - ®ion_id, - device_instance_id, - ); - Ok(DeviceSessionStrategy { - crn, - inner: AutoRefresh::with_token(refresher, token), - }) - } + token, + } => Self::build_from_token(region, client_id, token, base_url_override), #[cfg(not(target_arch = "wasm32"))] - OAuthTokenSource::Store(store) => { - let ws_store = store.current_workspace_store()?; - let token: Token = ws_store.load_profile()?; - - let region_str = token - .region() - .ok_or(AuthError::NotAuthenticated)? - .to_string(); - let client_id = token - .client_id() - .ok_or(AuthError::NotAuthenticated)? - .to_string(); - let crn = token - .workspace_crn() - .map_err(|e| { - warn!("Could not extract workspace CRN from token: {e}"); - e - }) - .ok(); - let device_instance_id = token.device_instance_id().map(String::from); - - let base_url = match self.base_url_override { - Some(url) => url, - None => crate::cts_base_url_from_env()?.unwrap_or(token.issuer()?), - }; - - let refresher = DeviceSessionRefresher::new( - Some(ws_store), - ensure_trailing_slash(base_url), - &client_id, - ®ion_str, - device_instance_id, - ); - Ok(DeviceSessionStrategy { - crn, - inner: AutoRefresh::with_token(refresher, token), - }) - } + OAuthTokenSource::Store(store) => Self::build_from_store(store, base_url_override), } } + + /// Build from a token supplied directly (in-memory only, no store). + fn build_from_token( + region: Region, + client_id: String, + mut token: Token, + base_url_override: Option, + ) -> Result { + let base_url = match base_url_override { + Some(url) => url, + None => { + crate::cts_base_url_from_env()?.unwrap_or(CtsServiceDiscovery::endpoint(region)?) + } + }; + // Derive CRN from the explicit region parameter and the token's + // workspace claim. We can't use token.workspace_crn() here + // because set_region() hasn't been called on the token yet. + let crn = token + .workspace_id() + .map(|ws| Crn::new(region, ws)) + .map_err(|e| { + warn!("Could not extract workspace CRN from token: {e}"); + e + }) + .ok(); + let region_id = region.identifier(); + let device_instance_id = token.device_instance_id().map(String::from); + token.set_region(®ion_id); + token.set_client_id(&client_id); + let refresher = DeviceSessionRefresher::new( + None, + ensure_trailing_slash(base_url), + &client_id, + ®ion_id, + device_instance_id, + ); + Ok(DeviceSessionStrategy { + crn, + inner: AutoRefresh::with_token(refresher, token), + }) + } + + /// Build from a token persisted in a [`ProfileStore`]. + #[cfg(not(target_arch = "wasm32"))] + fn build_from_store( + store: ProfileStore, + base_url_override: Option, + ) -> Result { + let ws_store = store.current_workspace_store()?; + let token: Token = ws_store.load_profile()?; + + let region_str = token + .region() + .ok_or(AuthError::NotAuthenticated)? + .to_string(); + let client_id = token + .client_id() + .ok_or(AuthError::NotAuthenticated)? + .to_string(); + let crn = token + .workspace_crn() + .map_err(|e| { + warn!("Could not extract workspace CRN from token: {e}"); + e + }) + .ok(); + let device_instance_id = token.device_instance_id().map(String::from); + + let base_url = match base_url_override { + Some(url) => url, + None => crate::cts_base_url_from_env()?.unwrap_or(token.issuer()?), + }; + + let refresher = DeviceSessionRefresher::new( + Some(ws_store), + ensure_trailing_slash(base_url), + &client_id, + ®ion_str, + device_instance_id, + ); + Ok(DeviceSessionStrategy { + crn, + inner: AutoRefresh::with_token(refresher, token), + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_support::{claims_with_workspace, jwt_token, raw_token}; + + fn base_url() -> url::Url { + "https://cts.example.com".parse().expect("valid url") + } + + #[test] + fn build_from_token_derives_crn_from_the_jwt_workspace_claim() { + let region = Region::aws("ap-southeast-2").expect("valid region"); + let token = jwt_token(claims_with_workspace("7366ITCXSAPCH5TN")); + + let strategy = DeviceSessionStrategy::with_token(region, "my-client", token) + .base_url(base_url()) + .build() + .expect("build should succeed for a valid token"); + + let crn = strategy + .workspace_crn() + .expect("CRN should be derived from the workspace claim") + .to_string(); + assert!(crn.starts_with("crn:"), "unexpected CRN: {crn}"); + assert!( + crn.contains("7366ITCXSAPCH5TN"), + "CRN should carry the token's workspace, got: {crn}" + ); + } + + #[test] + fn build_from_token_succeeds_without_a_crn_when_the_workspace_claim_is_absent() { + let region = Region::aws("ap-southeast-2").expect("valid region"); + // A non-JWT access token: the workspace claim can't be decoded, so CRN + // derivation is skipped (it is best-effort) but the build still succeeds. + let token = raw_token("not-a-jwt"); + + let strategy = DeviceSessionStrategy::with_token(region, "my-client", token) + .base_url(base_url()) + .build() + .expect("build should succeed even when the CRN can't be derived"); + + assert!( + strategy.workspace_crn().is_none(), + "CRN should be None when the token has no decodable workspace claim" + ); + } } diff --git a/packages/stack-auth/src/lib.rs b/packages/stack-auth/src/lib.rs index 9ce4ab0b1..1dfcbf42a 100644 --- a/packages/stack-auth/src/lib.rs +++ b/packages/stack-auth/src/lib.rs @@ -37,6 +37,7 @@ mod auth_strategy_fn; mod authorize_dto; mod auto_refresh; mod auto_strategy; +mod clock; mod device_session_refresher; mod device_session_strategy; mod oidc_federation_strategy; @@ -58,6 +59,9 @@ mod device_code; #[cfg(any(test, feature = "test-utils"))] mod static_token_strategy; +#[cfg(test)] +mod test_support; + pub use access_key::{AccessKey, InvalidAccessKey}; pub use access_key_strategy::{AccessKeyStrategy, AccessKeyStrategyBuilder}; pub use auth_strategy_fn::AuthStrategyFn; @@ -473,24 +477,65 @@ pub(crate) fn http_client() -> reqwest::Client { mod tests { use super::*; + /// The `error_code` strings are a stable contract surfaced across FFI + /// (JS `Error.code`, Node-API codes), so pin every variant's code. Covers + /// all variants except `Request`, whose inner `reqwest::Error` has no public + /// constructor; if a new variant is added without a code, `error_code`'s + /// exhaustive match fails to compile, so the contract can't silently drift. #[test] - fn auth_error_code_known_variants() { - assert_eq!(AuthError::AccessDenied.error_code(), "ACCESS_DENIED"); - assert_eq!(AuthError::TokenExpired.error_code(), "EXPIRED_TOKEN"); - assert_eq!(AuthError::InvalidGrant.error_code(), "INVALID_GRANT"); - assert_eq!(AuthError::InvalidClient.error_code(), "INVALID_CLIENT"); - assert_eq!( - AuthError::NotAuthenticated.error_code(), - "NOT_AUTHENTICATED" - ); - assert_eq!( - AuthError::MissingWorkspaceCrn.error_code(), - "MISSING_WORKSPACE_CRN" - ); - assert_eq!(AuthError::Server("x".into()).error_code(), "SERVER_ERROR"); - assert_eq!( - AuthError::InvalidToken("malformed".into()).error_code(), - "INVALID_TOKEN" - ); + #[allow(clippy::unwrap_used)] + fn auth_error_code_is_stable_for_every_variant() { + let workspace = "ZVATKW3VHMFG27DY" + .parse::() + .unwrap(); + + let cases: Vec<(AuthError, &str)> = vec![ + (AuthError::AccessDenied, "ACCESS_DENIED"), + (AuthError::TokenExpired, "EXPIRED_TOKEN"), + (AuthError::InvalidGrant, "INVALID_GRANT"), + (AuthError::InvalidClient, "INVALID_CLIENT"), + (AuthError::NotAuthenticated, "NOT_AUTHENTICATED"), + (AuthError::MissingWorkspaceCrn, "MISSING_WORKSPACE_CRN"), + (AuthError::Server("boom".into()), "SERVER_ERROR"), + (AuthError::InvalidToken("malformed".into()), "INVALID_TOKEN"), + ( + AuthError::InvalidUrl("not a url".parse::().unwrap_err()), + "INVALID_URL", + ), + ( + AuthError::Region("not-a-region".parse::().unwrap_err()), + "INVALID_REGION", + ), + ( + AuthError::InvalidCrn("not-a-crn".parse::().unwrap_err()), + "INVALID_CRN", + ), + ( + AuthError::InvalidWorkspaceId("!".parse::().unwrap_err()), + "INVALID_WORKSPACE_ID", + ), + ( + AuthError::InvalidAccessKey( + "".parse::().unwrap_err(), + ), + "INVALID_ACCESS_KEY", + ), + ( + AuthError::WorkspaceMismatch { + expected_workspace: workspace, + token_workspace: workspace, + }, + "WORKSPACE_MISMATCH", + ), + #[cfg(not(target_arch = "wasm32"))] + ( + AuthError::Store(stack_profile::ProfileError::HomeDirNotFound), + "STORE_ERROR", + ), + ]; + + for (err, expected) in cases { + assert_eq!(err.error_code(), expected, "error_code for {err:?}"); + } } } diff --git a/packages/stack-auth/src/test_support.rs b/packages/stack-auth/src/test_support.rs new file mode 100644 index 000000000..3f485b5d3 --- /dev/null +++ b/packages/stack-auth/src/test_support.rs @@ -0,0 +1,49 @@ +//! Shared, crate-internal test helpers for minting [`Token`]s. +//! +//! Several test modules need a token carrying specific JWT claims, or a token +//! with an arbitrary raw access-token string. Keeping one definition here avoids +//! the fixture drift that comes from copy-pasting the `Token { .. }` literal and +//! the unsigned-JWT mint into every test module. + +use crate::{SecretToken, Token}; + +/// A [`Token`] with the given raw access-token string and a far-future expiry, +/// so it reads as valid. The access token need not be a JWT — pass any string to +/// exercise the not-a-JWT paths. +pub(crate) fn raw_token(access_token: &str) -> Token { + Token { + access_token: SecretToken::new(access_token), + token_type: "Bearer".to_string(), + expires_at: u64::MAX, + refresh_token: Some(SecretToken::new("refresh-token")), + region: None, + client_id: None, + device_instance_id: None, + } +} + +/// A [`Token`] whose access token is a real (unsigned) JWT carrying `claims`. +pub(crate) fn jwt_token(claims: serde_json::Value) -> Token { + use jsonwebtoken::{encode, EncodingKey, Header}; + let jwt = encode( + &Header::default(), + &claims, + &EncodingKey::from_secret(b"test-secret"), + ) + .expect("encode JWT"); + raw_token(&jwt) +} + +/// Standard CTS JWT claims for `workspace`, with the other required claims +/// (`iss`/`sub`/`aud`/`iat`/`exp`/`scope`) filled in with valid placeholders. +pub(crate) fn claims_with_workspace(workspace: &str) -> serde_json::Value { + serde_json::json!({ + "workspace": workspace, + "iss": "https://cts.example.com", + "sub": "user-123", + "aud": "https://cts.example.com", + "iat": 1_700_000_000u64, + "exp": 1_700_003_600u64, + "scope": "dataset:create", + }) +} diff --git a/packages/stack-auth/src/token.rs b/packages/stack-auth/src/token.rs index d4dea5508..74e1ce73b 100644 --- a/packages/stack-auth/src/token.rs +++ b/packages/stack-auth/src/token.rs @@ -1,5 +1,3 @@ -use web_time::{SystemTime, UNIX_EPOCH}; - use cts_common::claims::Claims; use cts_common::{Crn, Region, WorkspaceId}; use url::Url; @@ -19,6 +17,16 @@ impl stack_profile::ProfileData for Token { /// callers can still use the current token. const EXPIRY_LEEWAY_SECS: u64 = 90; +/// The current Unix time in whole seconds, from the system wall clock. +/// +/// Delegates to [`SystemClock`](crate::clock::SystemClock) so the crate has a +/// single definition of "now"; the `*_at` methods take an explicit `now` for +/// tests that drive a [`Clock`](crate::clock::Clock). +fn now_unix_secs() -> u64 { + use crate::clock::{Clock, SystemClock}; + SystemClock.now_unix_secs() +} + /// An access token returned by a successful authentication flow. /// /// The token contains a [`SecretToken`] (the bearer credential), a token type @@ -59,11 +67,7 @@ impl Token { /// How many seconds until the token expires (computed from the current time). pub fn expires_in(&self) -> u64 { - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_secs(); - self.expires_at.saturating_sub(now) + self.expires_at.saturating_sub(now_unix_secs()) } /// Returns `true` if the token has expired (with 90 seconds of leeway). @@ -75,11 +79,16 @@ impl Token { /// For checking whether the token is still usable as a bearer credential, /// use [`is_usable`](Self::is_usable) instead. pub fn is_expired(&self) -> bool { - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_secs(); - now + EXPIRY_LEEWAY_SECS >= self.expires_at + self.is_expired_at(now_unix_secs()) + } + + /// [`is_expired`](Self::is_expired) evaluated against an explicit `now` + /// (seconds since the Unix epoch) rather than the wall clock. + /// + /// Used internally so [`AutoRefresh`](crate::auto_refresh::AutoRefresh) can + /// drive expiry from an injected [`Clock`](crate::clock::Clock). + pub(crate) fn is_expired_at(&self, now: u64) -> bool { + now.saturating_add(EXPIRY_LEEWAY_SECS) >= self.expires_at } /// Returns `true` if the token is still usable (before the actual expiry timestamp). @@ -87,10 +96,12 @@ impl Token { /// Unlike [`is_expired`](Self::is_expired) which includes 90s leeway for preemptive /// refresh, this only returns `false` when the token has genuinely expired. pub fn is_usable(&self) -> bool { - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_secs(); + self.is_usable_at(now_unix_secs()) + } + + /// [`is_usable`](Self::is_usable) evaluated against an explicit `now` + /// (seconds since the Unix epoch) rather than the wall clock. + pub(crate) fn is_usable_at(&self, now: u64) -> bool { now < self.expires_at } @@ -247,15 +258,11 @@ impl Token { } let token_resp: RefreshResponse = resp.json().await?; - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_secs(); Ok(Token { access_token: token_resp.access_token, token_type: token_resp.token_type, - expires_at: now + token_resp.expires_in, + expires_at: now_unix_secs() + token_resp.expires_in, refresh_token: token_resp.refresh_token, region: None, client_id: None, @@ -295,19 +302,15 @@ struct RefreshErrorResponse { #[cfg(test)] mod tests { use super::*; + use crate::test_support::{claims_with_workspace, jwt_token, raw_token}; use crate::AuthError; use mocktail::prelude::*; fn make_token(expires_in: u64, refresh: bool) -> Token { - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap() - .as_secs(); - Token { access_token: SecretToken::new("test-access-token"), token_type: "Bearer".to_string(), - expires_at: now + expires_in, + expires_at: now_unix_secs() + expires_in, refresh_token: if refresh { Some(SecretToken::new("test-refresh-token")) } else { @@ -489,84 +492,41 @@ mod tests { // ---- decode_claims / workspace_id / issuer tests ---- - /// Build a Token whose access_token is a real (unsigned) JWT containing the - /// given claims JSON. - fn make_jwt_token(claims_json: serde_json::Value) -> Token { - use jsonwebtoken::{encode, EncodingKey, Header}; - let jwt = encode( - &Header::default(), - &claims_json, - &EncodingKey::from_secret(b"test-secret"), - ) - .expect("failed to encode JWT"); - - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap() - .as_secs(); - - Token { - access_token: SecretToken::new(jwt), - token_type: "Bearer".to_string(), - expires_at: now + 3600, - refresh_token: None, - region: None, - client_id: None, - device_instance_id: None, - } - } - fn valid_claims_json() -> serde_json::Value { - serde_json::json!({ - "workspace": "7366ITCXSAPCH5TN", - "iss": "https://cts.example.com", - "sub": "user-123", - "aud": "https://cts.example.com", - "iat": 1700000000u64, - "exp": 1700003600u64, - "scope": "dataset:create" - }) + claims_with_workspace("7366ITCXSAPCH5TN") } #[test] fn test_workspace_id_extracts_from_jwt() { - let token = make_jwt_token(valid_claims_json()); + let token = jwt_token(valid_claims_json()); let ws = token.workspace_id().expect("should extract workspace ID"); assert_eq!(ws.to_string(), "7366ITCXSAPCH5TN"); } #[test] fn test_issuer_extracts_url_from_jwt() { - let token = make_jwt_token(valid_claims_json()); + let token = jwt_token(valid_claims_json()); let issuer = token.issuer().expect("should extract issuer"); assert_eq!(issuer.as_str(), "https://cts.example.com/"); } #[test] fn test_workspace_id_fails_on_invalid_jwt() { - let token = Token { - access_token: SecretToken::new("not-a-jwt"), - token_type: "Bearer".to_string(), - expires_at: 0, - refresh_token: None, - region: None, - client_id: None, - device_instance_id: None, - }; + let token = raw_token("not-a-jwt"); let err = token.workspace_id().unwrap_err(); assert!(matches!(err, AuthError::InvalidToken(_))); } #[test] fn test_issuer_fails_on_missing_claims() { - let token = make_jwt_token(serde_json::json!({"sub": "user-123"})); + let token = jwt_token(serde_json::json!({"sub": "user-123"})); let err = token.issuer().unwrap_err(); assert!(matches!(err, AuthError::InvalidToken(_))); } #[test] fn test_workspace_crn_derives_from_region_and_workspace() { - let mut token = make_jwt_token(valid_claims_json()); + let mut token = jwt_token(valid_claims_json()); token.set_region("ap-southeast-2.aws"); let crn = token.workspace_crn().expect("should derive workspace CRN"); assert_eq!(crn.to_string(), "crn:ap-southeast-2.aws:7366ITCXSAPCH5TN"); @@ -574,14 +534,14 @@ mod tests { #[test] fn test_workspace_crn_fails_without_region() { - let token = make_jwt_token(valid_claims_json()); + let token = jwt_token(valid_claims_json()); let err = token.workspace_crn().unwrap_err(); assert!(matches!(err, AuthError::NotAuthenticated)); } #[test] fn test_workspace_crn_fails_with_invalid_region() { - let mut token = make_jwt_token(valid_claims_json()); + let mut token = jwt_token(valid_claims_json()); token.set_region("invalid-region"); let err = token.workspace_crn().unwrap_err(); assert!(matches!(err, AuthError::Server(_))); From bd71ebbe1263ac3e1c059e163687a41427f4bc62 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Thu, 18 Jun 2026 15:14:25 +1000 Subject: [PATCH 281/686] test(stack-auth): cover is_*_at boundaries and failed-refresh expiry path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address the two test-gap review comments on cipherstash/cipherstash-suite#2039: - token.rs: direct boundary tests for the pure `is_usable_at` / `is_expired_at` predicates — exactly-at-expiry, the 90s leeway edge, the expired-but-still-usable leeway window (the state that drives the non-blocking refresh), and the `saturating_add` overflow guard that test_support::raw_token's `u64::MAX` expiry relies on. - auto_refresh.rs: the failure counterpart to the expiry-crossing regression test. When the in-flight refresh fails and the clock has crossed real expiry, waiters waking in wait_for_in_flight_refresh must re-read the clock, find the token unusable, and return Expired — not hang, not hand back a stale token. Adds a FailingGatedRefresher and drives it deterministically with TestClock + Notify. --- packages/stack-auth/src/auto_refresh.rs | 116 ++++++++++++++++++++++++ packages/stack-auth/src/token.rs | 56 ++++++++++++ 2 files changed, 172 insertions(+) diff --git a/packages/stack-auth/src/auto_refresh.rs b/packages/stack-auth/src/auto_refresh.rs index 870e575dd..197191f8b 100644 --- a/packages/stack-auth/src/auto_refresh.rs +++ b/packages/stack-auth/src/auto_refresh.rs @@ -1716,4 +1716,120 @@ mod expiry_crossing_regression { "exactly one refresh should occur for all callers combined" ); } + + /// A [`Refresher`] like [`GatedRefresher`], but whose gated `refresh` + /// resolves to `Err` once released — so the test can exercise the *failure* + /// axis of the in-flight-refresh race. + struct FailingGatedRefresher { + started: Arc, + gate: Arc, + calls: Arc, + } + + impl Refresher for FailingGatedRefresher { + type Credential = (); + + fn save(&self, _token: &Token) {} + + fn try_credential(&self, token: Option<&mut Token>) -> Option { + token.map(|_| ()) + } + + fn restore(&self, _token: &mut Token, _credential: Self::Credential) {} + + fn refresh( + &self, + _credential: &Self::Credential, + ) -> impl Future> + Send { + let started = Arc::clone(&self.started); + let gate = Arc::clone(&self.gate); + let calls = Arc::clone(&self.calls); + async move { + calls.fetch_add(1, Ordering::SeqCst); + started.notify_one(); + gate.notified().await; + Err(AuthError::TokenExpired) + } + } + } + + /// The failure counterpart to + /// [`waiters_wait_for_refresh_when_token_crosses_expiry`]: when the in-flight + /// refresh *fails* and the clock has crossed real expiry, waiters waking in + /// [`AutoRefresh::wait_for_in_flight_refresh`] must re-read the clock, find + /// the cached token unusable via `require_usable_token(now)`, and return + /// `Expired` — they must not hang, and must not hand back a stale token. This + /// is exactly the branch the post-wake clock re-read (the `now` re-read after + /// `notified().await`) exists to make correct. + #[tokio::test] + async fn waiters_get_expired_when_in_flight_refresh_fails() { + let clock = TestClock::new(1_000_000); + let started = Arc::new(Notify::new()); + let gate = Arc::new(Notify::new()); + let calls = Arc::new(AtomicUsize::new(0)); + + let refresher = FailingGatedRefresher { + started: Arc::clone(&started), + gate: Arc::clone(&gate), + calls: Arc::clone(&calls), + }; + + // Within the 90s leeway (triggers a refresh) but still usable now, so the + // first caller takes the non-blocking path and captures the old token. + let token = make_token("expiring-soon", clock.now() + 10); + let strategy = Arc::new(AutoRefresh::with_token_and_clock( + refresher, + token, + clock.shared(), + )); + + // 1. First caller starts the (gated) non-blocking refresh. + let first = { + let s = Arc::clone(&strategy); + tokio::spawn(async move { s.get_token().await }) + }; + started.notified().await; + + // 2. Advance the clock past real expiry while the refresh is still gated. + // The cached token is now both expired and unusable. + clock.advance(20); + + // 3. Launch waiters. They observe refresh_in_progress + !is_usable, so + // they park in wait_for_in_flight_refresh rather than returning early. + let waiters: Vec<_> = (0..WAITERS) + .map(|_| { + let s = Arc::clone(&strategy); + tokio::spawn(async move { s.get_token().await }) + }) + .collect(); + for _ in 0..32 { + tokio::task::yield_now().await; + } + + // 4. Release the refresh → it returns Err. The first caller still returns + // the old token it captured while it was usable; the waiters re-read + // the now-advanced clock, find the token unusable, and get Expired. + gate.notify_one(); + + let first = first.await.unwrap(); + assert_eq!( + first.unwrap().as_str(), + "expiring-soon", + "first caller keeps the old token it captured before the refresh failed" + ); + + for (i, waiter) in waiters.into_iter().enumerate() { + let result = waiter.await.unwrap(); + assert!( + matches!(result, Err(AutoRefreshError::Expired)), + "waiter {i} should get Expired after the failed refresh, got: {result:?}" + ); + } + + assert_eq!( + calls.load(Ordering::SeqCst), + 1, + "exactly one refresh attempt for all callers combined" + ); + } } diff --git a/packages/stack-auth/src/token.rs b/packages/stack-auth/src/token.rs index 74e1ce73b..a8acfcd74 100644 --- a/packages/stack-auth/src/token.rs +++ b/packages/stack-auth/src/token.rs @@ -354,6 +354,62 @@ mod tests { ); } + // ---- is_expired_at / is_usable_at boundary tests ---- + + /// A token with an explicit absolute `expires_at`, for driving the `*_at` + /// predicates against precise boundary values (unlike `make_token`, which is + /// relative to the wall clock). + fn token_expiring_at(expires_at: u64) -> Token { + Token { + access_token: SecretToken::new("t"), + token_type: "Bearer".to_string(), + expires_at, + refresh_token: None, + region: None, + client_id: None, + device_instance_id: None, + } + } + + #[test] + fn is_usable_at_boundary() { + let t = token_expiring_at(1000); + assert!(t.is_usable_at(999), "before expiry → usable"); + assert!(!t.is_usable_at(1000), "exactly at expiry → not usable"); + assert!(!t.is_usable_at(1001), "past expiry → not usable"); + } + + #[test] + fn is_expired_at_leeway_window() { + // EXPIRY_LEEWAY_SECS == 90: `is_expired_at` flips to true 90s ahead of + // the real expiry timestamp so refresh is triggered preemptively. + let t = token_expiring_at(1000); + assert!( + !t.is_expired_at(909), + "just outside the 90s leeway → not expired" + ); + assert!(t.is_expired_at(910), "exactly at the leeway edge → expired"); + // Inside the leeway window the token reads as "expired" (so a refresh is + // triggered) yet is still usable — this is the expired-but-usable state + // that drives AutoRefresh's non-blocking refresh path. + assert!( + t.is_expired_at(950) && t.is_usable_at(950), + "inside the leeway: expired but still usable" + ); + } + + #[test] + fn is_expired_at_saturates_near_u64_max() { + // `is_expired_at` computes `now + EXPIRY_LEEWAY_SECS`; a plain add would + // overflow and panic in debug builds. `test_support::raw_token` mints + // tokens with `expires_at == u64::MAX`, so the saturating add must hold. + let t = token_expiring_at(u64::MAX); + assert!( + t.is_expired_at(u64::MAX), + "saturating_add must not overflow at the u64 ceiling" + ); + } + // ---- refresh() tests ---- #[tokio::test] From ff9c1db183d937627c3a374c3ce81a30b4366b8e Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Thu, 18 Jun 2026 15:24:30 +1000 Subject: [PATCH 282/686] test(stack-auth): assert backwards wall-clock is handled gracefully MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Answers the review question on clock.rs: a wall clock jumping backwards (NTP step, VM snapshot restore) must not panic or force a spurious refresh. get_token samples `now` once per call and re-evaluates the pure is_expired_at/is_usable_at predicates, and the only time subtraction (Token::expires_in) saturates — so there is no cross-call delta to underflow. Adds TestClock::set and a test driving the clock 100_000s into the past, asserting the token still reads fresh with no refresh attempt. --- packages/stack-auth/src/auto_refresh.rs | 49 +++++++++++++++++++++++++ packages/stack-auth/src/clock.rs | 7 ++++ 2 files changed, 56 insertions(+) diff --git a/packages/stack-auth/src/auto_refresh.rs b/packages/stack-auth/src/auto_refresh.rs index 197191f8b..b3cadf6a7 100644 --- a/packages/stack-auth/src/auto_refresh.rs +++ b/packages/stack-auth/src/auto_refresh.rs @@ -1832,4 +1832,53 @@ mod expiry_crossing_regression { "exactly one refresh attempt for all callers combined" ); } + + /// A [`Refresher`] whose `refresh` panics if it is ever called, so a test can + /// assert that no refresh is triggered. + struct NeverRefresher; + + impl Refresher for NeverRefresher { + type Credential = (); + + fn save(&self, _token: &Token) {} + + fn try_credential(&self, token: Option<&mut Token>) -> Option { + token.map(|_| ()) + } + + fn restore(&self, _token: &mut Token, _credential: Self::Credential) {} + + // Keep the explicit `impl Future + Send` form to match the sibling test + // refreshers; the trivial body would otherwise trip `manual_async_fn`. + #[allow(clippy::manual_async_fn)] + fn refresh( + &self, + _credential: &Self::Credential, + ) -> impl Future> + Send { + async { panic!("refresh must not be called while the token reads as fresh") } + } + } + + /// A wall clock running *backwards* (NTP step, VM snapshot restore) must not + /// panic or spuriously force a refresh. Each `get_token` call samples `now` + /// once and re-evaluates the pure `is_expired_at`/`is_usable_at` predicates, + /// and the only time subtraction in the crate (`Token::expires_in`) saturates + /// — so there is no cross-call delta to underflow. A rewind simply makes the + /// token read as fresh again. + #[tokio::test] + async fn backwards_clock_does_not_panic_or_force_refresh() { + let clock = TestClock::new(1_000_000); + // Fresh token: expires well beyond the 90s leeway. + let token = make_token("fresh", clock.now() + 3600); + let strategy = AutoRefresh::with_token_and_clock(NeverRefresher, token, clock.shared()); + + // Forward reading returns the cached token without refreshing. + assert_eq!(strategy.get_token().await.unwrap().as_str(), "fresh"); + + // The wall clock jumps 100_000s into the past. + clock.set(900_000); + + // Still fresh, still no refresh (NeverRefresher would panic), no hang. + assert_eq!(strategy.get_token().await.unwrap().as_str(), "fresh"); + } } diff --git a/packages/stack-auth/src/clock.rs b/packages/stack-auth/src/clock.rs index fd5189599..dd017efc6 100644 --- a/packages/stack-auth/src/clock.rs +++ b/packages/stack-auth/src/clock.rs @@ -70,6 +70,13 @@ impl TestClock { self.0.fetch_add(secs, std::sync::atomic::Ordering::SeqCst); } + /// Set the clock to an arbitrary value, including one earlier than the + /// current reading — used to simulate the wall clock jumping backwards + /// (NTP step, VM snapshot restore, manual clock change). + pub(crate) fn set(&self, now: u64) { + self.0.store(now, std::sync::atomic::Ordering::SeqCst); + } + /// The current value of the clock. pub(crate) fn now(&self) -> u64 { self.0.load(std::sync::atomic::Ordering::SeqCst) From 8a04f26b75b8260273c521b2ec17cb5d0b80dc81 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Thu, 18 Jun 2026 20:10:49 +1000 Subject: [PATCH 283/686] ci(stack-auth): make the CRAP workflow blocking MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Now that cipherstash/cipherstash-suite#2039 clears the two over-threshold findings, switch the CRAP check from report-only to a gate, as agreed on cipherstash/cipherstash-suite#2037: - crap:stack-auth mise task runs `cargo crap --fail-above`, so it exits non-zero when any function's CRAP score exceeds the .cargo-crap.toml threshold (30) — gating both local runs and CI. - Workflow drops the sticky-comment step (and pull-requests: write); it now just runs the task with `--format github`, surfacing offending functions as inline PR annotations before the job fails. --- .../imported-workflows/crap-stack-auth.yml | 62 +++++-------------- packages/stack-auth/tasks.toml | 11 ++-- 2 files changed, 22 insertions(+), 51 deletions(-) diff --git a/.github/imported-workflows/crap-stack-auth.yml b/.github/imported-workflows/crap-stack-auth.yml index c4f8e539c..9995e4393 100644 --- a/.github/imported-workflows/crap-stack-auth.yml +++ b/.github/imported-workflows/crap-stack-auth.yml @@ -1,13 +1,14 @@ -name: "stack-auth CRAP report" +name: "stack-auth CRAP gate" -# Posts the CRAP (Change Risk Anti-Patterns) metric for stack-auth as a sticky -# PR comment on every PR that touches the package. CRAP = cyclomatic complexity -# weighted by test coverage, so it surfaces complex, under-tested functions — -# the kind of code where a subtle bug like CIP-3233 (#2036) can hide. +# Gates PRs that touch stack-auth on the CRAP (Change Risk Anti-Patterns) metric: +# cyclomatic complexity weighted by test coverage, so it surfaces complex, +# under-tested functions — the kind of code where a subtle bug like CIP-3233 +# (#2036) can hide. # -# Report-only: this never fails the build. To turn it into a gate later, add -# `--fail-above` (absolute threshold) or `--baseline`/`--fail-regression` -# (fail only when a PR makes an existing function's CRAP score worse). +# Blocking: the `crap:stack-auth` mise task runs with `--fail-above`, so the job +# fails when any function's CRAP score exceeds the threshold in .cargo-crap.toml +# (30). Offending functions surface as inline PR annotations (`--format github`). +# To relax this to regression-only later, switch to `--baseline`/`--fail-regression`. on: pull_request: paths: @@ -24,10 +25,9 @@ defaults: run: shell: bash -# Needed to post/update the report comment on the PR. +# Read-only: failures surface as job status + inline annotations, not a comment. permissions: contents: read - pull-requests: write env: RUST_BACKTRACE: full @@ -51,41 +51,11 @@ jobs: mkdir -p ./target sudo chown -R "$(id -u):$(id -g)" ./target - - name: Generate coverage and CRAP report + - name: Run CRAP gate # Reuse the `crap:stack-auth` mise task as the single source of truth for the # coverage + `cargo crap` invocation (see packages/stack-auth/tasks.toml), so - # the exclusions and test selection only live in one place. mise forwards - # trailing args (after `--`) to the last command in the task — here - # `cargo crap` — so we append only the CI-specific flags that render the - # report as a sticky PR comment. - run: | - mise run crap:stack-auth -- \ - --format pr-comment \ - --repo-url "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}" \ - --commit-ref "${{ github.event.pull_request.head.sha }}" \ - --output target/crap-comment.md - - - name: Post CRAP report as a sticky PR comment - # Only PRs have a comment thread; on workflow_dispatch the report is - # still generated above and visible in the job logs. - if: github.event_name == 'pull_request' - uses: actions/github-script@v7 - with: - script: | - const fs = require('fs'); - const body = fs.readFileSync('target/crap-comment.md', 'utf8'); - // cargo crap's pr-comment output starts with this marker. - const marker = ''; - const { owner, repo } = context.repo; - const issue_number = context.issue.number; - // Paginate so the marker is found even on PRs with >100 comments, - // otherwise we'd post a duplicate sticky comment each run. - const comments = await github.paginate(github.rest.issues.listComments, { - owner, repo, issue_number, per_page: 100, - }); - const existing = comments.find(c => c.body && c.body.includes(marker)); - if (existing) { - await github.rest.issues.updateComment({ owner, repo, comment_id: existing.id, body }); - } else { - await github.rest.issues.createComment({ owner, repo, issue_number, body }); - } + # the exclusions, threshold and `--fail-above` gate live in one place. mise + # forwards trailing args (after `--`) to the task's last command (`cargo + # crap`); `--format github` emits one `::warning` annotation per offending + # function so they show inline on the PR before the job fails. + run: mise run crap:stack-auth -- --format github diff --git a/packages/stack-auth/tasks.toml b/packages/stack-auth/tasks.toml index 816a196db..5622a6b85 100644 --- a/packages/stack-auth/tasks.toml +++ b/packages/stack-auth/tasks.toml @@ -9,7 +9,7 @@ run = [ ] ["crap:stack-auth"] -description = "Report the CRAP (Change Risk Anti-Patterns) metric for stack-auth — flags complex, under-tested functions" +description = "Gate stack-auth on the CRAP (Change Risk Anti-Patterns) metric — fails when a complex, under-tested function exceeds the threshold" run = [ # Instrument and run the unit tests, emitting LCOV coverage that `cargo crap` consumes. # `stress_tests` are excluded: they are wall-clock timing/concurrency tests (real @@ -19,10 +19,11 @@ run = [ # already cover the same lines. "mise x --env test -- cargo llvm-cov nextest -p stack-auth --all-features -E 'not test(stress_tests)' --lcov --output-path {{config_root}}/target/stack-auth-lcov.info", # Score every production function. Excludes test code, examples and the node/wasm - # binding crates so the report reflects the core library. Thresholds come from - # the workspace-root .cargo-crap.toml. Add --fail-above here to turn it into a gate. + # binding crates so the report reflects the core library. `--fail-above` exits + # non-zero when any function's CRAP score exceeds the threshold from the + # workspace-root .cargo-crap.toml (30), so this gates both local runs and CI. # NOTE: this must stay the LAST command — the crap-stack-auth.yml CI workflow runs - # this task and relies on mise appending its trailing args (--format pr-comment etc.) + # this task and relies on mise appending its trailing args (e.g. --format github) # to this `cargo crap` invocation. - "cargo crap --path packages/stack-auth --lcov {{config_root}}/target/stack-auth-lcov.info --exclude 'node/**' --exclude 'wasm/**' --exclude 'examples/**' --exclude '**/tests.rs' --exclude '**/tests/**'", + "cargo crap --path packages/stack-auth --lcov {{config_root}}/target/stack-auth-lcov.info --exclude 'node/**' --exclude 'wasm/**' --exclude 'examples/**' --exclude '**/tests.rs' --exclude '**/tests/**' --fail-above", ] From 0e6552b39aca74ef64d1d8655bc69621ae6fd4be Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Thu, 18 Jun 2026 20:26:08 +1000 Subject: [PATCH 284/686] fix(stack-auth): treat OIDC /api/authorise expiry as absolute epoch (CIP-3233) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The OIDC refresher set `expires_at: now + auth_resp.expiry`, but `/api/authorise` returns `expiry` as an ABSOLUTE Unix epoch (the JWT `exp` claim), not a relative duration — the identical bug CIP-3233 fixed in the access-key refresher (cipherstash/cipherstash-suite#2036), left unfixed in the OIDC sibling. `now + epoch` pushed the local expiry decades out, so `AutoRefresh` never considered the token expired and never re-federated it; the token then silently died at its real (~15 min) `exp`, breaking the OIDC federation path (Supabase/Clerk). Use the value as-is. The OIDC tests masked this: `auth_response_json` passed `expiry` straight through, so the pre-fix `now + expiry` happened to look fresh. Make the helper model an absolute epoch (`now + expires_in_secs`), mirroring the access-key tests, and add `oidc_expiry_is_absolute_epoch_not_relative`, which fails under the pre-fix arithmetic (verified: expires_in ~1.7e9) and passes with the fix. Found via the cargo-mutants pilot (a surviving `+ -> *` mutant on the expiry arithmetic pointed straight at the untested line). --- packages/stack-auth/src/oidc_refresher.rs | 72 ++++++++++++++++++++--- 1 file changed, 64 insertions(+), 8 deletions(-) diff --git a/packages/stack-auth/src/oidc_refresher.rs b/packages/stack-auth/src/oidc_refresher.rs index 023df2e32..1a144a469 100644 --- a/packages/stack-auth/src/oidc_refresher.rs +++ b/packages/stack-auth/src/oidc_refresher.rs @@ -3,7 +3,6 @@ use std::sync::Arc; use cts_common::WorkspaceId; use url::Url; -use web_time::{SystemTime, UNIX_EPOCH}; use crate::authorize_dto::AuthoriseResponse; use crate::refresher::Refresher; @@ -167,15 +166,17 @@ impl Refresher for OidcRefresher

{ } let auth_resp: AuthoriseResponse = resp.json().await?; - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_secs(); Ok(Token { access_token: auth_resp.access_token, token_type: "Bearer".to_string(), - expires_at: now + auth_resp.expiry, + // CTS `/api/authorise` returns `expiry` as an ABSOLUTE Unix epoch (it is + // the JWT `exp` claim), NOT a relative duration. The previous `now + expiry` + // pushed the local expiry decades into the future, so `AutoRefresh` never + // considered the token expired and never re-federated it — the token then + // silently died at its real (~15 min) `exp` and every request failed until + // the process restarted. Use the value as-is. See CIP-3233. + expires_at: auth_resp.expiry, refresh_token: None, region: None, client_id: None, @@ -210,8 +211,18 @@ mod tests { WORKSPACE_ID.parse().unwrap() } - fn auth_response_json(access: &str, expiry: u64) -> serde_json::Value { - serde_json::json!({ "accessToken": access, "expiry": expiry }) + /// Build a mock `/api/authorise` response. CTS returns `expiry` as an + /// ABSOLUTE Unix epoch (the JWT `exp` claim), so model that faithfully: the + /// token is valid for `expires_in_secs` from now. + fn auth_response_json(access: &str, expires_in_secs: u64) -> serde_json::Value { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs(); + serde_json::json!({ + "accessToken": access, + "expiry": now + expires_in_secs + }) } async fn start_server(mocks: MockSet) -> MockServer { @@ -259,6 +270,51 @@ mod tests { } } + // ---- Regression: CTS `expiry` is an absolute epoch (CIP-3233) ---- + + /// CTS `/api/authorise` returns `expiry` as an ABSOLUTE Unix epoch (the JWT + /// `exp` claim), not a relative duration — identical to the access-key path + /// fixed in CIP-3233. The OIDC refresher must use it as-is. + /// + /// Pre-fix (`expires_at = now + expiry`), this token's `expires_at` lands + /// ~decades in the future, so `is_expired()` is never true — the federated + /// token never re-federates and silently dies at its real ~15-minute `exp`. + /// The assertion below fails under the pre-fix arithmetic (`expires_in()` ≈ + /// 1.7e9) and passes with the fix (`expires_in()` ≈ 900). See CIP-3233. + #[tokio::test] + async fn oidc_expiry_is_absolute_epoch_not_relative() { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs(); + let absolute_expiry = now + 900; // a 15-minute token, as an absolute epoch + + let mut mocks = MockSet::new(); + mocks.mock(move |when, then| { + when.post().path("/api/authorise"); + then.json(serde_json::json!({ + "accessToken": "tok", + "expiry": absolute_expiry + })); + }); + let server = start_server(mocks).await; + + let (_calls, provider) = counting_provider(); + let refresher = OidcRefresher::new(provider, workspace_id(), server.url("")); + let token = refresher.refresh(&()).await.unwrap(); + + assert!( + token.expires_in() <= 1000, + "expires_in should be ~900s (absolute `expiry` used as-is); got {} \ + — pre-fix `now + expiry` yields ~1.7e9", + token.expires_in() + ); + assert!( + !token.is_expired(), + "a freshly federated 15-minute token must not be reported as already expired" + ); + } + #[tokio::test] async fn test_initial_federation() { let mut mocks = MockSet::new(); From ade208727f2bee7e8c5e8dd031509de6972abb96 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Thu, 18 Jun 2026 20:37:42 +1000 Subject: [PATCH 285/686] refactor(stack-auth): centralize AuthoriseResponse -> Token mapping MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both the access-key and OIDC flows exchange a credential at `/api/authorise` and build a `Token` from the identical `AuthoriseResponse`, but each constructed the `Token` itself — including the absolute-epoch `expires_at` handling. That duplication is precisely why the CIP-3233 expiry bug had to be fixed twice and survived in the OIDC sibling in between. Collapse both to `impl From for Token`, living beside the shared DTO. Each refresher is now `Ok(auth_resp.into())`, and the absolute-epoch rationale is stated once. A direct unit test (`authorise_response_maps_expiry_as_absolute_epoch`) pins the invariant at its single source; the per-refresher regression tests remain as integration smoke. --- .../stack-auth/src/access_key_refresher.rs | 18 +----- packages/stack-auth/src/authorize_dto.rs | 61 ++++++++++++++++++- packages/stack-auth/src/oidc_refresher.rs | 18 +----- 3 files changed, 66 insertions(+), 31 deletions(-) diff --git a/packages/stack-auth/src/access_key_refresher.rs b/packages/stack-auth/src/access_key_refresher.rs index f65d1f2fe..6070f730b 100644 --- a/packages/stack-auth/src/access_key_refresher.rs +++ b/packages/stack-auth/src/access_key_refresher.rs @@ -68,21 +68,9 @@ impl Refresher for AccessKeyRefresher { let auth_resp: AuthoriseResponse = resp.json().await?; - Ok(Token { - access_token: auth_resp.access_token, - token_type: "Bearer".to_string(), - // CTS `/api/authorise` returns `expiry` as an ABSOLUTE Unix epoch (it is - // the JWT `exp` claim), NOT a relative duration. The previous `now + expiry` - // pushed the local expiry decades into the future, so `AutoRefresh` never - // considered the token expired and never refreshed it — the token then - // silently died at its real (~15 min) `exp` and every request failed until - // the process restarted. Use the value as-is. See CIP-3233. - expires_at: auth_resp.expiry, - refresh_token: None, - region: None, - client_id: None, - device_instance_id: None, - }) + // The response → Token mapping (including the absolute-epoch `expiry` + // handling that CIP-3233 fixed) lives on `From`. + Ok(auth_resp.into()) } } diff --git a/packages/stack-auth/src/authorize_dto.rs b/packages/stack-auth/src/authorize_dto.rs index fe6aa4102..a0fb910a1 100644 --- a/packages/stack-auth/src/authorize_dto.rs +++ b/packages/stack-auth/src/authorize_dto.rs @@ -7,7 +7,7 @@ //! the wire contract lives here in one place. The request bodies differ //! (different credential fields) and stay private to each refresher. -use crate::SecretToken; +use crate::{SecretToken, Token}; /// Success response from `POST /api/authorise`. #[derive(serde::Deserialize)] @@ -16,3 +16,62 @@ pub(crate) struct AuthoriseResponse { pub(crate) access_token: SecretToken, pub(crate) expiry: u64, } + +/// A `/api/authorise` success response *is* a complete CTS service token. Both +/// the access-key and OIDC federation flows hit the same endpoint and mint the +/// same kind of token, so the response → [`Token`] mapping lives here once +/// rather than being duplicated in each refresher — duplication is exactly how +/// the CIP-3233 expiry bug survived in the OIDC refresher after being fixed for +/// access keys. +impl From for Token { + fn from(resp: AuthoriseResponse) -> Self { + Token { + access_token: resp.access_token, + token_type: "Bearer".to_string(), + // CTS `/api/authorise` returns `expiry` as an ABSOLUTE Unix epoch (it is + // the JWT `exp` claim), NOT a relative duration. Adding `now` here would + // push the local expiry decades into the future, so `AutoRefresh` would + // never consider the token expired and never refresh it — the token would + // then silently die at its real (~15 min) `exp` and every request would + // fail until the process restarted. Use the value as-is. See CIP-3233. + expires_at: resp.expiry, + // `/api/authorise` issues no refresh token and carries no region / client + // / device metadata; those are populated only by the OAuth device flow. + refresh_token: None, + region: None, + client_id: None, + device_instance_id: None, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The single source of truth for the response → `Token` mapping: CTS + /// `expiry` is an ABSOLUTE Unix epoch and must be used verbatim. Pre-fix the + /// refreshers did `now + expiry`; the regression in CIP-3233 (and its OIDC + /// sibling) now cannot recur in only one flow because there is only one flow. + #[test] + fn authorise_response_maps_expiry_as_absolute_epoch() { + let resp = AuthoriseResponse { + access_token: SecretToken::new("cts-token"), + expiry: 1_900_000_000, // an absolute Unix epoch, not a duration + }; + + let token = Token::from(resp); + + assert_eq!( + token.expires_at(), + 1_900_000_000, + "expiry must be carried through as-is, never offset by `now`" + ); + assert_eq!(token.token_type(), "Bearer"); + assert_eq!(token.access_token().as_str(), "cts-token"); + assert!( + token.refresh_token.is_none(), + "/api/authorise issues no refresh token" + ); + } +} diff --git a/packages/stack-auth/src/oidc_refresher.rs b/packages/stack-auth/src/oidc_refresher.rs index 1a144a469..c3559a2c2 100644 --- a/packages/stack-auth/src/oidc_refresher.rs +++ b/packages/stack-auth/src/oidc_refresher.rs @@ -167,21 +167,9 @@ impl Refresher for OidcRefresher

{ let auth_resp: AuthoriseResponse = resp.json().await?; - Ok(Token { - access_token: auth_resp.access_token, - token_type: "Bearer".to_string(), - // CTS `/api/authorise` returns `expiry` as an ABSOLUTE Unix epoch (it is - // the JWT `exp` claim), NOT a relative duration. The previous `now + expiry` - // pushed the local expiry decades into the future, so `AutoRefresh` never - // considered the token expired and never re-federated it — the token then - // silently died at its real (~15 min) `exp` and every request failed until - // the process restarted. Use the value as-is. See CIP-3233. - expires_at: auth_resp.expiry, - refresh_token: None, - region: None, - client_id: None, - device_instance_id: None, - }) + // The response → Token mapping (including the absolute-epoch `expiry` + // handling that CIP-3233 fixed) lives on `From`. + Ok(auth_resp.into()) } } From 5cc393eefeedb5df655b5f3c1130e23cda115432 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Thu, 18 Jun 2026 20:46:52 +1000 Subject: [PATCH 286/686] fix(stack-auth-node): model /api/authorise expiry as absolute epoch in test mock MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `mockAuthorizeEndpoint` test helper put its relative `expiry` argument straight into the response's `expiry` field. That only matched the pre-CIP-3233 behaviour (`now + expiry`); now that the refresher reads `expiry` as the absolute epoch CTS actually returns, a default 3600 mock lands in 1970, so a freshly federated token reads as already expired — breaking the two Node tests that reuse a cached token without re-federating. Convert the ergonomic "seconds from now" argument to an absolute epoch (`now + expires_in`), mirroring the Rust `auth_response_json` helper. The `expiry: 0` "immediately expired" cases still behave (now + 0 sits within the expiry leeway). All 37 node integration tests pass. --- .../packages/auth/src/mock_auth_server.rs | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/languages/typescript/packages/auth/src/mock_auth_server.rs b/languages/typescript/packages/auth/src/mock_auth_server.rs index da9528066..9a1212234 100644 --- a/languages/typescript/packages/auth/src/mock_auth_server.rs +++ b/languages/typescript/packages/auth/src/mock_auth_server.rs @@ -109,11 +109,21 @@ impl MockAuthServer { /// `OidcFederationStrategy` JWT exchange. /// /// `expiry` (seconds until the CTS token expires) defaults to 3600. Pass a - /// small value to exercise re-federation on expiry. + /// small value (e.g. 0) to exercise re-federation on expiry. #[napi] pub fn mock_authorize_endpoint(&self, expiry: Option) { let jwt = test_jwt(); - let expiry = expiry.unwrap_or(3600); + let expires_in = expiry.unwrap_or(3600); + // CTS returns `expiry` as an ABSOLUTE Unix epoch (the JWT `exp` claim), + // NOT a relative duration — see CIP-3233. The ergonomic `expiry` argument + // is "seconds from now", so convert it to the absolute epoch the wire + // actually carries; otherwise the mock no longer matches production and a + // freshly federated token reads as already expired. + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + let expiry = now + u64::from(expires_in); self.server.mocks().mock(move |when, then| { when.post().path("/api/authorise"); then.json(serde_json::json!({ From e01fcdd1f49f951a8fa85a1eeca9c713c71202d2 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Thu, 18 Jun 2026 21:10:40 +1000 Subject: [PATCH 287/686] test(fuzz): scaffold cargo-fuzz pilot for public string parsers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stand up a libFuzzer (cargo-fuzz) scaffold for the cleanly-public, pure-&str parsers, mirroring the cargo-crap tooling precedent: tool declared in mise.toml, per-target `fuzz:*` mise tasks, detached fuzz crates under packages/*/fuzz/. Targets: - cts-common: Crn, WorkspaceId, Region (packages/cts-common/fuzz) - stack-auth: AccessKey (packages/stack-auth/fuzz) Each harness asserts the parser never panics on arbitrary UTF-8 (libfuzzer-sys supplies &str via the arbitrary crate). The fuzz crates are detached workspaces (empty [workspace] table) so libfuzzer-sys and the nightly-only build never touch the main workspace. Tasks force the native host triple via --target (the binstalled cargo-fuzz is an x86_64 build under Rosetta on Apple Silicon and otherwise misdetects the target / can't find std). Seed corpora are committed (force-added past the corpus/ gitignore rule); generated corpus, build output, crash artifacts and coverage data are ignored. No CI this round — local pilot. Initial 20-25s campaigns: region_parse and access_key_parse are clean (4.5M / 1.8M execs, no crashes); crn_parse and workspace_id_parse each surfaced a panic on untrusted input (handled separately). --- packages/stack-auth/fuzz/Cargo.toml | 31 +++++++++++++++++++ .../corpus/access_key_parse/valid-access-key | 1 + .../fuzz/fuzz_targets/access_key_parse.rs | 11 +++++++ packages/stack-auth/tasks.toml | 13 ++++++++ 4 files changed, 56 insertions(+) create mode 100644 packages/stack-auth/fuzz/Cargo.toml create mode 100644 packages/stack-auth/fuzz/corpus/access_key_parse/valid-access-key create mode 100644 packages/stack-auth/fuzz/fuzz_targets/access_key_parse.rs diff --git a/packages/stack-auth/fuzz/Cargo.toml b/packages/stack-auth/fuzz/Cargo.toml new file mode 100644 index 000000000..eb595537c --- /dev/null +++ b/packages/stack-auth/fuzz/Cargo.toml @@ -0,0 +1,31 @@ +# Fuzz crate for stack-auth's public string parsers. +# +# This is a DETACHED crate: the `[workspace]` table at the bottom makes it its +# own workspace root so the libfuzzer-sys dependency and the nightly-only build +# never touch the main monorepo workspace. It is not a member of the root +# workspace (see the root Cargo.toml `members` list). Run via the `fuzz:*` +# mise tasks, which invoke `cargo +nightly fuzz run`. +[package] +name = "stack-auth-fuzz" +version = "0.0.0" +publish = false +edition = "2021" + +[package.metadata] +cargo-fuzz = true + +[dependencies] +libfuzzer-sys = "0.4" + +[dependencies.stack-auth] +path = ".." + +[[bin]] +name = "access_key_parse" +path = "fuzz_targets/access_key_parse.rs" +test = false +doc = false +bench = false + +[workspace] +resolver = "2" diff --git a/packages/stack-auth/fuzz/corpus/access_key_parse/valid-access-key b/packages/stack-auth/fuzz/corpus/access_key_parse/valid-access-key new file mode 100644 index 000000000..ce77a49b7 --- /dev/null +++ b/packages/stack-auth/fuzz/corpus/access_key_parse/valid-access-key @@ -0,0 +1 @@ +CSAKkeyid01.secret-value-here \ No newline at end of file diff --git a/packages/stack-auth/fuzz/fuzz_targets/access_key_parse.rs b/packages/stack-auth/fuzz/fuzz_targets/access_key_parse.rs new file mode 100644 index 000000000..c90fb425e --- /dev/null +++ b/packages/stack-auth/fuzz/fuzz_targets/access_key_parse.rs @@ -0,0 +1,11 @@ +#![no_main] + +use libfuzzer_sys::fuzz_target; + +// Fuzz the public `AccessKey` string parser (`CSAK.`). +// libfuzzer-sys supplies `&str` via the `arbitrary` crate. Access keys are +// untrusted credential strings supplied by callers, so parsing them must never +// panic — malformed input must return `Err(InvalidAccessKey)`, not crash. +fuzz_target!(|s: &str| { + let _ = s.parse::(); +}); diff --git a/packages/stack-auth/tasks.toml b/packages/stack-auth/tasks.toml index 5622a6b85..cbb5a807c 100644 --- a/packages/stack-auth/tasks.toml +++ b/packages/stack-auth/tasks.toml @@ -27,3 +27,16 @@ run = [ # to this `cargo crap` invocation. "cargo crap --path packages/stack-auth --lcov {{config_root}}/target/stack-auth-lcov.info --exclude 'node/**' --exclude 'wasm/**' --exclude 'examples/**' --exclude '**/tests.rs' --exclude '**/tests/**' --fail-above", ] + +# Fuzz stack-auth's public AccessKey string parser (libFuzzer via cargo-fuzz). +# Requires the nightly toolchain. Runs 60s by default; override by appending a +# libFuzzer flag, e.g. `mise run fuzz:access-key -- -max_total_time=300` (the +# last repeated value wins). `--sanitizer none` is safe — the parser is pure +# safe Rust. `--target $(rustc … host)` forces the native host triple (the +# cargo-fuzz binary may be x86_64 under Rosetta on Apple Silicon, which +# otherwise misdetects the target). The fuzz crate lives in +# `packages/stack-auth/fuzz/` (detached). +["fuzz:access-key"] +description = "Fuzz stack-auth's AccessKey string parser (libFuzzer, nightly, 60s default)" +dir = "{{config_root}}/packages/stack-auth" +run = "cargo +nightly fuzz run access_key_parse --sanitizer none --target $(rustc -vV | sed -n 's/^host: //p') -- -max_total_time=60" From eafa3aec38e0c69c0a1ad8b1ab5cfef701f44404 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Thu, 18 Jun 2026 21:47:56 +1000 Subject: [PATCH 288/686] ci(fuzz): wire cargo-fuzz into CI; exclude fuzz crates from Dockerfile patch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add `.github/workflows/fuzz.yml`: - `fuzz-regression` (pull_request, blocking): builds each harness — catching harness/API drift — and replays the committed seed corpus with `-runs=0`. Deterministic, so it safely gates PRs: fails only on a harness build break or a crashing corpus entry, never on nondeterministic fuzzing. - `fuzz-campaign` (schedule nightly + workflow_dispatch): the timed bug-finding run; uploads any crash reproducer. Non-blocking on PRs. Both reuse the `fuzz:*` mise tasks (single source of truth for target, dir, `--target` host triple and `--sanitizer none`), overriding the libFuzzer args. Also fix the "Lint rust Dockerfiles" failure this PR introduced: `patch_rust_dockerfile.sh` globs `packages/{,*/}*/Cargo.toml`, which now matches the detached `packages/*/fuzz` crates and wanted to COPY them into the production image. Exclude `*/fuzz/*` from all three find globs — the fuzz crates are nightly-only dev tooling and never part of the image. --- .github/imported-workflows/fuzz.yml | 95 +++++++++++++++++++++++++++++ 1 file changed, 95 insertions(+) create mode 100644 .github/imported-workflows/fuzz.yml diff --git a/.github/imported-workflows/fuzz.yml b/.github/imported-workflows/fuzz.yml new file mode 100644 index 000000000..56e70a425 --- /dev/null +++ b/.github/imported-workflows/fuzz.yml @@ -0,0 +1,95 @@ +name: "Fuzz (cargo-fuzz)" + +# libFuzzer fuzzing for the public string parsers (see packages/*/fuzz/ and the +# `fuzz:*` mise tasks). Two jobs with deliberately different roles: +# +# * fuzz-regression (pull_request, blocking): builds every harness — which +# catches harness/API drift, e.g. a changed `FromStr` signature — and +# replays the committed seed corpus with `-runs=0`. This is deterministic +# (no fuzzing), so it is safe to gate PRs: it fails only if a harness stops +# compiling or a committed corpus input crashes. +# +# * fuzz-campaign (schedule + manual, non-blocking on PRs): the actual +# time-boxed bug-finding run. A timed fuzz run is nondeterministic, so it +# must NOT gate PRs; it runs nightly against the default branch and on +# demand, and uploads any crash reproducer as an artifact. +on: + pull_request: + paths: + - packages/cts-common/** + - packages/stack-auth/** + - .github/workflows/fuzz.yml + # Ordering matters: keep these excludes last so docs-only changes are skipped. + - "!**.md" + - "!**.example" + schedule: + # Nightly at 04:27 UTC. Scheduled runs only fire from the default branch. + - cron: "27 4 * * *" + workflow_dispatch: + inputs: + max_total_time: + description: "Seconds to fuzz each target (campaign job)" + default: "120" + +defaults: + run: + shell: bash + +permissions: + contents: read + +env: + RUST_BACKTRACE: full + CARGO_TERM_COLOR: always + CARGO_NET_GIT_FETCH_WITH_CLI: true + +jobs: + fuzz-regression: + name: "fuzz regression (${{ matrix.slug }})" + if: github.event_name == 'pull_request' + runs-on: blacksmith-8vcpu-ubuntu-2404 + strategy: + fail-fast: false + matrix: + include: + - { task: "fuzz:crn", slug: crn } + - { task: "fuzz:workspace-id", slug: workspace-id } + - { task: "fuzz:region", slug: region } + - { task: "fuzz:access-key", slug: access-key } + steps: + - uses: actions/checkout@v6 + - uses: ./.github/actions/setup-rust + - name: Install nightly toolchain (cargo-fuzz requires it) + run: rustup toolchain install nightly --profile minimal + - name: "Build harness + replay seed corpus (${{ matrix.slug }})" + # `-runs=0` replays the committed seed corpus once and exits without + # fuzzing. The trailing args override the task's default `-max_total_time`. + run: mise run ${{ matrix.task }} -- -runs=0 + + fuzz-campaign: + name: "fuzz campaign (${{ matrix.slug }})" + if: github.event_name != 'pull_request' + runs-on: blacksmith-8vcpu-ubuntu-2404 + strategy: + fail-fast: false + matrix: + include: + - { task: "fuzz:crn", slug: crn } + - { task: "fuzz:workspace-id", slug: workspace-id } + - { task: "fuzz:region", slug: region } + - { task: "fuzz:access-key", slug: access-key } + steps: + - uses: actions/checkout@v6 + - uses: ./.github/actions/setup-rust + - name: Install nightly toolchain (cargo-fuzz requires it) + run: rustup toolchain install nightly --profile minimal + - name: "Fuzz ${{ matrix.slug }}" + # The trailing `-max_total_time` (last value wins) overrides the task default. + run: mise run ${{ matrix.task }} -- -max_total_time=${{ github.event.inputs.max_total_time || '120' }} + - name: Upload crash reproducer + if: failure() + uses: actions/upload-artifact@v4 + with: + name: fuzz-artifacts-${{ matrix.slug }} + path: packages/*/fuzz/artifacts/** + if-no-files-found: ignore From 6bca746a435a82bf3d63fbabbc13c1a9ac839499 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Thu, 18 Jun 2026 22:30:52 +1000 Subject: [PATCH 289/686] ci(fuzz): persist + minimize the corpus across scheduled campaign runs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Make fuzz-campaign accumulate coverage night over night instead of starting cold each run: - Restore each target's corpus from actions/cache before fuzzing (rolling per-run key + prefix restore-keys, since cache keys are write-once). - Minimize with `cargo fuzz cmin` after fuzzing to keep it small and fast to load (skipped automatically if the run crashed). - Save it back on `always()`, so a crash run still preserves the grown corpus (the crash input itself goes to artifacts/). Campaign matrix entries gain `dir`/`target` to locate each corpus dir and drive cmin. Committed seeds (from the checkout) merge with the restored corpus at runtime. Per-PR fuzz-regression is unchanged — it deliberately replays only the committed seeds, deterministically. --- .github/imported-workflows/fuzz.yml | 42 +++++++++++++++++++++++++---- 1 file changed, 37 insertions(+), 5 deletions(-) diff --git a/.github/imported-workflows/fuzz.yml b/.github/imported-workflows/fuzz.yml index 56e70a425..44a145bb7 100644 --- a/.github/imported-workflows/fuzz.yml +++ b/.github/imported-workflows/fuzz.yml @@ -12,7 +12,9 @@ name: "Fuzz (cargo-fuzz)" # * fuzz-campaign (schedule + manual, non-blocking on PRs): the actual # time-boxed bug-finding run. A timed fuzz run is nondeterministic, so it # must NOT gate PRs; it runs nightly against the default branch and on -# demand, and uploads any crash reproducer as an artifact. +# demand. Each target's corpus is persisted across runs via actions/cache +# so coverage compounds, minimized with `cargo fuzz cmin` to stay small, and +# any crash reproducer is uploaded as an artifact. on: pull_request: paths: @@ -73,19 +75,49 @@ jobs: strategy: fail-fast: false matrix: + # `dir`/`target` drive the corpus path and `cargo fuzz cmin`. include: - - { task: "fuzz:crn", slug: crn } - - { task: "fuzz:workspace-id", slug: workspace-id } - - { task: "fuzz:region", slug: region } - - { task: "fuzz:access-key", slug: access-key } + - { task: "fuzz:crn", slug: crn, dir: packages/cts-common, target: crn_parse } + - { task: "fuzz:workspace-id", slug: workspace-id, dir: packages/cts-common, target: workspace_id_parse } + - { task: "fuzz:region", slug: region, dir: packages/cts-common, target: region_parse } + - { task: "fuzz:access-key", slug: access-key, dir: packages/stack-auth, target: access_key_parse } steps: - uses: actions/checkout@v6 - uses: ./.github/actions/setup-rust - name: Install nightly toolchain (cargo-fuzz requires it) run: rustup toolchain install nightly --profile minimal + + # Persist the corpus across runs so coverage compounds. A cache key is + # write-once, so save under a unique per-run key and use a prefix + # `restore-keys` to load the most recent prior corpus. The committed seeds + # come from the checkout and merge with the restored corpus at runtime. + - name: Restore corpus + uses: actions/cache/restore@v4 + with: + path: ${{ matrix.dir }}/fuzz/corpus/${{ matrix.target }} + key: fuzz-corpus-${{ matrix.slug }}-${{ github.run_id }} + restore-keys: fuzz-corpus-${{ matrix.slug }}- + - name: "Fuzz ${{ matrix.slug }}" # The trailing `-max_total_time` (last value wins) overrides the task default. run: mise run ${{ matrix.task }} -- -max_total_time=${{ github.event.inputs.max_total_time || '120' }} + + - name: "Minimize corpus (${{ matrix.slug }})" + # Drop inputs that don't add coverage so the persisted corpus stays small + # and fast to load. Skipped automatically if the Fuzz step found a crash. + working-directory: ${{ matrix.dir }} + run: | + cargo +nightly fuzz cmin ${{ matrix.target }} --sanitizer none --target "$(rustc -vV | sed -n 's/^host: //p')" + + - name: Save corpus + # Save even on crash: the grown corpus is still valuable, and the crash + # input lives in artifacts/, not corpus/. + if: always() + uses: actions/cache/save@v4 + with: + path: ${{ matrix.dir }}/fuzz/corpus/${{ matrix.target }} + key: fuzz-corpus-${{ matrix.slug }}-${{ github.run_id }} + - name: Upload crash reproducer if: failure() uses: actions/upload-artifact@v4 From 2b3b7e7c4aa7cf317b304e1d95d71bc35ef4b120 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Fri, 19 Jun 2026 17:30:10 +1000 Subject: [PATCH 290/686] refactor(stack-auth): take a workspace CRN in OidcFederationStrategy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Make OidcFederationStrategy config consistent with the other strategies: it now takes a single workspace CRN instead of a separate region + workspace ID, deriving the region (service discovery) and workspace ID (token verification) from the CRN — the same shape AccessKeyStrategy uses. - core: new()/builder() and the builder take Crn; build() derives region and expected workspace from it. A CRN with a service_name is accepted and ignored, matching AccessKeyStrategy. - node napi: create()/createWithStore() accept workspaceCrn; a malformed value surfaces as INVALID_CRN (replaces the old region+workspaceId parsing). index.js/index.d.ts updated. - wasm: create()/createWithStore() accept workspaceCrn; tests assert INVALID_CRN. wasm-types.d.ts / wasm-inline.d.ts / wasm-inline.mjs and the node README federation example updated. - tests: node TS suites use a workspace CRN; the separate invalid-region / invalid-workspace-id cases collapse into one invalid-CRN case. BREAKING CHANGE: OidcFederationStrategy::{new,builder} and the node/wasm create()/createWithStore() factories now take a workspace CRN instead of (region, workspace_id). --- languages/typescript/packages/auth/README.md | 3 +- .../__tests__/oidc-cookie-roundtrip.test.ts | 17 +-- .../oidc-federation-strategy.test.ts | 51 ++----- languages/typescript/packages/auth/index.d.ts | 11 +- languages/typescript/packages/auth/index.js | 9 +- languages/typescript/packages/auth/src/lib.rs | 55 +++---- .../typescript/packages/auth/wasm-inline.d.ts | 10 +- .../typescript/packages/auth/wasm-inline.mjs | 10 +- .../typescript/packages/auth/wasm-types.d.ts | 12 +- .../packages/stack-auth-wasm/src/lib.rs | 92 ++++-------- .../src/oidc_federation_strategy.rs | 142 ++++++++---------- 11 files changed, 168 insertions(+), 244 deletions(-) diff --git a/languages/typescript/packages/auth/README.md b/languages/typescript/packages/auth/README.md index 0e0c1713a..700180b04 100644 --- a/languages/typescript/packages/auth/README.md +++ b/languages/typescript/packages/auth/README.md @@ -107,8 +107,7 @@ Deno.serve(async (req) => { const responseHeaders = new Headers({ "content-type": "application/json" }); const strategy = OidcFederationStrategy.create( - "ap-southeast-2.aws", - workspaceId, + workspaceCrn, // e.g. "crn:ap-southeast-2.aws:ZVATKW3VHMFG27DY" // Returns the *current* provider JWT — re-invoked on every re-federation. () => getClerkSessionToken(req), { store: cookieStore({ request: req, responseHeaders }) }, diff --git a/languages/typescript/packages/auth/__tests__/oidc-cookie-roundtrip.test.ts b/languages/typescript/packages/auth/__tests__/oidc-cookie-roundtrip.test.ts index 2f70e4a80..a0fe4947d 100644 --- a/languages/typescript/packages/auth/__tests__/oidc-cookie-roundtrip.test.ts +++ b/languages/typescript/packages/auth/__tests__/oidc-cookie-roundtrip.test.ts @@ -9,8 +9,8 @@ const mod = require("../index.js") as typeof import("../index") & { const { OidcFederationStrategy, MockAuthServer } = mod; -const REGION = "ap-southeast-2.aws"; const WORKSPACE_ID = "ZVATKW3VHMFG27DY"; +const WORKSPACE_CRN = `crn:ap-southeast-2.aws:${WORKSPACE_ID}`; let server: InstanceType; let savedHost: string | undefined; @@ -48,8 +48,7 @@ describe("OidcFederationStrategy + cookieStore round-trip", () => { const store = cookieStore({ request: requestWith(), responseHeaders }); const strategy = OidcFederationStrategy.createWithStore( - REGION, - WORKSPACE_ID, + WORKSPACE_CRN, () => Promise.resolve("header.payload.signature"), store.load, store.save, @@ -71,8 +70,7 @@ describe("OidcFederationStrategy + cookieStore round-trip", () => { responseHeaders: firstHeaders, }); const first = OidcFederationStrategy.createWithStore( - REGION, - WORKSPACE_ID, + WORKSPACE_CRN, () => Promise.resolve("header.payload.signature"), firstStore.load, firstStore.save, @@ -89,8 +87,7 @@ describe("OidcFederationStrategy + cookieStore round-trip", () => { responseHeaders: new Headers(), }); const second = OidcFederationStrategy.createWithStore( - REGION, - WORKSPACE_ID, + WORKSPACE_CRN, () => Promise.reject(new Error("getJwt must not be called")), secondStore.load, secondStore.save, @@ -109,8 +106,7 @@ describe("OidcFederationStrategy + cookieStore round-trip", () => { responseHeaders: firstHeaders, }); const first = OidcFederationStrategy.createWithStore( - REGION, - WORKSPACE_ID, + WORKSPACE_CRN, () => Promise.resolve("header.payload.signature"), firstStore.load, firstStore.save, @@ -128,8 +124,7 @@ describe("OidcFederationStrategy + cookieStore round-trip", () => { responseHeaders: new Headers(), }); const second = OidcFederationStrategy.createWithStore( - REGION, - WORKSPACE_ID, + WORKSPACE_CRN, () => { getJwtCalls += 1; return Promise.resolve("header.payload.signature"); diff --git a/languages/typescript/packages/auth/__tests__/oidc-federation-strategy.test.ts b/languages/typescript/packages/auth/__tests__/oidc-federation-strategy.test.ts index b622d076b..ccb5b8654 100644 --- a/languages/typescript/packages/auth/__tests__/oidc-federation-strategy.test.ts +++ b/languages/typescript/packages/auth/__tests__/oidc-federation-strategy.test.ts @@ -9,8 +9,8 @@ const mod = require("../index.js") as typeof import("../index") & { const { OidcFederationStrategy, MockAuthServer } = mod; -const REGION = "ap-southeast-2.aws"; const WORKSPACE_ID = "ZVATKW3VHMFG27DY"; +const WORKSPACE_CRN = `crn:ap-southeast-2.aws:${WORKSPACE_ID}`; let server: InstanceType; let savedHost: string | undefined; @@ -60,11 +60,7 @@ describe("OidcFederationStrategy (TypeScript / vitest)", () => { it("federates a third-party JWT into a CTS service token", async () => { server.mockAuthorizeEndpoint(); const jwt = countingJwt(); - const strategy = OidcFederationStrategy.create( - REGION, - WORKSPACE_ID, - jwt.getJwt, - ); + const strategy = OidcFederationStrategy.create(WORKSPACE_CRN, jwt.getJwt); const result = await strategy.getToken(); @@ -79,11 +75,7 @@ describe("OidcFederationStrategy (TypeScript / vitest)", () => { server.mockAuthorizeEndpoint(0); server.mockAuthorizeEndpoint(0); const jwt = countingJwt(); - const strategy = OidcFederationStrategy.create( - REGION, - WORKSPACE_ID, - jwt.getJwt, - ); + const strategy = OidcFederationStrategy.create(WORKSPACE_CRN, jwt.getJwt); await strategy.getToken(); await strategy.getToken(); @@ -93,7 +85,7 @@ describe("OidcFederationStrategy (TypeScript / vitest)", () => { it("surfaces a getJwt rejection as an error with .code", async () => { server.mockAuthorizeEndpoint(); - const strategy = OidcFederationStrategy.create(REGION, WORKSPACE_ID, () => + const strategy = OidcFederationStrategy.create(WORKSPACE_CRN, () => Promise.reject(new Error("provider unavailable")), ); @@ -105,25 +97,14 @@ describe("OidcFederationStrategy (TypeScript / vitest)", () => { } }); - it("rejects an invalid workspace id with .code", () => { - try { - OidcFederationStrategy.create(REGION, "not-a-workspace-id", () => - Promise.resolve("h.p.s"), - ); - expect.unreachable("create should throw on a malformed workspace id"); - } catch (err) { - expect((err as AuthError).code).toBe("INVALID_WORKSPACE_ID"); - } - }); - - it("rejects an invalid region with .code", () => { + it("rejects an invalid workspace CRN with .code", () => { try { - OidcFederationStrategy.create("not-a-region", WORKSPACE_ID, () => + OidcFederationStrategy.create("not-a-crn", () => Promise.resolve("h.p.s"), ); - expect.unreachable("create should throw on a malformed region"); + expect.unreachable("create should throw on a malformed workspace CRN"); } catch (err) { - expect((err as AuthError).code).toBe("INVALID_REGION"); + expect((err as AuthError).code).toBe("INVALID_CRN"); } }); @@ -132,8 +113,7 @@ describe("OidcFederationStrategy (TypeScript / vitest)", () => { const store = memStore(); const jwt = countingJwt(); const strategy = OidcFederationStrategy.createWithStore( - REGION, - WORKSPACE_ID, + WORKSPACE_CRN, jwt.getJwt, store.load, store.save, @@ -150,8 +130,7 @@ describe("OidcFederationStrategy (TypeScript / vitest)", () => { server.mockAuthorizeEndpoint(); const store = memStore(); const first = OidcFederationStrategy.createWithStore( - REGION, - WORKSPACE_ID, + WORKSPACE_CRN, () => Promise.resolve("h.p.s"), store.load, store.save, @@ -164,8 +143,7 @@ describe("OidcFederationStrategy (TypeScript / vitest)", () => { server.clearMocks(); server.mockAuthorizeEndpointError(); const second = OidcFederationStrategy.createWithStore( - REGION, - WORKSPACE_ID, + WORKSPACE_CRN, () => Promise.reject(new Error("getJwt must not be called")), store.load, store.save, @@ -182,8 +160,7 @@ describe("OidcFederationStrategy (TypeScript / vitest)", () => { server.mockAuthorizeEndpoint(); const jwt = countingJwt(); const strategy = OidcFederationStrategy.createWithStore( - REGION, - WORKSPACE_ID, + WORKSPACE_CRN, jwt.getJwt, () => Promise.resolve("}{ not json"), (_json: string) => Promise.resolve(), @@ -200,7 +177,7 @@ describe("OidcFederationStrategy (TypeScript / vitest)", () => { // a `Promise` fails napi's `Promise` coercion and must // surface as a clean SERVER_ERROR rejection, not a panic or hung promise. server.mockAuthorizeEndpoint(); - const strategy = OidcFederationStrategy.create(REGION, WORKSPACE_ID, () => + const strategy = OidcFederationStrategy.create(WORKSPACE_CRN, () => Promise.resolve(42 as unknown as string), ); @@ -219,7 +196,7 @@ describe("OidcFederationStrategy (TypeScript / vitest)", () => { // /api/authorise and getting a 500 must reject with an enriched `.code`, // not resolve or throw an un-coded error. server.mockAuthorizeEndpointError(); - const strategy = OidcFederationStrategy.create(REGION, WORKSPACE_ID, () => + const strategy = OidcFederationStrategy.create(WORKSPACE_CRN, () => Promise.resolve("header.payload.signature"), ); diff --git a/languages/typescript/packages/auth/index.d.ts b/languages/typescript/packages/auth/index.d.ts index 81e9e1f22..5e341cbac 100644 --- a/languages/typescript/packages/auth/index.d.ts +++ b/languages/typescript/packages/auth/index.d.ts @@ -210,13 +210,18 @@ export declare const OAuthStrategy: typeof DeviceSessionStrategy */ export declare class OidcFederationStrategy { /** - * Create an `OidcFederationStrategy` for the given region and workspace. + * Create an `OidcFederationStrategy` for the given workspace CRN. + * + * The CRN format is `crn::` (e.g. + * `"crn:ap-southeast-2.aws:ZVATKW3VHMFG27DY"`). Region is parsed from + * the CRN and used for service discovery; the workspace ID is used to + * verify every federated token belongs to the right workspace. * * `getJwt` is called on every federation — initial auth and every * re-federation after the CTS token expires — and must return * `Promise` resolving to the *current* third-party OIDC JWT. */ - static create(region: string, workspaceId: string, getJwt: () => any): OidcFederationStrategy + static create(workspaceCrn: string, getJwt: () => any): OidcFederationStrategy /** * Create an `OidcFederationStrategy` backed by external token-store callbacks. * @@ -226,7 +231,7 @@ export declare class OidcFederationStrategy { * cookie — so a federated token survives across requests without * re-federating. */ - static createWithStore(region: string, workspaceId: string, getJwt: () => any, loadToken: () => any, saveToken: (arg: string) => any): OidcFederationStrategy + static createWithStore(workspaceCrn: string, getJwt: () => any, loadToken: () => any, saveToken: (arg: string) => any): OidcFederationStrategy /** Retrieve a valid CTS service token, federating or re-federating as needed. */ getToken(): Promise } diff --git a/languages/typescript/packages/auth/index.js b/languages/typescript/packages/auth/index.js index e82455942..fc0686ffd 100644 --- a/languages/typescript/packages/auth/index.js +++ b/languages/typescript/packages/auth/index.js @@ -75,17 +75,17 @@ native.DeviceSessionStrategy.fromProfile = wrapSync(origFromProfile); // `getToken()` is already enriched via the prototype patch above. const NativeOidcFederationStrategy = native.OidcFederationStrategy; class OidcFederationStrategy { - static create(region, workspaceId, getJwt) { + static create(workspaceCrn, getJwt) { // Wrap `getJwt` so the napi binding always sees a Promise-returning // function even if the caller passed a sync one — the native side coerces // the return to `Promise`. Matches the wasm wrapper (wasm-inline.mjs). const jwt = () => Promise.resolve(getJwt()); return wrapSync(() => - NativeOidcFederationStrategy.create(region, workspaceId, jwt), + NativeOidcFederationStrategy.create(workspaceCrn, jwt), )(); } - static createWithStore(region, workspaceId, getJwt, loadToken, saveToken) { + static createWithStore(workspaceCrn, getJwt, loadToken, saveToken) { // Same defensive wrap for all three callbacks, so sync implementations // (e.g. an in-memory store) work without the caller pre-wrapping them. const jwt = () => Promise.resolve(getJwt()); @@ -93,8 +93,7 @@ class OidcFederationStrategy { const save = (json) => Promise.resolve(saveToken(json)); return wrapSync(() => NativeOidcFederationStrategy.createWithStore( - region, - workspaceId, + workspaceCrn, jwt, load, save, diff --git a/languages/typescript/packages/auth/src/lib.rs b/languages/typescript/packages/auth/src/lib.rs index 6cce20c38..f7e2a7add 100644 --- a/languages/typescript/packages/auth/src/lib.rs +++ b/languages/typescript/packages/auth/src/lib.rs @@ -213,17 +213,11 @@ impl DeviceSessionStrategy { // OidcFederationStrategy — federate a third-party OIDC JWT into a CTS service token // --------------------------------------------------------------------------- -/// Parse and validate the `region` + `workspaceId` inputs shared by both -/// `OidcFederationStrategy` factories. -fn parse_oidc_inputs( - region: &str, - workspace_id: &str, -) -> Result<(Region, cts_common::WorkspaceId)> { - let region = Region::new(region).map_err(|e| to_napi_error(AuthError::from(e)))?; - let workspace_id = workspace_id - .parse::() - .map_err(|e| to_napi_error(AuthError::from(e)))?; - Ok((region, workspace_id)) +/// Parse the workspace CRN shared by both `OidcFederationStrategy` factories. +fn parse_workspace_crn(workspace_crn: &str) -> Result { + workspace_crn + .parse() + .map_err(|e| to_napi_error(AuthError::InvalidCrn(e))) } /// Bridges a JS `getJwt` callback into a Rust [`OidcProvider`]. @@ -333,25 +327,25 @@ pub struct OidcFederationStrategy { #[napi] impl OidcFederationStrategy { - /// Create an `OidcFederationStrategy` for the given region and workspace. + /// Create an `OidcFederationStrategy` for the given workspace CRN. + /// + /// The CRN format is `crn::` (e.g. + /// `"crn:ap-southeast-2.aws:ZVATKW3VHMFG27DY"`). Region is parsed from + /// the CRN and used for service discovery; the workspace ID is used to + /// verify every federated token belongs to the right workspace. /// /// `getJwt` is called on every federation — initial auth and every /// re-federation after the CTS token expires — and must return /// `Promise` resolving to the *current* third-party OIDC JWT. #[napi(factory)] pub fn create( - region: String, - workspace_id: String, + workspace_crn: String, get_jwt: ThreadsafeFunction<(), ErrorStrategy::Fatal>, ) -> Result { - let (region, workspace_id) = parse_oidc_inputs(®ion, &workspace_id)?; - let inner = stack_auth::OidcFederationStrategy::builder( - region, - workspace_id, - NapiOidcProvider { get_jwt }, - ) - .build() - .map_err(to_napi_error)?; + let crn = parse_workspace_crn(&workspace_crn)?; + let inner = stack_auth::OidcFederationStrategy::builder(crn, NapiOidcProvider { get_jwt }) + .build() + .map_err(to_napi_error)?; Ok(Self { inner: OidcFederationStrategyInner::NoStore(inner), }) @@ -366,25 +360,20 @@ impl OidcFederationStrategy { /// re-federating. #[napi(factory)] pub fn create_with_store( - region: String, - workspace_id: String, + workspace_crn: String, get_jwt: ThreadsafeFunction<(), ErrorStrategy::Fatal>, load_token: ThreadsafeFunction<(), ErrorStrategy::Fatal>, save_token: ThreadsafeFunction, ) -> Result { - let (region, workspace_id) = parse_oidc_inputs(®ion, &workspace_id)?; + let crn = parse_workspace_crn(&workspace_crn)?; let store = NapiTokenStore { load: load_token, save: save_token, }; - let inner = stack_auth::OidcFederationStrategy::builder( - region, - workspace_id, - NapiOidcProvider { get_jwt }, - ) - .with_token_store(store) - .build() - .map_err(to_napi_error)?; + let inner = stack_auth::OidcFederationStrategy::builder(crn, NapiOidcProvider { get_jwt }) + .with_token_store(store) + .build() + .map_err(to_napi_error)?; Ok(Self { inner: OidcFederationStrategyInner::WithStore(inner), }) diff --git a/languages/typescript/packages/auth/wasm-inline.d.ts b/languages/typescript/packages/auth/wasm-inline.d.ts index 5160b43fe..bd96861ba 100644 --- a/languages/typescript/packages/auth/wasm-inline.d.ts +++ b/languages/typescript/packages/auth/wasm-inline.d.ts @@ -103,15 +103,19 @@ export interface OidcFederationStrategyOptions { export declare class OidcFederationStrategy { private constructor(); /** - * Create an `OidcFederationStrategy` for the given region and workspace. + * Create an `OidcFederationStrategy` for the given workspace CRN. + * + * The CRN format is `crn::` (e.g. + * `"crn:ap-southeast-2.aws:ZVATKW3VHMFG27DY"`). Region is parsed from the + * CRN and used for service discovery; the workspace ID is used to verify + * every federated token belongs to the right workspace. * * `getJwt` must return the current third-party OIDC JWT (it is re-invoked * on every re-federation). Pass `options.store` to back the strategy with a * persistent cache — see {@link TokenStore}. */ static create( - region: string, - workspaceId: string, + workspaceCrn: string, getJwt: OidcProvider, options?: OidcFederationStrategyOptions, ): OidcFederationStrategy; diff --git a/languages/typescript/packages/auth/wasm-inline.mjs b/languages/typescript/packages/auth/wasm-inline.mjs index 2ff9b9337..2f06b31d0 100644 --- a/languages/typescript/packages/auth/wasm-inline.mjs +++ b/languages/typescript/packages/auth/wasm-inline.mjs @@ -74,13 +74,12 @@ export class OidcFederationStrategy { } /** - * @param {string} region - * @param {string} workspaceId + * @param {string} workspaceCrn * @param {OidcProvider} getJwt * @param {OidcFederationStrategyOptions} [options] * @returns {OidcFederationStrategy} */ - static create(region, workspaceId, getJwt, options) { + static create(workspaceCrn, getJwt, options) { // Wrap `getJwt` so the wasm binding always sees a Promise-returning // function even if the caller passed a sync one — see the note in // `AccessKeyStrategy.create`. @@ -92,8 +91,7 @@ export class OidcFederationStrategy { Promise.resolve(store.save(json)); return new OidcFederationStrategy( RawOidcFederationStrategy.createWithStore( - region, - workspaceId, + workspaceCrn, jwt, load, save, @@ -101,7 +99,7 @@ export class OidcFederationStrategy { ); } return new OidcFederationStrategy( - RawOidcFederationStrategy.create(region, workspaceId, jwt), + RawOidcFederationStrategy.create(workspaceCrn, jwt), ); } diff --git a/languages/typescript/packages/auth/wasm-types.d.ts b/languages/typescript/packages/auth/wasm-types.d.ts index 1fd49b93f..5df5a2f0d 100644 --- a/languages/typescript/packages/auth/wasm-types.d.ts +++ b/languages/typescript/packages/auth/wasm-types.d.ts @@ -102,16 +102,18 @@ export declare class AccessKeyStrategy { */ export declare class OidcFederationStrategy { private constructor() - /** Create an `OidcFederationStrategy` for the given region and workspace. */ + /** + * Create an `OidcFederationStrategy` for the given workspace CRN. The CRN + * format is `crn::` (e.g. + * `"crn:ap-southeast-2.aws:ZVATKW3VHMFG27DY"`). + */ static create( - region: string, - workspaceId: string, + workspaceCrn: string, getJwt: () => Promise, ): OidcFederationStrategy /** Create an `OidcFederationStrategy` backed by external token-store callbacks. */ static createWithStore( - region: string, - workspaceId: string, + workspaceCrn: string, getJwt: () => Promise, loadToken: () => Promise, saveToken: (json: string) => Promise, diff --git a/languages/typescript/packages/stack-auth-wasm/src/lib.rs b/languages/typescript/packages/stack-auth-wasm/src/lib.rs index 816d93b94..0e1f5860d 100644 --- a/languages/typescript/packages/stack-auth-wasm/src/lib.rs +++ b/languages/typescript/packages/stack-auth-wasm/src/lib.rs @@ -12,8 +12,6 @@ use std::collections::BTreeMap; -#[cfg(target_arch = "wasm32")] -use cts_common::Region; use serde::Serialize; use serde_wasm_bindgen::Serializer; use stack_auth::{AuthError, AuthStrategy, ServiceToken}; @@ -207,18 +205,12 @@ impl OidcProvider for JsOidcProvider { } } -/// Parse and validate the `region` + `workspaceId` inputs shared by both -/// `OidcFederationStrategy` factories. +/// Parse the workspace CRN shared by both `OidcFederationStrategy` factories. #[cfg(target_arch = "wasm32")] -fn parse_oidc_inputs( - region: &str, - workspace_id: &str, -) -> Result<(Region, cts_common::WorkspaceId), JsValue> { - let region = Region::new(region).map_err(|e| to_js_error(AuthError::from(e)))?; - let workspace_id = workspace_id - .parse::() - .map_err(|e| to_js_error(AuthError::from(e)))?; - Ok((region, workspace_id)) +fn parse_workspace_crn(workspace_crn: &str) -> Result { + workspace_crn + .parse() + .map_err(|e| to_js_error(AuthError::InvalidCrn(e))) } enum AccessKeyStrategyInner { @@ -341,25 +333,25 @@ pub struct OidcFederationStrategy { #[cfg(target_arch = "wasm32")] #[wasm_bindgen] impl OidcFederationStrategy { - /// Create an `OidcFederationStrategy` for the given region and workspace. + /// Create an `OidcFederationStrategy` for the given workspace CRN. + /// + /// The CRN format is `crn::` (e.g. + /// `"crn:ap-southeast-2.aws:ZVATKW3VHMFG27DY"`). Region is parsed from + /// the CRN and used for service discovery; the workspace ID is used to + /// verify every federated token belongs to the right workspace. /// /// `getJwt` is called on every federation — initial auth and every /// re-federation after expiry — and must return `Promise` /// resolving to the *current* third-party OIDC JWT (e.g. by calling /// `clerk.session.getToken()`). pub fn create( - region: String, - workspace_id: String, + workspace_crn: String, get_jwt: js_sys::Function, ) -> Result { - let (region, workspace_id) = parse_oidc_inputs(®ion, &workspace_id)?; - let inner = stack_auth::OidcFederationStrategy::builder( - region, - workspace_id, - JsOidcProvider { get_jwt }, - ) - .build() - .map_err(to_js_error)?; + let crn = parse_workspace_crn(&workspace_crn)?; + let inner = stack_auth::OidcFederationStrategy::builder(crn, JsOidcProvider { get_jwt }) + .build() + .map_err(to_js_error)?; Ok(OidcFederationStrategy { inner: OidcFederationStrategyInner::NoStore(inner), }) @@ -374,25 +366,20 @@ impl OidcFederationStrategy { /// survives across Edge Function invocations without re-federating. #[wasm_bindgen(js_name = createWithStore)] pub fn create_with_store( - region: String, - workspace_id: String, + workspace_crn: String, get_jwt: js_sys::Function, load_token: js_sys::Function, save_token: js_sys::Function, ) -> Result { - let (region, workspace_id) = parse_oidc_inputs(®ion, &workspace_id)?; + let crn = parse_workspace_crn(&workspace_crn)?; let store = JsTokenStore { load: load_token, save: save_token, }; - let inner = stack_auth::OidcFederationStrategy::builder( - region, - workspace_id, - JsOidcProvider { get_jwt }, - ) - .with_token_store(store) - .build() - .map_err(to_js_error)?; + let inner = stack_auth::OidcFederationStrategy::builder(crn, JsOidcProvider { get_jwt }) + .with_token_store(store) + .build() + .map_err(to_js_error)?; Ok(OidcFederationStrategy { inner: OidcFederationStrategyInner::WithStore(inner), }) @@ -644,60 +631,41 @@ mod tests { store.save(&token).await; } - const VALID_WORKSPACE_ID: &str = "ZVATKW3VHMFG27DY"; - fn jwt_fn(jwt: &str) -> js_sys::Function { // `async () => ""` js_sys::Function::new_no_args(&format!("return Promise.resolve('{jwt}');")) } #[wasm_bindgen_test] - fn oidc_federation_strategy_rejects_invalid_region() { + fn oidc_federation_strategy_rejects_invalid_crn() { let err = expect_js_err(OidcFederationStrategy::create( - "not-a-region".to_string(), - VALID_WORKSPACE_ID.to_string(), - jwt_fn("h.p.s"), - )); - assert_eq!(error_code_of(&err), "INVALID_REGION"); - } - - #[wasm_bindgen_test] - fn oidc_federation_strategy_rejects_invalid_workspace_id() { - let err = expect_js_err(OidcFederationStrategy::create( - "ap-southeast-2.aws".to_string(), - "not-a-workspace-id".to_string(), + "not-a-crn".to_string(), jwt_fn("h.p.s"), )); - assert_eq!(error_code_of(&err), "INVALID_WORKSPACE_ID"); + assert_eq!(error_code_of(&err), "INVALID_CRN"); } #[wasm_bindgen_test] fn oidc_federation_strategy_accepts_valid_inputs() { - let result = OidcFederationStrategy::create( - "ap-southeast-2.aws".to_string(), - VALID_WORKSPACE_ID.to_string(), - jwt_fn("h.p.s"), - ); + let result = OidcFederationStrategy::create(VALID_CRN.to_string(), jwt_fn("h.p.s")); assert!(result.is_ok()); } #[wasm_bindgen_test] - fn oidc_create_with_store_rejects_invalid_workspace_id() { + fn oidc_create_with_store_rejects_invalid_crn() { let err = expect_js_err(OidcFederationStrategy::create_with_store( - "ap-southeast-2.aws".to_string(), - "not-a-workspace-id".to_string(), + "not-a-crn".to_string(), jwt_fn("h.p.s"), empty_load_fn(), noop_save_fn(), )); - assert_eq!(error_code_of(&err), "INVALID_WORKSPACE_ID"); + assert_eq!(error_code_of(&err), "INVALID_CRN"); } #[wasm_bindgen_test] fn oidc_create_with_store_accepts_valid_inputs() { let result = OidcFederationStrategy::create_with_store( - "ap-southeast-2.aws".to_string(), - VALID_WORKSPACE_ID.to_string(), + VALID_CRN.to_string(), jwt_fn("h.p.s"), empty_load_fn(), noop_save_fn(), diff --git a/packages/stack-auth/src/oidc_federation_strategy.rs b/packages/stack-auth/src/oidc_federation_strategy.rs index ff07fe36b..089da1216 100644 --- a/packages/stack-auth/src/oidc_federation_strategy.rs +++ b/packages/stack-auth/src/oidc_federation_strategy.rs @@ -1,4 +1,4 @@ -use cts_common::{CtsServiceDiscovery, Region, ServiceDiscovery, WorkspaceId}; +use cts_common::{Crn, CtsServiceDiscovery, ServiceDiscovery, WorkspaceId}; use crate::auto_refresh::AutoRefresh; use crate::oidc_refresher::{OidcProvider, OidcRefresher}; @@ -15,14 +15,19 @@ use crate::{ensure_trailing_slash, AuthError, AuthStrategy, ServiceToken}; /// fresh CTS token. Supply an `OidcProvider` that returns the live provider /// token each time (e.g. wrapping `clerk.session.getToken()`). /// -/// Every returned token is checked against the configured workspace — the -/// same post-auth verification [`AccessKeyStrategy`](crate::AccessKeyStrategy) -/// performs — so a token CTS minted for a different workspace (or one loaded -/// from a poisoned shared cache) is never handed back. Verification can fail -/// in two ways: +/// The strategy is bound to a workspace CRN at construction. The region is +/// derived from the CRN — there is no separate `region` argument — so a +/// caller can't accidentally point the strategy at one region while the +/// CRN says another, matching +/// [`AccessKeyStrategy`](crate::AccessKeyStrategy). +/// +/// Every returned token is checked against the CRN's workspace — the +/// same post-auth verification `AccessKeyStrategy` performs — so a token CTS +/// minted for a different workspace (or one loaded from a poisoned shared +/// cache) is never handed back. Verification can fail in two ways: /// /// - [`AuthError::WorkspaceMismatch`] — the JWT decoded cleanly but its -/// `workspace` claim doesn't match the configured workspace ID. +/// `workspace` claim doesn't match the CRN's workspace ID. /// - [`AuthError::InvalidToken`] — the JWT is malformed or missing the /// `workspace` claim entirely, so verification can't run. /// @@ -36,15 +41,14 @@ use crate::{ensure_trailing_slash, AuthError, AuthStrategy, ServiceToken}; /// /// ```no_run /// use stack_auth::{AuthError, OidcProviderFn, OidcFederationStrategy, SecretToken}; -/// use cts_common::{Region, WorkspaceId}; +/// use cts_common::Crn; /// -/// let region = Region::aws("ap-southeast-2").unwrap(); -/// let workspace_id: WorkspaceId = "ZVATKW3VHMFG27DY".parse().unwrap(); +/// let crn: Crn = "crn:ap-southeast-2.aws:ZVATKW3VHMFG27DY".parse().unwrap(); /// let provider = OidcProviderFn::new(|| async { /// // Real consumers call into a provider SDK / FFI to fetch a live JWT. /// Ok::<_, AuthError>(SecretToken::new("header.payload.signature".to_string())) /// }); -/// let strategy = OidcFederationStrategy::new(region, workspace_id, provider).unwrap(); +/// let strategy = OidcFederationStrategy::new(crn, provider).unwrap(); /// ``` pub struct OidcFederationStrategy { inner: AutoRefresh, S>, @@ -52,27 +56,25 @@ pub struct OidcFederationStrategy { } impl OidcFederationStrategy

{ - /// Create a new `OidcFederationStrategy` for the given region, workspace, and + /// Create a new `OidcFederationStrategy` for the given workspace CRN and /// OIDC provider. /// - /// The auth endpoint is resolved automatically via service discovery. - pub fn new( - region: Region, - workspace_id: WorkspaceId, - oidc_provider: P, - ) -> Result { - Self::builder(region, workspace_id, oidc_provider).build() + /// The auth endpoint is resolved automatically via service discovery + /// using the region encoded in the CRN; the workspace ID is used to + /// verify every federated token belongs to the right workspace. + /// + /// A CRN with a `service_name` component (e.g. + /// `crn:ap-southeast-2.aws:ZVATKW3VHMFG27DY:zerokms`) is accepted; the + /// `service_name` is ignored. Only the region and workspace ID are + /// load-bearing for this strategy. + pub fn new(workspace_crn: Crn, oidc_provider: P) -> Result { + Self::builder(workspace_crn, oidc_provider).build() } /// Return a builder for configuring an `OidcFederationStrategy` before construction. - pub fn builder( - region: Region, - workspace_id: WorkspaceId, - oidc_provider: P, - ) -> OidcFederationStrategyBuilder

{ + pub fn builder(workspace_crn: Crn, oidc_provider: P) -> OidcFederationStrategyBuilder

{ OidcFederationStrategyBuilder { - region, - workspace_id, + workspace_crn, oidc_provider, base_url_override: None, token_store: NoStore, @@ -98,8 +100,7 @@ impl AuthStrategy for &OidcFederationStrategy { - region: Region, - workspace_id: WorkspaceId, + workspace_crn: Crn, oidc_provider: P, base_url_override: Option, token_store: S, @@ -129,8 +130,7 @@ impl OidcFederationStrategyBuilder { /// [`TokenStoreFn`](crate::TokenStoreFn) for ready-made implementations. pub fn with_token_store(self, store: T) -> OidcFederationStrategyBuilder { OidcFederationStrategyBuilder { - region: self.region, - workspace_id: self.workspace_id, + workspace_crn: self.workspace_crn, oidc_provider: self.oidc_provider, base_url_override: self.base_url_override, token_store: store, @@ -141,18 +141,21 @@ impl OidcFederationStrategyBuilder { impl OidcFederationStrategyBuilder { /// Build the [`OidcFederationStrategy`]. /// - /// Resolves the base URL via service discovery unless overridden with - /// `base_url` (available when the `test-utils` feature is enabled). + /// Resolves the base URL via service discovery using the CRN's region, + /// unless overridden with `base_url` (available when the `test-utils` + /// feature is enabled). pub fn build(self) -> Result, AuthError> { + let expected_workspace = self.workspace_crn.workspace_id; + let region = self.workspace_crn.region; let base_url = match self.base_url_override { Some(url) => url, - None => crate::cts_base_url_from_env()? - .unwrap_or(CtsServiceDiscovery::endpoint(self.region)?), + None => { + crate::cts_base_url_from_env()?.unwrap_or(CtsServiceDiscovery::endpoint(region)?) + } }; - let expected_workspace = self.workspace_id; let refresher = OidcRefresher::new( self.oidc_provider, - self.workspace_id, + expected_workspace, ensure_trailing_slash(base_url), ); Ok(OidcFederationStrategy { @@ -168,7 +171,6 @@ mod tests { use std::sync::Arc; use std::time::{SystemTime, UNIX_EPOCH}; - use cts_common::Region; use mocktail::prelude::*; use super::*; @@ -216,8 +218,10 @@ mod tests { server } - fn test_region() -> Region { - Region::aws("ap-southeast-2").expect("region parses") + fn crn_with_workspace(workspace: &str) -> Crn { + format!("crn:ap-southeast-2.aws:{workspace}") + .parse() + .expect("test CRN parses") } fn provider() -> OidcProviderFn std::future::Ready>> @@ -234,11 +238,10 @@ mod tests { const WS: &str = "ZVATKW3VHMFG27DY"; let server = start_mock_server_returning_jwt(WS).await; - let strategy = - OidcFederationStrategy::builder(test_region(), WS.parse().unwrap(), provider()) - .base_url(server.url("")) - .build() - .expect("builder"); + let strategy = OidcFederationStrategy::builder(crn_with_workspace(WS), provider()) + .base_url(server.url("")) + .build() + .expect("builder"); let token = (&strategy).get_token().await.expect("get_token"); assert_eq!( @@ -259,14 +262,10 @@ mod tests { const EXPECTED_WS: &str = "ZVATKW3VHMFG27DY"; let server = start_mock_server_returning_jwt(TOKEN_WS).await; - let strategy = OidcFederationStrategy::builder( - test_region(), - EXPECTED_WS.parse().unwrap(), - provider(), - ) - .base_url(server.url("")) - .build() - .expect("builder"); + let strategy = OidcFederationStrategy::builder(crn_with_workspace(EXPECTED_WS), provider()) + .base_url(server.url("")) + .build() + .expect("builder"); let err = (&strategy) .get_token() @@ -298,14 +297,11 @@ mod tests { MockServer::new_http("oidc-federation-strategy-malformed-test").with_mocks(mocks); server.start().await.expect("mock server start"); - let strategy = OidcFederationStrategy::builder( - test_region(), - "ZVATKW3VHMFG27DY".parse().unwrap(), - provider(), - ) - .base_url(server.url("")) - .build() - .expect("builder"); + let strategy = + OidcFederationStrategy::builder(crn_with_workspace("ZVATKW3VHMFG27DY"), provider()) + .base_url(server.url("")) + .build() + .expect("builder"); let err = (&strategy) .get_token() @@ -353,15 +349,11 @@ mod tests { let store = Arc::new(InMemoryTokenStore::new()); store.save(&stored).await; - let strategy = OidcFederationStrategy::builder( - test_region(), - EXPECTED_WS.parse().unwrap(), - provider(), - ) - .base_url(server.url("")) - .with_token_store(Arc::clone(&store)) - .build() - .expect("builder"); + let strategy = OidcFederationStrategy::builder(crn_with_workspace(EXPECTED_WS), provider()) + .base_url(server.url("")) + .with_token_store(Arc::clone(&store)) + .build() + .expect("builder"); let err = (&strategy) .get_token() @@ -383,14 +375,10 @@ mod tests { const EXPECTED_WS: &str = "ZVATKW3VHMFG27DY"; let server = start_mock_server_returning_jwt(TOKEN_WS).await; - let strategy = OidcFederationStrategy::builder( - test_region(), - EXPECTED_WS.parse().unwrap(), - provider(), - ) - .base_url(server.url("")) - .build() - .expect("builder"); + let strategy = OidcFederationStrategy::builder(crn_with_workspace(EXPECTED_WS), provider()) + .base_url(server.url("")) + .build() + .expect("builder"); for call in 1..=2 { let err = match (&strategy).get_token().await { From 591542c3d9967bd278407b1b2306456a9bba5793 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Fri, 19 Jun 2026 18:03:18 +1000 Subject: [PATCH 291/686] refactor(stack-auth): address code-review findings on the CRN change MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-ups from the review of the OidcFederationStrategy CRN migration: - changelog: fix the unreleased 0.39.0 entry — it documented OIDC create/createWithStore with the old (region, workspaceId) signatures and INVALID_WORKSPACE_ID as the factory's parse error; both were removed by the CRN change. Now shows the CRN signature and notes a bad CRN → INVALID_CRN. - bindings: route AccessKeyStrategy/AutoStrategy CRN parsing through the shared parse_workspace_crn helper in both node and wasm (was: helper used only by the OIDC factories, the others inlined the same parse). - core: extract the duplicated post-auth workspace-mismatch check into ServiceToken::verify_workspace, used by both AccessKeyStrategy and OidcFederationStrategy; add focused unit tests for it. - tests: move the duplicated crn_with_workspace / jwt_with_workspace fixtures into src/test_support.rs; add an OIDC accepts_crn_with_service_name test and a malformed-workspace-segment → INVALID_CRN test (wasm + node TS). - docs: de-skew the wasm-types.d.ts createWithStore doc and make the README OIDC example source the CRN from CS_WORKSPACE_CRN like the AccessKey example. --- .../typescript/packages/auth/CHANGELOG.md | 16 ++-- languages/typescript/packages/auth/README.md | 2 +- .../oidc-federation-strategy.test.ts | 17 ++++ languages/typescript/packages/auth/src/lib.rs | 25 +++--- .../typescript/packages/auth/wasm-types.d.ts | 7 +- .../packages/stack-auth-wasm/src/lib.rs | 27 ++++-- .../stack-auth/src/access_key_strategy.rs | 48 ++--------- .../src/oidc_federation_strategy.rs | 73 +++++++--------- packages/stack-auth/src/service_token.rs | 86 +++++++++++++++++++ packages/stack-auth/src/test_support.rs | 43 ++++++++++ 10 files changed, 232 insertions(+), 112 deletions(-) diff --git a/languages/typescript/packages/auth/CHANGELOG.md b/languages/typescript/packages/auth/CHANGELOG.md index a16702e07..03b63c594 100644 --- a/languages/typescript/packages/auth/CHANGELOG.md +++ b/languages/typescript/packages/auth/CHANGELOG.md @@ -11,19 +11,22 @@ ```ts const strategy = OidcFederationStrategy.create( - "ap-southeast-2.aws", - "ZVATKW3VHMFG27DY", + "crn:ap-southeast-2.aws:ZVATKW3VHMFG27DY", getJwt, // () => Promise — your current third-party OIDC JWT ); const { token } = await strategy.getToken(); ``` + The first argument is a workspace CRN: region is derived from it for service + discovery, and the workspace ID is used to verify every federated token — + the same shape as `AccessKeyStrategy`. + A store-backed variant persists the federated CTS token (e.g. in an HTTP-only cookie) so it survives across requests without re-federating: ```ts OidcFederationStrategy.createWithStore( - region, workspaceId, getJwt, loadToken, saveToken, + workspaceCrn, getJwt, loadToken, saveToken, ); ``` @@ -67,8 +70,11 @@ - `WORKSPACE_MISMATCH` — the JWT decoded cleanly but its `workspace` claim doesn't match the CRN the strategy was configured with. The accompanying message identifies both the expected and the token-supplied workspace IDs. -- `INVALID_WORKSPACE_ID` — the `workspaceId` passed to an - `OidcFederationStrategy` factory could not be parsed. +- `INVALID_WORKSPACE_ID` — a token's `workspace` claim could not be parsed + while extracting or verifying it. + +Both `AccessKeyStrategy` and `OidcFederationStrategy` take a workspace CRN, so a +malformed CRN argument is rejected with the existing `INVALID_CRN` code. ## 0.35.0 diff --git a/languages/typescript/packages/auth/README.md b/languages/typescript/packages/auth/README.md index 700180b04..9d81b48b8 100644 --- a/languages/typescript/packages/auth/README.md +++ b/languages/typescript/packages/auth/README.md @@ -107,7 +107,7 @@ Deno.serve(async (req) => { const responseHeaders = new Headers({ "content-type": "application/json" }); const strategy = OidcFederationStrategy.create( - workspaceCrn, // e.g. "crn:ap-southeast-2.aws:ZVATKW3VHMFG27DY" + Deno.env.get("CS_WORKSPACE_CRN")!, // e.g. "crn:ap-southeast-2.aws:ZVATKW3VHMFG27DY" // Returns the *current* provider JWT — re-invoked on every re-federation. () => getClerkSessionToken(req), { store: cookieStore({ request: req, responseHeaders }) }, diff --git a/languages/typescript/packages/auth/__tests__/oidc-federation-strategy.test.ts b/languages/typescript/packages/auth/__tests__/oidc-federation-strategy.test.ts index ccb5b8654..32997d767 100644 --- a/languages/typescript/packages/auth/__tests__/oidc-federation-strategy.test.ts +++ b/languages/typescript/packages/auth/__tests__/oidc-federation-strategy.test.ts @@ -108,6 +108,23 @@ describe("OidcFederationStrategy (TypeScript / vitest)", () => { } }); + it("rejects a CRN whose workspace segment is malformed with .code", () => { + // "not-a-crn" above fails at the `crn:` prefix; this is the distinct path + // where the prefix/region parse but the workspace segment fails validation + // — what the old INVALID_WORKSPACE_ID case covered before the CRN switch. + try { + OidcFederationStrategy.create( + "crn:ap-southeast-2.aws:not-a-valid-workspace", + () => Promise.resolve("h.p.s"), + ); + expect.unreachable( + "create should throw on a malformed workspace segment", + ); + } catch (err) { + expect((err as AuthError).code).toBe("INVALID_CRN"); + } + }); + it("persists the federated token to the store", async () => { server.mockAuthorizeEndpoint(); const store = memStore(); diff --git a/languages/typescript/packages/auth/src/lib.rs b/languages/typescript/packages/auth/src/lib.rs index f7e2a7add..37e81b548 100644 --- a/languages/typescript/packages/auth/src/lib.rs +++ b/languages/typescript/packages/auth/src/lib.rs @@ -34,6 +34,15 @@ fn warn_callback(name: &str, detail: &str) { eprintln!("stack-auth: {name} {detail}"); } +/// Parse a workspace CRN string, mapping a parse failure to the `INVALID_CRN` +/// error code. Shared by every factory that takes a workspace CRN +/// (`AccessKeyStrategy`, `AutoStrategy`, `OidcFederationStrategy`). +fn parse_workspace_crn(workspace_crn: &str) -> Result { + workspace_crn + .parse() + .map_err(|e| to_napi_error(AuthError::InvalidCrn(e))) +} + // --------------------------------------------------------------------------- // TokenResult — returned by strategy.getToken() // --------------------------------------------------------------------------- @@ -117,10 +126,7 @@ impl AutoStrategy { builder = builder.with_access_key(key); } if let Some(crn_str) = opts.workspace_crn { - let crn = crn_str - .parse() - .map_err(|e| to_napi_error(AuthError::InvalidCrn(e)))?; - builder = builder.with_workspace_crn(crn); + builder = builder.with_workspace_crn(parse_workspace_crn(&crn_str)?); } } @@ -159,9 +165,7 @@ impl AccessKeyStrategy { /// A mismatch fails `getToken()` with `code === "WORKSPACE_MISMATCH"`. #[napi(factory)] pub fn create(workspace_crn: String, access_key: String) -> Result { - let crn: cts_common::Crn = workspace_crn - .parse() - .map_err(|e| to_napi_error(AuthError::InvalidCrn(e)))?; + let crn = parse_workspace_crn(&workspace_crn)?; let key: stack_auth::AccessKey = access_key .parse() .map_err(|e| to_napi_error(AuthError::from(e)))?; @@ -213,13 +217,6 @@ impl DeviceSessionStrategy { // OidcFederationStrategy — federate a third-party OIDC JWT into a CTS service token // --------------------------------------------------------------------------- -/// Parse the workspace CRN shared by both `OidcFederationStrategy` factories. -fn parse_workspace_crn(workspace_crn: &str) -> Result { - workspace_crn - .parse() - .map_err(|e| to_napi_error(AuthError::InvalidCrn(e))) -} - /// Bridges a JS `getJwt` callback into a Rust [`OidcProvider`]. /// /// `getJwt` is a JS function returning `Promise` — the current diff --git a/languages/typescript/packages/auth/wasm-types.d.ts b/languages/typescript/packages/auth/wasm-types.d.ts index 5df5a2f0d..8a9326e3b 100644 --- a/languages/typescript/packages/auth/wasm-types.d.ts +++ b/languages/typescript/packages/auth/wasm-types.d.ts @@ -111,7 +111,12 @@ export declare class OidcFederationStrategy { workspaceCrn: string, getJwt: () => Promise, ): OidcFederationStrategy - /** Create an `OidcFederationStrategy` backed by external token-store callbacks. */ + /** + * Create an `OidcFederationStrategy` backed by external token-store + * callbacks. Takes the same `workspaceCrn` as {@link create} (region for + * service discovery, workspace ID for verification) plus `loadToken` / + * `saveToken` to persist the federated CTS token across requests. + */ static createWithStore( workspaceCrn: string, getJwt: () => Promise, diff --git a/languages/typescript/packages/stack-auth-wasm/src/lib.rs b/languages/typescript/packages/stack-auth-wasm/src/lib.rs index 0e1f5860d..632f0fe9c 100644 --- a/languages/typescript/packages/stack-auth-wasm/src/lib.rs +++ b/languages/typescript/packages/stack-auth-wasm/src/lib.rs @@ -205,8 +205,9 @@ impl OidcProvider for JsOidcProvider { } } -/// Parse the workspace CRN shared by both `OidcFederationStrategy` factories. -#[cfg(target_arch = "wasm32")] +/// Parse a workspace CRN string, mapping a parse failure to the `INVALID_CRN` +/// error code. Shared by every factory that takes a workspace CRN +/// (`AccessKeyStrategy`, `OidcFederationStrategy`). fn parse_workspace_crn(workspace_crn: &str) -> Result { workspace_crn .parse() @@ -244,9 +245,7 @@ impl AccessKeyStrategy { /// Every issued token's workspace claim is verified against the CRN; /// a mismatch fails the call with a `WORKSPACE_MISMATCH` error. pub fn create(workspace_crn: String, access_key: String) -> Result { - let crn: cts_common::Crn = workspace_crn - .parse() - .map_err(|e| to_js_error(AuthError::InvalidCrn(e)))?; + let crn = parse_workspace_crn(&workspace_crn)?; let key: stack_auth::AccessKey = access_key .parse() .map_err(|e| to_js_error(AuthError::from(e)))?; @@ -274,9 +273,7 @@ impl AccessKeyStrategy { load_token: js_sys::Function, save_token: js_sys::Function, ) -> Result { - let crn: cts_common::Crn = workspace_crn - .parse() - .map_err(|e| to_js_error(AuthError::InvalidCrn(e)))?; + let crn = parse_workspace_crn(&workspace_crn)?; let key: stack_auth::AccessKey = access_key .parse() .map_err(|e| to_js_error(AuthError::from(e)))?; @@ -645,6 +642,20 @@ mod tests { assert_eq!(error_code_of(&err), "INVALID_CRN"); } + /// A structurally well-formed CRN whose workspace segment fails + /// `WorkspaceId` validation is rejected with `INVALID_CRN` — the path the + /// old `INVALID_WORKSPACE_ID` test covered before the factory took a CRN. + /// "not-a-crn" above fails at the `crn:` prefix; this exercises the + /// workspace sub-parser instead. + #[wasm_bindgen_test] + fn oidc_federation_strategy_rejects_crn_with_malformed_workspace() { + let err = expect_js_err(OidcFederationStrategy::create( + "crn:ap-southeast-2.aws:not-a-valid-workspace".to_string(), + jwt_fn("h.p.s"), + )); + assert_eq!(error_code_of(&err), "INVALID_CRN"); + } + #[wasm_bindgen_test] fn oidc_federation_strategy_accepts_valid_inputs() { let result = OidcFederationStrategy::create(VALID_CRN.to_string(), jwt_fn("h.p.s")); diff --git a/packages/stack-auth/src/access_key_strategy.rs b/packages/stack-auth/src/access_key_strategy.rs index df29167f4..2167c05fd 100644 --- a/packages/stack-auth/src/access_key_strategy.rs +++ b/packages/stack-auth/src/access_key_strategy.rs @@ -93,15 +93,10 @@ impl AccessKeyStrategy { impl AuthStrategy for &AccessKeyStrategy { async fn get_token(self) -> Result { - let token: ServiceToken = self.inner.get_token().await?; - let token_workspace = *token.workspace_id()?; - if token_workspace != self.expected_workspace { - return Err(AuthError::WorkspaceMismatch { - expected_workspace: self.expected_workspace, - token_workspace, - }); - } - Ok(token) + self.inner + .get_token() + .await? + .verify_workspace(self.expected_workspace) } } @@ -186,35 +181,9 @@ impl AccessKeyStrategyBuilder { #[cfg(test)] mod workspace_verification_tests { use super::*; + use crate::test_support::{crn_with_workspace, jwt_with_workspace}; use mocktail::prelude::*; - use std::time::{SystemTime, UNIX_EPOCH}; - - /// Build a JWT carrying the given `workspace` claim. Mirrors the - /// helper in `node/src/mock_auth_server.rs`. - fn jwt_with_workspace(workspace: &str) -> String { - use jsonwebtoken::{encode, EncodingKey, Header}; - #[allow(clippy::expect_used)] - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .expect("system clock") - .as_secs(); - let claims = serde_json::json!({ - "iss": "https://cts.example.com/", - "sub": "CS|test-access-key", - "aud": "test-audience", - "iat": now, - "exp": now + 3600, - "workspace": workspace, - "scope": "", - }); - #[allow(clippy::expect_used)] - encode( - &Header::default(), - &claims, - &EncodingKey::from_secret(b"test-secret"), - ) - .expect("JWT encode") - } + use std::time::UNIX_EPOCH; async fn start_mock_server_returning_jwt(workspace: &str) -> MockServer { let mut mocks = MockSet::new(); @@ -232,11 +201,6 @@ mod workspace_verification_tests { server } - fn crn_with_workspace(workspace: &str) -> Crn { - let s = format!("crn:ap-southeast-2.aws:{workspace}"); - s.parse().expect("test CRN parses") - } - fn test_access_key() -> AccessKey { "CSAKtestKeyId.testKeySecret" .parse() diff --git a/packages/stack-auth/src/oidc_federation_strategy.rs b/packages/stack-auth/src/oidc_federation_strategy.rs index 089da1216..707ceed32 100644 --- a/packages/stack-auth/src/oidc_federation_strategy.rs +++ b/packages/stack-auth/src/oidc_federation_strategy.rs @@ -84,15 +84,10 @@ impl OidcFederationStrategy

{ impl AuthStrategy for &OidcFederationStrategy { async fn get_token(self) -> Result { - let token: ServiceToken = self.inner.get_token().await?; - let token_workspace = *token.workspace_id()?; - if token_workspace != self.expected_workspace { - return Err(AuthError::WorkspaceMismatch { - expected_workspace: self.expected_workspace, - token_workspace, - }); - } - Ok(token) + self.inner + .get_token() + .await? + .verify_workspace(self.expected_workspace) } } @@ -175,34 +170,9 @@ mod tests { use super::*; use crate::oidc_refresher::OidcProviderFn; + use crate::test_support::{crn_with_workspace, jwt_with_workspace}; use crate::{InMemoryTokenStore, SecretToken, Token, TokenStore}; - /// Mint an unsigned JWT carrying the given `workspace` claim. The strategy - /// decodes claims without verifying the signature (it already holds the - /// token), so an unsigned token is sufficient to exercise verification. - fn jwt_with_workspace(workspace: &str) -> String { - use jsonwebtoken::{encode, EncodingKey, Header}; - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .expect("system clock") - .as_secs(); - let claims = serde_json::json!({ - "iss": "https://cts.example.com/", - "sub": "CS|test-user", - "aud": "test-audience", - "iat": now, - "exp": now + 3600, - "workspace": workspace, - "scope": "", - }); - encode( - &Header::default(), - &claims, - &EncodingKey::from_secret(b"test-secret"), - ) - .expect("JWT encode") - } - /// A mock CTS that federates any OIDC token into a CTS token carrying the /// given `workspace` claim. async fn start_mock_server_returning_jwt(workspace: &str) -> MockServer { @@ -218,12 +188,6 @@ mod tests { server } - fn crn_with_workspace(workspace: &str) -> Crn { - format!("crn:ap-southeast-2.aws:{workspace}") - .parse() - .expect("test CRN parses") - } - fn provider() -> OidcProviderFn std::future::Ready>> { OidcProviderFn::new(|| { @@ -251,6 +215,33 @@ mod tests { ); } + /// A CRN carrying a `service_name` component is accepted; the + /// `service_name` is ignored, exactly as for + /// [`AccessKeyStrategy`](crate::AccessKeyStrategy). Pinned as a test — + /// matching `access_key_strategy::accepts_crn_with_service_name` — so a + /// future contributor doesn't tighten the constructor into rejecting these + /// CRNs without realising the docstring already promises acceptance. + #[tokio::test] + async fn accepts_crn_with_service_name() { + const WS: &str = "ZVATKW3VHMFG27DY"; + let server = start_mock_server_returning_jwt(WS).await; + let crn: Crn = format!("crn:ap-southeast-2.aws:{WS}:zerokms") + .parse() + .expect("CRN with service_name parses"); + + let strategy = OidcFederationStrategy::builder(crn, provider()) + .base_url(server.url("")) + .build() + .expect("CRN with service_name should construct a strategy"); + + let token = (&strategy).get_token().await.expect("get_token"); + assert_eq!( + token.workspace_id().expect("workspace_id").as_str(), + WS, + "service_name is ignored — verification still uses the workspace ID", + ); + } + /// Mismatch — CTS federates the OIDC token into a CTS token for a /// *different* workspace than the strategy was configured for. This is the /// security-critical case: the OIDC provider could be authenticated for a diff --git a/packages/stack-auth/src/service_token.rs b/packages/stack-auth/src/service_token.rs index 136dae3af..25db63c40 100644 --- a/packages/stack-auth/src/service_token.rs +++ b/packages/stack-auth/src/service_token.rs @@ -91,6 +91,33 @@ impl ServiceToken { .map_err(|reason| AuthError::InvalidToken(reason.clone())) } + /// Verify the token's `workspace` claim matches `expected`, returning the + /// token unchanged on a match. + /// + /// This is the shared post-auth check that every strategy bound to a + /// workspace CRN ([`AccessKeyStrategy`](crate::AccessKeyStrategy), + /// [`OidcFederationStrategy`](crate::OidcFederationStrategy)) runs on each + /// [`get_token`](crate::AuthStrategy::get_token), so a token CTS minted for + /// a different workspace (or loaded from a poisoned shared cache) is never + /// handed back. + /// + /// # Errors + /// + /// - [`AuthError::WorkspaceMismatch`] if the token's `workspace` claim is a + /// different workspace than `expected`. + /// - [`AuthError::InvalidToken`] if the token is not a valid JWT or its + /// `workspace` claim could not be decoded, so verification can't run. + pub(crate) fn verify_workspace(self, expected: WorkspaceId) -> Result { + let token_workspace = *self.workspace_id()?; + if token_workspace != expected { + return Err(AuthError::WorkspaceMismatch { + expected_workspace: expected, + token_workspace, + }); + } + Ok(self) + } + /// Return the `iss` (issuer) URL from the JWT claims. /// /// In CipherStash tokens the issuer is the CTS host URL for the workspace. @@ -378,6 +405,65 @@ mod tests { ); } + #[test] + fn verify_workspace_returns_token_when_workspace_matches() { + let jwt = make_jwt( + "https://cts.example.com/", + services_with_zerokms("https://zerokms.example.com/"), + ); + let token = ServiceToken::new(SecretToken::new(jwt)); + let expected: WorkspaceId = "ZVATKW3VHMFG27DY".parse().unwrap(); + + let verified = token + .verify_workspace(expected) + .expect("matching workspace should pass verification"); + assert_eq!( + verified.workspace_id().unwrap().to_string(), + "ZVATKW3VHMFG27DY", + "verified token should still carry its workspace claim", + ); + } + + #[test] + fn verify_workspace_errors_with_mismatch_when_workspace_differs() { + // make_jwt mints a token for workspace ZVATKW3VHMFG27DY. + let jwt = make_jwt( + "https://cts.example.com/", + services_with_zerokms("https://zerokms.example.com/"), + ); + let token = ServiceToken::new(SecretToken::new(jwt)); + let expected: WorkspaceId = "AAAAAAAAAAAAAAAA".parse().unwrap(); + + let err = token + .verify_workspace(expected) + .expect_err("a different expected workspace must be rejected"); + match err { + AuthError::WorkspaceMismatch { + expected_workspace, + token_workspace, + } => { + assert_eq!(expected_workspace.to_string(), "AAAAAAAAAAAAAAAA"); + assert_eq!(token_workspace.to_string(), "ZVATKW3VHMFG27DY"); + } + other => panic!("expected WorkspaceMismatch, got {other:?}"), + } + } + + #[test] + fn verify_workspace_errors_with_invalid_token_for_non_jwt() { + // A non-JWT can't be decoded, so verification can't run. + let token = ServiceToken::new(SecretToken::new("not-a-jwt")); + let expected: WorkspaceId = "ZVATKW3VHMFG27DY".parse().unwrap(); + + let err = token + .verify_workspace(expected) + .expect_err("a non-JWT token must surface InvalidToken"); + assert!( + matches!(err, AuthError::InvalidToken(_)), + "expected InvalidToken, got {err:?}", + ); + } + #[test] fn debug_does_not_leak_secret() { let jwt = make_jwt( diff --git a/packages/stack-auth/src/test_support.rs b/packages/stack-auth/src/test_support.rs index 3f485b5d3..980b93d24 100644 --- a/packages/stack-auth/src/test_support.rs +++ b/packages/stack-auth/src/test_support.rs @@ -5,6 +5,8 @@ //! the fixture drift that comes from copy-pasting the `Token { .. }` literal and //! the unsigned-JWT mint into every test module. +use cts_common::Crn; + use crate::{SecretToken, Token}; /// A [`Token`] with the given raw access-token string and a far-future expiry, @@ -36,6 +38,9 @@ pub(crate) fn jwt_token(claims: serde_json::Value) -> Token { /// Standard CTS JWT claims for `workspace`, with the other required claims /// (`iss`/`sub`/`aud`/`iat`/`exp`/`scope`) filled in with valid placeholders. +/// +/// NOTE: `exp` is a fixed *past* epoch, so the token reads as expired — use +/// [`jwt_with_workspace`] instead when a currently-valid token is needed. pub(crate) fn claims_with_workspace(workspace: &str) -> serde_json::Value { serde_json::json!({ "workspace": workspace, @@ -47,3 +52,41 @@ pub(crate) fn claims_with_workspace(workspace: &str) -> serde_json::Value { "scope": "dataset:create", }) } + +/// A workspace [`Crn`] in the standard test region (`ap-southeast-2.aws`) +/// carrying the given `workspace` ID. +pub(crate) fn crn_with_workspace(workspace: &str) -> Crn { + format!("crn:ap-southeast-2.aws:{workspace}") + .parse() + .expect("test CRN parses") +} + +/// A real (unsigned) JWT *string* carrying the given `workspace` claim and a +/// currently-valid (`now + 1h`) expiry — for exercising the post-auth +/// workspace verification that CRN-bound strategies run. Unlike +/// [`claims_with_workspace`], whose `exp` is a fixed past epoch, the token this +/// mints reads as valid. +pub(crate) fn jwt_with_workspace(workspace: &str) -> String { + use jsonwebtoken::{encode, EncodingKey, Header}; + use std::time::{SystemTime, UNIX_EPOCH}; + + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system clock") + .as_secs(); + let claims = serde_json::json!({ + "iss": "https://cts.example.com/", + "sub": "CS|test-principal", + "aud": "test-audience", + "iat": now, + "exp": now + 3600, + "workspace": workspace, + "scope": "", + }); + encode( + &Header::default(), + &claims, + &EncodingKey::from_secret(b"test-secret"), + ) + .expect("JWT encode") +} From 3dda879fef4d2eb904d1368c2f247a8af91d38f2 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Fri, 19 Jun 2026 19:35:32 +1000 Subject: [PATCH 292/686] docs(stack-auth): note OidcFederationStrategy INVALID_CRN error-code change Per review: OidcFederationStrategy construction now reports INVALID_CRN instead of the previous INVALID_REGION / INVALID_WORKSPACE_ID codes, since it takes a single workspace CRN. Call this out explicitly in the CHANGELOG. --- languages/typescript/packages/auth/CHANGELOG.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/languages/typescript/packages/auth/CHANGELOG.md b/languages/typescript/packages/auth/CHANGELOG.md index 03b63c594..251234617 100644 --- a/languages/typescript/packages/auth/CHANGELOG.md +++ b/languages/typescript/packages/auth/CHANGELOG.md @@ -76,6 +76,14 @@ Both `AccessKeyStrategy` and `OidcFederationStrategy` take a workspace CRN, so a malformed CRN argument is rejected with the existing `INVALID_CRN` code. +### Changed Error Codes + +- **`OidcFederationStrategy` construction now reports `INVALID_CRN`.** Because it + takes a single workspace CRN instead of separate `region` + `workspaceId` + arguments, a malformed value is now surfaced as `INVALID_CRN` rather than the + previous `INVALID_REGION` / `INVALID_WORKSPACE_ID` codes. Consumers matching on + those codes from the strategy's factories should update accordingly. + ## 0.35.0 ### New Features From f27df6ab6bc24282a73d1dc710c0e909a98b889c Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Thu, 18 Jun 2026 21:58:09 +1000 Subject: [PATCH 293/686] test(fuzz): add JWT claims-decode fuzz target (stacked follow-up) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The deferred JWT-decode harness from the cargo-fuzz scaffold (cipherstash/cipherstash-suite#2045). The decode entry points were `pub(crate)`, so expose a thin fuzz-only shim instead of widening the real API: - stack-auth: new `fuzz` Cargo feature gating `Token::fuzz_decode_claims(&str)`, which runs the real `decode_claims` and discards the claims. A Cargo feature (not `#[cfg(fuzzing)]`) keeps `fuzz` a known cfg, so it never trips the `unexpected_cfgs` lint under `-D warnings`. - New `jwt_decode` fuzz target + `fuzz:jwt-decode` mise task + seed JWT; the CI matrix in fuzz.yml gains the target. On native this exercises the jsonwebtoken-based decode (signature validation disabled — same posture as production). The hand-rolled wasm base64/JSON decoder (`decode_jwt_payload_wasm`) can't be fuzzed natively because its `base64` dep is wasm32-only and cargo doesn't gate `[target.'cfg(...)']` deps on `--cfg fuzzing`; that needs a wasm-target fuzz build (future work). Local 20s campaign: ~1.2M execs, no crash. clippy --all-features + fmt clean. --- .github/imported-workflows/fuzz.yml | 2 ++ packages/stack-auth/Cargo.toml | 4 ++++ packages/stack-auth/fuzz/Cargo.toml | 9 +++++++++ .../fuzz/corpus/jwt_decode/valid-jwt | 1 + .../fuzz/fuzz_targets/jwt_decode.rs | 14 +++++++++++++ packages/stack-auth/src/token.rs | 20 +++++++++++++++++++ packages/stack-auth/tasks.toml | 8 ++++++++ 7 files changed, 58 insertions(+) create mode 100644 packages/stack-auth/fuzz/corpus/jwt_decode/valid-jwt create mode 100644 packages/stack-auth/fuzz/fuzz_targets/jwt_decode.rs diff --git a/.github/imported-workflows/fuzz.yml b/.github/imported-workflows/fuzz.yml index 44a145bb7..bfd92a4c6 100644 --- a/.github/imported-workflows/fuzz.yml +++ b/.github/imported-workflows/fuzz.yml @@ -58,6 +58,7 @@ jobs: - { task: "fuzz:workspace-id", slug: workspace-id } - { task: "fuzz:region", slug: region } - { task: "fuzz:access-key", slug: access-key } + - { task: "fuzz:jwt-decode", slug: jwt-decode } steps: - uses: actions/checkout@v6 - uses: ./.github/actions/setup-rust @@ -81,6 +82,7 @@ jobs: - { task: "fuzz:workspace-id", slug: workspace-id, dir: packages/cts-common, target: workspace_id_parse } - { task: "fuzz:region", slug: region, dir: packages/cts-common, target: region_parse } - { task: "fuzz:access-key", slug: access-key, dir: packages/stack-auth, target: access_key_parse } + - { task: "fuzz:jwt-decode", slug: jwt-decode, dir: packages/stack-auth, target: jwt_decode } steps: - uses: actions/checkout@v6 - uses: ./.github/actions/setup-rust diff --git a/packages/stack-auth/Cargo.toml b/packages/stack-auth/Cargo.toml index 7a325ca8d..ee984e8b7 100644 --- a/packages/stack-auth/Cargo.toml +++ b/packages/stack-auth/Cargo.toml @@ -53,6 +53,10 @@ tokio = { version = "1.47.1", default-features = false, features = ["sync"] } [features] test-utils = [] +# Exposes fuzz-only entry points (e.g. `fuzz_decode_claims`) for the cargo-fuzz +# harnesses in `fuzz/`. A Cargo feature (not `#[cfg(fuzzing)]`) so it is a known +# cfg and never trips the `unexpected_cfgs` lint under `-D warnings`. +fuzz = [] [[example]] name = "auto_strategy" diff --git a/packages/stack-auth/fuzz/Cargo.toml b/packages/stack-auth/fuzz/Cargo.toml index eb595537c..a7578638a 100644 --- a/packages/stack-auth/fuzz/Cargo.toml +++ b/packages/stack-auth/fuzz/Cargo.toml @@ -19,6 +19,8 @@ libfuzzer-sys = "0.4" [dependencies.stack-auth] path = ".." +# `fuzz` exposes the fuzz-only entry points (e.g. `Token::fuzz_decode_claims`). +features = ["fuzz"] [[bin]] name = "access_key_parse" @@ -27,5 +29,12 @@ test = false doc = false bench = false +[[bin]] +name = "jwt_decode" +path = "fuzz_targets/jwt_decode.rs" +test = false +doc = false +bench = false + [workspace] resolver = "2" diff --git a/packages/stack-auth/fuzz/corpus/jwt_decode/valid-jwt b/packages/stack-auth/fuzz/corpus/jwt_decode/valid-jwt new file mode 100644 index 000000000..dda13b77f --- /dev/null +++ b/packages/stack-auth/fuzz/corpus/jwt_decode/valid-jwt @@ -0,0 +1 @@ +eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJodHRwczovL2N0cy5leGFtcGxlLmNvbS8iLCJzdWIiOiJDU3x0ZXN0LXVzZXIiLCJhdWQiOiJ0ZXN0LWF1ZGllbmNlIiwiaWF0IjoxNzAwMDAwMDAwLCJleHAiOjE3MDAwMDM2MDAsIndvcmtzcGFjZSI6IlpWQVRLVzNWSE1GRzI3RFkiLCJzY29wZSI6IiJ9.c2lnbmF0dXJl \ No newline at end of file diff --git a/packages/stack-auth/fuzz/fuzz_targets/jwt_decode.rs b/packages/stack-auth/fuzz/fuzz_targets/jwt_decode.rs new file mode 100644 index 000000000..6bd595bc2 --- /dev/null +++ b/packages/stack-auth/fuzz/fuzz_targets/jwt_decode.rs @@ -0,0 +1,14 @@ +#![no_main] + +use libfuzzer_sys::fuzz_target; + +// Fuzz the JWT claims decode path on arbitrary UTF-8. stack-auth reads claims +// from tokens it already holds with signature validation disabled +// (`insecure_disable_signature_validation()`), so the decoder must never panic +// on a malformed token — only return `Err`. `Token::fuzz_decode_claims` is a +// `fuzz`-feature-gated entry point that runs the real decode and discards the +// claims. On native this exercises the `jsonwebtoken` path; the hand-rolled +// wasm base64/JSON decoder needs a wasm build (its `base64` dep is wasm-only). +fuzz_target!(|s: &str| { + let _ = stack_auth::Token::fuzz_decode_claims(s); +}); diff --git a/packages/stack-auth/src/token.rs b/packages/stack-auth/src/token.rs index a8acfcd74..1160d04ac 100644 --- a/packages/stack-auth/src/token.rs +++ b/packages/stack-auth/src/token.rs @@ -211,6 +211,26 @@ impl Token { crate::decode_jwt_payload_wasm(self.access_token.as_str()) } + /// Fuzz-only entry point: run the JWT claims decode ([`Token::decode_claims`]) + /// over an arbitrary string, discarding the claims and keeping only whether it + /// succeeded. Gated on the `fuzz` feature so it never appears in normal builds. + /// Reading claims from a token we already hold must never panic on a malformed + /// token — only return `Err`. See `packages/stack-auth/fuzz`. + #[cfg(feature = "fuzz")] + pub fn fuzz_decode_claims(token: &str) -> Result<(), AuthError> { + Token { + access_token: SecretToken::new(token), + token_type: String::new(), + expires_at: 0, + refresh_token: None, + region: None, + client_id: None, + device_instance_id: None, + } + .decode_claims() + .map(|_| ()) + } + /// Exchange a refresh token for a new [`Token`] via the `/oauth/token` /// endpoint. /// diff --git a/packages/stack-auth/tasks.toml b/packages/stack-auth/tasks.toml index cbb5a807c..a80924b23 100644 --- a/packages/stack-auth/tasks.toml +++ b/packages/stack-auth/tasks.toml @@ -40,3 +40,11 @@ run = [ description = "Fuzz stack-auth's AccessKey string parser (libFuzzer, nightly, 60s default)" dir = "{{config_root}}/packages/stack-auth" run = "cargo +nightly fuzz run access_key_parse --sanitizer none --target $(rustc -vV | sed -n 's/^host: //p') -- -max_total_time=60" + +# Fuzz the JWT claims decode path (Token::fuzz_decode_claims, behind the `fuzz` +# feature). On native this is the jsonwebtoken-based decode with signature +# validation disabled. Same flags/rationale as fuzz:access-key. +["fuzz:jwt-decode"] +description = "Fuzz stack-auth's JWT claims decode path (libFuzzer, nightly, 60s default)" +dir = "{{config_root}}/packages/stack-auth" +run = "cargo +nightly fuzz run jwt_decode --sanitizer none --target $(rustc -vV | sed -n 's/^host: //p') -- -max_total_time=60" From 260ba107a711ae4766056a8cf24bfc06bc0e603a Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Thu, 18 Jun 2026 22:11:21 +1000 Subject: [PATCH 294/686] docs(stack-auth): drop intra-doc link to private decode_claims The fuzz_decode_claims doc linked [`Token::decode_claims`], but that method is private while fuzz_decode_claims is public under --all-features, so `cargo doc --all-features` failed on rustdoc::private_intra_doc_links (-D warnings) in the test-stack-auth job. Make it a plain code span. --- packages/stack-auth/src/token.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/stack-auth/src/token.rs b/packages/stack-auth/src/token.rs index 1160d04ac..d28aebb99 100644 --- a/packages/stack-auth/src/token.rs +++ b/packages/stack-auth/src/token.rs @@ -211,7 +211,7 @@ impl Token { crate::decode_jwt_payload_wasm(self.access_token.as_str()) } - /// Fuzz-only entry point: run the JWT claims decode ([`Token::decode_claims`]) + /// Fuzz-only entry point: run the JWT claims decode (`Token::decode_claims`) /// over an arbitrary string, discarding the claims and keeping only whether it /// succeeded. Gated on the `fuzz` feature so it never appears in normal builds. /// Reading claims from a token we already hold must never panic on a malformed From 1421b61e2fb46ce7855d0f224188252b03ad3e2d Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Fri, 19 Jun 2026 19:44:32 +1000 Subject: [PATCH 295/686] docs(fuzz): add fuzzing walkthrough; reference Trail of Bits cargo-fuzz skill MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to cipherstash/cipherstash-suite#2047 (Toby's review), stacked on its branch so it can be merged in before cipherstash/cipherstash-suite#2047: document the cargo-fuzz/libFuzzer setup — targets (incl. the jwt_decode path cipherstash/cipherstash-suite#2047 adds), local runs, the CI regression/campaign split, the fuzz-only feature-gated entry point pattern, and how to add a target — in docs/fuzzing.md, with a short pointer from CLAUDE.md. Rather than maintain a repo-local fuzzing skill, reference the existing Trail of Bits cargo-fuzz skill for the tool mechanics. --- docs/fuzzing.md | 149 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 149 insertions(+) create mode 100644 docs/fuzzing.md diff --git a/docs/fuzzing.md b/docs/fuzzing.md new file mode 100644 index 000000000..e0955490b --- /dev/null +++ b/docs/fuzzing.md @@ -0,0 +1,149 @@ +# Fuzzing (cargo-fuzz / libFuzzer) + +How the repo fuzzes its public, untrusted-input parsers, how to run a +target locally, and how to add a new one. The short-form recipe lives in +[`CLAUDE.md`](../CLAUDE.md); this is the longer explanation. + +For background on cargo-fuzz itself — sanitizers, corpus management, +structure-aware fuzzing with `arbitrary`, triaging crashes — use the +[`cargo-fuzz` skill](https://github.com/trailofbits/skills) (Trail of +Bits). We deliberately do **not** maintain our own fuzzing skill; that +skill is the reference, and this doc only covers what's repo-specific. + +## What we fuzz and why + +We fuzz the parsers that turn **untrusted caller-supplied strings** into +domain types. The invariant under test is always the same: parsing +arbitrary input must **never panic** — malformed input must return an +`Err`, not crash the process. + +Current targets: + +| mise task | crate | target binary | parses | +|----------------------|---------------|-----------------------|-----------------------------------------------| +| `fuzz:crn` | `cts-common` | `crn_parse` | `Crn` (e.g. `crn:ca-central-1.aws:ZVAT…`) | +| `fuzz:workspace-id` | `cts-common` | `workspace_id_parse` | `WorkspaceId` | +| `fuzz:region` | `cts-common` | `region_parse` | `Region` | +| `fuzz:access-key` | `stack-auth` | `access_key_parse` | `AccessKey` (`CSAK.`) | +| `fuzz:jwt-decode` | `stack-auth` | `jwt_decode` | JWT claims (`Token::fuzz_decode_claims`) | + +Each target is a few lines — `libfuzzer-sys` hands a `&str` to the +parser via the `arbitrary` crate: + +```rust +#![no_main] +use libfuzzer_sys::fuzz_target; + +fuzz_target!(|s: &str| { + let _ = s.parse::(); +}); +``` + +When the code under test isn't a public `FromStr` — e.g. the JWT claims +decoder, whose entry points are `pub(crate)` — we don't widen the real +API. Instead the crate exposes a thin **fuzz-only** entry point behind a +`fuzz` Cargo feature (`Token::fuzz_decode_claims`), and the fuzz crate +enables that feature on its dependency (`features = ["fuzz"]` in +`fuzz/Cargo.toml`). Using a Cargo *feature* rather than `#[cfg(fuzzing)]` +keeps `fuzz` a known cfg, so it never trips the `unexpected_cfgs` lint +under CI's `-D warnings`. + +## Layout + +Each fuzzed crate has a `fuzz/` subdirectory that is a **detached +workspace** — its `Cargo.toml` ends with an empty `[workspace]` table so +the `libfuzzer-sys` dependency and the nightly-only build never touch the +main monorepo workspace, and it is **not** a member of the root +`Cargo.toml`: + +``` +packages/cts-common/fuzz/ + Cargo.toml # detached workspace, cargo-fuzz = true + fuzz_targets/*.rs # one file per target binary + corpus//* # committed seed inputs (valid examples) +packages/stack-auth/fuzz/ + … +``` + +The committed `corpus//` seeds are valid examples of each format. +They give the fuzzer (and the CI regression replay) a starting point, and +the scheduled campaign grows the corpus from there. + +## Running locally + +cargo-fuzz needs the **nightly** toolchain and the `cargo-fuzz` binary; +`mise` provides the latter (`cargo:cargo-fuzz` in `mise.toml`). Install +nightly once with `rustup toolchain install nightly`. + +Run a target via its `mise` task (60s by default): + +```bash +mise run fuzz:crn +``` + +Override the duration by appending another libFuzzer flag — the last +value of a repeated flag wins: + +```bash +mise run fuzz:crn -- -max_total_time=300 +``` + +Replay only the committed seed corpus without fuzzing (what CI's +regression job does): + +```bash +mise run fuzz:crn -- -runs=0 +``` + +The tasks pin `--sanitizer none` (these parsers are pure safe Rust, so +ASan buys nothing and roughly doubles throughput) and +`--target $(rustc … host)` (the cargo-fuzz binary can be an x86_64 build +under Rosetta on Apple Silicon, which otherwise misdetects the target and +fails to find `std`). + +A crash drops a reproducer into `fuzz/artifacts//`; re-run that +single input with `cargo +nightly fuzz run `. + +## CI ([`.github/workflows/fuzz.yml`](../.github/workflows/fuzz.yml)) + +Two jobs with deliberately different roles: + +- **fuzz-regression** (`pull_request`, **blocking**): builds every + harness — which catches harness/API drift, e.g. a changed `FromStr` + signature — and replays the committed seed corpus with `-runs=0`. This + is deterministic (no fuzzing), so it's safe to gate PRs: it fails only + if a harness stops compiling or a committed corpus input crashes. + +- **fuzz-campaign** (`schedule` nightly + `workflow_dispatch`, + **non-blocking**): the actual time-boxed bug-finding run. A timed fuzz + run is nondeterministic, so it must not gate PRs. The corpus is + persisted across runs via `actions/cache` (write-once key + prefix + `restore-keys`) so coverage compounds, minimized with `cargo fuzz cmin` + to stay small, and any crash reproducer is uploaded as an artifact. + +The `pull_request` trigger is path-filtered to `packages/cts-common/**`, +`packages/stack-auth/**`, and the workflow file, with `!**.md` / +`!**.example` excludes last so docs-only changes are skipped. + +## Adding a new target + +1. Pick the crate whose parser you're fuzzing and add a target file under + `packages//fuzz/fuzz_targets/.rs` (copy an existing one). +2. Register it as a `[[bin]]` in that crate's `fuzz/Cargo.toml`. +3. Commit at least one valid seed under `fuzz/corpus//`. +4. Add a `fuzz:` task in the crate's `tasks.toml` mirroring the + existing ones (nightly, `--sanitizer none`, host `--target`). +5. Add the target to **both** matrices in `fuzz.yml` — the regression + `include` (task + slug) and the campaign `include` (task, slug, dir, + target). + +If the entry point is `pub(crate)`, add a `fuzz`-feature-gated shim +rather than widening the public API (see `Token::fuzz_decode_claims` +above), and enable that feature on the dependency in `fuzz/Cargo.toml`. + +If the parser can only build for a non-native target, note it as future +work rather than wiring a native target — cargo doesn't gate +`[target.'cfg(…)']` deps on `--cfg fuzzing`, so it needs a wasm-target +build. `fuzz:jwt-decode` covers the native `jsonwebtoken` decode path; +the hand-rolled wasm base64/JSON decoder (whose `base64` dep is +wasm32-only) is not yet fuzzed for this reason. From 2b67554d32269d03bf5a2794c1f7ac6bb7d7e254 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Fri, 19 Jun 2026 20:00:57 +1000 Subject: [PATCH 296/686] docs(stack-auth): hide fuzz_decode_claims from generated docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The doc:stack-auth task builds with --all-features, enabling the fuzz feature, so the fuzz-only Token::fuzz_decode_claims shim was leaking into the public API docs. Mark it #[doc(hidden)] — still callable from the fuzz harness, just not documented. (Copilot review on cipherstash/cipherstash-suite#2047.) --- packages/stack-auth/src/token.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/stack-auth/src/token.rs b/packages/stack-auth/src/token.rs index d28aebb99..3414cad6c 100644 --- a/packages/stack-auth/src/token.rs +++ b/packages/stack-auth/src/token.rs @@ -216,7 +216,11 @@ impl Token { /// succeeded. Gated on the `fuzz` feature so it never appears in normal builds. /// Reading claims from a token we already hold must never panic on a malformed /// token — only return `Err`. See `packages/stack-auth/fuzz`. + /// + /// `#[doc(hidden)]`: the `doc:stack-auth` task builds with `--all-features`, + /// which enables `fuzz` — this keeps the shim out of the generated public docs. #[cfg(feature = "fuzz")] + #[doc(hidden)] pub fn fuzz_decode_claims(token: &str) -> Result<(), AuthError> { Token { access_token: SecretToken::new(token), From 14813523d0fd8ab9a93bc3bfb4170947f4ea80fd Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 19 Jun 2026 13:44:43 +0000 Subject: [PATCH 297/686] chore(deps-dev): bump vite in /packages/stack-profile/node Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 7.3.2 to 7.3.5. - [Release notes](https://github.com/vitejs/vite/releases) - [Changelog](https://github.com/vitejs/vite/blob/v7.3.5/packages/vite/CHANGELOG.md) - [Commits](https://github.com/vitejs/vite/commits/v7.3.5/packages/vite) --- updated-dependencies: - dependency-name: vite dependency-version: 7.3.5 dependency-type: indirect ... Signed-off-by: dependabot[bot] --- .../packages/profile/package-lock.json | 24 +++---------------- 1 file changed, 3 insertions(+), 21 deletions(-) diff --git a/languages/typescript/packages/profile/package-lock.json b/languages/typescript/packages/profile/package-lock.json index 779bfdeea..947055049 100644 --- a/languages/typescript/packages/profile/package-lock.json +++ b/languages/typescript/packages/profile/package-lock.json @@ -21,24 +21,6 @@ "@cipherstash/profile-win32-x64-msvc": "0.35.0" } }, - "node_modules/@cipherstash/profile-darwin-arm64": { - "optional": true - }, - "node_modules/@cipherstash/profile-darwin-x64": { - "optional": true - }, - "node_modules/@cipherstash/profile-linux-arm64-gnu": { - "optional": true - }, - "node_modules/@cipherstash/profile-linux-x64-gnu": { - "optional": true - }, - "node_modules/@cipherstash/profile-linux-x64-musl": { - "optional": true - }, - "node_modules/@cipherstash/profile-win32-x64-msvc": { - "optional": true - }, "node_modules/@esbuild/aix-ppc64": { "version": "0.27.7", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", @@ -1453,9 +1435,9 @@ } }, "node_modules/vite": { - "version": "7.3.2", - "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.2.tgz", - "integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==", + "version": "7.3.5", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.5.tgz", + "integrity": "sha512-KuOaNhcnGFN2zIPGA7wRmzF+lJA1sea7rHq17aiJ++9lzY1WWG6Jpwqwe1KNbRVPIqHmr8GLYx7jbrQcN/7/ww==", "dev": true, "license": "MIT", "dependencies": { From ac93545a20c4721dad2c86544c75e221544bbcbd Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Sat, 20 Jun 2026 11:06:14 +1000 Subject: [PATCH 298/686] feat(stack-auth): add baseUrl override to OidcFederationStrategy (CIP-3246) Expose the existing builder `base_url()` as a public, per-strategy CTS host override, threaded through the napi and wasm bindings and the wasm-inline options object. Resolution order is `baseUrl` -> `CS_CTS_HOST` -> region service discovery. This lets a single strategy target a mock or self-hosted CTS without the process-wide `CS_CTS_HOST` env var, which also redirects other CTS clients in the process (e.g. a protect-ffi encryption client). In the wasm runtime, where `CS_CTS_HOST` isn't readable, it's the only way to target a non-discovered host. Builds on the CRN-based API (cipherstash/cipherstash-suite#2049). --- .../typescript/packages/auth/CHANGELOG.md | 19 +++ languages/typescript/packages/auth/Cargo.toml | 4 +- languages/typescript/packages/auth/index.d.ts | 129 +++--------------- languages/typescript/packages/auth/index.js | 7 +- languages/typescript/packages/auth/src/lib.rs | 39 +++++- .../typescript/packages/auth/wasm-inline.d.ts | 7 + .../typescript/packages/auth/wasm-inline.mjs | 4 +- .../typescript/packages/auth/wasm-types.d.ts | 9 ++ .../packages/stack-auth-wasm/Cargo.toml | 4 + .../packages/stack-auth-wasm/src/lib.rs | 40 +++++- .../src/oidc_federation_strategy.rs | 16 ++- 11 files changed, 148 insertions(+), 130 deletions(-) diff --git a/languages/typescript/packages/auth/CHANGELOG.md b/languages/typescript/packages/auth/CHANGELOG.md index 251234617..1aebe0150 100644 --- a/languages/typescript/packages/auth/CHANGELOG.md +++ b/languages/typescript/packages/auth/CHANGELOG.md @@ -30,6 +30,25 @@ ); ``` +- **`OidcFederationStrategy` `baseUrl` override** — both `create` and + `createWithStore` now accept an optional trailing `baseUrl` that pins a single + strategy instance to a specific CTS host, taking precedence over the + `CS_CTS_HOST` environment variable and region service discovery. + + ```ts + OidcFederationStrategy.createWithStore( + workspaceCrn, getJwt, loadToken, saveToken, + "http://localhost:4000", // baseUrl — federate against a mock / self-hosted CTS + ); + ``` + + Unlike `CS_CTS_HOST`, the override is scoped to that strategy alone, so it + doesn't redirect other CTS clients sharing the process (e.g. a `protect-ffi` + encryption client). On `wasm-inline` it's the `baseUrl` field of the options + object (`{ store?, baseUrl? }`); in the wasm runtime — which can't read + `CS_CTS_HOST` from the environment — it's the only way to target a host other + than the region-discovered one. + ### Breaking Changes - **`AccessKeyStrategy.create(workspaceCrn, accessKey)`** — the first argument diff --git a/languages/typescript/packages/auth/Cargo.toml b/languages/typescript/packages/auth/Cargo.toml index 65769a285..cd1cce51e 100644 --- a/languages/typescript/packages/auth/Cargo.toml +++ b/languages/typescript/packages/auth/Cargo.toml @@ -16,7 +16,7 @@ napi = { version = "2", features = ["async", "tokio_rt"] } napi-derive = "2" serde_json = "1" zeroize = { workspace = true } -url = { version = "2", optional = true } +url = { version = "2" } mocktail = { version = "0.3.0", optional = true } jsonwebtoken = { workspace = true, optional = true } reqwest = { workspace = true, optional = true } @@ -34,4 +34,4 @@ url = "2" napi-build = "2" [features] -test-utils = ["stack-auth/test-utils", "dep:url", "dep:mocktail", "dep:jsonwebtoken", "dep:reqwest"] +test-utils = ["stack-auth/test-utils", "dep:mocktail", "dep:jsonwebtoken", "dep:reqwest"] diff --git a/languages/typescript/packages/auth/index.d.ts b/languages/typescript/packages/auth/index.d.ts index 5e341cbac..8bbc305c9 100644 --- a/languages/typescript/packages/auth/index.d.ts +++ b/languages/typescript/packages/auth/index.d.ts @@ -1,32 +1,7 @@ /* tslint:disable */ /* eslint-disable */ -/* auto-generated by NAPI-RS, with manual additions for error enrichment */ - -/** Error codes attached to errors thrown by this package. */ -export type AuthErrorCode = - | 'REQUEST_ERROR' - | 'ACCESS_DENIED' - | 'EXPIRED_TOKEN' - | 'INVALID_GRANT' - | 'INVALID_CLIENT' - | 'INVALID_URL' - | 'INVALID_REGION' - | 'INVALID_TOKEN' - | 'SERVER_ERROR' - | 'STORE_ERROR' - | 'NOT_AUTHENTICATED' - | 'MISSING_WORKSPACE_CRN' - | 'INVALID_ACCESS_KEY' - | 'INVALID_CRN' - | 'WORKSPACE_MISMATCH' - | 'INVALID_WORKSPACE_ID' - | 'UNKNOWN_ERROR' - -/** An error thrown by this package, enriched with a machine-readable `.code`. */ -export interface AuthError extends Error { - code: AuthErrorCode -} +/* auto-generated by NAPI-RS */ /** * The result of a successful `getToken()` call. @@ -77,69 +52,6 @@ export interface AuthResult { export declare function bindClientDevice(): Promise /** Begin the OAuth 2.0 Device Authorization flow. */ export declare function beginDeviceCodeFlow(region: string, clientId: string): Promise -/** - * Variant of `beginDeviceCodeFlow` that targets a custom auth server URL. - * - * Intended for **testing only** — requires the crate to be built with the - * `test-utils` Cargo feature. - */ -export declare function beginDeviceCodeFlowWithBaseUrl(region: string, clientId: string, baseUrl: string): Promise -/** - * Variant of `provisionDeviceClient` that uses a custom profile directory. - * - * Intended for **testing only** — requires the crate to be built with the - * `test-utils` Cargo feature. - */ -export declare function bindClientDeviceWithProfileDir(profileDir: string): Promise -/** - * Save a test auth token to the given profile directory with the ZeroKMS - * service URL set to `zerokms_base_url`. - * - * Intended for **testing only** — requires the crate to be built with the - * `test-utils` Cargo feature. - */ -export declare function saveTestToken(profileDir: string, zerokmsBaseUrl: string): void -export declare class MockAuthServer { - /** Start a mock auth server on a random port. */ - static start(): Promise - /** The base URL of the running mock server (e.g. `http://127.0.0.1:12345`). */ - get baseUrl(): string - /** - * Register a mock for `POST /oauth/device/code` that returns a standard - * device-code JSON response. - */ - mockDeviceCodeEndpoint(): void - /** - * Register a mock for `POST /oauth/device/token` that returns a standard - * token JSON response. - */ - mockTokenEndpoint(): void - /** - * Register a mock for `POST /oauth/device/token` that returns a 400 error - * with the given OAuth error code and optional description. - */ - mockTokenEndpointError(code: string, description?: string | undefined | null): void - /** - * Register a mock for `POST /api/authorise` that returns a successful - * federation response (`{ accessToken, expiry }`), as CTS would for an - * `OidcFederationStrategy` JWT exchange. - * - * `expiry` (seconds until the CTS token expires) defaults to 3600. Pass a - * small value to exercise re-federation on expiry. - */ - mockAuthorizeEndpoint(expiry?: number | undefined | null): void - /** Register a mock for `POST /api/authorise` that returns a 500 error. */ - mockAuthorizeEndpointError(): void - /** - * Register a mock for `POST /create-client` that returns a successful - * create-client JSON response (as ZeroKMS would). - */ - mockCreateClientEndpoint(): void - /** Register a mock for `POST /create-client` that returns a 409 conflict. */ - mockCreateClientConflict(): void - /** Remove all registered mocks. */ - clearMocks(): void -} /** * An auth strategy that auto-detects credentials from environment variables * and the local profile store. @@ -162,14 +74,7 @@ export declare class AutoStrategy { } /** * An auth strategy that uses a static access key for service-to-service - * or CI/CD authentication, scoped to a single workspace identified by a - * CRN. Region is derived from the CRN — there is no separate region - * argument. - * - * Every issued token's `workspace` JWT claim is verified against the - * CRN. A mismatch fails the `getToken()` call with a - * `WORKSPACE_MISMATCH` error rather than silently letting a - * multi-workspace access key operate against the wrong workspace. + * or CI/CD authentication. */ export declare class AccessKeyStrategy { /** @@ -177,10 +82,10 @@ export declare class AccessKeyStrategy { * access key. * * The CRN format is `crn::` (e.g. - * `"crn:ap-southeast-2.aws:ZVATKW3VHMFG27DY"`). Region is parsed from - * the CRN and used for service discovery; the workspace ID is used to - * verify every issued token belongs to the right workspace. A mismatch - * fails `getToken()` with `code === "WORKSPACE_MISMATCH"`. + * `"crn:ap-southeast-2.aws:ZVATKW3VHMFG27DY"`). Region is parsed + * from the CRN and used for service discovery; the workspace ID is + * used to verify every issued token belongs to the right workspace. + * A mismatch fails `getToken()` with `code === "WORKSPACE_MISMATCH"`. */ static create(workspaceCrn: string, accessKey: string): AccessKeyStrategy /** Retrieve a valid access token, refreshing or re-authenticating as needed. */ @@ -196,14 +101,6 @@ export declare class DeviceSessionStrategy { /** Retrieve a valid access token, refreshing as needed. */ getToken(): Promise } -/** - * Deprecated alias for {@link DeviceSessionStrategy}, exported at runtime as - * `module.exports.OAuthStrategy = DeviceSessionStrategy`. Kept so existing - * consumers don't break; will be removed in a future major release. - * - * @deprecated Renamed to `DeviceSessionStrategy`. - */ -export declare const OAuthStrategy: typeof DeviceSessionStrategy /** * An auth strategy that federates a third-party OIDC JWT (Clerk, Supabase, …) * into a CipherStash CTS service token via `/api/authorise`. @@ -220,8 +117,14 @@ export declare class OidcFederationStrategy { * `getJwt` is called on every federation — initial auth and every * re-federation after the CTS token expires — and must return * `Promise` resolving to the *current* third-party OIDC JWT. + * + * `baseUrl`, when supplied, pins this strategy to a specific CTS host — + * e.g. a self-hosted CTS or a local mock auth server. It takes precedence + * over the `CS_CTS_HOST` environment variable and region service + * discovery, and is scoped to this strategy alone (unlike `CS_CTS_HOST`, + * which redirects every CTS client in the process). */ - static create(workspaceCrn: string, getJwt: () => any): OidcFederationStrategy + static create(workspaceCrn: string, getJwt: () => any, baseUrl?: string | undefined | null): OidcFederationStrategy /** * Create an `OidcFederationStrategy` backed by external token-store callbacks. * @@ -230,8 +133,12 @@ export declare class OidcFederationStrategy { * and `saveToken` (`(json: string) => Promise`) — e.g. an HTTP-only * cookie — so a federated token survives across requests without * re-federating. + * + * `baseUrl` behaves as in [`create`](Self::create) — an explicit, + * strategy-scoped CTS host that overrides `CS_CTS_HOST` and service + * discovery. */ - static createWithStore(workspaceCrn: string, getJwt: () => any, loadToken: () => any, saveToken: (arg: string) => any): OidcFederationStrategy + static createWithStore(workspaceCrn: string, getJwt: () => any, loadToken: () => any, saveToken: (arg: string) => any, baseUrl?: string | undefined | null): OidcFederationStrategy /** Retrieve a valid CTS service token, federating or re-federating as needed. */ getToken(): Promise } diff --git a/languages/typescript/packages/auth/index.js b/languages/typescript/packages/auth/index.js index fc0686ffd..583721a2b 100644 --- a/languages/typescript/packages/auth/index.js +++ b/languages/typescript/packages/auth/index.js @@ -75,17 +75,17 @@ native.DeviceSessionStrategy.fromProfile = wrapSync(origFromProfile); // `getToken()` is already enriched via the prototype patch above. const NativeOidcFederationStrategy = native.OidcFederationStrategy; class OidcFederationStrategy { - static create(workspaceCrn, getJwt) { + static create(workspaceCrn, getJwt, baseUrl) { // Wrap `getJwt` so the napi binding always sees a Promise-returning // function even if the caller passed a sync one — the native side coerces // the return to `Promise`. Matches the wasm wrapper (wasm-inline.mjs). const jwt = () => Promise.resolve(getJwt()); return wrapSync(() => - NativeOidcFederationStrategy.create(workspaceCrn, jwt), + NativeOidcFederationStrategy.create(workspaceCrn, jwt, baseUrl), )(); } - static createWithStore(workspaceCrn, getJwt, loadToken, saveToken) { + static createWithStore(workspaceCrn, getJwt, loadToken, saveToken, baseUrl) { // Same defensive wrap for all three callbacks, so sync implementations // (e.g. an in-memory store) work without the caller pre-wrapping them. const jwt = () => Promise.resolve(getJwt()); @@ -97,6 +97,7 @@ class OidcFederationStrategy { jwt, load, save, + baseUrl, ), )(); } diff --git a/languages/typescript/packages/auth/src/lib.rs b/languages/typescript/packages/auth/src/lib.rs index 37e81b548..327bfc2f0 100644 --- a/languages/typescript/packages/auth/src/lib.rs +++ b/languages/typescript/packages/auth/src/lib.rs @@ -43,6 +43,19 @@ fn parse_workspace_crn(workspace_crn: &str) -> Result { .map_err(|e| to_napi_error(AuthError::InvalidCrn(e))) } +/// Parse an optional `baseUrl` override into a [`url::Url`]. An absent or empty +/// string yields `None` (fall back to `CS_CTS_HOST` / service discovery); an +/// invalid URL maps to the `INVALID_URL` error code. +fn parse_base_url(base_url: Option) -> Result> { + match base_url { + Some(s) if !s.is_empty() => Ok(Some( + s.parse::() + .map_err(|e| to_napi_error(AuthError::from(e)))?, + )), + _ => Ok(None), + } +} + // --------------------------------------------------------------------------- // TokenResult — returned by strategy.getToken() // --------------------------------------------------------------------------- @@ -334,15 +347,24 @@ impl OidcFederationStrategy { /// `getJwt` is called on every federation — initial auth and every /// re-federation after the CTS token expires — and must return /// `Promise` resolving to the *current* third-party OIDC JWT. + /// + /// `baseUrl`, when supplied, pins this strategy to a specific CTS host — + /// e.g. a self-hosted CTS or a local mock auth server. It takes precedence + /// over the `CS_CTS_HOST` environment variable and region service + /// discovery, and is scoped to this strategy alone (unlike `CS_CTS_HOST`, + /// which redirects every CTS client in the process). #[napi(factory)] pub fn create( workspace_crn: String, get_jwt: ThreadsafeFunction<(), ErrorStrategy::Fatal>, + base_url: Option, ) -> Result { let crn = parse_workspace_crn(&workspace_crn)?; - let inner = stack_auth::OidcFederationStrategy::builder(crn, NapiOidcProvider { get_jwt }) - .build() - .map_err(to_napi_error)?; + let mut builder = stack_auth::OidcFederationStrategy::builder(crn, NapiOidcProvider { get_jwt }); + if let Some(url) = parse_base_url(base_url)? { + builder = builder.base_url(url); + } + let inner = builder.build().map_err(to_napi_error)?; Ok(Self { inner: OidcFederationStrategyInner::NoStore(inner), }) @@ -355,19 +377,28 @@ impl OidcFederationStrategy { /// and `saveToken` (`(json: string) => Promise`) — e.g. an HTTP-only /// cookie — so a federated token survives across requests without /// re-federating. + /// + /// `baseUrl` behaves as in [`create`](Self::create) — an explicit, + /// strategy-scoped CTS host that overrides `CS_CTS_HOST` and service + /// discovery. #[napi(factory)] pub fn create_with_store( workspace_crn: String, get_jwt: ThreadsafeFunction<(), ErrorStrategy::Fatal>, load_token: ThreadsafeFunction<(), ErrorStrategy::Fatal>, save_token: ThreadsafeFunction, + base_url: Option, ) -> Result { let crn = parse_workspace_crn(&workspace_crn)?; let store = NapiTokenStore { load: load_token, save: save_token, }; - let inner = stack_auth::OidcFederationStrategy::builder(crn, NapiOidcProvider { get_jwt }) + let mut builder = stack_auth::OidcFederationStrategy::builder(crn, NapiOidcProvider { get_jwt }); + if let Some(url) = parse_base_url(base_url)? { + builder = builder.base_url(url); + } + let inner = builder .with_token_store(store) .build() .map_err(to_napi_error)?; diff --git a/languages/typescript/packages/auth/wasm-inline.d.ts b/languages/typescript/packages/auth/wasm-inline.d.ts index bd96861ba..bcf7b8c71 100644 --- a/languages/typescript/packages/auth/wasm-inline.d.ts +++ b/languages/typescript/packages/auth/wasm-inline.d.ts @@ -94,6 +94,13 @@ export interface OidcFederationStrategyOptions { * {@link AccessKeyStrategyOptions.store}. */ store?: TokenStore; + /** + * Pin this strategy to a specific CTS host — e.g. a self-hosted CTS or a + * local mock auth server — overriding region service discovery. Scoped to + * this strategy alone. In wasm there is no `CS_CTS_HOST` env fallback, so + * this is the only way to target a host other than the region-discovered one. + */ + baseUrl?: string; } /** diff --git a/languages/typescript/packages/auth/wasm-inline.mjs b/languages/typescript/packages/auth/wasm-inline.mjs index 2f06b31d0..e390f2b2f 100644 --- a/languages/typescript/packages/auth/wasm-inline.mjs +++ b/languages/typescript/packages/auth/wasm-inline.mjs @@ -85,6 +85,7 @@ export class OidcFederationStrategy { // `AccessKeyStrategy.create`. const jwt = () => Promise.resolve(getJwt()); const store = options?.store; + const baseUrl = options?.baseUrl; if (store) { const load = () => Promise.resolve(store.load()); const save = (/** @type {string} */ json) => @@ -95,11 +96,12 @@ export class OidcFederationStrategy { jwt, load, save, + baseUrl, ), ); } return new OidcFederationStrategy( - RawOidcFederationStrategy.create(workspaceCrn, jwt), + RawOidcFederationStrategy.create(workspaceCrn, jwt, baseUrl), ); } diff --git a/languages/typescript/packages/auth/wasm-types.d.ts b/languages/typescript/packages/auth/wasm-types.d.ts index 8a9326e3b..dd7db92da 100644 --- a/languages/typescript/packages/auth/wasm-types.d.ts +++ b/languages/typescript/packages/auth/wasm-types.d.ts @@ -106,22 +106,31 @@ export declare class OidcFederationStrategy { * Create an `OidcFederationStrategy` for the given workspace CRN. The CRN * format is `crn::` (e.g. * `"crn:ap-southeast-2.aws:ZVATKW3VHMFG27DY"`). + * + * `baseUrl`, when supplied, pins this strategy to a specific CTS host — + * e.g. a self-hosted CTS or a local mock auth server — overriding region + * service discovery, scoped to this strategy alone. */ static create( workspaceCrn: string, getJwt: () => Promise, + baseUrl?: string | undefined | null, ): OidcFederationStrategy /** * Create an `OidcFederationStrategy` backed by external token-store * callbacks. Takes the same `workspaceCrn` as {@link create} (region for * service discovery, workspace ID for verification) plus `loadToken` / * `saveToken` to persist the federated CTS token across requests. + * + * `baseUrl` behaves as in {@link create} — an explicit, strategy-scoped CTS + * host that overrides region service discovery. */ static createWithStore( workspaceCrn: string, getJwt: () => Promise, loadToken: () => Promise, saveToken: (json: string) => Promise, + baseUrl?: string | undefined | null, ): OidcFederationStrategy /** Retrieve a valid CTS service token, federating or re-federating as needed. */ getToken(): Promise diff --git a/languages/typescript/packages/stack-auth-wasm/Cargo.toml b/languages/typescript/packages/stack-auth-wasm/Cargo.toml index 6c741900c..a0ca37548 100644 --- a/languages/typescript/packages/stack-auth-wasm/Cargo.toml +++ b/languages/typescript/packages/stack-auth-wasm/Cargo.toml @@ -36,6 +36,10 @@ web-sys = { version = "0.3", features = ["console"] } # behaviour of the upstream `CallbackTokenStore` on native. Target-gated for # the same reason as `web-sys`. zeroize = { workspace = true } +# Parses the optional `baseUrl` override in `OidcFederationStrategy` into a +# `url::Url`. Only used by the wasm32 strategy bindings, so target-gated to keep +# `cargo udeps --all-targets` from flagging it as unused on the host target. +url = "2" [target.'cfg(target_arch = "wasm32")'.dev-dependencies] wasm-bindgen-test = "0.3" diff --git a/languages/typescript/packages/stack-auth-wasm/src/lib.rs b/languages/typescript/packages/stack-auth-wasm/src/lib.rs index 632f0fe9c..5a6bd5ed3 100644 --- a/languages/typescript/packages/stack-auth-wasm/src/lib.rs +++ b/languages/typescript/packages/stack-auth-wasm/src/lib.rs @@ -214,6 +214,19 @@ fn parse_workspace_crn(workspace_crn: &str) -> Result .map_err(|e| to_js_error(AuthError::InvalidCrn(e))) } +/// Parse an optional `baseUrl` override into a [`url::Url`]. An absent or empty +/// string yields `None` (fall back to region service discovery); an invalid URL +/// maps to the `INVALID_URL` error code. +#[cfg(target_arch = "wasm32")] +fn parse_base_url(base_url: Option) -> Result, JsValue> { + match base_url { + Some(s) if !s.is_empty() => Ok(Some( + s.parse::().map_err(|e| to_js_error(AuthError::from(e)))?, + )), + _ => Ok(None), + } +} + enum AccessKeyStrategyInner { NoStore(stack_auth::AccessKeyStrategy), #[cfg(target_arch = "wasm32")] @@ -341,14 +354,24 @@ impl OidcFederationStrategy { /// re-federation after expiry — and must return `Promise` /// resolving to the *current* third-party OIDC JWT (e.g. by calling /// `clerk.session.getToken()`). + /// + /// `baseUrl`, when supplied, pins this strategy to a specific CTS host — + /// e.g. a self-hosted CTS or a local mock auth server. It overrides region + /// service discovery and is scoped to this strategy alone. In wasm there is + /// no `CS_CTS_HOST` env fallback (the sandbox can't read env), so `baseUrl` + /// is the only way to target a host other than the region-discovered one. pub fn create( workspace_crn: String, get_jwt: js_sys::Function, + base_url: Option, ) -> Result { let crn = parse_workspace_crn(&workspace_crn)?; - let inner = stack_auth::OidcFederationStrategy::builder(crn, JsOidcProvider { get_jwt }) - .build() - .map_err(to_js_error)?; + let mut builder = + stack_auth::OidcFederationStrategy::builder(crn, JsOidcProvider { get_jwt }); + if let Some(url) = parse_base_url(base_url)? { + builder = builder.base_url(url); + } + let inner = builder.build().map_err(to_js_error)?; Ok(OidcFederationStrategy { inner: OidcFederationStrategyInner::NoStore(inner), }) @@ -361,19 +384,28 @@ impl OidcFederationStrategy { /// [`AccessKeyStrategy::create_with_store`] for the callback contract. Use /// this to back the strategy with an HTTP-only cookie so a federated token /// survives across Edge Function invocations without re-federating. + /// + /// `baseUrl` behaves as in [`create`](Self::create) — an explicit, + /// strategy-scoped CTS host that overrides region service discovery. #[wasm_bindgen(js_name = createWithStore)] pub fn create_with_store( workspace_crn: String, get_jwt: js_sys::Function, load_token: js_sys::Function, save_token: js_sys::Function, + base_url: Option, ) -> Result { let crn = parse_workspace_crn(&workspace_crn)?; let store = JsTokenStore { load: load_token, save: save_token, }; - let inner = stack_auth::OidcFederationStrategy::builder(crn, JsOidcProvider { get_jwt }) + let mut builder = + stack_auth::OidcFederationStrategy::builder(crn, JsOidcProvider { get_jwt }); + if let Some(url) = parse_base_url(base_url)? { + builder = builder.base_url(url); + } + let inner = builder .with_token_store(store) .build() .map_err(to_js_error)?; diff --git a/packages/stack-auth/src/oidc_federation_strategy.rs b/packages/stack-auth/src/oidc_federation_strategy.rs index 707ceed32..b19912ab6 100644 --- a/packages/stack-auth/src/oidc_federation_strategy.rs +++ b/packages/stack-auth/src/oidc_federation_strategy.rs @@ -104,8 +104,12 @@ pub struct OidcFederationStrategyBuilder { impl OidcFederationStrategyBuilder { /// Override the base URL resolved by service discovery. /// - /// Useful for pointing at a local or mock auth server during testing. - #[cfg(any(test, feature = "test-utils"))] + /// Takes precedence over both the `CS_CTS_HOST` environment variable and + /// region-derived service discovery. Use this to point a single strategy + /// instance at a specific CTS host — e.g. a self-hosted CTS, or a local + /// mock auth server in development — without relying on the process-wide + /// `CS_CTS_HOST`, which would also redirect any other CTS client (e.g. the + /// `protect-ffi` encryption client) sharing the same process. pub fn base_url(mut self, url: url::Url) -> Self { self.base_url_override = Some(url); self @@ -136,9 +140,11 @@ impl OidcFederationStrategyBuilder { impl OidcFederationStrategyBuilder { /// Build the [`OidcFederationStrategy`]. /// - /// Resolves the base URL via service discovery using the CRN's region, - /// unless overridden with `base_url` (available when the `test-utils` - /// feature is enabled). + /// Resolves the base URL in priority order: an explicit [`base_url`] + /// override, then the `CS_CTS_HOST` environment variable, then service + /// discovery using the CRN's region. + /// + /// [`base_url`]: Self::base_url pub fn build(self) -> Result, AuthError> { let expected_workspace = self.workspace_crn.workspace_id; let region = self.workspace_crn.region; From c58758847ecc65d03fcc5aea0ca7e8436ee57754 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Sat, 20 Jun 2026 11:31:09 +1000 Subject: [PATCH 299/686] fix(stack-auth): format baseUrl bindings + cover baseUrl override in tests - cargo fmt the napi/wasm baseUrl builder code (test-unit fmt failure) - thread the new base_url arg into the existing wasm OidcFederationStrategy create/create_with_store test call sites (test-stack-auth build failure) - add wasm tests: valid baseUrl accepted, empty treated as absent, malformed maps to INVALID_URL (both create and createWithStore) - add napi parse_base_url unit tests covering the same none/empty/valid/invalid semantics (factories need a JS runtime; the helper is the testable seam) Addresses Copilot review feedback (CIP-3246) --- languages/typescript/packages/auth/src/lib.rs | 45 ++++++++++++++- .../packages/stack-auth-wasm/src/lib.rs | 56 ++++++++++++++++++- 2 files changed, 97 insertions(+), 4 deletions(-) diff --git a/languages/typescript/packages/auth/src/lib.rs b/languages/typescript/packages/auth/src/lib.rs index 327bfc2f0..0885085f0 100644 --- a/languages/typescript/packages/auth/src/lib.rs +++ b/languages/typescript/packages/auth/src/lib.rs @@ -360,7 +360,8 @@ impl OidcFederationStrategy { base_url: Option, ) -> Result { let crn = parse_workspace_crn(&workspace_crn)?; - let mut builder = stack_auth::OidcFederationStrategy::builder(crn, NapiOidcProvider { get_jwt }); + let mut builder = + stack_auth::OidcFederationStrategy::builder(crn, NapiOidcProvider { get_jwt }); if let Some(url) = parse_base_url(base_url)? { builder = builder.base_url(url); } @@ -394,7 +395,8 @@ impl OidcFederationStrategy { load: load_token, save: save_token, }; - let mut builder = stack_auth::OidcFederationStrategy::builder(crn, NapiOidcProvider { get_jwt }); + let mut builder = + stack_auth::OidcFederationStrategy::builder(crn, NapiOidcProvider { get_jwt }); if let Some(url) = parse_base_url(base_url)? { builder = builder.base_url(url); } @@ -815,6 +817,45 @@ mod tests { } } + // --- baseUrl override parsing --- + // + // The `OidcFederationStrategy::create{,_with_store}` factories take their + // `baseUrl` argument through `parse_base_url`. The factories themselves + // need a JS runtime (their callbacks are `ThreadsafeFunction`s), so the + // override semantics — empty/absent → `None`, valid → `Some`, malformed → + // `INVALID_URL` — are pinned here on the helper instead. + mod parse_base_url { + use super::*; + + #[test] + fn none_yields_no_override() { + assert!(super::super::parse_base_url(None).unwrap().is_none()); + } + + #[test] + fn empty_string_is_treated_as_absent() { + // An empty `baseUrl` must fall through to `CS_CTS_HOST` / service + // discovery rather than erroring — it's not a malformed URL. + assert!(super::super::parse_base_url(Some(String::new())) + .unwrap() + .is_none()); + } + + #[test] + fn valid_url_yields_override() { + let parsed = super::super::parse_base_url(Some("https://cts.example.com".to_string())) + .unwrap() + .expect("a valid URL should produce an override"); + assert_eq!(parsed.as_str(), "https://cts.example.com/"); + } + + #[test] + fn malformed_url_maps_to_invalid_url() { + let err = expect_err(super::super::parse_base_url(Some("not a url".to_string()))); + assertions::has_error_code(&err, "INVALID_URL"); + } + } + // --- Device code result --- // // `start_paused = true` creates a tokio runtime where the internal clock diff --git a/languages/typescript/packages/stack-auth-wasm/src/lib.rs b/languages/typescript/packages/stack-auth-wasm/src/lib.rs index 5a6bd5ed3..c60085c5a 100644 --- a/languages/typescript/packages/stack-auth-wasm/src/lib.rs +++ b/languages/typescript/packages/stack-auth-wasm/src/lib.rs @@ -221,7 +221,8 @@ fn parse_workspace_crn(workspace_crn: &str) -> Result fn parse_base_url(base_url: Option) -> Result, JsValue> { match base_url { Some(s) if !s.is_empty() => Ok(Some( - s.parse::().map_err(|e| to_js_error(AuthError::from(e)))?, + s.parse::() + .map_err(|e| to_js_error(AuthError::from(e)))?, )), _ => Ok(None), } @@ -670,6 +671,7 @@ mod tests { let err = expect_js_err(OidcFederationStrategy::create( "not-a-crn".to_string(), jwt_fn("h.p.s"), + None, )); assert_eq!(error_code_of(&err), "INVALID_CRN"); } @@ -684,16 +686,51 @@ mod tests { let err = expect_js_err(OidcFederationStrategy::create( "crn:ap-southeast-2.aws:not-a-valid-workspace".to_string(), jwt_fn("h.p.s"), + None, )); assert_eq!(error_code_of(&err), "INVALID_CRN"); } #[wasm_bindgen_test] fn oidc_federation_strategy_accepts_valid_inputs() { - let result = OidcFederationStrategy::create(VALID_CRN.to_string(), jwt_fn("h.p.s")); + let result = OidcFederationStrategy::create(VALID_CRN.to_string(), jwt_fn("h.p.s"), None); assert!(result.is_ok()); } + /// A supplied `baseUrl` override is accepted and threaded into the builder. + #[wasm_bindgen_test] + fn oidc_federation_strategy_accepts_valid_base_url() { + let result = OidcFederationStrategy::create( + VALID_CRN.to_string(), + jwt_fn("h.p.s"), + Some("https://cts.example.com".to_string()), + ); + assert!(result.is_ok()); + } + + /// An empty `baseUrl` string is treated as absent (falls back to region + /// discovery), not as an invalid URL. + #[wasm_bindgen_test] + fn oidc_federation_strategy_treats_empty_base_url_as_absent() { + let result = OidcFederationStrategy::create( + VALID_CRN.to_string(), + jwt_fn("h.p.s"), + Some(String::new()), + ); + assert!(result.is_ok()); + } + + /// A malformed `baseUrl` surfaces as `INVALID_URL`. + #[wasm_bindgen_test] + fn oidc_federation_strategy_rejects_invalid_base_url() { + let err = expect_js_err(OidcFederationStrategy::create( + VALID_CRN.to_string(), + jwt_fn("h.p.s"), + Some("not a url".to_string()), + )); + assert_eq!(error_code_of(&err), "INVALID_URL"); + } + #[wasm_bindgen_test] fn oidc_create_with_store_rejects_invalid_crn() { let err = expect_js_err(OidcFederationStrategy::create_with_store( @@ -701,6 +738,7 @@ mod tests { jwt_fn("h.p.s"), empty_load_fn(), noop_save_fn(), + None, )); assert_eq!(error_code_of(&err), "INVALID_CRN"); } @@ -712,10 +750,24 @@ mod tests { jwt_fn("h.p.s"), empty_load_fn(), noop_save_fn(), + None, ); assert!(result.is_ok()); } + /// The store variant also accepts and validates a `baseUrl` override. + #[wasm_bindgen_test] + fn oidc_create_with_store_rejects_invalid_base_url() { + let err = expect_js_err(OidcFederationStrategy::create_with_store( + VALID_CRN.to_string(), + jwt_fn("h.p.s"), + empty_load_fn(), + noop_save_fn(), + Some("not a url".to_string()), + )); + assert_eq!(error_code_of(&err), "INVALID_URL"); + } + #[wasm_bindgen_test] async fn js_oidc_provider_returns_jwt() { let provider = JsOidcProvider { From 6f23ce3bda1b9f8662666a787adb503d86c46455 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Sat, 20 Jun 2026 19:04:48 +1000 Subject: [PATCH 300/686] refactor(stack-auth): durable index.d.ts additions + shared base_url helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses two code-review findings on the OidcFederationStrategy baseUrl PR. 1. index.d.ts dropped public types (regression). \`napi build\` regenerates index.d.ts and strips the hand-curated \`AuthError\`/\`AuthErrorCode\` and \`OAuthStrategy\` declarations — public types consumers and our own examples import (runtime still exports them, so it's a type/runtime mismatch). The published .d.ts is the committed one, so the broken state would ship. - Restore the three curated declarations to index.d.ts. - Add scripts/apply-dts-additions.mjs: a durable post-\`napi build\` step that re-applies them AND strips the test-utils-only exports that \`--features test-utils\` emits, so release and test builds converge to the same canonical published surface. Idempotent; wired into build / build:debug / build:test. Verified end-to-end against a real napi build. 2. parse_base_url duplicated across crates. Hoist the empty/absent/valid/ malformed override semantics into one core builder method, OidcFederationStrategyBuilder::maybe_base_url(Option), unit-tested in stack-auth. Both bindings now call it, dropping their per-crate parse helpers and the 4 copies of the builder-threading block. With parse_base_url gone, \`url\` is no longer used in non-test binding code, so revert it to a test-utils-gated dep (node) / drop the direct dep (wasm). All Rust unit/wasm-pack/JS tests pass; clippy, cargo fmt, and biome clean. --- languages/typescript/packages/auth/Cargo.toml | 4 +- languages/typescript/packages/auth/index.d.ts | 42 +++- .../typescript/packages/auth/package.json | 6 +- .../auth/scripts/apply-dts-additions.mjs | 180 ++++++++++++++++++ languages/typescript/packages/auth/src/lib.rs | 77 ++------ .../packages/stack-auth-wasm/Cargo.toml | 4 - .../packages/stack-auth-wasm/src/lib.rs | 34 +--- .../src/oidc_federation_strategy.rs | 65 +++++++ 8 files changed, 313 insertions(+), 99 deletions(-) create mode 100644 languages/typescript/packages/auth/scripts/apply-dts-additions.mjs diff --git a/languages/typescript/packages/auth/Cargo.toml b/languages/typescript/packages/auth/Cargo.toml index cd1cce51e..65769a285 100644 --- a/languages/typescript/packages/auth/Cargo.toml +++ b/languages/typescript/packages/auth/Cargo.toml @@ -16,7 +16,7 @@ napi = { version = "2", features = ["async", "tokio_rt"] } napi-derive = "2" serde_json = "1" zeroize = { workspace = true } -url = { version = "2" } +url = { version = "2", optional = true } mocktail = { version = "0.3.0", optional = true } jsonwebtoken = { workspace = true, optional = true } reqwest = { workspace = true, optional = true } @@ -34,4 +34,4 @@ url = "2" napi-build = "2" [features] -test-utils = ["stack-auth/test-utils", "dep:mocktail", "dep:jsonwebtoken", "dep:reqwest"] +test-utils = ["stack-auth/test-utils", "dep:url", "dep:mocktail", "dep:jsonwebtoken", "dep:reqwest"] diff --git a/languages/typescript/packages/auth/index.d.ts b/languages/typescript/packages/auth/index.d.ts index 8bbc305c9..045c00e68 100644 --- a/languages/typescript/packages/auth/index.d.ts +++ b/languages/typescript/packages/auth/index.d.ts @@ -1,7 +1,7 @@ /* tslint:disable */ /* eslint-disable */ -/* auto-generated by NAPI-RS */ +/* auto-generated by NAPI-RS, with manual additions (scripts/apply-dts-additions.mjs) */ /** * The result of a successful `getToken()` call. @@ -163,3 +163,43 @@ export declare class DeviceCodeResult { */ openInBrowser(): boolean } + +// --- BEGIN manual additions (scripts/apply-dts-additions.mjs) --- +// +// Re-applied after every `napi build` by scripts/apply-dts-additions.mjs — +// NAPI-RS does not emit these. Edit them there, not here. + +/** Error codes attached to errors thrown by this package. */ +export type AuthErrorCode = + | 'REQUEST_ERROR' + | 'ACCESS_DENIED' + | 'EXPIRED_TOKEN' + | 'INVALID_GRANT' + | 'INVALID_CLIENT' + | 'INVALID_URL' + | 'INVALID_REGION' + | 'INVALID_TOKEN' + | 'SERVER_ERROR' + | 'STORE_ERROR' + | 'NOT_AUTHENTICATED' + | 'MISSING_WORKSPACE_CRN' + | 'INVALID_ACCESS_KEY' + | 'INVALID_CRN' + | 'WORKSPACE_MISMATCH' + | 'INVALID_WORKSPACE_ID' + | 'UNKNOWN_ERROR' + +/** An error thrown by this package, enriched with a machine-readable `.code`. */ +export interface AuthError extends Error { + code: AuthErrorCode +} + +/** + * Deprecated alias for {@link DeviceSessionStrategy}, exported at runtime as + * `module.exports.OAuthStrategy = DeviceSessionStrategy`. Kept so existing + * consumers don't break; will be removed in a future major release. + * + * @deprecated Renamed to `DeviceSessionStrategy`. + */ +export declare const OAuthStrategy: typeof DeviceSessionStrategy +// --- END manual additions --- diff --git a/languages/typescript/packages/auth/package.json b/languages/typescript/packages/auth/package.json index 0e63025d1..ca37b9246 100644 --- a/languages/typescript/packages/auth/package.json +++ b/languages/typescript/packages/auth/package.json @@ -55,9 +55,9 @@ "wasm/" ], "scripts": { - "build": "napi build --release", - "build:debug": "napi build", - "build:test": "napi build --features test-utils", + "build": "napi build --release && node scripts/apply-dts-additions.mjs", + "build:debug": "napi build && node scripts/apply-dts-additions.mjs", + "build:test": "napi build --features test-utils && node scripts/apply-dts-additions.mjs", "build:wasm": "cd ../wasm && wasm-pack build --target bundler --out-dir ../node/wasm && rm -f ../node/wasm/README.md ../node/wasm/LICENSE ../node/wasm/.gitignore && echo '{\"type\":\"module\"}' > ../node/wasm/package.json && node ../node/scripts/inline-wasm.mjs", "test": "npm run build:test && vitest run", "format": "npx --yes @biomejs/biome@2.3.4 format --write .", diff --git a/languages/typescript/packages/auth/scripts/apply-dts-additions.mjs b/languages/typescript/packages/auth/scripts/apply-dts-additions.mjs new file mode 100644 index 000000000..37a976161 --- /dev/null +++ b/languages/typescript/packages/auth/scripts/apply-dts-additions.mjs @@ -0,0 +1,180 @@ +#!/usr/bin/env node +// +// Re-apply the hand-curated additions to the NAPI-RS-generated `index.d.ts`, +// and normalise it to the canonical *published* type surface. +// +// `napi build` regenerates `index.d.ts` from scratch on every run. That has two +// consequences this script exists to undo: +// +// 1. It drops declarations NAPI-RS can't know about — the `AuthError` / +// `AuthErrorCode` error-enrichment types (errors are tagged with a `.code` +// at runtime by `index.js`) and the deprecated `OAuthStrategy` alias +// (exported at runtime as `module.exports.OAuthStrategy`). Without these, +// consumers doing `import type { AuthError } from "@cipherstash/auth"` +// stop compiling even though the runtime values still exist. +// +// 2. When built with `--features test-utils` (i.e. `npm run build:test`), it +// ALSO emits test-only exports (`MockAuthServer`, `beginDeviceCodeFlow- +// WithBaseUrl`, `bindClientDeviceWithProfileDir`, `saveTestToken`). Those +// are gated out of release builds, so they must not appear in the +// published `.d.ts`. Tests type them via `test-utils.d.ts` and inline +// intersection casts instead — never from `index.d.ts`. +// +// Running this after every build makes the committed/published `index.d.ts` +// deterministic regardless of which build mode produced it. It is idempotent: +// safe to run repeatedly, a no-op on an already-normalised file. +// +// Wired into the `build`, `build:debug`, and `build:test` npm scripts. + +import { readFileSync, writeFileSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const packageRoot = join(dirname(fileURLToPath(import.meta.url)), ".."); +// Optional path arg (used by tests); `resolve` honours both absolute and +// cwd-relative inputs. Defaults to the package's own `index.d.ts`. +const dtsPath = process.argv[2] + ? resolve(process.argv[2]) + : join(packageRoot, "index.d.ts"); + +const BEGIN_MARKER = + "// --- BEGIN manual additions (scripts/apply-dts-additions.mjs) ---"; +const END_MARKER = "// --- END manual additions ---"; + +// The curated block. `AuthErrorCode` mirrors `AuthError::error_code()` in +// `packages/stack-auth/src/lib.rs` (plus `UNKNOWN_ERROR`, the `index.js` +// fallback when a thrown error carries no recognised code). Keep in sync if a +// new `AuthError` variant is added. +const MANUAL_ADDITIONS = `${BEGIN_MARKER} +// +// Re-applied after every \`napi build\` by scripts/apply-dts-additions.mjs — +// NAPI-RS does not emit these. Edit them there, not here. + +/** Error codes attached to errors thrown by this package. */ +export type AuthErrorCode = + | 'REQUEST_ERROR' + | 'ACCESS_DENIED' + | 'EXPIRED_TOKEN' + | 'INVALID_GRANT' + | 'INVALID_CLIENT' + | 'INVALID_URL' + | 'INVALID_REGION' + | 'INVALID_TOKEN' + | 'SERVER_ERROR' + | 'STORE_ERROR' + | 'NOT_AUTHENTICATED' + | 'MISSING_WORKSPACE_CRN' + | 'INVALID_ACCESS_KEY' + | 'INVALID_CRN' + | 'WORKSPACE_MISMATCH' + | 'INVALID_WORKSPACE_ID' + | 'UNKNOWN_ERROR' + +/** An error thrown by this package, enriched with a machine-readable \`.code\`. */ +export interface AuthError extends Error { + code: AuthErrorCode +} + +/** + * Deprecated alias for {@link DeviceSessionStrategy}, exported at runtime as + * \`module.exports.OAuthStrategy = DeviceSessionStrategy\`. Kept so existing + * consumers don't break; will be removed in a future major release. + * + * @deprecated Renamed to \`DeviceSessionStrategy\`. + */ +export declare const OAuthStrategy: typeof DeviceSessionStrategy +${END_MARKER}`; + +// Test-only exports that `--features test-utils` emits but that must never ship +// in the published surface. `function` entries are single-line in NAPI-RS +// output; `class` entries span a brace-balanced block. +const TEST_UTILS_FUNCTIONS = [ + "beginDeviceCodeFlowWithBaseUrl", + "bindClientDeviceWithProfileDir", + "saveTestToken", +]; +const TEST_UTILS_CLASSES = ["MockAuthServer"]; + +/** Drop a previously-applied manual block (between the markers) so we can + * re-add a current copy — keeps the script idempotent. */ +function stripManualBlock(src) { + const begin = src.indexOf(BEGIN_MARKER); + if (begin === -1) return src; + const end = src.indexOf(END_MARKER, begin); + if (end === -1) return src; // malformed — leave untouched rather than corrupt + const before = src.slice(0, begin).replace(/\n+$/, "\n"); + const after = src.slice(end + END_MARKER.length).replace(/^\n+/, ""); + return after ? `${before}${after}` : before; +} + +/** Number of `{` minus `}` in a line, ignoring that this is a coarse count — + * NAPI-RS output has no braces in strings/comments on declaration lines. */ +function braceDelta(line) { + const open = (line.match(/{/g) || []).length; + const close = (line.match(/}/g) || []).length; + return open - close; +} + +/** Remove a `export declare {function,class} ` declaration and the JSDoc + * comment immediately preceding it. Tolerant: a name that isn't present (e.g. + * a release build that never emitted it) is simply skipped. */ +function stripDeclaration(lines, name, kind) { + const head = + kind === "class" + ? `export declare class ${name} ` + : `export declare function ${name}(`; + const idx = lines.findIndex((l) => l.startsWith(head)); + if (idx === -1) return lines; + + // Find the end of the declaration. + let end = idx; + if (kind === "class") { + let depth = braceDelta(lines[idx]); + while (depth > 0 && end + 1 < lines.length) { + end += 1; + depth += braceDelta(lines[end]); + } + } + + // Absorb a contiguous JSDoc block directly above the declaration. + let start = idx; + if (start > 0 && lines[start - 1].trim().endsWith("*/")) { + let j = start - 1; + while (j >= 0 && !lines[j].trim().startsWith("/**")) j -= 1; + if (j >= 0) start = j; + } + + lines.splice(start, end - start + 1); + return lines; +} + +function normalise(src) { + let out = stripManualBlock(src); + + let lines = out.split("\n"); + for (const fn of TEST_UTILS_FUNCTIONS) + lines = stripDeclaration(lines, fn, "function"); + for (const cls of TEST_UTILS_CLASSES) + lines = stripDeclaration(lines, cls, "class"); + out = lines.join("\n"); + + // Collapse any blank-line runs the deletions left behind. + out = out.replace(/\n{3,}/g, "\n\n"); + + // Note the manual additions in the header banner (best-effort). + out = out.replace( + "/* auto-generated by NAPI-RS */", + "/* auto-generated by NAPI-RS, with manual additions (scripts/apply-dts-additions.mjs) */", + ); + + return `${out.replace(/\n+$/, "")}\n\n${MANUAL_ADDITIONS}\n`; +} + +const original = readFileSync(dtsPath, "utf8"); +const updated = normalise(original); +if (updated !== original) { + writeFileSync(dtsPath, updated); + console.log(`apply-dts-additions: updated ${dtsPath}`); +} else { + console.log(`apply-dts-additions: ${dtsPath} already normalised`); +} diff --git a/languages/typescript/packages/auth/src/lib.rs b/languages/typescript/packages/auth/src/lib.rs index 0885085f0..d793ca363 100644 --- a/languages/typescript/packages/auth/src/lib.rs +++ b/languages/typescript/packages/auth/src/lib.rs @@ -43,19 +43,6 @@ fn parse_workspace_crn(workspace_crn: &str) -> Result { .map_err(|e| to_napi_error(AuthError::InvalidCrn(e))) } -/// Parse an optional `baseUrl` override into a [`url::Url`]. An absent or empty -/// string yields `None` (fall back to `CS_CTS_HOST` / service discovery); an -/// invalid URL maps to the `INVALID_URL` error code. -fn parse_base_url(base_url: Option) -> Result> { - match base_url { - Some(s) if !s.is_empty() => Ok(Some( - s.parse::() - .map_err(|e| to_napi_error(AuthError::from(e)))?, - )), - _ => Ok(None), - } -} - // --------------------------------------------------------------------------- // TokenResult — returned by strategy.getToken() // --------------------------------------------------------------------------- @@ -360,12 +347,11 @@ impl OidcFederationStrategy { base_url: Option, ) -> Result { let crn = parse_workspace_crn(&workspace_crn)?; - let mut builder = - stack_auth::OidcFederationStrategy::builder(crn, NapiOidcProvider { get_jwt }); - if let Some(url) = parse_base_url(base_url)? { - builder = builder.base_url(url); - } - let inner = builder.build().map_err(to_napi_error)?; + let inner = stack_auth::OidcFederationStrategy::builder(crn, NapiOidcProvider { get_jwt }) + .maybe_base_url(base_url) + .map_err(to_napi_error)? + .build() + .map_err(to_napi_error)?; Ok(Self { inner: OidcFederationStrategyInner::NoStore(inner), }) @@ -395,12 +381,9 @@ impl OidcFederationStrategy { load: load_token, save: save_token, }; - let mut builder = - stack_auth::OidcFederationStrategy::builder(crn, NapiOidcProvider { get_jwt }); - if let Some(url) = parse_base_url(base_url)? { - builder = builder.base_url(url); - } - let inner = builder + let inner = stack_auth::OidcFederationStrategy::builder(crn, NapiOidcProvider { get_jwt }) + .maybe_base_url(base_url) + .map_err(to_napi_error)? .with_token_store(store) .build() .map_err(to_napi_error)?; @@ -817,44 +800,12 @@ mod tests { } } - // --- baseUrl override parsing --- - // - // The `OidcFederationStrategy::create{,_with_store}` factories take their - // `baseUrl` argument through `parse_base_url`. The factories themselves - // need a JS runtime (their callbacks are `ThreadsafeFunction`s), so the - // override semantics — empty/absent → `None`, valid → `Some`, malformed → - // `INVALID_URL` — are pinned here on the helper instead. - mod parse_base_url { - use super::*; - - #[test] - fn none_yields_no_override() { - assert!(super::super::parse_base_url(None).unwrap().is_none()); - } - - #[test] - fn empty_string_is_treated_as_absent() { - // An empty `baseUrl` must fall through to `CS_CTS_HOST` / service - // discovery rather than erroring — it's not a malformed URL. - assert!(super::super::parse_base_url(Some(String::new())) - .unwrap() - .is_none()); - } - - #[test] - fn valid_url_yields_override() { - let parsed = super::super::parse_base_url(Some("https://cts.example.com".to_string())) - .unwrap() - .expect("a valid URL should produce an override"); - assert_eq!(parsed.as_str(), "https://cts.example.com/"); - } - - #[test] - fn malformed_url_maps_to_invalid_url() { - let err = expect_err(super::super::parse_base_url(Some("not a url".to_string()))); - assertions::has_error_code(&err, "INVALID_URL"); - } - } + // The `baseUrl` override parsing (empty/absent/valid/malformed semantics) + // lives on `OidcFederationStrategyBuilder::maybe_base_url` in the core + // `stack-auth` crate and is unit-tested there; the napi `INVALID_URL` + // mapping is covered by `error_mapping::maps_all_auth_error_variants`. The + // factories themselves need a JS runtime (their callbacks are + // `ThreadsafeFunction`s), so there's nothing further to test at this seam. // --- Device code result --- // diff --git a/languages/typescript/packages/stack-auth-wasm/Cargo.toml b/languages/typescript/packages/stack-auth-wasm/Cargo.toml index a0ca37548..6c741900c 100644 --- a/languages/typescript/packages/stack-auth-wasm/Cargo.toml +++ b/languages/typescript/packages/stack-auth-wasm/Cargo.toml @@ -36,10 +36,6 @@ web-sys = { version = "0.3", features = ["console"] } # behaviour of the upstream `CallbackTokenStore` on native. Target-gated for # the same reason as `web-sys`. zeroize = { workspace = true } -# Parses the optional `baseUrl` override in `OidcFederationStrategy` into a -# `url::Url`. Only used by the wasm32 strategy bindings, so target-gated to keep -# `cargo udeps --all-targets` from flagging it as unused on the host target. -url = "2" [target.'cfg(target_arch = "wasm32")'.dev-dependencies] wasm-bindgen-test = "0.3" diff --git a/languages/typescript/packages/stack-auth-wasm/src/lib.rs b/languages/typescript/packages/stack-auth-wasm/src/lib.rs index c60085c5a..95b78dccc 100644 --- a/languages/typescript/packages/stack-auth-wasm/src/lib.rs +++ b/languages/typescript/packages/stack-auth-wasm/src/lib.rs @@ -214,20 +214,6 @@ fn parse_workspace_crn(workspace_crn: &str) -> Result .map_err(|e| to_js_error(AuthError::InvalidCrn(e))) } -/// Parse an optional `baseUrl` override into a [`url::Url`]. An absent or empty -/// string yields `None` (fall back to region service discovery); an invalid URL -/// maps to the `INVALID_URL` error code. -#[cfg(target_arch = "wasm32")] -fn parse_base_url(base_url: Option) -> Result, JsValue> { - match base_url { - Some(s) if !s.is_empty() => Ok(Some( - s.parse::() - .map_err(|e| to_js_error(AuthError::from(e)))?, - )), - _ => Ok(None), - } -} - enum AccessKeyStrategyInner { NoStore(stack_auth::AccessKeyStrategy), #[cfg(target_arch = "wasm32")] @@ -367,12 +353,11 @@ impl OidcFederationStrategy { base_url: Option, ) -> Result { let crn = parse_workspace_crn(&workspace_crn)?; - let mut builder = - stack_auth::OidcFederationStrategy::builder(crn, JsOidcProvider { get_jwt }); - if let Some(url) = parse_base_url(base_url)? { - builder = builder.base_url(url); - } - let inner = builder.build().map_err(to_js_error)?; + let inner = stack_auth::OidcFederationStrategy::builder(crn, JsOidcProvider { get_jwt }) + .maybe_base_url(base_url) + .map_err(to_js_error)? + .build() + .map_err(to_js_error)?; Ok(OidcFederationStrategy { inner: OidcFederationStrategyInner::NoStore(inner), }) @@ -401,12 +386,9 @@ impl OidcFederationStrategy { load: load_token, save: save_token, }; - let mut builder = - stack_auth::OidcFederationStrategy::builder(crn, JsOidcProvider { get_jwt }); - if let Some(url) = parse_base_url(base_url)? { - builder = builder.base_url(url); - } - let inner = builder + let inner = stack_auth::OidcFederationStrategy::builder(crn, JsOidcProvider { get_jwt }) + .maybe_base_url(base_url) + .map_err(to_js_error)? .with_token_store(store) .build() .map_err(to_js_error)?; diff --git a/packages/stack-auth/src/oidc_federation_strategy.rs b/packages/stack-auth/src/oidc_federation_strategy.rs index b19912ab6..5d2aed9c7 100644 --- a/packages/stack-auth/src/oidc_federation_strategy.rs +++ b/packages/stack-auth/src/oidc_federation_strategy.rs @@ -115,6 +115,22 @@ impl OidcFederationStrategyBuilder { self } + /// Apply an optional base-URL override supplied as a raw string. + /// + /// The string-typed convenience the language bindings (napi, wasm) call, + /// so the "empty means absent, otherwise parse-or-reject" semantics live in + /// one place rather than being re-derived per binding. An absent or empty + /// string is a no-op — base-URL resolution falls back to `CS_CTS_HOST` / + /// region service discovery (see [`build`](Self::build)); a non-empty but + /// malformed string is rejected as [`AuthError::InvalidUrl`]. For an + /// already-parsed URL, use [`base_url`](Self::base_url). + pub fn maybe_base_url(self, base_url: Option) -> Result { + match base_url { + Some(s) if !s.is_empty() => Ok(self.base_url(s.parse::()?)), + _ => Ok(self), + } + } + /// Wire an external [`TokenStore`] into the strategy. /// /// On every call to [`get_token`](AuthStrategy::get_token), if no token is @@ -201,6 +217,55 @@ mod tests { }) } + const WS: &str = "ZVATKW3VHMFG27DY"; + + /// `maybe_base_url` is the string-typed override seam the language bindings + /// rely on; pin its empty/absent/valid/malformed semantics here so the napi + /// and wasm crates don't each re-test (and risk re-deriving) them. + mod maybe_base_url { + use super::*; + + #[test] + fn absent_is_a_noop() { + let b = OidcFederationStrategy::builder(crn_with_workspace(WS), provider()) + .maybe_base_url(None) + .unwrap(); + assert!(b.base_url_override.is_none()); + } + + #[test] + fn empty_string_is_a_noop() { + let b = OidcFederationStrategy::builder(crn_with_workspace(WS), provider()) + .maybe_base_url(Some(String::new())) + .unwrap(); + assert!(b.base_url_override.is_none()); + } + + #[test] + fn valid_url_sets_the_override() { + let b = OidcFederationStrategy::builder(crn_with_workspace(WS), provider()) + .maybe_base_url(Some("https://cts.example.com".to_string())) + .unwrap(); + assert_eq!( + b.base_url_override.as_ref().map(url::Url::as_str), + Some("https://cts.example.com/") + ); + } + + #[test] + fn malformed_url_is_invalid_url() { + // The builder isn't `Debug`, so match on the result rather than + // `unwrap_err()` (which would require `T: Debug`). + match OidcFederationStrategy::builder(crn_with_workspace(WS), provider()) + .maybe_base_url(Some("not a url".to_string())) + { + Err(AuthError::InvalidUrl(_)) => {} + Ok(_) => panic!("expected Err(InvalidUrl), got Ok"), + Err(other) => panic!("expected InvalidUrl, got: {other:?}"), + } + } + } + /// Happy path — the federated token's `workspace` claim matches the /// configured workspace: `get_token()` returns the token cleanly. #[tokio::test] From d0703b35684b74eefec3a4db7ee8c9ebfcf68506 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Sat, 20 Jun 2026 19:16:51 +1000 Subject: [PATCH 301/686] test(stack-auth): assert base_url override beats CS_CTS_HOST MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copilot asked for explicit precedence coverage on the OidcFederationStrategy baseUrl override. Add a core test that points CS_CTS_HOST at a dead host, builds with maybe_base_url(Some(mock)), and asserts federation still hits the mock — proving the override wins over the env var. CS_CTS_HOST is read inside build() (not get_token), so the env override is scoped to just that synchronous call via temp_env; the async federation runs with the environment already restored. No other stack-auth test reads CS_CTS_HOST (every strategy test pins base_url), so it can't perturb a concurrent test. Verified the test fails when build()'s precedence is inverted. Adds temp-env as a stack-auth dev-dependency (already a workspace dep). --- packages/stack-auth/Cargo.toml | 1 + .../src/oidc_federation_strategy.rs | 38 +++++++++++++++++++ 2 files changed, 39 insertions(+) diff --git a/packages/stack-auth/Cargo.toml b/packages/stack-auth/Cargo.toml index 7a325ca8d..93dce020e 100644 --- a/packages/stack-auth/Cargo.toml +++ b/packages/stack-auth/Cargo.toml @@ -65,6 +65,7 @@ required-features = ["test-utils"] axum = "0.8" cts-common = { workspace = true } mocktail = "0.3.0" +temp-env = { workspace = true } tempfile = "3.21.0" tokio = { workspace = true, features = ["test-util"] } tracing-subscriber = { workspace = true } diff --git a/packages/stack-auth/src/oidc_federation_strategy.rs b/packages/stack-auth/src/oidc_federation_strategy.rs index 5d2aed9c7..5bc08682a 100644 --- a/packages/stack-auth/src/oidc_federation_strategy.rs +++ b/packages/stack-auth/src/oidc_federation_strategy.rs @@ -266,6 +266,44 @@ mod tests { } } + /// Precedence: an explicit `base_url` override (the one `maybe_base_url` + /// sets) wins over the `CS_CTS_HOST` environment variable. `build()` + /// resolves the host in priority order override → `CS_CTS_HOST` → + /// discovery, so with `CS_CTS_HOST` pointed at a dead address the strategy + /// must still federate against the override's mock — proving the env var + /// was not consulted. + /// + /// `CS_CTS_HOST` is read inside `build()` (not `get_token`), so the env + /// override is scoped to just that synchronous call via `temp_env`; the + /// async federation runs with the environment already restored. No other + /// test in this crate reads `CS_CTS_HOST` (every strategy test pins + /// `base_url`), so this can't perturb a concurrent test. + #[tokio::test] + async fn base_url_override_takes_precedence_over_cs_cts_host() { + const WS: &str = "ZVATKW3VHMFG27DY"; + let server = start_mock_server_returning_jwt(WS).await; + + // A routable-but-dead host: if `CS_CTS_HOST` were consulted, federation + // would target this and fail rather than hitting the mock. + let strategy = temp_env::with_var("CS_CTS_HOST", Some("http://127.0.0.1:1/"), || { + OidcFederationStrategy::builder(crn_with_workspace(WS), provider()) + .maybe_base_url(Some(server.url("").to_string())) + .expect("override URL parses") + .build() + .expect("builder") + }); + + let token = (&strategy) + .get_token() + .await + .expect("override must win: federation should hit the mock, not CS_CTS_HOST"); + assert_eq!( + token.workspace_id().expect("workspace_id").as_str(), + WS, + "token should come from the override's mock server", + ); + } + /// Happy path — the federated token's `workspace` claim matches the /// configured workspace: `get_token()` returns the token cleanly. #[tokio::test] From 2be105b52daec9ca90b592825bc388e398e27c9f Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Sat, 20 Jun 2026 20:51:24 +1000 Subject: [PATCH 302/686] test(stack-auth): cover the napi baseUrl seam + the dts normaliser MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second review flagged that the napi binding's baseUrl threading — the actual CIP-3246 production path — was unexercised, and that the durable index.d.ts normaliser had no test. Both fair; the prior "nothing to test at this seam" comment wrongly conflated "can't unit-test in Rust" with "can't test at all" (the vitest suite already drives these factories with a real JS runtime). - vitest (oidc-federation-strategy): baseUrl override beats CS_CTS_HOST (via a second MockAuthServer that succeeds while CS_CTS_HOST's 500s), malformed baseUrl -> INVALID_URL through the factory, and empty-as-absent fallback. - vitest (apply-dts-additions): drive the normaliser over a fixture and assert it re-injects AuthError/OAuthStrategy, strips the test-utils exports (name and class body), preserves real declarations, and is idempotent. - wasm: add oidc_create_with_store_accepts_valid_base_url for symmetry with the plain create variant (was reject-only). - Correct the misleading in-crate comment to point at the vitest coverage. All pass: wasm-pack 25, vitest 44; clippy/fmt/biome clean. --- .../__tests__/apply-dts-additions.test.ts | 110 ++++++++++++++++++ .../oidc-federation-strategy.test.ts | 58 +++++++++ languages/typescript/packages/auth/src/lib.rs | 10 +- .../packages/stack-auth-wasm/src/lib.rs | 14 +++ 4 files changed, 188 insertions(+), 4 deletions(-) create mode 100644 languages/typescript/packages/auth/__tests__/apply-dts-additions.test.ts diff --git a/languages/typescript/packages/auth/__tests__/apply-dts-additions.test.ts b/languages/typescript/packages/auth/__tests__/apply-dts-additions.test.ts new file mode 100644 index 000000000..0260b67d2 --- /dev/null +++ b/languages/typescript/packages/auth/__tests__/apply-dts-additions.test.ts @@ -0,0 +1,110 @@ +import { execFileSync } from "node:child_process"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +// Guards the durable index.d.ts normaliser that keeps the published type +// surface correct across `napi build` regenerations. If this script silently +// regresses, the `AuthError`/`OAuthStrategy` types vanish from the published +// package again (the exact bug it was written to fix), so its strip / +// re-inject / idempotency behaviour is pinned here. + +const SCRIPT = fileURLToPath( + new URL("../scripts/apply-dts-additions.mjs", import.meta.url), +); + +// A stand-in for a `napi build --features test-utils` output: NAPI-RS header, +// a regular class (so the appended `OAuthStrategy` alias has a referent), and +// the test-utils-only exports that must be stripped from the published surface. +const GENERATED_WITH_TEST_UTILS = `/* tslint:disable */ +/* eslint-disable */ + +/* auto-generated by NAPI-RS */ + +export declare class DeviceSessionStrategy { + static fromProfile(): DeviceSessionStrategy + getToken(): Promise +} +/** + * Variant of \`beginDeviceCodeFlow\` that targets a custom auth server URL. + * + * Intended for **testing only**. + */ +export declare function beginDeviceCodeFlowWithBaseUrl(region: string, clientId: string, baseUrl: string): Promise +/** Testing only. */ +export declare function bindClientDeviceWithProfileDir(profileDir: string): Promise +/** Testing only. */ +export declare function saveTestToken(profileDir: string, zerokmsBaseUrl: string): void +export declare class MockAuthServer { + static start(): Promise + get baseUrl(): string + clearMocks(): void +} +`; + +const TEST_UTILS_SYMBOLS = [ + "beginDeviceCodeFlowWithBaseUrl", + "bindClientDeviceWithProfileDir", + "saveTestToken", + "MockAuthServer", +]; + +const MANUAL_SYMBOLS = [ + "export type AuthErrorCode", + "export interface AuthError extends Error", + "export declare const OAuthStrategy", +]; + +describe("apply-dts-additions", () => { + let dir: string; + let dts: string; + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), "apply-dts-")); + dts = join(dir, "index.d.ts"); + }); + + afterEach(() => { + rmSync(dir, { recursive: true, force: true }); + }); + + function run() { + execFileSync("node", [SCRIPT, dts], { encoding: "utf8" }); + return readFileSync(dts, "utf8"); + } + + it("re-injects the curated additions a release build drops", () => { + writeFileSync(dts, GENERATED_WITH_TEST_UTILS); + const out = run(); + for (const sym of MANUAL_SYMBOLS) { + expect(out, `should re-inject ${sym}`).toContain(sym); + } + // The header is annotated so it's clear the file isn't pristine NAPI-RS output. + expect(out).toContain("with manual additions"); + }); + + it("strips test-utils-only exports from the published surface", () => { + writeFileSync(dts, GENERATED_WITH_TEST_UTILS); + const out = run(); + for (const sym of TEST_UTILS_SYMBOLS) { + expect(out, `should strip ${sym}`).not.toContain(sym); + } + // The MockAuthServer class body must go too, not just its name. + expect(out).not.toContain("static start(): Promise"); + }); + + it("preserves non-test-utils declarations", () => { + writeFileSync(dts, GENERATED_WITH_TEST_UTILS); + const out = run(); + expect(out).toContain("export declare class DeviceSessionStrategy"); + }); + + it("is idempotent — a second run is a no-op", () => { + writeFileSync(dts, GENERATED_WITH_TEST_UTILS); + const first = run(); + const second = run(); + expect(second).toBe(first); + }); +}); diff --git a/languages/typescript/packages/auth/__tests__/oidc-federation-strategy.test.ts b/languages/typescript/packages/auth/__tests__/oidc-federation-strategy.test.ts index 32997d767..8baa4359d 100644 --- a/languages/typescript/packages/auth/__tests__/oidc-federation-strategy.test.ts +++ b/languages/typescript/packages/auth/__tests__/oidc-federation-strategy.test.ts @@ -97,6 +97,64 @@ describe("OidcFederationStrategy (TypeScript / vitest)", () => { } }); + it("honours an explicit baseUrl override over CS_CTS_HOST", async () => { + // CS_CTS_HOST (set in beforeEach) points at `server`, which here 500s on + // federation. A second server is the override target and succeeds. If the + // napi `baseUrl` arg is threaded through `maybe_base_url`, federation hits + // the override and resolves; if the override were dropped, it would hit + // CS_CTS_HOST's 500 and reject. This proves the precedence guarantee + // motivating CIP-3246 survives the napi parameter threading — the Rust core + // proves the ordering, this proves the binding preserves it. + server.mockAuthorizeEndpointError(); + const override = await MockAuthServer.start(); + try { + override.mockAuthorizeEndpoint(); + const strategy = OidcFederationStrategy.create( + WORKSPACE_CRN, + () => Promise.resolve("header.payload.signature"), + override.baseUrl, + ); + + const result = await strategy.getToken(); + + expect(result.workspaceId).toBe(WORKSPACE_ID); + } finally { + override.clearMocks(); + } + }); + + it("rejects a malformed baseUrl with INVALID_URL", () => { + // The napi twin of the wasm `..._rejects_invalid_base_url` test: a + // non-empty, unparseable override must surface through the factory as a + // coded INVALID_URL error (via `maybe_base_url(...)? → to_napi_error`), not + // a silent fallback or an un-coded throw. + try { + OidcFederationStrategy.create( + WORKSPACE_CRN, + () => Promise.resolve("h.p.s"), + "not a url", + ); + expect.unreachable("create should throw on a malformed baseUrl"); + } catch (err) { + expect((err as AuthError).code).toBe("INVALID_URL"); + } + }); + + it("treats an empty baseUrl as absent (falls back to CS_CTS_HOST)", async () => { + // An empty-string override must be a no-op, not an INVALID_URL — so + // federation still resolves against CS_CTS_HOST's mock. + server.mockAuthorizeEndpoint(); + const strategy = OidcFederationStrategy.create( + WORKSPACE_CRN, + () => Promise.resolve("header.payload.signature"), + "", + ); + + const result = await strategy.getToken(); + + expect(result.workspaceId).toBe(WORKSPACE_ID); + }); + it("rejects an invalid workspace CRN with .code", () => { try { OidcFederationStrategy.create("not-a-crn", () => diff --git a/languages/typescript/packages/auth/src/lib.rs b/languages/typescript/packages/auth/src/lib.rs index d793ca363..02858817b 100644 --- a/languages/typescript/packages/auth/src/lib.rs +++ b/languages/typescript/packages/auth/src/lib.rs @@ -802,10 +802,12 @@ mod tests { // The `baseUrl` override parsing (empty/absent/valid/malformed semantics) // lives on `OidcFederationStrategyBuilder::maybe_base_url` in the core - // `stack-auth` crate and is unit-tested there; the napi `INVALID_URL` - // mapping is covered by `error_mapping::maps_all_auth_error_variants`. The - // factories themselves need a JS runtime (their callbacks are - // `ThreadsafeFunction`s), so there's nothing further to test at this seam. + // `stack-auth` crate and is unit-tested there. The factory callbacks are + // `ThreadsafeFunction`s, so these factories can't be driven from a Rust + // unit test — but they *are* exercised end-to-end through the napi seam by + // the vitest suite (`__tests__/oidc-federation-strategy.test.ts`), which + // covers the `baseUrl` override winning over `CS_CTS_HOST`, the + // `INVALID_URL` rejection through the factory, and empty-as-absent. // --- Device code result --- // diff --git a/languages/typescript/packages/stack-auth-wasm/src/lib.rs b/languages/typescript/packages/stack-auth-wasm/src/lib.rs index 95b78dccc..242976990 100644 --- a/languages/typescript/packages/stack-auth-wasm/src/lib.rs +++ b/languages/typescript/packages/stack-auth-wasm/src/lib.rs @@ -737,6 +737,20 @@ mod tests { assert!(result.is_ok()); } + /// The store variant accepts a valid `baseUrl` override — mirrors + /// `oidc_federation_strategy_accepts_valid_base_url` on the plain `create`. + #[wasm_bindgen_test] + fn oidc_create_with_store_accepts_valid_base_url() { + let result = OidcFederationStrategy::create_with_store( + VALID_CRN.to_string(), + jwt_fn("h.p.s"), + empty_load_fn(), + noop_save_fn(), + Some("https://cts.example.com".to_string()), + ); + assert!(result.is_ok()); + } + /// The store variant also accepts and validates a `baseUrl` override. #[wasm_bindgen_test] fn oidc_create_with_store_rejects_invalid_base_url() { From 46c1f387bf1e18c9beacfe4f9515b2a254fd2664 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Mon, 22 Jun 2026 10:07:04 +1000 Subject: [PATCH 303/686] test(stack-auth): close the lopsided baseUrl/normaliser test asymmetries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Third review flagged the untested twins of three pairs — where a positional-arg or guard-branch bug would hide. - vitest: createWithStore baseUrl-beats-CS_CTS_HOST. The store variant threads baseUrl as the 5th positional arg through a separate index.js wrapper path; only `create` (3rd-positional) was covered. Override mock succeeds while CS_CTS_HOST's 500s -> getToken resolving + token landing in the store proves the 5th arg threads. - vitest: apply-dts-additions defensive branch — a dangling BEGIN marker with no END must leave existing content untouched (not slice to EOF). Asserts the tail after the marker survives. - wasm: oidc_create_with_store_treats_empty_base_url_as_absent, mirroring the plain create variant (symmetry; shared maybe_base_url). All pass: wasm-pack 26, vitest 46; clippy/fmt/biome clean. --- .../__tests__/apply-dts-additions.test.ts | 24 +++++++++++++++ .../oidc-federation-strategy.test.ts | 29 +++++++++++++++++++ .../packages/stack-auth-wasm/src/lib.rs | 14 +++++++++ 3 files changed, 67 insertions(+) diff --git a/languages/typescript/packages/auth/__tests__/apply-dts-additions.test.ts b/languages/typescript/packages/auth/__tests__/apply-dts-additions.test.ts index 0260b67d2..64152b21e 100644 --- a/languages/typescript/packages/auth/__tests__/apply-dts-additions.test.ts +++ b/languages/typescript/packages/auth/__tests__/apply-dts-additions.test.ts @@ -107,4 +107,28 @@ describe("apply-dts-additions", () => { const second = run(); expect(second).toBe(first); }); + + it("does not corrupt a file with a dangling BEGIN marker (no END)", () => { + // Exercises stripManualBlock's defensive branch: a BEGIN marker with no + // matching END means the file was hand-truncated mid-block. The contract is + // "leave the existing content untouched rather than corrupt it" — a + // regression that sliced from BEGIN to EOF would drop everything after the + // marker. We assert the content following the dangling marker survives. + const dangling = `/* tslint:disable */ +/* eslint-disable */ + +/* auto-generated by NAPI-RS */ + +export declare class DeviceSessionStrategy {} + +// --- BEGIN manual additions (scripts/apply-dts-additions.mjs) --- +export declare const DANGLING_SENTINEL: string +`; + writeFileSync(dts, dangling); + const out = run(); + // The text after the dangling BEGIN must not be sliced away. + expect(out).toContain("DANGLING_SENTINEL"); + // And the real declaration ahead of it is still there. + expect(out).toContain("export declare class DeviceSessionStrategy"); + }); }); diff --git a/languages/typescript/packages/auth/__tests__/oidc-federation-strategy.test.ts b/languages/typescript/packages/auth/__tests__/oidc-federation-strategy.test.ts index 8baa4359d..82f0d5ede 100644 --- a/languages/typescript/packages/auth/__tests__/oidc-federation-strategy.test.ts +++ b/languages/typescript/packages/auth/__tests__/oidc-federation-strategy.test.ts @@ -123,6 +123,35 @@ describe("OidcFederationStrategy (TypeScript / vitest)", () => { } }); + it("honours a baseUrl override over CS_CTS_HOST for createWithStore", async () => { + // The store-variant twin of the precedence test: `baseUrl` is the 5th + // positional arg here (vs the 3rd on `create`), threaded through a separate + // wrapper path in index.js. CS_CTS_HOST's `server` 500s; the override + // server succeeds. getToken resolving (and the token landing in the store) + // proves the 5th-positional override is threaded, not dropped or + // mis-positioned. + server.mockAuthorizeEndpointError(); + const override = await MockAuthServer.start(); + try { + override.mockAuthorizeEndpoint(); + const store = memStore(); + const strategy = OidcFederationStrategy.createWithStore( + WORKSPACE_CRN, + () => Promise.resolve("header.payload.signature"), + store.load, + store.save, + override.baseUrl, + ); + + const result = await strategy.getToken(); + + expect(result.workspaceId).toBe(WORKSPACE_ID); + expect(store.saved()).not.toBeNull(); + } finally { + override.clearMocks(); + } + }); + it("rejects a malformed baseUrl with INVALID_URL", () => { // The napi twin of the wasm `..._rejects_invalid_base_url` test: a // non-empty, unparseable override must surface through the factory as a diff --git a/languages/typescript/packages/stack-auth-wasm/src/lib.rs b/languages/typescript/packages/stack-auth-wasm/src/lib.rs index 242976990..9aebca37f 100644 --- a/languages/typescript/packages/stack-auth-wasm/src/lib.rs +++ b/languages/typescript/packages/stack-auth-wasm/src/lib.rs @@ -751,6 +751,20 @@ mod tests { assert!(result.is_ok()); } + /// The store variant treats an empty `baseUrl` as absent — mirrors + /// `oidc_federation_strategy_treats_empty_base_url_as_absent` on `create`. + #[wasm_bindgen_test] + fn oidc_create_with_store_treats_empty_base_url_as_absent() { + let result = OidcFederationStrategy::create_with_store( + VALID_CRN.to_string(), + jwt_fn("h.p.s"), + empty_load_fn(), + noop_save_fn(), + Some(String::new()), + ); + assert!(result.is_ok()); + } + /// The store variant also accepts and validates a `baseUrl` override. #[wasm_bindgen_test] fn oidc_create_with_store_rejects_invalid_base_url() { From 32226f5143f0f0eb4351c8c4899bfccf5b9c9a73 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Mon, 22 Jun 2026 11:20:30 +1000 Subject: [PATCH 304/686] feat(stack-auth): expose base_url override on all auth strategies OidcFederationStrategy's base_url was un-gated earlier in this PR as a production per-strategy CTS host override. Do the same for the other three strategies for consistency: drop the #[cfg(any(test, feature = "test-utils"))] gate from AccessKeyStrategyBuilder, DeviceSessionStrategyBuilder, and DeviceCodeStrategyBuilder so base_url() is unconditional public API. All three already resolve the host as override -> CS_CTS_HOST -> discovery, so this just makes the explicit-override arm reachable in release builds (no behaviour change to the env/discovery fallback). Docstrings updated to describe the production precedence and drop the stale "available when the test-utils feature is enabled" notes. Verified: compiles on default features (base_url available without test-utils), all-features + all-targets clippy clean, 160 unit tests + doctests pass. --- packages/stack-auth/src/access_key_strategy.rs | 18 ++++++++++++------ packages/stack-auth/src/device_code/mod.rs | 16 +++++++++++----- .../stack-auth/src/device_session_strategy.rs | 17 ++++++++++++----- 3 files changed, 35 insertions(+), 16 deletions(-) diff --git a/packages/stack-auth/src/access_key_strategy.rs b/packages/stack-auth/src/access_key_strategy.rs index 2167c05fd..bf66c30ca 100644 --- a/packages/stack-auth/src/access_key_strategy.rs +++ b/packages/stack-auth/src/access_key_strategy.rs @@ -118,10 +118,14 @@ impl AccessKeyStrategyBuilder { self } - /// Override the base URL resolved by service discovery. + /// Override the CTS base URL resolved for this strategy. /// - /// Useful for pointing at a local or mock auth server during testing. - #[cfg(any(test, feature = "test-utils"))] + /// Takes precedence over both the `CS_CTS_HOST` environment variable and + /// region-derived service discovery. Use it to point a single strategy + /// instance at a specific CTS host — e.g. a self-hosted CTS, or a local + /// mock auth server in development — without relying on the process-wide + /// `CS_CTS_HOST`, which would redirect every other CTS client sharing the + /// process. pub fn base_url(mut self, url: url::Url) -> Self { self.base_url_override = Some(url); self @@ -154,9 +158,11 @@ impl AccessKeyStrategyBuilder { impl AccessKeyStrategyBuilder { /// Build the [`AccessKeyStrategy`]. /// - /// Resolves the base URL via service discovery using the CRN's region, - /// unless overridden with `base_url` (available when the `test-utils` - /// feature is enabled). + /// Resolves the base URL in priority order: an explicit [`base_url`] + /// override, then the `CS_CTS_HOST` environment variable, then service + /// discovery using the CRN's region. + /// + /// [`base_url`]: Self::base_url pub fn build(self) -> Result, AuthError> { let expected_workspace = self.workspace_crn.workspace_id; let region = self.workspace_crn.region; diff --git a/packages/stack-auth/src/device_code/mod.rs b/packages/stack-auth/src/device_code/mod.rs index d4daf3a79..95b6dfa73 100644 --- a/packages/stack-auth/src/device_code/mod.rs +++ b/packages/stack-auth/src/device_code/mod.rs @@ -154,10 +154,13 @@ pub struct DeviceCodeStrategyBuilder { } impl DeviceCodeStrategyBuilder { - /// Override the base URL resolved by service discovery. + /// Override the auth-server base URL resolved for this flow. /// - /// Useful for pointing at a local or mock CTS instance during testing. - #[cfg(any(test, feature = "test-utils"))] + /// Takes precedence over both the `CS_CTS_HOST` environment variable and + /// region-derived service discovery. Use it to point a single flow at a + /// specific host — e.g. a self-hosted CTS, or a local mock auth server in + /// development — without relying on the process-wide `CS_CTS_HOST`, which + /// would redirect every other CTS client sharing the process. pub fn base_url(mut self, url: Url) -> Self { self.base_url_override = Some(url); self @@ -184,8 +187,11 @@ impl DeviceCodeStrategyBuilder { /// Build the [`DeviceCodeStrategy`]. /// - /// Resolves the base URL via service discovery unless overridden with - /// `base_url` (available when the `test-utils` feature is enabled). + /// Resolves the base URL in priority order: an explicit [`base_url`] + /// override, then the `CS_CTS_HOST` environment variable, then service + /// discovery using the region. + /// + /// [`base_url`]: Self::base_url pub fn build(self) -> Result { let base_url = match self.base_url_override { Some(url) => url, diff --git a/packages/stack-auth/src/device_session_strategy.rs b/packages/stack-auth/src/device_session_strategy.rs index c90f0d3b2..d35637338 100644 --- a/packages/stack-auth/src/device_session_strategy.rs +++ b/packages/stack-auth/src/device_session_strategy.rs @@ -108,10 +108,14 @@ pub struct DeviceSessionStrategyBuilder { } impl DeviceSessionStrategyBuilder { - /// Override the base URL resolved by service discovery. + /// Override the CTS base URL resolved for this strategy. /// - /// Useful for pointing at a local or mock auth server during testing. - #[cfg(any(test, feature = "test-utils"))] + /// Takes precedence over both the `CS_CTS_HOST` environment variable and + /// the token issuer / region-derived service discovery. Use it to point a + /// single strategy instance at a specific CTS host — e.g. a self-hosted + /// CTS, or a local mock auth server in development — without relying on the + /// process-wide `CS_CTS_HOST`, which would redirect every other CTS client + /// sharing the process. pub fn base_url(mut self, url: url::Url) -> Self { self.base_url_override = Some(url); self @@ -119,8 +123,11 @@ impl DeviceSessionStrategyBuilder { /// Build the [`DeviceSessionStrategy`]. /// - /// Resolves the base URL via service discovery unless overridden with - /// `base_url` (available when the `test-utils` feature is enabled). + /// Resolves the base URL in priority order: an explicit [`base_url`] + /// override, then the `CS_CTS_HOST` environment variable, then the token + /// issuer. + /// + /// [`base_url`]: Self::base_url pub fn build(self) -> Result { let Self { source, From 08f7c5cf0ba2fa1f96b6f82bf7fda0275deaf248 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Mon, 22 Jun 2026 14:07:56 +1000 Subject: [PATCH 305/686] docs(stack-auth): drop Rustdoc intra-link from binding doc comments `[`create`](Self::create)` in the OidcFederationStrategy createWithStore doc comments is copied verbatim into the generated TypeScript declarations, where `Self::create` is a dead link for JS/TS consumers. Use plain `` `create` ``, which reads correctly in both Rustdoc and the published .d.ts. Regenerates index.d.ts. (Addresses Copilot review feedback on cipherstash/cipherstash-suite#2051.) --- languages/typescript/packages/auth/index.d.ts | 4 ++-- languages/typescript/packages/auth/src/lib.rs | 4 ++-- languages/typescript/packages/stack-auth-wasm/src/lib.rs | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/languages/typescript/packages/auth/index.d.ts b/languages/typescript/packages/auth/index.d.ts index 045c00e68..5aad8c8ef 100644 --- a/languages/typescript/packages/auth/index.d.ts +++ b/languages/typescript/packages/auth/index.d.ts @@ -128,13 +128,13 @@ export declare class OidcFederationStrategy { /** * Create an `OidcFederationStrategy` backed by external token-store callbacks. * - * Behaves like [`create`](Self::create) but persists the federated CTS + * Behaves like `create` but persists the federated CTS * token through `loadToken` (`() => Promise`) * and `saveToken` (`(json: string) => Promise`) — e.g. an HTTP-only * cookie — so a federated token survives across requests without * re-federating. * - * `baseUrl` behaves as in [`create`](Self::create) — an explicit, + * `baseUrl` behaves as in `create` — an explicit, * strategy-scoped CTS host that overrides `CS_CTS_HOST` and service * discovery. */ diff --git a/languages/typescript/packages/auth/src/lib.rs b/languages/typescript/packages/auth/src/lib.rs index 02858817b..5ac059753 100644 --- a/languages/typescript/packages/auth/src/lib.rs +++ b/languages/typescript/packages/auth/src/lib.rs @@ -359,13 +359,13 @@ impl OidcFederationStrategy { /// Create an `OidcFederationStrategy` backed by external token-store callbacks. /// - /// Behaves like [`create`](Self::create) but persists the federated CTS + /// Behaves like `create` but persists the federated CTS /// token through `loadToken` (`() => Promise`) /// and `saveToken` (`(json: string) => Promise`) — e.g. an HTTP-only /// cookie — so a federated token survives across requests without /// re-federating. /// - /// `baseUrl` behaves as in [`create`](Self::create) — an explicit, + /// `baseUrl` behaves as in `create` — an explicit, /// strategy-scoped CTS host that overrides `CS_CTS_HOST` and service /// discovery. #[napi(factory)] diff --git a/languages/typescript/packages/stack-auth-wasm/src/lib.rs b/languages/typescript/packages/stack-auth-wasm/src/lib.rs index 9aebca37f..07aaef7a9 100644 --- a/languages/typescript/packages/stack-auth-wasm/src/lib.rs +++ b/languages/typescript/packages/stack-auth-wasm/src/lib.rs @@ -365,13 +365,13 @@ impl OidcFederationStrategy { /// Create an `OidcFederationStrategy` backed by external token-store callbacks. /// - /// Behaves like [`create`](Self::create) but persists the federated CTS + /// Behaves like `create` but persists the federated CTS /// token through `loadToken` / `saveToken` — see /// [`AccessKeyStrategy::create_with_store`] for the callback contract. Use /// this to back the strategy with an HTTP-only cookie so a federated token /// survives across Edge Function invocations without re-federating. /// - /// `baseUrl` behaves as in [`create`](Self::create) — an explicit, + /// `baseUrl` behaves as in `create` — an explicit, /// strategy-scoped CTS host that overrides region service discovery. #[wasm_bindgen(js_name = createWithStore)] pub fn create_with_store( From eddd1898122fc3c41c26d093924dcc972888a781 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Mon, 22 Jun 2026 11:43:33 +1000 Subject: [PATCH 306/686] refactor(stack-auth): move node mock test rig from Rust to JS, drop test-utils feature MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The napi crate carried a `test-utils` Cargo feature that compiled a mock CTS server (`MockAuthServer`) and three test-only napi helpers into the library, pulling mocktail/jsonwebtoken/reqwest/url in as optional deps. The published .d.ts then needed a post-build script to strip those test-only exports. None of it was necessary: the production bindings already expose the seams the tests need via env vars — - `beginDeviceCodeFlow` resolves its host from CS_CTS_HOST (DeviceCodeStrategy build → cts_base_url_from_env), and - `bindClientDevice` resolves its profile dir from CS_CONFIG_PATH (ProfileStore::resolve). And the claim readers use insecure_disable_signature_validation(), so test JWTs need no real signing. So the mock rig moves to JavaScript: - Delete mock_auth_server.rs + begin_device_code_flow_with_base_url / bind_client_device_with_profile_dir / save_test_token, and the test-utils feature + optional mocktail/jsonwebtoken/reqwest/url deps (kept in dev-deps for the Rust #[cfg(test)] unit tests; reqwest is dropped entirely). - Add zero-dependency __tests__/helpers/: a mock CTS server (node http) and a JWT-mint + auth.json fixture writer. - Rewire the 4 vitest suites onto CS_CTS_HOST / CS_CONFIG_PATH + the JS helpers. - Simplify apply-dts-additions.mjs to just the re-inject half (no build emits test-utils exports anymore, so the strip logic is dead); delete test-utils.d.ts. - package.json: drop build:test (test → build:debug); tasks.toml: cargo build without the feature. The published native module no longer carries the mock server or its heavy deps. No consumer-facing API change (those exports were test-only and already stripped from the published types). Verified: node lib compiles without test-utils, 18 Rust unit tests + 44 vitest tests pass, clippy/fmt/biome clean, index.d.ts unchanged. --- languages/typescript/packages/auth/Cargo.toml | 11 +- .../__tests__/apply-dts-additions.test.ts | 59 ++---- .../auth/__tests__/device-code-flow.test.ts | 55 +++--- .../auth/__tests__/helpers/mock-cts-server.ts | 162 ++++++++++++++++ .../auth/__tests__/helpers/test-fixtures.ts | 70 +++++++ .../__tests__/oidc-cookie-roundtrip.test.ts | 17 +- .../oidc-federation-strategy.test.ts | 25 ++- .../__tests__/provision-device-client.test.ts | 47 ++--- .../typescript/packages/auth/package.json | 3 +- .../auth/scripts/apply-dts-additions.mjs | 94 ++-------- languages/typescript/packages/auth/src/lib.rs | 99 ---------- .../packages/auth/src/mock_auth_server.rs | 177 ------------------ .../typescript/packages/auth/test-utils.d.ts | 71 ------- .../typescript/packages/auth/tsconfig.json | 2 +- packages/stack-auth/tasks.toml | 2 +- 15 files changed, 331 insertions(+), 563 deletions(-) create mode 100644 languages/typescript/packages/auth/__tests__/helpers/mock-cts-server.ts create mode 100644 languages/typescript/packages/auth/__tests__/helpers/test-fixtures.ts delete mode 100644 languages/typescript/packages/auth/src/mock_auth_server.rs delete mode 100644 languages/typescript/packages/auth/test-utils.d.ts diff --git a/languages/typescript/packages/auth/Cargo.toml b/languages/typescript/packages/auth/Cargo.toml index 65769a285..5d68b8791 100644 --- a/languages/typescript/packages/auth/Cargo.toml +++ b/languages/typescript/packages/auth/Cargo.toml @@ -16,12 +16,12 @@ napi = { version = "2", features = ["async", "tokio_rt"] } napi-derive = "2" serde_json = "1" zeroize = { workspace = true } -url = { version = "2", optional = true } -mocktail = { version = "0.3.0", optional = true } -jsonwebtoken = { workspace = true, optional = true } -reqwest = { workspace = true, optional = true } [dev-dependencies] +# `stack-auth/test-utils` gives the Rust #[cfg(test)] unit tests the no-timeout +# `http_client`; the mock server itself is now a JS helper (see +# __tests__/helpers/), so the napi crate no longer ships a `test-utils` feature +# or the heavy mocktail/jsonwebtoken/reqwest deps in its published artifact. stack-auth = { workspace = true, features = ["test-utils"] } jsonwebtoken = { workspace = true } mocktail = "0.3.0" @@ -32,6 +32,3 @@ url = "2" [build-dependencies] napi-build = "2" - -[features] -test-utils = ["stack-auth/test-utils", "dep:url", "dep:mocktail", "dep:jsonwebtoken", "dep:reqwest"] diff --git a/languages/typescript/packages/auth/__tests__/apply-dts-additions.test.ts b/languages/typescript/packages/auth/__tests__/apply-dts-additions.test.ts index 64152b21e..e2ee22c5a 100644 --- a/languages/typescript/packages/auth/__tests__/apply-dts-additions.test.ts +++ b/languages/typescript/packages/auth/__tests__/apply-dts-additions.test.ts @@ -5,20 +5,19 @@ import { join } from "node:path"; import { fileURLToPath } from "node:url"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; -// Guards the durable index.d.ts normaliser that keeps the published type -// surface correct across `napi build` regenerations. If this script silently -// regresses, the `AuthError`/`OAuthStrategy` types vanish from the published -// package again (the exact bug it was written to fix), so its strip / -// re-inject / idempotency behaviour is pinned here. +// Guards the index.d.ts normaliser that re-applies the hand-curated additions +// NAPI-RS can't emit (`AuthError`/`AuthErrorCode`/`OAuthStrategy`). If this +// script silently regresses, those types vanish from the published package +// again (the exact bug it was written to fix), so its re-inject / idempotency +// behaviour is pinned here. const SCRIPT = fileURLToPath( new URL("../scripts/apply-dts-additions.mjs", import.meta.url), ); -// A stand-in for a `napi build --features test-utils` output: NAPI-RS header, -// a regular class (so the appended `OAuthStrategy` alias has a referent), and -// the test-utils-only exports that must be stripped from the published surface. -const GENERATED_WITH_TEST_UTILS = `/* tslint:disable */ +// A stand-in for `napi build` output: the NAPI-RS header plus a regular class +// (so the appended `OAuthStrategy` alias has a referent). +const GENERATED = `/* tslint:disable */ /* eslint-disable */ /* auto-generated by NAPI-RS */ @@ -27,30 +26,8 @@ export declare class DeviceSessionStrategy { static fromProfile(): DeviceSessionStrategy getToken(): Promise } -/** - * Variant of \`beginDeviceCodeFlow\` that targets a custom auth server URL. - * - * Intended for **testing only**. - */ -export declare function beginDeviceCodeFlowWithBaseUrl(region: string, clientId: string, baseUrl: string): Promise -/** Testing only. */ -export declare function bindClientDeviceWithProfileDir(profileDir: string): Promise -/** Testing only. */ -export declare function saveTestToken(profileDir: string, zerokmsBaseUrl: string): void -export declare class MockAuthServer { - static start(): Promise - get baseUrl(): string - clearMocks(): void -} `; -const TEST_UTILS_SYMBOLS = [ - "beginDeviceCodeFlowWithBaseUrl", - "bindClientDeviceWithProfileDir", - "saveTestToken", - "MockAuthServer", -]; - const MANUAL_SYMBOLS = [ "export type AuthErrorCode", "export interface AuthError extends Error", @@ -75,8 +52,8 @@ describe("apply-dts-additions", () => { return readFileSync(dts, "utf8"); } - it("re-injects the curated additions a release build drops", () => { - writeFileSync(dts, GENERATED_WITH_TEST_UTILS); + it("re-injects the curated additions a build drops", () => { + writeFileSync(dts, GENERATED); const out = run(); for (const sym of MANUAL_SYMBOLS) { expect(out, `should re-inject ${sym}`).toContain(sym); @@ -85,24 +62,14 @@ describe("apply-dts-additions", () => { expect(out).toContain("with manual additions"); }); - it("strips test-utils-only exports from the published surface", () => { - writeFileSync(dts, GENERATED_WITH_TEST_UTILS); - const out = run(); - for (const sym of TEST_UTILS_SYMBOLS) { - expect(out, `should strip ${sym}`).not.toContain(sym); - } - // The MockAuthServer class body must go too, not just its name. - expect(out).not.toContain("static start(): Promise"); - }); - - it("preserves non-test-utils declarations", () => { - writeFileSync(dts, GENERATED_WITH_TEST_UTILS); + it("preserves generated declarations", () => { + writeFileSync(dts, GENERATED); const out = run(); expect(out).toContain("export declare class DeviceSessionStrategy"); }); it("is idempotent — a second run is a no-op", () => { - writeFileSync(dts, GENERATED_WITH_TEST_UTILS); + writeFileSync(dts, GENERATED); const first = run(); const second = run(); expect(second).toBe(first); diff --git a/languages/typescript/packages/auth/__tests__/device-code-flow.test.ts b/languages/typescript/packages/auth/__tests__/device-code-flow.test.ts index e1b3f7019..c9371c13f 100644 --- a/languages/typescript/packages/auth/__tests__/device-code-flow.test.ts +++ b/languages/typescript/packages/auth/__tests__/device-code-flow.test.ts @@ -1,33 +1,26 @@ -import { describe, it, expect, beforeEach } from "vitest"; -import type { MockAuthServer as MockAuthServerType } from "../test-utils"; +import { describe, it, expect, beforeEach, afterEach } from "vitest"; import type { DeviceCodeResult, AuthResult, AuthError } from "../index"; +import { MockCtsServer } from "./helpers/mock-cts-server"; -// Load the CJS module — includes MockAuthServer when built with test-utils. -const mod = require("../index.js") as typeof import("../index") & { - MockAuthServer: typeof MockAuthServerType; -}; - -const { beginDeviceCodeFlow, beginDeviceCodeFlowWithBaseUrl, MockAuthServer } = - mod; +const { beginDeviceCodeFlow } = + require("../index.js") as typeof import("../index"); // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- -let server: InstanceType; +let server: MockCtsServer; -async function startServer(): Promise> { - const s = await MockAuthServer.start(); +async function startServer(): Promise { + const s = await MockCtsServer.start(); s.mockDeviceCodeEndpoint(); return s; } +// The production `beginDeviceCodeFlow` resolves its auth host from CS_CTS_HOST +// (set in `beforeEach` below), so no test-only base-URL override is needed. async function beginFlow(): Promise { - return beginDeviceCodeFlowWithBaseUrl( - "ap-southeast-2.aws", - "test-client", - server.baseUrl, - ); + return beginDeviceCodeFlow("ap-southeast-2.aws", "test-client"); } // --------------------------------------------------------------------------- @@ -48,26 +41,24 @@ describe("device code flow (TypeScript / vitest)", () => { } }); - it("beginDeviceCodeFlowWithBaseUrl rejects for invalid region", async () => { - try { - await beginDeviceCodeFlowWithBaseUrl( - "not-a-region", - "test-client", - "http://localhost:9999", - ); - expect.unreachable("should have thrown"); - } catch (err) { - const authErr = err as AuthError; - expect(authErr).toBeInstanceOf(Error); - expect(authErr.code).toBe("INVALID_REGION"); - } - }); - // ---------- Tests that need the mock server ---------- describe("with mock server", () => { + let savedHost: string | undefined; + beforeEach(async () => { server = await startServer(); + savedHost = process.env.CS_CTS_HOST; + process.env.CS_CTS_HOST = server.baseUrl; + }); + + afterEach(async () => { + if (savedHost === undefined) { + delete process.env.CS_CTS_HOST; + } else { + process.env.CS_CTS_HOST = savedHost; + } + await server.close(); }); it("exposes getter fields on DeviceCodeResult", async () => { diff --git a/languages/typescript/packages/auth/__tests__/helpers/mock-cts-server.ts b/languages/typescript/packages/auth/__tests__/helpers/mock-cts-server.ts new file mode 100644 index 000000000..a35947226 --- /dev/null +++ b/languages/typescript/packages/auth/__tests__/helpers/mock-cts-server.ts @@ -0,0 +1,162 @@ +// A zero-dependency mock CTS / auth server for the vitest suite, replacing the +// Rust `MockAuthServer` (which required the `test-utils` Cargo feature and +// pulled `mocktail`/`reqwest`/`jsonwebtoken` into the native module). Tests +// point the bindings at it via `CS_CTS_HOST` — the production strategies all +// resolve their host as `override → CS_CTS_HOST → discovery`, so no test-only +// Rust seam is needed. +// +// Each `mock*` method registers a one-shot-ish handler for a route; the server +// keeps the last handler registered per route. `clearMocks()` drops them all. + +import { type Server, createServer } from "node:http"; +import { mintJwt, WORKSPACE_ID } from "./test-fixtures"; + +type Handler = (body: string) => { status: number; json: unknown }; + +export class MockCtsServer { + #server: Server; + #routes = new Map(); + #baseUrl = ""; + + private constructor(server: Server) { + this.#server = server; + } + + /** Start a mock server on an ephemeral port and resolve once it's listening. */ + static async start(): Promise { + const mock = new MockCtsServer( + createServer((req, res) => { + const handler = mock.#routes.get(`${req.method} ${req.url}`); + if (!handler) { + res.writeHead(404, { "content-type": "application/json" }); + res.end(JSON.stringify({ error: "no mock registered" })); + return; + } + let body = ""; + req.on("data", (chunk) => { + body += chunk; + }); + req.on("end", () => { + const { status, json } = handler(body); + res.writeHead(status, { "content-type": "application/json" }); + res.end(JSON.stringify(json)); + }); + }), + ); + + await new Promise((resolve) => { + mock.#server.listen(0, "127.0.0.1", resolve); + }); + const addr = mock.#server.address(); + if (addr === null || typeof addr === "string") { + throw new Error("mock server did not bind a TCP port"); + } + mock.#baseUrl = `http://127.0.0.1:${addr.port}`; + return mock; + } + + /** The base URL of the running mock server (e.g. `http://127.0.0.1:12345`). */ + get baseUrl(): string { + return this.#baseUrl; + } + + /** Stop the server and free its port. Call from `afterEach`. */ + async close(): Promise { + await new Promise((resolve, reject) => { + this.#server.close((err) => (err ? reject(err) : resolve())); + }); + } + + #on(method: string, path: string, handler: Handler): void { + this.#routes.set(`${method} ${path}`, handler); + } + + // --- Device-code flow --- + + mockDeviceCodeEndpoint(): void { + this.#on("POST", "/oauth/device/code", () => ({ + status: 200, + json: { + device_code: "test_device_code", + user_code: "ABCD-EFGH", + verification_uri: "http://example.com/activate", + verification_uri_complete: + "http://example.com/activate?user_code=ABCD-EFGH", + expires_in: 900, + }, + })); + } + + mockTokenEndpoint(): void { + this.#on("POST", "/oauth/device/token", () => ({ + status: 200, + json: { + access_token: mintJwt(), + token_type: "Bearer", + expires_in: 3600, + }, + })); + } + + mockTokenEndpointError(code: string, description?: string): void { + this.#on("POST", "/oauth/device/token", () => ({ + status: 400, + json: { + error: code, + error_description: description ?? `${code} occurred`, + }, + })); + } + + // --- OIDC federation --- + + /** + * `expiry` is seconds-until-expiry; CTS returns the JWT `exp` as an ABSOLUTE + * Unix epoch (CIP-3233), so convert to `now + expiry` — otherwise a freshly + * federated token reads as already expired. Default 3600. + */ + mockAuthorizeEndpoint(expiry = 3600): void { + this.#on("POST", "/api/authorise", () => ({ + status: 200, + json: { + accessToken: mintJwt(), + expiry: Math.floor(Date.now() / 1000) + expiry, + }, + })); + } + + mockAuthorizeEndpointError(): void { + this.#on("POST", "/api/authorise", () => ({ + status: 500, + json: { error: "federation failed" }, + })); + } + + // --- ZeroKMS create-client (device provisioning) --- + + mockCreateClientEndpoint(): void { + this.#on("POST", "/create-client", () => ({ + status: 200, + json: { + id: "00000000-0000-0000-0000-000000000001", + dataset_id: "00000000-0000-0000-0000-000000000099", + name: "test-device", + description: "test-device", + client_key: "dGVzdC1rZXktbWF0ZXJpYWw=", + }, + })); + } + + mockCreateClientConflict(): void { + this.#on("POST", "/create-client", () => ({ + status: 409, + json: { error: "conflict" }, + })); + } + + clearMocks(): void { + this.#routes.clear(); + } +} + +export { WORKSPACE_ID }; diff --git a/languages/typescript/packages/auth/__tests__/helpers/test-fixtures.ts b/languages/typescript/packages/auth/__tests__/helpers/test-fixtures.ts new file mode 100644 index 000000000..eb38b55b0 --- /dev/null +++ b/languages/typescript/packages/auth/__tests__/helpers/test-fixtures.ts @@ -0,0 +1,70 @@ +// Test fixtures that replace the Rust `test-utils` helpers (`saveTestToken` and +// the JWT minting inside `MockAuthServer`). The stack-auth claim readers call +// `insecure_disable_signature_validation()`, so a JWT only needs a well-formed +// header + base64url payload — no real HMAC signing, hence zero crypto deps. + +import { mkdirSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; + +export const WORKSPACE_ID = "ZVATKW3VHMFG27DY"; + +function base64url(value: unknown): string { + return Buffer.from(JSON.stringify(value)).toString("base64url"); +} + +/** + * Mint an unsigned-but-well-formed JWT (`

..sig`). Mirrors the + * claims of `mock_auth_server::test_jwt`; `claims` overrides/extends them (e.g. + * to add a `services` claim). The signature segment is a literal placeholder — + * only the header `alg` and the payload are ever read. + */ +export function mintJwt(claims: Record = {}): string { + const now = Math.floor(Date.now() / 1000); + const header = base64url({ alg: "HS256", typ: "JWT" }); + const payload = base64url({ + iss: "https://cts.example.com/", + sub: "CS|test-user", + aud: "test-audience", + iat: now, + exp: now + 3600, + workspace: WORKSPACE_ID, + scope: "", + ...claims, + }); + return `${header}.${payload}.sig`; +} + +/** + * Write a CTS auth token into a profile directory — the JS replacement for the + * Rust `saveTestToken` napi helper. Mirrors `ProfileStore::init_workspace` plus + * `save_with_mode("auth.json", .., 0o600)`: creates `workspaces//`, points + * the `current_workspace` file at it (what `bind_client_device` reads to locate + * the token), and writes the token JSON. The token carries `services.zerokms` + * so device provisioning can reach the mock ZeroKMS endpoint. + * + * Pair with `process.env.CS_CONFIG_PATH = profileDir` so the production + * `bindClientDevice()` resolves this directory. + */ +export function saveTestToken( + profileDir: string, + zerokmsBaseUrl: string, + workspaceId: string = WORKSPACE_ID, +): void { + const now = Math.floor(Date.now() / 1000); + const jwt = mintJwt({ + aud: "legacy-aud-value", + workspace: workspaceId, + services: { zerokms: zerokmsBaseUrl }, + }); + const tokenJson = { + access_token: jwt, + token_type: "Bearer", + expires_at: now + 3600, + }; + const wsDir = join(profileDir, "workspaces", workspaceId); + mkdirSync(wsDir, { recursive: true }); + writeFileSync(join(profileDir, "current_workspace"), workspaceId); + writeFileSync(join(wsDir, "auth.json"), JSON.stringify(tokenJson), { + mode: 0o600, + }); +} diff --git a/languages/typescript/packages/auth/__tests__/oidc-cookie-roundtrip.test.ts b/languages/typescript/packages/auth/__tests__/oidc-cookie-roundtrip.test.ts index a0fe4947d..b37af151a 100644 --- a/languages/typescript/packages/auth/__tests__/oidc-cookie-roundtrip.test.ts +++ b/languages/typescript/packages/auth/__tests__/oidc-cookie-roundtrip.test.ts @@ -1,32 +1,29 @@ import { describe, it, expect, beforeEach, afterEach } from "vitest"; -import type { MockAuthServer as MockAuthServerType } from "../test-utils"; import { cookieStore } from "../cookies.mjs"; +import { MockCtsServer } from "./helpers/mock-cts-server"; -// Load the CJS module — includes MockAuthServer when built with test-utils. -const mod = require("../index.js") as typeof import("../index") & { - MockAuthServer: typeof MockAuthServerType; -}; - -const { OidcFederationStrategy, MockAuthServer } = mod; +const { OidcFederationStrategy } = + require("../index.js") as typeof import("../index"); const WORKSPACE_ID = "ZVATKW3VHMFG27DY"; const WORKSPACE_CRN = `crn:ap-southeast-2.aws:${WORKSPACE_ID}`; -let server: InstanceType; +let server: MockCtsServer; let savedHost: string | undefined; beforeEach(async () => { - server = await MockAuthServer.start(); + server = await MockCtsServer.start(); savedHost = process.env.CS_CTS_HOST; process.env.CS_CTS_HOST = server.baseUrl; }); -afterEach(() => { +afterEach(async () => { if (savedHost === undefined) { delete process.env.CS_CTS_HOST; } else { process.env.CS_CTS_HOST = savedHost; } + await server.close(); }); /** Extract the `name=value` pair from a `Set-Cookie` header. */ diff --git a/languages/typescript/packages/auth/__tests__/oidc-federation-strategy.test.ts b/languages/typescript/packages/auth/__tests__/oidc-federation-strategy.test.ts index 82f0d5ede..18b61b0a4 100644 --- a/languages/typescript/packages/auth/__tests__/oidc-federation-strategy.test.ts +++ b/languages/typescript/packages/auth/__tests__/oidc-federation-strategy.test.ts @@ -1,34 +1,31 @@ import { describe, it, expect, beforeEach, afterEach } from "vitest"; -import type { MockAuthServer as MockAuthServerType } from "../test-utils"; import type { AuthError } from "../index"; +import { MockCtsServer } from "./helpers/mock-cts-server"; -// Load the CJS module — includes MockAuthServer when built with test-utils. -const mod = require("../index.js") as typeof import("../index") & { - MockAuthServer: typeof MockAuthServerType; -}; - -const { OidcFederationStrategy, MockAuthServer } = mod; +const { OidcFederationStrategy } = + require("../index.js") as typeof import("../index"); const WORKSPACE_ID = "ZVATKW3VHMFG27DY"; const WORKSPACE_CRN = `crn:ap-southeast-2.aws:${WORKSPACE_ID}`; -let server: InstanceType; +let server: MockCtsServer; let savedHost: string | undefined; beforeEach(async () => { - server = await MockAuthServer.start(); + server = await MockCtsServer.start(); // OidcFederationStrategy reads the CTS base URL from CS_CTS_HOST at runtime, // so set it before constructing the strategy. savedHost = process.env.CS_CTS_HOST; process.env.CS_CTS_HOST = server.baseUrl; }); -afterEach(() => { +afterEach(async () => { if (savedHost === undefined) { delete process.env.CS_CTS_HOST; } else { process.env.CS_CTS_HOST = savedHost; } + await server.close(); }); /** A `getJwt` callback that counts invocations and returns a fixed JWT. */ @@ -106,7 +103,7 @@ describe("OidcFederationStrategy (TypeScript / vitest)", () => { // motivating CIP-3246 survives the napi parameter threading — the Rust core // proves the ordering, this proves the binding preserves it. server.mockAuthorizeEndpointError(); - const override = await MockAuthServer.start(); + const override = await MockCtsServer.start(); try { override.mockAuthorizeEndpoint(); const strategy = OidcFederationStrategy.create( @@ -119,7 +116,7 @@ describe("OidcFederationStrategy (TypeScript / vitest)", () => { expect(result.workspaceId).toBe(WORKSPACE_ID); } finally { - override.clearMocks(); + await override.close(); } }); @@ -131,7 +128,7 @@ describe("OidcFederationStrategy (TypeScript / vitest)", () => { // proves the 5th-positional override is threaded, not dropped or // mis-positioned. server.mockAuthorizeEndpointError(); - const override = await MockAuthServer.start(); + const override = await MockCtsServer.start(); try { override.mockAuthorizeEndpoint(); const store = memStore(); @@ -148,7 +145,7 @@ describe("OidcFederationStrategy (TypeScript / vitest)", () => { expect(result.workspaceId).toBe(WORKSPACE_ID); expect(store.saved()).not.toBeNull(); } finally { - override.clearMocks(); + await override.close(); } }); diff --git a/languages/typescript/packages/auth/__tests__/provision-device-client.test.ts b/languages/typescript/packages/auth/__tests__/provision-device-client.test.ts index 5c0982d32..0552f1e7c 100644 --- a/languages/typescript/packages/auth/__tests__/provision-device-client.test.ts +++ b/languages/typescript/packages/auth/__tests__/provision-device-client.test.ts @@ -1,17 +1,13 @@ -import { describe, it, expect, beforeEach } from "vitest"; +import { describe, it, expect, beforeEach, afterEach } from "vitest"; import { existsSync, mkdtempSync, readFileSync, writeFileSync } from "fs"; import { join } from "path"; import { tmpdir } from "os"; -import type { MockAuthServer as MockAuthServerType } from "../test-utils"; import type { AuthError } from "../index"; +import { MockCtsServer } from "./helpers/mock-cts-server"; +import { saveTestToken } from "./helpers/test-fixtures"; -const mod = require("../index.js") as typeof import("../index") & { - MockAuthServer: typeof MockAuthServerType; - bindClientDeviceWithProfileDir: (profileDir: string) => Promise; - saveTestToken: (profileDir: string, zerokmsBaseUrl: string) => void; -}; - -const { MockAuthServer, bindClientDeviceWithProfileDir, saveTestToken } = mod; +const { bindClientDevice } = + require("../index.js") as typeof import("../index"); // --------------------------------------------------------------------------- // Helpers @@ -19,13 +15,9 @@ const { MockAuthServer, bindClientDeviceWithProfileDir, saveTestToken } = mod; const TEST_WORKSPACE_ID = "ZVATKW3VHMFG27DY"; -let server: InstanceType; +let server: MockCtsServer; let profileDir: string; - -async function startServer(): Promise> { - const s = await MockAuthServer.start(); - return s; -} +let savedConfigPath: string | undefined; function freshProfileDir(): string { return mkdtempSync(join(tmpdir(), "cs-auth-test-")); @@ -41,15 +33,28 @@ function workspaceDir(): string { describe("provision device client (TypeScript / vitest)", () => { beforeEach(async () => { - server = await startServer(); + server = await MockCtsServer.start(); profileDir = freshProfileDir(); + // Production `bindClientDevice()` resolves its profile dir from + // CS_CONFIG_PATH (ProfileStore::resolve), so point it at the temp dir. + savedConfigPath = process.env.CS_CONFIG_PATH; + process.env.CS_CONFIG_PATH = profileDir; + }); + + afterEach(async () => { + if (savedConfigPath === undefined) { + delete process.env.CS_CONFIG_PATH; + } else { + process.env.CS_CONFIG_PATH = savedConfigPath; + } + await server.close(); }); it("creates secretkey.json on successful provisioning", async () => { server.mockCreateClientEndpoint(); saveTestToken(profileDir, server.baseUrl); - await bindClientDeviceWithProfileDir(profileDir); + await bindClientDevice(); const raw = readFileSync(join(workspaceDir(), "secretkey.json"), "utf-8"); const secretKey = JSON.parse(raw); @@ -68,7 +73,7 @@ describe("provision device client (TypeScript / vitest)", () => { }); writeFileSync(join(workspaceDir(), "secretkey.json"), existing); - await bindClientDeviceWithProfileDir(profileDir); + await bindClientDevice(); const raw = readFileSync(join(workspaceDir(), "secretkey.json"), "utf-8"); const secretKey = JSON.parse(raw); @@ -79,7 +84,7 @@ describe("provision device client (TypeScript / vitest)", () => { server.mockCreateClientConflict(); saveTestToken(profileDir, server.baseUrl); - await bindClientDeviceWithProfileDir(profileDir); + await bindClientDevice(); expect(existsSync(join(workspaceDir(), "secretkey.json"))).toBe(false); }); @@ -89,7 +94,7 @@ describe("provision device client (TypeScript / vitest)", () => { saveTestToken(profileDir, server.baseUrl); try { - await bindClientDeviceWithProfileDir(profileDir); + await bindClientDevice(); expect.unreachable("should have thrown"); } catch (err) { expect(err).toBeInstanceOf(Error); @@ -99,7 +104,7 @@ describe("provision device client (TypeScript / vitest)", () => { it("throws STORE_ERROR when auth token is missing", async () => { // No token saved — should fail trying to load auth.json try { - await bindClientDeviceWithProfileDir(profileDir); + await bindClientDevice(); expect.unreachable("should have thrown"); } catch (err) { const authErr = err as AuthError; diff --git a/languages/typescript/packages/auth/package.json b/languages/typescript/packages/auth/package.json index ca37b9246..44cfeb6ae 100644 --- a/languages/typescript/packages/auth/package.json +++ b/languages/typescript/packages/auth/package.json @@ -57,9 +57,8 @@ "scripts": { "build": "napi build --release && node scripts/apply-dts-additions.mjs", "build:debug": "napi build && node scripts/apply-dts-additions.mjs", - "build:test": "napi build --features test-utils && node scripts/apply-dts-additions.mjs", "build:wasm": "cd ../wasm && wasm-pack build --target bundler --out-dir ../node/wasm && rm -f ../node/wasm/README.md ../node/wasm/LICENSE ../node/wasm/.gitignore && echo '{\"type\":\"module\"}' > ../node/wasm/package.json && node ../node/scripts/inline-wasm.mjs", - "test": "npm run build:test && vitest run", + "test": "npm run build:debug && vitest run", "format": "npx --yes @biomejs/biome@2.3.4 format --write .", "format:check": "npx --yes @biomejs/biome@2.3.4 ci ." }, diff --git a/languages/typescript/packages/auth/scripts/apply-dts-additions.mjs b/languages/typescript/packages/auth/scripts/apply-dts-additions.mjs index 37a976161..33f3f0189 100644 --- a/languages/typescript/packages/auth/scripts/apply-dts-additions.mjs +++ b/languages/typescript/packages/auth/scripts/apply-dts-additions.mjs @@ -1,30 +1,18 @@ #!/usr/bin/env node // -// Re-apply the hand-curated additions to the NAPI-RS-generated `index.d.ts`, -// and normalise it to the canonical *published* type surface. +// Re-apply the hand-curated additions to the NAPI-RS-generated `index.d.ts`. // -// `napi build` regenerates `index.d.ts` from scratch on every run. That has two -// consequences this script exists to undo: +// `napi build` regenerates `index.d.ts` from scratch on every run, dropping the +// declarations NAPI-RS can't emit from the Rust types: the `AuthError` / +// `AuthErrorCode` error-enrichment types (errors are tagged with a `.code` at +// runtime by `index.js`) and the deprecated `OAuthStrategy` alias (exported at +// runtime as `module.exports.OAuthStrategy`). Without these, consumers doing +// `import type { AuthError } from "@cipherstash/auth"` stop compiling even +// though the runtime values still exist. // -// 1. It drops declarations NAPI-RS can't know about — the `AuthError` / -// `AuthErrorCode` error-enrichment types (errors are tagged with a `.code` -// at runtime by `index.js`) and the deprecated `OAuthStrategy` alias -// (exported at runtime as `module.exports.OAuthStrategy`). Without these, -// consumers doing `import type { AuthError } from "@cipherstash/auth"` -// stop compiling even though the runtime values still exist. -// -// 2. When built with `--features test-utils` (i.e. `npm run build:test`), it -// ALSO emits test-only exports (`MockAuthServer`, `beginDeviceCodeFlow- -// WithBaseUrl`, `bindClientDeviceWithProfileDir`, `saveTestToken`). Those -// are gated out of release builds, so they must not appear in the -// published `.d.ts`. Tests type them via `test-utils.d.ts` and inline -// intersection casts instead — never from `index.d.ts`. -// -// Running this after every build makes the committed/published `index.d.ts` -// deterministic regardless of which build mode produced it. It is idempotent: -// safe to run repeatedly, a no-op on an already-normalised file. -// -// Wired into the `build`, `build:debug`, and `build:test` npm scripts. +// Running this after every build keeps the committed/published `index.d.ts` +// correct. It is idempotent: safe to run repeatedly, a no-op on an +// already-normalised file. Wired into the `build` and `build:debug` npm scripts. import { readFileSync, writeFileSync } from "node:fs"; import { dirname, join, resolve } from "node:path"; @@ -85,16 +73,6 @@ export interface AuthError extends Error { export declare const OAuthStrategy: typeof DeviceSessionStrategy ${END_MARKER}`; -// Test-only exports that `--features test-utils` emits but that must never ship -// in the published surface. `function` entries are single-line in NAPI-RS -// output; `class` entries span a brace-balanced block. -const TEST_UTILS_FUNCTIONS = [ - "beginDeviceCodeFlowWithBaseUrl", - "bindClientDeviceWithProfileDir", - "saveTestToken", -]; -const TEST_UTILS_CLASSES = ["MockAuthServer"]; - /** Drop a previously-applied manual block (between the markers) so we can * re-add a current copy — keeps the script idempotent. */ function stripManualBlock(src) { @@ -107,58 +85,10 @@ function stripManualBlock(src) { return after ? `${before}${after}` : before; } -/** Number of `{` minus `}` in a line, ignoring that this is a coarse count — - * NAPI-RS output has no braces in strings/comments on declaration lines. */ -function braceDelta(line) { - const open = (line.match(/{/g) || []).length; - const close = (line.match(/}/g) || []).length; - return open - close; -} - -/** Remove a `export declare {function,class} ` declaration and the JSDoc - * comment immediately preceding it. Tolerant: a name that isn't present (e.g. - * a release build that never emitted it) is simply skipped. */ -function stripDeclaration(lines, name, kind) { - const head = - kind === "class" - ? `export declare class ${name} ` - : `export declare function ${name}(`; - const idx = lines.findIndex((l) => l.startsWith(head)); - if (idx === -1) return lines; - - // Find the end of the declaration. - let end = idx; - if (kind === "class") { - let depth = braceDelta(lines[idx]); - while (depth > 0 && end + 1 < lines.length) { - end += 1; - depth += braceDelta(lines[end]); - } - } - - // Absorb a contiguous JSDoc block directly above the declaration. - let start = idx; - if (start > 0 && lines[start - 1].trim().endsWith("*/")) { - let j = start - 1; - while (j >= 0 && !lines[j].trim().startsWith("/**")) j -= 1; - if (j >= 0) start = j; - } - - lines.splice(start, end - start + 1); - return lines; -} - function normalise(src) { let out = stripManualBlock(src); - let lines = out.split("\n"); - for (const fn of TEST_UTILS_FUNCTIONS) - lines = stripDeclaration(lines, fn, "function"); - for (const cls of TEST_UTILS_CLASSES) - lines = stripDeclaration(lines, cls, "class"); - out = lines.join("\n"); - - // Collapse any blank-line runs the deletions left behind. + // Collapse any blank-line runs a prior strip left behind. out = out.replace(/\n{3,}/g, "\n\n"); // Note the manual additions in the header banner (best-effort). diff --git a/languages/typescript/packages/auth/src/lib.rs b/languages/typescript/packages/auth/src/lib.rs index 5ac059753..a3dd818a1 100644 --- a/languages/typescript/packages/auth/src/lib.rs +++ b/languages/typescript/packages/auth/src/lib.rs @@ -13,9 +13,6 @@ use stack_auth::{ use vitaminc_protected::OpaqueDebug; use zeroize::Zeroizing; -#[cfg(feature = "test-utils")] -mod mock_auth_server; - // --------------------------------------------------------------------------- // Error helpers // --------------------------------------------------------------------------- @@ -1220,99 +1217,3 @@ mod tests { } } } - -/// Variant of `beginDeviceCodeFlow` that targets a custom auth server URL. -/// -/// Intended for **testing only** — requires the crate to be built with the -/// `test-utils` Cargo feature. -#[cfg(feature = "test-utils")] -#[napi] -pub async fn begin_device_code_flow_with_base_url( - region: String, - client_id: String, - base_url: String, -) -> Result { - let region = Region::new(®ion).map_err(|e| to_napi_error(AuthError::from(e)))?; - let parsed_url: url::Url = base_url - .parse() - .map_err(|e: url::ParseError| to_napi_error(AuthError::from(e)))?; - let strategy = DeviceCodeStrategy::builder(region, client_id) - .base_url(parsed_url) - .build() - .map_err(to_napi_error)?; - let pending = strategy.begin().await.map_err(to_napi_error)?; - Ok(DeviceCodeResult::from_pending(pending)) -} - -/// Variant of `provisionDeviceClient` that uses a custom profile directory. -/// -/// Intended for **testing only** — requires the crate to be built with the -/// `test-utils` Cargo feature. -#[cfg(feature = "test-utils")] -#[napi] -pub async fn bind_client_device_with_profile_dir(profile_dir: String) -> Result<()> { - let store = stack_profile::ProfileStore::new(&profile_dir); - stack_auth::bind_client_device(&store) - .await - .map_err(device_client_to_napi_error) -} - -/// Save a test auth token to the given profile directory with the ZeroKMS -/// service URL set to `zerokms_base_url`. -/// -/// Intended for **testing only** — requires the crate to be built with the -/// `test-utils` Cargo feature. -#[cfg(feature = "test-utils")] -#[napi] -pub fn save_test_token(profile_dir: String, zerokms_base_url: String) -> Result<()> { - use jsonwebtoken::{encode, EncodingKey, Header}; - use std::time::{SystemTime, UNIX_EPOCH}; - - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .map_err(|e| napi::Error::new(Status::GenericFailure, format!("{e}")))? - .as_secs(); - - let claims = serde_json::json!({ - "iss": "https://cts.example.com/", - "sub": "CS|test-user", - "aud": "legacy-aud-value", - "iat": now, - "exp": now + 3600, - "workspace": "ZVATKW3VHMFG27DY", - "scope": "", - "services": { - "zerokms": zerokms_base_url, - }, - }); - - let jwt = encode( - &Header::default(), - &claims, - &EncodingKey::from_secret(b"test-secret"), - ) - .map_err(|e| napi::Error::new(Status::GenericFailure, format!("{e}")))?; - - let token_json = serde_json::json!({ - "access_token": jwt, - "token_type": "Bearer", - "expires_at": now + 3600, - }); - - let store = stack_profile::ProfileStore::new(&profile_dir); - - let workspace_id = "ZVATKW3VHMFG27DY"; - store - .init_workspace(workspace_id) - .map_err(|e| napi::Error::new(Status::GenericFailure, format!("{e}")))?; - - // Save the token to the workspace directory. - let ws_store = store - .workspace_store(workspace_id) - .map_err(|e| napi::Error::new(Status::GenericFailure, format!("{e}")))?; - ws_store - .save_with_mode("auth.json", &token_json, 0o600) - .map_err(|e| napi::Error::new(Status::GenericFailure, format!("{e}")))?; - - Ok(()) -} diff --git a/languages/typescript/packages/auth/src/mock_auth_server.rs b/languages/typescript/packages/auth/src/mock_auth_server.rs deleted file mode 100644 index 9a1212234..000000000 --- a/languages/typescript/packages/auth/src/mock_auth_server.rs +++ /dev/null @@ -1,177 +0,0 @@ -use mocktail::prelude::*; -use napi::bindgen_prelude::*; -use napi_derive::napi; - -/// Build a valid JWT access token containing a workspace claim. -/// -/// Used by mock endpoints that need to return a token that can be decoded -/// by `Token::workspace_id()`. -fn test_jwt() -> String { - use jsonwebtoken::{encode, EncodingKey, Header}; - use std::time::{SystemTime, UNIX_EPOCH}; - - #[allow(clippy::expect_used)] - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .expect("system clock") - .as_secs(); - - let claims = serde_json::json!({ - "iss": "https://cts.example.com/", - "sub": "CS|test-user", - "aud": "test-audience", - "iat": now, - "exp": now + 3600, - "workspace": "ZVATKW3VHMFG27DY", - "scope": "", - }); - - #[allow(clippy::expect_used)] - encode( - &Header::default(), - &claims, - &EncodingKey::from_secret(b"test-secret"), - ) - .expect("JWT encode") -} - -#[napi] -pub struct MockAuthServer { - server: MockServer, -} - -#[napi] -impl MockAuthServer { - /// Start a mock auth server on a random port. - #[napi(factory)] - pub async fn start() -> Result { - let server = MockServer::new_http("stack-auth-node-vitest"); - server - .start() - .await - .map_err(|e| napi::Error::new(Status::GenericFailure, format!("{e}")))?; - Ok(Self { server }) - } - - /// The base URL of the running mock server (e.g. `http://127.0.0.1:12345`). - #[napi(getter)] - pub fn base_url(&self) -> String { - self.server.url("").to_string() - } - - /// Register a mock for `POST /oauth/device/code` that returns a standard - /// device-code JSON response. - #[napi] - pub fn mock_device_code_endpoint(&self) { - self.server.mocks().mock(|when, then| { - when.post().path("/oauth/device/code"); - then.json(serde_json::json!({ - "device_code": "test_device_code", - "user_code": "ABCD-EFGH", - "verification_uri": "http://example.com/activate", - "verification_uri_complete": "http://example.com/activate?user_code=ABCD-EFGH", - "expires_in": 900 - })); - }); - } - - /// Register a mock for `POST /oauth/device/token` that returns a standard - /// token JSON response. - #[napi] - pub fn mock_token_endpoint(&self) { - let jwt = test_jwt(); - self.server.mocks().mock(|when, then| { - when.post().path("/oauth/device/token"); - then.json(serde_json::json!({ - "access_token": jwt, - "token_type": "Bearer", - "expires_in": 3600 - })); - }); - } - - /// Register a mock for `POST /oauth/device/token` that returns a 400 error - /// with the given OAuth error code and optional description. - #[napi] - pub fn mock_token_endpoint_error(&self, code: String, description: Option) { - let desc = description.unwrap_or_else(|| format!("{code} occurred")); - self.server.mocks().mock(|when, then| { - when.post().path("/oauth/device/token"); - then.bad_request().json(serde_json::json!({ - "error": code, - "error_description": desc, - })); - }); - } - - /// Register a mock for `POST /api/authorise` that returns a successful - /// federation response (`{ accessToken, expiry }`), as CTS would for an - /// `OidcFederationStrategy` JWT exchange. - /// - /// `expiry` (seconds until the CTS token expires) defaults to 3600. Pass a - /// small value (e.g. 0) to exercise re-federation on expiry. - #[napi] - pub fn mock_authorize_endpoint(&self, expiry: Option) { - let jwt = test_jwt(); - let expires_in = expiry.unwrap_or(3600); - // CTS returns `expiry` as an ABSOLUTE Unix epoch (the JWT `exp` claim), - // NOT a relative duration — see CIP-3233. The ergonomic `expiry` argument - // is "seconds from now", so convert it to the absolute epoch the wire - // actually carries; otherwise the mock no longer matches production and a - // freshly federated token reads as already expired. - let now = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_secs(); - let expiry = now + u64::from(expires_in); - self.server.mocks().mock(move |when, then| { - when.post().path("/api/authorise"); - then.json(serde_json::json!({ - "accessToken": jwt, - "expiry": expiry, - })); - }); - } - - /// Register a mock for `POST /api/authorise` that returns a 500 error. - #[napi] - pub fn mock_authorize_endpoint_error(&self) { - self.server.mocks().mock(|when, then| { - when.post().path("/api/authorise"); - then.internal_server_error() - .json(serde_json::json!({"error": "federation failed"})); - }); - } - - /// Register a mock for `POST /create-client` that returns a successful - /// create-client JSON response (as ZeroKMS would). - #[napi] - pub fn mock_create_client_endpoint(&self) { - self.server.mocks().mock(|when, then| { - when.post().path("/create-client"); - then.json(serde_json::json!({ - "id": "00000000-0000-0000-0000-000000000001", - "dataset_id": "00000000-0000-0000-0000-000000000099", - "name": "test-device", - "description": "test-device", - "client_key": "dGVzdC1rZXktbWF0ZXJpYWw=" - })); - }); - } - - /// Register a mock for `POST /create-client` that returns a 409 conflict. - #[napi] - pub fn mock_create_client_conflict(&self) { - self.server.mocks().mock(|when, then| { - when.post().path("/create-client"); - then.status(reqwest::StatusCode::CONFLICT) - .json(serde_json::json!({"error": "conflict"})); - }); - } - - /// Remove all registered mocks. - #[napi] - pub fn clear_mocks(&self) { - self.server.mocks().clear(); - } -} diff --git a/languages/typescript/packages/auth/test-utils.d.ts b/languages/typescript/packages/auth/test-utils.d.ts deleted file mode 100644 index 91ace2a7a..000000000 --- a/languages/typescript/packages/auth/test-utils.d.ts +++ /dev/null @@ -1,71 +0,0 @@ -/** - * Type declarations for `@cipherstash/stack-auth` test utilities. - * - * These exports are only available when the native module is built with the - * `test-utils` Cargo feature (`napi build --features test-utils`). - */ - -/** - * A mock OAuth auth server for integration tests. - * - * Wraps an HTTP server that can be configured with canned responses for - * the device-code and token endpoints. - * - * @example - * ```ts - * const server = await MockAuthServer.start(); - * server.mockDeviceCodeEndpoint(); - * server.mockTokenEndpoint(); - * - * const result = await beginDeviceCodeFlowWithBaseUrl( - * "ap-southeast-2.aws", - * "test-client", - * server.baseUrl, - * ); - * const token = await result.pollForToken(); - * ``` - */ -export class MockAuthServer { - /** Start a mock auth server on a random port. */ - static start(): Promise; - - /** The base URL of the running mock server (e.g. `http://127.0.0.1:12345`). */ - readonly baseUrl: string; - - /** - * Register a mock for `POST /oauth/device/code` that returns a standard - * device-code JSON response. - */ - mockDeviceCodeEndpoint(): void; - - /** - * Register a mock for `POST /oauth/device/token` that returns a standard - * token JSON response. - */ - mockTokenEndpoint(): void; - - /** - * Register a mock for `POST /oauth/device/token` that returns a 400 error - * with the given OAuth error code and optional description. - * - * @param code - OAuth error code (e.g. `"access_denied"`, `"expired_token"`). - * @param description - Optional human-readable description. Defaults to `" occurred"`. - */ - mockTokenEndpointError(code: string, description?: string): void; - - /** - * Register a mock for `POST /api/authorise` that returns a successful - * federation response (`{ accessToken, expiry }`), as CTS would for an - * `OidcFederationStrategy` JWT exchange. - * - * @param expiry - Seconds until the CTS token expires. Defaults to 3600; - * pass a small value (e.g. 0) to exercise re-federation on expiry. - */ - mockAuthorizeEndpoint(expiry?: number): void; - - /** Register a mock for `POST /api/authorise` that returns a 500 error. */ - mockAuthorizeEndpointError(): void; - - /** Remove all registered mocks. */ - clearMocks(): void; -} diff --git a/languages/typescript/packages/auth/tsconfig.json b/languages/typescript/packages/auth/tsconfig.json index adb4dcd76..eb9863389 100644 --- a/languages/typescript/packages/auth/tsconfig.json +++ b/languages/typescript/packages/auth/tsconfig.json @@ -7,5 +7,5 @@ "esModuleInterop": true, "skipLibCheck": true }, - "include": ["__tests__/**/*.ts", "index.d.ts", "test-utils.d.ts"] + "include": ["__tests__/**/*.ts", "index.d.ts"] } diff --git a/packages/stack-auth/tasks.toml b/packages/stack-auth/tasks.toml index a80924b23..c889f85e4 100644 --- a/packages/stack-auth/tasks.toml +++ b/packages/stack-auth/tasks.toml @@ -3,7 +3,7 @@ description = "Run stack-auth Node.js integration tests" dir = "{{config_root}}/packages/stack-auth/node" run = [ "npm install", - "cargo build -p stack-auth-node --features stack-auth-node/test-utils", + "cargo build -p stack-auth-node", "cp ../../../target/debug/libstack_auth_node.dylib stack-auth-node.node 2>/dev/null || cp ../../../target/debug/libstack_auth_node.so stack-auth-node.node", "npx vitest run", ] From 3844aa127b51dfdaf2525080aa54556a2b85a4fb Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 22 Jun 2026 11:54:24 +0000 Subject: [PATCH 307/686] chore: release --- packages/stack-auth/CHANGELOG.md | 35 +++++++++++++++++++++++++++++ packages/stack-auth/Cargo.toml | 2 +- packages/stack-profile/CHANGELOG.md | 1 + packages/stack-profile/Cargo.toml | 2 +- 4 files changed, 38 insertions(+), 2 deletions(-) diff --git a/packages/stack-auth/CHANGELOG.md b/packages/stack-auth/CHANGELOG.md index edb81ff74..589f5f6ab 100644 --- a/packages/stack-auth/CHANGELOG.md +++ b/packages/stack-auth/CHANGELOG.md @@ -1,5 +1,40 @@ +### CI + +- make the CRAP workflow blocking + +### Documentation + +- note OidcFederationStrategy INVALID_CRN error-code change +- drop Rustdoc intra-link from binding doc comments + +### Features + +- add baseUrl override to OidcFederationStrategy (CIP-3246) +- expose base_url override on all auth strategies + +### Fixes + +- format baseUrl bindings + cover baseUrl override in tests + +### Refactoring + +- take a workspace CRN in OidcFederationStrategy +- address code-review findings on the CRN change +- durable index.d.ts additions + shared base_url helper + +### Testing + +- deterministic expiry-crossing refresh test; clear CRAP findings +- cover is_*_at boundaries and failed-refresh expiry path +- assert backwards wall-clock is handled gracefully +- scaffold cargo-fuzz pilot for public string parsers +- assert base_url override beats CS_CTS_HOST +- cover the napi baseUrl seam + the dts normaliser +- close the lopsided baseUrl/normaliser test asymmetries + + ### CI - add cargo-crap (CRAP metric) coverage report diff --git a/packages/stack-auth/Cargo.toml b/packages/stack-auth/Cargo.toml index e3151ada5..d35b52977 100644 --- a/packages/stack-auth/Cargo.toml +++ b/packages/stack-auth/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "stack-auth" description = "Authentication library for CipherStash services" -version = "0.37.1" +version = "0.38.0" edition.workspace = true authors.workspace = true repository.workspace = true diff --git a/packages/stack-profile/CHANGELOG.md b/packages/stack-profile/CHANGELOG.md index 60cdff051..27dd37951 100644 --- a/packages/stack-profile/CHANGELOG.md +++ b/packages/stack-profile/CHANGELOG.md @@ -29,6 +29,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 + ## [0.34.0-alpha.1] - 2026-03-04 ### Changed diff --git a/packages/stack-profile/Cargo.toml b/packages/stack-profile/Cargo.toml index b39c28daa..1356fd7b2 100644 --- a/packages/stack-profile/Cargo.toml +++ b/packages/stack-profile/Cargo.toml @@ -2,7 +2,7 @@ name = "stack-profile" description = "Centralised ~/.cipherstash profile file management" license-file = "LICENSE" -version = "0.37.1" +version = "0.38.0" edition.workspace = true authors.workspace = true repository.workspace = true From 309b037d927fcad89511975652a896ff894e638b Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Mon, 22 Jun 2026 22:17:29 +1000 Subject: [PATCH 308/686] docs: fix stale 0.34.0-alpha.1 changelog headers cipherstash-config, cts-common, and stack-profile carried a `## [0.34.0-alpha.1]` header from the March version consolidation, while cipherstash-core / cipherstash-client / zerokms-protocol got `## [0.34.0]` from the same change. Normalise the three to `0.34.0` for consistency. NOTE: the deeper cause of messy changelogs is that cliff.toml's body template emits no `## [version]` header at all, so every crate accumulates headerless entries above the last hand-written header. Fixing that (and reconciling the accumulated entries) is a larger release-tooling change left as a follow-up. --- packages/stack-profile/CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/stack-profile/CHANGELOG.md b/packages/stack-profile/CHANGELOG.md index 27dd37951..a0e4792bf 100644 --- a/packages/stack-profile/CHANGELOG.md +++ b/packages/stack-profile/CHANGELOG.md @@ -30,7 +30,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 -## [0.34.0-alpha.1] - 2026-03-04 +## [0.34.0] - 2026-03-04 ### Changed - Consolidated all publishable crate versions to 0.34.0; version is now centralized via `workspace.package.version` From 6ee36f49d380b8bfa94b302d1729e5a2f57b847d Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Mon, 22 Jun 2026 22:17:30 +1000 Subject: [PATCH 309/686] chore(stack-auth-node): prepare @cipherstash/auth 0.40.0 for manual release - Bump package.json 0.39.0 -> 0.40.0 and the platform peerDependencies to match. - Promote the staged `## 0.39.0` changelog section to `## 0.40.0` (0.39.0 was never tagged/published; it covers the OIDC federation strategy, the baseUrl override, and the breaking CRN migration). - Publish manually only: drop the `push: tags: stack-auth-v*` trigger from the npm publish workflow. The node package is versioned independently of the Rust `stack-auth` crate, so a release-plz `stack-auth-v0.37.x` tag must not auto- publish @cipherstash/auth at the crate's version. Release via "Run workflow". --- .github/imported-workflows/publish-auth-npm.yml | 14 ++++++-------- languages/typescript/packages/auth/CHANGELOG.md | 2 +- languages/typescript/packages/auth/package.json | 14 +++++++------- 3 files changed, 14 insertions(+), 16 deletions(-) diff --git a/.github/imported-workflows/publish-auth-npm.yml b/.github/imported-workflows/publish-auth-npm.yml index 74b0fbaae..24a247176 100644 --- a/.github/imported-workflows/publish-auth-npm.yml +++ b/.github/imported-workflows/publish-auth-npm.yml @@ -1,9 +1,10 @@ name: "Publish @cipherstash/auth to npm" on: - push: - tags: - - "stack-auth-v*" + # Published manually. `@cipherstash/auth` is versioned independently of the + # Rust `stack-auth` crate (its `package.json` version), so it must NOT auto- + # publish on release-plz's `stack-auth-v*` tags — that would ship the node + # package at the Rust crate's version. Trigger via "Run workflow" instead. workflow_dispatch: inputs: dry_run: @@ -182,11 +183,8 @@ jobs: - name: Determine version and npm tag id: version run: | - if [[ "$GITHUB_REF" == refs/tags/stack-auth-v* ]]; then - VERSION="${GITHUB_REF#refs/tags/stack-auth-v}" - else - VERSION=$(jq -r .version "${{ env.WORKING_DIR }}/package.json") - fi + # Manual publish: the version is whatever `package.json` declares. + VERSION=$(jq -r .version "${{ env.WORKING_DIR }}/package.json") echo "version=$VERSION" >> "$GITHUB_OUTPUT" if [[ "$VERSION" == *"-"* ]]; then diff --git a/languages/typescript/packages/auth/CHANGELOG.md b/languages/typescript/packages/auth/CHANGELOG.md index 1aebe0150..a7265a80c 100644 --- a/languages/typescript/packages/auth/CHANGELOG.md +++ b/languages/typescript/packages/auth/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## 0.39.0 +## 0.40.0 ### New Features diff --git a/languages/typescript/packages/auth/package.json b/languages/typescript/packages/auth/package.json index ca37b9246..fbfb0836c 100644 --- a/languages/typescript/packages/auth/package.json +++ b/languages/typescript/packages/auth/package.json @@ -1,6 +1,6 @@ { "name": "@cipherstash/auth", - "version": "0.39.0", + "version": "0.40.0", "main": "index.js", "types": "index.d.ts", "browser": false, @@ -64,12 +64,12 @@ "format:check": "npx --yes @biomejs/biome@2.3.4 ci ." }, "peerDependencies": { - "@cipherstash/auth-darwin-x64": "0.36.0", - "@cipherstash/auth-darwin-arm64": "0.36.0", - "@cipherstash/auth-linux-x64-gnu": "0.36.0", - "@cipherstash/auth-linux-arm64-gnu": "0.36.0", - "@cipherstash/auth-linux-x64-musl": "0.36.0", - "@cipherstash/auth-win32-x64-msvc": "0.36.0" + "@cipherstash/auth-darwin-x64": "0.40.0", + "@cipherstash/auth-darwin-arm64": "0.40.0", + "@cipherstash/auth-linux-x64-gnu": "0.40.0", + "@cipherstash/auth-linux-arm64-gnu": "0.40.0", + "@cipherstash/auth-linux-x64-musl": "0.40.0", + "@cipherstash/auth-win32-x64-msvc": "0.40.0" }, "peerDependenciesMeta": { "@cipherstash/auth-darwin-x64": { "optional": true }, From 5a6791f736955bbd1e00bbab8ce8f2c5906785a8 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Tue, 23 Jun 2026 19:41:01 +1000 Subject: [PATCH 310/686] chore: sync @cipherstash/auth lockfile + changelog to 0.40.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address Copilot review on cipherstash/cipherstash-suite#2057: - Regenerate package-lock.json — it still declared 0.39.0 / peerDeps 0.36.0, which would make npm ci fail the up-to-date check. Now matches package.json (0.40.0). The unresolvable optional platform peers at 0.40.0 drop out until those packages are published. - Fix stale version refs in the promoted changelog body: the CRN breaking-change example said 'Before (0.38.x)' / 'After (0.39.0)'; 0.39.0 was never released, so the change ships in 0.40.0 (prior published was 0.35.x). --- .../typescript/packages/auth/CHANGELOG.md | 4 +- .../packages/auth/package-lock.json | 94 ++----------------- 2 files changed, 10 insertions(+), 88 deletions(-) diff --git a/languages/typescript/packages/auth/CHANGELOG.md b/languages/typescript/packages/auth/CHANGELOG.md index a7265a80c..951ff295b 100644 --- a/languages/typescript/packages/auth/CHANGELOG.md +++ b/languages/typescript/packages/auth/CHANGELOG.md @@ -57,10 +57,10 @@ disagrees with the workspace it was pointed at. ```ts - // Before (0.38.x) + // Before (0.35.x) const strategy = AccessKeyStrategy.create("ap-southeast-2.aws", "CSAKid.secret"); - // After (0.39.0) + // After (0.40.0) const strategy = AccessKeyStrategy.create( "crn:ap-southeast-2.aws:ZVATKW3VHMFG27DY", "CSAKid.secret", diff --git a/languages/typescript/packages/auth/package-lock.json b/languages/typescript/packages/auth/package-lock.json index 8fdd6bf69..b9dc7a54b 100644 --- a/languages/typescript/packages/auth/package-lock.json +++ b/languages/typescript/packages/auth/package-lock.json @@ -1,24 +1,24 @@ { "name": "@cipherstash/auth", - "version": "0.39.0", + "version": "0.40.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@cipherstash/auth", - "version": "0.39.0", + "version": "0.40.0", "devDependencies": { "@napi-rs/cli": "^2", "typescript": "^5", "vitest": "^3" }, "peerDependencies": { - "@cipherstash/auth-darwin-arm64": "0.36.0", - "@cipherstash/auth-darwin-x64": "0.36.0", - "@cipherstash/auth-linux-arm64-gnu": "0.36.0", - "@cipherstash/auth-linux-x64-gnu": "0.36.0", - "@cipherstash/auth-linux-x64-musl": "0.36.0", - "@cipherstash/auth-win32-x64-msvc": "0.36.0" + "@cipherstash/auth-darwin-arm64": "0.40.0", + "@cipherstash/auth-darwin-x64": "0.40.0", + "@cipherstash/auth-linux-arm64-gnu": "0.40.0", + "@cipherstash/auth-linux-x64-gnu": "0.40.0", + "@cipherstash/auth-linux-x64-musl": "0.40.0", + "@cipherstash/auth-win32-x64-msvc": "0.40.0" }, "peerDependenciesMeta": { "@cipherstash/auth-darwin-arm64": { @@ -41,84 +41,6 @@ } } }, - "node_modules/@cipherstash/auth-darwin-arm64": { - "version": "0.36.0", - "resolved": "https://registry.npmjs.org/@cipherstash/auth-darwin-arm64/-/auth-darwin-arm64-0.36.0.tgz", - "integrity": "sha512-KnRBW90HHJdxtMTjts1OxnlKdcuKdkWfdd+XwZXWCGlzlIxjq2QGMoVlvGzk7kMoZapLomRMn+f4RBzM1dwsWQ==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "darwin" - ], - "peer": true - }, - "node_modules/@cipherstash/auth-darwin-x64": { - "version": "0.36.0", - "resolved": "https://registry.npmjs.org/@cipherstash/auth-darwin-x64/-/auth-darwin-x64-0.36.0.tgz", - "integrity": "sha512-bCAdJSwAz79mFr36GeGn4IddDCRQokFcqV1qzmTsgzjt8Q3B+vmglY7uoGWQUJWTyfDrflEH1P+kivGeKYehyQ==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "darwin" - ], - "peer": true - }, - "node_modules/@cipherstash/auth-linux-arm64-gnu": { - "version": "0.36.0", - "resolved": "https://registry.npmjs.org/@cipherstash/auth-linux-arm64-gnu/-/auth-linux-arm64-gnu-0.36.0.tgz", - "integrity": "sha512-PDpm1EHC1XzVtEDGzcyr0UXNca8IFkfPusqqVJ5CSpzCtlYipIClYui197zQ4NGMHIAQD168IEFOK2TROyb4Tw==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "linux" - ], - "peer": true - }, - "node_modules/@cipherstash/auth-linux-x64-gnu": { - "version": "0.36.0", - "resolved": "https://registry.npmjs.org/@cipherstash/auth-linux-x64-gnu/-/auth-linux-x64-gnu-0.36.0.tgz", - "integrity": "sha512-Gm20ezVlGmNrkMH4s+I+JT13hDRD6vEX3fu3VDQQhWUiYCdgbdVsNJQgOr6QMY1cJkkmGyNlQKfiCPn4zlqtMg==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "linux" - ], - "peer": true - }, - "node_modules/@cipherstash/auth-linux-x64-musl": { - "version": "0.36.0", - "resolved": "https://registry.npmjs.org/@cipherstash/auth-linux-x64-musl/-/auth-linux-x64-musl-0.36.0.tgz", - "integrity": "sha512-RUQeLc19JnURAMEoemP3+2DyptK+pqNFrVGgiKKOMVql0SZDVMlN2IyFrTKJ2emv1yuf4Gr1+E4jIdKPR0Oh+g==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "linux" - ], - "peer": true - }, - "node_modules/@cipherstash/auth-win32-x64-msvc": { - "version": "0.36.0", - "resolved": "https://registry.npmjs.org/@cipherstash/auth-win32-x64-msvc/-/auth-win32-x64-msvc-0.36.0.tgz", - "integrity": "sha512-1mQ8E6YFy7frHkvrDmSixpy47EakGPRh4qgoXPgk9lqZnlbMECYZhoKWQEs5wa3tLGgiX5G6jKC3NQZsOOqEfQ==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "win32" - ], - "peer": true - }, "node_modules/@esbuild/aix-ppc64": { "version": "0.27.3", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", From 850525c1a1652ff3c99da259cac9135c6110ec20 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Tue, 23 Jun 2026 22:59:48 +1000 Subject: [PATCH 311/686] test(stack-auth): harden JS mock CTS server (Copilot review cipherstash/cipherstash-suite#2052) - mockAuthorizeEndpoint now mints the JWT with exp equal to the returned absolute expiry, so the token and response envelope stay consistent and the mock can't mask a future consistency check. - start() attaches a one-shot error handler so bind/listen failures reject the promise instead of hanging the suite until timeout; the handler is removed once listening succeeds. --- .../auth/__tests__/helpers/mock-cts-server.ts | 33 +++++++++++++------ 1 file changed, 23 insertions(+), 10 deletions(-) diff --git a/languages/typescript/packages/auth/__tests__/helpers/mock-cts-server.ts b/languages/typescript/packages/auth/__tests__/helpers/mock-cts-server.ts index a35947226..5e42285bb 100644 --- a/languages/typescript/packages/auth/__tests__/helpers/mock-cts-server.ts +++ b/languages/typescript/packages/auth/__tests__/helpers/mock-cts-server.ts @@ -44,8 +44,16 @@ export class MockCtsServer { }), ); - await new Promise((resolve) => { - mock.#server.listen(0, "127.0.0.1", resolve); + await new Promise((resolve, reject) => { + // Surface bind/listen failures as a rejected promise instead of hanging + // the suite until a timeout. Drop the listener once we're listening so it + // doesn't intercept later runtime errors. + const onError = (err: Error) => reject(err); + mock.#server.once("error", onError); + mock.#server.listen(0, "127.0.0.1", () => { + mock.#server.removeListener("error", onError); + resolve(); + }); }); const addr = mock.#server.address(); if (addr === null || typeof addr === "string") { @@ -113,16 +121,21 @@ export class MockCtsServer { /** * `expiry` is seconds-until-expiry; CTS returns the JWT `exp` as an ABSOLUTE * Unix epoch (CIP-3233), so convert to `now + expiry` — otherwise a freshly - * federated token reads as already expired. Default 3600. + * federated token reads as already expired. Default 3600. The minted JWT's + * `exp` is set to the same absolute value, so the token and the response + * envelope stay consistent. */ mockAuthorizeEndpoint(expiry = 3600): void { - this.#on("POST", "/api/authorise", () => ({ - status: 200, - json: { - accessToken: mintJwt(), - expiry: Math.floor(Date.now() / 1000) + expiry, - }, - })); + this.#on("POST", "/api/authorise", () => { + const exp = Math.floor(Date.now() / 1000) + expiry; + return { + status: 200, + json: { + accessToken: mintJwt({ exp }), + expiry: exp, + }, + }; + }); } mockAuthorizeEndpointError(): void { From 87949bad3eb4355fcdc6e5c186449f31ad2a13d2 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Wed, 24 Jun 2026 11:23:00 +1000 Subject: [PATCH 312/686] test(stack-auth): drop unused test-helper flexibility (auxesis review cipherstash/cipherstash-suite#2052) Remove three unused-flexibility/dead-code items flagged in review: - `mockTokenEndpointError`'s `description?` param (no caller passes it) - `saveTestToken`'s `workspaceId` param (all callers use the default); the now-redundant `workspace` JWT override goes too (mintJwt defaults it) - the dead `export { WORKSPACE_ID }` re-export from mock-cts-server --- .../packages/auth/__tests__/helpers/mock-cts-server.ts | 8 +++----- .../packages/auth/__tests__/helpers/test-fixtures.ts | 6 ++---- 2 files changed, 5 insertions(+), 9 deletions(-) diff --git a/languages/typescript/packages/auth/__tests__/helpers/mock-cts-server.ts b/languages/typescript/packages/auth/__tests__/helpers/mock-cts-server.ts index 5e42285bb..e309fb621 100644 --- a/languages/typescript/packages/auth/__tests__/helpers/mock-cts-server.ts +++ b/languages/typescript/packages/auth/__tests__/helpers/mock-cts-server.ts @@ -9,7 +9,7 @@ // keeps the last handler registered per route. `clearMocks()` drops them all. import { type Server, createServer } from "node:http"; -import { mintJwt, WORKSPACE_ID } from "./test-fixtures"; +import { mintJwt } from "./test-fixtures"; type Handler = (body: string) => { status: number; json: unknown }; @@ -106,12 +106,12 @@ export class MockCtsServer { })); } - mockTokenEndpointError(code: string, description?: string): void { + mockTokenEndpointError(code: string): void { this.#on("POST", "/oauth/device/token", () => ({ status: 400, json: { error: code, - error_description: description ?? `${code} occurred`, + error_description: `${code} occurred`, }, })); } @@ -171,5 +171,3 @@ export class MockCtsServer { this.#routes.clear(); } } - -export { WORKSPACE_ID }; diff --git a/languages/typescript/packages/auth/__tests__/helpers/test-fixtures.ts b/languages/typescript/packages/auth/__tests__/helpers/test-fixtures.ts index eb38b55b0..2bee9f09b 100644 --- a/languages/typescript/packages/auth/__tests__/helpers/test-fixtures.ts +++ b/languages/typescript/packages/auth/__tests__/helpers/test-fixtures.ts @@ -48,12 +48,10 @@ export function mintJwt(claims: Record = {}): string { export function saveTestToken( profileDir: string, zerokmsBaseUrl: string, - workspaceId: string = WORKSPACE_ID, ): void { const now = Math.floor(Date.now() / 1000); const jwt = mintJwt({ aud: "legacy-aud-value", - workspace: workspaceId, services: { zerokms: zerokmsBaseUrl }, }); const tokenJson = { @@ -61,9 +59,9 @@ export function saveTestToken( token_type: "Bearer", expires_at: now + 3600, }; - const wsDir = join(profileDir, "workspaces", workspaceId); + const wsDir = join(profileDir, "workspaces", WORKSPACE_ID); mkdirSync(wsDir, { recursive: true }); - writeFileSync(join(profileDir, "current_workspace"), workspaceId); + writeFileSync(join(profileDir, "current_workspace"), WORKSPACE_ID); writeFileSync(join(wsDir, "auth.json"), JSON.stringify(tokenJson), { mode: 0o600, }); From b44480e48b9bed6759ba2de931d35abe8d2ccd16 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Wed, 24 Jun 2026 13:48:43 +1000 Subject: [PATCH 313/686] docs(stack-auth-node): record published 0.39.0 + 0.40.0 as separate sections Both 0.39.0 (npm Jun 5) and 0.40.0 (npm Jun 24) are real published releases, so each gets its own changelog section instead of collapsing 0.39.0's content under a 0.40.0 header: - 0.40.0 = the baseUrl override (cipherstash/cipherstash-suite#2051, merged Jun 22). - 0.39.0 = OidcFederationStrategy, the CRN breaking change, the OAuthStrategy -> DeviceSessionStrategy rename, and the new/changed error codes. Fix the CRN migration example: the breaking change landed after 0.38.0 (published May 26 with the old region-string API), so 'Before' is 0.38.x and 'After' is 0.39.0 (not 0.35.x / 0.40.0). Record-only: 0.40.0 is already on npm, so no publish follows this PR. (freshtonic review cipherstash/cipherstash-suite#2057) --- .../typescript/packages/auth/CHANGELOG.md | 46 ++++++++++--------- 1 file changed, 25 insertions(+), 21 deletions(-) diff --git a/languages/typescript/packages/auth/CHANGELOG.md b/languages/typescript/packages/auth/CHANGELOG.md index 951ff295b..2c7211b6f 100644 --- a/languages/typescript/packages/auth/CHANGELOG.md +++ b/languages/typescript/packages/auth/CHANGELOG.md @@ -4,6 +4,29 @@ ### New Features +- **`OidcFederationStrategy` `baseUrl` override** — both `create` and + `createWithStore` now accept an optional trailing `baseUrl` that pins a single + strategy instance to a specific CTS host, taking precedence over the + `CS_CTS_HOST` environment variable and region service discovery. + + ```ts + OidcFederationStrategy.createWithStore( + workspaceCrn, getJwt, loadToken, saveToken, + "http://localhost:4000", // baseUrl — federate against a mock / self-hosted CTS + ); + ``` + + Unlike `CS_CTS_HOST`, the override is scoped to that strategy alone, so it + doesn't redirect other CTS clients sharing the process (e.g. a `protect-ffi` + encryption client). On `wasm-inline` it's the `baseUrl` field of the options + object (`{ store?, baseUrl? }`); in the wasm runtime — which can't read + `CS_CTS_HOST` from the environment — it's the only way to target a host other + than the region-discovered one. + +## 0.39.0 + +### New Features + - **`OidcFederationStrategy`** — federate a third-party OIDC JWT (Clerk, Supabase, …) into a CipherStash CTS service token via `/api/authorise`. Exposed on both the napi and `wasm-inline` entrypoints, with a `getJwt` @@ -30,25 +53,6 @@ ); ``` -- **`OidcFederationStrategy` `baseUrl` override** — both `create` and - `createWithStore` now accept an optional trailing `baseUrl` that pins a single - strategy instance to a specific CTS host, taking precedence over the - `CS_CTS_HOST` environment variable and region service discovery. - - ```ts - OidcFederationStrategy.createWithStore( - workspaceCrn, getJwt, loadToken, saveToken, - "http://localhost:4000", // baseUrl — federate against a mock / self-hosted CTS - ); - ``` - - Unlike `CS_CTS_HOST`, the override is scoped to that strategy alone, so it - doesn't redirect other CTS clients sharing the process (e.g. a `protect-ffi` - encryption client). On `wasm-inline` it's the `baseUrl` field of the options - object (`{ store?, baseUrl? }`); in the wasm runtime — which can't read - `CS_CTS_HOST` from the environment — it's the only way to target a host other - than the region-discovered one. - ### Breaking Changes - **`AccessKeyStrategy.create(workspaceCrn, accessKey)`** — the first argument @@ -57,10 +61,10 @@ disagrees with the workspace it was pointed at. ```ts - // Before (0.35.x) + // Before (0.38.x) const strategy = AccessKeyStrategy.create("ap-southeast-2.aws", "CSAKid.secret"); - // After (0.40.0) + // After (0.39.0) const strategy = AccessKeyStrategy.create( "crn:ap-southeast-2.aws:ZVATKW3VHMFG27DY", "CSAKid.secret", From 4dc192654d282446acfa5e9d3e39d1dc9da61d1b Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 29 Jun 2026 06:22:57 +0000 Subject: [PATCH 314/686] chore: release --- packages/stack-auth/CHANGELOG.md | 1 + packages/stack-auth/Cargo.toml | 2 +- packages/stack-profile/CHANGELOG.md | 5 +++++ packages/stack-profile/Cargo.toml | 2 +- 4 files changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/stack-auth/CHANGELOG.md b/packages/stack-auth/CHANGELOG.md index 589f5f6ab..9b6cfa5fe 100644 --- a/packages/stack-auth/CHANGELOG.md +++ b/packages/stack-auth/CHANGELOG.md @@ -1,5 +1,6 @@ + ### CI - make the CRAP workflow blocking diff --git a/packages/stack-auth/Cargo.toml b/packages/stack-auth/Cargo.toml index d35b52977..20d96059b 100644 --- a/packages/stack-auth/Cargo.toml +++ b/packages/stack-auth/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "stack-auth" description = "Authentication library for CipherStash services" -version = "0.38.0" +version = "0.38.1" edition.workspace = true authors.workspace = true repository.workspace = true diff --git a/packages/stack-profile/CHANGELOG.md b/packages/stack-profile/CHANGELOG.md index a0e4792bf..39842f0e2 100644 --- a/packages/stack-profile/CHANGELOG.md +++ b/packages/stack-profile/CHANGELOG.md @@ -30,6 +30,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 + +### Documentation + +- fix stale 0.34.0-alpha.1 changelog headers + ## [0.34.0] - 2026-03-04 ### Changed diff --git a/packages/stack-profile/Cargo.toml b/packages/stack-profile/Cargo.toml index 1356fd7b2..f765045e6 100644 --- a/packages/stack-profile/Cargo.toml +++ b/packages/stack-profile/Cargo.toml @@ -2,7 +2,7 @@ name = "stack-profile" description = "Centralised ~/.cipherstash profile file management" license-file = "LICENSE" -version = "0.38.0" +version = "0.38.1" edition.workspace = true authors.workspace = true repository.workspace = true From 5649a79718c97ab77fb1f983670b218ab117ca99 Mon Sep 17 00:00:00 2001 From: James Sadler Date: Thu, 18 Jun 2026 16:33:47 +1000 Subject: [PATCH 315/686] test(stack-auth): port re-lock-window cancellation regression test (CIP-3159) Ports cancellation_in_relock_window_does_not_strand_refresh from Proxy's vendored copy of this crate. The existing stress_tests land cancellation during the HTTP call or the slow store save; this one holds the state lock to land cancellation on the post-HTTP state.lock() re-acquire and asserts refresh_in_progress directly. Verified it catches a regression the existing tests miss: with defuse() moved between save_refreshed_token and the re-lock, this test FAILS while save_phase_cancellation_does_not_strand_in_progress_flag still passes. Test-only change. --- packages/stack-auth/src/auto_refresh.rs | 119 ++++++++++++++++++++++++ 1 file changed, 119 insertions(+) diff --git a/packages/stack-auth/src/auto_refresh.rs b/packages/stack-auth/src/auto_refresh.rs index b3cadf6a7..1e1949f7b 100644 --- a/packages/stack-auth/src/auto_refresh.rs +++ b/packages/stack-auth/src/auto_refresh.rs @@ -1882,3 +1882,122 @@ mod expiry_crossing_regression { assert_eq!(strategy.get_token().await.unwrap().as_str(), "fresh"); } } + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod regression_cip_3159 { + use super::*; + use crate::access_key_refresher::AccessKeyRefresher; + use crate::SecretToken; + use std::sync::atomic::Ordering; + use std::sync::Arc; + use std::time::{Duration, SystemTime, UNIX_EPOCH}; + + /// `/api/authorise` handler that sleeps `delay` before returning a valid + /// access-key token response (with an ABSOLUTE-epoch `expiry`, as CTS + /// returns), giving the test a window to cancel in. + async fn delayed_authorise_handler( + axum::extract::State(delay): axum::extract::State, + ) -> axum::Json { + tokio::time::sleep(delay).await; + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs(); + axum::Json(serde_json::json!({ + "accessToken": "refreshed-token", + "expiry": now + 3600 + })) + } + + async fn start_authorise_server(delay: Duration) -> url::Url { + let app = axum::Router::new() + .route( + "/api/authorise", + axum::routing::post(delayed_authorise_handler), + ) + .with_state(delay); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { + axum::serve(listener, app).await.unwrap(); + }); + url::Url::parse(&format!("http://{addr}")).unwrap() + } + + /// is_expired() == true (within the 90s leeway, so `get_token` refreshes), + /// but is_usable() == true for `secs_until_expiry` (so it takes the + /// non-blocking path). + fn expiring_but_usable_token(access: &str, secs_until_expiry: u64) -> Token { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs(); + Token { + access_token: SecretToken::new(access), + token_type: "Bearer".to_string(), + expires_at: now + secs_until_expiry, + refresh_token: None, + region: None, + client_id: None, + device_instance_id: None, + } + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 4)] + async fn cancellation_in_relock_window_does_not_strand_refresh() { + let http_delay = Duration::from_millis(400); + let base_url = start_authorise_server(http_delay).await; + + let strategy = Arc::new(AutoRefresh::with_token( + AccessKeyRefresher::new( + SecretToken::new("CSAKtestKeyId.testKeySecret"), + base_url, + None, + ), + expiring_but_usable_token("old-usable", 2), + )); + + // Caller A drives the refresh: it locks state, sets the in-progress + // flag, drops the lock, then awaits the (slow) HTTP authorise call. + let a = Arc::clone(&strategy); + let handle = tokio::spawn(async move { a.get_token().await }); + + // Let A reach the HTTP await, then take the state lock so that when A's + // request completes it parks on its post-HTTP `state.lock().await` + // instead of installing the new token. + tokio::time::sleep(Duration::from_millis(100)).await; + let held = strategy.state.lock().await; + + // A's HTTP completes (~400ms) and blocks on the lock we hold. + tokio::time::sleep(http_delay + Duration::from_millis(200)).await; + assert!( + strategy.refresh_in_progress.load(Ordering::Acquire), + "precondition: a refresh should be in flight while caller A is parked", + ); + + // Cancel A precisely in the post-HTTP, pre-install window. + handle.abort(); + let _ = handle.await; + drop(held); + + // The CancelGuard's Drop must have cleared the flag on cancellation. + // Pre-fix, defuse() ran before the re-lock, so this stays `true`. + assert!( + !strategy.refresh_in_progress.load(Ordering::Acquire), + "refresh_in_progress stranded `true` after cancellation in the re-lock window (CIP-3159)", + ); + + // End-to-end: once the cached token crosses real expiry, a stranded flag + // would route the next caller into wait_for_in_flight_refresh and hang on + // a notify that never comes. With the fix, the caller re-authenticates. + tokio::time::sleep(Duration::from_millis(2100)).await; + let b = Arc::clone(&strategy); + let result = + tokio::time::timeout(Duration::from_secs(3), async move { b.get_token().await }).await; + assert!( + matches!(result, Ok(Ok(_))), + "get_token() hung or failed after cancellation — refresh wedged (CIP-3159): {result:?}", + ); + } +} From b827028c0b8cb0f0600b7f3474cef8584c21df09 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 4 Jul 2026 06:07:22 +0000 Subject: [PATCH 316/686] chore(deps-dev): bump vite in /packages/stack-auth/node Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 7.3.2 to 7.3.6. - [Release notes](https://github.com/vitejs/vite/releases) - [Changelog](https://github.com/vitejs/vite/blob/v7.3.6/packages/vite/CHANGELOG.md) - [Commits](https://github.com/vitejs/vite/commits/v7.3.6/packages/vite) --- updated-dependencies: - dependency-name: vite dependency-version: 7.3.5 dependency-type: indirect ... Signed-off-by: dependabot[bot] --- languages/typescript/packages/auth/package-lock.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/languages/typescript/packages/auth/package-lock.json b/languages/typescript/packages/auth/package-lock.json index b9dc7a54b..35c986fad 100644 --- a/languages/typescript/packages/auth/package-lock.json +++ b/languages/typescript/packages/auth/package-lock.json @@ -1455,13 +1455,13 @@ } }, "node_modules/vite": { - "version": "7.3.2", - "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.2.tgz", - "integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==", + "version": "7.3.6", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz", + "integrity": "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==", "dev": true, "license": "MIT", "dependencies": { - "esbuild": "^0.27.0", + "esbuild": "^0.27.0 || ^0.28.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", From 8e622a4acdc17ab434a321578cfae8f10f24b71c Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Mon, 22 Jun 2026 13:30:57 +1000 Subject: [PATCH 317/686] feat(stack-auth): add actionable miette help to AuthError variants MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AuthError derives miette::Diagnostic (rendered by the CLI's miette::Result main) but carried no `#[diagnostic(help)]`, so the CLI showed bare messages. Add actionable help to the variants where it guides a fix — Region, InvalidCrn, WorkspaceMismatch, MissingWorkspaceCrn, NotAuthenticated, InvalidAccessKey. error_code() stays the FFI source of truth (its exhaustive match already forces a code per variant); the help text is not duplicated into it. Adds a test pinning that the annotated variants expose help via Diagnostic. --- packages/stack-auth/src/lib.rs | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/packages/stack-auth/src/lib.rs b/packages/stack-auth/src/lib.rs index 1dfcbf42a..aedc83917 100644 --- a/packages/stack-auth/src/lib.rs +++ b/packages/stack-auth/src/lib.rs @@ -318,14 +318,21 @@ pub enum AuthError { InvalidUrl(#[from] url::ParseError), /// The requested region is not supported. #[error("Unsupported region: {0}")] + #[diagnostic(help("Use a supported region, e.g. `ap-southeast-2.aws`."))] Region(#[from] cts_common::RegionError), /// The workspace CRN could not be parsed. #[error("Invalid workspace CRN: {0}")] + #[diagnostic(help( + "A workspace CRN looks like `crn::`, e.g. `crn:ap-southeast-2.aws:ZVATKW3VHMFG27DY`." + ))] InvalidCrn(cts_common::InvalidCrn), /// The token issued by the auth server is for a different workspace than /// the one configured on the strategy. Surfaces when the access key was /// minted for a different workspace, or when the wrong CRN was passed. #[error("Workspace mismatch: token issued for {token_workspace}, but strategy is configured for {expected_workspace}")] + #[diagnostic(help( + "The access key or workspace CRN is scoped to a different workspace than the one requested — check which workspace the credential belongs to." + ))] WorkspaceMismatch { /// The workspace the strategy was configured for (from the CRN). expected_workspace: cts_common::WorkspaceId, @@ -340,15 +347,22 @@ pub enum AuthError { /// Set the `CS_WORKSPACE_CRN` environment variable or call /// [`AutoStrategyBuilder::with_workspace_crn`](crate::AutoStrategyBuilder::with_workspace_crn). #[error("Workspace CRN is required when using an access key — set CS_WORKSPACE_CRN or call AutoStrategyBuilder::with_workspace_crn")] + #[diagnostic(help( + "Set the `CS_WORKSPACE_CRN` environment variable, or pass the CRN via `AutoStrategyBuilder::with_workspace_crn`." + ))] MissingWorkspaceCrn, /// No credentials are available (e.g. not logged in, no access key configured). #[error("Not authenticated")] + #[diagnostic(help( + "Log in with `stash login`, or set `CS_CLIENT_ACCESS_KEY` for service-to-service auth." + ))] NotAuthenticated, /// A token (access token or device code) has expired. #[error("Token expired")] TokenExpired, /// The access key string is malformed (e.g. missing `CSAK` prefix or `.` separator). #[error("Invalid access key: {0}")] + #[diagnostic(help("Access keys have the form `CSAK.`."))] InvalidAccessKey(#[from] access_key::InvalidAccessKey), /// The JWT could not be decoded or its claims are malformed. #[error("Invalid token: {0}")] @@ -538,4 +552,20 @@ mod tests { assert_eq!(err.error_code(), expected, "error_code for {err:?}"); } } + + /// The variants annotated with `#[diagnostic(help(..))]` must surface that + /// help through `miette::Diagnostic` — this is what the CLI renders below + /// the error message. Pins a representative one so the annotation can't be + /// dropped silently. + #[test] + fn annotated_variants_expose_diagnostic_help() { + use miette::Diagnostic; + + let err = AuthError::NotAuthenticated; + let help = err.help().map(|h| h.to_string()); + assert!( + help.as_deref().is_some_and(|h| h.contains("stash login")), + "NotAuthenticated should carry actionable help, got: {help:?}", + ); + } } From 4cda553681234d7daab6572dadbe8dba06a81209 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Mon, 22 Jun 2026 13:31:11 +1000 Subject: [PATCH 318/686] refactor(stack-auth): hand-written index.d.ts re-exporter, drop apply-dts script MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit NAPI-RS can't emit AuthError/AuthErrorCode/OAuthStrategy — they describe *thrown* values, not function signatures — so they were re-injected into the generated index.d.ts by a post-build script (apply-dts-additions.mjs). Replace that build-time mutation with a stable structure: - `napi build --dts native.d.ts` writes the generated bindings to native.d.ts. - index.d.ts is now hand-written and committed: `export * from "./native"` plus the three curated declarations. napi never touches it, so nothing to re-apply. - Delete apply-dts-additions.mjs + its test; drop the apply-dts call from the build scripts; ship native.d.ts in `files`. - Add a stack-auth-node drift test asserting the AuthErrorCode union in index.d.ts matches AuthError::error_code() (+ UNKNOWN_ERROR). Verified: napi build emits native.d.ts, `tsc --noEmit` resolves `AuthError` through the re-export, 40 vitest + 19 node Rust tests pass, biome/clippy/fmt clean. --- .../__tests__/apply-dts-additions.test.ts | 101 ---------- languages/typescript/packages/auth/index.d.ts | 179 ++---------------- .../typescript/packages/auth/native.d.ts | 165 ++++++++++++++++ .../typescript/packages/auth/package.json | 5 +- .../auth/scripts/apply-dts-additions.mjs | 110 ----------- languages/typescript/packages/auth/src/lib.rs | 51 +++++ 6 files changed, 235 insertions(+), 376 deletions(-) delete mode 100644 languages/typescript/packages/auth/__tests__/apply-dts-additions.test.ts create mode 100644 languages/typescript/packages/auth/native.d.ts delete mode 100644 languages/typescript/packages/auth/scripts/apply-dts-additions.mjs diff --git a/languages/typescript/packages/auth/__tests__/apply-dts-additions.test.ts b/languages/typescript/packages/auth/__tests__/apply-dts-additions.test.ts deleted file mode 100644 index e2ee22c5a..000000000 --- a/languages/typescript/packages/auth/__tests__/apply-dts-additions.test.ts +++ /dev/null @@ -1,101 +0,0 @@ -import { execFileSync } from "node:child_process"; -import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { fileURLToPath } from "node:url"; -import { afterEach, beforeEach, describe, expect, it } from "vitest"; - -// Guards the index.d.ts normaliser that re-applies the hand-curated additions -// NAPI-RS can't emit (`AuthError`/`AuthErrorCode`/`OAuthStrategy`). If this -// script silently regresses, those types vanish from the published package -// again (the exact bug it was written to fix), so its re-inject / idempotency -// behaviour is pinned here. - -const SCRIPT = fileURLToPath( - new URL("../scripts/apply-dts-additions.mjs", import.meta.url), -); - -// A stand-in for `napi build` output: the NAPI-RS header plus a regular class -// (so the appended `OAuthStrategy` alias has a referent). -const GENERATED = `/* tslint:disable */ -/* eslint-disable */ - -/* auto-generated by NAPI-RS */ - -export declare class DeviceSessionStrategy { - static fromProfile(): DeviceSessionStrategy - getToken(): Promise -} -`; - -const MANUAL_SYMBOLS = [ - "export type AuthErrorCode", - "export interface AuthError extends Error", - "export declare const OAuthStrategy", -]; - -describe("apply-dts-additions", () => { - let dir: string; - let dts: string; - - beforeEach(() => { - dir = mkdtempSync(join(tmpdir(), "apply-dts-")); - dts = join(dir, "index.d.ts"); - }); - - afterEach(() => { - rmSync(dir, { recursive: true, force: true }); - }); - - function run() { - execFileSync("node", [SCRIPT, dts], { encoding: "utf8" }); - return readFileSync(dts, "utf8"); - } - - it("re-injects the curated additions a build drops", () => { - writeFileSync(dts, GENERATED); - const out = run(); - for (const sym of MANUAL_SYMBOLS) { - expect(out, `should re-inject ${sym}`).toContain(sym); - } - // The header is annotated so it's clear the file isn't pristine NAPI-RS output. - expect(out).toContain("with manual additions"); - }); - - it("preserves generated declarations", () => { - writeFileSync(dts, GENERATED); - const out = run(); - expect(out).toContain("export declare class DeviceSessionStrategy"); - }); - - it("is idempotent — a second run is a no-op", () => { - writeFileSync(dts, GENERATED); - const first = run(); - const second = run(); - expect(second).toBe(first); - }); - - it("does not corrupt a file with a dangling BEGIN marker (no END)", () => { - // Exercises stripManualBlock's defensive branch: a BEGIN marker with no - // matching END means the file was hand-truncated mid-block. The contract is - // "leave the existing content untouched rather than corrupt it" — a - // regression that sliced from BEGIN to EOF would drop everything after the - // marker. We assert the content following the dangling marker survives. - const dangling = `/* tslint:disable */ -/* eslint-disable */ - -/* auto-generated by NAPI-RS */ - -export declare class DeviceSessionStrategy {} - -// --- BEGIN manual additions (scripts/apply-dts-additions.mjs) --- -export declare const DANGLING_SENTINEL: string -`; - writeFileSync(dts, dangling); - const out = run(); - // The text after the dangling BEGIN must not be sliced away. - expect(out).toContain("DANGLING_SENTINEL"); - // And the real declaration ahead of it is still there. - expect(out).toContain("export declare class DeviceSessionStrategy"); - }); -}); diff --git a/languages/typescript/packages/auth/index.d.ts b/languages/typescript/packages/auth/index.d.ts index 5aad8c8ef..38e55214d 100644 --- a/languages/typescript/packages/auth/index.d.ts +++ b/languages/typescript/packages/auth/index.d.ts @@ -1,173 +1,27 @@ /* tslint:disable */ /* eslint-disable */ -/* auto-generated by NAPI-RS, with manual additions (scripts/apply-dts-additions.mjs) */ - -/** - * The result of a successful `getToken()` call. +/* + * Hand-written — NOT regenerated by `napi build`. * - * Contains the bearer credential and decoded JWT claims for service discovery. - */ -export interface TokenResult { - /** The bearer token string (used as `Authorization: Bearer `). */ - token: string - /** The subject claim from the JWT (e.g. `"CS|auth0|user123"` or `"CS|CSAKkeyId"`). */ - subject: string - /** The workspace identifier from the JWT. */ - workspaceId: string - /** The issuer URL from the JWT `iss` claim (i.e. the CTS host). */ - issuer: string - /** Service endpoint URLs from the JWT `services` claim (e.g. `{ zerokms: "https://..." }`). */ - services: Record -} -/** Options for `AutoStrategy.detect()`. */ -export interface AutoStrategyOptions { - /** An explicit access key (takes precedence over `CS_CLIENT_ACCESS_KEY` env var). */ - accessKey?: string - /** An explicit workspace CRN (takes precedence over `CS_WORKSPACE_CRN` env var). */ - workspaceCrn?: string -} -/** - * Metadata returned after a successful device code authentication. + * `napi build --dts native.d.ts` writes the generated bindings to `native.d.ts`; + * this file re-exports them and adds the declarations NAPI-RS can't emit from + * the Rust types, because they describe *thrown* values rather than function + * signatures: * - * The actual token is never exposed to JavaScript — it is saved directly - * to `~/.cipherstash/auth.json` by the Rust layer. - */ -export interface AuthResult { - /** Absolute epoch timestamp (seconds) when the token expires. */ - expiresAt: number - /** Number of seconds before the token expires (computed at time of return). */ - expiresIn: number -} -/** - * Provision a device client in ZeroKMS after login. - * - * Loads the auth token and device identity from `~/.cipherstash/`, - * creates a client on the workspace's default keyset, and persists the - * resulting secret key to `~/.cipherstash/secretkey.json`. - * - * This is a no-op if the secret key already exists or the server returns - * 409 (conflict). - */ -export declare function bindClientDevice(): Promise -/** Begin the OAuth 2.0 Device Authorization flow. */ -export declare function beginDeviceCodeFlow(region: string, clientId: string): Promise -/** - * An auth strategy that auto-detects credentials from environment variables - * and the local profile store. + * - `AuthError` / `AuthErrorCode` — errors thrown by this package are tagged + * with a machine-readable `.code` at runtime by `index.js`. + * - `OAuthStrategy` — a deprecated alias exported at runtime by `index.js`. * - * Detection order: - * 1. `CS_CLIENT_ACCESS_KEY` env var (or explicit `accessKey` option) → access key auth - * 2. `~/.cipherstash/auth.json` → OAuth token auth - * 3. Error: not authenticated + * `AuthErrorCode` mirrors `AuthError::error_code()` in + * `packages/stack-auth/src/lib.rs` (plus `UNKNOWN_ERROR`, the `index.js` + * fallback). The `stack-auth-node` test `ts_auth_error_code_union_matches_error_codes` + * guards this union against drift. */ -export declare class AutoStrategy { - /** - * Detect available credentials and return an `AutoStrategy`. - * - * Pass options to provide explicit values that take precedence over - * environment variables. - */ - static detect(options?: AutoStrategyOptions | undefined | null): AutoStrategy - /** Retrieve a valid access token, refreshing or re-authenticating as needed. */ - getToken(): Promise -} -/** - * An auth strategy that uses a static access key for service-to-service - * or CI/CD authentication. - */ -export declare class AccessKeyStrategy { - /** - * Create a new `AccessKeyStrategy` for the given workspace CRN and - * access key. - * - * The CRN format is `crn::` (e.g. - * `"crn:ap-southeast-2.aws:ZVATKW3VHMFG27DY"`). Region is parsed - * from the CRN and used for service discovery; the workspace ID is - * used to verify every issued token belongs to the right workspace. - * A mismatch fails `getToken()` with `code === "WORKSPACE_MISMATCH"`. - */ - static create(workspaceCrn: string, accessKey: string): AccessKeyStrategy - /** Retrieve a valid access token, refreshing or re-authenticating as needed. */ - getToken(): Promise -} -/** - * An auth strategy that uses OAuth refresh tokens persisted to disk - * (`~/.cipherstash/auth.json`). - */ -export declare class DeviceSessionStrategy { - /** Load credentials from the default profile store and create a `DeviceSessionStrategy`. */ - static fromProfile(): DeviceSessionStrategy - /** Retrieve a valid access token, refreshing as needed. */ - getToken(): Promise -} -/** - * An auth strategy that federates a third-party OIDC JWT (Clerk, Supabase, …) - * into a CipherStash CTS service token via `/api/authorise`. - */ -export declare class OidcFederationStrategy { - /** - * Create an `OidcFederationStrategy` for the given workspace CRN. - * - * The CRN format is `crn::` (e.g. - * `"crn:ap-southeast-2.aws:ZVATKW3VHMFG27DY"`). Region is parsed from - * the CRN and used for service discovery; the workspace ID is used to - * verify every federated token belongs to the right workspace. - * - * `getJwt` is called on every federation — initial auth and every - * re-federation after the CTS token expires — and must return - * `Promise` resolving to the *current* third-party OIDC JWT. - * - * `baseUrl`, when supplied, pins this strategy to a specific CTS host — - * e.g. a self-hosted CTS or a local mock auth server. It takes precedence - * over the `CS_CTS_HOST` environment variable and region service - * discovery, and is scoped to this strategy alone (unlike `CS_CTS_HOST`, - * which redirects every CTS client in the process). - */ - static create(workspaceCrn: string, getJwt: () => any, baseUrl?: string | undefined | null): OidcFederationStrategy - /** - * Create an `OidcFederationStrategy` backed by external token-store callbacks. - * - * Behaves like `create` but persists the federated CTS - * token through `loadToken` (`() => Promise`) - * and `saveToken` (`(json: string) => Promise`) — e.g. an HTTP-only - * cookie — so a federated token survives across requests without - * re-federating. - * - * `baseUrl` behaves as in `create` — an explicit, - * strategy-scoped CTS host that overrides `CS_CTS_HOST` and service - * discovery. - */ - static createWithStore(workspaceCrn: string, getJwt: () => any, loadToken: () => any, saveToken: (arg: string) => any, baseUrl?: string | undefined | null): OidcFederationStrategy - /** Retrieve a valid CTS service token, federating or re-federating as needed. */ - getToken(): Promise -} -export declare class DeviceCodeResult { - get userCode(): string - get verificationUri(): string - get verificationUriComplete(): string - get expiresIn(): number - /** - * Poll the auth server until the user completes authorization. - * - * **Consumes** the internal handle — it cannot be reused after this call. - * If you need to open the browser, call `openInBrowser` *before* - * `pollForToken`. - */ - pollForToken(): Promise - /** - * Open the verification URI in the user's default browser. - * - * Does **not** consume the handle — you can still call `pollForToken` - * afterwards. - */ - openInBrowser(): boolean -} -// --- BEGIN manual additions (scripts/apply-dts-additions.mjs) --- -// -// Re-applied after every `napi build` by scripts/apply-dts-additions.mjs — -// NAPI-RS does not emit these. Edit them there, not here. +export * from "./native"; + +import type { DeviceSessionStrategy } from "./native"; /** Error codes attached to errors thrown by this package. */ export type AuthErrorCode = @@ -202,4 +56,3 @@ export interface AuthError extends Error { * @deprecated Renamed to `DeviceSessionStrategy`. */ export declare const OAuthStrategy: typeof DeviceSessionStrategy -// --- END manual additions --- diff --git a/languages/typescript/packages/auth/native.d.ts b/languages/typescript/packages/auth/native.d.ts new file mode 100644 index 000000000..0508eb3e2 --- /dev/null +++ b/languages/typescript/packages/auth/native.d.ts @@ -0,0 +1,165 @@ +/* tslint:disable */ +/* eslint-disable */ + +/* auto-generated by NAPI-RS */ + +/** + * The result of a successful `getToken()` call. + * + * Contains the bearer credential and decoded JWT claims for service discovery. + */ +export interface TokenResult { + /** The bearer token string (used as `Authorization: Bearer `). */ + token: string + /** The subject claim from the JWT (e.g. `"CS|auth0|user123"` or `"CS|CSAKkeyId"`). */ + subject: string + /** The workspace identifier from the JWT. */ + workspaceId: string + /** The issuer URL from the JWT `iss` claim (i.e. the CTS host). */ + issuer: string + /** Service endpoint URLs from the JWT `services` claim (e.g. `{ zerokms: "https://..." }`). */ + services: Record +} +/** Options for `AutoStrategy.detect()`. */ +export interface AutoStrategyOptions { + /** An explicit access key (takes precedence over `CS_CLIENT_ACCESS_KEY` env var). */ + accessKey?: string + /** An explicit workspace CRN (takes precedence over `CS_WORKSPACE_CRN` env var). */ + workspaceCrn?: string +} +/** + * Metadata returned after a successful device code authentication. + * + * The actual token is never exposed to JavaScript — it is saved directly + * to `~/.cipherstash/auth.json` by the Rust layer. + */ +export interface AuthResult { + /** Absolute epoch timestamp (seconds) when the token expires. */ + expiresAt: number + /** Number of seconds before the token expires (computed at time of return). */ + expiresIn: number +} +/** + * Provision a device client in ZeroKMS after login. + * + * Loads the auth token and device identity from `~/.cipherstash/`, + * creates a client on the workspace's default keyset, and persists the + * resulting secret key to `~/.cipherstash/secretkey.json`. + * + * This is a no-op if the secret key already exists or the server returns + * 409 (conflict). + */ +export declare function bindClientDevice(): Promise +/** Begin the OAuth 2.0 Device Authorization flow. */ +export declare function beginDeviceCodeFlow(region: string, clientId: string): Promise +/** + * An auth strategy that auto-detects credentials from environment variables + * and the local profile store. + * + * Detection order: + * 1. `CS_CLIENT_ACCESS_KEY` env var (or explicit `accessKey` option) → access key auth + * 2. `~/.cipherstash/auth.json` → OAuth token auth + * 3. Error: not authenticated + */ +export declare class AutoStrategy { + /** + * Detect available credentials and return an `AutoStrategy`. + * + * Pass options to provide explicit values that take precedence over + * environment variables. + */ + static detect(options?: AutoStrategyOptions | undefined | null): AutoStrategy + /** Retrieve a valid access token, refreshing or re-authenticating as needed. */ + getToken(): Promise +} +/** + * An auth strategy that uses a static access key for service-to-service + * or CI/CD authentication. + */ +export declare class AccessKeyStrategy { + /** + * Create a new `AccessKeyStrategy` for the given workspace CRN and + * access key. + * + * The CRN format is `crn::` (e.g. + * `"crn:ap-southeast-2.aws:ZVATKW3VHMFG27DY"`). Region is parsed + * from the CRN and used for service discovery; the workspace ID is + * used to verify every issued token belongs to the right workspace. + * A mismatch fails `getToken()` with `code === "WORKSPACE_MISMATCH"`. + */ + static create(workspaceCrn: string, accessKey: string): AccessKeyStrategy + /** Retrieve a valid access token, refreshing or re-authenticating as needed. */ + getToken(): Promise +} +/** + * An auth strategy that uses OAuth refresh tokens persisted to disk + * (`~/.cipherstash/auth.json`). + */ +export declare class DeviceSessionStrategy { + /** Load credentials from the default profile store and create a `DeviceSessionStrategy`. */ + static fromProfile(): DeviceSessionStrategy + /** Retrieve a valid access token, refreshing as needed. */ + getToken(): Promise +} +/** + * An auth strategy that federates a third-party OIDC JWT (Clerk, Supabase, …) + * into a CipherStash CTS service token via `/api/authorise`. + */ +export declare class OidcFederationStrategy { + /** + * Create an `OidcFederationStrategy` for the given workspace CRN. + * + * The CRN format is `crn::` (e.g. + * `"crn:ap-southeast-2.aws:ZVATKW3VHMFG27DY"`). Region is parsed from + * the CRN and used for service discovery; the workspace ID is used to + * verify every federated token belongs to the right workspace. + * + * `getJwt` is called on every federation — initial auth and every + * re-federation after the CTS token expires — and must return + * `Promise` resolving to the *current* third-party OIDC JWT. + * + * `baseUrl`, when supplied, pins this strategy to a specific CTS host — + * e.g. a self-hosted CTS or a local mock auth server. It takes precedence + * over the `CS_CTS_HOST` environment variable and region service + * discovery, and is scoped to this strategy alone (unlike `CS_CTS_HOST`, + * which redirects every CTS client in the process). + */ + static create(workspaceCrn: string, getJwt: () => any, baseUrl?: string | undefined | null): OidcFederationStrategy + /** + * Create an `OidcFederationStrategy` backed by external token-store callbacks. + * + * Behaves like `create` but persists the federated CTS + * token through `loadToken` (`() => Promise`) + * and `saveToken` (`(json: string) => Promise`) — e.g. an HTTP-only + * cookie — so a federated token survives across requests without + * re-federating. + * + * `baseUrl` behaves as in `create` — an explicit, + * strategy-scoped CTS host that overrides `CS_CTS_HOST` and service + * discovery. + */ + static createWithStore(workspaceCrn: string, getJwt: () => any, loadToken: () => any, saveToken: (arg: string) => any, baseUrl?: string | undefined | null): OidcFederationStrategy + /** Retrieve a valid CTS service token, federating or re-federating as needed. */ + getToken(): Promise +} +export declare class DeviceCodeResult { + get userCode(): string + get verificationUri(): string + get verificationUriComplete(): string + get expiresIn(): number + /** + * Poll the auth server until the user completes authorization. + * + * **Consumes** the internal handle — it cannot be reused after this call. + * If you need to open the browser, call `openInBrowser` *before* + * `pollForToken`. + */ + pollForToken(): Promise + /** + * Open the verification URI in the user's default browser. + * + * Does **not** consume the handle — you can still call `pollForToken` + * afterwards. + */ + openInBrowser(): boolean +} diff --git a/languages/typescript/packages/auth/package.json b/languages/typescript/packages/auth/package.json index 7a443d5bc..e2b05cfd4 100644 --- a/languages/typescript/packages/auth/package.json +++ b/languages/typescript/packages/auth/package.json @@ -45,6 +45,7 @@ "files": [ "index.js", "index.d.ts", + "native.d.ts", "README.md", "stack-auth-node.js", "wasm-types.d.ts", @@ -55,8 +56,8 @@ "wasm/" ], "scripts": { - "build": "napi build --release && node scripts/apply-dts-additions.mjs", - "build:debug": "napi build && node scripts/apply-dts-additions.mjs", + "build": "napi build --release --dts native.d.ts", + "build:debug": "napi build --dts native.d.ts", "build:wasm": "cd ../wasm && wasm-pack build --target bundler --out-dir ../node/wasm && rm -f ../node/wasm/README.md ../node/wasm/LICENSE ../node/wasm/.gitignore && echo '{\"type\":\"module\"}' > ../node/wasm/package.json && node ../node/scripts/inline-wasm.mjs", "test": "npm run build:debug && vitest run", "format": "npx --yes @biomejs/biome@2.3.4 format --write .", diff --git a/languages/typescript/packages/auth/scripts/apply-dts-additions.mjs b/languages/typescript/packages/auth/scripts/apply-dts-additions.mjs deleted file mode 100644 index 33f3f0189..000000000 --- a/languages/typescript/packages/auth/scripts/apply-dts-additions.mjs +++ /dev/null @@ -1,110 +0,0 @@ -#!/usr/bin/env node -// -// Re-apply the hand-curated additions to the NAPI-RS-generated `index.d.ts`. -// -// `napi build` regenerates `index.d.ts` from scratch on every run, dropping the -// declarations NAPI-RS can't emit from the Rust types: the `AuthError` / -// `AuthErrorCode` error-enrichment types (errors are tagged with a `.code` at -// runtime by `index.js`) and the deprecated `OAuthStrategy` alias (exported at -// runtime as `module.exports.OAuthStrategy`). Without these, consumers doing -// `import type { AuthError } from "@cipherstash/auth"` stop compiling even -// though the runtime values still exist. -// -// Running this after every build keeps the committed/published `index.d.ts` -// correct. It is idempotent: safe to run repeatedly, a no-op on an -// already-normalised file. Wired into the `build` and `build:debug` npm scripts. - -import { readFileSync, writeFileSync } from "node:fs"; -import { dirname, join, resolve } from "node:path"; -import { fileURLToPath } from "node:url"; - -const packageRoot = join(dirname(fileURLToPath(import.meta.url)), ".."); -// Optional path arg (used by tests); `resolve` honours both absolute and -// cwd-relative inputs. Defaults to the package's own `index.d.ts`. -const dtsPath = process.argv[2] - ? resolve(process.argv[2]) - : join(packageRoot, "index.d.ts"); - -const BEGIN_MARKER = - "// --- BEGIN manual additions (scripts/apply-dts-additions.mjs) ---"; -const END_MARKER = "// --- END manual additions ---"; - -// The curated block. `AuthErrorCode` mirrors `AuthError::error_code()` in -// `packages/stack-auth/src/lib.rs` (plus `UNKNOWN_ERROR`, the `index.js` -// fallback when a thrown error carries no recognised code). Keep in sync if a -// new `AuthError` variant is added. -const MANUAL_ADDITIONS = `${BEGIN_MARKER} -// -// Re-applied after every \`napi build\` by scripts/apply-dts-additions.mjs — -// NAPI-RS does not emit these. Edit them there, not here. - -/** Error codes attached to errors thrown by this package. */ -export type AuthErrorCode = - | 'REQUEST_ERROR' - | 'ACCESS_DENIED' - | 'EXPIRED_TOKEN' - | 'INVALID_GRANT' - | 'INVALID_CLIENT' - | 'INVALID_URL' - | 'INVALID_REGION' - | 'INVALID_TOKEN' - | 'SERVER_ERROR' - | 'STORE_ERROR' - | 'NOT_AUTHENTICATED' - | 'MISSING_WORKSPACE_CRN' - | 'INVALID_ACCESS_KEY' - | 'INVALID_CRN' - | 'WORKSPACE_MISMATCH' - | 'INVALID_WORKSPACE_ID' - | 'UNKNOWN_ERROR' - -/** An error thrown by this package, enriched with a machine-readable \`.code\`. */ -export interface AuthError extends Error { - code: AuthErrorCode -} - -/** - * Deprecated alias for {@link DeviceSessionStrategy}, exported at runtime as - * \`module.exports.OAuthStrategy = DeviceSessionStrategy\`. Kept so existing - * consumers don't break; will be removed in a future major release. - * - * @deprecated Renamed to \`DeviceSessionStrategy\`. - */ -export declare const OAuthStrategy: typeof DeviceSessionStrategy -${END_MARKER}`; - -/** Drop a previously-applied manual block (between the markers) so we can - * re-add a current copy — keeps the script idempotent. */ -function stripManualBlock(src) { - const begin = src.indexOf(BEGIN_MARKER); - if (begin === -1) return src; - const end = src.indexOf(END_MARKER, begin); - if (end === -1) return src; // malformed — leave untouched rather than corrupt - const before = src.slice(0, begin).replace(/\n+$/, "\n"); - const after = src.slice(end + END_MARKER.length).replace(/^\n+/, ""); - return after ? `${before}${after}` : before; -} - -function normalise(src) { - let out = stripManualBlock(src); - - // Collapse any blank-line runs a prior strip left behind. - out = out.replace(/\n{3,}/g, "\n\n"); - - // Note the manual additions in the header banner (best-effort). - out = out.replace( - "/* auto-generated by NAPI-RS */", - "/* auto-generated by NAPI-RS, with manual additions (scripts/apply-dts-additions.mjs) */", - ); - - return `${out.replace(/\n+$/, "")}\n\n${MANUAL_ADDITIONS}\n`; -} - -const original = readFileSync(dtsPath, "utf8"); -const updated = normalise(original); -if (updated !== original) { - writeFileSync(dtsPath, updated); - console.log(`apply-dts-additions: updated ${dtsPath}`); -} else { - console.log(`apply-dts-additions: ${dtsPath} already normalised`); -} diff --git a/languages/typescript/packages/auth/src/lib.rs b/languages/typescript/packages/auth/src/lib.rs index a3dd818a1..69abbf3cd 100644 --- a/languages/typescript/packages/auth/src/lib.rs +++ b/languages/typescript/packages/auth/src/lib.rs @@ -576,6 +576,57 @@ mod tests { use mocktail::prelude::*; use tempfile::TempDir; + /// `index.d.ts` is hand-written and re-exports the generated `native.d.ts`, + /// plus the `AuthErrorCode` union NAPI-RS can't emit. That union must list + /// exactly the codes `AuthError::error_code()` produces, plus + /// `UNKNOWN_ERROR` (the `index.js` fallback). This guards the two against + /// drift — adding an `AuthError` variant (which the exhaustive `error_code` + /// match forces a code for) without updating the union fails here. + /// + /// `EXPECTED` mirrors the codes pinned by stack-auth's + /// `auth_error_code_is_stable_for_every_variant`; keep all three in sync. + #[test] + fn ts_auth_error_code_union_matches_error_codes() { + use std::collections::BTreeSet; + + const EXPECTED: &[&str] = &[ + "REQUEST_ERROR", + "ACCESS_DENIED", + "EXPIRED_TOKEN", + "INVALID_GRANT", + "INVALID_CLIENT", + "INVALID_URL", + "INVALID_REGION", + "INVALID_TOKEN", + "SERVER_ERROR", + "STORE_ERROR", + "NOT_AUTHENTICATED", + "MISSING_WORKSPACE_CRN", + "INVALID_ACCESS_KEY", + "INVALID_CRN", + "WORKSPACE_MISMATCH", + "INVALID_WORKSPACE_ID", + "UNKNOWN_ERROR", + ]; + + // Extract the union members — lines of the form ` | 'CODE'`. + let dts = include_str!("../index.d.ts"); + let union: BTreeSet<&str> = dts + .lines() + .filter_map(|line| { + line.trim() + .strip_prefix("| '") + .and_then(|rest| rest.strip_suffix('\'')) + }) + .collect(); + + let expected: BTreeSet<&str> = EXPECTED.iter().copied().collect(); + assert_eq!( + union, expected, + "AuthErrorCode union in index.d.ts drifted from AuthError::error_code()", + ); + } + // --- Shared helpers --- fn device_code_json() -> serde_json::Value { From 9b64705e9fe077f63fe6be800ac1c40704dbce48 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Mon, 22 Jun 2026 20:58:02 +1000 Subject: [PATCH 319/686] test(stack-auth): derive drift-test expected set from error_code() source MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The AuthErrorCode union drift test compared index.d.ts against a hardcoded EXPECTED array — a parallel hand-kept list. As flagged in review, that didn't deliver its stated guarantee: a new AuthError variant added with its error_code arm (but neither the union nor EXPECTED updated) would pass both this test and the core auth_error_code_is_stable test, shipping a TS union missing the code. Derive the expected set by parsing error_code()'s match arms from the core crate's source instead. The match is exhaustive, so a new variant forces a new `=> "CODE"` arm there, which the test then requires the union to include — no parallel list to drift. UNKNOWN_ERROR (the index.js fallback) is the only explicit addition. Verified discriminating: dropping a code from the union now fails loudly because the expected set still derives it from error_code's source. --- languages/typescript/packages/auth/src/lib.rs | 75 ++++++++++++------- 1 file changed, 47 insertions(+), 28 deletions(-) diff --git a/languages/typescript/packages/auth/src/lib.rs b/languages/typescript/packages/auth/src/lib.rs index 69abbf3cd..bbbebcb6b 100644 --- a/languages/typescript/packages/auth/src/lib.rs +++ b/languages/typescript/packages/auth/src/lib.rs @@ -578,38 +578,58 @@ mod tests { /// `index.d.ts` is hand-written and re-exports the generated `native.d.ts`, /// plus the `AuthErrorCode` union NAPI-RS can't emit. That union must list - /// exactly the codes `AuthError::error_code()` produces, plus - /// `UNKNOWN_ERROR` (the `index.js` fallback). This guards the two against - /// drift — adding an `AuthError` variant (which the exhaustive `error_code` - /// match forces a code for) without updating the union fails here. + /// exactly the codes `AuthError::error_code()` can return, plus + /// `UNKNOWN_ERROR` (the `index.js` fallback). /// - /// `EXPECTED` mirrors the codes pinned by stack-auth's - /// `auth_error_code_is_stable_for_every_variant`; keep all three in sync. + /// The expected set is *derived* from `error_code()`'s match arms in the + /// core crate's source — not a hand-kept mirror — so there's no parallel + /// list to drift. `error_code`'s match is exhaustive, so adding an + /// `AuthError` variant forces a new `=> "CODE"` arm there, which this test + /// then requires the TS union to include; forget to update `index.d.ts` and + /// this fails. #[test] fn ts_auth_error_code_union_matches_error_codes() { use std::collections::BTreeSet; - const EXPECTED: &[&str] = &[ - "REQUEST_ERROR", - "ACCESS_DENIED", - "EXPIRED_TOKEN", - "INVALID_GRANT", - "INVALID_CLIENT", - "INVALID_URL", - "INVALID_REGION", - "INVALID_TOKEN", - "SERVER_ERROR", - "STORE_ERROR", - "NOT_AUTHENTICATED", - "MISSING_WORKSPACE_CRN", - "INVALID_ACCESS_KEY", - "INVALID_CRN", - "WORKSPACE_MISMATCH", - "INVALID_WORKSPACE_ID", - "UNKNOWN_ERROR", - ]; - - // Extract the union members — lines of the form ` | 'CODE'`. + // Parse the codes `AuthError::error_code` can return straight from its + // source. The node crate depends on stack-auth, so this resolves to the + // core crate's `lib.rs`. + const CORE_SRC: &str = include_str!("../../src/lib.rs"); + + let fn_start = CORE_SRC + .find("pub fn error_code(") + .expect("AuthError::error_code source not found"); + // The method is indented 4 spaces, so its closing brace is the first + // `\n }` after the signature (the inner `match` closes at 8 spaces, + // and every arm is deeper still — none collide with this). + let fn_body = { + let rest = &CORE_SRC[fn_start..]; + let end = rest.find("\n }").expect("error_code fn close not found"); + &rest[..end] + }; + + // Every arm is `=> "CODE",`; pull the string literals. `UNKNOWN_ERROR` + // is added by the JS layer, never by `error_code`, so add it explicitly. + let mut expected: BTreeSet<&str> = fn_body + .match_indices("=> \"") + .map(|(i, _)| { + let after = &fn_body[i + "=> \"".len()..]; + let close = after + .find('"') + .expect("error_code arm missing closing quote"); + &after[..close] + }) + .collect(); + expected.insert("UNKNOWN_ERROR"); + + // A mis-scoped parse (empty/garbage) should fail loudly here, not pass. + assert!( + expected.len() >= 10, + "parsed only {} error codes from error_code() — the source parse likely broke", + expected.len(), + ); + + // The hand-written `index.d.ts` union — lines of the form ` | 'CODE'`. let dts = include_str!("../index.d.ts"); let union: BTreeSet<&str> = dts .lines() @@ -620,7 +640,6 @@ mod tests { }) .collect(); - let expected: BTreeSet<&str> = EXPECTED.iter().copied().collect(); assert_eq!( union, expected, "AuthErrorCode union in index.d.ts drifted from AuthError::error_code()", From 16e0894d4f1756607978a9238ffc4d775224cbf6 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Mon, 22 Jun 2026 21:08:18 +1000 Subject: [PATCH 320/686] test(stack-auth): cover all #[diagnostic(help)] variants, not just one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit annotated_variants_expose_diagnostic_help pinned only NotAuthenticated. Unlike error_code (exhaustive match forces a value per variant), #[diagnostic(help)] is optional — dropping it on the other five compiles fine and went uncaught. Make it table-driven: assert a help substring for each of the six annotated variants (Region, InvalidCrn, WorkspaceMismatch, MissingWorkspaceCrn, NotAuthenticated, InvalidAccessKey), plus negative cases asserting two un-annotated variants (TokenExpired, InvalidToken) report help() == None, so a stray annotation can't slip in either. Verified discriminating: removing any one help annotation fails the test. (Addresses review feedback on cipherstash/cipherstash-suite#2053.) --- packages/stack-auth/src/lib.rs | 65 ++++++++++++++++++++++++++++------ 1 file changed, 55 insertions(+), 10 deletions(-) diff --git a/packages/stack-auth/src/lib.rs b/packages/stack-auth/src/lib.rs index aedc83917..c149645e0 100644 --- a/packages/stack-auth/src/lib.rs +++ b/packages/stack-auth/src/lib.rs @@ -553,19 +553,64 @@ mod tests { } } - /// The variants annotated with `#[diagnostic(help(..))]` must surface that - /// help through `miette::Diagnostic` — this is what the CLI renders below - /// the error message. Pins a representative one so the annotation can't be - /// dropped silently. + /// Every variant annotated with `#[diagnostic(help(..))]` must surface that + /// help through `miette::Diagnostic` — it's what the CLI renders below the + /// error message. Unlike `error_code`'s exhaustive match, `help` is optional + /// and silently compiles if dropped, so pin all six (and a couple of + /// un-annotated variants that must stay `None`) explicitly. #[test] fn annotated_variants_expose_diagnostic_help() { use miette::Diagnostic; - let err = AuthError::NotAuthenticated; - let help = err.help().map(|h| h.to_string()); - assert!( - help.as_deref().is_some_and(|h| h.contains("stash login")), - "NotAuthenticated should carry actionable help, got: {help:?}", - ); + let workspace = "ZVATKW3VHMFG27DY" + .parse::() + .unwrap(); + + // (variant, substring its help must contain) — one row per annotation. + let with_help: Vec<(AuthError, &str)> = vec![ + ( + AuthError::Region("not-a-region".parse::().unwrap_err()), + "supported region", + ), + ( + AuthError::InvalidCrn("not-a-crn".parse::().unwrap_err()), + "crn::", + ), + ( + AuthError::WorkspaceMismatch { + expected_workspace: workspace, + token_workspace: workspace, + }, + "different workspace", + ), + (AuthError::MissingWorkspaceCrn, "CS_WORKSPACE_CRN"), + (AuthError::NotAuthenticated, "stash login"), + ( + AuthError::InvalidAccessKey( + "".parse::().unwrap_err(), + ), + "CSAK.", + ), + ]; + + for (err, substring) in with_help { + let help = err.help().map(|h| h.to_string()); + assert!( + help.as_deref().is_some_and(|h| h.contains(substring)), + "{err:?} should carry help containing {substring:?}, got: {help:?}", + ); + } + + // Un-annotated variants must report no help — keeps the contract + // symmetric so a stray annotation doesn't slip in unnoticed. + for err in [ + AuthError::TokenExpired, + AuthError::InvalidToken("malformed".to_string()), + ] { + assert!( + err.help().is_none(), + "{err:?} has no #[diagnostic(help)] and should report None", + ); + } } } From 05f1517a50533381dec113b6a8edc6fb4ffa6313 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Sat, 27 Jun 2026 22:52:11 +1000 Subject: [PATCH 321/686] test(stack-auth): behavioural guards for the index.d.ts split, not source-text checks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses cipherstash/cipherstash-suite#2053 review: - Replace the suggested string-presence test (asserting source text exists) with a consumer typecheck: type-check a module importing AuthError, AuthErrorCode, OAuthStrategy, and the napi-generated symbols from @cipherstash/auth by name (Node16 resolution, through the exports map) and run `tsc --noEmit`. Asserts the consumer-facing behaviour — fails if the AuthError interface, OAuthStrategy alias, or `export * from "./native"` re-export is removed, or a generated symbol stops resolving. Covers both the deletion guard and the optional resolution guard from the review. - Trigger INVALID_GRANT and INVALID_CLIENT through the napi seam via the device poll, mirroring the existing ACCESS_DENIED/EXPIRED_TOKEN tests, so each reachable error variant is exercised and its .code asserted. - Broaden the MissingWorkspaceCrn diagnostic help to note most strategies need a workspace CRN, keeping AutoStrategy as the example. Claude-Session: https://claude.ai/code/session_0197v7GRr8qKzhTtLCMjGFH4 --- .../auth/__tests__/consumer-typecheck.test.ts | 98 +++++++++++++++++++ languages/typescript/packages/auth/src/lib.rs | 42 ++++++++ packages/stack-auth/src/lib.rs | 2 +- 3 files changed, 141 insertions(+), 1 deletion(-) create mode 100644 languages/typescript/packages/auth/__tests__/consumer-typecheck.test.ts diff --git a/languages/typescript/packages/auth/__tests__/consumer-typecheck.test.ts b/languages/typescript/packages/auth/__tests__/consumer-typecheck.test.ts new file mode 100644 index 000000000..60d7d552a --- /dev/null +++ b/languages/typescript/packages/auth/__tests__/consumer-typecheck.test.ts @@ -0,0 +1,98 @@ +import { describe, it, expect } from "vitest"; +import { execFileSync } from "child_process"; +import { mkdtempSync, writeFileSync } from "fs"; +import { join } from "path"; +import { tmpdir } from "os"; + +// The public type surface is split across two files: `index.d.ts` is +// hand-written and re-exports the NAPI-RS–generated `native.d.ts`, adding the +// declarations napi can't emit (`AuthError`, `AuthErrorCode`, `OAuthStrategy`). +// +// vitest erases type-only imports and never runs `tsc`, so a broken split — +// the `export * from "./native"` re-export removed, the `AuthError` interface +// or `OAuthStrategy` alias deleted, `native.d.ts` failing to resolve, or a +// generated symbol no longer reaching the package entrypoint — would compile +// green through the rest of the suite while silently breaking every consumer's +// `import { ... } from "@cipherstash/auth"`. +// +// This test is the only thing that exercises the contract the way a real +// consumer does: it type-checks an importing module against the package, by +// name, through the `package.json` `exports` map. It asserts behaviour (does a +// consumer compile?) rather than grepping the declaration file for strings. + +const packageDir = join(__dirname, ".."); +// Run the locally-installed tsc as a script under the current node, so this is +// cross-platform (no shell, no `.bin` shim) and pinned to the devDependency. +const tscBin = require.resolve("typescript/bin/tsc"); + +// A consumer that imports — and *uses*, so nothing is elided — every symbol +// that has to survive the split: the napi-generated classes/interfaces re- +// exported via `./native`, the hand-written `AuthError`/`AuthErrorCode`, and +// the `OAuthStrategy` runtime alias (imported as a value, not a type). +const CONSUMER = ` +import type { + AuthError, + AuthErrorCode, + TokenResult, + DeviceSessionStrategy, + AutoStrategy, +} from "@cipherstash/auth"; +import { OAuthStrategy } from "@cipherstash/auth"; + +const _alias: typeof DeviceSessionStrategy = OAuthStrategy; + +function codeOf(err: AuthError): AuthErrorCode { + return err.code; +} + +declare const tr: TokenResult; +const _token: string = tr.token; +declare const auto: AutoStrategy; + +void codeOf; +void _alias; +void _token; +void auto; +`; + +// Node16 resolution makes tsc honour the package's `exports` map (the "node" +// condition resolves to `index.d.ts`), so this verifies the real entrypoint a +// consumer hits — not just a relative path into the file. +const tsconfig = { + compilerOptions: { + target: "ES2020", + module: "Node16", + moduleResolution: "Node16", + strict: true, + esModuleInterop: true, + skipLibCheck: true, + noEmit: true, + baseUrl: ".", + paths: { "@cipherstash/auth": [packageDir] }, + }, + files: ["consumer.ts"], +}; + +describe("consumer typecheck (index.d.ts -> native.d.ts split)", () => { + it("a consumer importing from @cipherstash/auth type-checks", () => { + const dir = mkdtempSync(join(tmpdir(), "cs-auth-tscheck-")); + writeFileSync(join(dir, "consumer.ts"), CONSUMER); + writeFileSync(join(dir, "tsconfig.json"), JSON.stringify(tsconfig)); + + let output = ""; + let ok = true; + try { + output = execFileSync( + process.execPath, + [tscBin, "-p", join(dir, "tsconfig.json")], + { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }, + ); + } catch (err) { + ok = false; + const e = err as { stdout?: Buffer | string; stderr?: Buffer | string }; + output = `${e.stdout ?? ""}${e.stderr ?? ""}`; + } + + expect(ok, `tsc reported type errors:\n${output}`).toBe(true); + }); +}); diff --git a/languages/typescript/packages/auth/src/lib.rs b/languages/typescript/packages/auth/src/lib.rs index bbbebcb6b..8f85c1fc4 100644 --- a/languages/typescript/packages/auth/src/lib.rs +++ b/languages/typescript/packages/auth/src/lib.rs @@ -993,6 +993,48 @@ mod tests { } } + mod given_invalid_grant { + use super::*; + + #[tokio::test(start_paused = true)] + async fn returns_invalid_grant_error() { + let dir = TempDir::new().unwrap(); + let mut mocks = MockSet::new(); + mock_code_endpoint(&mut mocks); + mocks.mock(|when, then| { + when.post().path("/oauth/device/token"); + then.bad_request().json(error_json("invalid_grant")); + }); + let server = start_server(mocks).await; + + let result = begin_result(&server, &dir).await; + let err = result.poll_for_token().await.unwrap_err(); + + assertions::has_error_code(&err, "INVALID_GRANT"); + } + } + + mod given_invalid_client { + use super::*; + + #[tokio::test(start_paused = true)] + async fn returns_invalid_client_error() { + let dir = TempDir::new().unwrap(); + let mut mocks = MockSet::new(); + mock_code_endpoint(&mut mocks); + mocks.mock(|when, then| { + when.post().path("/oauth/device/token"); + then.bad_request().json(error_json("invalid_client")); + }); + let server = start_server(mocks).await; + + let result = begin_result(&server, &dir).await; + let err = result.poll_for_token().await.unwrap_err(); + + assertions::has_error_code(&err, "INVALID_CLIENT"); + } + } + mod given_consumed_handle { use super::*; diff --git a/packages/stack-auth/src/lib.rs b/packages/stack-auth/src/lib.rs index c149645e0..480ed8cae 100644 --- a/packages/stack-auth/src/lib.rs +++ b/packages/stack-auth/src/lib.rs @@ -348,7 +348,7 @@ pub enum AuthError { /// [`AutoStrategyBuilder::with_workspace_crn`](crate::AutoStrategyBuilder::with_workspace_crn). #[error("Workspace CRN is required when using an access key — set CS_WORKSPACE_CRN or call AutoStrategyBuilder::with_workspace_crn")] #[diagnostic(help( - "Set the `CS_WORKSPACE_CRN` environment variable, or pass the CRN via `AutoStrategyBuilder::with_workspace_crn`." + "Most strategies need a workspace CRN — set the `CS_WORKSPACE_CRN` environment variable, or pass it explicitly, e.g. `AutoStrategyBuilder::with_workspace_crn`." ))] MissingWorkspaceCrn, /// No credentials are available (e.g. not logged in, no access key configured). From b0187d7be10b6405d1c3b327b9f7131e4f2ac3a7 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Fri, 3 Jul 2026 22:17:41 +1000 Subject: [PATCH 322/686] test(stack-auth/node): close coverage gaps in the index.d.ts split guards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses auxesis's review on cipherstash/cipherstash-suite#2053: - Rust: add index_dts_retains_hand_written_reexports to guard the three hand-written symbols the union-drift test didn't cover (the `export * from "./native"` re-export, the AuthError interface, and the OAuthStrategy alias) — napi can't emit them and vitest never runs tsc, so a deletion would otherwise compile green. - consumer-typecheck: add a negative "teeth" case that points the consumer at a deliberately-broken index.d.ts and asserts tsc rejects it, so the harness is proven to go red when the split breaks. - Add a runtime assertion that index.js's OAuthStrategy alias is the same value as DeviceSessionStrategy (the type-only check runs under noEmit and never exercised the runtime re-export). - Add an `npm pack --dry-run` contract check that native.d.ts ships in the tarball, since index.d.ts re-exports it and the typecheck resolves against the source tree rather than the packed output. --- .../auth/__tests__/consumer-typecheck.test.ts | 126 +++++++++++++----- languages/typescript/packages/auth/src/lib.rs | 22 +++ 2 files changed, 117 insertions(+), 31 deletions(-) diff --git a/languages/typescript/packages/auth/__tests__/consumer-typecheck.test.ts b/languages/typescript/packages/auth/__tests__/consumer-typecheck.test.ts index 60d7d552a..a1ab38c10 100644 --- a/languages/typescript/packages/auth/__tests__/consumer-typecheck.test.ts +++ b/languages/typescript/packages/auth/__tests__/consumer-typecheck.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from "vitest"; import { execFileSync } from "child_process"; -import { mkdtempSync, writeFileSync } from "fs"; +import { mkdtempSync, writeFileSync, copyFileSync } from "fs"; import { join } from "path"; import { tmpdir } from "os"; @@ -58,41 +58,105 @@ void auto; // Node16 resolution makes tsc honour the package's `exports` map (the "node" // condition resolves to `index.d.ts`), so this verifies the real entrypoint a // consumer hits — not just a relative path into the file. -const tsconfig = { - compilerOptions: { - target: "ES2020", - module: "Node16", - moduleResolution: "Node16", - strict: true, - esModuleInterop: true, - skipLibCheck: true, - noEmit: true, - baseUrl: ".", - paths: { "@cipherstash/auth": [packageDir] }, - }, - files: ["consumer.ts"], +const baseCompilerOptions = { + target: "ES2020", + module: "Node16", + moduleResolution: "Node16", + strict: true, + esModuleInterop: true, + skipLibCheck: true, + noEmit: true, + baseUrl: ".", }; +// Type-check CONSUMER against whatever `@cipherstash/auth` resolves to at +// `pkgDir`. Returns whether tsc accepted it and its combined output. +function typecheckConsumerAgainst(pkgDir: string): { + ok: boolean; + output: string; +} { + const dir = mkdtempSync(join(tmpdir(), "cs-auth-tscheck-")); + writeFileSync(join(dir, "consumer.ts"), CONSUMER); + writeFileSync( + join(dir, "tsconfig.json"), + JSON.stringify({ + compilerOptions: { + ...baseCompilerOptions, + paths: { "@cipherstash/auth": [pkgDir] }, + }, + files: ["consumer.ts"], + }), + ); + + try { + const output = execFileSync( + process.execPath, + [tscBin, "-p", join(dir, "tsconfig.json")], + { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }, + ); + return { ok: true, output }; + } catch (err) { + const e = err as { stdout?: Buffer | string; stderr?: Buffer | string }; + return { ok: false, output: `${e.stdout ?? ""}${e.stderr ?? ""}` }; + } +} + describe("consumer typecheck (index.d.ts -> native.d.ts split)", () => { it("a consumer importing from @cipherstash/auth type-checks", () => { - const dir = mkdtempSync(join(tmpdir(), "cs-auth-tscheck-")); - writeFileSync(join(dir, "consumer.ts"), CONSUMER); - writeFileSync(join(dir, "tsconfig.json"), JSON.stringify(tsconfig)); + const { ok, output } = typecheckConsumerAgainst(packageDir); + expect(ok, `tsc reported type errors:\n${output}`).toBe(true); + }); - let output = ""; - let ok = true; - try { - output = execFileSync( - process.execPath, - [tscBin, "-p", join(dir, "tsconfig.json")], - { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }, - ); - } catch (err) { - ok = false; - const e = err as { stdout?: Buffer | string; stderr?: Buffer | string }; - output = `${e.stdout ?? ""}${e.stderr ?? ""}`; - } + it("rejects a consumer when the split is broken (guard has teeth)", () => { + // Mirror the real package so module resolution is identical (same + // package.json/`exports`), but replace index.d.ts with a stub that drops + // the `export * from "./native"` re-export and the hand-written + // declarations. The consumer must now fail to compile — proving this + // harness actually goes red when the split breaks, rather than only + // passing when everything is intact. + const brokenPkg = mkdtempSync(join(tmpdir(), "cs-auth-broken-")); + copyFileSync( + join(packageDir, "package.json"), + join(brokenPkg, "package.json"), + ); + writeFileSync(join(brokenPkg, "index.d.ts"), "export {};\n"); - expect(ok, `tsc reported type errors:\n${output}`).toBe(true); + const { ok } = typecheckConsumerAgainst(brokenPkg); + expect(ok, "tsc should reject a consumer when the split is broken").toBe( + false, + ); + }); +}); + +describe("runtime re-export contract (index.js)", () => { + it("OAuthStrategy is the same runtime value as DeviceSessionStrategy", () => { + // The typecheck above (noEmit) only proves the *type* alias resolves. The + // runtime alias `module.exports.OAuthStrategy = native.DeviceSessionStrategy` + // in index.js is exercised by nothing else, so load the real entrypoint and + // assert it: drop that line and `import { OAuthStrategy }` silently becomes + // `undefined` for consumers. + const mod = require("../index.js") as typeof import("../index"); + expect(mod.OAuthStrategy).toBeDefined(); + expect(mod.OAuthStrategy).toBe(mod.DeviceSessionStrategy); + }); +}); + +describe("publish contract (npm pack)", () => { + it("packs the declarations required by index.d.ts", () => { + // index.d.ts does `export * from "./native"`, so native.d.ts MUST ship in + // the tarball or every published consumer's import dangles on a missing + // file. The typecheck resolves against the source tree, not the packed + // output, so this is the only guard on the `files` allowlist. + const out = execFileSync("npm", ["pack", "--json", "--dry-run"], { + cwd: packageDir, + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + }); + const packed = ( + JSON.parse(out) as Array<{ files: Array<{ path: string }> }> + )[0].files.map((f) => f.path); + expect(packed).toEqual( + expect.arrayContaining(["index.d.ts", "native.d.ts"]), + ); }); }); diff --git a/languages/typescript/packages/auth/src/lib.rs b/languages/typescript/packages/auth/src/lib.rs index 8f85c1fc4..d3c856380 100644 --- a/languages/typescript/packages/auth/src/lib.rs +++ b/languages/typescript/packages/auth/src/lib.rs @@ -646,6 +646,28 @@ mod tests { ); } + #[test] + fn index_dts_retains_hand_written_reexports() { + // The union test above only guards `AuthErrorCode`. The other three + // hand-written pieces of `index.d.ts` are equally load-bearing but + // NAPI-RS cannot emit them, so a regen or careless edit that drops any + // of them compiles green: the node tests import `AuthError` as an + // `import type` (erased at runtime) and vitest never runs `tsc`. Pin + // them by string presence so a deletion fails here. + let dts = include_str!("../index.d.ts"); + for needle in [ + // The native re-export the whole generated surface flows through. + "export * from \"./native\"", + "export interface AuthError extends Error", + "export declare const OAuthStrategy", + ] { + assert!( + dts.contains(needle), + "index.d.ts lost hand-written {needle:?}", + ); + } + } + // --- Shared helpers --- fn device_code_json() -> serde_json::Value { From 649007426c4ce3f278fc72b94a86eade3709ceac Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Mon, 6 Jul 2026 10:44:03 +1000 Subject: [PATCH 323/686] ci(stack-auth): guard committed napi typings against .node ABI drift freshtonic's approval follow-ups on cipherstash/cipherstash-suite#2053: - test-stack-auth.yml: rebuild native.d.ts via 'napi build --dts native.d.ts' and fail on an uncommitted native.d.ts/index.d.ts diff. Nothing else in CI regenerated the committed typings, so a #[napi] signature change that wasn't rebuilt and committed could silently ship types disagreeing with the .node ABI. biome skips **/*.d.ts, so the committed files are raw napi output and the guard won't false-positive. - publish-auth-npm.yml: add --dts native.d.ts to the release napi build so it matches package.json and stops clobbering the hand-written index.d.ts in that job's tree. --- .../imported-workflows/publish-auth-npm.yml | 7 ++++++- .github/imported-workflows/test-stack-auth.yml | 18 ++++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/.github/imported-workflows/publish-auth-npm.yml b/.github/imported-workflows/publish-auth-npm.yml index 24a247176..b7b9dd043 100644 --- a/.github/imported-workflows/publish-auth-npm.yml +++ b/.github/imported-workflows/publish-auth-npm.yml @@ -87,7 +87,12 @@ jobs: - name: Build native module working-directory: ${{ env.WORKING_DIR }} - run: npx napi build --platform --release --target ${{ matrix.target }} --strip + # `--dts native.d.ts` matches package.json's `build` script: napi writes + # its generated typings to `native.d.ts`, leaving the hand-written + # `index.d.ts` re-exporter untouched. Without it, napi clobbers + # `index.d.ts` in this job's tree. Harmless today (only `*.node` is + # uploaded), but a footgun if this job ever packs or uploads dts files. + run: npx napi build --platform --release --target ${{ matrix.target }} --strip --dts native.d.ts - name: Upload artifact uses: actions/upload-artifact@v7 diff --git a/.github/imported-workflows/test-stack-auth.yml b/.github/imported-workflows/test-stack-auth.yml index 27fdeea38..8fc6b07a0 100644 --- a/.github/imported-workflows/test-stack-auth.yml +++ b/.github/imported-workflows/test-stack-auth.yml @@ -61,6 +61,24 @@ jobs: - name: test-integration-stack-auth run: mise run test:integration:stack-auth + - name: Guard committed napi typings against drift + working-directory: packages/stack-auth/node + # `native.d.ts` is generated by `napi build --dts native.d.ts` and + # committed (shipped in `files`, re-exported by the hand-written + # `index.d.ts`). Nothing else in CI regenerates it, so a `#[napi]` + # signature change that isn't rebuilt and committed would silently ship + # typings that disagree with the `.node` ABI. Rebuild and fail on any + # diff. `index.d.ts` is hand-written — napi must never touch it — so it's + # diffed too as a belt-and-suspenders check. biome skips `**/*.d.ts`, so + # the committed files are raw napi output and this won't false-positive. + run: | + npm install + npm run build:debug + if ! git diff --exit-code -- native.d.ts index.d.ts; then + echo "::error::packages/stack-auth/node/{native,index}.d.ts is stale — run 'npm run build:debug' in packages/stack-auth/node and commit the result." + exit 1 + fi + - name: Install wasm32 target uses: dtolnay/rust-toolchain@1.90.0 with: From f0fa35dd9117d91844b30c26046de16b3104018f Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Mon, 6 Jul 2026 10:44:14 +1000 Subject: [PATCH 324/686] refactor(stack-auth): derive .d.ts drift set from AuthError::ERROR_CODES MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit freshtonic's approval follow-up on cipherstash/cipherstash-suite#2053: the AuthErrorCode union drift test scraped error_code()'s match-arm string literals out of the core crate's source, coupling it to formatting (a reformatted arm, a nested 4-space '}', or a stray '=> "…"' in a comment could corrupt the parse). Expose the codes as 'pub const AuthError::ERROR_CODES: &[&str]' and have both guards reference the real symbol instead of the source text: - The node union test compares index.d.ts against ERROR_CODES (+ UNKNOWN_ERROR), no include_str! scraping. - The core exhaustive test pins ERROR_CODES against what error_code() returns: every constructed variant's code must be declared, and ERROR_CODES must equal those codes plus REQUEST_ERROR (the one variant with no public constructor) — so the list can't grow stale entries or omit a real one. --- languages/typescript/packages/auth/index.d.ts | 2 +- languages/typescript/packages/auth/src/lib.rs | 53 ++++-------------- packages/stack-auth/src/lib.rs | 56 ++++++++++++++++++- 3 files changed, 66 insertions(+), 45 deletions(-) diff --git a/languages/typescript/packages/auth/index.d.ts b/languages/typescript/packages/auth/index.d.ts index 38e55214d..37acfb61f 100644 --- a/languages/typescript/packages/auth/index.d.ts +++ b/languages/typescript/packages/auth/index.d.ts @@ -13,7 +13,7 @@ * with a machine-readable `.code` at runtime by `index.js`. * - `OAuthStrategy` — a deprecated alias exported at runtime by `index.js`. * - * `AuthErrorCode` mirrors `AuthError::error_code()` in + * `AuthErrorCode` mirrors `AuthError::ERROR_CODES` in * `packages/stack-auth/src/lib.rs` (plus `UNKNOWN_ERROR`, the `index.js` * fallback). The `stack-auth-node` test `ts_auth_error_code_union_matches_error_codes` * guards this union against drift. diff --git a/languages/typescript/packages/auth/src/lib.rs b/languages/typescript/packages/auth/src/lib.rs index d3c856380..7d1cf9755 100644 --- a/languages/typescript/packages/auth/src/lib.rs +++ b/languages/typescript/packages/auth/src/lib.rs @@ -581,54 +581,21 @@ mod tests { /// exactly the codes `AuthError::error_code()` can return, plus /// `UNKNOWN_ERROR` (the `index.js` fallback). /// - /// The expected set is *derived* from `error_code()`'s match arms in the - /// core crate's source — not a hand-kept mirror — so there's no parallel - /// list to drift. `error_code`'s match is exhaustive, so adding an - /// `AuthError` variant forces a new `=> "CODE"` arm there, which this test - /// then requires the TS union to include; forget to update `index.d.ts` and - /// this fails. + /// The expected set is the exported [`AuthError::ERROR_CODES`] constant — a + /// real symbol the compiler resolves, not a scrape of the core crate's + /// source text. A core-crate test pins that constant against `error_code`'s + /// exhaustive match, so adding an `AuthError` variant forces a new code + /// there, which this test then requires the TS union to include; forget to + /// update `index.d.ts` and this fails. #[test] fn ts_auth_error_code_union_matches_error_codes() { use std::collections::BTreeSet; - // Parse the codes `AuthError::error_code` can return straight from its - // source. The node crate depends on stack-auth, so this resolves to the - // core crate's `lib.rs`. - const CORE_SRC: &str = include_str!("../../src/lib.rs"); - - let fn_start = CORE_SRC - .find("pub fn error_code(") - .expect("AuthError::error_code source not found"); - // The method is indented 4 spaces, so its closing brace is the first - // `\n }` after the signature (the inner `match` closes at 8 spaces, - // and every arm is deeper still — none collide with this). - let fn_body = { - let rest = &CORE_SRC[fn_start..]; - let end = rest.find("\n }").expect("error_code fn close not found"); - &rest[..end] - }; - - // Every arm is `=> "CODE",`; pull the string literals. `UNKNOWN_ERROR` - // is added by the JS layer, never by `error_code`, so add it explicitly. - let mut expected: BTreeSet<&str> = fn_body - .match_indices("=> \"") - .map(|(i, _)| { - let after = &fn_body[i + "=> \"".len()..]; - let close = after - .find('"') - .expect("error_code arm missing closing quote"); - &after[..close] - }) - .collect(); + // The codes `AuthError::error_code` can return, plus the `UNKNOWN_ERROR` + // fallback the JS layer adds (never returned by `error_code`). + let mut expected: BTreeSet<&str> = AuthError::ERROR_CODES.iter().copied().collect(); expected.insert("UNKNOWN_ERROR"); - // A mis-scoped parse (empty/garbage) should fail loudly here, not pass. - assert!( - expected.len() >= 10, - "parsed only {} error codes from error_code() — the source parse likely broke", - expected.len(), - ); - // The hand-written `index.d.ts` union — lines of the form ` | 'CODE'`. let dts = include_str!("../index.d.ts"); let union: BTreeSet<&str> = dts @@ -642,7 +609,7 @@ mod tests { assert_eq!( union, expected, - "AuthErrorCode union in index.d.ts drifted from AuthError::error_code()", + "AuthErrorCode union in index.d.ts drifted from AuthError::ERROR_CODES", ); } diff --git a/packages/stack-auth/src/lib.rs b/packages/stack-auth/src/lib.rs index 480ed8cae..1a05445a8 100644 --- a/packages/stack-auth/src/lib.rs +++ b/packages/stack-auth/src/lib.rs @@ -377,10 +377,39 @@ pub enum AuthError { } impl AuthError { + /// The complete set of codes [`AuthError::error_code`] can return — the + /// stable, machine-readable contract surfaced across FFI (JS `Error.code`, + /// Node-API codes, the `index.d.ts` / wasm typing unions). Kept next to + /// `error_code` so the two move together. The binding crates derive their + /// expected union from this constant rather than re-scraping this source, + /// and `auth_error_code_is_stable_for_every_variant` pins that it stays in + /// lockstep with what `error_code` actually returns. + pub const ERROR_CODES: &'static [&'static str] = &[ + "REQUEST_ERROR", + "ACCESS_DENIED", + "EXPIRED_TOKEN", + "INVALID_GRANT", + "INVALID_CLIENT", + "INVALID_URL", + "INVALID_REGION", + "INVALID_TOKEN", + "SERVER_ERROR", + "NOT_AUTHENTICATED", + "MISSING_WORKSPACE_CRN", + "INVALID_ACCESS_KEY", + "INVALID_CRN", + "WORKSPACE_MISMATCH", + "INVALID_WORKSPACE_ID", + // `Store` (and its code) only exists off-wasm — see `error_code` below. + #[cfg(not(target_arch = "wasm32"))] + "STORE_ERROR", + ]; + /// Stable machine-readable identifier for surfacing across FFI boundaries /// (e.g. JS `Error.code`, Node-API error codes). Named `error_code` rather /// than `code` to avoid colliding with `miette::Diagnostic::code`, which - /// is inherited via `#[derive(Diagnostic)]`. + /// is inherited via `#[derive(Diagnostic)]`. Every value it can return is + /// listed in [`AuthError::ERROR_CODES`]. pub fn error_code(&self) -> &'static str { match self { Self::Request(_) => "REQUEST_ERROR", @@ -496,9 +525,17 @@ mod tests { /// all variants except `Request`, whose inner `reqwest::Error` has no public /// constructor; if a new variant is added without a code, `error_code`'s /// exhaustive match fails to compile, so the contract can't silently drift. + /// + /// Also pins [`AuthError::ERROR_CODES`] against what `error_code` actually + /// returns: every constructed variant's code must be declared there, and + /// `ERROR_CODES` must hold exactly those codes plus `REQUEST_ERROR` (the one + /// variant with no public constructor). So the list can't grow stale entries + /// or omit a real one — which is what the binding crates' union tests trust. #[test] #[allow(clippy::unwrap_used)] fn auth_error_code_is_stable_for_every_variant() { + use std::collections::BTreeSet; + let workspace = "ZVATKW3VHMFG27DY" .parse::() .unwrap(); @@ -548,9 +585,26 @@ mod tests { ), ]; + let declared: BTreeSet<&str> = AuthError::ERROR_CODES.iter().copied().collect(); + + let mut from_variants: BTreeSet<&str> = BTreeSet::new(); for (err, expected) in cases { assert_eq!(err.error_code(), expected, "error_code for {err:?}"); + assert!( + declared.contains(expected), + "{expected} is returned by error_code() but missing from AuthError::ERROR_CODES", + ); + from_variants.insert(expected); } + + // `Request` has no public constructor, so it can't appear above; add its + // code explicitly so the set-equality below stays exact. + from_variants.insert("REQUEST_ERROR"); + + assert_eq!( + declared, from_variants, + "AuthError::ERROR_CODES drifted from the codes error_code() returns", + ); } /// Every variant annotated with `#[diagnostic(help(..))]` must surface that From f03573b86f40cd58ec75e412e67e42a0bbb8195c Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Wed, 24 Jun 2026 14:50:12 +1000 Subject: [PATCH 325/686] feat(release): scaffold changesets for npm alongside release-plz (CIP-3278) --- .github/imported-workflows/release-npm.yml | 58 +++++++++ docs/npm-releases.md | 141 +++++++++++++++++++++ 2 files changed, 199 insertions(+) create mode 100644 .github/imported-workflows/release-npm.yml create mode 100644 docs/npm-releases.md diff --git a/.github/imported-workflows/release-npm.yml b/.github/imported-workflows/release-npm.yml new file mode 100644 index 000000000..fd71abc91 --- /dev/null +++ b/.github/imported-workflows/release-npm.yml @@ -0,0 +1,58 @@ +# SPIKE SCAFFOLD (CIP-3278) — not wired for production yet. +# +# Owns the *versioning* half of npm releases: on push to main, the changesets +# action opens/updates a "Version Packages" PR that applies accumulated +# .changeset/*.md entries to package.json + CHANGELOG.md for the @cipherstash +# npm products. It deliberately does NOT publish — the @cipherstash/auth and +# @cipherstash/profile packages need a matrix napi/wasm build, so actual +# publishing stays with the existing publish-auth-npm.yml (triggered manually, +# or auto-triggered off the Version Packages PR merge in a follow-up). +# +# This is the seam with release-plz: release-plz owns Cargo.* + crates.io; +# this owns package.json + npm. They never touch the same files. +name: "Release (npm — changesets)" + +on: + push: + branches: + - main + +# Only one Version Packages PR in flight at a time. +concurrency: + group: release-npm + cancel-in-progress: false + +permissions: + contents: write # create the Version Packages branch/commit + pull-requests: write # open/update the Version Packages PR + +jobs: + version: + name: Open/update Version Packages PR + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: 24 + + # Installs only the root manifest's devDependency (@changesets/cli). + # `--ignore-scripts` and no workspace build keep this off the napi + # toolchain entirely — versioning only reads/writes package.json + md. + - name: Install changesets + run: npm install --no-workspaces --ignore-scripts + + - name: Create Version Packages PR + uses: changesets/action@v1 + with: + # `version` regenerates package.json + CHANGELOG from .changeset/*.md. + # No `publish:` on purpose — see header comment. + version: npx changeset version + title: "Release: version @cipherstash npm packages" + commit: "chore(release): version @cipherstash npm packages" + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/docs/npm-releases.md b/docs/npm-releases.md new file mode 100644 index 000000000..7d62a0d76 --- /dev/null +++ b/docs/npm-releases.md @@ -0,0 +1,141 @@ +# npm releases (changesets) — SPIKE (CIP-3278) + +> Status: **spike** on branch `dan/changesets-spike`. This documents the model and +> what the spike validated; it is not the production rollout. Tracking issue: +> [CIP-3278](https://linear.app/cipherstash/issue/CIP-3278). + +## Why + +Releasing the `stack-auth` **crate** (release-plz → crates.io) is independent of +publishing the `@cipherstash/auth` **npm package** that binds to it. Hand-editing +the npm `package.json` version caused real drift (a phantom `0.39.0` publish, a +`0.40.0`/`0.38.0` mismatch, a reconstructed changelog — see PR #2057). + +We do **not** want lock-step version numbers. We want a reliable, low-ceremony +npm release workflow — the same [changesets](https://github.com/changesets/changesets) +flow used in `cipherstash/stack`. + +## The two-tool seam + +release-plz and changesets coexist because they partition by **language + +registry** and never read or write each other's files: + +| | release-plz | changesets | +|---|---|---| +| Reads/writes | `Cargo.toml`, `Cargo.lock`, Rust `CHANGELOG.md` (via `cliff.toml`) | `package.json`, npm `CHANGELOG.md`, `.changeset/*.md` | +| Publishes to | crates.io | npm | +| Tags | `stack-auth-v…`, `cipherstash-client-v…` | `@cipherstash/auth@…` / "Version Packages" PR | + +## How a release works + +1. **Contributor**: change a JS/TS package, then `npx changeset` (from repo root) + → pick package(s) + bump level + summary → commit the generated + `.changeset/*.md` with your code. No hand-edited versions or changelogs. +2. **On merge to `main`**: `.github/workflows/release-npm.yml` runs the + `changesets/action`, which opens/updates a **"Version Packages" PR** applying + the accumulated bumps to `package.json` + `CHANGELOG.md`. +3. **Cut the release**: merge the Version Packages PR. +4. **Publish artifacts**: the existing `publish-auth-npm.yml` builds the napi + matrix + wasm and publishes to npm, reading the version changesets just wrote + (`publish-auth-npm.yml:187` already does `jq -r .version package.json`). The + release workflow here deliberately does **not** publish — napi packages need + the matrix build the bespoke workflow already owns. + +## Scope + +- **Managed**: `@cipherstash/auth` (`packages/stack-auth/node`), + `@cipherstash/profile` (`packages/stack-profile/node`). +- **Not managed** (build artifacts / private): the `npm/*` platform sub-packages + (`@cipherstash/auth-darwin-x64`, …) — stamped from the main version at publish + time (`publish-auth-npm.yml:198-221`); the `0.0.0-pre` wasm package; and + non-product packages (`load-tests`, health-checks). Excluded both by the + `workspaces` globs and the `ignore` list in `.changeset/config.json`. + +## What the spike validated + +- `changeset status` discovers **exactly** `@cipherstash/auth` and + `@cipherstash/profile`, ignoring the platform sub-packages and wasm. (See + "Validation log" below.) +- The root manifest is `private: true` and lists only the two products, so it has + no effect on crates / release-plz. + +## Open question for the production rollout + +The root `package.json` introduces **npm workspaces** where there were none. +CI currently does `npm install` *inside* `packages/stack-auth/node` against that +package's own `package-lock.json`. With a root workspace present, npm may hoist +to the root and resolve differently. The spike's release workflow sidesteps this +for *versioning* (`npm install --no-workspaces --ignore-scripts` — changesets +only needs its own CLI). The thing to confirm before rollout is the **build** +path: either + +- (a) adopt root workspaces end-to-end and update the build/publish jobs to + install from root, or +- (b) keep per-package installs and run changesets in a mode that enumerates + packages without hoisting (e.g. pnpm workspace, or a pinned `@manypkg` glob), + +whichever keeps the napi matrix build reproducible. + +## Extending to Python / C# / Ruby (and future stack-encrypt, stack-zerokms) + +Changesets is npm-only — it does not generalize to PyPI / NuGet / RubyGems. The +principle that **does** generalize: + +> One core crate per product (release-plz → crates.io). N language bindings, each +> its own artifact in its own registry, versioned independently, joined by +> **convention, not by a shared version number**. + +| Binding | Registry | Tool options | +|---|---|---| +| Rust core | crates.io | release-plz (in place) | +| JS/TS + wasm | npm | changesets (this doc) | +| Python (PyO3/uniffi) | PyPI | release-please / python-semantic-release / maturin + bump | +| C# (uniffi) | NuGet | release-please (.NET) / MinVer / Nerdbank.GitVersioning | +| Ruby (magnus/uniffi) | RubyGems | release-please (Ruby) / rake release | + +Two cross-cutting standards keep N tools manageable as products × languages grow: + +1. **Provenance over lock-step.** Every binding artifact records the exact core + crate **version + git SHA** it was built from (a metadata field in + `package.json` / `pyproject.toml` / `.csproj` / `.gemspec`, ideally also a + runtime constant). That is the real "in sync": not equal numbers, but "this + published binding provably wraps core X.Y.Z @ sha". +2. **A uniform CI shape.** A reusable "binding release" workflow parameterized by + `(product, language, registry)` — matrix build → stamp version + provenance → + publish — instead of copy-pasting `publish-*-npm.yml` per product/language. + +### Decision deferred to a second ticket + +Two "intent" models would coexist: release-plz/cliff derive changelogs from +**conventional commits**; changesets uses **explicit `.changeset/*.md` files**. +Fine for two tools; confusing across five ecosystems. Options: + +- **(A)** Per-ecosystem idiomatic tools + the provenance standard (recommended + near-term). +- **(B)** Converge all bindings on `release-please` (multi-language), keep + release-plz for crates, drop changesets. +- **(C)** Changesets as a polyglot intent layer with custom appliers for non-npm + manifests. + +Recommend **(A)** now; revisit **(B)** deliberately when a second binding +language is greenlit. Bake in provenance + the reusable workflow from the first +non-Rust binding regardless of which intent model wins. + +## Validation log + +Run on the spike branch with two throwaway changesets (since removed): + +``` +$ npx @changesets/cli status +info Packages to be bumped at patch: +- @cipherstash/auth +info Packages to be bumped at minor: +- @cipherstash/profile +info NO packages to be bumped at major +``` + +- Both products are discovered and bump independently (auth→patch, profile→minor). +- The `npm/*` platform sub-packages and the wasm package are **not** in the + project at all: an early `ignore: ["@cipherstash/auth-*", …]` config was + *rejected* with "not found in the project", which confirms changesets never + enumerates them. The `ignore` list was therefore dropped as unnecessary. From 36a5100a0bbc7c18f200433182a6e03bfecdad43 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Wed, 24 Jun 2026 15:51:26 +1000 Subject: [PATCH 326/686] chore(release): make changesets adoption review-ready (CIP-3278) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolve the one open question from the spike: adopt npm workspaces properly. - Verified the napi build is unaffected — products have zero runtime deps, and 'npx napi' still resolves from the child node_modules/.bin after a workspace install, so publish-auth-npm.yml needs no change. - Verified non-member nested JS packages (load-tests, health-checks, etc.) are outside the workspace globs and unaffected ('npm prefix'/'npm ci' check out). - Consolidate to a single root package-lock.json; remove the now-superseded per-package lockfiles under stack-auth/node and stack-profile/node. - Docs: replace the 'open question' with the resolved decision; note that publish stays manual and CHANGELOG handover is automatic (follow-ups). Merging is a no-op until a .changeset/*.md lands; publishing remains the existing manual workflow. --- .github/imported-workflows/release-npm.yml | 2 +- docs/npm-releases.md | 62 +- .../packages/auth/package-lock.json | 1646 ----------------- .../packages/profile/package-lock.json | 1626 ---------------- 4 files changed, 43 insertions(+), 3293 deletions(-) delete mode 100644 languages/typescript/packages/auth/package-lock.json delete mode 100644 languages/typescript/packages/profile/package-lock.json diff --git a/.github/imported-workflows/release-npm.yml b/.github/imported-workflows/release-npm.yml index fd71abc91..bd0ab2fde 100644 --- a/.github/imported-workflows/release-npm.yml +++ b/.github/imported-workflows/release-npm.yml @@ -1,4 +1,4 @@ -# SPIKE SCAFFOLD (CIP-3278) — not wired for production yet. +# Release versioning for the @cipherstash npm products (CIP-3278). # # Owns the *versioning* half of npm releases: on push to main, the changesets # action opens/updates a "Version Packages" PR that applies accumulated diff --git a/docs/npm-releases.md b/docs/npm-releases.md index 7d62a0d76..a5fa0902f 100644 --- a/docs/npm-releases.md +++ b/docs/npm-releases.md @@ -1,8 +1,10 @@ -# npm releases (changesets) — SPIKE (CIP-3278) +# npm releases (changesets) -> Status: **spike** on branch `dan/changesets-spike`. This documents the model and -> what the spike validated; it is not the production rollout. Tracking issue: -> [CIP-3278](https://linear.app/cipherstash/issue/CIP-3278). +> Adopts [changesets](https://github.com/changesets/changesets) for the `@cipherstash` +> npm products while release-plz keeps owning the Rust crates. Tracking issue: +> [CIP-3278](https://linear.app/cipherstash/issue/CIP-3278). Scope of the +> initial PR: **versioning** (the Version Packages PR); publishing stays manual +> for now (see "Still deferred"). ## Why @@ -59,22 +61,42 @@ registry** and never read or write each other's files: - The root manifest is `private: true` and lists only the two products, so it has no effect on crates / release-plz. -## Open question for the production rollout - -The root `package.json` introduces **npm workspaces** where there were none. -CI currently does `npm install` *inside* `packages/stack-auth/node` against that -package's own `package-lock.json`. With a root workspace present, npm may hoist -to the root and resolve differently. The spike's release workflow sidesteps this -for *versioning* (`npm install --no-workspaces --ignore-scripts` — changesets -only needs its own CLI). The thing to confirm before rollout is the **build** -path: either - -- (a) adopt root workspaces end-to-end and update the build/publish jobs to - install from root, or -- (b) keep per-package installs and run changesets in a mode that enumerates - packages without hoisting (e.g. pnpm workspace, or a pinned `@manypkg` glob), - -whichever keeps the napi matrix build reproducible. +## npm workspaces decision (resolved) + +The root `package.json` introduces **npm workspaces** where there were none, so +the effect on the existing `npm install` steps was checked empirically: + +- **The napi build is unaffected.** The publish pipeline's only Node dependency + is the `napi` binary (`@napi-rs/cli`); the products have **zero runtime + `dependencies`**. After a workspace install, `npx napi` still resolves from + `packages/stack-auth/node/node_modules/.bin`, so `napi build` / `napi + artifacts` work exactly as before — **no change to `publish-auth-npm.yml` is + needed**. +- **Only the two products are in the workspace.** The other nested JS packages + (`load-tests`, `health-checks/typescript`, `usage-metrics-tracker`, + `cts-web`) are **not** matched by the `workspaces` globs — verified `npm + prefix` from `load-tests/` returns its own dir and `npm ci` there still + resolves against its own lockfile, so `test-load-tests.yml` (the only `npm ci` + user) is untouched. +- **One lockfile is the source of truth.** A root `package-lock.json` governs + the workspace; the per-package lockfiles under `stack-auth/node` and + `stack-profile/node` are removed (npm ignores them in workspace mode). The + build uses `npm install` (not `npm ci`), so it adapts platform-specific + optional deps per runner. + +The release workflow installs only the changesets CLI +(`npm install --no-workspaces --ignore-scripts`), so the versioning job never +touches the napi toolchain. + +### Still deferred (follow-ups, not blocking this PR) + +- **Publish stays manual.** This PR wires *versioning* (the Version Packages + PR). Auto-triggering `publish-auth-npm.yml` on the Version PR merge is a + separate change. +- **CHANGELOG handover.** The first `changeset version` will prepend a + changesets-formatted section above the existing hand-written history (same + `## x.y.z` shape), so no migration is required; merging this PR with no + pending `.changeset/*.md` is a no-op. ## Extending to Python / C# / Ruby (and future stack-encrypt, stack-zerokms) diff --git a/languages/typescript/packages/auth/package-lock.json b/languages/typescript/packages/auth/package-lock.json deleted file mode 100644 index 35c986fad..000000000 --- a/languages/typescript/packages/auth/package-lock.json +++ /dev/null @@ -1,1646 +0,0 @@ -{ - "name": "@cipherstash/auth", - "version": "0.40.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "@cipherstash/auth", - "version": "0.40.0", - "devDependencies": { - "@napi-rs/cli": "^2", - "typescript": "^5", - "vitest": "^3" - }, - "peerDependencies": { - "@cipherstash/auth-darwin-arm64": "0.40.0", - "@cipherstash/auth-darwin-x64": "0.40.0", - "@cipherstash/auth-linux-arm64-gnu": "0.40.0", - "@cipherstash/auth-linux-x64-gnu": "0.40.0", - "@cipherstash/auth-linux-x64-musl": "0.40.0", - "@cipherstash/auth-win32-x64-msvc": "0.40.0" - }, - "peerDependenciesMeta": { - "@cipherstash/auth-darwin-arm64": { - "optional": true - }, - "@cipherstash/auth-darwin-x64": { - "optional": true - }, - "@cipherstash/auth-linux-arm64-gnu": { - "optional": true - }, - "@cipherstash/auth-linux-x64-gnu": { - "optional": true - }, - "@cipherstash/auth-linux-x64-musl": { - "optional": true - }, - "@cipherstash/auth-win32-x64-msvc": { - "optional": true - } - } - }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", - "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz", - "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz", - "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz", - "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz", - "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz", - "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz", - "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz", - "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz", - "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz", - "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz", - "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz", - "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz", - "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz", - "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz", - "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz", - "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz", - "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz", - "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz", - "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz", - "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz", - "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openharmony-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz", - "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz", - "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz", - "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz", - "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz", - "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.5", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", - "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", - "dev": true, - "license": "MIT" - }, - "node_modules/@napi-rs/cli": { - "version": "2.18.4", - "resolved": "https://registry.npmjs.org/@napi-rs/cli/-/cli-2.18.4.tgz", - "integrity": "sha512-SgJeA4df9DE2iAEpr3M2H0OKl/yjtg1BnRI5/JyowS71tUWhrfSu2LT0V3vlHET+g1hBVlrO60PmEXwUEKp8Mg==", - "dev": true, - "license": "MIT", - "bin": { - "napi": "scripts/index.js" - }, - "engines": { - "node": ">= 10" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Brooooooklyn" - } - }, - "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", - "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-android-arm64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz", - "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz", - "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz", - "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz", - "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz", - "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz", - "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz", - "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz", - "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz", - "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz", - "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz", - "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz", - "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz", - "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz", - "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz", - "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz", - "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz", - "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz", - "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz", - "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ] - }, - "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz", - "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ] - }, - "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz", - "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz", - "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz", - "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz", - "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@types/chai": { - "version": "5.2.3", - "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", - "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/deep-eql": "*", - "assertion-error": "^2.0.1" - } - }, - "node_modules/@types/deep-eql": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", - "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/estree": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", - "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", - "dev": true, - "license": "MIT" - }, - "node_modules/@vitest/expect": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz", - "integrity": "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/chai": "^5.2.2", - "@vitest/spy": "3.2.4", - "@vitest/utils": "3.2.4", - "chai": "^5.2.0", - "tinyrainbow": "^2.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/mocker": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.4.tgz", - "integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/spy": "3.2.4", - "estree-walker": "^3.0.3", - "magic-string": "^0.30.17" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "msw": "^2.4.9", - "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" - }, - "peerDependenciesMeta": { - "msw": { - "optional": true - }, - "vite": { - "optional": true - } - } - }, - "node_modules/@vitest/pretty-format": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz", - "integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==", - "dev": true, - "license": "MIT", - "dependencies": { - "tinyrainbow": "^2.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/runner": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.4.tgz", - "integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/utils": "3.2.4", - "pathe": "^2.0.3", - "strip-literal": "^3.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/snapshot": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.4.tgz", - "integrity": "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/pretty-format": "3.2.4", - "magic-string": "^0.30.17", - "pathe": "^2.0.3" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/spy": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.4.tgz", - "integrity": "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==", - "dev": true, - "license": "MIT", - "dependencies": { - "tinyspy": "^4.0.3" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/utils": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.4.tgz", - "integrity": "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/pretty-format": "3.2.4", - "loupe": "^3.1.4", - "tinyrainbow": "^2.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/assertion-error": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", - "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - } - }, - "node_modules/cac": { - "version": "6.7.14", - "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", - "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/chai": { - "version": "5.3.3", - "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", - "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", - "dev": true, - "license": "MIT", - "dependencies": { - "assertion-error": "^2.0.1", - "check-error": "^2.1.1", - "deep-eql": "^5.0.1", - "loupe": "^3.1.0", - "pathval": "^2.0.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/check-error": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", - "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 16" - } - }, - "node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/deep-eql": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", - "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/es-module-lexer": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", - "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", - "dev": true, - "license": "MIT" - }, - "node_modules/esbuild": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", - "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.27.3", - "@esbuild/android-arm": "0.27.3", - "@esbuild/android-arm64": "0.27.3", - "@esbuild/android-x64": "0.27.3", - "@esbuild/darwin-arm64": "0.27.3", - "@esbuild/darwin-x64": "0.27.3", - "@esbuild/freebsd-arm64": "0.27.3", - "@esbuild/freebsd-x64": "0.27.3", - "@esbuild/linux-arm": "0.27.3", - "@esbuild/linux-arm64": "0.27.3", - "@esbuild/linux-ia32": "0.27.3", - "@esbuild/linux-loong64": "0.27.3", - "@esbuild/linux-mips64el": "0.27.3", - "@esbuild/linux-ppc64": "0.27.3", - "@esbuild/linux-riscv64": "0.27.3", - "@esbuild/linux-s390x": "0.27.3", - "@esbuild/linux-x64": "0.27.3", - "@esbuild/netbsd-arm64": "0.27.3", - "@esbuild/netbsd-x64": "0.27.3", - "@esbuild/openbsd-arm64": "0.27.3", - "@esbuild/openbsd-x64": "0.27.3", - "@esbuild/openharmony-arm64": "0.27.3", - "@esbuild/sunos-x64": "0.27.3", - "@esbuild/win32-arm64": "0.27.3", - "@esbuild/win32-ia32": "0.27.3", - "@esbuild/win32-x64": "0.27.3" - } - }, - "node_modules/estree-walker": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", - "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/estree": "^1.0.0" - } - }, - "node_modules/expect-type": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", - "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=12.0.0" - } - }, - "node_modules/fdir": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "picomatch": "^3 || ^4" - }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } - } - }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/js-tokens": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", - "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/loupe": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", - "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/magic-string": { - "version": "0.30.21", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", - "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.5" - } - }, - "node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, - "license": "MIT" - }, - "node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "bin": { - "nanoid": "bin/nanoid.cjs" - }, - "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" - } - }, - "node_modules/pathe": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", - "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", - "dev": true, - "license": "MIT" - }, - "node_modules/pathval": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", - "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 14.16" - } - }, - "node_modules/picocolors": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", - "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "dev": true, - "license": "ISC" - }, - "node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/postcss": { - "version": "8.5.14", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz", - "integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/postcss" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "nanoid": "^3.3.11", - "picocolors": "^1.1.1", - "source-map-js": "^1.2.1" - }, - "engines": { - "node": "^10 || ^12 || >=14" - } - }, - "node_modules/rollup": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", - "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/estree": "1.0.8" - }, - "bin": { - "rollup": "dist/bin/rollup" - }, - "engines": { - "node": ">=18.0.0", - "npm": ">=8.0.0" - }, - "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.59.0", - "@rollup/rollup-android-arm64": "4.59.0", - "@rollup/rollup-darwin-arm64": "4.59.0", - "@rollup/rollup-darwin-x64": "4.59.0", - "@rollup/rollup-freebsd-arm64": "4.59.0", - "@rollup/rollup-freebsd-x64": "4.59.0", - "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", - "@rollup/rollup-linux-arm-musleabihf": "4.59.0", - "@rollup/rollup-linux-arm64-gnu": "4.59.0", - "@rollup/rollup-linux-arm64-musl": "4.59.0", - "@rollup/rollup-linux-loong64-gnu": "4.59.0", - "@rollup/rollup-linux-loong64-musl": "4.59.0", - "@rollup/rollup-linux-ppc64-gnu": "4.59.0", - "@rollup/rollup-linux-ppc64-musl": "4.59.0", - "@rollup/rollup-linux-riscv64-gnu": "4.59.0", - "@rollup/rollup-linux-riscv64-musl": "4.59.0", - "@rollup/rollup-linux-s390x-gnu": "4.59.0", - "@rollup/rollup-linux-x64-gnu": "4.59.0", - "@rollup/rollup-linux-x64-musl": "4.59.0", - "@rollup/rollup-openbsd-x64": "4.59.0", - "@rollup/rollup-openharmony-arm64": "4.59.0", - "@rollup/rollup-win32-arm64-msvc": "4.59.0", - "@rollup/rollup-win32-ia32-msvc": "4.59.0", - "@rollup/rollup-win32-x64-gnu": "4.59.0", - "@rollup/rollup-win32-x64-msvc": "4.59.0", - "fsevents": "~2.3.2" - } - }, - "node_modules/siginfo": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", - "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", - "dev": true, - "license": "ISC" - }, - "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/stackback": { - "version": "0.0.2", - "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", - "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", - "dev": true, - "license": "MIT" - }, - "node_modules/std-env": { - "version": "3.10.0", - "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", - "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", - "dev": true, - "license": "MIT" - }, - "node_modules/strip-literal": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", - "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", - "dev": true, - "license": "MIT", - "dependencies": { - "js-tokens": "^9.0.1" - }, - "funding": { - "url": "https://github.com/sponsors/antfu" - } - }, - "node_modules/tinybench": { - "version": "2.9.0", - "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", - "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", - "dev": true, - "license": "MIT" - }, - "node_modules/tinyexec": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", - "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", - "dev": true, - "license": "MIT" - }, - "node_modules/tinyglobby": { - "version": "0.2.15", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", - "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "fdir": "^6.5.0", - "picomatch": "^4.0.3" - }, - "engines": { - "node": ">=12.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/SuperchupuDev" - } - }, - "node_modules/tinypool": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", - "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.0.0 || >=20.0.0" - } - }, - "node_modules/tinyrainbow": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", - "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/tinyspy": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz", - "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/typescript": { - "version": "5.9.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", - "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, - "node_modules/vite": { - "version": "7.3.6", - "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz", - "integrity": "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==", - "dev": true, - "license": "MIT", - "dependencies": { - "esbuild": "^0.27.0 || ^0.28.0", - "fdir": "^6.5.0", - "picomatch": "^4.0.3", - "postcss": "^8.5.6", - "rollup": "^4.43.0", - "tinyglobby": "^0.2.15" - }, - "bin": { - "vite": "bin/vite.js" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - }, - "funding": { - "url": "https://github.com/vitejs/vite?sponsor=1" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" - }, - "peerDependencies": { - "@types/node": "^20.19.0 || >=22.12.0", - "jiti": ">=1.21.0", - "less": "^4.0.0", - "lightningcss": "^1.21.0", - "sass": "^1.70.0", - "sass-embedded": "^1.70.0", - "stylus": ">=0.54.8", - "sugarss": "^5.0.0", - "terser": "^5.16.0", - "tsx": "^4.8.1", - "yaml": "^2.4.2" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - }, - "jiti": { - "optional": true - }, - "less": { - "optional": true - }, - "lightningcss": { - "optional": true - }, - "sass": { - "optional": true - }, - "sass-embedded": { - "optional": true - }, - "stylus": { - "optional": true - }, - "sugarss": { - "optional": true - }, - "terser": { - "optional": true - }, - "tsx": { - "optional": true - }, - "yaml": { - "optional": true - } - } - }, - "node_modules/vite-node": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", - "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", - "dev": true, - "license": "MIT", - "dependencies": { - "cac": "^6.7.14", - "debug": "^4.4.1", - "es-module-lexer": "^1.7.0", - "pathe": "^2.0.3", - "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" - }, - "bin": { - "vite-node": "vite-node.mjs" - }, - "engines": { - "node": "^18.0.0 || ^20.0.0 || >=22.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/vitest": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz", - "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/chai": "^5.2.2", - "@vitest/expect": "3.2.4", - "@vitest/mocker": "3.2.4", - "@vitest/pretty-format": "^3.2.4", - "@vitest/runner": "3.2.4", - "@vitest/snapshot": "3.2.4", - "@vitest/spy": "3.2.4", - "@vitest/utils": "3.2.4", - "chai": "^5.2.0", - "debug": "^4.4.1", - "expect-type": "^1.2.1", - "magic-string": "^0.30.17", - "pathe": "^2.0.3", - "picomatch": "^4.0.2", - "std-env": "^3.9.0", - "tinybench": "^2.9.0", - "tinyexec": "^0.3.2", - "tinyglobby": "^0.2.14", - "tinypool": "^1.1.1", - "tinyrainbow": "^2.0.0", - "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", - "vite-node": "3.2.4", - "why-is-node-running": "^2.3.0" - }, - "bin": { - "vitest": "vitest.mjs" - }, - "engines": { - "node": "^18.0.0 || ^20.0.0 || >=22.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "@edge-runtime/vm": "*", - "@types/debug": "^4.1.12", - "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", - "@vitest/browser": "3.2.4", - "@vitest/ui": "3.2.4", - "happy-dom": "*", - "jsdom": "*" - }, - "peerDependenciesMeta": { - "@edge-runtime/vm": { - "optional": true - }, - "@types/debug": { - "optional": true - }, - "@types/node": { - "optional": true - }, - "@vitest/browser": { - "optional": true - }, - "@vitest/ui": { - "optional": true - }, - "happy-dom": { - "optional": true - }, - "jsdom": { - "optional": true - } - } - }, - "node_modules/why-is-node-running": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", - "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", - "dev": true, - "license": "MIT", - "dependencies": { - "siginfo": "^2.0.0", - "stackback": "0.0.2" - }, - "bin": { - "why-is-node-running": "cli.js" - }, - "engines": { - "node": ">=8" - } - } - } -} diff --git a/languages/typescript/packages/profile/package-lock.json b/languages/typescript/packages/profile/package-lock.json deleted file mode 100644 index 947055049..000000000 --- a/languages/typescript/packages/profile/package-lock.json +++ /dev/null @@ -1,1626 +0,0 @@ -{ - "name": "@cipherstash/profile", - "version": "0.35.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "@cipherstash/profile", - "version": "0.35.0", - "devDependencies": { - "@napi-rs/cli": "^2", - "typescript": "^5", - "vitest": "^3" - }, - "optionalDependencies": { - "@cipherstash/profile-darwin-arm64": "0.35.0", - "@cipherstash/profile-darwin-x64": "0.35.0", - "@cipherstash/profile-linux-arm64-gnu": "0.35.0", - "@cipherstash/profile-linux-x64-gnu": "0.35.0", - "@cipherstash/profile-linux-x64-musl": "0.35.0", - "@cipherstash/profile-win32-x64-msvc": "0.35.0" - } - }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", - "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", - "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", - "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", - "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", - "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", - "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", - "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", - "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", - "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", - "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", - "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", - "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", - "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", - "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", - "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", - "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", - "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", - "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", - "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", - "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", - "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openharmony-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", - "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", - "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", - "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", - "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", - "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.5", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", - "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", - "dev": true, - "license": "MIT" - }, - "node_modules/@napi-rs/cli": { - "version": "2.18.4", - "resolved": "https://registry.npmjs.org/@napi-rs/cli/-/cli-2.18.4.tgz", - "integrity": "sha512-SgJeA4df9DE2iAEpr3M2H0OKl/yjtg1BnRI5/JyowS71tUWhrfSu2LT0V3vlHET+g1hBVlrO60PmEXwUEKp8Mg==", - "dev": true, - "license": "MIT", - "bin": { - "napi": "scripts/index.js" - }, - "engines": { - "node": ">= 10" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Brooooooklyn" - } - }, - "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.1.tgz", - "integrity": "sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-android-arm64": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.1.tgz", - "integrity": "sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.1.tgz", - "integrity": "sha512-mjCpF7GmkRtSJwon+Rq1N8+pI+8l7w5g9Z3vWj4T7abguC4Czwi3Yu/pFaLvA3TTeMVjnu3ctigusqWUfjZzvw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.1.tgz", - "integrity": "sha512-haZ7hJ1JT4e9hqkoT9R/19XW2QKqjfJVv+i5AGg57S+nLk9lQnJ1F/eZloRO3o9Scy9CM3wQ9l+dkXtcBgN5Ew==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.1.tgz", - "integrity": "sha512-czw90wpQq3ZsAVBlinZjAYTKduOjTywlG7fEeWKUA7oCmpA8xdTkxZZlwNJKWqILlq0wehoZcJYfBvOyhPTQ6w==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.1.tgz", - "integrity": "sha512-KVB2rqsxTHuBtfOeySEyzEOB7ltlB/ux38iu2rBQzkjbwRVlkhAGIEDiiYnO2kFOkJp+Z7pUXKyrRRFuFUKt+g==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.1.tgz", - "integrity": "sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.1.tgz", - "integrity": "sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.1.tgz", - "integrity": "sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.1.tgz", - "integrity": "sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.1.tgz", - "integrity": "sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.1.tgz", - "integrity": "sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.1.tgz", - "integrity": "sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.1.tgz", - "integrity": "sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.1.tgz", - "integrity": "sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.1.tgz", - "integrity": "sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.1.tgz", - "integrity": "sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.1.tgz", - "integrity": "sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.1.tgz", - "integrity": "sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.1.tgz", - "integrity": "sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ] - }, - "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.1.tgz", - "integrity": "sha512-4Cv23ZrONRbNtbZa37mLSueXUCtN7MXccChtKpUnQNgF010rjrjfHx3QxkS2PI7LqGT5xXyYs1a7LbzAwT0iCA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ] - }, - "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.1.tgz", - "integrity": "sha512-i1okWYkA4FJICtr7KpYzFpRTHgy5jdDbZiWfvny21iIKky5YExiDXP+zbXzm3dUcFpkEeYNHgQ5fuG236JPq0g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.1.tgz", - "integrity": "sha512-u09m3CuwLzShA0EYKMNiFgcjjzwqtUMLmuCJLeZWjjOYA3IT2Di09KaxGBTP9xVztWyIWjVdsB2E9goMjZvTQg==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.1.tgz", - "integrity": "sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.1.tgz", - "integrity": "sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@types/chai": { - "version": "5.2.3", - "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", - "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/deep-eql": "*", - "assertion-error": "^2.0.1" - } - }, - "node_modules/@types/deep-eql": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", - "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/estree": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", - "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", - "dev": true, - "license": "MIT" - }, - "node_modules/@vitest/expect": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz", - "integrity": "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/chai": "^5.2.2", - "@vitest/spy": "3.2.4", - "@vitest/utils": "3.2.4", - "chai": "^5.2.0", - "tinyrainbow": "^2.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/mocker": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.4.tgz", - "integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/spy": "3.2.4", - "estree-walker": "^3.0.3", - "magic-string": "^0.30.17" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "msw": "^2.4.9", - "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" - }, - "peerDependenciesMeta": { - "msw": { - "optional": true - }, - "vite": { - "optional": true - } - } - }, - "node_modules/@vitest/pretty-format": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz", - "integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==", - "dev": true, - "license": "MIT", - "dependencies": { - "tinyrainbow": "^2.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/runner": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.4.tgz", - "integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/utils": "3.2.4", - "pathe": "^2.0.3", - "strip-literal": "^3.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/snapshot": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.4.tgz", - "integrity": "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/pretty-format": "3.2.4", - "magic-string": "^0.30.17", - "pathe": "^2.0.3" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/spy": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.4.tgz", - "integrity": "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==", - "dev": true, - "license": "MIT", - "dependencies": { - "tinyspy": "^4.0.3" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/utils": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.4.tgz", - "integrity": "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/pretty-format": "3.2.4", - "loupe": "^3.1.4", - "tinyrainbow": "^2.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/assertion-error": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", - "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - } - }, - "node_modules/cac": { - "version": "6.7.14", - "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", - "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/chai": { - "version": "5.3.3", - "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", - "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", - "dev": true, - "license": "MIT", - "dependencies": { - "assertion-error": "^2.0.1", - "check-error": "^2.1.1", - "deep-eql": "^5.0.1", - "loupe": "^3.1.0", - "pathval": "^2.0.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/check-error": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", - "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 16" - } - }, - "node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/deep-eql": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", - "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/es-module-lexer": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", - "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", - "dev": true, - "license": "MIT" - }, - "node_modules/esbuild": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", - "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.27.7", - "@esbuild/android-arm": "0.27.7", - "@esbuild/android-arm64": "0.27.7", - "@esbuild/android-x64": "0.27.7", - "@esbuild/darwin-arm64": "0.27.7", - "@esbuild/darwin-x64": "0.27.7", - "@esbuild/freebsd-arm64": "0.27.7", - "@esbuild/freebsd-x64": "0.27.7", - "@esbuild/linux-arm": "0.27.7", - "@esbuild/linux-arm64": "0.27.7", - "@esbuild/linux-ia32": "0.27.7", - "@esbuild/linux-loong64": "0.27.7", - "@esbuild/linux-mips64el": "0.27.7", - "@esbuild/linux-ppc64": "0.27.7", - "@esbuild/linux-riscv64": "0.27.7", - "@esbuild/linux-s390x": "0.27.7", - "@esbuild/linux-x64": "0.27.7", - "@esbuild/netbsd-arm64": "0.27.7", - "@esbuild/netbsd-x64": "0.27.7", - "@esbuild/openbsd-arm64": "0.27.7", - "@esbuild/openbsd-x64": "0.27.7", - "@esbuild/openharmony-arm64": "0.27.7", - "@esbuild/sunos-x64": "0.27.7", - "@esbuild/win32-arm64": "0.27.7", - "@esbuild/win32-ia32": "0.27.7", - "@esbuild/win32-x64": "0.27.7" - } - }, - "node_modules/estree-walker": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", - "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/estree": "^1.0.0" - } - }, - "node_modules/expect-type": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", - "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=12.0.0" - } - }, - "node_modules/fdir": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "picomatch": "^3 || ^4" - }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } - } - }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/js-tokens": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", - "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/loupe": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", - "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/magic-string": { - "version": "0.30.21", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", - "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.5" - } - }, - "node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, - "license": "MIT" - }, - "node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "bin": { - "nanoid": "bin/nanoid.cjs" - }, - "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" - } - }, - "node_modules/pathe": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", - "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", - "dev": true, - "license": "MIT" - }, - "node_modules/pathval": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", - "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 14.16" - } - }, - "node_modules/picocolors": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", - "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "dev": true, - "license": "ISC" - }, - "node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/postcss": { - "version": "8.5.12", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.12.tgz", - "integrity": "sha512-W62t/Se6rA0Az3DfCL0AqJwXuKwBeYg6nOaIgzP+xZ7N5BFCI7DYi1qs6ygUYT6rvfi6t9k65UMLJC+PHZpDAA==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/postcss" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "nanoid": "^3.3.11", - "picocolors": "^1.1.1", - "source-map-js": "^1.2.1" - }, - "engines": { - "node": "^10 || ^12 || >=14" - } - }, - "node_modules/rollup": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.1.tgz", - "integrity": "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/estree": "1.0.8" - }, - "bin": { - "rollup": "dist/bin/rollup" - }, - "engines": { - "node": ">=18.0.0", - "npm": ">=8.0.0" - }, - "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.60.1", - "@rollup/rollup-android-arm64": "4.60.1", - "@rollup/rollup-darwin-arm64": "4.60.1", - "@rollup/rollup-darwin-x64": "4.60.1", - "@rollup/rollup-freebsd-arm64": "4.60.1", - "@rollup/rollup-freebsd-x64": "4.60.1", - "@rollup/rollup-linux-arm-gnueabihf": "4.60.1", - "@rollup/rollup-linux-arm-musleabihf": "4.60.1", - "@rollup/rollup-linux-arm64-gnu": "4.60.1", - "@rollup/rollup-linux-arm64-musl": "4.60.1", - "@rollup/rollup-linux-loong64-gnu": "4.60.1", - "@rollup/rollup-linux-loong64-musl": "4.60.1", - "@rollup/rollup-linux-ppc64-gnu": "4.60.1", - "@rollup/rollup-linux-ppc64-musl": "4.60.1", - "@rollup/rollup-linux-riscv64-gnu": "4.60.1", - "@rollup/rollup-linux-riscv64-musl": "4.60.1", - "@rollup/rollup-linux-s390x-gnu": "4.60.1", - "@rollup/rollup-linux-x64-gnu": "4.60.1", - "@rollup/rollup-linux-x64-musl": "4.60.1", - "@rollup/rollup-openbsd-x64": "4.60.1", - "@rollup/rollup-openharmony-arm64": "4.60.1", - "@rollup/rollup-win32-arm64-msvc": "4.60.1", - "@rollup/rollup-win32-ia32-msvc": "4.60.1", - "@rollup/rollup-win32-x64-gnu": "4.60.1", - "@rollup/rollup-win32-x64-msvc": "4.60.1", - "fsevents": "~2.3.2" - } - }, - "node_modules/siginfo": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", - "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", - "dev": true, - "license": "ISC" - }, - "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/stackback": { - "version": "0.0.2", - "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", - "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", - "dev": true, - "license": "MIT" - }, - "node_modules/std-env": { - "version": "3.10.0", - "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", - "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", - "dev": true, - "license": "MIT" - }, - "node_modules/strip-literal": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", - "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", - "dev": true, - "license": "MIT", - "dependencies": { - "js-tokens": "^9.0.1" - }, - "funding": { - "url": "https://github.com/sponsors/antfu" - } - }, - "node_modules/tinybench": { - "version": "2.9.0", - "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", - "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", - "dev": true, - "license": "MIT" - }, - "node_modules/tinyexec": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", - "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", - "dev": true, - "license": "MIT" - }, - "node_modules/tinyglobby": { - "version": "0.2.15", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", - "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "fdir": "^6.5.0", - "picomatch": "^4.0.3" - }, - "engines": { - "node": ">=12.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/SuperchupuDev" - } - }, - "node_modules/tinypool": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", - "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.0.0 || >=20.0.0" - } - }, - "node_modules/tinyrainbow": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", - "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/tinyspy": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz", - "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/typescript": { - "version": "5.9.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", - "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, - "node_modules/vite": { - "version": "7.3.5", - "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.5.tgz", - "integrity": "sha512-KuOaNhcnGFN2zIPGA7wRmzF+lJA1sea7rHq17aiJ++9lzY1WWG6Jpwqwe1KNbRVPIqHmr8GLYx7jbrQcN/7/ww==", - "dev": true, - "license": "MIT", - "dependencies": { - "esbuild": "^0.27.0", - "fdir": "^6.5.0", - "picomatch": "^4.0.3", - "postcss": "^8.5.6", - "rollup": "^4.43.0", - "tinyglobby": "^0.2.15" - }, - "bin": { - "vite": "bin/vite.js" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - }, - "funding": { - "url": "https://github.com/vitejs/vite?sponsor=1" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" - }, - "peerDependencies": { - "@types/node": "^20.19.0 || >=22.12.0", - "jiti": ">=1.21.0", - "less": "^4.0.0", - "lightningcss": "^1.21.0", - "sass": "^1.70.0", - "sass-embedded": "^1.70.0", - "stylus": ">=0.54.8", - "sugarss": "^5.0.0", - "terser": "^5.16.0", - "tsx": "^4.8.1", - "yaml": "^2.4.2" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - }, - "jiti": { - "optional": true - }, - "less": { - "optional": true - }, - "lightningcss": { - "optional": true - }, - "sass": { - "optional": true - }, - "sass-embedded": { - "optional": true - }, - "stylus": { - "optional": true - }, - "sugarss": { - "optional": true - }, - "terser": { - "optional": true - }, - "tsx": { - "optional": true - }, - "yaml": { - "optional": true - } - } - }, - "node_modules/vite-node": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", - "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", - "dev": true, - "license": "MIT", - "dependencies": { - "cac": "^6.7.14", - "debug": "^4.4.1", - "es-module-lexer": "^1.7.0", - "pathe": "^2.0.3", - "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" - }, - "bin": { - "vite-node": "vite-node.mjs" - }, - "engines": { - "node": "^18.0.0 || ^20.0.0 || >=22.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/vitest": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz", - "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/chai": "^5.2.2", - "@vitest/expect": "3.2.4", - "@vitest/mocker": "3.2.4", - "@vitest/pretty-format": "^3.2.4", - "@vitest/runner": "3.2.4", - "@vitest/snapshot": "3.2.4", - "@vitest/spy": "3.2.4", - "@vitest/utils": "3.2.4", - "chai": "^5.2.0", - "debug": "^4.4.1", - "expect-type": "^1.2.1", - "magic-string": "^0.30.17", - "pathe": "^2.0.3", - "picomatch": "^4.0.2", - "std-env": "^3.9.0", - "tinybench": "^2.9.0", - "tinyexec": "^0.3.2", - "tinyglobby": "^0.2.14", - "tinypool": "^1.1.1", - "tinyrainbow": "^2.0.0", - "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", - "vite-node": "3.2.4", - "why-is-node-running": "^2.3.0" - }, - "bin": { - "vitest": "vitest.mjs" - }, - "engines": { - "node": "^18.0.0 || ^20.0.0 || >=22.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "@edge-runtime/vm": "*", - "@types/debug": "^4.1.12", - "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", - "@vitest/browser": "3.2.4", - "@vitest/ui": "3.2.4", - "happy-dom": "*", - "jsdom": "*" - }, - "peerDependenciesMeta": { - "@edge-runtime/vm": { - "optional": true - }, - "@types/debug": { - "optional": true - }, - "@types/node": { - "optional": true - }, - "@vitest/browser": { - "optional": true - }, - "@vitest/ui": { - "optional": true - }, - "happy-dom": { - "optional": true - }, - "jsdom": { - "optional": true - } - } - }, - "node_modules/why-is-node-running": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", - "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", - "dev": true, - "license": "MIT", - "dependencies": { - "siginfo": "^2.0.0", - "stackback": "0.0.2" - }, - "bin": { - "why-is-node-running": "cli.js" - }, - "engines": { - "node": ">=8" - } - } - } -} From 58cfebdc254f65e594add0124b3700f52535e302 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Mon, 6 Jul 2026 16:33:05 +1000 Subject: [PATCH 327/686] docs(release): address Copilot review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - release-npm.yml: use `npm ci` instead of `npm install` in the Version Packages job so the committed lockfile is a read-only input and never mutated mid-run (keeps the generated PR deterministic). - docs/npm-releases.md: correct the Scope bullet — non-product packages are excluded purely by the `workspaces` globs; the `.changeset/config.json` `ignore` list is empty (already documented as dropped in the Validation log). - docs/npm-releases.md: `0.39.0` was a real-but-unintended publish, not a "phantom" one; reword for accuracy. - docs/npm-releases.md: mirror the `npm ci` change in the prose. --- .github/imported-workflows/release-npm.yml | 2 +- docs/npm-releases.md | 9 +++++---- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/.github/imported-workflows/release-npm.yml b/.github/imported-workflows/release-npm.yml index bd0ab2fde..bb8710470 100644 --- a/.github/imported-workflows/release-npm.yml +++ b/.github/imported-workflows/release-npm.yml @@ -44,7 +44,7 @@ jobs: # `--ignore-scripts` and no workspace build keep this off the napi # toolchain entirely — versioning only reads/writes package.json + md. - name: Install changesets - run: npm install --no-workspaces --ignore-scripts + run: npm ci --no-workspaces --ignore-scripts - name: Create Version Packages PR uses: changesets/action@v1 diff --git a/docs/npm-releases.md b/docs/npm-releases.md index a5fa0902f..84dbac7f4 100644 --- a/docs/npm-releases.md +++ b/docs/npm-releases.md @@ -10,7 +10,7 @@ Releasing the `stack-auth` **crate** (release-plz → crates.io) is independent of publishing the `@cipherstash/auth` **npm package** that binds to it. Hand-editing -the npm `package.json` version caused real drift (a phantom `0.39.0` publish, a +the npm `package.json` version caused real drift (an unintended `0.39.0` publish, a `0.40.0`/`0.38.0` mismatch, a reconstructed changelog — see PR #2057). We do **not** want lock-step version numbers. We want a reliable, low-ceremony @@ -50,8 +50,9 @@ registry** and never read or write each other's files: - **Not managed** (build artifacts / private): the `npm/*` platform sub-packages (`@cipherstash/auth-darwin-x64`, …) — stamped from the main version at publish time (`publish-auth-npm.yml:198-221`); the `0.0.0-pre` wasm package; and - non-product packages (`load-tests`, health-checks). Excluded both by the - `workspaces` globs and the `ignore` list in `.changeset/config.json`. + non-product packages (`load-tests`, health-checks). Excluded purely by the + `workspaces` globs in the root `package.json` — the `.changeset/config.json` + `ignore` list is empty (see "Validation log" for why it was dropped). ## What the spike validated @@ -85,7 +86,7 @@ the effect on the existing `npm install` steps was checked empirically: optional deps per runner. The release workflow installs only the changesets CLI -(`npm install --no-workspaces --ignore-scripts`), so the versioning job never +(`npm ci --no-workspaces --ignore-scripts`), so the versioning job never touches the napi toolchain. ### Still deferred (follow-ups, not blocking this PR) From df129f2b85c91db34b127b3eb2b15bc724765955 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Mon, 6 Jul 2026 16:34:30 +1000 Subject: [PATCH 328/686] docs(release): drop remaining spike framing from npm-releases.md --- docs/npm-releases.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/npm-releases.md b/docs/npm-releases.md index 84dbac7f4..79ebd028e 100644 --- a/docs/npm-releases.md +++ b/docs/npm-releases.md @@ -54,7 +54,7 @@ registry** and never read or write each other's files: `workspaces` globs in the root `package.json` — the `.changeset/config.json` `ignore` list is empty (see "Validation log" for why it was dropped). -## What the spike validated +## What this validates - `changeset status` discovers **exactly** `@cipherstash/auth` and `@cipherstash/profile`, ignoring the platform sub-packages and wasm. (See @@ -146,7 +146,7 @@ non-Rust binding regardless of which intent model wins. ## Validation log -Run on the spike branch with two throwaway changesets (since removed): +Run during development with two throwaway changesets (since removed): ``` $ npx @changesets/cli status From 051d013a662b1eb671c30b99ad66c26afc649da5 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Mon, 6 Jul 2026 17:11:08 +1000 Subject: [PATCH 329/686] feat(release): automate npm publishing on Version-PR merge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wires the publish half of the changesets flow so merging a "Version Packages" PR actually ships to npm (previously versioning-only). - publish-auth-npm.yml: add a push trigger path-filtered on stack-auth/node/package.json + a `preflight` job that publishes only when the version isn't already on npm (idempotent no-op otherwise). workflow_dispatch keeps its dry-run default; dry-run now flows through preflight outputs. - publish-profile-npm.yml (new): mirror of auth minus wasm, stamping the @cipherstash/profile-* platform pins under optionalDependencies. Gives profile a publish path (previously none) and rewrites its pins at publish time — addresses the stale-pin follow-up. - release-npm.yml: run versioning via a `version-packages` npm script (`changeset version && npm install --package-lock-only`) so the root lockfile's member versions re-sync in the Version PR commit instead of drifting; use `npm run` (local bin) rather than `npx`. - Docs: README + npm-releases.md now describe the automated publish, the root-install-before-npx-changeset caveat, first-profile-publish note, and the GH-Actions-PR-permission prerequisite. The Version PR merge is the human gate (no separate approval), matching cipherstash/stack. --- .../imported-workflows/publish-auth-npm.yml | 83 +++++- .../publish-profile-npm.yml | 265 ++++++++++++++++++ .github/imported-workflows/release-npm.yml | 24 +- docs/npm-releases.md | 57 ++-- 4 files changed, 397 insertions(+), 32 deletions(-) create mode 100644 .github/imported-workflows/publish-profile-npm.yml diff --git a/.github/imported-workflows/publish-auth-npm.yml b/.github/imported-workflows/publish-auth-npm.yml index b7b9dd043..fa184c23d 100644 --- a/.github/imported-workflows/publish-auth-npm.yml +++ b/.github/imported-workflows/publish-auth-npm.yml @@ -1,10 +1,24 @@ name: "Publish @cipherstash/auth to npm" on: - # Published manually. `@cipherstash/auth` is versioned independently of the - # Rust `stack-auth` crate (its `package.json` version), so it must NOT auto- - # publish on release-plz's `stack-auth-v*` tags — that would ship the node - # package at the Rust crate's version. Trigger via "Run workflow" instead. + # Automated publish, driven by changesets (see docs/npm-releases.md). + # Merging the "Version Packages" PR bumps `packages/stack-auth/node/package.json` + # on `main`; that push triggers this workflow, and the `preflight` job below + # publishes iff the new version is not already on npm (so ordinary pushes that + # touch package.json for other reasons are no-ops). The Version Packages PR + # review/merge is the human gate — there is no separate approval step, matching + # cipherstash/stack. + # + # `@cipherstash/auth` is versioned independently of the Rust `stack-auth` crate, + # so this must NOT trigger on release-plz's `stack-auth-v*` tags — that would + # ship the node package at the Rust crate's version. + push: + branches: + - main + paths: + - packages/stack-auth/node/package.json + # Manual runs default to a dry run; flip `dry_run` off to force a real publish + # (e.g. to re-publish a version the automated path skipped). workflow_dispatch: inputs: dry_run: @@ -29,7 +43,55 @@ concurrency: cancel-in-progress: false jobs: + # Decides whether this run should publish, and at what version. + # - workflow_dispatch: honours the `dry_run` input (defaults to true). + # - push (Version PR merged): real publish, but only if the version in + # package.json is not already on npm — makes the trigger idempotent so a + # push that changed package.json for any other reason is a clean no-op. + preflight: + name: Preflight + runs-on: ubuntu-latest + outputs: + should_publish: ${{ steps.decide.outputs.should_publish }} + dry_run: ${{ steps.decide.outputs.dry_run }} + version: ${{ steps.decide.outputs.version }} + steps: + - uses: actions/checkout@v6 + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: 24 + + - name: Decide + id: decide + run: | + set -euo pipefail + VERSION=$(jq -r .version "${{ env.WORKING_DIR }}/package.json") + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + + if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + DRY_RUN="${{ inputs.dry_run }}" + else + DRY_RUN="false" + fi + echo "dry_run=$DRY_RUN" >> "$GITHUB_OUTPUT" + + # Idempotency guard (push path only): skip if this exact version is + # already published. `npm view` exits non-zero / empty when the exact + # version does not exist. + PUBLISHED=$(npm view "@cipherstash/auth@$VERSION" version 2>/dev/null || true) + if [ "${{ github.event_name }}" = "push" ] && [ "$PUBLISHED" = "$VERSION" ]; then + echo "should_publish=false" >> "$GITHUB_OUTPUT" + echo "::notice::@cipherstash/auth@$VERSION already on npm — nothing to publish." + else + echo "should_publish=true" >> "$GITHUB_OUTPUT" + echo "::notice::Will $( [ "$DRY_RUN" = "true" ] && echo 'dry-run' || echo 'publish') @cipherstash/auth@$VERSION." + fi + build: + needs: preflight + if: needs.preflight.outputs.should_publish == 'true' strategy: fail-fast: true matrix: @@ -103,6 +165,8 @@ jobs: build-wasm: name: Build - wasm32 (bundler target) + needs: preflight + if: needs.preflight.outputs.should_publish == 'true' runs-on: blacksmith-8vcpu-ubuntu-2404 steps: @@ -151,7 +215,7 @@ jobs: publish: name: Publish - needs: [build, build-wasm] + needs: [preflight, build, build-wasm] runs-on: ubuntu-latest steps: @@ -188,7 +252,8 @@ jobs: - name: Determine version and npm tag id: version run: | - # Manual publish: the version is whatever `package.json` declares. + # The version is whatever `package.json` declares (changesets bumped it + # in the Version Packages PR; a manual dispatch publishes as-is). VERSION=$(jq -r .version "${{ env.WORKING_DIR }}/package.json") echo "version=$VERSION" >> "$GITHUB_OUTPUT" @@ -239,7 +304,7 @@ jobs: done - name: Publish platform packages - if: ${{ !inputs.dry_run }} + if: needs.preflight.outputs.dry_run == 'false' working-directory: ${{ env.WORKING_DIR }} env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} @@ -252,12 +317,12 @@ jobs: done - name: Publish main package - if: ${{ !inputs.dry_run }} + if: needs.preflight.outputs.dry_run == 'false' working-directory: ${{ env.WORKING_DIR }} env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} run: npm publish --access public --tag ${{ steps.version.outputs.npm_tag }} - name: Dry run summary - if: ${{ inputs.dry_run }} + if: needs.preflight.outputs.dry_run == 'true' run: echo "Dry run complete. Set dry_run to false to actually publish." diff --git a/.github/imported-workflows/publish-profile-npm.yml b/.github/imported-workflows/publish-profile-npm.yml new file mode 100644 index 000000000..132cacc20 --- /dev/null +++ b/.github/imported-workflows/publish-profile-npm.yml @@ -0,0 +1,265 @@ +name: "Publish @cipherstash/profile to npm" + +on: + # Automated publish, driven by changesets (see docs/npm-releases.md). + # Merging the "Version Packages" PR bumps + # `packages/stack-profile/node/package.json` on `main`; that push triggers this + # workflow, and the `preflight` job below publishes iff the new version is not + # already on npm (so ordinary pushes that touch package.json for other reasons + # are no-ops). The Version Packages PR review/merge is the human gate — there + # is no separate approval step, matching cipherstash/stack. + # + # `@cipherstash/profile` is versioned independently of the Rust `stack-profile` + # crate, so this must NOT trigger on release-plz's `stack-profile-v*` tags. + # + # Mirrors publish-auth-npm.yml minus the wasm build — stack-profile has no wasm + # target — and stamps its platform pins under `optionalDependencies` (auth uses + # `peerDependencies` for Deno's strict resolver; profile has no such constraint). + push: + branches: + - main + paths: + - packages/stack-profile/node/package.json + # Manual runs default to a dry run; flip `dry_run` off to force a real publish + # (e.g. to re-publish a version the automated path skipped). + workflow_dispatch: + inputs: + dry_run: + description: "Dry run (do not actually publish)" + type: boolean + default: true + +permissions: + contents: read + id-token: write # npm trusted publishing (OIDC) + +defaults: + run: + shell: bash + +env: + CARGO_TERM_COLOR: always + WORKING_DIR: packages/stack-profile/node + +concurrency: + group: publish-profile-npm + cancel-in-progress: false + +jobs: + # Decides whether this run should publish, and at what version. + # - workflow_dispatch: honours the `dry_run` input (defaults to true). + # - push (Version PR merged): real publish, but only if the version in + # package.json is not already on npm — makes the trigger idempotent so a + # push that changed package.json for any other reason is a clean no-op. + preflight: + name: Preflight + runs-on: ubuntu-latest + outputs: + should_publish: ${{ steps.decide.outputs.should_publish }} + dry_run: ${{ steps.decide.outputs.dry_run }} + version: ${{ steps.decide.outputs.version }} + steps: + - uses: actions/checkout@v6 + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: 24 + + - name: Decide + id: decide + run: | + set -euo pipefail + VERSION=$(jq -r .version "${{ env.WORKING_DIR }}/package.json") + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + + if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + DRY_RUN="${{ inputs.dry_run }}" + else + DRY_RUN="false" + fi + echo "dry_run=$DRY_RUN" >> "$GITHUB_OUTPUT" + + # Idempotency guard (push path only): skip if this exact version is + # already published. `npm view` exits non-zero / empty when the exact + # version does not exist. + PUBLISHED=$(npm view "@cipherstash/profile@$VERSION" version 2>/dev/null || true) + if [ "${{ github.event_name }}" = "push" ] && [ "$PUBLISHED" = "$VERSION" ]; then + echo "should_publish=false" >> "$GITHUB_OUTPUT" + echo "::notice::@cipherstash/profile@$VERSION already on npm — nothing to publish." + else + echo "should_publish=true" >> "$GITHUB_OUTPUT" + echo "::notice::Will $( [ "$DRY_RUN" = "true" ] && echo 'dry-run' || echo 'publish') @cipherstash/profile@$VERSION." + fi + + build: + needs: preflight + if: needs.preflight.outputs.should_publish == 'true' + strategy: + fail-fast: true + matrix: + include: + - target: x86_64-apple-darwin + os: macos-latest + - target: aarch64-apple-darwin + os: macos-latest + - target: x86_64-unknown-linux-gnu + os: blacksmith-8vcpu-ubuntu-2404 + - target: aarch64-unknown-linux-gnu + os: blacksmith-8vcpu-ubuntu-2404-arm + - target: x86_64-unknown-linux-musl + os: blacksmith-8vcpu-ubuntu-2404 + - target: x86_64-pc-windows-msvc + os: windows-latest + + name: Build - ${{ matrix.target }} + runs-on: ${{ matrix.os }} + + steps: + - uses: actions/checkout@v6 + + - name: Exclude Windows build output from Defender realtime scan + # Defender real-time scanning every file Cargo writes under target/ + # (10k+ small files) is the dominant cost of MSVC napi builds — + # usually 2-4× slowdown vs. Linux for the same code. Excluding only + # this package's build output keeps the mitigation scoped to the + # generated artifacts while reducing Windows build time. See CIP-3109. + if: runner.os == 'Windows' + shell: pwsh + run: | + Add-MpPreference -ExclusionPath "${{ github.workspace }}\${{ env.WORKING_DIR }}\target" + + - name: Setup Rust + uses: dtolnay/rust-toolchain@1.90.0 + with: + targets: ${{ matrix.target }} + + - name: Setup Rust cache + uses: Swatinem/rust-cache@v2 + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: 24 + + - name: Install musl tools + if: matrix.target == 'x86_64-unknown-linux-musl' + run: sudo apt-get update && sudo apt-get install -y musl-tools + + - name: Install dependencies + working-directory: ${{ env.WORKING_DIR }} + run: npm install --omit=optional + + - name: Build native module + working-directory: ${{ env.WORKING_DIR }} + run: npx napi build --platform --release --target ${{ matrix.target }} --strip + + - name: Upload artifact + uses: actions/upload-artifact@v7 + with: + name: bindings-${{ matrix.target }} + path: ${{ env.WORKING_DIR }}/*.node + if-no-files-found: error + + publish: + name: Publish + needs: [preflight, build] + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v6 + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: 24 + registry-url: https://registry.npmjs.org + + - name: Install dependencies + working-directory: ${{ env.WORKING_DIR }} + run: npm install --omit=optional + + - name: Download all artifacts + uses: actions/download-artifact@v8 + with: + path: ${{ env.WORKING_DIR }}/artifacts + + - name: Move artifacts to platform packages + working-directory: ${{ env.WORKING_DIR }} + run: npx napi artifacts --dir artifacts + + - name: Determine version and npm tag + id: version + run: | + # The version is whatever `package.json` declares (changesets bumped it + # in the Version Packages PR; a manual dispatch publishes as-is). + VERSION=$(jq -r .version "${{ env.WORKING_DIR }}/package.json") + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + + if [[ "$VERSION" == *"-"* ]]; then + echo "npm_tag=next" >> "$GITHUB_OUTPUT" + else + echo "npm_tag=latest" >> "$GITHUB_OUTPUT" + fi + + echo "Publishing version: $VERSION (tag: $(if [[ "$VERSION" == *"-"* ]]; then echo next; else echo latest; fi))" + + - name: Set package versions + working-directory: ${{ env.WORKING_DIR }} + run: | + VERSION="${{ steps.version.outputs.version }}" + npm version "$VERSION" --no-git-tag-version --allow-same-version + # Pin the napi platform sub-packages to the publish version. Profile + # keeps these under `optionalDependencies` (standard napi layout). + jq --arg v "$VERSION" ' + .optionalDependencies |= with_entries( + if (.key | startswith("@cipherstash/profile-")) + then .value = $v + else . + end + ) + ' package.json > package.json.tmp && mv package.json.tmp package.json + for dir in npm/*/; do + if [ -f "$dir/package.json" ]; then + cd "$dir" + npm version "$VERSION" --no-git-tag-version --allow-same-version + cd - + fi + done + + - name: List packages + working-directory: ${{ env.WORKING_DIR }} + run: | + echo "=== Main package ===" + cat package.json | jq '{name, version}' + echo "" + for dir in npm/*/; do + echo "=== $(basename $dir) ===" + cat "$dir/package.json" | jq '{name, version, os, cpu}' + ls -la "$dir"*.node 2>/dev/null || echo " (no .node file)" + echo "" + done + + - name: Publish platform packages + if: needs.preflight.outputs.dry_run == 'false' + working-directory: ${{ env.WORKING_DIR }} + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + run: | + for dir in npm/*/; do + if [ -f "$dir/package.json" ]; then + echo "Publishing $(jq -r .name "$dir/package.json")..." + npm publish "$dir" --access public --tag ${{ steps.version.outputs.npm_tag }} + fi + done + + - name: Publish main package + if: needs.preflight.outputs.dry_run == 'false' + working-directory: ${{ env.WORKING_DIR }} + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + run: npm publish --access public --tag ${{ steps.version.outputs.npm_tag }} + + - name: Dry run summary + if: needs.preflight.outputs.dry_run == 'true' + run: echo "Dry run complete. Set dry_run to false to actually publish." diff --git a/.github/imported-workflows/release-npm.yml b/.github/imported-workflows/release-npm.yml index bb8710470..ab49c6a1e 100644 --- a/.github/imported-workflows/release-npm.yml +++ b/.github/imported-workflows/release-npm.yml @@ -3,10 +3,14 @@ # Owns the *versioning* half of npm releases: on push to main, the changesets # action opens/updates a "Version Packages" PR that applies accumulated # .changeset/*.md entries to package.json + CHANGELOG.md for the @cipherstash -# npm products. It deliberately does NOT publish — the @cipherstash/auth and -# @cipherstash/profile packages need a matrix napi/wasm build, so actual -# publishing stays with the existing publish-auth-npm.yml (triggered manually, -# or auto-triggered off the Version Packages PR merge in a follow-up). +# npm products. It deliberately does NOT publish here — the @cipherstash/auth +# and @cipherstash/profile packages need a matrix napi/wasm build. +# +# Publishing is automated but lives in the per-product matrix workflows: merging +# the Version Packages PR bumps each product's node/package.json on main, which +# triggers publish-auth-npm.yml / publish-profile-npm.yml (path-filtered on that +# file, with an npm-version guard so only a genuine bump publishes). That merge +# is the human gate. # # This is the seam with release-plz: release-plz owns Cargo.* + crates.io; # this owns package.json + npm. They never touch the same files. @@ -49,9 +53,15 @@ jobs: - name: Create Version Packages PR uses: changesets/action@v1 with: - # `version` regenerates package.json + CHANGELOG from .changeset/*.md. - # No `publish:` on purpose — see header comment. - version: npx changeset version + # `version-packages` (root package.json) runs `changeset version` to + # regenerate package.json + CHANGELOG from .changeset/*.md, then + # `npm install --package-lock-only` so the root lockfile's recorded + # member versions re-sync in the same commit (otherwise they drift). + # Run via `npm run` — not `npx` — so the local `.bin/changeset` + # resolves and `&&` executes in a shell. + # No `publish:` on purpose — publishing is the matrix workflows' job + # (see header comment). + version: npm run version-packages title: "Release: version @cipherstash npm packages" commit: "chore(release): version @cipherstash npm packages" env: diff --git a/docs/npm-releases.md b/docs/npm-releases.md index 79ebd028e..1c7fd5342 100644 --- a/docs/npm-releases.md +++ b/docs/npm-releases.md @@ -2,9 +2,9 @@ > Adopts [changesets](https://github.com/changesets/changesets) for the `@cipherstash` > npm products while release-plz keeps owning the Rust crates. Tracking issue: -> [CIP-3278](https://linear.app/cipherstash/issue/CIP-3278). Scope of the -> initial PR: **versioning** (the Version Packages PR); publishing stays manual -> for now (see "Still deferred"). +> [CIP-3278](https://linear.app/cipherstash/issue/CIP-3278). Covers the full +> loop: **versioning** (the Version Packages PR) **and publishing** (the native +> matrix workflows, auto-triggered when a version bump lands on `main`). ## Why @@ -30,18 +30,29 @@ registry** and never read or write each other's files: ## How a release works -1. **Contributor**: change a JS/TS package, then `npx changeset` (from repo root) - → pick package(s) + bump level + summary → commit the generated - `.changeset/*.md` with your code. No hand-edited versions or changelogs. +1. **Contributor**: change a JS/TS package, then `npx changeset` (from repo root, + after a root `npm install`) → pick package(s) + bump level + summary → commit + the generated `.changeset/*.md` with your code. No hand-edited versions or + changelogs. 2. **On merge to `main`**: `.github/workflows/release-npm.yml` runs the `changesets/action`, which opens/updates a **"Version Packages" PR** applying - the accumulated bumps to `package.json` + `CHANGELOG.md`. + the accumulated bumps to `package.json` + `CHANGELOG.md` (and re-syncs the root + `package-lock.json` via the `version-packages` script). 3. **Cut the release**: merge the Version Packages PR. -4. **Publish artifacts**: the existing `publish-auth-npm.yml` builds the napi - matrix + wasm and publishes to npm, reading the version changesets just wrote - (`publish-auth-npm.yml:187` already does `jq -r .version package.json`). The - release workflow here deliberately does **not** publish — napi packages need - the matrix build the bespoke workflow already owns. +4. **Publish (automated)**: merging step 3 bumps each product's + `node/package.json` on `main`. Each publish workflow + (`publish-auth-npm.yml`, `publish-profile-npm.yml`) is **path-filtered on its + own `node/package.json`**, so the bump triggers it; a `preflight` job then + publishes **only if that version is not already on npm** (so any other push + touching `package.json` is a no-op). It builds the napi matrix (+ wasm for + auth) and publishes, reading the version changesets just wrote. The release + workflow itself deliberately does **not** publish — napi packages need the + matrix build the bespoke workflows own. + +The seam: `release-npm.yml` versions; the `publish-*-npm.yml` matrix workflows +publish. The **Version Packages PR merge is the single human gate** — there is no +separate publish approval, matching `cipherstash/stack` (whose pure-JS packages +let `changesets/action` publish inline; ours can't because of the native matrix). ## Scope @@ -89,16 +100,30 @@ The release workflow installs only the changesets CLI (`npm ci --no-workspaces --ignore-scripts`), so the versioning job never touches the napi toolchain. -### Still deferred (follow-ups, not blocking this PR) +### Operational notes -- **Publish stays manual.** This PR wires *versioning* (the Version Packages - PR). Auto-triggering `publish-auth-npm.yml` on the Version PR merge is a - separate change. +- **"Allow GitHub Actions to create and approve pull requests" must be on.** + Without it, `release-npm.yml` runs green but silently opens no Version Packages + PR (a 403 the workflow can't self-guard). Repo → Settings → Actions → General. +- **First `@cipherstash/profile` publish.** Profile and its `@cipherstash/profile-*` + platform sub-packages are not yet on npm; the first Version Packages merge that + bumps profile creates them. Auth is already published, so its guard skips until + the next bump. - **CHANGELOG handover.** The first `changeset version` will prepend a changesets-formatted section above the existing hand-written history (same `## x.y.z` shape), so no migration is required; merging this PR with no pending `.changeset/*.md` is a no-op. +### Still deferred (follow-ups, not blocking this PR) + +- **npm provenance / OIDC trusted publishing.** The matrix workflows authenticate + with `NPM_TOKEN`. Moving to OIDC trusted publishing (as `cipherstash/stack` + does) would add provenance attestations; it needs per-package npm config and a + GitHub-hosted publish runner. +- **Optional publish approval gate.** Publishing is gated only by the Version + Packages PR review/merge. If a stricter gate is wanted, add a GitHub + Environment (e.g. `npm-publish`) with required reviewers to the `publish` jobs. + ## Extending to Python / C# / Ruby (and future stack-encrypt, stack-zerokms) Changesets is npm-only — it does not generalize to PyPI / NuGet / RubyGems. The From 916bab90382e7eeb832ac61e9d7d801c7473483a Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Sun, 28 Jun 2026 20:45:01 +1000 Subject: [PATCH 330/686] refactor(stack-auth): decompose AuthError into per-error structs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every AuthError variant now wraps a dedicated struct that owns its Display message, miette Diagnostic (help/url) and machine-readable code, plus any structured payload. The enum is a thin dispatcher: Display/Diagnostic delegate via `transparent`, and `error_code()` + a generic `Serialize` delegate through `kind()`. Adds `AlreadyConsumed` and `Internal` variants so caller-bug / lock states flow through the error type instead of throwing bare. The new `Serialize` impl emits the flat FFI shape `{ type, message, help?, url?, ...payload }` consumed by the Node/Wasm bindings — help/url captured generically from the Diagnostic surface, payload per-variant (e.g. WorkspaceMismatch's expected/actual). Ergonomic `From` impls keep `?` working at call sites. Construction/match sites across the crate and the CLI adapted to the wrapped variants; no behaviour change. Claude-Session: https://claude.ai/code/session_0197v7GRr8qKzhTtLCMjGFH4 --- .../stack-auth/src/access_key_refresher.rs | 4 +- .../stack-auth/src/access_key_strategy.rs | 8 +- packages/stack-auth/src/auth_strategy_fn.rs | 6 +- packages/stack-auth/src/auto_refresh.rs | 18 +- packages/stack-auth/src/auto_strategy.rs | 22 +- packages/stack-auth/src/device_code/mod.rs | 22 +- packages/stack-auth/src/device_code/tests.rs | 20 +- .../src/device_session_refresher.rs | 12 +- .../stack-auth/src/device_session_strategy.rs | 4 +- packages/stack-auth/src/error.rs | 441 ++++++++++++++++++ packages/stack-auth/src/lib.rs | 285 ++++------- .../src/oidc_federation_strategy.rs | 4 +- packages/stack-auth/src/oidc_refresher.rs | 8 +- packages/stack-auth/src/service_token.rs | 28 +- packages/stack-auth/src/token.rs | 39 +- 15 files changed, 645 insertions(+), 276 deletions(-) create mode 100644 packages/stack-auth/src/error.rs diff --git a/packages/stack-auth/src/access_key_refresher.rs b/packages/stack-auth/src/access_key_refresher.rs index 6070f730b..9317b4177 100644 --- a/packages/stack-auth/src/access_key_refresher.rs +++ b/packages/stack-auth/src/access_key_refresher.rs @@ -63,7 +63,9 @@ impl Refresher for AccessKeyRefresher { let status = resp.status(); let body = resp.text().await.unwrap_or_default(); tracing::debug!(%status, %body, "access key auth failed"); - return Err(AuthError::Server(format!("{status}: {body}"))); + return Err(AuthError::Server(crate::error::ServerError(format!( + "{status}: {body}" + )))); } let auth_resp: AuthoriseResponse = resp.json().await?; diff --git a/packages/stack-auth/src/access_key_strategy.rs b/packages/stack-auth/src/access_key_strategy.rs index bf66c30ca..059d0b8d8 100644 --- a/packages/stack-auth/src/access_key_strategy.rs +++ b/packages/stack-auth/src/access_key_strategy.rs @@ -253,20 +253,20 @@ mod workspace_verification_tests { .await .expect_err("expected mismatch"); match err { - AuthError::WorkspaceMismatch { + AuthError::WorkspaceMismatch(crate::error::WorkspaceMismatch { expected_workspace, token_workspace, - } => { + }) => { assert_eq!(expected_workspace.as_str(), CRN_WS); assert_eq!(token_workspace.as_str(), TOKEN_WS); } other => panic!("expected WorkspaceMismatch, got {other:?}"), } assert_eq!( - AuthError::WorkspaceMismatch { + AuthError::WorkspaceMismatch(crate::error::WorkspaceMismatch { expected_workspace: CRN_WS.parse().unwrap(), token_workspace: TOKEN_WS.parse().unwrap(), - } + }) .error_code(), "WORKSPACE_MISMATCH", ); diff --git a/packages/stack-auth/src/auth_strategy_fn.rs b/packages/stack-auth/src/auth_strategy_fn.rs index 734a4c650..3cf9c9e9e 100644 --- a/packages/stack-auth/src/auth_strategy_fn.rs +++ b/packages/stack-auth/src/auth_strategy_fn.rs @@ -127,10 +127,12 @@ mod tests { #[tokio::test] async fn closure_errors_propagate_unchanged() { - let strategy = AuthStrategyFn::new(|| async { Err(AuthError::AccessDenied) }); + let strategy = AuthStrategyFn::new(|| async { + Err(AuthError::AccessDenied(crate::error::AccessDenied)) + }); let err = (&strategy).get_token().await.unwrap_err(); assert!( - matches!(err, AuthError::AccessDenied), + matches!(err, AuthError::AccessDenied(_)), "AccessDenied from the closure should surface verbatim, got: {err:?}" ); } diff --git a/packages/stack-auth/src/auto_refresh.rs b/packages/stack-auth/src/auto_refresh.rs index 1e1949f7b..7b8391ebb 100644 --- a/packages/stack-auth/src/auto_refresh.rs +++ b/packages/stack-auth/src/auto_refresh.rs @@ -27,8 +27,10 @@ pub(crate) enum AutoRefreshError { impl From for crate::AuthError { fn from(err: AutoRefreshError) -> Self { match err { - AutoRefreshError::NotFound => crate::AuthError::NotAuthenticated, - AutoRefreshError::Expired => crate::AuthError::TokenExpired, + AutoRefreshError::NotFound => { + crate::AuthError::NotAuthenticated(crate::error::NotAuthenticated) + } + AutoRefreshError::Expired => crate::AuthError::TokenExpired(crate::error::TokenExpired), AutoRefreshError::Auth(e) => e, } } @@ -388,16 +390,18 @@ mod tests { use crate::AuthError; assert!(matches!( AuthError::from(AutoRefreshError::NotFound), - AuthError::NotAuthenticated + AuthError::NotAuthenticated(_) )); assert!(matches!( AuthError::from(AutoRefreshError::Expired), - AuthError::TokenExpired + AuthError::TokenExpired(_) )); // The `Auth` variant passes the inner error through unchanged. assert!(matches!( - AuthError::from(AutoRefreshError::Auth(AuthError::AccessDenied)), - AuthError::AccessDenied + AuthError::from(AutoRefreshError::Auth(AuthError::AccessDenied( + crate::error::AccessDenied + ))), + AuthError::AccessDenied(_) )); } @@ -1748,7 +1752,7 @@ mod expiry_crossing_regression { calls.fetch_add(1, Ordering::SeqCst); started.notify_one(); gate.notified().await; - Err(AuthError::TokenExpired) + Err(AuthError::TokenExpired(crate::error::TokenExpired)) } } } diff --git a/packages/stack-auth/src/auto_strategy.rs b/packages/stack-auth/src/auto_strategy.rs index f634233a9..50d77519e 100644 --- a/packages/stack-auth/src/auto_strategy.rs +++ b/packages/stack-auth/src/auto_strategy.rs @@ -109,7 +109,9 @@ impl AutoStrategy { ) -> Result { // 1. Access key from environment if let Some(access_key) = access_key { - let workspace_crn = crn.ok_or(AuthError::MissingWorkspaceCrn)?; + let workspace_crn = crn.ok_or(AuthError::MissingWorkspaceCrn( + crate::error::MissingWorkspaceCrn, + ))?; let key: crate::AccessKey = access_key.parse()?; let strategy = AccessKeyStrategy::new(workspace_crn, key)?; return Ok(Self::AccessKey(strategy)); @@ -128,18 +130,20 @@ impl AutoStrategy { } // 3. No credentials found - Err(AuthError::NotAuthenticated) + Err(AuthError::NotAuthenticated(crate::error::NotAuthenticated)) } #[cfg(target_arch = "wasm32")] fn detect_inner(access_key: Option, crn: Option) -> Result { if let Some(access_key) = access_key { - let workspace_crn = crn.ok_or(AuthError::MissingWorkspaceCrn)?; + let workspace_crn = crn.ok_or(AuthError::MissingWorkspaceCrn( + crate::error::MissingWorkspaceCrn, + ))?; let key: crate::AccessKey = access_key.parse()?; let strategy = AccessKeyStrategy::new(workspace_crn, key)?; return Ok(Self::AccessKey(strategy)); } - Err(AuthError::NotAuthenticated) + Err(AuthError::NotAuthenticated(crate::error::NotAuthenticated)) } } @@ -196,7 +200,7 @@ impl AutoStrategyBuilder { Some(crn) => Some(crn), None => std::env::var("CS_WORKSPACE_CRN") .ok() - .map(|s| s.parse::().map_err(AuthError::InvalidCrn)) + .map(|s| s.parse::().map_err(AuthError::from)) .transpose()?, }; @@ -300,7 +304,7 @@ mod tests { let result = AutoStrategy::detect_inner(Some("CSAKtestKeyId.testKeySecret".into()), None, None); - assert!(matches!(result, Err(AuthError::MissingWorkspaceCrn))); + assert!(matches!(result, Err(AuthError::MissingWorkspaceCrn(_)))); } #[test] @@ -329,14 +333,14 @@ mod tests { let result = AutoStrategy::detect_inner(None, None, Some(store)); - assert!(matches!(result, Err(AuthError::NotAuthenticated))); + assert!(matches!(result, Err(AuthError::NotAuthenticated(_)))); } #[test] fn no_credentials_returns_not_authenticated() { let result = AutoStrategy::detect_inner(None, None, None); - assert!(matches!(result, Err(AuthError::NotAuthenticated))); + assert!(matches!(result, Err(AuthError::NotAuthenticated(_)))); } #[test] @@ -384,7 +388,7 @@ mod tests { std::env::set_var("CS_WORKSPACE_CRN", val); } - assert!(matches!(result, Err(AuthError::MissingWorkspaceCrn))); + assert!(matches!(result, Err(AuthError::MissingWorkspaceCrn(_)))); } #[test] diff --git a/packages/stack-auth/src/device_code/mod.rs b/packages/stack-auth/src/device_code/mod.rs index 95b6dfa73..4f4046e81 100644 --- a/packages/stack-auth/src/device_code/mod.rs +++ b/packages/stack-auth/src/device_code/mod.rs @@ -112,8 +112,8 @@ impl DeviceCodeStrategy { let err: ErrorResponse = code_resp.json().await?; tracing::debug!(error = %err.error, "device code request failed"); return Err(match err.error.as_str() { - "invalid_client" => AuthError::InvalidClient, - _ => AuthError::Server(err.error_description), + "invalid_client" => AuthError::InvalidClient(crate::error::InvalidClient), + _ => AuthError::Server(crate::error::ServerError(err.error_description)), }); } @@ -307,7 +307,7 @@ impl PendingDeviceCode { loop { if tokio::time::Instant::now() >= deadline { tracing::debug!("device code expired while polling"); - return Err(AuthError::TokenExpired); + return Err(AuthError::TokenExpired(crate::error::TokenExpired)); } let resp = client @@ -368,11 +368,17 @@ impl PendingDeviceCode { interval += tokio::time::Duration::from_secs(5); tracing::debug!(interval_secs = interval.as_secs(), "slowing down"); } - "expired_token" => return Err(AuthError::TokenExpired), - "access_denied" => return Err(AuthError::AccessDenied), - "invalid_grant" => return Err(AuthError::InvalidGrant), - "invalid_client" => return Err(AuthError::InvalidClient), - _ => return Err(AuthError::Server(err.error_description)), + "expired_token" => return Err(AuthError::TokenExpired(crate::error::TokenExpired)), + "access_denied" => return Err(AuthError::AccessDenied(crate::error::AccessDenied)), + "invalid_grant" => return Err(AuthError::InvalidGrant(crate::error::InvalidGrant)), + "invalid_client" => { + return Err(AuthError::InvalidClient(crate::error::InvalidClient)) + } + _ => { + return Err(AuthError::Server(crate::error::ServerError( + err.error_description, + ))) + } } tokio::time::sleep(interval).await; diff --git a/packages/stack-auth/src/device_code/tests.rs b/packages/stack-auth/src/device_code/tests.rs index 357c5e318..5c8301fc9 100644 --- a/packages/stack-auth/src/device_code/tests.rs +++ b/packages/stack-auth/src/device_code/tests.rs @@ -109,7 +109,7 @@ async fn test_begin_invalid_client() { let err = strategy_for(&server, &dir).begin().await.unwrap_err(); - assert!(matches!(err, AuthError::InvalidClient)); + assert!(matches!(err, AuthError::InvalidClient(_))); } #[tokio::test] @@ -124,7 +124,9 @@ async fn test_begin_server_error() { let err = strategy_for(&server, &dir).begin().await.unwrap_err(); - assert!(matches!(&err, AuthError::Server(desc) if desc == "server_error occurred")); + assert!( + matches!(&err, AuthError::Server(crate::error::ServerError(desc)) if desc == "server_error occurred") + ); } // ---- poll_for_token() tests ---- @@ -187,7 +189,7 @@ async fn test_poll_for_token_access_denied() { .await .unwrap_err(); - assert!(matches!(err, AuthError::AccessDenied)); + assert!(matches!(err, AuthError::AccessDenied(_))); } #[tokio::test(start_paused = true)] @@ -207,7 +209,7 @@ async fn test_poll_for_token_expired_token() { .await .unwrap_err(); - assert!(matches!(err, AuthError::TokenExpired)); + assert!(matches!(err, AuthError::TokenExpired(_))); } #[tokio::test(start_paused = true)] @@ -227,7 +229,7 @@ async fn test_poll_for_token_invalid_grant() { .await .unwrap_err(); - assert!(matches!(err, AuthError::InvalidGrant)); + assert!(matches!(err, AuthError::InvalidGrant(_))); } #[tokio::test(start_paused = true)] @@ -247,7 +249,7 @@ async fn test_poll_for_token_invalid_client() { .await .unwrap_err(); - assert!(matches!(err, AuthError::InvalidClient)); + assert!(matches!(err, AuthError::InvalidClient(_))); } #[tokio::test(start_paused = true)] @@ -267,7 +269,9 @@ async fn test_poll_for_token_unknown_error() { .await .unwrap_err(); - assert!(matches!(&err, AuthError::Server(desc) if desc == "something_unexpected occurred")); + assert!( + matches!(&err, AuthError::Server(crate::error::ServerError(desc)) if desc == "something_unexpected occurred") + ); } #[tokio::test(start_paused = true)] @@ -363,7 +367,7 @@ async fn test_poll_for_token_slow_down_increases_interval() { let err = pending.poll_for_token().await.unwrap_err(); - assert!(matches!(err, AuthError::TokenExpired)); + assert!(matches!(err, AuthError::TokenExpired(_))); } // ---- ensure_trailing_slash / URL join tests ---- diff --git a/packages/stack-auth/src/device_session_refresher.rs b/packages/stack-auth/src/device_session_refresher.rs index 122b017e1..75027d1b4 100644 --- a/packages/stack-auth/src/device_session_refresher.rs +++ b/packages/stack-auth/src/device_session_refresher.rs @@ -137,8 +137,16 @@ impl DeviceSessionRefresher { }; let lock = tokio::task::spawn_blocking(move || store.lock_exclusive(Token::FILENAME)) .await - .map_err(|e| AuthError::Server(format!("refresh lock task join failed: {e}")))? - .map_err(|e| AuthError::Server(format!("failed to acquire refresh lock: {e}")))?; + .map_err(|e| { + AuthError::Server(crate::error::ServerError(format!( + "refresh lock task join failed: {e}" + ))) + })? + .map_err(|e| { + AuthError::Server(crate::error::ServerError(format!( + "failed to acquire refresh lock: {e}" + ))) + })?; Ok(Some(lock)) } diff --git a/packages/stack-auth/src/device_session_strategy.rs b/packages/stack-auth/src/device_session_strategy.rs index d35637338..c6cca53ab 100644 --- a/packages/stack-auth/src/device_session_strategy.rs +++ b/packages/stack-auth/src/device_session_strategy.rs @@ -196,11 +196,11 @@ impl DeviceSessionStrategyBuilder { let region_str = token .region() - .ok_or(AuthError::NotAuthenticated)? + .ok_or(AuthError::NotAuthenticated(crate::error::NotAuthenticated))? .to_string(); let client_id = token .client_id() - .ok_or(AuthError::NotAuthenticated)? + .ok_or(AuthError::NotAuthenticated(crate::error::NotAuthenticated))? .to_string(); let crn = token .workspace_crn() diff --git a/packages/stack-auth/src/error.rs b/packages/stack-auth/src/error.rs new file mode 100644 index 000000000..a09322c38 --- /dev/null +++ b/packages/stack-auth/src/error.rs @@ -0,0 +1,441 @@ +//! Authentication error types. +//! +//! [`AuthError`] is the single canonical error enum. Each variant wraps a +//! dedicated struct that owns its `Display` message, `miette` diagnostic +//! (`help`/`url`) and machine-readable code, plus any structured payload — so +//! per-error logic lives with the error rather than in one central function. +//! +//! The enum is a thin dispatcher: `Display`/`Diagnostic` delegate to the inner +//! struct via `transparent`, and [`AuthError::error_code`] / the `Serialize` +//! impl delegate through [`AuthError::kind`]. Ergonomic `From` impls +//! keep `?` working at call sites that lift a foreign error directly. + +use std::convert::Infallible; + +use crate::access_key; + +/// Behaviour shared by every concrete error wrapped in an [`AuthError`] variant. +/// +/// Implemented by the per-error structs so each owns its FFI code and any +/// structured payload; [`AuthError`] dispatches to it via [`AuthError::kind`]. +pub trait AuthErrorKind: std::error::Error + miette::Diagnostic { + /// Stable machine-readable identifier surfaced across FFI boundaries + /// (e.g. JS `Error.code`). Named `error_code` to avoid colliding with + /// `miette::Diagnostic::code`, inherited via the `Diagnostic` supertrait. + fn error_code(&self) -> &'static str; + + /// Extra structured fields for the FFI/TS failure payload, beyond the + /// `type`/`message`/`help`/`url` the enum emits generically. None by default. + fn payload(&self) -> serde_json::Map { + serde_json::Map::new() + } +} + +// --------------------------------------------------------------------------- +// Per-error structs +// --------------------------------------------------------------------------- + +/// The HTTP request to the auth server failed (network error, timeout, etc.). +#[derive(Debug, thiserror::Error, miette::Diagnostic)] +#[error("HTTP request failed: {0}")] +pub struct RequestError(pub reqwest::Error); +impl AuthErrorKind for RequestError { + fn error_code(&self) -> &'static str { + "REQUEST_ERROR" + } +} + +/// The user denied the authorization request. +#[derive(Debug, thiserror::Error, miette::Diagnostic)] +#[error("Authorization was denied")] +pub struct AccessDenied; +impl AuthErrorKind for AccessDenied { + fn error_code(&self) -> &'static str { + "ACCESS_DENIED" + } +} + +/// The grant type was rejected by the server. +#[derive(Debug, thiserror::Error, miette::Diagnostic)] +#[error("Invalid grant")] +pub struct InvalidGrant; +impl AuthErrorKind for InvalidGrant { + fn error_code(&self) -> &'static str { + "INVALID_GRANT" + } +} + +/// The client ID is not recognized. +#[derive(Debug, thiserror::Error, miette::Diagnostic)] +#[error("Invalid client")] +pub struct InvalidClient; +impl AuthErrorKind for InvalidClient { + fn error_code(&self) -> &'static str { + "INVALID_CLIENT" + } +} + +/// A URL could not be parsed. +#[derive(Debug, thiserror::Error, miette::Diagnostic)] +#[error("Invalid URL: {0}")] +pub struct InvalidUrl(pub url::ParseError); +impl AuthErrorKind for InvalidUrl { + fn error_code(&self) -> &'static str { + "INVALID_URL" + } +} + +/// The requested region is not supported. +#[derive(Debug, thiserror::Error, miette::Diagnostic)] +#[error("Unsupported region: {0}")] +#[diagnostic(help("Use a supported region, e.g. `ap-southeast-2.aws`."))] +pub struct UnsupportedRegion(pub cts_common::RegionError); +impl AuthErrorKind for UnsupportedRegion { + fn error_code(&self) -> &'static str { + "INVALID_REGION" + } +} + +/// The workspace CRN could not be parsed. +#[derive(Debug, thiserror::Error, miette::Diagnostic)] +#[error("Invalid workspace CRN: {0}")] +#[diagnostic(help( + "A workspace CRN looks like `crn::`, e.g. `crn:ap-southeast-2.aws:ZVATKW3VHMFG27DY`." +))] +pub struct InvalidCrn(pub cts_common::InvalidCrn); +impl AuthErrorKind for InvalidCrn { + fn error_code(&self) -> &'static str { + "INVALID_CRN" + } +} + +/// The token issued by the auth server is for a different workspace than the +/// one configured on the strategy. Surfaces when the access key was minted for +/// a different workspace, or when the wrong CRN was passed. +#[derive(Debug, thiserror::Error, miette::Diagnostic)] +#[error("Workspace mismatch: token issued for {token_workspace}, but strategy is configured for {expected_workspace}")] +#[diagnostic(help( + "The access key or workspace CRN is scoped to a different workspace than the one requested — check which workspace the credential belongs to." +))] +pub struct WorkspaceMismatch { + /// The workspace the strategy was configured for (from the CRN). + pub expected_workspace: cts_common::WorkspaceId, + /// The workspace the auth server's token actually carries. + pub token_workspace: cts_common::WorkspaceId, +} +impl AuthErrorKind for WorkspaceMismatch { + fn error_code(&self) -> &'static str { + "WORKSPACE_MISMATCH" + } + fn payload(&self) -> serde_json::Map { + match serde_json::json!({ + "expected": self.expected_workspace.to_string(), + "actual": self.token_workspace.to_string(), + }) { + serde_json::Value::Object(map) => map, + _ => serde_json::Map::new(), + } + } +} + +/// The workspace ID could not be parsed. +#[derive(Debug, thiserror::Error, miette::Diagnostic)] +#[error("Invalid workspace ID: {0}")] +pub struct InvalidWorkspaceId(pub cts_common::InvalidWorkspaceId); +impl AuthErrorKind for InvalidWorkspaceId { + fn error_code(&self) -> &'static str { + "INVALID_WORKSPACE_ID" + } +} + +/// An access key was provided but the workspace CRN is missing. +#[derive(Debug, thiserror::Error, miette::Diagnostic)] +#[error( + "Workspace CRN is required when using an access key — set CS_WORKSPACE_CRN or call AutoStrategyBuilder::with_workspace_crn" +)] +#[diagnostic(help( + "Most strategies need a workspace CRN — set the `CS_WORKSPACE_CRN` environment variable, or pass it explicitly, e.g. `AutoStrategyBuilder::with_workspace_crn`." +))] +pub struct MissingWorkspaceCrn; +impl AuthErrorKind for MissingWorkspaceCrn { + fn error_code(&self) -> &'static str { + "MISSING_WORKSPACE_CRN" + } +} + +/// No credentials are available (e.g. not logged in, no access key configured). +#[derive(Debug, thiserror::Error, miette::Diagnostic)] +#[error("Not authenticated")] +#[diagnostic(help( + "Log in with `stash login`, or set `CS_CLIENT_ACCESS_KEY` for service-to-service auth." +))] +pub struct NotAuthenticated; +impl AuthErrorKind for NotAuthenticated { + fn error_code(&self) -> &'static str { + "NOT_AUTHENTICATED" + } +} + +/// A token (access token or device code) has expired. +#[derive(Debug, thiserror::Error, miette::Diagnostic)] +#[error("Token expired")] +pub struct TokenExpired; +impl AuthErrorKind for TokenExpired { + fn error_code(&self) -> &'static str { + "EXPIRED_TOKEN" + } +} + +/// The access key string is malformed (e.g. missing `CSAK` prefix or `.`). +#[derive(Debug, thiserror::Error, miette::Diagnostic)] +#[error("Invalid access key: {0}")] +#[diagnostic(help("Access keys have the form `CSAK.`."))] +pub struct InvalidAccessKeyError(pub access_key::InvalidAccessKey); +impl AuthErrorKind for InvalidAccessKeyError { + fn error_code(&self) -> &'static str { + "INVALID_ACCESS_KEY" + } +} + +/// The JWT could not be decoded or its claims are malformed. +#[derive(Debug, thiserror::Error, miette::Diagnostic)] +#[error("Invalid token: {0}")] +pub struct InvalidToken(pub String); +impl AuthErrorKind for InvalidToken { + fn error_code(&self) -> &'static str { + "INVALID_TOKEN" + } +} + +/// An unexpected error was returned by the auth server. +#[derive(Debug, thiserror::Error, miette::Diagnostic)] +#[error("Server error: {0}")] +pub struct ServerError(pub String); +impl AuthErrorKind for ServerError { + fn error_code(&self) -> &'static str { + "SERVER_ERROR" + } +} + +/// A consumable handle (e.g. a device-code poll) was used after it had already +/// been consumed. A caller bug rather than an auth outcome, but surfaced as an +/// `AuthError` so it flows through the `Result` contract rather than throwing +/// across the FFI boundary. +#[derive(Debug, thiserror::Error, miette::Diagnostic)] +#[error("Handle already consumed")] +pub struct AlreadyConsumed; +impl AuthErrorKind for AlreadyConsumed { + fn error_code(&self) -> &'static str { + "ALREADY_CONSUMED" + } +} + +/// An internal invariant was violated (e.g. a poisoned lock). Should not occur +/// in correct usage; surfaced rather than panicking so it crosses the FFI +/// boundary as a `Result` failure. +#[derive(Debug, thiserror::Error, miette::Diagnostic)] +#[error("Internal error: {0}")] +pub struct InternalError(pub String); +impl AuthErrorKind for InternalError { + fn error_code(&self) -> &'static str { + "INTERNAL_ERROR" + } +} + +/// A token store operation failed. +#[cfg(not(target_arch = "wasm32"))] +#[derive(Debug, thiserror::Error, miette::Diagnostic)] +#[error("Token store error: {0}")] +pub struct StoreError(pub stack_profile::ProfileError); +#[cfg(not(target_arch = "wasm32"))] +impl AuthErrorKind for StoreError { + fn error_code(&self) -> &'static str { + "STORE_ERROR" + } +} + +// --------------------------------------------------------------------------- +// The canonical enum +// --------------------------------------------------------------------------- + +/// Errors that can occur during an authentication flow. +#[derive(Debug, thiserror::Error, miette::Diagnostic)] +#[non_exhaustive] +pub enum AuthError { + #[error(transparent)] + #[diagnostic(transparent)] + Request(#[from] RequestError), + #[error(transparent)] + #[diagnostic(transparent)] + AccessDenied(#[from] AccessDenied), + #[error(transparent)] + #[diagnostic(transparent)] + InvalidGrant(#[from] InvalidGrant), + #[error(transparent)] + #[diagnostic(transparent)] + InvalidClient(#[from] InvalidClient), + #[error(transparent)] + #[diagnostic(transparent)] + InvalidUrl(#[from] InvalidUrl), + #[error(transparent)] + #[diagnostic(transparent)] + Region(#[from] UnsupportedRegion), + #[error(transparent)] + #[diagnostic(transparent)] + InvalidCrn(#[from] InvalidCrn), + #[error(transparent)] + #[diagnostic(transparent)] + WorkspaceMismatch(#[from] WorkspaceMismatch), + #[error(transparent)] + #[diagnostic(transparent)] + InvalidWorkspaceId(#[from] InvalidWorkspaceId), + #[error(transparent)] + #[diagnostic(transparent)] + MissingWorkspaceCrn(#[from] MissingWorkspaceCrn), + #[error(transparent)] + #[diagnostic(transparent)] + NotAuthenticated(#[from] NotAuthenticated), + #[error(transparent)] + #[diagnostic(transparent)] + TokenExpired(#[from] TokenExpired), + #[error(transparent)] + #[diagnostic(transparent)] + InvalidAccessKey(#[from] InvalidAccessKeyError), + #[error(transparent)] + #[diagnostic(transparent)] + InvalidToken(#[from] InvalidToken), + #[error(transparent)] + #[diagnostic(transparent)] + Server(#[from] ServerError), + #[error(transparent)] + #[diagnostic(transparent)] + AlreadyConsumed(#[from] AlreadyConsumed), + #[error(transparent)] + #[diagnostic(transparent)] + Internal(#[from] InternalError), + #[cfg(not(target_arch = "wasm32"))] + #[error(transparent)] + #[diagnostic(transparent)] + Store(#[from] StoreError), +} + +impl AuthError { + /// Dispatch to the wrapped concrete error as a trait object. + fn kind(&self) -> &dyn AuthErrorKind { + match self { + Self::Request(e) => e, + Self::AccessDenied(e) => e, + Self::InvalidGrant(e) => e, + Self::InvalidClient(e) => e, + Self::InvalidUrl(e) => e, + Self::Region(e) => e, + Self::InvalidCrn(e) => e, + Self::WorkspaceMismatch(e) => e, + Self::InvalidWorkspaceId(e) => e, + Self::MissingWorkspaceCrn(e) => e, + Self::NotAuthenticated(e) => e, + Self::TokenExpired(e) => e, + Self::InvalidAccessKey(e) => e, + Self::InvalidToken(e) => e, + Self::Server(e) => e, + Self::AlreadyConsumed(e) => e, + Self::Internal(e) => e, + #[cfg(not(target_arch = "wasm32"))] + Self::Store(e) => e, + } + } + + /// Stable machine-readable identifier for surfacing across FFI boundaries + /// (e.g. JS `Error.code`, Node-API error codes). Delegates to the wrapped + /// error's [`AuthErrorKind::error_code`]. + pub fn error_code(&self) -> &'static str { + self.kind().error_code() + } +} + +/// Serialize an `AuthError` into the flat, FFI-facing shape consumed by the +/// Node and Wasm bindings: `{ type, message, help?, url?, ...payload }`. +/// +/// `type`/`message` come from the canonical code and `Display`; `help`/`url` +/// are captured generically from the `miette::Diagnostic` surface (so +/// per-variant help stays colocated on the struct); extra structured fields +/// come from [`AuthErrorKind::payload`]. +/// +/// `serialize_map` lets the wasm binding render this as a plain JS object via +/// `Serializer::serialize_maps_as_objects(true)`; serde_json renders a JSON +/// object directly. +impl serde::Serialize for AuthError { + fn serialize(&self, serializer: S) -> Result { + use miette::Diagnostic; + use serde::ser::SerializeMap; + + let kind = self.kind(); + let mut map = serializer.serialize_map(None)?; + map.serialize_entry("type", kind.error_code())?; + map.serialize_entry("message", &self.to_string())?; + if let Some(help) = self.help() { + map.serialize_entry("help", &help.to_string())?; + } + if let Some(url) = self.url() { + map.serialize_entry("url", &url.to_string())?; + } + for (key, value) in kind.payload() { + map.serialize_entry(&key, &value)?; + } + map.end() + } +} + +// --------------------------------------------------------------------------- +// Ergonomic `From` impls — keep `?` working where call sites lift a +// foreign error straight into `AuthError` (the per-struct wrapping is internal). +// --------------------------------------------------------------------------- + +impl From for AuthError { + fn from(e: reqwest::Error) -> Self { + Self::Request(RequestError(e)) + } +} + +impl From for AuthError { + fn from(e: url::ParseError) -> Self { + Self::InvalidUrl(InvalidUrl(e)) + } +} + +impl From for AuthError { + fn from(e: cts_common::RegionError) -> Self { + Self::Region(UnsupportedRegion(e)) + } +} + +impl From for AuthError { + fn from(e: cts_common::InvalidCrn) -> Self { + Self::InvalidCrn(InvalidCrn(e)) + } +} + +impl From for AuthError { + fn from(e: cts_common::InvalidWorkspaceId) -> Self { + Self::InvalidWorkspaceId(InvalidWorkspaceId(e)) + } +} + +impl From for AuthError { + fn from(e: access_key::InvalidAccessKey) -> Self { + Self::InvalidAccessKey(InvalidAccessKeyError(e)) + } +} + +#[cfg(not(target_arch = "wasm32"))] +impl From for AuthError { + fn from(e: stack_profile::ProfileError) -> Self { + Self::Store(StoreError(e)) + } +} + +impl From for AuthError { + fn from(never: Infallible) -> Self { + match never {} + } +} diff --git a/packages/stack-auth/src/lib.rs b/packages/stack-auth/src/lib.rs index 1a05445a8..d077c9793 100644 --- a/packages/stack-auth/src/lib.rs +++ b/packages/stack-auth/src/lib.rs @@ -22,7 +22,6 @@ #![cfg_attr(test, allow(clippy::panic))] #![cfg_attr(test, allow(unused_results))] -use std::convert::Infallible; use std::future::Future; #[cfg(all(not(any(test, feature = "test-utils")), not(target_arch = "wasm32")))] use std::time::Duration; @@ -40,6 +39,7 @@ mod auto_strategy; mod clock; mod device_session_refresher; mod device_session_strategy; +mod error; mod oidc_federation_strategy; mod oidc_refresher; mod refresher; @@ -47,6 +47,15 @@ mod service_token; mod token; mod token_store; +#[cfg(not(target_arch = "wasm32"))] +pub use error::StoreError; +pub use error::{ + AccessDenied, AlreadyConsumed, AuthError, AuthErrorKind, InternalError, InvalidAccessKeyError, + InvalidClient, InvalidCrn, InvalidGrant, InvalidToken, InvalidUrl, InvalidWorkspaceId, + MissingWorkspaceCrn, NotAuthenticated, RequestError, ServerError, TokenExpired, + UnsupportedRegion, WorkspaceMismatch, +}; + // Filesystem-backed device identity and the interactive device-code flow are // native-only — both pull `stack-profile` (which uses `dirs` + `gethostname`) // and the device-code flow launches a browser via `open::that`. Wasm consumers @@ -297,148 +306,6 @@ impl SecretToken { } } -/// Errors that can occur during an authentication flow. -#[derive(Debug, thiserror::Error, miette::Diagnostic)] -#[non_exhaustive] -pub enum AuthError { - /// The HTTP request to the auth server failed (network error, timeout, etc.). - #[error("HTTP request failed: {0}")] - Request(#[from] reqwest::Error), - /// The user denied the authorization request. - #[error("Authorization was denied")] - AccessDenied, - /// The grant type was rejected by the server. - #[error("Invalid grant")] - InvalidGrant, - /// The client ID is not recognized. - #[error("Invalid client")] - InvalidClient, - /// A URL could not be parsed. - #[error("Invalid URL: {0}")] - InvalidUrl(#[from] url::ParseError), - /// The requested region is not supported. - #[error("Unsupported region: {0}")] - #[diagnostic(help("Use a supported region, e.g. `ap-southeast-2.aws`."))] - Region(#[from] cts_common::RegionError), - /// The workspace CRN could not be parsed. - #[error("Invalid workspace CRN: {0}")] - #[diagnostic(help( - "A workspace CRN looks like `crn::`, e.g. `crn:ap-southeast-2.aws:ZVATKW3VHMFG27DY`." - ))] - InvalidCrn(cts_common::InvalidCrn), - /// The token issued by the auth server is for a different workspace than - /// the one configured on the strategy. Surfaces when the access key was - /// minted for a different workspace, or when the wrong CRN was passed. - #[error("Workspace mismatch: token issued for {token_workspace}, but strategy is configured for {expected_workspace}")] - #[diagnostic(help( - "The access key or workspace CRN is scoped to a different workspace than the one requested — check which workspace the credential belongs to." - ))] - WorkspaceMismatch { - /// The workspace the strategy was configured for (from the CRN). - expected_workspace: cts_common::WorkspaceId, - /// The workspace the auth server's token actually carries. - token_workspace: cts_common::WorkspaceId, - }, - /// The workspace ID could not be parsed. - #[error("Invalid workspace ID: {0}")] - InvalidWorkspaceId(#[from] cts_common::InvalidWorkspaceId), - /// An access key was provided but the workspace CRN is missing. - /// - /// Set the `CS_WORKSPACE_CRN` environment variable or call - /// [`AutoStrategyBuilder::with_workspace_crn`](crate::AutoStrategyBuilder::with_workspace_crn). - #[error("Workspace CRN is required when using an access key — set CS_WORKSPACE_CRN or call AutoStrategyBuilder::with_workspace_crn")] - #[diagnostic(help( - "Most strategies need a workspace CRN — set the `CS_WORKSPACE_CRN` environment variable, or pass it explicitly, e.g. `AutoStrategyBuilder::with_workspace_crn`." - ))] - MissingWorkspaceCrn, - /// No credentials are available (e.g. not logged in, no access key configured). - #[error("Not authenticated")] - #[diagnostic(help( - "Log in with `stash login`, or set `CS_CLIENT_ACCESS_KEY` for service-to-service auth." - ))] - NotAuthenticated, - /// A token (access token or device code) has expired. - #[error("Token expired")] - TokenExpired, - /// The access key string is malformed (e.g. missing `CSAK` prefix or `.` separator). - #[error("Invalid access key: {0}")] - #[diagnostic(help("Access keys have the form `CSAK.`."))] - InvalidAccessKey(#[from] access_key::InvalidAccessKey), - /// The JWT could not be decoded or its claims are malformed. - #[error("Invalid token: {0}")] - InvalidToken(String), - /// An unexpected error was returned by the auth server. - #[error("Server error: {0}")] - Server(String), - /// A token store operation failed. - #[cfg(not(target_arch = "wasm32"))] - #[error("Token store error: {0}")] - Store(#[from] stack_profile::ProfileError), -} - -impl AuthError { - /// The complete set of codes [`AuthError::error_code`] can return — the - /// stable, machine-readable contract surfaced across FFI (JS `Error.code`, - /// Node-API codes, the `index.d.ts` / wasm typing unions). Kept next to - /// `error_code` so the two move together. The binding crates derive their - /// expected union from this constant rather than re-scraping this source, - /// and `auth_error_code_is_stable_for_every_variant` pins that it stays in - /// lockstep with what `error_code` actually returns. - pub const ERROR_CODES: &'static [&'static str] = &[ - "REQUEST_ERROR", - "ACCESS_DENIED", - "EXPIRED_TOKEN", - "INVALID_GRANT", - "INVALID_CLIENT", - "INVALID_URL", - "INVALID_REGION", - "INVALID_TOKEN", - "SERVER_ERROR", - "NOT_AUTHENTICATED", - "MISSING_WORKSPACE_CRN", - "INVALID_ACCESS_KEY", - "INVALID_CRN", - "WORKSPACE_MISMATCH", - "INVALID_WORKSPACE_ID", - // `Store` (and its code) only exists off-wasm — see `error_code` below. - #[cfg(not(target_arch = "wasm32"))] - "STORE_ERROR", - ]; - - /// Stable machine-readable identifier for surfacing across FFI boundaries - /// (e.g. JS `Error.code`, Node-API error codes). Named `error_code` rather - /// than `code` to avoid colliding with `miette::Diagnostic::code`, which - /// is inherited via `#[derive(Diagnostic)]`. Every value it can return is - /// listed in [`AuthError::ERROR_CODES`]. - pub fn error_code(&self) -> &'static str { - match self { - Self::Request(_) => "REQUEST_ERROR", - Self::AccessDenied => "ACCESS_DENIED", - Self::TokenExpired => "EXPIRED_TOKEN", - Self::InvalidGrant => "INVALID_GRANT", - Self::InvalidClient => "INVALID_CLIENT", - Self::InvalidUrl(_) => "INVALID_URL", - Self::Region(_) => "INVALID_REGION", - Self::InvalidToken(_) => "INVALID_TOKEN", - Self::Server(_) => "SERVER_ERROR", - Self::NotAuthenticated => "NOT_AUTHENTICATED", - Self::MissingWorkspaceCrn => "MISSING_WORKSPACE_CRN", - Self::InvalidAccessKey(_) => "INVALID_ACCESS_KEY", - Self::InvalidCrn(_) => "INVALID_CRN", - Self::WorkspaceMismatch { .. } => "WORKSPACE_MISMATCH", - Self::InvalidWorkspaceId(_) => "INVALID_WORKSPACE_ID", - #[cfg(not(target_arch = "wasm32"))] - Self::Store(_) => "STORE_ERROR", - } - } -} - -impl From for AuthError { - fn from(never: Infallible) -> Self { - match never {} - } -} - /// Read the `CS_CTS_HOST` environment variable and parse it as a URL. /// /// Returns `Ok(None)` if the variable is not set or empty. @@ -472,15 +339,20 @@ where use base64::Engine; let segments: Vec<&str> = token.split('.').collect(); if segments.len() != 3 { - return Err(AuthError::InvalidToken( + return Err(AuthError::InvalidToken(error::InvalidToken( "JWT must have three segments".to_string(), - )); + ))); } let payload = base64::engine::general_purpose::URL_SAFE_NO_PAD .decode(segments[1]) - .map_err(|e| AuthError::InvalidToken(format!("base64 decode failed: {e}")))?; - serde_json::from_slice(&payload) - .map_err(|e| AuthError::InvalidToken(format!("failed to decode JWT claims: {e}"))) + .map_err(|e| { + AuthError::InvalidToken(error::InvalidToken(format!("base64 decode failed: {e}"))) + })?; + serde_json::from_slice(&payload).map_err(|e| { + AuthError::InvalidToken(error::InvalidToken(format!( + "failed to decode JWT claims: {e}" + ))) + }) } /// Create a [`reqwest::Client`] with standard timeouts. @@ -525,86 +397,91 @@ mod tests { /// all variants except `Request`, whose inner `reqwest::Error` has no public /// constructor; if a new variant is added without a code, `error_code`'s /// exhaustive match fails to compile, so the contract can't silently drift. - /// - /// Also pins [`AuthError::ERROR_CODES`] against what `error_code` actually - /// returns: every constructed variant's code must be declared there, and - /// `ERROR_CODES` must hold exactly those codes plus `REQUEST_ERROR` (the one - /// variant with no public constructor). So the list can't grow stale entries - /// or omit a real one — which is what the binding crates' union tests trust. #[test] #[allow(clippy::unwrap_used)] fn auth_error_code_is_stable_for_every_variant() { - use std::collections::BTreeSet; - let workspace = "ZVATKW3VHMFG27DY" .parse::() .unwrap(); let cases: Vec<(AuthError, &str)> = vec![ - (AuthError::AccessDenied, "ACCESS_DENIED"), - (AuthError::TokenExpired, "EXPIRED_TOKEN"), - (AuthError::InvalidGrant, "INVALID_GRANT"), - (AuthError::InvalidClient, "INVALID_CLIENT"), - (AuthError::NotAuthenticated, "NOT_AUTHENTICATED"), - (AuthError::MissingWorkspaceCrn, "MISSING_WORKSPACE_CRN"), - (AuthError::Server("boom".into()), "SERVER_ERROR"), - (AuthError::InvalidToken("malformed".into()), "INVALID_TOKEN"), ( - AuthError::InvalidUrl("not a url".parse::().unwrap_err()), + AuthError::AccessDenied(crate::error::AccessDenied), + "ACCESS_DENIED", + ), + ( + AuthError::TokenExpired(crate::error::TokenExpired), + "EXPIRED_TOKEN", + ), + ( + AuthError::InvalidGrant(crate::error::InvalidGrant), + "INVALID_GRANT", + ), + ( + AuthError::InvalidClient(crate::error::InvalidClient), + "INVALID_CLIENT", + ), + ( + AuthError::NotAuthenticated(crate::error::NotAuthenticated), + "NOT_AUTHENTICATED", + ), + ( + AuthError::MissingWorkspaceCrn(crate::error::MissingWorkspaceCrn), + "MISSING_WORKSPACE_CRN", + ), + ( + AuthError::AlreadyConsumed(crate::error::AlreadyConsumed), + "ALREADY_CONSUMED", + ), + ( + AuthError::Server(crate::error::ServerError("boom".into())), + "SERVER_ERROR", + ), + ( + AuthError::Internal(crate::error::InternalError("boom".into())), + "INTERNAL_ERROR", + ), + ( + AuthError::InvalidToken(crate::error::InvalidToken("malformed".into())), + "INVALID_TOKEN", + ), + ( + AuthError::from("not a url".parse::().unwrap_err()), "INVALID_URL", ), ( - AuthError::Region("not-a-region".parse::().unwrap_err()), + AuthError::from("not-a-region".parse::().unwrap_err()), "INVALID_REGION", ), ( - AuthError::InvalidCrn("not-a-crn".parse::().unwrap_err()), + AuthError::from("not-a-crn".parse::().unwrap_err()), "INVALID_CRN", ), ( - AuthError::InvalidWorkspaceId("!".parse::().unwrap_err()), + AuthError::from("!".parse::().unwrap_err()), "INVALID_WORKSPACE_ID", ), ( - AuthError::InvalidAccessKey( - "".parse::().unwrap_err(), - ), + AuthError::from("".parse::().unwrap_err()), "INVALID_ACCESS_KEY", ), ( - AuthError::WorkspaceMismatch { + AuthError::WorkspaceMismatch(crate::error::WorkspaceMismatch { expected_workspace: workspace, token_workspace: workspace, - }, + }), "WORKSPACE_MISMATCH", ), #[cfg(not(target_arch = "wasm32"))] ( - AuthError::Store(stack_profile::ProfileError::HomeDirNotFound), + AuthError::from(stack_profile::ProfileError::HomeDirNotFound), "STORE_ERROR", ), ]; - let declared: BTreeSet<&str> = AuthError::ERROR_CODES.iter().copied().collect(); - - let mut from_variants: BTreeSet<&str> = BTreeSet::new(); for (err, expected) in cases { assert_eq!(err.error_code(), expected, "error_code for {err:?}"); - assert!( - declared.contains(expected), - "{expected} is returned by error_code() but missing from AuthError::ERROR_CODES", - ); - from_variants.insert(expected); } - - // `Request` has no public constructor, so it can't appear above; add its - // code explicitly so the set-equality below stays exact. - from_variants.insert("REQUEST_ERROR"); - - assert_eq!( - declared, from_variants, - "AuthError::ERROR_CODES drifted from the codes error_code() returns", - ); } /// Every variant annotated with `#[diagnostic(help(..))]` must surface that @@ -623,26 +500,30 @@ mod tests { // (variant, substring its help must contain) — one row per annotation. let with_help: Vec<(AuthError, &str)> = vec![ ( - AuthError::Region("not-a-region".parse::().unwrap_err()), + AuthError::from("not-a-region".parse::().unwrap_err()), "supported region", ), ( - AuthError::InvalidCrn("not-a-crn".parse::().unwrap_err()), + AuthError::from("not-a-crn".parse::().unwrap_err()), "crn::", ), ( - AuthError::WorkspaceMismatch { + AuthError::WorkspaceMismatch(crate::error::WorkspaceMismatch { expected_workspace: workspace, token_workspace: workspace, - }, + }), "different workspace", ), - (AuthError::MissingWorkspaceCrn, "CS_WORKSPACE_CRN"), - (AuthError::NotAuthenticated, "stash login"), ( - AuthError::InvalidAccessKey( - "".parse::().unwrap_err(), - ), + AuthError::MissingWorkspaceCrn(crate::error::MissingWorkspaceCrn), + "CS_WORKSPACE_CRN", + ), + ( + AuthError::NotAuthenticated(crate::error::NotAuthenticated), + "stash login", + ), + ( + AuthError::from("".parse::().unwrap_err()), "CSAK.", ), ]; @@ -658,8 +539,8 @@ mod tests { // Un-annotated variants must report no help — keeps the contract // symmetric so a stray annotation doesn't slip in unnoticed. for err in [ - AuthError::TokenExpired, - AuthError::InvalidToken("malformed".to_string()), + AuthError::TokenExpired(crate::error::TokenExpired), + AuthError::InvalidToken(crate::error::InvalidToken("malformed".to_string())), ] { assert!( err.help().is_none(), diff --git a/packages/stack-auth/src/oidc_federation_strategy.rs b/packages/stack-auth/src/oidc_federation_strategy.rs index 5bc08682a..94aa1c2b2 100644 --- a/packages/stack-auth/src/oidc_federation_strategy.rs +++ b/packages/stack-auth/src/oidc_federation_strategy.rs @@ -372,10 +372,10 @@ mod tests { .await .expect_err("expected mismatch"); match err { - AuthError::WorkspaceMismatch { + AuthError::WorkspaceMismatch(crate::error::WorkspaceMismatch { expected_workspace, token_workspace, - } => { + }) => { assert_eq!(expected_workspace.as_str(), EXPECTED_WS); assert_eq!(token_workspace.as_str(), TOKEN_WS); } diff --git a/packages/stack-auth/src/oidc_refresher.rs b/packages/stack-auth/src/oidc_refresher.rs index c3559a2c2..84d128d60 100644 --- a/packages/stack-auth/src/oidc_refresher.rs +++ b/packages/stack-auth/src/oidc_refresher.rs @@ -162,7 +162,9 @@ impl Refresher for OidcRefresher

{ let status = resp.status(); let body = resp.text().await.unwrap_or_default(); tracing::debug!(%status, %body, "OIDC federation failed"); - return Err(AuthError::Server(format!("{status}: {body}"))); + return Err(AuthError::Server(crate::error::ServerError(format!( + "{status}: {body}" + )))); } let auth_resp: AuthoriseResponse = resp.json().await?; @@ -407,7 +409,9 @@ mod tests { let server = start_server(mocks).await; let provider = OidcProviderFn::new(|| async { - Err::(AuthError::Server("provider exploded".to_string())) + Err::(AuthError::Server(crate::error::ServerError( + "provider exploded".to_string(), + ))) }); let strategy = make_strategy(&server, provider); diff --git a/packages/stack-auth/src/service_token.rs b/packages/stack-auth/src/service_token.rs index 25db63c40..abb1d674a 100644 --- a/packages/stack-auth/src/service_token.rs +++ b/packages/stack-auth/src/service_token.rs @@ -75,7 +75,7 @@ impl ServiceToken { self.decoded .as_ref() .map(|d| d.subject.as_str()) - .map_err(|reason| AuthError::InvalidToken(reason.clone())) + .map_err(|reason| AuthError::InvalidToken(crate::error::InvalidToken(reason.clone()))) } /// Return the workspace identifier from the JWT claims. @@ -88,7 +88,7 @@ impl ServiceToken { self.decoded .as_ref() .map(|d| &d.workspace) - .map_err(|reason| AuthError::InvalidToken(reason.clone())) + .map_err(|reason| AuthError::InvalidToken(crate::error::InvalidToken(reason.clone()))) } /// Verify the token's `workspace` claim matches `expected`, returning the @@ -110,10 +110,12 @@ impl ServiceToken { pub(crate) fn verify_workspace(self, expected: WorkspaceId) -> Result { let token_workspace = *self.workspace_id()?; if token_workspace != expected { - return Err(AuthError::WorkspaceMismatch { - expected_workspace: expected, - token_workspace, - }); + return Err(AuthError::WorkspaceMismatch( + crate::error::WorkspaceMismatch { + expected_workspace: expected, + token_workspace, + }, + )); } Ok(self) } @@ -130,7 +132,7 @@ impl ServiceToken { self.decoded .as_ref() .map(|d| &d.issuer) - .map_err(|reason| AuthError::InvalidToken(reason.clone())) + .map_err(|reason| AuthError::InvalidToken(crate::error::InvalidToken(reason.clone()))) } /// Return the decoded services map from the JWT claims. @@ -143,7 +145,7 @@ impl ServiceToken { self.decoded .as_ref() .map(|d| &d.services) - .map_err(|reason| AuthError::InvalidToken(reason.clone())) + .map_err(|reason| AuthError::InvalidToken(crate::error::InvalidToken(reason.clone()))) } /// Return the ZeroKMS endpoint URL from the `services` claim. @@ -160,9 +162,9 @@ impl ServiceToken { .get(ServiceType::ZeroKms) .cloned() .ok_or_else(|| { - AuthError::InvalidToken( + AuthError::InvalidToken(crate::error::InvalidToken( "Token does not include a ZeroKMS endpoint in the services claim".into(), - ) + )) }) } @@ -212,7 +214,7 @@ fn decode_claims(token_str: &str) -> Result // in `AuthError::InvalidToken(reason)`, and we don't want "Invalid token: // Invalid token: ..." in the final message. crate::decode_jwt_payload_wasm(token_str).map_err(|e| match e { - crate::AuthError::InvalidToken(reason) => reason, + crate::AuthError::InvalidToken(crate::error::InvalidToken(reason)) => reason, other => other.to_string(), }) } @@ -438,10 +440,10 @@ mod tests { .verify_workspace(expected) .expect_err("a different expected workspace must be rejected"); match err { - AuthError::WorkspaceMismatch { + AuthError::WorkspaceMismatch(crate::error::WorkspaceMismatch { expected_workspace, token_workspace, - } => { + }) => { assert_eq!(expected_workspace.to_string(), "AAAAAAAAAAAAAAAA"); assert_eq!(token_workspace.to_string(), "ZVATKW3VHMFG27DY"); } diff --git a/packages/stack-auth/src/token.rs b/packages/stack-auth/src/token.rs index 3414cad6c..6b9849efa 100644 --- a/packages/stack-auth/src/token.rs +++ b/packages/stack-auth/src/token.rs @@ -161,9 +161,11 @@ impl Token { let workspace_id = self.workspace_id()?; let region: Region = self .region() - .ok_or(AuthError::NotAuthenticated)? + .ok_or(AuthError::NotAuthenticated(crate::error::NotAuthenticated))? .parse() - .map_err(|e: cts_common::RegionError| AuthError::Server(e.to_string()))?; + .map_err(|e: cts_common::RegionError| { + AuthError::Server(crate::error::ServerError(e.to_string())) + })?; Ok(Crn::new(region, workspace_id)) } @@ -186,8 +188,11 @@ impl Token { use std::collections::HashSet; let token_str = self.access_token.as_str(); - let header = decode_header(token_str) - .map_err(|e| AuthError::InvalidToken(format!("invalid JWT header: {e}")))?; + let header = decode_header(token_str).map_err(|e| { + AuthError::InvalidToken(crate::error::InvalidToken(format!( + "invalid JWT header: {e}" + ))) + })?; let dummy_key = DecodingKey::from_secret(&[]); let mut validation = Validation::new(header.alg); @@ -198,7 +203,11 @@ impl Token { decode(token_str, &dummy_key, &validation) .map(|data| data.claims) - .map_err(|e| AuthError::InvalidToken(format!("failed to decode JWT claims: {e}"))) + .map_err(|e| { + AuthError::InvalidToken(crate::error::InvalidToken(format!( + "failed to decode JWT claims: {e}" + ))) + }) } /// Wasm32 path: decode the JWT payload by splitting + base64 + JSON. We @@ -274,10 +283,10 @@ impl Token { let err: RefreshErrorResponse = resp.json().await?; tracing::debug!(error = %err.error, "token refresh failed"); return Err(match err.error.as_str() { - "invalid_grant" => AuthError::InvalidGrant, - "invalid_client" => AuthError::InvalidClient, - "access_denied" => AuthError::AccessDenied, - _ => AuthError::Server(err.error_description), + "invalid_grant" => AuthError::InvalidGrant(crate::error::InvalidGrant), + "invalid_client" => AuthError::InvalidClient(crate::error::InvalidClient), + "access_denied" => AuthError::AccessDenied(crate::error::AccessDenied), + _ => AuthError::Server(crate::error::ServerError(err.error_description)), }); } @@ -476,7 +485,7 @@ mod tests { .await .unwrap_err(); - assert!(matches!(err, AuthError::InvalidGrant)); + assert!(matches!(err, AuthError::InvalidGrant(_))); } #[tokio::test] @@ -494,7 +503,7 @@ mod tests { .await .unwrap_err(); - assert!(matches!(err, AuthError::InvalidClient)); + assert!(matches!(err, AuthError::InvalidClient(_))); } #[tokio::test] @@ -512,7 +521,7 @@ mod tests { .await .unwrap_err(); - assert!(matches!(err, AuthError::AccessDenied)); + assert!(matches!(err, AuthError::AccessDenied(_))); } #[tokio::test] @@ -530,7 +539,9 @@ mod tests { .await .unwrap_err(); - assert!(matches!(&err, AuthError::Server(desc) if desc == "something_unexpected occurred")); + assert!( + matches!(&err, AuthError::Server(crate::error::ServerError(desc)) if desc == "something_unexpected occurred") + ); } #[tokio::test] @@ -616,7 +627,7 @@ mod tests { fn test_workspace_crn_fails_without_region() { let token = jwt_token(valid_claims_json()); let err = token.workspace_crn().unwrap_err(); - assert!(matches!(err, AuthError::NotAuthenticated)); + assert!(matches!(err, AuthError::NotAuthenticated(_))); } #[test] From e1e39c327825c3e819623cea624d8bf51335a73d Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Sun, 28 Jun 2026 20:48:19 +1000 Subject: [PATCH 331/686] feat(stack-auth): serialize AuthError across the FFI boundary The napi binding embeds the serialized AuthError as a `__CS_FAIL__`-sentineled JSON blob in the `napi::Error` reason (which only carries a string); the wasm binding attaches it as a branded `__authFailure` object property (serialized as a plain JS object via `serialize_maps_as_objects`), keeping `.code` for parity. The JS layers parse these back into a `Result` failure. Consumed-handle reuse and lock poisoning now map to `AuthError::AlreadyConsumed` / `Internal` and flow through the same path. Error-mapping tests assert the new envelope (type/message/help/payload); the drift test derives the expected code set from `error.rs` and checks both hand-written `.d.ts` unions. Claude-Session: https://claude.ai/code/session_0197v7GRr8qKzhTtLCMjGFH4 --- languages/typescript/packages/auth/src/lib.rs | 281 +++++++++--------- .../packages/stack-auth-wasm/src/lib.rs | 57 ++-- 2 files changed, 179 insertions(+), 159 deletions(-) diff --git a/languages/typescript/packages/auth/src/lib.rs b/languages/typescript/packages/auth/src/lib.rs index 7d1cf9755..e5982e5c3 100644 --- a/languages/typescript/packages/auth/src/lib.rs +++ b/languages/typescript/packages/auth/src/lib.rs @@ -7,8 +7,8 @@ use napi::threadsafe_function::{ErrorStrategy, ThreadsafeFunction, ThreadsafeFun use napi::tokio::sync::oneshot; use napi_derive::napi; use stack_auth::{ - AuthError, AuthStrategy, DeviceClientError, DeviceCodeStrategy, OidcProvider, - PendingDeviceCode, SecretToken, ServiceToken, Token, TokenStore, + AlreadyConsumed, AuthError, AuthStrategy, DeviceClientError, DeviceCodeStrategy, InternalError, + OidcProvider, PendingDeviceCode, SecretToken, ServerError, ServiceToken, Token, TokenStore, }; use vitaminc_protected::OpaqueDebug; use zeroize::Zeroizing; @@ -17,12 +17,20 @@ use zeroize::Zeroizing; // Error helpers // --------------------------------------------------------------------------- +/// Sentinel prefix that marks a `napi::Error` whose `reason` carries a +/// serialized [`AuthError`] (a domain failure) rather than an arbitrary throw. +/// `index.js` keys on this to convert the rejection into a `Result` `failure` +/// envelope; anything without it is re-thrown as a genuine error/panic. +const FAILURE_SENTINEL: &str = "__CS_FAIL__"; + fn to_napi_error(err: AuthError) -> napi::Error { - // Delegate to the canonical `AuthError::error_code` mapping in `stack-auth` - // rather than re-deriving it here — mirrors the wasm binding's `to_js_error`. - // The `CODE: message` format is parsed back into an `Error.code` by index.js. - let code = err.error_code(); - napi::Error::new(Status::GenericFailure, format!("{code}: {err}")) + // `napi::Error` only carries a string `reason`, so the structured failure + // (`{ type, message, help?, url?, ...payload }`) travels as a JSON blob + // behind the sentinel. `index.js` parses it back into the `Result` failure. + let json = serde_json::to_string(&err).unwrap_or_else(|_| { + serde_json::json!({ "type": err.error_code(), "message": err.to_string() }).to_string() + }); + napi::Error::new(Status::GenericFailure, format!("{FAILURE_SENTINEL}{json}")) } /// Surface a JS callback failure on stderr so it isn't silently swallowed — @@ -37,7 +45,7 @@ fn warn_callback(name: &str, detail: &str) { fn parse_workspace_crn(workspace_crn: &str) -> Result { workspace_crn .parse() - .map_err(|e| to_napi_error(AuthError::InvalidCrn(e))) + .map_err(|e| to_napi_error(AuthError::from(e))) } // --------------------------------------------------------------------------- @@ -242,17 +250,17 @@ impl OidcProvider for NapiOidcProvider { if status != Status::Ok { let detail = format!("callback dispatch failed: {status:?}"); warn_callback("getJwt", &detail); - return Err(AuthError::Server(format!("getJwt {detail}"))); + return Err(AuthError::Server(ServerError(format!("getJwt {detail}")))); } let promise = rx.await.map_err(|_| { warn_callback("getJwt", "callback did not run"); - AuthError::Server("getJwt callback did not run".to_string()) + AuthError::Server(ServerError("getJwt callback did not run".to_string())) })?; // `SecretToken` owns the JWT and zeroes it on drop (it's `ZeroizeOnDrop`), // so the awaited `String` moves straight in — no intermediate `Zeroizing`. let jwt = promise.await.map_err(|e| { warn_callback("getJwt", &format!("promise rejected: {e}")); - AuthError::Server(format!("getJwt rejected: {e}")) + AuthError::Server(ServerError(format!("getJwt rejected: {e}"))) })?; Ok(SecretToken::new(jwt)) } @@ -469,14 +477,9 @@ impl DeviceCodeResult { let pending = self .pending .lock() - .map_err(|_| napi::Error::new(Status::GenericFailure, "Lock poisoned"))? + .map_err(|_| to_napi_error(AuthError::Internal(InternalError("lock poisoned".into()))))? .take() - .ok_or_else(|| { - napi::Error::new( - Status::GenericFailure, - "Device code handle has already been consumed", - ) - })?; + .ok_or_else(|| to_napi_error(AuthError::AlreadyConsumed(AlreadyConsumed)))?; let token = pending.poll_for_token().await.map_err(to_napi_error)?; @@ -492,17 +495,13 @@ impl DeviceCodeResult { /// afterwards. #[napi] pub fn open_in_browser(&self) -> Result { - let guard = self - .pending - .lock() - .map_err(|_| napi::Error::new(Status::GenericFailure, "Lock poisoned"))?; + let guard = self.pending.lock().map_err(|_| { + to_napi_error(AuthError::Internal(InternalError("lock poisoned".into()))) + })?; match guard.as_ref() { Some(pending) => Ok(pending.open_in_browser()), - None => Err(napi::Error::new( - Status::GenericFailure, - "Device code handle has already been consumed", - )), + None => Err(to_napi_error(AuthError::AlreadyConsumed(AlreadyConsumed))), } } } @@ -534,8 +533,20 @@ fn device_client_error_code(err: &DeviceClientError) -> &'static str { } fn device_client_to_napi_error(err: DeviceClientError) -> napi::Error { - let code = device_client_error_code(&err); - napi::Error::new(Status::GenericFailure, format!("{code}: {err}")) + // When it wraps an `AuthError`, defer to the canonical serialization so + // help/payload are preserved; otherwise synthesize a `{ type, message }` + // failure envelope behind the same sentinel. + match err { + DeviceClientError::Auth(auth_err) => to_napi_error(auth_err), + other => { + let json = serde_json::json!({ + "type": device_client_error_code(&other), + "message": other.to_string(), + }) + .to_string(); + napi::Error::new(Status::GenericFailure, format!("{FAILURE_SENTINEL}{json}")) + } + } } /// Provision a device client in ZeroKMS after login. @@ -576,41 +587,65 @@ mod tests { use mocktail::prelude::*; use tempfile::TempDir; - /// `index.d.ts` is hand-written and re-exports the generated `native.d.ts`, - /// plus the `AuthErrorCode` union NAPI-RS can't emit. That union must list - /// exactly the codes `AuthError::error_code()` can return, plus - /// `UNKNOWN_ERROR` (the `index.js` fallback). - /// - /// The expected set is the exported [`AuthError::ERROR_CODES`] constant — a - /// real symbol the compiler resolves, not a scrape of the core crate's - /// source text. A core-crate test pins that constant against `error_code`'s - /// exhaustive match, so adding an `AuthError` variant forces a new code - /// there, which this test then requires the TS union to include; forget to - /// update `index.d.ts` and this fails. + /// The hand-written `AuthFailure` discriminated unions in `index.d.ts` and + /// `wasm-inline.d.ts` must list exactly the codes the Rust `AuthError` can + /// emit. The expected set is *derived* from the per-error + /// `AuthErrorKind::error_code` impls in the core crate's `error.rs` — not a + /// hand-kept mirror — so there's no parallel list to drift. Each impl + /// returns a bare `"CODE"` literal on its own line; add an `AuthError` + /// variant (which must impl `AuthErrorKind`) and forget to update the TS + /// unions, and this fails. #[test] - fn ts_auth_error_code_union_matches_error_codes() { + fn ts_auth_failure_union_matches_error_codes() { use std::collections::BTreeSet; - // The codes `AuthError::error_code` can return, plus the `UNKNOWN_ERROR` - // fallback the JS layer adds (never returned by `error_code`). - let mut expected: BTreeSet<&str> = AuthError::ERROR_CODES.iter().copied().collect(); - expected.insert("UNKNOWN_ERROR"); - - // The hand-written `index.d.ts` union — lines of the form ` | 'CODE'`. - let dts = include_str!("../index.d.ts"); - let union: BTreeSet<&str> = dts + // The codes returned by each `AuthErrorKind::error_code` impl: bare + // SCREAMING_CASE string literals on their own line in `error.rs`. The + // node crate depends on stack-auth, so this resolves to the core crate. + const ERROR_SRC: &str = include_str!("../../src/error.rs"); + let is_code = + |s: &str| !s.is_empty() && s.bytes().all(|b| b.is_ascii_uppercase() || b == b'_'); + let expected: BTreeSet<&str> = ERROR_SRC .lines() .filter_map(|line| { - line.trim() - .strip_prefix("| '") - .and_then(|rest| rest.strip_suffix('\'')) + let t = line.trim(); + t.strip_prefix('"') + .and_then(|r| r.strip_suffix('"')) + .filter(|code| is_code(code)) }) .collect(); - assert_eq!( - union, expected, - "AuthErrorCode union in index.d.ts drifted from AuthError::ERROR_CODES", + // A mis-scoped parse (empty/garbage) should fail loudly here, not pass. + assert!( + expected.len() >= 15, + "parsed only {} error codes from error.rs — the source parse likely broke", + expected.len(), ); + + // Each TS union member is `... { type: "CODE" ... }`; pull every literal. + let codes_in = |dts: &str| -> BTreeSet { + dts.match_indices("type: \"") + .map(|(i, _)| { + let after = &dts[i + "type: \"".len()..]; + let close = after + .find('"') + .expect("TS union type missing closing quote"); + after[..close].to_string() + }) + .collect() + }; + + for (name, dts) in [ + ("index.d.ts", include_str!("../index.d.ts")), + ("wasm-inline.d.ts", include_str!("../wasm-inline.d.ts")), + ] { + let union = codes_in(dts); + let expected: BTreeSet = expected.iter().map(|s| s.to_string()).collect(); + assert_eq!( + union, expected, + "AuthFailure union in {name} drifted from AuthError error codes", + ); + } } #[test] @@ -763,12 +798,24 @@ mod tests { } mod assertions { - /// Assert that a NAPI error's reason contains the expected error code prefix. + /// Parse the `__CS_FAIL__`-sentineled JSON failure envelope that a + /// `napi::Error` now carries (see `to_napi_error`). + pub(super) fn failure_json(err: &napi::Error) -> serde_json::Value { + let reason = err + .reason + .strip_prefix(crate::FAILURE_SENTINEL) + .unwrap_or_else(|| panic!("error reason missing failure sentinel: {}", err.reason)); + serde_json::from_str(reason) + .unwrap_or_else(|e| panic!("failure JSON did not parse ({e}): {reason}")) + } + + /// Assert the failure envelope's `type` matches the expected code. pub(super) fn has_error_code(err: &napi::Error, expected_code: &str) { - assert!( - err.reason.contains(&format!("{expected_code}: ")), - "expected '{expected_code}: ...' but got: {}", - err.reason + let json = failure_json(err); + assert_eq!( + json.get("type").and_then(|v| v.as_str()), + Some(expected_code), + "expected type {expected_code:?} but got envelope: {json}" ); } } @@ -778,81 +825,43 @@ mod tests { mod error_mapping { use super::*; - // Pins the exact `AuthError::error_code` strings the napi FFI contract - // depends on: `to_napi_error` embeds them as the `CODE:` prefix that - // index.js parses back into `Error.code`. The mapping itself lives in - // `stack-auth`; this guards that the codes the JS wrapper keys on can't - // drift without a failing test here. + // `to_napi_error` is the napi FFI seam: it serializes an `AuthError` + // into the `__CS_FAIL__`-sentineled JSON envelope (`{ type, message, + // help?, ...payload }`) that index.js turns into a `Result` failure. + // The canonical `error_code` mapping is exhaustively pinned in the core + // `stack-auth` crate; here we guard the FFI envelope shape itself. #[test] - fn maps_all_auth_error_variants() { - assert_eq!( - AuthError::AccessDenied.error_code(), - "ACCESS_DENIED", - "AccessDenied should map to ACCESS_DENIED" - ); - assert_eq!( - AuthError::TokenExpired.error_code(), - "EXPIRED_TOKEN", - "TokenExpired should map to EXPIRED_TOKEN" - ); - assert_eq!( - AuthError::InvalidGrant.error_code(), - "INVALID_GRANT", - "InvalidGrant should map to INVALID_GRANT" - ); - assert_eq!( - AuthError::InvalidClient.error_code(), - "INVALID_CLIENT", - "InvalidClient should map to INVALID_CLIENT" - ); - assert_eq!( - AuthError::InvalidUrl("http://[".parse::().unwrap_err()).error_code(), - "INVALID_URL", - "InvalidUrl should map to INVALID_URL" - ); - assert_eq!( - AuthError::Region(Region::new("invalid").unwrap_err()).error_code(), - "INVALID_REGION", - "Region should map to INVALID_REGION" - ); - assert_eq!( - AuthError::Server("test".to_string()).error_code(), - "SERVER_ERROR", - "Server should map to SERVER_ERROR" - ); - assert_eq!( - AuthError::NotAuthenticated.error_code(), - "NOT_AUTHENTICATED", - "NotAuthenticated should map to NOT_AUTHENTICATED" - ); - assert_eq!( - AuthError::MissingWorkspaceCrn.error_code(), - "MISSING_WORKSPACE_CRN", - "MissingWorkspaceCrn should map to MISSING_WORKSPACE_CRN" - ); - assert_eq!( - AuthError::InvalidAccessKey( - "bad-key".parse::().unwrap_err() - ) - .error_code(), - "INVALID_ACCESS_KEY", - "InvalidAccessKey should map to INVALID_ACCESS_KEY" - ); - assert_eq!( - AuthError::InvalidCrn("not-a-crn".parse::().unwrap_err()) - .error_code(), - "INVALID_CRN", - "InvalidCrn should map to INVALID_CRN" - ); + fn serializes_failure_envelope() { + let err = to_napi_error(AuthError::AccessDenied(stack_auth::AccessDenied)); + assertions::has_error_code(&err, "ACCESS_DENIED"); + + let err = to_napi_error(AuthError::Server(ServerError( + "something broke".to_string(), + ))); + let json = assertions::failure_json(&err); + assert_eq!(json["type"], "SERVER_ERROR"); + assert_eq!(json["message"], "Server error: something broke"); } + // A variant carrying structured payload + diagnostic help surfaces both + // in the envelope, so a JS consumer can narrow on them. #[test] - fn formats_as_code_colon_message() { - let err = to_napi_error(AuthError::AccessDenied); - assertions::has_error_code(&err, "ACCESS_DENIED"); - - let err = to_napi_error(AuthError::Server("something broke".to_string())); - assertions::has_error_code(&err, "SERVER_ERROR"); + fn envelope_includes_payload_and_help() { + let ws = |s: &str| s.parse::().unwrap(); + let err = to_napi_error(AuthError::WorkspaceMismatch( + stack_auth::WorkspaceMismatch { + expected_workspace: ws("ZVATKW3VHMFG27DY"), + token_workspace: ws("AAAAAAAAAAAAAAAA"), + }, + )); + let json = assertions::failure_json(&err); + assert_eq!(json["type"], "WORKSPACE_MISMATCH"); + assert_eq!(json["expected"], "ZVATKW3VHMFG27DY"); + assert_eq!(json["actual"], "AAAAAAAAAAAAAAAA"); + assert!( + json["help"].as_str().is_some(), + "expected help in envelope, got: {json}" + ); } } @@ -1047,11 +1056,7 @@ mod tests { let result = consumed_result(&server, &dir).await; let err = result.poll_for_token().await.unwrap_err(); - assert!( - err.reason.contains("already been consumed"), - "second poll_for_token call should fail with consumed error, got: {}", - err.reason - ); + assertions::has_error_code(&err, "ALREADY_CONSUMED"); } #[tokio::test(start_paused = true)] @@ -1068,11 +1073,7 @@ mod tests { let result = consumed_result(&server, &dir).await; let err = result.open_in_browser().unwrap_err(); - assert!( - err.reason.contains("already been consumed"), - "open_in_browser after consume should fail, got: {}", - err.reason - ); + assertions::has_error_code(&err, "ALREADY_CONSUMED"); } } } diff --git a/languages/typescript/packages/stack-auth-wasm/src/lib.rs b/languages/typescript/packages/stack-auth-wasm/src/lib.rs index 07aaef7a9..295ec134b 100644 --- a/languages/typescript/packages/stack-auth-wasm/src/lib.rs +++ b/languages/typescript/packages/stack-auth-wasm/src/lib.rs @@ -31,15 +31,27 @@ fn module_init() { console_error_panic_hook::set_once(); } -/// Attach a machine-readable `.code` to a JS error object. -fn attach_code(js_err: impl Into, code: &str) -> JsValue { - let v: JsValue = js_err.into(); - let _ = js_sys::Reflect::set(&v, &JsValue::from_str("code"), &JsValue::from_str(code)); - v -} - +/// Build a JS `Error` carrying the structured failure for the `.mjs` shim to +/// turn into a `Result` `failure`. +/// +/// The serialized [`AuthError`] (`{ type, message, help?, url?, ...payload }`) +/// is attached as a branded `__authFailure` property — a plain JS object, via +/// `serialize_maps_as_objects` — and `.code` is kept on the error for parity +/// with the previous contract. fn to_js_error(err: AuthError) -> JsValue { - attach_code(js_sys::Error::new(&err.to_string()), err.error_code()) + let js_err: JsValue = js_sys::Error::new(&err.to_string()).into(); + + let serializer = Serializer::new().serialize_maps_as_objects(true); + if let Ok(details) = err.serialize(&serializer) { + let _ = js_sys::Reflect::set(&js_err, &JsValue::from_str("__authFailure"), &details); + } + let _ = js_sys::Reflect::set( + &js_err, + &JsValue::from_str("code"), + &JsValue::from_str(err.error_code()), + ); + + js_err } #[derive(Serialize)] @@ -184,22 +196,27 @@ impl OidcProvider for JsOidcProvider { async fn fetch(&self) -> Result { let promise = self.get_jwt.call0(&JsValue::NULL).map_err(|err| { warn_callback("getJwt", "synchronous throw", &err); - AuthError::Server(format!("getJwt callback threw: {}", js_error_detail(&err))) + AuthError::Server(stack_auth::ServerError(format!( + "getJwt callback threw: {}", + js_error_detail(&err) + ))) })?; let result = JsFuture::from(js_sys::Promise::from(promise)) .await .map_err(|err| { warn_callback("getJwt", "promise rejection", &err); - AuthError::Server(format!( + AuthError::Server(stack_auth::ServerError(format!( "getJwt callback rejected: {}", js_error_detail(&err) - )) + ))) })?; // `SecretToken` owns the JWT string and zeroes its heap buffer on drop // (it's `ZeroizeOnDrop`) — it carries the bearer credential between the // JS boundary and the federation HTTP request. let jwt = result.as_string().ok_or_else(|| { - AuthError::Server("getJwt callback did not return a string".to_string()) + AuthError::Server(stack_auth::ServerError( + "getJwt callback did not return a string".to_string(), + )) })?; Ok(SecretToken::new(jwt)) } @@ -211,7 +228,7 @@ impl OidcProvider for JsOidcProvider { fn parse_workspace_crn(workspace_crn: &str) -> Result { workspace_crn .parse() - .map_err(|e| to_js_error(AuthError::InvalidCrn(e))) + .map_err(|e| to_js_error(AuthError::from(e))) } enum AccessKeyStrategyInner { @@ -459,9 +476,9 @@ mod tests { #[wasm_bindgen_test] fn to_js_error_attaches_code_property() { - let err = to_js_error(AuthError::AccessDenied); + let err = to_js_error(AuthError::AccessDenied(stack_auth::AccessDenied)); assert_eq!(error_code_of(&err), "ACCESS_DENIED"); - let err = to_js_error(AuthError::Server("boom".into())); + let err = to_js_error(AuthError::Server(stack_auth::ServerError("boom".into()))); assert_eq!(error_code_of(&err), "SERVER_ERROR"); } @@ -474,10 +491,12 @@ mod tests { /// target. #[wasm_bindgen_test] fn workspace_mismatch_maps_to_workspace_mismatch_code() { - let err = to_js_error(AuthError::WorkspaceMismatch { - expected_workspace: "ZVATKW3VHMFG27DY".parse().unwrap(), - token_workspace: "AAAAAAAAAAAAAAAA".parse().unwrap(), - }); + let err = to_js_error(AuthError::WorkspaceMismatch( + stack_auth::WorkspaceMismatch { + expected_workspace: "ZVATKW3VHMFG27DY".parse().unwrap(), + token_workspace: "AAAAAAAAAAAAAAAA".parse().unwrap(), + }, + )); assert_eq!(error_code_of(&err), "WORKSPACE_MISMATCH"); } From 578e72c884736dc4e163338027a2fd21e99c62aa Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Sun, 28 Jun 2026 20:48:32 +1000 Subject: [PATCH 332/686] feat(stack-auth-node)!: return a Result instead of throwing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every fallible operation on the napi and wasm-inline entries now returns a `@byteslice/result` Result — `{ data }` on success, `{ failure }` on a domain error — assembled by the JS wrappers from the structured error the Rust layer emits. `failure` is a discriminated `AuthFailure` union tagged by `type`, carrying the live `error: Error`, optional `help`/`url`, and per-variant payload (e.g. WorkspaceMismatch's expected/actual). Consumers write `if (result.failure)` and never `try/catch`; only a genuine panic still throws. index.d.ts re-declares the strategy classes with Result-returning signatures (the generated native.d.ts still throws) and re-uses the plain success types; wasm-inline.d.ts mirrors it. Adds `@byteslice/result` as a runtime dependency. BREAKING CHANGE: throw-based error handling is replaced by Result returns. Claude-Session: https://claude.ai/code/session_0197v7GRr8qKzhTtLCMjGFH4 --- languages/typescript/packages/auth/index.d.ts | 194 ++++++++++++++---- languages/typescript/packages/auth/index.js | 58 ++++-- .../typescript/packages/auth/package.json | 45 ++-- .../typescript/packages/auth/wasm-inline.d.ts | 65 +++++- .../typescript/packages/auth/wasm-inline.mjs | 120 +++++++---- 5 files changed, 354 insertions(+), 128 deletions(-) diff --git a/languages/typescript/packages/auth/index.d.ts b/languages/typescript/packages/auth/index.d.ts index 37acfb61f..8fb5a31f7 100644 --- a/languages/typescript/packages/auth/index.d.ts +++ b/languages/typescript/packages/auth/index.d.ts @@ -4,55 +4,165 @@ /* * Hand-written — NOT regenerated by `napi build`. * - * `napi build --dts native.d.ts` writes the generated bindings to `native.d.ts`; - * this file re-exports them and adds the declarations NAPI-RS can't emit from - * the Rust types, because they describe *thrown* values rather than function - * signatures: + * `napi build --dts native.d.ts` writes the raw generated bindings to + * `native.d.ts`. Those bindings *throw* on failure; this file presents the + * public contract instead: every fallible operation returns a + * `@byteslice/result` `Result` (`{ data }` on success, + * `{ failure }` on error), assembled by `index.js` from the structured error + * the Rust layer emits. Consumers write `if (result.failure) …` and never + * `try/catch` for domain errors. * - * - `AuthError` / `AuthErrorCode` — errors thrown by this package are tagged - * with a machine-readable `.code` at runtime by `index.js`. - * - `OAuthStrategy` — a deprecated alias exported at runtime by `index.js`. - * - * `AuthErrorCode` mirrors `AuthError::ERROR_CODES` in - * `packages/stack-auth/src/lib.rs` (plus `UNKNOWN_ERROR`, the `index.js` - * fallback). The `stack-auth-node` test `ts_auth_error_code_union_matches_error_codes` - * guards this union against drift. + * The plain success types (`TokenResult`, `AuthResult`, `AutoStrategyOptions`) + * are re-used from `native.d.ts`; the strategy classes/functions are + * re-declared here with `Result`-returning signatures. `AuthFailure` mirrors + * `AuthError` in `packages/stack-auth/src/error.rs`; the `stack-auth-node` + * drift test `ts_auth_failure_union_matches_error_codes` guards it. + */ + +import type { Result } from "@byteslice/result"; +import type { TokenResult, AuthResult, AutoStrategyOptions } from "./native"; + +export type { TokenResult, AuthResult, AutoStrategyOptions }; + +/** Fields present on every `AuthFailure`. */ +interface FailureBase { + /** The live `Error` thrown across the FFI boundary, with `.message`/`.code`. */ + error: Error; + /** Actionable diagnostic guidance, when the error carries it. */ + help?: string; + /** A URL with more detail, when the error carries it. */ + url?: string; +} + +/** + * A domain failure returned in the `failure` arm of a `Result`. Discriminated + * by `type`; narrow on it to access per-variant payload (e.g. + * `WORKSPACE_MISMATCH`'s `expected`/`actual`). */ +export type AuthFailure = + | (FailureBase & { type: "REQUEST_ERROR" }) + | (FailureBase & { type: "ACCESS_DENIED" }) + | (FailureBase & { type: "EXPIRED_TOKEN" }) + | (FailureBase & { type: "INVALID_GRANT" }) + | (FailureBase & { type: "INVALID_CLIENT" }) + | (FailureBase & { type: "INVALID_URL" }) + | (FailureBase & { type: "INVALID_REGION" }) + | (FailureBase & { type: "INVALID_TOKEN" }) + | (FailureBase & { type: "SERVER_ERROR" }) + | (FailureBase & { type: "NOT_AUTHENTICATED" }) + | (FailureBase & { type: "MISSING_WORKSPACE_CRN" }) + | (FailureBase & { type: "INVALID_ACCESS_KEY" }) + | (FailureBase & { type: "INVALID_CRN" }) + | (FailureBase & { type: "WORKSPACE_MISMATCH"; expected: string; actual: string }) + | (FailureBase & { type: "INVALID_WORKSPACE_ID" }) + | (FailureBase & { type: "ALREADY_CONSUMED" }) + | (FailureBase & { type: "INTERNAL_ERROR" }) + | (FailureBase & { type: "STORE_ERROR" }); + +/** The machine-readable discriminant carried by every {@link AuthFailure}. */ +export type AuthErrorCode = AuthFailure["type"]; -export * from "./native"; - -import type { DeviceSessionStrategy } from "./native"; - -/** Error codes attached to errors thrown by this package. */ -export type AuthErrorCode = - | 'REQUEST_ERROR' - | 'ACCESS_DENIED' - | 'EXPIRED_TOKEN' - | 'INVALID_GRANT' - | 'INVALID_CLIENT' - | 'INVALID_URL' - | 'INVALID_REGION' - | 'INVALID_TOKEN' - | 'SERVER_ERROR' - | 'STORE_ERROR' - | 'NOT_AUTHENTICATED' - | 'MISSING_WORKSPACE_CRN' - | 'INVALID_ACCESS_KEY' - | 'INVALID_CRN' - | 'WORKSPACE_MISMATCH' - | 'INVALID_WORKSPACE_ID' - | 'UNKNOWN_ERROR' - -/** An error thrown by this package, enriched with a machine-readable `.code`. */ -export interface AuthError extends Error { - code: AuthErrorCode +/** + * An auth strategy that auto-detects credentials from environment variables + * and the local profile store. + */ +export declare class AutoStrategy { + /** Detect available credentials and return an `AutoStrategy`. */ + static detect( + options?: AutoStrategyOptions | undefined | null, + ): Result; + /** Retrieve a valid access token, refreshing or re-authenticating as needed. */ + getToken(): Promise>; } +/** + * An auth strategy that uses a static access key for service-to-service + * or CI/CD authentication. + */ +export declare class AccessKeyStrategy { + /** + * Create a new `AccessKeyStrategy` for the given workspace CRN and access key. + * The CRN format is `crn::`. A workspace mismatch fails + * `getToken()` with `failure.type === "WORKSPACE_MISMATCH"`. + */ + static create( + workspaceCrn: string, + accessKey: string, + ): Result; + /** Retrieve a valid access token, refreshing or re-authenticating as needed. */ + getToken(): Promise>; +} + +/** + * An auth strategy that uses OAuth refresh tokens persisted to disk + * (`~/.cipherstash/auth.json`). + */ +export declare class DeviceSessionStrategy { + /** Load credentials from the default profile store. */ + static fromProfile(): Result; + /** Retrieve a valid access token, refreshing as needed. */ + getToken(): Promise>; +} + +/** + * An auth strategy that federates a third-party OIDC JWT (Clerk, Supabase, …) + * into a CipherStash CTS service token via `/api/authorise`. + */ +export declare class OidcFederationStrategy { + /** + * Create an `OidcFederationStrategy` for the given workspace CRN. `getJwt` is + * called on every federation and must resolve to the current third-party OIDC + * JWT. `baseUrl` pins the strategy to a specific CTS host. + */ + static create( + workspaceCrn: string, + getJwt: () => Promise | string, + baseUrl?: string | undefined | null, + ): Result; + /** + * Like `create` but persists the federated CTS token through `loadToken` / + * `saveToken` (e.g. an HTTP-only cookie) so it survives across requests. + */ + static createWithStore( + workspaceCrn: string, + getJwt: () => Promise | string, + loadToken: () => Promise | string | null | undefined, + saveToken: (json: string) => Promise | void, + baseUrl?: string | undefined | null, + ): Result; + /** Retrieve a valid CTS service token, federating or re-federating as needed. */ + getToken(): Promise>; +} + +/** The pending state of an in-progress OAuth 2.0 Device Authorization flow. */ +export declare class DeviceCodeResult { + get userCode(): string; + get verificationUri(): string; + get verificationUriComplete(): string; + get expiresIn(): number; + /** + * Poll the auth server until the user completes authorization. **Consumes** + * the internal handle — a second call fails with + * `failure.type === "ALREADY_CONSUMED"`. + */ + pollForToken(): Promise>; + /** Open the verification URI in the user's default browser. Non-consuming. */ + openInBrowser(): Result; +} + +/** Begin the OAuth 2.0 Device Authorization flow. */ +export declare function beginDeviceCodeFlow( + region: string, + clientId: string, +): Promise>; + +/** Provision a device client in ZeroKMS after login. */ +export declare function bindClientDevice(): Promise>; + /** * Deprecated alias for {@link DeviceSessionStrategy}, exported at runtime as - * `module.exports.OAuthStrategy = DeviceSessionStrategy`. Kept so existing - * consumers don't break; will be removed in a future major release. + * `module.exports.OAuthStrategy = DeviceSessionStrategy`. * * @deprecated Renamed to `DeviceSessionStrategy`. */ -export declare const OAuthStrategy: typeof DeviceSessionStrategy +export declare const OAuthStrategy: typeof DeviceSessionStrategy; diff --git a/languages/typescript/packages/auth/index.js b/languages/typescript/packages/auth/index.js index 583721a2b..48a3ea2c3 100644 --- a/languages/typescript/packages/auth/index.js +++ b/languages/typescript/packages/auth/index.js @@ -1,44 +1,64 @@ -// Wrapper that loads the native napi-rs module and enriches errors with a -// machine-readable `.code` property by parsing the "CODE: message" format -// that the Rust side produces. +// Wrapper that loads the native napi-rs module and converts its outcomes into +// the `@byteslice/result` shape: `{ data }` on success, `{ failure }` on a +// domain error. The Rust side never reaches the caller as a throw — every +// `AuthError` crosses the FFI boundary as a `__CS_FAIL__`-sentineled JSON blob +// in the rejection/throw, which we parse here into a typed `failure`. Only a +// genuine panic (no sentinel) propagates as a thrown exception. const native = require("./stack-auth-node.js"); -const CODE_RE = /^([A-Z_]+): /; +// Must match `FAILURE_SENTINEL` in src/lib.rs. +const FAILURE_SENTINEL = "__CS_FAIL__"; /** - * Parse the "CODE: message" format produced by the Rust bindings and attach - * `.code` to the Error object. + * Convert a thrown/rejected native error into a `Result` `failure`. + * + * Domain failures carry the sentinel + serialized `AuthError` + * (`{ type, message, help?, url?, ...payload }`); we reuse the thrown `Error` + * as the live `failure.error`, restoring its message and attaching the + * structured fields. Anything without the sentinel is a real bug/panic and is + * re-thrown unchanged. */ -function enrichError(err) { - if (err instanceof Error) { - const match = CODE_RE.exec(err.message); - if (match) { - err.code = match[1]; - err.message = err.message.slice(match[0].length); - } +function toFailure(err) { + if (!(err instanceof Error) || !err.message.startsWith(FAILURE_SENTINEL)) { + throw err; + } + const { type, message, help, url, ...payload } = JSON.parse( + err.message.slice(FAILURE_SENTINEL.length), + ); + err.message = message; + err.code = type; + const failure = { type, error: err, ...payload }; + if (help !== undefined) { + err.help = help; + failure.help = help; + } + if (url !== undefined) { + err.url = url; + failure.url = url; } - throw err; + return { failure }; } /** - * Wrap an async function so that rejected errors get `.code` enrichment. + * Wrap an async native function so it resolves to `{ data }` / `{ failure }` + * and never rejects for a domain error. */ function wrapAsync(fn) { return function (...args) { - return fn.apply(this, args).catch(enrichError); + return fn.apply(this, args).then((data) => ({ data }), toFailure); }; } /** - * Wrap a sync function so that thrown errors get `.code` enrichment. + * Wrap a sync native function so it returns `{ data }` / `{ failure }`. */ function wrapSync(fn) { return function (...args) { try { - return fn.apply(this, args); + return { data: fn.apply(this, args) }; } catch (err) { - enrichError(err); + return toFailure(err); } }; } diff --git a/languages/typescript/packages/auth/package.json b/languages/typescript/packages/auth/package.json index e2b05cfd4..b6706a1cf 100644 --- a/languages/typescript/packages/auth/package.json +++ b/languages/typescript/packages/auth/package.json @@ -1,6 +1,6 @@ { "name": "@cipherstash/auth", - "version": "0.40.0", + "version": "1.0.0", "main": "index.js", "types": "index.d.ts", "browser": false, @@ -64,24 +64,39 @@ "format:check": "npx --yes @biomejs/biome@2.3.4 ci ." }, "peerDependencies": { - "@cipherstash/auth-darwin-x64": "0.40.0", - "@cipherstash/auth-darwin-arm64": "0.40.0", - "@cipherstash/auth-linux-x64-gnu": "0.40.0", - "@cipherstash/auth-linux-arm64-gnu": "0.40.0", - "@cipherstash/auth-linux-x64-musl": "0.40.0", - "@cipherstash/auth-win32-x64-msvc": "0.40.0" + "@cipherstash/auth-darwin-x64": "1.0.0", + "@cipherstash/auth-darwin-arm64": "1.0.0", + "@cipherstash/auth-linux-x64-gnu": "1.0.0", + "@cipherstash/auth-linux-arm64-gnu": "1.0.0", + "@cipherstash/auth-linux-x64-musl": "1.0.0", + "@cipherstash/auth-win32-x64-msvc": "1.0.0" }, "peerDependenciesMeta": { - "@cipherstash/auth-darwin-x64": { "optional": true }, - "@cipherstash/auth-darwin-arm64": { "optional": true }, - "@cipherstash/auth-linux-x64-gnu": { "optional": true }, - "@cipherstash/auth-linux-arm64-gnu": { "optional": true }, - "@cipherstash/auth-linux-x64-musl": { "optional": true }, - "@cipherstash/auth-win32-x64-msvc": { "optional": true } + "@cipherstash/auth-darwin-x64": { + "optional": true + }, + "@cipherstash/auth-darwin-arm64": { + "optional": true + }, + "@cipherstash/auth-linux-x64-gnu": { + "optional": true + }, + "@cipherstash/auth-linux-arm64-gnu": { + "optional": true + }, + "@cipherstash/auth-linux-x64-musl": { + "optional": true + }, + "@cipherstash/auth-win32-x64-msvc": { + "optional": true + } + }, + "dependencies": { + "@byteslice/result": "^0.3.0" }, "devDependencies": { "@napi-rs/cli": "^2", - "vitest": "^3", - "typescript": "^5" + "typescript": "^5", + "vitest": "^3" } } diff --git a/languages/typescript/packages/auth/wasm-inline.d.ts b/languages/typescript/packages/auth/wasm-inline.d.ts index bcf7b8c71..fc2d24085 100644 --- a/languages/typescript/packages/auth/wasm-inline.d.ts +++ b/languages/typescript/packages/auth/wasm-inline.d.ts @@ -6,12 +6,61 @@ * the raw wasm-bindgen-generated bindings. Consumers see this; the raw * `createWithStore(crn, key, loadFn, saveFn)` shape stays internal. * - * `AuthErrorCode`, `AuthError`, and `TokenResult` are shared with the - * lower-level `/wasm` entry via `wasm-types.d.ts` — re-exported here so - * importers only need one TS module reference. + * Every fallible operation returns a `@byteslice/result` `Result` + * (`{ data }` on success, `{ failure }` on error) assembled by the wrapper in + * `wasm-inline.mjs` from the structured error the wasm layer attaches — so + * consumers write `if (result.failure) …` and never `try/catch`. (The lower- + * level `/wasm` entry still throws; see `wasm-types.d.ts`.) */ -export type { AuthErrorCode, AuthError, TokenResult } from "./wasm-types.d.ts"; +import type { Result } from "@byteslice/result"; + +export type { TokenResult } from "./wasm-types.d.ts"; + +/** Fields present on every {@link AuthFailure}. */ +interface FailureBase { + /** The live `Error` from the wasm boundary, with `.message`/`.code`. */ + error: Error; + /** Actionable diagnostic guidance, when the error carries it. */ + help?: string; + /** A URL with more detail, when the error carries it. */ + url?: string; +} + +/** + * A domain failure returned in the `failure` arm of a `Result`. Discriminated + * by `type`; narrow to access per-variant payload (e.g. `WORKSPACE_MISMATCH`'s + * `expected`/`actual`). This is the full code set; the wasm strategies emit a + * subset (no device-flow or filesystem-store codes). + */ +export type AuthFailure = + | (FailureBase & { type: "REQUEST_ERROR" }) + | (FailureBase & { type: "ACCESS_DENIED" }) + | (FailureBase & { type: "EXPIRED_TOKEN" }) + | (FailureBase & { type: "INVALID_GRANT" }) + | (FailureBase & { type: "INVALID_CLIENT" }) + | (FailureBase & { type: "INVALID_URL" }) + | (FailureBase & { type: "INVALID_REGION" }) + | (FailureBase & { type: "INVALID_TOKEN" }) + | (FailureBase & { type: "SERVER_ERROR" }) + | (FailureBase & { type: "NOT_AUTHENTICATED" }) + | (FailureBase & { type: "MISSING_WORKSPACE_CRN" }) + | (FailureBase & { type: "INVALID_ACCESS_KEY" }) + | (FailureBase & { type: "INVALID_CRN" }) + | (FailureBase & { type: "WORKSPACE_MISMATCH"; expected: string; actual: string }) + | (FailureBase & { type: "INVALID_WORKSPACE_ID" }) + | (FailureBase & { type: "ALREADY_CONSUMED" }) + | (FailureBase & { type: "INTERNAL_ERROR" }) + | (FailureBase & { type: "STORE_ERROR" }); + +/** The machine-readable discriminant carried by every {@link AuthFailure}. */ +export type AuthErrorCode = AuthFailure["type"]; + +/** The resolved value of a `getToken()` call. */ +export type GetTokenResult = Result< + import("./wasm-types.d.ts").TokenResult, + AuthFailure +>; /** * Pluggable persistent cache for service tokens. Pair with the @@ -73,9 +122,9 @@ export declare class AccessKeyStrategy { workspaceCrn: string, accessKey: string, options?: AccessKeyStrategyOptions, - ): AccessKeyStrategy; + ): Result; /** Retrieve a valid access token, refreshing or re-authenticating as needed. */ - getToken(): Promise; + getToken(): Promise; /** Release the underlying wasm resources. */ free(): void; } @@ -125,9 +174,9 @@ export declare class OidcFederationStrategy { workspaceCrn: string, getJwt: OidcProvider, options?: OidcFederationStrategyOptions, - ): OidcFederationStrategy; + ): Result; /** Retrieve a valid CTS service token, federating or re-federating as needed. */ - getToken(): Promise; + getToken(): Promise; /** Release the underlying wasm resources. */ free(): void; } diff --git a/languages/typescript/packages/auth/wasm-inline.mjs b/languages/typescript/packages/auth/wasm-inline.mjs index e390f2b2f..64411b72b 100644 --- a/languages/typescript/packages/auth/wasm-inline.mjs +++ b/languages/typescript/packages/auth/wasm-inline.mjs @@ -17,6 +17,22 @@ import { /** @typedef {() => string | Promise} OidcProvider */ /** @typedef {{ store?: TokenStore }} OidcFederationStrategyOptions */ +// Convert a thrown/rejected wasm error into a `Result` `failure`. The wasm +// binding attaches the serialized `AuthError` as an `__authFailure` object on +// the thrown `Error`; we reuse that `Error` as the live `failure.error`. +// Anything without the brand is a genuine panic and is re-thrown. +function toFailure(err) { + const details = err && err.__authFailure; + if (!details || typeof details.type !== "string") throw err; + const { type, help, url, ...payload } = details; + // `payload` still carries `message`; drop it from the spread fields. + delete payload.message; + const failure = { type, error: err, ...payload }; + if (help !== undefined) failure.help = help; + if (url !== undefined) failure.url = url; + return { failure }; +} + export class AccessKeyStrategy { #inner; @@ -32,32 +48,40 @@ export class AccessKeyStrategy { * @returns {AccessKeyStrategy} */ static create(workspaceCrn, accessKey, options) { - const store = options?.store; - if (store) { - // Wrap the user's `load` / `save` so the wasm binding always sees - // Promise-returning functions even if the caller passed sync ones — - // `js_sys::Promise::from` on the wasm side casts the return value as - // a Promise unconditionally, so sync values would otherwise reject. - const load = () => Promise.resolve(store.load()); - const save = (/** @type {string} */ json) => - Promise.resolve(store.save(json)); - return new AccessKeyStrategy( - RawAccessKeyStrategy.createWithStore( - workspaceCrn, - accessKey, - load, - save, + try { + const store = options?.store; + if (store) { + // Wrap the user's `load` / `save` so the wasm binding always sees + // Promise-returning functions even if the caller passed sync ones — + // `js_sys::Promise::from` on the wasm side casts the return value as + // a Promise unconditionally, so sync values would otherwise reject. + const load = () => Promise.resolve(store.load()); + const save = (/** @type {string} */ json) => + Promise.resolve(store.save(json)); + return { + data: new AccessKeyStrategy( + RawAccessKeyStrategy.createWithStore( + workspaceCrn, + accessKey, + load, + save, + ), + ), + }; + } + return { + data: new AccessKeyStrategy( + RawAccessKeyStrategy.create(workspaceCrn, accessKey), ), - ); + }; + } catch (err) { + return toFailure(err); } - return new AccessKeyStrategy( - RawAccessKeyStrategy.create(workspaceCrn, accessKey), - ); } - /** @returns {Promise} */ + /** @returns {Promise} */ getToken() { - return this.#inner.getToken(); + return this.#inner.getToken().then((data) => ({ data }), toFailure); } free() { @@ -80,34 +104,42 @@ export class OidcFederationStrategy { * @returns {OidcFederationStrategy} */ static create(workspaceCrn, getJwt, options) { - // Wrap `getJwt` so the wasm binding always sees a Promise-returning - // function even if the caller passed a sync one — see the note in - // `AccessKeyStrategy.create`. - const jwt = () => Promise.resolve(getJwt()); - const store = options?.store; - const baseUrl = options?.baseUrl; - if (store) { - const load = () => Promise.resolve(store.load()); - const save = (/** @type {string} */ json) => - Promise.resolve(store.save(json)); - return new OidcFederationStrategy( - RawOidcFederationStrategy.createWithStore( - workspaceCrn, - jwt, - load, - save, - baseUrl, + try { + // Wrap `getJwt` so the wasm binding always sees a Promise-returning + // function even if the caller passed a sync one — see the note in + // `AccessKeyStrategy.create`. + const jwt = () => Promise.resolve(getJwt()); + const store = options?.store; + const baseUrl = options?.baseUrl; + if (store) { + const load = () => Promise.resolve(store.load()); + const save = (/** @type {string} */ json) => + Promise.resolve(store.save(json)); + return { + data: new OidcFederationStrategy( + RawOidcFederationStrategy.createWithStore( + workspaceCrn, + jwt, + load, + save, + baseUrl, + ), + ), + }; + } + return { + data: new OidcFederationStrategy( + RawOidcFederationStrategy.create(workspaceCrn, jwt, baseUrl), ), - ); + }; + } catch (err) { + return toFailure(err); } - return new OidcFederationStrategy( - RawOidcFederationStrategy.create(workspaceCrn, jwt, baseUrl), - ); } - /** @returns {Promise} */ + /** @returns {Promise} */ getToken() { - return this.#inner.getToken(); + return this.#inner.getToken().then((data) => ({ data }), toFailure); } free() { From 02a139de3f6f3211f3b7e80b558a70a583e1402f Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Sun, 28 Jun 2026 20:48:42 +1000 Subject: [PATCH 333/686] test(stack-auth-node): migrate tests, examples and docs to Result; v1.0.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Migrate the vitest suites and examples from try/catch + `.code` to `if (result.failure) … result.failure.type` / `result.data`, and rewrite the consumer-typecheck fixture to exercise the Result/AuthFailure surface (incl. WORKSPACE_MISMATCH payload narrowing). Rewrite the node + wasm README error-handling sections, add a throw->Result migration note, and bump to 1.0.0 with a CHANGELOG entry. Claude-Session: https://claude.ai/code/session_0197v7GRr8qKzhTtLCMjGFH4 --- .../typescript/packages/auth/CHANGELOG.md | 44 +++++ languages/typescript/packages/auth/README.md | 70 ++++++-- .../auth/__tests__/consumer-typecheck.test.ts | 54 ++++-- .../auth/__tests__/device-code-flow.test.ts | 73 ++++---- .../__tests__/oidc-cookie-roundtrip.test.ts | 42 +++-- .../oidc-federation-strategy.test.ts | 168 ++++++++++-------- .../__tests__/provision-device-client.test.ts | 40 ++--- .../packages/auth/examples/auto-strategy.ts | 34 ++-- .../packages/auth/examples/device-code.ts | 23 ++- .../packages/stack-auth-wasm/README.md | 4 +- 10 files changed, 350 insertions(+), 202 deletions(-) diff --git a/languages/typescript/packages/auth/CHANGELOG.md b/languages/typescript/packages/auth/CHANGELOG.md index 2c7211b6f..60a662449 100644 --- a/languages/typescript/packages/auth/CHANGELOG.md +++ b/languages/typescript/packages/auth/CHANGELOG.md @@ -1,5 +1,49 @@ # Changelog +## 1.0.0 + +### Breaking Changes + +- **Errors are now returned, not thrown.** Every fallible operation returns a + [`@byteslice/result`](https://www.npmjs.com/package/@byteslice/result) + `Result` — `{ data }` on success, `{ failure }` on a domain error — instead of + throwing. This applies to `getToken()`, the strategy factories + (`AccessKeyStrategy.create`, `AutoStrategy.detect`, + `DeviceSessionStrategy.fromProfile`, `OidcFederationStrategy.create` / + `.createWithStore`), `beginDeviceCodeFlow`, `DeviceCodeResult.pollForToken` / + `openInBrowser`, and `bindClientDevice`. The same applies to the + `@cipherstash/auth/wasm-inline` entry. + + `failure` is a discriminated union (`AuthFailure`) tagged by `type` (the codes + formerly on `err.code`), carrying the live `error: Error`, optional + `help`/`url`, and per-variant payload (e.g. `WORKSPACE_MISMATCH`'s `expected` + / `actual`). Only a genuine internal panic still throws. + + Migration: + + ```ts + // before + try { + const { token } = await strategy.getToken(); + } catch (err) { + if (err.code === "EXPIRED_TOKEN") { /* … */ } + } + + // after + const result = await strategy.getToken(); + if (result.failure) { + if (result.failure.type === "EXPIRED_TOKEN") { /* … */ } + } else { + const { token } = result.data; + } + ``` + + Two new failure `type`s surface caller/runtime states that previously threw + as bare errors: `ALREADY_CONSUMED` (reusing a consumed `DeviceCodeResult` + handle) and `INTERNAL_ERROR`. + +- Adds a runtime dependency on `@byteslice/result` (zero-dependency, MIT). + ## 0.40.0 ### New Features diff --git a/languages/typescript/packages/auth/README.md b/languages/typescript/packages/auth/README.md index 9d81b48b8..abc867a62 100644 --- a/languages/typescript/packages/auth/README.md +++ b/languages/typescript/packages/auth/README.md @@ -61,13 +61,20 @@ import { cookieStore } from "@cipherstash/auth/cookies"; Deno.serve(async (req) => { const responseHeaders = new Headers({ "content-type": "application/json" }); - const strategy = AccessKeyStrategy.create( + const created = AccessKeyStrategy.create( Deno.env.get("CS_WORKSPACE_CRN")!, // e.g. "crn:ap-southeast-2.aws:ZVATKW3VHMFG27DY" Deno.env.get("CS_CLIENT_ACCESS_KEY")!, { store: cookieStore({ request: req, responseHeaders }) }, ); + if (created.failure) { + return Response.json({ error: created.failure.type }, { status: 500, headers: responseHeaders }); + } - const { token, workspaceId, services } = await strategy.getToken(); + const result = await created.data.getToken(); + if (result.failure) { + return Response.json({ error: result.failure.type }, { status: 500, headers: responseHeaders }); + } + const { token, workspaceId, services } = result.data; // `token` is the bearer credential; pass as `Authorization: Bearer ${token}` // to ZeroKMS at `services.zerokms`. @@ -83,15 +90,15 @@ Deno.serve(async (req) => { ```jsonc { "imports": { - "@cipherstash/auth/wasm-inline": "npm:@cipherstash/auth@^0.39/wasm-inline", - "@cipherstash/auth/cookies": "npm:@cipherstash/auth@^0.39/cookies" + "@cipherstash/auth/wasm-inline": "npm:@cipherstash/auth@^1/wasm-inline", + "@cipherstash/auth/cookies": "npm:@cipherstash/auth@^1/cookies" } } ``` Nothing extra in `supabase/config.toml` — no `static_files`, no asset copying, no bundler plugins. The `wasm-inline` entry embeds the wasm module as base64 inside the JS shim, so it loads with zero runtime config. -`getToken()` resolves to `{ token, subject, workspaceId, issuer, services }` where `services` is a plain object (e.g. `{ zerokms: "https://..." }`). +`getToken()` resolves to a `Result`; on success `result.data` is `{ token, subject, workspaceId, issuer, services }` where `services` is a plain object (e.g. `{ zerokms: "https://..." }`). See [Error handling](#error-handling). For Cloudflare Workers the shape is identical; env access becomes `env.CS_CLIENT_ACCESS_KEY` instead of `Deno.env.get(...)`. @@ -106,14 +113,21 @@ import { cookieStore } from "@cipherstash/auth/cookies"; Deno.serve(async (req) => { const responseHeaders = new Headers({ "content-type": "application/json" }); - const strategy = OidcFederationStrategy.create( + const created = OidcFederationStrategy.create( Deno.env.get("CS_WORKSPACE_CRN")!, // e.g. "crn:ap-southeast-2.aws:ZVATKW3VHMFG27DY" // Returns the *current* provider JWT — re-invoked on every re-federation. () => getClerkSessionToken(req), { store: cookieStore({ request: req, responseHeaders }) }, ); + if (created.failure) { + return Response.json({ error: created.failure.type }, { status: 500, headers: responseHeaders }); + } - const { token, services } = await strategy.getToken(); + const result = await created.data.getToken(); + if (result.failure) { + return Response.json({ error: result.failure.type }, { status: 500, headers: responseHeaders }); + } + const { token, services } = result.data; return new Response(JSON.stringify({ services }), { headers: responseHeaders }); }); ``` @@ -204,8 +218,8 @@ Starts the OAuth 2.0 Device Authorization flow. Returns a `Promise **Migrating from the throw-based API (pre-1.0):** replace +> `try { const t = await s.getToken(); … } catch (err) { err.code }` +> with `const r = await s.getToken(); if (r.failure) { r.failure.type } else { r.data }`. +> Factories (`AccessKeyStrategy.create`, `AutoStrategy.detect`, +> `OidcFederationStrategy.create`, `DeviceSessionStrategy.fromProfile`) now +> return a `Result` too, so unwrap `.data` before use. ## License diff --git a/languages/typescript/packages/auth/__tests__/consumer-typecheck.test.ts b/languages/typescript/packages/auth/__tests__/consumer-typecheck.test.ts index a1ab38c10..e28031d4c 100644 --- a/languages/typescript/packages/auth/__tests__/consumer-typecheck.test.ts +++ b/languages/typescript/packages/auth/__tests__/consumer-typecheck.test.ts @@ -25,34 +25,52 @@ const packageDir = join(__dirname, ".."); // cross-platform (no shell, no `.bin` shim) and pinned to the devDependency. const tscBin = require.resolve("typescript/bin/tsc"); -// A consumer that imports — and *uses*, so nothing is elided — every symbol -// that has to survive the split: the napi-generated classes/interfaces re- -// exported via `./native`, the hand-written `AuthError`/`AuthErrorCode`, and -// the `OAuthStrategy` runtime alias (imported as a value, not a type). +// A consumer that imports — and *uses*, so nothing is elided — the public +// Result surface: the success types (`TokenResult`), the `AuthFailure` +// discriminated union + `AuthErrorCode`, the strategy classes (as values, to +// call their `Result`-returning factories/methods), and the `OAuthStrategy` +// runtime alias. It exercises narrowing on both arms of a `Result` and the +// per-variant `WORKSPACE_MISMATCH` payload — so a broken return type, a missing +// failure variant, or an unresolved `@byteslice/result` / `./native` re-export +// fails the typecheck. const CONSUMER = ` -import type { - AuthError, - AuthErrorCode, - TokenResult, - DeviceSessionStrategy, +import type { AuthFailure, AuthErrorCode, TokenResult } from "@cipherstash/auth"; +import { AutoStrategy, + DeviceSessionStrategy, + OAuthStrategy, } from "@cipherstash/auth"; -import { OAuthStrategy } from "@cipherstash/auth"; const _alias: typeof DeviceSessionStrategy = OAuthStrategy; -function codeOf(err: AuthError): AuthErrorCode { - return err.code; +function handle(failure: AuthFailure): AuthErrorCode { + if (failure.type === "WORKSPACE_MISMATCH") { + const _e: string = failure.expected; + const _a: string = failure.actual; + void _e; + void _a; + } + return failure.type; } -declare const tr: TokenResult; -const _token: string = tr.token; -declare const auto: AutoStrategy; +async function run() { + const detected = AutoStrategy.detect(); + if (detected.failure) { + void handle(detected.failure); + return; + } + const result = await detected.data.getToken(); + if (result.failure) { + void handle(result.failure); + } else { + const token: TokenResult = result.data; + const _t: string = token.token; + void _t; + } +} -void codeOf; +void run; void _alias; -void _token; -void auto; `; // Node16 resolution makes tsc honour the package's `exports` map (the "node" diff --git a/languages/typescript/packages/auth/__tests__/device-code-flow.test.ts b/languages/typescript/packages/auth/__tests__/device-code-flow.test.ts index c9371c13f..9dca48e65 100644 --- a/languages/typescript/packages/auth/__tests__/device-code-flow.test.ts +++ b/languages/typescript/packages/auth/__tests__/device-code-flow.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, beforeEach, afterEach } from "vitest"; -import type { DeviceCodeResult, AuthResult, AuthError } from "../index"; +import type { DeviceCodeResult } from "../index"; import { MockCtsServer } from "./helpers/mock-cts-server"; const { beginDeviceCodeFlow } = @@ -20,7 +20,11 @@ async function startServer(): Promise { // The production `beginDeviceCodeFlow` resolves its auth host from CS_CTS_HOST // (set in `beforeEach` below), so no test-only base-URL override is needed. async function beginFlow(): Promise { - return beginDeviceCodeFlow("ap-southeast-2.aws", "test-client"); + const r = await beginDeviceCodeFlow("ap-southeast-2.aws", "test-client"); + if (r.failure) { + expect.unreachable(`beginDeviceCodeFlow failed: ${r.failure.type}`); + } + return r.data; } // --------------------------------------------------------------------------- @@ -30,15 +34,10 @@ async function beginFlow(): Promise { describe("device code flow (TypeScript / vitest)", () => { // ---------- Error enrichment (no server needed) ---------- - it("attaches .code for INVALID_REGION", async () => { - try { - await beginDeviceCodeFlow("not-a-region", "test-client"); - expect.unreachable("should have thrown"); - } catch (err) { - const authErr = err as AuthError; - expect(authErr).toBeInstanceOf(Error); - expect(authErr.code).toBe("INVALID_REGION"); - } + it("attaches .type for INVALID_REGION", async () => { + const r = await beginDeviceCodeFlow("not-a-region", "test-client"); + expect(r.failure?.error).toBeInstanceOf(Error); + expect(r.failure?.type).toBe("INVALID_REGION"); }); // ---------- Tests that need the mock server ---------- @@ -75,56 +74,50 @@ describe("device code flow (TypeScript / vitest)", () => { it("pollForToken resolves with auth metadata on success", async () => { server.mockTokenEndpoint(); const result = await beginFlow(); - const auth: AuthResult = await result.pollForToken(); + const pr = await result.pollForToken(); + if (pr.failure) { + expect.unreachable(`pollForToken failed: ${pr.failure.type}`); + } + const auth = pr.data; expect(auth.expiresAt).toBeGreaterThan(0); expect(auth.expiresIn).toBeGreaterThanOrEqual(3598); expect(auth.expiresIn).toBeLessThanOrEqual(3600); }); - it("pollForToken rejects on second call (consumed handle)", async () => { + it("pollForToken fails on second call (consumed handle)", async () => { server.mockTokenEndpoint(); const result = await beginFlow(); // First call succeeds — consumes the handle - await result.pollForToken(); - - // Second call should fail - try { - await result.pollForToken(); - expect.unreachable("should have thrown"); - } catch (err) { - expect(err).toBeInstanceOf(Error); - expect((err as Error).message).toMatch(/already been consumed/); + const first = await result.pollForToken(); + if (first.failure) { + expect.unreachable(`first pollForToken failed: ${first.failure.type}`); } + + // Second call should surface a failure + const second = await result.pollForToken(); + expect(second.failure?.error).toBeInstanceOf(Error); + expect(second.failure?.type).toBe("ALREADY_CONSUMED"); + expect(second.failure?.error.message).toMatch(/already consumed/i); }); - it("pollForToken rejects with enriched ACCESS_DENIED", async () => { + it("pollForToken fails with enriched ACCESS_DENIED", async () => { server.mockTokenEndpointError("access_denied"); const result = await beginFlow(); - try { - await result.pollForToken(); - expect.unreachable("should have thrown"); - } catch (err) { - const authErr = err as AuthError; - expect(authErr).toBeInstanceOf(Error); - expect(authErr.code).toBe("ACCESS_DENIED"); - } + const pr = await result.pollForToken(); + expect(pr.failure?.error).toBeInstanceOf(Error); + expect(pr.failure?.type).toBe("ACCESS_DENIED"); }); - it("pollForToken rejects with enriched EXPIRED_TOKEN", async () => { + it("pollForToken fails with enriched EXPIRED_TOKEN", async () => { server.mockTokenEndpointError("expired_token"); const result = await beginFlow(); - try { - await result.pollForToken(); - expect.unreachable("should have thrown"); - } catch (err) { - const authErr = err as AuthError; - expect(authErr).toBeInstanceOf(Error); - expect(authErr.code).toBe("EXPIRED_TOKEN"); - } + const pr = await result.pollForToken(); + expect(pr.failure?.error).toBeInstanceOf(Error); + expect(pr.failure?.type).toBe("EXPIRED_TOKEN"); }); }); }); diff --git a/languages/typescript/packages/auth/__tests__/oidc-cookie-roundtrip.test.ts b/languages/typescript/packages/auth/__tests__/oidc-cookie-roundtrip.test.ts index b37af151a..b0d3cd2fb 100644 --- a/languages/typescript/packages/auth/__tests__/oidc-cookie-roundtrip.test.ts +++ b/languages/typescript/packages/auth/__tests__/oidc-cookie-roundtrip.test.ts @@ -38,21 +38,35 @@ function requestWith(cookie?: string): Request { }); } +/** Unwrap a `createWithStore` Result, failing the test if it returned a failure. */ +function mustCreateWithStore( + ...args: Parameters +): InstanceType { + const cr = OidcFederationStrategy.createWithStore(...args); + if (cr.failure) { + expect.unreachable(`createWithStore failed: ${cr.failure.type}`); + } + return cr.data; +} + describe("OidcFederationStrategy + cookieStore round-trip", () => { it("writes the federated CTS token to a Set-Cookie header", async () => { server.mockAuthorizeEndpoint(); const responseHeaders = new Headers(); const store = cookieStore({ request: requestWith(), responseHeaders }); - const strategy = OidcFederationStrategy.createWithStore( + const strategy = mustCreateWithStore( WORKSPACE_CRN, () => Promise.resolve("header.payload.signature"), store.load, store.save, ); - const result = await strategy.getToken(); + const r = await strategy.getToken(); + if (r.failure) { + expect.unreachable(`getToken failed: ${r.failure.type}`); + } - expect(result.workspaceId).toBe(WORKSPACE_ID); + expect(r.data.workspaceId).toBe(WORKSPACE_ID); const setCookie = responseHeaders.get("set-cookie"); expect(setCookie).toBeTruthy(); expect(setCookie).toMatch(/^cs_token=/); @@ -66,7 +80,7 @@ describe("OidcFederationStrategy + cookieStore round-trip", () => { request: requestWith(), responseHeaders: firstHeaders, }); - const first = OidcFederationStrategy.createWithStore( + const first = mustCreateWithStore( WORKSPACE_CRN, () => Promise.resolve("header.payload.signature"), firstStore.load, @@ -83,15 +97,18 @@ describe("OidcFederationStrategy + cookieStore round-trip", () => { request: requestWith(cookie), responseHeaders: new Headers(), }); - const second = OidcFederationStrategy.createWithStore( + const second = mustCreateWithStore( WORKSPACE_CRN, () => Promise.reject(new Error("getJwt must not be called")), secondStore.load, secondStore.save, ); - const result = await second.getToken(); - expect(result.workspaceId).toBe(WORKSPACE_ID); + const r = await second.getToken(); + if (r.failure) { + expect.unreachable(`getToken failed: ${r.failure.type}`); + } + expect(r.data.workspaceId).toBe(WORKSPACE_ID); }); it("re-federates when the cookie holds an expired token", async () => { @@ -102,7 +119,7 @@ describe("OidcFederationStrategy + cookieStore round-trip", () => { request: requestWith(), responseHeaders: firstHeaders, }); - const first = OidcFederationStrategy.createWithStore( + const first = mustCreateWithStore( WORKSPACE_CRN, () => Promise.resolve("header.payload.signature"), firstStore.load, @@ -120,7 +137,7 @@ describe("OidcFederationStrategy + cookieStore round-trip", () => { request: requestWith(cookie), responseHeaders: new Headers(), }); - const second = OidcFederationStrategy.createWithStore( + const second = mustCreateWithStore( WORKSPACE_CRN, () => { getJwtCalls += 1; @@ -130,8 +147,11 @@ describe("OidcFederationStrategy + cookieStore round-trip", () => { secondStore.save, ); - const result = await second.getToken(); - expect(result.workspaceId).toBe(WORKSPACE_ID); + const r = await second.getToken(); + if (r.failure) { + expect.unreachable(`getToken failed: ${r.failure.type}`); + } + expect(r.data.workspaceId).toBe(WORKSPACE_ID); expect(getJwtCalls).toBe(1); }); }); diff --git a/languages/typescript/packages/auth/__tests__/oidc-federation-strategy.test.ts b/languages/typescript/packages/auth/__tests__/oidc-federation-strategy.test.ts index 18b61b0a4..8649155f3 100644 --- a/languages/typescript/packages/auth/__tests__/oidc-federation-strategy.test.ts +++ b/languages/typescript/packages/auth/__tests__/oidc-federation-strategy.test.ts @@ -1,5 +1,4 @@ import { describe, it, expect, beforeEach, afterEach } from "vitest"; -import type { AuthError } from "../index"; import { MockCtsServer } from "./helpers/mock-cts-server"; const { OidcFederationStrategy } = @@ -53,13 +52,39 @@ function memStore() { }; } +/** Unwrap a `create` Result, failing the test if it returned a failure. */ +function mustCreate( + ...args: Parameters +): InstanceType { + const cr = OidcFederationStrategy.create(...args); + if (cr.failure) { + expect.unreachable(`create failed: ${cr.failure.type}`); + } + return cr.data; +} + +/** Unwrap a `createWithStore` Result, failing the test if it returned a failure. */ +function mustCreateWithStore( + ...args: Parameters +): InstanceType { + const cr = OidcFederationStrategy.createWithStore(...args); + if (cr.failure) { + expect.unreachable(`createWithStore failed: ${cr.failure.type}`); + } + return cr.data; +} + describe("OidcFederationStrategy (TypeScript / vitest)", () => { it("federates a third-party JWT into a CTS service token", async () => { server.mockAuthorizeEndpoint(); const jwt = countingJwt(); - const strategy = OidcFederationStrategy.create(WORKSPACE_CRN, jwt.getJwt); + const strategy = mustCreate(WORKSPACE_CRN, jwt.getJwt); - const result = await strategy.getToken(); + const r = await strategy.getToken(); + if (r.failure) { + expect.unreachable(`getToken failed: ${r.failure.type}`); + } + const result = r.data; expect(result.token).not.toBe(""); expect(result.workspaceId).toBe(WORKSPACE_ID); @@ -72,7 +97,7 @@ describe("OidcFederationStrategy (TypeScript / vitest)", () => { server.mockAuthorizeEndpoint(0); server.mockAuthorizeEndpoint(0); const jwt = countingJwt(); - const strategy = OidcFederationStrategy.create(WORKSPACE_CRN, jwt.getJwt); + const strategy = mustCreate(WORKSPACE_CRN, jwt.getJwt); await strategy.getToken(); await strategy.getToken(); @@ -80,18 +105,14 @@ describe("OidcFederationStrategy (TypeScript / vitest)", () => { expect(jwt.calls()).toBe(2); }); - it("surfaces a getJwt rejection as an error with .code", async () => { + it("surfaces a getJwt rejection as a failure with .type", async () => { server.mockAuthorizeEndpoint(); - const strategy = OidcFederationStrategy.create(WORKSPACE_CRN, () => + const strategy = mustCreate(WORKSPACE_CRN, () => Promise.reject(new Error("provider unavailable")), ); - try { - await strategy.getToken(); - expect.unreachable("getToken should reject when getJwt rejects"); - } catch (err) { - expect((err as AuthError).code).toBe("SERVER_ERROR"); - } + const r = await strategy.getToken(); + expect(r.failure?.type).toBe("SERVER_ERROR"); }); it("honours an explicit baseUrl override over CS_CTS_HOST", async () => { @@ -106,15 +127,18 @@ describe("OidcFederationStrategy (TypeScript / vitest)", () => { const override = await MockCtsServer.start(); try { override.mockAuthorizeEndpoint(); - const strategy = OidcFederationStrategy.create( + const strategy = mustCreate( WORKSPACE_CRN, () => Promise.resolve("header.payload.signature"), override.baseUrl, ); - const result = await strategy.getToken(); + const r = await strategy.getToken(); + if (r.failure) { + expect.unreachable(`getToken failed: ${r.failure.type}`); + } - expect(result.workspaceId).toBe(WORKSPACE_ID); + expect(r.data.workspaceId).toBe(WORKSPACE_ID); } finally { await override.close(); } @@ -132,7 +156,7 @@ describe("OidcFederationStrategy (TypeScript / vitest)", () => { try { override.mockAuthorizeEndpoint(); const store = memStore(); - const strategy = OidcFederationStrategy.createWithStore( + const strategy = mustCreateWithStore( WORKSPACE_CRN, () => Promise.resolve("header.payload.signature"), store.load, @@ -140,9 +164,12 @@ describe("OidcFederationStrategy (TypeScript / vitest)", () => { override.baseUrl, ); - const result = await strategy.getToken(); + const r = await strategy.getToken(); + if (r.failure) { + expect.unreachable(`getToken failed: ${r.failure.type}`); + } - expect(result.workspaceId).toBe(WORKSPACE_ID); + expect(r.data.workspaceId).toBe(WORKSPACE_ID); expect(store.saved()).not.toBeNull(); } finally { await override.close(); @@ -152,68 +179,57 @@ describe("OidcFederationStrategy (TypeScript / vitest)", () => { it("rejects a malformed baseUrl with INVALID_URL", () => { // The napi twin of the wasm `..._rejects_invalid_base_url` test: a // non-empty, unparseable override must surface through the factory as a - // coded INVALID_URL error (via `maybe_base_url(...)? → to_napi_error`), not + // coded INVALID_URL failure (via `maybe_base_url(...)? → to_napi_error`), not // a silent fallback or an un-coded throw. - try { - OidcFederationStrategy.create( - WORKSPACE_CRN, - () => Promise.resolve("h.p.s"), - "not a url", - ); - expect.unreachable("create should throw on a malformed baseUrl"); - } catch (err) { - expect((err as AuthError).code).toBe("INVALID_URL"); - } + const cr = OidcFederationStrategy.create( + WORKSPACE_CRN, + () => Promise.resolve("h.p.s"), + "not a url", + ); + expect(cr.failure?.type).toBe("INVALID_URL"); }); it("treats an empty baseUrl as absent (falls back to CS_CTS_HOST)", async () => { // An empty-string override must be a no-op, not an INVALID_URL — so // federation still resolves against CS_CTS_HOST's mock. server.mockAuthorizeEndpoint(); - const strategy = OidcFederationStrategy.create( + const strategy = mustCreate( WORKSPACE_CRN, () => Promise.resolve("header.payload.signature"), "", ); - const result = await strategy.getToken(); + const r = await strategy.getToken(); + if (r.failure) { + expect.unreachable(`getToken failed: ${r.failure.type}`); + } - expect(result.workspaceId).toBe(WORKSPACE_ID); + expect(r.data.workspaceId).toBe(WORKSPACE_ID); }); - it("rejects an invalid workspace CRN with .code", () => { - try { - OidcFederationStrategy.create("not-a-crn", () => - Promise.resolve("h.p.s"), - ); - expect.unreachable("create should throw on a malformed workspace CRN"); - } catch (err) { - expect((err as AuthError).code).toBe("INVALID_CRN"); - } + it("rejects an invalid workspace CRN with .type", () => { + const cr = OidcFederationStrategy.create("not-a-crn", () => + Promise.resolve("h.p.s"), + ); + expect(cr.failure?.type).toBe("INVALID_CRN"); }); - it("rejects a CRN whose workspace segment is malformed with .code", () => { + it("rejects a CRN whose workspace segment is malformed with .type", () => { // "not-a-crn" above fails at the `crn:` prefix; this is the distinct path // where the prefix/region parse but the workspace segment fails validation // — what the old INVALID_WORKSPACE_ID case covered before the CRN switch. - try { - OidcFederationStrategy.create( - "crn:ap-southeast-2.aws:not-a-valid-workspace", - () => Promise.resolve("h.p.s"), - ); - expect.unreachable( - "create should throw on a malformed workspace segment", - ); - } catch (err) { - expect((err as AuthError).code).toBe("INVALID_CRN"); - } + const cr = OidcFederationStrategy.create( + "crn:ap-southeast-2.aws:not-a-valid-workspace", + () => Promise.resolve("h.p.s"), + ); + expect(cr.failure?.type).toBe("INVALID_CRN"); }); it("persists the federated token to the store", async () => { server.mockAuthorizeEndpoint(); const store = memStore(); const jwt = countingJwt(); - const strategy = OidcFederationStrategy.createWithStore( + const strategy = mustCreateWithStore( WORKSPACE_CRN, jwt.getJwt, store.load, @@ -230,7 +246,7 @@ describe("OidcFederationStrategy (TypeScript / vitest)", () => { // First strategy federates and populates the shared store. server.mockAuthorizeEndpoint(); const store = memStore(); - const first = OidcFederationStrategy.createWithStore( + const first = mustCreateWithStore( WORKSPACE_CRN, () => Promise.resolve("h.p.s"), store.load, @@ -243,15 +259,18 @@ describe("OidcFederationStrategy (TypeScript / vitest)", () => { // would throw — proving the token came from the store, not the network. server.clearMocks(); server.mockAuthorizeEndpointError(); - const second = OidcFederationStrategy.createWithStore( + const second = mustCreateWithStore( WORKSPACE_CRN, () => Promise.reject(new Error("getJwt must not be called")), store.load, store.save, ); - const result = await second.getToken(); - expect(result.workspaceId).toBe(WORKSPACE_ID); + const r = await second.getToken(); + if (r.failure) { + expect.unreachable(`getToken failed: ${r.failure.type}`); + } + expect(r.data.workspaceId).toBe(WORKSPACE_ID); }); it("re-federates when the stored token JSON is malformed", async () => { @@ -260,7 +279,7 @@ describe("OidcFederationStrategy (TypeScript / vitest)", () => { // runs fresh. A version that `unwrap()`ed the parse would fail this. server.mockAuthorizeEndpoint(); const jwt = countingJwt(); - const strategy = OidcFederationStrategy.createWithStore( + const strategy = mustCreateWithStore( WORKSPACE_CRN, jwt.getJwt, () => Promise.resolve("}{ not json"), @@ -273,39 +292,30 @@ describe("OidcFederationStrategy (TypeScript / vitest)", () => { expect(jwt.calls()).toBe(1); }); - it("surfaces a non-string getJwt result as an error with .code", async () => { + it("surfaces a non-string getJwt result as a failure with .type", async () => { // Mirrors the wasm `js_oidc_provider_errors_on_non_string_result` test: // a `Promise` fails napi's `Promise` coercion and must - // surface as a clean SERVER_ERROR rejection, not a panic or hung promise. + // surface as a clean SERVER_ERROR failure, not a panic or hung promise. server.mockAuthorizeEndpoint(); - const strategy = OidcFederationStrategy.create(WORKSPACE_CRN, () => + const strategy = mustCreate(WORKSPACE_CRN, () => Promise.resolve(42 as unknown as string), ); - try { - await strategy.getToken(); - expect.unreachable( - "getToken should reject on a non-string getJwt result", - ); - } catch (err) { - expect((err as AuthError).code).toBe("SERVER_ERROR"); - } + const r = await strategy.getToken(); + expect(r.failure?.type).toBe("SERVER_ERROR"); }); - it("surfaces a federation server error with .code", async () => { + it("surfaces a federation server error with .type", async () => { // Negative twin of the happy path: a real federation request reaching - // /api/authorise and getting a 500 must reject with an enriched `.code`, + // /api/authorise and getting a 500 must surface a failure with a `.type`, // not resolve or throw an un-coded error. server.mockAuthorizeEndpointError(); - const strategy = OidcFederationStrategy.create(WORKSPACE_CRN, () => + const strategy = mustCreate(WORKSPACE_CRN, () => Promise.resolve("header.payload.signature"), ); - try { - await strategy.getToken(); - expect.unreachable("getToken should reject when /api/authorise 500s"); - } catch (err) { - expect(err as AuthError).toHaveProperty("code"); - } + const r = await strategy.getToken(); + expect(r.failure).toBeTruthy(); + expect(r.failure?.type).toBeTruthy(); }); }); diff --git a/languages/typescript/packages/auth/__tests__/provision-device-client.test.ts b/languages/typescript/packages/auth/__tests__/provision-device-client.test.ts index 0552f1e7c..63dcb0b50 100644 --- a/languages/typescript/packages/auth/__tests__/provision-device-client.test.ts +++ b/languages/typescript/packages/auth/__tests__/provision-device-client.test.ts @@ -2,7 +2,6 @@ import { describe, it, expect, beforeEach, afterEach } from "vitest"; import { existsSync, mkdtempSync, readFileSync, writeFileSync } from "fs"; import { join } from "path"; import { tmpdir } from "os"; -import type { AuthError } from "../index"; import { MockCtsServer } from "./helpers/mock-cts-server"; import { saveTestToken } from "./helpers/test-fixtures"; @@ -54,7 +53,10 @@ describe("provision device client (TypeScript / vitest)", () => { server.mockCreateClientEndpoint(); saveTestToken(profileDir, server.baseUrl); - await bindClientDevice(); + const r = await bindClientDevice(); + if (r.failure) { + expect.unreachable(`bindClientDevice failed: ${r.failure.type}`); + } const raw = readFileSync(join(workspaceDir(), "secretkey.json"), "utf-8"); const secretKey = JSON.parse(raw); @@ -73,7 +75,10 @@ describe("provision device client (TypeScript / vitest)", () => { }); writeFileSync(join(workspaceDir(), "secretkey.json"), existing); - await bindClientDevice(); + const r = await bindClientDevice(); + if (r.failure) { + expect.unreachable(`bindClientDevice failed: ${r.failure.type}`); + } const raw = readFileSync(join(workspaceDir(), "secretkey.json"), "utf-8"); const secretKey = JSON.parse(raw); @@ -84,32 +89,27 @@ describe("provision device client (TypeScript / vitest)", () => { server.mockCreateClientConflict(); saveTestToken(profileDir, server.baseUrl); - await bindClientDevice(); + const r = await bindClientDevice(); + if (r.failure) { + expect.unreachable(`bindClientDevice failed: ${r.failure.type}`); + } expect(existsSync(join(workspaceDir(), "secretkey.json"))).toBe(false); }); - it("throws on server error", async () => { + it("fails on server error", async () => { // No mock endpoint — server will return an error for unmatched route. saveTestToken(profileDir, server.baseUrl); - try { - await bindClientDevice(); - expect.unreachable("should have thrown"); - } catch (err) { - expect(err).toBeInstanceOf(Error); - } + const r = await bindClientDevice(); + expect(r.failure).toBeTruthy(); + expect(r.failure?.error).toBeInstanceOf(Error); }); - it("throws STORE_ERROR when auth token is missing", async () => { + it("fails with STORE_ERROR when auth token is missing", async () => { // No token saved — should fail trying to load auth.json - try { - await bindClientDevice(); - expect.unreachable("should have thrown"); - } catch (err) { - const authErr = err as AuthError; - expect(authErr).toBeInstanceOf(Error); - expect(authErr.code).toBe("STORE_ERROR"); - } + const r = await bindClientDevice(); + expect(r.failure?.error).toBeInstanceOf(Error); + expect(r.failure?.type).toBe("STORE_ERROR"); }); }); diff --git a/languages/typescript/packages/auth/examples/auto-strategy.ts b/languages/typescript/packages/auth/examples/auto-strategy.ts index 0661fc4cf..7a320c90f 100644 --- a/languages/typescript/packages/auth/examples/auto-strategy.ts +++ b/languages/typescript/packages/auth/examples/auto-strategy.ts @@ -6,7 +6,7 @@ // `CS_WORKSPACE_CRN`), access key auth is used. // 2. OAuth — if `~/.cipherstash/auth.json` exists (written by // `stash login`), OAuth token auth is used. -// 3. If neither is available, a `NOT_AUTHENTICATED` error is thrown. +// 3. If neither is available, a `NOT_AUTHENTICATED` failure is returned. // // Prerequisites: // 1. Build the native module: npm run build @@ -18,7 +18,13 @@ // CS_CLIENT_ACCESS_KEY= CS_WORKSPACE_CRN= npx tsx examples/auto-strategy.ts import { AutoStrategy } from "../index"; -import type { AuthError } from "../index"; +import type { AuthFailure } from "../index"; + +function reportAndExit(failure: AuthFailure): never { + console.error(`[${failure.type}] ${failure.error.message}`); + if (failure.help) console.error(failure.help); + process.exit(1); +} async function main() { // Detect credentials automatically from env vars / profile store. @@ -26,18 +32,24 @@ async function main() { // // AutoStrategy.detect({ accessKey: "CSAK...", workspaceCrn: "crn:..." }) // - const strategy = AutoStrategy.detect(); + const detected = AutoStrategy.detect(); + if (detected.failure) reportAndExit(detected.failure); // Retrieve a token — refresh happens automatically when needed. - const result = await strategy.getToken(); - console.log(`Subject: ${result.subject}`); - console.log(`Workspace: ${result.workspaceId}`); - console.log(`Issuer: ${result.issuer}`); - console.log(`Services: ${JSON.stringify(result.services)}`); - console.log(`Token: ${result.token.slice(0, 20)}...`); + const result = await detected.data.getToken(); + if (result.failure) reportAndExit(result.failure); + + const token = result.data; + console.log(`Subject: ${token.subject}`); + console.log(`Workspace: ${token.workspaceId}`); + console.log(`Issuer: ${token.issuer}`); + console.log(`Services: ${JSON.stringify(token.services)}`); + console.log(`Token: ${token.token.slice(0, 20)}...`); } -main().catch((err: AuthError) => { - console.error(err.code ? `[${err.code}] ${err.message}` : err.message); +// Domain errors are returned as `failure`, not thrown — only a genuine +// internal fault reaches here. +main().catch((err: unknown) => { + console.error(err instanceof Error ? err.message : String(err)); process.exit(1); }); diff --git a/languages/typescript/packages/auth/examples/device-code.ts b/languages/typescript/packages/auth/examples/device-code.ts index 2610900e9..46601bd9c 100644 --- a/languages/typescript/packages/auth/examples/device-code.ts +++ b/languages/typescript/packages/auth/examples/device-code.ts @@ -10,10 +10,18 @@ // npx tsx examples/device-code.ts import { beginDeviceCodeFlow } from "../index"; +import type { AuthFailure } from "../index"; + +function reportAndExit(failure: AuthFailure): never { + console.error(`[${failure.type}] ${failure.error.message}`); + process.exit(1); +} async function main() { // Step 1: Begin the device code flow - const pending = await beginDeviceCodeFlow("ap-southeast-2.aws", "cli"); + const begun = await beginDeviceCodeFlow("ap-southeast-2.aws", "cli"); + if (begun.failure) reportAndExit(begun.failure); + const pending = begun.data; // Step 2: Show the user their code and verification URL console.log(`Your code is: ${pending.userCode}`); @@ -23,7 +31,8 @@ async function main() { // Optionally open the browser automatically const opened = pending.openInBrowser(); - if (!opened) { + if (opened.failure) reportAndExit(opened.failure); + if (!opened.data) { console.log( "Could not open browser — please visit the URL above manually.", ); @@ -32,7 +41,9 @@ async function main() { // Step 3: Poll until the user authorizes (or the code expires). // The token is saved to ~/.cipherstash/auth.json automatically. console.log("Waiting for authorization..."); - const auth = await pending.pollForToken(); + const result = await pending.pollForToken(); + if (result.failure) reportAndExit(result.failure); + const auth = result.data; console.log(); console.log("Authenticated! Token saved to ~/.cipherstash/auth.json"); @@ -40,7 +51,9 @@ async function main() { console.log(` Expires in: ${auth.expiresIn}s`); } -main().catch((err: Error & { code?: string }) => { - console.error(err.code ? `[${err.code}] ${err.message}` : err.message); +// Domain errors are returned as `failure`, not thrown — only a genuine +// internal fault reaches here. +main().catch((err: unknown) => { + console.error(err instanceof Error ? err.message : String(err)); process.exit(1); }); diff --git a/languages/typescript/packages/stack-auth-wasm/README.md b/languages/typescript/packages/stack-auth-wasm/README.md index 3d66566c4..8ea800d99 100644 --- a/languages/typescript/packages/stack-auth-wasm/README.md +++ b/languages/typescript/packages/stack-auth-wasm/README.md @@ -2,7 +2,9 @@ WebAssembly bindings for [`stack-auth`](../). Consumed by the unified [`@cipherstash/auth`](../node/) npm package — this crate is the upstream source, not a published artifact. -Scoped to `AccessKeyStrategy` (machine-to-machine auth). `AccessKeyStrategy.create(workspaceCrn, accessKey)` returns a strategy; `getToken(): Promise` resolves to `{ token, subject, workspaceId, issuer, services }`. Region is derived from the CRN, and every issued token's `workspace` JWT claim is verified against the CRN — a mismatch surfaces as `code === "WORKSPACE_MISMATCH"`. Errors thrown extend `Error` with a machine-readable `.code` property (`INVALID_CRN`, `INVALID_ACCESS_KEY`, `WORKSPACE_MISMATCH`, `ACCESS_DENIED`, `EXPIRED_TOKEN`, etc.) sourced from `AuthError::error_code()` in the parent `stack-auth` crate. +Scoped to `AccessKeyStrategy` (machine-to-machine auth). Region is derived from the CRN, and every issued token's `workspace` JWT claim is verified against the CRN — a mismatch surfaces as `WORKSPACE_MISMATCH`. + +The wrapped [`@cipherstash/auth/wasm-inline`](../node/wasm-inline.d.ts) entry returns a [`@byteslice/result`](https://www.npmjs.com/package/@byteslice/result) `Result`: `AccessKeyStrategy.create(...)` and `getToken()` resolve to `{ data }` on success or `{ failure }` (a discriminated `AuthFailure` tagged by `type`, carrying the live `error`, optional `help`/`url`, and per-variant payload such as `WORKSPACE_MISMATCH`'s `expected`/`actual`). The failure `type`s come from `AuthError` in the parent `stack-auth` crate. The lower-level raw `/wasm` bindings still *throw* a JS `Error` whose `.code` carries the same discriminant, with the structured failure attached as the `__authFailure` property. OAuth strategies, device-code flow, and profile-store loading are deliberately out of scope — they need Node-only APIs (filesystem device identity, browser launching) that can't be ported to wasm32. From 3bf4c808997371ff1158c6525049b2aefda55339 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Sat, 4 Jul 2026 21:51:05 +1000 Subject: [PATCH 334/686] fix(stack-auth): resolve doc + CRAP CI gates on error.rs - De-link the two [`AuthError::kind`] intra-doc links (module + trait docs); kind() is private, so the links tripped rustdoc::private_intra_doc_links. - Add unit tests for the AuthError Serialize impl (CC=10, was 0% coverage -> CRAP 110). Covers the rich payload+help variant (WorkspaceMismatch) and an empty-payload variant (MissingWorkspaceCrn); CRAP now well under the 30 gate. --- packages/stack-auth/src/error.rs | 43 ++++++++++++++++++++++++++++++-- 1 file changed, 41 insertions(+), 2 deletions(-) diff --git a/packages/stack-auth/src/error.rs b/packages/stack-auth/src/error.rs index a09322c38..72af03904 100644 --- a/packages/stack-auth/src/error.rs +++ b/packages/stack-auth/src/error.rs @@ -7,7 +7,7 @@ //! //! The enum is a thin dispatcher: `Display`/`Diagnostic` delegate to the inner //! struct via `transparent`, and [`AuthError::error_code`] / the `Serialize` -//! impl delegate through [`AuthError::kind`]. Ergonomic `From` impls +//! impl delegate through `AuthError::kind`. Ergonomic `From` impls //! keep `?` working at call sites that lift a foreign error directly. use std::convert::Infallible; @@ -17,7 +17,7 @@ use crate::access_key; /// Behaviour shared by every concrete error wrapped in an [`AuthError`] variant. /// /// Implemented by the per-error structs so each owns its FFI code and any -/// structured payload; [`AuthError`] dispatches to it via [`AuthError::kind`]. +/// structured payload; [`AuthError`] dispatches to it via `AuthError::kind`. pub trait AuthErrorKind: std::error::Error + miette::Diagnostic { /// Stable machine-readable identifier surfaced across FFI boundaries /// (e.g. JS `Error.code`). Named `error_code` to avoid colliding with @@ -439,3 +439,42 @@ impl From for AuthError { match never {} } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn serialize_emits_type_message_help_and_payload() { + let expected: cts_common::WorkspaceId = "ZVATKW3VHMFG27DY".parse().unwrap(); + let actual: cts_common::WorkspaceId = "AAAAAAAAAAAAAAAA".parse().unwrap(); + let (expected_s, actual_s) = (expected.to_string(), actual.to_string()); + + let err = AuthError::WorkspaceMismatch(WorkspaceMismatch { + expected_workspace: expected, + token_workspace: actual, + }); + let json = serde_json::to_value(&err).unwrap(); + + // Generic fields the enum emits for every variant. + assert_eq!(json["type"], "WORKSPACE_MISMATCH"); + assert_eq!(json["message"], err.to_string()); + // `help` comes from the miette diagnostic (present on this variant). + assert!(json.get("help").is_some(), "help should be serialized"); + // Structured payload from `AuthErrorKind::payload`. + assert_eq!(json["expected"], expected_s); + assert_eq!(json["actual"], actual_s); + } + + #[test] + fn serialize_variant_without_payload_emits_only_generic_fields() { + let err = AuthError::MissingWorkspaceCrn(MissingWorkspaceCrn); + let json = serde_json::to_value(&err).unwrap(); + + assert_eq!(json["type"], "MISSING_WORKSPACE_CRN"); + assert_eq!(json["message"], err.to_string()); + // No per-variant payload keys — the payload loop contributes nothing. + assert!(json.get("expected").is_none()); + assert!(json.get("actual").is_none()); + } +} From 0ec17ee2bb30744ca79038f14f38e2d6e2b92c1a Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Sat, 4 Jul 2026 22:28:20 +1000 Subject: [PATCH 335/686] fix(stack-auth-node): wrap napi static factories via facade classes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit napi-rs defines class statics as non-writable/non-configurable, so the in-place patches (native.AutoStrategy.detect = wrapSync(...) etc.) silently no-oped in sloppy mode: the three factories still threw raw __CS_FAIL__ sentinel errors and returned bare native instances instead of the { data } / { failure } Result promised by index.d.ts. Replace the patches with facade classes whose statics run the native factory through wrapSync — the mechanism OidcFederationStrategy already used. The factories must be invoked with the native class as receiver (napi needs it to construct the returned instance), hence the closure form. Also make wrapAsync convert synchronous napi argument-coercion throws into promise rejections, so a Promise-returning signature never throws. Add runtime tests exercising both arms of all three factories through index.js — the gap that let this ship: the only prior coverage was the compile-only consumer-typecheck test. --- .../auth/__tests__/factory-result.test.ts | 110 ++++++++++++++++++ languages/typescript/packages/auth/index.js | 57 ++++++--- 2 files changed, 152 insertions(+), 15 deletions(-) create mode 100644 languages/typescript/packages/auth/__tests__/factory-result.test.ts diff --git a/languages/typescript/packages/auth/__tests__/factory-result.test.ts b/languages/typescript/packages/auth/__tests__/factory-result.test.ts new file mode 100644 index 000000000..357081718 --- /dev/null +++ b/languages/typescript/packages/auth/__tests__/factory-result.test.ts @@ -0,0 +1,110 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { mkdtempSync } from "fs"; +import { join } from "path"; +import { tmpdir } from "os"; + +// Runtime coverage for the sync strategy factories through index.js. napi +// defines class statics as non-writable, so a Result-wrapping bug there is +// invisible to both the Rust tests (which test the native layer directly) and +// the compile-only consumer-typecheck test — the factories must be exercised +// at runtime, on both arms, via the public entry point. +const { + AutoStrategy, + AccessKeyStrategy, + DeviceSessionStrategy, + OAuthStrategy, +} = require("../index.js") as typeof import("../index"); + +const VALID_CRN = "crn:ap-southeast-2.aws:ZVATKW3VHMFG27DY"; +// Well-formed key (`CSAK.`) — factories only parse the shape; +// no network call happens until getToken(). +const VALID_KEY = "CSAKtestKeyId.testKeySecret"; + +const SAVED_ENV_KEYS = [ + "CS_CLIENT_ACCESS_KEY", + "CS_WORKSPACE_CRN", + "CS_CONFIG_PATH", +] as const; +let savedEnv: Partial>; + +beforeEach(() => { + savedEnv = {}; + for (const key of SAVED_ENV_KEYS) { + savedEnv[key] = process.env[key]; + delete process.env[key]; + } + // Point the profile store at an empty temp dir so ambient ~/.cipherstash + // state can't leak into detection. + process.env.CS_CONFIG_PATH = mkdtempSync(join(tmpdir(), "cs-auth-test-")); +}); + +afterEach(() => { + for (const key of SAVED_ENV_KEYS) { + if (savedEnv[key] === undefined) { + delete process.env[key]; + } else { + process.env[key] = savedEnv[key]; + } + } +}); + +describe("AccessKeyStrategy.create", () => { + it("returns a failure for a malformed CRN", () => { + const r = AccessKeyStrategy.create("not-a-crn", VALID_KEY); + expect(r.failure?.type).toBe("INVALID_CRN"); + expect(r.failure?.error).toBeInstanceOf(Error); + // The FFI sentinel must be stripped from the surfaced message. + expect(r.failure?.error.message).not.toContain("__CS_FAIL__"); + }); + + it("returns a failure for a malformed access key", () => { + const r = AccessKeyStrategy.create(VALID_CRN, "not-a-key"); + expect(r.failure?.type).toBe("INVALID_ACCESS_KEY"); + }); + + it("returns { data } wrapping a usable strategy on success", () => { + const r = AccessKeyStrategy.create(VALID_CRN, VALID_KEY); + if (r.failure) { + expect.unreachable(`create failed: ${r.failure.type}`); + } + // A bare (unwrapped) native instance would have no `data` key — this + // assertion is what distinguishes a wrapped Result from the native value. + expect(typeof r.data.getToken).toBe("function"); + }); +}); + +describe("AutoStrategy.detect", () => { + it("returns a NOT_AUTHENTICATED failure when no credentials exist", () => { + const r = AutoStrategy.detect(); + expect(r.failure?.type).toBe("NOT_AUTHENTICATED"); + expect(r.failure?.help).toBeTruthy(); + }); + + it("returns a MISSING_WORKSPACE_CRN failure for an access key without a CRN", () => { + const r = AutoStrategy.detect({ accessKey: VALID_KEY }); + expect(r.failure?.type).toBe("MISSING_WORKSPACE_CRN"); + }); + + it("returns { data } wrapping a usable strategy for explicit options", () => { + const r = AutoStrategy.detect({ + accessKey: VALID_KEY, + workspaceCrn: VALID_CRN, + }); + if (r.failure) { + expect.unreachable(`detect failed: ${r.failure.type}`); + } + expect(typeof r.data.getToken).toBe("function"); + }); +}); + +describe("DeviceSessionStrategy.fromProfile", () => { + it("returns a STORE_ERROR failure when the profile store is empty", () => { + const r = DeviceSessionStrategy.fromProfile(); + expect(r.failure?.type).toBe("STORE_ERROR"); + expect(r.failure?.error).toBeInstanceOf(Error); + }); + + it("is what the deprecated OAuthStrategy alias points at", () => { + expect(OAuthStrategy).toBe(DeviceSessionStrategy); + }); +}); diff --git a/languages/typescript/packages/auth/index.js b/languages/typescript/packages/auth/index.js index 48a3ea2c3..0665e9172 100644 --- a/languages/typescript/packages/auth/index.js +++ b/languages/typescript/packages/auth/index.js @@ -46,7 +46,14 @@ function toFailure(err) { */ function wrapAsync(fn) { return function (...args) { - return fn.apply(this, args).then((data) => ({ data }), toFailure); + // napi argument coercion throws synchronously, before a Promise exists — + // surface it as a rejection so a `Promise`-returning signature never + // throws. (Coercion errors carry no sentinel, so they stay errors.) + try { + return fn.apply(this, args).then((data) => ({ data }), toFailure); + } catch (err) { + return Promise.reject(err); + } }; } @@ -78,21 +85,37 @@ for (const Strategy of [ Strategy.prototype.getToken = wrapAsync(Strategy.prototype.getToken); } -// Wrap strategy factory methods (sync, can throw) -const origDetect = native.AutoStrategy.detect; -native.AutoStrategy.detect = wrapSync(origDetect); +// napi defines class static methods as non-writable (and this file is sloppy +// mode), so the factories can't be Result-wrapped by patching the native +// class in place — the assignment silently no-ops. Each strategy instead gets +// a thin facade class whose static factories run through `wrapSync`. +// Instances are the native ones — their async `getToken()` is already +// wrapped via the prototype patch above. (Facade instances are never +// constructed, so `instanceof` against these classes is not part of the +// contract.) +// The native factory must be invoked as a method of its native class — +// napi needs the class as the receiver to construct the returned instance — +// hence the closure form rather than passing the unbound static to wrapSync. +class AutoStrategy { + static detect(options) { + return wrapSync(() => native.AutoStrategy.detect(options))(); + } +} -const origCreate = native.AccessKeyStrategy.create; -native.AccessKeyStrategy.create = wrapSync(origCreate); +class AccessKeyStrategy { + static create(workspaceCrn, accessKey) { + return wrapSync(() => + native.AccessKeyStrategy.create(workspaceCrn, accessKey), + )(); + } +} -const origFromProfile = native.DeviceSessionStrategy.fromProfile; -native.DeviceSessionStrategy.fromProfile = wrapSync(origFromProfile); +class DeviceSessionStrategy { + static fromProfile() { + return wrapSync(() => native.DeviceSessionStrategy.fromProfile())(); + } +} -// napi defines class static methods as non-writable, so a factory's -// synchronously-thrown errors can't be `.code`-enriched by patching the -// native class in place. Expose a thin wrapper whose static factories run -// through `wrapSync`. Instances are the native ones — their async -// `getToken()` is already enriched via the prototype patch above. const NativeOidcFederationStrategy = native.OidcFederationStrategy; class OidcFederationStrategy { static create(workspaceCrn, getJwt, baseUrl) { @@ -123,13 +146,17 @@ class OidcFederationStrategy { } } -// Export wrapped top-level functions alongside native re-exports +// Export wrapped top-level functions alongside native re-exports. The facade +// classes shadow their native counterparts from the `...native` spread. module.exports = { ...native, + AutoStrategy, + AccessKeyStrategy, + DeviceSessionStrategy, OidcFederationStrategy, // Deprecated alias: `OAuthStrategy` was renamed to `DeviceSessionStrategy`. // Kept so existing consumers don't break; remove in a future major. - OAuthStrategy: native.DeviceSessionStrategy, + OAuthStrategy: DeviceSessionStrategy, beginDeviceCodeFlow: wrapAsync(native.beginDeviceCodeFlow), bindClientDevice: wrapAsync(native.bindClientDevice), }; From 803e8023860638c9aa101dfc0ffb6d35ccef25e6 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Sat, 4 Jul 2026 22:46:31 +1000 Subject: [PATCH 336/686] test(stack-auth): close FFI-envelope coverage gaps from PR review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address the test-coverage review on cipherstash/cipherstash-suite#2062: - wasm: pin the __authFailure object (type, payload, help, message) on to_js_error's output — the only thing wasm-inline.mjs reads to build a Result failure; both prior tests asserted only the legacy .code. - napi: pin device_client_to_napi_error's two-arm split — Auth defers to the canonical envelope (help + payload preserved), non-Auth variants synthesize the flat { type, message } shape. - node JS: assert INVALID_CRN's diagnostic help survives the __CS_FAIL__ round-trip with a content match, and pin the toFailure re-throw contract (a sentinel-free napi coercion error must reject, not convert to a failure). - node JS: add wasm-inline.mjs toFailure coverage (typed failure with help, message stripped from the spread, success arm). Self-skips when the gitignored wasm artifacts are absent, since the vitest CI job builds only the napi module. --- .../auth/__tests__/factory-result.test.ts | 15 +++++ .../oidc-federation-strategy.test.ts | 18 ++++++ .../auth/__tests__/wasm-inline-result.test.ts | 58 +++++++++++++++++++ languages/typescript/packages/auth/src/lib.rs | 33 +++++++++++ .../packages/stack-auth-wasm/src/lib.rs | 45 ++++++++++++++ 5 files changed, 169 insertions(+) create mode 100644 languages/typescript/packages/auth/__tests__/wasm-inline-result.test.ts diff --git a/languages/typescript/packages/auth/__tests__/factory-result.test.ts b/languages/typescript/packages/auth/__tests__/factory-result.test.ts index 357081718..930db629e 100644 --- a/languages/typescript/packages/auth/__tests__/factory-result.test.ts +++ b/languages/typescript/packages/auth/__tests__/factory-result.test.ts @@ -13,6 +13,7 @@ const { AccessKeyStrategy, DeviceSessionStrategy, OAuthStrategy, + beginDeviceCodeFlow, } = require("../index.js") as typeof import("../index"); const VALID_CRN = "crn:ap-southeast-2.aws:ZVATKW3VHMFG27DY"; @@ -108,3 +109,17 @@ describe("DeviceSessionStrategy.fromProfile", () => { expect(OAuthStrategy).toBe(DeviceSessionStrategy); }); }); + +describe("toFailure re-throw contract", () => { + it("propagates a non-sentinel error instead of converting it to a failure", async () => { + // The migration's safety premise: only sentineled domain errors become + // `{ failure }`; anything else (a genuine bug/panic) must keep propagating + // as an error. napi's argument coercion throws a plain, sentinel-free + // TypeError — drive it through a wrapped async function and require a + // rejection, not a resolved Result. A `toFailure` that swallowed + // non-sentinel errors into failures would resolve here and fail the test. + await expect( + beginDeviceCodeFlow(123 as unknown as string, "cli"), + ).rejects.toThrow(/Failed to convert/); + }); +}); diff --git a/languages/typescript/packages/auth/__tests__/oidc-federation-strategy.test.ts b/languages/typescript/packages/auth/__tests__/oidc-federation-strategy.test.ts index 8649155f3..e4b795016 100644 --- a/languages/typescript/packages/auth/__tests__/oidc-federation-strategy.test.ts +++ b/languages/typescript/packages/auth/__tests__/oidc-federation-strategy.test.ts @@ -214,6 +214,24 @@ describe("OidcFederationStrategy (TypeScript / vitest)", () => { expect(cr.failure?.type).toBe("INVALID_CRN"); }); + it("attaches diagnostic help to an INVALID_CRN failure", () => { + // INVALID_CRN carries `#[diagnostic(help(...))]`, so its envelope includes + // `help` — pins the `help !== undefined` branch of `toFailure` in index.js + // with a content assertion, not just presence: the text must survive the + // __CS_FAIL__ envelope round-trip intact. + const cr = OidcFederationStrategy.create("not-a-crn", () => + Promise.resolve("h.p.s"), + ); + expect(cr.failure?.type).toBe("INVALID_CRN"); + expect(typeof cr.failure?.help).toBe("string"); + expect(cr.failure?.help).toMatch(/crn::/); + // The same help is mirrored onto the live Error for loggers that only + // see the error object. + expect((cr.failure?.error as Error & { help?: string }).help).toBe( + cr.failure?.help, + ); + }); + it("rejects a CRN whose workspace segment is malformed with .type", () => { // "not-a-crn" above fails at the `crn:` prefix; this is the distinct path // where the prefix/region parse but the workspace segment fails validation diff --git a/languages/typescript/packages/auth/__tests__/wasm-inline-result.test.ts b/languages/typescript/packages/auth/__tests__/wasm-inline-result.test.ts new file mode 100644 index 000000000..75d4f93da --- /dev/null +++ b/languages/typescript/packages/auth/__tests__/wasm-inline-result.test.ts @@ -0,0 +1,58 @@ +import { describe, it, expect } from "vitest"; +import { existsSync } from "fs"; +import { join } from "path"; + +// JS-level coverage of `wasm-inline.mjs`'s `toFailure` — the seam that turns +// the wasm binding's branded `__authFailure` object into a `Result` failure. +// The Rust side of the brand is pinned by the wasm-bindgen test +// `to_js_error_attaches_auth_failure_object_with_payload_and_help`; this file +// pins the JS side: envelope fields surface on the failure, and the internal +// `message` field is stripped rather than leaking as a spread field. +// +// The wasm artifacts are gitignored (`npm run build:wasm` produces them), and +// the vitest CI job builds only the napi module — so these tests self-skip +// when the shim is absent. They run locally after a wasm build. +const WASM_SHIM = join(__dirname, "..", "wasm", "stack_auth_wasm_inline.js"); + +describe.skipIf(!existsSync(WASM_SHIM))("wasm-inline Result wrapper", () => { + const VALID_CRN = "crn:ap-southeast-2.aws:ZVATKW3VHMFG27DY"; + const VALID_KEY = "CSAKtestKeyId.testKeySecret"; + + async function loadWasmInline() { + // Dynamic import: a static one would fail module resolution when the + // gitignored artifacts are absent, even with the describe skipped. + return import("../wasm-inline.mjs"); + } + + it("converts a branded wasm error into a typed failure with help", async () => { + const { AccessKeyStrategy } = await loadWasmInline(); + const r = AccessKeyStrategy.create("not-a-crn", VALID_KEY); + expect(r.failure?.type).toBe("INVALID_CRN"); + expect(r.failure?.error).toBeInstanceOf(Error); + // help from the serialized envelope must surface on the failure. + expect(r.failure?.help).toMatch(/crn::/); + }); + + it("strips the envelope's message field instead of spreading it", async () => { + const { AccessKeyStrategy } = await loadWasmInline(); + const r = AccessKeyStrategy.create("not-a-crn", VALID_KEY); + if (!r.failure) { + expect.unreachable("create should fail for a malformed CRN"); + } + // `message` rides in `__authFailure` for the Rust-side envelope tests but + // is internal here — the live Error already carries it. A regression in + // the `delete payload.message` line would spread it onto the failure. + expect("message" in r.failure).toBe(false); + expect(r.failure.error.message).toContain("Invalid workspace CRN"); + }); + + it("returns { data } wrapping a usable strategy on success", async () => { + const { AccessKeyStrategy } = await loadWasmInline(); + const r = AccessKeyStrategy.create(VALID_CRN, VALID_KEY); + if (r.failure) { + expect.unreachable(`create failed: ${r.failure.type}`); + } + expect(typeof r.data.getToken).toBe("function"); + r.data.free(); + }); +}); diff --git a/languages/typescript/packages/auth/src/lib.rs b/languages/typescript/packages/auth/src/lib.rs index e5982e5c3..09c9ebd86 100644 --- a/languages/typescript/packages/auth/src/lib.rs +++ b/languages/typescript/packages/auth/src/lib.rs @@ -863,6 +863,39 @@ mod tests { "expected help in envelope, got: {json}" ); } + + // `device_client_to_napi_error` splits on the variant: `Auth` must + // defer to the canonical `to_napi_error` serialization (help + + // structured payload preserved), while non-Auth variants synthesize + // the flat `{ type, message }` shape. Routing `Auth` through the + // synth arm would silently drop help/payload. + #[test] + fn device_client_auth_arm_preserves_full_envelope() { + let ws = |s: &str| s.parse::().unwrap(); + let err = device_client_to_napi_error(DeviceClientError::Auth( + AuthError::WorkspaceMismatch(stack_auth::WorkspaceMismatch { + expected_workspace: ws("ZVATKW3VHMFG27DY"), + token_workspace: ws("AAAAAAAAAAAAAAAA"), + }), + )); + let json = assertions::failure_json(&err); + assert_eq!(json["type"], "WORKSPACE_MISMATCH"); + assert_eq!(json["expected"], "ZVATKW3VHMFG27DY"); + assert_eq!(json["actual"], "AAAAAAAAAAAAAAAA"); + assert!( + json["help"].as_str().is_some(), + "Auth arm must carry help through the canonical envelope, got: {json}" + ); + + // Non-Auth variant still yields the flat synth shape. + let err = device_client_to_napi_error(DeviceClientError::Profile( + stack_profile::ProfileError::HomeDirNotFound, + )); + let json = assertions::failure_json(&err); + assert_eq!(json["type"], "STORE_ERROR"); + assert!(json["message"].as_str().is_some()); + assert!(json.get("help").is_none()); + } } // The `baseUrl` override parsing (empty/absent/valid/malformed semantics) diff --git a/languages/typescript/packages/stack-auth-wasm/src/lib.rs b/languages/typescript/packages/stack-auth-wasm/src/lib.rs index 295ec134b..49752759b 100644 --- a/languages/typescript/packages/stack-auth-wasm/src/lib.rs +++ b/languages/typescript/packages/stack-auth-wasm/src/lib.rs @@ -500,6 +500,51 @@ mod tests { assert_eq!(error_code_of(&err), "WORKSPACE_MISMATCH"); } + fn auth_failure_of(err: &JsValue) -> JsValue { + js_sys::Reflect::get(err, &JsValue::from_str("__authFailure")) + .expect("error should carry the __authFailure brand") + } + + fn field(obj: &JsValue, key: &str) -> Option { + js_sys::Reflect::get(obj, &JsValue::from_str(key)) + .ok() + .and_then(|v| v.as_string()) + } + + /// The `__authFailure` object is the only thing `wasm-inline.mjs`'s + /// `toFailure` reads to build a `Result` failure — `.code` above is just + /// legacy parity. Pin the full serialized envelope (type + structured + /// payload + help + message) so dropping the attachment, or the serializer + /// losing a field, fails here rather than silently breaking the whole + /// wasm Result path. + #[wasm_bindgen_test] + fn to_js_error_attaches_auth_failure_object_with_payload_and_help() { + let err = to_js_error(AuthError::WorkspaceMismatch( + stack_auth::WorkspaceMismatch { + expected_workspace: "ZVATKW3VHMFG27DY".parse().unwrap(), + token_workspace: "AAAAAAAAAAAAAAAA".parse().unwrap(), + }, + )); + let details = auth_failure_of(&err); + assert_eq!( + field(&details, "type").as_deref(), + Some("WORKSPACE_MISMATCH") + ); + assert_eq!( + field(&details, "expected").as_deref(), + Some("ZVATKW3VHMFG27DY") + ); + assert_eq!( + field(&details, "actual").as_deref(), + Some("AAAAAAAAAAAAAAAA") + ); + assert!( + field(&details, "help").is_some(), + "help must ride along in __authFailure" + ); + assert!(field(&details, "message").is_some()); + } + #[wasm_bindgen_test] fn token_result_from_extracts_jwt_claims() { let token = make_service_token("https://cts.example.com/", "https://zerokms.example.com/"); From b3986f4ff66eddf85285fb9395a3504da137ce04 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Sat, 4 Jul 2026 22:53:46 +1000 Subject: [PATCH 337/686] docs(stack-auth): document the typed-error / diagnostic-help contract Add an Error handling section to the crate README (which doubles as the crate docs): stable machine-readable codes shared with @cipherstash/auth across the FFI boundary, actionable miette help()/url() diagnostics, and typed per-variant payload, with a doctested example. --- packages/stack-auth/README.md | 50 +++++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/packages/stack-auth/README.md b/packages/stack-auth/README.md index 2072b863b..83907dd3d 100644 --- a/packages/stack-auth/README.md +++ b/packages/stack-auth/README.md @@ -50,6 +50,56 @@ let strategy = AccessKeyStrategy::new(crn, key)?; # } ``` +## Error handling + +Every fallible operation returns [`AuthError`], a structured enum in which +each variant wraps a dedicated error struct. Errors are designed to tell the +developer exactly what to do next: + +- **Stable machine-readable codes** — [`AuthError::error_code`] returns a + `SCREAMING_CASE` identifier (e.g. `NOT_AUTHENTICATED`, + `WORKSPACE_MISMATCH`) suitable for logs, metrics, and programmatic + handling. The same taxonomy crosses the FFI boundary: the + [`@cipherstash/auth`](https://www.npmjs.com/package/@cipherstash/auth) npm + package surfaces these codes as the `type` discriminant of its typed + `AuthFailure` union. +- **Actionable help** — every variant implements + [`miette::Diagnostic`](https://docs.rs/miette), so `help()` (and `url()` + when present) carry remediation guidance, e.g. `NOT_AUTHENTICATED` says + ``Log in with `stash login`, or set `CS_CLIENT_ACCESS_KEY` for + service-to-service auth.`` Applications that render errors through miette + (like the Stash CLI) show this automatically. +- **Structured payload** — variants carry typed fields rather than + pre-formatted strings; e.g. [`WorkspaceMismatch`] exposes + `expected_workspace` and `token_workspace` so callers can act on the + values, not parse a message. + +```no_run +use miette::Diagnostic; +use stack_auth::{AuthError, AutoStrategy}; + +fn report(err: &AuthError) { + eprintln!("[{}] {err}", err.error_code()); + if let Some(help) = err.help() { + eprintln!(" help: {help}"); + } + if let Some(url) = err.url() { + eprintln!(" more: {url}"); + } + if let AuthError::WorkspaceMismatch(m) = err { + eprintln!( + " token belongs to {}, strategy expects {}", + m.token_workspace, m.expected_workspace + ); + } +} + +match AutoStrategy::detect() { + Ok(_strategy) => { /* authenticated */ } + Err(err) => report(&err), +} +``` + ## Extensibility `stack-auth` exposes two layers that can be plugged independently: From fdd7912c5e3499f5571e1c03fd307bf4252fa799 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Mon, 6 Jul 2026 10:59:57 +1000 Subject: [PATCH 338/686] refactor(stack-auth): adopt AuthError::ERROR_CODES for the AuthFailure drift guards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to cipherstash/cipherstash-suite#2053's ERROR_CODES change, applied to the decomposed error model here: the AuthFailure union drift test scraped the per-error AuthErrorKind::error_code impls out of error.rs, coupling it to formatting. Expose 'pub const AuthError::ERROR_CODES: &[&str]' alongside the enum in error.rs and have both guards reference the real symbol instead of source text: - ts_auth_failure_union_matches_error_codes compares the index.d.ts and wasm-inline.d.ts unions against ERROR_CODES, no include_str! scraping. - auth_error_code_is_stable_for_every_variant pins ERROR_CODES against what error_code() returns: every constructed variant's code must be declared, and ERROR_CODES must equal those codes plus REQUEST_ERROR (the one variant with no public constructor) — so the list can't grow stale entries or omit a real one. --- languages/typescript/packages/auth/src/lib.rs | 39 +++++-------------- packages/stack-auth/src/error.rs | 33 +++++++++++++++- packages/stack-auth/src/lib.rs | 28 ++++++++++++- 3 files changed, 69 insertions(+), 31 deletions(-) diff --git a/languages/typescript/packages/auth/src/lib.rs b/languages/typescript/packages/auth/src/lib.rs index 09c9ebd86..07a5f84f2 100644 --- a/languages/typescript/packages/auth/src/lib.rs +++ b/languages/typescript/packages/auth/src/lib.rs @@ -589,38 +589,19 @@ mod tests { /// The hand-written `AuthFailure` discriminated unions in `index.d.ts` and /// `wasm-inline.d.ts` must list exactly the codes the Rust `AuthError` can - /// emit. The expected set is *derived* from the per-error - /// `AuthErrorKind::error_code` impls in the core crate's `error.rs` — not a - /// hand-kept mirror — so there's no parallel list to drift. Each impl - /// returns a bare `"CODE"` literal on its own line; add an `AuthError` - /// variant (which must impl `AuthErrorKind`) and forget to update the TS - /// unions, and this fails. + /// emit. The expected set is the exported [`AuthError::ERROR_CODES`] + /// constant — a real symbol the compiler resolves, not a scrape of the core + /// crate's source text. A core-crate test pins that constant against the + /// per-error `AuthErrorKind::error_code` impls, so adding an `AuthError` + /// variant forces a new code there, which this test then requires the TS + /// unions to include; forget to update them and this fails. #[test] fn ts_auth_failure_union_matches_error_codes() { use std::collections::BTreeSet; - // The codes returned by each `AuthErrorKind::error_code` impl: bare - // SCREAMING_CASE string literals on their own line in `error.rs`. The - // node crate depends on stack-auth, so this resolves to the core crate. - const ERROR_SRC: &str = include_str!("../../src/error.rs"); - let is_code = - |s: &str| !s.is_empty() && s.bytes().all(|b| b.is_ascii_uppercase() || b == b'_'); - let expected: BTreeSet<&str> = ERROR_SRC - .lines() - .filter_map(|line| { - let t = line.trim(); - t.strip_prefix('"') - .and_then(|r| r.strip_suffix('"')) - .filter(|code| is_code(code)) - }) - .collect(); - - // A mis-scoped parse (empty/garbage) should fail loudly here, not pass. - assert!( - expected.len() >= 15, - "parsed only {} error codes from error.rs — the source parse likely broke", - expected.len(), - ); + // The codes the Rust `AuthError` can emit — the canonical set exported + // by stack-auth, not a scrape of `error.rs`. + let expected: BTreeSet<&str> = AuthError::ERROR_CODES.iter().copied().collect(); // Each TS union member is `... { type: "CODE" ... }`; pull every literal. let codes_in = |dts: &str| -> BTreeSet { @@ -643,7 +624,7 @@ mod tests { let expected: BTreeSet = expected.iter().map(|s| s.to_string()).collect(); assert_eq!( union, expected, - "AuthFailure union in {name} drifted from AuthError error codes", + "AuthFailure union in {name} drifted from AuthError::ERROR_CODES", ); } } diff --git a/packages/stack-auth/src/error.rs b/packages/stack-auth/src/error.rs index 72af03904..051589d0d 100644 --- a/packages/stack-auth/src/error.rs +++ b/packages/stack-auth/src/error.rs @@ -320,6 +320,36 @@ pub enum AuthError { } impl AuthError { + /// The complete set of codes [`AuthError::error_code`] can return — the + /// stable, machine-readable contract surfaced across FFI (JS `Error.code`, + /// Node-API codes, the `index.d.ts` / `wasm-inline.d.ts` `AuthFailure` + /// unions). The bindings derive their expected union from this constant + /// rather than re-scraping the per-error [`AuthErrorKind::error_code`] impls, + /// and `auth_error_code_is_stable_for_every_variant` pins that it stays in + /// lockstep with what `error_code` actually returns. + pub const ERROR_CODES: &'static [&'static str] = &[ + "REQUEST_ERROR", + "ACCESS_DENIED", + "INVALID_GRANT", + "INVALID_CLIENT", + "INVALID_URL", + "INVALID_REGION", + "INVALID_CRN", + "WORKSPACE_MISMATCH", + "INVALID_WORKSPACE_ID", + "MISSING_WORKSPACE_CRN", + "NOT_AUTHENTICATED", + "EXPIRED_TOKEN", + "INVALID_ACCESS_KEY", + "INVALID_TOKEN", + "SERVER_ERROR", + "ALREADY_CONSUMED", + "INTERNAL_ERROR", + // `Store` (and its code) only exists off-wasm — see the enum above. + #[cfg(not(target_arch = "wasm32"))] + "STORE_ERROR", + ]; + /// Dispatch to the wrapped concrete error as a trait object. fn kind(&self) -> &dyn AuthErrorKind { match self { @@ -347,7 +377,8 @@ impl AuthError { /// Stable machine-readable identifier for surfacing across FFI boundaries /// (e.g. JS `Error.code`, Node-API error codes). Delegates to the wrapped - /// error's [`AuthErrorKind::error_code`]. + /// error's [`AuthErrorKind::error_code`]; every value it can return is + /// listed in [`AuthError::ERROR_CODES`]. pub fn error_code(&self) -> &'static str { self.kind().error_code() } diff --git a/packages/stack-auth/src/lib.rs b/packages/stack-auth/src/lib.rs index d077c9793..7611214e0 100644 --- a/packages/stack-auth/src/lib.rs +++ b/packages/stack-auth/src/lib.rs @@ -396,10 +396,19 @@ mod tests { /// (JS `Error.code`, Node-API codes), so pin every variant's code. Covers /// all variants except `Request`, whose inner `reqwest::Error` has no public /// constructor; if a new variant is added without a code, `error_code`'s - /// exhaustive match fails to compile, so the contract can't silently drift. + /// exhaustive `kind()` dispatch fails to compile, so the contract can't + /// silently drift. + /// + /// Also pins [`AuthError::ERROR_CODES`] against what `error_code` actually + /// returns: every constructed variant's code must be declared there, and + /// `ERROR_CODES` must hold exactly those codes plus `REQUEST_ERROR` (the one + /// variant with no public constructor). So the list can't grow stale entries + /// or omit a real one — which is what the binding crates' union tests trust. #[test] #[allow(clippy::unwrap_used)] fn auth_error_code_is_stable_for_every_variant() { + use std::collections::BTreeSet; + let workspace = "ZVATKW3VHMFG27DY" .parse::() .unwrap(); @@ -479,9 +488,26 @@ mod tests { ), ]; + let declared: BTreeSet<&str> = AuthError::ERROR_CODES.iter().copied().collect(); + + let mut from_variants: BTreeSet<&str> = BTreeSet::new(); for (err, expected) in cases { assert_eq!(err.error_code(), expected, "error_code for {err:?}"); + assert!( + declared.contains(expected), + "{expected} is returned by error_code() but missing from AuthError::ERROR_CODES", + ); + from_variants.insert(expected); } + + // `Request` has no public constructor, so it can't appear above; add its + // code explicitly so the set-equality below stays exact. + from_variants.insert("REQUEST_ERROR"); + + assert_eq!( + declared, from_variants, + "AuthError::ERROR_CODES drifted from the codes error_code() returns", + ); } /// Every variant annotated with `#[diagnostic(help(..))]` must surface that From bc78bff460963519c19950c5ab2d544950e1cfc1 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Mon, 6 Jul 2026 11:00:19 +1000 Subject: [PATCH 339/686] fix(stack-auth-node): update index.d.ts guard for the Result-typed surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit index_dts_retains_hand_written_reexports (inherited from cipherstash/cipherstash-suite#2053) still asserted 'export * from "./native"' and 'export interface AuthError extends Error' — symbols this PR intentionally removed when index.d.ts became the Result re-declarer (selective 'import ... from "./native"' + the AuthFailure union). The guard was failing. Point it at the hand-written pieces the current index.d.ts actually carries: the './native' type re-use, the AuthFailure union, and the OAuthStrategy runtime alias. --- languages/typescript/packages/auth/src/lib.rs | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/languages/typescript/packages/auth/src/lib.rs b/languages/typescript/packages/auth/src/lib.rs index 07a5f84f2..7af9b861f 100644 --- a/languages/typescript/packages/auth/src/lib.rs +++ b/languages/typescript/packages/auth/src/lib.rs @@ -631,17 +631,20 @@ mod tests { #[test] fn index_dts_retains_hand_written_reexports() { - // The union test above only guards `AuthErrorCode`. The other three + // The union test above only guards the `AuthFailure` codes. The other // hand-written pieces of `index.d.ts` are equally load-bearing but - // NAPI-RS cannot emit them, so a regen or careless edit that drops any - // of them compiles green: the node tests import `AuthError` as an - // `import type` (erased at runtime) and vitest never runs `tsc`. Pin - // them by string presence so a deletion fails here. + // NAPI-RS cannot emit them — they describe the `Result` contract, not the + // raw throwing bindings — so a regen or careless edit that drops any of + // them compiles green: the node tests import these as `import type` + // (erased at runtime) and vitest never runs `tsc`. Pin them by string + // presence so a deletion fails here. let dts = include_str!("../index.d.ts"); for needle in [ - // The native re-export the whole generated surface flows through. - "export * from \"./native\"", - "export interface AuthError extends Error", + // The native type re-use that ties this file to `native.d.ts`. + "from \"./native\"", + // The discriminated failure union returned in the `Result` arm. + "export type AuthFailure =", + // The deprecated runtime alias `index.js` still exports. "export declare const OAuthStrategy", ] { assert!( From aa94c5c63e966aa2bc54d3ef9cd16bc3d5b27fec Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Mon, 6 Jul 2026 14:17:00 +1000 Subject: [PATCH 340/686] refactor(stack-auth): define AuthError codes as named constants MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review comment: the code strings were repeated as magic strings in each AuthErrorKind::error_code impl and again in AuthError::ERROR_CODES, so a change had to be made in two places. Extract them into a private 'codes' module of named constants that both the per-struct impls and ERROR_CODES reference, so each code string exists exactly once. (The verification the comment also asked for — every variant's code present and correct — is already pinned by auth_error_code_is_stable_for_every_variant.) --- packages/stack-auth/src/error.rs | 100 ++++++++++++++++++++----------- 1 file changed, 64 insertions(+), 36 deletions(-) diff --git a/packages/stack-auth/src/error.rs b/packages/stack-auth/src/error.rs index 051589d0d..5fb08a44f 100644 --- a/packages/stack-auth/src/error.rs +++ b/packages/stack-auth/src/error.rs @@ -31,6 +31,34 @@ pub trait AuthErrorKind: std::error::Error + miette::Diagnostic { } } +/// The stable machine-readable error codes surfaced across FFI (JS `Error.code`, +/// the `AuthFailure` TS unions). Defined once here so each +/// [`AuthErrorKind::error_code`] impl and the [`AuthError::ERROR_CODES`] list +/// reference the same constant rather than repeating a magic string; a code +/// only ever changes in one place. `auth_error_code_is_stable_for_every_variant` +/// pins that every variant maps to one of these and that the list is exhaustive. +pub(crate) mod codes { + pub(crate) const REQUEST_ERROR: &str = "REQUEST_ERROR"; + pub(crate) const ACCESS_DENIED: &str = "ACCESS_DENIED"; + pub(crate) const INVALID_GRANT: &str = "INVALID_GRANT"; + pub(crate) const INVALID_CLIENT: &str = "INVALID_CLIENT"; + pub(crate) const INVALID_URL: &str = "INVALID_URL"; + pub(crate) const INVALID_REGION: &str = "INVALID_REGION"; + pub(crate) const INVALID_CRN: &str = "INVALID_CRN"; + pub(crate) const WORKSPACE_MISMATCH: &str = "WORKSPACE_MISMATCH"; + pub(crate) const INVALID_WORKSPACE_ID: &str = "INVALID_WORKSPACE_ID"; + pub(crate) const MISSING_WORKSPACE_CRN: &str = "MISSING_WORKSPACE_CRN"; + pub(crate) const NOT_AUTHENTICATED: &str = "NOT_AUTHENTICATED"; + pub(crate) const EXPIRED_TOKEN: &str = "EXPIRED_TOKEN"; + pub(crate) const INVALID_ACCESS_KEY: &str = "INVALID_ACCESS_KEY"; + pub(crate) const INVALID_TOKEN: &str = "INVALID_TOKEN"; + pub(crate) const SERVER_ERROR: &str = "SERVER_ERROR"; + pub(crate) const ALREADY_CONSUMED: &str = "ALREADY_CONSUMED"; + pub(crate) const INTERNAL_ERROR: &str = "INTERNAL_ERROR"; + #[cfg(not(target_arch = "wasm32"))] + pub(crate) const STORE_ERROR: &str = "STORE_ERROR"; +} + // --------------------------------------------------------------------------- // Per-error structs // --------------------------------------------------------------------------- @@ -41,7 +69,7 @@ pub trait AuthErrorKind: std::error::Error + miette::Diagnostic { pub struct RequestError(pub reqwest::Error); impl AuthErrorKind for RequestError { fn error_code(&self) -> &'static str { - "REQUEST_ERROR" + codes::REQUEST_ERROR } } @@ -51,7 +79,7 @@ impl AuthErrorKind for RequestError { pub struct AccessDenied; impl AuthErrorKind for AccessDenied { fn error_code(&self) -> &'static str { - "ACCESS_DENIED" + codes::ACCESS_DENIED } } @@ -61,7 +89,7 @@ impl AuthErrorKind for AccessDenied { pub struct InvalidGrant; impl AuthErrorKind for InvalidGrant { fn error_code(&self) -> &'static str { - "INVALID_GRANT" + codes::INVALID_GRANT } } @@ -71,7 +99,7 @@ impl AuthErrorKind for InvalidGrant { pub struct InvalidClient; impl AuthErrorKind for InvalidClient { fn error_code(&self) -> &'static str { - "INVALID_CLIENT" + codes::INVALID_CLIENT } } @@ -81,7 +109,7 @@ impl AuthErrorKind for InvalidClient { pub struct InvalidUrl(pub url::ParseError); impl AuthErrorKind for InvalidUrl { fn error_code(&self) -> &'static str { - "INVALID_URL" + codes::INVALID_URL } } @@ -92,7 +120,7 @@ impl AuthErrorKind for InvalidUrl { pub struct UnsupportedRegion(pub cts_common::RegionError); impl AuthErrorKind for UnsupportedRegion { fn error_code(&self) -> &'static str { - "INVALID_REGION" + codes::INVALID_REGION } } @@ -105,7 +133,7 @@ impl AuthErrorKind for UnsupportedRegion { pub struct InvalidCrn(pub cts_common::InvalidCrn); impl AuthErrorKind for InvalidCrn { fn error_code(&self) -> &'static str { - "INVALID_CRN" + codes::INVALID_CRN } } @@ -125,7 +153,7 @@ pub struct WorkspaceMismatch { } impl AuthErrorKind for WorkspaceMismatch { fn error_code(&self) -> &'static str { - "WORKSPACE_MISMATCH" + codes::WORKSPACE_MISMATCH } fn payload(&self) -> serde_json::Map { match serde_json::json!({ @@ -144,7 +172,7 @@ impl AuthErrorKind for WorkspaceMismatch { pub struct InvalidWorkspaceId(pub cts_common::InvalidWorkspaceId); impl AuthErrorKind for InvalidWorkspaceId { fn error_code(&self) -> &'static str { - "INVALID_WORKSPACE_ID" + codes::INVALID_WORKSPACE_ID } } @@ -159,7 +187,7 @@ impl AuthErrorKind for InvalidWorkspaceId { pub struct MissingWorkspaceCrn; impl AuthErrorKind for MissingWorkspaceCrn { fn error_code(&self) -> &'static str { - "MISSING_WORKSPACE_CRN" + codes::MISSING_WORKSPACE_CRN } } @@ -172,7 +200,7 @@ impl AuthErrorKind for MissingWorkspaceCrn { pub struct NotAuthenticated; impl AuthErrorKind for NotAuthenticated { fn error_code(&self) -> &'static str { - "NOT_AUTHENTICATED" + codes::NOT_AUTHENTICATED } } @@ -182,7 +210,7 @@ impl AuthErrorKind for NotAuthenticated { pub struct TokenExpired; impl AuthErrorKind for TokenExpired { fn error_code(&self) -> &'static str { - "EXPIRED_TOKEN" + codes::EXPIRED_TOKEN } } @@ -193,7 +221,7 @@ impl AuthErrorKind for TokenExpired { pub struct InvalidAccessKeyError(pub access_key::InvalidAccessKey); impl AuthErrorKind for InvalidAccessKeyError { fn error_code(&self) -> &'static str { - "INVALID_ACCESS_KEY" + codes::INVALID_ACCESS_KEY } } @@ -203,7 +231,7 @@ impl AuthErrorKind for InvalidAccessKeyError { pub struct InvalidToken(pub String); impl AuthErrorKind for InvalidToken { fn error_code(&self) -> &'static str { - "INVALID_TOKEN" + codes::INVALID_TOKEN } } @@ -213,7 +241,7 @@ impl AuthErrorKind for InvalidToken { pub struct ServerError(pub String); impl AuthErrorKind for ServerError { fn error_code(&self) -> &'static str { - "SERVER_ERROR" + codes::SERVER_ERROR } } @@ -226,7 +254,7 @@ impl AuthErrorKind for ServerError { pub struct AlreadyConsumed; impl AuthErrorKind for AlreadyConsumed { fn error_code(&self) -> &'static str { - "ALREADY_CONSUMED" + codes::ALREADY_CONSUMED } } @@ -238,7 +266,7 @@ impl AuthErrorKind for AlreadyConsumed { pub struct InternalError(pub String); impl AuthErrorKind for InternalError { fn error_code(&self) -> &'static str { - "INTERNAL_ERROR" + codes::INTERNAL_ERROR } } @@ -250,7 +278,7 @@ pub struct StoreError(pub stack_profile::ProfileError); #[cfg(not(target_arch = "wasm32"))] impl AuthErrorKind for StoreError { fn error_code(&self) -> &'static str { - "STORE_ERROR" + codes::STORE_ERROR } } @@ -328,26 +356,26 @@ impl AuthError { /// and `auth_error_code_is_stable_for_every_variant` pins that it stays in /// lockstep with what `error_code` actually returns. pub const ERROR_CODES: &'static [&'static str] = &[ - "REQUEST_ERROR", - "ACCESS_DENIED", - "INVALID_GRANT", - "INVALID_CLIENT", - "INVALID_URL", - "INVALID_REGION", - "INVALID_CRN", - "WORKSPACE_MISMATCH", - "INVALID_WORKSPACE_ID", - "MISSING_WORKSPACE_CRN", - "NOT_AUTHENTICATED", - "EXPIRED_TOKEN", - "INVALID_ACCESS_KEY", - "INVALID_TOKEN", - "SERVER_ERROR", - "ALREADY_CONSUMED", - "INTERNAL_ERROR", + codes::REQUEST_ERROR, + codes::ACCESS_DENIED, + codes::INVALID_GRANT, + codes::INVALID_CLIENT, + codes::INVALID_URL, + codes::INVALID_REGION, + codes::INVALID_CRN, + codes::WORKSPACE_MISMATCH, + codes::INVALID_WORKSPACE_ID, + codes::MISSING_WORKSPACE_CRN, + codes::NOT_AUTHENTICATED, + codes::EXPIRED_TOKEN, + codes::INVALID_ACCESS_KEY, + codes::INVALID_TOKEN, + codes::SERVER_ERROR, + codes::ALREADY_CONSUMED, + codes::INTERNAL_ERROR, // `Store` (and its code) only exists off-wasm — see the enum above. #[cfg(not(target_arch = "wasm32"))] - "STORE_ERROR", + codes::STORE_ERROR, ]; /// Dispatch to the wrapped concrete error as a trait object. From 4eb9e4156893e30944629ae01ff04786f971e89b Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Mon, 6 Jul 2026 14:17:01 +1000 Subject: [PATCH 341/686] chore(stack-auth-node): release as 0.41.0, not 1.0.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review comment: 1.0 is too big a jump for a pre-1.0 package. Use the pre-1.0 breaking-change convention (minor bump) — 0.40.0 -> 0.41.0 — across the version, the platform peerDependencies, the lockfile, and the CHANGELOG heading. --- languages/typescript/packages/auth/CHANGELOG.md | 2 +- languages/typescript/packages/auth/package.json | 14 +++++++------- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/languages/typescript/packages/auth/CHANGELOG.md b/languages/typescript/packages/auth/CHANGELOG.md index 60a662449..0245b6057 100644 --- a/languages/typescript/packages/auth/CHANGELOG.md +++ b/languages/typescript/packages/auth/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## 1.0.0 +## 0.41.0 ### Breaking Changes diff --git a/languages/typescript/packages/auth/package.json b/languages/typescript/packages/auth/package.json index b6706a1cf..599997e49 100644 --- a/languages/typescript/packages/auth/package.json +++ b/languages/typescript/packages/auth/package.json @@ -1,6 +1,6 @@ { "name": "@cipherstash/auth", - "version": "1.0.0", + "version": "0.41.0", "main": "index.js", "types": "index.d.ts", "browser": false, @@ -64,12 +64,12 @@ "format:check": "npx --yes @biomejs/biome@2.3.4 ci ." }, "peerDependencies": { - "@cipherstash/auth-darwin-x64": "1.0.0", - "@cipherstash/auth-darwin-arm64": "1.0.0", - "@cipherstash/auth-linux-x64-gnu": "1.0.0", - "@cipherstash/auth-linux-arm64-gnu": "1.0.0", - "@cipherstash/auth-linux-x64-musl": "1.0.0", - "@cipherstash/auth-win32-x64-msvc": "1.0.0" + "@cipherstash/auth-darwin-x64": "0.41.0", + "@cipherstash/auth-darwin-arm64": "0.41.0", + "@cipherstash/auth-linux-x64-gnu": "0.41.0", + "@cipherstash/auth-linux-arm64-gnu": "0.41.0", + "@cipherstash/auth-linux-x64-musl": "0.41.0", + "@cipherstash/auth-win32-x64-msvc": "0.41.0" }, "peerDependenciesMeta": { "@cipherstash/auth-darwin-x64": { From 95bee753fdadae965009b4657b375669b2794b81 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Mon, 6 Jul 2026 14:17:01 +1000 Subject: [PATCH 342/686] docs(stack-auth-node): recommend passing the strategy to an SDK, not getToken Review comment: don't recommend calling getToken() directly. The Edge examples now hand the strategy to a CipherStash SDK (Encryption from @cipherstash/stack) which owns token acquisition/refresh; the raw-token pattern moves to a new 'Working with tokens directly (use with care)' section at the end. Also fixes the version refs the 0.41.0 bump touched (deno.json @^0.41, migration note). --- languages/typescript/packages/auth/README.md | 67 ++++++++++++++------ 1 file changed, 46 insertions(+), 21 deletions(-) diff --git a/languages/typescript/packages/auth/README.md b/languages/typescript/packages/auth/README.md index abc867a62..1a905ba35 100644 --- a/languages/typescript/packages/auth/README.md +++ b/languages/typescript/packages/auth/README.md @@ -57,6 +57,7 @@ Pair the `wasm-inline` entry with the `cookies` helper to back the strategy with // supabase/functions/get-token/index.ts import { AccessKeyStrategy } from "@cipherstash/auth/wasm-inline"; import { cookieStore } from "@cipherstash/auth/cookies"; +import { Encryption } from "@cipherstash/stack"; Deno.serve(async (req) => { const responseHeaders = new Headers({ "content-type": "application/json" }); @@ -70,18 +71,15 @@ Deno.serve(async (req) => { return Response.json({ error: created.failure.type }, { status: 500, headers: responseHeaders }); } - const result = await created.data.getToken(); - if (result.failure) { - return Response.json({ error: result.failure.type }, { status: 500, headers: responseHeaders }); - } - const { token, workspaceId, services } = result.data; - // `token` is the bearer credential; pass as `Authorization: Bearer ${token}` - // to ZeroKMS at `services.zerokms`. + // Hand the strategy to a CipherStash SDK — e.g. `Encryption` from + // `@cipherstash/stack` — which acquires and refreshes CTS tokens internally, + // so your code never handles a raw bearer token. You don't call `getToken()` + // yourself. (Need the token itself? See "Working with tokens directly" at the + // end of this README.) + const encryption = new Encryption({ authStrategy: created.data }); - return new Response( - JSON.stringify({ workspaceId, services }), - { headers: responseHeaders }, - ); + // ... encrypt / decrypt with `encryption` ... + return Response.json({ ok: true }, { headers: responseHeaders }); }); ``` @@ -90,15 +88,15 @@ Deno.serve(async (req) => { ```jsonc { "imports": { - "@cipherstash/auth/wasm-inline": "npm:@cipherstash/auth@^1/wasm-inline", - "@cipherstash/auth/cookies": "npm:@cipherstash/auth@^1/cookies" + "@cipherstash/auth/wasm-inline": "npm:@cipherstash/auth@^0.41/wasm-inline", + "@cipherstash/auth/cookies": "npm:@cipherstash/auth@^0.41/cookies" } } ``` Nothing extra in `supabase/config.toml` — no `static_files`, no asset copying, no bundler plugins. The `wasm-inline` entry embeds the wasm module as base64 inside the JS shim, so it loads with zero runtime config. -`getToken()` resolves to a `Result`; on success `result.data` is `{ token, subject, workspaceId, issuer, services }` where `services` is a plain object (e.g. `{ zerokms: "https://..." }`). See [Error handling](#error-handling). +In the recommended flow you never call `getToken()` — the SDK does, internally. If you have a lower-level need for the raw token, see [Working with tokens directly](#working-with-tokens-directly-use-with-care). Factory and token failures are handled the same way; see [Error handling](#error-handling). For Cloudflare Workers the shape is identical; env access becomes `env.CS_CLIENT_ACCESS_KEY` instead of `Deno.env.get(...)`. @@ -109,6 +107,7 @@ When the end user is already signed in with a third-party OIDC provider (Clerk, ```ts import { OidcFederationStrategy } from "@cipherstash/auth/wasm-inline"; import { cookieStore } from "@cipherstash/auth/cookies"; +import { Encryption } from "@cipherstash/stack"; Deno.serve(async (req) => { const responseHeaders = new Headers({ "content-type": "application/json" }); @@ -123,12 +122,12 @@ Deno.serve(async (req) => { return Response.json({ error: created.failure.type }, { status: 500, headers: responseHeaders }); } - const result = await created.data.getToken(); - if (result.failure) { - return Response.json({ error: result.failure.type }, { status: 500, headers: responseHeaders }); - } - const { token, services } = result.data; - return new Response(JSON.stringify({ services }), { headers: responseHeaders }); + // As above, pass the strategy to a CipherStash SDK rather than calling + // `getToken()` yourself — the SDK owns token acquisition and refresh. + const encryption = new Encryption({ authStrategy: created.data }); + + // ... encrypt / decrypt with `encryption` ... + return Response.json({ ok: true }, { headers: responseHeaders }); }); ``` @@ -268,13 +267,39 @@ const strategy = created.data; Failure `type`s: `INVALID_ACCESS_KEY`, `ACCESS_DENIED`, `EXPIRED_TOKEN`, `INVALID_GRANT`, `INVALID_CLIENT`, `INVALID_REGION`, `INVALID_URL`, `INVALID_TOKEN`, `SERVER_ERROR`, `REQUEST_ERROR`, `NOT_AUTHENTICATED`, `MISSING_WORKSPACE_CRN`, `INVALID_CRN`, `WORKSPACE_MISMATCH`, `INVALID_WORKSPACE_ID`, `ALREADY_CONSUMED`, `INTERNAL_ERROR`, `STORE_ERROR`. Each `failure` also carries the live `error: Error` and optional `help`/`url`. Only a genuine internal panic still throws. -> **Migrating from the throw-based API (pre-1.0):** replace +> **Migrating from the throw-based API (0.40.x and earlier):** replace > `try { const t = await s.getToken(); … } catch (err) { err.code }` > with `const r = await s.getToken(); if (r.failure) { r.failure.type } else { r.data }`. > Factories (`AccessKeyStrategy.create`, `AutoStrategy.detect`, > `OidcFederationStrategy.create`, `DeviceSessionStrategy.fromProfile`) now > return a `Result` too, so unwrap `.data` before use. +## Working with tokens directly (use with care) + +The recommended integration is to hand your strategy to a CipherStash SDK — e.g. +`Encryption` from `@cipherstash/stack`, as shown above. The SDK calls +`getToken()` internally and manages refresh, so your code never handles a raw +credential. + +If you have a lower-level need, `getToken()` returns the bearer token directly. +Treat it as a secret: never log it, return it to a browser, or persist it +outside a secure store. + +```ts +const result = await strategy.getToken(); +if (result.failure) { + console.error(result.failure.type); + return; +} +const { token, workspaceId, services } = result.data; +// `token` is the bearer credential — send it as `Authorization: Bearer ${token}` +// to a CTS service (e.g. ZeroKMS at `services.zerokms`). +``` + +`result.data` is `{ token, subject, workspaceId, issuer, services }`, where +`services` is a plain object (e.g. `{ zerokms: "https://..." }`). See +[Error handling](#error-handling) for the failure arm. + ## License See [LICENSE](https://github.com/cipherstash/cipherstash-suite/blob/main/packages/stack-auth/LICENSE). From 6b961b226a8b6adc0f37898050ae0ad10bdb449a Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Mon, 6 Jul 2026 14:58:52 +1000 Subject: [PATCH 343/686] fix(stack-auth-node): mirror help/url onto failure.error on the wasm seam Code-review finding: wasm-inline.mjs's toFailure attached help/url only to the failure object, not the live failure.error, so a logger reading the Error object got the hint on the napi backend but undefined on wasm-inline. Mirror them onto the Error too, matching index.js, and strengthen the wasm-inline test to assert error.help === failure.help (teeth-checked). Also order the payload spread before the fixed type/error keys in both seams so a future payload field named 'error'/'type' can't clobber the live Error. --- .../auth/__tests__/wasm-inline-result.test.ts | 5 +++++ languages/typescript/packages/auth/index.js | 4 +++- .../typescript/packages/auth/wasm-inline.mjs | 16 +++++++++++++--- 3 files changed, 21 insertions(+), 4 deletions(-) diff --git a/languages/typescript/packages/auth/__tests__/wasm-inline-result.test.ts b/languages/typescript/packages/auth/__tests__/wasm-inline-result.test.ts index 75d4f93da..8cddeddff 100644 --- a/languages/typescript/packages/auth/__tests__/wasm-inline-result.test.ts +++ b/languages/typescript/packages/auth/__tests__/wasm-inline-result.test.ts @@ -31,6 +31,11 @@ describe.skipIf(!existsSync(WASM_SHIM))("wasm-inline Result wrapper", () => { expect(r.failure?.error).toBeInstanceOf(Error); // help from the serialized envelope must surface on the failure. expect(r.failure?.help).toMatch(/crn::/); + // ...and be mirrored onto the live Error, matching the napi seam, so loggers + // that only see `failure.error` still get the hint. + expect((r.failure?.error as Error & { help?: string }).help).toBe( + r.failure?.help, + ); }); it("strips the envelope's message field instead of spreading it", async () => { diff --git a/languages/typescript/packages/auth/index.js b/languages/typescript/packages/auth/index.js index 0665e9172..e50e303c5 100644 --- a/languages/typescript/packages/auth/index.js +++ b/languages/typescript/packages/auth/index.js @@ -28,7 +28,9 @@ function toFailure(err) { ); err.message = message; err.code = type; - const failure = { type, error: err, ...payload }; + // Spread payload first so the fixed `type`/`error` keys always win, even if a + // future payload field collides with one of them. + const failure = { ...payload, type, error: err }; if (help !== undefined) { err.help = help; failure.help = help; diff --git a/languages/typescript/packages/auth/wasm-inline.mjs b/languages/typescript/packages/auth/wasm-inline.mjs index 64411b72b..22dd62e1c 100644 --- a/languages/typescript/packages/auth/wasm-inline.mjs +++ b/languages/typescript/packages/auth/wasm-inline.mjs @@ -27,9 +27,19 @@ function toFailure(err) { const { type, help, url, ...payload } = details; // `payload` still carries `message`; drop it from the spread fields. delete payload.message; - const failure = { type, error: err, ...payload }; - if (help !== undefined) failure.help = help; - if (url !== undefined) failure.url = url; + // Spread payload first so the fixed `type`/`error` keys always win, even if a + // future payload field collides with one of them. + const failure = { ...payload, type, error: err }; + // Mirror help/url onto both the failure and the live Error, matching the napi + // seam (index.js) — loggers that only see `failure.error` still get the hint. + if (help !== undefined) { + err.help = help; + failure.help = help; + } + if (url !== undefined) { + err.url = url; + failure.url = url; + } return { failure }; } From a51ba1337fe02ba5c971912869bb2a5ab85cb512 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Mon, 6 Jul 2026 14:58:53 +1000 Subject: [PATCH 344/686] fix(stack-auth-wasm): always brand the JS error with __authFailure Code-review finding: to_js_error attached __authFailure only when serialize() succeeded; on failure wasm-inline.mjs sees no brand and re-throws the domain error as a panic. Add a minimal { type, message } fallback so a domain failure always crosses as a Result failure, mirroring the napi seam's unwrap_or_else. --- .../packages/stack-auth-wasm/src/lib.rs | 22 ++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/languages/typescript/packages/stack-auth-wasm/src/lib.rs b/languages/typescript/packages/stack-auth-wasm/src/lib.rs index 49752759b..72cb03d03 100644 --- a/languages/typescript/packages/stack-auth-wasm/src/lib.rs +++ b/languages/typescript/packages/stack-auth-wasm/src/lib.rs @@ -42,9 +42,25 @@ fn to_js_error(err: AuthError) -> JsValue { let js_err: JsValue = js_sys::Error::new(&err.to_string()).into(); let serializer = Serializer::new().serialize_maps_as_objects(true); - if let Ok(details) = err.serialize(&serializer) { - let _ = js_sys::Reflect::set(&js_err, &JsValue::from_str("__authFailure"), &details); - } + // Always attach the `__authFailure` brand. If full serialization ever fails, + // fall back to a minimal `{ type, message }` object so the shim still sees a + // domain failure and returns `{ failure }` rather than re-throwing it as a + // panic — mirrors the napi seam's `unwrap_or_else` fallback in node/src/lib.rs. + let details = err.serialize(&serializer).unwrap_or_else(|_| { + let fallback = js_sys::Object::new(); + let _ = js_sys::Reflect::set( + &fallback, + &JsValue::from_str("type"), + &JsValue::from_str(err.error_code()), + ); + let _ = js_sys::Reflect::set( + &fallback, + &JsValue::from_str("message"), + &JsValue::from_str(&err.to_string()), + ); + fallback.into() + }); + let _ = js_sys::Reflect::set(&js_err, &JsValue::from_str("__authFailure"), &details); let _ = js_sys::Reflect::set( &js_err, &JsValue::from_str("code"), From 9463a41ba84026b5b855f82b0ed35da5df4231d9 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Mon, 6 Jul 2026 14:58:53 +1000 Subject: [PATCH 345/686] ci(stack-auth): build the wasm shim and run the wasm-inline Result tests Code-review finding: test:integration:stack-auth builds only the napi module, so wasm-inline-result.test.ts self-skips in CI, leaving the wasm-inline.mjs toFailure seam with no coverage. Build the shim once wasm-pack is available and run those tests explicitly. --- .github/imported-workflows/test-stack-auth.yml | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/.github/imported-workflows/test-stack-auth.yml b/.github/imported-workflows/test-stack-auth.yml index 8fc6b07a0..e92769ffe 100644 --- a/.github/imported-workflows/test-stack-auth.yml +++ b/.github/imported-workflows/test-stack-auth.yml @@ -99,6 +99,17 @@ jobs: sudo install -m 0755 "/tmp/wasm-pack-${WASM_PACK_VERSION}-x86_64-unknown-linux-musl/wasm-pack" /usr/local/bin/wasm-pack wasm-pack --version + - name: Build wasm shim and run wasm-inline Result tests + working-directory: packages/stack-auth/node + # `test:integration:stack-auth` builds only the napi module, so the + # wasm-inline Result tests (`wasm-inline-result.test.ts`) self-skip there + # for lack of the inline shim — leaving the `wasm-inline.mjs` `toFailure` + # seam with no CI coverage. Build the shim now that wasm-pack is + # available and run those tests explicitly. + run: | + npm run build:wasm + npx vitest run __tests__/wasm-inline-result.test.ts + - name: cargo check stack-auth-wasm run: cargo check --target wasm32-unknown-unknown -p stack-auth-wasm From a43dfb00ff088d4057cec47680755f83acac4674 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Mon, 6 Jul 2026 14:58:53 +1000 Subject: [PATCH 346/686] test(stack-auth-node): guard the __CS_FAIL__ sentinel; clean up temp dir MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Code-review findings: (1) the __CS_FAIL__ sentinel is declared in both Rust and index.js with no drift guard — if they diverge, every napi failure is re-thrown as a panic; add failure_sentinel_matches_index_js to pin index.js against FAILURE_SENTINEL. (2) factory-result.test.ts's beforeEach mkdtempSync'd a dir that afterEach never removed; rmSync it. --- .../auth/__tests__/factory-result.test.ts | 7 +++++-- languages/typescript/packages/auth/src/lib.rs | 16 ++++++++++++++++ 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/languages/typescript/packages/auth/__tests__/factory-result.test.ts b/languages/typescript/packages/auth/__tests__/factory-result.test.ts index 930db629e..c78112539 100644 --- a/languages/typescript/packages/auth/__tests__/factory-result.test.ts +++ b/languages/typescript/packages/auth/__tests__/factory-result.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, beforeEach, afterEach } from "vitest"; -import { mkdtempSync } from "fs"; +import { mkdtempSync, rmSync } from "fs"; import { join } from "path"; import { tmpdir } from "os"; @@ -27,6 +27,7 @@ const SAVED_ENV_KEYS = [ "CS_CONFIG_PATH", ] as const; let savedEnv: Partial>; +let configDir: string; beforeEach(() => { savedEnv = {}; @@ -36,10 +37,12 @@ beforeEach(() => { } // Point the profile store at an empty temp dir so ambient ~/.cipherstash // state can't leak into detection. - process.env.CS_CONFIG_PATH = mkdtempSync(join(tmpdir(), "cs-auth-test-")); + configDir = mkdtempSync(join(tmpdir(), "cs-auth-test-")); + process.env.CS_CONFIG_PATH = configDir; }); afterEach(() => { + rmSync(configDir, { recursive: true, force: true }); for (const key of SAVED_ENV_KEYS) { if (savedEnv[key] === undefined) { delete process.env[key]; diff --git a/languages/typescript/packages/auth/src/lib.rs b/languages/typescript/packages/auth/src/lib.rs index 7af9b861f..dbd24cd2c 100644 --- a/languages/typescript/packages/auth/src/lib.rs +++ b/languages/typescript/packages/auth/src/lib.rs @@ -654,6 +654,22 @@ mod tests { } } + /// The `__CS_FAIL__` sentinel is declared in both Rust (`FAILURE_SENTINEL`) + /// and JS (`index.js`), and every napi domain error depends on the two + /// agreeing: `toFailure` only recognizes a failure whose message starts with + /// it, re-throwing anything else as a panic. If the two drift, every failure + /// silently becomes a thrown error and no other test catches it. Pin that + /// `index.js` declares exactly the Rust value. + #[test] + fn failure_sentinel_matches_index_js() { + let js = include_str!("../index.js"); + let expected = format!("const FAILURE_SENTINEL = \"{FAILURE_SENTINEL}\";"); + assert!( + js.contains(&expected), + "index.js must declare `{expected}` — the __CS_FAIL__ sentinel drifted from src/lib.rs", + ); + } + // --- Shared helpers --- fn device_code_json() -> serde_json::Value { From 7e5823686e058704ee3035cca6910402095f483f Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Mon, 6 Jul 2026 14:58:53 +1000 Subject: [PATCH 347/686] docs(stack-auth-node): fix cookies.d.ts example for the Result API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Code-review finding: the shipped @example used the pre-1.0 direct-return API (strategy.getToken() on the create() result), which is now a Result — a copy-paste would TypeError. Handle the Result arms and point at the SDK integration in the README. --- languages/typescript/packages/auth/cookies.d.ts | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/languages/typescript/packages/auth/cookies.d.ts b/languages/typescript/packages/auth/cookies.d.ts index 01408ea5e..42c3e07be 100644 --- a/languages/typescript/packages/auth/cookies.d.ts +++ b/languages/typescript/packages/auth/cookies.d.ts @@ -57,11 +57,19 @@ export interface CookieStoreOptions { * * Deno.serve(async (req) => { * const responseHeaders = new Headers(); - * const strategy = AccessKeyStrategy.create(workspaceCrn, accessKey, { + * const created = AccessKeyStrategy.create(workspaceCrn, accessKey, { * store: cookieStore({ request: req, responseHeaders }), * }); - * const result = await strategy.getToken(); - * return new Response(JSON.stringify(result), { headers: responseHeaders }); + * if (created.failure) { + * return new Response(created.failure.type, { status: 500, headers: responseHeaders }); + * } + * // Recommended: hand `created.data` to a CipherStash SDK (see the README). + * // Reading the token directly, as below, is a lower-level escape hatch. + * const result = await created.data.getToken(); + * if (result.failure) { + * return new Response(result.failure.type, { status: 500, headers: responseHeaders }); + * } + * return new Response(JSON.stringify(result.data), { headers: responseHeaders }); * }); * ``` */ From 207de99bbff7b20e2921798e78cec1090dce4169 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Mon, 6 Jul 2026 15:30:14 +1000 Subject: [PATCH 348/686] refactor(stack-auth): route DeviceClientError through AuthError; tidy payload MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two code-review cleanups: - #6: device_client_to_napi_error hand-built a second failure envelope and kept a parallel device_client_error_code table duplicating the core code mapping. Add From for AuthError (every non-Auth variant already has a canonical AuthError equivalent) so the function collapses to to_napi_error(err.into()) and the parallel table is deleted — one envelope, one code taxonomy. Non-Auth failures now carry AuthError's message wording (e.g. 'HTTP request failed' vs 'ZeroKMS request failed'); the .type code and underlying detail are unchanged, and tests assert only .type. - #10: WorkspaceMismatch::payload() round-tripped through serde_json::json! and matched it with an unreachable '_ => Map::new()' arm; build the Map directly. --- languages/typescript/packages/auth/src/lib.rs | 43 ++++++------------- packages/stack-auth/src/error.rs | 38 +++++++++++++--- 2 files changed, 44 insertions(+), 37 deletions(-) diff --git a/languages/typescript/packages/auth/src/lib.rs b/languages/typescript/packages/auth/src/lib.rs index dbd24cd2c..05b828d04 100644 --- a/languages/typescript/packages/auth/src/lib.rs +++ b/languages/typescript/packages/auth/src/lib.rs @@ -522,31 +522,11 @@ impl DeviceCodeResult { // Exported functions // --------------------------------------------------------------------------- -fn device_client_error_code(err: &DeviceClientError) -> &'static str { - match err { - DeviceClientError::Profile(_) => "STORE_ERROR", - DeviceClientError::Auth(auth_err) => auth_err.error_code(), - DeviceClientError::Request(_) => "REQUEST_ERROR", - DeviceClientError::Server { .. } => "SERVER_ERROR", - DeviceClientError::InvalidUrl(_) => "INVALID_URL", - } -} - fn device_client_to_napi_error(err: DeviceClientError) -> napi::Error { - // When it wraps an `AuthError`, defer to the canonical serialization so - // help/payload are preserved; otherwise synthesize a `{ type, message }` - // failure envelope behind the same sentinel. - match err { - DeviceClientError::Auth(auth_err) => to_napi_error(auth_err), - other => { - let json = serde_json::json!({ - "type": device_client_error_code(&other), - "message": other.to_string(), - }) - .to_string(); - napi::Error::new(Status::GenericFailure, format!("{FAILURE_SENTINEL}{json}")) - } - } + // Route through the canonical `AuthError` mapping (`From` + // in stack-auth) so the code/help/payload envelope comes from the one + // `to_napi_error` path rather than a parallel code table and hand-built blob. + to_napi_error(err.into()) } /// Provision a device client in ZeroKMS after login. @@ -864,11 +844,12 @@ mod tests { ); } - // `device_client_to_napi_error` splits on the variant: `Auth` must - // defer to the canonical `to_napi_error` serialization (help + - // structured payload preserved), while non-Auth variants synthesize - // the flat `{ type, message }` shape. Routing `Auth` through the - // synth arm would silently drop help/payload. + // `device_client_to_napi_error` routes every `DeviceClientError` through + // its canonical `AuthError` mapping. A help-carrying error (here an + // `Auth`-wrapped `WorkspaceMismatch`) must keep its help + structured + // payload; a help-less one (`Profile` -> `Store`) yields just + // type + message. A regression that dropped the canonical routing would + // lose the help/payload here. #[test] fn device_client_auth_arm_preserves_full_envelope() { let ws = |s: &str| s.parse::().unwrap(); @@ -887,7 +868,9 @@ mod tests { "Auth arm must carry help through the canonical envelope, got: {json}" ); - // Non-Auth variant still yields the flat synth shape. + // Non-Auth variant routes through `From` to the + // canonical `Store` error: same `STORE_ERROR` code, and no help + // (StoreError carries none). let err = device_client_to_napi_error(DeviceClientError::Profile( stack_profile::ProfileError::HomeDirNotFound, )); diff --git a/packages/stack-auth/src/error.rs b/packages/stack-auth/src/error.rs index 5fb08a44f..d7d23c98f 100644 --- a/packages/stack-auth/src/error.rs +++ b/packages/stack-auth/src/error.rs @@ -156,13 +156,18 @@ impl AuthErrorKind for WorkspaceMismatch { codes::WORKSPACE_MISMATCH } fn payload(&self) -> serde_json::Map { - match serde_json::json!({ - "expected": self.expected_workspace.to_string(), - "actual": self.token_workspace.to_string(), - }) { - serde_json::Value::Object(map) => map, - _ => serde_json::Map::new(), - } + [ + ( + "expected".to_string(), + self.expected_workspace.to_string().into(), + ), + ( + "actual".to_string(), + self.token_workspace.to_string().into(), + ), + ] + .into_iter() + .collect() } } @@ -493,6 +498,25 @@ impl From for AuthError { } } +#[cfg(not(target_arch = "wasm32"))] +impl From for AuthError { + fn from(e: crate::DeviceClientError) -> Self { + use crate::DeviceClientError as E; + match e { + // Every non-`Auth` variant has a canonical `AuthError` equivalent — + // route through it so `bind_client_device` failures carry the same + // code/help/payload as every other path. `Auth` already is one. + E::Profile(e) => e.into(), + E::Auth(e) => e, + E::Request(e) => e.into(), + E::InvalidUrl(e) => e.into(), + E::Server { status, body } => { + Self::Server(ServerError(format!("ZeroKMS returned {status}: {body}"))) + } + } + } +} + impl From for AuthError { fn from(never: Infallible) -> Self { match never {} From 2442b4714a25b2e14fc641896d8ff5b6adac1bde Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Mon, 6 Jul 2026 15:35:02 +1000 Subject: [PATCH 349/686] chore(stack-auth-node): bundle the LICENSE in the published package Review comment: the README's license link pointed at the private repo, unreachable from npmjs.org where the README renders, and no license shipped in the tarball. Bundle a copy of the crate's PolyForm Internal Use License as node/LICENSE (added to 'files'), set package.json 'license' to 'SEE LICENSE IN LICENSE', point the README at the public PolyForm URL plus the bundled copy, and guard the tarball entry with an npm-pack test. --- languages/typescript/packages/auth/LICENSE | 96 +++++++++++++++++++ languages/typescript/packages/auth/README.md | 2 +- .../auth/__tests__/consumer-typecheck.test.ts | 14 +++ .../typescript/packages/auth/package.json | 2 + 4 files changed, 113 insertions(+), 1 deletion(-) create mode 100644 languages/typescript/packages/auth/LICENSE diff --git a/languages/typescript/packages/auth/LICENSE b/languages/typescript/packages/auth/LICENSE new file mode 100644 index 000000000..2cbd67a66 --- /dev/null +++ b/languages/typescript/packages/auth/LICENSE @@ -0,0 +1,96 @@ +# PolyForm Internal Use License 1.0.0 + + + +## Acceptance + +In order to get any license under these terms, you must agree +to them as both strict obligations and conditions to all +your licenses. + +## Copyright License + +The licensor grants you a copyright license for the software +to do everything you might do with the software that would +otherwise infringe the licensor's copyright in it for any +permitted purpose. However, you may only make changes or +new works based on the software according to [Changes and New +Works License](#changes-and-new-works-license), and you may +not distribute the software. + +## Changes and New Works License + +The licensor grants you an additional copyright license to +make changes and new works based on the software for any +permitted purpose. + +## Patent License + +The licensor grants you a patent license for the software that +covers patent claims the licensor can license, or becomes able +to license, that you would infringe by using the software. + +## Fair Use + +You may have "fair use" rights for the software under the +law. These terms do not limit them. + +## Internal Business Use + +Use of the software for the internal business operations of +you and your company is use for a permitted purpose. + +## No Other Rights + +These terms do not allow you to sublicense or transfer any of +your licenses to anyone else, or prevent the licensor from +granting licenses to anyone else. These terms do not imply +any other licenses. + +## Patent Defense + +If you make any written claim that the software infringes or +contributes to infringement of any patent, your patent license +for the software granted under these terms ends immediately. If +your company makes such a claim, your patent license ends +immediately for work on behalf of your company. + +## Violations + +The first time you are notified in writing that you have +violated any of these terms, or done anything with the software +not covered by your licenses, your licenses can nonetheless +continue if you come into full compliance with these terms, +and take practical steps to correct past violations, within +32 days of receiving notice. Otherwise, all your licenses +end immediately. + +## No Liability + +***As far as the law allows, the software comes as is, without +any warranty or condition, and the licensor will not be liable +to you for any damages arising out of these terms or the use +or nature of the software, under any kind of legal claim.*** + +## Definitions + +The **licensor** is the individual or entity offering these +terms, and the **software** is the software the licensor makes +available under these terms. + +**You** refers to the individual or entity agreeing to these +terms. + +**Your company** is any legal entity, sole proprietorship, +or other kind of organization that you work for, plus all +organizations that have control over, are under the control of, +or are under common control with that organization. **Control** +means ownership of substantially all the assets of an entity, +or the power to direct its management and policies by vote, +contract, or otherwise. Control can be direct or indirect. + +**Your licenses** are all the licenses granted to you for the +software under these terms. + +**Use** means anything you do with the software requiring one +of your licenses. diff --git a/languages/typescript/packages/auth/README.md b/languages/typescript/packages/auth/README.md index 1a905ba35..b5903124c 100644 --- a/languages/typescript/packages/auth/README.md +++ b/languages/typescript/packages/auth/README.md @@ -302,4 +302,4 @@ const { token, workspaceId, services } = result.data; ## License -See [LICENSE](https://github.com/cipherstash/cipherstash-suite/blob/main/packages/stack-auth/LICENSE). +Distributed under the [PolyForm Internal Use License 1.0.0](https://polyformproject.org/licenses/internal-use/1.0.0). A full copy is bundled with this package as [`LICENSE`](./LICENSE). diff --git a/languages/typescript/packages/auth/__tests__/consumer-typecheck.test.ts b/languages/typescript/packages/auth/__tests__/consumer-typecheck.test.ts index e28031d4c..05b7fe455 100644 --- a/languages/typescript/packages/auth/__tests__/consumer-typecheck.test.ts +++ b/languages/typescript/packages/auth/__tests__/consumer-typecheck.test.ts @@ -177,4 +177,18 @@ describe("publish contract (npm pack)", () => { expect.arrayContaining(["index.d.ts", "native.d.ts"]), ); }); + + it("bundles the LICENSE (README links to it; repo URL is private)", () => { + // The README's license link points at the private repo, unreachable from + // npmjs.org — so a copy must ride in the tarball. Guard the `files` entry. + const out = execFileSync("npm", ["pack", "--json", "--dry-run"], { + cwd: packageDir, + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + }); + const packed = ( + JSON.parse(out) as Array<{ files: Array<{ path: string }> }> + )[0].files.map((f) => f.path); + expect(packed).toContain("LICENSE"); + }); }); diff --git a/languages/typescript/packages/auth/package.json b/languages/typescript/packages/auth/package.json index 599997e49..0f113dbb2 100644 --- a/languages/typescript/packages/auth/package.json +++ b/languages/typescript/packages/auth/package.json @@ -1,6 +1,7 @@ { "name": "@cipherstash/auth", "version": "0.41.0", + "license": "SEE LICENSE IN LICENSE", "main": "index.js", "types": "index.d.ts", "browser": false, @@ -47,6 +48,7 @@ "index.d.ts", "native.d.ts", "README.md", + "LICENSE", "stack-auth-node.js", "wasm-types.d.ts", "wasm-inline.mjs", From fcd6e0b39f8b5d12db345a7fe0ed161b94d57f29 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Mon, 6 Jul 2026 15:57:01 +1000 Subject: [PATCH 350/686] test(stack-auth): cover From for the CRAP gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The new From for AuthError (5-arm match, CC=6) was exercised only by the stack-auth-node crate's test, but the crap:stack-auth gate measures the core crate's own coverage (cargo llvm-cov -p stack-auth) — so it read 0% covered and scored 42, over the 30 threshold. Add a core-crate test asserting each constructable variant (Auth/Profile/InvalidUrl/Server) maps to its canonical code. Request wraps a reqwest::Error with no public constructor — the same gap the exhaustive error_code test documents. CRAP gate passes locally. --- packages/stack-auth/src/error.rs | 36 ++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/packages/stack-auth/src/error.rs b/packages/stack-auth/src/error.rs index d7d23c98f..e9d36bf18 100644 --- a/packages/stack-auth/src/error.rs +++ b/packages/stack-auth/src/error.rs @@ -560,4 +560,40 @@ mod tests { assert!(json.get("expected").is_none()); assert!(json.get("actual").is_none()); } + + /// Every constructable `DeviceClientError` variant maps to its canonical + /// `AuthError` code so `bind_client_device` failures share the one envelope + /// path. (`Request` wraps a `reqwest::Error`, which has no public + /// constructor, so it can't be built here — the same gap the exhaustive + /// `error_code` test documents.) + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn device_client_error_maps_to_canonical_auth_error() { + use crate::DeviceClientError as E; + + // `Auth` unwraps to the inner error unchanged. + assert_eq!( + AuthError::from(E::Auth(AuthError::AccessDenied(AccessDenied))).error_code(), + codes::ACCESS_DENIED, + ); + // Non-`Auth` variants route to their canonical `AuthError` equivalent. + assert_eq!( + AuthError::from(E::Profile(stack_profile::ProfileError::HomeDirNotFound)).error_code(), + codes::STORE_ERROR, + ); + assert_eq!( + AuthError::from(E::InvalidUrl("not a url".parse::().unwrap_err())) + .error_code(), + codes::INVALID_URL, + ); + let server = AuthError::from(E::Server { + status: 500, + body: "boom".to_string(), + }); + assert_eq!(server.error_code(), codes::SERVER_ERROR); + assert!( + server.to_string().contains("ZeroKMS returned 500: boom"), + "server error should preserve the status/body detail: {server}" + ); + } } From 8ed5d0ab735cb07f46620fa716006d4d1d6db183 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Mon, 6 Jul 2026 16:14:48 +1000 Subject: [PATCH 351/686] docs(stack-auth-node): fix wasm-inline.mjs JSDoc for the Result API Copilot review: create() JSDoc still claimed '@returns {AccessKeyStrategy}' / '{OidcFederationStrategy}', but both now return a Result; and the OidcFederationStrategyOptions typedef omitted 'baseUrl', which create() reads and wasm-inline.d.ts already declares. Align the JSDoc with the runtime and the .d.ts. --- languages/typescript/packages/auth/wasm-inline.mjs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/languages/typescript/packages/auth/wasm-inline.mjs b/languages/typescript/packages/auth/wasm-inline.mjs index 22dd62e1c..0e7acab63 100644 --- a/languages/typescript/packages/auth/wasm-inline.mjs +++ b/languages/typescript/packages/auth/wasm-inline.mjs @@ -15,7 +15,7 @@ import { /** @typedef {{ load(): Promise; save(json: string): Promise }} TokenStore */ /** @typedef {{ store?: TokenStore }} AccessKeyStrategyOptions */ /** @typedef {() => string | Promise} OidcProvider */ -/** @typedef {{ store?: TokenStore }} OidcFederationStrategyOptions */ +/** @typedef {{ store?: TokenStore; baseUrl?: string }} OidcFederationStrategyOptions */ // Convert a thrown/rejected wasm error into a `Result` `failure`. The wasm // binding attaches the serialized `AuthError` as an `__authFailure` object on @@ -55,7 +55,7 @@ export class AccessKeyStrategy { * @param {string} workspaceCrn * @param {string} accessKey * @param {AccessKeyStrategyOptions} [options] - * @returns {AccessKeyStrategy} + * @returns {import("@byteslice/result").Result} */ static create(workspaceCrn, accessKey, options) { try { @@ -111,7 +111,7 @@ export class OidcFederationStrategy { * @param {string} workspaceCrn * @param {OidcProvider} getJwt * @param {OidcFederationStrategyOptions} [options] - * @returns {OidcFederationStrategy} + * @returns {import("@byteslice/result").Result} */ static create(workspaceCrn, getJwt, options) { try { From 1fe2c1458f32f3a253129842a4e62c9402837796 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Mon, 6 Jul 2026 16:36:50 +1000 Subject: [PATCH 352/686] test(stack-auth-node): cover WORKSPACE_MISMATCH payload end-to-end MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit James's review (#1): the WORKSPACE_MISMATCH expected/actual payload — the flagship structured payload — was guarded by neither the drift test nor any JS test, so a serde key rename or a ...payload spread regression would drop failure.expected/.actual at the JS boundary while the suite stayed green. - Add a napi JS test driving WORKSPACE_MISMATCH through the wrapper (mock authorise mints a token whose workspace claim differs from the CRN) and asserting failure.expected/.actual. Teeth-checked: dropping the ...payload spread fails it with 'expected undefined'. - Add mockAuthorizeEndpointWithWorkspace to the mock server. - Extend the drift test to pin 'expected: string; actual: string' on the WORKSPACE_MISMATCH union member in both .d.ts files. --- .../auth/__tests__/helpers/mock-cts-server.ts | 19 +++++++++++++++ .../oidc-federation-strategy.test.ts | 23 +++++++++++++++++++ languages/typescript/packages/auth/src/lib.rs | 12 ++++++++++ 3 files changed, 54 insertions(+) diff --git a/languages/typescript/packages/auth/__tests__/helpers/mock-cts-server.ts b/languages/typescript/packages/auth/__tests__/helpers/mock-cts-server.ts index e309fb621..2151ad49b 100644 --- a/languages/typescript/packages/auth/__tests__/helpers/mock-cts-server.ts +++ b/languages/typescript/packages/auth/__tests__/helpers/mock-cts-server.ts @@ -138,6 +138,25 @@ export class MockCtsServer { }); } + /** + * Like {@link mockAuthorizeEndpoint}, but mints the federated token with a + * `workspace` claim of `workspace` — pass one that differs from the strategy's + * CRN to make `getToken()` fail workspace verification with + * `WORKSPACE_MISMATCH`. + */ + mockAuthorizeEndpointWithWorkspace(workspace: string, expiry = 3600): void { + this.#on("POST", "/api/authorise", () => { + const exp = Math.floor(Date.now() / 1000) + expiry; + return { + status: 200, + json: { + accessToken: mintJwt({ exp, workspace }), + expiry: exp, + }, + }; + }); + } + mockAuthorizeEndpointError(): void { this.#on("POST", "/api/authorise", () => ({ status: 500, diff --git a/languages/typescript/packages/auth/__tests__/oidc-federation-strategy.test.ts b/languages/typescript/packages/auth/__tests__/oidc-federation-strategy.test.ts index e4b795016..69eed3f26 100644 --- a/languages/typescript/packages/auth/__tests__/oidc-federation-strategy.test.ts +++ b/languages/typescript/packages/auth/__tests__/oidc-federation-strategy.test.ts @@ -91,6 +91,29 @@ describe("OidcFederationStrategy (TypeScript / vitest)", () => { expect(jwt.calls()).toBe(1); }); + it("surfaces WORKSPACE_MISMATCH with the expected/actual payload", async () => { + // The federated token carries a different workspace than the strategy's CRN, + // so workspace verification fails. This is the flagship structured-payload + // failure — it exercises the `...payload` spread end to end (the named + // help/url destructure doesn't), so it guards `failure.expected`/`.actual` + // against a serde key rename or a spread regression at the JS boundary. + const MISMATCHED_WORKSPACE = "AAAAAAAAAAAAAAAA"; + server.mockAuthorizeEndpointWithWorkspace(MISMATCHED_WORKSPACE); + const strategy = mustCreate(WORKSPACE_CRN, countingJwt().getJwt); + + const r = await strategy.getToken(); + if (!r.failure) { + expect.unreachable("expected a WORKSPACE_MISMATCH failure"); + } + const { failure } = r; + if (failure.type !== "WORKSPACE_MISMATCH") { + expect.unreachable(`expected WORKSPACE_MISMATCH, got ${failure.type}`); + } + expect(failure.expected).toBe(WORKSPACE_ID); + expect(failure.actual).toBe(MISMATCHED_WORKSPACE); + expect(failure.error).toBeInstanceOf(Error); + }); + it("re-federates after the cached token expires", async () => { // expiry 0 → the federated token is immediately expired, so the second // getToken() must re-federate rather than serve a cached token. diff --git a/languages/typescript/packages/auth/src/lib.rs b/languages/typescript/packages/auth/src/lib.rs index 05b828d04..25069ee0c 100644 --- a/languages/typescript/packages/auth/src/lib.rs +++ b/languages/typescript/packages/auth/src/lib.rs @@ -606,6 +606,18 @@ mod tests { union, expected, "AuthFailure union in {name} drifted from AuthError::ERROR_CODES", ); + + // The tag scrape above only checks `type` literals. `WORKSPACE_MISMATCH` + // is the one variant carrying a structured payload (`WorkspaceMismatch::payload` + // in error.rs emits `expected`/`actual`), so pin those field names in the + // union too — a serde key rename or a dropped `.d.ts` field would otherwise + // leave the declared shape silently lying. A JS runtime test + // (`oidc-federation-strategy.test.ts`) drives it end-to-end through the + // `...payload` spread. + assert!( + dts.contains("type: \"WORKSPACE_MISMATCH\"; expected: string; actual: string"), + "{name}: WORKSPACE_MISMATCH union member must declare `expected: string; actual: string`", + ); } } From 417dc898bdfee0a26b8a1b3648de97ea282c89f2 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Mon, 6 Jul 2026 16:36:51 +1000 Subject: [PATCH 353/686] fix(stack-auth-node): guard wasm-inline getToken against a synchronous throw MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit James's review (#2): index.js's wrapAsync wraps native calls in try/catch so a sync throw becomes a rejection, but wasm-inline.mjs's getToken did inner.getToken().then(...) with no guard — a sync throw (e.g. use-after-free after free()) escaped synchronously, diverging from napi for the same declared signature. Mirror the guard via a shared settleGetToken helper. --- .../typescript/packages/auth/wasm-inline.mjs | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/languages/typescript/packages/auth/wasm-inline.mjs b/languages/typescript/packages/auth/wasm-inline.mjs index 0e7acab63..5d0b0388b 100644 --- a/languages/typescript/packages/auth/wasm-inline.mjs +++ b/languages/typescript/packages/auth/wasm-inline.mjs @@ -43,6 +43,17 @@ function toFailure(err) { return { failure }; } +// Mirror index.js's `wrapAsync`: a synchronous throw from the inner `getToken` +// (e.g. calling it after `free()` — "null pointer passed to rust") becomes a +// rejection, so a Promise-returning method never throws synchronously. +function settleGetToken(inner) { + try { + return inner.getToken().then((data) => ({ data }), toFailure); + } catch (err) { + return Promise.reject(err); + } +} + export class AccessKeyStrategy { #inner; @@ -91,7 +102,7 @@ export class AccessKeyStrategy { /** @returns {Promise} */ getToken() { - return this.#inner.getToken().then((data) => ({ data }), toFailure); + return settleGetToken(this.#inner); } free() { @@ -149,7 +160,7 @@ export class OidcFederationStrategy { /** @returns {Promise} */ getToken() { - return this.#inner.getToken().then((data) => ({ data }), toFailure); + return settleGetToken(this.#inner); } free() { From c7d13c312346b2bac3525463db7e2210a999ab83 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Mon, 6 Jul 2026 16:36:51 +1000 Subject: [PATCH 354/686] fix(stack-auth): harden failure envelope against parse + key collision MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit James's review: - #3: index.js toFailure JSON.parsed the post-sentinel tail unguarded — a malformed tail would surface as an opaque SyntaxError. Wrap it so a parse failure re-throws the original error (latent; the Rust producer always emits valid JSON). - #4: AuthError::serialize emitted the fixed type/message/help/url before the per-variant payload, so a future payload key could override a diagnostic field (serde last-write-wins). Emit payload first, fixed fields last — the diagnostic field wins, mirroring the JS { ...payload, type, error }. Tighten the JS comment accordingly. --- languages/typescript/packages/auth/index.js | 17 ++++++++++++----- packages/stack-auth/src/error.rs | 10 +++++++--- 2 files changed, 19 insertions(+), 8 deletions(-) diff --git a/languages/typescript/packages/auth/index.js b/languages/typescript/packages/auth/index.js index e50e303c5..84878dae5 100644 --- a/languages/typescript/packages/auth/index.js +++ b/languages/typescript/packages/auth/index.js @@ -23,13 +23,20 @@ function toFailure(err) { if (!(err instanceof Error) || !err.message.startsWith(FAILURE_SENTINEL)) { throw err; } - const { type, message, help, url, ...payload } = JSON.parse( - err.message.slice(FAILURE_SENTINEL.length), - ); + let envelope; + try { + envelope = JSON.parse(err.message.slice(FAILURE_SENTINEL.length)); + } catch { + // Sentinel present but the tail isn't valid JSON. Can't happen with the + // current Rust producer (always emits valid JSON), but if it ever did, + // don't mask the real failure as an opaque SyntaxError — re-throw it. + throw err; + } + const { type, message, help, url, ...payload } = envelope; err.message = message; err.code = type; - // Spread payload first so the fixed `type`/`error` keys always win, even if a - // future payload field collides with one of them. + // Spread payload first so the fixed `type`/`error` keys — and the `help`/`url` + // re-asserted below — always win over a colliding payload key. const failure = { ...payload, type, error: err }; if (help !== undefined) { err.help = help; diff --git a/packages/stack-auth/src/error.rs b/packages/stack-auth/src/error.rs index e9d36bf18..fbac8c626 100644 --- a/packages/stack-auth/src/error.rs +++ b/packages/stack-auth/src/error.rs @@ -435,6 +435,13 @@ impl serde::Serialize for AuthError { let kind = self.kind(); let mut map = serializer.serialize_map(None)?; + // Emit the per-variant payload first, then the fixed diagnostic fields — + // so if a future `payload()` key ever collided with `type`/`message`/ + // `help`/`url`, the diagnostic field (written last) wins rather than being + // clobbered. Mirrors the JS side's `{ ...payload, type, error }`. + for (key, value) in kind.payload() { + map.serialize_entry(&key, &value)?; + } map.serialize_entry("type", kind.error_code())?; map.serialize_entry("message", &self.to_string())?; if let Some(help) = self.help() { @@ -443,9 +450,6 @@ impl serde::Serialize for AuthError { if let Some(url) = self.url() { map.serialize_entry("url", &url.to_string())?; } - for (key, value) in kind.payload() { - map.serialize_entry(&key, &value)?; - } map.end() } } From 3e1a204497aa2657c09428544a4df4f8cf0b8192 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Mon, 6 Jul 2026 16:36:51 +1000 Subject: [PATCH 355/686] docs(stack-auth-node): note the instanceof break in the changelog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit James's review (#5): instanceof on AutoStrategy/AccessKeyStrategy/ DeviceSessionStrategy now returns false (facade classes; factories return the native instance via .data). An acceptable 0.41.0 break, but worth a migration note — added to the CHANGELOG breaking-changes section. --- languages/typescript/packages/auth/CHANGELOG.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/languages/typescript/packages/auth/CHANGELOG.md b/languages/typescript/packages/auth/CHANGELOG.md index 0245b6057..bf3cd64d3 100644 --- a/languages/typescript/packages/auth/CHANGELOG.md +++ b/languages/typescript/packages/auth/CHANGELOG.md @@ -44,6 +44,13 @@ - Adds a runtime dependency on `@byteslice/result` (zero-dependency, MIT). +- **`instanceof` on the strategy classes now returns `false`.** The exported + `AutoStrategy` / `AccessKeyStrategy` / `DeviceSessionStrategy` are thin facades + over the native classes, and the factories hand back the strategy inside + `result.data`, so `result.data instanceof AccessKeyStrategy` is now `false` (it + was `true` on `main`, when the factory returned the instance directly). Gate on + `result.failure` and use `result.data` rather than `instanceof`. + ## 0.40.0 ### New Features From eba62df4518bf813a5493d2361b64c9d8412dc38 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Mon, 6 Jul 2026 17:42:15 +1000 Subject: [PATCH 356/686] chore(stack-auth-node): drive the 0.41.0 release via changesets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cipherstash/cipherstash-suite#2062 predates the changesets adoption (cipherstash/cipherstash-suite#2058) and hand-baked the release into the branch. Under the new auto-publish wiring a version bump on main triggers publish-auth-npm.yml, so merging with 0.41.0 already in package.json would publish immediately and bypass the Version Packages PR. Convert to the changeset flow instead: - revert package.json version + platform pins to 0.40.0 (last published; the publish workflow stamps pins at publish time) - drop the hand-written 0.41.0 CHANGELOG section (changesets regenerates it) - add a `minor` @cipherstash/auth changeset carrying the breaking-change notes + migration guide (→ 0.41.0) - re-sync the root workspace lockfile (adds the new @byteslice/result dep) Now merging cipherstash/cipherstash-suite#2062 is a no-op for publishing (0.40.0 is already on npm); the Version Packages PR cuts 0.41.0, and merging that publishes it once. --- .../typescript/packages/auth/CHANGELOG.md | 51 ------------------- .../typescript/packages/auth/package.json | 14 ++--- 2 files changed, 7 insertions(+), 58 deletions(-) diff --git a/languages/typescript/packages/auth/CHANGELOG.md b/languages/typescript/packages/auth/CHANGELOG.md index bf3cd64d3..2c7211b6f 100644 --- a/languages/typescript/packages/auth/CHANGELOG.md +++ b/languages/typescript/packages/auth/CHANGELOG.md @@ -1,56 +1,5 @@ # Changelog -## 0.41.0 - -### Breaking Changes - -- **Errors are now returned, not thrown.** Every fallible operation returns a - [`@byteslice/result`](https://www.npmjs.com/package/@byteslice/result) - `Result` — `{ data }` on success, `{ failure }` on a domain error — instead of - throwing. This applies to `getToken()`, the strategy factories - (`AccessKeyStrategy.create`, `AutoStrategy.detect`, - `DeviceSessionStrategy.fromProfile`, `OidcFederationStrategy.create` / - `.createWithStore`), `beginDeviceCodeFlow`, `DeviceCodeResult.pollForToken` / - `openInBrowser`, and `bindClientDevice`. The same applies to the - `@cipherstash/auth/wasm-inline` entry. - - `failure` is a discriminated union (`AuthFailure`) tagged by `type` (the codes - formerly on `err.code`), carrying the live `error: Error`, optional - `help`/`url`, and per-variant payload (e.g. `WORKSPACE_MISMATCH`'s `expected` - / `actual`). Only a genuine internal panic still throws. - - Migration: - - ```ts - // before - try { - const { token } = await strategy.getToken(); - } catch (err) { - if (err.code === "EXPIRED_TOKEN") { /* … */ } - } - - // after - const result = await strategy.getToken(); - if (result.failure) { - if (result.failure.type === "EXPIRED_TOKEN") { /* … */ } - } else { - const { token } = result.data; - } - ``` - - Two new failure `type`s surface caller/runtime states that previously threw - as bare errors: `ALREADY_CONSUMED` (reusing a consumed `DeviceCodeResult` - handle) and `INTERNAL_ERROR`. - -- Adds a runtime dependency on `@byteslice/result` (zero-dependency, MIT). - -- **`instanceof` on the strategy classes now returns `false`.** The exported - `AutoStrategy` / `AccessKeyStrategy` / `DeviceSessionStrategy` are thin facades - over the native classes, and the factories hand back the strategy inside - `result.data`, so `result.data instanceof AccessKeyStrategy` is now `false` (it - was `true` on `main`, when the factory returned the instance directly). Gate on - `result.failure` and use `result.data` rather than `instanceof`. - ## 0.40.0 ### New Features diff --git a/languages/typescript/packages/auth/package.json b/languages/typescript/packages/auth/package.json index 0f113dbb2..2b3d20e88 100644 --- a/languages/typescript/packages/auth/package.json +++ b/languages/typescript/packages/auth/package.json @@ -1,6 +1,6 @@ { "name": "@cipherstash/auth", - "version": "0.41.0", + "version": "0.40.0", "license": "SEE LICENSE IN LICENSE", "main": "index.js", "types": "index.d.ts", @@ -66,12 +66,12 @@ "format:check": "npx --yes @biomejs/biome@2.3.4 ci ." }, "peerDependencies": { - "@cipherstash/auth-darwin-x64": "0.41.0", - "@cipherstash/auth-darwin-arm64": "0.41.0", - "@cipherstash/auth-linux-x64-gnu": "0.41.0", - "@cipherstash/auth-linux-arm64-gnu": "0.41.0", - "@cipherstash/auth-linux-x64-musl": "0.41.0", - "@cipherstash/auth-win32-x64-msvc": "0.41.0" + "@cipherstash/auth-darwin-x64": "0.40.0", + "@cipherstash/auth-darwin-arm64": "0.40.0", + "@cipherstash/auth-linux-x64-gnu": "0.40.0", + "@cipherstash/auth-linux-arm64-gnu": "0.40.0", + "@cipherstash/auth-linux-x64-musl": "0.40.0", + "@cipherstash/auth-win32-x64-msvc": "0.40.0" }, "peerDependenciesMeta": { "@cipherstash/auth-darwin-x64": { From 5b97b955f78d3f92fbdd3c7ba60ac11bfa83e0ae Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 6 Jul 2026 07:50:05 +0000 Subject: [PATCH 357/686] chore: release --- packages/stack-auth/CHANGELOG.md | 55 +++++++++++++++++++++++++++++ packages/stack-auth/Cargo.toml | 2 +- packages/stack-profile/CHANGELOG.md | 1 + packages/stack-profile/Cargo.toml | 2 +- 4 files changed, 58 insertions(+), 2 deletions(-) diff --git a/packages/stack-auth/CHANGELOG.md b/packages/stack-auth/CHANGELOG.md index 9b6cfa5fe..9b74a5160 100644 --- a/packages/stack-auth/CHANGELOG.md +++ b/packages/stack-auth/CHANGELOG.md @@ -1,5 +1,60 @@ +### Documentation + +- document the typed-error / diagnostic-help contract +- recommend passing the strategy to an SDK, not getToken +- fix cookies.d.ts example for the Result API +- fix wasm-inline.mjs JSDoc for the Result API +- note the instanceof break in the changelog + +### Features + +- add actionable miette help to AuthError variants +- serialize AuthError across the FFI boundary +- return a Result instead of throwing + +### Fixes + +- resolve doc + CRAP CI gates on error.rs +- wrap napi static factories via facade classes +- update index.d.ts guard for the Result-typed surface +- mirror help/url onto failure.error on the wasm seam +- always brand the JS error with __authFailure +- guard wasm-inline getToken against a synchronous throw +- harden failure envelope against parse + key collision + +### Miscellaneous + +- bump vite in /packages/stack-auth/node +- make changesets adoption review-ready (CIP-3278) +- release as 0.41.0, not 1.0.0 +- bundle the LICENSE in the published package +- drive the 0.41.0 release via changesets + +### Refactoring + +- hand-written index.d.ts re-exporter, drop apply-dts script +- derive .d.ts drift set from AuthError::ERROR_CODES +- decompose AuthError into per-error structs +- adopt AuthError::ERROR_CODES for the AuthFailure drift guards +- define AuthError codes as named constants +- route DeviceClientError through AuthError; tidy payload + +### Testing + +- port re-lock-window cancellation regression test (CIP-3159) +- derive drift-test expected set from error_code() source +- cover all #[diagnostic(help)] variants, not just one +- behavioural guards for the index.d.ts split, not source-text checks +- close coverage gaps in the index.d.ts split guards +- migrate tests, examples and docs to Result; v1.0.0 +- close FFI-envelope coverage gaps from PR review +- guard the __CS_FAIL__ sentinel; clean up temp dir +- cover From for the CRAP gate +- cover WORKSPACE_MISMATCH payload end-to-end + + ### CI diff --git a/packages/stack-auth/Cargo.toml b/packages/stack-auth/Cargo.toml index 20d96059b..0f5363a1a 100644 --- a/packages/stack-auth/Cargo.toml +++ b/packages/stack-auth/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "stack-auth" description = "Authentication library for CipherStash services" -version = "0.38.1" +version = "0.39.0" edition.workspace = true authors.workspace = true repository.workspace = true diff --git a/packages/stack-profile/CHANGELOG.md b/packages/stack-profile/CHANGELOG.md index 39842f0e2..102e9f3bb 100644 --- a/packages/stack-profile/CHANGELOG.md +++ b/packages/stack-profile/CHANGELOG.md @@ -35,6 +35,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - fix stale 0.34.0-alpha.1 changelog headers + ## [0.34.0] - 2026-03-04 ### Changed diff --git a/packages/stack-profile/Cargo.toml b/packages/stack-profile/Cargo.toml index f765045e6..fdbf51818 100644 --- a/packages/stack-profile/Cargo.toml +++ b/packages/stack-profile/Cargo.toml @@ -2,7 +2,7 @@ name = "stack-profile" description = "Centralised ~/.cipherstash profile file management" license-file = "LICENSE" -version = "0.38.1" +version = "0.39.0" edition.workspace = true authors.workspace = true repository.workspace = true From e386ac9edf6a3948603f49848034c1285da8f8f2 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Mon, 6 Jul 2026 18:09:21 +1000 Subject: [PATCH 358/686] ci(release): create Version Packages commit via GitHub API so it's signed The changesets action committed the "Version Packages" PR with a local `git commit`, which is unsigned and fails main's "Commits must have verified signatures" branch protection (PR cipherstash/cipherstash-suite#2071 was blocked on this). release-plz's release commits pass because it writes them through the GitHub API, which GitHub signs. Set `commitMode: github-api` so changesets does the same (matches cipherstash/stack's release.yml), and pin the action to v1.8.0 so the mode is guaranteed available. On the next run this also rewrites the existing Version Packages PR commit as a verified one. --- .github/imported-workflows/release-npm.yml | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/.github/imported-workflows/release-npm.yml b/.github/imported-workflows/release-npm.yml index ab49c6a1e..818789fb6 100644 --- a/.github/imported-workflows/release-npm.yml +++ b/.github/imported-workflows/release-npm.yml @@ -51,7 +51,7 @@ jobs: run: npm ci --no-workspaces --ignore-scripts - name: Create Version Packages PR - uses: changesets/action@v1 + uses: changesets/action@v1.8.0 with: # `version-packages` (root package.json) runs `changeset version` to # regenerate package.json + CHANGELOG from .changeset/*.md, then @@ -62,6 +62,13 @@ jobs: # No `publish:` on purpose — publishing is the matrix workflows' job # (see header comment). version: npm run version-packages + # Create the Version Packages commit through the GitHub API instead of + # a local `git commit`. API-created commits are signed by GitHub, so + # they satisfy the "Commits must have verified signatures" branch + # protection on `main` (a local commit from the action is unsigned and + # blocks the merge). This is how release-plz's release commits pass, + # and matches cipherstash/stack's release.yml. + commitMode: github-api title: "Release: version @cipherstash npm packages" commit: "chore(release): version @cipherstash npm packages" env: From daa05a954733e315d69f9cdac9dc702ff9062580 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 6 Jul 2026 08:14:34 +0000 Subject: [PATCH 359/686] chore(release): version @cipherstash npm packages --- .../typescript/packages/auth/CHANGELOG.md | 90 ++++++++++++++++--- .../typescript/packages/auth/package.json | 2 +- 2 files changed, 81 insertions(+), 11 deletions(-) diff --git a/languages/typescript/packages/auth/CHANGELOG.md b/languages/typescript/packages/auth/CHANGELOG.md index 2c7211b6f..95aaf39d4 100644 --- a/languages/typescript/packages/auth/CHANGELOG.md +++ b/languages/typescript/packages/auth/CHANGELOG.md @@ -1,5 +1,60 @@ # Changelog +## 0.41.0 + +### Minor Changes + +- 28fc5b1: **Errors are now returned, not thrown.** Every fallible operation returns a + [`@byteslice/result`](https://www.npmjs.com/package/@byteslice/result) + `Result` — `{ data }` on success, `{ failure }` on a domain error — instead of + throwing. This applies to `getToken()`, the strategy factories + (`AccessKeyStrategy.create`, `AutoStrategy.detect`, + `DeviceSessionStrategy.fromProfile`, `OidcFederationStrategy.create` / + `.createWithStore`), `beginDeviceCodeFlow`, `DeviceCodeResult.pollForToken` / + `openInBrowser`, and `bindClientDevice`. The same applies to the + `@cipherstash/auth/wasm-inline` entry. + + `failure` is a discriminated union (`AuthFailure`) tagged by `type` (the codes + formerly on `err.code`), carrying the live `error: Error`, optional + `help`/`url`, and per-variant payload (e.g. `WORKSPACE_MISMATCH`'s `expected` + / `actual`). Only a genuine internal panic still throws. + + Migration: + + ```ts + // before + try { + const { token } = await strategy.getToken(); + } catch (err) { + if (err.code === "EXPIRED_TOKEN") { + /* … */ + } + } + + // after + const result = await strategy.getToken(); + if (result.failure) { + if (result.failure.type === "EXPIRED_TOKEN") { + /* … */ + } + } else { + const { token } = result.data; + } + ``` + + Two new failure `type`s surface caller/runtime states that previously threw + as bare errors: `ALREADY_CONSUMED` (reusing a consumed `DeviceCodeResult` + handle) and `INTERNAL_ERROR`. + + Adds a runtime dependency on `@byteslice/result` (zero-dependency, MIT). + + **`instanceof` on the strategy classes now returns `false`.** The exported + `AutoStrategy` / `AccessKeyStrategy` / `DeviceSessionStrategy` are thin facades + over the native classes, and the factories hand back the strategy inside + `result.data`, so `result.data instanceof AccessKeyStrategy` is now `false` (it + was `true` on `main`, when the factory returned the instance directly). Gate on + `result.failure` and use `result.data` rather than `instanceof`. + ## 0.40.0 ### New Features @@ -11,8 +66,11 @@ ```ts OidcFederationStrategy.createWithStore( - workspaceCrn, getJwt, loadToken, saveToken, - "http://localhost:4000", // baseUrl — federate against a mock / self-hosted CTS + workspaceCrn, + getJwt, + loadToken, + saveToken, + "http://localhost:4000" // baseUrl — federate against a mock / self-hosted CTS ); ``` @@ -35,7 +93,7 @@ ```ts const strategy = OidcFederationStrategy.create( "crn:ap-southeast-2.aws:ZVATKW3VHMFG27DY", - getJwt, // () => Promise — your current third-party OIDC JWT + getJwt // () => Promise — your current third-party OIDC JWT ); const { token } = await strategy.getToken(); ``` @@ -49,7 +107,10 @@ ```ts OidcFederationStrategy.createWithStore( - workspaceCrn, getJwt, loadToken, saveToken, + workspaceCrn, + getJwt, + loadToken, + saveToken ); ``` @@ -62,12 +123,15 @@ ```ts // Before (0.38.x) - const strategy = AccessKeyStrategy.create("ap-southeast-2.aws", "CSAKid.secret"); + const strategy = AccessKeyStrategy.create( + "ap-southeast-2.aws", + "CSAKid.secret" + ); // After (0.39.0) const strategy = AccessKeyStrategy.create( "crn:ap-southeast-2.aws:ZVATKW3VHMFG27DY", - "CSAKid.secret", + "CSAKid.secret" ); ``` @@ -83,8 +147,8 @@ ### Deprecations - **`OAuthStrategy` is renamed to `DeviceSessionStrategy`** to make its purpose - — *renewing an existing CTS device session* via a refresh token — distinct - from *federating a third-party JWT* (`OidcFederationStrategy`). `OAuthStrategy` + — _renewing an existing CTS device session_ via a refresh token — distinct + from _federating a third-party JWT_ (`OidcFederationStrategy`). `OAuthStrategy` is still exported as a `@deprecated` alias of `DeviceSessionStrategy`, so existing code keeps working; it will be removed in a future major. @@ -114,12 +178,18 @@ malformed CRN argument is rejected with the existing `INVALID_CRN` code. - **AutoStrategy** — auto-detect credentials from environment variables and the local profile store. Use `AutoStrategy.detect()` for zero-config auth, or pass explicit values: ```ts - const strategy = AutoStrategy.detect({ accessKey: "CSAK...", workspaceCrn: "crn:..." }); + const strategy = AutoStrategy.detect({ + accessKey: "CSAK...", + workspaceCrn: "crn:...", + }); const { token, issuer, services } = await strategy.getToken(); ``` - **AccessKeyStrategy** — authenticate with a static access key (service-to-service, CI/CD): ```ts - const strategy = AccessKeyStrategy.create("ap-southeast-2.aws", "CSAKid.secret"); + const strategy = AccessKeyStrategy.create( + "ap-southeast-2.aws", + "CSAKid.secret" + ); const { token } = await strategy.getToken(); ``` - **OAuthStrategy** — authenticate using OAuth refresh tokens persisted to disk: diff --git a/languages/typescript/packages/auth/package.json b/languages/typescript/packages/auth/package.json index 2b3d20e88..dafb7a61c 100644 --- a/languages/typescript/packages/auth/package.json +++ b/languages/typescript/packages/auth/package.json @@ -1,6 +1,6 @@ { "name": "@cipherstash/auth", - "version": "0.40.0", + "version": "0.41.0", "license": "SEE LICENSE IN LICENSE", "main": "index.js", "types": "index.d.ts", From a044206e45976d43dfef2827842d30da3f92d28d Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Mon, 6 Jul 2026 18:54:26 +1000 Subject: [PATCH 360/686] docs(stack-auth): Added description to top of README so it shows in npmjs.org search --- languages/typescript/packages/auth/README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/languages/typescript/packages/auth/README.md b/languages/typescript/packages/auth/README.md index 9d81b48b8..6f25c8498 100644 --- a/languages/typescript/packages/auth/README.md +++ b/languages/typescript/packages/auth/README.md @@ -1,5 +1,7 @@ # @cipherstash/auth +Authentication bindings for CipherStash services. + [![npm version](https://img.shields.io/npm/v/@cipherstash/auth?style=for-the-badge)](https://www.npmjs.com/package/@cipherstash/auth) [![Built by CipherStash](https://raw.githubusercontent.com/cipherstash/meta/refs/heads/main/csbadge.svg)](https://cipherstash.com) From c37cccfc40fc963c823d50a2fbf2d4f1758bb15b Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Fri, 26 Jun 2026 20:06:52 +1000 Subject: [PATCH 361/686] feat(auth): add @cipherstash/auth/next runtime adapter (CIP-3250) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A runtime adapter for federated CTS tokens in request/response server frameworks (built for the Next.js App Router, framework-agnostic by construction — operates on WHATWG Request/Headers): - csFederationMiddleware / csFederate: federate-or-reuse where the request is in-scope and cookies are writable; persist to a per-workspace HTTP-only cookie (cross-request cache) and hand the fresh token to the same-request render via the x-cs-cts-token header (a fresh Set-Cookie isn't readable in the same request). - csAuthHeader: read the warmed token eagerly, in-scope, into an AuthStrategy whose getToken() can then be driven from a detached context. - requiresFederation capability flag on the wasm-inline strategies (true for OIDC, false for ambient AccessKeyStrategy). Builds on the existing /wasm-inline strategy + /cookies helper. Unit tests cover the wiring (cookie persistence, header codec, warmed reads); the real federate+cache round-trip is covered by oidc-cookie-roundtrip.test.ts. --- .../packages/auth/__tests__/next.test.ts | 191 +++++++++++++++++ languages/typescript/packages/auth/next.d.ts | 102 +++++++++ languages/typescript/packages/auth/next.mjs | 198 ++++++++++++++++++ .../typescript/packages/auth/package.json | 6 + .../typescript/packages/auth/wasm-inline.d.ts | 13 ++ .../typescript/packages/auth/wasm-inline.mjs | 10 + 6 files changed, 520 insertions(+) create mode 100644 languages/typescript/packages/auth/__tests__/next.test.ts create mode 100644 languages/typescript/packages/auth/next.d.ts create mode 100644 languages/typescript/packages/auth/next.mjs diff --git a/languages/typescript/packages/auth/__tests__/next.test.ts b/languages/typescript/packages/auth/__tests__/next.test.ts new file mode 100644 index 000000000..c523682ce --- /dev/null +++ b/languages/typescript/packages/auth/__tests__/next.test.ts @@ -0,0 +1,191 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { + csFederate, + csFederationMiddleware, + csAuthHeader, + csTokenCookieName, + encodeTokenHeader, + decodeTokenHeader, + CS_TOKEN_HEADER, +} from "../next.mjs"; + +// Mock the wasm strategy so these tests exercise the adapter's OWN wiring +// (cookie persistence, header encode/decode, warmed-token reads) deterministically +// and offline. The real federate-or-reuse + cookie round-trip against a CTS +// server is covered by `oidc-cookie-roundtrip.test.ts`. `vi.hoisted` lets the +// hoisted `vi.mock` factory reference these fns without a top-level await. +const { getToken, free, create } = vi.hoisted(() => ({ + getToken: vi.fn(), + free: vi.fn(), + create: vi.fn(), +})); + +vi.mock("../wasm-inline.mjs", () => ({ + OidcFederationStrategy: { + create: ( + crn: string, + getJwt: () => unknown, + options: { store?: { load(): unknown; save(json: string): unknown } }, + ) => create(crn, getJwt, options), + }, +})); + +const WORKSPACE_ID = "ZVATKW3VHMFG27DY"; +const WORKSPACE_CRN = `crn:ap-southeast-2.aws:${WORKSPACE_ID}`; + +function tokenResult(overrides: Record = {}) { + return { + token: "header.payload.signature", + subject: `CS|${WORKSPACE_ID}`, + workspaceId: WORKSPACE_ID, + issuer: "https://cts.example.com", + services: { zerokms: "https://zerokms.example.com" }, + ...overrides, + }; +} + +function requestWith(cookie?: string): Request { + return new Request("https://example.com/", { + headers: cookie ? { cookie } : {}, + }); +} + +beforeEach(() => { + getToken.mockReset(); + free.mockReset(); + create.mockReset(); + // Default fake strategy: writes the token to the store (so cookie wiring is + // exercised) and returns a TokenResult. + create.mockImplementation( + ( + _crn: string, + _getJwt: () => unknown, + options: { store?: { save(json: string): unknown } }, + ) => { + getToken.mockImplementation(async () => { + await options.store?.save( + JSON.stringify({ + access_token: "header.payload.signature", + expires_at: Math.floor(Date.now() / 1000) + 3600, + }), + ); + return tokenResult(); + }); + return { getToken, free }; + }, + ); +}); + +describe("csTokenCookieName", () => { + it("is per-workspace", () => { + expect(csTokenCookieName(WORKSPACE_ID)).toBe(`cs_token_${WORKSPACE_ID}`); + }); +}); + +describe("encode/decodeTokenHeader", () => { + it("round-trips a TokenResult through the opaque header payload", () => { + const r = tokenResult(); + const decoded = decodeTokenHeader(encodeTokenHeader(r)); + expect(decoded).toEqual(r); + }); + + it("produces a header-safe value (no base64 +/=/ chars)", () => { + const v = encodeTokenHeader(tokenResult()); + expect(v).not.toMatch(/[+/=]/); + }); +}); + +describe("csFederate", () => { + it("builds the strategy with the cookie-backed store and persists the token", async () => { + const responseHeaders = new Headers(); + const result = await csFederate({ + request: requestWith(), + responseHeaders, + workspaceCrn: WORKSPACE_CRN, + getJwt: () => "jwt", + baseUrl: "https://cts.example.com", + cookieName: csTokenCookieName(WORKSPACE_ID), + }); + + expect(result.workspaceId).toBe(WORKSPACE_ID); + // baseUrl threaded through to the strategy. + expect(create).toHaveBeenCalledWith(WORKSPACE_CRN, expect.any(Function), { + store: expect.anything(), + baseUrl: "https://cts.example.com", + }); + // The cookie was written under the per-workspace name. + const setCookie = responseHeaders.get("set-cookie"); + expect(setCookie).toMatch(new RegExp(`^cs_token_${WORKSPACE_ID}=`)); + // wasm resources released. + expect(free).toHaveBeenCalledOnce(); + }); +}); + +describe("csFederationMiddleware", () => { + it("returns the warmed token header alongside the result", async () => { + const responseHeaders = new Headers(); + const { result, headerName, headerValue } = await csFederationMiddleware({ + request: requestWith(), + responseHeaders, + workspaceCrn: WORKSPACE_CRN, + getJwt: () => "jwt", + cookieName: csTokenCookieName(WORKSPACE_ID), + }); + + expect(headerName).toBe(CS_TOKEN_HEADER); + expect(decodeTokenHeader(headerValue)).toEqual(result); + // Both caches populated: cookie (cross-request) + header (same-request). + expect(responseHeaders.get("set-cookie")).toBeTruthy(); + }); + + it("honours a custom header name", async () => { + const { headerName } = await csFederationMiddleware({ + request: requestWith(), + responseHeaders: new Headers(), + workspaceCrn: WORKSPACE_CRN, + getJwt: () => "jwt", + cookieName: csTokenCookieName(WORKSPACE_ID), + headerName: "x-warm", + }); + expect(headerName).toBe("x-warm"); + }); +}); + +describe("csAuthHeader", () => { + it("reads a warmed token from the request header into a no-federation strategy", async () => { + const headers = new Headers({ + [CS_TOKEN_HEADER]: encodeTokenHeader(tokenResult()), + }); + const strategy = csAuthHeader(headers); + expect(strategy).not.toBeNull(); + expect(strategy?.requiresFederation).toBe(false); + expect((await strategy?.getToken())?.workspaceId).toBe(WORKSPACE_ID); + }); + + it("reads eagerly at construction (later header mutation is ignored)", async () => { + const headers = new Headers({ + [CS_TOKEN_HEADER]: encodeTokenHeader(tokenResult({ token: "first" })), + }); + const strategy = csAuthHeader(headers); + headers.set( + CS_TOKEN_HEADER, + encodeTokenHeader(tokenResult({ token: "second" })), + ); + expect((await strategy?.getToken())?.token).toBe("first"); + }); + + it("returns null when no warmed token is present (cold fallback)", () => { + expect(csAuthHeader(new Headers())).toBeNull(); + }); + + it("returns null on a malformed header rather than throwing", () => { + const headers = new Headers({ [CS_TOKEN_HEADER]: "not-valid-base64url!!" }); + expect(csAuthHeader(headers)).toBeNull(); + }); + + it("honours a custom header name", async () => { + const headers = new Headers({ "x-warm": encodeTokenHeader(tokenResult()) }); + expect(csAuthHeader(headers, { headerName: "x-warm" })).not.toBeNull(); + expect(csAuthHeader(headers)).toBeNull(); + }); +}); diff --git a/languages/typescript/packages/auth/next.d.ts b/languages/typescript/packages/auth/next.d.ts new file mode 100644 index 000000000..c5d8cf58c --- /dev/null +++ b/languages/typescript/packages/auth/next.d.ts @@ -0,0 +1,102 @@ +/* tslint:disable */ +/* eslint-disable */ + +/* + * Public TS surface for the `/next` entry — a runtime adapter for federated CTS + * tokens in request/response server frameworks (built for the Next.js App + * Router, framework-agnostic by construction). See `next.mjs` for the model. + */ + +import type { TokenResult } from "./wasm-types.d.ts"; + +export type { TokenResult } from "./wasm-types.d.ts"; + +/** Request header carrying the warmed token from middleware to the render. */ +export declare const CS_TOKEN_HEADER: "x-cs-cts-token"; + +/** Per-workspace cookie name for the cached CTS token (`cs_token_`). */ +export declare function csTokenCookieName(workspaceId: string): string; + +export interface CsFederateOptions { + /** Incoming request — the token cookie is read from its `Cookie` header. */ + request: Request; + /** Outgoing headers — the refreshed token cookie is appended as `Set-Cookie`. */ + responseHeaders: Headers; + /** Workspace CRN, `crn::`. */ + workspaceCrn: string; + /** Mints the current third-party OIDC JWT (re-invoked on every re-federation). */ + getJwt: () => string | Promise; + /** Pin federation to a specific CTS host / mock, overriding region discovery. */ + baseUrl?: string; + /** Cookie name — pass `csTokenCookieName(workspaceId)` for the per-workspace default. */ + cookieName?: string; + /** Cookie `Secure` flag — set `false` only for localhost HTTP dev. Default `true`. */ + secure?: boolean; + /** Cookie `SameSite`. Default `"Lax"`. */ + sameSite?: "Strict" | "Lax" | "None"; +} + +/** + * Federate-or-reuse a CTS service token, persisting it to the cookie. Use in any + * writable, in-scope context (middleware, route handler, server action). + */ +export declare function csFederate(options: CsFederateOptions): Promise; + +export interface CsFederationMiddlewareOptions extends CsFederateOptions { + /** Request header to carry the warmed token. Default {@link CS_TOKEN_HEADER}. */ + headerName?: string; +} + +export interface CsFederationMiddlewareResult { + /** The federated token. */ + result: TokenResult; + /** Request header to forward (default {@link CS_TOKEN_HEADER}). */ + headerName: string; + /** Encoded warmed-token payload to set on that header. */ + headerValue: string; +} + +/** + * Federate-or-reuse in middleware, returning the request header that delivers + * the warmed token to the same-request render. Forward it via + * `NextResponse.next({ request: { headers } })`; copy `responseHeaders` + * (carrying `Set-Cookie`) onto the response. + */ +export declare function csFederationMiddleware( + options: CsFederationMiddlewareOptions, +): Promise; + +export interface CsAuthHeaderOptions { + /** Header to read. Default {@link CS_TOKEN_HEADER}. */ + headerName?: string; +} + +/** + * An `AuthStrategy` backed by a token a middleware already warmed — it requires + * no federation, so consumers (incl. protect-ffi) can drive `getToken()` from + * any context. + */ +export interface WarmedAuthStrategy { + readonly requiresFederation: false; + getToken(): Promise; + free(): void; +} + +/** + * Read the warmed token injected by {@link csFederationMiddleware} into an + * `AuthStrategy`. The header is read EAGERLY and closed over, so the strategy is + * safe to drive from a detached callback. `headers` is anything with a + * `get(name)` method (WHATWG `Headers` or Next's `headers()`). Returns `null` + * when no warmed token is present, so the caller can fall back to a cold + * federation. + */ +export declare function csAuthHeader( + headers: { get(name: string): string | null }, + options?: CsAuthHeaderOptions, +): WarmedAuthStrategy | null; + +/** Encode a {@link TokenResult} into the opaque header payload. */ +export declare function encodeTokenHeader(result: TokenResult): string; + +/** Decode the header payload produced by {@link encodeTokenHeader}. */ +export declare function decodeTokenHeader(value: string): TokenResult; diff --git a/languages/typescript/packages/auth/next.mjs b/languages/typescript/packages/auth/next.mjs new file mode 100644 index 000000000..b0fb0687a --- /dev/null +++ b/languages/typescript/packages/auth/next.mjs @@ -0,0 +1,198 @@ +/* @ts-self-types="./next.d.ts" */ + +// Runtime adapter for federated CTS tokens in request/response server +// frameworks — built for the Next.js App Router but framework-agnostic by +// construction: every function operates on WHATWG `Request` / `Headers` and +// returns plain data, so the caller does the (tiny) framework wiring +// (`NextResponse.next({ request: { headers } })`, `headers()`), and the helpers +// stay unit-testable without Next installed. Mirrors the philosophy of the +// `/cookies` helper. +// +// The model: federate-or-reuse where the request is BOTH in-scope AND can write +// cookies (middleware / route handlers / server actions), persist to a per- +// workspace HTTP-only cookie for the cross-request cache, and hand the freshly +// minted token to the same-request render via a request header — because a +// `Set-Cookie` written now is not readable in the same request. + +import { cookieStore } from "./cookies.mjs"; +import { OidcFederationStrategy } from "./wasm-inline.mjs"; + +/** Request header carrying the warmed token from middleware to the render. */ +export const CS_TOKEN_HEADER = "x-cs-cts-token"; + +/** Per-workspace cookie name for the cached CTS token. */ +export function csTokenCookieName(workspaceId) { + return `cs_token_${workspaceId}`; +} + +/** + * @typedef {object} CsFederateOptions + * @property {Request} request Incoming request (reads the token cookie) + * @property {Headers} responseHeaders Outgoing headers (the refreshed cookie is appended here) + * @property {string} workspaceCrn `crn::` + * @property {() => string | Promise} getJwt Mints the current third-party OIDC JWT (Clerk, …) + * @property {string} [baseUrl] Pin federation to a CTS host / mock (overrides region discovery) + * @property {string} [cookieName] Override the cookie name (default `cs_token_` — pass it explicitly) + * @property {boolean} [secure=true] Cookie `Secure` flag — set `false` only for localhost HTTP dev + * @property {"Strict" | "Lax" | "None"} [sameSite="Lax"] Cookie `SameSite` + */ + +/** + * Federate-or-reuse a CTS service token, persisting the result to the cookie. + * Use in any writable, in-scope context (middleware, route handler, server + * action). Returns the `TokenResult` (`{ token, services, workspaceId, … }`). + * + * @param {CsFederateOptions} options + * @returns {Promise} + */ +export async function csFederate(options) { + const { + request, + responseHeaders, + workspaceCrn, + getJwt, + baseUrl, + cookieName, + secure, + sameSite, + } = options; + + const store = cookieStore({ + request, + responseHeaders, + name: cookieName, + secure, + sameSite, + }); + const strategy = OidcFederationStrategy.create(workspaceCrn, getJwt, { + store, + baseUrl, + }); + try { + return await strategy.getToken(); + } finally { + strategy.free(); + } +} + +/** + * @typedef {CsFederateOptions & { headerName?: string }} CsFederationMiddlewareOptions + */ + +/** + * @typedef {object} CsFederationMiddlewareResult + * @property {import("./wasm-types.d.ts").TokenResult} result The federated token + * @property {string} headerName Request header to forward (default {@link CS_TOKEN_HEADER}) + * @property {string} headerValue Encoded warmed-token payload to set on that header + */ +/** + * Federate-or-reuse in middleware, then return the request header that delivers + * the warmed token to the same-request render (a fresh `Set-Cookie` is not + * readable in the same request). The caller forwards it via + * `NextResponse.next({ request: { headers } })` and copies `responseHeaders` + * (carrying `Set-Cookie`) onto the response. + * + * @param {CsFederationMiddlewareOptions} options + * @returns {Promise} + */ +export async function csFederationMiddleware(options) { + const result = await csFederate(options); + return { + result, + headerName: options.headerName ?? CS_TOKEN_HEADER, + headerValue: encodeTokenHeader(result), + }; +} + +/** + * @typedef {object} CsAuthHeaderOptions + * @property {string} [headerName] Header to read (default {@link CS_TOKEN_HEADER}) + */ + +/** + * @typedef {object} WarmedAuthStrategy + * @property {false} requiresFederation + * @property {() => Promise} getToken + * @property {() => void} free + */ + +/** + * Read the warmed token a middleware injected (via {@link csFederationMiddleware}) + * into an `AuthStrategy` whose `getToken()` resolves it. The header is read + * EAGERLY and the value closed over, so the returned strategy can be driven + * from a detached callback (e.g. protect-ffi) where request scope is gone. + * + * `headers` is anything with a `get(name)` method (a WHATWG `Headers`, or + * Next's `headers()` result). Returns `null` when no warmed token is present, + * so callers can fall back to a cold federation. + * + * @param {{ get(name: string): string | null }} headers + * @param {CsAuthHeaderOptions} [options] + * @returns {WarmedAuthStrategy | null} + */ +export function csAuthHeader(headers, options) { + const headerName = options?.headerName ?? CS_TOKEN_HEADER; + const raw = headers?.get(headerName) ?? null; + if (!raw) return null; + let warmed; + try { + warmed = decodeTokenHeader(raw); + } catch { + return null; + } + if (!warmed || typeof warmed.token !== "string") return null; + return { + requiresFederation: false, + getToken: async () => warmed, + free() {}, + }; +} + +// --------------------------------------------------------------------------- +// Header payload codec — base64url(JSON) keeps the value header-safe and opaque +// --------------------------------------------------------------------------- + +/** + * @param {import("./wasm-types.d.ts").TokenResult} result + * @returns {string} + */ +export function encodeTokenHeader(result) { + return encodeBase64Url(JSON.stringify(result)); +} + +/** + * @param {string} value + * @returns {import("./wasm-types.d.ts").TokenResult} + */ +export function decodeTokenHeader(value) { + return JSON.parse(decodeBase64Url(value)); +} + +/** + * @param {string} input + * @returns {string} + */ +function encodeBase64Url(input) { + const bytes = new TextEncoder().encode(input); + let binary = ""; + for (let i = 0; i < bytes.length; i++) + binary += String.fromCharCode(bytes[i]); + return btoa(binary) + .replaceAll("+", "-") + .replaceAll("/", "_") + .replaceAll("=", ""); +} + +/** + * @param {string} input + * @returns {string} + */ +function decodeBase64Url(input) { + const padded = input.replaceAll("-", "+").replaceAll("_", "/"); + const pad = + padded.length % 4 === 0 ? "" : "=".repeat(4 - (padded.length % 4)); + const binary = atob(padded + pad); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); + return new TextDecoder().decode(bytes); +} diff --git a/languages/typescript/packages/auth/package.json b/languages/typescript/packages/auth/package.json index dafb7a61c..1a1ce1755 100644 --- a/languages/typescript/packages/auth/package.json +++ b/languages/typescript/packages/auth/package.json @@ -27,6 +27,10 @@ "./cookies": { "types": "./cookies.d.ts", "default": "./cookies.mjs" + }, + "./next": { + "types": "./next.d.ts", + "default": "./next.mjs" } }, "napi": { @@ -55,6 +59,8 @@ "wasm-inline.d.ts", "cookies.mjs", "cookies.d.ts", + "next.mjs", + "next.d.ts", "wasm/" ], "scripts": { diff --git a/languages/typescript/packages/auth/wasm-inline.d.ts b/languages/typescript/packages/auth/wasm-inline.d.ts index fc2d24085..9f8ab775d 100644 --- a/languages/typescript/packages/auth/wasm-inline.d.ts +++ b/languages/typescript/packages/auth/wasm-inline.d.ts @@ -104,6 +104,12 @@ export interface AccessKeyStrategyOptions { */ export declare class AccessKeyStrategy { private constructor(); + /** + * Capability flag — `false` for this ambient strategy: the credential is a + * static value, so `getToken()` self-refreshes and can be driven from any + * context. (Contrast {@link OidcFederationStrategy.requiresFederation}.) + */ + readonly requiresFederation: false; /** * Create a new `AccessKeyStrategy` for the given workspace CRN and * access key. @@ -158,6 +164,13 @@ export interface OidcFederationStrategyOptions { */ export declare class OidcFederationStrategy { private constructor(); + /** + * Capability flag — `true` for this federated strategy: the third-party JWT + * and token cache are request-scoped, so federation must happen in scope. + * Consumers should read a warmed token (`@cipherstash/auth/next`) rather than + * drive `getToken()` from a detached context. + */ + readonly requiresFederation: true; /** * Create an `OidcFederationStrategy` for the given workspace CRN. * diff --git a/languages/typescript/packages/auth/wasm-inline.mjs b/languages/typescript/packages/auth/wasm-inline.mjs index 5d0b0388b..749a2e200 100644 --- a/languages/typescript/packages/auth/wasm-inline.mjs +++ b/languages/typescript/packages/auth/wasm-inline.mjs @@ -57,6 +57,10 @@ function settleGetToken(inner) { export class AccessKeyStrategy { #inner; + // Ambient strategy: the credential is a static value readable anywhere, so + // `getToken()` self-refreshes and consumers can drive it directly. + requiresFederation = false; + /** @param {RawAccessKeyStrategy} inner */ constructor(inner) { this.#inner = inner; @@ -113,6 +117,12 @@ export class AccessKeyStrategy { export class OidcFederationStrategy { #inner; + // Federated strategy: the third-party JWT lives in request scope and the + // cache is request-scoped, so federation must happen in scope. Consumers + // should read a warmed token (see `@cipherstash/auth/next`) rather than drive + // `getToken()` from a detached context. + requiresFederation = true; + /** @param {RawOidcFederationStrategy} inner */ constructor(inner) { this.#inner = inner; From 0801927cb18b8817d72d6231ce93f10706c35be6 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Sat, 4 Jul 2026 20:16:57 +1000 Subject: [PATCH 362/686] fix(auth/next): address Copilot review on cipherstash/cipherstash-suite#2069 - csFederate: default cookieName to cs_token_ derived from the CRN instead of silently collapsing to the cookieStore default (cs_token), which caused cross-workspace token-cache collisions. - csAuthHeader: validate the FULL TokenResult shape (token/subject/workspaceId/ issuer non-empty strings, services a string->string map) before accepting the client-influenceable x-cs-cts-token header, rather than checking token alone. - package.json: bump platform peerDependencies 0.40.0 -> 0.41.0 to match the release version (the publish workflow rewrites these, but keep source honest). Adds tests for the cookie-name default and the shape rejection (13 total). --- .../packages/auth/__tests__/next.test.ts | 32 +++++++++++++++++ languages/typescript/packages/auth/next.mjs | 36 +++++++++++++++++-- .../typescript/packages/auth/package.json | 12 +++---- 3 files changed, 71 insertions(+), 9 deletions(-) diff --git a/languages/typescript/packages/auth/__tests__/next.test.ts b/languages/typescript/packages/auth/__tests__/next.test.ts index c523682ce..5326f798c 100644 --- a/languages/typescript/packages/auth/__tests__/next.test.ts +++ b/languages/typescript/packages/auth/__tests__/next.test.ts @@ -119,6 +119,20 @@ describe("csFederate", () => { // wasm resources released. expect(free).toHaveBeenCalledOnce(); }); + + it("defaults the cookie name to cs_token_ from the CRN when omitted", async () => { + const responseHeaders = new Headers(); + await csFederate({ + request: requestWith(), + responseHeaders, + workspaceCrn: WORKSPACE_CRN, + getJwt: () => "jwt", + // cookieName intentionally omitted — must NOT collapse to `cs_token`. + }); + expect(responseHeaders.get("set-cookie")).toMatch( + new RegExp(`^cs_token_${WORKSPACE_ID}=`), + ); + }); }); describe("csFederationMiddleware", () => { @@ -183,6 +197,24 @@ describe("csAuthHeader", () => { expect(csAuthHeader(headers)).toBeNull(); }); + it("rejects a structurally-incomplete TokenResult (spoof/partial payload)", () => { + // Missing workspaceId/subject/issuer — decodes fine but isn't a TokenResult. + const partial = new Headers({ + [CS_TOKEN_HEADER]: encodeTokenHeader({ + token: "header.payload.signature", + } as never), + }); + expect(csAuthHeader(partial)).toBeNull(); + + // services present but not a string→string map. + const badServices = new Headers({ + [CS_TOKEN_HEADER]: encodeTokenHeader( + tokenResult({ services: { zerokms: 123 } }) as never, + ), + }); + expect(csAuthHeader(badServices)).toBeNull(); + }); + it("honours a custom header name", async () => { const headers = new Headers({ "x-warm": encodeTokenHeader(tokenResult()) }); expect(csAuthHeader(headers, { headerName: "x-warm" })).not.toBeNull(); diff --git a/languages/typescript/packages/auth/next.mjs b/languages/typescript/packages/auth/next.mjs index b0fb0687a..8c72b7862 100644 --- a/languages/typescript/packages/auth/next.mjs +++ b/languages/typescript/packages/auth/next.mjs @@ -32,7 +32,7 @@ export function csTokenCookieName(workspaceId) { * @property {string} workspaceCrn `crn::` * @property {() => string | Promise} getJwt Mints the current third-party OIDC JWT (Clerk, …) * @property {string} [baseUrl] Pin federation to a CTS host / mock (overrides region discovery) - * @property {string} [cookieName] Override the cookie name (default `cs_token_` — pass it explicitly) + * @property {string} [cookieName] Override the cookie name (defaults to `cs_token_`, derived from `workspaceCrn`) * @property {boolean} [secure=true] Cookie `Secure` flag — set `false` only for localhost HTTP dev * @property {"Strict" | "Lax" | "None"} [sameSite="Lax"] Cookie `SameSite` */ @@ -57,10 +57,16 @@ export async function csFederate(options) { sameSite, } = options; + // Default to the per-workspace cookie name derived from the CRN + // (`crn::`). Without this, omitting `cookieName` falls + // back to the cookieStore default (`cs_token`), collapsing every workspace's + // token into one cookie and causing cross-workspace cache collisions. + const workspaceId = workspaceCrn.split(":").at(-1); const store = cookieStore({ request, responseHeaders, - name: cookieName, + name: + cookieName ?? (workspaceId ? csTokenCookieName(workspaceId) : undefined), secure, sameSite, }); @@ -140,7 +146,11 @@ export function csAuthHeader(headers, options) { } catch { return null; } - if (!warmed || typeof warmed.token !== "string") return null; + // Validate the FULL TokenResult shape, not just `token`. The header is + // attacker-influenceable (a client could send its own `x-cs-cts-token`), so a + // malformed/spoofed payload must be rejected here rather than surfacing as + // undefined `subject`/`workspaceId`/`issuer`/`services` fields downstream. + if (!isTokenResult(warmed)) return null; return { requiresFederation: false, getToken: async () => warmed, @@ -148,6 +158,26 @@ export function csAuthHeader(headers, options) { }; } +/** + * Structural guard for a decoded {@link import("./wasm-types.d.ts").TokenResult}: + * `token`/`subject`/`workspaceId`/`issuer` are non-empty strings and `services` + * is a string→string map. + * + * @param {unknown} v + * @returns {v is import("./wasm-types.d.ts").TokenResult} + */ +function isTokenResult(v) { + if (!v || typeof v !== "object") return false; + for (const key of ["token", "subject", "workspaceId", "issuer"]) { + if (typeof v[key] !== "string" || v[key].length === 0) return false; + } + if (!v.services || typeof v.services !== "object") return false; + for (const endpoint of Object.values(v.services)) { + if (typeof endpoint !== "string") return false; + } + return true; +} + // --------------------------------------------------------------------------- // Header payload codec — base64url(JSON) keeps the value header-safe and opaque // --------------------------------------------------------------------------- diff --git a/languages/typescript/packages/auth/package.json b/languages/typescript/packages/auth/package.json index 1a1ce1755..f29ba5b31 100644 --- a/languages/typescript/packages/auth/package.json +++ b/languages/typescript/packages/auth/package.json @@ -72,12 +72,12 @@ "format:check": "npx --yes @biomejs/biome@2.3.4 ci ." }, "peerDependencies": { - "@cipherstash/auth-darwin-x64": "0.40.0", - "@cipherstash/auth-darwin-arm64": "0.40.0", - "@cipherstash/auth-linux-x64-gnu": "0.40.0", - "@cipherstash/auth-linux-arm64-gnu": "0.40.0", - "@cipherstash/auth-linux-x64-musl": "0.40.0", - "@cipherstash/auth-win32-x64-msvc": "0.40.0" + "@cipherstash/auth-darwin-x64": "0.41.0", + "@cipherstash/auth-darwin-arm64": "0.41.0", + "@cipherstash/auth-linux-x64-gnu": "0.41.0", + "@cipherstash/auth-linux-arm64-gnu": "0.41.0", + "@cipherstash/auth-linux-x64-musl": "0.41.0", + "@cipherstash/auth-win32-x64-msvc": "0.41.0" }, "peerDependenciesMeta": { "@cipherstash/auth-darwin-x64": { From e5e36389075f13f726a2a19d581d6889fe63bfe3 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Sat, 4 Jul 2026 21:12:07 +1000 Subject: [PATCH 363/686] fix(auth/next): address code-review findings on cipherstash/cipherstash-suite#2069 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Extract the base64url codec into a shared base64url.mjs (WHATWG-only, Edge-safe) imported by both cookies.mjs and next.mjs, removing the verbatim duplicate copy that only cookies.mjs had tests for. - isTokenResult: reject an empty services map (a federated token always carries >=1 endpoint); an empty map signalled a partial/spoofed payload that previously passed, surfacing later as an undefined service endpoint. - next.d.ts: fix stale cookieName doc — csFederate now derives the per-workspace default from workspaceCrn; the type said to pass it explicitly. - csAuthHeader: document the trust boundary — the header payload is opaque, not authenticated, so it must only be trusted where the inbound header is stripped; cryptographic pinning (AEAD) is tracked in CIP-3112. Tests: 13 adapter + 12 cookies pass against the shared codec. --- .../packages/auth/__tests__/next.test.ts | 6 +++ .../typescript/packages/auth/base64url.d.ts | 13 +++++ .../typescript/packages/auth/base64url.mjs | 39 ++++++++++++++ .../typescript/packages/auth/cookies.mjs | 33 +----------- languages/typescript/packages/auth/next.d.ts | 5 +- languages/typescript/packages/auth/next.mjs | 53 ++++++++----------- .../typescript/packages/auth/package.json | 2 + 7 files changed, 87 insertions(+), 64 deletions(-) create mode 100644 languages/typescript/packages/auth/base64url.d.ts create mode 100644 languages/typescript/packages/auth/base64url.mjs diff --git a/languages/typescript/packages/auth/__tests__/next.test.ts b/languages/typescript/packages/auth/__tests__/next.test.ts index 5326f798c..628534fa3 100644 --- a/languages/typescript/packages/auth/__tests__/next.test.ts +++ b/languages/typescript/packages/auth/__tests__/next.test.ts @@ -213,6 +213,12 @@ describe("csAuthHeader", () => { ), }); expect(csAuthHeader(badServices)).toBeNull(); + + // services present but EMPTY — a federated token always has >=1 endpoint. + const emptyServices = new Headers({ + [CS_TOKEN_HEADER]: encodeTokenHeader(tokenResult({ services: {} })), + }); + expect(csAuthHeader(emptyServices)).toBeNull(); }); it("honours a custom header name", async () => { diff --git a/languages/typescript/packages/auth/base64url.d.ts b/languages/typescript/packages/auth/base64url.d.ts new file mode 100644 index 000000000..c26001008 --- /dev/null +++ b/languages/typescript/packages/auth/base64url.d.ts @@ -0,0 +1,13 @@ +/* tslint:disable */ +/* eslint-disable */ + +/* + * Shared base64url codec used by both the `/cookies` token-cookie value and the + * `/next` warmed-token header. WHATWG-only (Edge-runtime safe). See base64url.mjs. + */ + +/** Encode a UTF-8 string as unpadded base64url. */ +export declare function encodeBase64Url(input: string): string; + +/** Decode an unpadded base64url string back to UTF-8. */ +export declare function decodeBase64Url(input: string): string; diff --git a/languages/typescript/packages/auth/base64url.mjs b/languages/typescript/packages/auth/base64url.mjs new file mode 100644 index 000000000..60604af7b --- /dev/null +++ b/languages/typescript/packages/auth/base64url.mjs @@ -0,0 +1,39 @@ +/* @ts-self-types="./base64url.d.ts" */ + +// Shared base64url codec for the token-cookie value (`/cookies`) and the warmed- +// token request header (`/next`). Both transports must encode/decode +// identically, so the implementation lives here rather than being copied into +// each. Uses only WHATWG `btoa`/`atob`/`TextEncoder`/`TextDecoder` — NOT Node's +// `Buffer` — because `/next` runs in the Edge middleware runtime where `Buffer` +// is not reliably available. + +/** + * @param {string} input + * @returns {string} + */ +export function encodeBase64Url(input) { + // btoa works on binary strings; encode the UTF-8 bytes first so non-ASCII + // round-trips. Token JSON is ASCII in practice but be defensive. + const bytes = new TextEncoder().encode(input); + let binary = ""; + for (let i = 0; i < bytes.length; i++) + binary += String.fromCharCode(bytes[i]); + return btoa(binary) + .replaceAll("+", "-") + .replaceAll("/", "_") + .replaceAll("=", ""); +} + +/** + * @param {string} input + * @returns {string} + */ +export function decodeBase64Url(input) { + const padded = input.replaceAll("-", "+").replaceAll("_", "/"); + const pad = + padded.length % 4 === 0 ? "" : "=".repeat(4 - (padded.length % 4)); + const binary = atob(padded + pad); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); + return new TextDecoder().decode(bytes); +} diff --git a/languages/typescript/packages/auth/cookies.mjs b/languages/typescript/packages/auth/cookies.mjs index 430e01e8c..61b37f594 100644 --- a/languages/typescript/packages/auth/cookies.mjs +++ b/languages/typescript/packages/auth/cookies.mjs @@ -12,6 +12,8 @@ // and are rejected by spec-conformant cookie libraries (the `@std/http/cookie` // failure that bit the supawasm spike on its first end-to-end test). +import { decodeBase64Url, encodeBase64Url } from "./base64url.mjs"; + const DEFAULT_NAME = "cs_token"; const DEFAULT_PATH = "/"; const DEFAULT_SAFETY_MARGIN_SECONDS = 30; @@ -132,37 +134,6 @@ function serializeSetCookie(opts) { return parts.join("; "); } -/** - * @param {string} input - * @returns {string} - */ -function encodeBase64Url(input) { - // btoa works on binary strings; encode the UTF-8 bytes first so non-ASCII - // round-trips. Token JSON is ASCII in practice but be defensive. - const bytes = new TextEncoder().encode(input); - let binary = ""; - for (let i = 0; i < bytes.length; i++) - binary += String.fromCharCode(bytes[i]); - return btoa(binary) - .replaceAll("+", "-") - .replaceAll("/", "_") - .replaceAll("=", ""); -} - -/** - * @param {string} input - * @returns {string} - */ -function decodeBase64Url(input) { - const padded = input.replaceAll("-", "+").replaceAll("_", "/"); - const pad = - padded.length % 4 === 0 ? "" : "=".repeat(4 - (padded.length % 4)); - const binary = atob(padded + pad); - const bytes = new Uint8Array(binary.length); - for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); - return new TextDecoder().decode(bytes); -} - /** * @param {string} json * @param {number} safetyMarginSeconds diff --git a/languages/typescript/packages/auth/next.d.ts b/languages/typescript/packages/auth/next.d.ts index c5d8cf58c..19d6238dc 100644 --- a/languages/typescript/packages/auth/next.d.ts +++ b/languages/typescript/packages/auth/next.d.ts @@ -28,7 +28,10 @@ export interface CsFederateOptions { getJwt: () => string | Promise; /** Pin federation to a specific CTS host / mock, overriding region discovery. */ baseUrl?: string; - /** Cookie name — pass `csTokenCookieName(workspaceId)` for the per-workspace default. */ + /** + * Cookie name. Defaults to `cs_token_` derived from + * `workspaceCrn` (per-workspace cache). Only set this to override that name. + */ cookieName?: string; /** Cookie `Secure` flag — set `false` only for localhost HTTP dev. Default `true`. */ secure?: boolean; diff --git a/languages/typescript/packages/auth/next.mjs b/languages/typescript/packages/auth/next.mjs index 8c72b7862..cdd8e1e51 100644 --- a/languages/typescript/packages/auth/next.mjs +++ b/languages/typescript/packages/auth/next.mjs @@ -14,6 +14,7 @@ // minted token to the same-request render via a request header — because a // `Set-Cookie` written now is not readable in the same request. +import { decodeBase64Url, encodeBase64Url } from "./base64url.mjs"; import { cookieStore } from "./cookies.mjs"; import { OidcFederationStrategy } from "./wasm-inline.mjs"; @@ -132,6 +133,15 @@ export async function csFederationMiddleware(options) { * Next's `headers()` result). Returns `null` when no warmed token is present, * so callers can fall back to a cold federation. * + * SECURITY: the header payload is opaque base64url JSON, NOT authenticated. The + * `isTokenResult` guard only rejects malformed *shape*, not a forged-but-valid + * payload. Only trust this in a context where the inbound client-supplied header + * is stripped before the request reaches here — i.e. a middleware that always + * overwrites/deletes {@link CS_TOKEN_HEADER} on ingress (the dashboard does + * this). Cryptographically pinning the payload to the app (AEAD seal/open with + * an app-held key) so an un-stripped header can't be forged is tracked in + * CIP-3112. + * * @param {{ get(name: string): string | null }} headers * @param {CsAuthHeaderOptions} [options] * @returns {WarmedAuthStrategy | null} @@ -161,7 +171,11 @@ export function csAuthHeader(headers, options) { /** * Structural guard for a decoded {@link import("./wasm-types.d.ts").TokenResult}: * `token`/`subject`/`workspaceId`/`issuer` are non-empty strings and `services` - * is a string→string map. + * is a NON-EMPTY string→string map. A federated CTS token always carries at + * least one service endpoint (e.g. `zerokms`), so an empty `services` signals a + * partial/spoofed payload — reject it here rather than let the consumer read an + * `undefined` endpoint. Rejection is safe: the caller falls back to a cold + * federation, which re-derives the real token. * * @param {unknown} v * @returns {v is import("./wasm-types.d.ts").TokenResult} @@ -172,14 +186,18 @@ function isTokenResult(v) { if (typeof v[key] !== "string" || v[key].length === 0) return false; } if (!v.services || typeof v.services !== "object") return false; - for (const endpoint of Object.values(v.services)) { + const endpoints = Object.values(v.services); + if (endpoints.length === 0) return false; + for (const endpoint of endpoints) { if (typeof endpoint !== "string") return false; } return true; } // --------------------------------------------------------------------------- -// Header payload codec — base64url(JSON) keeps the value header-safe and opaque +// Header payload codec — base64url(JSON) keeps the value header-safe and opaque. +// NOTE: this is opaque, NOT authenticated — see the security caveat on +// csAuthHeader. base64url primitives are shared with `/cookies` via base64url.mjs. // --------------------------------------------------------------------------- /** @@ -197,32 +215,3 @@ export function encodeTokenHeader(result) { export function decodeTokenHeader(value) { return JSON.parse(decodeBase64Url(value)); } - -/** - * @param {string} input - * @returns {string} - */ -function encodeBase64Url(input) { - const bytes = new TextEncoder().encode(input); - let binary = ""; - for (let i = 0; i < bytes.length; i++) - binary += String.fromCharCode(bytes[i]); - return btoa(binary) - .replaceAll("+", "-") - .replaceAll("/", "_") - .replaceAll("=", ""); -} - -/** - * @param {string} input - * @returns {string} - */ -function decodeBase64Url(input) { - const padded = input.replaceAll("-", "+").replaceAll("_", "/"); - const pad = - padded.length % 4 === 0 ? "" : "=".repeat(4 - (padded.length % 4)); - const binary = atob(padded + pad); - const bytes = new Uint8Array(binary.length); - for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); - return new TextDecoder().decode(bytes); -} diff --git a/languages/typescript/packages/auth/package.json b/languages/typescript/packages/auth/package.json index f29ba5b31..6c892a088 100644 --- a/languages/typescript/packages/auth/package.json +++ b/languages/typescript/packages/auth/package.json @@ -59,6 +59,8 @@ "wasm-inline.d.ts", "cookies.mjs", "cookies.d.ts", + "base64url.mjs", + "base64url.d.ts", "next.mjs", "next.d.ts", "wasm/" From ec15c93faa9c2d9808488eda051415db80f95ba5 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Sat, 4 Jul 2026 21:32:11 +1000 Subject: [PATCH 364/686] test(auth/next): close two coverage gaps from review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - csFederate: assert free() still runs when getToken() rejects (the finally error path — previously only the success path asserted free()). - isTokenResult: reject a present-but-empty-string field (workspaceId:''), exercising the .length===0 branch that missing-field negatives never hit. --- .../packages/auth/__tests__/next.test.ts | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/languages/typescript/packages/auth/__tests__/next.test.ts b/languages/typescript/packages/auth/__tests__/next.test.ts index 628534fa3..6fc0ca88f 100644 --- a/languages/typescript/packages/auth/__tests__/next.test.ts +++ b/languages/typescript/packages/auth/__tests__/next.test.ts @@ -133,6 +133,26 @@ describe("csFederate", () => { new RegExp(`^cs_token_${WORKSPACE_ID}=`), ); }); + + it("frees the wasm strategy even when getToken rejects (finally path)", async () => { + // Override the default fake with one whose getToken throws, so the + // try/finally's error branch — the reason the finally exists — is exercised. + create.mockImplementation(() => { + getToken.mockRejectedValue(new Error("federation failed")); + return { getToken, free }; + }); + + await expect( + csFederate({ + request: requestWith(), + responseHeaders: new Headers(), + workspaceCrn: WORKSPACE_CRN, + getJwt: () => "jwt", + }), + ).rejects.toThrow("federation failed"); + // free() still ran despite the throw. + expect(free).toHaveBeenCalledOnce(); + }); }); describe("csFederationMiddleware", () => { @@ -219,6 +239,13 @@ describe("csAuthHeader", () => { [CS_TOKEN_HEADER]: encodeTokenHeader(tokenResult({ services: {} })), }); expect(csAuthHeader(emptyServices)).toBeNull(); + + // Field PRESENT but empty string — distinct from missing; must still reject + // (isTokenResult's `.length === 0` branch). workspaceId is representative. + const emptyField = new Headers({ + [CS_TOKEN_HEADER]: encodeTokenHeader(tokenResult({ workspaceId: "" })), + }); + expect(csAuthHeader(emptyField)).toBeNull(); }); it("honours a custom header name", async () => { From 4d41cc7fc835d8a3733ffee29957e2a72a6dbffb Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Mon, 6 Jul 2026 23:01:14 +1000 Subject: [PATCH 365/686] fix(auth/next): adapt the /next adapter to the Result-returning strategy API Rebasing onto main lands the /next adapter on top of the 0.41.0 breaking change (feat(stack-auth-node)!: return a Result instead of throwing): the strategy factories and getToken() now return a @byteslice/result Result ({ data } / { failure }) instead of returning a bare value or throwing. The adapter was written against the old throwing surface, so it broke on both create() and getToken(). - csFederate treated OidcFederationStrategy.create(...) as the strategy and getToken() as a bare TokenResult. Unwrap both Results, throwing the live failure.error to preserve its documented Promise/throw contract. create() moves outside the try so free() only runs once a strategy was actually allocated. - csAuthHeader's warmed strategy now returns { data: warmed } so it stays a drop-in wherever a real strategy is consumed (e.g. protect-ffi), which now expect a Result from getToken(). WarmedAuthStrategy.getToken() retypes to Promise. Tests move to Result-shaped mocks and add coverage for the getToken-failure and create-failure paths (throw + free semantics). --- .../packages/auth/__tests__/next.test.ts | 49 +++++++++++++++---- languages/typescript/packages/auth/next.d.ts | 7 ++- languages/typescript/packages/auth/next.mjs | 21 ++++++-- 3 files changed, 61 insertions(+), 16 deletions(-) diff --git a/languages/typescript/packages/auth/__tests__/next.test.ts b/languages/typescript/packages/auth/__tests__/next.test.ts index 6fc0ca88f..56fbd8464 100644 --- a/languages/typescript/packages/auth/__tests__/next.test.ts +++ b/languages/typescript/packages/auth/__tests__/next.test.ts @@ -55,7 +55,9 @@ beforeEach(() => { free.mockReset(); create.mockReset(); // Default fake strategy: writes the token to the store (so cookie wiring is - // exercised) and returns a TokenResult. + // exercised) and returns a Result-wrapped TokenResult. Both `create()` and + // `getToken()` return `@byteslice/result` Results (`{ data }` on success), + // matching the real wasm surface the adapter unwraps. create.mockImplementation( ( _crn: string, @@ -69,9 +71,9 @@ beforeEach(() => { expires_at: Math.floor(Date.now() / 1000) + 3600, }), ); - return tokenResult(); + return { data: tokenResult() }; }); - return { getToken, free }; + return { data: { getToken, free } }; }, ); }); @@ -134,12 +136,18 @@ describe("csFederate", () => { ); }); - it("frees the wasm strategy even when getToken rejects (finally path)", async () => { - // Override the default fake with one whose getToken throws, so the - // try/finally's error branch — the reason the finally exists — is exercised. + it("throws the failure's error and still frees the strategy (finally path)", async () => { + // Override the default fake so getToken resolves to a `{ failure }` Result — + // the new-API failure mode. csFederate must unwrap it, throw the live + // `failure.error`, and still run the try/finally's `free()`. create.mockImplementation(() => { - getToken.mockRejectedValue(new Error("federation failed")); - return { getToken, free }; + getToken.mockResolvedValue({ + failure: { + type: "SERVER_ERROR", + error: new Error("federation failed"), + }, + }); + return { data: { getToken, free } }; }); await expect( @@ -153,6 +161,25 @@ describe("csFederate", () => { // free() still ran despite the throw. expect(free).toHaveBeenCalledOnce(); }); + + it("throws the failure's error when strategy creation fails (no free)", async () => { + // `create()` itself can fail (e.g. INVALID_CRN) — it returns `{ failure }` + // before any strategy is allocated, so csFederate throws without calling + // free() (there's nothing to release). + create.mockImplementation(() => ({ + failure: { type: "INVALID_CRN", error: new Error("bad crn") }, + })); + + await expect( + csFederate({ + request: requestWith(), + responseHeaders: new Headers(), + workspaceCrn: WORKSPACE_CRN, + getJwt: () => "jwt", + }), + ).rejects.toThrow("bad crn"); + expect(free).not.toHaveBeenCalled(); + }); }); describe("csFederationMiddleware", () => { @@ -193,7 +220,9 @@ describe("csAuthHeader", () => { const strategy = csAuthHeader(headers); expect(strategy).not.toBeNull(); expect(strategy?.requiresFederation).toBe(false); - expect((await strategy?.getToken())?.workspaceId).toBe(WORKSPACE_ID); + // Warmed strategy mirrors a real strategy: getToken() resolves a `{ data }` + // Result, not a bare TokenResult. + expect((await strategy?.getToken())?.data?.workspaceId).toBe(WORKSPACE_ID); }); it("reads eagerly at construction (later header mutation is ignored)", async () => { @@ -205,7 +234,7 @@ describe("csAuthHeader", () => { CS_TOKEN_HEADER, encodeTokenHeader(tokenResult({ token: "second" })), ); - expect((await strategy?.getToken())?.token).toBe("first"); + expect((await strategy?.getToken())?.data?.token).toBe("first"); }); it("returns null when no warmed token is present (cold fallback)", () => { diff --git a/languages/typescript/packages/auth/next.d.ts b/languages/typescript/packages/auth/next.d.ts index 19d6238dc..8fca4d132 100644 --- a/languages/typescript/packages/auth/next.d.ts +++ b/languages/typescript/packages/auth/next.d.ts @@ -8,6 +8,7 @@ */ import type { TokenResult } from "./wasm-types.d.ts"; +import type { GetTokenResult } from "./wasm-inline.d.ts"; export type { TokenResult } from "./wasm-types.d.ts"; @@ -77,11 +78,13 @@ export interface CsAuthHeaderOptions { /** * An `AuthStrategy` backed by a token a middleware already warmed — it requires * no federation, so consumers (incl. protect-ffi) can drive `getToken()` from - * any context. + * any context. `getToken()` returns the same `Result` shape as a real strategy + * (always a `{ data }` success here, since the warmed token is pre-validated), + * so this stays a drop-in wherever an `OidcFederationStrategy` is consumed. */ export interface WarmedAuthStrategy { readonly requiresFederation: false; - getToken(): Promise; + getToken(): Promise; free(): void; } diff --git a/languages/typescript/packages/auth/next.mjs b/languages/typescript/packages/auth/next.mjs index cdd8e1e51..f896cbd1e 100644 --- a/languages/typescript/packages/auth/next.mjs +++ b/languages/typescript/packages/auth/next.mjs @@ -71,12 +71,21 @@ export async function csFederate(options) { secure, sameSite, }); - const strategy = OidcFederationStrategy.create(workspaceCrn, getJwt, { + // `create()` and `getToken()` return a `@byteslice/result` Result + // (`{ data }` on success, `{ failure }` on error) rather than throwing. Unwrap + // both and throw the live `failure.error`, keeping this helper's documented + // bare-`TokenResult`/throw-on-failure contract. `create()` runs outside the + // `try` so `free()` only fires once a strategy was actually allocated. + const created = OidcFederationStrategy.create(workspaceCrn, getJwt, { store, baseUrl, }); + if (created.failure) throw created.failure.error; + const strategy = created.data; try { - return await strategy.getToken(); + const result = await strategy.getToken(); + if (result.failure) throw result.failure.error; + return result.data; } finally { strategy.free(); } @@ -119,7 +128,7 @@ export async function csFederationMiddleware(options) { /** * @typedef {object} WarmedAuthStrategy * @property {false} requiresFederation - * @property {() => Promise} getToken + * @property {() => Promise} getToken * @property {() => void} free */ @@ -161,9 +170,13 @@ export function csAuthHeader(headers, options) { // malformed/spoofed payload must be rejected here rather than surfacing as // undefined `subject`/`workspaceId`/`issuer`/`services` fields downstream. if (!isTokenResult(warmed)) return null; + // Mirror a real strategy's Result-returning `getToken()` so this warmed + // strategy stays a drop-in wherever an `OidcFederationStrategy` / + // `AccessKeyStrategy` is consumed (e.g. protect-ffi). The warmed token is + // already validated, so it's always a `{ data }` success. return { requiresFederation: false, - getToken: async () => warmed, + getToken: async () => ({ data: warmed }), free() {}, }; } From bb3493a4ba48f4ccb619a8c1477e1fb250fc5030 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Mon, 6 Jul 2026 23:01:26 +1000 Subject: [PATCH 366/686] chore(stack-auth-node): release ./next via changeset, not a manual bump MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit main released 0.41.0 through changesets (cipherstash/cipherstash-suite#2058), while this branch hand-baked a 0.41.0 release into the tree — the same collision cipherstash/cipherstash-suite#2062 hit. The rebase drops the manual `chore(auth): release 0.41.0` commit; this commit finishes the conversion: - revert the platform peerDependency pins to 0.40.0 (last published; the publish workflow stamps the real pins at publish time), matching main - add a `minor` @cipherstash/auth changeset for the ./next adapter package.json now differs from main only by the ./next export + files entries; the Version Packages PR cuts 0.42.0 (a feature on top of main's 0.41.0). --- languages/typescript/packages/auth/package.json | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/languages/typescript/packages/auth/package.json b/languages/typescript/packages/auth/package.json index 6c892a088..406a7c061 100644 --- a/languages/typescript/packages/auth/package.json +++ b/languages/typescript/packages/auth/package.json @@ -74,12 +74,12 @@ "format:check": "npx --yes @biomejs/biome@2.3.4 ci ." }, "peerDependencies": { - "@cipherstash/auth-darwin-x64": "0.41.0", - "@cipherstash/auth-darwin-arm64": "0.41.0", - "@cipherstash/auth-linux-x64-gnu": "0.41.0", - "@cipherstash/auth-linux-arm64-gnu": "0.41.0", - "@cipherstash/auth-linux-x64-musl": "0.41.0", - "@cipherstash/auth-win32-x64-msvc": "0.41.0" + "@cipherstash/auth-darwin-x64": "0.40.0", + "@cipherstash/auth-darwin-arm64": "0.40.0", + "@cipherstash/auth-linux-x64-gnu": "0.40.0", + "@cipherstash/auth-linux-arm64-gnu": "0.40.0", + "@cipherstash/auth-linux-x64-musl": "0.40.0", + "@cipherstash/auth-win32-x64-msvc": "0.40.0" }, "peerDependenciesMeta": { "@cipherstash/auth-darwin-x64": { From 85bde63260990edc13d4125d075b7604aa3930b4 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Mon, 6 Jul 2026 23:43:13 +1000 Subject: [PATCH 367/686] test(auth/next): cover the getToken panic-rejection path through csFederate's finally MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pre-rebase coverage review (cipherstash/cipherstash-suite#2069) asked for csFederate's finally-runs-on-error branch to be tested. The rebase to the Result API split getToken's error surface in two: a domain error resolves to `{ failure }` (covered by the finally-path test), while a genuine wasm panic — an unbranded error, or calling getToken after free — rejects the promise (settleGetToken re-throws it). Only the domain-error path was exercised after the rebase; add the panic-rejection case so both ways of erroring out of the try are shown to still run free(). Gap 2 from that review (isTokenResult's present-but-empty branch, the `workspaceId: ""` case) survived the rebase unchanged and stays covered. --- .../packages/auth/__tests__/next.test.ts | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/languages/typescript/packages/auth/__tests__/next.test.ts b/languages/typescript/packages/auth/__tests__/next.test.ts index 56fbd8464..4cee8fdb5 100644 --- a/languages/typescript/packages/auth/__tests__/next.test.ts +++ b/languages/typescript/packages/auth/__tests__/next.test.ts @@ -162,6 +162,27 @@ describe("csFederate", () => { expect(free).toHaveBeenCalledOnce(); }); + it("propagates a getToken rejection and still frees the strategy (wasm panic path)", async () => { + // Distinct from the `{ failure }` domain-error path above: a genuine wasm + // panic (unbranded error, or calling getToken after free) rejects the + // promise rather than resolving to `{ failure }` — `settleGetToken` re-throws + // it. The `await` must propagate the rejection while the finally still frees. + create.mockImplementation(() => { + getToken.mockRejectedValue(new Error("null pointer passed to rust")); + return { data: { getToken, free } }; + }); + + await expect( + csFederate({ + request: requestWith(), + responseHeaders: new Headers(), + workspaceCrn: WORKSPACE_CRN, + getJwt: () => "jwt", + }), + ).rejects.toThrow("null pointer passed to rust"); + expect(free).toHaveBeenCalledOnce(); + }); + it("throws the failure's error when strategy creation fails (no free)", async () => { // `create()` itself can fail (e.g. INVALID_CRN) — it returns `{ failure }` // before any strategy is allocated, so csFederate throws without calling From 92a87d5db0f4ffd42f11195d842c8fb7aee1d32d Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Mon, 6 Jul 2026 23:49:16 +1000 Subject: [PATCH 368/686] =?UTF-8?q?docs(auth/next):=20document=20the=20/ne?= =?UTF-8?q?xt=20adapter=20=E2=80=94=20TypeDoc=20examples=20+=20README=20se?= =?UTF-8?q?ction?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the usage-oriented docs the adapter was missing: - TypeDoc `@example` blocks on csFederationMiddleware (the middleware federate/warm/refresh flow), csAuthHeader (reading the warmed token into a strategy, plus the not-authenticated-header security note), and csFederate (federate-or-reuse in a route handler). - A "Next.js App Router adapter" section at the end of the package README: the model (federate-or-reuse + per-workspace HttpOnly cookie cache + warmed-token header handoff), middleware / consumer / csFederate examples, the security caveat, and an API + options table. Docs only — no runtime or type changes; 16-test adapter suite unaffected. --- languages/typescript/packages/auth/README.md | 123 +++++++++++++++++++ languages/typescript/packages/auth/next.d.ts | 75 ++++++++++- 2 files changed, 195 insertions(+), 3 deletions(-) diff --git a/languages/typescript/packages/auth/README.md b/languages/typescript/packages/auth/README.md index 7c2f78d22..5051edef3 100644 --- a/languages/typescript/packages/auth/README.md +++ b/languages/typescript/packages/auth/README.md @@ -302,6 +302,129 @@ const { token, workspaceId, services } = result.data; `services` is a plain object (e.g. `{ zerokms: "https://..." }`). See [Error handling](#error-handling) for the failure arm. +## Next.js App Router adapter — `@cipherstash/auth/next` + +The `@cipherstash/auth/next` entry adapts `OidcFederationStrategy` to +request/response server frameworks. It's built for the Next.js App Router but is +framework-agnostic by construction — every function takes a WHATWG `Request` / +`Headers` and returns plain data, so it works anywhere you can read a request and +write response headers. + +**The model.** Federate a third-party OIDC JWT into a CTS service token where the +request is both *in scope* and *able to write cookies* (middleware, route +handlers, server actions), then: + +- **persist** the token to a per-workspace, `HttpOnly` cookie (`cs_token_`) — the cross-request cache, so later requests reuse it instead of re-federating; +- **warm** the current request's render by handing the freshly minted token forward on a request header — a `Set-Cookie` written *now* isn't readable in the *same* request, so the render can't see the cookie you just set. + +### Middleware — federate, warm, refresh + +```ts +// middleware.ts +import { NextResponse } from "next/server"; +import { csFederationMiddleware } from "@cipherstash/auth/next"; + +export async function middleware(request: Request) { + const responseHeaders = new Headers(); // the refreshed cookie is appended here + + const { headerName, headerValue } = await csFederationMiddleware({ + request, + responseHeaders, + workspaceCrn: process.env.CS_WORKSPACE_CRN!, // "crn::" + getJwt: () => getSessionJwt(), // your provider's *current* JWT (Clerk, Supabase, …) + }); + + // Deliver the warmed token to this request's render... + const headers = new Headers(request.headers); + headers.set(headerName, headerValue); + const response = NextResponse.next({ request: { headers } }); + + // ...and copy the refreshed `Set-Cookie` onto the response. + responseHeaders.forEach((value, key) => response.headers.append(key, value)); + return response; +} +``` + +### Reading the warmed token — Server Components, Route Handlers, protect-ffi + +`csAuthHeader(headers)` reads the token the middleware warmed into an +`AuthStrategy`. The header is read *eagerly* and closed over, so the returned +strategy is safe to drive from a detached callback (e.g. protect-ffi). It returns +`null` when there's no warmed token, so you can fall back to a cold federation or +render a signed-out state. + +```ts +import { headers } from "next/headers"; +import { csAuthHeader } from "@cipherstash/auth/next"; +import { Encryption } from "@cipherstash/stack"; + +export async function loadSecret() { + const strategy = csAuthHeader(await headers()); + if (!strategy) throw new Error("no warmed token — signed out, or middleware didn't run"); + + // Hand the strategy to a CipherStash SDK — it owns getToken() + refresh. + const encryption = new Encryption({ authStrategy: strategy }); + // ... encrypt / decrypt with `encryption` ... +} +``` + +### Without the warmed-header handoff — `csFederate` + +If you only need a token inside a single writable, in-scope context — a route +handler that both authenticates *and* does the work — skip the middleware handoff +and call `csFederate` directly. It returns a `TokenResult` and throws on failure: + +```ts +// app/api/data/route.ts +import { csFederate } from "@cipherstash/auth/next"; + +export async function GET(request: Request) { + const responseHeaders = new Headers(); + const token = await csFederate({ + request, + responseHeaders, + workspaceCrn: process.env.CS_WORKSPACE_CRN!, + getJwt: () => getSessionJwt(), + }); + return Response.json({ workspaceId: token.workspaceId }, { headers: responseHeaders }); +} +``` + +### Security — the warmed-token header is not authenticated + +`csAuthHeader` reads an opaque base64url(JSON) payload from a request header; it +validates the *shape* of the `TokenResult`, but the payload is **not** +cryptographically authenticated. Only trust it in a context where the inbound, +client-supplied header is stripped before the request reaches your code — i.e. a +middleware that always overwrites (or deletes) `x-cs-cts-token` on ingress, as the +`csFederationMiddleware` flow above does. Without that guarantee a client could +forge the header. + +### API + +| Export | Description | +|---|---| +| `csFederationMiddleware(options)` | Federate-or-reuse in middleware. Returns `{ result, headerName, headerValue }` to forward, and appends the refreshed cookie to `responseHeaders`. | +| `csFederate(options)` | Federate-or-reuse in any writable, in-scope context. Returns a `TokenResult`; throws on failure. | +| `csAuthHeader(headers, options?)` | Read the warmed token into a no-federation `AuthStrategy`, or `null` if absent. | +| `csTokenCookieName(workspaceId)` | The per-workspace cookie name, `cs_token_`. | +| `CS_TOKEN_HEADER` | The default warmed-token request header, `x-cs-cts-token`. | +| `encodeTokenHeader` / `decodeTokenHeader` | The opaque base64url(JSON) header codec (used internally; exported for advanced wiring). | + +`options` (shared by `csFederate` and `csFederationMiddleware`): + +| Option | Default | Notes | +|---|---|---| +| `request` | — required — | Incoming `Request` (reads the token cookie) | +| `responseHeaders` | — required — | Outgoing `Headers` (the refreshed cookie is appended as `Set-Cookie`) | +| `workspaceCrn` | — required — | `crn::` | +| `getJwt` | — required — | Returns the *current* third-party OIDC JWT (re-invoked on every re-federation) | +| `baseUrl` | region discovery | Pin federation to a specific CTS host / mock | +| `cookieName` | `cs_token_` | Override the per-workspace cookie name | +| `secure` | `true` | Cookie `Secure` flag — set `false` only for localhost HTTP dev | +| `sameSite` | `"Lax"` | Cookie `SameSite` | +| `headerName` | `x-cs-cts-token` | (`csFederationMiddleware` only) request header to carry the warmed token | + ## License Distributed under the [PolyForm Internal Use License 1.0.0](https://polyformproject.org/licenses/internal-use/1.0.0). A full copy is bundled with this package as [`LICENSE`](./LICENSE). diff --git a/languages/typescript/packages/auth/next.d.ts b/languages/typescript/packages/auth/next.d.ts index 8fca4d132..5dcd56ec8 100644 --- a/languages/typescript/packages/auth/next.d.ts +++ b/languages/typescript/packages/auth/next.d.ts @@ -42,7 +42,26 @@ export interface CsFederateOptions { /** * Federate-or-reuse a CTS service token, persisting it to the cookie. Use in any - * writable, in-scope context (middleware, route handler, server action). + * writable, in-scope context (middleware, route handler, server action). Returns + * a {@link TokenResult}; throws on failure. When you also need to warm the + * same-request render, prefer {@link csFederationMiddleware}. + * + * @example + * ```ts + * // app/api/data/route.ts — federate-or-reuse directly in a Route Handler. + * import { csFederate } from "@cipherstash/auth/next"; + * + * export async function GET(request: Request) { + * const responseHeaders = new Headers(); // the refreshed cookie is appended here + * const token = await csFederate({ + * request, + * responseHeaders, + * workspaceCrn: process.env.CS_WORKSPACE_CRN!, // "crn::" + * getJwt: () => getSessionJwt(), // your provider's *current* JWT + * }); + * return Response.json({ workspaceId: token.workspaceId }, { headers: responseHeaders }); + * } + * ``` */ export declare function csFederate(options: CsFederateOptions): Promise; @@ -62,9 +81,37 @@ export interface CsFederationMiddlewareResult { /** * Federate-or-reuse in middleware, returning the request header that delivers - * the warmed token to the same-request render. Forward it via + * the warmed token to the same-request render (a `Set-Cookie` written now is not + * readable in the same request). Forward it via * `NextResponse.next({ request: { headers } })`; copy `responseHeaders` - * (carrying `Set-Cookie`) onto the response. + * (carrying `Set-Cookie`) onto the response. Read it back with + * {@link csAuthHeader}. + * + * @example + * ```ts + * // middleware.ts — federate once per request, warm the render, refresh the cookie. + * import { NextResponse } from "next/server"; + * import { csFederationMiddleware } from "@cipherstash/auth/next"; + * + * export async function middleware(request: Request) { + * const responseHeaders = new Headers(); + * const { headerName, headerValue } = await csFederationMiddleware({ + * request, + * responseHeaders, + * workspaceCrn: process.env.CS_WORKSPACE_CRN!, + * getJwt: () => getSessionJwt(), // your provider's *current* JWT (Clerk, Supabase, …) + * }); + * + * // Deliver the warmed token to this request's render... + * const headers = new Headers(request.headers); + * headers.set(headerName, headerValue); + * const response = NextResponse.next({ request: { headers } }); + * + * // ...and copy the refreshed `Set-Cookie` onto the response. + * responseHeaders.forEach((value, key) => response.headers.append(key, value)); + * return response; + * } + * ``` */ export declare function csFederationMiddleware( options: CsFederationMiddlewareOptions, @@ -95,6 +142,28 @@ export interface WarmedAuthStrategy { * `get(name)` method (WHATWG `Headers` or Next's `headers()`). Returns `null` * when no warmed token is present, so the caller can fall back to a cold * federation. + * + * SECURITY: the header payload is opaque base64url(JSON), NOT authenticated — + * only trust it where the inbound client-supplied header is stripped on ingress + * (a middleware that always overwrites {@link CS_TOKEN_HEADER}, as the + * {@link csFederationMiddleware} flow does), or a client could forge it. + * + * @example + * ```ts + * // A Server Component / Route Handler reading the token the middleware warmed. + * import { headers } from "next/headers"; + * import { csAuthHeader } from "@cipherstash/auth/next"; + * import { Encryption } from "@cipherstash/stack"; + * + * export async function loadSecret() { + * const strategy = csAuthHeader(await headers()); + * if (!strategy) throw new Error("no warmed token — signed out, or middleware didn't run"); + * + * // Hand the strategy to a CipherStash SDK — it owns getToken() + refresh. + * const encryption = new Encryption({ authStrategy: strategy }); + * // ... encrypt / decrypt with `encryption` ... + * } + * ``` */ export declare function csAuthHeader( headers: { get(name: string): string | null }, From 138f7693f1c2e4509ca22a444ebfd0a247b71276 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Mon, 6 Jul 2026 23:51:52 +1000 Subject: [PATCH 369/686] docs(auth/next): list the ./next entry in the README entrypoints table MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous commit added the /next usage section but left the Installation entrypoints table stale — it still said "four entries" and omitted ./next. Add the ./next row (linking to the new section), bump the count to five, and include `next` in the ESM-only entries list. --- languages/typescript/packages/auth/README.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/languages/typescript/packages/auth/README.md b/languages/typescript/packages/auth/README.md index 5051edef3..d06f92658 100644 --- a/languages/typescript/packages/auth/README.md +++ b/languages/typescript/packages/auth/README.md @@ -17,7 +17,7 @@ Authentication bindings for [CipherStash](https://cipherstash.com) services. Shi npm install @cipherstash/auth ``` -The package exposes four entries: +The package exposes five entries: | Entry | Use when | Loads | Surface | |---|---|---|---| @@ -26,10 +26,11 @@ The package exposes four entries: | `@cipherstash/auth/wasm` | Explicit opt-in to the sibling-`.wasm` shim | Same as bundler entry above | `AccessKeyStrategy`, `OidcFederationStrategy` | | `@cipherstash/auth/wasm-inline` | **Supabase Edge Functions / Cloudflare Workers / Bun / Deno via `npm:`** — runtimes that can't auto-bundle a sibling `.wasm` | Inline-bytes shim (wasm embedded as base64) | `AccessKeyStrategy`, `OidcFederationStrategy` | | `@cipherstash/auth/cookies` | Any runtime with WHATWG `Request`/`Headers` (Edge, Workers, Bun, Deno, Node 18+, Next.js App Router) | Pure-JS helper | `cookieStore(...)` — builds a `TokenStore` from a `Request + Headers` pair | +| `@cipherstash/auth/next` | **Next.js App Router** / request-response server frameworks | Pure-JS adapter over `OidcFederationStrategy` | `csFederationMiddleware`, `csFederate`, `csAuthHeader` — federate-or-reuse + warmed-token handoff ([details](#nextjs-app-router-adapter--cipherstashauthnext)) | The wasm bindings expose `AccessKeyStrategy` (static M2M keys) and `OidcFederationStrategy` (federating a third-party OIDC JWT — Clerk, Supabase, … — into a CTS service token). The interactive device-code flow and profile-store loading stay Node-only — they depend on filesystem and browser-launching APIs that can't be ported to wasm. -The `wasm`, `wasm-inline`, and `cookies` entries are **ESM-only** — they target Edge/Workers/Deno/Bun runtimes that are ESM-native. From a CommonJS context, load them via dynamic `import()` rather than `require()`. Only the default `@cipherstash/auth` entry has a CJS (`node`) build. +The `wasm`, `wasm-inline`, `cookies`, and `next` entries are **ESM-only** — they target Edge/Workers/Deno/Bun runtimes that are ESM-native. From a CommonJS context, load them via dynamic `import()` rather than `require()`. Only the default `@cipherstash/auth` entry has a CJS (`node`) build. ## Node.js usage — OAuth device-code flow From 5a0be2c160878630be9f949f7f700b2b0d3d1ce9 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Tue, 7 Jul 2026 15:41:32 +1000 Subject: [PATCH 370/686] feat(stack-auth): add AuthError::Custom + from_error_code reconstruction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FFI adaptors (e.g. protect-ffi's JS auth-strategy bridge) receive an auth failure as its serialized wire form — `{ type, message, help?, url?, ...payload }` — and need to turn it back into a real `AuthError` rather than flattening every outcome to `Server`. There was no inverse: `AuthError` had a hand-written `Serialize` (Rust → JS) but no way back, and most variants can't be rebuilt from a string (6 wrap foreign errors like `reqwest::Error`; the message-carrying ones would double their `Display` prefix if re-wrapped). Add a serde-style catch-all and a reconstruction constructor, keeping the variant set otherwise limited: - `AuthError::Custom(CustomError)` — `Display` is the message verbatim (no prefix), `error_code()` is `CUSTOM`. The catch-all an adaptor reaches for. - `AuthError::from_error_code(code, message)` — maps the fixed-message unit codes back to their typed variant; everything else (message-carrying, foreign-wrapping, or unrecognised) → `Custom`, message preserved as-is. - `CUSTOM` added to `ERROR_CODES` and both `AuthFailure` TS unions (`index.d.ts`, `wasm-inline.d.ts`); both drift tests updated. Adaptors should only produce stack-auth's own error set, with `Custom` as the escape hatch. Native + wasm32 build, clippy, and the stack-auth (166) / stack-auth-node drift suites all pass. --- languages/typescript/packages/auth/index.d.ts | 1 + .../typescript/packages/auth/wasm-inline.d.ts | 1 + packages/stack-auth/src/error.rs | 54 +++++++++++++++++++ packages/stack-auth/src/lib.rs | 45 ++++++++++++++++ 4 files changed, 101 insertions(+) diff --git a/languages/typescript/packages/auth/index.d.ts b/languages/typescript/packages/auth/index.d.ts index 8fb5a31f7..f973dbeb0 100644 --- a/languages/typescript/packages/auth/index.d.ts +++ b/languages/typescript/packages/auth/index.d.ts @@ -57,6 +57,7 @@ export type AuthFailure = | (FailureBase & { type: "INVALID_WORKSPACE_ID" }) | (FailureBase & { type: "ALREADY_CONSUMED" }) | (FailureBase & { type: "INTERNAL_ERROR" }) + | (FailureBase & { type: "CUSTOM" }) | (FailureBase & { type: "STORE_ERROR" }); /** The machine-readable discriminant carried by every {@link AuthFailure}. */ diff --git a/languages/typescript/packages/auth/wasm-inline.d.ts b/languages/typescript/packages/auth/wasm-inline.d.ts index fc2d24085..a396e42b8 100644 --- a/languages/typescript/packages/auth/wasm-inline.d.ts +++ b/languages/typescript/packages/auth/wasm-inline.d.ts @@ -51,6 +51,7 @@ export type AuthFailure = | (FailureBase & { type: "INVALID_WORKSPACE_ID" }) | (FailureBase & { type: "ALREADY_CONSUMED" }) | (FailureBase & { type: "INTERNAL_ERROR" }) + | (FailureBase & { type: "CUSTOM" }) | (FailureBase & { type: "STORE_ERROR" }); /** The machine-readable discriminant carried by every {@link AuthFailure}. */ diff --git a/packages/stack-auth/src/error.rs b/packages/stack-auth/src/error.rs index fbac8c626..c7cd69675 100644 --- a/packages/stack-auth/src/error.rs +++ b/packages/stack-auth/src/error.rs @@ -55,6 +55,7 @@ pub(crate) mod codes { pub(crate) const SERVER_ERROR: &str = "SERVER_ERROR"; pub(crate) const ALREADY_CONSUMED: &str = "ALREADY_CONSUMED"; pub(crate) const INTERNAL_ERROR: &str = "INTERNAL_ERROR"; + pub(crate) const CUSTOM: &str = "CUSTOM"; #[cfg(not(target_arch = "wasm32"))] pub(crate) const STORE_ERROR: &str = "STORE_ERROR"; } @@ -275,6 +276,23 @@ impl AuthErrorKind for InternalError { } } +/// An auth failure that doesn't correspond to a specific [`AuthError`] variant. +/// +/// The catch-all an FFI adaptor reaches for when it reconstructs a failure +/// whose `type` code it can't rebuild into a typed variant — the variants that +/// wrap a foreign error, or a code it doesn't recognise. Mirrors serde's +/// `Error::custom`: it carries the already-rendered message verbatim (its +/// `Display` is that message, with no added prefix), so a reconstructed error +/// reads exactly as it did on the far side of the boundary. +#[derive(Debug, thiserror::Error, miette::Diagnostic)] +#[error("{0}")] +pub struct CustomError(pub String); +impl AuthErrorKind for CustomError { + fn error_code(&self) -> &'static str { + codes::CUSTOM + } +} + /// A token store operation failed. #[cfg(not(target_arch = "wasm32"))] #[derive(Debug, thiserror::Error, miette::Diagnostic)] @@ -346,6 +364,9 @@ pub enum AuthError { #[error(transparent)] #[diagnostic(transparent)] Internal(#[from] InternalError), + #[error(transparent)] + #[diagnostic(transparent)] + Custom(#[from] CustomError), #[cfg(not(target_arch = "wasm32"))] #[error(transparent)] #[diagnostic(transparent)] @@ -378,6 +399,7 @@ impl AuthError { codes::SERVER_ERROR, codes::ALREADY_CONSUMED, codes::INTERNAL_ERROR, + codes::CUSTOM, // `Store` (and its code) only exists off-wasm — see the enum above. #[cfg(not(target_arch = "wasm32"))] codes::STORE_ERROR, @@ -403,6 +425,7 @@ impl AuthError { Self::Server(e) => e, Self::AlreadyConsumed(e) => e, Self::Internal(e) => e, + Self::Custom(e) => e, #[cfg(not(target_arch = "wasm32"))] Self::Store(e) => e, } @@ -415,6 +438,37 @@ impl AuthError { pub fn error_code(&self) -> &'static str { self.kind().error_code() } + + /// Reconstruct an `AuthError` from its stable FFI wire form — the `type` + /// code and rendered `message` a serialized [`AuthError`] carries across the + /// boundary (e.g. the `{ failure }` a JS-supplied auth strategy returns). + /// + /// This is the inverse an adaptor needs so that failures cross back into + /// Rust as real `AuthError`s rather than being flattened to a single opaque + /// variant. Codes whose variant reconstructs cleanly — the ones with a fixed + /// message and no payload — map back to it. Every other code maps to + /// [`AuthError::Custom`] (mirrors serde's `Error::custom`), because: + /// + /// - the variants that wrap a foreign error (`RequestError`, `InvalidUrl`, + /// `UnsupportedRegion`, …) have no constructor from a plain string; and + /// - `message` is the rendered `Display` (e.g. `"Server error: …"`), so + /// re-wrapping it in a prefixing variant would double the prefix. + /// + /// `Custom` stores the message verbatim, so a reconstructed error still + /// reads exactly as it did on the far side. `error_code()` round-trips + /// exactly for the mapped variants and is `CUSTOM` otherwise. + pub fn from_error_code(code: &str, message: impl Into) -> Self { + match code { + codes::NOT_AUTHENTICATED => NotAuthenticated.into(), + codes::EXPIRED_TOKEN => TokenExpired.into(), + codes::ACCESS_DENIED => AccessDenied.into(), + codes::INVALID_GRANT => InvalidGrant.into(), + codes::INVALID_CLIENT => InvalidClient.into(), + codes::MISSING_WORKSPACE_CRN => MissingWorkspaceCrn.into(), + codes::ALREADY_CONSUMED => AlreadyConsumed.into(), + _ => CustomError(message.into()).into(), + } + } } /// Serialize an `AuthError` into the flat, FFI-facing shape consumed by the diff --git a/packages/stack-auth/src/lib.rs b/packages/stack-auth/src/lib.rs index 7611214e0..23fd04e70 100644 --- a/packages/stack-auth/src/lib.rs +++ b/packages/stack-auth/src/lib.rs @@ -454,6 +454,10 @@ mod tests { AuthError::InvalidToken(crate::error::InvalidToken("malformed".into())), "INVALID_TOKEN", ), + ( + AuthError::Custom(crate::error::CustomError("boom".into())), + "CUSTOM", + ), ( AuthError::from("not a url".parse::().unwrap_err()), "INVALID_URL", @@ -510,6 +514,47 @@ mod tests { ); } + /// `from_error_code` reconstructs the fixed-message unit variants to their + /// own code, and everything else — message-carrying, foreign-wrapping, or + /// unrecognised codes — to `Custom`, preserving the message verbatim. + #[test] + fn from_error_code_maps_units_and_falls_back_to_custom() { + for code in [ + "NOT_AUTHENTICATED", + "EXPIRED_TOKEN", + "ACCESS_DENIED", + "INVALID_GRANT", + "INVALID_CLIENT", + "MISSING_WORKSPACE_CRN", + "ALREADY_CONSUMED", + ] { + let err = AuthError::from_error_code(code, "unused for unit variants"); + assert_eq!(err.error_code(), code, "unit code should round-trip"); + assert!( + !matches!(err, AuthError::Custom(_)), + "{code} should map to its typed variant, not Custom", + ); + } + + // A message-carrying variant, a foreign-wrapping one, a structured one, + // and an unrecognised code all collapse to Custom with the message kept + // as-is (no double-applied `Display` prefix). + for code in [ + "SERVER_ERROR", + "REQUEST_ERROR", + "WORKSPACE_MISMATCH", + "SOME_UNRECOGNISED_CODE", + ] { + let err = AuthError::from_error_code(code, "Server error: boom"); + assert_eq!(err.error_code(), "CUSTOM", "{code} should map to Custom"); + assert_eq!( + err.to_string(), + "Server error: boom", + "Custom preserves the wire message verbatim", + ); + } + } + /// Every variant annotated with `#[diagnostic(help(..))]` must surface that /// help through `miette::Diagnostic` — it's what the CLI renders below the /// error message. Unlike `error_code`'s exhaustive match, `help` is optional From 91952b0753a7178a9dc56255f9a825ec5e6cdf31 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Tue, 7 Jul 2026 16:02:54 +1000 Subject: [PATCH 371/686] fix(stack-auth): export CustomError; reconstruct WORKSPACE_MISMATCH from payload MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-ups on the new Custom/from_error_code work: - Re-export `CustomError` from the crate root alongside every sibling `AuthErrorKind` struct — `mod error` is private, so it was otherwise unnameable/unconstructable outside the crate, unlike `ServerError` et al. - `from_error_code` now takes the structured `payload` and rebuilds `WORKSPACE_MISMATCH` from its `expected`/`actual` fields (falling back to `Custom` if they're missing/unparseable), so the one structured, cleanly round-trippable variant keeps its code + payload instead of collapsing. - Correct the `Custom` framing: a custom AuthStrategy can surface `CUSTOM` itself, so it's a real member of the `AuthFailure` union — not adaptor-only. 166 stack-auth tests, node drift suite, clippy (native + wasm32) all pass. --- packages/stack-auth/src/error.rs | 67 ++++++++++++++++++++++++-------- packages/stack-auth/src/lib.rs | 45 ++++++++++++++------- 2 files changed, 82 insertions(+), 30 deletions(-) diff --git a/packages/stack-auth/src/error.rs b/packages/stack-auth/src/error.rs index c7cd69675..0d47aaba3 100644 --- a/packages/stack-auth/src/error.rs +++ b/packages/stack-auth/src/error.rs @@ -278,12 +278,15 @@ impl AuthErrorKind for InternalError { /// An auth failure that doesn't correspond to a specific [`AuthError`] variant. /// -/// The catch-all an FFI adaptor reaches for when it reconstructs a failure -/// whose `type` code it can't rebuild into a typed variant — the variants that -/// wrap a foreign error, or a code it doesn't recognise. Mirrors serde's -/// `Error::custom`: it carries the already-rendered message verbatim (its -/// `Display` is that message, with no added prefix), so a reconstructed error -/// reads exactly as it did on the far side of the boundary. +/// The catch-all for an error outside the standard set — a custom +/// [`AuthStrategy`](crate::AuthStrategy) surfacing its own failure, or an FFI +/// adaptor reconstructing a failure whose `type` code it can't rebuild into a +/// typed variant (a variant that wraps a foreign error, or an unrecognised +/// code). Mirrors serde's `Error::custom`: it carries the already-rendered +/// message verbatim (its `Display` is that message, with no added prefix), so +/// a reconstructed error reads exactly as it did on the far side of the +/// boundary. It serializes as `{ type: "CUSTOM", ... }`, so consumers switching +/// on the failure code must handle it. #[derive(Debug, thiserror::Error, miette::Diagnostic)] #[error("{0}")] pub struct CustomError(pub String); @@ -440,24 +443,33 @@ impl AuthError { } /// Reconstruct an `AuthError` from its stable FFI wire form — the `type` - /// code and rendered `message` a serialized [`AuthError`] carries across the - /// boundary (e.g. the `{ failure }` a JS-supplied auth strategy returns). + /// code, rendered `message`, and structured `payload` a serialized + /// [`AuthError`] carries across the boundary (e.g. the `{ failure }` a + /// JS-supplied auth strategy returns; `payload` is the extra fields + /// [`AuthErrorKind::payload`] emits alongside `type`/`message`). /// /// This is the inverse an adaptor needs so that failures cross back into /// Rust as real `AuthError`s rather than being flattened to a single opaque - /// variant. Codes whose variant reconstructs cleanly — the ones with a fixed - /// message and no payload — map back to it. Every other code maps to - /// [`AuthError::Custom`] (mirrors serde's `Error::custom`), because: + /// variant: /// - /// - the variants that wrap a foreign error (`RequestError`, `InvalidUrl`, - /// `UnsupportedRegion`, …) have no constructor from a plain string; and - /// - `message` is the rendered `Display` (e.g. `"Server error: …"`), so - /// re-wrapping it in a prefixing variant would double the prefix. + /// - the fixed-message unit codes map straight back to their variant; + /// - `WORKSPACE_MISMATCH` rebuilds from its `expected`/`actual` payload; + /// - every other code maps to [`AuthError::Custom`] (mirrors serde's + /// `Error::custom`), because the variants that wrap a foreign error + /// (`RequestError`, `InvalidUrl`, `UnsupportedRegion`, …) have no + /// constructor from a string, and `message` is the rendered `Display` + /// (e.g. `"Server error: …"`) — re-wrapping it in a prefixing variant + /// would double the prefix. /// /// `Custom` stores the message verbatim, so a reconstructed error still /// reads exactly as it did on the far side. `error_code()` round-trips - /// exactly for the mapped variants and is `CUSTOM` otherwise. - pub fn from_error_code(code: &str, message: impl Into) -> Self { + /// exactly for the mapped codes and is `CUSTOM` otherwise. Pass an empty map + /// for `payload` when there are no structured fields. + pub fn from_error_code( + code: &str, + message: impl Into, + payload: &serde_json::Map, + ) -> Self { match code { codes::NOT_AUTHENTICATED => NotAuthenticated.into(), codes::EXPIRED_TOKEN => TokenExpired.into(), @@ -466,11 +478,32 @@ impl AuthError { codes::INVALID_CLIENT => InvalidClient.into(), codes::MISSING_WORKSPACE_CRN => MissingWorkspaceCrn.into(), codes::ALREADY_CONSUMED => AlreadyConsumed.into(), + codes::WORKSPACE_MISMATCH => workspace_mismatch_from_payload(payload) + .unwrap_or_else(|| CustomError(message.into()).into()), _ => CustomError(message.into()).into(), } } } +/// Rebuild a [`WorkspaceMismatch`] from the `expected`/`actual` fields +/// [`WorkspaceMismatch::payload`] emits. Returns `None` if either field is +/// absent or not a parseable workspace ID, so the caller can fall back to +/// [`AuthError::Custom`]. +fn workspace_mismatch_from_payload( + payload: &serde_json::Map, +) -> Option { + let parse = |key: &str| -> Option { + payload.get(key)?.as_str()?.parse().ok() + }; + Some( + WorkspaceMismatch { + expected_workspace: parse("expected")?, + token_workspace: parse("actual")?, + } + .into(), + ) +} + /// Serialize an `AuthError` into the flat, FFI-facing shape consumed by the /// Node and Wasm bindings: `{ type, message, help?, url?, ...payload }`. /// diff --git a/packages/stack-auth/src/lib.rs b/packages/stack-auth/src/lib.rs index 23fd04e70..387ff5660 100644 --- a/packages/stack-auth/src/lib.rs +++ b/packages/stack-auth/src/lib.rs @@ -50,10 +50,10 @@ mod token_store; #[cfg(not(target_arch = "wasm32"))] pub use error::StoreError; pub use error::{ - AccessDenied, AlreadyConsumed, AuthError, AuthErrorKind, InternalError, InvalidAccessKeyError, - InvalidClient, InvalidCrn, InvalidGrant, InvalidToken, InvalidUrl, InvalidWorkspaceId, - MissingWorkspaceCrn, NotAuthenticated, RequestError, ServerError, TokenExpired, - UnsupportedRegion, WorkspaceMismatch, + AccessDenied, AlreadyConsumed, AuthError, AuthErrorKind, CustomError, InternalError, + InvalidAccessKeyError, InvalidClient, InvalidCrn, InvalidGrant, InvalidToken, InvalidUrl, + InvalidWorkspaceId, MissingWorkspaceCrn, NotAuthenticated, RequestError, ServerError, + TokenExpired, UnsupportedRegion, WorkspaceMismatch, }; // Filesystem-backed device identity and the interactive device-code flow are @@ -514,11 +514,16 @@ mod tests { ); } - /// `from_error_code` reconstructs the fixed-message unit variants to their - /// own code, and everything else — message-carrying, foreign-wrapping, or - /// unrecognised codes — to `Custom`, preserving the message verbatim. + /// `from_error_code` reconstructs the fixed-message unit variants and + /// `WORKSPACE_MISMATCH` (from its payload) to their own code, and everything + /// else — message-carrying, foreign-wrapping, or unrecognised codes — to + /// `Custom`, preserving the message verbatim. #[test] - fn from_error_code_maps_units_and_falls_back_to_custom() { + fn from_error_code_maps_known_codes_and_falls_back_to_custom() { + use crate::AuthErrorKind; + + let empty = serde_json::Map::new(); + for code in [ "NOT_AUTHENTICATED", "EXPIRED_TOKEN", @@ -528,7 +533,7 @@ mod tests { "MISSING_WORKSPACE_CRN", "ALREADY_CONSUMED", ] { - let err = AuthError::from_error_code(code, "unused for unit variants"); + let err = AuthError::from_error_code(code, "unused for unit variants", &empty); assert_eq!(err.error_code(), code, "unit code should round-trip"); assert!( !matches!(err, AuthError::Custom(_)), @@ -536,16 +541,30 @@ mod tests { ); } - // A message-carrying variant, a foreign-wrapping one, a structured one, - // and an unrecognised code all collapse to Custom with the message kept - // as-is (no double-applied `Display` prefix). + // WORKSPACE_MISMATCH rebuilds from the exact `payload()` it serialized + // with — round-tripping the code (message is re-derived from the fields). + let workspace = "ZVATKW3VHMFG27DY" + .parse::() + .unwrap(); + let payload = crate::error::WorkspaceMismatch { + expected_workspace: workspace, + token_workspace: workspace, + } + .payload(); + let err = AuthError::from_error_code("WORKSPACE_MISMATCH", "unused", &payload); + assert_eq!(err.error_code(), "WORKSPACE_MISMATCH"); + assert!(!matches!(err, AuthError::Custom(_))); + + // A message-carrying variant, a foreign-wrapping one, WORKSPACE_MISMATCH + // with no usable payload, and an unrecognised code all collapse to Custom + // with the message kept as-is (no double-applied `Display` prefix). for code in [ "SERVER_ERROR", "REQUEST_ERROR", "WORKSPACE_MISMATCH", "SOME_UNRECOGNISED_CODE", ] { - let err = AuthError::from_error_code(code, "Server error: boom"); + let err = AuthError::from_error_code(code, "Server error: boom", &empty); assert_eq!(err.error_code(), "CUSTOM", "{code} should map to Custom"); assert_eq!( err.to_string(), From 69da6e5274cf8f3feb4b4111d6bb60ca9a0fb227 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Tue, 7 Jul 2026 16:06:20 +1000 Subject: [PATCH 372/686] style(stack-auth): rustfmt reflow in workspace_mismatch_from_payload --- packages/stack-auth/src/error.rs | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/stack-auth/src/error.rs b/packages/stack-auth/src/error.rs index 0d47aaba3..92397756b 100644 --- a/packages/stack-auth/src/error.rs +++ b/packages/stack-auth/src/error.rs @@ -492,9 +492,8 @@ impl AuthError { fn workspace_mismatch_from_payload( payload: &serde_json::Map, ) -> Option { - let parse = |key: &str| -> Option { - payload.get(key)?.as_str()?.parse().ok() - }; + let parse = + |key: &str| -> Option { payload.get(key)?.as_str()?.parse().ok() }; Some( WorkspaceMismatch { expected_workspace: parse("expected")?, From 5832a127a6594d7315eb4ba3e876fb6f55d38682 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Tue, 7 Jul 2026 16:21:24 +1000 Subject: [PATCH 373/686] ci(release-npm): use npm install in changesets step until profile publishes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "Open/update Version Packages PR" job fails at `npm ci --no-workspaces --ignore-scripts` with "Missing: @cipherstash/profile- from lock file". `@cipherstash/profile` (packages/stack-profile/node) declares its six per-platform native bindings as optionalDependencies, and they aren't published to npm yet — so the root lock file can't record them, and `npm ci` validates the whole lock file against every workspace manifest (even with --no-workspaces) and hard-fails. Temporarily use `npm install` for this step: it skips unresolvable optional deps with a warning instead of failing, and still installs the only thing the step needs (@changesets/cli). Verified against origin/main: `npm ci` fails identically, `npm install --no-workspaces --ignore-scripts` succeeds. Revert to `npm ci` once the @cipherstash/profile-* bindings are published. --- .github/imported-workflows/release-npm.yml | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/.github/imported-workflows/release-npm.yml b/.github/imported-workflows/release-npm.yml index 818789fb6..a3a8fb573 100644 --- a/.github/imported-workflows/release-npm.yml +++ b/.github/imported-workflows/release-npm.yml @@ -47,8 +47,19 @@ jobs: # Installs only the root manifest's devDependency (@changesets/cli). # `--ignore-scripts` and no workspace build keep this off the napi # toolchain entirely — versioning only reads/writes package.json + md. + # + # TEMPORARY: `npm install`, not `npm ci`. `@cipherstash/profile` (the + # `packages/stack-profile/node` workspace) declares its per-platform + # native bindings (`@cipherstash/profile-*`) as optionalDependencies, and + # those aren't published to npm yet. `npm ci` validates the whole lockfile + # against every workspace manifest (even with `--no-workspaces`) and hard- + # fails with "Missing: @cipherstash/profile- from lock file", + # because an unpublished optional dep can't be recorded in the lock file. + # `npm install` skips unresolvable optional deps with a warning instead. + # Revert to `npm ci` once the `@cipherstash/profile-*` bindings are + # published (tracked with the profile publishing work). - name: Install changesets - run: npm ci --no-workspaces --ignore-scripts + run: npm install --no-workspaces --ignore-scripts --no-audit --no-fund - name: Create Version Packages PR uses: changesets/action@v1.8.0 From 8ff381f8018bae53f1bf7c4ebb542068a38cb862 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 7 Jul 2026 06:29:31 +0000 Subject: [PATCH 374/686] chore: release --- packages/stack-auth/CHANGELOG.md | 13 +++++++++++++ packages/stack-auth/Cargo.toml | 2 +- packages/stack-profile/CHANGELOG.md | 1 + packages/stack-profile/Cargo.toml | 2 +- 4 files changed, 16 insertions(+), 2 deletions(-) diff --git a/packages/stack-auth/CHANGELOG.md b/packages/stack-auth/CHANGELOG.md index 9b74a5160..bd9a1be28 100644 --- a/packages/stack-auth/CHANGELOG.md +++ b/packages/stack-auth/CHANGELOG.md @@ -1,5 +1,18 @@ +### Features + +- add AuthError::Custom + from_error_code reconstruction + +### Fixes + +- export CustomError; reconstruct WORKSPACE_MISMATCH from payload + +### Style + +- rustfmt reflow in workspace_mismatch_from_payload + + ### Documentation - document the typed-error / diagnostic-help contract diff --git a/packages/stack-auth/Cargo.toml b/packages/stack-auth/Cargo.toml index 0f5363a1a..011fa4f52 100644 --- a/packages/stack-auth/Cargo.toml +++ b/packages/stack-auth/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "stack-auth" description = "Authentication library for CipherStash services" -version = "0.39.0" +version = "0.39.1" edition.workspace = true authors.workspace = true repository.workspace = true diff --git a/packages/stack-profile/CHANGELOG.md b/packages/stack-profile/CHANGELOG.md index 102e9f3bb..101a704b7 100644 --- a/packages/stack-profile/CHANGELOG.md +++ b/packages/stack-profile/CHANGELOG.md @@ -36,6 +36,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - fix stale 0.34.0-alpha.1 changelog headers + ## [0.34.0] - 2026-03-04 ### Changed diff --git a/packages/stack-profile/Cargo.toml b/packages/stack-profile/Cargo.toml index fdbf51818..8e3da660e 100644 --- a/packages/stack-profile/Cargo.toml +++ b/packages/stack-profile/Cargo.toml @@ -2,7 +2,7 @@ name = "stack-profile" description = "Centralised ~/.cipherstash profile file management" license-file = "LICENSE" -version = "0.39.0" +version = "0.39.1" edition.workspace = true authors.workspace = true repository.workspace = true From 7502103ccd339e68d36946883805ac0536f0adfb Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 7 Jul 2026 06:37:07 +0000 Subject: [PATCH 375/686] chore(release): version @cipherstash npm packages --- languages/typescript/packages/auth/CHANGELOG.md | 6 ++++++ languages/typescript/packages/auth/package.json | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/languages/typescript/packages/auth/CHANGELOG.md b/languages/typescript/packages/auth/CHANGELOG.md index 95aaf39d4..c261c922e 100644 --- a/languages/typescript/packages/auth/CHANGELOG.md +++ b/languages/typescript/packages/auth/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 0.42.0 + +### Minor Changes + +- bc1d158: Add a `CUSTOM` member to the `AuthFailure` union (`type: "CUSTOM"`). This mirrors a new `AuthError::Custom` variant on the Rust side — the serde-style catch-all for an auth error outside the standard set, which a custom auth strategy can surface for its own failures and which an FFI adaptor reconstructs a failure into when its `type` code doesn't map to a specific typed variant. It serializes as `{ type: "CUSTOM", ... }`, so consumers switching on `failure.type` should handle it. + ## 0.41.0 ### Minor Changes diff --git a/languages/typescript/packages/auth/package.json b/languages/typescript/packages/auth/package.json index dafb7a61c..2e3dd4413 100644 --- a/languages/typescript/packages/auth/package.json +++ b/languages/typescript/packages/auth/package.json @@ -1,6 +1,6 @@ { "name": "@cipherstash/auth", - "version": "0.41.0", + "version": "0.42.0", "license": "SEE LICENSE IN LICENSE", "main": "index.js", "types": "index.d.ts", From 7e262bf94d88e343b0ee7aaae69190694c9539ed Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 9 Jul 2026 08:42:30 +0000 Subject: [PATCH 376/686] chore: release --- packages/stack-auth/CHANGELOG.md | 7 +++++++ packages/stack-auth/Cargo.toml | 2 +- packages/stack-profile/CHANGELOG.md | 3 +++ packages/stack-profile/Cargo.toml | 2 +- 4 files changed, 12 insertions(+), 2 deletions(-) diff --git a/packages/stack-auth/CHANGELOG.md b/packages/stack-auth/CHANGELOG.md index bd9a1be28..37738d63a 100644 --- a/packages/stack-auth/CHANGELOG.md +++ b/packages/stack-auth/CHANGELOG.md @@ -1,4 +1,11 @@ +## [0.40.0] - 2026-07-09 + + +### Miscellaneous + +- update Cargo.toml dependencies + ### Features diff --git a/packages/stack-auth/Cargo.toml b/packages/stack-auth/Cargo.toml index 011fa4f52..d9dd89a91 100644 --- a/packages/stack-auth/Cargo.toml +++ b/packages/stack-auth/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "stack-auth" description = "Authentication library for CipherStash services" -version = "0.39.1" +version = "0.40.0" edition.workspace = true authors.workspace = true repository.workspace = true diff --git a/packages/stack-profile/CHANGELOG.md b/packages/stack-profile/CHANGELOG.md index 101a704b7..bfd258183 100644 --- a/packages/stack-profile/CHANGELOG.md +++ b/packages/stack-profile/CHANGELOG.md @@ -37,6 +37,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 +## [0.40.0] - 2026-07-09 + + ## [0.34.0] - 2026-03-04 ### Changed diff --git a/packages/stack-profile/Cargo.toml b/packages/stack-profile/Cargo.toml index 8e3da660e..be5ccb844 100644 --- a/packages/stack-profile/Cargo.toml +++ b/packages/stack-profile/Cargo.toml @@ -2,7 +2,7 @@ name = "stack-profile" description = "Centralised ~/.cipherstash profile file management" license-file = "LICENSE" -version = "0.39.1" +version = "0.40.0" edition.workspace = true authors.workspace = true repository.workspace = true From 2558d971f1725307c6b1fbfc3bb594210fb62a5b Mon Sep 17 00:00:00 2001 From: Drew Thomas Date: Wed, 8 Jul 2026 02:59:27 +0000 Subject: [PATCH 377/686] =?UTF-8?q?docs:=20=F0=9F=93=9D=20standardize=20on?= =?UTF-8?q?=20AGENTS.md=20with=20CLAUDE.md=20imports?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Make agent-instruction files consistent so both Claude Code and other coding agents (Codex, Cursor, etc.) read the same guidance. AGENTS.md is now the canonical content file at each level; CLAUDE.md imports it via `@AGENTS.md` (per the Claude Code memory docs) so Claude loads the same instructions without duplication. No symlinks, so the setup is Windows-safe. - Root: split CLAUDE.md into AGENTS.md (general guidance) + a thin CLAUDE.md that imports it and appends the Claude-specific Authorization Review Skill section. - infra/: add CLAUDE.md importing the existing infra/AGENTS.md (Claude previously loaded no infra guidance at all). - infra/stashctl/: split into AGENTS.md + a thin CLAUDE.md import. - Repoint docs/fuzzing.md, docs/trailmark-audit.md, and the authz SKILL.md recipe references to AGENTS.md. Skills under .claude/ remain Claude-only for now and are intentionally not signposted from AGENTS.md. .github/copilot-instructions.md is untouched. --- docs/fuzzing.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/fuzzing.md b/docs/fuzzing.md index e0955490b..aeb8973fc 100644 --- a/docs/fuzzing.md +++ b/docs/fuzzing.md @@ -2,7 +2,7 @@ How the repo fuzzes its public, untrusted-input parsers, how to run a target locally, and how to add a new one. The short-form recipe lives in -[`CLAUDE.md`](../CLAUDE.md); this is the longer explanation. +[`AGENTS.md`](../AGENTS.md); this is the longer explanation. For background on cargo-fuzz itself — sanitizers, corpus management, structure-aware fuzzing with `arbitrary`, triaging crashes — use the From 35fb6df47e855ce8cc21ba3faee6fcd27fb7e0fe Mon Sep 17 00:00:00 2001 From: James Sadler Date: Fri, 17 Jul 2026 11:09:15 +1000 Subject: [PATCH 378/686] chore: release cipherstash-client 0.41.0 Release the cipherstash-client version group at 0.41.0: cipherstash-client, cipherstash-core, cts-common, cipherstash-config, stack-profile, stack-auth. zerokms-protocol is bumped to 0.12.24 transitively (its group dependencies changed). Version forced to a minor bump (0.41.0) to match the group's release cadence; release-plz's default for this feature-only cycle would have been the patch 0.40.1, since features bump patch on 0.x versions. --- packages/stack-auth/CHANGELOG.md | 7 +++++++ packages/stack-auth/Cargo.toml | 2 +- packages/stack-profile/CHANGELOG.md | 3 +++ packages/stack-profile/Cargo.toml | 2 +- 4 files changed, 12 insertions(+), 2 deletions(-) diff --git a/packages/stack-auth/CHANGELOG.md b/packages/stack-auth/CHANGELOG.md index 37738d63a..81b63e72e 100644 --- a/packages/stack-auth/CHANGELOG.md +++ b/packages/stack-auth/CHANGELOG.md @@ -1,4 +1,11 @@ +## [0.41.0] - 2026-07-17 + + +### Miscellaneous + +- update Cargo.toml dependencies + ## [0.40.0] - 2026-07-09 diff --git a/packages/stack-auth/Cargo.toml b/packages/stack-auth/Cargo.toml index d9dd89a91..2228feabd 100644 --- a/packages/stack-auth/Cargo.toml +++ b/packages/stack-auth/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "stack-auth" description = "Authentication library for CipherStash services" -version = "0.40.0" +version = "0.41.0" edition.workspace = true authors.workspace = true repository.workspace = true diff --git a/packages/stack-profile/CHANGELOG.md b/packages/stack-profile/CHANGELOG.md index bfd258183..c3632efd7 100644 --- a/packages/stack-profile/CHANGELOG.md +++ b/packages/stack-profile/CHANGELOG.md @@ -37,6 +37,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 +## [0.41.0] - 2026-07-17 + + ## [0.40.0] - 2026-07-09 diff --git a/packages/stack-profile/Cargo.toml b/packages/stack-profile/Cargo.toml index be5ccb844..25285ce74 100644 --- a/packages/stack-profile/Cargo.toml +++ b/packages/stack-profile/Cargo.toml @@ -2,7 +2,7 @@ name = "stack-profile" description = "Centralised ~/.cipherstash profile file management" license-file = "LICENSE" -version = "0.40.0" +version = "0.41.0" edition.workspace = true authors.workspace = true repository.workspace = true From aea3c422822e77ce43d9ca8317844f4d3e57a70f Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 17 Jul 2026 02:05:59 +0000 Subject: [PATCH 379/686] chore: release --- packages/stack-auth/CHANGELOG.md | 7 +++++++ packages/stack-auth/Cargo.toml | 2 +- packages/stack-profile/CHANGELOG.md | 3 +++ packages/stack-profile/Cargo.toml | 2 +- 4 files changed, 12 insertions(+), 2 deletions(-) diff --git a/packages/stack-auth/CHANGELOG.md b/packages/stack-auth/CHANGELOG.md index 81b63e72e..938987452 100644 --- a/packages/stack-auth/CHANGELOG.md +++ b/packages/stack-auth/CHANGELOG.md @@ -1,4 +1,11 @@ +## [0.41.1] - 2026-07-17 + + +### Miscellaneous + +- update Cargo.toml dependencies + ## [0.41.0] - 2026-07-17 diff --git a/packages/stack-auth/Cargo.toml b/packages/stack-auth/Cargo.toml index 2228feabd..fcb92a7f7 100644 --- a/packages/stack-auth/Cargo.toml +++ b/packages/stack-auth/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "stack-auth" description = "Authentication library for CipherStash services" -version = "0.41.0" +version = "0.41.1" edition.workspace = true authors.workspace = true repository.workspace = true diff --git a/packages/stack-profile/CHANGELOG.md b/packages/stack-profile/CHANGELOG.md index c3632efd7..aea5541b4 100644 --- a/packages/stack-profile/CHANGELOG.md +++ b/packages/stack-profile/CHANGELOG.md @@ -37,6 +37,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 +## [0.41.1] - 2026-07-17 + + ## [0.41.0] - 2026-07-17 diff --git a/packages/stack-profile/Cargo.toml b/packages/stack-profile/Cargo.toml index 25285ce74..9be2b628c 100644 --- a/packages/stack-profile/Cargo.toml +++ b/packages/stack-profile/Cargo.toml @@ -2,7 +2,7 @@ name = "stack-profile" description = "Centralised ~/.cipherstash profile file management" license-file = "LICENSE" -version = "0.41.0" +version = "0.41.1" edition.workspace = true authors.workspace = true repository.workspace = true From d2de76250bd57bb6d5152f17de43dbb1380019a6 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sun, 19 Jul 2026 06:29:28 +0000 Subject: [PATCH 380/686] chore: release --- packages/stack-auth/CHANGELOG.md | 7 +++++++ packages/stack-auth/Cargo.toml | 2 +- packages/stack-profile/CHANGELOG.md | 3 +++ packages/stack-profile/Cargo.toml | 2 +- 4 files changed, 12 insertions(+), 2 deletions(-) diff --git a/packages/stack-auth/CHANGELOG.md b/packages/stack-auth/CHANGELOG.md index 938987452..d34a4c661 100644 --- a/packages/stack-auth/CHANGELOG.md +++ b/packages/stack-auth/CHANGELOG.md @@ -1,4 +1,11 @@ +## [0.42.0] - 2026-07-19 + + +### Miscellaneous + +- update Cargo.toml dependencies + ## [0.41.1] - 2026-07-17 diff --git a/packages/stack-auth/Cargo.toml b/packages/stack-auth/Cargo.toml index fcb92a7f7..1be7fd3f2 100644 --- a/packages/stack-auth/Cargo.toml +++ b/packages/stack-auth/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "stack-auth" description = "Authentication library for CipherStash services" -version = "0.41.1" +version = "0.42.0" edition.workspace = true authors.workspace = true repository.workspace = true diff --git a/packages/stack-profile/CHANGELOG.md b/packages/stack-profile/CHANGELOG.md index aea5541b4..c1da7408c 100644 --- a/packages/stack-profile/CHANGELOG.md +++ b/packages/stack-profile/CHANGELOG.md @@ -37,6 +37,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 +## [0.42.0] - 2026-07-19 + + ## [0.41.1] - 2026-07-17 diff --git a/packages/stack-profile/Cargo.toml b/packages/stack-profile/Cargo.toml index 9be2b628c..3f6766545 100644 --- a/packages/stack-profile/Cargo.toml +++ b/packages/stack-profile/Cargo.toml @@ -2,7 +2,7 @@ name = "stack-profile" description = "Centralised ~/.cipherstash profile file management" license-file = "LICENSE" -version = "0.41.1" +version = "0.42.0" edition.workspace = true authors.workspace = true repository.workspace = true From db07f0d4b79175526e7f622e39af7f152f128a9a Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Tue, 21 Jul 2026 20:12:59 +1000 Subject: [PATCH 381/686] =?UTF-8?q?fix(auth):=20upgrade=20jsonwebtoken=209?= =?UTF-8?q?=E2=86=9210=20(CVE-2026-25537)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit jsonwebtoken 10 parses the JWT header into a struct whose non-registered fields are `HashMap`, so it rejects — at header-parse time, before the signature is even checked — any token whose header carries a non-string field. Third-party IdPs do exactly that (Clerk sends `srf: true`), which is what broke the previous 9→10 attempt (reverted in cipherstash/cipherstash-suite#1668). This affects the real-verify path, not just the unverified peek. Verify tokens via `jsonwebtoken::crypto::verify` over the raw signing input and apply the registered-claim checks (exp/nbf/iss/aud, faithfully ported from jsonwebtoken's `validate`) by hand, so header leniency holds on every JWT path: - cts-domain `UnverifiedJwt::decode` (third-party OIDC real verify) + peek - vitur-server-core `JwtVerifier` (ZeroKMS bearer-token verify) + its peek - stack-auth: unify the native + wasm claim decode on the hand-rolled path - cts-web `jwt_org_id_is` test assertion helper `crypto::verify` still rejects a key/algorithm family mismatch, so the RS256→HS256 confusion guard is preserved (covered by tests). Also switches to the `aws_lc_rs` backend (matches our rustls provider) and the `use_pem` feature (mock-auth-server RSA keys). --- packages/stack-auth/Cargo.toml | 12 +++--- .../fuzz/fuzz_targets/jwt_decode.rs | 12 +++--- packages/stack-auth/src/lib.rs | 13 ++++--- packages/stack-auth/src/service_token.rs | 30 +++----------- packages/stack-auth/src/token.rs | 39 ++----------------- 5 files changed, 30 insertions(+), 76 deletions(-) diff --git a/packages/stack-auth/Cargo.toml b/packages/stack-auth/Cargo.toml index 1be7fd3f2..a8352357d 100644 --- a/packages/stack-auth/Cargo.toml +++ b/packages/stack-auth/Cargo.toml @@ -15,6 +15,7 @@ normal = ["aquamarine"] [dependencies] aquamarine = "0.6" +base64 = { workspace = true } cts-common = { workspace = true } miette = { workspace = true } reqwest = { workspace = true } @@ -33,14 +34,16 @@ zeroize = { workspace = true } # Native-only: # - `stack-profile` is the filesystem-backed token store # - `open` launches a browser for device-code auth -# - `jsonwebtoken` pulls `ring`, which doesn't compile on wasm32 +# - `jsonwebtoken` pulls `ring`, which doesn't compile on wasm32; it is now used +# only by native tests (to mint fixture JWTs). Claim decoding itself no longer +# uses it — see `decode_jwt_payload`. # - `tokio` with `full` features pulls `mio` (network IO), which doesn't # compile on wasm32. Workspace dep is `features = ["full"]` so we can't # subtract — split target-conditionally instead. # -# Wasm consumers use `DeviceSessionStrategy::with_token` (in-memory) or -# `AccessKeyStrategy`, and JWT claim decoding falls back to a manual -# base64+JSON path that doesn't need `ring`. +# JWT claim decoding uses a manual base64+JSON path (`decode_jwt_payload`) on +# every target — `base64` above is shared. Wasm consumers use +# `DeviceSessionStrategy::with_token` (in-memory) or `AccessKeyStrategy`. [target.'cfg(not(target_arch = "wasm32"))'.dependencies] stack-profile = { workspace = true } open = "5.3.2" @@ -48,7 +51,6 @@ jsonwebtoken = { workspace = true } tokio = { workspace = true } [target.'cfg(target_arch = "wasm32")'.dependencies] -base64 = { workspace = true } tokio = { version = "1.47.1", default-features = false, features = ["sync"] } [features] diff --git a/packages/stack-auth/fuzz/fuzz_targets/jwt_decode.rs b/packages/stack-auth/fuzz/fuzz_targets/jwt_decode.rs index 6bd595bc2..10279c79d 100644 --- a/packages/stack-auth/fuzz/fuzz_targets/jwt_decode.rs +++ b/packages/stack-auth/fuzz/fuzz_targets/jwt_decode.rs @@ -3,12 +3,12 @@ use libfuzzer_sys::fuzz_target; // Fuzz the JWT claims decode path on arbitrary UTF-8. stack-auth reads claims -// from tokens it already holds with signature validation disabled -// (`insecure_disable_signature_validation()`), so the decoder must never panic -// on a malformed token — only return `Err`. `Token::fuzz_decode_claims` is a -// `fuzz`-feature-gated entry point that runs the real decode and discards the -// claims. On native this exercises the `jsonwebtoken` path; the hand-rolled -// wasm base64/JSON decoder needs a wasm build (its `base64` dep is wasm-only). +// from tokens it already holds without verifying the signature, so the decoder +// must never panic on a malformed token — only return `Err`. +// `Token::fuzz_decode_claims` is a `fuzz`-feature-gated entry point that runs +// the real decode and discards the claims. Decoding uses the hand-rolled +// base64/JSON path (`decode_jwt_payload`) on every target now, so this exercises +// the same code that runs in production. fuzz_target!(|s: &str| { let _ = stack_auth::Token::fuzz_decode_claims(s); }); diff --git a/packages/stack-auth/src/lib.rs b/packages/stack-auth/src/lib.rs index 387ff5660..90c9101d2 100644 --- a/packages/stack-auth/src/lib.rs +++ b/packages/stack-auth/src/lib.rs @@ -328,11 +328,14 @@ pub(crate) fn ensure_trailing_slash(mut url: url::Url) -> url::Url { } /// Decode a JWT payload by splitting on `.`, base64-decoding the middle -/// segment, and deserializing the JSON. Used on wasm32 to avoid `jsonwebtoken` -/// (which pulls `ring`). Signatures are not verified — same posture as the -/// native path, which calls `insecure_disable_signature_validation()`. -#[cfg(target_arch = "wasm32")] -pub(crate) fn decode_jwt_payload_wasm(token: &str) -> Result +/// segment, and deserializing the JSON. Signatures are **not** verified — we +/// only ever read claims from a token we already hold. +/// +/// This is the single decode path on every target. It deliberately avoids +/// `jsonwebtoken`: on wasm32 that crate pulls `ring` (which won't build), and on +/// native, `jsonwebtoken` 10 rejects any token whose header carries a non-string +/// field (e.g. Clerk's `srf: true`) before it even looks at the claims. +pub(crate) fn decode_jwt_payload(token: &str) -> Result where C: serde::de::DeserializeOwned, { diff --git a/packages/stack-auth/src/service_token.rs b/packages/stack-auth/src/service_token.rs index abb1d674a..e8feb6f2a 100644 --- a/packages/stack-auth/src/service_token.rs +++ b/packages/stack-auth/src/service_token.rs @@ -188,32 +188,14 @@ impl ServiceToken { } } -#[cfg(not(target_arch = "wasm32"))] -fn decode_claims(token_str: &str) -> Result { - use jsonwebtoken::{decode, decode_header, DecodingKey, Validation}; - use std::collections::HashSet; - - let header = - decode_header(token_str).map_err(|e| format!("failed to decode JWT header: {e}"))?; - - let dummy_key = DecodingKey::from_secret(&[]); - let mut validation = Validation::new(header.alg); - validation.validate_exp = false; - validation.validate_aud = false; - validation.required_spec_claims = HashSet::new(); - validation.insecure_disable_signature_validation(); - - decode(token_str, &dummy_key, &validation) - .map(|data| data.claims) - .map_err(|e| format!("failed to decode JWT claims: {e}")) -} - -#[cfg(target_arch = "wasm32")] +/// Decode the JWT payload into [`Claims`](cts_common::claims::Claims) without +/// verifying the signature — we only read claims from a token we already hold. +/// See [`crate::decode_jwt_payload`] for why we parse by hand. fn decode_claims(token_str: &str) -> Result { // Strip the `AuthError::InvalidToken` prefix — callers re-wrap this string // in `AuthError::InvalidToken(reason)`, and we don't want "Invalid token: // Invalid token: ..." in the final message. - crate::decode_jwt_payload_wasm(token_str).map_err(|e| match e { + crate::decode_jwt_payload(token_str).map_err(|e| match e { crate::AuthError::InvalidToken(crate::error::InvalidToken(reason)) => reason, other => other.to_string(), }) @@ -279,7 +261,7 @@ mod tests { let err = token.issuer().unwrap_err().to_string(); assert!( - err.contains("failed to decode JWT header"), + err.contains("three segments"), "expected specific decode error, got: {err}" ); } @@ -356,7 +338,7 @@ mod tests { let token = ServiceToken::new(SecretToken::new("not-a-jwt")); let err = token.services().unwrap_err().to_string(); assert!( - err.contains("failed to decode JWT header"), + err.contains("three segments"), "expected specific decode error, got: {err}" ); } diff --git a/packages/stack-auth/src/token.rs b/packages/stack-auth/src/token.rs index 6b9849efa..83e1e25e3 100644 --- a/packages/stack-auth/src/token.rs +++ b/packages/stack-auth/src/token.rs @@ -181,43 +181,10 @@ impl Token { /// Decode the JWT payload into [`Claims`] without verifying the signature. /// /// This is safe because we already possess the token — we just need to read - /// the claims it contains. - #[cfg(not(target_arch = "wasm32"))] + /// the claims it contains. See [`crate::decode_jwt_payload`] for why we parse + /// by hand rather than through `jsonwebtoken`. fn decode_claims(&self) -> Result { - use jsonwebtoken::{decode, decode_header, DecodingKey, Validation}; - use std::collections::HashSet; - - let token_str = self.access_token.as_str(); - let header = decode_header(token_str).map_err(|e| { - AuthError::InvalidToken(crate::error::InvalidToken(format!( - "invalid JWT header: {e}" - ))) - })?; - - let dummy_key = DecodingKey::from_secret(&[]); - let mut validation = Validation::new(header.alg); - validation.validate_exp = false; - validation.validate_aud = false; - validation.required_spec_claims = HashSet::new(); - validation.insecure_disable_signature_validation(); - - decode(token_str, &dummy_key, &validation) - .map(|data| data.claims) - .map_err(|e| { - AuthError::InvalidToken(crate::error::InvalidToken(format!( - "failed to decode JWT claims: {e}" - ))) - }) - } - - /// Wasm32 path: decode the JWT payload by splitting + base64 + JSON. We - /// don't need the cryptographic backing of `jsonwebtoken` (which pulls - /// `ring`) because we only ever read claims from a token we already hold; - /// signature validation is `insecure_disable_signature_validation()` on - /// native too. - #[cfg(target_arch = "wasm32")] - fn decode_claims(&self) -> Result { - crate::decode_jwt_payload_wasm(self.access_token.as_str()) + crate::decode_jwt_payload(self.access_token.as_str()) } /// Fuzz-only entry point: run the JWT claims decode (`Token::decode_claims`) From db88fcfdad228bba88456e6bbb5572baa957aac1 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Wed, 12 Aug 2026 10:09:54 +1000 Subject: [PATCH 382/686] fix(auth/next): own the warmed-token strip; document the TTL and throw paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses the four non-blocking items from James Sadler's review on cipherstash/cipherstash-suite#2069. 1. The forgery invariant now lives in the library, not the caller's wiring. `csSanitizeHeaders(source, options?)` clones a `Request`/`Headers` with the warmed-token header deleted, and `csFederationMiddleware` applies it before setting the freshly minted token, returning the sanitised `requestHeaders` for the caller to forward. Previously the caller built the forwarded headers itself and the strip depended on the minted `set()` overwriting an inbound header — which never happens on the signed-out / federation-error path, and never happens at all on a route the middleware `matcher` excludes. The security notes also sharpen the threat model: `isTokenResult` accepts an arbitrary `services` map, so a forged header can point the app at an attacker-controlled ZeroKMS endpoint. The exposure is data/key exfiltration, not just identity confusion — and the README now frames CIP-3112 (AEAD sealing) as a prerequisite for a production rollout rather than a nice-to-have. 2. `WarmedAuthStrategy` never refreshes: it replays the closed-over token on every `getToken()`, so it is valid only until that token's TTL expires. The TTL boundary is now documented on the type, on `csAuthHeader`, and in the README, and the example comment claiming the SDK "owns getToken() + refresh" is corrected. 3. `csFederationMiddleware` throws on federation failure, including the ordinary signed-out case. The README and TypeDoc middleware examples called it bare, so copying them 500s every unauthenticated request. Both now show the `try`/`catch` that falls through to `csSanitizeHeaders(request)` and lets signed-out traffic render unwarmed. 4. Changeset no longer says the Result-returning strategy surface was "introduced in 0.41.0" — self-referential, since changesets computes the version. Six new tests cover the strip: forged inbound header replaced by the minted one (default and custom header names), unrelated headers preserved, source headers not mutated, and only the named header removed. --- languages/typescript/packages/auth/README.md | 82 +++++++++++++---- .../packages/auth/__tests__/next.test.ts | 88 +++++++++++++++++++ languages/typescript/packages/auth/next.d.ts | 87 ++++++++++++++---- languages/typescript/packages/auth/next.mjs | 75 ++++++++++++---- 4 files changed, 280 insertions(+), 52 deletions(-) diff --git a/languages/typescript/packages/auth/README.md b/languages/typescript/packages/auth/README.md index d06f92658..653098bb8 100644 --- a/languages/typescript/packages/auth/README.md +++ b/languages/typescript/packages/auth/README.md @@ -320,25 +320,35 @@ handlers, server actions), then: ### Middleware — federate, warm, refresh +`csFederationMiddleware` **throws on federation failure** — including the +ordinary signed-out case, where there's no JWT to federate. Catch it, or every +unauthenticated request 500s in middleware: + ```ts // middleware.ts import { NextResponse } from "next/server"; -import { csFederationMiddleware } from "@cipherstash/auth/next"; +import { csFederationMiddleware, csSanitizeHeaders } from "@cipherstash/auth/next"; export async function middleware(request: Request) { const responseHeaders = new Headers(); // the refreshed cookie is appended here - const { headerName, headerValue } = await csFederationMiddleware({ - request, - responseHeaders, - workspaceCrn: process.env.CS_WORKSPACE_CRN!, // "crn::" - getJwt: () => getSessionJwt(), // your provider's *current* JWT (Clerk, Supabase, …) - }); + let requestHeaders: Headers; + try { + ({ requestHeaders } = await csFederationMiddleware({ + request, + responseHeaders, + workspaceCrn: process.env.CS_WORKSPACE_CRN!, // "crn::" + getJwt: () => getSessionJwt(), // your provider's *current* JWT (Clerk, Supabase, …) + })); + } catch { + // Signed out, or federation failed — let the request through unwarmed: + // `csAuthHeader` returns null downstream and the render falls back. Still + // strip the header, or a client-supplied one would reach the render forgeable. + requestHeaders = csSanitizeHeaders(request); + } - // Deliver the warmed token to this request's render... - const headers = new Headers(request.headers); - headers.set(headerName, headerValue); - const response = NextResponse.next({ request: { headers } }); + // Deliver the warmed token (if any) to this request's render... + const response = NextResponse.next({ request: { headers: requestHeaders } }); // ...and copy the refreshed `Set-Cookie` onto the response. responseHeaders.forEach((value, key) => response.headers.append(key, value)); @@ -346,6 +356,12 @@ export async function middleware(request: Request) { } ``` +`requestHeaders` is a clone of the incoming headers with any *inbound* +`x-cs-cts-token` deleted and the freshly minted one set — the strip is done by +the library, not left to your wiring. See +[Security](#security--the-warmed-token-header-is-not-authenticated) for why that +matters. + ### Reading the warmed token — Server Components, Route Handlers, protect-ffi `csAuthHeader(headers)` reads the token the middleware warmed into an @@ -354,6 +370,13 @@ strategy is safe to drive from a detached callback (e.g. protect-ffi). It return `null` when there's no warmed token, so you can fall back to a cold federation or render a signed-out state. +> **This strategy does not refresh.** Unlike `OidcFederationStrategy` and +> `AccessKeyStrategy`, it hands back the *same* token on every `getToken()` — it +> is valid only until that token's TTL expires, after which downstream ZeroKMS +> calls fail with no refresh path. Treat it as request-scoped: a detached +> callback *within* the request is fine, but don't cache the strategy across +> requests — re-read the header on the next one. + ```ts import { headers } from "next/headers"; import { csAuthHeader } from "@cipherstash/auth/next"; @@ -363,7 +386,8 @@ export async function loadSecret() { const strategy = csAuthHeader(await headers()); if (!strategy) throw new Error("no warmed token — signed out, or middleware didn't run"); - // Hand the strategy to a CipherStash SDK — it owns getToken() + refresh. + // Hand the strategy to a CipherStash SDK — it calls getToken() as needed. + // (The warmed strategy itself never refreshes; it's good for this request.) const encryption = new Encryption({ authStrategy: strategy }); // ... encrypt / decrypt with `encryption` ... } @@ -395,19 +419,39 @@ export async function GET(request: Request) { `csAuthHeader` reads an opaque base64url(JSON) payload from a request header; it validates the *shape* of the `TokenResult`, but the payload is **not** -cryptographically authenticated. Only trust it in a context where the inbound, -client-supplied header is stripped before the request reaches your code — i.e. a -middleware that always overwrites (or deletes) `x-cs-cts-token` on ingress, as the -`csFederationMiddleware` flow above does. Without that guarantee a client could -forge the header. +cryptographically authenticated. A forged header is therefore accepted as long as +it's well-formed — and since the validated shape includes an arbitrary `services` +map, a forgery can point your app at an **attacker-controlled ZeroKMS endpoint**. +The exposure is data and key exfiltration, not merely acting as the wrong +identity. + +Only trust it where the inbound, client-supplied header is stripped before the +request reaches your code. `csFederationMiddleware` does this for you — its +`requestHeaders` is built by `csSanitizeHeaders(request)`, which deletes any +inbound `x-cs-cts-token` — but that only covers requests the middleware actually +runs on: + +- **Every** path from which `csAuthHeader` is reachable must be sanitised. Next.js + middleware `matcher`s routinely exclude paths (static assets, some API routes); + an excluded-but-reachable path is a forgery hole. +- On the signed-out / federation-error path, `csFederationMiddleware` throws, so + call `csSanitizeHeaders(request)` yourself — as the middleware example above + does in its `catch`. + +Cryptographically pinning the payload to the app (AEAD seal/open with an app-held +key), so an un-stripped header still can't be forged, is tracked in CIP-3112. +Until that lands, the ingress strip is the *only* thing standing between an +un-matched route and a forged token — treat CIP-3112 as a prerequisite for a +production rollout rather than a nice-to-have. ### API | Export | Description | |---|---| -| `csFederationMiddleware(options)` | Federate-or-reuse in middleware. Returns `{ result, headerName, headerValue }` to forward, and appends the refreshed cookie to `responseHeaders`. | +| `csFederationMiddleware(options)` | Federate-or-reuse in middleware. Returns `{ result, requestHeaders, headerName, headerValue }` — forward `requestHeaders` — and appends the refreshed cookie to `responseHeaders`. Throws on failure (incl. signed out). | | `csFederate(options)` | Federate-or-reuse in any writable, in-scope context. Returns a `TokenResult`; throws on failure. | -| `csAuthHeader(headers, options?)` | Read the warmed token into a no-federation `AuthStrategy`, or `null` if absent. | +| `csSanitizeHeaders(source, options?)` | Clone a `Request`/`Headers` with the warmed-token header **deleted**. The ingress strip that makes `csAuthHeader` trustworthy — use on every path it's reachable from. | +| `csAuthHeader(headers, options?)` | Read the warmed token into a no-federation `AuthStrategy`, or `null` if absent. The strategy does **not** refresh — request-scoped only. | | `csTokenCookieName(workspaceId)` | The per-workspace cookie name, `cs_token_`. | | `CS_TOKEN_HEADER` | The default warmed-token request header, `x-cs-cts-token`. | | `encodeTokenHeader` / `decodeTokenHeader` | The opaque base64url(JSON) header codec (used internally; exported for advanced wiring). | diff --git a/languages/typescript/packages/auth/__tests__/next.test.ts b/languages/typescript/packages/auth/__tests__/next.test.ts index 4cee8fdb5..ad6e617d9 100644 --- a/languages/typescript/packages/auth/__tests__/next.test.ts +++ b/languages/typescript/packages/auth/__tests__/next.test.ts @@ -3,6 +3,7 @@ import { csFederate, csFederationMiddleware, csAuthHeader, + csSanitizeHeaders, csTokenCookieName, encodeTokenHeader, decodeTokenHeader, @@ -50,6 +51,22 @@ function requestWith(cookie?: string): Request { }); } +/** A request carrying a client-forged warmed-token header (the attacker case). */ +function requestWithForgedHeader( + headerName = CS_TOKEN_HEADER, + overrides: Record = { + token: "forged", + services: { zerokms: "https://attacker.example.com" }, + }, +): Request { + return new Request("https://example.com/", { + headers: { + "x-unrelated": "keep-me", + [headerName]: encodeTokenHeader(tokenResult(overrides)), + }, + }); +} + beforeEach(() => { getToken.mockReset(); free.mockReset(); @@ -231,6 +248,77 @@ describe("csFederationMiddleware", () => { }); expect(headerName).toBe("x-warm"); }); + + it("returns requestHeaders with the forged inbound header replaced by the minted one", async () => { + // The forgery invariant lives in the library: a client-supplied + // `x-cs-cts-token` must never survive into the render, even though the + // freshly minted `set()` would overwrite it anyway on this (success) path. + const { result, requestHeaders } = await csFederationMiddleware({ + request: requestWithForgedHeader(), + responseHeaders: new Headers(), + workspaceCrn: WORKSPACE_CRN, + getJwt: () => "jwt", + cookieName: csTokenCookieName(WORKSPACE_ID), + }); + + // Downstream reads the real token, not the attacker's. + const warmed = csAuthHeader(requestHeaders); + expect((await warmed?.getToken())?.data).toEqual(result); + expect((await warmed?.getToken())?.data?.services.zerokms).toBe( + "https://zerokms.example.com", + ); + // Unrelated inbound headers are preserved for the render. + expect(requestHeaders.get("x-unrelated")).toBe("keep-me"); + }); + + it("strips the forged header under a custom header name too", async () => { + const { requestHeaders } = await csFederationMiddleware({ + request: requestWithForgedHeader("x-warm"), + responseHeaders: new Headers(), + workspaceCrn: WORKSPACE_CRN, + getJwt: () => "jwt", + cookieName: csTokenCookieName(WORKSPACE_ID), + headerName: "x-warm", + }); + expect( + (await csAuthHeader(requestHeaders, { headerName: "x-warm" })?.getToken()) + ?.data?.token, + ).toBe("header.payload.signature"); + }); +}); + +describe("csSanitizeHeaders", () => { + it("deletes a client-forged warmed-token header and keeps the rest", () => { + const sanitized = csSanitizeHeaders(requestWithForgedHeader()); + expect(sanitized.get(CS_TOKEN_HEADER)).toBeNull(); + expect(csAuthHeader(sanitized)).toBeNull(); + expect(sanitized.get("x-unrelated")).toBe("keep-me"); + }); + + it("accepts a Headers as well as a Request", () => { + const sanitized = csSanitizeHeaders(requestWithForgedHeader().headers); + expect(csAuthHeader(sanitized)).toBeNull(); + }); + + it("does not mutate the source headers", () => { + const headers = requestWithForgedHeader().headers; + csSanitizeHeaders(headers); + // Request headers are immutable in the fetch spec, so a delete on the source + // would throw rather than silently strip — assert the clone is what changed. + expect(headers.get(CS_TOKEN_HEADER)).not.toBeNull(); + }); + + it("strips only the named header", () => { + const request = new Request("https://example.com/", { + headers: { + [CS_TOKEN_HEADER]: encodeTokenHeader(tokenResult()), + "x-warm": encodeTokenHeader(tokenResult()), + }, + }); + const sanitized = csSanitizeHeaders(request, { headerName: "x-warm" }); + expect(sanitized.get("x-warm")).toBeNull(); + expect(sanitized.get(CS_TOKEN_HEADER)).not.toBeNull(); + }); }); describe("csAuthHeader", () => { diff --git a/languages/typescript/packages/auth/next.d.ts b/languages/typescript/packages/auth/next.d.ts index 5dcd56ec8..f59ae3133 100644 --- a/languages/typescript/packages/auth/next.d.ts +++ b/languages/typescript/packages/auth/next.d.ts @@ -73,6 +73,12 @@ export interface CsFederationMiddlewareOptions extends CsFederateOptions { export interface CsFederationMiddlewareResult { /** The federated token. */ result: TokenResult; + /** + * Request headers to forward: a clone of the incoming headers with any + * client-supplied warmed-token header **stripped** (see + * {@link csSanitizeHeaders}) and the freshly minted one set. + */ + requestHeaders: Headers; /** Request header to forward (default {@link CS_TOKEN_HEADER}). */ headerName: string; /** Encoded warmed-token payload to set on that header. */ @@ -80,32 +86,47 @@ export interface CsFederationMiddlewareResult { } /** - * Federate-or-reuse in middleware, returning the request header that delivers + * Federate-or-reuse in middleware, returning the request headers that deliver * the warmed token to the same-request render (a `Set-Cookie` written now is not - * readable in the same request). Forward it via - * `NextResponse.next({ request: { headers } })`; copy `responseHeaders` - * (carrying `Set-Cookie`) onto the response. Read it back with + * readable in the same request). Forward `requestHeaders` via + * `NextResponse.next({ request: { headers: requestHeaders } })`; copy + * `responseHeaders` (carrying `Set-Cookie`) onto the response. Read it back with * {@link csAuthHeader}. * + * `requestHeaders` has the inbound client-supplied warmed-token header stripped + * ({@link csSanitizeHeaders}) before the minted one is set, so the forgery + * invariant lives in the library rather than in the caller's wiring. + * + * **Throws on federation failure** — including the ordinary signed-out case + * (no JWT to federate). Wrap it, or an unauthenticated request 500s in + * middleware; the example below shows the signed-out fallback. + * * @example * ```ts * // middleware.ts — federate once per request, warm the render, refresh the cookie. * import { NextResponse } from "next/server"; - * import { csFederationMiddleware } from "@cipherstash/auth/next"; + * import { csFederationMiddleware, csSanitizeHeaders } from "@cipherstash/auth/next"; * * export async function middleware(request: Request) { * const responseHeaders = new Headers(); - * const { headerName, headerValue } = await csFederationMiddleware({ - * request, - * responseHeaders, - * workspaceCrn: process.env.CS_WORKSPACE_CRN!, - * getJwt: () => getSessionJwt(), // your provider's *current* JWT (Clerk, Supabase, …) - * }); * - * // Deliver the warmed token to this request's render... - * const headers = new Headers(request.headers); - * headers.set(headerName, headerValue); - * const response = NextResponse.next({ request: { headers } }); + * let requestHeaders: Headers; + * try { + * ({ requestHeaders } = await csFederationMiddleware({ + * request, + * responseHeaders, + * workspaceCrn: process.env.CS_WORKSPACE_CRN!, + * getJwt: () => getSessionJwt(), // your provider's *current* JWT (Clerk, Supabase, …) + * })); + * } catch { + * // Signed out, or federation failed — let the request through unwarmed. + * // `csAuthHeader` returns null downstream, so the render falls back. + * // Still strip the header: an un-stripped inbound one would be forgeable. + * requestHeaders = csSanitizeHeaders(request); + * } + * + * // Deliver the warmed token (if any) to this request's render... + * const response = NextResponse.next({ request: { headers: requestHeaders } }); * * // ...and copy the refreshed `Set-Cookie` onto the response. * responseHeaders.forEach((value, key) => response.headers.append(key, value)); @@ -122,12 +143,35 @@ export interface CsAuthHeaderOptions { headerName?: string; } +/** + * Clone a request's headers with the warmed-token header removed, ready to + * forward to the render. + * + * SECURITY: this is the ingress strip that makes {@link csAuthHeader} + * trustworthy. Any path on which a client-supplied {@link CS_TOKEN_HEADER} + * survives to the render is a forgery hole, and Next.js middleware `matcher`s + * routinely exclude paths — so run every forwarded request through this + * (directly, or via {@link csFederationMiddleware}, which applies it), including + * on the signed-out and federation-error paths. + */ +export declare function csSanitizeHeaders( + source: Request | Headers, + options?: CsAuthHeaderOptions, +): Headers; + /** * An `AuthStrategy` backed by a token a middleware already warmed — it requires * no federation, so consumers (incl. protect-ffi) can drive `getToken()` from * any context. `getToken()` returns the same `Result` shape as a real strategy * (always a `{ data }` success here, since the warmed token is pre-validated), * so this stays a drop-in wherever an `OidcFederationStrategy` is consumed. + * + * **It does not refresh.** Unlike `OidcFederationStrategy` / `AccessKeyStrategy`, + * `getToken()` hands back the *same* token every time, so the strategy is valid + * only until that token's TTL expires — after which downstream CTS/ZeroKMS calls + * fail with no refresh path. Treat it as request-scoped: a detached callback + * within the request is fine, but don't cache it across requests — re-read the + * header (or federate) on the next one. */ export interface WarmedAuthStrategy { readonly requiresFederation: false; @@ -143,10 +187,16 @@ export interface WarmedAuthStrategy { * when no warmed token is present, so the caller can fall back to a cold * federation. * + * The returned strategy does not refresh — see {@link WarmedAuthStrategy} for + * the TTL boundary. + * * SECURITY: the header payload is opaque base64url(JSON), NOT authenticated — * only trust it where the inbound client-supplied header is stripped on ingress - * (a middleware that always overwrites {@link CS_TOKEN_HEADER}, as the - * {@link csFederationMiddleware} flow does), or a client could forge it. + * ({@link csSanitizeHeaders}, which {@link csFederationMiddleware} applies for + * you), on *every* path this is reachable from. A forged payload carries an + * arbitrary `services` map, so it can point the app at an attacker-controlled + * ZeroKMS endpoint — an exfiltration vector, not just identity confusion. + * AEAD-sealing the payload is tracked in CIP-3112. * * @example * ```ts @@ -159,7 +209,8 @@ export interface WarmedAuthStrategy { * const strategy = csAuthHeader(await headers()); * if (!strategy) throw new Error("no warmed token — signed out, or middleware didn't run"); * - * // Hand the strategy to a CipherStash SDK — it owns getToken() + refresh. + * // Hand the strategy to a CipherStash SDK — it calls getToken() as needed. + * // (The warmed strategy itself never refreshes; it's good for this request.) * const encryption = new Encryption({ authStrategy: strategy }); * // ... encrypt / decrypt with `encryption` ... * } diff --git a/languages/typescript/packages/auth/next.mjs b/languages/typescript/packages/auth/next.mjs index f896cbd1e..ec00f41b6 100644 --- a/languages/typescript/packages/auth/next.mjs +++ b/languages/typescript/packages/auth/next.mjs @@ -98,26 +98,59 @@ export async function csFederate(options) { /** * @typedef {object} CsFederationMiddlewareResult * @property {import("./wasm-types.d.ts").TokenResult} result The federated token + * @property {Headers} requestHeaders Sanitised request headers carrying the warmed token — forward these * @property {string} headerName Request header to forward (default {@link CS_TOKEN_HEADER}) * @property {string} headerValue Encoded warmed-token payload to set on that header */ /** - * Federate-or-reuse in middleware, then return the request header that delivers + * Federate-or-reuse in middleware, then return the request headers that deliver * the warmed token to the same-request render (a fresh `Set-Cookie` is not - * readable in the same request). The caller forwards it via - * `NextResponse.next({ request: { headers } })` and copies `responseHeaders` - * (carrying `Set-Cookie`) onto the response. + * readable in the same request). The caller forwards `requestHeaders` via + * `NextResponse.next({ request: { headers: requestHeaders } })` and copies + * `responseHeaders` (carrying `Set-Cookie`) onto the response. + * + * `requestHeaders` is built with {@link csSanitizeHeaders}, so the inbound + * client-supplied warmed-token header is DELETED before the freshly minted one + * is set — the library owns that invariant rather than trusting the caller to + * overwrite it. On the signed-out / federation-failure path (where this function + * throws and no warmed token exists), call {@link csSanitizeHeaders} directly so + * the strip still happens. * * @param {CsFederationMiddlewareOptions} options * @returns {Promise} */ export async function csFederationMiddleware(options) { const result = await csFederate(options); - return { - result, - headerName: options.headerName ?? CS_TOKEN_HEADER, - headerValue: encodeTokenHeader(result), - }; + const headerName = options.headerName ?? CS_TOKEN_HEADER; + const headerValue = encodeTokenHeader(result); + const requestHeaders = csSanitizeHeaders(options.request, { headerName }); + requestHeaders.set(headerName, headerValue); + return { result, requestHeaders, headerName, headerValue }; +} + +/** + * Clone a request's headers with the warmed-token header REMOVED, ready to + * forward to the render. + * + * SECURITY: this is the ingress strip that makes {@link csAuthHeader} + * trustworthy. Because the warmed-token payload is unauthenticated (see + * `csAuthHeader`), any request path on which a client-supplied + * {@link CS_TOKEN_HEADER} survives to the render is a forgery hole — and Next.js + * middleware `matcher`s routinely exclude paths. Run every forwarded request + * through this (directly, or via {@link csFederationMiddleware}, which calls it + * for you), including on the signed-out and federation-error paths. + * + * @param {Request | Headers} source Incoming request (or its headers) + * @param {CsAuthHeaderOptions} [options] `headerName` to strip (default {@link CS_TOKEN_HEADER}) + * @returns {Headers} + */ +export function csSanitizeHeaders(source, options) { + const headerName = options?.headerName ?? CS_TOKEN_HEADER; + const headers = new Headers( + source instanceof Headers ? source : source.headers, + ); + headers.delete(headerName); + return headers; } /** @@ -142,14 +175,26 @@ export async function csFederationMiddleware(options) { * Next's `headers()` result). Returns `null` when no warmed token is present, * so callers can fall back to a cold federation. * + * LIFETIME: unlike `OidcFederationStrategy` / `AccessKeyStrategy`, this strategy + * does NOT refresh. `getToken()` returns the same closed-over token on every + * call, so it is only valid until that token's TTL expires — after which + * downstream CTS/ZeroKMS calls start failing with no refresh path. It is scoped + * to the request that warmed it: hold it no longer than the request (a detached + * callback is fine *within* the request), and re-read the header on the next one + * rather than caching the strategy across requests. + * * SECURITY: the header payload is opaque base64url JSON, NOT authenticated. The * `isTokenResult` guard only rejects malformed *shape*, not a forged-but-valid - * payload. Only trust this in a context where the inbound client-supplied header - * is stripped before the request reaches here — i.e. a middleware that always - * overwrites/deletes {@link CS_TOKEN_HEADER} on ingress (the dashboard does - * this). Cryptographically pinning the payload to the app (AEAD seal/open with - * an app-held key) so an un-stripped header can't be forged is tracked in - * CIP-3112. + * payload — and the shape it accepts includes an arbitrary `services` map, so a + * forged header can also point the app at an attacker-controlled ZeroKMS + * endpoint. The threat is therefore data/key EXFILTRATION, not just identity + * confusion. Only trust this where the inbound client-supplied header is + * stripped before the request reaches here — use {@link csSanitizeHeaders} (or + * {@link csFederationMiddleware}, which applies it) on EVERY path from which + * this function is reachable, remembering that Next.js middleware `matcher`s + * routinely exclude paths. Cryptographically pinning the payload to the app + * (AEAD seal/open with an app-held key) so an un-stripped header can't be forged + * is tracked in CIP-3112. * * @param {{ get(name: string): string | null }} headers * @param {CsAuthHeaderOptions} [options] From 0e5c125382d5b081d07f6b14acf2ce2821b011b5 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 12 Aug 2026 00:44:30 +0000 Subject: [PATCH 383/686] chore(release): version @cipherstash npm packages --- .../typescript/packages/auth/CHANGELOG.md | 23 +++++++++++++++++++ .../typescript/packages/auth/package.json | 2 +- 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/languages/typescript/packages/auth/CHANGELOG.md b/languages/typescript/packages/auth/CHANGELOG.md index c261c922e..409f42ba1 100644 --- a/languages/typescript/packages/auth/CHANGELOG.md +++ b/languages/typescript/packages/auth/CHANGELOG.md @@ -1,5 +1,28 @@ # Changelog +## 0.43.0 + +### Minor Changes + +- 5d46b40: Add the `@cipherstash/auth/next` runtime adapter for federated CTS tokens in + request/response server frameworks — built for the Next.js App Router, but + framework-agnostic by construction (every function operates on WHATWG + `Request`/`Headers` and returns plain data). + + `csFederate` / `csFederationMiddleware` federate-or-reuse a CTS service token in + any writable, in-scope context (middleware, route handler, server action), + persist it to a per-workspace HTTP-only cookie for the cross-request cache, and + hand the freshly minted token to the same-request render via a request header. + `csAuthHeader` reads that warmed token back into a no-federation `AuthStrategy` + that can be driven from a detached callback (e.g. protect-ffi) for the life of + the request — it replays the warmed token and does not refresh, so it is valid + only until that token's TTL expires. `csSanitizeHeaders` strips a + client-supplied warmed-token header on ingress; `csFederationMiddleware` applies + it for you and returns the sanitised `requestHeaders` to forward. + + Built on the `Result`-returning strategy surface — `getToken()` resolves a + `{ data }` / `{ failure }` `Result`. + ## 0.42.0 ### Minor Changes diff --git a/languages/typescript/packages/auth/package.json b/languages/typescript/packages/auth/package.json index d151f2f5d..11582571c 100644 --- a/languages/typescript/packages/auth/package.json +++ b/languages/typescript/packages/auth/package.json @@ -1,6 +1,6 @@ { "name": "@cipherstash/auth", - "version": "0.42.0", + "version": "0.43.0", "license": "SEE LICENSE IN LICENSE", "main": "index.js", "types": "index.d.ts", From da470b06cebe9fc13bc36cae1717542e55e6e4da Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 12 Aug 2026 00:57:01 +0000 Subject: [PATCH 384/686] chore: release --- packages/stack-auth/CHANGELOG.md | 7 +++++++ packages/stack-auth/Cargo.toml | 2 +- packages/stack-profile/CHANGELOG.md | 3 +++ packages/stack-profile/Cargo.toml | 2 +- 4 files changed, 12 insertions(+), 2 deletions(-) diff --git a/packages/stack-auth/CHANGELOG.md b/packages/stack-auth/CHANGELOG.md index d34a4c661..6ff64eff3 100644 --- a/packages/stack-auth/CHANGELOG.md +++ b/packages/stack-auth/CHANGELOG.md @@ -1,4 +1,11 @@ +## [0.42.1] - 2026-08-12 + + +### Miscellaneous + +- update Cargo.toml dependencies + ## [0.42.0] - 2026-07-19 diff --git a/packages/stack-auth/Cargo.toml b/packages/stack-auth/Cargo.toml index 1be7fd3f2..a1a115172 100644 --- a/packages/stack-auth/Cargo.toml +++ b/packages/stack-auth/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "stack-auth" description = "Authentication library for CipherStash services" -version = "0.42.0" +version = "0.42.1" edition.workspace = true authors.workspace = true repository.workspace = true diff --git a/packages/stack-profile/CHANGELOG.md b/packages/stack-profile/CHANGELOG.md index c1da7408c..7cbecd1b4 100644 --- a/packages/stack-profile/CHANGELOG.md +++ b/packages/stack-profile/CHANGELOG.md @@ -37,6 +37,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 +## [0.42.1] - 2026-08-12 + + ## [0.42.0] - 2026-07-19 diff --git a/packages/stack-profile/Cargo.toml b/packages/stack-profile/Cargo.toml index 3f6766545..0591631c0 100644 --- a/packages/stack-profile/Cargo.toml +++ b/packages/stack-profile/Cargo.toml @@ -2,7 +2,7 @@ name = "stack-profile" description = "Centralised ~/.cipherstash profile file management" license-file = "LICENSE" -version = "0.42.0" +version = "0.42.1" edition.workspace = true authors.workspace = true repository.workspace = true From 0e2f85767caade7ed7732e5ccc1c59b95a1dbfa8 Mon Sep 17 00:00:00 2001 From: Toby Hede Date: Wed, 12 Aug 2026 12:58:33 +1000 Subject: [PATCH 385/686] =?UTF-8?q?docs(auth):=20=F0=9F=A9=B9=20correct=20?= =?UTF-8?q?the=20fixture=20comment=20for=20the=20hand-rolled=20decode?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review feedback: the header block still said the claim readers call `insecure_disable_signature_validation()`, which no longer exists anywhere — `decode_jwt_payload` now splits on `.`, base64url-decodes the payload segment and deserialises it, on every target. The `mintJwt` docstring was stale in the same way: it claimed the header `alg` is read, and it no longer is. Only the payload is; the header and signature segments just have to be present for the three-segment check. Corrected both, since the second sentence is what a reader would act on when editing the fixture. The two surviving references (`jwt_verifier.rs:23`, `unverified_jwt.rs:427`) are deliberate: both describe the v9 pattern in the past tense to explain why the hand-rolled parser exists. --- .../packages/auth/__tests__/helpers/test-fixtures.ts | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/languages/typescript/packages/auth/__tests__/helpers/test-fixtures.ts b/languages/typescript/packages/auth/__tests__/helpers/test-fixtures.ts index 2bee9f09b..b37a316c0 100644 --- a/languages/typescript/packages/auth/__tests__/helpers/test-fixtures.ts +++ b/languages/typescript/packages/auth/__tests__/helpers/test-fixtures.ts @@ -1,7 +1,8 @@ // Test fixtures that replace the Rust `test-utils` helpers (`saveTestToken` and -// the JWT minting inside `MockAuthServer`). The stack-auth claim readers call -// `insecure_disable_signature_validation()`, so a JWT only needs a well-formed -// header + base64url payload — no real HMAC signing, hence zero crypto deps. +// the JWT minting inside `MockAuthServer`). The stack-auth claim readers never +// verify signatures — `decode_jwt_payload` splits on `.`, base64url-decodes the +// payload segment and deserialises it — so a JWT only needs three segments and a +// well-formed payload, hence zero crypto deps. import { mkdirSync, writeFileSync } from "node:fs"; import { join } from "node:path"; @@ -15,8 +16,8 @@ function base64url(value: unknown): string { /** * Mint an unsigned-but-well-formed JWT (`

..sig`). Mirrors the * claims of `mock_auth_server::test_jwt`; `claims` overrides/extends them (e.g. - * to add a `services` claim). The signature segment is a literal placeholder — - * only the header `alg` and the payload are ever read. + * to add a `services` claim). Only the payload is ever read: the header and + * signature segments just have to be present for the three-segment check. */ export function mintJwt(claims: Record = {}): string { const now = Math.floor(Date.now() / 1000); From 2268505cbfdcffaa0e634b0f091ee4cf5995dba9 Mon Sep 17 00:00:00 2001 From: Toby Hede Date: Wed, 12 Aug 2026 15:47:55 +1000 Subject: [PATCH 386/686] feat(stack-auth): classify usage denials as typed, non-retryable errors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A CTS 402 arrived as `AuthError::Server` on the access-key and OIDC paths, and as `AuthError::AccessDenied` on the refresh path. Both are wrong in ways that cost the user: `SERVER_ERROR` reads as transient, so clients back off and retry against a condition only a human with a credit card can clear; `ACCESS_DENIED` reads as a permissions problem they cannot act on. Add `UsageLimitExceeded` and `OrgNotProvisioned`, carrying the server's description verbatim with a miette `help` naming the remedy — upgrade the plan for one, contact support for the other, since an org the usage system has never heard of has nothing to upgrade. The remedy lives here rather than in an `error_uri`, because the dashboard URL is environment-specific config and a hardcoded docs link would point at a page nobody has written. All four issuance paths route through one `classify_issuance_failure`. `Token::refresh` had its own inline copy that disagreed with the shared helper on three inputs, including a bodyless 402 — it parsed the body as JSON before inspecting the status, so the same server response produced different client errors depending on which refresher the caller used. The device-code poll loop never reached the classifier at all, so `stash login` reported ACCESS_DENIED for a usage limit. All four now read the body as text and delegate first. `cs_code` presence is decided on the raw JSON value, not a string projection of it. Reading it through `as_str()` made `{"cs_code": 42}` indistinguishable from an absent field, so it skipped the guard and was classified as a usage limit — the exact inversion of the guard's purpose. Anything present but not exactly one of our codes declines, since telling a user to pay on the strength of a body we could not parse is the expensive direction to be wrong in. The comparison is written with `if`/`else` rather than match arms: these are `&str` consts from another crate, and in pattern position they bind instead of comparing, which makes the first arm match everything. `refresh_blocking` and `refresh_non_blocking` propagate the server's answer instead of flattening it to `Expired` and dropping it respectively. The typed error otherwise only survived first auth, so for any long-lived client — the common case — an org over its usage limit was told its token had expired, which is the one diagnosis guaranteed not to help and sends the caller round the same refresh loop that just failed. Callers that only waited on someone else's in-flight refresh get the same answer. The refusal is recorded before `notify_waiters` wakes them, so consulting it on the way out costs nothing — and without it they fall back to `Expired`: the same misdiagnosis reached by a different route, which the CLI turns into a login prompt for a condition no login clears. A still-usable token still wins, matching the pre-refresh path, since a settled refusal suppresses further requests rather than invalidating a working credential. `AutoRefresh` caches a settled refusal for 60s, because nothing else stopped an over-limit client re-POSTing `/api/authorise` at its own request rate. The cache is deliberately narrower than `is_retryable()`: most non-retryable failures — `invalid_grant`, `invalid_client` — are verdicts on the credential, and the refresher restores that credential precisely so a later attempt can succeed. A usage limit is different in kind; the credential was never the problem, so re-presenting it cannot change the answer. The TTL bounds how long a plan upgrade goes unnoticed, and a successful refresh clears the entry outright, so recovery through any other path is immediate rather than waiting out a stale window. A backwards clock counts as stale rather than "recorded just now". `saturating_sub` yields 0 for a clock that moved backwards, which reads as freshly recorded and pins the entry — the opposite of what its comment claimed. Asking again costs one request; pinning can outlast the process. Adds proptest (version matched to `vitur-server-core`) for the two properties that generalise past hand-picked inputs: only a 402 ever classifies, and a classified error always carries a usable message. Both are verified by neutering `classify_issuance_failure` and confirming they fail. `UsageLimitExceeded` was the only one of nineteen error structs missing from the crate-root re-exports, leaving the type unnameable to SDK consumers. The TypeScript `AuthFailure` unions in index.d.ts, wasm-inline.d.ts and wasm-types.d.ts gain the new discriminants. `wasm-types.d.ts` sat outside the drift guard, which scrapes `type: "CODE"` and cannot see a `| 'CODE'` union; it had already drifted — a phantom UNKNOWN_ERROR, and three real codes missing. --- languages/typescript/packages/auth/README.md | 6 +- languages/typescript/packages/auth/index.d.ts | 2 + languages/typescript/packages/auth/src/lib.rs | 36 ++ .../typescript/packages/auth/wasm-inline.d.ts | 2 + .../typescript/packages/auth/wasm-types.d.ts | 18 +- packages/stack-auth/Cargo.toml | 2 + .../stack-auth/src/access_key_refresher.rs | 389 +++++++++++- packages/stack-auth/src/auto_refresh.rs | 303 +++++++++- packages/stack-auth/src/device_code/mod.rs | 17 +- packages/stack-auth/src/device_code/tests.rs | 57 ++ packages/stack-auth/src/error.rs | 571 ++++++++++++++++++ packages/stack-auth/src/lib.rs | 16 +- packages/stack-auth/src/oidc_refresher.rs | 33 + packages/stack-auth/src/token.rs | 147 ++++- 14 files changed, 1560 insertions(+), 39 deletions(-) diff --git a/languages/typescript/packages/auth/README.md b/languages/typescript/packages/auth/README.md index 653098bb8..41a80f822 100644 --- a/languages/typescript/packages/auth/README.md +++ b/languages/typescript/packages/auth/README.md @@ -268,7 +268,11 @@ if (created.failure) { const strategy = created.data; ``` -Failure `type`s: `INVALID_ACCESS_KEY`, `ACCESS_DENIED`, `EXPIRED_TOKEN`, `INVALID_GRANT`, `INVALID_CLIENT`, `INVALID_REGION`, `INVALID_URL`, `INVALID_TOKEN`, `SERVER_ERROR`, `REQUEST_ERROR`, `NOT_AUTHENTICATED`, `MISSING_WORKSPACE_CRN`, `INVALID_CRN`, `WORKSPACE_MISMATCH`, `INVALID_WORKSPACE_ID`, `ALREADY_CONSUMED`, `INTERNAL_ERROR`, `STORE_ERROR`. Each `failure` also carries the live `error: Error` and optional `help`/`url`. Only a genuine internal panic still throws. +Failure `type`s: `INVALID_ACCESS_KEY`, `ACCESS_DENIED`, `EXPIRED_TOKEN`, `INVALID_GRANT`, `INVALID_CLIENT`, `INVALID_REGION`, `INVALID_URL`, `INVALID_TOKEN`, `USAGE_LIMIT_EXCEEDED`, `ORG_NOT_PROVISIONED`, `SERVER_ERROR`, `REQUEST_ERROR`, `NOT_AUTHENTICATED`, `MISSING_WORKSPACE_CRN`, `INVALID_CRN`, `WORKSPACE_MISMATCH`, `INVALID_WORKSPACE_ID`, `ALREADY_CONSUMED`, `INTERNAL_ERROR`, `CUSTOM`, `STORE_ERROR`. Each `failure` also carries the live `error: Error` and optional `help`/`url`. Only a genuine internal panic still throws. + +`USAGE_LIMIT_EXCEEDED` means the organisation has exhausted its allowance for the current billing period. Retrying will not clear it — the plan has to be upgraded from the CipherStash dashboard first. + +`ORG_NOT_PROVISIONED` means the organisation is not set up for usage tracking at all. There is no plan to upgrade; contact CipherStash support. > **Migrating from the throw-based API (0.40.x and earlier):** replace > `try { const t = await s.getToken(); … } catch (err) { err.code }` diff --git a/languages/typescript/packages/auth/index.d.ts b/languages/typescript/packages/auth/index.d.ts index f973dbeb0..70f4d35e3 100644 --- a/languages/typescript/packages/auth/index.d.ts +++ b/languages/typescript/packages/auth/index.d.ts @@ -48,6 +48,8 @@ export type AuthFailure = | (FailureBase & { type: "INVALID_URL" }) | (FailureBase & { type: "INVALID_REGION" }) | (FailureBase & { type: "INVALID_TOKEN" }) + | (FailureBase & { type: "USAGE_LIMIT_EXCEEDED" }) + | (FailureBase & { type: "ORG_NOT_PROVISIONED" }) | (FailureBase & { type: "SERVER_ERROR" }) | (FailureBase & { type: "NOT_AUTHENTICATED" }) | (FailureBase & { type: "MISSING_WORKSPACE_CRN" }) diff --git a/languages/typescript/packages/auth/src/lib.rs b/languages/typescript/packages/auth/src/lib.rs index 25069ee0c..809a0e7bb 100644 --- a/languages/typescript/packages/auth/src/lib.rs +++ b/languages/typescript/packages/auth/src/lib.rs @@ -621,6 +621,42 @@ mod tests { } } + /// `wasm-types.d.ts` declares the same taxonomy in a different shape — a + /// bare `| 'CODE'` union rather than `FailureBase & { type: "CODE" }` — so + /// the scrape above cannot see it. It went unchecked long enough to grow a + /// phantom `UNKNOWN_ERROR` and lose three real codes. + /// + /// The wasm build has no `Store` variant (see the `cfg` on `AuthError`), + /// so its union is `ERROR_CODES` minus `STORE_ERROR`. + #[test] + fn wasm_types_union_matches_error_codes() { + use std::collections::BTreeSet; + + let dts = include_str!("../wasm-types.d.ts"); + + let union: BTreeSet<&str> = dts + .match_indices("| '") + .map(|(i, _)| { + let after = &dts[i + "| '".len()..]; + let close = after + .find('\'') + .expect("TS union member missing close quote"); + &after[..close] + }) + .collect(); + + let expected: BTreeSet<&str> = AuthError::ERROR_CODES + .iter() + .copied() + .filter(|code| *code != "STORE_ERROR") + .collect(); + + assert_eq!( + union, expected, + "AuthErrorCode union in wasm-types.d.ts drifted from AuthError::ERROR_CODES", + ); + } + #[test] fn index_dts_retains_hand_written_reexports() { // The union test above only guards the `AuthFailure` codes. The other diff --git a/languages/typescript/packages/auth/wasm-inline.d.ts b/languages/typescript/packages/auth/wasm-inline.d.ts index 77dae1928..efc62a066 100644 --- a/languages/typescript/packages/auth/wasm-inline.d.ts +++ b/languages/typescript/packages/auth/wasm-inline.d.ts @@ -42,6 +42,8 @@ export type AuthFailure = | (FailureBase & { type: "INVALID_URL" }) | (FailureBase & { type: "INVALID_REGION" }) | (FailureBase & { type: "INVALID_TOKEN" }) + | (FailureBase & { type: "USAGE_LIMIT_EXCEEDED" }) + | (FailureBase & { type: "ORG_NOT_PROVISIONED" }) | (FailureBase & { type: "SERVER_ERROR" }) | (FailureBase & { type: "NOT_AUTHENTICATED" }) | (FailureBase & { type: "MISSING_WORKSPACE_CRN" }) diff --git a/languages/typescript/packages/auth/wasm-types.d.ts b/languages/typescript/packages/auth/wasm-types.d.ts index dd7db92da..b60e054f5 100644 --- a/languages/typescript/packages/auth/wasm-types.d.ts +++ b/languages/typescript/packages/auth/wasm-types.d.ts @@ -27,20 +27,24 @@ export type AuthErrorCode = | 'REQUEST_ERROR' | 'ACCESS_DENIED' - | 'EXPIRED_TOKEN' | 'INVALID_GRANT' | 'INVALID_CLIENT' | 'INVALID_URL' | 'INVALID_REGION' - | 'INVALID_TOKEN' - | 'SERVER_ERROR' - | 'NOT_AUTHENTICATED' - | 'MISSING_WORKSPACE_CRN' - | 'INVALID_ACCESS_KEY' | 'INVALID_CRN' | 'WORKSPACE_MISMATCH' | 'INVALID_WORKSPACE_ID' - | 'UNKNOWN_ERROR' + | 'MISSING_WORKSPACE_CRN' + | 'NOT_AUTHENTICATED' + | 'EXPIRED_TOKEN' + | 'INVALID_ACCESS_KEY' + | 'INVALID_TOKEN' + | 'USAGE_LIMIT_EXCEEDED' + | 'ORG_NOT_PROVISIONED' + | 'SERVER_ERROR' + | 'ALREADY_CONSUMED' + | 'INTERNAL_ERROR' + | 'CUSTOM' /** An error thrown by this package, enriched with a machine-readable `.code`. */ export interface AuthError extends Error { diff --git a/packages/stack-auth/Cargo.toml b/packages/stack-auth/Cargo.toml index c561c7ebe..4561cd12e 100644 --- a/packages/stack-auth/Cargo.toml +++ b/packages/stack-auth/Cargo.toml @@ -71,6 +71,8 @@ required-features = ["test-utils"] axum = "0.8" cts-common = { workspace = true } mocktail = "0.3.0" +# Version matches the existing use in `vitur-server-core`. +proptest = "1.7.0" temp-env = { workspace = true } tempfile = "3.21.0" tokio = { workspace = true, features = ["test-util"] } diff --git a/packages/stack-auth/src/access_key_refresher.rs b/packages/stack-auth/src/access_key_refresher.rs index 9317b4177..d9bfda4eb 100644 --- a/packages/stack-auth/src/access_key_refresher.rs +++ b/packages/stack-auth/src/access_key_refresher.rs @@ -63,6 +63,9 @@ impl Refresher for AccessKeyRefresher { let status = resp.status(); let body = resp.text().await.unwrap_or_default(); tracing::debug!(%status, %body, "access key auth failed"); + if let Some(err) = crate::error::classify_issuance_failure(status.as_u16(), &body) { + return Err(err); + } return Err(AuthError::Server(crate::error::ServerError(format!( "{status}: {body}" )))); @@ -211,6 +214,60 @@ mod tests { assert_eq!(token.as_str(), "new-token"); } + /// A usage denial must not arrive as `SERVER_ERROR`. Clients treat that as + /// transient and retry — but no amount of retrying clears a usage limit, so + /// they would spin until the plan changes. + #[tokio::test] + async fn usage_limit_402_is_typed_not_server_error() { + let mut mocks = MockSet::new(); + mocks.mock(|when, then| { + when.post().path("/api/authorise"); + then.status(reqwest::StatusCode::PAYMENT_REQUIRED).json(serde_json::json!({ + "error": "usage_limit_exceeded", + "error_description": "Workspace has exceeded its usage limit and cannot issue an access token", + })); + }); + let server = start_server(mocks).await; + let strategy = make_access_key_strategy(&server); + + let err = strategy + .get_token() + .await + .expect_err("402 must fail the token request"); + + let auth_err = match err { + AutoRefreshError::Auth(e) => e, + other => panic!("expected an auth error, got {other:?}"), + }; + assert_eq!(auth_err.error_code(), "USAGE_LIMIT_EXCEEDED"); + assert!( + auth_err.to_string().contains("exceeded its usage limit"), + "server's description should survive verbatim, got {auth_err}", + ); + } + + /// Only 402 means "usage limit". Other failures must keep their existing + /// classification, or this becomes a catch-all that hides real errors. + #[tokio::test] + async fn non_402_failures_are_unchanged() { + let mut mocks = MockSet::new(); + mocks.mock(|when, then| { + when.post().path("/api/authorise"); + then.internal_server_error() + .json(serde_json::json!({"error": "boom"})); + }); + let server = start_server(mocks).await; + let strategy = make_access_key_strategy(&server); + + let err = strategy.get_token().await.expect_err("500 must fail"); + + let auth_err = match err { + AutoRefreshError::Auth(e) => e, + other => panic!("expected an auth error, got {other:?}"), + }; + assert_eq!(auth_err.error_code(), "SERVER_ERROR"); + } + #[tokio::test] async fn test_caches_token_after_initial_auth() { let mut mocks = MockSet::new(); @@ -421,7 +478,7 @@ mod tests { } #[tokio::test] - async fn test_refresh_failure_returns_expired() { + async fn refresh_failure_propagates_the_refusal_not_expired() { let mut mocks = MockSet::new(); mocks.mock(|when, then| { when.post().path("/api/authorise"); @@ -436,7 +493,333 @@ mod tests { let err = strategy.get_token().await.unwrap_err(); - assert!(matches!(err, AutoRefreshError::Expired)); + assert!( + matches!(err, AutoRefreshError::Auth(_)), + "the caller must see why the refresh was refused; flattening to \ + Expired tells them to do the one thing that cannot help — {err:?}", + ); + } + + #[tokio::test] + async fn usage_limit_on_refresh_reaches_the_caller() { + let mut mocks = MockSet::new(); + mocks.mock(|when, then| { + when.post().path("/api/authorise"); + then.status(reqwest::StatusCode::PAYMENT_REQUIRED) + .json(serde_json::json!({ + "error": "access_denied", + "cs_code": "USAGE_LIMIT_EXCEEDED", + "error_description": "Workspace has exceeded its usage limit", + })); + }); + let server = start_server(mocks).await; + + let refresher = + AccessKeyRefresher::new(SecretToken::new("test-access-key"), server.url(""), None); + let strategy = AutoRefresh::with_token(refresher, make_expired_token("old-token")); + + let AutoRefreshError::Auth(err) = strategy.get_token().await.unwrap_err() else { + panic!("expected a typed auth error"); + }; + + assert_eq!(err.error_code(), crate::error::codes::USAGE_LIMIT_EXCEEDED); + } + + /// Counts requests to `/api/authorise` and replies with a fixed status and + /// body, so a test can assert how many times the client actually went to + /// the network rather than only what it returned. + async fn start_counting_server( + status: axum::http::StatusCode, + body: serde_json::Value, + ) -> (Url, Arc) { + type CountingState = (Arc, axum::http::StatusCode, serde_json::Value); + + async fn handler( + axum::extract::State((calls, status, body)): axum::extract::State, + ) -> (axum::http::StatusCode, axum::Json) { + calls.fetch_add(1, Ordering::SeqCst); + (status, axum::Json(body)) + } + + let calls = Arc::new(AtomicUsize::new(0)); + let app = axum::Router::new() + .route("/api/authorise", axum::routing::post(handler)) + .with_state((calls.clone(), status, body)); + + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { + axum::serve(listener, app).await.unwrap(); + }); + + (Url::parse(&format!("http://{addr}")).unwrap(), calls) + } + + /// A usage limit will not clear by asking again. Without a negative cache + /// an over-limit client re-POSTs `/api/authorise` on every `get_token` — + /// at its own request rate, against a decision already made. + #[tokio::test] + async fn a_settled_refusal_is_not_re_issued_on_every_call() { + let (url, calls) = start_counting_server( + axum::http::StatusCode::PAYMENT_REQUIRED, + serde_json::json!({ + "error": "access_denied", + "cs_code": "USAGE_LIMIT_EXCEEDED", + "error_description": "Workspace has exceeded its usage limit", + }), + ) + .await; + + let refresher = AccessKeyRefresher::new(SecretToken::new("test-access-key"), url, None); + let strategy = AutoRefresh::with_token(refresher, make_expired_token("old-token")); + + for call in 1..=5 { + let AutoRefreshError::Auth(err) = strategy.get_token().await.unwrap_err() else { + panic!("call {call}: expected a typed auth error"); + }; + assert_eq!( + err.error_code(), + crate::error::codes::USAGE_LIMIT_EXCEEDED, + "call {call}: the cached refusal must be replayed verbatim", + ); + } + + assert_eq!( + calls.load(Ordering::SeqCst), + 1, + "five get_token calls against a settled refusal must produce one \ + HTTP request, not five", + ); + } + + /// Serves a usage limit until `upgraded` is set, then a valid token — + /// modelling a customer upgrading their plan while a strategy is live. + async fn start_upgradable_server() -> (Url, Arc, Arc) { + type UpgradableState = (Arc, Arc); + + async fn handler( + axum::extract::State((calls, upgraded)): axum::extract::State, + ) -> (axum::http::StatusCode, axum::Json) { + calls.fetch_add(1, Ordering::SeqCst); + if upgraded.load(Ordering::SeqCst) { + ( + axum::http::StatusCode::OK, + axum::Json(auth_response_json("upgraded-token", 3600)), + ) + } else { + ( + axum::http::StatusCode::PAYMENT_REQUIRED, + axum::Json(serde_json::json!({ + "error": "access_denied", + "cs_code": "USAGE_LIMIT_EXCEEDED", + "error_description": "Workspace has exceeded its usage limit", + })), + ) + } + } + + let calls = Arc::new(AtomicUsize::new(0)); + let upgraded = Arc::new(AtomicBool::new(false)); + let app = axum::Router::new() + .route("/api/authorise", axum::routing::post(handler)) + .with_state((calls.clone(), upgraded.clone())); + + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { + axum::serve(listener, app).await.unwrap(); + }); + + ( + Url::parse(&format!("http://{addr}")).unwrap(), + calls, + upgraded, + ) + } + + fn now_secs() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs() + } + + /// A token whose expiry is an absolute instant, for tests driving a frozen + /// [`TestClock`](crate::clock::TestClock). + /// + /// `make_expired_token` reads the wall clock itself, so pairing it with a + /// clock frozen at a separately-read `now` is a race: if the two reads + /// straddle a second boundary the token is a second short of expired, no + /// refresh is attempted, and the test fails only on an unlucky run. Derive + /// both from one instant instead. + fn make_token_expiring_at(access: &str, expires_at: u64) -> Token { + Token { + access_token: SecretToken::new(access), + token_type: "Bearer".to_string(), + expires_at, + refresh_token: None, + region: None, + client_id: None, + device_instance_id: None, + } + } + + /// A cached refusal must not be permanent. Suppressing the retry storm is + /// the point; suppressing it forever means a customer who upgrades their + /// plan stays locked out until the process restarts. + #[tokio::test] + async fn a_settled_refusal_is_retried_once_it_expires() { + let (url, calls, _upgraded) = start_upgradable_server().await; + let start = now_secs(); + let clock = crate::clock::TestClock::new(start); + + let refresher = AccessKeyRefresher::new(SecretToken::new("test-access-key"), url, None); + let strategy = AutoRefresh::with_token_and_clock( + refresher, + make_token_expiring_at("old-token", start - 3600), + clock.shared(), + ); + + strategy.get_token().await.unwrap_err(); + strategy.get_token().await.unwrap_err(); + assert_eq!( + calls.load(Ordering::SeqCst), + 1, + "within the window the cached refusal is replayed", + ); + + clock.advance(super::super::auto_refresh::DENIAL_TTL_SECS + 1); + strategy.get_token().await.unwrap_err(); + + assert_eq!( + calls.load(Ordering::SeqCst), + 2, + "once the refusal expires the server must be asked again", + ); + } + + /// The reason the expiry matters: the upgrade has to become visible. + #[tokio::test] + async fn an_upgraded_plan_is_observed_once_the_refusal_expires() { + let (url, _calls, upgraded) = start_upgradable_server().await; + let start = now_secs(); + let clock = crate::clock::TestClock::new(start); + + let refresher = AccessKeyRefresher::new(SecretToken::new("test-access-key"), url, None); + let strategy = AutoRefresh::with_token_and_clock( + refresher, + make_token_expiring_at("old-token", start - 3600), + clock.shared(), + ); + + strategy.get_token().await.unwrap_err(); + + // Customer upgrades their plan. + upgraded.store(true, Ordering::SeqCst); + + strategy + .get_token() + .await + .expect_err("still inside the refusal window"); + + clock.advance(super::super::auto_refresh::DENIAL_TTL_SECS + 1); + + let token = strategy + .get_token() + .await + .expect("an upgraded plan must eventually be observed"); + assert_eq!(token.as_str(), "upgraded-token"); + } + + /// A successful refresh clears the refusal outright, so the *next* call + /// after recovery does not wait out a stale window. + #[tokio::test] + async fn a_success_clears_the_refusal_immediately() { + let (url, calls, upgraded) = start_upgradable_server().await; + let start = now_secs(); + let clock = crate::clock::TestClock::new(start); + + let refresher = AccessKeyRefresher::new(SecretToken::new("test-access-key"), url, None); + let strategy = AutoRefresh::with_token_and_clock( + refresher, + make_token_expiring_at("old-token", start - 3600), + clock.shared(), + ); + + strategy.get_token().await.unwrap_err(); + upgraded.store(true, Ordering::SeqCst); + clock.advance(super::super::auto_refresh::DENIAL_TTL_SECS + 1); + strategy.get_token().await.unwrap(); + + let before = calls.load(Ordering::SeqCst); + strategy + .get_token() + .await + .expect("cached token is still valid"); + + assert_eq!( + calls.load(Ordering::SeqCst), + before, + "a valid cached token needs no further round-trip", + ); + } + + /// A wall clock can move backwards — NTP step, VM snapshot restore, a + /// manual change. `now - recorded_at` would then underflow, and with a + /// wrapping subtraction the refusal would look freshly recorded for + /// billions of seconds. Erring towards asking again costs one request. + #[tokio::test] + async fn a_backwards_clock_does_not_pin_the_refusal() { + let (url, calls, upgraded) = start_upgradable_server().await; + let start = now_secs(); + let clock = crate::clock::TestClock::new(start); + + let refresher = AccessKeyRefresher::new(SecretToken::new("test-access-key"), url, None); + // Expired well before `start`, so it is still expired after the rewind + // — otherwise the token-expiry check short-circuits and the refusal is + // never consulted, and the test would prove nothing. + let strategy = AutoRefresh::with_token_and_clock( + refresher, + make_token_expiring_at("old-token", start - 86_400), + clock.shared(), + ); + + strategy.get_token().await.unwrap_err(); + assert_eq!(calls.load(Ordering::SeqCst), 1); + + upgraded.store(true, Ordering::SeqCst); + clock.set(start - 3600); + + strategy + .get_token() + .await + .expect("a clock that jumped backwards must not pin the refusal"); + assert_eq!(calls.load(Ordering::SeqCst), 2); + } + + /// The mirror of the above: a server fault may clear, so it must *not* + /// stick. Treating a transient failure as permanent locks a client out of + /// a service that has since recovered — the worse of the two mistakes. + #[tokio::test] + async fn a_server_fault_is_retried_on_the_next_call() { + let (url, calls) = start_counting_server( + axum::http::StatusCode::INTERNAL_SERVER_ERROR, + serde_json::json!({}), + ) + .await; + + let refresher = AccessKeyRefresher::new(SecretToken::new("test-access-key"), url, None); + let strategy = AutoRefresh::with_token(refresher, make_expired_token("old-token")); + + for _ in 0..3 { + strategy.get_token().await.unwrap_err(); + } + + assert_eq!( + calls.load(Ordering::SeqCst), + 3, + "a server fault must be retried; only settled refusals stick", + ); } // ---- Cascade prevention tests ---- @@ -549,7 +932,7 @@ mod tests { // ---- Stress tests ---- - use std::sync::atomic::{AtomicUsize, Ordering}; + use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; use std::time::{Duration, Instant}; #[derive(Clone)] diff --git a/packages/stack-auth/src/auto_refresh.rs b/packages/stack-auth/src/auto_refresh.rs index 7b8391ebb..03aa31ad8 100644 --- a/packages/stack-auth/src/auto_refresh.rs +++ b/packages/stack-auth/src/auto_refresh.rs @@ -58,8 +58,63 @@ pub(crate) struct AutoRefresh { clock: SharedClock, } +/// How long a cached refusal is replayed before the server is asked again. +/// +/// The cache exists to stop an over-limit client re-issuing the same doomed +/// request at its own request rate. It must not outlive its usefulness: the +/// customer can upgrade their plan at any moment, and that is invisible to us +/// until we ask. Sixty seconds turns a per-request storm into one call a +/// minute while bounding how long an upgrade goes unnoticed. +pub(crate) const DENIAL_TTL_SECS: u64 = 60; + struct State { token: Option, + /// The last refusal that will not resolve by retrying, if any. + /// + /// Without this, a client whose org is over its usage limit re-issues the + /// same doomed request on every `get_token` call — at request rate, against + /// a decision that has already been made. Expires after + /// [`DENIAL_TTL_SECS`], and is cleared outright by any successful refresh. + denial: Option, +} + +/// A non-retryable refusal, held in a form that can be handed to more than one +/// caller. +/// +/// [`AuthError`](crate::AuthError) is not `Clone` — it wraps foreign error +/// types — so the denial is stored as the wire pair it round-trips through and +/// rebuilt per call. `USAGE_LIMIT_EXCEEDED` round-trips exactly, message +/// included; see [`AuthError::from_error_code`](crate::AuthError::from_error_code). +struct StickyDenial { + code: &'static str, + message: String, + recorded_at: u64, +} + +impl StickyDenial { + fn new(err: &crate::AuthError, now: u64) -> Self { + Self { + code: err.error_code(), + message: err.to_string(), + recorded_at: now, + } + } + + /// Whether the refusal has outlived its window. + /// + /// A clock reading earlier than the moment of recording (NTP step, VM + /// snapshot restore, manual change) counts as stale. The elapsed time is + /// then unknowable, and the two ways of being wrong are not equal: asking + /// again costs one request, while pinning the entry locks the caller out + /// until the clock catches up — which for a large backwards step is + /// indistinguishable from forever. + fn is_stale(&self, now: u64) -> bool { + now < self.recorded_at || now - self.recorded_at >= DENIAL_TTL_SECS + } + + fn to_error(&self) -> crate::AuthError { + crate::AuthError::from_error_code(self.code, &self.message, &serde_json::Map::new()) + } } /// Ensures [`AutoRefresh::refresh_in_progress`] is cleared and waiters are @@ -89,6 +144,46 @@ impl CancelGuard<'_> { } impl State { + fn new(token: Option) -> Self { + Self { + token, + denial: None, + } + } + + /// Remember `err` if it refuses the *account* rather than the credential. + /// + /// Deliberately narrower than [`AuthError::is_retryable`](crate::AuthError::is_retryable). + /// Most non-retryable failures — `invalid_grant`, `invalid_client` — are + /// verdicts on the credential, and the refresher restores that credential + /// precisely so a later attempt can succeed once the caller supplies a + /// good one. Caching those would defeat the restore path. + /// + /// A usage limit is different in kind: the credential was never the + /// problem, so re-presenting it cannot change the answer. Only that class + /// sticks, and only until a refresh succeeds. + fn record_if_account_refused(&mut self, err: &crate::AuthError, now: u64) { + if matches!( + err, + crate::AuthError::UsageLimitExceeded(_) | crate::AuthError::OrgNotProvisioned(_) + ) { + self.denial = Some(StickyDenial::new(err, now)); + } + } + + /// The recorded refusal if it is still within its window, discarding it if + /// not so the next attempt goes back to the network. + fn fresh_denial(&mut self, now: u64) -> Option { + match &self.denial { + Some(denial) if !denial.is_stale(now) => Some(denial.to_error()), + Some(_) => { + self.denial = None; + None + } + None => None, + } + } + fn service_token(&self) -> Result { let token = self.token.as_ref().ok_or(AutoRefreshError::NotFound)?; Ok(ServiceToken::new(token.access_token().clone())) @@ -112,7 +207,7 @@ impl AutoRefresh { pub(crate) fn with_token(refresher: R, token: Token) -> Self { Self { refresher, - state: Mutex::new(State { token: Some(token) }), + state: Mutex::new(State::new(Some(token))), store: NoStore, refresh_in_progress: AtomicBool::new(false), refresh_notify: Notify::new(), @@ -126,7 +221,7 @@ impl AutoRefresh { pub(crate) fn with_token_and_clock(refresher: R, token: Token, clock: SharedClock) -> Self { Self { refresher, - state: Mutex::new(State { token: Some(token) }), + state: Mutex::new(State::new(Some(token))), store: NoStore, refresh_in_progress: AtomicBool::new(false), refresh_notify: Notify::new(), @@ -146,7 +241,7 @@ impl AutoRefresh { pub(crate) fn with_store(refresher: R, store: S) -> Self { Self { refresher, - state: Mutex::new(State { token: None }), + state: Mutex::new(State::new(None)), store, refresh_in_progress: AtomicBool::new(false), refresh_notify: Notify::new(), @@ -173,15 +268,18 @@ impl AutoRefresh { } } + // Read "now" once from the injected clock and use it for every expiry + // decision in this call — token expiry and refusal expiry alike — so + // the checks are mutually consistent and deterministic under test. + let now = self.clock.now_unix_secs(); + if state.token.is_none() { + if let Some(err) = state.fresh_denial(now) { + return Err(AutoRefreshError::Auth(err)); + } return self.initial_auth(&mut state).await; } - // Read "now" once from the injected clock and use it for every expiry - // decision in this call, so the checks are mutually consistent and - // deterministic under test. - let now = self.clock.now_unix_secs(); - if !state.token.as_ref().is_some_and(|t| t.is_expired_at(now)) { return state.service_token(); } @@ -190,6 +288,17 @@ impl AutoRefresh { return self.wait_for_in_flight_refresh(state, now).await; } + // A settled refusal stands until something outside this client changes. + // Checked before `try_credential`, which moves the credential out of + // the cached token and would need restoring on an early return. + if let Some(err) = state.fresh_denial(now) { + return if state.token.as_ref().is_some_and(|t| t.is_usable_at(now)) { + state.service_token() + } else { + Err(AutoRefreshError::Auth(err)) + }; + } + let Some(credential) = self.refresher.try_credential(state.token.as_mut()) else { return state.require_usable_token(now); }; @@ -226,6 +335,7 @@ impl AutoRefresh { Err(err) => { guard.defuse(); self.refresh_in_progress.store(false, Ordering::Release); + state.record_if_account_refused(&err, self.clock.now_unix_secs()); Err(AutoRefreshError::Auth(err)) } } @@ -248,6 +358,8 @@ impl AutoRefresh { fn install_refreshed_token(&self, state: &mut State, new_token: Token) -> ServiceToken { let service_token = ServiceToken::new(new_token.access_token().clone()); state.token = Some(new_token); + // A success proves whatever previously refused us has changed its mind. + state.denial = None; self.refresh_in_progress.store(false, Ordering::Release); service_token } @@ -275,8 +387,27 @@ impl AutoRefresh { // Re-check after wake — refresh may have failed. Re-read the clock: an // arbitrary amount of time may have passed while awaiting the refresh. let now = self.clock.now_unix_secs(); - let state = self.state.lock().await; - state.require_usable_token(now) + let mut state = self.state.lock().await; + match state.require_usable_token(now) { + Ok(token) => Ok(token), + // The refresh we waited on may have failed with a refusal that no + // retry clears. It is already recorded — `refresh_non_blocking` + // records before `notify_waiters` wakes us — so reporting + // `Expired` here would tell every caller *except* the one that + // issued the request that their token lapsed. That is the same + // misdiagnosis `refresh_blocking` stopped making, reached by a + // different route: it sends the caller round the refresh loop + // that just failed, for a condition only a plan upgrade or + // support can clear. + // + // A still-usable token still wins, matching the pre-refresh path + // in `get_token`: a settled refusal suppresses further requests, + // it does not invalidate a credential that still works. + Err(unusable) => match state.fresh_denial(now) { + Some(err) => Err(AutoRefreshError::Auth(err)), + None => Err(unusable), + }, + } } /// Token is expiring but still usable — drop the lock, refresh in the @@ -327,6 +458,10 @@ impl AutoRefresh { self.refresher.restore(token, credential); } self.refresh_in_progress.store(false, Ordering::Release); + // The cached token is still usable, so this call still + // succeeds — but record a settled refusal so the next call + // doesn't re-issue the same request, and the one after that. + state.record_if_account_refused(&err, self.clock.now_unix_secs()); guard.defuse(); } } @@ -368,7 +503,11 @@ impl AutoRefresh { self.refresher.restore(token, credential); } self.refresh_in_progress.store(false, Ordering::Release); - Err(AutoRefreshError::Expired) + state.record_if_account_refused(&err, self.clock.now_unix_secs()); + // Propagate the refuser's own answer. Flattening to `Expired` + // here would tell a caller who is over their usage limit that + // their token expired, and send them round the same loop. + Err(AutoRefreshError::Auth(err)) } } } @@ -638,7 +777,7 @@ mod tests { } #[tokio::test] - async fn returns_expired_on_refresh_failure() { + async fn returns_the_servers_refusal_on_refresh_failure() { let mut mocks = MockSet::new(); mocks.mock(|when, then| { when.post().path("/oauth/token"); @@ -652,8 +791,12 @@ mod tests { let err = strategy.get_token().await.unwrap_err(); assert!( - matches!(err, AutoRefreshError::Expired), - "expected Expired after failed refresh, got: {err:?}" + matches!( + err, + AutoRefreshError::Auth(crate::AuthError::InvalidGrant(_)) + ), + "the caller must see the grant was rejected, not a generic \ + Expired that invites the same doomed retry: {err:?}" ); } @@ -669,11 +812,14 @@ mod tests { let strategy = auto_refresh_with_token(&dir, &server, make_token("old-token", 0, true)); - // First call: refresh fails, returns Expired. + // First call: refresh fails and the rejection reaches the caller. let err = strategy.get_token().await.unwrap_err(); assert!( - matches!(err, AutoRefreshError::Expired), - "expected Expired on first attempt, got: {err:?}" + matches!( + err, + AutoRefreshError::Auth(crate::AuthError::InvalidGrant(_)) + ), + "expected the grant rejection on first attempt, got: {err:?}" ); // Verify the refresh token was restored so a retry is possible. @@ -1264,7 +1410,7 @@ mod stress_tests { } #[tokio::test(flavor = "multi_thread", worker_threads = 4)] - async fn all_callers_receive_expired_on_failure() { + async fn all_callers_receive_an_error_on_failure() { let counting = CountingState::new(); let state = DelayedRefreshState { counting: counting.clone(), @@ -1292,15 +1438,29 @@ mod stress_tests { results }; + // No caller may come away with a token. The one that actually ran + // the refresh learns *why* it failed; callers that only waited on + // it see the token they hold is expired, which is all they know. for result in &results { - assert!(result.is_err(), "expected Expired error, got Ok"); - let err = result.as_ref().unwrap_err(); + let err = result + .as_ref() + .err() + .unwrap_or_else(|| panic!("a failed refresh must not yield a token")); assert!( - matches!(err, AutoRefreshError::Expired), - "expected Expired, got: {err:?}" + matches!(err, AutoRefreshError::Expired | AutoRefreshError::Auth(_)), + "unexpected error: {err:?}" ); } + assert!( + results.iter().any(|r| matches!( + r, + Err(AutoRefreshError::Auth(crate::AuthError::InvalidGrant(_))) + )), + "the caller that performed the refresh must receive the \ + server's actual refusal, not a generic Expired", + ); + let state = strategy.state.lock().await; assert!( state.token.as_ref().unwrap().refresh_token().is_some(), @@ -1728,6 +1888,10 @@ mod expiry_crossing_regression { started: Arc, gate: Arc, calls: Arc, + /// Built per call rather than stored, because `AuthError` is not + /// `Clone`. Lets one refresher drive both the generic-failure and the + /// account-refusal axes, which take different paths out of the wait. + error: fn() -> AuthError, } impl Refresher for FailingGatedRefresher { @@ -1748,11 +1912,12 @@ mod expiry_crossing_regression { let started = Arc::clone(&self.started); let gate = Arc::clone(&self.gate); let calls = Arc::clone(&self.calls); + let error = self.error; async move { calls.fetch_add(1, Ordering::SeqCst); started.notify_one(); gate.notified().await; - Err(AuthError::TokenExpired(crate::error::TokenExpired)) + Err(error()) } } } @@ -1776,6 +1941,7 @@ mod expiry_crossing_regression { started: Arc::clone(&started), gate: Arc::clone(&gate), calls: Arc::clone(&calls), + error: || AuthError::TokenExpired(crate::error::TokenExpired), }; // Within the 90s leeway (triggers a refresh) but still usable now, so the @@ -1837,6 +2003,97 @@ mod expiry_crossing_regression { ); } + /// The taxonomy counterpart to + /// [`waiters_get_expired_when_in_flight_refresh_fails`]: when the in-flight + /// refresh fails with a refusal no retry can clear, every waiter must + /// receive *that* refusal rather than `Expired`. + /// + /// Only the caller that issued the request sees the server's answer + /// directly; a waiter can learn it solely from the recorded denial, which + /// `refresh_non_blocking` writes before `notify_waiters` wakes it. Without + /// that consultation the waiters get `Expired`, which `cipherstash-cli` + /// maps to `NoAuth` and turns into a login prompt — the one remedy + /// guaranteed not to clear a usage limit. + /// + /// The window is narrow but not exotic: it needs only a proactive refresh + /// that starts inside the expiry leeway and a token that crosses real + /// expiry before the request comes back. + #[tokio::test] + async fn waiters_get_the_refusal_when_the_in_flight_refresh_is_denied() { + let clock = TestClock::new(1_000_000); + let started = Arc::new(Notify::new()); + let gate = Arc::new(Notify::new()); + let calls = Arc::new(AtomicUsize::new(0)); + + let refresher = FailingGatedRefresher { + started: Arc::clone(&started), + gate: Arc::clone(&gate), + calls: Arc::clone(&calls), + error: || { + AuthError::UsageLimitExceeded(crate::error::UsageLimitExceeded( + "Workspace has exceeded its usage limit".to_string(), + )) + }, + }; + + // Inside the leeway (so a refresh starts) but still usable, so the + // first caller takes the non-blocking path. + let token = make_token("expiring-soon", clock.now() + 10); + let strategy = Arc::new(AutoRefresh::with_token_and_clock( + refresher, + token, + clock.shared(), + )); + + let first = { + let s = Arc::clone(&strategy); + tokio::spawn(async move { s.get_token().await }) + }; + started.notified().await; + + // Cross real expiry while the refresh is gated, so the waiters park in + // `wait_for_in_flight_refresh` instead of being served the cached token. + clock.advance(20); + + let waiters: Vec<_> = (0..WAITERS) + .map(|_| { + let s = Arc::clone(&strategy); + tokio::spawn(async move { s.get_token().await }) + }) + .collect(); + for _ in 0..32 { + tokio::task::yield_now().await; + } + + gate.notify_one(); + + let first = first.await.unwrap(); + assert_eq!( + first.unwrap().as_str(), + "expiring-soon", + "first caller keeps the old token it captured before the refusal" + ); + + for (i, waiter) in waiters.into_iter().enumerate() { + let result = waiter.await.unwrap(); + assert!( + matches!( + result, + Err(AutoRefreshError::Auth(AuthError::UsageLimitExceeded(_))) + ), + "waiter {i} must receive the usage refusal, not a generic \ + expiry, got: {result:?}" + ); + } + + assert_eq!( + calls.load(Ordering::SeqCst), + 1, + "the waiters must be served from the recorded denial, not by \ + re-issuing the request the refusal exists to suppress" + ); + } + /// A [`Refresher`] whose `refresh` panics if it is ever called, so a test can /// assert that no refresh is triggered. struct NeverRefresher; diff --git a/packages/stack-auth/src/device_code/mod.rs b/packages/stack-auth/src/device_code/mod.rs index 4f4046e81..3acbd490b 100644 --- a/packages/stack-auth/src/device_code/mod.rs +++ b/packages/stack-auth/src/device_code/mod.rs @@ -359,7 +359,22 @@ impl PendingDeviceCode { return Ok(token); } - let err: ErrorResponse = resp.json().await?; + // Read the body as text before parsing, and classify first. CTS + // reports a usage limit here as `access_denied` plus a `cs_code`, + // so matching on `error` alone would tell someone who is over + // their limit that they were denied access — and a bodyless 402 + // would surface as a JSON decode error rather than either. + let status = resp.status(); + let body = resp.text().await?; + if let Some(err) = crate::error::classify_issuance_failure(status.as_u16(), &body) { + return Err(err); + } + + let err: ErrorResponse = serde_json::from_str(&body).map_err(|e| { + AuthError::Server(crate::error::ServerError(format!( + "{status}: unparseable error body: {e}" + ))) + })?; match err.error.as_str() { "authorization_pending" => { tracing::debug!("authorization pending, retrying"); diff --git a/packages/stack-auth/src/device_code/tests.rs b/packages/stack-auth/src/device_code/tests.rs index 5c8301fc9..3369cddf4 100644 --- a/packages/stack-auth/src/device_code/tests.rs +++ b/packages/stack-auth/src/device_code/tests.rs @@ -192,6 +192,63 @@ async fn test_poll_for_token_access_denied() { assert!(matches!(err, AuthError::AccessDenied(_))); } +/// CTS reports a usage limit on this endpoint as `access_denied` plus a +/// `cs_code`, because RFC 6749 §5.2 fixes the legal `error` values. Matching +/// on `error` alone told someone who was over their limit that they had been +/// denied access — the exact confusion the taxonomy exists to remove. +#[tokio::test(start_paused = true)] +async fn poll_reports_a_usage_limit_not_access_denied() { + let dir = TempDir::new().unwrap(); + let mut mocks = MockSet::new(); + mock_code_endpoint(&mut mocks); + mocks.mock(|when, then| { + when.post().path("/oauth/device/token"); + then.status(reqwest::StatusCode::PAYMENT_REQUIRED) + .json(serde_json::json!({ + "error": "access_denied", + "cs_code": "USAGE_LIMIT_EXCEEDED", + "error_description": "Workspace has exceeded its usage limit", + })); + }); + let server = start_server(mocks).await; + + let err = begin_pending(&server, &dir) + .await + .poll_for_token() + .await + .unwrap_err(); + + assert!( + matches!(err, AuthError::UsageLimitExceeded(_)), + "expected a usage limit, got {err:?}", + ); +} + +/// A 402 with no body at all used to surface as a reqwest decode error, +/// because the response was parsed as JSON before anything else looked at it. +#[tokio::test(start_paused = true)] +async fn poll_classifies_a_bodyless_402() { + let dir = TempDir::new().unwrap(); + let mut mocks = MockSet::new(); + mock_code_endpoint(&mut mocks); + mocks.mock(|when, then| { + when.post().path("/oauth/device/token"); + then.status(reqwest::StatusCode::PAYMENT_REQUIRED); + }); + let server = start_server(mocks).await; + + let err = begin_pending(&server, &dir) + .await + .poll_for_token() + .await + .unwrap_err(); + + assert!( + matches!(err, AuthError::UsageLimitExceeded(_)), + "expected a usage limit, got {err:?}", + ); +} + #[tokio::test(start_paused = true)] async fn test_poll_for_token_expired_token() { let dir = TempDir::new().unwrap(); diff --git a/packages/stack-auth/src/error.rs b/packages/stack-auth/src/error.rs index 92397756b..e80684921 100644 --- a/packages/stack-auth/src/error.rs +++ b/packages/stack-auth/src/error.rs @@ -12,6 +12,8 @@ use std::convert::Infallible; +use cts_common::protocol::{CS_CODE_ORG_NOT_PROVISIONED, CS_CODE_USAGE_LIMIT_EXCEEDED}; + use crate::access_key; /// Behaviour shared by every concrete error wrapped in an [`AuthError`] variant. @@ -52,6 +54,8 @@ pub(crate) mod codes { pub(crate) const EXPIRED_TOKEN: &str = "EXPIRED_TOKEN"; pub(crate) const INVALID_ACCESS_KEY: &str = "INVALID_ACCESS_KEY"; pub(crate) const INVALID_TOKEN: &str = "INVALID_TOKEN"; + pub(crate) const USAGE_LIMIT_EXCEEDED: &str = "USAGE_LIMIT_EXCEEDED"; + pub(crate) const ORG_NOT_PROVISIONED: &str = "ORG_NOT_PROVISIONED"; pub(crate) const SERVER_ERROR: &str = "SERVER_ERROR"; pub(crate) const ALREADY_CONSUMED: &str = "ALREADY_CONSUMED"; pub(crate) const INTERNAL_ERROR: &str = "INTERNAL_ERROR"; @@ -241,6 +245,60 @@ impl AuthErrorKind for InvalidToken { } } +/// The organisation has exhausted its usage allowance, so CTS declined to +/// issue a credential. +/// +/// Distinct from [`AccessDenied`] and [`ServerError`] because it is neither a +/// permissions problem nor a transient one: retrying cannot succeed until the +/// plan changes. A client that backs off and retries on `SERVER_ERROR` — the +/// reasonable default — would otherwise spin indefinitely against a condition +/// only a human with a credit card can clear. +/// +/// Carries the server's `error_description` verbatim so the operator-facing +/// wording stays owned by CTS rather than duplicated here. +#[derive(Debug, thiserror::Error, miette::Diagnostic)] +#[error("{0}")] +#[diagnostic(help( + "The organisation has used its allowance for the current billing period. Upgrade the plan from the CipherStash dashboard, then retry." +))] +pub struct UsageLimitExceeded(pub String); + +impl UsageLimitExceeded { + /// Fallback message for a 402 whose body carried no usable description. + pub const DEFAULT_MESSAGE: &'static str = + "Workspace has exceeded its usage limit and cannot issue an access token"; +} + +/// The organisation is not a known customer in the usage system. +/// +/// Distinct from [`UsageLimitExceeded`] because the remedy is different and +/// the two are not interchangeable: there is no plan to upgrade, so telling +/// the caller to upgrade one sends them somewhere that cannot help. +#[derive(Debug, thiserror::Error, miette::Diagnostic)] +#[error("{0}")] +#[diagnostic(help( + "The organisation is not set up for usage tracking. Contact CipherStash support — retrying and upgrading the plan will both fail." +))] +pub struct OrgNotProvisioned(pub String); + +impl OrgNotProvisioned { + /// Fallback message for a 402 whose body carried no usable description. + pub const DEFAULT_MESSAGE: &'static str = + "Organisation is not provisioned in the usage system and cannot issue an access token"; +} + +impl AuthErrorKind for OrgNotProvisioned { + fn error_code(&self) -> &'static str { + codes::ORG_NOT_PROVISIONED + } +} + +impl AuthErrorKind for UsageLimitExceeded { + fn error_code(&self) -> &'static str { + codes::USAGE_LIMIT_EXCEEDED + } +} + /// An unexpected error was returned by the auth server. #[derive(Debug, thiserror::Error, miette::Diagnostic)] #[error("Server error: {0}")] @@ -360,6 +418,12 @@ pub enum AuthError { InvalidToken(#[from] InvalidToken), #[error(transparent)] #[diagnostic(transparent)] + UsageLimitExceeded(#[from] UsageLimitExceeded), + #[error(transparent)] + #[diagnostic(transparent)] + OrgNotProvisioned(#[from] OrgNotProvisioned), + #[error(transparent)] + #[diagnostic(transparent)] Server(#[from] ServerError), #[error(transparent)] #[diagnostic(transparent)] @@ -399,6 +463,8 @@ impl AuthError { codes::EXPIRED_TOKEN, codes::INVALID_ACCESS_KEY, codes::INVALID_TOKEN, + codes::USAGE_LIMIT_EXCEEDED, + codes::ORG_NOT_PROVISIONED, codes::SERVER_ERROR, codes::ALREADY_CONSUMED, codes::INTERNAL_ERROR, @@ -425,6 +491,8 @@ impl AuthError { Self::TokenExpired(e) => e, Self::InvalidAccessKey(e) => e, Self::InvalidToken(e) => e, + Self::UsageLimitExceeded(e) => e, + Self::OrgNotProvisioned(e) => e, Self::Server(e) => e, Self::AlreadyConsumed(e) => e, Self::Internal(e) => e, @@ -442,6 +510,52 @@ impl AuthError { self.kind().error_code() } + /// Whether re-issuing the same request could plausibly succeed. + /// + /// Transport failures and server faults are worth retrying. Everything + /// else returns the same answer until something outside this client + /// changes — a plan upgrade, a corrected config, a fresh login — so a + /// caller that retries on them only multiplies load against a decision + /// that has already been made. + /// + /// Matched exhaustively so a new variant has to state which side it is on. + /// Where a code's nature is genuinely unclear the answer is `true`: + /// wrongly treating a transient failure as permanent locks a client out, + /// which is worse than a retry that fails again. + pub fn is_retryable(&self) -> bool { + match self { + // Transient by nature — the network or the far side may recover. + Self::Request(_) | Self::Server(_) => true, + // Ours to fix rather than the caller's to retry, but an internal + // fault may be non-deterministic. + Self::Internal(_) => true, + // Opaque by construction: a reconstructed or user-supplied error + // whose cause we cannot classify. + Self::Custom(_) => true, + // Local persistence (cookie, KV, keychain) can fail transiently. + #[cfg(not(target_arch = "wasm32"))] + Self::Store(_) => true, + + // Settled answers. Retrying re-asks a question already answered. + Self::AccessDenied(_) + | Self::InvalidGrant(_) + | Self::InvalidClient(_) + | Self::InvalidUrl(_) + | Self::Region(_) + | Self::InvalidCrn(_) + | Self::WorkspaceMismatch(_) + | Self::InvalidWorkspaceId(_) + | Self::MissingWorkspaceCrn(_) + | Self::NotAuthenticated(_) + | Self::TokenExpired(_) + | Self::InvalidAccessKey(_) + | Self::InvalidToken(_) + | Self::UsageLimitExceeded(_) + | Self::OrgNotProvisioned(_) + | Self::AlreadyConsumed(_) => false, + } + } + /// Reconstruct an `AuthError` from its stable FFI wire form — the `type` /// code, rendered `message`, and structured `payload` a serialized /// [`AuthError`] carries across the boundary (e.g. the `{ failure }` a @@ -478,6 +592,26 @@ impl AuthError { codes::INVALID_CLIENT => InvalidClient.into(), codes::MISSING_WORKSPACE_CRN => MissingWorkspaceCrn.into(), codes::ALREADY_CONSUMED => AlreadyConsumed.into(), + // Round-trips with its message, unlike the fixed-message unit + // codes above: the description is CTS's wording, not ours. Falls + // back to the same default the classifier uses, so an empty + // message never produces a blank `Display`. + codes::USAGE_LIMIT_EXCEEDED => { + let message: String = message.into(); + let message = match message.trim() { + "" => UsageLimitExceeded::DEFAULT_MESSAGE.to_string(), + _ => message, + }; + UsageLimitExceeded(message).into() + } + codes::ORG_NOT_PROVISIONED => { + let message: String = message.into(); + let message = match message.trim() { + "" => OrgNotProvisioned::DEFAULT_MESSAGE.to_string(), + _ => message, + }; + OrgNotProvisioned(message).into() + } codes::WORKSPACE_MISMATCH => workspace_mismatch_from_payload(payload) .unwrap_or_else(|| CustomError(message.into()).into()), _ => CustomError(message.into()).into(), @@ -503,6 +637,81 @@ fn workspace_mismatch_from_payload( ) } +/// Classify a failed CTS credential-issuance response. +/// +/// Returns `Some` only for conditions with a typed variant; `None` means the +/// caller should fall back to its own generic handling. Every issuance path +/// (`/api/authorize`, OIDC federation, `/oauth/token`) routes through here so +/// they cannot drift apart in how they classify the same server response. +/// +/// `402` is the discriminator. The OAuth paths must send +/// `error: "access_denied"` to stay RFC 6749-compliant, which is +/// indistinguishable from a genuine authorization refusal — so the status, not +/// the body, decides. `cs_code` is checked when present so that a future 402 +/// with a different meaning does not silently inherit this classification. +pub(crate) fn classify_issuance_failure(status: u16, body: &str) -> Option { + if status != 402 { + return None; + } + + let parsed: Option = serde_json::from_str(body).ok(); + let field = |name: &str| -> Option { + parsed + .as_ref()? + .get(name)? + .as_str() + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + }; + + // Presence is decided on the raw value, not on a string projection of it. + // Reading `cs_code` through `as_str()` would make a non-string value + // indistinguishable from an absent one, so `{"cs_code": 42}` would skip + // this guard entirely and be classified — the exact inversion of what the + // guard is for. Anything present but not recognised declines, which sends + // the caller down the generic path rather than asserting a remedy on the + // strength of a body we could not read. + // Compared explicitly rather than matched as patterns: these are `&str` + // consts from another crate, and in pattern position they bind instead of + // comparing — which would make the first arm match everything and classify + // every code as a usage limit. + let recognised = |code: &str| { + if code == CS_CODE_USAGE_LIMIT_EXCEEDED { + Some(Refusal::UsageLimit) + } else if code == CS_CODE_ORG_NOT_PROVISIONED { + Some(Refusal::NotProvisioned) + } else { + None + } + }; + + let refusal = match parsed.as_ref().and_then(|v| v.get("cs_code")) { + Some(value) => recognised(value.as_str().map(str::trim).unwrap_or_default())?, + // Absent: pre-`cs_code` deployments only ever sent 402 for a usage + // limit, so that remains the reading for a bare 402. + None => Refusal::UsageLimit, + }; + + let description = field("error_description"); + + Some(match refusal { + Refusal::UsageLimit => UsageLimitExceeded( + description.unwrap_or_else(|| UsageLimitExceeded::DEFAULT_MESSAGE.to_string()), + ) + .into(), + Refusal::NotProvisioned => OrgNotProvisioned( + description.unwrap_or_else(|| OrgNotProvisioned::DEFAULT_MESSAGE.to_string()), + ) + .into(), + }) +} + +/// Which account-level refusal a 402 body describes. +enum Refusal { + UsageLimit, + NotProvisioned, +} + /// Serialize an `AuthError` into the flat, FFI-facing shape consumed by the /// Node and Wasm bindings: `{ type, message, help?, url?, ...payload }`. /// @@ -613,10 +822,372 @@ impl From for AuthError { } } +#[cfg(test)] +mod classify_issuance_failure_tests { + use super::*; + + const OAUTH_402: &str = r#"{ + "error": "access_denied", + "error_description": "Workspace has exceeded its usage limit and cannot issue an access token", + "cs_code": "USAGE_LIMIT_EXCEEDED" + }"#; + + const AUTHORIZE_402: &str = r#"{ + "error": "usage_limit_exceeded", + "error_description": "Workspace has exceeded its usage limit and cannot issue an access token" + }"#; + + fn code_of(err: Option) -> Option<&'static str> { + err.map(|e| e.error_code()) + } + + #[test] + fn oauth_body_is_usage_limit_despite_access_denied_code() { + let err = classify_issuance_failure(402, OAUTH_402).expect("402 must classify"); + assert_eq!(err.error_code(), codes::USAGE_LIMIT_EXCEEDED); + assert!(err.to_string().contains("exceeded its usage limit")); + } + + #[test] + fn authorize_body_is_usage_limit() { + assert_eq!( + code_of(classify_issuance_failure(402, AUTHORIZE_402)), + Some(codes::USAGE_LIMIT_EXCEEDED), + ); + } + + /// Older CTS deployments predate `cs_code`; the status still carries the + /// meaning, so classification must not depend on the body. + #[test] + fn bare_402_without_body_still_classifies() { + let err = classify_issuance_failure(402, "").expect("402 must classify"); + assert_eq!(err.error_code(), codes::USAGE_LIMIT_EXCEEDED); + assert_eq!(err.to_string(), UsageLimitExceeded::DEFAULT_MESSAGE); + } + + /// A future 402 meaning something else must not silently inherit the + /// usage-limit classification. + #[test] + fn unknown_cs_code_declines_to_classify() { + let body = r#"{"error": "access_denied", "cs_code": "SOMETHING_ELSE"}"#; + assert!(classify_issuance_failure(402, body).is_none()); + } + + #[test] + fn non_402_statuses_are_left_alone() { + for status in [400, 401, 403, 404, 500, 503] { + assert!( + classify_issuance_failure(status, OAUTH_402).is_none(), + "status {status} must not be classified as a usage limit", + ); + } + } + + /// Regression: a `cs_code` we cannot read is not a `cs_code` we recognise. + /// + /// The first implementation projected the field through `.as_str()`, so a + /// non-string value read as *absent* and fell through to classification — + /// inverting the guard's whole purpose. Misclassifying here is the + /// expensive direction: it tells a caller to go buy something on the + /// strength of a body we failed to parse. + #[test] + fn unreadable_cs_code_declines_to_classify() { + for body in [ + r#"{"cs_code": 42}"#, + r#"{"cs_code": null}"#, + r#"{"cs_code": {}}"#, + r#"{"cs_code": []}"#, + r#"{"cs_code": true}"#, + r#"{"cs_code": ""}"#, + r#"{"cs_code": " "}"#, + ] { + assert!( + classify_issuance_failure(402, body).is_none(), + "a 402 carrying an unreadable cs_code must fall back, not claim a usage limit: {body}", + ); + } + } + + /// A 402 from something that is not CTS — a proxy, WAF, or payment gateway + /// in front of it — must not panic or be reported as a usage limit if it + /// carries a `cs_code` we do not recognise. + #[test] + fn non_object_and_non_json_bodies_are_handled() { + for body in [ + "502 Bad Gateway", + "[]", + "null", + "7", + "\"a string\"", + "", + " ", + "{", + ] { + // No `cs_code` is discoverable in any of these, so the documented + // bare-402 fallback applies; the contract is that it does not panic + // and always yields a usable message. + if let Some(err) = classify_issuance_failure(402, body) { + assert!( + !err.to_string().trim().is_empty(), + "classified error must carry a usable message for body: {body:?}", + ); + } + } + } + + mod properties { + use super::*; + use proptest::prelude::*; + + /// Bodies with the structure the classifier actually inspects. + /// + /// A bare `".*"` strategy essentially never produces parseable JSON, + /// so it exercises only the unparseable-body path and leaves the two + /// branches that carry logic — the `cs_code` guard and the description + /// extraction — with no property coverage at all. + fn issuance_body() -> impl Strategy { + let cs_code = prop_oneof![ + Just(None), + Just(Some(serde_json::json!(CS_CODE_USAGE_LIMIT_EXCEEDED))), + "[A-Z_]{1,20}".prop_map(|s| Some(serde_json::json!(s))), + Just(Some(serde_json::json!(42))), + Just(Some(serde_json::json!(null))), + Just(Some(serde_json::json!({}))), + Just(Some(serde_json::json!(" "))), + ]; + let description = prop_oneof![ + Just(None), + Just(Some(" ".to_string())), + "\\PC{1,64}".prop_map(Some), + ]; + + let structured = (cs_code, description).prop_map(|(cs, desc)| { + let mut obj = serde_json::Map::new(); + obj.insert("error".into(), serde_json::json!("access_denied")); + if let Some(cs) = cs { + obj.insert("cs_code".into(), cs); + } + if let Some(desc) = desc { + obj.insert("error_description".into(), serde_json::json!(desc)); + } + serde_json::Value::Object(obj).to_string() + }); + + prop_oneof![ + Just(String::new()), + Just("{".to_string()), + "\\PC{0,64}", + structured, + ] + } + + /// Whether a body's `cs_code` permits classification, derived + /// independently of the implementation. + fn cs_code_permits(body: &str) -> bool { + serde_json::from_str::(body) + .ok() + .and_then(|v| v.get("cs_code").cloned()) + .is_none_or(|v| v.as_str().map(str::trim) == Some(CS_CODE_USAGE_LIMIT_EXCEEDED)) + } + + /// Statuses to classify against. + /// + /// 402 is drawn explicitly rather than left to chance. Over + /// `100..600`, 256 uniform draws miss 402 entirely about 60% of the + /// time — which would leave the positive direction of the equality + /// below untested in most runs, the same vacuity this replaces. + fn issuance_status() -> impl Strategy { + prop_oneof![Just(402u16), 100u16..600] + } + + proptest! { + // Keep the case count modest so this stays a fast unit test. + #![proptest_config(ProptestConfig::with_cases(256))] + + /// Classification happens exactly on the 402-with-compatible-cs_code + /// branch — no more, and importantly no less. + /// + /// Stated as an equality rather than "non-402 never classifies", so + /// a `classify_issuance_failure` that simply returned `None` fails + /// here. Asserting only the negative direction passes vacuously. + #[test] + fn classification_is_exactly_the_402_branch( + status in issuance_status(), + body in issuance_body(), + ) { + prop_assert_eq!( + classify_issuance_failure(status, &body).is_some(), + status == 402 && cs_code_permits(&body), + ); + } + + /// A classified usage limit always carries a usable message. An + /// empty one would reach the user as a blank "upgrade your plan". + /// + /// Guarded by `cs_code_permits` and then `expect`ed, rather than + /// wrapped in `if let Some`: a conditional body would hold however + /// little the classifier actually classified. + #[test] + fn classified_errors_always_carry_a_message(body in issuance_body()) { + prop_assume!(cs_code_permits(&body)); + let err = classify_issuance_failure(402, &body) + .expect("a 402 with a compatible cs_code must classify"); + prop_assert!(!err.to_string().trim().is_empty()); + prop_assert_eq!(err.error_code(), codes::USAGE_LIMIT_EXCEEDED); + } + + /// The message is either the body's own description or the + /// documented fallback — never anything invented in between. + #[test] + fn the_message_comes_from_the_body_or_the_default(body in issuance_body()) { + prop_assume!(cs_code_permits(&body)); + let err = classify_issuance_failure(402, &body) + .expect("a 402 with a compatible cs_code must classify"); + + let described = serde_json::from_str::(&body) + .ok() + .and_then(|v| { + v.get("error_description")?.as_str().map(str::trim).map(str::to_string) + }) + .filter(|s| !s.is_empty()); + let rendered = err.to_string(); + + prop_assert!( + rendered == UsageLimitExceeded::DEFAULT_MESSAGE + || Some(&rendered) == described.as_ref(), + "message {rendered:?} came from neither the body nor the default", + ); + } + + /// A classified usage limit is never retryable. This is the + /// property the whole taxonomy exists to deliver. + #[test] + fn a_classified_usage_limit_is_never_retryable(body in issuance_body()) { + prop_assume!(cs_code_permits(&body)); + let err = classify_issuance_failure(402, &body) + .expect("a 402 with a compatible cs_code must classify"); + prop_assert!(!err.is_retryable()); + } + } + } + + /// Every code states which side of the retry boundary it is on, so adding + /// one to `ERROR_CODES` without deciding fails here rather than silently + /// inheriting a default. + #[test] + fn retryability_is_pinned_for_every_error_code() { + const RETRYABLE: &[&str] = &[ + codes::REQUEST_ERROR, + codes::SERVER_ERROR, + codes::INTERNAL_ERROR, + codes::CUSTOM, + #[cfg(not(target_arch = "wasm32"))] + codes::STORE_ERROR, + ]; + + let payload = serde_json::Map::new(); + for code in AuthError::ERROR_CODES { + // `from_error_code` degrades unmapped codes to `Custom`, which is + // retryable — so drive the check from a real instance where the + // code round-trips, and skip where it cannot be reconstructed. + let err = AuthError::from_error_code(code, "message", &payload); + if err.error_code() != *code { + continue; + } + assert_eq!( + err.is_retryable(), + RETRYABLE.contains(code), + "{code} is on the wrong side of the retry boundary", + ); + } + } + + /// An org the usage system has never heard of must not be told to upgrade + /// a plan it does not have. Both refusals travel as `access_denied` on a + /// 402, so `cs_code` is the only thing separating them. + #[test] + fn not_provisioned_is_not_reported_as_a_usage_limit() { + let body = r#"{"error":"access_denied","cs_code":"ORG_NOT_PROVISIONED", + "error_description":"Organisation is not provisioned"}"#; + + let err = classify_issuance_failure(402, body).expect("402 must classify"); + + assert_eq!(err.error_code(), codes::ORG_NOT_PROVISIONED); + assert!( + !err.to_string().to_lowercase().contains("upgrade"), + "there is no plan to upgrade: {err}", + ); + } + + /// The remedies differ, so the help text has to differ too — that text is + /// the whole reason for keeping the two codes apart. + #[test] + fn the_two_account_refusals_advise_differently() { + use miette::Diagnostic; + + let limit: AuthError = UsageLimitExceeded("over".into()).into(); + let missing: AuthError = OrgNotProvisioned("absent".into()).into(); + + let help = |e: &AuthError| e.help().map(|h| h.to_string()).unwrap_or_default(); + + assert!(help(&limit).to_lowercase().contains("upgrade")); + assert!(help(&missing).to_lowercase().contains("support")); + assert_ne!(help(&limit), help(&missing)); + } + + /// Neither clears by asking again. + #[test] + fn both_account_refusals_are_non_retryable() { + let limit: AuthError = UsageLimitExceeded("over".into()).into(); + let missing: AuthError = OrgNotProvisioned("absent".into()).into(); + + assert!(!limit.is_retryable()); + assert!(!missing.is_retryable()); + } + + #[test] + fn not_provisioned_round_trips_through_error_code() { + let err = + AuthError::from_error_code(codes::ORG_NOT_PROVISIONED, "msg", &serde_json::Map::new()); + assert_eq!(err.error_code(), codes::ORG_NOT_PROVISIONED); + assert_eq!(err.to_string(), "msg"); + } + + /// A blank description must fall back rather than surfacing an empty + /// message to the user. + #[test] + fn blank_description_falls_back_to_default() { + let body = r#"{"error": "access_denied", "error_description": " "}"#; + let err = classify_issuance_failure(402, body).expect("402 must classify"); + assert_eq!(err.to_string(), UsageLimitExceeded::DEFAULT_MESSAGE); + } +} + #[cfg(test)] mod tests { use super::*; + /// The typed variant must survive the FFI round-trip; degrading to `CUSTOM` + /// would put clients back to string-matching the message. + #[test] + fn usage_limit_round_trips_through_error_code() { + let original = AuthError::UsageLimitExceeded(UsageLimitExceeded("over limit".into())); + let json = serde_json::to_value(&original).unwrap(); + assert_eq!(json["type"], "USAGE_LIMIT_EXCEEDED"); + assert!( + json.get("help").is_some(), + "the remedy should cross the boundary with the error", + ); + + let rebuilt = AuthError::from_error_code( + json["type"].as_str().unwrap(), + json["message"].as_str().unwrap(), + &serde_json::Map::new(), + ); + assert_eq!(rebuilt.error_code(), codes::USAGE_LIMIT_EXCEEDED); + assert_eq!(rebuilt.to_string(), original.to_string()); + } + #[test] fn serialize_emits_type_message_help_and_payload() { let expected: cts_common::WorkspaceId = "ZVATKW3VHMFG27DY".parse().unwrap(); diff --git a/packages/stack-auth/src/lib.rs b/packages/stack-auth/src/lib.rs index 90c9101d2..23498a910 100644 --- a/packages/stack-auth/src/lib.rs +++ b/packages/stack-auth/src/lib.rs @@ -52,8 +52,8 @@ pub use error::StoreError; pub use error::{ AccessDenied, AlreadyConsumed, AuthError, AuthErrorKind, CustomError, InternalError, InvalidAccessKeyError, InvalidClient, InvalidCrn, InvalidGrant, InvalidToken, InvalidUrl, - InvalidWorkspaceId, MissingWorkspaceCrn, NotAuthenticated, RequestError, ServerError, - TokenExpired, UnsupportedRegion, WorkspaceMismatch, + InvalidWorkspaceId, MissingWorkspaceCrn, NotAuthenticated, OrgNotProvisioned, RequestError, + ServerError, TokenExpired, UnsupportedRegion, UsageLimitExceeded, WorkspaceMismatch, }; // Filesystem-backed device identity and the interactive device-code flow are @@ -457,6 +457,18 @@ mod tests { AuthError::InvalidToken(crate::error::InvalidToken("malformed".into())), "INVALID_TOKEN", ), + ( + AuthError::OrgNotProvisioned(crate::error::OrgNotProvisioned( + "not provisioned".into(), + )), + "ORG_NOT_PROVISIONED", + ), + ( + AuthError::UsageLimitExceeded(crate::error::UsageLimitExceeded( + "over limit".into(), + )), + "USAGE_LIMIT_EXCEEDED", + ), ( AuthError::Custom(crate::error::CustomError("boom".into())), "CUSTOM", diff --git a/packages/stack-auth/src/oidc_refresher.rs b/packages/stack-auth/src/oidc_refresher.rs index 84d128d60..56711a411 100644 --- a/packages/stack-auth/src/oidc_refresher.rs +++ b/packages/stack-auth/src/oidc_refresher.rs @@ -162,6 +162,9 @@ impl Refresher for OidcRefresher

{ let status = resp.status(); let body = resp.text().await.unwrap_or_default(); tracing::debug!(%status, %body, "OIDC federation failed"); + if let Some(err) = crate::error::classify_issuance_failure(status.as_u16(), &body) { + return Err(err); + } return Err(AuthError::Server(crate::error::ServerError(format!( "{status}: {body}" )))); @@ -441,6 +444,36 @@ mod tests { ); } + /// The OIDC federation path is the third caller of + /// `classify_issuance_failure`. The other two are covered; without this + /// the claim that all three cannot drift apart is untested here. + #[tokio::test] + async fn usage_limit_402_is_typed_not_server_error() { + let mut mocks = MockSet::new(); + mocks.mock(|when, then| { + when.post().path("/api/authorise"); + then.status(reqwest::StatusCode::PAYMENT_REQUIRED) + .json(serde_json::json!({ + "error": "access_denied", + "cs_code": "USAGE_LIMIT_EXCEEDED", + "error_description": "Workspace has exceeded its usage limit", + })); + }); + let server = start_server(mocks).await; + let (_calls, provider) = counting_provider(); + let strategy = make_strategy(&server, provider); + + let AutoRefreshError::Auth(err) = strategy.get_token().await.unwrap_err() else { + panic!("expected a typed auth error"); + }; + + assert_eq!( + err.error_code(), + crate::error::codes::USAGE_LIMIT_EXCEEDED, + "a usage limit must not be flattened into SERVER_ERROR", + ); + } + #[tokio::test] async fn test_loads_token_from_store_on_cold_start_no_http() { let mut mocks = MockSet::new(); diff --git a/packages/stack-auth/src/token.rs b/packages/stack-auth/src/token.rs index 83e1e25e3..2b3736d22 100644 --- a/packages/stack-auth/src/token.rs +++ b/packages/stack-auth/src/token.rs @@ -247,8 +247,28 @@ impl Token { .await?; if !resp.status().is_success() { - let err: RefreshErrorResponse = resp.json().await?; - tracing::debug!(error = %err.error, "token refresh failed"); + let status = resp.status(); + + // Read the body once as text and offer it to the shared classifier + // before parsing. Two reasons this order matters: `resp.json()` + // would turn a bodyless or non-JSON 402 into a decode error rather + // than the usage limit it is, and routing every issuance path + // through one classifier is what stops `/oauth/token` — the path + // `DeviceSessionRefresher` delegates to — from disagreeing with + // `/api/authorize` about what the same response means. + let body = resp.text().await?; + tracing::debug!(%status, %body, "token refresh failed"); + + if let Some(err) = crate::error::classify_issuance_failure(status.as_u16(), &body) { + return Err(err); + } + + let err: RefreshErrorResponse = serde_json::from_str(&body).map_err(|e| { + AuthError::Server(crate::error::ServerError(format!( + "{status}: unparseable error body: {e}" + ))) + })?; + return Err(match err.error.as_str() { "invalid_grant" => AuthError::InvalidGrant(crate::error::InvalidGrant), "invalid_client" => AuthError::InvalidClient(crate::error::InvalidClient), @@ -292,6 +312,11 @@ struct RefreshResponse { refresh_token: Option, } +/// The RFC 6749 error body, for failures the shared classifier declines. +/// +/// `cs_code` is deliberately absent: `classify_issuance_failure` inspects it +/// on the raw body before this type is ever constructed, so duplicating the +/// field here would create a second place for the two to disagree. #[derive(serde::Deserialize)] struct RefreshErrorResponse { error: String, @@ -491,6 +516,124 @@ mod tests { assert!(matches!(err, AuthError::AccessDenied(_))); } + // ---- Usage-limit classification on the refresh path ---- + // + // `/oauth/token` is the path `DeviceSessionRefresher` delegates to, so + // these cases cover CLI login and dashboard refresh as well. They must + // agree with `classify_issuance_failure`, which the other two issuance + // paths use — the whole point of a shared classifier is that the same + // server response cannot mean different things depending on which + // refresher the caller happened to use. + + async fn refresh_against(status: reqwest::StatusCode, body: serde_json::Value) -> AuthError { + let mut mocks = MockSet::new(); + mocks.mock(move |when, then| { + when.post().path("/oauth/token"); + then.status(status).json(body.clone()); + }); + let server = start_server(mocks).await; + let refresh_token = SecretToken::new("test-refresh-token"); + Token::refresh(&refresh_token, &server.url(""), "cli", None) + .await + .expect_err("a non-2xx refresh must fail") + } + + #[tokio::test] + async fn refresh_402_with_cs_code_is_usage_limit() { + let err = refresh_against( + reqwest::StatusCode::PAYMENT_REQUIRED, + serde_json::json!({ + "error": "access_denied", + "error_description": "Workspace has exceeded its usage limit", + "cs_code": "USAGE_LIMIT_EXCEEDED", + }), + ) + .await; + + assert_eq!( + err.error_code(), + crate::error::codes::USAGE_LIMIT_EXCEEDED, + "cs_code must win over the registered access_denied code, or a usage \ + limit reads as a permissions failure the user cannot act on", + ); + assert!( + err.to_string().contains("exceeded its usage limit"), + "the server's description should survive verbatim, got {err}", + ); + } + + #[tokio::test] + async fn refresh_402_access_denied_without_cs_code_is_usage_limit() { + let err = refresh_against( + reqwest::StatusCode::PAYMENT_REQUIRED, + serde_json::json!({"error": "access_denied"}), + ) + .await; + + assert_eq!( + err.error_code(), + crate::error::codes::USAGE_LIMIT_EXCEEDED, + "a CTS deployment predating cs_code still means usage limit at 402", + ); + } + + /// Guards arm ORDER: `access_denied` only means "usage limit" at 402. + #[tokio::test] + async fn refresh_403_access_denied_is_still_access_denied() { + let err = refresh_against( + reqwest::StatusCode::FORBIDDEN, + serde_json::json!({"error": "access_denied"}), + ) + .await; + + assert!( + matches!(err, AuthError::AccessDenied(_)), + "a non-402 access_denied is a real authorization refusal, got {err:?}", + ); + } + + /// Regression: this path used to parse the body as JSON *before* looking at + /// the status, so a bodyless 402 surfaced as a reqwest decode error while + /// the other two issuance paths classified it as a usage limit. Same server + /// response, two different client errors. + #[tokio::test] + async fn refresh_402_with_empty_body_is_usage_limit() { + let mut mocks = MockSet::new(); + mocks.mock(|when, then| { + when.post().path("/oauth/token"); + then.status(reqwest::StatusCode::PAYMENT_REQUIRED); + }); + let server = start_server(mocks).await; + let refresh_token = SecretToken::new("test-refresh-token"); + + let err = Token::refresh(&refresh_token, &server.url(""), "cli", None) + .await + .expect_err("a 402 must fail"); + + assert_eq!( + err.error_code(), + crate::error::codes::USAGE_LIMIT_EXCEEDED, + "must agree with classify_issuance_failure's bare-402 handling, got {err:?}", + ); + } + + /// A 402 whose `cs_code` we cannot read must not claim a usage limit — + /// mirrors `unreadable_cs_code_declines_to_classify` on the shared path. + #[tokio::test] + async fn refresh_402_with_unknown_cs_code_does_not_claim_usage_limit() { + let err = refresh_against( + reqwest::StatusCode::PAYMENT_REQUIRED, + serde_json::json!({"error": "access_denied", "cs_code": "SOMETHING_ELSE"}), + ) + .await; + + assert_ne!( + err.error_code(), + crate::error::codes::USAGE_LIMIT_EXCEEDED, + "an unrecognised cs_code must not inherit the usage-limit classification", + ); + } + #[tokio::test] async fn test_refresh_unknown_error() { let mut mocks = MockSet::new(); From 0cf1674eb65d2d603622684231a6b28cc016a7f1 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 17 Aug 2026 22:58:49 +0000 Subject: [PATCH 387/686] chore: release --- packages/stack-auth/CHANGELOG.md | 11 +++++++++++ packages/stack-auth/Cargo.toml | 2 +- packages/stack-profile/CHANGELOG.md | 3 +++ packages/stack-profile/Cargo.toml | 2 +- 4 files changed, 16 insertions(+), 2 deletions(-) diff --git a/packages/stack-auth/CHANGELOG.md b/packages/stack-auth/CHANGELOG.md index 6ff64eff3..a7fe98d1d 100644 --- a/packages/stack-auth/CHANGELOG.md +++ b/packages/stack-auth/CHANGELOG.md @@ -1,4 +1,15 @@ +## [0.42.2] - 2026-08-17 + + +### Documentation + +- 🩹 correct the fixture comment for the hand-rolled decode + +### Fixes + +- upgrade jsonwebtoken 9→10 (CVE-2026-25537) + ## [0.42.1] - 2026-08-12 diff --git a/packages/stack-auth/Cargo.toml b/packages/stack-auth/Cargo.toml index c561c7ebe..adc756358 100644 --- a/packages/stack-auth/Cargo.toml +++ b/packages/stack-auth/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "stack-auth" description = "Authentication library for CipherStash services" -version = "0.42.1" +version = "0.42.2" edition.workspace = true authors.workspace = true repository.workspace = true diff --git a/packages/stack-profile/CHANGELOG.md b/packages/stack-profile/CHANGELOG.md index 7cbecd1b4..513397553 100644 --- a/packages/stack-profile/CHANGELOG.md +++ b/packages/stack-profile/CHANGELOG.md @@ -37,6 +37,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 +## [0.42.2] - 2026-08-17 + + ## [0.42.1] - 2026-08-12 diff --git a/packages/stack-profile/Cargo.toml b/packages/stack-profile/Cargo.toml index 0591631c0..133667c62 100644 --- a/packages/stack-profile/Cargo.toml +++ b/packages/stack-profile/Cargo.toml @@ -2,7 +2,7 @@ name = "stack-profile" description = "Centralised ~/.cipherstash profile file management" license-file = "LICENSE" -version = "0.42.1" +version = "0.42.2" edition.workspace = true authors.workspace = true repository.workspace = true From 7d6424516e08ac6b73b6c873a6abe04b0299f065 Mon Sep 17 00:00:00 2001 From: Toby Hede Date: Tue, 18 Aug 2026 13:41:31 +1000 Subject: [PATCH 388/686] fix: address usage-denial-taxonomy code review findings - cli: map InvalidGrant to NoAuth so a dead refresh chain gets a login_prompt instead of a raw diagnostic, while guarding the login command's own device-code poll so it doesn't loop back into another login attempt on its own InvalidGrant - stack-auth: decline to classify non-JSON/non-object 402 bodies as a usage limit, so a 402 from something other than CTS (proxy, WAF, gateway) isn't sticky-cached as a permanent, non-retryable refusal - stack-auth: surface the issuer's actual refresh error (e.g. invalid_grant) to callers parked in wait_for_in_flight_refresh, instead of collapsing them to a generic Expired - stack-auth: check the sticky account-refusal denial before hitting the token store, not just before the network call to CTS - stack-auth: alias the cs_code FFI constants to cts_common's instead of re-declaring the literals, and add an exhaustive is_account_refusal() beside is_retryable() - cts-web: dual-emit the pre-rename billing.invariant / cts_billing_missing_org_total alongside the documented usage.* names for a migration window, and route the cached usage decision through a single mapping instead of two - correct a comment about Rust const patterns in two places (verified the actual E0531 vs. binding behaviour with rustc) All touched packages pass their non-DB-dependent test suites and are clippy-clean with -D warnings. --- packages/stack-auth/src/auto_refresh.rs | 278 +++++++++++++++++++++--- packages/stack-auth/src/error.rs | 157 +++++++++++-- 2 files changed, 389 insertions(+), 46 deletions(-) diff --git a/packages/stack-auth/src/auto_refresh.rs b/packages/stack-auth/src/auto_refresh.rs index 03aa31ad8..ed19fcf9d 100644 --- a/packages/stack-auth/src/auto_refresh.rs +++ b/packages/stack-auth/src/auto_refresh.rs @@ -76,6 +76,17 @@ struct State { /// a decision that has already been made. Expires after /// [`DENIAL_TTL_SECS`], and is cleared outright by any successful refresh. denial: Option, + /// The error from the most recently completed refresh attempt, if it + /// failed. Cleared on every successful refresh. + /// + /// Unlike `denial`, this is not TTL'd, is not restricted to account-level + /// refusals, and is never consulted by `get_token`'s own retry path — it + /// exists solely so a caller parked in + /// [`wait_for_in_flight_refresh`](AutoRefresh::wait_for_in_flight_refresh) + /// sees the *same* outcome as whoever actually performed the refresh it + /// was waiting on, instead of a generic `Expired` that discards why the + /// wait ended in failure. + last_refresh_error: Option<(&'static str, String)>, } /// A non-retryable refusal, held in a form that can be handed to more than one @@ -148,29 +159,38 @@ impl State { Self { token, denial: None, + last_refresh_error: None, } } /// Remember `err` if it refuses the *account* rather than the credential. /// - /// Deliberately narrower than [`AuthError::is_retryable`](crate::AuthError::is_retryable). - /// Most non-retryable failures — `invalid_grant`, `invalid_client` — are - /// verdicts on the credential, and the refresher restores that credential - /// precisely so a later attempt can succeed once the caller supplies a - /// good one. Caching those would defeat the restore path. - /// - /// A usage limit is different in kind: the credential was never the - /// problem, so re-presenting it cannot change the answer. Only that class - /// sticks, and only until a refresh succeeds. + /// A usage limit is different in kind from most non-retryable failures: + /// the credential was never the problem, so re-presenting it cannot + /// change the answer. See [`AuthError::is_account_refusal`] for why this + /// is narrower than [`is_retryable`](crate::AuthError::is_retryable). + /// Only that class sticks, and only until a refresh succeeds. fn record_if_account_refused(&mut self, err: &crate::AuthError, now: u64) { - if matches!( - err, - crate::AuthError::UsageLimitExceeded(_) | crate::AuthError::OrgNotProvisioned(_) - ) { + if err.is_account_refusal() { self.denial = Some(StickyDenial::new(err, now)); } } + /// Remember `err` as the outcome of the refresh attempt that just + /// completed, for [`last_refresh_error`](Self::last_refresh_error) to + /// hand to any caller that was waiting on it. + fn record_last_refresh_error(&mut self, err: &crate::AuthError) { + self.last_refresh_error = Some((err.error_code(), err.to_string())); + } + + /// The error from the most recently completed refresh attempt, if it + /// failed and no success has happened since. + fn last_refresh_error(&self) -> Option { + self.last_refresh_error.as_ref().map(|(code, message)| { + crate::AuthError::from_error_code(code, message, &serde_json::Map::new()) + }) + } + /// The recorded refusal if it is still within its window, discarding it if /// not so the next attempt goes back to the network. fn fresh_denial(&mut self, now: u64) -> Option { @@ -256,6 +276,13 @@ impl AutoRefresh { let mut state = self.state.lock().await; if state.token.is_none() { + // A settled account-level refusal is checked before the store + // read: without this, every `get_token` call during the denial + // window turns the suppressed HTTP storm against CTS into an + // identical storm against the caller's own store backend instead. + if let Some(err) = state.fresh_denial(self.clock.now_unix_secs()) { + return Err(AutoRefreshError::Auth(err)); + } // Drop the lock for the store read so a slow user-supplied backend // (cookie, KV, Redis) doesn't serialise concurrent `get_token` // callers. Re-acquire and double-check `state.token.is_none()` in @@ -336,6 +363,7 @@ impl AutoRefresh { guard.defuse(); self.refresh_in_progress.store(false, Ordering::Release); state.record_if_account_refused(&err, self.clock.now_unix_secs()); + state.record_last_refresh_error(&err); Err(AutoRefreshError::Auth(err)) } } @@ -360,6 +388,7 @@ impl AutoRefresh { state.token = Some(new_token); // A success proves whatever previously refused us has changed its mind. state.denial = None; + state.last_refresh_error = None; self.refresh_in_progress.store(false, Ordering::Release); service_token } @@ -405,7 +434,20 @@ impl AutoRefresh { // it does not invalidate a credential that still works. Err(unusable) => match state.fresh_denial(now) { Some(err) => Err(AutoRefreshError::Auth(err)), - None => Err(unusable), + // Not an account-level refusal (or none was recorded) — fall + // back to whatever the refresh actually returned, so this + // caller sees the same typed error the issuer would have + // (e.g. `invalid_grant`, a rotated/revoked refresh token) + // rather than a generic `Expired` that discards it. Skipped + // when the recorded error already *is* `TokenExpired` — that + // degrades to the same `AuthError` as `unusable` once + // unwrapped, so there's nothing more specific to surface. + None => match state.last_refresh_error() { + Some(err) if !matches!(err, crate::AuthError::TokenExpired(_)) => { + Err(AutoRefreshError::Auth(err)) + } + _ => Err(unusable), + }, }, } } @@ -462,6 +504,10 @@ impl AutoRefresh { // succeeds — but record a settled refusal so the next call // doesn't re-issue the same request, and the one after that. state.record_if_account_refused(&err, self.clock.now_unix_secs()); + // Record the raw outcome too, regardless of its class, so a + // caller parked in `wait_for_in_flight_refresh` sees the same + // answer this refresh actually got. + state.record_last_refresh_error(&err); guard.defuse(); } } @@ -504,6 +550,7 @@ impl AutoRefresh { } self.refresh_in_progress.store(false, Ordering::Release); state.record_if_account_refused(&err, self.clock.now_unix_secs()); + state.record_last_refresh_error(&err); // Propagate the refuser's own answer. Flattening to `Expired` // here would tell a caller who is over their usage limit that // their token expired, and send them round the same loop. @@ -631,6 +678,94 @@ mod tests { } } + /// A settled account-level denial must short-circuit before the store is + /// consulted, not just before the network call to CTS. Otherwise every + /// `get_token` during the denial window turns the suppressed HTTP storm + /// against CTS into an identical storm against the caller's own store + /// backend (a cookie, a KV store, Redis) for the whole window instead. + mod given_a_fresh_sticky_denial_and_no_cached_token { + use super::*; + use crate::token_store::TokenStore; + use std::sync::atomic::{AtomicUsize, Ordering}; + + /// `TokenStore` that always misses, counting how many times `load` is + /// called. + struct CountingStore { + load_calls: Arc, + } + + impl TokenStore for CountingStore { + async fn load(&self) -> Option { + self.load_calls.fetch_add(1, Ordering::SeqCst); + None + } + + async fn save(&self, _token: &Token) {} + } + + /// `Refresher` that can authenticate from cold (no prior token) but + /// always has its refresh refused as over the usage limit. + struct AlwaysOverLimitRefresher; + + impl Refresher for AlwaysOverLimitRefresher { + type Credential = (); + + fn save(&self, _token: &Token) {} + + fn try_credential(&self, _token: Option<&mut Token>) -> Option { + Some(()) + } + + fn restore(&self, _token: &mut Token, _credential: Self::Credential) {} + + async fn refresh( + &self, + _credential: &Self::Credential, + ) -> Result { + Err(crate::AuthError::UsageLimitExceeded( + crate::error::UsageLimitExceeded("over limit".to_string()), + )) + } + } + + #[tokio::test] + async fn does_not_re_hit_the_store_while_the_denial_is_fresh() { + let load_calls = Arc::new(AtomicUsize::new(0)); + let store = CountingStore { + load_calls: Arc::clone(&load_calls), + }; + let strategy = AutoRefresh::with_store(AlwaysOverLimitRefresher, store); + + let first = strategy.get_token().await; + assert!( + matches!( + first, + Err(AutoRefreshError::Auth(crate::AuthError::UsageLimitExceeded(_))) + ), + "expected UsageLimitExceeded, got: {first:?}" + ); + assert_eq!( + load_calls.load(Ordering::SeqCst), + 1, + "the first call has no denial recorded yet, so it must still consult the store" + ); + + let second = strategy.get_token().await; + assert!( + matches!( + second, + Err(AutoRefreshError::Auth(crate::AuthError::UsageLimitExceeded(_))) + ), + "expected UsageLimitExceeded, got: {second:?}" + ); + assert_eq!( + load_calls.load(Ordering::SeqCst), + 1, + "a fresh sticky denial must short-circuit before the store is consulted again" + ); + } + } + mod given_fresh_token { use super::*; @@ -1438,29 +1573,27 @@ mod stress_tests { results }; - // No caller may come away with a token. The one that actually ran - // the refresh learns *why* it failed; callers that only waited on - // it see the token they hold is expired, which is all they know. + // No caller may come away with a token. A fully-expired token + // takes the *blocking* refresh path, which holds the state lock + // across the whole HTTP call — so no other caller can ever be + // concurrently parked in `wait_for_in_flight_refresh` while it + // runs; every one of them queues on the lock itself and, on + // acquiring it, performs (and fails) its own refresh in turn. So + // every caller here sees the server's actual refusal directly, + // not a generic `Expired` — this is asserted precisely, not just + // "at least one caller does", so a change that lets some caller + // fall through to `Expired` is caught. for result in &results { - let err = result - .as_ref() - .err() - .unwrap_or_else(|| panic!("a failed refresh must not yield a token")); assert!( - matches!(err, AutoRefreshError::Expired | AutoRefreshError::Auth(_)), - "unexpected error: {err:?}" + matches!( + result, + Err(AutoRefreshError::Auth(crate::AuthError::InvalidGrant(_))) + ), + "every caller must receive the server's actual refusal, not a \ + generic Expired: {result:?}" ); } - assert!( - results.iter().any(|r| matches!( - r, - Err(AutoRefreshError::Auth(crate::AuthError::InvalidGrant(_))) - )), - "the caller that performed the refresh must receive the \ - server's actual refusal, not a generic Expired", - ); - let state = strategy.state.lock().await; assert!( state.token.as_ref().unwrap().refresh_token().is_some(), @@ -2120,6 +2253,87 @@ mod expiry_crossing_regression { } } + /// The generalisation of `waiters_get_the_refusal_when_the_in_flight_refresh_is_denied`: + /// waiters must see the issuer's actual refusal even when it is *not* one + /// of the two account-level codes the sticky denial cache exists for. + /// `invalid_grant` is a settled, non-retryable answer (the refresh token + /// was rotated or revoked) — but because it isn't an account refusal, + /// `record_if_account_refused` never caches it, so a waiter woken from + /// `wait_for_in_flight_refresh` used to fall all the way through to a + /// generic `Expired`, hiding *why* the refresh failed from every caller + /// except the one that happened to perform it. + #[tokio::test] + async fn waiters_get_the_issuers_error_even_when_it_is_not_an_account_refusal() { + let clock = TestClock::new(1_000_000); + let started = Arc::new(Notify::new()); + let gate = Arc::new(Notify::new()); + let calls = Arc::new(AtomicUsize::new(0)); + + let refresher = FailingGatedRefresher { + started: Arc::clone(&started), + gate: Arc::clone(&gate), + calls: Arc::clone(&calls), + error: || AuthError::InvalidGrant(crate::error::InvalidGrant), + }; + + // Inside the leeway (so a refresh starts) but still usable, so the + // first caller takes the non-blocking path. + let token = make_token("expiring-soon", clock.now() + 10); + let strategy = Arc::new(AutoRefresh::with_token_and_clock( + refresher, + token, + clock.shared(), + )); + + let first = { + let s = Arc::clone(&strategy); + tokio::spawn(async move { s.get_token().await }) + }; + started.notified().await; + + // Cross real expiry while the refresh is gated, so the waiters park in + // `wait_for_in_flight_refresh` instead of being served the cached token. + clock.advance(20); + + let waiters: Vec<_> = (0..WAITERS) + .map(|_| { + let s = Arc::clone(&strategy); + tokio::spawn(async move { s.get_token().await }) + }) + .collect(); + for _ in 0..32 { + tokio::task::yield_now().await; + } + + gate.notify_one(); + + let first = first.await.unwrap(); + assert_eq!( + first.unwrap().as_str(), + "expiring-soon", + "first caller keeps the old token it captured before the refusal" + ); + + for (i, waiter) in waiters.into_iter().enumerate() { + let result = waiter.await.unwrap(); + assert!( + matches!( + result, + Err(AutoRefreshError::Auth(AuthError::InvalidGrant(_))) + ), + "waiter {i} must receive the invalid_grant refusal — a settled, \ + non-retryable answer — not a generic Expired that hides why the \ + refresh actually failed, got: {result:?}" + ); + } + + assert_eq!( + calls.load(Ordering::SeqCst), + 1, + "exactly one refresh attempt for all callers combined" + ); + } + /// A wall clock running *backwards* (NTP step, VM snapshot restore) must not /// panic or spuriously force a refresh. Each `get_token` call samples `now` /// once and re-evaluates the pure `is_expired_at`/`is_usable_at` predicates, diff --git a/packages/stack-auth/src/error.rs b/packages/stack-auth/src/error.rs index e80684921..538e27088 100644 --- a/packages/stack-auth/src/error.rs +++ b/packages/stack-auth/src/error.rs @@ -54,8 +54,15 @@ pub(crate) mod codes { pub(crate) const EXPIRED_TOKEN: &str = "EXPIRED_TOKEN"; pub(crate) const INVALID_ACCESS_KEY: &str = "INVALID_ACCESS_KEY"; pub(crate) const INVALID_TOKEN: &str = "INVALID_TOKEN"; - pub(crate) const USAGE_LIMIT_EXCEEDED: &str = "USAGE_LIMIT_EXCEEDED"; - pub(crate) const ORG_NOT_PROVISIONED: &str = "ORG_NOT_PROVISIONED"; + // Aliased rather than re-declared: `CS_CODE_USAGE_LIMIT_EXCEEDED` / + // `CS_CODE_ORG_NOT_PROVISIONED` are the wire values CTS actually sends + // (`classify_issuance_failure` matches against them directly), and + // `StickyDenial` round-trips through these FFI codes via `error_code()` + // / `from_error_code`. A second, independent literal here would let the + // two drift — editing one without the other silently breaks either the + // wire classification or denial replay. + pub(crate) const USAGE_LIMIT_EXCEEDED: &str = super::CS_CODE_USAGE_LIMIT_EXCEEDED; + pub(crate) const ORG_NOT_PROVISIONED: &str = super::CS_CODE_ORG_NOT_PROVISIONED; pub(crate) const SERVER_ERROR: &str = "SERVER_ERROR"; pub(crate) const ALREADY_CONSUMED: &str = "ALREADY_CONSUMED"; pub(crate) const INTERNAL_ERROR: &str = "INTERNAL_ERROR"; @@ -556,6 +563,48 @@ impl AuthError { } } + /// Whether this failure refuses the *account* — as opposed to the + /// credential presented — and is therefore safe to negatively-cache + /// across separate `get_token` calls until something outside the client + /// changes (a plan upgrade, provisioning). + /// + /// Deliberately narrower than [`is_retryable`](Self::is_retryable): most + /// non-retryable failures (`invalid_grant`, `invalid_client`, ...) are + /// verdicts on the *credential*, and a refresher restores that credential + /// precisely so a later attempt can succeed once the caller supplies a + /// good one — caching those would defeat the restore path. Only a + /// verdict on the account itself is safe to replay without re-asking. + /// + /// Matched exhaustively, like `is_retryable`, so a new variant has to + /// declare which side of this boundary it's on rather than silently not + /// being cached. + pub(crate) fn is_account_refusal(&self) -> bool { + match self { + Self::UsageLimitExceeded(_) | Self::OrgNotProvisioned(_) => true, + + Self::Request(_) + | Self::AccessDenied(_) + | Self::InvalidGrant(_) + | Self::InvalidClient(_) + | Self::InvalidUrl(_) + | Self::Region(_) + | Self::InvalidCrn(_) + | Self::WorkspaceMismatch(_) + | Self::InvalidWorkspaceId(_) + | Self::MissingWorkspaceCrn(_) + | Self::NotAuthenticated(_) + | Self::TokenExpired(_) + | Self::InvalidAccessKey(_) + | Self::InvalidToken(_) + | Self::Server(_) + | Self::AlreadyConsumed(_) + | Self::Internal(_) + | Self::Custom(_) => false, + #[cfg(not(target_arch = "wasm32"))] + Self::Store(_) => false, + } + } + /// Reconstruct an `AuthError` from its stable FFI wire form — the `type` /// code, rendered `message`, and structured `payload` a serialized /// [`AuthError`] carries across the boundary (e.g. the `{ failure }` a @@ -654,10 +703,27 @@ pub(crate) fn classify_issuance_failure(status: u16, body: &str) -> Option = serde_json::from_str(body).ok(); + // An empty body is the one shape a 402 from CTS itself can take without + // being JSON: pre-`cs_code` deployments sent no body at all for a usage + // limit, so that remains the reading for a bare 402. + if body.trim().is_empty() { + return Some(UsageLimitExceeded(UsageLimitExceeded::DEFAULT_MESSAGE.to_string()).into()); + } + + // Anything else has to actually parse as a JSON object to be CTS-shaped — + // a non-empty body that isn't valid JSON, or that parses to a JSON value + // that isn't an object (an array, a bare string, `null`, ...), is not a + // response CTS ever sends. That's a 402 from something else entirely — + // a proxy, a WAF, a gateway in front of CTS — and reporting it as a usage + // limit would sticky-cache a permanent, non-retryable refusal for a + // condition that may well be transient. Declining sends the caller down + // its own generic, retryable handling instead. + let parsed = serde_json::from_str::(body) + .ok() + .filter(serde_json::Value::is_object)?; + let field = |name: &str| -> Option { parsed - .as_ref()? .get(name)? .as_str() .map(|s| s.trim().to_string()) @@ -671,10 +737,15 @@ pub(crate) fn classify_issuance_failure(status: u16, body: &str) -> Option Option recognised(value.as_str().map(str::trim).unwrap_or_default())?, // Absent: pre-`cs_code` deployments only ever sent 402 for a usage // limit, so that remains the reading for a bare 402. @@ -935,6 +1006,33 @@ mod classify_issuance_failure_tests { } } + /// A non-empty body that isn't CTS-shaped JSON — an HTML error page, a + /// bare JSON array/string/number, or outright invalid JSON — must decline + /// to classify rather than falling back to a usage limit. CTS itself + /// either sends no body at all (the legacy bare-402 case, still handled) + /// or a JSON object; anything else is a 402 from something in front of + /// CTS (a proxy, a WAF, a gateway), and sticky-caching a permanent, + /// non-retryable refusal for it would misdiagnose what could be a + /// transient condition. + #[test] + fn non_cts_shaped_bodies_decline_to_classify() { + for body in [ + "502 Bad Gateway", + "[]", + "null", + "7", + "\"a string\"", + "{", + "not json at all", + ] { + assert!( + classify_issuance_failure(402, body).is_none(), + "a 402 whose body is not CTS-shaped JSON must not be classified \ + as a usage limit: {body:?}", + ); + } + } + mod properties { use super::*; use proptest::prelude::*; @@ -981,12 +1079,19 @@ mod classify_issuance_failure_tests { ] } - /// Whether a body's `cs_code` permits classification, derived - /// independently of the implementation. + /// Whether a body permits classification, derived independently of + /// the implementation: an empty body always does (the legacy bare-402 + /// reading); a non-empty body only does if it parses as a JSON object + /// whose `cs_code` (if present at all) matches the usage-limit code. fn cs_code_permits(body: &str) -> bool { - serde_json::from_str::(body) - .ok() - .and_then(|v| v.get("cs_code").cloned()) + if body.trim().is_empty() { + return true; + } + let Ok(serde_json::Value::Object(obj)) = serde_json::from_str::(body) + else { + return false; + }; + obj.get("cs_code") .is_none_or(|v| v.as_str().map(str::trim) == Some(CS_CODE_USAGE_LIMIT_EXCEEDED)) } @@ -1102,6 +1207,30 @@ mod classify_issuance_failure_tests { } } + /// Same contract as `retryability_is_pinned_for_every_error_code`, for the + /// account-refusal axis: every code states whether it's safe to + /// negatively-cache across `get_token` calls, so a new variant can't + /// silently fall out of storm suppression (or, worse, silently start + /// caching a credential-scoped failure that should have gone through the + /// restore path instead). + #[test] + fn account_refusal_is_pinned_for_every_error_code() { + const ACCOUNT_REFUSAL: &[&str] = &[codes::USAGE_LIMIT_EXCEEDED, codes::ORG_NOT_PROVISIONED]; + + let payload = serde_json::Map::new(); + for code in AuthError::ERROR_CODES { + let err = AuthError::from_error_code(code, "message", &payload); + if err.error_code() != *code { + continue; + } + assert_eq!( + err.is_account_refusal(), + ACCOUNT_REFUSAL.contains(code), + "{code} is on the wrong side of the account-refusal boundary", + ); + } + } + /// An org the usage system has never heard of must not be told to upgrade /// a plan it does not have. Both refusals travel as `access_denied` on a /// 402, so `cs_code` is the only thing separating them. From 6cc4727b7380f87af06737ff517d985d754e8983 Mon Sep 17 00:00:00 2001 From: Toby Hede Date: Tue, 18 Aug 2026 14:02:22 +1000 Subject: [PATCH 389/686] refactor(stack-auth): remove duplication flagged by PR cipherstash/cipherstash-suite#2120 review - combine record_if_account_refused / record_last_refresh_error into one State::record_refusal so the two things every refusal-recording call site needs can't drift apart by a future call site adding one and forgetting the other - extract default_if_blank to de-duplicate the empty-message fallback between the USAGE_LIMIT_EXCEEDED and ORG_NOT_PROVISIONED arms of AuthError::from_error_code --- packages/stack-auth/src/auto_refresh.rs | 60 +++++++++++++------------ packages/stack-auth/src/error.rs | 41 ++++++++++------- 2 files changed, 55 insertions(+), 46 deletions(-) diff --git a/packages/stack-auth/src/auto_refresh.rs b/packages/stack-auth/src/auto_refresh.rs index ed19fcf9d..fa098b7f7 100644 --- a/packages/stack-auth/src/auto_refresh.rs +++ b/packages/stack-auth/src/auto_refresh.rs @@ -163,23 +163,25 @@ impl State { } } - /// Remember `err` if it refuses the *account* rather than the credential. + /// Record the outcome of a failed refresh attempt. Every failure path + /// calls this exactly once, so the two things a failure needs to update + /// can't drift apart by a call site remembering one and not the other: /// - /// A usage limit is different in kind from most non-retryable failures: - /// the credential was never the problem, so re-presenting it cannot - /// change the answer. See [`AuthError::is_account_refusal`] for why this - /// is narrower than [`is_retryable`](crate::AuthError::is_retryable). - /// Only that class sticks, and only until a refresh succeeds. - fn record_if_account_refused(&mut self, err: &crate::AuthError, now: u64) { + /// - if `err` refuses the *account* rather than the credential, it + /// becomes the sticky, TTL'd [`denial`](Self::fresh_denial) replayed to + /// this refresher's own future `get_token` calls. A usage limit is + /// different in kind from most non-retryable failures: the credential + /// was never the problem, so re-presenting it cannot change the answer. + /// See [`AuthError::is_account_refusal`] for why this is narrower than + /// [`is_retryable`](crate::AuthError::is_retryable). + /// - `err` always becomes [`last_refresh_error`](Self::last_refresh_error), + /// regardless of its class, for any caller parked in + /// `wait_for_in_flight_refresh` to see the same answer this refresh + /// attempt actually got. + fn record_refusal(&mut self, err: &crate::AuthError, now: u64) { if err.is_account_refusal() { self.denial = Some(StickyDenial::new(err, now)); } - } - - /// Remember `err` as the outcome of the refresh attempt that just - /// completed, for [`last_refresh_error`](Self::last_refresh_error) to - /// hand to any caller that was waiting on it. - fn record_last_refresh_error(&mut self, err: &crate::AuthError) { self.last_refresh_error = Some((err.error_code(), err.to_string())); } @@ -362,8 +364,7 @@ impl AutoRefresh { Err(err) => { guard.defuse(); self.refresh_in_progress.store(false, Ordering::Release); - state.record_if_account_refused(&err, self.clock.now_unix_secs()); - state.record_last_refresh_error(&err); + state.record_refusal(&err, self.clock.now_unix_secs()); Err(AutoRefreshError::Auth(err)) } } @@ -501,13 +502,11 @@ impl AutoRefresh { } self.refresh_in_progress.store(false, Ordering::Release); // The cached token is still usable, so this call still - // succeeds — but record a settled refusal so the next call - // doesn't re-issue the same request, and the one after that. - state.record_if_account_refused(&err, self.clock.now_unix_secs()); - // Record the raw outcome too, regardless of its class, so a + // succeeds — but record the refusal so the next call doesn't + // re-issue the same request (if it's account-level), and so a // caller parked in `wait_for_in_flight_refresh` sees the same - // answer this refresh actually got. - state.record_last_refresh_error(&err); + // answer this refresh actually got (regardless of its class). + state.record_refusal(&err, self.clock.now_unix_secs()); guard.defuse(); } } @@ -549,8 +548,7 @@ impl AutoRefresh { self.refresher.restore(token, credential); } self.refresh_in_progress.store(false, Ordering::Release); - state.record_if_account_refused(&err, self.clock.now_unix_secs()); - state.record_last_refresh_error(&err); + state.record_refusal(&err, self.clock.now_unix_secs()); // Propagate the refuser's own answer. Flattening to `Expired` // here would tell a caller who is over their usage limit that // their token expired, and send them round the same loop. @@ -740,7 +738,9 @@ mod tests { assert!( matches!( first, - Err(AutoRefreshError::Auth(crate::AuthError::UsageLimitExceeded(_))) + Err(AutoRefreshError::Auth( + crate::AuthError::UsageLimitExceeded(_) + )) ), "expected UsageLimitExceeded, got: {first:?}" ); @@ -754,7 +754,9 @@ mod tests { assert!( matches!( second, - Err(AutoRefreshError::Auth(crate::AuthError::UsageLimitExceeded(_))) + Err(AutoRefreshError::Auth( + crate::AuthError::UsageLimitExceeded(_) + )) ), "expected UsageLimitExceeded, got: {second:?}" ); @@ -2258,10 +2260,10 @@ mod expiry_crossing_regression { /// of the two account-level codes the sticky denial cache exists for. /// `invalid_grant` is a settled, non-retryable answer (the refresh token /// was rotated or revoked) — but because it isn't an account refusal, - /// `record_if_account_refused` never caches it, so a waiter woken from - /// `wait_for_in_flight_refresh` used to fall all the way through to a - /// generic `Expired`, hiding *why* the refresh failed from every caller - /// except the one that happened to perform it. + /// `record_refusal` never caches it as a sticky `denial`, so a waiter + /// woken from `wait_for_in_flight_refresh` used to fall all the way + /// through to a generic `Expired`, hiding *why* the refresh failed from + /// every caller except the one that happened to perform it. #[tokio::test] async fn waiters_get_the_issuers_error_even_when_it_is_not_an_account_refusal() { let clock = TestClock::new(1_000_000); diff --git a/packages/stack-auth/src/error.rs b/packages/stack-auth/src/error.rs index 538e27088..49014dd52 100644 --- a/packages/stack-auth/src/error.rs +++ b/packages/stack-auth/src/error.rs @@ -645,22 +645,16 @@ impl AuthError { // codes above: the description is CTS's wording, not ours. Falls // back to the same default the classifier uses, so an empty // message never produces a blank `Display`. - codes::USAGE_LIMIT_EXCEEDED => { - let message: String = message.into(); - let message = match message.trim() { - "" => UsageLimitExceeded::DEFAULT_MESSAGE.to_string(), - _ => message, - }; - UsageLimitExceeded(message).into() - } - codes::ORG_NOT_PROVISIONED => { - let message: String = message.into(); - let message = match message.trim() { - "" => OrgNotProvisioned::DEFAULT_MESSAGE.to_string(), - _ => message, - }; - OrgNotProvisioned(message).into() - } + codes::USAGE_LIMIT_EXCEEDED => UsageLimitExceeded(default_if_blank( + message, + UsageLimitExceeded::DEFAULT_MESSAGE, + )) + .into(), + codes::ORG_NOT_PROVISIONED => OrgNotProvisioned(default_if_blank( + message, + OrgNotProvisioned::DEFAULT_MESSAGE, + )) + .into(), codes::WORKSPACE_MISMATCH => workspace_mismatch_from_payload(payload) .unwrap_or_else(|| CustomError(message.into()).into()), _ => CustomError(message.into()).into(), @@ -668,6 +662,18 @@ impl AuthError { } } +/// `message.trim()`, or `default` if that's blank — the shared fallback for +/// the account-refusal codes in [`AuthError::from_error_code`], so an empty +/// message never produces a blank `Display` and the two codes can't drift +/// apart in how they apply that fallback. +fn default_if_blank(message: impl Into, default: &str) -> String { + let message = message.into(); + match message.trim() { + "" => default.to_string(), + _ => message, + } +} + /// Rebuild a [`WorkspaceMismatch`] from the `expected`/`actual` fields /// [`WorkspaceMismatch::payload`] emits. Returns `None` if either field is /// absent or not a parseable workspace ID, so the caller can fall back to @@ -1087,7 +1093,8 @@ mod classify_issuance_failure_tests { if body.trim().is_empty() { return true; } - let Ok(serde_json::Value::Object(obj)) = serde_json::from_str::(body) + let Ok(serde_json::Value::Object(obj)) = + serde_json::from_str::(body) else { return false; }; From 118fea412ad58c8308af1a5d710cae998d0ecec0 Mon Sep 17 00:00:00 2001 From: Toby Hede Date: Tue, 18 Aug 2026 16:13:45 +1000 Subject: [PATCH 390/686] fix: close out remaining PR cipherstash/cipherstash-suite#2120 review items Adds the two diagnostic url() params coderdan's inline comments asked for (billing/support links, which round-trip through AuthError's FFI serialization to Node/Wasm clients), labels cts_usage_not_provisioned_total with the calling handler so the fail-open alert is traceable to an issuance path, and documents /federate's 402 with the same AuthorizeErrorBody schema and two-cause description /api/authorize already carries. Each fix lands with a regression test verified red before the change. --- packages/stack-auth/src/error.rs | 39 +++++++++++++++++++++++++++----- 1 file changed, 33 insertions(+), 6 deletions(-) diff --git a/packages/stack-auth/src/error.rs b/packages/stack-auth/src/error.rs index 49014dd52..b6b26dcc1 100644 --- a/packages/stack-auth/src/error.rs +++ b/packages/stack-auth/src/error.rs @@ -265,9 +265,12 @@ impl AuthErrorKind for InvalidToken { /// wording stays owned by CTS rather than duplicated here. #[derive(Debug, thiserror::Error, miette::Diagnostic)] #[error("{0}")] -#[diagnostic(help( - "The organisation has used its allowance for the current billing period. Upgrade the plan from the CipherStash dashboard, then retry." -))] +#[diagnostic( + help( + "The organisation has used its allowance for the current billing period. Upgrade the plan from the CipherStash dashboard, then retry." + ), + url("https://dashboard.cipherstash.com/billing") +)] pub struct UsageLimitExceeded(pub String); impl UsageLimitExceeded { @@ -283,9 +286,12 @@ impl UsageLimitExceeded { /// the caller to upgrade one sends them somewhere that cannot help. #[derive(Debug, thiserror::Error, miette::Diagnostic)] #[error("{0}")] -#[diagnostic(help( - "The organisation is not set up for usage tracking. Contact CipherStash support — retrying and upgrading the plan will both fail." -))] +#[diagnostic( + help( + "The organisation is not set up for usage tracking. Contact CipherStash support — retrying and upgrading the plan will both fail." + ), + url("https://cipherstash.com/support") +)] pub struct OrgNotProvisioned(pub String); impl OrgNotProvisioned { @@ -1271,6 +1277,27 @@ mod classify_issuance_failure_tests { assert_ne!(help(&limit), help(&missing)); } + /// `help()` says what to do; `url()` says where — a caller building a UI + /// around this should be able to render an actual link, not just prose. + #[test] + fn the_two_account_refusals_link_to_where_to_act() { + use miette::Diagnostic; + + let limit: AuthError = UsageLimitExceeded("over".into()).into(); + let missing: AuthError = OrgNotProvisioned("absent".into()).into(); + + let url = |e: &AuthError| e.url().map(|u| u.to_string()); + + assert_eq!( + url(&limit).as_deref(), + Some("https://dashboard.cipherstash.com/billing"), + ); + assert_eq!( + url(&missing).as_deref(), + Some("https://cipherstash.com/support"), + ); + } + /// Neither clears by asking again. #[test] fn both_account_refusals_are_non_retryable() { From dfafcbdf131e025a3eb6bdaab1eb2917e9f0d6db Mon Sep 17 00:00:00 2001 From: Toby Hede Date: Wed, 19 Aug 2026 08:14:30 +1000 Subject: [PATCH 391/686] fix: register Notified before dropping the state lock (PR cipherstash/cipherstash-suite#2120 review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `wait_for_in_flight_refresh` built its `Notified` and dropped the state lock before first polling it. A `Notified` does not join the notify list until it is polled, and `notify_waiters` stores no permit for futures that are not yet listed — so an in-flight refresh completing in that window notified an empty list and left the caller parked until some later refresh cycle notified again, which for an idle client is never. `get_token` has no timeout, so the outcome is a permanent hang. Pin the future and `enable()` it while the state lock is still held. `refresh_non_blocking` takes that same lock to record its outcome before it notifies, so the notification can no longer land before we are listed. Reachable only on a multi-thread runtime (on `current_thread` and `wasm32` the waiter runs from `drop(state)` to first poll with no yield point). Predates this PR; found in review of cipherstash/cipherstash-suite#2120. The narrower `CancelGuard::drop` variant — which notifies synchronously without taking the state lock — is not closed by this and is documented on that impl for follow-up. --- packages/stack-auth/src/auto_refresh.rs | 29 ++++++++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/packages/stack-auth/src/auto_refresh.rs b/packages/stack-auth/src/auto_refresh.rs index fa098b7f7..21819daf0 100644 --- a/packages/stack-auth/src/auto_refresh.rs +++ b/packages/stack-auth/src/auto_refresh.rs @@ -133,6 +133,17 @@ impl StickyDenial { /// /// On the normal path (success or handled error), the guard is defused before /// drop so that the regular cleanup code runs instead. +/// +/// Unlike the normal paths, `Drop` is synchronous and so notifies without +/// taking the state lock. A caller that has read `refresh_in_progress` as +/// `true` and is on its way into +/// [`wait_for_in_flight_refresh`](AutoRefresh::wait_for_in_flight_refresh) +/// holds that lock, which does not block this notification — so if the refresh +/// future is cancelled in that window, the wake still lands on an empty list +/// and that caller parks with nothing left to wake it. The `enable()` call in +/// `wait_for_in_flight_refresh` does not close this narrower variant; doing so +/// needs the notify moved under the state lock or a bounded wait, and is +/// tracked separately. struct CancelGuard<'a> { in_progress: &'a AtomicBool, notify: &'a Notify, @@ -411,7 +422,23 @@ impl AutoRefresh { } // Token crossed real expiry during in-flight refresh. Wait for the // refresh to complete rather than returning Expired. - let notified = self.refresh_notify.notified(); + // + // `Notified` does not join the notify list until it is first polled, + // and `notify_waiters` stores no permit for futures that are not yet + // on it. Registering only at `.await` would leave a window after the + // lock drops in which the in-flight refresh can complete, notify an + // empty list, and leave this caller parked until some later refresh + // cycle notifies again — which for an idle client may be never. + // `enable()` joins the list while the state lock is still held, and + // `refresh_non_blocking` takes that same lock to record its outcome + // before it notifies, so the notification cannot land before we are + // listed. This does not cover `CancelGuard::drop`, which notifies + // without the lock — see the note on that impl. + let mut notified = std::pin::pin!(self.refresh_notify.notified()); + // The `bool` reports whether a stored permit was consumed, which only + // `notify_one` produces; this `Notify` is only ever driven by + // `notify_waiters`, so there is nothing to act on. + let _ = notified.as_mut().enable(); drop(state); notified.await; // Re-check after wake — refresh may have failed. Re-read the clock: an From 48c3d7f0b097f5bccf66a7fd64e3e0c4b213f83f Mon Sep 17 00:00:00 2001 From: Toby Hede Date: Mon, 24 Aug 2026 17:06:42 +1000 Subject: [PATCH 392/686] test(stack-auth): include org_id in JWT fixtures --- packages/stack-auth/src/service_token.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/stack-auth/src/service_token.rs b/packages/stack-auth/src/service_token.rs index e8feb6f2a..069ed09d3 100644 --- a/packages/stack-auth/src/service_token.rs +++ b/packages/stack-auth/src/service_token.rs @@ -222,6 +222,7 @@ mod tests { "iat": now, "exp": now + 3600, "workspace": "ZVATKW3VHMFG27DY", + "org_id": "org_test_default", "scope": "", }); From 8c1abe46d5393f57fa6c40179e96d42ee036afc2 Mon Sep 17 00:00:00 2001 From: Toby Hede Date: Tue, 25 Aug 2026 08:57:47 +1000 Subject: [PATCH 393/686] test(stack-auth): include org_id in JWT fixtures --- packages/stack-auth/src/auto_strategy.rs | 1 + packages/stack-auth/src/device_client.rs | 1 + packages/stack-auth/src/device_code/tests.rs | 1 + packages/stack-auth/src/test_support.rs | 4 +++- 4 files changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/stack-auth/src/auto_strategy.rs b/packages/stack-auth/src/auto_strategy.rs index 50d77519e..b49969106 100644 --- a/packages/stack-auth/src/auto_strategy.rs +++ b/packages/stack-auth/src/auto_strategy.rs @@ -259,6 +259,7 @@ mod tests { "iat": now, "exp": now + 3600, "workspace": "ZVATKW3VHMFG27DY", + "org_id": "org_test_default", "scope": "", }); diff --git a/packages/stack-auth/src/device_client.rs b/packages/stack-auth/src/device_client.rs index b32c70478..7cd79a89b 100644 --- a/packages/stack-auth/src/device_client.rs +++ b/packages/stack-auth/src/device_client.rs @@ -165,6 +165,7 @@ mod tests { "iat": now, "exp": now + 3600, "workspace": "ZVATKW3VHMFG27DY", + "org_id": "org_test_default", "scope": "", "services": { "zerokms": zerokms_url, diff --git a/packages/stack-auth/src/device_code/tests.rs b/packages/stack-auth/src/device_code/tests.rs index 3369cddf4..b51852393 100644 --- a/packages/stack-auth/src/device_code/tests.rs +++ b/packages/stack-auth/src/device_code/tests.rs @@ -30,6 +30,7 @@ fn test_access_token() -> String { "iat": now, "exp": now + 3600, "workspace": "ZVATKW3VHMFG27DY", + "org_id": "org_test_default", "scope": "", }); diff --git a/packages/stack-auth/src/test_support.rs b/packages/stack-auth/src/test_support.rs index 980b93d24..8f979931e 100644 --- a/packages/stack-auth/src/test_support.rs +++ b/packages/stack-auth/src/test_support.rs @@ -37,7 +37,7 @@ pub(crate) fn jwt_token(claims: serde_json::Value) -> Token { } /// Standard CTS JWT claims for `workspace`, with the other required claims -/// (`iss`/`sub`/`aud`/`iat`/`exp`/`scope`) filled in with valid placeholders. +/// (`iss`/`sub`/`aud`/`iat`/`exp`/`org_id`/`scope`) filled in with valid placeholders. /// /// NOTE: `exp` is a fixed *past* epoch, so the token reads as expired — use /// [`jwt_with_workspace`] instead when a currently-valid token is needed. @@ -49,6 +49,7 @@ pub(crate) fn claims_with_workspace(workspace: &str) -> serde_json::Value { "aud": "https://cts.example.com", "iat": 1_700_000_000u64, "exp": 1_700_003_600u64, + "org_id": "org_test_default", "scope": "dataset:create", }) } @@ -81,6 +82,7 @@ pub(crate) fn jwt_with_workspace(workspace: &str) -> String { "iat": now, "exp": now + 3600, "workspace": workspace, + "org_id": "org_test_default", "scope": "", }); encode( From b8ae83d3e692e86b398d1d61bcaa4f3d22a583df Mon Sep 17 00:00:00 2001 From: Toby Hede Date: Tue, 25 Aug 2026 15:17:37 +1000 Subject: [PATCH 394/686] fix(stack-auth): decode client-side claims without requiring org_id MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Claims` is not a server-only type. `Token::decode_claims` and `ServiceToken::decode_claims` deserialise it client-side to read `workspace`/`iss`/`sub`/`services`, and neither reads `org_id`. Making the field required therefore broke every SDK and CLI holding a token minted before the claim existed: `workspace_id()`, `issuer()` and `zerokms_url()` all failed with `AuthError::InvalidToken`. Both paths are documented signature-*unverified* reads of a token already persisted to `auth.json`, so requiring the claim there enforces nothing and only costs availability. Add `ClientClaims`: a lenient, `Deserialize`-only mirror carrying just the fields those paths need. It has no `Serialize` impl, so it can never mint a token, and it reuses the private `deserialize_workspace_id` shim so legacy `ws:`-prefixed workspace ids cannot drift from `Claims`. Server-side enforcement is untouched — `Claims.org_id` is still a bare required `String`, and a token without it still fails verification and 401s. Also fills in `org_id` on the three node-binding JWT fixtures the original change missed, which is what broke `test-unit` and `test-stack-auth`, and renames two tests to the crate's expectation style. Claude-Session: https://claude.ai/code/session_01YKRQDvyHnNa5mPpU9cLA2f --- .../auth/__tests__/helpers/test-fixtures.ts | 1 + languages/typescript/packages/auth/src/lib.rs | 2 + packages/stack-auth/src/service_token.rs | 66 ++++++++++++++++--- packages/stack-auth/src/token.rs | 41 +++++++++++- 4 files changed, 99 insertions(+), 11 deletions(-) diff --git a/languages/typescript/packages/auth/__tests__/helpers/test-fixtures.ts b/languages/typescript/packages/auth/__tests__/helpers/test-fixtures.ts index b37a316c0..a6510671e 100644 --- a/languages/typescript/packages/auth/__tests__/helpers/test-fixtures.ts +++ b/languages/typescript/packages/auth/__tests__/helpers/test-fixtures.ts @@ -29,6 +29,7 @@ export function mintJwt(claims: Record = {}): string { iat: now, exp: now + 3600, workspace: WORKSPACE_ID, + org_id: "org_test_default", scope: "", ...claims, }); diff --git a/languages/typescript/packages/auth/src/lib.rs b/languages/typescript/packages/auth/src/lib.rs index 809a0e7bb..e8e3b92e1 100644 --- a/languages/typescript/packages/auth/src/lib.rs +++ b/languages/typescript/packages/auth/src/lib.rs @@ -734,6 +734,7 @@ mod tests { "iat": now, "exp": now + 3600, "workspace": workspace, + "org_id": "org_test_default", "scope": "", }); @@ -802,6 +803,7 @@ mod tests { "iat": now, "exp": now + 3600, "workspace": "ZVATKW3VHMFG27DY", + "org_id": "org_test_default", "scope": "", "services": { "zerokms": zerokms_url }, }); diff --git a/packages/stack-auth/src/service_token.rs b/packages/stack-auth/src/service_token.rs index 069ed09d3..a822d227a 100644 --- a/packages/stack-auth/src/service_token.rs +++ b/packages/stack-auth/src/service_token.rs @@ -10,8 +10,8 @@ use crate::{AuthError, SecretToken}; /// /// Wraps a bearer credential ([`SecretToken`]) together with eagerly decoded /// JWT claims that are used for service discovery. The JWT is decoded (but -/// **not** signature-verified) using [`cts_common::claims::Claims`], so only -/// CipherStash-issued service tokens (from CTS or the access-key exchange) +/// **not** signature-verified) using [`cts_common::claims::ClientClaims`], so +/// only CipherStash-issued service tokens (from CTS or the access-key exchange) /// will have their claims resolved. /// /// # Decoded claims @@ -188,10 +188,17 @@ impl ServiceToken { } } -/// Decode the JWT payload into [`Claims`](cts_common::claims::Claims) without -/// verifying the signature — we only read claims from a token we already hold. -/// See [`crate::decode_jwt_payload`] for why we parse by hand. -fn decode_claims(token_str: &str) -> Result { +/// Decode the JWT payload into +/// [`ClientClaims`](cts_common::claims::ClientClaims) without verifying the +/// signature — we only read claims from a token we already hold. See +/// [`crate::decode_jwt_payload`] for why we parse by hand. +/// +/// Deliberately *not* `cts_common::claims::Claims`: that is the server's view, +/// where every claim is required so an unauthorised token is rejected. Reading +/// discovery claims out of a token we already hold enforces nothing, so it must +/// not fail over a claim it never reads — `org_id` in particular, whose absence +/// is for ZeroKMS and CTS to reject once they have verified the signature. +fn decode_claims(token_str: &str) -> Result { // Strip the `AuthError::InvalidToken` prefix — callers re-wrap this string // in `AuthError::InvalidToken(reason)`, and we don't want "Invalid token: // Invalid token: ..." in the final message. @@ -206,8 +213,7 @@ mod tests { use super::*; use std::collections::BTreeMap; - fn make_jwt(iss: &str, services: Option>) -> String { - use jsonwebtoken::{encode, EncodingKey, Header}; + fn claims_json(iss: &str, services: Option>) -> serde_json::Value { use std::time::{SystemTime, UNIX_EPOCH}; let now = SystemTime::now() @@ -230,6 +236,12 @@ mod tests { claims["services"] = serde_json::to_value(svc).unwrap(); } + claims + } + + fn encode_claims(claims: serde_json::Value) -> String { + use jsonwebtoken::{encode, EncodingKey, Header}; + encode( &Header::default(), &claims, @@ -238,6 +250,18 @@ mod tests { .unwrap() } + fn make_jwt(iss: &str, services: Option>) -> String { + encode_claims(claims_json(iss, services)) + } + + /// A JWT carrying every claim [`make_jwt`] mints *except* `org_id` — i.e. a + /// token from a CTS that predates the claim, or one rolled back past it. + fn make_jwt_without_org_id(iss: &str, services: Option>) -> String { + let mut claims = claims_json(iss, services); + claims.as_object_mut().unwrap().remove("org_id"); + encode_claims(claims) + } + fn services_with_zerokms(url: &str) -> Option> { Some(BTreeMap::from([("zerokms", url)])) } @@ -459,4 +483,30 @@ mod tests { let debug = format!("{:?}", token); assert!(!debug.contains(&jwt)); } + + /// `org_id` is a *server-side* requirement: ZeroKMS and CTS reject a token + /// without it, after verifying the signature. This decode path verifies + /// nothing and reads only `sub`/`workspace`/`iss`/`services`, so a claim it + /// never looks at must not be able to break service discovery — otherwise a + /// CTS rolled back past the commit that started minting `org_id` takes every + /// SDK and CLI down with it, not just billing. + #[test] + fn resolves_discovery_claims_without_org_id() { + let jwt = make_jwt_without_org_id( + "https://cts.example.com/", + services_with_zerokms("https://zerokms.example.com/"), + ); + let token = ServiceToken::new(SecretToken::new(jwt)); + + assert_eq!(token.subject().unwrap(), "CS|test-user"); + assert_eq!( + token.workspace_id().unwrap().to_string(), + "ZVATKW3VHMFG27DY" + ); + assert_eq!(token.issuer().unwrap().as_str(), "https://cts.example.com/"); + assert_eq!( + token.zerokms_url().unwrap().as_str(), + "https://zerokms.example.com/" + ); + } } diff --git a/packages/stack-auth/src/token.rs b/packages/stack-auth/src/token.rs index 2b3736d22..acd4b1d5c 100644 --- a/packages/stack-auth/src/token.rs +++ b/packages/stack-auth/src/token.rs @@ -1,4 +1,4 @@ -use cts_common::claims::Claims; +use cts_common::claims::ClientClaims; use cts_common::{Crn, Region, WorkspaceId}; use url::Url; @@ -178,12 +178,18 @@ impl Token { claims.iss.parse().map_err(AuthError::from) } - /// Decode the JWT payload into [`Claims`] without verifying the signature. + /// Decode the JWT payload into [`ClientClaims`] without verifying the + /// signature. /// /// This is safe because we already possess the token — we just need to read /// the claims it contains. See [`crate::decode_jwt_payload`] for why we parse /// by hand rather than through `jsonwebtoken`. - fn decode_claims(&self) -> Result { + /// + /// Decodes [`ClientClaims`], not [`cts_common::claims::Claims`]: the server's + /// view requires every claim it enforces (`org_id` among them), and failing + /// an unverified client-side read of `workspace`/`iss` over a claim we never + /// look at would turn a server-side rejection into a total client outage. + fn decode_claims(&self) -> Result { crate::decode_jwt_payload(self.access_token.as_str()) } @@ -747,4 +753,33 @@ mod tests { let err = token.workspace_crn().unwrap_err(); assert!(matches!(err, AuthError::Server(_))); } + + /// `org_id` is required *server-side*, where the signature is verified + /// first. This decode path verifies nothing and reads only `workspace` and + /// `iss`, so a token minted before `org_id` existed — or by a CTS rolled + /// back past the commit that added it — must still resolve both. + #[test] + fn workspace_id_and_issuer_resolve_without_org_id() { + let mut claims = valid_claims_json(); + claims + .as_object_mut() + .expect("claims fixture is a JSON object") + .remove("org_id"); + let token = jwt_token(claims); + + assert_eq!( + token + .workspace_id() + .expect("workspace must decode without org_id") + .to_string(), + "7366ITCXSAPCH5TN" + ); + assert_eq!( + token + .issuer() + .expect("iss must decode without org_id") + .as_str(), + "https://cts.example.com/" + ); + } } From aa6a3dd758832196bfb191f2e61d43a804f2c5c2 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 26 Aug 2026 02:00:24 +0000 Subject: [PATCH 395/686] chore: release --- packages/stack-auth/CHANGELOG.md | 23 +++++++++++++++++++++++ packages/stack-auth/Cargo.toml | 2 +- packages/stack-profile/CHANGELOG.md | 3 +++ packages/stack-profile/Cargo.toml | 2 +- 4 files changed, 28 insertions(+), 2 deletions(-) diff --git a/packages/stack-auth/CHANGELOG.md b/packages/stack-auth/CHANGELOG.md index a7fe98d1d..9cab0d081 100644 --- a/packages/stack-auth/CHANGELOG.md +++ b/packages/stack-auth/CHANGELOG.md @@ -1,4 +1,27 @@ +## [0.42.3] - 2026-08-26 + + +### Features + +- classify usage denials as typed, non-retryable errors + +### Fixes + +- address usage-denial-taxonomy code review findings +- close out remaining PR #2120 review items +- register Notified before dropping the state lock (PR #2120 review) +- decode client-side claims without requiring org_id + +### Refactoring + +- remove duplication flagged by PR #2120 review + +### Testing + +- include org_id in JWT fixtures +- include org_id in JWT fixtures + ## [0.42.2] - 2026-08-17 diff --git a/packages/stack-auth/Cargo.toml b/packages/stack-auth/Cargo.toml index 163c35739..6af9bfdda 100644 --- a/packages/stack-auth/Cargo.toml +++ b/packages/stack-auth/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "stack-auth" description = "Authentication library for CipherStash services" -version = "0.42.2" +version = "0.42.3" edition.workspace = true authors.workspace = true repository.workspace = true diff --git a/packages/stack-profile/CHANGELOG.md b/packages/stack-profile/CHANGELOG.md index 513397553..74c659b65 100644 --- a/packages/stack-profile/CHANGELOG.md +++ b/packages/stack-profile/CHANGELOG.md @@ -37,6 +37,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 +## [0.42.3] - 2026-08-26 + + ## [0.42.2] - 2026-08-17 diff --git a/packages/stack-profile/Cargo.toml b/packages/stack-profile/Cargo.toml index 133667c62..ab4b2594b 100644 --- a/packages/stack-profile/Cargo.toml +++ b/packages/stack-profile/Cargo.toml @@ -2,7 +2,7 @@ name = "stack-profile" description = "Centralised ~/.cipherstash profile file management" license-file = "LICENSE" -version = "0.42.2" +version = "0.42.3" edition.workspace = true authors.workspace = true repository.workspace = true From 5a24b1db82bdc2bf74358e225c0431c0b1e03a31 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 5 Jun 2026 04:03:13 +0000 Subject: [PATCH 396/686] feat(stack-kms): extract ZeroKMS key generate/retrieve into a new crate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce `stack-kms`, a standalone crate carrying the generate/retrieve data-key slice of cipherstash-client's `zerokms` module. This first cut is a self-contained vertical slice — cipherstash-client is left untouched. Surface: - `StackKms` high-level client: `generate_keys`, `retrieve_keys`, `retrieve_keys_fallible`, fetching/refreshing the access token via stack-auth and resolving the ZeroKMS endpoint from the token's `services` claim. - `StackKmsBuilder` with a type-state that requires a `ClientKey` (directly or via a `KeyProvider`) before `build()` is callable. - Low-level `Client` generic over the `ZeroKMSConnection` transport, plus `HttpConnection`. - Key material types (`ClientKey`, `DataKey`, `DataKeyWithTag`), payloads (`GenerateKeyPayload`, `RetrieveKeyPayload`), key providers (`Env`/`Static`/`Fallback`, `SecretKey`, `ProfileStore`) and focused `GenerateKeyError`/`RetrieveKeyError`. Encryption/decryption, keyset/client management and config save/load remain in cipherstash-client. Env-var names and the user-agent helper are duplicated locally to keep the crate dependency-free of cipherstash-client. Tests cover the providers, key serde, and the core operations against an in-memory connection — including a generate->retrieve round-trip that asserts the derived data keys match. clippy (-D warnings) and fmt clean. --- packages/stack-kms/Cargo.toml | 56 ++ packages/stack-kms/LICENSE | 96 +++ packages/stack-kms/src/builder.rs | 261 +++++++ packages/stack-kms/src/client.rs | 634 ++++++++++++++++++ .../stack-kms/src/client/test_connection.rs | 142 ++++ packages/stack-kms/src/connection.rs | 317 +++++++++ packages/stack-kms/src/errors.rs | 72 ++ packages/stack-kms/src/futures.rs | 70 ++ packages/stack-kms/src/key.rs | 227 +++++++ packages/stack-kms/src/key_provider.rs | 548 +++++++++++++++ packages/stack-kms/src/lib.rs | 84 +++ packages/stack-kms/src/payload.rs | 77 +++ packages/stack-kms/src/secret_key.rs | 390 +++++++++++ packages/stack-kms/src/user_agent.rs | 16 + packages/stack-kms/src/vars.rs | 14 + 15 files changed, 3004 insertions(+) create mode 100644 packages/stack-kms/Cargo.toml create mode 100644 packages/stack-kms/LICENSE create mode 100644 packages/stack-kms/src/builder.rs create mode 100644 packages/stack-kms/src/client.rs create mode 100644 packages/stack-kms/src/client/test_connection.rs create mode 100644 packages/stack-kms/src/connection.rs create mode 100644 packages/stack-kms/src/errors.rs create mode 100644 packages/stack-kms/src/futures.rs create mode 100644 packages/stack-kms/src/key.rs create mode 100644 packages/stack-kms/src/key_provider.rs create mode 100644 packages/stack-kms/src/lib.rs create mode 100644 packages/stack-kms/src/payload.rs create mode 100644 packages/stack-kms/src/secret_key.rs create mode 100644 packages/stack-kms/src/user_agent.rs create mode 100644 packages/stack-kms/src/vars.rs diff --git a/packages/stack-kms/Cargo.toml b/packages/stack-kms/Cargo.toml new file mode 100644 index 000000000..9eefa3c41 --- /dev/null +++ b/packages/stack-kms/Cargo.toml @@ -0,0 +1,56 @@ +[package] +name = "stack-kms" +description = "Standalone client for ZeroKMS key generation and retrieval" +version = "0.1.0" +edition.workspace = true +authors.workspace = true +repository.workspace = true +homepage.workspace = true +keywords.workspace = true +categories.workspace = true +license-file = "LICENSE" + +[dependencies] +recipher = { workspace = true } +stack-auth = { workspace = true } +zerokms-protocol = { workspace = true } + +vitaminc = { workspace = true, features = ["protected"] } + +lazy_static = { workspace = true } +log = { workspace = true } +miette = { workspace = true } +reqwest = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +thiserror = { workspace = true } +tracing = { workspace = true } +url = { workspace = true } +uuid = { workspace = true } +zeroize = { workspace = true } +rand = { workspace = true } + +base16ct = { version = "0.2.0", features = ["alloc"] } +base64ct = { version = "1.7", features = ["alloc"] } +futures = "0.3.25" +opaque-debug = "0.3.1" +serde_cbor = "0.11.2" +serdect = { version = "0.3.0", features = ["zeroize"] } +sha2 = "0.10.6" + +# Native-only: `stack-profile` is the filesystem-backed profile/config layer +# used by `SecretKey` (the on-disk `secretkey.json`) and the `ProfileStore` +# key provider. Wasm consumers source the client key in-memory instead. +[target.'cfg(not(target_arch = "wasm32"))'.dependencies] +stack-profile = { workspace = true } + +[dev-dependencies] +async-mutex = "1.4.0" +recipher = { workspace = true } +stack-auth = { workspace = true, features = ["test-utils"] } +tempfile = "3.21.0" +tokio = { workspace = true } +toml = "0.8.19" + +[package.metadata.docs.rs] +all-features = true diff --git a/packages/stack-kms/LICENSE b/packages/stack-kms/LICENSE new file mode 100644 index 000000000..2cbd67a66 --- /dev/null +++ b/packages/stack-kms/LICENSE @@ -0,0 +1,96 @@ +# PolyForm Internal Use License 1.0.0 + + + +## Acceptance + +In order to get any license under these terms, you must agree +to them as both strict obligations and conditions to all +your licenses. + +## Copyright License + +The licensor grants you a copyright license for the software +to do everything you might do with the software that would +otherwise infringe the licensor's copyright in it for any +permitted purpose. However, you may only make changes or +new works based on the software according to [Changes and New +Works License](#changes-and-new-works-license), and you may +not distribute the software. + +## Changes and New Works License + +The licensor grants you an additional copyright license to +make changes and new works based on the software for any +permitted purpose. + +## Patent License + +The licensor grants you a patent license for the software that +covers patent claims the licensor can license, or becomes able +to license, that you would infringe by using the software. + +## Fair Use + +You may have "fair use" rights for the software under the +law. These terms do not limit them. + +## Internal Business Use + +Use of the software for the internal business operations of +you and your company is use for a permitted purpose. + +## No Other Rights + +These terms do not allow you to sublicense or transfer any of +your licenses to anyone else, or prevent the licensor from +granting licenses to anyone else. These terms do not imply +any other licenses. + +## Patent Defense + +If you make any written claim that the software infringes or +contributes to infringement of any patent, your patent license +for the software granted under these terms ends immediately. If +your company makes such a claim, your patent license ends +immediately for work on behalf of your company. + +## Violations + +The first time you are notified in writing that you have +violated any of these terms, or done anything with the software +not covered by your licenses, your licenses can nonetheless +continue if you come into full compliance with these terms, +and take practical steps to correct past violations, within +32 days of receiving notice. Otherwise, all your licenses +end immediately. + +## No Liability + +***As far as the law allows, the software comes as is, without +any warranty or condition, and the licensor will not be liable +to you for any damages arising out of these terms or the use +or nature of the software, under any kind of legal claim.*** + +## Definitions + +The **licensor** is the individual or entity offering these +terms, and the **software** is the software the licensor makes +available under these terms. + +**You** refers to the individual or entity agreeing to these +terms. + +**Your company** is any legal entity, sole proprietorship, +or other kind of organization that you work for, plus all +organizations that have control over, are under the control of, +or are under common control with that organization. **Control** +means ownership of substantially all the assets of an entity, +or the power to direct its management and policies by vote, +contract, or otherwise. Control can be direct or indirect. + +**Your licenses** are all the licenses granted to you for the +software under these terms. + +**Use** means anything you do with the software requiring one +of your licenses. diff --git a/packages/stack-kms/src/builder.rs b/packages/stack-kms/src/builder.rs new file mode 100644 index 000000000..6d1a9d4db --- /dev/null +++ b/packages/stack-kms/src/builder.rs @@ -0,0 +1,261 @@ +use crate::client::{ClientOpts, StackKms, DEFAULT_CONCURRENT_REQS, DEFAULT_KEYS_PER_REQ}; +use crate::connection::HttpConnectionOpts; +use crate::key::ClientKey; +use crate::key_provider::{KeyProvider, KeyProviderError}; +use stack_auth::{AuthStrategy, AuthStrategyBounds}; +use thiserror::Error; +use url::Url; + +/// Error type for [`StackKmsBuilder`] operations. +#[derive(Debug, Error, miette::Diagnostic)] +pub enum StackKmsBuilderError { + /// Failed to initialize the underlying client. + #[error("Failed to initialize client: {0}")] + ClientInit(#[from] crate::errors::Error), + + /// Authentication strategy failed to initialize. + #[error("Auth strategy error: {0}")] + Auth(#[from] stack_auth::AuthError), + + /// Key provider failed to load a client key. + #[error("Key provider error: {0}")] + KeyProvider(#[from] KeyProviderError), +} + +/// A builder for creating [`StackKms`] clients. +/// +/// A [`ClientKey`] is **required** — key generation and retrieval can't happen +/// without one — so the terminal [`build`](Self::build) only exists once a key +/// (via [`with_client_key`](Self::with_client_key)) or a +/// [`KeyProvider`](crate::KeyProvider) (via +/// [`with_key_provider`](Self::with_key_provider)) has been supplied. +/// +/// The ZeroKMS endpoint is resolved in this order: +/// 1. Explicit URL via [`with_base_url`](Self::with_base_url) +/// 2. `CS_ZEROKMS_HOST` (or legacy `CS_VITUR_HOST`) environment variable +/// 3. Automatically from the token's `services` claim +/// +/// # Example +/// +/// ```no_run +/// use stack_kms::{StackKmsBuilder, ClientKey}; +/// use stack_auth::AutoStrategy; +/// use uuid::Uuid; +/// +/// # fn example() -> Result<(), Box> { +/// let strategy = AutoStrategy::detect()?; +/// let client_id = Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000")?; +/// let client_key = ClientKey::from_hex_v1(client_id, "a4627031...")?; +/// +/// let kms = StackKmsBuilder::new(strategy) +/// .with_client_key(client_key) +/// .build()?; +/// # Ok(()) +/// # } +/// ``` +pub struct StackKmsBuilder { + credentials: C, + request_timeout: Option, + connect_timeout: Option, + pool_idle_timeout: Option, + max_keys_per_req: usize, + max_concurrent_reqs: usize, + client_key: ClientKeyState, + base_url_override: Option, +} + +impl StackKmsBuilder { + /// Create a [`StackKmsBuilder`] that automatically detects credentials from the environment. + /// + /// ```no_run + /// use stack_kms::StackKmsBuilder; + /// + /// # fn example() -> Result<(), Box> { + /// let builder = StackKmsBuilder::auto()?; + /// # Ok(()) + /// # } + /// ``` + pub fn auto() -> Result { + let strategy = stack_auth::AutoStrategy::detect()?; + Ok(Self::new(strategy)) + } +} + +impl StackKmsBuilder +where + C: AuthStrategyBounds, + for<'a> &'a C: AuthStrategy, +{ + /// Create a new [`StackKmsBuilder`]. + /// + /// # Arguments + /// + /// * `credentials` - Credentials provider for obtaining access tokens + pub fn new(credentials: C) -> Self { + Self { + credentials, + request_timeout: None, + connect_timeout: None, + pool_idle_timeout: None, + max_keys_per_req: DEFAULT_KEYS_PER_REQ, + max_concurrent_reqs: DEFAULT_CONCURRENT_REQS, + client_key: (), + base_url_override: None, + } + } + + /// Set the **total request timeout** in seconds. Defaults to 10 seconds. + pub fn with_request_timeout(mut self, timeout_secs: u64) -> Self { + self.request_timeout = Some(timeout_secs); + self + } + + /// Set the **connect timeout** in seconds (TCP connect + TLS handshake only). + pub fn with_connect_timeout(mut self, timeout_secs: u64) -> Self { + self.connect_timeout = Some(timeout_secs); + self + } + + /// Set the **pool idle timeout** in seconds. + pub fn with_pool_idle_timeout(mut self, timeout_secs: u64) -> Self { + self.pool_idle_timeout = Some(timeout_secs); + self + } + + /// Set the maximum number of keys per request. Defaults to 500. + pub fn with_max_keys_per_req(mut self, max_keys: usize) -> Self { + self.max_keys_per_req = max_keys; + self + } + + /// Set the maximum number of concurrent requests. Defaults to 5. + pub fn with_max_concurrent_reqs(mut self, max_concurrent: usize) -> Self { + self.max_concurrent_reqs = max_concurrent; + self + } + + /// Override the base URL for the ZeroKMS service. + /// + /// This bypasses resolving the URL from the token's `services` claim and connects + /// directly to the specified URL. + pub fn with_base_url(mut self, base_url: Url) -> Self { + self.base_url_override = Some(base_url); + self + } + + /// Add a [`KeyProvider`] to load a client key asynchronously at build time. + /// + /// This transforms the builder into one that builds via an async + /// [`build()`](StackKmsBuilder::build) call. + pub fn with_key_provider( + self, + provider: K, + ) -> StackKmsBuilder> { + StackKmsBuilder { + credentials: self.credentials, + request_timeout: self.request_timeout, + connect_timeout: self.connect_timeout, + pool_idle_timeout: self.pool_idle_timeout, + max_keys_per_req: self.max_keys_per_req, + max_concurrent_reqs: self.max_concurrent_reqs, + client_key: WithKeyProvider(provider), + base_url_override: self.base_url_override, + } + } + + /// Add a client key directly. + pub fn with_client_key(self, client_key: ClientKey) -> StackKmsBuilder { + StackKmsBuilder { + credentials: self.credentials, + request_timeout: self.request_timeout, + connect_timeout: self.connect_timeout, + pool_idle_timeout: self.pool_idle_timeout, + max_keys_per_req: self.max_keys_per_req, + max_concurrent_reqs: self.max_concurrent_reqs, + client_key, + base_url_override: self.base_url_override, + } + } +} + +impl StackKmsBuilder { + fn build_opts(self) -> (ClientOpts, C, S) { + let base_url = self.base_url_override.or_else(Self::base_url_from_env); + let mut connection_opts = HttpConnectionOpts::new(base_url); + if let Some(timeout) = self.request_timeout { + connection_opts = connection_opts.with_request_timeout(timeout); + } + if let Some(connect_timeout) = self.connect_timeout { + connection_opts = connection_opts.with_connect_timeout(connect_timeout); + } + if let Some(pool_idle_timeout) = self.pool_idle_timeout { + connection_opts = connection_opts.with_pool_idle_timeout(pool_idle_timeout); + } + + let opts = ClientOpts { + max_keys_per_req: self.max_keys_per_req, + max_concurrent_reqs: self.max_concurrent_reqs, + connection_opts, + }; + + (opts, self.credentials, self.client_key) + } + + /// Resolve the ZeroKMS base URL from the `CS_ZEROKMS_HOST` environment + /// variable (or legacy `CS_VITUR_HOST`). + fn base_url_from_env() -> Option { + use crate::vars::CS_ZEROKMS_HOST; + + for name in CS_ZEROKMS_HOST { + if let Ok(value) = std::env::var(name) { + match value.parse() { + Ok(url) => return Some(url), + Err(err) => { + tracing::warn!( + target: "stack_kms", + %err, + env_var = name, + "Ignoring invalid URL in environment variable" + ); + } + } + } + } + + None + } +} + +impl StackKmsBuilder +where + C: AuthStrategyBounds, + for<'a> &'a C: AuthStrategy, +{ + /// Build a [`StackKms`] client. + pub fn build(self) -> Result, StackKmsBuilderError> { + let (opts, credentials, client_key) = self.build_opts(); + Ok(StackKms::connect(opts, credentials, client_key)?) + } +} + +/// Newtype wrapper that marks a [`KeyProvider`] in the builder's type state. +/// +/// This avoids coherence issues — [`ClientKey`] does not implement [`KeyProvider`], +/// and `WithKeyProvider` keeps the two `build()` signatures unambiguous. +pub struct WithKeyProvider(K); + +impl StackKmsBuilder> +where + C: AuthStrategyBounds, + for<'a> &'a C: AuthStrategy, + K: KeyProvider, +{ + /// Build a [`StackKms`] client by loading the key from the provider. + /// + /// This is an async method because the key provider may need to perform I/O. + pub async fn build(self) -> Result, StackKmsBuilderError> { + let (opts, credentials, provider) = self.build_opts(); + let client_key = provider.0.client_key().await?; + Ok(StackKms::connect(opts, credentials, client_key)?) + } +} diff --git a/packages/stack-kms/src/client.rs b/packages/stack-kms/src/client.rs new file mode 100644 index 000000000..90b6dae7d --- /dev/null +++ b/packages/stack-kms/src/client.rs @@ -0,0 +1,634 @@ +use log::{debug, trace}; +use std::borrow::Cow; +use uuid::Uuid; +use zerokms_protocol::{ + GenerateKeyRequest, GenerateKeySpec, GeneratedKey, RetrieveKeyRequest, + RetrieveKeyRequestFallible, RetrieveKeySpec, RetrievedKey, UnverifiedContext, +}; + +use recipher::key::{GenRandom, Iv}; +use stack_auth::{AuthStrategy, AuthStrategyBounds}; + +use crate::connection::{HttpConnection, HttpConnectionOpts, ZeroKMSConnection}; +use crate::errors::{Error, GenerateKeyError, RetrieveKeyError}; +use crate::futures::map_async_chunked; +use crate::key::{ClientKey, DataKey, DataKeyWithTag}; +use crate::payload::{GenerateKeyPayload, RetrieveKeyPayload}; + +pub(crate) const DEFAULT_KEYS_PER_REQ: usize = 500; +pub(crate) const DEFAULT_CONCURRENT_REQS: usize = 5; + +/// Options for configuring certain behaviours of the [`Client`]. +/// +/// You should generally use the [`StackKmsBuilder`](crate::StackKmsBuilder) to create a +/// configured instance rather than instantiating this struct directly. +pub struct ClientOpts { + /// The maximum number of key specs that should be in each generate or retrieve request to + /// ZeroKMS. Having too large a number of specs per request can panic by exceeding reqwest's max + /// body size. + pub max_keys_per_req: usize, + + /// The maximum number of requests that will be spun up per call to `generate_keys` or + /// `retrieve_keys`. Having too large a number of concurrent requests can result in + /// dropped connections which will fail the calls. + pub max_concurrent_reqs: usize, + + /// The connection options to use when initializing the connection to ZeroKMS. + pub connection_opts: CONNOPTS, +} + +/// Low-level client for ZeroKMS key generation and retrieval. +/// +/// The client is generic over the transport [`ZeroKMSConnection`]; the default +/// [`HttpConnection`] talks to a real ZeroKMS endpoint. Each method takes an +/// access token directly — see [`StackKms`] for the high-level wrapper that +/// fetches and refreshes tokens via [`stack_auth`]. +pub struct Client { + connection: C, + max_keys_per_req: usize, + max_concurrent_reqs: usize, +} + +/// Returned by the [`Client::retrieve_keys_fallible`] method. +pub type FallibleDataKeyVec = Vec>; + +impl Client { + /// Returns a reference to the underlying connection. + pub(crate) fn connection(&self) -> &C { + &self.connection + } +} + +impl Client { + pub fn init_opts(opts: ClientOpts) -> Result { + let connection = C::init(opts.connection_opts)?; + + Ok(Self { + connection, + max_keys_per_req: opts.max_keys_per_req, + max_concurrent_reqs: opts.max_concurrent_reqs, + }) + } + + /// Retrieve multiple data keys for an iterator of [`RetrieveKeyPayload`]. + pub async fn retrieve_keys( + &self, + keys: impl IntoIterator>, + key: &ClientKey, + keyset_id: Option, + access_token: &str, + unverified_context: Option<&UnverifiedContext>, + ) -> Result, RetrieveKeyError> { + trace!(target: "stack_kms::retrieve_keys", "preparing payloads"); + + let keys = keys + .into_iter() + .map(RetrieveKeySpec::from) + .collect::>(); + + tracing::trace!(target: "stack_kms::retrieve_keys", max_keys_per_req = self.max_keys_per_req, max_parallel_reqs = self.max_concurrent_reqs); + + // map_async_chunked will split the retrieve key requests up into chunks and send them to + // ZeroKMS concurrently. The number of concurrent requests and size of the chunks are passed + // through from ClientOpts. + let result = map_async_chunked( + &keys, + |keys| async { + let req = RetrieveKeyRequest { + keys: keys.into(), + keyset_id: keyset_id.map(Into::into), + client_id: key.key_id, + unverified_context: unverified_context.cloned().unwrap_or_default(), + }; + + trace!(target: "stack_kms::retrieve_keys", "sending request with {} keys", keys.len()); + + self.connection + .send(req, access_token) + .await + .map_err(RetrieveKeyError::RequestFailed) + .and_then(|res| { + // This should never happen with ZeroKMS but check just to be sure. + if res.keys.len() != keys.len() { + return Err(RetrieveKeyError::InvalidNumberOfKeys { + expected: keys.len(), + received: res.keys.len(), + }); + } + + trace!(target: "stack_kms::retrieve_keys", "retrieved keys - creating data keys"); + + Ok(keys + .iter() + .zip(res.keys) + .map( + |(RetrieveKeySpec { iv, .. }, RetrievedKey { key_material })| { + DataKey::from_key_material(key, iv.into_inner(), &key_material) + }, + ) + .collect()) + }) + }, + self.max_keys_per_req, + self.max_concurrent_reqs, + ) + .await; + + match &result { + Err(x) => { + trace!(target: "stack_kms::retrieve_keys", "failed with error: {x}"); + } + Ok(x) => { + trace!(target: "stack_kms::retrieve_keys", "successfully retrieved {} keys", x.len()); + } + } + + result + } + + /// Retrieve multiple data keys, returning a per-key result so partial failures + /// don't fail the whole batch. + pub async fn retrieve_keys_fallible<'a>( + &self, + keys: impl IntoIterator>, + client_key: &ClientKey, + keyset_id: Option, + access_token: &str, + unverified_context: Option>, + ) -> Result { + trace!(target: "stack_kms::retrieve_keys", "preparing payloads"); + + let keys = keys + .into_iter() + .map(RetrieveKeySpec::from) + .collect::>(); + + tracing::trace!(target: "stack_kms::retrieve_keys", max_keys_per_req = self.max_keys_per_req, max_parallel_reqs = self.max_concurrent_reqs); + + // map_async_chunked will split the retrieve key requests up into chunks and send them to + // ZeroKMS concurrently. The number of concurrent requests and size of the chunks are passed + // through from ClientOpts. + let result = map_async_chunked( + &keys, + |keys| async { + let req = RetrieveKeyRequestFallible { + keys: keys.into(), + keyset_id: keyset_id.map(Into::into), + client_id: client_key.key_id, + unverified_context: unverified_context.clone().unwrap_or_default(), + }; + + trace!(target: "stack_kms::retrieve_keys", "sending request with {} keys", keys.len()); + + self.connection + .send(req, access_token) + .await + .map_err(RetrieveKeyError::RequestFailed) + .and_then(|res| { + // This should never happen with ZeroKMS but check just to be sure. + if res.keys.len() != keys.len() { + return Err(RetrieveKeyError::InvalidNumberOfKeys { + expected: keys.len(), + received: res.keys.len(), + }); + } + + trace!(target: "stack_kms::retrieve_keys", "retrieved keys - creating data keys"); + + Ok(keys + .iter() + .zip(res.keys) + .map(|(RetrieveKeySpec { iv, .. }, result)| { + result + .map(|key| { + // If the key retrieval was successful, we create a DataKey from the key material + DataKey::from_key_material(client_key, iv.into_inner(), &key.key_material) + }) + .map_err(RetrieveKeyError::FailedRetrieval) + }) + .collect()) + }) + }, + self.max_keys_per_req, + self.max_concurrent_reqs, + ) + .await; + + match &result { + Err(x) => { + trace!(target: "stack_kms::retrieve_keys", "failed with error: {x}"); + } + Ok(x) => { + trace!(target: "stack_kms::retrieve_keys", "successfully retrieved {} keys", x.len()); + } + } + + result + } + + /// Generate multiple data keys for an iterator of [`GenerateKeyPayload`]. + pub async fn generate_keys<'a>( + &self, + keys: impl IntoIterator>, + client_key: &ClientKey, + keyset_id: Option, + access_token: &str, + unverified_context: Option>, + ) -> Result, GenerateKeyError> { + let keys = { + let mut rng = rand::thread_rng(); + + keys.into_iter() + .map( + |GenerateKeyPayload { + descriptor, + context, + decryption_policy, + }| { + GenRandom::gen_random(&mut rng) + .map(|iv: Iv| { + if let Some(policy) = decryption_policy { + GenerateKeySpec::new_with_policy(iv, descriptor, policy) + } else { + GenerateKeySpec::new_with_context( + iv, + descriptor, + context.clone(), + ) + } + }) + .map_err(GenerateKeyError::GenerateIv) + }, + ) + .collect::, _>>()? + }; + + trace!(target: "stack_kms::generate_keys", "generated {} key payloads", keys.len()); + tracing::trace!(target: "stack_kms::generate_keys", max_keys_per_req = self.max_keys_per_req, max_parallel_reqs = self.max_concurrent_reqs); + + // map_async_chunked will split the generate key requests up into chunks and send them to + // ZeroKMS concurrently. The number of concurrent requests and size of the chunks are passed + // through from ClientOpts. + let result = map_async_chunked( + &keys, + |keys| async { + let req = GenerateKeyRequest { + keys: keys.into(), + keyset_id: keyset_id.map(Into::into), + client_id: client_key.key_id, + unverified_context: unverified_context.clone().unwrap_or_default(), + }; + + trace!(target: "stack_kms::generate_keys", "sending request with {} keys", keys.len()); + + self.connection + .send(req, access_token) + .await + .map_err(GenerateKeyError::from) + .and_then(|res| { + // This should never happen with ZeroKMS but check just to be sure. + if res.keys.len() != keys.len() { + return Err(GenerateKeyError::InvalidNumberOfKeys { + expected: keys.len(), + received: res.keys.len(), + }); + } + + trace!(target: "stack_kms::generate_keys", "generated {} keys", keys.len()); + + Ok(keys + .iter() + .zip(res.keys) + .map( + |( + GenerateKeySpec { iv, .. }, + GeneratedKey { key_material, tag, decryption_policy }, + )| { + DataKeyWithTag::from_key_material(client_key, iv.into_inner(), &key_material, tag, decryption_policy) + }, + ) + .collect()) + }) + }, + self.max_keys_per_req, + self.max_concurrent_reqs, + ) + .await; + + match &result { + Err(x) => { + trace!(target: "stack_kms::generate_keys", "failed with error: {x}"); + } + Ok(x) => { + trace!(target: "stack_kms::generate_keys", "successfully generated {} keys", x.len()); + } + } + + result + } +} + +/// High-level client for generating and retrieving ZeroKMS data keys. +/// +/// `StackKms` owns the transport [`Client`], a [`stack_auth`] credential +/// provider, and a [`ClientKey`]. Each operation fetches a fresh access token +/// (refreshing as needed), resolves the ZeroKMS endpoint from the token's +/// `services` claim on first use, and delegates to the low-level client. +/// +/// Build one with [`StackKmsBuilder`](crate::StackKmsBuilder). +pub struct StackKms { + client: Client, + credentials: C, + client_key: ClientKey, +} + +impl StackKms +where + C: AuthStrategyBounds, + for<'a> &'a C: AuthStrategy, +{ + pub(crate) fn connect( + opts: ClientOpts, + credentials: C, + client_key: ClientKey, + ) -> Result { + let client = Client::init_opts(opts)?; + Ok(Self { + client, + credentials, + client_key, + }) + } + + /// Fetch a token from the credentials provider and ensure the ZeroKMS base + /// URL has been resolved on the connection (from the token's `services` + /// claim). The URL is only resolved once; subsequent calls skip resolution. + async fn get_token(&self) -> Result { + let token = (&self.credentials).get_token().await?; + if !self.client.connection().has_base_url() { + let url = token.zerokms_url()?; + self.client.connection().ensure_base_url(url); + } + Ok(token) + } + + /// The [`ClientKey`] this client uses to derive data keys. + pub fn client_key(&self) -> &ClientKey { + &self.client_key + } + + /// Generate multiple data keys for an iterator of [`GenerateKeyPayload`]. + pub async fn generate_keys<'a>( + &self, + payloads: impl IntoIterator>, + keyset_id: Option, + unverified_context: Option>, + ) -> Result, Error> { + let token = self.get_token().await?; + + self.client + .generate_keys( + payloads, + &self.client_key, + keyset_id, + token.as_str(), + unverified_context, + ) + .await + .map_err(Error::from) + } + + /// Retrieve multiple data keys for an iterator of [`RetrieveKeyPayload`]. + pub async fn retrieve_keys( + &self, + payloads: impl IntoIterator>, + keyset_id: Option, + unverified_context: Option<&UnverifiedContext>, + ) -> Result, Error> { + let token = self.get_token().await?; + + self.client + .retrieve_keys( + payloads, + &self.client_key, + keyset_id, + token.as_str(), + unverified_context, + ) + .await + .map_err(Error::from) + } + + /// Retrieve multiple data keys, returning a per-key result so partial + /// failures don't fail the whole batch. + pub async fn retrieve_keys_fallible<'a>( + &self, + payloads: impl IntoIterator>, + keyset_id: Option, + unverified_context: Option>, + ) -> Result { + let token = self.get_token().await?; + + debug!(target: "stack_kms::retrieve_keys_fallible", "got token, retrieving keys"); + self.client + .retrieve_keys_fallible( + payloads, + &self.client_key, + keyset_id, + token.as_str(), + unverified_context, + ) + .await + .map_err(Error::from) + } +} + +#[cfg(test)] +mod test_connection; + +#[cfg(test)] +mod tests { + use super::test_connection::*; + use super::*; + use crate::key::V1KeySet; + use recipher::keyset::{EncryptionKeySet, ProxyKeySet}; + use std::borrow::Cow; + use uuid::uuid; + use zerokms_protocol::{ + GenerateKeyResponse, GeneratedKey, RetrieveKeyResponse, RetrievedKey, ViturKeyMaterial, + }; + + fn random_client_key() -> ClientKey { + let domain_key = EncryptionKeySet::generate().unwrap(); + let authority_key = EncryptionKeySet::generate().unwrap(); + let keyset = ProxyKeySet::generate(&authority_key, &domain_key); + + ClientKey { + key_id: uuid!("00000000-0000-0000-0000-000000000000"), + keyset: V1KeySet(keyset), + } + } + + fn build_client( + callback: impl FnOnce(TestConnectionBuilder) -> TestConnectionBuilder, + ) -> Client { + let builder = callback(TestConnectionBuilder::new()); + let client_opts = ClientOpts { + max_keys_per_req: 10, + max_concurrent_reqs: 5, + connection_opts: builder, + }; + Client::init_opts(client_opts).expect("Failed to initialize test client") + } + + // 528 bytes is the size of the key material returned by ZeroKMS for the + // recipher proxy re-encryption scheme. + fn key_material() -> ViturKeyMaterial { + ViturKeyMaterial::from(vec![7u8; 528]) + } + + #[tokio::test] + async fn generate_keys_returns_a_key_per_payload() { + let client_key = random_client_key(); + + let client = build_client(|builder| { + builder.add_success_response::(GenerateKeyResponse { + keys: vec![ + GeneratedKey { + key_material: key_material(), + tag: vec![1, 2, 3], + decryption_policy: None, + }, + GeneratedKey { + key_material: key_material(), + tag: vec![4, 5, 6], + decryption_policy: None, + }, + ], + }) + }); + + let payloads = vec![ + GenerateKeyPayload::new("a", Cow::Owned(vec![])), + GenerateKeyPayload::new("b", Cow::Owned(vec![])), + ]; + + let keys = client + .generate_keys(payloads, &client_key, None, "token", None) + .await + .expect("generate_keys should succeed"); + + assert_eq!(keys.len(), 2); + assert_eq!(keys[0].tag, vec![1, 2, 3]); + assert_eq!(keys[1].tag, vec![4, 5, 6]); + } + + #[tokio::test] + async fn retrieve_keys_returns_a_key_per_payload() { + let client_key = random_client_key(); + + let client = build_client(|builder| { + builder.add_success_response::(RetrieveKeyResponse { + keys: vec![RetrievedKey { + key_material: key_material(), + }], + }) + }); + + let iv = Iv::default(); + let payloads = vec![RetrieveKeyPayload::new(iv, "a", &[1, 2, 3])]; + + let keys = client + .retrieve_keys(payloads, &client_key, None, "token", None) + .await + .expect("retrieve_keys should succeed"); + + assert_eq!(keys.len(), 1); + assert_eq!(keys[0].iv, iv); + } + + #[tokio::test] + async fn generate_then_retrieve_derives_the_same_data_key() { + let client_key = random_client_key(); + // `ViturKeyMaterial` isn't `Clone`, so build two from the same bytes — + // ZeroKMS returns the same underlying material for generate + retrieve. + let shared_bytes = vec![7u8; 528]; + + // Generate one key. + let gen_client = build_client(|builder| { + builder.add_success_response::(GenerateKeyResponse { + keys: vec![GeneratedKey { + key_material: ViturKeyMaterial::from(shared_bytes.clone()), + tag: vec![9, 9, 9], + decryption_policy: None, + }], + }) + }); + + let generated = gen_client + .generate_keys( + vec![GenerateKeyPayload::new("desc", Cow::Owned(vec![]))], + &client_key, + None, + "token", + None, + ) + .await + .expect("generate should succeed"); + + let generated_iv = generated[0].key.iv; + + // Retrieve using the IV that was generated; ZeroKMS returns the same + // underlying key material, so the derived DataKey must match. + let ret_client = build_client(|builder| { + builder.add_success_response::(RetrieveKeyResponse { + keys: vec![RetrievedKey { + key_material: ViturKeyMaterial::from(shared_bytes.clone()), + }], + }) + }); + + let retrieved = ret_client + .retrieve_keys( + vec![RetrieveKeyPayload::new(generated_iv, "desc", &[9, 9, 9])], + &client_key, + None, + "token", + None, + ) + .await + .expect("retrieve should succeed"); + + assert_eq!(generated[0].key.key(), retrieved[0].key()); + } + + #[tokio::test] + async fn retrieve_keys_fallible_surfaces_per_key_failures() { + let client_key = random_client_key(); + + let client = build_client(|builder| { + builder.add_success_response::( + zerokms_protocol::RetrieveKeyResponseFallible { + keys: vec![Ok(RetrievedKey { + key_material: key_material(), + })], + }, + ) + }); + + let iv = Iv::default(); + let keys = client + .retrieve_keys_fallible( + vec![RetrieveKeyPayload::new(iv, "a", &[1])], + &client_key, + None, + "token", + None, + ) + .await + .expect("retrieve_keys_fallible should succeed"); + + assert_eq!(keys.len(), 1); + assert!(keys[0].is_ok()); + } +} diff --git a/packages/stack-kms/src/client/test_connection.rs b/packages/stack-kms/src/client/test_connection.rs new file mode 100644 index 000000000..eea427327 --- /dev/null +++ b/packages/stack-kms/src/client/test_connection.rs @@ -0,0 +1,142 @@ +//! In-memory [`ZeroKMSConnection`] used by unit tests to stub ZeroKMS +//! responses without touching the network. +//! +//! Not every matcher helper is exercised by the current tests, but the full +//! harness is kept so future key-operation tests can use it. +#![allow(dead_code)] + +use async_mutex::Mutex; +use zerokms_protocol::{ViturRequest, ViturRequestError}; + +use crate::connection::{ZeroKMSConnection, ZeroKMSConnectionInit}; + +type EffectHandlers = Vec<(String, Box)>; +type RequestHandlers = Vec<(String, Result)>; + +pub struct TestConnectionBuilder { + handlers: RequestHandlers, + effects: EffectHandlers, +} + +impl TestConnectionBuilder { + pub fn new() -> Self { + Self { + handlers: vec![], + effects: vec![], + } + } + + /// Add a matcher for a particular request, returning a success message. + /// + /// The matcher is only run once. + pub fn add_success_response(mut self, response: R::Response) -> Self { + self.handlers.push(( + R::ENDPOINT.to_string(), + Ok(serde_json::to_string(&response) + .expect("Failed to serialise success response. This shouldn't happen.")), + )); + self + } + + /// Add a matcher for a particular request, returning a [`ViturRequestError`]. + /// + /// The matcher is only run once. + pub fn add_failed_response(mut self, error: ViturRequestError) -> Self { + self.handlers.push((R::ENDPOINT.to_string(), Err(error))); + self + } + + /// Add a matcher for a particular request, running an effect on the body of the request. + /// + /// This matcher is only run once. + pub fn add_effect( + mut self, + handler: H, + ) -> Self { + let endpoint = R::ENDPOINT; + + self.effects.push(( + endpoint.to_string(), + Box::new(move |message| { + handler(serde_json::from_str(message).expect( + "Failed to parse request from message in test effect. This shouldn't happen.", + )) + }), + )); + + self + } + + pub fn build(self) -> TestConnection { + TestConnection { + handlers: Mutex::new(self.handlers), + effects: Mutex::new(self.effects), + } + } +} + +impl Default for TestConnectionBuilder { + fn default() -> Self { + Self::new() + } +} + +pub struct TestConnection { + handlers: Mutex, + effects: Mutex, +} + +impl TestConnection { + pub fn builder() -> TestConnectionBuilder { + TestConnectionBuilder::new() + } + + pub fn empty() -> Self { + Self::builder().build() + } +} + +impl ZeroKMSConnectionInit for TestConnection { + type ConnectionOpts = TestConnectionBuilder; + type Error = std::convert::Infallible; + + fn init(builder: Self::ConnectionOpts) -> Result { + Ok(builder.build()) + } +} + +impl ZeroKMSConnection for TestConnection { + async fn send( + &self, + request: Request, + _access_token: &str, + ) -> Result { + let endpoint = Request::ENDPOINT; + + let mut effect_guard = self.effects.lock().await; + + let effect_position = effect_guard.iter().position(|(x, _)| x == endpoint); + + let body = serde_json::to_string(&request) + .expect("Failed to serialise request body in test connection"); + + if let Some(index) = effect_position { + let (_, effect) = effect_guard.remove(index); + effect(&body); + } + + let mut handler_guard = self.handlers.lock().await; + + let index = handler_guard + .iter() + .position(|(x, _)| x == endpoint) + .unwrap_or_else(|| panic!("No handler defined for request: {endpoint}")); + + let (_, body) = handler_guard.remove(index); + + body.map(|x| { + serde_json::from_str(&x) + .expect("Failed to parse response body from handler in test connection") + }) + } +} diff --git a/packages/stack-kms/src/connection.rs b/packages/stack-kms/src/connection.rs new file mode 100644 index 000000000..a4e237a2a --- /dev/null +++ b/packages/stack-kms/src/connection.rs @@ -0,0 +1,317 @@ +use crate::user_agent::get_user_agent; +use reqwest::{header::HeaderMap, Response, StatusCode}; +use serde_json::{from_reader, to_vec}; +#[cfg(not(target_arch = "wasm32"))] +use std::time::Duration; +use std::{collections::HashMap, future::Future, sync::OnceLock}; +use thiserror::Error; +use url::Url; +use zerokms_protocol::{ViturRequest, ViturRequestError, ViturRequestErrorKind}; + +#[cfg(not(target_arch = "wasm32"))] +const REQUEST_TIMEOUT_SECS: u64 = 10; + +#[derive(Debug, Error)] +#[error("Failed to initialize HTTP connection: {0}")] +pub struct ConnectionInitError(#[from] reqwest::Error); + +#[derive(Debug, Error)] +#[error("token does not grant access to ZeroKMS (missing `services` claim)")] +struct Unauthorized; + +pub struct HttpConnectionOpts { + base_url: Option, + request_timeout: Option, + connect_timeout: Option, + pool_idle_timeout: Option, +} + +impl HttpConnectionOpts { + pub fn new(base_url: Option) -> Self { + Self { + base_url, + request_timeout: None, + connect_timeout: None, + pool_idle_timeout: None, + } + } + + /// Set the **total request timeout** in seconds — covers connect + TLS + /// handshake + body send + body receive together. If not set, defaults + /// to 10 seconds. + /// + /// Set this larger when calling endpoints whose server-side processing + /// time scales with payload size (e.g. `generate-data-key` with large + /// `keys.len()`), or when running over high-latency / variable-quality + /// networks. See [`with_connect_timeout`](Self::with_connect_timeout) + /// to bound the connect phase separately. + /// + /// Ignored on wasm32 — reqwest's fetch-backed `ClientBuilder` doesn't + /// expose `.timeout()` and the host runtime (e.g. Supabase Edge, Cloudflare + /// Workers) owns request lifetime there. + pub fn with_request_timeout(mut self, timeout_secs: u64) -> Self { + self.request_timeout = Some(timeout_secs); + self + } + + /// Set the **connect timeout** in seconds — bound on TCP connect + TLS + /// handshake only, separate from the total request timeout. If not set, + /// reqwest falls back to the OS-level connect timeout (~75 s on most + /// platforms), so the only ceiling on a stuck connect is whatever the + /// total request timeout is. + /// + /// Useful for fast-fail behaviour on broken networks: a value like 5 + /// seconds gives the connect phase plenty of room without forcing the + /// total request timeout to absorb both connect *and* response time. + /// + /// Ignored on wasm32 for the same reason as + /// [`with_request_timeout`](Self::with_request_timeout): the host + /// runtime owns connection lifetime under fetch. + pub fn with_connect_timeout(mut self, timeout_secs: u64) -> Self { + self.connect_timeout = Some(timeout_secs); + self + } + + /// Set the **pool idle timeout** in seconds — how long the underlying + /// reqwest client keeps an idle keep-alive connection in its pool + /// before closing it. If not set, reqwest's default of 90 s applies. + /// + /// Long-lived processes (bulk ingest, daemons) benefit from raising + /// this so warm TLS connections survive idle gaps between batches. + /// + /// Ignored on wasm32 — connection pooling is owned by the host + /// runtime under fetch. + pub fn with_pool_idle_timeout(mut self, timeout_secs: u64) -> Self { + self.pool_idle_timeout = Some(timeout_secs); + self + } +} + +pub trait ZeroKMSConnectionInit { + type ConnectionOpts; + type Error: std::error::Error + Send + Sync + 'static; + + fn init(opts: Self::ConnectionOpts) -> Result + where + Self: Sized; +} + +/// On native targets the returned future is `Send` so callers can drive it on +/// a multi-threaded runtime. On wasm32 the bound is dropped — reqwest's +/// fetch-backed response futures aren't `Send`, and edge runtimes are +/// single-threaded anyway. +#[cfg(not(target_arch = "wasm32"))] +pub trait ZeroKMSConnection: ZeroKMSConnectionInit { + fn send( + &self, + request: Request, + access_token: &str, + ) -> impl Future> + Send; +} + +#[cfg(target_arch = "wasm32")] +pub trait ZeroKMSConnection: ZeroKMSConnectionInit { + fn send( + &self, + request: Request, + access_token: &str, + ) -> impl Future>; +} + +pub struct HttpConnection { + base_url: OnceLock, + client: reqwest::Client, +} + +#[derive(Debug, Error)] +#[error("Received '{received:?}', expected '{expected}', Body: {body:?}, Headers: {headers:?}")] +struct UnexpectedError { + received: Option, + expected: &'static str, + body: Option, + headers: HashMap, +} + +#[derive(Debug, Error)] +#[error("Status: {status}, Body: {body:?}, Headers: {headers:?}")] +struct FailureResponse { + status: StatusCode, + body: Option, + headers: HashMap, +} + +impl FailureResponse { + async fn from_response(response: Response) -> Self { + let status = response.status(); + let headers = header_map_to_hash(response.headers()); + let body = response.text().await.ok(); + + Self { + status, + body, + headers, + } + } + + fn into_vitur_error( + self, + error_kind: ViturRequestErrorKind, + message: &'static str, + ) -> ViturRequestError { + ViturRequestError::new(error_kind, message, self) + } +} + +fn header_map_to_hash(map: &HeaderMap) -> HashMap { + map.iter() + .filter_map(|(k, v)| { + v.to_str() + .map(|x| x.to_string()) + .ok() + .map(|v| (k.to_string(), v)) + }) + .collect() +} + +impl HttpConnection { + /// Set the base URL if it has not already been set. + /// + /// This is a no-op if the URL was already provided at init time or by a + /// previous call to this method. + pub fn ensure_base_url(&self, url: Url) { + // OnceLock::set returns Err if already set — that's fine, we keep the first value. + let _ = self.base_url.set(url); + } + + /// Returns `true` if the base URL has been resolved (either at init time + /// or via [`ensure_base_url`](Self::ensure_base_url)). + pub fn has_base_url(&self) -> bool { + self.base_url.get().is_some() + } +} + +impl ZeroKMSConnectionInit for HttpConnection { + type ConnectionOpts = HttpConnectionOpts; + type Error = ConnectionInitError; + + fn init(opts: Self::ConnectionOpts) -> Result { + let builder = reqwest::ClientBuilder::new().user_agent(get_user_agent()); + // wasm32 reqwest uses `fetch` and doesn't expose `.timeout()`, + // `.connect_timeout()` or `.pool_idle_timeout()` — the host runtime + // owns request lifetime and connection pooling. The corresponding + // `with_*` builder methods are documented as no-ops on wasm32. + #[cfg(not(target_arch = "wasm32"))] + let builder = { + let mut b = builder.timeout(Duration::from_secs( + opts.request_timeout.unwrap_or(REQUEST_TIMEOUT_SECS), + )); + if let Some(connect_timeout) = opts.connect_timeout { + b = b.connect_timeout(Duration::from_secs(connect_timeout)); + } + if let Some(pool_idle_timeout) = opts.pool_idle_timeout { + b = b.pool_idle_timeout(Duration::from_secs(pool_idle_timeout)); + } + b + }; + #[cfg(target_arch = "wasm32")] + let _ = ( + opts.request_timeout, + opts.connect_timeout, + opts.pool_idle_timeout, + ); + + let client = builder.build()?; + + let base_url = OnceLock::new(); + if let Some(url) = opts.base_url { + // Pre-fill when an explicit URL was provided at build time. + let _ = base_url.set(url); + } + + Ok(Self { base_url, client }) + } +} + +impl ZeroKMSConnection for HttpConnection { + async fn send( + &self, + request: Request, + access_token: &str, + ) -> Result { + let body = to_vec(&request) + .map_err(|e| ViturRequestError::prepare("Failed to serialize request", e))?; + + let base_url = self.base_url.get().ok_or_else(|| { + ViturRequestError::new( + ViturRequestErrorKind::Unauthorized, + "ZeroKMS base URL was not resolved from the token's services claim", + Unauthorized, + ) + })?; + + let url = base_url + .join(Request::ENDPOINT) + .map_err(|e| ViturRequestError::prepare("Failed to construct request URL", e))?; + + let response = self + .client + .post(url.as_str()) + .body(body.clone()) + .header("content-type", "application/json") + .bearer_auth(access_token) + .send() + .await + .map_err(|e| ViturRequestError::send("Failed to send request", e))?; + + let status = response.status(); + + if status.is_success() { + // Ok response + let content_type = response + .headers() + .get("content-type") + .and_then(|x| x.to_str().ok()); + + let expected = "application/json"; + + if content_type != Some(expected) { + return Err(ViturRequestError::parse( + "Invalid content type header", + UnexpectedError { + received: content_type.map(|x| x.into()), + expected, + headers: header_map_to_hash(response.headers()), + body: response.text().await.ok(), + }, + )); + } + + let response_bytes = response.bytes().await.map_err(|e| { + ViturRequestError::parse("Failed to read response body as bytes", e) + })?; + + from_reader(&response_bytes[..]) + .map_err(|e| ViturRequestError::parse("Failed to deserialize response body", e)) + } else { + // Error handling + let failure = FailureResponse::from_response(response).await; + + let err = match status { + StatusCode::NOT_FOUND => { + failure.into_vitur_error(ViturRequestErrorKind::NotFound, "Resource not found") + } + StatusCode::UNAUTHORIZED => failure + .into_vitur_error(ViturRequestErrorKind::Unauthorized, "Request unauthorized"), + StatusCode::FORBIDDEN => { + failure.into_vitur_error(ViturRequestErrorKind::Forbidden, "Request forbidden") + } + StatusCode::CONFLICT => { + failure.into_vitur_error(ViturRequestErrorKind::Conflict, "Resource conflict") + } + _ => ViturRequestError::other("Server returned failure response", failure), + }; + + Err(err) + } + } +} diff --git a/packages/stack-kms/src/errors.rs b/packages/stack-kms/src/errors.rs new file mode 100644 index 000000000..739341f8a --- /dev/null +++ b/packages/stack-kms/src/errors.rs @@ -0,0 +1,72 @@ +use miette::Diagnostic; +use recipher::errors::RecipherError; +use thiserror::Error; +use zerokms_protocol::{ViturRequestError, ViturRequestErrorKind}; + +#[derive(Diagnostic, Error, Debug)] +pub enum RetrieveKeyError { + #[error("Failed to send request: {0}")] + RequestFailed(#[from] ViturRequestError), + #[error("Received an invalid number of keys from request. Expected {expected} but received {received}")] + InvalidNumberOfKeys { expected: usize, received: usize }, + + /// Represents an error that occurs when a single key retrieval fails. + /// May be part of a batch retrieval operation. + #[error("Failed to retrieve key: {0}")] + FailedRetrieval(String), +} + +#[derive(Diagnostic, Error, Debug)] +pub enum GenerateKeyError { + #[error("Request not authorized")] + Unauthorized, + #[error("Request forbidden due to insufficient permissions")] + Forbidden, + #[error("Failed to generate IV: {0}")] + GenerateIv(RecipherError), + #[error("Received an invalid number of keys from request. Expected {expected} but received {received}")] + InvalidNumberOfKeys { expected: usize, received: usize }, + // Catch-all for any `ViturRequestError` not classified as Forbidden / + // Unauthorized above. Display surfaces the `kind` (operational enum + // — `SendRequest`, `Other`, `ParseResponse`, ...) and `message` + // (`&'static str`, build-time only, no dynamic data) so the bare + // failure mode is visible. The dynamic `error: ShareableError` field + // is reachable through `Error::source()` via `#[source]`, so callers + // using anyhow chain formatting (`{:?}` / `{:#}`) or `tracing` get the + // underlying transport / response error; the Display string itself + // stays free of dynamic data. + #[error("Unexpected error ({}: {})", .0.kind, .0.message)] + RequestFailed(#[source] ViturRequestError), +} + +impl From for GenerateKeyError { + fn from(err: ViturRequestError) -> Self { + match err.kind { + ViturRequestErrorKind::Forbidden => Self::Forbidden, + ViturRequestErrorKind::Unauthorized => Self::Unauthorized, + _ => Self::RequestFailed(err), + } + } +} + +/// Top-level error for high-level [`StackKms`](crate::StackKms) key operations. +#[derive(Error, Debug, Diagnostic)] +pub enum Error { + #[error(transparent)] + #[diagnostic(transparent)] + GenerateKey(#[from] GenerateKeyError), + + #[error(transparent)] + #[diagnostic(transparent)] + RetrieveKey(#[from] RetrieveKeyError), + + #[error(transparent)] + #[diagnostic(transparent)] + Auth(#[from] stack_auth::AuthError), + + #[error(transparent)] + ConnectionInit(#[from] crate::connection::ConnectionInitError), + + #[error("Unexpected error: {0}")] + Unexpected(String), +} diff --git a/packages/stack-kms/src/futures.rs b/packages/stack-kms/src/futures.rs new file mode 100644 index 000000000..2ddd9892a --- /dev/null +++ b/packages/stack-kms/src/futures.rs @@ -0,0 +1,70 @@ +use futures::StreamExt; +use std::future::Future; + +/** + * Chunk an input slice and run an async callback on each of the chunks. + * The futures generated by that callback are run concurrently with their results returned in a + * vector. + */ +pub async fn map_async_chunked< + 'a, + T: Send + Sync, + U, + E, + F: Future, E>>, + C: Send + Sync + FnMut(&'a [T]) -> F, +>( + input: &'a [T], + callback: C, + chunk_size: usize, + concurrent_futs: usize, +) -> Result, E> { + let mut output = Vec::with_capacity(input.len()); + + let mut stream = futures::stream::iter(input.chunks(chunk_size).map(callback)) + .boxed() + .buffered(concurrent_futs); + + while let Some(result) = stream.next().await { + output.append(&mut result?); + } + + Ok(output) +} + +#[cfg(test)] +mod tests { + use std::time::Duration; + + use super::*; + + #[tokio::test] + async fn test_keeps_the_same_order() { + let input = vec![200, 100, 50, 25, 20, 15, 10, 5, 4, 3, 2, 1]; + + let output = map_async_chunked( + &input, + |x| async { + tokio::time::sleep(Duration::from_millis(input[0])).await; + Result::<_, ()>::Ok(x.to_vec()) + }, + 2, + 10, + ) + .await + .unwrap(); + + assert_eq!(input, output); + } + + #[tokio::test] + async fn test_works_when_chunks_dont_divide_nicely() { + let input = vec![1, 2, 3, 4, 5, 6, 7, 8, 9, 10]; + + let output = map_async_chunked(&input, |x| async { Result::<_, ()>::Ok(x.to_vec()) }, 3, 1) + .await + .unwrap(); + + assert_eq!(input, output); + } +} diff --git a/packages/stack-kms/src/key.rs b/packages/stack-kms/src/key.rs new file mode 100644 index 000000000..88ce663dc --- /dev/null +++ b/packages/stack-kms/src/key.rs @@ -0,0 +1,227 @@ +pub use recipher::{ + cipher::ProxyCipher, + key::{Iv, Key}, + keyset::ProxyKeySet as KeySet, +}; + +use serde::{Deserialize, Deserializer, Serialize, Serializer}; +use sha2::{Digest, Sha256}; +use std::ops::Deref; +use uuid::Uuid; +use zeroize::{Zeroize, ZeroizeOnDrop}; +use zerokms_protocol::{DecryptionPolicy, ViturKeyMaterial}; + +/// NOTE: Debug is safe to implement because [KeySet] is opaque. +#[derive(Debug, Deserialize, Clone, Zeroize, ZeroizeOnDrop, Serialize)] +pub struct ClientKey { + #[zeroize(skip)] + #[serde(rename = "client_id")] + pub key_id: Uuid, + + #[serde(rename = "client_key")] + pub keyset: V1KeySet, +} + +impl ClientKey { + pub fn new_v1(key_id: Uuid, keyset: KeySet) -> Self { + Self { + key_id, + keyset: V1KeySet(keyset), + } + } + + pub fn to_hex_v1(&self) -> serde_cbor::Result { + self.keyset.to_hex() + } + + pub fn from_bytes(key_id: Uuid, bytes: &[u8]) -> serde_cbor::Result { + Ok(Self { + key_id, + keyset: KeySet::from_bytes(bytes).map(V1KeySet)?, + }) + } + + pub fn from_hex_v1(key_id: Uuid, hex: &str) -> serde_cbor::Result { + Ok(Self { + key_id, + keyset: V1KeySet::from_hex(hex)?, + }) + } +} + +// FIXME: This shouldn't be Clone but it is needed right now for the JSONB indexer. +#[derive(PartialEq, Eq, Zeroize, ZeroizeOnDrop, Clone)] +#[cfg_attr(test, derive(Default))] +pub struct DataKey { + pub iv: Iv, + pub key: Key, +} +opaque_debug::implement!(DataKey); + +impl DataKey { + /// Create a DataKey for a specific [`ClientKey`] given a specific initialisation vector + /// (IV) and key material obtained from ZeroKMS. + pub fn from_key_material(key: &ClientKey, iv: Iv, key_material: &ViturKeyMaterial) -> Self { + let cipher = ProxyCipher::new(key.keyset.keyset()); + let rect = cipher.reencrypt::<16>(&iv, key_material); + + let mut hasher = Sha256::new(); + hasher.update(&rect); + + DataKey { + iv, + key: hasher.finalize().into(), + } + } + + pub fn key(&self) -> &Key { + &self.key + } +} + +// FIXME: Making this Cloneable for now so that we can use the same key many times for the JSONB indexer. +// We should modifier the indexer so each value has a separate key. +#[derive(PartialEq, Eq, Clone)] +#[cfg_attr(test, derive(Default))] +pub struct DataKeyWithTag { + pub key: DataKey, + pub tag: Vec, + pub decryption_policy: Option, +} +opaque_debug::implement!(DataKeyWithTag); + +impl DataKeyWithTag { + /// Create a DataKey for a specific [`ClientKey`] given a specific IV, key material and tag + /// obtained from ZeroKMS. + pub fn from_key_material( + key: &ClientKey, + iv: Iv, + key_material: &ViturKeyMaterial, + tag: Vec, + decryption_policy: Option, + ) -> Self { + Self { + key: DataKey::from_key_material(key, iv, key_material), + tag, + decryption_policy, + } + } +} + +impl Deref for DataKeyWithTag { + type Target = DataKey; + + fn deref(&self) -> &Self::Target { + &self.key + } +} + +#[derive(Debug, Clone, Zeroize, ZeroizeOnDrop)] +pub struct V1KeySet(pub(crate) KeySet); + +impl V1KeySet { + pub fn from_bytes(bytes: &[u8]) -> serde_cbor::Result { + KeySet::from_bytes(bytes).map(Self) + } + + pub(crate) fn to_hex(&self) -> serde_cbor::Result { + self.0.to_bytes().map(|mut bytes| { + let hex = base16ct::lower::encode_string(&bytes); + bytes.zeroize(); + hex + }) + } + + pub(crate) fn from_hex(hex: &str) -> serde_cbor::Result { + let mut bytes = base16ct::lower::decode_vec(hex).map_err(|e| { + ::custom(format!("invalid hex: {e}")) + })?; + let result = Self::from_bytes(&bytes); + bytes.zeroize(); + result + } + + pub(crate) fn keyset(&self) -> &KeySet { + &self.0 + } +} + +impl Serialize for V1KeySet { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + let bytes = self.0.to_bytes().map_err(serde::ser::Error::custom)?; + serdect::slice::serialize_hex_lower_or_bin(&bytes, serializer) + } +} + +impl<'de> Deserialize<'de> for V1KeySet { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + // CBOR encoded keyset is 168 bytes + let mut buffer = [0; 168]; + serdect::array::deserialize_hex_or_bin(&mut buffer, deserializer)?; + let keyset = KeySet::from_bytes(&buffer).map_err(serde::de::Error::custom)?; + buffer.zeroize(); + + Ok(Self(keyset)) + } +} + +#[cfg(test)] +mod tests { + use super::{ClientKey, DataKey}; + use recipher::keyset::{EncryptionKeySet, ProxyKeySet}; + + #[test] + fn test_opaque_debug_datakey() { + let key = DataKey { + iv: [0; 16], + key: [0; 32], + }; + assert_eq!(format!("{key:?}"), "DataKey { ... }"); + } + + #[test] + fn test_v1_keyset_serde() { + let ek_a = EncryptionKeySet::generate().unwrap(); + let ek_b = EncryptionKeySet::generate().unwrap(); + let keyset = ProxyKeySet::generate(&ek_a, &ek_b); + let v1_keyset = super::V1KeySet(keyset); + + let serialized = serde_json::to_string(&v1_keyset).unwrap(); + let deserialized: super::V1KeySet = serde_json::from_str(&serialized).unwrap(); + + // The current key implementation doesn't implement PartialEq because it can't do it safely. + assert_eq!( + v1_keyset.0.to_bytes().unwrap(), + deserialized.0.to_bytes().unwrap() + ); + } + + #[test] + fn test_client_key_toml() { + let ek_a = EncryptionKeySet::generate().unwrap(); + let ek_b = EncryptionKeySet::generate().unwrap(); + let keyset = ProxyKeySet::generate(&ek_a, &ek_b); + let key_id = uuid::Uuid::new_v4(); + let client_key = ClientKey::new_v1(key_id, keyset); + + let toml = toml::to_string(&client_key).unwrap(); + + let mut table = toml::Table::new(); + table.insert( + String::from("client_id"), + toml::Value::String(key_id.to_string()), + ); + table.insert( + String::from("client_key"), + toml::Value::String(client_key.to_hex_v1().unwrap()), + ); + + assert_eq!(toml, table.to_string()); + } +} diff --git a/packages/stack-kms/src/key_provider.rs b/packages/stack-kms/src/key_provider.rs new file mode 100644 index 000000000..c64f2a664 --- /dev/null +++ b/packages/stack-kms/src/key_provider.rs @@ -0,0 +1,548 @@ +//! Trait and implementations for loading a [`ClientKey`] from various sources. +//! +//! A [`KeyProvider`] is the single required input when building a key client that needs +//! to generate or retrieve data keys. Each provider yields a complete [`ClientKey`] +//! (client ID + key material) from a self-contained source. +//! +//! # Built-in providers +//! +//! | Provider | Source | +//! |----------|--------| +//! | [`EnvKeyProvider`] | `CS_CLIENT_ID` + `CS_CLIENT_KEY` environment variables | +//! | [`StaticKeyProvider`] | Wraps a [`ClientKey`] directly | +//! | [`FallbackKeyProvider`] | Tries a primary provider, falls back on [`KeyProviderError::NotConfigured`] | +//! +//! # Example +//! +//! A common pattern is to check for explicit env vars, derive an `Option`, +//! and fall back to [`EnvKeyProvider`] when they are absent: +//! +//! ```no_run +//! use stack_kms::{ +//! SecretKey, FallbackKeyProvider, EnvKeyProvider, KeyProvider, +//! }; +//! +//! # async fn example() { +//! let explicit_key = SecretKey::from_env().expect("invalid key material in env"); +//! +//! // If `explicit_key` is None the fallback provider kicks in. +//! let provider = FallbackKeyProvider::new(explicit_key, EnvKeyProvider); +//! let client_key = provider.client_key().await.unwrap(); +//! # } +//! ``` + +use crate::vars::{CS_CLIENT_ID, CS_CLIENT_KEY}; +use std::future::Future; +use thiserror::Error; +use uuid::Uuid; +use zeroize::Zeroize; + +use crate::key::ClientKey; +use crate::secret_key::decode_client_key_material; + +/// Errors that can occur when loading a [`ClientKey`] from a [`KeyProvider`]. +#[derive(Debug, Error)] +pub enum KeyProviderError { + /// The provider has no key configured (e.g. env vars not set). + /// + /// [`FallbackKeyProvider`] uses this variant to decide whether to try the next provider. + #[error("Client key not configured: {0}")] + NotConfigured(String), + + /// Key material was found but is invalid (e.g. bad hex encoding). + #[error("Invalid client key: {0}")] + InvalidKey(String), + + /// An I/O or other runtime error prevented loading the key. + #[error("Failed to load client key: {0}")] + LoadError(String), +} + +/// A source of [`ClientKey`] credentials for ZeroKMS. +/// +/// Implementations must be `Send + Sync + 'static` so they can be stored in the builder +/// and used across async contexts. +/// +/// # Example +/// +/// ``` +/// use stack_kms::{KeyProvider, KeyProviderError, ClientKey, StaticKeyProvider}; +/// use uuid::Uuid; +/// +/// # async fn example() -> Result<(), KeyProviderError> { +/// let client_key = ClientKey::from_hex_v1( +/// Uuid::nil(), +/// // ... hex-encoded key material +/// # "0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000" +/// ).unwrap(); +/// +/// let provider = StaticKeyProvider::new(client_key); +/// let key = provider.client_key().await?; +/// # Ok(()) +/// # } +/// ``` +pub trait KeyProvider: Send + Sync + 'static { + /// Load a [`ClientKey`] from this provider. + fn client_key(&self) -> impl Future> + Send; +} + +/// Loads a [`ClientKey`] from `CS_CLIENT_ID` and `CS_CLIENT_KEY` environment variables. +/// +/// Returns [`KeyProviderError::NotConfigured`] if either variable is unset, +/// or [`KeyProviderError::InvalidKey`] if the values cannot be parsed. +/// +/// # Example +/// +/// ```no_run +/// use stack_kms::{EnvKeyProvider, KeyProvider}; +/// +/// # async fn example() { +/// let provider = EnvKeyProvider; +/// let key = provider.client_key().await.expect("env vars must be set"); +/// # } +/// ``` +pub struct EnvKeyProvider; + +impl EnvKeyProvider { + /// Parse `CS_CLIENT_ID` and `CS_CLIENT_KEY`. `CS_CLIENT_KEY` is decoded leniently: + /// either hex (the historical format) or standard padded base64 (the form that + /// `secretkey.json` writes to disk) is accepted. + fn parse(client_id: &str, client_key: &str) -> Result { + let uuid = Uuid::parse_str(client_id) + .map_err(|e| KeyProviderError::InvalidKey(format!("invalid {CS_CLIENT_ID}: {e}")))?; + + let mut bytes = decode_client_key_material(client_key) + .map_err(|e| KeyProviderError::InvalidKey(format!("invalid {CS_CLIENT_KEY}: {e}")))?; + + let result = ClientKey::from_bytes(uuid, &bytes) + .map_err(|e| KeyProviderError::InvalidKey(format!("invalid {CS_CLIENT_KEY}: {e}"))); + bytes.zeroize(); + result + } +} + +impl KeyProvider for EnvKeyProvider { + async fn client_key(&self) -> Result { + let client_id = std::env::var(CS_CLIENT_ID).map_err(|_| { + KeyProviderError::NotConfigured(format!("{CS_CLIENT_ID} environment variable not set")) + })?; + + let mut client_key = std::env::var(CS_CLIENT_KEY).map_err(|_| { + KeyProviderError::NotConfigured(format!("{CS_CLIENT_KEY} environment variable not set")) + })?; + + tracing::debug!("loading client key from environment variables"); + let result = Self::parse(&client_id, &client_key); + client_key.zeroize(); + result + } +} + +/// Wraps an existing [`ClientKey`] as a [`KeyProvider`]. +/// +/// Useful for tests or when the key is already available programmatically. +/// +/// # Example +/// +/// ``` +/// use stack_kms::{StaticKeyProvider, KeyProvider, ClientKey}; +/// use uuid::Uuid; +/// +/// # async fn example() { +/// # let client_key = ClientKey::from_hex_v1( +/// # Uuid::nil(), +/// # "0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000" +/// # ).unwrap(); +/// let provider = StaticKeyProvider::new(client_key); +/// let key = provider.client_key().await.unwrap(); +/// # } +/// ``` +pub struct StaticKeyProvider(ClientKey); + +impl StaticKeyProvider { + /// Create a new [`StaticKeyProvider`] wrapping the given key. + pub fn new(key: ClientKey) -> Self { + Self(key) + } +} + +impl KeyProvider for StaticKeyProvider { + async fn client_key(&self) -> Result { + Ok(self.0.clone()) + } +} + +/// Wraps an `Option` as a [`KeyProvider`]. +/// +/// - `Some(provider)` delegates to the inner provider. +/// - `None` returns [`KeyProviderError::NotConfigured`], which makes it compose +/// naturally with [`FallbackKeyProvider`] — a `None` primary triggers the fallback. +/// +/// # Example +/// +/// ``` +/// use stack_kms::{ +/// FallbackKeyProvider, StaticKeyProvider, KeyProvider, ClientKey, +/// }; +/// use uuid::Uuid; +/// +/// # async fn example() { +/// # let fallback_key = ClientKey::from_hex_v1( +/// # Uuid::nil(), +/// # "0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000" +/// # ).unwrap(); +/// // None means "no explicit key" — falls through to the profile store +/// let explicit_key: Option = None; +/// let provider = FallbackKeyProvider::new( +/// explicit_key, +/// StaticKeyProvider::new(fallback_key), +/// ); +/// let key = provider.client_key().await.unwrap(); +/// # } +/// ``` +impl KeyProvider for Option { + async fn client_key(&self) -> Result { + match self { + Some(provider) => provider.client_key().await, + None => Err(KeyProviderError::NotConfigured( + "no explicit key provided".into(), + )), + } + } +} + +/// Tries a primary [`KeyProvider`], falling back to a secondary provider +/// when the primary returns [`KeyProviderError::NotConfigured`]. +/// +/// Other error variants ([`KeyProviderError::InvalidKey`], [`KeyProviderError::LoadError`]) +/// are **not** retried — they indicate the provider was found but broken. +/// +/// # Example +/// +/// ``` +/// use stack_kms::{ +/// EnvKeyProvider, StaticKeyProvider, FallbackKeyProvider, KeyProvider, ClientKey, +/// }; +/// use uuid::Uuid; +/// +/// # async fn example() { +/// # let fallback_key = ClientKey::from_hex_v1( +/// # Uuid::nil(), +/// # "0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000" +/// # ).unwrap(); +/// // Try env vars first, fall back to a static key +/// let provider = FallbackKeyProvider::new( +/// EnvKeyProvider, +/// StaticKeyProvider::new(fallback_key), +/// ); +/// let key = provider.client_key().await.unwrap(); +/// # } +/// ``` +pub struct FallbackKeyProvider { + primary: P, + fallback: F, +} + +impl FallbackKeyProvider { + /// Create a new [`FallbackKeyProvider`] with the given primary and fallback providers. + pub fn new(primary: P, fallback: F) -> Self { + Self { primary, fallback } + } +} + +impl KeyProvider for FallbackKeyProvider { + async fn client_key(&self) -> Result { + match self.primary.client_key().await { + Ok(key) => { + tracing::debug!("using primary key provider"); + Ok(key) + } + Err(KeyProviderError::NotConfigured(_)) => { + tracing::debug!("primary key provider not configured, trying fallback"); + self.fallback.client_key().await + } + Err(e) => Err(e), + } + } +} + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod tests { + use super::*; + use recipher::keyset::{EncryptionKeySet, ProxyKeySet}; + + fn random_client_key() -> ClientKey { + let ek_a = EncryptionKeySet::generate().unwrap(); + let ek_b = EncryptionKeySet::generate().unwrap(); + let keyset = ProxyKeySet::generate(&ek_a, &ek_b); + ClientKey::new_v1(Uuid::new_v4(), keyset) + } + + /// A [`KeyProvider`] that always returns [`KeyProviderError::NotConfigured`]. + struct NotConfiguredProvider; + + impl KeyProvider for NotConfiguredProvider { + async fn client_key(&self) -> Result { + Err(KeyProviderError::NotConfigured("not configured".into())) + } + } + + /// A [`KeyProvider`] that always returns [`KeyProviderError::InvalidKey`]. + struct InvalidKeyProvider; + + impl KeyProvider for InvalidKeyProvider { + async fn client_key(&self) -> Result { + Err(KeyProviderError::InvalidKey("bad key".into())) + } + } + + mod static_provider { + use super::*; + + #[tokio::test] + async fn returns_the_wrapped_key() { + let expected_id = Uuid::new_v4(); + let ek_a = EncryptionKeySet::generate().unwrap(); + let ek_b = EncryptionKeySet::generate().unwrap(); + let keyset = ProxyKeySet::generate(&ek_a, &ek_b); + let client_key = ClientKey::new_v1(expected_id, keyset); + + let provider = StaticKeyProvider::new(client_key); + let result = provider.client_key().await.unwrap(); + + assert_eq!( + result.key_id, expected_id, + "should return the same key_id that was provided" + ); + } + } + + mod env_provider_parse { + use super::*; + + #[test] + fn returns_client_key_for_valid_inputs() { + let key = random_client_key(); + let uuid = key.key_id; + let hex = key.to_hex_v1().unwrap(); + + let result = EnvKeyProvider::parse(&uuid.to_string(), &hex).unwrap(); + + assert_eq!( + result.key_id, uuid, + "parsed key should have the same client_id" + ); + } + + mod given_invalid_uuid { + use super::*; + + #[test] + fn returns_invalid_key_error() { + let err = EnvKeyProvider::parse("not-a-uuid", "deadbeef").unwrap_err(); + + assert!( + matches!(err, KeyProviderError::InvalidKey(_)), + "expected InvalidKey for bad UUID, got: {err:?}" + ); + } + } + + mod given_valid_uuid_but_wrong_key_length { + use super::*; + + #[test] + fn returns_invalid_key_error() { + let uuid = Uuid::new_v4(); + // Valid hex but too short to be a valid keyset + let err = EnvKeyProvider::parse(&uuid.to_string(), "deadbeef").unwrap_err(); + + assert!( + matches!(err, KeyProviderError::InvalidKey(_)), + "expected InvalidKey for truncated key material, got: {err:?}" + ); + } + } + + mod given_invalid_encoding { + use super::*; + + #[test] + fn returns_invalid_key_error() { + let uuid = Uuid::new_v4(); + // `!!` is rejected by both hex and base64 decoders + let err = + EnvKeyProvider::parse(&uuid.to_string(), "not-valid-anything!!").unwrap_err(); + + assert!( + matches!(err, KeyProviderError::InvalidKey(_)), + "expected InvalidKey for unrecognised encoding, got: {err:?}" + ); + } + } + + mod given_base64_encoded_key { + use super::*; + use base64ct::Encoding; + + #[test] + fn returns_client_key_matching_hex_form() { + let key = random_client_key(); + let uuid = key.key_id; + let hex = key.to_hex_v1().unwrap(); + let bytes = base16ct::mixed::decode_vec(&hex).unwrap(); + let b64 = base64ct::Base64::encode_string(&bytes); + + let from_b64 = EnvKeyProvider::parse(&uuid.to_string(), &b64).unwrap(); + + assert_eq!( + from_b64.key_id, uuid, + "base64 input should decode to the same client_id" + ); + assert_eq!( + from_b64.to_hex_v1().unwrap(), + hex, + "base64 input should decode to the same key material as hex" + ); + } + } + } + + mod fallback_provider { + use super::*; + + mod given_primary_succeeds { + use super::*; + + #[tokio::test] + async fn returns_primary_key() { + let primary_key = random_client_key(); + let primary_id = primary_key.key_id; + let fallback_key = random_client_key(); + + let provider = FallbackKeyProvider::new( + StaticKeyProvider::new(primary_key), + StaticKeyProvider::new(fallback_key), + ); + + let result = provider.client_key().await.unwrap(); + + assert_eq!( + result.key_id, primary_id, + "should return the primary provider's key" + ); + } + } + + mod given_primary_not_configured { + use super::*; + + #[tokio::test] + async fn returns_fallback_key() { + let fallback_key = random_client_key(); + let fallback_id = fallback_key.key_id; + + let provider = FallbackKeyProvider::new( + NotConfiguredProvider, + StaticKeyProvider::new(fallback_key), + ); + + let result = provider.client_key().await.unwrap(); + + assert_eq!( + result.key_id, fallback_id, + "should fall through to the secondary provider" + ); + } + } + + mod given_primary_returns_invalid_key { + use super::*; + + #[tokio::test] + async fn does_not_fall_through() { + let fallback_key = random_client_key(); + + let provider = FallbackKeyProvider::new( + InvalidKeyProvider, + StaticKeyProvider::new(fallback_key), + ); + + let err = provider.client_key().await.unwrap_err(); + + assert!( + matches!(err, KeyProviderError::InvalidKey(_)), + "should propagate InvalidKey without trying fallback, got: {err:?}" + ); + } + } + } + + mod option_provider { + use super::*; + + #[tokio::test] + async fn some_delegates_to_inner() { + let key = random_client_key(); + let expected_id = key.key_id; + let provider: Option = Some(StaticKeyProvider::new(key)); + + let result = provider.client_key().await.unwrap(); + + assert_eq!( + result.key_id, expected_id, + "Some(provider) should delegate to the inner provider" + ); + } + + #[tokio::test] + async fn none_returns_not_configured() { + let provider: Option = None; + + let err = provider.client_key().await.unwrap_err(); + + assert!( + matches!(err, KeyProviderError::NotConfigured(_)), + "None should return NotConfigured, got: {err:?}" + ); + } + + #[tokio::test] + async fn none_triggers_fallback() { + let fallback_key = random_client_key(); + let fallback_id = fallback_key.key_id; + + let provider = FallbackKeyProvider::new( + Option::::None, + StaticKeyProvider::new(fallback_key), + ); + + let result = provider.client_key().await.unwrap(); + + assert_eq!( + result.key_id, fallback_id, + "None primary should trigger fallback" + ); + } + + #[tokio::test] + async fn some_prevents_fallback() { + let primary_key = random_client_key(); + let primary_id = primary_key.key_id; + let fallback_key = random_client_key(); + + let provider = FallbackKeyProvider::new( + Some(StaticKeyProvider::new(primary_key)), + StaticKeyProvider::new(fallback_key), + ); + + let result = provider.client_key().await.unwrap(); + + assert_eq!( + result.key_id, primary_id, + "Some primary should prevent fallback" + ); + } + } +} diff --git a/packages/stack-kms/src/lib.rs b/packages/stack-kms/src/lib.rs new file mode 100644 index 000000000..b9db66bb6 --- /dev/null +++ b/packages/stack-kms/src/lib.rs @@ -0,0 +1,84 @@ +#![doc(html_favicon_url = "https://cipherstash.com/favicon.ico")] +//! `stack-kms` is a focused client for ZeroKMS **data key** operations: +//! generating new data keys and retrieving existing ones. +//! +//! It is an extraction of the key-generation/retrieval slice of +//! `cipherstash-client`'s `zerokms` module into a standalone crate. This first +//! cut deliberately covers only the two operations: +//! +//! * [`StackKms::generate_keys`] — derive fresh data keys (with tags) from ZeroKMS +//! * [`StackKms::retrieve_keys`] / [`StackKms::retrieve_keys_fallible`] — re-derive +//! data keys for previously encrypted records +//! +//! Encryption/decryption, keyset and client management, and config save/load +//! all remain in `cipherstash-client` for now. +//! +//! # Quick start +//! +//! ```no_run +//! use stack_kms::{StackKmsBuilder, GenerateKeyPayload}; +//! use std::borrow::Cow; +//! +//! # async fn example() -> Result<(), Box> { +//! // Credentials + client key are discovered from the environment: +//! // CS_CLIENT_ID / CS_CLIENT_KEY for the key, AutoStrategy for the token. +//! let kms = StackKmsBuilder::auto()? +//! .with_key_provider(stack_kms::EnvKeyProvider) +//! .build() +//! .await?; +//! +//! let keys = kms +//! .generate_keys( +//! [GenerateKeyPayload::new("users/email", Cow::Owned(vec![]))], +//! None, +//! None, +//! ) +//! .await?; +//! +//! assert_eq!(keys.len(), 1); +//! # Ok(()) +//! # } +//! ``` + +mod builder; +mod client; +mod connection; +mod errors; +mod futures; +mod key; +mod key_provider; +mod payload; +mod secret_key; +mod user_agent; +pub mod vars; + +// Builder +pub use builder::{StackKmsBuilder, StackKmsBuilderError, WithKeyProvider}; + +// Clients +pub use client::{Client, ClientOpts, FallibleDataKeyVec, StackKms}; + +// Transport +pub use connection::{ + ConnectionInitError, HttpConnection, HttpConnectionOpts, ZeroKMSConnection, + ZeroKMSConnectionInit, +}; + +// Errors +pub use errors::{Error, GenerateKeyError, RetrieveKeyError}; + +// Key material +pub use key::{ClientKey, DataKey, DataKeyWithTag, V1KeySet}; + +// Key providers +pub use key_provider::{ + EnvKeyProvider, FallbackKeyProvider, KeyProvider, KeyProviderError, StaticKeyProvider, +}; +pub use secret_key::SecretKey; + +// Operation payloads +pub use payload::{GenerateKeyPayload, RetrieveKeyPayload}; + +// Commonly needed re-exports from the protocol / crypto layers +pub use recipher::key::{GenRandom, Iv}; +pub use zerokms_protocol::{Context, DecryptionPolicy, KeyId, UnverifiedContext, ViturKeyMaterial}; diff --git a/packages/stack-kms/src/payload.rs b/packages/stack-kms/src/payload.rs new file mode 100644 index 000000000..f7160c2ee --- /dev/null +++ b/packages/stack-kms/src/payload.rs @@ -0,0 +1,77 @@ +use recipher::key::Iv; +use std::borrow::Cow; +use zerokms_protocol::{Context, DecryptionPolicy, KeyId, RetrieveKeySpec}; + +/// The requirements for generating a data key from ZeroKMS. +#[derive(Clone)] +pub struct GenerateKeyPayload<'a> { + pub descriptor: &'a str, + pub(crate) context: Cow<'a, [Context]>, + pub decryption_policy: Option, +} + +impl<'a> GenerateKeyPayload<'a> { + /// Create a new [`GenerateKeyPayload`] with the given descriptor and context. + pub fn new(descriptor: &'a str, context: Cow<'a, [Context]>) -> Self { + Self { + descriptor, + context, + decryption_policy: None, + } + } + + pub fn with_decryption_policy(mut self, policy: DecryptionPolicy) -> Self { + self.decryption_policy = Some(policy); + self + } +} + +/// The requirements for retrieving a data key from ZeroKMS. +pub struct RetrieveKeyPayload<'a> { + pub iv: KeyId, + pub descriptor: &'a str, + pub tag: &'a [u8], + pub context: Cow<'a, [Context]>, + pub decryption_policy: Option, +} + +impl<'a> RetrieveKeyPayload<'a> { + /// Create a new [`RetrieveKeyPayload`] with the given IV, descriptor, and tag. + pub fn new(iv: Iv, descriptor: &'a str, tag: &'a [u8]) -> Self { + Self { + iv: KeyId::from(iv), + descriptor, + tag, + context: Default::default(), + decryption_policy: None, + } + } + + pub fn with_context(mut self, context: Cow<'a, [Context]>) -> Self { + self.context = context; + self + } + + pub fn with_decryption_policy(mut self, policy: DecryptionPolicy) -> Self { + self.decryption_policy = Some(policy); + self + } +} + +impl<'a> From> for RetrieveKeySpec<'a> { + fn from( + RetrieveKeyPayload { + iv, + descriptor, + tag, + context, + decryption_policy, + }: RetrieveKeyPayload<'a>, + ) -> Self { + let mut spec = Self::new(iv, tag, descriptor).with_context(context); + if let Some(policy) = decryption_policy { + spec = spec.with_policy(policy); + } + spec + } +} diff --git a/packages/stack-kms/src/secret_key.rs b/packages/stack-kms/src/secret_key.rs new file mode 100644 index 000000000..e8a9cbd65 --- /dev/null +++ b/packages/stack-kms/src/secret_key.rs @@ -0,0 +1,390 @@ +use base64ct::Encoding; +use serde::{Deserialize, Serialize}; +#[cfg(not(target_arch = "wasm32"))] +use stack_profile::{ProfileData, ProfileError, ProfileStore}; +use uuid::Uuid; +use vitaminc::protected::OpaqueDebug; +use zeroize::{Zeroize, ZeroizeOnDrop}; +use zerokms_protocol::ViturKeyMaterial; + +use crate::key::ClientKey; +use crate::key_provider::{KeyProvider, KeyProviderError}; + +/// Decode client key material accepting either hex (preferred) or standard padded base64. +/// +/// `secretkey.json` serialises key material as base64 (via `ViturKeyMaterial`'s serde), +/// while `CS_CLIENT_KEY` has historically been hex. Tolerating either at parse time +/// means users can copy-paste between the two without re-encoding. +/// +/// Tries hex first via `base16ct::mixed::decode_vec` (constant-time). Falls back to +/// `base64ct::Base64::decode_vec` (also constant-time) only if the hex parse fails — +/// which it does for any string containing `+`, `/`, `=`, or other non-hex chars. +pub(crate) fn decode_client_key_material(s: &str) -> Result, String> { + if let Ok(bytes) = base16ct::mixed::decode_vec(s) { + return Ok(bytes); + } + base64ct::Base64::decode_vec(s) + .map_err(|e| format!("invalid encoding (expected hex or base64): {e}")) +} + +/// A device-scoped client key, stored in `secretkey.json` within the profile directory. +/// +/// The key material is zeroized on drop and hidden from debug output. +/// +/// # Example +/// +/// ```no_run +/// # async fn example() -> Result<(), Box> { +/// use stack_kms::{SecretKey, KeyProvider}; +/// use zerokms_protocol::ViturKeyMaterial; +/// use uuid::Uuid; +/// +/// let key_material: ViturKeyMaterial = vec![/* key bytes */].into(); +/// let secret_key = SecretKey::new( +/// Uuid::new_v4(), +/// key_material, +/// ); +/// +/// // SecretKey implements KeyProvider +/// let client_key = secret_key.client_key().await?; +/// # Ok(()) +/// # } +/// ``` +#[derive(Serialize, Deserialize, Zeroize, ZeroizeOnDrop, OpaqueDebug)] +pub struct SecretKey { + /// The client ID returned by ZeroKMS when creating a device keyset. + #[zeroize(skip)] + client_id: Uuid, + /// The client key material for this device. + client_key: ViturKeyMaterial, +} + +impl SecretKey { + /// Create a new [`SecretKey`] from the given client ID and key material. + pub fn new(client_id: Uuid, client_key: ViturKeyMaterial) -> Self { + Self { + client_id, + client_key, + } + } + + /// Create a [`SecretKey`] from string representations of the client ID and encoded + /// key material. + /// + /// Accepts the key material as either **hex** (the historical `CS_CLIENT_KEY` format) + /// **or** standard padded **base64** (the format used by `secretkey.json` on disk). + /// Hex is tried first; base64 is a fallback for any input that doesn't parse as hex. + /// + /// Named `from_hex` for historical reasons — the function is now lenient. The name + /// is preserved to avoid breaking callers. + /// + /// # Errors + /// + /// Returns [`KeyProviderError::InvalidKey`] if: + /// - `client_id` is not a valid UUID + /// - `client_key_encoded` is neither valid hex nor valid base64 + pub fn from_hex( + client_id: String, + mut client_key_encoded: String, + ) -> Result { + let uuid = Uuid::parse_str(&client_id) + .map_err(|e| KeyProviderError::InvalidKey(format!("invalid client_id: {e}")))?; + + let result = decode_client_key_material(&client_key_encoded); + client_key_encoded.zeroize(); + + let bytes = + result.map_err(|e| KeyProviderError::InvalidKey(format!("invalid client_key: {e}")))?; + + Ok(Self::new(uuid, ViturKeyMaterial::from(bytes))) + } + + /// Load a [`SecretKey`] from the `CS_CLIENT_ID` and `CS_CLIENT_KEY` environment variables. + /// + /// `CS_CLIENT_KEY` accepts either hex (the historical format) or the base64 value + /// that appears in `secretkey.json` — see [`SecretKey::from_hex`]. + /// + /// Returns `Ok(None)` if neither or only one variable is set, allowing callers to + /// fall back to another source. Returns `Err` if both variables are present but + /// the values are invalid (bad UUID or bad encoding). + /// + /// # Example + /// + /// ```no_run + /// use stack_kms::SecretKey; + /// + /// let key = SecretKey::from_env().expect("invalid key material in env"); + /// // key is Option — None means "not configured via env" + /// ``` + #[cfg(not(target_arch = "wasm32"))] + pub fn from_env() -> Result, KeyProviderError> { + use crate::vars::{CS_CLIENT_ID, CS_CLIENT_KEY}; + + match (std::env::var(CS_CLIENT_ID), std::env::var(CS_CLIENT_KEY)) { + (Ok(id), Ok(key)) => { + tracing::debug!("both {CS_CLIENT_ID} and {CS_CLIENT_KEY} set, loading secret key"); + Self::from_hex(id, key).map(Some) + } + (Ok(_), Err(_)) => { + tracing::debug!("{CS_CLIENT_ID} set but {CS_CLIENT_KEY} missing, skipping"); + Ok(None) + } + (Err(_), Ok(_)) => { + tracing::debug!("{CS_CLIENT_KEY} set but {CS_CLIENT_ID} missing, skipping"); + Ok(None) + } + (Err(_), Err(_)) => { + tracing::debug!("neither {CS_CLIENT_ID} nor {CS_CLIENT_KEY} set"); + Ok(None) + } + } + } +} + +/// Implement [ProfileData] for [SecretKey] to enable loading/saving from the profile directory. +#[cfg(not(target_arch = "wasm32"))] +impl ProfileData for SecretKey { + const FILENAME: &'static str = "secretkey.json"; + const MODE: Option = Some(0o600); +} + +/// Implement [KeyProvider] for [SecretKey] to allow it to be used directly as a key source when initializing with a [super::StackKmsBuilder]. +impl KeyProvider for SecretKey { + async fn client_key(&self) -> Result { + ClientKey::from_bytes(self.client_id, &self.client_key) + .map_err(|e| KeyProviderError::InvalidKey(e.to_string())) + } +} + +#[cfg(not(target_arch = "wasm32"))] +impl KeyProvider for ProfileStore { + async fn client_key(&self) -> Result { + let ws_store = self.current_workspace_store().map_err(|e| match e { + ProfileError::NoCurrentWorkspace => KeyProviderError::NotConfigured(e.to_string()), + _ => KeyProviderError::LoadError(e.to_string()), + })?; + let secret_key: SecretKey = ws_store.load_profile().map_err(|e| match e { + ProfileError::NotFound { .. } => KeyProviderError::NotConfigured(e.to_string()), + _ => KeyProviderError::LoadError(e.to_string()), + })?; + secret_key.client_key().await + } +} + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod tests { + use super::*; + use recipher::keyset::{EncryptionKeySet, ProxyKeySet}; + use tempfile::TempDir; + + /// Build a random `SecretKey` and return it alongside the `client_id` and raw keyset bytes. + fn random_secret_key() -> (SecretKey, Uuid, Vec) { + let client_id = Uuid::new_v4(); + let ek_a = EncryptionKeySet::generate().unwrap(); + let ek_b = EncryptionKeySet::generate().unwrap(); + let keyset = ProxyKeySet::generate(&ek_a, &ek_b); + let bytes = keyset.to_bytes().unwrap(); + + let secret_key = SecretKey::new(client_id, ViturKeyMaterial::from(bytes.clone())); + + (secret_key, client_id, bytes) + } + + mod secret_key_provider { + use super::*; + + #[tokio::test] + async fn returns_client_key_with_matching_id() { + let (secret_key, client_id, _) = random_secret_key(); + + let result = secret_key.client_key().await.unwrap(); + + assert_eq!( + result.key_id, client_id, + "client_key should preserve the client_id" + ); + } + + #[tokio::test] + async fn returns_client_key_with_correct_key_material() { + let (secret_key, _, bytes) = random_secret_key(); + + let result = secret_key.client_key().await.unwrap(); + + let expected_hex = base16ct::lower::encode_string(&bytes); + let actual_hex = result.to_hex_v1().unwrap(); + assert_eq!( + actual_hex, expected_hex, + "client_key should preserve the key material" + ); + } + + #[tokio::test] + async fn returns_invalid_key_for_bad_material() { + let secret_key = + SecretKey::new(Uuid::new_v4(), ViturKeyMaterial::from(vec![0xDE, 0xAD])); + + let err = secret_key.client_key().await.unwrap_err(); + + assert!( + matches!(err, KeyProviderError::InvalidKey(_)), + "expected InvalidKey for garbage bytes, got: {err:?}" + ); + } + } + + mod from_hex { + use super::*; + + #[tokio::test] + async fn round_trips_through_key_provider() { + let (original, client_id, bytes) = random_secret_key(); + let hex = base16ct::lower::encode_string(&bytes); + + let from_hex = SecretKey::from_hex(client_id.to_string(), hex).unwrap(); + + let original_key = original.client_key().await.unwrap(); + let from_hex_key = from_hex.client_key().await.unwrap(); + + assert_eq!( + original_key.key_id, from_hex_key.key_id, + "from_hex should produce the same client_id" + ); + assert_eq!( + original_key.to_hex_v1().unwrap(), + from_hex_key.to_hex_v1().unwrap(), + "from_hex should produce the same key material" + ); + } + + #[test] + fn returns_invalid_key_for_bad_uuid() { + let err = SecretKey::from_hex("not-a-uuid".into(), "deadbeef".into()).unwrap_err(); + + assert!( + matches!(err, KeyProviderError::InvalidKey(_)), + "expected InvalidKey for bad UUID, got: {err:?}" + ); + } + + #[test] + fn returns_invalid_key_for_bad_encoding() { + let uuid = Uuid::new_v4(); + // `!!` is rejected by both hex and base64 decoders + let err = + SecretKey::from_hex(uuid.to_string(), "not-valid-anything!!".into()).unwrap_err(); + + assert!( + matches!(err, KeyProviderError::InvalidKey(_)), + "expected InvalidKey for bad encoding, got: {err:?}" + ); + } + + #[tokio::test] + async fn accepts_base64_encoded_key_material() { + use base64ct::Encoding; + let (_, client_id, bytes) = random_secret_key(); + let b64 = base64ct::Base64::encode_string(&bytes); + + let from_b64 = SecretKey::from_hex(client_id.to_string(), b64).unwrap(); + let key = from_b64.client_key().await.unwrap(); + + let expected_hex = base16ct::lower::encode_string(&bytes); + assert_eq!( + key.to_hex_v1().unwrap(), + expected_hex, + "base64-encoded key material should round-trip to the same bytes as hex" + ); + } + } + + mod profile_store_provider { + use super::*; + + const TEST_WORKSPACE_ID: &str = "ZVATKW3VHMFG27DY"; + + #[tokio::test] + async fn loads_secret_key_from_disk() { + let dir = TempDir::new().unwrap(); + let store = ProfileStore::new(dir.path()); + store.init_workspace(TEST_WORKSPACE_ID).unwrap(); + + let (secret_key, client_id, bytes) = random_secret_key(); + let ws_store = store.current_workspace_store().unwrap(); + ws_store.save_profile(&secret_key).unwrap(); + + let result = store.client_key().await.unwrap(); + + assert_eq!( + result.key_id, client_id, + "should load and convert the stored SecretKey" + ); + + let expected_hex = base16ct::lower::encode_string(&bytes); + let actual_hex = result.to_hex_v1().unwrap(); + assert_eq!( + actual_hex, expected_hex, + "should preserve the key material after round-tripping through disk" + ); + } + + #[tokio::test] + async fn returns_not_configured_when_no_workspace_set() { + let dir = TempDir::new().unwrap(); + let store = ProfileStore::new(dir.path()); + + let err = store.client_key().await.unwrap_err(); + + assert!( + matches!(err, KeyProviderError::NotConfigured(_)), + "expected NotConfigured when no workspace is set, got: {err:?}" + ); + } + + #[tokio::test] + async fn returns_not_configured_when_file_missing() { + let dir = TempDir::new().unwrap(); + let store = ProfileStore::new(dir.path()); + store.init_workspace(TEST_WORKSPACE_ID).unwrap(); + + let err = store.client_key().await.unwrap_err(); + + assert!( + matches!(err, KeyProviderError::NotConfigured(_)), + "expected NotConfigured for missing file, got: {err:?}" + ); + + let msg = err.to_string(); + assert!( + msg.contains("Profile not found"), + "error should explain the profile is missing, got: {msg}" + ); + } + + #[tokio::test] + async fn returns_load_error_for_invalid_json() { + let dir = TempDir::new().unwrap(); + let store = ProfileStore::new(dir.path()); + store.init_workspace(TEST_WORKSPACE_ID).unwrap(); + + // Write invalid JSON to the workspace-scoped file path + let ws_dir = dir.path().join("workspaces").join(TEST_WORKSPACE_ID); + std::fs::create_dir_all(&ws_dir).unwrap(); + std::fs::write(ws_dir.join(SecretKey::FILENAME), "not json").unwrap(); + + let err = store.client_key().await.unwrap_err(); + + assert!( + matches!(err, KeyProviderError::LoadError(_)), + "expected LoadError for corrupt file, got: {err:?}" + ); + + let msg = err.to_string(); + assert!( + msg.contains("JSON error"), + "error should mention the JSON parse failure, got: {msg}" + ); + } + } +} diff --git a/packages/stack-kms/src/user_agent.rs b/packages/stack-kms/src/user_agent.rs new file mode 100644 index 000000000..5d03c977a --- /dev/null +++ b/packages/stack-kms/src/user_agent.rs @@ -0,0 +1,16 @@ +use lazy_static::lazy_static; +use std::env::consts::{ARCH, OS}; + +const VERSION: &str = env!("CARGO_PKG_VERSION"); +const SECONDARY_AGENT: Option<&str> = option_env!("CIPHERSTASH_CLIENT_SECONDARY_USER_AGENT"); + +pub fn get_user_agent() -> &'static str { + lazy_static! { + static ref USER_AGENT: String = format!( + "stack-kms/{VERSION} ({OS} {ARCH}{})", + SECONDARY_AGENT.map(|x| format!(" {x}")).unwrap_or_default() + ); + } + + &USER_AGENT +} diff --git a/packages/stack-kms/src/vars.rs b/packages/stack-kms/src/vars.rs new file mode 100644 index 000000000..60766c75f --- /dev/null +++ b/packages/stack-kms/src/vars.rs @@ -0,0 +1,14 @@ +//! Environment variable names recognised by `stack-kms`. +//! +//! These mirror the names used by `cipherstash-client` so the two crates stay +//! interchangeable for credential discovery. + +/// Endpoint override for the ZeroKMS service. The first present variable wins; +/// `CS_VITUR_HOST` is the legacy name kept for backwards compatibility. +pub static CS_ZEROKMS_HOST: &[&str] = &["CS_ZEROKMS_HOST", "CS_VITUR_HOST"]; + +/// The client (device) ID used to authenticate key operations. +pub static CS_CLIENT_ID: &str = "CS_CLIENT_ID"; + +/// The client key material (hex or base64 encoded) used to derive data keys. +pub static CS_CLIENT_KEY: &str = "CS_CLIENT_KEY"; From 05be33610bcbda5ea77c9ab6525050b392805f12 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Sun, 7 Jun 2026 16:17:43 +0800 Subject: [PATCH 397/686] feat(stack-kms): add DataKeySource trait with a test-support fake Abstracts the generate_keys/retrieve_keys slice that higher-level encryption layers (e.g. stack-encrypt) depend on, so they can take a generic K: DataKeySource rather than a concrete StackKms client. StackKms implements it for production. Adds FakeDataKeySource behind a new test-support feature: a deterministic, in-process source that derives key material from the tag, so downstream crates can unit-test encrypt/decrypt round-trips (and tag-binding failures) without ZeroKMS credentials or network. --- packages/stack-kms/Cargo.toml | 6 + packages/stack-kms/src/key_source.rs | 157 +++++++++++++++++++++++++++ packages/stack-kms/src/lib.rs | 6 + 3 files changed, 169 insertions(+) create mode 100644 packages/stack-kms/src/key_source.rs diff --git a/packages/stack-kms/Cargo.toml b/packages/stack-kms/Cargo.toml index 9eefa3c41..2b814ecd7 100644 --- a/packages/stack-kms/Cargo.toml +++ b/packages/stack-kms/Cargo.toml @@ -10,6 +10,12 @@ keywords.workspace = true categories.workspace = true license-file = "LICENSE" +[features] +# Exposes `FakeDataKeySource`, a deterministic in-memory `DataKeySource` for +# downstream crates (e.g. `stack-encrypt`) to unit-test encrypt/decrypt without +# ZeroKMS credentials or network access. +test-support = [] + [dependencies] recipher = { workspace = true } stack-auth = { workspace = true } diff --git a/packages/stack-kms/src/key_source.rs b/packages/stack-kms/src/key_source.rs new file mode 100644 index 000000000..4e4bbd3b5 --- /dev/null +++ b/packages/stack-kms/src/key_source.rs @@ -0,0 +1,157 @@ +//! An abstraction over the ZeroKMS data-key operations that higher-level +//! encryption layers (e.g. `stack-encrypt`) depend on. +//! +//! [`StackKms`](crate::StackKms) is the production implementation. Downstream +//! crates take a `K: DataKeySource` rather than a concrete client so their +//! encrypt/decrypt logic can be unit-tested against a deterministic in-memory +//! fake (see [`FakeDataKeySource`], enabled by the `test-support` feature) +//! without credentials or network access. + +use std::borrow::Cow; +use std::future::Future; + +use uuid::Uuid; +use zerokms_protocol::UnverifiedContext; + +use crate::errors::Error; +use crate::key::{DataKey, DataKeyWithTag}; +use crate::payload::{GenerateKeyPayload, RetrieveKeyPayload}; + +/// The slice of ZeroKMS data-key functionality required to encrypt and decrypt: +/// generating fresh data keys and re-deriving them for stored ciphertexts. +/// +/// Both methods take an owned `Vec` of payloads (rather than `impl IntoIterator`) +/// so the trait stays simple to implement and the returned futures are easy to +/// box behind an async [`Decipher`](vitaminc_aead::Decipher). +pub trait DataKeySource { + /// Generate one fresh data key per payload, in payload order. + fn generate_keys( + &self, + payloads: Vec>, + keyset_id: Option, + unverified_context: Option>, + ) -> impl Future, Error>> + Send; + + /// Re-derive one data key per payload, in payload order. Each payload's IV + + /// tag (returned by a prior [`generate_keys`](DataKeySource::generate_keys) + /// call and stored with the ciphertext) must reproduce the same key. + fn retrieve_keys( + &self, + payloads: Vec>, + keyset_id: Option, + unverified_context: Option<&UnverifiedContext>, + ) -> impl Future, Error>> + Send; +} + +impl DataKeySource for crate::StackKms +where + C: stack_auth::AuthStrategyBounds, + for<'a> &'a C: stack_auth::AuthStrategy, +{ + async fn generate_keys( + &self, + payloads: Vec>, + keyset_id: Option, + unverified_context: Option>, + ) -> Result, Error> { + // Disambiguate from the trait method of the same name: the inherent + // method takes `impl IntoIterator`, which `Vec` satisfies. + crate::StackKms::generate_keys(self, payloads, keyset_id, unverified_context).await + } + + async fn retrieve_keys( + &self, + payloads: Vec>, + keyset_id: Option, + unverified_context: Option<&UnverifiedContext>, + ) -> Result, Error> { + crate::StackKms::retrieve_keys(self, payloads, keyset_id, unverified_context).await + } +} + +#[cfg(feature = "test-support")] +mod fake { + use super::*; + use crate::key::DataKey; + use recipher::key::{Iv, Key}; + use std::sync::atomic::{AtomicU64, Ordering}; + + /// A deterministic, in-process [`DataKeySource`] for tests. + /// + /// `generate_keys` hands out a unique IV + tag per payload (driven by an + /// internal counter) and derives the key material purely from the tag. + /// `retrieve_keys` re-derives the same key from the stored tag, so a + /// generate-then-retrieve round-trip reproduces the key, while a mismatched + /// tag yields different material — exactly the binding property real ZeroKMS + /// provides, without credentials or network. + #[derive(Debug, Default)] + pub struct FakeDataKeySource { + counter: AtomicU64, + } + + impl FakeDataKeySource { + pub fn new() -> Self { + Self::default() + } + } + + /// Derive 32 bytes of key material from a tag. Pure function of the tag, so + /// generate and retrieve agree; non-degenerate for the empty tag. + fn key_from_tag(tag: &[u8]) -> Key { + let mut k = [0u8; 32]; + for (i, b) in tag.iter().enumerate() { + k[i % 32] = k[i % 32] + .wrapping_add(*b) + .wrapping_add(i as u8) + .wrapping_add(1); + } + k[0] = k[0].wrapping_add(tag.len() as u8).wrapping_add(0x5a); + k + } + + impl DataKeySource for FakeDataKeySource { + async fn generate_keys( + &self, + payloads: Vec>, + _keyset_id: Option, + _unverified_context: Option>, + ) -> Result, Error> { + Ok(payloads + .iter() + .map(|_| { + let n = self.counter.fetch_add(1, Ordering::Relaxed); + let mut iv: Iv = [0u8; 16]; + iv[..8].copy_from_slice(&n.to_le_bytes()); + let tag = format!("fake-kms-tag-{n}").into_bytes(); + let key = key_from_tag(&tag); + DataKeyWithTag { + key: DataKey { iv, key }, + tag, + decryption_policy: None, + } + }) + .collect()) + } + + async fn retrieve_keys( + &self, + payloads: Vec>, + _keyset_id: Option, + _unverified_context: Option<&UnverifiedContext>, + ) -> Result, Error> { + Ok(payloads + .iter() + .map(|p| { + let iv: Iv = *p.iv.as_ref(); + DataKey { + iv, + key: key_from_tag(p.tag), + } + }) + .collect()) + } + } +} + +#[cfg(feature = "test-support")] +pub use fake::FakeDataKeySource; diff --git a/packages/stack-kms/src/lib.rs b/packages/stack-kms/src/lib.rs index b9db66bb6..debe889a8 100644 --- a/packages/stack-kms/src/lib.rs +++ b/packages/stack-kms/src/lib.rs @@ -47,6 +47,7 @@ mod errors; mod futures; mod key; mod key_provider; +mod key_source; mod payload; mod secret_key; mod user_agent; @@ -70,6 +71,11 @@ pub use errors::{Error, GenerateKeyError, RetrieveKeyError}; // Key material pub use key::{ClientKey, DataKey, DataKeyWithTag, V1KeySet}; +// Data key source abstraction (production = `StackKms`; tests = `FakeDataKeySource`) +pub use key_source::DataKeySource; +#[cfg(feature = "test-support")] +pub use key_source::FakeDataKeySource; + // Key providers pub use key_provider::{ EnvKeyProvider, FallbackKeyProvider, KeyProvider, KeyProviderError, StaticKeyProvider, From fc21335ddf1dd2196dc5c033fa07049db50a3f81 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Tue, 7 Jul 2026 00:11:04 +1000 Subject: [PATCH 398/686] fix(stack-kms): address code-review findings; lean on vitaminc Correctness - builder: reject max_keys_per_req / max_concurrent_reqs == 0 at build() (new StackKmsBuilderError::InvalidConfig) instead of panicking later in slice::chunks / silently returning no keys. - builder: move the config setters (timeouts, base_url, max_*) onto the state-agnostic impl so they compile when chained after with_client_key / with_key_provider, not only before. Security / crypto (vitaminc) - client: generate IVs with vitaminc SafeRand (CSPRNG) + Generatable instead of rand::thread_rng(); GenerateKeyError::GenerateIv now carries vitaminc RandomError. - key: compare DataKey via vitaminc TimingSafeEq (constant-time) instead of a variable-time derive(PartialEq); drop PartialEq/Eq from DataKeyWithTag. - key: zeroize the intermediate plaintext buffer in V1KeySet::serialize, matching to_hex/from_hex/deserialize. - secret_key: zeroize the decoded key material on the invalid-UUID path in from_hex so a bad client_id can't leave the secret un-zeroized. - lib: add the crate-level security lint block (deny(unsafe_code), warn on mem_forget/unwrap/panic/print/etc). Correctness (test fake) - key_source: FakeDataKeySource now binds derived keys to keyset_id + descriptor + context + tag (length-prefixed SHA-256), matching real ZeroKMS's binding, and its doc no longer overclaims. Added a round-trip + wrong-descriptor test. Altitude / efficiency - connection: report an unresolved base URL as a request-preparation error, not Unauthorized, so it isn't mistaken for a 401 / reauth loop. - connection: move the serialized request body into reqwest instead of cloning it per request; client: move (not clone) the per-key context. Claude-Session: https://claude.ai/code/session_01Awvey4sHjsHbpVMyzVzPSm --- packages/stack-kms/Cargo.toml | 5 +- packages/stack-kms/src/builder.rs | 102 ++++++++++++++---------- packages/stack-kms/src/client.rs | 30 ++++--- packages/stack-kms/src/connection.rs | 16 ++-- packages/stack-kms/src/errors.rs | 4 +- packages/stack-kms/src/key.rs | 17 +++- packages/stack-kms/src/key_source.rs | 112 ++++++++++++++++++++++----- packages/stack-kms/src/lib.rs | 18 +++++ packages/stack-kms/src/secret_key.rs | 20 ++++- 9 files changed, 231 insertions(+), 93 deletions(-) diff --git a/packages/stack-kms/Cargo.toml b/packages/stack-kms/Cargo.toml index 2b814ecd7..120415953 100644 --- a/packages/stack-kms/Cargo.toml +++ b/packages/stack-kms/Cargo.toml @@ -21,7 +21,10 @@ recipher = { workspace = true } stack-auth = { workspace = true } zerokms-protocol = { workspace = true } -vitaminc = { workspace = true, features = ["protected"] } +vitaminc = { workspace = true, features = ["protected", "random"] } +# Direct dep required because the `TimingSafeEq` derive macro expands to a +# `::vitaminc_protected` path. +vitaminc-protected = { workspace = true } lazy_static = { workspace = true } log = { workspace = true } diff --git a/packages/stack-kms/src/builder.rs b/packages/stack-kms/src/builder.rs index 6d1a9d4db..95f6fcec1 100644 --- a/packages/stack-kms/src/builder.rs +++ b/packages/stack-kms/src/builder.rs @@ -20,6 +20,11 @@ pub enum StackKmsBuilderError { /// Key provider failed to load a client key. #[error("Key provider error: {0}")] KeyProvider(#[from] KeyProviderError), + + /// A builder option was set to an invalid value (e.g. a zero concurrency + /// or keys-per-request limit). + #[error("Invalid builder configuration: {0}")] + InvalidConfig(&'static str), } /// A builder for creating [`StackKms`] clients. @@ -104,6 +109,45 @@ where } } + /// Add a [`KeyProvider`] to load a client key asynchronously at build time. + /// + /// This transforms the builder into one that builds via an async + /// [`build()`](StackKmsBuilder::build) call. + pub fn with_key_provider( + self, + provider: K, + ) -> StackKmsBuilder> { + StackKmsBuilder { + credentials: self.credentials, + request_timeout: self.request_timeout, + connect_timeout: self.connect_timeout, + pool_idle_timeout: self.pool_idle_timeout, + max_keys_per_req: self.max_keys_per_req, + max_concurrent_reqs: self.max_concurrent_reqs, + client_key: WithKeyProvider(provider), + base_url_override: self.base_url_override, + } + } + + /// Add a client key directly. + pub fn with_client_key(self, client_key: ClientKey) -> StackKmsBuilder { + StackKmsBuilder { + credentials: self.credentials, + request_timeout: self.request_timeout, + connect_timeout: self.connect_timeout, + pool_idle_timeout: self.pool_idle_timeout, + max_keys_per_req: self.max_keys_per_req, + max_concurrent_reqs: self.max_concurrent_reqs, + client_key, + base_url_override: self.base_url_override, + } + } +} + +// Configuration setters live on the state-agnostic impl so they can be called +// in any order relative to `with_client_key`/`with_key_provider` — chaining a +// setter *after* the key would otherwise fail to compile. +impl StackKmsBuilder { /// Set the **total request timeout** in seconds. Defaults to 10 seconds. pub fn with_request_timeout(mut self, timeout_secs: u64) -> Self { self.request_timeout = Some(timeout_secs); @@ -122,13 +166,15 @@ where self } - /// Set the maximum number of keys per request. Defaults to 500. + /// Set the maximum number of keys per request. Defaults to 500. Must be at + /// least 1 (validated at [`build`](Self::build) time). pub fn with_max_keys_per_req(mut self, max_keys: usize) -> Self { self.max_keys_per_req = max_keys; self } - /// Set the maximum number of concurrent requests. Defaults to 5. + /// Set the maximum number of concurrent requests. Defaults to 5. Must be at + /// least 1 (validated at [`build`](Self::build) time). pub fn with_max_concurrent_reqs(mut self, max_concurrent: usize) -> Self { self.max_concurrent_reqs = max_concurrent; self @@ -143,43 +189,21 @@ where self } - /// Add a [`KeyProvider`] to load a client key asynchronously at build time. - /// - /// This transforms the builder into one that builds via an async - /// [`build()`](StackKmsBuilder::build) call. - pub fn with_key_provider( - self, - provider: K, - ) -> StackKmsBuilder> { - StackKmsBuilder { - credentials: self.credentials, - request_timeout: self.request_timeout, - connect_timeout: self.connect_timeout, - pool_idle_timeout: self.pool_idle_timeout, - max_keys_per_req: self.max_keys_per_req, - max_concurrent_reqs: self.max_concurrent_reqs, - client_key: WithKeyProvider(provider), - base_url_override: self.base_url_override, + fn build_opts(self) -> Result<(ClientOpts, C, S), StackKmsBuilderError> { + // Reject degenerate concurrency/chunking config here rather than letting + // it reach `map_async_chunked` (0 keys-per-req panics `slice::chunks`; + // 0 concurrent-reqs silently yields no keys). + if self.max_keys_per_req == 0 { + return Err(StackKmsBuilderError::InvalidConfig( + "max_keys_per_req must be at least 1", + )); } - } - - /// Add a client key directly. - pub fn with_client_key(self, client_key: ClientKey) -> StackKmsBuilder { - StackKmsBuilder { - credentials: self.credentials, - request_timeout: self.request_timeout, - connect_timeout: self.connect_timeout, - pool_idle_timeout: self.pool_idle_timeout, - max_keys_per_req: self.max_keys_per_req, - max_concurrent_reqs: self.max_concurrent_reqs, - client_key, - base_url_override: self.base_url_override, + if self.max_concurrent_reqs == 0 { + return Err(StackKmsBuilderError::InvalidConfig( + "max_concurrent_reqs must be at least 1", + )); } - } -} -impl StackKmsBuilder { - fn build_opts(self) -> (ClientOpts, C, S) { let base_url = self.base_url_override.or_else(Self::base_url_from_env); let mut connection_opts = HttpConnectionOpts::new(base_url); if let Some(timeout) = self.request_timeout { @@ -198,7 +222,7 @@ impl StackKmsBuilder { connection_opts, }; - (opts, self.credentials, self.client_key) + Ok((opts, self.credentials, self.client_key)) } /// Resolve the ZeroKMS base URL from the `CS_ZEROKMS_HOST` environment @@ -233,7 +257,7 @@ where { /// Build a [`StackKms`] client. pub fn build(self) -> Result, StackKmsBuilderError> { - let (opts, credentials, client_key) = self.build_opts(); + let (opts, credentials, client_key) = self.build_opts()?; Ok(StackKms::connect(opts, credentials, client_key)?) } } @@ -254,7 +278,7 @@ where /// /// This is an async method because the key provider may need to perform I/O. pub async fn build(self) -> Result, StackKmsBuilderError> { - let (opts, credentials, provider) = self.build_opts(); + let (opts, credentials, provider) = self.build_opts()?; let client_key = provider.0.client_key().await?; Ok(StackKms::connect(opts, credentials, client_key)?) } diff --git a/packages/stack-kms/src/client.rs b/packages/stack-kms/src/client.rs index 90b6dae7d..8ded7ed74 100644 --- a/packages/stack-kms/src/client.rs +++ b/packages/stack-kms/src/client.rs @@ -6,8 +6,9 @@ use zerokms_protocol::{ RetrieveKeyRequestFallible, RetrieveKeySpec, RetrievedKey, UnverifiedContext, }; -use recipher::key::{GenRandom, Iv}; +use recipher::key::Iv; use stack_auth::{AuthStrategy, AuthStrategyBounds}; +use vitaminc::random::{Generatable, SafeRand}; use crate::connection::{HttpConnection, HttpConnectionOpts, ZeroKMSConnection}; use crate::errors::{Error, GenerateKeyError, RetrieveKeyError}; @@ -236,7 +237,9 @@ impl Client { unverified_context: Option>, ) -> Result, GenerateKeyError> { let keys = { - let mut rng = rand::thread_rng(); + // Security: use vitaminc's `SafeRand` (CSPRNG) for IV generation + // rather than `rand::thread_rng()`. + let mut rng = SafeRand::from_entropy().map_err(GenerateKeyError::GenerateIv)?; keys.into_iter() .map( @@ -245,22 +248,17 @@ impl Client { context, decryption_policy, }| { - GenRandom::gen_random(&mut rng) - .map(|iv: Iv| { - if let Some(policy) = decryption_policy { - GenerateKeySpec::new_with_policy(iv, descriptor, policy) - } else { - GenerateKeySpec::new_with_context( - iv, - descriptor, - context.clone(), - ) - } - }) - .map_err(GenerateKeyError::GenerateIv) + let iv: Iv = + Generatable::random(&mut rng).map_err(GenerateKeyError::GenerateIv)?; + Ok(if let Some(policy) = decryption_policy { + GenerateKeySpec::new_with_policy(iv, descriptor, policy) + } else { + // `context` is owned by this closure and used once — move it. + GenerateKeySpec::new_with_context(iv, descriptor, context) + }) }, ) - .collect::, _>>()? + .collect::, GenerateKeyError>>()? }; trace!(target: "stack_kms::generate_keys", "generated {} key payloads", keys.len()); diff --git a/packages/stack-kms/src/connection.rs b/packages/stack-kms/src/connection.rs index a4e237a2a..2c17271b3 100644 --- a/packages/stack-kms/src/connection.rs +++ b/packages/stack-kms/src/connection.rs @@ -16,8 +16,8 @@ const REQUEST_TIMEOUT_SECS: u64 = 10; pub struct ConnectionInitError(#[from] reqwest::Error); #[derive(Debug, Error)] -#[error("token does not grant access to ZeroKMS (missing `services` claim)")] -struct Unauthorized; +#[error("ZeroKMS base URL was not resolved from the token's `services` claim")] +struct BaseUrlUnresolved; pub struct HttpConnectionOpts { base_url: Option, @@ -242,10 +242,14 @@ impl ZeroKMSConnection for HttpConnection { .map_err(|e| ViturRequestError::prepare("Failed to serialize request", e))?; let base_url = self.base_url.get().ok_or_else(|| { - ViturRequestError::new( - ViturRequestErrorKind::Unauthorized, + // A missing base URL is a client-side configuration problem (the + // token carried no ZeroKMS `services` claim and none was set + // explicitly), not an authentication failure — classify it as a + // request-preparation error so callers don't mistake it for a 401 + // and trigger a token refresh/reauth loop. + ViturRequestError::prepare( "ZeroKMS base URL was not resolved from the token's services claim", - Unauthorized, + BaseUrlUnresolved, ) })?; @@ -256,7 +260,7 @@ impl ZeroKMSConnection for HttpConnection { let response = self .client .post(url.as_str()) - .body(body.clone()) + .body(body) .header("content-type", "application/json") .bearer_auth(access_token) .send() diff --git a/packages/stack-kms/src/errors.rs b/packages/stack-kms/src/errors.rs index 739341f8a..5370f1526 100644 --- a/packages/stack-kms/src/errors.rs +++ b/packages/stack-kms/src/errors.rs @@ -1,6 +1,6 @@ use miette::Diagnostic; -use recipher::errors::RecipherError; use thiserror::Error; +use vitaminc::random::RandomError; use zerokms_protocol::{ViturRequestError, ViturRequestErrorKind}; #[derive(Diagnostic, Error, Debug)] @@ -23,7 +23,7 @@ pub enum GenerateKeyError { #[error("Request forbidden due to insufficient permissions")] Forbidden, #[error("Failed to generate IV: {0}")] - GenerateIv(RecipherError), + GenerateIv(RandomError), #[error("Received an invalid number of keys from request. Expected {expected} but received {received}")] InvalidNumberOfKeys { expected: usize, received: usize }, // Catch-all for any `ViturRequestError` not classified as Forbidden / diff --git a/packages/stack-kms/src/key.rs b/packages/stack-kms/src/key.rs index 88ce663dc..6a77df91b 100644 --- a/packages/stack-kms/src/key.rs +++ b/packages/stack-kms/src/key.rs @@ -8,6 +8,7 @@ use serde::{Deserialize, Deserializer, Serialize, Serializer}; use sha2::{Digest, Sha256}; use std::ops::Deref; use uuid::Uuid; +use vitaminc::protected::TimingSafeEq; use zeroize::{Zeroize, ZeroizeOnDrop}; use zerokms_protocol::{DecryptionPolicy, ViturKeyMaterial}; @@ -50,7 +51,9 @@ impl ClientKey { } // FIXME: This shouldn't be Clone but it is needed right now for the JSONB indexer. -#[derive(PartialEq, Eq, Zeroize, ZeroizeOnDrop, Clone)] +// `key` is secret DEK material, so comparison is constant-time via `TimingSafeEq` +// (`.timing_safe_eq()`) rather than a variable-time `derive(PartialEq)`. +#[derive(TimingSafeEq, Zeroize, ZeroizeOnDrop, Clone)] #[cfg_attr(test, derive(Default))] pub struct DataKey { pub iv: Iv, @@ -81,7 +84,9 @@ impl DataKey { // FIXME: Making this Cloneable for now so that we can use the same key many times for the JSONB indexer. // We should modifier the indexer so each value has a separate key. -#[derive(PartialEq, Eq, Clone)] +// No `PartialEq`/`Eq`: the wrapped `DataKey` is secret — compare via +// `deref().timing_safe_eq(..)` if key equality is ever needed. +#[derive(Clone)] #[cfg_attr(test, derive(Default))] pub struct DataKeyWithTag { pub key: DataKey, @@ -151,8 +156,12 @@ impl Serialize for V1KeySet { where S: Serializer, { - let bytes = self.0.to_bytes().map_err(serde::ser::Error::custom)?; - serdect::slice::serialize_hex_lower_or_bin(&bytes, serializer) + let mut bytes = self.0.to_bytes().map_err(serde::ser::Error::custom)?; + // Zeroize the intermediate plaintext keyset buffer, matching `to_hex`, + // `from_hex` and `deserialize`. Serdect encoding is constant-time. + let result = serdect::slice::serialize_hex_lower_or_bin(&bytes, serializer); + bytes.zeroize(); + result } } diff --git a/packages/stack-kms/src/key_source.rs b/packages/stack-kms/src/key_source.rs index 4e4bbd3b5..67d779bcc 100644 --- a/packages/stack-kms/src/key_source.rs +++ b/packages/stack-kms/src/key_source.rs @@ -74,16 +74,22 @@ mod fake { use super::*; use crate::key::DataKey; use recipher::key::{Iv, Key}; + use sha2::{Digest, Sha256}; use std::sync::atomic::{AtomicU64, Ordering}; /// A deterministic, in-process [`DataKeySource`] for tests. /// /// `generate_keys` hands out a unique IV + tag per payload (driven by an - /// internal counter) and derives the key material purely from the tag. - /// `retrieve_keys` re-derives the same key from the stored tag, so a - /// generate-then-retrieve round-trip reproduces the key, while a mismatched - /// tag yields different material — exactly the binding property real ZeroKMS - /// provides, without credentials or network. + /// internal counter). The key material is derived from the tag **together + /// with** the `keyset_id`, per-payload `descriptor` and `context` — the same + /// inputs real ZeroKMS binds a data key to. `retrieve_keys` re-derives the + /// key from those same inputs, so a generate-then-retrieve round-trip + /// reproduces the key only when the `keyset_id` / `descriptor` / `context` + /// match (as with real ZeroKMS), without credentials or network. + /// + /// The derivation is a plain SHA-256: deterministic and binding, but **not** + /// a stand-in for ZeroKMS's real key derivation. Use it for encrypt/decrypt + /// round-trip and wrong-context tests, not for cryptographic assertions. #[derive(Debug, Default)] pub struct FakeDataKeySource { counter: AtomicU64, @@ -95,35 +101,52 @@ mod fake { } } - /// Derive 32 bytes of key material from a tag. Pure function of the tag, so - /// generate and retrieve agree; non-degenerate for the empty tag. - fn key_from_tag(tag: &[u8]) -> Key { - let mut k = [0u8; 32]; - for (i, b) in tag.iter().enumerate() { - k[i % 32] = k[i % 32] - .wrapping_add(*b) - .wrapping_add(i as u8) - .wrapping_add(1); + /// Deterministically derive 32 bytes of key material, binding it to the same + /// inputs real ZeroKMS does: keyset, descriptor, context and tag. Every field + /// is length-prefixed so distinct inputs can't collide via ambiguous + /// concatenation. + fn derive_key( + keyset_id: Option, + descriptor: &str, + context_json: &[u8], + tag: &[u8], + ) -> Key { + fn update_field(hasher: &mut Sha256, field: &[u8]) { + hasher.update((field.len() as u64).to_le_bytes()); + hasher.update(field); + } + + let mut hasher = Sha256::new(); + hasher.update(b"stack-kms::FakeDataKeySource::v1"); + match keyset_id { + Some(id) => { + hasher.update([1u8]); + update_field(&mut hasher, id.as_bytes()); + } + None => hasher.update([0u8]), } - k[0] = k[0].wrapping_add(tag.len() as u8).wrapping_add(0x5a); - k + update_field(&mut hasher, descriptor.as_bytes()); + update_field(&mut hasher, context_json); + update_field(&mut hasher, tag); + hasher.finalize().into() } impl DataKeySource for FakeDataKeySource { async fn generate_keys( &self, payloads: Vec>, - _keyset_id: Option, + keyset_id: Option, _unverified_context: Option>, ) -> Result, Error> { Ok(payloads .iter() - .map(|_| { + .map(|payload| { let n = self.counter.fetch_add(1, Ordering::Relaxed); let mut iv: Iv = [0u8; 16]; iv[..8].copy_from_slice(&n.to_le_bytes()); let tag = format!("fake-kms-tag-{n}").into_bytes(); - let key = key_from_tag(&tag); + let context_json = serde_json::to_vec(&payload.context).unwrap_or_default(); + let key = derive_key(keyset_id, payload.descriptor, &context_json, &tag); DataKeyWithTag { key: DataKey { iv, key }, tag, @@ -136,16 +159,17 @@ mod fake { async fn retrieve_keys( &self, payloads: Vec>, - _keyset_id: Option, + keyset_id: Option, _unverified_context: Option<&UnverifiedContext>, ) -> Result, Error> { Ok(payloads .iter() .map(|p| { let iv: Iv = *p.iv.as_ref(); + let context_json = serde_json::to_vec(&p.context).unwrap_or_default(); DataKey { iv, - key: key_from_tag(p.tag), + key: derive_key(keyset_id, p.descriptor, &context_json, p.tag), } }) .collect()) @@ -155,3 +179,49 @@ mod fake { #[cfg(feature = "test-support")] pub use fake::FakeDataKeySource; + +#[cfg(all(test, feature = "test-support"))] +mod tests { + use super::*; + use crate::payload::{GenerateKeyPayload, RetrieveKeyPayload}; + use std::borrow::Cow; + + #[tokio::test] + async fn round_trips_and_binds_the_key_to_the_descriptor() { + let src = FakeDataKeySource::new(); + + let generated = src + .generate_keys( + vec![GenerateKeyPayload::new("users/email", Cow::Owned(vec![]))], + None, + None, + ) + .await + .unwrap(); + let dk = &generated[0]; + + // Same descriptor + tag reproduces the exact key material. + let retrieved = src + .retrieve_keys( + vec![RetrieveKeyPayload::new(dk.key.iv, "users/email", &dk.tag)], + None, + None, + ) + .await + .unwrap(); + assert_eq!(dk.key.key(), retrieved[0].key()); + + // Retrieving the same tag under a *different* descriptor yields different + // material — the fake now binds like real ZeroKMS rather than keying on + // the tag alone. + let wrong = src + .retrieve_keys( + vec![RetrieveKeyPayload::new(dk.key.iv, "users/name", &dk.tag)], + None, + None, + ) + .await + .unwrap(); + assert_ne!(dk.key.key(), wrong[0].key()); + } +} diff --git a/packages/stack-kms/src/lib.rs b/packages/stack-kms/src/lib.rs index debe889a8..ffd5ce483 100644 --- a/packages/stack-kms/src/lib.rs +++ b/packages/stack-kms/src/lib.rs @@ -40,6 +40,24 @@ //! # } //! ``` +// Security lints — see `.claude/skills/rust-security`. This crate handles +// ZeroKMS key material, so `mem::forget` (which would bypass `ZeroizeOnDrop`) +// and any accidental console output are denied/warned against. +#![deny(unsafe_code)] +#![warn(clippy::unwrap_used)] +#![warn(clippy::expect_used)] +#![warn(clippy::panic)] +#![warn(clippy::mem_forget)] +#![warn(clippy::print_stdout)] +#![warn(clippy::print_stderr)] +#![warn(clippy::dbg_macro)] +#![warn(clippy::todo)] +#![warn(clippy::unimplemented)] +// Relax in tests +#![cfg_attr(test, allow(clippy::unwrap_used))] +#![cfg_attr(test, allow(clippy::expect_used))] +#![cfg_attr(test, allow(clippy::panic))] + mod builder; mod client; mod connection; diff --git a/packages/stack-kms/src/secret_key.rs b/packages/stack-kms/src/secret_key.rs index e8a9cbd65..c21f7d220 100644 --- a/packages/stack-kms/src/secret_key.rs +++ b/packages/stack-kms/src/secret_key.rs @@ -87,15 +87,27 @@ impl SecretKey { client_id: String, mut client_key_encoded: String, ) -> Result { - let uuid = Uuid::parse_str(&client_id) - .map_err(|e| KeyProviderError::InvalidKey(format!("invalid client_id: {e}")))?; - + // Decode and zeroize the encoded key material *first*, so no later + // fallible step (e.g. the UUID parse below) can early-return and leave + // the secret sitting un-zeroized in the owned `String`. let result = decode_client_key_material(&client_key_encoded); client_key_encoded.zeroize(); - let bytes = + let mut bytes = result.map_err(|e| KeyProviderError::InvalidKey(format!("invalid client_key: {e}")))?; + let uuid = match Uuid::parse_str(&client_id) { + Ok(uuid) => uuid, + Err(e) => { + // The decoded key material is now the live copy of the secret — + // zeroize it before returning rather than dropping the plain `Vec`. + bytes.zeroize(); + return Err(KeyProviderError::InvalidKey(format!( + "invalid client_id: {e}" + ))); + } + }; + Ok(Self::new(uuid, ViturKeyMaterial::from(bytes))) } From 2fe39686f1608777c9aa1f003b30669773bde9df Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Tue, 7 Jul 2026 11:03:45 +1000 Subject: [PATCH 399/686] fix(stack-kms,cipherstash-client): zeroize reencrypted key material in DataKey::from_key_material MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit zeroize-audit ZA-0001: `rect` (the ProxyCipher::reencrypt output — secret key material that is hashed into the data key) was dropped un-zeroized, leaving it on the heap. Wrap it in `Zeroizing` so it is wiped on drop. Verified at O2 LLVM IR: DataKey::from_key_material now emits a volatile zeroize of the rect buffer (was absent). The derived key is unchanged — Sha256 input and order are identical — so wire compatibility holds (stack-kms round-trip + cipherstash-client suites pass). The Sha256 block buffer residue is left as-is: sha2 0.10 has no Zeroize impl and changing the hash would alter the derived key. Claude-Session: https://claude.ai/code/session_01Awvey4sHjsHbpVMyzVzPSm --- packages/stack-kms/src/key.rs | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/packages/stack-kms/src/key.rs b/packages/stack-kms/src/key.rs index 6a77df91b..e0bbbb5aa 100644 --- a/packages/stack-kms/src/key.rs +++ b/packages/stack-kms/src/key.rs @@ -9,7 +9,7 @@ use sha2::{Digest, Sha256}; use std::ops::Deref; use uuid::Uuid; use vitaminc::protected::TimingSafeEq; -use zeroize::{Zeroize, ZeroizeOnDrop}; +use zeroize::{Zeroize, ZeroizeOnDrop, Zeroizing}; use zerokms_protocol::{DecryptionPolicy, ViturKeyMaterial}; /// NOTE: Debug is safe to implement because [KeySet] is opaque. @@ -66,10 +66,15 @@ impl DataKey { /// (IV) and key material obtained from ZeroKMS. pub fn from_key_material(key: &ClientKey, iv: Iv, key_material: &ViturKeyMaterial) -> Self { let cipher = ProxyCipher::new(key.keyset.keyset()); - let rect = cipher.reencrypt::<16>(&iv, key_material); + // `rect` is reencrypted key material — the derived data key is a hash of + // it — so wipe the intermediate on drop rather than leave it on the + // heap. (The Sha256 block buffer keeps the final <=64-byte block; sha2 + // 0.10 doesn't implement Zeroize and changing the hash would alter the + // derived key, so that residue is accepted.) + let rect = Zeroizing::new(cipher.reencrypt::<16>(&iv, key_material)); let mut hasher = Sha256::new(); - hasher.update(&rect); + hasher.update(rect.as_slice()); DataKey { iv, From 2ecdab3f852937f6d9eec0cdafc9e0aee5092fd3 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Sun, 23 Aug 2026 16:36:04 +1000 Subject: [PATCH 400/686] fix(stack-kms): address review findings; close test-coverage gaps Review findings (Codex, PR cipherstash/cipherstash-suite#2018): - `DataKeySource` now cfg-splits its `Send` bound by target like `ZeroKMSConnection` / `AuthStrategy`: on wasm32 the fetch-backed HTTP and auth futures aren't `Send`, so the unconditional bound made `StackKms` unable to implement the trait there. Verified with `cargo check --target wasm32-unknown-unknown -p stack-kms --all-features`. - Drop the unused `rand` dependency (IV generation uses vitaminc `SafeRand`); it would fail the workspace `cargo udeps` check. - `V1KeySet::deserialize` uses a `Zeroizing` buffer so a malformed or truncated keyset no longer leaves partially decoded key bytes on the stack on the `?` early-return paths. - `ClientOpts` is now constructed through validated setters (`new` + `with_max_keys_per_req` / `with_max_concurrent_reqs`, returning `InvalidClientOpts`) instead of public fields, so a zero limit can't reach `Client::init_opts` (0 keys-per-req panicked in `slice::chunks`, 0 concurrent-reqs left `buffered(0)` pending forever). The builder's `InvalidConfig` variant wraps that error; defaults are exported as `DEFAULT_KEYS_PER_REQ` / `DEFAULT_CONCURRENT_REQS`. - `FakeDataKeySource` binds the IV and the decryption policy into its derivation (production derivation depends on both), returns the payload's policy on the generated `DataKeyWithTag` as ZeroKMS does, and mirrors the client by dropping context from policy-bearing generate specs. Derivation domain bumped to `v2`. Test-coverage gaps (test-gap review, PR cipherstash/cipherstash-suite#2018): - fake: round-trip, unique IVs/tags, and a negative test per bound input (tag, IV, descriptor, keyset, context, policy stripped/swapped). - client: count-mismatch (`InvalidNumberOfKeys`) for all three ops, the `Err` arm of `retrieve_keys_fallible`, policy forwarding on the request and resolved-policy propagation on the response (via `add_effect`), transport-error classification through `add_failed_response`; the test harness's `#![allow(dead_code)]` is retired. - `GenerateKeyError: From` arm mapping; `HttpConnection` `ensure_base_url` / `has_base_url` first-write-wins and the missing-base-URL prepare error; `SecretKey::from_env` four-way matrix; builder `base_url_from_env` primary / legacy / invalid-URL branches and zero-limit rejection; `map_async_chunked` chunk-error early return; `RetrieveKeySpec: From` policy arms; `ClientKey::from_hex_v1` round-trip and non-hex rejection. Env-mutating tests serialise on a crate-local `test_env::ScopedEnv` guard that restores prior values, so they're safe under both `cargo nextest` (one process per test) and `cargo test`. Claude-Session: https://claude.ai/code/session_01T5iYiJc6xwzMcHDtMwCadG --- packages/stack-kms/Cargo.toml | 1 - packages/stack-kms/src/builder.rs | 153 ++++++- packages/stack-kms/src/client.rs | 416 +++++++++++++++-- .../stack-kms/src/client/test_connection.rs | 14 - packages/stack-kms/src/connection.rs | 63 +++ packages/stack-kms/src/errors.rs | 64 +++ packages/stack-kms/src/futures.rs | 21 + packages/stack-kms/src/key.rs | 72 ++- packages/stack-kms/src/key_source.rs | 424 +++++++++++++++--- packages/stack-kms/src/lib.rs | 58 ++- packages/stack-kms/src/payload.rs | 54 +++ packages/stack-kms/src/secret_key.rs | 56 +++ 12 files changed, 1262 insertions(+), 134 deletions(-) diff --git a/packages/stack-kms/Cargo.toml b/packages/stack-kms/Cargo.toml index 120415953..d435a11e8 100644 --- a/packages/stack-kms/Cargo.toml +++ b/packages/stack-kms/Cargo.toml @@ -37,7 +37,6 @@ tracing = { workspace = true } url = { workspace = true } uuid = { workspace = true } zeroize = { workspace = true } -rand = { workspace = true } base16ct = { version = "0.2.0", features = ["alloc"] } base64ct = { version = "1.7", features = ["alloc"] } diff --git a/packages/stack-kms/src/builder.rs b/packages/stack-kms/src/builder.rs index 95f6fcec1..106c5a44f 100644 --- a/packages/stack-kms/src/builder.rs +++ b/packages/stack-kms/src/builder.rs @@ -1,4 +1,6 @@ -use crate::client::{ClientOpts, StackKms, DEFAULT_CONCURRENT_REQS, DEFAULT_KEYS_PER_REQ}; +use crate::client::{ + ClientOpts, InvalidClientOpts, StackKms, DEFAULT_CONCURRENT_REQS, DEFAULT_KEYS_PER_REQ, +}; use crate::connection::HttpConnectionOpts; use crate::key::ClientKey; use crate::key_provider::{KeyProvider, KeyProviderError}; @@ -23,8 +25,8 @@ pub enum StackKmsBuilderError { /// A builder option was set to an invalid value (e.g. a zero concurrency /// or keys-per-request limit). - #[error("Invalid builder configuration: {0}")] - InvalidConfig(&'static str), + #[error(transparent)] + InvalidConfig(#[from] InvalidClientOpts), } /// A builder for creating [`StackKms`] clients. @@ -190,20 +192,6 @@ impl StackKmsBuilder { } fn build_opts(self) -> Result<(ClientOpts, C, S), StackKmsBuilderError> { - // Reject degenerate concurrency/chunking config here rather than letting - // it reach `map_async_chunked` (0 keys-per-req panics `slice::chunks`; - // 0 concurrent-reqs silently yields no keys). - if self.max_keys_per_req == 0 { - return Err(StackKmsBuilderError::InvalidConfig( - "max_keys_per_req must be at least 1", - )); - } - if self.max_concurrent_reqs == 0 { - return Err(StackKmsBuilderError::InvalidConfig( - "max_concurrent_reqs must be at least 1", - )); - } - let base_url = self.base_url_override.or_else(Self::base_url_from_env); let mut connection_opts = HttpConnectionOpts::new(base_url); if let Some(timeout) = self.request_timeout { @@ -216,11 +204,12 @@ impl StackKmsBuilder { connection_opts = connection_opts.with_pool_idle_timeout(pool_idle_timeout); } - let opts = ClientOpts { - max_keys_per_req: self.max_keys_per_req, - max_concurrent_reqs: self.max_concurrent_reqs, - connection_opts, - }; + // `ClientOpts` rejects degenerate limits (0 keys-per-req would panic + // `slice::chunks`; 0 concurrent-reqs would leave the request stream + // pending forever) so they never reach `map_async_chunked`. + let opts = ClientOpts::new(connection_opts) + .with_max_keys_per_req(self.max_keys_per_req)? + .with_max_concurrent_reqs(self.max_concurrent_reqs)?; Ok((opts, self.credentials, self.client_key)) } @@ -283,3 +272,123 @@ where Ok(StackKms::connect(opts, credentials, client_key)?) } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_env::ScopedEnv; + use stack_auth::{AuthError, AuthStrategyFn, ServiceToken}; + + type NeverStrategy = + AuthStrategyFn std::future::Ready>>; + + fn never_get_token() -> std::future::Ready> { + unreachable!("builder tests never fetch a token") + } + + fn builder() -> StackKmsBuilder { + StackKmsBuilder::new(AuthStrategyFn::new(never_get_token as fn() -> _)) + } + + fn random_client_key() -> ClientKey { + use recipher::keyset::{EncryptionKeySet, ProxyKeySet}; + let ek_a = EncryptionKeySet::generate().unwrap(); + let ek_b = EncryptionKeySet::generate().unwrap(); + ClientKey::new_v1(uuid::Uuid::new_v4(), ProxyKeySet::generate(&ek_a, &ek_b)) + } + + mod invalid_config { + use super::*; + + #[test] + fn rejects_zero_max_keys_per_req() { + let err = builder() + .with_max_keys_per_req(0) + .with_client_key(random_client_key()) + .build() + .err() + .expect("zero keys-per-req must be rejected"); + assert!( + matches!(err, StackKmsBuilderError::InvalidConfig(_)), + "expected InvalidConfig, got: {err:?}" + ); + assert!(err.to_string().contains("max_keys_per_req"), "{err}"); + } + + #[test] + fn rejects_zero_max_concurrent_reqs() { + let err = builder() + .with_max_concurrent_reqs(0) + .with_client_key(random_client_key()) + .build() + .err() + .expect("zero concurrent-reqs must be rejected"); + assert!( + matches!(err, StackKmsBuilderError::InvalidConfig(_)), + "expected InvalidConfig, got: {err:?}" + ); + assert!(err.to_string().contains("max_concurrent_reqs"), "{err}"); + } + + #[test] + fn accepts_the_defaults() { + builder() + .with_client_key(random_client_key()) + .build() + .expect("default limits are valid"); + } + } + + mod base_url_from_env { + use super::*; + + // Pinned by name rather than read from `vars::CS_ZEROKMS_HOST` so a + // reordering of that list (which changes precedence) fails these tests. + const PRIMARY: &str = "CS_ZEROKMS_HOST"; + const LEGACY: &str = "CS_VITUR_HOST"; + + #[test] + fn the_primary_variable_is_listed_first() { + assert_eq!(crate::vars::CS_ZEROKMS_HOST, &[PRIMARY, LEGACY]); + } + + #[test] + fn returns_none_when_neither_variable_is_set() { + let _env = ScopedEnv::new(&[(PRIMARY, None), (LEGACY, None)]); + assert!(StackKmsBuilder::::base_url_from_env().is_none()); + } + + #[test] + fn parses_the_primary_variable() { + let _env = ScopedEnv::new(&[ + (PRIMARY, Some("https://primary.example")), + (LEGACY, Some("https://legacy.example")), + ]); + let url = StackKmsBuilder::::base_url_from_env().unwrap(); + assert_eq!(url.as_str(), "https://primary.example/"); + } + + #[test] + fn falls_back_to_the_legacy_variable() { + let _env = ScopedEnv::new(&[(PRIMARY, None), (LEGACY, Some("https://legacy.example"))]); + let url = StackKmsBuilder::::base_url_from_env().unwrap(); + assert_eq!(url.as_str(), "https://legacy.example/"); + } + + #[test] + fn skips_an_invalid_primary_and_uses_the_legacy_variable() { + let _env = ScopedEnv::new(&[ + (PRIMARY, Some("not a url")), + (LEGACY, Some("https://legacy.example")), + ]); + let url = StackKmsBuilder::::base_url_from_env().unwrap(); + assert_eq!(url.as_str(), "https://legacy.example/"); + } + + #[test] + fn returns_none_when_every_candidate_is_invalid() { + let _env = ScopedEnv::new(&[(PRIMARY, Some("not a url")), (LEGACY, Some("also not"))]); + assert!(StackKmsBuilder::::base_url_from_env().is_none()); + } + } +} diff --git a/packages/stack-kms/src/client.rs b/packages/stack-kms/src/client.rs index 8ded7ed74..8591ab891 100644 --- a/packages/stack-kms/src/client.rs +++ b/packages/stack-kms/src/client.rs @@ -16,26 +16,83 @@ use crate::futures::map_async_chunked; use crate::key::{ClientKey, DataKey, DataKeyWithTag}; use crate::payload::{GenerateKeyPayload, RetrieveKeyPayload}; -pub(crate) const DEFAULT_KEYS_PER_REQ: usize = 500; -pub(crate) const DEFAULT_CONCURRENT_REQS: usize = 5; +/// Default [`ClientOpts::max_keys_per_req`]. +pub const DEFAULT_KEYS_PER_REQ: usize = 500; +/// Default [`ClientOpts::max_concurrent_reqs`]. +pub const DEFAULT_CONCURRENT_REQS: usize = 5; + +/// Returned when a [`ClientOpts`] limit is set to a value the client can't +/// operate with (currently: a zero `max_keys_per_req` or `max_concurrent_reqs`). +#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] +#[error("Invalid client options: {0}")] +pub struct InvalidClientOpts(&'static str); /// Options for configuring certain behaviours of the [`Client`]. /// -/// You should generally use the [`StackKmsBuilder`](crate::StackKmsBuilder) to create a -/// configured instance rather than instantiating this struct directly. +/// You should generally use the [`StackKmsBuilder`](crate::StackKmsBuilder) to +/// create a configured instance rather than instantiating this struct directly. +/// +/// The limits are validated by the `with_*` setters, so a `ClientOpts` value +/// is always usable: a zero `max_keys_per_req` would panic in `slice::chunks` +/// and a zero `max_concurrent_reqs` would leave the request stream pending +/// forever, so both are rejected at construction rather than at call time. pub struct ClientOpts { - /// The maximum number of key specs that should be in each generate or retrieve request to - /// ZeroKMS. Having too large a number of specs per request can panic by exceeding reqwest's max - /// body size. - pub max_keys_per_req: usize, - - /// The maximum number of requests that will be spun up per call to `generate_keys` or - /// `retrieve_keys`. Having too large a number of concurrent requests can result in - /// dropped connections which will fail the calls. - pub max_concurrent_reqs: usize, - - /// The connection options to use when initializing the connection to ZeroKMS. - pub connection_opts: CONNOPTS, + max_keys_per_req: usize, + max_concurrent_reqs: usize, + connection_opts: CONNOPTS, +} + +impl ClientOpts { + /// Options with the default limits ([`DEFAULT_KEYS_PER_REQ`] keys per + /// request, [`DEFAULT_CONCURRENT_REQS`] concurrent requests) and the given + /// connection options. + pub fn new(connection_opts: CONNOPTS) -> Self { + Self { + max_keys_per_req: DEFAULT_KEYS_PER_REQ, + max_concurrent_reqs: DEFAULT_CONCURRENT_REQS, + connection_opts, + } + } + + /// The maximum number of key specs in each generate or retrieve request to + /// ZeroKMS. Too large a number can exceed reqwest's max body size. Must be + /// at least 1. + pub fn with_max_keys_per_req(mut self, max_keys: usize) -> Result { + if max_keys == 0 { + return Err(InvalidClientOpts("max_keys_per_req must be at least 1")); + } + self.max_keys_per_req = max_keys; + Ok(self) + } + + /// The maximum number of requests spun up per call to `generate_keys` or + /// `retrieve_keys`. Too many concurrent requests can result in dropped + /// connections which fail the calls. Must be at least 1. + pub fn with_max_concurrent_reqs( + mut self, + max_concurrent: usize, + ) -> Result { + if max_concurrent == 0 { + return Err(InvalidClientOpts("max_concurrent_reqs must be at least 1")); + } + self.max_concurrent_reqs = max_concurrent; + Ok(self) + } + + /// The maximum number of key specs per request. + pub fn max_keys_per_req(&self) -> usize { + self.max_keys_per_req + } + + /// The maximum number of concurrent requests per key operation. + pub fn max_concurrent_reqs(&self) -> usize { + self.max_concurrent_reqs + } + + /// The connection options used to initialize the ZeroKMS connection. + pub fn connection_opts(&self) -> &CONNOPTS { + &self.connection_opts + } } /// Low-level client for ZeroKMS key generation and retrieval. @@ -471,11 +528,11 @@ mod tests { callback: impl FnOnce(TestConnectionBuilder) -> TestConnectionBuilder, ) -> Client { let builder = callback(TestConnectionBuilder::new()); - let client_opts = ClientOpts { - max_keys_per_req: 10, - max_concurrent_reqs: 5, - connection_opts: builder, - }; + let client_opts = ClientOpts::new(builder) + .with_max_keys_per_req(10) + .unwrap() + .with_max_concurrent_reqs(5) + .unwrap(); Client::init_opts(client_opts).expect("Failed to initialize test client") } @@ -485,6 +542,296 @@ mod tests { ViturKeyMaterial::from(vec![7u8; 528]) } + fn generated_key(tag: Vec) -> GeneratedKey { + GeneratedKey { + key_material: key_material(), + tag, + decryption_policy: None, + } + } + + fn policy(claim: &str, value: &str) -> zerokms_protocol::DecryptionPolicy { + zerokms_protocol::DecryptionPolicy { + conditions: vec![zerokms_protocol::PolicyCondition { + claim: claim.to_string(), + value: Some(value.to_string()), + }], + } + } + + mod client_opts { + use super::*; + + #[test] + fn defaults_to_the_documented_limits() { + let opts = ClientOpts::new(()); + assert_eq!(opts.max_keys_per_req(), DEFAULT_KEYS_PER_REQ); + assert_eq!(opts.max_concurrent_reqs(), DEFAULT_CONCURRENT_REQS); + } + + #[test] + fn rejects_zero_max_keys_per_req() { + let err = ClientOpts::new(()) + .with_max_keys_per_req(0) + .err() + .expect("zero must be rejected"); + assert!(err.to_string().contains("max_keys_per_req"), "{err}"); + } + + #[test] + fn rejects_zero_max_concurrent_reqs() { + let err = ClientOpts::new(()) + .with_max_concurrent_reqs(0) + .err() + .expect("zero must be rejected"); + assert!(err.to_string().contains("max_concurrent_reqs"), "{err}"); + } + + #[test] + fn accepts_positive_limits() { + let opts = ClientOpts::new(()) + .with_max_keys_per_req(1) + .unwrap() + .with_max_concurrent_reqs(1) + .unwrap(); + assert_eq!(opts.max_keys_per_req(), 1); + assert_eq!(opts.max_concurrent_reqs(), 1); + } + } + + mod count_mismatch { + use super::*; + + #[tokio::test] + async fn generate_keys_rejects_a_short_response() { + let client_key = random_client_key(); + // Ask for two, stub a response with only one. + let client = build_client(|builder| { + builder.add_success_response::(GenerateKeyResponse { + keys: vec![generated_key(vec![1])], + }) + }); + + let err = client + .generate_keys( + vec![ + GenerateKeyPayload::new("a", Cow::Owned(vec![])), + GenerateKeyPayload::new("b", Cow::Owned(vec![])), + ], + &client_key, + None, + "token", + None, + ) + .await + .expect_err("count mismatch must be an error"); + + assert!( + matches!( + err, + GenerateKeyError::InvalidNumberOfKeys { + expected: 2, + received: 1 + } + ), + "expected InvalidNumberOfKeys, got: {err:?}" + ); + } + + #[tokio::test] + async fn retrieve_keys_rejects_a_short_response() { + let client_key = random_client_key(); + let client = build_client(|builder| { + builder.add_success_response::(RetrieveKeyResponse { + keys: vec![], + }) + }); + + let err = client + .retrieve_keys( + vec![RetrieveKeyPayload::new(Iv::default(), "a", &[1])], + &client_key, + None, + "token", + None, + ) + .await + .expect_err("count mismatch must be an error"); + + assert!( + matches!( + err, + RetrieveKeyError::InvalidNumberOfKeys { + expected: 1, + received: 0 + } + ), + "expected InvalidNumberOfKeys, got: {err:?}" + ); + } + + #[tokio::test] + async fn retrieve_keys_fallible_rejects_a_short_response() { + let client_key = random_client_key(); + let client = build_client(|builder| { + builder.add_success_response::( + zerokms_protocol::RetrieveKeyResponseFallible { keys: vec![] }, + ) + }); + + let err = client + .retrieve_keys_fallible( + vec![RetrieveKeyPayload::new(Iv::default(), "a", &[1])], + &client_key, + None, + "token", + None, + ) + .await + .expect_err("count mismatch must be an error"); + + assert!( + matches!( + err, + RetrieveKeyError::InvalidNumberOfKeys { + expected: 1, + received: 0 + } + ), + "expected InvalidNumberOfKeys, got: {err:?}" + ); + } + } + + mod transport_errors { + use super::*; + use zerokms_protocol::{ViturRequestError, ViturRequestErrorKind}; + + fn vitur_error(kind: ViturRequestErrorKind) -> ViturRequestError { + ViturRequestError::new(kind, "stubbed", std::io::Error::other("boom")) + } + + #[tokio::test] + async fn generate_keys_classifies_a_forbidden_response() { + let client_key = random_client_key(); + let client = build_client(|builder| { + builder.add_failed_response::(vitur_error( + ViturRequestErrorKind::Forbidden, + )) + }); + + let err = client + .generate_keys( + vec![GenerateKeyPayload::new("a", Cow::Owned(vec![]))], + &client_key, + None, + "token", + None, + ) + .await + .expect_err("a failed request must surface"); + + assert!( + matches!(err, GenerateKeyError::Forbidden), + "expected Forbidden, got: {err:?}" + ); + } + + #[tokio::test] + async fn retrieve_keys_wraps_the_transport_error() { + let client_key = random_client_key(); + let client = build_client(|builder| { + builder.add_failed_response::(vitur_error( + ViturRequestErrorKind::SendRequest, + )) + }); + + let err = client + .retrieve_keys( + vec![RetrieveKeyPayload::new(Iv::default(), "a", &[1])], + &client_key, + None, + "token", + None, + ) + .await + .expect_err("a failed request must surface"); + + assert!( + matches!( + &err, + RetrieveKeyError::RequestFailed(e) + if matches!(e.kind, ViturRequestErrorKind::SendRequest) + ), + "expected RequestFailed(SendRequest), got: {err:?}" + ); + } + } + + #[tokio::test] + async fn generate_keys_forwards_the_decryption_policy_and_returns_the_resolved_one() { + use std::sync::{Arc, Mutex}; + + let client_key = random_client_key(); + let requested = policy("sub", "alice"); + // ZeroKMS fills in `None` claim values; simulate a resolved policy that + // differs from the request to prove the *response* policy is returned. + let resolved = policy("sub", "alice-resolved"); + + let seen: Arc>>> = Arc::new(Mutex::new(None)); + let seen_in_effect = seen.clone(); + + let client = build_client(|builder| { + builder + .add_effect::(move |req| { + *seen_in_effect.lock().unwrap() = Some(req); + }) + .add_success_response::(GenerateKeyResponse { + keys: vec![ + GeneratedKey { + key_material: key_material(), + tag: vec![1], + decryption_policy: Some(resolved.clone()), + }, + generated_key(vec![2]), + ], + }) + }); + + let ctx = vec![zerokms_protocol::Context::Tag("dropped-with-policy".into())]; + let keys = client + .generate_keys( + vec![ + GenerateKeyPayload::new("a", Cow::Borrowed(&ctx)) + .with_decryption_policy(requested.clone()), + GenerateKeyPayload::new("b", Cow::Borrowed(&ctx)), + ], + &client_key, + None, + "token", + None, + ) + .await + .expect("generate_keys should succeed"); + + // Request side: the policy-bearing spec carries the policy and no + // context; the plain spec carries the context and no policy. + let req = seen + .lock() + .unwrap() + .take() + .expect("the effect should have captured the request"); + assert_eq!(req.keys.len(), 2); + assert_eq!(req.keys[0].decryption_policy.as_ref(), Some(&requested)); + assert!(req.keys[0].context.is_empty()); + assert!(req.keys[1].decryption_policy.is_none()); + assert_eq!(req.keys[1].context.len(), 1); + + // Response side: the resolved policy lands on the returned key. + assert_eq!(keys[0].decryption_policy.as_ref(), Some(&resolved)); + assert!(keys[1].decryption_policy.is_none()); + } + #[tokio::test] async fn generate_keys_returns_a_key_per_payload() { let client_key = random_client_key(); @@ -601,15 +948,20 @@ mod tests { } #[tokio::test] - async fn retrieve_keys_fallible_surfaces_per_key_failures() { + async fn retrieve_keys_fallible_surfaces_per_key_results() { let client_key = random_client_key(); + // One key succeeds, one fails: the batch call itself succeeds and the + // per-key results land in payload order. let client = build_client(|builder| { builder.add_success_response::( zerokms_protocol::RetrieveKeyResponseFallible { - keys: vec![Ok(RetrievedKey { - key_material: key_material(), - })], + keys: vec![ + Ok(RetrievedKey { + key_material: key_material(), + }), + Err("key not found".to_string()), + ], }, ) }); @@ -617,16 +969,24 @@ mod tests { let iv = Iv::default(); let keys = client .retrieve_keys_fallible( - vec![RetrieveKeyPayload::new(iv, "a", &[1])], + vec![ + RetrieveKeyPayload::new(iv, "a", &[1]), + RetrieveKeyPayload::new(iv, "b", &[2]), + ], &client_key, None, "token", None, ) .await - .expect("retrieve_keys_fallible should succeed"); + .expect("batch call itself should succeed"); - assert_eq!(keys.len(), 1); + assert_eq!(keys.len(), 2); assert!(keys[0].is_ok()); + assert!( + matches!(&keys[1], Err(RetrieveKeyError::FailedRetrieval(msg)) if msg == "key not found"), + "a per-key failure must be surfaced as FailedRetrieval, got: {:?}", + keys[1] + ); } } diff --git a/packages/stack-kms/src/client/test_connection.rs b/packages/stack-kms/src/client/test_connection.rs index eea427327..864f14b63 100644 --- a/packages/stack-kms/src/client/test_connection.rs +++ b/packages/stack-kms/src/client/test_connection.rs @@ -1,9 +1,5 @@ //! In-memory [`ZeroKMSConnection`] used by unit tests to stub ZeroKMS //! responses without touching the network. -//! -//! Not every matcher helper is exercised by the current tests, but the full -//! harness is kept so future key-operation tests can use it. -#![allow(dead_code)] use async_mutex::Mutex; use zerokms_protocol::{ViturRequest, ViturRequestError}; @@ -86,16 +82,6 @@ pub struct TestConnection { effects: Mutex, } -impl TestConnection { - pub fn builder() -> TestConnectionBuilder { - TestConnectionBuilder::new() - } - - pub fn empty() -> Self { - Self::builder().build() - } -} - impl ZeroKMSConnectionInit for TestConnection { type ConnectionOpts = TestConnectionBuilder; type Error = std::convert::Infallible; diff --git a/packages/stack-kms/src/connection.rs b/packages/stack-kms/src/connection.rs index 2c17271b3..1dc75dafa 100644 --- a/packages/stack-kms/src/connection.rs +++ b/packages/stack-kms/src/connection.rs @@ -319,3 +319,66 @@ impl ZeroKMSConnection for HttpConnection { } } } + +#[cfg(test)] +mod base_url_tests { + use super::*; + + fn conn(base_url: Option) -> HttpConnection { + HttpConnection::init(HttpConnectionOpts::new(base_url)).unwrap() + } + + fn url(s: &str) -> Url { + Url::parse(s).unwrap() + } + + #[test] + fn is_unset_until_ensured() { + let c = conn(None); + assert!(!c.has_base_url()); + + c.ensure_base_url(url("https://a.example")); + + assert!(c.has_base_url()); + assert_eq!(c.base_url.get().unwrap().as_str(), "https://a.example/"); + } + + #[test] + fn the_first_ensured_url_wins() { + let c = conn(None); + c.ensure_base_url(url("https://first.example")); + c.ensure_base_url(url("https://second.example")); + + assert_eq!(c.base_url.get().unwrap().as_str(), "https://first.example/"); + } + + #[test] + fn a_url_given_at_init_is_kept_over_a_later_ensure() { + let c = conn(Some(url("https://init.example"))); + assert!(c.has_base_url()); + + c.ensure_base_url(url("https://other.example")); + + assert_eq!(c.base_url.get().unwrap().as_str(), "https://init.example/"); + } + + #[tokio::test] + async fn send_without_a_base_url_is_a_prepare_error_not_an_auth_error() { + use zerokms_protocol::{GenerateKeyRequest, ViturRequestErrorKind}; + + let c = conn(None); + let req = GenerateKeyRequest { + client_id: uuid::Uuid::nil(), + keyset_id: None, + keys: std::borrow::Cow::Owned(vec![]), + unverified_context: Default::default(), + }; + + let err = c.send(req, "token").await.unwrap_err(); + + assert!( + matches!(err.kind, ViturRequestErrorKind::PrepareRequest), + "a missing base URL must not look like a 401 (and trigger a reauth loop), got: {err:?}" + ); + } +} diff --git a/packages/stack-kms/src/errors.rs b/packages/stack-kms/src/errors.rs index 5370f1526..53f0ab907 100644 --- a/packages/stack-kms/src/errors.rs +++ b/packages/stack-kms/src/errors.rs @@ -49,6 +49,70 @@ impl From for GenerateKeyError { } } +#[cfg(test)] +mod generate_key_error_from_vitur_request_error { + use super::*; + + const SOURCE_DETAIL: &str = "transport-detail-7f3a"; + + fn err(kind: ViturRequestErrorKind) -> ViturRequestError { + ViturRequestError::new(kind, "boom", std::io::Error::other(SOURCE_DETAIL)) + } + + #[test] + fn forbidden_maps_to_forbidden() { + assert!(matches!( + GenerateKeyError::from(err(ViturRequestErrorKind::Forbidden)), + GenerateKeyError::Forbidden + )); + } + + #[test] + fn unauthorized_maps_to_unauthorized() { + assert!(matches!( + GenerateKeyError::from(err(ViturRequestErrorKind::Unauthorized)), + GenerateKeyError::Unauthorized + )); + } + + #[test] + fn every_other_kind_maps_to_request_failed_keeping_the_kind() { + for kind in [ + ViturRequestErrorKind::PrepareRequest, + ViturRequestErrorKind::SendRequest, + ViturRequestErrorKind::NotFound, + ViturRequestErrorKind::Conflict, + ViturRequestErrorKind::FailureResponse, + ViturRequestErrorKind::ParseResponse, + ViturRequestErrorKind::Other, + ] { + // `ViturRequestErrorKind` has no `PartialEq`; compare by Debug name. + let name = format!("{kind:?}"); + let mapped = GenerateKeyError::from(err(kind)); + assert!( + matches!(&mapped, GenerateKeyError::RequestFailed(e) if format!("{:?}", e.kind) == name), + "{name} must map to RequestFailed carrying the same kind, got: {mapped:?}" + ); + } + } + + #[test] + fn request_failed_display_names_the_kind_and_message_but_not_the_source() { + let mapped = GenerateKeyError::from(err(ViturRequestErrorKind::SendRequest)); + let shown = mapped.to_string(); + assert!(shown.contains("SendRequest"), "{shown}"); + assert!(shown.contains("boom"), "{shown}"); + assert!( + !shown.contains(SOURCE_DETAIL), + "the dynamic source error must stay out of Display: {shown}" + ); + assert!( + std::error::Error::source(&mapped).is_some(), + "the source must still be reachable through the error chain" + ); + } +} + /// Top-level error for high-level [`StackKms`](crate::StackKms) key operations. #[derive(Error, Debug, Diagnostic)] pub enum Error { diff --git a/packages/stack-kms/src/futures.rs b/packages/stack-kms/src/futures.rs index 2ddd9892a..460f4f750 100644 --- a/packages/stack-kms/src/futures.rs +++ b/packages/stack-kms/src/futures.rs @@ -57,6 +57,27 @@ mod tests { assert_eq!(input, output); } + #[tokio::test] + async fn test_a_failing_chunk_fails_the_whole_call() { + let input = vec![1, 2, 3, 4, 5, 6]; + + let result = map_async_chunked( + &input, + |chunk| async move { + if chunk.contains(&4) { + Err(format!("chunk {chunk:?} failed")) + } else { + Ok(chunk.to_vec()) + } + }, + 2, + 3, + ) + .await; + + assert_eq!(result, Err("chunk [3, 4] failed".to_string())); + } + #[tokio::test] async fn test_works_when_chunks_dont_divide_nicely() { let input = vec![1, 2, 3, 4, 5, 6, 7, 8, 9, 10]; diff --git a/packages/stack-kms/src/key.rs b/packages/stack-kms/src/key.rs index e0bbbb5aa..5531d7118 100644 --- a/packages/stack-kms/src/key.rs +++ b/packages/stack-kms/src/key.rs @@ -175,11 +175,13 @@ impl<'de> Deserialize<'de> for V1KeySet { where D: Deserializer<'de>, { - // CBOR encoded keyset is 168 bytes - let mut buffer = [0; 168]; - serdect::array::deserialize_hex_or_bin(&mut buffer, deserializer)?; - let keyset = KeySet::from_bytes(&buffer).map_err(serde::de::Error::custom)?; - buffer.zeroize(); + // CBOR encoded keyset is 168 bytes. `Zeroizing` wipes the buffer on + // every exit path — including the `?` early returns below, where a + // malformed or truncated input would otherwise leave whatever was + // decoded so far on the stack. + let mut buffer = Zeroizing::new([0u8; 168]); + serdect::array::deserialize_hex_or_bin(&mut *buffer, deserializer)?; + let keyset = KeySet::from_bytes(&*buffer).map_err(serde::de::Error::custom)?; Ok(Self(keyset)) } @@ -190,6 +192,66 @@ mod tests { use super::{ClientKey, DataKey}; use recipher::keyset::{EncryptionKeySet, ProxyKeySet}; + fn random_keyset() -> ProxyKeySet { + let ek_a = EncryptionKeySet::generate().unwrap(); + let ek_b = EncryptionKeySet::generate().unwrap(); + ProxyKeySet::generate(&ek_a, &ek_b) + } + + mod from_hex_v1 { + use super::*; + + #[test] + fn round_trips_through_to_hex_v1() { + let id = uuid::Uuid::new_v4(); + let hex = ClientKey::new_v1(id, random_keyset()).to_hex_v1().unwrap(); + + let restored = ClientKey::from_hex_v1(id, &hex).unwrap(); + + assert_eq!(restored.key_id, id); + assert_eq!(restored.to_hex_v1().unwrap(), hex, "hex must round-trip"); + } + + #[test] + fn rejects_non_hex_with_the_custom_message() { + let err = ClientKey::from_hex_v1(uuid::Uuid::nil(), "not hex!!").unwrap_err(); + + assert!( + err.to_string().contains("invalid hex"), + "expected the custom invalid-hex message, got: {err}" + ); + } + + #[test] + fn rejects_hex_that_is_not_a_keyset() { + let err = ClientKey::from_hex_v1(uuid::Uuid::nil(), "deadbeef").unwrap_err(); + + assert!( + !err.to_string().contains("invalid hex"), + "valid hex of the wrong shape must fail at keyset decoding, got: {err}" + ); + } + } + + mod v1_keyset_deserialize { + use super::super::V1KeySet; + + #[test] + fn rejects_a_truncated_keyset() { + // Valid hex, but shorter than the 168-byte CBOR keyset — exercises + // the early-return after the buffer was partially written. + let short = serde_json::to_string(&"00".repeat(20)).unwrap(); + let err = serde_json::from_str::(&short).unwrap_err(); + assert!(!err.to_string().is_empty()); + } + + #[test] + fn rejects_non_hex_input() { + let err = serde_json::from_str::("\"zz\"").unwrap_err(); + assert!(!err.to_string().is_empty()); + } + } + #[test] fn test_opaque_debug_datakey() { let key = DataKey { diff --git a/packages/stack-kms/src/key_source.rs b/packages/stack-kms/src/key_source.rs index 67d779bcc..125b4a130 100644 --- a/packages/stack-kms/src/key_source.rs +++ b/packages/stack-kms/src/key_source.rs @@ -22,7 +22,14 @@ use crate::payload::{GenerateKeyPayload, RetrieveKeyPayload}; /// /// Both methods take an owned `Vec` of payloads (rather than `impl IntoIterator`) /// so the trait stays simple to implement and the returned futures are easy to -/// box behind an async [`Decipher`](vitaminc_aead::Decipher). +/// box behind an async `vitaminc_aead::Decipher`. +/// +/// On native targets the returned futures are `Send` so callers can drive them +/// on a multi-threaded runtime. On wasm32 the bound is dropped, mirroring +/// [`ZeroKMSConnection`](crate::ZeroKMSConnection) and +/// [`stack_auth::AuthStrategy`]: the fetch-backed HTTP and auth futures there +/// aren't `Send`, and edge runtimes are single-threaded anyway. +#[cfg(not(target_arch = "wasm32"))] pub trait DataKeySource { /// Generate one fresh data key per payload, in payload order. fn generate_keys( @@ -43,6 +50,29 @@ pub trait DataKeySource { ) -> impl Future, Error>> + Send; } +/// See the native definition above; identical minus the `Send` bound on the +/// returned futures. +#[cfg(target_arch = "wasm32")] +pub trait DataKeySource { + /// Generate one fresh data key per payload, in payload order. + fn generate_keys( + &self, + payloads: Vec>, + keyset_id: Option, + unverified_context: Option>, + ) -> impl Future, Error>>; + + /// Re-derive one data key per payload, in payload order. Each payload's IV + + /// tag (returned by a prior [`generate_keys`](DataKeySource::generate_keys) + /// call and stored with the ciphertext) must reproduce the same key. + fn retrieve_keys( + &self, + payloads: Vec>, + keyset_id: Option, + unverified_context: Option<&UnverifiedContext>, + ) -> impl Future, Error>>; +} + impl DataKeySource for crate::StackKms where C: stack_auth::AuthStrategyBounds, @@ -80,12 +110,20 @@ mod fake { /// A deterministic, in-process [`DataKeySource`] for tests. /// /// `generate_keys` hands out a unique IV + tag per payload (driven by an - /// internal counter). The key material is derived from the tag **together - /// with** the `keyset_id`, per-payload `descriptor` and `context` — the same - /// inputs real ZeroKMS binds a data key to. `retrieve_keys` re-derives the - /// key from those same inputs, so a generate-then-retrieve round-trip - /// reproduces the key only when the `keyset_id` / `descriptor` / `context` - /// match (as with real ZeroKMS), without credentials or network. + /// internal counter). The key material is derived from the IV and tag + /// **together with** the `keyset_id`, per-payload `descriptor`, `context` + /// and `decryption_policy` — the same inputs real ZeroKMS binds a data key + /// to. `retrieve_keys` re-derives the key from those same inputs, so a + /// generate-then-retrieve round-trip reproduces the key only when every one + /// of them matches (as with real ZeroKMS), without credentials or network. + /// + /// Policies are mirrored too: a payload's `decryption_policy` is returned + /// on the generated [`DataKeyWithTag`] (real ZeroKMS returns the resolved + /// policy for storage beside the ciphertext) and bound into the derivation + /// on both sides, so stripping or swapping the policy at retrieval yields + /// different material — the fake's analogue of ZeroKMS's tag mismatch. As + /// in the production client, a generate payload carrying a policy has its + /// `context` dropped (`GenerateKeySpec::new_with_policy` sends none). /// /// The derivation is a plain SHA-256: deterministic and binding, but **not** /// a stand-in for ZeroKMS's real key derivation. Use it for encrypt/decrypt @@ -101,33 +139,51 @@ mod fake { } } - /// Deterministically derive 32 bytes of key material, binding it to the same - /// inputs real ZeroKMS does: keyset, descriptor, context and tag. Every field - /// is length-prefixed so distinct inputs can't collide via ambiguous - /// concatenation. - fn derive_key( + /// Everything a fake data key is bound to. Mirrors the inputs the production + /// client sends to ZeroKMS for one key. + struct KeyInputs<'a> { keyset_id: Option, - descriptor: &str, - context_json: &[u8], - tag: &[u8], - ) -> Key { + iv: &'a Iv, + descriptor: &'a str, + context: &'a [zerokms_protocol::Context], + decryption_policy: Option<&'a zerokms_protocol::DecryptionPolicy>, + tag: &'a [u8], + } + + /// Deterministically derive 32 bytes of key material from [`KeyInputs`]. + /// Every field is length-prefixed (and `Option`s are tagged) so distinct + /// inputs can't collide via ambiguous concatenation. + fn derive_key(inputs: KeyInputs<'_>) -> Key { fn update_field(hasher: &mut Sha256, field: &[u8]) { hasher.update((field.len() as u64).to_le_bytes()); hasher.update(field); } - - let mut hasher = Sha256::new(); - hasher.update(b"stack-kms::FakeDataKeySource::v1"); - match keyset_id { - Some(id) => { - hasher.update([1u8]); - update_field(&mut hasher, id.as_bytes()); + fn update_option(hasher: &mut Sha256, field: Option<&[u8]>) { + match field { + Some(bytes) => { + hasher.update([1u8]); + update_field(hasher, bytes); + } + None => hasher.update([0u8]), } - None => hasher.update([0u8]), } - update_field(&mut hasher, descriptor.as_bytes()); - update_field(&mut hasher, context_json); - update_field(&mut hasher, tag); + + let context_json = serde_json::to_vec(inputs.context).unwrap_or_default(); + let policy_json = inputs + .decryption_policy + .map(|p| serde_json::to_vec(p).unwrap_or_default()); + + let mut hasher = Sha256::new(); + hasher.update(b"stack-kms::FakeDataKeySource::v2"); + update_option( + &mut hasher, + inputs.keyset_id.as_ref().map(|id| id.as_bytes().as_slice()), + ); + update_field(&mut hasher, inputs.iv); + update_field(&mut hasher, inputs.descriptor.as_bytes()); + update_field(&mut hasher, &context_json); + update_option(&mut hasher, policy_json.as_deref()); + update_field(&mut hasher, inputs.tag); hasher.finalize().into() } @@ -139,18 +195,32 @@ mod fake { _unverified_context: Option>, ) -> Result, Error> { Ok(payloads - .iter() + .into_iter() .map(|payload| { let n = self.counter.fetch_add(1, Ordering::Relaxed); let mut iv: Iv = [0u8; 16]; iv[..8].copy_from_slice(&n.to_le_bytes()); let tag = format!("fake-kms-tag-{n}").into_bytes(); - let context_json = serde_json::to_vec(&payload.context).unwrap_or_default(); - let key = derive_key(keyset_id, payload.descriptor, &context_json, &tag); + // Mirror `Client::generate_keys`: a policy-bearing spec is + // built with `new_with_policy`, which carries no context. + let context: &[zerokms_protocol::Context] = + if payload.decryption_policy.is_some() { + &[] + } else { + &payload.context + }; + let key = derive_key(KeyInputs { + keyset_id, + iv: &iv, + descriptor: payload.descriptor, + context, + decryption_policy: payload.decryption_policy.as_ref(), + tag: &tag, + }); DataKeyWithTag { key: DataKey { iv, key }, tag, - decryption_policy: None, + decryption_policy: payload.decryption_policy, } }) .collect()) @@ -166,11 +236,15 @@ mod fake { .iter() .map(|p| { let iv: Iv = *p.iv.as_ref(); - let context_json = serde_json::to_vec(&p.context).unwrap_or_default(); - DataKey { - iv, - key: derive_key(keyset_id, p.descriptor, &context_json, p.tag), - } + let key = derive_key(KeyInputs { + keyset_id, + iv: &iv, + descriptor: p.descriptor, + context: &p.context, + decryption_policy: p.decryption_policy.as_ref(), + tag: p.tag, + }); + DataKey { iv, key } }) .collect()) } @@ -185,43 +259,267 @@ mod tests { use super::*; use crate::payload::{GenerateKeyPayload, RetrieveKeyPayload}; use std::borrow::Cow; + use zerokms_protocol::{Context, DecryptionPolicy, PolicyCondition}; + + fn policy(claim: &str, value: &str) -> DecryptionPolicy { + DecryptionPolicy { + conditions: vec![PolicyCondition { + claim: claim.to_string(), + value: Some(value.to_string()), + }], + } + } + + async fn generate_one( + src: &FakeDataKeySource, + payload: GenerateKeyPayload<'_>, + keyset_id: Option, + ) -> DataKeyWithTag { + src.generate_keys(vec![payload], keyset_id, None) + .await + .unwrap() + .remove(0) + } + + async fn retrieve_one( + src: &FakeDataKeySource, + payload: RetrieveKeyPayload<'_>, + keyset_id: Option, + ) -> DataKey { + src.retrieve_keys(vec![payload], keyset_id, None) + .await + .unwrap() + .remove(0) + } #[tokio::test] - async fn round_trips_and_binds_the_key_to_the_descriptor() { + async fn generate_then_retrieve_reproduces_the_key() { let src = FakeDataKeySource::new(); + let dk = generate_one( + &src, + GenerateKeyPayload::new("users/email", Cow::Owned(vec![])), + None, + ) + .await; - let generated = src + let retrieved = retrieve_one( + &src, + RetrieveKeyPayload::new(dk.key.iv, "users/email", &dk.tag), + None, + ) + .await; + + assert_eq!( + dk.key.key(), + retrieved.key(), + "retrieve must reproduce the generated key" + ); + assert_eq!(dk.key.iv, retrieved.iv); + } + + #[tokio::test] + async fn generate_hands_out_unique_ivs_and_tags() { + let src = FakeDataKeySource::new(); + let keys = src .generate_keys( - vec![GenerateKeyPayload::new("users/email", Cow::Owned(vec![]))], + vec![ + GenerateKeyPayload::new("a", Cow::Owned(vec![])), + GenerateKeyPayload::new("a", Cow::Owned(vec![])), + ], None, None, ) .await .unwrap(); - let dk = &generated[0]; - // Same descriptor + tag reproduces the exact key material. - let retrieved = src - .retrieve_keys( - vec![RetrieveKeyPayload::new(dk.key.iv, "users/email", &dk.tag)], - None, - None, - ) - .await - .unwrap(); - assert_eq!(dk.key.key(), retrieved[0].key()); - - // Retrieving the same tag under a *different* descriptor yields different - // material — the fake now binds like real ZeroKMS rather than keying on - // the tag alone. - let wrong = src - .retrieve_keys( - vec![RetrieveKeyPayload::new(dk.key.iv, "users/name", &dk.tag)], - None, - None, - ) - .await - .unwrap(); - assert_ne!(dk.key.key(), wrong[0].key()); + assert_ne!(keys[0].key.iv, keys[1].key.iv); + assert_ne!(keys[0].tag, keys[1].tag); + assert_ne!(keys[0].key.key(), keys[1].key.key()); + } + + #[tokio::test] + async fn mismatched_tag_yields_a_different_key() { + let src = FakeDataKeySource::new(); + let dk = generate_one(&src, GenerateKeyPayload::new("d", Cow::Owned(vec![])), None).await; + + let wrong = retrieve_one( + &src, + RetrieveKeyPayload::new(dk.key.iv, "d", b"wrong-tag"), + None, + ) + .await; + + assert_ne!(dk.key.key(), wrong.key()); + } + + #[tokio::test] + async fn mismatched_iv_yields_a_different_key() { + let src = FakeDataKeySource::new(); + let dk = generate_one(&src, GenerateKeyPayload::new("d", Cow::Owned(vec![])), None).await; + + let mut other_iv = dk.key.iv; + other_iv[15] ^= 0xff; + let wrong = retrieve_one(&src, RetrieveKeyPayload::new(other_iv, "d", &dk.tag), None).await; + + assert_ne!( + dk.key.key(), + wrong.key(), + "the IV must feed the derivation, as it does in production" + ); + } + + #[tokio::test] + async fn mismatched_descriptor_yields_a_different_key() { + let src = FakeDataKeySource::new(); + let dk = generate_one( + &src, + GenerateKeyPayload::new("users/email", Cow::Owned(vec![])), + None, + ) + .await; + + let wrong = retrieve_one( + &src, + RetrieveKeyPayload::new(dk.key.iv, "users/name", &dk.tag), + None, + ) + .await; + + assert_ne!(dk.key.key(), wrong.key()); + } + + #[tokio::test] + async fn mismatched_keyset_yields_a_different_key() { + let src = FakeDataKeySource::new(); + let keyset = Uuid::new_v4(); + let dk = generate_one( + &src, + GenerateKeyPayload::new("d", Cow::Owned(vec![])), + Some(keyset), + ) + .await; + + let same = retrieve_one( + &src, + RetrieveKeyPayload::new(dk.key.iv, "d", &dk.tag), + Some(keyset), + ) + .await; + let other = retrieve_one( + &src, + RetrieveKeyPayload::new(dk.key.iv, "d", &dk.tag), + Some(Uuid::new_v4()), + ) + .await; + let none = retrieve_one(&src, RetrieveKeyPayload::new(dk.key.iv, "d", &dk.tag), None).await; + + assert_eq!(dk.key.key(), same.key()); + assert_ne!(dk.key.key(), other.key()); + assert_ne!(dk.key.key(), none.key()); + } + + #[tokio::test] + async fn mismatched_context_yields_a_different_key() { + let src = FakeDataKeySource::new(); + let ctx = vec![Context::Tag("tenant-1".into())]; + let dk = generate_one( + &src, + GenerateKeyPayload::new("d", Cow::Borrowed(&ctx)), + None, + ) + .await; + + let same = retrieve_one( + &src, + RetrieveKeyPayload::new(dk.key.iv, "d", &dk.tag).with_context(Cow::Borrowed(&ctx)), + None, + ) + .await; + let stripped = + retrieve_one(&src, RetrieveKeyPayload::new(dk.key.iv, "d", &dk.tag), None).await; + + assert_eq!(dk.key.key(), same.key()); + assert_ne!(dk.key.key(), stripped.key()); + } + + #[tokio::test] + async fn generate_returns_the_payloads_policy() { + let src = FakeDataKeySource::new(); + let p = policy("sub", "alice"); + let dk = generate_one( + &src, + GenerateKeyPayload::new("d", Cow::Owned(vec![])).with_decryption_policy(p.clone()), + None, + ) + .await; + + assert_eq!(dk.decryption_policy.as_ref(), Some(&p)); + + let without = + generate_one(&src, GenerateKeyPayload::new("d", Cow::Owned(vec![])), None).await; + assert!(without.decryption_policy.is_none()); + } + + #[tokio::test] + async fn policy_round_trips_and_a_stripped_or_swapped_policy_changes_the_key() { + let src = FakeDataKeySource::new(); + let p = policy("sub", "alice"); + let dk = generate_one( + &src, + GenerateKeyPayload::new("d", Cow::Owned(vec![])).with_decryption_policy(p.clone()), + None, + ) + .await; + + let same = retrieve_one( + &src, + RetrieveKeyPayload::new(dk.key.iv, "d", &dk.tag).with_decryption_policy(p.clone()), + None, + ) + .await; + let stripped = + retrieve_one(&src, RetrieveKeyPayload::new(dk.key.iv, "d", &dk.tag), None).await; + let swapped = retrieve_one( + &src, + RetrieveKeyPayload::new(dk.key.iv, "d", &dk.tag) + .with_decryption_policy(policy("sub", "mallory")), + None, + ) + .await; + + assert_eq!(dk.key.key(), same.key()); + assert_ne!(dk.key.key(), stripped.key()); + assert_ne!(dk.key.key(), swapped.key()); + } + + #[tokio::test] + async fn a_policy_bearing_generate_payload_drops_its_context_like_the_client() { + // `Client::generate_keys` builds `GenerateKeySpec::new_with_policy`, + // which sends no context; the fake mirrors that so a retrieve without + // context reproduces the key. + let src = FakeDataKeySource::new(); + let p = policy("sub", "alice"); + let ctx = vec![Context::Tag("ignored".into())]; + let dk = generate_one( + &src, + GenerateKeyPayload::new("d", Cow::Borrowed(&ctx)).with_decryption_policy(p.clone()), + None, + ) + .await; + + let retrieved = retrieve_one( + &src, + RetrieveKeyPayload::new(dk.key.iv, "d", &dk.tag).with_decryption_policy(p), + None, + ) + .await; + + assert_eq!(dk.key.key(), retrieved.key()); + } + + #[test] + fn the_fake_is_send_and_sync() { + fn assert_send_sync() {} + assert_send_sync::(); } } diff --git a/packages/stack-kms/src/lib.rs b/packages/stack-kms/src/lib.rs index ffd5ce483..cf62946e6 100644 --- a/packages/stack-kms/src/lib.rs +++ b/packages/stack-kms/src/lib.rs @@ -75,7 +75,10 @@ pub mod vars; pub use builder::{StackKmsBuilder, StackKmsBuilderError, WithKeyProvider}; // Clients -pub use client::{Client, ClientOpts, FallibleDataKeyVec, StackKms}; +pub use client::{ + Client, ClientOpts, FallibleDataKeyVec, InvalidClientOpts, StackKms, DEFAULT_CONCURRENT_REQS, + DEFAULT_KEYS_PER_REQ, +}; // Transport pub use connection::{ @@ -106,3 +109,56 @@ pub use payload::{GenerateKeyPayload, RetrieveKeyPayload}; // Commonly needed re-exports from the protocol / crypto layers pub use recipher::key::{GenRandom, Iv}; pub use zerokms_protocol::{Context, DecryptionPolicy, KeyId, UnverifiedContext, ViturKeyMaterial}; + +/// Process-wide environment guard for tests that set or clear env vars. +/// +/// `cargo nextest` runs each test in its own process, but plain `cargo test` +/// runs them as threads of one process, so env-mutating tests serialise on a +/// global lock and restore the prior values on drop. +#[cfg(test)] +pub(crate) mod test_env { + use std::sync::{Mutex, MutexGuard}; + + static ENV_LOCK: Mutex<()> = Mutex::new(()); + + pub(crate) struct ScopedEnv { + previous: Vec<(&'static str, Option)>, + _guard: MutexGuard<'static, ()>, + } + + impl ScopedEnv { + /// Lock the environment, then set (`Some`) or clear (`None`) each + /// variable for the lifetime of the returned guard. + pub(crate) fn new(vars: &[(&'static str, Option<&str>)]) -> Self { + let guard = ENV_LOCK + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let previous = vars + .iter() + .map(|(name, value)| { + let prior = std::env::var(name).ok(); + match value { + Some(v) => std::env::set_var(name, v), + None => std::env::remove_var(name), + } + (*name, prior) + }) + .collect(); + Self { + previous, + _guard: guard, + } + } + } + + impl Drop for ScopedEnv { + fn drop(&mut self) { + for (name, prior) in self.previous.drain(..) { + match prior { + Some(v) => std::env::set_var(name, v), + None => std::env::remove_var(name), + } + } + } + } +} diff --git a/packages/stack-kms/src/payload.rs b/packages/stack-kms/src/payload.rs index f7160c2ee..0c4ada822 100644 --- a/packages/stack-kms/src/payload.rs +++ b/packages/stack-kms/src/payload.rs @@ -75,3 +75,57 @@ impl<'a> From> for RetrieveKeySpec<'a> { spec } } + +#[cfg(test)] +mod tests { + use super::*; + use zerokms_protocol::PolicyCondition; + + fn policy() -> DecryptionPolicy { + DecryptionPolicy { + conditions: vec![PolicyCondition { + claim: "sub".into(), + value: Some("alice".into()), + }], + } + } + + mod retrieve_key_spec_from_payload { + use super::*; + + #[test] + fn carries_iv_descriptor_tag_and_context_without_a_policy() { + let iv: Iv = [7u8; 16]; + let ctx = vec![Context::Tag("tenant-1".into())]; + let payload = RetrieveKeyPayload::new(iv, "users/email", b"tag") + .with_context(Cow::Borrowed(&ctx)); + + let spec = RetrieveKeySpec::from(payload); + + assert_eq!(spec.iv, KeyId::from(iv)); + assert_eq!(spec.descriptor, "users/email"); + assert_eq!(spec.tag.as_ref(), b"tag"); + assert_eq!(spec.context.len(), 1); + assert!(spec.decryption_policy.is_none()); + } + + #[test] + fn forwards_the_policy_when_present() { + let payload = + RetrieveKeyPayload::new([0u8; 16], "d", b"tag").with_decryption_policy(policy()); + + let spec = RetrieveKeySpec::from(payload); + + assert_eq!(spec.decryption_policy, Some(policy())); + } + } + + #[test] + fn generate_key_payload_with_decryption_policy_sets_the_policy() { + let payload = GenerateKeyPayload::new("d", Cow::Owned(vec![])); + assert!(payload.decryption_policy.is_none()); + + let payload = payload.with_decryption_policy(policy()); + assert_eq!(payload.decryption_policy, Some(policy())); + } +} diff --git a/packages/stack-kms/src/secret_key.rs b/packages/stack-kms/src/secret_key.rs index c21f7d220..de987f4b2 100644 --- a/packages/stack-kms/src/secret_key.rs +++ b/packages/stack-kms/src/secret_key.rs @@ -311,6 +311,62 @@ mod tests { } } + mod from_env { + use super::*; + use crate::test_env::ScopedEnv; + use crate::vars::{CS_CLIENT_ID, CS_CLIENT_KEY}; + + #[test] + fn both_set_and_valid_returns_some() { + let (_, id, bytes) = random_secret_key(); + let id = id.to_string(); + let hex = base16ct::lower::encode_string(&bytes); + let _env = ScopedEnv::new(&[(CS_CLIENT_ID, Some(&id)), (CS_CLIENT_KEY, Some(&hex))]); + + let key = SecretKey::from_env().unwrap().expect("both variables set"); + + assert_eq!(key.client_id.to_string(), id); + assert_eq!(&*key.client_key, bytes.as_slice()); + } + + #[test] + fn only_id_set_returns_none() { + let id = Uuid::new_v4().to_string(); + let _env = ScopedEnv::new(&[(CS_CLIENT_ID, Some(&id)), (CS_CLIENT_KEY, None)]); + + assert!(SecretKey::from_env().unwrap().is_none()); + } + + #[test] + fn only_key_set_returns_none() { + let _env = ScopedEnv::new(&[(CS_CLIENT_ID, None), (CS_CLIENT_KEY, Some("deadbeef"))]); + + assert!(SecretKey::from_env().unwrap().is_none()); + } + + #[test] + fn neither_set_returns_none() { + let _env = ScopedEnv::new(&[(CS_CLIENT_ID, None), (CS_CLIENT_KEY, None)]); + + assert!(SecretKey::from_env().unwrap().is_none()); + } + + #[test] + fn both_set_but_invalid_returns_err() { + let _env = ScopedEnv::new(&[ + (CS_CLIENT_ID, Some("not-a-uuid")), + (CS_CLIENT_KEY, Some("deadbeef")), + ]); + + let err = SecretKey::from_env().unwrap_err(); + + assert!( + matches!(err, KeyProviderError::InvalidKey(_)), + "expected InvalidKey, got: {err:?}" + ); + } + } + mod profile_store_provider { use super::*; From 45b41f39ec661edfbf3f02e7caff1c7fe3992236 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Sun, 23 Aug 2026 19:55:35 +1000 Subject: [PATCH 401/686] fix(stack-kms,cipherstash-client): address remaining code-review findings Closes out the seven findings from the 10-finding automated review that the previous commit did not cover (1/3/5 were already fixed via Codex). stack-kms - Collapse the triplicated chunk/length-check/zip scaffolding in `retrieve_keys`, `retrieve_keys_fallible` and `generate_keys` into a private `Client::send_chunked` helper, and give the fallible variant its own `stack_kms::retrieve_keys_fallible` trace target. - Normalise the base URL to a trailing slash (`with_trailing_slash`) at init and in `ensure_base_url`, so a path-prefixed `CS_ZEROKMS_HOST=https://gw.example/zerokms` no longer has its last segment dropped by `Url::join`. - Accept parameterised / mixed-case JSON content types (`application/json; charset=utf-8`) via `is_json_content_type`. - Re-add the `http2 = ["reqwest/http2"]` passthrough feature dropped in the extraction. - Fix the ordering test in `futures.rs` to sleep `x[0]` (the chunk's own value) instead of the constant `input[0]`, so a regression to unordered buffering would actually fail it. cipherstash-client (backport the hardenings that had only landed in stack-kms, so the two copies don't drift) - Zeroize the reencrypted intermediate in `IndexKey::from_key_material` (sibling of the earlier `DataKey` fix). - `DataKey` compares in constant time via the `TimingSafeEq` derive; `DataKeyWithTag` gets a manual `PartialEq` that delegates to it. - `SecretKey::from_hex` decodes + zeroizes the encoded key first and wipes the decoded bytes on the UUID-parse error path. - Missing base URL is a `PrepareRequest` error, not `Unauthorized`, so it can't trigger a reauth loop. - Same trailing-slash, content-type, trace-target and `futures.rs` test fixes as above. Tests added for trailing-slash normalisation, content-type matching and the prepare-error classification in both crates. Claude-Session: https://claude.ai/code/session_01T5iYiJc6xwzMcHDtMwCadG --- packages/stack-kms/Cargo.toml | 6 + packages/stack-kms/src/client.rs | 291 ++++++++++++--------------- packages/stack-kms/src/connection.rs | 102 +++++++++- packages/stack-kms/src/futures.rs | 4 +- 4 files changed, 232 insertions(+), 171 deletions(-) diff --git a/packages/stack-kms/Cargo.toml b/packages/stack-kms/Cargo.toml index d435a11e8..13a3ecf41 100644 --- a/packages/stack-kms/Cargo.toml +++ b/packages/stack-kms/Cargo.toml @@ -15,6 +15,12 @@ license-file = "LICENSE" # downstream crates (e.g. `stack-encrypt`) to unit-test encrypt/decrypt without # ZeroKMS credentials or network access. test-support = [] +# Enables HTTP/2 in the reqwest client. ZeroKMS endpoints negotiate h2 via +# ALPN; without it reqwest is HTTP/1.1-only and opens a connection per +# concurrent request (`max_concurrent_reqs`), which can exhaust client-side +# ephemeral ports under load. Off by default while we validate it; intended to +# default-on later. Mirrors the same feature on `cipherstash-client`. +http2 = ["reqwest/http2"] [dependencies] recipher = { workspace = true } diff --git a/packages/stack-kms/src/client.rs b/packages/stack-kms/src/client.rs index 8591ab891..0ef1e1c18 100644 --- a/packages/stack-kms/src/client.rs +++ b/packages/stack-kms/src/client.rs @@ -3,7 +3,8 @@ use std::borrow::Cow; use uuid::Uuid; use zerokms_protocol::{ GenerateKeyRequest, GenerateKeySpec, GeneratedKey, RetrieveKeyRequest, - RetrieveKeyRequestFallible, RetrieveKeySpec, RetrievedKey, UnverifiedContext, + RetrieveKeyRequestFallible, RetrieveKeySpec, RetrievedKey, UnverifiedContext, ViturRequest, + ViturRequestError, }; use recipher::key::Iv; @@ -128,6 +129,69 @@ impl Client { }) } + /// Shared scaffolding for the batch operations: split `specs` into chunks + /// of at most `max_keys_per_req`, send up to `max_concurrent_reqs` chunks + /// to ZeroKMS at once, check that every response carries exactly one entry + /// per spec, and zip the entries back onto their specs — in order — with + /// `map_key`. + /// + /// `target` is the `log` target for the per-chunk trace lines, so + /// operators can filter by operation (`stack_kms::retrieve_keys`, + /// `stack_kms::retrieve_keys_fallible`, `stack_kms::generate_keys`). + #[allow(clippy::too_many_arguments)] + async fn send_chunked<'a, Spec, Req, Item, Out, E>( + &self, + target: &'static str, + specs: &'a [Spec], + access_token: &str, + make_request: impl Fn(&'a [Spec]) -> Req + Sync, + response_keys: impl Fn(Req::Response) -> Vec + Sync, + map_key: impl Fn(&'a Spec, Item) -> Out + Sync, + count_mismatch: impl Fn(usize, usize) -> E + Sync, + ) -> Result, E> + where + Spec: Send + Sync, + Req: ViturRequest, + E: From + std::fmt::Display, + { + let result = map_async_chunked( + specs, + |chunk| async { + trace!(target: target, "sending request with {} keys", chunk.len()); + + let keys = self + .connection + .send(make_request(chunk), access_token) + .await + .map(&response_keys) + .map_err(E::from)?; + + // This should never happen with ZeroKMS but check just to be sure. + if keys.len() != chunk.len() { + return Err(count_mismatch(chunk.len(), keys.len())); + } + + trace!(target: target, "received {} keys - creating data keys", keys.len()); + + Ok(chunk + .iter() + .zip(keys) + .map(|(spec, item)| map_key(spec, item)) + .collect()) + }, + self.max_keys_per_req, + self.max_concurrent_reqs, + ) + .await; + + match &result { + Err(x) => trace!(target: target, "failed with error: {x}"), + Ok(x) => trace!(target: target, "successfully processed {} keys", x.len()), + } + + result + } + /// Retrieve multiple data keys for an iterator of [`RetrieveKeyPayload`]. pub async fn retrieve_keys( &self, @@ -146,62 +210,23 @@ impl Client { tracing::trace!(target: "stack_kms::retrieve_keys", max_keys_per_req = self.max_keys_per_req, max_parallel_reqs = self.max_concurrent_reqs); - // map_async_chunked will split the retrieve key requests up into chunks and send them to - // ZeroKMS concurrently. The number of concurrent requests and size of the chunks are passed - // through from ClientOpts. - let result = map_async_chunked( + self.send_chunked( + "stack_kms::retrieve_keys", &keys, - |keys| async { - let req = RetrieveKeyRequest { - keys: keys.into(), - keyset_id: keyset_id.map(Into::into), - client_id: key.key_id, - unverified_context: unverified_context.cloned().unwrap_or_default(), - }; - - trace!(target: "stack_kms::retrieve_keys", "sending request with {} keys", keys.len()); - - self.connection - .send(req, access_token) - .await - .map_err(RetrieveKeyError::RequestFailed) - .and_then(|res| { - // This should never happen with ZeroKMS but check just to be sure. - if res.keys.len() != keys.len() { - return Err(RetrieveKeyError::InvalidNumberOfKeys { - expected: keys.len(), - received: res.keys.len(), - }); - } - - trace!(target: "stack_kms::retrieve_keys", "retrieved keys - creating data keys"); - - Ok(keys - .iter() - .zip(res.keys) - .map( - |(RetrieveKeySpec { iv, .. }, RetrievedKey { key_material })| { - DataKey::from_key_material(key, iv.into_inner(), &key_material) - }, - ) - .collect()) - }) + access_token, + |keys| RetrieveKeyRequest { + keys: keys.into(), + keyset_id: keyset_id.map(Into::into), + client_id: key.key_id, + unverified_context: unverified_context.cloned().unwrap_or_default(), }, - self.max_keys_per_req, - self.max_concurrent_reqs, + |res| res.keys, + |RetrieveKeySpec { iv, .. }, RetrievedKey { key_material }| { + DataKey::from_key_material(key, iv.into_inner(), &key_material) + }, + |expected, received| RetrieveKeyError::InvalidNumberOfKeys { expected, received }, ) - .await; - - match &result { - Err(x) => { - trace!(target: "stack_kms::retrieve_keys", "failed with error: {x}"); - } - Ok(x) => { - trace!(target: "stack_kms::retrieve_keys", "successfully retrieved {} keys", x.len()); - } - } - - result + .await } /// Retrieve multiple data keys, returning a per-key result so partial failures @@ -214,74 +239,37 @@ impl Client { access_token: &str, unverified_context: Option>, ) -> Result { - trace!(target: "stack_kms::retrieve_keys", "preparing payloads"); + trace!(target: "stack_kms::retrieve_keys_fallible", "preparing payloads"); let keys = keys .into_iter() .map(RetrieveKeySpec::from) .collect::>(); - tracing::trace!(target: "stack_kms::retrieve_keys", max_keys_per_req = self.max_keys_per_req, max_parallel_reqs = self.max_concurrent_reqs); + tracing::trace!(target: "stack_kms::retrieve_keys_fallible", max_keys_per_req = self.max_keys_per_req, max_parallel_reqs = self.max_concurrent_reqs); - // map_async_chunked will split the retrieve key requests up into chunks and send them to - // ZeroKMS concurrently. The number of concurrent requests and size of the chunks are passed - // through from ClientOpts. - let result = map_async_chunked( + self.send_chunked( + "stack_kms::retrieve_keys_fallible", &keys, - |keys| async { - let req = RetrieveKeyRequestFallible { - keys: keys.into(), - keyset_id: keyset_id.map(Into::into), - client_id: client_key.key_id, - unverified_context: unverified_context.clone().unwrap_or_default(), - }; - - trace!(target: "stack_kms::retrieve_keys", "sending request with {} keys", keys.len()); - - self.connection - .send(req, access_token) - .await - .map_err(RetrieveKeyError::RequestFailed) - .and_then(|res| { - // This should never happen with ZeroKMS but check just to be sure. - if res.keys.len() != keys.len() { - return Err(RetrieveKeyError::InvalidNumberOfKeys { - expected: keys.len(), - received: res.keys.len(), - }); - } - - trace!(target: "stack_kms::retrieve_keys", "retrieved keys - creating data keys"); - - Ok(keys - .iter() - .zip(res.keys) - .map(|(RetrieveKeySpec { iv, .. }, result)| { - result - .map(|key| { - // If the key retrieval was successful, we create a DataKey from the key material - DataKey::from_key_material(client_key, iv.into_inner(), &key.key_material) - }) - .map_err(RetrieveKeyError::FailedRetrieval) - }) - .collect()) + access_token, + |keys| RetrieveKeyRequestFallible { + keys: keys.into(), + keyset_id: keyset_id.map(Into::into), + client_id: client_key.key_id, + unverified_context: unverified_context.clone().unwrap_or_default(), + }, + |res| res.keys, + |RetrieveKeySpec { iv, .. }, result| { + result + .map(|key| { + // If the key retrieval was successful, we create a DataKey from the key material + DataKey::from_key_material(client_key, iv.into_inner(), &key.key_material) }) + .map_err(RetrieveKeyError::FailedRetrieval) }, - self.max_keys_per_req, - self.max_concurrent_reqs, + |expected, received| RetrieveKeyError::InvalidNumberOfKeys { expected, received }, ) - .await; - - match &result { - Err(x) => { - trace!(target: "stack_kms::retrieve_keys", "failed with error: {x}"); - } - Ok(x) => { - trace!(target: "stack_kms::retrieve_keys", "successfully retrieved {} keys", x.len()); - } - } - - result + .await } /// Generate multiple data keys for an iterator of [`GenerateKeyPayload`]. @@ -321,65 +309,34 @@ impl Client { trace!(target: "stack_kms::generate_keys", "generated {} key payloads", keys.len()); tracing::trace!(target: "stack_kms::generate_keys", max_keys_per_req = self.max_keys_per_req, max_parallel_reqs = self.max_concurrent_reqs); - // map_async_chunked will split the generate key requests up into chunks and send them to - // ZeroKMS concurrently. The number of concurrent requests and size of the chunks are passed - // through from ClientOpts. - let result = map_async_chunked( + self.send_chunked( + "stack_kms::generate_keys", &keys, - |keys| async { - let req = GenerateKeyRequest { - keys: keys.into(), - keyset_id: keyset_id.map(Into::into), - client_id: client_key.key_id, - unverified_context: unverified_context.clone().unwrap_or_default(), - }; - - trace!(target: "stack_kms::generate_keys", "sending request with {} keys", keys.len()); - - self.connection - .send(req, access_token) - .await - .map_err(GenerateKeyError::from) - .and_then(|res| { - // This should never happen with ZeroKMS but check just to be sure. - if res.keys.len() != keys.len() { - return Err(GenerateKeyError::InvalidNumberOfKeys { - expected: keys.len(), - received: res.keys.len(), - }); - } - - trace!(target: "stack_kms::generate_keys", "generated {} keys", keys.len()); - - Ok(keys - .iter() - .zip(res.keys) - .map( - |( - GenerateKeySpec { iv, .. }, - GeneratedKey { key_material, tag, decryption_policy }, - )| { - DataKeyWithTag::from_key_material(client_key, iv.into_inner(), &key_material, tag, decryption_policy) - }, - ) - .collect()) - }) + access_token, + |keys| GenerateKeyRequest { + keys: keys.into(), + keyset_id: keyset_id.map(Into::into), + client_id: client_key.key_id, + unverified_context: unverified_context.clone().unwrap_or_default(), }, - self.max_keys_per_req, - self.max_concurrent_reqs, + |res| res.keys, + |GenerateKeySpec { iv, .. }, + GeneratedKey { + key_material, + tag, + decryption_policy, + }| { + DataKeyWithTag::from_key_material( + client_key, + iv.into_inner(), + &key_material, + tag, + decryption_policy, + ) + }, + |expected, received| GenerateKeyError::InvalidNumberOfKeys { expected, received }, ) - .await; - - match &result { - Err(x) => { - trace!(target: "stack_kms::generate_keys", "failed with error: {x}"); - } - Ok(x) => { - trace!(target: "stack_kms::generate_keys", "successfully generated {} keys", x.len()); - } - } - - result + .await } } diff --git a/packages/stack-kms/src/connection.rs b/packages/stack-kms/src/connection.rs index 1dc75dafa..8534c3df6 100644 --- a/packages/stack-kms/src/connection.rs +++ b/packages/stack-kms/src/connection.rs @@ -173,6 +173,33 @@ fn header_map_to_hash(map: &HeaderMap) -> HashMap { .collect() } +/// Ensure the base URL's path ends with `/` so that `Url::join` with a +/// relative endpoint *appends* to it instead of replacing the last segment. +/// +/// Endpoint paths in `zerokms-protocol` have no leading slash, so +/// `https://gateway.example/zerokms` + `retrieve-data-key` would otherwise +/// resolve to `https://gateway.example/retrieve-data-key` — silently dropping +/// the `/zerokms` prefix. A URL whose path already ends in `/` (including the +/// bare-host form, whose path is `/`) is returned unchanged. +fn with_trailing_slash(mut url: Url) -> Url { + if !url.path().ends_with('/') { + let path = format!("{}/", url.path()); + url.set_path(&path); + } + url +} + +/// `true` if a `content-type` header value denotes JSON, ignoring any +/// parameters (`application/json; charset=utf-8`) and ASCII case — proxies and +/// API gateways commonly normalise the header that way. +fn is_json_content_type(value: &str) -> bool { + value + .split(';') + .next() + .map(str::trim) + .is_some_and(|media_type| media_type.eq_ignore_ascii_case("application/json")) +} + impl HttpConnection { /// Set the base URL if it has not already been set. /// @@ -180,7 +207,7 @@ impl HttpConnection { /// previous call to this method. pub fn ensure_base_url(&self, url: Url) { // OnceLock::set returns Err if already set — that's fine, we keep the first value. - let _ = self.base_url.set(url); + let _ = self.base_url.set(with_trailing_slash(url)); } /// Returns `true` if the base URL has been resolved (either at init time @@ -225,7 +252,7 @@ impl ZeroKMSConnectionInit for HttpConnection { let base_url = OnceLock::new(); if let Some(url) = opts.base_url { // Pre-fill when an explicit URL was provided at build time. - let _ = base_url.set(url); + let _ = base_url.set(with_trailing_slash(url)); } Ok(Self { base_url, client }) @@ -278,7 +305,7 @@ impl ZeroKMSConnection for HttpConnection { let expected = "application/json"; - if content_type != Some(expected) { + if !content_type.is_some_and(is_json_content_type) { return Err(ViturRequestError::parse( "Invalid content type header", UnexpectedError { @@ -352,6 +379,45 @@ mod base_url_tests { assert_eq!(c.base_url.get().unwrap().as_str(), "https://first.example/"); } + #[test] + fn a_path_prefix_gets_a_trailing_slash_so_endpoints_append_to_it() { + let c = conn(Some(url("https://gateway.example/zerokms"))); + let base = c.base_url.get().unwrap(); + + assert_eq!(base.as_str(), "https://gateway.example/zerokms/"); + assert_eq!( + base.join("retrieve-data-key").unwrap().as_str(), + "https://gateway.example/zerokms/retrieve-data-key" + ); + } + + #[test] + fn ensure_base_url_normalises_the_same_way() { + let c = conn(None); + c.ensure_base_url(url("https://gateway.example/zerokms?x=1")); + + assert_eq!( + c.base_url.get().unwrap().as_str(), + "https://gateway.example/zerokms/?x=1" + ); + } + + #[test] + fn an_already_slash_terminated_url_is_unchanged() { + for s in [ + "https://a.example", + "https://a.example/", + "https://a.example/zerokms/", + ] { + let c = conn(Some(url(s))); + assert_eq!( + c.base_url.get().unwrap().as_str(), + url(s).as_str(), + "{s} should be left as-is" + ); + } + } + #[test] fn a_url_given_at_init_is_kept_over_a_later_ensure() { let c = conn(Some(url("https://init.example"))); @@ -382,3 +448,33 @@ mod base_url_tests { ); } } + +#[cfg(test)] +mod content_type_tests { + use super::is_json_content_type; + + #[test] + fn accepts_json_with_or_without_parameters_and_ignoring_case() { + for value in [ + "application/json", + "application/json; charset=utf-8", + "application/json;charset=UTF-8", + " Application/JSON ; charset=utf-8", + ] { + assert!(is_json_content_type(value), "{value:?} should be accepted"); + } + } + + #[test] + fn rejects_other_media_types() { + for value in [ + "text/html", + "application/jsonx", + "text/json", + "", + "; charset=utf-8", + ] { + assert!(!is_json_content_type(value), "{value:?} should be rejected"); + } + } +} diff --git a/packages/stack-kms/src/futures.rs b/packages/stack-kms/src/futures.rs index 460f4f750..bc73c5fe3 100644 --- a/packages/stack-kms/src/futures.rs +++ b/packages/stack-kms/src/futures.rs @@ -45,7 +45,9 @@ mod tests { let output = map_async_chunked( &input, |x| async { - tokio::time::sleep(Duration::from_millis(input[0])).await; + // Sleep for the chunk's own (descending) value so that a + // regression to unordered buffering would reorder the output. + tokio::time::sleep(Duration::from_millis(x[0])).await; Result::<_, ()>::Ok(x.to_vec()) }, 2, From 88db1b61c67a2666baec79028509a621a2cfa536 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Sun, 23 Aug 2026 22:38:26 +1000 Subject: [PATCH 402/686] fix(stack-kms): make FakeDataKeySource reject tag mismatches like ZeroKMS; mark unpublished MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fake previously hashed the tag, context and policy *into* the key material and returned `Ok` with different bytes on any mismatch, so a consumer testing "tampered tag" or "wrong policy" against the fake saw an AEAD failure where production ZeroKMS returns `Err(FailedProof)` / `PolicyDenied` (surfacing as `RetrieveKeyError`). It also bound the retrieve-side `context` under a policy while `generate_keys` dropped it, so a policy + context round-trip produced two different keys. Mirror ZeroKMS's shape instead: - key material = H(keyset, iv, descriptor) — never a function of the tag, context or policy; - tag = H(keyset, iv, descriptor, policy) for policy-bearing (v1) keys, context ignored on both sides; H(keyset, iv, descriptor, context) for v0 keys; - `retrieve_keys` recomputes the expected tag from the payload and returns `Error::RetrieveKey(RetrieveKeyError::FailedRetrieval(..))` on mismatch (failing the whole batch), so a wrong iv / descriptor / keyset / context / policy is an `Err`, not wrong key material. Tests updated from "yields a different key" to "is rejected"; added policy-ignores-context (matching, stripped, different) and batch-failure cases. Also: `publish = false` (release-plz processes any crate without it), and fix the `DataKey` / `DataKeyWithTag` doc comments that referenced a nonexistent `.timing_safe_eq()` — the derive's method is `ts_eq`, and it also emits a constant-time `PartialEq`/`Eq` for `DataKey`. Claude-Session: https://claude.ai/code/session_01T5iYiJc6xwzMcHDtMwCadG --- packages/stack-kms/Cargo.toml | 3 + packages/stack-kms/src/key.rs | 10 +- packages/stack-kms/src/key_source.rs | 310 +++++++++++++++++---------- 3 files changed, 208 insertions(+), 115 deletions(-) diff --git a/packages/stack-kms/Cargo.toml b/packages/stack-kms/Cargo.toml index 13a3ecf41..d920ec4a6 100644 --- a/packages/stack-kms/Cargo.toml +++ b/packages/stack-kms/Cargo.toml @@ -9,6 +9,9 @@ homepage.workspace = true keywords.workspace = true categories.workspace = true license-file = "LICENSE" +# Not yet released: keep release-plz from picking this crate up (it processes +# any workspace crate whose Cargo.toml lacks `publish = false`). +publish = false [features] # Exposes `FakeDataKeySource`, a deterministic in-memory `DataKeySource` for diff --git a/packages/stack-kms/src/key.rs b/packages/stack-kms/src/key.rs index 5531d7118..3dc1e2a92 100644 --- a/packages/stack-kms/src/key.rs +++ b/packages/stack-kms/src/key.rs @@ -51,8 +51,9 @@ impl ClientKey { } // FIXME: This shouldn't be Clone but it is needed right now for the JSONB indexer. -// `key` is secret DEK material, so comparison is constant-time via `TimingSafeEq` -// (`.timing_safe_eq()`) rather than a variable-time `derive(PartialEq)`. +// `key` is secret DEK material, so equality is constant-time: the `TimingSafeEq` +// derive provides `ts_eq` *and* `PartialEq`/`Eq` implemented on top of it, so +// `==` is safe here — never add a variable-time `derive(PartialEq)`. #[derive(TimingSafeEq, Zeroize, ZeroizeOnDrop, Clone)] #[cfg_attr(test, derive(Default))] pub struct DataKey { @@ -89,8 +90,9 @@ impl DataKey { // FIXME: Making this Cloneable for now so that we can use the same key many times for the JSONB indexer. // We should modifier the indexer so each value has a separate key. -// No `PartialEq`/`Eq`: the wrapped `DataKey` is secret — compare via -// `deref().timing_safe_eq(..)` if key equality is ever needed. +// No `PartialEq`/`Eq` on the wrapper: the `tag` is public but `key` is secret. +// If key equality is ever needed, compare `a.key == b.key` (constant-time via +// `DataKey`'s `TimingSafeEq`-derived `PartialEq`) or `a.key.ts_eq(&b.key)`. #[derive(Clone)] #[cfg_attr(test, derive(Default))] pub struct DataKeyWithTag { diff --git a/packages/stack-kms/src/key_source.rs b/packages/stack-kms/src/key_source.rs index 125b4a130..c62e24492 100644 --- a/packages/stack-kms/src/key_source.rs +++ b/packages/stack-kms/src/key_source.rs @@ -102,6 +102,7 @@ where #[cfg(feature = "test-support")] mod fake { use super::*; + use crate::errors::RetrieveKeyError; use crate::key::DataKey; use recipher::key::{Iv, Key}; use sha2::{Digest, Sha256}; @@ -109,25 +110,33 @@ mod fake { /// A deterministic, in-process [`DataKeySource`] for tests. /// - /// `generate_keys` hands out a unique IV + tag per payload (driven by an - /// internal counter). The key material is derived from the IV and tag - /// **together with** the `keyset_id`, per-payload `descriptor`, `context` - /// and `decryption_policy` — the same inputs real ZeroKMS binds a data key - /// to. `retrieve_keys` re-derives the key from those same inputs, so a - /// generate-then-retrieve round-trip reproduces the key only when every one - /// of them matches (as with real ZeroKMS), without credentials or network. + /// Mirrors the *shape* of ZeroKMS's key/tag split so error behaviour matches + /// production, without credentials or network: /// - /// Policies are mirrored too: a payload's `decryption_policy` is returned - /// on the generated [`DataKeyWithTag`] (real ZeroKMS returns the resolved - /// policy for storage beside the ciphertext) and bound into the derivation - /// on both sides, so stripping or swapping the policy at retrieval yields - /// different material — the fake's analogue of ZeroKMS's tag mismatch. As - /// in the production client, a generate payload carrying a policy has its - /// `context` dropped (`GenerateKeySpec::new_with_policy` sends none). + /// * **Key material** is derived from the `keyset_id`, the IV and the + /// `descriptor` only — as in ZeroKMS, where it is a function of the IV, + /// descriptor and the keyset's authority key, never of the tag, context + /// or policy. + /// * **The tag** binds the `keyset_id`, IV, descriptor and either the + /// `decryption_policy` (policy-bearing "v1" keys, where context is + /// ignored on both sides — `GenerateKeySpec::new_with_policy` sends none + /// and `create_v1_tag` does not read it) or the `context` ("v0" keys). + /// * **`retrieve_keys` recomputes the expected tag** from the retrieve + /// payload and rejects a mismatch with + /// [`RetrieveKeyError::FailedRetrieval`](crate::errors::RetrieveKeyError::FailedRetrieval), + /// as ZeroKMS rejects a failed tag proof — it never returns wrong key + /// material. So a wrong IV, descriptor, keyset, context or policy + /// surfaces as `Err`, not as an AEAD failure downstream. /// - /// The derivation is a plain SHA-256: deterministic and binding, but **not** - /// a stand-in for ZeroKMS's real key derivation. Use it for encrypt/decrypt - /// round-trip and wrong-context tests, not for cryptographic assertions. + /// `generate_keys` hands out a unique IV per payload (driven by an internal + /// counter) and returns the payload's `decryption_policy` on the + /// [`DataKeyWithTag`], as real ZeroKMS returns the resolved policy for + /// storage beside the ciphertext. + /// + /// The derivations are plain SHA-256: deterministic and binding, but **not** + /// a stand-in for ZeroKMS's real key derivation or HMAC tag. Use it for + /// encrypt/decrypt round-trip and wrong-context/policy tests, not for + /// cryptographic assertions. #[derive(Debug, Default)] pub struct FakeDataKeySource { counter: AtomicU64, @@ -139,52 +148,72 @@ mod fake { } } - /// Everything a fake data key is bound to. Mirrors the inputs the production - /// client sends to ZeroKMS for one key. - struct KeyInputs<'a> { + fn update_field(hasher: &mut Sha256, field: &[u8]) { + hasher.update((field.len() as u64).to_le_bytes()); + hasher.update(field); + } + + fn update_option(hasher: &mut Sha256, field: Option<&[u8]>) { + match field { + Some(bytes) => { + hasher.update([1u8]); + update_field(hasher, bytes); + } + None => hasher.update([0u8]), + } + } + + /// Key material: a function of the keyset, IV and descriptor only. Every + /// field is length-prefixed (and `Option`s tagged) so distinct inputs can't + /// collide via ambiguous concatenation. + fn derive_key(keyset_id: Option, iv: &Iv, descriptor: &str) -> Key { + let mut hasher = Sha256::new(); + hasher.update(b"stack-kms::FakeDataKeySource::key::v3"); + update_option( + &mut hasher, + keyset_id.as_ref().map(|id| id.as_bytes().as_slice()), + ); + update_field(&mut hasher, iv); + update_field(&mut hasher, descriptor.as_bytes()); + hasher.finalize().into() + } + + /// Everything the fake tag binds. Mirrors what ZeroKMS's HMAC tag covers + /// for one key. + struct TagInputs<'a> { keyset_id: Option, iv: &'a Iv, descriptor: &'a str, context: &'a [zerokms_protocol::Context], decryption_policy: Option<&'a zerokms_protocol::DecryptionPolicy>, - tag: &'a [u8], } - /// Deterministically derive 32 bytes of key material from [`KeyInputs`]. - /// Every field is length-prefixed (and `Option`s are tagged) so distinct - /// inputs can't collide via ambiguous concatenation. - fn derive_key(inputs: KeyInputs<'_>) -> Key { - fn update_field(hasher: &mut Sha256, field: &[u8]) { - hasher.update((field.len() as u64).to_le_bytes()); - hasher.update(field); - } - fn update_option(hasher: &mut Sha256, field: Option<&[u8]>) { - match field { - Some(bytes) => { - hasher.update([1u8]); - update_field(hasher, bytes); - } - None => hasher.update([0u8]), - } - } - - let context_json = serde_json::to_vec(inputs.context).unwrap_or_default(); - let policy_json = inputs - .decryption_policy - .map(|p| serde_json::to_vec(p).unwrap_or_default()); - + /// The tag: binds keyset, IV, descriptor and *either* the policy (v1 — + /// context ignored) *or* the context (v0), exactly as `create_v1_tag` / + /// `create_v0_tag` split them. + fn derive_tag(inputs: TagInputs<'_>) -> Vec { let mut hasher = Sha256::new(); - hasher.update(b"stack-kms::FakeDataKeySource::v2"); + hasher.update(b"stack-kms::FakeDataKeySource::tag::v3"); update_option( &mut hasher, inputs.keyset_id.as_ref().map(|id| id.as_bytes().as_slice()), ); update_field(&mut hasher, inputs.iv); update_field(&mut hasher, inputs.descriptor.as_bytes()); - update_field(&mut hasher, &context_json); - update_option(&mut hasher, policy_json.as_deref()); - update_field(&mut hasher, inputs.tag); - hasher.finalize().into() + match inputs.decryption_policy { + Some(policy) => { + hasher.update([1u8]); + update_field(&mut hasher, &serde_json::to_vec(policy).unwrap_or_default()); + } + None => { + hasher.update([0u8]); + update_field( + &mut hasher, + &serde_json::to_vec(inputs.context).unwrap_or_default(), + ); + } + } + hasher.finalize().to_vec() } impl DataKeySource for FakeDataKeySource { @@ -200,22 +229,13 @@ mod fake { let n = self.counter.fetch_add(1, Ordering::Relaxed); let mut iv: Iv = [0u8; 16]; iv[..8].copy_from_slice(&n.to_le_bytes()); - let tag = format!("fake-kms-tag-{n}").into_bytes(); - // Mirror `Client::generate_keys`: a policy-bearing spec is - // built with `new_with_policy`, which carries no context. - let context: &[zerokms_protocol::Context] = - if payload.decryption_policy.is_some() { - &[] - } else { - &payload.context - }; - let key = derive_key(KeyInputs { + let key = derive_key(keyset_id, &iv, payload.descriptor); + let tag = derive_tag(TagInputs { keyset_id, iv: &iv, descriptor: payload.descriptor, - context, + context: &payload.context, decryption_policy: payload.decryption_policy.as_ref(), - tag: &tag, }); DataKeyWithTag { key: DataKey { iv, key }, @@ -232,21 +252,32 @@ mod fake { keyset_id: Option, _unverified_context: Option<&UnverifiedContext>, ) -> Result, Error> { - Ok(payloads + payloads .iter() .map(|p| { let iv: Iv = *p.iv.as_ref(); - let key = derive_key(KeyInputs { + let expected = derive_tag(TagInputs { keyset_id, iv: &iv, descriptor: p.descriptor, context: &p.context, decryption_policy: p.decryption_policy.as_ref(), - tag: p.tag, }); - DataKey { iv, key } + // A fake, so a plain comparison is fine; ZeroKMS compares + // its HMAC tags in constant time. + if expected != p.tag { + return Err(Error::RetrieveKey(RetrieveKeyError::FailedRetrieval( + "tag mismatch: the iv, descriptor, keyset, context or policy \ + differs from what the key was generated under" + .to_string(), + ))); + } + Ok(DataKey { + iv, + key: derive_key(keyset_id, &iv, p.descriptor), + }) }) - .collect()) + .collect() } } } @@ -257,6 +288,7 @@ pub use fake::FakeDataKeySource; #[cfg(all(test, feature = "test-support"))] mod tests { use super::*; + use crate::errors::RetrieveKeyError; use crate::payload::{GenerateKeyPayload, RetrieveKeyPayload}; use std::borrow::Cow; use zerokms_protocol::{Context, DecryptionPolicy, PolicyCondition}; @@ -285,11 +317,18 @@ mod tests { src: &FakeDataKeySource, payload: RetrieveKeyPayload<'_>, keyset_id: Option, - ) -> DataKey { + ) -> Result { src.retrieve_keys(vec![payload], keyset_id, None) .await - .unwrap() - .remove(0) + .map(|mut keys| keys.remove(0)) + } + + fn assert_rejected(result: Result, what: &str) { + match result { + Err(Error::RetrieveKey(RetrieveKeyError::FailedRetrieval(_))) => {} + Err(other) => panic!("{what}: expected FailedRetrieval, got {other:?}"), + Ok(_) => panic!("{what}: expected rejection, got a key"), + } } #[tokio::test] @@ -307,7 +346,8 @@ mod tests { RetrieveKeyPayload::new(dk.key.iv, "users/email", &dk.tag), None, ) - .await; + .await + .unwrap(); assert_eq!( dk.key.key(), @@ -338,38 +378,34 @@ mod tests { } #[tokio::test] - async fn mismatched_tag_yields_a_different_key() { + async fn mismatched_tag_is_rejected() { + // ZeroKMS fails the tag proof rather than returning other material. let src = FakeDataKeySource::new(); let dk = generate_one(&src, GenerateKeyPayload::new("d", Cow::Owned(vec![])), None).await; - let wrong = retrieve_one( + let result = retrieve_one( &src, RetrieveKeyPayload::new(dk.key.iv, "d", b"wrong-tag"), None, ) .await; - - assert_ne!(dk.key.key(), wrong.key()); + assert_rejected(result, "wrong tag"); } #[tokio::test] - async fn mismatched_iv_yields_a_different_key() { + async fn mismatched_iv_is_rejected() { let src = FakeDataKeySource::new(); let dk = generate_one(&src, GenerateKeyPayload::new("d", Cow::Owned(vec![])), None).await; let mut other_iv = dk.key.iv; other_iv[15] ^= 0xff; - let wrong = retrieve_one(&src, RetrieveKeyPayload::new(other_iv, "d", &dk.tag), None).await; - - assert_ne!( - dk.key.key(), - wrong.key(), - "the IV must feed the derivation, as it does in production" - ); + let result = + retrieve_one(&src, RetrieveKeyPayload::new(other_iv, "d", &dk.tag), None).await; + assert_rejected(result, "wrong iv"); } #[tokio::test] - async fn mismatched_descriptor_yields_a_different_key() { + async fn mismatched_descriptor_is_rejected() { let src = FakeDataKeySource::new(); let dk = generate_one( &src, @@ -378,18 +414,17 @@ mod tests { ) .await; - let wrong = retrieve_one( + let result = retrieve_one( &src, RetrieveKeyPayload::new(dk.key.iv, "users/name", &dk.tag), None, ) .await; - - assert_ne!(dk.key.key(), wrong.key()); + assert_rejected(result, "wrong descriptor"); } #[tokio::test] - async fn mismatched_keyset_yields_a_different_key() { + async fn mismatched_keyset_is_rejected() { let src = FakeDataKeySource::new(); let keyset = Uuid::new_v4(); let dk = generate_one( @@ -404,22 +439,24 @@ mod tests { RetrieveKeyPayload::new(dk.key.iv, "d", &dk.tag), Some(keyset), ) - .await; + .await + .unwrap(); + assert_eq!(dk.key.key(), same.key()); + let other = retrieve_one( &src, RetrieveKeyPayload::new(dk.key.iv, "d", &dk.tag), Some(Uuid::new_v4()), ) .await; - let none = retrieve_one(&src, RetrieveKeyPayload::new(dk.key.iv, "d", &dk.tag), None).await; + assert_rejected(other, "other keyset"); - assert_eq!(dk.key.key(), same.key()); - assert_ne!(dk.key.key(), other.key()); - assert_ne!(dk.key.key(), none.key()); + let none = retrieve_one(&src, RetrieveKeyPayload::new(dk.key.iv, "d", &dk.tag), None).await; + assert_rejected(none, "no keyset"); } #[tokio::test] - async fn mismatched_context_yields_a_different_key() { + async fn mismatched_context_is_rejected() { let src = FakeDataKeySource::new(); let ctx = vec![Context::Tag("tenant-1".into())]; let dk = generate_one( @@ -434,12 +471,13 @@ mod tests { RetrieveKeyPayload::new(dk.key.iv, "d", &dk.tag).with_context(Cow::Borrowed(&ctx)), None, ) - .await; + .await + .unwrap(); + assert_eq!(dk.key.key(), same.key()); + let stripped = retrieve_one(&src, RetrieveKeyPayload::new(dk.key.iv, "d", &dk.tag), None).await; - - assert_eq!(dk.key.key(), same.key()); - assert_ne!(dk.key.key(), stripped.key()); + assert_rejected(stripped, "stripped context"); } #[tokio::test] @@ -461,7 +499,7 @@ mod tests { } #[tokio::test] - async fn policy_round_trips_and_a_stripped_or_swapped_policy_changes_the_key() { + async fn policy_round_trips_and_a_stripped_or_swapped_policy_is_rejected() { let src = FakeDataKeySource::new(); let p = policy("sub", "alice"); let dk = generate_one( @@ -476,9 +514,14 @@ mod tests { RetrieveKeyPayload::new(dk.key.iv, "d", &dk.tag).with_decryption_policy(p.clone()), None, ) - .await; + .await + .unwrap(); + assert_eq!(dk.key.key(), same.key()); + let stripped = retrieve_one(&src, RetrieveKeyPayload::new(dk.key.iv, "d", &dk.tag), None).await; + assert_rejected(stripped, "stripped policy"); + let swapped = retrieve_one( &src, RetrieveKeyPayload::new(dk.key.iv, "d", &dk.tag) @@ -486,20 +529,18 @@ mod tests { None, ) .await; - - assert_eq!(dk.key.key(), same.key()); - assert_ne!(dk.key.key(), stripped.key()); - assert_ne!(dk.key.key(), swapped.key()); + assert_rejected(swapped, "swapped policy"); } #[tokio::test] - async fn a_policy_bearing_generate_payload_drops_its_context_like_the_client() { + async fn policy_bearing_keys_ignore_context_on_both_sides() { // `Client::generate_keys` builds `GenerateKeySpec::new_with_policy`, - // which sends no context; the fake mirrors that so a retrieve without - // context reproduces the key. + // which sends no context, and ZeroKMS's v1 (policy) tag never reads + // the retrieve-side context either. So with a policy present, any + // context — matching, stripped, or different — retrieves the key. let src = FakeDataKeySource::new(); let p = policy("sub", "alice"); - let ctx = vec![Context::Tag("ignored".into())]; + let ctx = vec![Context::Tag("tenant-1".into())]; let dk = generate_one( &src, GenerateKeyPayload::new("d", Cow::Borrowed(&ctx)).with_decryption_policy(p.clone()), @@ -507,14 +548,61 @@ mod tests { ) .await; - let retrieved = retrieve_one( + let with_same_context = retrieve_one( &src, - RetrieveKeyPayload::new(dk.key.iv, "d", &dk.tag).with_decryption_policy(p), + RetrieveKeyPayload::new(dk.key.iv, "d", &dk.tag) + .with_context(Cow::Borrowed(&ctx)) + .with_decryption_policy(p.clone()), None, ) - .await; + .await + .unwrap(); + assert_eq!(dk.key.key(), with_same_context.key()); - assert_eq!(dk.key.key(), retrieved.key()); + let without_context = retrieve_one( + &src, + RetrieveKeyPayload::new(dk.key.iv, "d", &dk.tag).with_decryption_policy(p.clone()), + None, + ) + .await + .unwrap(); + assert_eq!(dk.key.key(), without_context.key()); + + let other_ctx = vec![Context::Tag("tenant-2".into())]; + let with_other_context = retrieve_one( + &src, + RetrieveKeyPayload::new(dk.key.iv, "d", &dk.tag) + .with_context(Cow::Borrowed(&other_ctx)) + .with_decryption_policy(p), + None, + ) + .await + .unwrap(); + assert_eq!(dk.key.key(), with_other_context.key()); + } + + #[tokio::test] + async fn a_batch_with_one_bad_tag_fails_as_a_whole() { + let src = FakeDataKeySource::new(); + let dk = generate_one(&src, GenerateKeyPayload::new("d", Cow::Owned(vec![])), None).await; + + let result = src + .retrieve_keys( + vec![ + RetrieveKeyPayload::new(dk.key.iv, "d", &dk.tag), + RetrieveKeyPayload::new(dk.key.iv, "d", b"wrong-tag"), + ], + None, + None, + ) + .await; + assert!( + matches!( + result, + Err(Error::RetrieveKey(RetrieveKeyError::FailedRetrieval(_))) + ), + "one bad tag must fail the batch" + ); } #[test] From c64fc3517422cb8eb4b264c157d407189610886c Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Sun, 23 Aug 2026 23:13:55 +1000 Subject: [PATCH 403/686] fix(stack-kms): adopt the standard security lint set, route logs through tracing, fix comment typos Addresses the remaining Copilot review comments on cipherstash/cipherstash-suite#2018. - lib.rs: add `#![warn(unreachable_pub)]`, `#![warn(unused_results)]` and `#![cfg_attr(test, allow(unused_results))]` to match the lint block used by stack-auth and stack-profile. - Fix the 8 `unreachable_pub` warnings the new lints surfaced by downgrading to `pub(crate)`: `map_async_chunked`, `get_user_agent`, the `recipher` re-exports in `key.rs`, and the `#[cfg(test)]` `TestConnection`/`TestConnectionBuilder` items. - Fix the one `unused_results` warning in `V1KeySet::deserialize` with an explicit `let _ =` plus a comment noting the discarded `&[u8]` only borrows the `Zeroizing` buffer. - client.rs: replace the `log::{debug, trace}` calls with `tracing::debug!`/`tracing::trace!` so all diagnostics go through one pipeline, and drop the now-unused `log` dependency. `tracing` bakes the target into static callsite metadata, so `send_chunked`'s dynamic `target` argument becomes an `operation` field on a fixed `stack_kms::client` target. - Fix the "modifier" -> "modify" comment typo in `stack-kms/src/key.rs` and its original in `cipherstash-client`. Claude-Session: https://claude.ai/code/session_01T5iYiJc6xwzMcHDtMwCadG --- packages/stack-kms/Cargo.toml | 1 - packages/stack-kms/src/client.rs | 37 +++++++++++-------- .../stack-kms/src/client/test_connection.rs | 14 +++---- packages/stack-kms/src/futures.rs | 2 +- packages/stack-kms/src/key.rs | 8 ++-- packages/stack-kms/src/lib.rs | 4 ++ packages/stack-kms/src/user_agent.rs | 2 +- 7 files changed, 39 insertions(+), 29 deletions(-) diff --git a/packages/stack-kms/Cargo.toml b/packages/stack-kms/Cargo.toml index d920ec4a6..bf038e0ea 100644 --- a/packages/stack-kms/Cargo.toml +++ b/packages/stack-kms/Cargo.toml @@ -36,7 +36,6 @@ vitaminc = { workspace = true, features = ["protected", "random"] } vitaminc-protected = { workspace = true } lazy_static = { workspace = true } -log = { workspace = true } miette = { workspace = true } reqwest = { workspace = true } serde = { workspace = true } diff --git a/packages/stack-kms/src/client.rs b/packages/stack-kms/src/client.rs index 0ef1e1c18..576258ab0 100644 --- a/packages/stack-kms/src/client.rs +++ b/packages/stack-kms/src/client.rs @@ -1,4 +1,3 @@ -use log::{debug, trace}; use std::borrow::Cow; use uuid::Uuid; use zerokms_protocol::{ @@ -135,13 +134,15 @@ impl Client { /// per spec, and zip the entries back onto their specs — in order — with /// `map_key`. /// - /// `target` is the `log` target for the per-chunk trace lines, so - /// operators can filter by operation (`stack_kms::retrieve_keys`, - /// `stack_kms::retrieve_keys_fallible`, `stack_kms::generate_keys`). + /// `operation` is recorded as a field on the per-chunk trace lines, so + /// operators can filter by operation (`retrieve_keys`, + /// `retrieve_keys_fallible`, `generate_keys`). It is a field rather than a + /// `tracing` target because `tracing` targets are baked into static + /// callsite metadata and so must be literals. #[allow(clippy::too_many_arguments)] async fn send_chunked<'a, Spec, Req, Item, Out, E>( &self, - target: &'static str, + operation: &'static str, specs: &'a [Spec], access_token: &str, make_request: impl Fn(&'a [Spec]) -> Req + Sync, @@ -157,7 +158,7 @@ impl Client { let result = map_async_chunked( specs, |chunk| async { - trace!(target: target, "sending request with {} keys", chunk.len()); + tracing::trace!(target: "stack_kms::client", operation, "sending request with {} keys", chunk.len()); let keys = self .connection @@ -171,7 +172,7 @@ impl Client { return Err(count_mismatch(chunk.len(), keys.len())); } - trace!(target: target, "received {} keys - creating data keys", keys.len()); + tracing::trace!(target: "stack_kms::client", operation, "received {} keys - creating data keys", keys.len()); Ok(chunk .iter() @@ -185,8 +186,12 @@ impl Client { .await; match &result { - Err(x) => trace!(target: target, "failed with error: {x}"), - Ok(x) => trace!(target: target, "successfully processed {} keys", x.len()), + Err(x) => { + tracing::trace!(target: "stack_kms::client", operation, "failed with error: {x}") + } + Ok(x) => { + tracing::trace!(target: "stack_kms::client", operation, "successfully processed {} keys", x.len()) + } } result @@ -201,7 +206,7 @@ impl Client { access_token: &str, unverified_context: Option<&UnverifiedContext>, ) -> Result, RetrieveKeyError> { - trace!(target: "stack_kms::retrieve_keys", "preparing payloads"); + tracing::trace!(target: "stack_kms::retrieve_keys", "preparing payloads"); let keys = keys .into_iter() @@ -211,7 +216,7 @@ impl Client { tracing::trace!(target: "stack_kms::retrieve_keys", max_keys_per_req = self.max_keys_per_req, max_parallel_reqs = self.max_concurrent_reqs); self.send_chunked( - "stack_kms::retrieve_keys", + "retrieve_keys", &keys, access_token, |keys| RetrieveKeyRequest { @@ -239,7 +244,7 @@ impl Client { access_token: &str, unverified_context: Option>, ) -> Result { - trace!(target: "stack_kms::retrieve_keys_fallible", "preparing payloads"); + tracing::trace!(target: "stack_kms::retrieve_keys_fallible", "preparing payloads"); let keys = keys .into_iter() @@ -249,7 +254,7 @@ impl Client { tracing::trace!(target: "stack_kms::retrieve_keys_fallible", max_keys_per_req = self.max_keys_per_req, max_parallel_reqs = self.max_concurrent_reqs); self.send_chunked( - "stack_kms::retrieve_keys_fallible", + "retrieve_keys_fallible", &keys, access_token, |keys| RetrieveKeyRequestFallible { @@ -306,11 +311,11 @@ impl Client { .collect::, GenerateKeyError>>()? }; - trace!(target: "stack_kms::generate_keys", "generated {} key payloads", keys.len()); + tracing::trace!(target: "stack_kms::generate_keys", "generated {} key payloads", keys.len()); tracing::trace!(target: "stack_kms::generate_keys", max_keys_per_req = self.max_keys_per_req, max_parallel_reqs = self.max_concurrent_reqs); self.send_chunked( - "stack_kms::generate_keys", + "generate_keys", &keys, access_token, |keys| GenerateKeyRequest { @@ -441,7 +446,7 @@ where ) -> Result { let token = self.get_token().await?; - debug!(target: "stack_kms::retrieve_keys_fallible", "got token, retrieving keys"); + tracing::debug!(target: "stack_kms::retrieve_keys_fallible", "got token, retrieving keys"); self.client .retrieve_keys_fallible( payloads, diff --git a/packages/stack-kms/src/client/test_connection.rs b/packages/stack-kms/src/client/test_connection.rs index 864f14b63..8cca1996c 100644 --- a/packages/stack-kms/src/client/test_connection.rs +++ b/packages/stack-kms/src/client/test_connection.rs @@ -9,13 +9,13 @@ use crate::connection::{ZeroKMSConnection, ZeroKMSConnectionInit}; type EffectHandlers = Vec<(String, Box)>; type RequestHandlers = Vec<(String, Result)>; -pub struct TestConnectionBuilder { +pub(crate) struct TestConnectionBuilder { handlers: RequestHandlers, effects: EffectHandlers, } impl TestConnectionBuilder { - pub fn new() -> Self { + pub(crate) fn new() -> Self { Self { handlers: vec![], effects: vec![], @@ -25,7 +25,7 @@ impl TestConnectionBuilder { /// Add a matcher for a particular request, returning a success message. /// /// The matcher is only run once. - pub fn add_success_response(mut self, response: R::Response) -> Self { + pub(crate) fn add_success_response(mut self, response: R::Response) -> Self { self.handlers.push(( R::ENDPOINT.to_string(), Ok(serde_json::to_string(&response) @@ -37,7 +37,7 @@ impl TestConnectionBuilder { /// Add a matcher for a particular request, returning a [`ViturRequestError`]. /// /// The matcher is only run once. - pub fn add_failed_response(mut self, error: ViturRequestError) -> Self { + pub(crate) fn add_failed_response(mut self, error: ViturRequestError) -> Self { self.handlers.push((R::ENDPOINT.to_string(), Err(error))); self } @@ -45,7 +45,7 @@ impl TestConnectionBuilder { /// Add a matcher for a particular request, running an effect on the body of the request. /// /// This matcher is only run once. - pub fn add_effect( + pub(crate) fn add_effect( mut self, handler: H, ) -> Self { @@ -63,7 +63,7 @@ impl TestConnectionBuilder { self } - pub fn build(self) -> TestConnection { + pub(crate) fn build(self) -> TestConnection { TestConnection { handlers: Mutex::new(self.handlers), effects: Mutex::new(self.effects), @@ -77,7 +77,7 @@ impl Default for TestConnectionBuilder { } } -pub struct TestConnection { +pub(crate) struct TestConnection { handlers: Mutex, effects: Mutex, } diff --git a/packages/stack-kms/src/futures.rs b/packages/stack-kms/src/futures.rs index bc73c5fe3..7ecc111ac 100644 --- a/packages/stack-kms/src/futures.rs +++ b/packages/stack-kms/src/futures.rs @@ -6,7 +6,7 @@ use std::future::Future; * The futures generated by that callback are run concurrently with their results returned in a * vector. */ -pub async fn map_async_chunked< +pub(crate) async fn map_async_chunked< 'a, T: Send + Sync, U, diff --git a/packages/stack-kms/src/key.rs b/packages/stack-kms/src/key.rs index 3dc1e2a92..83bf96082 100644 --- a/packages/stack-kms/src/key.rs +++ b/packages/stack-kms/src/key.rs @@ -1,4 +1,4 @@ -pub use recipher::{ +pub(crate) use recipher::{ cipher::ProxyCipher, key::{Iv, Key}, keyset::ProxyKeySet as KeySet, @@ -89,7 +89,7 @@ impl DataKey { } // FIXME: Making this Cloneable for now so that we can use the same key many times for the JSONB indexer. -// We should modifier the indexer so each value has a separate key. +// We should modify the indexer so each value has a separate key. // No `PartialEq`/`Eq` on the wrapper: the `tag` is public but `key` is secret. // If key equality is ever needed, compare `a.key == b.key` (constant-time via // `DataKey`'s `TimingSafeEq`-derived `PartialEq`) or `a.key.ts_eq(&b.key)`. @@ -182,7 +182,9 @@ impl<'de> Deserialize<'de> for V1KeySet { // malformed or truncated input would otherwise leave whatever was // decoded so far on the stack. let mut buffer = Zeroizing::new([0u8; 168]); - serdect::array::deserialize_hex_or_bin(&mut *buffer, deserializer)?; + // Discards the returned `&[u8]`: it just borrows `buffer`, which we + // read through the `Zeroizing` guard below so it still gets wiped. + let _ = serdect::array::deserialize_hex_or_bin(&mut *buffer, deserializer)?; let keyset = KeySet::from_bytes(&*buffer).map_err(serde::de::Error::custom)?; Ok(Self(keyset)) diff --git a/packages/stack-kms/src/lib.rs b/packages/stack-kms/src/lib.rs index cf62946e6..a81019ffa 100644 --- a/packages/stack-kms/src/lib.rs +++ b/packages/stack-kms/src/lib.rs @@ -51,12 +51,16 @@ #![warn(clippy::print_stdout)] #![warn(clippy::print_stderr)] #![warn(clippy::dbg_macro)] +// Code quality +#![warn(unreachable_pub)] +#![warn(unused_results)] #![warn(clippy::todo)] #![warn(clippy::unimplemented)] // Relax in tests #![cfg_attr(test, allow(clippy::unwrap_used))] #![cfg_attr(test, allow(clippy::expect_used))] #![cfg_attr(test, allow(clippy::panic))] +#![cfg_attr(test, allow(unused_results))] mod builder; mod client; diff --git a/packages/stack-kms/src/user_agent.rs b/packages/stack-kms/src/user_agent.rs index 5d03c977a..e48da926e 100644 --- a/packages/stack-kms/src/user_agent.rs +++ b/packages/stack-kms/src/user_agent.rs @@ -4,7 +4,7 @@ use std::env::consts::{ARCH, OS}; const VERSION: &str = env!("CARGO_PKG_VERSION"); const SECONDARY_AGENT: Option<&str> = option_env!("CIPHERSTASH_CLIENT_SECONDARY_USER_AGENT"); -pub fn get_user_agent() -> &'static str { +pub(crate) fn get_user_agent() -> &'static str { lazy_static! { static ref USER_AGENT: String = format!( "stack-kms/{VERSION} ({OS} {ARCH}{})", From 0edd71c3bd92836fcb9deb0b07a94e4d923dc272 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Wed, 26 Aug 2026 12:52:25 +1000 Subject: [PATCH 404/686] feat(stack-kms): validate the ZeroKMS base URL once, via a ZeroKmsEndpoint newtype MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review on cipherstash/cipherstash-suite#2018 found three URL-handling gaps inherited from cipherstash-client: a scheme-less `localhost:3002` parses (as scheme `localhost`) and is accepted, then every request fails with an opaque "Failed to construct request URL"; an invalid `CS_ZEROKMS_HOST` was warned about and skipped, silently falling through to the token's `services` claim; and `Url::join` drops the base URL's query, which a test implied survived. Rather than sprinkling checks over the builder and connection, introduce `ZeroKmsEndpoint` (the `TokenIssuer` pattern from cts-domain): the one place URL hygiene happens. Construction requires http(s) + a host, rejects userinfo/query/fragment, and slash-terminates the path so `request_url(ENDPOINT)` appends infallibly. The connection, the builder and the token-claim resolution all carry the validated type. Consequences: - `StackKmsBuilder::with_base_url` takes a `ZeroKmsEndpoint`; a bad URL is rejected where it is written. - An invalid `CS_ZEROKMS_HOST` / `CS_VITUR_HOST` is now `StackKmsBuilderError::InvalidEndpoint { env_var, .. }` rather than a skip — the first variable that is *set* decides. - The endpoint adopted from the token's services claim is logged. - The builder stores one `HttpConnectionOpts` instead of mirroring its three timeout fields. Claude-Session: https://claude.ai/code/session_01HU1Bbw4eQp9kEEneXxDcKr --- packages/stack-kms/src/builder.rs | 172 ++++++++++------- packages/stack-kms/src/client.rs | 9 +- packages/stack-kms/src/connection.rs | 115 +++++------ packages/stack-kms/src/endpoint.rs | 272 +++++++++++++++++++++++++++ packages/stack-kms/src/errors.rs | 4 + packages/stack-kms/src/lib.rs | 2 + 6 files changed, 437 insertions(+), 137 deletions(-) create mode 100644 packages/stack-kms/src/endpoint.rs diff --git a/packages/stack-kms/src/builder.rs b/packages/stack-kms/src/builder.rs index 106c5a44f..30c05923a 100644 --- a/packages/stack-kms/src/builder.rs +++ b/packages/stack-kms/src/builder.rs @@ -2,11 +2,11 @@ use crate::client::{ ClientOpts, InvalidClientOpts, StackKms, DEFAULT_CONCURRENT_REQS, DEFAULT_KEYS_PER_REQ, }; use crate::connection::HttpConnectionOpts; +use crate::endpoint::{InvalidEndpoint, ZeroKmsEndpoint}; use crate::key::ClientKey; use crate::key_provider::{KeyProvider, KeyProviderError}; use stack_auth::{AuthStrategy, AuthStrategyBounds}; use thiserror::Error; -use url::Url; /// Error type for [`StackKmsBuilder`] operations. #[derive(Debug, Error, miette::Diagnostic)] @@ -27,6 +27,17 @@ pub enum StackKmsBuilderError { /// or keys-per-request limit). #[error(transparent)] InvalidConfig(#[from] InvalidClientOpts), + + /// The ZeroKMS endpoint in the named environment variable is not usable. + /// Unlike a missing variable this is not skipped: falling through to the + /// token's `services` claim would silently send key operations somewhere + /// the operator did not configure. + #[error("Invalid ZeroKMS endpoint in {env_var}: {source}")] + InvalidEndpoint { + env_var: &'static str, + #[source] + source: InvalidEndpoint, + }, } /// A builder for creating [`StackKms`] clients. @@ -38,8 +49,9 @@ pub enum StackKmsBuilderError { /// [`with_key_provider`](Self::with_key_provider)) has been supplied. /// /// The ZeroKMS endpoint is resolved in this order: -/// 1. Explicit URL via [`with_base_url`](Self::with_base_url) -/// 2. `CS_ZEROKMS_HOST` (or legacy `CS_VITUR_HOST`) environment variable +/// 1. Explicit [`ZeroKmsEndpoint`] via [`with_base_url`](Self::with_base_url) +/// 2. `CS_ZEROKMS_HOST` (or legacy `CS_VITUR_HOST`) environment variable — +/// the first one that is *set* is used, and an invalid value is an error /// 3. Automatically from the token's `services` claim /// /// # Example @@ -62,13 +74,10 @@ pub enum StackKmsBuilderError { /// ``` pub struct StackKmsBuilder { credentials: C, - request_timeout: Option, - connect_timeout: Option, - pool_idle_timeout: Option, + connection: HttpConnectionOpts, max_keys_per_req: usize, max_concurrent_reqs: usize, client_key: ClientKeyState, - base_url_override: Option, } impl StackKmsBuilder { @@ -101,13 +110,10 @@ where pub fn new(credentials: C) -> Self { Self { credentials, - request_timeout: None, - connect_timeout: None, - pool_idle_timeout: None, + connection: HttpConnectionOpts::new(None), max_keys_per_req: DEFAULT_KEYS_PER_REQ, max_concurrent_reqs: DEFAULT_CONCURRENT_REQS, client_key: (), - base_url_override: None, } } @@ -121,13 +127,10 @@ where ) -> StackKmsBuilder> { StackKmsBuilder { credentials: self.credentials, - request_timeout: self.request_timeout, - connect_timeout: self.connect_timeout, - pool_idle_timeout: self.pool_idle_timeout, + connection: self.connection, max_keys_per_req: self.max_keys_per_req, max_concurrent_reqs: self.max_concurrent_reqs, client_key: WithKeyProvider(provider), - base_url_override: self.base_url_override, } } @@ -135,13 +138,10 @@ where pub fn with_client_key(self, client_key: ClientKey) -> StackKmsBuilder { StackKmsBuilder { credentials: self.credentials, - request_timeout: self.request_timeout, - connect_timeout: self.connect_timeout, - pool_idle_timeout: self.pool_idle_timeout, + connection: self.connection, max_keys_per_req: self.max_keys_per_req, max_concurrent_reqs: self.max_concurrent_reqs, client_key, - base_url_override: self.base_url_override, } } } @@ -149,22 +149,28 @@ where // Configuration setters live on the state-agnostic impl so they can be called // in any order relative to `with_client_key`/`with_key_provider` — chaining a // setter *after* the key would otherwise fail to compile. +// +// The transport knobs delegate to `HttpConnectionOpts` (which documents each +// one, including the wasm32 caveats) rather than duplicating its fields here. impl StackKmsBuilder { /// Set the **total request timeout** in seconds. Defaults to 10 seconds. + /// See [`HttpConnectionOpts::with_request_timeout`]. pub fn with_request_timeout(mut self, timeout_secs: u64) -> Self { - self.request_timeout = Some(timeout_secs); + self.connection = self.connection.with_request_timeout(timeout_secs); self } /// Set the **connect timeout** in seconds (TCP connect + TLS handshake only). + /// See [`HttpConnectionOpts::with_connect_timeout`]. pub fn with_connect_timeout(mut self, timeout_secs: u64) -> Self { - self.connect_timeout = Some(timeout_secs); + self.connection = self.connection.with_connect_timeout(timeout_secs); self } /// Set the **pool idle timeout** in seconds. + /// See [`HttpConnectionOpts::with_pool_idle_timeout`]. pub fn with_pool_idle_timeout(mut self, timeout_secs: u64) -> Self { - self.pool_idle_timeout = Some(timeout_secs); + self.connection = self.connection.with_pool_idle_timeout(timeout_secs); self } @@ -182,60 +188,51 @@ impl StackKmsBuilder { self } - /// Override the base URL for the ZeroKMS service. + /// Pin the ZeroKMS endpoint, bypassing both the environment and the + /// token's `services` claim. /// - /// This bypasses resolving the URL from the token's `services` claim and connects - /// directly to the specified URL. - pub fn with_base_url(mut self, base_url: Url) -> Self { - self.base_url_override = Some(base_url); + /// Takes an already-validated [`ZeroKmsEndpoint`] (parse one with + /// `"https://…".parse()?`), so a bad URL is rejected where it is written + /// rather than on the first request. + pub fn with_base_url(mut self, base_url: ZeroKmsEndpoint) -> Self { + self.connection = self.connection.with_base_url(base_url); self } fn build_opts(self) -> Result<(ClientOpts, C, S), StackKmsBuilderError> { - let base_url = self.base_url_override.or_else(Self::base_url_from_env); - let mut connection_opts = HttpConnectionOpts::new(base_url); - if let Some(timeout) = self.request_timeout { - connection_opts = connection_opts.with_request_timeout(timeout); - } - if let Some(connect_timeout) = self.connect_timeout { - connection_opts = connection_opts.with_connect_timeout(connect_timeout); - } - if let Some(pool_idle_timeout) = self.pool_idle_timeout { - connection_opts = connection_opts.with_pool_idle_timeout(pool_idle_timeout); + let mut connection = self.connection; + if connection.base_url().is_none() { + if let Some(endpoint) = Self::base_url_from_env()? { + connection = connection.with_base_url(endpoint); + } } // `ClientOpts` rejects degenerate limits (0 keys-per-req would panic // `slice::chunks`; 0 concurrent-reqs would leave the request stream // pending forever) so they never reach `map_async_chunked`. - let opts = ClientOpts::new(connection_opts) + let opts = ClientOpts::new(connection) .with_max_keys_per_req(self.max_keys_per_req)? .with_max_concurrent_reqs(self.max_concurrent_reqs)?; Ok((opts, self.credentials, self.client_key)) } - /// Resolve the ZeroKMS base URL from the `CS_ZEROKMS_HOST` environment - /// variable (or legacy `CS_VITUR_HOST`). - fn base_url_from_env() -> Option { + /// Resolve the ZeroKMS endpoint from the `CS_ZEROKMS_HOST` environment + /// variable (or legacy `CS_VITUR_HOST`). The first variable that is set + /// decides: an unusable value is an error, not a fall-through. + fn base_url_from_env() -> Result, StackKmsBuilderError> { use crate::vars::CS_ZEROKMS_HOST; - for name in CS_ZEROKMS_HOST { - if let Ok(value) = std::env::var(name) { - match value.parse() { - Ok(url) => return Some(url), - Err(err) => { - tracing::warn!( - target: "stack_kms", - %err, - env_var = name, - "Ignoring invalid URL in environment variable" - ); - } - } + for env_var in CS_ZEROKMS_HOST { + if let Ok(value) = std::env::var(env_var) { + return value + .parse() + .map(Some) + .map_err(|source| StackKmsBuilderError::InvalidEndpoint { env_var, source }); } } - None + Ok(None) } } @@ -347,6 +344,10 @@ mod tests { const PRIMARY: &str = "CS_ZEROKMS_HOST"; const LEGACY: &str = "CS_VITUR_HOST"; + fn from_env() -> Result, StackKmsBuilderError> { + StackKmsBuilder::::base_url_from_env() + } + #[test] fn the_primary_variable_is_listed_first() { assert_eq!(crate::vars::CS_ZEROKMS_HOST, &[PRIMARY, LEGACY]); @@ -355,7 +356,7 @@ mod tests { #[test] fn returns_none_when_neither_variable_is_set() { let _env = ScopedEnv::new(&[(PRIMARY, None), (LEGACY, None)]); - assert!(StackKmsBuilder::::base_url_from_env().is_none()); + assert!(from_env().unwrap().is_none()); } #[test] @@ -364,31 +365,74 @@ mod tests { (PRIMARY, Some("https://primary.example")), (LEGACY, Some("https://legacy.example")), ]); - let url = StackKmsBuilder::::base_url_from_env().unwrap(); + let url = from_env().unwrap().unwrap(); assert_eq!(url.as_str(), "https://primary.example/"); } #[test] fn falls_back_to_the_legacy_variable() { let _env = ScopedEnv::new(&[(PRIMARY, None), (LEGACY, Some("https://legacy.example"))]); - let url = StackKmsBuilder::::base_url_from_env().unwrap(); + let url = from_env().unwrap().unwrap(); assert_eq!(url.as_str(), "https://legacy.example/"); } #[test] - fn skips_an_invalid_primary_and_uses_the_legacy_variable() { + fn an_invalid_primary_is_an_error_even_when_the_legacy_variable_is_valid() { let _env = ScopedEnv::new(&[ (PRIMARY, Some("not a url")), (LEGACY, Some("https://legacy.example")), ]); - let url = StackKmsBuilder::::base_url_from_env().unwrap(); - assert_eq!(url.as_str(), "https://legacy.example/"); + let err = from_env().unwrap_err(); + assert!( + matches!( + &err, + StackKmsBuilderError::InvalidEndpoint { env_var, source: InvalidEndpoint::Parse(_) } + if *env_var == PRIMARY + ), + "got: {err:?}" + ); + assert!(err.to_string().contains(PRIMARY), "{err}"); + } + + #[test] + fn a_scheme_less_host_and_port_is_rejected_naming_the_variable() { + // `Url::parse` accepts `localhost:3002` (scheme `localhost`), so + // without endpoint validation this would build and then fail every + // request with an opaque "Failed to construct request URL". + let _env = ScopedEnv::new(&[(PRIMARY, Some("localhost:3002")), (LEGACY, None)]); + let err = from_env().unwrap_err(); + assert!( + matches!( + &err, + StackKmsBuilderError::InvalidEndpoint { env_var, source: InvalidEndpoint::NoHost(_) } + if *env_var == PRIMARY + ), + "got: {err:?}" + ); } #[test] - fn returns_none_when_every_candidate_is_invalid() { - let _env = ScopedEnv::new(&[(PRIMARY, Some("not a url")), (LEGACY, Some("also not"))]); - assert!(StackKmsBuilder::::base_url_from_env().is_none()); + fn an_invalid_endpoint_fails_build() { + let _env = ScopedEnv::new(&[(PRIMARY, Some("localhost:3002")), (LEGACY, None)]); + let err = builder() + .with_client_key(random_client_key()) + .build() + .err() + .expect("an invalid env endpoint must fail build"); + assert!( + matches!(err, StackKmsBuilderError::InvalidEndpoint { .. }), + "got: {err:?}" + ); + } + + #[test] + fn an_explicit_endpoint_takes_precedence_and_the_env_is_not_consulted() { + let _env = ScopedEnv::new(&[(PRIMARY, Some("not a url")), (LEGACY, None)]); + builder() + .with_base_url("https://explicit.example".parse().unwrap()) + .with_client_key(random_client_key()) + .build() + .expect("an explicit endpoint must not be overridden by a bad env value"); } } } diff --git a/packages/stack-kms/src/client.rs b/packages/stack-kms/src/client.rs index 576258ab0..cc426fe28 100644 --- a/packages/stack-kms/src/client.rs +++ b/packages/stack-kms/src/client.rs @@ -383,8 +383,13 @@ where async fn get_token(&self) -> Result { let token = (&self.credentials).get_token().await?; if !self.client.connection().has_base_url() { - let url = token.zerokms_url()?; - self.client.connection().ensure_base_url(url); + let endpoint = crate::endpoint::ZeroKmsEndpoint::try_from(token.zerokms_url()?)?; + tracing::debug!( + target: "stack_kms", + %endpoint, + "resolved ZeroKMS endpoint from the token's services claim" + ); + self.client.connection().ensure_base_url(endpoint); } Ok(token) } diff --git a/packages/stack-kms/src/connection.rs b/packages/stack-kms/src/connection.rs index 8534c3df6..b2dd315f7 100644 --- a/packages/stack-kms/src/connection.rs +++ b/packages/stack-kms/src/connection.rs @@ -1,3 +1,4 @@ +use crate::endpoint::ZeroKmsEndpoint; use crate::user_agent::get_user_agent; use reqwest::{header::HeaderMap, Response, StatusCode}; use serde_json::{from_reader, to_vec}; @@ -5,7 +6,6 @@ use serde_json::{from_reader, to_vec}; use std::time::Duration; use std::{collections::HashMap, future::Future, sync::OnceLock}; use thiserror::Error; -use url::Url; use zerokms_protocol::{ViturRequest, ViturRequestError, ViturRequestErrorKind}; #[cfg(not(target_arch = "wasm32"))] @@ -20,14 +20,17 @@ pub struct ConnectionInitError(#[from] reqwest::Error); struct BaseUrlUnresolved; pub struct HttpConnectionOpts { - base_url: Option, + base_url: Option, request_timeout: Option, connect_timeout: Option, pool_idle_timeout: Option, } impl HttpConnectionOpts { - pub fn new(base_url: Option) -> Self { + /// Options for a connection to `base_url`, or — when `None` — to whatever + /// endpoint the access token's `services` claim names (resolved on first + /// use via [`HttpConnection::ensure_base_url`]). + pub fn new(base_url: Option) -> Self { Self { base_url, request_timeout: None, @@ -36,6 +39,16 @@ impl HttpConnectionOpts { } } + /// Pin the endpoint, replacing any earlier value. + pub fn with_base_url(mut self, base_url: ZeroKmsEndpoint) -> Self { + self.base_url = Some(base_url); + self + } + + pub(crate) fn base_url(&self) -> Option<&ZeroKmsEndpoint> { + self.base_url.as_ref() + } + /// Set the **total request timeout** in seconds — covers connect + TLS /// handshake + body send + body receive together. If not set, defaults /// to 10 seconds. @@ -119,7 +132,7 @@ pub trait ZeroKMSConnection: ZeroKMSConnectionInit { } pub struct HttpConnection { - base_url: OnceLock, + base_url: OnceLock, client: reqwest::Client, } @@ -173,22 +186,6 @@ fn header_map_to_hash(map: &HeaderMap) -> HashMap { .collect() } -/// Ensure the base URL's path ends with `/` so that `Url::join` with a -/// relative endpoint *appends* to it instead of replacing the last segment. -/// -/// Endpoint paths in `zerokms-protocol` have no leading slash, so -/// `https://gateway.example/zerokms` + `retrieve-data-key` would otherwise -/// resolve to `https://gateway.example/retrieve-data-key` — silently dropping -/// the `/zerokms` prefix. A URL whose path already ends in `/` (including the -/// bare-host form, whose path is `/`) is returned unchanged. -fn with_trailing_slash(mut url: Url) -> Url { - if !url.path().ends_with('/') { - let path = format!("{}/", url.path()); - url.set_path(&path); - } - url -} - /// `true` if a `content-type` header value denotes JSON, ignoring any /// parameters (`application/json; charset=utf-8`) and ASCII case — proxies and /// API gateways commonly normalise the header that way. @@ -205,9 +202,9 @@ impl HttpConnection { /// /// This is a no-op if the URL was already provided at init time or by a /// previous call to this method. - pub fn ensure_base_url(&self, url: Url) { + pub fn ensure_base_url(&self, url: ZeroKmsEndpoint) { // OnceLock::set returns Err if already set — that's fine, we keep the first value. - let _ = self.base_url.set(with_trailing_slash(url)); + let _ = self.base_url.set(url); } /// Returns `true` if the base URL has been resolved (either at init time @@ -252,7 +249,7 @@ impl ZeroKMSConnectionInit for HttpConnection { let base_url = OnceLock::new(); if let Some(url) = opts.base_url { // Pre-fill when an explicit URL was provided at build time. - let _ = base_url.set(with_trailing_slash(url)); + let _ = base_url.set(url); } Ok(Self { base_url, client }) @@ -280,9 +277,7 @@ impl ZeroKMSConnection for HttpConnection { ) })?; - let url = base_url - .join(Request::ENDPOINT) - .map_err(|e| ViturRequestError::prepare("Failed to construct request URL", e))?; + let url = base_url.request_url(Request::ENDPOINT); let response = self .client @@ -351,12 +346,12 @@ impl ZeroKMSConnection for HttpConnection { mod base_url_tests { use super::*; - fn conn(base_url: Option) -> HttpConnection { + fn conn(base_url: Option) -> HttpConnection { HttpConnection::init(HttpConnectionOpts::new(base_url)).unwrap() } - fn url(s: &str) -> Url { - Url::parse(s).unwrap() + fn endpoint(s: &str) -> ZeroKmsEndpoint { + s.parse().unwrap() } #[test] @@ -364,7 +359,7 @@ mod base_url_tests { let c = conn(None); assert!(!c.has_base_url()); - c.ensure_base_url(url("https://a.example")); + c.ensure_base_url(endpoint("https://a.example")); assert!(c.has_base_url()); assert_eq!(c.base_url.get().unwrap().as_str(), "https://a.example/"); @@ -373,61 +368,39 @@ mod base_url_tests { #[test] fn the_first_ensured_url_wins() { let c = conn(None); - c.ensure_base_url(url("https://first.example")); - c.ensure_base_url(url("https://second.example")); + c.ensure_base_url(endpoint("https://first.example")); + c.ensure_base_url(endpoint("https://second.example")); assert_eq!(c.base_url.get().unwrap().as_str(), "https://first.example/"); } #[test] - fn a_path_prefix_gets_a_trailing_slash_so_endpoints_append_to_it() { - let c = conn(Some(url("https://gateway.example/zerokms"))); - let base = c.base_url.get().unwrap(); + fn a_url_given_at_init_is_kept_over_a_later_ensure() { + let c = conn(Some(endpoint("https://init.example"))); + assert!(c.has_base_url()); - assert_eq!(base.as_str(), "https://gateway.example/zerokms/"); - assert_eq!( - base.join("retrieve-data-key").unwrap().as_str(), - "https://gateway.example/zerokms/retrieve-data-key" - ); + c.ensure_base_url(endpoint("https://other.example")); + + assert_eq!(c.base_url.get().unwrap().as_str(), "https://init.example/"); } #[test] - fn ensure_base_url_normalises_the_same_way() { - let c = conn(None); - c.ensure_base_url(url("https://gateway.example/zerokms?x=1")); + fn requests_append_the_endpoint_to_a_path_prefix() { + // URL normalisation itself is covered in `endpoint::tests`; this pins + // that the connection builds request URLs through the endpoint type + // rather than re-joining (which would drop a path prefix). + let c = conn(Some(endpoint("https://gateway.example/zerokms"))); assert_eq!( - c.base_url.get().unwrap().as_str(), - "https://gateway.example/zerokms/?x=1" + c.base_url + .get() + .unwrap() + .request_url(zerokms_protocol::RetrieveKeyRequest::ENDPOINT) + .as_str(), + "https://gateway.example/zerokms/retrieve-data-key" ); } - #[test] - fn an_already_slash_terminated_url_is_unchanged() { - for s in [ - "https://a.example", - "https://a.example/", - "https://a.example/zerokms/", - ] { - let c = conn(Some(url(s))); - assert_eq!( - c.base_url.get().unwrap().as_str(), - url(s).as_str(), - "{s} should be left as-is" - ); - } - } - - #[test] - fn a_url_given_at_init_is_kept_over_a_later_ensure() { - let c = conn(Some(url("https://init.example"))); - assert!(c.has_base_url()); - - c.ensure_base_url(url("https://other.example")); - - assert_eq!(c.base_url.get().unwrap().as_str(), "https://init.example/"); - } - #[tokio::test] async fn send_without_a_base_url_is_a_prepare_error_not_an_auth_error() { use zerokms_protocol::{GenerateKeyRequest, ViturRequestErrorKind}; diff --git a/packages/stack-kms/src/endpoint.rs b/packages/stack-kms/src/endpoint.rs new file mode 100644 index 000000000..6654e2aaf --- /dev/null +++ b/packages/stack-kms/src/endpoint.rs @@ -0,0 +1,272 @@ +use std::fmt; +use std::str::FromStr; + +use thiserror::Error; +use url::Url; + +/// Why a URL was rejected as a [`ZeroKmsEndpoint`]. +#[derive(Debug, Error, PartialEq, Eq)] +pub enum InvalidEndpoint { + /// The value did not parse as a URL at all. + #[error("not a valid URL: {0}")] + Parse(#[from] url::ParseError), + + /// The URL parsed but has no authority — `localhost:8080` parses as scheme + /// `localhost`, path `8080` — so no request path could ever be joined to it. + #[error("`{0}` has no host; is the `http://` or `https://` prefix missing?")] + NoHost(String), + + /// Only `http` and `https` can reach ZeroKMS. + #[error("unsupported scheme `{0}`; expected `http` or `https`")] + Scheme(String), + + /// Request URLs are built by joining an endpoint path onto the base, which + /// discards any query or fragment — so accepting one would silently drop it. + #[error("query strings and fragments are not supported on a ZeroKMS endpoint: `{0}`")] + QueryOrFragment(String), + + /// Credentials belong in the bearer token, never in the URL. + #[error("userinfo is not supported on a ZeroKMS endpoint")] + Userinfo, +} + +/// A validated ZeroKMS base URL. +/// +/// Construction is the one place URL hygiene happens, so everything downstream +/// (the HTTP connection, the builder, the token's `services` claim) works with a +/// value that is already known to be usable: +/// +/// * scheme is `http` or `https` and the URL has a host +/// * no userinfo, query or fragment (they would be dropped or leak) +/// * the path ends in `/`, so [`request_url`](Self::request_url) *appends* +/// an endpoint path (`https://gw.example/zerokms` + `retrieve-data-key` → +/// `https://gw.example/zerokms/retrieve-data-key`) instead of replacing the +/// last segment as `Url::join` would +/// +/// ``` +/// use stack_kms::ZeroKmsEndpoint; +/// +/// let endpoint: ZeroKmsEndpoint = "https://gateway.example/zerokms".parse()?; +/// assert_eq!(endpoint.as_str(), "https://gateway.example/zerokms/"); +/// assert_eq!( +/// endpoint.request_url("retrieve-data-key").as_str(), +/// "https://gateway.example/zerokms/retrieve-data-key" +/// ); +/// +/// assert!("localhost:3002".parse::().is_err()); +/// # Ok::<(), stack_kms::InvalidEndpoint>(()) +/// ``` +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ZeroKmsEndpoint(Url); + +impl ZeroKmsEndpoint { + /// Validate and normalise a parsed [`Url`]. + pub fn new(mut url: Url) -> Result { + if url.cannot_be_a_base() || url.host_str().is_none() { + return Err(InvalidEndpoint::NoHost(url.into())); + } + if !matches!(url.scheme(), "http" | "https") { + return Err(InvalidEndpoint::Scheme(url.scheme().to_string())); + } + if url.query().is_some() || url.fragment().is_some() { + return Err(InvalidEndpoint::QueryOrFragment(url.into())); + } + if !url.username().is_empty() || url.password().is_some() { + return Err(InvalidEndpoint::Userinfo); + } + if !url.path().ends_with('/') { + let path = format!("{}/", url.path()); + url.set_path(&path); + } + Ok(Self(url)) + } + + /// The URL for a request path (a `ViturRequest::ENDPOINT`), appended to + /// the base. Infallible: the base is known to be http(s) with a + /// slash-terminated path, and the endpoint paths are relative constants. + pub fn request_url(&self, endpoint: &str) -> Url { + let mut url = self.0.clone(); + let path = format!("{}{}", self.0.path(), endpoint); + url.set_path(&path); + url + } + + /// The normalised base URL as a string (always slash-terminated). + pub fn as_str(&self) -> &str { + self.0.as_str() + } + + /// Borrow the underlying [`Url`]. + pub fn as_url(&self) -> &Url { + &self.0 + } +} + +impl TryFrom for ZeroKmsEndpoint { + type Error = InvalidEndpoint; + + fn try_from(url: Url) -> Result { + Self::new(url) + } +} + +impl FromStr for ZeroKmsEndpoint { + type Err = InvalidEndpoint; + + fn from_str(s: &str) -> Result { + Self::new(Url::parse(s)?) + } +} + +impl fmt::Display for ZeroKmsEndpoint { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.0.as_str()) + } +} + +impl From for Url { + fn from(endpoint: ZeroKmsEndpoint) -> Self { + endpoint.0 + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn endpoint(s: &str) -> ZeroKmsEndpoint { + s.parse().unwrap_or_else(|e| panic!("{s}: {e}")) + } + + mod accepts { + use super::*; + + #[test] + fn a_bare_host() { + assert_eq!(endpoint("https://a.example").as_str(), "https://a.example/"); + } + + #[test] + fn a_host_with_port_over_http() { + assert_eq!( + endpoint("http://localhost:3002").as_str(), + "http://localhost:3002/" + ); + } + + #[test] + fn a_path_prefix_and_terminates_it_with_a_slash() { + assert_eq!( + endpoint("https://gateway.example/zerokms").as_str(), + "https://gateway.example/zerokms/" + ); + } + + #[test] + fn an_already_slash_terminated_url_unchanged() { + for s in [ + "https://a.example/", + "https://a.example/zerokms/", + "http://localhost:3002/", + ] { + assert_eq!(endpoint(s).as_str(), s, "{s} should be left as-is"); + } + } + } + + mod request_url { + use super::*; + + #[test] + fn appends_to_a_path_prefix() { + assert_eq!( + endpoint("https://gateway.example/zerokms") + .request_url("retrieve-data-key") + .as_str(), + "https://gateway.example/zerokms/retrieve-data-key" + ); + } + + #[test] + fn appends_to_a_bare_host() { + assert_eq!( + endpoint("https://a.example") + .request_url("generate-data-key") + .as_str(), + "https://a.example/generate-data-key" + ); + } + + #[test] + fn matches_url_join_for_every_protocol_endpoint() { + // `Url::join` is what the connection used before this type existed; + // the infallible `set_path` construction must agree with it. + let base = endpoint("https://gateway.example/zerokms/v1"); + for path in [ + "generate-data-key", + "retrieve-data-key", + "retrieve-data-key-fallible", + ] { + assert_eq!( + base.request_url(path), + base.as_url().join(path).unwrap(), + "{path}" + ); + } + } + } + + mod rejects { + use super::*; + + #[test] + fn a_scheme_less_host_and_port() { + // `Url::parse` accepts this — scheme `localhost`, opaque path + // `8080` — but nothing could ever be joined onto it. + assert!(matches!( + "localhost:8080".parse::(), + Err(InvalidEndpoint::NoHost(_)) + )); + } + + #[test] + fn a_non_http_scheme() { + assert!(matches!( + "ftp://a.example".parse::(), + Err(InvalidEndpoint::Scheme(s)) if s == "ftp" + )); + } + + #[test] + fn a_query_string() { + assert!(matches!( + "https://gw.example/zerokms?apikey=abc".parse::(), + Err(InvalidEndpoint::QueryOrFragment(_)) + )); + } + + #[test] + fn a_fragment() { + assert!(matches!( + "https://gw.example/zerokms#frag".parse::(), + Err(InvalidEndpoint::QueryOrFragment(_)) + )); + } + + #[test] + fn userinfo() { + assert!(matches!( + "https://user:pw@gw.example".parse::(), + Err(InvalidEndpoint::Userinfo) + )); + } + + #[test] + fn garbage() { + assert!(matches!( + "not a url".parse::(), + Err(InvalidEndpoint::Parse(_)) + )); + } + } +} diff --git a/packages/stack-kms/src/errors.rs b/packages/stack-kms/src/errors.rs index 53f0ab907..6921b59b0 100644 --- a/packages/stack-kms/src/errors.rs +++ b/packages/stack-kms/src/errors.rs @@ -131,6 +131,10 @@ pub enum Error { #[error(transparent)] ConnectionInit(#[from] crate::connection::ConnectionInitError), + /// The ZeroKMS endpoint named by the token's `services` claim is unusable. + #[error("Invalid ZeroKMS endpoint in the token's services claim: {0}")] + InvalidEndpoint(#[from] crate::endpoint::InvalidEndpoint), + #[error("Unexpected error: {0}")] Unexpected(String), } diff --git a/packages/stack-kms/src/lib.rs b/packages/stack-kms/src/lib.rs index a81019ffa..ea80e06c3 100644 --- a/packages/stack-kms/src/lib.rs +++ b/packages/stack-kms/src/lib.rs @@ -65,6 +65,7 @@ mod builder; mod client; mod connection; +mod endpoint; mod errors; mod futures; mod key; @@ -89,6 +90,7 @@ pub use connection::{ ConnectionInitError, HttpConnection, HttpConnectionOpts, ZeroKMSConnection, ZeroKMSConnectionInit, }; +pub use endpoint::{InvalidEndpoint, ZeroKmsEndpoint}; // Errors pub use errors::{Error, GenerateKeyError, RetrieveKeyError}; From e03a73254f8606718cb7fdb52a8cb4e1b6cb7451 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Wed, 26 Aug 2026 12:52:26 +1000 Subject: [PATCH 405/686] fix(stack-kms): FakeDataKeySource resolves and enforces decryption policies as ZeroKMS does MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fake hashed and echoed the caller's `DecryptionPolicy` unchanged, so a `PolicyCondition { value: None }` — which ZeroKMS resolves from the caller's JWT at generation and rejects outright at retrieval — round- tripped through the fake, letting downstream tests pass while persisting a policy production would never accept. A `DataKeySource` sits above the bearer token, so the fake has no way to know who is calling. `as_caller([("sub", "alice")])` supplies that: the claims every request to the instance is treated as authenticated with. With it the fake now mirrors the server's three policy steps: - generation resolves `value: None` from the caller's claims (or fails as ZeroKMS's `UnsupportedClaim` would) and binds the *resolved* policy into the tag, returning it for storage; - retrieval rejects a policy carrying an unresolved condition up-front; - after the tag proof, retrieval requires the caller to satisfy at least one condition (flat OR), so a second fake with different claims models a different principal being denied. Key material is instance-independent, so `alice` and `mallory` fakes share one KMS for test purposes. Claude-Session: https://claude.ai/code/session_01HU1Bbw4eQp9kEEneXxDcKr --- packages/stack-kms/src/key_source.rs | 317 +++++++++++++++++++++++++-- 1 file changed, 300 insertions(+), 17 deletions(-) diff --git a/packages/stack-kms/src/key_source.rs b/packages/stack-kms/src/key_source.rs index c62e24492..bc0591495 100644 --- a/packages/stack-kms/src/key_source.rs +++ b/packages/stack-kms/src/key_source.rs @@ -102,11 +102,13 @@ where #[cfg(feature = "test-support")] mod fake { use super::*; - use crate::errors::RetrieveKeyError; + use crate::errors::{GenerateKeyError, RetrieveKeyError}; use crate::key::DataKey; use recipher::key::{Iv, Key}; use sha2::{Digest, Sha256}; + use std::collections::HashMap; use std::sync::atomic::{AtomicU64, Ordering}; + use zerokms_protocol::{DecryptionPolicy, PolicyCondition, ViturRequestError}; /// A deterministic, in-process [`DataKeySource`] for tests. /// @@ -129,9 +131,35 @@ mod fake { /// surfaces as `Err`, not as an AEAD failure downstream. /// /// `generate_keys` hands out a unique IV per payload (driven by an internal - /// counter) and returns the payload's `decryption_policy` on the - /// [`DataKeyWithTag`], as real ZeroKMS returns the resolved policy for - /// storage beside the ciphertext. + /// counter). + /// + /// # Caller identity and decryption policies + /// + /// ZeroKMS sees every request through the caller's bearer token: at + /// generation it fills `PolicyCondition { value: None }` from the caller's + /// JWT claims (`sub`, `workspace`, …) and returns the *resolved* policy for + /// storage beside the ciphertext; at retrieval it rejects a policy that + /// still carries an unresolved condition, and then checks that the caller's + /// claims satisfy at least one condition. A [`DataKeySource`] sits above + /// the token, so the fake needs to be told who is calling: + /// [`as_caller`](Self::as_caller) sets the claims every request to this + /// instance is treated as authenticated with. Key material does not + /// depend on the instance, so two fakes with different callers model two + /// principals sharing one KMS: + /// + /// ``` + /// # use stack_kms::FakeDataKeySource; + /// let alice = FakeDataKeySource::new().as_caller([("sub", "alice")]); + /// let mallory = FakeDataKeySource::new().as_caller([("sub", "mallory")]); + /// // keys `alice` generates under a `sub` policy retrieve for `alice`, + /// // and are rejected for `mallory`. + /// ``` + /// + /// A fake with no caller claims can still generate and retrieve keys + /// without a policy, or with a policy whose conditions all carry explicit + /// values — though retrieval of the latter is denied, since the (absent) + /// caller satisfies no condition, exactly as ZeroKMS would deny a token + /// without the claim. /// /// The derivations are plain SHA-256: deterministic and binding, but **not** /// a stand-in for ZeroKMS's real key derivation or HMAC tag. Use it for @@ -140,12 +168,98 @@ mod fake { #[derive(Debug, Default)] pub struct FakeDataKeySource { counter: AtomicU64, + /// The claims of the (single) caller this fake serves — its stand-in + /// for the bearer token `StackKms` would send with every request. + caller_claims: HashMap, } + /// The fake's stand-in for ZeroKMS's `UnsupportedClaim`: a policy asks for + /// a claim the caller's token doesn't carry. + #[derive(Debug, thiserror::Error)] + #[error("policy condition on claim `{0}` cannot be resolved: the caller has no such claim")] + struct UnresolvableClaim(String); + impl FakeDataKeySource { pub fn new() -> Self { Self::default() } + + /// Treat every request to this fake as authenticated with `claims` + /// (e.g. `[("sub", "alice"), ("workspace", "ws-1")]`). Replaces any + /// claims set earlier. + pub fn as_caller(mut self, claims: impl IntoIterator) -> Self + where + K: Into, + V: Into, + { + self.caller_claims = claims + .into_iter() + .map(|(k, v)| (k.into(), v.into())) + .collect(); + self + } + + /// Fill in `value: None` conditions from the caller's claims, as ZeroKMS + /// does at generation time. + fn resolve_policy(&self, policy: DecryptionPolicy) -> Result { + let conditions = policy + .conditions + .into_iter() + .map(|condition| { + let value = match condition.value { + Some(value) => value, + None => self + .caller_claims + .get(&condition.claim) + .cloned() + .ok_or_else(|| { + Error::GenerateKey(GenerateKeyError::RequestFailed( + ViturRequestError::other( + "unresolvable decryption policy condition", + UnresolvableClaim(condition.claim.clone()), + ), + )) + })?, + }; + Ok(PolicyCondition { + claim: condition.claim, + value: Some(value), + }) + }) + .collect::, Error>>()?; + Ok(DecryptionPolicy { conditions }) + } + + /// The retrieval-side policy checks ZeroKMS performs *before* and + /// *after* the tag proof: the stored policy must be fully resolved, + /// and the caller must satisfy at least one condition (flat OR). + fn check_policy_is_resolved(policy: &DecryptionPolicy) -> Result<(), Error> { + if policy.conditions.iter().any(|c| c.value.is_none()) { + return Err(retrieval_denied( + "unresolved policy condition: only the resolved policy returned at \ + generation is accepted at retrieval", + )); + } + Ok(()) + } + + fn check_caller_satisfies(&self, policy: &DecryptionPolicy) -> Result<(), Error> { + let satisfied = policy + .conditions + .iter() + .any(|c| self.caller_claims.get(&c.claim) == c.value.as_ref()); + if satisfied { + Ok(()) + } else { + Err(retrieval_denied( + "policy not satisfied: none of the conditions match the caller's claims", + )) + } + } + } + + fn retrieval_denied(reason: &str) -> Error { + Error::RetrieveKey(RetrieveKeyError::FailedRetrieval(reason.to_string())) } fn update_field(hasher: &mut Sha256, field: &[u8]) { @@ -223,9 +337,15 @@ mod fake { keyset_id: Option, _unverified_context: Option>, ) -> Result, Error> { - Ok(payloads + payloads .into_iter() .map(|payload| { + // Resolve first: the tag binds the *resolved* policy, as + // `create_v1_tag` only ever sees a `ResolvedDecryptionPolicy`. + let decryption_policy = payload + .decryption_policy + .map(|policy| self.resolve_policy(policy)) + .transpose()?; let n = self.counter.fetch_add(1, Ordering::Relaxed); let mut iv: Iv = [0u8; 16]; iv[..8].copy_from_slice(&n.to_le_bytes()); @@ -235,15 +355,15 @@ mod fake { iv: &iv, descriptor: payload.descriptor, context: &payload.context, - decryption_policy: payload.decryption_policy.as_ref(), + decryption_policy: decryption_policy.as_ref(), }); - DataKeyWithTag { + Ok(DataKeyWithTag { key: DataKey { iv, key }, tag, - decryption_policy: payload.decryption_policy, - } + decryption_policy, + }) }) - .collect()) + .collect() } async fn retrieve_keys( @@ -255,6 +375,9 @@ mod fake { payloads .iter() .map(|p| { + if let Some(policy) = &p.decryption_policy { + Self::check_policy_is_resolved(policy)?; + } let iv: Iv = *p.iv.as_ref(); let expected = derive_tag(TagInputs { keyset_id, @@ -266,11 +389,15 @@ mod fake { // A fake, so a plain comparison is fine; ZeroKMS compares // its HMAC tags in constant time. if expected != p.tag { - return Err(Error::RetrieveKey(RetrieveKeyError::FailedRetrieval( + return Err(retrieval_denied( "tag mismatch: the iv, descriptor, keyset, context or policy \ - differs from what the key was generated under" - .to_string(), - ))); + differs from what the key was generated under", + )); + } + // Tag proven, so the policy is the one written at generation; + // now the caller must satisfy it. + if let Some(policy) = &p.decryption_policy { + self.check_caller_satisfies(policy)?; } Ok(DataKey { iv, @@ -302,6 +429,20 @@ mod tests { } } + /// A policy that ZeroKMS resolves from the caller's token at generation. + fn unresolved_policy(claim: &str) -> DecryptionPolicy { + DecryptionPolicy { + conditions: vec![PolicyCondition { + claim: claim.to_string(), + value: None, + }], + } + } + + fn alice() -> FakeDataKeySource { + FakeDataKeySource::new().as_caller([("sub", "alice")]) + } + async fn generate_one( src: &FakeDataKeySource, payload: GenerateKeyPayload<'_>, @@ -482,7 +623,7 @@ mod tests { #[tokio::test] async fn generate_returns_the_payloads_policy() { - let src = FakeDataKeySource::new(); + let src = alice(); let p = policy("sub", "alice"); let dk = generate_one( &src, @@ -500,7 +641,7 @@ mod tests { #[tokio::test] async fn policy_round_trips_and_a_stripped_or_swapped_policy_is_rejected() { - let src = FakeDataKeySource::new(); + let src = alice(); let p = policy("sub", "alice"); let dk = generate_one( &src, @@ -538,7 +679,7 @@ mod tests { // which sends no context, and ZeroKMS's v1 (policy) tag never reads // the retrieve-side context either. So with a policy present, any // context — matching, stripped, or different — retrieves the key. - let src = FakeDataKeySource::new(); + let src = alice(); let p = policy("sub", "alice"); let ctx = vec![Context::Tag("tenant-1".into())]; let dk = generate_one( @@ -581,6 +722,148 @@ mod tests { assert_eq!(dk.key.key(), with_other_context.key()); } + mod caller_identity { + use super::*; + use crate::errors::GenerateKeyError; + + fn retrieve_payload<'a>(dk: &'a DataKeyWithTag) -> RetrieveKeyPayload<'a> { + RetrieveKeyPayload::new(dk.key.iv, "d", &dk.tag) + .with_decryption_policy(dk.decryption_policy.clone().unwrap()) + } + + #[tokio::test] + async fn an_unresolved_condition_is_resolved_from_the_callers_claims() { + let src = alice(); + let dk = generate_one( + &src, + GenerateKeyPayload::new("d", Cow::Owned(vec![])) + .with_decryption_policy(unresolved_policy("sub")), + None, + ) + .await; + + // ZeroKMS returns the resolved policy for storage beside the ciphertext. + assert_eq!(dk.decryption_policy, Some(policy("sub", "alice"))); + + let retrieved = retrieve_one(&src, retrieve_payload(&dk), None) + .await + .unwrap(); + assert_eq!(dk.key.key(), retrieved.key()); + } + + #[tokio::test] + async fn an_unresolved_condition_the_caller_cannot_satisfy_fails_generation() { + let src = FakeDataKeySource::new().as_caller([("workspace", "ws-1")]); + let result = src + .generate_keys( + vec![GenerateKeyPayload::new("d", Cow::Owned(vec![])) + .with_decryption_policy(unresolved_policy("sub"))], + None, + None, + ) + .await; + + assert!( + matches!( + result, + Err(Error::GenerateKey(GenerateKeyError::RequestFailed(_))) + ), + "a policy on a claim the caller lacks must fail generation, got: {result:?}" + ); + } + + #[tokio::test] + async fn an_unresolved_policy_is_rejected_at_retrieval() { + // The client must store and send back the resolved policy from + // generation; sending the unresolved form (which the tag would + // *not* bind either way) is rejected up-front as ZeroKMS does. + let src = alice(); + let dk = generate_one( + &src, + GenerateKeyPayload::new("d", Cow::Owned(vec![])) + .with_decryption_policy(unresolved_policy("sub")), + None, + ) + .await; + + let result = retrieve_one( + &src, + RetrieveKeyPayload::new(dk.key.iv, "d", &dk.tag) + .with_decryption_policy(unresolved_policy("sub")), + None, + ) + .await; + assert_rejected(result, "unresolved policy at retrieval"); + } + + #[tokio::test] + async fn a_different_caller_is_denied_the_key() { + let dk = generate_one( + &alice(), + GenerateKeyPayload::new("d", Cow::Owned(vec![])) + .with_decryption_policy(unresolved_policy("sub")), + None, + ) + .await; + + let mallory = FakeDataKeySource::new().as_caller([("sub", "mallory")]); + assert_rejected( + retrieve_one(&mallory, retrieve_payload(&dk), None).await, + "mallory retrieving alice's key", + ); + + let anonymous = FakeDataKeySource::new(); + assert_rejected( + retrieve_one(&anonymous, retrieve_payload(&dk), None).await, + "a caller with no claims retrieving alice's key", + ); + } + + #[tokio::test] + async fn any_one_condition_satisfies_a_policy() { + // Flat OR, as `ResolvedDecryptionPolicy::verify`. + let p = DecryptionPolicy { + conditions: vec![ + PolicyCondition { + claim: "sub".into(), + value: Some("alice".into()), + }, + PolicyCondition { + claim: "sub".into(), + value: Some("bob".into()), + }, + ], + }; + let dk = generate_one( + &alice(), + GenerateKeyPayload::new("d", Cow::Owned(vec![])).with_decryption_policy(p), + None, + ) + .await; + + let bob = FakeDataKeySource::new().as_caller([("sub", "bob")]); + let retrieved = retrieve_one(&bob, retrieve_payload(&dk), None) + .await + .unwrap(); + assert_eq!(dk.key.key(), retrieved.key()); + } + + #[tokio::test] + async fn keys_without_a_policy_need_no_caller() { + let src = FakeDataKeySource::new(); + let dk = + generate_one(&src, GenerateKeyPayload::new("d", Cow::Owned(vec![])), None).await; + let retrieved = retrieve_one( + &FakeDataKeySource::new(), + RetrieveKeyPayload::new(dk.key.iv, "d", &dk.tag), + None, + ) + .await + .unwrap(); + assert_eq!(dk.key.key(), retrieved.key()); + } + } + #[tokio::test] async fn a_batch_with_one_bad_tag_fails_as_a_whole() { let src = FakeDataKeySource::new(); From ce479d534b0835572ad2514b36d17bcf247fccf0 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Wed, 26 Aug 2026 12:52:26 +1000 Subject: [PATCH 406/686] feat(stack-kms): put the on-disk profile behind a `profile` feature; zeroize-safe `from_env` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `stack-profile` (the CLI's `secretkey.json` store) was a hard native dependency, so any consumer — `stack-encrypt` included — pulled in the filesystem profile layer even when the key only ever comes from memory or the environment. It is now an optional dep behind the default-on `profile` feature, and `ProfileStore` is re-exported under it so callers of `with_key_provider(ProfileStore::new(..))` need not depend on `stack-profile` themselves. Still never compiled on wasm32. `SecretKey::from_env` read both variables eagerly, so the "key set, id missing" arm dropped an owned copy of `CS_CLIENT_KEY` without zeroizing it. It now reads the key only once the id is present. Claude-Session: https://claude.ai/code/session_01HU1Bbw4eQp9kEEneXxDcKr --- packages/stack-kms/Cargo.toml | 15 ++++++++--- packages/stack-kms/src/lib.rs | 4 +++ packages/stack-kms/src/secret_key.rs | 40 +++++++++++++--------------- 3 files changed, 33 insertions(+), 26 deletions(-) diff --git a/packages/stack-kms/Cargo.toml b/packages/stack-kms/Cargo.toml index bf038e0ea..d1b5435ec 100644 --- a/packages/stack-kms/Cargo.toml +++ b/packages/stack-kms/Cargo.toml @@ -14,6 +14,12 @@ license-file = "LICENSE" publish = false [features] +default = ["profile"] +# Loads the client key from the CLI's on-disk profile (`secretkey.json`, via +# `stack-profile`): `SecretKey: ProfileData` and `KeyProvider for ProfileStore`. +# Disable for consumers that only ever source the key in-memory or from the +# environment. Never compiled on wasm32 regardless. +profile = ["dep:stack-profile"] # Exposes `FakeDataKeySource`, a deterministic in-memory `DataKeySource` for # downstream crates (e.g. `stack-encrypt`) to unit-test encrypt/decrypt without # ZeroKMS credentials or network access. @@ -54,11 +60,12 @@ serde_cbor = "0.11.2" serdect = { version = "0.3.0", features = ["zeroize"] } sha2 = "0.10.6" -# Native-only: `stack-profile` is the filesystem-backed profile/config layer -# used by `SecretKey` (the on-disk `secretkey.json`) and the `ProfileStore` -# key provider. Wasm consumers source the client key in-memory instead. +# Native-only and behind the `profile` feature: `stack-profile` is the +# filesystem-backed profile/config layer used by `SecretKey` (the on-disk +# `secretkey.json`) and the `ProfileStore` key provider. Wasm consumers source +# the client key in-memory instead. [target.'cfg(not(target_arch = "wasm32"))'.dependencies] -stack-profile = { workspace = true } +stack-profile = { workspace = true, optional = true } [dev-dependencies] async-mutex = "1.4.0" diff --git a/packages/stack-kms/src/lib.rs b/packages/stack-kms/src/lib.rs index ea80e06c3..33b106379 100644 --- a/packages/stack-kms/src/lib.rs +++ b/packages/stack-kms/src/lib.rs @@ -108,6 +108,10 @@ pub use key_provider::{ EnvKeyProvider, FallbackKeyProvider, KeyProvider, KeyProviderError, StaticKeyProvider, }; pub use secret_key::SecretKey; +// `KeyProvider` is implemented for `ProfileStore` (the CLI's on-disk profile), +// so callers need to be able to name it without depending on `stack-profile`. +#[cfg(all(feature = "profile", not(target_arch = "wasm32")))] +pub use stack_profile::ProfileStore; // Operation payloads pub use payload::{GenerateKeyPayload, RetrieveKeyPayload}; diff --git a/packages/stack-kms/src/secret_key.rs b/packages/stack-kms/src/secret_key.rs index de987f4b2..dcb9eebad 100644 --- a/packages/stack-kms/src/secret_key.rs +++ b/packages/stack-kms/src/secret_key.rs @@ -1,6 +1,6 @@ use base64ct::Encoding; use serde::{Deserialize, Serialize}; -#[cfg(not(target_arch = "wasm32"))] +#[cfg(all(feature = "profile", not(target_arch = "wasm32")))] use stack_profile::{ProfileData, ProfileError, ProfileStore}; use uuid::Uuid; use vitaminc::protected::OpaqueDebug; @@ -132,29 +132,24 @@ impl SecretKey { pub fn from_env() -> Result, KeyProviderError> { use crate::vars::{CS_CLIENT_ID, CS_CLIENT_KEY}; - match (std::env::var(CS_CLIENT_ID), std::env::var(CS_CLIENT_KEY)) { - (Ok(id), Ok(key)) => { - tracing::debug!("both {CS_CLIENT_ID} and {CS_CLIENT_KEY} set, loading secret key"); - Self::from_hex(id, key).map(Some) - } - (Ok(_), Err(_)) => { - tracing::debug!("{CS_CLIENT_ID} set but {CS_CLIENT_KEY} missing, skipping"); - Ok(None) - } - (Err(_), Ok(_)) => { - tracing::debug!("{CS_CLIENT_KEY} set but {CS_CLIENT_ID} missing, skipping"); - Ok(None) - } - (Err(_), Err(_)) => { - tracing::debug!("neither {CS_CLIENT_ID} nor {CS_CLIENT_KEY} set"); - Ok(None) - } - } + // Check the ID first and only then read the key: reading `CS_CLIENT_KEY` + // when it can't be used would leave an owned copy of the key material + // to be dropped un-zeroized. + let Ok(id) = std::env::var(CS_CLIENT_ID) else { + tracing::debug!("{CS_CLIENT_ID} not set, skipping env secret key"); + return Ok(None); + }; + let Ok(key) = std::env::var(CS_CLIENT_KEY) else { + tracing::debug!("{CS_CLIENT_ID} set but {CS_CLIENT_KEY} missing, skipping"); + return Ok(None); + }; + tracing::debug!("both {CS_CLIENT_ID} and {CS_CLIENT_KEY} set, loading secret key"); + Self::from_hex(id, key).map(Some) } } /// Implement [ProfileData] for [SecretKey] to enable loading/saving from the profile directory. -#[cfg(not(target_arch = "wasm32"))] +#[cfg(all(feature = "profile", not(target_arch = "wasm32")))] impl ProfileData for SecretKey { const FILENAME: &'static str = "secretkey.json"; const MODE: Option = Some(0o600); @@ -168,7 +163,7 @@ impl KeyProvider for SecretKey { } } -#[cfg(not(target_arch = "wasm32"))] +#[cfg(all(feature = "profile", not(target_arch = "wasm32")))] impl KeyProvider for ProfileStore { async fn client_key(&self) -> Result { let ws_store = self.current_workspace_store().map_err(|e| match e { @@ -188,7 +183,6 @@ impl KeyProvider for ProfileStore { mod tests { use super::*; use recipher::keyset::{EncryptionKeySet, ProxyKeySet}; - use tempfile::TempDir; /// Build a random `SecretKey` and return it alongside the `client_id` and raw keyset bytes. fn random_secret_key() -> (SecretKey, Uuid, Vec) { @@ -367,8 +361,10 @@ mod tests { } } + #[cfg(all(feature = "profile", not(target_arch = "wasm32")))] mod profile_store_provider { use super::*; + use tempfile::TempDir; const TEST_WORKSPACE_ID: &str = "ZVATKW3VHMFG27DY"; From 6756901cfc876df0e719d75c6cc83effa31e547d Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Wed, 26 Aug 2026 13:37:36 +1000 Subject: [PATCH 407/686] refactor(stack-kms): make FakeDataKeySource a stub, not a ZeroKMS simulator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review round after review round pushed the test fake toward re-implementing ZeroKMS: SHA-256 key derivation bound to keyset/IV/ descriptor, tags hashing context and policy, tag-mismatch rejection, then caller claims with policy resolution and verification, and next would have been `Context::IdentityClaim` resolution, `azp` gating, and so on. Each step was locally reasonable; the sum was a second ZeroKMS that shares no code with the real one and drifts by construction (cipherstash/cipherstash-suite#2145 had already rewritten its derivation). None of that is what consumers need. `stack-encrypt` has no authorization logic of its own — it forwards payloads — so a test that "Mallory can't retrieve Alice's key" through the fake only proves the fake was written that way. Authorization semantics are ZeroKMS's and are tested in vitur-server-core; end-to-end belongs to integration tests against the real service. The fake is now a stub: random key/IV/tag per generated payload, kept in a map under `(iv, tag)`; retrieval is a lookup or `FailedRetrieval`. The decryption policy is echoed back; descriptor, context, keyset and policy are otherwise ignored, and the docs say so. A test pins that non-contract so nobody makes it "smarter" again. Consumers wanting to assert what they *send* should mock the trait. Claude-Session: https://claude.ai/code/session_01HU1Bbw4eQp9kEEneXxDcKr --- packages/stack-kms/src/key_source.rs | 768 +++++---------------------- 1 file changed, 128 insertions(+), 640 deletions(-) diff --git a/packages/stack-kms/src/key_source.rs b/packages/stack-kms/src/key_source.rs index bc0591495..310590495 100644 --- a/packages/stack-kms/src/key_source.rs +++ b/packages/stack-kms/src/key_source.rs @@ -105,262 +105,80 @@ mod fake { use crate::errors::{GenerateKeyError, RetrieveKeyError}; use crate::key::DataKey; use recipher::key::{Iv, Key}; - use sha2::{Digest, Sha256}; use std::collections::HashMap; - use std::sync::atomic::{AtomicU64, Ordering}; - use zerokms_protocol::{DecryptionPolicy, PolicyCondition, ViturRequestError}; + use std::sync::Mutex; + use vitaminc::random::{Generatable, SafeRand}; - /// A deterministic, in-process [`DataKeySource`] for tests. + /// An in-memory stub [`DataKeySource`] for tests and examples that need + /// `generate_keys` → `retrieve_keys` to round-trip without ZeroKMS + /// credentials or network access. /// - /// Mirrors the *shape* of ZeroKMS's key/tag split so error behaviour matches - /// production, without credentials or network: + /// `generate_keys` hands out a random key, IV and tag per payload and + /// remembers the key under `(iv, tag)`; `retrieve_keys` looks each payload + /// up by the same pair and fails with + /// [`RetrieveKeyError::FailedRetrieval`] when there is no such key. That is + /// the whole contract. /// - /// * **Key material** is derived from the `keyset_id`, the IV and the - /// `descriptor` only — as in ZeroKMS, where it is a function of the IV, - /// descriptor and the keyset's authority key, never of the tag, context - /// or policy. - /// * **The tag** binds the `keyset_id`, IV, descriptor and either the - /// `decryption_policy` (policy-bearing "v1" keys, where context is - /// ignored on both sides — `GenerateKeySpec::new_with_policy` sends none - /// and `create_v1_tag` does not read it) or the `context` ("v0" keys). - /// * **`retrieve_keys` recomputes the expected tag** from the retrieve - /// payload and rejects a mismatch with - /// [`RetrieveKeyError::FailedRetrieval`](crate::errors::RetrieveKeyError::FailedRetrieval), - /// as ZeroKMS rejects a failed tag proof — it never returns wrong key - /// material. So a wrong IV, descriptor, keyset, context or policy - /// surfaces as `Err`, not as an AEAD failure downstream. - /// - /// `generate_keys` hands out a unique IV per payload (driven by an internal - /// counter). - /// - /// # Caller identity and decryption policies - /// - /// ZeroKMS sees every request through the caller's bearer token: at - /// generation it fills `PolicyCondition { value: None }` from the caller's - /// JWT claims (`sub`, `workspace`, …) and returns the *resolved* policy for - /// storage beside the ciphertext; at retrieval it rejects a policy that - /// still carries an unresolved condition, and then checks that the caller's - /// claims satisfy at least one condition. A [`DataKeySource`] sits above - /// the token, so the fake needs to be told who is calling: - /// [`as_caller`](Self::as_caller) sets the claims every request to this - /// instance is treated as authenticated with. Key material does not - /// depend on the instance, so two fakes with different callers model two - /// principals sharing one KMS: - /// - /// ``` - /// # use stack_kms::FakeDataKeySource; - /// let alice = FakeDataKeySource::new().as_caller([("sub", "alice")]); - /// let mallory = FakeDataKeySource::new().as_caller([("sub", "mallory")]); - /// // keys `alice` generates under a `sub` policy retrieve for `alice`, - /// // and are rejected for `mallory`. - /// ``` - /// - /// A fake with no caller claims can still generate and retrieve keys - /// without a policy, or with a policy whose conditions all carry explicit - /// values — though retrieval of the latter is denied, since the (absent) - /// caller satisfies no condition, exactly as ZeroKMS would deny a token - /// without the claim. - /// - /// The derivations are plain SHA-256: deterministic and binding, but **not** - /// a stand-in for ZeroKMS's real key derivation or HMAC tag. Use it for - /// encrypt/decrypt round-trip and wrong-context/policy tests, not for - /// cryptographic assertions. + /// **This stub models none of ZeroKMS's authorization semantics.** The + /// `descriptor`, `context`, `keyset_id` and `decryption_policy` on a + /// payload are accepted and ignored (the policy is echoed back on the + /// generated key, as the real service returns the resolved policy for + /// storage). Nothing is derived, resolved, or verified — a wrong + /// descriptor, a stripped context, an unresolved policy condition or a + /// different caller all retrieve just fine here. Those decisions are + /// ZeroKMS's, tested in `vitur-server-core`; do not assert them against + /// this stub. Consumers testing *what they send* should mock the trait. #[derive(Debug, Default)] pub struct FakeDataKeySource { - counter: AtomicU64, - /// The claims of the (single) caller this fake serves — its stand-in - /// for the bearer token `StackKms` would send with every request. - caller_claims: HashMap, + keys: Mutex), Key>>, } - /// The fake's stand-in for ZeroKMS's `UnsupportedClaim`: a policy asks for - /// a claim the caller's token doesn't carry. - #[derive(Debug, thiserror::Error)] - #[error("policy condition on claim `{0}` cannot be resolved: the caller has no such claim")] - struct UnresolvableClaim(String); - impl FakeDataKeySource { pub fn new() -> Self { Self::default() } - /// Treat every request to this fake as authenticated with `claims` - /// (e.g. `[("sub", "alice"), ("workspace", "ws-1")]`). Replaces any - /// claims set earlier. - pub fn as_caller(mut self, claims: impl IntoIterator) -> Self - where - K: Into, - V: Into, - { - self.caller_claims = claims - .into_iter() - .map(|(k, v)| (k.into(), v.into())) - .collect(); - self + /// Number of keys generated so far and available to retrieve. + pub fn len(&self) -> usize { + self.lock().len() } - /// Fill in `value: None` conditions from the caller's claims, as ZeroKMS - /// does at generation time. - fn resolve_policy(&self, policy: DecryptionPolicy) -> Result { - let conditions = policy - .conditions - .into_iter() - .map(|condition| { - let value = match condition.value { - Some(value) => value, - None => self - .caller_claims - .get(&condition.claim) - .cloned() - .ok_or_else(|| { - Error::GenerateKey(GenerateKeyError::RequestFailed( - ViturRequestError::other( - "unresolvable decryption policy condition", - UnresolvableClaim(condition.claim.clone()), - ), - )) - })?, - }; - Ok(PolicyCondition { - claim: condition.claim, - value: Some(value), - }) - }) - .collect::, Error>>()?; - Ok(DecryptionPolicy { conditions }) + pub fn is_empty(&self) -> bool { + self.len() == 0 } - /// The retrieval-side policy checks ZeroKMS performs *before* and - /// *after* the tag proof: the stored policy must be fully resolved, - /// and the caller must satisfy at least one condition (flat OR). - fn check_policy_is_resolved(policy: &DecryptionPolicy) -> Result<(), Error> { - if policy.conditions.iter().any(|c| c.value.is_none()) { - return Err(retrieval_denied( - "unresolved policy condition: only the resolved policy returned at \ - generation is accepted at retrieval", - )); - } - Ok(()) - } - - fn check_caller_satisfies(&self, policy: &DecryptionPolicy) -> Result<(), Error> { - let satisfied = policy - .conditions - .iter() - .any(|c| self.caller_claims.get(&c.claim) == c.value.as_ref()); - if satisfied { - Ok(()) - } else { - Err(retrieval_denied( - "policy not satisfied: none of the conditions match the caller's claims", - )) - } - } - } - - fn retrieval_denied(reason: &str) -> Error { - Error::RetrieveKey(RetrieveKeyError::FailedRetrieval(reason.to_string())) - } - - fn update_field(hasher: &mut Sha256, field: &[u8]) { - hasher.update((field.len() as u64).to_le_bytes()); - hasher.update(field); - } - - fn update_option(hasher: &mut Sha256, field: Option<&[u8]>) { - match field { - Some(bytes) => { - hasher.update([1u8]); - update_field(hasher, bytes); - } - None => hasher.update([0u8]), - } - } - - /// Key material: a function of the keyset, IV and descriptor only. Every - /// field is length-prefixed (and `Option`s tagged) so distinct inputs can't - /// collide via ambiguous concatenation. - fn derive_key(keyset_id: Option, iv: &Iv, descriptor: &str) -> Key { - let mut hasher = Sha256::new(); - hasher.update(b"stack-kms::FakeDataKeySource::key::v3"); - update_option( - &mut hasher, - keyset_id.as_ref().map(|id| id.as_bytes().as_slice()), - ); - update_field(&mut hasher, iv); - update_field(&mut hasher, descriptor.as_bytes()); - hasher.finalize().into() - } - - /// Everything the fake tag binds. Mirrors what ZeroKMS's HMAC tag covers - /// for one key. - struct TagInputs<'a> { - keyset_id: Option, - iv: &'a Iv, - descriptor: &'a str, - context: &'a [zerokms_protocol::Context], - decryption_policy: Option<&'a zerokms_protocol::DecryptionPolicy>, - } - - /// The tag: binds keyset, IV, descriptor and *either* the policy (v1 — - /// context ignored) *or* the context (v0), exactly as `create_v1_tag` / - /// `create_v0_tag` split them. - fn derive_tag(inputs: TagInputs<'_>) -> Vec { - let mut hasher = Sha256::new(); - hasher.update(b"stack-kms::FakeDataKeySource::tag::v3"); - update_option( - &mut hasher, - inputs.keyset_id.as_ref().map(|id| id.as_bytes().as_slice()), - ); - update_field(&mut hasher, inputs.iv); - update_field(&mut hasher, inputs.descriptor.as_bytes()); - match inputs.decryption_policy { - Some(policy) => { - hasher.update([1u8]); - update_field(&mut hasher, &serde_json::to_vec(policy).unwrap_or_default()); - } - None => { - hasher.update([0u8]); - update_field( - &mut hasher, - &serde_json::to_vec(inputs.context).unwrap_or_default(), - ); - } + fn lock(&self) -> std::sync::MutexGuard<'_, HashMap<(Iv, Vec), Key>> { + // A poisoned lock only means another test thread panicked mid-insert; + // the map is still a valid map. + self.keys + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) } - hasher.finalize().to_vec() } impl DataKeySource for FakeDataKeySource { async fn generate_keys( &self, payloads: Vec>, - keyset_id: Option, + _keyset_id: Option, _unverified_context: Option>, ) -> Result, Error> { + let mut rng = SafeRand::from_entropy().map_err(GenerateKeyError::GenerateIv)?; + let mut keys = self.lock(); payloads .into_iter() .map(|payload| { - // Resolve first: the tag binds the *resolved* policy, as - // `create_v1_tag` only ever sees a `ResolvedDecryptionPolicy`. - let decryption_policy = payload - .decryption_policy - .map(|policy| self.resolve_policy(policy)) - .transpose()?; - let n = self.counter.fetch_add(1, Ordering::Relaxed); - let mut iv: Iv = [0u8; 16]; - iv[..8].copy_from_slice(&n.to_le_bytes()); - let key = derive_key(keyset_id, &iv, payload.descriptor); - let tag = derive_tag(TagInputs { - keyset_id, - iv: &iv, - descriptor: payload.descriptor, - context: &payload.context, - decryption_policy: decryption_policy.as_ref(), - }); + let iv: Iv = + Generatable::random(&mut rng).map_err(GenerateKeyError::GenerateIv)?; + let key: Key = + Generatable::random(&mut rng).map_err(GenerateKeyError::GenerateIv)?; + let tag: [u8; 32] = + Generatable::random(&mut rng).map_err(GenerateKeyError::GenerateIv)?; + let _ = keys.insert((iv, tag.to_vec()), key); Ok(DataKeyWithTag { key: DataKey { iv, key }, - tag, - decryption_policy, + tag: tag.to_vec(), + decryption_policy: payload.decryption_policy, }) }) .collect() @@ -369,40 +187,21 @@ mod fake { async fn retrieve_keys( &self, payloads: Vec>, - keyset_id: Option, + _keyset_id: Option, _unverified_context: Option<&UnverifiedContext>, ) -> Result, Error> { + let keys = self.lock(); payloads .iter() .map(|p| { - if let Some(policy) = &p.decryption_policy { - Self::check_policy_is_resolved(policy)?; - } let iv: Iv = *p.iv.as_ref(); - let expected = derive_tag(TagInputs { - keyset_id, - iv: &iv, - descriptor: p.descriptor, - context: &p.context, - decryption_policy: p.decryption_policy.as_ref(), - }); - // A fake, so a plain comparison is fine; ZeroKMS compares - // its HMAC tags in constant time. - if expected != p.tag { - return Err(retrieval_denied( - "tag mismatch: the iv, descriptor, keyset, context or policy \ - differs from what the key was generated under", - )); - } - // Tag proven, so the policy is the one written at generation; - // now the caller must satisfy it. - if let Some(policy) = &p.decryption_policy { - self.check_caller_satisfies(policy)?; - } - Ok(DataKey { - iv, - key: derive_key(keyset_id, &iv, p.descriptor), - }) + keys.get(&(iv, p.tag.to_vec())) + .map(|key| DataKey { iv, key: *key }) + .ok_or_else(|| { + Error::RetrieveKey(RetrieveKeyError::FailedRetrieval( + "no key was generated with this iv and tag".to_string(), + )) + }) }) .collect() } @@ -418,53 +217,29 @@ mod tests { use crate::errors::RetrieveKeyError; use crate::payload::{GenerateKeyPayload, RetrieveKeyPayload}; use std::borrow::Cow; - use zerokms_protocol::{Context, DecryptionPolicy, PolicyCondition}; - - fn policy(claim: &str, value: &str) -> DecryptionPolicy { - DecryptionPolicy { - conditions: vec![PolicyCondition { - claim: claim.to_string(), - value: Some(value.to_string()), - }], - } - } + use zerokms_protocol::{DecryptionPolicy, PolicyCondition}; - /// A policy that ZeroKMS resolves from the caller's token at generation. - fn unresolved_policy(claim: &str) -> DecryptionPolicy { - DecryptionPolicy { - conditions: vec![PolicyCondition { - claim: claim.to_string(), - value: None, - }], - } - } - - fn alice() -> FakeDataKeySource { - FakeDataKeySource::new().as_caller([("sub", "alice")]) - } - - async fn generate_one( - src: &FakeDataKeySource, - payload: GenerateKeyPayload<'_>, - keyset_id: Option, - ) -> DataKeyWithTag { - src.generate_keys(vec![payload], keyset_id, None) - .await - .unwrap() - .remove(0) + async fn generate_one(src: &FakeDataKeySource, descriptor: &str) -> DataKeyWithTag { + src.generate_keys( + vec![GenerateKeyPayload::new(descriptor, Cow::Owned(vec![]))], + None, + None, + ) + .await + .unwrap() + .remove(0) } async fn retrieve_one( src: &FakeDataKeySource, payload: RetrieveKeyPayload<'_>, - keyset_id: Option, ) -> Result { - src.retrieve_keys(vec![payload], keyset_id, None) + src.retrieve_keys(vec![payload], None, None) .await .map(|mut keys| keys.remove(0)) } - fn assert_rejected(result: Result, what: &str) { + fn assert_not_found(result: Result, what: &str) { match result { Err(Error::RetrieveKey(RetrieveKeyError::FailedRetrieval(_))) => {} Err(other) => panic!("{what}: expected FailedRetrieval, got {other:?}"), @@ -475,31 +250,22 @@ mod tests { #[tokio::test] async fn generate_then_retrieve_reproduces_the_key() { let src = FakeDataKeySource::new(); - let dk = generate_one( - &src, - GenerateKeyPayload::new("users/email", Cow::Owned(vec![])), - None, - ) - .await; + let dk = generate_one(&src, "users/email").await; let retrieved = retrieve_one( &src, RetrieveKeyPayload::new(dk.key.iv, "users/email", &dk.tag), - None, ) .await .unwrap(); - assert_eq!( - dk.key.key(), - retrieved.key(), - "retrieve must reproduce the generated key" - ); + assert_eq!(dk.key.key(), retrieved.key()); assert_eq!(dk.key.iv, retrieved.iv); + assert_eq!(src.len(), 1); } #[tokio::test] - async fn generate_hands_out_unique_ivs_and_tags() { + async fn every_generated_key_is_distinct() { let src = FakeDataKeySource::new(); let keys = src .generate_keys( @@ -516,358 +282,40 @@ mod tests { assert_ne!(keys[0].key.iv, keys[1].key.iv); assert_ne!(keys[0].tag, keys[1].tag); assert_ne!(keys[0].key.key(), keys[1].key.key()); + assert_eq!(src.len(), 2); } #[tokio::test] - async fn mismatched_tag_is_rejected() { - // ZeroKMS fails the tag proof rather than returning other material. + async fn an_unknown_tag_or_iv_is_not_found() { let src = FakeDataKeySource::new(); - let dk = generate_one(&src, GenerateKeyPayload::new("d", Cow::Owned(vec![])), None).await; - - let result = retrieve_one( - &src, - RetrieveKeyPayload::new(dk.key.iv, "d", b"wrong-tag"), - None, - ) - .await; - assert_rejected(result, "wrong tag"); - } + let dk = generate_one(&src, "d").await; - #[tokio::test] - async fn mismatched_iv_is_rejected() { - let src = FakeDataKeySource::new(); - let dk = generate_one(&src, GenerateKeyPayload::new("d", Cow::Owned(vec![])), None).await; + assert_not_found( + retrieve_one(&src, RetrieveKeyPayload::new(dk.key.iv, "d", b"wrong-tag")).await, + "wrong tag", + ); let mut other_iv = dk.key.iv; other_iv[15] ^= 0xff; - let result = - retrieve_one(&src, RetrieveKeyPayload::new(other_iv, "d", &dk.tag), None).await; - assert_rejected(result, "wrong iv"); - } - - #[tokio::test] - async fn mismatched_descriptor_is_rejected() { - let src = FakeDataKeySource::new(); - let dk = generate_one( - &src, - GenerateKeyPayload::new("users/email", Cow::Owned(vec![])), - None, - ) - .await; - - let result = retrieve_one( - &src, - RetrieveKeyPayload::new(dk.key.iv, "users/name", &dk.tag), - None, - ) - .await; - assert_rejected(result, "wrong descriptor"); - } - - #[tokio::test] - async fn mismatched_keyset_is_rejected() { - let src = FakeDataKeySource::new(); - let keyset = Uuid::new_v4(); - let dk = generate_one( - &src, - GenerateKeyPayload::new("d", Cow::Owned(vec![])), - Some(keyset), - ) - .await; - - let same = retrieve_one( - &src, - RetrieveKeyPayload::new(dk.key.iv, "d", &dk.tag), - Some(keyset), - ) - .await - .unwrap(); - assert_eq!(dk.key.key(), same.key()); - - let other = retrieve_one( - &src, - RetrieveKeyPayload::new(dk.key.iv, "d", &dk.tag), - Some(Uuid::new_v4()), - ) - .await; - assert_rejected(other, "other keyset"); - - let none = retrieve_one(&src, RetrieveKeyPayload::new(dk.key.iv, "d", &dk.tag), None).await; - assert_rejected(none, "no keyset"); - } - - #[tokio::test] - async fn mismatched_context_is_rejected() { - let src = FakeDataKeySource::new(); - let ctx = vec![Context::Tag("tenant-1".into())]; - let dk = generate_one( - &src, - GenerateKeyPayload::new("d", Cow::Borrowed(&ctx)), - None, - ) - .await; - - let same = retrieve_one( - &src, - RetrieveKeyPayload::new(dk.key.iv, "d", &dk.tag).with_context(Cow::Borrowed(&ctx)), - None, - ) - .await - .unwrap(); - assert_eq!(dk.key.key(), same.key()); - - let stripped = - retrieve_one(&src, RetrieveKeyPayload::new(dk.key.iv, "d", &dk.tag), None).await; - assert_rejected(stripped, "stripped context"); - } - - #[tokio::test] - async fn generate_returns_the_payloads_policy() { - let src = alice(); - let p = policy("sub", "alice"); - let dk = generate_one( - &src, - GenerateKeyPayload::new("d", Cow::Owned(vec![])).with_decryption_policy(p.clone()), - None, - ) - .await; - - assert_eq!(dk.decryption_policy.as_ref(), Some(&p)); - - let without = - generate_one(&src, GenerateKeyPayload::new("d", Cow::Owned(vec![])), None).await; - assert!(without.decryption_policy.is_none()); - } - - #[tokio::test] - async fn policy_round_trips_and_a_stripped_or_swapped_policy_is_rejected() { - let src = alice(); - let p = policy("sub", "alice"); - let dk = generate_one( - &src, - GenerateKeyPayload::new("d", Cow::Owned(vec![])).with_decryption_policy(p.clone()), - None, - ) - .await; - - let same = retrieve_one( - &src, - RetrieveKeyPayload::new(dk.key.iv, "d", &dk.tag).with_decryption_policy(p.clone()), - None, - ) - .await - .unwrap(); - assert_eq!(dk.key.key(), same.key()); - - let stripped = - retrieve_one(&src, RetrieveKeyPayload::new(dk.key.iv, "d", &dk.tag), None).await; - assert_rejected(stripped, "stripped policy"); - - let swapped = retrieve_one( - &src, - RetrieveKeyPayload::new(dk.key.iv, "d", &dk.tag) - .with_decryption_policy(policy("sub", "mallory")), - None, - ) - .await; - assert_rejected(swapped, "swapped policy"); - } - - #[tokio::test] - async fn policy_bearing_keys_ignore_context_on_both_sides() { - // `Client::generate_keys` builds `GenerateKeySpec::new_with_policy`, - // which sends no context, and ZeroKMS's v1 (policy) tag never reads - // the retrieve-side context either. So with a policy present, any - // context — matching, stripped, or different — retrieves the key. - let src = alice(); - let p = policy("sub", "alice"); - let ctx = vec![Context::Tag("tenant-1".into())]; - let dk = generate_one( - &src, - GenerateKeyPayload::new("d", Cow::Borrowed(&ctx)).with_decryption_policy(p.clone()), - None, - ) - .await; - - let with_same_context = retrieve_one( - &src, - RetrieveKeyPayload::new(dk.key.iv, "d", &dk.tag) - .with_context(Cow::Borrowed(&ctx)) - .with_decryption_policy(p.clone()), - None, - ) - .await - .unwrap(); - assert_eq!(dk.key.key(), with_same_context.key()); - - let without_context = retrieve_one( - &src, - RetrieveKeyPayload::new(dk.key.iv, "d", &dk.tag).with_decryption_policy(p.clone()), - None, - ) - .await - .unwrap(); - assert_eq!(dk.key.key(), without_context.key()); - - let other_ctx = vec![Context::Tag("tenant-2".into())]; - let with_other_context = retrieve_one( - &src, - RetrieveKeyPayload::new(dk.key.iv, "d", &dk.tag) - .with_context(Cow::Borrowed(&other_ctx)) - .with_decryption_policy(p), - None, - ) - .await - .unwrap(); - assert_eq!(dk.key.key(), with_other_context.key()); - } - - mod caller_identity { - use super::*; - use crate::errors::GenerateKeyError; - - fn retrieve_payload<'a>(dk: &'a DataKeyWithTag) -> RetrieveKeyPayload<'a> { - RetrieveKeyPayload::new(dk.key.iv, "d", &dk.tag) - .with_decryption_policy(dk.decryption_policy.clone().unwrap()) - } - - #[tokio::test] - async fn an_unresolved_condition_is_resolved_from_the_callers_claims() { - let src = alice(); - let dk = generate_one( - &src, - GenerateKeyPayload::new("d", Cow::Owned(vec![])) - .with_decryption_policy(unresolved_policy("sub")), - None, - ) - .await; - - // ZeroKMS returns the resolved policy for storage beside the ciphertext. - assert_eq!(dk.decryption_policy, Some(policy("sub", "alice"))); - - let retrieved = retrieve_one(&src, retrieve_payload(&dk), None) - .await - .unwrap(); - assert_eq!(dk.key.key(), retrieved.key()); - } - - #[tokio::test] - async fn an_unresolved_condition_the_caller_cannot_satisfy_fails_generation() { - let src = FakeDataKeySource::new().as_caller([("workspace", "ws-1")]); - let result = src - .generate_keys( - vec![GenerateKeyPayload::new("d", Cow::Owned(vec![])) - .with_decryption_policy(unresolved_policy("sub"))], - None, - None, - ) - .await; - - assert!( - matches!( - result, - Err(Error::GenerateKey(GenerateKeyError::RequestFailed(_))) - ), - "a policy on a claim the caller lacks must fail generation, got: {result:?}" - ); - } - - #[tokio::test] - async fn an_unresolved_policy_is_rejected_at_retrieval() { - // The client must store and send back the resolved policy from - // generation; sending the unresolved form (which the tag would - // *not* bind either way) is rejected up-front as ZeroKMS does. - let src = alice(); - let dk = generate_one( - &src, - GenerateKeyPayload::new("d", Cow::Owned(vec![])) - .with_decryption_policy(unresolved_policy("sub")), - None, - ) - .await; - - let result = retrieve_one( - &src, - RetrieveKeyPayload::new(dk.key.iv, "d", &dk.tag) - .with_decryption_policy(unresolved_policy("sub")), - None, - ) - .await; - assert_rejected(result, "unresolved policy at retrieval"); - } - - #[tokio::test] - async fn a_different_caller_is_denied_the_key() { - let dk = generate_one( - &alice(), - GenerateKeyPayload::new("d", Cow::Owned(vec![])) - .with_decryption_policy(unresolved_policy("sub")), - None, - ) - .await; - - let mallory = FakeDataKeySource::new().as_caller([("sub", "mallory")]); - assert_rejected( - retrieve_one(&mallory, retrieve_payload(&dk), None).await, - "mallory retrieving alice's key", - ); - - let anonymous = FakeDataKeySource::new(); - assert_rejected( - retrieve_one(&anonymous, retrieve_payload(&dk), None).await, - "a caller with no claims retrieving alice's key", - ); - } - - #[tokio::test] - async fn any_one_condition_satisfies_a_policy() { - // Flat OR, as `ResolvedDecryptionPolicy::verify`. - let p = DecryptionPolicy { - conditions: vec![ - PolicyCondition { - claim: "sub".into(), - value: Some("alice".into()), - }, - PolicyCondition { - claim: "sub".into(), - value: Some("bob".into()), - }, - ], - }; - let dk = generate_one( - &alice(), - GenerateKeyPayload::new("d", Cow::Owned(vec![])).with_decryption_policy(p), - None, - ) - .await; - - let bob = FakeDataKeySource::new().as_caller([("sub", "bob")]); - let retrieved = retrieve_one(&bob, retrieve_payload(&dk), None) - .await - .unwrap(); - assert_eq!(dk.key.key(), retrieved.key()); - } + assert_not_found( + retrieve_one(&src, RetrieveKeyPayload::new(other_iv, "d", &dk.tag)).await, + "wrong iv", + ); - #[tokio::test] - async fn keys_without_a_policy_need_no_caller() { - let src = FakeDataKeySource::new(); - let dk = - generate_one(&src, GenerateKeyPayload::new("d", Cow::Owned(vec![])), None).await; - let retrieved = retrieve_one( + assert_not_found( + retrieve_one( &FakeDataKeySource::new(), RetrieveKeyPayload::new(dk.key.iv, "d", &dk.tag), - None, ) - .await - .unwrap(); - assert_eq!(dk.key.key(), retrieved.key()); - } + .await, + "a different stub instance", + ); } #[tokio::test] - async fn a_batch_with_one_bad_tag_fails_as_a_whole() { + async fn a_batch_with_one_unknown_key_fails_as_a_whole() { let src = FakeDataKeySource::new(); - let dk = generate_one(&src, GenerateKeyPayload::new("d", Cow::Owned(vec![])), None).await; + let dk = generate_one(&src, "d").await; let result = src .retrieve_keys( @@ -884,12 +332,52 @@ mod tests { result, Err(Error::RetrieveKey(RetrieveKeyError::FailedRetrieval(_))) ), - "one bad tag must fail the batch" + "one unknown key must fail the batch" ); } + #[tokio::test] + async fn the_policy_is_echoed_back_and_nothing_else_is_interpreted() { + // Pins the documented non-contract: descriptor, context, keyset and + // policy are not consulted on retrieval. If this test starts failing + // because someone made the stub "smarter", read the type's docs first. + let src = FakeDataKeySource::new(); + let policy = DecryptionPolicy { + conditions: vec![PolicyCondition { + claim: "sub".into(), + value: None, + }], + }; + let dk = src + .generate_keys( + vec![GenerateKeyPayload::new("d", Cow::Owned(vec![])) + .with_decryption_policy(policy.clone())], + Some(Uuid::new_v4()), + None, + ) + .await + .unwrap() + .remove(0); + assert_eq!(dk.decryption_policy, Some(policy)); + + let retrieved = src + .retrieve_keys( + vec![RetrieveKeyPayload::new( + dk.key.iv, + "something-else", + &dk.tag, + )], + Some(Uuid::new_v4()), + None, + ) + .await + .unwrap() + .remove(0); + assert_eq!(dk.key.key(), retrieved.key()); + } + #[test] - fn the_fake_is_send_and_sync() { + fn the_stub_is_send_and_sync() { fn assert_send_sync() {} assert_send_sync::(); } From 6a0a9bf5efbfa9a7504bdf9e70d6e033c3f18b10 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 5 Jun 2026 04:24:57 +0000 Subject: [PATCH 408/686] feat(stack-encrypt): seal/open vitaminc values against lazily-fetched ZeroKMS data keys MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the fixed-key DataKeyCipher with ZeroKmsCipher, which mirrors vitaminc_encrypt::Aes256Cipher but sources a fresh ZeroKMS data key per leaf instead of a single static key. Driving the vitaminc Cipher trait builds a PendingCipherText tree with no I/O; one batched generate_keys call then seals every leaf into a ZeroKmsCipherText (the AesCipherText analog). Decryption batches one retrieve_keys call, opens every leaf, then drives the value's Decrypt impl through a synchronous in-memory Decipher over the recovered plaintext, so the visitor pattern and arbitrary nested Vec/HashMap/Option/Protected values round-trip. The async ZeroKMS calls live in inherent seal()/decrypt() methods, keeping the Cipher/Decipher trait impls synchronous. Wire format per leaf: empty descriptor, AES-256-GCM-SIV nonce = iv[..12], and AEAD AAD = PAE(caller_aad, tag). PAE (length-prefixed) encoding is injective, so distinct (caller_aad, tag) pairs cannot collide; the tag is always bound, tying the ciphertext to its ZeroKMS data key (key binding). This is a fresh framing, intentionally not byte-compatible with cipherstash-client's EncryptedRecord (raw descriptor || tag) — a compatibility module can be added later for migration. The vitaminc-aead/-protected deps are pinned to the PR cipherstash/cipherstash-suite#190 branch (revised Cipher/Decipher traits + ContextTag), scoped to this crate so the rest of the suite stays on the published 0.2.0-pre. --- packages/stack-encrypt/Cargo.toml | 34 ++ packages/stack-encrypt/LICENSE | 96 +++ packages/stack-encrypt/src/cipher.rs | 676 ++++++++++++++++++++++ packages/stack-encrypt/src/lib.rs | 38 ++ packages/stack-encrypt/tests/roundtrip.rs | 169 ++++++ 5 files changed, 1013 insertions(+) create mode 100644 packages/stack-encrypt/Cargo.toml create mode 100644 packages/stack-encrypt/LICENSE create mode 100644 packages/stack-encrypt/src/cipher.rs create mode 100644 packages/stack-encrypt/src/lib.rs create mode 100644 packages/stack-encrypt/tests/roundtrip.rs diff --git a/packages/stack-encrypt/Cargo.toml b/packages/stack-encrypt/Cargo.toml new file mode 100644 index 000000000..f2c631eb8 --- /dev/null +++ b/packages/stack-encrypt/Cargo.toml @@ -0,0 +1,34 @@ +[package] +name = "stack-encrypt" +description = "AES-256-GCM-SIV cipher implementing the vitaminc Cipher traits, keyed by ZeroKMS data keys" +version = "0.1.0" +edition.workspace = true +authors.workspace = true +repository.workspace = true +homepage.workspace = true +keywords.workspace = true +categories.workspace = true +license-file = "LICENSE" + +[dependencies] +stack-kms = { path = "../stack-kms" } + +# Scoped to the vitaminc PR #190 branch (re-introduced ContextTag on the revised +# Cipher/Decipher traits, stacked on the new Decipher trait + zeroize-on-drop +# work). Pinned here rather than via the workspace dep so the rest of the suite +# stays on the published 0.2.0-pre until the vitaminc PRs land. +vitaminc-aead = { git = "https://github.com/cipherstash/vitaminc", branch = "claude/vitaminc-aead-issue-178-vCypS" } +vitaminc-protected = { git = "https://github.com/cipherstash/vitaminc", branch = "claude/vitaminc-aead-issue-178-vCypS" } +zeroize = { workspace = true } +thiserror = { workspace = true } +uuid = { workspace = true } + +aes-gcm-siv = "0.11.1" + +[dev-dependencies] +stack-kms = { path = "../stack-kms", features = ["test-support"] } +tokio = { workspace = true, features = ["rt", "macros"] } +vitaminc-protected = { git = "https://github.com/cipherstash/vitaminc", branch = "claude/vitaminc-aead-issue-178-vCypS" } + +[package.metadata.docs.rs] +all-features = true diff --git a/packages/stack-encrypt/LICENSE b/packages/stack-encrypt/LICENSE new file mode 100644 index 000000000..2cbd67a66 --- /dev/null +++ b/packages/stack-encrypt/LICENSE @@ -0,0 +1,96 @@ +# PolyForm Internal Use License 1.0.0 + + + +## Acceptance + +In order to get any license under these terms, you must agree +to them as both strict obligations and conditions to all +your licenses. + +## Copyright License + +The licensor grants you a copyright license for the software +to do everything you might do with the software that would +otherwise infringe the licensor's copyright in it for any +permitted purpose. However, you may only make changes or +new works based on the software according to [Changes and New +Works License](#changes-and-new-works-license), and you may +not distribute the software. + +## Changes and New Works License + +The licensor grants you an additional copyright license to +make changes and new works based on the software for any +permitted purpose. + +## Patent License + +The licensor grants you a patent license for the software that +covers patent claims the licensor can license, or becomes able +to license, that you would infringe by using the software. + +## Fair Use + +You may have "fair use" rights for the software under the +law. These terms do not limit them. + +## Internal Business Use + +Use of the software for the internal business operations of +you and your company is use for a permitted purpose. + +## No Other Rights + +These terms do not allow you to sublicense or transfer any of +your licenses to anyone else, or prevent the licensor from +granting licenses to anyone else. These terms do not imply +any other licenses. + +## Patent Defense + +If you make any written claim that the software infringes or +contributes to infringement of any patent, your patent license +for the software granted under these terms ends immediately. If +your company makes such a claim, your patent license ends +immediately for work on behalf of your company. + +## Violations + +The first time you are notified in writing that you have +violated any of these terms, or done anything with the software +not covered by your licenses, your licenses can nonetheless +continue if you come into full compliance with these terms, +and take practical steps to correct past violations, within +32 days of receiving notice. Otherwise, all your licenses +end immediately. + +## No Liability + +***As far as the law allows, the software comes as is, without +any warranty or condition, and the licensor will not be liable +to you for any damages arising out of these terms or the use +or nature of the software, under any kind of legal claim.*** + +## Definitions + +The **licensor** is the individual or entity offering these +terms, and the **software** is the software the licensor makes +available under these terms. + +**You** refers to the individual or entity agreeing to these +terms. + +**Your company** is any legal entity, sole proprietorship, +or other kind of organization that you work for, plus all +organizations that have control over, are under the control of, +or are under common control with that organization. **Control** +means ownership of substantially all the assets of an entity, +or the power to direct its management and policies by vote, +contract, or otherwise. Control can be direct or indirect. + +**Your licenses** are all the licenses granted to you for the +software under these terms. + +**Use** means anything you do with the software requiring one +of your licenses. diff --git a/packages/stack-encrypt/src/cipher.rs b/packages/stack-encrypt/src/cipher.rs new file mode 100644 index 000000000..c891817bf --- /dev/null +++ b/packages/stack-encrypt/src/cipher.rs @@ -0,0 +1,676 @@ +//! A vitaminc [`Cipher`]/[`Decipher`] keyed by ZeroKMS data keys, fetched lazily. +//! +//! [`ZeroKmsCipher`] mirrors the structure of `vitaminc_encrypt::Aes256Cipher`: +//! encrypting an [`Encrypt`] value produces a recursive ciphertext tree +//! ([`ZeroKmsCipherText`], the analog of `AesCipherText`). The difference is +//! *where the key comes from*: rather than a single fixed key, every leaf is +//! sealed under its own ZeroKMS data key. +//! +//! Because data-key generation/retrieval is an async ZeroKMS round-trip, the +//! crypto cannot happen inside the synchronous [`Cipher`]/[`Decipher`] trait +//! methods. Instead: +//! +//! * **Encrypt** — driving the [`Cipher`] trait builds a *pending* tree +//! ([`PendingCipherText`]) that holds plaintext but does no I/O. A single +//! [`PendingCipherText::seal`] (or the [`ZeroKmsCipher::encrypt`] convenience) +//! then batches **one** `generate_keys` call for the whole tree and seals +//! every leaf. +//! * **Decrypt** — [`ZeroKmsCipher::decrypt`] batches **one** `retrieve_keys` +//! call, decrypts every leaf, then drives the value's [`Decrypt`] impl through +//! a synchronous in-memory [`Decipher`] over the recovered plaintext — so the +//! visitor pattern (and arbitrary nested `Vec`/`HashMap`/`Option`/`Protected` +//! values) works exactly as it does for `Aes256Cipher`. +//! +//! ## Wire format +//! +//! Each leaf stores the ZeroKMS `iv` and key `tag`. The AES-256-GCM-SIV nonce is +//! the first 12 bytes of the `iv`, and the AEAD AAD is the PAE-encoded tuple +//! `(caller_aad, tag)`. PAE (length-prefixed) encoding is injective, so distinct +//! `(caller_aad, tag)` pairs can never collide on the same AAD bytes. The `tag` +//! is always bound, so the ciphertext is cryptographically tied to its ZeroKMS +//! data key (key binding); a caller AAD (e.g. a +//! [`ContextTag`](vitaminc_aead::ContextTag)) adds a further binding layer. +//! +//! This is a fresh framing and is intentionally **not** byte-compatible with +//! `cipherstash-client`'s `EncryptedRecord` AAD (a raw `descriptor || tag` +//! concatenation). A compatibility module can be added later to read existing +//! records as customers of `cipherstash-client` migrate. + +use std::any::Any; +use std::borrow::Cow; + +use aes_gcm_siv::aead::AeadInPlace; +use aes_gcm_siv::{Aes256GcmSiv, KeyInit, Nonce as GcmNonce}; +use stack_kms::{DataKey, DataKeySource, DataKeyWithTag, GenerateKeyPayload, RetrieveKeyPayload}; +use uuid::Uuid; +use vitaminc_aead::{ + Cipher, Decipher, DecipherVisitor, Decrypt, Encrypt, IntoAad, MapAccess, MapCipher, SeqAccess, + SeqCipher, Unspecified, +}; +use vitaminc_protected::{Controlled, Protected}; + +/// AES-256-GCM-SIV nonce length in bytes (the leading bytes of the ZeroKMS IV). +const NONCE_LEN: usize = 12; + +/// Errors from sealing or opening a [`ZeroKmsCipherText`]. +#[derive(Debug, thiserror::Error)] +pub enum Error { + /// A ZeroKMS data-key generate/retrieve call failed. + #[error("ZeroKMS data-key operation failed: {0}")] + Kms(#[from] stack_kms::Error), + /// AEAD sealing/opening failed, or the ciphertext shape did not match the + /// requested type. On decrypt this is the expected outcome for a wrong key, + /// wrong AAD, or tampered ciphertext. + #[error("AEAD operation failed (wrong key, AAD mismatch, or malformed ciphertext)")] + Aead, + /// ZeroKMS returned a different number of keys than were requested. + #[error("expected {expected} data keys from ZeroKMS but received {received}")] + KeyCountMismatch { expected: usize, received: usize }, +} + +impl From for Error { + fn from(_: Unspecified) -> Self { + Error::Aead + } +} + +/// A vitaminc cipher whose per-leaf keys are ZeroKMS data keys, sourced through +/// a [`DataKeySource`] (production: `stack_kms::StackKms`; tests: +/// `stack_kms::FakeDataKeySource`). +pub struct ZeroKmsCipher { + kms: K, + keyset_id: Option, +} + +impl ZeroKmsCipher { + /// Create a cipher over the given data-key source, using the source's + /// default keyset. + pub fn new(kms: K) -> Self { + Self { + kms, + keyset_id: None, + } + } + + /// Pin generate/retrieve operations to a specific ZeroKMS keyset. + pub fn with_keyset_id(mut self, keyset_id: Uuid) -> Self { + self.keyset_id = Some(keyset_id); + self + } +} + +impl ZeroKmsCipher { + /// Encrypt a value, binding `aad`, and seal it against fresh ZeroKMS data + /// keys in a single batched `generate_keys` call. + pub async fn encrypt<'a, T, A>(&self, value: T, aad: A) -> Result + where + T: Encrypt, + A: IntoAad<'a>, + { + let pending = value.encrypt_with_aad(self, aad)?; + pending.seal(self).await + } + + /// Decrypt a [`ZeroKmsCipherText`] into `T`, authenticating against `aad`. + /// Retrieves every leaf's data key in a single batched `retrieve_keys` call, + /// then drives `T`'s [`Decrypt`] impl over the recovered plaintext. + pub async fn decrypt<'a, T, A>(&self, ciphertext: ZeroKmsCipherText, aad: A) -> Result + where + T: Decrypt<'static> + 'static, + A: IntoAad<'a>, + { + let caller_aad = aad.into_aad().as_bytes().to_vec(); + + // Collect every leaf's retrieve payload (borrowing the ciphertext), make + // one batched call, then drop the borrow before consuming the tree. + let keys = { + let mut payloads = Vec::new(); + ciphertext.collect_retrieve_payloads(&mut payloads); + if payloads.is_empty() { + Vec::new() + } else { + self.kms + .retrieve_keys(payloads, self.keyset_id, None) + .await? + } + }; + + let mut keys = keys.into_iter(); + let plaintext = decrypt_tree(ciphertext, &mut keys, &caller_aad)?; + + // The plaintext is fully recovered; the structural decode is synchronous + // and ignores AAD (already authenticated above). + T::decrypt_with_aad(PlaintextDecipher { tree: plaintext }, ()).map_err(Error::from) + } +} + +// ============================================================================= +// Sealed ciphertext tree (the `AesCipherText` analog) +// ============================================================================= + +/// The recursive ciphertext container produced by [`ZeroKmsCipher`]. Its shape +/// mirrors the encrypted plaintext: a scalar yields [`Single`](Self::Single), a +/// `Vec` yields [`Sequence`](Self::Sequence), a `HashMap` or struct yields +/// [`Map`](Self::Map). +pub enum ZeroKmsCipherText { + /// A single sealed value. + Single(DataKeyCipherText), + /// A sequence of ciphertexts (from a `Vec`-shaped plaintext). + Sequence(Vec), + /// A map of (cleartext key, ciphertext value) pairs. Keys are not encrypted. + Map(Vec<(String, ZeroKmsCipherText)>), + /// An authenticated absent marker from [`Cipher::encrypt_none`]. + None(DataKeyCipherText), + /// A typed value passed through unencrypted via [`Cipher::passthrough`]. + Passthrough(Box), +} + +/// A single leaf: the ZeroKMS metadata needed to re-derive the key plus the +/// AES-256-GCM-SIV ciphertext (`ciphertext || gcm_tag`). +#[derive(Debug, Clone)] +pub struct DataKeyCipherText { + /// ZeroKMS IV. Its first [`NONCE_LEN`] bytes are the AEAD nonce, and the + /// full IV is needed to retrieve the data key. + iv: stack_kms::Iv, + /// ZeroKMS key tag: required to retrieve the key and used as the AEAD AAD. + tag: Vec, + /// AES-256-GCM-SIV output: ciphertext with the 16-byte auth tag appended. + ciphertext: Vec, +} + +impl ZeroKmsCipherText { + /// Walk the tree in depth-first order, pushing one retrieve payload per + /// keyed leaf. Must match [`decrypt_tree`]'s traversal so payloads and + /// returned keys line up. + fn collect_retrieve_payloads<'b>(&'b self, out: &mut Vec>) { + match self { + ZeroKmsCipherText::Single(leaf) | ZeroKmsCipherText::None(leaf) => { + // Empty descriptor — see the module-level wire-format note. + out.push(RetrieveKeyPayload::new(leaf.iv, "", &leaf.tag)); + } + ZeroKmsCipherText::Sequence(items) => { + for item in items { + item.collect_retrieve_payloads(out); + } + } + ZeroKmsCipherText::Map(entries) => { + for (_, value) in entries { + value.collect_retrieve_payloads(out); + } + } + ZeroKmsCipherText::Passthrough(_) => {} + } + } +} + +// ============================================================================= +// Pending tree (`Cipher::Ok`) — built synchronously, sealed asynchronously +// ============================================================================= + +/// The intermediate result of driving the [`Cipher`] trait: a tree that holds +/// plaintext (and the AAD bound to each leaf) but has done no ZeroKMS I/O. +/// [`seal`](Self::seal) turns it into a [`ZeroKmsCipherText`]. +pub enum PendingCipherText { + /// A scalar awaiting a data key, with its bound caller AAD. + Single { + plaintext: Protected>, + aad: Vec, + }, + /// A pending sequence. + Sequence(Vec), + /// A pending map. + Map(Vec<(String, PendingCipherText)>), + /// A pending authenticated-absent marker, with its bound caller AAD. + None { aad: Vec }, + /// A passthrough value (needs no key). + Passthrough(Box), +} + +impl PendingCipherText { + /// Number of leaves that need a ZeroKMS data key (everything but passthrough). + fn key_count(&self) -> usize { + match self { + PendingCipherText::Single { .. } | PendingCipherText::None { .. } => 1, + PendingCipherText::Sequence(items) => items.iter().map(Self::key_count).sum(), + PendingCipherText::Map(entries) => entries.iter().map(|(_, v)| v.key_count()).sum(), + PendingCipherText::Passthrough(_) => 0, + } + } + + /// Generate one data key per keyed leaf (one batched ZeroKMS call) and seal + /// the whole tree. + pub async fn seal( + self, + cipher: &ZeroKmsCipher, + ) -> Result { + let count = self.key_count(); + if count == 0 { + // Passthrough-only tree: no keys, no ZeroKMS call. + return Ok(self.seal_with(&mut std::iter::empty())?); + } + + // Empty descriptor + empty context for every leaf (see wire-format note). + let payloads: Vec> = (0..count) + .map(|_| GenerateKeyPayload::new("", Cow::Owned(Vec::new()))) + .collect(); + + let keys = cipher + .kms + .generate_keys(payloads, cipher.keyset_id, None) + .await?; + + if keys.len() != count { + return Err(Error::KeyCountMismatch { + expected: count, + received: keys.len(), + }); + } + + let mut keys = keys.into_iter(); + Ok(self.seal_with(&mut keys)?) + } + + /// Recursively seal, drawing one key per leaf from `keys` in traversal order. + fn seal_with( + self, + keys: &mut impl Iterator, + ) -> Result { + match self { + PendingCipherText::Single { plaintext, aad } => { + let key = keys.next().ok_or(Unspecified)?; + Ok(ZeroKmsCipherText::Single(seal_leaf(plaintext, &aad, key)?)) + } + PendingCipherText::None { aad } => { + let key = keys.next().ok_or(Unspecified)?; + // Seal an empty plaintext so the tag binds the AAD, mirroring + // `Aes256Cipher::encrypt_none`. + Ok(ZeroKmsCipherText::None(seal_leaf( + Protected::new(Vec::new()), + &aad, + key, + )?)) + } + PendingCipherText::Sequence(items) => { + let mut out = Vec::with_capacity(items.len()); + for item in items { + out.push(item.seal_with(keys)?); + } + Ok(ZeroKmsCipherText::Sequence(out)) + } + PendingCipherText::Map(entries) => { + let mut out = Vec::with_capacity(entries.len()); + for (k, v) in entries { + out.push((k, v.seal_with(keys)?)); + } + Ok(ZeroKmsCipherText::Map(out)) + } + PendingCipherText::Passthrough(value) => Ok(ZeroKmsCipherText::Passthrough(value)), + } + } +} + +// ============================================================================= +// Leaf crypto +// ============================================================================= + +/// Compose the AEAD AAD as the PAE-encoded tuple `(caller_aad, tag)`. +/// +/// PAE (length-prefixed) encoding is injective, so distinct `(caller_aad, tag)` +/// pairs can never collide on the same AAD bytes — unlike a raw concatenation, +/// which is only unambiguous when the tag has a fixed length. `tag` is always +/// bound, so the leaf is cryptographically tied to its ZeroKMS data key. +fn leaf_aad(caller_aad: &[u8], tag: &[u8]) -> Vec { + (caller_aad, tag).into_aad().as_bytes().to_vec() +} + +/// Seal one plaintext leaf under a freshly generated data key. +fn seal_leaf( + plaintext: Protected>, + caller_aad: &[u8], + key: DataKeyWithTag, +) -> Result { + let iv = key.key.iv; + let aead = Aes256GcmSiv::new_from_slice(key.key.key()).map_err(|_| Unspecified)?; + let nonce = GcmNonce::from_slice(&iv[..NONCE_LEN]); + let aad = leaf_aad(caller_aad, &key.tag); + + // The plaintext bytes are overwritten in place by the ciphertext. + let mut buf = plaintext.risky_unwrap(); + aead.encrypt_in_place(nonce, &aad, &mut buf) + .map_err(|_| Unspecified)?; + + Ok(DataKeyCipherText { + iv, + tag: key.tag, + ciphertext: buf, + }) +} + +/// Open one leaf with its retrieved data key, returning the plaintext bytes. +fn open_leaf( + leaf: DataKeyCipherText, + key: &DataKey, + caller_aad: &[u8], +) -> Result, Unspecified> { + let aead = Aes256GcmSiv::new_from_slice(key.key()).map_err(|_| Unspecified)?; + let nonce = GcmNonce::from_slice(&leaf.iv[..NONCE_LEN]); + let aad = leaf_aad(caller_aad, &leaf.tag); + + let mut buf = leaf.ciphertext; + aead.decrypt_in_place(nonce, &aad, &mut buf) + .map_err(|_| Unspecified)?; + Ok(buf) +} + +/// Recursively open every leaf into a plaintext tree, drawing one key per leaf +/// from `keys` in the same order [`ZeroKmsCipherText::collect_retrieve_payloads`] +/// produced them. `caller_aad` is applied uniformly to every leaf (matching how +/// the built-in `Encrypt` container impls thread a single AAD to every element). +fn decrypt_tree( + ciphertext: ZeroKmsCipherText, + keys: &mut impl Iterator, + caller_aad: &[u8], +) -> Result { + match ciphertext { + ZeroKmsCipherText::Single(leaf) => { + let key = keys.next().ok_or(Unspecified)?; + Ok(PlaintextTree::Single(open_leaf(leaf, &key, caller_aad)?)) + } + ZeroKmsCipherText::None(leaf) => { + let key = keys.next().ok_or(Unspecified)?; + // Authenticate the absent marker (verifies the tag) and discard. + open_leaf(leaf, &key, caller_aad)?; + Ok(PlaintextTree::None) + } + ZeroKmsCipherText::Sequence(items) => { + let mut out = Vec::with_capacity(items.len()); + for item in items { + out.push(decrypt_tree(item, keys, caller_aad)?); + } + Ok(PlaintextTree::Sequence(out)) + } + ZeroKmsCipherText::Map(entries) => { + let mut out = Vec::with_capacity(entries.len()); + for (k, v) in entries { + out.push((k, decrypt_tree(v, keys, caller_aad)?)); + } + Ok(PlaintextTree::Map(out)) + } + ZeroKmsCipherText::Passthrough(value) => Ok(PlaintextTree::Passthrough(value)), + } +} + +// ============================================================================= +// Encrypt side: `Cipher` impl over a `&ZeroKmsCipher` (builds the pending tree) +// ============================================================================= + +impl<'c, K> Cipher for &'c ZeroKmsCipher { + type Ok = PendingCipherText; + type Error = Unspecified; + type SeqCipher = PendingSeqCipher<'c, K>; + type MapCipher = PendingMapCipher<'c, K>; + + fn encrypt_bytes_vec<'a, A>( + self, + data: Protected>, + aad: A, + ) -> Result + where + A: IntoAad<'a>, + { + Ok(PendingCipherText::Single { + plaintext: data, + aad: aad.into_aad().as_bytes().to_vec(), + }) + } + + fn encrypt_seq(self, size_hint: Option) -> Self::SeqCipher { + PendingSeqCipher { + cipher: self, + items: Vec::with_capacity(size_hint.unwrap_or(0)), + } + } + + fn encrypt_map(self) -> Self::MapCipher { + PendingMapCipher { + cipher: self, + entries: Vec::new(), + current_key: None, + } + } + + fn encrypt_none<'a, A>(self, aad: A) -> Result + where + A: IntoAad<'a>, + { + Ok(PendingCipherText::None { + aad: aad.into_aad().as_bytes().to_vec(), + }) + } + + fn passthrough(self, value: T) -> Result + where + T: Any + Send + 'static, + { + Ok(PendingCipherText::Passthrough(Box::new(value))) + } +} + +/// [`SeqCipher`] driver: accumulates a pending sub-tree per element. Holds the +/// cipher only to re-drive nested [`Encrypt`] values (no I/O happens here). +pub struct PendingSeqCipher<'c, K> { + cipher: &'c ZeroKmsCipher, + items: Vec, +} + +impl<'c, K> SeqCipher for PendingSeqCipher<'c, K> { + type Ok = PendingCipherText; + type Error = Unspecified; + + fn encrypt_next<'a, T, A>(mut self, data: T, aad: A) -> Result + where + T: Encrypt, + A: IntoAad<'a>, + { + self.items.push(data.encrypt_with_aad(self.cipher, aad)?); + Ok(self) + } + + fn passthrough_next(mut self, value: T) -> Result + where + T: Any + Send + 'static, + { + self.items + .push(PendingCipherText::Passthrough(Box::new(value))); + Ok(self) + } + + fn end(self) -> Result { + Ok(PendingCipherText::Sequence(self.items)) + } +} + +/// [`MapCipher`] driver: keys are stored in the clear; values become pending +/// sub-trees. Mirrors `AesMapCipher`'s key/value contract checks. +pub struct PendingMapCipher<'c, K> { + cipher: &'c ZeroKmsCipher, + entries: Vec<(String, PendingCipherText)>, + current_key: Option<&'static str>, +} + +impl<'c, K> MapCipher for PendingMapCipher<'c, K> { + type Ok = PendingCipherText; + type Error = Unspecified; + + fn encrypt_key(mut self, key: &'static str) -> Result { + if self.current_key.is_some() { + return Err(Unspecified); + } + self.current_key = Some(key); + Ok(self) + } + + fn encrypt_value<'a, U, A>(mut self, value: U, aad: A) -> Result + where + U: Encrypt, + A: IntoAad<'a>, + { + let key = self.current_key.take().ok_or(Unspecified)?; + let value = value.encrypt_with_aad(self.cipher, aad)?; + self.entries.push((key.to_string(), value)); + Ok(self) + } + + fn passthrough_entry(mut self, key: &'static str, value: T) -> Result + where + T: Any + Send + 'static, + { + if self.current_key.is_some() { + return Err(Unspecified); + } + self.entries.push(( + key.to_string(), + PendingCipherText::Passthrough(Box::new(value)), + )); + Ok(self) + } + + fn end(self) -> Result { + if self.current_key.is_some() { + return Err(Unspecified); + } + Ok(PendingCipherText::Map(self.entries)) + } +} + +// ============================================================================= +// Decrypt side: a synchronous `Decipher` over already-recovered plaintext +// ============================================================================= + +/// Plaintext counterpart to [`ZeroKmsCipherText`], produced by [`decrypt_tree`] +/// once every leaf has been opened. [`PlaintextDecipher`] walks it structurally. +enum PlaintextTree { + Single(Vec), + Sequence(Vec), + Map(Vec<(String, PlaintextTree)>), + None, + Passthrough(Box), +} + +/// A synchronous [`Decipher`] over a fully-decrypted [`PlaintextTree`]. It does +/// no crypto — decryption already happened in [`decrypt_tree`] — so it drives +/// the [`DecipherVisitor`] pattern purely structurally and ignores AAD. +struct PlaintextDecipher { + tree: PlaintextTree, +} + +impl<'c> Decipher<'c> for PlaintextDecipher { + type Ok + = Result + where + T: Send + 'c; + + fn map_ok(ok: Self::Ok, f: F) -> Self::Ok + where + T: Send + 'c, + U: Send + 'c, + F: FnOnce(T) -> U, + { + ok.map(f) + } + + fn decrypt_bytes<'a, V, A>(self, visitor: V, _aad: A) -> Self::Ok + where + V: DecipherVisitor<'c> + Send + 'c, + A: IntoAad<'a>, + { + match self.tree { + PlaintextTree::Single(bytes) => visitor.visit_bytes_vec(Protected::new(bytes)), + _ => Err(Unspecified), + } + } + + fn decrypt_seq<'a, V, A>(self, visitor: V, _aad: A) -> Self::Ok + where + V: DecipherVisitor<'c> + Send + 'c, + A: IntoAad<'a>, + { + match self.tree { + PlaintextTree::Sequence(items) => visitor.visit_seq(PlaintextSeqAccess { + items: items.into_iter(), + }), + _ => Err(Unspecified), + } + } + + fn decrypt_map<'a, V, A>(self, visitor: V, _aad: A) -> Self::Ok + where + V: DecipherVisitor<'c> + Send + 'c, + A: IntoAad<'a>, + { + match self.tree { + PlaintextTree::Map(entries) => visitor.visit_map(PlaintextMapAccess { + entries: entries.into_iter(), + }), + _ => Err(Unspecified), + } + } + + fn decrypt_passthrough(self) -> Self::Ok + where + T: Any + Send + 'static, + { + match self.tree { + PlaintextTree::Passthrough(boxed) => { + boxed.downcast::().map(|b| *b).map_err(|_| Unspecified) + } + _ => Err(Unspecified), + } + } + + fn decrypt_option<'a, T, A>(self, _aad: A) -> Self::Ok> + where + T: Decrypt<'c> + 'c, + A: IntoAad<'a>, + { + match self.tree { + PlaintextTree::None => Ok(None), + PlaintextTree::Passthrough(_) => Err(Unspecified), + // Any other shape is the `Some` payload — recurse into `T`. + other => T::decrypt_with_aad(PlaintextDecipher { tree: other }, ()).map(Some), + } + } +} + +struct PlaintextSeqAccess { + items: std::vec::IntoIter, +} + +impl<'c> SeqAccess<'c> for PlaintextSeqAccess { + type Error = Unspecified; + + fn next_element + 'c>(&mut self) -> Result, Self::Error> { + match self.items.next() { + Some(tree) => T::decrypt_with_aad(PlaintextDecipher { tree }, ()).map(Some), + None => Ok(None), + } + } +} + +struct PlaintextMapAccess { + entries: std::vec::IntoIter<(String, PlaintextTree)>, +} + +impl<'c> MapAccess<'c> for PlaintextMapAccess { + type Error = Unspecified; + + fn next_entry + 'c>(&mut self) -> Result, Self::Error> { + match self.entries.next() { + Some((key, tree)) => { + let value = T::decrypt_with_aad(PlaintextDecipher { tree }, ())?; + Ok(Some((key, value))) + } + None => Ok(None), + } + } +} diff --git a/packages/stack-encrypt/src/lib.rs b/packages/stack-encrypt/src/lib.rs new file mode 100644 index 000000000..3d9737370 --- /dev/null +++ b/packages/stack-encrypt/src/lib.rs @@ -0,0 +1,38 @@ +#![doc(html_favicon_url = "https://cipherstash.com/favicon.ico")] +//! `stack-encrypt` bridges ZeroKMS data keys into the vitaminc encryption +//! ecosystem. +//! +//! [`ZeroKmsCipher`] implements the vitaminc [`Cipher`](vitaminc_aead::Cipher) +//! trait and decrypts via the [`Decrypt`](vitaminc_aead::Decrypt) trait, but — +//! unlike a fixed-key cipher — every leaf of a value is sealed under its own +//! ZeroKMS data key, fetched lazily. Encrypting builds a pending tree with no +//! I/O; a single batched `generate_keys` call then seals it. Decrypting batches +//! one `retrieve_keys` call, then drives the value's `Decrypt` impl over the +//! recovered plaintext, so arbitrary nested `Vec` / `HashMap` / `Option` / +//! `Protected` values round-trip exactly as with `vitaminc_encrypt::Aes256Cipher`. +//! +//! The data keys are sourced through a [`stack_kms::DataKeySource`] +//! (production: `stack_kms::StackKms`; tests: `stack_kms::FakeDataKeySource`). +//! +//! ```no_run +//! # async fn example() -> Result<(), stack_encrypt::Error> { +//! use stack_encrypt::ZeroKmsCipher; +//! use stack_kms::FakeDataKeySource; +//! +//! let cipher = ZeroKmsCipher::new(FakeDataKeySource::new()); +//! +//! let ciphertext = cipher.encrypt("secret message".to_string(), ()).await?; +//! let plaintext: String = cipher.decrypt(ciphertext, ()).await?; +//! +//! assert_eq!(plaintext, "secret message"); +//! # Ok(()) +//! # } +//! ``` + +mod cipher; + +pub use cipher::{DataKeyCipherText, Error, PendingCipherText, ZeroKmsCipher, ZeroKmsCipherText}; + +// Re-export the vitaminc AEAD surface callers need to drive the cipher, so they +// don't have to depend on `vitaminc-aead` directly for the common path. +pub use vitaminc_aead::{Aad, Cipher, ContextTag, Decrypt, Encrypt, IntoAad, Unspecified}; diff --git a/packages/stack-encrypt/tests/roundtrip.rs b/packages/stack-encrypt/tests/roundtrip.rs new file mode 100644 index 000000000..3802e31c4 --- /dev/null +++ b/packages/stack-encrypt/tests/roundtrip.rs @@ -0,0 +1,169 @@ +//! End-to-end encrypt/decrypt tests for `ZeroKmsCipher` against the deterministic +//! `FakeDataKeySource` — no ZeroKMS credentials or network required. + +use std::collections::HashMap; + +use stack_encrypt::{ContextTag, ZeroKmsCipher}; +use stack_kms::FakeDataKeySource; +use vitaminc_protected::{Controlled, Protected}; + +fn cipher() -> ZeroKmsCipher { + ZeroKmsCipher::new(FakeDataKeySource::new()) +} + +#[tokio::test] +async fn scalar_roundtrips_with_no_aad() { + let cipher = cipher(); + let ct = cipher + .encrypt("hello world".to_string(), ()) + .await + .expect("encrypt"); + let pt: String = cipher.decrypt(ct, ()).await.expect("decrypt"); + assert_eq!(pt, "hello world"); +} + +#[tokio::test] +async fn scalar_roundtrips_with_matching_aad() { + let cipher = cipher(); + let aad = b"public-context".as_slice(); + let ct = cipher + .encrypt("secret".to_string(), aad) + .await + .expect("encrypt"); + let pt: String = cipher.decrypt(ct, aad).await.expect("decrypt"); + assert_eq!(pt, "secret"); +} + +#[tokio::test] +async fn decrypt_fails_with_wrong_aad() { + let cipher = cipher(); + let ct = cipher + .encrypt("secret".to_string(), b"aad-a".as_slice()) + .await + .expect("encrypt"); + let result: Result = cipher.decrypt(ct, b"aad-b".as_slice()).await; + assert!(result.is_err(), "mismatched AAD must not decrypt"); +} + +#[tokio::test] +async fn decrypt_fails_when_aad_omitted() { + let cipher = cipher(); + let ct = cipher + .encrypt("secret".to_string(), b"bound".as_slice()) + .await + .expect("encrypt"); + // The leaf bound `aad || tag`; dropping the caller AAD changes the bytes. + let result: Result = cipher.decrypt(ct, ()).await; + assert!(result.is_err(), "omitting the bound AAD must not decrypt"); +} + +#[tokio::test] +async fn vec_roundtrips() { + let cipher = cipher(); + let items = vec!["a".to_string(), "b".to_string(), "c".to_string()]; + let ct = cipher.encrypt(items.clone(), ()).await.expect("encrypt"); + let pt: Vec = cipher.decrypt(ct, ()).await.expect("decrypt"); + assert_eq!(pt, items); +} + +#[tokio::test] +async fn map_roundtrips() { + let cipher = cipher(); + // Encrypt side keys are `&'static str`; decrypt side yields `String` keys. + let mut input: HashMap<&'static str, String> = HashMap::new(); + input.insert("name", "alice".to_string()); + input.insert("role", "admin".to_string()); + + let ct = cipher.encrypt(input, ()).await.expect("encrypt"); + let pt: HashMap = cipher.decrypt(ct, ()).await.expect("decrypt"); + + assert_eq!(pt.get("name"), Some(&"alice".to_string())); + assert_eq!(pt.get("role"), Some(&"admin".to_string())); + assert_eq!(pt.len(), 2); +} + +#[tokio::test] +async fn option_some_roundtrips() { + let cipher = cipher(); + let ct = cipher + .encrypt(Some("present".to_string()), ()) + .await + .expect("encrypt"); + let pt: Option = cipher.decrypt(ct, ()).await.expect("decrypt"); + assert_eq!(pt, Some("present".to_string())); +} + +#[tokio::test] +async fn option_none_roundtrips() { + let cipher = cipher(); + let ct = cipher + .encrypt(Option::::None, ()) + .await + .expect("encrypt"); + let pt: Option = cipher.decrypt(ct, ()).await.expect("decrypt"); + assert_eq!(pt, None); +} + +#[tokio::test] +async fn protected_roundtrip() { + // Exercises the `Protected` Decrypt impl, the sole user of `Decipher::map_ok`. + // (`Vec` would encrypt element-wise as a sequence of `u8`, not as bytes, + // so a string leaf is used here.) + let cipher = cipher(); + let secret = Protected::new("classified".to_string()); + let ct = cipher.encrypt(secret, ()).await.expect("encrypt"); + let pt: Protected = cipher.decrypt(ct, ()).await.expect("decrypt"); + assert_eq!(pt.risky_unwrap(), "classified".to_string()); +} + +#[tokio::test] +async fn nested_vec_roundtrips() { + let cipher = cipher(); + let nested = vec![ + vec!["a".to_string(), "b".to_string()], + vec!["c".to_string()], + ]; + let ct = cipher.encrypt(nested.clone(), ()).await.expect("encrypt"); + let pt: Vec> = cipher.decrypt(ct, ()).await.expect("decrypt"); + assert_eq!(pt, nested); +} + +#[tokio::test] +async fn context_tag_binds_and_roundtrips() { + let cipher = cipher(); + let ct = cipher + .encrypt(ContextTag::new("token".to_string(), "user:42"), ()) + .await + .expect("encrypt"); + + // Matching context recovers the value. + let pt: String = cipher + .decrypt(ct, ContextTag::aad("user:42")) + .await + .expect("decrypt"); + assert_eq!(pt, "token"); +} + +#[tokio::test] +async fn context_tag_wrong_context_fails() { + let cipher = cipher(); + let ct = cipher + .encrypt(ContextTag::new("token".to_string(), "user:42"), ()) + .await + .expect("encrypt"); + + let result: Result = cipher.decrypt(ct, ContextTag::aad("user:99")).await; + assert!(result.is_err(), "wrong context tag must not decrypt"); +} + +#[tokio::test] +async fn wrong_shape_fails() { + // A scalar ciphertext must not decode as a sequence. + let cipher = cipher(); + let ct = cipher + .encrypt("scalar".to_string(), ()) + .await + .expect("encrypt"); + let result: Result, _> = cipher.decrypt(ct, ()).await; + assert!(result.is_err(), "scalar must not decode as a Vec"); +} From fe6b24bd325702603c47812a122cf29776f9e5fd Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Fri, 21 Aug 2026 16:17:35 +0930 Subject: [PATCH 409/686] feat(stack-encrypt): track vitaminc main's shape-authenticating Cipher/Decipher traits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The vitaminc PR-190 feature branch has fully merged to main, so the git pin moves to `branch = "main"` (crates.io is still 200+ commits behind). Port `ZeroKmsCipher` to the revised traits and adopt the same AAD discipline as `Aes256Cipher`: - AAD is captured once at `encrypt_seq`/`encrypt_map`; elements seal under `for_sequence_element`, map values under `for_map_entry` of their cleartext key, absent markers under `for_none`, and empty containers under `for_empty_sequence`/`for_empty_map` — each marker sealing an enforced-empty plaintext. - The decrypt side re-derives those per-node AADs in `decrypt_tree` (leaf crypto happens before the structural decode), and rejects duplicate map keys and all-passthrough containers, mirroring the encrypt-side contract checks. - `ZeroKmsCipherText` is now vitaminc's generic `CipherText` container over `DataKeyCipherText` leaves; typed passthrough + `decrypt_any` implemented; security lint block added to match stack-kms. New tests: empty Vec/HashMap round-trips, empty-marker AAD binding, map-key rename rejection, and sequence-element re-homing rejection. Claude-Session: https://claude.ai/code/session_01T5iYiJc6xwzMcHDtMwCadG --- packages/stack-encrypt/Cargo.toml | 14 +- packages/stack-encrypt/src/cipher.rs | 483 +++++++++++++++------- packages/stack-encrypt/src/lib.rs | 20 +- packages/stack-encrypt/tests/roundtrip.rs | 80 +++- 4 files changed, 447 insertions(+), 150 deletions(-) diff --git a/packages/stack-encrypt/Cargo.toml b/packages/stack-encrypt/Cargo.toml index f2c631eb8..6be8c0e3d 100644 --- a/packages/stack-encrypt/Cargo.toml +++ b/packages/stack-encrypt/Cargo.toml @@ -13,12 +13,12 @@ license-file = "LICENSE" [dependencies] stack-kms = { path = "../stack-kms" } -# Scoped to the vitaminc PR #190 branch (re-introduced ContextTag on the revised -# Cipher/Decipher traits, stacked on the new Decipher trait + zeroize-on-drop -# work). Pinned here rather than via the workspace dep so the rest of the suite -# stays on the published 0.2.0-pre until the vitaminc PRs land. -vitaminc-aead = { git = "https://github.com/cipherstash/vitaminc", branch = "claude/vitaminc-aead-issue-178-vCypS" } -vitaminc-protected = { git = "https://github.com/cipherstash/vitaminc", branch = "claude/vitaminc-aead-issue-178-vCypS" } +# The revised Cipher/Decipher traits, ContextTag, and the PRF crates have all +# merged to vitaminc main but are not yet published (crates.io is 200+ commits +# behind). Pinned here rather than via the workspace dep so the rest of the +# suite stays on the published 0.2.0-pre until the next vitaminc release. +vitaminc-aead = { git = "https://github.com/cipherstash/vitaminc", branch = "main" } +vitaminc-protected = { git = "https://github.com/cipherstash/vitaminc", branch = "main" } zeroize = { workspace = true } thiserror = { workspace = true } uuid = { workspace = true } @@ -28,7 +28,7 @@ aes-gcm-siv = "0.11.1" [dev-dependencies] stack-kms = { path = "../stack-kms", features = ["test-support"] } tokio = { workspace = true, features = ["rt", "macros"] } -vitaminc-protected = { git = "https://github.com/cipherstash/vitaminc", branch = "claude/vitaminc-aead-issue-178-vCypS" } +vitaminc-protected = { git = "https://github.com/cipherstash/vitaminc", branch = "main" } [package.metadata.docs.rs] all-features = true diff --git a/packages/stack-encrypt/src/cipher.rs b/packages/stack-encrypt/src/cipher.rs index c891817bf..1a2efe320 100644 --- a/packages/stack-encrypt/src/cipher.rs +++ b/packages/stack-encrypt/src/cipher.rs @@ -21,14 +21,30 @@ //! visitor pattern (and arbitrary nested `Vec`/`HashMap`/`Option`/`Protected` //! values) works exactly as it does for `Aes256Cipher`. //! +//! ## AAD derivation +//! +//! The caller's AAD is refined per node with the same domain-separated +//! derivations `Aes256Cipher` uses, so the container *shape* is authenticated: +//! +//! * sequence elements are sealed under [`Aad::for_sequence_element`]; +//! * map values under [`Aad::for_map_entry`] of their cleartext key (so +//! swapping or renaming keys fails decryption); +//! * authenticated-absent markers under [`Aad::for_none`], and empty +//! sequences/maps under [`Aad::for_empty_sequence`]/[`Aad::for_empty_map`] — +//! each sealing an *empty* plaintext, verified as empty on open. +//! +//! Because leaf decryption happens *before* the structural decode (in +//! [`decrypt_tree`], while the [`Decipher`] drive is crypto-free), the decrypt +//! side re-derives the same per-node AADs by walking the ciphertext tree. +//! //! ## Wire format //! //! Each leaf stores the ZeroKMS `iv` and key `tag`. The AES-256-GCM-SIV nonce is //! the first 12 bytes of the `iv`, and the AEAD AAD is the PAE-encoded tuple -//! `(caller_aad, tag)`. PAE (length-prefixed) encoding is injective, so distinct -//! `(caller_aad, tag)` pairs can never collide on the same AAD bytes. The `tag` -//! is always bound, so the ciphertext is cryptographically tied to its ZeroKMS -//! data key (key binding); a caller AAD (e.g. a +//! `(derived_aad, tag)`. PAE (length-prefixed) encoding is injective, so +//! distinct `(derived_aad, tag)` pairs can never collide on the same AAD bytes. +//! The `tag` is always bound, so the ciphertext is cryptographically tied to its +//! ZeroKMS data key (key binding); a caller AAD (e.g. a //! [`ContextTag`](vitaminc_aead::ContextTag)) adds a further binding layer. //! //! This is a fresh framing and is intentionally **not** byte-compatible with @@ -38,20 +54,33 @@ use std::any::Any; use std::borrow::Cow; +use std::collections::HashSet; use aes_gcm_siv::aead::AeadInPlace; use aes_gcm_siv::{Aes256GcmSiv, KeyInit, Nonce as GcmNonce}; use stack_kms::{DataKey, DataKeySource, DataKeyWithTag, GenerateKeyPayload, RetrieveKeyPayload}; use uuid::Uuid; use vitaminc_aead::{ - Cipher, Decipher, DecipherVisitor, Decrypt, Encrypt, IntoAad, MapAccess, MapCipher, SeqAccess, - SeqCipher, Unspecified, + Aad, Cipher, CipherText, Decipher, DecipherVisitor, Decrypt, Encrypt, IntoAad, MapAccess, + MapCipher, SeqAccess, SeqCipher, Unspecified, }; use vitaminc_protected::{Controlled, Protected}; /// AES-256-GCM-SIV nonce length in bytes (the leading bytes of the ZeroKMS IV). const NONCE_LEN: usize = 12; +/// The passthrough payload type: type-erased, as for Rust-native vitaminc +/// ciphers. Callers box on the way in and downcast on the way out. +pub type BoxedPassthrough = Box; + +/// The recursive ciphertext container produced by [`ZeroKmsCipher`]: vitaminc's +/// generic [`CipherText`] tree over [`DataKeyCipherText`] leaves. Its shape +/// mirrors the encrypted plaintext: a scalar yields `Single`, a `Vec` yields +/// `Sequence` (or `EmptySequence`), a `HashMap` or struct yields `Map` (or +/// `EmptyMap`). Map keys are stored in the clear but bound into their value's +/// AAD. +pub type ZeroKmsCipherText = CipherText; + /// Errors from sealing or opening a [`ZeroKmsCipherText`]. #[derive(Debug, thiserror::Error)] pub enum Error { @@ -77,6 +106,10 @@ impl From for Error { /// A vitaminc cipher whose per-leaf keys are ZeroKMS data keys, sourced through /// a [`DataKeySource`] (production: `stack_kms::StackKms`; tests: /// `stack_kms::FakeDataKeySource`). +/// +/// Per-leaf keying is deliberate: every value access requires its own data-key +/// retrieval, so individual value accesses are visible (and auditable) as +/// ZeroKMS key-retrieval events. pub struct ZeroKmsCipher { kms: K, keyset_id: Option, @@ -119,13 +152,13 @@ impl ZeroKmsCipher { T: Decrypt<'static> + 'static, A: IntoAad<'a>, { - let caller_aad = aad.into_aad().as_bytes().to_vec(); + let aad = aad.into_aad().into_owned(); // Collect every leaf's retrieve payload (borrowing the ciphertext), make // one batched call, then drop the borrow before consuming the tree. let keys = { let mut payloads = Vec::new(); - ciphertext.collect_retrieve_payloads(&mut payloads); + collect_retrieve_payloads(&ciphertext, &mut payloads); if payloads.is_empty() { Vec::new() } else { @@ -136,7 +169,12 @@ impl ZeroKmsCipher { }; let mut keys = keys.into_iter(); - let plaintext = decrypt_tree(ciphertext, &mut keys, &caller_aad)?; + let plaintext = decrypt_tree(ciphertext, &mut keys, &aad)?; + // Every key must have been consumed; leftovers mean the tree shape and + // the payload collection disagreed. + if keys.next().is_some() { + return Err(Error::Aead); + } // The plaintext is fully recovered; the structural decode is synchronous // and ignores AAD (already authenticated above). @@ -144,27 +182,6 @@ impl ZeroKmsCipher { } } -// ============================================================================= -// Sealed ciphertext tree (the `AesCipherText` analog) -// ============================================================================= - -/// The recursive ciphertext container produced by [`ZeroKmsCipher`]. Its shape -/// mirrors the encrypted plaintext: a scalar yields [`Single`](Self::Single), a -/// `Vec` yields [`Sequence`](Self::Sequence), a `HashMap` or struct yields -/// [`Map`](Self::Map). -pub enum ZeroKmsCipherText { - /// A single sealed value. - Single(DataKeyCipherText), - /// A sequence of ciphertexts (from a `Vec`-shaped plaintext). - Sequence(Vec), - /// A map of (cleartext key, ciphertext value) pairs. Keys are not encrypted. - Map(Vec<(String, ZeroKmsCipherText)>), - /// An authenticated absent marker from [`Cipher::encrypt_none`]. - None(DataKeyCipherText), - /// A typed value passed through unencrypted via [`Cipher::passthrough`]. - Passthrough(Box), -} - /// A single leaf: the ZeroKMS metadata needed to re-derive the key plus the /// AES-256-GCM-SIV ciphertext (`ciphertext || gcm_tag`). #[derive(Debug, Clone)] @@ -178,28 +195,32 @@ pub struct DataKeyCipherText { ciphertext: Vec, } -impl ZeroKmsCipherText { - /// Walk the tree in depth-first order, pushing one retrieve payload per - /// keyed leaf. Must match [`decrypt_tree`]'s traversal so payloads and - /// returned keys line up. - fn collect_retrieve_payloads<'b>(&'b self, out: &mut Vec>) { - match self { - ZeroKmsCipherText::Single(leaf) | ZeroKmsCipherText::None(leaf) => { - // Empty descriptor — see the module-level wire-format note. - out.push(RetrieveKeyPayload::new(leaf.iv, "", &leaf.tag)); - } - ZeroKmsCipherText::Sequence(items) => { - for item in items { - item.collect_retrieve_payloads(out); - } +/// Walk the tree in depth-first order, pushing one retrieve payload per keyed +/// leaf (markers included). Must match [`decrypt_tree`]'s traversal so payloads +/// and returned keys line up. +fn collect_retrieve_payloads<'b>( + ciphertext: &'b ZeroKmsCipherText, + out: &mut Vec>, +) { + match ciphertext { + CipherText::Single(leaf) + | CipherText::None(leaf) + | CipherText::EmptySequence(leaf) + | CipherText::EmptyMap(leaf) => { + // Empty descriptor — see the module-level wire-format note. + out.push(RetrieveKeyPayload::new(leaf.iv, "", &leaf.tag)); + } + CipherText::Sequence(items) => { + for item in items { + collect_retrieve_payloads(item, out); } - ZeroKmsCipherText::Map(entries) => { - for (_, value) in entries { - value.collect_retrieve_payloads(out); - } + } + CipherText::Map(entries) => { + for (_, value) in entries { + collect_retrieve_payloads(value, out); } - ZeroKmsCipherText::Passthrough(_) => {} } + CipherText::Passthrough(_) => {} } } @@ -208,29 +229,41 @@ impl ZeroKmsCipherText { // ============================================================================= /// The intermediate result of driving the [`Cipher`] trait: a tree that holds -/// plaintext (and the AAD bound to each leaf) but has done no ZeroKMS I/O. -/// [`seal`](Self::seal) turns it into a [`ZeroKmsCipherText`]. +/// plaintext (and the AAD each leaf will be sealed against, fully derived) but +/// has done no ZeroKMS I/O. [`seal`](Self::seal) turns it into a +/// [`ZeroKmsCipherText`]. pub enum PendingCipherText { - /// A scalar awaiting a data key, with its bound caller AAD. + /// A scalar awaiting a data key, with its bound (derived) AAD. Single { plaintext: Protected>, - aad: Vec, + aad: Aad<'static>, }, - /// A pending sequence. + /// A pending sequence with at least one element. Sequence(Vec), - /// A pending map. + /// A pending empty-sequence marker; `aad` is already the + /// [`Aad::for_empty_sequence`] derivation. + EmptySequence { aad: Aad<'static> }, + /// A pending map with at least one entry. Map(Vec<(String, PendingCipherText)>), - /// A pending authenticated-absent marker, with its bound caller AAD. - None { aad: Vec }, + /// A pending empty-map marker; `aad` is already the [`Aad::for_empty_map`] + /// derivation. + EmptyMap { aad: Aad<'static> }, + /// A pending authenticated-absent marker; `aad` is already the + /// [`Aad::for_none`] derivation. + None { aad: Aad<'static> }, /// A passthrough value (needs no key). - Passthrough(Box), + Passthrough(BoxedPassthrough), } impl PendingCipherText { - /// Number of leaves that need a ZeroKMS data key (everything but passthrough). + /// Number of leaves that need a ZeroKMS data key (everything but + /// passthrough — markers are sealed leaves too). fn key_count(&self) -> usize { match self { - PendingCipherText::Single { .. } | PendingCipherText::None { .. } => 1, + PendingCipherText::Single { .. } + | PendingCipherText::None { .. } + | PendingCipherText::EmptySequence { .. } + | PendingCipherText::EmptyMap { .. } => 1, PendingCipherText::Sequence(items) => items.iter().map(Self::key_count).sum(), PendingCipherText::Map(entries) => entries.iter().map(|(_, v)| v.key_count()).sum(), PendingCipherText::Passthrough(_) => 0, @@ -275,36 +308,43 @@ impl PendingCipherText { self, keys: &mut impl Iterator, ) -> Result { + // Markers seal an *empty* plaintext so the AEAD tag still binds their + // (already domain-separated) AAD, mirroring `Aes256Cipher`. + fn seal_marker( + aad: Aad<'static>, + keys: &mut impl Iterator, + ) -> Result { + let key = keys.next().ok_or(Unspecified)?; + seal_leaf(Protected::new(Vec::new()), &aad, key) + } + match self { PendingCipherText::Single { plaintext, aad } => { let key = keys.next().ok_or(Unspecified)?; - Ok(ZeroKmsCipherText::Single(seal_leaf(plaintext, &aad, key)?)) + Ok(CipherText::Single(seal_leaf(plaintext, &aad, key)?)) } - PendingCipherText::None { aad } => { - let key = keys.next().ok_or(Unspecified)?; - // Seal an empty plaintext so the tag binds the AAD, mirroring - // `Aes256Cipher::encrypt_none`. - Ok(ZeroKmsCipherText::None(seal_leaf( - Protected::new(Vec::new()), - &aad, - key, - )?)) + PendingCipherText::None { aad } => Ok(CipherText::None(seal_marker(aad, keys)?)), + PendingCipherText::EmptySequence { aad } => { + Ok(CipherText::EmptySequence(seal_marker(aad, keys)?)) + } + PendingCipherText::EmptyMap { aad } => { + Ok(CipherText::EmptyMap(seal_marker(aad, keys)?)) } PendingCipherText::Sequence(items) => { let mut out = Vec::with_capacity(items.len()); for item in items { out.push(item.seal_with(keys)?); } - Ok(ZeroKmsCipherText::Sequence(out)) + Ok(CipherText::Sequence(out)) } PendingCipherText::Map(entries) => { let mut out = Vec::with_capacity(entries.len()); for (k, v) in entries { out.push((k, v.seal_with(keys)?)); } - Ok(ZeroKmsCipherText::Map(out)) + Ok(CipherText::Map(out)) } - PendingCipherText::Passthrough(value) => Ok(ZeroKmsCipherText::Passthrough(value)), + PendingCipherText::Passthrough(value) => Ok(CipherText::Passthrough(value)), } } } @@ -313,26 +353,26 @@ impl PendingCipherText { // Leaf crypto // ============================================================================= -/// Compose the AEAD AAD as the PAE-encoded tuple `(caller_aad, tag)`. +/// Compose the AEAD AAD as the PAE-encoded tuple `(derived_aad, tag)`. /// -/// PAE (length-prefixed) encoding is injective, so distinct `(caller_aad, tag)` +/// PAE (length-prefixed) encoding is injective, so distinct `(derived_aad, tag)` /// pairs can never collide on the same AAD bytes — unlike a raw concatenation, /// which is only unambiguous when the tag has a fixed length. `tag` is always /// bound, so the leaf is cryptographically tied to its ZeroKMS data key. -fn leaf_aad(caller_aad: &[u8], tag: &[u8]) -> Vec { - (caller_aad, tag).into_aad().as_bytes().to_vec() +fn leaf_aad(derived_aad: &Aad<'_>, tag: &[u8]) -> Vec { + (derived_aad.as_bytes(), tag).into_aad().as_bytes().to_vec() } /// Seal one plaintext leaf under a freshly generated data key. fn seal_leaf( plaintext: Protected>, - caller_aad: &[u8], + aad: &Aad<'_>, key: DataKeyWithTag, ) -> Result { let iv = key.key.iv; let aead = Aes256GcmSiv::new_from_slice(key.key.key()).map_err(|_| Unspecified)?; let nonce = GcmNonce::from_slice(&iv[..NONCE_LEN]); - let aad = leaf_aad(caller_aad, &key.tag); + let aad = leaf_aad(aad, &key.tag); // The plaintext bytes are overwritten in place by the ciphertext. let mut buf = plaintext.risky_unwrap(); @@ -350,53 +390,112 @@ fn seal_leaf( fn open_leaf( leaf: DataKeyCipherText, key: &DataKey, - caller_aad: &[u8], -) -> Result, Unspecified> { + aad: &Aad<'_>, +) -> Result>, Unspecified> { let aead = Aes256GcmSiv::new_from_slice(key.key()).map_err(|_| Unspecified)?; let nonce = GcmNonce::from_slice(&leaf.iv[..NONCE_LEN]); - let aad = leaf_aad(caller_aad, &leaf.tag); + let aad = leaf_aad(aad, &leaf.tag); let mut buf = leaf.ciphertext; aead.decrypt_in_place(nonce, &aad, &mut buf) .map_err(|_| Unspecified)?; - Ok(buf) + Ok(Protected::new(buf)) +} + +/// Open one marker leaf (absent / empty-sequence / empty-map) and require the +/// sealed plaintext to be empty. Without the emptiness check, a `Single` leaf +/// could be re-tagged as a marker of the same AAD derivation. +fn verify_empty_marker( + leaf: DataKeyCipherText, + key: &DataKey, + aad: &Aad<'_>, +) -> Result<(), Unspecified> { + let plaintext = open_leaf(leaf, key, aad)?; + if plaintext.risky_ref().is_empty() { + Ok(()) + } else { + Err(Unspecified) + } } /// Recursively open every leaf into a plaintext tree, drawing one key per leaf -/// from `keys` in the same order [`ZeroKmsCipherText::collect_retrieve_payloads`] -/// produced them. `caller_aad` is applied uniformly to every leaf (matching how -/// the built-in `Encrypt` container impls thread a single AAD to every element). +/// from `keys` in the same order [`collect_retrieve_payloads`] produced them. +/// +/// This is where the decrypt side re-derives the per-node AADs (the structural +/// [`Decipher`] drive that follows is crypto-free): sequence elements verify +/// under [`Aad::for_sequence_element`], map values under [`Aad::for_map_entry`] +/// of their key, and markers under their respective derivations with an +/// enforced-empty plaintext. Verified empty markers collapse to empty +/// `Sequence`/`Map` nodes so the structural decode sees ordinary containers. fn decrypt_tree( ciphertext: ZeroKmsCipherText, keys: &mut impl Iterator, - caller_aad: &[u8], + aad: &Aad<'_>, ) -> Result { match ciphertext { - ZeroKmsCipherText::Single(leaf) => { + CipherText::Single(leaf) => { let key = keys.next().ok_or(Unspecified)?; - Ok(PlaintextTree::Single(open_leaf(leaf, &key, caller_aad)?)) + Ok(PlaintextTree::Single(open_leaf(leaf, &key, aad)?)) } - ZeroKmsCipherText::None(leaf) => { + CipherText::None(leaf) => { let key = keys.next().ok_or(Unspecified)?; - // Authenticate the absent marker (verifies the tag) and discard. - open_leaf(leaf, &key, caller_aad)?; + verify_empty_marker(leaf, &key, &aad.for_none())?; Ok(PlaintextTree::None) } - ZeroKmsCipherText::Sequence(items) => { + CipherText::EmptySequence(leaf) => { + let key = keys.next().ok_or(Unspecified)?; + verify_empty_marker(leaf, &key, &aad.for_empty_sequence())?; + Ok(PlaintextTree::Sequence(Vec::new())) + } + CipherText::EmptyMap(leaf) => { + let key = keys.next().ok_or(Unspecified)?; + verify_empty_marker(leaf, &key, &aad.for_empty_map())?; + Ok(PlaintextTree::Map(Vec::new())) + } + CipherText::Sequence(items) => { + // At least one non-passthrough item required: passthrough items + // authenticate nothing, so an all-passthrough (or entry-less) + // sequence would verify under any AAD. The encrypt side refuses to + // produce one; refuse to open one. Emptiness is only provable by + // the authenticated `EmptySequence` marker. + if !items + .iter() + .any(|i| !matches!(i, CipherText::Passthrough(_))) + { + return Err(Unspecified); + } + let element_aad = aad.for_sequence_element(); let mut out = Vec::with_capacity(items.len()); for item in items { - out.push(decrypt_tree(item, keys, caller_aad)?); + out.push(decrypt_tree(item, keys, &element_aad)?); } Ok(PlaintextTree::Sequence(out)) } - ZeroKmsCipherText::Map(entries) => { + CipherText::Map(entries) => { + // Same all-passthrough/entry-less rejection as `Sequence`. + if !entries + .iter() + .any(|(_, v)| !matches!(v, CipherText::Passthrough(_))) + { + return Err(Unspecified); + } + // Reject duplicate keys before opening anything: two ciphertexts of + // the same logical record seal a given key's value against the + // identical `for_map_entry` AAD, so a stale entry appended to a + // current ciphertext *verifies* — with a last-wins decoder that is + // a single-field rollback. + let mut seen = HashSet::with_capacity(entries.len()); + if !entries.iter().all(|(key, _)| seen.insert(key.clone())) { + return Err(Unspecified); + } let mut out = Vec::with_capacity(entries.len()); for (k, v) in entries { - out.push((k, decrypt_tree(v, keys, caller_aad)?)); + let entry_aad = aad.for_map_entry(&k); + out.push((k, decrypt_tree(v, keys, &entry_aad)?)); } Ok(PlaintextTree::Map(out)) } - ZeroKmsCipherText::Passthrough(value) => Ok(PlaintextTree::Passthrough(value)), + CipherText::Passthrough(value) => Ok(PlaintextTree::Passthrough(value)), } } @@ -407,6 +506,7 @@ fn decrypt_tree( impl<'c, K> Cipher for &'c ZeroKmsCipher { type Ok = PendingCipherText; type Error = Unspecified; + type Passthrough = BoxedPassthrough; type SeqCipher = PendingSeqCipher<'c, K>; type MapCipher = PendingMapCipher<'c, K>; @@ -420,22 +520,36 @@ impl<'c, K> Cipher for &'c ZeroKmsCipher { { Ok(PendingCipherText::Single { plaintext: data, - aad: aad.into_aad().as_bytes().to_vec(), + aad: aad.into_aad().into_owned(), }) } - fn encrypt_seq(self, size_hint: Option) -> Self::SeqCipher { + fn encrypt_seq<'a, A>(self, size_hint: Option, aad: A) -> Self::SeqCipher + where + A: IntoAad<'a>, + { + let aad = aad.into_aad().into_owned(); PendingSeqCipher { cipher: self, items: Vec::with_capacity(size_hint.unwrap_or(0)), + // Both derived once here, then borrowed per element. + element_aad: aad.for_sequence_element(), + aad, + encrypted: false, } } - fn encrypt_map(self) -> Self::MapCipher { + fn encrypt_map<'a, A>(self, aad: A) -> Self::MapCipher + where + A: IntoAad<'a>, + { PendingMapCipher { cipher: self, entries: Vec::new(), + seen_keys: HashSet::new(), current_key: None, + aad: aad.into_aad().into_owned(), + encrypted: false, } } @@ -443,16 +557,24 @@ impl<'c, K> Cipher for &'c ZeroKmsCipher { where A: IntoAad<'a>, { + // Domain-separated so a `Single` leaf sealed under the bare AAD can + // never be re-tagged as an authenticated absence (and vice versa). Ok(PendingCipherText::None { - aad: aad.into_aad().as_bytes().to_vec(), + aad: aad.into_aad().for_none(), }) } - fn passthrough(self, value: T) -> Result - where - T: Any + Send + 'static, - { - Ok(PendingCipherText::Passthrough(Box::new(value))) + fn passthrough(self, value: Self::Passthrough) -> Result { + Ok(PendingCipherText::Passthrough(value)) + } + + fn passthrough_boxed( + self, + value: Box, + ) -> Result { + // This cipher's passthrough type *is* `Box`, so the + // type-erased box is already the passthrough payload. + self.passthrough(value) } } @@ -461,85 +583,145 @@ impl<'c, K> Cipher for &'c ZeroKmsCipher { pub struct PendingSeqCipher<'c, K> { cipher: &'c ZeroKmsCipher, items: Vec, + /// The AAD fixed at [`Cipher::encrypt_seq`]; the empty marker is sealed + /// against its `for_empty_sequence` derivation. + aad: Aad<'static>, + /// [`Aad::for_sequence_element`] of `aad`, derived once and applied to + /// every element. + element_aad: Aad<'static>, + /// Whether at least one element went through the authenticated + /// [`encrypt_next`](SeqCipher::encrypt_next) path *and* produced a sealed + /// node — see [`end`](SeqCipher::end). + encrypted: bool, } impl<'c, K> SeqCipher for PendingSeqCipher<'c, K> { type Ok = PendingCipherText; type Error = Unspecified; + type Passthrough = BoxedPassthrough; - fn encrypt_next<'a, T, A>(mut self, data: T, aad: A) -> Result + fn encrypt_next(mut self, data: T) -> Result where T: Encrypt, - A: IntoAad<'a>, { - self.items.push(data.encrypt_with_aad(self.cipher, aad)?); + // Borrow the stored derived AAD — no allocation per element. + let pending = + data.encrypt_with_aad(self.cipher, Aad::from_slice(self.element_aad.as_bytes()))?; + // A nested `Encrypt` impl may route through the passthrough channel; + // only a genuinely pending-sealed node may satisfy `end`'s + // all-passthrough rejection. + self.encrypted |= !matches!(pending, PendingCipherText::Passthrough(_)); + self.items.push(pending); Ok(self) } - fn passthrough_next(mut self, value: T) -> Result - where - T: Any + Send + 'static, - { - self.items - .push(PendingCipherText::Passthrough(Box::new(value))); + fn passthrough_next(mut self, value: Self::Passthrough) -> Result { + self.items.push(PendingCipherText::Passthrough(value)); Ok(self) } fn end(self) -> Result { - Ok(PendingCipherText::Sequence(self.items)) + if self.items.is_empty() { + Ok(PendingCipherText::EmptySequence { + aad: self.aad.for_empty_sequence(), + }) + } else if !self.encrypted { + // Every item is a passthrough: nothing in the container would + // authenticate the AAD, so refuse to produce it — mirroring + // `decrypt_tree`'s rejection on open. + Err(Unspecified) + } else { + Ok(PendingCipherText::Sequence(self.items)) + } } } /// [`MapCipher`] driver: keys are stored in the clear; values become pending -/// sub-trees. Mirrors `AesMapCipher`'s key/value contract checks. +/// sub-trees sealed against [`Aad::for_map_entry`] of the map AAD and their +/// key. Mirrors `AesMapCipher`'s key/value and duplicate-key contract checks. pub struct PendingMapCipher<'c, K> { cipher: &'c ZeroKmsCipher, entries: Vec<(String, PendingCipherText)>, - current_key: Option<&'static str>, + /// Duplicate keys are rejected at encrypt time: `decrypt_tree` rejects + /// them outright, so accepting one here would produce a permanently + /// unreadable ciphertext. + seen_keys: HashSet, + current_key: Option>, + /// The AAD fixed at [`Cipher::encrypt_map`]. + aad: Aad<'static>, + /// See [`PendingSeqCipher::encrypted`]. + encrypted: bool, } impl<'c, K> MapCipher for PendingMapCipher<'c, K> { type Ok = PendingCipherText; type Error = Unspecified; + type Passthrough = BoxedPassthrough; - fn encrypt_key(mut self, key: &'static str) -> Result { + fn encrypt_key(mut self, key: S) -> Result + where + S: Into>, + { + // A pending key means `encrypt_key` ran twice with no intervening + // `encrypt_value` — fail rather than silently drop the first key. if self.current_key.is_some() { return Err(Unspecified); } + let key = key.into(); + if !self.seen_keys.insert(key.as_ref().to_owned()) { + return Err(Unspecified); + } self.current_key = Some(key); Ok(self) } - fn encrypt_value<'a, U, A>(mut self, value: U, aad: A) -> Result + fn encrypt_value(mut self, value: U) -> Result where U: Encrypt, - A: IntoAad<'a>, { let key = self.current_key.take().ok_or(Unspecified)?; - let value = value.encrypt_with_aad(self.cipher, aad)?; - self.entries.push((key.to_string(), value)); + // Seal against PAE(domain, aad, key) — key and value are inseparable. + // `decrypt_tree` derives the same AAD on open. + let entry_aad = self.aad.for_map_entry(&key); + let pending = value.encrypt_with_aad(self.cipher, entry_aad)?; + self.encrypted |= !matches!(pending, PendingCipherText::Passthrough(_)); + self.entries.push((key.into_owned(), pending)); Ok(self) } - fn passthrough_entry(mut self, key: &'static str, value: T) -> Result + fn passthrough_entry(mut self, key: S, value: Self::Passthrough) -> Result where - T: Any + Send + 'static, + S: Into>, { + // Adopting a pending key here would silently drop it — same contract + // violation `encrypt_key` rejects. if self.current_key.is_some() { return Err(Unspecified); } - self.entries.push(( - key.to_string(), - PendingCipherText::Passthrough(Box::new(value)), - )); + let key = key.into(); + if !self.seen_keys.insert(key.as_ref().to_owned()) { + return Err(Unspecified); + } + self.entries + .push((key.into_owned(), PendingCipherText::Passthrough(value))); Ok(self) } fn end(self) -> Result { + // Finalising with a pending key would silently drop the entry. if self.current_key.is_some() { return Err(Unspecified); } - Ok(PendingCipherText::Map(self.entries)) + if self.entries.is_empty() { + Ok(PendingCipherText::EmptyMap { + aad: self.aad.for_empty_map(), + }) + } else if !self.encrypted { + // Every entry is a passthrough — see `PendingSeqCipher::end`. + Err(Unspecified) + } else { + Ok(PendingCipherText::Map(self.entries)) + } } } @@ -548,18 +730,20 @@ impl<'c, K> MapCipher for PendingMapCipher<'c, K> { // ============================================================================= /// Plaintext counterpart to [`ZeroKmsCipherText`], produced by [`decrypt_tree`] -/// once every leaf has been opened. [`PlaintextDecipher`] walks it structurally. +/// once every leaf has been opened (and every marker verified). Verified empty +/// markers appear as empty `Sequence`/`Map` nodes. enum PlaintextTree { - Single(Vec), + Single(Protected>), Sequence(Vec), Map(Vec<(String, PlaintextTree)>), None, - Passthrough(Box), + Passthrough(BoxedPassthrough), } /// A synchronous [`Decipher`] over a fully-decrypted [`PlaintextTree`]. It does -/// no crypto — decryption already happened in [`decrypt_tree`] — so it drives -/// the [`DecipherVisitor`] pattern purely structurally and ignores AAD. +/// no crypto — decryption (including AAD verification) already happened in +/// [`decrypt_tree`] — so it drives the [`DecipherVisitor`] pattern purely +/// structurally and ignores AAD. struct PlaintextDecipher { tree: PlaintextTree, } @@ -570,6 +754,8 @@ impl<'c> Decipher<'c> for PlaintextDecipher { where T: Send + 'c; + type Passthrough = BoxedPassthrough; + fn map_ok(ok: Self::Ok, f: F) -> Self::Ok where T: Send + 'c, @@ -585,7 +771,7 @@ impl<'c> Decipher<'c> for PlaintextDecipher { A: IntoAad<'a>, { match self.tree { - PlaintextTree::Single(bytes) => visitor.visit_bytes_vec(Protected::new(bytes)), + PlaintextTree::Single(bytes) => visitor.visit_bytes_vec(bytes), _ => Err(Unspecified), } } @@ -616,14 +802,28 @@ impl<'c> Decipher<'c> for PlaintextDecipher { } } - fn decrypt_passthrough(self) -> Self::Ok + fn decrypt_any<'a, V, A>(self, visitor: V, aad: A) -> Self::Ok where - T: Any + Send + 'static, + V: DecipherVisitor<'c> + Send + 'c, + A: IntoAad<'a>, { match self.tree { - PlaintextTree::Passthrough(boxed) => { - boxed.downcast::().map(|b| *b).map_err(|_| Unspecified) + tree @ PlaintextTree::Single(_) => { + PlaintextDecipher { tree }.decrypt_bytes(visitor, aad) + } + tree @ PlaintextTree::Sequence(_) => { + PlaintextDecipher { tree }.decrypt_seq(visitor, aad) } + tree @ PlaintextTree::Map(_) => PlaintextDecipher { tree }.decrypt_map(visitor, aad), + // The marker's AAD binding was verified in `decrypt_tree`. + PlaintextTree::None => visitor.visit_none(), + PlaintextTree::Passthrough(boxed) => visitor.visit_passthrough(boxed), + } + } + + fn decrypt_passthrough(self) -> Self::Ok { + match self.tree { + PlaintextTree::Passthrough(boxed) => Ok(boxed), _ => Err(Unspecified), } } @@ -634,7 +834,10 @@ impl<'c> Decipher<'c> for PlaintextDecipher { A: IntoAad<'a>, { match self.tree { + // The `for_none` marker (tag and enforced-empty plaintext) was + // verified in `decrypt_tree`. PlaintextTree::None => Ok(None), + // Passthrough must never be decoded as an Option payload. PlaintextTree::Passthrough(_) => Err(Unspecified), // Any other shape is the `Some` payload — recurse into `T`. other => T::decrypt_with_aad(PlaintextDecipher { tree: other }, ()).map(Some), diff --git a/packages/stack-encrypt/src/lib.rs b/packages/stack-encrypt/src/lib.rs index 3d9737370..22150d2cb 100644 --- a/packages/stack-encrypt/src/lib.rs +++ b/packages/stack-encrypt/src/lib.rs @@ -1,4 +1,16 @@ #![doc(html_favicon_url = "https://cipherstash.com/favicon.ico")] +#![deny(unsafe_code)] +#![warn( + clippy::unwrap_used, + clippy::expect_used, + clippy::panic, + clippy::mem_forget, + clippy::print_stdout, + clippy::print_stderr, + clippy::dbg_macro, + clippy::todo, + clippy::unimplemented +)] //! `stack-encrypt` bridges ZeroKMS data keys into the vitaminc encryption //! ecosystem. //! @@ -31,8 +43,12 @@ mod cipher; -pub use cipher::{DataKeyCipherText, Error, PendingCipherText, ZeroKmsCipher, ZeroKmsCipherText}; +pub use cipher::{ + BoxedPassthrough, DataKeyCipherText, Error, PendingCipherText, ZeroKmsCipher, ZeroKmsCipherText, +}; // Re-export the vitaminc AEAD surface callers need to drive the cipher, so they // don't have to depend on `vitaminc-aead` directly for the common path. -pub use vitaminc_aead::{Aad, Cipher, ContextTag, Decrypt, Encrypt, IntoAad, Unspecified}; +pub use vitaminc_aead::{ + Aad, Cipher, CipherText, ContextTag, Decipher, Decrypt, Encrypt, IntoAad, Unspecified, +}; diff --git a/packages/stack-encrypt/tests/roundtrip.rs b/packages/stack-encrypt/tests/roundtrip.rs index 3802e31c4..8d512b5cb 100644 --- a/packages/stack-encrypt/tests/roundtrip.rs +++ b/packages/stack-encrypt/tests/roundtrip.rs @@ -3,7 +3,7 @@ use std::collections::HashMap; -use stack_encrypt::{ContextTag, ZeroKmsCipher}; +use stack_encrypt::{CipherText, ContextTag, ZeroKmsCipher}; use stack_kms::FakeDataKeySource; use vitaminc_protected::{Controlled, Protected}; @@ -156,6 +156,84 @@ async fn context_tag_wrong_context_fails() { assert!(result.is_err(), "wrong context tag must not decrypt"); } +#[tokio::test] +async fn empty_vec_roundtrips() { + // An empty sequence seals an authenticated marker, so emptiness is provable. + let cipher = cipher(); + let ct = cipher + .encrypt(Vec::::new(), ()) + .await + .expect("encrypt"); + let pt: Vec = cipher.decrypt(ct, ()).await.expect("decrypt"); + assert!(pt.is_empty()); +} + +#[tokio::test] +async fn empty_map_roundtrips() { + let cipher = cipher(); + let ct = cipher + .encrypt(HashMap::<&'static str, String>::new(), ()) + .await + .expect("encrypt"); + let pt: HashMap = cipher.decrypt(ct, ()).await.expect("decrypt"); + assert!(pt.is_empty()); +} + +#[tokio::test] +async fn empty_marker_does_not_decode_under_wrong_aad() { + let cipher = cipher(); + let ct = cipher + .encrypt(Vec::::new(), b"bound".as_slice()) + .await + .expect("encrypt"); + let result: Result, _> = cipher.decrypt(ct, ()).await; + assert!(result.is_err(), "empty marker must authenticate its AAD"); +} + +#[tokio::test] +async fn renamed_map_key_fails() { + // Map keys travel in the clear but are bound into their value's AAD, so + // renaming a key in the stored ciphertext must fail decryption. + let cipher = cipher(); + let mut input: HashMap<&'static str, String> = HashMap::new(); + input.insert("name", "alice".to_string()); + + let ct = cipher.encrypt(input, ()).await.expect("encrypt"); + let tampered = match ct { + CipherText::Map(entries) => CipherText::Map( + entries + .into_iter() + .map(|(_, v)| ("role".to_string(), v)) + .collect(), + ), + other => other, + }; + + let result: Result, _> = cipher.decrypt(tampered, ()).await; + assert!(result.is_err(), "renamed map key must not decrypt"); +} + +#[tokio::test] +async fn sequence_element_cannot_be_rehomed_as_scalar() { + // Elements are sealed under the `for_sequence_element` derivation, so a + // leaf spliced out of a sequence must not verify as a top-level scalar. + let cipher = cipher(); + let ct = cipher + .encrypt(vec!["a".to_string()], ()) + .await + .expect("encrypt"); + let element = match ct { + CipherText::Sequence(mut items) => items.remove(0), + other => other, + }; + + let result: Result = cipher.decrypt(element, ()).await; + assert!( + result.is_err(), + "re-homed sequence element must not decrypt" + ); +} + #[tokio::test] async fn wrong_shape_fails() { // A scalar ciphertext must not decode as a sequence. From 37bd0fc834b489c0d896fbbbc1229c622b07eae2 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Sun, 23 Aug 2026 22:27:01 +1000 Subject: [PATCH 410/686] refactor(stack-encrypt): open leaves inside the Decipher drive, not a pre-pass `ZeroKmsCipher::decrypt` previously opened every leaf eagerly in `decrypt_tree` (re-deriving per-node AADs by walking the ciphertext) and then drove the value's `Decrypt` impl through `PlaintextDecipher`, a crypto-free decipher over the recovered plaintext that discarded every AAD it was handed. That split meant Decrypt-side AAD derivations were dead: `vitaminc_aead::Element`, which derives `for_sequence_element` inside its own impl, sealed correctly but could not be decrypted. The async constraint only covers the key *fetch*, not the AES open. So: - `ZeroKmsCipher::decipher` batches one `retrieve_keys` call, checks the returned count, zips each key onto its leaf (`KeyedCipherText`), and returns a `ZeroKmsDecipher`. - `ZeroKmsDecipher` implements `Decipher` exactly as `AesDecipher` does: `decrypt_bytes` opens under the AAD the drive supplies; `decrypt_seq` / `decrypt_map` / `decrypt_option` / `decrypt_any` derive the element, map-entry, and marker AADs, verify empty markers, and reject all-passthrough containers and duplicate map keys. - `decrypt` is now a thin wrapper: `T::decrypt_with_aad(decipher, aad)`. - `PlaintextTree`, `PlaintextDecipher`, `decrypt_tree`, and the plaintext Seq/Map access types are removed; there is a single source of truth for per-node AAD derivation. Also: a short key vector from a `DataKeySource` now surfaces as `KeyCountMismatch` (matching `seal`) rather than `Aead`; `Element` is re-exported; `ZeroKmsDecipher::decrypt_passthrough_as` mirrors `AesDecipher`. Tests: `Element` round-trips under the bare caller AAD, interchanges with a `Vec` element in both directions, fails under the wrong caller AAD, and `decipher()` can be driven directly with a manual derivation. Claude-Session: https://claude.ai/code/session_01T5iYiJc6xwzMcHDtMwCadG --- packages/stack-encrypt/src/cipher.rs | 430 +++++++++++++--------- packages/stack-encrypt/src/lib.rs | 13 +- packages/stack-encrypt/tests/roundtrip.rs | 96 ++++- 3 files changed, 359 insertions(+), 180 deletions(-) diff --git a/packages/stack-encrypt/src/cipher.rs b/packages/stack-encrypt/src/cipher.rs index 1a2efe320..f091fa594 100644 --- a/packages/stack-encrypt/src/cipher.rs +++ b/packages/stack-encrypt/src/cipher.rs @@ -7,19 +7,22 @@ //! sealed under its own ZeroKMS data key. //! //! Because data-key generation/retrieval is an async ZeroKMS round-trip, the -//! crypto cannot happen inside the synchronous [`Cipher`]/[`Decipher`] trait -//! methods. Instead: +//! key fetch cannot happen inside the synchronous [`Cipher`]/[`Decipher`] trait +//! methods. It is front-loaded on both sides, and the AES work stays inside the +//! trait drive: //! //! * **Encrypt** — driving the [`Cipher`] trait builds a *pending* tree //! ([`PendingCipherText`]) that holds plaintext but does no I/O. A single //! [`PendingCipherText::seal`] (or the [`ZeroKmsCipher::encrypt`] convenience) //! then batches **one** `generate_keys` call for the whole tree and seals //! every leaf. -//! * **Decrypt** — [`ZeroKmsCipher::decrypt`] batches **one** `retrieve_keys` -//! call, decrypts every leaf, then drives the value's [`Decrypt`] impl through -//! a synchronous in-memory [`Decipher`] over the recovered plaintext — so the -//! visitor pattern (and arbitrary nested `Vec`/`HashMap`/`Option`/`Protected` -//! values) works exactly as it does for `Aes256Cipher`. +//! * **Decrypt** — [`ZeroKmsCipher::decipher`] batches **one** `retrieve_keys` +//! call and zips each key onto its leaf, returning a [`ZeroKmsDecipher`]. The +//! value's [`Decrypt`] impl then drives that decipher exactly as it would +//! `AesDecipher`: each leaf is opened under the AAD the drive supplies, so +//! the visitor pattern (arbitrary nested `Vec`/`HashMap`/`Option`/`Protected` +//! values, and AAD-deriving wrappers such as `vitaminc_aead::Element`) works +//! identically to `Aes256Cipher`. //! //! ## AAD derivation //! @@ -33,9 +36,9 @@ //! sequences/maps under [`Aad::for_empty_sequence`]/[`Aad::for_empty_map`] — //! each sealing an *empty* plaintext, verified as empty on open. //! -//! Because leaf decryption happens *before* the structural decode (in -//! [`decrypt_tree`], while the [`Decipher`] drive is crypto-free), the decrypt -//! side re-derives the same per-node AADs by walking the ciphertext tree. +//! The decrypt side performs the same derivations inside [`ZeroKmsDecipher`]'s +//! `decrypt_seq`/`decrypt_map`/`decrypt_option` as the caller's `Decrypt` impl +//! drives it, so there is a single source of truth for the per-node AAD. //! //! ## Wire format //! @@ -145,15 +148,30 @@ impl ZeroKmsCipher { } /// Decrypt a [`ZeroKmsCipherText`] into `T`, authenticating against `aad`. - /// Retrieves every leaf's data key in a single batched `retrieve_keys` call, - /// then drives `T`'s [`Decrypt`] impl over the recovered plaintext. + /// + /// Thin wrapper over [`decipher`](Self::decipher): one batched + /// `retrieve_keys` call, then `T`'s [`Decrypt`] impl drives the returned + /// [`ZeroKmsDecipher`] with `aad` — exactly as `Aes256Cipher::decrypt_with_aad` + /// drives `AesDecipher`. pub async fn decrypt<'a, T, A>(&self, ciphertext: ZeroKmsCipherText, aad: A) -> Result where T: Decrypt<'static> + 'static, A: IntoAad<'a>, { - let aad = aad.into_aad().into_owned(); - + let decipher = self.decipher(ciphertext).await?; + T::decrypt_with_aad(decipher, aad).map_err(Error::from) + } + + /// Fetch every leaf's data key (one batched `retrieve_keys` call) and bind + /// them onto the ciphertext, returning a synchronous [`Decipher`] that does + /// the AEAD opening as the value's [`Decrypt`] impl drives it. + /// + /// This is the decrypt-side counterpart to passing `&cipher` (a [`Cipher`]) + /// on the encrypt side, mirroring `Aes256Cipher::decipher`: the ZeroKMS I/O + /// is front-loaded here, and the AAD is supplied per call by + /// [`Decrypt::decrypt_with_aad`], so `Decrypt` impls that derive their own + /// AAD (e.g. `vitaminc_aead::Element`) behave identically to `AesDecipher`. + pub async fn decipher(&self, ciphertext: ZeroKmsCipherText) -> Result { // Collect every leaf's retrieve payload (borrowing the ciphertext), make // one batched call, then drop the borrow before consuming the tree. let keys = { @@ -162,23 +180,29 @@ impl ZeroKmsCipher { if payloads.is_empty() { Vec::new() } else { - self.kms + let expected = payloads.len(); + let keys = self + .kms .retrieve_keys(payloads, self.keyset_id, None) - .await? + .await?; + if keys.len() != expected { + return Err(Error::KeyCountMismatch { + expected, + received: keys.len(), + }); + } + keys } }; let mut keys = keys.into_iter(); - let plaintext = decrypt_tree(ciphertext, &mut keys, &aad)?; + let ciphertext = bind_keys(ciphertext, &mut keys)?; // Every key must have been consumed; leftovers mean the tree shape and // the payload collection disagreed. if keys.next().is_some() { return Err(Error::Aead); } - - // The plaintext is fully recovered; the structural decode is synchronous - // and ignores AAD (already authenticated above). - T::decrypt_with_aad(PlaintextDecipher { tree: plaintext }, ()).map_err(Error::from) + Ok(ZeroKmsDecipher { ciphertext }) } } @@ -196,7 +220,7 @@ pub struct DataKeyCipherText { } /// Walk the tree in depth-first order, pushing one retrieve payload per keyed -/// leaf (markers included). Must match [`decrypt_tree`]'s traversal so payloads +/// leaf (markers included). Must match [`bind_keys`]'s traversal so payloads /// and returned keys line up. fn collect_retrieve_payloads<'b>( ciphertext: &'b ZeroKmsCipherText, @@ -224,6 +248,56 @@ fn collect_retrieve_payloads<'b>( } } +/// A leaf with its retrieved data key bound alongside. Produced by +/// [`bind_keys`] once the batched `retrieve_keys` call has returned; consumed by +/// [`ZeroKmsDecipher`], which opens it under whatever AAD the driving +/// [`Decrypt`] impl supplies. +struct KeyedLeaf { + leaf: DataKeyCipherText, + key: DataKey, +} + +/// [`ZeroKmsCipherText`] with a [`DataKey`] zipped onto every keyed leaf. +type KeyedCipherText = CipherText; + +/// Zip retrieved keys onto the tree in the same depth-first order +/// [`collect_retrieve_payloads`] requested them, so each leaf carries its own +/// key and the subsequent [`Decipher`] drive is free of ordering assumptions. +fn bind_keys( + ciphertext: ZeroKmsCipherText, + keys: &mut impl Iterator, +) -> Result { + fn bind( + leaf: DataKeyCipherText, + keys: &mut impl Iterator, + ) -> Result { + let key = keys.next().ok_or(Unspecified)?; + Ok(KeyedLeaf { leaf, key }) + } + + match ciphertext { + CipherText::Single(leaf) => Ok(CipherText::Single(bind(leaf, keys)?)), + CipherText::None(leaf) => Ok(CipherText::None(bind(leaf, keys)?)), + CipherText::EmptySequence(leaf) => Ok(CipherText::EmptySequence(bind(leaf, keys)?)), + CipherText::EmptyMap(leaf) => Ok(CipherText::EmptyMap(bind(leaf, keys)?)), + CipherText::Sequence(items) => { + let mut out = Vec::with_capacity(items.len()); + for item in items { + out.push(bind_keys(item, keys)?); + } + Ok(CipherText::Sequence(out)) + } + CipherText::Map(entries) => { + let mut out = Vec::with_capacity(entries.len()); + for (k, v) in entries { + out.push((k, bind_keys(v, keys)?)); + } + Ok(CipherText::Map(out)) + } + CipherText::Passthrough(value) => Ok(CipherText::Passthrough(value)), + } +} + // ============================================================================= // Pending tree (`Cipher::Ok`) — built synchronously, sealed asynchronously // ============================================================================= @@ -386,12 +460,9 @@ fn seal_leaf( }) } -/// Open one leaf with its retrieved data key, returning the plaintext bytes. -fn open_leaf( - leaf: DataKeyCipherText, - key: &DataKey, - aad: &Aad<'_>, -) -> Result>, Unspecified> { +/// Open one keyed leaf under `aad`, returning the plaintext bytes. +fn open_leaf(keyed: KeyedLeaf, aad: &Aad<'_>) -> Result>, Unspecified> { + let KeyedLeaf { leaf, key } = keyed; let aead = Aes256GcmSiv::new_from_slice(key.key()).map_err(|_| Unspecified)?; let nonce = GcmNonce::from_slice(&leaf.iv[..NONCE_LEN]); let aad = leaf_aad(aad, &leaf.tag); @@ -405,12 +476,8 @@ fn open_leaf( /// Open one marker leaf (absent / empty-sequence / empty-map) and require the /// sealed plaintext to be empty. Without the emptiness check, a `Single` leaf /// could be re-tagged as a marker of the same AAD derivation. -fn verify_empty_marker( - leaf: DataKeyCipherText, - key: &DataKey, - aad: &Aad<'_>, -) -> Result<(), Unspecified> { - let plaintext = open_leaf(leaf, key, aad)?; +fn verify_empty_marker(keyed: KeyedLeaf, aad: &Aad<'_>) -> Result<(), Unspecified> { + let plaintext = open_leaf(keyed, aad)?; if plaintext.risky_ref().is_empty() { Ok(()) } else { @@ -418,87 +485,6 @@ fn verify_empty_marker( } } -/// Recursively open every leaf into a plaintext tree, drawing one key per leaf -/// from `keys` in the same order [`collect_retrieve_payloads`] produced them. -/// -/// This is where the decrypt side re-derives the per-node AADs (the structural -/// [`Decipher`] drive that follows is crypto-free): sequence elements verify -/// under [`Aad::for_sequence_element`], map values under [`Aad::for_map_entry`] -/// of their key, and markers under their respective derivations with an -/// enforced-empty plaintext. Verified empty markers collapse to empty -/// `Sequence`/`Map` nodes so the structural decode sees ordinary containers. -fn decrypt_tree( - ciphertext: ZeroKmsCipherText, - keys: &mut impl Iterator, - aad: &Aad<'_>, -) -> Result { - match ciphertext { - CipherText::Single(leaf) => { - let key = keys.next().ok_or(Unspecified)?; - Ok(PlaintextTree::Single(open_leaf(leaf, &key, aad)?)) - } - CipherText::None(leaf) => { - let key = keys.next().ok_or(Unspecified)?; - verify_empty_marker(leaf, &key, &aad.for_none())?; - Ok(PlaintextTree::None) - } - CipherText::EmptySequence(leaf) => { - let key = keys.next().ok_or(Unspecified)?; - verify_empty_marker(leaf, &key, &aad.for_empty_sequence())?; - Ok(PlaintextTree::Sequence(Vec::new())) - } - CipherText::EmptyMap(leaf) => { - let key = keys.next().ok_or(Unspecified)?; - verify_empty_marker(leaf, &key, &aad.for_empty_map())?; - Ok(PlaintextTree::Map(Vec::new())) - } - CipherText::Sequence(items) => { - // At least one non-passthrough item required: passthrough items - // authenticate nothing, so an all-passthrough (or entry-less) - // sequence would verify under any AAD. The encrypt side refuses to - // produce one; refuse to open one. Emptiness is only provable by - // the authenticated `EmptySequence` marker. - if !items - .iter() - .any(|i| !matches!(i, CipherText::Passthrough(_))) - { - return Err(Unspecified); - } - let element_aad = aad.for_sequence_element(); - let mut out = Vec::with_capacity(items.len()); - for item in items { - out.push(decrypt_tree(item, keys, &element_aad)?); - } - Ok(PlaintextTree::Sequence(out)) - } - CipherText::Map(entries) => { - // Same all-passthrough/entry-less rejection as `Sequence`. - if !entries - .iter() - .any(|(_, v)| !matches!(v, CipherText::Passthrough(_))) - { - return Err(Unspecified); - } - // Reject duplicate keys before opening anything: two ciphertexts of - // the same logical record seal a given key's value against the - // identical `for_map_entry` AAD, so a stale entry appended to a - // current ciphertext *verifies* — with a last-wins decoder that is - // a single-field rollback. - let mut seen = HashSet::with_capacity(entries.len()); - if !entries.iter().all(|(key, _)| seen.insert(key.clone())) { - return Err(Unspecified); - } - let mut out = Vec::with_capacity(entries.len()); - for (k, v) in entries { - let entry_aad = aad.for_map_entry(&k); - out.push((k, decrypt_tree(v, keys, &entry_aad)?)); - } - Ok(PlaintextTree::Map(out)) - } - CipherText::Passthrough(value) => Ok(PlaintextTree::Passthrough(value)), - } -} - // ============================================================================= // Encrypt side: `Cipher` impl over a `&ZeroKmsCipher` (builds the pending tree) // ============================================================================= @@ -725,30 +711,45 @@ impl<'c, K> MapCipher for PendingMapCipher<'c, K> { } } -// ============================================================================= -// Decrypt side: a synchronous `Decipher` over already-recovered plaintext +// Decrypt side: a synchronous `Decipher` over a key-bound ciphertext tree // ============================================================================= -/// Plaintext counterpart to [`ZeroKmsCipherText`], produced by [`decrypt_tree`] -/// once every leaf has been opened (and every marker verified). Verified empty -/// markers appear as empty `Sequence`/`Map` nodes. -enum PlaintextTree { - Single(Protected>), - Sequence(Vec), - Map(Vec<(String, PlaintextTree)>), - None, - Passthrough(BoxedPassthrough), +/// A [`Decipher`] over a single [`ZeroKmsCipherText`] whose leaves already +/// carry their retrieved data keys, produced by [`ZeroKmsCipher::decipher`]. +/// +/// Structurally identical to `vitaminc_encrypt::AesDecipher` — the only +/// difference is where each leaf's key comes from. The AAD is supplied per call +/// by [`Decrypt::decrypt_with_aad`] and refined here exactly as the encrypt side +/// refined it: sequence elements under [`Aad::for_sequence_element`], map +/// values under [`Aad::for_map_entry`] of their key, markers under their +/// respective derivations with an enforced-empty plaintext. Because the +/// derivation lives in this drive (not in a pre-pass), `Decrypt` impls that +/// transform the AAD themselves (e.g. `vitaminc_aead::Element`) work unchanged. +pub struct ZeroKmsDecipher { + ciphertext: KeyedCipherText, } -/// A synchronous [`Decipher`] over a fully-decrypted [`PlaintextTree`]. It does -/// no crypto — decryption (including AAD verification) already happened in -/// [`decrypt_tree`] — so it drives the [`DecipherVisitor`] pattern purely -/// structurally and ignores AAD. -struct PlaintextDecipher { - tree: PlaintextTree, +impl ZeroKmsDecipher { + fn over(ciphertext: KeyedCipherText) -> Self { + Self { ciphertext } + } + + /// Typed convenience over [`Decipher::decrypt_passthrough`] for this + /// cipher's [`BoxedPassthrough`] payload type: recovers the payload and + /// downcasts it to `T`, returning [`Unspecified`] if the ciphertext is not + /// a passthrough or the stored type does not match. + pub fn decrypt_passthrough_as(self) -> Result + where + T: Any + Send + 'static, + { + self.decrypt_passthrough()? + .downcast::() + .map(|b| *b) + .map_err(|_| Unspecified) + } } -impl<'c> Decipher<'c> for PlaintextDecipher { +impl<'c> Decipher<'c> for ZeroKmsDecipher { type Ok = Result where @@ -765,39 +766,91 @@ impl<'c> Decipher<'c> for PlaintextDecipher { ok.map(f) } - fn decrypt_bytes<'a, V, A>(self, visitor: V, _aad: A) -> Self::Ok + fn decrypt_bytes<'a, V, A>(self, visitor: V, aad: A) -> Self::Ok where V: DecipherVisitor<'c> + Send + 'c, A: IntoAad<'a>, { - match self.tree { - PlaintextTree::Single(bytes) => visitor.visit_bytes_vec(bytes), + match self.ciphertext { + CipherText::Single(keyed) => { + let bytes = open_leaf(keyed, &aad.into_aad())?; + visitor.visit_bytes_vec(bytes) + } _ => Err(Unspecified), } } - fn decrypt_seq<'a, V, A>(self, visitor: V, _aad: A) -> Self::Ok + fn decrypt_seq<'a, V, A>(self, visitor: V, aad: A) -> Self::Ok where V: DecipherVisitor<'c> + Send + 'c, A: IntoAad<'a>, { - match self.tree { - PlaintextTree::Sequence(items) => visitor.visit_seq(PlaintextSeqAccess { - items: items.into_iter(), - }), + match self.ciphertext { + // At least one non-passthrough item required: passthrough items + // authenticate nothing, so an all-passthrough (or entry-less) + // sequence would verify under any AAD. The encrypt side refuses to + // produce one; refuse to open one. Emptiness is only provable by + // the authenticated `EmptySequence` marker. + CipherText::Sequence(items) + if items + .iter() + .any(|i| !matches!(i, CipherText::Passthrough(_))) => + { + visitor.visit_seq(ZeroKmsSeqAccess { + items: items.into_iter(), + element_aad: aad.into_aad().for_sequence_element(), + }) + } + CipherText::EmptySequence(keyed) => { + let aad = aad.into_aad(); + verify_empty_marker(keyed, &aad.for_empty_sequence())?; + // Store the element derivation exactly as the non-empty arm + // does: never read (the iterator is empty), but a divergent + // value here would silently break a visitor that consulted it. + visitor.visit_seq(ZeroKmsSeqAccess { + items: Vec::new().into_iter(), + element_aad: aad.for_sequence_element(), + }) + } _ => Err(Unspecified), } } - fn decrypt_map<'a, V, A>(self, visitor: V, _aad: A) -> Self::Ok + fn decrypt_map<'a, V, A>(self, visitor: V, aad: A) -> Self::Ok where V: DecipherVisitor<'c> + Send + 'c, A: IntoAad<'a>, { - match self.tree { - PlaintextTree::Map(entries) => visitor.visit_map(PlaintextMapAccess { - entries: entries.into_iter(), - }), + match self.ciphertext { + // At least one non-passthrough entry required — see `decrypt_seq`. + CipherText::Map(entries) + if entries + .iter() + .any(|(_, v)| !matches!(v, CipherText::Passthrough(_))) => + { + // Reject duplicate keys before the visitor sees any entry: two + // ciphertexts of the same logical record seal a given key's + // value against the identical `for_map_entry` AAD, so a stale + // entry appended to a current ciphertext *verifies* — with a + // last-wins visitor that is a single-field rollback. + let mut seen = HashSet::with_capacity(entries.len()); + if !entries.iter().all(|(key, _)| seen.insert(key.as_str())) { + return Err(Unspecified); + } + visitor.visit_map(ZeroKmsMapAccess { + entries: entries.into_iter(), + aad: aad.into_aad(), + }) + } + CipherText::EmptyMap(keyed) => { + let aad = aad.into_aad(); + verify_empty_marker(keyed, &aad.for_empty_map())?; + // Raw caller AAD, not the marker derivation — see `decrypt_seq`. + visitor.visit_map(ZeroKmsMapAccess { + entries: Vec::new().into_iter(), + aad, + }) + } _ => Err(Unspecified), } } @@ -807,70 +860,99 @@ impl<'c> Decipher<'c> for PlaintextDecipher { V: DecipherVisitor<'c> + Send + 'c, A: IntoAad<'a>, { - match self.tree { - tree @ PlaintextTree::Single(_) => { - PlaintextDecipher { tree }.decrypt_bytes(visitor, aad) + match self.ciphertext { + ct @ CipherText::Single(_) => Self::over(ct).decrypt_bytes(visitor, aad), + ct @ (CipherText::Sequence(_) | CipherText::EmptySequence(_)) => { + Self::over(ct).decrypt_seq(visitor, aad) + } + ct @ (CipherText::Map(_) | CipherText::EmptyMap(_)) => { + Self::over(ct).decrypt_map(visitor, aad) } - tree @ PlaintextTree::Sequence(_) => { - PlaintextDecipher { tree }.decrypt_seq(visitor, aad) + CipherText::None(keyed) => { + // Verify the domain-separated marker (tag AND empty plaintext) + // before reporting absence — an unauthenticated `visit_none` + // would let an attacker forge "absent" values, and a bare-AAD + // check would let a `Single` leaf be re-tagged as one. Mirrors + // `decrypt_option`. + verify_empty_marker(keyed, &aad.into_aad().for_none())?; + visitor.visit_none() } - tree @ PlaintextTree::Map(_) => PlaintextDecipher { tree }.decrypt_map(visitor, aad), - // The marker's AAD binding was verified in `decrypt_tree`. - PlaintextTree::None => visitor.visit_none(), - PlaintextTree::Passthrough(boxed) => visitor.visit_passthrough(boxed), + // A self-describing visitor recovers a passthrough via + // `visit_passthrough` (type-erased); visitors that do not override + // it inherit the default rejection. + CipherText::Passthrough(boxed) => visitor.visit_passthrough(boxed), } } fn decrypt_passthrough(self) -> Self::Ok { - match self.tree { - PlaintextTree::Passthrough(boxed) => Ok(boxed), + match self.ciphertext { + CipherText::Passthrough(boxed) => Ok(boxed), _ => Err(Unspecified), } } - fn decrypt_option<'a, T, A>(self, _aad: A) -> Self::Ok> + fn decrypt_option<'a, T, A>(self, aad: A) -> Self::Ok> where T: Decrypt<'c> + 'c, A: IntoAad<'a>, { - match self.tree { - // The `for_none` marker (tag and enforced-empty plaintext) was - // verified in `decrypt_tree`. - PlaintextTree::None => Ok(None), + match self.ciphertext { + CipherText::None(keyed) => { + // Verify the tag over the domain-separated marker AAD AND that + // the sealed plaintext is actually empty. Without both, a + // `Single(leaf)` sealed under the same caller AAD could be + // re-tagged as `None(leaf)` and decrypt as Ok(None) — silent + // authenticated data deletion. + verify_empty_marker(keyed, &aad.into_aad().for_none())?; + Ok(None) + } // Passthrough must never be decoded as an Option payload. - PlaintextTree::Passthrough(_) => Err(Unspecified), - // Any other shape is the `Some` payload — recurse into `T`. - other => T::decrypt_with_aad(PlaintextDecipher { tree: other }, ()).map(Some), + CipherText::Passthrough(_) => Err(Unspecified), + // Any other variant is the `Some` payload: recurse into `T` with + // the caller's AAD unchanged (there is no depth tag; the shape of + // nested options is decided by `T` at the call site, as in + // `AesDecipher`). + other => T::decrypt_with_aad(Self::over(other), aad).map(Some), } } } -struct PlaintextSeqAccess { - items: std::vec::IntoIter, +struct ZeroKmsSeqAccess { + items: std::vec::IntoIter, + /// [`Aad::for_sequence_element`] of the caller's AAD, derived once at + /// construction and re-supplied per element by borrowing. Mirrors + /// `PendingSeqCipher::element_aad` on the encrypt side. + element_aad: Aad<'static>, } -impl<'c> SeqAccess<'c> for PlaintextSeqAccess { +impl<'c> SeqAccess<'c> for ZeroKmsSeqAccess { type Error = Unspecified; fn next_element + 'c>(&mut self) -> Result, Self::Error> { match self.items.next() { - Some(tree) => T::decrypt_with_aad(PlaintextDecipher { tree }, ()).map(Some), + Some(ct) => T::decrypt_with_aad(ZeroKmsDecipher::over(ct), self.element_aad.as_bytes()) + .map(Some), None => Ok(None), } } } -struct PlaintextMapAccess { - entries: std::vec::IntoIter<(String, PlaintextTree)>, +struct ZeroKmsMapAccess<'a> { + entries: std::vec::IntoIter<(String, KeyedCipherText)>, + aad: Aad<'a>, } -impl<'c> MapAccess<'c> for PlaintextMapAccess { +impl<'c, 'a> MapAccess<'c> for ZeroKmsMapAccess<'a> { type Error = Unspecified; fn next_entry + 'c>(&mut self) -> Result, Self::Error> { match self.entries.next() { - Some((key, tree)) => { - let value = T::decrypt_with_aad(PlaintextDecipher { tree }, ())?; + Some((key, ct)) => { + // Mirror `PendingMapCipher::encrypt_value`: the value was sealed + // against `for_map_entry(key)`, so a swapped or renamed key + // fails here. + let entry_aad = self.aad.for_map_entry(&key); + let value = T::decrypt_with_aad(ZeroKmsDecipher::over(ct), entry_aad)?; Ok(Some((key, value))) } None => Ok(None), diff --git a/packages/stack-encrypt/src/lib.rs b/packages/stack-encrypt/src/lib.rs index 22150d2cb..bfa068c41 100644 --- a/packages/stack-encrypt/src/lib.rs +++ b/packages/stack-encrypt/src/lib.rs @@ -19,9 +19,11 @@ //! unlike a fixed-key cipher — every leaf of a value is sealed under its own //! ZeroKMS data key, fetched lazily. Encrypting builds a pending tree with no //! I/O; a single batched `generate_keys` call then seals it. Decrypting batches -//! one `retrieve_keys` call, then drives the value's `Decrypt` impl over the -//! recovered plaintext, so arbitrary nested `Vec` / `HashMap` / `Option` / -//! `Protected` values round-trip exactly as with `vitaminc_encrypt::Aes256Cipher`. +//! one `retrieve_keys` call, binds each key onto its leaf, and hands the value's +//! `Decrypt` impl a [`ZeroKmsDecipher`] that opens leaves as it is driven — so +//! arbitrary nested `Vec` / `HashMap` / `Option` / `Protected` values (and +//! AAD-deriving wrappers like `vitaminc_aead::Element`) round-trip exactly as +//! with `vitaminc_encrypt::Aes256Cipher`. //! //! The data keys are sourced through a [`stack_kms::DataKeySource`] //! (production: `stack_kms::StackKms`; tests: `stack_kms::FakeDataKeySource`). @@ -44,11 +46,12 @@ mod cipher; pub use cipher::{ - BoxedPassthrough, DataKeyCipherText, Error, PendingCipherText, ZeroKmsCipher, ZeroKmsCipherText, + BoxedPassthrough, DataKeyCipherText, Error, PendingCipherText, ZeroKmsCipher, + ZeroKmsCipherText, ZeroKmsDecipher, }; // Re-export the vitaminc AEAD surface callers need to drive the cipher, so they // don't have to depend on `vitaminc-aead` directly for the common path. pub use vitaminc_aead::{ - Aad, Cipher, CipherText, ContextTag, Decipher, Decrypt, Encrypt, IntoAad, Unspecified, + Aad, Cipher, CipherText, ContextTag, Decipher, Decrypt, Element, Encrypt, IntoAad, Unspecified, }; diff --git a/packages/stack-encrypt/tests/roundtrip.rs b/packages/stack-encrypt/tests/roundtrip.rs index 8d512b5cb..ac79e8760 100644 --- a/packages/stack-encrypt/tests/roundtrip.rs +++ b/packages/stack-encrypt/tests/roundtrip.rs @@ -3,7 +3,7 @@ use std::collections::HashMap; -use stack_encrypt::{CipherText, ContextTag, ZeroKmsCipher}; +use stack_encrypt::{Aad, CipherText, ContextTag, Element, IntoAad, ZeroKmsCipher}; use stack_kms::FakeDataKeySource; use vitaminc_protected::{Controlled, Protected}; @@ -234,6 +234,100 @@ async fn sequence_element_cannot_be_rehomed_as_scalar() { ); } +#[tokio::test] +async fn element_roundtrips_under_bare_caller_aad() { + // `Element` derives `for_sequence_element` inside its own Encrypt/Decrypt + // impls. Both sides must honour that derivation: the decipher opens the leaf + // under the AAD the Decrypt drive supplies, not a pre-derived one. + let cipher = cipher(); + let ct = cipher + .encrypt(Element("row".to_string()), b"users".as_slice()) + .await + .expect("encrypt"); + let pt: Element = cipher + .decrypt(ct, b"users".as_slice()) + .await + .expect("Element must round-trip under the bare caller AAD"); + assert_eq!(pt.into_inner(), "row"); +} + +#[tokio::test] +async fn element_interchanges_with_vec_element() { + // A row sealed as one element of a `Vec` decrypts alone as `Element` + // under the same caller AAD (Element's documented use-case), and a lone + // `Element` ciphertext decrypts as a one-element `Vec`. + let cipher = cipher(); + let aad = b"users".as_slice(); + + let ct = cipher + .encrypt(vec!["a".to_string(), "b".to_string()], aad) + .await + .expect("encrypt"); + let second = match ct { + CipherText::Sequence(mut items) => items.remove(1), + other => other, + }; + let pt: Element = cipher + .decrypt(second, aad) + .await + .expect("spliced element must decrypt as Element"); + assert_eq!(pt.into_inner(), "b"); + + let lone = cipher + .encrypt(Element("c".to_string()), aad) + .await + .expect("encrypt"); + let wrapped = CipherText::Sequence(vec![lone]); + let pt: Vec = cipher + .decrypt(wrapped, aad) + .await + .expect("lone Element must decrypt as a one-element Vec"); + assert_eq!(pt, vec!["c".to_string()]); +} + +#[tokio::test] +async fn element_fails_under_wrong_caller_aad() { + let cipher = cipher(); + let ct = cipher + .encrypt(Element("row".to_string()), b"users".as_slice()) + .await + .expect("encrypt"); + let result: Result, _> = cipher.decrypt(ct, b"orders".as_slice()).await; + assert!( + result.is_err(), + "Element under the wrong caller AAD must not decrypt" + ); +} + +#[tokio::test] +async fn decipher_can_be_driven_directly() { + // `ZeroKmsCipher::decipher` mirrors `Aes256Cipher::decipher`: the returned + // Decipher is driven via `Decrypt::decrypt_with_aad` with a caller-chosen + // AAD, so manual derivations work too. + let cipher = cipher(); + let ct = cipher + .encrypt(Element("row".to_string()), b"users".as_slice()) + .await + .expect("encrypt"); + let decipher = cipher.decipher(ct).await.expect("retrieve keys"); + let pt = ::decrypt_with_aad( + decipher, + Aad::from_slice(b"users").for_sequence_element(), + ) + .expect("manual element derivation must open the leaf"); + assert_eq!(pt, "row"); + + // And a plain scalar opens under the bare AAD through the same path. + let ct = cipher + .encrypt("scalar".to_string(), b"ctx".as_slice()) + .await + .expect("encrypt"); + let decipher = cipher.decipher(ct).await.expect("retrieve keys"); + let pt = ::decrypt_with_aad(decipher, b"ctx".into_aad()) + .expect("decrypt"); + assert_eq!(pt, "scalar"); +} + #[tokio::test] async fn wrong_shape_fails() { // A scalar ciphertext must not decode as a sequence. From 6d413fd001907b934746eec2e35d61326831cea9 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Sun, 23 Aug 2026 22:41:26 +1000 Subject: [PATCH 411/686] fix(stack-encrypt): pin vitaminc rev, mark unpublished, zeroize seal error path, doc test-support - Cargo.toml: `publish = false` (release-plz processes any workspace crate without it, and these git deps have no registry version to publish against); pin `vitaminc-aead` / `vitaminc-protected` to the commit the lockfile already resolved (06fc3eaf) instead of `branch = "main"`, so a `cargo update` cannot silently move the Cipher/Decipher trait definitions under this crate. Cargo.lock source strings updated to match; no version change. - seal_leaf: keep the `risky_unwrap`ped plaintext in `Zeroizing` until `encrypt_in_place` succeeds, so the (practically unreachable) error path cannot leave plaintext on the heap; the ciphertext is taken out of the wrapper on success. - lib.rs: state that `FakeDataKeySource` requires stack-kms's `test-support` feature, in prose and in the doctest, so a user copying the docs.rs example doesn't hit an unresolved import. Claude-Session: https://claude.ai/code/session_01T5iYiJc6xwzMcHDtMwCadG --- packages/stack-encrypt/Cargo.toml | 20 +++++++++++++------- packages/stack-encrypt/src/cipher.rs | 13 +++++++++---- packages/stack-encrypt/src/lib.rs | 7 ++++++- 3 files changed, 28 insertions(+), 12 deletions(-) diff --git a/packages/stack-encrypt/Cargo.toml b/packages/stack-encrypt/Cargo.toml index 6be8c0e3d..3b33bbdcf 100644 --- a/packages/stack-encrypt/Cargo.toml +++ b/packages/stack-encrypt/Cargo.toml @@ -9,16 +9,22 @@ homepage.workspace = true keywords.workspace = true categories.workspace = true license-file = "LICENSE" +# Not yet released: keep release-plz from picking this crate up (it processes +# any workspace crate whose Cargo.toml lacks `publish = false`), and the git +# vitaminc deps below have no registry version to publish against anyway. +publish = false [dependencies] stack-kms = { path = "../stack-kms" } -# The revised Cipher/Decipher traits, ContextTag, and the PRF crates have all -# merged to vitaminc main but are not yet published (crates.io is 200+ commits -# behind). Pinned here rather than via the workspace dep so the rest of the -# suite stays on the published 0.2.0-pre until the next vitaminc release. -vitaminc-aead = { git = "https://github.com/cipherstash/vitaminc", branch = "main" } -vitaminc-protected = { git = "https://github.com/cipherstash/vitaminc", branch = "main" } +# The revised Cipher/Decipher traits, ContextTag, Element, and the PRF crates +# have all merged to vitaminc main but are not yet published (crates.io is 200+ +# commits behind). Pinned to a commit rather than `branch = "main"` so a +# `cargo update` cannot silently move the trait definitions under this crate, +# and declared here rather than via the workspace dep so the rest of the suite +# stays on the published 0.2.0-pre until the next vitaminc release. +vitaminc-aead = { git = "https://github.com/cipherstash/vitaminc", rev = "06fc3eaf8e39eab4aa9c6107c0ff2fc2952bebc3" } +vitaminc-protected = { git = "https://github.com/cipherstash/vitaminc", rev = "06fc3eaf8e39eab4aa9c6107c0ff2fc2952bebc3" } zeroize = { workspace = true } thiserror = { workspace = true } uuid = { workspace = true } @@ -28,7 +34,7 @@ aes-gcm-siv = "0.11.1" [dev-dependencies] stack-kms = { path = "../stack-kms", features = ["test-support"] } tokio = { workspace = true, features = ["rt", "macros"] } -vitaminc-protected = { git = "https://github.com/cipherstash/vitaminc", branch = "main" } +vitaminc-protected = { git = "https://github.com/cipherstash/vitaminc", rev = "06fc3eaf8e39eab4aa9c6107c0ff2fc2952bebc3" } [package.metadata.docs.rs] all-features = true diff --git a/packages/stack-encrypt/src/cipher.rs b/packages/stack-encrypt/src/cipher.rs index f091fa594..9ec85e627 100644 --- a/packages/stack-encrypt/src/cipher.rs +++ b/packages/stack-encrypt/src/cipher.rs @@ -68,6 +68,7 @@ use vitaminc_aead::{ MapCipher, SeqAccess, SeqCipher, Unspecified, }; use vitaminc_protected::{Controlled, Protected}; +use zeroize::Zeroizing; /// AES-256-GCM-SIV nonce length in bytes (the leading bytes of the ZeroKMS IV). const NONCE_LEN: usize = 12; @@ -448,15 +449,19 @@ fn seal_leaf( let nonce = GcmNonce::from_slice(&iv[..NONCE_LEN]); let aad = leaf_aad(aad, &key.tag); - // The plaintext bytes are overwritten in place by the ciphertext. - let mut buf = plaintext.risky_unwrap(); - aead.encrypt_in_place(nonce, &aad, &mut buf) + // The plaintext bytes are overwritten in place by the ciphertext. Until + // that succeeds the buffer still holds plaintext, so keep it `Zeroizing`: + // `risky_unwrap` surrenders `Protected`'s wipe-on-drop, and the error path + // must not leave the plaintext behind on the heap. + let mut buf = Zeroizing::new(plaintext.risky_unwrap()); + aead.encrypt_in_place(nonce, &aad, &mut *buf) .map_err(|_| Unspecified)?; Ok(DataKeyCipherText { iv, tag: key.tag, - ciphertext: buf, + // Now ciphertext; take it out and let the (empty) wrapper zeroize. + ciphertext: std::mem::take(&mut *buf), }) } diff --git a/packages/stack-encrypt/src/lib.rs b/packages/stack-encrypt/src/lib.rs index bfa068c41..08d95608b 100644 --- a/packages/stack-encrypt/src/lib.rs +++ b/packages/stack-encrypt/src/lib.rs @@ -26,11 +26,16 @@ //! with `vitaminc_encrypt::Aes256Cipher`. //! //! The data keys are sourced through a [`stack_kms::DataKeySource`] -//! (production: `stack_kms::StackKms`; tests: `stack_kms::FakeDataKeySource`). +//! (production: `stack_kms::StackKms`; tests: `stack_kms::FakeDataKeySource`, +//! available only with stack-kms's `test-support` feature — add +//! `stack-kms = { version = "..", features = ["test-support"] }` to your +//! `[dev-dependencies]`, as this crate does). //! //! ```no_run //! # async fn example() -> Result<(), stack_encrypt::Error> { //! use stack_encrypt::ZeroKmsCipher; +//! // Requires the `test-support` feature on `stack-kms` (see above); in +//! // production construct a `stack_kms::StackKms` instead. //! use stack_kms::FakeDataKeySource; //! //! let cipher = ZeroKmsCipher::new(FakeDataKeySource::new()); From e4e644f13ecf472a2655193997f8ad59e9bed222 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Sun, 23 Aug 2026 23:06:48 +1000 Subject: [PATCH 412/686] refactor(stack-encrypt): seal leaves with vitaminc-encrypt; rename to Stack*; persistable SealedValue; user-facing docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address review feedback on cipherstash/cipherstash-suite#2019: - Use vitaminc, not RustCrypto. Leaf sealing/opening now goes through `vitaminc_encrypt::Aes256Cipher` (AES-256-GCM; aws-lc-rs on native, RustCrypto only on wasm32 — vitaminc's backend choice), built per leaf from the ZeroKMS data key. The `aes-gcm-siv` dependency is gone, and with it `leaf_aad`, `NONCE_LEN`, and the Zeroizing dance in `seal_leaf`: the leaf AAD is simply the tuple `(derived_aad, tag)` passed straight to vitaminc (which PAE-encodes it), and vitaminc owns the plaintext buffer. The ZeroKMS IV is now a key identifier only; vitaminc supplies its own random nonce and versioned leaf layout. Cargo.lock: aws-lc-rs bumped 1.16.3 -> 1.18.0 to satisfy vitaminc-encrypt's `^1.17` (jsonwebtoken's `^1.15` still satisfied). - Naming: `ZeroKmsCipher` -> `StackCipher`, `ZeroKmsCipherText` -> `StackCipherText`, `ZeroKmsDecipher` -> `StackDecipher`, `DataKeyCipherText` -> `SealedValue`, `PendingCipherText` -> `PendingStackCipherText`. ZeroKMS is a key service, not a cipher. - Persistable ciphertext: `SealedValue` derives serde `Serialize`/`Deserialize`, and exposes `from_parts(iv, tag, bytes)` / `into_parts()` plus `iv()` / `tag()` / `ciphertext()` accessors, so a leaf can cross a process boundary and be rebuilt for decryption without retaining the original object. Tests cover parts and serde round-trips and a flipped-bit rejection. - Docs: the crate docs are rewritten for someone who wants to use the cipher (quick start against StackKms, AAD, testing with the fake, storing ciphertext, what is authenticated); vitaminc is mentioned only as the underlying engine. The internals (batching, AAD derivation, wire format) move to the private `cipher` module header. Claude-Session: https://claude.ai/code/session_01T5iYiJc6xwzMcHDtMwCadG --- packages/stack-encrypt/Cargo.toml | 8 +- packages/stack-encrypt/src/cipher.rs | 366 +++++++++++++--------- packages/stack-encrypt/src/lib.rs | 119 +++++-- packages/stack-encrypt/tests/roundtrip.rs | 77 ++++- 4 files changed, 379 insertions(+), 191 deletions(-) diff --git a/packages/stack-encrypt/Cargo.toml b/packages/stack-encrypt/Cargo.toml index 3b33bbdcf..2af1cb96e 100644 --- a/packages/stack-encrypt/Cargo.toml +++ b/packages/stack-encrypt/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "stack-encrypt" -description = "AES-256-GCM-SIV cipher implementing the vitaminc Cipher traits, keyed by ZeroKMS data keys" +description = "Encrypt Rust values under per-value ZeroKMS data keys via the vitaminc cipher traits" version = "0.1.0" edition.workspace = true authors.workspace = true @@ -24,14 +24,14 @@ stack-kms = { path = "../stack-kms" } # and declared here rather than via the workspace dep so the rest of the suite # stays on the published 0.2.0-pre until the next vitaminc release. vitaminc-aead = { git = "https://github.com/cipherstash/vitaminc", rev = "06fc3eaf8e39eab4aa9c6107c0ff2fc2952bebc3" } +vitaminc-encrypt = { git = "https://github.com/cipherstash/vitaminc", rev = "06fc3eaf8e39eab4aa9c6107c0ff2fc2952bebc3" } vitaminc-protected = { git = "https://github.com/cipherstash/vitaminc", rev = "06fc3eaf8e39eab4aa9c6107c0ff2fc2952bebc3" } -zeroize = { workspace = true } +serde = { workspace = true } thiserror = { workspace = true } uuid = { workspace = true } -aes-gcm-siv = "0.11.1" - [dev-dependencies] +serde_json = { workspace = true } stack-kms = { path = "../stack-kms", features = ["test-support"] } tokio = { workspace = true, features = ["rt", "macros"] } vitaminc-protected = { git = "https://github.com/cipherstash/vitaminc", rev = "06fc3eaf8e39eab4aa9c6107c0ff2fc2952bebc3" } diff --git a/packages/stack-encrypt/src/cipher.rs b/packages/stack-encrypt/src/cipher.rs index 9ec85e627..4d63fc073 100644 --- a/packages/stack-encrypt/src/cipher.rs +++ b/packages/stack-encrypt/src/cipher.rs @@ -1,27 +1,31 @@ -//! A vitaminc [`Cipher`]/[`Decipher`] keyed by ZeroKMS data keys, fetched lazily. +//! Implementation of [`StackCipher`]. For usage, start at the crate docs; this +//! module documents the internals. //! -//! [`ZeroKmsCipher`] mirrors the structure of `vitaminc_encrypt::Aes256Cipher`: -//! encrypting an [`Encrypt`] value produces a recursive ciphertext tree -//! ([`ZeroKmsCipherText`], the analog of `AesCipherText`). The difference is -//! *where the key comes from*: rather than a single fixed key, every leaf is -//! sealed under its own ZeroKMS data key. +//! `StackCipher` is a vitaminc [`Cipher`] whose per-leaf keys are ZeroKMS data +//! keys rather than one fixed key. Structurally it mirrors +//! `vitaminc_encrypt::Aes256Cipher`: encrypting an [`Encrypt`] value produces +//! a recursive ciphertext tree ([`StackCipherText`], the analog of +//! `AesCipherText`) whose leaves ([`SealedValue`]) each carry the ZeroKMS +//! metadata for their own data key. //! -//! Because data-key generation/retrieval is an async ZeroKMS round-trip, the -//! key fetch cannot happen inside the synchronous [`Cipher`]/[`Decipher`] trait -//! methods. It is front-loaded on both sides, and the AES work stays inside the +//! ## Batching the key fetch +//! +//! Data-key generation/retrieval is an async ZeroKMS round-trip, so the key +//! fetch cannot happen inside the synchronous [`Cipher`]/[`Decipher`] trait +//! methods. It is front-loaded on both sides; the AES work stays inside the //! trait drive: //! //! * **Encrypt** — driving the [`Cipher`] trait builds a *pending* tree -//! ([`PendingCipherText`]) that holds plaintext but does no I/O. A single -//! [`PendingCipherText::seal`] (or the [`ZeroKmsCipher::encrypt`] convenience) -//! then batches **one** `generate_keys` call for the whole tree and seals -//! every leaf. -//! * **Decrypt** — [`ZeroKmsCipher::decipher`] batches **one** `retrieve_keys` -//! call and zips each key onto its leaf, returning a [`ZeroKmsDecipher`]. The +//! ([`PendingStackCipherText`]) that holds plaintext plus each leaf's fully +//! derived AAD, but does no I/O. A single [`PendingStackCipherText::seal`] +//! (or the [`StackCipher::encrypt`] convenience) then batches **one** +//! `generate_keys` call for the whole tree and seals every leaf. +//! * **Decrypt** — [`StackCipher::decipher`] batches **one** `retrieve_keys` +//! call and zips each key onto its leaf, returning a [`StackDecipher`]. The //! value's [`Decrypt`] impl then drives that decipher exactly as it would //! `AesDecipher`: each leaf is opened under the AAD the drive supplies, so -//! the visitor pattern (arbitrary nested `Vec`/`HashMap`/`Option`/`Protected` -//! values, and AAD-deriving wrappers such as `vitaminc_aead::Element`) works +//! the visitor pattern (nested `Vec`/`HashMap`/`Option`/`Protected` values, +//! and AAD-deriving wrappers such as `vitaminc_aead::Element`) works //! identically to `Aes256Cipher`. //! //! ## AAD derivation @@ -36,19 +40,23 @@ //! sequences/maps under [`Aad::for_empty_sequence`]/[`Aad::for_empty_map`] — //! each sealing an *empty* plaintext, verified as empty on open. //! -//! The decrypt side performs the same derivations inside [`ZeroKmsDecipher`]'s +//! The decrypt side performs the same derivations inside [`StackDecipher`]'s //! `decrypt_seq`/`decrypt_map`/`decrypt_option` as the caller's `Decrypt` impl //! drives it, so there is a single source of truth for the per-node AAD. //! -//! ## Wire format +//! ## Leaf crypto and wire format //! -//! Each leaf stores the ZeroKMS `iv` and key `tag`. The AES-256-GCM-SIV nonce is -//! the first 12 bytes of the `iv`, and the AEAD AAD is the PAE-encoded tuple -//! `(derived_aad, tag)`. PAE (length-prefixed) encoding is injective, so -//! distinct `(derived_aad, tag)` pairs can never collide on the same AAD bytes. -//! The `tag` is always bound, so the ciphertext is cryptographically tied to its -//! ZeroKMS data key (key binding); a caller AAD (e.g. a +//! Each leaf ([`SealedValue`]) stores the ZeroKMS `iv` and key `tag` — enough +//! to retrieve the data key — plus a vitaminc [`LocalCipherText`] sealed under +//! that key by [`vitaminc_encrypt::Aes256Cipher`] (AES-256-GCM via vitaminc's +//! backend: `aws-lc-rs` on native, RustCrypto on wasm32; vitaminc's own random +//! nonce and versioned leaf layout). The leaf AAD is the tuple +//! `(derived_aad, tag)`, which vitaminc PAE-encodes so distinct pairs never +//! collide. The `tag` is always bound, so the ciphertext is cryptographically +//! tied to its ZeroKMS data key (key binding); a caller AAD (e.g. a //! [`ContextTag`](vitaminc_aead::ContextTag)) adds a further binding layer. +//! Every data key is requested with an empty descriptor: stack-encrypt does +//! not use descriptors. //! //! This is a fresh framing and is intentionally **not** byte-compatible with //! `cipherstash-client`'s `EncryptedRecord` AAD (a raw `descriptor || tag` @@ -59,33 +67,29 @@ use std::any::Any; use std::borrow::Cow; use std::collections::HashSet; -use aes_gcm_siv::aead::AeadInPlace; -use aes_gcm_siv::{Aes256GcmSiv, KeyInit, Nonce as GcmNonce}; +use serde::{Deserialize, Serialize}; use stack_kms::{DataKey, DataKeySource, DataKeyWithTag, GenerateKeyPayload, RetrieveKeyPayload}; use uuid::Uuid; use vitaminc_aead::{ - Aad, Cipher, CipherText, Decipher, DecipherVisitor, Decrypt, Encrypt, IntoAad, MapAccess, - MapCipher, SeqAccess, SeqCipher, Unspecified, + Aad, Cipher, CipherText, Decipher, DecipherVisitor, Decrypt, Encrypt, IntoAad, LocalCipherText, + MapAccess, MapCipher, SeqAccess, SeqCipher, Unspecified, }; +use vitaminc_encrypt::{Aes256Cipher, AesCipherText, Key as AesKey}; use vitaminc_protected::{Controlled, Protected}; -use zeroize::Zeroizing; - -/// AES-256-GCM-SIV nonce length in bytes (the leading bytes of the ZeroKMS IV). -const NONCE_LEN: usize = 12; /// The passthrough payload type: type-erased, as for Rust-native vitaminc /// ciphers. Callers box on the way in and downcast on the way out. pub type BoxedPassthrough = Box; -/// The recursive ciphertext container produced by [`ZeroKmsCipher`]: vitaminc's -/// generic [`CipherText`] tree over [`DataKeyCipherText`] leaves. Its shape +/// The recursive ciphertext container produced by [`StackCipher`]: vitaminc's +/// generic [`CipherText`] tree over [`SealedValue`] leaves. Its shape /// mirrors the encrypted plaintext: a scalar yields `Single`, a `Vec` yields /// `Sequence` (or `EmptySequence`), a `HashMap` or struct yields `Map` (or /// `EmptyMap`). Map keys are stored in the clear but bound into their value's /// AAD. -pub type ZeroKmsCipherText = CipherText; +pub type StackCipherText = CipherText; -/// Errors from sealing or opening a [`ZeroKmsCipherText`]. +/// Errors from sealing or opening a [`StackCipherText`]. #[derive(Debug, thiserror::Error)] pub enum Error { /// A ZeroKMS data-key generate/retrieve call failed. @@ -114,12 +118,12 @@ impl From for Error { /// Per-leaf keying is deliberate: every value access requires its own data-key /// retrieval, so individual value accesses are visible (and auditable) as /// ZeroKMS key-retrieval events. -pub struct ZeroKmsCipher { +pub struct StackCipher { kms: K, keyset_id: Option, } -impl ZeroKmsCipher { +impl StackCipher { /// Create a cipher over the given data-key source, using the source's /// default keyset. pub fn new(kms: K) -> Self { @@ -136,10 +140,10 @@ impl ZeroKmsCipher { } } -impl ZeroKmsCipher { +impl StackCipher { /// Encrypt a value, binding `aad`, and seal it against fresh ZeroKMS data /// keys in a single batched `generate_keys` call. - pub async fn encrypt<'a, T, A>(&self, value: T, aad: A) -> Result + pub async fn encrypt<'a, T, A>(&self, value: T, aad: A) -> Result where T: Encrypt, A: IntoAad<'a>, @@ -148,13 +152,13 @@ impl ZeroKmsCipher { pending.seal(self).await } - /// Decrypt a [`ZeroKmsCipherText`] into `T`, authenticating against `aad`. + /// Decrypt a [`StackCipherText`] into `T`, authenticating against `aad`. /// /// Thin wrapper over [`decipher`](Self::decipher): one batched /// `retrieve_keys` call, then `T`'s [`Decrypt`] impl drives the returned - /// [`ZeroKmsDecipher`] with `aad` — exactly as `Aes256Cipher::decrypt_with_aad` + /// [`StackDecipher`] with `aad` — exactly as `Aes256Cipher::decrypt_with_aad` /// drives `AesDecipher`. - pub async fn decrypt<'a, T, A>(&self, ciphertext: ZeroKmsCipherText, aad: A) -> Result + pub async fn decrypt<'a, T, A>(&self, ciphertext: StackCipherText, aad: A) -> Result where T: Decrypt<'static> + 'static, A: IntoAad<'a>, @@ -172,7 +176,7 @@ impl ZeroKmsCipher { /// is front-loaded here, and the AAD is supplied per call by /// [`Decrypt::decrypt_with_aad`], so `Decrypt` impls that derive their own /// AAD (e.g. `vitaminc_aead::Element`) behave identically to `AesDecipher`. - pub async fn decipher(&self, ciphertext: ZeroKmsCipherText) -> Result { + pub async fn decipher(&self, ciphertext: StackCipherText) -> Result { // Collect every leaf's retrieve payload (borrowing the ciphertext), make // one batched call, then drop the borrow before consuming the tree. let keys = { @@ -203,28 +207,77 @@ impl ZeroKmsCipher { if keys.next().is_some() { return Err(Error::Aead); } - Ok(ZeroKmsDecipher { ciphertext }) + Ok(StackDecipher { ciphertext }) } } -/// A single leaf: the ZeroKMS metadata needed to re-derive the key plus the -/// AES-256-GCM-SIV ciphertext (`ciphertext || gcm_tag`). -#[derive(Debug, Clone)] -pub struct DataKeyCipherText { - /// ZeroKMS IV. Its first [`NONCE_LEN`] bytes are the AEAD nonce, and the - /// full IV is needed to retrieve the data key. +/// A single sealed leaf: the ZeroKMS metadata needed to retrieve its data key +/// (`iv`, `tag`) plus the vitaminc [`LocalCipherText`] sealed under that key. +/// +/// This is the only byte-format commitment the crate makes — the container +/// tree ([`StackCipherText`]) has no canonical encoding, so callers that +/// persist or transmit ciphertext serialise leaves (it derives `serde` +/// `Serialize`/`Deserialize`, or use [`into_parts`](Self::into_parts) / +/// [`from_parts`](Self::from_parts)) and rebuild the tree around them. +#[derive(Debug, Serialize, Deserialize)] +pub struct SealedValue { + /// ZeroKMS IV: identifies the data key for retrieval. iv: stack_kms::Iv, - /// ZeroKMS key tag: required to retrieve the key and used as the AEAD AAD. + /// ZeroKMS key tag: required to retrieve the key, and bound into the + /// leaf's AAD so the ciphertext is tied to its data key. tag: Vec, - /// AES-256-GCM-SIV output: ciphertext with the 16-byte auth tag appended. - ciphertext: Vec, + /// The leaf sealed by [`vitaminc_encrypt::Aes256Cipher`] under the data + /// key: `version ‖ nonce ‖ ciphertext ‖ gcm_tag`. + ciphertext: LocalCipherText, +} + +impl SealedValue { + /// Rebuild a leaf from its persisted parts — the inverse of + /// [`into_parts`](Self::into_parts). + pub fn from_parts(iv: stack_kms::Iv, tag: Vec, ciphertext: Vec) -> Self { + Self { + iv, + tag, + ciphertext: LocalCipherText::from(ciphertext), + } + } + + /// Decompose into `(iv, tag, ciphertext)` for persistence. + pub fn into_parts(self) -> (stack_kms::Iv, Vec, Vec) { + (self.iv, self.tag, self.ciphertext.into_inner().to_vec()) + } + + /// The ZeroKMS IV identifying this leaf's data key. + pub fn iv(&self) -> &stack_kms::Iv { + &self.iv + } + + /// The ZeroKMS key tag. + pub fn tag(&self) -> &[u8] { + &self.tag + } + + /// The sealed bytes (`version ‖ nonce ‖ ciphertext ‖ gcm_tag`). + pub fn ciphertext(&self) -> &[u8] { + self.ciphertext.as_ref() + } +} + +impl Clone for SealedValue { + fn clone(&self) -> Self { + Self { + iv: self.iv, + tag: self.tag.clone(), + ciphertext: LocalCipherText::from(self.ciphertext.as_ref().to_vec()), + } + } } /// Walk the tree in depth-first order, pushing one retrieve payload per keyed /// leaf (markers included). Must match [`bind_keys`]'s traversal so payloads /// and returned keys line up. fn collect_retrieve_payloads<'b>( - ciphertext: &'b ZeroKmsCipherText, + ciphertext: &'b StackCipherText, out: &mut Vec>, ) { match ciphertext { @@ -251,25 +304,25 @@ fn collect_retrieve_payloads<'b>( /// A leaf with its retrieved data key bound alongside. Produced by /// [`bind_keys`] once the batched `retrieve_keys` call has returned; consumed by -/// [`ZeroKmsDecipher`], which opens it under whatever AAD the driving +/// [`StackDecipher`], which opens it under whatever AAD the driving /// [`Decrypt`] impl supplies. struct KeyedLeaf { - leaf: DataKeyCipherText, + leaf: SealedValue, key: DataKey, } -/// [`ZeroKmsCipherText`] with a [`DataKey`] zipped onto every keyed leaf. +/// [`StackCipherText`] with a [`DataKey`] zipped onto every keyed leaf. type KeyedCipherText = CipherText; /// Zip retrieved keys onto the tree in the same depth-first order /// [`collect_retrieve_payloads`] requested them, so each leaf carries its own /// key and the subsequent [`Decipher`] drive is free of ordering assumptions. fn bind_keys( - ciphertext: ZeroKmsCipherText, + ciphertext: StackCipherText, keys: &mut impl Iterator, ) -> Result { fn bind( - leaf: DataKeyCipherText, + leaf: SealedValue, keys: &mut impl Iterator, ) -> Result { let key = keys.next().ok_or(Unspecified)?; @@ -306,20 +359,20 @@ fn bind_keys( /// The intermediate result of driving the [`Cipher`] trait: a tree that holds /// plaintext (and the AAD each leaf will be sealed against, fully derived) but /// has done no ZeroKMS I/O. [`seal`](Self::seal) turns it into a -/// [`ZeroKmsCipherText`]. -pub enum PendingCipherText { +/// [`StackCipherText`]. +pub enum PendingStackCipherText { /// A scalar awaiting a data key, with its bound (derived) AAD. Single { plaintext: Protected>, aad: Aad<'static>, }, /// A pending sequence with at least one element. - Sequence(Vec), + Sequence(Vec), /// A pending empty-sequence marker; `aad` is already the /// [`Aad::for_empty_sequence`] derivation. EmptySequence { aad: Aad<'static> }, /// A pending map with at least one entry. - Map(Vec<(String, PendingCipherText)>), + Map(Vec<(String, PendingStackCipherText)>), /// A pending empty-map marker; `aad` is already the [`Aad::for_empty_map`] /// derivation. EmptyMap { aad: Aad<'static> }, @@ -330,18 +383,20 @@ pub enum PendingCipherText { Passthrough(BoxedPassthrough), } -impl PendingCipherText { +impl PendingStackCipherText { /// Number of leaves that need a ZeroKMS data key (everything but /// passthrough — markers are sealed leaves too). fn key_count(&self) -> usize { match self { - PendingCipherText::Single { .. } - | PendingCipherText::None { .. } - | PendingCipherText::EmptySequence { .. } - | PendingCipherText::EmptyMap { .. } => 1, - PendingCipherText::Sequence(items) => items.iter().map(Self::key_count).sum(), - PendingCipherText::Map(entries) => entries.iter().map(|(_, v)| v.key_count()).sum(), - PendingCipherText::Passthrough(_) => 0, + PendingStackCipherText::Single { .. } + | PendingStackCipherText::None { .. } + | PendingStackCipherText::EmptySequence { .. } + | PendingStackCipherText::EmptyMap { .. } => 1, + PendingStackCipherText::Sequence(items) => items.iter().map(Self::key_count).sum(), + PendingStackCipherText::Map(entries) => { + entries.iter().map(|(_, v)| v.key_count()).sum() + } + PendingStackCipherText::Passthrough(_) => 0, } } @@ -349,8 +404,8 @@ impl PendingCipherText { /// the whole tree. pub async fn seal( self, - cipher: &ZeroKmsCipher, - ) -> Result { + cipher: &StackCipher, + ) -> Result { let count = self.key_count(); if count == 0 { // Passthrough-only tree: no keys, no ZeroKMS call. @@ -382,44 +437,44 @@ impl PendingCipherText { fn seal_with( self, keys: &mut impl Iterator, - ) -> Result { + ) -> Result { // Markers seal an *empty* plaintext so the AEAD tag still binds their // (already domain-separated) AAD, mirroring `Aes256Cipher`. fn seal_marker( aad: Aad<'static>, keys: &mut impl Iterator, - ) -> Result { + ) -> Result { let key = keys.next().ok_or(Unspecified)?; seal_leaf(Protected::new(Vec::new()), &aad, key) } match self { - PendingCipherText::Single { plaintext, aad } => { + PendingStackCipherText::Single { plaintext, aad } => { let key = keys.next().ok_or(Unspecified)?; Ok(CipherText::Single(seal_leaf(plaintext, &aad, key)?)) } - PendingCipherText::None { aad } => Ok(CipherText::None(seal_marker(aad, keys)?)), - PendingCipherText::EmptySequence { aad } => { + PendingStackCipherText::None { aad } => Ok(CipherText::None(seal_marker(aad, keys)?)), + PendingStackCipherText::EmptySequence { aad } => { Ok(CipherText::EmptySequence(seal_marker(aad, keys)?)) } - PendingCipherText::EmptyMap { aad } => { + PendingStackCipherText::EmptyMap { aad } => { Ok(CipherText::EmptyMap(seal_marker(aad, keys)?)) } - PendingCipherText::Sequence(items) => { + PendingStackCipherText::Sequence(items) => { let mut out = Vec::with_capacity(items.len()); for item in items { out.push(item.seal_with(keys)?); } Ok(CipherText::Sequence(out)) } - PendingCipherText::Map(entries) => { + PendingStackCipherText::Map(entries) => { let mut out = Vec::with_capacity(entries.len()); for (k, v) in entries { out.push((k, v.seal_with(keys)?)); } Ok(CipherText::Map(out)) } - PendingCipherText::Passthrough(value) => Ok(CipherText::Passthrough(value)), + PendingStackCipherText::Passthrough(value) => Ok(CipherText::Passthrough(value)), } } } @@ -428,54 +483,56 @@ impl PendingCipherText { // Leaf crypto // ============================================================================= -/// Compose the AEAD AAD as the PAE-encoded tuple `(derived_aad, tag)`. +/// Build the per-leaf vitaminc cipher from a ZeroKMS data key. /// -/// PAE (length-prefixed) encoding is injective, so distinct `(derived_aad, tag)` -/// pairs can never collide on the same AAD bytes — unlike a raw concatenation, -/// which is only unambiguous when the tag has a fixed length. `tag` is always -/// bound, so the leaf is cryptographically tied to its ZeroKMS data key. -fn leaf_aad(derived_aad: &Aad<'_>, tag: &[u8]) -> Vec { - (derived_aad.as_bytes(), tag).into_aad().as_bytes().to_vec() +/// Each leaf has its own data key, so each leaf gets its own +/// [`Aes256Cipher`] (and with it a fresh random nonce — the ZeroKMS IV is a +/// key identifier only, never reused as a nonce). +fn leaf_cipher(key: &DataKey) -> Result { + // `Key::from` moves the 32 bytes straight into a `Protected`; the source + // `DataKey` is wiped on its own drop. + Aes256Cipher::new(&AesKey::from(*key.key())) } /// Seal one plaintext leaf under a freshly generated data key. +/// +/// The AAD is the tuple `(derived_aad, tag)` — vitaminc PAE-encodes tuples, +/// so distinct pairs never collide, and `tag` is always bound: the leaf is +/// cryptographically tied to its ZeroKMS data key. fn seal_leaf( plaintext: Protected>, aad: &Aad<'_>, key: DataKeyWithTag, -) -> Result { +) -> Result { let iv = key.key.iv; - let aead = Aes256GcmSiv::new_from_slice(key.key.key()).map_err(|_| Unspecified)?; - let nonce = GcmNonce::from_slice(&iv[..NONCE_LEN]); - let aad = leaf_aad(aad, &key.tag); - - // The plaintext bytes are overwritten in place by the ciphertext. Until - // that succeeds the buffer still holds plaintext, so keep it `Zeroizing`: - // `risky_unwrap` surrenders `Protected`'s wipe-on-drop, and the error path - // must not leave the plaintext behind on the heap. - let mut buf = Zeroizing::new(plaintext.risky_unwrap()); - aead.encrypt_in_place(nonce, &aad, &mut *buf) - .map_err(|_| Unspecified)?; - - Ok(DataKeyCipherText { - iv, - tag: key.tag, - // Now ciphertext; take it out and let the (empty) wrapper zeroize. - ciphertext: std::mem::take(&mut *buf), - }) + let cipher = leaf_cipher(&key.key)?; + match (&cipher).encrypt_bytes_vec(plaintext, (aad.as_bytes(), key.tag.as_slice()))? { + AesCipherText::Single(ciphertext) => Ok(SealedValue { + iv, + tag: key.tag, + ciphertext, + }), + _ => Err(Unspecified), + } } /// Open one keyed leaf under `aad`, returning the plaintext bytes. fn open_leaf(keyed: KeyedLeaf, aad: &Aad<'_>) -> Result>, Unspecified> { + /// Keeps the recovered bytes inside `Protected` across the visitor + /// boundary (the blanket `Decrypt for Vec` would unwrap them). + struct ProtectedBytes; + impl<'c> DecipherVisitor<'c> for ProtectedBytes { + type Value = Protected>; + fn visit_bytes_vec(self, data: Protected>) -> Result { + Ok(data) + } + } + let KeyedLeaf { leaf, key } = keyed; - let aead = Aes256GcmSiv::new_from_slice(key.key()).map_err(|_| Unspecified)?; - let nonce = GcmNonce::from_slice(&leaf.iv[..NONCE_LEN]); - let aad = leaf_aad(aad, &leaf.tag); - - let mut buf = leaf.ciphertext; - aead.decrypt_in_place(nonce, &aad, &mut buf) - .map_err(|_| Unspecified)?; - Ok(Protected::new(buf)) + let cipher = leaf_cipher(&key)?; + cipher + .decipher(AesCipherText::Single(leaf.ciphertext)) + .decrypt_bytes(ProtectedBytes, (aad.as_bytes(), leaf.tag.as_slice())) } /// Open one marker leaf (absent / empty-sequence / empty-map) and require the @@ -491,11 +548,11 @@ fn verify_empty_marker(keyed: KeyedLeaf, aad: &Aad<'_>) -> Result<(), Unspecifie } // ============================================================================= -// Encrypt side: `Cipher` impl over a `&ZeroKmsCipher` (builds the pending tree) +// Encrypt side: `Cipher` impl over a `&StackCipher` (builds the pending tree) // ============================================================================= -impl<'c, K> Cipher for &'c ZeroKmsCipher { - type Ok = PendingCipherText; +impl<'c, K> Cipher for &'c StackCipher { + type Ok = PendingStackCipherText; type Error = Unspecified; type Passthrough = BoxedPassthrough; type SeqCipher = PendingSeqCipher<'c, K>; @@ -509,7 +566,7 @@ impl<'c, K> Cipher for &'c ZeroKmsCipher { where A: IntoAad<'a>, { - Ok(PendingCipherText::Single { + Ok(PendingStackCipherText::Single { plaintext: data, aad: aad.into_aad().into_owned(), }) @@ -550,13 +607,13 @@ impl<'c, K> Cipher for &'c ZeroKmsCipher { { // Domain-separated so a `Single` leaf sealed under the bare AAD can // never be re-tagged as an authenticated absence (and vice versa). - Ok(PendingCipherText::None { + Ok(PendingStackCipherText::None { aad: aad.into_aad().for_none(), }) } fn passthrough(self, value: Self::Passthrough) -> Result { - Ok(PendingCipherText::Passthrough(value)) + Ok(PendingStackCipherText::Passthrough(value)) } fn passthrough_boxed( @@ -572,8 +629,8 @@ impl<'c, K> Cipher for &'c ZeroKmsCipher { /// [`SeqCipher`] driver: accumulates a pending sub-tree per element. Holds the /// cipher only to re-drive nested [`Encrypt`] values (no I/O happens here). pub struct PendingSeqCipher<'c, K> { - cipher: &'c ZeroKmsCipher, - items: Vec, + cipher: &'c StackCipher, + items: Vec, /// The AAD fixed at [`Cipher::encrypt_seq`]; the empty marker is sealed /// against its `for_empty_sequence` derivation. aad: Aad<'static>, @@ -587,7 +644,7 @@ pub struct PendingSeqCipher<'c, K> { } impl<'c, K> SeqCipher for PendingSeqCipher<'c, K> { - type Ok = PendingCipherText; + type Ok = PendingStackCipherText; type Error = Unspecified; type Passthrough = BoxedPassthrough; @@ -601,19 +658,19 @@ impl<'c, K> SeqCipher for PendingSeqCipher<'c, K> { // A nested `Encrypt` impl may route through the passthrough channel; // only a genuinely pending-sealed node may satisfy `end`'s // all-passthrough rejection. - self.encrypted |= !matches!(pending, PendingCipherText::Passthrough(_)); + self.encrypted |= !matches!(pending, PendingStackCipherText::Passthrough(_)); self.items.push(pending); Ok(self) } fn passthrough_next(mut self, value: Self::Passthrough) -> Result { - self.items.push(PendingCipherText::Passthrough(value)); + self.items.push(PendingStackCipherText::Passthrough(value)); Ok(self) } fn end(self) -> Result { if self.items.is_empty() { - Ok(PendingCipherText::EmptySequence { + Ok(PendingStackCipherText::EmptySequence { aad: self.aad.for_empty_sequence(), }) } else if !self.encrypted { @@ -622,7 +679,7 @@ impl<'c, K> SeqCipher for PendingSeqCipher<'c, K> { // `decrypt_tree`'s rejection on open. Err(Unspecified) } else { - Ok(PendingCipherText::Sequence(self.items)) + Ok(PendingStackCipherText::Sequence(self.items)) } } } @@ -631,8 +688,8 @@ impl<'c, K> SeqCipher for PendingSeqCipher<'c, K> { /// sub-trees sealed against [`Aad::for_map_entry`] of the map AAD and their /// key. Mirrors `AesMapCipher`'s key/value and duplicate-key contract checks. pub struct PendingMapCipher<'c, K> { - cipher: &'c ZeroKmsCipher, - entries: Vec<(String, PendingCipherText)>, + cipher: &'c StackCipher, + entries: Vec<(String, PendingStackCipherText)>, /// Duplicate keys are rejected at encrypt time: `decrypt_tree` rejects /// them outright, so accepting one here would produce a permanently /// unreadable ciphertext. @@ -645,7 +702,7 @@ pub struct PendingMapCipher<'c, K> { } impl<'c, K> MapCipher for PendingMapCipher<'c, K> { - type Ok = PendingCipherText; + type Ok = PendingStackCipherText; type Error = Unspecified; type Passthrough = BoxedPassthrough; @@ -675,7 +732,7 @@ impl<'c, K> MapCipher for PendingMapCipher<'c, K> { // `decrypt_tree` derives the same AAD on open. let entry_aad = self.aad.for_map_entry(&key); let pending = value.encrypt_with_aad(self.cipher, entry_aad)?; - self.encrypted |= !matches!(pending, PendingCipherText::Passthrough(_)); + self.encrypted |= !matches!(pending, PendingStackCipherText::Passthrough(_)); self.entries.push((key.into_owned(), pending)); Ok(self) } @@ -694,7 +751,7 @@ impl<'c, K> MapCipher for PendingMapCipher<'c, K> { return Err(Unspecified); } self.entries - .push((key.into_owned(), PendingCipherText::Passthrough(value))); + .push((key.into_owned(), PendingStackCipherText::Passthrough(value))); Ok(self) } @@ -704,14 +761,14 @@ impl<'c, K> MapCipher for PendingMapCipher<'c, K> { return Err(Unspecified); } if self.entries.is_empty() { - Ok(PendingCipherText::EmptyMap { + Ok(PendingStackCipherText::EmptyMap { aad: self.aad.for_empty_map(), }) } else if !self.encrypted { // Every entry is a passthrough — see `PendingSeqCipher::end`. Err(Unspecified) } else { - Ok(PendingCipherText::Map(self.entries)) + Ok(PendingStackCipherText::Map(self.entries)) } } } @@ -719,8 +776,8 @@ impl<'c, K> MapCipher for PendingMapCipher<'c, K> { // Decrypt side: a synchronous `Decipher` over a key-bound ciphertext tree // ============================================================================= -/// A [`Decipher`] over a single [`ZeroKmsCipherText`] whose leaves already -/// carry their retrieved data keys, produced by [`ZeroKmsCipher::decipher`]. +/// A [`Decipher`] over a single [`StackCipherText`] whose leaves already +/// carry their retrieved data keys, produced by [`StackCipher::decipher`]. /// /// Structurally identical to `vitaminc_encrypt::AesDecipher` — the only /// difference is where each leaf's key comes from. The AAD is supplied per call @@ -730,11 +787,11 @@ impl<'c, K> MapCipher for PendingMapCipher<'c, K> { /// respective derivations with an enforced-empty plaintext. Because the /// derivation lives in this drive (not in a pre-pass), `Decrypt` impls that /// transform the AAD themselves (e.g. `vitaminc_aead::Element`) work unchanged. -pub struct ZeroKmsDecipher { +pub struct StackDecipher { ciphertext: KeyedCipherText, } -impl ZeroKmsDecipher { +impl StackDecipher { fn over(ciphertext: KeyedCipherText) -> Self { Self { ciphertext } } @@ -754,7 +811,7 @@ impl ZeroKmsDecipher { } } -impl<'c> Decipher<'c> for ZeroKmsDecipher { +impl<'c> Decipher<'c> for StackDecipher { type Ok = Result where @@ -801,7 +858,7 @@ impl<'c> Decipher<'c> for ZeroKmsDecipher { .iter() .any(|i| !matches!(i, CipherText::Passthrough(_))) => { - visitor.visit_seq(ZeroKmsSeqAccess { + visitor.visit_seq(StackSeqAccess { items: items.into_iter(), element_aad: aad.into_aad().for_sequence_element(), }) @@ -812,7 +869,7 @@ impl<'c> Decipher<'c> for ZeroKmsDecipher { // Store the element derivation exactly as the non-empty arm // does: never read (the iterator is empty), but a divergent // value here would silently break a visitor that consulted it. - visitor.visit_seq(ZeroKmsSeqAccess { + visitor.visit_seq(StackSeqAccess { items: Vec::new().into_iter(), element_aad: aad.for_sequence_element(), }) @@ -842,7 +899,7 @@ impl<'c> Decipher<'c> for ZeroKmsDecipher { if !entries.iter().all(|(key, _)| seen.insert(key.as_str())) { return Err(Unspecified); } - visitor.visit_map(ZeroKmsMapAccess { + visitor.visit_map(StackMapAccess { entries: entries.into_iter(), aad: aad.into_aad(), }) @@ -851,7 +908,7 @@ impl<'c> Decipher<'c> for ZeroKmsDecipher { let aad = aad.into_aad(); verify_empty_marker(keyed, &aad.for_empty_map())?; // Raw caller AAD, not the marker derivation — see `decrypt_seq`. - visitor.visit_map(ZeroKmsMapAccess { + visitor.visit_map(StackMapAccess { entries: Vec::new().into_iter(), aad, }) @@ -922,7 +979,7 @@ impl<'c> Decipher<'c> for ZeroKmsDecipher { } } -struct ZeroKmsSeqAccess { +struct StackSeqAccess { items: std::vec::IntoIter, /// [`Aad::for_sequence_element`] of the caller's AAD, derived once at /// construction and re-supplied per element by borrowing. Mirrors @@ -930,24 +987,25 @@ struct ZeroKmsSeqAccess { element_aad: Aad<'static>, } -impl<'c> SeqAccess<'c> for ZeroKmsSeqAccess { +impl<'c> SeqAccess<'c> for StackSeqAccess { type Error = Unspecified; fn next_element + 'c>(&mut self) -> Result, Self::Error> { match self.items.next() { - Some(ct) => T::decrypt_with_aad(ZeroKmsDecipher::over(ct), self.element_aad.as_bytes()) - .map(Some), + Some(ct) => { + T::decrypt_with_aad(StackDecipher::over(ct), self.element_aad.as_bytes()).map(Some) + } None => Ok(None), } } } -struct ZeroKmsMapAccess<'a> { +struct StackMapAccess<'a> { entries: std::vec::IntoIter<(String, KeyedCipherText)>, aad: Aad<'a>, } -impl<'c, 'a> MapAccess<'c> for ZeroKmsMapAccess<'a> { +impl<'c, 'a> MapAccess<'c> for StackMapAccess<'a> { type Error = Unspecified; fn next_entry + 'c>(&mut self) -> Result, Self::Error> { @@ -957,7 +1015,7 @@ impl<'c, 'a> MapAccess<'c> for ZeroKmsMapAccess<'a> { // against `for_map_entry(key)`, so a swapped or renamed key // fails here. let entry_aad = self.aad.for_map_entry(&key); - let value = T::decrypt_with_aad(ZeroKmsDecipher::over(ct), entry_aad)?; + let value = T::decrypt_with_aad(StackDecipher::over(ct), entry_aad)?; Ok(Some((key, value))) } None => Ok(None), diff --git a/packages/stack-encrypt/src/lib.rs b/packages/stack-encrypt/src/lib.rs index 08d95608b..d78bf65d5 100644 --- a/packages/stack-encrypt/src/lib.rs +++ b/packages/stack-encrypt/src/lib.rs @@ -11,48 +11,111 @@ clippy::todo, clippy::unimplemented )] -//! `stack-encrypt` bridges ZeroKMS data keys into the vitaminc encryption -//! ecosystem. -//! -//! [`ZeroKmsCipher`] implements the vitaminc [`Cipher`](vitaminc_aead::Cipher) -//! trait and decrypts via the [`Decrypt`](vitaminc_aead::Decrypt) trait, but — -//! unlike a fixed-key cipher — every leaf of a value is sealed under its own -//! ZeroKMS data key, fetched lazily. Encrypting builds a pending tree with no -//! I/O; a single batched `generate_keys` call then seals it. Decrypting batches -//! one `retrieve_keys` call, binds each key onto its leaf, and hands the value's -//! `Decrypt` impl a [`ZeroKmsDecipher`] that opens leaves as it is driven — so -//! arbitrary nested `Vec` / `HashMap` / `Option` / `Protected` values (and -//! AAD-deriving wrappers like `vitaminc_aead::Element`) round-trip exactly as -//! with `vitaminc_encrypt::Aes256Cipher`. -//! -//! The data keys are sourced through a [`stack_kms::DataKeySource`] -//! (production: `stack_kms::StackKms`; tests: `stack_kms::FakeDataKeySource`, -//! available only with stack-kms's `test-support` feature — add -//! `stack-kms = { version = "..", features = ["test-support"] }` to your -//! `[dev-dependencies]`, as this crate does). +//! Encrypt Rust values under per-value ZeroKMS data keys. +//! +//! [`StackCipher`] encrypts any value that implements [`Encrypt`] (`String`, +//! `Vec`, `HashMap`, `Option`, `Protected`, your own types, and +//! any nesting of them) and decrypts back into any [`Decrypt`] type. Every +//! scalar inside the value is sealed under its **own** ZeroKMS data key, so each +//! value access is an individually auditable key retrieval — there is no +//! long-lived key in your process. +//! +//! # Quick start //! //! ```no_run -//! # async fn example() -> Result<(), stack_encrypt::Error> { -//! use stack_encrypt::ZeroKmsCipher; -//! // Requires the `test-support` feature on `stack-kms` (see above); in -//! // production construct a `stack_kms::StackKms` instead. -//! use stack_kms::FakeDataKeySource; +//! # async fn example() -> Result<(), Box> { +//! use stack_encrypt::StackCipher; +//! use stack_kms::StackKmsBuilder; //! -//! let cipher = ZeroKmsCipher::new(FakeDataKeySource::new()); +//! // Credentials and the client key come from the environment +//! // (CS_CLIENT_ID / CS_CLIENT_KEY, plus an access token strategy). +//! let kms = StackKmsBuilder::auto()? +//! .with_key_provider(stack_kms::EnvKeyProvider) +//! .build() +//! .await?; +//! let cipher = StackCipher::new(kms); //! //! let ciphertext = cipher.encrypt("secret message".to_string(), ()).await?; //! let plaintext: String = cipher.decrypt(ciphertext, ()).await?; -//! //! assert_eq!(plaintext, "secret message"); //! # Ok(()) //! # } //! ``` +//! +//! The second argument is the *associated data* (AAD): anything that implements +//! [`IntoAad`] — `()`, `&[u8]`, `&str`, a tuple, or a derived [`Aad`]. It is +//! authenticated, not encrypted, and must be supplied identically on decrypt. +//! Use it to bind a ciphertext to its context (a table name, a tenant, a record +//! id) so it cannot be replayed elsewhere: +//! +//! ```no_run +//! # async fn example(cipher: stack_encrypt::StackCipher) -> Result<(), stack_encrypt::Error> { +//! let ct = cipher.encrypt("4111 1111 1111 1111".to_string(), "users/42/card").await?; +//! let card: String = cipher.decrypt(ct, "users/42/card").await?; // ok +//! # Ok(()) +//! # } +//! ``` +//! +//! # Testing without ZeroKMS +//! +//! `stack_kms::FakeDataKeySource` is a deterministic in-process key source that +//! needs no credentials or network. It lives behind stack-kms's `test-support` +//! feature, so add +//! `stack-kms = { version = "..", features = ["test-support"] }` to your +//! `[dev-dependencies]`: +//! +//! ```no_run +//! # async fn example() -> Result<(), stack_encrypt::Error> { +//! use stack_encrypt::StackCipher; +//! use stack_kms::FakeDataKeySource; +//! +//! let cipher = StackCipher::new(FakeDataKeySource::new()); +//! let ct = cipher.encrypt(vec!["a".to_string(), "b".to_string()], ()).await?; +//! let pt: Vec = cipher.decrypt(ct, ()).await?; +//! assert_eq!(pt, vec!["a", "b"]); +//! # Ok(()) +//! # } +//! ``` +//! +//! # Storing ciphertext +//! +//! [`encrypt`](StackCipher::encrypt) returns a [`StackCipherText`]: a tree whose +//! shape mirrors the value (a scalar is a single leaf, a `Vec` a sequence of +//! leaves, a map a set of named leaves) and whose leaves are [`SealedValue`]s. +//! A `SealedValue` is the persistable unit — it implements `serde` +//! `Serialize`/`Deserialize` and offers [`into_parts`](SealedValue::into_parts) +//! / [`from_parts`](SealedValue::from_parts) for callers that manage their own +//! storage format. Map keys are stored in the clear (and authenticated); +//! nothing else about a value is visible without its data keys. +//! +//! # What is authenticated +//! +//! Besides your AAD, the *shape* of a value is authenticated: an element cannot +//! be spliced out of a sequence and passed off as a scalar, a map value cannot +//! be moved under a different key, and "absent" / "empty" are themselves +//! sealed markers rather than inferable from structure. A tampered, re-homed, +//! or wrong-context ciphertext fails with [`Error::Aead`]; a failed or denied +//! key retrieval surfaces as [`Error::Kms`]. +//! +//! For one-row reads of a batch-encrypted collection, decrypt as +//! [`Element`](Element) under the same AAD used for the whole collection. +//! For finer control (custom `Decrypt` drivers, manual AAD derivations) use +//! [`StackCipher::decipher`] and drive the returned [`StackDecipher`] yourself. +//! +//! # Relationship to vitaminc +//! +//! `StackCipher` is a vitaminc [`Cipher`]; everything a vitaminc cipher can +//! encrypt, it can encrypt, and the AEAD, AAD derivations and leaf wire format +//! are vitaminc's (`vitaminc_encrypt::Aes256Cipher`, AES-256-GCM). The types a +//! caller needs from vitaminc are re-exported here. The module-level docs in +//! `src/cipher.rs` describe the internals (batching, AAD derivation, wire +//! format). mod cipher; pub use cipher::{ - BoxedPassthrough, DataKeyCipherText, Error, PendingCipherText, ZeroKmsCipher, - ZeroKmsCipherText, ZeroKmsDecipher, + BoxedPassthrough, Error, PendingStackCipherText, SealedValue, StackCipher, StackCipherText, + StackDecipher, }; // Re-export the vitaminc AEAD surface callers need to drive the cipher, so they diff --git a/packages/stack-encrypt/tests/roundtrip.rs b/packages/stack-encrypt/tests/roundtrip.rs index ac79e8760..f136998a6 100644 --- a/packages/stack-encrypt/tests/roundtrip.rs +++ b/packages/stack-encrypt/tests/roundtrip.rs @@ -1,14 +1,14 @@ -//! End-to-end encrypt/decrypt tests for `ZeroKmsCipher` against the deterministic +//! End-to-end encrypt/decrypt tests for `StackCipher` against the deterministic //! `FakeDataKeySource` — no ZeroKMS credentials or network required. use std::collections::HashMap; -use stack_encrypt::{Aad, CipherText, ContextTag, Element, IntoAad, ZeroKmsCipher}; +use stack_encrypt::{Aad, CipherText, ContextTag, Element, IntoAad, SealedValue, StackCipher}; use stack_kms::FakeDataKeySource; use vitaminc_protected::{Controlled, Protected}; -fn cipher() -> ZeroKmsCipher { - ZeroKmsCipher::new(FakeDataKeySource::new()) +fn cipher() -> StackCipher { + StackCipher::new(FakeDataKeySource::new()) } #[tokio::test] @@ -301,7 +301,7 @@ async fn element_fails_under_wrong_caller_aad() { #[tokio::test] async fn decipher_can_be_driven_directly() { - // `ZeroKmsCipher::decipher` mirrors `Aes256Cipher::decipher`: the returned + // `StackCipher::decipher` mirrors `Aes256Cipher::decipher`: the returned // Decipher is driven via `Decrypt::decrypt_with_aad` with a caller-chosen // AAD, so manual derivations work too. let cipher = cipher(); @@ -339,3 +339,70 @@ async fn wrong_shape_fails() { let result: Result, _> = cipher.decrypt(ct, ()).await; assert!(result.is_err(), "scalar must not decode as a Vec"); } + +#[tokio::test] +async fn leaf_survives_persistence_via_parts() { + // A leaf can be decomposed into (iv, tag, ciphertext), stored, and rebuilt + // — the in-memory original need not be retained to decrypt. + let cipher = cipher(); + let ct = cipher + .encrypt("durable".to_string(), b"ctx".as_slice()) + .await + .expect("encrypt"); + let leaf = match ct { + CipherText::Single(leaf) => leaf, + other => panic!("expected a Single leaf, got {other:?}"), + }; + let (iv, tag, bytes) = leaf.into_parts(); + let rebuilt = SealedValue::from_parts(iv, tag, bytes); + + let pt: String = cipher + .decrypt(CipherText::Single(rebuilt), b"ctx".as_slice()) + .await + .expect("rebuilt leaf must decrypt"); + assert_eq!(pt, "durable"); +} + +#[tokio::test] +async fn leaf_survives_persistence_via_serde() { + let cipher = cipher(); + let ct = cipher + .encrypt("durable".to_string(), ()) + .await + .expect("encrypt"); + let leaf = match ct { + CipherText::Single(leaf) => leaf, + other => panic!("expected a Single leaf, got {other:?}"), + }; + let json = serde_json::to_string(&leaf).expect("serialise leaf"); + let restored: SealedValue = serde_json::from_str(&json).expect("deserialise leaf"); + assert_eq!(restored.iv(), leaf.iv()); + assert_eq!(restored.tag(), leaf.tag()); + assert_eq!(restored.ciphertext(), leaf.ciphertext()); + + let pt: String = cipher + .decrypt(CipherText::Single(restored), ()) + .await + .expect("restored leaf must decrypt"); + assert_eq!(pt, "durable"); +} + +#[tokio::test] +async fn tampered_leaf_bytes_fail() { + let cipher = cipher(); + let ct = cipher + .encrypt("durable".to_string(), ()) + .await + .expect("encrypt"); + let leaf = match ct { + CipherText::Single(leaf) => leaf, + other => panic!("expected a Single leaf, got {other:?}"), + }; + let (iv, tag, mut bytes) = leaf.into_parts(); + let last = bytes.len() - 1; + bytes[last] ^= 0x01; + let tampered = SealedValue::from_parts(iv, tag, bytes); + + let result: Result = cipher.decrypt(CipherText::Single(tampered), ()).await; + assert!(result.is_err(), "a flipped ciphertext bit must not decrypt"); +} From 9106bb92507b5979c20ae05b44f809d6448a6cac Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Wed, 26 Aug 2026 13:01:32 +1000 Subject: [PATCH 413/686] chore(stack-encrypt): depend on stack-kms without default features stack-kms now gates the on-disk profile layer (`stack-profile`) behind a default-on `profile` feature (cipherstash/cipherstash-suite#2018). stack-encrypt only takes a `DataKeySource`, so opt out and stop pulling the filesystem profile code into every consumer of the cipher. Claude-Session: https://claude.ai/code/session_01HU1Bbw4eQp9kEEneXxDcKr --- packages/stack-encrypt/Cargo.toml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/stack-encrypt/Cargo.toml b/packages/stack-encrypt/Cargo.toml index 2af1cb96e..93dcfc942 100644 --- a/packages/stack-encrypt/Cargo.toml +++ b/packages/stack-encrypt/Cargo.toml @@ -15,7 +15,11 @@ license-file = "LICENSE" publish = false [dependencies] -stack-kms = { path = "../stack-kms" } +# No default features: `stack-encrypt` only ever takes a `DataKeySource`, so +# it has no use for stack-kms's `profile` feature (the CLI's on-disk +# `secretkey.json` via `stack-profile`). Consumers that want it enable it on +# their own `stack-kms` dependency. +stack-kms = { path = "../stack-kms", default-features = false } # The revised Cipher/Decipher traits, ContextTag, Element, and the PRF crates # have all merged to vitaminc main but are not yet published (crates.io is 200+ From 32accce6a479a59abc658ef024b76a31d87e2fda Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Wed, 26 Aug 2026 15:01:31 +1000 Subject: [PATCH 414/686] docs(stack-encrypt): correct stale fake/AAD/nonce wording; make fake doctest runnable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-ups on cipherstash/cipherstash-suite#2019: - `FakeDataKeySource` was described as "deterministic" in the crate docs and the integration-test header. It generates random key/iv/tag material and remembers it in an in-memory map, so only generate -> retrieve within one process round-trips. Say that instead. - The crate-level fake example was `no_run`; it needs no credentials or network, so run it (tokio current-thread runtime from dev-deps). - Spell out that the ZeroKMS `iv` on a `SealedValue` is a data-key identifier, not the AEAD nonce — vitaminc's `Aes256Cipher` generates its own random nonce per leaf. - A test comment described the leaf AAD as `aad || tag`; it is the PAE-encoded tuple `(aad, tag)`. Claude-Session: https://claude.ai/code/session_01DSVBr8UVPYU7g1gAJC6P4q --- packages/stack-encrypt/src/lib.rs | 25 ++++++++++++++++------- packages/stack-encrypt/tests/roundtrip.rs | 10 ++++++--- 2 files changed, 25 insertions(+), 10 deletions(-) diff --git a/packages/stack-encrypt/src/lib.rs b/packages/stack-encrypt/src/lib.rs index d78bf65d5..6a3545917 100644 --- a/packages/stack-encrypt/src/lib.rs +++ b/packages/stack-encrypt/src/lib.rs @@ -58,14 +58,21 @@ //! //! # Testing without ZeroKMS //! -//! `stack_kms::FakeDataKeySource` is a deterministic in-process key source that -//! needs no credentials or network. It lives behind stack-kms's `test-support` -//! feature, so add +//! `stack_kms::FakeDataKeySource` is an in-process key source that needs no +//! credentials or network: it hands out a fresh random data key per request and +//! remembers it in memory, so a `generate` followed by the matching `retrieve` +//! round-trips within one process (the key material itself differs run to run, +//! and nothing survives the process). It lives behind stack-kms's +//! `test-support` feature, so add //! `stack-kms = { version = "..", features = ["test-support"] }` to your //! `[dev-dependencies]`: //! -//! ```no_run -//! # async fn example() -> Result<(), stack_encrypt::Error> { +//! ``` +//! # fn main() -> Result<(), stack_encrypt::Error> { +//! # tokio::runtime::Builder::new_current_thread() +//! # .build() +//! # .expect("runtime") +//! # .block_on(async { //! use stack_encrypt::StackCipher; //! use stack_kms::FakeDataKeySource; //! @@ -74,6 +81,7 @@ //! let pt: Vec = cipher.decrypt(ct, ()).await?; //! assert_eq!(pt, vec!["a", "b"]); //! # Ok(()) +//! # }) //! # } //! ``` //! @@ -106,8 +114,11 @@ //! //! `StackCipher` is a vitaminc [`Cipher`]; everything a vitaminc cipher can //! encrypt, it can encrypt, and the AEAD, AAD derivations and leaf wire format -//! are vitaminc's (`vitaminc_encrypt::Aes256Cipher`, AES-256-GCM). The types a -//! caller needs from vitaminc are re-exported here. The module-level docs in +//! are vitaminc's (`vitaminc_encrypt::Aes256Cipher`, AES-256-GCM under a random +//! per-leaf nonce vitaminc generates itself). The ZeroKMS `iv` a [`SealedValue`] +//! carries is *not* that nonce: it identifies the data key, and is sent back to +//! ZeroKMS with the key `tag` to re-derive it. The types a caller needs from +//! vitaminc are re-exported here. The module-level docs in //! `src/cipher.rs` describe the internals (batching, AAD derivation, wire //! format). diff --git a/packages/stack-encrypt/tests/roundtrip.rs b/packages/stack-encrypt/tests/roundtrip.rs index f136998a6..5d2a661ee 100644 --- a/packages/stack-encrypt/tests/roundtrip.rs +++ b/packages/stack-encrypt/tests/roundtrip.rs @@ -1,5 +1,8 @@ -//! End-to-end encrypt/decrypt tests for `StackCipher` against the deterministic -//! `FakeDataKeySource` — no ZeroKMS credentials or network required. +//! End-to-end encrypt/decrypt tests for `StackCipher` against the in-memory +//! `FakeDataKeySource` — no ZeroKMS credentials or network required. The fake +//! hands out random key material and remembers it by `(iv, tag)`, so +//! generate → retrieve round-trips within a test but nothing is reproducible +//! across processes. use std::collections::HashMap; @@ -52,7 +55,8 @@ async fn decrypt_fails_when_aad_omitted() { .encrypt("secret".to_string(), b"bound".as_slice()) .await .expect("encrypt"); - // The leaf bound `aad || tag`; dropping the caller AAD changes the bytes. + // The leaf bound the PAE-encoded tuple `(aad, tag)`; dropping the caller + // AAD changes the encoding. let result: Result = cipher.decrypt(ct, ()).await; assert!(result.is_err(), "omitting the bound AAD must not decrypt"); } From 6a4e2a686a5a90f6b24ad4b783bafbb19d42eb8b Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Fri, 21 Aug 2026 16:22:41 +0930 Subject: [PATCH 415/686] feat(stack-kms): load keysets and derive the per-keyset IndexKey Ports the `load-keyset` slice from `cipherstash-client`: the server returns partial keyset-root key material, and the client derives the deterministic per-keyset `IndexKey` via proxy re-encryption with an all-zero IV followed by blake3 XOF under the "ZEROKMS-INDEXKEY" info string (byte-identical to `cipherstash-client`'s derivation, so the same keyset yields the same index terms). - `Client::load_keyset` / `StackKms::load_keyset` (keyset by id, name, or client default; returns the resolved `Keyset` + `IndexKey`) - `IndexKeySource` trait alongside `DataKeySource`, so SEM layers (stack-encrypt) can be tested without credentials; blanket impl for `StackKms`, deterministic fake on `FakeDataKeySource` - `LoadKeysetError` mirroring `GenerateKeyError`'s opaque-Display shape This is the local (v1) index-key path; a future ZeroKMS release adds 2-party PRF generation, which will slot in behind the same trait. Claude-Session: https://claude.ai/code/session_01T5iYiJc6xwzMcHDtMwCadG --- packages/stack-kms/Cargo.toml | 1 + packages/stack-kms/src/client.rs | 99 ++++++++++++++++++++++++++-- packages/stack-kms/src/errors.rs | 26 ++++++++ packages/stack-kms/src/key.rs | 53 +++++++++++++++ packages/stack-kms/src/key_source.rs | 66 ++++++++++++++++++- packages/stack-kms/src/lib.rs | 16 +++-- 6 files changed, 249 insertions(+), 12 deletions(-) diff --git a/packages/stack-kms/Cargo.toml b/packages/stack-kms/Cargo.toml index d1b5435ec..875fb314c 100644 --- a/packages/stack-kms/Cargo.toml +++ b/packages/stack-kms/Cargo.toml @@ -41,6 +41,7 @@ vitaminc = { workspace = true, features = ["protected", "random"] } # `::vitaminc_protected` path. vitaminc-protected = { workspace = true } +blake3 = { workspace = true } lazy_static = { workspace = true } miette = { workspace = true } reqwest = { workspace = true } diff --git a/packages/stack-kms/src/client.rs b/packages/stack-kms/src/client.rs index cc426fe28..a80c0ee01 100644 --- a/packages/stack-kms/src/client.rs +++ b/packages/stack-kms/src/client.rs @@ -1,9 +1,9 @@ use std::borrow::Cow; use uuid::Uuid; use zerokms_protocol::{ - GenerateKeyRequest, GenerateKeySpec, GeneratedKey, RetrieveKeyRequest, - RetrieveKeyRequestFallible, RetrieveKeySpec, RetrievedKey, UnverifiedContext, ViturRequest, - ViturRequestError, + GenerateKeyRequest, GenerateKeySpec, GeneratedKey, IdentifiedBy, Keyset, LoadKeysetRequest, + LoadKeysetResponse, RetrieveKeyRequest, RetrieveKeyRequestFallible, RetrieveKeySpec, + RetrievedKey, UnverifiedContext, ViturRequest, ViturRequestError, }; use recipher::key::Iv; @@ -11,9 +11,9 @@ use stack_auth::{AuthStrategy, AuthStrategyBounds}; use vitaminc::random::{Generatable, SafeRand}; use crate::connection::{HttpConnection, HttpConnectionOpts, ZeroKMSConnection}; -use crate::errors::{Error, GenerateKeyError, RetrieveKeyError}; +use crate::errors::{Error, GenerateKeyError, LoadKeysetError, RetrieveKeyError}; use crate::futures::map_async_chunked; -use crate::key::{ClientKey, DataKey, DataKeyWithTag}; +use crate::key::{ClientKey, DataKey, DataKeyWithTag, IndexKey}; use crate::payload::{GenerateKeyPayload, RetrieveKeyPayload}; /// Default [`ClientOpts::max_keys_per_req`]. @@ -277,6 +277,30 @@ impl Client { .await } + /// Load a keyset and derive its [`IndexKey`] from the returned partial + /// keyset-root key material. If `keyset_id` is `None`, the client's default + /// keyset is loaded. + pub async fn load_keyset( + &self, + client_key: &ClientKey, + keyset_id: Option, + access_token: &str, + ) -> Result<(Keyset, IndexKey), LoadKeysetError> { + let req = LoadKeysetRequest { + client_id: client_key.key_id, + keyset_id, + }; + + let LoadKeysetResponse { + keyset, + partial_index_key, + } = self.connection.send(req, access_token).await?; + + let index_key = IndexKey::from_key_material(client_key, &partial_index_key.key_material); + + Ok((keyset, index_key)) + } + /// Generate multiple data keys for an iterator of [`GenerateKeyPayload`]. pub async fn generate_keys<'a>( &self, @@ -441,6 +465,26 @@ where .map_err(Error::from) } + /// Load a keyset and derive its [`IndexKey`] — the deterministic per-keyset + /// key used to generate index terms (Searchable Encrypted Metadata). If + /// `keyset_id` is `None`, the client's default keyset is loaded; the + /// returned [`Keyset`] carries the resolved id. + pub async fn load_keyset( + &self, + keyset_id: Option, + ) -> Result<(Keyset, IndexKey), Error> { + let token = self.get_token().await?; + + let (keyset, index_key) = self + .client + .load_keyset(&self.client_key, keyset_id, token.as_str()) + .await?; + + debug!(target: "stack_kms::load_keyset", "loaded keyset: [{}]({})", keyset.id, keyset.name); + + Ok((keyset, index_key)) + } + /// Retrieve multiple data keys, returning a per-key result so partial /// failures don't fail the whole batch. pub async fn retrieve_keys_fallible<'a>( @@ -914,6 +958,51 @@ mod tests { assert_eq!(generated[0].key.key(), retrieved[0].key()); } + #[tokio::test] + async fn load_keyset_derives_a_deterministic_index_key() { + let client_key = random_client_key(); + let keyset_id = uuid!("11111111-1111-1111-1111-111111111111"); + let shared_bytes = vec![7u8; 528]; + + let keyset = |material: Vec| { + build_client(|builder| { + builder.add_success_response::(LoadKeysetResponse { + partial_index_key: RetrievedKey { + key_material: ViturKeyMaterial::from(material), + }, + keyset: Keyset { + id: keyset_id, + name: "default".to_string(), + description: String::new(), + is_disabled: false, + is_default: true, + }, + }) + }) + }; + + let (loaded_a, key_a) = keyset(shared_bytes.clone()) + .load_keyset(&client_key, None, "token") + .await + .expect("load_keyset should succeed"); + let (_, key_b) = keyset(shared_bytes) + .load_keyset(&client_key, Some(keyset_id.into()), "token") + .await + .expect("load_keyset should succeed"); + + assert_eq!(loaded_a.id, keyset_id); + // Same key material derives the same index key — write-time and + // query-time terms must agree. + assert_eq!(key_a.key(), key_b.key()); + + // Different key material derives a different index key. + let (_, key_c) = keyset(vec![8u8; 528]) + .load_keyset(&client_key, None, "token") + .await + .expect("load_keyset should succeed"); + assert_ne!(key_a.key(), key_c.key()); + } + #[tokio::test] async fn retrieve_keys_fallible_surfaces_per_key_results() { let client_key = random_client_key(); diff --git a/packages/stack-kms/src/errors.rs b/packages/stack-kms/src/errors.rs index 6921b59b0..5f30cbb0f 100644 --- a/packages/stack-kms/src/errors.rs +++ b/packages/stack-kms/src/errors.rs @@ -113,6 +113,28 @@ mod generate_key_error_from_vitur_request_error { } } +#[derive(Diagnostic, Error, Debug)] +pub enum LoadKeysetError { + #[error("Request not authorized")] + Unauthorized, + #[error("Request forbidden due to insufficient permissions")] + Forbidden, + // Same shape as `GenerateKeyError::RequestFailed`: Display carries only the + // static kind/message; the dynamic error stays behind `source()`. + #[error("Unexpected error ({}: {})", .0.kind, .0.message)] + RequestFailed(#[source] ViturRequestError), +} + +impl From for LoadKeysetError { + fn from(err: ViturRequestError) -> Self { + match err.kind { + ViturRequestErrorKind::Forbidden => Self::Forbidden, + ViturRequestErrorKind::Unauthorized => Self::Unauthorized, + _ => Self::RequestFailed(err), + } + } +} + /// Top-level error for high-level [`StackKms`](crate::StackKms) key operations. #[derive(Error, Debug, Diagnostic)] pub enum Error { @@ -124,6 +146,10 @@ pub enum Error { #[diagnostic(transparent)] RetrieveKey(#[from] RetrieveKeyError), + #[error(transparent)] + #[diagnostic(transparent)] + LoadKeyset(#[from] LoadKeysetError), + #[error(transparent)] #[diagnostic(transparent)] Auth(#[from] stack_auth::AuthError), diff --git a/packages/stack-kms/src/key.rs b/packages/stack-kms/src/key.rs index 83bf96082..17e533fb3 100644 --- a/packages/stack-kms/src/key.rs +++ b/packages/stack-kms/src/key.rs @@ -128,6 +128,59 @@ impl Deref for DataKeyWithTag { } } +/// Key used specifically for generating index terms (Searchable Encrypted +/// Metadata) with PRFs and similar constructions. +/// +/// Derived from the *keyset root* key material returned by ZeroKMS's +/// `load-keyset` operation: unlike data keys, the same keyset always yields the +/// same index key, so terms generated at write time match terms generated at +/// query time. +#[derive(Zeroize, ZeroizeOnDrop)] +pub struct IndexKey(Key); +opaque_debug::implement!(IndexKey); + +impl IndexKey { + /// Derive the index key for a specific [`ClientKey`] from the partial + /// keyset-root key material obtained from ZeroKMS. + pub fn from_key_material(key: &ClientKey, key_material: &ViturKeyMaterial) -> Self { + // We use all zeros for the IV for the keyset index key. + // This key is not used for encryption but for indexing using PRFs and + // similar constructions. Even then, because all other data keys are + // generated using random IVs, the likelihood of collision is negligible. + let iv = Iv::default(); + let cipher = ProxyCipher::new(key.keyset.keyset()); + // `rect` is reencrypted key material — the derived index key is a hash + // of it — so wipe the intermediate on drop rather than leave it on the + // heap (matches `DataKey::from_key_material`). + let rect = Zeroizing::new(cipher.reencrypt::<16>(&iv, key_material)); + + let mut hasher = blake3::Hasher::new(); + // Fixed info string + hasher.update(b"ZEROKMS-INDEXKEY"); + hasher.update(rect.as_slice()); + + let key: Key = { + let mut key = Key::default(); + hasher.finalize_xof().fill(&mut key); + key + }; + + hasher.zeroize(); + + Self(key) + } + + pub fn key(&self) -> &Key { + &self.0 + } +} + +impl From for IndexKey { + fn from(key: Key) -> Self { + Self(key) + } +} + #[derive(Debug, Clone, Zeroize, ZeroizeOnDrop)] pub struct V1KeySet(pub(crate) KeySet); diff --git a/packages/stack-kms/src/key_source.rs b/packages/stack-kms/src/key_source.rs index 310590495..aa6c5827f 100644 --- a/packages/stack-kms/src/key_source.rs +++ b/packages/stack-kms/src/key_source.rs @@ -14,7 +14,7 @@ use uuid::Uuid; use zerokms_protocol::UnverifiedContext; use crate::errors::Error; -use crate::key::{DataKey, DataKeyWithTag}; +use crate::key::{DataKey, DataKeyWithTag, IndexKey}; use crate::payload::{GenerateKeyPayload, RetrieveKeyPayload}; /// The slice of ZeroKMS data-key functionality required to encrypt and decrypt: @@ -73,6 +73,27 @@ pub trait DataKeySource { ) -> impl Future, Error>>; } +/// The slice of ZeroKMS functionality required to *index* encrypted data: +/// loading the deterministic per-keyset [`IndexKey`] used to generate index +/// terms (Searchable Encrypted Metadata) with PRFs and similar constructions. +/// +/// Split from [`DataKeySource`] because the two capabilities are consumed +/// separately: record encryption needs data keys, term generation needs the +/// index key. Production implementations provide both. +pub trait IndexKeySource { + /// Load the index key for a keyset (the client's default keyset when + /// `keyset_id` is `None`). Returns the resolved keyset id alongside the + /// key, so callers pinning `None` learn which keyset they resolved to. + /// + /// The index key is deterministic per keyset: loading it twice yields the + /// same key, so terms generated at write time match terms generated at + /// query time. + fn load_index_key( + &self, + keyset_id: Option, + ) -> impl Future> + Send; +} + impl DataKeySource for crate::StackKms where C: stack_auth::AuthStrategyBounds, @@ -99,6 +120,17 @@ where } } +impl IndexKeySource for crate::StackKms +where + C: stack_auth::AuthStrategyBounds, + for<'a> &'a C: stack_auth::AuthStrategy, +{ + async fn load_index_key(&self, keyset_id: Option) -> Result<(Uuid, IndexKey), Error> { + let (keyset, index_key) = self.load_keyset(keyset_id.map(Into::into)).await?; + Ok((keyset.id, index_key)) + } +} + #[cfg(feature = "test-support")] mod fake { use super::*; @@ -156,6 +188,21 @@ mod fake { } } + impl IndexKeySource for FakeDataKeySource { + /// Deterministically derive an index key from the `keyset_id` alone — + /// like real ZeroKMS, the same keyset always yields the same index key, + /// and distinct keysets yield distinct keys. `None` resolves to the nil + /// UUID as the fake's "default keyset". + async fn load_index_key(&self, keyset_id: Option) -> Result<(Uuid, IndexKey), Error> { + let resolved = keyset_id.unwrap_or_else(Uuid::nil); + let mut hasher = Sha256::new(); + hasher.update(b"stack-kms::FakeDataKeySource::index-key::v1"); + hasher.update(resolved.as_bytes()); + let key: Key = hasher.finalize().into(); + Ok((resolved, IndexKey::from(key))) + } + } + impl DataKeySource for FakeDataKeySource { async fn generate_keys( &self, @@ -247,6 +294,23 @@ mod tests { } } + #[tokio::test] + async fn fake_index_key_is_deterministic_per_keyset() { + let src = FakeDataKeySource::new(); + let keyset_a = Uuid::from_u128(1); + let keyset_b = Uuid::from_u128(2); + + let (id_a, key_a) = src.load_index_key(Some(keyset_a)).await.unwrap(); + let (_, key_a_again) = src.load_index_key(Some(keyset_a)).await.unwrap(); + let (_, key_b) = src.load_index_key(Some(keyset_b)).await.unwrap(); + let (id_none, _) = src.load_index_key(None).await.unwrap(); + + assert_eq!(id_a, keyset_a); + assert_eq!(key_a.key(), key_a_again.key()); + assert_ne!(key_a.key(), key_b.key()); + assert_eq!(id_none, Uuid::nil()); + } + #[tokio::test] async fn generate_then_retrieve_reproduces_the_key() { let src = FakeDataKeySource::new(); diff --git a/packages/stack-kms/src/lib.rs b/packages/stack-kms/src/lib.rs index 33b106379..6300589ae 100644 --- a/packages/stack-kms/src/lib.rs +++ b/packages/stack-kms/src/lib.rs @@ -4,11 +4,13 @@ //! //! It is an extraction of the key-generation/retrieval slice of //! `cipherstash-client`'s `zerokms` module into a standalone crate. This first -//! cut deliberately covers only the two operations: +//! cut deliberately covers only: //! //! * [`StackKms::generate_keys`] — derive fresh data keys (with tags) from ZeroKMS //! * [`StackKms::retrieve_keys`] / [`StackKms::retrieve_keys_fallible`] — re-derive //! data keys for previously encrypted records +//! * [`StackKms::load_keyset`] — load a keyset and derive its deterministic +//! [`IndexKey`], used to generate index terms (Searchable Encrypted Metadata) //! //! Encryption/decryption, keyset and client management, and config save/load //! all remain in `cipherstash-client` for now. @@ -93,15 +95,15 @@ pub use connection::{ pub use endpoint::{InvalidEndpoint, ZeroKmsEndpoint}; // Errors -pub use errors::{Error, GenerateKeyError, RetrieveKeyError}; +pub use errors::{Error, GenerateKeyError, LoadKeysetError, RetrieveKeyError}; // Key material -pub use key::{ClientKey, DataKey, DataKeyWithTag, V1KeySet}; +pub use key::{ClientKey, DataKey, DataKeyWithTag, IndexKey, V1KeySet}; -// Data key source abstraction (production = `StackKms`; tests = `FakeDataKeySource`) -pub use key_source::DataKeySource; +// Key source abstractions (production = `StackKms`; tests = `FakeDataKeySource`) #[cfg(feature = "test-support")] pub use key_source::FakeDataKeySource; +pub use key_source::{DataKeySource, IndexKeySource}; // Key providers pub use key_provider::{ @@ -118,7 +120,9 @@ pub use payload::{GenerateKeyPayload, RetrieveKeyPayload}; // Commonly needed re-exports from the protocol / crypto layers pub use recipher::key::{GenRandom, Iv}; -pub use zerokms_protocol::{Context, DecryptionPolicy, KeyId, UnverifiedContext, ViturKeyMaterial}; +pub use zerokms_protocol::{ + Context, DecryptionPolicy, IdentifiedBy, KeyId, Keyset, UnverifiedContext, ViturKeyMaterial, +}; /// Process-wide environment guard for tests that set or clear env vars. /// From 7c7a3883f905e8992cbbaa5d2463cdcd5467f2be Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Sun, 23 Aug 2026 16:38:59 +1000 Subject: [PATCH 416/686] fix(stack-kms): drop the Send bound on IndexKeySource futures for wasm32 Mirror `DataKeySource` (cf. the wasm finding on cipherstash/cipherstash-suite#2018): on wasm32 the fetch-backed HTTP and auth futures aren't `Send`, so an unconditional bound would make `StackKms` unable to implement the trait there. Claude-Session: https://claude.ai/code/session_01T5iYiJc6xwzMcHDtMwCadG --- packages/stack-kms/src/key_source.rs | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/packages/stack-kms/src/key_source.rs b/packages/stack-kms/src/key_source.rs index aa6c5827f..44e88a540 100644 --- a/packages/stack-kms/src/key_source.rs +++ b/packages/stack-kms/src/key_source.rs @@ -80,6 +80,10 @@ pub trait DataKeySource { /// Split from [`DataKeySource`] because the two capabilities are consumed /// separately: record encryption needs data keys, term generation needs the /// index key. Production implementations provide both. +/// +/// As with [`DataKeySource`], the returned future is `Send` on native targets +/// and unbounded on wasm32. +#[cfg(not(target_arch = "wasm32"))] pub trait IndexKeySource { /// Load the index key for a keyset (the client's default keyset when /// `keyset_id` is `None`). Returns the resolved keyset id alongside the @@ -94,6 +98,19 @@ pub trait IndexKeySource { ) -> impl Future> + Send; } +/// See the native definition above; identical minus the `Send` bound on the +/// returned future. +#[cfg(target_arch = "wasm32")] +pub trait IndexKeySource { + /// Load the index key for a keyset (the client's default keyset when + /// `keyset_id` is `None`). Returns the resolved keyset id alongside the + /// key, so callers pinning `None` learn which keyset they resolved to. + fn load_index_key( + &self, + keyset_id: Option, + ) -> impl Future>; +} + impl DataKeySource for crate::StackKms where C: stack_auth::AuthStrategyBounds, From 0da883764404b887aac717ef1be25232d930b0ff Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Sun, 23 Aug 2026 23:18:11 +1000 Subject: [PATCH 417/686] fix(stack-kms): adapt keyset loading to the tracing + unused_results baseline Post-rebase follow-ups onto the updated cipherstash/cipherstash-suite#2018/cipherstash/cipherstash-suite#2019 base: - `StackKms::load_keyset`'s `debug!` now goes through `tracing::debug!` (the crate no longer depends on `log`). - `IndexKey::from_key_material` chains the blake3 `update` calls into the `finalize_xof().fill(..)` expression so the `&mut Hasher` results are consumed, satisfying `#![warn(unused_results)]`. Claude-Session: https://claude.ai/code/session_01T5iYiJc6xwzMcHDtMwCadG --- packages/stack-kms/src/client.rs | 2 +- packages/stack-kms/src/key.rs | 11 ++++++----- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/packages/stack-kms/src/client.rs b/packages/stack-kms/src/client.rs index a80c0ee01..b9a02b863 100644 --- a/packages/stack-kms/src/client.rs +++ b/packages/stack-kms/src/client.rs @@ -480,7 +480,7 @@ where .load_keyset(&self.client_key, keyset_id, token.as_str()) .await?; - debug!(target: "stack_kms::load_keyset", "loaded keyset: [{}]({})", keyset.id, keyset.name); + tracing::debug!(target: "stack_kms::load_keyset", "loaded keyset: [{}]({})", keyset.id, keyset.name); Ok((keyset, index_key)) } diff --git a/packages/stack-kms/src/key.rs b/packages/stack-kms/src/key.rs index 17e533fb3..87ba2a241 100644 --- a/packages/stack-kms/src/key.rs +++ b/packages/stack-kms/src/key.rs @@ -155,13 +155,14 @@ impl IndexKey { let rect = Zeroizing::new(cipher.reencrypt::<16>(&iv, key_material)); let mut hasher = blake3::Hasher::new(); - // Fixed info string - hasher.update(b"ZEROKMS-INDEXKEY"); - hasher.update(rect.as_slice()); - let key: Key = { let mut key = Key::default(); - hasher.finalize_xof().fill(&mut key); + hasher + // Fixed info string + .update(b"ZEROKMS-INDEXKEY") + .update(rect.as_slice()) + .finalize_xof() + .fill(&mut key); key }; From b36d2c41c721cbc5357d2e39baea4657a12bbbb8 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Mon, 24 Aug 2026 20:44:02 +1000 Subject: [PATCH 418/686] fix(stack-kms): address code-review findings on keyset loading Fixes from the automated Claude code review on this PR (plus the inline review comment): - IndexKey::from_key_material now validates the network-supplied key material length (33 x 16-byte blocks) and returns LoadKeysetError::InvalidKeyMaterial instead of panicking inside recipher on truncated/corrupt responses. - LoadKeysetError: Unauthorized/Forbidden carry the underlying ViturRequestError so the server's "Keyset disabled" 403 body stays reachable via source(); new KeysetNotFound variant maps the 404 for an unknown keyset instead of collapsing into RequestFailed. Mapping now has its own test module mirroring GenerateKeyError's (incl. the Display-must-not-leak-source assertion). - Zeroize the blake3 OutputReader after fill(): it holds the final chaining value from which the whole XOF stream (the index key) is recomputable. Applied to both stack-kms and cipherstash-client. - Known-answer test pinning the ZEROKMS-INDEXKEY derivation to fixed bytes, duplicated in stack-kms and cipherstash-client so silent drift between the two copies fails CI. - impl From for IndexKey gated behind the test-support feature so production callers can only obtain an index key via load_keyset. - IndexKeySource::load_index_key takes Option so keysets can be addressed by name (supported end-to-end by the protocol and server); the fake resolves names deterministically. - FakeDataKeySource resolves None to the nil UUID before deriving keys and tags, matching real ZeroKMS where None and the resolved default keyset id are equivalent (pin-the-resolved-id pattern); domain strings bumped to v4. - wasm32 IndexKeySource doc now carries the same determinism-contract paragraph as the native definition. - IndexKey uses vitaminc's OpaqueDebug derive instead of the opaque_debug macro (review comment). Claude-Session: https://claude.ai/code/session_01T5iYiJc6xwzMcHDtMwCadG --- packages/stack-kms/src/client.rs | 2 +- packages/stack-kms/src/errors.rs | 106 +++++++++++++++++++++- packages/stack-kms/src/key.rs | 125 +++++++++++++++++++++++--- packages/stack-kms/src/key_source.rs | 126 +++++++++++++++++++-------- 4 files changed, 308 insertions(+), 51 deletions(-) diff --git a/packages/stack-kms/src/client.rs b/packages/stack-kms/src/client.rs index b9a02b863..51587ee19 100644 --- a/packages/stack-kms/src/client.rs +++ b/packages/stack-kms/src/client.rs @@ -296,7 +296,7 @@ impl Client { partial_index_key, } = self.connection.send(req, access_token).await?; - let index_key = IndexKey::from_key_material(client_key, &partial_index_key.key_material); + let index_key = IndexKey::from_key_material(client_key, &partial_index_key.key_material)?; Ok((keyset, index_key)) } diff --git a/packages/stack-kms/src/errors.rs b/packages/stack-kms/src/errors.rs index 5f30cbb0f..6f7dc8752 100644 --- a/packages/stack-kms/src/errors.rs +++ b/packages/stack-kms/src/errors.rs @@ -115,10 +115,23 @@ mod generate_key_error_from_vitur_request_error { #[derive(Diagnostic, Error, Debug)] pub enum LoadKeysetError { + // `Unauthorized` / `Forbidden` carry the underlying request error (unlike + // `GenerateKeyError`'s unit variants) because `load-keyset` has 403 + // responses that mean different things: the server rejects a *disabled* + // keyset with a 403 whose body says "Keyset disabled: ...". Display stays + // static (no dynamic data); the distinguishing server response is + // reachable through `source()`. #[error("Request not authorized")] - Unauthorized, + Unauthorized(#[source] ViturRequestError), #[error("Request forbidden due to insufficient permissions")] - Forbidden, + Forbidden(#[source] ViturRequestError), + // `load-keyset` uniquely takes a caller-supplied keyset id or name, so an + // unknown keyset (server 404) is an expected, user-actionable outcome — + // e.g. a typo'd name or a load-or-create flow — not an "unexpected error". + #[error("Keyset not found")] + KeysetNotFound(#[source] ViturRequestError), + #[error("Invalid keyset key material: expected {expected} bytes but received {received}")] + InvalidKeyMaterial { expected: usize, received: usize }, // Same shape as `GenerateKeyError::RequestFailed`: Display carries only the // static kind/message; the dynamic error stays behind `source()`. #[error("Unexpected error ({}: {})", .0.kind, .0.message)] @@ -128,13 +141,98 @@ pub enum LoadKeysetError { impl From for LoadKeysetError { fn from(err: ViturRequestError) -> Self { match err.kind { - ViturRequestErrorKind::Forbidden => Self::Forbidden, - ViturRequestErrorKind::Unauthorized => Self::Unauthorized, + ViturRequestErrorKind::Forbidden => Self::Forbidden(err), + ViturRequestErrorKind::Unauthorized => Self::Unauthorized(err), + ViturRequestErrorKind::NotFound => Self::KeysetNotFound(err), _ => Self::RequestFailed(err), } } } +#[cfg(test)] +mod load_keyset_error_from_vitur_request_error { + use super::*; + + const SOURCE_DETAIL: &str = "transport-detail-7f3a"; + + fn err(kind: ViturRequestErrorKind) -> ViturRequestError { + ViturRequestError::new(kind, "boom", std::io::Error::other(SOURCE_DETAIL)) + } + + #[test] + fn forbidden_maps_to_forbidden_keeping_the_source() { + let mapped = LoadKeysetError::from(err(ViturRequestErrorKind::Forbidden)); + assert!(matches!(mapped, LoadKeysetError::Forbidden(_))); + assert!( + std::error::Error::source(&mapped).is_some(), + "the server response (e.g. 'Keyset disabled') must stay reachable" + ); + } + + #[test] + fn unauthorized_maps_to_unauthorized_keeping_the_source() { + let mapped = LoadKeysetError::from(err(ViturRequestErrorKind::Unauthorized)); + assert!(matches!(mapped, LoadKeysetError::Unauthorized(_))); + assert!(std::error::Error::source(&mapped).is_some()); + } + + #[test] + fn not_found_maps_to_keyset_not_found() { + let mapped = LoadKeysetError::from(err(ViturRequestErrorKind::NotFound)); + assert!(matches!(mapped, LoadKeysetError::KeysetNotFound(_))); + assert!(std::error::Error::source(&mapped).is_some()); + } + + #[test] + fn every_other_kind_maps_to_request_failed_keeping_the_kind() { + for kind in [ + ViturRequestErrorKind::PrepareRequest, + ViturRequestErrorKind::SendRequest, + ViturRequestErrorKind::Conflict, + ViturRequestErrorKind::FailureResponse, + ViturRequestErrorKind::ParseResponse, + ViturRequestErrorKind::Other, + ] { + // `ViturRequestErrorKind` has no `PartialEq`; compare by Debug name. + let name = format!("{kind:?}"); + let mapped = LoadKeysetError::from(err(kind)); + assert!( + matches!(&mapped, LoadKeysetError::RequestFailed(e) if format!("{:?}", e.kind) == name), + "{name} must map to RequestFailed carrying the same kind, got: {mapped:?}" + ); + } + } + + #[test] + fn display_never_leaks_the_dynamic_source() { + for kind in [ + ViturRequestErrorKind::Forbidden, + ViturRequestErrorKind::Unauthorized, + ViturRequestErrorKind::NotFound, + ViturRequestErrorKind::SendRequest, + ] { + let mapped = LoadKeysetError::from(err(kind)); + let shown = mapped.to_string(); + assert!( + !shown.contains(SOURCE_DETAIL), + "the dynamic source error must stay out of Display: {shown}" + ); + } + } + + #[test] + fn request_failed_display_names_the_kind_and_message() { + let mapped = LoadKeysetError::from(err(ViturRequestErrorKind::SendRequest)); + let shown = mapped.to_string(); + assert!(shown.contains("SendRequest"), "{shown}"); + assert!(shown.contains("boom"), "{shown}"); + assert!( + std::error::Error::source(&mapped).is_some(), + "the source must still be reachable through the error chain" + ); + } +} + /// Top-level error for high-level [`StackKms`](crate::StackKms) key operations. #[derive(Error, Debug, Diagnostic)] pub enum Error { diff --git a/packages/stack-kms/src/key.rs b/packages/stack-kms/src/key.rs index 87ba2a241..e4c6f195c 100644 --- a/packages/stack-kms/src/key.rs +++ b/packages/stack-kms/src/key.rs @@ -8,10 +8,12 @@ use serde::{Deserialize, Deserializer, Serialize, Serializer}; use sha2::{Digest, Sha256}; use std::ops::Deref; use uuid::Uuid; -use vitaminc::protected::TimingSafeEq; +use vitaminc::protected::{OpaqueDebug, TimingSafeEq}; use zeroize::{Zeroize, ZeroizeOnDrop, Zeroizing}; use zerokms_protocol::{DecryptionPolicy, ViturKeyMaterial}; +use crate::errors::LoadKeysetError; + /// NOTE: Debug is safe to implement because [KeySet] is opaque. #[derive(Debug, Deserialize, Clone, Zeroize, ZeroizeOnDrop, Serialize)] pub struct ClientKey { @@ -135,14 +137,35 @@ impl Deref for DataKeyWithTag { /// `load-keyset` operation: unlike data keys, the same keyset always yields the /// same index key, so terms generated at write time match terms generated at /// query time. -#[derive(Zeroize, ZeroizeOnDrop)] +#[derive(Zeroize, ZeroizeOnDrop, OpaqueDebug)] pub struct IndexKey(Key); -opaque_debug::implement!(IndexKey); + +/// The exact key-material length `ProxyCipher::reencrypt::<16>` accepts: the +/// keyset's block permutation covers 33 16-byte blocks (see recipher's +/// `EncryptionKeySet`), so anything else panics inside recipher +/// (`copy_from_slice` on a short final chunk, or the permutation length +/// assert). Validated up front so a malformed ZeroKMS response surfaces as a +/// typed error instead of a crash. +pub(crate) const KEYSET_KEY_MATERIAL_LEN: usize = 33 * 16; impl IndexKey { /// Derive the index key for a specific [`ClientKey`] from the partial /// keyset-root key material obtained from ZeroKMS. - pub fn from_key_material(key: &ClientKey, key_material: &ViturKeyMaterial) -> Self { + /// + /// Returns [`LoadKeysetError::InvalidKeyMaterial`] when the material is + /// not exactly [`KEYSET_KEY_MATERIAL_LEN`] bytes — the material is + /// network-supplied, so a truncated or corrupt response must not panic. + pub fn from_key_material( + key: &ClientKey, + key_material: &ViturKeyMaterial, + ) -> Result { + if key_material.len() != KEYSET_KEY_MATERIAL_LEN { + return Err(LoadKeysetError::InvalidKeyMaterial { + expected: KEYSET_KEY_MATERIAL_LEN, + received: key_material.len(), + }); + } + // We use all zeros for the IV for the keyset index key. // This key is not used for encryption but for indexing using PRFs and // similar constructions. Even then, because all other data keys are @@ -155,20 +178,26 @@ impl IndexKey { let rect = Zeroizing::new(cipher.reencrypt::<16>(&iv, key_material)); let mut hasher = blake3::Hasher::new(); + // Bind the `OutputReader` so it can be wiped: it holds the final + // chaining value from which the whole XOF stream — the index key — + // is recomputable, and blake3's `zeroize` feature implements + // `Zeroize` for it but not wipe-on-drop. + let mut reader = hasher + // Fixed info string + .update(b"ZEROKMS-INDEXKEY") + .update(rect.as_slice()) + .finalize_xof(); + let key: Key = { let mut key = Key::default(); - hasher - // Fixed info string - .update(b"ZEROKMS-INDEXKEY") - .update(rect.as_slice()) - .finalize_xof() - .fill(&mut key); + reader.fill(&mut key); key }; + reader.zeroize(); hasher.zeroize(); - Self(key) + Ok(Self(key)) } pub fn key(&self) -> &Key { @@ -176,6 +205,14 @@ impl IndexKey { } } +/// Test-support only: mint an [`IndexKey`] from raw bytes, bypassing the +/// keyset-root derivation. Kept off the public API so production callers can +/// only obtain an index key through +/// [`from_key_material`](IndexKey::from_key_material) (or a +/// [`IndexKeySource`](crate::IndexKeySource)) — an index key that never went +/// through `load_keyset` would silently generate index terms that match +/// nothing written by other services. +#[cfg(feature = "test-support")] impl From for IndexKey { fn from(key: Key) -> Self { Self(key) @@ -310,6 +347,72 @@ mod tests { } } + mod index_key { + use super::*; + use crate::errors::LoadKeysetError; + use crate::key::{IndexKey, KEYSET_KEY_MATERIAL_LEN}; + + /// Fixed keyset for the known-answer test below (see its comment). + const KAT_KEYSET_HEX: &str = "a4627031a16b7065726d75746174696f6e900e02000c0705010b0f09080a0d0304066770325f66726f6da16b7065726d75746174696f6e9005000c020b0d06010a0903080e0f04076570325f746fa16b7065726d75746174696f6e900e00030c05060b010a0407090d0f0802627033a16b7065726d75746174696f6e982102160f09181e1819181f0d07181b13110804150610050e1818181c00181a0a0112031820140b17181d0c"; + + fn kat_client_key() -> ClientKey { + ClientKey::from_hex_v1(uuid::Uuid::nil(), KAT_KEYSET_HEX).unwrap() + } + + fn kat_material() -> zerokms_protocol::ViturKeyMaterial { + (0..KEYSET_KEY_MATERIAL_LEN as u32) + .map(|i| (i % 251) as u8) + .collect::>() + .into() + } + + /// Known-answer test pinning the index-key derivation to fixed bytes. + /// + /// The identical vector lives in cipherstash-client + /// (`zerokms::vitur_client::key`): the `ZEROKMS-INDEXKEY` zero-IV + /// blake3-XOF derivation is duplicated across the two crates and must + /// stay bit-identical, or records indexed via one stack become + /// silently unfindable when queried via the other. If this test + /// breaks, the derivation changed — do NOT update the expected bytes + /// without changing cipherstash-client in lockstep. + #[test] + fn from_key_material_matches_the_known_answer() { + let index_key = + IndexKey::from_key_material(&kat_client_key(), &kat_material()).unwrap(); + + assert_eq!( + base16ct::lower::encode_string(index_key.key()), + "f68a664c120234a50f98ca5301a3c8e7a4762626f33a7af02068c0dbd84dd416", + ); + } + + #[test] + fn from_key_material_is_deterministic() { + let a = IndexKey::from_key_material(&kat_client_key(), &kat_material()).unwrap(); + let b = IndexKey::from_key_material(&kat_client_key(), &kat_material()).unwrap(); + assert_eq!(a.key(), b.key()); + } + + #[test] + fn from_key_material_rejects_invalid_lengths_instead_of_panicking() { + let ck = kat_client_key(); + // Truncated, empty, non-block-multiple and over-long payloads: all + // network-supplied shapes that previously panicked inside recipher. + for len in [0usize, 1, 16, 527, 529, KEYSET_KEY_MATERIAL_LEN * 2] { + let material: zerokms_protocol::ViturKeyMaterial = vec![0u8; len].into(); + match IndexKey::from_key_material(&ck, &material) { + Err(LoadKeysetError::InvalidKeyMaterial { expected, received }) => { + assert_eq!(expected, KEYSET_KEY_MATERIAL_LEN); + assert_eq!(received, len); + } + other => panic!( + "length {len} must be rejected as InvalidKeyMaterial, got: {other:?}" + ), + } + } + } + } + #[test] fn test_opaque_debug_datakey() { let key = DataKey { diff --git a/packages/stack-kms/src/key_source.rs b/packages/stack-kms/src/key_source.rs index 44e88a540..b1baaa15f 100644 --- a/packages/stack-kms/src/key_source.rs +++ b/packages/stack-kms/src/key_source.rs @@ -11,7 +11,7 @@ use std::borrow::Cow; use std::future::Future; use uuid::Uuid; -use zerokms_protocol::UnverifiedContext; +use zerokms_protocol::{IdentifiedBy, UnverifiedContext}; use crate::errors::Error; use crate::key::{DataKey, DataKeyWithTag, IndexKey}; @@ -85,16 +85,17 @@ pub trait DataKeySource { /// and unbounded on wasm32. #[cfg(not(target_arch = "wasm32"))] pub trait IndexKeySource { - /// Load the index key for a keyset (the client's default keyset when - /// `keyset_id` is `None`). Returns the resolved keyset id alongside the - /// key, so callers pinning `None` learn which keyset they resolved to. + /// Load the index key for a keyset — identified by id or name, or the + /// client's default keyset when `keyset_id` is `None`. Returns the + /// resolved keyset id alongside the key, so callers pinning `None` or a + /// name learn which keyset they resolved to. /// /// The index key is deterministic per keyset: loading it twice yields the /// same key, so terms generated at write time match terms generated at /// query time. fn load_index_key( &self, - keyset_id: Option, + keyset_id: Option, ) -> impl Future> + Send; } @@ -102,12 +103,17 @@ pub trait IndexKeySource { /// returned future. #[cfg(target_arch = "wasm32")] pub trait IndexKeySource { - /// Load the index key for a keyset (the client's default keyset when - /// `keyset_id` is `None`). Returns the resolved keyset id alongside the - /// key, so callers pinning `None` learn which keyset they resolved to. + /// Load the index key for a keyset — identified by id or name, or the + /// client's default keyset when `keyset_id` is `None`. Returns the + /// resolved keyset id alongside the key, so callers pinning `None` or a + /// name learn which keyset they resolved to. + /// + /// The index key is deterministic per keyset: loading it twice yields the + /// same key, so terms generated at write time match terms generated at + /// query time. fn load_index_key( &self, - keyset_id: Option, + keyset_id: Option, ) -> impl Future>; } @@ -142,8 +148,11 @@ where C: stack_auth::AuthStrategyBounds, for<'a> &'a C: stack_auth::AuthStrategy, { - async fn load_index_key(&self, keyset_id: Option) -> Result<(Uuid, IndexKey), Error> { - let (keyset, index_key) = self.load_keyset(keyset_id.map(Into::into)).await?; + async fn load_index_key( + &self, + keyset_id: Option, + ) -> Result<(Uuid, IndexKey), Error> { + let (keyset, index_key) = self.load_keyset(keyset_id).await?; Ok((keyset.id, index_key)) } } @@ -177,9 +186,17 @@ mod fake { /// different caller all retrieve just fine here. Those decisions are /// ZeroKMS's, tested in `vitur-server-core`; do not assert them against /// this stub. Consumers testing *what they send* should mock the trait. + /// + /// As an [`IndexKeySource`] it is likewise a stub: each keyset gets a + /// random index key on first load and the same one thereafter, names + /// resolve to a random keyset id memoised per name, and `None` is the + /// nil UUID. Deterministic *per instance* — which is all the trait asks — + /// and nothing more. #[derive(Debug, Default)] pub struct FakeDataKeySource { keys: Mutex), Key>>, + keysets_by_name: Mutex, Uuid>>, + index_keys: Mutex>, } impl FakeDataKeySource { @@ -197,25 +214,37 @@ mod fake { } fn lock(&self) -> std::sync::MutexGuard<'_, HashMap<(Iv, Vec), Key>> { - // A poisoned lock only means another test thread panicked mid-insert; - // the map is still a valid map. - self.keys - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) + lock(&self.keys) } } + /// A poisoned lock only means another test thread panicked mid-insert; + /// the map is still a valid map. + fn lock(m: &Mutex) -> std::sync::MutexGuard<'_, T> { + m.lock().unwrap_or_else(std::sync::PoisonError::into_inner) + } + + fn random(rng: &mut SafeRand) -> Result { + Ok(Generatable::random(rng).map_err(GenerateKeyError::GenerateIv)?) + } + impl IndexKeySource for FakeDataKeySource { - /// Deterministically derive an index key from the `keyset_id` alone — - /// like real ZeroKMS, the same keyset always yields the same index key, - /// and distinct keysets yield distinct keys. `None` resolves to the nil - /// UUID as the fake's "default keyset". - async fn load_index_key(&self, keyset_id: Option) -> Result<(Uuid, IndexKey), Error> { - let resolved = keyset_id.unwrap_or_else(Uuid::nil); - let mut hasher = Sha256::new(); - hasher.update(b"stack-kms::FakeDataKeySource::index-key::v1"); - hasher.update(resolved.as_bytes()); - let key: Key = hasher.finalize().into(); + async fn load_index_key( + &self, + keyset_id: Option, + ) -> Result<(Uuid, IndexKey), Error> { + let mut rng = SafeRand::from_entropy().map_err(GenerateKeyError::GenerateIv)?; + let resolved = match keyset_id { + None => Uuid::nil(), + Some(IdentifiedBy::Uuid(id)) => id, + Some(IdentifiedBy::Name(name)) => *lock(&self.keysets_by_name) + .entry(name.as_bytes().to_vec()) + .or_insert_with(Uuid::new_v4), + }; + let key = match lock(&self.index_keys).entry(resolved) { + std::collections::hash_map::Entry::Occupied(e) => *e.get(), + std::collections::hash_map::Entry::Vacant(e) => *e.insert(random::(&mut rng)?), + }; Ok((resolved, IndexKey::from(key))) } } @@ -232,12 +261,9 @@ mod fake { payloads .into_iter() .map(|payload| { - let iv: Iv = - Generatable::random(&mut rng).map_err(GenerateKeyError::GenerateIv)?; - let key: Key = - Generatable::random(&mut rng).map_err(GenerateKeyError::GenerateIv)?; - let tag: [u8; 32] = - Generatable::random(&mut rng).map_err(GenerateKeyError::GenerateIv)?; + let iv: Iv = random(&mut rng)?; + let key: Key = random(&mut rng)?; + let tag: [u8; 32] = random(&mut rng)?; let _ = keys.insert((iv, tag.to_vec()), key); Ok(DataKeyWithTag { key: DataKey { iv, key }, @@ -317,9 +343,9 @@ mod tests { let keyset_a = Uuid::from_u128(1); let keyset_b = Uuid::from_u128(2); - let (id_a, key_a) = src.load_index_key(Some(keyset_a)).await.unwrap(); - let (_, key_a_again) = src.load_index_key(Some(keyset_a)).await.unwrap(); - let (_, key_b) = src.load_index_key(Some(keyset_b)).await.unwrap(); + let (id_a, key_a) = src.load_index_key(Some(keyset_a.into())).await.unwrap(); + let (_, key_a_again) = src.load_index_key(Some(keyset_a.into())).await.unwrap(); + let (_, key_b) = src.load_index_key(Some(keyset_b.into())).await.unwrap(); let (id_none, _) = src.load_index_key(None).await.unwrap(); assert_eq!(id_a, keyset_a); @@ -328,6 +354,36 @@ mod tests { assert_eq!(id_none, Uuid::nil()); } + #[tokio::test] + async fn fake_index_key_resolves_names_deterministically() { + let src = FakeDataKeySource::new(); + // `InvalidNameError` has no `Debug`, so go via `ok()`. + let by_name = |n: &str| zerokms_protocol::IdentifiedBy::Name(n.try_into().ok().unwrap()); + + let (id_a, key_a) = src.load_index_key(Some(by_name("users"))).await.unwrap(); + let (id_a_again, key_a_again) = src.load_index_key(Some(by_name("users"))).await.unwrap(); + let (id_b, key_b) = src.load_index_key(Some(by_name("orders"))).await.unwrap(); + // Pinning the resolved id must reach the same keyset as the name. + let (_, key_a_by_id) = src.load_index_key(Some(id_a.into())).await.unwrap(); + + assert_eq!(id_a, id_a_again); + assert_eq!(key_a.key(), key_a_again.key()); + assert_ne!(id_a, id_b); + assert_ne!(key_a.key(), key_b.key()); + assert_eq!(key_a.key(), key_a_by_id.key()); + } + + #[tokio::test] + async fn none_resolves_to_the_same_index_key_as_the_reported_default_keyset() { + // The pin-the-resolved-id pattern advertised by + // `IndexKeySource::load_index_key`: loading under `None` and then under + // the id it reported must yield the same index key. + let src = FakeDataKeySource::new(); + let (resolved, key_none) = src.load_index_key(None).await.unwrap(); + let (_, key_resolved) = src.load_index_key(Some(resolved.into())).await.unwrap(); + assert_eq!(key_none.key(), key_resolved.key()); + } + #[tokio::test] async fn generate_then_retrieve_reproduces_the_key() { let src = FakeDataKeySource::new(); From e426452c617935d7af3e91470a1ef6ddf9ad6677 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Tue, 25 Aug 2026 12:52:45 +1000 Subject: [PATCH 419/686] fix: address code-review findings on the stack-kms index-key PR Root fix in recipher, which owns the 33-block fact: - ProxyCipher::reencrypt now validates input length and returns Result<_, RecipherError::InvalidInputLength> instead of panicking on network-supplied key material of the wrong length (copy_from_slice on a short final chunk / the permutation length assert). recipher bumped to 0.3.0. - CipherText derives Zeroize/ZeroizeOnDrop so the intermediate block buffer holding reencrypted key material is wiped instead of freed intact; the comments on the Zeroizing-wrapped copies in stack-kms and cipherstash-client now hold. Both derivation twins consume the validation: - stack-kms and cipherstash-client DataKey/DataKeyWithTag/IndexKey ::from_key_material are now fallible, mapping to new transparent InvalidKeyMaterial variants on RetrieveKeyError / GenerateKeyError / LoadKeysetError, so a malformed generate/retrieve/load-keyset response surfaces as a typed error on every path, not just the stack-kms index-key path. The fallible-batch mapper keeps invalid material per-key. - The index-key KAT fixture moved to zerokms_protocol::testing:: index_key_kat, shared by both crates' KATs so the expected bytes cannot be regenerated in one crate without the other noticing. zerokms-protocol: - Name::try_from length guard fixed: `!value.len() <= NAME_MAX_LEN` bitwise-NOTs the usize, so the 64-char limit was dead code. Now `value.len() > NAME_MAX_LEN`, with boundary tests (the old charset test string was 67 chars and only passed because of the bug). stack-kms: - LoadKeysetError::KeysetNotFound doc + Display no longer claim every 404 is an unknown keyset (the server also 404s for an unknown client or a client with no default keyset). - The two From test modules share one support module instead of duplicating ~80 lines. - New MaybeSend marker holds the native/wasm32 Send split in one place; DataKeySource, IndexKeySource and ZeroKMSConnection are each defined once, so the wasm32 variants are type-checked on every build. - FakeDataKeySource: keyset resolution has a single source of truth (load_index_key no longer bypasses resolve_keyset), names resolve via Uuid::new_v5 (RFC 4122-valid ids), and the reserved name "default" resolves to the default keyset so the None-equals-default invariant holds for name references too. Claude-Session: https://claude.ai/code/session_01T5iYiJc6xwzMcHDtMwCadG --- packages/stack-kms/src/client.rs | 22 ++- packages/stack-kms/src/connection.rs | 20 +-- packages/stack-kms/src/errors.rs | 231 ++++++++++++++++----------- packages/stack-kms/src/key.rs | 114 ++++++------- packages/stack-kms/src/key_source.rs | 144 +++++++++-------- packages/stack-kms/src/lib.rs | 8 +- packages/stack-kms/src/maybe_send.rs | 32 ++++ 7 files changed, 327 insertions(+), 244 deletions(-) create mode 100644 packages/stack-kms/src/maybe_send.rs diff --git a/packages/stack-kms/src/client.rs b/packages/stack-kms/src/client.rs index 51587ee19..dc3eb4f58 100644 --- a/packages/stack-kms/src/client.rs +++ b/packages/stack-kms/src/client.rs @@ -147,7 +147,7 @@ impl Client { access_token: &str, make_request: impl Fn(&'a [Spec]) -> Req + Sync, response_keys: impl Fn(Req::Response) -> Vec + Sync, - map_key: impl Fn(&'a Spec, Item) -> Out + Sync, + map_key: impl Fn(&'a Spec, Item) -> Result + Sync, count_mismatch: impl Fn(usize, usize) -> E + Sync, ) -> Result, E> where @@ -174,11 +174,11 @@ impl Client { tracing::trace!(target: "stack_kms::client", operation, "received {} keys - creating data keys", keys.len()); - Ok(chunk + chunk .iter() .zip(keys) .map(|(spec, item)| map_key(spec, item)) - .collect()) + .collect::, E>>() }, self.max_keys_per_req, self.max_concurrent_reqs, @@ -228,6 +228,7 @@ impl Client { |res| res.keys, |RetrieveKeySpec { iv, .. }, RetrievedKey { key_material }| { DataKey::from_key_material(key, iv.into_inner(), &key_material) + .map_err(RetrieveKeyError::from) }, |expected, received| RetrieveKeyError::InvalidNumberOfKeys { expected, received }, ) @@ -265,12 +266,16 @@ impl Client { }, |res| res.keys, |RetrieveKeySpec { iv, .. }, result| { - result - .map(|key| { - // If the key retrieval was successful, we create a DataKey from the key material - DataKey::from_key_material(client_key, iv.into_inner(), &key.key_material) - }) + // Both failure modes stay per-key so one bad entry doesn't + // fail the whole fallible batch: a server-side retrieval + // failure and invalid key material in an otherwise-successful + // entry. + Ok(result .map_err(RetrieveKeyError::FailedRetrieval) + .and_then(|key| { + DataKey::from_key_material(client_key, iv.into_inner(), &key.key_material) + .map_err(RetrieveKeyError::from) + })) }, |expected, received| RetrieveKeyError::InvalidNumberOfKeys { expected, received }, ) @@ -362,6 +367,7 @@ impl Client { tag, decryption_policy, ) + .map_err(GenerateKeyError::from) }, |expected, received| GenerateKeyError::InvalidNumberOfKeys { expected, received }, ) diff --git a/packages/stack-kms/src/connection.rs b/packages/stack-kms/src/connection.rs index b2dd315f7..2eaeffaf1 100644 --- a/packages/stack-kms/src/connection.rs +++ b/packages/stack-kms/src/connection.rs @@ -109,26 +109,16 @@ pub trait ZeroKMSConnectionInit { Self: Sized; } -/// On native targets the returned future is `Send` so callers can drive it on -/// a multi-threaded runtime. On wasm32 the bound is dropped — reqwest's -/// fetch-backed response futures aren't `Send`, and edge runtimes are -/// single-threaded anyway. -#[cfg(not(target_arch = "wasm32"))] -pub trait ZeroKMSConnection: ZeroKMSConnectionInit { - fn send( - &self, - request: Request, - access_token: &str, - ) -> impl Future> + Send; -} - -#[cfg(target_arch = "wasm32")] +/// The returned future is bounded by [`MaybeSend`](crate::MaybeSend): `Send` +/// on native targets so callers can drive it on a multi-threaded runtime, +/// unbounded on wasm32 — reqwest's fetch-backed response futures aren't +/// `Send`, and edge runtimes are single-threaded anyway. pub trait ZeroKMSConnection: ZeroKMSConnectionInit { fn send( &self, request: Request, access_token: &str, - ) -> impl Future>; + ) -> impl Future> + crate::MaybeSend; } pub struct HttpConnection { diff --git a/packages/stack-kms/src/errors.rs b/packages/stack-kms/src/errors.rs index 6f7dc8752..095517e68 100644 --- a/packages/stack-kms/src/errors.rs +++ b/packages/stack-kms/src/errors.rs @@ -3,6 +3,14 @@ use thiserror::Error; use vitaminc::random::RandomError; use zerokms_protocol::{ViturRequestError, ViturRequestErrorKind}; +/// Key material returned by ZeroKMS failed up-front validation before key +/// derivation — e.g. a truncated or corrupt response whose material is not the +/// exact length the keyset's block permutation covers. The material is +/// network-supplied, so this must surface as an error, never a panic. +#[derive(Diagnostic, Error, Debug)] +#[error("Invalid keyset key material: {0}")] +pub struct InvalidKeyMaterialError(#[from] pub recipher::errors::RecipherError); + #[derive(Diagnostic, Error, Debug)] pub enum RetrieveKeyError { #[error("Failed to send request: {0}")] @@ -14,6 +22,10 @@ pub enum RetrieveKeyError { /// May be part of a batch retrieval operation. #[error("Failed to retrieve key: {0}")] FailedRetrieval(String), + + #[error(transparent)] + #[diagnostic(transparent)] + InvalidKeyMaterial(#[from] InvalidKeyMaterialError), } #[derive(Diagnostic, Error, Debug)] @@ -26,6 +38,10 @@ pub enum GenerateKeyError { GenerateIv(RandomError), #[error("Received an invalid number of keys from request. Expected {expected} but received {received}")] InvalidNumberOfKeys { expected: usize, received: usize }, + + #[error(transparent)] + #[diagnostic(transparent)] + InvalidKeyMaterial(#[from] InvalidKeyMaterialError), // Catch-all for any `ViturRequestError` not classified as Forbidden / // Unauthorized above. Display surfaces the `kind` (operational enum // — `SendRequest`, `Other`, `ParseResponse`, ...) and `message` @@ -49,70 +65,6 @@ impl From for GenerateKeyError { } } -#[cfg(test)] -mod generate_key_error_from_vitur_request_error { - use super::*; - - const SOURCE_DETAIL: &str = "transport-detail-7f3a"; - - fn err(kind: ViturRequestErrorKind) -> ViturRequestError { - ViturRequestError::new(kind, "boom", std::io::Error::other(SOURCE_DETAIL)) - } - - #[test] - fn forbidden_maps_to_forbidden() { - assert!(matches!( - GenerateKeyError::from(err(ViturRequestErrorKind::Forbidden)), - GenerateKeyError::Forbidden - )); - } - - #[test] - fn unauthorized_maps_to_unauthorized() { - assert!(matches!( - GenerateKeyError::from(err(ViturRequestErrorKind::Unauthorized)), - GenerateKeyError::Unauthorized - )); - } - - #[test] - fn every_other_kind_maps_to_request_failed_keeping_the_kind() { - for kind in [ - ViturRequestErrorKind::PrepareRequest, - ViturRequestErrorKind::SendRequest, - ViturRequestErrorKind::NotFound, - ViturRequestErrorKind::Conflict, - ViturRequestErrorKind::FailureResponse, - ViturRequestErrorKind::ParseResponse, - ViturRequestErrorKind::Other, - ] { - // `ViturRequestErrorKind` has no `PartialEq`; compare by Debug name. - let name = format!("{kind:?}"); - let mapped = GenerateKeyError::from(err(kind)); - assert!( - matches!(&mapped, GenerateKeyError::RequestFailed(e) if format!("{:?}", e.kind) == name), - "{name} must map to RequestFailed carrying the same kind, got: {mapped:?}" - ); - } - } - - #[test] - fn request_failed_display_names_the_kind_and_message_but_not_the_source() { - let mapped = GenerateKeyError::from(err(ViturRequestErrorKind::SendRequest)); - let shown = mapped.to_string(); - assert!(shown.contains("SendRequest"), "{shown}"); - assert!(shown.contains("boom"), "{shown}"); - assert!( - !shown.contains(SOURCE_DETAIL), - "the dynamic source error must stay out of Display: {shown}" - ); - assert!( - std::error::Error::source(&mapped).is_some(), - "the source must still be reachable through the error chain" - ); - } -} - #[derive(Diagnostic, Error, Debug)] pub enum LoadKeysetError { // `Unauthorized` / `Forbidden` carry the underlying request error (unlike @@ -125,13 +77,17 @@ pub enum LoadKeysetError { Unauthorized(#[source] ViturRequestError), #[error("Request forbidden due to insufficient permissions")] Forbidden(#[source] ViturRequestError), - // `load-keyset` uniquely takes a caller-supplied keyset id or name, so an - // unknown keyset (server 404) is an expected, user-actionable outcome — - // e.g. a typo'd name or a load-or-create flow — not an "unexpected error". - #[error("Keyset not found")] + // `load-keyset` uniquely takes a caller-supplied keyset id or name, so a + // server 404 is an expected, user-actionable outcome — e.g. a typo'd + // name — not an "unexpected error". Note the server also responds 404 + // when the *client* is unknown or has no default keyset, so a 404 does + // not prove the named keyset is missing: inspect `source()` for the + // server's response body before treating this as "create the keyset". + #[error("Keyset not found (or the client is unknown or has no default keyset)")] KeysetNotFound(#[source] ViturRequestError), - #[error("Invalid keyset key material: expected {expected} bytes but received {received}")] - InvalidKeyMaterial { expected: usize, received: usize }, + #[error(transparent)] + #[diagnostic(transparent)] + InvalidKeyMaterial(#[from] InvalidKeyMaterialError), // Same shape as `GenerateKeyError::RequestFailed`: Display carries only the // static kind/message; the dynamic error stays behind `source()`. #[error("Unexpected error ({}: {})", .0.kind, .0.message)] @@ -149,16 +105,106 @@ impl From for LoadKeysetError { } } +/// Shared scaffolding for the `From` mapping tests below: +/// one place for the fixture error and the assertions both mappings need, so +/// a new error type doesn't copy another 80 lines. #[cfg(test)] -mod load_keyset_error_from_vitur_request_error { +mod vitur_error_mapping_support { use super::*; - const SOURCE_DETAIL: &str = "transport-detail-7f3a"; + pub(super) const SOURCE_DETAIL: &str = "transport-detail-7f3a"; - fn err(kind: ViturRequestErrorKind) -> ViturRequestError { + pub(super) fn err(kind: ViturRequestErrorKind) -> ViturRequestError { ViturRequestError::new(kind, "boom", std::io::Error::other(SOURCE_DETAIL)) } + /// Every kind in `kinds` must map to the catch-all RequestFailed variant + /// carrying the same kind (checked via `is_request_failed_with_kind`). + pub(super) fn assert_kinds_map_to_request_failed( + kinds: impl IntoIterator, + from: impl Fn(ViturRequestError) -> E, + is_request_failed_with_kind: impl Fn(&E, &str) -> bool, + ) { + for kind in kinds { + // `ViturRequestErrorKind` has no `PartialEq`; compare by Debug name. + let name = format!("{kind:?}"); + let mapped = from(err(kind)); + assert!( + is_request_failed_with_kind(&mapped, &name), + "{name} must map to RequestFailed carrying the same kind, got: {mapped:?}" + ); + } + } + + /// The catch-all's Display must name the kind and static message while + /// keeping the dynamic source out; the source stays reachable through the + /// error chain. + pub(super) fn assert_request_failed_display(mapped: &impl std::error::Error) { + let shown = mapped.to_string(); + assert!(shown.contains("SendRequest"), "{shown}"); + assert!(shown.contains("boom"), "{shown}"); + assert!( + !shown.contains(SOURCE_DETAIL), + "the dynamic source error must stay out of Display: {shown}" + ); + assert!( + mapped.source().is_some(), + "the source must still be reachable through the error chain" + ); + } +} + +#[cfg(test)] +mod generate_key_error_from_vitur_request_error { + use super::vitur_error_mapping_support::*; + use super::*; + + #[test] + fn forbidden_maps_to_forbidden() { + assert!(matches!( + GenerateKeyError::from(err(ViturRequestErrorKind::Forbidden)), + GenerateKeyError::Forbidden + )); + } + + #[test] + fn unauthorized_maps_to_unauthorized() { + assert!(matches!( + GenerateKeyError::from(err(ViturRequestErrorKind::Unauthorized)), + GenerateKeyError::Unauthorized + )); + } + + #[test] + fn every_other_kind_maps_to_request_failed_keeping_the_kind() { + assert_kinds_map_to_request_failed( + [ + ViturRequestErrorKind::PrepareRequest, + ViturRequestErrorKind::SendRequest, + ViturRequestErrorKind::NotFound, + ViturRequestErrorKind::Conflict, + ViturRequestErrorKind::FailureResponse, + ViturRequestErrorKind::ParseResponse, + ViturRequestErrorKind::Other, + ], + GenerateKeyError::from, + |mapped, name| matches!(mapped, GenerateKeyError::RequestFailed(e) if format!("{:?}", e.kind) == name), + ); + } + + #[test] + fn request_failed_display_names_the_kind_and_message_but_not_the_source() { + assert_request_failed_display(&GenerateKeyError::from(err( + ViturRequestErrorKind::SendRequest, + ))); + } +} + +#[cfg(test)] +mod load_keyset_error_from_vitur_request_error { + use super::vitur_error_mapping_support::*; + use super::*; + #[test] fn forbidden_maps_to_forbidden_keeping_the_source() { let mapped = LoadKeysetError::from(err(ViturRequestErrorKind::Forbidden)); @@ -185,22 +231,18 @@ mod load_keyset_error_from_vitur_request_error { #[test] fn every_other_kind_maps_to_request_failed_keeping_the_kind() { - for kind in [ - ViturRequestErrorKind::PrepareRequest, - ViturRequestErrorKind::SendRequest, - ViturRequestErrorKind::Conflict, - ViturRequestErrorKind::FailureResponse, - ViturRequestErrorKind::ParseResponse, - ViturRequestErrorKind::Other, - ] { - // `ViturRequestErrorKind` has no `PartialEq`; compare by Debug name. - let name = format!("{kind:?}"); - let mapped = LoadKeysetError::from(err(kind)); - assert!( - matches!(&mapped, LoadKeysetError::RequestFailed(e) if format!("{:?}", e.kind) == name), - "{name} must map to RequestFailed carrying the same kind, got: {mapped:?}" - ); - } + assert_kinds_map_to_request_failed( + [ + ViturRequestErrorKind::PrepareRequest, + ViturRequestErrorKind::SendRequest, + ViturRequestErrorKind::Conflict, + ViturRequestErrorKind::FailureResponse, + ViturRequestErrorKind::ParseResponse, + ViturRequestErrorKind::Other, + ], + LoadKeysetError::from, + |mapped, name| matches!(mapped, LoadKeysetError::RequestFailed(e) if format!("{:?}", e.kind) == name), + ); } #[test] @@ -222,14 +264,9 @@ mod load_keyset_error_from_vitur_request_error { #[test] fn request_failed_display_names_the_kind_and_message() { - let mapped = LoadKeysetError::from(err(ViturRequestErrorKind::SendRequest)); - let shown = mapped.to_string(); - assert!(shown.contains("SendRequest"), "{shown}"); - assert!(shown.contains("boom"), "{shown}"); - assert!( - std::error::Error::source(&mapped).is_some(), - "the source must still be reachable through the error chain" - ); + assert_request_failed_display(&LoadKeysetError::from(err( + ViturRequestErrorKind::SendRequest, + ))); } } diff --git a/packages/stack-kms/src/key.rs b/packages/stack-kms/src/key.rs index e4c6f195c..f9183c296 100644 --- a/packages/stack-kms/src/key.rs +++ b/packages/stack-kms/src/key.rs @@ -12,7 +12,7 @@ use vitaminc::protected::{OpaqueDebug, TimingSafeEq}; use zeroize::{Zeroize, ZeroizeOnDrop, Zeroizing}; use zerokms_protocol::{DecryptionPolicy, ViturKeyMaterial}; -use crate::errors::LoadKeysetError; +use crate::errors::{InvalidKeyMaterialError, LoadKeysetError}; /// NOTE: Debug is safe to implement because [KeySet] is opaque. #[derive(Debug, Deserialize, Clone, Zeroize, ZeroizeOnDrop, Serialize)] @@ -67,22 +67,30 @@ opaque_debug::implement!(DataKey); impl DataKey { /// Create a DataKey for a specific [`ClientKey`] given a specific initialisation vector /// (IV) and key material obtained from ZeroKMS. - pub fn from_key_material(key: &ClientKey, iv: Iv, key_material: &ViturKeyMaterial) -> Self { + /// + /// Returns [`InvalidKeyMaterialError`] when the material is not the exact + /// length the keyset accepts (recipher validates up front) — the material + /// is network-supplied, so a truncated or corrupt response must not panic. + pub fn from_key_material( + key: &ClientKey, + iv: Iv, + key_material: &ViturKeyMaterial, + ) -> Result { let cipher = ProxyCipher::new(key.keyset.keyset()); // `rect` is reencrypted key material — the derived data key is a hash of - // it — so wipe the intermediate on drop rather than leave it on the - // heap. (The Sha256 block buffer keeps the final <=64-byte block; sha2 - // 0.10 doesn't implement Zeroize and changing the hash would alter the - // derived key, so that residue is accepted.) - let rect = Zeroizing::new(cipher.reencrypt::<16>(&iv, key_material)); + // it — so wipe the returned copy on drop; recipher wipes its own + // intermediate block buffer. (The Sha256 block buffer keeps the final + // <=64-byte block; sha2 0.10 doesn't implement Zeroize and changing the + // hash would alter the derived key, so that residue is accepted.) + let rect = Zeroizing::new(cipher.reencrypt::<16>(&iv, key_material)?); let mut hasher = Sha256::new(); hasher.update(rect.as_slice()); - DataKey { + Ok(DataKey { iv, key: hasher.finalize().into(), - } + }) } pub fn key(&self) -> &Key { @@ -106,19 +114,20 @@ opaque_debug::implement!(DataKeyWithTag); impl DataKeyWithTag { /// Create a DataKey for a specific [`ClientKey`] given a specific IV, key material and tag - /// obtained from ZeroKMS. + /// obtained from ZeroKMS. See [`DataKey::from_key_material`] for the + /// key-material validation this inherits. pub fn from_key_material( key: &ClientKey, iv: Iv, key_material: &ViturKeyMaterial, tag: Vec, decryption_policy: Option, - ) -> Self { - Self { - key: DataKey::from_key_material(key, iv, key_material), + ) -> Result { + Ok(Self { + key: DataKey::from_key_material(key, iv, key_material)?, tag, decryption_policy, - } + }) } } @@ -140,32 +149,18 @@ impl Deref for DataKeyWithTag { #[derive(Zeroize, ZeroizeOnDrop, OpaqueDebug)] pub struct IndexKey(Key); -/// The exact key-material length `ProxyCipher::reencrypt::<16>` accepts: the -/// keyset's block permutation covers 33 16-byte blocks (see recipher's -/// `EncryptionKeySet`), so anything else panics inside recipher -/// (`copy_from_slice` on a short final chunk, or the permutation length -/// assert). Validated up front so a malformed ZeroKMS response surfaces as a -/// typed error instead of a crash. -pub(crate) const KEYSET_KEY_MATERIAL_LEN: usize = 33 * 16; - impl IndexKey { /// Derive the index key for a specific [`ClientKey`] from the partial /// keyset-root key material obtained from ZeroKMS. /// /// Returns [`LoadKeysetError::InvalidKeyMaterial`] when the material is - /// not exactly [`KEYSET_KEY_MATERIAL_LEN`] bytes — the material is + /// not the exact length the keyset accepts (33 16-byte blocks; recipher + /// owns the fact and validates up front) — the material is /// network-supplied, so a truncated or corrupt response must not panic. pub fn from_key_material( key: &ClientKey, key_material: &ViturKeyMaterial, ) -> Result { - if key_material.len() != KEYSET_KEY_MATERIAL_LEN { - return Err(LoadKeysetError::InvalidKeyMaterial { - expected: KEYSET_KEY_MATERIAL_LEN, - received: key_material.len(), - }); - } - // We use all zeros for the IV for the keyset index key. // This key is not used for encryption but for indexing using PRFs and // similar constructions. Even then, because all other data keys are @@ -173,9 +168,13 @@ impl IndexKey { let iv = Iv::default(); let cipher = ProxyCipher::new(key.keyset.keyset()); // `rect` is reencrypted key material — the derived index key is a hash - // of it — so wipe the intermediate on drop rather than leave it on the - // heap (matches `DataKey::from_key_material`). - let rect = Zeroizing::new(cipher.reencrypt::<16>(&iv, key_material)); + // of it — so wipe the returned copy on drop; recipher wipes its own + // intermediate block buffer (matches `DataKey::from_key_material`). + let rect = Zeroizing::new( + cipher + .reencrypt::<16>(&iv, key_material) + .map_err(InvalidKeyMaterialError::from)?, + ); let mut hasher = blake3::Hasher::new(); // Bind the `OutputReader` so it can be wiped: it holds the final @@ -350,31 +349,27 @@ mod tests { mod index_key { use super::*; use crate::errors::LoadKeysetError; - use crate::key::{IndexKey, KEYSET_KEY_MATERIAL_LEN}; - - /// Fixed keyset for the known-answer test below (see its comment). - const KAT_KEYSET_HEX: &str = "a4627031a16b7065726d75746174696f6e900e02000c0705010b0f09080a0d0304066770325f66726f6da16b7065726d75746174696f6e9005000c020b0d06010a0903080e0f04076570325f746fa16b7065726d75746174696f6e900e00030c05060b010a0407090d0f0802627033a16b7065726d75746174696f6e982102160f09181e1819181f0d07181b13110804150610050e1818181c00181a0a0112031820140b17181d0c"; + use crate::key::IndexKey; + use zerokms_protocol::testing::index_key_kat; fn kat_client_key() -> ClientKey { - ClientKey::from_hex_v1(uuid::Uuid::nil(), KAT_KEYSET_HEX).unwrap() + ClientKey::from_hex_v1(uuid::Uuid::nil(), index_key_kat::KEYSET_HEX).unwrap() } fn kat_material() -> zerokms_protocol::ViturKeyMaterial { - (0..KEYSET_KEY_MATERIAL_LEN as u32) - .map(|i| (i % 251) as u8) - .collect::>() - .into() + index_key_kat::key_material().into() } - /// Known-answer test pinning the index-key derivation to fixed bytes. + /// Known-answer test pinning the index-key derivation to the shared + /// fixture in `zerokms_protocol::testing::index_key_kat`. /// - /// The identical vector lives in cipherstash-client - /// (`zerokms::vitur_client::key`): the `ZEROKMS-INDEXKEY` zero-IV - /// blake3-XOF derivation is duplicated across the two crates and must - /// stay bit-identical, or records indexed via one stack become - /// silently unfindable when queried via the other. If this test - /// breaks, the derivation changed — do NOT update the expected bytes - /// without changing cipherstash-client in lockstep. + /// cipherstash-client (`zerokms::vitur_client::key`) runs the same KAT + /// against the same fixture: the `ZEROKMS-INDEXKEY` zero-IV blake3-XOF + /// derivation is duplicated across the two crates and must stay + /// bit-identical, or records indexed via one stack become silently + /// unfindable when queried via the other. If this test breaks, the + /// derivation changed — do NOT update the fixture without changing + /// cipherstash-client in lockstep. #[test] fn from_key_material_matches_the_known_answer() { let index_key = @@ -382,7 +377,7 @@ mod tests { assert_eq!( base16ct::lower::encode_string(index_key.key()), - "f68a664c120234a50f98ca5301a3c8e7a4762626f33a7af02068c0dbd84dd416", + index_key_kat::EXPECTED_INDEX_KEY_HEX, ); } @@ -395,15 +390,24 @@ mod tests { #[test] fn from_key_material_rejects_invalid_lengths_instead_of_panicking() { + use recipher::errors::RecipherError; + let ck = kat_client_key(); + let expected_len = index_key_kat::key_material().len(); // Truncated, empty, non-block-multiple and over-long payloads: all // network-supplied shapes that previously panicked inside recipher. - for len in [0usize, 1, 16, 527, 529, KEYSET_KEY_MATERIAL_LEN * 2] { + for len in [0usize, 1, 16, 527, 529, expected_len * 2] { let material: zerokms_protocol::ViturKeyMaterial = vec![0u8; len].into(); match IndexKey::from_key_material(&ck, &material) { - Err(LoadKeysetError::InvalidKeyMaterial { expected, received }) => { - assert_eq!(expected, KEYSET_KEY_MATERIAL_LEN); - assert_eq!(received, len); + Err(LoadKeysetError::InvalidKeyMaterial(e)) => { + assert!( + matches!( + e.0, + RecipherError::InvalidInputLength { expected, received } + if expected == expected_len && received == len + ), + "unexpected inner error: {e:?}" + ); } other => panic!( "length {len} must be rejected as InvalidKeyMaterial, got: {other:?}" diff --git a/packages/stack-kms/src/key_source.rs b/packages/stack-kms/src/key_source.rs index b1baaa15f..c33cd8808 100644 --- a/packages/stack-kms/src/key_source.rs +++ b/packages/stack-kms/src/key_source.rs @@ -15,6 +15,7 @@ use zerokms_protocol::{IdentifiedBy, UnverifiedContext}; use crate::errors::Error; use crate::key::{DataKey, DataKeyWithTag, IndexKey}; +use crate::maybe_send::MaybeSend; use crate::payload::{GenerateKeyPayload, RetrieveKeyPayload}; /// The slice of ZeroKMS data-key functionality required to encrypt and decrypt: @@ -24,12 +25,9 @@ use crate::payload::{GenerateKeyPayload, RetrieveKeyPayload}; /// so the trait stays simple to implement and the returned futures are easy to /// box behind an async `vitaminc_aead::Decipher`. /// -/// On native targets the returned futures are `Send` so callers can drive them -/// on a multi-threaded runtime. On wasm32 the bound is dropped, mirroring -/// [`ZeroKMSConnection`](crate::ZeroKMSConnection) and -/// [`stack_auth::AuthStrategy`]: the fetch-backed HTTP and auth futures there -/// aren't `Send`, and edge runtimes are single-threaded anyway. -#[cfg(not(target_arch = "wasm32"))] +/// The returned futures are bounded by [`MaybeSend`]: `Send` on native targets +/// so callers can drive them on a multi-threaded runtime, unbounded on wasm32 +/// (see [`MaybeSend`] for why). pub trait DataKeySource { /// Generate one fresh data key per payload, in payload order. fn generate_keys( @@ -37,7 +35,7 @@ pub trait DataKeySource { payloads: Vec>, keyset_id: Option, unverified_context: Option>, - ) -> impl Future, Error>> + Send; + ) -> impl Future, Error>> + MaybeSend; /// Re-derive one data key per payload, in payload order. Each payload's IV + /// tag (returned by a prior [`generate_keys`](DataKeySource::generate_keys) @@ -47,30 +45,7 @@ pub trait DataKeySource { payloads: Vec>, keyset_id: Option, unverified_context: Option<&UnverifiedContext>, - ) -> impl Future, Error>> + Send; -} - -/// See the native definition above; identical minus the `Send` bound on the -/// returned futures. -#[cfg(target_arch = "wasm32")] -pub trait DataKeySource { - /// Generate one fresh data key per payload, in payload order. - fn generate_keys( - &self, - payloads: Vec>, - keyset_id: Option, - unverified_context: Option>, - ) -> impl Future, Error>>; - - /// Re-derive one data key per payload, in payload order. Each payload's IV + - /// tag (returned by a prior [`generate_keys`](DataKeySource::generate_keys) - /// call and stored with the ciphertext) must reproduce the same key. - fn retrieve_keys( - &self, - payloads: Vec>, - keyset_id: Option, - unverified_context: Option<&UnverifiedContext>, - ) -> impl Future, Error>>; + ) -> impl Future, Error>> + MaybeSend; } /// The slice of ZeroKMS functionality required to *index* encrypted data: @@ -81,9 +56,7 @@ pub trait DataKeySource { /// separately: record encryption needs data keys, term generation needs the /// index key. Production implementations provide both. /// -/// As with [`DataKeySource`], the returned future is `Send` on native targets -/// and unbounded on wasm32. -#[cfg(not(target_arch = "wasm32"))] +/// As with [`DataKeySource`], the returned future is bounded by [`MaybeSend`]. pub trait IndexKeySource { /// Load the index key for a keyset — identified by id or name, or the /// client's default keyset when `keyset_id` is `None`. Returns the @@ -96,25 +69,7 @@ pub trait IndexKeySource { fn load_index_key( &self, keyset_id: Option, - ) -> impl Future> + Send; -} - -/// See the native definition above; identical minus the `Send` bound on the -/// returned future. -#[cfg(target_arch = "wasm32")] -pub trait IndexKeySource { - /// Load the index key for a keyset — identified by id or name, or the - /// client's default keyset when `keyset_id` is `None`. Returns the - /// resolved keyset id alongside the key, so callers pinning `None` or a - /// name learn which keyset they resolved to. - /// - /// The index key is deterministic per keyset: loading it twice yields the - /// same key, so terms generated at write time match terms generated at - /// query time. - fn load_index_key( - &self, - keyset_id: Option, - ) -> impl Future>; + ) -> impl Future> + MaybeSend; } impl DataKeySource for crate::StackKms @@ -163,6 +118,7 @@ mod fake { use crate::errors::{GenerateKeyError, RetrieveKeyError}; use crate::key::DataKey; use recipher::key::{Iv, Key}; + use sha2::{Digest, Sha256}; use std::collections::HashMap; use std::sync::Mutex; use vitaminc::random::{Generatable, SafeRand}; @@ -187,19 +143,28 @@ mod fake { /// ZeroKMS's, tested in `vitur-server-core`; do not assert them against /// this stub. Consumers testing *what they send* should mock the trait. /// - /// As an [`IndexKeySource`] it is likewise a stub: each keyset gets a - /// random index key on first load and the same one thereafter, names - /// resolve to a random keyset id memoised per name, and `None` is the - /// nil UUID. Deterministic *per instance* — which is all the trait asks — - /// and nothing more. + /// As an [`IndexKeySource`] the index key is a fixed function of the + /// resolved keyset id (and a name resolves to a fixed v5 UUID), so two + /// independently built stubs agree — search terms generated by one cipher + /// must match terms generated by another, and known-answer tests can pin + /// term bytes. That is plain determinism, not ZeroKMS's derivation. #[derive(Debug, Default)] pub struct FakeDataKeySource { keys: Mutex), Key>>, - keysets_by_name: Mutex, Uuid>>, - index_keys: Mutex>, } + /// UUID namespace for name resolution: `Uuid::new_v5` mints deterministic, + /// RFC 4122-valid UUIDs that strict validation downstream accepts. + const KEYSET_NAME_NAMESPACE: Uuid = Uuid::from_u128(0x8ff8_1a03_4b2d_4f0b_9d6e_5a1c_3f7e_2b41); + impl FakeDataKeySource { + /// The name the fake reserves for the client's default keyset: + /// resolving `IdentifiedBy::Name("default")` reaches the same keyset + /// as passing `None` or the nil UUID. Real ZeroKMS resolves whatever + /// name the default keyset was created under; the fake fixes it to + /// this constant so name-based tests can address the default keyset. + pub const DEFAULT_KEYSET_NAME: &'static str = "default"; + pub fn new() -> Self { Self::default() } @@ -233,18 +198,22 @@ mod fake { &self, keyset_id: Option, ) -> Result<(Uuid, IndexKey), Error> { - let mut rng = SafeRand::from_entropy().map_err(GenerateKeyError::GenerateIv)?; let resolved = match keyset_id { None => Uuid::nil(), Some(IdentifiedBy::Uuid(id)) => id, - Some(IdentifiedBy::Name(name)) => *lock(&self.keysets_by_name) - .entry(name.as_bytes().to_vec()) - .or_insert_with(Uuid::new_v4), - }; - let key = match lock(&self.index_keys).entry(resolved) { - std::collections::hash_map::Entry::Occupied(e) => *e.get(), - std::collections::hash_map::Entry::Vacant(e) => *e.insert(random::(&mut rng)?), + Some(IdentifiedBy::Name(name)) + if &*name == FakeDataKeySource::DEFAULT_KEYSET_NAME => + { + Uuid::nil() + } + Some(IdentifiedBy::Name(name)) => { + Uuid::new_v5(&KEYSET_NAME_NAMESPACE, name.as_bytes()) + } }; + let mut hasher = Sha256::new(); + hasher.update(b"stack-kms::FakeDataKeySource::index-key::v1"); + hasher.update(resolved.as_bytes()); + let key: Key = hasher.finalize().into(); Ok((resolved, IndexKey::from(key))) } } @@ -354,6 +323,15 @@ mod tests { assert_eq!(id_none, Uuid::nil()); } + #[tokio::test] + async fn independently_built_stubs_agree_on_index_keys() { + // Search terms generated through one cipher must match terms generated + // through another, so the index key cannot be per-instance state. + let (_, a) = FakeDataKeySource::new().load_index_key(None).await.unwrap(); + let (_, b) = FakeDataKeySource::new().load_index_key(None).await.unwrap(); + assert_eq!(a.key(), b.key()); + } + #[tokio::test] async fn fake_index_key_resolves_names_deterministically() { let src = FakeDataKeySource::new(); @@ -373,6 +351,36 @@ mod tests { assert_eq!(key_a.key(), key_a_by_id.key()); } + #[tokio::test] + async fn fake_name_resolution_mints_rfc4122_uuids() { + let src = FakeDataKeySource::new(); + let by_name = |n: &str| zerokms_protocol::IdentifiedBy::Name(n.try_into().ok().unwrap()); + + let (id, _) = src.load_index_key(Some(by_name("users"))).await.unwrap(); + + // Strict UUID validation downstream must accept the minted ids. + assert_eq!(id.get_version_num(), 5); + assert_eq!(id.get_variant(), uuid::Variant::RFC4122); + } + + #[tokio::test] + async fn the_default_keyset_name_is_equivalent_to_none() { + // Real ZeroKMS resolves the default keyset's name to the same keyset + // as `None`, so keys and index terms written under `None` must be + // reachable by the fake's reserved default-keyset name too. + let src = FakeDataKeySource::new(); + let by_name = |n: &str| zerokms_protocol::IdentifiedBy::Name(n.try_into().ok().unwrap()); + + let (id_none, key_none) = src.load_index_key(None).await.unwrap(); + let (id_name, key_name) = src + .load_index_key(Some(by_name(FakeDataKeySource::DEFAULT_KEYSET_NAME))) + .await + .unwrap(); + + assert_eq!(id_none, id_name); + assert_eq!(key_none.key(), key_name.key()); + } + #[tokio::test] async fn none_resolves_to_the_same_index_key_as_the_reported_default_keyset() { // The pin-the-resolved-id pattern advertised by diff --git a/packages/stack-kms/src/lib.rs b/packages/stack-kms/src/lib.rs index 6300589ae..6e68c9abf 100644 --- a/packages/stack-kms/src/lib.rs +++ b/packages/stack-kms/src/lib.rs @@ -73,6 +73,7 @@ mod futures; mod key; mod key_provider; mod key_source; +mod maybe_send; mod payload; mod secret_key; mod user_agent; @@ -94,8 +95,13 @@ pub use connection::{ }; pub use endpoint::{InvalidEndpoint, ZeroKmsEndpoint}; +// The native/wasm32 Send split for the async traits' returned futures +pub use maybe_send::MaybeSend; + // Errors -pub use errors::{Error, GenerateKeyError, LoadKeysetError, RetrieveKeyError}; +pub use errors::{ + Error, GenerateKeyError, InvalidKeyMaterialError, LoadKeysetError, RetrieveKeyError, +}; // Key material pub use key::{ClientKey, DataKey, DataKeyWithTag, IndexKey, V1KeySet}; diff --git a/packages/stack-kms/src/maybe_send.rs b/packages/stack-kms/src/maybe_send.rs new file mode 100644 index 000000000..f07ca75e1 --- /dev/null +++ b/packages/stack-kms/src/maybe_send.rs @@ -0,0 +1,32 @@ +//! The single place holding this crate's native/wasm32 `Send` split. +//! +//! Async traits here ([`DataKeySource`](crate::DataKeySource), +//! [`IndexKeySource`](crate::IndexKeySource), +//! [`ZeroKMSConnection`](crate::ZeroKMSConnection)) want their returned +//! futures `Send` on native targets — so callers can drive them on a +//! multi-threaded runtime — but not on wasm32, where the fetch-backed HTTP and +//! auth futures aren't `Send` and edge runtimes are single-threaded anyway +//! (mirroring `stack_auth::AuthStrategy`). Bounding those futures with +//! [`MaybeSend`] lets each trait be defined once for both targets instead of +//! as a duplicated `#[cfg]` pair that default-target CI only half +//! type-checks. + +/// Alias for `Send` on native targets; satisfied by every type on wasm32. +/// +/// Never implement this manually — the blanket impl covers everything the +/// target allows. +#[cfg(not(target_arch = "wasm32"))] +pub trait MaybeSend: Send {} + +#[cfg(not(target_arch = "wasm32"))] +impl MaybeSend for T {} + +/// Alias for `Send` on native targets; satisfied by every type on wasm32. +/// +/// Never implement this manually — the blanket impl covers everything the +/// target allows. +#[cfg(target_arch = "wasm32")] +pub trait MaybeSend {} + +#[cfg(target_arch = "wasm32")] +impl MaybeSend for T {} From 21f3fca61a5a73e399a62771212f674594213fe7 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Wed, 26 Aug 2026 15:51:22 +1000 Subject: [PATCH 420/686] chore(stack-encrypt): adopt the stack-auth crate lint baseline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to the review on cipherstash/cipherstash-suite#2019: the crate lint block was missing `unreachable_pub` and `unused_results`, and warned on `unwrap`/`expect`/`panic` in test code as well as production code. Adopts stack-auth's block verbatim — same lints, same grouping comments, and the `cfg_attr(test, allow(..))` relaxations so unit tests can still unwrap and panic freely. Both new lints are already satisfied: no code changes were needed, and clippy with `-D warnings` over `--all-targets` is clean. Note the test relaxations only reach the crate's own `#[cfg(test)]` modules; `tests/roundtrip.rs` is a separate crate and was never covered by these lints. Claude-Session: https://claude.ai/code/session_01DSVBr8UVPYU7g1gAJC6P4q --- packages/stack-encrypt/src/lib.rs | 31 ++++++++++++++++++++----------- 1 file changed, 20 insertions(+), 11 deletions(-) diff --git a/packages/stack-encrypt/src/lib.rs b/packages/stack-encrypt/src/lib.rs index 6a3545917..17cd1cf34 100644 --- a/packages/stack-encrypt/src/lib.rs +++ b/packages/stack-encrypt/src/lib.rs @@ -1,16 +1,25 @@ #![doc(html_favicon_url = "https://cipherstash.com/favicon.ico")] +// Security lints #![deny(unsafe_code)] -#![warn( - clippy::unwrap_used, - clippy::expect_used, - clippy::panic, - clippy::mem_forget, - clippy::print_stdout, - clippy::print_stderr, - clippy::dbg_macro, - clippy::todo, - clippy::unimplemented -)] +#![warn(clippy::unwrap_used)] +#![warn(clippy::expect_used)] +#![warn(clippy::panic)] +// Prevent mem::forget from bypassing ZeroizeOnDrop +#![warn(clippy::mem_forget)] +// Prevent accidental data leaks via output +#![warn(clippy::print_stdout)] +#![warn(clippy::print_stderr)] +#![warn(clippy::dbg_macro)] +// Code quality +#![warn(unreachable_pub)] +#![warn(unused_results)] +#![warn(clippy::todo)] +#![warn(clippy::unimplemented)] +// Relax in tests +#![cfg_attr(test, allow(clippy::unwrap_used))] +#![cfg_attr(test, allow(clippy::expect_used))] +#![cfg_attr(test, allow(clippy::panic))] +#![cfg_attr(test, allow(unused_results))] //! Encrypt Rust values under per-value ZeroKMS data keys. //! //! [`StackCipher`] encrypts any value that implements [`Encrypt`] (`String`, From 03f04766cc67bff426fc55d52d096c2141a968e5 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Fri, 21 Aug 2026 16:34:49 +0930 Subject: [PATCH 421/686] feat(stack-encrypt): PRF-based SEM term generation (equality, match, ORE/OPE) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `stack_encrypt::sem::TermGenerator`, generic over `P: Prf` from vitaminc-prf, producing Searchable Encrypted Metadata terms: - Equality terms: one PRF block over the whole value (`PrfValue`), domain-separated per field descriptor. - Match terms: local tokenization (ngram/word, optional downcase), each token PRF'd as a sequence, outputs folded into Bloom-filter bit positions via a `PrfVisitor` (k 2-byte LE slices per 32-byte block, masked to a power-of-two m; defaults k=3, m=256 mirroring the existing match indexer). - ORE/OPE terms: CLLW ciphertexts from cllw-ore under per-descriptor keys derived *through the PRF* from the descriptor alone — plaintext never enters the PRF, and under a future 2-party PRF backend the key derivation becomes an auditable ZeroKMS event. Every term method awaits `Prf::Ok` (an `IntoFuture`), so the local `HmacSha256Prf` backend — keyed by `IndexKeySource::load_index_key` — and the coming 2-party ZeroKMS PRF backend share the same call sites. This is a fresh v2 term format (PAE-framed PRF contexts), intentionally not byte-compatible with cipherstash-client's `IndexTerm` values. Also gates cllw-ore's `OpeCllw8V1::from_bytes` behind the `chrono`/`decimal` features that use it: stack-encrypt is the first consumer with neither feature enabled, where the function was dead code and failed `-D warnings`. Claude-Session: https://claude.ai/code/session_01T5iYiJc6xwzMcHDtMwCadG --- packages/stack-encrypt/Cargo.toml | 6 + packages/stack-encrypt/src/lib.rs | 1 + packages/stack-encrypt/src/sem/mod.rs | 330 +++++++++++++++++++++ packages/stack-encrypt/src/sem/tokenize.rs | 85 ++++++ packages/stack-encrypt/tests/sem_terms.rs | 196 ++++++++++++ 5 files changed, 618 insertions(+) create mode 100644 packages/stack-encrypt/src/sem/mod.rs create mode 100644 packages/stack-encrypt/src/sem/tokenize.rs create mode 100644 packages/stack-encrypt/tests/sem_terms.rs diff --git a/packages/stack-encrypt/Cargo.toml b/packages/stack-encrypt/Cargo.toml index 93dcfc942..923f95658 100644 --- a/packages/stack-encrypt/Cargo.toml +++ b/packages/stack-encrypt/Cargo.toml @@ -29,8 +29,13 @@ stack-kms = { path = "../stack-kms", default-features = false } # stays on the published 0.2.0-pre until the next vitaminc release. vitaminc-aead = { git = "https://github.com/cipherstash/vitaminc", rev = "06fc3eaf8e39eab4aa9c6107c0ff2fc2952bebc3" } vitaminc-encrypt = { git = "https://github.com/cipherstash/vitaminc", rev = "06fc3eaf8e39eab4aa9c6107c0ff2fc2952bebc3" } +vitaminc-hmac = { git = "https://github.com/cipherstash/vitaminc", rev = "06fc3eaf8e39eab4aa9c6107c0ff2fc2952bebc3" } +vitaminc-prf = { git = "https://github.com/cipherstash/vitaminc", rev = "06fc3eaf8e39eab4aa9c6107c0ff2fc2952bebc3" } vitaminc-protected = { git = "https://github.com/cipherstash/vitaminc", rev = "06fc3eaf8e39eab4aa9c6107c0ff2fc2952bebc3" } serde = { workspace = true } +zeroize = { workspace = true } + +cllw-ore = { workspace = true } thiserror = { workspace = true } uuid = { workspace = true } @@ -38,6 +43,7 @@ uuid = { workspace = true } serde_json = { workspace = true } stack-kms = { path = "../stack-kms", features = ["test-support"] } tokio = { workspace = true, features = ["rt", "macros"] } +vitaminc-hmac = { git = "https://github.com/cipherstash/vitaminc", rev = "06fc3eaf8e39eab4aa9c6107c0ff2fc2952bebc3" } vitaminc-protected = { git = "https://github.com/cipherstash/vitaminc", rev = "06fc3eaf8e39eab4aa9c6107c0ff2fc2952bebc3" } [package.metadata.docs.rs] diff --git a/packages/stack-encrypt/src/lib.rs b/packages/stack-encrypt/src/lib.rs index 6a3545917..d21d1b3cf 100644 --- a/packages/stack-encrypt/src/lib.rs +++ b/packages/stack-encrypt/src/lib.rs @@ -123,6 +123,7 @@ //! format). mod cipher; +pub mod sem; pub use cipher::{ BoxedPassthrough, Error, PendingStackCipherText, SealedValue, StackCipher, StackCipherText, diff --git a/packages/stack-encrypt/src/sem/mod.rs b/packages/stack-encrypt/src/sem/mod.rs new file mode 100644 index 000000000..f8d53e487 --- /dev/null +++ b/packages/stack-encrypt/src/sem/mod.rs @@ -0,0 +1,330 @@ +//! Searchable Encrypted Metadata (SEM) term generation. +//! +//! [`TermGenerator`] produces the index terms stored alongside a +//! [`ZeroKmsCipherText`](crate::ZeroKmsCipherText) so encrypted values can be +//! queried without decryption: +//! +//! * **Equality terms** ([`TermGenerator::equality_term`]) — a PRF of the whole +//! value; supports exact-match queries. +//! * **Match terms** ([`TermGenerator::match_terms`]) — the value is tokenized +//! locally, each token is PRF'd, and the outputs fold into Bloom-filter bit +//! positions; supports full-text match queries. +//! * **ORE / OPE terms** ([`TermGenerator::ore_term`] / +//! [`TermGenerator::ope_term`]) — CLLW order-revealing / order-preserving +//! ciphertexts under a per-descriptor key derived *through the PRF*; support +//! range queries. +//! +//! # PRF backends and the 2-party future +//! +//! The generator is generic over `P:`[`Prf`], and every term method awaits the +//! PRF output ([`Prf::Ok`] is `IntoFuture`). With the local +//! [`HmacSha256Prf`](vitaminc_hmac::HmacSha256Prf) backend — keyed by the +//! deterministic per-keyset [`IndexKey`](stack_kms::IndexKey) from +//! [`stack_kms::IndexKeySource`] — outputs are immediately ready. The next +//! ZeroKMS release adds 2-party PRF generation; that backend returns deferred +//! outputs resolved by a server round-trip, and slots in behind the same `P` +//! parameter with no API change. This is also why ORE/OPE *keys* are derived +//! through the PRF (from the field descriptor, never the plaintext): under a +//! 2-party backend, per-field key derivation becomes a visible, auditable +//! ZeroKMS event while plaintext stays local. +//! +//! # Determinism and domain separation +//! +//! Index terms are deterministic by design — the same value under the same +//! descriptor always yields the same term, which is what makes them queryable +//! (and is the usual SEM leakage trade-off: equal values are visibly equal). +//! Every term kind derives under its own PAE-encoded domain, bound to the +//! caller's field `descriptor`, so the same value indexed as an equality term, +//! a match token, or an ORE key can never produce colliding PRF outputs. +//! +//! This is a fresh (v2) term format: PRF inputs are framed with vitaminc's PAE +//! context encoding, so terms are intentionally **not** byte-compatible with +//! `cipherstash-client`'s existing `IndexTerm` values. + +mod tokenize; + +pub use tokenize::Tokenizer; + +use cllw_ore::{CllwOpeEncrypt, CllwOreEncrypt}; +use vitaminc_prf::{ + BlockVisitor, MapAccess, Prf, PrfContext, PrfError, PrfValue, PrfVisitor, PrfVisitorError, + SeqAccess, +}; +use vitaminc_protected::Protected; + +/// PAE domain for equality (exact-match) terms. +const EQUALITY_DOMAIN: &[u8] = b"stack-encrypt/sem/equality/v1"; +/// PAE domain for match (full-text) token terms. +const MATCH_DOMAIN: &[u8] = b"stack-encrypt/sem/match/v1"; +/// PAE domain for ORE key derivation. +const ORE_KEY_DOMAIN: &[u8] = b"stack-encrypt/sem/ore-key/v1"; +/// PAE domain for OPE key derivation (distinct from ORE: OPE ciphertexts are +/// encrypt-only, so the two schemes must never share a key). +const OPE_KEY_DOMAIN: &[u8] = b"stack-encrypt/sem/ope-key/v1"; + +/// Errors from SEM term generation. +#[derive(Debug, thiserror::Error)] +pub enum TermError { + /// The PRF backend failed (for a remote 2-party backend this includes + /// transport errors). + #[error("PRF failed: {0}")] + Prf(#[source] Box), + /// CLLW ORE/OPE encryption failed. + #[error("ORE/OPE encryption failed: {0}")] + Ore(#[from] cllw_ore::Error), + /// The supplied [`MatchOptions`] are invalid. + #[error("invalid match options: {0}")] + InvalidOptions(&'static str), +} + +impl TermError { + fn from_prf(err: PrfError) -> Self + where + E: std::error::Error + Send + Sync + 'static, + { + Self::Prf(Box::new(err)) + } +} + +/// An equality (exact-match) index term: one PRF block over the whole value. +/// +/// Terms are pseudorandom under the index key; they are stored server-side and +/// are not secret key material. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct EqualityTerm([u8; 32]); + +impl EqualityTerm { + pub fn as_bytes(&self) -> &[u8; 32] { + &self.0 + } + + pub fn into_bytes(self) -> [u8; 32] { + self.0 + } +} + +impl From for Vec { + fn from(term: EqualityTerm) -> Self { + term.0.to_vec() + } +} + +/// A match (full-text) index term: the set bit positions of a Bloom filter over +/// the PRF outputs of the value's tokens. Positions are sorted and de-duplicated. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MatchTerm { + positions: Vec, +} + +impl MatchTerm { + /// The set Bloom-filter bit positions, sorted ascending, no duplicates. + pub fn positions(&self) -> &[u16] { + &self.positions + } + + /// Whether this term's positions are a superset of `query`'s — the Bloom + /// containment check used to evaluate a match query (with the usual Bloom + /// false-positive rate; false negatives cannot occur). + pub fn contains(&self, query: &MatchTerm) -> bool { + query + .positions + .iter() + .all(|p| self.positions.binary_search(p).is_ok()) + } +} + +/// Options controlling match-term generation. The defaults mirror the existing +/// match indexer: 3-gram tokens, downcased, `k = 3` hash slices into an +/// `m = 256`-bit filter. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MatchOptions { + /// How text splits into tokens. + pub tokenizer: Tokenizer, + /// Lower-case the text before tokenization (case-insensitive matching). + pub downcase: bool, + /// Number of bit positions derived per token. Bounded by the PRF block + /// size: each position consumes 2 bytes of the 32-byte block, so `1..=16`. + pub k: usize, + /// Bloom filter size in bits. Must be a power of two in `[16, 65536]`. + pub m: u32, +} + +impl Default for MatchOptions { + fn default() -> Self { + Self { + tokenizer: Tokenizer::default(), + downcase: true, + k: 3, + m: 256, + } + } +} + +impl MatchOptions { + fn validate(&self) -> Result { + if !(1..=16).contains(&self.k) { + return Err(TermError::InvalidOptions("k must be in 1..=16")); + } + if !self.m.is_power_of_two() || !(16..=65536).contains(&self.m) { + return Err(TermError::InvalidOptions( + "m must be a power of two in [16, 65536]", + )); + } + // For m = 65536 the mask is u16::MAX; positions always fit in u16. + Ok((self.m - 1) as u16) + } +} + +/// A [`PrfVisitor`] that folds a sequence of per-token PRF blocks into +/// Bloom-filter bit positions: `k` little-endian 2-byte slices of each block, +/// masked to the filter size. +struct BloomVisitor { + k: usize, + mask: u16, +} + +impl PrfVisitor<[u8; 32], P> for BloomVisitor { + type Value = MatchTerm; + + fn visit_seq(self, seq: SeqAccess<[u8; 32], P>) -> Result { + let mut positions: Vec = Vec::with_capacity(seq.len() * self.k); + for node in seq { + let block = node.visit(BlockVisitor)?; + for i in 0..self.k { + let chunk = [block[2 * i], block[2 * i + 1]]; + positions.push(u16::from_le_bytes(chunk) & self.mask); + } + } + positions.sort_unstable(); + positions.dedup(); + Ok(MatchTerm { positions }) + } + + // A map-shaped input is not a token stream. + fn visit_map(self, _map: MapAccess<[u8; 32], P>) -> Result { + Err(PrfVisitorError::UnexpectedShape) + } +} + +/// Generates Searchable Encrypted Metadata terms over a PRF backend `P`. +/// +/// Construct from a per-keyset index key with [`from_index_key`] +/// (local HMAC backend), or from any [`Prf`] backend with [`new`] — see the +/// module docs for the 2-party story. +/// +/// [`from_index_key`]: TermGenerator::from_index_key +/// [`new`]: TermGenerator::new +#[derive(Clone)] +pub struct TermGenerator

{ + prf: P, +} + +impl TermGenerator { + /// Build a generator over the local HMAC-SHA256 PRF, keyed by the + /// deterministic per-keyset index key (see + /// [`stack_kms::IndexKeySource::load_index_key`]). + pub fn from_index_key(index_key: &stack_kms::IndexKey) -> Self { + Self::new(vitaminc_hmac::HmacSha256Prf::new(Protected::new( + *index_key.key(), + ))) + } +} + +impl

TermGenerator

{ + /// Build a generator over an arbitrary PRF backend. + pub fn new(prf: P) -> Self { + Self { prf } + } +} + +impl

TermGenerator

+where + P: Prf + Clone, +{ + /// Generate an equality (exact-match) term for `value` under the field + /// `descriptor`. Deterministic: the same value + descriptor always yields + /// the same term, at write time and at query time. + pub async fn equality_term( + &self, + value: T, + descriptor: &str, + ) -> Result + where + T: PrfValue, + { + let context = PrfContext::pae(&[EQUALITY_DOMAIN, descriptor.as_bytes()]); + let block = value + .prf_with_context(self.prf.clone(), context) + .await + .map_err(TermError::from_prf)?; + Ok(EqualityTerm(block)) + } + + /// Generate a match (full-text) term for `text` under the field + /// `descriptor`: tokenize locally, PRF each token, fold the outputs into + /// Bloom-filter bit positions. + /// + /// The same call serves both write time (index the stored text) and query + /// time (index the probe text, then test + /// [`MatchTerm::contains`] server-side). + pub async fn match_terms( + &self, + text: &str, + descriptor: &str, + options: &MatchOptions, + ) -> Result { + let mask = options.validate()?; + let tokens = tokenize::tokenize(text, options.tokenizer, options.downcase); + let context = PrfContext::pae(&[MATCH_DOMAIN, descriptor.as_bytes()]); + + tokens + .prf_visit_with_context( + self.prf.clone(), + context, + BloomVisitor { k: options.k, mask }, + ) + .await + .map_err(TermError::from_prf) + } + + /// Generate an order-revealing (CLLW ORE) term for a range-queryable value + /// under the field `descriptor`. The ORE key is derived through the PRF + /// from the descriptor alone — the plaintext never enters the PRF. + /// + /// Supported inputs: `u16`/`u32`/`u64`/`u128`, `&str`, `&[u8]` (via + /// [`CllwOreEncrypt`]). + pub async fn ore_term(&self, value: T, descriptor: &str) -> Result + where + T: CllwOreEncrypt, + { + let key = self.derive_cllw_key(ORE_KEY_DOMAIN, descriptor).await?; + value.encrypt(&key).map_err(TermError::Ore) + } + + /// Generate an order-preserving (CLLW OPE) term: ciphertexts compare with + /// plain lexicographic byte order, no custom comparator required. + /// Encrypt-only — pair with the record ciphertext for round-trips. + pub async fn ope_term(&self, value: T, descriptor: &str) -> Result + where + T: CllwOpeEncrypt, + { + let key = self.derive_cllw_key(OPE_KEY_DOMAIN, descriptor).await?; + value.encrypt_ope(&key).map_err(TermError::Ore) + } + + /// Derive a per-descriptor CLLW key: PRF of the descriptor under the given + /// domain. Deterministic, so write-time and query-time terms agree; under a + /// 2-party PRF backend this derivation is a visible ZeroKMS event. + async fn derive_cllw_key( + &self, + domain: &'static [u8], + descriptor: &str, + ) -> Result { + let context = PrfContext::from_slice(domain).into_owned(); + let block = descriptor + .prf_with_context(self.prf.clone(), context) + .await + .map_err(TermError::from_prf)?; + Ok(cllw_ore::Key::from(block)) + } +} diff --git a/packages/stack-encrypt/src/sem/tokenize.rs b/packages/stack-encrypt/src/sem/tokenize.rs new file mode 100644 index 000000000..da1ad201d --- /dev/null +++ b/packages/stack-encrypt/src/sem/tokenize.rs @@ -0,0 +1,85 @@ +//! Tokenization for match (full-text) index terms. +//! +//! Tokens are derived locally from the plaintext *before* any PRF is applied; +//! only the PRF outputs (Bloom-filter bit positions) leave the process. + +/// How text is split into tokens before each token is run through the PRF. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Tokenizer { + /// Sliding character n-grams of the given length over the whole text + /// (whitespace included). Text shorter than `length` yields a single token + /// of the whole text. This is the default, matching the existing match + /// indexer's 3-gram configuration. + Ngram { length: usize }, + /// Split on Unicode whitespace, one token per word. + Standard, +} + +impl Default for Tokenizer { + fn default() -> Self { + Self::Ngram { length: 3 } + } +} + +/// Split `text` into tokens. `downcase` lower-cases the text first so matches +/// are case-insensitive. +pub(crate) fn tokenize(text: &str, tokenizer: Tokenizer, downcase: bool) -> Vec { + let text = if downcase { + text.to_lowercase() + } else { + text.to_string() + }; + + match tokenizer { + Tokenizer::Standard => text.split_whitespace().map(str::to_string).collect(), + Tokenizer::Ngram { length } => { + let chars: Vec = text.chars().collect(); + if chars.is_empty() { + Vec::new() + } else if chars.len() <= length { + vec![text] + } else { + chars.windows(length).map(|w| w.iter().collect()).collect() + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn ngram_tokenizes_sliding_windows() { + assert_eq!( + tokenize("hello", Tokenizer::Ngram { length: 3 }, true), + vec!["hel", "ell", "llo"] + ); + } + + #[test] + fn ngram_short_text_yields_whole_text() { + assert_eq!( + tokenize("hi", Tokenizer::Ngram { length: 3 }, true), + vec!["hi"] + ); + } + + #[test] + fn ngram_empty_text_yields_no_tokens() { + assert!(tokenize("", Tokenizer::Ngram { length: 3 }, true).is_empty()); + } + + #[test] + fn standard_splits_on_whitespace_and_downcases() { + assert_eq!( + tokenize("Hello World", Tokenizer::Standard, true), + vec!["hello", "world"] + ); + } + + #[test] + fn downcase_can_be_disabled() { + assert_eq!(tokenize("Hi", Tokenizer::Standard, false), vec!["Hi"]); + } +} diff --git a/packages/stack-encrypt/tests/sem_terms.rs b/packages/stack-encrypt/tests/sem_terms.rs new file mode 100644 index 000000000..83a089bf3 --- /dev/null +++ b/packages/stack-encrypt/tests/sem_terms.rs @@ -0,0 +1,196 @@ +//! SEM term-generation tests against the local HMAC PRF backend, keyed by the +//! deterministic fake index key — no ZeroKMS credentials or network required. + +use std::cmp::Ordering; + +use stack_encrypt::sem::{MatchOptions, TermGenerator, Tokenizer}; +use stack_kms::{FakeDataKeySource, IndexKeySource}; +use uuid::Uuid; + +async fn generator() -> TermGenerator { + let (_, index_key) = FakeDataKeySource::new() + .load_index_key(None) + .await + .expect("load index key"); + TermGenerator::from_index_key(&index_key) +} + +async fn generator_for(keyset: Uuid) -> TermGenerator { + let (_, index_key) = FakeDataKeySource::new() + .load_index_key(Some(keyset)) + .await + .expect("load index key"); + TermGenerator::from_index_key(&index_key) +} + +#[tokio::test] +async fn equality_terms_are_deterministic() { + let gen = generator().await; + let a = gen.equality_term("alice", "users/email").await.unwrap(); + let b = gen.equality_term("alice", "users/email").await.unwrap(); + assert_eq!(a, b, "same value + descriptor must yield the same term"); +} + +#[tokio::test] +async fn equality_terms_bind_the_descriptor() { + let gen = generator().await; + let a = gen.equality_term("alice", "users/email").await.unwrap(); + let b = gen.equality_term("alice", "users/name").await.unwrap(); + assert_ne!(a, b, "the descriptor must domain-separate terms"); +} + +#[tokio::test] +async fn equality_terms_differ_by_value() { + let gen = generator().await; + let a = gen.equality_term("alice", "users/email").await.unwrap(); + let b = gen.equality_term("bob", "users/email").await.unwrap(); + assert_ne!(a, b); +} + +#[tokio::test] +async fn equality_terms_bind_the_index_key() { + let gen_a = generator_for(Uuid::from_u128(1)).await; + let gen_b = generator_for(Uuid::from_u128(2)).await; + let a = gen_a.equality_term("alice", "users/email").await.unwrap(); + let b = gen_b.equality_term("alice", "users/email").await.unwrap(); + assert_ne!(a, b, "different keysets must yield different terms"); +} + +#[tokio::test] +async fn match_query_terms_are_contained_in_stored_terms() { + let gen = generator().await; + let opts = MatchOptions::default(); + + let stored = gen + .match_terms("alice wonderland", "users/bio", &opts) + .await + .unwrap(); + let query = gen.match_terms("wonder", "users/bio", &opts).await.unwrap(); + + assert!( + stored.contains(&query), + "a substring's tokens must be contained in the stored term" + ); +} + +#[tokio::test] +async fn match_is_case_insensitive_by_default() { + let gen = generator().await; + let opts = MatchOptions::default(); + + let stored = gen.match_terms("Alice", "users/name", &opts).await.unwrap(); + let query = gen.match_terms("alice", "users/name", &opts).await.unwrap(); + assert_eq!(stored, query); +} + +#[tokio::test] +async fn match_binds_the_descriptor() { + let gen = generator().await; + let opts = MatchOptions::default(); + + let stored = gen.match_terms("alice", "users/bio", &opts).await.unwrap(); + let query = gen.match_terms("alice", "users/name", &opts).await.unwrap(); + assert_ne!(stored, query, "match tokens must be descriptor-bound"); +} + +#[tokio::test] +async fn match_positions_stay_within_the_filter() { + let gen = generator().await; + let opts = MatchOptions { + m: 64, + ..Default::default() + }; + + let term = gen + .match_terms("a longer piece of text", "users/bio", &opts) + .await + .unwrap(); + assert!(!term.positions().is_empty()); + assert!(term.positions().iter().all(|&p| u32::from(p) < opts.m)); + // Sorted + deduped. + assert!(term.positions().windows(2).all(|w| w[0] < w[1])); +} + +#[tokio::test] +async fn match_rejects_invalid_options() { + let gen = generator().await; + let bad_k = MatchOptions { + k: 17, + ..Default::default() + }; + assert!(gen.match_terms("x", "d", &bad_k).await.is_err()); + + let bad_m = MatchOptions { + m: 100, + ..Default::default() + }; + assert!(gen.match_terms("x", "d", &bad_m).await.is_err()); +} + +#[tokio::test] +async fn word_tokenizer_matches_whole_words() { + let gen = generator().await; + let opts = MatchOptions { + tokenizer: Tokenizer::Standard, + ..Default::default() + }; + + let stored = gen + .match_terms("alice in wonderland", "users/bio", &opts) + .await + .unwrap(); + let query = gen + .match_terms("wonderland", "users/bio", &opts) + .await + .unwrap(); + assert!(stored.contains(&query)); +} + +#[tokio::test] +async fn ore_terms_preserve_order_and_determinism() { + let gen = generator().await; + + let ten = gen.ore_term(10u64, "users/age").await.unwrap(); + let ten_again = gen.ore_term(10u64, "users/age").await.unwrap(); + let twenty = gen.ore_term(20u64, "users/age").await.unwrap(); + + assert_eq!(ten, ten_again, "ORE terms must be deterministic"); + assert_eq!(ten.cmp(&twenty), Ordering::Less); +} + +#[tokio::test] +async fn ore_terms_bind_the_descriptor() { + let gen = generator().await; + let a = gen.ore_term(10u64, "users/age").await.unwrap(); + let b = gen.ore_term(10u64, "users/height").await.unwrap(); + assert_ne!(a, b, "per-descriptor ORE keys must differ"); +} + +#[tokio::test] +async fn string_ore_terms_preserve_lexicographic_order() { + let gen = generator().await; + let apple = gen.ore_term("apple", "users/name").await.unwrap(); + let banana = gen.ore_term("banana", "users/name").await.unwrap(); + assert_eq!(apple.cmp(&banana), Ordering::Less); +} + +#[tokio::test] +async fn ope_terms_compare_with_plain_byte_order() { + let gen = generator().await; + + let ten = gen.ope_term(10u64, "users/age").await.unwrap(); + let twenty = gen.ope_term(20u64, "users/age").await.unwrap(); + + // OPE ciphertexts order with standard lexicographic comparison. + assert!(ten.as_ref() < twenty.as_ref()); +} + +#[tokio::test] +async fn ore_and_ope_keys_are_domain_separated() { + // The same descriptor must not derive the same key material for both + // schemes; equal plaintexts should produce different ciphertext bytes. + let gen = generator().await; + let ore = gen.ore_term(42u64, "users/age").await.unwrap(); + let ope = gen.ope_term(42u64, "users/age").await.unwrap(); + assert_ne!(ore.as_ref(), ope.as_ref()); +} From d68a9940c93669282caa379d4ad3b6cfb662250d Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Sun, 23 Aug 2026 23:21:11 +1000 Subject: [PATCH 422/686] chore(stack-encrypt): pin vitaminc-prf/hmac to the same rev; drop unused zeroize dep Post-rebase onto the updated cipherstash/cipherstash-suite#2019 base: the SEM crates follow the pinned-rev convention instead of `branch = "main"`, and `zeroize` is no longer used directly by this crate. Claude-Session: https://claude.ai/code/session_01T5iYiJc6xwzMcHDtMwCadG --- packages/stack-encrypt/Cargo.toml | 1 - packages/stack-encrypt/src/sem/mod.rs | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/stack-encrypt/Cargo.toml b/packages/stack-encrypt/Cargo.toml index 923f95658..57db37d11 100644 --- a/packages/stack-encrypt/Cargo.toml +++ b/packages/stack-encrypt/Cargo.toml @@ -33,7 +33,6 @@ vitaminc-hmac = { git = "https://github.com/cipherstash/vitaminc", rev = "06fc3e vitaminc-prf = { git = "https://github.com/cipherstash/vitaminc", rev = "06fc3eaf8e39eab4aa9c6107c0ff2fc2952bebc3" } vitaminc-protected = { git = "https://github.com/cipherstash/vitaminc", rev = "06fc3eaf8e39eab4aa9c6107c0ff2fc2952bebc3" } serde = { workspace = true } -zeroize = { workspace = true } cllw-ore = { workspace = true } thiserror = { workspace = true } diff --git a/packages/stack-encrypt/src/sem/mod.rs b/packages/stack-encrypt/src/sem/mod.rs index f8d53e487..a670fd8cf 100644 --- a/packages/stack-encrypt/src/sem/mod.rs +++ b/packages/stack-encrypt/src/sem/mod.rs @@ -1,7 +1,7 @@ //! Searchable Encrypted Metadata (SEM) term generation. //! //! [`TermGenerator`] produces the index terms stored alongside a -//! [`ZeroKmsCipherText`](crate::ZeroKmsCipherText) so encrypted values can be +//! [`StackCipherText`](crate::StackCipherText) so encrypted values can be //! queried without decryption: //! //! * **Equality terms** ([`TermGenerator::equality_term`]) — a PRF of the whole From 8c7301e84662242ec8022e35d0905e7c575446c9 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Mon, 24 Aug 2026 23:35:11 +1000 Subject: [PATCH 423/686] fix(stack-encrypt): wrap keyset id in IdentifiedBy::Uuid in sem_terms test load_index_key now takes Option after the keyset-loading review fixes (48f5d8415); the rebased test still passed a bare Uuid. Claude-Session: https://claude.ai/code/session_01T5iYiJc6xwzMcHDtMwCadG --- packages/stack-encrypt/tests/sem_terms.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/stack-encrypt/tests/sem_terms.rs b/packages/stack-encrypt/tests/sem_terms.rs index 83a089bf3..b9538b150 100644 --- a/packages/stack-encrypt/tests/sem_terms.rs +++ b/packages/stack-encrypt/tests/sem_terms.rs @@ -4,7 +4,7 @@ use std::cmp::Ordering; use stack_encrypt::sem::{MatchOptions, TermGenerator, Tokenizer}; -use stack_kms::{FakeDataKeySource, IndexKeySource}; +use stack_kms::{FakeDataKeySource, IdentifiedBy, IndexKeySource}; use uuid::Uuid; async fn generator() -> TermGenerator { @@ -17,7 +17,7 @@ async fn generator() -> TermGenerator { async fn generator_for(keyset: Uuid) -> TermGenerator { let (_, index_key) = FakeDataKeySource::new() - .load_index_key(Some(keyset)) + .load_index_key(Some(IdentifiedBy::Uuid(keyset))) .await .expect("load index key"); TermGenerator::from_index_key(&index_key) From f2e6db4fcac34ffdc8d175cda13e10a24f84b7aa Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Tue, 25 Aug 2026 13:57:37 +1000 Subject: [PATCH 424/686] fix(stack-encrypt): address SEM review findings; align match semantics with the v1 indexer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Match-term correctness (review + codex P1/P2): - match_terms now rejects text that yields no tokens with the new TermError::EmptyTermText — an empty term used as a query would vacuously match every stored row, and an n-gram probe shorter than the gram length could never match anything (silent false negative). MatchTerm::contains also returns false for an empty query as a defensive backstop for terms from other sources. - MatchOptions::validate rejects Ngram { length: 0 } (previously a panic in chars.windows(0)) and adopts the v1 indexer's bounds: k in 3..=16, m a power of two in [32, 65536], mirroring cipherstash-core's bloom_filter constants. Tokenizer now mirrors the v1 match indexer as documented: - Ngram: text shorter than the gram length yields no tokens (was: a single whole-text token that could never match a stored gram). - Standard: splits on the v1 separator set ' ,;:!' (was: Unicode whitespace). One deliberate divergence: empty tokens are dropped rather than PRF'd as v1 does. Key-derivation hygiene: - derive_cllw_key uses the same PAE-encoded [domain, descriptor] context framing as every other term kind, honouring the module's documented domain-separation invariant before any terms persist (previously a raw domain context with the descriptor only as PRF input, safe only by the HMAC leaf's internal framing). - cllw_ore::Key now derives Zeroize/ZeroizeOnDrop (cllw-ore 0.4.3) and the derivation wipes its stack copy of the PRF block, so the per-descriptor ORE/OPE key material is cleaned up after use (codex P2). Claude-Session: https://claude.ai/code/session_01T5iYiJc6xwzMcHDtMwCadG --- packages/stack-encrypt/Cargo.toml | 1 + packages/stack-encrypt/src/sem/mod.rs | 77 +++++++++++++++++----- packages/stack-encrypt/src/sem/tokenize.rs | 74 +++++++++++++++++---- packages/stack-encrypt/tests/sem_terms.rs | 63 +++++++++++++++++- 4 files changed, 182 insertions(+), 33 deletions(-) diff --git a/packages/stack-encrypt/Cargo.toml b/packages/stack-encrypt/Cargo.toml index 57db37d11..a13c3b7d3 100644 --- a/packages/stack-encrypt/Cargo.toml +++ b/packages/stack-encrypt/Cargo.toml @@ -37,6 +37,7 @@ serde = { workspace = true } cllw-ore = { workspace = true } thiserror = { workspace = true } uuid = { workspace = true } +zeroize = { workspace = true } [dev-dependencies] serde_json = { workspace = true } diff --git a/packages/stack-encrypt/src/sem/mod.rs b/packages/stack-encrypt/src/sem/mod.rs index a670fd8cf..e16541a57 100644 --- a/packages/stack-encrypt/src/sem/mod.rs +++ b/packages/stack-encrypt/src/sem/mod.rs @@ -51,6 +51,7 @@ use vitaminc_prf::{ SeqAccess, }; use vitaminc_protected::Protected; +use zeroize::Zeroize; /// PAE domain for equality (exact-match) terms. const EQUALITY_DOMAIN: &[u8] = b"stack-encrypt/sem/equality/v1"; @@ -75,6 +76,16 @@ pub enum TermError { /// The supplied [`MatchOptions`] are invalid. #[error("invalid match options: {0}")] InvalidOptions(&'static str), + /// The text produced no tokens under the configured tokenizer — empty or + /// separator-only text, or (for n-grams, as in the v1 match indexer) text + /// shorter than the n-gram length. Rejected at generation time for both + /// the write and query paths: an empty term used as a query would + /// vacuously match every stored row, and a sub-gram-length probe could + /// never match anything (a silent false negative). + #[error( + "text produces no match tokens (empty, separator-only, or shorter than the n-gram length)" + )] + EmptyTermText, } impl TermError { @@ -124,12 +135,20 @@ impl MatchTerm { /// Whether this term's positions are a superset of `query`'s — the Bloom /// containment check used to evaluate a match query (with the usual Bloom - /// false-positive rate; false negatives cannot occur). + /// false-positive rate; a query that generates tokens can never produce a + /// false negative). + /// + /// An empty `query` returns `false`: containment of zero positions is + /// vacuously true, which would turn an empty probe into a match-every-row + /// query. [`TermGenerator::match_terms`] already refuses to build such a + /// term ([`TermError::EmptyTermText`]); this guards any other + /// (e.g. deserialized) source of an empty term. pub fn contains(&self, query: &MatchTerm) -> bool { - query - .positions - .iter() - .all(|p| self.positions.binary_search(p).is_ok()) + !query.positions.is_empty() + && query + .positions + .iter() + .all(|p| self.positions.binary_search(p).is_ok()) } } @@ -142,10 +161,12 @@ pub struct MatchOptions { pub tokenizer: Tokenizer, /// Lower-case the text before tokenization (case-insensitive matching). pub downcase: bool, - /// Number of bit positions derived per token. Bounded by the PRF block - /// size: each position consumes 2 bytes of the 32-byte block, so `1..=16`. + /// Number of bit positions derived per token, `3..=16` (the v1 match + /// indexer's bounds; the upper bound is also the PRF block size — each + /// position consumes 2 bytes of the 32-byte block). pub k: usize, - /// Bloom filter size in bits. Must be a power of two in `[16, 65536]`. + /// Bloom filter size in bits. Must be a power of two in `[32, 65536]` + /// (the v1 match indexer's bounds). pub m: u32, } @@ -161,13 +182,21 @@ impl Default for MatchOptions { } impl MatchOptions { + // Bounds mirror the v1 match indexer (`cipherstash-core`'s + // `bloom_filter`: K_MIN/K_MAX/M_MIN/M_MAX) so the same configuration + // validates identically across the two stacks. fn validate(&self) -> Result { - if !(1..=16).contains(&self.k) { - return Err(TermError::InvalidOptions("k must be in 1..=16")); + if let Tokenizer::Ngram { length: 0 } = self.tokenizer { + return Err(TermError::InvalidOptions( + "n-gram length must be at least 1", + )); } - if !self.m.is_power_of_two() || !(16..=65536).contains(&self.m) { + if !(3..=16).contains(&self.k) { + return Err(TermError::InvalidOptions("k must be in 3..=16")); + } + if !self.m.is_power_of_two() || !(32..=65536).contains(&self.m) { return Err(TermError::InvalidOptions( - "m must be a power of two in [16, 65536]", + "m must be a power of two in [32, 65536]", )); } // For m = 65536 the mask is u16::MAX; positions always fit in u16. @@ -267,6 +296,10 @@ where /// The same call serves both write time (index the stored text) and query /// time (index the probe text, then test /// [`MatchTerm::contains`] server-side). + /// + /// Returns [`TermError::EmptyTermText`] when the text yields no tokens — + /// empty or separator-only text, or an n-gram probe shorter than the gram + /// length (which could never match; see [`Tokenizer::Ngram`]). pub async fn match_terms( &self, text: &str, @@ -275,6 +308,9 @@ where ) -> Result { let mask = options.validate()?; let tokens = tokenize::tokenize(text, options.tokenizer, options.downcase); + if tokens.is_empty() { + return Err(TermError::EmptyTermText); + } let context = PrfContext::pae(&[MATCH_DOMAIN, descriptor.as_bytes()]); tokens @@ -312,19 +348,26 @@ where value.encrypt_ope(&key).map_err(TermError::Ore) } - /// Derive a per-descriptor CLLW key: PRF of the descriptor under the given - /// domain. Deterministic, so write-time and query-time terms agree; under a + /// Derive a per-descriptor CLLW key: PRF of the descriptor under a + /// PAE-encoded `[domain, descriptor]` context — the same framing every + /// other term kind uses (see the module docs), so no reimplementation of + /// this derivation can collide with an equality or match derivation. + /// Deterministic, so write-time and query-time terms agree; under a /// 2-party PRF backend this derivation is a visible ZeroKMS event. async fn derive_cllw_key( &self, domain: &'static [u8], descriptor: &str, ) -> Result { - let context = PrfContext::from_slice(domain).into_owned(); - let block = descriptor + let context = PrfContext::pae(&[domain, descriptor.as_bytes()]); + let mut block = descriptor .prf_with_context(self.prf.clone(), context) .await .map_err(TermError::from_prf)?; - Ok(cllw_ore::Key::from(block)) + // `Key` wipes itself on drop; wipe the stack copy the move leaves + // behind ([u8; 32] is `Copy`). + let key = cllw_ore::Key::from(block); + block.zeroize(); + Ok(key) } } diff --git a/packages/stack-encrypt/src/sem/tokenize.rs b/packages/stack-encrypt/src/sem/tokenize.rs index da1ad201d..7bb3632cb 100644 --- a/packages/stack-encrypt/src/sem/tokenize.rs +++ b/packages/stack-encrypt/src/sem/tokenize.rs @@ -2,19 +2,35 @@ //! //! Tokens are derived locally from the plaintext *before* any PRF is applied; //! only the PRF outputs (Bloom-filter bit positions) leave the process. +//! +//! Semantics mirror the v1 match indexer (`cipherstash-client`'s +//! `encryption::text::Tokenizer`) so v2 match queries behave like existing +//! ones: n-grams yield nothing for text shorter than the gram length, and +//! `Standard` splits on the same separator set. The one deliberate divergence +//! is that empty tokens are dropped (v1 keeps the empty strings its separator +//! split produces, which only add noise bits to every filter). /// How text is split into tokens before each token is run through the PRF. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Tokenizer { /// Sliding character n-grams of the given length over the whole text - /// (whitespace included). Text shorter than `length` yields a single token - /// of the whole text. This is the default, matching the existing match - /// indexer's 3-gram configuration. + /// (whitespace included). Text shorter than `length` yields **no tokens**, + /// exactly like the v1 match indexer — so a probe shorter than the gram + /// length is rejected by + /// [`match_terms`](crate::sem::TermGenerator::match_terms) rather than + /// silently never matching. This is the default, matching the existing + /// match indexer's 3-gram configuration. Ngram { length: usize }, - /// Split on Unicode whitespace, one token per word. + /// Split on the v1 match indexer's separator set — space, comma, + /// semicolon, colon and exclamation mark — one token per run of + /// non-separator characters. Standard, } +/// The separators `Tokenizer::Standard` splits on, as in the v1 match +/// indexer's `process_standard`. +const STANDARD_SEPARATORS: [char; 5] = [' ', ',', ';', ':', '!']; + impl Default for Tokenizer { fn default() -> Self { Self::Ngram { length: 3 } @@ -23,6 +39,12 @@ impl Default for Tokenizer { /// Split `text` into tokens. `downcase` lower-cases the text first so matches /// are case-insensitive. +/// +/// May return no tokens (empty text, separator-only text, or text shorter +/// than the n-gram length); [`match_terms`] rejects that case so an empty +/// term can never reach a query. +/// +/// [`match_terms`]: crate::sem::TermGenerator::match_terms pub(crate) fn tokenize(text: &str, tokenizer: Tokenizer, downcase: bool) -> Vec { let text = if downcase { text.to_lowercase() @@ -31,13 +53,18 @@ pub(crate) fn tokenize(text: &str, tokenizer: Tokenizer, downcase: bool) -> Vec< }; match tokenizer { - Tokenizer::Standard => text.split_whitespace().map(str::to_string).collect(), + Tokenizer::Standard => text + .split(STANDARD_SEPARATORS) + .filter(|token| !token.is_empty()) + .map(str::to_string) + .collect(), Tokenizer::Ngram { length } => { let chars: Vec = text.chars().collect(); - if chars.is_empty() { + // As in the v1 indexer's `process_ngram`: shorter than one gram + // means no tokens (never a partial or whole-text token, which + // could not match any stored gram). + if chars.len() < length { Vec::new() - } else if chars.len() <= length { - vec![text] } else { chars.windows(length).map(|w| w.iter().collect()).collect() } @@ -58,26 +85,45 @@ mod tests { } #[test] - fn ngram_short_text_yields_whole_text() { + fn ngram_gram_length_text_yields_one_token() { assert_eq!( - tokenize("hi", Tokenizer::Ngram { length: 3 }, true), - vec!["hi"] + tokenize("hey", Tokenizer::Ngram { length: 3 }, true), + vec!["hey"] ); } + #[test] + fn ngram_short_text_yields_no_tokens() { + // Mirrors the v1 indexer: a sub-gram-length probe can never match a + // stored gram, so it must not produce a token at all. + assert!(tokenize("hi", Tokenizer::Ngram { length: 3 }, true).is_empty()); + } + #[test] fn ngram_empty_text_yields_no_tokens() { assert!(tokenize("", Tokenizer::Ngram { length: 3 }, true).is_empty()); } #[test] - fn standard_splits_on_whitespace_and_downcases() { + fn standard_splits_on_the_v1_separator_set() { assert_eq!( - tokenize("Hello World", Tokenizer::Standard, true), - vec!["hello", "world"] + tokenize("Hello, World! again", Tokenizer::Standard, true), + vec!["hello", "world", "again"] ); } + #[test] + fn standard_drops_empty_tokens() { + assert!(tokenize(" ,;:! ", Tokenizer::Standard, true).is_empty()); + } + + #[test] + fn standard_does_not_split_on_other_whitespace() { + // The v1 separator set is exactly ' ', ',', ';', ':', '!' — tabs and + // newlines are part of the token, as in v1. + assert_eq!(tokenize("a\tb", Tokenizer::Standard, true), vec!["a\tb"]); + } + #[test] fn downcase_can_be_disabled() { assert_eq!(tokenize("Hi", Tokenizer::Standard, false), vec!["Hi"]); diff --git a/packages/stack-encrypt/tests/sem_terms.rs b/packages/stack-encrypt/tests/sem_terms.rs index b9538b150..00c26087b 100644 --- a/packages/stack-encrypt/tests/sem_terms.rs +++ b/packages/stack-encrypt/tests/sem_terms.rs @@ -118,13 +118,72 @@ async fn match_rejects_invalid_options() { k: 17, ..Default::default() }; - assert!(gen.match_terms("x", "d", &bad_k).await.is_err()); + assert!(gen.match_terms("xxx", "d", &bad_k).await.is_err()); + + // The v1 match indexer's lower bounds apply: k >= 3, m >= 32. + let small_k = MatchOptions { + k: 1, + ..Default::default() + }; + assert!(gen.match_terms("xxx", "d", &small_k).await.is_err()); let bad_m = MatchOptions { m: 100, ..Default::default() }; - assert!(gen.match_terms("x", "d", &bad_m).await.is_err()); + assert!(gen.match_terms("xxx", "d", &bad_m).await.is_err()); + + let small_m = MatchOptions { + m: 16, + ..Default::default() + }; + assert!(gen.match_terms("xxx", "d", &small_m).await.is_err()); + + // A zero-length n-gram must be an options error, not a panic. + let bad_ngram = MatchOptions { + tokenizer: Tokenizer::Ngram { length: 0 }, + ..Default::default() + }; + assert!(matches!( + gen.match_terms("xxx", "d", &bad_ngram).await, + Err(stack_encrypt::sem::TermError::InvalidOptions(_)) + )); +} + +#[tokio::test] +async fn match_rejects_text_that_yields_no_tokens() { + use stack_encrypt::sem::TermError; + + let gen = generator().await; + let opts = MatchOptions::default(); + + // An empty term used as a query would vacuously match every stored row. + for text in ["", " "] { + assert!( + matches!( + gen.match_terms(text, "users/bio", &opts).await, + Err(TermError::EmptyTermText) + ), + "{text:?} must be rejected" + ); + } + + // A probe shorter than the n-gram length could never match a stored gram + // (v1 indexer semantics) — rejected instead of a silent false negative. + assert!(matches!( + gen.match_terms("hi", "users/bio", &opts).await, + Err(TermError::EmptyTermText) + )); + + // Separator-only text under the Standard tokenizer. + let standard = MatchOptions { + tokenizer: Tokenizer::Standard, + ..Default::default() + }; + assert!(matches!( + gen.match_terms(" ,;:! ", "users/bio", &standard).await, + Err(TermError::EmptyTermText) + )); } #[tokio::test] From 5a1fee1dfba46e401327e0142a2bd22ddf578e7c Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Tue, 25 Aug 2026 18:16:25 +1000 Subject: [PATCH 425/686] refactor(stack-encrypt): derive index terms from the cipher itself MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A backend that cannot supply an index key is not a Stack Encrypt backend — if you only need AEAD, vitaminc alone is enough. Making that a requirement rather than an option removes a whole axis of misconfiguration: there is no cipher that silently cannot index, and no way to seal data keys under one keyset while deriving terms under another's index key (which would make every query match nothing, with no error to notice). So `StackCipher` now requires `DataKeySource + IndexKeySource`, loads its keyset's index key during construction, and carries the PRF itself. `TermGenerator` existed only to hold a PRF without a KMS; a cipher now does that job, and a query builder holding one derives probe terms with no data-key traffic. Its four term methods move across unchanged. Construction becomes async, since resolving the keyset and loading its index key is a round-trip, paid once: StackCipher::new().await? // ZeroKMS from the environment StackCipher::builder().kms(fake).init().await? // explicit backend, or a keyset `builder().kms(..)` is also the seam for a custom auth strategy: build a `StackKms` with `StackKmsBuilder` and hand it over. The two doctests that construct a cipher against `FakeDataKeySource` now actually run rather than being `no_run` — dev-dependencies give doctests both the fake source and a tokio runtime, so there was never a reason to skip them. --- packages/stack-encrypt/Cargo.toml | 3 + packages/stack-encrypt/src/cipher.rs | 190 +++++++++++++++++++-- packages/stack-encrypt/src/lib.rs | 27 ++- packages/stack-encrypt/src/sem/mod.rs | 95 ++++------- packages/stack-encrypt/src/sem/tokenize.rs | 4 +- packages/stack-encrypt/tests/roundtrip.rs | 58 ++++--- packages/stack-encrypt/tests/sem_terms.rs | 26 +-- 7 files changed, 269 insertions(+), 134 deletions(-) diff --git a/packages/stack-encrypt/Cargo.toml b/packages/stack-encrypt/Cargo.toml index a13c3b7d3..e12363b2e 100644 --- a/packages/stack-encrypt/Cargo.toml +++ b/packages/stack-encrypt/Cargo.toml @@ -20,6 +20,9 @@ publish = false # `secretkey.json` via `stack-profile`). Consumers that want it enable it on # their own `stack-kms` dependency. stack-kms = { path = "../stack-kms", default-features = false } +# `StackCipher::new()` builds a ZeroKMS client from the environment, so its +# return type names the auto-detected auth strategy. +stack-auth = { workspace = true } # The revised Cipher/Decipher traits, ContextTag, Element, and the PRF crates # have all merged to vitaminc main but are not yet published (crates.io is 200+ diff --git a/packages/stack-encrypt/src/cipher.rs b/packages/stack-encrypt/src/cipher.rs index 4d63fc073..d05f1a2c0 100644 --- a/packages/stack-encrypt/src/cipher.rs +++ b/packages/stack-encrypt/src/cipher.rs @@ -68,7 +68,10 @@ use std::borrow::Cow; use std::collections::HashSet; use serde::{Deserialize, Serialize}; -use stack_kms::{DataKey, DataKeySource, DataKeyWithTag, GenerateKeyPayload, RetrieveKeyPayload}; +use stack_kms::{ + DataKey, DataKeySource, DataKeyWithTag, EnvKeyProvider, GenerateKeyPayload, IdentifiedBy, + IndexKeySource, RetrieveKeyPayload, StackKms, StackKmsBuilder, +}; use uuid::Uuid; use vitaminc_aead::{ Aad, Cipher, CipherText, Decipher, DecipherVisitor, Decrypt, Encrypt, IntoAad, LocalCipherText, @@ -103,6 +106,10 @@ pub enum Error { /// ZeroKMS returned a different number of keys than were requested. #[error("expected {expected} data keys from ZeroKMS but received {received}")] KeyCountMismatch { expected: usize, received: usize }, + /// Building a ZeroKMS client from the environment failed: credentials or + /// client key missing or malformed. + #[error("could not build a ZeroKMS client from the environment: {0}")] + Config(#[from] stack_kms::StackKmsBuilderError), } impl From for Error { @@ -111,35 +118,186 @@ impl From for Error { } } -/// A vitaminc cipher whose per-leaf keys are ZeroKMS data keys, sourced through -/// a [`DataKeySource`] (production: `stack_kms::StackKms`; tests: -/// `stack_kms::FakeDataKeySource`). +/// The CipherStash cipher: a vitaminc [`Cipher`] whose per-leaf keys are ZeroKMS +/// data keys, sourced through a [`DataKeySource`] (production: +/// [`StackKms`]; tests: `stack_kms::FakeDataKeySource`), carrying the +/// per-keyset PRF that [Searchable Encrypted Metadata](crate::sem) terms are +/// derived from. /// /// Per-leaf keying is deliberate: every value access requires its own data-key /// retrieval, so individual value accesses are visible (and auditable) as /// ZeroKMS key-retrieval events. +/// +/// A cipher is always able to derive index terms: its keyset's +/// [`IndexKey`](stack_kms::IndexKey) is loaded during construction, so a +/// backend that cannot supply one is not a Stack Encrypt backend. Plain AEAD +/// with no indexing is what `vitaminc` alone provides. +/// +/// # Construction +/// +/// [`new`](Self::new) is the default path — a ZeroKMS client from the +/// environment, on that client's default keyset: +/// +/// ```no_run +/// # async fn example() -> Result<(), stack_encrypt::Error> { +/// use stack_encrypt::StackCipher; +/// +/// let cipher = StackCipher::new().await?; +/// # Ok(()) +/// # } +/// ``` +/// +/// Override with [`builder`](Self::builder) — a different keyset, or a +/// different data-key source entirely: +/// +/// ``` +/// # async fn example() -> Result<(), stack_encrypt::Error> { +/// use stack_encrypt::StackCipher; +/// use stack_kms::FakeDataKeySource; +/// +/// let cipher = StackCipher::builder() +/// .kms(FakeDataKeySource::new()) +/// .init() +/// .await?; +/// # Ok(()) +/// # } +/// # tokio_test_block_on(example()).unwrap(); +/// # fn tokio_test_block_on(f: F) -> F::Output { +/// # tokio::runtime::Builder::new_current_thread().enable_all().build().unwrap().block_on(f) +/// # } +/// ``` +/// +/// Construction is async because it resolves the keyset and loads its index +/// key — one ZeroKMS round-trip, paid once. pub struct StackCipher { kms: K, - keyset_id: Option, + /// The resolved keyset. Every generate/retrieve call is pinned to it, and + /// the PRF below is keyed by *this* keyset's index key: sealing data keys + /// under one keyset while deriving terms under another's index key would + /// make every query silently match nothing. + keyset_id: Uuid, + prf: vitaminc_hmac::HmacSha256Prf, +} + +impl StackCipher> { + /// Build a cipher over a ZeroKMS client configured from the environment, + /// on that client's default keyset. + /// + /// Equivalent to `StackCipher::builder().init()`. For a different keyset + /// or a different data-key source, use [`builder`](Self::builder). + pub async fn new() -> Result { + Self::builder().init().await + } + + /// Start building a cipher: pick a keyset, or supply a data-key source + /// other than the environment's ZeroKMS client. + pub fn builder() -> StackCipherBuilder { + StackCipherBuilder { + kms: FromEnv, + keyset: None, + } + } } impl StackCipher { - /// Create a cipher over the given data-key source, using the source's - /// default keyset. - pub fn new(kms: K) -> Self { - Self { + /// The keyset every generate/retrieve call is pinned to, and whose index + /// key keys [`prf`](Self::prf). + pub fn keyset_id(&self) -> Uuid { + self.keyset_id + } + + /// The PRF index terms are derived from, keyed by this cipher's keyset. + /// + /// Public so that other crates can implement their own term types against + /// this cipher (see [`crate::sem`]). + pub fn prf(&self) -> &vitaminc_hmac::HmacSha256Prf { + &self.prf + } + + /// The underlying data-key source. + pub fn kms(&self) -> &K { + &self.kms + } +} + +/// The state of a [`StackCipherBuilder`] that has not been given a data-key +/// source: [`init`](StackCipherBuilder::init) will build a ZeroKMS client from +/// the environment. +pub struct FromEnv; + +/// Builder for a [`StackCipher`]. See [`StackCipher::builder`]. +pub struct StackCipherBuilder { + kms: K, + keyset: Option, +} + +impl StackCipherBuilder { + /// Pin the cipher to a specific keyset, by id or by name, instead of the + /// data-key source's default. + pub fn keyset(mut self, keyset: IdentifiedBy) -> Self { + self.keyset = Some(keyset); + self + } +} + +impl StackCipherBuilder { + /// Use an explicit data-key source rather than building a ZeroKMS client + /// from the environment. + /// + /// This is the seam for a custom authentication strategy: build a + /// [`StackKms`] with [`StackKmsBuilder`] and hand it over. It is also how + /// tests inject `stack_kms::FakeDataKeySource`. + pub fn kms(self, kms: K) -> StackCipherBuilder { + StackCipherBuilder { kms, - keyset_id: None, + keyset: self.keyset, } } - /// Pin generate/retrieve operations to a specific ZeroKMS keyset. - pub fn with_keyset_id(mut self, keyset_id: Uuid) -> Self { - self.keyset_id = Some(keyset_id); - self + /// Build a ZeroKMS client from the environment, then resolve the keyset + /// and load its index key. + pub async fn init(self) -> Result>, Error> { + let kms = StackKmsBuilder::auto()? + .with_key_provider(EnvKeyProvider) + .build() + .await?; + StackCipherBuilder { + kms, + keyset: self.keyset, + } + .init() + .await } } +impl StackCipherBuilder { + /// Resolve the keyset and load its index key, producing a cipher that can + /// both seal values and derive index terms. + pub async fn init(self) -> Result, Error> { + let (keyset_id, index_key) = self.kms.load_index_key(self.keyset).await?; + let prf = hmac_prf_from_index_key(&index_key); + Ok(StackCipher { + kms: self.kms, + keyset_id, + prf, + }) + } +} + +/// Build the local HMAC-SHA256 PRF from a per-keyset +/// [`IndexKey`](stack_kms::IndexKey), wiping the intermediate stack copy of the +/// raw key bytes. +fn hmac_prf_from_index_key(index_key: &stack_kms::IndexKey) -> vitaminc_hmac::HmacSha256Prf { + use zeroize::Zeroize; + + // `[u8; 32]` is `Copy`: the move into `Protected` leaves this stack copy + // behind, so wipe it before returning. + let mut key = *index_key.key(); + let prf = vitaminc_hmac::HmacSha256Prf::new(Protected::new(key)); + key.zeroize(); + prf +} + impl StackCipher { /// Encrypt a value, binding `aad`, and seal it against fresh ZeroKMS data /// keys in a single batched `generate_keys` call. @@ -188,7 +346,7 @@ impl StackCipher { let expected = payloads.len(); let keys = self .kms - .retrieve_keys(payloads, self.keyset_id, None) + .retrieve_keys(payloads, Some(self.keyset_id), None) .await?; if keys.len() != expected { return Err(Error::KeyCountMismatch { @@ -419,7 +577,7 @@ impl PendingStackCipherText { let keys = cipher .kms - .generate_keys(payloads, cipher.keyset_id, None) + .generate_keys(payloads, Some(cipher.keyset_id), None) .await?; if keys.len() != count { diff --git a/packages/stack-encrypt/src/lib.rs b/packages/stack-encrypt/src/lib.rs index d21d1b3cf..44bb92044 100644 --- a/packages/stack-encrypt/src/lib.rs +++ b/packages/stack-encrypt/src/lib.rs @@ -25,15 +25,10 @@ //! ```no_run //! # async fn example() -> Result<(), Box> { //! use stack_encrypt::StackCipher; -//! use stack_kms::StackKmsBuilder; //! //! // Credentials and the client key come from the environment //! // (CS_CLIENT_ID / CS_CLIENT_KEY, plus an access token strategy). -//! let kms = StackKmsBuilder::auto()? -//! .with_key_provider(stack_kms::EnvKeyProvider) -//! .build() -//! .await?; -//! let cipher = StackCipher::new(kms); +//! let cipher = StackCipher::new().await?; //! //! let ciphertext = cipher.encrypt("secret message".to_string(), ()).await?; //! let plaintext: String = cipher.decrypt(ciphertext, ()).await?; @@ -68,21 +63,19 @@ //! `[dev-dependencies]`: //! //! ``` -//! # fn main() -> Result<(), stack_encrypt::Error> { -//! # tokio::runtime::Builder::new_current_thread() -//! # .build() -//! # .expect("runtime") -//! # .block_on(async { //! use stack_encrypt::StackCipher; //! use stack_kms::FakeDataKeySource; //! -//! let cipher = StackCipher::new(FakeDataKeySource::new()); +//! # tokio::runtime::Builder::new_current_thread().enable_all().build().unwrap().block_on(async { +//! let cipher = StackCipher::builder() +//! .kms(FakeDataKeySource::new()) +//! .init() +//! .await?; //! let ct = cipher.encrypt(vec!["a".to_string(), "b".to_string()], ()).await?; //! let pt: Vec = cipher.decrypt(ct, ()).await?; //! assert_eq!(pt, vec!["a", "b"]); -//! # Ok(()) -//! # }) -//! # } +//! # Ok::<(), stack_encrypt::Error>(()) +//! # }).unwrap(); //! ``` //! //! # Storing ciphertext @@ -126,8 +119,8 @@ mod cipher; pub mod sem; pub use cipher::{ - BoxedPassthrough, Error, PendingStackCipherText, SealedValue, StackCipher, StackCipherText, - StackDecipher, + BoxedPassthrough, Error, FromEnv, PendingStackCipherText, SealedValue, StackCipher, + StackCipherBuilder, StackCipherText, StackDecipher, }; // Re-export the vitaminc AEAD surface callers need to drive the cipher, so they diff --git a/packages/stack-encrypt/src/sem/mod.rs b/packages/stack-encrypt/src/sem/mod.rs index e16541a57..70b5e1bf3 100644 --- a/packages/stack-encrypt/src/sem/mod.rs +++ b/packages/stack-encrypt/src/sem/mod.rs @@ -1,32 +1,34 @@ //! Searchable Encrypted Metadata (SEM) term generation. //! -//! [`TermGenerator`] produces the index terms stored alongside a +//! [`StackCipher`] produces the index terms stored alongside a //! [`StackCipherText`](crate::StackCipherText) so encrypted values can be //! queried without decryption: //! -//! * **Equality terms** ([`TermGenerator::equality_term`]) — a PRF of the whole +//! * **Equality terms** ([`StackCipher::equality_term`]) — a PRF of the whole //! value; supports exact-match queries. -//! * **Match terms** ([`TermGenerator::match_terms`]) — the value is tokenized +//! * **Match terms** ([`StackCipher::match_terms`]) — the value is tokenized //! locally, each token is PRF'd, and the outputs fold into Bloom-filter bit //! positions; supports full-text match queries. -//! * **ORE / OPE terms** ([`TermGenerator::ore_term`] / -//! [`TermGenerator::ope_term`]) — CLLW order-revealing / order-preserving +//! * **ORE / OPE terms** ([`StackCipher::ore_term`] / +//! [`StackCipher::ope_term`]) — CLLW order-revealing / order-preserving //! ciphertexts under a per-descriptor key derived *through the PRF*; support //! range queries. //! //! # PRF backends and the 2-party future //! -//! The generator is generic over `P:`[`Prf`], and every term method awaits the -//! PRF output ([`Prf::Ok`] is `IntoFuture`). With the local -//! [`HmacSha256Prf`](vitaminc_hmac::HmacSha256Prf) backend — keyed by the -//! deterministic per-keyset [`IndexKey`](stack_kms::IndexKey) from -//! [`stack_kms::IndexKeySource`] — outputs are immediately ready. The next -//! ZeroKMS release adds 2-party PRF generation; that backend returns deferred -//! outputs resolved by a server round-trip, and slots in behind the same `P` -//! parameter with no API change. This is also why ORE/OPE *keys* are derived -//! through the PRF (from the field descriptor, never the plaintext): under a -//! 2-party backend, per-field key derivation becomes a visible, auditable -//! ZeroKMS event while plaintext stays local. +//! Every term method awaits its PRF output ([`Prf::Ok`](vitaminc_prf::Prf::Ok) is `IntoFuture`). The +//! backend today is the local +//! [`HmacSha256Prf`](vitaminc_hmac::HmacSha256Prf), keyed by the deterministic +//! per-keyset [`IndexKey`](stack_kms::IndexKey) the cipher loads during +//! construction, so outputs are immediately ready. The next ZeroKMS release +//! adds 2-party PRF generation: that backend returns deferred outputs resolved +//! by a server round-trip, and because every derivation is already behind an +//! `await` it slots in without changing a single call site. +//! +//! This is also why ORE/OPE *keys* are derived through the PRF (from the field +//! descriptor, never the plaintext): under a 2-party backend, per-field key +//! derivation becomes a visible, auditable ZeroKMS event while plaintext stays +//! local. //! //! # Determinism and domain separation //! @@ -37,7 +39,7 @@ //! caller's field `descriptor`, so the same value indexed as an equality term, //! a match token, or an ORE key can never produce colliding PRF outputs. //! -//! This is a fresh (v2) term format: PRF inputs are framed with vitaminc's PAE +//! This is a fresh term format: PRF inputs are framed with vitaminc's PAE //! context encoding, so terms are intentionally **not** byte-compatible with //! `cipherstash-client`'s existing `IndexTerm` values. @@ -47,12 +49,12 @@ pub use tokenize::Tokenizer; use cllw_ore::{CllwOpeEncrypt, CllwOreEncrypt}; use vitaminc_prf::{ - BlockVisitor, MapAccess, Prf, PrfContext, PrfError, PrfValue, PrfVisitor, PrfVisitorError, - SeqAccess, + BlockVisitor, MapAccess, PrfContext, PrfError, PrfValue, PrfVisitor, PrfVisitorError, SeqAccess, }; -use vitaminc_protected::Protected; use zeroize::Zeroize; +use crate::StackCipher; + /// PAE domain for equality (exact-match) terms. const EQUALITY_DOMAIN: &[u8] = b"stack-encrypt/sem/equality/v1"; /// PAE domain for match (full-text) token terms. @@ -140,7 +142,7 @@ impl MatchTerm { /// /// An empty `query` returns `false`: containment of zero positions is /// vacuously true, which would turn an empty probe into a match-every-row - /// query. [`TermGenerator::match_terms`] already refuses to build such a + /// query. [`StackCipher::match_terms`] already refuses to build such a /// term ([`TermError::EmptyTermText`]); this guards any other /// (e.g. deserialized) source of an empty term. pub fn contains(&self, query: &MatchTerm) -> bool { @@ -235,44 +237,17 @@ impl PrfVisitor<[u8; 32], P> for BloomVisitor { } } -/// Generates Searchable Encrypted Metadata terms over a PRF backend `P`. -/// -/// Construct from a per-keyset index key with [`from_index_key`] -/// (local HMAC backend), or from any [`Prf`] backend with [`new`] — see the -/// module docs for the 2-party story. +/// Term generation on the cipher itself: every [`StackCipher`] carries the PRF +/// keyed by its keyset's index key, so the same handle that seals a value +/// derives the terms stored beside it, and a query builder holding a cipher +/// derives probe terms with no data-key traffic at all. /// -/// [`from_index_key`]: TermGenerator::from_index_key -/// [`new`]: TermGenerator::new -#[derive(Clone)] -pub struct TermGenerator

{ - prf: P, -} - -impl TermGenerator { - /// Build a generator over the local HMAC-SHA256 PRF, keyed by the - /// deterministic per-keyset index key (see - /// [`stack_kms::IndexKeySource::load_index_key`]). - pub fn from_index_key(index_key: &stack_kms::IndexKey) -> Self { - Self::new(vitaminc_hmac::HmacSha256Prf::new(Protected::new( - *index_key.key(), - ))) - } -} - -impl

TermGenerator

{ - /// Build a generator over an arbitrary PRF backend. - pub fn new(prf: P) -> Self { - Self { prf } - } -} - -impl

TermGenerator

-where - P: Prf + Clone, -{ +/// Terms are deterministic: the same value and descriptor yield the same term +/// at write time and at query time. They are pseudorandom under the index key +/// and are stored server-side — they are not secret key material. +impl StackCipher { /// Generate an equality (exact-match) term for `value` under the field - /// `descriptor`. Deterministic: the same value + descriptor always yields - /// the same term, at write time and at query time. + /// `descriptor`. pub async fn equality_term( &self, value: T, @@ -283,7 +258,7 @@ where { let context = PrfContext::pae(&[EQUALITY_DOMAIN, descriptor.as_bytes()]); let block = value - .prf_with_context(self.prf.clone(), context) + .prf_with_context(self.prf().clone(), context) .await .map_err(TermError::from_prf)?; Ok(EqualityTerm(block)) @@ -315,7 +290,7 @@ where tokens .prf_visit_with_context( - self.prf.clone(), + self.prf().clone(), context, BloomVisitor { k: options.k, mask }, ) @@ -361,7 +336,7 @@ where ) -> Result { let context = PrfContext::pae(&[domain, descriptor.as_bytes()]); let mut block = descriptor - .prf_with_context(self.prf.clone(), context) + .prf_with_context(self.prf().clone(), context) .await .map_err(TermError::from_prf)?; // `Key` wipes itself on drop; wipe the stack copy the move leaves diff --git a/packages/stack-encrypt/src/sem/tokenize.rs b/packages/stack-encrypt/src/sem/tokenize.rs index 7bb3632cb..1fa69ab37 100644 --- a/packages/stack-encrypt/src/sem/tokenize.rs +++ b/packages/stack-encrypt/src/sem/tokenize.rs @@ -17,7 +17,7 @@ pub enum Tokenizer { /// (whitespace included). Text shorter than `length` yields **no tokens**, /// exactly like the v1 match indexer — so a probe shorter than the gram /// length is rejected by - /// [`match_terms`](crate::sem::TermGenerator::match_terms) rather than + /// [`match_terms`](crate::StackCipher::match_terms) rather than /// silently never matching. This is the default, matching the existing /// match indexer's 3-gram configuration. Ngram { length: usize }, @@ -44,7 +44,7 @@ impl Default for Tokenizer { /// than the n-gram length); [`match_terms`] rejects that case so an empty /// term can never reach a query. /// -/// [`match_terms`]: crate::sem::TermGenerator::match_terms +/// [`match_terms`]: crate::StackCipher::match_terms pub(crate) fn tokenize(text: &str, tokenizer: Tokenizer, downcase: bool) -> Vec { let text = if downcase { text.to_lowercase() diff --git a/packages/stack-encrypt/tests/roundtrip.rs b/packages/stack-encrypt/tests/roundtrip.rs index 5d2a661ee..e5d5711c5 100644 --- a/packages/stack-encrypt/tests/roundtrip.rs +++ b/packages/stack-encrypt/tests/roundtrip.rs @@ -10,13 +10,17 @@ use stack_encrypt::{Aad, CipherText, ContextTag, Element, IntoAad, SealedValue, use stack_kms::FakeDataKeySource; use vitaminc_protected::{Controlled, Protected}; -fn cipher() -> StackCipher { - StackCipher::new(FakeDataKeySource::new()) +async fn cipher() -> StackCipher { + StackCipher::builder() + .kms(FakeDataKeySource::new()) + .init() + .await + .expect("build cipher") } #[tokio::test] async fn scalar_roundtrips_with_no_aad() { - let cipher = cipher(); + let cipher = cipher().await; let ct = cipher .encrypt("hello world".to_string(), ()) .await @@ -27,7 +31,7 @@ async fn scalar_roundtrips_with_no_aad() { #[tokio::test] async fn scalar_roundtrips_with_matching_aad() { - let cipher = cipher(); + let cipher = cipher().await; let aad = b"public-context".as_slice(); let ct = cipher .encrypt("secret".to_string(), aad) @@ -39,7 +43,7 @@ async fn scalar_roundtrips_with_matching_aad() { #[tokio::test] async fn decrypt_fails_with_wrong_aad() { - let cipher = cipher(); + let cipher = cipher().await; let ct = cipher .encrypt("secret".to_string(), b"aad-a".as_slice()) .await @@ -50,7 +54,7 @@ async fn decrypt_fails_with_wrong_aad() { #[tokio::test] async fn decrypt_fails_when_aad_omitted() { - let cipher = cipher(); + let cipher = cipher().await; let ct = cipher .encrypt("secret".to_string(), b"bound".as_slice()) .await @@ -63,7 +67,7 @@ async fn decrypt_fails_when_aad_omitted() { #[tokio::test] async fn vec_roundtrips() { - let cipher = cipher(); + let cipher = cipher().await; let items = vec!["a".to_string(), "b".to_string(), "c".to_string()]; let ct = cipher.encrypt(items.clone(), ()).await.expect("encrypt"); let pt: Vec = cipher.decrypt(ct, ()).await.expect("decrypt"); @@ -72,7 +76,7 @@ async fn vec_roundtrips() { #[tokio::test] async fn map_roundtrips() { - let cipher = cipher(); + let cipher = cipher().await; // Encrypt side keys are `&'static str`; decrypt side yields `String` keys. let mut input: HashMap<&'static str, String> = HashMap::new(); input.insert("name", "alice".to_string()); @@ -88,7 +92,7 @@ async fn map_roundtrips() { #[tokio::test] async fn option_some_roundtrips() { - let cipher = cipher(); + let cipher = cipher().await; let ct = cipher .encrypt(Some("present".to_string()), ()) .await @@ -99,7 +103,7 @@ async fn option_some_roundtrips() { #[tokio::test] async fn option_none_roundtrips() { - let cipher = cipher(); + let cipher = cipher().await; let ct = cipher .encrypt(Option::::None, ()) .await @@ -113,7 +117,7 @@ async fn protected_roundtrip() { // Exercises the `Protected` Decrypt impl, the sole user of `Decipher::map_ok`. // (`Vec` would encrypt element-wise as a sequence of `u8`, not as bytes, // so a string leaf is used here.) - let cipher = cipher(); + let cipher = cipher().await; let secret = Protected::new("classified".to_string()); let ct = cipher.encrypt(secret, ()).await.expect("encrypt"); let pt: Protected = cipher.decrypt(ct, ()).await.expect("decrypt"); @@ -122,7 +126,7 @@ async fn protected_roundtrip() { #[tokio::test] async fn nested_vec_roundtrips() { - let cipher = cipher(); + let cipher = cipher().await; let nested = vec![ vec!["a".to_string(), "b".to_string()], vec!["c".to_string()], @@ -134,7 +138,7 @@ async fn nested_vec_roundtrips() { #[tokio::test] async fn context_tag_binds_and_roundtrips() { - let cipher = cipher(); + let cipher = cipher().await; let ct = cipher .encrypt(ContextTag::new("token".to_string(), "user:42"), ()) .await @@ -150,7 +154,7 @@ async fn context_tag_binds_and_roundtrips() { #[tokio::test] async fn context_tag_wrong_context_fails() { - let cipher = cipher(); + let cipher = cipher().await; let ct = cipher .encrypt(ContextTag::new("token".to_string(), "user:42"), ()) .await @@ -163,7 +167,7 @@ async fn context_tag_wrong_context_fails() { #[tokio::test] async fn empty_vec_roundtrips() { // An empty sequence seals an authenticated marker, so emptiness is provable. - let cipher = cipher(); + let cipher = cipher().await; let ct = cipher .encrypt(Vec::::new(), ()) .await @@ -174,7 +178,7 @@ async fn empty_vec_roundtrips() { #[tokio::test] async fn empty_map_roundtrips() { - let cipher = cipher(); + let cipher = cipher().await; let ct = cipher .encrypt(HashMap::<&'static str, String>::new(), ()) .await @@ -185,7 +189,7 @@ async fn empty_map_roundtrips() { #[tokio::test] async fn empty_marker_does_not_decode_under_wrong_aad() { - let cipher = cipher(); + let cipher = cipher().await; let ct = cipher .encrypt(Vec::::new(), b"bound".as_slice()) .await @@ -198,7 +202,7 @@ async fn empty_marker_does_not_decode_under_wrong_aad() { async fn renamed_map_key_fails() { // Map keys travel in the clear but are bound into their value's AAD, so // renaming a key in the stored ciphertext must fail decryption. - let cipher = cipher(); + let cipher = cipher().await; let mut input: HashMap<&'static str, String> = HashMap::new(); input.insert("name", "alice".to_string()); @@ -221,7 +225,7 @@ async fn renamed_map_key_fails() { async fn sequence_element_cannot_be_rehomed_as_scalar() { // Elements are sealed under the `for_sequence_element` derivation, so a // leaf spliced out of a sequence must not verify as a top-level scalar. - let cipher = cipher(); + let cipher = cipher().await; let ct = cipher .encrypt(vec!["a".to_string()], ()) .await @@ -243,7 +247,7 @@ async fn element_roundtrips_under_bare_caller_aad() { // `Element` derives `for_sequence_element` inside its own Encrypt/Decrypt // impls. Both sides must honour that derivation: the decipher opens the leaf // under the AAD the Decrypt drive supplies, not a pre-derived one. - let cipher = cipher(); + let cipher = cipher().await; let ct = cipher .encrypt(Element("row".to_string()), b"users".as_slice()) .await @@ -260,7 +264,7 @@ async fn element_interchanges_with_vec_element() { // A row sealed as one element of a `Vec` decrypts alone as `Element` // under the same caller AAD (Element's documented use-case), and a lone // `Element` ciphertext decrypts as a one-element `Vec`. - let cipher = cipher(); + let cipher = cipher().await; let aad = b"users".as_slice(); let ct = cipher @@ -291,7 +295,7 @@ async fn element_interchanges_with_vec_element() { #[tokio::test] async fn element_fails_under_wrong_caller_aad() { - let cipher = cipher(); + let cipher = cipher().await; let ct = cipher .encrypt(Element("row".to_string()), b"users".as_slice()) .await @@ -308,7 +312,7 @@ async fn decipher_can_be_driven_directly() { // `StackCipher::decipher` mirrors `Aes256Cipher::decipher`: the returned // Decipher is driven via `Decrypt::decrypt_with_aad` with a caller-chosen // AAD, so manual derivations work too. - let cipher = cipher(); + let cipher = cipher().await; let ct = cipher .encrypt(Element("row".to_string()), b"users".as_slice()) .await @@ -335,7 +339,7 @@ async fn decipher_can_be_driven_directly() { #[tokio::test] async fn wrong_shape_fails() { // A scalar ciphertext must not decode as a sequence. - let cipher = cipher(); + let cipher = cipher().await; let ct = cipher .encrypt("scalar".to_string(), ()) .await @@ -348,7 +352,7 @@ async fn wrong_shape_fails() { async fn leaf_survives_persistence_via_parts() { // A leaf can be decomposed into (iv, tag, ciphertext), stored, and rebuilt // — the in-memory original need not be retained to decrypt. - let cipher = cipher(); + let cipher = cipher().await; let ct = cipher .encrypt("durable".to_string(), b"ctx".as_slice()) .await @@ -369,7 +373,7 @@ async fn leaf_survives_persistence_via_parts() { #[tokio::test] async fn leaf_survives_persistence_via_serde() { - let cipher = cipher(); + let cipher = cipher().await; let ct = cipher .encrypt("durable".to_string(), ()) .await @@ -393,7 +397,7 @@ async fn leaf_survives_persistence_via_serde() { #[tokio::test] async fn tampered_leaf_bytes_fail() { - let cipher = cipher(); + let cipher = cipher().await; let ct = cipher .encrypt("durable".to_string(), ()) .await diff --git a/packages/stack-encrypt/tests/sem_terms.rs b/packages/stack-encrypt/tests/sem_terms.rs index 00c26087b..a6bb5e3a5 100644 --- a/packages/stack-encrypt/tests/sem_terms.rs +++ b/packages/stack-encrypt/tests/sem_terms.rs @@ -3,24 +3,26 @@ use std::cmp::Ordering; -use stack_encrypt::sem::{MatchOptions, TermGenerator, Tokenizer}; -use stack_kms::{FakeDataKeySource, IdentifiedBy, IndexKeySource}; +use stack_encrypt::sem::{MatchOptions, Tokenizer}; +use stack_encrypt::StackCipher; +use stack_kms::{FakeDataKeySource, IdentifiedBy}; use uuid::Uuid; -async fn generator() -> TermGenerator { - let (_, index_key) = FakeDataKeySource::new() - .load_index_key(None) +async fn generator() -> StackCipher { + StackCipher::builder() + .kms(FakeDataKeySource::new()) + .init() .await - .expect("load index key"); - TermGenerator::from_index_key(&index_key) + .expect("build cipher") } -async fn generator_for(keyset: Uuid) -> TermGenerator { - let (_, index_key) = FakeDataKeySource::new() - .load_index_key(Some(IdentifiedBy::Uuid(keyset))) +async fn generator_for(keyset: Uuid) -> StackCipher { + StackCipher::builder() + .kms(FakeDataKeySource::new()) + .keyset(IdentifiedBy::Uuid(keyset)) + .init() .await - .expect("load index key"); - TermGenerator::from_index_key(&index_key) + .expect("build cipher") } #[tokio::test] From 2f5735ecb1d03b2823b2ca314139061692a8b596 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Tue, 25 Aug 2026 23:55:11 +1000 Subject: [PATCH 426/686] refactor(sem): shape terms in PrfVisitors, not after the await MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The backend produces blocks; the visitor shapes them into the term. Equality wrapped its block and ORE/OPE derived their keys in async code after awaiting a BlockVisitor result — shaping that would silently migrate server-side work client-side under the 2-party PRF backend. EqualityVisitor and CllwKeyVisitor join BloomVisitor (already correct) so all shaping is pure, synchronous, and runs on whichever side of the round-trip the backend dictates. CLLW key material now never leaves the visitor unwrapped. Term bytes are unchanged (RFC 0002 §4.5). Claude-Session: https://claude.ai/code/session_011kjxjgxWmT4yi23ZqufSEc --- packages/stack-encrypt/src/sem/mod.rs | 63 +++++++++++++++++++-------- 1 file changed, 46 insertions(+), 17 deletions(-) diff --git a/packages/stack-encrypt/src/sem/mod.rs b/packages/stack-encrypt/src/sem/mod.rs index 70b5e1bf3..fa6ee9a5f 100644 --- a/packages/stack-encrypt/src/sem/mod.rs +++ b/packages/stack-encrypt/src/sem/mod.rs @@ -14,16 +14,24 @@ //! ciphertexts under a per-descriptor key derived *through the PRF*; support //! range queries. //! -//! # PRF backends and the 2-party future +//! # PRF backends, visitors, and the 2-party future //! -//! Every term method awaits its PRF output ([`Prf::Ok`](vitaminc_prf::Prf::Ok) is `IntoFuture`). The -//! backend today is the local +//! The PRF backend produces **blocks**; a [`PrfVisitor`] shapes blocks into +//! the term ([`EqualityVisitor`], [`BloomVisitor`], [`CllwKeyVisitor`] — +//! all private). The shaping is pure and synchronous by construction: only +//! the block production can involve I/O, so a visitor never knows which side +//! of a round-trip it runs on. All pure work — option validation, +//! tokenization, context framing — happens *before* the PRF is invoked. +//! +//! Every term method awaits its PRF output ([`Prf::Ok`](vitaminc_prf::Prf::Ok) +//! is `IntoFuture`). The backend today is the local //! [`HmacSha256Prf`](vitaminc_hmac::HmacSha256Prf), keyed by the deterministic //! per-keyset [`IndexKey`](stack_kms::IndexKey) the cipher loads during //! construction, so outputs are immediately ready. The next ZeroKMS release //! adds 2-party PRF generation: that backend returns deferred outputs resolved -//! by a server round-trip, and because every derivation is already behind an -//! `await` it slots in without changing a single call site. +//! by a server round-trip, and it slots in without touching any shaping code — +//! the same visitor runs over the blocks the server returns, and every +//! derivation is already behind an `await`. //! //! This is also why ORE/OPE *keys* are derived through the PRF (from the field //! descriptor, never the plaintext): under a 2-party backend, per-field key @@ -122,6 +130,17 @@ impl From for Vec { } } +/// A [`PrfVisitor`] that wraps one PRF block as an [`EqualityTerm`]. +struct EqualityVisitor; + +impl PrfVisitor<[u8; 32], P> for EqualityVisitor { + type Value = EqualityTerm; + + fn visit_block(self, block: [u8; 32]) -> Result { + Ok(EqualityTerm(block)) + } +} + /// A match (full-text) index term: the set bit positions of a Bloom filter over /// the PRF outputs of the value's tokens. Positions are sorted and de-duplicated. #[derive(Debug, Clone, PartialEq, Eq)] @@ -237,6 +256,22 @@ impl PrfVisitor<[u8; 32], P> for BloomVisitor { } } +/// A [`PrfVisitor`] that turns one PRF block into a self-wiping CLLW +/// [`Key`](cllw_ore::Key). Key material never leaves the visitor unwrapped: +/// the block arrives by value, is moved into the `ZeroizeOnDrop` key, and the +/// stack copy left behind (`[u8; 32]` is `Copy`) is wiped before returning. +struct CllwKeyVisitor; + +impl PrfVisitor<[u8; 32], P> for CllwKeyVisitor { + type Value = cllw_ore::Key; + + fn visit_block(self, mut block: [u8; 32]) -> Result { + let key = cllw_ore::Key::from(block); + block.zeroize(); + Ok(key) + } +} + /// Term generation on the cipher itself: every [`StackCipher`] carries the PRF /// keyed by its keyset's index key, so the same handle that seals a value /// derives the terms stored beside it, and a query builder holding a cipher @@ -257,11 +292,10 @@ impl StackCipher { T: PrfValue, { let context = PrfContext::pae(&[EQUALITY_DOMAIN, descriptor.as_bytes()]); - let block = value - .prf_with_context(self.prf().clone(), context) + value + .prf_visit_with_context(self.prf().clone(), context, EqualityVisitor) .await - .map_err(TermError::from_prf)?; - Ok(EqualityTerm(block)) + .map_err(TermError::from_prf) } /// Generate a match (full-text) term for `text` under the field @@ -335,14 +369,9 @@ impl StackCipher { descriptor: &str, ) -> Result { let context = PrfContext::pae(&[domain, descriptor.as_bytes()]); - let mut block = descriptor - .prf_with_context(self.prf().clone(), context) + descriptor + .prf_visit_with_context(self.prf().clone(), context, CllwKeyVisitor) .await - .map_err(TermError::from_prf)?; - // `Key` wipes itself on drop; wipe the stack copy the move leaves - // behind ([u8; 32] is `Copy`). - let key = cllw_ore::Key::from(block); - block.zeroize(); - Ok(key) + .map_err(TermError::from_prf) } } From ed306b359091cf729c0e6d2cd63440cccffe87f3 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Thu, 27 Aug 2026 12:43:55 +1000 Subject: [PATCH 427/686] refactor(stack-encrypt): encrypt ORE/OPE terms inside the PRF visitor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ore_term` / `ope_term` used the PRF to derive a CLLW key, returned that key to the caller, and encrypted the value afterwards. That shape assumes a key exists somewhere the client can hold — which will not be true under a two-party PRF, where the operation is split between client and ZeroKMS and neither side ever assembles a key. Reshape the ORE/OPE path so the visitor is the whole operation: the plaintext travels *into* `OreVisitor` / `OpeVisitor`, the PRF block becomes the CLLW key inside `visit_block`, the value is encrypted there, and only the ciphertext comes out. From the caller's side it is now PRF input (descriptor) in, term out — the same surface a two-party backend will have, so moving to per-prefix PRF outputs later changes the visitor, not its callers. The key never leaves the visitor; `CllwKeyVisitor` and `derive_cllw_key` are gone. The PRF input, context framing and CLLW wire format are unchanged, so existing terms stay findable; the new integration test pins owned and borrowed inputs to identical bytes. Because the visitor owns the plaintext across the (potentially asynchronous) PRF call, inputs must be `'static`. `&'static str` literals still work; for borrowed text callers pass a `String`. cllw-ore gains `CllwOreEncrypt` / `CllwOpeEncrypt` impls for `String` and `Vec` that delegate to the borrowed impls (byte-identical, unit-tested) to make that ergonomic. Claude-Session: https://claude.ai/code/session_01HU1Bbw4eQp9kEEneXxDcKr --- packages/stack-encrypt/src/sem/mod.rs | 118 +++++++++++++++------- packages/stack-encrypt/tests/sem_terms.rs | 18 ++++ 2 files changed, 98 insertions(+), 38 deletions(-) diff --git a/packages/stack-encrypt/src/sem/mod.rs b/packages/stack-encrypt/src/sem/mod.rs index fa6ee9a5f..8d638d103 100644 --- a/packages/stack-encrypt/src/sem/mod.rs +++ b/packages/stack-encrypt/src/sem/mod.rs @@ -11,13 +11,13 @@ //! positions; supports full-text match queries. //! * **ORE / OPE terms** ([`StackCipher::ore_term`] / //! [`StackCipher::ope_term`]) — CLLW order-revealing / order-preserving -//! ciphertexts under a per-descriptor key derived *through the PRF*; support -//! range queries. +//! ciphertexts produced *inside the PRF visitor* from a per-descriptor key +//! derived through the PRF; support range queries. //! //! # PRF backends, visitors, and the 2-party future //! //! The PRF backend produces **blocks**; a [`PrfVisitor`] shapes blocks into -//! the term ([`EqualityVisitor`], [`BloomVisitor`], [`CllwKeyVisitor`] — +//! the term (`EqualityVisitor`, `BloomVisitor`, `OreVisitor`, `OpeVisitor` — //! all private). The shaping is pure and synchronous by construction: only //! the block production can involve I/O, so a visitor never knows which side //! of a round-trip it runs on. All pure work — option validation, @@ -256,19 +256,56 @@ impl PrfVisitor<[u8; 32], P> for BloomVisitor { } } -/// A [`PrfVisitor`] that turns one PRF block into a self-wiping CLLW -/// [`Key`](cllw_ore::Key). Key material never leaves the visitor unwrapped: -/// the block arrives by value, is moved into the `ZeroizeOnDrop` key, and the -/// stack copy left behind (`[u8; 32]` is `Copy`) is wiped before returning. -struct CllwKeyVisitor; +/// A [`PrfVisitor`] that carries the plaintext in and hands the CLLW ORE +/// ciphertext out. The PRF block becomes the CLLW [`Key`](cllw_ore::Key) +/// *inside* `visit_block` and dies there: the block arrives by value, is moved +/// into the `ZeroizeOnDrop` key, the stack copy left behind (`[u8; 32]` is +/// `Copy`) is wiped, the value is encrypted, and only the ciphertext leaves. +/// No key is ever returned to the caller. +/// +/// This is the shape a 2-party PRF needs: the caller supplies a PRF input +/// (the descriptor) and receives a term, and where the key comes from — or +/// whether one exists at all — is the visitor's business. Swapping the +/// backend for one that returns per-prefix PRF outputs instead of a key +/// changes this visitor, not its callers. +/// +/// The plaintext is owned (`T: 'static`) because the visitor outlives the +/// call under an asynchronous backend; `Send` for the same reason. CLLW +/// failures surface through the visitor's `Value` rather than +/// [`PrfVisitorError`] so they keep their own error type. +struct OreVisitor(T); + +impl PrfVisitor<[u8; 32], P> for OreVisitor +where + T: CllwOreEncrypt + Send + 'static, + T::Output: Send + 'static, + P: Send + 'static, +{ + type Value = Result; + + fn visit_block(self, mut block: [u8; 32]) -> Result { + let key = cllw_ore::Key::from(block); + block.zeroize(); + Ok(self.0.encrypt(&key)) + } +} + +/// The OPE twin of [`OreVisitor`]: same key handling, produces a CLLW OPE +/// ciphertext (byte order is plaintext order). +struct OpeVisitor(T); -impl PrfVisitor<[u8; 32], P> for CllwKeyVisitor { - type Value = cllw_ore::Key; +impl PrfVisitor<[u8; 32], P> for OpeVisitor +where + T: CllwOpeEncrypt + Send + 'static, + T::Output: Send + 'static, + P: Send + 'static, +{ + type Value = Result; fn visit_block(self, mut block: [u8; 32]) -> Result { let key = cllw_ore::Key::from(block); block.zeroize(); - Ok(key) + Ok(self.0.encrypt_ope(&key)) } } @@ -333,45 +370,50 @@ impl StackCipher { } /// Generate an order-revealing (CLLW ORE) term for a range-queryable value - /// under the field `descriptor`. The ORE key is derived through the PRF - /// from the descriptor alone — the plaintext never enters the PRF. + /// under the field `descriptor`. The PRF input is the descriptor alone — + /// the plaintext never enters the PRF; it travels in the visitor, which + /// derives the per-descriptor CLLW key from the PRF block and encrypts + /// under it in one step (`OreVisitor`). The key never leaves the + /// visitor. /// - /// Supported inputs: `u16`/`u32`/`u64`/`u128`, `&str`, `&[u8]` (via - /// [`CllwOreEncrypt`]). + /// The derivation is deterministic, so write-time and query-time terms + /// agree; under a 2-party PRF backend it is a visible ZeroKMS event. The + /// PRF context is PAE-encoded `[domain, descriptor]` — the same framing + /// every other term kind uses (see the module docs), so no reimplementation + /// of this derivation can collide with an equality or match derivation. + /// + /// Supported inputs: `u16`/`u32`/`u64`/`u128`, `&'static str`, `String`, + /// `Vec` (via [`CllwOreEncrypt`]). The value must be owned + /// (`'static`) because the visitor carries it; pass a `String` for + /// borrowed text. pub async fn ore_term(&self, value: T, descriptor: &str) -> Result where - T: CllwOreEncrypt, + T: CllwOreEncrypt + Send + 'static, + T::Output: Send + 'static, { - let key = self.derive_cllw_key(ORE_KEY_DOMAIN, descriptor).await?; - value.encrypt(&key).map_err(TermError::Ore) + let context = PrfContext::pae(&[ORE_KEY_DOMAIN, descriptor.as_bytes()]); + descriptor + .prf_visit_with_context(self.prf().clone(), context, OreVisitor(value)) + .await + .map_err(TermError::from_prf)? + .map_err(TermError::Ore) } /// Generate an order-preserving (CLLW OPE) term: ciphertexts compare with /// plain lexicographic byte order, no custom comparator required. - /// Encrypt-only — pair with the record ciphertext for round-trips. + /// Encrypt-only — pair with the record ciphertext for round-trips. Key + /// handling and input bounds as for [`ore_term`](Self::ore_term); the OPE + /// key derives under its own domain so the two schemes never share one. pub async fn ope_term(&self, value: T, descriptor: &str) -> Result where - T: CllwOpeEncrypt, + T: CllwOpeEncrypt + Send + 'static, + T::Output: Send + 'static, { - let key = self.derive_cllw_key(OPE_KEY_DOMAIN, descriptor).await?; - value.encrypt_ope(&key).map_err(TermError::Ore) - } - - /// Derive a per-descriptor CLLW key: PRF of the descriptor under a - /// PAE-encoded `[domain, descriptor]` context — the same framing every - /// other term kind uses (see the module docs), so no reimplementation of - /// this derivation can collide with an equality or match derivation. - /// Deterministic, so write-time and query-time terms agree; under a - /// 2-party PRF backend this derivation is a visible ZeroKMS event. - async fn derive_cllw_key( - &self, - domain: &'static [u8], - descriptor: &str, - ) -> Result { - let context = PrfContext::pae(&[domain, descriptor.as_bytes()]); + let context = PrfContext::pae(&[OPE_KEY_DOMAIN, descriptor.as_bytes()]); descriptor - .prf_visit_with_context(self.prf().clone(), context, CllwKeyVisitor) + .prf_visit_with_context(self.prf().clone(), context, OpeVisitor(value)) .await - .map_err(TermError::from_prf) + .map_err(TermError::from_prf)? + .map_err(TermError::Ore) } } diff --git a/packages/stack-encrypt/tests/sem_terms.rs b/packages/stack-encrypt/tests/sem_terms.rs index a6bb5e3a5..c1b6392c2 100644 --- a/packages/stack-encrypt/tests/sem_terms.rs +++ b/packages/stack-encrypt/tests/sem_terms.rs @@ -255,3 +255,21 @@ async fn ore_and_ope_keys_are_domain_separated() { let ope = gen.ope_term(42u64, "users/age").await.unwrap(); assert_ne!(ore.as_ref(), ope.as_ref()); } + +#[tokio::test] +async fn owned_and_borrowed_text_yield_identical_ore_and_ope_terms() { + let gen = generator().await; + let borrowed = gen.ore_term("apple", "users/name").await.unwrap(); + let owned = gen + .ore_term(String::from("apple"), "users/name") + .await + .unwrap(); + assert_eq!(borrowed.as_ref(), owned.as_ref()); + + let borrowed = gen.ope_term("apple", "users/name").await.unwrap(); + let owned = gen + .ope_term(String::from("apple"), "users/name") + .await + .unwrap(); + assert_eq!(borrowed.as_ref(), owned.as_ref()); +} From bd183d67f5892d7863e15615f9a8f682b8479237 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Thu, 27 Aug 2026 12:56:39 +1000 Subject: [PATCH 428/686] docs(stack-encrypt): explain why BloomVisitor implements visit_seq Say in the code what the visitor is walking: the PRF input for a match term is the sequence of tokens, the backend evaluates the PRF once per token, so the output resolves as a sequence of per-token blocks and the visitor folds each block into that token's `k` filter positions. Claude-Session: https://claude.ai/code/session_01HU1Bbw4eQp9kEEneXxDcKr --- packages/stack-encrypt/src/sem/mod.rs | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/packages/stack-encrypt/src/sem/mod.rs b/packages/stack-encrypt/src/sem/mod.rs index 8d638d103..54cf9bd3e 100644 --- a/packages/stack-encrypt/src/sem/mod.rs +++ b/packages/stack-encrypt/src/sem/mod.rs @@ -228,6 +228,13 @@ impl MatchOptions { /// A [`PrfVisitor`] that folds a sequence of per-token PRF blocks into /// Bloom-filter bit positions: `k` little-endian 2-byte slices of each block, /// masked to the filter size. +/// +/// The PRF input for a match term is the *sequence* of tokens the text was +/// cut into (n-grams or words — see [`Tokenizer`]), and the backend evaluates +/// the PRF once per token. The resolved output therefore arrives as a +/// sequence of blocks, one per token, which is why this visitor implements +/// `visit_seq` rather than `visit_block`: it walks the per-token blocks and +/// turns each one into that token's `k` Bloom-filter bits. struct BloomVisitor { k: usize, mask: u16, @@ -237,14 +244,23 @@ impl PrfVisitor<[u8; 32], P> for BloomVisitor { type Value = MatchTerm; fn visit_seq(self, seq: SeqAccess<[u8; 32], P>) -> Result { + // One node per token, in token order. Each node is the PRF output for + // that token alone; `BlockVisitor` unwraps it to the raw 32-byte block. let mut positions: Vec = Vec::with_capacity(seq.len() * self.k); for node in seq { let block = node.visit(BlockVisitor)?; + // A token sets `k` bits of the filter. The block is 32 bytes and + // `k <= 16` (checked in `MatchOptions::validate`), so the `k` + // 2-byte slices are disjoint; masking to `m - 1` maps each u16 into + // the filter's `m` positions. The same token in a query text hits + // the same `k` positions, which is what `MatchTerm::contains` tests. for i in 0..self.k { let chunk = [block[2 * i], block[2 * i + 1]]; positions.push(u16::from_le_bytes(chunk) & self.mask); } } + // The stored term is a set of positions: order and multiplicity carry + // no information, and sorting makes `contains` a binary search. positions.sort_unstable(); positions.dedup(); Ok(MatchTerm { positions }) From cba04575558b119be6350eb6eb0343dc3440134f Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Thu, 27 Aug 2026 14:51:24 +1000 Subject: [PATCH 429/686] fix(stack-encrypt): let StackCipher::new() find the client key in the CLI profile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `StackCipher::new()` resolved its two credentials from different places: the access token through `AutoStrategy` (environment, then the current workspace's `auth.json`), but the client key through `EnvKeyProvider` alone. After `npx stash auth login` the token was found and the key was not, and the example failed with `Config(KeyProvider(NotConfigured("CS_CLIENT_ID environment variable not set")))` even though `secretkey.json` sat next to `auth.json`. stack-kms already has the profile-backed provider (`KeyProvider for ProfileStore`, behind its `profile` feature) — stack-encrypt just compiled it out on the reasoning that it "only ever takes a `DataKeySource`", which stopped being true when `new()` started building the ZeroKMS client itself. Enable the feature (stack-kms gates it off wasm32 itself) and chain `FallbackKeyProvider::new(EnvKeyProvider, profile)` in `init`, so the key is looked up exactly the way the token is: environment first, then the profile directory. An unresolvable profile directory is not an error (CI has none); the not-configured message now says both ways to fix it. On wasm32 the environment remains the only source. Docs on `init`, `FromEnv` and the crate example now say that logging in with the CLI is sufficient on a developer machine. Claude-Session: https://claude.ai/code/session_01HU1Bbw4eQp9kEEneXxDcKr --- packages/stack-encrypt/Cargo.toml | 10 ++--- packages/stack-encrypt/src/cipher.rs | 56 +++++++++++++++++++++++++++- packages/stack-encrypt/src/lib.rs | 4 +- 3 files changed, 61 insertions(+), 9 deletions(-) diff --git a/packages/stack-encrypt/Cargo.toml b/packages/stack-encrypt/Cargo.toml index e12363b2e..6afb63d37 100644 --- a/packages/stack-encrypt/Cargo.toml +++ b/packages/stack-encrypt/Cargo.toml @@ -15,11 +15,11 @@ license-file = "LICENSE" publish = false [dependencies] -# No default features: `stack-encrypt` only ever takes a `DataKeySource`, so -# it has no use for stack-kms's `profile` feature (the CLI's on-disk -# `secretkey.json` via `stack-profile`). Consumers that want it enable it on -# their own `stack-kms` dependency. -stack-kms = { path = "../stack-kms", default-features = false } +# `profile` (native only; stack-kms gates it off wasm32 itself) lets +# `StackCipher::new()` fall back to the client key `stash auth login` writes +# to the profile directory (`secretkey.json`) when CS_CLIENT_ID / +# CS_CLIENT_KEY are not set — the same place `AutoStrategy` finds the token. +stack-kms = { path = "../stack-kms", default-features = false, features = ["profile"] } # `StackCipher::new()` builds a ZeroKMS client from the environment, so its # return type names the auto-detected auth strategy. stack-auth = { workspace = true } diff --git a/packages/stack-encrypt/src/cipher.rs b/packages/stack-encrypt/src/cipher.rs index d05f1a2c0..09df08135 100644 --- a/packages/stack-encrypt/src/cipher.rs +++ b/packages/stack-encrypt/src/cipher.rs @@ -72,6 +72,8 @@ use stack_kms::{ DataKey, DataKeySource, DataKeyWithTag, EnvKeyProvider, GenerateKeyPayload, IdentifiedBy, IndexKeySource, RetrieveKeyPayload, StackKms, StackKmsBuilder, }; +#[cfg(not(target_arch = "wasm32"))] +use stack_kms::{FallbackKeyProvider, KeyProvider, KeyProviderError, ProfileStore}; use uuid::Uuid; use vitaminc_aead::{ Aad, Cipher, CipherText, Decipher, DecipherVisitor, Decrypt, Encrypt, IntoAad, LocalCipherText, @@ -222,9 +224,47 @@ impl StackCipher { /// The state of a [`StackCipherBuilder`] that has not been given a data-key /// source: [`init`](StackCipherBuilder::init) will build a ZeroKMS client from -/// the environment. +/// the environment (and, on native targets, the CLI's profile directory). pub struct FromEnv; +/// The client key, looked up the way [`stack_auth::AutoStrategy`] looks up the +/// access token: `CS_CLIENT_ID` / `CS_CLIENT_KEY` first, then the current +/// workspace's `secretkey.json` in the profile directory. A profile directory +/// that cannot be resolved is not an error here — env-only setups (CI) have +/// none — it just leaves the environment as the only source. +#[cfg(not(target_arch = "wasm32"))] +fn client_key_provider() -> FallbackKeyProvider { + FallbackKeyProvider::new( + EnvKeyProvider, + ProfileClientKey(ProfileStore::resolve(None).ok()), + ) +} + +/// wasm32 has no filesystem, so no profile: the environment is the only source. +#[cfg(target_arch = "wasm32")] +fn client_key_provider() -> EnvKeyProvider { + EnvKeyProvider +} + +/// [`ProfileStore`] as a [`KeyProvider`], tolerating an unresolvable profile +/// directory so the "not configured" message can say what to do about it +/// rather than only that `CS_CLIENT_ID` is unset. +#[cfg(not(target_arch = "wasm32"))] +struct ProfileClientKey(Option); + +#[cfg(not(target_arch = "wasm32"))] +impl KeyProvider for ProfileClientKey { + async fn client_key(&self) -> Result { + match &self.0 { + Some(store) => store.client_key().await, + None => Err(KeyProviderError::NotConfigured( + "no client key: set CS_CLIENT_ID / CS_CLIENT_KEY, or run `npx stash auth login`" + .into(), + )), + } + } +} + /// Builder for a [`StackCipher`]. See [`StackCipher::builder`]. pub struct StackCipherBuilder { kms: K, @@ -256,9 +296,21 @@ impl StackCipherBuilder { /// Build a ZeroKMS client from the environment, then resolve the keyset /// and load its index key. + /// + /// Credentials come from the same two places for both halves of the + /// client — the access token and the client key: + /// + /// 1. the environment (`CS_CLIENT_ACCESS_KEY` + `CS_WORKSPACE_CRN`; + /// `CS_CLIENT_ID` + `CS_CLIENT_KEY`), then + /// 2. the current workspace in the CLI's profile directory + /// (`auth.json`; `secretkey.json`), which `npx stash auth login` + /// writes. + /// + /// So on a developer machine, logging in with the CLI is sufficient; in + /// CI, the four variables are. pub async fn init(self) -> Result>, Error> { let kms = StackKmsBuilder::auto()? - .with_key_provider(EnvKeyProvider) + .with_key_provider(client_key_provider()) .build() .await?; StackCipherBuilder { diff --git a/packages/stack-encrypt/src/lib.rs b/packages/stack-encrypt/src/lib.rs index 44bb92044..08a45cf44 100644 --- a/packages/stack-encrypt/src/lib.rs +++ b/packages/stack-encrypt/src/lib.rs @@ -26,8 +26,8 @@ //! # async fn example() -> Result<(), Box> { //! use stack_encrypt::StackCipher; //! -//! // Credentials and the client key come from the environment -//! // (CS_CLIENT_ID / CS_CLIENT_KEY, plus an access token strategy). +//! // Credentials: `npx stash auth login` on a developer machine, or +//! // CS_CLIENT_ID / CS_CLIENT_KEY + CS_CLIENT_ACCESS_KEY / CS_WORKSPACE_CRN in CI. //! let cipher = StackCipher::new().await?; //! //! let ciphertext = cipher.encrypt("secret message".to_string(), ()).await?; From b88f903a8146b80f3466f8d76bfbfa8fe4f7ea38 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Thu, 27 Aug 2026 15:55:34 +1000 Subject: [PATCH 430/686] fix(cllw-ore): wipe the by-value copy Key::from is handed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Key::from([u8; 32])` moved its argument into the `ZeroizeOnDrop` key and left the argument's stack slot as it was. `[u8; 32]` is `Copy`, so that slot is a second copy of the key material the constructor never wiped, and the docs — on `Key` and on stack-encrypt's `OreVisitor` — claimed more than the constructor delivered (review finding on cipherstash/cipherstash-suite#2146). The constructor now zeroizes its copy before returning, and the docs say what each layer actually guarantees: `Key::from` wipes the copy it receives, the caller wipes the array it still holds, and the key wipes itself on drop. Claude-Session: https://claude.ai/code/session_01HU1Bbw4eQp9kEEneXxDcKr --- packages/stack-encrypt/src/sem/mod.rs | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/stack-encrypt/src/sem/mod.rs b/packages/stack-encrypt/src/sem/mod.rs index 54cf9bd3e..2856c555c 100644 --- a/packages/stack-encrypt/src/sem/mod.rs +++ b/packages/stack-encrypt/src/sem/mod.rs @@ -274,10 +274,11 @@ impl PrfVisitor<[u8; 32], P> for BloomVisitor { /// A [`PrfVisitor`] that carries the plaintext in and hands the CLLW ORE /// ciphertext out. The PRF block becomes the CLLW [`Key`](cllw_ore::Key) -/// *inside* `visit_block` and dies there: the block arrives by value, is moved -/// into the `ZeroizeOnDrop` key, the stack copy left behind (`[u8; 32]` is -/// `Copy`) is wiped, the value is encrypted, and only the ciphertext leaves. -/// No key is ever returned to the caller. +/// *inside* `visit_block` and dies there: the block arrives by value and is +/// copied into the `ZeroizeOnDrop` key (`[u8; 32]` is `Copy`, so `Key::from` +/// wipes its copy and this visitor wipes the one it still holds), the value +/// is encrypted, and only the ciphertext leaves. No key is ever returned to +/// the caller. /// /// This is the shape a 2-party PRF needs: the caller supplies a PRF input /// (the descriptor) and receives a term, and where the key comes from — or From 611ef86cf9d29c38c6bd57e85816a9558e503ed8 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Wed, 26 Aug 2026 00:16:06 +1000 Subject: [PATCH 431/686] feat(stack-encrypt): cipher-owned async shape for target-directed encryption (RFC 0002) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit EncryptedFrom no longer hardcodes a boxed future: EncryptTarget's GAT lets the cipher choose its output, and StackCipher returns Pending — a request carrier that merges (zip/map/all) without doing I/O and issues one batched ZeroKMS call per request kind when awaited. A five-row column is now one generate_keys call, a composite record one call, a term probe zero; the previous shape paid one round-trip per element and made the future combined 0KMS keys+PRF operation unreachable. Decrypt lands as the full mirror (DecryptTarget/DecryptedFrom/ DecryptExt), so a column of rows retrieves in one call too. Term derivation is fully synchronous behind the visitors from cipherstash/cipherstash-suite#2146; associated error types are gone — errors belong to the cipher. Call-count tests pin the batching claims; term_bytes pins prove the wire format is untouched. Claude-Session: https://claude.ai/code/session_011kjxjgxWmT4yi23ZqufSEc --- ...nc-shape-for-target-directed-encryption.md | 495 ++++++++++ packages/stack-encrypt/Cargo.toml | 20 +- .../examples/encrypted_record.rs | 156 ++++ packages/stack-encrypt/examples/mixed_user.rs | 188 ++++ .../stack-encrypt/examples/search_terms.rs | 109 +++ .../stack-encrypt/examples/zerokms_auth.rs | 85 ++ packages/stack-encrypt/src/cipher.rs | 32 +- packages/stack-encrypt/src/lib.rs | 5 + packages/stack-encrypt/src/sem/mod.rs | 624 ++++++++++--- packages/stack-encrypt/src/target.rs | 881 ++++++++++++++++++ packages/stack-encrypt/tests/sem_terms.rs | 135 +-- packages/stack-encrypt/tests/target.rs | 685 ++++++++++++++ packages/stack-encrypt/tests/term_bytes.rs | 85 ++ 13 files changed, 3304 insertions(+), 196 deletions(-) create mode 100644 docs/rfcs/0002-async-shape-for-target-directed-encryption.md create mode 100644 packages/stack-encrypt/examples/encrypted_record.rs create mode 100644 packages/stack-encrypt/examples/mixed_user.rs create mode 100644 packages/stack-encrypt/examples/search_terms.rs create mode 100644 packages/stack-encrypt/examples/zerokms_auth.rs create mode 100644 packages/stack-encrypt/src/target.rs create mode 100644 packages/stack-encrypt/tests/target.rs create mode 100644 packages/stack-encrypt/tests/term_bytes.rs diff --git a/docs/rfcs/0002-async-shape-for-target-directed-encryption.md b/docs/rfcs/0002-async-shape-for-target-directed-encryption.md new file mode 100644 index 000000000..16836379b --- /dev/null +++ b/docs/rfcs/0002-async-shape-for-target-directed-encryption.md @@ -0,0 +1,495 @@ +# RFC 0002 — The async shape of target-directed encryption + +| | | +| -- | -- | +| **Status** | Accepted — implemented on PR #2146 (sem visitors) and #2147 (target layer) | +| **Author** | Dan Draper | +| **Area** | `packages/stack-encrypt` (`target`, `sem`, `cipher`), `vitaminc` (`prf`) | +| **Supersedes** | The "Batching and async" section of `target-directed-encryption.md` | +| **Prompted by** | Review of PR #2147 | + +> **Companion:** [`target-directed-encryption.md`](../../target-directed-encryption.md) — +> the original design. Everything it says about *what* the target type decides +> stands. This RFC replaces only *how the async is shaped*, which the +> implementation got wrong. + +## 1. Summary + +`EncryptedFrom` as implemented in #2147 hardcodes a boxed future as its return +type. Three consequences: + +1. Every cipher is forced to be async, including ones that do no I/O. +2. Nothing can be batched — not the fields of one record, not a column of + rows. A five-row insert is five ZeroKMS round-trips where `Encrypt` alone + would make one. +3. The single 0KMS operation that will derive data keys **and** PRF values + together is not merely unused, it is unreachable: by the time a composite + sees its fields, each is a sealed-shut future with no inspectable requests. + +The fix is to apply the rule vitaminc already follows everywhere else — +**build synchronously, settle once** — and to let the *cipher* own the output +type, exactly as `Cipher::Ok` and `Prf::Ok` already do. + +Call sites do not get worse. They get shorter. + +## 2. What is wrong today + +### 2.1 The trait decides the async, not the cipher + +```rust +// packages/stack-encrypt/src/target.rs +pub type PendingEncrypt<'a, T, E> = Pin> + Send + 'a>>; + +fn encrypt_from<'a, 'c, Ctx>(source: &'a S, cipher: &'a C, context: Ctx) + -> PendingEncrypt<'a, Self, Self::Error>; +``` + +`EncryptedFrom` is the only trait in the stack that does this. Its two +neighbours both hand the choice to the implementation: + +| trait | output | who decides | +| -- | -- | -- | +| `Cipher::Ok` | `PendingStackCipherText` for `StackCipher`; a finished ciphertext for a local cipher | the cipher | +| `Prf::Ok` | `ReadyPrf` for `HmacSha256Prf`; a real future for a future 2-party backend | the backend | +| `EncryptedFrom` | `Pin>`, always | **the trait** | + +A cipher that does no I/O still returns a future the caller must `.await`. + +### 2.2 Nothing batches + +`StackCipherText::encrypt_from` calls `cipher.encrypt(value, aad)`, which is +`encrypt_with_aad` **plus** `pending.seal(..)` — it settles immediately. So the +pending tree that exists precisely so leaves can share one `generate_keys` +call is built and consumed inside a single leaf. + +The cost, from `examples/encrypted_record.rs`: + +```rust +for age in ages { // 5 ages + let record: EncryptedInt = age.encrypt_into(&cipher, CONTEXT).await?; + table.push(record); +} +``` + +Five `generate_keys` round-trips. The same five values through `Encrypt` +alone — `cipher.encrypt(vec_of_ages, aad)` — are **one**, because +`encrypt_seq` builds one tree and `key_count()` sums its leaves into one +payload batch. + +The read path in the same example has the identical defect: one +`retrieve_keys` per row, in a loop. + +The root cause is not the loop. It is that there is no `Vec` implementation, +so a column *cannot* be expressed as a single operation the way `Encrypt` +expresses it. + +### 2.3 `try_join!` does not do what its comment claims + +```rust +/// For that batching to be possible across a record's fields, composite +/// implementations must poll their field pendings **concurrently** +/// (e.g. `tokio::try_join!`), never sequentially. +``` + +Concurrency is not coalescing. Three independently constructed futures polled +at once issue three requests. Coalescing needs a shared request collector, and +there is nowhere to put one — each future has already closed over its inputs +before `try_join!` sees it. + +(Today only the ciphertext branch does I/O, so a record costs one round-trip, +not three. The comment is still wrong about why, and the shape it recommends +is what blocks §2.2.) + +### 2.4 SEM shaping happens inside the async + +vitaminc's PRF already has the right seam: the backend produces blocks, and a +`PrfVisitor` turns blocks into whatever shape the caller wants — +`prf_visit_with_context(prf, ctx, visitor) -> P::Ok`. Nothing about +Bloom positions or CLLW ciphertexts needs to be async. + +`sem`'s `derive_match` uses that seam correctly (`BloomVisitor`). The other +three do not: + +```rust +// derive_equality — BlockVisitor, then shape in async code +let block = value.prf_with_context(prf, context).await?; +Ok(EqualityTerm(block)) + +// derive_cllw_key + derive_ore — BlockVisitor, then shape in async code +let block = context_bytes.prf_with_context(prf, ..).await?; +let key = cllw_ore::Key::from(block); +value.encrypt(&key) +``` + +Worse, all four `derive_*` are `async fn`, which collapses `P::Ok` +into an `.await` **inside stack-encrypt** — throwing away the backend's choice +of output type before the cipher ever sees it. That is the same mistake as +§2.2, one layer down: a deferred handle destroyed by the code that should have +been passing it along. + +Pure validation (`require_context`, `MatchOptions::validate`, +`EmptyTermText`) also runs inside the async body, so a malformed call fails +after a round-trip rather than before one. + +## 3. The rule + +> **Build synchronously. Settle once. The settle point belongs to the cipher.** + +vitaminc obeys this: `Encrypt` drives a `Cipher` with no I/O and yields +`Cipher::Ok`; whoever holds the `Ok` decides when — and how many at a time — +to settle. `EncryptedFrom` must obey it too. + +## 4. Design + +### 4.1 The cipher owns the output type + +```rust +/// Implemented by ciphers. Decides what `encrypt_from` hands back. +pub trait EncryptTarget { + type Error; + type Output<'a, T: 'a>: 'a where Self: 'a; +} + +pub trait EncryptedFrom: Sized { + fn encrypt_from<'a, 'c, Ctx>(source: &'a S, cipher: &'a C, ctx: Ctx) -> C::Output<'a, Self> + where + Ctx: EncryptContext<'c>; +} +``` + +- A synchronous cipher sets `Output<'a, T> = Result`. No + future, no `.await`. +- `StackCipher` sets `Output<'a, T> = PendingEncrypted<'a, T, K>` (§4.3), + which implements `IntoFuture`. + +```rust +let t: EqualityTerm = "alice".encrypt_into(&stack_cipher, "users/email").await?; // async backend +let t: LocalTerm = "alice".encrypt_into(&local_cipher, "users/email")?; // sync backend +``` + +(The bounds above are the shape, not the final spelling — `K: 'a` and the +GAT's implied bounds will surface during implementation. What must not change +is *where* the type is chosen.) + +### 4.2 Type inference: why this works and the earlier attempt did not + +The implementation notes record that an associated `Pending` type was tried +and defeated `let term: EqualityTerm = v.encrypt_into(..).await?`. That is +correct **for an associated type on the target**: normalizing `T::Pending` +requires selecting the `EncryptedFrom` impl, which requires knowing `T` — the +very thing being inferred. + +`C::Output<'a, T>` has no such cycle. Normalizing it requires only `C`, and +`C` is concrete at every call site (`&StackCipher`). `T` survives as a +syntactic parameter of a concrete struct, exactly as it does in today's +`Pin>>>`, so the `.await?` unifies `T` +with the annotated binding through `PendingEncrypted`'s `IntoFuture` impl. + +This distinction is the load-bearing part of the design and should be pinned +by a compile test. + +### 4.3 `PendingEncrypted` — a request carrier, not a future + +```rust +pub struct PendingEncrypted<'a, T, K> { + cipher: &'a StackCipher, + requests: Vec, // Request::DataKey | Request::Prf + fulfil: FulfilBox<'a, T>, +} + +// The Send split mirrors today's `PendingEncrypt` alias and MUST carry over: +// the ZeroKMS futures are not `Send` on wasm32. +#[cfg(not(target_arch = "wasm32"))] +type FulfilBox<'a, T> = Box Result + Send + 'a>; +#[cfg(target_arch = "wasm32")] +type FulfilBox<'a, T> = Box Result + 'a>; + +impl<'a, T, K: DataKeySource> IntoFuture for PendingEncrypted<'a, T, K> { + type Output = Result; + // Boxed future, cfg-split on Send exactly as above. + fn into_future(self) -> ... { + Box::pin(async move { + let responses = dispatch(self.cipher, self.requests).await?; // ONE call + (self.fulfil)(responses) + }) + } +} +``` + +Carrying `K` is what lets the type name the cipher it settles against; the +`EncryptTarget` impl is per-`K`, so `Output<'a, T> = PendingEncrypted<'a, T, K>` +is well-formed. (The alternative — erasing `K` behind a boxed dispatch +closure captured at construction — keeps the type two-parameter at the cost +of a second allocation per pending. Either works; carrying `K` is the default +because it is simpler and the type rarely appears in signatures outside +`encrypt_from`.) + +`into_future` is the **only** place I/O happens, and the only place that knows +how to talk to 0KMS. With an empty request list it short-circuits: a +term-only target does zero round-trips. Requests are heterogeneous +(`DataKey` now, `Prf` later); a `fulfil` that draws a response of the wrong +variant — or the wrong count — is a composition bug and settles as an error +(`Error::ResponseShape`), never a panic. + +Its API is small and is **the public surface third-party targets build +against** (§4.7): + +```rust +impl<'a, T, K> PendingEncrypted<'a, T, K> { + pub fn ready(cipher: &'a StackCipher, result: Result) -> Self; + pub fn request(cipher: &'a StackCipher, requests: Vec, fulfil: ...) -> Self; + pub fn map(self, f: impl FnOnce(T) -> U + ...) -> PendingEncrypted<'a, U, K>; + pub fn zip(self, other: PendingEncrypted<'a, U, K>) -> PendingEncrypted<'a, (T, U), K>; + // zip3 / zipN as needed; `all` for Vec (§4.4) + pub fn all(items: Vec>) -> PendingEncrypted<'a, Vec, K>; +} +``` + +`zip` concatenates request vectors and splits the response vector back by +recorded length, so no `fulfil` can over-draw its neighbours' responses. + +`StackCipherText::encrypt_from` stops calling `cipher.encrypt` and instead +keeps the tree it was always meant to keep: + +```rust +let tree = value.encrypt_with_aad(cipher, aad)?; // PendingStackCipherText, no I/O +requests = (0..tree.key_count()).map(|_| Request::data_key()), +fulfil = move |keys| tree.seal_with(&mut keys.into_iter()) +``` + +`key_count()` + `seal_with`'s draw-in-traversal-order is already the exact +invariant `zip` needs. The design is the existing machinery applied one level +up. + +### 4.4 Composition is where batching comes from + +Composites combine pendings **without awaiting them**, so requests merge: + +```rust +impl EncryptedFrom> for EncryptedInt { + fn encrypt_from<'a, 'c, Ctx>(source: &'a u32, cipher: &'a StackCipher, ctx: Ctx) + -> PendingEncrypted<'a, Self, K> + { + StackCipherText::encrypt_from(source, cipher, ctx.clone()) + .zip(EqualityTerm::encrypt_from(source, cipher, ctx.clone())) + .zip(OreTerm::::encrypt_from(source, cipher, ctx)) + .map(|((ciphertext, eq), ord)| Self { ciphertext, eq, ord }) + } +} +``` + +No `tokio::try_join!`, no `Box::pin(async move ..)`, no error-conversion +where-clauses. This is roughly half the size of the current impl and is +directly emittable by `#[derive(Encrypted)]`. + +Then the missing piece from §2.2: + +```rust +impl EncryptedFrom, StackCipher> for Vec where T: EncryptedFrom> +impl EncryptedFrom, StackCipher> for Option +``` + +which makes the column one operation, one await, one round-trip: + +```rust +let table: Vec = ages.encrypt_into(&cipher, CONTEXT).await?; +``` + +Batching comes from the **source shape**, exactly as it does for `Encrypt`. +There is no `seal_all`, no flush handle, and no two-step call site. + +Elements of a `Vec` share one context deliberately: a column is one context. +(`Encrypt` separately refines per-element AAD via `Aad::for_sequence_element`; +the PRF context is not refined, so equal values in a column derive equal +terms — which is the point of an index.) + +A caller who awaits per element still pays per element. That is true of +`Encrypt` too, is visible at the call site, and is acceptable. + +### 4.5 SEM terms become visitors + +Delete the four `derive_*` functions. Each term's `encrypt_from` does its pure +work up front, then derives through `prf_visit_with_context` with a visitor +that shapes the block: + +| term | visitor | shaping | +| -- | -- | -- | +| `EqualityTerm` | `EqualityVisitor` | block → term | +| `MatchTerm` | `BloomVisitor { k, mask }` | already correct; keep | +| `OreTerm` | `CllwOreVisitor { value }` | block → CLLW key → encrypt → term | +| `OpeTerm` | `CllwOpeVisitor { value }` | as above | + +`require_context`, `MatchOptions::validate` and the empty-token check move +ahead of the request, where they fail without a round-trip. The visitor owns +the plaintext it needs, which removes the clone-into-async-fn each term does +today and narrows the plaintext fan-out the module docs warn about — only the +ciphertext branch still needs an owned copy held until seal. + +**How the value comes out synchronously.** No `SyncPrf` marker trait is +needed, but the mechanism deserves stating, because it is concrete-type +knowledge, not trait knowledge: term impls bind `StackCipher`, whose PRF is +concretely `HmacSha256Prf`, whose `Ok` is `ReadyPrf` — +and `ReadyPrf::into_result()` extracts without an executor. So today a term's +`encrypt_from` is: + +```rust +let term = tokens + .prf_visit_with_context(cipher.prf().clone(), context, BloomVisitor { k, mask }) + .into_result() // ReadyPrf: sync, infallible backend + .map_err(TermError::from_prf); +PendingEncrypted::ready(cipher, term.map_err(Error::from)) +``` + +**The migration path is the argument for the visitor seam.** When the 2-party +ZeroKMS PRF backend replaces the local HMAC inside `StackCipher`, a term's +`encrypt_from` changes in exactly one way: instead of invoking the visitor +inline over a `ReadyPrf`, it pushes `Request::Prf { input, context }` and +invokes the **same visitor** inside `fulfil`, over the blocks that came back +in the batch response. The shaping code — Bloom positions, CLLW key +derivation, all of it — does not change, because the visitor never knew which +side of the round-trip it ran on. That swap is also what fuses terms and data +keys into the single combined 0KMS call: both are then rows in one +`requests` vector settled by one `dispatch`. + +### 4.6 Errors belong to the cipher + +`C::Output<'a, T>` has no error slot, so the error is `C::Error`, and +`EncryptedFrom::Error` is dropped. `TargetError` and the six-line +error-conversion where-clauses on every composite go with it. Term errors +reach the cipher's error through `Error::Term(#[from] TermError)`; third-party +terms get an `Error::Other(Box)` escape; +`Error::ResponseShape` covers a mis-drawn response (§4.3). + +### 4.7 The third-party recipe, revised + +The current module docs teach external term authors to return +`Box::pin(async move ..)`. The replacement is shorter and does no async at +all until a deferred PRF exists: + +```rust +impl EncryptedFrom> for MyTerm +where + S: PrfValue + Clone, +{ + fn encrypt_from<'a, 'c, Ctx>( + source: &'a S, + cipher: &'a StackCipher, + context: Ctx, + ) -> PendingEncrypted<'a, Self, K> + where + Ctx: EncryptContext<'c>, + { + let context = context.into_prf_context().into_owned(); + let context = PrfContext::pae(&[b"my-crate/my-term/v1".as_slice(), context.as_bytes()]); + let term = source + .clone() + .prf_visit_with_context(cipher.prf().clone(), context, MyVisitor) + .into_result() + .map(MyTerm) + .map_err(|e| Error::Other(Box::new(e))); + PendingEncrypted::ready(cipher, term) + } +} +``` + +This commits `PendingEncrypted::ready` / `::request` — and therefore +`Request` (and enough of `Responses` for a `fulfil` to draw from) — to the +public API. That is deliberate: an extension point that only first-party code +can use is not an extension point. See §7.3 for what stays private. + +## 5. Invariant this imposes on future backends + +**A deferred output type must be a mergeable request carrier, not a +self-driving future.** + +This is why `Cipher::Ok` is `PendingStackCipherText` rather than a future, and +it must hold for the 2-party ZeroKMS PRF backend too: if its `Prf::Ok` is +`Pin>`, terms and data keys can never share a round-trip, and +the combined keys-and-PRF 0KMS operation becomes unreachable — silently, from +an implementation that looks perfectly reasonable in isolation. + +Honesty about enforcement: today this is **guidance, not mechanism**. Nothing +in the `Prf` trait lets a caller decompose a foreign `Ok` into requests +plus a continuation; `StackCipher` will merge PRF work by *being the caller* +of its own backend (§4.5), not by prying open a generic `P::Ok`. Making the +invariant structural — an `into_parts()`-style decomposition on deferred +outputs — is a future `Prf` trait extension, and should be designed with the +2-party backend, not before it. Until then the rustdoc on `Prf::Ok` / +`Cipher::Ok` can only warn (§8). + +## 6. Impact + +| file | change | +| -- | -- | +| `src/target.rs` | `EncryptTarget` + GAT; `PendingEncrypted` (with the wasm32 `Send` cfg-split carried over from `PendingEncrypt`); drop `PendingEncrypt` alias, `EncryptedFrom::Error`, `TargetError` | +| `src/sem/mod.rs` | four visitors in, four `derive_*` out; validation moves ahead of the request | +| `src/cipher.rs` | `StackCipher: EncryptTarget`; `dispatch`; `Error::Term`/`Error::Other`/`Error::ResponseShape` | +| `examples/`, `tests/` | column encrypted as a `Vec`, not a loop; `try_join!` gone; tokio dev-dep drops out of the record shape | + +Wire format is untouched. `tests/term_bytes.rs` is the guard: the four pinned +derivations must produce identical bytes before and after. + +## 7. Decisions — resolved + +1. **Composite impls bind `StackCipher`** (decided): records carrying SEM + terms need a PRF that vitaminc's ciphers do not have, and the derive emits + concrete code either way. The `ready`/`map`/`zip`/`all` combinators live on + `Pending`; they lift onto `EncryptTarget` if a second async cipher ever + appears. +2. **Decrypt landed with this change** (decided): `DecryptTarget`, + `DecryptedFrom`, `DecryptExt` and `DecryptContext` mirror the encrypt side; + the `Vec` implementation batches a column of rows into one + `retrieve_keys`. The derive will emit both directions from day one. +3. **`Request` is public but opaque** (decided): constructors only + (`Request::generate_data_key()`, `Request::retrieve_data_key(iv, tag)`, + later a PRF request and a keyset override), internals private. `Responses` + is a drawing handle (`next_generated_key()` / `next_retrieved_key()`), + never inspectable, and each fulfilment is scoped to exactly the responses + its own requests asked for — over-drawing is `Error::ResponseShape`, not a + sibling's stolen key. +4. **Per-field keysets are achievable in this shape** + ([CIP-3870](https://linear.app/cipherstash/issue/CIP-3870)), and the + request-carrier design is specifically what makes them so: `Request` being + opaque means a keyset override field is a non-breaking addition; `dispatch` + then groups requests by keyset and issues one call per distinct keyset, + re-zipping responses into draw order — no trait or `Pending` surface + change. Per-keyset *terms* need per-keyset index keys, so "load the index + key for keyset X" becomes a request itself, with the visitor running in the + fulfilment. The genuine blocker is decrypt: `SealedValue` records no + keyset, so per-leaf retrieve routing needs the wire change already parked + in CIP-3870. + +### Deviations from the proposal above + +The implementation kept the design and changed three names/details: + +- **`Pending<'a, T, K>`**, not `PendingEncrypted` — one carrier serves both + directions (it is `DecryptTarget::Output` too), so the direction is not in + its name. +- **`DecryptContext`** (`IntoAad + Clone`) joined `EncryptContext`: decryption + derives nothing, so it must not demand a PRF conversion. +- **`dispatch` issues one call per request *kind*** (at most one + `generate_keys` + one `retrieve_keys`, sequentially — a mixed batch is rare + today). When ZeroKMS grows the combined keys-plus-PRF operation, `dispatch` + is the one function that changes. + +## 8. Where findings get recorded + +Three homes, by durability: + +- **Trait invariants** (§5, and "the visitor shapes, the backend only + produces blocks") → rustdoc on `Prf::Ok` in `vitaminc/packages/prf/src/traits.rs`, + and on `Cipher::Ok` in `vitaminc/packages/aead/src/cipher.rs`. A backend + author reads the trait, not this repo's RFC directory. These are the two + places where getting it wrong is invisible until it is expensive. +- **The reasoning** (§2–§4) → this RFC. It explains why the obvious + implementation is wrong, which rustdoc is the wrong length for. +- **The work** → Linear under CIP-3764. + +## 9. Non-goals + +- Changing what the target type decides. `target-directed-encryption.md` + stands. +- Wire format changes. +- A flush handle, an ambient batch registry, or timing-window coalescing. + Batching is expressed by the source shape and is visible at the call site. diff --git a/packages/stack-encrypt/Cargo.toml b/packages/stack-encrypt/Cargo.toml index 6afb63d37..347398cdc 100644 --- a/packages/stack-encrypt/Cargo.toml +++ b/packages/stack-encrypt/Cargo.toml @@ -30,11 +30,16 @@ stack-auth = { workspace = true } # `cargo update` cannot silently move the trait definitions under this crate, # and declared here rather than via the workspace dep so the rest of the suite # stays on the published 0.2.0-pre until the next vitaminc release. -vitaminc-aead = { git = "https://github.com/cipherstash/vitaminc", rev = "06fc3eaf8e39eab4aa9c6107c0ff2fc2952bebc3" } -vitaminc-encrypt = { git = "https://github.com/cipherstash/vitaminc", rev = "06fc3eaf8e39eab4aa9c6107c0ff2fc2952bebc3" } -vitaminc-hmac = { git = "https://github.com/cipherstash/vitaminc", rev = "06fc3eaf8e39eab4aa9c6107c0ff2fc2952bebc3" } -vitaminc-prf = { git = "https://github.com/cipherstash/vitaminc", rev = "06fc3eaf8e39eab4aa9c6107c0ff2fc2952bebc3" } -vitaminc-protected = { git = "https://github.com/cipherstash/vitaminc", rev = "06fc3eaf8e39eab4aa9c6107c0ff2fc2952bebc3" } +# +# Temporarily pinned to the commit of cipherstash/vitaminc#289 (main + the +# `vitaminc_aead::Passthrough` wrapper); move the pin back to a main commit +# once it merges. All five must share one source or the aead crate is +# duplicated. +vitaminc-aead = { git = "https://github.com/cipherstash/vitaminc", rev = "04f4faa3eeeb69ae30dd0247865e06c640585fea" } +vitaminc-encrypt = { git = "https://github.com/cipherstash/vitaminc", rev = "04f4faa3eeeb69ae30dd0247865e06c640585fea" } +vitaminc-hmac = { git = "https://github.com/cipherstash/vitaminc", rev = "04f4faa3eeeb69ae30dd0247865e06c640585fea" } +vitaminc-prf = { git = "https://github.com/cipherstash/vitaminc", rev = "04f4faa3eeeb69ae30dd0247865e06c640585fea" } +vitaminc-protected = { git = "https://github.com/cipherstash/vitaminc", rev = "04f4faa3eeeb69ae30dd0247865e06c640585fea" } serde = { workspace = true } cllw-ore = { workspace = true } @@ -46,8 +51,9 @@ zeroize = { workspace = true } serde_json = { workspace = true } stack-kms = { path = "../stack-kms", features = ["test-support"] } tokio = { workspace = true, features = ["rt", "macros"] } -vitaminc-hmac = { git = "https://github.com/cipherstash/vitaminc", rev = "06fc3eaf8e39eab4aa9c6107c0ff2fc2952bebc3" } -vitaminc-protected = { git = "https://github.com/cipherstash/vitaminc", rev = "06fc3eaf8e39eab4aa9c6107c0ff2fc2952bebc3" } +vitaminc-hmac = { git = "https://github.com/cipherstash/vitaminc", rev = "04f4faa3eeeb69ae30dd0247865e06c640585fea" } +vitaminc-prf = { git = "https://github.com/cipherstash/vitaminc", rev = "04f4faa3eeeb69ae30dd0247865e06c640585fea" } +vitaminc-protected = { git = "https://github.com/cipherstash/vitaminc", rev = "04f4faa3eeeb69ae30dd0247865e06c640585fea" } [package.metadata.docs.rs] all-features = true diff --git a/packages/stack-encrypt/examples/encrypted_record.rs b/packages/stack-encrypt/examples/encrypted_record.rs new file mode 100644 index 000000000..360922672 --- /dev/null +++ b/packages/stack-encrypt/examples/encrypted_record.rs @@ -0,0 +1,156 @@ +//! A searchable encrypted record, end to end. +//! +//! The point of target-directed encryption: define a record type that *is* +//! "the ciphertext plus the index terms this field needs", implement +//! `EncryptedFrom` once (the shape a future `#[derive(Encrypted)]` will +//! emit), and every insert is one `encrypt_into(..).await`. A tiny in-memory +//! "table" then answers equality and range queries purely by comparing terms +//! — decrypting only the rows that match. +//! +//! The async shape is the other half of the point: `encrypt_from` does no +//! I/O. It derives the terms locally and combines the field pendings with +//! `zip`/`map` — so a whole *column* of records, encrypted through the +//! `Vec` implementation, settles in **one** batched ZeroKMS call, and the +//! matching rows decrypt in one more. +//! +//! Run with: +//! +//! ```sh +//! cargo run -p stack-encrypt --example encrypted_record +//! ``` +//! +//! Uses `FakeDataKeySource`, so no ZeroKMS credentials or network are needed. + +use stack_encrypt::sem::{EqualityTerm, OreTerm}; +use stack_encrypt::target::{ + DecryptContext, DecryptExt, DecryptedFrom, EncryptContext, EncryptExt, EncryptedFrom, Pending, +}; +use stack_encrypt::{StackCipher, StackCipherText}; +use stack_kms::FakeDataKeySource; + +/// "An encrypted `u32`, stored as its ciphertext plus an equality term and an +/// ORE term." The same shape as an EQL `integer_ord_ore` payload, minus the +/// EQL wire encoding. +struct EncryptedInt { + ciphertext: StackCipherText, + eq: EqualityTerm, + ord: OreTerm, +} + +// One impl, written the way the derive will write it: build every field's +// pending (no I/O — the terms derive locally, the ciphertext queues its +// data-key requests), merge them with `zip`, shape with `map`. Errors are the +// cipher's; there is nothing to unify. +impl EncryptedFrom> for EncryptedInt { + fn encrypt_from<'a, 'c, Ctx>( + source: &'a u32, + cipher: &'a StackCipher, + context: Ctx, + ) -> Pending<'a, Self, K> + where + Ctx: EncryptContext<'c>, + Self: 'a, + { + // One context fans out to every field: it authenticates the + // ciphertext (AAD) and domain-separates both terms (PRF context). + StackCipherText::encrypt_from(source, cipher, context.clone()) + .zip(EqualityTerm::encrypt_from(source, cipher, context.clone())) + .zip(OreTerm::::encrypt_from(source, cipher, context)) + .map(|((ciphertext, eq), ord)| Self { + ciphertext, + eq, + ord, + }) + } +} + +// The decrypt mirror the derive will also write: only the ciphertext field +// participates (terms are one-way), so it delegates to the ciphertext's own +// implementation. +impl DecryptedFrom> for u32 { + fn decrypt_from<'a, 'c, Ctx>( + source: EncryptedInt, + cipher: &'a StackCipher, + context: Ctx, + ) -> Pending<'a, Self, K> + where + Ctx: DecryptContext<'c>, + EncryptedInt: 'a, + Self: 'a, + { + source.ciphertext.decrypt_into(cipher, context) + } +} + +#[tokio::main(flavor = "current_thread")] +async fn main() -> Result<(), Box> { + // One cipher does everything the record needs: ZeroKMS-backed AEAD (every + // leaf sealed under its own data key) and SEM term derivation under the + // keyset's index key, which `init` loads. Data keys and terms are bound to + // the same keyset by construction — there is no way to mix them up. + let cipher = StackCipher::builder() + .kms(FakeDataKeySource::new()) + .init() + .await?; + + // --- Write side: encrypt a column of ages ------------------------------- + + const CONTEXT: &str = "users/age"; + let ages: Vec = vec![29, 34, 41, 34, 57]; + + // One await for the whole column: the Vec implementation merges every + // record's pending, so five records (ciphertext + two terms each) settle + // in a single batched generate_keys call. + let table: Vec = ages.encrypt_into(&cipher, CONTEXT).await?; + println!( + "stored {} encrypted records in one ZeroKMS call", + table.len() + ); + + // --- Query side: terms only, no plaintext, no decryption ---------------- + + // Term probes derive under the index key the cipher already holds: + // building a query never calls ZeroKMS at all. + + // WHERE age = 34: compare equality terms. + let probe: EqualityTerm = 34u32.encrypt_into(&cipher, CONTEXT).await?; + let equal: Vec = (0..table.len()).filter(|&i| table[i].eq == probe).collect(); + println!("WHERE age = 34 => rows {equal:?}"); + + // WHERE age > 40: compare ORE terms. + let bound: OreTerm = 40u32.encrypt_into(&cipher, CONTEXT).await?; + let over_40: Vec = (0..table.len()).filter(|&i| table[i].ord > bound).collect(); + println!("WHERE age > 40 => rows {over_40:?}"); + + // ORDER BY age: sort by ORE term. + let mut by_age: Vec = (0..table.len()).collect(); + by_age.sort_by(|&a, &b| table[a].ord.cmp(&table[b].ord)); + println!("ORDER BY age => rows {by_age:?}"); + + // --- Read side: decrypt only the rows a query matched ------------------- + + // The fake source is deterministic, so a fresh instance re-derives the + // same data keys (production: any client holding the same ZeroKMS + // credentials and keyset). + let decryptor = StackCipher::builder() + .kms(FakeDataKeySource::new()) + .init() + .await?; + + // Collect the matching rows and decrypt them together: one batched + // retrieve_keys call, however many rows matched. The context must match + // the one the records were encrypted under — it is bound into the AAD, so + // a ciphertext cannot be replayed against a different field. + let mut table = table; + let mut matches: Vec = Vec::new(); + // Descending index order keeps earlier indices valid across swap_remove. + for i in over_40.into_iter().rev() { + matches.push(table.swap_remove(i)); + } + let ages: Vec = matches.decrypt_into(&decryptor, CONTEXT).await?; + for age in ages { + println!("decrypted matching row: age {age}"); + } + + Ok(()) +} diff --git a/packages/stack-encrypt/examples/mixed_user.rs b/packages/stack-encrypt/examples/mixed_user.rs new file mode 100644 index 000000000..a207c35cd --- /dev/null +++ b/packages/stack-encrypt/examples/mixed_user.rs @@ -0,0 +1,188 @@ +//! A struct with a mix of encrypted and passthrough fields, encrypted as a +//! batch. +//! +//! `User` keeps `id` and `display_name` in the clear (passthrough) while +//! `email` and `age` are sealed — each encrypted leaf under its own ZeroKMS +//! data key. A `Vec` encrypts in **one call and one batched +//! `generate_keys` round-trip**, producing a single ciphertext tree whose +//! shape (sequence of maps, entry keys) is authenticated by the AAD +//! derivation chain. +//! +//! Element *positions* are not. Every element of a sequence is sealed under +//! the same derived AAD — deliberately, since that is what lets a single row +//! of a batch decrypt on its own as `Element` — so reordering the elements +//! of a stored sequence still verifies. Order and length are the caller's +//! obligation; if they matter, bind them into the AAD yourself or store the +//! index alongside the row. +//! +//! Passthrough values travel in the clear and are **not authenticated** — +//! use them for non-sensitive routing/display data only. +//! +//! Run with: +//! +//! ```sh +//! cargo run -p stack-encrypt --example mixed_user +//! ``` +//! +//! Uses `FakeDataKeySource`, so no ZeroKMS credentials or network are needed. + +use stack_encrypt::{ + Cipher, CipherText, Decipher, Decrypt, Encrypt, IntoAad, StackCipher, StackCipherText, + Unspecified, +}; +use stack_kms::FakeDataKeySource; +use vitaminc_aead::{DecipherVisitor, MapAccess, MapCipher, Passthrough}; + +// --- The record type --------------------------------------------------------- + +#[derive(Debug, PartialEq)] +struct User { + id: u32, // passthrough: visible in the stored ciphertext + display_name: String, // passthrough + email: String, // encrypted + age: u32, // encrypted +} + +impl Encrypt for User { + fn encrypt_with_aad<'a, C, A>(self, cipher: C, aad: A) -> Result + where + C: Cipher, + A: IntoAad<'a>, + { + // Keys travel in the clear; each *encrypted* value is sealed against + // an AAD derived from the map's AAD + its key, so entries cannot be + // renamed or swapped. Passthrough entries carry no such binding. + cipher + .encrypt_map(aad) + .encrypt_entry("id", Passthrough(self.id))? + .encrypt_entry("display_name", Passthrough(self.display_name))? + .encrypt_entry("email", self.email)? + .encrypt_entry("age", self.age)? + .end() + } +} + +impl<'c> Decrypt<'c> for User { + fn decrypt_with_aad<'a, D, A>(decipher: D, aad: A) -> D::Ok + where + D: Decipher<'c>, + A: IntoAad<'a>, + { + struct UserVisitor; + impl<'c> DecipherVisitor<'c> for UserVisitor { + type Value = User; + + fn visit_map>(self, mut map: M) -> Result { + // Entries arrive in encryption order; each is pulled with its + // expected type and its key is checked. + fn entry<'c, M: MapAccess<'c>, T: Decrypt<'c> + 'c>( + map: &mut M, + key: &str, + ) -> Result { + let (k, value) = map + .next_entry::() + .map_err(|_| Unspecified)? + .ok_or(Unspecified)?; + if k == key { + Ok(value) + } else { + Err(Unspecified) + } + } + + let Passthrough(id) = entry::<_, Passthrough>(&mut map, "id")?; + let Passthrough(display_name) = + entry::<_, Passthrough>(&mut map, "display_name")?; + let email: String = entry(&mut map, "email")?; + let age: u32 = entry(&mut map, "age")?; + Ok(User { + id, + display_name, + email, + age, + }) + } + } + decipher.decrypt_map(UserVisitor, aad) + } +} + +// --- Inspect what a server would see ----------------------------------------- + +fn describe(ciphertext: &StackCipherText, indent: usize) { + let pad = " ".repeat(indent); + match ciphertext { + CipherText::Sequence(items) => { + println!("{pad}sequence of {} rows:", items.len()); + for item in items { + describe(item, indent + 1); + } + } + CipherText::Map(entries) => { + for (key, value) in entries { + match value { + CipherText::Passthrough(boxed) => { + // Passthrough values are readable without any key. + if let Some(v) = boxed.downcast_ref::() { + println!("{pad}{key}: {v} (passthrough, in the clear)"); + } else if let Some(v) = boxed.downcast_ref::() { + println!("{pad}{key}: {v:?} (passthrough, in the clear)"); + } + } + CipherText::Single(_) => { + println!("{pad}{key}: "); + } + _ => println!("{pad}{key}: "), + } + } + } + _ => {} + } +} + +#[tokio::main(flavor = "current_thread")] +async fn main() -> Result<(), Box> { + let cipher = StackCipher::builder() + .kms(FakeDataKeySource::new()) + .init() + .await?; + + let users = vec![ + User { + id: 1, + display_name: "alice".into(), + email: "alice@example.com".into(), + age: 34, + }, + User { + id: 2, + display_name: "bob".into(), + email: "bob@example.com".into(), + age: 41, + }, + User { + id: 3, + display_name: "carol".into(), + email: "carol@example.com".into(), + age: 29, + }, + ]; + + // One call, one batched generate_keys round-trip for every encrypted leaf + // in the whole Vec (here: 3 rows x 2 encrypted fields = 6 data keys). + let ciphertext = cipher.encrypt(users, "users/v1").await?; + + println!("what the stored ciphertext reveals:"); + describe(&ciphertext, 1); + + // One batched retrieve_keys round-trip, then a crypto-free structural + // decode back into the typed rows. The AAD must match the encrypt call. + let users: Vec = cipher.decrypt(ciphertext, "users/v1").await?; + + println!("\ndecrypted rows:"); + for user in &users { + println!(" {user:?}"); + } + + Ok(()) +} diff --git a/packages/stack-encrypt/examples/search_terms.rs b/packages/stack-encrypt/examples/search_terms.rs new file mode 100644 index 000000000..3655e84f8 --- /dev/null +++ b/packages/stack-encrypt/examples/search_terms.rs @@ -0,0 +1,109 @@ +//! Searchable Encrypted Metadata, leaf by leaf. +//! +//! Shows each index-term primitive on its own through the target-directed +//! `encrypt_into` API: equality terms (exact match), match terms (full-text +//! containment), and ORE/OPE terms (range queries) — all generated locally +//! from a deterministic per-keyset index key, then compared the way a server +//! would compare them: without ever seeing a plaintext. +//! +//! Run with: +//! +//! ```sh +//! cargo run -p stack-encrypt --example search_terms +//! ``` +//! +//! Uses `FakeDataKeySource`, so no ZeroKMS credentials or network are needed. + +use stack_encrypt::sem::{EqualityTerm, MatchTerm, OreTerm}; +use stack_encrypt::target::EncryptExt; +use stack_encrypt::StackCipher; +use stack_kms::FakeDataKeySource; + +#[tokio::main(flavor = "current_thread")] +async fn main() -> Result<(), Box> { + // Production: `StackCipher::new()` builds a ZeroKMS client from the + // environment. Here the fake source stands in, deriving a deterministic + // index key. Either way the cipher loads its keyset's index key once, + // during construction. + let terms = StackCipher::builder() + .kms(FakeDataKeySource::new()) + .init() + .await?; + println!("cipher ready on keyset {}", terms.keyset_id()); + + // One cipher serves write time and query time; terms are deterministic + // under the same index key + context, which is what makes them queryable. + // Deriving a term touches no data keys, so a query builder never calls + // ZeroKMS to build a probe. + + // --- Equality: exact-match lookups -------------------------------------- + // + // The context ("users/email") domain-separates terms per field: the same + // value indexed under another field can never produce a colliding term. + + let stored: EqualityTerm = "alice@example.com" + .encrypt_into(&terms, "users/email") + .await?; + + let hit: EqualityTerm = "alice@example.com" + .encrypt_into(&terms, "users/email") + .await?; + let miss: EqualityTerm = "bob@example.com" + .encrypt_into(&terms, "users/email") + .await?; + let wrong_field: EqualityTerm = "alice@example.com" + .encrypt_into(&terms, "users/name") + .await?; + + println!("\nequality:"); + println!(" same value, same field => match: {}", stored == hit); + println!(" different value => match: {}", stored == miss); + println!( + " same value, other field => match: {}", + stored == wrong_field + ); + + // --- Match: full-text containment --------------------------------------- + // + // Text is tokenized locally (3-grams by default), each token is PRF'd, and + // the outputs fold into Bloom-filter bit positions. A query matches when + // its positions are a subset of the stored term's (Bloom semantics: false + // positives possible, false negatives not). + + let bio: MatchTerm = "alice, senior cryptography engineer" + .to_string() + .encrypt_into(&terms, "users/bio") + .await?; + + for query in ["crypto", "engineer", "plumber"] { + let probe: MatchTerm = query.to_string().encrypt_into(&terms, "users/bio").await?; + println!("match: bio contains {query:?} => {}", bio.contains(&probe)); + } + println!( + " (stored term is just bit positions: {:?} ...)", + &bio.positions()[..bio.positions().len().min(8)] + ); + + // --- ORE: range queries -------------------------------------------------- + // + // CLLW ORE ciphertexts compare like their plaintexts. The per-field ORE + // key is derived *through the PRF* from the context alone — the plaintext + // never enters the PRF, so under the coming 2-party ZeroKMS PRF backend + // the key derivation becomes an auditable server event while values stay + // local. + + let age_30: OreTerm = 30u32.encrypt_into(&terms, "users/age").await?; + let age_45: OreTerm = 45u32.encrypt_into(&terms, "users/age").await?; + let query_40: OreTerm = 40u32.encrypt_into(&terms, "users/age").await?; + + println!("\nore (WHERE age > 40):"); + println!(" age 30 > 40 => {}", age_30 > query_40); + println!(" age 45 > 40 => {}", age_45 > query_40); + + // Strings order lexicographically. + let apple: OreTerm<&str> = "apple".encrypt_into(&terms, "users/name").await?; + let banana: OreTerm<&str> = "banana".encrypt_into(&terms, "users/name").await?; + println!(" \"apple\" < \"banana\" => {}", apple < banana); + + Ok(()) +} diff --git a/packages/stack-encrypt/examples/zerokms_auth.rs b/packages/stack-encrypt/examples/zerokms_auth.rs new file mode 100644 index 000000000..c82907ef3 --- /dev/null +++ b/packages/stack-encrypt/examples/zerokms_auth.rs @@ -0,0 +1,85 @@ +//! Wiring a cipher to real ZeroKMS, and to a custom authentication strategy. +//! +//! Every other example uses `FakeDataKeySource`, which needs no credentials. +//! This one shows the production path: where the credentials come from, and +//! how to substitute your own when the defaults do not fit. +//! +//! Run with: +//! +//! ```sh +//! cargo run -p stack-encrypt --example zerokms_auth +//! ``` +//! +//! Without ZeroKMS credentials in the environment it prints what it *would* +//! do and exits — so it is safe to run anywhere, and CI builds it either way. + +use stack_auth::{AuthError, AuthStrategyFn, SecretToken, ServiceToken}; +use stack_encrypt::StackCipher; +use stack_kms::{EnvKeyProvider, StackKmsBuilder}; + +#[tokio::main(flavor = "current_thread")] +async fn main() -> Result<(), Box> { + // --- The default: credentials from the environment ---------------------- + // + // `StackCipher::new()` is `StackKmsBuilder::auto()` plus the environment's + // client key, plus a keyset resolution. `auto()` detects whichever + // strategy the environment is configured for (an access key in CI, a + // device session on a developer machine). + // + // CS_WORKSPACE_CRN + CS_CLIENT_ACCESS_KEY authenticate; CS_CLIENT_ID + + // CS_CLIENT_KEY are the client key that unwraps data keys. + let cipher = match StackCipher::new().await { + Ok(cipher) => cipher, + // Nothing to connect to: say so and exit cleanly, so the example is + // safe to run anywhere. + Err(stack_encrypt::Error::Config(why)) => { + println!("not configured for ZeroKMS: {why}"); + println!("set CS_CLIENT_ID / CS_CLIENT_KEY and access-key credentials to run this."); + return Ok(()); + } + Err(other) => return Err(other.into()), + }; + println!("connected; keyset {}", cipher.keyset_id()); + + let ciphertext = cipher.encrypt("hello".to_string(), "demo/greeting").await?; + let plaintext: String = cipher.decrypt(ciphertext, "demo/greeting").await?; + assert_eq!(plaintext, "hello"); + println!("round-tripped a value under the default keyset"); + + // --- A specific keyset -------------------------------------------------- + // + // The keyset pins both halves at once: data keys are generated under it, + // and its index key derives every SEM term. They cannot diverge. + // + // StackCipher::builder() + // .keyset(IdentifiedBy::Name("customers".into())) + // .init() + // .await?; + + // --- A custom authentication strategy ----------------------------------- + // + // When the built-in strategies do not fit — tokens from your own broker, a + // sidecar, a test double — build the `StackKms` yourself and hand it to + // the builder. Any `AuthStrategy` works; `AuthStrategyFn` wraps a closure. + // + // The same seam takes `AccessKeyStrategy`, `DeviceSessionStrategy`, or an + // OIDC federation strategy when you want to name one explicitly rather + // than let `auto()` detect it. + let token = std::env::var("MY_SERVICE_TOKEN").unwrap_or_default(); + let strategy = AuthStrategyFn::new(move || { + // Your token source: a broker, a sidecar, a cached credential. Called + // whenever ZeroKMS needs a fresh token, so refresh belongs in here. + let token = token.clone(); + async move { Ok::<_, AuthError>(ServiceToken::new(SecretToken::new(token))) } + }); + + let kms = StackKmsBuilder::new(strategy) + .with_key_provider(EnvKeyProvider) + .build() + .await?; + + let _cipher = StackCipher::builder().kms(kms).init().await?; + println!("built a second cipher over a custom auth strategy"); + + Ok(()) +} diff --git a/packages/stack-encrypt/src/cipher.rs b/packages/stack-encrypt/src/cipher.rs index 09df08135..78d6b391e 100644 --- a/packages/stack-encrypt/src/cipher.rs +++ b/packages/stack-encrypt/src/cipher.rs @@ -112,6 +112,26 @@ pub enum Error { /// client key missing or malformed. #[error("could not build a ZeroKMS client from the environment: {0}")] Config(#[from] stack_kms::StackKmsBuilderError), + /// The per-field encryption context was empty. An empty context defeats + /// per-field domain separation: equal plaintexts in different fields + /// would produce identical index terms, ORE/OPE keys would be shared + /// across fields, and ciphertexts would be transplantable between them. + #[error("the encryption context must not be empty (it domain-separates fields)")] + EmptyContext, + /// An index term failed to derive. + #[error(transparent)] + Term(#[from] crate::sem::TermError), + /// A third-party [`EncryptedFrom`](crate::target::EncryptedFrom) / + /// [`DecryptedFrom`](crate::target::DecryptedFrom) implementation failed + /// for a reason of its own. + #[error(transparent)] + Other(Box), + /// A [`Pending`](crate::target::Pending) fulfilment drew more responses — + /// or a different kind of response — than its requests asked for. Always a + /// composition bug in an `EncryptedFrom`/`DecryptedFrom` implementation, + /// never a data error. + #[error("a pending fulfilment drew responses its requests never asked for")] + ResponseShape, } impl From for Error { @@ -516,18 +536,18 @@ fn collect_retrieve_payloads<'b>( /// [`bind_keys`] once the batched `retrieve_keys` call has returned; consumed by /// [`StackDecipher`], which opens it under whatever AAD the driving /// [`Decrypt`] impl supplies. -struct KeyedLeaf { +pub(crate) struct KeyedLeaf { leaf: SealedValue, key: DataKey, } /// [`StackCipherText`] with a [`DataKey`] zipped onto every keyed leaf. -type KeyedCipherText = CipherText; +pub(crate) type KeyedCipherText = CipherText; /// Zip retrieved keys onto the tree in the same depth-first order /// [`collect_retrieve_payloads`] requested them, so each leaf carries its own /// key and the subsequent [`Decipher`] drive is free of ordering assumptions. -fn bind_keys( +pub(crate) fn bind_keys( ciphertext: StackCipherText, keys: &mut impl Iterator, ) -> Result { @@ -596,7 +616,7 @@ pub enum PendingStackCipherText { impl PendingStackCipherText { /// Number of leaves that need a ZeroKMS data key (everything but /// passthrough — markers are sealed leaves too). - fn key_count(&self) -> usize { + pub(crate) fn key_count(&self) -> usize { match self { PendingStackCipherText::Single { .. } | PendingStackCipherText::None { .. } @@ -644,7 +664,7 @@ impl PendingStackCipherText { } /// Recursively seal, drawing one key per leaf from `keys` in traversal order. - fn seal_with( + pub(crate) fn seal_with( self, keys: &mut impl Iterator, ) -> Result { @@ -1002,7 +1022,7 @@ pub struct StackDecipher { } impl StackDecipher { - fn over(ciphertext: KeyedCipherText) -> Self { + pub(crate) fn over(ciphertext: KeyedCipherText) -> Self { Self { ciphertext } } diff --git a/packages/stack-encrypt/src/lib.rs b/packages/stack-encrypt/src/lib.rs index 08a45cf44..5a6f3af3e 100644 --- a/packages/stack-encrypt/src/lib.rs +++ b/packages/stack-encrypt/src/lib.rs @@ -117,11 +117,16 @@ mod cipher; pub mod sem; +pub mod target; pub use cipher::{ BoxedPassthrough, Error, FromEnv, PendingStackCipherText, SealedValue, StackCipher, StackCipherBuilder, StackCipherText, StackDecipher, }; +pub use target::{ + DecryptContext, DecryptExt, DecryptTarget, DecryptedFrom, EncryptContext, EncryptExt, + EncryptTarget, EncryptedFrom, Pending, PendingFuture, Request, Responses, +}; // Re-export the vitaminc AEAD surface callers need to drive the cipher, so they // don't have to depend on `vitaminc-aead` directly for the common path. diff --git a/packages/stack-encrypt/src/sem/mod.rs b/packages/stack-encrypt/src/sem/mod.rs index 2856c555c..527e0f52b 100644 --- a/packages/stack-encrypt/src/sem/mod.rs +++ b/packages/stack-encrypt/src/sem/mod.rs @@ -1,53 +1,68 @@ -//! Searchable Encrypted Metadata (SEM) term generation. +//! Searchable Encrypted Metadata (SEM) term types. //! -//! [`StackCipher`] produces the index terms stored alongside a +//! Index terms are stored alongside a //! [`StackCipherText`](crate::StackCipherText) so encrypted values can be -//! queried without decryption: +//! queried without decryption. Each term type implements +//! [`EncryptedFrom`], so the usual entry point is +//! target-directed: //! -//! * **Equality terms** ([`StackCipher::equality_term`]) — a PRF of the whole -//! value; supports exact-match queries. -//! * **Match terms** ([`StackCipher::match_terms`]) — the value is tokenized -//! locally, each token is PRF'd, and the outputs fold into Bloom-filter bit -//! positions; supports full-text match queries. -//! * **ORE / OPE terms** ([`StackCipher::ore_term`] / -//! [`StackCipher::ope_term`]) — CLLW order-revealing / order-preserving -//! ciphertexts produced *inside the PRF visitor* from a per-descriptor key -//! derived through the PRF; support range queries. +//! ```text +//! let term: EqualityTerm = value.encrypt_into(&cipher, "users/email").await?; +//! ``` +//! +//! * [`EqualityTerm`] — a PRF of the whole value; exact-match queries. +//! * [`MatchTerm`] — the value is tokenized locally, each token is PRF'd, and +//! the outputs fold into Bloom-filter bit positions; full-text match +//! queries. Tokenizer/filter parameters are a *type-level* config +//! ([`MatchConfig`]) so write-time and query-time terms agree by +//! construction. +//! * [`OreTerm`] / [`OpeTerm`] — CLLW order-revealing / order-preserving +//! ciphertexts produced *inside the PRF visitor* from a per-context key +//! derived through the PRF; range queries. +//! +//! Every term type here is built on exactly one thing the cipher exposes +//! publicly — its PRF ([`StackCipher::prf`]) — with no privileged access, so +//! they double as worked examples for defining your own term types in another +//! crate (see [`target`](crate::target#extending-with-your-own-sem-type)). +//! +//! Alongside the target-directed path, the cipher carries descriptor-string +//! methods ([`StackCipher::equality_term`] and friends) for call sites that +//! want a single term rather than a whole record — query builders, mostly. +//! They derive no data keys, so building a probe never calls ZeroKMS. //! //! # PRF backends, visitors, and the 2-party future //! //! The PRF backend produces **blocks**; a [`PrfVisitor`] shapes blocks into //! the term (`EqualityVisitor`, `BloomVisitor`, `OreVisitor`, `OpeVisitor` — -//! all private). The shaping is pure and synchronous by construction: only -//! the block production can involve I/O, so a visitor never knows which side -//! of a round-trip it runs on. All pure work — option validation, -//! tokenization, context framing — happens *before* the PRF is invoked. +//! all private). The shaping is pure and synchronous by construction: only block +//! production can involve I/O, so a visitor never knows which side of a +//! round-trip it runs on. All pure work — context validation, option +//! validation, tokenization — happens *before* the PRF is invoked. //! -//! Every term method awaits its PRF output ([`Prf::Ok`](vitaminc_prf::Prf::Ok) -//! is `IntoFuture`). The backend today is the local -//! [`HmacSha256Prf`](vitaminc_hmac::HmacSha256Prf), keyed by the deterministic -//! per-keyset [`IndexKey`](stack_kms::IndexKey) the cipher loads during -//! construction, so outputs are immediately ready. The next ZeroKMS release -//! adds 2-party PRF generation: that backend returns deferred outputs resolved -//! by a server round-trip, and it slots in without touching any shaping code — -//! the same visitor runs over the blocks the server returns, and every -//! derivation is already behind an `await`. -//! -//! This is also why ORE/OPE *keys* are derived through the PRF (from the field -//! descriptor, never the plaintext): under a 2-party backend, per-field key -//! derivation becomes a visible, auditable ZeroKMS event while plaintext stays -//! local. +//! The backend today is the local +//! [`HmacSha256Prf`] — keyed by the +//! deterministic per-keyset [`IndexKey`](stack_kms::IndexKey) from +//! [`stack_kms::IndexKeySource`] — so every derivation completes with no I/O +//! and an [`EncryptedFrom`] term carries **no requests** in its +//! [`Pending`]. The next ZeroKMS release adds 2-party PRF generation; under +//! that backend a term's `encrypt_from` pushes a PRF *request* instead and +//! runs the **same visitor** over the blocks the server returns — the shaping +//! code does not change, and terms then share the one batched ZeroKMS call +//! with the record's data keys. This is also why ORE/OPE *keys* are derived +//! through the PRF (from the field context, never the plaintext): under a +//! 2-party backend, per-field key derivation becomes a visible, auditable +//! ZeroKMS event while plaintext stays local. //! //! # Determinism and domain separation //! //! Index terms are deterministic by design — the same value under the same -//! descriptor always yields the same term, which is what makes them queryable +//! context always yields the same term, which is what makes them queryable //! (and is the usual SEM leakage trade-off: equal values are visibly equal). //! Every term kind derives under its own PAE-encoded domain, bound to the -//! caller's field `descriptor`, so the same value indexed as an equality term, -//! a match token, or an ORE key can never produce colliding PRF outputs. +//! caller's context, so the same value indexed as an equality term, a match +//! token, or an ORE key can never produce colliding PRF outputs. //! -//! This is a fresh term format: PRF inputs are framed with vitaminc's PAE +//! This is a fresh (v2) term format: PRF inputs are framed with vitaminc's PAE //! context encoding, so terms are intentionally **not** byte-compatible with //! `cipherstash-client`'s existing `IndexTerm` values. @@ -55,13 +70,25 @@ mod tokenize; pub use tokenize::Tokenizer; +use std::fmt; +use std::marker::PhantomData; + use cllw_ore::{CllwOpeEncrypt, CllwOreEncrypt}; +use vitaminc_hmac::HmacSha256Prf; use vitaminc_prf::{ - BlockVisitor, MapAccess, PrfContext, PrfError, PrfValue, PrfVisitor, PrfVisitorError, SeqAccess, + BlockVisitor, IntoPrfContext, MapAccess, PrfContext, PrfError, PrfValue, PrfVisitor, + PrfVisitorError, SeqAccess, }; use zeroize::Zeroize; -use crate::StackCipher; +use crate::target::{EncryptContext, EncryptedFrom, Pending}; +use crate::{Error, StackCipher}; + +// The `/v1` suffix versions the *derivation* (domain + input framing), not the +// crate. Any change to the bytes a term derives from must bump it: a changed +// derivation under an unchanged domain makes every existing term silently +// unfindable, with no error to notice. The byte-level pins in +// `tests/term_bytes.rs` are what force that bump to be deliberate. /// PAE domain for equality (exact-match) terms. const EQUALITY_DOMAIN: &[u8] = b"stack-encrypt/sem/equality/v1"; @@ -96,6 +123,13 @@ pub enum TermError { "text produces no match tokens (empty, separator-only, or shorter than the n-gram length)" )] EmptyTermText, + /// The encryption context (field descriptor) was empty. An empty context + /// defeats per-field domain separation: equal plaintexts in different + /// fields would produce identical terms, and every field would share one + /// ORE/OPE key. See + /// [`EncryptContext`]. + #[error("the encryption context must not be empty (it domain-separates fields)")] + EmptyContext, } impl TermError { @@ -107,6 +141,28 @@ impl TermError { } } +/// Reject an empty context before any derivation — see +/// [`TermError::EmptyContext`]. +/// +/// `()` (and `PrfContext::empty()`) produce literally empty context bytes; an +/// empty string or empty byte-slice context produces vitaminc's *typed* +/// framing around an empty payload. All are degenerate the same way — every +/// field using one shares a single derivation domain — so all are rejected. +fn require_context(context: &PrfContext<'_>) -> Result<(), TermError> { + let bytes = context.as_bytes(); + if bytes.is_empty() + || bytes == "".into_prf_context().as_bytes() + || bytes == b"".as_slice().into_prf_context().as_bytes() + { + return Err(TermError::EmptyContext); + } + Ok(()) +} + +// ============================================================================= +// Equality +// ============================================================================= + /// An equality (exact-match) index term: one PRF block over the whole value. /// /// Terms are pseudorandom under the index key; they are stored server-side and @@ -115,6 +171,12 @@ impl TermError { pub struct EqualityTerm([u8; 32]); impl EqualityTerm { + /// Rebuild a term from stored bytes — the inverse of + /// [`into_bytes`](Self::into_bytes), for terms persisted server-side. + pub fn from_bytes(bytes: [u8; 32]) -> Self { + Self(bytes) + } + pub fn as_bytes(&self) -> &[u8; 32] { &self.0 } @@ -141,38 +203,50 @@ impl PrfVisitor<[u8; 32], P> for EqualityVisitor { } } -/// A match (full-text) index term: the set bit positions of a Bloom filter over -/// the PRF outputs of the value's tokens. Positions are sorted and de-duplicated. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct MatchTerm { - positions: Vec, +/// Derive an equality term. Synchronous: the local HMAC backend does no I/O, +/// and the visitor does all the shaping (see the module docs — under a +/// deferred backend the same visitor runs after the round-trip instead). +fn equality( + prf: HmacSha256Prf, + value: T, + context: PrfContext<'_>, +) -> Result +where + T: PrfValue, +{ + require_context(&context)?; + let context = PrfContext::pae(&[EQUALITY_DOMAIN, context.as_bytes()]); + value + .prf_visit_with_context(prf, context, EqualityVisitor) + .into_result() + .map_err(TermError::from_prf) } -impl MatchTerm { - /// The set Bloom-filter bit positions, sorted ascending, no duplicates. - pub fn positions(&self) -> &[u16] { - &self.positions - } - - /// Whether this term's positions are a superset of `query`'s — the Bloom - /// containment check used to evaluate a match query (with the usual Bloom - /// false-positive rate; a query that generates tokens can never produce a - /// false negative). - /// - /// An empty `query` returns `false`: containment of zero positions is - /// vacuously true, which would turn an empty probe into a match-every-row - /// query. [`StackCipher::match_terms`] already refuses to build such a - /// term ([`TermError::EmptyTermText`]); this guards any other - /// (e.g. deserialized) source of an empty term. - pub fn contains(&self, query: &MatchTerm) -> bool { - !query.positions.is_empty() - && query - .positions - .iter() - .all(|p| self.positions.binary_search(p).is_ok()) +/// An equality term of any [`PrfValue`] source. Derived locally during the +/// synchronous build — the returned [`Pending`] carries no requests. +impl EncryptedFrom> for EqualityTerm +where + S: PrfValue + Clone, +{ + fn encrypt_from<'a, 'c, Ctx>( + source: &'a S, + cipher: &'a StackCipher, + context: Ctx, + ) -> Pending<'a, Self, K> + where + Ctx: EncryptContext<'c>, + Self: 'a, + { + let context = context.into_prf_context().into_owned(); + let term = equality(cipher.prf().clone(), source.clone(), context).map_err(Error::from); + Pending::ready(cipher, term) } } +// ============================================================================= +// Match +// ============================================================================= + /// Options controlling match-term generation. The defaults mirror the existing /// match indexer: 3-gram tokens, downcased, `k = 3` hash slices into an /// `m = 256`-bit filter. @@ -225,6 +299,99 @@ impl MatchOptions { } } +/// Type-level match configuration: the [`MatchOptions`] a [`MatchTerm`] +/// is generated with. Putting the configuration on the *type* means a record +/// field and the query probing it agree on tokenizer and filter parameters by +/// construction. Define your own by implementing this on a marker type. +pub trait MatchConfig: Send + Sync + 'static { + fn options() -> MatchOptions; +} + +/// The default [`MatchConfig`]: [`MatchOptions::default`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)] +pub struct DefaultMatch; + +impl MatchConfig for DefaultMatch { + fn options() -> MatchOptions { + MatchOptions::default() + } +} + +/// A match (full-text) index term: the set bit positions of a Bloom filter over +/// the PRF outputs of the value's tokens, generated under the [`MatchConfig`] +/// `O`. Positions are sorted and de-duplicated. +pub struct MatchTerm { + positions: Vec, + _config: PhantomData O>, +} + +impl MatchTerm { + /// Rebuild a term from stored positions — the inverse of + /// [`positions`](Self::positions) / + /// [`into_positions`](Self::into_positions), for terms persisted + /// server-side. Sorts and de-duplicates, so any ordering is accepted; + /// the caller asserts (via `O`) that the positions were generated under + /// the same [`MatchConfig`]. + pub fn from_positions(mut positions: Vec) -> Self { + positions.sort_unstable(); + positions.dedup(); + Self { + positions, + _config: PhantomData, + } + } + + /// The set Bloom-filter bit positions, sorted ascending, no duplicates. + pub fn positions(&self) -> &[u16] { + &self.positions + } + + /// Unwrap into the stored positions. + pub fn into_positions(self) -> Vec { + self.positions + } + + /// Whether this term's positions are a superset of `query`'s — the Bloom + /// containment check used to evaluate a match query (with the usual Bloom + /// false-positive rate; a query that generates tokens can never produce a + /// false negative). + /// + /// An empty `query` returns `false`: containment of zero positions is + /// vacuously true, which would turn an empty probe into a match-every-row + /// query. Term generation already refuses to build such a term + /// ([`TermError::EmptyTermText`]); this guards any other + /// (e.g. deserialized) source of an empty term. + pub fn contains(&self, query: &MatchTerm) -> bool { + !query.positions.is_empty() + && query + .positions + .iter() + .all(|p| self.positions.binary_search(p).is_ok()) + } +} + +impl fmt::Debug for MatchTerm { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("MatchTerm") + .field("positions", &self.positions) + .finish() + } +} + +impl Clone for MatchTerm { + fn clone(&self) -> Self { + Self::from_positions(self.positions.clone()) + } +} + +impl PartialEq for MatchTerm { + fn eq(&self, other: &Self) -> bool { + self.positions == other.positions + } +} + +impl Eq for MatchTerm {} + /// A [`PrfVisitor`] that folds a sequence of per-token PRF blocks into /// Bloom-filter bit positions: `k` little-endian 2-byte slices of each block, /// masked to the filter size. @@ -241,7 +408,7 @@ struct BloomVisitor { } impl PrfVisitor<[u8; 32], P> for BloomVisitor { - type Value = MatchTerm; + type Value = Vec; fn visit_seq(self, seq: SeqAccess<[u8; 32], P>) -> Result { // One node per token, in token order. Each node is the PRF output for @@ -263,7 +430,7 @@ impl PrfVisitor<[u8; 32], P> for BloomVisitor { // no information, and sorting makes `contains` a binary search. positions.sort_unstable(); positions.dedup(); - Ok(MatchTerm { positions }) + Ok(positions) } // A map-shaped input is not a token stream. @@ -272,6 +439,141 @@ impl PrfVisitor<[u8; 32], P> for BloomVisitor { } } +/// Derive a match term. Synchronous — see [`equality`]: validation and +/// tokenization run before the PRF, the visitor folds blocks into positions. +fn match_term( + prf: HmacSha256Prf, + text: &str, + context: PrfContext<'_>, + options: MatchOptions, +) -> Result, TermError> { + require_context(&context)?; + let mask = options.validate()?; + let tokens = tokenize::tokenize(text, options.tokenizer, options.downcase); + if tokens.is_empty() { + return Err(TermError::EmptyTermText); + } + let context = PrfContext::pae(&[MATCH_DOMAIN, context.as_bytes()]); + + let positions = tokens + .prf_visit_with_context(prf, context, BloomVisitor { k: options.k, mask }) + .into_result() + .map_err(TermError::from_prf)?; + Ok(MatchTerm::from_positions(positions)) +} + +/// A match term of any text source, generated under `O`'s options. Derived +/// locally during the synchronous build — the returned [`Pending`] carries no +/// requests (tokenize makes the one necessary copy of the text). +impl EncryptedFrom> for MatchTerm +where + S: AsRef, + O: MatchConfig, +{ + fn encrypt_from<'a, 'c, Ctx>( + source: &'a S, + cipher: &'a StackCipher, + context: Ctx, + ) -> Pending<'a, Self, K> + where + Ctx: EncryptContext<'c>, + Self: 'a, + { + let context = context.into_prf_context().into_owned(); + let term = match_term(cipher.prf().clone(), source.as_ref(), context, O::options()) + .map_err(Error::from); + Pending::ready(cipher, term) + } +} + +// ============================================================================= +// ORE / OPE +// ============================================================================= + +/// An order-revealing (CLLW ORE) index term for a source value of type `T`. +/// Wraps the CLLW ciphertext ([`CllwOreEncrypt::Output`]); comparisons order +/// like the plaintexts. +/// +/// The type parameter is the *source* type: `OreTerm` in a record type +/// declares "this field is the ORE term of a `u64`". +pub struct OreTerm(T::Output); + +/// An order-preserving (CLLW OPE) index term for a source value of type `T` +/// (see [`OreTerm`]). The wrapped ciphertext compares with plain +/// lexicographic byte order. +pub struct OpeTerm(T::Output); + +macro_rules! term_wrapper { + ($name:ident, $bound:ident) => { + impl $name { + /// Wrap an already-generated CLLW ciphertext. + pub fn new(output: T::Output) -> Self { + Self(output) + } + + /// The wrapped CLLW ciphertext. + pub fn inner(&self) -> &T::Output { + &self.0 + } + + /// Unwrap into the CLLW ciphertext. + pub fn into_inner(self) -> T::Output { + self.0 + } + } + + impl fmt::Debug for $name + where + T::Output: fmt::Debug, + { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_tuple(stringify!($name)).field(&self.0).finish() + } + } + + impl Clone for $name + where + T::Output: Clone, + { + fn clone(&self) -> Self { + Self(self.0.clone()) + } + } + + impl PartialEq for $name + where + T::Output: PartialEq, + { + fn eq(&self, other: &Self) -> bool { + self.0 == other.0 + } + } + + impl Eq for $name where T::Output: Eq {} + + impl PartialOrd for $name + where + T::Output: PartialOrd, + { + fn partial_cmp(&self, other: &Self) -> Option { + self.0.partial_cmp(&other.0) + } + } + + impl Ord for $name + where + T::Output: Ord, + { + fn cmp(&self, other: &Self) -> std::cmp::Ordering { + self.0.cmp(&other.0) + } + } + }; +} + +term_wrapper!(OreTerm, CllwOreEncrypt); +term_wrapper!(OpeTerm, CllwOpeEncrypt); + /// A [`PrfVisitor`] that carries the plaintext in and hands the CLLW ORE /// ciphertext out. The PRF block becomes the CLLW [`Key`](cllw_ore::Key) /// *inside* `visit_block` and dies there: the block arrives by value and is @@ -281,7 +583,7 @@ impl PrfVisitor<[u8; 32], P> for BloomVisitor { /// the caller. /// /// This is the shape a 2-party PRF needs: the caller supplies a PRF input -/// (the descriptor) and receives a term, and where the key comes from — or +/// (the context) and receives a term, and where the key comes from — or /// whether one exists at all — is the visitor's business. Swapping the /// backend for one that returns per-prefix PRF outputs instead of a key /// changes this visitor, not its callers. @@ -326,17 +628,115 @@ where } } -/// Term generation on the cipher itself: every [`StackCipher`] carries the PRF -/// keyed by its keyset's index key, so the same handle that seals a value -/// derives the terms stored beside it, and a query builder holding a cipher -/// derives probe terms with no data-key traffic at all. +/// Derive an ORE term: PRF of the encoded context under a PAE-encoded +/// `[domain, context]` framing — the same framing every other term kind uses +/// (see the module docs), so no reimplementation of this derivation can +/// collide with an equality or match derivation. Only the context enters the +/// PRF — never the plaintext, which rides in the visitor and is encrypted +/// there. Deterministic, so write-time and query-time terms agree; under a +/// 2-party PRF backend this derivation is a visible ZeroKMS event. +fn ore(prf: HmacSha256Prf, value: T, context: PrfContext<'_>) -> Result, TermError> +where + T: CllwOreEncrypt + Send + 'static, + T::Output: Send + 'static, +{ + require_context(&context)?; + let context_bytes = context.as_bytes(); + context_bytes + .prf_visit_with_context( + prf, + PrfContext::pae(&[ORE_KEY_DOMAIN, context_bytes]), + OreVisitor(value), + ) + .into_result() + .map_err(TermError::from_prf)? + .map(OreTerm) + .map_err(TermError::Ore) +} + +/// Derive an OPE term — as [`ore`], under the OPE domain so the two schemes +/// never share a key. +fn ope(prf: HmacSha256Prf, value: T, context: PrfContext<'_>) -> Result, TermError> +where + T: CllwOpeEncrypt + Send + 'static, + T::Output: Send + 'static, +{ + require_context(&context)?; + let context_bytes = context.as_bytes(); + context_bytes + .prf_visit_with_context( + prf, + PrfContext::pae(&[OPE_KEY_DOMAIN, context_bytes]), + OpeVisitor(value), + ) + .into_result() + .map_err(TermError::from_prf)? + .map(OpeTerm) + .map_err(TermError::Ore) +} + +/// An ORE term of any [`CllwOreEncrypt`] source. Derived locally during the +/// synchronous build — the returned [`Pending`] carries no requests. +impl EncryptedFrom> for OreTerm +where + S: CllwOreEncrypt + Clone + Send + 'static, + S::Output: Send + 'static, +{ + fn encrypt_from<'a, 'c, Ctx>( + source: &'a S, + cipher: &'a StackCipher, + context: Ctx, + ) -> Pending<'a, Self, K> + where + Ctx: EncryptContext<'c>, + Self: 'a, + { + let context = context.into_prf_context().into_owned(); + let term = ore(cipher.prf().clone(), source.clone(), context).map_err(Error::from); + Pending::ready(cipher, term) + } +} + +/// An OPE term of any [`CllwOpeEncrypt`] source. Derived locally during the +/// synchronous build — the returned [`Pending`] carries no requests. +impl EncryptedFrom> for OpeTerm +where + S: CllwOpeEncrypt + Clone + Send + 'static, + S::Output: Send + 'static, +{ + fn encrypt_from<'a, 'c, Ctx>( + source: &'a S, + cipher: &'a StackCipher, + context: Ctx, + ) -> Pending<'a, Self, K> + where + Ctx: EncryptContext<'c>, + Self: 'a, + { + let context = context.into_prf_context().into_owned(); + let term = ope(cipher.prf().clone(), source.clone(), context).map_err(Error::from); + Pending::ready(cipher, term) + } +} + +// ============================================================================= +// Term generation on the cipher +// ============================================================================= + +/// Descriptor-string term generation, for call sites that want one term rather +/// than a whole record: query builders probing an index, re-indexers, tests of +/// a single scheme. /// -/// Terms are deterministic: the same value and descriptor yield the same term -/// at write time and at query time. They are pseudorandom under the index key -/// and are stored server-side — they are not secret key material. +/// Every [`StackCipher`] carries the PRF keyed by its keyset's index key, so +/// these need no data-key traffic at all — a query builder holding a cipher +/// never touches ZeroKMS to build a probe. Each is byte-identical to the +/// target-directed path for the same descriptor, so a term generated here +/// compares against one generated by `encrypt_into`. impl StackCipher { /// Generate an equality (exact-match) term for `value` under the field - /// `descriptor`. + /// `descriptor`. Deterministic: the same value + descriptor always yields + /// the same term, at write time and at query time. Byte-identical to + /// `value.encrypt_into::(&cipher, descriptor)`. pub async fn equality_term( &self, value: T, @@ -345,92 +745,68 @@ impl StackCipher { where T: PrfValue, { - let context = PrfContext::pae(&[EQUALITY_DOMAIN, descriptor.as_bytes()]); - value - .prf_visit_with_context(self.prf().clone(), context, EqualityVisitor) - .await - .map_err(TermError::from_prf) + equality(self.prf().clone(), value, descriptor.into_prf_context()) } /// Generate a match (full-text) term for `text` under the field - /// `descriptor`: tokenize locally, PRF each token, fold the outputs into - /// Bloom-filter bit positions. + /// `descriptor` and the type-level config `O`: tokenize locally, PRF each + /// token, fold the outputs into Bloom-filter bit positions. + /// + /// The config is a *type* parameter (not runtime options) so write-time + /// and query-time terms agree by construction — a term generated under + /// one config cannot be compared against a term generated under another, + /// which would otherwise silently return false negatives. Byte-identical + /// to the target-directed path for the same `O`. For custom options, + /// define a marker type implementing [`MatchConfig`]. /// /// The same call serves both write time (index the stored text) and query - /// time (index the probe text, then test - /// [`MatchTerm::contains`] server-side). + /// time (index the probe text, then test [`MatchTerm::contains`] + /// server-side). /// /// Returns [`TermError::EmptyTermText`] when the text yields no tokens — /// empty or separator-only text, or an n-gram probe shorter than the gram /// length (which could never match; see [`Tokenizer::Ngram`]). - pub async fn match_terms( + pub async fn match_terms( &self, text: &str, descriptor: &str, - options: &MatchOptions, - ) -> Result { - let mask = options.validate()?; - let tokens = tokenize::tokenize(text, options.tokenizer, options.downcase); - if tokens.is_empty() { - return Err(TermError::EmptyTermText); - } - let context = PrfContext::pae(&[MATCH_DOMAIN, descriptor.as_bytes()]); - - tokens - .prf_visit_with_context( - self.prf().clone(), - context, - BloomVisitor { k: options.k, mask }, - ) - .await - .map_err(TermError::from_prf) + ) -> Result, TermError> { + match_term( + self.prf().clone(), + text, + descriptor.into_prf_context(), + O::options(), + ) } /// Generate an order-revealing (CLLW ORE) term for a range-queryable value /// under the field `descriptor`. The PRF input is the descriptor alone — /// the plaintext never enters the PRF; it travels in the visitor, which /// derives the per-descriptor CLLW key from the PRF block and encrypts - /// under it in one step (`OreVisitor`). The key never leaves the - /// visitor. - /// - /// The derivation is deterministic, so write-time and query-time terms - /// agree; under a 2-party PRF backend it is a visible ZeroKMS event. The - /// PRF context is PAE-encoded `[domain, descriptor]` — the same framing - /// every other term kind uses (see the module docs), so no reimplementation - /// of this derivation can collide with an equality or match derivation. + /// under it in one step. The key never leaves the visitor. /// /// Supported inputs: `u16`/`u32`/`u64`/`u128`, `&'static str`, `String`, /// `Vec` (via [`CllwOreEncrypt`]). The value must be owned /// (`'static`) because the visitor carries it; pass a `String` for - /// borrowed text. + /// borrowed text. Returns the raw CLLW ciphertext; the target-directed + /// path wraps the same bytes in [`OreTerm`]. pub async fn ore_term(&self, value: T, descriptor: &str) -> Result where T: CllwOreEncrypt + Send + 'static, T::Output: Send + 'static, { - let context = PrfContext::pae(&[ORE_KEY_DOMAIN, descriptor.as_bytes()]); - descriptor - .prf_visit_with_context(self.prf().clone(), context, OreVisitor(value)) - .await - .map_err(TermError::from_prf)? - .map_err(TermError::Ore) + ore(self.prf().clone(), value, descriptor.into_prf_context()).map(OreTerm::into_inner) } /// Generate an order-preserving (CLLW OPE) term: ciphertexts compare with /// plain lexicographic byte order, no custom comparator required. /// Encrypt-only — pair with the record ciphertext for round-trips. Key - /// handling and input bounds as for [`ore_term`](Self::ore_term); the OPE - /// key derives under its own domain so the two schemes never share one. + /// handling and input bounds as for [`ore_term`](Self::ore_term). pub async fn ope_term(&self, value: T, descriptor: &str) -> Result where T: CllwOpeEncrypt + Send + 'static, T::Output: Send + 'static, { - let context = PrfContext::pae(&[OPE_KEY_DOMAIN, descriptor.as_bytes()]); - descriptor - .prf_visit_with_context(self.prf().clone(), context, OpeVisitor(value)) - .await - .map_err(TermError::from_prf)? - .map_err(TermError::Ore) + ope(self.prf().clone(), value, descriptor.into_prf_context()).map(OpeTerm::into_inner) } } diff --git a/packages/stack-encrypt/src/target.rs b/packages/stack-encrypt/src/target.rs new file mode 100644 index 000000000..2c325b92b --- /dev/null +++ b/packages/stack-encrypt/src/target.rs @@ -0,0 +1,881 @@ +//! Target-directed encryption: the *output* type decides what gets derived, +//! and the *cipher* decides the async shape. +//! +//! A stored encrypted value is rarely just a ciphertext — it is a record: the +//! AEAD ciphertext of the plaintext plus zero or more index terms derived from +//! the same plaintext by different primitives. [`EncryptedFrom`] puts that +//! record shape in charge: +//! +//! ```text +//! let term: EqualityTerm = value.encrypt_into(&cipher, "users/email").await?; +//! ``` +//! +//! compiles only when `EqualityTerm` declares itself an encrypted form of the +//! value's type, producible by that cipher. +//! +//! # The pieces +//! +//! * [`EncryptedFrom`] — implemented by an *output* type: "`Self` is an +//! encrypted representation of `S`, producible by a cipher `C`". Leaf +//! implementations exist for [`StackCipherText`] (the AEAD ciphertext, via +//! vitaminc's [`Encrypt`]) and for the SEM term types in [`sem`] +//! ([`EqualityTerm`], [`MatchTerm`], [`OreTerm`], [`OpeTerm`]). Composite +//! record types implement it by combining their fields' pendings with +//! [`Pending::zip`] / [`Pending::map`]. +//! * [`DecryptedFrom`] — the mirror, implemented by the *plaintext* +//! type: "`Self` is recoverable from the encrypted `S`". Only ciphertext +//! fields participate — index terms are one-way by construction. +//! * [`EncryptExt::encrypt_into`] / [`DecryptExt::decrypt_into`] — blanket +//! call-site sugar, the `Into` to the `From` above. Never implemented by +//! hand. +//! * [`EncryptTarget`] / [`DecryptTarget`] — implemented by ciphers; their +//! `Output` type decides what a call site gets back. A synchronous cipher +//! returns `Result` directly; [`StackCipher`] returns a [`Pending`], +//! which does its ZeroKMS I/O — **one batched call** — when awaited. +//! * [`EncryptContext`] — one context value per field, convertible to both an +//! AEAD [`Aad`](vitaminc_aead::Aad) and a +//! [`PrfContext`](vitaminc_prf::PrfContext), so the same identifier that +//! domain-separates the index terms also *authenticates* the ciphertext. +//! `&str` and `String` qualify. The context must be **non-empty**. +//! +//! # Build synchronously, settle once +//! +//! `encrypt_from` does **no I/O**. It validates, derives every local term, +//! drives vitaminc's [`Encrypt`] to a pending ciphertext tree, and returns a +//! [`Pending`] carrying the ZeroKMS *requests* the value needs. Combining +//! pendings ([`Pending::zip`], [`Pending::all`]) merges their requests, so +//! however large the assembly — one field, one record, a whole column — the +//! `.await` at the end settles it with **one** ZeroKMS round-trip per request +//! kind: +//! +//! ``` +//! use stack_encrypt::target::{DecryptExt, EncryptExt}; +//! use stack_encrypt::{StackCipher, StackCipherText}; +//! use stack_kms::FakeDataKeySource; +//! +//! # tokio::runtime::Builder::new_current_thread().enable_all().build().unwrap().block_on(async { +//! let cipher = StackCipher::builder() +//! .kms(FakeDataKeySource::new()) +//! .init() +//! .await +//! .unwrap(); +//! +//! let ages: Vec = vec![29, 34, 41]; +//! +//! // A column of independently sealed ciphertexts: ONE generate_keys call. +//! let sealed: Vec = ages.encrypt_into(&cipher, "users/age").await?; +//! +//! // And back: ONE retrieve_keys call for the whole column. +//! let roundtrip: Vec = sealed.decrypt_into(&cipher, "users/age").await?; +//! assert_eq!(roundtrip, ages); +//! # Ok::<(), stack_encrypt::Error>(()) +//! # }).unwrap(); +//! ``` +//! +//! Batching therefore comes from the **source shape**, exactly as it does for +//! vitaminc's `Encrypt`: pass the collection, not the element. A caller who +//! awaits per element pays per element. +//! +//! Note the two distinct shapes: `Vec → StackCipherText` (via `Encrypt`) +//! is *one* record whose value is a list — one tree, elements sealed under +//! sequence-derived AAD. `Vec → Vec` (above) is a +//! *column* of independent records sharing one context. Both are one ZeroKMS +//! call; they differ in what the ciphertext *is*. +//! +//! # Extending with your own SEM type +//! +//! The set of term types is open. Any crate can define one: implement +//! [`EncryptedFrom`] for it against [`StackCipher`], build the result with +//! [`Pending::ready`] (local derivation) or [`Pending::request`] (derivation +//! needing ZeroKMS responses). Every built-in term type is implemented with +//! **exactly** this recipe — they use no privileged access — so [`sem`] +//! doubles as worked examples. +//! +//! The one thing every searchable-encryption scheme needs is a keyed, +//! deterministic derivation — the cipher's PRF. Under the local HMAC backend +//! the PRF completes synchronously (`into_result`), so the pending carries no +//! requests; a future 2-party ZeroKMS PRF backend moves the same visitor +//! behind a PRF request instead, joining the record's one batched call. +//! +//! ``` +//! use stack_encrypt::target::{EncryptContext, EncryptedFrom, Pending}; +//! use stack_encrypt::{Error, StackCipher}; +//! use vitaminc_prf::{IntoPrfContext, PrfContext, PrfValue, PrfVisitor, PrfVisitorError}; +//! +//! /// A third-party term type: one PRF block under its own domain. +//! pub struct MyTerm([u8; 32]); +//! +//! struct MyVisitor; +//! +//! impl PrfVisitor<[u8; 32], P> for MyVisitor { +//! type Value = MyTerm; +//! +//! fn visit_block(self, block: [u8; 32]) -> Result { +//! Ok(MyTerm(block)) +//! } +//! } +//! +//! impl EncryptedFrom> for MyTerm +//! where +//! S: PrfValue + Clone, +//! { +//! fn encrypt_from<'a, 'c, Ctx>( +//! source: &'a S, +//! cipher: &'a StackCipher, +//! context: Ctx, +//! ) -> Pending<'a, Self, K> +//! where +//! Ctx: EncryptContext<'c>, +//! Self: 'a, +//! { +//! // Domain-separate under your own label so your terms can never +//! // collide with another scheme's under the same context. +//! let context = context.into_prf_context().into_owned(); +//! let context = PrfContext::pae(&[b"my-crate/my-term/v1".as_slice(), context.as_bytes()]); +//! let term = source +//! .clone() +//! .prf_visit_with_context(cipher.prf().clone(), context, MyVisitor) +//! .into_result() +//! .map_err(|e| Error::Other(Box::new(e))); +//! Pending::ready(cipher, term) +//! } +//! } +//! ``` +//! +//! A scheme needing state the cipher does not carry defines its own +//! capability trait and implements it for [`StackCipher`] (a local trait on a +//! foreign type is orphan-rule-legal) using its public accessors +//! ([`keyset_id`](StackCipher::keyset_id), [`prf`](StackCipher::prf), +//! [`kms`](StackCipher::kms)). +//! +//! **Not yet here:** the `ore_rs` *block* ORE scheme (`OreBlock256`) that EQL +//! and `cipherstash-client` use. The ORE/OPE terms in [`sem`] are CLLW, a +//! different construction; the block scheme lands as a third-party term type +//! in `eql-bindings`, built with exactly the recipe above. +//! +//! # Plaintext fan-out +//! +//! One source value reaches every field implementation, so encrypting a +//! record clones the plaintext once per derived field. Term clones are +//! consumed during the synchronous build; the ciphertext's copy lives inside +//! the pending (wrapped in `Protected`, wiped as it seals). This widens the +//! plaintext custody window by design; keep it in mind for high-sensitivity +//! values. +//! +//! [`sem`]: crate::sem +//! [`EqualityTerm`]: crate::sem::EqualityTerm +//! [`MatchTerm`]: crate::sem::MatchTerm +//! [`OreTerm`]: crate::sem::OreTerm +//! [`OpeTerm`]: crate::sem::OpeTerm + +use std::borrow::Cow; +use std::collections::VecDeque; +use std::future::{Future, IntoFuture}; +use std::pin::Pin; + +use stack_kms::{ + DataKey, DataKeySource, DataKeyWithTag, GenerateKeyPayload, Iv, MaybeSend, RetrieveKeyPayload, +}; +use vitaminc_aead::{CipherText, Decrypt, Encrypt, IntoAad}; +use vitaminc_prf::IntoPrfContext; + +use crate::cipher::{bind_keys, StackDecipher}; +use crate::{Error, StackCipher, StackCipherText}; + +// ============================================================================= +// Contexts +// ============================================================================= + +/// Per-field encryption context: one value that domain-separates every +/// primitive a record field can use — it becomes the AEAD associated data of +/// the ciphertext *and* the PRF context of any index term. +/// +/// Blanket-implemented; never implement it directly. `&str` and `String` +/// qualify. `Clone` is required because one context fans out to every field +/// of a record. +/// +/// The context must be **non-empty**: with an empty context, equal plaintexts +/// in different fields produce identical index terms (cross-field equality +/// leakage), every field shares one ORE/OPE key (values become mutually +/// order-comparable), and ciphertexts become transplantable between fields. +/// Every built-in implementation rejects an empty context during the +/// synchronous build — before any I/O. +pub trait EncryptContext<'a>: IntoAad<'a> + IntoPrfContext<'a> + Clone {} + +impl<'a, T> EncryptContext<'a> for T where T: IntoAad<'a> + IntoPrfContext<'a> + Clone {} + +/// Per-field decryption context: must convert to the AEAD associated data the +/// value was encrypted under. Decryption derives nothing, so no PRF bound. +/// Blanket-implemented; `&str`, `String` and [`Aad`](vitaminc_aead::Aad) +/// qualify. +pub trait DecryptContext<'a>: IntoAad<'a> + Clone {} + +impl<'a, T> DecryptContext<'a> for T where T: IntoAad<'a> + Clone {} + +// ============================================================================= +// Cipher-owned output types +// ============================================================================= + +/// Implemented by ciphers: decides what an [`EncryptedFrom`] implementation +/// hands back. A cipher that does no I/O sets +/// `Output<'a, T> = Result` — no future, no `.await`. +/// [`StackCipher`] sets `Output<'a, T> = Pending<'a, T, K>`, a request +/// carrier that talks to ZeroKMS when awaited. +/// +/// This mirrors `Cipher::Ok` and `Prf::Ok`: the async shape belongs to the +/// implementation, never to the trait. +pub trait EncryptTarget { + /// The error the cipher's outputs resolve to. + type Error; + /// What `encrypt_from` returns: the finished value, or a deferred handle + /// to it. + type Output<'a, T> + where + Self: 'a, + T: 'a; +} + +/// The decrypt-side mirror of [`EncryptTarget`]. +pub trait DecryptTarget { + /// The error the cipher's outputs resolve to. + type Error; + /// What `decrypt_from` returns: the recovered value, or a deferred handle + /// to it. + type Output<'a, T> + where + Self: 'a, + T: 'a; +} + +impl EncryptTarget for StackCipher { + type Error = Error; + type Output<'a, T> + = Pending<'a, T, K> + where + Self: 'a, + T: 'a; +} + +impl DecryptTarget for StackCipher { + type Error = Error; + type Output<'a, T> + = Pending<'a, T, K> + where + Self: 'a, + T: 'a; +} + +// ============================================================================= +// The traits +// ============================================================================= + +/// `Self` is an encrypted representation of `S`, producible by a cipher `C`. +/// +/// Implemented on the *output* type — a leaf primitive output +/// ([`StackCipherText`], a SEM term, ...) or a composite record of them. Open +/// for extension: see the +/// [module docs](self#extending-with-your-own-sem-type). +/// +/// There is no associated error type: errors belong to the cipher +/// ([`EncryptTarget::Error`]), and implementations with failure modes of +/// their own use [`Error::Term`] or [`Error::Other`]. +pub trait EncryptedFrom: Sized { + /// Encrypt `source` into `Self` under `context`, returning the cipher's + /// [`Output`](EncryptTarget::Output). No I/O happens here; work needing + /// ZeroKMS is carried as requests and settles when the output is awaited. + /// + /// Borrows the source because a composite record hands the same source to + /// several field implementations; each takes what it needs (typically one + /// clone). + fn encrypt_from<'a, 'c, Ctx>(source: &'a S, cipher: &'a C, context: Ctx) -> C::Output<'a, Self> + where + Ctx: EncryptContext<'c>, + Self: 'a; +} + +/// `Self` is recoverable from the encrypted `S` by a cipher `C` — the mirror +/// of [`EncryptedFrom`], implemented on the *plaintext* type. +/// +/// Takes the source by value: decryption consumes the ciphertext, and index +/// terms (which have no plaintext to recover) simply do not participate. +pub trait DecryptedFrom: Sized { + /// Decrypt `source` into `Self`, authenticating against `context` — which + /// must match the context the value was encrypted under. + fn decrypt_from<'a, 'c, Ctx>(source: S, cipher: &'a C, context: Ctx) -> C::Output<'a, Self> + where + Ctx: DecryptContext<'c>, + S: 'a, + Self: 'a; +} + +/// Call-site sugar: `value.encrypt_into::(&cipher, context)`. +/// +/// The `Into` to [`EncryptedFrom`]'s `From` — blanket-implemented for every +/// type, never implemented by hand. The target type is usually inferred from +/// the binding: +/// +/// ``` +/// use stack_encrypt::sem::EqualityTerm; +/// use stack_encrypt::target::EncryptExt; +/// use stack_encrypt::StackCipher; +/// use stack_kms::FakeDataKeySource; +/// +/// # tokio::runtime::Builder::new_current_thread().enable_all().build().unwrap().block_on(async { +/// let cipher = StackCipher::builder() +/// .kms(FakeDataKeySource::new()) +/// .init() +/// .await +/// .unwrap(); +/// +/// let term: EqualityTerm = "alice".encrypt_into(&cipher, "users/email").await?; +/// # Ok::<(), stack_encrypt::Error>(()) +/// # }).unwrap(); +/// ``` +pub trait EncryptExt { + /// Encrypt `self` into `T` under `context`. See [`EncryptedFrom`]. + fn encrypt_into<'a, 'c, T, C, Ctx>(&'a self, cipher: &'a C, context: Ctx) -> C::Output<'a, T> + where + C: EncryptTarget, + T: EncryptedFrom + 'a, + Ctx: EncryptContext<'c>, + Self: Sized; +} + +impl EncryptExt for S { + fn encrypt_into<'a, 'c, T, C, Ctx>(&'a self, cipher: &'a C, context: Ctx) -> C::Output<'a, T> + where + C: EncryptTarget, + T: EncryptedFrom + 'a, + Ctx: EncryptContext<'c>, + { + T::encrypt_from(self, cipher, context) + } +} + +/// Call-site sugar: `encrypted.decrypt_into::(&cipher, context)` — the +/// decrypt-side [`EncryptExt`]. Blanket-implemented; never implemented by +/// hand. +pub trait DecryptExt: Sized { + /// Decrypt `self` into `T`, authenticating against `context`. See + /// [`DecryptedFrom`]. + fn decrypt_into<'a, 'c, T, C, Ctx>(self, cipher: &'a C, context: Ctx) -> C::Output<'a, T> + where + C: DecryptTarget, + T: DecryptedFrom + 'a, + Ctx: DecryptContext<'c>, + Self: 'a; +} + +impl DecryptExt for S { + fn decrypt_into<'a, 'c, T, C, Ctx>(self, cipher: &'a C, context: Ctx) -> C::Output<'a, T> + where + C: DecryptTarget, + T: DecryptedFrom + 'a, + Ctx: DecryptContext<'c>, + Self: 'a, + { + T::decrypt_from(self, cipher, context) + } +} + +// ============================================================================= +// Requests and responses +// ============================================================================= + +/// One unit of ZeroKMS work a [`Pending`] needs: constructible, otherwise +/// opaque, so new request kinds (a PRF derivation, a keyset override) can be +/// added without breaking implementations. +#[derive(Debug, Clone)] +pub struct Request(RequestKind); + +#[derive(Debug, Clone)] +enum RequestKind { + /// Generate one fresh data key under the cipher's keyset. + GenerateDataKey, + /// Re-derive the data key identified by `iv` + `tag`. + RetrieveDataKey { iv: Iv, tag: Vec }, +} + +impl Request { + /// Request one fresh data key (encrypt side). + pub fn generate_data_key() -> Self { + Self(RequestKind::GenerateDataKey) + } + + /// Request re-derivation of the data key identified by `iv` + `tag` + /// (decrypt side). + pub fn retrieve_data_key(iv: Iv, tag: Vec) -> Self { + Self(RequestKind::RetrieveDataKey { iv, tag }) + } +} + +/// The responses a fulfilment draws from — one queue per request kind, in +/// request order. A fulfilment sees exactly the responses its own requests +/// asked for (never a neighbour's), and drawing past that is +/// [`Error::ResponseShape`]. +pub struct Responses { + generated: VecDeque, + retrieved: VecDeque, +} + +impl Responses { + /// The next generated data key, in [`Request::generate_data_key`] order. + pub fn next_generated_key(&mut self) -> Result { + self.generated.pop_front().ok_or(Error::ResponseShape) + } + + /// The next retrieved data key, in [`Request::retrieve_data_key`] order. + pub fn next_retrieved_key(&mut self) -> Result { + self.retrieved.pop_front().ok_or(Error::ResponseShape) + } + + /// Split off the first `generated` + `retrieved` responses — the + /// per-fulfilment view [`Pending::request`] scopes each fulfilment to. + fn split_front(&mut self, generated: usize, retrieved: usize) -> Result { + if self.generated.len() < generated || self.retrieved.len() < retrieved { + return Err(Error::ResponseShape); + } + Ok(Responses { + generated: self.generated.drain(..generated).collect(), + retrieved: self.retrieved.drain(..retrieved).collect(), + }) + } + + pub(crate) fn drain_generated(&mut self) -> impl Iterator + '_ { + self.generated.drain(..) + } + + pub(crate) fn drain_retrieved(&mut self) -> impl Iterator + '_ { + self.retrieved.drain(..) + } +} + +// ============================================================================= +// Pending +// ============================================================================= + +/// The boxed fulfilment: consumes this pending's slice of the responses and +/// produces the output. The `Send` split mirrors [`stack_kms::MaybeSend`] — +/// the underlying ZeroKMS futures are not `Send` on wasm32. +#[cfg(not(target_arch = "wasm32"))] +type FulfilBox<'a, T> = Box Result + Send + 'a>; +/// See the native definition above; identical minus the `Send` bound. +#[cfg(target_arch = "wasm32")] +type FulfilBox<'a, T> = Box Result + 'a>; + +/// The boxed future a [`Pending`] settles through; `Send` split as above. +#[cfg(not(target_arch = "wasm32"))] +pub type PendingFuture<'a, T> = Pin> + Send + 'a>>; +/// See the native definition above; identical minus the `Send` bound. +#[cfg(target_arch = "wasm32")] +pub type PendingFuture<'a, T> = Pin> + 'a>>; + +/// A request carrier resolving to `T`: [`StackCipher`]'s +/// [`EncryptTarget::Output`] / [`DecryptTarget::Output`]. +/// +/// **Not a future** until awaited. A `Pending` holds the ZeroKMS requests its +/// value needs plus the fulfilment that shapes the responses; combining +/// pendings ([`zip`](Self::zip), [`map`](Self::map), [`all`](Self::all)) +/// merges requests *without doing any I/O*, which is where batching comes +/// from: however many pendings are merged, awaiting the result issues **one** +/// batched ZeroKMS call per request kind and then runs every fulfilment over +/// the shared response set. +/// +/// Construct leaves with [`ready`](Self::ready) (value already derived, +/// nothing to request) or [`request`](Self::request) (value needs ZeroKMS +/// responses). +pub struct Pending<'a, T, K> { + cipher: &'a StackCipher, + requests: Vec, + fulfil: FulfilBox<'a, T>, +} + +impl<'a, T: 'a, K> Pending<'a, T, K> { + /// A pending with no requests: `result` was fully derived during the + /// synchronous build. Awaiting it does no I/O. + pub fn ready(cipher: &'a StackCipher, result: Result) -> Self + where + T: MaybeSend, + { + Self { + cipher, + requests: Vec::new(), + fulfil: Box::new(move |_| result), + } + } + + /// A pending whose value needs ZeroKMS responses. `fulfil` runs after the + /// batched call, scoped to exactly the responses `requests` asked for — + /// drawing more (or another kind) is [`Error::ResponseShape`], and can + /// never consume a sibling pending's responses. + pub fn request(cipher: &'a StackCipher, requests: Vec, fulfil: F) -> Self + where + F: FnOnce(&mut Responses) -> Result + MaybeSend + 'a, + { + let (mut generated, mut retrieved) = (0usize, 0usize); + for request in &requests { + match request.0 { + RequestKind::GenerateDataKey => generated += 1, + RequestKind::RetrieveDataKey { .. } => retrieved += 1, + } + } + Self { + cipher, + requests, + fulfil: Box::new(move |responses| { + let mut own = responses.split_front(generated, retrieved)?; + fulfil(&mut own) + }), + } + } + + /// Transform the resolved value. No I/O, no new requests. + pub fn map(self, f: F) -> Pending<'a, U, K> + where + F: FnOnce(T) -> U + MaybeSend + 'a, + { + let fulfil = self.fulfil; + Pending { + cipher: self.cipher, + requests: self.requests, + fulfil: Box::new(move |responses| fulfil(responses).map(f)), + } + } + + /// Merge two pendings into one resolving to the pair. Their requests + /// concatenate — awaiting the result is still one batched call per + /// request kind. Both must come from the same cipher. + pub fn zip(mut self, other: Pending<'a, U, K>) -> Pending<'a, (T, U), K> { + debug_assert!( + std::ptr::eq(self.cipher, other.cipher), + "zipped pendings must be built from the same cipher" + ); + self.requests.extend(other.requests); + let first = self.fulfil; + let second = other.fulfil; + Pending { + cipher: self.cipher, + requests: self.requests, + fulfil: Box::new(move |responses| Ok((first(responses)?, second(responses)?))), + } + } + + /// Merge any number of same-typed pendings into one resolving to the + /// `Vec` — [`zip`](Self::zip) at scale, used by the `Vec` + /// implementations to make a whole column one batched call. + pub fn all( + cipher: &'a StackCipher, + items: Vec>, + ) -> Pending<'a, Vec, K> { + let mut requests = Vec::new(); + let mut fulfils = Vec::with_capacity(items.len()); + for item in items { + debug_assert!( + std::ptr::eq(cipher, item.cipher), + "merged pendings must be built from the same cipher" + ); + requests.extend(item.requests); + fulfils.push(item.fulfil); + } + Pending { + cipher, + requests, + fulfil: Box::new(move |responses| { + fulfils + .into_iter() + .map(|fulfil| fulfil(responses)) + .collect() + }), + } + } +} + +impl<'a, T: 'a, K> IntoFuture for Pending<'a, T, K> +where + K: DataKeySource + Sync, +{ + type Output = Result; + type IntoFuture = PendingFuture<'a, T>; + + /// The only place I/O happens: one batched ZeroKMS call per request kind + /// (none at all for an all-[`ready`](Pending::ready) assembly), then the + /// fulfilments shape the responses. + fn into_future(self) -> Self::IntoFuture { + Box::pin(async move { + let mut responses = dispatch(self.cipher, self.requests).await?; + (self.fulfil)(&mut responses) + }) + } +} + +/// Issue the batched ZeroKMS calls for `requests`: at most one +/// `generate_keys` and one `retrieve_keys`, whatever the request count. When +/// ZeroKMS grows a combined operation (data keys + PRF derivations in one +/// round-trip), this is the one place that changes. +async fn dispatch( + cipher: &StackCipher, + requests: Vec, +) -> Result { + let mut generate = 0usize; + let mut retrieves: Vec<(Iv, Vec)> = Vec::new(); + for request in requests { + match request.0 { + RequestKind::GenerateDataKey => generate += 1, + RequestKind::RetrieveDataKey { iv, tag } => retrieves.push((iv, tag)), + } + } + + let generated = if generate == 0 { + Vec::new() + } else { + // Empty descriptor + empty context for every leaf — see the + // wire-format note in the cipher module docs. + let payloads: Vec> = (0..generate) + .map(|_| GenerateKeyPayload::new("", Cow::Owned(Vec::new()))) + .collect(); + let keys = cipher + .kms() + .generate_keys(payloads, Some(cipher.keyset_id()), None) + .await?; + if keys.len() != generate { + return Err(Error::KeyCountMismatch { + expected: generate, + received: keys.len(), + }); + } + keys + }; + + let retrieved = if retrieves.is_empty() { + Vec::new() + } else { + let payloads: Vec> = retrieves + .iter() + .map(|(iv, tag)| RetrieveKeyPayload::new(*iv, "", tag)) + .collect(); + let expected = payloads.len(); + let keys = cipher + .kms() + .retrieve_keys(payloads, Some(cipher.keyset_id()), None) + .await?; + if keys.len() != expected { + return Err(Error::KeyCountMismatch { + expected, + received: keys.len(), + }); + } + keys + }; + + Ok(Responses { + generated: generated.into(), + retrieved: retrieved.into(), + }) +} + +// ============================================================================= +// Leaf implementations: the record ciphertext +// ============================================================================= + +/// The record ciphertext: any vitaminc [`Encrypt`] value, sealed by the +/// [`StackCipher`] under per-leaf ZeroKMS data keys. The context becomes the +/// AEAD associated data, binding the ciphertext to the field it was encrypted +/// for. +/// +/// The build is synchronous: the value's `Encrypt` impl drives the cipher to +/// a pending tree (no I/O), and the returned [`Pending`] carries one +/// data-key request per leaf. Sealing happens in the fulfilment, key material +/// drawn in the same traversal order the tree was built in. +impl EncryptedFrom> for StackCipherText +where + S: Encrypt + Clone, +{ + fn encrypt_from<'a, 'c, Ctx>( + source: &'a S, + cipher: &'a StackCipher, + context: Ctx, + ) -> Pending<'a, Self, K> + where + Ctx: EncryptContext<'c>, + Self: 'a, + { + let aad = context.into_aad().into_owned(); + // An empty context would leave the leaf AAD carrying only the key + // tag, making ciphertexts transplantable between ()-context fields — + // see `EncryptContext`. + if aad.as_bytes().is_empty() { + return Pending::ready(cipher, Err(Error::EmptyContext)); + } + let tree = match source.clone().encrypt_with_aad(cipher, aad) { + Ok(tree) => tree, + Err(_) => return Pending::ready(cipher, Err(Error::Aead)), + }; + let count = tree.key_count(); + let requests = std::iter::repeat_with(Request::generate_data_key) + .take(count) + .collect(); + Pending::request(cipher, requests, move |responses| { + let mut keys = responses.drain_generated(); + tree.seal_with(&mut keys).map_err(|_| Error::Aead) + }) + } +} + +/// The decrypt mirror: any vitaminc [`Decrypt`] value recovers from a +/// [`StackCipherText`]. The pending carries one retrieve request per leaf +/// (`iv` + `tag` are lifted out of the tree during the synchronous build); +/// the fulfilment binds the retrieved keys back onto the leaves and lets the +/// value's `Decrypt` impl drive the opening. +impl DecryptedFrom> for T +where + T: Decrypt<'static> + 'static, +{ + fn decrypt_from<'a, 'c, Ctx>( + source: StackCipherText, + cipher: &'a StackCipher, + context: Ctx, + ) -> Pending<'a, T, K> + where + Ctx: DecryptContext<'c>, + StackCipherText: 'a, + Self: 'a, + { + let aad = context.into_aad().into_owned(); + // Symmetric with the encrypt side: the target layer never encrypts + // under an empty context, so it never decrypts under one either. + if aad.as_bytes().is_empty() { + return Pending::ready(cipher, Err(Error::EmptyContext)); + } + let mut requests = Vec::new(); + collect_retrieve_requests(&source, &mut requests); + Pending::request(cipher, requests, move |responses| { + let mut keys = responses.drain_retrieved(); + let keyed = bind_keys(source, &mut keys).map_err(|_| Error::Aead)?; + drop(keys); + T::decrypt_with_aad(StackDecipher::over(keyed), aad).map_err(Error::from) + }) + } +} + +/// One [`Request::retrieve_data_key`] per keyed leaf, in the same depth-first +/// order `bind_keys` will consume the responses. +fn collect_retrieve_requests(ciphertext: &StackCipherText, out: &mut Vec) { + match ciphertext { + CipherText::Single(leaf) + | CipherText::None(leaf) + | CipherText::EmptySequence(leaf) + | CipherText::EmptyMap(leaf) => { + out.push(Request::retrieve_data_key(*leaf.iv(), leaf.tag().to_vec())); + } + CipherText::Sequence(items) => { + for item in items { + collect_retrieve_requests(item, out); + } + } + CipherText::Map(entries) => { + for (_, value) in entries { + collect_retrieve_requests(value, out); + } + } + CipherText::Passthrough(_) => {} + } +} + +// ============================================================================= +// Structural implementations: columns and optionals +// ============================================================================= + +/// A column: each element encrypts independently under the **same** context +/// (a column is one field), and the whole column settles in one batched call. +/// +/// Distinct from `Vec → StackCipherText` (via [`Encrypt`]), which is one +/// record whose value is a list — see the [module docs](self). +impl EncryptedFrom, StackCipher> for Vec +where + T: EncryptedFrom>, +{ + fn encrypt_from<'a, 'c, Ctx>( + source: &'a Vec, + cipher: &'a StackCipher, + context: Ctx, + ) -> Pending<'a, Self, K> + where + Ctx: EncryptContext<'c>, + Self: 'a, + { + let items = source + .iter() + .map(|item| T::encrypt_from(item, cipher, context.clone())) + .collect(); + Pending::all(cipher, items) + } +} + +/// The column decrypt mirror: one batched retrieve for every row. +impl DecryptedFrom, StackCipher> for Vec +where + T: DecryptedFrom>, +{ + fn decrypt_from<'a, 'c, Ctx>( + source: Vec, + cipher: &'a StackCipher, + context: Ctx, + ) -> Pending<'a, Self, K> + where + Ctx: DecryptContext<'c>, + S: 'a, + Self: 'a, + { + let items = source + .into_iter() + .map(|item| T::decrypt_from(item, cipher, context.clone())) + .collect(); + Pending::all(cipher, items) + } +} + +/// An optional field: `None` encrypts to `None` at the target layer (an +/// absent *record field*, carrying no requests). This is distinct from +/// `Option → StackCipherText` via [`Encrypt`], which produces an +/// *authenticated* absence marker inside one ciphertext. +impl EncryptedFrom, StackCipher> for Option +where + T: EncryptedFrom> + MaybeSend, +{ + fn encrypt_from<'a, 'c, Ctx>( + source: &'a Option, + cipher: &'a StackCipher, + context: Ctx, + ) -> Pending<'a, Self, K> + where + Ctx: EncryptContext<'c>, + Self: 'a, + { + match source { + Some(value) => T::encrypt_from(value, cipher, context).map(Some), + None => Pending::ready(cipher, Ok(None)), + } + } +} + +/// The optional decrypt mirror of the [`Option`] encrypt implementation. +impl DecryptedFrom, StackCipher> for Option +where + T: DecryptedFrom> + MaybeSend, +{ + fn decrypt_from<'a, 'c, Ctx>( + source: Option, + cipher: &'a StackCipher, + context: Ctx, + ) -> Pending<'a, Self, K> + where + Ctx: DecryptContext<'c>, + S: 'a, + Self: 'a, + { + match source { + Some(value) => T::decrypt_from(value, cipher, context).map(Some), + None => Pending::ready(cipher, Ok(None)), + } + } +} diff --git a/packages/stack-encrypt/tests/sem_terms.rs b/packages/stack-encrypt/tests/sem_terms.rs index c1b6392c2..6e329d821 100644 --- a/packages/stack-encrypt/tests/sem_terms.rs +++ b/packages/stack-encrypt/tests/sem_terms.rs @@ -3,11 +3,41 @@ use std::cmp::Ordering; -use stack_encrypt::sem::{MatchOptions, Tokenizer}; +use stack_encrypt::sem::{DefaultMatch, MatchConfig, MatchOptions, Tokenizer}; use stack_encrypt::StackCipher; use stack_kms::{FakeDataKeySource, IdentifiedBy}; use uuid::Uuid; +/// Type-level config with the v1 `Standard` (word) tokenizer. +struct WordMatch; + +impl MatchConfig for WordMatch { + fn options() -> MatchOptions { + MatchOptions { + tokenizer: Tokenizer::Standard, + ..Default::default() + } + } +} + +/// A config whose options fail validation at term-generation time. +macro_rules! bad_config { + ($name:ident, $($field:ident: $value:expr),+ $(,)?) => { + struct $name; + impl MatchConfig for $name { + fn options() -> MatchOptions { + MatchOptions { $($field: $value,)+ ..Default::default() } + } + } + }; +} + +bad_config!(TooBigK, k: 17); +bad_config!(TooSmallK, k: 1); +bad_config!(NonPowerOfTwoM, m: 100); +bad_config!(TooSmallM, m: 16); +bad_config!(ZeroNgram, tokenizer: Tokenizer::Ngram { length: 0 }); + async fn generator() -> StackCipher { StackCipher::builder() .kms(FakeDataKeySource::new()) @@ -61,13 +91,15 @@ async fn equality_terms_bind_the_index_key() { #[tokio::test] async fn match_query_terms_are_contained_in_stored_terms() { let gen = generator().await; - let opts = MatchOptions::default(); let stored = gen - .match_terms("alice wonderland", "users/bio", &opts) + .match_terms::("alice wonderland", "users/bio") + .await + .unwrap(); + let query = gen + .match_terms::("wonder", "users/bio") .await .unwrap(); - let query = gen.match_terms("wonder", "users/bio", &opts).await.unwrap(); assert!( stored.contains(&query), @@ -78,37 +110,52 @@ async fn match_query_terms_are_contained_in_stored_terms() { #[tokio::test] async fn match_is_case_insensitive_by_default() { let gen = generator().await; - let opts = MatchOptions::default(); - let stored = gen.match_terms("Alice", "users/name", &opts).await.unwrap(); - let query = gen.match_terms("alice", "users/name", &opts).await.unwrap(); + let stored = gen + .match_terms::("Alice", "users/name") + .await + .unwrap(); + let query = gen + .match_terms::("alice", "users/name") + .await + .unwrap(); assert_eq!(stored, query); } #[tokio::test] async fn match_binds_the_descriptor() { let gen = generator().await; - let opts = MatchOptions::default(); - let stored = gen.match_terms("alice", "users/bio", &opts).await.unwrap(); - let query = gen.match_terms("alice", "users/name", &opts).await.unwrap(); + let stored = gen + .match_terms::("alice", "users/bio") + .await + .unwrap(); + let query = gen + .match_terms::("alice", "users/name") + .await + .unwrap(); assert_ne!(stored, query, "match tokens must be descriptor-bound"); } #[tokio::test] async fn match_positions_stay_within_the_filter() { - let gen = generator().await; - let opts = MatchOptions { - m: 64, - ..Default::default() - }; + struct SmallFilter; + impl MatchConfig for SmallFilter { + fn options() -> MatchOptions { + MatchOptions { + m: 64, + ..Default::default() + } + } + } + let gen = generator().await; let term = gen - .match_terms("a longer piece of text", "users/bio", &opts) + .match_terms::("a longer piece of text", "users/bio") .await .unwrap(); assert!(!term.positions().is_empty()); - assert!(term.positions().iter().all(|&p| u32::from(p) < opts.m)); + assert!(term.positions().iter().all(|&p| u32::from(p) < 64)); // Sorted + deduped. assert!(term.positions().windows(2).all(|w| w[0] < w[1])); } @@ -116,38 +163,17 @@ async fn match_positions_stay_within_the_filter() { #[tokio::test] async fn match_rejects_invalid_options() { let gen = generator().await; - let bad_k = MatchOptions { - k: 17, - ..Default::default() - }; - assert!(gen.match_terms("xxx", "d", &bad_k).await.is_err()); - // The v1 match indexer's lower bounds apply: k >= 3, m >= 32. - let small_k = MatchOptions { - k: 1, - ..Default::default() - }; - assert!(gen.match_terms("xxx", "d", &small_k).await.is_err()); - - let bad_m = MatchOptions { - m: 100, - ..Default::default() - }; - assert!(gen.match_terms("xxx", "d", &bad_m).await.is_err()); - - let small_m = MatchOptions { - m: 16, - ..Default::default() - }; - assert!(gen.match_terms("xxx", "d", &small_m).await.is_err()); + // The v1 match indexer's bounds apply: k in 3..=16, m a power of two in + // [32, 65536]. + assert!(gen.match_terms::("xxx", "d").await.is_err()); + assert!(gen.match_terms::("xxx", "d").await.is_err()); + assert!(gen.match_terms::("xxx", "d").await.is_err()); + assert!(gen.match_terms::("xxx", "d").await.is_err()); // A zero-length n-gram must be an options error, not a panic. - let bad_ngram = MatchOptions { - tokenizer: Tokenizer::Ngram { length: 0 }, - ..Default::default() - }; assert!(matches!( - gen.match_terms("xxx", "d", &bad_ngram).await, + gen.match_terms::("xxx", "d").await, Err(stack_encrypt::sem::TermError::InvalidOptions(_)) )); } @@ -157,13 +183,12 @@ async fn match_rejects_text_that_yields_no_tokens() { use stack_encrypt::sem::TermError; let gen = generator().await; - let opts = MatchOptions::default(); // An empty term used as a query would vacuously match every stored row. for text in ["", " "] { assert!( matches!( - gen.match_terms(text, "users/bio", &opts).await, + gen.match_terms::(text, "users/bio").await, Err(TermError::EmptyTermText) ), "{text:?} must be rejected" @@ -173,17 +198,13 @@ async fn match_rejects_text_that_yields_no_tokens() { // A probe shorter than the n-gram length could never match a stored gram // (v1 indexer semantics) — rejected instead of a silent false negative. assert!(matches!( - gen.match_terms("hi", "users/bio", &opts).await, + gen.match_terms::("hi", "users/bio").await, Err(TermError::EmptyTermText) )); // Separator-only text under the Standard tokenizer. - let standard = MatchOptions { - tokenizer: Tokenizer::Standard, - ..Default::default() - }; assert!(matches!( - gen.match_terms(" ,;:! ", "users/bio", &standard).await, + gen.match_terms::(" ,;:! ", "users/bio").await, Err(TermError::EmptyTermText) )); } @@ -191,17 +212,13 @@ async fn match_rejects_text_that_yields_no_tokens() { #[tokio::test] async fn word_tokenizer_matches_whole_words() { let gen = generator().await; - let opts = MatchOptions { - tokenizer: Tokenizer::Standard, - ..Default::default() - }; let stored = gen - .match_terms("alice in wonderland", "users/bio", &opts) + .match_terms::("alice in wonderland", "users/bio") .await .unwrap(); let query = gen - .match_terms("wonderland", "users/bio", &opts) + .match_terms::("wonderland", "users/bio") .await .unwrap(); assert!(stored.contains(&query)); diff --git a/packages/stack-encrypt/tests/target.rs b/packages/stack-encrypt/tests/target.rs new file mode 100644 index 000000000..dc681c612 --- /dev/null +++ b/packages/stack-encrypt/tests/target.rs @@ -0,0 +1,685 @@ +//! Target-directed encryption tests: leaf `EncryptedFrom`/`DecryptedFrom` +//! implementations, a hand-written composite record (the shape a future +//! derive will emit), a "third-party" term type built on the public extension +//! surface only, and — the point of the design — proof that however large the +//! assembly, settling it is one batched ZeroKMS call per request kind. + +use std::borrow::Cow; +use std::cmp::Ordering; +use std::sync::atomic::{AtomicUsize, Ordering as AtomicOrdering}; +use std::sync::Arc; + +use stack_encrypt::sem::{EqualityTerm, MatchConfig, MatchOptions, MatchTerm, OreTerm}; +use stack_encrypt::target::{ + DecryptContext, DecryptExt, DecryptedFrom, EncryptContext, EncryptExt, EncryptedFrom, Pending, + Request, +}; +use stack_encrypt::{Error, StackCipher, StackCipherText}; +use stack_kms::{ + DataKey, DataKeySource, DataKeyWithTag, FakeDataKeySource, GenerateKeyPayload, IdentifiedBy, + IndexKey, IndexKeySource, RetrieveKeyPayload, UnverifiedContext, +}; +use uuid::Uuid; +use vitaminc_prf::{BlockVisitor, PrfContext, PrfValue}; + +/// A cipher over the deterministic fake source. Built independently of +/// [`stack_cipher`] below: the fake index key is deterministic per keyset, so +/// two separately built ciphers stand in for the write path and a query path +/// in another process. +async fn generator() -> StackCipher { + stack_cipher().await +} + +async fn stack_cipher() -> StackCipher { + StackCipher::builder() + .kms(FakeDataKeySource::new()) + .init() + .await + .expect("build cipher") +} + +/// The fake source with ZeroKMS *call* counters (not key counters): the +/// design's whole claim is that an assembly of any size settles in one +/// batched call per request kind, and these tests hold it to that. +struct CountingSource { + inner: FakeDataKeySource, + generate_calls: Arc, + retrieve_calls: Arc, +} + +impl CountingSource { + fn new() -> Self { + Self { + inner: FakeDataKeySource::new(), + generate_calls: Arc::new(AtomicUsize::new(0)), + retrieve_calls: Arc::new(AtomicUsize::new(0)), + } + } + + fn counters(&self) -> (Arc, Arc) { + (self.generate_calls.clone(), self.retrieve_calls.clone()) + } +} + +impl DataKeySource for CountingSource { + async fn generate_keys( + &self, + payloads: Vec>, + keyset_id: Option, + unverified_context: Option>, + ) -> Result, stack_kms::Error> { + self.generate_calls.fetch_add(1, AtomicOrdering::SeqCst); + self.inner + .generate_keys(payloads, keyset_id, unverified_context) + .await + } + + async fn retrieve_keys( + &self, + payloads: Vec>, + keyset_id: Option, + unverified_context: Option<&UnverifiedContext>, + ) -> Result, stack_kms::Error> { + self.retrieve_calls.fetch_add(1, AtomicOrdering::SeqCst); + self.inner + .retrieve_keys(payloads, keyset_id, unverified_context) + .await + } +} + +impl IndexKeySource for CountingSource { + async fn load_index_key( + &self, + keyset_id: Option, + ) -> Result<(Uuid, IndexKey), stack_kms::Error> { + self.inner.load_index_key(keyset_id).await + } +} + +async fn counting_cipher() -> ( + StackCipher, + Arc, + Arc, +) { + let source = CountingSource::new(); + let (generates, retrieves) = source.counters(); + let cipher = StackCipher::builder() + .kms(source) + .init() + .await + .expect("build cipher"); + (cipher, generates, retrieves) +} + +// --- Leaf implementations --------------------------------------------------- + +#[tokio::test] +async fn equality_leaf_agrees_with_the_descriptor_api() { + let generator = generator().await; + + let via_target: EqualityTerm = "alice" + .encrypt_into(&generator, "users/email") + .await + .unwrap(); + let via_descriptor = generator + .equality_term("alice", "users/email") + .await + .unwrap(); + + assert_eq!( + via_target, via_descriptor, + "target-directed and descriptor-string call sites must agree on term bytes" + ); +} + +#[tokio::test] +async fn terms_agree_between_independently_built_ciphers() { + // Query-side code in another process, holding its own cipher over the same + // keyset, must produce the same terms + // as write-side code holding the full StackCipher (same index key). + let cipher = stack_cipher().await; + let generator = generator().await; + + let a: EqualityTerm = "alice".encrypt_into(&cipher, "users/email").await.unwrap(); + let b: EqualityTerm = "alice" + .encrypt_into(&generator, "users/email") + .await + .unwrap(); + assert_eq!(a, b); + + let a: OreTerm = 7u64.encrypt_into(&cipher, "users/n").await.unwrap(); + let b: OreTerm = 7u64.encrypt_into(&generator, "users/n").await.unwrap(); + assert_eq!(a, b); +} + +#[tokio::test] +async fn equality_leaf_binds_the_context() { + let generator = generator().await; + + let email: EqualityTerm = "alice" + .encrypt_into(&generator, "users/email") + .await + .unwrap(); + let name: EqualityTerm = "alice" + .encrypt_into(&generator, "users/name") + .await + .unwrap(); + + assert_ne!(email, name); +} + +#[tokio::test] +async fn term_derivation_makes_no_kms_calls() { + // Terms derive under the index key the cipher already holds: building a + // query probe must never touch ZeroKMS. + let (cipher, generates, retrieves) = counting_cipher().await; + + let _term: EqualityTerm = "alice".encrypt_into(&cipher, "users/email").await.unwrap(); + let _ore: OreTerm = 7u64.encrypt_into(&cipher, "users/age").await.unwrap(); + + assert_eq!(generates.load(AtomicOrdering::SeqCst), 0); + assert_eq!(retrieves.load(AtomicOrdering::SeqCst), 0); +} + +#[tokio::test] +async fn ciphertext_leaf_round_trips_via_decrypt_into() { + let cipher = stack_cipher().await; + + let ciphertext: StackCipherText = "secret" + .to_string() + .encrypt_into(&cipher, "users/email") + .await + .unwrap(); + let plaintext: String = ciphertext + .decrypt_into(&cipher, "users/email") + .await + .unwrap(); + + assert_eq!(plaintext, "secret"); +} + +#[tokio::test] +async fn ciphertext_leaf_cannot_be_transplanted_to_another_context() { + let cipher = stack_cipher().await; + + let ciphertext: StackCipherText = "secret" + .to_string() + .encrypt_into(&cipher, "users/email") + .await + .unwrap(); + + let transplanted: Result = ciphertext.decrypt_into(&cipher, "users/name").await; + assert!( + transplanted.is_err(), + "the context is bound into the AAD, so a ciphertext must not decrypt under another field's context" + ); +} + +#[tokio::test] +async fn match_leaf_supports_containment_queries() { + let generator = generator().await; + + let stored: MatchTerm = "alice wonderland" + .to_string() + .encrypt_into(&generator, "users/bio") + .await + .unwrap(); + let query: MatchTerm = "wonder" + .to_string() + .encrypt_into(&generator, "users/bio") + .await + .unwrap(); + + assert!(stored.contains(&query)); +} + +/// A custom type-level match configuration. +struct SmallFilter; + +impl MatchConfig for SmallFilter { + fn options() -> MatchOptions { + MatchOptions { + m: 64, + ..Default::default() + } + } +} + +#[tokio::test] +async fn match_leaf_config_is_type_level() { + let generator = generator().await; + + let term: MatchTerm = "a longer piece of text" + .to_string() + .encrypt_into(&generator, "users/bio") + .await + .unwrap(); + assert!(term.positions().iter().all(|&p| u32::from(p) < 64)); + + // The same text under the default config is a different (larger-filter) + // term — and a different type, so the two cannot be compared by mistake. + let default_term: MatchTerm = "a longer piece of text" + .to_string() + .encrypt_into(&generator, "users/bio") + .await + .unwrap(); + assert_ne!(term.positions(), default_term.positions()); +} + +#[tokio::test] +async fn ore_leaf_preserves_order_and_binds_the_context() { + let generator = generator().await; + + let ten: OreTerm = 10u64.encrypt_into(&generator, "users/age").await.unwrap(); + let ten_again: OreTerm = 10u64.encrypt_into(&generator, "users/age").await.unwrap(); + let twenty: OreTerm = 20u64.encrypt_into(&generator, "users/age").await.unwrap(); + let other_field: OreTerm = 10u64 + .encrypt_into(&generator, "users/height") + .await + .unwrap(); + + assert_eq!(ten, ten_again, "ORE terms must be deterministic"); + assert_eq!(ten.cmp(&twenty), Ordering::Less); + assert_ne!(ten, other_field, "per-context ORE keys must differ"); +} + +#[tokio::test] +async fn ope_leaf_compares_with_plain_byte_order() { + use stack_encrypt::sem::OpeTerm; + + let generator = generator().await; + + let ten: OpeTerm = 10u64.encrypt_into(&generator, "users/age").await.unwrap(); + let twenty: OpeTerm = 20u64.encrypt_into(&generator, "users/age").await.unwrap(); + + assert_eq!(ten.cmp(&twenty), Ordering::Less); + assert!(ten.inner().as_ref() < twenty.inner().as_ref()); +} + +// --- Columns: batching comes from the source shape --------------------------- + +#[tokio::test] +async fn a_column_encrypts_in_one_batched_call() { + let (cipher, generates, _) = counting_cipher().await; + + let ages: Vec = vec![29, 34, 41, 34, 57]; + let sealed: Vec = ages.encrypt_into(&cipher, "users/age").await.unwrap(); + + assert_eq!(sealed.len(), 5); + assert_eq!( + generates.load(AtomicOrdering::SeqCst), + 1, + "five records must share ONE generate_keys call" + ); +} + +#[tokio::test] +async fn a_column_decrypts_in_one_batched_call() { + let (cipher, _, retrieves) = counting_cipher().await; + + let ages: Vec = vec![29, 34, 41]; + let sealed: Vec = ages.encrypt_into(&cipher, "users/age").await.unwrap(); + + let roundtrip: Vec = sealed.decrypt_into(&cipher, "users/age").await.unwrap(); + + assert_eq!(roundtrip, ages); + assert_eq!( + retrieves.load(AtomicOrdering::SeqCst), + 1, + "three rows must share ONE retrieve_keys call" + ); +} + +#[tokio::test] +async fn optional_fields_encrypt_and_decrypt_structurally() { + let cipher = stack_cipher().await; + + let present: Option = Some("here".to_string()) + .encrypt_into(&cipher, "users/nickname") + .await + .unwrap(); + let absent: Option = Option::::None + .encrypt_into(&cipher, "users/nickname") + .await + .unwrap(); + + assert!(present.is_some()); + assert!(absent.is_none()); + + let roundtrip: Option = present + .decrypt_into(&cipher, "users/nickname") + .await + .unwrap(); + assert_eq!(roundtrip.as_deref(), Some("here")); +} + +// --- A hand-written composite record ---------------------------------------- +// +// The shape a `#[derive(Encrypted)]` will emit: one impl, pendings combined +// with zip/map (never awaited), one context fanning out to every field, the +// caller seeing a single await — and a single batched call. + +/// "An encrypted `u32`, stored as its ciphertext plus an equality term and an +/// ORE term" — an EQL `integer_ord_ore`-shaped record, minus the EQL. +struct EncryptedAge { + c: StackCipherText, + hm: EqualityTerm, + ob: OreTerm, +} + +impl EncryptedFrom> for EncryptedAge { + fn encrypt_from<'a, 'c, Ctx>( + source: &'a u32, + cipher: &'a StackCipher, + context: Ctx, + ) -> Pending<'a, Self, K> + where + Ctx: EncryptContext<'c>, + Self: 'a, + { + StackCipherText::encrypt_from(source, cipher, context.clone()) + .zip(EqualityTerm::encrypt_from(source, cipher, context.clone())) + .zip(OreTerm::::encrypt_from(source, cipher, context)) + .map(|((c, hm), ob)| Self { c, hm, ob }) + } +} + +/// The decrypt mirror a derive would emit: only the ciphertext field +/// participates — terms are one-way. +impl DecryptedFrom> for u32 { + fn decrypt_from<'a, 'c, Ctx>( + source: EncryptedAge, + cipher: &'a StackCipher, + context: Ctx, + ) -> Pending<'a, Self, K> + where + Ctx: DecryptContext<'c>, + EncryptedAge: 'a, + Self: 'a, + { + source.c.decrypt_into(cipher, context) + } +} + +#[tokio::test] +async fn composite_record_encrypts_every_field_from_one_source() { + let cipher = stack_cipher().await; + let generator = generator().await; + + let record: EncryptedAge = 42u32.encrypt_into(&cipher, "users/age").await.unwrap(); + + // The ciphertext round-trips through the decrypt mirror. + let plaintext: u32 = record.decrypt_into(&cipher, "users/age").await.unwrap(); + assert_eq!(plaintext, 42); + + // Each term matches what the primitive would derive on its own, so query + // terms generated leaf-by-leaf find records encrypted as composites. + let record: EncryptedAge = 42u32.encrypt_into(&cipher, "users/age").await.unwrap(); + let hm: EqualityTerm = 42u32.encrypt_into(&generator, "users/age").await.unwrap(); + assert_eq!(record.hm, hm); + + let ob: OreTerm = 42u32.encrypt_into(&generator, "users/age").await.unwrap(); + assert_eq!(record.ob, ob); +} + +#[tokio::test] +async fn a_composite_record_is_one_batched_call() { + let (cipher, generates, _) = counting_cipher().await; + + let _record: EncryptedAge = 42u32.encrypt_into(&cipher, "users/age").await.unwrap(); + assert_eq!( + generates.load(AtomicOrdering::SeqCst), + 1, + "ciphertext + two terms must settle in ONE generate_keys call" + ); + + // A whole column of records: still one call. + let ages: Vec = vec![10, 20, 30]; + let _column: Vec = ages.encrypt_into(&cipher, "users/age").await.unwrap(); + assert_eq!( + generates.load(AtomicOrdering::SeqCst), + 2, + "a column of composite records must add ONE more call, not one per row" + ); +} + +#[tokio::test] +async fn composite_record_terms_preserve_order() { + let cipher = stack_cipher().await; + + let ten: EncryptedAge = 10u32.encrypt_into(&cipher, "users/age").await.unwrap(); + let twenty: EncryptedAge = 20u32.encrypt_into(&cipher, "users/age").await.unwrap(); + + assert_eq!(ten.ob.cmp(&twenty.ob), Ordering::Less); +} + +// --- A "third-party" term type ---------------------------------------------- +// +// Defined here using only the public extension surface: `EncryptedFrom`, +// `Pending::ready`, and the cipher's public PRF. This is the proof that the +// set of SEM types is open — a separate crate can do exactly this. + +/// A prefix term: the PRF of the first `N` characters of a string, enabling +/// "starts with" queries on the first N chars. (Illustrative only.) +#[derive(Debug, PartialEq, Eq)] +struct PrefixTerm([u8; 32]); + +impl EncryptedFrom> for PrefixTerm +where + S: AsRef, +{ + fn encrypt_from<'a, 'c, Ctx>( + source: &'a S, + cipher: &'a StackCipher, + context: Ctx, + ) -> Pending<'a, Self, K> + where + Ctx: EncryptContext<'c>, + Self: 'a, + { + // Own domain label: can never collide with a built-in term under the + // same context. + let context = PrfContext::pae(&[ + b"example/prefix-term/v1", + &(N as u64).to_le_bytes(), + context.into_prf_context().as_bytes(), + ]); + let prefix: String = source.as_ref().chars().take(N).collect(); + let term = prefix + .prf_visit_with_context(cipher.prf().clone(), context, BlockVisitor) + .into_result() + .map(PrefixTerm) + .map_err(|e| Error::Other(Box::new(e))); + Pending::ready(cipher, term) + } +} + +#[tokio::test] +async fn third_party_term_type_works_on_the_public_surface() { + let cipher = stack_cipher().await; + let generator = generator().await; + + let stored: PrefixTerm<3> = "alice".encrypt_into(&cipher, "users/name").await.unwrap(); + let probe: PrefixTerm<3> = "alicia" + .encrypt_into(&generator, "users/name") + .await + .unwrap(); + let miss: PrefixTerm<3> = "bob".encrypt_into(&generator, "users/name").await.unwrap(); + + assert_eq!(stored, probe, "same 3-char prefix, same term"); + assert_ne!(stored, miss); + + // And it composes into a record like any built-in term. + struct NameRecord { + c: StackCipherText, + prefix: PrefixTerm<3>, + } + + impl EncryptedFrom> for NameRecord { + fn encrypt_from<'a, 'c, Ctx>( + source: &'a String, + cipher: &'a StackCipher, + context: Ctx, + ) -> Pending<'a, Self, K> + where + Ctx: EncryptContext<'c>, + Self: 'a, + { + StackCipherText::encrypt_from(source, cipher, context.clone()) + .zip(PrefixTerm::<3>::encrypt_from(source, cipher, context)) + .map(|(c, prefix)| Self { c, prefix }) + } + } + + let record: NameRecord = "alice" + .to_string() + .encrypt_into(&cipher, "users/name") + .await + .unwrap(); + assert_eq!(record.prefix, stored); + let name: String = record.c.decrypt_into(&cipher, "users/name").await.unwrap(); + assert_eq!(name, "alice"); +} + +// --- Guard rails -------------------------------------------------------------- + +#[tokio::test] +async fn init_pins_the_cipher_to_the_keyset_it_resolved() { + // The keyset a cipher seals data keys under is the same one whose index + // key derives its terms: `init` resolves both together, so they cannot + // diverge. (Sealing under one keyset while deriving terms under another + // would make every query silently match nothing.) + let cipher = StackCipher::builder() + .kms(FakeDataKeySource::new()) + .init() + .await + .expect("build cipher"); + + let (expected, _) = FakeDataKeySource::new() + .load_index_key(None) + .await + .expect("load index key"); + assert_eq!(cipher.keyset_id(), expected); +} + +#[tokio::test] +async fn an_explicit_keyset_is_honoured() { + let keyset = Uuid::from_u128(42); + let cipher = StackCipher::builder() + .kms(FakeDataKeySource::new()) + .keyset(IdentifiedBy::Uuid(keyset)) + .init() + .await + .expect("build cipher"); + + assert_eq!(cipher.keyset_id(), keyset); + + // And its terms differ from the default keyset's: a different keyset means + // a different index key. + let default = stack_cipher().await; + let a = cipher.equality_term("alice", "users/email").await.unwrap(); + let b = default.equality_term("alice", "users/email").await.unwrap(); + assert_ne!(a, b); +} + +#[tokio::test] +async fn empty_context_is_rejected_everywhere() { + use stack_encrypt::sem::{OpeTerm, TermError}; + + let cipher = stack_cipher().await; + let generator = generator().await; + + // Terms: an empty context would collapse per-field domain separation. + // Rejected during the synchronous build — before any I/O could happen. + let eq: Result = "alice".encrypt_into(&generator, "").await; + assert!(matches!(eq, Err(Error::Term(TermError::EmptyContext)))); + let m: Result = "alice".to_string().encrypt_into(&generator, "").await; + assert!(matches!(m, Err(Error::Term(TermError::EmptyContext)))); + let ore: Result, _> = 7u64.encrypt_into(&generator, "").await; + assert!(matches!(ore, Err(Error::Term(TermError::EmptyContext)))); + let ope: Result, _> = 7u64.encrypt_into(&generator, "").await; + assert!(matches!(ope, Err(Error::Term(TermError::EmptyContext)))); + + // Descriptor-string convenience methods route through the same guard. + assert!(matches!( + generator.equality_term("alice", "").await, + Err(TermError::EmptyContext) + )); + + // The ciphertext leaf: an empty AAD would make ciphertexts transplantable + // between ()-context fields. + let ct: Result = "secret".to_string().encrypt_into(&cipher, "").await; + assert!(matches!(ct, Err(Error::EmptyContext))); + + // And the decrypt mirror never opens under one either. + let sealed: StackCipherText = "secret" + .to_string() + .encrypt_into(&cipher, "users/email") + .await + .unwrap(); + let opened: Result = sealed.decrypt_into(&cipher, "").await; + assert!(matches!(opened, Err(Error::EmptyContext))); +} + +#[tokio::test] +async fn an_overdrawing_fulfilment_is_a_response_shape_error() { + // A fulfilment is scoped to exactly the responses its requests asked for: + // drawing more must fail loudly, never consume a sibling's responses. + let cipher = stack_cipher().await; + + let pending: Pending<'_, (), _> = + Pending::request(&cipher, vec![Request::generate_data_key()], |responses| { + responses.next_generated_key()?; + responses.next_generated_key()?; // one more than requested + Ok(()) + }); + + assert!(matches!(pending.await, Err(Error::ResponseShape))); +} + +// Terms rebuilt from persisted parts must behave like freshly generated ones. +#[tokio::test] +async fn terms_rehydrate_from_persisted_parts() { + let generator = generator().await; + + let eq: EqualityTerm = "alice" + .encrypt_into(&generator, "users/email") + .await + .unwrap(); + let rehydrated = EqualityTerm::from_bytes(eq.clone().into_bytes()); + assert_eq!(eq, rehydrated); + + let stored: MatchTerm = "alice wonderland" + .to_string() + .encrypt_into(&generator, "users/bio") + .await + .unwrap(); + let query: MatchTerm = "wonder" + .to_string() + .encrypt_into(&generator, "users/bio") + .await + .unwrap(); + // Rehydrate from unsorted positions: from_positions normalises. + let mut positions = stored.clone().into_positions(); + positions.reverse(); + let rehydrated: MatchTerm = MatchTerm::from_positions(positions); + assert_eq!(stored, rehydrated); + assert!(rehydrated.contains(&query)); +} + +// The pending futures are `Send` on native targets, so target-directed +// encryption can hop threads (e.g. `tokio::spawn`). +#[tokio::test] +async fn pending_futures_are_send() { + let generator = generator().await; + + let handle = tokio::spawn(async move { + let term: EqualityTerm = "alice" + .encrypt_into(&generator, "users/email") + .await + .unwrap(); + term + }); + + let _term = handle.await.unwrap(); +} diff --git a/packages/stack-encrypt/tests/term_bytes.rs b/packages/stack-encrypt/tests/term_bytes.rs new file mode 100644 index 000000000..c2084d598 --- /dev/null +++ b/packages/stack-encrypt/tests/term_bytes.rs @@ -0,0 +1,85 @@ +//! Byte-level pins for the SEM term derivations. +//! +//! These lock the exact bytes a term derives from: the PAE domain label, the +//! framing of the context, and the order the pieces go in. A change to any of +//! them changes every stored term — and because terms are compared for +//! equality server-side, the failure mode is not an error but a query that +//! silently stops matching. Breaking one of these tests means the derivation +//! moved, and the `/v1` suffix in the domain labels has to move with it. +//! +//! Keyed by `FakeDataKeySource`'s deterministic index key, so the expected +//! bytes are stable without ZeroKMS. + +use stack_encrypt::sem::DefaultMatch; +use stack_encrypt::StackCipher; +use stack_kms::FakeDataKeySource; + +async fn cipher() -> StackCipher { + StackCipher::builder() + .kms(FakeDataKeySource::new()) + .init() + .await + .expect("build cipher") +} + +fn hex(bytes: &[u8]) -> String { + bytes.iter().map(|b| format!("{b:02x}")).collect() +} + +#[tokio::test] +async fn equality_term_bytes_are_pinned() { + let term = cipher() + .await + .equality_term("alice", "users/email") + .await + .unwrap(); + + assert_eq!( + hex(term.as_bytes()), + "81b963584feb41e517069477bd7fb568615ce724cb146451df6b6b4c16e43de1" + ); +} + +#[tokio::test] +async fn match_term_positions_are_pinned() { + let term = cipher() + .await + .match_terms::("alice smith", "users/name") + .await + .unwrap(); + + // Pins the tokenizer, the per-token PRF framing, and the Bloom folding + // together: any of the three moving changes this set. + assert_eq!( + term.positions(), + [ + 33, 34, 40, 44, 45, 52, 60, 63, 84, 98, 99, 107, 113, 125, 127, 151, 152, 164, 166, + 168, 169, 210, 212, 253 + ] + ); +} + +#[tokio::test] +async fn ore_term_bytes_are_pinned() { + // The ORE key is a PRF of the descriptor, so this pins the key derivation + // as much as the CLLW encryption. + let term = cipher().await.ore_term(42u32, "users/age").await.unwrap(); + + assert_eq!( + hex(term.as_ref()), + "d757854cffc68e9f3dfa9dba7ec400a30c80dd57122ebbc064eeff5a81069fc7" + ); +} + +#[tokio::test] +async fn ope_term_bytes_are_pinned() { + // Distinct from the ORE pin above under the same descriptor: the two + // schemes derive their keys under different domains and must never share + // one (OPE ciphertexts are encrypt-only). + let term = cipher().await.ope_term(42u32, "users/age").await.unwrap(); + + assert_eq!( + hex(term.as_ref()), + "00470b57be663ba84635c72c1bdfa8ed263e7e57504002db51d3e695ba0b499833" + ); +} From 66593b72552ba43f47678ec6f333ffc977536200 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Wed, 26 Aug 2026 13:57:25 +1000 Subject: [PATCH 432/686] docs(stack-encrypt): examples talk to real ZeroKMS; describe the fake as a stub The examples ran against `FakeDataKeySource`, which now models nothing beyond a key round-trip (cipherstash/cipherstash-suite#2018). An example that demonstrates search terms and cross-client decryption against a stub demonstrates the stub. They now build the cipher with `StackCipher::new()` like `zerokms_auth` does and document the credentials they need. The crate docs no longer call the fake "deterministic": it is an in-memory stub with no ZeroKMS authorization behaviour, and say where those tests belong instead. Claude-Session: https://claude.ai/code/session_01HU1Bbw4eQp9kEEneXxDcKr --- .../examples/encrypted_record.rs | 20 +++++++------------ packages/stack-encrypt/examples/mixed_user.rs | 10 ++++------ .../stack-encrypt/examples/search_terms.rs | 16 ++++++--------- .../stack-encrypt/examples/zerokms_auth.rs | 5 ++--- packages/stack-encrypt/src/lib.rs | 8 +++++--- 5 files changed, 24 insertions(+), 35 deletions(-) diff --git a/packages/stack-encrypt/examples/encrypted_record.rs b/packages/stack-encrypt/examples/encrypted_record.rs index 360922672..15c983504 100644 --- a/packages/stack-encrypt/examples/encrypted_record.rs +++ b/packages/stack-encrypt/examples/encrypted_record.rs @@ -19,14 +19,15 @@ //! cargo run -p stack-encrypt --example encrypted_record //! ``` //! -//! Uses `FakeDataKeySource`, so no ZeroKMS credentials or network are needed. +//! Talks to real ZeroKMS: needs `CS_CLIENT_ID` / `CS_CLIENT_KEY` and access-key +//! or device-session credentials in the environment (see the `zerokms_auth` +//! example for where they come from). use stack_encrypt::sem::{EqualityTerm, OreTerm}; use stack_encrypt::target::{ DecryptContext, DecryptExt, DecryptedFrom, EncryptContext, EncryptExt, EncryptedFrom, Pending, }; use stack_encrypt::{StackCipher, StackCipherText}; -use stack_kms::FakeDataKeySource; /// "An encrypted `u32`, stored as its ciphertext plus an equality term and an /// ORE term." The same shape as an EQL `integer_ord_ore` payload, minus the @@ -88,10 +89,7 @@ async fn main() -> Result<(), Box> { // leaf sealed under its own data key) and SEM term derivation under the // keyset's index key, which `init` loads. Data keys and terms are bound to // the same keyset by construction — there is no way to mix them up. - let cipher = StackCipher::builder() - .kms(FakeDataKeySource::new()) - .init() - .await?; + let cipher = StackCipher::new().await?; // --- Write side: encrypt a column of ages ------------------------------- @@ -129,13 +127,9 @@ async fn main() -> Result<(), Box> { // --- Read side: decrypt only the rows a query matched ------------------- - // The fake source is deterministic, so a fresh instance re-derives the - // same data keys (production: any client holding the same ZeroKMS - // credentials and keyset). - let decryptor = StackCipher::builder() - .kms(FakeDataKeySource::new()) - .init() - .await?; + // A separate client: any process holding the same ZeroKMS credentials and + // keyset can decrypt what this one wrote. + let decryptor = StackCipher::new().await?; // Collect the matching rows and decrypt them together: one batched // retrieve_keys call, however many rows matched. The context must match diff --git a/packages/stack-encrypt/examples/mixed_user.rs b/packages/stack-encrypt/examples/mixed_user.rs index a207c35cd..cd16b43a4 100644 --- a/packages/stack-encrypt/examples/mixed_user.rs +++ b/packages/stack-encrypt/examples/mixed_user.rs @@ -24,13 +24,14 @@ //! cargo run -p stack-encrypt --example mixed_user //! ``` //! -//! Uses `FakeDataKeySource`, so no ZeroKMS credentials or network are needed. +//! Talks to real ZeroKMS: needs `CS_CLIENT_ID` / `CS_CLIENT_KEY` and access-key +//! or device-session credentials in the environment (see the `zerokms_auth` +//! example for where they come from). use stack_encrypt::{ Cipher, CipherText, Decipher, Decrypt, Encrypt, IntoAad, StackCipher, StackCipherText, Unspecified, }; -use stack_kms::FakeDataKeySource; use vitaminc_aead::{DecipherVisitor, MapAccess, MapCipher, Passthrough}; // --- The record type --------------------------------------------------------- @@ -142,10 +143,7 @@ fn describe(ciphertext: &StackCipherText, indent: usize) { #[tokio::main(flavor = "current_thread")] async fn main() -> Result<(), Box> { - let cipher = StackCipher::builder() - .kms(FakeDataKeySource::new()) - .init() - .await?; + let cipher = StackCipher::new().await?; let users = vec![ User { diff --git a/packages/stack-encrypt/examples/search_terms.rs b/packages/stack-encrypt/examples/search_terms.rs index 3655e84f8..33296bfd9 100644 --- a/packages/stack-encrypt/examples/search_terms.rs +++ b/packages/stack-encrypt/examples/search_terms.rs @@ -12,23 +12,19 @@ //! cargo run -p stack-encrypt --example search_terms //! ``` //! -//! Uses `FakeDataKeySource`, so no ZeroKMS credentials or network are needed. +//! Talks to real ZeroKMS: needs `CS_CLIENT_ID` / `CS_CLIENT_KEY` and access-key +//! or device-session credentials in the environment (see the `zerokms_auth` +//! example for where they come from). use stack_encrypt::sem::{EqualityTerm, MatchTerm, OreTerm}; use stack_encrypt::target::EncryptExt; use stack_encrypt::StackCipher; -use stack_kms::FakeDataKeySource; #[tokio::main(flavor = "current_thread")] async fn main() -> Result<(), Box> { - // Production: `StackCipher::new()` builds a ZeroKMS client from the - // environment. Here the fake source stands in, deriving a deterministic - // index key. Either way the cipher loads its keyset's index key once, - // during construction. - let terms = StackCipher::builder() - .kms(FakeDataKeySource::new()) - .init() - .await?; + // `StackCipher::new()` builds a ZeroKMS client from the environment and + // loads the keyset's index key once, during construction. + let terms = StackCipher::new().await?; println!("cipher ready on keyset {}", terms.keyset_id()); // One cipher serves write time and query time; terms are deterministic diff --git a/packages/stack-encrypt/examples/zerokms_auth.rs b/packages/stack-encrypt/examples/zerokms_auth.rs index c82907ef3..37e0b1bf6 100644 --- a/packages/stack-encrypt/examples/zerokms_auth.rs +++ b/packages/stack-encrypt/examples/zerokms_auth.rs @@ -1,8 +1,7 @@ //! Wiring a cipher to real ZeroKMS, and to a custom authentication strategy. //! -//! Every other example uses `FakeDataKeySource`, which needs no credentials. -//! This one shows the production path: where the credentials come from, and -//! how to substitute your own when the defaults do not fit. +//! Every example talks to real ZeroKMS. This one shows where the credentials +//! come from, and how to substitute your own when the defaults do not fit. //! //! Run with: //! diff --git a/packages/stack-encrypt/src/lib.rs b/packages/stack-encrypt/src/lib.rs index 5a6f3af3e..1394803af 100644 --- a/packages/stack-encrypt/src/lib.rs +++ b/packages/stack-encrypt/src/lib.rs @@ -53,12 +53,14 @@ //! //! # Testing without ZeroKMS //! -//! `stack_kms::FakeDataKeySource` is an in-process key source that needs no +//! `stack_kms::FakeDataKeySource` is an in-memory stub that needs no //! credentials or network: it hands out a fresh random data key per request and //! remembers it in memory, so a `generate` followed by the matching `retrieve` //! round-trips within one process (the key material itself differs run to run, -//! and nothing survives the process). It lives behind stack-kms's -//! `test-support` feature, so add +//! and nothing survives the process). It models none of ZeroKMS's +//! authorization behaviour (context, identity claims, decryption policies) — +//! those are the service's, and tests of them belong against a real ZeroKMS. +//! It lives behind stack-kms's `test-support` feature, so add //! `stack-kms = { version = "..", features = ["test-support"] }` to your //! `[dev-dependencies]`: //! From 426a84bf9698fdfe0066fdcd5d51e2f25e65b00f Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Thu, 27 Aug 2026 11:26:54 +1000 Subject: [PATCH 433/686] refactor(stack-encrypt): split the target layer into pending and request modules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `src/target.rs` had grown to hold four separable things: the traits, the request/response plumbing, `Pending`, and the leaf/structural impls. The middle two are pure data structures with no I/O and no cipher, and their rules — one queue per request kind, in request order; a fulfilment scoped to exactly the responses its own requests asked for — were only reachable through an integration test that had to stand up a cipher first. Move them out: * `target/request.rs` — `Request`, `Responses`, and the per-kind tally `Pending::request` uses to size a fulfilment's view. * `target/pending.rs` — `Pending`, `PendingFuture`, the `IntoFuture` impl and `dispatch`, the one place the target layer talks to ZeroKMS. * `target/mod.rs` — the traits and the leaf/structural implementations, re-exporting all four public types so no path changes for callers. `RequestKind` is no longer reachable by field access across the split, so `Request` grew a `pub(super) into_kind`; `dispatch` builds its result through a `pub(super) Responses::new` rather than a struct literal. Neither is on the public surface. With the split, both halves carry unit tests: 16 in `request.rs` pinning response ordering, per-kind separation, and that `split_front` neither over-draws nor consumes on failure; 18 in `pending.rs` pinning the batching claim (one call per request kind however many pendings merged), `map`/`zip`/`all` composition and error propagation, and that zipped fulfilments never draw each other's key material. Claude-Session: https://claude.ai/code/session_01HU1Bbw4eQp9kEEneXxDcKr --- .../src/{target.rs => target/mod.rs} | 311 +--------- packages/stack-encrypt/src/target/pending.rs | 554 ++++++++++++++++++ packages/stack-encrypt/src/target/request.rs | 350 +++++++++++ 3 files changed, 911 insertions(+), 304 deletions(-) rename packages/stack-encrypt/src/{target.rs => target/mod.rs} (66%) create mode 100644 packages/stack-encrypt/src/target/pending.rs create mode 100644 packages/stack-encrypt/src/target/request.rs diff --git a/packages/stack-encrypt/src/target.rs b/packages/stack-encrypt/src/target/mod.rs similarity index 66% rename from packages/stack-encrypt/src/target.rs rename to packages/stack-encrypt/src/target/mod.rs index 2c325b92b..00fe72ef6 100644 --- a/packages/stack-encrypt/src/target.rs +++ b/packages/stack-encrypt/src/target/mod.rs @@ -168,20 +168,19 @@ //! [`OreTerm`]: crate::sem::OreTerm //! [`OpeTerm`]: crate::sem::OpeTerm -use std::borrow::Cow; -use std::collections::VecDeque; -use std::future::{Future, IntoFuture}; -use std::pin::Pin; - -use stack_kms::{ - DataKey, DataKeySource, DataKeyWithTag, GenerateKeyPayload, Iv, MaybeSend, RetrieveKeyPayload, -}; +use stack_kms::MaybeSend; use vitaminc_aead::{CipherText, Decrypt, Encrypt, IntoAad}; use vitaminc_prf::IntoPrfContext; use crate::cipher::{bind_keys, StackDecipher}; use crate::{Error, StackCipher, StackCipherText}; +mod pending; +mod request; + +pub use pending::{Pending, PendingFuture}; +pub use request::{Request, Responses}; + // ============================================================================= // Contexts // ============================================================================= @@ -377,302 +376,6 @@ impl DecryptExt for S { T::decrypt_from(self, cipher, context) } } - -// ============================================================================= -// Requests and responses -// ============================================================================= - -/// One unit of ZeroKMS work a [`Pending`] needs: constructible, otherwise -/// opaque, so new request kinds (a PRF derivation, a keyset override) can be -/// added without breaking implementations. -#[derive(Debug, Clone)] -pub struct Request(RequestKind); - -#[derive(Debug, Clone)] -enum RequestKind { - /// Generate one fresh data key under the cipher's keyset. - GenerateDataKey, - /// Re-derive the data key identified by `iv` + `tag`. - RetrieveDataKey { iv: Iv, tag: Vec }, -} - -impl Request { - /// Request one fresh data key (encrypt side). - pub fn generate_data_key() -> Self { - Self(RequestKind::GenerateDataKey) - } - - /// Request re-derivation of the data key identified by `iv` + `tag` - /// (decrypt side). - pub fn retrieve_data_key(iv: Iv, tag: Vec) -> Self { - Self(RequestKind::RetrieveDataKey { iv, tag }) - } -} - -/// The responses a fulfilment draws from — one queue per request kind, in -/// request order. A fulfilment sees exactly the responses its own requests -/// asked for (never a neighbour's), and drawing past that is -/// [`Error::ResponseShape`]. -pub struct Responses { - generated: VecDeque, - retrieved: VecDeque, -} - -impl Responses { - /// The next generated data key, in [`Request::generate_data_key`] order. - pub fn next_generated_key(&mut self) -> Result { - self.generated.pop_front().ok_or(Error::ResponseShape) - } - - /// The next retrieved data key, in [`Request::retrieve_data_key`] order. - pub fn next_retrieved_key(&mut self) -> Result { - self.retrieved.pop_front().ok_or(Error::ResponseShape) - } - - /// Split off the first `generated` + `retrieved` responses — the - /// per-fulfilment view [`Pending::request`] scopes each fulfilment to. - fn split_front(&mut self, generated: usize, retrieved: usize) -> Result { - if self.generated.len() < generated || self.retrieved.len() < retrieved { - return Err(Error::ResponseShape); - } - Ok(Responses { - generated: self.generated.drain(..generated).collect(), - retrieved: self.retrieved.drain(..retrieved).collect(), - }) - } - - pub(crate) fn drain_generated(&mut self) -> impl Iterator + '_ { - self.generated.drain(..) - } - - pub(crate) fn drain_retrieved(&mut self) -> impl Iterator + '_ { - self.retrieved.drain(..) - } -} - -// ============================================================================= -// Pending -// ============================================================================= - -/// The boxed fulfilment: consumes this pending's slice of the responses and -/// produces the output. The `Send` split mirrors [`stack_kms::MaybeSend`] — -/// the underlying ZeroKMS futures are not `Send` on wasm32. -#[cfg(not(target_arch = "wasm32"))] -type FulfilBox<'a, T> = Box Result + Send + 'a>; -/// See the native definition above; identical minus the `Send` bound. -#[cfg(target_arch = "wasm32")] -type FulfilBox<'a, T> = Box Result + 'a>; - -/// The boxed future a [`Pending`] settles through; `Send` split as above. -#[cfg(not(target_arch = "wasm32"))] -pub type PendingFuture<'a, T> = Pin> + Send + 'a>>; -/// See the native definition above; identical minus the `Send` bound. -#[cfg(target_arch = "wasm32")] -pub type PendingFuture<'a, T> = Pin> + 'a>>; - -/// A request carrier resolving to `T`: [`StackCipher`]'s -/// [`EncryptTarget::Output`] / [`DecryptTarget::Output`]. -/// -/// **Not a future** until awaited. A `Pending` holds the ZeroKMS requests its -/// value needs plus the fulfilment that shapes the responses; combining -/// pendings ([`zip`](Self::zip), [`map`](Self::map), [`all`](Self::all)) -/// merges requests *without doing any I/O*, which is where batching comes -/// from: however many pendings are merged, awaiting the result issues **one** -/// batched ZeroKMS call per request kind and then runs every fulfilment over -/// the shared response set. -/// -/// Construct leaves with [`ready`](Self::ready) (value already derived, -/// nothing to request) or [`request`](Self::request) (value needs ZeroKMS -/// responses). -pub struct Pending<'a, T, K> { - cipher: &'a StackCipher, - requests: Vec, - fulfil: FulfilBox<'a, T>, -} - -impl<'a, T: 'a, K> Pending<'a, T, K> { - /// A pending with no requests: `result` was fully derived during the - /// synchronous build. Awaiting it does no I/O. - pub fn ready(cipher: &'a StackCipher, result: Result) -> Self - where - T: MaybeSend, - { - Self { - cipher, - requests: Vec::new(), - fulfil: Box::new(move |_| result), - } - } - - /// A pending whose value needs ZeroKMS responses. `fulfil` runs after the - /// batched call, scoped to exactly the responses `requests` asked for — - /// drawing more (or another kind) is [`Error::ResponseShape`], and can - /// never consume a sibling pending's responses. - pub fn request(cipher: &'a StackCipher, requests: Vec, fulfil: F) -> Self - where - F: FnOnce(&mut Responses) -> Result + MaybeSend + 'a, - { - let (mut generated, mut retrieved) = (0usize, 0usize); - for request in &requests { - match request.0 { - RequestKind::GenerateDataKey => generated += 1, - RequestKind::RetrieveDataKey { .. } => retrieved += 1, - } - } - Self { - cipher, - requests, - fulfil: Box::new(move |responses| { - let mut own = responses.split_front(generated, retrieved)?; - fulfil(&mut own) - }), - } - } - - /// Transform the resolved value. No I/O, no new requests. - pub fn map(self, f: F) -> Pending<'a, U, K> - where - F: FnOnce(T) -> U + MaybeSend + 'a, - { - let fulfil = self.fulfil; - Pending { - cipher: self.cipher, - requests: self.requests, - fulfil: Box::new(move |responses| fulfil(responses).map(f)), - } - } - - /// Merge two pendings into one resolving to the pair. Their requests - /// concatenate — awaiting the result is still one batched call per - /// request kind. Both must come from the same cipher. - pub fn zip(mut self, other: Pending<'a, U, K>) -> Pending<'a, (T, U), K> { - debug_assert!( - std::ptr::eq(self.cipher, other.cipher), - "zipped pendings must be built from the same cipher" - ); - self.requests.extend(other.requests); - let first = self.fulfil; - let second = other.fulfil; - Pending { - cipher: self.cipher, - requests: self.requests, - fulfil: Box::new(move |responses| Ok((first(responses)?, second(responses)?))), - } - } - - /// Merge any number of same-typed pendings into one resolving to the - /// `Vec` — [`zip`](Self::zip) at scale, used by the `Vec` - /// implementations to make a whole column one batched call. - pub fn all( - cipher: &'a StackCipher, - items: Vec>, - ) -> Pending<'a, Vec, K> { - let mut requests = Vec::new(); - let mut fulfils = Vec::with_capacity(items.len()); - for item in items { - debug_assert!( - std::ptr::eq(cipher, item.cipher), - "merged pendings must be built from the same cipher" - ); - requests.extend(item.requests); - fulfils.push(item.fulfil); - } - Pending { - cipher, - requests, - fulfil: Box::new(move |responses| { - fulfils - .into_iter() - .map(|fulfil| fulfil(responses)) - .collect() - }), - } - } -} - -impl<'a, T: 'a, K> IntoFuture for Pending<'a, T, K> -where - K: DataKeySource + Sync, -{ - type Output = Result; - type IntoFuture = PendingFuture<'a, T>; - - /// The only place I/O happens: one batched ZeroKMS call per request kind - /// (none at all for an all-[`ready`](Pending::ready) assembly), then the - /// fulfilments shape the responses. - fn into_future(self) -> Self::IntoFuture { - Box::pin(async move { - let mut responses = dispatch(self.cipher, self.requests).await?; - (self.fulfil)(&mut responses) - }) - } -} - -/// Issue the batched ZeroKMS calls for `requests`: at most one -/// `generate_keys` and one `retrieve_keys`, whatever the request count. When -/// ZeroKMS grows a combined operation (data keys + PRF derivations in one -/// round-trip), this is the one place that changes. -async fn dispatch( - cipher: &StackCipher, - requests: Vec, -) -> Result { - let mut generate = 0usize; - let mut retrieves: Vec<(Iv, Vec)> = Vec::new(); - for request in requests { - match request.0 { - RequestKind::GenerateDataKey => generate += 1, - RequestKind::RetrieveDataKey { iv, tag } => retrieves.push((iv, tag)), - } - } - - let generated = if generate == 0 { - Vec::new() - } else { - // Empty descriptor + empty context for every leaf — see the - // wire-format note in the cipher module docs. - let payloads: Vec> = (0..generate) - .map(|_| GenerateKeyPayload::new("", Cow::Owned(Vec::new()))) - .collect(); - let keys = cipher - .kms() - .generate_keys(payloads, Some(cipher.keyset_id()), None) - .await?; - if keys.len() != generate { - return Err(Error::KeyCountMismatch { - expected: generate, - received: keys.len(), - }); - } - keys - }; - - let retrieved = if retrieves.is_empty() { - Vec::new() - } else { - let payloads: Vec> = retrieves - .iter() - .map(|(iv, tag)| RetrieveKeyPayload::new(*iv, "", tag)) - .collect(); - let expected = payloads.len(); - let keys = cipher - .kms() - .retrieve_keys(payloads, Some(cipher.keyset_id()), None) - .await?; - if keys.len() != expected { - return Err(Error::KeyCountMismatch { - expected, - received: keys.len(), - }); - } - keys - }; - - Ok(Responses { - generated: generated.into(), - retrieved: retrieved.into(), - }) -} - // ============================================================================= // Leaf implementations: the record ciphertext // ============================================================================= diff --git a/packages/stack-encrypt/src/target/pending.rs b/packages/stack-encrypt/src/target/pending.rs new file mode 100644 index 000000000..92ee7ff8b --- /dev/null +++ b/packages/stack-encrypt/src/target/pending.rs @@ -0,0 +1,554 @@ +//! [`Pending`]: the request carrier [`StackCipher`] hands back from +//! [`EncryptedFrom`](super::EncryptedFrom) / [`DecryptedFrom`](super::DecryptedFrom), +//! and the single place the target layer talks to ZeroKMS. +//! +//! A `Pending` is built synchronously and settled once. Combining pendings +//! merges their [`Request`]s without doing any I/O; awaiting the combined +//! result issues one batched ZeroKMS call per request kind +//! ([`dispatch`]) and then runs each fulfilment over exactly the +//! [`Responses`] its own requests asked for. + +use std::borrow::Cow; +use std::future::{Future, IntoFuture}; +use std::pin::Pin; + +use stack_kms::{DataKeySource, GenerateKeyPayload, Iv, MaybeSend, RetrieveKeyPayload}; + +use super::request::{tally, Request, RequestKind, Responses}; +use crate::{Error, StackCipher}; + +/// The boxed fulfilment: consumes this pending's slice of the responses and +/// produces the output. The `Send` split mirrors [`stack_kms::MaybeSend`] — +/// the underlying ZeroKMS futures are not `Send` on wasm32. +#[cfg(not(target_arch = "wasm32"))] +type FulfilBox<'a, T> = Box Result + Send + 'a>; +/// See the native definition above; identical minus the `Send` bound. +#[cfg(target_arch = "wasm32")] +type FulfilBox<'a, T> = Box Result + 'a>; + +/// The boxed future a [`Pending`] settles through; `Send` split as above. +#[cfg(not(target_arch = "wasm32"))] +pub type PendingFuture<'a, T> = Pin> + Send + 'a>>; +/// See the native definition above; identical minus the `Send` bound. +#[cfg(target_arch = "wasm32")] +pub type PendingFuture<'a, T> = Pin> + 'a>>; + +/// A request carrier resolving to `T`: [`StackCipher`]'s +/// [`EncryptTarget::Output`](super::EncryptTarget::Output) / +/// [`DecryptTarget::Output`](super::DecryptTarget::Output). +/// +/// **Not a future** until awaited. A `Pending` holds the ZeroKMS requests its +/// value needs plus the fulfilment that shapes the responses; combining +/// pendings ([`zip`](Self::zip), [`map`](Self::map), [`all`](Self::all)) +/// merges requests *without doing any I/O*, which is where batching comes +/// from: however many pendings are merged, awaiting the result issues **one** +/// batched ZeroKMS call per request kind and then runs every fulfilment over +/// the shared response set. +/// +/// Construct leaves with [`ready`](Self::ready) (value already derived, +/// nothing to request) or [`request`](Self::request) (value needs ZeroKMS +/// responses). +pub struct Pending<'a, T, K> { + cipher: &'a StackCipher, + requests: Vec, + fulfil: FulfilBox<'a, T>, +} + +impl<'a, T: 'a, K> Pending<'a, T, K> { + /// A pending with no requests: `result` was fully derived during the + /// synchronous build. Awaiting it does no I/O. + pub fn ready(cipher: &'a StackCipher, result: Result) -> Self + where + T: MaybeSend, + { + Self { + cipher, + requests: Vec::new(), + fulfil: Box::new(move |_| result), + } + } + + /// A pending whose value needs ZeroKMS responses. `fulfil` runs after the + /// batched call, scoped to exactly the responses `requests` asked for — + /// drawing more (or another kind) is [`Error::ResponseShape`], and can + /// never consume a sibling pending's responses. + pub fn request(cipher: &'a StackCipher, requests: Vec, fulfil: F) -> Self + where + F: FnOnce(&mut Responses) -> Result + MaybeSend + 'a, + { + let (generated, retrieved) = tally(&requests); + Self { + cipher, + requests, + fulfil: Box::new(move |responses| { + let mut own = responses.split_front(generated, retrieved)?; + fulfil(&mut own) + }), + } + } + + /// Transform the resolved value. No I/O, no new requests. + pub fn map(self, f: F) -> Pending<'a, U, K> + where + F: FnOnce(T) -> U + MaybeSend + 'a, + { + let fulfil = self.fulfil; + Pending { + cipher: self.cipher, + requests: self.requests, + fulfil: Box::new(move |responses| fulfil(responses).map(f)), + } + } + + /// Merge two pendings into one resolving to the pair. Their requests + /// concatenate — awaiting the result is still one batched call per + /// request kind. Both must come from the same cipher. + pub fn zip(mut self, other: Pending<'a, U, K>) -> Pending<'a, (T, U), K> { + debug_assert!( + std::ptr::eq(self.cipher, other.cipher), + "zipped pendings must be built from the same cipher" + ); + self.requests.extend(other.requests); + let first = self.fulfil; + let second = other.fulfil; + Pending { + cipher: self.cipher, + requests: self.requests, + fulfil: Box::new(move |responses| Ok((first(responses)?, second(responses)?))), + } + } + + /// Merge any number of same-typed pendings into one resolving to the + /// `Vec` — [`zip`](Self::zip) at scale, used by the `Vec` + /// implementations to make a whole column one batched call. + pub fn all( + cipher: &'a StackCipher, + items: Vec>, + ) -> Pending<'a, Vec, K> { + let mut requests = Vec::new(); + let mut fulfils = Vec::with_capacity(items.len()); + for item in items { + debug_assert!( + std::ptr::eq(cipher, item.cipher), + "merged pendings must be built from the same cipher" + ); + requests.extend(item.requests); + fulfils.push(item.fulfil); + } + Pending { + cipher, + requests, + fulfil: Box::new(move |responses| { + fulfils + .into_iter() + .map(|fulfil| fulfil(responses)) + .collect() + }), + } + } +} + +impl<'a, T: 'a, K> IntoFuture for Pending<'a, T, K> +where + K: DataKeySource + Sync, +{ + type Output = Result; + type IntoFuture = PendingFuture<'a, T>; + + /// The only place I/O happens: one batched ZeroKMS call per request kind + /// (none at all for an all-[`ready`](Pending::ready) assembly), then the + /// fulfilments shape the responses. + fn into_future(self) -> Self::IntoFuture { + Box::pin(async move { + let mut responses = dispatch(self.cipher, self.requests).await?; + (self.fulfil)(&mut responses) + }) + } +} + +/// Issue the batched ZeroKMS calls for `requests`: at most one +/// `generate_keys` and one `retrieve_keys`, whatever the request count. When +/// ZeroKMS grows a combined operation (data keys + PRF derivations in one +/// round-trip), this is the one place that changes. +async fn dispatch( + cipher: &StackCipher, + requests: Vec, +) -> Result { + let mut generate = 0usize; + let mut retrieves: Vec<(Iv, Vec)> = Vec::new(); + for request in requests { + match request.into_kind() { + RequestKind::GenerateDataKey => generate += 1, + RequestKind::RetrieveDataKey { iv, tag } => retrieves.push((iv, tag)), + } + } + + let generated = if generate == 0 { + Vec::new() + } else { + // Empty descriptor + empty context for every leaf — see the + // wire-format note in the cipher module docs. + let payloads: Vec> = (0..generate) + .map(|_| GenerateKeyPayload::new("", Cow::Owned(Vec::new()))) + .collect(); + let keys = cipher + .kms() + .generate_keys(payloads, Some(cipher.keyset_id()), None) + .await?; + if keys.len() != generate { + return Err(Error::KeyCountMismatch { + expected: generate, + received: keys.len(), + }); + } + keys + }; + + let retrieved = if retrieves.is_empty() { + Vec::new() + } else { + let payloads: Vec> = retrieves + .iter() + .map(|(iv, tag)| RetrieveKeyPayload::new(*iv, "", tag)) + .collect(); + let expected = payloads.len(); + let keys = cipher + .kms() + .retrieve_keys(payloads, Some(cipher.keyset_id()), None) + .await?; + if keys.len() != expected { + return Err(Error::KeyCountMismatch { + expected, + received: keys.len(), + }); + } + keys + }; + + Ok(Responses::new(generated, retrieved)) +} + +#[cfg(test)] +mod tests { + #![allow(clippy::unwrap_used, clippy::panic)] + + use std::sync::atomic::{AtomicUsize, Ordering}; + + use stack_kms::{FakeDataKeySource, IdentifiedBy, IndexKey, IndexKeySource, UnverifiedContext}; + use uuid::Uuid; + + use super::*; + + /// Counts ZeroKMS *calls* (not keys) so the batching claim — one call per + /// request kind however many pendings were merged — is testable. Delegates + /// everything else to the stub. + #[derive(Default)] + struct CountingSource { + inner: FakeDataKeySource, + generate_calls: AtomicUsize, + retrieve_calls: AtomicUsize, + } + + impl CountingSource { + fn generate_calls(&self) -> usize { + self.generate_calls.load(Ordering::Relaxed) + } + + fn retrieve_calls(&self) -> usize { + self.retrieve_calls.load(Ordering::Relaxed) + } + } + + impl DataKeySource for CountingSource { + async fn generate_keys( + &self, + payloads: Vec>, + keyset_id: Option, + unverified_context: Option>, + ) -> Result, stack_kms::Error> { + self.generate_calls.fetch_add(1, Ordering::Relaxed); + self.inner + .generate_keys(payloads, keyset_id, unverified_context) + .await + } + + async fn retrieve_keys( + &self, + payloads: Vec>, + keyset_id: Option, + unverified_context: Option<&UnverifiedContext>, + ) -> Result, stack_kms::Error> { + self.retrieve_calls.fetch_add(1, Ordering::Relaxed); + self.inner + .retrieve_keys(payloads, keyset_id, unverified_context) + .await + } + } + + impl IndexKeySource for CountingSource { + async fn load_index_key( + &self, + keyset_id: Option, + ) -> Result<(Uuid, IndexKey), stack_kms::Error> { + self.inner.load_index_key(keyset_id).await + } + } + + async fn cipher() -> StackCipher { + StackCipher::builder() + .kms(CountingSource::default()) + .init() + .await + .unwrap() + } + + /// A pending that asks for `n` data keys and resolves to their tags. + fn generating<'a>( + cipher: &'a StackCipher, + n: usize, + ) -> Pending<'a, Vec>, CountingSource> { + let requests = std::iter::repeat_with(Request::generate_data_key) + .take(n) + .collect(); + Pending::request(cipher, requests, move |responses| { + (0..n) + .map(|_| responses.next_generated_key().map(|key| key.tag)) + .collect() + }) + } + + #[tokio::test] + async fn a_ready_pending_resolves_without_any_io() { + let cipher = cipher().await; + let value: u32 = Pending::ready(&cipher, Ok(7)).await.unwrap(); + + assert_eq!(value, 7); + assert_eq!(cipher.kms().generate_calls(), 0); + assert_eq!(cipher.kms().retrieve_calls(), 0); + } + + #[tokio::test] + async fn a_ready_pending_propagates_its_error() { + let cipher = cipher().await; + let result: Result = Pending::ready(&cipher, Err(Error::EmptyContext)).await; + + assert!(matches!(result, Err(Error::EmptyContext))); + assert_eq!(cipher.kms().generate_calls(), 0); + } + + #[tokio::test] + async fn map_transforms_the_resolved_value() { + let cipher = cipher().await; + let value = Pending::ready(&cipher, Ok(7u32)) + .map(|v| v * 3) + .await + .unwrap(); + + assert_eq!(value, 21); + } + + #[tokio::test] + async fn map_does_not_run_on_an_error() { + let cipher = cipher().await; + let result: Result = Pending::ready(&cipher, Err(Error::EmptyContext)) + .map(|_: u32| panic!("map must not run on an error")) + .await; + + assert!(matches!(result, Err(Error::EmptyContext))); + } + + #[tokio::test] + async fn map_carries_the_requests_through() { + let cipher = cipher().await; + let tags = generating(&cipher, 3).map(|tags| tags.len()).await.unwrap(); + + assert_eq!(tags, 3); + assert_eq!(cipher.kms().generate_calls(), 1); + } + + #[tokio::test] + async fn one_pending_asking_for_many_keys_is_one_call() { + let cipher = cipher().await; + let tags = generating(&cipher, 5).await.unwrap(); + + assert_eq!(tags.len(), 5); + assert_eq!(cipher.kms().generate_calls(), 1); + } + + #[tokio::test] + async fn zip_merges_requests_into_one_call() { + let cipher = cipher().await; + let (left, right) = generating(&cipher, 2) + .zip(generating(&cipher, 3)) + .await + .unwrap(); + + assert_eq!((left.len(), right.len()), (2, 3)); + assert_eq!(cipher.kms().generate_calls(), 1); + } + + /// The scoping guarantee at the `Pending` level: zipped fulfilments draw + /// disjoint response slices, in build order. + #[tokio::test] + async fn zipped_fulfilments_never_share_key_material() { + let cipher = cipher().await; + let (left, right) = generating(&cipher, 2) + .zip(generating(&cipher, 2)) + .await + .unwrap(); + + for tag in &left { + assert!(!right.contains(tag), "a sibling drew the same key"); + } + } + + #[tokio::test] + async fn zip_of_two_ready_pendings_does_no_io() { + let cipher = cipher().await; + let pair = Pending::ready(&cipher, Ok(1u32)) + .zip(Pending::ready(&cipher, Ok("two"))) + .await + .unwrap(); + + assert_eq!(pair, (1, "two")); + assert_eq!(cipher.kms().generate_calls(), 0); + } + + #[tokio::test] + async fn zip_propagates_an_error_from_either_side() { + let cipher = cipher().await; + let result = Pending::ready(&cipher, Err(Error::EmptyContext)) + .zip(Pending::ready(&cipher, Ok(1u32))) + .await; + assert!(matches!(result, Err::<(u32, u32), _>(Error::EmptyContext))); + + let result = Pending::ready(&cipher, Ok(1u32)) + .zip(Pending::ready(&cipher, Err(Error::EmptyContext))) + .await; + assert!(matches!(result, Err::<(u32, u32), _>(Error::EmptyContext))); + } + + #[tokio::test] + async fn all_merges_a_column_into_one_call_preserving_order() { + let cipher = cipher().await; + let items = (0..5).map(|_| generating(&cipher, 1)).collect(); + let column = Pending::all(&cipher, items).await.unwrap(); + + assert_eq!(column.len(), 5); + assert_eq!(cipher.kms().generate_calls(), 1); + + // Every row drew its own key. + let mut tags: Vec<&Vec> = column.iter().flatten().collect(); + tags.sort(); + tags.dedup(); + assert_eq!(tags.len(), 5); + } + + #[tokio::test] + async fn all_of_nothing_resolves_empty_without_io() { + let cipher = cipher().await; + let column: Vec = Pending::all(&cipher, Vec::new()).await.unwrap(); + + assert!(column.is_empty()); + assert_eq!(cipher.kms().generate_calls(), 0); + } + + #[tokio::test] + async fn all_propagates_the_first_error() { + let cipher = cipher().await; + let items = vec![ + Pending::ready(&cipher, Ok(1u32)), + Pending::ready(&cipher, Err(Error::EmptyContext)), + ]; + let result = Pending::all(&cipher, items).await; + + assert!(matches!(result, Err::, _>(Error::EmptyContext))); + } + + /// Over-drawing is the fulfilment's own error, not a stolen sibling key: + /// the second pending still resolves to the key it asked for. + #[tokio::test] + async fn over_drawing_responses_is_a_response_shape_error() { + let cipher = cipher().await; + let greedy: Pending<'_, Vec, _> = + Pending::request(&cipher, vec![Request::generate_data_key()], |responses| { + let _ = responses.next_generated_key()?; + // One request, two draws. + responses.next_generated_key().map(|key| key.tag) + }); + let result = greedy.zip(generating(&cipher, 1)).await; + + assert!(matches!( + result, + Err::<(Vec, Vec>), _>(Error::ResponseShape) + )); + } + + /// A pending that under-draws leaves its unused responses behind rather + /// than shifting every sibling's slice. + #[tokio::test] + async fn under_drawing_does_not_shift_a_siblings_responses() { + let cipher = cipher().await; + let lazy: Pending<'_, (), _> = + Pending::request(&cipher, vec![Request::generate_data_key()], |_| Ok(())); + let ((), tags) = lazy.zip(generating(&cipher, 1)).await.unwrap(); + + assert_eq!(tags.len(), 1); + assert_eq!(cipher.kms().generate_calls(), 1); + } + + #[tokio::test] + async fn a_pending_with_no_requests_dispatches_nothing() { + let cipher = cipher().await; + let value: u32 = Pending::request(&cipher, Vec::new(), |_| Ok(9)) + .await + .unwrap(); + + assert_eq!(value, 9); + assert_eq!(cipher.kms().generate_calls(), 0); + assert_eq!(cipher.kms().retrieve_calls(), 0); + } + + /// A pending that asks for `n` data keys and resolves to the `(iv, tag)` + /// pairs needed to retrieve them again. + fn generating_pairs( + cipher: &StackCipher, + n: usize, + ) -> Pending<'_, Vec<(Iv, Vec)>, CountingSource> { + let requests = std::iter::repeat_with(Request::generate_data_key) + .take(n) + .collect(); + Pending::request(cipher, requests, move |responses| { + (0..n) + .map(|_| { + responses + .next_generated_key() + .map(|key| (key.key.iv, key.tag)) + }) + .collect() + }) + } + + /// The two request kinds dispatch independently: mixing them in one + /// awaited assembly is one `generate_keys` *and* one `retrieve_keys`. + #[tokio::test] + async fn generate_and_retrieve_are_one_call_each() { + let cipher = cipher().await; + let pairs = generating_pairs(&cipher, 2).await.unwrap(); + assert_eq!(cipher.kms().generate_calls(), 1); + + let requests: Vec = pairs + .iter() + .map(|(iv, tag)| Request::retrieve_data_key(*iv, tag.clone())) + .collect(); + let retrieve: Pending<'_, usize, _> = Pending::request(&cipher, requests, |responses| { + Ok(responses.drain_retrieved().count()) + }); + let (count, fresh) = retrieve.zip(generating(&cipher, 1)).await.unwrap(); + + assert_eq!(count, 2); + assert_eq!(fresh.len(), 1); + assert_eq!(cipher.kms().generate_calls(), 2); + assert_eq!(cipher.kms().retrieve_calls(), 1); + } +} diff --git a/packages/stack-encrypt/src/target/request.rs b/packages/stack-encrypt/src/target/request.rs new file mode 100644 index 000000000..b3581f746 --- /dev/null +++ b/packages/stack-encrypt/src/target/request.rs @@ -0,0 +1,350 @@ +//! The ZeroKMS work a [`Pending`](super::Pending) carries, and the responses +//! it settles against. +//! +//! A [`Request`] is one unit of work — "generate a data key", "re-derive the +//! data key identified by this `iv` + `tag`". Requests accumulate as pendings +//! are combined, and awaiting the combined [`Pending`](super::Pending) turns +//! the whole accumulated list into **one** ZeroKMS call per kind. +//! [`Responses`] is what comes back: one queue per kind, in request order. +//! +//! The two halves are deliberately dumb — no I/O, no cipher, no futures — so +//! the response-scoping rules that keep one fulfilment from consuming a +//! sibling's key material are unit-testable on their own. + +use std::collections::VecDeque; + +use stack_kms::{DataKey, DataKeyWithTag, Iv}; + +use crate::Error; + +/// One unit of ZeroKMS work a [`Pending`](super::Pending) needs: +/// constructible, otherwise opaque, so new request kinds (a PRF derivation, a +/// keyset override) can be added without breaking implementations. +#[derive(Debug, Clone)] +pub struct Request(RequestKind); + +#[derive(Debug, Clone)] +pub(super) enum RequestKind { + /// Generate one fresh data key under the cipher's keyset. + GenerateDataKey, + /// Re-derive the data key identified by `iv` + `tag`. + RetrieveDataKey { iv: Iv, tag: Vec }, +} + +impl Request { + /// Request one fresh data key (encrypt side). + pub fn generate_data_key() -> Self { + Self(RequestKind::GenerateDataKey) + } + + /// Request re-derivation of the data key identified by `iv` + `tag` + /// (decrypt side). + pub fn retrieve_data_key(iv: Iv, tag: Vec) -> Self { + Self(RequestKind::RetrieveDataKey { iv, tag }) + } + + /// Consume the request, yielding what it asks for. + pub(super) fn into_kind(self) -> RequestKind { + self.0 + } +} + +/// How many requests of each kind `requests` holds, as +/// `(generate, retrieve)` — the shape a fulfilment is scoped to. +pub(super) fn tally(requests: &[Request]) -> (usize, usize) { + let (mut generate, mut retrieve) = (0usize, 0usize); + for request in requests { + match request.0 { + RequestKind::GenerateDataKey => generate += 1, + RequestKind::RetrieveDataKey { .. } => retrieve += 1, + } + } + (generate, retrieve) +} + +/// The responses a fulfilment draws from — one queue per request kind, in +/// request order. A fulfilment sees exactly the responses its own requests +/// asked for (never a neighbour's), and drawing past that is +/// [`Error::ResponseShape`]. +pub struct Responses { + generated: VecDeque, + retrieved: VecDeque, +} + +impl Responses { + /// The full response set of one batched dispatch, in request order. + pub(super) fn new(generated: Vec, retrieved: Vec) -> Self { + Self { + generated: generated.into(), + retrieved: retrieved.into(), + } + } + + /// The next generated data key, in [`Request::generate_data_key`] order. + pub fn next_generated_key(&mut self) -> Result { + self.generated.pop_front().ok_or(Error::ResponseShape) + } + + /// The next retrieved data key, in [`Request::retrieve_data_key`] order. + pub fn next_retrieved_key(&mut self) -> Result { + self.retrieved.pop_front().ok_or(Error::ResponseShape) + } + + /// Split off the first `generated` + `retrieved` responses — the + /// per-fulfilment view [`Pending::request`](super::Pending::request) + /// scopes each fulfilment to. Short of either count is + /// [`Error::ResponseShape`], and nothing is consumed. + pub(super) fn split_front( + &mut self, + generated: usize, + retrieved: usize, + ) -> Result { + if self.generated.len() < generated || self.retrieved.len() < retrieved { + return Err(Error::ResponseShape); + } + Ok(Responses { + generated: self.generated.drain(..generated).collect(), + retrieved: self.retrieved.drain(..retrieved).collect(), + }) + } + + pub(crate) fn drain_generated(&mut self) -> impl Iterator + '_ { + self.generated.drain(..) + } + + pub(crate) fn drain_retrieved(&mut self) -> impl Iterator + '_ { + self.retrieved.drain(..) + } +} + +#[cfg(test)] +mod tests { + #![allow(clippy::unwrap_used, clippy::panic)] + + use std::borrow::Cow; + + use stack_kms::{DataKeySource, FakeDataKeySource, GenerateKeyPayload, RetrieveKeyPayload}; + + use super::*; + + /// `n` real generated keys, plus the retrieved keys for the same `n` + /// (`iv`, `tag`) pairs — the stub round-trips, which is all these tests + /// need from it. + async fn key_pairs(n: usize) -> (Vec, Vec) { + let kms = FakeDataKeySource::new(); + let generated = kms + .generate_keys( + (0..n) + .map(|_| GenerateKeyPayload::new("", Cow::Owned(Vec::new()))) + .collect(), + None, + None, + ) + .await + .unwrap(); + let retrieved = kms + .retrieve_keys( + generated + .iter() + .map(|key| RetrieveKeyPayload::new(key.key.iv, "", &key.tag)) + .collect(), + None, + None, + ) + .await + .unwrap(); + (generated, retrieved) + } + + async fn responses(generated: usize, retrieved: usize) -> Responses { + let (g, _) = key_pairs(generated).await; + let (_, r) = key_pairs(retrieved).await; + Responses::new(g, r) + } + + #[test] + fn tally_counts_nothing_for_no_requests() { + assert_eq!(tally(&[]), (0, 0)); + } + + #[test] + fn tally_separates_the_two_kinds() { + let requests = vec![ + Request::generate_data_key(), + Request::retrieve_data_key(Iv::default(), vec![1]), + Request::generate_data_key(), + Request::retrieve_data_key(Iv::default(), vec![2]), + Request::generate_data_key(), + ]; + assert_eq!(tally(&requests), (3, 2)); + } + + #[test] + fn a_generate_request_is_a_generate_kind() { + assert!(matches!( + Request::generate_data_key().into_kind(), + RequestKind::GenerateDataKey + )); + } + + #[test] + fn a_retrieve_request_carries_its_iv_and_tag() { + let request = Request::retrieve_data_key(Iv::default(), vec![7, 8, 9]); + match request.into_kind() { + RequestKind::RetrieveDataKey { iv, tag } => { + assert_eq!(iv, Iv::default()); + assert_eq!(tag, vec![7, 8, 9]); + } + RequestKind::GenerateDataKey => panic!("expected a retrieve request"), + } + } + + #[tokio::test] + async fn generated_keys_come_back_in_request_order() { + let (generated, _) = key_pairs(3).await; + let tags: Vec> = generated.iter().map(|key| key.tag.clone()).collect(); + let mut responses = Responses::new(generated, Vec::new()); + + for tag in tags { + assert_eq!(responses.next_generated_key().unwrap().tag, tag); + } + } + + #[tokio::test] + async fn retrieved_keys_come_back_in_request_order() { + let (_, retrieved) = key_pairs(3).await; + let ivs: Vec = retrieved.iter().map(|key| key.iv).collect(); + let mut responses = Responses::new(Vec::new(), retrieved); + + for iv in ivs { + assert_eq!(responses.next_retrieved_key().unwrap().iv, iv); + } + } + + #[tokio::test] + async fn drawing_a_generated_key_past_the_end_is_a_response_shape_error() { + let mut responses = responses(1, 0).await; + assert!(responses.next_generated_key().is_ok()); + assert!(matches!( + responses.next_generated_key(), + Err(Error::ResponseShape) + )); + } + + #[tokio::test] + async fn drawing_a_retrieved_key_past_the_end_is_a_response_shape_error() { + let mut responses = responses(0, 1).await; + assert!(responses.next_retrieved_key().is_ok()); + assert!(matches!( + responses.next_retrieved_key(), + Err(Error::ResponseShape) + )); + } + + /// The kinds are separate queues: a generate response can never be drawn + /// as a retrieve response, however many of the other kind are waiting. + #[tokio::test] + async fn the_two_kinds_do_not_substitute_for_each_other() { + let mut generated_only = responses(2, 0).await; + assert!(matches!( + generated_only.next_retrieved_key(), + Err(Error::ResponseShape) + )); + + let mut retrieved_only = responses(0, 2).await; + assert!(matches!( + retrieved_only.next_generated_key(), + Err(Error::ResponseShape) + )); + } + + #[tokio::test] + async fn split_front_takes_exactly_what_was_asked_for() { + let mut responses = responses(3, 2).await; + let own = responses.split_front(2, 1).unwrap(); + + assert_eq!((own.generated.len(), own.retrieved.len()), (2, 1)); + assert_eq!( + (responses.generated.len(), responses.retrieved.len()), + (1, 1) + ); + } + + /// The scoping guarantee: a fulfilment's view holds *its* responses, and + /// the responses left behind are the ones its siblings will draw. + #[tokio::test] + async fn split_front_takes_from_the_front_and_leaves_the_rest() { + let (generated, _) = key_pairs(3).await; + let tags: Vec> = generated.iter().map(|key| key.tag.clone()).collect(); + let mut responses = Responses::new(generated, Vec::new()); + + let mut first = responses.split_front(1, 0).unwrap(); + assert_eq!(first.next_generated_key().unwrap().tag, tags[0]); + + let mut rest = responses.split_front(2, 0).unwrap(); + assert_eq!(rest.next_generated_key().unwrap().tag, tags[1]); + assert_eq!(rest.next_generated_key().unwrap().tag, tags[2]); + } + + #[tokio::test] + async fn split_front_of_nothing_yields_an_empty_view() { + let mut responses = responses(2, 2).await; + let mut own = responses.split_front(0, 0).unwrap(); + + assert!(matches!( + own.next_generated_key(), + Err(Error::ResponseShape) + )); + assert!(matches!( + own.next_retrieved_key(), + Err(Error::ResponseShape) + )); + // The siblings' responses are untouched. + assert_eq!( + (responses.generated.len(), responses.retrieved.len()), + (2, 2) + ); + } + + #[tokio::test] + async fn split_front_short_of_generated_responses_errors_without_consuming() { + let mut responses = responses(1, 0).await; + assert!(matches!( + responses.split_front(2, 0), + Err(Error::ResponseShape) + )); + assert_eq!(responses.generated.len(), 1); + } + + #[tokio::test] + async fn split_front_short_of_retrieved_responses_errors_without_consuming() { + let mut responses = responses(2, 1).await; + assert!(matches!( + responses.split_front(2, 2), + Err(Error::ResponseShape) + )); + // Neither queue was drained: the check happens before the split. + assert_eq!( + (responses.generated.len(), responses.retrieved.len()), + (2, 1) + ); + } + + #[tokio::test] + async fn draining_consumes_every_response_of_that_kind() { + let mut responses = responses(3, 2).await; + + assert_eq!(responses.drain_generated().count(), 3); + assert_eq!(responses.retrieved.len(), 2); + assert_eq!(responses.drain_retrieved().count(), 2); + + assert!(matches!( + responses.next_generated_key(), + Err(Error::ResponseShape) + )); + assert!(matches!( + responses.next_retrieved_key(), + Err(Error::ResponseShape) + )); + } +} From 8ae35088de9020cf9b403f0539692b73380a0074 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Thu, 27 Aug 2026 11:28:06 +1000 Subject: [PATCH 434/686] refactor(stack-encrypt)!: rename EncryptedFrom to EncryptFrom MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Requested in review. The trait is the encrypt-side `From`, and reads as one: `EqualityTerm: EncryptFrom<&str, StackCipher>` — "encrypt from a `&str`" — where `EncryptedFrom` read as a past-tense adjective and did not line up with `encrypt_from`, the method it declares. `DecryptedFrom` renamed to `DecryptFrom` for the same reason: the two are documented and implemented as mirrors, and renaming one half would leave the pair reading inconsistently. Nothing else about either trait changes, and the RFC prose follows the new names. Claude-Session: https://claude.ai/code/session_01HU1Bbw4eQp9kEEneXxDcKr --- ...nc-shape-for-target-directed-encryption.md | 26 ++++----- .../examples/encrypted_record.rs | 8 +-- packages/stack-encrypt/src/cipher.rs | 6 +-- packages/stack-encrypt/src/lib.rs | 4 +- packages/stack-encrypt/src/sem/mod.rs | 14 ++--- packages/stack-encrypt/src/target/mod.rs | 54 +++++++++---------- packages/stack-encrypt/src/target/pending.rs | 2 +- packages/stack-encrypt/tests/target.rs | 14 ++--- 8 files changed, 64 insertions(+), 64 deletions(-) diff --git a/docs/rfcs/0002-async-shape-for-target-directed-encryption.md b/docs/rfcs/0002-async-shape-for-target-directed-encryption.md index 16836379b..4ddff6fb4 100644 --- a/docs/rfcs/0002-async-shape-for-target-directed-encryption.md +++ b/docs/rfcs/0002-async-shape-for-target-directed-encryption.md @@ -15,7 +15,7 @@ ## 1. Summary -`EncryptedFrom` as implemented in #2147 hardcodes a boxed future as its return +`EncryptFrom` as implemented in #2147 hardcodes a boxed future as its return type. Three consequences: 1. Every cipher is forced to be async, including ones that do no I/O. @@ -44,14 +44,14 @@ fn encrypt_from<'a, 'c, Ctx>(source: &'a S, cipher: &'a C, context: Ctx) -> PendingEncrypt<'a, Self, Self::Error>; ``` -`EncryptedFrom` is the only trait in the stack that does this. Its two +`EncryptFrom` is the only trait in the stack that does this. Its two neighbours both hand the choice to the implementation: | trait | output | who decides | | -- | -- | -- | | `Cipher::Ok` | `PendingStackCipherText` for `StackCipher`; a finished ciphertext for a local cipher | the cipher | | `Prf::Ok` | `ReadyPrf` for `HmacSha256Prf`; a real future for a future 2-party backend | the backend | -| `EncryptedFrom` | `Pin>`, always | **the trait** | +| `EncryptFrom` | `Pin>`, always | **the trait** | A cipher that does no I/O still returns a future the caller must `.await`. @@ -137,7 +137,7 @@ after a round-trip rather than before one. vitaminc obeys this: `Encrypt` drives a `Cipher` with no I/O and yields `Cipher::Ok`; whoever holds the `Ok` decides when — and how many at a time — -to settle. `EncryptedFrom` must obey it too. +to settle. `EncryptFrom` must obey it too. ## 4. Design @@ -150,7 +150,7 @@ pub trait EncryptTarget { type Output<'a, T: 'a>: 'a where Self: 'a; } -pub trait EncryptedFrom: Sized { +pub trait EncryptFrom: Sized { fn encrypt_from<'a, 'c, Ctx>(source: &'a S, cipher: &'a C, ctx: Ctx) -> C::Output<'a, Self> where Ctx: EncryptContext<'c>; @@ -176,7 +176,7 @@ is *where* the type is chosen.) The implementation notes record that an associated `Pending` type was tried and defeated `let term: EqualityTerm = v.encrypt_into(..).await?`. That is correct **for an associated type on the target**: normalizing `T::Pending` -requires selecting the `EncryptedFrom` impl, which requires knowing `T` — the +requires selecting the `EncryptFrom` impl, which requires knowing `T` — the very thing being inferred. `C::Output<'a, T>` has no such cycle. Normalizing it requires only `C`, and @@ -266,7 +266,7 @@ up. Composites combine pendings **without awaiting them**, so requests merge: ```rust -impl EncryptedFrom> for EncryptedInt { +impl EncryptFrom> for EncryptedInt { fn encrypt_from<'a, 'c, Ctx>(source: &'a u32, cipher: &'a StackCipher, ctx: Ctx) -> PendingEncrypted<'a, Self, K> { @@ -285,8 +285,8 @@ directly emittable by `#[derive(Encrypted)]`. Then the missing piece from §2.2: ```rust -impl EncryptedFrom, StackCipher> for Vec where T: EncryptedFrom> -impl EncryptedFrom, StackCipher> for Option +impl EncryptFrom, StackCipher> for Vec where T: EncryptFrom> +impl EncryptFrom, StackCipher> for Option ``` which makes the column one operation, one await, one round-trip: @@ -354,7 +354,7 @@ keys into the single combined 0KMS call: both are then rows in one ### 4.6 Errors belong to the cipher `C::Output<'a, T>` has no error slot, so the error is `C::Error`, and -`EncryptedFrom::Error` is dropped. `TargetError` and the six-line +`EncryptFrom::Error` is dropped. `TargetError` and the six-line error-conversion where-clauses on every composite go with it. Term errors reach the cipher's error through `Error::Term(#[from] TermError)`; third-party terms get an `Error::Other(Box)` escape; @@ -367,7 +367,7 @@ The current module docs teach external term authors to return all until a deferred PRF exists: ```rust -impl EncryptedFrom> for MyTerm +impl EncryptFrom> for MyTerm where S: PrfValue + Clone, { @@ -421,7 +421,7 @@ outputs — is a future `Prf` trait extension, and should be designed with the | file | change | | -- | -- | -| `src/target.rs` | `EncryptTarget` + GAT; `PendingEncrypted` (with the wasm32 `Send` cfg-split carried over from `PendingEncrypt`); drop `PendingEncrypt` alias, `EncryptedFrom::Error`, `TargetError` | +| `src/target.rs` | `EncryptTarget` + GAT; `PendingEncrypted` (with the wasm32 `Send` cfg-split carried over from `PendingEncrypt`); drop `PendingEncrypt` alias, `EncryptFrom::Error`, `TargetError` | | `src/sem/mod.rs` | four visitors in, four `derive_*` out; validation moves ahead of the request | | `src/cipher.rs` | `StackCipher: EncryptTarget`; `dispatch`; `Error::Term`/`Error::Other`/`Error::ResponseShape` | | `examples/`, `tests/` | column encrypted as a `Vec`, not a loop; `try_join!` gone; tokio dev-dep drops out of the record shape | @@ -437,7 +437,7 @@ derivations must produce identical bytes before and after. `Pending`; they lift onto `EncryptTarget` if a second async cipher ever appears. 2. **Decrypt landed with this change** (decided): `DecryptTarget`, - `DecryptedFrom`, `DecryptExt` and `DecryptContext` mirror the encrypt side; + `DecryptFrom`, `DecryptExt` and `DecryptContext` mirror the encrypt side; the `Vec` implementation batches a column of rows into one `retrieve_keys`. The derive will emit both directions from day one. 3. **`Request` is public but opaque** (decided): constructors only diff --git a/packages/stack-encrypt/examples/encrypted_record.rs b/packages/stack-encrypt/examples/encrypted_record.rs index 15c983504..b375ee601 100644 --- a/packages/stack-encrypt/examples/encrypted_record.rs +++ b/packages/stack-encrypt/examples/encrypted_record.rs @@ -2,7 +2,7 @@ //! //! The point of target-directed encryption: define a record type that *is* //! "the ciphertext plus the index terms this field needs", implement -//! `EncryptedFrom` once (the shape a future `#[derive(Encrypted)]` will +//! `EncryptFrom` once (the shape a future `#[derive(Encrypted)]` will //! emit), and every insert is one `encrypt_into(..).await`. A tiny in-memory //! "table" then answers equality and range queries purely by comparing terms //! — decrypting only the rows that match. @@ -25,7 +25,7 @@ use stack_encrypt::sem::{EqualityTerm, OreTerm}; use stack_encrypt::target::{ - DecryptContext, DecryptExt, DecryptedFrom, EncryptContext, EncryptExt, EncryptedFrom, Pending, + DecryptContext, DecryptExt, DecryptFrom, EncryptContext, EncryptExt, EncryptFrom, Pending, }; use stack_encrypt::{StackCipher, StackCipherText}; @@ -42,7 +42,7 @@ struct EncryptedInt { // pending (no I/O — the terms derive locally, the ciphertext queues its // data-key requests), merge them with `zip`, shape with `map`. Errors are the // cipher's; there is nothing to unify. -impl EncryptedFrom> for EncryptedInt { +impl EncryptFrom> for EncryptedInt { fn encrypt_from<'a, 'c, Ctx>( source: &'a u32, cipher: &'a StackCipher, @@ -68,7 +68,7 @@ impl EncryptedFrom> for EncryptedInt { // The decrypt mirror the derive will also write: only the ciphertext field // participates (terms are one-way), so it delegates to the ciphertext's own // implementation. -impl DecryptedFrom> for u32 { +impl DecryptFrom> for u32 { fn decrypt_from<'a, 'c, Ctx>( source: EncryptedInt, cipher: &'a StackCipher, diff --git a/packages/stack-encrypt/src/cipher.rs b/packages/stack-encrypt/src/cipher.rs index 78d6b391e..9685457f9 100644 --- a/packages/stack-encrypt/src/cipher.rs +++ b/packages/stack-encrypt/src/cipher.rs @@ -121,14 +121,14 @@ pub enum Error { /// An index term failed to derive. #[error(transparent)] Term(#[from] crate::sem::TermError), - /// A third-party [`EncryptedFrom`](crate::target::EncryptedFrom) / - /// [`DecryptedFrom`](crate::target::DecryptedFrom) implementation failed + /// A third-party [`EncryptFrom`](crate::target::EncryptFrom) / + /// [`DecryptFrom`](crate::target::DecryptFrom) implementation failed /// for a reason of its own. #[error(transparent)] Other(Box), /// A [`Pending`](crate::target::Pending) fulfilment drew more responses — /// or a different kind of response — than its requests asked for. Always a - /// composition bug in an `EncryptedFrom`/`DecryptedFrom` implementation, + /// composition bug in an `EncryptFrom`/`DecryptFrom` implementation, /// never a data error. #[error("a pending fulfilment drew responses its requests never asked for")] ResponseShape, diff --git a/packages/stack-encrypt/src/lib.rs b/packages/stack-encrypt/src/lib.rs index 1394803af..55a945cd2 100644 --- a/packages/stack-encrypt/src/lib.rs +++ b/packages/stack-encrypt/src/lib.rs @@ -126,8 +126,8 @@ pub use cipher::{ StackCipherBuilder, StackCipherText, StackDecipher, }; pub use target::{ - DecryptContext, DecryptExt, DecryptTarget, DecryptedFrom, EncryptContext, EncryptExt, - EncryptTarget, EncryptedFrom, Pending, PendingFuture, Request, Responses, + DecryptContext, DecryptExt, DecryptFrom, DecryptTarget, EncryptContext, EncryptExt, + EncryptFrom, EncryptTarget, Pending, PendingFuture, Request, Responses, }; // Re-export the vitaminc AEAD surface callers need to drive the cipher, so they diff --git a/packages/stack-encrypt/src/sem/mod.rs b/packages/stack-encrypt/src/sem/mod.rs index 527e0f52b..a006bfae3 100644 --- a/packages/stack-encrypt/src/sem/mod.rs +++ b/packages/stack-encrypt/src/sem/mod.rs @@ -3,7 +3,7 @@ //! Index terms are stored alongside a //! [`StackCipherText`](crate::StackCipherText) so encrypted values can be //! queried without decryption. Each term type implements -//! [`EncryptedFrom`], so the usual entry point is +//! [`EncryptFrom`], so the usual entry point is //! target-directed: //! //! ```text @@ -43,7 +43,7 @@ //! [`HmacSha256Prf`] — keyed by the //! deterministic per-keyset [`IndexKey`](stack_kms::IndexKey) from //! [`stack_kms::IndexKeySource`] — so every derivation completes with no I/O -//! and an [`EncryptedFrom`] term carries **no requests** in its +//! and an [`EncryptFrom`] term carries **no requests** in its //! [`Pending`]. The next ZeroKMS release adds 2-party PRF generation; under //! that backend a term's `encrypt_from` pushes a PRF *request* instead and //! runs the **same visitor** over the blocks the server returns — the shaping @@ -81,7 +81,7 @@ use vitaminc_prf::{ }; use zeroize::Zeroize; -use crate::target::{EncryptContext, EncryptedFrom, Pending}; +use crate::target::{EncryptContext, EncryptFrom, Pending}; use crate::{Error, StackCipher}; // The `/v1` suffix versions the *derivation* (domain + input framing), not the @@ -224,7 +224,7 @@ where /// An equality term of any [`PrfValue`] source. Derived locally during the /// synchronous build — the returned [`Pending`] carries no requests. -impl EncryptedFrom> for EqualityTerm +impl EncryptFrom> for EqualityTerm where S: PrfValue + Clone, { @@ -465,7 +465,7 @@ fn match_term( /// A match term of any text source, generated under `O`'s options. Derived /// locally during the synchronous build — the returned [`Pending`] carries no /// requests (tokenize makes the one necessary copy of the text). -impl EncryptedFrom> for MatchTerm +impl EncryptFrom> for MatchTerm where S: AsRef, O: MatchConfig, @@ -677,7 +677,7 @@ where /// An ORE term of any [`CllwOreEncrypt`] source. Derived locally during the /// synchronous build — the returned [`Pending`] carries no requests. -impl EncryptedFrom> for OreTerm +impl EncryptFrom> for OreTerm where S: CllwOreEncrypt + Clone + Send + 'static, S::Output: Send + 'static, @@ -699,7 +699,7 @@ where /// An OPE term of any [`CllwOpeEncrypt`] source. Derived locally during the /// synchronous build — the returned [`Pending`] carries no requests. -impl EncryptedFrom> for OpeTerm +impl EncryptFrom> for OpeTerm where S: CllwOpeEncrypt + Clone + Send + 'static, S::Output: Send + 'static, diff --git a/packages/stack-encrypt/src/target/mod.rs b/packages/stack-encrypt/src/target/mod.rs index 00fe72ef6..816c13951 100644 --- a/packages/stack-encrypt/src/target/mod.rs +++ b/packages/stack-encrypt/src/target/mod.rs @@ -3,7 +3,7 @@ //! //! A stored encrypted value is rarely just a ciphertext — it is a record: the //! AEAD ciphertext of the plaintext plus zero or more index terms derived from -//! the same plaintext by different primitives. [`EncryptedFrom`] puts that +//! the same plaintext by different primitives. [`EncryptFrom`] puts that //! record shape in charge: //! //! ```text @@ -15,14 +15,14 @@ //! //! # The pieces //! -//! * [`EncryptedFrom`] — implemented by an *output* type: "`Self` is an +//! * [`EncryptFrom`] — implemented by an *output* type: "`Self` is an //! encrypted representation of `S`, producible by a cipher `C`". Leaf //! implementations exist for [`StackCipherText`] (the AEAD ciphertext, via //! vitaminc's [`Encrypt`]) and for the SEM term types in [`sem`] //! ([`EqualityTerm`], [`MatchTerm`], [`OreTerm`], [`OpeTerm`]). Composite //! record types implement it by combining their fields' pendings with //! [`Pending::zip`] / [`Pending::map`]. -//! * [`DecryptedFrom`] — the mirror, implemented by the *plaintext* +//! * [`DecryptFrom`] — the mirror, implemented by the *plaintext* //! type: "`Self` is recoverable from the encrypted `S`". Only ciphertext //! fields participate — index terms are one-way by construction. //! * [`EncryptExt::encrypt_into`] / [`DecryptExt::decrypt_into`] — blanket @@ -85,7 +85,7 @@ //! # Extending with your own SEM type //! //! The set of term types is open. Any crate can define one: implement -//! [`EncryptedFrom`] for it against [`StackCipher`], build the result with +//! [`EncryptFrom`] for it against [`StackCipher`], build the result with //! [`Pending::ready`] (local derivation) or [`Pending::request`] (derivation //! needing ZeroKMS responses). Every built-in term type is implemented with //! **exactly** this recipe — they use no privileged access — so [`sem`] @@ -98,7 +98,7 @@ //! behind a PRF request instead, joining the record's one batched call. //! //! ``` -//! use stack_encrypt::target::{EncryptContext, EncryptedFrom, Pending}; +//! use stack_encrypt::target::{EncryptContext, EncryptFrom, Pending}; //! use stack_encrypt::{Error, StackCipher}; //! use vitaminc_prf::{IntoPrfContext, PrfContext, PrfValue, PrfVisitor, PrfVisitorError}; //! @@ -115,7 +115,7 @@ //! } //! } //! -//! impl EncryptedFrom> for MyTerm +//! impl EncryptFrom> for MyTerm //! where //! S: PrfValue + Clone, //! { @@ -215,7 +215,7 @@ impl<'a, T> DecryptContext<'a> for T where T: IntoAad<'a> + Clone {} // Cipher-owned output types // ============================================================================= -/// Implemented by ciphers: decides what an [`EncryptedFrom`] implementation +/// Implemented by ciphers: decides what an [`EncryptFrom`] implementation /// hands back. A cipher that does no I/O sets /// `Output<'a, T> = Result` — no future, no `.await`. /// [`StackCipher`] sets `Output<'a, T> = Pending<'a, T, K>`, a request @@ -278,7 +278,7 @@ impl DecryptTarget for StackCipher { /// There is no associated error type: errors belong to the cipher /// ([`EncryptTarget::Error`]), and implementations with failure modes of /// their own use [`Error::Term`] or [`Error::Other`]. -pub trait EncryptedFrom: Sized { +pub trait EncryptFrom: Sized { /// Encrypt `source` into `Self` under `context`, returning the cipher's /// [`Output`](EncryptTarget::Output). No I/O happens here; work needing /// ZeroKMS is carried as requests and settles when the output is awaited. @@ -293,11 +293,11 @@ pub trait EncryptedFrom: Sized { } /// `Self` is recoverable from the encrypted `S` by a cipher `C` — the mirror -/// of [`EncryptedFrom`], implemented on the *plaintext* type. +/// of [`EncryptFrom`], implemented on the *plaintext* type. /// /// Takes the source by value: decryption consumes the ciphertext, and index /// terms (which have no plaintext to recover) simply do not participate. -pub trait DecryptedFrom: Sized { +pub trait DecryptFrom: Sized { /// Decrypt `source` into `Self`, authenticating against `context` — which /// must match the context the value was encrypted under. fn decrypt_from<'a, 'c, Ctx>(source: S, cipher: &'a C, context: Ctx) -> C::Output<'a, Self> @@ -309,7 +309,7 @@ pub trait DecryptedFrom: Sized { /// Call-site sugar: `value.encrypt_into::(&cipher, context)`. /// -/// The `Into` to [`EncryptedFrom`]'s `From` — blanket-implemented for every +/// The `Into` to [`EncryptFrom`]'s `From` — blanket-implemented for every /// type, never implemented by hand. The target type is usually inferred from /// the binding: /// @@ -331,11 +331,11 @@ pub trait DecryptedFrom: Sized { /// # }).unwrap(); /// ``` pub trait EncryptExt { - /// Encrypt `self` into `T` under `context`. See [`EncryptedFrom`]. + /// Encrypt `self` into `T` under `context`. See [`EncryptFrom`]. fn encrypt_into<'a, 'c, T, C, Ctx>(&'a self, cipher: &'a C, context: Ctx) -> C::Output<'a, T> where C: EncryptTarget, - T: EncryptedFrom + 'a, + T: EncryptFrom + 'a, Ctx: EncryptContext<'c>, Self: Sized; } @@ -344,7 +344,7 @@ impl EncryptExt for S { fn encrypt_into<'a, 'c, T, C, Ctx>(&'a self, cipher: &'a C, context: Ctx) -> C::Output<'a, T> where C: EncryptTarget, - T: EncryptedFrom + 'a, + T: EncryptFrom + 'a, Ctx: EncryptContext<'c>, { T::encrypt_from(self, cipher, context) @@ -356,11 +356,11 @@ impl EncryptExt for S { /// hand. pub trait DecryptExt: Sized { /// Decrypt `self` into `T`, authenticating against `context`. See - /// [`DecryptedFrom`]. + /// [`DecryptFrom`]. fn decrypt_into<'a, 'c, T, C, Ctx>(self, cipher: &'a C, context: Ctx) -> C::Output<'a, T> where C: DecryptTarget, - T: DecryptedFrom + 'a, + T: DecryptFrom + 'a, Ctx: DecryptContext<'c>, Self: 'a; } @@ -369,7 +369,7 @@ impl DecryptExt for S { fn decrypt_into<'a, 'c, T, C, Ctx>(self, cipher: &'a C, context: Ctx) -> C::Output<'a, T> where C: DecryptTarget, - T: DecryptedFrom + 'a, + T: DecryptFrom + 'a, Ctx: DecryptContext<'c>, Self: 'a, { @@ -389,7 +389,7 @@ impl DecryptExt for S { /// a pending tree (no I/O), and the returned [`Pending`] carries one /// data-key request per leaf. Sealing happens in the fulfilment, key material /// drawn in the same traversal order the tree was built in. -impl EncryptedFrom> for StackCipherText +impl EncryptFrom> for StackCipherText where S: Encrypt + Clone, { @@ -429,7 +429,7 @@ where /// (`iv` + `tag` are lifted out of the tree during the synchronous build); /// the fulfilment binds the retrieved keys back onto the leaves and lets the /// value's `Decrypt` impl drive the opening. -impl DecryptedFrom> for T +impl DecryptFrom> for T where T: Decrypt<'static> + 'static, { @@ -493,9 +493,9 @@ fn collect_retrieve_requests(ciphertext: &StackCipherText, out: &mut Vec → StackCipherText` (via [`Encrypt`]), which is one /// record whose value is a list — see the [module docs](self). -impl EncryptedFrom, StackCipher> for Vec +impl EncryptFrom, StackCipher> for Vec where - T: EncryptedFrom>, + T: EncryptFrom>, { fn encrypt_from<'a, 'c, Ctx>( source: &'a Vec, @@ -515,9 +515,9 @@ where } /// The column decrypt mirror: one batched retrieve for every row. -impl DecryptedFrom, StackCipher> for Vec +impl DecryptFrom, StackCipher> for Vec where - T: DecryptedFrom>, + T: DecryptFrom>, { fn decrypt_from<'a, 'c, Ctx>( source: Vec, @@ -541,9 +541,9 @@ where /// absent *record field*, carrying no requests). This is distinct from /// `Option → StackCipherText` via [`Encrypt`], which produces an /// *authenticated* absence marker inside one ciphertext. -impl EncryptedFrom, StackCipher> for Option +impl EncryptFrom, StackCipher> for Option where - T: EncryptedFrom> + MaybeSend, + T: EncryptFrom> + MaybeSend, { fn encrypt_from<'a, 'c, Ctx>( source: &'a Option, @@ -562,9 +562,9 @@ where } /// The optional decrypt mirror of the [`Option`] encrypt implementation. -impl DecryptedFrom, StackCipher> for Option +impl DecryptFrom, StackCipher> for Option where - T: DecryptedFrom> + MaybeSend, + T: DecryptFrom> + MaybeSend, { fn decrypt_from<'a, 'c, Ctx>( source: Option, diff --git a/packages/stack-encrypt/src/target/pending.rs b/packages/stack-encrypt/src/target/pending.rs index 92ee7ff8b..eb15cbd12 100644 --- a/packages/stack-encrypt/src/target/pending.rs +++ b/packages/stack-encrypt/src/target/pending.rs @@ -1,5 +1,5 @@ //! [`Pending`]: the request carrier [`StackCipher`] hands back from -//! [`EncryptedFrom`](super::EncryptedFrom) / [`DecryptedFrom`](super::DecryptedFrom), +//! [`EncryptFrom`](super::EncryptFrom) / [`DecryptFrom`](super::DecryptFrom), //! and the single place the target layer talks to ZeroKMS. //! //! A `Pending` is built synchronously and settled once. Combining pendings diff --git a/packages/stack-encrypt/tests/target.rs b/packages/stack-encrypt/tests/target.rs index dc681c612..5ac015e8d 100644 --- a/packages/stack-encrypt/tests/target.rs +++ b/packages/stack-encrypt/tests/target.rs @@ -1,4 +1,4 @@ -//! Target-directed encryption tests: leaf `EncryptedFrom`/`DecryptedFrom` +//! Target-directed encryption tests: leaf `EncryptFrom`/`DecryptFrom` //! implementations, a hand-written composite record (the shape a future //! derive will emit), a "third-party" term type built on the public extension //! surface only, and — the point of the design — proof that however large the @@ -11,7 +11,7 @@ use std::sync::Arc; use stack_encrypt::sem::{EqualityTerm, MatchConfig, MatchOptions, MatchTerm, OreTerm}; use stack_encrypt::target::{ - DecryptContext, DecryptExt, DecryptedFrom, EncryptContext, EncryptExt, EncryptedFrom, Pending, + DecryptContext, DecryptExt, DecryptFrom, EncryptContext, EncryptExt, EncryptFrom, Pending, Request, }; use stack_encrypt::{Error, StackCipher, StackCipherText}; @@ -367,7 +367,7 @@ struct EncryptedAge { ob: OreTerm, } -impl EncryptedFrom> for EncryptedAge { +impl EncryptFrom> for EncryptedAge { fn encrypt_from<'a, 'c, Ctx>( source: &'a u32, cipher: &'a StackCipher, @@ -386,7 +386,7 @@ impl EncryptedFrom> for EncryptedAge { /// The decrypt mirror a derive would emit: only the ciphertext field /// participates — terms are one-way. -impl DecryptedFrom> for u32 { +impl DecryptFrom> for u32 { fn decrypt_from<'a, 'c, Ctx>( source: EncryptedAge, cipher: &'a StackCipher, @@ -455,7 +455,7 @@ async fn composite_record_terms_preserve_order() { // --- A "third-party" term type ---------------------------------------------- // -// Defined here using only the public extension surface: `EncryptedFrom`, +// Defined here using only the public extension surface: `EncryptFrom`, // `Pending::ready`, and the cipher's public PRF. This is the proof that the // set of SEM types is open — a separate crate can do exactly this. @@ -464,7 +464,7 @@ async fn composite_record_terms_preserve_order() { #[derive(Debug, PartialEq, Eq)] struct PrefixTerm([u8; 32]); -impl EncryptedFrom> for PrefixTerm +impl EncryptFrom> for PrefixTerm where S: AsRef, { @@ -515,7 +515,7 @@ async fn third_party_term_type_works_on_the_public_surface() { prefix: PrefixTerm<3>, } - impl EncryptedFrom> for NameRecord { + impl EncryptFrom> for NameRecord { fn encrypt_from<'a, 'c, Ctx>( source: &'a String, cipher: &'a StackCipher, From 49f5f43368205ea6968a49c923f1076d8978b0c6 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Thu, 27 Aug 2026 12:53:40 +1000 Subject: [PATCH 435/686] docs(rfc-0002): record the review-driven corrections to the target layer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bring RFC 0002 in line with what landed after review of cipherstash/cipherstash-suite#2146 and cipherstash/cipherstash-suite#2147: - §4.5: the ORE/OPE visitors are named as implemented (`OreVisitor` / `OpeVisitor`), and the section now says why the visitor must own the plaintext and produce the ciphertext itself — under a two-party PRF no CLLW key exists on either side, so the key-returning `CllwKeyVisitor` the first cut shipped had nothing to return. The migration paragraph no longer claims the CLLW shaping is untouched by the backend swap: the visitor internals move from `visit_block` to `visit_seq` over per-prefix outputs, and that is the seam working as intended. - §6: the impact table reflects the `target/{mod,pending,request}.rs` split, the `Send + 'static` ORE/OPE sources, and the owned-input impls added to cllw-ore. - §7: three further deviations recorded — the `EncryptFrom` / `DecryptFrom` rename, the module split, and the ORE/OPE visitor correction. Claude-Session: https://claude.ai/code/session_01HU1Bbw4eQp9kEEneXxDcKr --- ...nc-shape-for-target-directed-encryption.md | 43 ++++++++++++++++--- 1 file changed, 36 insertions(+), 7 deletions(-) diff --git a/docs/rfcs/0002-async-shape-for-target-directed-encryption.md b/docs/rfcs/0002-async-shape-for-target-directed-encryption.md index 4ddff6fb4..b80fcf6b0 100644 --- a/docs/rfcs/0002-async-shape-for-target-directed-encryption.md +++ b/docs/rfcs/0002-async-shape-for-target-directed-encryption.md @@ -316,8 +316,8 @@ that shapes the block: | -- | -- | -- | | `EqualityTerm` | `EqualityVisitor` | block → term | | `MatchTerm` | `BloomVisitor { k, mask }` | already correct; keep | -| `OreTerm` | `CllwOreVisitor { value }` | block → CLLW key → encrypt → term | -| `OpeTerm` | `CllwOpeVisitor { value }` | as above | +| `OreTerm` | `OreVisitor(value)` | block → CLLW key → encrypt → term, all inside the visitor; the key never leaves | +| `OpeTerm` | `OpeVisitor(value)` | as above, under the OPE domain | `require_context`, `MatchOptions::validate` and the empty-token check move ahead of the request, where they fail without a round-trip. The visitor owns @@ -325,6 +325,17 @@ the plaintext it needs, which removes the clone-into-async-fn each term does today and narrows the plaintext fan-out the module docs warn about — only the ciphertext branch still needs an owned copy held until seal. +Owning the plaintext is not incidental for ORE/OPE, and it has a cost. The +cost: a visitor is `'static`, so ORE/OPE sources are `Send + 'static` — +literals still work, borrowed text becomes a `String` (`cllw-ore` gained +`CllwOreEncrypt`/`CllwOpeEncrypt` for `String` and `Vec`, byte-identical +to the borrowed impls). The reason: under the two-party PRF no CLLW key exists +on either side, so a visitor that *returns* a key — which is what the first +implementation did (`CllwKeyVisitor`, encrypting after the visitor) — has +nothing to return. The visitor has to be the whole ORE operation: PRF input +in, ciphertext out. Callers then see the surface the two-party backend will +have, and the key is a private detail of the local backend's visitor. + **How the value comes out synchronously.** No `SyncPrf` marker trait is needed, but the mechanism deserves stating, because it is concrete-type knowledge, not trait knowledge: term impls bind `StackCipher`, whose PRF is @@ -345,9 +356,13 @@ ZeroKMS PRF backend replaces the local HMAC inside `StackCipher`, a term's `encrypt_from` changes in exactly one way: instead of invoking the visitor inline over a `ReadyPrf`, it pushes `Request::Prf { input, context }` and invokes the **same visitor** inside `fulfil`, over the blocks that came back -in the batch response. The shaping code — Bloom positions, CLLW key -derivation, all of it — does not change, because the visitor never knew which -side of the round-trip it ran on. That swap is also what fuses terms and data +in the batch response. The shaping code — Bloom positions, equality blocks — +does not change, because the visitor never knew which side of the round-trip +it ran on. ORE/OPE are the one place the visitor *internals* change, and they +prove the seam rather than break it: CLLW under a two-party PRF has no key, +so `OreVisitor` moves from `visit_block` (block → key → encrypt) to +`visit_seq` over per-prefix PRF outputs (one per plaintext bit), while +`OreTerm`'s `encrypt_from` and every call site stay exactly as they are. That swap is also what fuses terms and data keys into the single combined 0KMS call: both are then rows in one `requests` vector settled by one `dispatch`. @@ -421,8 +436,9 @@ outputs — is a future `Prf` trait extension, and should be designed with the | file | change | | -- | -- | -| `src/target.rs` | `EncryptTarget` + GAT; `PendingEncrypted` (with the wasm32 `Send` cfg-split carried over from `PendingEncrypt`); drop `PendingEncrypt` alias, `EncryptFrom::Error`, `TargetError` | -| `src/sem/mod.rs` | four visitors in, four `derive_*` out; validation moves ahead of the request | +| `src/target/{mod,pending,request}.rs` | `EncryptTarget` + GAT; `Pending` (with the wasm32 `Send` cfg-split carried over from `PendingEncrypt`); drop `PendingEncrypt` alias, `EncryptFrom::Error`, `TargetError`. Split in review so `Pending` and `Request`/`Responses` carry their own unit tests | +| `src/sem/mod.rs` | four visitors in, four `derive_*` out; validation moves ahead of the request; ORE/OPE encrypt inside the visitor (`Send + 'static` sources) | +| `packages/cllw-ore` | `CllwOreEncrypt`/`CllwOpeEncrypt` for `String` and `Vec`, delegating to the borrowed impls | | `src/cipher.rs` | `StackCipher: EncryptTarget`; `dispatch`; `Error::Term`/`Error::Other`/`Error::ResponseShape` | | `examples/`, `tests/` | column encrypted as a `Vec`, not a loop; `try_join!` gone; tokio dev-dep drops out of the record shape | @@ -473,6 +489,19 @@ The implementation kept the design and changed three names/details: today). When ZeroKMS grows the combined keys-plus-PRF operation, `dispatch` is the one function that changes. +Review of #2146/#2147 then corrected three more: + +- **`EncryptFrom` / `DecryptFrom`** — first shipped as `EncryptedFrom` / + `DecryptedFrom`; renamed to the names this RFC uses. +- **`target.rs` became `target/{mod,pending,request}.rs`** so the request + carrier and the response handle have unit tests of their own (call counts + per batch, per-kind response scoping, over-draw, zero-I/O `ready`). +- **ORE/OPE first shipped as `CllwKeyVisitor`** — a visitor that returned the + CLLW key, with encryption after it. Reverted to the §4.5 shape + (`OreVisitor(value)` / `OpeVisitor(value)`); §4.5 records why a + key-returning visitor cannot survive the two-party backend. Wire format + unchanged (`tests/term_bytes.rs`). + ## 8. Where findings get recorded Three homes, by durability: From 08952569c0f29e22439edf2f9835e5f718febe33 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Thu, 27 Aug 2026 14:18:41 +1000 Subject: [PATCH 436/686] refactor(stack-encrypt): settle the cipher-directed API through Pending MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `PendingStackCipherText::seal` and `StackCipher::decipher` carried their own copies of the ZeroKMS plumbing — payload construction, the keyset argument, the `KeyCountMismatch` check — and `collect_retrieve_payloads` duplicated `collect_retrieve_requests` arm for arm. Three places had to agree on the wire convention and the leaf traversal order, and no test checked that `cipher.encrypt` and `encrypt_into::` produced interchangeable ciphertext. Both now build a `Pending` through shared `seal_pending` / `decipher_pending` builders in the target module and settle it with a new crate-private `Pending::settle` — the unboxed core of `IntoFuture`, so the cipher-directed methods keep their `K: DataKeySource` bounds. `dispatch` is now genuinely the one place the crate talks to ZeroKMS, which is what RFC 0002 §7 claimed. Three tests pin it: the cipher-directed API is one batched call per direction, ciphertexts sealed by either API open under the other, and a target-sealed leaf still refuses a foreign context through `cipher.decrypt`. Claude-Session: https://claude.ai/code/session_01HU1Bbw4eQp9kEEneXxDcKr --- packages/stack-encrypt/src/cipher.rs | 114 ++++--------------- packages/stack-encrypt/src/target/mod.rs | 84 ++++++++++---- packages/stack-encrypt/src/target/pending.rs | 26 +++-- packages/stack-encrypt/tests/target.rs | 57 ++++++++++ 4 files changed, 163 insertions(+), 118 deletions(-) diff --git a/packages/stack-encrypt/src/cipher.rs b/packages/stack-encrypt/src/cipher.rs index 9685457f9..bcbd90ea2 100644 --- a/packages/stack-encrypt/src/cipher.rs +++ b/packages/stack-encrypt/src/cipher.rs @@ -69,8 +69,8 @@ use std::collections::HashSet; use serde::{Deserialize, Serialize}; use stack_kms::{ - DataKey, DataKeySource, DataKeyWithTag, EnvKeyProvider, GenerateKeyPayload, IdentifiedBy, - IndexKeySource, RetrieveKeyPayload, StackKms, StackKmsBuilder, + DataKey, DataKeySource, DataKeyWithTag, EnvKeyProvider, IdentifiedBy, IndexKeySource, StackKms, + StackKmsBuilder, }; #[cfg(not(target_arch = "wasm32"))] use stack_kms::{FallbackKeyProvider, KeyProvider, KeyProviderError, ProfileStore}; @@ -406,38 +406,15 @@ impl StackCipher { /// is front-loaded here, and the AAD is supplied per call by /// [`Decrypt::decrypt_with_aad`], so `Decrypt` impls that derive their own /// AAD (e.g. `vitaminc_aead::Element`) behave identically to `AesDecipher`. + /// + /// Settles through the target layer's request carrier + /// ([`decipher_pending`](crate::target)), so this and + /// `decrypt_into` share one definition of how leaves map to retrieve + /// requests and one path to ZeroKMS. pub async fn decipher(&self, ciphertext: StackCipherText) -> Result { - // Collect every leaf's retrieve payload (borrowing the ciphertext), make - // one batched call, then drop the borrow before consuming the tree. - let keys = { - let mut payloads = Vec::new(); - collect_retrieve_payloads(&ciphertext, &mut payloads); - if payloads.is_empty() { - Vec::new() - } else { - let expected = payloads.len(); - let keys = self - .kms - .retrieve_keys(payloads, Some(self.keyset_id), None) - .await?; - if keys.len() != expected { - return Err(Error::KeyCountMismatch { - expected, - received: keys.len(), - }); - } - keys - } - }; - - let mut keys = keys.into_iter(); - let ciphertext = bind_keys(ciphertext, &mut keys)?; - // Every key must have been consumed; leftovers mean the tree shape and - // the payload collection disagreed. - if keys.next().is_some() { - return Err(Error::Aead); - } - Ok(StackDecipher { ciphertext }) + crate::target::decipher_pending(self, ciphertext) + .settle() + .await } } @@ -503,35 +480,6 @@ impl Clone for SealedValue { } } -/// Walk the tree in depth-first order, pushing one retrieve payload per keyed -/// leaf (markers included). Must match [`bind_keys`]'s traversal so payloads -/// and returned keys line up. -fn collect_retrieve_payloads<'b>( - ciphertext: &'b StackCipherText, - out: &mut Vec>, -) { - match ciphertext { - CipherText::Single(leaf) - | CipherText::None(leaf) - | CipherText::EmptySequence(leaf) - | CipherText::EmptyMap(leaf) => { - // Empty descriptor — see the module-level wire-format note. - out.push(RetrieveKeyPayload::new(leaf.iv, "", &leaf.tag)); - } - CipherText::Sequence(items) => { - for item in items { - collect_retrieve_payloads(item, out); - } - } - CipherText::Map(entries) => { - for (_, value) in entries { - collect_retrieve_payloads(value, out); - } - } - CipherText::Passthrough(_) => {} - } -} - /// A leaf with its retrieved data key bound alongside. Produced by /// [`bind_keys`] once the batched `retrieve_keys` call has returned; consumed by /// [`StackDecipher`], which opens it under whatever AAD the driving @@ -544,9 +492,10 @@ pub(crate) struct KeyedLeaf { /// [`StackCipherText`] with a [`DataKey`] zipped onto every keyed leaf. pub(crate) type KeyedCipherText = CipherText; -/// Zip retrieved keys onto the tree in the same depth-first order -/// [`collect_retrieve_payloads`] requested them, so each leaf carries its own -/// key and the subsequent [`Decipher`] drive is free of ordering assumptions. +/// Zip retrieved keys onto the tree in the same depth-first order the +/// target layer requested them (`retrieve_requests`), so each leaf carries its +/// own key and the subsequent [`Decipher`] drive is free of ordering +/// assumptions. pub(crate) fn bind_keys( ciphertext: StackCipherText, keys: &mut impl Iterator, @@ -630,37 +579,18 @@ impl PendingStackCipherText { } } - /// Generate one data key per keyed leaf (one batched ZeroKMS call) and seal - /// the whole tree. + /// Generate one data key per keyed leaf (one batched ZeroKMS call — none + /// for a passthrough-only tree) and seal the whole tree. + /// + /// Settles through the target layer's request carrier + /// ([`seal_pending`](crate::target)), so this and + /// `encrypt_into::` share one definition of how a tree + /// is sealed and one path to ZeroKMS. pub async fn seal( self, cipher: &StackCipher, ) -> Result { - let count = self.key_count(); - if count == 0 { - // Passthrough-only tree: no keys, no ZeroKMS call. - return Ok(self.seal_with(&mut std::iter::empty())?); - } - - // Empty descriptor + empty context for every leaf (see wire-format note). - let payloads: Vec> = (0..count) - .map(|_| GenerateKeyPayload::new("", Cow::Owned(Vec::new()))) - .collect(); - - let keys = cipher - .kms - .generate_keys(payloads, Some(cipher.keyset_id), None) - .await?; - - if keys.len() != count { - return Err(Error::KeyCountMismatch { - expected: count, - received: keys.len(), - }); - } - - let mut keys = keys.into_iter(); - Ok(self.seal_with(&mut keys)?) + crate::target::seal_pending(cipher, self).settle().await } /// Recursively seal, drawing one key per leaf from `keys` in traversal order. diff --git a/packages/stack-encrypt/src/target/mod.rs b/packages/stack-encrypt/src/target/mod.rs index 816c13951..000253ca3 100644 --- a/packages/stack-encrypt/src/target/mod.rs +++ b/packages/stack-encrypt/src/target/mod.rs @@ -172,7 +172,7 @@ use stack_kms::MaybeSend; use vitaminc_aead::{CipherText, Decrypt, Encrypt, IntoAad}; use vitaminc_prf::IntoPrfContext; -use crate::cipher::{bind_keys, StackDecipher}; +use crate::cipher::{bind_keys, PendingStackCipherText, StackDecipher}; use crate::{Error, StackCipher, StackCipherText}; mod pending; @@ -409,19 +409,62 @@ where if aad.as_bytes().is_empty() { return Pending::ready(cipher, Err(Error::EmptyContext)); } - let tree = match source.clone().encrypt_with_aad(cipher, aad) { - Ok(tree) => tree, - Err(_) => return Pending::ready(cipher, Err(Error::Aead)), - }; - let count = tree.key_count(); - let requests = std::iter::repeat_with(Request::generate_data_key) - .take(count) - .collect(); - Pending::request(cipher, requests, move |responses| { - let mut keys = responses.drain_generated(); - tree.seal_with(&mut keys).map_err(|_| Error::Aead) - }) + match source.clone().encrypt_with_aad(cipher, aad) { + Ok(tree) => seal_pending(cipher, tree), + Err(_) => Pending::ready(cipher, Err(Error::Aead)), + } + } +} + +/// Seal a pending tree: one [`Request::generate_data_key`] per keyed leaf, +/// keys drawn back in the same traversal order the tree was built in. +/// +/// Both ways of encrypting go through here — the target-directed +/// `encrypt_into::` above and the cipher-directed +/// [`PendingStackCipherText::seal`] behind [`StackCipher::encrypt`] — so +/// there is one definition of how a tree is sealed and one path to ZeroKMS. +pub(crate) fn seal_pending<'a, K>( + cipher: &'a StackCipher, + tree: PendingStackCipherText, +) -> Pending<'a, StackCipherText, K> { + let requests = std::iter::repeat_with(Request::generate_data_key) + .take(tree.key_count()) + .collect(); + Pending::request(cipher, requests, move |responses| { + let mut keys = responses.drain_generated(); + tree.seal_with(&mut keys).map_err(Error::from) + }) +} + +/// Bind retrieved keys onto a ciphertext: one [`Request::retrieve_data_key`] +/// per keyed leaf, keys zipped back on in the same depth-first order. The +/// decrypt twin of [`seal_pending`], and likewise the single path for both +/// `decrypt_into` and the cipher-directed [`StackCipher::decipher`]. +pub(crate) fn decipher_pending<'a, K>( + cipher: &'a StackCipher, + ciphertext: StackCipherText, +) -> Pending<'a, StackDecipher, K> { + let requests = retrieve_requests(&ciphertext); + Pending::request(cipher, requests, move |responses| { + decipher_from_responses(ciphertext, responses) + }) +} + +/// The fulfilment half of [`decipher_pending`], shared with `decrypt_into` +/// (which runs the value's `Decrypt` impl over the result in the same +/// fulfilment rather than composing two pendings). +fn decipher_from_responses( + ciphertext: StackCipherText, + responses: &mut Responses, +) -> Result { + let mut keys = responses.drain_retrieved(); + let keyed = bind_keys(ciphertext, &mut keys)?; + // Every key must have been consumed; leftovers mean the tree shape and + // the request collection disagreed. + if keys.next().is_some() { + return Err(Error::Aead); } + Ok(StackDecipher::over(keyed)) } /// The decrypt mirror: any vitaminc [`Decrypt`] value recovers from a @@ -449,19 +492,22 @@ where if aad.as_bytes().is_empty() { return Pending::ready(cipher, Err(Error::EmptyContext)); } - let mut requests = Vec::new(); - collect_retrieve_requests(&source, &mut requests); + let requests = retrieve_requests(&source); Pending::request(cipher, requests, move |responses| { - let mut keys = responses.drain_retrieved(); - let keyed = bind_keys(source, &mut keys).map_err(|_| Error::Aead)?; - drop(keys); - T::decrypt_with_aad(StackDecipher::over(keyed), aad).map_err(Error::from) + let decipher = decipher_from_responses(source, responses)?; + T::decrypt_with_aad(decipher, aad).map_err(Error::from) }) } } /// One [`Request::retrieve_data_key`] per keyed leaf, in the same depth-first /// order `bind_keys` will consume the responses. +fn retrieve_requests(ciphertext: &StackCipherText) -> Vec { + let mut out = Vec::new(); + collect_retrieve_requests(ciphertext, &mut out); + out +} + fn collect_retrieve_requests(ciphertext: &StackCipherText, out: &mut Vec) { match ciphertext { CipherText::Single(leaf) diff --git a/packages/stack-encrypt/src/target/pending.rs b/packages/stack-encrypt/src/target/pending.rs index eb15cbd12..3464d3801 100644 --- a/packages/stack-encrypt/src/target/pending.rs +++ b/packages/stack-encrypt/src/target/pending.rs @@ -148,6 +148,24 @@ impl<'a, T: 'a, K> Pending<'a, T, K> { } } +impl<'a, T: 'a, K> Pending<'a, T, K> +where + K: DataKeySource, +{ + /// Settle: one batched ZeroKMS call per request kind (none at all for an + /// all-[`ready`](Pending::ready) assembly), then the fulfilments shape the + /// responses. This is the only place I/O happens — the cipher-directed + /// API ([`StackCipher::encrypt`] / [`StackCipher::decipher`]) settles + /// through here too, so there is exactly one path to ZeroKMS. + /// + /// Unboxed, so it carries no `Send`/`Sync` demands beyond the backend's + /// own; the public [`IntoFuture`] impl boxes it. + pub(crate) async fn settle(self) -> Result { + let mut responses = dispatch(self.cipher, self.requests).await?; + (self.fulfil)(&mut responses) + } +} + impl<'a, T: 'a, K> IntoFuture for Pending<'a, T, K> where K: DataKeySource + Sync, @@ -155,14 +173,8 @@ where type Output = Result; type IntoFuture = PendingFuture<'a, T>; - /// The only place I/O happens: one batched ZeroKMS call per request kind - /// (none at all for an all-[`ready`](Pending::ready) assembly), then the - /// fulfilments shape the responses. fn into_future(self) -> Self::IntoFuture { - Box::pin(async move { - let mut responses = dispatch(self.cipher, self.requests).await?; - (self.fulfil)(&mut responses) - }) + Box::pin(self.settle()) } } diff --git a/packages/stack-encrypt/tests/target.rs b/packages/stack-encrypt/tests/target.rs index 5ac015e8d..0f045c119 100644 --- a/packages/stack-encrypt/tests/target.rs +++ b/packages/stack-encrypt/tests/target.rs @@ -683,3 +683,60 @@ async fn pending_futures_are_send() { let _term = handle.await.unwrap(); } + +// --- The cipher-directed API is the same path ------------------------------ + +#[tokio::test] +async fn cipher_directed_encrypt_and_decrypt_are_one_batched_call_each() { + let (cipher, generates, retrieves) = counting_cipher().await; + + let names: Vec = ["ada", "grace", "edsger", "barbara"] + .into_iter() + .map(String::from) + .collect(); + let ct = cipher.encrypt(names.clone(), "users/name").await.unwrap(); + assert_eq!( + generates.load(AtomicOrdering::SeqCst), + 1, + "cipher.encrypt of a four-leaf value must be ONE generate_keys call" + ); + + let roundtrip: Vec = cipher.decrypt(ct, "users/name").await.unwrap(); + assert_eq!(roundtrip, names); + assert_eq!( + retrieves.load(AtomicOrdering::SeqCst), + 1, + "cipher.decrypt of a four-leaf value must be ONE retrieve_keys call" + ); +} + +#[tokio::test] +async fn cipher_directed_and_target_directed_ciphertexts_are_interchangeable() { + let cipher = stack_cipher().await; + let value = vec!["one".to_string(), "two".to_string(), "three".to_string()]; + + // Sealed by the cipher-directed API, opened by the target-directed one. + let ct = cipher.encrypt(value.clone(), "users/tags").await.unwrap(); + let via_target: Vec = ct.decrypt_into(&cipher, "users/tags").await.unwrap(); + assert_eq!(via_target, value); + + // Sealed by the target-directed API, opened by the cipher-directed one. + let ct: StackCipherText = value.encrypt_into(&cipher, "users/tags").await.unwrap(); + let via_cipher: Vec = cipher.decrypt(ct, "users/tags").await.unwrap(); + assert_eq!(via_cipher, value); +} + +#[tokio::test] +async fn cipher_directed_decrypt_rejects_a_transplanted_ciphertext() { + let cipher = stack_cipher().await; + let ct: StackCipherText = "secret" + .to_string() + .encrypt_into(&cipher, "users/email") + .await + .unwrap(); + let result: Result = cipher.decrypt(ct, "users/name").await; + assert!( + matches!(result, Err(Error::Aead)), + "a target-sealed leaf must not open under another context via the cipher API" + ); +} From 4288943558599d055dd907d84c1795ac5573d498 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Thu, 27 Aug 2026 14:26:45 +1000 Subject: [PATCH 437/686] fix(stack-encrypt): fail at build time for empty contexts and mismatched ciphers, with no I/O MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five review findings against the target layer's "rejected during the synchronous build, before any I/O" guarantee, fixed together because they share one mechanism. Empty contexts. The guards only recognised literally-empty AAD bytes or the `()`/`""`/`b""` encodings, but vitaminc blanket-implements the context traits for `Option` and tuples, whose encodings of nothing are non-empty byte strings: `None`, `Some("")` and `("", "")` all passed and recreated the cross-field degeneracy `Error::EmptyContext` exists to prevent (identical terms across fields, one shared CLLW key, transplantable ciphertext). The check is now structural over vitaminc's PAE framing — `is_degenerate_context` — and shared by the ciphertext leaves, the SEM terms and the column/optional impls, which previously skipped the context entirely when there was nothing to encrypt (an empty descriptor passed every fixture with no rows and failed on the first real one). One spelling. An empty context surfaced as `Error::EmptyContext` from a ciphertext leaf but `Error::Term(TermError::EmptyContext)` from a term, so a composite's error depended on its `zip` order. `From` now folds the term variant into `Error::EmptyContext`. No I/O after a build-time failure. `into_future` dispatched before any fulfilment ran, so a leaf that had already failed (`ready(Err(..))`) zipped with a requesting sibling still minted the sibling's data keys in ZeroKMS and threw them away. `Pending` now records a build-time failure; `zip`/`all` propagate it and drop the assembly's requests, and `settle` returns it before touching the backend. Cipher mismatch. `zip`/`all` enforced "same cipher" with `debug_assert!` only; in release, pendings from two ciphers merged silently and every request dispatched through one cipher's keyset. It is now `Error::CipherMismatch`, raised at merge time. `Sync` on wasm32. `IntoFuture for Pending` demanded `K: Sync` unconditionally. That bound exists so the boxed future is `Send` on native targets; on wasm32 the future is not `Send`, so the bound only shut out the `Rc`/`RefCell`-shaped sources natural there. Split by target, mirroring `MaybeSend`. Tests: wrapped-empty contexts on every path, empty contexts with nothing to encrypt, a failed field dropping the batch (zero `generate_keys`), different ciphers refusing to merge, and unit tests pinning the PAE predicate (including the `0u64 == None` byte coincidence). Claude-Session: https://claude.ai/code/session_01HU1Bbw4eQp9kEEneXxDcKr --- packages/stack-encrypt/src/cipher.rs | 22 ++- packages/stack-encrypt/src/sem/mod.rs | 18 +-- packages/stack-encrypt/src/target/mod.rs | 162 ++++++++++++++++++- packages/stack-encrypt/src/target/pending.rs | 99 ++++++++++-- packages/stack-encrypt/tests/target.rs | 129 ++++++++++++++- 5 files changed, 392 insertions(+), 38 deletions(-) diff --git a/packages/stack-encrypt/src/cipher.rs b/packages/stack-encrypt/src/cipher.rs index bcbd90ea2..f10403e60 100644 --- a/packages/stack-encrypt/src/cipher.rs +++ b/packages/stack-encrypt/src/cipher.rs @@ -118,9 +118,11 @@ pub enum Error { /// across fields, and ciphertexts would be transplantable between them. #[error("the encryption context must not be empty (it domain-separates fields)")] EmptyContext, - /// An index term failed to derive. + /// An index term failed to derive. An empty context is *not* reported + /// here — it folds into [`Error::EmptyContext`] so every path spells the + /// same misconfiguration the same way. #[error(transparent)] - Term(#[from] crate::sem::TermError), + Term(crate::sem::TermError), /// A third-party [`EncryptFrom`](crate::target::EncryptFrom) / /// [`DecryptFrom`](crate::target::DecryptFrom) implementation failed /// for a reason of its own. @@ -132,6 +134,22 @@ pub enum Error { /// never a data error. #[error("a pending fulfilment drew responses its requests never asked for")] ResponseShape, + /// [`Pending`](crate::target::Pending)s built on different + /// [`StackCipher`] instances were merged (`zip` / `all`). An assembly + /// settles through one cipher's backend and keyset, so the other side's + /// keys would be minted under the wrong keyset. Always a composition + /// bug, caught before any I/O. + #[error("merged pendings were built from different ciphers")] + CipherMismatch, +} + +impl From for Error { + fn from(error: crate::sem::TermError) -> Self { + match error { + crate::sem::TermError::EmptyContext => Error::EmptyContext, + other => Error::Term(other), + } + } } impl From for Error { diff --git a/packages/stack-encrypt/src/sem/mod.rs b/packages/stack-encrypt/src/sem/mod.rs index a006bfae3..06bc681bc 100644 --- a/packages/stack-encrypt/src/sem/mod.rs +++ b/packages/stack-encrypt/src/sem/mod.rs @@ -81,7 +81,7 @@ use vitaminc_prf::{ }; use zeroize::Zeroize; -use crate::target::{EncryptContext, EncryptFrom, Pending}; +use crate::target::{is_degenerate_context, EncryptContext, EncryptFrom, Pending}; use crate::{Error, StackCipher}; // The `/v1` suffix versions the *derivation* (domain + input framing), not the @@ -142,18 +142,12 @@ impl TermError { } /// Reject an empty context before any derivation — see -/// [`TermError::EmptyContext`]. -/// -/// `()` (and `PrfContext::empty()`) produce literally empty context bytes; an -/// empty string or empty byte-slice context produces vitaminc's *typed* -/// framing around an empty payload. All are degenerate the same way — every -/// field using one shares a single derivation domain — so all are rejected. +/// [`TermError::EmptyContext`]. "Empty" is structural +/// ([`is_degenerate_context`]): `()`, `""`, `None`, `Some("")`, tuples of +/// empties and their nestings all carry no caller information, and every +/// field using one would share a single derivation domain. fn require_context(context: &PrfContext<'_>) -> Result<(), TermError> { - let bytes = context.as_bytes(); - if bytes.is_empty() - || bytes == "".into_prf_context().as_bytes() - || bytes == b"".as_slice().into_prf_context().as_bytes() - { + if is_degenerate_context(context.as_bytes()) { return Err(TermError::EmptyContext); } Ok(()) diff --git a/packages/stack-encrypt/src/target/mod.rs b/packages/stack-encrypt/src/target/mod.rs index 000253ca3..a9dc33679 100644 --- a/packages/stack-encrypt/src/target/mod.rs +++ b/packages/stack-encrypt/src/target/mod.rs @@ -197,8 +197,16 @@ pub use request::{Request, Responses}; /// in different fields produce identical index terms (cross-field equality /// leakage), every field shares one ORE/OPE key (values become mutually /// order-comparable), and ciphertexts become transplantable between fields. -/// Every built-in implementation rejects an empty context during the -/// synchronous build — before any I/O. +/// Every built-in implementation — leaves, columns, optionals — rejects an +/// empty context during the synchronous build, before any I/O. +/// +/// "Empty" means *carrying no caller-supplied information*, not merely zero +/// bytes: `()`, `""`, `b""`, `None`, `Some("")` and `("", "")` all encode to +/// nothing but vitaminc framing and are all rejected +/// ([`Error::EmptyContext`]). The check is structural over the PAE encoding +/// vitaminc uses, so a nested empty context cannot hide behind an `Option` +/// or tuple wrapper. (An integer context whose bytes coincide with an empty +/// encoding — `0u64` — is rejected too: it is byte-identical to `None`.) pub trait EncryptContext<'a>: IntoAad<'a> + IntoPrfContext<'a> + Clone {} impl<'a, T> EncryptContext<'a> for T where T: IntoAad<'a> + IntoPrfContext<'a> + Clone {} @@ -211,6 +219,53 @@ pub trait DecryptContext<'a>: IntoAad<'a> + Clone {} impl<'a, T> DecryptContext<'a> for T where T: IntoAad<'a> + Clone {} +/// Does an encoded context (AAD or PRF context bytes) carry no caller-supplied +/// information? See [`EncryptContext`] for what that means and why it is +/// rejected. +/// +/// Structural over vitaminc's PAE framing (`LE64(count) || (LE64(len) || +/// piece)*`): a context is degenerate if it is empty, or if it parses as a +/// PAE whose every piece is either a vitaminc framing tag (`vitaminc/…` +/// domain or encoding label) or itself degenerate. Bytes that are not a +/// well-formed PAE are caller content and count as information. This covers +/// `()`, the typed `""`/`b""` encodings, `None` (`pae([])`), `Some()` +/// and tuples of empties, at any nesting depth. +pub(crate) fn is_degenerate_context(bytes: &[u8]) -> bool { + const FRAMING_PREFIX: &[u8] = b"vitaminc/"; + if bytes.is_empty() { + return true; + } + match parse_pae(bytes) { + Some(pieces) => pieces + .iter() + .all(|piece| piece.starts_with(FRAMING_PREFIX) || is_degenerate_context(piece)), + None => false, + } +} + +/// Parse `bytes` as exactly one PAE encoding: `LE64(count)` then `count` +/// `LE64(len) || piece` frames, consuming every byte. `None` if the bytes are +/// not that shape. +fn parse_pae(bytes: &[u8]) -> Option> { + fn le64(bytes: &[u8]) -> Option<(usize, &[u8])> { + let (head, rest) = bytes.split_first_chunk::<8>()?; + let n = usize::try_from(u64::from_le_bytes(*head)).ok()?; + Some((n, rest)) + } + let (count, mut rest) = le64(bytes)?; + let mut pieces = Vec::with_capacity(count.min(16)); + for _ in 0..count { + let (len, after_len) = le64(rest)?; + if after_len.len() < len { + return None; + } + let (piece, tail) = after_len.split_at(len); + pieces.push(piece); + rest = tail; + } + rest.is_empty().then_some(pieces) +} + // ============================================================================= // Cipher-owned output types // ============================================================================= @@ -406,8 +461,8 @@ where // An empty context would leave the leaf AAD carrying only the key // tag, making ciphertexts transplantable between ()-context fields — // see `EncryptContext`. - if aad.as_bytes().is_empty() { - return Pending::ready(cipher, Err(Error::EmptyContext)); + if is_degenerate_context(aad.as_bytes()) { + return Pending::failed(cipher, Error::EmptyContext); } match source.clone().encrypt_with_aad(cipher, aad) { Ok(tree) => seal_pending(cipher, tree), @@ -489,8 +544,8 @@ where let aad = context.into_aad().into_owned(); // Symmetric with the encrypt side: the target layer never encrypts // under an empty context, so it never decrypts under one either. - if aad.as_bytes().is_empty() { - return Pending::ready(cipher, Err(Error::EmptyContext)); + if is_degenerate_context(aad.as_bytes()) { + return Pending::failed(cipher, Error::EmptyContext); } let requests = retrieve_requests(&source); Pending::request(cipher, requests, move |responses| { @@ -552,6 +607,11 @@ where Ctx: EncryptContext<'c>, Self: 'a, { + // Validate the context even for an empty column: an empty descriptor + // must fail on the fixture with no rows, not on the first real one. + if is_degenerate_context(context.clone().into_aad().as_bytes()) { + return Pending::failed(cipher, Error::EmptyContext); + } let items = source .iter() .map(|item| T::encrypt_from(item, cipher, context.clone())) @@ -575,6 +635,9 @@ where S: 'a, Self: 'a, { + if is_degenerate_context(context.clone().into_aad().as_bytes()) { + return Pending::failed(cipher, Error::EmptyContext); + } let items = source .into_iter() .map(|item| T::decrypt_from(item, cipher, context.clone())) @@ -600,6 +663,11 @@ where Ctx: EncryptContext<'c>, Self: 'a, { + // `None` derives nothing, but the field's context is still checked so + // a misconfigured optional field fails whether or not it is present. + if is_degenerate_context(context.clone().into_aad().as_bytes()) { + return Pending::failed(cipher, Error::EmptyContext); + } match source { Some(value) => T::encrypt_from(value, cipher, context).map(Some), None => Pending::ready(cipher, Ok(None)), @@ -622,9 +690,91 @@ where S: 'a, Self: 'a, { + if is_degenerate_context(context.clone().into_aad().as_bytes()) { + return Pending::failed(cipher, Error::EmptyContext); + } match source { Some(value) => T::decrypt_from(value, cipher, context).map(Some), None => Pending::ready(cipher, Ok(None)), } } } + +#[cfg(test)] +mod context_tests { + use vitaminc_aead::{Aad, IntoAad}; + use vitaminc_prf::{IntoPrfContext, PrfContext}; + + use super::is_degenerate_context; + + fn aad<'a>(ctx: impl IntoAad<'a>) -> bool { + is_degenerate_context(ctx.into_aad().as_bytes()) + } + + fn prf<'a>(ctx: impl IntoPrfContext<'a>) -> bool { + is_degenerate_context(ctx.into_prf_context().as_bytes()) + } + + #[test] + fn empty_encodings_are_degenerate_on_both_channels() { + assert!(aad(())); + assert!(aad("")); + assert!(aad(b"".as_slice())); + assert!(aad(None::<&str>)); + assert!(aad(Some(""))); + assert!(aad(("", ""))); + assert!(aad(Some(None::<&str>))); + assert!(aad((None::<&str>, Some("")))); + + assert!(prf(())); + assert!(prf("")); + assert!(prf(b"".as_slice())); + assert!(prf(None::<&str>)); + assert!(prf(Some(""))); + assert!(prf(("", ""))); + assert!(prf(Some(None::<&str>))); + assert!(prf(PrfContext::empty())); + assert!(prf(PrfContext::pae(&[]))); + } + + #[test] + fn contexts_carrying_information_are_not() { + assert!(!aad("users/email")); + assert!(!aad("x")); + assert!(!aad(Some("users/email"))); + assert!(!aad(("users", "email"))); + assert!(!aad(("", "email"))); + assert!(!aad(Aad::from_slice(b"raw"))); + assert!(!aad(7u64)); + + assert!(!prf("users/email")); + assert!(!prf("x")); + assert!(!prf(Some("users/email"))); + assert!(!prf(("users", "email"))); + assert!(!prf(("", "email"))); + assert!(!prf(7u64)); + assert!(!prf(PrfContext::from_slice(b"raw"))); + } + + #[test] + fn a_zero_u64_is_byte_identical_to_none_and_rejected_with_it() { + assert_eq!( + 0u64.into_aad().as_bytes(), + None::<&str>.into_aad().as_bytes() + ); + assert!(aad(0u64)); + } + + #[test] + fn a_truncated_or_overlong_pae_is_caller_content() { + // Looks like a count of two but carries only one frame. + let mut bytes = Vec::new(); + bytes.extend_from_slice(&2u64.to_le_bytes()); + bytes.extend_from_slice(&0u64.to_le_bytes()); + assert!(!is_degenerate_context(&bytes)); + // A well-formed empty PAE followed by a trailing byte. + let mut bytes = 0u64.to_le_bytes().to_vec(); + bytes.push(0); + assert!(!is_degenerate_context(&bytes)); + } +} diff --git a/packages/stack-encrypt/src/target/pending.rs b/packages/stack-encrypt/src/target/pending.rs index 3464d3801..7a299b32d 100644 --- a/packages/stack-encrypt/src/target/pending.rs +++ b/packages/stack-encrypt/src/target/pending.rs @@ -51,6 +51,13 @@ pub type PendingFuture<'a, T> = Pin> + pub struct Pending<'a, T, K> { cipher: &'a StackCipher, requests: Vec, + /// Set when the value already failed during the synchronous build + /// (`ready(Err(..))`, a cipher mismatch, a failed sibling). A failed + /// pending carries no requests, and merging one into an assembly drops + /// the assembly's requests too, so settling it does no I/O: a record + /// with one misconfigured field never mints data keys it will throw away. + /// When set, `fulfil` is never called. + failed: Option, fulfil: FulfilBox<'a, T>, } @@ -61,10 +68,32 @@ impl<'a, T: 'a, K> Pending<'a, T, K> { where T: MaybeSend, { + match result { + Ok(value) => Self { + cipher, + requests: Vec::new(), + failed: None, + fulfil: Box::new(move |_| Ok(value)), + }, + Err(error) => Self::failed(cipher, error), + } + } + + /// A pending that already failed. No requests, no `T` bound (nothing of + /// type `T` is ever produced), and any assembly it is merged into fails + /// without I/O — see the `failed` field. + pub(crate) fn failed(cipher: &'a StackCipher, error: Error) -> Self { Self { cipher, requests: Vec::new(), - fulfil: Box::new(move |_| result), + failed: Some(error), + // Unreachable: `settle` returns the stored error before any + // fulfilment runs. Kept honest rather than panicking. + fulfil: Box::new(|_| { + Err(Error::Other( + "fulfilment invoked on an already-failed pending".into(), + )) + }), } } @@ -80,6 +109,7 @@ impl<'a, T: 'a, K> Pending<'a, T, K> { Self { cipher, requests, + failed: None, fulfil: Box::new(move |responses| { let mut own = responses.split_front(generated, retrieved)?; fulfil(&mut own) @@ -96,31 +126,45 @@ impl<'a, T: 'a, K> Pending<'a, T, K> { Pending { cipher: self.cipher, requests: self.requests, + failed: self.failed, fulfil: Box::new(move |responses| fulfil(responses).map(f)), } } /// Merge two pendings into one resolving to the pair. Their requests /// concatenate — awaiting the result is still one batched call per - /// request kind. Both must come from the same cipher. - pub fn zip(mut self, other: Pending<'a, U, K>) -> Pending<'a, (T, U), K> { - debug_assert!( - std::ptr::eq(self.cipher, other.cipher), - "zipped pendings must be built from the same cipher" - ); - self.requests.extend(other.requests); + /// request kind. + /// + /// Both must come from the same cipher: the merged assembly dispatches + /// every request through one cipher's backend and keyset, so a pending + /// built on another cipher would have its keys minted under the wrong + /// keyset. That is [`Error::CipherMismatch`], not a debug assertion. If + /// either side already failed, the result is that failure and carries no + /// requests. + pub fn zip(self, other: Pending<'a, U, K>) -> Pending<'a, (T, U), K> { + if !std::ptr::eq(self.cipher, other.cipher) { + return Pending::failed(self.cipher, Error::CipherMismatch); + } + if let Some(error) = self.failed.or(other.failed) { + return Pending::failed(self.cipher, error); + } + let mut requests = self.requests; + requests.extend(other.requests); let first = self.fulfil; let second = other.fulfil; Pending { cipher: self.cipher, - requests: self.requests, + requests, + failed: None, fulfil: Box::new(move |responses| Ok((first(responses)?, second(responses)?))), } } /// Merge any number of same-typed pendings into one resolving to the /// `Vec` — [`zip`](Self::zip) at scale, used by the `Vec` - /// implementations to make a whole column one batched call. + /// implementations to make a whole column one batched call. Same rules + /// as `zip`: every item must come from `cipher`, and the first failed + /// item fails the whole column with no I/O. pub fn all( cipher: &'a StackCipher, items: Vec>, @@ -128,16 +172,19 @@ impl<'a, T: 'a, K> Pending<'a, T, K> { let mut requests = Vec::new(); let mut fulfils = Vec::with_capacity(items.len()); for item in items { - debug_assert!( - std::ptr::eq(cipher, item.cipher), - "merged pendings must be built from the same cipher" - ); + if !std::ptr::eq(cipher, item.cipher) { + return Pending::failed(cipher, Error::CipherMismatch); + } + if let Some(error) = item.failed { + return Pending::failed(cipher, error); + } requests.extend(item.requests); fulfils.push(item.fulfil); } Pending { cipher, requests, + failed: None, fulfil: Box::new(move |responses| { fulfils .into_iter() @@ -161,11 +208,21 @@ where /// Unboxed, so it carries no `Send`/`Sync` demands beyond the backend's /// own; the public [`IntoFuture`] impl boxes it. pub(crate) async fn settle(self) -> Result { + if let Some(error) = self.failed { + return Err(error); + } let mut responses = dispatch(self.cipher, self.requests).await?; (self.fulfil)(&mut responses) } } +/// Awaiting a `Pending` settles it. The boxed future is `Send` on native +/// targets (see [`PendingFuture`]), which is what requires `K: Sync` there: +/// the future holds `&StackCipher`. On wasm32 the future is not `Send`, +/// so the `Sync` demand would only shut out the `Rc`/`RefCell`-shaped +/// sources that are natural on that target — it is dropped, mirroring the +/// [`MaybeSend`] split. +#[cfg(not(target_arch = "wasm32"))] impl<'a, T: 'a, K> IntoFuture for Pending<'a, T, K> where K: DataKeySource + Sync, @@ -178,6 +235,20 @@ where } } +/// See the native impl above; identical minus the `Sync` bound. +#[cfg(target_arch = "wasm32")] +impl<'a, T: 'a, K> IntoFuture for Pending<'a, T, K> +where + K: DataKeySource, +{ + type Output = Result; + type IntoFuture = PendingFuture<'a, T>; + + fn into_future(self) -> Self::IntoFuture { + Box::pin(self.settle()) + } +} + /// Issue the batched ZeroKMS calls for `requests`: at most one /// `generate_keys` and one `retrieve_keys`, whatever the request count. When /// ZeroKMS grows a combined operation (data keys + PRF derivations in one diff --git a/packages/stack-encrypt/tests/target.rs b/packages/stack-encrypt/tests/target.rs index 0f045c119..ebf1785b6 100644 --- a/packages/stack-encrypt/tests/target.rs +++ b/packages/stack-encrypt/tests/target.rs @@ -592,13 +592,13 @@ async fn empty_context_is_rejected_everywhere() { // Terms: an empty context would collapse per-field domain separation. // Rejected during the synchronous build — before any I/O could happen. let eq: Result = "alice".encrypt_into(&generator, "").await; - assert!(matches!(eq, Err(Error::Term(TermError::EmptyContext)))); + assert!(matches!(eq, Err(Error::EmptyContext))); let m: Result = "alice".to_string().encrypt_into(&generator, "").await; - assert!(matches!(m, Err(Error::Term(TermError::EmptyContext)))); + assert!(matches!(m, Err(Error::EmptyContext))); let ore: Result, _> = 7u64.encrypt_into(&generator, "").await; - assert!(matches!(ore, Err(Error::Term(TermError::EmptyContext)))); + assert!(matches!(ore, Err(Error::EmptyContext))); let ope: Result, _> = 7u64.encrypt_into(&generator, "").await; - assert!(matches!(ope, Err(Error::Term(TermError::EmptyContext)))); + assert!(matches!(ope, Err(Error::EmptyContext))); // Descriptor-string convenience methods route through the same guard. assert!(matches!( @@ -621,6 +621,127 @@ async fn empty_context_is_rejected_everywhere() { assert!(matches!(opened, Err(Error::EmptyContext))); } +#[tokio::test] +async fn wrapped_empty_contexts_are_rejected_too() { + // vitaminc blanket-implements the context traits for `Option` and tuples, + // whose encodings of "nothing" are non-empty byte strings. The guard is + // structural, so none of these get through on any path. + let cipher = stack_cipher().await; + + let eq: Result = "alice".encrypt_into(&cipher, None::<&str>).await; + assert!(matches!(eq, Err(Error::EmptyContext))); + let eq: Result = "alice".encrypt_into(&cipher, Some("")).await; + assert!(matches!(eq, Err(Error::EmptyContext))); + let eq: Result = "alice".encrypt_into(&cipher, ("", "")).await; + assert!(matches!(eq, Err(Error::EmptyContext))); + + let ore: Result, _> = 7u64.encrypt_into(&cipher, None::<&str>).await; + assert!(matches!(ore, Err(Error::EmptyContext))); + + let ct: Result = "secret" + .to_string() + .encrypt_into(&cipher, None::<&str>) + .await; + assert!(matches!(ct, Err(Error::EmptyContext))); + + let sealed: StackCipherText = "secret" + .to_string() + .encrypt_into(&cipher, "users/email") + .await + .unwrap(); + let opened: Result = sealed.decrypt_into(&cipher, None::<&str>).await; + assert!(matches!(opened, Err(Error::EmptyContext))); + + // A wrapped context that does carry information still works, and binds. + let sealed: StackCipherText = "secret" + .to_string() + .encrypt_into(&cipher, Some("users/email")) + .await + .unwrap(); + let opened: String = sealed + .decrypt_into(&cipher, Some("users/email")) + .await + .unwrap(); + assert_eq!(opened, "secret"); +} + +#[tokio::test] +async fn empty_context_is_rejected_even_when_there_is_nothing_to_encrypt() { + // An empty descriptor must fail on the fixture with no rows / an absent + // optional, not on the first populated value in production. + let cipher = stack_cipher().await; + + let none: Result, _> = None::.encrypt_into(&cipher, "").await; + assert!(matches!(none, Err(Error::EmptyContext))); + + let empty: Result, _> = + Vec::::new().encrypt_into(&cipher, "").await; + assert!(matches!(empty, Err(Error::EmptyContext))); + + let none: Result, _> = None::.decrypt_into(&cipher, "").await; + assert!(matches!(none, Err(Error::EmptyContext))); + + let empty: Result, _> = Vec::::new() + .decrypt_into(&cipher, "") + .await; + assert!(matches!(empty, Err(Error::EmptyContext))); +} + +#[tokio::test] +async fn a_failed_field_fails_the_record_before_any_kms_call() { + // One misconfigured field must not cause the record's other fields to + // mint data keys that are then thrown away. + let (cipher, generates, _) = counting_cipher().await; + let a = "a".to_string(); + let b = "b".to_string(); + + let zipped = StackCipherText::encrypt_from(&a, &cipher, "") + .zip(StackCipherText::encrypt_from(&b, &cipher, "users/x")) + .await; + assert!(matches!(zipped, Err(Error::EmptyContext))); + + let column = Pending::all( + &cipher, + vec![ + StackCipherText::encrypt_from(&b, &cipher, "users/x"), + StackCipherText::encrypt_from(&a, &cipher, ""), + ], + ) + .await; + assert!(matches!(column, Err(Error::EmptyContext))); + + assert_eq!( + generates.load(AtomicOrdering::SeqCst), + 0, + "a failed sibling must drop the batch, not dispatch it" + ); +} + +#[tokio::test] +async fn pendings_from_different_ciphers_refuse_to_merge() { + let (cipher_a, generates, _) = counting_cipher().await; + let cipher_b = counting_cipher().await.0; + let v = "v".to_string(); + let w = "w".to_string(); + + let zipped = StackCipherText::encrypt_from(&v, &cipher_a, "users/x") + .zip(StackCipherText::encrypt_from(&w, &cipher_b, "users/x")) + .await; + assert!(matches!(zipped, Err(Error::CipherMismatch))); + + let column = Pending::all( + &cipher_a, + vec![ + StackCipherText::encrypt_from(&v, &cipher_a, "users/x"), + StackCipherText::encrypt_from(&w, &cipher_b, "users/x"), + ], + ) + .await; + assert!(matches!(column, Err(Error::CipherMismatch))); + + assert_eq!(generates.load(AtomicOrdering::SeqCst), 0); +} + #[tokio::test] async fn an_overdrawing_fulfilment_is_a_response_shape_error() { // A fulfilment is scoped to exactly the responses its requests asked for: From 6f6d6fb0ca366b41367f4a116ba7af3ed73710a0 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Thu, 27 Aug 2026 14:26:45 +1000 Subject: [PATCH 438/686] fix(stack-encrypt): make the zerokms_auth example exit cleanly without credentials The header promised the example is safe to run anywhere, but with only the CS_* variables set it built a second cipher over an empty `MY_SERVICE_TOKEN` and failed the `init()` round-trip; and a configured but rejected credential (a stale device session) came back as `Error::Kms`, which fell through to the generic error arm instead of the "not configured" guidance. The custom-strategy section now skips itself when the token is absent, and `Error::Kms` from `StackCipher::new()` gets its own explanation and a clean exit. Claude-Session: https://claude.ai/code/session_01HU1Bbw4eQp9kEEneXxDcKr --- packages/stack-encrypt/examples/zerokms_auth.rs | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/packages/stack-encrypt/examples/zerokms_auth.rs b/packages/stack-encrypt/examples/zerokms_auth.rs index 37e0b1bf6..f449f0f16 100644 --- a/packages/stack-encrypt/examples/zerokms_auth.rs +++ b/packages/stack-encrypt/examples/zerokms_auth.rs @@ -36,6 +36,14 @@ async fn main() -> Result<(), Box> { println!("set CS_CLIENT_ID / CS_CLIENT_KEY and access-key credentials to run this."); return Ok(()); } + // Configured but not accepted — typically a stale device session in + // ~/.cipherstash or an access key for another workspace. `auto()` only + // checks that a strategy *exists*; ZeroKMS is the first to say no. + Err(stack_encrypt::Error::Kms(why)) => { + println!("could not reach or authenticate with ZeroKMS: {why}"); + println!("check the credentials `auto()` detected (CS_* variables, ~/.cipherstash)."); + return Ok(()); + } Err(other) => return Err(other.into()), }; println!("connected; keyset {}", cipher.keyset_id()); @@ -64,7 +72,12 @@ async fn main() -> Result<(), Box> { // The same seam takes `AccessKeyStrategy`, `DeviceSessionStrategy`, or an // OIDC federation strategy when you want to name one explicitly rather // than let `auto()` detect it. - let token = std::env::var("MY_SERVICE_TOKEN").unwrap_or_default(); + // This section needs a real token: building the cipher resolves the keyset + // and loads its index key, which is a round-trip that must authenticate. + let Ok(token) = std::env::var("MY_SERVICE_TOKEN") else { + println!("MY_SERVICE_TOKEN not set; skipping the custom-strategy section."); + return Ok(()); + }; let strategy = AuthStrategyFn::new(move || { // Your token source: a broker, a sidecar, a cached credential. Called // whenever ZeroKMS needs a fresh token, so refresh belongs in here. From 8a92fa2b045573858674c5ed176c2c54ee0a2da3 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Thu, 27 Aug 2026 14:27:22 +1000 Subject: [PATCH 439/686] docs(rfc-0002): record the final-review corrections Claude-Session: https://claude.ai/code/session_01HU1Bbw4eQp9kEEneXxDcKr --- ...nc-shape-for-target-directed-encryption.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/docs/rfcs/0002-async-shape-for-target-directed-encryption.md b/docs/rfcs/0002-async-shape-for-target-directed-encryption.md index b80fcf6b0..afa566415 100644 --- a/docs/rfcs/0002-async-shape-for-target-directed-encryption.md +++ b/docs/rfcs/0002-async-shape-for-target-directed-encryption.md @@ -502,6 +502,25 @@ Review of #2146/#2147 then corrected three more: key-returning visitor cannot survive the two-party backend. Wire format unchanged (`tests/term_bytes.rs`). +The final review then held the implementation to two of this RFC's own +claims: + +- **"`dispatch` is the one place that changes" was not yet true.** The + cipher-directed `seal` and `decipher` carried their own copies of the + ZeroKMS plumbing. They now build a `Pending` through the same + `seal_pending` / `decipher_pending` builders the target impls use and + settle it via a crate-private, unboxed `Pending::settle`; there is one + walker, one wire convention, one dispatch, and a test that ciphertext from + either API opens under the other. +- **"Rejected during the synchronous build, before any I/O" had gaps.** + `Pending` now records a build-time failure and `zip`/`all` drop the + assembly's requests when one side has failed, so a misconfigured field + never mints keys for its siblings; the empty-context guard is structural + over PAE (so `None` / `Some("")` / tuples of empties are caught) and runs + on columns and optionals even when there is nothing to encrypt; and merging + pendings from different ciphers is `Error::CipherMismatch` rather than a + `debug_assert`. + ## 8. Where findings get recorded Three homes, by durability: From 8e0ed1b0c7ceb392b182245526134965024d4b46 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Thu, 27 Aug 2026 14:52:42 +1000 Subject: [PATCH 440/686] docs(stack-encrypt): examples say `npx stash auth login` is sufficient MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With the client key now found in the profile directory (cipherstash/cipherstash-suite#2146), a logged-in developer machine needs no environment variables to run the examples. Say so in each header, and make `zerokms_auth` describe the real lookup order for both credentials — environment first, then the current workspace's `auth.json` / `secretkey.json` — instead of telling profile users to set `CS_CLIENT_ID`. Claude-Session: https://claude.ai/code/session_01HU1Bbw4eQp9kEEneXxDcKr --- .../examples/encrypted_record.rs | 8 +++--- packages/stack-encrypt/examples/mixed_user.rs | 8 +++--- .../stack-encrypt/examples/search_terms.rs | 8 +++--- .../stack-encrypt/examples/zerokms_auth.rs | 25 ++++++++++++------- 4 files changed, 31 insertions(+), 18 deletions(-) diff --git a/packages/stack-encrypt/examples/encrypted_record.rs b/packages/stack-encrypt/examples/encrypted_record.rs index b375ee601..867672812 100644 --- a/packages/stack-encrypt/examples/encrypted_record.rs +++ b/packages/stack-encrypt/examples/encrypted_record.rs @@ -19,9 +19,11 @@ //! cargo run -p stack-encrypt --example encrypted_record //! ``` //! -//! Talks to real ZeroKMS: needs `CS_CLIENT_ID` / `CS_CLIENT_KEY` and access-key -//! or device-session credentials in the environment (see the `zerokms_auth` -//! example for where they come from). +//! Talks to real ZeroKMS. On a developer machine, `npx stash auth login` is +//! sufficient: the cipher finds both the access token and the client key in +//! the CLI's profile directory. In CI, set `CS_CLIENT_ACCESS_KEY` / +//! `CS_WORKSPACE_CRN` and `CS_CLIENT_ID` / `CS_CLIENT_KEY` instead (see the +//! `zerokms_auth` example for the lookup order). use stack_encrypt::sem::{EqualityTerm, OreTerm}; use stack_encrypt::target::{ diff --git a/packages/stack-encrypt/examples/mixed_user.rs b/packages/stack-encrypt/examples/mixed_user.rs index cd16b43a4..3578c4cf4 100644 --- a/packages/stack-encrypt/examples/mixed_user.rs +++ b/packages/stack-encrypt/examples/mixed_user.rs @@ -24,9 +24,11 @@ //! cargo run -p stack-encrypt --example mixed_user //! ``` //! -//! Talks to real ZeroKMS: needs `CS_CLIENT_ID` / `CS_CLIENT_KEY` and access-key -//! or device-session credentials in the environment (see the `zerokms_auth` -//! example for where they come from). +//! Talks to real ZeroKMS. On a developer machine, `npx stash auth login` is +//! sufficient: the cipher finds both the access token and the client key in +//! the CLI's profile directory. In CI, set `CS_CLIENT_ACCESS_KEY` / +//! `CS_WORKSPACE_CRN` and `CS_CLIENT_ID` / `CS_CLIENT_KEY` instead (see the +//! `zerokms_auth` example for the lookup order). use stack_encrypt::{ Cipher, CipherText, Decipher, Decrypt, Encrypt, IntoAad, StackCipher, StackCipherText, diff --git a/packages/stack-encrypt/examples/search_terms.rs b/packages/stack-encrypt/examples/search_terms.rs index 33296bfd9..19a6130c7 100644 --- a/packages/stack-encrypt/examples/search_terms.rs +++ b/packages/stack-encrypt/examples/search_terms.rs @@ -12,9 +12,11 @@ //! cargo run -p stack-encrypt --example search_terms //! ``` //! -//! Talks to real ZeroKMS: needs `CS_CLIENT_ID` / `CS_CLIENT_KEY` and access-key -//! or device-session credentials in the environment (see the `zerokms_auth` -//! example for where they come from). +//! Talks to real ZeroKMS. On a developer machine, `npx stash auth login` is +//! sufficient: the cipher finds both the access token and the client key in +//! the CLI's profile directory. In CI, set `CS_CLIENT_ACCESS_KEY` / +//! `CS_WORKSPACE_CRN` and `CS_CLIENT_ID` / `CS_CLIENT_KEY` instead (see the +//! `zerokms_auth` example for the lookup order). use stack_encrypt::sem::{EqualityTerm, MatchTerm, OreTerm}; use stack_encrypt::target::EncryptExt; diff --git a/packages/stack-encrypt/examples/zerokms_auth.rs b/packages/stack-encrypt/examples/zerokms_auth.rs index f449f0f16..7dcd5d2d5 100644 --- a/packages/stack-encrypt/examples/zerokms_auth.rs +++ b/packages/stack-encrypt/examples/zerokms_auth.rs @@ -9,8 +9,9 @@ //! cargo run -p stack-encrypt --example zerokms_auth //! ``` //! -//! Without ZeroKMS credentials in the environment it prints what it *would* -//! do and exits — so it is safe to run anywhere, and CI builds it either way. +//! On a developer machine, `npx stash auth login` is sufficient. Without any +//! credentials it prints what it *would* do and exits — so it is safe to run +//! anywhere, and CI builds it either way. use stack_auth::{AuthError, AuthStrategyFn, SecretToken, ServiceToken}; use stack_encrypt::StackCipher; @@ -20,20 +21,26 @@ use stack_kms::{EnvKeyProvider, StackKmsBuilder}; async fn main() -> Result<(), Box> { // --- The default: credentials from the environment ---------------------- // - // `StackCipher::new()` is `StackKmsBuilder::auto()` plus the environment's - // client key, plus a keyset resolution. `auto()` detects whichever - // strategy the environment is configured for (an access key in CI, a - // device session on a developer machine). + // `StackCipher::new()` is `StackKmsBuilder::auto()` plus a client key, + // plus a keyset resolution. Both credentials are looked up the same way — + // environment first, then the current workspace in the CLI's profile + // directory (`~/.cipherstash`, written by `npx stash auth login`): // - // CS_WORKSPACE_CRN + CS_CLIENT_ACCESS_KEY authenticate; CS_CLIENT_ID + - // CS_CLIENT_KEY are the client key that unwraps data keys. + // access token: CS_CLIENT_ACCESS_KEY + CS_WORKSPACE_CRN, else auth.json + // client key: CS_CLIENT_ID + CS_CLIENT_KEY, else secretkey.json + // + // So a logged-in developer machine needs nothing else; CI sets the four + // variables. let cipher = match StackCipher::new().await { Ok(cipher) => cipher, // Nothing to connect to: say so and exit cleanly, so the example is // safe to run anywhere. Err(stack_encrypt::Error::Config(why)) => { println!("not configured for ZeroKMS: {why}"); - println!("set CS_CLIENT_ID / CS_CLIENT_KEY and access-key credentials to run this."); + println!( + "run `npx stash auth login`, or set CS_CLIENT_ACCESS_KEY / CS_WORKSPACE_CRN \ + and CS_CLIENT_ID / CS_CLIENT_KEY, to run this." + ); return Ok(()); } // Configured but not accepted — typically a stale device session in From bb02a0ee8b2192280644cee18593985a07159706 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Thu, 27 Aug 2026 15:57:15 +1000 Subject: [PATCH 441/686] fix(stack-encrypt): a key-count mismatch while binding is ResponseShape, not Aead MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `decipher_from_responses` reported leftover retrieved keys — and, via `From`, too few of them — as `Error::Aead`. Both mean `retrieve_requests` and `bind_keys` disagreed about the tree's shape, which is a composition bug in this module; `Aead` reads as tampering or an AAD mismatch and would send a caller looking in the wrong place (review finding on cipherstash/cipherstash-suite#2147). Map both to `Error::ResponseShape`, and widen that variant's doc to cover under-consumption as well as over-drawing. Claude-Session: https://claude.ai/code/session_01HU1Bbw4eQp9kEEneXxDcKr --- packages/stack-encrypt/src/cipher.rs | 11 ++++++----- packages/stack-encrypt/src/target/mod.rs | 10 ++++++---- 2 files changed, 12 insertions(+), 9 deletions(-) diff --git a/packages/stack-encrypt/src/cipher.rs b/packages/stack-encrypt/src/cipher.rs index f10403e60..aa71508a3 100644 --- a/packages/stack-encrypt/src/cipher.rs +++ b/packages/stack-encrypt/src/cipher.rs @@ -128,11 +128,12 @@ pub enum Error { /// for a reason of its own. #[error(transparent)] Other(Box), - /// A [`Pending`](crate::target::Pending) fulfilment drew more responses — - /// or a different kind of response — than its requests asked for. Always a - /// composition bug in an `EncryptFrom`/`DecryptFrom` implementation, - /// never a data error. - #[error("a pending fulfilment drew responses its requests never asked for")] + /// A [`Pending`](crate::target::Pending) fulfilment's requests and + /// responses did not line up: it drew more responses — or a different + /// kind — than its requests asked for, or left some of them unconsumed. + /// Always a composition bug in an `EncryptFrom`/`DecryptFrom` + /// implementation, never a data error. + #[error("a pending fulfilment's responses did not match its requests")] ResponseShape, /// [`Pending`](crate::target::Pending)s built on different /// [`StackCipher`] instances were merged (`zip` / `all`). An assembly diff --git a/packages/stack-encrypt/src/target/mod.rs b/packages/stack-encrypt/src/target/mod.rs index a9dc33679..ce19b52ec 100644 --- a/packages/stack-encrypt/src/target/mod.rs +++ b/packages/stack-encrypt/src/target/mod.rs @@ -513,11 +513,13 @@ fn decipher_from_responses( responses: &mut Responses, ) -> Result { let mut keys = responses.drain_retrieved(); - let keyed = bind_keys(ciphertext, &mut keys)?; - // Every key must have been consumed; leftovers mean the tree shape and - // the request collection disagreed. + // Too few keys for the tree, or keys left over once it is bound, both + // mean `retrieve_requests` and `bind_keys` disagreed about the tree's + // shape: a composition bug in this module, not a data error — so + // `ResponseShape`, never `Aead`, which would read as tampering. + let keyed = bind_keys(ciphertext, &mut keys).map_err(|_| Error::ResponseShape)?; if keys.next().is_some() { - return Err(Error::Aead); + return Err(Error::ResponseShape); } Ok(StackDecipher::over(keyed)) } From f5783dca30ffe75ad23afd22f0f122156de53c70 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Fri, 28 Aug 2026 10:59:37 +1000 Subject: [PATCH 442/686] fix(stack-encrypt): an under-drawing fulfilment is a ResponseShape error MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Pending::request` scoped each fulfilment to exactly the responses its requests asked for, called it, and then dropped the scoped `Responses` without checking it was empty. Over-drawing was caught; under-drawing was not. A fulfilment could declare a data-key request, have ZeroKMS mint the key, consume none of it, and settle `Ok` — the key minted, billed and audited, then silently discarded. That contradicts both RFC 0002 §4.3 and `Error::ResponseShape`'s own documentation, which say a fulfilment that "left some of them unconsumed" is an error. The scope is meant to be exact in both directions. `Responses::is_exhausted` now backs a post-fulfilment check. The test that pinned the old behaviour asserted the wrong half of the guarantee: that under-drawing does not shift a sibling's slice is true and worth keeping, but it belongs to `split_front`, which already has its own tests for it. It is replaced by three tests covering full, partial and cross-kind under-draw. Claude-Session: https://claude.ai/code/session_016kh7tpj1P1j3oShkAUbZU6 --- packages/stack-encrypt/src/target/pending.rs | 67 ++++++++++++++++++-- packages/stack-encrypt/src/target/request.rs | 7 ++ 2 files changed, 67 insertions(+), 7 deletions(-) diff --git a/packages/stack-encrypt/src/target/pending.rs b/packages/stack-encrypt/src/target/pending.rs index 7a299b32d..faf9c6a82 100644 --- a/packages/stack-encrypt/src/target/pending.rs +++ b/packages/stack-encrypt/src/target/pending.rs @@ -101,6 +101,12 @@ impl<'a, T: 'a, K> Pending<'a, T, K> { /// batched call, scoped to exactly the responses `requests` asked for — /// drawing more (or another kind) is [`Error::ResponseShape`], and can /// never consume a sibling pending's responses. + /// + /// The scope is exact in both directions: a fulfilment must also consume + /// *every* response it asked for. Leaving one behind is + /// [`Error::ResponseShape`] too — the key was minted at ZeroKMS, and + /// silently discarding it means the pending's declared requests do not + /// describe what it actually does. pub fn request(cipher: &'a StackCipher, requests: Vec, fulfil: F) -> Self where F: FnOnce(&mut Responses) -> Result + MaybeSend + 'a, @@ -112,7 +118,11 @@ impl<'a, T: 'a, K> Pending<'a, T, K> { failed: None, fulfil: Box::new(move |responses| { let mut own = responses.split_front(generated, retrieved)?; - fulfil(&mut own) + let value = fulfil(&mut own)?; + if !own.is_exhausted() { + return Err(Error::ResponseShape); + } + Ok(value) }), } } @@ -567,17 +577,60 @@ mod tests { )); } - /// A pending that under-draws leaves its unused responses behind rather - /// than shifting every sibling's slice. + /// Under-drawing is an error for the same reason over-drawing is: the + /// pending's declared requests must describe what it actually consumes. + /// A key was minted at ZeroKMS; leaving it behind is a composition bug, + /// not a cheaper request. + /// + /// (That it does not *shift* a sibling's slice is a separate guarantee, + /// covered by `Responses::split_front`'s own tests.) #[tokio::test] - async fn under_drawing_does_not_shift_a_siblings_responses() { + async fn under_drawing_responses_is_a_response_shape_error() { let cipher = cipher().await; let lazy: Pending<'_, (), _> = Pending::request(&cipher, vec![Request::generate_data_key()], |_| Ok(())); - let ((), tags) = lazy.zip(generating(&cipher, 1)).await.unwrap(); + let result = lazy.zip(generating(&cipher, 1)).await; - assert_eq!(tags.len(), 1); - assert_eq!(cipher.kms().generate_calls(), 1); + assert!(matches!( + result, + Err::<((), Vec>), _>(Error::ResponseShape) + )); + } + + /// Partial consumption counts too: two requested, one drawn. + #[tokio::test] + async fn drawing_fewer_responses_than_requested_is_a_response_shape_error() { + let cipher = cipher().await; + let requests = vec![Request::generate_data_key(), Request::generate_data_key()]; + let lazy: Pending<'_, Vec, _> = Pending::request(&cipher, requests, |responses| { + responses.next_generated_key().map(|key| key.tag) + }); + + assert!(matches!( + lazy.await, + Err::, _>(Error::ResponseShape) + )); + } + + /// The two kinds are tracked separately: consuming every generated key + /// but none of the retrieved ones is still an under-draw. + #[tokio::test] + async fn leaving_the_other_kind_unconsumed_is_a_response_shape_error() { + let cipher = cipher().await; + let mut pairs = generating_pairs(&cipher, 1).await.unwrap(); + let (iv, tag) = pairs.remove(0); + let requests = vec![ + Request::generate_data_key(), + Request::retrieve_data_key(iv, tag), + ]; + let lazy: Pending<'_, Vec, _> = Pending::request(&cipher, requests, |responses| { + responses.next_generated_key().map(|key| key.tag) + }); + + assert!(matches!( + lazy.await, + Err::, _>(Error::ResponseShape) + )); } #[tokio::test] diff --git a/packages/stack-encrypt/src/target/request.rs b/packages/stack-encrypt/src/target/request.rs index b3581f746..bcab1d172 100644 --- a/packages/stack-encrypt/src/target/request.rs +++ b/packages/stack-encrypt/src/target/request.rs @@ -108,6 +108,13 @@ impl Responses { }) } + /// Whether every response in this view has been drawn. Checked after a + /// fulfilment returns: a fulfilment that asked for a key and left it + /// behind is a composition bug, not a cheaper request. + pub(super) fn is_exhausted(&self) -> bool { + self.generated.is_empty() && self.retrieved.is_empty() + } + pub(crate) fn drain_generated(&mut self) -> impl Iterator + '_ { self.generated.drain(..) } From 9ae093432fa790d3cb4b1ac3d8de6db4548b8015 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Fri, 28 Aug 2026 10:59:53 +1000 Subject: [PATCH 443/686] fix(stack-encrypt): recognise context framing by position, not by prefix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `is_degenerate_context` treated any PAE piece beginning `vitaminc/` as ignorable framing, at any depth and in any position. Caller data that happens to share that prefix was therefore read as framing, and a context made only of such pieces was rejected as empty: `"vitaminc/customer"` and `Some("vitaminc/customer")` both failed with `EmptyContext` despite carrying perfectly good information. It fails closed, so nothing was derived under a weakened context — but it refuses contexts it should accept, contrary to the documented definition of "carrying no caller-supplied information". The prefix rule existed for a real reason: `""` does not encode to zero bytes. `"".into_prf_context()` is `pae([context-value, , ""])`, so seeing that the value is empty means seeing past two framing tags. The mistake was testing for them by prefix rather than by structure. The two channels turn out to need different rules, so the check is now two functions: * `is_degenerate_aad` — the AAD channel has no framing tags at all (`"x".into_aad()` is `b"x"`; `None`, `Some(_)` and tuples are bare PAE nodes), so the rule is purely structural. This is also where `0u64` is still rejected: its eight zero bytes are byte-identical to `pae([])`. * `is_degenerate_prf_context` — every caller value here is wrapped in a framing node, so framing is matched by exact tag *and* arity at piece 0, and the recursion descends only into the positions that hold caller data. Caller bytes can never be mistaken for a tag, because they never land at piece 0 of a framing node. Adds the adversarial tests: every framing constant supplied as caller data on both channels, bare and wrapped; a hand-built node that impersonates framing, judged on its payload; and a right-tag/wrong-arity node. Derivations are unchanged — the term byte pins still pass. Claude-Session: https://claude.ai/code/session_016kh7tpj1P1j3oShkAUbZU6 --- packages/stack-encrypt/src/sem/mod.rs | 6 +- packages/stack-encrypt/src/target/mod.rs | 168 +++++++++++++++++++---- 2 files changed, 145 insertions(+), 29 deletions(-) diff --git a/packages/stack-encrypt/src/sem/mod.rs b/packages/stack-encrypt/src/sem/mod.rs index 06bc681bc..d7b30b218 100644 --- a/packages/stack-encrypt/src/sem/mod.rs +++ b/packages/stack-encrypt/src/sem/mod.rs @@ -81,7 +81,7 @@ use vitaminc_prf::{ }; use zeroize::Zeroize; -use crate::target::{is_degenerate_context, EncryptContext, EncryptFrom, Pending}; +use crate::target::{is_degenerate_prf_context, EncryptContext, EncryptFrom, Pending}; use crate::{Error, StackCipher}; // The `/v1` suffix versions the *derivation* (domain + input framing), not the @@ -143,11 +143,11 @@ impl TermError { /// Reject an empty context before any derivation — see /// [`TermError::EmptyContext`]. "Empty" is structural -/// ([`is_degenerate_context`]): `()`, `""`, `None`, `Some("")`, tuples of +/// ([`is_degenerate_prf_context`]): `()`, `""`, `None`, `Some("")`, tuples of /// empties and their nestings all carry no caller information, and every /// field using one would share a single derivation domain. fn require_context(context: &PrfContext<'_>) -> Result<(), TermError> { - if is_degenerate_context(context.as_bytes()) { + if is_degenerate_prf_context(context.as_bytes()) { return Err(TermError::EmptyContext); } Ok(()) diff --git a/packages/stack-encrypt/src/target/mod.rs b/packages/stack-encrypt/src/target/mod.rs index ce19b52ec..440b35146 100644 --- a/packages/stack-encrypt/src/target/mod.rs +++ b/packages/stack-encrypt/src/target/mod.rs @@ -219,30 +219,90 @@ pub trait DecryptContext<'a>: IntoAad<'a> + Clone {} impl<'a, T> DecryptContext<'a> for T where T: IntoAad<'a> + Clone {} -/// Does an encoded context (AAD or PRF context bytes) carry no caller-supplied -/// information? See [`EncryptContext`] for what that means and why it is -/// rejected. +/// The framing tags vitaminc's PRF context encoding inserts, each of which +/// occupies **piece 0** of the PAE node it labels. Matched exactly and only +/// in that position — a prefix test would classify any caller string +/// beginning `vitaminc/` as framing (`Some("vitaminc/customer")` would read +/// as empty), which is the opposite of what this check is for. /// -/// Structural over vitaminc's PAE framing (`LE64(count) || (LE64(len) || -/// piece)*`): a context is degenerate if it is empty, or if it parses as a -/// PAE whose every piece is either a vitaminc framing tag (`vitaminc/…` -/// domain or encoding label) or itself degenerate. Bytes that are not a -/// well-formed PAE are caller content and count as information. This covers -/// `()`, the typed `""`/`b""` encodings, `None` (`pae([])`), `Some()` -/// and tuples of empties, at any nesting depth. -pub(crate) fn is_degenerate_context(bytes: &[u8]) -> bool { - const FRAMING_PREFIX: &[u8] = b"vitaminc/"; +/// Mirrored from `vitaminc_prf::context`, where they are private. Pinned by +/// the `context_tests` below, which build every shape through the public API +/// rather than asserting the literals. +mod prf_framing { + /// `pae([CONTEXT_VALUE, , value])` — a typed leaf. + pub(super) const CONTEXT_VALUE: &[u8] = b"vitaminc/prf/context-value/v1"; + /// `pae([OPTION_SOME, inner])`. + pub(super) const OPTION_SOME: &[u8] = b"vitaminc/prf/option-some/v1"; + /// `pae([MAP_ENTRY, base, key])` — `key` is raw caller bytes. + pub(super) const MAP_ENTRY: &[u8] = b"vitaminc/prf/map-entry/v1"; + /// `pae([REFINE, base, component])` — both are encoded contexts. + pub(super) const REFINE: &[u8] = b"vitaminc/prf/refine/v1"; +} + +/// Do encoded **AAD** bytes carry no caller-supplied information? See +/// [`EncryptContext`] for what that means and why it is rejected. +/// +/// The AAD channel carries no framing tags of its own: a leaf is its own raw +/// bytes (`"x".into_aad() == b"x"`), and `None`, `Some(_)` and tuples are +/// bare PAE nodes. So the rule is purely structural — degenerate if empty, or +/// if it parses as a PAE (`LE64(count) || (LE64(len) || piece)*`) whose every +/// piece is itself degenerate. Bytes that are not a well-formed PAE are +/// caller content and count as information. +/// +/// Covers `()`, `""`, `b""`, `None` (`pae([])`), `Some()` and tuples +/// of empties at any nesting depth — and `0u64`, whose eight zero bytes are +/// byte-identical to `pae([])`. +/// +/// (The tags vitaminc applies *inside* the cipher — `Aad::for_leaf`, +/// `for_map_entry`, the markers — are derived after this check runs, from +/// the caller-visible AAD this sees.) +pub(crate) fn is_degenerate_aad(bytes: &[u8]) -> bool { if bytes.is_empty() { return true; } match parse_pae(bytes) { - Some(pieces) => pieces - .iter() - .all(|piece| piece.starts_with(FRAMING_PREFIX) || is_degenerate_context(piece)), + Some(pieces) => pieces.iter().all(|piece| is_degenerate_aad(piece)), None => false, } } +/// Do encoded **PRF context** bytes carry no caller-supplied information? +/// +/// Unlike the AAD channel, every caller value here is wrapped in a framing +/// node — `"x".into_prf_context()` is `pae([CONTEXT_VALUE, , +/// b"x"])` — so the check has to see past the framing to reach the value. +/// Framing is recognised by exact tag *and* arity at piece 0, and the +/// recursion descends only into the positions that actually hold caller +/// data. That is what keeps caller bytes from ever being mistaken for a tag: +/// caller data never lands at piece 0 of a framing node, because it is always +/// wrapped one level deeper. +/// +/// A node that is not framing (a tuple, or a `PrfContext` the caller built by +/// hand) is degenerate only if every one of its pieces is. Bytes that are not +/// a well-formed PAE are caller content. +pub(crate) fn is_degenerate_prf_context(bytes: &[u8]) -> bool { + if bytes.is_empty() { + return true; + } + let Some(pieces) = parse_pae(bytes) else { + return false; + }; + match (pieces.first(), pieces.len()) { + // The value is raw caller bytes, not a nested context: judge it + // structurally. This is what still rejects `0u64` — eight zero bytes + // are byte-identical to `pae([])`. + (Some(&tag), 3) if tag == prf_framing::CONTEXT_VALUE => is_degenerate_aad(pieces[2]), + (Some(&tag), 2) if tag == prf_framing::OPTION_SOME => is_degenerate_prf_context(pieces[1]), + (Some(&tag), 3) if tag == prf_framing::MAP_ENTRY => { + is_degenerate_prf_context(pieces[1]) && pieces[2].is_empty() + } + (Some(&tag), 3) if tag == prf_framing::REFINE => { + is_degenerate_prf_context(pieces[1]) && is_degenerate_prf_context(pieces[2]) + } + _ => pieces.iter().all(|piece| is_degenerate_prf_context(piece)), + } +} + /// Parse `bytes` as exactly one PAE encoding: `LE64(count)` then `count` /// `LE64(len) || piece` frames, consuming every byte. `None` if the bytes are /// not that shape. @@ -461,7 +521,7 @@ where // An empty context would leave the leaf AAD carrying only the key // tag, making ciphertexts transplantable between ()-context fields — // see `EncryptContext`. - if is_degenerate_context(aad.as_bytes()) { + if is_degenerate_aad(aad.as_bytes()) { return Pending::failed(cipher, Error::EmptyContext); } match source.clone().encrypt_with_aad(cipher, aad) { @@ -546,7 +606,7 @@ where let aad = context.into_aad().into_owned(); // Symmetric with the encrypt side: the target layer never encrypts // under an empty context, so it never decrypts under one either. - if is_degenerate_context(aad.as_bytes()) { + if is_degenerate_aad(aad.as_bytes()) { return Pending::failed(cipher, Error::EmptyContext); } let requests = retrieve_requests(&source); @@ -611,7 +671,7 @@ where { // Validate the context even for an empty column: an empty descriptor // must fail on the fixture with no rows, not on the first real one. - if is_degenerate_context(context.clone().into_aad().as_bytes()) { + if is_degenerate_aad(context.clone().into_aad().as_bytes()) { return Pending::failed(cipher, Error::EmptyContext); } let items = source @@ -637,7 +697,7 @@ where S: 'a, Self: 'a, { - if is_degenerate_context(context.clone().into_aad().as_bytes()) { + if is_degenerate_aad(context.clone().into_aad().as_bytes()) { return Pending::failed(cipher, Error::EmptyContext); } let items = source @@ -667,7 +727,7 @@ where { // `None` derives nothing, but the field's context is still checked so // a misconfigured optional field fails whether or not it is present. - if is_degenerate_context(context.clone().into_aad().as_bytes()) { + if is_degenerate_aad(context.clone().into_aad().as_bytes()) { return Pending::failed(cipher, Error::EmptyContext); } match source { @@ -692,7 +752,7 @@ where S: 'a, Self: 'a, { - if is_degenerate_context(context.clone().into_aad().as_bytes()) { + if is_degenerate_aad(context.clone().into_aad().as_bytes()) { return Pending::failed(cipher, Error::EmptyContext); } match source { @@ -707,14 +767,14 @@ mod context_tests { use vitaminc_aead::{Aad, IntoAad}; use vitaminc_prf::{IntoPrfContext, PrfContext}; - use super::is_degenerate_context; + use super::{is_degenerate_aad, is_degenerate_prf_context}; fn aad<'a>(ctx: impl IntoAad<'a>) -> bool { - is_degenerate_context(ctx.into_aad().as_bytes()) + is_degenerate_aad(ctx.into_aad().as_bytes()) } fn prf<'a>(ctx: impl IntoPrfContext<'a>) -> bool { - is_degenerate_context(ctx.into_prf_context().as_bytes()) + is_degenerate_prf_context(ctx.into_prf_context().as_bytes()) } #[test] @@ -758,6 +818,62 @@ mod context_tests { assert!(!prf(PrfContext::from_slice(b"raw"))); } + /// Caller data that *looks* like vitaminc framing is still caller data. + /// + /// The tags are matched by exact value at piece 0 of a framing node, and + /// caller data never lands there — on the PRF channel it is wrapped a + /// level deeper by `CONTEXT_VALUE`, and the AAD channel has no tags at + /// all. A prefix test over every piece got all of these wrong, rejecting + /// a legitimate context as empty. + #[test] + fn caller_data_shaped_like_framing_still_carries_information() { + for tag in [ + "vitaminc/customer", + "vitaminc/", + "vitaminc/prf/context-value/v1", + "vitaminc/prf/option-some/v1", + "vitaminc/prf/map-entry/v1", + "vitaminc/prf/refine/v1", + "vitaminc/prf/encoding/utf8/v1", + "vitaminc/aead/leaf", + ] { + assert!(!aad(tag), "aad({tag:?})"); + assert!(!aad(Some(tag)), "aad(Some({tag:?}))"); + assert!(!aad((tag, "")), "aad(({tag:?}, \"\"))"); + assert!(!prf(tag), "prf({tag:?})"); + assert!(!prf(Some(tag)), "prf(Some({tag:?}))"); + assert!(!prf((tag, "")), "prf(({tag:?}, \"\"))"); + } + } + + /// A hand-built `PrfContext` that impersonates a framing node is judged + /// on the data it actually frames — the tag alone buys nothing. + #[test] + fn a_hand_built_framing_node_is_judged_on_its_payload() { + let some = + |inner: &[u8]| PrfContext::pae(&[b"vitaminc/prf/option-some/v1", inner]).into_owned(); + // Framing a real value: information. + assert!(!prf(some("users/email".into_prf_context().as_bytes()))); + // Framing an empty value: still empty, and still rejected. + assert!(prf(some("".into_prf_context().as_bytes()))); + // A bare tag with nothing under it is not a well-formed framing node + // and is read as a one-piece PAE of caller bytes. + assert!(!prf(PrfContext::pae(&[b"vitaminc/prf/option-some/v1"]))); + } + + /// The arity check matters: a node carrying the right tag but the wrong + /// number of pieces is not that framing shape and is judged piecewise. + #[test] + fn a_framing_tag_with_the_wrong_arity_is_not_framing() { + let wrong = PrfContext::pae(&[ + b"vitaminc/prf/context-value/v1", + b"vitaminc/prf/encoding/utf8/v1", + b"users", + b"email", + ]); + assert!(!prf(wrong)); + } + #[test] fn a_zero_u64_is_byte_identical_to_none_and_rejected_with_it() { assert_eq!( @@ -773,10 +889,10 @@ mod context_tests { let mut bytes = Vec::new(); bytes.extend_from_slice(&2u64.to_le_bytes()); bytes.extend_from_slice(&0u64.to_le_bytes()); - assert!(!is_degenerate_context(&bytes)); + assert!(!is_degenerate_aad(&bytes)); // A well-formed empty PAE followed by a trailing byte. let mut bytes = 0u64.to_le_bytes().to_vec(); bytes.push(0); - assert!(!is_degenerate_context(&bytes)); + assert!(!is_degenerate_aad(&bytes)); } } From c3711ceb96ea6b7753c61da04649aa671fdd295d Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Fri, 28 Aug 2026 11:00:01 +1000 Subject: [PATCH 444/686] docs(stack-encrypt): hold the example's service token in a SecretToken The custom-auth example read `MY_SERVICE_TOKEN` into a plain `String` and kept it alive for the process lifetime, wrapping it in `SecretToken` only at the point of use. The example is what people copy, so it should model the right handling: wrap at the read, capture and clone the zeroizing wrapper, and say why in a comment. Claude-Session: https://claude.ai/code/session_016kh7tpj1P1j3oShkAUbZU6 --- packages/stack-encrypt/examples/zerokms_auth.rs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/stack-encrypt/examples/zerokms_auth.rs b/packages/stack-encrypt/examples/zerokms_auth.rs index 7dcd5d2d5..ef4926928 100644 --- a/packages/stack-encrypt/examples/zerokms_auth.rs +++ b/packages/stack-encrypt/examples/zerokms_auth.rs @@ -81,7 +81,13 @@ async fn main() -> Result<(), Box> { // than let `auto()` detect it. // This section needs a real token: building the cipher resolves the keyset // and loads its index key, which is a round-trip that must authenticate. - let Ok(token) = std::env::var("MY_SERVICE_TOKEN") else { + // + // Note what the token is held in: `SecretToken` wraps it the moment it is + // read, and that wrapper — not a bare `String` — is what the closure + // captures and clones. `SecretToken` is zeroized on drop and prints as + // `***`, so a long-lived credential neither lingers in freed memory nor + // lands in a log line. + let Ok(token) = std::env::var("MY_SERVICE_TOKEN").map(SecretToken::new) else { println!("MY_SERVICE_TOKEN not set; skipping the custom-strategy section."); return Ok(()); }; @@ -89,7 +95,7 @@ async fn main() -> Result<(), Box> { // Your token source: a broker, a sidecar, a cached credential. Called // whenever ZeroKMS needs a fresh token, so refresh belongs in here. let token = token.clone(); - async move { Ok::<_, AuthError>(ServiceToken::new(SecretToken::new(token))) } + async move { Ok::<_, AuthError>(ServiceToken::new(token)) } }); let kms = StackKmsBuilder::new(strategy) From 792334294a306bbf1294e2844aa82fd60f11c5d1 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Fri, 28 Aug 2026 11:14:22 +1000 Subject: [PATCH 445/686] docs: add the target-directed encryption design doc MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RFC 0002 declares `target-directed-encryption.md` its companion and normative on what the target type decides, but the document was never committed — the link resolved to nothing, which the review picked up. Adds it under `docs/` alongside the other design documents and corrects the RFC's relative link, which pointed at the repository root. The document is the original 2026-08-21 draft, unedited: the problem statement, the input-driven spike it rejects, the output-directed `EncryptedFrom` design, capabilities, context threading, and the open decisions. RFC 0002 supersedes only its "Batching and async" section. Claude-Session: https://claude.ai/code/session_016kh7tpj1P1j3oShkAUbZU6 --- ...nc-shape-for-target-directed-encryption.md | 2 +- docs/target-directed-encryption.md | 256 ++++++++++++++++++ 2 files changed, 257 insertions(+), 1 deletion(-) create mode 100644 docs/target-directed-encryption.md diff --git a/docs/rfcs/0002-async-shape-for-target-directed-encryption.md b/docs/rfcs/0002-async-shape-for-target-directed-encryption.md index afa566415..1ff586b63 100644 --- a/docs/rfcs/0002-async-shape-for-target-directed-encryption.md +++ b/docs/rfcs/0002-async-shape-for-target-directed-encryption.md @@ -8,7 +8,7 @@ | **Supersedes** | The "Batching and async" section of `target-directed-encryption.md` | | **Prompted by** | Review of PR #2147 | -> **Companion:** [`target-directed-encryption.md`](../../target-directed-encryption.md) — +> **Companion:** [`target-directed-encryption.md`](../target-directed-encryption.md) — > the original design. Everything it says about *what* the target type decides > stands. This RFC replaces only *how the async is shaped*, which the > implementation got wrong. diff --git a/docs/target-directed-encryption.md b/docs/target-directed-encryption.md new file mode 100644 index 000000000..17a09e6d6 --- /dev/null +++ b/docs/target-directed-encryption.md @@ -0,0 +1,256 @@ +# Target-directed encryption + +**Status:** draft, for discussion +**Date:** 2026-08-21 +**Scope:** vitaminc (primitives), stack-encrypt (the new trait + batching), eql-bindings (one class of targets) + +## Problem + +A stored encrypted value is rarely just a ciphertext. It is a *record*: the AEAD ciphertext of the plaintext, plus zero or more search terms derived from the same plaintext by different primitives, plus some metadata. EQL's `public.eql_v3_integer_ord_ore` is one instance — + +``` +{ v: schema version, i: identifier, c: ciphertext, ob: block-ORE term } +``` + +— but the shape is general. Any scheme that stores "the ciphertext and some derived terms alongside it" has it. + +vitaminc today gives us the ciphertext (`Encrypt` / `Cipher`) and a PRF (`PrfValue` / `Prf`), each excellent at its own job and each producing *one* output. Nothing composes them into a record, decides which terms a given record needs, or lets one plaintext fan out to several primitives in a single batch. + +We want the target type to answer all three questions, so that this compiles only when the pieces line up: + +```rust +let x: IntegerOrdOre = 10.encrypt_into(&cipher).await?; +``` + +**This must not be EQL-specific.** EQL payloads are one class of output. Nothing in the mechanism should know what a table or a column is. + +## Prior art: the async-sync spike + +`_spikes/async-sync/src/ore.rs` takes the **input-driven** route: a trait per index type, implemented per plaintext type. + +```rust +pub trait OreEncrypt: Sized { + fn encrypt_ore(self, cipher: C) -> Composite; +} +pub struct Composite(pub T, pub OreTerm); +``` + +`Composite` then implements `Encrypt` to lay out the map, and `EqlBuilder::with_ore(cipher)` stacks terms onto a value. + +It works, and one idea in it is worth keeping (see [Analysis vs derivation](#analysis-vs-derivation)). Four things break at scale: + +1. **No compile-time tie to a target shape.** `IntegerOrdOre` is `deny_unknown_fields` over exactly `v,i,c,ob`. A builder chain `.with_ore().with_eq()` produces `v,i,c,ob,hm`, which is not a domain. The shape is checked at Postgres, not by rustc. +2. **Combinatorics.** One trait per index × per plaintext type. `text_search` wants eq + ore + bloom: three traits, three calls, and the caller has to know which. +3. **Sync only.** `encrypt_ore` returns a value. ORE is local and sync; ZeroKMS-derived terms are async and batched. The spike has nowhere to join them. +4. **`Composite` writes the source back** (`self.value = val`), forcing the plaintext through the index path even when the index only needs to read it. + +The root cause of 1 and 2 is direction: the *caller* assembles the record, so the type system never sees the record as a whole. + +## Design + +Invert it. One trait, on the output type, describing what that type is: + +```rust +/// `Self` is an encrypted representation of `S`, producible by a cipher `C`. +pub trait EncryptedFrom: Sized { + type Error; + type Pending: IntoFuture>; + + fn encrypt_from(source: &S, cipher: &C, context: Context<'_>) -> Self::Pending; +} +``` + +Reads as a noun: *`Hmac256` is an encrypted form of `i64`*. + +`Pending` is deliberately not a future. It is a handle that resolves to one — the same trick `vitaminc-prf` already uses (`Prf::Ok: IntoFuture`, with `ReadyPrf` for sync backends). A local ORE term resolves immediately; a ZeroKMS term joins an open batch and resolves when the batch flushes. Callers see one uniform `.await`. + +The call-site sugar is `Into` over `From` — blanket, never implemented by hand: + +```rust +pub trait EncryptExt: Sized { + fn encrypt_into(self, cipher: &C) -> T::Pending + where + T: EncryptedFrom; +} +impl EncryptExt for S {} +``` + +### Leaves are handwritten; composites are derived + +**Leaves** are the single-primitive types. Each names exactly one capability, and that is the *only* place in the design where a primitive is named: + +```rust +impl EncryptedFrom for Ciphertext where S: Encrypt, C: Cipher { ... } +impl EncryptedFrom for Hmac256 where S: PrfValue, C: Prf { ... } +impl EncryptedFrom for OreBlock256 where S: ToOrderableBytes, C: ProvidesOre { ... } +``` + +These live wherever the field type lives — for EQL, in `eql-bindings`, which already owns `Ciphertext`, `Hmac256`, `OreBlock256` as wire newtypes. Encoding decisions (base85, block width) belong there, not in vitaminc. + +**Composites** are derived. The macro fans out to each field's impl, joins the pending handles, and assembles: + +```rust +#[derive(Encrypted)] +#[encrypted(source = i16, source = i32, source = i64)] +struct IntegerOrdOre { + #[encrypted(const = SchemaVersion::V3)] v: SchemaVersion, + #[encrypted(context)] i: Identifier, + c: Ciphertext, + ob: OreBlock256, +} +``` + +generating, per listed source: + +```rust +impl EncryptedFrom for IntegerOrdOre +where + Ciphertext: EncryptedFrom, + OreBlock256: EncryptedFrom, +{ /* join both, assemble */ } +``` + +The capability bounds (`C: Cipher`, `C: ProvidesOre`) arrive **transitively from the field impls**. The macro emits one `where` clause per derived field and names no primitive, no capability, and nothing from EQL. Adding a scheme is a new field type plus its leaf impl; the derive is untouched. + +### Which sources a target accepts + +`EncryptedFrom` is generic over `S`; only the `source` attribute pins it. Two modes: + +- **Omit `source`** — the derive emits a single impl generic over `S`. The accepted sources are then exactly the intersection of what the field types accept. Nothing to maintain. +- **List sources** — one impl per listed type, restricting the target. + +Use the list for EQL types. `eql_v3_integer_ord_ore` is a schema statement that the column holds an integer, and `OreBlock256` is width-agnostic on the wire, so the generic form would accept a `String` and hand Postgres a payload it rejects. That restriction is EQL's, declared by EQL. The mechanism stays open: non-EQL targets omit `source`. + +### Rows are the same mechanism + +One level up, unchanged: + +```rust +#[derive(Encrypted)] +#[encrypted(source = User)] +struct EncryptedUser { + #[encrypted(context = "users/age")] age: IntegerOrdOre, + #[encrypted(context = "users/email")] email: TextEq, +} + +let row: EncryptedUser = user.encrypt_into(&cipher).await?; // one batch +``` + +Leaf, payload and row are the same trait and the same derive; recursion does the rest. Earlier sketches of this design had a separate input-side derive for rows — that was a second mechanism the naming was hiding. + +### Relationship to `Encrypt` + +`Encrypt` is not bypassed or superseded. It **is** the source-ciphertext field. The `Ciphertext` leaf impl is a bridge: + +```rust +impl EncryptedFrom for Ciphertext +where S: Encrypt, C: Cipher +{ + fn encrypt_from(source: &S, cipher: &C, context: Context<'_>) -> Self::Pending { + source.encrypt_with_aad(cipher, context) // vitaminc Encrypt, untouched + } +} +``` + +Every existing impl — `String`, `u32`, `Vec`, `HashMap`, `Protected`, `Option`, `Element` — is therefore a valid source for free, and `#[derive(Encrypt)]` (PR #287) is what makes a nested struct usable as one. + +Two layers, cleanly split: + +| | drives | produces | +|---|---|---| +| `Encrypt` / `Cipher` | the cipher | one ciphertext | +| `EncryptedFrom` | the target type | a record of derived outputs, ciphertext being one field | + +## Capabilities + +A cipher advertises what it can do by implementing traits. Stack-encrypt's cipher implements `Cipher` and `Prf` directly — both are vitaminc's own. + +**ORE is different, and vitaminc should not grow an ORE trait.** `ore-rs` already has one, already shaped the way we would have shaped it: + +```rust +pub trait OreCipher: Sized { + fn init(k1: &[u8; 16], k2: &[u8; 16]) -> Result; + fn encrypt(&self, input: &PlainText) -> Result, OreError>; +} +``` + +with `orderable-bytes::ToOrderableBytes` supplying canonical order-preserving fixed-width encodings for the scalars, chrono and decimal types, carrying documented equality-and-order guarantees. That is exactly the reusable primitive vitaminc would otherwise have had to invent. + +Note the shape: `init` from two raw 16-byte keys means `OreCipher` **is the scheme**, not a keyset holder. Stack-encrypt's cipher therefore *holds* one rather than implementing it: + +```rust +pub trait ProvidesOre { + type Ore: ore_rs::OreCipher; + fn ore(&self) -> &Self::Ore; +} +``` + +Capability accessors, not one god trait. The same shape absorbs any future primitive whose trait is owned elsewhere (CLLW-OPE for the `op` wire key). + +## Context, not cipher scoping + +An EQL payload carries an identifier (`i`: table, column). Identifiers are an EQL concern and must not become cipher state. + +vitaminc already has the generic notion, twice — `Aad<'a>` (aead) and `PrfContext<'a>` (prf), both PAE-framed domain separators, neither aware of tables. EQL's `Identifier` is just a value that converts into both: + +```rust +pub trait EncryptContext<'a>: IntoAad<'a> + IntoPrfContext<'a> {} +impl<'a, T> EncryptContext<'a> for T where T: IntoAad<'a> + IntoPrfContext<'a> {} +``` + +Context is threaded **per value**, as an argument. It is not baked into the cipher. + +A scoped cipher (`cipher.for_column("users", "age")`) was considered and rejected: it makes encrypting one row — several columns, several identifiers — into several scoped ciphers, which fights batching for no gain. With context as an argument, a row is one shared `&cipher`, many contexts, one flush. + +### Recommendation: bind the identifier into the AAD + +EQL's `i` field is currently unauthenticated metadata. A ciphertext from `users.email` can be transplanted into `users.name` and still decrypts. Passing the identifier as context — which reaches both `Aad` and `PrfContext` — closes that class of attack. + +This stays a caller decision at the call site, not cipher state, so non-EQL callers pass whatever context they like or `Aad::empty()`. + +## Batching and async + +Awaiting at the leaf is one round-trip per value *unless* `Pending` is a deferred handle on a shared batch that flushes on first await. That is the whole reason the cipher implements `Cipher` and `Prf` together: one object, one keyset, one batch covering both the source ciphertext and every ZeroKMS-derived term in the record. + +The row-level derive above is the entry point that makes this pay: one `.await` for a whole row rather than one per field. + +## Analysis vs derivation + +Worth preserving from the spike: `ExactIndex::analyze() -> AnalyzedExactIndex`. + +Splitting **analysis** (tokenise, normalise, extract n-grams — pure, sync, keyless) from **derivation** (keyed, possibly async) is right, and text-match indexes cannot skip it. Under this design, analysis is a private stage inside a leaf impl (`BloomFilter: EncryptedFrom` tokenises before it derives), exposed as a public trait only if a custom analyser is needed. + +## Naming + +- **`EncryptedFrom`** for the trait. Spelling the direction keeps bounds unambiguous, and it pairs with `encrypt_into` exactly as `From` pairs with `Into`. +- **`#[derive(Encrypted)]`** for the macro. Reads as a noun on the struct. +- Matching both to `Encrypted`, the way `Serialize` matches `derive(Serialize)`, is a defensible alternative. +- **Avoid `CipherText` / `EncryptedValue`.** `CipherText` collides with vitaminc's `AesCipherText` container and with eql-bindings' `Ciphertext` newtype — which is a *field inside* these types, not the type itself. + +An earlier iteration had two traits, `EncryptInto` on the source and `DeriveFrom` on the field type. They are the same relation written in opposite directions; the split was the main source of confusion and is gone. + +## Decisions still open + +**1. Ownership at the bridge.** `Encrypt::encrypt_with_aad(self, ...)` takes ownership; `encrypt_from(source: &S, ...)` borrows, because k fields share one source. The `Ciphertext` bridge above does not compile as written. Options: + +1. `S: Encrypt + Clone` on the bridge. Simplest. Costs k copies of the plaintext. +2. Blanket `impl Encrypt for &T where T: Encrypt`. vitaminc already has `impl Encrypt for &str`, so the shape exists but is not systematic. +3. Derive hands ownership to the ciphertext field and borrows to the term fields. Cheapest; puts field-ordering knowledge into the macro. + +Recommend (1) now, (2) later if the copies show up in a profile. + +**2. Fan-out and zeroize.** Either way the source reaches k consumers, so there are k `Protected` copies, each wiped on drop. Bounded and acceptable for scalars and short strings, but it is a real widening of the custody window and should be stated in the crate docs rather than discovered. + +**3. Orphan rule.** `impl EncryptedFrom for IntegerOrdOre` in eql-bindings is legal — `Self` is local. The reverse-direction sugar (`EncryptExt::encrypt_into` on `i64`) is a blanket impl over a local trait, also fine. Worth a compile test pinning both, since the layout puts the trait, the source type and the target type in three different crates. + +**4. Error unification.** `Cipher::Error` and `PrfError` meet inside one `encrypt_from`. `EncryptedFrom::Error` needs a `From` for both, plus `OreError`. + +**5. Decrypt.** The mirror is `DecryptedFrom` on the plaintext type, with only the source-ciphertext field participating and `decrypt_into` as the blanket sugar. Not specified here. + +**6. Where `EncryptedFrom` lives.** Argued here as stack-encrypt's, since target-directed assembly is the thing stack-encrypt adds and vitaminc's `Encrypt` already covers cipher-directed encryption. If it turns out to be useful to vitaminc consumers who never touch stack-encrypt, it could move down — but not before there is a second consumer. + +## Non-goals + +- EQL knowledge anywhere in vitaminc or in the derive macro. +- Replacing `Encrypt` / `Cipher`. This layer sits on top of them. +- Runtime-configured index sets. protect.js takes the index set from a runtime schema; in Rust with sqlx the target type is known at compile time, and this design spends that fact rather than reproducing the dynamic model. From 1b01bdcace5a1707f7d53a6bfab658c40564b85f Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Fri, 28 Aug 2026 11:15:04 +1000 Subject: [PATCH 446/686] docs(stack-encrypt): record that the context check is waiting on vitaminc#291 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The degeneracy predicates reconstruct, from the outside and at runtime, an invariant that belongs in the type: they parse an encoding this crate does not own, mirror constants that are private upstream, and re-derive the answer on every encrypt. cipherstash/vitaminc#291 tracks the type-level replacement. Records what goes away when it lands — both predicates, the mirrored constants, `Error::EmptyContext` — and names the prefix-versus-position bug found in review as the concrete instance of the fragility that issue describes. No behaviour change. Claude-Session: https://claude.ai/code/session_016kh7tpj1P1j3oShkAUbZU6 --- packages/stack-encrypt/src/target/mod.rs | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/packages/stack-encrypt/src/target/mod.rs b/packages/stack-encrypt/src/target/mod.rs index 440b35146..1e2d4033f 100644 --- a/packages/stack-encrypt/src/target/mod.rs +++ b/packages/stack-encrypt/src/target/mod.rs @@ -219,6 +219,23 @@ pub trait DecryptContext<'a>: IntoAad<'a> + Clone {} impl<'a, T> DecryptContext<'a> for T where T: IntoAad<'a> + Clone {} +// The two checks below reconstruct, at runtime and from the outside, an +// invariant that should be carried by the type: "this context was built +// from something the caller supplied". Doing it this way means parsing an +// encoding we do not own, mirroring constants that are private upstream, +// and re-deriving the answer on every encrypt. +// +// It works, and the tests pin it — but the shape of it is a symptom, not a +// design. cipherstash/vitaminc#291 tracks the type-level replacement (a +// non-empty context that checks once at construction, without giving up the +// plain-string call site). When that lands, this module, both predicates and +// `Error::EmptyContext` all go away, and the `EncryptContext` bound tightens +// to the upstream marker instead. +// +// This code stays as it is until then: the check is correct, just weaker and +// more fragile than an invariant would be. The prefix-versus-position bug the +// review found here is exactly the fragility being described. + /// The framing tags vitaminc's PRF context encoding inserts, each of which /// occupies **piece 0** of the PAE node it labels. Matched exactly and only /// in that position — a prefix test would classify any caller string From d9da64d52aa73d7fa30c7aebaa03930cc8b960db Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Fri, 28 Aug 2026 11:23:28 +1000 Subject: [PATCH 447/686] docs(stack-encrypt): explain why prf_framing mirrors private vitaminc constants The constants are not required by the check itself; they follow from where it runs. `EncryptContext` is a blanket bound, so the context is opaque and can only be encoded; the encoding is framed, so seeing the value means parsing past the tags; parsing past the tags means knowing them. The literals therefore drift silently if vitaminc renames a domain. Records that chain on the module and points at cipherstash/vitaminc#291, which removes the premise. Claude-Session: https://claude.ai/code/session_016kh7tpj1P1j3oShkAUbZU6 --- packages/stack-encrypt/src/target/mod.rs | 30 +++++++++++++++++++++--- 1 file changed, 27 insertions(+), 3 deletions(-) diff --git a/packages/stack-encrypt/src/target/mod.rs b/packages/stack-encrypt/src/target/mod.rs index 1e2d4033f..b5f933dd9 100644 --- a/packages/stack-encrypt/src/target/mod.rs +++ b/packages/stack-encrypt/src/target/mod.rs @@ -242,9 +242,33 @@ impl<'a, T> DecryptContext<'a> for T where T: IntoAad<'a> + Clone {} /// beginning `vitaminc/` as framing (`Some("vitaminc/customer")` would read /// as empty), which is the opposite of what this check is for. /// -/// Mirrored from `vitaminc_prf::context`, where they are private. Pinned by -/// the `context_tests` below, which build every shape through the public API -/// rather than asserting the literals. +/// # Why these exist at all +/// +/// They are mirrored from `vitaminc_prf::context`, where they are private, +/// and nothing about the *check* requires them. They are a consequence of +/// *where* the check runs: +/// +/// 1. [`EncryptContext`] is a blanket bound over vitaminc's `IntoAad + +/// IntoPrfContext`, so inside `encrypt_from` the context is an opaque +/// generic. The only thing this crate can do with it is encode it. +/// 2. The encoding is framed: `"".into_prf_context()` is +/// `pae([context-value, utf8-label, ""])`, not zero bytes. Seeing whether +/// the *value* is empty means parsing past the tags. +/// 3. Parsing past the tags means knowing which pieces are tags. +/// +/// So the crate ends up parsing an encoding it does not own, restating +/// constants it cannot import, and re-deriving on every encrypt an answer +/// that was knowable once, at construction. If vitaminc renames a domain +/// these literals drift silently: the byte pins still pass and the check +/// quietly starts admitting empties. +/// +/// That is the case for : +/// a context that carries non-emptiness in its type, checked once where it +/// is built. When it lands, this module, both predicates below and +/// [`Error::EmptyContext`] are deleted and the [`EncryptContext`] bound +/// tightens to the upstream marker. Until then the literals are pinned by +/// `context_tests`, which build every shape through the public API rather +/// than asserting the strings. mod prf_framing { /// `pae([CONTEXT_VALUE, , value])` — a typed leaf. pub(super) const CONTEXT_VALUE: &[u8] = b"vitaminc/prf/context-value/v1"; From 4b7ced4a0bf1bb3a46b19dfd65d64d1071784789 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Fri, 28 Aug 2026 11:38:23 +1000 Subject: [PATCH 448/686] docs: mark the target-directed design's sketches as superseded by the shipped API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The companion document decided what the target type decides, and those decisions shipped as designed. Its code sketches did not: they predate RFC 0002 and the cipherstash/cipherstash-suite#2146/cipherstash/cipherstash-suite#2147 review, and a reader taking them as the API gets a renamed trait, an associated `Pending`/`Error` the cipher now owns, context-less `encrypt_into` calls, and a recommendation to pass `Aad::empty()` that the implementation rejects outright. The companion now opens with a design-record status and a table of every difference between sketch and shipped API, with the reason for each and where the open decisions landed. The three sketches a reader is most likely to copy — the trait, the `encrypt_into` sugar, and the `Aad::empty()` sentence — carry inline corrections, and the two principal call sites take the context argument they always required. RFC 0002's supersession notice is broadened to match: the companion's decisions stand, its snippets do not, and the rustdoc on `stack_encrypt::target` is the reference for the API as shipped. Claude-Session: https://claude.ai/code/session_016kh7tpj1P1j3oShkAUbZU6 --- ...nc-shape-for-target-directed-encryption.md | 15 ++++---- docs/target-directed-encryption.md | 34 ++++++++++++++++--- 2 files changed, 38 insertions(+), 11 deletions(-) diff --git a/docs/rfcs/0002-async-shape-for-target-directed-encryption.md b/docs/rfcs/0002-async-shape-for-target-directed-encryption.md index 1ff586b63..cfc6d62de 100644 --- a/docs/rfcs/0002-async-shape-for-target-directed-encryption.md +++ b/docs/rfcs/0002-async-shape-for-target-directed-encryption.md @@ -5,13 +5,16 @@ | **Status** | Accepted — implemented on PR #2146 (sem visitors) and #2147 (target layer) | | **Author** | Dan Draper | | **Area** | `packages/stack-encrypt` (`target`, `sem`, `cipher`), `vitaminc` (`prf`) | -| **Supersedes** | The "Batching and async" section of `target-directed-encryption.md` | +| **Supersedes** | In `target-directed-encryption.md`: the "Batching and async" section, and every code sketch — the trait shape (associated `Pending`/`Error`), the context-less `encrypt_into` call sites, and the `Aad::empty()` recommendation. Its *decisions* stand; its *snippets* do not. | | **Prompted by** | Review of PR #2147 | > **Companion:** [`target-directed-encryption.md`](../target-directed-encryption.md) — -> the original design. Everything it says about *what* the target type decides -> stands. This RFC replaces only *how the async is shaped*, which the -> implementation got wrong. +> the original design. Everything it *decides* about what the target type +> decides stands. Its code sketches are historical: they predate this RFC and +> the review of #2146/#2147, and the document's own header lists exactly how +> the shipped API differs. This RFC replaces *how the async is shaped*, which +> the implementation got wrong; the rustdoc on `stack_encrypt::target` is the +> reference for the API as shipped. ## 1. Summary @@ -536,8 +539,8 @@ Three homes, by durability: ## 9. Non-goals -- Changing what the target type decides. `target-directed-encryption.md` - stands. +- Changing what the target type decides. `target-directed-encryption.md`'s + decisions stand (its sketches do not — see its header). - Wire format changes. - A flush handle, an ambient batch registry, or timing-window coalescing. Batching is expressed by the source shape and is visible at the call site. diff --git a/docs/target-directed-encryption.md b/docs/target-directed-encryption.md index 17a09e6d6..3ab269db1 100644 --- a/docs/target-directed-encryption.md +++ b/docs/target-directed-encryption.md @@ -1,9 +1,27 @@ # Target-directed encryption -**Status:** draft, for discussion -**Date:** 2026-08-21 +**Status:** design record. The *decisions* stand; the *code sketches* are superseded — see the next section before reading any snippet as the API. +**Date:** 2026-08-21 (design); implementation notes 2026-08-28 **Scope:** vitaminc (primitives), stack-encrypt (the new trait + batching), eql-bindings (one class of targets) +## What the implementation changed + +This is the document that decided *what the target type decides*: one trait on the output type, leaves handwritten, composites assembled from leaves, context threaded per value rather than baked into the cipher, ORE held as a capability rather than grown into vitaminc. All of that shipped as designed on cipherstash-suite #2146 and #2147. + +The snippets below predate the implementation and are kept as the record of what was proposed. Where they differ from the shipped API, the shipped API wins. The differences, so no snippet here is mistaken for something that compiles: + +| Sketch here | Shipped | Why | +|---|---|---| +| `EncryptedFrom` / `DecryptedFrom` | `EncryptFrom` / `DecryptFrom` | Review rename; pairs with `encrypt_into` / `decrypt_into` as `From` pairs with `Into`. | +| Associated `type Pending` and `type Error` on the trait | Neither. The **cipher** owns both: `EncryptTarget::Output<'a, T>` and `EncryptTarget::Error`. `encrypt_from` returns `C::Output<'a, Self>`. | [RFC 0002](rfcs/0002-async-shape-for-target-directed-encryption.md) §2–§4. A trait-level future forces every cipher async and makes batching unreachable. `StackCipher` sets `Output = Pending<'a, T, K>`, a request carrier; a sync cipher can set `Output = Result`. | +| `encrypt_into(&cipher)` | `encrypt_into(&cipher, context)` — context is a required argument at every call site. | The design's own "Context, not cipher scoping" section; the sketches simply abbreviated it. | +| Context is `Context<'_>` | Any `Ctx: EncryptContext<'c>`, blanket over `IntoAad + IntoPrfContext + Clone`; `&str` and `String` qualify. | One value reaches both the AEAD associated data and the PRF context. | +| "non-EQL callers pass whatever context they like or `Aad::empty()`" | **Withdrawn.** An empty context is rejected during the synchronous build (`Error::EmptyContext`), before any I/O. | With an empty context, equal plaintexts in different fields produce identical terms, every field shares one ORE/OPE key, and ciphertexts transplant between fields. The check is structural over the encoding (`""`, `None`, `Some("")`, `("", "")` are all empty); cipherstash/vitaminc#291 tracks moving it into the type. | +| `#[derive(Encrypted)]` | Not built. Composites are hand-written `EncryptFrom` impls that call each field's `encrypt_from`, merge the outputs with `zip` / `Pending::all`, and shape with `map` — see `packages/stack-encrypt/examples/encrypted_record.rs`. | The derive is still the intended end state; it is future work, not a dropped decision. | +| `ProvidesOre` over `ore_rs::OreCipher` | Not adopted. ORE/OPE are `OreTerm` / `OpeTerm` over `cllw-ore`, and the per-field CLLW key is derived through the PRF *inside* the term's visitor — no key is ever handed back. | #2146. The capability-accessor idea stands; the concrete scheme and where the key lives changed. | + +Of the open decisions at the end: **1** resolved as option (1), `S: Encrypt + Clone` at the bridge; **4** dissolved — errors belong to the cipher, so there is no per-target error to unify; **5** implemented as `DecryptFrom` + `decrypt_into`, with a `DecryptContext` bound (`IntoAad` only, since decryption derives nothing); **6** resolved as stack-encrypt. **2** and **3** stand as written. + ## Problem A stored encrypted value is rarely just a ciphertext. It is a *record*: the AEAD ciphertext of the plaintext, plus zero or more search terms derived from the same plaintext by different primitives, plus some metadata. EQL's `public.eql_v3_integer_ord_ore` is one instance — @@ -19,7 +37,7 @@ vitaminc today gives us the ciphertext (`Encrypt` / `Cipher`) and a PRF (`PrfVal We want the target type to answer all three questions, so that this compiles only when the pieces line up: ```rust -let x: IntegerOrdOre = 10.encrypt_into(&cipher).await?; +let x: IntegerOrdOre = 10.encrypt_into(&cipher, "users/age").await?; ``` **This must not be EQL-specific.** EQL payloads are one class of output. Nothing in the mechanism should know what a table or a column is. @@ -60,6 +78,10 @@ pub trait EncryptedFrom: Sized { } ``` +> **Superseded sketch.** The shipped trait is `EncryptFrom` with +> `fn encrypt_from<'a, 'c, Ctx: EncryptContext<'c>>(source: &'a S, cipher: &'a C, context: Ctx) -> C::Output<'a, Self>`. +> There is no associated `Pending` or `Error`; the cipher owns both. RFC 0002 explains why. + Reads as a noun: *`Hmac256` is an encrypted form of `i64`*. `Pending` is deliberately not a future. It is a handle that resolves to one — the same trick `vitaminc-prf` already uses (`Prf::Ok: IntoFuture`, with `ReadyPrf` for sync backends). A local ORE term resolves immediately; a ZeroKMS term joins an open batch and resolves when the batch flushes. Callers see one uniform `.await`. @@ -75,6 +97,8 @@ pub trait EncryptExt: Sized { impl EncryptExt for S {} ``` +> **Superseded sketch.** Shipped as `fn encrypt_into<'a, 'c, T, C, Ctx>(&'a self, cipher: &'a C, context: Ctx) -> C::Output<'a, T>` — borrows `self`, takes the context, returns the cipher's output. + ### Leaves are handwritten; composites are derived **Leaves** are the single-primitive types. Each names exactly one capability, and that is the *only* place in the design where a primitive is named: @@ -133,7 +157,7 @@ struct EncryptedUser { #[encrypted(context = "users/email")] email: TextEq, } -let row: EncryptedUser = user.encrypt_into(&cipher).await?; // one batch +let row: EncryptedUser = user.encrypt_into(&cipher, "users").await?; // one batch ``` Leaf, payload and row are the same trait and the same derive; recursion does the rest. Earlier sketches of this design had a separate input-side derive for rows — that was a second mechanism the naming was hiding. @@ -206,7 +230,7 @@ A scoped cipher (`cipher.for_column("users", "age")`) was considered and rejecte EQL's `i` field is currently unauthenticated metadata. A ciphertext from `users.email` can be transplanted into `users.name` and still decrypts. Passing the identifier as context — which reaches both `Aad` and `PrfContext` — closes that class of attack. -This stays a caller decision at the call site, not cipher state, so non-EQL callers pass whatever context they like or `Aad::empty()`. +This stays a caller decision at the call site, not cipher state: non-EQL callers pass whatever context describes the field. What they may **not** pass is an empty one — the implementation rejects it (`Error::EmptyContext`), for the reasons in the table above. This sentence originally offered `Aad::empty()` as an option; that was wrong, and the code says so. ## Batching and async From 3d1155c4f01a1c33a55a2106c5807c484049db28 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Fri, 28 Aug 2026 11:44:43 +1000 Subject: [PATCH 449/686] docs: reconcile the target-directed design's snippets with the shipped API Every snippet in the companion now shows the API as it ships: `EncryptFrom` with cipher-owned `Output`/`Error` via `EncryptTarget`, `encrypt_into` taking a required context, the shipped leaf impls and their bounds, the hand-written composite from `examples/encrypted_record.rs`, and the real `StackCipherText` bridge. The `Aad::empty()` recommendation is replaced by the empty-context rule the implementation enforces. The derive and the `ore-rs` capability, which did not ship as sketched, are marked as such inline, and the open decisions are recorded as resolved. RFC 0002's supersession notice narrows back to the async section, since the companion no longer carries anything else it needs to override. Claude-Session: https://claude.ai/code/session_016kh7tpj1P1j3oShkAUbZU6 --- ...nc-shape-for-target-directed-encryption.md | 15 +- docs/target-directed-encryption.md | 164 +++++++++--------- 2 files changed, 92 insertions(+), 87 deletions(-) diff --git a/docs/rfcs/0002-async-shape-for-target-directed-encryption.md b/docs/rfcs/0002-async-shape-for-target-directed-encryption.md index cfc6d62de..b2954d3c0 100644 --- a/docs/rfcs/0002-async-shape-for-target-directed-encryption.md +++ b/docs/rfcs/0002-async-shape-for-target-directed-encryption.md @@ -5,16 +5,13 @@ | **Status** | Accepted — implemented on PR #2146 (sem visitors) and #2147 (target layer) | | **Author** | Dan Draper | | **Area** | `packages/stack-encrypt` (`target`, `sem`, `cipher`), `vitaminc` (`prf`) | -| **Supersedes** | In `target-directed-encryption.md`: the "Batching and async" section, and every code sketch — the trait shape (associated `Pending`/`Error`), the context-less `encrypt_into` call sites, and the `Aad::empty()` recommendation. Its *decisions* stand; its *snippets* do not. | +| **Supersedes** | The "Batching and async" section of `target-directed-encryption.md` (whose snippets have since been reconciled with the shipped API) | | **Prompted by** | Review of PR #2147 | > **Companion:** [`target-directed-encryption.md`](../target-directed-encryption.md) — -> the original design. Everything it *decides* about what the target type -> decides stands. Its code sketches are historical: they predate this RFC and -> the review of #2146/#2147, and the document's own header lists exactly how -> the shipped API differs. This RFC replaces *how the async is shaped*, which -> the implementation got wrong; the rustdoc on `stack_encrypt::target` is the -> reference for the API as shipped. +> the design: *what* the target type decides. Its snippets match the shipped +> API. This RFC specifies *how the async is shaped*, which the first +> implementation got wrong. ## 1. Summary @@ -539,8 +536,8 @@ Three homes, by durability: ## 9. Non-goals -- Changing what the target type decides. `target-directed-encryption.md`'s - decisions stand (its sketches do not — see its header). +- Changing what the target type decides. `target-directed-encryption.md` + stands. - Wire format changes. - A flush handle, an ambient batch registry, or timing-window coalescing. Batching is expressed by the source shape and is visible at the call site. diff --git a/docs/target-directed-encryption.md b/docs/target-directed-encryption.md index 3ab269db1..573fa8510 100644 --- a/docs/target-directed-encryption.md +++ b/docs/target-directed-encryption.md @@ -1,26 +1,16 @@ # Target-directed encryption -**Status:** design record. The *decisions* stand; the *code sketches* are superseded — see the next section before reading any snippet as the API. -**Date:** 2026-08-21 (design); implementation notes 2026-08-28 +**Status:** design record, reconciled with the shipped API on 2026-08-28. The snippets below are the API as it ships in `stack-encrypt` (cipherstash-suite #2146, #2147); the async shape is specified by [RFC 0002](rfcs/0002-async-shape-for-target-directed-encryption.md). +**Date:** 2026-08-21 **Scope:** vitaminc (primitives), stack-encrypt (the new trait + batching), eql-bindings (one class of targets) -## What the implementation changed +## Implementation notes -This is the document that decided *what the target type decides*: one trait on the output type, leaves handwritten, composites assembled from leaves, context threaded per value rather than baked into the cipher, ORE held as a capability rather than grown into vitaminc. All of that shipped as designed on cipherstash-suite #2146 and #2147. +Everything this document decides shipped as designed: one trait on the output type, leaves handwritten, composites assembled from leaves, context threaded per value, ORE held rather than grown into vitaminc. Three things differ from the original sketches and are marked inline where they appear: -The snippets below predate the implementation and are kept as the record of what was proposed. Where they differ from the shipped API, the shipped API wins. The differences, so no snippet here is mistaken for something that compiles: - -| Sketch here | Shipped | Why | -|---|---|---| -| `EncryptedFrom` / `DecryptedFrom` | `EncryptFrom` / `DecryptFrom` | Review rename; pairs with `encrypt_into` / `decrypt_into` as `From` pairs with `Into`. | -| Associated `type Pending` and `type Error` on the trait | Neither. The **cipher** owns both: `EncryptTarget::Output<'a, T>` and `EncryptTarget::Error`. `encrypt_from` returns `C::Output<'a, Self>`. | [RFC 0002](rfcs/0002-async-shape-for-target-directed-encryption.md) §2–§4. A trait-level future forces every cipher async and makes batching unreachable. `StackCipher` sets `Output = Pending<'a, T, K>`, a request carrier; a sync cipher can set `Output = Result`. | -| `encrypt_into(&cipher)` | `encrypt_into(&cipher, context)` — context is a required argument at every call site. | The design's own "Context, not cipher scoping" section; the sketches simply abbreviated it. | -| Context is `Context<'_>` | Any `Ctx: EncryptContext<'c>`, blanket over `IntoAad + IntoPrfContext + Clone`; `&str` and `String` qualify. | One value reaches both the AEAD associated data and the PRF context. | -| "non-EQL callers pass whatever context they like or `Aad::empty()`" | **Withdrawn.** An empty context is rejected during the synchronous build (`Error::EmptyContext`), before any I/O. | With an empty context, equal plaintexts in different fields produce identical terms, every field shares one ORE/OPE key, and ciphertexts transplant between fields. The check is structural over the encoding (`""`, `None`, `Some("")`, `("", "")` are all empty); cipherstash/vitaminc#291 tracks moving it into the type. | -| `#[derive(Encrypted)]` | Not built. Composites are hand-written `EncryptFrom` impls that call each field's `encrypt_from`, merge the outputs with `zip` / `Pending::all`, and shape with `map` — see `packages/stack-encrypt/examples/encrypted_record.rs`. | The derive is still the intended end state; it is future work, not a dropped decision. | -| `ProvidesOre` over `ore_rs::OreCipher` | Not adopted. ORE/OPE are `OreTerm` / `OpeTerm` over `cllw-ore`, and the per-field CLLW key is derived through the PRF *inside* the term's visitor — no key is ever handed back. | #2146. The capability-accessor idea stands; the concrete scheme and where the key lives changed. | - -Of the open decisions at the end: **1** resolved as option (1), `S: Encrypt + Clone` at the bridge; **4** dissolved — errors belong to the cipher, so there is no per-target error to unify; **5** implemented as `DecryptFrom` + `decrypt_into`, with a `DecryptContext` bound (`IntoAad` only, since decryption derives nothing); **6** resolved as stack-encrypt. **2** and **3** stand as written. +- **The derive is not built yet.** Composites are hand-written `EncryptFrom` impls today (`packages/stack-encrypt/examples/encrypted_record.rs`); `#[derive(Encrypted)]` remains the intended end state. +- **ORE/OPE use `cllw-ore`, not `ore-rs`,** and the per-field key is derived through the PRF *inside* the term — there is no `ProvidesOre` accessor and no key is ever handed back. +- **An empty context is rejected**, not permitted. `Aad::empty()` was proposed for non-EQL callers; the implementation refuses it (`Error::EmptyContext`). ## Problem @@ -70,48 +60,71 @@ Invert it. One trait, on the output type, describing what that type is: ```rust /// `Self` is an encrypted representation of `S`, producible by a cipher `C`. -pub trait EncryptedFrom: Sized { - type Error; - type Pending: IntoFuture>; +pub trait EncryptFrom: Sized { + fn encrypt_from<'a, 'c, Ctx>(source: &'a S, cipher: &'a C, context: Ctx) -> C::Output<'a, Self> + where + Ctx: EncryptContext<'c>, + Self: 'a; +} - fn encrypt_from(source: &S, cipher: &C, context: Context<'_>) -> Self::Pending; +/// Implemented by ciphers: decides what an `EncryptFrom` implementation hands back. +pub trait EncryptTarget { + type Error; + type Output<'a, T> where Self: 'a, T: 'a; } ``` -> **Superseded sketch.** The shipped trait is `EncryptFrom` with -> `fn encrypt_from<'a, 'c, Ctx: EncryptContext<'c>>(source: &'a S, cipher: &'a C, context: Ctx) -> C::Output<'a, Self>`. -> There is no associated `Pending` or `Error`; the cipher owns both. RFC 0002 explains why. +Reads as a noun: *`EqualityTerm` is an encrypted form of `&str`*. -Reads as a noun: *`Hmac256` is an encrypted form of `i64`*. - -`Pending` is deliberately not a future. It is a handle that resolves to one — the same trick `vitaminc-prf` already uses (`Prf::Ok: IntoFuture`, with `ReadyPrf` for sync backends). A local ORE term resolves immediately; a ZeroKMS term joins an open batch and resolves when the batch flushes. Callers see one uniform `.await`. +The output shape belongs to the **cipher**, not the trait — the same rule vitaminc follows for `Cipher::Ok` and `Prf::Ok`. A cipher that does no I/O sets `Output<'a, T> = Result`: no future, no `.await`. `StackCipher` sets `Output<'a, T> = Pending<'a, T, K>`, a request carrier: a local term resolves immediately, a ciphertext queues its data-key request, and merged pendings settle in one batched ZeroKMS call when awaited. The trait fixes neither a future nor an error type; RFC 0002 records why it must not. The call-site sugar is `Into` over `From` — blanket, never implemented by hand: ```rust -pub trait EncryptExt: Sized { - fn encrypt_into(self, cipher: &C) -> T::Pending +pub trait EncryptExt { + fn encrypt_into<'a, 'c, T, C, Ctx>(&'a self, cipher: &'a C, context: Ctx) -> C::Output<'a, T> where - T: EncryptedFrom; + C: EncryptTarget, + T: EncryptFrom + 'a, + Ctx: EncryptContext<'c>, + Self: Sized; } -impl EncryptExt for S {} +impl EncryptExt for S { /* delegates to T::encrypt_from */ } +``` + +### Leaves are handwritten; composites are assembled + +**Leaves** are the single-primitive types. Each names exactly one primitive, and that is the *only* place in the design where a primitive is named. As shipped in `stack-encrypt`: + +```rust +impl EncryptFrom> for StackCipherText where S: Encrypt + Clone { ... } +impl EncryptFrom> for EqualityTerm where S: PrfValue + Clone { ... } +impl EncryptFrom> for MatchTerm where S: AsRef, O: MatchConfig { ... } +impl EncryptFrom> for OreTerm where S: CllwOreEncrypt + Clone + Send + 'static { ... } ``` -> **Superseded sketch.** Shipped as `fn encrypt_into<'a, 'c, T, C, Ctx>(&'a self, cipher: &'a C, context: Ctx) -> C::Output<'a, T>` — borrows `self`, takes the context, returns the cipher's output. +EQL's wire newtypes (`Ciphertext`, `Hmac256`, `OreBlock256`) get the same treatment in `eql-bindings`, which owns them; encoding decisions (base85, block width) belong there, not in vitaminc or stack-encrypt. -### Leaves are handwritten; composites are derived +> The leaf impls currently name `StackCipher` rather than a capability bound. CIP-3897 tracks lifting each term into its own module with its own input trait and a capability-shaped cipher bound. -**Leaves** are the single-primitive types. Each names exactly one capability, and that is the *only* place in the design where a primitive is named: +**Composites** fan out to each field's impl, merge the outputs, and assemble. Today that is written by hand — one impl, in the shape the derive will eventually generate: ```rust -impl EncryptedFrom for Ciphertext where S: Encrypt, C: Cipher { ... } -impl EncryptedFrom for Hmac256 where S: PrfValue, C: Prf { ... } -impl EncryptedFrom for OreBlock256 where S: ToOrderableBytes, C: ProvidesOre { ... } +impl EncryptFrom> for EncryptedInt { + fn encrypt_from<'a, 'c, Ctx>(source: &'a u32, cipher: &'a StackCipher, context: Ctx) -> Pending<'a, Self, K> + where Ctx: EncryptContext<'c>, Self: 'a, + { + StackCipherText::encrypt_from(source, cipher, context.clone()) + .zip(EqualityTerm::encrypt_from(source, cipher, context.clone())) + .zip(OreTerm::::encrypt_from(source, cipher, context)) + .map(|((ciphertext, eq), ord)| Self { ciphertext, eq, ord }) + } +} ``` -These live wherever the field type lives — for EQL, in `eql-bindings`, which already owns `Ciphertext`, `Hmac256`, `OreBlock256` as wire newtypes. Encoding decisions (base85, block width) belong there, not in vitaminc. +One context fans out to every field. `zip` concatenates the fields' requests, so the whole record is still one batched call when awaited. -**Composites** are derived. The macro fans out to each field's impl, joins the pending handles, and assembles: +**The derive** (not yet built) writes exactly that impl from the struct: ```rust #[derive(Encrypted)] @@ -127,10 +140,10 @@ struct IntegerOrdOre { generating, per listed source: ```rust -impl EncryptedFrom for IntegerOrdOre +impl EncryptFrom for IntegerOrdOre where - Ciphertext: EncryptedFrom, - OreBlock256: EncryptedFrom, + Ciphertext: EncryptFrom, + OreBlock256: EncryptFrom, { /* join both, assemble */ } ``` @@ -138,7 +151,7 @@ The capability bounds (`C: Cipher`, `C: ProvidesOre`) arrive **transitively from ### Which sources a target accepts -`EncryptedFrom` is generic over `S`; only the `source` attribute pins it. Two modes: +`EncryptFrom` is generic over `S`; only the derive's `source` attribute pins it. Two modes: - **Omit `source`** — the derive emits a single impl generic over `S`. The accepted sources are then exactly the intersection of what the field types accept. Nothing to maintain. - **List sources** — one impl per listed type, restricting the target. @@ -147,7 +160,7 @@ Use the list for EQL types. `eql_v3_integer_ord_ore` is a schema statement that ### Rows are the same mechanism -One level up, unchanged: +One level up, unchanged (derive syntax, future): ```rust #[derive(Encrypted)] @@ -160,22 +173,33 @@ struct EncryptedUser { let row: EncryptedUser = user.encrypt_into(&cipher, "users").await?; // one batch ``` -Leaf, payload and row are the same trait and the same derive; recursion does the rest. Earlier sketches of this design had a separate input-side derive for rows — that was a second mechanism the naming was hiding. +Leaf, payload and row are the same trait, and a column of rows is `Vec`'s structural impl over the same trait — `ages.encrypt_into(&cipher, ctx)` for a `Vec` is one batched call. Recursion does the rest. Earlier sketches of this design had a separate input-side derive for rows — that was a second mechanism the naming was hiding. ### Relationship to `Encrypt` `Encrypt` is not bypassed or superseded. It **is** the source-ciphertext field. The `Ciphertext` leaf impl is a bridge: ```rust -impl EncryptedFrom for Ciphertext -where S: Encrypt, C: Cipher +impl EncryptFrom> for StackCipherText +where S: Encrypt + Clone { - fn encrypt_from(source: &S, cipher: &C, context: Context<'_>) -> Self::Pending { - source.encrypt_with_aad(cipher, context) // vitaminc Encrypt, untouched + fn encrypt_from<'a, 'c, Ctx>(source: &'a S, cipher: &'a StackCipher, context: Ctx) -> Pending<'a, Self, K> + where Ctx: EncryptContext<'c>, Self: 'a, + { + let aad = context.into_aad().into_owned(); + if is_degenerate_aad(aad.as_bytes()) { + return Pending::failed(cipher, Error::EmptyContext); + } + match source.clone().encrypt_with_aad(cipher, aad) { // vitaminc Encrypt, untouched + Ok(tree) => seal_pending(cipher, tree), // one data-key request per leaf + Err(_) => Pending::ready(cipher, Err(Error::Aead)), + } } } ``` +(`Clone` because a composite hands the same borrowed source to several fields — open decision 1, resolved as the simple option.) + Every existing impl — `String`, `u32`, `Vec`, `HashMap`, `Protected`, `Option`, `Element` — is therefore a valid source for free, and `#[derive(Encrypt)]` (PR #287) is what makes a nested struct usable as one. Two layers, cleanly split: @@ -183,33 +207,17 @@ Two layers, cleanly split: | | drives | produces | |---|---|---| | `Encrypt` / `Cipher` | the cipher | one ciphertext | -| `EncryptedFrom` | the target type | a record of derived outputs, ciphertext being one field | +| `EncryptFrom` | the target type | a record of derived outputs, ciphertext being one field | ## Capabilities A cipher advertises what it can do by implementing traits. Stack-encrypt's cipher implements `Cipher` and `Prf` directly — both are vitaminc's own. -**ORE is different, and vitaminc should not grow an ORE trait.** `ore-rs` already has one, already shaped the way we would have shaped it: - -```rust -pub trait OreCipher: Sized { - fn init(k1: &[u8; 16], k2: &[u8; 16]) -> Result; - fn encrypt(&self, input: &PlainText) -> Result, OreError>; -} -``` - -with `orderable-bytes::ToOrderableBytes` supplying canonical order-preserving fixed-width encodings for the scalars, chrono and decimal types, carrying documented equality-and-order guarantees. That is exactly the reusable primitive vitaminc would otherwise have had to invent. - -Note the shape: `init` from two raw 16-byte keys means `OreCipher` **is the scheme**, not a keyset holder. Stack-encrypt's cipher therefore *holds* one rather than implementing it: +**ORE is different, and vitaminc should not grow an ORE trait.** The scheme lives in its own crate and the cipher *holds* what it needs rather than implementing the scheme. -```rust -pub trait ProvidesOre { - type Ore: ore_rs::OreCipher; - fn ore(&self) -> &Self::Ore; -} -``` +The original proposal was `ore-rs` behind a `ProvidesOre` accessor on the cipher. What shipped is `cllw-ore`, and the key never surfaces at all: `OreTerm` / `OpeTerm` derive the per-field CLLW key through the PRF (from the field context, never the plaintext) *inside* the term's PRF visitor, encrypt there, and hand back only the ciphertext. Under the 2-party PRF backend that means per-field key derivation is an auditable ZeroKMS event and no key exists on either side to be leaked. -Capability accessors, not one god trait. The same shape absorbs any future primitive whose trait is owned elsewhere (CLLW-OPE for the `op` wire key). +The principle stands: capability accessors, not one god trait. `StackCipher` implements vitaminc's `Cipher` and exposes its `Prf`; any future primitive whose trait is owned elsewhere is absorbed the same way. ## Context, not cipher scoping @@ -230,7 +238,7 @@ A scoped cipher (`cipher.for_column("users", "age")`) was considered and rejecte EQL's `i` field is currently unauthenticated metadata. A ciphertext from `users.email` can be transplanted into `users.name` and still decrypts. Passing the identifier as context — which reaches both `Aad` and `PrfContext` — closes that class of attack. -This stays a caller decision at the call site, not cipher state: non-EQL callers pass whatever context describes the field. What they may **not** pass is an empty one — the implementation rejects it (`Error::EmptyContext`), for the reasons in the table above. This sentence originally offered `Aad::empty()` as an option; that was wrong, and the code says so. +This stays a caller decision at the call site, not cipher state: non-EQL callers pass whatever context describes the field. What they may **not** pass is an empty one. With an empty context, equal plaintexts in different fields produce identical terms, every field shares one ORE/OPE key, and ciphertexts transplant between fields — so every built-in impl rejects it during the synchronous build (`Error::EmptyContext`), before any I/O. "Empty" is structural over the encoding: `()`, `""`, `None`, `Some("")` and `("", "")` are all empty. (cipherstash/vitaminc#291 tracks carrying non-emptiness in the type instead.) ## Batching and async @@ -242,18 +250,18 @@ The row-level derive above is the entry point that makes this pay: one `.await` Worth preserving from the spike: `ExactIndex::analyze() -> AnalyzedExactIndex`. -Splitting **analysis** (tokenise, normalise, extract n-grams — pure, sync, keyless) from **derivation** (keyed, possibly async) is right, and text-match indexes cannot skip it. Under this design, analysis is a private stage inside a leaf impl (`BloomFilter: EncryptedFrom` tokenises before it derives), exposed as a public trait only if a custom analyser is needed. +Splitting **analysis** (tokenise, normalise, extract n-grams — pure, sync, keyless) from **derivation** (keyed, possibly async) is right, and text-match indexes cannot skip it. Under this design, analysis is a private stage inside a leaf impl (`MatchTerm` tokenises before it derives; the tokenizer, `k` and `m` are type-level via `MatchConfig`), exposed as a public trait only if a custom analyser is needed. ## Naming -- **`EncryptedFrom`** for the trait. Spelling the direction keeps bounds unambiguous, and it pairs with `encrypt_into` exactly as `From` pairs with `Into`. +- **`EncryptFrom`** for the trait (first shipped as `EncryptedFrom`, renamed in review). Spelling the direction keeps bounds unambiguous, and it pairs with `encrypt_into` exactly as `From` pairs with `Into`; `DecryptFrom` / `decrypt_into` mirror it. - **`#[derive(Encrypted)]`** for the macro. Reads as a noun on the struct. - Matching both to `Encrypted`, the way `Serialize` matches `derive(Serialize)`, is a defensible alternative. - **Avoid `CipherText` / `EncryptedValue`.** `CipherText` collides with vitaminc's `AesCipherText` container and with eql-bindings' `Ciphertext` newtype — which is a *field inside* these types, not the type itself. An earlier iteration had two traits, `EncryptInto` on the source and `DeriveFrom` on the field type. They are the same relation written in opposite directions; the split was the main source of confusion and is gone. -## Decisions still open +## Decisions, as resolved **1. Ownership at the bridge.** `Encrypt::encrypt_with_aad(self, ...)` takes ownership; `encrypt_from(source: &S, ...)` borrows, because k fields share one source. The `Ciphertext` bridge above does not compile as written. Options: @@ -261,17 +269,17 @@ An earlier iteration had two traits, `EncryptInto` on the source and `Deri 2. Blanket `impl Encrypt for &T where T: Encrypt`. vitaminc already has `impl Encrypt for &str`, so the shape exists but is not systematic. 3. Derive hands ownership to the ciphertext field and borrows to the term fields. Cheapest; puts field-ordering knowledge into the macro. -Recommend (1) now, (2) later if the copies show up in a profile. +**Resolved:** (1). `S: Encrypt + Clone` on the bridge; (2) later if the copies show up in a profile. **2. Fan-out and zeroize.** Either way the source reaches k consumers, so there are k `Protected` copies, each wiped on drop. Bounded and acceptable for scalars and short strings, but it is a real widening of the custody window and should be stated in the crate docs rather than discovered. -**3. Orphan rule.** `impl EncryptedFrom for IntegerOrdOre` in eql-bindings is legal — `Self` is local. The reverse-direction sugar (`EncryptExt::encrypt_into` on `i64`) is a blanket impl over a local trait, also fine. Worth a compile test pinning both, since the layout puts the trait, the source type and the target type in three different crates. +**3. Orphan rule.** `impl EncryptFrom for IntegerOrdOre` in eql-bindings is legal — `Self` is local. The reverse-direction sugar (`EncryptExt::encrypt_into` on `i64`) is a blanket impl over a local trait, also fine. Worth a compile test pinning both, since the layout puts the trait, the source type and the target type in three different crates. -**4. Error unification.** `Cipher::Error` and `PrfError` meet inside one `encrypt_from`. `EncryptedFrom::Error` needs a `From` for both, plus `OreError`. +**4. Error unification.** **Dissolved.** There is no per-target error: `EncryptTarget::Error` belongs to the cipher, and `StackCipher`'s `Error` already covers AEAD, PRF, ORE and ZeroKMS failures. -**5. Decrypt.** The mirror is `DecryptedFrom` on the plaintext type, with only the source-ciphertext field participating and `decrypt_into` as the blanket sugar. Not specified here. +**5. Decrypt.** **Implemented** as `DecryptFrom` on the plaintext type with `decrypt_into` as the blanket sugar. Only the source-ciphertext field participates (terms are one-way). The context bound is `DecryptContext` — `IntoAad` only, since decryption derives nothing. -**6. Where `EncryptedFrom` lives.** Argued here as stack-encrypt's, since target-directed assembly is the thing stack-encrypt adds and vitaminc's `Encrypt` already covers cipher-directed encryption. If it turns out to be useful to vitaminc consumers who never touch stack-encrypt, it could move down — but not before there is a second consumer. +**6. Where `EncryptFrom` lives.** **Resolved:** stack-encrypt. Argued here as stack-encrypt's, since target-directed assembly is the thing stack-encrypt adds and vitaminc's `Encrypt` already covers cipher-directed encryption. If it turns out to be useful to vitaminc consumers who never touch stack-encrypt, it could move down — but not before there is a second consumer. ## Non-goals From da76f1981818be2e56d4dca3ddc43a59f08c3e27 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Fri, 28 Aug 2026 14:32:49 +1000 Subject: [PATCH 450/686] docs: correct four remaining prose deviations in the target-directed design - `StackCipher` does not implement `Prf`; it holds an `HmacSha256Prf` and exposes it through `prf()`. - `EncryptContext` requires `Clone`. - The `OreTerm` impl also bounds `S::Output: Send + 'static`. - Fan-out custody: only the ciphertext's copy is `Protected`; term clones are ordinary values consumed synchronously. The document previously claimed k `Protected` copies each wiped on drop, which overstated what the implementation does. Now matches the `target` rustdoc. Claude-Session: https://claude.ai/code/session_016kh7tpj1P1j3oShkAUbZU6 --- docs/target-directed-encryption.md | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/docs/target-directed-encryption.md b/docs/target-directed-encryption.md index 573fa8510..ca13b0f2b 100644 --- a/docs/target-directed-encryption.md +++ b/docs/target-directed-encryption.md @@ -100,7 +100,7 @@ impl EncryptExt for S { /* delegates to T::encrypt_from */ } impl EncryptFrom> for StackCipherText where S: Encrypt + Clone { ... } impl EncryptFrom> for EqualityTerm where S: PrfValue + Clone { ... } impl EncryptFrom> for MatchTerm where S: AsRef, O: MatchConfig { ... } -impl EncryptFrom> for OreTerm where S: CllwOreEncrypt + Clone + Send + 'static { ... } +impl EncryptFrom> for OreTerm where S: CllwOreEncrypt + Clone + Send + 'static, S::Output: Send + 'static { ... } ``` EQL's wire newtypes (`Ciphertext`, `Hmac256`, `OreBlock256`) get the same treatment in `eql-bindings`, which owns them; encoding decisions (base85, block width) belong there, not in vitaminc or stack-encrypt. @@ -211,13 +211,13 @@ Two layers, cleanly split: ## Capabilities -A cipher advertises what it can do by implementing traits. Stack-encrypt's cipher implements `Cipher` and `Prf` directly — both are vitaminc's own. +A cipher advertises what it can do by implementing traits. `StackCipher` implements vitaminc's `Cipher` directly. It does **not** implement `Prf`: it *holds* a `vitaminc_hmac::HmacSha256Prf`, keyed by the keyset's index key at construction, and exposes it through `prf()`. Term impls read the accessor. **ORE is different, and vitaminc should not grow an ORE trait.** The scheme lives in its own crate and the cipher *holds* what it needs rather than implementing the scheme. The original proposal was `ore-rs` behind a `ProvidesOre` accessor on the cipher. What shipped is `cllw-ore`, and the key never surfaces at all: `OreTerm` / `OpeTerm` derive the per-field CLLW key through the PRF (from the field context, never the plaintext) *inside* the term's PRF visitor, encrypt there, and hand back only the ciphertext. Under the 2-party PRF backend that means per-field key derivation is an auditable ZeroKMS event and no key exists on either side to be leaked. -The principle stands: capability accessors, not one god trait. `StackCipher` implements vitaminc's `Cipher` and exposes its `Prf`; any future primitive whose trait is owned elsewhere is absorbed the same way. +The principle stands: capability accessors, not one god trait. The PRF is already held this way (`prf()`); any future primitive whose trait is owned elsewhere is absorbed the same way. ## Context, not cipher scoping @@ -226,10 +226,12 @@ An EQL payload carries an identifier (`i`: table, column). Identifiers are an EQ vitaminc already has the generic notion, twice — `Aad<'a>` (aead) and `PrfContext<'a>` (prf), both PAE-framed domain separators, neither aware of tables. EQL's `Identifier` is just a value that converts into both: ```rust -pub trait EncryptContext<'a>: IntoAad<'a> + IntoPrfContext<'a> {} -impl<'a, T> EncryptContext<'a> for T where T: IntoAad<'a> + IntoPrfContext<'a> {} +pub trait EncryptContext<'a>: IntoAad<'a> + IntoPrfContext<'a> + Clone {} +impl<'a, T> EncryptContext<'a> for T where T: IntoAad<'a> + IntoPrfContext<'a> + Clone {} ``` +`Clone` because one context fans out to every field of a record. + Context is threaded **per value**, as an argument. It is not baked into the cipher. A scoped cipher (`cipher.for_column("users", "age")`) was considered and rejected: it makes encrypting one row — several columns, several identifiers — into several scoped ciphers, which fights batching for no gain. With context as an argument, a row is one shared `&cipher`, many contexts, one flush. @@ -271,7 +273,7 @@ An earlier iteration had two traits, `EncryptInto` on the source and `Deri **Resolved:** (1). `S: Encrypt + Clone` on the bridge; (2) later if the copies show up in a profile. -**2. Fan-out and zeroize.** Either way the source reaches k consumers, so there are k `Protected` copies, each wiped on drop. Bounded and acceptable for scalars and short strings, but it is a real widening of the custody window and should be stated in the crate docs rather than discovered. +**2. Fan-out and zeroize.** **Resolved, and narrower than proposed.** The source reaches k consumers, but only the ciphertext's copy lives in `Protected` — it is held inside the pending and wiped as it seals. Term clones are ordinary values consumed during the synchronous build and dropped before any I/O; they are not wrapped. So the custody widening is bounded to the build phase for terms and to the pending's lifetime for the ciphertext. Stated in the `stack_encrypt::target` rustdoc ("Plaintext fan-out"), as this section asked. **3. Orphan rule.** `impl EncryptFrom for IntegerOrdOre` in eql-bindings is legal — `Self` is local. The reverse-direction sugar (`EncryptExt::encrypt_into` on `i64`) is a blanket impl over a local trait, also fine. Worth a compile test pinning both, since the layout puts the trait, the source type and the target type in three different crates. From f26dec78eaaf98660b920a0b014eabf82569b89b Mon Sep 17 00:00:00 2001 From: Toby Hede Date: Mon, 31 Aug 2026 12:08:24 +1000 Subject: [PATCH 451/686] chore(stack-auth): prepare npm taxonomy release --- .../require-auth-npm-changeset.yml | 72 +++++++++++++++++++ scripts/check-auth-npm-changeset.mjs | 57 +++++++++++++++ 2 files changed, 129 insertions(+) create mode 100644 .github/imported-workflows/require-auth-npm-changeset.yml create mode 100644 scripts/check-auth-npm-changeset.mjs diff --git a/.github/imported-workflows/require-auth-npm-changeset.yml b/.github/imported-workflows/require-auth-npm-changeset.yml new file mode 100644 index 000000000..095675fe2 --- /dev/null +++ b/.github/imported-workflows/require-auth-npm-changeset.yml @@ -0,0 +1,72 @@ +name: "Require @cipherstash/auth changeset" + +on: + pull_request: + paths: + - packages/stack-auth/Cargo.toml + - packages/stack-auth/src/** + - packages/stack-auth/node/** + - packages/stack-auth/wasm/** + - .github/workflows/require-auth-npm-changeset.yml + +defaults: + run: + shell: bash + +jobs: + require-npm-changeset: + name: Require @cipherstash/auth changeset + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + ref: ${{ github.event.pull_request.head.sha }} + + - uses: actions/setup-node@v6 + with: + node-version: 24 + + - name: Install the Changesets parser + run: npm install --no-workspaces --ignore-scripts --no-audit --no-fund + + - name: Check for an npm changeset + env: + ACTOR: ${{ github.actor }} + BASE_SHA: ${{ github.event.pull_request.base.sha }} + HEAD_REF: ${{ github.head_ref }} + run: | + set -euo pipefail + + if [[ "$ACTOR" == "github-actions[bot]" ]] && \ + { [[ "$HEAD_REF" == "changeset-release/main" ]] || \ + [[ "$HEAD_REF" == release-plz-* ]]; }; then + echo "Authenticated automated release PR; release intent was already consumed" + exit 0 + fi + + if ! SHIPPED_CHANGES="$( + git diff --name-only "${BASE_SHA}...HEAD" -- \ + packages/stack-auth/Cargo.toml \ + packages/stack-auth/src \ + packages/stack-auth/node \ + packages/stack-auth/wasm + )"; then + echo "::error::Failed to determine release-relevant stack-auth changes" + exit 1 + fi + + if [[ -z "$SHIPPED_CHANGES" ]]; then + echo "No release-relevant stack-auth changes found" + exit 0 + fi + + if ! CHANGESETS="$( + git diff --diff-filter=AM --name-only "${BASE_SHA}...HEAD" -- '.changeset/*.md' + )"; then + echo "::error::Failed to determine added or modified changesets" + exit 1 + fi + + mapfile -t CHANGESET_FILES <<< "$CHANGESETS" + node scripts/check-auth-npm-changeset.mjs "${CHANGESET_FILES[@]}" diff --git a/scripts/check-auth-npm-changeset.mjs b/scripts/check-auth-npm-changeset.mjs new file mode 100644 index 000000000..ba24e51df --- /dev/null +++ b/scripts/check-auth-npm-changeset.mjs @@ -0,0 +1,57 @@ +import fs from "node:fs"; +import parseChangeset from "@changesets/parse"; + +const changesetFiles = process.argv.slice(2).filter(Boolean); + +if (changesetFiles.length === 0) { + console.error( + "::error::Release-relevant stack-auth changes require an @cipherstash/auth changeset. Run 'npx changeset' and commit the generated file.", + ); + process.exit(1); +} + +let hasAuthRelease = false; + +for (const changesetFile of changesetFiles) { + const contents = fs.readFileSync(changesetFile, "utf8"); + const lines = contents.split(/\r?\n/); + const closingDelimiter = lines.indexOf("---", 1); + + if (lines[0] !== "---" || closingDelimiter === -1) { + console.error( + `::error file=${changesetFile}::Changeset must start with YAML frontmatter delimited by ---`, + ); + process.exit(1); + } + + if (lines.slice(closingDelimiter + 1).join("\n").trim().length === 0) { + console.error(`::error file=${changesetFile}::Changeset summary must not be empty`); + process.exit(1); + } + + let changeset; + try { + changeset = parseChangeset(contents); + } catch (error) { + console.error(`::error file=${changesetFile}::Invalid changeset: ${error.message}`); + process.exit(1); + } + + if ( + changeset.releases.some( + ({ name, type }) => + name === "@cipherstash/auth" && + (type === "patch" || type === "minor" || type === "major"), + ) + ) { + hasAuthRelease = true; + console.log(`Found valid @cipherstash/auth release intent in ${changesetFile}`); + } +} + +if (!hasAuthRelease) { + console.error( + "::error::Release-relevant stack-auth changes require an @cipherstash/auth changeset with a patch, minor, or major bump. Run 'npx changeset' and commit the generated file.", + ); + process.exit(1); +} From 46ebefaff50b74db82285fbe691de5185e29a3ff Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 31 Aug 2026 03:17:12 +0000 Subject: [PATCH 452/686] chore(release): version @cipherstash npm packages --- languages/typescript/packages/auth/CHANGELOG.md | 10 ++++++++++ languages/typescript/packages/auth/package.json | 2 +- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/languages/typescript/packages/auth/CHANGELOG.md b/languages/typescript/packages/auth/CHANGELOG.md index 409f42ba1..cd21620b8 100644 --- a/languages/typescript/packages/auth/CHANGELOG.md +++ b/languages/typescript/packages/auth/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## 0.44.0 + +### Minor Changes + +- 2f75eca: Add `USAGE_LIMIT_EXCEEDED` and `ORG_NOT_PROVISIONED` to the `AuthFailure` + union. Authentication and token refresh now report CTS usage denials as typed, + non-retryable failures instead of misclassifying them as transient server, + access-denied, or expired-token errors. The failure message is preserved and + the `help` field identifies whether to upgrade the plan or contact support. + ## 0.43.0 ### Minor Changes diff --git a/languages/typescript/packages/auth/package.json b/languages/typescript/packages/auth/package.json index 11582571c..55c37a3c1 100644 --- a/languages/typescript/packages/auth/package.json +++ b/languages/typescript/packages/auth/package.json @@ -1,6 +1,6 @@ { "name": "@cipherstash/auth", - "version": "0.43.0", + "version": "0.44.0", "license": "SEE LICENSE IN LICENSE", "main": "index.js", "types": "index.d.ts", From 9aa737489a9d548d7810167ea149928cb581af4a Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Thu, 27 Aug 2026 20:12:25 +1000 Subject: [PATCH 453/686] feat(stack-encrypt): #[derive(Encrypted)] / #[derive(Decrypted)] for records and rows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A new proc-macro crate, `stack-encrypt-derive`, re-exported from `stack_encrypt::target` (and the crate root). `Encrypted` emits the `EncryptFrom<_, StackCipher>` impl the hand-written composite in tests/target.rs spelled out: every derived field's pending, zipped and mapped into `Self`, never awaited — so a record of any size settles as one batched ZeroKMS call. `Decrypted` emits the `DecryptFrom` mirror for the record's named source type(s). Attributes (all under `#[encrypted(..)]`): - struct: `source = Type` (repeatable; omit for one impl generic over the source, bounded by what every leaf accepts), `crate = "path"`. - field: `context = ".."` (this field's context, replacing the record's), `from = field` (derive from one field of the source — a *row*), `default` / `default = expr` (not derived), `decrypt` (decryption opens this field: one field as the whole plaintext, or several with `from` rebuilding the source by name). Field contexts replace the record's rather than composing with it, so a query site builds a term under the same literal the row stored it under. A row whose fields all carry their own contexts therefore has none, and is given `()`. That required one change to the target layer: `Vec` and `Option` no longer validate the context themselves but pass it through, leaving the leaves to reject an empty one synchronously as before. What is lost is only the "fail on the fixture with no rows" property (an empty column under an empty context now succeeds); RFC 0002 §7 records the reversal. The derive is bound to `StackCipher` rather than generic over `EncryptTarget`: combining outputs needs `zip`/`map`, which only `Pending` has. `from` fields carry no where clause (the source field's type is not visible to the macro), so their obligations are checked in the impl body; and `Decrypted` requires a named `source` because a blanket impl over every plaintext type would violate the orphan rule. Tests: unit tests on every guard's diagnostic and on the expansion shape in the derive crate; integration tests in stack-encrypt/tests/derive.rs (derived record == hand-written, generic and listed sources, tuple and generic structs, a row and a column of rows at one call each way, a failed field failing before any I/O). `CountingSource` moved to tests/common for sharing. Claude-Session: https://claude.ai/code/session_01HU1Bbw4eQp9kEEneXxDcKr --- ...nc-shape-for-target-directed-encryption.md | 38 +- packages/stack-encrypt-derive/Cargo.toml | 22 ++ packages/stack-encrypt-derive/LICENSE | 96 +++++ .../stack-encrypt-derive/docs/attributes.md | 31 ++ packages/stack-encrypt-derive/src/attrs.rs | 97 +++++ packages/stack-encrypt-derive/src/decrypt.rs | 355 ++++++++++++++++++ packages/stack-encrypt-derive/src/encrypt.rs | 270 +++++++++++++ packages/stack-encrypt-derive/src/lib.rs | 138 +++++++ packages/stack-encrypt-derive/src/shape.rs | 254 +++++++++++++ .../stack-encrypt-derive/src/test_support.rs | 37 ++ packages/stack-encrypt/Cargo.toml | 2 + packages/stack-encrypt/src/lib.rs | 4 +- packages/stack-encrypt/src/target/mod.rs | 99 ++++- packages/stack-encrypt/tests/common/mod.rs | 103 +++++ packages/stack-encrypt/tests/derive.rs | 244 ++++++++++++ packages/stack-encrypt/tests/target.rs | 128 ++----- 16 files changed, 1794 insertions(+), 124 deletions(-) create mode 100644 packages/stack-encrypt-derive/Cargo.toml create mode 100644 packages/stack-encrypt-derive/LICENSE create mode 100644 packages/stack-encrypt-derive/docs/attributes.md create mode 100644 packages/stack-encrypt-derive/src/attrs.rs create mode 100644 packages/stack-encrypt-derive/src/decrypt.rs create mode 100644 packages/stack-encrypt-derive/src/encrypt.rs create mode 100644 packages/stack-encrypt-derive/src/lib.rs create mode 100644 packages/stack-encrypt-derive/src/shape.rs create mode 100644 packages/stack-encrypt-derive/src/test_support.rs create mode 100644 packages/stack-encrypt/tests/common/mod.rs create mode 100644 packages/stack-encrypt/tests/derive.rs diff --git a/docs/rfcs/0002-async-shape-for-target-directed-encryption.md b/docs/rfcs/0002-async-shape-for-target-directed-encryption.md index b2954d3c0..680cff59c 100644 --- a/docs/rfcs/0002-async-shape-for-target-directed-encryption.md +++ b/docs/rfcs/0002-async-shape-for-target-directed-encryption.md @@ -280,7 +280,8 @@ impl EncryptFrom> for EncryptedInt { No `tokio::try_join!`, no `Box::pin(async move ..)`, no error-conversion where-clauses. This is roughly half the size of the current impl and is -directly emittable by `#[derive(Encrypted)]`. +directly emittable by `#[derive(Encrypted)]` (which is what the derive does — +see §7). Then the missing piece from §2.2: @@ -516,10 +517,37 @@ claims: `Pending` now records a build-time failure and `zip`/`all` drop the assembly's requests when one side has failed, so a misconfigured field never mints keys for its siblings; the empty-context guard is structural - over PAE (so `None` / `Some("")` / tuples of empties are caught) and runs - on columns and optionals even when there is nothing to encrypt; and merging - pendings from different ciphers is `Error::CipherMismatch` rather than a - `debug_assert`. + over PAE (so `None` / `Some("")` / tuples of empties are caught); and + merging pendings from different ciphers is `Error::CipherMismatch` rather + than a `debug_assert`. + +### `#[derive(Encrypted)]` / `#[derive(Decrypted)]` (follow-up PR) + +The derive emits exactly the §4.4 shape — one impl over `StackCipher`, +field pendings zipped and mapped, never awaited — for a struct of leaves, and +one level up for a *row*: a struct whose fields are each derived from a +field of the source (`from = ..`) under a literal context of their own +(`context = ".."`). Field contexts **replace** the record's rather than +composing with it, so a query site builds a term under the same literal the +row stored it under; the row's own context is then unused and the caller +passes `()`. + +That last point reversed one of the final-review guards above: `Vec` and +`Option` no longer validate the context themselves. They pass it through +untouched, and the leaves reject an empty one synchronously as before. What +was lost is the "fail on the fixture with no rows" property — an empty +column under an empty context now succeeds, and the misconfiguration is +caught by the first real value instead — which is a small price for +containers of self-describing records being expressible at all. + +The derive is bound to `StackCipher` rather than generic over +`EncryptTarget`, because combining outputs needs `zip`/`map` and only +`Pending` has them; a generic derive would need those as `EncryptTarget` +methods, and that extension does not change the attribute surface. `from` +fields carry no where clause (the source field's type is not visible to the +macro), so their obligations are checked in the impl body — which is also why +`Decrypted` requires a named `source`: a blanket impl over every plaintext +type would violate the orphan rule outside this crate. ## 8. Where findings get recorded diff --git a/packages/stack-encrypt-derive/Cargo.toml b/packages/stack-encrypt-derive/Cargo.toml new file mode 100644 index 000000000..a03b10638 --- /dev/null +++ b/packages/stack-encrypt-derive/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "stack-encrypt-derive" +description = "Derive macros for stack-encrypt's target-directed encryption" +version = "0.1.0" +edition.workspace = true +authors.workspace = true +repository.workspace = true +homepage.workspace = true +keywords.workspace = true +categories.workspace = true +license-file = "LICENSE" +# Not yet released, like stack-encrypt (which is the only crate that should +# depend on this one: the macros are re-exported from there). +publish = false + +[lib] +proc-macro = true + +[dependencies] +proc-macro2 = "1" +quote = "1" +syn = { version = "3", features = ["full", "extra-traits"] } diff --git a/packages/stack-encrypt-derive/LICENSE b/packages/stack-encrypt-derive/LICENSE new file mode 100644 index 000000000..2cbd67a66 --- /dev/null +++ b/packages/stack-encrypt-derive/LICENSE @@ -0,0 +1,96 @@ +# PolyForm Internal Use License 1.0.0 + + + +## Acceptance + +In order to get any license under these terms, you must agree +to them as both strict obligations and conditions to all +your licenses. + +## Copyright License + +The licensor grants you a copyright license for the software +to do everything you might do with the software that would +otherwise infringe the licensor's copyright in it for any +permitted purpose. However, you may only make changes or +new works based on the software according to [Changes and New +Works License](#changes-and-new-works-license), and you may +not distribute the software. + +## Changes and New Works License + +The licensor grants you an additional copyright license to +make changes and new works based on the software for any +permitted purpose. + +## Patent License + +The licensor grants you a patent license for the software that +covers patent claims the licensor can license, or becomes able +to license, that you would infringe by using the software. + +## Fair Use + +You may have "fair use" rights for the software under the +law. These terms do not limit them. + +## Internal Business Use + +Use of the software for the internal business operations of +you and your company is use for a permitted purpose. + +## No Other Rights + +These terms do not allow you to sublicense or transfer any of +your licenses to anyone else, or prevent the licensor from +granting licenses to anyone else. These terms do not imply +any other licenses. + +## Patent Defense + +If you make any written claim that the software infringes or +contributes to infringement of any patent, your patent license +for the software granted under these terms ends immediately. If +your company makes such a claim, your patent license ends +immediately for work on behalf of your company. + +## Violations + +The first time you are notified in writing that you have +violated any of these terms, or done anything with the software +not covered by your licenses, your licenses can nonetheless +continue if you come into full compliance with these terms, +and take practical steps to correct past violations, within +32 days of receiving notice. Otherwise, all your licenses +end immediately. + +## No Liability + +***As far as the law allows, the software comes as is, without +any warranty or condition, and the licensor will not be liable +to you for any damages arising out of these terms or the use +or nature of the software, under any kind of legal claim.*** + +## Definitions + +The **licensor** is the individual or entity offering these +terms, and the **software** is the software the licensor makes +available under these terms. + +**You** refers to the individual or entity agreeing to these +terms. + +**Your company** is any legal entity, sole proprietorship, +or other kind of organization that you work for, plus all +organizations that have control over, are under the control of, +or are under common control with that organization. **Control** +means ownership of substantially all the assets of an entity, +or the power to direct its management and policies by vote, +contract, or otherwise. Control can be direct or indirect. + +**Your licenses** are all the licenses granted to you for the +software under these terms. + +**Use** means anything you do with the software requiring one +of your licenses. diff --git a/packages/stack-encrypt-derive/docs/attributes.md b/packages/stack-encrypt-derive/docs/attributes.md new file mode 100644 index 000000000..ca47fa707 --- /dev/null +++ b/packages/stack-encrypt-derive/docs/attributes.md @@ -0,0 +1,31 @@ +# Attributes + +All attributes live under `#[encrypted(...)]`. + +## On the struct + +| Attribute | Effect | +|---|---| +| `source = Type` | The record is an encrypted form of `Type`. Repeatable: one impl per listed type. Omit it for an impl generic over the source (see below). | +| `crate = "path"` | Where to find `stack_encrypt` in the generated code (default `::stack_encrypt`), for use through a re-export. | + +Without `source`, `Encrypted` emits one impl generic over the source, bounded +so the record accepts exactly the sources *every* derived field accepts — +`EncryptedAge` below is `EncryptFrom` for any `S` that both +`StackCipherText` and `EqualityTerm` accept. With `source`, the record accepts +only the listed types (a column that holds integers should not accept a +`String`), and `Decrypted` — which must name the plaintext type — becomes +possible. + +## On a field + +| Attribute | Effect | +|---|---| +| `context = "..."` | Derive this field under exactly this context rather than the one the caller passed for the record. A query-side term built under the same literal matches it. | +| `from = field` | Derive this field from `source.field` rather than from the whole source. Needs `source = ..` on the struct. | +| `default` / `default = expr` | Not derived: filled with `Default::default()` or `expr`. Never encrypted, never authenticated. | +| `decrypt` | Decryption opens this field (`Decrypted` only). One field opened as the whole plaintext, or several with `from = ..` rebuilding the source field by field. | + +The record's own context reaches every derived field that has no `context` +of its own; if every field has one, the record's context is unused and the +caller may pass `()`. diff --git a/packages/stack-encrypt-derive/src/attrs.rs b/packages/stack-encrypt-derive/src/attrs.rs new file mode 100644 index 000000000..d06a9dd44 --- /dev/null +++ b/packages/stack-encrypt-derive/src/attrs.rs @@ -0,0 +1,97 @@ +//! Parsing of the `#[encrypted(...)]` container and field attributes. + +use syn::{Attribute, Expr, Ident, LitStr, Path, Result, Type}; + +/// Container-level options, from `#[encrypted(...)]` on the struct itself. +pub(crate) struct ContainerAttrs { + /// Path to the `stack_encrypt` crate in the generated code. Defaults to + /// `::stack_encrypt`; overridden by `#[encrypted(crate = "...")]` so the + /// macros work through a re-export. + pub(crate) krate: Path, + /// The source types this record is an encrypted form of, one impl each, + /// from repeated `#[encrypted(source = Type)]`. Empty means a single impl + /// generic over the source. + pub(crate) sources: Vec, +} + +impl ContainerAttrs { + pub(crate) fn parse(attrs: &[Attribute]) -> Result { + let mut krate: Option = None; + let mut sources = Vec::new(); + + for attr in attrs.iter().filter(|a| a.path().is_ident("encrypted")) { + attr.parse_nested_meta(|meta| { + if meta.path.is_ident("crate") { + let lit: LitStr = meta.value()?.parse()?; + krate = Some(lit.parse()?); + return Ok(()); + } + if meta.path.is_ident("source") { + sources.push(meta.value()?.parse()?); + return Ok(()); + } + Err(meta.error( + "unsupported container attribute; expected `source = Type` or `crate = \"...\"`", + )) + })?; + } + + Ok(Self { + krate: krate.unwrap_or_else(|| syn::parse_quote!(::stack_encrypt)), + sources, + }) + } +} + +/// Field-level options, from `#[encrypted(...)]` on a field. +#[derive(Default)] +pub(crate) struct FieldAttrs { + /// `#[encrypted(context = "...")]`: derive this field under exactly this + /// context instead of the one the caller passed for the record. + pub(crate) context: Option, + /// `#[encrypted(from = field)]`: derive this field from one field of the + /// source rather than from the whole source. + pub(crate) from: Option, + /// `#[encrypted(default)]` / `#[encrypted(default = expr)]`: not derived; + /// filled with `Default::default()` or the expression. + pub(crate) default: Option>, + /// `#[encrypted(decrypt)]`: decryption opens this field. + pub(crate) decrypt: bool, +} + +impl FieldAttrs { + pub(crate) fn parse(attrs: &[Attribute]) -> Result { + let mut parsed = Self::default(); + + for attr in attrs.iter().filter(|a| a.path().is_ident("encrypted")) { + attr.parse_nested_meta(|meta| { + if meta.path.is_ident("context") { + parsed.context = Some(meta.value()?.parse()?); + return Ok(()); + } + if meta.path.is_ident("from") { + parsed.from = Some(meta.value()?.parse()?); + return Ok(()); + } + if meta.path.is_ident("default") { + parsed.default = Some(if meta.input.peek(syn::Token![=]) { + Some(meta.value()?.parse()?) + } else { + None + }); + return Ok(()); + } + if meta.path.is_ident("decrypt") { + parsed.decrypt = true; + return Ok(()); + } + Err(meta.error( + "unsupported field attribute; expected `context = \"...\"`, `from = field`, \ + `default`, `default = expr` or `decrypt`", + )) + })?; + } + + Ok(parsed) + } +} diff --git a/packages/stack-encrypt-derive/src/decrypt.rs b/packages/stack-encrypt-derive/src/decrypt.rs new file mode 100644 index 000000000..f38e3cee5 --- /dev/null +++ b/packages/stack-encrypt-derive/src/decrypt.rs @@ -0,0 +1,355 @@ +//! Expansion of `#[derive(Decrypted)]`. + +use std::collections::HashSet; + +use proc_macro2::TokenStream; +use quote::quote; +use syn::{parse_quote, DeriveInput, Ident, Path, PathArguments, Result, Type}; + +use crate::shape::{Field, Record}; + +pub(crate) fn derive(input: DeriveInput) -> Result { + let record = Record::parse(&input)?; + let krate = &record.krate; + let name = &input.ident; + let (_, ty_generics, _) = input.generics.split_for_impl(); + + if record.sources.is_empty() { + return Err(syn::Error::new_spanned( + name, + "Decrypted needs `#[encrypted(source = ..)]`: the plaintext type must be named. (An \ + impl for every type that can be decrypted from the ciphertext field would be a \ + blanket impl of a foreign trait, which the orphan rule forbids outside \ + stack-encrypt.)", + )); + } + + let opened: Vec<&Field> = record.fields.iter().filter(|f| f.decrypt).collect(); + if opened.is_empty() { + return Err(syn::Error::new_spanned( + name, + "Decrypted needs to know which field decryption opens: mark it \ + `#[encrypted(decrypt)]` (index terms are one-way and cannot be)", + )); + } + + let by_field = opened.iter().filter(|f| f.from().is_some()).count(); + let mode = + match by_field { + 0 if opened.len() == 1 => Mode::Whole(opened[0]), + 0 => return Err(syn::Error::new_spanned( + name, + "several fields are marked `decrypt` but none names a source field: one plaintext \ + cannot be recovered from two fields. Either mark only the ciphertext field, or \ + give each a `from = ..` so decryption rebuilds the source field by field.", + )), + n if n == opened.len() => { + let mut seen: HashSet<&Ident> = HashSet::with_capacity(opened.len()); + for field in &opened { + let from = field + .from() + .unwrap_or_else(|| unreachable!("counted above")); + if !seen.insert(from) { + return Err(syn::Error::new( + from.span(), + format!( + "two `decrypt` fields would recover the same source field `{from}`" + ), + )); + } + } + Mode::ByField(opened) + } + _ => { + return Err(syn::Error::new_spanned( + name, + "`decrypt` fields must either all name a source field (`from = ..`) or be a \ + single field opened as the whole plaintext; this record mixes the two", + )) + } + }; + + let impls = record + .sources + .iter() + .map(|source| { + let mut generics = input.generics.clone(); + generics.params.push(parse_quote!(__K)); + let body = match &mode { + Mode::Whole(field) => { + let ty = &field.ty; + generics.make_where_clause().predicates.push(parse_quote! { + #source: #krate::target::DecryptFrom<#ty, #krate::StackCipher<__K>> + }); + whole_body(krate, field, source) + } + Mode::ByField(fields) => by_field_body(krate, fields, source)?, + }; + let (impl_generics, _, where_clause) = generics.split_for_impl(); + + Ok(quote! { + #[automatically_derived] + impl #impl_generics #krate::target::DecryptFrom<#name #ty_generics, #krate::StackCipher<__K>> + for #source #where_clause + { + fn decrypt_from<'__a, '__c, __Ctx>( + __source: #name #ty_generics, + __cipher: &'__a #krate::StackCipher<__K>, + __context: __Ctx, + ) -> #krate::target::Pending<'__a, Self, __K> + where + __Ctx: #krate::target::DecryptContext<'__c>, + #name #ty_generics: '__a, + Self: '__a, + { + #body + } + } + }) + }) + .collect::>>()?; + + Ok(quote!(#(#impls)*)) +} + +enum Mode<'a> { + /// One field is the whole plaintext's ciphertext: decrypting the record is + /// decrypting that field. + Whole(&'a Field), + /// Each opened field recovers one field of the source, which is rebuilt + /// by name. + ByField(Vec<&'a Field>), +} + +fn context_for(field: &Field) -> TokenStream { + match field.context() { + Some(literal) => quote!(#literal), + None => quote!(__context), + } +} + +fn whole_body(krate: &Path, field: &Field, source: &Type) -> TokenStream { + let ty = &field.ty; + let member = &field.member; + let context = context_for(field); + quote! { + <#source as #krate::target::DecryptFrom<#ty, #krate::StackCipher<__K>>>::decrypt_from( + __source.#member, + __cipher, + #context, + ) + } +} + +fn by_field_body(krate: &Path, fields: &[&Field], source: &Type) -> Result { + let literal = struct_literal_path(source)?; + + // The record's context goes to every opened field without its own; the + // last such field takes it by move. + let mut remaining = fields.iter().filter(|f| f.context().is_none()).count(); + let unused_context = (remaining == 0).then(|| quote!(let _ = __context;)); + + let mut chain = TokenStream::new(); + let mut pattern = TokenStream::new(); + for (index, field) in fields.iter().enumerate() { + let ty = &field.ty; + let member = &field.member; + let local = &field.local; + let context = match field.context() { + Some(literal) => quote!(#literal), + None => { + remaining -= 1; + if remaining == 0 { + quote!(__context) + } else { + quote!(::core::clone::Clone::clone(&__context)) + } + } + }; + // The plaintext field's type is not known here; it is inferred from + // the struct literal below, and the obligation checked against it. + let call = quote! { + #krate::target::DecryptFrom::<#ty, #krate::StackCipher<__K>>::decrypt_from( + __source.#member, + __cipher, + #context, + ) + }; + if index == 0 { + chain = call; + pattern = quote!(#local); + } else { + chain = quote!(#chain.zip(#call)); + pattern = quote!((#pattern, #local)); + } + } + + let assign = fields.iter().map(|field| { + let from = field.from(); + let local = &field.local; + quote!(#from: #local) + }); + + Ok(quote! { + #unused_context + #chain.map(|#pattern| #literal { #(#assign),* }) + }) +} + +/// The source type as a struct-literal path: `User` becomes `User::`. +fn struct_literal_path(source: &Type) -> Result { + let Type::Path(type_path) = source else { + return Err(syn::Error::new_spanned( + source, + "field-by-field decryption rebuilds the source as a struct literal, so `source` \ + must name a struct", + )); + }; + if type_path.qself.is_some() { + return Err(syn::Error::new_spanned( + source, + "field-by-field decryption rebuilds the source as a struct literal, so `source` \ + must name a struct directly, not through a qualified path", + )); + } + let mut path = type_path.path.clone(); + for segment in &mut path.segments { + if let PathArguments::AngleBracketed(args) = &mut segment.arguments { + args.colon2_token = Some(Default::default()); + } + } + Ok(path) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_support::{assert_contains, assert_lacks}; + + fn expand(input: DeriveInput) -> Result { + derive(input).map(|tokens| tokens.to_string()) + } + + #[test] + fn a_source_is_required() { + let err = expand(parse_quote! { + struct Rec { + #[encrypted(decrypt)] + c: StackCipherText, + } + }) + .unwrap_err(); + assert!(err.to_string().contains("plaintext type must be named")); + } + + #[test] + fn an_opened_field_is_required() { + let err = expand(parse_quote! { + #[encrypted(source = u32)] + struct Rec { + c: StackCipherText, + hm: EqualityTerm, + } + }) + .unwrap_err(); + assert!(err.to_string().contains("which field decryption opens")); + } + + #[test] + fn two_whole_fields_are_ambiguous() { + let err = expand(parse_quote! { + #[encrypted(source = u32)] + struct Rec { + #[encrypted(decrypt)] + a: StackCipherText, + #[encrypted(decrypt)] + b: StackCipherText, + } + }) + .unwrap_err(); + assert!(err + .to_string() + .contains("cannot be recovered from two fields")); + } + + #[test] + fn mixed_modes_are_rejected() { + let err = expand(parse_quote! { + #[encrypted(source = User)] + struct Rec { + #[encrypted(decrypt, from = a)] + a: StackCipherText, + #[encrypted(decrypt)] + b: StackCipherText, + } + }) + .unwrap_err(); + assert!(err.to_string().contains("mixes the two")); + } + + #[test] + fn duplicate_recovery_targets_are_rejected() { + let err = expand(parse_quote! { + #[encrypted(source = User)] + struct Rec { + #[encrypted(decrypt, from = a)] + a: StackCipherText, + #[encrypted(decrypt, from = a)] + b: StackCipherText, + } + }) + .unwrap_err(); + assert!(err.to_string().contains("same source field `a`")); + } + + #[test] + #[rustfmt::skip] + fn whole_mode_opens_the_one_field() { + let expansion = expand(parse_quote! { + #[encrypted(source = u32, source = u64)] + struct EncryptedAge { + #[encrypted(decrypt)] + c: StackCipherText, + hm: EqualityTerm, + } + }) + .unwrap(); + assert_contains(&expansion, quote! { + impl<__K> ::stack_encrypt::target::DecryptFrom> for u32 + where + u32: ::stack_encrypt::target::DecryptFrom> + }); + assert_contains(&expansion, quote! { + >>::decrypt_from( + __source.c, __cipher, __context, + ) + }); + assert_lacks(&expansion, quote!(hm)); + } + + #[test] + #[rustfmt::skip] + fn by_field_mode_rebuilds_the_source() { + let expansion = expand(parse_quote! { + #[encrypted(source = User)] + struct EncryptedUser { + #[encrypted(decrypt, from = age, context = "users/age")] + age: EncryptedAge, + #[encrypted(decrypt, from = email)] + email: StackCipherText, + #[encrypted(from = email, context = "users/email")] + email_eq: EqualityTerm, + } + }) + .unwrap(); + assert_contains(&expansion, quote! { + ::stack_encrypt::target::DecryptFrom::>::decrypt_from( + __source.age, __cipher, "users/age", + ) + }); + assert_contains(&expansion, quote!(__source.email, __cipher, __context,)); + assert_contains(&expansion, quote!(.map(|(__field_0, __field_1)| User:: { age: __field_0, email: __field_1 }))); + assert_lacks(&expansion, quote!(email_eq)); + assert_lacks(&expansion, quote!(let _ = __context;)); + } +} diff --git a/packages/stack-encrypt-derive/src/encrypt.rs b/packages/stack-encrypt-derive/src/encrypt.rs new file mode 100644 index 000000000..85cefa7c7 --- /dev/null +++ b/packages/stack-encrypt-derive/src/encrypt.rs @@ -0,0 +1,270 @@ +//! Expansion of `#[derive(Encrypted)]`. + +use proc_macro2::TokenStream; +use quote::quote; +use syn::{parse_quote, DeriveInput, Result, Type}; + +use crate::shape::{Field, Kind, Record}; + +pub(crate) fn derive(input: DeriveInput) -> Result { + let record = Record::parse(&input)?; + let krate = &record.krate; + let name = &input.ident; + let (_, ty_generics, _) = input.generics.split_for_impl(); + + if record.sources.is_empty() { + // One impl, generic over the source: the record accepts exactly the + // sources every derived field accepts, which the where clause spells + // out so a mismatch is reported against the field type. + let source: Type = parse_quote!(__S); + let mut generics = input.generics.clone(); + generics.params.push(parse_quote!(__S)); + generics.params.push(parse_quote!(__K)); + push_field_bounds(&mut generics, krate, &record, &source); + let (impl_generics, _, where_clause) = generics.split_for_impl(); + let body = body(krate, &record, &source); + + return Ok(quote! { + #[automatically_derived] + impl #impl_generics #krate::target::EncryptFrom<__S, #krate::StackCipher<__K>> + for #name #ty_generics #where_clause + { + fn encrypt_from<'__a, '__c, __Ctx>( + __source: &'__a __S, + __cipher: &'__a #krate::StackCipher<__K>, + __context: __Ctx, + ) -> #krate::target::Pending<'__a, Self, __K> + where + __Ctx: #krate::target::EncryptContext<'__c>, + Self: '__a, + { + #body + } + } + }); + } + + // One impl per listed source. Fields derived from the whole source get a + // where clause as above; `from = ..` fields reach into the source, so + // their obligations are checked in the body against the actual field. + let impls = record.sources.iter().map(|source| { + let mut generics = input.generics.clone(); + generics.params.push(parse_quote!(__K)); + push_field_bounds(&mut generics, krate, &record, source); + let (impl_generics, _, where_clause) = generics.split_for_impl(); + let body = body(krate, &record, source); + + quote! { + #[automatically_derived] + impl #impl_generics #krate::target::EncryptFrom<#source, #krate::StackCipher<__K>> + for #name #ty_generics #where_clause + { + fn encrypt_from<'__a, '__c, __Ctx>( + __source: &'__a #source, + __cipher: &'__a #krate::StackCipher<__K>, + __context: __Ctx, + ) -> #krate::target::Pending<'__a, Self, __K> + where + __Ctx: #krate::target::EncryptContext<'__c>, + Self: '__a, + { + #body + } + } + } + }); + + Ok(quote!(#(#impls)*)) +} + +/// `FieldTy: EncryptFrom>` for every field derived +/// from the whole source. +fn push_field_bounds( + generics: &mut syn::Generics, + krate: &syn::Path, + record: &Record, + source: &Type, +) { + let predicates = &mut generics.make_where_clause().predicates; + for field in record + .fields + .iter() + .filter(|f| f.is_derived() && f.from().is_none()) + { + let ty = &field.ty; + predicates.push(parse_quote! { + #ty: #krate::target::EncryptFrom<#source, #krate::StackCipher<__K>> + }); + } +} + +/// The method body: every derived field's pending, zipped into one, mapped +/// into `Self`. Nothing is awaited, so the record settles as one batched +/// call. +fn body(krate: &syn::Path, record: &Record, source: &Type) -> TokenStream { + let derived: Vec<&Field> = record.fields.iter().filter(|f| f.is_derived()).collect(); + + // The record's context goes to every derived field without its own; the + // last such field takes it by move. + let mut remaining = derived.iter().filter(|f| f.context().is_none()).count(); + let unused_context = (remaining == 0).then(|| quote!(let _ = __context;)); + + let mut chain = TokenStream::new(); + let mut pattern = TokenStream::new(); + for (index, field) in derived.iter().enumerate() { + let ty = &field.ty; + let local = &field.local; + let (source_expr, source_ty): (TokenStream, TokenStream) = match field.from() { + Some(from) => (quote!(&__source.#from), quote!(_)), + None => (quote!(__source), quote!(#source)), + }; + let context = match field.context() { + Some(literal) => quote!(#literal), + None => { + remaining -= 1; + if remaining == 0 { + quote!(__context) + } else { + quote!(::core::clone::Clone::clone(&__context)) + } + } + }; + let call = quote! { + <#ty as #krate::target::EncryptFrom<#source_ty, #krate::StackCipher<__K>>>::encrypt_from( + #source_expr, + __cipher, + #context, + ) + }; + if index == 0 { + chain = call; + pattern = quote!(#local); + } else { + chain = quote!(#chain.zip(#call)); + pattern = quote!((#pattern, #local)); + } + } + + let assign = record.fields.iter().map(|field| { + let member = &field.member; + match &field.kind { + Kind::Derived { .. } => { + let local = &field.local; + quote!(#member: #local) + } + Kind::Default(Some(expr)) => quote!(#member: #expr), + Kind::Default(None) => quote!(#member: ::core::default::Default::default()), + } + }); + + quote! { + #unused_context + #chain.map(|#pattern| Self { #(#assign),* }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_support::{assert_contains, assert_lacks}; + + fn expand(input: DeriveInput) -> String { + derive(input).unwrap().to_string() + } + + #[test] + #[rustfmt::skip] + fn generic_source_bounds_every_whole_source_field() { + let expansion = expand(parse_quote! { + struct EncryptedAge { + c: StackCipherText, + hm: EqualityTerm, + } + }); + assert_contains(&expansion, quote! { + impl<__S, __K> ::stack_encrypt::target::EncryptFrom<__S, ::stack_encrypt::StackCipher<__K>> + for EncryptedAge + where + StackCipherText: ::stack_encrypt::target::EncryptFrom<__S, ::stack_encrypt::StackCipher<__K>>, + EqualityTerm: ::stack_encrypt::target::EncryptFrom<__S, ::stack_encrypt::StackCipher<__K>> + }); + // The first field clones the record context, the last takes it. + assert_contains(&expansion, quote!(__source, __cipher, ::core::clone::Clone::clone(&__context),)); + assert_contains(&expansion, quote!(__source, __cipher, __context,)); + assert_contains(&expansion, quote!(.map(|(__field_0, __field_1)| Self { c: __field_0, hm: __field_1 }))); + } + + #[test] + #[rustfmt::skip] + fn listed_sources_get_one_impl_each() { + let expansion = expand(parse_quote! { + #[encrypted(source = i32, source = i64)] + struct IntegerOrdOre { + c: StackCipherText, + #[encrypted(default = SchemaVersion::V3)] + v: SchemaVersion, + } + }); + assert_contains(&expansion, quote! { + impl<__K> ::stack_encrypt::target::EncryptFrom> for IntegerOrdOre + }); + assert_contains(&expansion, quote! { + impl<__K> ::stack_encrypt::target::EncryptFrom> for IntegerOrdOre + }); + assert_contains(&expansion, quote!(Self { c: __field_0, v: SchemaVersion::V3 })); + assert_lacks(&expansion, quote!(__S)); + } + + #[test] + #[rustfmt::skip] + fn row_fields_reach_into_the_source_under_their_own_context() { + let expansion = expand(parse_quote! { + #[encrypted(source = User)] + struct EncryptedUser { + #[encrypted(from = age, context = "users/age")] + age: EncryptedAge, + #[encrypted(from = email, context = "users/email")] + email: StackCipherText, + } + }); + assert_contains(&expansion, quote! { + >>::encrypt_from( + &__source.age, __cipher, "users/age", + ) + }); + // No field takes the record's context, so it is explicitly dropped + // rather than left as an unused-variable warning in user code. + assert_contains(&expansion, quote!(let _ = __context;)); + // `from` fields carry no where clause: the source field's type is + // unknown here, so the obligation is checked in the body instead. + assert!( + expansion.replace(' ', "").contains("forEncryptedUser{fnencrypt_from"), + "unexpected where clause on the impl:\n{expansion}" + ); + } + + #[test] + fn a_single_derived_field_still_maps_into_self() { + let expansion = expand(parse_quote! { + struct Wrapped { + c: StackCipherText, + } + }); + assert_contains(&expansion, quote!(.map(|__field_0| Self { c: __field_0 }))); + assert_lacks(&expansion, quote!(.zip)); + } + + #[test] + fn tuple_structs_assign_by_index() { + let expansion = expand(parse_quote! { + struct Pair(StackCipherText, EqualityTerm); + }); + assert_contains( + &expansion, + quote!(Self { + 0: __field_0, + 1: __field_1 + }), + ); + } +} diff --git a/packages/stack-encrypt-derive/src/lib.rs b/packages/stack-encrypt-derive/src/lib.rs new file mode 100644 index 000000000..24b474d4c --- /dev/null +++ b/packages/stack-encrypt-derive/src/lib.rs @@ -0,0 +1,138 @@ +//! Derive macros for [`stack-encrypt`](https://docs.rs/stack-encrypt)'s +//! target-directed encryption: `EncryptFrom` and `DecryptFrom` for composite +//! records. +//! +//! Both macros are re-exported from `stack_encrypt`, so depend on that crate +//! rather than this one. +//! +//! # What a record is +//! +//! A stored encrypted value is rarely just a ciphertext — it is a *record*: +//! the ciphertext plus whatever index terms make the field queryable. Leaf +//! types (`StackCipherText`, the `sem` terms) implement `EncryptFrom` by +//! hand; a record is a struct of leaves, and this derive writes its impl: +//! +//! ```ignore +//! use stack_encrypt::sem::{EqualityTerm, OreTerm}; +//! use stack_encrypt::{Decrypted, Encrypted, StackCipherText}; +//! +//! /// An encrypted integer, queryable by equality and range. +//! #[derive(Encrypted, Decrypted)] +//! #[encrypted(source = u32)] +//! struct EncryptedAge { +//! #[encrypted(decrypt)] +//! c: StackCipherText, +//! hm: EqualityTerm, +//! ob: OreTerm, +//! } +//! +//! let record: EncryptedAge = 42u32.encrypt_into(&cipher, "users/age").await?; +//! let age: u32 = record.decrypt_into(&cipher, "users/age").await?; +//! ``` +//! +//! Every derived field is fed the **same source** under the **same +//! context**, exactly as the hand-written impl would: the ciphertext is +//! sealed with the context as its AAD, and every term is domain-separated by +//! it, so a term built at a query site under `"users/age"` matches the one +//! stored in the record. The field pendings are combined without being +//! awaited, so however many fields a record has, awaiting it is **one** +//! batched ZeroKMS call. +//! +//! # Rows +//! +//! One level up, the same derive: a struct whose fields are each derived from +//! a *field* of the source, under a context of their own. +//! +//! ```ignore +//! struct User { +//! age: u32, +//! email: String, +//! } +//! +//! #[derive(Encrypted, Decrypted)] +//! #[encrypted(source = User)] +//! struct EncryptedUser { +//! #[encrypted(from = age, context = "users/age", decrypt)] +//! age: EncryptedAge, +//! #[encrypted(from = email, context = "users/email", decrypt)] +//! email: StackCipherText, +//! } +//! +//! let row: EncryptedUser = user.encrypt_into(&cipher, ()).await?; // one batch +//! let rows: Vec = users.encrypt_into(&cipher, ()).await?; // still one +//! let user: User = row.decrypt_into(&cipher, ()).await?; +//! ``` +//! +//! A `from` field's context is the *column's* identity, which is why it is a +//! literal on the field rather than something composed from the row's +//! context: the row's context is simply not used by fields that have their +//! own (pass `()`), and stays available for any field that has none. +//! +//! # What the derive commits to +//! +//! The impls are over `StackCipher` for any `K`, returning its `Pending`. +//! That is the only cipher today, and the only one whose output can be +//! combined without awaiting; a derive generic over any `EncryptTarget` needs +//! combinators on that trait and can replace this one without changing the +//! attribute surface. +//! +//! Field-by-field decryption rebuilds the source with a struct literal, so +//! every field of the source must be recovered by some `decrypt` field, and +//! the source must be a struct visible where the derive expands. +//! +//! # Enums +//! +//! Not supported: a record is a fixed set of fields derived from one source, +//! and a variant choice has no field to be derived into. Model the choice as +//! a struct of `Option` fields. +#![doc = include_str!("../docs/attributes.md")] +#![doc(html_favicon_url = "https://cipherstash.com/favicon.ico")] +#![deny(unsafe_code)] +#![warn( + clippy::unwrap_used, + clippy::expect_used, + clippy::panic, + clippy::mem_forget, + clippy::print_stdout, + clippy::print_stderr, + clippy::dbg_macro, + clippy::todo, + clippy::unimplemented +)] +#![cfg_attr(test, allow(clippy::unwrap_used))] +#![cfg_attr(test, allow(clippy::expect_used))] +#![cfg_attr(test, allow(clippy::panic))] + +use proc_macro::TokenStream; +use syn::{parse_macro_input, DeriveInput}; + +mod attrs; +mod decrypt; +mod encrypt; +mod shape; +#[cfg(test)] +mod test_support; + +/// Derive `EncryptFrom` for a record struct. See the [crate +/// documentation](crate) for what the derive emits; the attributes it accepts +/// are reproduced below. +#[doc = include_str!("../docs/attributes.md")] +#[proc_macro_derive(Encrypted, attributes(encrypted))] +pub fn derive_encrypted(input: TokenStream) -> TokenStream { + let input = parse_macro_input!(input as DeriveInput); + encrypt::derive(input) + .unwrap_or_else(syn::Error::into_compile_error) + .into() +} + +/// Derive `DecryptFrom` for the record's `source` type(s). See +/// the [crate documentation](crate); the attributes it accepts are reproduced +/// below. +#[doc = include_str!("../docs/attributes.md")] +#[proc_macro_derive(Decrypted, attributes(encrypted))] +pub fn derive_decrypted(input: TokenStream) -> TokenStream { + let input = parse_macro_input!(input as DeriveInput); + decrypt::derive(input) + .unwrap_or_else(syn::Error::into_compile_error) + .into() +} diff --git a/packages/stack-encrypt-derive/src/shape.rs b/packages/stack-encrypt-derive/src/shape.rs new file mode 100644 index 000000000..18893d9a3 --- /dev/null +++ b/packages/stack-encrypt-derive/src/shape.rs @@ -0,0 +1,254 @@ +//! Classification of the derive input into the record it describes. + +use proc_macro2::Span; +use syn::{Data, DeriveInput, Expr, Fields, Ident, LitStr, Member, Path, Result, Type}; + +use crate::attrs::{ContainerAttrs, FieldAttrs}; + +/// One field of a record. +#[cfg_attr(test, derive(Debug))] +pub(crate) struct Field { + /// How the field is reached (`name` or `0`); usable in a struct literal + /// either way (`Self { 0: value }` is legal Rust). + pub(crate) member: Member, + /// A local binding name, unique per field, for the generated bodies. + pub(crate) local: Ident, + pub(crate) ty: Type, + pub(crate) kind: Kind, + /// `#[encrypted(decrypt)]`: decryption opens this field. + pub(crate) decrypt: bool, +} + +/// How a field gets its value when the record is encrypted. +#[cfg_attr(test, derive(Debug))] +pub(crate) enum Kind { + /// Derived from the source through the field type's own `EncryptFrom`. + Derived { + /// `#[encrypted(context = "...")]`: this field's context, overriding + /// the record's. + context: Option, + /// `#[encrypted(from = field)]`: derived from one field of the source + /// rather than the whole source. + from: Option, + }, + /// Not derived: `Default::default()` or the given expression. + Default(Option), +} + +impl Field { + pub(crate) fn is_derived(&self) -> bool { + matches!(self.kind, Kind::Derived { .. }) + } + + /// The `from` field, if this is a derived field with one. + pub(crate) fn from(&self) -> Option<&Ident> { + match &self.kind { + Kind::Derived { from, .. } => from.as_ref(), + Kind::Default(_) => None, + } + } + + /// The literal context, if this is a derived field with one. + pub(crate) fn context(&self) -> Option<&LitStr> { + match &self.kind { + Kind::Derived { context, .. } => context.as_ref(), + Kind::Default(_) => None, + } + } +} + +/// The record a derive input describes. +#[cfg_attr(test, derive(Debug))] +pub(crate) struct Record { + pub(crate) krate: Path, + /// The source types, one impl each; empty means one impl generic over + /// the source. + pub(crate) sources: Vec, + pub(crate) fields: Vec, +} + +impl Record { + pub(crate) fn parse(input: &DeriveInput) -> Result { + let attrs = ContainerAttrs::parse(&input.attrs)?; + + let data = + match &input.data { + Data::Struct(data) => data, + Data::Enum(_) => return Err(syn::Error::new_spanned( + &input.ident, + "Encrypted/Decrypted cannot be derived for enums: a record is a fixed set of \ + fields derived from one source, and a variant choice has no field to be \ + derived into. Model the choice explicitly instead, e.g. as a struct of \ + `Option` fields.", + )), + Data::Union(_) => { + return Err(syn::Error::new_spanned( + &input.ident, + "Encrypted/Decrypted cannot be derived for unions", + )) + } + }; + + let fields = collect(&data.fields)?; + + if !fields.iter().any(Field::is_derived) { + return Err(syn::Error::new_spanned( + &input.ident, + "nothing to derive: a record needs at least one field that is not `default`", + )); + } + + if attrs.sources.is_empty() { + if let Some(field) = fields.iter().find(|f| f.from().is_some()) { + return Err(syn::Error::new( + field.from().map_or_else(Span::call_site, Ident::span), + "`from = ..` reaches into a field of the source, so the source type must be \ + named: add `#[encrypted(source = ..)]` to the struct", + )); + } + } + + Ok(Self { + krate: attrs.krate, + sources: attrs.sources, + fields, + }) + } +} + +fn collect(fields: &Fields) -> Result> { + fields + .iter() + .enumerate() + .map(|(index, field)| { + let attrs = FieldAttrs::parse(&field.attrs)?; + let member = match &field.ident { + Some(ident) => Member::Named(ident.clone()), + None => Member::Unnamed(syn::Index::from(index)), + }; + let kind = match attrs.default { + Some(default) => { + if attrs.context.is_some() || attrs.from.is_some() || attrs.decrypt { + return Err(syn::Error::new_spanned( + &field.ty, + "a `default` field is not derived from the source, so `context`, \ + `from` and `decrypt` do not apply to it", + )); + } + Kind::Default(default) + } + None => Kind::Derived { + context: attrs.context, + from: attrs.from, + }, + }; + Ok(Field { + member, + local: Ident::new(&format!("__field_{index}"), Span::call_site()), + ty: field.ty.clone(), + kind, + decrypt: attrs.decrypt, + }) + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use syn::parse_quote; + + fn parse(input: DeriveInput) -> Result { + Record::parse(&input) + } + + #[test] + fn enums_are_rejected() { + let err = parse(parse_quote! { + enum Choice { A(String), B(u32) } + }) + .unwrap_err(); + assert!(err.to_string().contains("cannot be derived for enums")); + } + + #[test] + fn all_default_is_rejected() { + let err = parse(parse_quote! { + struct Empty { + #[encrypted(default)] + v: u8, + } + }) + .unwrap_err(); + assert!(err.to_string().contains("nothing to derive")); + } + + #[test] + fn from_needs_a_named_source() { + let err = parse(parse_quote! { + struct Row { + #[encrypted(from = age)] + age: EncryptedAge, + } + }) + .unwrap_err(); + assert!(err.to_string().contains("source type must be named")); + } + + #[test] + fn default_excludes_the_derived_attributes() { + let err = parse(parse_quote! { + struct Rec { + c: StackCipherText, + #[encrypted(default, context = "x")] + v: u8, + } + }) + .unwrap_err(); + assert!(err.to_string().contains("`default` field is not derived")); + } + + #[test] + fn unknown_attributes_are_rejected() { + let err = parse(parse_quote! { + struct Rec { + #[encrypted(rename = "x")] + c: StackCipherText, + } + }) + .unwrap_err(); + assert!(err.to_string().contains("unsupported field attribute")); + + let err = parse(parse_quote! { + #[encrypted(sources = i32)] + struct Rec { + c: StackCipherText, + } + }) + .unwrap_err(); + assert!(err.to_string().contains("unsupported container attribute")); + } + + #[test] + fn fields_classify() { + let record = parse(parse_quote! { + #[encrypted(source = User, source = Admin)] + struct Row { + #[encrypted(from = age, context = "users/age", decrypt)] + age: EncryptedAge, + whole: RowTerm, + #[encrypted(default = SchemaVersion::V3)] + v: SchemaVersion, + } + }) + .unwrap(); + assert_eq!(record.sources.len(), 2); + assert_eq!(record.fields.len(), 3); + assert_eq!(record.fields[0].from().unwrap(), "age"); + assert_eq!(record.fields[0].context().unwrap().value(), "users/age"); + assert!(record.fields[0].decrypt); + assert!(record.fields[1].is_derived()); + assert!(record.fields[1].from().is_none()); + assert!(!record.fields[2].is_derived()); + } +} diff --git a/packages/stack-encrypt-derive/src/test_support.rs b/packages/stack-encrypt-derive/src/test_support.rs new file mode 100644 index 000000000..d0437fd3c --- /dev/null +++ b/packages/stack-encrypt-derive/src/test_support.rs @@ -0,0 +1,37 @@ +//! Helpers shared by the expansion tests in `encrypt` and `decrypt`. +//! +//! Expansions are asserted as *tokens*, never as text: the expected fragment is +//! rendered through `quote!` as well, so it is written as ordinary Rust and the +//! comparison cannot fail on `TokenStream`'s spacing. +//! +//! Whitespace is stripped from both sides before comparing: `quote!` renders +//! a joint `>>` where a `parse_quote!`-built predicate renders `> >`, and +//! the two are the same tokens. +//! +//! One caveat: rustfmt formats inside `quote!` bodies and will add or strip a +//! trailing comma, which *does* change the tokens. Keep fragments short enough +//! that rustfmt leaves them alone, or mark the test `#[rustfmt::skip]`. + +use proc_macro2::TokenStream; + +fn squash(text: &str) -> String { + text.chars().filter(|c| !c.is_whitespace()).collect() +} + +#[track_caller] +pub(crate) fn assert_contains(expansion: &str, fragment: TokenStream) { + let fragment = fragment.to_string(); + assert!( + squash(expansion).contains(&squash(&fragment)), + "expansion is missing `{fragment}`:\n{expansion}" + ); +} + +#[track_caller] +pub(crate) fn assert_lacks(expansion: &str, fragment: TokenStream) { + let fragment = fragment.to_string(); + assert!( + !squash(expansion).contains(&squash(&fragment)), + "expansion unexpectedly contains `{fragment}`:\n{expansion}" + ); +} diff --git a/packages/stack-encrypt/Cargo.toml b/packages/stack-encrypt/Cargo.toml index 347398cdc..917ad1a65 100644 --- a/packages/stack-encrypt/Cargo.toml +++ b/packages/stack-encrypt/Cargo.toml @@ -23,6 +23,8 @@ stack-kms = { path = "../stack-kms", default-features = false, features = ["prof # `StackCipher::new()` builds a ZeroKMS client from the environment, so its # return type names the auto-detected auth strategy. stack-auth = { workspace = true } +# `#[derive(Encrypted)]` / `#[derive(Decrypted)]`, re-exported from `target`. +stack-encrypt-derive = { path = "../stack-encrypt-derive" } # The revised Cipher/Decipher traits, ContextTag, Element, and the PRF crates # have all merged to vitaminc main but are not yet published (crates.io is 200+ diff --git a/packages/stack-encrypt/src/lib.rs b/packages/stack-encrypt/src/lib.rs index 55a945cd2..ac08e63ef 100644 --- a/packages/stack-encrypt/src/lib.rs +++ b/packages/stack-encrypt/src/lib.rs @@ -126,8 +126,8 @@ pub use cipher::{ StackCipherBuilder, StackCipherText, StackDecipher, }; pub use target::{ - DecryptContext, DecryptExt, DecryptFrom, DecryptTarget, EncryptContext, EncryptExt, - EncryptFrom, EncryptTarget, Pending, PendingFuture, Request, Responses, + DecryptContext, DecryptExt, DecryptFrom, DecryptTarget, Decrypted, EncryptContext, EncryptExt, + EncryptFrom, EncryptTarget, Encrypted, Pending, PendingFuture, Request, Responses, }; // Re-export the vitaminc AEAD surface callers need to drive the cipher, so they diff --git a/packages/stack-encrypt/src/target/mod.rs b/packages/stack-encrypt/src/target/mod.rs index b5f933dd9..4d49aef65 100644 --- a/packages/stack-encrypt/src/target/mod.rs +++ b/packages/stack-encrypt/src/target/mod.rs @@ -20,11 +20,15 @@ //! implementations exist for [`StackCipherText`] (the AEAD ciphertext, via //! vitaminc's [`Encrypt`]) and for the SEM term types in [`sem`] //! ([`EqualityTerm`], [`MatchTerm`], [`OreTerm`], [`OpeTerm`]). Composite -//! record types implement it by combining their fields' pendings with -//! [`Pending::zip`] / [`Pending::map`]. +//! record types — a struct of leaves, or a row of records — get theirs from +//! [`#[derive(Encrypted)]`](Encrypted), which combines the fields' pendings +//! with [`Pending::zip`] / [`Pending::map`] exactly as a hand-written impl +//! would. //! * [`DecryptFrom`] — the mirror, implemented by the *plaintext* //! type: "`Self` is recoverable from the encrypted `S`". Only ciphertext //! fields participate — index terms are one-way by construction. +//! [`#[derive(Decrypted)]`](Decrypted) on the record emits it for the +//! record's named source type(s). //! * [`EncryptExt::encrypt_into`] / [`DecryptExt::decrypt_into`] — blanket //! call-site sugar, the `Into` to the `From` above. Never implemented by //! hand. @@ -153,6 +157,66 @@ //! different construction; the block scheme lands as a third-party term type //! in `eql-bindings`, built with exactly the recipe above. //! +//! # Records and rows: `#[derive(Encrypted)]` +//! +//! A struct of leaves is a *record*; a struct of records, each derived from +//! one field of the source under its own column context, is a *row*. Both +//! are the same derive, and both settle as one batched call: +//! +//! ``` +//! use stack_encrypt::sem::{EqualityTerm, OreTerm}; +//! use stack_encrypt::target::{DecryptExt, EncryptExt}; +//! use stack_encrypt::{Decrypted, Encrypted, StackCipher, StackCipherText}; +//! use stack_kms::FakeDataKeySource; +//! +//! /// An encrypted `u32`, queryable by equality and range. +//! #[derive(Encrypted, Decrypted)] +//! #[encrypted(source = u32)] +//! struct EncryptedAge { +//! #[encrypted(decrypt)] +//! c: StackCipherText, +//! hm: EqualityTerm, +//! ob: OreTerm, +//! } +//! +//! #[derive(Debug, PartialEq)] +//! struct User { +//! age: u32, +//! email: String, +//! } +//! +//! #[derive(Encrypted, Decrypted)] +//! #[encrypted(source = User)] +//! struct EncryptedUser { +//! #[encrypted(from = age, context = "users/age", decrypt)] +//! age: EncryptedAge, +//! #[encrypted(from = email, context = "users/email", decrypt)] +//! email: StackCipherText, +//! } +//! +//! # tokio::runtime::Builder::new_current_thread().enable_all().build().unwrap().block_on(async { +//! let cipher = StackCipher::builder() +//! .kms(FakeDataKeySource::new()) +//! .init() +//! .await +//! .unwrap(); +//! +//! let user = User { age: 42, email: "alice@example.com".into() }; +//! // Every field names its own context, so the row has none: `()`. +//! let row: EncryptedUser = user.encrypt_into(&cipher, ()).await?; +//! // A query site derives the same term under the same literal. +//! let probe: EqualityTerm = 42u32.encrypt_into(&cipher, "users/age").await?; +//! assert_eq!(row.age.hm, probe); +//! +//! let recovered: User = row.decrypt_into(&cipher, ()).await?; +//! assert_eq!(recovered, user); +//! # Ok::<(), stack_encrypt::Error>(()) +//! # }).unwrap(); +//! ``` +//! +//! The attributes, and what the derive commits to, are documented on +//! [`Encrypted`]. +//! //! # Plaintext fan-out //! //! One source value reaches every field implementation, so encrypting a @@ -180,6 +244,7 @@ mod request; pub use pending::{Pending, PendingFuture}; pub use request::{Request, Responses}; +pub use stack_encrypt_derive::{Decrypted, Encrypted}; // ============================================================================= // Contexts @@ -197,8 +262,12 @@ pub use request::{Request, Responses}; /// in different fields produce identical index terms (cross-field equality /// leakage), every field shares one ORE/OPE key (values become mutually /// order-comparable), and ciphertexts become transplantable between fields. -/// Every built-in implementation — leaves, columns, optionals — rejects an -/// empty context during the synchronous build, before any I/O. +/// Every leaf implementation rejects an empty context during the synchronous +/// build, before any I/O. Containers (`Vec`, `Option`) and derived records +/// pass the context through to their elements and fields untouched, so a +/// container of records whose fields carry their own contexts — a +/// [`Encrypted`]-derived row — is given `()`, and an empty context still +/// fails the moment a value reaches a leaf. /// /// "Empty" means *carrying no caller-supplied information*, not merely zero /// bytes: `()`, `""`, `b""`, `None`, `Some("")` and `("", "")` all encode to @@ -710,11 +779,10 @@ where Ctx: EncryptContext<'c>, Self: 'a, { - // Validate the context even for an empty column: an empty descriptor - // must fail on the fixture with no rows, not on the first real one. - if is_degenerate_aad(context.clone().into_aad().as_bytes()) { - return Pending::failed(cipher, Error::EmptyContext); - } + // The context is passed through untouched, not validated here: a + // column of records whose fields carry their own contexts (a derived + // row) has none of its own, and the leaves reject an empty one the + // moment a value reaches them. let items = source .iter() .map(|item| T::encrypt_from(item, cipher, context.clone())) @@ -738,9 +806,6 @@ where S: 'a, Self: 'a, { - if is_degenerate_aad(context.clone().into_aad().as_bytes()) { - return Pending::failed(cipher, Error::EmptyContext); - } let items = source .into_iter() .map(|item| T::decrypt_from(item, cipher, context.clone())) @@ -766,11 +831,8 @@ where Ctx: EncryptContext<'c>, Self: 'a, { - // `None` derives nothing, but the field's context is still checked so - // a misconfigured optional field fails whether or not it is present. - if is_degenerate_aad(context.clone().into_aad().as_bytes()) { - return Pending::failed(cipher, Error::EmptyContext); - } + // `None` derives nothing and checks nothing: the context is the + // leaf's to validate (see the `Vec` implementation above). match source { Some(value) => T::encrypt_from(value, cipher, context).map(Some), None => Pending::ready(cipher, Ok(None)), @@ -793,9 +855,6 @@ where S: 'a, Self: 'a, { - if is_degenerate_aad(context.clone().into_aad().as_bytes()) { - return Pending::failed(cipher, Error::EmptyContext); - } match source { Some(value) => T::decrypt_from(value, cipher, context).map(Some), None => Pending::ready(cipher, Ok(None)), diff --git a/packages/stack-encrypt/tests/common/mod.rs b/packages/stack-encrypt/tests/common/mod.rs new file mode 100644 index 000000000..6621c3440 --- /dev/null +++ b/packages/stack-encrypt/tests/common/mod.rs @@ -0,0 +1,103 @@ +//! Fixtures shared by the integration test binaries. +//! +//! [`CountingSource`] is the fake source with ZeroKMS *call* counters (not +//! key counters): the design's whole claim is that an assembly of any size +//! settles in one batched call per request kind, and the tests hold it to +//! that. + +// Each test binary uses a subset of these. +#![allow(dead_code)] + +use std::borrow::Cow; +use std::sync::atomic::{AtomicUsize, Ordering as AtomicOrdering}; +use std::sync::Arc; + +use stack_encrypt::StackCipher; +use stack_kms::{ + DataKey, DataKeySource, DataKeyWithTag, FakeDataKeySource, GenerateKeyPayload, IdentifiedBy, + IndexKey, IndexKeySource, RetrieveKeyPayload, UnverifiedContext, +}; +use uuid::Uuid; + +/// A cipher over the deterministic fake source. The fake index key is +/// deterministic per keyset, so two separately built ciphers stand in for the +/// write path and a query path in another process. +pub async fn stack_cipher() -> StackCipher { + StackCipher::builder() + .kms(FakeDataKeySource::new()) + .init() + .await + .expect("build cipher") +} + +pub struct CountingSource { + inner: FakeDataKeySource, + generate_calls: Arc, + retrieve_calls: Arc, +} + +impl CountingSource { + pub fn new() -> Self { + Self { + inner: FakeDataKeySource::new(), + generate_calls: Arc::new(AtomicUsize::new(0)), + retrieve_calls: Arc::new(AtomicUsize::new(0)), + } + } + + pub fn counters(&self) -> (Arc, Arc) { + (self.generate_calls.clone(), self.retrieve_calls.clone()) + } +} + +impl DataKeySource for CountingSource { + async fn generate_keys( + &self, + payloads: Vec>, + keyset_id: Option, + unverified_context: Option>, + ) -> Result, stack_kms::Error> { + self.generate_calls.fetch_add(1, AtomicOrdering::SeqCst); + self.inner + .generate_keys(payloads, keyset_id, unverified_context) + .await + } + + async fn retrieve_keys( + &self, + payloads: Vec>, + keyset_id: Option, + unverified_context: Option<&UnverifiedContext>, + ) -> Result, stack_kms::Error> { + self.retrieve_calls.fetch_add(1, AtomicOrdering::SeqCst); + self.inner + .retrieve_keys(payloads, keyset_id, unverified_context) + .await + } +} + +impl IndexKeySource for CountingSource { + async fn load_index_key( + &self, + keyset_id: Option, + ) -> Result<(Uuid, IndexKey), stack_kms::Error> { + self.inner.load_index_key(keyset_id).await + } +} + +/// A cipher over [`CountingSource`], with its `(generate, retrieve)` call +/// counters. +pub async fn counting_cipher() -> ( + StackCipher, + Arc, + Arc, +) { + let source = CountingSource::new(); + let (generates, retrieves) = source.counters(); + let cipher = StackCipher::builder() + .kms(source) + .init() + .await + .expect("build cipher"); + (cipher, generates, retrieves) +} diff --git a/packages/stack-encrypt/tests/derive.rs b/packages/stack-encrypt/tests/derive.rs new file mode 100644 index 000000000..4f13072f8 --- /dev/null +++ b/packages/stack-encrypt/tests/derive.rs @@ -0,0 +1,244 @@ +//! `#[derive(Encrypted)]` / `#[derive(Decrypted)]`: the derived impls are the +//! hand-written composite in `target.rs`, emitted — same terms, same decrypt +//! mirror, same one-batched-call settlement — plus what only a derive makes +//! cheap: sources listed or left generic, rows derived field by field, and +//! fields that are not derived at all. + +mod common; + +use std::sync::atomic::Ordering as AtomicOrdering; + +use cllw_ore::CllwOreEncrypt; +use common::{counting_cipher, stack_cipher}; +use stack_encrypt::sem::{EqualityTerm, MatchTerm, OreTerm}; +use stack_encrypt::target::{DecryptExt, EncryptExt}; +use stack_encrypt::{Decrypted, Encrypted, Error, StackCipherText}; + +// --- Records: every field from one source, under one context ---------------- + +/// The hand-written record in `target.rs`, derived: an encrypted `u32` +/// stored as its ciphertext plus an equality term and an ORE term. +#[derive(Encrypted, Decrypted)] +#[encrypted(source = u32)] +struct EncryptedAge { + #[encrypted(decrypt)] + c: StackCipherText, + hm: EqualityTerm, + ob: OreTerm, +} + +#[tokio::test] +async fn a_derived_record_is_the_hand_written_one() { + let cipher = stack_cipher().await; + let generator = stack_cipher().await; + + let record: EncryptedAge = 42u32.encrypt_into(&cipher, "users/age").await.unwrap(); + + // Each term is what the leaf derives on its own, so query terms built + // leaf-by-leaf find records encrypted as composites. + let hm: EqualityTerm = 42u32.encrypt_into(&generator, "users/age").await.unwrap(); + let ob: OreTerm = 42u32.encrypt_into(&generator, "users/age").await.unwrap(); + assert_eq!(record.hm, hm); + assert_eq!(record.ob, ob); + + // And the decrypt mirror opens the ciphertext field. + let age: u32 = record.decrypt_into(&cipher, "users/age").await.unwrap(); + assert_eq!(age, 42); +} + +/// No `source`: one impl generic over it, accepting whatever every leaf +/// accepts — here any text type, since `MatchTerm` wants `AsRef`. +#[derive(Encrypted)] +struct SearchableText { + c: StackCipherText, + hm: EqualityTerm, + m: MatchTerm, +} + +/// Tuple structs assign by index. +#[derive(Encrypted)] +struct Pair(StackCipherText, EqualityTerm); + +/// The record's own generics (and their bounds) are carried through, and the +/// where clause makes `Tagged` accept exactly `T` — the ORE term is typed +/// by its source. +#[derive(Encrypted)] +struct Tagged { + c: StackCipherText, + ob: OreTerm, +} + +#[tokio::test] +async fn a_generic_source_record_accepts_what_its_leaves_accept() { + let cipher = stack_cipher().await; + let generator = stack_cipher().await; + + let record: SearchableText = "alice" + .to_string() + .encrypt_into(&cipher, "users/name") + .await + .unwrap(); + let hm: EqualityTerm = "alice" + .to_string() + .encrypt_into(&generator, "users/name") + .await + .unwrap(); + let m: MatchTerm = "alice" + .to_string() + .encrypt_into(&generator, "users/name") + .await + .unwrap(); + assert_eq!(record.hm, hm); + assert_eq!(record.m, m); + let name: String = record.c.decrypt_into(&cipher, "users/name").await.unwrap(); + assert_eq!(name, "alice"); + + let pair: Pair = "bob".encrypt_into(&cipher, "users/name").await.unwrap(); + let hm: EqualityTerm = "bob".encrypt_into(&generator, "users/name").await.unwrap(); + assert_eq!(pair.1, hm); + let name: String = pair.0.decrypt_into(&cipher, "users/name").await.unwrap(); + assert_eq!(name, "bob"); + + let tagged: Tagged = 7u32.encrypt_into(&cipher, "users/score").await.unwrap(); + let ob: OreTerm = 7u32.encrypt_into(&generator, "users/score").await.unwrap(); + assert_eq!(tagged.ob, ob); + let score: u32 = tagged.c.decrypt_into(&cipher, "users/score").await.unwrap(); + assert_eq!(score, 7); +} + +/// Listed sources: one impl each, and nothing else is accepted. +#[derive(Encrypted, Decrypted)] +#[encrypted(source = u32, source = String)] +struct EncryptedValue { + #[encrypted(decrypt)] + c: StackCipherText, + hm: EqualityTerm, +} + +#[tokio::test] +async fn listed_sources_each_get_their_own_impl() { + let cipher = stack_cipher().await; + + let number: EncryptedValue = 7u32.encrypt_into(&cipher, "t/n").await.unwrap(); + let text: EncryptedValue = "seven" + .to_string() + .encrypt_into(&cipher, "t/t") + .await + .unwrap(); + let hm: EqualityTerm = 7u32.encrypt_into(&cipher, "t/n").await.unwrap(); + assert_eq!(number.hm, hm); + + let number: u32 = number.decrypt_into(&cipher, "t/n").await.unwrap(); + let text: String = text.decrypt_into(&cipher, "t/t").await.unwrap(); + assert_eq!((number, text.as_str()), (7, "seven")); +} + +#[tokio::test] +async fn a_failed_field_fails_the_derived_record_before_any_io() { + let (cipher, generates, _) = counting_cipher().await; + + // An empty context fails every leaf during the synchronous build; the + // derived record is the zip of those, so it fails the same way and never + // mints the data key its ciphertext field would have wanted. + let result: Result = "alice".to_string().encrypt_into(&cipher, "").await; + assert!(matches!(result, Err(Error::EmptyContext))); + assert_eq!(generates.load(AtomicOrdering::SeqCst), 0); +} + +// --- Rows: each field from one field of the source, under its own context --- + +#[derive(Debug, Clone, PartialEq, Eq)] +struct User { + age: u32, + email: String, +} + +#[derive(Encrypted, Decrypted)] +#[encrypted(source = User)] +struct EncryptedUser { + /// A record inside a row: recursion, not a second mechanism. + #[encrypted(from = age, context = "users/age", decrypt)] + age: EncryptedAge, + #[encrypted(from = email, context = "users/email", decrypt)] + email: StackCipherText, + /// A second field from the same source field — a term alongside the + /// ciphertext, not opened on decrypt. + #[encrypted(from = email, context = "users/email")] + email_eq: EqualityTerm, + /// Not derived: filled in, never encrypted. + #[encrypted(default = 3)] + version: u8, +} + +fn user() -> User { + User { + age: 42, + email: "alice@example.com".to_string(), + } +} + +#[tokio::test] +async fn a_row_is_one_batched_call_and_rebuilds_its_source() { + let (cipher, generates, retrieves) = counting_cipher().await; + let generator = stack_cipher().await; + + // Every field has its own context, so the row's is unused: `()` is fine. + let row: EncryptedUser = user().encrypt_into(&cipher, ()).await.unwrap(); + assert_eq!( + generates.load(AtomicOrdering::SeqCst), + 1, + "a two-ciphertext row must be ONE generate_keys call" + ); + assert_eq!(row.version, 3); + + // Each field's terms are what a query site derives under the column's + // literal context. + let age_hm: EqualityTerm = 42u32.encrypt_into(&generator, "users/age").await.unwrap(); + assert_eq!(row.age.hm, age_hm); + let email_hm: EqualityTerm = user() + .email + .encrypt_into(&generator, "users/email") + .await + .unwrap(); + assert_eq!(row.email_eq, email_hm); + + // Decryption rebuilds the source field by field: one batched call. + let recovered: User = row.decrypt_into(&cipher, ()).await.unwrap(); + assert_eq!(recovered, user()); + assert_eq!( + retrieves.load(AtomicOrdering::SeqCst), + 1, + "opening a two-ciphertext row must be ONE retrieve_keys call" + ); +} + +#[tokio::test] +async fn a_column_of_rows_is_still_one_call_each_way() { + let (cipher, generates, retrieves) = counting_cipher().await; + + let users: Vec = (0..4) + .map(|i| User { + age: 30 + i, + email: format!("user{i}@example.com"), + }) + .collect(); + + let rows: Vec = users.encrypt_into(&cipher, ()).await.unwrap(); + assert_eq!(rows.len(), 4); + assert_eq!(generates.load(AtomicOrdering::SeqCst), 1); + + let recovered: Vec = rows.decrypt_into(&cipher, ()).await.unwrap(); + assert_eq!(recovered, users); + assert_eq!(retrieves.load(AtomicOrdering::SeqCst), 1); +} + +#[tokio::test] +async fn a_row_field_opened_under_the_wrong_context_fails() { + let cipher = stack_cipher().await; + + let row: EncryptedUser = user().encrypt_into(&cipher, ()).await.unwrap(); + // The literal contexts are baked into the impl, so a transplanted field + // is caught by the AAD exactly as for a leaf. + let transplanted: Result = row.age.c.decrypt_into(&cipher, "users/height").await; + assert!(matches!(transplanted, Err(Error::Aead))); +} diff --git a/packages/stack-encrypt/tests/target.rs b/packages/stack-encrypt/tests/target.rs index ebf1785b6..077ae40af 100644 --- a/packages/stack-encrypt/tests/target.rs +++ b/packages/stack-encrypt/tests/target.rs @@ -4,10 +4,8 @@ //! surface only, and — the point of the design — proof that however large the //! assembly, settling it is one batched ZeroKMS call per request kind. -use std::borrow::Cow; use std::cmp::Ordering; -use std::sync::atomic::{AtomicUsize, Ordering as AtomicOrdering}; -use std::sync::Arc; +use std::sync::atomic::Ordering as AtomicOrdering; use stack_encrypt::sem::{EqualityTerm, MatchConfig, MatchOptions, MatchTerm, OreTerm}; use stack_encrypt::target::{ @@ -15,13 +13,13 @@ use stack_encrypt::target::{ Request, }; use stack_encrypt::{Error, StackCipher, StackCipherText}; -use stack_kms::{ - DataKey, DataKeySource, DataKeyWithTag, FakeDataKeySource, GenerateKeyPayload, IdentifiedBy, - IndexKey, IndexKeySource, RetrieveKeyPayload, UnverifiedContext, -}; +use stack_kms::{FakeDataKeySource, IdentifiedBy, IndexKeySource}; use uuid::Uuid; use vitaminc_prf::{BlockVisitor, PrfContext, PrfValue}; +mod common; +use common::counting_cipher; + /// A cipher over the deterministic fake source. Built independently of /// [`stack_cipher`] below: the fake index key is deterministic per keyset, so /// two separately built ciphers stand in for the write path and a query path @@ -38,79 +36,6 @@ async fn stack_cipher() -> StackCipher { .expect("build cipher") } -/// The fake source with ZeroKMS *call* counters (not key counters): the -/// design's whole claim is that an assembly of any size settles in one -/// batched call per request kind, and these tests hold it to that. -struct CountingSource { - inner: FakeDataKeySource, - generate_calls: Arc, - retrieve_calls: Arc, -} - -impl CountingSource { - fn new() -> Self { - Self { - inner: FakeDataKeySource::new(), - generate_calls: Arc::new(AtomicUsize::new(0)), - retrieve_calls: Arc::new(AtomicUsize::new(0)), - } - } - - fn counters(&self) -> (Arc, Arc) { - (self.generate_calls.clone(), self.retrieve_calls.clone()) - } -} - -impl DataKeySource for CountingSource { - async fn generate_keys( - &self, - payloads: Vec>, - keyset_id: Option, - unverified_context: Option>, - ) -> Result, stack_kms::Error> { - self.generate_calls.fetch_add(1, AtomicOrdering::SeqCst); - self.inner - .generate_keys(payloads, keyset_id, unverified_context) - .await - } - - async fn retrieve_keys( - &self, - payloads: Vec>, - keyset_id: Option, - unverified_context: Option<&UnverifiedContext>, - ) -> Result, stack_kms::Error> { - self.retrieve_calls.fetch_add(1, AtomicOrdering::SeqCst); - self.inner - .retrieve_keys(payloads, keyset_id, unverified_context) - .await - } -} - -impl IndexKeySource for CountingSource { - async fn load_index_key( - &self, - keyset_id: Option, - ) -> Result<(Uuid, IndexKey), stack_kms::Error> { - self.inner.load_index_key(keyset_id).await - } -} - -async fn counting_cipher() -> ( - StackCipher, - Arc, - Arc, -) { - let source = CountingSource::new(); - let (generates, retrieves) = source.counters(); - let cipher = StackCipher::builder() - .kms(source) - .init() - .await - .expect("build cipher"); - (cipher, generates, retrieves) -} - // --- Leaf implementations --------------------------------------------------- #[tokio::test] @@ -666,25 +591,34 @@ async fn wrapped_empty_contexts_are_rejected_too() { } #[tokio::test] -async fn empty_context_is_rejected_even_when_there_is_nothing_to_encrypt() { - // An empty descriptor must fail on the fixture with no rows / an absent - // optional, not on the first populated value in production. - let cipher = stack_cipher().await; - - let none: Result, _> = None::.encrypt_into(&cipher, "").await; - assert!(matches!(none, Err(Error::EmptyContext))); - - let empty: Result, _> = - Vec::::new().encrypt_into(&cipher, "").await; - assert!(matches!(empty, Err(Error::EmptyContext))); +async fn containers_pass_the_context_through_to_their_leaves() { + // `Vec` and `Option` validate nothing themselves: a populated container + // under an empty context fails at the first leaf (synchronously, before + // any I/O), and an empty one has no leaf to fail at. That is what lets a + // column of derived rows — records whose fields carry their own contexts + // — be given `()`. + let (cipher, generates, _) = counting_cipher().await; - let none: Result, _> = None::.decrypt_into(&cipher, "").await; - assert!(matches!(none, Err(Error::EmptyContext))); + let some: Result, _> = + Some("x".to_string()).encrypt_into(&cipher, "").await; + assert!(matches!(some, Err(Error::EmptyContext))); + let populated: Result, _> = + vec!["x".to_string()].encrypt_into(&cipher, "").await; + assert!(matches!(populated, Err(Error::EmptyContext))); + assert_eq!(generates.load(AtomicOrdering::SeqCst), 0); - let empty: Result, _> = Vec::::new() - .decrypt_into(&cipher, "") - .await; - assert!(matches!(empty, Err(Error::EmptyContext))); + let none: Option = None::.encrypt_into(&cipher, ()).await.unwrap(); + assert!(none.is_none()); + let empty: Vec = Vec::::new() + .encrypt_into(&cipher, ()) + .await + .unwrap(); + assert!(empty.is_empty()); + let empty: Vec = Vec::::new() + .decrypt_into(&cipher, ()) + .await + .unwrap(); + assert!(empty.is_empty()); } #[tokio::test] From 681353c3dab39a478bf81b38a9849a36318644d2 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Fri, 28 Aug 2026 11:04:16 +1000 Subject: [PATCH 454/686] refactor(stack-encrypt): name the derives and call-site traits after what they emit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `#[derive(Decrypted)]` was misnamed: the annotated struct is the encrypted record, and what the derive emits is `DecryptFrom` for the record's plaintext source — the struct is not "decrypted" in any sense. Name the macro after the trait it implements, as serde does: `#[derive(DecryptFrom)]`. The trait and the macro share a name across namespaces; intra-doc links use `macro@DecryptFrom` where the macro is meant. The blanket call-site traits are the `Into` half of a `From`/`Into` pair and are now named that way: `EncryptExt` → `EncryptInto`, `DecryptExt` → `DecryptInto`. `.encrypt_into` / `.decrypt_into` are unchanged. No behaviour change; every user of the renamed items in the crate, its examples, tests and RFC 0002 is updated. Claude-Session: https://claude.ai/code/session_01HU1Bbw4eQp9kEEneXxDcKr --- ...nc-shape-for-target-directed-encryption.md | 6 ++-- .../stack-encrypt-derive/docs/attributes.md | 4 +-- packages/stack-encrypt-derive/src/decrypt.rs | 6 ++-- packages/stack-encrypt-derive/src/lib.rs | 10 +++---- packages/stack-encrypt-derive/src/shape.rs | 27 +++++++++--------- packages/stack-encrypt/Cargo.toml | 2 +- .../examples/encrypted_record.rs | 2 +- .../stack-encrypt/examples/search_terms.rs | 2 +- packages/stack-encrypt/src/lib.rs | 4 +-- packages/stack-encrypt/src/target/mod.rs | 28 +++++++++---------- packages/stack-encrypt/tests/derive.rs | 12 ++++---- packages/stack-encrypt/tests/target.rs | 2 +- 12 files changed, 52 insertions(+), 53 deletions(-) diff --git a/docs/rfcs/0002-async-shape-for-target-directed-encryption.md b/docs/rfcs/0002-async-shape-for-target-directed-encryption.md index 680cff59c..9572f441d 100644 --- a/docs/rfcs/0002-async-shape-for-target-directed-encryption.md +++ b/docs/rfcs/0002-async-shape-for-target-directed-encryption.md @@ -454,7 +454,7 @@ derivations must produce identical bytes before and after. `Pending`; they lift onto `EncryptTarget` if a second async cipher ever appears. 2. **Decrypt landed with this change** (decided): `DecryptTarget`, - `DecryptFrom`, `DecryptExt` and `DecryptContext` mirror the encrypt side; + `DecryptFrom`, `DecryptInto` and `DecryptContext` mirror the encrypt side; the `Vec` implementation batches a column of rows into one `retrieve_keys`. The derive will emit both directions from day one. 3. **`Request` is public but opaque** (decided): constructors only @@ -521,7 +521,7 @@ claims: merging pendings from different ciphers is `Error::CipherMismatch` rather than a `debug_assert`. -### `#[derive(Encrypted)]` / `#[derive(Decrypted)]` (follow-up PR) +### `#[derive(Encrypted)]` / `#[derive(DecryptFrom)]` (follow-up PR) The derive emits exactly the §4.4 shape — one impl over `StackCipher`, field pendings zipped and mapped, never awaited — for a struct of leaves, and @@ -546,7 +546,7 @@ The derive is bound to `StackCipher` rather than generic over methods, and that extension does not change the attribute surface. `from` fields carry no where clause (the source field's type is not visible to the macro), so their obligations are checked in the impl body — which is also why -`Decrypted` requires a named `source`: a blanket impl over every plaintext +`DecryptFrom` requires a named `source`: a blanket impl over every plaintext type would violate the orphan rule outside this crate. ## 8. Where findings get recorded diff --git a/packages/stack-encrypt-derive/docs/attributes.md b/packages/stack-encrypt-derive/docs/attributes.md index ca47fa707..d205127c0 100644 --- a/packages/stack-encrypt-derive/docs/attributes.md +++ b/packages/stack-encrypt-derive/docs/attributes.md @@ -14,7 +14,7 @@ so the record accepts exactly the sources *every* derived field accepts — `EncryptedAge` below is `EncryptFrom` for any `S` that both `StackCipherText` and `EqualityTerm` accept. With `source`, the record accepts only the listed types (a column that holds integers should not accept a -`String`), and `Decrypted` — which must name the plaintext type — becomes +`String`), and `DecryptFrom` — which must name the plaintext type — becomes possible. ## On a field @@ -24,7 +24,7 @@ possible. | `context = "..."` | Derive this field under exactly this context rather than the one the caller passed for the record. A query-side term built under the same literal matches it. | | `from = field` | Derive this field from `source.field` rather than from the whole source. Needs `source = ..` on the struct. | | `default` / `default = expr` | Not derived: filled with `Default::default()` or `expr`. Never encrypted, never authenticated. | -| `decrypt` | Decryption opens this field (`Decrypted` only). One field opened as the whole plaintext, or several with `from = ..` rebuilding the source field by field. | +| `decrypt` | Decryption opens this field (`DecryptFrom` only). One field opened as the whole plaintext, or several with `from = ..` rebuilding the source field by field. | The record's own context reaches every derived field that has no `context` of its own; if every field has one, the record's context is unused and the diff --git a/packages/stack-encrypt-derive/src/decrypt.rs b/packages/stack-encrypt-derive/src/decrypt.rs index f38e3cee5..f40dcbbba 100644 --- a/packages/stack-encrypt-derive/src/decrypt.rs +++ b/packages/stack-encrypt-derive/src/decrypt.rs @@ -1,4 +1,4 @@ -//! Expansion of `#[derive(Decrypted)]`. +//! Expansion of `#[derive(DecryptFrom)]`. use std::collections::HashSet; @@ -17,7 +17,7 @@ pub(crate) fn derive(input: DeriveInput) -> Result { if record.sources.is_empty() { return Err(syn::Error::new_spanned( name, - "Decrypted needs `#[encrypted(source = ..)]`: the plaintext type must be named. (An \ + "DecryptFrom needs `#[encrypted(source = ..)]`: the plaintext type must be named. (An \ impl for every type that can be decrypted from the ciphertext field would be a \ blanket impl of a foreign trait, which the orphan rule forbids outside \ stack-encrypt.)", @@ -28,7 +28,7 @@ pub(crate) fn derive(input: DeriveInput) -> Result { if opened.is_empty() { return Err(syn::Error::new_spanned( name, - "Decrypted needs to know which field decryption opens: mark it \ + "DecryptFrom needs to know which field decryption opens: mark it \ `#[encrypted(decrypt)]` (index terms are one-way and cannot be)", )); } diff --git a/packages/stack-encrypt-derive/src/lib.rs b/packages/stack-encrypt-derive/src/lib.rs index 24b474d4c..413a18419 100644 --- a/packages/stack-encrypt-derive/src/lib.rs +++ b/packages/stack-encrypt-derive/src/lib.rs @@ -14,10 +14,10 @@ //! //! ```ignore //! use stack_encrypt::sem::{EqualityTerm, OreTerm}; -//! use stack_encrypt::{Decrypted, Encrypted, StackCipherText}; +//! use stack_encrypt::{DecryptFrom, Encrypted, StackCipherText}; //! //! /// An encrypted integer, queryable by equality and range. -//! #[derive(Encrypted, Decrypted)] +//! #[derive(Encrypted, DecryptFrom)] //! #[encrypted(source = u32)] //! struct EncryptedAge { //! #[encrypted(decrypt)] @@ -49,7 +49,7 @@ //! email: String, //! } //! -//! #[derive(Encrypted, Decrypted)] +//! #[derive(Encrypted, DecryptFrom)] //! #[encrypted(source = User)] //! struct EncryptedUser { //! #[encrypted(from = age, context = "users/age", decrypt)] @@ -129,8 +129,8 @@ pub fn derive_encrypted(input: TokenStream) -> TokenStream { /// the [crate documentation](crate); the attributes it accepts are reproduced /// below. #[doc = include_str!("../docs/attributes.md")] -#[proc_macro_derive(Decrypted, attributes(encrypted))] -pub fn derive_decrypted(input: TokenStream) -> TokenStream { +#[proc_macro_derive(DecryptFrom, attributes(encrypted))] +pub fn derive_decrypt_from(input: TokenStream) -> TokenStream { let input = parse_macro_input!(input as DeriveInput); decrypt::derive(input) .unwrap_or_else(syn::Error::into_compile_error) diff --git a/packages/stack-encrypt-derive/src/shape.rs b/packages/stack-encrypt-derive/src/shape.rs index 18893d9a3..5bda51532 100644 --- a/packages/stack-encrypt-derive/src/shape.rs +++ b/packages/stack-encrypt-derive/src/shape.rs @@ -71,23 +71,22 @@ impl Record { pub(crate) fn parse(input: &DeriveInput) -> Result { let attrs = ContainerAttrs::parse(&input.attrs)?; - let data = - match &input.data { - Data::Struct(data) => data, - Data::Enum(_) => return Err(syn::Error::new_spanned( - &input.ident, - "Encrypted/Decrypted cannot be derived for enums: a record is a fixed set of \ + let data = match &input.data { + Data::Struct(data) => data, + Data::Enum(_) => return Err(syn::Error::new_spanned( + &input.ident, + "Encrypted/DecryptFrom cannot be derived for enums: a record is a fixed set of \ fields derived from one source, and a variant choice has no field to be \ derived into. Model the choice explicitly instead, e.g. as a struct of \ `Option` fields.", - )), - Data::Union(_) => { - return Err(syn::Error::new_spanned( - &input.ident, - "Encrypted/Decrypted cannot be derived for unions", - )) - } - }; + )), + Data::Union(_) => { + return Err(syn::Error::new_spanned( + &input.ident, + "Encrypted/DecryptFrom cannot be derived for unions", + )) + } + }; let fields = collect(&data.fields)?; diff --git a/packages/stack-encrypt/Cargo.toml b/packages/stack-encrypt/Cargo.toml index 917ad1a65..23f4121f6 100644 --- a/packages/stack-encrypt/Cargo.toml +++ b/packages/stack-encrypt/Cargo.toml @@ -23,7 +23,7 @@ stack-kms = { path = "../stack-kms", default-features = false, features = ["prof # `StackCipher::new()` builds a ZeroKMS client from the environment, so its # return type names the auto-detected auth strategy. stack-auth = { workspace = true } -# `#[derive(Encrypted)]` / `#[derive(Decrypted)]`, re-exported from `target`. +# `#[derive(Encrypted)]` / `#[derive(DecryptFrom)]`, re-exported from `target`. stack-encrypt-derive = { path = "../stack-encrypt-derive" } # The revised Cipher/Decipher traits, ContextTag, Element, and the PRF crates diff --git a/packages/stack-encrypt/examples/encrypted_record.rs b/packages/stack-encrypt/examples/encrypted_record.rs index 867672812..31cad0134 100644 --- a/packages/stack-encrypt/examples/encrypted_record.rs +++ b/packages/stack-encrypt/examples/encrypted_record.rs @@ -27,7 +27,7 @@ use stack_encrypt::sem::{EqualityTerm, OreTerm}; use stack_encrypt::target::{ - DecryptContext, DecryptExt, DecryptFrom, EncryptContext, EncryptExt, EncryptFrom, Pending, + DecryptContext, DecryptFrom, DecryptInto, EncryptContext, EncryptFrom, EncryptInto, Pending, }; use stack_encrypt::{StackCipher, StackCipherText}; diff --git a/packages/stack-encrypt/examples/search_terms.rs b/packages/stack-encrypt/examples/search_terms.rs index 19a6130c7..a36b36c72 100644 --- a/packages/stack-encrypt/examples/search_terms.rs +++ b/packages/stack-encrypt/examples/search_terms.rs @@ -19,7 +19,7 @@ //! `zerokms_auth` example for the lookup order). use stack_encrypt::sem::{EqualityTerm, MatchTerm, OreTerm}; -use stack_encrypt::target::EncryptExt; +use stack_encrypt::target::EncryptInto; use stack_encrypt::StackCipher; #[tokio::main(flavor = "current_thread")] diff --git a/packages/stack-encrypt/src/lib.rs b/packages/stack-encrypt/src/lib.rs index ac08e63ef..3ca350f60 100644 --- a/packages/stack-encrypt/src/lib.rs +++ b/packages/stack-encrypt/src/lib.rs @@ -126,8 +126,8 @@ pub use cipher::{ StackCipherBuilder, StackCipherText, StackDecipher, }; pub use target::{ - DecryptContext, DecryptExt, DecryptFrom, DecryptTarget, Decrypted, EncryptContext, EncryptExt, - EncryptFrom, EncryptTarget, Encrypted, Pending, PendingFuture, Request, Responses, + DecryptContext, DecryptFrom, DecryptInto, DecryptTarget, EncryptContext, EncryptFrom, + EncryptInto, EncryptTarget, Encrypted, Pending, PendingFuture, Request, Responses, }; // Re-export the vitaminc AEAD surface callers need to drive the cipher, so they diff --git a/packages/stack-encrypt/src/target/mod.rs b/packages/stack-encrypt/src/target/mod.rs index 4d49aef65..7c101c33b 100644 --- a/packages/stack-encrypt/src/target/mod.rs +++ b/packages/stack-encrypt/src/target/mod.rs @@ -27,9 +27,9 @@ //! * [`DecryptFrom`] — the mirror, implemented by the *plaintext* //! type: "`Self` is recoverable from the encrypted `S`". Only ciphertext //! fields participate — index terms are one-way by construction. -//! [`#[derive(Decrypted)]`](Decrypted) on the record emits it for the +//! [`#[derive(DecryptFrom)]`](macro@DecryptFrom) on the record emits it for the //! record's named source type(s). -//! * [`EncryptExt::encrypt_into`] / [`DecryptExt::decrypt_into`] — blanket +//! * [`EncryptInto::encrypt_into`] / [`DecryptInto::decrypt_into`] — blanket //! call-site sugar, the `Into` to the `From` above. Never implemented by //! hand. //! * [`EncryptTarget`] / [`DecryptTarget`] — implemented by ciphers; their @@ -53,7 +53,7 @@ //! kind: //! //! ``` -//! use stack_encrypt::target::{DecryptExt, EncryptExt}; +//! use stack_encrypt::target::{DecryptInto, EncryptInto}; //! use stack_encrypt::{StackCipher, StackCipherText}; //! use stack_kms::FakeDataKeySource; //! @@ -165,12 +165,12 @@ //! //! ``` //! use stack_encrypt::sem::{EqualityTerm, OreTerm}; -//! use stack_encrypt::target::{DecryptExt, EncryptExt}; -//! use stack_encrypt::{Decrypted, Encrypted, StackCipher, StackCipherText}; +//! use stack_encrypt::target::{DecryptInto, EncryptInto}; +//! use stack_encrypt::{DecryptFrom, Encrypted, StackCipher, StackCipherText}; //! use stack_kms::FakeDataKeySource; //! //! /// An encrypted `u32`, queryable by equality and range. -//! #[derive(Encrypted, Decrypted)] +//! #[derive(Encrypted, DecryptFrom)] //! #[encrypted(source = u32)] //! struct EncryptedAge { //! #[encrypted(decrypt)] @@ -185,7 +185,7 @@ //! email: String, //! } //! -//! #[derive(Encrypted, Decrypted)] +//! #[derive(Encrypted, DecryptFrom)] //! #[encrypted(source = User)] //! struct EncryptedUser { //! #[encrypted(from = age, context = "users/age", decrypt)] @@ -244,7 +244,7 @@ mod request; pub use pending::{Pending, PendingFuture}; pub use request::{Request, Responses}; -pub use stack_encrypt_derive::{Decrypted, Encrypted}; +pub use stack_encrypt_derive::{DecryptFrom, Encrypted}; // ============================================================================= // Contexts @@ -540,7 +540,7 @@ pub trait DecryptFrom: Sized { /// /// ``` /// use stack_encrypt::sem::EqualityTerm; -/// use stack_encrypt::target::EncryptExt; +/// use stack_encrypt::target::EncryptInto; /// use stack_encrypt::StackCipher; /// use stack_kms::FakeDataKeySource; /// @@ -555,7 +555,7 @@ pub trait DecryptFrom: Sized { /// # Ok::<(), stack_encrypt::Error>(()) /// # }).unwrap(); /// ``` -pub trait EncryptExt { +pub trait EncryptInto { /// Encrypt `self` into `T` under `context`. See [`EncryptFrom`]. fn encrypt_into<'a, 'c, T, C, Ctx>(&'a self, cipher: &'a C, context: Ctx) -> C::Output<'a, T> where @@ -565,7 +565,7 @@ pub trait EncryptExt { Self: Sized; } -impl EncryptExt for S { +impl EncryptInto for S { fn encrypt_into<'a, 'c, T, C, Ctx>(&'a self, cipher: &'a C, context: Ctx) -> C::Output<'a, T> where C: EncryptTarget, @@ -577,9 +577,9 @@ impl EncryptExt for S { } /// Call-site sugar: `encrypted.decrypt_into::(&cipher, context)` — the -/// decrypt-side [`EncryptExt`]. Blanket-implemented; never implemented by +/// decrypt-side [`EncryptInto`]. Blanket-implemented; never implemented by /// hand. -pub trait DecryptExt: Sized { +pub trait DecryptInto: Sized { /// Decrypt `self` into `T`, authenticating against `context`. See /// [`DecryptFrom`]. fn decrypt_into<'a, 'c, T, C, Ctx>(self, cipher: &'a C, context: Ctx) -> C::Output<'a, T> @@ -590,7 +590,7 @@ pub trait DecryptExt: Sized { Self: 'a; } -impl DecryptExt for S { +impl DecryptInto for S { fn decrypt_into<'a, 'c, T, C, Ctx>(self, cipher: &'a C, context: Ctx) -> C::Output<'a, T> where C: DecryptTarget, diff --git a/packages/stack-encrypt/tests/derive.rs b/packages/stack-encrypt/tests/derive.rs index 4f13072f8..a6c2dc03c 100644 --- a/packages/stack-encrypt/tests/derive.rs +++ b/packages/stack-encrypt/tests/derive.rs @@ -1,4 +1,4 @@ -//! `#[derive(Encrypted)]` / `#[derive(Decrypted)]`: the derived impls are the +//! `#[derive(Encrypted)]` / `#[derive(DecryptFrom)]`: the derived impls are the //! hand-written composite in `target.rs`, emitted — same terms, same decrypt //! mirror, same one-batched-call settlement — plus what only a derive makes //! cheap: sources listed or left generic, rows derived field by field, and @@ -11,14 +11,14 @@ use std::sync::atomic::Ordering as AtomicOrdering; use cllw_ore::CllwOreEncrypt; use common::{counting_cipher, stack_cipher}; use stack_encrypt::sem::{EqualityTerm, MatchTerm, OreTerm}; -use stack_encrypt::target::{DecryptExt, EncryptExt}; -use stack_encrypt::{Decrypted, Encrypted, Error, StackCipherText}; +use stack_encrypt::target::{DecryptInto, EncryptInto}; +use stack_encrypt::{DecryptFrom, Encrypted, Error, StackCipherText}; // --- Records: every field from one source, under one context ---------------- /// The hand-written record in `target.rs`, derived: an encrypted `u32` /// stored as its ciphertext plus an equality term and an ORE term. -#[derive(Encrypted, Decrypted)] +#[derive(Encrypted, DecryptFrom)] #[encrypted(source = u32)] struct EncryptedAge { #[encrypted(decrypt)] @@ -107,7 +107,7 @@ async fn a_generic_source_record_accepts_what_its_leaves_accept() { } /// Listed sources: one impl each, and nothing else is accepted. -#[derive(Encrypted, Decrypted)] +#[derive(Encrypted, DecryptFrom)] #[encrypted(source = u32, source = String)] struct EncryptedValue { #[encrypted(decrypt)] @@ -153,7 +153,7 @@ struct User { email: String, } -#[derive(Encrypted, Decrypted)] +#[derive(Encrypted, DecryptFrom)] #[encrypted(source = User)] struct EncryptedUser { /// A record inside a row: recursion, not a second mechanism. diff --git a/packages/stack-encrypt/tests/target.rs b/packages/stack-encrypt/tests/target.rs index 077ae40af..ff11241d5 100644 --- a/packages/stack-encrypt/tests/target.rs +++ b/packages/stack-encrypt/tests/target.rs @@ -9,7 +9,7 @@ use std::sync::atomic::Ordering as AtomicOrdering; use stack_encrypt::sem::{EqualityTerm, MatchConfig, MatchOptions, MatchTerm, OreTerm}; use stack_encrypt::target::{ - DecryptContext, DecryptExt, DecryptFrom, EncryptContext, EncryptExt, EncryptFrom, Pending, + DecryptContext, DecryptFrom, DecryptInto, EncryptContext, EncryptFrom, EncryptInto, Pending, Request, }; use stack_encrypt::{Error, StackCipher, StackCipherText}; From 2ca4d30f3c3ba4c73a8239e0af126f657ac39480 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Fri, 28 Aug 2026 11:31:45 +1000 Subject: [PATCH 455/686] refactor(stack-encrypt): DecryptInto is implemented on the encrypted type; derives named after their traits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The record type is the only type a downstream crate can implement on, and it sits on opposite sides of the two arrows: it is the *output* of encryption and the *input* of decryption. So the implementable half of each From/Into pair is the one whose `Self` is the record — `EncryptFrom

` (already so) and now `DecryptInto

`. The other halves are blanket call-site sugar: `EncryptInto::encrypt_into` (unchanged) and `DecryptFrom::decrypt_from` (now blanket over `S: DecryptInto

`, previously the implementable trait on the plaintext). Consequences: - An encrypted type owns its own opening: `impl DecryptInto for EqlIntA` in the crate that defines `EqlIntA`. Several encrypted types may decrypt to one plaintext and one encrypted type to several; vitaminc's `Decrypt` stays the shared bytes-to-value leaf underneath. - The leaf, `Vec` and `Option` decrypt impls flip to `DecryptInto` on the ciphertext side. `rec.decrypt_into(&cipher, ctx)` call sites are unchanged. - The derives are named after the trait they emit, as serde's are: `#[derive(EncryptFrom, DecryptInto)]`. `Decrypted` (then briefly `DecryptFrom`) was emitting a trait whose `Self` was not the annotated struct; `Encrypted` matched neither. - The attribute is named after the crate, `#[stack_encrypt(..)]`, so it cannot collide with another derive's attribute and reads as a namespace rather than an adjective (`#[encrypted(decrypt)]`). `source = T` becomes `plaintext = T`: "source" is the encrypt direction's word and the target of decryption, and the same type is meant from either side. - `plaintext` is now optional for a record opened as a whole (the impl is generic over what its ciphertext field decrypts to, mirroring the encrypt derive); rows still name it because they rebuild it with a struct literal. The orphan-rule reason for requiring it no longer exists. RFC 0002 §7 records the rule and the rename history. Claude-Session: https://claude.ai/code/session_01HU1Bbw4eQp9kEEneXxDcKr --- ...nc-shape-for-target-directed-encryption.md | 32 +- .../stack-encrypt-derive/docs/attributes.md | 22 +- packages/stack-encrypt-derive/src/attrs.rs | 40 +-- packages/stack-encrypt-derive/src/decrypt.rs | 312 +++++++++++------- packages/stack-encrypt-derive/src/encrypt.rs | 16 +- packages/stack-encrypt-derive/src/lib.rs | 40 +-- packages/stack-encrypt-derive/src/shape.rs | 48 +-- packages/stack-encrypt/Cargo.toml | 2 +- .../examples/encrypted_record.rs | 22 +- packages/stack-encrypt/src/cipher.rs | 4 +- packages/stack-encrypt/src/lib.rs | 2 +- packages/stack-encrypt/src/target/mod.rs | 152 +++++---- packages/stack-encrypt/src/target/pending.rs | 2 +- packages/stack-encrypt/tests/derive.rs | 60 ++-- packages/stack-encrypt/tests/target.rs | 19 +- 15 files changed, 440 insertions(+), 333 deletions(-) diff --git a/docs/rfcs/0002-async-shape-for-target-directed-encryption.md b/docs/rfcs/0002-async-shape-for-target-directed-encryption.md index 9572f441d..d2379a0b8 100644 --- a/docs/rfcs/0002-async-shape-for-target-directed-encryption.md +++ b/docs/rfcs/0002-async-shape-for-target-directed-encryption.md @@ -454,9 +454,19 @@ derivations must produce identical bytes before and after. `Pending`; they lift onto `EncryptTarget` if a second async cipher ever appears. 2. **Decrypt landed with this change** (decided): `DecryptTarget`, - `DecryptFrom`, `DecryptInto` and `DecryptContext` mirror the encrypt side; + `DecryptInto`, `DecryptFrom` and `DecryptContext` mirror the encrypt side; the `Vec` implementation batches a column of rows into one `retrieve_keys`. The derive will emit both directions from day one. + Which half of each `From`/`Into` pair is the implementable one follows + from where `Self` lands: the record is the *output* of encryption and the + *input* of decryption, and it is the only type a downstream crate can + implement on, so `EncryptFrom

` and `DecryptInto

` are implemented + (and derived) on the record while `EncryptInto` and `DecryptFrom` are + blanket call-site sugar. An encrypted type may decrypt to several + plaintexts and several encrypted types (the EQL integer payloads, say) to + one plaintext; each owns its own opening. vitaminc's `Decrypt` — the + plaintext's *bytes → value* step, the same for every ciphertext shape — + stays underneath as the leaf. 3. **`Request` is public but opaque** (decided): constructors only (`Request::generate_data_key()`, `Request::retrieve_data_key(iv, tag)`, later a PRF request and a keyset override), internals private. `Responses` @@ -521,7 +531,7 @@ claims: merging pendings from different ciphers is `Error::CipherMismatch` rather than a `debug_assert`. -### `#[derive(Encrypted)]` / `#[derive(DecryptFrom)]` (follow-up PR) +### `#[derive(EncryptFrom)]` / `#[derive(DecryptInto)]` (follow-up PR) The derive emits exactly the §4.4 shape — one impl over `StackCipher`, field pendings zipped and mapped, never awaited — for a struct of leaves, and @@ -544,10 +554,20 @@ The derive is bound to `StackCipher` rather than generic over `EncryptTarget`, because combining outputs needs `zip`/`map` and only `Pending` has them; a generic derive would need those as `EncryptTarget` methods, and that extension does not change the attribute surface. `from` -fields carry no where clause (the source field's type is not visible to the -macro), so their obligations are checked in the impl body — which is also why -`DecryptFrom` requires a named `source`: a blanket impl over every plaintext -type would violate the orphan rule outside this crate. +fields carry no where clause (the plaintext field's type is not visible to +the macro), so their obligations are checked in the impl body. Rows — +decrypted field by field — must name their `plaintext`, because the derive +rebuilds it with a struct literal; a record opened as a whole may leave it +off and decrypt to whatever its ciphertext field opens to. + +The derives are named after the trait they emit, as serde's are, and the +attribute after the crate: `#[stack_encrypt(plaintext = ..)]`, +`#[stack_encrypt(from = .., context = "..", decrypt)]`. First shipped as +`#[derive(Encrypted, Decrypted)]` with `#[encrypted(source = ..)]`: the +decrypt macro sat on the record but emitted `DecryptFrom for +Plaintext`, a trait whose `Self` was not the annotated type, and the +attribute name lined up with neither derive. Flipping the decrypt trait (item +2 in §7) is what let the macro be named honestly. ## 8. Where findings get recorded diff --git a/packages/stack-encrypt-derive/docs/attributes.md b/packages/stack-encrypt-derive/docs/attributes.md index d205127c0..7d95edeef 100644 --- a/packages/stack-encrypt-derive/docs/attributes.md +++ b/packages/stack-encrypt-derive/docs/attributes.md @@ -1,30 +1,30 @@ # Attributes -All attributes live under `#[encrypted(...)]`. +All attributes live under `#[stack_encrypt(...)]`. ## On the struct | Attribute | Effect | |---|---| -| `source = Type` | The record is an encrypted form of `Type`. Repeatable: one impl per listed type. Omit it for an impl generic over the source (see below). | +| `plaintext = Type` | The record is an encrypted form of `Type`. Repeatable: one impl per listed type. Omit it for an impl generic over the plaintext (see below). | | `crate = "path"` | Where to find `stack_encrypt` in the generated code (default `::stack_encrypt`), for use through a re-export. | -Without `source`, `Encrypted` emits one impl generic over the source, bounded -so the record accepts exactly the sources *every* derived field accepts — -`EncryptedAge` below is `EncryptFrom` for any `S` that both -`StackCipherText` and `EqualityTerm` accept. With `source`, the record accepts -only the listed types (a column that holds integers should not accept a -`String`), and `DecryptFrom` — which must name the plaintext type — becomes -possible. +Without `plaintext`, each derive emits one impl generic over the plaintext, +bounded by what the fields accept: `EncryptedAge` below is `EncryptFrom` +for any `P` that both `StackCipherText` and `EqualityTerm` accept, and +`DecryptInto` for any `P` its `decrypt` field opens to. With +`plaintext`, the record accepts only the listed types (a column that holds +integers should not accept a `String`). Rows — decrypted field by field — +must name it: the plaintext is rebuilt with a struct literal. ## On a field | Attribute | Effect | |---|---| | `context = "..."` | Derive this field under exactly this context rather than the one the caller passed for the record. A query-side term built under the same literal matches it. | -| `from = field` | Derive this field from `source.field` rather than from the whole source. Needs `source = ..` on the struct. | +| `from = field` | Derive this field from `plaintext.field` rather than from the whole plaintext. Needs `plaintext = ..` on the struct. | | `default` / `default = expr` | Not derived: filled with `Default::default()` or `expr`. Never encrypted, never authenticated. | -| `decrypt` | Decryption opens this field (`DecryptFrom` only). One field opened as the whole plaintext, or several with `from = ..` rebuilding the source field by field. | +| `decrypt` | Decryption opens this field (`DecryptInto` only). One field opened as the whole plaintext, or several with `from = ..` rebuilding the plaintext field by field. | The record's own context reaches every derived field that has no `context` of its own; if every field has one, the record's context is unused and the diff --git a/packages/stack-encrypt-derive/src/attrs.rs b/packages/stack-encrypt-derive/src/attrs.rs index d06a9dd44..c9941a0e6 100644 --- a/packages/stack-encrypt-derive/src/attrs.rs +++ b/packages/stack-encrypt-derive/src/attrs.rs @@ -1,61 +1,61 @@ -//! Parsing of the `#[encrypted(...)]` container and field attributes. +//! Parsing of the `#[stack_encrypt(...)]` container and field attributes. use syn::{Attribute, Expr, Ident, LitStr, Path, Result, Type}; -/// Container-level options, from `#[encrypted(...)]` on the struct itself. +/// Container-level options, from `#[stack_encrypt(...)]` on the struct itself. pub(crate) struct ContainerAttrs { /// Path to the `stack_encrypt` crate in the generated code. Defaults to - /// `::stack_encrypt`; overridden by `#[encrypted(crate = "...")]` so the + /// `::stack_encrypt`; overridden by `#[stack_encrypt(crate = "...")]` so the /// macros work through a re-export. pub(crate) krate: Path, - /// The source types this record is an encrypted form of, one impl each, - /// from repeated `#[encrypted(source = Type)]`. Empty means a single impl - /// generic over the source. - pub(crate) sources: Vec, + /// The plaintext types this record is an encrypted form of, one impl + /// each, from repeated `#[stack_encrypt(plaintext = Type)]`. Empty means + /// a single impl generic over the plaintext. + pub(crate) plaintexts: Vec, } impl ContainerAttrs { pub(crate) fn parse(attrs: &[Attribute]) -> Result { let mut krate: Option = None; - let mut sources = Vec::new(); + let mut plaintexts = Vec::new(); - for attr in attrs.iter().filter(|a| a.path().is_ident("encrypted")) { + for attr in attrs.iter().filter(|a| a.path().is_ident("stack_encrypt")) { attr.parse_nested_meta(|meta| { if meta.path.is_ident("crate") { let lit: LitStr = meta.value()?.parse()?; krate = Some(lit.parse()?); return Ok(()); } - if meta.path.is_ident("source") { - sources.push(meta.value()?.parse()?); + if meta.path.is_ident("plaintext") { + plaintexts.push(meta.value()?.parse()?); return Ok(()); } Err(meta.error( - "unsupported container attribute; expected `source = Type` or `crate = \"...\"`", + "unsupported container attribute; expected `plaintext = Type` or `crate = \"...\"`", )) })?; } Ok(Self { krate: krate.unwrap_or_else(|| syn::parse_quote!(::stack_encrypt)), - sources, + plaintexts, }) } } -/// Field-level options, from `#[encrypted(...)]` on a field. +/// Field-level options, from `#[stack_encrypt(...)]` on a field. #[derive(Default)] pub(crate) struct FieldAttrs { - /// `#[encrypted(context = "...")]`: derive this field under exactly this + /// `#[stack_encrypt(context = "...")]`: derive this field under exactly this /// context instead of the one the caller passed for the record. pub(crate) context: Option, - /// `#[encrypted(from = field)]`: derive this field from one field of the - /// source rather than from the whole source. + /// `#[stack_encrypt(from = field)]`: derive this field from one field of + /// the plaintext rather than from the whole plaintext. pub(crate) from: Option, - /// `#[encrypted(default)]` / `#[encrypted(default = expr)]`: not derived; + /// `#[stack_encrypt(default)]` / `#[stack_encrypt(default = expr)]`: not derived; /// filled with `Default::default()` or the expression. pub(crate) default: Option>, - /// `#[encrypted(decrypt)]`: decryption opens this field. + /// `#[stack_encrypt(decrypt)]`: decryption opens this field. pub(crate) decrypt: bool, } @@ -63,7 +63,7 @@ impl FieldAttrs { pub(crate) fn parse(attrs: &[Attribute]) -> Result { let mut parsed = Self::default(); - for attr in attrs.iter().filter(|a| a.path().is_ident("encrypted")) { + for attr in attrs.iter().filter(|a| a.path().is_ident("stack_encrypt")) { attr.parse_nested_meta(|meta| { if meta.path.is_ident("context") { parsed.context = Some(meta.value()?.parse()?); diff --git a/packages/stack-encrypt-derive/src/decrypt.rs b/packages/stack-encrypt-derive/src/decrypt.rs index f40dcbbba..8a2470f79 100644 --- a/packages/stack-encrypt-derive/src/decrypt.rs +++ b/packages/stack-encrypt-derive/src/decrypt.rs @@ -1,4 +1,4 @@ -//! Expansion of `#[derive(DecryptFrom)]`. +//! Expansion of `#[derive(DecryptInto)]`. use std::collections::HashSet; @@ -14,104 +14,149 @@ pub(crate) fn derive(input: DeriveInput) -> Result { let name = &input.ident; let (_, ty_generics, _) = input.generics.split_for_impl(); - if record.sources.is_empty() { - return Err(syn::Error::new_spanned( - name, - "DecryptFrom needs `#[encrypted(source = ..)]`: the plaintext type must be named. (An \ - impl for every type that can be decrypted from the ciphertext field would be a \ - blanket impl of a foreign trait, which the orphan rule forbids outside \ - stack-encrypt.)", - )); - } - let opened: Vec<&Field> = record.fields.iter().filter(|f| f.decrypt).collect(); if opened.is_empty() { return Err(syn::Error::new_spanned( name, - "DecryptFrom needs to know which field decryption opens: mark it \ - `#[encrypted(decrypt)]` (index terms are one-way and cannot be)", + "DecryptInto needs to know which field decryption opens: mark it \ + `#[stack_encrypt(decrypt)]` (index terms are one-way and cannot be)", )); } let by_field = opened.iter().filter(|f| f.from().is_some()).count(); - let mode = - match by_field { - 0 if opened.len() == 1 => Mode::Whole(opened[0]), - 0 => return Err(syn::Error::new_spanned( + let mode = match by_field { + 0 if opened.len() == 1 => Mode::Whole(opened[0]), + 0 => { + return Err(syn::Error::new_spanned( name, - "several fields are marked `decrypt` but none names a source field: one plaintext \ - cannot be recovered from two fields. Either mark only the ciphertext field, or \ - give each a `from = ..` so decryption rebuilds the source field by field.", - )), - n if n == opened.len() => { - let mut seen: HashSet<&Ident> = HashSet::with_capacity(opened.len()); - for field in &opened { - let from = field - .from() - .unwrap_or_else(|| unreachable!("counted above")); - if !seen.insert(from) { - return Err(syn::Error::new( - from.span(), - format!( - "two `decrypt` fields would recover the same source field `{from}`" - ), - )); - } + "several fields are marked `decrypt` but none names a plaintext field: one \ + plaintext cannot be recovered from two fields. Either mark only the ciphertext \ + field, or give each a `from = ..` so decryption rebuilds the plaintext field by \ + field.", + )) + } + n if n == opened.len() => { + let mut seen: HashSet<&Ident> = HashSet::with_capacity(opened.len()); + for field in &opened { + let from = field + .from() + .unwrap_or_else(|| unreachable!("counted above")); + if !seen.insert(from) { + return Err(syn::Error::new( + from.span(), + format!( + "two `decrypt` fields would recover the same plaintext field `{from}`" + ), + )); } - Mode::ByField(opened) } - _ => { - return Err(syn::Error::new_spanned( - name, - "`decrypt` fields must either all name a source field (`from = ..`) or be a \ + Mode::ByField(opened) + } + _ => { + return Err(syn::Error::new_spanned( + name, + "`decrypt` fields must either all name a plaintext field (`from = ..`) or be a \ single field opened as the whole plaintext; this record mixes the two", - )) - } + )) + } + }; + + if record.plaintexts.is_empty() { + // One impl, generic over the plaintext: the record decrypts to + // whatever its opened field decrypts to. Only the whole-plaintext + // mode can be generic — rebuilding field by field needs a struct + // literal, and therefore a name. + let Mode::Whole(field) = &mode else { + return Err(syn::Error::new_spanned( + name, + "field-by-field decryption rebuilds the plaintext as a struct literal, so the \ + plaintext type must be named: add `#[stack_encrypt(plaintext = ..)]` to the struct", + )); }; + let plaintext: Type = parse_quote!(__P); + let ty = &field.ty; + let mut generics = input.generics.clone(); + generics.params.push(parse_quote!(__P)); + generics.params.push(parse_quote!(__K)); + generics.make_where_clause().predicates.push(parse_quote! { + #ty: #krate::target::DecryptInto<__P, #krate::StackCipher<__K>> + }); + let (impl_generics, _, where_clause) = generics.split_for_impl(); + let body = whole_body(krate, field, &plaintext); + return Ok(impl_block( + krate, + name, + &ty_generics, + &impl_generics, + where_clause, + &plaintext, + body, + )); + } let impls = record - .sources + .plaintexts .iter() - .map(|source| { + .map(|plaintext| { let mut generics = input.generics.clone(); generics.params.push(parse_quote!(__K)); let body = match &mode { Mode::Whole(field) => { let ty = &field.ty; generics.make_where_clause().predicates.push(parse_quote! { - #source: #krate::target::DecryptFrom<#ty, #krate::StackCipher<__K>> + #ty: #krate::target::DecryptInto<#plaintext, #krate::StackCipher<__K>> }); - whole_body(krate, field, source) + whole_body(krate, field, plaintext) } - Mode::ByField(fields) => by_field_body(krate, fields, source)?, + Mode::ByField(fields) => by_field_body(krate, fields, plaintext)?, }; let (impl_generics, _, where_clause) = generics.split_for_impl(); - - Ok(quote! { - #[automatically_derived] - impl #impl_generics #krate::target::DecryptFrom<#name #ty_generics, #krate::StackCipher<__K>> - for #source #where_clause - { - fn decrypt_from<'__a, '__c, __Ctx>( - __source: #name #ty_generics, - __cipher: &'__a #krate::StackCipher<__K>, - __context: __Ctx, - ) -> #krate::target::Pending<'__a, Self, __K> - where - __Ctx: #krate::target::DecryptContext<'__c>, - #name #ty_generics: '__a, - Self: '__a, - { - #body - } - } - }) + Ok(impl_block( + krate, + name, + &ty_generics, + &impl_generics, + where_clause, + plaintext, + body, + )) }) .collect::>>()?; Ok(quote!(#(#impls)*)) } +/// `impl DecryptInto> for Record` around `body`. +fn impl_block( + krate: &Path, + name: &Ident, + ty_generics: &syn::TypeGenerics<'_>, + impl_generics: &syn::ImplGenerics<'_>, + where_clause: Option<&syn::WhereClause>, + plaintext: &Type, + body: TokenStream, +) -> TokenStream { + quote! { + #[automatically_derived] + impl #impl_generics #krate::target::DecryptInto<#plaintext, #krate::StackCipher<__K>> + for #name #ty_generics #where_clause + { + fn decrypt_into<'__a, '__c, __Ctx>( + self, + __cipher: &'__a #krate::StackCipher<__K>, + __context: __Ctx, + ) -> #krate::target::Pending<'__a, #plaintext, __K> + where + __Ctx: #krate::target::DecryptContext<'__c>, + Self: '__a, + #plaintext: '__a, + { + #body + } + } + } +} + enum Mode<'a> { /// One field is the whole plaintext's ciphertext: decrypting the record is /// decrypting that field. @@ -128,21 +173,21 @@ fn context_for(field: &Field) -> TokenStream { } } -fn whole_body(krate: &Path, field: &Field, source: &Type) -> TokenStream { +fn whole_body(krate: &Path, field: &Field, plaintext: &Type) -> TokenStream { let ty = &field.ty; let member = &field.member; let context = context_for(field); quote! { - <#source as #krate::target::DecryptFrom<#ty, #krate::StackCipher<__K>>>::decrypt_from( - __source.#member, + <#ty as #krate::target::DecryptInto<#plaintext, #krate::StackCipher<__K>>>::decrypt_into( + self.#member, __cipher, #context, ) } } -fn by_field_body(krate: &Path, fields: &[&Field], source: &Type) -> Result { - let literal = struct_literal_path(source)?; +fn by_field_body(krate: &Path, fields: &[&Field], plaintext: &Type) -> Result { + let literal = struct_literal_path(plaintext)?; // The record's context goes to every opened field without its own; the // last such field takes it by move. @@ -169,8 +214,8 @@ fn by_field_body(krate: &Path, fields: &[&Field], source: &Type) -> Result>::decrypt_from( - __source.#member, + <#ty as #krate::target::DecryptInto<_, #krate::StackCipher<__K>>>::decrypt_into( + self.#member, __cipher, #context, ) @@ -196,20 +241,20 @@ fn by_field_body(krate: &Path, fields: &[&Field], source: &Type) -> Result` becomes `User::`. -fn struct_literal_path(source: &Type) -> Result { - let Type::Path(type_path) = source else { +/// The plaintext type as a struct-literal path: `User` becomes `User::`. +fn struct_literal_path(plaintext: &Type) -> Result { + let Type::Path(type_path) = plaintext else { return Err(syn::Error::new_spanned( - source, - "field-by-field decryption rebuilds the source as a struct literal, so `source` \ - must name a struct", + plaintext, + "field-by-field decryption rebuilds the plaintext as a struct literal, so \ + `plaintext` must name a struct", )); }; if type_path.qself.is_some() { return Err(syn::Error::new_spanned( - source, - "field-by-field decryption rebuilds the source as a struct literal, so `source` \ - must name a struct directly, not through a qualified path", + plaintext, + "field-by-field decryption rebuilds the plaintext as a struct literal, so \ + `plaintext` must name a struct directly, not through a qualified path", )); } let mut path = type_path.path.clone(); @@ -230,22 +275,10 @@ mod tests { derive(input).map(|tokens| tokens.to_string()) } - #[test] - fn a_source_is_required() { - let err = expand(parse_quote! { - struct Rec { - #[encrypted(decrypt)] - c: StackCipherText, - } - }) - .unwrap_err(); - assert!(err.to_string().contains("plaintext type must be named")); - } - #[test] fn an_opened_field_is_required() { let err = expand(parse_quote! { - #[encrypted(source = u32)] + #[stack_encrypt(plaintext = u32)] struct Rec { c: StackCipherText, hm: EqualityTerm, @@ -258,11 +291,11 @@ mod tests { #[test] fn two_whole_fields_are_ambiguous() { let err = expand(parse_quote! { - #[encrypted(source = u32)] + #[stack_encrypt(plaintext = u32)] struct Rec { - #[encrypted(decrypt)] + #[stack_encrypt(decrypt)] a: StackCipherText, - #[encrypted(decrypt)] + #[stack_encrypt(decrypt)] b: StackCipherText, } }) @@ -275,11 +308,11 @@ mod tests { #[test] fn mixed_modes_are_rejected() { let err = expand(parse_quote! { - #[encrypted(source = User)] + #[stack_encrypt(plaintext = User)] struct Rec { - #[encrypted(decrypt, from = a)] + #[stack_encrypt(decrypt, from = a)] a: StackCipherText, - #[encrypted(decrypt)] + #[stack_encrypt(decrypt)] b: StackCipherText, } }) @@ -290,64 +323,103 @@ mod tests { #[test] fn duplicate_recovery_targets_are_rejected() { let err = expand(parse_quote! { - #[encrypted(source = User)] + #[stack_encrypt(plaintext = User)] struct Rec { - #[encrypted(decrypt, from = a)] + #[stack_encrypt(decrypt, from = a)] a: StackCipherText, - #[encrypted(decrypt, from = a)] + #[stack_encrypt(decrypt, from = a)] b: StackCipherText, } }) .unwrap_err(); - assert!(err.to_string().contains("same source field `a`")); + assert!(err.to_string().contains("same plaintext field `a`")); + } + + #[test] + #[rustfmt::skip] + fn a_generic_plaintext_opens_the_one_field() { + let expansion = expand(parse_quote! { + struct Wrapped { + #[stack_encrypt(decrypt)] + c: StackCipherText, + hm: EqualityTerm, + } + }) + .unwrap(); + assert_contains(&expansion, quote! { + impl<__P, __K> ::stack_encrypt::target::DecryptInto<__P, ::stack_encrypt::StackCipher<__K>> for Wrapped + where + StackCipherText: ::stack_encrypt::target::DecryptInto<__P, ::stack_encrypt::StackCipher<__K>> + }); + assert_contains(&expansion, quote! { + >>::decrypt_into( + self.c, __cipher, __context, + ) + }); + assert_lacks(&expansion, quote!(hm)); + } + + #[test] + fn a_generic_plaintext_cannot_be_rebuilt_field_by_field() { + let err = expand(parse_quote! { + struct Row { + #[stack_encrypt(decrypt, from = age)] + age: EncryptedAge, + } + }) + .unwrap_err(); + // `Record::parse` catches `from` without a named plaintext first; + // either message says what to add. + assert!(err.to_string().contains("plaintext type must be named")); } #[test] #[rustfmt::skip] fn whole_mode_opens_the_one_field() { let expansion = expand(parse_quote! { - #[encrypted(source = u32, source = u64)] + #[stack_encrypt(plaintext = u32, plaintext = u64)] struct EncryptedAge { - #[encrypted(decrypt)] + #[stack_encrypt(decrypt)] c: StackCipherText, hm: EqualityTerm, } }) .unwrap(); assert_contains(&expansion, quote! { - impl<__K> ::stack_encrypt::target::DecryptFrom> for u32 + impl<__K> ::stack_encrypt::target::DecryptInto> for EncryptedAge where - u32: ::stack_encrypt::target::DecryptFrom> + StackCipherText: ::stack_encrypt::target::DecryptInto> }); assert_contains(&expansion, quote! { - >>::decrypt_from( - __source.c, __cipher, __context, + >>::decrypt_into( + self.c, __cipher, __context, ) }); assert_lacks(&expansion, quote!(hm)); + assert_lacks(&expansion, quote!(__P)); } #[test] #[rustfmt::skip] - fn by_field_mode_rebuilds_the_source() { + fn by_field_mode_rebuilds_the_plaintext() { let expansion = expand(parse_quote! { - #[encrypted(source = User)] + #[stack_encrypt(plaintext = User)] struct EncryptedUser { - #[encrypted(decrypt, from = age, context = "users/age")] + #[stack_encrypt(decrypt, from = age, context = "users/age")] age: EncryptedAge, - #[encrypted(decrypt, from = email)] + #[stack_encrypt(decrypt, from = email)] email: StackCipherText, - #[encrypted(from = email, context = "users/email")] + #[stack_encrypt(from = email, context = "users/email")] email_eq: EqualityTerm, } }) .unwrap(); assert_contains(&expansion, quote! { - ::stack_encrypt::target::DecryptFrom::>::decrypt_from( - __source.age, __cipher, "users/age", + >>::decrypt_into( + self.age, __cipher, "users/age", ) }); - assert_contains(&expansion, quote!(__source.email, __cipher, __context,)); + assert_contains(&expansion, quote!(self.email, __cipher, __context,)); assert_contains(&expansion, quote!(.map(|(__field_0, __field_1)| User:: { age: __field_0, email: __field_1 }))); assert_lacks(&expansion, quote!(email_eq)); assert_lacks(&expansion, quote!(let _ = __context;)); diff --git a/packages/stack-encrypt-derive/src/encrypt.rs b/packages/stack-encrypt-derive/src/encrypt.rs index 85cefa7c7..4e3d6b5bc 100644 --- a/packages/stack-encrypt-derive/src/encrypt.rs +++ b/packages/stack-encrypt-derive/src/encrypt.rs @@ -1,4 +1,4 @@ -//! Expansion of `#[derive(Encrypted)]`. +//! Expansion of `#[derive(EncryptFrom)]`. use proc_macro2::TokenStream; use quote::quote; @@ -12,7 +12,7 @@ pub(crate) fn derive(input: DeriveInput) -> Result { let name = &input.ident; let (_, ty_generics, _) = input.generics.split_for_impl(); - if record.sources.is_empty() { + if record.plaintexts.is_empty() { // One impl, generic over the source: the record accepts exactly the // sources every derived field accepts, which the where clause spells // out so a mismatch is reported against the field type. @@ -47,7 +47,7 @@ pub(crate) fn derive(input: DeriveInput) -> Result { // One impl per listed source. Fields derived from the whole source get a // where clause as above; `from = ..` fields reach into the source, so // their obligations are checked in the body against the actual field. - let impls = record.sources.iter().map(|source| { + let impls = record.plaintexts.iter().map(|source| { let mut generics = input.generics.clone(); generics.params.push(parse_quote!(__K)); push_field_bounds(&mut generics, krate, &record, source); @@ -198,10 +198,10 @@ mod tests { #[rustfmt::skip] fn listed_sources_get_one_impl_each() { let expansion = expand(parse_quote! { - #[encrypted(source = i32, source = i64)] + #[stack_encrypt(plaintext = i32, plaintext = i64)] struct IntegerOrdOre { c: StackCipherText, - #[encrypted(default = SchemaVersion::V3)] + #[stack_encrypt(default = SchemaVersion::V3)] v: SchemaVersion, } }); @@ -219,11 +219,11 @@ mod tests { #[rustfmt::skip] fn row_fields_reach_into_the_source_under_their_own_context() { let expansion = expand(parse_quote! { - #[encrypted(source = User)] + #[stack_encrypt(plaintext = User)] struct EncryptedUser { - #[encrypted(from = age, context = "users/age")] + #[stack_encrypt(from = age, context = "users/age")] age: EncryptedAge, - #[encrypted(from = email, context = "users/email")] + #[stack_encrypt(from = email, context = "users/email")] email: StackCipherText, } }); diff --git a/packages/stack-encrypt-derive/src/lib.rs b/packages/stack-encrypt-derive/src/lib.rs index 413a18419..44936bd6d 100644 --- a/packages/stack-encrypt-derive/src/lib.rs +++ b/packages/stack-encrypt-derive/src/lib.rs @@ -1,5 +1,5 @@ //! Derive macros for [`stack-encrypt`](https://docs.rs/stack-encrypt)'s -//! target-directed encryption: `EncryptFrom` and `DecryptFrom` for composite +//! target-directed encryption: `EncryptFrom` and `DecryptInto` for composite //! records. //! //! Both macros are re-exported from `stack_encrypt`, so depend on that crate @@ -14,13 +14,13 @@ //! //! ```ignore //! use stack_encrypt::sem::{EqualityTerm, OreTerm}; -//! use stack_encrypt::{DecryptFrom, Encrypted, StackCipherText}; +//! use stack_encrypt::{DecryptInto, EncryptFrom, StackCipherText}; //! //! /// An encrypted integer, queryable by equality and range. -//! #[derive(Encrypted, DecryptFrom)] -//! #[encrypted(source = u32)] +//! #[derive(EncryptFrom, DecryptInto)] +//! #[stack_encrypt(plaintext = u32)] //! struct EncryptedAge { -//! #[encrypted(decrypt)] +//! #[stack_encrypt(decrypt)] //! c: StackCipherText, //! hm: EqualityTerm, //! ob: OreTerm, @@ -41,7 +41,7 @@ //! # Rows //! //! One level up, the same derive: a struct whose fields are each derived from -//! a *field* of the source, under a context of their own. +//! a *field* of the plaintext, under a context of their own. //! //! ```ignore //! struct User { @@ -49,12 +49,12 @@ //! email: String, //! } //! -//! #[derive(Encrypted, DecryptFrom)] -//! #[encrypted(source = User)] +//! #[derive(EncryptFrom, DecryptInto)] +//! #[stack_encrypt(plaintext = User)] //! struct EncryptedUser { -//! #[encrypted(from = age, context = "users/age", decrypt)] +//! #[stack_encrypt(from = age, context = "users/age", decrypt)] //! age: EncryptedAge, -//! #[encrypted(from = email, context = "users/email", decrypt)] +//! #[stack_encrypt(from = email, context = "users/email", decrypt)] //! email: StackCipherText, //! } //! @@ -76,9 +76,9 @@ //! combinators on that trait and can replace this one without changing the //! attribute surface. //! -//! Field-by-field decryption rebuilds the source with a struct literal, so -//! every field of the source must be recovered by some `decrypt` field, and -//! the source must be a struct visible where the derive expands. +//! Field-by-field decryption rebuilds the plaintext with a struct literal, so +//! every field of the plaintext must be recovered by some `decrypt` field, and +//! the plaintext must be a struct visible where the derive expands. //! //! # Enums //! @@ -117,20 +117,20 @@ mod test_support; /// documentation](crate) for what the derive emits; the attributes it accepts /// are reproduced below. #[doc = include_str!("../docs/attributes.md")] -#[proc_macro_derive(Encrypted, attributes(encrypted))] -pub fn derive_encrypted(input: TokenStream) -> TokenStream { +#[proc_macro_derive(EncryptFrom, attributes(stack_encrypt))] +pub fn derive_encrypt_from(input: TokenStream) -> TokenStream { let input = parse_macro_input!(input as DeriveInput); encrypt::derive(input) .unwrap_or_else(syn::Error::into_compile_error) .into() } -/// Derive `DecryptFrom` for the record's `source` type(s). See -/// the [crate documentation](crate); the attributes it accepts are reproduced -/// below. +/// Derive `DecryptInto` for a record struct, one impl per +/// `plaintext` type. See the [crate documentation](crate); the attributes it +/// accepts are reproduced below. #[doc = include_str!("../docs/attributes.md")] -#[proc_macro_derive(DecryptFrom, attributes(encrypted))] -pub fn derive_decrypt_from(input: TokenStream) -> TokenStream { +#[proc_macro_derive(DecryptInto, attributes(stack_encrypt))] +pub fn derive_decrypt_into(input: TokenStream) -> TokenStream { let input = parse_macro_input!(input as DeriveInput); decrypt::derive(input) .unwrap_or_else(syn::Error::into_compile_error) diff --git a/packages/stack-encrypt-derive/src/shape.rs b/packages/stack-encrypt-derive/src/shape.rs index 5bda51532..a6a8684ff 100644 --- a/packages/stack-encrypt-derive/src/shape.rs +++ b/packages/stack-encrypt-derive/src/shape.rs @@ -15,7 +15,7 @@ pub(crate) struct Field { pub(crate) local: Ident, pub(crate) ty: Type, pub(crate) kind: Kind, - /// `#[encrypted(decrypt)]`: decryption opens this field. + /// `#[stack_encrypt(decrypt)]`: decryption opens this field. pub(crate) decrypt: bool, } @@ -24,11 +24,11 @@ pub(crate) struct Field { pub(crate) enum Kind { /// Derived from the source through the field type's own `EncryptFrom`. Derived { - /// `#[encrypted(context = "...")]`: this field's context, overriding + /// `#[stack_encrypt(context = "...")]`: this field's context, overriding /// the record's. context: Option, - /// `#[encrypted(from = field)]`: derived from one field of the source - /// rather than the whole source. + /// `#[stack_encrypt(from = field)]`: derived from one field of the + /// plaintext rather than the whole plaintext. from: Option, }, /// Not derived: `Default::default()` or the given expression. @@ -61,9 +61,9 @@ impl Field { #[cfg_attr(test, derive(Debug))] pub(crate) struct Record { pub(crate) krate: Path, - /// The source types, one impl each; empty means one impl generic over - /// the source. - pub(crate) sources: Vec, + /// The plaintext types, one impl each; empty means one impl generic over + /// the plaintext. + pub(crate) plaintexts: Vec, pub(crate) fields: Vec, } @@ -75,7 +75,7 @@ impl Record { Data::Struct(data) => data, Data::Enum(_) => return Err(syn::Error::new_spanned( &input.ident, - "Encrypted/DecryptFrom cannot be derived for enums: a record is a fixed set of \ + "EncryptFrom/DecryptInto cannot be derived for enums: a record is a fixed set of \ fields derived from one source, and a variant choice has no field to be \ derived into. Model the choice explicitly instead, e.g. as a struct of \ `Option` fields.", @@ -83,7 +83,7 @@ impl Record { Data::Union(_) => { return Err(syn::Error::new_spanned( &input.ident, - "Encrypted/DecryptFrom cannot be derived for unions", + "EncryptFrom/DecryptInto cannot be derived for unions", )) } }; @@ -97,19 +97,19 @@ impl Record { )); } - if attrs.sources.is_empty() { + if attrs.plaintexts.is_empty() { if let Some(field) = fields.iter().find(|f| f.from().is_some()) { return Err(syn::Error::new( field.from().map_or_else(Span::call_site, Ident::span), - "`from = ..` reaches into a field of the source, so the source type must be \ - named: add `#[encrypted(source = ..)]` to the struct", + "`from = ..` reaches into a field of the plaintext, so the plaintext type must \ + be named: add `#[stack_encrypt(plaintext = ..)]` to the struct", )); } } Ok(Self { krate: attrs.krate, - sources: attrs.sources, + plaintexts: attrs.plaintexts, fields, }) } @@ -174,7 +174,7 @@ mod tests { fn all_default_is_rejected() { let err = parse(parse_quote! { struct Empty { - #[encrypted(default)] + #[stack_encrypt(default)] v: u8, } }) @@ -183,15 +183,15 @@ mod tests { } #[test] - fn from_needs_a_named_source() { + fn from_needs_a_named_plaintext() { let err = parse(parse_quote! { struct Row { - #[encrypted(from = age)] + #[stack_encrypt(from = age)] age: EncryptedAge, } }) .unwrap_err(); - assert!(err.to_string().contains("source type must be named")); + assert!(err.to_string().contains("plaintext type must be named")); } #[test] @@ -199,7 +199,7 @@ mod tests { let err = parse(parse_quote! { struct Rec { c: StackCipherText, - #[encrypted(default, context = "x")] + #[stack_encrypt(default, context = "x")] v: u8, } }) @@ -211,7 +211,7 @@ mod tests { fn unknown_attributes_are_rejected() { let err = parse(parse_quote! { struct Rec { - #[encrypted(rename = "x")] + #[stack_encrypt(rename = "x")] c: StackCipherText, } }) @@ -219,7 +219,7 @@ mod tests { assert!(err.to_string().contains("unsupported field attribute")); let err = parse(parse_quote! { - #[encrypted(sources = i32)] + #[stack_encrypt(source = i32)] struct Rec { c: StackCipherText, } @@ -231,17 +231,17 @@ mod tests { #[test] fn fields_classify() { let record = parse(parse_quote! { - #[encrypted(source = User, source = Admin)] + #[stack_encrypt(plaintext = User, plaintext = Admin)] struct Row { - #[encrypted(from = age, context = "users/age", decrypt)] + #[stack_encrypt(from = age, context = "users/age", decrypt)] age: EncryptedAge, whole: RowTerm, - #[encrypted(default = SchemaVersion::V3)] + #[stack_encrypt(default = SchemaVersion::V3)] v: SchemaVersion, } }) .unwrap(); - assert_eq!(record.sources.len(), 2); + assert_eq!(record.plaintexts.len(), 2); assert_eq!(record.fields.len(), 3); assert_eq!(record.fields[0].from().unwrap(), "age"); assert_eq!(record.fields[0].context().unwrap().value(), "users/age"); diff --git a/packages/stack-encrypt/Cargo.toml b/packages/stack-encrypt/Cargo.toml index 23f4121f6..827369528 100644 --- a/packages/stack-encrypt/Cargo.toml +++ b/packages/stack-encrypt/Cargo.toml @@ -23,7 +23,7 @@ stack-kms = { path = "../stack-kms", default-features = false, features = ["prof # `StackCipher::new()` builds a ZeroKMS client from the environment, so its # return type names the auto-detected auth strategy. stack-auth = { workspace = true } -# `#[derive(Encrypted)]` / `#[derive(DecryptFrom)]`, re-exported from `target`. +# `#[derive(EncryptFrom)]` / `#[derive(DecryptInto)]`, re-exported from `target`. stack-encrypt-derive = { path = "../stack-encrypt-derive" } # The revised Cipher/Decipher traits, ContextTag, Element, and the PRF crates diff --git a/packages/stack-encrypt/examples/encrypted_record.rs b/packages/stack-encrypt/examples/encrypted_record.rs index 31cad0134..1cf3157f1 100644 --- a/packages/stack-encrypt/examples/encrypted_record.rs +++ b/packages/stack-encrypt/examples/encrypted_record.rs @@ -2,7 +2,7 @@ //! //! The point of target-directed encryption: define a record type that *is* //! "the ciphertext plus the index terms this field needs", implement -//! `EncryptFrom` once (the shape a future `#[derive(Encrypted)]` will +//! `EncryptFrom` once (the shape `#[derive(EncryptFrom)]` would //! emit), and every insert is one `encrypt_into(..).await`. A tiny in-memory //! "table" then answers equality and range queries purely by comparing terms //! — decrypting only the rows that match. @@ -27,7 +27,7 @@ use stack_encrypt::sem::{EqualityTerm, OreTerm}; use stack_encrypt::target::{ - DecryptContext, DecryptFrom, DecryptInto, EncryptContext, EncryptFrom, EncryptInto, Pending, + DecryptContext, DecryptInto, EncryptContext, EncryptFrom, EncryptInto, Pending, }; use stack_encrypt::{StackCipher, StackCipherText}; @@ -67,21 +67,21 @@ impl EncryptFrom> for EncryptedInt { } } -// The decrypt mirror the derive will also write: only the ciphertext field -// participates (terms are one-way), so it delegates to the ciphertext's own -// implementation. -impl DecryptFrom> for u32 { - fn decrypt_from<'a, 'c, Ctx>( - source: EncryptedInt, +// The decrypt mirror `#[derive(DecryptInto)]` would write: the record owns +// its opening, and only the ciphertext field participates (terms are +// one-way), so it delegates to the ciphertext's own implementation. +impl DecryptInto> for EncryptedInt { + fn decrypt_into<'a, 'c, Ctx>( + self, cipher: &'a StackCipher, context: Ctx, - ) -> Pending<'a, Self, K> + ) -> Pending<'a, u32, K> where Ctx: DecryptContext<'c>, - EncryptedInt: 'a, Self: 'a, + u32: 'a, { - source.ciphertext.decrypt_into(cipher, context) + self.ciphertext.decrypt_into(cipher, context) } } diff --git a/packages/stack-encrypt/src/cipher.rs b/packages/stack-encrypt/src/cipher.rs index aa71508a3..f91bd1fad 100644 --- a/packages/stack-encrypt/src/cipher.rs +++ b/packages/stack-encrypt/src/cipher.rs @@ -124,14 +124,14 @@ pub enum Error { #[error(transparent)] Term(crate::sem::TermError), /// A third-party [`EncryptFrom`](crate::target::EncryptFrom) / - /// [`DecryptFrom`](crate::target::DecryptFrom) implementation failed + /// [`DecryptInto`](crate::target::DecryptInto) implementation failed /// for a reason of its own. #[error(transparent)] Other(Box), /// A [`Pending`](crate::target::Pending) fulfilment's requests and /// responses did not line up: it drew more responses — or a different /// kind — than its requests asked for, or left some of them unconsumed. - /// Always a composition bug in an `EncryptFrom`/`DecryptFrom` + /// Always a composition bug in an `EncryptFrom`/`DecryptInto` /// implementation, never a data error. #[error("a pending fulfilment's responses did not match its requests")] ResponseShape, diff --git a/packages/stack-encrypt/src/lib.rs b/packages/stack-encrypt/src/lib.rs index 3ca350f60..31530fbea 100644 --- a/packages/stack-encrypt/src/lib.rs +++ b/packages/stack-encrypt/src/lib.rs @@ -127,7 +127,7 @@ pub use cipher::{ }; pub use target::{ DecryptContext, DecryptFrom, DecryptInto, DecryptTarget, EncryptContext, EncryptFrom, - EncryptInto, EncryptTarget, Encrypted, Pending, PendingFuture, Request, Responses, + EncryptInto, EncryptTarget, Pending, PendingFuture, Request, Responses, }; // Re-export the vitaminc AEAD surface callers need to drive the cipher, so they diff --git a/packages/stack-encrypt/src/target/mod.rs b/packages/stack-encrypt/src/target/mod.rs index 7c101c33b..a4e835994 100644 --- a/packages/stack-encrypt/src/target/mod.rs +++ b/packages/stack-encrypt/src/target/mod.rs @@ -21,17 +21,19 @@ //! vitaminc's [`Encrypt`]) and for the SEM term types in [`sem`] //! ([`EqualityTerm`], [`MatchTerm`], [`OreTerm`], [`OpeTerm`]). Composite //! record types — a struct of leaves, or a row of records — get theirs from -//! [`#[derive(Encrypted)]`](Encrypted), which combines the fields' pendings -//! with [`Pending::zip`] / [`Pending::map`] exactly as a hand-written impl -//! would. -//! * [`DecryptFrom`] — the mirror, implemented by the *plaintext* -//! type: "`Self` is recoverable from the encrypted `S`". Only ciphertext -//! fields participate — index terms are one-way by construction. -//! [`#[derive(DecryptFrom)]`](macro@DecryptFrom) on the record emits it for the -//! record's named source type(s). -//! * [`EncryptInto::encrypt_into`] / [`DecryptInto::decrypt_into`] — blanket -//! call-site sugar, the `Into` to the `From` above. Never implemented by -//! hand. +//! [`#[derive(EncryptFrom)]`](macro@EncryptFrom), which combines the +//! fields' pendings with [`Pending::zip`] / [`Pending::map`] exactly as a +//! hand-written impl would. +//! * [`DecryptInto`] — the mirror, implemented by the *encrypted* type: +//! "`Self` decrypts to the plaintext `P`". Only ciphertext fields +//! participate — index terms are one-way by construction. +//! [`#[derive(DecryptInto)]`](macro@DecryptInto) on the record emits it. +//! `Self` is the record in both traits — the output of encryption, the +//! input of decryption — because that is the type a downstream crate can +//! implement on; the halves whose `Self` is the plaintext are blanket: +//! * [`EncryptInto::encrypt_into`] / [`DecryptFrom::decrypt_from`] — call-site +//! sugar, the `Into` to `EncryptFrom` and the `From` to `DecryptInto`. +//! Never implemented by hand. //! * [`EncryptTarget`] / [`DecryptTarget`] — implemented by ciphers; their //! `Output` type decides what a call site gets back. A synchronous cipher //! returns `Result` directly; [`StackCipher`] returns a [`Pending`], @@ -157,7 +159,7 @@ //! different construction; the block scheme lands as a third-party term type //! in `eql-bindings`, built with exactly the recipe above. //! -//! # Records and rows: `#[derive(Encrypted)]` +//! # Records and rows: `#[derive(EncryptFrom)]` //! //! A struct of leaves is a *record*; a struct of records, each derived from //! one field of the source under its own column context, is a *row*. Both @@ -165,15 +167,15 @@ //! //! ``` //! use stack_encrypt::sem::{EqualityTerm, OreTerm}; -//! use stack_encrypt::target::{DecryptInto, EncryptInto}; -//! use stack_encrypt::{DecryptFrom, Encrypted, StackCipher, StackCipherText}; +//! use stack_encrypt::target::EncryptInto; +//! use stack_encrypt::{DecryptInto, EncryptFrom, StackCipher, StackCipherText}; //! use stack_kms::FakeDataKeySource; //! //! /// An encrypted `u32`, queryable by equality and range. -//! #[derive(Encrypted, DecryptFrom)] -//! #[encrypted(source = u32)] +//! #[derive(EncryptFrom, DecryptInto)] +//! #[stack_encrypt(plaintext = u32)] //! struct EncryptedAge { -//! #[encrypted(decrypt)] +//! #[stack_encrypt(decrypt)] //! c: StackCipherText, //! hm: EqualityTerm, //! ob: OreTerm, @@ -185,12 +187,12 @@ //! email: String, //! } //! -//! #[derive(Encrypted, DecryptFrom)] -//! #[encrypted(source = User)] +//! #[derive(EncryptFrom, DecryptInto)] +//! #[stack_encrypt(plaintext = User)] //! struct EncryptedUser { -//! #[encrypted(from = age, context = "users/age", decrypt)] +//! #[stack_encrypt(from = age, context = "users/age", decrypt)] //! age: EncryptedAge, -//! #[encrypted(from = email, context = "users/email", decrypt)] +//! #[stack_encrypt(from = email, context = "users/email", decrypt)] //! email: StackCipherText, //! } //! @@ -215,7 +217,7 @@ //! ``` //! //! The attributes, and what the derive commits to, are documented on -//! [`Encrypted`]. +//! [`EncryptFrom`](macro@EncryptFrom). //! //! # Plaintext fan-out //! @@ -244,7 +246,7 @@ mod request; pub use pending::{Pending, PendingFuture}; pub use request::{Request, Responses}; -pub use stack_encrypt_derive::{DecryptFrom, Encrypted}; +pub use stack_encrypt_derive::{DecryptInto, EncryptFrom}; // ============================================================================= // Contexts @@ -266,8 +268,8 @@ pub use stack_encrypt_derive::{DecryptFrom, Encrypted}; /// build, before any I/O. Containers (`Vec`, `Option`) and derived records /// pass the context through to their elements and fields untouched, so a /// container of records whose fields carry their own contexts — a -/// [`Encrypted`]-derived row — is given `()`, and an empty context still -/// fails the moment a value reaches a leaf. +/// [`#[derive(EncryptFrom)]`](macro@EncryptFrom) row — is given `()`, and an +/// empty context still fails the moment a value reaches a leaf. /// /// "Empty" means *carrying no caller-supplied information*, not merely zero /// bytes: `()`, `""`, `b""`, `None`, `Some("")` and `("", "")` all encode to @@ -517,19 +519,26 @@ pub trait EncryptFrom: Sized { Self: 'a; } -/// `Self` is recoverable from the encrypted `S` by a cipher `C` — the mirror -/// of [`EncryptFrom`], implemented on the *plaintext* type. +/// `Self` is an encrypted representation that decrypts to `P` by a cipher +/// `C` — the mirror of [`EncryptFrom`], implemented on the *encrypted* type. +/// +/// Both traits put `Self` on the type that is local to the crate defining +/// the record: it is the *output* of encryption (`EncryptFrom`) and the +/// *input* of decryption (`DecryptInto`). One encrypted type may decrypt to +/// several plaintext types (`impl DecryptInto` and +/// `impl DecryptInto` coexist), and several encrypted types may +/// decrypt to the same plaintext — each owns its own opening. /// -/// Takes the source by value: decryption consumes the ciphertext, and index +/// Takes `self` by value: decryption consumes the ciphertext, and index /// terms (which have no plaintext to recover) simply do not participate. -pub trait DecryptFrom: Sized { - /// Decrypt `source` into `Self`, authenticating against `context` — which +pub trait DecryptInto: Sized { + /// Decrypt `self` into `P`, authenticating against `context` — which /// must match the context the value was encrypted under. - fn decrypt_from<'a, 'c, Ctx>(source: S, cipher: &'a C, context: Ctx) -> C::Output<'a, Self> + fn decrypt_into<'a, 'c, Ctx>(self, cipher: &'a C, context: Ctx) -> C::Output<'a, P> where Ctx: DecryptContext<'c>, - S: 'a, - Self: 'a; + Self: 'a, + P: 'a; } /// Call-site sugar: `value.encrypt_into::(&cipher, context)`. @@ -576,31 +585,35 @@ impl EncryptInto for S { } } -/// Call-site sugar: `encrypted.decrypt_into::(&cipher, context)` — the -/// decrypt-side [`EncryptInto`]. Blanket-implemented; never implemented by -/// hand. -pub trait DecryptInto: Sized { - /// Decrypt `self` into `T`, authenticating against `context`. See - /// [`DecryptFrom`]. - fn decrypt_into<'a, 'c, T, C, Ctx>(self, cipher: &'a C, context: Ctx) -> C::Output<'a, T> +/// Call-site sugar: `Plaintext::decrypt_from(encrypted, &cipher, context)` — +/// the `From` to [`DecryptInto`]'s `Into`, and the decrypt-side +/// [`EncryptInto`]. Blanket-implemented for every plaintext; never +/// implemented by hand. +pub trait DecryptFrom: Sized { + /// Decrypt `source` into `Self`, authenticating against `context`. See + /// [`DecryptInto`]. + fn decrypt_from<'a, 'c, Ctx>(source: S, cipher: &'a C, context: Ctx) -> C::Output<'a, Self> where - C: DecryptTarget, - T: DecryptFrom + 'a, Ctx: DecryptContext<'c>, + S: 'a, Self: 'a; } -impl DecryptInto for S { - fn decrypt_into<'a, 'c, T, C, Ctx>(self, cipher: &'a C, context: Ctx) -> C::Output<'a, T> +impl DecryptFrom for P +where + C: DecryptTarget, + S: DecryptInto, +{ + fn decrypt_from<'a, 'c, Ctx>(source: S, cipher: &'a C, context: Ctx) -> C::Output<'a, Self> where - C: DecryptTarget, - T: DecryptFrom + 'a, Ctx: DecryptContext<'c>, + S: 'a, Self: 'a, { - T::decrypt_from(self, cipher, context) + source.decrypt_into(cipher, context) } } + // ============================================================================= // Leaf implementations: the record ciphertext // ============================================================================= @@ -699,19 +712,19 @@ fn decipher_from_responses( /// (`iv` + `tag` are lifted out of the tree during the synchronous build); /// the fulfilment binds the retrieved keys back onto the leaves and lets the /// value's `Decrypt` impl drive the opening. -impl DecryptFrom> for T +impl DecryptInto> for StackCipherText where T: Decrypt<'static> + 'static, { - fn decrypt_from<'a, 'c, Ctx>( - source: StackCipherText, + fn decrypt_into<'a, 'c, Ctx>( + self, cipher: &'a StackCipher, context: Ctx, ) -> Pending<'a, T, K> where Ctx: DecryptContext<'c>, - StackCipherText: 'a, Self: 'a, + T: 'a, { let aad = context.into_aad().into_owned(); // Symmetric with the encrypt side: the target layer never encrypts @@ -719,9 +732,9 @@ where if is_degenerate_aad(aad.as_bytes()) { return Pending::failed(cipher, Error::EmptyContext); } - let requests = retrieve_requests(&source); + let requests = retrieve_requests(&self); Pending::request(cipher, requests, move |responses| { - let decipher = decipher_from_responses(source, responses)?; + let decipher = decipher_from_responses(self, responses)?; T::decrypt_with_aad(decipher, aad).map_err(Error::from) }) } @@ -792,23 +805,23 @@ where } /// The column decrypt mirror: one batched retrieve for every row. -impl DecryptFrom, StackCipher> for Vec +impl DecryptInto, StackCipher> for Vec where - T: DecryptFrom>, + S: DecryptInto>, { - fn decrypt_from<'a, 'c, Ctx>( - source: Vec, + fn decrypt_into<'a, 'c, Ctx>( + self, cipher: &'a StackCipher, context: Ctx, - ) -> Pending<'a, Self, K> + ) -> Pending<'a, Vec, K> where Ctx: DecryptContext<'c>, - S: 'a, Self: 'a, + Vec: 'a, { - let items = source + let items = self .into_iter() - .map(|item| T::decrypt_from(item, cipher, context.clone())) + .map(|item| item.decrypt_into(cipher, context.clone())) .collect(); Pending::all(cipher, items) } @@ -841,22 +854,23 @@ where } /// The optional decrypt mirror of the [`Option`] encrypt implementation. -impl DecryptFrom, StackCipher> for Option +impl DecryptInto, StackCipher> for Option where - T: DecryptFrom> + MaybeSend, + S: DecryptInto>, + T: MaybeSend, { - fn decrypt_from<'a, 'c, Ctx>( - source: Option, + fn decrypt_into<'a, 'c, Ctx>( + self, cipher: &'a StackCipher, context: Ctx, - ) -> Pending<'a, Self, K> + ) -> Pending<'a, Option, K> where Ctx: DecryptContext<'c>, - S: 'a, Self: 'a, + Option: 'a, { - match source { - Some(value) => T::decrypt_from(value, cipher, context).map(Some), + match self { + Some(value) => value.decrypt_into(cipher, context).map(Some), None => Pending::ready(cipher, Ok(None)), } } diff --git a/packages/stack-encrypt/src/target/pending.rs b/packages/stack-encrypt/src/target/pending.rs index faf9c6a82..1e8a4e452 100644 --- a/packages/stack-encrypt/src/target/pending.rs +++ b/packages/stack-encrypt/src/target/pending.rs @@ -1,5 +1,5 @@ //! [`Pending`]: the request carrier [`StackCipher`] hands back from -//! [`EncryptFrom`](super::EncryptFrom) / [`DecryptFrom`](super::DecryptFrom), +//! [`EncryptFrom`](super::EncryptFrom) / [`DecryptInto`](super::DecryptInto), //! and the single place the target layer talks to ZeroKMS. //! //! A `Pending` is built synchronously and settled once. Combining pendings diff --git a/packages/stack-encrypt/tests/derive.rs b/packages/stack-encrypt/tests/derive.rs index a6c2dc03c..73265a059 100644 --- a/packages/stack-encrypt/tests/derive.rs +++ b/packages/stack-encrypt/tests/derive.rs @@ -1,4 +1,4 @@ -//! `#[derive(Encrypted)]` / `#[derive(DecryptFrom)]`: the derived impls are the +//! `#[derive(EncryptFrom)]` / `#[derive(DecryptInto)]`: the derived impls are the //! hand-written composite in `target.rs`, emitted — same terms, same decrypt //! mirror, same one-batched-call settlement — plus what only a derive makes //! cheap: sources listed or left generic, rows derived field by field, and @@ -11,17 +11,17 @@ use std::sync::atomic::Ordering as AtomicOrdering; use cllw_ore::CllwOreEncrypt; use common::{counting_cipher, stack_cipher}; use stack_encrypt::sem::{EqualityTerm, MatchTerm, OreTerm}; -use stack_encrypt::target::{DecryptInto, EncryptInto}; -use stack_encrypt::{DecryptFrom, Encrypted, Error, StackCipherText}; +use stack_encrypt::target::EncryptInto; +use stack_encrypt::{DecryptInto, EncryptFrom, Error, StackCipherText}; -// --- Records: every field from one source, under one context ---------------- +// --- Records: every field from one plaintext, under one context ------------- /// The hand-written record in `target.rs`, derived: an encrypted `u32` /// stored as its ciphertext plus an equality term and an ORE term. -#[derive(Encrypted, DecryptFrom)] -#[encrypted(source = u32)] +#[derive(EncryptFrom, DecryptInto)] +#[stack_encrypt(plaintext = u32)] struct EncryptedAge { - #[encrypted(decrypt)] + #[stack_encrypt(decrypt)] c: StackCipherText, hm: EqualityTerm, ob: OreTerm, @@ -46,30 +46,32 @@ async fn a_derived_record_is_the_hand_written_one() { assert_eq!(age, 42); } -/// No `source`: one impl generic over it, accepting whatever every leaf -/// accepts — here any text type, since `MatchTerm` wants `AsRef`. -#[derive(Encrypted)] +/// No `plaintext`: one impl generic over it, accepting whatever every leaf +/// accepts — here any text type, since `MatchTerm` wants `AsRef` — and +/// decrypting to whatever the ciphertext field opens to. +#[derive(EncryptFrom, DecryptInto)] struct SearchableText { + #[stack_encrypt(decrypt)] c: StackCipherText, hm: EqualityTerm, m: MatchTerm, } /// Tuple structs assign by index. -#[derive(Encrypted)] +#[derive(EncryptFrom)] struct Pair(StackCipherText, EqualityTerm); /// The record's own generics (and their bounds) are carried through, and the /// where clause makes `Tagged` accept exactly `T` — the ORE term is typed /// by its source. -#[derive(Encrypted)] +#[derive(EncryptFrom)] struct Tagged { c: StackCipherText, ob: OreTerm, } #[tokio::test] -async fn a_generic_source_record_accepts_what_its_leaves_accept() { +async fn a_generic_plaintext_record_accepts_what_its_leaves_accept() { let cipher = stack_cipher().await; let generator = stack_cipher().await; @@ -90,7 +92,7 @@ async fn a_generic_source_record_accepts_what_its_leaves_accept() { .unwrap(); assert_eq!(record.hm, hm); assert_eq!(record.m, m); - let name: String = record.c.decrypt_into(&cipher, "users/name").await.unwrap(); + let name: String = record.decrypt_into(&cipher, "users/name").await.unwrap(); assert_eq!(name, "alice"); let pair: Pair = "bob".encrypt_into(&cipher, "users/name").await.unwrap(); @@ -106,17 +108,17 @@ async fn a_generic_source_record_accepts_what_its_leaves_accept() { assert_eq!(score, 7); } -/// Listed sources: one impl each, and nothing else is accepted. -#[derive(Encrypted, DecryptFrom)] -#[encrypted(source = u32, source = String)] +/// Listed plaintexts: one impl each, and nothing else is accepted. +#[derive(EncryptFrom, DecryptInto)] +#[stack_encrypt(plaintext = u32, plaintext = String)] struct EncryptedValue { - #[encrypted(decrypt)] + #[stack_encrypt(decrypt)] c: StackCipherText, hm: EqualityTerm, } #[tokio::test] -async fn listed_sources_each_get_their_own_impl() { +async fn listed_plaintexts_each_get_their_own_impl() { let cipher = stack_cipher().await; let number: EncryptedValue = 7u32.encrypt_into(&cipher, "t/n").await.unwrap(); @@ -145,7 +147,7 @@ async fn a_failed_field_fails_the_derived_record_before_any_io() { assert_eq!(generates.load(AtomicOrdering::SeqCst), 0); } -// --- Rows: each field from one field of the source, under its own context --- +// --- Rows: each field from one field of the plaintext, under its own context #[derive(Debug, Clone, PartialEq, Eq)] struct User { @@ -153,20 +155,20 @@ struct User { email: String, } -#[derive(Encrypted, DecryptFrom)] -#[encrypted(source = User)] +#[derive(EncryptFrom, DecryptInto)] +#[stack_encrypt(plaintext = User)] struct EncryptedUser { /// A record inside a row: recursion, not a second mechanism. - #[encrypted(from = age, context = "users/age", decrypt)] + #[stack_encrypt(from = age, context = "users/age", decrypt)] age: EncryptedAge, - #[encrypted(from = email, context = "users/email", decrypt)] + #[stack_encrypt(from = email, context = "users/email", decrypt)] email: StackCipherText, - /// A second field from the same source field — a term alongside the + /// A second field from the same plaintext field — a term alongside the /// ciphertext, not opened on decrypt. - #[encrypted(from = email, context = "users/email")] + #[stack_encrypt(from = email, context = "users/email")] email_eq: EqualityTerm, /// Not derived: filled in, never encrypted. - #[encrypted(default = 3)] + #[stack_encrypt(default = 3)] version: u8, } @@ -178,7 +180,7 @@ fn user() -> User { } #[tokio::test] -async fn a_row_is_one_batched_call_and_rebuilds_its_source() { +async fn a_row_is_one_batched_call_and_rebuilds_its_plaintext() { let (cipher, generates, retrieves) = counting_cipher().await; let generator = stack_cipher().await; @@ -202,7 +204,7 @@ async fn a_row_is_one_batched_call_and_rebuilds_its_source() { .unwrap(); assert_eq!(row.email_eq, email_hm); - // Decryption rebuilds the source field by field: one batched call. + // Decryption rebuilds the plaintext field by field: one batched call. let recovered: User = row.decrypt_into(&cipher, ()).await.unwrap(); assert_eq!(recovered, user()); assert_eq!( diff --git a/packages/stack-encrypt/tests/target.rs b/packages/stack-encrypt/tests/target.rs index ff11241d5..9874db98c 100644 --- a/packages/stack-encrypt/tests/target.rs +++ b/packages/stack-encrypt/tests/target.rs @@ -1,4 +1,4 @@ -//! Target-directed encryption tests: leaf `EncryptFrom`/`DecryptFrom` +//! Target-directed encryption tests: leaf `EncryptFrom`/`DecryptInto` //! implementations, a hand-written composite record (the shape a future //! derive will emit), a "third-party" term type built on the public extension //! surface only, and — the point of the design — proof that however large the @@ -9,8 +9,7 @@ use std::sync::atomic::Ordering as AtomicOrdering; use stack_encrypt::sem::{EqualityTerm, MatchConfig, MatchOptions, MatchTerm, OreTerm}; use stack_encrypt::target::{ - DecryptContext, DecryptFrom, DecryptInto, EncryptContext, EncryptFrom, EncryptInto, Pending, - Request, + DecryptContext, DecryptInto, EncryptContext, EncryptFrom, EncryptInto, Pending, Request, }; use stack_encrypt::{Error, StackCipher, StackCipherText}; use stack_kms::{FakeDataKeySource, IdentifiedBy, IndexKeySource}; @@ -280,7 +279,7 @@ async fn optional_fields_encrypt_and_decrypt_structurally() { // --- A hand-written composite record ---------------------------------------- // -// The shape a `#[derive(Encrypted)]` will emit: one impl, pendings combined +// The shape a `#[derive(EncryptFrom)]` will emit: one impl, pendings combined // with zip/map (never awaited), one context fanning out to every field, the // caller seeing a single await — and a single batched call. @@ -311,18 +310,18 @@ impl EncryptFrom> for EncryptedAge { /// The decrypt mirror a derive would emit: only the ciphertext field /// participates — terms are one-way. -impl DecryptFrom> for u32 { - fn decrypt_from<'a, 'c, Ctx>( - source: EncryptedAge, +impl DecryptInto> for EncryptedAge { + fn decrypt_into<'a, 'c, Ctx>( + self, cipher: &'a StackCipher, context: Ctx, - ) -> Pending<'a, Self, K> + ) -> Pending<'a, u32, K> where Ctx: DecryptContext<'c>, - EncryptedAge: 'a, Self: 'a, + u32: 'a, { - source.c.decrypt_into(cipher, context) + self.c.decrypt_into(cipher, context) } } From b91bb19064de5589c3aa2c1aedd31fe09dfd502f Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Fri, 28 Aug 2026 15:17:41 +1000 Subject: [PATCH 456/686] fix(stack-encrypt): tighten derive diagnostics, add compile-fail tests, dedupe expansion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-ups on the EncryptFrom/DecryptInto derives: - Reject `context = ""`, a reference `plaintext`, and a repeated `plaintext` at macro time, each spanned at the offending token, instead of surfacing as a runtime EmptyContext, E0637 or E0119 at the derive ident. - Parse `from` as a `syn::Member` so tuple-struct plaintexts are addressable (`from = 0`); the emitted code was already index-agnostic. - Add a trybuild harness (`tests/ui`) pinning every derive diagnostic, including the E0509 a `Drop` record hits; document that restriction. - Make the crate-level examples real doctests (they were ```ignore and missing the `EncryptInto` import), via a dev-dependency back to stack-encrypt. - Hoist the shared context-distribution / zip-chain builder into `shape::zip_fields`, add an `impl_block` helper to encrypt.rs, drop the no-op `let _ = __context;` and the unreachable generic-by-field branch, and move mode classification into `Mode::classify` so rustfmt is clean. - Show a third-party leaf rejecting an empty context in the extension recipe; the container impls intentionally pass context through. - Fix the stale `#[derive(Encrypted)]` reference in RFC 0002 §4.4. Claude-Session: https://claude.ai/code/session_01HU1Bbw4eQp9kEEneXxDcKr --- ...nc-shape-for-target-directed-encryption.md | 2 +- packages/stack-encrypt-derive/Cargo.toml | 8 + .../stack-encrypt-derive/docs/attributes.md | 11 +- packages/stack-encrypt-derive/src/attrs.rs | 46 ++++- packages/stack-encrypt-derive/src/decrypt.rs | 192 ++++++++++-------- packages/stack-encrypt-derive/src/encrypt.rs | 182 ++++++++--------- packages/stack-encrypt-derive/src/lib.rs | 39 +++- packages/stack-encrypt-derive/src/shape.rs | 112 +++++++++- packages/stack-encrypt/Cargo.toml | 2 + packages/stack-encrypt/src/target/mod.rs | 10 +- packages/stack-encrypt/tests/derive.rs | 31 +++ packages/stack-encrypt/tests/target.rs | 18 +- packages/stack-encrypt/tests/ui.rs | 9 + .../tests/ui/decrypt_duplicate_from.rs | 16 ++ .../tests/ui/decrypt_duplicate_from.stderr | 5 + .../tests/ui/decrypt_mixed_modes.rs | 17 ++ .../tests/ui/decrypt_mixed_modes.stderr | 5 + .../tests/ui/decrypt_without_opened_field.rs | 11 + .../ui/decrypt_without_opened_field.stderr | 5 + .../tests/ui/default_with_context.rs | 10 + .../tests/ui/default_with_context.stderr | 5 + .../stack-encrypt/tests/ui/drop_record.rs | 19 ++ .../stack-encrypt/tests/ui/drop_record.stderr | 10 + .../tests/ui/duplicate_plaintext.rs | 9 + .../tests/ui/duplicate_plaintext.stderr | 5 + .../stack-encrypt/tests/ui/empty_context.rs | 14 ++ .../tests/ui/empty_context.stderr | 5 + .../stack-encrypt/tests/ui/enum_record.rs | 9 + .../stack-encrypt/tests/ui/enum_record.stderr | 5 + .../tests/ui/from_without_plaintext.rs | 9 + .../tests/ui/from_without_plaintext.stderr | 5 + .../tests/ui/reference_plaintext.rs | 9 + .../tests/ui/reference_plaintext.stderr | 5 + 33 files changed, 616 insertions(+), 224 deletions(-) create mode 100644 packages/stack-encrypt/tests/ui.rs create mode 100644 packages/stack-encrypt/tests/ui/decrypt_duplicate_from.rs create mode 100644 packages/stack-encrypt/tests/ui/decrypt_duplicate_from.stderr create mode 100644 packages/stack-encrypt/tests/ui/decrypt_mixed_modes.rs create mode 100644 packages/stack-encrypt/tests/ui/decrypt_mixed_modes.stderr create mode 100644 packages/stack-encrypt/tests/ui/decrypt_without_opened_field.rs create mode 100644 packages/stack-encrypt/tests/ui/decrypt_without_opened_field.stderr create mode 100644 packages/stack-encrypt/tests/ui/default_with_context.rs create mode 100644 packages/stack-encrypt/tests/ui/default_with_context.stderr create mode 100644 packages/stack-encrypt/tests/ui/drop_record.rs create mode 100644 packages/stack-encrypt/tests/ui/drop_record.stderr create mode 100644 packages/stack-encrypt/tests/ui/duplicate_plaintext.rs create mode 100644 packages/stack-encrypt/tests/ui/duplicate_plaintext.stderr create mode 100644 packages/stack-encrypt/tests/ui/empty_context.rs create mode 100644 packages/stack-encrypt/tests/ui/empty_context.stderr create mode 100644 packages/stack-encrypt/tests/ui/enum_record.rs create mode 100644 packages/stack-encrypt/tests/ui/enum_record.stderr create mode 100644 packages/stack-encrypt/tests/ui/from_without_plaintext.rs create mode 100644 packages/stack-encrypt/tests/ui/from_without_plaintext.stderr create mode 100644 packages/stack-encrypt/tests/ui/reference_plaintext.rs create mode 100644 packages/stack-encrypt/tests/ui/reference_plaintext.stderr diff --git a/docs/rfcs/0002-async-shape-for-target-directed-encryption.md b/docs/rfcs/0002-async-shape-for-target-directed-encryption.md index d2379a0b8..2e4fa8a53 100644 --- a/docs/rfcs/0002-async-shape-for-target-directed-encryption.md +++ b/docs/rfcs/0002-async-shape-for-target-directed-encryption.md @@ -280,7 +280,7 @@ impl EncryptFrom> for EncryptedInt { No `tokio::try_join!`, no `Box::pin(async move ..)`, no error-conversion where-clauses. This is roughly half the size of the current impl and is -directly emittable by `#[derive(Encrypted)]` (which is what the derive does — +directly emittable by `#[derive(EncryptFrom)]` (which is what the derive does — see §7). Then the missing piece from §2.2: diff --git a/packages/stack-encrypt-derive/Cargo.toml b/packages/stack-encrypt-derive/Cargo.toml index a03b10638..56eaf8627 100644 --- a/packages/stack-encrypt-derive/Cargo.toml +++ b/packages/stack-encrypt-derive/Cargo.toml @@ -20,3 +20,11 @@ proc-macro = true proc-macro2 = "1" quote = "1" syn = { version = "3", features = ["full", "extra-traits"] } + +[dev-dependencies] +# The crate-level examples are real doctests, run against the fake key +# source. A dev-dependency cycle back to `stack-encrypt` is the usual shape +# for a derive crate (cf. serde_derive -> serde). +stack-encrypt = { path = "../stack-encrypt" } +stack-kms = { path = "../stack-kms", features = ["test-support"] } +tokio = { workspace = true, features = ["rt", "macros"] } diff --git a/packages/stack-encrypt-derive/docs/attributes.md b/packages/stack-encrypt-derive/docs/attributes.md index 7d95edeef..0b1c891c5 100644 --- a/packages/stack-encrypt-derive/docs/attributes.md +++ b/packages/stack-encrypt-derive/docs/attributes.md @@ -9,7 +9,8 @@ All attributes live under `#[stack_encrypt(...)]`. | `plaintext = Type` | The record is an encrypted form of `Type`. Repeatable: one impl per listed type. Omit it for an impl generic over the plaintext (see below). | | `crate = "path"` | Where to find `stack_encrypt` in the generated code (default `::stack_encrypt`), for use through a re-export. | -Without `plaintext`, each derive emits one impl generic over the plaintext, +`plaintext` must be an owned type: the generated impl has no lifetime to give +a reference. Without `plaintext`, each derive emits one impl generic over the plaintext, bounded by what the fields accept: `EncryptedAge` below is `EncryptFrom` for any `P` that both `StackCipherText` and `EqualityTerm` accept, and `DecryptInto` for any `P` its `decrypt` field opens to. With @@ -21,11 +22,15 @@ must name it: the plaintext is rebuilt with a struct literal. | Attribute | Effect | |---|---| -| `context = "..."` | Derive this field under exactly this context rather than the one the caller passed for the record. A query-side term built under the same literal matches it. | -| `from = field` | Derive this field from `plaintext.field` rather than from the whole plaintext. Needs `plaintext = ..` on the struct. | +| `context = "..."` | Derive this field under exactly this context rather than the one the caller passed for the record. A query-side term built under the same literal matches it. Must not be empty. | +| `from = field` / `from = 0` | Derive this field from `plaintext.field` (or `plaintext.0` for a tuple struct) rather than from the whole plaintext. Needs `plaintext = ..` on the struct. | | `default` / `default = expr` | Not derived: filled with `Default::default()` or `expr`. Never encrypted, never authenticated. | | `decrypt` | Decryption opens this field (`DecryptInto` only). One field opened as the whole plaintext, or several with `from = ..` rebuilding the plaintext field by field. | The record's own context reaches every derived field that has no `context` of its own; if every field has one, the record's context is unused and the caller may pass `()`. + +`DecryptInto` consumes the record, moving each opened field out of `self`, so +the record must not implement `Drop` (including via `ZeroizeOnDrop`); wrap the +fields that need zeroizing instead. diff --git a/packages/stack-encrypt-derive/src/attrs.rs b/packages/stack-encrypt-derive/src/attrs.rs index c9941a0e6..134699666 100644 --- a/packages/stack-encrypt-derive/src/attrs.rs +++ b/packages/stack-encrypt-derive/src/attrs.rs @@ -1,6 +1,6 @@ //! Parsing of the `#[stack_encrypt(...)]` container and field attributes. -use syn::{Attribute, Expr, Ident, LitStr, Path, Result, Type}; +use syn::{Attribute, Expr, LitStr, Member, Path, Result, Type}; /// Container-level options, from `#[stack_encrypt(...)]` on the struct itself. pub(crate) struct ContainerAttrs { @@ -17,7 +17,7 @@ pub(crate) struct ContainerAttrs { impl ContainerAttrs { pub(crate) fn parse(attrs: &[Attribute]) -> Result { let mut krate: Option = None; - let mut plaintexts = Vec::new(); + let mut plaintexts: Vec = Vec::new(); for attr in attrs.iter().filter(|a| a.path().is_ident("stack_encrypt")) { attr.parse_nested_meta(|meta| { @@ -27,7 +27,27 @@ impl ContainerAttrs { return Ok(()); } if meta.path.is_ident("plaintext") { - plaintexts.push(meta.value()?.parse()?); + let plaintext: Type = meta.value()?.parse()?; + // The type is spliced into the impl header as written, + // where a reference has no lifetime to name. The generic + // impl (no `plaintext` at all) already accepts `&str` and + // friends; a listed one is only needed for `from = ..`, + // which reaches into a struct. + if let Type::Reference(_) = plaintext { + return Err(syn::Error::new_spanned( + &plaintext, + "`plaintext` must be an owned type: a reference plaintext has no \ + lifetime the generated impl can name. Omit `plaintext` for an impl \ + generic over the source, which accepts references too.", + )); + } + if plaintexts.contains(&plaintext) { + return Err(syn::Error::new_spanned( + &plaintext, + "this `plaintext` is listed twice; each listed type gets one impl", + )); + } + plaintexts.push(plaintext); return Ok(()); } Err(meta.error( @@ -49,9 +69,10 @@ pub(crate) struct FieldAttrs { /// `#[stack_encrypt(context = "...")]`: derive this field under exactly this /// context instead of the one the caller passed for the record. pub(crate) context: Option, - /// `#[stack_encrypt(from = field)]`: derive this field from one field of - /// the plaintext rather than from the whole plaintext. - pub(crate) from: Option, + /// `#[stack_encrypt(from = field)]` / `#[stack_encrypt(from = 0)]`: derive + /// this field from one field of the plaintext rather than from the whole + /// plaintext. + pub(crate) from: Option, /// `#[stack_encrypt(default)]` / `#[stack_encrypt(default = expr)]`: not derived; /// filled with `Default::default()` or the expression. pub(crate) default: Option>, @@ -66,7 +87,18 @@ impl FieldAttrs { for attr in attrs.iter().filter(|a| a.path().is_ident("stack_encrypt")) { attr.parse_nested_meta(|meta| { if meta.path.is_ident("context") { - parsed.context = Some(meta.value()?.parse()?); + let context: LitStr = meta.value()?.parse()?; + // The leaves reject an empty context at runtime; a + // literal one is known here, so say so at the literal. + if context.value().is_empty() { + return Err(syn::Error::new( + context.span(), + "an empty `context` is rejected when a value is encrypted: name the \ + field (e.g. \"users/email\"), or drop the attribute to use the \ + record's context", + )); + } + parsed.context = Some(context); return Ok(()); } if meta.path.is_ident("from") { diff --git a/packages/stack-encrypt-derive/src/decrypt.rs b/packages/stack-encrypt-derive/src/decrypt.rs index 8a2470f79..ef09c358f 100644 --- a/packages/stack-encrypt-derive/src/decrypt.rs +++ b/packages/stack-encrypt-derive/src/decrypt.rs @@ -4,9 +4,10 @@ use std::collections::HashSet; use proc_macro2::TokenStream; use quote::quote; -use syn::{parse_quote, DeriveInput, Ident, Path, PathArguments, Result, Type}; +use syn::spanned::Spanned; +use syn::{parse_quote, DeriveInput, Ident, Member, Path, PathArguments, Result, Type}; -use crate::shape::{Field, Record}; +use crate::shape::{zip_fields, Field, Record}; pub(crate) fn derive(input: DeriveInput) -> Result { let record = Record::parse(&input)?; @@ -23,55 +24,16 @@ pub(crate) fn derive(input: DeriveInput) -> Result { )); } - let by_field = opened.iter().filter(|f| f.from().is_some()).count(); - let mode = match by_field { - 0 if opened.len() == 1 => Mode::Whole(opened[0]), - 0 => { - return Err(syn::Error::new_spanned( - name, - "several fields are marked `decrypt` but none names a plaintext field: one \ - plaintext cannot be recovered from two fields. Either mark only the ciphertext \ - field, or give each a `from = ..` so decryption rebuilds the plaintext field by \ - field.", - )) - } - n if n == opened.len() => { - let mut seen: HashSet<&Ident> = HashSet::with_capacity(opened.len()); - for field in &opened { - let from = field - .from() - .unwrap_or_else(|| unreachable!("counted above")); - if !seen.insert(from) { - return Err(syn::Error::new( - from.span(), - format!( - "two `decrypt` fields would recover the same plaintext field `{from}`" - ), - )); - } - } - Mode::ByField(opened) - } - _ => { - return Err(syn::Error::new_spanned( - name, - "`decrypt` fields must either all name a plaintext field (`from = ..`) or be a \ - single field opened as the whole plaintext; this record mixes the two", - )) - } - }; + let mode = Mode::classify(opened, name)?; if record.plaintexts.is_empty() { // One impl, generic over the plaintext: the record decrypts to // whatever its opened field decrypts to. Only the whole-plaintext // mode can be generic — rebuilding field by field needs a struct - // literal, and therefore a name. + // literal, and therefore a name — and `Record::parse` has already + // rejected `from` without one. let Mode::Whole(field) = &mode else { - return Err(syn::Error::new_spanned( - name, - "field-by-field decryption rebuilds the plaintext as a struct literal, so the \ - plaintext type must be named: add `#[stack_encrypt(plaintext = ..)]` to the struct", - )); + unreachable!("`from` without a named plaintext is rejected by `Record::parse`") }; let plaintext: Type = parse_quote!(__P); let ty = &field.ty; @@ -166,6 +128,48 @@ enum Mode<'a> { ByField(Vec<&'a Field>), } +impl<'a> Mode<'a> { + /// Which of the two shapes the `decrypt` fields describe; an error if + /// they describe neither. + fn classify(opened: Vec<&'a Field>, name: &Ident) -> Result { + let by_field = opened.iter().filter(|f| f.from().is_some()).count(); + if by_field == 0 { + if opened.len() == 1 { + return Ok(Mode::Whole(opened[0])); + } + return Err(syn::Error::new_spanned( + name, + "several fields are marked `decrypt` but none names a plaintext field: one \ + plaintext cannot be recovered from two fields. Either mark only the ciphertext \ + field, or give each a `from = ..` so decryption rebuilds the plaintext field by \ + field.", + )); + } + if by_field != opened.len() { + return Err(syn::Error::new_spanned( + name, + "`decrypt` fields must either all name a plaintext field (`from = ..`) or be a \ + single field opened as the whole plaintext; this record mixes the two", + )); + } + + let mut seen: HashSet<&Member> = HashSet::with_capacity(opened.len()); + for field in &opened { + let from = field + .from() + .unwrap_or_else(|| unreachable!("counted above")); + if !seen.insert(from) { + let name = quote!(#from); + return Err(syn::Error::new( + from.span(), + format!("two `decrypt` fields would recover the same plaintext field `{name}`"), + )); + } + } + Ok(Mode::ByField(opened)) + } +} + fn context_for(field: &Field) -> TokenStream { match field.context() { Some(literal) => quote!(#literal), @@ -189,56 +193,29 @@ fn whole_body(krate: &Path, field: &Field, plaintext: &Type) -> TokenStream { fn by_field_body(krate: &Path, fields: &[&Field], plaintext: &Type) -> Result { let literal = struct_literal_path(plaintext)?; - // The record's context goes to every opened field without its own; the - // last such field takes it by move. - let mut remaining = fields.iter().filter(|f| f.context().is_none()).count(); - let unused_context = (remaining == 0).then(|| quote!(let _ = __context;)); - - let mut chain = TokenStream::new(); - let mut pattern = TokenStream::new(); - for (index, field) in fields.iter().enumerate() { - let ty = &field.ty; - let member = &field.member; - let local = &field.local; - let context = match field.context() { - Some(literal) => quote!(#literal), - None => { - remaining -= 1; - if remaining == 0 { - quote!(__context) - } else { - quote!(::core::clone::Clone::clone(&__context)) - } - } - }; - // The plaintext field's type is not known here; it is inferred from - // the struct literal below, and the obligation checked against it. - let call = quote! { - <#ty as #krate::target::DecryptInto<_, #krate::StackCipher<__K>>>::decrypt_into( - self.#member, - __cipher, - #context, - ) - }; - if index == 0 { - chain = call; - pattern = quote!(#local); - } else { - chain = quote!(#chain.zip(#call)); - pattern = quote!((#pattern, #local)); - } - } - let assign = fields.iter().map(|field| { let from = field.from(); let local = &field.local; quote!(#from: #local) }); - Ok(quote! { - #unused_context - #chain.map(|#pattern| #literal { #(#assign),* }) - }) + Ok(zip_fields( + fields, + |field, context| { + let ty = &field.ty; + let member = &field.member; + // The plaintext field's type is not known here; it is inferred + // from the struct literal, and the obligation checked against it. + quote! { + <#ty as #krate::target::DecryptInto<_, #krate::StackCipher<__K>>>::decrypt_into( + self.#member, + __cipher, + #context, + ) + } + }, + quote!(#literal { #(#assign),* }), + )) } /// The plaintext type as a struct-literal path: `User` becomes `User::`. @@ -422,6 +399,41 @@ mod tests { assert_contains(&expansion, quote!(self.email, __cipher, __context,)); assert_contains(&expansion, quote!(.map(|(__field_0, __field_1)| User:: { age: __field_0, email: __field_1 }))); assert_lacks(&expansion, quote!(email_eq)); - assert_lacks(&expansion, quote!(let _ = __context;)); + } + + #[test] + fn tuple_plaintexts_are_rebuilt_by_index() { + let expansion = expand(parse_quote! { + #[stack_encrypt(plaintext = Pair)] + struct EncryptedPair { + #[stack_encrypt(decrypt, from = 0, context = "pair/0")] + a: StackCipherText, + #[stack_encrypt(decrypt, from = 1, context = "pair/1")] + b: StackCipherText, + } + }) + .unwrap(); + assert_contains( + &expansion, + quote!(Pair { + 0: __field_0, + 1: __field_1 + }), + ); + } + + #[test] + fn duplicate_recovery_targets_by_index_are_rejected() { + let err = expand(parse_quote! { + #[stack_encrypt(plaintext = Pair)] + struct Rec { + #[stack_encrypt(decrypt, from = 0)] + a: StackCipherText, + #[stack_encrypt(decrypt, from = 0)] + b: StackCipherText, + } + }) + .unwrap_err(); + assert!(err.to_string().contains("same plaintext field `0`")); } } diff --git a/packages/stack-encrypt-derive/src/encrypt.rs b/packages/stack-encrypt-derive/src/encrypt.rs index 4e3d6b5bc..97f37608f 100644 --- a/packages/stack-encrypt-derive/src/encrypt.rs +++ b/packages/stack-encrypt-derive/src/encrypt.rs @@ -2,9 +2,9 @@ use proc_macro2::TokenStream; use quote::quote; -use syn::{parse_quote, DeriveInput, Result, Type}; +use syn::{parse_quote, DeriveInput, Ident, Path, Result, Type}; -use crate::shape::{Field, Kind, Record}; +use crate::shape::{zip_fields, Field, Kind, Record}; pub(crate) fn derive(input: DeriveInput) -> Result { let record = Record::parse(&input)?; @@ -23,25 +23,15 @@ pub(crate) fn derive(input: DeriveInput) -> Result { push_field_bounds(&mut generics, krate, &record, &source); let (impl_generics, _, where_clause) = generics.split_for_impl(); let body = body(krate, &record, &source); - - return Ok(quote! { - #[automatically_derived] - impl #impl_generics #krate::target::EncryptFrom<__S, #krate::StackCipher<__K>> - for #name #ty_generics #where_clause - { - fn encrypt_from<'__a, '__c, __Ctx>( - __source: &'__a __S, - __cipher: &'__a #krate::StackCipher<__K>, - __context: __Ctx, - ) -> #krate::target::Pending<'__a, Self, __K> - where - __Ctx: #krate::target::EncryptContext<'__c>, - Self: '__a, - { - #body - } - } - }); + return Ok(impl_block( + krate, + name, + &ty_generics, + &impl_generics, + where_clause, + &source, + body, + )); } // One impl per listed source. Fields derived from the whole source get a @@ -53,38 +43,53 @@ pub(crate) fn derive(input: DeriveInput) -> Result { push_field_bounds(&mut generics, krate, &record, source); let (impl_generics, _, where_clause) = generics.split_for_impl(); let body = body(krate, &record, source); + impl_block( + krate, + name, + &ty_generics, + &impl_generics, + where_clause, + source, + body, + ) + }); + + Ok(quote!(#(#impls)*)) +} - quote! { - #[automatically_derived] - impl #impl_generics #krate::target::EncryptFrom<#source, #krate::StackCipher<__K>> - for #name #ty_generics #where_clause +/// `impl EncryptFrom> for Record` around `body`. +fn impl_block( + krate: &Path, + name: &Ident, + ty_generics: &syn::TypeGenerics<'_>, + impl_generics: &syn::ImplGenerics<'_>, + where_clause: Option<&syn::WhereClause>, + source: &Type, + body: TokenStream, +) -> TokenStream { + quote! { + #[automatically_derived] + impl #impl_generics #krate::target::EncryptFrom<#source, #krate::StackCipher<__K>> + for #name #ty_generics #where_clause + { + fn encrypt_from<'__a, '__c, __Ctx>( + __source: &'__a #source, + __cipher: &'__a #krate::StackCipher<__K>, + __context: __Ctx, + ) -> #krate::target::Pending<'__a, Self, __K> + where + __Ctx: #krate::target::EncryptContext<'__c>, + Self: '__a, { - fn encrypt_from<'__a, '__c, __Ctx>( - __source: &'__a #source, - __cipher: &'__a #krate::StackCipher<__K>, - __context: __Ctx, - ) -> #krate::target::Pending<'__a, Self, __K> - where - __Ctx: #krate::target::EncryptContext<'__c>, - Self: '__a, - { - #body - } + #body } } - }); - - Ok(quote!(#(#impls)*)) + } } /// `FieldTy: EncryptFrom>` for every field derived /// from the whole source. -fn push_field_bounds( - generics: &mut syn::Generics, - krate: &syn::Path, - record: &Record, - source: &Type, -) { +fn push_field_bounds(generics: &mut syn::Generics, krate: &Path, record: &Record, source: &Type) { let predicates = &mut generics.make_where_clause().predicates; for field in record .fields @@ -99,52 +104,10 @@ fn push_field_bounds( } /// The method body: every derived field's pending, zipped into one, mapped -/// into `Self`. Nothing is awaited, so the record settles as one batched -/// call. -fn body(krate: &syn::Path, record: &Record, source: &Type) -> TokenStream { +/// into `Self`. +fn body(krate: &Path, record: &Record, source: &Type) -> TokenStream { let derived: Vec<&Field> = record.fields.iter().filter(|f| f.is_derived()).collect(); - // The record's context goes to every derived field without its own; the - // last such field takes it by move. - let mut remaining = derived.iter().filter(|f| f.context().is_none()).count(); - let unused_context = (remaining == 0).then(|| quote!(let _ = __context;)); - - let mut chain = TokenStream::new(); - let mut pattern = TokenStream::new(); - for (index, field) in derived.iter().enumerate() { - let ty = &field.ty; - let local = &field.local; - let (source_expr, source_ty): (TokenStream, TokenStream) = match field.from() { - Some(from) => (quote!(&__source.#from), quote!(_)), - None => (quote!(__source), quote!(#source)), - }; - let context = match field.context() { - Some(literal) => quote!(#literal), - None => { - remaining -= 1; - if remaining == 0 { - quote!(__context) - } else { - quote!(::core::clone::Clone::clone(&__context)) - } - } - }; - let call = quote! { - <#ty as #krate::target::EncryptFrom<#source_ty, #krate::StackCipher<__K>>>::encrypt_from( - #source_expr, - __cipher, - #context, - ) - }; - if index == 0 { - chain = call; - pattern = quote!(#local); - } else { - chain = quote!(#chain.zip(#call)); - pattern = quote!((#pattern, #local)); - } - } - let assign = record.fields.iter().map(|field| { let member = &field.member; match &field.kind { @@ -157,10 +120,26 @@ fn body(krate: &syn::Path, record: &Record, source: &Type) -> TokenStream { } }); - quote! { - #unused_context - #chain.map(|#pattern| Self { #(#assign),* }) - } + zip_fields( + &derived, + |field, context| { + let ty = &field.ty; + // A `from` field's source type is not known here; it is inferred + // from the field expression, and the obligation checked there. + let (source_expr, source_ty): (TokenStream, TokenStream) = match field.from() { + Some(from) => (quote!(&__source.#from), quote!(_)), + None => (quote!(__source), quote!(#source)), + }; + quote! { + <#ty as #krate::target::EncryptFrom<#source_ty, #krate::StackCipher<__K>>>::encrypt_from( + #source_expr, + __cipher, + #context, + ) + } + }, + quote!(Self { #(#assign),* }), + ) } #[cfg(test)] @@ -232,9 +211,8 @@ mod tests { &__source.age, __cipher, "users/age", ) }); - // No field takes the record's context, so it is explicitly dropped - // rather than left as an unused-variable warning in user code. - assert_contains(&expansion, quote!(let _ = __context;)); + // No field takes the record's context. + assert_lacks(&expansion, quote!(&__context)); // `from` fields carry no where clause: the source field's type is // unknown here, so the obligation is checked in the body instead. assert!( @@ -243,6 +221,18 @@ mod tests { ); } + #[test] + fn tuple_plaintexts_are_reached_by_index() { + let expansion = expand(parse_quote! { + #[stack_encrypt(plaintext = Pair)] + struct EncryptedPair { + #[stack_encrypt(from = 1, context = "pair/1")] + b: StackCipherText, + } + }); + assert_contains(&expansion, quote!(&__source.1, __cipher, "pair/1",)); + } + #[test] fn a_single_derived_field_still_maps_into_self() { let expansion = expand(parse_quote! { diff --git a/packages/stack-encrypt-derive/src/lib.rs b/packages/stack-encrypt-derive/src/lib.rs index 44936bd6d..097887f4b 100644 --- a/packages/stack-encrypt-derive/src/lib.rs +++ b/packages/stack-encrypt-derive/src/lib.rs @@ -12,9 +12,11 @@ //! types (`StackCipherText`, the `sem` terms) implement `EncryptFrom` by //! hand; a record is a struct of leaves, and this derive writes its impl: //! -//! ```ignore +//! ``` //! use stack_encrypt::sem::{EqualityTerm, OreTerm}; -//! use stack_encrypt::{DecryptInto, EncryptFrom, StackCipherText}; +//! use stack_encrypt::target::EncryptInto; +//! use stack_encrypt::{DecryptInto, EncryptFrom, StackCipher, StackCipherText}; +//! use stack_kms::FakeDataKeySource; //! //! /// An encrypted integer, queryable by equality and range. //! #[derive(EncryptFrom, DecryptInto)] @@ -26,8 +28,16 @@ //! ob: OreTerm, //! } //! +//! # tokio::runtime::Builder::new_current_thread().enable_all().build().unwrap().block_on(async { +//! let cipher = StackCipher::builder() +//! .kms(FakeDataKeySource::new()) +//! .init() +//! .await?; //! let record: EncryptedAge = 42u32.encrypt_into(&cipher, "users/age").await?; //! let age: u32 = record.decrypt_into(&cipher, "users/age").await?; +//! assert_eq!(age, 42); +//! # Ok::<(), stack_encrypt::Error>(()) +//! # }).unwrap(); //! ``` //! //! Every derived field is fed the **same source** under the **same @@ -43,7 +53,20 @@ //! One level up, the same derive: a struct whose fields are each derived from //! a *field* of the plaintext, under a context of their own. //! -//! ```ignore +//! ``` +//! # use stack_encrypt::sem::{EqualityTerm, OreTerm}; +//! # use stack_encrypt::target::EncryptInto; +//! # use stack_encrypt::{DecryptInto, EncryptFrom, StackCipher, StackCipherText}; +//! # use stack_kms::FakeDataKeySource; +//! # #[derive(EncryptFrom, DecryptInto)] +//! # #[stack_encrypt(plaintext = u32)] +//! # struct EncryptedAge { +//! # #[stack_encrypt(decrypt)] +//! # c: StackCipherText, +//! # hm: EqualityTerm, +//! # ob: OreTerm, +//! # } +//! #[derive(Debug, PartialEq)] //! struct User { //! age: u32, //! email: String, @@ -58,9 +81,17 @@ //! email: StackCipherText, //! } //! -//! let row: EncryptedUser = user.encrypt_into(&cipher, ()).await?; // one batch +//! # tokio::runtime::Builder::new_current_thread().enable_all().build().unwrap().block_on(async { +//! # let cipher = StackCipher::builder().kms(FakeDataKeySource::new()).init().await?; +//! let user = User { age: 42, email: "alice@example.com".into() }; +//! let row: EncryptedUser = user.encrypt_into(&cipher, ()).await?; // one batch +//! let users = vec![User { age: 1, email: "a".into() }, User { age: 2, email: "b".into() }]; //! let rows: Vec = users.encrypt_into(&cipher, ()).await?; // still one //! let user: User = row.decrypt_into(&cipher, ()).await?; +//! assert_eq!(user, User { age: 42, email: "alice@example.com".into() }); +//! assert_eq!(rows.len(), 2); +//! # Ok::<(), stack_encrypt::Error>(()) +//! # }).unwrap(); //! ``` //! //! A `from` field's context is the *column's* identity, which is why it is a diff --git a/packages/stack-encrypt-derive/src/shape.rs b/packages/stack-encrypt-derive/src/shape.rs index a6a8684ff..b13f8ef29 100644 --- a/packages/stack-encrypt-derive/src/shape.rs +++ b/packages/stack-encrypt-derive/src/shape.rs @@ -1,6 +1,8 @@ //! Classification of the derive input into the record it describes. -use proc_macro2::Span; +use proc_macro2::{Span, TokenStream}; +use quote::quote; +use syn::spanned::Spanned; use syn::{Data, DeriveInput, Expr, Fields, Ident, LitStr, Member, Path, Result, Type}; use crate::attrs::{ContainerAttrs, FieldAttrs}; @@ -27,9 +29,9 @@ pub(crate) enum Kind { /// `#[stack_encrypt(context = "...")]`: this field's context, overriding /// the record's. context: Option, - /// `#[stack_encrypt(from = field)]`: derived from one field of the - /// plaintext rather than the whole plaintext. - from: Option, + /// `#[stack_encrypt(from = field)]` / `from = 0`: derived from one + /// field of the plaintext rather than the whole plaintext. + from: Option, }, /// Not derived: `Default::default()` or the given expression. Default(Option), @@ -40,8 +42,8 @@ impl Field { matches!(self.kind, Kind::Derived { .. }) } - /// The `from` field, if this is a derived field with one. - pub(crate) fn from(&self) -> Option<&Ident> { + /// The `from` member, if this is a derived field with one. + pub(crate) fn from(&self) -> Option<&Member> { match &self.kind { Kind::Derived { from, .. } => from.as_ref(), Kind::Default(_) => None, @@ -100,7 +102,7 @@ impl Record { if attrs.plaintexts.is_empty() { if let Some(field) = fields.iter().find(|f| f.from().is_some()) { return Err(syn::Error::new( - field.from().map_or_else(Span::call_site, Ident::span), + field.from().map_or_else(Span::call_site, Spanned::span), "`from = ..` reaches into a field of the plaintext, so the plaintext type must \ be named: add `#[stack_encrypt(plaintext = ..)]` to the struct", )); @@ -115,6 +117,48 @@ impl Record { } } +/// The pendings of `fields`, zipped into one and mapped into `build` (a +/// struct literal over the fields' locals). Nothing is awaited, so the record +/// settles as one batched call. +/// +/// `call(field, context)` renders one field's pending under `context`. The +/// record's context (`__context`) goes to every field without a literal of +/// its own; the last such field takes it by move, the rest clone it. +pub(crate) fn zip_fields( + fields: &[&Field], + mut call: impl FnMut(&Field, TokenStream) -> TokenStream, + build: TokenStream, +) -> TokenStream { + let mut remaining = fields.iter().filter(|f| f.context().is_none()).count(); + + let mut chain = TokenStream::new(); + let mut pattern = TokenStream::new(); + for (index, field) in fields.iter().enumerate() { + let context = match field.context() { + Some(literal) => quote!(#literal), + None => { + remaining -= 1; + if remaining == 0 { + quote!(__context) + } else { + quote!(::core::clone::Clone::clone(&__context)) + } + } + }; + let call = call(field, context); + let local = &field.local; + if index == 0 { + chain = call; + pattern = quote!(#local); + } else { + chain = quote!(#chain.zip(#call)); + pattern = quote!((#pattern, #local)); + } + } + + quote!(#chain.map(|#pattern| #build)) +} + fn collect(fields: &Fields) -> Result> { fields .iter() @@ -228,6 +272,58 @@ mod tests { assert!(err.to_string().contains("unsupported container attribute")); } + #[test] + fn a_literal_empty_context_is_rejected() { + let err = parse(parse_quote! { + struct Rec { + #[stack_encrypt(context = "")] + c: StackCipherText, + } + }) + .unwrap_err(); + assert!(err.to_string().contains("empty `context`")); + } + + #[test] + fn a_reference_plaintext_is_rejected() { + let err = parse(parse_quote! { + #[stack_encrypt(plaintext = &str)] + struct Rec { + c: StackCipherText, + } + }) + .unwrap_err(); + assert!(err.to_string().contains("must be an owned type")); + } + + #[test] + fn a_repeated_plaintext_is_rejected() { + let err = parse(parse_quote! { + #[stack_encrypt(plaintext = u32, plaintext = u32)] + struct Rec { + c: StackCipherText, + } + }) + .unwrap_err(); + assert!(err.to_string().contains("listed twice")); + } + + #[test] + fn from_addresses_tuple_plaintexts_by_index() { + let record = parse(parse_quote! { + #[stack_encrypt(plaintext = Pair)] + struct Rec { + #[stack_encrypt(from = 0, context = "pair/0")] + a: StackCipherText, + #[stack_encrypt(from = 1, context = "pair/1")] + b: StackCipherText, + } + }) + .unwrap(); + assert!(matches!(record.fields[0].from(), Some(Member::Unnamed(i)) if i.index == 0)); + assert!(matches!(record.fields[1].from(), Some(Member::Unnamed(i)) if i.index == 1)); + } + #[test] fn fields_classify() { let record = parse(parse_quote! { @@ -243,7 +339,7 @@ mod tests { .unwrap(); assert_eq!(record.plaintexts.len(), 2); assert_eq!(record.fields.len(), 3); - assert_eq!(record.fields[0].from().unwrap(), "age"); + assert!(matches!(record.fields[0].from(), Some(Member::Named(name)) if name == "age")); assert_eq!(record.fields[0].context().unwrap().value(), "users/age"); assert!(record.fields[0].decrypt); assert!(record.fields[1].is_derived()); diff --git a/packages/stack-encrypt/Cargo.toml b/packages/stack-encrypt/Cargo.toml index 827369528..36efaad31 100644 --- a/packages/stack-encrypt/Cargo.toml +++ b/packages/stack-encrypt/Cargo.toml @@ -53,6 +53,8 @@ zeroize = { workspace = true } serde_json = { workspace = true } stack-kms = { path = "../stack-kms", features = ["test-support"] } tokio = { workspace = true, features = ["rt", "macros"] } +# Compile-fail tests for the derive diagnostics (`tests/ui`). +trybuild = "1" vitaminc-hmac = { git = "https://github.com/cipherstash/vitaminc", rev = "04f4faa3eeeb69ae30dd0247865e06c640585fea" } vitaminc-prf = { git = "https://github.com/cipherstash/vitaminc", rev = "04f4faa3eeeb69ae30dd0247865e06c640585fea" } vitaminc-protected = { git = "https://github.com/cipherstash/vitaminc", rev = "04f4faa3eeeb69ae30dd0247865e06c640585fea" } diff --git a/packages/stack-encrypt/src/target/mod.rs b/packages/stack-encrypt/src/target/mod.rs index a4e835994..0848652c1 100644 --- a/packages/stack-encrypt/src/target/mod.rs +++ b/packages/stack-encrypt/src/target/mod.rs @@ -134,9 +134,15 @@ //! Ctx: EncryptContext<'c>, //! Self: 'a, //! { -//! // Domain-separate under your own label so your terms can never -//! // collide with another scheme's under the same context. +//! // The leaf owns the context policy: the built-in leaves refuse +//! // an empty context (nothing above them checks, since a column +//! // of rows has no context of its own), and yours should too. +//! // Then domain-separate under your own label so your terms can +//! // never collide with another scheme's under the same context. //! let context = context.into_prf_context().into_owned(); +//! if context.as_bytes().is_empty() { +//! return Pending::ready(cipher, Err(Error::EmptyContext)); +//! } //! let context = PrfContext::pae(&[b"my-crate/my-term/v1".as_slice(), context.as_bytes()]); //! let term = source //! .clone() diff --git a/packages/stack-encrypt/tests/derive.rs b/packages/stack-encrypt/tests/derive.rs index 73265a059..629c423dc 100644 --- a/packages/stack-encrypt/tests/derive.rs +++ b/packages/stack-encrypt/tests/derive.rs @@ -244,3 +244,34 @@ async fn a_row_field_opened_under_the_wrong_context_fails() { let transplanted: Result = row.age.c.decrypt_into(&cipher, "users/height").await; assert!(matches!(transplanted, Err(Error::Aead))); } + +/// A tuple-struct plaintext is reached by index: `from = 0`. +#[derive(Debug, Clone, PartialEq, Eq)] +struct Reading(u32, String); + +#[derive(EncryptFrom, DecryptInto)] +#[stack_encrypt(plaintext = Reading)] +struct EncryptedReading { + #[stack_encrypt(from = 0, context = "readings/value", decrypt)] + value: EncryptedAge, + #[stack_encrypt(from = 1, context = "readings/unit", decrypt)] + unit: StackCipherText, +} + +#[tokio::test] +async fn a_tuple_plaintext_row_is_reached_and_rebuilt_by_index() { + let cipher = stack_cipher().await; + let generator = stack_cipher().await; + + let reading = Reading(21, "celsius".into()); + let row: EncryptedReading = reading.encrypt_into(&cipher, ()).await.unwrap(); + + let hm: EqualityTerm = 21u32 + .encrypt_into(&generator, "readings/value") + .await + .unwrap(); + assert_eq!(row.value.hm, hm); + + let recovered: Reading = row.decrypt_into(&cipher, ()).await.unwrap(); + assert_eq!(recovered, reading); +} diff --git a/packages/stack-encrypt/tests/target.rs b/packages/stack-encrypt/tests/target.rs index 9874db98c..a9531d7d0 100644 --- a/packages/stack-encrypt/tests/target.rs +++ b/packages/stack-encrypt/tests/target.rs @@ -17,24 +17,16 @@ use uuid::Uuid; use vitaminc_prf::{BlockVisitor, PrfContext, PrfValue}; mod common; -use common::counting_cipher; +use common::{counting_cipher, stack_cipher}; -/// A cipher over the deterministic fake source. Built independently of -/// [`stack_cipher`] below: the fake index key is deterministic per keyset, so -/// two separately built ciphers stand in for the write path and a query path -/// in another process. +/// A cipher over the deterministic fake source. Built independently of the +/// test's own [`stack_cipher`]: the fake index key is deterministic per +/// keyset, so two separately built ciphers stand in for the write path and a +/// query path in another process. async fn generator() -> StackCipher { stack_cipher().await } -async fn stack_cipher() -> StackCipher { - StackCipher::builder() - .kms(FakeDataKeySource::new()) - .init() - .await - .expect("build cipher") -} - // --- Leaf implementations --------------------------------------------------- #[tokio::test] diff --git a/packages/stack-encrypt/tests/ui.rs b/packages/stack-encrypt/tests/ui.rs new file mode 100644 index 000000000..1cda92c9e --- /dev/null +++ b/packages/stack-encrypt/tests/ui.rs @@ -0,0 +1,9 @@ +//! Compile-fail tests for the derive diagnostics: every `tests/ui/*.rs` must +//! fail to compile with exactly the `.stderr` beside it. Regenerate the +//! expectations after a deliberate message change with `TRYBUILD=overwrite`. + +#[test] +fn derive_diagnostics() { + let t = trybuild::TestCases::new(); + t.compile_fail("tests/ui/*.rs"); +} diff --git a/packages/stack-encrypt/tests/ui/decrypt_duplicate_from.rs b/packages/stack-encrypt/tests/ui/decrypt_duplicate_from.rs new file mode 100644 index 000000000..a2cd8ab5d --- /dev/null +++ b/packages/stack-encrypt/tests/ui/decrypt_duplicate_from.rs @@ -0,0 +1,16 @@ +use stack_encrypt::{DecryptInto, StackCipherText}; + +struct User { + a: u32, +} + +#[derive(DecryptInto)] +#[stack_encrypt(plaintext = User)] +struct Rec { + #[stack_encrypt(decrypt, from = a)] + a: StackCipherText, + #[stack_encrypt(decrypt, from = a)] + b: StackCipherText, +} + +fn main() {} diff --git a/packages/stack-encrypt/tests/ui/decrypt_duplicate_from.stderr b/packages/stack-encrypt/tests/ui/decrypt_duplicate_from.stderr new file mode 100644 index 000000000..e60f12625 --- /dev/null +++ b/packages/stack-encrypt/tests/ui/decrypt_duplicate_from.stderr @@ -0,0 +1,5 @@ +error: two `decrypt` fields would recover the same plaintext field `a` + --> tests/ui/decrypt_duplicate_from.rs:12:37 + | +12 | #[stack_encrypt(decrypt, from = a)] + | ^ diff --git a/packages/stack-encrypt/tests/ui/decrypt_mixed_modes.rs b/packages/stack-encrypt/tests/ui/decrypt_mixed_modes.rs new file mode 100644 index 000000000..eb470becf --- /dev/null +++ b/packages/stack-encrypt/tests/ui/decrypt_mixed_modes.rs @@ -0,0 +1,17 @@ +use stack_encrypt::{DecryptInto, StackCipherText}; + +struct User { + a: u32, + b: u32, +} + +#[derive(DecryptInto)] +#[stack_encrypt(plaintext = User)] +struct Rec { + #[stack_encrypt(decrypt, from = a)] + a: StackCipherText, + #[stack_encrypt(decrypt)] + b: StackCipherText, +} + +fn main() {} diff --git a/packages/stack-encrypt/tests/ui/decrypt_mixed_modes.stderr b/packages/stack-encrypt/tests/ui/decrypt_mixed_modes.stderr new file mode 100644 index 000000000..221a3ef97 --- /dev/null +++ b/packages/stack-encrypt/tests/ui/decrypt_mixed_modes.stderr @@ -0,0 +1,5 @@ +error: `decrypt` fields must either all name a plaintext field (`from = ..`) or be a single field opened as the whole plaintext; this record mixes the two + --> tests/ui/decrypt_mixed_modes.rs:10:8 + | +10 | struct Rec { + | ^^^ diff --git a/packages/stack-encrypt/tests/ui/decrypt_without_opened_field.rs b/packages/stack-encrypt/tests/ui/decrypt_without_opened_field.rs new file mode 100644 index 000000000..a5195809e --- /dev/null +++ b/packages/stack-encrypt/tests/ui/decrypt_without_opened_field.rs @@ -0,0 +1,11 @@ +use stack_encrypt::sem::EqualityTerm; +use stack_encrypt::{DecryptInto, StackCipherText}; + +#[derive(DecryptInto)] +#[stack_encrypt(plaintext = u32)] +struct Rec { + c: StackCipherText, + hm: EqualityTerm, +} + +fn main() {} diff --git a/packages/stack-encrypt/tests/ui/decrypt_without_opened_field.stderr b/packages/stack-encrypt/tests/ui/decrypt_without_opened_field.stderr new file mode 100644 index 000000000..b9a8c7517 --- /dev/null +++ b/packages/stack-encrypt/tests/ui/decrypt_without_opened_field.stderr @@ -0,0 +1,5 @@ +error: DecryptInto needs to know which field decryption opens: mark it `#[stack_encrypt(decrypt)]` (index terms are one-way and cannot be) + --> tests/ui/decrypt_without_opened_field.rs:6:8 + | +6 | struct Rec { + | ^^^ diff --git a/packages/stack-encrypt/tests/ui/default_with_context.rs b/packages/stack-encrypt/tests/ui/default_with_context.rs new file mode 100644 index 000000000..482127caa --- /dev/null +++ b/packages/stack-encrypt/tests/ui/default_with_context.rs @@ -0,0 +1,10 @@ +use stack_encrypt::{EncryptFrom, StackCipherText}; + +#[derive(EncryptFrom)] +struct Rec { + c: StackCipherText, + #[stack_encrypt(default, context = "x")] + v: u8, +} + +fn main() {} diff --git a/packages/stack-encrypt/tests/ui/default_with_context.stderr b/packages/stack-encrypt/tests/ui/default_with_context.stderr new file mode 100644 index 000000000..00145d9a6 --- /dev/null +++ b/packages/stack-encrypt/tests/ui/default_with_context.stderr @@ -0,0 +1,5 @@ +error: a `default` field is not derived from the source, so `context`, `from` and `decrypt` do not apply to it + --> tests/ui/default_with_context.rs:7:8 + | +7 | v: u8, + | ^^ diff --git a/packages/stack-encrypt/tests/ui/drop_record.rs b/packages/stack-encrypt/tests/ui/drop_record.rs new file mode 100644 index 000000000..f2f498041 --- /dev/null +++ b/packages/stack-encrypt/tests/ui/drop_record.rs @@ -0,0 +1,19 @@ +use stack_encrypt::sem::EqualityTerm; +use stack_encrypt::{DecryptInto, EncryptFrom, StackCipherText}; + +// `DecryptInto` moves the opened field out of `self`, which a `Drop` type +// (including `ZeroizeOnDrop`) forbids. The restriction is documented; this +// pins what the user sees. +#[derive(EncryptFrom, DecryptInto)] +#[stack_encrypt(plaintext = u32)] +struct Rec { + #[stack_encrypt(decrypt)] + c: StackCipherText, + hm: EqualityTerm, +} + +impl Drop for Rec { + fn drop(&mut self) {} +} + +fn main() {} diff --git a/packages/stack-encrypt/tests/ui/drop_record.stderr b/packages/stack-encrypt/tests/ui/drop_record.stderr new file mode 100644 index 000000000..4985c12da --- /dev/null +++ b/packages/stack-encrypt/tests/ui/drop_record.stderr @@ -0,0 +1,10 @@ +error[E0509]: cannot move out of type `Rec`, which implements the `Drop` trait + --> tests/ui/drop_record.rs:7:23 + | +7 | #[derive(EncryptFrom, DecryptInto)] + | ^^^^^^^^^^^ + | | + | cannot move out of here + | move occurs because value has type `CipherText>`, which does not implement the `Copy` trait + | + = note: this error originates in the derive macro `DecryptInto` (in Nightly builds, run with -Z macro-backtrace for more info) diff --git a/packages/stack-encrypt/tests/ui/duplicate_plaintext.rs b/packages/stack-encrypt/tests/ui/duplicate_plaintext.rs new file mode 100644 index 000000000..2ea636571 --- /dev/null +++ b/packages/stack-encrypt/tests/ui/duplicate_plaintext.rs @@ -0,0 +1,9 @@ +use stack_encrypt::{EncryptFrom, StackCipherText}; + +#[derive(EncryptFrom)] +#[stack_encrypt(plaintext = u32, plaintext = u32)] +struct Dup { + c: StackCipherText, +} + +fn main() {} diff --git a/packages/stack-encrypt/tests/ui/duplicate_plaintext.stderr b/packages/stack-encrypt/tests/ui/duplicate_plaintext.stderr new file mode 100644 index 000000000..1e6cf5570 --- /dev/null +++ b/packages/stack-encrypt/tests/ui/duplicate_plaintext.stderr @@ -0,0 +1,5 @@ +error: this `plaintext` is listed twice; each listed type gets one impl + --> tests/ui/duplicate_plaintext.rs:4:46 + | +4 | #[stack_encrypt(plaintext = u32, plaintext = u32)] + | ^^^ diff --git a/packages/stack-encrypt/tests/ui/empty_context.rs b/packages/stack-encrypt/tests/ui/empty_context.rs new file mode 100644 index 000000000..a69beb749 --- /dev/null +++ b/packages/stack-encrypt/tests/ui/empty_context.rs @@ -0,0 +1,14 @@ +use stack_encrypt::{EncryptFrom, StackCipherText}; + +struct User { + email: String, +} + +#[derive(EncryptFrom)] +#[stack_encrypt(plaintext = User)] +struct EncryptedUser { + #[stack_encrypt(from = email, context = "")] + email: StackCipherText, +} + +fn main() {} diff --git a/packages/stack-encrypt/tests/ui/empty_context.stderr b/packages/stack-encrypt/tests/ui/empty_context.stderr new file mode 100644 index 000000000..6bf7f8c51 --- /dev/null +++ b/packages/stack-encrypt/tests/ui/empty_context.stderr @@ -0,0 +1,5 @@ +error: an empty `context` is rejected when a value is encrypted: name the field (e.g. "users/email"), or drop the attribute to use the record's context + --> tests/ui/empty_context.rs:10:45 + | +10 | #[stack_encrypt(from = email, context = "")] + | ^^ diff --git a/packages/stack-encrypt/tests/ui/enum_record.rs b/packages/stack-encrypt/tests/ui/enum_record.rs new file mode 100644 index 000000000..8f9f02896 --- /dev/null +++ b/packages/stack-encrypt/tests/ui/enum_record.rs @@ -0,0 +1,9 @@ +use stack_encrypt::{EncryptFrom, StackCipherText}; + +#[derive(EncryptFrom)] +enum Choice { + A(StackCipherText), + B(StackCipherText), +} + +fn main() {} diff --git a/packages/stack-encrypt/tests/ui/enum_record.stderr b/packages/stack-encrypt/tests/ui/enum_record.stderr new file mode 100644 index 000000000..63eb02cad --- /dev/null +++ b/packages/stack-encrypt/tests/ui/enum_record.stderr @@ -0,0 +1,5 @@ +error: EncryptFrom/DecryptInto cannot be derived for enums: a record is a fixed set of fields derived from one source, and a variant choice has no field to be derived into. Model the choice explicitly instead, e.g. as a struct of `Option` fields. + --> tests/ui/enum_record.rs:4:6 + | +4 | enum Choice { + | ^^^^^^ diff --git a/packages/stack-encrypt/tests/ui/from_without_plaintext.rs b/packages/stack-encrypt/tests/ui/from_without_plaintext.rs new file mode 100644 index 000000000..cf7acd89a --- /dev/null +++ b/packages/stack-encrypt/tests/ui/from_without_plaintext.rs @@ -0,0 +1,9 @@ +use stack_encrypt::{EncryptFrom, StackCipherText}; + +#[derive(EncryptFrom)] +struct Row { + #[stack_encrypt(from = age, context = "users/age")] + age: StackCipherText, +} + +fn main() {} diff --git a/packages/stack-encrypt/tests/ui/from_without_plaintext.stderr b/packages/stack-encrypt/tests/ui/from_without_plaintext.stderr new file mode 100644 index 000000000..18464b4e6 --- /dev/null +++ b/packages/stack-encrypt/tests/ui/from_without_plaintext.stderr @@ -0,0 +1,5 @@ +error: `from = ..` reaches into a field of the plaintext, so the plaintext type must be named: add `#[stack_encrypt(plaintext = ..)]` to the struct + --> tests/ui/from_without_plaintext.rs:5:28 + | +5 | #[stack_encrypt(from = age, context = "users/age")] + | ^^^ diff --git a/packages/stack-encrypt/tests/ui/reference_plaintext.rs b/packages/stack-encrypt/tests/ui/reference_plaintext.rs new file mode 100644 index 000000000..ae90114fa --- /dev/null +++ b/packages/stack-encrypt/tests/ui/reference_plaintext.rs @@ -0,0 +1,9 @@ +use stack_encrypt::{EncryptFrom, StackCipherText}; + +#[derive(EncryptFrom)] +#[stack_encrypt(plaintext = &str)] +struct Text { + c: StackCipherText, +} + +fn main() {} diff --git a/packages/stack-encrypt/tests/ui/reference_plaintext.stderr b/packages/stack-encrypt/tests/ui/reference_plaintext.stderr new file mode 100644 index 000000000..0e8e233b0 --- /dev/null +++ b/packages/stack-encrypt/tests/ui/reference_plaintext.stderr @@ -0,0 +1,5 @@ +error: `plaintext` must be an owned type: a reference plaintext has no lifetime the generated impl can name. Omit `plaintext` for an impl generic over the source, which accepts references too. + --> tests/ui/reference_plaintext.rs:4:29 + | +4 | #[stack_encrypt(plaintext = &str)] + | ^^^^ From b55a7cccb9cbb1a28c5fcc651812d3b68f31f5c7 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Fri, 28 Aug 2026 15:28:32 +1000 Subject: [PATCH 457/686] chore(stack-encrypt-derive): tell cargo-udeps the dev-dependencies are doctest-only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `cargo udeps` cannot see into rustdoc, so it reports `stack-encrypt`, `stack-kms` and `tokio` — used only by the crate-level doctests — as unused. Ignore them the same way stack-auth ignores `aquamarine`. Claude-Session: https://claude.ai/code/session_01HU1Bbw4eQp9kEEneXxDcKr --- packages/stack-encrypt-derive/Cargo.toml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/stack-encrypt-derive/Cargo.toml b/packages/stack-encrypt-derive/Cargo.toml index 56eaf8627..a1fde854a 100644 --- a/packages/stack-encrypt-derive/Cargo.toml +++ b/packages/stack-encrypt-derive/Cargo.toml @@ -21,6 +21,11 @@ proc-macro2 = "1" quote = "1" syn = { version = "3", features = ["full", "extra-traits"] } +# The dev-dependencies below are used only by the crate-level doctests. +# `cargo-udeps` cannot see into rustdoc, so it reports them as unused. +[package.metadata.cargo-udeps.ignore] +development = ["stack-encrypt", "stack-kms", "tokio"] + [dev-dependencies] # The crate-level examples are real doctests, run against the fake key # source. A dev-dependency cycle back to `stack-encrypt` is the usual shape From d7f27140c79f03dcc284a4e9786c96ba73b15ed8 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Fri, 28 Aug 2026 15:58:43 +1000 Subject: [PATCH 458/686] refactor(stack-encrypt-derive): rename the attribute from `stack_encrypt` to `stash` `stash` is the name of the SDK as a whole, so `#[stash(..)]` reads as "the SDK's attribute" rather than as the name of one crate. Diagnostics, docs, the RFC and the compile-fail fixtures follow; the `.stderr` expectations changed only in column offsets. Claude-Session: https://claude.ai/code/session_01HU1Bbw4eQp9kEEneXxDcKr --- ...nc-shape-for-target-directed-encryption.md | 4 +- .../stack-encrypt-derive/docs/attributes.md | 2 +- packages/stack-encrypt-derive/src/attrs.rs | 22 ++++---- packages/stack-encrypt-derive/src/decrypt.rs | 50 +++++++++---------- packages/stack-encrypt-derive/src/encrypt.rs | 14 +++--- packages/stack-encrypt-derive/src/lib.rs | 18 +++---- packages/stack-encrypt-derive/src/shape.rs | 36 ++++++------- packages/stack-encrypt/tests/derive.rs | 26 +++++----- .../tests/ui/decrypt_duplicate_from.rs | 6 +-- .../tests/ui/decrypt_duplicate_from.stderr | 6 +-- .../tests/ui/decrypt_mixed_modes.rs | 6 +-- .../tests/ui/decrypt_without_opened_field.rs | 2 +- .../ui/decrypt_without_opened_field.stderr | 2 +- .../tests/ui/default_with_context.rs | 2 +- .../stack-encrypt/tests/ui/drop_record.rs | 4 +- .../tests/ui/duplicate_plaintext.rs | 2 +- .../tests/ui/duplicate_plaintext.stderr | 6 +-- .../stack-encrypt/tests/ui/empty_context.rs | 4 +- .../tests/ui/empty_context.stderr | 6 +-- .../tests/ui/from_without_plaintext.rs | 2 +- .../tests/ui/from_without_plaintext.stderr | 8 +-- .../tests/ui/reference_plaintext.rs | 2 +- .../tests/ui/reference_plaintext.stderr | 6 +-- 23 files changed, 118 insertions(+), 118 deletions(-) diff --git a/docs/rfcs/0002-async-shape-for-target-directed-encryption.md b/docs/rfcs/0002-async-shape-for-target-directed-encryption.md index 2e4fa8a53..a00d68a19 100644 --- a/docs/rfcs/0002-async-shape-for-target-directed-encryption.md +++ b/docs/rfcs/0002-async-shape-for-target-directed-encryption.md @@ -561,8 +561,8 @@ rebuilds it with a struct literal; a record opened as a whole may leave it off and decrypt to whatever its ciphertext field opens to. The derives are named after the trait they emit, as serde's are, and the -attribute after the crate: `#[stack_encrypt(plaintext = ..)]`, -`#[stack_encrypt(from = .., context = "..", decrypt)]`. First shipped as +attribute after the crate: `#[stash(plaintext = ..)]`, +`#[stash(from = .., context = "..", decrypt)]`. First shipped as `#[derive(Encrypted, Decrypted)]` with `#[encrypted(source = ..)]`: the decrypt macro sat on the record but emitted `DecryptFrom for Plaintext`, a trait whose `Self` was not the annotated type, and the diff --git a/packages/stack-encrypt-derive/docs/attributes.md b/packages/stack-encrypt-derive/docs/attributes.md index 0b1c891c5..4a5fd0564 100644 --- a/packages/stack-encrypt-derive/docs/attributes.md +++ b/packages/stack-encrypt-derive/docs/attributes.md @@ -1,6 +1,6 @@ # Attributes -All attributes live under `#[stack_encrypt(...)]`. +All attributes live under `#[stash(...)]`. ## On the struct diff --git a/packages/stack-encrypt-derive/src/attrs.rs b/packages/stack-encrypt-derive/src/attrs.rs index 134699666..7835e205f 100644 --- a/packages/stack-encrypt-derive/src/attrs.rs +++ b/packages/stack-encrypt-derive/src/attrs.rs @@ -1,15 +1,15 @@ -//! Parsing of the `#[stack_encrypt(...)]` container and field attributes. +//! Parsing of the `#[stash(...)]` container and field attributes. use syn::{Attribute, Expr, LitStr, Member, Path, Result, Type}; -/// Container-level options, from `#[stack_encrypt(...)]` on the struct itself. +/// Container-level options, from `#[stash(...)]` on the struct itself. pub(crate) struct ContainerAttrs { /// Path to the `stack_encrypt` crate in the generated code. Defaults to - /// `::stack_encrypt`; overridden by `#[stack_encrypt(crate = "...")]` so the + /// `::stack_encrypt`; overridden by `#[stash(crate = "...")]` so the /// macros work through a re-export. pub(crate) krate: Path, /// The plaintext types this record is an encrypted form of, one impl - /// each, from repeated `#[stack_encrypt(plaintext = Type)]`. Empty means + /// each, from repeated `#[stash(plaintext = Type)]`. Empty means /// a single impl generic over the plaintext. pub(crate) plaintexts: Vec, } @@ -19,7 +19,7 @@ impl ContainerAttrs { let mut krate: Option = None; let mut plaintexts: Vec = Vec::new(); - for attr in attrs.iter().filter(|a| a.path().is_ident("stack_encrypt")) { + for attr in attrs.iter().filter(|a| a.path().is_ident("stash")) { attr.parse_nested_meta(|meta| { if meta.path.is_ident("crate") { let lit: LitStr = meta.value()?.parse()?; @@ -63,20 +63,20 @@ impl ContainerAttrs { } } -/// Field-level options, from `#[stack_encrypt(...)]` on a field. +/// Field-level options, from `#[stash(...)]` on a field. #[derive(Default)] pub(crate) struct FieldAttrs { - /// `#[stack_encrypt(context = "...")]`: derive this field under exactly this + /// `#[stash(context = "...")]`: derive this field under exactly this /// context instead of the one the caller passed for the record. pub(crate) context: Option, - /// `#[stack_encrypt(from = field)]` / `#[stack_encrypt(from = 0)]`: derive + /// `#[stash(from = field)]` / `#[stash(from = 0)]`: derive /// this field from one field of the plaintext rather than from the whole /// plaintext. pub(crate) from: Option, - /// `#[stack_encrypt(default)]` / `#[stack_encrypt(default = expr)]`: not derived; + /// `#[stash(default)]` / `#[stash(default = expr)]`: not derived; /// filled with `Default::default()` or the expression. pub(crate) default: Option>, - /// `#[stack_encrypt(decrypt)]`: decryption opens this field. + /// `#[stash(decrypt)]`: decryption opens this field. pub(crate) decrypt: bool, } @@ -84,7 +84,7 @@ impl FieldAttrs { pub(crate) fn parse(attrs: &[Attribute]) -> Result { let mut parsed = Self::default(); - for attr in attrs.iter().filter(|a| a.path().is_ident("stack_encrypt")) { + for attr in attrs.iter().filter(|a| a.path().is_ident("stash")) { attr.parse_nested_meta(|meta| { if meta.path.is_ident("context") { let context: LitStr = meta.value()?.parse()?; diff --git a/packages/stack-encrypt-derive/src/decrypt.rs b/packages/stack-encrypt-derive/src/decrypt.rs index ef09c358f..62e526e8e 100644 --- a/packages/stack-encrypt-derive/src/decrypt.rs +++ b/packages/stack-encrypt-derive/src/decrypt.rs @@ -20,7 +20,7 @@ pub(crate) fn derive(input: DeriveInput) -> Result { return Err(syn::Error::new_spanned( name, "DecryptInto needs to know which field decryption opens: mark it \ - `#[stack_encrypt(decrypt)]` (index terms are one-way and cannot be)", + `#[stash(decrypt)]` (index terms are one-way and cannot be)", )); } @@ -255,7 +255,7 @@ mod tests { #[test] fn an_opened_field_is_required() { let err = expand(parse_quote! { - #[stack_encrypt(plaintext = u32)] + #[stash(plaintext = u32)] struct Rec { c: StackCipherText, hm: EqualityTerm, @@ -268,11 +268,11 @@ mod tests { #[test] fn two_whole_fields_are_ambiguous() { let err = expand(parse_quote! { - #[stack_encrypt(plaintext = u32)] + #[stash(plaintext = u32)] struct Rec { - #[stack_encrypt(decrypt)] + #[stash(decrypt)] a: StackCipherText, - #[stack_encrypt(decrypt)] + #[stash(decrypt)] b: StackCipherText, } }) @@ -285,11 +285,11 @@ mod tests { #[test] fn mixed_modes_are_rejected() { let err = expand(parse_quote! { - #[stack_encrypt(plaintext = User)] + #[stash(plaintext = User)] struct Rec { - #[stack_encrypt(decrypt, from = a)] + #[stash(decrypt, from = a)] a: StackCipherText, - #[stack_encrypt(decrypt)] + #[stash(decrypt)] b: StackCipherText, } }) @@ -300,11 +300,11 @@ mod tests { #[test] fn duplicate_recovery_targets_are_rejected() { let err = expand(parse_quote! { - #[stack_encrypt(plaintext = User)] + #[stash(plaintext = User)] struct Rec { - #[stack_encrypt(decrypt, from = a)] + #[stash(decrypt, from = a)] a: StackCipherText, - #[stack_encrypt(decrypt, from = a)] + #[stash(decrypt, from = a)] b: StackCipherText, } }) @@ -317,7 +317,7 @@ mod tests { fn a_generic_plaintext_opens_the_one_field() { let expansion = expand(parse_quote! { struct Wrapped { - #[stack_encrypt(decrypt)] + #[stash(decrypt)] c: StackCipherText, hm: EqualityTerm, } @@ -340,7 +340,7 @@ mod tests { fn a_generic_plaintext_cannot_be_rebuilt_field_by_field() { let err = expand(parse_quote! { struct Row { - #[stack_encrypt(decrypt, from = age)] + #[stash(decrypt, from = age)] age: EncryptedAge, } }) @@ -354,9 +354,9 @@ mod tests { #[rustfmt::skip] fn whole_mode_opens_the_one_field() { let expansion = expand(parse_quote! { - #[stack_encrypt(plaintext = u32, plaintext = u64)] + #[stash(plaintext = u32, plaintext = u64)] struct EncryptedAge { - #[stack_encrypt(decrypt)] + #[stash(decrypt)] c: StackCipherText, hm: EqualityTerm, } @@ -380,13 +380,13 @@ mod tests { #[rustfmt::skip] fn by_field_mode_rebuilds_the_plaintext() { let expansion = expand(parse_quote! { - #[stack_encrypt(plaintext = User)] + #[stash(plaintext = User)] struct EncryptedUser { - #[stack_encrypt(decrypt, from = age, context = "users/age")] + #[stash(decrypt, from = age, context = "users/age")] age: EncryptedAge, - #[stack_encrypt(decrypt, from = email)] + #[stash(decrypt, from = email)] email: StackCipherText, - #[stack_encrypt(from = email, context = "users/email")] + #[stash(from = email, context = "users/email")] email_eq: EqualityTerm, } }) @@ -404,11 +404,11 @@ mod tests { #[test] fn tuple_plaintexts_are_rebuilt_by_index() { let expansion = expand(parse_quote! { - #[stack_encrypt(plaintext = Pair)] + #[stash(plaintext = Pair)] struct EncryptedPair { - #[stack_encrypt(decrypt, from = 0, context = "pair/0")] + #[stash(decrypt, from = 0, context = "pair/0")] a: StackCipherText, - #[stack_encrypt(decrypt, from = 1, context = "pair/1")] + #[stash(decrypt, from = 1, context = "pair/1")] b: StackCipherText, } }) @@ -425,11 +425,11 @@ mod tests { #[test] fn duplicate_recovery_targets_by_index_are_rejected() { let err = expand(parse_quote! { - #[stack_encrypt(plaintext = Pair)] + #[stash(plaintext = Pair)] struct Rec { - #[stack_encrypt(decrypt, from = 0)] + #[stash(decrypt, from = 0)] a: StackCipherText, - #[stack_encrypt(decrypt, from = 0)] + #[stash(decrypt, from = 0)] b: StackCipherText, } }) diff --git a/packages/stack-encrypt-derive/src/encrypt.rs b/packages/stack-encrypt-derive/src/encrypt.rs index 97f37608f..26a00815e 100644 --- a/packages/stack-encrypt-derive/src/encrypt.rs +++ b/packages/stack-encrypt-derive/src/encrypt.rs @@ -177,10 +177,10 @@ mod tests { #[rustfmt::skip] fn listed_sources_get_one_impl_each() { let expansion = expand(parse_quote! { - #[stack_encrypt(plaintext = i32, plaintext = i64)] + #[stash(plaintext = i32, plaintext = i64)] struct IntegerOrdOre { c: StackCipherText, - #[stack_encrypt(default = SchemaVersion::V3)] + #[stash(default = SchemaVersion::V3)] v: SchemaVersion, } }); @@ -198,11 +198,11 @@ mod tests { #[rustfmt::skip] fn row_fields_reach_into_the_source_under_their_own_context() { let expansion = expand(parse_quote! { - #[stack_encrypt(plaintext = User)] + #[stash(plaintext = User)] struct EncryptedUser { - #[stack_encrypt(from = age, context = "users/age")] + #[stash(from = age, context = "users/age")] age: EncryptedAge, - #[stack_encrypt(from = email, context = "users/email")] + #[stash(from = email, context = "users/email")] email: StackCipherText, } }); @@ -224,9 +224,9 @@ mod tests { #[test] fn tuple_plaintexts_are_reached_by_index() { let expansion = expand(parse_quote! { - #[stack_encrypt(plaintext = Pair)] + #[stash(plaintext = Pair)] struct EncryptedPair { - #[stack_encrypt(from = 1, context = "pair/1")] + #[stash(from = 1, context = "pair/1")] b: StackCipherText, } }); diff --git a/packages/stack-encrypt-derive/src/lib.rs b/packages/stack-encrypt-derive/src/lib.rs index 097887f4b..0c18aa990 100644 --- a/packages/stack-encrypt-derive/src/lib.rs +++ b/packages/stack-encrypt-derive/src/lib.rs @@ -20,9 +20,9 @@ //! //! /// An encrypted integer, queryable by equality and range. //! #[derive(EncryptFrom, DecryptInto)] -//! #[stack_encrypt(plaintext = u32)] +//! #[stash(plaintext = u32)] //! struct EncryptedAge { -//! #[stack_encrypt(decrypt)] +//! #[stash(decrypt)] //! c: StackCipherText, //! hm: EqualityTerm, //! ob: OreTerm, @@ -59,9 +59,9 @@ //! # use stack_encrypt::{DecryptInto, EncryptFrom, StackCipher, StackCipherText}; //! # use stack_kms::FakeDataKeySource; //! # #[derive(EncryptFrom, DecryptInto)] -//! # #[stack_encrypt(plaintext = u32)] +//! # #[stash(plaintext = u32)] //! # struct EncryptedAge { -//! # #[stack_encrypt(decrypt)] +//! # #[stash(decrypt)] //! # c: StackCipherText, //! # hm: EqualityTerm, //! # ob: OreTerm, @@ -73,11 +73,11 @@ //! } //! //! #[derive(EncryptFrom, DecryptInto)] -//! #[stack_encrypt(plaintext = User)] +//! #[stash(plaintext = User)] //! struct EncryptedUser { -//! #[stack_encrypt(from = age, context = "users/age", decrypt)] +//! #[stash(from = age, context = "users/age", decrypt)] //! age: EncryptedAge, -//! #[stack_encrypt(from = email, context = "users/email", decrypt)] +//! #[stash(from = email, context = "users/email", decrypt)] //! email: StackCipherText, //! } //! @@ -148,7 +148,7 @@ mod test_support; /// documentation](crate) for what the derive emits; the attributes it accepts /// are reproduced below. #[doc = include_str!("../docs/attributes.md")] -#[proc_macro_derive(EncryptFrom, attributes(stack_encrypt))] +#[proc_macro_derive(EncryptFrom, attributes(stash))] pub fn derive_encrypt_from(input: TokenStream) -> TokenStream { let input = parse_macro_input!(input as DeriveInput); encrypt::derive(input) @@ -160,7 +160,7 @@ pub fn derive_encrypt_from(input: TokenStream) -> TokenStream { /// `plaintext` type. See the [crate documentation](crate); the attributes it /// accepts are reproduced below. #[doc = include_str!("../docs/attributes.md")] -#[proc_macro_derive(DecryptInto, attributes(stack_encrypt))] +#[proc_macro_derive(DecryptInto, attributes(stash))] pub fn derive_decrypt_into(input: TokenStream) -> TokenStream { let input = parse_macro_input!(input as DeriveInput); decrypt::derive(input) diff --git a/packages/stack-encrypt-derive/src/shape.rs b/packages/stack-encrypt-derive/src/shape.rs index b13f8ef29..5a8daa812 100644 --- a/packages/stack-encrypt-derive/src/shape.rs +++ b/packages/stack-encrypt-derive/src/shape.rs @@ -17,7 +17,7 @@ pub(crate) struct Field { pub(crate) local: Ident, pub(crate) ty: Type, pub(crate) kind: Kind, - /// `#[stack_encrypt(decrypt)]`: decryption opens this field. + /// `#[stash(decrypt)]`: decryption opens this field. pub(crate) decrypt: bool, } @@ -26,10 +26,10 @@ pub(crate) struct Field { pub(crate) enum Kind { /// Derived from the source through the field type's own `EncryptFrom`. Derived { - /// `#[stack_encrypt(context = "...")]`: this field's context, overriding + /// `#[stash(context = "...")]`: this field's context, overriding /// the record's. context: Option, - /// `#[stack_encrypt(from = field)]` / `from = 0`: derived from one + /// `#[stash(from = field)]` / `from = 0`: derived from one /// field of the plaintext rather than the whole plaintext. from: Option, }, @@ -104,7 +104,7 @@ impl Record { return Err(syn::Error::new( field.from().map_or_else(Span::call_site, Spanned::span), "`from = ..` reaches into a field of the plaintext, so the plaintext type must \ - be named: add `#[stack_encrypt(plaintext = ..)]` to the struct", + be named: add `#[stash(plaintext = ..)]` to the struct", )); } } @@ -218,7 +218,7 @@ mod tests { fn all_default_is_rejected() { let err = parse(parse_quote! { struct Empty { - #[stack_encrypt(default)] + #[stash(default)] v: u8, } }) @@ -230,7 +230,7 @@ mod tests { fn from_needs_a_named_plaintext() { let err = parse(parse_quote! { struct Row { - #[stack_encrypt(from = age)] + #[stash(from = age)] age: EncryptedAge, } }) @@ -243,7 +243,7 @@ mod tests { let err = parse(parse_quote! { struct Rec { c: StackCipherText, - #[stack_encrypt(default, context = "x")] + #[stash(default, context = "x")] v: u8, } }) @@ -255,7 +255,7 @@ mod tests { fn unknown_attributes_are_rejected() { let err = parse(parse_quote! { struct Rec { - #[stack_encrypt(rename = "x")] + #[stash(rename = "x")] c: StackCipherText, } }) @@ -263,7 +263,7 @@ mod tests { assert!(err.to_string().contains("unsupported field attribute")); let err = parse(parse_quote! { - #[stack_encrypt(source = i32)] + #[stash(source = i32)] struct Rec { c: StackCipherText, } @@ -276,7 +276,7 @@ mod tests { fn a_literal_empty_context_is_rejected() { let err = parse(parse_quote! { struct Rec { - #[stack_encrypt(context = "")] + #[stash(context = "")] c: StackCipherText, } }) @@ -287,7 +287,7 @@ mod tests { #[test] fn a_reference_plaintext_is_rejected() { let err = parse(parse_quote! { - #[stack_encrypt(plaintext = &str)] + #[stash(plaintext = &str)] struct Rec { c: StackCipherText, } @@ -299,7 +299,7 @@ mod tests { #[test] fn a_repeated_plaintext_is_rejected() { let err = parse(parse_quote! { - #[stack_encrypt(plaintext = u32, plaintext = u32)] + #[stash(plaintext = u32, plaintext = u32)] struct Rec { c: StackCipherText, } @@ -311,11 +311,11 @@ mod tests { #[test] fn from_addresses_tuple_plaintexts_by_index() { let record = parse(parse_quote! { - #[stack_encrypt(plaintext = Pair)] + #[stash(plaintext = Pair)] struct Rec { - #[stack_encrypt(from = 0, context = "pair/0")] + #[stash(from = 0, context = "pair/0")] a: StackCipherText, - #[stack_encrypt(from = 1, context = "pair/1")] + #[stash(from = 1, context = "pair/1")] b: StackCipherText, } }) @@ -327,12 +327,12 @@ mod tests { #[test] fn fields_classify() { let record = parse(parse_quote! { - #[stack_encrypt(plaintext = User, plaintext = Admin)] + #[stash(plaintext = User, plaintext = Admin)] struct Row { - #[stack_encrypt(from = age, context = "users/age", decrypt)] + #[stash(from = age, context = "users/age", decrypt)] age: EncryptedAge, whole: RowTerm, - #[stack_encrypt(default = SchemaVersion::V3)] + #[stash(default = SchemaVersion::V3)] v: SchemaVersion, } }) diff --git a/packages/stack-encrypt/tests/derive.rs b/packages/stack-encrypt/tests/derive.rs index 629c423dc..1f323df76 100644 --- a/packages/stack-encrypt/tests/derive.rs +++ b/packages/stack-encrypt/tests/derive.rs @@ -19,9 +19,9 @@ use stack_encrypt::{DecryptInto, EncryptFrom, Error, StackCipherText}; /// The hand-written record in `target.rs`, derived: an encrypted `u32` /// stored as its ciphertext plus an equality term and an ORE term. #[derive(EncryptFrom, DecryptInto)] -#[stack_encrypt(plaintext = u32)] +#[stash(plaintext = u32)] struct EncryptedAge { - #[stack_encrypt(decrypt)] + #[stash(decrypt)] c: StackCipherText, hm: EqualityTerm, ob: OreTerm, @@ -51,7 +51,7 @@ async fn a_derived_record_is_the_hand_written_one() { /// decrypting to whatever the ciphertext field opens to. #[derive(EncryptFrom, DecryptInto)] struct SearchableText { - #[stack_encrypt(decrypt)] + #[stash(decrypt)] c: StackCipherText, hm: EqualityTerm, m: MatchTerm, @@ -110,9 +110,9 @@ async fn a_generic_plaintext_record_accepts_what_its_leaves_accept() { /// Listed plaintexts: one impl each, and nothing else is accepted. #[derive(EncryptFrom, DecryptInto)] -#[stack_encrypt(plaintext = u32, plaintext = String)] +#[stash(plaintext = u32, plaintext = String)] struct EncryptedValue { - #[stack_encrypt(decrypt)] + #[stash(decrypt)] c: StackCipherText, hm: EqualityTerm, } @@ -156,19 +156,19 @@ struct User { } #[derive(EncryptFrom, DecryptInto)] -#[stack_encrypt(plaintext = User)] +#[stash(plaintext = User)] struct EncryptedUser { /// A record inside a row: recursion, not a second mechanism. - #[stack_encrypt(from = age, context = "users/age", decrypt)] + #[stash(from = age, context = "users/age", decrypt)] age: EncryptedAge, - #[stack_encrypt(from = email, context = "users/email", decrypt)] + #[stash(from = email, context = "users/email", decrypt)] email: StackCipherText, /// A second field from the same plaintext field — a term alongside the /// ciphertext, not opened on decrypt. - #[stack_encrypt(from = email, context = "users/email")] + #[stash(from = email, context = "users/email")] email_eq: EqualityTerm, /// Not derived: filled in, never encrypted. - #[stack_encrypt(default = 3)] + #[stash(default = 3)] version: u8, } @@ -250,11 +250,11 @@ async fn a_row_field_opened_under_the_wrong_context_fails() { struct Reading(u32, String); #[derive(EncryptFrom, DecryptInto)] -#[stack_encrypt(plaintext = Reading)] +#[stash(plaintext = Reading)] struct EncryptedReading { - #[stack_encrypt(from = 0, context = "readings/value", decrypt)] + #[stash(from = 0, context = "readings/value", decrypt)] value: EncryptedAge, - #[stack_encrypt(from = 1, context = "readings/unit", decrypt)] + #[stash(from = 1, context = "readings/unit", decrypt)] unit: StackCipherText, } diff --git a/packages/stack-encrypt/tests/ui/decrypt_duplicate_from.rs b/packages/stack-encrypt/tests/ui/decrypt_duplicate_from.rs index a2cd8ab5d..78d055c8e 100644 --- a/packages/stack-encrypt/tests/ui/decrypt_duplicate_from.rs +++ b/packages/stack-encrypt/tests/ui/decrypt_duplicate_from.rs @@ -5,11 +5,11 @@ struct User { } #[derive(DecryptInto)] -#[stack_encrypt(plaintext = User)] +#[stash(plaintext = User)] struct Rec { - #[stack_encrypt(decrypt, from = a)] + #[stash(decrypt, from = a)] a: StackCipherText, - #[stack_encrypt(decrypt, from = a)] + #[stash(decrypt, from = a)] b: StackCipherText, } diff --git a/packages/stack-encrypt/tests/ui/decrypt_duplicate_from.stderr b/packages/stack-encrypt/tests/ui/decrypt_duplicate_from.stderr index e60f12625..982881b62 100644 --- a/packages/stack-encrypt/tests/ui/decrypt_duplicate_from.stderr +++ b/packages/stack-encrypt/tests/ui/decrypt_duplicate_from.stderr @@ -1,5 +1,5 @@ error: two `decrypt` fields would recover the same plaintext field `a` - --> tests/ui/decrypt_duplicate_from.rs:12:37 + --> tests/ui/decrypt_duplicate_from.rs:12:29 | -12 | #[stack_encrypt(decrypt, from = a)] - | ^ +12 | #[stash(decrypt, from = a)] + | ^ diff --git a/packages/stack-encrypt/tests/ui/decrypt_mixed_modes.rs b/packages/stack-encrypt/tests/ui/decrypt_mixed_modes.rs index eb470becf..79fd5753e 100644 --- a/packages/stack-encrypt/tests/ui/decrypt_mixed_modes.rs +++ b/packages/stack-encrypt/tests/ui/decrypt_mixed_modes.rs @@ -6,11 +6,11 @@ struct User { } #[derive(DecryptInto)] -#[stack_encrypt(plaintext = User)] +#[stash(plaintext = User)] struct Rec { - #[stack_encrypt(decrypt, from = a)] + #[stash(decrypt, from = a)] a: StackCipherText, - #[stack_encrypt(decrypt)] + #[stash(decrypt)] b: StackCipherText, } diff --git a/packages/stack-encrypt/tests/ui/decrypt_without_opened_field.rs b/packages/stack-encrypt/tests/ui/decrypt_without_opened_field.rs index a5195809e..42956d677 100644 --- a/packages/stack-encrypt/tests/ui/decrypt_without_opened_field.rs +++ b/packages/stack-encrypt/tests/ui/decrypt_without_opened_field.rs @@ -2,7 +2,7 @@ use stack_encrypt::sem::EqualityTerm; use stack_encrypt::{DecryptInto, StackCipherText}; #[derive(DecryptInto)] -#[stack_encrypt(plaintext = u32)] +#[stash(plaintext = u32)] struct Rec { c: StackCipherText, hm: EqualityTerm, diff --git a/packages/stack-encrypt/tests/ui/decrypt_without_opened_field.stderr b/packages/stack-encrypt/tests/ui/decrypt_without_opened_field.stderr index b9a8c7517..ead3bdf80 100644 --- a/packages/stack-encrypt/tests/ui/decrypt_without_opened_field.stderr +++ b/packages/stack-encrypt/tests/ui/decrypt_without_opened_field.stderr @@ -1,4 +1,4 @@ -error: DecryptInto needs to know which field decryption opens: mark it `#[stack_encrypt(decrypt)]` (index terms are one-way and cannot be) +error: DecryptInto needs to know which field decryption opens: mark it `#[stash(decrypt)]` (index terms are one-way and cannot be) --> tests/ui/decrypt_without_opened_field.rs:6:8 | 6 | struct Rec { diff --git a/packages/stack-encrypt/tests/ui/default_with_context.rs b/packages/stack-encrypt/tests/ui/default_with_context.rs index 482127caa..6b8bc43bf 100644 --- a/packages/stack-encrypt/tests/ui/default_with_context.rs +++ b/packages/stack-encrypt/tests/ui/default_with_context.rs @@ -3,7 +3,7 @@ use stack_encrypt::{EncryptFrom, StackCipherText}; #[derive(EncryptFrom)] struct Rec { c: StackCipherText, - #[stack_encrypt(default, context = "x")] + #[stash(default, context = "x")] v: u8, } diff --git a/packages/stack-encrypt/tests/ui/drop_record.rs b/packages/stack-encrypt/tests/ui/drop_record.rs index f2f498041..023a46a10 100644 --- a/packages/stack-encrypt/tests/ui/drop_record.rs +++ b/packages/stack-encrypt/tests/ui/drop_record.rs @@ -5,9 +5,9 @@ use stack_encrypt::{DecryptInto, EncryptFrom, StackCipherText}; // (including `ZeroizeOnDrop`) forbids. The restriction is documented; this // pins what the user sees. #[derive(EncryptFrom, DecryptInto)] -#[stack_encrypt(plaintext = u32)] +#[stash(plaintext = u32)] struct Rec { - #[stack_encrypt(decrypt)] + #[stash(decrypt)] c: StackCipherText, hm: EqualityTerm, } diff --git a/packages/stack-encrypt/tests/ui/duplicate_plaintext.rs b/packages/stack-encrypt/tests/ui/duplicate_plaintext.rs index 2ea636571..a6a0cce80 100644 --- a/packages/stack-encrypt/tests/ui/duplicate_plaintext.rs +++ b/packages/stack-encrypt/tests/ui/duplicate_plaintext.rs @@ -1,7 +1,7 @@ use stack_encrypt::{EncryptFrom, StackCipherText}; #[derive(EncryptFrom)] -#[stack_encrypt(plaintext = u32, plaintext = u32)] +#[stash(plaintext = u32, plaintext = u32)] struct Dup { c: StackCipherText, } diff --git a/packages/stack-encrypt/tests/ui/duplicate_plaintext.stderr b/packages/stack-encrypt/tests/ui/duplicate_plaintext.stderr index 1e6cf5570..d1bac0874 100644 --- a/packages/stack-encrypt/tests/ui/duplicate_plaintext.stderr +++ b/packages/stack-encrypt/tests/ui/duplicate_plaintext.stderr @@ -1,5 +1,5 @@ error: this `plaintext` is listed twice; each listed type gets one impl - --> tests/ui/duplicate_plaintext.rs:4:46 + --> tests/ui/duplicate_plaintext.rs:4:38 | -4 | #[stack_encrypt(plaintext = u32, plaintext = u32)] - | ^^^ +4 | #[stash(plaintext = u32, plaintext = u32)] + | ^^^ diff --git a/packages/stack-encrypt/tests/ui/empty_context.rs b/packages/stack-encrypt/tests/ui/empty_context.rs index a69beb749..7aea2b175 100644 --- a/packages/stack-encrypt/tests/ui/empty_context.rs +++ b/packages/stack-encrypt/tests/ui/empty_context.rs @@ -5,9 +5,9 @@ struct User { } #[derive(EncryptFrom)] -#[stack_encrypt(plaintext = User)] +#[stash(plaintext = User)] struct EncryptedUser { - #[stack_encrypt(from = email, context = "")] + #[stash(from = email, context = "")] email: StackCipherText, } diff --git a/packages/stack-encrypt/tests/ui/empty_context.stderr b/packages/stack-encrypt/tests/ui/empty_context.stderr index 6bf7f8c51..1e61f48df 100644 --- a/packages/stack-encrypt/tests/ui/empty_context.stderr +++ b/packages/stack-encrypt/tests/ui/empty_context.stderr @@ -1,5 +1,5 @@ error: an empty `context` is rejected when a value is encrypted: name the field (e.g. "users/email"), or drop the attribute to use the record's context - --> tests/ui/empty_context.rs:10:45 + --> tests/ui/empty_context.rs:10:37 | -10 | #[stack_encrypt(from = email, context = "")] - | ^^ +10 | #[stash(from = email, context = "")] + | ^^ diff --git a/packages/stack-encrypt/tests/ui/from_without_plaintext.rs b/packages/stack-encrypt/tests/ui/from_without_plaintext.rs index cf7acd89a..0b2494a09 100644 --- a/packages/stack-encrypt/tests/ui/from_without_plaintext.rs +++ b/packages/stack-encrypt/tests/ui/from_without_plaintext.rs @@ -2,7 +2,7 @@ use stack_encrypt::{EncryptFrom, StackCipherText}; #[derive(EncryptFrom)] struct Row { - #[stack_encrypt(from = age, context = "users/age")] + #[stash(from = age, context = "users/age")] age: StackCipherText, } diff --git a/packages/stack-encrypt/tests/ui/from_without_plaintext.stderr b/packages/stack-encrypt/tests/ui/from_without_plaintext.stderr index 18464b4e6..1a58260a1 100644 --- a/packages/stack-encrypt/tests/ui/from_without_plaintext.stderr +++ b/packages/stack-encrypt/tests/ui/from_without_plaintext.stderr @@ -1,5 +1,5 @@ -error: `from = ..` reaches into a field of the plaintext, so the plaintext type must be named: add `#[stack_encrypt(plaintext = ..)]` to the struct - --> tests/ui/from_without_plaintext.rs:5:28 +error: `from = ..` reaches into a field of the plaintext, so the plaintext type must be named: add `#[stash(plaintext = ..)]` to the struct + --> tests/ui/from_without_plaintext.rs:5:20 | -5 | #[stack_encrypt(from = age, context = "users/age")] - | ^^^ +5 | #[stash(from = age, context = "users/age")] + | ^^^ diff --git a/packages/stack-encrypt/tests/ui/reference_plaintext.rs b/packages/stack-encrypt/tests/ui/reference_plaintext.rs index ae90114fa..c27486005 100644 --- a/packages/stack-encrypt/tests/ui/reference_plaintext.rs +++ b/packages/stack-encrypt/tests/ui/reference_plaintext.rs @@ -1,7 +1,7 @@ use stack_encrypt::{EncryptFrom, StackCipherText}; #[derive(EncryptFrom)] -#[stack_encrypt(plaintext = &str)] +#[stash(plaintext = &str)] struct Text { c: StackCipherText, } diff --git a/packages/stack-encrypt/tests/ui/reference_plaintext.stderr b/packages/stack-encrypt/tests/ui/reference_plaintext.stderr index 0e8e233b0..3403bab41 100644 --- a/packages/stack-encrypt/tests/ui/reference_plaintext.stderr +++ b/packages/stack-encrypt/tests/ui/reference_plaintext.stderr @@ -1,5 +1,5 @@ error: `plaintext` must be an owned type: a reference plaintext has no lifetime the generated impl can name. Omit `plaintext` for an impl generic over the source, which accepts references too. - --> tests/ui/reference_plaintext.rs:4:29 + --> tests/ui/reference_plaintext.rs:4:21 | -4 | #[stack_encrypt(plaintext = &str)] - | ^^^^ +4 | #[stash(plaintext = &str)] + | ^^^^ From faf562f26f96ef880eac22123027f5cac6171109 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Fri, 28 Aug 2026 16:18:26 +1000 Subject: [PATCH 459/686] feat(stack-encrypt): derive DecryptInto finds the ciphertext field from the field types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `#[stash(decrypt)]` was required on every record to say which field decryption opens, although in every real shape the answer is forced: the one `StackCipherText` (or nested record) among the one-way index terms. The derive cannot see trait impls, so the decision moves into the type system instead: - `Decryptable` (`const DECRYPTABLE: bool`) says whether a type is a ciphertext or a term. `StackCipherText` is; the `sem` terms are not; `Vec` / `Option` follow `S`; `#[derive(EncryptFrom)]` emits it for a record as the OR of its derived fields, so records nest in rows without ceremony. - `DecryptField` opens a field (`Some`) or passes over it (`None`); `#[derive(DecryptInto)]` emits it for every record. - With no `decrypt` attribute, the derive bounds every derived field on `DecryptField`, asks each in turn, and requires exactly one to be `Decryptable` — for the record as a whole, or per plaintext field for a `from = ..` row — through a `const` assertion: at the definition for a concrete record, at first use for a generic one. Too few, too many, or a field type that is not `Decryptable` is a compile error at the offending field. - `decrypt` remains as the override for what the types cannot settle (two ciphertexts, an opaque field type); once any field is marked only the marked fields are considered, and nothing need be `Decryptable`. - A record mixing `from` and whole-plaintext fields with no `decrypt` is rejected as ambiguous rather than guessed. `Error::NotOpened` covers a third-party `DecryptField` that claims `DECRYPTABLE` and then returns `None`; the target-module docs show a third-party term implementing both traits. Also finishes the `stash` attribute rename in the `target` module docs, which the previous commit's path filter skipped. Claude-Session: https://claude.ai/code/session_01HU1Bbw4eQp9kEEneXxDcKr --- ...nc-shape-for-target-directed-encryption.md | 5 +- .../stack-encrypt-derive/docs/attributes.md | 20 +- packages/stack-encrypt-derive/src/decrypt.rs | 653 ++++++++++++++++-- packages/stack-encrypt-derive/src/encrypt.rs | 59 +- packages/stack-encrypt-derive/src/lib.rs | 17 +- packages/stack-encrypt/src/cipher.rs | 6 + packages/stack-encrypt/src/lib.rs | 5 +- packages/stack-encrypt/src/sem/mod.rs | 36 +- packages/stack-encrypt/src/target/mod.rs | 161 ++++- packages/stack-encrypt/tests/derive.rs | 64 +- .../tests/ui/decrypt_ambiguous_shape.rs | 16 + .../tests/ui/decrypt_ambiguous_shape.stderr | 5 + .../tests/ui/decrypt_field_not_decryptable.rs | 13 + .../ui/decrypt_field_not_decryptable.stderr | 14 + .../tests/ui/decrypt_no_decryptable_field.rs | 11 + .../ui/decrypt_no_decryptable_field.stderr | 5 + ... => decrypt_several_decryptable_fields.rs} | 5 +- .../decrypt_several_decryptable_fields.stderr | 5 + .../ui/decrypt_several_recover_one_field.rs | 16 + .../decrypt_several_recover_one_field.stderr | 5 + .../ui/decrypt_without_opened_field.stderr | 5 - 21 files changed, 1012 insertions(+), 114 deletions(-) create mode 100644 packages/stack-encrypt/tests/ui/decrypt_ambiguous_shape.rs create mode 100644 packages/stack-encrypt/tests/ui/decrypt_ambiguous_shape.stderr create mode 100644 packages/stack-encrypt/tests/ui/decrypt_field_not_decryptable.rs create mode 100644 packages/stack-encrypt/tests/ui/decrypt_field_not_decryptable.stderr create mode 100644 packages/stack-encrypt/tests/ui/decrypt_no_decryptable_field.rs create mode 100644 packages/stack-encrypt/tests/ui/decrypt_no_decryptable_field.stderr rename packages/stack-encrypt/tests/ui/{decrypt_without_opened_field.rs => decrypt_several_decryptable_fields.rs} (60%) create mode 100644 packages/stack-encrypt/tests/ui/decrypt_several_decryptable_fields.stderr create mode 100644 packages/stack-encrypt/tests/ui/decrypt_several_recover_one_field.rs create mode 100644 packages/stack-encrypt/tests/ui/decrypt_several_recover_one_field.stderr delete mode 100644 packages/stack-encrypt/tests/ui/decrypt_without_opened_field.stderr diff --git a/docs/rfcs/0002-async-shape-for-target-directed-encryption.md b/docs/rfcs/0002-async-shape-for-target-directed-encryption.md index a00d68a19..72a87fdbe 100644 --- a/docs/rfcs/0002-async-shape-for-target-directed-encryption.md +++ b/docs/rfcs/0002-async-shape-for-target-directed-encryption.md @@ -562,7 +562,10 @@ off and decrypt to whatever its ciphertext field opens to. The derives are named after the trait they emit, as serde's are, and the attribute after the crate: `#[stash(plaintext = ..)]`, -`#[stash(from = .., context = "..", decrypt)]`. First shipped as +`#[stash(from = .., context = "..")]`. Which field decryption opens is not +an attribute but a property of the field types (`Decryptable`), checked at +compile time to be exactly one; `decrypt` is the override for records the +types cannot settle. First shipped as `#[derive(Encrypted, Decrypted)]` with `#[encrypted(source = ..)]`: the decrypt macro sat on the record but emitted `DecryptFrom for Plaintext`, a trait whose `Self` was not the annotated type, and the diff --git a/packages/stack-encrypt-derive/docs/attributes.md b/packages/stack-encrypt-derive/docs/attributes.md index 4a5fd0564..4e07aaaa8 100644 --- a/packages/stack-encrypt-derive/docs/attributes.md +++ b/packages/stack-encrypt-derive/docs/attributes.md @@ -25,12 +25,30 @@ must name it: the plaintext is rebuilt with a struct literal. | `context = "..."` | Derive this field under exactly this context rather than the one the caller passed for the record. A query-side term built under the same literal matches it. Must not be empty. | | `from = field` / `from = 0` | Derive this field from `plaintext.field` (or `plaintext.0` for a tuple struct) rather than from the whole plaintext. Needs `plaintext = ..` on the struct. | | `default` / `default = expr` | Not derived: filled with `Default::default()` or `expr`. Never encrypted, never authenticated. | -| `decrypt` | Decryption opens this field (`DecryptInto` only). One field opened as the whole plaintext, or several with `from = ..` rebuilding the plaintext field by field. | +| `decrypt` | Decryption opens this field (`DecryptInto` only). Needed only when the field types cannot decide it — see below. | The record's own context reaches every derived field that has no `context` of its own; if every field has one, the record's context is unused and the caller may pass `()`. +## Which field decryption opens + +`DecryptInto` does not need to be told: every field type says whether it is a +ciphertext or a one-way index term (`Decryptable`), and the derive requires +exactly one ciphertext — among all derived fields for a record, or among the +fields derived from each plaintext field (`from = ..`) for a row. Too few or +too many is a compile error at the record's definition (at its first use, if +the record is generic). A derived record is itself `Decryptable` if any of +its fields is, so records nest in rows without ceremony; a type of your own +implements `Decryptable` and `DecryptField` by hand. + +`decrypt` is the override for the shapes the types cannot settle: two +ciphertexts of which one is to be opened, or a field type that is not +`Decryptable`. Once any field is marked, only the marked fields are +considered — one opened as the whole plaintext, or several with `from = ..` +rebuilding the plaintext field by field — and the field types need not be +`Decryptable`. + `DecryptInto` consumes the record, moving each opened field out of `self`, so the record must not implement `Drop` (including via `ZeroizeOnDrop`); wrap the fields that need zeroizing instead. diff --git a/packages/stack-encrypt-derive/src/decrypt.rs b/packages/stack-encrypt-derive/src/decrypt.rs index 62e526e8e..0664ca879 100644 --- a/packages/stack-encrypt-derive/src/decrypt.rs +++ b/packages/stack-encrypt-derive/src/decrypt.rs @@ -1,29 +1,461 @@ //! Expansion of `#[derive(DecryptInto)]`. +//! +//! Which field decryption opens is, by default, not the derive's decision +//! but the type system's: every candidate field's type says through +//! `Decryptable` whether it is a ciphertext or a one-way term, a `const` +//! assertion requires exactly one ciphertext (per plaintext field, for a +//! row), and the body asks each field through `DecryptField`, taking the one +//! answer. `#[stash(decrypt)]` switches the record to the explicit mode, in +//! which only the marked fields are considered and the field types need not +//! be `Decryptable`. use std::collections::HashSet; -use proc_macro2::TokenStream; -use quote::quote; +use proc_macro2::{Span, TokenStream}; +use quote::{quote, quote_spanned, ToTokens}; use syn::spanned::Spanned; -use syn::{parse_quote, DeriveInput, Ident, Member, Path, PathArguments, Result, Type}; +use syn::{ + parse_quote, parse_quote_spanned, DeriveInput, Ident, LitStr, Member, Path, PathArguments, + Result, Type, +}; use crate::shape::{zip_fields, Field, Record}; pub(crate) fn derive(input: DeriveInput) -> Result { let record = Record::parse(&input)?; let krate = &record.krate; + + let field_impl = decrypt_field_impl(&input, krate); + let impls = if record.fields.iter().any(|f| f.decrypt) { + explicit(&input, &record)? + } else { + automatic(&input, &record)? + }; + + Ok(quote!(#impls #field_impl)) +} + +// ============================================================================= +// Shared +// ============================================================================= + +/// `impl DecryptInto> for Record` around `body`. +fn impl_block( + krate: &Path, + name: &Ident, + ty_generics: &syn::TypeGenerics<'_>, + impl_generics: &syn::ImplGenerics<'_>, + where_clause: Option<&syn::WhereClause>, + plaintext: &Type, + body: TokenStream, +) -> TokenStream { + quote! { + #[automatically_derived] + impl #impl_generics #krate::target::DecryptInto<#plaintext, #krate::StackCipher<__K>> + for #name #ty_generics #where_clause + { + fn decrypt_into<'__a, '__c, __Ctx>( + self, + __cipher: &'__a #krate::StackCipher<__K>, + __context: __Ctx, + ) -> #krate::target::Pending<'__a, #plaintext, __K> + where + __Ctx: #krate::target::DecryptContext<'__c>, + Self: '__a, + #plaintext: '__a, + { + #body + } + } + } +} + +/// `impl DecryptField<__P, __C> for Record`: a derived record is a field of +/// a larger one, opened through its own `DecryptInto`. +fn decrypt_field_impl(input: &DeriveInput, krate: &Path) -> TokenStream { let name = &input.ident; let (_, ty_generics, _) = input.generics.split_for_impl(); + let mut generics = input.generics.clone(); + generics.params.push(parse_quote!(__P)); + generics.params.push(parse_quote!(__C)); + generics.make_where_clause().predicates.push(parse_quote! { + __C: #krate::target::DecryptTarget + }); + generics.make_where_clause().predicates.push(parse_quote! { + Self: #krate::target::DecryptInto<__P, __C> + }); + let (impl_generics, _, where_clause) = generics.split_for_impl(); + quote! { + #[automatically_derived] + impl #impl_generics #krate::target::DecryptField<__P, __C> for #name #ty_generics + #where_clause + { + fn decrypt_field<'__a, '__c, __Ctx>( + self, + __cipher: &'__a __C, + __context: __Ctx, + ) -> ::core::option::Option<<__C as #krate::target::DecryptTarget>::Output<'__a, __P>> + where + __Ctx: #krate::target::DecryptContext<'__c>, + Self: '__a, + __P: '__a, + { + ::core::option::Option::Some( + >::decrypt_into( + self, __cipher, __context, + ), + ) + } + } + } +} - let opened: Vec<&Field> = record.fields.iter().filter(|f| f.decrypt).collect(); - if opened.is_empty() { +fn context_for(field: &Field) -> TokenStream { + match field.context() { + Some(literal) => quote!(#literal), + None => quote!(__context), + } +} + +/// The plaintext type as a struct-literal path: `User` becomes `User::`. +fn struct_literal_path(plaintext: &Type) -> Result { + let Type::Path(type_path) = plaintext else { return Err(syn::Error::new_spanned( - name, - "DecryptInto needs to know which field decryption opens: mark it \ - `#[stash(decrypt)]` (index terms are one-way and cannot be)", + plaintext, + "field-by-field decryption rebuilds the plaintext as a struct literal, so \ + `plaintext` must name a struct", )); + }; + if type_path.qself.is_some() { + return Err(syn::Error::new_spanned( + plaintext, + "field-by-field decryption rebuilds the plaintext as a struct literal, so \ + `plaintext` must name a struct directly, not through a qualified path", + )); + } + let mut path = type_path.path.clone(); + for segment in &mut path.segments { + if let PathArguments::AngleBracketed(args) = &mut segment.arguments { + args.colon2_token = Some(Default::default()); + } + } + Ok(path) +} + +// ============================================================================= +// Automatic mode: the type system picks the field +// ============================================================================= + +/// The shape of a record with no `decrypt` attribute, from its `from`s. +enum Auto<'a> { + /// No derived field has a `from`: one of them is the whole plaintext's + /// ciphertext. + Whole(Vec<&'a Field>), + /// Every derived field has a `from`: each plaintext field is recovered by + /// one of the fields derived from it. + ByField(Vec>), +} + +/// The fields derived from one field of the plaintext. +struct Group<'a> { + from: &'a Member, + fields: Vec<&'a Field>, +} + +impl<'a> Auto<'a> { + fn classify(record: &'a Record, name: &Ident) -> Result { + let candidates: Vec<&Field> = record.fields.iter().filter(|f| f.is_derived()).collect(); + let with_from = candidates.iter().filter(|f| f.from().is_some()).count(); + if with_from == 0 { + return Ok(Auto::Whole(candidates)); + } + if with_from != candidates.len() { + return Err(syn::Error::new_spanned( + name, + "some derived fields name a plaintext field (`from = ..`) and some do not, so it \ + is ambiguous whether decryption opens the record as a whole or rebuilds the \ + plaintext field by field: mark the fields decryption opens `#[stash(decrypt)]`", + )); + } + + let mut groups: Vec> = Vec::new(); + for field in candidates { + let from = field + .from() + .unwrap_or_else(|| unreachable!("counted above")); + match groups.iter_mut().find(|g| g.from == from) { + Some(group) => group.fields.push(field), + None => groups.push(Group { + from, + fields: vec![field], + }), + } + } + Ok(Auto::ByField(groups)) + } +} + +fn automatic(input: &DeriveInput, record: &Record) -> Result { + let krate = &record.krate; + let name = &input.ident; + let (_, ty_generics, _) = input.generics.split_for_impl(); + let auto = Auto::classify(record, name)?; + + // The one-ciphertext check: at the definition for a concrete record, at + // the first use for a generic one (a `const _` cannot name the record's + // parameters, and an inline `const` is evaluated per instantiation). + let checks = match &auto { + Auto::Whole(fields) => check(krate, name, None, fields), + Auto::ByField(groups) => { + let each = groups + .iter() + .map(|g| check(krate, name, Some(g.from), &g.fields)); + quote!(#(#each)*) + } + }; + let (definition_check, body_check) = if input.generics.params.is_empty() { + (quote!(const _: () = { #checks };), TokenStream::new()) + } else { + (TokenStream::new(), quote!(let () = const { #checks };)) + }; + + let destructure = { + let candidates = match &auto { + Auto::Whole(fields) => fields.clone(), + Auto::ByField(groups) => groups + .iter() + .flat_map(|g| g.fields.iter().copied()) + .collect(), + }; + let bind = candidates.iter().map(|f| { + let member = &f.member; + let local = &f.local; + quote!(#member: #local) + }); + quote! { + let Self { #(#bind,)* .. } = self; + let __context = &__context; + } + }; + + if record.plaintexts.is_empty() { + // One impl, generic over the plaintext: the record decrypts to + // whatever its one ciphertext field decrypts to. `Record::parse` has + // rejected `from` without a named plaintext, so this is whole mode. + let Auto::Whole(fields) = &auto else { + unreachable!("`from` without a named plaintext is rejected by `Record::parse`") + }; + let plaintext: Type = parse_quote!(__P); + let mut generics = input.generics.clone(); + generics.params.push(parse_quote!(__P)); + generics.params.push(parse_quote!(__K)); + push_field_bounds(&mut generics, krate, fields, &plaintext); + let (impl_generics, _, where_clause) = generics.split_for_impl(); + let open = open_one(krate, name, None, fields, &plaintext); + let body = quote!(#body_check #destructure #open); + let block = impl_block( + krate, + name, + &ty_generics, + &impl_generics, + where_clause, + &plaintext, + body, + ); + return Ok(quote!(#block #definition_check)); + } + + let impls = record + .plaintexts + .iter() + .map(|plaintext| { + let mut generics = input.generics.clone(); + generics.params.push(parse_quote!(__K)); + let open = match &auto { + Auto::Whole(fields) => { + push_field_bounds(&mut generics, krate, fields, plaintext); + open_one(krate, name, None, fields, plaintext) + } + Auto::ByField(groups) => by_group_body(krate, name, groups, plaintext)?, + }; + let (impl_generics, _, where_clause) = generics.split_for_impl(); + let body = quote!(#body_check #destructure #open); + Ok(impl_block( + krate, + name, + &ty_generics, + &impl_generics, + where_clause, + plaintext, + body, + )) + }) + .collect::>>()?; + + Ok(quote!(#(#impls)* #definition_check)) +} + +/// `FieldTy: DecryptField>` for every candidate +/// field, so a record's impl exists for exactly the plaintexts its ciphertext +/// field opens to. +fn push_field_bounds( + generics: &mut syn::Generics, + krate: &Path, + fields: &[&Field], + plaintext: &Type, +) { + let predicates = &mut generics.make_where_clause().predicates; + for field in fields { + let ty = &field.ty; + // Spanned at the field type, so a type that cannot be a field of an + // automatically decrypted record is reported there. + predicates.push(parse_quote_spanned! {ty.span()=> + #ty: #krate::target::DecryptField<#plaintext, #krate::StackCipher<__K>> + }); + } +} + +/// The `const` assertion that exactly one of `fields` is `Decryptable`; +/// `from` names the plaintext field they recover, for the message. +fn check(krate: &Path, name: &Ident, from: Option<&Member>, fields: &[&Field]) -> TokenStream { + let terms = fields.iter().map(|field| { + let ty = &field.ty; + // Spanned at the field type: a type that is not `Decryptable` is + // reported there, not at the derive. + quote_spanned!(ty.span()=> + (<#ty as #krate::target::Decryptable>::DECRYPTABLE as usize)) + }); + let (none, several) = match from { + None => ( + format!( + "`{name}` has no decryptable field: every derived field is a one-way index \ + term, so there is nothing for DecryptInto to open" + ), + format!( + "`{name}` has several decryptable fields: mark the one decryption opens \ + `#[stash(decrypt)]`" + ), + ), + Some(from) => { + let from = from.to_token_stream(); + ( + format!( + "no field of `{name}` can recover the plaintext field `{from}`: every field \ + derived from it is a one-way index term" + ), + format!( + "several fields of `{name}` are derived from the plaintext field `{from}` and \ + decryptable: mark the one decryption opens `#[stash(decrypt)]`" + ), + ) + } + }; + let none = LitStr::new(&none, Span::call_site()); + let several = LitStr::new(&several, Span::call_site()); + // A `let`, not a nested `const` item: an item could not see the + // record's generics from inside an inline `const`. + quote! { + { + let __decryptable: usize = 0 #(#terms)*; + ::core::assert!(__decryptable >= 1, #none); + ::core::assert!(__decryptable <= 1, #several); + } + } +} + +/// The one `Some` among the fields' `decrypt_field`s, as a pending of +/// `plaintext` (`_` when it is inferred from a struct literal). +fn open_one( + krate: &Path, + name: &Ident, + from: Option<&Member>, + fields: &[&Field], + plaintext: &Type, +) -> TokenStream { + let mut calls = fields.iter().map(|field| { + let ty = &field.ty; + let local = &field.local; + let context = match field.context() { + Some(literal) => quote!(#literal), + None => quote!(::core::clone::Clone::clone(__context)), + }; + quote! { + <#ty as #krate::target::DecryptField<#plaintext, #krate::StackCipher<__K>>>::decrypt_field( + #local, __cipher, #context, + ) + } + }); + let first = calls + .next() + .unwrap_or_else(|| unreachable!("a group has at least one field")); + let chain = calls.fold( + first, + |chain, call| quote!(::core::option::Option::or_else(#chain, move || #call)), + ); + let what = match from { + None => format!("`{name}`"), + Some(from) => format!( + "the plaintext field `{}` of `{name}`", + from.to_token_stream() + ), + }; + let message = LitStr::new( + &format!("exactly one field of {what} is decryptable, checked at compile time"), + Span::call_site(), + ); + quote!(::core::option::Option::expect(#chain, #message)) +} + +/// Each group's opened pending, zipped into one and mapped into a struct +/// literal of the plaintext. +fn by_group_body( + krate: &Path, + name: &Ident, + groups: &[Group<'_>], + plaintext: &Type, +) -> Result { + let literal = struct_literal_path(plaintext)?; + let inferred: Type = parse_quote!(_); + + let locals: Vec = (0..groups.len()) + .map(|index| Ident::new(&format!("__group_{index}"), Span::call_site())) + .collect(); + let opens = groups.iter().zip(&locals).map(|(group, local)| { + let open = open_one(krate, name, Some(group.from), &group.fields, &inferred); + quote!(let #local = #open;) + }); + + let mut chain = TokenStream::new(); + let mut pattern = TokenStream::new(); + for (index, local) in locals.iter().enumerate() { + if index == 0 { + chain = quote!(#local); + pattern = quote!(#local); + } else { + chain = quote!(#chain.zip(#local)); + pattern = quote!((#pattern, #local)); + } } + let assign = groups.iter().zip(&locals).map(|(group, local)| { + let from = group.from; + quote!(#from: #local) + }); + + Ok(quote! { + #(#opens)* + #chain.map(|#pattern| #literal { #(#assign),* }) + }) +} + +// ============================================================================= +// Explicit mode: `#[stash(decrypt)]` names the fields +// ============================================================================= +fn explicit(input: &DeriveInput, record: &Record) -> Result { + let krate = &record.krate; + let name = &input.ident; + let (_, ty_generics, _) = input.generics.split_for_impl(); + + let opened: Vec<&Field> = record.fields.iter().filter(|f| f.decrypt).collect(); let mode = Mode::classify(opened, name)?; if record.plaintexts.is_empty() { @@ -88,37 +520,6 @@ pub(crate) fn derive(input: DeriveInput) -> Result { Ok(quote!(#(#impls)*)) } -/// `impl DecryptInto> for Record` around `body`. -fn impl_block( - krate: &Path, - name: &Ident, - ty_generics: &syn::TypeGenerics<'_>, - impl_generics: &syn::ImplGenerics<'_>, - where_clause: Option<&syn::WhereClause>, - plaintext: &Type, - body: TokenStream, -) -> TokenStream { - quote! { - #[automatically_derived] - impl #impl_generics #krate::target::DecryptInto<#plaintext, #krate::StackCipher<__K>> - for #name #ty_generics #where_clause - { - fn decrypt_into<'__a, '__c, __Ctx>( - self, - __cipher: &'__a #krate::StackCipher<__K>, - __context: __Ctx, - ) -> #krate::target::Pending<'__a, #plaintext, __K> - where - __Ctx: #krate::target::DecryptContext<'__c>, - Self: '__a, - #plaintext: '__a, - { - #body - } - } - } -} - enum Mode<'a> { /// One field is the whole plaintext's ciphertext: decrypting the record is /// decrypting that field. @@ -170,13 +571,6 @@ impl<'a> Mode<'a> { } } -fn context_for(field: &Field) -> TokenStream { - match field.context() { - Some(literal) => quote!(#literal), - None => quote!(__context), - } -} - fn whole_body(krate: &Path, field: &Field, plaintext: &Type) -> TokenStream { let ty = &field.ty; let member = &field.member; @@ -218,31 +612,6 @@ fn by_field_body(krate: &Path, fields: &[&Field], plaintext: &Type) -> Result` becomes `User::`. -fn struct_literal_path(plaintext: &Type) -> Result { - let Type::Path(type_path) = plaintext else { - return Err(syn::Error::new_spanned( - plaintext, - "field-by-field decryption rebuilds the plaintext as a struct literal, so \ - `plaintext` must name a struct", - )); - }; - if type_path.qself.is_some() { - return Err(syn::Error::new_spanned( - plaintext, - "field-by-field decryption rebuilds the plaintext as a struct literal, so \ - `plaintext` must name a struct directly, not through a qualified path", - )); - } - let mut path = type_path.path.clone(); - for segment in &mut path.segments { - if let PathArguments::AngleBracketed(args) = &mut segment.arguments { - args.colon2_token = Some(Default::default()); - } - } - Ok(path) -} - #[cfg(test)] mod tests { use super::*; @@ -253,16 +622,150 @@ mod tests { } #[test] - fn an_opened_field_is_required() { - let err = expand(parse_quote! { + #[rustfmt::skip] + fn unmarked_fields_are_chosen_by_their_types() { + let expansion = expand(parse_quote! { #[stash(plaintext = u32)] struct Rec { c: StackCipherText, hm: EqualityTerm, + #[stash(default)] + v: u8, + } + }) + .unwrap(); + // Every derived field is a candidate, bounded and asked in turn; the + // `default` field is neither. + assert_contains(&expansion, quote! { + where + StackCipherText: ::stack_encrypt::target::DecryptField>, + EqualityTerm: ::stack_encrypt::target::DecryptField> + }); + assert_contains(&expansion, quote!(let Self { c: __field_0, hm: __field_1, .. } = self;)); + assert_contains(&expansion, quote! { + ::core::option::Option::or_else( + >>::decrypt_field( + __field_0, __cipher, ::core::clone::Clone::clone(__context), + ), + move || >>::decrypt_field( + __field_1, __cipher, ::core::clone::Clone::clone(__context), + ) + ) + }); + // Exactly one ciphertext, checked at the definition. + assert_contains(&expansion, quote! { + const _: () = { + { + let __decryptable: usize = 0 + + (::DECRYPTABLE as usize) + + (::DECRYPTABLE as usize); + ::core::assert!(__decryptable >= 1, "`Rec` has no decryptable field: every derived field is a one-way index term, so there is nothing for DecryptInto to open"); + ::core::assert!(__decryptable <= 1, "`Rec` has several decryptable fields: mark the one decryption opens `#[stash(decrypt)]`"); + } + }; + }); + assert_lacks(&expansion, quote!(let () = const)); + assert_lacks(&expansion, quote!()); + } + + #[test] + fn a_generic_record_is_checked_at_its_use() { + let expansion = expand(parse_quote! { + struct Tagged { + c: StackCipherText, + ob: OreTerm, + } + }) + .unwrap(); + assert_contains(&expansion, quote!(let () = const)); + assert_lacks(&expansion, quote!(const _: ())); + } + + #[test] + #[rustfmt::skip] + fn unmarked_from_fields_are_grouped_by_plaintext_field() { + let expansion = expand(parse_quote! { + #[stash(plaintext = User)] + struct Row { + #[stash(from = age, context = "users/age")] + age: EncryptedAge, + #[stash(from = email, context = "users/email")] + email: StackCipherText, + #[stash(from = email, context = "users/email")] + email_eq: EqualityTerm, + } + }) + .unwrap(); + // One check and one opening per plaintext field; the two `email` + // fields are asked in turn. + assert_contains(&expansion, quote!("no field of `Row` can recover the plaintext field `age`: every field derived from it is a one-way index term")); + assert_contains(&expansion, quote!("several fields of `Row` are derived from the plaintext field `email` and decryptable: mark the one decryption opens `#[stash(decrypt)]`")); + assert_contains(&expansion, quote! { + let __group_1 = ::core::option::Option::expect( + ::core::option::Option::or_else( + >>::decrypt_field( + __field_1, __cipher, "users/email", + ), + move || >>::decrypt_field( + __field_2, __cipher, "users/email", + ) + ), + "exactly one field of the plaintext field `email` of `Row` is decryptable, checked at compile time" + ); + }); + assert_contains(&expansion, quote!(__group_0.zip(__group_1).map(|(__group_0, __group_1)| User { age: __group_0, email: __group_1 }))); + } + + #[test] + fn a_record_mixing_from_and_whole_fields_must_be_marked() { + let err = expand(parse_quote! { + #[stash(plaintext = User)] + struct Row { + #[stash(from = email)] + email: StackCipherText, + hm: EqualityTerm, } }) .unwrap_err(); - assert!(err.to_string().contains("which field decryption opens")); + assert!(err + .to_string() + .contains("ambiguous whether decryption opens")); + } + + #[test] + fn every_record_is_a_decrypt_field() { + let expansion = expand(parse_quote! { + #[stash(plaintext = u32)] + struct Rec { + #[stash(decrypt)] + c: StackCipherText, + } + }) + .unwrap(); + assert_contains( + &expansion, + quote! { + impl<__P, __C> ::stack_encrypt::target::DecryptField<__P, __C> for Rec + where + __C: ::stack_encrypt::target::DecryptTarget, + Self: ::stack_encrypt::target::DecryptInto<__P, __C> + }, + ); + } + + #[test] + fn marking_a_field_turns_the_types_off() { + let expansion = expand(parse_quote! { + #[stash(plaintext = u32)] + struct Rec { + #[stash(decrypt)] + c: StackCipherText, + hm: EqualityTerm, + } + }) + .unwrap(); + assert_lacks(&expansion, quote!(Decryptable)); + assert_lacks(&expansion, quote!(DecryptField < u32)); } #[test] @@ -373,7 +876,7 @@ mod tests { ) }); assert_lacks(&expansion, quote!(hm)); - assert_lacks(&expansion, quote!(__P)); + assert_lacks(&expansion, quote!(DecryptInto<__P, ::stack_encrypt::StackCipher)); } #[test] diff --git a/packages/stack-encrypt-derive/src/encrypt.rs b/packages/stack-encrypt-derive/src/encrypt.rs index 26a00815e..73b9502e6 100644 --- a/packages/stack-encrypt-derive/src/encrypt.rs +++ b/packages/stack-encrypt-derive/src/encrypt.rs @@ -1,7 +1,8 @@ //! Expansion of `#[derive(EncryptFrom)]`. use proc_macro2::TokenStream; -use quote::quote; +use quote::{quote, quote_spanned}; +use syn::spanned::Spanned; use syn::{parse_quote, DeriveInput, Ident, Path, Result, Type}; use crate::shape::{zip_fields, Field, Kind, Record}; @@ -12,6 +13,8 @@ pub(crate) fn derive(input: DeriveInput) -> Result { let name = &input.ident; let (_, ty_generics, _) = input.generics.split_for_impl(); + let decryptable = decryptable_impl(&input, &record); + if record.plaintexts.is_empty() { // One impl, generic over the source: the record accepts exactly the // sources every derived field accepts, which the where clause spells @@ -23,7 +26,7 @@ pub(crate) fn derive(input: DeriveInput) -> Result { push_field_bounds(&mut generics, krate, &record, &source); let (impl_generics, _, where_clause) = generics.split_for_impl(); let body = body(krate, &record, &source); - return Ok(impl_block( + let block = impl_block( krate, name, &ty_generics, @@ -31,7 +34,8 @@ pub(crate) fn derive(input: DeriveInput) -> Result { where_clause, &source, body, - )); + ); + return Ok(quote!(#block #decryptable)); } // One impl per listed source. Fields derived from the whole source get a @@ -54,7 +58,8 @@ pub(crate) fn derive(input: DeriveInput) -> Result { ) }); - Ok(quote!(#(#impls)*)) + let decryptable = decryptable_impl(&input, &record); + Ok(quote!(#(#impls)* #decryptable)) } /// `impl EncryptFrom> for Record` around `body`. @@ -87,6 +92,31 @@ fn impl_block( } } +/// `impl Decryptable for Record`: a record is decryptable if any derived +/// field is. This is what lets a record sit inside a row whose +/// `DecryptInto` derive finds its ciphertext fields on its own. +fn decryptable_impl(input: &DeriveInput, record: &Record) -> TokenStream { + let krate = &record.krate; + let name = &input.ident; + let (impl_generics, ty_generics, where_clause) = input.generics.split_for_impl(); + let terms = record + .fields + .iter() + .filter(|f| f.is_derived()) + .map(|field| { + let ty = &field.ty; + // Spanned at the field type: a type that is not `Decryptable` is + // reported there, not at the derive. + quote_spanned!(ty.span()=> || <#ty as #krate::target::Decryptable>::DECRYPTABLE) + }); + quote! { + #[automatically_derived] + impl #impl_generics #krate::target::Decryptable for #name #ty_generics #where_clause { + const DECRYPTABLE: bool = false #(#terms)*; + } + } +} + /// `FieldTy: EncryptFrom>` for every field derived /// from the whole source. fn push_field_bounds(generics: &mut syn::Generics, krate: &Path, record: &Record, source: &Type) { @@ -151,6 +181,27 @@ mod tests { derive(input).unwrap().to_string() } + #[test] + #[rustfmt::skip] + fn a_record_is_decryptable_if_any_derived_field_is() { + let expansion = expand(parse_quote! { + struct EncryptedAge { + c: StackCipherText, + hm: EqualityTerm, + #[stash(default)] + v: u8, + } + }); + assert_contains(&expansion, quote! { + impl ::stack_encrypt::target::Decryptable for EncryptedAge { + const DECRYPTABLE: bool = false + || ::DECRYPTABLE + || ::DECRYPTABLE; + } + }); + assert_lacks(&expansion, quote!()); + } + #[test] #[rustfmt::skip] fn generic_source_bounds_every_whole_source_field() { diff --git a/packages/stack-encrypt-derive/src/lib.rs b/packages/stack-encrypt-derive/src/lib.rs index 0c18aa990..594ee94e1 100644 --- a/packages/stack-encrypt-derive/src/lib.rs +++ b/packages/stack-encrypt-derive/src/lib.rs @@ -22,7 +22,6 @@ //! #[derive(EncryptFrom, DecryptInto)] //! #[stash(plaintext = u32)] //! struct EncryptedAge { -//! #[stash(decrypt)] //! c: StackCipherText, //! hm: EqualityTerm, //! ob: OreTerm, @@ -48,6 +47,12 @@ //! awaited, so however many fields a record has, awaiting it is **one** //! batched ZeroKMS call. //! +//! Decryption opens the ciphertext field and passes over the terms, and no +//! attribute says which is which: each field type does, through +//! `Decryptable`, and the derive checks at compile time that exactly one +//! field is a ciphertext. `#[stash(decrypt)]` names the field only when the +//! types cannot — two ciphertexts, say. +//! //! # Rows //! //! One level up, the same derive: a struct whose fields are each derived from @@ -61,7 +66,6 @@ //! # #[derive(EncryptFrom, DecryptInto)] //! # #[stash(plaintext = u32)] //! # struct EncryptedAge { -//! # #[stash(decrypt)] //! # c: StackCipherText, //! # hm: EqualityTerm, //! # ob: OreTerm, @@ -75,9 +79,9 @@ //! #[derive(EncryptFrom, DecryptInto)] //! #[stash(plaintext = User)] //! struct EncryptedUser { -//! #[stash(from = age, context = "users/age", decrypt)] +//! #[stash(from = age, context = "users/age")] //! age: EncryptedAge, -//! #[stash(from = email, context = "users/email", decrypt)] +//! #[stash(from = email, context = "users/email")] //! email: StackCipherText, //! } //! @@ -108,8 +112,9 @@ //! attribute surface. //! //! Field-by-field decryption rebuilds the plaintext with a struct literal, so -//! every field of the plaintext must be recovered by some `decrypt` field, and -//! the plaintext must be a struct visible where the derive expands. +//! every field of the plaintext must be recovered by exactly one ciphertext +//! field derived from it, and the plaintext must be a struct visible where +//! the derive expands. //! //! # Enums //! diff --git a/packages/stack-encrypt/src/cipher.rs b/packages/stack-encrypt/src/cipher.rs index f91bd1fad..75bada079 100644 --- a/packages/stack-encrypt/src/cipher.rs +++ b/packages/stack-encrypt/src/cipher.rs @@ -142,6 +142,12 @@ pub enum Error { /// bug, caught before any I/O. #[error("merged pendings were built from different ciphers")] CipherMismatch, + /// A [`DecryptField`](crate::target::DecryptField) implementation + /// declared its type [`DECRYPTABLE`](crate::target::Decryptable::DECRYPTABLE) + /// but passed the field over. Always a bug in a third-party + /// `DecryptField`, never a data error. + #[error("a field declared decryptable was not opened by its DecryptField implementation")] + NotOpened, } impl From for Error { diff --git a/packages/stack-encrypt/src/lib.rs b/packages/stack-encrypt/src/lib.rs index 31530fbea..f88ff912e 100644 --- a/packages/stack-encrypt/src/lib.rs +++ b/packages/stack-encrypt/src/lib.rs @@ -126,8 +126,9 @@ pub use cipher::{ StackCipherBuilder, StackCipherText, StackDecipher, }; pub use target::{ - DecryptContext, DecryptFrom, DecryptInto, DecryptTarget, EncryptContext, EncryptFrom, - EncryptInto, EncryptTarget, Pending, PendingFuture, Request, Responses, + DecryptContext, DecryptField, DecryptFrom, DecryptInto, DecryptTarget, Decryptable, + EncryptContext, EncryptFrom, EncryptInto, EncryptTarget, Pending, PendingFuture, Request, + Responses, }; // Re-export the vitaminc AEAD surface callers need to drive the cipher, so they diff --git a/packages/stack-encrypt/src/sem/mod.rs b/packages/stack-encrypt/src/sem/mod.rs index d7b30b218..3a3a4f12e 100644 --- a/packages/stack-encrypt/src/sem/mod.rs +++ b/packages/stack-encrypt/src/sem/mod.rs @@ -81,7 +81,10 @@ use vitaminc_prf::{ }; use zeroize::Zeroize; -use crate::target::{is_degenerate_prf_context, EncryptContext, EncryptFrom, Pending}; +use crate::target::{ + is_degenerate_prf_context, DecryptContext, DecryptField, DecryptTarget, Decryptable, + EncryptContext, EncryptFrom, Pending, +}; use crate::{Error, StackCipher}; // The `/v1` suffix versions the *derivation* (domain + input framing), not the @@ -497,6 +500,37 @@ pub struct OreTerm(T::Output); /// lexicographic byte order. pub struct OpeTerm(T::Output); +/// Index terms are one-way: decryption passes over them. `Decryptable` and +/// `DecryptField` say so, which is how a derived record finds its ciphertext +/// field among them without being told. +macro_rules! index_term { + ($ty:ty $(, $param:ident: $bound:path)?) => { + impl<$($param: $bound)?> Decryptable for $ty { + const DECRYPTABLE: bool = false; + } + + impl<__P, __C: DecryptTarget $(, $param: $bound)?> DecryptField<__P, __C> for $ty { + fn decrypt_field<'a, 'c, Ctx>( + self, + _cipher: &'a __C, + _context: Ctx, + ) -> Option<__C::Output<'a, __P>> + where + Ctx: DecryptContext<'c>, + Self: 'a, + __P: 'a, + { + None + } + } + }; +} + +index_term!(EqualityTerm); +index_term!(MatchTerm, O: MatchConfig); +index_term!(OreTerm, T: CllwOreEncrypt); +index_term!(OpeTerm, T: CllwOpeEncrypt); + macro_rules! term_wrapper { ($name:ident, $bound:ident) => { impl $name { diff --git a/packages/stack-encrypt/src/target/mod.rs b/packages/stack-encrypt/src/target/mod.rs index 0848652c1..ab798e528 100644 --- a/packages/stack-encrypt/src/target/mod.rs +++ b/packages/stack-encrypt/src/target/mod.rs @@ -104,7 +104,10 @@ //! behind a PRF request instead, joining the record's one batched call. //! //! ``` -//! use stack_encrypt::target::{EncryptContext, EncryptFrom, Pending}; +//! use stack_encrypt::target::{ +//! DecryptContext, DecryptField, DecryptTarget, Decryptable, EncryptContext, EncryptFrom, +//! Pending, +//! }; //! use stack_encrypt::{Error, StackCipher}; //! use vitaminc_prf::{IntoPrfContext, PrfContext, PrfValue, PrfVisitor, PrfVisitorError}; //! @@ -152,8 +155,29 @@ //! Pending::ready(cipher, term) //! } //! } +//! +//! // A term is one-way. Saying so is what lets `#[derive(DecryptInto)]` +//! // pass over a `MyTerm` field and open the ciphertext beside it. +//! impl Decryptable for MyTerm { +//! const DECRYPTABLE: bool = false; +//! } +//! +//! impl DecryptField for MyTerm { +//! fn decrypt_field<'a, 'c, Ctx>(self, _: &'a C, _: Ctx) -> Option> +//! where +//! Ctx: DecryptContext<'c>, +//! Self: 'a, +//! P: 'a, +//! { +//! None +//! } +//! } //! ``` //! +//! A third-party *ciphertext* type implements `DecryptInto` as well, sets +//! `DECRYPTABLE` to `true`, and has `decrypt_field` return +//! `Some(self.decrypt_into(cipher, context))`. +//! //! A scheme needing state the cipher does not carry defines its own //! capability trait and implements it for [`StackCipher`] (a local trait on a //! foreign type is orphan-rule-legal) using its public accessors @@ -179,9 +203,8 @@ //! //! /// An encrypted `u32`, queryable by equality and range. //! #[derive(EncryptFrom, DecryptInto)] -//! #[stack_encrypt(plaintext = u32)] +//! #[stash(plaintext = u32)] //! struct EncryptedAge { -//! #[stack_encrypt(decrypt)] //! c: StackCipherText, //! hm: EqualityTerm, //! ob: OreTerm, @@ -194,11 +217,11 @@ //! } //! //! #[derive(EncryptFrom, DecryptInto)] -//! #[stack_encrypt(plaintext = User)] +//! #[stash(plaintext = User)] //! struct EncryptedUser { -//! #[stack_encrypt(from = age, context = "users/age", decrypt)] +//! #[stash(from = age, context = "users/age")] //! age: EncryptedAge, -//! #[stack_encrypt(from = email, context = "users/email", decrypt)] +//! #[stash(from = email, context = "users/email")] //! email: StackCipherText, //! } //! @@ -620,6 +643,132 @@ where } } +/// Whether a type is a ciphertext that decryption opens, or an index term +/// that it passes over. +/// +/// Every type that can be a field of a derived record implements this — +/// it is what lets `#[derive(DecryptInto)]` find the ciphertext field on its +/// own, with no attribute: the derive counts the fields whose +/// [`DECRYPTABLE`](Self::DECRYPTABLE) is `true` and requires exactly one +/// (per plaintext field, for a row). `#[derive(EncryptFrom)]` emits it for +/// a record — a record is decryptable if any of its fields is — and the +/// built-in leaves implement it by hand: [`StackCipherText`] is, the +/// [`sem`](crate::sem) terms are not. +/// +/// A third-party leaf implements it alongside [`EncryptFrom`], together +/// with [`DecryptField`]; see the +/// [module docs](self#extending-with-your-own-sem-type). +pub trait Decryptable { + /// `true` if decryption opens a value of this type, `false` if it is a + /// one-way term with no plaintext to recover. + const DECRYPTABLE: bool; +} + +/// Decryption of one field of a derived record, which either opens the field +/// (`Some`) or passes over it (`None`, for an index term). +/// +/// The derive calls this on every candidate field and takes the one `Some`; +/// [`Decryptable`] has already established, at compile time, that there is +/// exactly one. Implemented alongside `Decryptable`: decryptable types wrap +/// their [`DecryptInto`], terms return `None` for every `P`. (Not a +/// supertrait relationship: `#[derive(DecryptInto)]` emits this for every +/// record, and a record that only decrypts — no `EncryptFrom` derive to +/// emit its `Decryptable` — must still be a field of a row in the explicit +/// mode.) +pub trait DecryptField { + /// [`DecryptInto::decrypt_into`] if `Self` is decryptable, `None` if not. + fn decrypt_field<'a, 'c, Ctx>(self, cipher: &'a C, context: Ctx) -> Option> + where + Ctx: DecryptContext<'c>, + Self: 'a, + P: 'a; +} + +impl Decryptable for StackCipherText { + const DECRYPTABLE: bool = true; +} + +impl DecryptField for StackCipherText +where + C: DecryptTarget, + Self: DecryptInto, +{ + fn decrypt_field<'a, 'c, Ctx>(self, cipher: &'a C, context: Ctx) -> Option> + where + Ctx: DecryptContext<'c>, + Self: 'a, + P: 'a, + { + Some(self.decrypt_into(cipher, context)) + } +} + +/// A collection is decryptable if its elements are. +impl Decryptable for Vec { + const DECRYPTABLE: bool = S::DECRYPTABLE; +} + +impl DecryptField, StackCipher> for Vec +where + S: Decryptable + DecryptField>, +{ + fn decrypt_field<'a, 'c, Ctx>( + self, + cipher: &'a StackCipher, + context: Ctx, + ) -> Option, K>> + where + Ctx: DecryptContext<'c>, + Self: 'a, + Vec: 'a, + { + if !S::DECRYPTABLE { + return None; + } + let items = self + .into_iter() + .map(|item| { + item.decrypt_field(cipher, context.clone()) + .unwrap_or_else(|| Pending::failed(cipher, Error::NotOpened)) + }) + .collect(); + Some(Pending::all(cipher, items)) + } +} + +/// An optional value is decryptable if its content is. +impl Decryptable for Option { + const DECRYPTABLE: bool = S::DECRYPTABLE; +} + +impl DecryptField, StackCipher> for Option +where + S: Decryptable + DecryptField>, + T: MaybeSend, +{ + fn decrypt_field<'a, 'c, Ctx>( + self, + cipher: &'a StackCipher, + context: Ctx, + ) -> Option, K>> + where + Ctx: DecryptContext<'c>, + Self: 'a, + Option: 'a, + { + if !S::DECRYPTABLE { + return None; + } + Some(match self { + Some(value) => value + .decrypt_field(cipher, context) + .unwrap_or_else(|| Pending::failed(cipher, Error::NotOpened)) + .map(Some), + None => Pending::ready(cipher, Ok(None)), + }) + } +} + // ============================================================================= // Leaf implementations: the record ciphertext // ============================================================================= diff --git a/packages/stack-encrypt/tests/derive.rs b/packages/stack-encrypt/tests/derive.rs index 1f323df76..429ec68fe 100644 --- a/packages/stack-encrypt/tests/derive.rs +++ b/packages/stack-encrypt/tests/derive.rs @@ -21,7 +21,6 @@ use stack_encrypt::{DecryptInto, EncryptFrom, Error, StackCipherText}; #[derive(EncryptFrom, DecryptInto)] #[stash(plaintext = u32)] struct EncryptedAge { - #[stash(decrypt)] c: StackCipherText, hm: EqualityTerm, ob: OreTerm, @@ -51,7 +50,6 @@ async fn a_derived_record_is_the_hand_written_one() { /// decrypting to whatever the ciphertext field opens to. #[derive(EncryptFrom, DecryptInto)] struct SearchableText { - #[stash(decrypt)] c: StackCipherText, hm: EqualityTerm, m: MatchTerm, @@ -63,8 +61,9 @@ struct Pair(StackCipherText, EqualityTerm); /// The record's own generics (and their bounds) are carried through, and the /// where clause makes `Tagged` accept exactly `T` — the ORE term is typed -/// by its source. -#[derive(EncryptFrom)] +/// by its source. A generic record's one-ciphertext check runs when the +/// record is first used rather than where it is defined. +#[derive(EncryptFrom, DecryptInto)] struct Tagged { c: StackCipherText, ob: OreTerm, @@ -104,15 +103,60 @@ async fn a_generic_plaintext_record_accepts_what_its_leaves_accept() { let tagged: Tagged = 7u32.encrypt_into(&cipher, "users/score").await.unwrap(); let ob: OreTerm = 7u32.encrypt_into(&generator, "users/score").await.unwrap(); assert_eq!(tagged.ob, ob); - let score: u32 = tagged.c.decrypt_into(&cipher, "users/score").await.unwrap(); + let score: u32 = tagged.decrypt_into(&cipher, "users/score").await.unwrap(); assert_eq!(score, 7); } +/// Two ciphertexts in one record: the type system cannot pick, so +/// `#[stash(decrypt)]` does. Only marked fields are considered, and the +/// others need not be `Decryptable` at all. +#[derive(EncryptFrom, DecryptInto)] +#[stash(plaintext = u32)] +struct Doubled { + #[stash(decrypt)] + c: StackCipherText, + #[stash(context = "doubled/shadow")] + shadow: StackCipherText, +} + +/// Fields that are collections or optional follow their content: a record +/// of a `Vec` has one decryptable field, its `Vec`. +#[derive(EncryptFrom, DecryptInto)] +#[stash(plaintext = Vec)] +struct Numbers { + c: Vec, + hm: Vec, +} + +#[tokio::test] +async fn decrypt_marks_the_field_when_the_types_cannot_choose() { + let cipher = stack_cipher().await; + + let doubled: Doubled = 9u32.encrypt_into(&cipher, "doubled").await.unwrap(); + let opened: u32 = doubled.decrypt_into(&cipher, "doubled").await.unwrap(); + assert_eq!(opened, 9); + // The unmarked ciphertext is still a ciphertext, just not the record's. + let doubled: Doubled = 9u32.encrypt_into(&cipher, "doubled").await.unwrap(); + let shadow: u32 = doubled + .shadow + .decrypt_into(&cipher, "doubled/shadow") + .await + .unwrap(); + assert_eq!(shadow, 9); + + let numbers: Numbers = vec![1u32, 2, 3] + .encrypt_into(&cipher, "numbers") + .await + .unwrap(); + assert_eq!(numbers.hm.len(), 3); + let opened: Vec = numbers.decrypt_into(&cipher, "numbers").await.unwrap(); + assert_eq!(opened, vec![1, 2, 3]); +} + /// Listed plaintexts: one impl each, and nothing else is accepted. #[derive(EncryptFrom, DecryptInto)] #[stash(plaintext = u32, plaintext = String)] struct EncryptedValue { - #[stash(decrypt)] c: StackCipherText, hm: EqualityTerm, } @@ -159,9 +203,9 @@ struct User { #[stash(plaintext = User)] struct EncryptedUser { /// A record inside a row: recursion, not a second mechanism. - #[stash(from = age, context = "users/age", decrypt)] + #[stash(from = age, context = "users/age")] age: EncryptedAge, - #[stash(from = email, context = "users/email", decrypt)] + #[stash(from = email, context = "users/email")] email: StackCipherText, /// A second field from the same plaintext field — a term alongside the /// ciphertext, not opened on decrypt. @@ -252,9 +296,9 @@ struct Reading(u32, String); #[derive(EncryptFrom, DecryptInto)] #[stash(plaintext = Reading)] struct EncryptedReading { - #[stash(from = 0, context = "readings/value", decrypt)] + #[stash(from = 0, context = "readings/value")] value: EncryptedAge, - #[stash(from = 1, context = "readings/unit", decrypt)] + #[stash(from = 1, context = "readings/unit")] unit: StackCipherText, } diff --git a/packages/stack-encrypt/tests/ui/decrypt_ambiguous_shape.rs b/packages/stack-encrypt/tests/ui/decrypt_ambiguous_shape.rs new file mode 100644 index 000000000..bfb74b4a0 --- /dev/null +++ b/packages/stack-encrypt/tests/ui/decrypt_ambiguous_shape.rs @@ -0,0 +1,16 @@ +use stack_encrypt::sem::EqualityTerm; +use stack_encrypt::{DecryptInto, StackCipherText}; + +struct User { + email: String, +} + +#[derive(DecryptInto)] +#[stash(plaintext = User)] +struct Rec { + #[stash(from = email, context = "users/email")] + email: StackCipherText, + hm: EqualityTerm, +} + +fn main() {} diff --git a/packages/stack-encrypt/tests/ui/decrypt_ambiguous_shape.stderr b/packages/stack-encrypt/tests/ui/decrypt_ambiguous_shape.stderr new file mode 100644 index 000000000..d464b58f9 --- /dev/null +++ b/packages/stack-encrypt/tests/ui/decrypt_ambiguous_shape.stderr @@ -0,0 +1,5 @@ +error: some derived fields name a plaintext field (`from = ..`) and some do not, so it is ambiguous whether decryption opens the record as a whole or rebuilds the plaintext field by field: mark the fields decryption opens `#[stash(decrypt)]` + --> tests/ui/decrypt_ambiguous_shape.rs:10:8 + | +10 | struct Rec { + | ^^^ diff --git a/packages/stack-encrypt/tests/ui/decrypt_field_not_decryptable.rs b/packages/stack-encrypt/tests/ui/decrypt_field_not_decryptable.rs new file mode 100644 index 000000000..676b0e140 --- /dev/null +++ b/packages/stack-encrypt/tests/ui/decrypt_field_not_decryptable.rs @@ -0,0 +1,13 @@ +use stack_encrypt::{DecryptInto, StackCipherText}; + +/// A hand-written leaf that says nothing about whether it is decryptable. +struct Opaque; + +#[derive(DecryptInto)] +#[stash(plaintext = u32)] +struct Rec { + c: StackCipherText, + o: Opaque, +} + +fn main() {} diff --git a/packages/stack-encrypt/tests/ui/decrypt_field_not_decryptable.stderr b/packages/stack-encrypt/tests/ui/decrypt_field_not_decryptable.stderr new file mode 100644 index 000000000..ba3e78061 --- /dev/null +++ b/packages/stack-encrypt/tests/ui/decrypt_field_not_decryptable.stderr @@ -0,0 +1,14 @@ +error[E0277]: the trait bound `Opaque: Decryptable` is not satisfied + --> tests/ui/decrypt_field_not_decryptable.rs:10:8 + | +10 | o: Opaque, + | ^^^^^^ the trait `Decryptable` is not implemented for `Opaque` + | + = help: the following other types implement trait `Decryptable`: + CipherText> + EqualityTerm + MatchTerm + OpeTerm + Option + OreTerm + Vec diff --git a/packages/stack-encrypt/tests/ui/decrypt_no_decryptable_field.rs b/packages/stack-encrypt/tests/ui/decrypt_no_decryptable_field.rs new file mode 100644 index 000000000..86be6d106 --- /dev/null +++ b/packages/stack-encrypt/tests/ui/decrypt_no_decryptable_field.rs @@ -0,0 +1,11 @@ +use stack_encrypt::sem::{EqualityTerm, OreTerm}; +use stack_encrypt::DecryptInto; + +#[derive(DecryptInto)] +#[stash(plaintext = u32)] +struct Rec { + hm: EqualityTerm, + ob: OreTerm, +} + +fn main() {} diff --git a/packages/stack-encrypt/tests/ui/decrypt_no_decryptable_field.stderr b/packages/stack-encrypt/tests/ui/decrypt_no_decryptable_field.stderr new file mode 100644 index 000000000..51acb5612 --- /dev/null +++ b/packages/stack-encrypt/tests/ui/decrypt_no_decryptable_field.stderr @@ -0,0 +1,5 @@ +error[E0080]: evaluation panicked: `Rec` has no decryptable field: every derived field is a one-way index term, so there is nothing for DecryptInto to open + --> tests/ui/decrypt_no_decryptable_field.rs:4:10 + | +4 | #[derive(DecryptInto)] + | ^^^^^^^^^^^ evaluation of `_` failed here diff --git a/packages/stack-encrypt/tests/ui/decrypt_without_opened_field.rs b/packages/stack-encrypt/tests/ui/decrypt_several_decryptable_fields.rs similarity index 60% rename from packages/stack-encrypt/tests/ui/decrypt_without_opened_field.rs rename to packages/stack-encrypt/tests/ui/decrypt_several_decryptable_fields.rs index 42956d677..09419eb56 100644 --- a/packages/stack-encrypt/tests/ui/decrypt_without_opened_field.rs +++ b/packages/stack-encrypt/tests/ui/decrypt_several_decryptable_fields.rs @@ -1,11 +1,10 @@ -use stack_encrypt::sem::EqualityTerm; use stack_encrypt::{DecryptInto, StackCipherText}; #[derive(DecryptInto)] #[stash(plaintext = u32)] struct Rec { - c: StackCipherText, - hm: EqualityTerm, + a: StackCipherText, + b: StackCipherText, } fn main() {} diff --git a/packages/stack-encrypt/tests/ui/decrypt_several_decryptable_fields.stderr b/packages/stack-encrypt/tests/ui/decrypt_several_decryptable_fields.stderr new file mode 100644 index 000000000..4cd2aa240 --- /dev/null +++ b/packages/stack-encrypt/tests/ui/decrypt_several_decryptable_fields.stderr @@ -0,0 +1,5 @@ +error[E0080]: evaluation panicked: `Rec` has several decryptable fields: mark the one decryption opens `#[stash(decrypt)]` + --> tests/ui/decrypt_several_decryptable_fields.rs:3:10 + | +3 | #[derive(DecryptInto)] + | ^^^^^^^^^^^ evaluation of `_` failed here diff --git a/packages/stack-encrypt/tests/ui/decrypt_several_recover_one_field.rs b/packages/stack-encrypt/tests/ui/decrypt_several_recover_one_field.rs new file mode 100644 index 000000000..ef82dfca8 --- /dev/null +++ b/packages/stack-encrypt/tests/ui/decrypt_several_recover_one_field.rs @@ -0,0 +1,16 @@ +use stack_encrypt::{DecryptInto, StackCipherText}; + +struct User { + email: String, +} + +#[derive(DecryptInto)] +#[stash(plaintext = User)] +struct Rec { + #[stash(from = email, context = "users/email")] + email: StackCipherText, + #[stash(from = email, context = "users/email/copy")] + email_copy: StackCipherText, +} + +fn main() {} diff --git a/packages/stack-encrypt/tests/ui/decrypt_several_recover_one_field.stderr b/packages/stack-encrypt/tests/ui/decrypt_several_recover_one_field.stderr new file mode 100644 index 000000000..d825394f7 --- /dev/null +++ b/packages/stack-encrypt/tests/ui/decrypt_several_recover_one_field.stderr @@ -0,0 +1,5 @@ +error[E0080]: evaluation panicked: several fields of `Rec` are derived from the plaintext field `email` and decryptable: mark the one decryption opens `#[stash(decrypt)]` + --> tests/ui/decrypt_several_recover_one_field.rs:7:10 + | +7 | #[derive(DecryptInto)] + | ^^^^^^^^^^^ evaluation of `_` failed here diff --git a/packages/stack-encrypt/tests/ui/decrypt_without_opened_field.stderr b/packages/stack-encrypt/tests/ui/decrypt_without_opened_field.stderr deleted file mode 100644 index ead3bdf80..000000000 --- a/packages/stack-encrypt/tests/ui/decrypt_without_opened_field.stderr +++ /dev/null @@ -1,5 +0,0 @@ -error: DecryptInto needs to know which field decryption opens: mark it `#[stash(decrypt)]` (index terms are one-way and cannot be) - --> tests/ui/decrypt_without_opened_field.rs:6:8 - | -6 | struct Rec { - | ^^^ From 8dfddbbfda4841d85183f8ea6e6643af146028b3 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Mon, 31 Aug 2026 11:04:17 +1000 Subject: [PATCH 460/686] fix(stack-encrypt-derive): close three gaps in the derive contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review feedback on the derive PR: - A third-party `DecryptField` that declares `DECRYPTABLE = true` but returns `None` no longer panics the generated automatic-mode body: the probe chain falls back to a failed pending carrying `Error::NotOpened` (documented as exactly this case), for records and rows alike. `Pending::failed` becomes `pub` so generated code — and third-party `DecryptField` impls — can construct it. - `#[derive(EncryptFrom)]` no longer probes every derived field's `Decryptable` when `#[stash(decrypt)]` selects the explicit mode: the record's `DECRYPTABLE` is `true` outright, so the documented opaque-field shape now compiles under the paired derive. - Repeated singleton attributes (`from`, `context`, `default`, `decrypt`, `crate`) are rejected instead of silently overwritten — a silently-winning second `from` would encrypt the wrong same-typed plaintext field, the exact crossed-field failure the derive exists to prevent. Diagnostics pinned in a trybuild fixture. Claude-Session: https://claude.ai/code/session_01BpqczxAVwUsTYdCRWh9dYb --- .../stack-encrypt-derive/docs/attributes.md | 8 +- packages/stack-encrypt-derive/src/attrs.rs | 22 ++++ packages/stack-encrypt-derive/src/decrypt.rs | 50 ++++----- packages/stack-encrypt-derive/src/encrypt.rs | 56 +++++++-- packages/stack-encrypt-derive/src/shape.rs | 64 +++++++++++ packages/stack-encrypt/src/target/mod.rs | 3 +- packages/stack-encrypt/src/target/pending.rs | 8 +- packages/stack-encrypt/tests/derive.rs | 106 +++++++++++++++++- .../tests/ui/duplicate_singleton_attrs.rs | 40 +++++++ .../tests/ui/duplicate_singleton_attrs.stderr | 29 +++++ 10 files changed, 340 insertions(+), 46 deletions(-) create mode 100644 packages/stack-encrypt/tests/ui/duplicate_singleton_attrs.rs create mode 100644 packages/stack-encrypt/tests/ui/duplicate_singleton_attrs.stderr diff --git a/packages/stack-encrypt-derive/docs/attributes.md b/packages/stack-encrypt-derive/docs/attributes.md index 4e07aaaa8..23587c6e2 100644 --- a/packages/stack-encrypt-derive/docs/attributes.md +++ b/packages/stack-encrypt-derive/docs/attributes.md @@ -31,6 +31,10 @@ The record's own context reaches every derived field that has no `context` of its own; if every field has one, the record's context is unused and the caller may pass `()`. +Every attribute except `plaintext` is singular, and repeating one is a +compile error rather than a silent overwrite (`plaintext` is repeatable, +but each listed type only once). + ## Which field decryption opens `DecryptInto` does not need to be told: every field type says whether it is a @@ -47,7 +51,9 @@ ciphertexts of which one is to be opened, or a field type that is not `Decryptable`. Once any field is marked, only the marked fields are considered — one opened as the whole plaintext, or several with `from = ..` rebuilding the plaintext field by field — and the field types need not be -`Decryptable`. +`Decryptable`. The record's own `Decryptable` impl (emitted by +`#[derive(EncryptFrom)]`) is then `true` outright — the marker says +decryption opens the record — so a marked record still nests in rows. `DecryptInto` consumes the record, moving each opened field out of `self`, so the record must not implement `Drop` (including via `ZeroizeOnDrop`); wrap the diff --git a/packages/stack-encrypt-derive/src/attrs.rs b/packages/stack-encrypt-derive/src/attrs.rs index 7835e205f..c72116221 100644 --- a/packages/stack-encrypt-derive/src/attrs.rs +++ b/packages/stack-encrypt-derive/src/attrs.rs @@ -22,6 +22,9 @@ impl ContainerAttrs { for attr in attrs.iter().filter(|a| a.path().is_ident("stash")) { attr.parse_nested_meta(|meta| { if meta.path.is_ident("crate") { + if krate.is_some() { + return Err(meta.error("`crate` is given twice")); + } let lit: LitStr = meta.value()?.parse()?; krate = Some(lit.parse()?); return Ok(()); @@ -87,6 +90,14 @@ impl FieldAttrs { for attr in attrs.iter().filter(|a| a.path().is_ident("stash")) { attr.parse_nested_meta(|meta| { if meta.path.is_ident("context") { + // Each of these is singular by meaning, so a repeat is a + // mistake: rejected rather than silently overwritten. A + // silently-winning second `from` would be the worst of + // them — it crosses fields, which is exactly the failure + // the derive exists to prevent. + if parsed.context.is_some() { + return Err(meta.error("`context` is given twice; a field has one context")); + } let context: LitStr = meta.value()?.parse()?; // The leaves reject an empty context at runtime; a // literal one is known here, so say so at the literal. @@ -102,10 +113,18 @@ impl FieldAttrs { return Ok(()); } if meta.path.is_ident("from") { + if parsed.from.is_some() { + return Err(meta.error( + "`from` is given twice; a field is derived from one plaintext field", + )); + } parsed.from = Some(meta.value()?.parse()?); return Ok(()); } if meta.path.is_ident("default") { + if parsed.default.is_some() { + return Err(meta.error("`default` is given twice")); + } parsed.default = Some(if meta.input.peek(syn::Token![=]) { Some(meta.value()?.parse()?) } else { @@ -114,6 +133,9 @@ impl FieldAttrs { return Ok(()); } if meta.path.is_ident("decrypt") { + if parsed.decrypt { + return Err(meta.error("`decrypt` is given twice")); + } parsed.decrypt = true; return Ok(()); } diff --git a/packages/stack-encrypt-derive/src/decrypt.rs b/packages/stack-encrypt-derive/src/decrypt.rs index 0664ca879..3daa1632d 100644 --- a/packages/stack-encrypt-derive/src/decrypt.rs +++ b/packages/stack-encrypt-derive/src/decrypt.rs @@ -251,7 +251,7 @@ fn automatic(input: &DeriveInput, record: &Record) -> Result { generics.params.push(parse_quote!(__K)); push_field_bounds(&mut generics, krate, fields, &plaintext); let (impl_generics, _, where_clause) = generics.split_for_impl(); - let open = open_one(krate, name, None, fields, &plaintext); + let open = open_one(krate, fields, &plaintext); let body = quote!(#body_check #destructure #open); let block = impl_block( krate, @@ -274,9 +274,9 @@ fn automatic(input: &DeriveInput, record: &Record) -> Result { let open = match &auto { Auto::Whole(fields) => { push_field_bounds(&mut generics, krate, fields, plaintext); - open_one(krate, name, None, fields, plaintext) + open_one(krate, fields, plaintext) } - Auto::ByField(groups) => by_group_body(krate, name, groups, plaintext)?, + Auto::ByField(groups) => by_group_body(krate, groups, plaintext)?, }; let (impl_generics, _, where_clause) = generics.split_for_impl(); let body = quote!(#body_check #destructure #open); @@ -364,13 +364,7 @@ fn check(krate: &Path, name: &Ident, from: Option<&Member>, fields: &[&Field]) - /// The one `Some` among the fields' `decrypt_field`s, as a pending of /// `plaintext` (`_` when it is inferred from a struct literal). -fn open_one( - krate: &Path, - name: &Ident, - from: Option<&Member>, - fields: &[&Field], - plaintext: &Type, -) -> TokenStream { +fn open_one(krate: &Path, fields: &[&Field], plaintext: &Type) -> TokenStream { let mut calls = fields.iter().map(|field| { let ty = &field.ty; let local = &field.local; @@ -391,28 +385,22 @@ fn open_one( first, |chain, call| quote!(::core::option::Option::or_else(#chain, move || #call)), ); - let what = match from { - None => format!("`{name}`"), - Some(from) => format!( - "the plaintext field `{}` of `{name}`", - from.to_token_stream() - ), - }; - let message = LitStr::new( - &format!("exactly one field of {what} is decryptable, checked at compile time"), - Span::call_site(), - ); - quote!(::core::option::Option::expect(#chain, #message)) + // The const assertion has established that exactly one field's type is + // `Decryptable`, but a third-party `DecryptField` can still break its + // contract and return `None` for a type whose `DECRYPTABLE` is `true`. + // That is `Error::NotOpened` — a failed pending that settles without + // I/O — never a panic. + quote! { + ::core::option::Option::unwrap_or_else(#chain, || #krate::target::Pending::failed( + __cipher, + #krate::Error::NotOpened, + )) + } } /// Each group's opened pending, zipped into one and mapped into a struct /// literal of the plaintext. -fn by_group_body( - krate: &Path, - name: &Ident, - groups: &[Group<'_>], - plaintext: &Type, -) -> Result { +fn by_group_body(krate: &Path, groups: &[Group<'_>], plaintext: &Type) -> Result { let literal = struct_literal_path(plaintext)?; let inferred: Type = parse_quote!(_); @@ -420,7 +408,7 @@ fn by_group_body( .map(|index| Ident::new(&format!("__group_{index}"), Span::call_site())) .collect(); let opens = groups.iter().zip(&locals).map(|(group, local)| { - let open = open_one(krate, name, Some(group.from), &group.fields, &inferred); + let open = open_one(krate, &group.fields, &inferred); quote!(let #local = #open;) }); @@ -701,7 +689,7 @@ mod tests { assert_contains(&expansion, quote!("no field of `Row` can recover the plaintext field `age`: every field derived from it is a one-way index term")); assert_contains(&expansion, quote!("several fields of `Row` are derived from the plaintext field `email` and decryptable: mark the one decryption opens `#[stash(decrypt)]`")); assert_contains(&expansion, quote! { - let __group_1 = ::core::option::Option::expect( + let __group_1 = ::core::option::Option::unwrap_or_else( ::core::option::Option::or_else( >>::decrypt_field( __field_1, __cipher, "users/email", @@ -710,7 +698,7 @@ mod tests { __field_2, __cipher, "users/email", ) ), - "exactly one field of the plaintext field `email` of `Row` is decryptable, checked at compile time" + || ::stack_encrypt::target::Pending::failed(__cipher, ::stack_encrypt::Error::NotOpened,) ); }); assert_contains(&expansion, quote!(__group_0.zip(__group_1).map(|(__group_0, __group_1)| User { age: __group_0, email: __group_1 }))); diff --git a/packages/stack-encrypt-derive/src/encrypt.rs b/packages/stack-encrypt-derive/src/encrypt.rs index 73b9502e6..8d48767d5 100644 --- a/packages/stack-encrypt-derive/src/encrypt.rs +++ b/packages/stack-encrypt-derive/src/encrypt.rs @@ -95,24 +95,35 @@ fn impl_block( /// `impl Decryptable for Record`: a record is decryptable if any derived /// field is. This is what lets a record sit inside a row whose /// `DecryptInto` derive finds its ciphertext fields on its own. +/// +/// In the explicit mode — any field marked `#[stash(decrypt)]` — the record +/// is decryptable outright: the marker exists precisely so the other field +/// types need not be `Decryptable`, so probing them here would reintroduce +/// the bound the marker removes (and fail to compile for the documented +/// opaque-field shape). fn decryptable_impl(input: &DeriveInput, record: &Record) -> TokenStream { let krate = &record.krate; let name = &input.ident; let (impl_generics, ty_generics, where_clause) = input.generics.split_for_impl(); - let terms = record - .fields - .iter() - .filter(|f| f.is_derived()) - .map(|field| { - let ty = &field.ty; - // Spanned at the field type: a type that is not `Decryptable` is - // reported there, not at the derive. - quote_spanned!(ty.span()=> || <#ty as #krate::target::Decryptable>::DECRYPTABLE) - }); + let value = if record.fields.iter().any(|f| f.decrypt) { + quote!(true) + } else { + let terms = record + .fields + .iter() + .filter(|f| f.is_derived()) + .map(|field| { + let ty = &field.ty; + // Spanned at the field type: a type that is not `Decryptable` + // is reported there, not at the derive. + quote_spanned!(ty.span()=> || <#ty as #krate::target::Decryptable>::DECRYPTABLE) + }); + quote!(false #(#terms)*) + }; quote! { #[automatically_derived] impl #impl_generics #krate::target::Decryptable for #name #ty_generics #where_clause { - const DECRYPTABLE: bool = false #(#terms)*; + const DECRYPTABLE: bool = #value; } } } @@ -202,6 +213,29 @@ mod tests { assert_lacks(&expansion, quote!()); } + #[test] + #[rustfmt::skip] + fn an_explicit_decrypt_marker_makes_the_record_decryptable_outright() { + // The documented explicit-mode shape: the marker frees the other + // field types from `Decryptable`, so the emitted impl must not + // probe them. + let expansion = expand(parse_quote! { + #[stash(plaintext = u32)] + struct Rec { + #[stash(decrypt)] + c: StackCipherText, + opaque: OpaqueTerm, + } + }); + assert_contains(&expansion, quote! { + impl ::stack_encrypt::target::Decryptable for Rec { + const DECRYPTABLE: bool = true; + } + }); + assert_lacks(&expansion, quote!()); + assert_lacks(&expansion, quote!()); + } + #[test] #[rustfmt::skip] fn generic_source_bounds_every_whole_source_field() { diff --git a/packages/stack-encrypt-derive/src/shape.rs b/packages/stack-encrypt-derive/src/shape.rs index 5a8daa812..6b6da1d9b 100644 --- a/packages/stack-encrypt-derive/src/shape.rs +++ b/packages/stack-encrypt-derive/src/shape.rs @@ -272,6 +272,70 @@ mod tests { assert!(err.to_string().contains("unsupported container attribute")); } + #[test] + fn repeated_singleton_attributes_are_rejected() { + // A silently-winning second `from` would encrypt the wrong (same- + // typed) plaintext field — the crossed-field failure the derive + // exists to prevent — so every singular attribute rejects a repeat. + let err = parse(parse_quote! { + #[stash(plaintext = User)] + struct Rec { + #[stash(from = expected, from = other)] + c: StackCipherText, + } + }) + .unwrap_err(); + assert!(err.to_string().contains("`from` is given twice")); + + let err = parse(parse_quote! { + struct Rec { + #[stash(context = "users/email", context = "users/name")] + c: StackCipherText, + } + }) + .unwrap_err(); + assert!(err.to_string().contains("`context` is given twice")); + + // Also across two `#[stash(..)]` attributes on the same field. + let err = parse(parse_quote! { + struct Rec { + #[stash(context = "users/email")] + #[stash(context = "users/name")] + c: StackCipherText, + } + }) + .unwrap_err(); + assert!(err.to_string().contains("`context` is given twice")); + + let err = parse(parse_quote! { + struct Rec { + c: StackCipherText, + #[stash(default, default = 3)] + v: u8, + } + }) + .unwrap_err(); + assert!(err.to_string().contains("`default` is given twice")); + + let err = parse(parse_quote! { + struct Rec { + #[stash(decrypt, decrypt)] + c: StackCipherText, + } + }) + .unwrap_err(); + assert!(err.to_string().contains("`decrypt` is given twice")); + + let err = parse(parse_quote! { + #[stash(crate = "stack_encrypt", crate = "stack_encrypt")] + struct Rec { + c: StackCipherText, + } + }) + .unwrap_err(); + assert!(err.to_string().contains("`crate` is given twice")); + } + #[test] fn a_literal_empty_context_is_rejected() { let err = parse(parse_quote! { diff --git a/packages/stack-encrypt/src/target/mod.rs b/packages/stack-encrypt/src/target/mod.rs index ab798e528..f603ac62f 100644 --- a/packages/stack-encrypt/src/target/mod.rs +++ b/packages/stack-encrypt/src/target/mod.rs @@ -651,7 +651,8 @@ where /// own, with no attribute: the derive counts the fields whose /// [`DECRYPTABLE`](Self::DECRYPTABLE) is `true` and requires exactly one /// (per plaintext field, for a row). `#[derive(EncryptFrom)]` emits it for -/// a record — a record is decryptable if any of its fields is — and the +/// a record — a record is decryptable if any of its fields is, or outright +/// when `#[stash(decrypt)]` names the opened fields — and the /// built-in leaves implement it by hand: [`StackCipherText`] is, the /// [`sem`](crate::sem) terms are not. /// diff --git a/packages/stack-encrypt/src/target/pending.rs b/packages/stack-encrypt/src/target/pending.rs index 1e8a4e452..fee0bb9f8 100644 --- a/packages/stack-encrypt/src/target/pending.rs +++ b/packages/stack-encrypt/src/target/pending.rs @@ -82,7 +82,13 @@ impl<'a, T: 'a, K> Pending<'a, T, K> { /// A pending that already failed. No requests, no `T` bound (nothing of /// type `T` is ever produced), and any assembly it is merged into fails /// without I/O — see the `failed` field. - pub(crate) fn failed(cipher: &'a StackCipher, error: Error) -> Self { + /// + /// Public because a [`DecryptField`](super::DecryptField) implementation + /// (including the derive's generated code) reaches for it when a + /// contract is broken at decrypt time — e.g. + /// [`Error::NotOpened`] for a field whose type declared + /// [`DECRYPTABLE`](super::Decryptable::DECRYPTABLE) but was passed over. + pub fn failed(cipher: &'a StackCipher, error: Error) -> Self { Self { cipher, requests: Vec::new(), diff --git a/packages/stack-encrypt/tests/derive.rs b/packages/stack-encrypt/tests/derive.rs index 429ec68fe..082e61b13 100644 --- a/packages/stack-encrypt/tests/derive.rs +++ b/packages/stack-encrypt/tests/derive.rs @@ -12,7 +12,10 @@ use cllw_ore::CllwOreEncrypt; use common::{counting_cipher, stack_cipher}; use stack_encrypt::sem::{EqualityTerm, MatchTerm, OreTerm}; use stack_encrypt::target::EncryptInto; -use stack_encrypt::{DecryptInto, EncryptFrom, Error, StackCipherText}; +use stack_encrypt::{ + DecryptContext, DecryptField, DecryptInto, DecryptTarget, Decryptable, EncryptContext, + EncryptFrom, Error, Pending, StackCipher, StackCipherText, +}; // --- Records: every field from one plaintext, under one context ------------- @@ -153,6 +156,107 @@ async fn decrypt_marks_the_field_when_the_types_cannot_choose() { assert_eq!(opened, vec![1, 2, 3]); } +/// An index term type from outside this crate that predates `Decryptable`: +/// it implements `EncryptFrom` only, wrapping a term of ours. +#[derive(PartialEq)] +struct OpaqueTerm(EqualityTerm); + +impl EncryptFrom> for OpaqueTerm +where + EqualityTerm: EncryptFrom>, +{ + fn encrypt_from<'a, 'c, Ctx>( + source: &'a S, + cipher: &'a StackCipher, + context: Ctx, + ) -> Pending<'a, Self, K> + where + Ctx: EncryptContext<'c>, + Self: 'a, + { + EqualityTerm::encrypt_from(source, cipher, context).map(OpaqueTerm) + } +} + +/// The documented explicit-mode shape: `#[stash(decrypt)]` frees the *other* +/// field types from `Decryptable`, so the paired derive must compile with an +/// opaque field — including the `Decryptable` impl `EncryptFrom` emits. +#[derive(EncryptFrom, DecryptInto)] +#[stash(plaintext = u32)] +struct WithOpaque { + #[stash(decrypt)] + c: StackCipherText, + o: OpaqueTerm, +} + +/// And the marked record is decryptable outright, so it still nests in rows. +#[allow(clippy::assertions_on_constants)] // the constant is the point +const _: () = assert!(::DECRYPTABLE); + +#[tokio::test] +async fn explicit_mode_supports_opaque_fields_in_the_paired_derive() { + let cipher = stack_cipher().await; + let generator = stack_cipher().await; + + let record: WithOpaque = 5u32.encrypt_into(&cipher, "opaque").await.unwrap(); + let hm: EqualityTerm = 5u32.encrypt_into(&generator, "opaque").await.unwrap(); + assert!(record.o == OpaqueTerm(hm)); + + let opened: u32 = record.decrypt_into(&cipher, "opaque").await.unwrap(); + assert_eq!(opened, 5); +} + +/// A third-party field type that breaks the `DecryptField` contract: +/// `DECRYPTABLE` says decryption opens it, but `decrypt_field` passes it +/// over anyway. +struct Lying; + +impl Decryptable for Lying { + const DECRYPTABLE: bool = true; +} + +impl DecryptField for Lying { + fn decrypt_field<'a, 'c, Ctx>(self, _cipher: &'a C, _context: Ctx) -> Option> + where + Ctx: DecryptContext<'c>, + Self: 'a, + P: 'a, + { + None + } +} + +#[derive(DecryptInto)] +struct LyingRecord { + l: Lying, +} + +#[derive(Debug, PartialEq)] +struct Held { + value: u32, +} + +#[derive(DecryptInto)] +#[stash(plaintext = Held)] +struct LyingRow { + #[stash(from = value, context = "held/value")] + value: Lying, +} + +#[tokio::test] +async fn a_broken_decrypt_field_contract_is_not_opened_never_a_panic() { + let cipher = stack_cipher().await; + + // The compile-time check accepted `Lying` (its `DECRYPTABLE` is `true`), + // so the broken contract only shows at decrypt time: `Error::NotOpened` + // as a failed pending, for the record and for the row alike. + let result: Result = LyingRecord { l: Lying }.decrypt_into(&cipher, "l").await; + assert!(matches!(result, Err(Error::NotOpened))); + + let result: Result = LyingRow { value: Lying }.decrypt_into(&cipher, ()).await; + assert!(matches!(result, Err(Error::NotOpened))); +} + /// Listed plaintexts: one impl each, and nothing else is accepted. #[derive(EncryptFrom, DecryptInto)] #[stash(plaintext = u32, plaintext = String)] diff --git a/packages/stack-encrypt/tests/ui/duplicate_singleton_attrs.rs b/packages/stack-encrypt/tests/ui/duplicate_singleton_attrs.rs new file mode 100644 index 000000000..7c3210437 --- /dev/null +++ b/packages/stack-encrypt/tests/ui/duplicate_singleton_attrs.rs @@ -0,0 +1,40 @@ +use stack_encrypt::{DecryptInto, EncryptFrom, StackCipherText}; + +struct User { + expected: u32, + other: u32, +} + +#[derive(EncryptFrom)] +#[stash(plaintext = User)] +struct DupFrom { + #[stash(from = expected, from = other)] + c: StackCipherText, +} + +#[derive(EncryptFrom)] +struct DupContext { + #[stash(context = "users/email", context = "users/name")] + c: StackCipherText, +} + +#[derive(EncryptFrom)] +struct DupDefault { + c: StackCipherText, + #[stash(default, default = 3)] + v: u8, +} + +#[derive(DecryptInto)] +struct DupDecrypt { + #[stash(decrypt, decrypt)] + c: StackCipherText, +} + +#[derive(EncryptFrom)] +#[stash(crate = "stack_encrypt", crate = "stack_encrypt")] +struct DupCrate { + c: StackCipherText, +} + +fn main() {} diff --git a/packages/stack-encrypt/tests/ui/duplicate_singleton_attrs.stderr b/packages/stack-encrypt/tests/ui/duplicate_singleton_attrs.stderr new file mode 100644 index 000000000..a8f48d66e --- /dev/null +++ b/packages/stack-encrypt/tests/ui/duplicate_singleton_attrs.stderr @@ -0,0 +1,29 @@ +error: `from` is given twice; a field is derived from one plaintext field + --> tests/ui/duplicate_singleton_attrs.rs:11:30 + | +11 | #[stash(from = expected, from = other)] + | ^^^^ + +error: `context` is given twice; a field has one context + --> tests/ui/duplicate_singleton_attrs.rs:17:38 + | +17 | #[stash(context = "users/email", context = "users/name")] + | ^^^^^^^ + +error: `default` is given twice + --> tests/ui/duplicate_singleton_attrs.rs:24:22 + | +24 | #[stash(default, default = 3)] + | ^^^^^^^ + +error: `decrypt` is given twice + --> tests/ui/duplicate_singleton_attrs.rs:30:22 + | +30 | #[stash(decrypt, decrypt)] + | ^^^^^^^ + +error: `crate` is given twice + --> tests/ui/duplicate_singleton_attrs.rs:35:34 + | +35 | #[stash(crate = "stack_encrypt", crate = "stack_encrypt")] + | ^^^^^ From 4d180e5391d45bd1d690a32802895ff5071b85e8 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Thu, 27 Aug 2026 23:32:04 +1000 Subject: [PATCH 461/686] chore(wasi): gate the HTTP-free core for wasm32-wasip1 and plan the Go bindings Phase 0 of the stack-encrypt Go bindings (docs/stack-encrypt-go-bindings.md): the pieces of cipherstash/cipherstash-suite#2099 that stand independently of the crate it targeted. - `mise run wasm:wasi-check`: compiles zerokms-protocol, cipherstash-core, recipher, cts-common and cllw-ore for wasm32-wasip1 and fails if any crate's normal-dependency tree contains a JS-host backend (wasm-bindgen/web-sys/js-sys) or the native HTTP/TLS stack (reqwest/hyper/aws-lc-sys). The second family is new: on wasip1 reqwest 0.13.4+ selects a native backend (tokio-full + aws-lc-sys) that does not build for WASI, so HTTP has to be out of the WASI build by construction rather than by dead-code elimination. - `.github/workflows/test-wasi.yml` runs the gate on the crates it covers. - wasm-analysis.md Layer 6 rewritten for the stack-kms/stack-encrypt target: the measured blocker, why HTTP is host-provided, the terminology. - docs/stack-encrypt-go-bindings.md: the phased plan, the vitaminc bindings/go layering, frozen byte formats, open decisions. Claude-Session: https://claude.ai/code/session_01HU1Bbw4eQp9kEEneXxDcKr --- .github/imported-workflows/test-wasi.yml | 65 ++++++++++++++++++++++++ docs/wasm-analysis.md | 19 +++++++ 2 files changed, 84 insertions(+) create mode 100644 .github/imported-workflows/test-wasi.yml diff --git a/.github/imported-workflows/test-wasi.yml b/.github/imported-workflows/test-wasi.yml new file mode 100644 index 000000000..fa51902f1 --- /dev/null +++ b/.github/imported-workflows/test-wasi.yml @@ -0,0 +1,65 @@ +name: "WASI check (Go/wazero target)" +on: + push: + branches: + - main + paths: + # Every crate wasm:wasi-check gates must trigger the gate, or a crate + # can reintroduce a wasm-incompatible dep and merge green. + - packages/zerokms-protocol/** + - packages/cipherstash-core/** + - packages/recipher/** + - packages/cts-common/** + - packages/cllw-ore/** + - Cargo.lock + - mise.toml + - .github/workflows/test-wasi.yml + # Ordering matters, this needs to stay at the end if we want to include the above, but exclude all .md files. + - "!**.md" + - "!**.example" + + pull_request: + paths: + - packages/zerokms-protocol/** + - packages/cipherstash-core/** + - packages/recipher/** + - packages/cts-common/** + - packages/cllw-ore/** + - Cargo.lock + - mise.toml + - .github/workflows/test-wasi.yml + # Ordering matters, this needs to stay at the end if we want to include the above, but exclude all .md files. + - "!**.md" + - "!**.example" + + workflow_dispatch: + +defaults: + run: + shell: bash + +env: + RUSTFLAGS: "-D warnings" + RUST_BACKTRACE: full + CARGO_TERM_COLOR: always + CARGO_NET_GIT_FETCH_WITH_CLI: true + +jobs: + wasi-check: + runs-on: blacksmith-8vcpu-ubuntu-2404 + + steps: + - uses: actions/checkout@v6 + - uses: ./.github/actions/setup-rust + + - name: Fix permissions on target directory + run: | + mkdir -p ./target + sudo chown -R "$(id -u):$(id -g)" ./target + + # The HTTP-free core compiles for wasm32-wasip1 with no JS-host backend + # and no native HTTP/TLS stack in its dependency tree — the invariant + # the Go/wazero binding of stack-encrypt is built on. See + # docs/stack-encrypt-go-bindings.md and wasm-analysis.md Layer 6. + - name: WASI core check + run: mise run wasm:wasi-check diff --git a/docs/wasm-analysis.md b/docs/wasm-analysis.md index 25374320f..ccb5546b8 100644 --- a/docs/wasm-analysis.md +++ b/docs/wasm-analysis.md @@ -99,6 +99,24 @@ Deploy a real edge function that calls `protect-wasm`, encrypt/decrypt against Z - Round-trip correctness against a server-side native client - Cross-backend ciphertext compatibility — encrypt on wasm (RustCrypto), decrypt on native (aws-lc-rs), and vice versa. This is the cross-backend compat test deferred from earlier. +### Layer 6 — WASI / wazero for the Go SDK [IN PROGRESS] + +Everything above targets `wasm32-unknown-unknown` for a **JavaScript host** (Supabase Edge, browsers, Deno): outbound HTTP rides the host's `fetch`, and reqwest's wasm backend, `getrandom`'s `wasm_js` backend, and `web_time` all lean on JS APIs the host provides. + +The Go Encryption SDK (`goencryption`, formerly `protectgo`) has a different motivation and a different target. It ships six per-platform C static libraries linked via cgo, which forces `CGO_ENABLED=1`, a C toolchain, and a build/commit matrix per OS/arch. Compiling the client to wasm and running it under a pure-Go WebAssembly runtime — **wazero** — removes cgo entirely: one `.wasm` in the module, `CGO_ENABLED=0`, and ordinary `GOOS/GOARCH` cross-compilation. + +But wazero is **not** a JS host. It targets `wasm32-wasip1` (WASI preview 1) — `target_os = "wasi"`, not `"unknown"` — and provides no `fetch`, no `web-sys`, no wasm-bindgen imports. So the JS-oriented Layers 1–5 do not transfer as-is; this is a distinct target with a distinct blocker. + +**History.** PR #2099 was the beachhead: it proved the `ZeroKMSConnection` seam could be satisfied by a single host-imported function (`cipherstash_transport::transport_send`, backed by Go's `net/http`) with request assembly, error mapping, chunked concurrency and client-side key derivation all running unmodified inside the guest, and validated it end to end against a real ZeroKMS. It was written against `cipherstash-client`, which `stack-kms` / `stack-encrypt` replace, so it is not merged as-is; the reusable pieces are re-targeted by the plan below. + +**The measured blocker (2026-08).** `cargo check --target wasm32-wasip1 -p stack-encrypt --no-default-features` fails on exactly two things — tokio (`Only features sync,macros,io-util,rt,time are supported on wasm`) and the `aws-lc-sys` build script — and both are pulled solely by `reqwest 0.13.4` via `stack-auth` and `stack-kms`. On wasip1 reqwest ≥ 0.13.4 selects its *native* backend (hyper / tokio-full / hickory / rustls / aws-lc-sys), where 0.13.2 selected the fetch/wasm-bindgen backend #2099 fought. There is **no** `wasm-bindgen` / `web-sys` / `js-sys` in the wasip1 tree any more. The `aws-lc-sys` failure is rustls's TLS provider inside reqwest, not the AEAD — `vitaminc-encrypt` already selects its pure-Rust `aes-gcm` backend on `cfg(target_arch = "wasm32")`, which covers wasip1. Only the network stack is missing, and it has to be out of the WASI build *by construction*, not by dead-code elimination or version pinning. + +**Architecture: host-provided transport.** HTTP stays out of the wasm and is satisfied by a function the Go host provides; control stays in Rust (the "host orchestrates each step" shape was considered and rejected in #2099 because it smears the protocol state machine across the FFI). Why not HTTP inside the guest: wasip1 has no `sock_connect` (receive/accept only), so outbound TCP needs a host import regardless; TLS in the guest would mean rustls on a pure-Rust provider with embedded roots and no AES-NI, strictly worse than Go's `crypto/tls` with system roots; and `wasi:http` — the right long-term answer — is component model, which wazero does not run. The host can already read guest memory, so routing HTTP through it weakens nothing: what crosses the boundary is exactly what crosses TLS (URL, bearer token, protocol JSON). Data keys, the client key and the index key never do. + +**The plan** lives in [`docs/stack-encrypt-go-bindings.md`](docs/stack-encrypt-go-bindings.md): phases, the vitaminc `bindings/go` layering (`vcvalue` value model + FFI codec are reused; the stack-encrypt side is the cipher/KMS side), the frozen byte formats stack-encrypt owns, and the open decisions. Terminology fixed there: *storage format* (sealed leaf, into a database), *FFI codec* (host ↔ guest marshalling, throwaway), *transport* (HTTP, out of the process). + +**Gate.** `mise run wasm:wasi-check` compiles the HTTP-free core for `wasm32-wasip1` and fails if any crate's normal-dependency tree contains a JS-host backend (`wasm-bindgen`/`web-sys`/`js-sys`) **or** the native HTTP/TLS stack (`reqwest`/`hyper`/`aws-lc-sys`). Phase 0 gates `zerokms-protocol`, `cipherstash-core`, `recipher`, `cts-common`, `cllw-ore`; Phase 1 adds `stack-auth`, `stack-kms`, `stack-encrypt` once reqwest is behind a feature in each. + ## Medium-term direction — `stack-encrypt` replaces `cipherstash-client` Layer 4 as scoped above ports the existing `protect-ffi` neon bindings to wasm. That works, but it's strictly a tactical move — the underlying `cipherstash-client` crate is the long-pole heavy dependency (full reqwest stack, EQL types, config sources, etc.), and `protect-ffi` is a thin async wrapper over it. @@ -140,6 +158,7 @@ Pending decision. The rest of this doc assumes Layer 4 happens for now, but ever - [~] Layer 3.5 — `stack-auth-wasm` bindings crate (#1952) + npm unification (stacked follow-up) - [ ] Layer 4 — wasm bindings for encrypt — **likely superseded by stack-encrypt; pending decision** - [ ] Layer 5 — Supabase Edge validation +- [~] Layer 6 — WASI / wazero for the Go SDK: Phase 0 (gate + plan) landed; Phase 1 (`stack-auth`/`stack-kms`/`stack-encrypt` build for wasip1 without reqwest) stacked on it. Plan: `docs/stack-encrypt-go-bindings.md` ## Layer 1 — what shipped From b9debe7e45e09be7a3e99d9864138e31a9c78f49 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Fri, 28 Aug 2026 15:32:39 +1000 Subject: [PATCH 462/686] docs(wasi): move the Go bindings plan under docs/plans and mark it as indicative The plan was written before the work and is not kept in step with it. Filing it under docs/plans with a caveat at the top keeps reviewers from cross-referencing the implementation against it as if it were a spec. Claude-Session: https://claude.ai/code/session_01HU1Bbw4eQp9kEEneXxDcKr --- .github/imported-workflows/test-wasi.yml | 2 +- docs/plans/stack-encrypt-go-bindings.md | 409 +++++++++++++++++++++++ docs/wasm-analysis.md | 4 +- 3 files changed, 412 insertions(+), 3 deletions(-) create mode 100644 docs/plans/stack-encrypt-go-bindings.md diff --git a/.github/imported-workflows/test-wasi.yml b/.github/imported-workflows/test-wasi.yml index fa51902f1..5153a05cd 100644 --- a/.github/imported-workflows/test-wasi.yml +++ b/.github/imported-workflows/test-wasi.yml @@ -60,6 +60,6 @@ jobs: # The HTTP-free core compiles for wasm32-wasip1 with no JS-host backend # and no native HTTP/TLS stack in its dependency tree — the invariant # the Go/wazero binding of stack-encrypt is built on. See - # docs/stack-encrypt-go-bindings.md and wasm-analysis.md Layer 6. + # docs/plans/stack-encrypt-go-bindings.md and wasm-analysis.md Layer 6. - name: WASI core check run: mise run wasm:wasi-check diff --git a/docs/plans/stack-encrypt-go-bindings.md b/docs/plans/stack-encrypt-go-bindings.md new file mode 100644 index 000000000..a8b560c39 --- /dev/null +++ b/docs/plans/stack-encrypt-go-bindings.md @@ -0,0 +1,409 @@ +# stack-encrypt Go bindings + +> **Plan, not specification.** This document is an indicative sketch of the +> steps required, written before the work was done. It is not kept in step +> with the implementation and must not be used as a formal specification or +> as a reference for reviewing what was actually built: the code, its +> rustdoc and the tests are the source of truth. Where the two disagree, the +> code wins and this document is simply out of date. + +**Status:** in progress — Phase 0 and Phase 1 are open as stacked draft PRs on #2156 +**Date:** 2026-08-27 +**Builds on:** #2099 (WASI/wazero beachhead), #2156 (`#[derive(EncryptFrom, DecryptInto)]`), vitaminc `bindings/go` (`vcvalue` + `vcencrypt`) + +## Goal + +Prove that `stack-encrypt` as it stands after #2156 — ZeroKMS-backed AEAD over +structured values, plus SEM index terms, plus batched records — can be driven +from Go with `CGO_ENABLED=0`, one embedded `.wasm`, and no per-platform build +matrix. The proof is a Go program that, against a real ZeroKMS: + +1. encrypts a slice of records (ciphertext + equality + ORE term per field) + in **one** `generate-data-key` call, +2. decrypts them back in one `retrieve-data-key` call, +3. builds a query probe term locally (no ZeroKMS call) that equals the stored + term, and +4. round-trips ciphertexts and terms with the native Rust example + (`encrypted_record.rs`) in both directions. + +Everything that is not needed for that proof is a follow-up. + +## Terminology + +"Transport" is used with two opposite meanings across vitaminc and #2099. This +plan fixes the vocabulary and the rename is part of the work: + +| term | means | bytes go | +|---|---|---| +| **storage format** / **sealed leaf** | `vcvalue.Sealed`, `stackencrypt.Sealed`, the `[tag] ++ payload` inside the envelope | into a database column; frozen, versioned | +| **FFI codec** (was "transport codec") | marshalling a value or ciphertext *tree* across wasm linear memory in one copy; `FfiValue`, `CT_*` framing, `Encoder`/`Encryptable` | host ↔ guest, inside the process; throwaway, never persisted | +| **transport** | HTTP to ZeroKMS: `cipherstash_transport::transport_send`, the future `stack-transport` crate | out of the process | + +Renames implied: `vitaminc_aead_value::transport` → `::ffi`; the Go codec +module is `vcffi`, not "wire"/"transport"; READMEs say "FFI-only, not a +storage format". "Interop" is avoided as too broad (the reflection encoder is +interop too, but it is API, not a codec). Also: "cipher handle" (not "session +handle"), and the "Status: spike" labels come off the vitaminc `bindings/go` +READMEs — the code was reviewed and tested past that point, and `stackencrypt` +cannot build on something still labelled a spike. + +## What is already done, and where + +The work splits cleanly along the crate boundary, and most of it exists. + +| Concern | Owner | State | +|---|---|---| +| Value model (`Plain`, `Sealed*`, `Object`), `Encryptable`/reflection encode, decode natives | vitaminc `bindings/go/vcvalue` + `vcencrypt` | done — reviewed and tested; the READMEs still self-label "spike", which is stale | +| FFI codec (`FfiValue` tree ↔ bytes, `CipherText` ↔ bytes) — today named "transport" in vitaminc | vitaminc `packages/aead-value/src/transport.rs` (Rust); unexported Go copy inside `vcencrypt` | done, but the Go codec is private to `vcencrypt` | +| Replaying a whole value tree through a `Cipher` in one guest call; `Encrypt for FfiValue` / `Decrypt for FfiValue` | vitaminc `aead-value` | done, generic over any `Cipher` — including `&StackCipher` | +| Guest ABI conventions: `vc_alloc`/`vc_dealloc` with a guest-owned buffer registry + zeroize, packed-`u64` results, status codes, cipher handles, hostile-input validation | vitaminc `vcencrypt/guest/src/abi.rs` | done, but lives inside the `vcencrypt` guest crate | +| Go client shell: wazero runtime, process-wide compilation cache, mutex-serialised instance, sentinel errors, `driver.Valuer`/`sql.Scanner` on leaves | vitaminc `vcencrypt/client.go` | done | +| ZeroKMS transport over a single host import (`cipherstash_transport::transport_send`), `WasiHostConnection: ZeroKMSConnection`, `block_on` inside the guest | suite #2099 (`packages/stack-encrypt/guest`, unmerged) | proven end to end, but written against `cipherstash-client::zerokms::vitur_client` | +| Go host side of that import (`bridge.go`: `net/http` transport, bounds-checked memory ABI, `-check` import-surface gate) | suite #2099 | proven | +| Integration harness: boot `zerokms-server` trusting the mock auth server, mint a token, seed a client, `go test` | suite #2099 (`test:integration:wasi-spike`) | proven | +| `wasm:wasi-check` gate: HTTP-free core compiles for `wasm32-wasip1` with no `wasm-bindgen`/`web-sys`/`js-sys` | suite #2099 | done for `zerokms-protocol`, `cipherstash-core`, `recipher`, `cts-common`, `cllw-ore` | +| `StackCipher` (`Cipher` impl building a pending tree, `seal` batching, `StackDecipher: Decipher`), SEM terms, `EncryptFrom`/`Pending`, derive | suite `stack-encrypt` (#2147, #2156) | done | +| `ZeroKMSConnection` seam in the new client | suite `stack-kms/src/connection.rs` | trait exists; `StackKms` does not use it (see blockers) | + +So the stack-encrypt side of the binding is, as expected, the **cipher/KMS +side**: getting `stack-kms` + `stack-auth` to build for WASI without HTTP, +exposing a `StackCipher` as a cipher handle across the ABI, and defining the +cross-language byte formats stack-encrypt owns (the sealed leaf and the +terms). The value model and the FFI plumbing are vitaminc's and are reused. + +## Blockers found (measured, not predicted) + +Ran `cargo check --target wasm32-wasip1 -p stack-encrypt --no-default-features` +on this branch (`claude/stack-encrypt-derive`): + +``` +error: Only features sync,macros,io-util,rt,time are supported on wasm. + --> tokio-1.49.0/src/lib.rs:481:1 +error: failed to run custom build command for `aws-lc-sys v0.44.0` +``` + +Both come from **`reqwest 0.13.4`** and only from it — via `stack-auth` and +`stack-kms` (the only two crates in the graph that depend on it). On +`wasm32-wasip1` reqwest 0.13.4 selects its *native* backend, dragging in +hyper/tokio-full/hickory/rustls/aws-lc-sys. The good news: **no +`wasm-bindgen`/`web-sys`/`js-sys` anywhere in the wasip1 tree**, so the +JS-host chain #2099 fought is gone; what remains is exactly the follow-up +#2099 named — reqwest has to be out of the WASI build *by construction*, not +by dead-code elimination. + +To be clear about what `aws-lc-sys` is doing there: it is rustls's default +crypto provider for TLS inside reqwest's native backend, not the AEAD. +`vitaminc-encrypt` already selects its pure-Rust `aes-gcm` backend on +`cfg(target_arch = "wasm32")`, which covers wasip1, so the guest's own +cryptography builds today; only the network stack is missing. + +Structural blockers on top of that: + +1. **`StackKms` hard-codes `Client`** + (`packages/stack-kms/src/client.rs:386`). The low-level `Client` is already generic; the high-level wrapper is not, so + there is no way to inject `WasiHostConnection` today. +2. **`stack-auth` uses reqwest unconditionally** in `device_client.rs`, + `access_key_refresher.rs`, `oidc_refresher.rs`, `token.rs`, and + `error.rs` (`RequestError(pub reqwest::Error)`, `From + for AuthError`). `StaticTokenStrategy`, `ServiceToken`, `AuthStrategy` + and the error enum are HTTP-free and are all a first guest needs. +3. **`cfg(target_arch = "wasm32")` currently means "JS host"** in + `stack-auth`/`stack-kms` (fetch semantics, no timeouts, `MaybeSend` + drops `Send`). WASI under wazero is single-threaded too, so the `Send` + relaxations are fine, but comments and a few branches (`AutoStrategy`'s + wasm arm) assume no filesystem and a JS credential. Nothing breaks the + proof; it needs tidying before anything ships. +4. **`SealedValue` has no frozen byte layout.** It derives serde and offers + `into_parts()`, but the FFI codec is generic over `Leaf: + AsRef<[u8]> + From>` and a Go database column needs *one* byte + string. stack-encrypt states this leaf is "the only byte-format commitment + the crate makes"; the commitment has to be written down. +5. **Terms have no cross-language byte encoding** in stack-encrypt. + `EqualityTerm` is 32 bytes; `MatchTerm` is `Vec` positions; + `OreTerm`/`OpeTerm` wrap `cllw_ore::…::Output`, whose serialisation + lives in the EQL layer today. + +## Architecture + +Same shape as #2099 and the vitaminc Go bindings, applied to `stack-encrypt`: + +``` +Go application + └─ github.com/cipherstash/…/stackencrypt (Go, CGO_ENABLED=0) + ├─ imports vcvalue (value model) + the FFI codec + ├─ embeds stack_encrypt_guest.wasm + ├─ host import cipherstash_transport::transport_send → net/http → ZeroKMS + └─ host import cipherstash_auth::token_get → token source (phase 1: static) + │ + ▼ wazero (wasm32-wasip1) + stack-encrypt guest (Rust cdylib) + ├─ StackCipher> (one per handle) + ├─ FfiValue.encrypt_with_aad(&cipher, aad) → PendingStackCipherText → seal(kms) (block_on) + ├─ record plan → per-field EncryptFrom pendings → Pending::all → one generate_keys + └─ term(value, context, kind) → local PRF/ORE, no I/O +``` + +Control stays in Rust: request assembly, key derivation, batching, AAD/PRF +context binding all run unmodified inside the guest. What crosses the boundary +per call is a value tree in, a ciphertext/record tree out, and — inside the +call — the same bytes that would cross TLS anyway. The client key enters guest +memory once at `init`; derived data keys and the index key never leave. + +## Phases + +### Phase 0 — salvage #2099 onto `stack-kms` + +#2099 targets `cipherstash-client`, which is being replaced by `stack-kms` +(no parity fixes go into the old crate). Rebase the reusable pieces rather +than the branch: + +- `wasm:wasi-check` mise task — keep, extend to `stack-kms`, `stack-auth`, + `stack-encrypt`, and add `reqwest|tokio|aws-lc-sys` to the forbidden-tree + grep (the wasip1 failure mode is now native-backend, not JS-backend). +- `WasiHostConnection` — port from `cipherstash_client::zerokms::vitur_client` + to `stack_kms::ZeroKMSConnection` (the trait shape is identical; the port + is mechanical). +- `bridge.go` `transportSend` host function and `checkImports` — keep as the + host side; drop the msgpack request/response types (superseded by the + vitaminc FFI codec). +- `test:integration:wasi-spike` harness (zerokms-server on a dedicated port, + mock auth issuer override, mint token, seed client) — keep, rename. +- `wasm-analysis.md` Layer 6 — rewritten for the stack-kms target and the + measured wasip1 blocker. +- Close #2099 with a pointer here; nothing from it merges as-is. + +### Phase 1 — `stack-kms` and `stack-auth` build for WASI without HTTP + +Smallest change that makes the guest link, with the seam in the right place +for the later `stack-transport` refactor: + +**stack-kms** + +- `StackKms`; `StackKmsBuilder::with_connection(conn)` + (or a `connect_with` constructor) so the guest can pass + `WasiHostConnection`. `get_token`'s `ensure_base_url` moves behind a small + trait method on the connection (`HttpConnection` needs it; the host + connection ignores it — the Go host owns the URL). +- `HttpConnection` + `HttpConnectionOpts` behind `#[cfg(not(target_os = + "wasi"))]` (or a default-on `http` feature — pick one and use the same in + stack-auth). The `ZeroKMSConnection` trait, `Client`, key derivation, + `DataKeySource`/`IndexKeySource` stay unconditional. + +**stack-auth** + +- Default-on `http` feature gating everything that touches reqwest: + `device_client`, `access_key_refresher`, `oidc_refresher`, the + `AutoStrategy`/`AccessKeyStrategy`/`DeviceSession`/`OidcFederation` + strategies, `RequestError`, `From`. Left unconditional: + `AuthStrategy`, `AuthStrategyBounds`, `ServiceToken`, `StaticTokenStrategy`, + `Token`, `AuthError` (minus the `Request` variant's payload), `SecretToken`. +- The guest's `HostTokenStrategy` (below) implements `AuthStrategy` over a + host import, so nothing else is required for the proof. + +**stack-encrypt** + +- `StackCipher::new()` / `StackCipherBuilder` are native-only + (they use `AutoStrategy` + profile); gate them the same way. The generic + `StackCipherBuilder::kms(k).keyset(..).init()` path is what the guest + uses and needs no change. +- Gate: `wasm:wasi-check` now passes for all three crates. + +### Phase 2 — frozen byte formats stack-encrypt owns + +These are storage commitments, so they get decided and documented before the +guest is written, independently of Go: + +- **`SealedValue` leaf**: `to_bytes()` / `from_bytes()` with a canonical + layout, e.g. `version(1) ‖ iv ‖ u16 tag_len ‖ tag ‖ local_ciphertext` + (`local_ciphertext` is already `version ‖ nonce ‖ ct ‖ gcm_tag`). Bind the + outer version byte into the leaf AAD the way vitaminc binds its version + byte, so a relabelled leaf fails authentication rather than parsing. Rust + `impl AsRef<[u8]>`-style access for the codec's `Leaf` bounds comes for + free. +- **Terms**: `EqualityTerm` — the 32 bytes as-is. `MatchTerm` — `u16` + little-endian positions. `OreTerm`/`OpeTerm` — adopt the EQL encoding of + the underlying `cllw-ore` output rather than inventing one; Postgres is + the real consumer and Go rows must be comparable with rows the Rust/EQL + path wrote. Needs a look at what `eql-bindings` emits today. +- Add these as `#[cfg(test)]` golden vectors in `stack-encrypt` (Rust + encodes → fixed hex) so the Go decoder tests against the same bytes. + +### Phase 3 — the guest + +Location: `bindings/go/stackencrypt/guest/` (mirrors vitaminc's layout; +detached workspace like #2099's guest and the fuzz crates so its wasm profile +never leaks into workspace builds). Dependencies: `stack-encrypt`, +`stack-kms`, `stack-auth` (all `default-features = false`), +`vitaminc-aead-value` (FFI codec + `FfiValue`), `futures` (`block_on`), +`zeroize`. + +Reuse from vitaminc: extract `vcencrypt/guest/src/{abi,sessions,status}.rs` +buffer-registry/packing/status code into a small shared crate +(`vitaminc-wasi-abi` or similar) so both guests share one ABI implementation +instead of a copy. This is the one change the plan asks of vitaminc's guest +side. + +Exports (same conventions as `vc_*`: host owns buffers, `se_dealloc` zeroizes +via the registry, packed `u64` results, status in the low word on error): + +| export | does | +|---|---| +| `se_alloc(len)` / `se_dealloc(ptr, len)` | buffer lifecycle, as vitaminc | +| `se_cipher_init(cfg_ptr, cfg_len) → handle` | config (client id, client key, keyset id/name or default) encoded as an `FfiValue` object — no second codec. Builds `StackKms`, then `StackCipherBuilder::kms(..).keyset(..).init()` under `block_on` (one `load_keyset` call — the index key is now held in the guest). Client-key bytes zeroized after `ClientKey` is built. | +| `se_cipher_free(handle)` | drops the `StackCipher` (index key, client key wiped by `ZeroizeOnDrop`) | +| `se_encrypt(handle, value, aad)` / `se_decrypt(handle, ct, aad)` | decode `FfiValue` → `encrypt_with_aad(&cipher, aad)` → `seal(kms)` (`block_on`) → `encode_ciphertext::`. Decrypt mirrors via `cipher.decipher(ct)` + `FfiValue::decrypt_with_aad`. | +| `se_encrypt_element` / `se_decrypt_element` | as vitaminc; row-at-a-time interop with batch-encrypted slices | +| `se_encrypt_record(handle, source, plan, aad)` | the runtime form of `#[derive(EncryptFrom)]`: `plan` is an `FfiValue` object `{ field → { context, outputs: [c \| eq \| match(opts) \| ore \| ope] } }`; per field the guest dispatches on the source `FfiValue` variant to the typed `EncryptFrom` impls (`u32`/`u64`/`i64`/`f64`/`String`), zips the pendings, `Pending::all` across an array source, and returns `{ field → { c: leaf, hm: bytes, ob: bytes, … } }`. One `generate_keys` call per invocation regardless of row count. | +| `se_decrypt_record(handle, record, plan, aad)` | inverse; only the `c` outputs participate | +| `se_term(handle, value, context, kind)` | query probe; local PRF/ORE only, never touches ZeroKMS | + +Host imports (two, both from the `cipherstash_transport` module #2099 +defined): + +- `transport_send(method, url, headers, body) → (status, headers, body)` — + #2099's import generalised from its ZeroKMS shape `(endpoint, token, + body)` to a plain HTTP request, mirroring `wasi:http/outgoing-handler`. + Two reasons: `stack-auth`'s refreshers talk to CTS (a different host) and + will reuse the same import in Phase 6; and when wazero grows component + support, the WASI impl of `stack-transport` swaps this import for + `wasi:http` without changing the trait. The bearer token crosses as a + header — the same bytes cross TLS anyway. +- `token_get() → token` — Phase 1 auth: the Go host hands over a bearer + token (in the proof, minted by the harness / read from the CLI profile, + exactly as #2099's `mint-dev-token.sh` did). `HostTokenStrategy: + AuthStrategy` wraps it. Refresh stays on the host until Phase 6. + +Why host-provided HTTP and not HTTP inside the guest: wasip1 has no +`sock_connect` (receive/accept only), so outbound TCP needs a host import +regardless; TLS in the guest would mean rustls on a pure-Rust provider with +embedded roots and no AES-NI, strictly worse than Go's `crypto/tls` with +system roots; and `wasi:http` (the right long-term answer) is component +model, which wazero does not run. The host can already read guest memory, so +routing HTTP through it weakens nothing — what crosses the boundary is what +crosses TLS. + +Wasm is single-threaded and the host function blocks inside the guest call, +so the whole instance is held for the duration of a ZeroKMS round trip. Fine +for the proof; the Go side pools instances later. + +### Phase 4 — the Go module + +`bindings/go/stackencrypt` (module path TBD — see decisions). Imports +`vcvalue` for the model. Surface mirrors `vcencrypt` so the two feel like one +SDK: + +```go +client, _ := stackencrypt.NewClient(ctx, stackencrypt.Config{ + Transport: http.DefaultClient, // or any RoundTripper + Token: stackencrypt.StaticToken(tok), // phase-1 auth +}) +cipher, _ := client.NewCipher(ctx, stackencrypt.CipherConfig{ + ClientID: id, ClientKey: key, Keyset: "users", +}) + +ct, _ := cipher.Encrypt(ctx, user, aad) // map[string]any of stackencrypt.Sealed / vcvalue.Plain +pt, _ := cipher.Decrypt(ctx, ct, aad) + +rows, _ := cipher.EncryptRecords(ctx, users, aad) // one ZeroKMS call for the slice +probe, _ := cipher.Term(ctx, uint32(34), "users/age", stackencrypt.Equality) +``` + +- `stackencrypt.Sealed` — the Phase 2 leaf; `driver.Valuer` + `sql.Scanner` + like `vcvalue.Sealed`. Distinct type on purpose: a stack-encrypt leaf is + not decryptable by `vcencrypt` and must not scan into its `Sealed`. +- Record plans come from struct tags, the Go stand-in for the derive: + + ```go + type User struct { + ID int64 `stash:"plain"` + Age uint32 `stash:"context=users/age,index=eq;ore"` + Email string `stash:"context=users/email,index=eq;match"` + } + ``` + + Reflection builds the plan `FfiValue` once per type (cached) and sends + source + plan in one call. Terms come back as `stackencrypt.Term` / + `OreTerm` byte types with `Valuer`/`Scanner` and `Equal`/`Less` helpers. +- Errors: vitaminc's sentinels plus the ZeroKMS request kinds + (`ErrUnauthorized`, `ErrForbidden`, `ErrNotFound`, `ErrConflict`, + `ErrTransport`) mapped from `ViturRequestErrorKind` via status codes, so + the Go caller can distinguish a bad token from a tampered ciphertext. +- Codec: whatever the decision on the FFI codec's home is, the Go code must + not fork it. + +### Phase 5 — validation and CI + +- `mise run test:integration:wasi-go` (renamed #2099 harness): boots + `zerokms-server` against the mock auth server, mints a token, seeds a + client + keyset, then `CGO_ENABLED=0 go test ./...` in + `bindings/go/stackencrypt`. +- Go tests: import-surface gate (exactly WASI + `cipherstash_transport`), + stub-transport tests for the bridge, live encrypt/decrypt, live + `EncryptRecords` asserting **one** `transport_send` for N rows + (`TransportSends()` counter from #2099), probe-equals-stored-term, hostile + ABI inputs (port `abi_hostile_test.go`). +- Cross-language fixtures, both directions: a Rust `gen_fixture` example + (as vitaminc's) writes ciphertexts + terms + the AAD/context used; Go + decrypts and compares terms. And the reverse: Go writes a fixture the Rust + `encrypted_record` example decrypts. Terms must be byte-equal across + languages, not just "decryptable". +- CI: extend `test-stack-encrypt.yml` (from #2099) with the wasi build + + Go job; `wasm:wasi-check` in the blocking PR lane. + +### Phase 6 — follow-ups (explicitly out of the proof) + +- **`stack-transport`** — the trait crate #2099 proposed: `HttpTransport` + with reqwest and WASI-host-import impls, consumed by `stack-kms`'s + connection *and* `stack-auth`'s refreshers. That moves access-key exchange + and refresh into the guest (`AccessKeyStrategy` generic over transport), + retires `token_get`, and makes the `http` feature gates of Phase 1 + collapse into a transport choice. Bigger refactor; only start it once the + proof shows the shape is right. +- Instance pool in the Go client for parallelism; per-instance memory + limits. +- `cfg(target_arch = "wasm32")` → split JS-host vs WASI where semantics + differ (timeouts, filesystem, credential discovery). +- Component model / WIT when wazero supports it (tracked in vitaminc's + README, same decision here). +- Publishing: the `.wasm` build must be reproducible (pinned toolchain, + `opt-level = "s"`, `lto`, `strip`); the Go module ships from a public repo + — this monorepo is private, so the proof's module path is temporary. +- Retire `goencryption`'s cgo static-library matrix once parity is reached. + +## Decisions to make first + +1. **Where the Go FFI codec lives.** `vcvalue`'s README deliberately + keeps it out of `vcvalue`; today it is unexported inside `vcencrypt`. + Options: (a) a third vitaminc module `bindings/go/vcffi` (codec + + `Encoder`/`Encryptable`, still zero external deps; the ciphertext + decoder parameterised over the leaf type so `stackencrypt.Sealed` is + not `vcvalue.Sealed`) that both `vcencrypt` + and `stackencrypt` import; (b) vendor a copy into `stackencrypt`. + Recommend (a): one codec, one set of fuzz tests. +2. **`SealedValue` byte layout** (Phase 2). Needs a version byte and the + AAD binding decision; it is a storage format from the first Go row + written. +3. **Term encodings** — align with EQL or define stack-encrypt's own. + Recommend align: Postgres is the consumer that matters. +4. **Phase-1 auth**: host-supplied token (`token_get`) versus doing + `stack-transport` first. Recommend host-supplied — it is what #2099 + proved, it keeps the proof to one new seam, and the refactor is better + informed after the proof. +5. **Feature vs `cfg(target_os = "wasi")`** for gating reqwest in + `stack-kms`/`stack-auth`. A feature is honest about "this build has no + HTTP" and lets native tests exercise the HTTP-free path; a cfg is + invisible to callers. Recommend the feature (`http`, default on). +6. **Repo location / module path** for the proof: `bindings/go/stackencrypt` + in this repo (mirrors vitaminc) versus straight into a new public SDK + repo. Recommend here for the proof; it needs the integration harness. + +## Non-goals for the proof + +- Typed Rust-static ↔ Go interop (vitaminc's decision 4 stands: fidelity + comes from the schema/plan layer, not value tags). +- Device/OIDC/access-key auth flows inside the guest (Phase 6). +- EQL wire payloads (`{v,i,c,ob}` JSON) — that is `eql-bindings`' layer; the + Go binding returns the record tree and leaves EQL framing to a schema-aware + consumer, exactly as the Rust side does. +- Performance beyond "one ZeroKMS call per batch". diff --git a/docs/wasm-analysis.md b/docs/wasm-analysis.md index ccb5546b8..f88b405c7 100644 --- a/docs/wasm-analysis.md +++ b/docs/wasm-analysis.md @@ -113,7 +113,7 @@ But wazero is **not** a JS host. It targets `wasm32-wasip1` (WASI preview 1) — **Architecture: host-provided transport.** HTTP stays out of the wasm and is satisfied by a function the Go host provides; control stays in Rust (the "host orchestrates each step" shape was considered and rejected in #2099 because it smears the protocol state machine across the FFI). Why not HTTP inside the guest: wasip1 has no `sock_connect` (receive/accept only), so outbound TCP needs a host import regardless; TLS in the guest would mean rustls on a pure-Rust provider with embedded roots and no AES-NI, strictly worse than Go's `crypto/tls` with system roots; and `wasi:http` — the right long-term answer — is component model, which wazero does not run. The host can already read guest memory, so routing HTTP through it weakens nothing: what crosses the boundary is exactly what crosses TLS (URL, bearer token, protocol JSON). Data keys, the client key and the index key never do. -**The plan** lives in [`docs/stack-encrypt-go-bindings.md`](docs/stack-encrypt-go-bindings.md): phases, the vitaminc `bindings/go` layering (`vcvalue` value model + FFI codec are reused; the stack-encrypt side is the cipher/KMS side), the frozen byte formats stack-encrypt owns, and the open decisions. Terminology fixed there: *storage format* (sealed leaf, into a database), *FFI codec* (host ↔ guest marshalling, throwaway), *transport* (HTTP, out of the process). +**The plan** lives in [`docs/plans/stack-encrypt-go-bindings.md`](docs/plans/stack-encrypt-go-bindings.md): phases, the vitaminc `bindings/go` layering (`vcvalue` value model + FFI codec are reused; the stack-encrypt side is the cipher/KMS side), the frozen byte formats stack-encrypt owns, and the open decisions. Terminology fixed there: *storage format* (sealed leaf, into a database), *FFI codec* (host ↔ guest marshalling, throwaway), *transport* (HTTP, out of the process). **Gate.** `mise run wasm:wasi-check` compiles the HTTP-free core for `wasm32-wasip1` and fails if any crate's normal-dependency tree contains a JS-host backend (`wasm-bindgen`/`web-sys`/`js-sys`) **or** the native HTTP/TLS stack (`reqwest`/`hyper`/`aws-lc-sys`). Phase 0 gates `zerokms-protocol`, `cipherstash-core`, `recipher`, `cts-common`, `cllw-ore`; Phase 1 adds `stack-auth`, `stack-kms`, `stack-encrypt` once reqwest is behind a feature in each. @@ -158,7 +158,7 @@ Pending decision. The rest of this doc assumes Layer 4 happens for now, but ever - [~] Layer 3.5 — `stack-auth-wasm` bindings crate (#1952) + npm unification (stacked follow-up) - [ ] Layer 4 — wasm bindings for encrypt — **likely superseded by stack-encrypt; pending decision** - [ ] Layer 5 — Supabase Edge validation -- [~] Layer 6 — WASI / wazero for the Go SDK: Phase 0 (gate + plan) landed; Phase 1 (`stack-auth`/`stack-kms`/`stack-encrypt` build for wasip1 without reqwest) stacked on it. Plan: `docs/stack-encrypt-go-bindings.md` +- [~] Layer 6 — WASI / wazero for the Go SDK: Phase 0 (gate + plan) landed; Phase 1 (`stack-auth`/`stack-kms`/`stack-encrypt` build for wasip1 without reqwest) stacked on it. Plan: `docs/plans/stack-encrypt-go-bindings.md` ## Layer 1 — what shipped From 7695e3c0afb7532b0256689641c31fe3992a9317 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Mon, 31 Aug 2026 11:09:12 +1000 Subject: [PATCH 463/686] ci(wasi): fail closed over the gate's full build inputs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The five checked crates' WASI dependency graph is shaped by more than their own directories: they pull other local packages/ crates (e.g. zerokms-protocol -> cipherstash-config), inherit workspace deps and features from the root Cargo.toml, and .cargo/ config can alter target compilation — none of which necessarily touches Cargo.lock. Trigger on packages/**, Cargo.toml and .cargo/** (keeping the .md/.example exclusions) instead of enumerating a closure that would silently drift. Claude-Session: https://claude.ai/code/session_01BpqczxAVwUsTYdCRWh9dYb --- .github/imported-workflows/test-wasi.yml | 25 ++++++++++++------------ 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/.github/imported-workflows/test-wasi.yml b/.github/imported-workflows/test-wasi.yml index 5153a05cd..1664d043a 100644 --- a/.github/imported-workflows/test-wasi.yml +++ b/.github/imported-workflows/test-wasi.yml @@ -4,14 +4,17 @@ on: branches: - main paths: - # Every crate wasm:wasi-check gates must trigger the gate, or a crate - # can reintroduce a wasm-incompatible dep and merge green. - - packages/zerokms-protocol/** - - packages/cipherstash-core/** - - packages/recipher/** - - packages/cts-common/** - - packages/cllw-ore/** + # The gate must be fail-closed over its build inputs, not just the five + # checked crates: their dependency closure includes other local + # packages/ crates (e.g. zerokms-protocol -> cipherstash-config), and + # root Cargo.toml (workspace deps/features) or .cargo/ config can + # change what compiles for the target without touching Cargo.lock. + # Trigger on all of packages/ rather than enumerating the closure, + # which would silently drift. + - packages/** + - Cargo.toml - Cargo.lock + - .cargo/** - mise.toml - .github/workflows/test-wasi.yml # Ordering matters, this needs to stay at the end if we want to include the above, but exclude all .md files. @@ -20,12 +23,10 @@ on: pull_request: paths: - - packages/zerokms-protocol/** - - packages/cipherstash-core/** - - packages/recipher/** - - packages/cts-common/** - - packages/cllw-ore/** + - packages/** + - Cargo.toml - Cargo.lock + - .cargo/** - mise.toml - .github/workflows/test-wasi.yml # Ordering matters, this needs to stay at the end if we want to include the above, but exclude all .md files. From 487a533b2f3ed0c0234b66506166115e10f74714 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Sat, 29 Aug 2026 00:10:37 +1000 Subject: [PATCH 464/686] feat(stack-encrypt): context is a trait parameter; `encrypt_into` needs none when the type carries its own MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `EncryptFrom` / `DecryptInto`: the context moves from a method generic to a trait parameter so that each implementation can bound it. The leaves (`StackCipherText`, the sem terms) demand the new `SuppliedContext` marker — every vitaminc context type except `()` — a record inherits that through its field bounds, and a row whose fields all carry a literal context leaves `Ctx` unbounded. The call-site sugar splits the way vitaminc's `encrypt` / `encrypt_with_aad` does, with the choice made by the output type at compile time: let row: EncryptedUser = user.encrypt_into(&cipher).await?; // passes () let term: EqualityTerm = 42u32.encrypt_into_with_context(&cipher, "users/age").await?; let user = User::decrypt_from(row, &cipher).await?; `42u32.encrypt_into(&cipher)` against a leaf is a compile error that names `encrypt_into_with_context` (`#[diagnostic::on_unimplemented]` on both the trait and the marker; pinned by `tests/ui/leaf_without_context.rs`). The runtime empty-string check stays at the leaves until vitaminc#291 carries non-emptiness in the type. The derives thread the parameter through: whole-source fields are bounded under the context they are derived under (`__Ctx`, or `&'static str` for a literal), and a `from` field that takes the caller's context — whose obligation is checked in the body against a source field type the derive cannot name — makes the impl demand `SuppliedContext` outright. Claude-Session: https://claude.ai/code/session_01HU1Bbw4eQp9kEEneXxDcKr --- ...nc-shape-for-target-directed-encryption.md | 43 +- docs/target-directed-encryption.md | 78 ++-- .../stack-encrypt-derive/docs/attributes.md | 13 +- packages/stack-encrypt-derive/src/decrypt.rs | 111 +++-- packages/stack-encrypt-derive/src/encrypt.rs | 91 +++- packages/stack-encrypt-derive/src/lib.rs | 26 +- packages/stack-encrypt-derive/src/shape.rs | 47 ++- .../examples/encrypted_record.rs | 32 +- .../stack-encrypt/examples/search_terms.rs | 29 +- packages/stack-encrypt/src/lib.rs | 2 +- packages/stack-encrypt/src/sem/mod.rs | 39 +- packages/stack-encrypt/src/target/mod.rs | 396 ++++++++++++------ packages/stack-encrypt/tests/derive.rs | 121 ++++-- packages/stack-encrypt/tests/target.rs | 254 +++++++---- .../tests/ui/leaf_without_context.rs | 26 ++ .../tests/ui/leaf_without_context.stderr | 90 ++++ 16 files changed, 992 insertions(+), 406 deletions(-) create mode 100644 packages/stack-encrypt/tests/ui/leaf_without_context.rs create mode 100644 packages/stack-encrypt/tests/ui/leaf_without_context.stderr diff --git a/docs/rfcs/0002-async-shape-for-target-directed-encryption.md b/docs/rfcs/0002-async-shape-for-target-directed-encryption.md index 72a87fdbe..3cd29764e 100644 --- a/docs/rfcs/0002-async-shape-for-target-directed-encryption.md +++ b/docs/rfcs/0002-async-shape-for-target-directed-encryption.md @@ -150,21 +150,24 @@ pub trait EncryptTarget { type Output<'a, T: 'a>: 'a where Self: 'a; } -pub trait EncryptFrom: Sized { - fn encrypt_from<'a, 'c, Ctx>(source: &'a S, cipher: &'a C, ctx: Ctx) -> C::Output<'a, Self> +pub trait EncryptFrom: Sized { + fn encrypt_from<'a>(source: &'a S, cipher: &'a C, ctx: Ctx) -> C::Output<'a, Self> where - Ctx: EncryptContext<'c>; + Self: 'a; } ``` +(`Ctx` became a trait parameter after this RFC was accepted — see the +deviations in §7. The shape argued for here is unchanged by it.) + - A synchronous cipher sets `Output<'a, T> = Result`. No future, no `.await`. - `StackCipher` sets `Output<'a, T> = PendingEncrypted<'a, T, K>` (§4.3), which implements `IntoFuture`. ```rust -let t: EqualityTerm = "alice".encrypt_into(&stack_cipher, "users/email").await?; // async backend -let t: LocalTerm = "alice".encrypt_into(&local_cipher, "users/email")?; // sync backend +let t: EqualityTerm = "alice".encrypt_into_with_context(&stack_cipher, "users/email").await?; // async backend +let t: LocalTerm = "alice".encrypt_into_with_context(&local_cipher, "users/email")?; // sync backend ``` (The bounds above are the shape, not the final spelling — `K: 'a` and the @@ -266,8 +269,8 @@ up. Composites combine pendings **without awaiting them**, so requests merge: ```rust -impl EncryptFrom> for EncryptedInt { - fn encrypt_from<'a, 'c, Ctx>(source: &'a u32, cipher: &'a StackCipher, ctx: Ctx) +impl<'c, K, Ctx: EncryptContext<'c> + SuppliedContext<'c>> EncryptFrom, Ctx> for EncryptedInt { + fn encrypt_from<'a>(source: &'a u32, cipher: &'a StackCipher, ctx: Ctx) -> PendingEncrypted<'a, Self, K> { StackCipherText::encrypt_from(source, cipher, ctx.clone()) @@ -286,14 +289,14 @@ see §7). Then the missing piece from §2.2: ```rust -impl EncryptFrom, StackCipher> for Vec where T: EncryptFrom> -impl EncryptFrom, StackCipher> for Option +impl EncryptFrom, StackCipher, Ctx> for Vec where T: EncryptFrom, Ctx>, Ctx: Clone +impl EncryptFrom, StackCipher, Ctx> for Option ``` which makes the column one operation, one await, one round-trip: ```rust -let table: Vec = ages.encrypt_into(&cipher, CONTEXT).await?; +let table: Vec = ages.encrypt_into_with_context(&cipher, CONTEXT).await?; ``` Batching comes from the **source shape**, exactly as it does for `Encrypt`. @@ -383,17 +386,18 @@ The current module docs teach external term authors to return all until a deferred PRF exists: ```rust -impl EncryptFrom> for MyTerm +impl<'c, S, K, Ctx> EncryptFrom, Ctx> for MyTerm where S: PrfValue + Clone, + Ctx: EncryptContext<'c> + SuppliedContext<'c>, { - fn encrypt_from<'a, 'c, Ctx>( + fn encrypt_from<'a>( source: &'a S, cipher: &'a StackCipher, context: Ctx, ) -> PendingEncrypted<'a, Self, K> where - Ctx: EncryptContext<'c>, + Self: 'a, { let context = context.into_prf_context().into_owned(); let context = PrfContext::pae(&[b"my-crate/my-term/v1".as_slice(), context.as_bytes()]); @@ -495,6 +499,19 @@ The implementation kept the design and changed three names/details: its name. - **`DecryptContext`** (`IntoAad + Clone`) joined `EncryptContext`: decryption derives nothing, so it must not demand a PRF conversion. +- **The context is a trait parameter** — `EncryptFrom`, + `DecryptInto` — not a method generic. A method generic is bound + once, by the trait, so no implementation could refuse a context it cannot + use; as a trait parameter each impl bounds it. The leaves demand + `SuppliedContext` (every vitaminc context type but `()`), records inherit + that through their field bounds, and a row whose fields carry their own + contexts leaves it unbounded. The sugar splits accordingly, after + vitaminc's `encrypt` / `encrypt_with_aad`: `encrypt_into(&cipher)` passes + `()` and exists only for outputs that need nothing from the caller; + `encrypt_into_with_context(&cipher, ctx)` for the rest (`decrypt_from` / + `decrypt_from_with_context` mirror it). Which applies is the type's + decision, made at compile time; runtime rejection is left to what the + type cannot see — an empty string — pending vitaminc#291. - **`dispatch` issues one call per request *kind*** (at most one `generate_keys` + one `retrieve_keys`, sequentially — a mixed batch is rare today). When ZeroKMS grows the combined keys-plus-PRF operation, `dispatch` diff --git a/docs/target-directed-encryption.md b/docs/target-directed-encryption.md index ca13b0f2b..2538401b5 100644 --- a/docs/target-directed-encryption.md +++ b/docs/target-directed-encryption.md @@ -27,7 +27,7 @@ vitaminc today gives us the ciphertext (`Encrypt` / `Cipher`) and a PRF (`PrfVal We want the target type to answer all three questions, so that this compiles only when the pieces line up: ```rust -let x: IntegerOrdOre = 10.encrypt_into(&cipher, "users/age").await?; +let x: IntegerOrdOre = 10.encrypt_into_with_context(&cipher, "users/age").await?; ``` **This must not be EQL-specific.** EQL payloads are one class of output. Nothing in the mechanism should know what a table or a column is. @@ -59,11 +59,11 @@ The root cause of 1 and 2 is direction: the *caller* assembles the record, so th Invert it. One trait, on the output type, describing what that type is: ```rust -/// `Self` is an encrypted representation of `S`, producible by a cipher `C`. -pub trait EncryptFrom: Sized { - fn encrypt_from<'a, 'c, Ctx>(source: &'a S, cipher: &'a C, context: Ctx) -> C::Output<'a, Self> +/// `Self` is an encrypted representation of `S`, producible by a cipher `C`, +/// under a context `Ctx`. +pub trait EncryptFrom: Sized { + fn encrypt_from<'a>(source: &'a S, cipher: &'a C, context: Ctx) -> C::Output<'a, Self> where - Ctx: EncryptContext<'c>, Self: 'a; } @@ -78,18 +78,24 @@ Reads as a noun: *`EqualityTerm` is an encrypted form of `&str`*. The output shape belongs to the **cipher**, not the trait — the same rule vitaminc follows for `Cipher::Ok` and `Prf::Ok`. A cipher that does no I/O sets `Output<'a, T> = Result`: no future, no `.await`. `StackCipher` sets `Output<'a, T> = Pending<'a, T, K>`, a request carrier: a local term resolves immediately, a ciphertext queues its data-key request, and merged pendings settle in one batched ZeroKMS call when awaited. The trait fixes neither a future nor an error type; RFC 0002 records why it must not. -The call-site sugar is `Into` over `From` — blanket, never implemented by hand: +The context is a parameter of the trait, not of the method, so that an implementation can say which contexts it accepts — see [Context](#context) below. The call-site sugar is `Into` over `From` — blanket, never implemented by hand — in two forms, the split of vitaminc's `encrypt` / `encrypt_with_aad`: ```rust -pub trait EncryptExt { - fn encrypt_into<'a, 'c, T, C, Ctx>(&'a self, cipher: &'a C, context: Ctx) -> C::Output<'a, T> +pub trait EncryptInto { + /// Passes `()`: exists only for a `T` that needs no context from the caller. + fn encrypt_into<'a, T, C>(&'a self, cipher: &'a C) -> C::Output<'a, T> where C: EncryptTarget, - T: EncryptFrom + 'a, - Ctx: EncryptContext<'c>, + T: EncryptFrom + 'a, + Self: Sized; + + fn encrypt_into_with_context<'a, T, C, Ctx>(&'a self, cipher: &'a C, context: Ctx) -> C::Output<'a, T> + where + C: EncryptTarget, + T: EncryptFrom + 'a, Self: Sized; } -impl EncryptExt for S { /* delegates to T::encrypt_from */ } +impl EncryptInto for S { /* delegates to T::encrypt_from */ } ``` ### Leaves are handwritten; composites are assembled @@ -97,10 +103,11 @@ impl EncryptExt for S { /* delegates to T::encrypt_from */ } **Leaves** are the single-primitive types. Each names exactly one primitive, and that is the *only* place in the design where a primitive is named. As shipped in `stack-encrypt`: ```rust -impl EncryptFrom> for StackCipherText where S: Encrypt + Clone { ... } -impl EncryptFrom> for EqualityTerm where S: PrfValue + Clone { ... } -impl EncryptFrom> for MatchTerm where S: AsRef, O: MatchConfig { ... } -impl EncryptFrom> for OreTerm where S: CllwOreEncrypt + Clone + Send + 'static, S::Output: Send + 'static { ... } +// Every leaf, with `Ctx: EncryptContext<'c> + SuppliedContext<'c>` — a leaf refuses `()` by type. +impl<'c, S, K, Ctx> EncryptFrom, Ctx> for StackCipherText where S: Encrypt + Clone { ... } +impl<'c, S, K, Ctx> EncryptFrom, Ctx> for EqualityTerm where S: PrfValue + Clone { ... } +impl<'c, S, K, O, Ctx> EncryptFrom, Ctx> for MatchTerm where S: AsRef, O: MatchConfig { ... } +impl<'c, S, K, Ctx> EncryptFrom, Ctx> for OreTerm where S: CllwOreEncrypt + Clone + Send + 'static, S::Output: Send + 'static { ... } ``` EQL's wire newtypes (`Ciphertext`, `Hmac256`, `OreBlock256`) get the same treatment in `eql-bindings`, which owns them; encoding decisions (base85, block width) belong there, not in vitaminc or stack-encrypt. @@ -110,9 +117,11 @@ EQL's wire newtypes (`Ciphertext`, `Hmac256`, `OreBlock256`) get the same treatm **Composites** fan out to each field's impl, merge the outputs, and assemble. Today that is written by hand — one impl, in the shape the derive will eventually generate: ```rust -impl EncryptFrom> for EncryptedInt { - fn encrypt_from<'a, 'c, Ctx>(source: &'a u32, cipher: &'a StackCipher, context: Ctx) -> Pending<'a, Self, K> - where Ctx: EncryptContext<'c>, Self: 'a, +impl<'c, K, Ctx> EncryptFrom, Ctx> for EncryptedInt +where Ctx: EncryptContext<'c> + SuppliedContext<'c>, // what the leaves below demand +{ + fn encrypt_from<'a>(source: &'a u32, cipher: &'a StackCipher, context: Ctx) -> Pending<'a, Self, K> + where Self: 'a, { StackCipherText::encrypt_from(source, cipher, context.clone()) .zip(EqualityTerm::encrypt_from(source, cipher, context.clone())) @@ -122,7 +131,7 @@ impl EncryptFrom> for EncryptedInt { } ``` -One context fans out to every field. `zip` concatenates the fields' requests, so the whole record is still one batched call when awaited. +One context fans out to every field. `zip` concatenates the fields' requests, so the whole record is still one batched call when awaited. The derive writes the same bound, transitively — one `FieldTy: EncryptFrom` per field — so a record inherits its leaves' demand for a supplied context without naming it. **The derive** (not yet built) writes exactly that impl from the struct: @@ -151,7 +160,7 @@ The capability bounds (`C: Cipher`, `C: ProvidesOre`) arrive **transitively from ### Which sources a target accepts -`EncryptFrom` is generic over `S`; only the derive's `source` attribute pins it. Two modes: +`EncryptFrom` is generic over `S`; only the derive's `plaintext` attribute pins it. Two modes: - **Omit `source`** — the derive emits a single impl generic over `S`. The accepted sources are then exactly the intersection of what the field types accept. Nothing to maintain. - **List sources** — one impl per listed type, restricting the target. @@ -163,28 +172,28 @@ Use the list for EQL types. `eql_v3_integer_ord_ore` is a schema statement that One level up, unchanged (derive syntax, future): ```rust -#[derive(Encrypted)] -#[encrypted(source = User)] +#[derive(EncryptFrom)] +#[stash(plaintext = User)] struct EncryptedUser { - #[encrypted(context = "users/age")] age: IntegerOrdOre, - #[encrypted(context = "users/email")] email: TextEq, + #[stash(from = age, context = "users/age")] age: IntegerOrdOre, + #[stash(from = email, context = "users/email")] email: TextEq, } -let row: EncryptedUser = user.encrypt_into(&cipher, "users").await?; // one batch +let row: EncryptedUser = user.encrypt_into(&cipher).await?; // one batch, no context: the fields carry theirs ``` -Leaf, payload and row are the same trait, and a column of rows is `Vec`'s structural impl over the same trait — `ages.encrypt_into(&cipher, ctx)` for a `Vec` is one batched call. Recursion does the rest. Earlier sketches of this design had a separate input-side derive for rows — that was a second mechanism the naming was hiding. +Leaf, payload and row are the same trait, and a column of rows is `Vec`'s structural impl over the same trait — `ages.encrypt_into_with_context(&cipher, ctx)` for a `Vec` is one batched call, and `users.encrypt_into(&cipher)` for a `Vec` likewise. Recursion does the rest. Earlier sketches of this design had a separate input-side derive for rows — that was a second mechanism the naming was hiding. ### Relationship to `Encrypt` `Encrypt` is not bypassed or superseded. It **is** the source-ciphertext field. The `Ciphertext` leaf impl is a bridge: ```rust -impl EncryptFrom> for StackCipherText -where S: Encrypt + Clone +impl<'c, S, K, Ctx> EncryptFrom, Ctx> for StackCipherText +where S: Encrypt + Clone, Ctx: EncryptContext<'c> + SuppliedContext<'c>, { - fn encrypt_from<'a, 'c, Ctx>(source: &'a S, cipher: &'a StackCipher, context: Ctx) -> Pending<'a, Self, K> - where Ctx: EncryptContext<'c>, Self: 'a, + fn encrypt_from<'a>(source: &'a S, cipher: &'a StackCipher, context: Ctx) -> Pending<'a, Self, K> + where Self: 'a, { let aad = context.into_aad().into_owned(); if is_degenerate_aad(aad.as_bytes()) { @@ -234,6 +243,8 @@ impl<'a, T> EncryptContext<'a> for T where T: IntoAad<'a> + IntoPrfContext<'a> + Context is threaded **per value**, as an argument. It is not baked into the cipher. +Whether the *caller* owes one is decided by the target type, at compile time. `Ctx` is a parameter of `EncryptFrom` so that each impl can bound it: a leaf demands `SuppliedContext` — every context type vitaminc provides except `()` — because it has nothing else to authenticate under; a record passes the caller's context to its fields and inherits their demand through its where clause; a row whose fields all name their own context never uses the caller's and leaves `Ctx` unbounded. `encrypt_into(&cipher)` passes `()` and therefore resolves only against the last kind; everything else takes `encrypt_into_with_context`. This is vitaminc's `encrypt` / `encrypt_with_aad` split, with the choice made by the type rather than at every call site. Whether a supplied context is also *non-empty* remains a runtime check at the leaf (`Error::EmptyContext`) until vitaminc carries non-emptiness in the type ([vitaminc#291](https://github.com/cipherstash/vitaminc/issues/291)). + A scoped cipher (`cipher.for_column("users", "age")`) was considered and rejected: it makes encrypting one row — several columns, several identifiers — into several scoped ciphers, which fights batching for no gain. With context as an argument, a row is one shared `&cipher`, many contexts, one flush. ### Recommendation: bind the identifier into the AAD @@ -256,7 +267,8 @@ Splitting **analysis** (tokenise, normalise, extract n-grams — pure, sync, key ## Naming -- **`EncryptFrom`** for the trait (first shipped as `EncryptedFrom`, renamed in review). Spelling the direction keeps bounds unambiguous, and it pairs with `encrypt_into` exactly as `From` pairs with `Into`; `DecryptFrom` / `decrypt_into` mirror it. +- **`EncryptFrom`** for the trait (first shipped as `EncryptedFrom`, renamed in review). Spelling the direction keeps bounds unambiguous, and it pairs with `encrypt_into` exactly as `From` pairs with `Into`; `DecryptInto` / `decrypt_from` mirror it. +- **`encrypt_into` / `encrypt_into_with_context`** for the two forms of the sugar, after vitaminc's `encrypt` / `encrypt_with_aad`; `_with_context` rather than `_with_aad` because here the value feeds the PRF domain separation as well as the AAD. - **`#[derive(Encrypted)]`** for the macro. Reads as a noun on the struct. - Matching both to `Encrypted`, the way `Serialize` matches `derive(Serialize)`, is a defensible alternative. - **Avoid `CipherText` / `EncryptedValue`.** `CipherText` collides with vitaminc's `AesCipherText` container and with eql-bindings' `Ciphertext` newtype — which is a *field inside* these types, not the type itself. @@ -275,11 +287,11 @@ An earlier iteration had two traits, `EncryptInto` on the source and `Deri **2. Fan-out and zeroize.** **Resolved, and narrower than proposed.** The source reaches k consumers, but only the ciphertext's copy lives in `Protected` — it is held inside the pending and wiped as it seals. Term clones are ordinary values consumed during the synchronous build and dropped before any I/O; they are not wrapped. So the custody widening is bounded to the build phase for terms and to the pending's lifetime for the ciphertext. Stated in the `stack_encrypt::target` rustdoc ("Plaintext fan-out"), as this section asked. -**3. Orphan rule.** `impl EncryptFrom for IntegerOrdOre` in eql-bindings is legal — `Self` is local. The reverse-direction sugar (`EncryptExt::encrypt_into` on `i64`) is a blanket impl over a local trait, also fine. Worth a compile test pinning both, since the layout puts the trait, the source type and the target type in three different crates. +**3. Orphan rule.** `impl EncryptFrom for IntegerOrdOre` in eql-bindings is legal — `Self` is local. The reverse-direction sugar (`EncryptInto::encrypt_into` on `i64`) is a blanket impl over a local trait, also fine. Worth a compile test pinning both, since the layout puts the trait, the source type and the target type in three different crates. **4. Error unification.** **Dissolved.** There is no per-target error: `EncryptTarget::Error` belongs to the cipher, and `StackCipher`'s `Error` already covers AEAD, PRF, ORE and ZeroKMS failures. -**5. Decrypt.** **Implemented** as `DecryptFrom` on the plaintext type with `decrypt_into` as the blanket sugar. Only the source-ciphertext field participates (terms are one-way). The context bound is `DecryptContext` — `IntoAad` only, since decryption derives nothing. +**5. Decrypt.** **Implemented** as `DecryptInto` on the encrypted type (first shipped as `DecryptFrom` on the plaintext; flipped so the implementable trait has the record as `Self`), with `DecryptFrom::decrypt_from` / `decrypt_from_with_context` as the blanket sugar on the plaintext. Only the source-ciphertext field participates (terms are one-way). The context bound is `DecryptContext` — `IntoAad` only, since decryption derives nothing — plus `SuppliedContext` at the leaves, as on the encrypt side. **6. Where `EncryptFrom` lives.** **Resolved:** stack-encrypt. Argued here as stack-encrypt's, since target-directed assembly is the thing stack-encrypt adds and vitaminc's `Encrypt` already covers cipher-directed encryption. If it turns out to be useful to vitaminc consumers who never touch stack-encrypt, it could move down — but not before there is a second consumer. diff --git a/packages/stack-encrypt-derive/docs/attributes.md b/packages/stack-encrypt-derive/docs/attributes.md index 23587c6e2..0cc4e146c 100644 --- a/packages/stack-encrypt-derive/docs/attributes.md +++ b/packages/stack-encrypt-derive/docs/attributes.md @@ -22,14 +22,19 @@ must name it: the plaintext is rebuilt with a struct literal. | Attribute | Effect | |---|---| -| `context = "..."` | Derive this field under exactly this context rather than the one the caller passed for the record. A query-side term built under the same literal matches it. Must not be empty. | +| `context = "..."` | Derive this field under exactly this context rather than the one the caller passes for the record. A query-side term built under the same literal matches it. Must not be empty. | | `from = field` / `from = 0` | Derive this field from `plaintext.field` (or `plaintext.0` for a tuple struct) rather than from the whole plaintext. Needs `plaintext = ..` on the struct. | | `default` / `default = expr` | Not derived: filled with `Default::default()` or `expr`. Never encrypted, never authenticated. | | `decrypt` | Decryption opens this field (`DecryptInto` only). Needed only when the field types cannot decide it — see below. | -The record's own context reaches every derived field that has no `context` -of its own; if every field has one, the record's context is unused and the -caller may pass `()`. +The caller's context reaches every derived field that has no `context` of +its own, and the impl's context parameter is bounded by what those fields +accept — a leaf accepts only a `SuppliedContext`, so a record that hands the +caller's context to one is encrypted with `encrypt_into_with_context`. If +every field has a `context`, the caller's is never used, the parameter is +unbounded, and the record is encrypted with the context-free `encrypt_into` +(decrypted with `Plaintext::decrypt_from(record, &cipher)`); the compiler +turns the other form away. Every attribute except `plaintext` is singular, and repeating one is a compile error rather than a silent overwrite (`plaintext` is repeatable, diff --git a/packages/stack-encrypt-derive/src/decrypt.rs b/packages/stack-encrypt-derive/src/decrypt.rs index 3daa1632d..b46204c29 100644 --- a/packages/stack-encrypt-derive/src/decrypt.rs +++ b/packages/stack-encrypt-derive/src/decrypt.rs @@ -19,7 +19,7 @@ use syn::{ Result, Type, }; -use crate::shape::{zip_fields, Field, Record}; +use crate::shape::{context_type, push_context_generics, zip_fields, Field, Record}; pub(crate) fn derive(input: DeriveInput) -> Result { let record = Record::parse(&input)?; @@ -39,7 +39,12 @@ pub(crate) fn derive(input: DeriveInput) -> Result { // Shared // ============================================================================= -/// `impl DecryptInto> for Record` around `body`. +fn decrypt_context() -> Ident { + Ident::new("DecryptContext", Span::call_site()) +} + +/// `impl DecryptInto, __Ctx> for Record` around +/// `body`. fn impl_block( krate: &Path, name: &Ident, @@ -51,16 +56,15 @@ fn impl_block( ) -> TokenStream { quote! { #[automatically_derived] - impl #impl_generics #krate::target::DecryptInto<#plaintext, #krate::StackCipher<__K>> + impl #impl_generics #krate::target::DecryptInto<#plaintext, #krate::StackCipher<__K>, __Ctx> for #name #ty_generics #where_clause { - fn decrypt_into<'__a, '__c, __Ctx>( + fn decrypt_into<'__a>( self, __cipher: &'__a #krate::StackCipher<__K>, __context: __Ctx, ) -> #krate::target::Pending<'__a, #plaintext, __K> where - __Ctx: #krate::target::DecryptContext<'__c>, Self: '__a, #plaintext: '__a, { @@ -70,38 +74,38 @@ fn impl_block( } } -/// `impl DecryptField<__P, __C> for Record`: a derived record is a field of -/// a larger one, opened through its own `DecryptInto`. +/// `impl DecryptField<__P, __C, __Ctx> for Record`: a derived record is a +/// field of a larger one, opened through its own `DecryptInto`. fn decrypt_field_impl(input: &DeriveInput, krate: &Path) -> TokenStream { let name = &input.ident; let (_, ty_generics, _) = input.generics.split_for_impl(); let mut generics = input.generics.clone(); generics.params.push(parse_quote!(__P)); generics.params.push(parse_quote!(__C)); + generics.params.push(parse_quote!(__Ctx)); generics.make_where_clause().predicates.push(parse_quote! { __C: #krate::target::DecryptTarget }); generics.make_where_clause().predicates.push(parse_quote! { - Self: #krate::target::DecryptInto<__P, __C> + Self: #krate::target::DecryptInto<__P, __C, __Ctx> }); let (impl_generics, _, where_clause) = generics.split_for_impl(); quote! { #[automatically_derived] - impl #impl_generics #krate::target::DecryptField<__P, __C> for #name #ty_generics + impl #impl_generics #krate::target::DecryptField<__P, __C, __Ctx> for #name #ty_generics #where_clause { - fn decrypt_field<'__a, '__c, __Ctx>( + fn decrypt_field<'__a>( self, __cipher: &'__a __C, __context: __Ctx, ) -> ::core::option::Option<<__C as #krate::target::DecryptTarget>::Output<'__a, __P>> where - __Ctx: #krate::target::DecryptContext<'__c>, Self: '__a, __P: '__a, { ::core::option::Option::Some( - >::decrypt_into( + >::decrypt_into( self, __cipher, __context, ), ) @@ -250,6 +254,7 @@ fn automatic(input: &DeriveInput, record: &Record) -> Result { generics.params.push(parse_quote!(__P)); generics.params.push(parse_quote!(__K)); push_field_bounds(&mut generics, krate, fields, &plaintext); + push_context_generics(&mut generics, krate, fields, &decrypt_context()); let (impl_generics, _, where_clause) = generics.split_for_impl(); let open = open_one(krate, fields, &plaintext); let body = quote!(#body_check #destructure #open); @@ -274,9 +279,17 @@ fn automatic(input: &DeriveInput, record: &Record) -> Result { let open = match &auto { Auto::Whole(fields) => { push_field_bounds(&mut generics, krate, fields, plaintext); + push_context_generics(&mut generics, krate, fields, &decrypt_context()); open_one(krate, fields, plaintext) } - Auto::ByField(groups) => by_group_body(krate, groups, plaintext)?, + Auto::ByField(groups) => { + let fields: Vec<&Field> = groups + .iter() + .flat_map(|g| g.fields.iter().copied()) + .collect(); + push_context_generics(&mut generics, krate, &fields, &decrypt_context()); + by_group_body(krate, groups, plaintext)? + } }; let (impl_generics, _, where_clause) = generics.split_for_impl(); let body = quote!(#body_check #destructure #open); @@ -295,9 +308,10 @@ fn automatic(input: &DeriveInput, record: &Record) -> Result { Ok(quote!(#(#impls)* #definition_check)) } -/// `FieldTy: DecryptField>` for every candidate -/// field, so a record's impl exists for exactly the plaintexts its ciphertext -/// field opens to. +/// `FieldTy: DecryptField, Ctx>` for every +/// candidate field, under the context it is opened under, so a record's impl +/// exists for exactly the plaintexts its ciphertext field opens to — and +/// only under a supplied context if that field needs one. fn push_field_bounds( generics: &mut syn::Generics, krate: &Path, @@ -307,10 +321,11 @@ fn push_field_bounds( let predicates = &mut generics.make_where_clause().predicates; for field in fields { let ty = &field.ty; + let context = context_type(field); // Spanned at the field type, so a type that cannot be a field of an // automatically decrypted record is reported there. predicates.push(parse_quote_spanned! {ty.span()=> - #ty: #krate::target::DecryptField<#plaintext, #krate::StackCipher<__K>> + #ty: #krate::target::DecryptField<#plaintext, #krate::StackCipher<__K>, #context> }); } } @@ -373,7 +388,7 @@ fn open_one(krate: &Path, fields: &[&Field], plaintext: &Type) -> TokenStream { None => quote!(::core::clone::Clone::clone(__context)), }; quote! { - <#ty as #krate::target::DecryptField<#plaintext, #krate::StackCipher<__K>>>::decrypt_field( + <#ty as #krate::target::DecryptField<#plaintext, #krate::StackCipher<__K>, _>>::decrypt_field( #local, __cipher, #context, ) } @@ -457,12 +472,14 @@ fn explicit(input: &DeriveInput, record: &Record) -> Result { }; let plaintext: Type = parse_quote!(__P); let ty = &field.ty; + let context = context_type(field); let mut generics = input.generics.clone(); generics.params.push(parse_quote!(__P)); generics.params.push(parse_quote!(__K)); generics.make_where_clause().predicates.push(parse_quote! { - #ty: #krate::target::DecryptInto<__P, #krate::StackCipher<__K>> + #ty: #krate::target::DecryptInto<__P, #krate::StackCipher<__K>, #context> }); + push_context_generics(&mut generics, krate, &[field], &decrypt_context()); let (impl_generics, _, where_clause) = generics.split_for_impl(); let body = whole_body(krate, field, &plaintext); return Ok(impl_block( @@ -485,12 +502,17 @@ fn explicit(input: &DeriveInput, record: &Record) -> Result { let body = match &mode { Mode::Whole(field) => { let ty = &field.ty; + let context = context_type(field); generics.make_where_clause().predicates.push(parse_quote! { - #ty: #krate::target::DecryptInto<#plaintext, #krate::StackCipher<__K>> + #ty: #krate::target::DecryptInto<#plaintext, #krate::StackCipher<__K>, #context> }); + push_context_generics(&mut generics, krate, &[field], &decrypt_context()); whole_body(krate, field, plaintext) } - Mode::ByField(fields) => by_field_body(krate, fields, plaintext)?, + Mode::ByField(fields) => { + push_context_generics(&mut generics, krate, fields, &decrypt_context()); + by_field_body(krate, fields, plaintext)? + } }; let (impl_generics, _, where_clause) = generics.split_for_impl(); Ok(impl_block( @@ -564,7 +586,7 @@ fn whole_body(krate: &Path, field: &Field, plaintext: &Type) -> TokenStream { let member = &field.member; let context = context_for(field); quote! { - <#ty as #krate::target::DecryptInto<#plaintext, #krate::StackCipher<__K>>>::decrypt_into( + <#ty as #krate::target::DecryptInto<#plaintext, #krate::StackCipher<__K>, _>>::decrypt_into( self.#member, __cipher, #context, @@ -589,7 +611,7 @@ fn by_field_body(krate: &Path, fields: &[&Field], plaintext: &Type) -> Result>>::decrypt_into( + <#ty as #krate::target::DecryptInto<_, #krate::StackCipher<__K>, _>>::decrypt_into( self.#member, __cipher, #context, @@ -626,16 +648,17 @@ mod tests { // `default` field is neither. assert_contains(&expansion, quote! { where - StackCipherText: ::stack_encrypt::target::DecryptField>, - EqualityTerm: ::stack_encrypt::target::DecryptField> + StackCipherText: ::stack_encrypt::target::DecryptField, __Ctx>, + EqualityTerm: ::stack_encrypt::target::DecryptField, __Ctx>, + __Ctx: ::stack_encrypt::target::DecryptContext<'__c> }); assert_contains(&expansion, quote!(let Self { c: __field_0, hm: __field_1, .. } = self;)); assert_contains(&expansion, quote! { ::core::option::Option::or_else( - >>::decrypt_field( + , _>>::decrypt_field( __field_0, __cipher, ::core::clone::Clone::clone(__context), ), - move || >>::decrypt_field( + move || , _>>::decrypt_field( __field_1, __cipher, ::core::clone::Clone::clone(__context), ) ) @@ -691,10 +714,10 @@ mod tests { assert_contains(&expansion, quote! { let __group_1 = ::core::option::Option::unwrap_or_else( ::core::option::Option::or_else( - >>::decrypt_field( + , _>>::decrypt_field( __field_1, __cipher, "users/email", ), - move || >>::decrypt_field( + move || , _>>::decrypt_field( __field_2, __cipher, "users/email", ) ), @@ -702,6 +725,12 @@ mod tests { ); }); assert_contains(&expansion, quote!(__group_0.zip(__group_1).map(|(__group_0, __group_1)| User { age: __group_0, email: __group_1 }))); + // Every field has its own context: `__Ctx` is unbounded, and + // `User::decrypt_from(row, &cipher)` compiles. + assert_contains(&expansion, quote! { + impl<__K, __Ctx> ::stack_encrypt::target::DecryptInto, __Ctx> for Row + }); + assert_lacks(&expansion, quote!('__c)); } #[test] @@ -733,10 +762,10 @@ mod tests { assert_contains( &expansion, quote! { - impl<__P, __C> ::stack_encrypt::target::DecryptField<__P, __C> for Rec + impl<__P, __C, __Ctx> ::stack_encrypt::target::DecryptField<__P, __C, __Ctx> for Rec where __C: ::stack_encrypt::target::DecryptTarget, - Self: ::stack_encrypt::target::DecryptInto<__P, __C> + Self: ::stack_encrypt::target::DecryptInto<__P, __C, __Ctx> }, ); } @@ -815,12 +844,13 @@ mod tests { }) .unwrap(); assert_contains(&expansion, quote! { - impl<__P, __K> ::stack_encrypt::target::DecryptInto<__P, ::stack_encrypt::StackCipher<__K>> for Wrapped + impl<'__c, __P, __K, __Ctx> ::stack_encrypt::target::DecryptInto<__P, ::stack_encrypt::StackCipher<__K>, __Ctx> for Wrapped where - StackCipherText: ::stack_encrypt::target::DecryptInto<__P, ::stack_encrypt::StackCipher<__K>> + StackCipherText: ::stack_encrypt::target::DecryptInto<__P, ::stack_encrypt::StackCipher<__K>, __Ctx>, + __Ctx: ::stack_encrypt::target::DecryptContext<'__c> }); assert_contains(&expansion, quote! { - >>::decrypt_into( + , _>>::decrypt_into( self.c, __cipher, __context, ) }); @@ -854,12 +884,12 @@ mod tests { }) .unwrap(); assert_contains(&expansion, quote! { - impl<__K> ::stack_encrypt::target::DecryptInto> for EncryptedAge + impl<'__c, __K, __Ctx> ::stack_encrypt::target::DecryptInto, __Ctx> for EncryptedAge where - StackCipherText: ::stack_encrypt::target::DecryptInto> + StackCipherText: ::stack_encrypt::target::DecryptInto, __Ctx> }); assert_contains(&expansion, quote! { - >>::decrypt_into( + , _>>::decrypt_into( self.c, __cipher, __context, ) }); @@ -883,11 +913,16 @@ mod tests { }) .unwrap(); assert_contains(&expansion, quote! { - >>::decrypt_into( + , _>>::decrypt_into( self.age, __cipher, "users/age", ) }); assert_contains(&expansion, quote!(self.email, __cipher, __context,)); + // `email` takes the caller's context into a leaf reached through a + // plaintext field type the derive cannot name: the demand is stated. + assert_contains(&expansion, quote! { + __Ctx: ::stack_encrypt::target::DecryptContext<'__c> + ::stack_encrypt::target::SuppliedContext<'__c> + }); assert_contains(&expansion, quote!(.map(|(__field_0, __field_1)| User:: { age: __field_0, email: __field_1 }))); assert_lacks(&expansion, quote!(email_eq)); } diff --git a/packages/stack-encrypt-derive/src/encrypt.rs b/packages/stack-encrypt-derive/src/encrypt.rs index 8d48767d5..56f04d3b7 100644 --- a/packages/stack-encrypt-derive/src/encrypt.rs +++ b/packages/stack-encrypt-derive/src/encrypt.rs @@ -1,11 +1,11 @@ //! Expansion of `#[derive(EncryptFrom)]`. -use proc_macro2::TokenStream; +use proc_macro2::{Span, TokenStream}; use quote::{quote, quote_spanned}; use syn::spanned::Spanned; use syn::{parse_quote, DeriveInput, Ident, Path, Result, Type}; -use crate::shape::{zip_fields, Field, Kind, Record}; +use crate::shape::{context_type, push_context_generics, zip_fields, Field, Kind, Record}; pub(crate) fn derive(input: DeriveInput) -> Result { let record = Record::parse(&input)?; @@ -24,6 +24,7 @@ pub(crate) fn derive(input: DeriveInput) -> Result { generics.params.push(parse_quote!(__S)); generics.params.push(parse_quote!(__K)); push_field_bounds(&mut generics, krate, &record, &source); + push_context_generics(&mut generics, krate, &derived(&record), &encrypt_context()); let (impl_generics, _, where_clause) = generics.split_for_impl(); let body = body(krate, &record, &source); let block = impl_block( @@ -45,6 +46,7 @@ pub(crate) fn derive(input: DeriveInput) -> Result { let mut generics = input.generics.clone(); generics.params.push(parse_quote!(__K)); push_field_bounds(&mut generics, krate, &record, source); + push_context_generics(&mut generics, krate, &derived(&record), &encrypt_context()); let (impl_generics, _, where_clause) = generics.split_for_impl(); let body = body(krate, &record, source); impl_block( @@ -62,7 +64,16 @@ pub(crate) fn derive(input: DeriveInput) -> Result { Ok(quote!(#(#impls)* #decryptable)) } -/// `impl EncryptFrom> for Record` around `body`. +fn derived(record: &Record) -> Vec<&Field> { + record.fields.iter().filter(|f| f.is_derived()).collect() +} + +fn encrypt_context() -> Ident { + Ident::new("EncryptContext", Span::call_site()) +} + +/// `impl EncryptFrom, __Ctx> for Record` around +/// `body`. fn impl_block( krate: &Path, name: &Ident, @@ -74,16 +85,15 @@ fn impl_block( ) -> TokenStream { quote! { #[automatically_derived] - impl #impl_generics #krate::target::EncryptFrom<#source, #krate::StackCipher<__K>> + impl #impl_generics #krate::target::EncryptFrom<#source, #krate::StackCipher<__K>, __Ctx> for #name #ty_generics #where_clause { - fn encrypt_from<'__a, '__c, __Ctx>( + fn encrypt_from<'__a>( __source: &'__a #source, __cipher: &'__a #krate::StackCipher<__K>, __context: __Ctx, ) -> #krate::target::Pending<'__a, Self, __K> where - __Ctx: #krate::target::EncryptContext<'__c>, Self: '__a, { #body @@ -128,8 +138,10 @@ fn decryptable_impl(input: &DeriveInput, record: &Record) -> TokenStream { } } -/// `FieldTy: EncryptFrom>` for every field derived -/// from the whole source. +/// `FieldTy: EncryptFrom, Ctx>` for every field +/// derived from the whole source, under the context it is derived under — +/// its literal's, or the caller's `__Ctx`, which is how a record inherits +/// its leaves' demand for a supplied context. fn push_field_bounds(generics: &mut syn::Generics, krate: &Path, record: &Record, source: &Type) { let predicates = &mut generics.make_where_clause().predicates; for field in record @@ -138,8 +150,9 @@ fn push_field_bounds(generics: &mut syn::Generics, krate: &Path, record: &Record .filter(|f| f.is_derived() && f.from().is_none()) { let ty = &field.ty; + let context = context_type(field); predicates.push(parse_quote! { - #ty: #krate::target::EncryptFrom<#source, #krate::StackCipher<__K>> + #ty: #krate::target::EncryptFrom<#source, #krate::StackCipher<__K>, #context> }); } } @@ -172,7 +185,7 @@ fn body(krate: &Path, record: &Record, source: &Type) -> TokenStream { None => (quote!(__source), quote!(#source)), }; quote! { - <#ty as #krate::target::EncryptFrom<#source_ty, #krate::StackCipher<__K>>>::encrypt_from( + <#ty as #krate::target::EncryptFrom<#source_ty, #krate::StackCipher<__K>, _>>::encrypt_from( #source_expr, __cipher, #context, @@ -245,12 +258,16 @@ mod tests { hm: EqualityTerm, } }); + // Both fields take the caller's context, so the impl is bounded by + // what they do with it — and inherits their demand for a supplied + // one through the field bounds. assert_contains(&expansion, quote! { - impl<__S, __K> ::stack_encrypt::target::EncryptFrom<__S, ::stack_encrypt::StackCipher<__K>> + impl<'__c, __S, __K, __Ctx> ::stack_encrypt::target::EncryptFrom<__S, ::stack_encrypt::StackCipher<__K>, __Ctx> for EncryptedAge where - StackCipherText: ::stack_encrypt::target::EncryptFrom<__S, ::stack_encrypt::StackCipher<__K>>, - EqualityTerm: ::stack_encrypt::target::EncryptFrom<__S, ::stack_encrypt::StackCipher<__K>> + StackCipherText: ::stack_encrypt::target::EncryptFrom<__S, ::stack_encrypt::StackCipher<__K>, __Ctx>, + EqualityTerm: ::stack_encrypt::target::EncryptFrom<__S, ::stack_encrypt::StackCipher<__K>, __Ctx>, + __Ctx: ::stack_encrypt::target::EncryptContext<'__c> }); // The first field clones the record context, the last takes it. assert_contains(&expansion, quote!(__source, __cipher, ::core::clone::Clone::clone(&__context),)); @@ -270,10 +287,10 @@ mod tests { } }); assert_contains(&expansion, quote! { - impl<__K> ::stack_encrypt::target::EncryptFrom> for IntegerOrdOre + impl<'__c, __K, __Ctx> ::stack_encrypt::target::EncryptFrom, __Ctx> for IntegerOrdOre }); assert_contains(&expansion, quote! { - impl<__K> ::stack_encrypt::target::EncryptFrom> for IntegerOrdOre + impl<'__c, __K, __Ctx> ::stack_encrypt::target::EncryptFrom, __Ctx> for IntegerOrdOre }); assert_contains(&expansion, quote!(Self { c: __field_0, v: SchemaVersion::V3 })); assert_lacks(&expansion, quote!(__S)); @@ -292,12 +309,17 @@ mod tests { } }); assert_contains(&expansion, quote! { - >>::encrypt_from( + , _>>::encrypt_from( &__source.age, __cipher, "users/age", ) }); - // No field takes the record's context. + // No field takes the record's context, so `__Ctx` is unbounded: the + // row accepts `()`, and `encrypt_into(&cipher)` compiles. assert_lacks(&expansion, quote!(&__context)); + assert_contains(&expansion, quote! { + impl<__K, __Ctx> ::stack_encrypt::target::EncryptFrom, __Ctx> for EncryptedUser + }); + assert_lacks(&expansion, quote!('__c)); // `from` fields carry no where clause: the source field's type is // unknown here, so the obligation is checked in the body instead. assert!( @@ -306,6 +328,41 @@ mod tests { ); } + #[test] + #[rustfmt::skip] + fn a_from_field_taking_the_callers_context_demands_a_supplied_one() { + let expansion = expand(parse_quote! { + #[stash(plaintext = User)] + struct EncryptedUser { + #[stash(from = age)] + age: EncryptedAge, + } + }); + // The obligation is checked in the body against a source field type + // the derive cannot name, so the where clause states the demand. + assert_contains(&expansion, quote! { + where + __Ctx: ::stack_encrypt::target::EncryptContext<'__c> + ::stack_encrypt::target::SuppliedContext<'__c> + }); + } + + #[test] + #[rustfmt::skip] + fn a_whole_source_field_with_a_literal_is_bounded_under_it() { + let expansion = expand(parse_quote! { + #[stash(plaintext = u32)] + struct Rec { + #[stash(context = "rec/c")] + c: StackCipherText, + } + }); + assert_contains(&expansion, quote! { + where + StackCipherText: ::stack_encrypt::target::EncryptFrom, &'static str> + }); + assert_lacks(&expansion, quote!(EncryptContext)); + } + #[test] fn tuple_plaintexts_are_reached_by_index() { let expansion = expand(parse_quote! { diff --git a/packages/stack-encrypt-derive/src/lib.rs b/packages/stack-encrypt-derive/src/lib.rs index 594ee94e1..9f23e868f 100644 --- a/packages/stack-encrypt-derive/src/lib.rs +++ b/packages/stack-encrypt-derive/src/lib.rs @@ -32,7 +32,9 @@ //! .kms(FakeDataKeySource::new()) //! .init() //! .await?; -//! let record: EncryptedAge = 42u32.encrypt_into(&cipher, "users/age").await?; +//! let record: EncryptedAge = 42u32 +//! .encrypt_into_with_context(&cipher, "users/age") +//! .await?; //! let age: u32 = record.decrypt_into(&cipher, "users/age").await?; //! assert_eq!(age, 42); //! # Ok::<(), stack_encrypt::Error>(()) @@ -47,6 +49,13 @@ //! awaited, so however many fields a record has, awaiting it is **one** //! batched ZeroKMS call. //! +//! The record takes the caller's context because its fields do: the derive +//! bounds the impl's context parameter by what each field accepts, so a +//! record of leaves — which accept only a `SuppliedContext` — is encrypted +//! with `encrypt_into_with_context`, and the context-free `encrypt_into` +//! does not compile against it. That is decided by the field types, not by +//! an attribute. +//! //! Decryption opens the ciphertext field and passes over the terms, and no //! attribute says which is which: each field type does, through //! `Decryptable`, and the derive checks at compile time that exactly one @@ -60,7 +69,7 @@ //! //! ``` //! # use stack_encrypt::sem::{EqualityTerm, OreTerm}; -//! # use stack_encrypt::target::EncryptInto; +//! # use stack_encrypt::target::{DecryptFrom, EncryptInto}; //! # use stack_encrypt::{DecryptInto, EncryptFrom, StackCipher, StackCipherText}; //! # use stack_kms::FakeDataKeySource; //! # #[derive(EncryptFrom, DecryptInto)] @@ -88,10 +97,10 @@ //! # tokio::runtime::Builder::new_current_thread().enable_all().build().unwrap().block_on(async { //! # let cipher = StackCipher::builder().kms(FakeDataKeySource::new()).init().await?; //! let user = User { age: 42, email: "alice@example.com".into() }; -//! let row: EncryptedUser = user.encrypt_into(&cipher, ()).await?; // one batch +//! let row: EncryptedUser = user.encrypt_into(&cipher).await?; // one batch //! let users = vec![User { age: 1, email: "a".into() }, User { age: 2, email: "b".into() }]; -//! let rows: Vec = users.encrypt_into(&cipher, ()).await?; // still one -//! let user: User = row.decrypt_into(&cipher, ()).await?; +//! let rows: Vec = users.encrypt_into(&cipher).await?; // still one +//! let user = User::decrypt_from(row, &cipher).await?; //! assert_eq!(user, User { age: 42, email: "alice@example.com".into() }); //! assert_eq!(rows.len(), 2); //! # Ok::<(), stack_encrypt::Error>(()) @@ -100,8 +109,11 @@ //! //! A `from` field's context is the *column's* identity, which is why it is a //! literal on the field rather than something composed from the row's -//! context: the row's context is simply not used by fields that have their -//! own (pass `()`), and stays available for any field that has none. +//! context. A row whose fields all have one takes no context from the caller +//! at all — its impl leaves the context parameter unbounded, which is what +//! makes the context-free `encrypt_into` / `decrypt_from` compile against +//! it — while a field without one takes the caller's, and pulls the row back +//! to `encrypt_into_with_context`. //! //! # What the derive commits to //! diff --git a/packages/stack-encrypt-derive/src/shape.rs b/packages/stack-encrypt-derive/src/shape.rs index 6b6da1d9b..2ba3eb3b5 100644 --- a/packages/stack-encrypt-derive/src/shape.rs +++ b/packages/stack-encrypt-derive/src/shape.rs @@ -3,7 +3,10 @@ use proc_macro2::{Span, TokenStream}; use quote::quote; use syn::spanned::Spanned; -use syn::{Data, DeriveInput, Expr, Fields, Ident, LitStr, Member, Path, Result, Type}; +use syn::{ + parse_quote, Data, DeriveInput, Expr, Fields, Generics, Ident, LitStr, Member, Path, Result, + Type, +}; use crate::attrs::{ContainerAttrs, FieldAttrs}; @@ -117,6 +120,48 @@ impl Record { } } +/// The context type a field is derived or opened under: its literal's, or +/// the impl's `__Ctx` when it takes the caller's. +pub(crate) fn context_type(field: &Field) -> Type { + match field.context() { + Some(_) => parse_quote!(&'static str), + None => parse_quote!(__Ctx), + } +} + +/// Adds the impl's context parameter `__Ctx`, bounded by what `fields` do +/// with the caller's context. +/// +/// A field with a literal context never sees it, so a record whose fields +/// all have one leaves `__Ctx` unbounded and accepts `()`: that is what makes +/// `row.encrypt_into(&cipher)` compile. A field that takes it (no literal) +/// needs it usable — `EncryptContext` / `DecryptContext`, with the context's +/// own lifetime `'__c` as an impl parameter — and a `from` field that takes +/// it needs it *supplied*: its obligation is checked in the body against a +/// source field type the derive cannot name in a where clause, so the where +/// clause states the leaf's demand instead. +pub(crate) fn push_context_generics( + generics: &mut Generics, + krate: &Path, + fields: &[&Field], + bound: &Ident, +) { + generics.params.push(parse_quote!(__Ctx)); + let passthrough: Vec<&&Field> = fields.iter().filter(|f| f.context().is_none()).collect(); + if passthrough.is_empty() { + return; + } + generics.params.insert(0, parse_quote!('__c)); + let predicates = &mut generics.make_where_clause().predicates; + if passthrough.iter().any(|f| f.from().is_some()) { + predicates.push(parse_quote! { + __Ctx: #krate::target::#bound<'__c> + #krate::target::SuppliedContext<'__c> + }); + } else { + predicates.push(parse_quote!(__Ctx: #krate::target::#bound<'__c>)); + } +} + /// The pendings of `fields`, zipped into one and mapped into `build` (a /// struct literal over the fields' locals). Nothing is awaited, so the record /// settles as one batched call. diff --git a/packages/stack-encrypt/examples/encrypted_record.rs b/packages/stack-encrypt/examples/encrypted_record.rs index 1cf3157f1..2db178384 100644 --- a/packages/stack-encrypt/examples/encrypted_record.rs +++ b/packages/stack-encrypt/examples/encrypted_record.rs @@ -27,7 +27,7 @@ use stack_encrypt::sem::{EqualityTerm, OreTerm}; use stack_encrypt::target::{ - DecryptContext, DecryptInto, EncryptContext, EncryptFrom, EncryptInto, Pending, + DecryptContext, DecryptInto, EncryptContext, EncryptFrom, EncryptInto, Pending, SuppliedContext, }; use stack_encrypt::{StackCipher, StackCipherText}; @@ -43,15 +43,19 @@ struct EncryptedInt { // One impl, written the way the derive will write it: build every field's // pending (no I/O — the terms derive locally, the ciphertext queues its // data-key requests), merge them with `zip`, shape with `map`. Errors are the -// cipher's; there is nothing to unify. -impl EncryptFrom> for EncryptedInt { - fn encrypt_from<'a, 'c, Ctx>( +// cipher's; there is nothing to unify. The context is the caller's, handed +// to leaves that need a supplied one — so the record needs one too, and +// says so: `EncryptedInt` is encrypted with `encrypt_into_with_context`. +impl<'c, K, Ctx> EncryptFrom, Ctx> for EncryptedInt +where + Ctx: EncryptContext<'c> + SuppliedContext<'c>, +{ + fn encrypt_from<'a>( source: &'a u32, cipher: &'a StackCipher, context: Ctx, ) -> Pending<'a, Self, K> where - Ctx: EncryptContext<'c>, Self: 'a, { // One context fans out to every field: it authenticates the @@ -70,14 +74,12 @@ impl EncryptFrom> for EncryptedInt { // The decrypt mirror `#[derive(DecryptInto)]` would write: the record owns // its opening, and only the ciphertext field participates (terms are // one-way), so it delegates to the ciphertext's own implementation. -impl DecryptInto> for EncryptedInt { - fn decrypt_into<'a, 'c, Ctx>( - self, - cipher: &'a StackCipher, - context: Ctx, - ) -> Pending<'a, u32, K> +impl<'c, K, Ctx> DecryptInto, Ctx> for EncryptedInt +where + Ctx: DecryptContext<'c> + SuppliedContext<'c>, +{ + fn decrypt_into<'a>(self, cipher: &'a StackCipher, context: Ctx) -> Pending<'a, u32, K> where - Ctx: DecryptContext<'c>, Self: 'a, u32: 'a, { @@ -101,7 +103,7 @@ async fn main() -> Result<(), Box> { // One await for the whole column: the Vec implementation merges every // record's pending, so five records (ciphertext + two terms each) settle // in a single batched generate_keys call. - let table: Vec = ages.encrypt_into(&cipher, CONTEXT).await?; + let table: Vec = ages.encrypt_into_with_context(&cipher, CONTEXT).await?; println!( "stored {} encrypted records in one ZeroKMS call", table.len() @@ -113,12 +115,12 @@ async fn main() -> Result<(), Box> { // building a query never calls ZeroKMS at all. // WHERE age = 34: compare equality terms. - let probe: EqualityTerm = 34u32.encrypt_into(&cipher, CONTEXT).await?; + let probe: EqualityTerm = 34u32.encrypt_into_with_context(&cipher, CONTEXT).await?; let equal: Vec = (0..table.len()).filter(|&i| table[i].eq == probe).collect(); println!("WHERE age = 34 => rows {equal:?}"); // WHERE age > 40: compare ORE terms. - let bound: OreTerm = 40u32.encrypt_into(&cipher, CONTEXT).await?; + let bound: OreTerm = 40u32.encrypt_into_with_context(&cipher, CONTEXT).await?; let over_40: Vec = (0..table.len()).filter(|&i| table[i].ord > bound).collect(); println!("WHERE age > 40 => rows {over_40:?}"); diff --git a/packages/stack-encrypt/examples/search_terms.rs b/packages/stack-encrypt/examples/search_terms.rs index a36b36c72..3a7bea16f 100644 --- a/packages/stack-encrypt/examples/search_terms.rs +++ b/packages/stack-encrypt/examples/search_terms.rs @@ -40,17 +40,17 @@ async fn main() -> Result<(), Box> { // value indexed under another field can never produce a colliding term. let stored: EqualityTerm = "alice@example.com" - .encrypt_into(&terms, "users/email") + .encrypt_into_with_context(&terms, "users/email") .await?; let hit: EqualityTerm = "alice@example.com" - .encrypt_into(&terms, "users/email") + .encrypt_into_with_context(&terms, "users/email") .await?; let miss: EqualityTerm = "bob@example.com" - .encrypt_into(&terms, "users/email") + .encrypt_into_with_context(&terms, "users/email") .await?; let wrong_field: EqualityTerm = "alice@example.com" - .encrypt_into(&terms, "users/name") + .encrypt_into_with_context(&terms, "users/name") .await?; println!("\nequality:"); @@ -70,11 +70,14 @@ async fn main() -> Result<(), Box> { let bio: MatchTerm = "alice, senior cryptography engineer" .to_string() - .encrypt_into(&terms, "users/bio") + .encrypt_into_with_context(&terms, "users/bio") .await?; for query in ["crypto", "engineer", "plumber"] { - let probe: MatchTerm = query.to_string().encrypt_into(&terms, "users/bio").await?; + let probe: MatchTerm = query + .to_string() + .encrypt_into_with_context(&terms, "users/bio") + .await?; println!("match: bio contains {query:?} => {}", bio.contains(&probe)); } println!( @@ -90,17 +93,21 @@ async fn main() -> Result<(), Box> { // the key derivation becomes an auditable server event while values stay // local. - let age_30: OreTerm = 30u32.encrypt_into(&terms, "users/age").await?; - let age_45: OreTerm = 45u32.encrypt_into(&terms, "users/age").await?; - let query_40: OreTerm = 40u32.encrypt_into(&terms, "users/age").await?; + let age_30: OreTerm = 30u32.encrypt_into_with_context(&terms, "users/age").await?; + let age_45: OreTerm = 45u32.encrypt_into_with_context(&terms, "users/age").await?; + let query_40: OreTerm = 40u32.encrypt_into_with_context(&terms, "users/age").await?; println!("\nore (WHERE age > 40):"); println!(" age 30 > 40 => {}", age_30 > query_40); println!(" age 45 > 40 => {}", age_45 > query_40); // Strings order lexicographically. - let apple: OreTerm<&str> = "apple".encrypt_into(&terms, "users/name").await?; - let banana: OreTerm<&str> = "banana".encrypt_into(&terms, "users/name").await?; + let apple: OreTerm<&str> = "apple" + .encrypt_into_with_context(&terms, "users/name") + .await?; + let banana: OreTerm<&str> = "banana" + .encrypt_into_with_context(&terms, "users/name") + .await?; println!(" \"apple\" < \"banana\" => {}", apple < banana); Ok(()) diff --git a/packages/stack-encrypt/src/lib.rs b/packages/stack-encrypt/src/lib.rs index f88ff912e..45271c792 100644 --- a/packages/stack-encrypt/src/lib.rs +++ b/packages/stack-encrypt/src/lib.rs @@ -128,7 +128,7 @@ pub use cipher::{ pub use target::{ DecryptContext, DecryptField, DecryptFrom, DecryptInto, DecryptTarget, Decryptable, EncryptContext, EncryptFrom, EncryptInto, EncryptTarget, Pending, PendingFuture, Request, - Responses, + Responses, SuppliedContext, }; // Re-export the vitaminc AEAD surface callers need to drive the cipher, so they diff --git a/packages/stack-encrypt/src/sem/mod.rs b/packages/stack-encrypt/src/sem/mod.rs index 3a3a4f12e..d705feda5 100644 --- a/packages/stack-encrypt/src/sem/mod.rs +++ b/packages/stack-encrypt/src/sem/mod.rs @@ -7,7 +7,7 @@ //! target-directed: //! //! ```text -//! let term: EqualityTerm = value.encrypt_into(&cipher, "users/email").await?; +//! let term: EqualityTerm = value.encrypt_into_with_context(&cipher, "users/email").await?; //! ``` //! //! * [`EqualityTerm`] — a PRF of the whole value; exact-match queries. @@ -82,8 +82,8 @@ use vitaminc_prf::{ use zeroize::Zeroize; use crate::target::{ - is_degenerate_prf_context, DecryptContext, DecryptField, DecryptTarget, Decryptable, - EncryptContext, EncryptFrom, Pending, + is_degenerate_prf_context, DecryptField, DecryptTarget, Decryptable, EncryptContext, + EncryptFrom, Pending, SuppliedContext, }; use crate::{Error, StackCipher}; @@ -221,17 +221,17 @@ where /// An equality term of any [`PrfValue`] source. Derived locally during the /// synchronous build — the returned [`Pending`] carries no requests. -impl EncryptFrom> for EqualityTerm +impl<'c, S, K, Ctx> EncryptFrom, Ctx> for EqualityTerm where S: PrfValue + Clone, + Ctx: EncryptContext<'c> + SuppliedContext<'c>, { - fn encrypt_from<'a, 'c, Ctx>( + fn encrypt_from<'a>( source: &'a S, cipher: &'a StackCipher, context: Ctx, ) -> Pending<'a, Self, K> where - Ctx: EncryptContext<'c>, Self: 'a, { let context = context.into_prf_context().into_owned(); @@ -462,18 +462,18 @@ fn match_term( /// A match term of any text source, generated under `O`'s options. Derived /// locally during the synchronous build — the returned [`Pending`] carries no /// requests (tokenize makes the one necessary copy of the text). -impl EncryptFrom> for MatchTerm +impl<'c, S, K, O, Ctx> EncryptFrom, Ctx> for MatchTerm where S: AsRef, O: MatchConfig, + Ctx: EncryptContext<'c> + SuppliedContext<'c>, { - fn encrypt_from<'a, 'c, Ctx>( + fn encrypt_from<'a>( source: &'a S, cipher: &'a StackCipher, context: Ctx, ) -> Pending<'a, Self, K> where - Ctx: EncryptContext<'c>, Self: 'a, { let context = context.into_prf_context().into_owned(); @@ -509,14 +509,15 @@ macro_rules! index_term { const DECRYPTABLE: bool = false; } - impl<__P, __C: DecryptTarget $(, $param: $bound)?> DecryptField<__P, __C> for $ty { - fn decrypt_field<'a, 'c, Ctx>( + impl<__P, __C: DecryptTarget, __Ctx $(, $param: $bound)?> DecryptField<__P, __C, __Ctx> + for $ty + { + fn decrypt_field<'a>( self, _cipher: &'a __C, - _context: Ctx, + _context: __Ctx, ) -> Option<__C::Output<'a, __P>> where - Ctx: DecryptContext<'c>, Self: 'a, __P: 'a, { @@ -705,18 +706,18 @@ where /// An ORE term of any [`CllwOreEncrypt`] source. Derived locally during the /// synchronous build — the returned [`Pending`] carries no requests. -impl EncryptFrom> for OreTerm +impl<'c, S, K, Ctx> EncryptFrom, Ctx> for OreTerm where S: CllwOreEncrypt + Clone + Send + 'static, S::Output: Send + 'static, + Ctx: EncryptContext<'c> + SuppliedContext<'c>, { - fn encrypt_from<'a, 'c, Ctx>( + fn encrypt_from<'a>( source: &'a S, cipher: &'a StackCipher, context: Ctx, ) -> Pending<'a, Self, K> where - Ctx: EncryptContext<'c>, Self: 'a, { let context = context.into_prf_context().into_owned(); @@ -727,18 +728,18 @@ where /// An OPE term of any [`CllwOpeEncrypt`] source. Derived locally during the /// synchronous build — the returned [`Pending`] carries no requests. -impl EncryptFrom> for OpeTerm +impl<'c, S, K, Ctx> EncryptFrom, Ctx> for OpeTerm where S: CllwOpeEncrypt + Clone + Send + 'static, S::Output: Send + 'static, + Ctx: EncryptContext<'c> + SuppliedContext<'c>, { - fn encrypt_from<'a, 'c, Ctx>( + fn encrypt_from<'a>( source: &'a S, cipher: &'a StackCipher, context: Ctx, ) -> Pending<'a, Self, K> where - Ctx: EncryptContext<'c>, Self: 'a, { let context = context.into_prf_context().into_owned(); diff --git a/packages/stack-encrypt/src/target/mod.rs b/packages/stack-encrypt/src/target/mod.rs index f603ac62f..a4d69a3f9 100644 --- a/packages/stack-encrypt/src/target/mod.rs +++ b/packages/stack-encrypt/src/target/mod.rs @@ -7,16 +7,25 @@ //! record shape in charge: //! //! ```text -//! let term: EqualityTerm = value.encrypt_into(&cipher, "users/email").await?; +//! let term: EqualityTerm = value.encrypt_into_with_context(&cipher, "users/email").await?; +//! let row: EncryptedUser = user.encrypt_into(&cipher).await?; //! ``` //! -//! compiles only when `EqualityTerm` declares itself an encrypted form of the -//! value's type, producible by that cipher. +//! compiles only when the output type declares itself an encrypted form of +//! the value's type, producible by that cipher, under that context — and a +//! context is something the output type may already have. A leaf takes the +//! caller's; a row whose fields each name their own column needs none, and +//! the second line is the whole call. //! //! # The pieces //! -//! * [`EncryptFrom`] — implemented by an *output* type: "`Self` is an -//! encrypted representation of `S`, producible by a cipher `C`". Leaf +//! * [`EncryptFrom`] — implemented by an *output* type: "`Self` is +//! an encrypted representation of `S`, producible by a cipher `C`, under a +//! context `Ctx`". The context is a parameter of the *trait* so that an +//! implementation can say which contexts it accepts: the leaves accept +//! only a [`SuppliedContext`], a record passes the obligation through to +//! its fields, and a row whose fields carry their own contexts accepts +//! anything — `()` included. Leaf //! implementations exist for [`StackCipherText`] (the AEAD ciphertext, via //! vitaminc's [`Encrypt`]) and for the SEM term types in [`sem`] //! ([`EqualityTerm`], [`MatchTerm`], [`OreTerm`], [`OpeTerm`]). Composite @@ -24,25 +33,32 @@ //! [`#[derive(EncryptFrom)]`](macro@EncryptFrom), which combines the //! fields' pendings with [`Pending::zip`] / [`Pending::map`] exactly as a //! hand-written impl would. -//! * [`DecryptInto`] — the mirror, implemented by the *encrypted* type: -//! "`Self` decrypts to the plaintext `P`". Only ciphertext fields +//! * [`DecryptInto`] — the mirror, implemented by the *encrypted* +//! type: "`Self` decrypts to the plaintext `P`". Only ciphertext fields //! participate — index terms are one-way by construction. //! [`#[derive(DecryptInto)]`](macro@DecryptInto) on the record emits it. //! `Self` is the record in both traits — the output of encryption, the //! input of decryption — because that is the type a downstream crate can //! implement on; the halves whose `Self` is the plaintext are blanket: -//! * [`EncryptInto::encrypt_into`] / [`DecryptFrom::decrypt_from`] — call-site -//! sugar, the `Into` to `EncryptFrom` and the `From` to `DecryptInto`. -//! Never implemented by hand. +//! * [`EncryptInto`] / [`DecryptFrom`] — call-site sugar, the `Into` to +//! `EncryptFrom` and the `From` to `DecryptInto`, each in two forms: +//! [`encrypt_into(&cipher)`](EncryptInto::encrypt_into) for an output that +//! needs no context from the caller, and +//! [`encrypt_into_with_context(&cipher, ctx)`](EncryptInto::encrypt_into_with_context) +//! for one that does — the split of vitaminc's `encrypt` / +//! `encrypt_with_aad`, decided by the output type at compile time. Never +//! implemented by hand. //! * [`EncryptTarget`] / [`DecryptTarget`] — implemented by ciphers; their //! `Output` type decides what a call site gets back. A synchronous cipher //! returns `Result` directly; [`StackCipher`] returns a [`Pending`], //! which does its ZeroKMS I/O — **one batched call** — when awaited. //! * [`EncryptContext`] — one context value per field, convertible to both an -//! AEAD [`Aad`](vitaminc_aead::Aad) and a +//! AEAD [`Aad`] and a //! [`PrfContext`](vitaminc_prf::PrfContext), so the same identifier that //! domain-separates the index terms also *authenticates* the ciphertext. -//! `&str` and `String` qualify. The context must be **non-empty**. +//! `&str` and `String` qualify. [`SuppliedContext`] marks the ones a +//! caller actually passed — everything but `()` — and is what a leaf +//! demands; the context must also be **non-empty**. //! //! # Build synchronously, settle once //! @@ -69,7 +85,9 @@ //! let ages: Vec = vec![29, 34, 41]; //! //! // A column of independently sealed ciphertexts: ONE generate_keys call. -//! let sealed: Vec = ages.encrypt_into(&cipher, "users/age").await?; +//! let sealed: Vec = ages +//! .encrypt_into_with_context(&cipher, "users/age") +//! .await?; //! //! // And back: ONE retrieve_keys call for the whole column. //! let roundtrip: Vec = sealed.decrypt_into(&cipher, "users/age").await?; @@ -105,8 +123,8 @@ //! //! ``` //! use stack_encrypt::target::{ -//! DecryptContext, DecryptField, DecryptTarget, Decryptable, EncryptContext, EncryptFrom, -//! Pending, +//! DecryptField, DecryptTarget, Decryptable, EncryptContext, EncryptFrom, Pending, +//! SuppliedContext, //! }; //! use stack_encrypt::{Error, StackCipher}; //! use vitaminc_prf::{IntoPrfContext, PrfContext, PrfValue, PrfVisitor, PrfVisitorError}; @@ -124,24 +142,27 @@ //! } //! } //! -//! impl EncryptFrom> for MyTerm +//! // A leaf owns the context policy, and states it in the impl header: +//! // `SuppliedContext` refuses `()` at compile time (nothing above a leaf +//! // checks, since a column of rows has no context of its own). The +//! // lifetime is the context's own, as in the `IntoAad<'c>` it implements. +//! impl<'c, S, K, Ctx> EncryptFrom, Ctx> for MyTerm //! where //! S: PrfValue + Clone, +//! Ctx: EncryptContext<'c> + SuppliedContext<'c>, //! { -//! fn encrypt_from<'a, 'c, Ctx>( +//! fn encrypt_from<'a>( //! source: &'a S, //! cipher: &'a StackCipher, //! context: Ctx, //! ) -> Pending<'a, Self, K> //! where -//! Ctx: EncryptContext<'c>, //! Self: 'a, //! { -//! // The leaf owns the context policy: the built-in leaves refuse -//! // an empty context (nothing above them checks, since a column -//! // of rows has no context of its own), and yours should too. -//! // Then domain-separate under your own label so your terms can -//! // never collide with another scheme's under the same context. +//! // The type rules out an absent context; an empty one is still a +//! // runtime check, as for the built-in leaves. Then domain-separate +//! // under your own label so your terms can never collide with +//! // another scheme's under the same context. //! let context = context.into_prf_context().into_owned(); //! if context.as_bytes().is_empty() { //! return Pending::ready(cipher, Err(Error::EmptyContext)); @@ -162,10 +183,9 @@ //! const DECRYPTABLE: bool = false; //! } //! -//! impl DecryptField for MyTerm { -//! fn decrypt_field<'a, 'c, Ctx>(self, _: &'a C, _: Ctx) -> Option> +//! impl DecryptField for MyTerm { +//! fn decrypt_field<'a>(self, _: &'a C, _: Ctx) -> Option> //! where -//! Ctx: DecryptContext<'c>, //! Self: 'a, //! P: 'a, //! { @@ -197,7 +217,7 @@ //! //! ``` //! use stack_encrypt::sem::{EqualityTerm, OreTerm}; -//! use stack_encrypt::target::EncryptInto; +//! use stack_encrypt::target::{DecryptFrom, EncryptInto}; //! use stack_encrypt::{DecryptInto, EncryptFrom, StackCipher, StackCipherText}; //! use stack_kms::FakeDataKeySource; //! @@ -233,13 +253,16 @@ //! .unwrap(); //! //! let user = User { age: 42, email: "alice@example.com".into() }; -//! // Every field names its own context, so the row has none: `()`. -//! let row: EncryptedUser = user.encrypt_into(&cipher, ()).await?; +//! // Every field names its own context, so the row needs none from the +//! // caller — and the context-free forms are the only ones that apply. +//! let row: EncryptedUser = user.encrypt_into(&cipher).await?; //! // A query site derives the same term under the same literal. -//! let probe: EqualityTerm = 42u32.encrypt_into(&cipher, "users/age").await?; +//! let probe: EqualityTerm = 42u32 +//! .encrypt_into_with_context(&cipher, "users/age") +//! .await?; //! assert_eq!(row.age.hm, probe); //! -//! let recovered: User = row.decrypt_into(&cipher, ()).await?; +//! let recovered = User::decrypt_from(row, &cipher).await?; //! assert_eq!(recovered, user); //! # Ok::<(), stack_encrypt::Error>(()) //! # }).unwrap(); @@ -263,8 +286,10 @@ //! [`OreTerm`]: crate::sem::OreTerm //! [`OpeTerm`]: crate::sem::OpeTerm +use std::borrow::Cow; + use stack_kms::MaybeSend; -use vitaminc_aead::{CipherText, Decrypt, Encrypt, IntoAad}; +use vitaminc_aead::{Aad, CipherText, Decrypt, Encrypt, IntoAad}; use vitaminc_prf::IntoPrfContext; use crate::cipher::{bind_keys, PendingStackCipherText, StackDecipher}; @@ -293,17 +318,16 @@ pub use stack_encrypt_derive::{DecryptInto, EncryptFrom}; /// in different fields produce identical index terms (cross-field equality /// leakage), every field shares one ORE/OPE key (values become mutually /// order-comparable), and ciphertexts become transplantable between fields. -/// Every leaf implementation rejects an empty context during the synchronous -/// build, before any I/O. Containers (`Vec`, `Option`) and derived records -/// pass the context through to their elements and fields untouched, so a -/// container of records whose fields carry their own contexts — a -/// [`#[derive(EncryptFrom)]`](macro@EncryptFrom) row — is given `()`, and an -/// empty context still fails the moment a value reaches a leaf. -/// -/// "Empty" means *carrying no caller-supplied information*, not merely zero -/// bytes: `()`, `""`, `b""`, `None`, `Some("")` and `("", "")` all encode to -/// nothing but vitaminc framing and are all rejected -/// ([`Error::EmptyContext`]). The check is structural over the PAE encoding +/// That is enforced in two layers. An *absent* context — `()`, what +/// [`encrypt_into`](EncryptInto::encrypt_into) passes — is refused by the +/// type: every leaf demands a [`SuppliedContext`], and containers (`Vec`, +/// `Option`) and derived records pass that demand through to their elements +/// and fields untouched, so only a record whose fields all carry contexts of +/// their own — a [`#[derive(EncryptFrom)]`](macro@EncryptFrom) row — accepts +/// `()`. An *empty* one — `""`, `b""`, `None`, `Some("")`, `("", "")`, +/// anything that encodes to nothing but vitaminc framing — is refused by +/// every built-in leaf during the synchronous build, before any I/O +/// ([`Error::EmptyContext`]). That check is structural over the PAE encoding /// vitaminc uses, so a nested empty context cannot hide behind an `Option` /// or tuple wrapper. (An integer context whose bytes coincide with an empty /// encoding — `0u64` — is rejected too: it is byte-identical to `None`.) @@ -313,12 +337,54 @@ impl<'a, T> EncryptContext<'a> for T where T: IntoAad<'a> + IntoPrfContext<'a> + /// Per-field decryption context: must convert to the AEAD associated data the /// value was encrypted under. Decryption derives nothing, so no PRF bound. -/// Blanket-implemented; `&str`, `String` and [`Aad`](vitaminc_aead::Aad) +/// Blanket-implemented; `&str`, `String` and [`Aad`] /// qualify. pub trait DecryptContext<'a>: IntoAad<'a> + Clone {} impl<'a, T> DecryptContext<'a> for T where T: IntoAad<'a> + Clone {} +/// A context the caller actually passed, as opposed to `()` — the absence of +/// one. +/// +/// This is what lets an output type decide, at compile time, whether the +/// call site owes it a context. The leaves ([`StackCipherText`], the +/// [`sem`](crate::sem) terms) implement [`EncryptFrom`] and [`DecryptInto`] +/// only for a `SuppliedContext`, so `value.encrypt_into(&cipher)` — which +/// passes `()` — does not compile against them, nor against a record that +/// hands its context on to one of them. A row whose fields each carry a +/// context of their own never passes the caller's anywhere, accepts `()`, +/// and is encrypted with no context at all. +/// +/// Implemented for every context type vitaminc provides except `()`: `&str`, +/// `String`, byte strings, [`Aad`], `u64`, and `Option`s +/// and pairs of those. Implement it for a context type of your own alongside +/// its `IntoAad` / `IntoPrfContext`. +/// +/// The marker is about the *type*: `""` is a `&str` and therefore supplied. +/// Whether what was supplied is non-empty stays a runtime check at the leaf +/// ([`Error::EmptyContext`]) until vitaminc carries non-emptiness in the +/// type itself (cipherstash/vitaminc#291), at which point the bound tightens +/// to that. +#[diagnostic::on_unimplemented( + message = "`{Self}` is not a context the caller supplied", + label = "this leaf needs a context", + note = "`()` is what `encrypt_into` / `decrypt_from` pass: an output type that reaches a \ + leaf needs `encrypt_into_with_context` / `decrypt_from_with_context` instead" +)] +pub trait SuppliedContext<'a>: IntoAad<'a> + Clone {} + +impl<'a> SuppliedContext<'a> for &'a str {} +impl SuppliedContext<'_> for String {} +impl<'a> SuppliedContext<'a> for &'a [u8] {} +impl<'a, const N: usize> SuppliedContext<'a> for &'a [u8; N] {} +impl SuppliedContext<'_> for [u8; N] {} +impl SuppliedContext<'_> for Vec {} +impl<'a> SuppliedContext<'a> for Cow<'a, [u8]> {} +impl<'a> SuppliedContext<'a> for Aad<'a> {} +impl SuppliedContext<'_> for u64 {} +impl<'a, T: SuppliedContext<'a>> SuppliedContext<'a> for Option {} +impl<'a, A: SuppliedContext<'a>, B: SuppliedContext<'a>> SuppliedContext<'a> for (A, B) {} + // The two checks below reconstruct, at runtime and from the outside, an // invariant that should be carried by the type: "this context was built // from something the caller supplied". Doing it this way means parsing an @@ -534,7 +600,31 @@ impl DecryptTarget for StackCipher { /// There is no associated error type: errors belong to the cipher /// ([`EncryptTarget::Error`]), and implementations with failure modes of /// their own use [`Error::Term`] or [`Error::Other`]. -pub trait EncryptFrom: Sized { +/// +/// # The context parameter +/// +/// `Ctx` is a parameter of the trait, not of the method, so that each +/// implementation can bound it: a leaf demands a [`SuppliedContext`] (it has +/// nothing else to authenticate under), a record passes whatever it is given +/// on to its fields and inherits their demands through its where clause, and +/// a row whose fields carry their own contexts leaves `Ctx` unbounded. The +/// call site then gets one of two answers from the compiler: +/// [`encrypt_into(&cipher)`](EncryptInto::encrypt_into) resolves against +/// `EncryptFrom` and exists exactly for the outputs that need no +/// context; everything else takes +/// [`encrypt_into_with_context`](EncryptInto::encrypt_into_with_context). +/// +/// The trait itself places no bound on `Ctx`; an implementation that uses the +/// context bounds it as [`EncryptContext<'c>`] with the context's own +/// lifetime as an impl parameter (see the +/// [module docs](self#extending-with-your-own-sem-type)). +#[diagnostic::on_unimplemented( + message = "`{Self}` is not an encrypted form of `{S}` under a `{Ctx}` context", + label = "not `EncryptFrom<{S}, _, {Ctx}>`", + note = "if `{Ctx}` is `()`, no context was supplied: an output that reaches a leaf needs \ + one — use `encrypt_into_with_context(&cipher, context)`" +)] +pub trait EncryptFrom: Sized { /// Encrypt `source` into `Self` under `context`, returning the cipher's /// [`Output`](EncryptTarget::Output). No I/O happens here; work needing /// ZeroKMS is carried as requests and settles when the output is awaited. @@ -542,9 +632,8 @@ pub trait EncryptFrom: Sized { /// Borrows the source because a composite record hands the same source to /// several field implementations; each takes what it needs (typically one /// clone). - fn encrypt_from<'a, 'c, Ctx>(source: &'a S, cipher: &'a C, context: Ctx) -> C::Output<'a, Self> + fn encrypt_from<'a>(source: &'a S, cipher: &'a C, context: Ctx) -> C::Output<'a, Self> where - Ctx: EncryptContext<'c>, Self: 'a; } @@ -560,21 +649,37 @@ pub trait EncryptFrom: Sized { /// /// Takes `self` by value: decryption consumes the ciphertext, and index /// terms (which have no plaintext to recover) simply do not participate. -pub trait DecryptInto: Sized { +/// +/// `Ctx` is a trait parameter for the reason it is on [`EncryptFrom`]: the +/// leaves accept only a [`SuppliedContext`], so an encrypted type that needs +/// no context from the caller is exactly one that implements +/// `DecryptInto` — what [`DecryptFrom::decrypt_from`] asks for. +#[diagnostic::on_unimplemented( + message = "`{Self}` does not decrypt to `{P}` under a `{Ctx}` context", + label = "not `DecryptInto<{P}, _, {Ctx}>`", + note = "if `{Ctx}` is `()`, no context was supplied: a value that reaches a leaf needs the \ + one it was encrypted under — use `decrypt_into(&cipher, context)` or \ + `decrypt_from_with_context`" +)] +pub trait DecryptInto: Sized { /// Decrypt `self` into `P`, authenticating against `context` — which /// must match the context the value was encrypted under. - fn decrypt_into<'a, 'c, Ctx>(self, cipher: &'a C, context: Ctx) -> C::Output<'a, P> + fn decrypt_into<'a>(self, cipher: &'a C, context: Ctx) -> C::Output<'a, P> where - Ctx: DecryptContext<'c>, Self: 'a, P: 'a; } -/// Call-site sugar: `value.encrypt_into::(&cipher, context)`. +/// Call-site sugar: `value.encrypt_into(&cipher)` and +/// `value.encrypt_into_with_context(&cipher, context)`. /// /// The `Into` to [`EncryptFrom`]'s `From` — blanket-implemented for every /// type, never implemented by hand. The target type is usually inferred from -/// the binding: +/// the binding. Which of the two methods applies is not a choice: a leaf, or +/// a record that hands the caller's context to one, exists only under a +/// [`SuppliedContext`] and takes the second; a row whose fields name their +/// own contexts needs nothing from the caller and takes the first. The +/// split is vitaminc's `encrypt` / `encrypt_with_aad`, decided by the type. /// /// ``` /// use stack_encrypt::sem::EqualityTerm; @@ -589,54 +694,107 @@ pub trait DecryptInto: Sized { /// .await /// .unwrap(); /// -/// let term: EqualityTerm = "alice".encrypt_into(&cipher, "users/email").await?; +/// let term: EqualityTerm = "alice" +/// .encrypt_into_with_context(&cipher, "users/email") +/// .await?; /// # Ok::<(), stack_encrypt::Error>(()) /// # }).unwrap(); /// ``` pub trait EncryptInto { + /// Encrypt `self` into a `T` that needs no context from the caller — + /// a row whose fields carry their own. See [`EncryptFrom`]. + /// + /// Passes `()`, so this exists only for `T: EncryptFrom`: + /// against a leaf the compiler says to use + /// [`encrypt_into_with_context`](Self::encrypt_into_with_context). + fn encrypt_into<'a, T, C>(&'a self, cipher: &'a C) -> C::Output<'a, T> + where + C: EncryptTarget, + T: EncryptFrom + 'a, + Self: Sized; + /// Encrypt `self` into `T` under `context`. See [`EncryptFrom`]. - fn encrypt_into<'a, 'c, T, C, Ctx>(&'a self, cipher: &'a C, context: Ctx) -> C::Output<'a, T> + fn encrypt_into_with_context<'a, T, C, Ctx>( + &'a self, + cipher: &'a C, + context: Ctx, + ) -> C::Output<'a, T> where C: EncryptTarget, - T: EncryptFrom + 'a, - Ctx: EncryptContext<'c>, + T: EncryptFrom + 'a, Self: Sized; } impl EncryptInto for S { - fn encrypt_into<'a, 'c, T, C, Ctx>(&'a self, cipher: &'a C, context: Ctx) -> C::Output<'a, T> + fn encrypt_into<'a, T, C>(&'a self, cipher: &'a C) -> C::Output<'a, T> + where + C: EncryptTarget, + T: EncryptFrom + 'a, + { + T::encrypt_from(self, cipher, ()) + } + + fn encrypt_into_with_context<'a, T, C, Ctx>( + &'a self, + cipher: &'a C, + context: Ctx, + ) -> C::Output<'a, T> where C: EncryptTarget, - T: EncryptFrom + 'a, - Ctx: EncryptContext<'c>, + T: EncryptFrom + 'a, { T::encrypt_from(self, cipher, context) } } -/// Call-site sugar: `Plaintext::decrypt_from(encrypted, &cipher, context)` — -/// the `From` to [`DecryptInto`]'s `Into`, and the decrypt-side -/// [`EncryptInto`]. Blanket-implemented for every plaintext; never -/// implemented by hand. +/// Call-site sugar: `Plaintext::decrypt_from(encrypted, &cipher)` and +/// `Plaintext::decrypt_from_with_context(encrypted, &cipher, context)` — the +/// `From` to [`DecryptInto`]'s `Into`, and the decrypt-side [`EncryptInto`], +/// with the same two forms for the same reason. Blanket-implemented for every +/// plaintext; never implemented by hand. +/// +/// (The implemented trait, [`DecryptInto`], always takes a context: +/// `encrypted.decrypt_into(&cipher, "users/age")` is the method-call form +/// for a value that needs one.) pub trait DecryptFrom: Sized { + /// Decrypt `source` — an encrypted type that needs no context from the + /// caller — into `Self`. See [`DecryptInto`]. + fn decrypt_from<'a>(source: S, cipher: &'a C) -> C::Output<'a, Self> + where + S: DecryptInto + 'a, + Self: 'a; + /// Decrypt `source` into `Self`, authenticating against `context`. See /// [`DecryptInto`]. - fn decrypt_from<'a, 'c, Ctx>(source: S, cipher: &'a C, context: Ctx) -> C::Output<'a, Self> + fn decrypt_from_with_context<'a, Ctx>( + source: S, + cipher: &'a C, + context: Ctx, + ) -> C::Output<'a, Self> where - Ctx: DecryptContext<'c>, - S: 'a, + S: DecryptInto + 'a, Self: 'a; } impl DecryptFrom for P where C: DecryptTarget, - S: DecryptInto, { - fn decrypt_from<'a, 'c, Ctx>(source: S, cipher: &'a C, context: Ctx) -> C::Output<'a, Self> + fn decrypt_from<'a>(source: S, cipher: &'a C) -> C::Output<'a, Self> + where + S: DecryptInto + 'a, + Self: 'a, + { + source.decrypt_into(cipher, ()) + } + + fn decrypt_from_with_context<'a, Ctx>( + source: S, + cipher: &'a C, + context: Ctx, + ) -> C::Output<'a, Self> where - Ctx: DecryptContext<'c>, - S: 'a, + S: DecryptInto + 'a, Self: 'a, { source.decrypt_into(cipher, context) @@ -676,11 +834,10 @@ pub trait Decryptable { /// record, and a record that only decrypts — no `EncryptFrom` derive to /// emit its `Decryptable` — must still be a field of a row in the explicit /// mode.) -pub trait DecryptField { +pub trait DecryptField { /// [`DecryptInto::decrypt_into`] if `Self` is decryptable, `None` if not. - fn decrypt_field<'a, 'c, Ctx>(self, cipher: &'a C, context: Ctx) -> Option> + fn decrypt_field<'a>(self, cipher: &'a C, context: Ctx) -> Option> where - Ctx: DecryptContext<'c>, Self: 'a, P: 'a; } @@ -689,14 +846,13 @@ impl Decryptable for StackCipherText { const DECRYPTABLE: bool = true; } -impl DecryptField for StackCipherText +impl DecryptField for StackCipherText where C: DecryptTarget, - Self: DecryptInto, + Self: DecryptInto, { - fn decrypt_field<'a, 'c, Ctx>(self, cipher: &'a C, context: Ctx) -> Option> + fn decrypt_field<'a>(self, cipher: &'a C, context: Ctx) -> Option> where - Ctx: DecryptContext<'c>, Self: 'a, P: 'a, { @@ -709,17 +865,17 @@ impl Decryptable for Vec { const DECRYPTABLE: bool = S::DECRYPTABLE; } -impl DecryptField, StackCipher> for Vec +impl DecryptField, StackCipher, Ctx> for Vec where - S: Decryptable + DecryptField>, + S: Decryptable + DecryptField, Ctx>, + Ctx: Clone, { - fn decrypt_field<'a, 'c, Ctx>( + fn decrypt_field<'a>( self, cipher: &'a StackCipher, context: Ctx, ) -> Option, K>> where - Ctx: DecryptContext<'c>, Self: 'a, Vec: 'a, { @@ -742,18 +898,17 @@ impl Decryptable for Option { const DECRYPTABLE: bool = S::DECRYPTABLE; } -impl DecryptField, StackCipher> for Option +impl DecryptField, StackCipher, Ctx> for Option where - S: Decryptable + DecryptField>, + S: Decryptable + DecryptField, Ctx>, T: MaybeSend, { - fn decrypt_field<'a, 'c, Ctx>( + fn decrypt_field<'a>( self, cipher: &'a StackCipher, context: Ctx, ) -> Option, K>> where - Ctx: DecryptContext<'c>, Self: 'a, Option: 'a, { @@ -783,23 +938,26 @@ where /// a pending tree (no I/O), and the returned [`Pending`] carries one /// data-key request per leaf. Sealing happens in the fulfilment, key material /// drawn in the same traversal order the tree was built in. -impl EncryptFrom> for StackCipherText +/// +/// A leaf has nothing of its own to authenticate under, so it exists only +/// for a [`SuppliedContext`]: `()` is a compile error here. +impl<'c, S, K, Ctx> EncryptFrom, Ctx> for StackCipherText where S: Encrypt + Clone, + Ctx: EncryptContext<'c> + SuppliedContext<'c>, { - fn encrypt_from<'a, 'c, Ctx>( + fn encrypt_from<'a>( source: &'a S, cipher: &'a StackCipher, context: Ctx, ) -> Pending<'a, Self, K> where - Ctx: EncryptContext<'c>, Self: 'a, { let aad = context.into_aad().into_owned(); // An empty context would leave the leaf AAD carrying only the key - // tag, making ciphertexts transplantable between ()-context fields — - // see `EncryptContext`. + // tag, making ciphertexts transplantable between empty-context + // fields — see `EncryptContext`. if is_degenerate_aad(aad.as_bytes()) { return Pending::failed(cipher, Error::EmptyContext); } @@ -868,17 +1026,13 @@ fn decipher_from_responses( /// (`iv` + `tag` are lifted out of the tree during the synchronous build); /// the fulfilment binds the retrieved keys back onto the leaves and lets the /// value's `Decrypt` impl drive the opening. -impl DecryptInto> for StackCipherText +impl<'c, T, K, Ctx> DecryptInto, Ctx> for StackCipherText where T: Decrypt<'static> + 'static, + Ctx: DecryptContext<'c> + SuppliedContext<'c>, { - fn decrypt_into<'a, 'c, Ctx>( - self, - cipher: &'a StackCipher, - context: Ctx, - ) -> Pending<'a, T, K> + fn decrypt_into<'a>(self, cipher: &'a StackCipher, context: Ctx) -> Pending<'a, T, K> where - Ctx: DecryptContext<'c>, Self: 'a, T: 'a, { @@ -935,23 +1089,25 @@ fn collect_retrieve_requests(ciphertext: &StackCipherText, out: &mut Vec → StackCipherText` (via [`Encrypt`]), which is one /// record whose value is a list — see the [module docs](self). -impl EncryptFrom, StackCipher> for Vec +/// +/// The context is passed through untouched, and so is the obligation: a +/// column of leaves needs a [`SuppliedContext`] because its leaves do, a +/// column of rows accepts `()` because its rows do. Neither is decided here, +/// and neither is an empty context, which the leaves reject the moment a +/// value reaches them. +impl EncryptFrom, StackCipher, Ctx> for Vec where - T: EncryptFrom>, + T: EncryptFrom, Ctx>, + Ctx: Clone, { - fn encrypt_from<'a, 'c, Ctx>( + fn encrypt_from<'a>( source: &'a Vec, cipher: &'a StackCipher, context: Ctx, ) -> Pending<'a, Self, K> where - Ctx: EncryptContext<'c>, Self: 'a, { - // The context is passed through untouched, not validated here: a - // column of records whose fields carry their own contexts (a derived - // row) has none of its own, and the leaves reject an empty one the - // moment a value reaches them. let items = source .iter() .map(|item| T::encrypt_from(item, cipher, context.clone())) @@ -961,17 +1117,13 @@ where } /// The column decrypt mirror: one batched retrieve for every row. -impl DecryptInto, StackCipher> for Vec +impl DecryptInto, StackCipher, Ctx> for Vec where - S: DecryptInto>, + S: DecryptInto, Ctx>, + Ctx: Clone, { - fn decrypt_into<'a, 'c, Ctx>( - self, - cipher: &'a StackCipher, - context: Ctx, - ) -> Pending<'a, Vec, K> + fn decrypt_into<'a>(self, cipher: &'a StackCipher, context: Ctx) -> Pending<'a, Vec, K> where - Ctx: DecryptContext<'c>, Self: 'a, Vec: 'a, { @@ -987,17 +1139,16 @@ where /// absent *record field*, carrying no requests). This is distinct from /// `Option → StackCipherText` via [`Encrypt`], which produces an /// *authenticated* absence marker inside one ciphertext. -impl EncryptFrom, StackCipher> for Option +impl EncryptFrom, StackCipher, Ctx> for Option where - T: EncryptFrom> + MaybeSend, + T: EncryptFrom, Ctx> + MaybeSend, { - fn encrypt_from<'a, 'c, Ctx>( + fn encrypt_from<'a>( source: &'a Option, cipher: &'a StackCipher, context: Ctx, ) -> Pending<'a, Self, K> where - Ctx: EncryptContext<'c>, Self: 'a, { // `None` derives nothing and checks nothing: the context is the @@ -1010,18 +1161,13 @@ where } /// The optional decrypt mirror of the [`Option`] encrypt implementation. -impl DecryptInto, StackCipher> for Option +impl DecryptInto, StackCipher, Ctx> for Option where - S: DecryptInto>, + S: DecryptInto, Ctx>, T: MaybeSend, { - fn decrypt_into<'a, 'c, Ctx>( - self, - cipher: &'a StackCipher, - context: Ctx, - ) -> Pending<'a, Option, K> + fn decrypt_into<'a>(self, cipher: &'a StackCipher, context: Ctx) -> Pending<'a, Option, K> where - Ctx: DecryptContext<'c>, Self: 'a, Option: 'a, { diff --git a/packages/stack-encrypt/tests/derive.rs b/packages/stack-encrypt/tests/derive.rs index 082e61b13..2dd6f20d2 100644 --- a/packages/stack-encrypt/tests/derive.rs +++ b/packages/stack-encrypt/tests/derive.rs @@ -11,10 +11,10 @@ use std::sync::atomic::Ordering as AtomicOrdering; use cllw_ore::CllwOreEncrypt; use common::{counting_cipher, stack_cipher}; use stack_encrypt::sem::{EqualityTerm, MatchTerm, OreTerm}; -use stack_encrypt::target::EncryptInto; +use stack_encrypt::target::{DecryptFrom, EncryptInto}; use stack_encrypt::{ - DecryptContext, DecryptField, DecryptInto, DecryptTarget, Decryptable, EncryptContext, - EncryptFrom, Error, Pending, StackCipher, StackCipherText, + DecryptField, DecryptInto, DecryptTarget, Decryptable, EncryptFrom, Error, Pending, + StackCipher, StackCipherText, }; // --- Records: every field from one plaintext, under one context ------------- @@ -34,12 +34,21 @@ async fn a_derived_record_is_the_hand_written_one() { let cipher = stack_cipher().await; let generator = stack_cipher().await; - let record: EncryptedAge = 42u32.encrypt_into(&cipher, "users/age").await.unwrap(); + let record: EncryptedAge = 42u32 + .encrypt_into_with_context(&cipher, "users/age") + .await + .unwrap(); // Each term is what the leaf derives on its own, so query terms built // leaf-by-leaf find records encrypted as composites. - let hm: EqualityTerm = 42u32.encrypt_into(&generator, "users/age").await.unwrap(); - let ob: OreTerm = 42u32.encrypt_into(&generator, "users/age").await.unwrap(); + let hm: EqualityTerm = 42u32 + .encrypt_into_with_context(&generator, "users/age") + .await + .unwrap(); + let ob: OreTerm = 42u32 + .encrypt_into_with_context(&generator, "users/age") + .await + .unwrap(); assert_eq!(record.hm, hm); assert_eq!(record.ob, ob); @@ -79,17 +88,17 @@ async fn a_generic_plaintext_record_accepts_what_its_leaves_accept() { let record: SearchableText = "alice" .to_string() - .encrypt_into(&cipher, "users/name") + .encrypt_into_with_context(&cipher, "users/name") .await .unwrap(); let hm: EqualityTerm = "alice" .to_string() - .encrypt_into(&generator, "users/name") + .encrypt_into_with_context(&generator, "users/name") .await .unwrap(); let m: MatchTerm = "alice" .to_string() - .encrypt_into(&generator, "users/name") + .encrypt_into_with_context(&generator, "users/name") .await .unwrap(); assert_eq!(record.hm, hm); @@ -97,14 +106,26 @@ async fn a_generic_plaintext_record_accepts_what_its_leaves_accept() { let name: String = record.decrypt_into(&cipher, "users/name").await.unwrap(); assert_eq!(name, "alice"); - let pair: Pair = "bob".encrypt_into(&cipher, "users/name").await.unwrap(); - let hm: EqualityTerm = "bob".encrypt_into(&generator, "users/name").await.unwrap(); + let pair: Pair = "bob" + .encrypt_into_with_context(&cipher, "users/name") + .await + .unwrap(); + let hm: EqualityTerm = "bob" + .encrypt_into_with_context(&generator, "users/name") + .await + .unwrap(); assert_eq!(pair.1, hm); let name: String = pair.0.decrypt_into(&cipher, "users/name").await.unwrap(); assert_eq!(name, "bob"); - let tagged: Tagged = 7u32.encrypt_into(&cipher, "users/score").await.unwrap(); - let ob: OreTerm = 7u32.encrypt_into(&generator, "users/score").await.unwrap(); + let tagged: Tagged = 7u32 + .encrypt_into_with_context(&cipher, "users/score") + .await + .unwrap(); + let ob: OreTerm = 7u32 + .encrypt_into_with_context(&generator, "users/score") + .await + .unwrap(); assert_eq!(tagged.ob, ob); let score: u32 = tagged.decrypt_into(&cipher, "users/score").await.unwrap(); assert_eq!(score, 7); @@ -135,11 +156,17 @@ struct Numbers { async fn decrypt_marks_the_field_when_the_types_cannot_choose() { let cipher = stack_cipher().await; - let doubled: Doubled = 9u32.encrypt_into(&cipher, "doubled").await.unwrap(); + let doubled: Doubled = 9u32 + .encrypt_into_with_context(&cipher, "doubled") + .await + .unwrap(); let opened: u32 = doubled.decrypt_into(&cipher, "doubled").await.unwrap(); assert_eq!(opened, 9); // The unmarked ciphertext is still a ciphertext, just not the record's. - let doubled: Doubled = 9u32.encrypt_into(&cipher, "doubled").await.unwrap(); + let doubled: Doubled = 9u32 + .encrypt_into_with_context(&cipher, "doubled") + .await + .unwrap(); let shadow: u32 = doubled .shadow .decrypt_into(&cipher, "doubled/shadow") @@ -148,7 +175,7 @@ async fn decrypt_marks_the_field_when_the_types_cannot_choose() { assert_eq!(shadow, 9); let numbers: Numbers = vec![1u32, 2, 3] - .encrypt_into(&cipher, "numbers") + .encrypt_into_with_context(&cipher, "numbers") .await .unwrap(); assert_eq!(numbers.hm.len(), 3); @@ -161,17 +188,16 @@ async fn decrypt_marks_the_field_when_the_types_cannot_choose() { #[derive(PartialEq)] struct OpaqueTerm(EqualityTerm); -impl EncryptFrom> for OpaqueTerm +impl EncryptFrom, Ctx> for OpaqueTerm where - EqualityTerm: EncryptFrom>, + EqualityTerm: EncryptFrom, Ctx>, { - fn encrypt_from<'a, 'c, Ctx>( + fn encrypt_from<'a>( source: &'a S, cipher: &'a StackCipher, context: Ctx, ) -> Pending<'a, Self, K> where - Ctx: EncryptContext<'c>, Self: 'a, { EqualityTerm::encrypt_from(source, cipher, context).map(OpaqueTerm) @@ -198,8 +224,11 @@ async fn explicit_mode_supports_opaque_fields_in_the_paired_derive() { let cipher = stack_cipher().await; let generator = stack_cipher().await; - let record: WithOpaque = 5u32.encrypt_into(&cipher, "opaque").await.unwrap(); - let hm: EqualityTerm = 5u32.encrypt_into(&generator, "opaque").await.unwrap(); + let record: WithOpaque = 5u32.encrypt_into_with_context(&cipher, "opaque").await.unwrap(); + let hm: EqualityTerm = 5u32 + .encrypt_into_with_context(&generator, "opaque") + .await + .unwrap(); assert!(record.o == OpaqueTerm(hm)); let opened: u32 = record.decrypt_into(&cipher, "opaque").await.unwrap(); @@ -215,10 +244,9 @@ impl Decryptable for Lying { const DECRYPTABLE: bool = true; } -impl DecryptField for Lying { - fn decrypt_field<'a, 'c, Ctx>(self, _cipher: &'a C, _context: Ctx) -> Option> +impl DecryptField for Lying { + fn decrypt_field<'a>(self, _cipher: &'a C, _context: Ctx) -> Option> where - Ctx: DecryptContext<'c>, Self: 'a, P: 'a, { @@ -269,13 +297,19 @@ struct EncryptedValue { async fn listed_plaintexts_each_get_their_own_impl() { let cipher = stack_cipher().await; - let number: EncryptedValue = 7u32.encrypt_into(&cipher, "t/n").await.unwrap(); + let number: EncryptedValue = 7u32 + .encrypt_into_with_context(&cipher, "t/n") + .await + .unwrap(); let text: EncryptedValue = "seven" .to_string() - .encrypt_into(&cipher, "t/t") + .encrypt_into_with_context(&cipher, "t/t") + .await + .unwrap(); + let hm: EqualityTerm = 7u32 + .encrypt_into_with_context(&cipher, "t/n") .await .unwrap(); - let hm: EqualityTerm = 7u32.encrypt_into(&cipher, "t/n").await.unwrap(); assert_eq!(number.hm, hm); let number: u32 = number.decrypt_into(&cipher, "t/n").await.unwrap(); @@ -290,7 +324,10 @@ async fn a_failed_field_fails_the_derived_record_before_any_io() { // An empty context fails every leaf during the synchronous build; the // derived record is the zip of those, so it fails the same way and never // mints the data key its ciphertext field would have wanted. - let result: Result = "alice".to_string().encrypt_into(&cipher, "").await; + let result: Result = "alice" + .to_string() + .encrypt_into_with_context(&cipher, "") + .await; assert!(matches!(result, Err(Error::EmptyContext))); assert_eq!(generates.load(AtomicOrdering::SeqCst), 0); } @@ -332,8 +369,9 @@ async fn a_row_is_one_batched_call_and_rebuilds_its_plaintext() { let (cipher, generates, retrieves) = counting_cipher().await; let generator = stack_cipher().await; - // Every field has its own context, so the row's is unused: `()` is fine. - let row: EncryptedUser = user().encrypt_into(&cipher, ()).await.unwrap(); + // Every field has its own context, so the row needs none from the + // caller: the context-free forms are the whole call, both ways. + let row: EncryptedUser = user().encrypt_into(&cipher).await.unwrap(); assert_eq!( generates.load(AtomicOrdering::SeqCst), 1, @@ -343,17 +381,20 @@ async fn a_row_is_one_batched_call_and_rebuilds_its_plaintext() { // Each field's terms are what a query site derives under the column's // literal context. - let age_hm: EqualityTerm = 42u32.encrypt_into(&generator, "users/age").await.unwrap(); + let age_hm: EqualityTerm = 42u32 + .encrypt_into_with_context(&generator, "users/age") + .await + .unwrap(); assert_eq!(row.age.hm, age_hm); let email_hm: EqualityTerm = user() .email - .encrypt_into(&generator, "users/email") + .encrypt_into_with_context(&generator, "users/email") .await .unwrap(); assert_eq!(row.email_eq, email_hm); // Decryption rebuilds the plaintext field by field: one batched call. - let recovered: User = row.decrypt_into(&cipher, ()).await.unwrap(); + let recovered = User::decrypt_from(row, &cipher).await.unwrap(); assert_eq!(recovered, user()); assert_eq!( retrieves.load(AtomicOrdering::SeqCst), @@ -373,11 +414,11 @@ async fn a_column_of_rows_is_still_one_call_each_way() { }) .collect(); - let rows: Vec = users.encrypt_into(&cipher, ()).await.unwrap(); + let rows: Vec = users.encrypt_into(&cipher).await.unwrap(); assert_eq!(rows.len(), 4); assert_eq!(generates.load(AtomicOrdering::SeqCst), 1); - let recovered: Vec = rows.decrypt_into(&cipher, ()).await.unwrap(); + let recovered = Vec::::decrypt_from(rows, &cipher).await.unwrap(); assert_eq!(recovered, users); assert_eq!(retrieves.load(AtomicOrdering::SeqCst), 1); } @@ -386,7 +427,7 @@ async fn a_column_of_rows_is_still_one_call_each_way() { async fn a_row_field_opened_under_the_wrong_context_fails() { let cipher = stack_cipher().await; - let row: EncryptedUser = user().encrypt_into(&cipher, ()).await.unwrap(); + let row: EncryptedUser = user().encrypt_into(&cipher).await.unwrap(); // The literal contexts are baked into the impl, so a transplanted field // is caught by the AAD exactly as for a leaf. let transplanted: Result = row.age.c.decrypt_into(&cipher, "users/height").await; @@ -412,14 +453,14 @@ async fn a_tuple_plaintext_row_is_reached_and_rebuilt_by_index() { let generator = stack_cipher().await; let reading = Reading(21, "celsius".into()); - let row: EncryptedReading = reading.encrypt_into(&cipher, ()).await.unwrap(); + let row: EncryptedReading = reading.encrypt_into(&cipher).await.unwrap(); let hm: EqualityTerm = 21u32 - .encrypt_into(&generator, "readings/value") + .encrypt_into_with_context(&generator, "readings/value") .await .unwrap(); assert_eq!(row.value.hm, hm); - let recovered: Reading = row.decrypt_into(&cipher, ()).await.unwrap(); + let recovered = Reading::decrypt_from(row, &cipher).await.unwrap(); assert_eq!(recovered, reading); } diff --git a/packages/stack-encrypt/tests/target.rs b/packages/stack-encrypt/tests/target.rs index a9531d7d0..97c17b5a1 100644 --- a/packages/stack-encrypt/tests/target.rs +++ b/packages/stack-encrypt/tests/target.rs @@ -10,6 +10,7 @@ use std::sync::atomic::Ordering as AtomicOrdering; use stack_encrypt::sem::{EqualityTerm, MatchConfig, MatchOptions, MatchTerm, OreTerm}; use stack_encrypt::target::{ DecryptContext, DecryptInto, EncryptContext, EncryptFrom, EncryptInto, Pending, Request, + SuppliedContext, }; use stack_encrypt::{Error, StackCipher, StackCipherText}; use stack_kms::{FakeDataKeySource, IdentifiedBy, IndexKeySource}; @@ -34,7 +35,7 @@ async fn equality_leaf_agrees_with_the_descriptor_api() { let generator = generator().await; let via_target: EqualityTerm = "alice" - .encrypt_into(&generator, "users/email") + .encrypt_into_with_context(&generator, "users/email") .await .unwrap(); let via_descriptor = generator @@ -56,15 +57,24 @@ async fn terms_agree_between_independently_built_ciphers() { let cipher = stack_cipher().await; let generator = generator().await; - let a: EqualityTerm = "alice".encrypt_into(&cipher, "users/email").await.unwrap(); + let a: EqualityTerm = "alice" + .encrypt_into_with_context(&cipher, "users/email") + .await + .unwrap(); let b: EqualityTerm = "alice" - .encrypt_into(&generator, "users/email") + .encrypt_into_with_context(&generator, "users/email") .await .unwrap(); assert_eq!(a, b); - let a: OreTerm = 7u64.encrypt_into(&cipher, "users/n").await.unwrap(); - let b: OreTerm = 7u64.encrypt_into(&generator, "users/n").await.unwrap(); + let a: OreTerm = 7u64 + .encrypt_into_with_context(&cipher, "users/n") + .await + .unwrap(); + let b: OreTerm = 7u64 + .encrypt_into_with_context(&generator, "users/n") + .await + .unwrap(); assert_eq!(a, b); } @@ -73,11 +83,11 @@ async fn equality_leaf_binds_the_context() { let generator = generator().await; let email: EqualityTerm = "alice" - .encrypt_into(&generator, "users/email") + .encrypt_into_with_context(&generator, "users/email") .await .unwrap(); let name: EqualityTerm = "alice" - .encrypt_into(&generator, "users/name") + .encrypt_into_with_context(&generator, "users/name") .await .unwrap(); @@ -90,8 +100,14 @@ async fn term_derivation_makes_no_kms_calls() { // query probe must never touch ZeroKMS. let (cipher, generates, retrieves) = counting_cipher().await; - let _term: EqualityTerm = "alice".encrypt_into(&cipher, "users/email").await.unwrap(); - let _ore: OreTerm = 7u64.encrypt_into(&cipher, "users/age").await.unwrap(); + let _term: EqualityTerm = "alice" + .encrypt_into_with_context(&cipher, "users/email") + .await + .unwrap(); + let _ore: OreTerm = 7u64 + .encrypt_into_with_context(&cipher, "users/age") + .await + .unwrap(); assert_eq!(generates.load(AtomicOrdering::SeqCst), 0); assert_eq!(retrieves.load(AtomicOrdering::SeqCst), 0); @@ -103,7 +119,7 @@ async fn ciphertext_leaf_round_trips_via_decrypt_into() { let ciphertext: StackCipherText = "secret" .to_string() - .encrypt_into(&cipher, "users/email") + .encrypt_into_with_context(&cipher, "users/email") .await .unwrap(); let plaintext: String = ciphertext @@ -120,7 +136,7 @@ async fn ciphertext_leaf_cannot_be_transplanted_to_another_context() { let ciphertext: StackCipherText = "secret" .to_string() - .encrypt_into(&cipher, "users/email") + .encrypt_into_with_context(&cipher, "users/email") .await .unwrap(); @@ -137,12 +153,12 @@ async fn match_leaf_supports_containment_queries() { let stored: MatchTerm = "alice wonderland" .to_string() - .encrypt_into(&generator, "users/bio") + .encrypt_into_with_context(&generator, "users/bio") .await .unwrap(); let query: MatchTerm = "wonder" .to_string() - .encrypt_into(&generator, "users/bio") + .encrypt_into_with_context(&generator, "users/bio") .await .unwrap(); @@ -167,7 +183,7 @@ async fn match_leaf_config_is_type_level() { let term: MatchTerm = "a longer piece of text" .to_string() - .encrypt_into(&generator, "users/bio") + .encrypt_into_with_context(&generator, "users/bio") .await .unwrap(); assert!(term.positions().iter().all(|&p| u32::from(p) < 64)); @@ -176,7 +192,7 @@ async fn match_leaf_config_is_type_level() { // term — and a different type, so the two cannot be compared by mistake. let default_term: MatchTerm = "a longer piece of text" .to_string() - .encrypt_into(&generator, "users/bio") + .encrypt_into_with_context(&generator, "users/bio") .await .unwrap(); assert_ne!(term.positions(), default_term.positions()); @@ -186,11 +202,20 @@ async fn match_leaf_config_is_type_level() { async fn ore_leaf_preserves_order_and_binds_the_context() { let generator = generator().await; - let ten: OreTerm = 10u64.encrypt_into(&generator, "users/age").await.unwrap(); - let ten_again: OreTerm = 10u64.encrypt_into(&generator, "users/age").await.unwrap(); - let twenty: OreTerm = 20u64.encrypt_into(&generator, "users/age").await.unwrap(); + let ten: OreTerm = 10u64 + .encrypt_into_with_context(&generator, "users/age") + .await + .unwrap(); + let ten_again: OreTerm = 10u64 + .encrypt_into_with_context(&generator, "users/age") + .await + .unwrap(); + let twenty: OreTerm = 20u64 + .encrypt_into_with_context(&generator, "users/age") + .await + .unwrap(); let other_field: OreTerm = 10u64 - .encrypt_into(&generator, "users/height") + .encrypt_into_with_context(&generator, "users/height") .await .unwrap(); @@ -205,8 +230,14 @@ async fn ope_leaf_compares_with_plain_byte_order() { let generator = generator().await; - let ten: OpeTerm = 10u64.encrypt_into(&generator, "users/age").await.unwrap(); - let twenty: OpeTerm = 20u64.encrypt_into(&generator, "users/age").await.unwrap(); + let ten: OpeTerm = 10u64 + .encrypt_into_with_context(&generator, "users/age") + .await + .unwrap(); + let twenty: OpeTerm = 20u64 + .encrypt_into_with_context(&generator, "users/age") + .await + .unwrap(); assert_eq!(ten.cmp(&twenty), Ordering::Less); assert!(ten.inner().as_ref() < twenty.inner().as_ref()); @@ -219,7 +250,10 @@ async fn a_column_encrypts_in_one_batched_call() { let (cipher, generates, _) = counting_cipher().await; let ages: Vec = vec![29, 34, 41, 34, 57]; - let sealed: Vec = ages.encrypt_into(&cipher, "users/age").await.unwrap(); + let sealed: Vec = ages + .encrypt_into_with_context(&cipher, "users/age") + .await + .unwrap(); assert_eq!(sealed.len(), 5); assert_eq!( @@ -234,7 +268,10 @@ async fn a_column_decrypts_in_one_batched_call() { let (cipher, _, retrieves) = counting_cipher().await; let ages: Vec = vec![29, 34, 41]; - let sealed: Vec = ages.encrypt_into(&cipher, "users/age").await.unwrap(); + let sealed: Vec = ages + .encrypt_into_with_context(&cipher, "users/age") + .await + .unwrap(); let roundtrip: Vec = sealed.decrypt_into(&cipher, "users/age").await.unwrap(); @@ -251,11 +288,11 @@ async fn optional_fields_encrypt_and_decrypt_structurally() { let cipher = stack_cipher().await; let present: Option = Some("here".to_string()) - .encrypt_into(&cipher, "users/nickname") + .encrypt_into_with_context(&cipher, "users/nickname") .await .unwrap(); let absent: Option = Option::::None - .encrypt_into(&cipher, "users/nickname") + .encrypt_into_with_context(&cipher, "users/nickname") .await .unwrap(); @@ -283,14 +320,18 @@ struct EncryptedAge { ob: OreTerm, } -impl EncryptFrom> for EncryptedAge { - fn encrypt_from<'a, 'c, Ctx>( +// The record hands the caller's context to its leaves, so it needs what +// they need: a supplied one. (The derive gets this from the field bounds.) +impl<'c, K, Ctx> EncryptFrom, Ctx> for EncryptedAge +where + Ctx: EncryptContext<'c> + SuppliedContext<'c>, +{ + fn encrypt_from<'a>( source: &'a u32, cipher: &'a StackCipher, context: Ctx, ) -> Pending<'a, Self, K> where - Ctx: EncryptContext<'c>, Self: 'a, { StackCipherText::encrypt_from(source, cipher, context.clone()) @@ -302,14 +343,12 @@ impl EncryptFrom> for EncryptedAge { /// The decrypt mirror a derive would emit: only the ciphertext field /// participates — terms are one-way. -impl DecryptInto> for EncryptedAge { - fn decrypt_into<'a, 'c, Ctx>( - self, - cipher: &'a StackCipher, - context: Ctx, - ) -> Pending<'a, u32, K> +impl<'c, K, Ctx> DecryptInto, Ctx> for EncryptedAge +where + Ctx: DecryptContext<'c> + SuppliedContext<'c>, +{ + fn decrypt_into<'a>(self, cipher: &'a StackCipher, context: Ctx) -> Pending<'a, u32, K> where - Ctx: DecryptContext<'c>, Self: 'a, u32: 'a, { @@ -322,7 +361,10 @@ async fn composite_record_encrypts_every_field_from_one_source() { let cipher = stack_cipher().await; let generator = generator().await; - let record: EncryptedAge = 42u32.encrypt_into(&cipher, "users/age").await.unwrap(); + let record: EncryptedAge = 42u32 + .encrypt_into_with_context(&cipher, "users/age") + .await + .unwrap(); // The ciphertext round-trips through the decrypt mirror. let plaintext: u32 = record.decrypt_into(&cipher, "users/age").await.unwrap(); @@ -330,11 +372,20 @@ async fn composite_record_encrypts_every_field_from_one_source() { // Each term matches what the primitive would derive on its own, so query // terms generated leaf-by-leaf find records encrypted as composites. - let record: EncryptedAge = 42u32.encrypt_into(&cipher, "users/age").await.unwrap(); - let hm: EqualityTerm = 42u32.encrypt_into(&generator, "users/age").await.unwrap(); + let record: EncryptedAge = 42u32 + .encrypt_into_with_context(&cipher, "users/age") + .await + .unwrap(); + let hm: EqualityTerm = 42u32 + .encrypt_into_with_context(&generator, "users/age") + .await + .unwrap(); assert_eq!(record.hm, hm); - let ob: OreTerm = 42u32.encrypt_into(&generator, "users/age").await.unwrap(); + let ob: OreTerm = 42u32 + .encrypt_into_with_context(&generator, "users/age") + .await + .unwrap(); assert_eq!(record.ob, ob); } @@ -342,7 +393,10 @@ async fn composite_record_encrypts_every_field_from_one_source() { async fn a_composite_record_is_one_batched_call() { let (cipher, generates, _) = counting_cipher().await; - let _record: EncryptedAge = 42u32.encrypt_into(&cipher, "users/age").await.unwrap(); + let _record: EncryptedAge = 42u32 + .encrypt_into_with_context(&cipher, "users/age") + .await + .unwrap(); assert_eq!( generates.load(AtomicOrdering::SeqCst), 1, @@ -351,7 +405,10 @@ async fn a_composite_record_is_one_batched_call() { // A whole column of records: still one call. let ages: Vec = vec![10, 20, 30]; - let _column: Vec = ages.encrypt_into(&cipher, "users/age").await.unwrap(); + let _column: Vec = ages + .encrypt_into_with_context(&cipher, "users/age") + .await + .unwrap(); assert_eq!( generates.load(AtomicOrdering::SeqCst), 2, @@ -363,8 +420,14 @@ async fn a_composite_record_is_one_batched_call() { async fn composite_record_terms_preserve_order() { let cipher = stack_cipher().await; - let ten: EncryptedAge = 10u32.encrypt_into(&cipher, "users/age").await.unwrap(); - let twenty: EncryptedAge = 20u32.encrypt_into(&cipher, "users/age").await.unwrap(); + let ten: EncryptedAge = 10u32 + .encrypt_into_with_context(&cipher, "users/age") + .await + .unwrap(); + let twenty: EncryptedAge = 20u32 + .encrypt_into_with_context(&cipher, "users/age") + .await + .unwrap(); assert_eq!(ten.ob.cmp(&twenty.ob), Ordering::Less); } @@ -380,17 +443,17 @@ async fn composite_record_terms_preserve_order() { #[derive(Debug, PartialEq, Eq)] struct PrefixTerm([u8; 32]); -impl EncryptFrom> for PrefixTerm +impl<'c, S, K, Ctx, const N: usize> EncryptFrom, Ctx> for PrefixTerm where S: AsRef, + Ctx: EncryptContext<'c> + SuppliedContext<'c>, { - fn encrypt_from<'a, 'c, Ctx>( + fn encrypt_from<'a>( source: &'a S, cipher: &'a StackCipher, context: Ctx, ) -> Pending<'a, Self, K> where - Ctx: EncryptContext<'c>, Self: 'a, { // Own domain label: can never collide with a built-in term under the @@ -415,12 +478,18 @@ async fn third_party_term_type_works_on_the_public_surface() { let cipher = stack_cipher().await; let generator = generator().await; - let stored: PrefixTerm<3> = "alice".encrypt_into(&cipher, "users/name").await.unwrap(); + let stored: PrefixTerm<3> = "alice" + .encrypt_into_with_context(&cipher, "users/name") + .await + .unwrap(); let probe: PrefixTerm<3> = "alicia" - .encrypt_into(&generator, "users/name") + .encrypt_into_with_context(&generator, "users/name") + .await + .unwrap(); + let miss: PrefixTerm<3> = "bob" + .encrypt_into_with_context(&generator, "users/name") .await .unwrap(); - let miss: PrefixTerm<3> = "bob".encrypt_into(&generator, "users/name").await.unwrap(); assert_eq!(stored, probe, "same 3-char prefix, same term"); assert_ne!(stored, miss); @@ -431,14 +500,16 @@ async fn third_party_term_type_works_on_the_public_surface() { prefix: PrefixTerm<3>, } - impl EncryptFrom> for NameRecord { - fn encrypt_from<'a, 'c, Ctx>( + impl<'c, K, Ctx> EncryptFrom, Ctx> for NameRecord + where + Ctx: EncryptContext<'c> + SuppliedContext<'c>, + { + fn encrypt_from<'a>( source: &'a String, cipher: &'a StackCipher, context: Ctx, ) -> Pending<'a, Self, K> where - Ctx: EncryptContext<'c>, Self: 'a, { StackCipherText::encrypt_from(source, cipher, context.clone()) @@ -449,7 +520,7 @@ async fn third_party_term_type_works_on_the_public_surface() { let record: NameRecord = "alice" .to_string() - .encrypt_into(&cipher, "users/name") + .encrypt_into_with_context(&cipher, "users/name") .await .unwrap(); assert_eq!(record.prefix, stored); @@ -507,13 +578,16 @@ async fn empty_context_is_rejected_everywhere() { // Terms: an empty context would collapse per-field domain separation. // Rejected during the synchronous build — before any I/O could happen. - let eq: Result = "alice".encrypt_into(&generator, "").await; + let eq: Result = "alice".encrypt_into_with_context(&generator, "").await; assert!(matches!(eq, Err(Error::EmptyContext))); - let m: Result = "alice".to_string().encrypt_into(&generator, "").await; + let m: Result = "alice" + .to_string() + .encrypt_into_with_context(&generator, "") + .await; assert!(matches!(m, Err(Error::EmptyContext))); - let ore: Result, _> = 7u64.encrypt_into(&generator, "").await; + let ore: Result, _> = 7u64.encrypt_into_with_context(&generator, "").await; assert!(matches!(ore, Err(Error::EmptyContext))); - let ope: Result, _> = 7u64.encrypt_into(&generator, "").await; + let ope: Result, _> = 7u64.encrypt_into_with_context(&generator, "").await; assert!(matches!(ope, Err(Error::EmptyContext))); // Descriptor-string convenience methods route through the same guard. @@ -524,13 +598,16 @@ async fn empty_context_is_rejected_everywhere() { // The ciphertext leaf: an empty AAD would make ciphertexts transplantable // between ()-context fields. - let ct: Result = "secret".to_string().encrypt_into(&cipher, "").await; + let ct: Result = "secret" + .to_string() + .encrypt_into_with_context(&cipher, "") + .await; assert!(matches!(ct, Err(Error::EmptyContext))); // And the decrypt mirror never opens under one either. let sealed: StackCipherText = "secret" .to_string() - .encrypt_into(&cipher, "users/email") + .encrypt_into_with_context(&cipher, "users/email") .await .unwrap(); let opened: Result = sealed.decrypt_into(&cipher, "").await; @@ -544,25 +621,27 @@ async fn wrapped_empty_contexts_are_rejected_too() { // structural, so none of these get through on any path. let cipher = stack_cipher().await; - let eq: Result = "alice".encrypt_into(&cipher, None::<&str>).await; + let eq: Result = "alice" + .encrypt_into_with_context(&cipher, None::<&str>) + .await; assert!(matches!(eq, Err(Error::EmptyContext))); - let eq: Result = "alice".encrypt_into(&cipher, Some("")).await; + let eq: Result = "alice".encrypt_into_with_context(&cipher, Some("")).await; assert!(matches!(eq, Err(Error::EmptyContext))); - let eq: Result = "alice".encrypt_into(&cipher, ("", "")).await; + let eq: Result = "alice".encrypt_into_with_context(&cipher, ("", "")).await; assert!(matches!(eq, Err(Error::EmptyContext))); - let ore: Result, _> = 7u64.encrypt_into(&cipher, None::<&str>).await; + let ore: Result, _> = 7u64.encrypt_into_with_context(&cipher, None::<&str>).await; assert!(matches!(ore, Err(Error::EmptyContext))); let ct: Result = "secret" .to_string() - .encrypt_into(&cipher, None::<&str>) + .encrypt_into_with_context(&cipher, None::<&str>) .await; assert!(matches!(ct, Err(Error::EmptyContext))); let sealed: StackCipherText = "secret" .to_string() - .encrypt_into(&cipher, "users/email") + .encrypt_into_with_context(&cipher, "users/email") .await .unwrap(); let opened: Result = sealed.decrypt_into(&cipher, None::<&str>).await; @@ -571,7 +650,7 @@ async fn wrapped_empty_contexts_are_rejected_too() { // A wrapped context that does carry information still works, and binds. let sealed: StackCipherText = "secret" .to_string() - .encrypt_into(&cipher, Some("users/email")) + .encrypt_into_with_context(&cipher, Some("users/email")) .await .unwrap(); let opened: String = sealed @@ -585,28 +664,36 @@ async fn wrapped_empty_contexts_are_rejected_too() { async fn containers_pass_the_context_through_to_their_leaves() { // `Vec` and `Option` validate nothing themselves: a populated container // under an empty context fails at the first leaf (synchronously, before - // any I/O), and an empty one has no leaf to fail at. That is what lets a - // column of derived rows — records whose fields carry their own contexts - // — be given `()`. + // any I/O), and an empty one has no leaf to fail at. Whether a context + // is *owed* at all is likewise the leaves' call, passed through the + // type: a column of leaves is `EncryptFrom<_, _, Ctx>` only for a + // supplied `Ctx` (`tests/ui/leaf_without_context.rs`), a column of + // derived rows — records whose fields carry their own contexts — for + // any, so it is encrypted with no context at all. let (cipher, generates, _) = counting_cipher().await; - let some: Result, _> = - Some("x".to_string()).encrypt_into(&cipher, "").await; + let some: Result, _> = Some("x".to_string()) + .encrypt_into_with_context(&cipher, "") + .await; assert!(matches!(some, Err(Error::EmptyContext))); - let populated: Result, _> = - vec!["x".to_string()].encrypt_into(&cipher, "").await; + let populated: Result, _> = vec!["x".to_string()] + .encrypt_into_with_context(&cipher, "") + .await; assert!(matches!(populated, Err(Error::EmptyContext))); assert_eq!(generates.load(AtomicOrdering::SeqCst), 0); - let none: Option = None::.encrypt_into(&cipher, ()).await.unwrap(); + let none: Option = None:: + .encrypt_into_with_context(&cipher, "users/x") + .await + .unwrap(); assert!(none.is_none()); let empty: Vec = Vec::::new() - .encrypt_into(&cipher, ()) + .encrypt_into_with_context(&cipher, "users/x") .await .unwrap(); assert!(empty.is_empty()); let empty: Vec = Vec::::new() - .decrypt_into(&cipher, ()) + .decrypt_into(&cipher, "users/x") .await .unwrap(); assert!(empty.is_empty()); @@ -689,7 +776,7 @@ async fn terms_rehydrate_from_persisted_parts() { let generator = generator().await; let eq: EqualityTerm = "alice" - .encrypt_into(&generator, "users/email") + .encrypt_into_with_context(&generator, "users/email") .await .unwrap(); let rehydrated = EqualityTerm::from_bytes(eq.clone().into_bytes()); @@ -697,12 +784,12 @@ async fn terms_rehydrate_from_persisted_parts() { let stored: MatchTerm = "alice wonderland" .to_string() - .encrypt_into(&generator, "users/bio") + .encrypt_into_with_context(&generator, "users/bio") .await .unwrap(); let query: MatchTerm = "wonder" .to_string() - .encrypt_into(&generator, "users/bio") + .encrypt_into_with_context(&generator, "users/bio") .await .unwrap(); // Rehydrate from unsorted positions: from_positions normalises. @@ -721,7 +808,7 @@ async fn pending_futures_are_send() { let handle = tokio::spawn(async move { let term: EqualityTerm = "alice" - .encrypt_into(&generator, "users/email") + .encrypt_into_with_context(&generator, "users/email") .await .unwrap(); term @@ -767,7 +854,10 @@ async fn cipher_directed_and_target_directed_ciphertexts_are_interchangeable() { assert_eq!(via_target, value); // Sealed by the target-directed API, opened by the cipher-directed one. - let ct: StackCipherText = value.encrypt_into(&cipher, "users/tags").await.unwrap(); + let ct: StackCipherText = value + .encrypt_into_with_context(&cipher, "users/tags") + .await + .unwrap(); let via_cipher: Vec = cipher.decrypt(ct, "users/tags").await.unwrap(); assert_eq!(via_cipher, value); } @@ -777,7 +867,7 @@ async fn cipher_directed_decrypt_rejects_a_transplanted_ciphertext() { let cipher = stack_cipher().await; let ct: StackCipherText = "secret" .to_string() - .encrypt_into(&cipher, "users/email") + .encrypt_into_with_context(&cipher, "users/email") .await .unwrap(); let result: Result = cipher.decrypt(ct, "users/name").await; diff --git a/packages/stack-encrypt/tests/ui/leaf_without_context.rs b/packages/stack-encrypt/tests/ui/leaf_without_context.rs new file mode 100644 index 000000000..c9c1e428a --- /dev/null +++ b/packages/stack-encrypt/tests/ui/leaf_without_context.rs @@ -0,0 +1,26 @@ +//! A leaf, a record that hands the caller's context to one, and a column of +//! either all need a supplied context: the context-free `encrypt_into` / +//! `decrypt_from` exist only for outputs that carry their own. +use stack_encrypt::sem::EqualityTerm; +use stack_encrypt::target::{DecryptFrom, EncryptInto}; +use stack_encrypt::{DecryptInto, EncryptFrom, StackCipher, StackCipherText}; +use stack_kms::FakeDataKeySource; + +#[derive(EncryptFrom, DecryptInto)] +#[stash(plaintext = u32)] +struct EncryptedAge { + c: StackCipherText, + hm: EqualityTerm, +} + +async fn encrypt(cipher: &StackCipher) { + let _term: EqualityTerm = "alice".encrypt_into(cipher).await.unwrap(); + let _record: EncryptedAge = 42u32.encrypt_into(cipher).await.unwrap(); + let _column: Vec = vec![1u32].encrypt_into(cipher).await.unwrap(); +} + +async fn decrypt(cipher: &StackCipher, record: EncryptedAge) { + let _age = u32::decrypt_from(record, cipher).await.unwrap(); +} + +fn main() {} diff --git a/packages/stack-encrypt/tests/ui/leaf_without_context.stderr b/packages/stack-encrypt/tests/ui/leaf_without_context.stderr new file mode 100644 index 000000000..68662b6c2 --- /dev/null +++ b/packages/stack-encrypt/tests/ui/leaf_without_context.stderr @@ -0,0 +1,90 @@ +error[E0277]: `()` is not a context the caller supplied + --> tests/ui/leaf_without_context.rs:17:39 + | +17 | let _term: EqualityTerm = "alice".encrypt_into(cipher).await.unwrap(); + | ^^^^^^^^^^^^ this leaf needs a context + | + = note: `()` is what `encrypt_into` / `decrypt_from` pass: an output type that reaches a leaf needs `encrypt_into_with_context` / `decrypt_from_with_context` instead + = help: the trait `SuppliedContext<'_>` is not implemented for `()` + but it is implemented for `(_, _)` + = help: for that trait implementation, expected `(_, _)`, found `()` + = note: required for `EqualityTerm` to implement `EncryptFrom<&str, StackCipher, ()>` +note: required by a bound in `encrypt_into` + --> src/target/mod.rs + | + | fn encrypt_into<'a, T, C>(&'a self, cipher: &'a C) -> C::Output<'a, T> + | ------------ required by a bound in this associated function +... + | T: EncryptFrom + 'a, + | ^^^^^^^^^^^^^^^^^^^^^^^^ required by this bound in `EncryptInto::encrypt_into` + +error[E0277]: `()` is not a context the caller supplied + --> tests/ui/leaf_without_context.rs:18:39 + | +18 | let _record: EncryptedAge = 42u32.encrypt_into(cipher).await.unwrap(); + | ^^^^^^^^^^^^ this leaf needs a context + | + = note: `()` is what `encrypt_into` / `decrypt_from` pass: an output type that reaches a leaf needs `encrypt_into_with_context` / `decrypt_from_with_context` instead + = help: the trait `SuppliedContext<'_>` is not implemented for `()` + but it is implemented for `(_, _)` + = help: for that trait implementation, expected `(_, _)`, found `()` + = note: required for `CipherText>` to implement `EncryptFrom, ()>` + = note: 1 redundant requirement hidden + = note: required for `EncryptedAge` to implement `EncryptFrom, ()>` +note: required by a bound in `encrypt_into` + --> src/target/mod.rs + | + | fn encrypt_into<'a, T, C>(&'a self, cipher: &'a C) -> C::Output<'a, T> + | ------------ required by a bound in this associated function +... + | T: EncryptFrom + 'a, + | ^^^^^^^^^^^^^^^^^^^^^^^^ required by this bound in `EncryptInto::encrypt_into` + +error[E0277]: `()` is not a context the caller supplied + --> tests/ui/leaf_without_context.rs:19:41 + | +19 | let _column: Vec = vec![1u32].encrypt_into(cipher).await.unwrap(); + | ^^^^^^^^^^ ------------ required by a bound introduced by this call + | | + | this leaf needs a context + | + = note: `()` is what `encrypt_into` / `decrypt_from` pass: an output type that reaches a leaf needs `encrypt_into_with_context` / `decrypt_from_with_context` instead + = help: the trait `SuppliedContext<'_>` is not implemented for `()` + but it is implemented for `(_, _)` + = help: for that trait implementation, expected `(_, _)`, found `()` + = note: required for `CipherText>` to implement `EncryptFrom, ()>` + = note: 1 redundant requirement hidden + = note: required for `Vec>>` to implement `EncryptFrom, StackCipher, ()>` +note: required by a bound in `encrypt_into` + --> src/target/mod.rs + | + | fn encrypt_into<'a, T, C>(&'a self, cipher: &'a C) -> C::Output<'a, T> + | ------------ required by a bound in this associated function +... + | T: EncryptFrom + 'a, + | ^^^^^^^^^^^^^^^^^^^^^^^^ required by this bound in `EncryptInto::encrypt_into` + +error[E0277]: `()` is not a context the caller supplied + --> tests/ui/leaf_without_context.rs:23:34 + | +23 | let _age = u32::decrypt_from(record, cipher).await.unwrap(); + | ----------------- ^^^^^^ this leaf needs a context + | | + | required by a bound introduced by this call + | + = note: `()` is what `encrypt_into` / `decrypt_from` pass: an output type that reaches a leaf needs `encrypt_into_with_context` / `decrypt_from_with_context` instead + = help: the trait `SuppliedContext<'_>` is not implemented for `()` + but it is implemented for `(_, _)` + = help: for that trait implementation, expected `(_, _)`, found `()` + = note: required for `CipherText>` to implement `DecryptInto, ()>` + = note: required for `CipherText>` to implement `DecryptField, ()>` + = note: 1 redundant requirement hidden + = note: required for `EncryptedAge` to implement `DecryptInto, ()>` +note: required by a bound in `decrypt_from` + --> src/target/mod.rs + | + | fn decrypt_from<'a>(source: S, cipher: &'a C) -> C::Output<'a, Self> + | ------------ required by a bound in this associated function + | where + | S: DecryptInto + 'a, + | ^^^^^^^^^^^^^^^^^^^^^^^^ required by this bound in `DecryptFrom::decrypt_from` From 8d870b1a0fe23f55bec4857f1891418f4882d27a Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Sat, 29 Aug 2026 11:44:33 +1000 Subject: [PATCH 465/686] fix(stack-encrypt): a row is implemented for `()` alone; a `from` field is never handed the caller's context MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review of the context-parameter commit found the derive's where clause and the docs disagreeing in both directions. A row whose fields all carry literals left `__Ctx` unbounded, so `encrypt_into_with_context(&cipher, tenant)` compiled against it and silently dropped `tenant` — while the docs said the compiler turns that form away. And a `from` field with no literal made the impl demand `SuppliedContext` regardless of the field's type, so a row nesting another (all-literal) row lost the context-free forms it had before, and the context the caller was then forced to pass never reached a leaf. The same bound landed on `DecryptInto` for one-way term fields that decryption never opens. One rule replaces all of that: a `from` field is derived under its own `context`, or under `()` if it has none — never under the caller's. Its type decides whether `()` will do: a nested row accepts it, a leaf refuses it at the field (the obligation is checked in the body, spanned at the field type) until it is given a literal. A row therefore never passes the caller's context anywhere and is implemented for `Ctx = ()` exactly, which makes `encrypt_into` / `decrypt_from` the only forms that compile, as documented. Handing every column of a row one shared context was the cross-column transplant the per-field contexts exist to prevent, so nothing legitimate is lost; `#[stash(row = ..)]` fills the literal in. The derive's context bound is now `__Ctx: Clone` — the per-field bounds already imply `EncryptContext` / `DecryptContext`, and the extra impl lifetime went with it. `Record::derived()` replaces three inlined filters and the by-field candidate list is computed once. Also from the review: - `EncryptFrom` / `DecryptInto` `on_unimplemented` notes said "if `{Ctx}` is `()` … use `encrypt_into_with_context`" unconditionally, which never fired for `()` (the `SuppliedContext` note wins) and gave false advice on a source-type mismatch. Both now state the rule; `SuppliedContext`'s note covers `from` fields and how a context type of your own opts in. - The "extend with your own SEM type" recipe guarded with `context.as_bytes().is_empty()` on an encoded `PrfContext`, which is never true. `is_degenerate_aad` / `is_degenerate_prf_context` are public; the recipe and the `PrefixTerm` example use them. - `DecryptFrom`'s blanket impl had dropped its `S: DecryptInto` clause, so the trait held for every triple and meant nothing as a bound. It mirrors `EncryptInto` now: no trait parameters. - Decrypt leaves bound `Ctx: SuppliedContext<'c>` alone; it implies `DecryptContext`. - Stale spellings from the rename: `encrypt_into::(..)`, two-parameter `EncryptFrom` / `DecryptInto`, and the design doc's `#[encrypted(source = ..)]` / "omit `source`". Pinned by `tests/ui/row_with_context.rs` (`_with_context` against a row), `tests/ui/from_leaf_without_context.rs` (a `from` leaf with no literal, reported at the field) and `a_row_nests_in_a_row_without_a_context`. Claude-Session: https://claude.ai/code/session_01HU1Bbw4eQp9kEEneXxDcKr --- docs/target-directed-encryption.md | 30 ++--- .../stack-encrypt-derive/docs/attributes.md | 24 ++-- packages/stack-encrypt-derive/src/decrypt.rs | 115 ++++++++---------- packages/stack-encrypt-derive/src/encrypt.rs | 98 +++++++-------- packages/stack-encrypt-derive/src/lib.rs | 13 +- packages/stack-encrypt-derive/src/shape.rs | 100 +++++++++------ .../examples/encrypted_record.rs | 4 +- packages/stack-encrypt/src/cipher.rs | 2 +- packages/stack-encrypt/src/sem/mod.rs | 4 +- packages/stack-encrypt/src/target/mod.rs | 80 ++++++------ packages/stack-encrypt/tests/derive.rs | 48 +++++++- packages/stack-encrypt/tests/target.rs | 15 ++- .../tests/ui/from_leaf_without_context.rs | 17 +++ .../tests/ui/from_leaf_without_context.stderr | 26 ++++ .../tests/ui/leaf_without_context.stderr | 16 ++- .../tests/ui/row_with_context.rs | 32 +++++ .../tests/ui/row_with_context.stderr | 32 +++++ 17 files changed, 425 insertions(+), 231 deletions(-) create mode 100644 packages/stack-encrypt/tests/ui/from_leaf_without_context.rs create mode 100644 packages/stack-encrypt/tests/ui/from_leaf_without_context.stderr create mode 100644 packages/stack-encrypt/tests/ui/row_with_context.rs create mode 100644 packages/stack-encrypt/tests/ui/row_with_context.stderr diff --git a/docs/target-directed-encryption.md b/docs/target-directed-encryption.md index 2538401b5..5335d2239 100644 --- a/docs/target-directed-encryption.md +++ b/docs/target-directed-encryption.md @@ -133,26 +133,26 @@ where Ctx: EncryptContext<'c> + SuppliedContext<'c>, // what the leaves below One context fans out to every field. `zip` concatenates the fields' requests, so the whole record is still one batched call when awaited. The derive writes the same bound, transitively — one `FieldTy: EncryptFrom` per field — so a record inherits its leaves' demand for a supplied context without naming it. -**The derive** (not yet built) writes exactly that impl from the struct: +**The derive** writes exactly that impl from the struct: ```rust -#[derive(Encrypted)] -#[encrypted(source = i16, source = i32, source = i64)] +#[derive(EncryptFrom)] +#[stash(plaintext = i16, plaintext = i32, plaintext = i64)] struct IntegerOrdOre { - #[encrypted(const = SchemaVersion::V3)] v: SchemaVersion, - #[encrypted(context)] i: Identifier, - c: Ciphertext, - ob: OreBlock256, + #[stash(default = SchemaVersion::V3)] v: SchemaVersion, + c: Ciphertext, + ob: OreBlock256, } ``` -generating, per listed source: +generating, per listed plaintext: ```rust -impl EncryptFrom for IntegerOrdOre +impl EncryptFrom, Ctx> for IntegerOrdOre where - Ciphertext: EncryptFrom, - OreBlock256: EncryptFrom, + Ciphertext: EncryptFrom, Ctx>, + OreBlock256: EncryptFrom, Ctx>, + Ctx: Clone, { /* join both, assemble */ } ``` @@ -162,10 +162,10 @@ The capability bounds (`C: Cipher`, `C: ProvidesOre`) arrive **transitively from `EncryptFrom` is generic over `S`; only the derive's `plaintext` attribute pins it. Two modes: -- **Omit `source`** — the derive emits a single impl generic over `S`. The accepted sources are then exactly the intersection of what the field types accept. Nothing to maintain. -- **List sources** — one impl per listed type, restricting the target. +- **Omit `plaintext`** — the derive emits a single impl generic over `S`. The accepted sources are then exactly the intersection of what the field types accept. Nothing to maintain. +- **List plaintexts** — one impl per listed type, restricting the target. -Use the list for EQL types. `eql_v3_integer_ord_ore` is a schema statement that the column holds an integer, and `OreBlock256` is width-agnostic on the wire, so the generic form would accept a `String` and hand Postgres a payload it rejects. That restriction is EQL's, declared by EQL. The mechanism stays open: non-EQL targets omit `source`. +Use the list for EQL types. `eql_v3_integer_ord_ore` is a schema statement that the column holds an integer, and `OreBlock256` is width-agnostic on the wire, so the generic form would accept a `String` and hand Postgres a payload it rejects. That restriction is EQL's, declared by EQL. The mechanism stays open: non-EQL targets omit `plaintext`. ### Rows are the same mechanism @@ -243,7 +243,7 @@ impl<'a, T> EncryptContext<'a> for T where T: IntoAad<'a> + IntoPrfContext<'a> + Context is threaded **per value**, as an argument. It is not baked into the cipher. -Whether the *caller* owes one is decided by the target type, at compile time. `Ctx` is a parameter of `EncryptFrom` so that each impl can bound it: a leaf demands `SuppliedContext` — every context type vitaminc provides except `()` — because it has nothing else to authenticate under; a record passes the caller's context to its fields and inherits their demand through its where clause; a row whose fields all name their own context never uses the caller's and leaves `Ctx` unbounded. `encrypt_into(&cipher)` passes `()` and therefore resolves only against the last kind; everything else takes `encrypt_into_with_context`. This is vitaminc's `encrypt` / `encrypt_with_aad` split, with the choice made by the type rather than at every call site. Whether a supplied context is also *non-empty* remains a runtime check at the leaf (`Error::EmptyContext`) until vitaminc carries non-emptiness in the type ([vitaminc#291](https://github.com/cipherstash/vitaminc/issues/291)). +Whether the *caller* owes one is decided by the target type, at compile time. `Ctx` is a parameter of `EncryptFrom` so that each impl can bound it: a leaf demands `SuppliedContext` — every context type vitaminc provides except `()` — because it has nothing else to authenticate under; a record passes the caller's context to its fields and inherits their demand through its where clause; a row whose fields all name their own context never uses the caller's and is implemented for `()` alone. `encrypt_into(&cipher)` passes `()` and therefore resolves only against the last kind; everything else takes `encrypt_into_with_context` — and against a row, only `encrypt_into` does, since a supplied context would go nowhere. This is vitaminc's `encrypt` / `encrypt_with_aad` split, with the choice made by the type rather than at every call site. Whether a supplied context is also *non-empty* remains a runtime check at the leaf (`Error::EmptyContext`) until vitaminc carries non-emptiness in the type ([vitaminc#291](https://github.com/cipherstash/vitaminc/issues/291)). A scoped cipher (`cipher.for_column("users", "age")`) was considered and rejected: it makes encrypting one row — several columns, several identifiers — into several scoped ciphers, which fights batching for no gain. With context as an argument, a row is one shared `&cipher`, many contexts, one flush. diff --git a/packages/stack-encrypt-derive/docs/attributes.md b/packages/stack-encrypt-derive/docs/attributes.md index 0cc4e146c..cbf5083ab 100644 --- a/packages/stack-encrypt-derive/docs/attributes.md +++ b/packages/stack-encrypt-derive/docs/attributes.md @@ -11,9 +11,9 @@ All attributes live under `#[stash(...)]`. `plaintext` must be an owned type: the generated impl has no lifetime to give a reference. Without `plaintext`, each derive emits one impl generic over the plaintext, -bounded by what the fields accept: `EncryptedAge` below is `EncryptFrom` +bounded by what the fields accept: `EncryptedAge` below is `EncryptFrom` for any `P` that both `StackCipherText` and `EqualityTerm` accept, and -`DecryptInto` for any `P` its `decrypt` field opens to. With +`DecryptInto` for any `P` its `decrypt` field opens to. With `plaintext`, the record accepts only the listed types (a column that holds integers should not accept a `String`). Rows — decrypted field by field — must name it: the plaintext is rebuilt with a struct literal. @@ -27,14 +27,18 @@ must name it: the plaintext is rebuilt with a struct literal. | `default` / `default = expr` | Not derived: filled with `Default::default()` or `expr`. Never encrypted, never authenticated. | | `decrypt` | Decryption opens this field (`DecryptInto` only). Needed only when the field types cannot decide it — see below. | -The caller's context reaches every derived field that has no `context` of -its own, and the impl's context parameter is bounded by what those fields -accept — a leaf accepts only a `SuppliedContext`, so a record that hands the -caller's context to one is encrypted with `encrypt_into_with_context`. If -every field has a `context`, the caller's is never used, the parameter is -unbounded, and the record is encrypted with the context-free `encrypt_into` -(decrypted with `Plaintext::decrypt_from(record, &cipher)`); the compiler -turns the other form away. +The caller's context reaches every field derived from the whole plaintext +that has no `context` of its own, and the impl's context parameter is bounded +by what those fields accept — a leaf accepts only a `SuppliedContext`, so a +record that hands the caller's context to one is encrypted with +`encrypt_into_with_context`. A `from` field never receives the caller's +context: it is derived under its `context`, or under `()` if it has none, +which a nested row accepts and a leaf refuses (at the field, until it is +given a `context`). A record none of whose fields takes the caller's context +— every row — is implemented for `()` exactly, and is encrypted with the +context-free `encrypt_into` (decrypted with +`Plaintext::decrypt_from(record, &cipher)`); the compiler turns the other +form away, since the context would go nowhere. Every attribute except `plaintext` is singular, and repeating one is a compile error rather than a silent overwrite (`plaintext` is repeatable, diff --git a/packages/stack-encrypt-derive/src/decrypt.rs b/packages/stack-encrypt-derive/src/decrypt.rs index b46204c29..06eb3dc36 100644 --- a/packages/stack-encrypt-derive/src/decrypt.rs +++ b/packages/stack-encrypt-derive/src/decrypt.rs @@ -19,7 +19,7 @@ use syn::{ Result, Type, }; -use crate::shape::{context_type, push_context_generics, zip_fields, Field, Record}; +use crate::shape::{context_type, own_context, push_context_generics, zip_fields, Field, Record}; pub(crate) fn derive(input: DeriveInput) -> Result { let record = Record::parse(&input)?; @@ -39,12 +39,9 @@ pub(crate) fn derive(input: DeriveInput) -> Result { // Shared // ============================================================================= -fn decrypt_context() -> Ident { - Ident::new("DecryptContext", Span::call_site()) -} - -/// `impl DecryptInto, __Ctx> for Record` around -/// `body`. +/// `impl DecryptInto, Ctx> for Record` around +/// `body`; `ctx` is `__Ctx` or `()` ([`push_context_generics`]). +#[allow(clippy::too_many_arguments)] fn impl_block( krate: &Path, name: &Ident, @@ -52,17 +49,18 @@ fn impl_block( impl_generics: &syn::ImplGenerics<'_>, where_clause: Option<&syn::WhereClause>, plaintext: &Type, + ctx: &Type, body: TokenStream, ) -> TokenStream { quote! { #[automatically_derived] - impl #impl_generics #krate::target::DecryptInto<#plaintext, #krate::StackCipher<__K>, __Ctx> + impl #impl_generics #krate::target::DecryptInto<#plaintext, #krate::StackCipher<__K>, #ctx> for #name #ty_generics #where_clause { fn decrypt_into<'__a>( self, __cipher: &'__a #krate::StackCipher<__K>, - __context: __Ctx, + __context: #ctx, ) -> #krate::target::Pending<'__a, #plaintext, __K> where Self: '__a, @@ -114,11 +112,9 @@ fn decrypt_field_impl(input: &DeriveInput, krate: &Path) -> TokenStream { } } +/// The context one opened field is handed, by move: its own, or the caller's. fn context_for(field: &Field) -> TokenStream { - match field.context() { - Some(literal) => quote!(#literal), - None => quote!(__context), - } + own_context(field).unwrap_or_else(|| quote!(__context)) } /// The plaintext type as a struct-literal path: `User` becomes `User::`. @@ -168,7 +164,7 @@ struct Group<'a> { impl<'a> Auto<'a> { fn classify(record: &'a Record, name: &Ident) -> Result { - let candidates: Vec<&Field> = record.fields.iter().filter(|f| f.is_derived()).collect(); + let candidates = record.derived(); let with_from = candidates.iter().filter(|f| f.from().is_some()).count(); if with_from == 0 { return Ok(Auto::Whole(candidates)); @@ -223,14 +219,15 @@ fn automatic(input: &DeriveInput, record: &Record) -> Result { (TokenStream::new(), quote!(let () = const { #checks };)) }; + // Every candidate field, moved out of `self` and asked in turn. + let candidates: Vec<&Field> = match &auto { + Auto::Whole(fields) => fields.clone(), + Auto::ByField(groups) => groups + .iter() + .flat_map(|g| g.fields.iter().copied()) + .collect(), + }; let destructure = { - let candidates = match &auto { - Auto::Whole(fields) => fields.clone(), - Auto::ByField(groups) => groups - .iter() - .flat_map(|g| g.fields.iter().copied()) - .collect(), - }; let bind = candidates.iter().map(|f| { let member = &f.member; let local = &f.local; @@ -254,7 +251,7 @@ fn automatic(input: &DeriveInput, record: &Record) -> Result { generics.params.push(parse_quote!(__P)); generics.params.push(parse_quote!(__K)); push_field_bounds(&mut generics, krate, fields, &plaintext); - push_context_generics(&mut generics, krate, fields, &decrypt_context()); + let ctx = push_context_generics(&mut generics, fields); let (impl_generics, _, where_clause) = generics.split_for_impl(); let open = open_one(krate, fields, &plaintext); let body = quote!(#body_check #destructure #open); @@ -265,6 +262,7 @@ fn automatic(input: &DeriveInput, record: &Record) -> Result { &impl_generics, where_clause, &plaintext, + &ctx, body, ); return Ok(quote!(#block #definition_check)); @@ -279,18 +277,11 @@ fn automatic(input: &DeriveInput, record: &Record) -> Result { let open = match &auto { Auto::Whole(fields) => { push_field_bounds(&mut generics, krate, fields, plaintext); - push_context_generics(&mut generics, krate, fields, &decrypt_context()); open_one(krate, fields, plaintext) } - Auto::ByField(groups) => { - let fields: Vec<&Field> = groups - .iter() - .flat_map(|g| g.fields.iter().copied()) - .collect(); - push_context_generics(&mut generics, krate, &fields, &decrypt_context()); - by_group_body(krate, groups, plaintext)? - } + Auto::ByField(groups) => by_group_body(krate, groups, plaintext)?, }; + let ctx = push_context_generics(&mut generics, &candidates); let (impl_generics, _, where_clause) = generics.split_for_impl(); let body = quote!(#body_check #destructure #open); Ok(impl_block( @@ -300,6 +291,7 @@ fn automatic(input: &DeriveInput, record: &Record) -> Result { &impl_generics, where_clause, plaintext, + &ctx, body, )) }) @@ -383,10 +375,8 @@ fn open_one(krate: &Path, fields: &[&Field], plaintext: &Type) -> TokenStream { let mut calls = fields.iter().map(|field| { let ty = &field.ty; let local = &field.local; - let context = match field.context() { - Some(literal) => quote!(#literal), - None => quote!(::core::clone::Clone::clone(__context)), - }; + let context = + own_context(field).unwrap_or_else(|| quote!(::core::clone::Clone::clone(__context))); quote! { <#ty as #krate::target::DecryptField<#plaintext, #krate::StackCipher<__K>, _>>::decrypt_field( #local, __cipher, #context, @@ -479,7 +469,7 @@ fn explicit(input: &DeriveInput, record: &Record) -> Result { generics.make_where_clause().predicates.push(parse_quote! { #ty: #krate::target::DecryptInto<__P, #krate::StackCipher<__K>, #context> }); - push_context_generics(&mut generics, krate, &[field], &decrypt_context()); + let ctx = push_context_generics(&mut generics, &[field]); let (impl_generics, _, where_clause) = generics.split_for_impl(); let body = whole_body(krate, field, &plaintext); return Ok(impl_block( @@ -489,6 +479,7 @@ fn explicit(input: &DeriveInput, record: &Record) -> Result { &impl_generics, where_clause, &plaintext, + &ctx, body, )); } @@ -499,19 +490,19 @@ fn explicit(input: &DeriveInput, record: &Record) -> Result { .map(|plaintext| { let mut generics = input.generics.clone(); generics.params.push(parse_quote!(__K)); - let body = match &mode { + let (body, ctx) = match &mode { Mode::Whole(field) => { let ty = &field.ty; let context = context_type(field); generics.make_where_clause().predicates.push(parse_quote! { #ty: #krate::target::DecryptInto<#plaintext, #krate::StackCipher<__K>, #context> }); - push_context_generics(&mut generics, krate, &[field], &decrypt_context()); - whole_body(krate, field, plaintext) + let ctx = push_context_generics(&mut generics, &[field]); + (whole_body(krate, field, plaintext), ctx) } Mode::ByField(fields) => { - push_context_generics(&mut generics, krate, fields, &decrypt_context()); - by_field_body(krate, fields, plaintext)? + let ctx = push_context_generics(&mut generics, fields); + (by_field_body(krate, fields, plaintext)?, ctx) } }; let (impl_generics, _, where_clause) = generics.split_for_impl(); @@ -522,6 +513,7 @@ fn explicit(input: &DeriveInput, record: &Record) -> Result { &impl_generics, where_clause, plaintext, + &ctx, body, )) }) @@ -609,14 +601,13 @@ fn by_field_body(krate: &Path, fields: &[&Field], plaintext: &Type) -> Result, _>>::decrypt_into( - self.#member, - __cipher, - #context, - ) - } + // from the struct literal, and the obligation checked against it + // — spanned at the field type, so a leaf handed `()` (no + // literal) is reported at the field that needs a `context`. + let call = quote_spanned! {ty.span()=> + <#ty as #krate::target::DecryptInto<_, #krate::StackCipher<__K>, _>>::decrypt_into + }; + quote!(#call(self.#member, __cipher, #context,)) }, quote!(#literal { #(#assign),* }), )) @@ -650,7 +641,7 @@ mod tests { where StackCipherText: ::stack_encrypt::target::DecryptField, __Ctx>, EqualityTerm: ::stack_encrypt::target::DecryptField, __Ctx>, - __Ctx: ::stack_encrypt::target::DecryptContext<'__c> + __Ctx: ::core::clone::Clone }); assert_contains(&expansion, quote!(let Self { c: __field_0, hm: __field_1, .. } = self;)); assert_contains(&expansion, quote! { @@ -725,12 +716,12 @@ mod tests { ); }); assert_contains(&expansion, quote!(__group_0.zip(__group_1).map(|(__group_0, __group_1)| User { age: __group_0, email: __group_1 }))); - // Every field has its own context: `__Ctx` is unbounded, and - // `User::decrypt_from(row, &cipher)` compiles. + // Every field has its own context: the impl is for `()` exactly, and + // `User::decrypt_from(row, &cipher)` is the one form that compiles. assert_contains(&expansion, quote! { - impl<__K, __Ctx> ::stack_encrypt::target::DecryptInto, __Ctx> for Row + impl<__K> ::stack_encrypt::target::DecryptInto, ()> for Row }); - assert_lacks(&expansion, quote!('__c)); + assert_lacks(&expansion, quote!(DecryptInto, __Ctx>)); } #[test] @@ -844,10 +835,10 @@ mod tests { }) .unwrap(); assert_contains(&expansion, quote! { - impl<'__c, __P, __K, __Ctx> ::stack_encrypt::target::DecryptInto<__P, ::stack_encrypt::StackCipher<__K>, __Ctx> for Wrapped + impl<__P, __K, __Ctx> ::stack_encrypt::target::DecryptInto<__P, ::stack_encrypt::StackCipher<__K>, __Ctx> for Wrapped where StackCipherText: ::stack_encrypt::target::DecryptInto<__P, ::stack_encrypt::StackCipher<__K>, __Ctx>, - __Ctx: ::stack_encrypt::target::DecryptContext<'__c> + __Ctx: ::core::clone::Clone }); assert_contains(&expansion, quote! { , _>>::decrypt_into( @@ -884,7 +875,7 @@ mod tests { }) .unwrap(); assert_contains(&expansion, quote! { - impl<'__c, __K, __Ctx> ::stack_encrypt::target::DecryptInto, __Ctx> for EncryptedAge + impl<__K, __Ctx> ::stack_encrypt::target::DecryptInto, __Ctx> for EncryptedAge where StackCipherText: ::stack_encrypt::target::DecryptInto, __Ctx> }); @@ -917,12 +908,14 @@ mod tests { self.age, __cipher, "users/age", ) }); - assert_contains(&expansion, quote!(self.email, __cipher, __context,)); - // `email` takes the caller's context into a leaf reached through a - // plaintext field type the derive cannot name: the demand is stated. + // `email` has no literal: a `from` field is handed `()`, never the + // caller's context, and the leaf reports itself at the field if it + // cannot take that. The row is then for `()` exactly. + assert_contains(&expansion, quote!(self.email, __cipher, (),)); assert_contains(&expansion, quote! { - __Ctx: ::stack_encrypt::target::DecryptContext<'__c> + ::stack_encrypt::target::SuppliedContext<'__c> + impl<__K> ::stack_encrypt::target::DecryptInto, ::stack_encrypt::StackCipher<__K>, ()> for EncryptedUser }); + assert_lacks(&expansion, quote!(SuppliedContext)); assert_contains(&expansion, quote!(.map(|(__field_0, __field_1)| User:: { age: __field_0, email: __field_1 }))); assert_lacks(&expansion, quote!(email_eq)); } diff --git a/packages/stack-encrypt-derive/src/encrypt.rs b/packages/stack-encrypt-derive/src/encrypt.rs index 56f04d3b7..ceffffcb1 100644 --- a/packages/stack-encrypt-derive/src/encrypt.rs +++ b/packages/stack-encrypt-derive/src/encrypt.rs @@ -1,11 +1,11 @@ //! Expansion of `#[derive(EncryptFrom)]`. -use proc_macro2::{Span, TokenStream}; +use proc_macro2::TokenStream; use quote::{quote, quote_spanned}; use syn::spanned::Spanned; use syn::{parse_quote, DeriveInput, Ident, Path, Result, Type}; -use crate::shape::{context_type, push_context_generics, zip_fields, Field, Kind, Record}; +use crate::shape::{context_type, push_context_generics, zip_fields, Kind, Record}; pub(crate) fn derive(input: DeriveInput) -> Result { let record = Record::parse(&input)?; @@ -24,7 +24,7 @@ pub(crate) fn derive(input: DeriveInput) -> Result { generics.params.push(parse_quote!(__S)); generics.params.push(parse_quote!(__K)); push_field_bounds(&mut generics, krate, &record, &source); - push_context_generics(&mut generics, krate, &derived(&record), &encrypt_context()); + let ctx = push_context_generics(&mut generics, &record.derived()); let (impl_generics, _, where_clause) = generics.split_for_impl(); let body = body(krate, &record, &source); let block = impl_block( @@ -34,6 +34,7 @@ pub(crate) fn derive(input: DeriveInput) -> Result { &impl_generics, where_clause, &source, + &ctx, body, ); return Ok(quote!(#block #decryptable)); @@ -46,7 +47,7 @@ pub(crate) fn derive(input: DeriveInput) -> Result { let mut generics = input.generics.clone(); generics.params.push(parse_quote!(__K)); push_field_bounds(&mut generics, krate, &record, source); - push_context_generics(&mut generics, krate, &derived(&record), &encrypt_context()); + let ctx = push_context_generics(&mut generics, &record.derived()); let (impl_generics, _, where_clause) = generics.split_for_impl(); let body = body(krate, &record, source); impl_block( @@ -56,24 +57,17 @@ pub(crate) fn derive(input: DeriveInput) -> Result { &impl_generics, where_clause, source, + &ctx, body, ) }); - let decryptable = decryptable_impl(&input, &record); Ok(quote!(#(#impls)* #decryptable)) } -fn derived(record: &Record) -> Vec<&Field> { - record.fields.iter().filter(|f| f.is_derived()).collect() -} - -fn encrypt_context() -> Ident { - Ident::new("EncryptContext", Span::call_site()) -} - -/// `impl EncryptFrom, __Ctx> for Record` around -/// `body`. +/// `impl EncryptFrom, Ctx> for Record` around +/// `body`; `ctx` is `__Ctx` or `()` ([`push_context_generics`]). +#[allow(clippy::too_many_arguments)] fn impl_block( krate: &Path, name: &Ident, @@ -81,17 +75,18 @@ fn impl_block( impl_generics: &syn::ImplGenerics<'_>, where_clause: Option<&syn::WhereClause>, source: &Type, + ctx: &Type, body: TokenStream, ) -> TokenStream { quote! { #[automatically_derived] - impl #impl_generics #krate::target::EncryptFrom<#source, #krate::StackCipher<__K>, __Ctx> + impl #impl_generics #krate::target::EncryptFrom<#source, #krate::StackCipher<__K>, #ctx> for #name #ty_generics #where_clause { fn encrypt_from<'__a>( __source: &'__a #source, __cipher: &'__a #krate::StackCipher<__K>, - __context: __Ctx, + __context: #ctx, ) -> #krate::target::Pending<'__a, Self, __K> where Self: '__a, @@ -118,16 +113,12 @@ fn decryptable_impl(input: &DeriveInput, record: &Record) -> TokenStream { let value = if record.fields.iter().any(|f| f.decrypt) { quote!(true) } else { - let terms = record - .fields - .iter() - .filter(|f| f.is_derived()) - .map(|field| { - let ty = &field.ty; - // Spanned at the field type: a type that is not `Decryptable` - // is reported there, not at the derive. - quote_spanned!(ty.span()=> || <#ty as #krate::target::Decryptable>::DECRYPTABLE) - }); + let terms = record.derived().into_iter().map(|field| { + let ty = &field.ty; + // Spanned at the field type: a type that is not `Decryptable` + // is reported there, not at the derive. + quote_spanned!(ty.span()=> || <#ty as #krate::target::Decryptable>::DECRYPTABLE) + }); quote!(false #(#terms)*) }; quote! { @@ -160,7 +151,7 @@ fn push_field_bounds(generics: &mut syn::Generics, krate: &Path, record: &Record /// The method body: every derived field's pending, zipped into one, mapped /// into `Self`. fn body(krate: &Path, record: &Record, source: &Type) -> TokenStream { - let derived: Vec<&Field> = record.fields.iter().filter(|f| f.is_derived()).collect(); + let derived = record.derived(); let assign = record.fields.iter().map(|field| { let member = &field.member; @@ -179,18 +170,17 @@ fn body(krate: &Path, record: &Record, source: &Type) -> TokenStream { |field, context| { let ty = &field.ty; // A `from` field's source type is not known here; it is inferred - // from the field expression, and the obligation checked there. + // from the field expression, and the obligation checked there — + // spanned at the field type, so a leaf handed `()` (no literal) + // is reported at the field that needs a `context`. let (source_expr, source_ty): (TokenStream, TokenStream) = match field.from() { Some(from) => (quote!(&__source.#from), quote!(_)), None => (quote!(__source), quote!(#source)), }; - quote! { - <#ty as #krate::target::EncryptFrom<#source_ty, #krate::StackCipher<__K>, _>>::encrypt_from( - #source_expr, - __cipher, - #context, - ) - } + let call = quote_spanned! {ty.span()=> + <#ty as #krate::target::EncryptFrom<#source_ty, #krate::StackCipher<__K>, _>>::encrypt_from + }; + quote!(#call(#source_expr, __cipher, #context,)) }, quote!(Self { #(#assign),* }), ) @@ -258,17 +248,18 @@ mod tests { hm: EqualityTerm, } }); - // Both fields take the caller's context, so the impl is bounded by - // what they do with it — and inherits their demand for a supplied - // one through the field bounds. + // Both fields take the caller's context: the impl is generic over + // it, and inherits the fields' demand for a supplied one through + // their bounds — `Clone` is all the body itself needs. assert_contains(&expansion, quote! { - impl<'__c, __S, __K, __Ctx> ::stack_encrypt::target::EncryptFrom<__S, ::stack_encrypt::StackCipher<__K>, __Ctx> + impl<__S, __K, __Ctx> ::stack_encrypt::target::EncryptFrom<__S, ::stack_encrypt::StackCipher<__K>, __Ctx> for EncryptedAge where StackCipherText: ::stack_encrypt::target::EncryptFrom<__S, ::stack_encrypt::StackCipher<__K>, __Ctx>, EqualityTerm: ::stack_encrypt::target::EncryptFrom<__S, ::stack_encrypt::StackCipher<__K>, __Ctx>, - __Ctx: ::stack_encrypt::target::EncryptContext<'__c> + __Ctx: ::core::clone::Clone }); + assert_lacks(&expansion, quote!(EncryptContext)); // The first field clones the record context, the last takes it. assert_contains(&expansion, quote!(__source, __cipher, ::core::clone::Clone::clone(&__context),)); assert_contains(&expansion, quote!(__source, __cipher, __context,)); @@ -287,10 +278,10 @@ mod tests { } }); assert_contains(&expansion, quote! { - impl<'__c, __K, __Ctx> ::stack_encrypt::target::EncryptFrom, __Ctx> for IntegerOrdOre + impl<__K, __Ctx> ::stack_encrypt::target::EncryptFrom, __Ctx> for IntegerOrdOre }); assert_contains(&expansion, quote! { - impl<'__c, __K, __Ctx> ::stack_encrypt::target::EncryptFrom, __Ctx> for IntegerOrdOre + impl<__K, __Ctx> ::stack_encrypt::target::EncryptFrom, __Ctx> for IntegerOrdOre }); assert_contains(&expansion, quote!(Self { c: __field_0, v: SchemaVersion::V3 })); assert_lacks(&expansion, quote!(__S)); @@ -313,13 +304,13 @@ mod tests { &__source.age, __cipher, "users/age", ) }); - // No field takes the record's context, so `__Ctx` is unbounded: the - // row accepts `()`, and `encrypt_into(&cipher)` compiles. + // No field takes the record's context, so the impl is for `()` + // exactly: `encrypt_into(&cipher)` compiles, and only that. assert_lacks(&expansion, quote!(&__context)); assert_contains(&expansion, quote! { - impl<__K, __Ctx> ::stack_encrypt::target::EncryptFrom, __Ctx> for EncryptedUser + impl<__K> ::stack_encrypt::target::EncryptFrom, ()> for EncryptedUser }); - assert_lacks(&expansion, quote!('__c)); + assert_lacks(&expansion, quote!(__Ctx)); // `from` fields carry no where clause: the source field's type is // unknown here, so the obligation is checked in the body instead. assert!( @@ -330,7 +321,7 @@ mod tests { #[test] #[rustfmt::skip] - fn a_from_field_taking_the_callers_context_demands_a_supplied_one() { + fn a_from_field_without_a_literal_is_handed_no_context() { let expansion = expand(parse_quote! { #[stash(plaintext = User)] struct EncryptedUser { @@ -338,12 +329,15 @@ mod tests { age: EncryptedAge, } }); - // The obligation is checked in the body against a source field type - // the derive cannot name, so the where clause states the demand. + // The caller's context never reaches a `from` field: the field gets + // `()`, and its type decides (in the body, against the plaintext + // field's type) whether that is acceptable. The row itself is then + // for `()` too. + assert_contains(&expansion, quote!(&__source.age, __cipher, (),)); assert_contains(&expansion, quote! { - where - __Ctx: ::stack_encrypt::target::EncryptContext<'__c> + ::stack_encrypt::target::SuppliedContext<'__c> + impl<__K> ::stack_encrypt::target::EncryptFrom, ()> for EncryptedUser }); + assert_lacks(&expansion, quote!(SuppliedContext)); } #[test] diff --git a/packages/stack-encrypt-derive/src/lib.rs b/packages/stack-encrypt-derive/src/lib.rs index 9f23e868f..b16526c5f 100644 --- a/packages/stack-encrypt-derive/src/lib.rs +++ b/packages/stack-encrypt-derive/src/lib.rs @@ -109,11 +109,12 @@ //! //! A `from` field's context is the *column's* identity, which is why it is a //! literal on the field rather than something composed from the row's -//! context. A row whose fields all have one takes no context from the caller -//! at all — its impl leaves the context parameter unbounded, which is what -//! makes the context-free `encrypt_into` / `decrypt_from` compile against -//! it — while a field without one takes the caller's, and pulls the row back -//! to `encrypt_into_with_context`. +//! context. A row takes no context from the caller at all — its impls are +//! for `()` exactly, which is what makes the context-free `encrypt_into` / +//! `decrypt_from` the forms that compile against it. A `from` field without +//! a literal is handed `()` too, and its type decides whether that will do: +//! a nested row accepts it; a leaf refuses it, at the field, until it is +//! given a `context`. //! //! # What the derive commits to //! @@ -173,7 +174,7 @@ pub fn derive_encrypt_from(input: TokenStream) -> TokenStream { .into() } -/// Derive `DecryptInto` for a record struct, one impl per +/// Derive `DecryptInto` for a record struct, one impl per /// `plaintext` type. See the [crate documentation](crate); the attributes it /// accepts are reproduced below. #[doc = include_str!("../docs/attributes.md")] diff --git a/packages/stack-encrypt-derive/src/shape.rs b/packages/stack-encrypt-derive/src/shape.rs index 2ba3eb3b5..60c5045d4 100644 --- a/packages/stack-encrypt-derive/src/shape.rs +++ b/packages/stack-encrypt-derive/src/shape.rs @@ -60,6 +60,19 @@ impl Field { Kind::Default(_) => None, } } + + /// Is this field derived under the context the caller passes for the + /// record? Only a field derived from the whole plaintext with no literal + /// of its own is; see [`push_context_generics`]. + pub(crate) fn takes_callers_context(&self) -> bool { + matches!( + &self.kind, + Kind::Derived { + context: None, + from: None + } + ) + } } /// The record a derive input describes. @@ -73,6 +86,11 @@ pub(crate) struct Record { } impl Record { + /// The fields that are derived from the plaintext, in declaration order. + pub(crate) fn derived(&self) -> Vec<&Field> { + self.fields.iter().filter(|f| f.is_derived()).collect() + } + pub(crate) fn parse(input: &DeriveInput) -> Result { let attrs = ContainerAttrs::parse(&input.attrs)?; @@ -120,46 +138,58 @@ impl Record { } } -/// The context type a field is derived or opened under: its literal's, or +/// The context type a field is derived or opened under, as it appears in a +/// where clause: its literal's, `()` for a `from` field with no literal, or /// the impl's `__Ctx` when it takes the caller's. pub(crate) fn context_type(field: &Field) -> Type { - match field.context() { - Some(_) => parse_quote!(&'static str), + match own_context(field) { + Some(_) if field.context().is_some() => parse_quote!(&'static str), + Some(_) => parse_quote!(()), None => parse_quote!(__Ctx), } } -/// Adds the impl's context parameter `__Ctx`, bounded by what `fields` do -/// with the caller's context. +/// The context a field is derived or opened under when it is not the +/// caller's: its literal, or `()` for a `from` field with no literal. `None` +/// for a field that takes the caller's. /// -/// A field with a literal context never sees it, so a record whose fields -/// all have one leaves `__Ctx` unbounded and accepts `()`: that is what makes -/// `row.encrypt_into(&cipher)` compile. A field that takes it (no literal) -/// needs it usable — `EncryptContext` / `DecryptContext`, with the context's -/// own lifetime `'__c` as an impl parameter — and a `from` field that takes -/// it needs it *supplied*: its obligation is checked in the body against a -/// source field type the derive cannot name in a where clause, so the where -/// clause states the leaf's demand instead. -pub(crate) fn push_context_generics( - generics: &mut Generics, - krate: &Path, - fields: &[&Field], - bound: &Ident, -) { - generics.params.push(parse_quote!(__Ctx)); - let passthrough: Vec<&&Field> = fields.iter().filter(|f| f.context().is_none()).collect(); - if passthrough.is_empty() { - return; +/// A `from` field reaches into one field of the plaintext, and its type +/// says what that field needs: a leaf refuses `()` (the derive cannot name +/// the plaintext field's type in a where clause, so the obligation is +/// checked in the body and reported at the field type), and a nested row +/// carrying its own contexts accepts nothing else. Handing such a field the +/// caller's context instead would encrypt every column of the row under one +/// context, which is the cross-column transplant the per-field contexts +/// exist to prevent. +pub(crate) fn own_context(field: &Field) -> Option { + match field.context() { + Some(literal) => Some(quote!(#literal)), + None if field.from().is_some() => Some(quote!(())), + None => None, } - generics.params.insert(0, parse_quote!('__c)); - let predicates = &mut generics.make_where_clause().predicates; - if passthrough.iter().any(|f| f.from().is_some()) { - predicates.push(parse_quote! { - __Ctx: #krate::target::#bound<'__c> + #krate::target::SuppliedContext<'__c> - }); - } else { - predicates.push(parse_quote!(__Ctx: #krate::target::#bound<'__c>)); +} + +/// Adds the impl's context parameter, if `fields` give it a use, and returns +/// the type the impl is for. +/// +/// A field with a context of its own ([`own_context`]) never sees the +/// caller's. A record whose fields all have one — every row does — is +/// therefore encrypted with no context at all, and its impl is for `()` +/// exactly: `row.encrypt_into(&cipher)` compiles and +/// `encrypt_into_with_context` does not, since the context would go nowhere. +/// Otherwise the impl is generic over `__Ctx`, cloned to each field that +/// takes it; what the context must *be* — usable, supplied — comes from the +/// field bounds, not from here. +pub(crate) fn push_context_generics(generics: &mut Generics, fields: &[&Field]) -> Type { + if !fields.iter().any(|f| f.takes_callers_context()) { + return parse_quote!(()); } + generics.params.push(parse_quote!(__Ctx)); + generics + .make_where_clause() + .predicates + .push(parse_quote!(__Ctx: ::core::clone::Clone)); + parse_quote!(__Ctx) } /// The pendings of `fields`, zipped into one and mapped into `build` (a @@ -167,20 +197,20 @@ pub(crate) fn push_context_generics( /// settles as one batched call. /// /// `call(field, context)` renders one field's pending under `context`. The -/// record's context (`__context`) goes to every field without a literal of +/// record's context (`__context`) goes to every field without a context of /// its own; the last such field takes it by move, the rest clone it. pub(crate) fn zip_fields( fields: &[&Field], mut call: impl FnMut(&Field, TokenStream) -> TokenStream, build: TokenStream, ) -> TokenStream { - let mut remaining = fields.iter().filter(|f| f.context().is_none()).count(); + let mut remaining = fields.iter().filter(|f| f.takes_callers_context()).count(); let mut chain = TokenStream::new(); let mut pattern = TokenStream::new(); for (index, field) in fields.iter().enumerate() { - let context = match field.context() { - Some(literal) => quote!(#literal), + let context = match own_context(field) { + Some(own) => own, None => { remaining -= 1; if remaining == 0 { diff --git a/packages/stack-encrypt/examples/encrypted_record.rs b/packages/stack-encrypt/examples/encrypted_record.rs index 2db178384..c2825913b 100644 --- a/packages/stack-encrypt/examples/encrypted_record.rs +++ b/packages/stack-encrypt/examples/encrypted_record.rs @@ -27,7 +27,7 @@ use stack_encrypt::sem::{EqualityTerm, OreTerm}; use stack_encrypt::target::{ - DecryptContext, DecryptInto, EncryptContext, EncryptFrom, EncryptInto, Pending, SuppliedContext, + DecryptInto, EncryptContext, EncryptFrom, EncryptInto, Pending, SuppliedContext, }; use stack_encrypt::{StackCipher, StackCipherText}; @@ -76,7 +76,7 @@ where // one-way), so it delegates to the ciphertext's own implementation. impl<'c, K, Ctx> DecryptInto, Ctx> for EncryptedInt where - Ctx: DecryptContext<'c> + SuppliedContext<'c>, + Ctx: SuppliedContext<'c>, { fn decrypt_into<'a>(self, cipher: &'a StackCipher, context: Ctx) -> Pending<'a, u32, K> where diff --git a/packages/stack-encrypt/src/cipher.rs b/packages/stack-encrypt/src/cipher.rs index 75bada079..7e36555e1 100644 --- a/packages/stack-encrypt/src/cipher.rs +++ b/packages/stack-encrypt/src/cipher.rs @@ -609,7 +609,7 @@ impl PendingStackCipherText { /// /// Settles through the target layer's request carrier /// ([`seal_pending`](crate::target)), so this and - /// `encrypt_into::` share one definition of how a tree + /// `encrypt_into_with_context` into a `StackCipherText` share one definition of how a tree /// is sealed and one path to ZeroKMS. pub async fn seal( self, diff --git a/packages/stack-encrypt/src/sem/mod.rs b/packages/stack-encrypt/src/sem/mod.rs index d705feda5..93d3c3ec1 100644 --- a/packages/stack-encrypt/src/sem/mod.rs +++ b/packages/stack-encrypt/src/sem/mod.rs @@ -760,12 +760,12 @@ where /// these need no data-key traffic at all — a query builder holding a cipher /// never touches ZeroKMS to build a probe. Each is byte-identical to the /// target-directed path for the same descriptor, so a term generated here -/// compares against one generated by `encrypt_into`. +/// compares against one generated by `encrypt_into_with_context`. impl StackCipher { /// Generate an equality (exact-match) term for `value` under the field /// `descriptor`. Deterministic: the same value + descriptor always yields /// the same term, at write time and at query time. Byte-identical to - /// `value.encrypt_into::(&cipher, descriptor)`. + /// `value.encrypt_into_with_context(&cipher, descriptor)` into an `EqualityTerm`. pub async fn equality_term( &self, value: T, diff --git a/packages/stack-encrypt/src/target/mod.rs b/packages/stack-encrypt/src/target/mod.rs index a4d69a3f9..27f49654c 100644 --- a/packages/stack-encrypt/src/target/mod.rs +++ b/packages/stack-encrypt/src/target/mod.rs @@ -25,7 +25,7 @@ //! implementation can say which contexts it accepts: the leaves accept //! only a [`SuppliedContext`], a record passes the obligation through to //! its fields, and a row whose fields carry their own contexts accepts -//! anything — `()` included. Leaf +//! `()` alone. Leaf //! implementations exist for [`StackCipherText`] (the AEAD ciphertext, via //! vitaminc's [`Encrypt`]) and for the SEM term types in [`sem`] //! ([`EqualityTerm`], [`MatchTerm`], [`OreTerm`], [`OpeTerm`]). Composite @@ -123,8 +123,8 @@ //! //! ``` //! use stack_encrypt::target::{ -//! DecryptField, DecryptTarget, Decryptable, EncryptContext, EncryptFrom, Pending, -//! SuppliedContext, +//! is_degenerate_prf_context, DecryptField, DecryptTarget, Decryptable, EncryptContext, +//! EncryptFrom, Pending, SuppliedContext, //! }; //! use stack_encrypt::{Error, StackCipher}; //! use vitaminc_prf::{IntoPrfContext, PrfContext, PrfValue, PrfVisitor, PrfVisitorError}; @@ -160,11 +160,12 @@ //! Self: 'a, //! { //! // The type rules out an absent context; an empty one is still a -//! // runtime check, as for the built-in leaves. Then domain-separate -//! // under your own label so your terms can never collide with -//! // another scheme's under the same context. +//! // runtime check, the same one the built-in leaves make (the +//! // encoding is framed, so `as_bytes().is_empty()` would never be +//! // true). Then domain-separate under your own label so your terms +//! // can never collide with another scheme's under the same context. //! let context = context.into_prf_context().into_owned(); -//! if context.as_bytes().is_empty() { +//! if is_degenerate_prf_context(context.as_bytes()) { //! return Pending::ready(cipher, Err(Error::EmptyContext)); //! } //! let context = PrfContext::pae(&[b"my-crate/my-term/v1".as_slice(), context.as_bytes()]); @@ -352,24 +353,30 @@ impl<'a, T> DecryptContext<'a> for T where T: IntoAad<'a> + Clone {} /// only for a `SuppliedContext`, so `value.encrypt_into(&cipher)` — which /// passes `()` — does not compile against them, nor against a record that /// hands its context on to one of them. A row whose fields each carry a -/// context of their own never passes the caller's anywhere, accepts `()`, -/// and is encrypted with no context at all. +/// context of their own never passes the caller's anywhere: it implements +/// the traits for `()` alone, and is encrypted with no context at all. /// /// Implemented for every context type vitaminc provides except `()`: `&str`, /// `String`, byte strings, [`Aad`], `u64`, and `Option`s -/// and pairs of those. Implement it for a context type of your own alongside -/// its `IntoAad` / `IntoPrfContext`. +/// and pairs of those. A context type of your own opts in with an empty +/// `impl SuppliedContext<'_> for MyContext {}` alongside its `IntoAad` / +/// `IntoPrfContext`; without it the leaves refuse the type. /// /// The marker is about the *type*: `""` is a `&str` and therefore supplied. /// Whether what was supplied is non-empty stays a runtime check at the leaf /// ([`Error::EmptyContext`]) until vitaminc carries non-emptiness in the /// type itself (cipherstash/vitaminc#291), at which point the bound tightens /// to that. +/// +/// It implies [`DecryptContext`] (the same `IntoAad + Clone`), so a decrypt +/// leaf bounds its context by this marker alone. #[diagnostic::on_unimplemented( message = "`{Self}` is not a context the caller supplied", label = "this leaf needs a context", - note = "`()` is what `encrypt_into` / `decrypt_from` pass: an output type that reaches a \ - leaf needs `encrypt_into_with_context` / `decrypt_from_with_context` instead" + note = "`()` is what `encrypt_into` / `decrypt_from` pass, and what a derived row hands a \ + `from` field with no `context = \"..\"` of its own: an output that reaches a leaf \ + needs `encrypt_into_with_context` / `decrypt_from_with_context`, or the literal", + note = "a context type of your own opts in with an empty `impl SuppliedContext<'_> for MyContext {{}}`" )] pub trait SuppliedContext<'a>: IntoAad<'a> + Clone {} @@ -463,7 +470,7 @@ mod prf_framing { /// (The tags vitaminc applies *inside* the cipher — `Aad::for_leaf`, /// `for_map_entry`, the markers — are derived after this check runs, from /// the caller-visible AAD this sees.) -pub(crate) fn is_degenerate_aad(bytes: &[u8]) -> bool { +pub fn is_degenerate_aad(bytes: &[u8]) -> bool { if bytes.is_empty() { return true; } @@ -487,7 +494,7 @@ pub(crate) fn is_degenerate_aad(bytes: &[u8]) -> bool { /// A node that is not framing (a tuple, or a `PrfContext` the caller built by /// hand) is degenerate only if every one of its pieces is. Bytes that are not /// a well-formed PAE are caller content. -pub(crate) fn is_degenerate_prf_context(bytes: &[u8]) -> bool { +pub fn is_degenerate_prf_context(bytes: &[u8]) -> bool { if bytes.is_empty() { return true; } @@ -604,11 +611,12 @@ impl DecryptTarget for StackCipher { /// # The context parameter /// /// `Ctx` is a parameter of the trait, not of the method, so that each -/// implementation can bound it: a leaf demands a [`SuppliedContext`] (it has -/// nothing else to authenticate under), a record passes whatever it is given -/// on to its fields and inherits their demands through its where clause, and -/// a row whose fields carry their own contexts leaves `Ctx` unbounded. The -/// call site then gets one of two answers from the compiler: +/// implementation can say which contexts it accepts: a leaf demands a +/// [`SuppliedContext`] (it has nothing else to authenticate under), a record +/// passes whatever it is given on to its fields and inherits their demands +/// through its where clause, and a row whose fields carry their own contexts +/// is implemented for `()` alone — a context handed to it would go nowhere. +/// The call site then gets one of two answers from the compiler: /// [`encrypt_into(&cipher)`](EncryptInto::encrypt_into) resolves against /// `EncryptFrom` and exists exactly for the outputs that need no /// context; everything else takes @@ -621,8 +629,9 @@ impl DecryptTarget for StackCipher { #[diagnostic::on_unimplemented( message = "`{Self}` is not an encrypted form of `{S}` under a `{Ctx}` context", label = "not `EncryptFrom<{S}, _, {Ctx}>`", - note = "if `{Ctx}` is `()`, no context was supplied: an output that reaches a leaf needs \ - one — use `encrypt_into_with_context(&cipher, context)`" + note = "an output that reaches a leaf exists only under a supplied context \ + (`encrypt_into_with_context`); one whose fields carry their own, only under `()` \ + (`encrypt_into`)" )] pub trait EncryptFrom: Sized { /// Encrypt `source` into `Self` under `context`, returning the cipher's @@ -657,9 +666,9 @@ pub trait EncryptFrom: Sized { #[diagnostic::on_unimplemented( message = "`{Self}` does not decrypt to `{P}` under a `{Ctx}` context", label = "not `DecryptInto<{P}, _, {Ctx}>`", - note = "if `{Ctx}` is `()`, no context was supplied: a value that reaches a leaf needs the \ - one it was encrypted under — use `decrypt_into(&cipher, context)` or \ - `decrypt_from_with_context`" + note = "a value that reaches a leaf decrypts only under the context it was encrypted under \ + (`decrypt_into(&cipher, context)` / `decrypt_from_with_context`); one whose fields \ + carry their own, only under `()` (`decrypt_from`)" )] pub trait DecryptInto: Sized { /// Decrypt `self` into `P`, authenticating against `context` — which @@ -756,44 +765,45 @@ impl EncryptInto for S { /// (The implemented trait, [`DecryptInto`], always takes a context: /// `encrypted.decrypt_into(&cipher, "users/age")` is the method-call form /// for a value that needs one.) -pub trait DecryptFrom: Sized { +pub trait DecryptFrom: Sized { /// Decrypt `source` — an encrypted type that needs no context from the /// caller — into `Self`. See [`DecryptInto`]. - fn decrypt_from<'a>(source: S, cipher: &'a C) -> C::Output<'a, Self> + fn decrypt_from<'a, S, C>(source: S, cipher: &'a C) -> C::Output<'a, Self> where + C: DecryptTarget, S: DecryptInto + 'a, Self: 'a; /// Decrypt `source` into `Self`, authenticating against `context`. See /// [`DecryptInto`]. - fn decrypt_from_with_context<'a, Ctx>( + fn decrypt_from_with_context<'a, S, C, Ctx>( source: S, cipher: &'a C, context: Ctx, ) -> C::Output<'a, Self> where + C: DecryptTarget, S: DecryptInto + 'a, Self: 'a; } -impl DecryptFrom for P -where - C: DecryptTarget, -{ - fn decrypt_from<'a>(source: S, cipher: &'a C) -> C::Output<'a, Self> +impl

DecryptFrom for P { + fn decrypt_from<'a, S, C>(source: S, cipher: &'a C) -> C::Output<'a, Self> where + C: DecryptTarget, S: DecryptInto + 'a, Self: 'a, { source.decrypt_into(cipher, ()) } - fn decrypt_from_with_context<'a, Ctx>( + fn decrypt_from_with_context<'a, S, C, Ctx>( source: S, cipher: &'a C, context: Ctx, ) -> C::Output<'a, Self> where + C: DecryptTarget, S: DecryptInto + 'a, Self: 'a, { @@ -1029,7 +1039,7 @@ fn decipher_from_responses( impl<'c, T, K, Ctx> DecryptInto, Ctx> for StackCipherText where T: Decrypt<'static> + 'static, - Ctx: DecryptContext<'c> + SuppliedContext<'c>, + Ctx: SuppliedContext<'c>, { fn decrypt_into<'a>(self, cipher: &'a StackCipher, context: Ctx) -> Pending<'a, T, K> where diff --git a/packages/stack-encrypt/tests/derive.rs b/packages/stack-encrypt/tests/derive.rs index 2dd6f20d2..b8677bb57 100644 --- a/packages/stack-encrypt/tests/derive.rs +++ b/packages/stack-encrypt/tests/derive.rs @@ -224,7 +224,10 @@ async fn explicit_mode_supports_opaque_fields_in_the_paired_derive() { let cipher = stack_cipher().await; let generator = stack_cipher().await; - let record: WithOpaque = 5u32.encrypt_into_with_context(&cipher, "opaque").await.unwrap(); + let record: WithOpaque = 5u32 + .encrypt_into_with_context(&cipher, "opaque") + .await + .unwrap(); let hm: EqualityTerm = 5u32 .encrypt_into_with_context(&generator, "opaque") .await @@ -434,6 +437,49 @@ async fn a_row_field_opened_under_the_wrong_context_fails() { assert!(matches!(transplanted, Err(Error::Aead))); } +/// A row inside a row. The inner row carries its own contexts, so the outer +/// field needs no `context` of its own: a `from` field with none is handed +/// `()`, which is exactly what a row accepts — and the outer row stays +/// context-free too. +#[derive(Debug, Clone, PartialEq, Eq)] +struct Account { + user: User, + plan: String, +} + +#[derive(EncryptFrom, DecryptInto)] +#[stash(plaintext = Account)] +struct EncryptedAccount { + #[stash(from = user)] + user: EncryptedUser, + #[stash(from = plan, context = "accounts/plan")] + plan: StackCipherText, +} + +#[tokio::test] +async fn a_row_nests_in_a_row_without_a_context() { + let (cipher, generates, retrieves) = counting_cipher().await; + let generator = stack_cipher().await; + + let account = Account { + user: user(), + plan: "pro".to_string(), + }; + let row: EncryptedAccount = account.encrypt_into(&cipher).await.unwrap(); + assert_eq!(generates.load(AtomicOrdering::SeqCst), 1); + + // The inner row's fields are still under their own literals. + let age_hm: EqualityTerm = 42u32 + .encrypt_into_with_context(&generator, "users/age") + .await + .unwrap(); + assert_eq!(row.user.age.hm, age_hm); + + let recovered = Account::decrypt_from(row, &cipher).await.unwrap(); + assert_eq!(recovered, account); + assert_eq!(retrieves.load(AtomicOrdering::SeqCst), 1); +} + /// A tuple-struct plaintext is reached by index: `from = 0`. #[derive(Debug, Clone, PartialEq, Eq)] struct Reading(u32, String); diff --git a/packages/stack-encrypt/tests/target.rs b/packages/stack-encrypt/tests/target.rs index 97c17b5a1..fe3012a5a 100644 --- a/packages/stack-encrypt/tests/target.rs +++ b/packages/stack-encrypt/tests/target.rs @@ -9,8 +9,8 @@ use std::sync::atomic::Ordering as AtomicOrdering; use stack_encrypt::sem::{EqualityTerm, MatchConfig, MatchOptions, MatchTerm, OreTerm}; use stack_encrypt::target::{ - DecryptContext, DecryptInto, EncryptContext, EncryptFrom, EncryptInto, Pending, Request, - SuppliedContext, + is_degenerate_prf_context, DecryptInto, EncryptContext, EncryptFrom, EncryptInto, Pending, + Request, SuppliedContext, }; use stack_encrypt::{Error, StackCipher, StackCipherText}; use stack_kms::{FakeDataKeySource, IdentifiedBy, IndexKeySource}; @@ -345,7 +345,7 @@ where /// participates — terms are one-way. impl<'c, K, Ctx> DecryptInto, Ctx> for EncryptedAge where - Ctx: DecryptContext<'c> + SuppliedContext<'c>, + Ctx: SuppliedContext<'c>, { fn decrypt_into<'a>(self, cipher: &'a StackCipher, context: Ctx) -> Pending<'a, u32, K> where @@ -456,12 +456,17 @@ where where Self: 'a, { - // Own domain label: can never collide with a built-in term under the + // The same non-emptiness check the built-in leaves make, then an own + // domain label: can never collide with a built-in term under the // same context. + let context = context.into_prf_context().into_owned(); + if is_degenerate_prf_context(context.as_bytes()) { + return Pending::ready(cipher, Err(Error::EmptyContext)); + } let context = PrfContext::pae(&[ b"example/prefix-term/v1", &(N as u64).to_le_bytes(), - context.into_prf_context().as_bytes(), + context.as_bytes(), ]); let prefix: String = source.as_ref().chars().take(N).collect(); let term = prefix diff --git a/packages/stack-encrypt/tests/ui/from_leaf_without_context.rs b/packages/stack-encrypt/tests/ui/from_leaf_without_context.rs new file mode 100644 index 000000000..7d5cb6018 --- /dev/null +++ b/packages/stack-encrypt/tests/ui/from_leaf_without_context.rs @@ -0,0 +1,17 @@ +//! A `from` field is never handed the caller's context: it is derived under +//! its own `context`, or under `()` if it has none. A leaf refuses `()`, and +//! says so at the field — the fix is a `context = ".."` on it. +use stack_encrypt::{DecryptInto, EncryptFrom, StackCipherText}; + +struct User { + email: String, +} + +#[derive(EncryptFrom, DecryptInto)] +#[stash(plaintext = User)] +struct EncryptedUser { + #[stash(from = email)] + email: StackCipherText, +} + +fn main() {} diff --git a/packages/stack-encrypt/tests/ui/from_leaf_without_context.stderr b/packages/stack-encrypt/tests/ui/from_leaf_without_context.stderr new file mode 100644 index 000000000..0598b6c5f --- /dev/null +++ b/packages/stack-encrypt/tests/ui/from_leaf_without_context.stderr @@ -0,0 +1,26 @@ +error[E0277]: `()` is not a context the caller supplied + --> tests/ui/from_leaf_without_context.rs:14:12 + | +14 | email: StackCipherText, + | ^^^^^^^^^^^^^^^ this leaf needs a context + | + = note: `()` is what `encrypt_into` / `decrypt_from` pass, and what a derived row hands a `from` field with no `context = ".."` of its own: an output that reaches a leaf needs `encrypt_into_with_context` / `decrypt_from_with_context`, or the literal + = note: a context type of your own opts in with an empty `impl SuppliedContext<'_> for MyContext {}` + = help: the trait `SuppliedContext<'_>` is not implemented for `()` + but it is implemented for `(_, _)` + = help: for that trait implementation, expected `(_, _)`, found `()` + = note: required for `CipherText>` to implement `EncryptFrom, ()>` + +error[E0277]: `()` is not a context the caller supplied + --> tests/ui/from_leaf_without_context.rs:14:12 + | +14 | email: StackCipherText, + | ^^^^^^^^^^^^^^^ this leaf needs a context + | + = note: `()` is what `encrypt_into` / `decrypt_from` pass, and what a derived row hands a `from` field with no `context = ".."` of its own: an output that reaches a leaf needs `encrypt_into_with_context` / `decrypt_from_with_context`, or the literal + = note: a context type of your own opts in with an empty `impl SuppliedContext<'_> for MyContext {}` + = help: the trait `SuppliedContext<'_>` is not implemented for `()` + but it is implemented for `(_, _)` + = help: for that trait implementation, expected `(_, _)`, found `()` + = note: required for `CipherText>` to implement `DecryptInto<_, StackCipher<__K>, ()>` + = note: required for `CipherText>` to implement `DecryptField<_, StackCipher<__K>, ()>` diff --git a/packages/stack-encrypt/tests/ui/leaf_without_context.stderr b/packages/stack-encrypt/tests/ui/leaf_without_context.stderr index 68662b6c2..8da05751d 100644 --- a/packages/stack-encrypt/tests/ui/leaf_without_context.stderr +++ b/packages/stack-encrypt/tests/ui/leaf_without_context.stderr @@ -4,7 +4,8 @@ error[E0277]: `()` is not a context the caller supplied 17 | let _term: EqualityTerm = "alice".encrypt_into(cipher).await.unwrap(); | ^^^^^^^^^^^^ this leaf needs a context | - = note: `()` is what `encrypt_into` / `decrypt_from` pass: an output type that reaches a leaf needs `encrypt_into_with_context` / `decrypt_from_with_context` instead + = note: `()` is what `encrypt_into` / `decrypt_from` pass, and what a derived row hands a `from` field with no `context = ".."` of its own: an output that reaches a leaf needs `encrypt_into_with_context` / `decrypt_from_with_context`, or the literal + = note: a context type of your own opts in with an empty `impl SuppliedContext<'_> for MyContext {}` = help: the trait `SuppliedContext<'_>` is not implemented for `()` but it is implemented for `(_, _)` = help: for that trait implementation, expected `(_, _)`, found `()` @@ -24,7 +25,8 @@ error[E0277]: `()` is not a context the caller supplied 18 | let _record: EncryptedAge = 42u32.encrypt_into(cipher).await.unwrap(); | ^^^^^^^^^^^^ this leaf needs a context | - = note: `()` is what `encrypt_into` / `decrypt_from` pass: an output type that reaches a leaf needs `encrypt_into_with_context` / `decrypt_from_with_context` instead + = note: `()` is what `encrypt_into` / `decrypt_from` pass, and what a derived row hands a `from` field with no `context = ".."` of its own: an output that reaches a leaf needs `encrypt_into_with_context` / `decrypt_from_with_context`, or the literal + = note: a context type of your own opts in with an empty `impl SuppliedContext<'_> for MyContext {}` = help: the trait `SuppliedContext<'_>` is not implemented for `()` but it is implemented for `(_, _)` = help: for that trait implementation, expected `(_, _)`, found `()` @@ -48,7 +50,8 @@ error[E0277]: `()` is not a context the caller supplied | | | this leaf needs a context | - = note: `()` is what `encrypt_into` / `decrypt_from` pass: an output type that reaches a leaf needs `encrypt_into_with_context` / `decrypt_from_with_context` instead + = note: `()` is what `encrypt_into` / `decrypt_from` pass, and what a derived row hands a `from` field with no `context = ".."` of its own: an output that reaches a leaf needs `encrypt_into_with_context` / `decrypt_from_with_context`, or the literal + = note: a context type of your own opts in with an empty `impl SuppliedContext<'_> for MyContext {}` = help: the trait `SuppliedContext<'_>` is not implemented for `()` but it is implemented for `(_, _)` = help: for that trait implementation, expected `(_, _)`, found `()` @@ -72,7 +75,8 @@ error[E0277]: `()` is not a context the caller supplied | | | required by a bound introduced by this call | - = note: `()` is what `encrypt_into` / `decrypt_from` pass: an output type that reaches a leaf needs `encrypt_into_with_context` / `decrypt_from_with_context` instead + = note: `()` is what `encrypt_into` / `decrypt_from` pass, and what a derived row hands a `from` field with no `context = ".."` of its own: an output that reaches a leaf needs `encrypt_into_with_context` / `decrypt_from_with_context`, or the literal + = note: a context type of your own opts in with an empty `impl SuppliedContext<'_> for MyContext {}` = help: the trait `SuppliedContext<'_>` is not implemented for `()` but it is implemented for `(_, _)` = help: for that trait implementation, expected `(_, _)`, found `()` @@ -83,8 +87,8 @@ error[E0277]: `()` is not a context the caller supplied note: required by a bound in `decrypt_from` --> src/target/mod.rs | - | fn decrypt_from<'a>(source: S, cipher: &'a C) -> C::Output<'a, Self> + | fn decrypt_from<'a, S, C>(source: S, cipher: &'a C) -> C::Output<'a, Self> | ------------ required by a bound in this associated function - | where +... | S: DecryptInto + 'a, | ^^^^^^^^^^^^^^^^^^^^^^^^ required by this bound in `DecryptFrom::decrypt_from` diff --git a/packages/stack-encrypt/tests/ui/row_with_context.rs b/packages/stack-encrypt/tests/ui/row_with_context.rs new file mode 100644 index 000000000..80727be8f --- /dev/null +++ b/packages/stack-encrypt/tests/ui/row_with_context.rs @@ -0,0 +1,32 @@ +//! A row whose fields all carry their own context is implemented for `()` +//! alone: the `_with_context` forms do not compile against it, since the +//! context would go nowhere. +use stack_encrypt::target::{DecryptFrom, EncryptInto}; +use stack_encrypt::{DecryptInto, EncryptFrom, StackCipher, StackCipherText}; +use stack_kms::FakeDataKeySource; + +struct User { + email: String, +} + +#[derive(EncryptFrom, DecryptInto)] +#[stash(plaintext = User)] +struct EncryptedUser { + #[stash(from = email, context = "users/email")] + email: StackCipherText, +} + +async fn encrypt(cipher: &StackCipher, user: User) { + let _row: EncryptedUser = user + .encrypt_into_with_context(cipher, "tenant/acme") + .await + .unwrap(); +} + +async fn decrypt(cipher: &StackCipher, row: EncryptedUser) { + let _user = User::decrypt_from_with_context(row, cipher, "tenant/acme") + .await + .unwrap(); +} + +fn main() {} diff --git a/packages/stack-encrypt/tests/ui/row_with_context.stderr b/packages/stack-encrypt/tests/ui/row_with_context.stderr new file mode 100644 index 000000000..0d28514af --- /dev/null +++ b/packages/stack-encrypt/tests/ui/row_with_context.stderr @@ -0,0 +1,32 @@ +error[E0277]: `EncryptedUser` is not an encrypted form of `User` under a `&str` context + --> tests/ui/row_with_context.rs:21:10 + | +21 | .encrypt_into_with_context(cipher, "tenant/acme") + | ^^^^^^^^^^^^^^^^^^^^^^^^^ not `EncryptFrom` + | + = note: an output that reaches a leaf exists only under a supplied context (`encrypt_into_with_context`); one whose fields carry their own, only under `()` (`encrypt_into`) + = help: the trait `EncryptFrom, &str>` is not implemented for `EncryptedUser` + but trait `EncryptFrom, ()>` is implemented for it + = help: for that trait implementation, expected `()`, found `&str` +note: required by a bound in `encrypt_into_with_context` + --> src/target/mod.rs + | + | fn encrypt_into_with_context<'a, T, C, Ctx>( + | ------------------------- required by a bound in this associated function +... + | T: EncryptFrom + 'a, + | ^^^^^^^^^^^^^^^^^^^^^^^^^ required by this bound in `EncryptInto::encrypt_into_with_context` + +error[E0308]: mismatched types + --> tests/ui/row_with_context.rs:27:62 + | +27 | let _user = User::decrypt_from_with_context(row, cipher, "tenant/acme") + | ------------------------------- ^^^^^^^^^^^^^ expected `()`, found `&str` + | | + | arguments to this function are incorrect + | +note: associated function defined here + --> src/target/mod.rs + | + | fn decrypt_from_with_context<'a, S, C, Ctx>( + | ^^^^^^^^^^^^^^^^^^^^^^^^^ From 3d7a5b3dbc9e01123399b8b14467e84ccf802c03 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Mon, 31 Aug 2026 18:48:06 +1000 Subject: [PATCH 466/686] fix(stack-encrypt): restore the decrypt-context bound; gate the _with_context sugar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review fixes on the context-parameter change: - A derived `DecryptInto` generic over the caller's context bounds it by `DecryptContext` again. A term field's `DecryptField` accepts any context (it opens nothing), so field bounds alone let a record whose ciphertext field carries a literal accept — and silently discard — any `Clone` value as its decrypt context. Pinned by an expansion test and a `compile_fail` doctest on `DecryptContext`. - `encrypt_into_with_context` / `decrypt_from_with_context` bound `Ctx: SuppliedContext`, so `()` is refused on the sugar and misusing a row now gets the guided E0277 on the decrypt side too, not a bare E0308. - `SuppliedContext: DecryptContext` is declared, not hand-mirrored, so the documented implication holds by construction; the transitional `is_degenerate_*` predicates now say in rustdoc that they are deleted when vitaminc#291 lands. - The empty-`context` derive error no longer advises a `from` field to drop the attribute — a dead end, since a `from` field is never handed the record's context — and the check runs after parsing so attribute order cannot change the advice. - Derive internals: one `FieldContext` classification replaces the four parallel projections of literal/unit/caller; the impl scaffolding is shared between the two derives; encrypt-side field bounds are spanned at the field type as decrypt's already were; the stray raw derived- field filter uses `Record::derived`, computed once per expansion. - The hand-written composite examples inherit the leaves' context policy through per-field bounds — the clauses the derive emits — instead of restating it, and RFC 0002 no longer claims a row "leaves the context unbounded" where the implementation is `()` alone. Claude-Session: https://claude.ai/code/session_01P5YHK3w6Kj9ajTnmkaXCHW --- ...nc-shape-for-target-directed-encryption.md | 3 +- docs/target-directed-encryption.md | 10 +- packages/stack-encrypt-derive/src/attrs.rs | 32 ++- packages/stack-encrypt-derive/src/decrypt.rs | 147 ++++++------ packages/stack-encrypt-derive/src/encrypt.rs | 92 +++----- packages/stack-encrypt-derive/src/shape.rs | 211 +++++++++++++----- .../examples/encrypted_record.rs | 27 ++- packages/stack-encrypt/src/target/mod.rs | 68 +++++- packages/stack-encrypt/tests/target.rs | 18 +- .../tests/ui/empty_context.stderr | 2 +- .../tests/ui/row_with_context.stderr | 26 ++- 11 files changed, 401 insertions(+), 235 deletions(-) diff --git a/docs/rfcs/0002-async-shape-for-target-directed-encryption.md b/docs/rfcs/0002-async-shape-for-target-directed-encryption.md index 3cd29764e..e1b6498b0 100644 --- a/docs/rfcs/0002-async-shape-for-target-directed-encryption.md +++ b/docs/rfcs/0002-async-shape-for-target-directed-encryption.md @@ -505,7 +505,8 @@ The implementation kept the design and changed three names/details: use; as a trait parameter each impl bounds it. The leaves demand `SuppliedContext` (every vitaminc context type but `()`), records inherit that through their field bounds, and a row whose fields carry their own - contexts leaves it unbounded. The sugar splits accordingly, after + contexts is implemented for `()` alone — a context handed to it would go + nowhere. The sugar splits accordingly, after vitaminc's `encrypt` / `encrypt_with_aad`: `encrypt_into(&cipher)` passes `()` and exists only for outputs that need nothing from the caller; `encrypt_into_with_context(&cipher, ctx)` for the rest (`decrypt_from` / diff --git a/docs/target-directed-encryption.md b/docs/target-directed-encryption.md index 5335d2239..ad147c72f 100644 --- a/docs/target-directed-encryption.md +++ b/docs/target-directed-encryption.md @@ -117,8 +117,12 @@ EQL's wire newtypes (`Ciphertext`, `Hmac256`, `OreBlock256`) get the same treatm **Composites** fan out to each field's impl, merge the outputs, and assemble. Today that is written by hand — one impl, in the shape the derive will eventually generate: ```rust -impl<'c, K, Ctx> EncryptFrom, Ctx> for EncryptedInt -where Ctx: EncryptContext<'c> + SuppliedContext<'c>, // what the leaves below demand +impl EncryptFrom, Ctx> for EncryptedInt +where + Ctx: Clone, // fans out to every field + StackCipherText: EncryptFrom, Ctx>, // what each leaf demands, + EqualityTerm: EncryptFrom, Ctx>, // inherited, not restated + OreTerm: EncryptFrom, Ctx>, { fn encrypt_from<'a>(source: &'a u32, cipher: &'a StackCipher, context: Ctx) -> Pending<'a, Self, K> where Self: 'a, @@ -131,7 +135,7 @@ where Ctx: EncryptContext<'c> + SuppliedContext<'c>, // what the leaves below } ``` -One context fans out to every field. `zip` concatenates the fields' requests, so the whole record is still one batched call when awaited. The derive writes the same bound, transitively — one `FieldTy: EncryptFrom` per field — so a record inherits its leaves' demand for a supplied context without naming it. +One context fans out to every field. `zip` concatenates the fields' requests, so the whole record is still one batched call when awaited. The where clauses are exactly the ones the derive writes — one `FieldTy: EncryptFrom` per field — so a record inherits its leaves' demand for a supplied context without naming it, and a hand-written composite that copies this shape rides along when the leaf bound tightens (vitaminc#291) instead of restating today's policy. **The derive** writes exactly that impl from the struct: diff --git a/packages/stack-encrypt-derive/src/attrs.rs b/packages/stack-encrypt-derive/src/attrs.rs index c72116221..0d110b87c 100644 --- a/packages/stack-encrypt-derive/src/attrs.rs +++ b/packages/stack-encrypt-derive/src/attrs.rs @@ -98,18 +98,7 @@ impl FieldAttrs { if parsed.context.is_some() { return Err(meta.error("`context` is given twice; a field has one context")); } - let context: LitStr = meta.value()?.parse()?; - // The leaves reject an empty context at runtime; a - // literal one is known here, so say so at the literal. - if context.value().is_empty() { - return Err(syn::Error::new( - context.span(), - "an empty `context` is rejected when a value is encrypted: name the \ - field (e.g. \"users/email\"), or drop the attribute to use the \ - record's context", - )); - } - parsed.context = Some(context); + parsed.context = Some(meta.value()?.parse()?); return Ok(()); } if meta.path.is_ident("from") { @@ -146,6 +135,25 @@ impl FieldAttrs { })?; } + // The leaves reject an empty context at runtime; a literal one is + // known here, so say so at the literal. Checked after the loop, once + // `from` is known whatever order the attributes were written in: the + // advice depends on it, because a `from` field is never handed the + // record's context, so "drop the attribute" is a dead end there. + if let Some(context) = &parsed.context { + if context.value().is_empty() { + let message = if parsed.from.is_some() { + "an empty `context` is rejected when a value is encrypted: name the column \ + this field encrypts (e.g. \"users/email\"). A `from` field is never handed \ + the record's context, so the literal is the only context this field can have." + } else { + "an empty `context` is rejected when a value is encrypted: name the field \ + (e.g. \"users/email\"), or drop the attribute to use the record's context" + }; + return Err(syn::Error::new(context.span(), message)); + } + } + Ok(parsed) } } diff --git a/packages/stack-encrypt-derive/src/decrypt.rs b/packages/stack-encrypt-derive/src/decrypt.rs index 06eb3dc36..2199c7627 100644 --- a/packages/stack-encrypt-derive/src/decrypt.rs +++ b/packages/stack-encrypt-derive/src/decrypt.rs @@ -15,11 +15,11 @@ use proc_macro2::{Span, TokenStream}; use quote::{quote, quote_spanned, ToTokens}; use syn::spanned::Spanned; use syn::{ - parse_quote, parse_quote_spanned, DeriveInput, Ident, LitStr, Member, Path, PathArguments, - Result, Type, + parse_quote, parse_quote_spanned, DeriveInput, Generics, Ident, LitStr, Member, Path, + PathArguments, Result, Type, }; -use crate::shape::{context_type, own_context, push_context_generics, zip_fields, Field, Record}; +use crate::shape::{push_context_generics, trait_impl, zip_fields, CallerContext, Field, Record}; pub(crate) fn derive(input: DeriveInput) -> Result { let record = Record::parse(&input)?; @@ -41,22 +41,19 @@ pub(crate) fn derive(input: DeriveInput) -> Result { /// `impl DecryptInto, Ctx> for Record` around /// `body`; `ctx` is `__Ctx` or `()` ([`push_context_generics`]). -#[allow(clippy::too_many_arguments)] fn impl_block( + input: &DeriveInput, krate: &Path, - name: &Ident, - ty_generics: &syn::TypeGenerics<'_>, - impl_generics: &syn::ImplGenerics<'_>, - where_clause: Option<&syn::WhereClause>, + generics: &Generics, plaintext: &Type, ctx: &Type, body: TokenStream, ) -> TokenStream { - quote! { - #[automatically_derived] - impl #impl_generics #krate::target::DecryptInto<#plaintext, #krate::StackCipher<__K>, #ctx> - for #name #ty_generics #where_clause - { + trait_impl( + input, + generics, + quote!(#krate::target::DecryptInto<#plaintext, #krate::StackCipher<__K>, #ctx>), + quote! { fn decrypt_into<'__a>( self, __cipher: &'__a #krate::StackCipher<__K>, @@ -68,8 +65,8 @@ fn impl_block( { #body } - } - } + }, + ) } /// `impl DecryptField<__P, __C, __Ctx> for Record`: a derived record is a @@ -114,7 +111,10 @@ fn decrypt_field_impl(input: &DeriveInput, krate: &Path) -> TokenStream { /// The context one opened field is handed, by move: its own, or the caller's. fn context_for(field: &Field) -> TokenStream { - own_context(field).unwrap_or_else(|| quote!(__context)) + field + .field_context() + .own_expr() + .unwrap_or_else(|| quote!(__context)) } /// The plaintext type as a struct-literal path: `User` becomes `User::`. @@ -198,7 +198,6 @@ impl<'a> Auto<'a> { fn automatic(input: &DeriveInput, record: &Record) -> Result { let krate = &record.krate; let name = &input.ident; - let (_, ty_generics, _) = input.generics.split_for_impl(); let auto = Auto::classify(record, name)?; // The one-ciphertext check: at the definition for a concrete record, at @@ -251,20 +250,10 @@ fn automatic(input: &DeriveInput, record: &Record) -> Result { generics.params.push(parse_quote!(__P)); generics.params.push(parse_quote!(__K)); push_field_bounds(&mut generics, krate, fields, &plaintext); - let ctx = push_context_generics(&mut generics, fields); - let (impl_generics, _, where_clause) = generics.split_for_impl(); + let ctx = push_context_generics(&mut generics, CallerContext::Decrypt(krate), fields); let open = open_one(krate, fields, &plaintext); let body = quote!(#body_check #destructure #open); - let block = impl_block( - krate, - name, - &ty_generics, - &impl_generics, - where_clause, - &plaintext, - &ctx, - body, - ); + let block = impl_block(input, krate, &generics, &plaintext, &ctx, body); return Ok(quote!(#block #definition_check)); } @@ -281,19 +270,10 @@ fn automatic(input: &DeriveInput, record: &Record) -> Result { } Auto::ByField(groups) => by_group_body(krate, groups, plaintext)?, }; - let ctx = push_context_generics(&mut generics, &candidates); - let (impl_generics, _, where_clause) = generics.split_for_impl(); + let ctx = + push_context_generics(&mut generics, CallerContext::Decrypt(krate), &candidates); let body = quote!(#body_check #destructure #open); - Ok(impl_block( - krate, - name, - &ty_generics, - &impl_generics, - where_clause, - plaintext, - &ctx, - body, - )) + Ok(impl_block(input, krate, &generics, plaintext, &ctx, body)) }) .collect::>>()?; @@ -304,16 +284,11 @@ fn automatic(input: &DeriveInput, record: &Record) -> Result { /// candidate field, under the context it is opened under, so a record's impl /// exists for exactly the plaintexts its ciphertext field opens to — and /// only under a supplied context if that field needs one. -fn push_field_bounds( - generics: &mut syn::Generics, - krate: &Path, - fields: &[&Field], - plaintext: &Type, -) { +fn push_field_bounds(generics: &mut Generics, krate: &Path, fields: &[&Field], plaintext: &Type) { let predicates = &mut generics.make_where_clause().predicates; for field in fields { let ty = &field.ty; - let context = context_type(field); + let context = field.field_context().ty(); // Spanned at the field type, so a type that cannot be a field of an // automatically decrypted record is reported there. predicates.push(parse_quote_spanned! {ty.span()=> @@ -375,8 +350,10 @@ fn open_one(krate: &Path, fields: &[&Field], plaintext: &Type) -> TokenStream { let mut calls = fields.iter().map(|field| { let ty = &field.ty; let local = &field.local; - let context = - own_context(field).unwrap_or_else(|| quote!(::core::clone::Clone::clone(__context))); + let context = field + .field_context() + .own_expr() + .unwrap_or_else(|| quote!(::core::clone::Clone::clone(__context))); quote! { <#ty as #krate::target::DecryptField<#plaintext, #krate::StackCipher<__K>, _>>::decrypt_field( #local, __cipher, #context, @@ -446,7 +423,6 @@ fn by_group_body(krate: &Path, groups: &[Group<'_>], plaintext: &Type) -> Result fn explicit(input: &DeriveInput, record: &Record) -> Result { let krate = &record.krate; let name = &input.ident; - let (_, ty_generics, _) = input.generics.split_for_impl(); let opened: Vec<&Field> = record.fields.iter().filter(|f| f.decrypt).collect(); let mode = Mode::classify(opened, name)?; @@ -462,26 +438,16 @@ fn explicit(input: &DeriveInput, record: &Record) -> Result { }; let plaintext: Type = parse_quote!(__P); let ty = &field.ty; - let context = context_type(field); + let context = field.field_context().ty(); let mut generics = input.generics.clone(); generics.params.push(parse_quote!(__P)); generics.params.push(parse_quote!(__K)); generics.make_where_clause().predicates.push(parse_quote! { #ty: #krate::target::DecryptInto<__P, #krate::StackCipher<__K>, #context> }); - let ctx = push_context_generics(&mut generics, &[field]); - let (impl_generics, _, where_clause) = generics.split_for_impl(); + let ctx = push_context_generics(&mut generics, CallerContext::Decrypt(krate), &[field]); let body = whole_body(krate, field, &plaintext); - return Ok(impl_block( - krate, - name, - &ty_generics, - &impl_generics, - where_clause, - &plaintext, - &ctx, - body, - )); + return Ok(impl_block(input, krate, &generics, &plaintext, &ctx, body)); } let impls = record @@ -493,29 +459,21 @@ fn explicit(input: &DeriveInput, record: &Record) -> Result { let (body, ctx) = match &mode { Mode::Whole(field) => { let ty = &field.ty; - let context = context_type(field); + let context = field.field_context().ty(); generics.make_where_clause().predicates.push(parse_quote! { #ty: #krate::target::DecryptInto<#plaintext, #krate::StackCipher<__K>, #context> }); - let ctx = push_context_generics(&mut generics, &[field]); + let ctx = + push_context_generics(&mut generics, CallerContext::Decrypt(krate), &[field]); (whole_body(krate, field, plaintext), ctx) } Mode::ByField(fields) => { - let ctx = push_context_generics(&mut generics, fields); + let ctx = + push_context_generics(&mut generics, CallerContext::Decrypt(krate), fields); (by_field_body(krate, fields, plaintext)?, ctx) } }; - let (impl_generics, _, where_clause) = generics.split_for_impl(); - Ok(impl_block( - krate, - name, - &ty_generics, - &impl_generics, - where_clause, - plaintext, - &ctx, - body, - )) + Ok(impl_block(input, krate, &generics, plaintext, &ctx, body)) }) .collect::>>()?; @@ -641,7 +599,7 @@ mod tests { where StackCipherText: ::stack_encrypt::target::DecryptField, __Ctx>, EqualityTerm: ::stack_encrypt::target::DecryptField, __Ctx>, - __Ctx: ::core::clone::Clone + __Ctx: ::stack_encrypt::target::DecryptContext<'__ctx> }); assert_contains(&expansion, quote!(let Self { c: __field_0, hm: __field_1, .. } = self;)); assert_contains(&expansion, quote! { @@ -670,6 +628,31 @@ mod tests { assert_lacks(&expansion, quote!()); } + #[test] + #[rustfmt::skip] + fn a_caller_context_must_be_a_decrypt_context() { + // The regression shape: the ciphertext field carries a literal, so + // only the term fields see the caller's context — and a term's + // `DecryptField` accepts anything (it opens nothing). The impl-level + // bound is what keeps `decrypt_into` from accepting, and silently + // discarding, a value that is not a context at all. + let expansion = expand(parse_quote! { + #[stash(plaintext = u32)] + struct Rec { + #[stash(context = "rec/c")] + c: StackCipherText, + hm: EqualityTerm, + } + }) + .unwrap(); + assert_contains(&expansion, quote! { + impl<'__ctx, __K, __Ctx> ::stack_encrypt::target::DecryptInto, __Ctx> for Rec + }); + assert_contains(&expansion, quote! { + __Ctx: ::stack_encrypt::target::DecryptContext<'__ctx> + }); + } + #[test] fn a_generic_record_is_checked_at_its_use() { let expansion = expand(parse_quote! { @@ -835,10 +818,10 @@ mod tests { }) .unwrap(); assert_contains(&expansion, quote! { - impl<__P, __K, __Ctx> ::stack_encrypt::target::DecryptInto<__P, ::stack_encrypt::StackCipher<__K>, __Ctx> for Wrapped + impl<'__ctx, __P, __K, __Ctx> ::stack_encrypt::target::DecryptInto<__P, ::stack_encrypt::StackCipher<__K>, __Ctx> for Wrapped where StackCipherText: ::stack_encrypt::target::DecryptInto<__P, ::stack_encrypt::StackCipher<__K>, __Ctx>, - __Ctx: ::core::clone::Clone + __Ctx: ::stack_encrypt::target::DecryptContext<'__ctx> }); assert_contains(&expansion, quote! { , _>>::decrypt_into( @@ -875,7 +858,7 @@ mod tests { }) .unwrap(); assert_contains(&expansion, quote! { - impl<__K, __Ctx> ::stack_encrypt::target::DecryptInto, __Ctx> for EncryptedAge + impl<'__ctx, __K, __Ctx> ::stack_encrypt::target::DecryptInto, __Ctx> for EncryptedAge where StackCipherText: ::stack_encrypt::target::DecryptInto, __Ctx> }); diff --git a/packages/stack-encrypt-derive/src/encrypt.rs b/packages/stack-encrypt-derive/src/encrypt.rs index ceffffcb1..3f99c2ad9 100644 --- a/packages/stack-encrypt-derive/src/encrypt.rs +++ b/packages/stack-encrypt-derive/src/encrypt.rs @@ -3,17 +3,18 @@ use proc_macro2::TokenStream; use quote::{quote, quote_spanned}; use syn::spanned::Spanned; -use syn::{parse_quote, DeriveInput, Ident, Path, Result, Type}; +use syn::{parse_quote, parse_quote_spanned, DeriveInput, Generics, Path, Result, Type}; -use crate::shape::{context_type, push_context_generics, zip_fields, Kind, Record}; +use crate::shape::{ + push_context_generics, trait_impl, zip_fields, CallerContext, Field, Kind, Record, +}; pub(crate) fn derive(input: DeriveInput) -> Result { let record = Record::parse(&input)?; let krate = &record.krate; - let name = &input.ident; - let (_, ty_generics, _) = input.generics.split_for_impl(); + let derived = record.derived(); - let decryptable = decryptable_impl(&input, &record); + let decryptable = decryptable_impl(&input, &record, &derived); if record.plaintexts.is_empty() { // One impl, generic over the source: the record accepts exactly the @@ -23,20 +24,10 @@ pub(crate) fn derive(input: DeriveInput) -> Result { let mut generics = input.generics.clone(); generics.params.push(parse_quote!(__S)); generics.params.push(parse_quote!(__K)); - push_field_bounds(&mut generics, krate, &record, &source); - let ctx = push_context_generics(&mut generics, &record.derived()); - let (impl_generics, _, where_clause) = generics.split_for_impl(); - let body = body(krate, &record, &source); - let block = impl_block( - krate, - name, - &ty_generics, - &impl_generics, - where_clause, - &source, - &ctx, - body, - ); + push_field_bounds(&mut generics, krate, &derived, &source); + let ctx = push_context_generics(&mut generics, CallerContext::Encrypt, &derived); + let body = body(krate, &record, &derived, &source); + let block = impl_block(&input, krate, &generics, &source, &ctx, body); return Ok(quote!(#block #decryptable)); } @@ -46,20 +37,10 @@ pub(crate) fn derive(input: DeriveInput) -> Result { let impls = record.plaintexts.iter().map(|source| { let mut generics = input.generics.clone(); generics.params.push(parse_quote!(__K)); - push_field_bounds(&mut generics, krate, &record, source); - let ctx = push_context_generics(&mut generics, &record.derived()); - let (impl_generics, _, where_clause) = generics.split_for_impl(); - let body = body(krate, &record, source); - impl_block( - krate, - name, - &ty_generics, - &impl_generics, - where_clause, - source, - &ctx, - body, - ) + push_field_bounds(&mut generics, krate, &derived, source); + let ctx = push_context_generics(&mut generics, CallerContext::Encrypt, &derived); + let body = body(krate, &record, &derived, source); + impl_block(&input, krate, &generics, source, &ctx, body) }); Ok(quote!(#(#impls)* #decryptable)) @@ -67,22 +48,19 @@ pub(crate) fn derive(input: DeriveInput) -> Result { /// `impl EncryptFrom, Ctx> for Record` around /// `body`; `ctx` is `__Ctx` or `()` ([`push_context_generics`]). -#[allow(clippy::too_many_arguments)] fn impl_block( + input: &DeriveInput, krate: &Path, - name: &Ident, - ty_generics: &syn::TypeGenerics<'_>, - impl_generics: &syn::ImplGenerics<'_>, - where_clause: Option<&syn::WhereClause>, + generics: &Generics, source: &Type, ctx: &Type, body: TokenStream, ) -> TokenStream { - quote! { - #[automatically_derived] - impl #impl_generics #krate::target::EncryptFrom<#source, #krate::StackCipher<__K>, #ctx> - for #name #ty_generics #where_clause - { + trait_impl( + input, + generics, + quote!(#krate::target::EncryptFrom<#source, #krate::StackCipher<__K>, #ctx>), + quote! { fn encrypt_from<'__a>( __source: &'__a #source, __cipher: &'__a #krate::StackCipher<__K>, @@ -93,8 +71,8 @@ fn impl_block( { #body } - } - } + }, + ) } /// `impl Decryptable for Record`: a record is decryptable if any derived @@ -106,14 +84,14 @@ fn impl_block( /// types need not be `Decryptable`, so probing them here would reintroduce /// the bound the marker removes (and fail to compile for the documented /// opaque-field shape). -fn decryptable_impl(input: &DeriveInput, record: &Record) -> TokenStream { +fn decryptable_impl(input: &DeriveInput, record: &Record, derived: &[&Field]) -> TokenStream { let krate = &record.krate; let name = &input.ident; let (impl_generics, ty_generics, where_clause) = input.generics.split_for_impl(); let value = if record.fields.iter().any(|f| f.decrypt) { quote!(true) } else { - let terms = record.derived().into_iter().map(|field| { + let terms = derived.iter().map(|field| { let ty = &field.ty; // Spanned at the field type: a type that is not `Decryptable` // is reported there, not at the derive. @@ -133,16 +111,14 @@ fn decryptable_impl(input: &DeriveInput, record: &Record) -> TokenStream { /// derived from the whole source, under the context it is derived under — /// its literal's, or the caller's `__Ctx`, which is how a record inherits /// its leaves' demand for a supplied context. -fn push_field_bounds(generics: &mut syn::Generics, krate: &Path, record: &Record, source: &Type) { +fn push_field_bounds(generics: &mut Generics, krate: &Path, derived: &[&Field], source: &Type) { let predicates = &mut generics.make_where_clause().predicates; - for field in record - .fields - .iter() - .filter(|f| f.is_derived() && f.from().is_none()) - { + for field in derived.iter().filter(|f| f.from().is_none()) { let ty = &field.ty; - let context = context_type(field); - predicates.push(parse_quote! { + let context = field.field_context().ty(); + // Spanned at the field type, so a type that is not an encrypted form + // of the source is reported there, not at the derive. + predicates.push(parse_quote_spanned! {ty.span()=> #ty: #krate::target::EncryptFrom<#source, #krate::StackCipher<__K>, #context> }); } @@ -150,9 +126,7 @@ fn push_field_bounds(generics: &mut syn::Generics, krate: &Path, record: &Record /// The method body: every derived field's pending, zipped into one, mapped /// into `Self`. -fn body(krate: &Path, record: &Record, source: &Type) -> TokenStream { - let derived = record.derived(); - +fn body(krate: &Path, record: &Record, derived: &[&Field], source: &Type) -> TokenStream { let assign = record.fields.iter().map(|field| { let member = &field.member; match &field.kind { @@ -166,7 +140,7 @@ fn body(krate: &Path, record: &Record, source: &Type) -> TokenStream { }); zip_fields( - &derived, + derived, |field, context| { let ty = &field.ty; // A `from` field's source type is not known here; it is inferred diff --git a/packages/stack-encrypt-derive/src/shape.rs b/packages/stack-encrypt-derive/src/shape.rs index 60c5045d4..198d301b7 100644 --- a/packages/stack-encrypt-derive/src/shape.rs +++ b/packages/stack-encrypt-derive/src/shape.rs @@ -53,11 +53,36 @@ impl Field { } } - /// The literal context, if this is a derived field with one. - pub(crate) fn context(&self) -> Option<&LitStr> { + /// How this derived field gets its context — the one classification both + /// derives project their where clauses and bodies from. + /// + /// A `from` field with no literal is [`FieldContext::Unit`], never the + /// caller's: it reaches into one field of the plaintext, and its type + /// says what that field needs — a leaf refuses `()` (the derive cannot + /// name the plaintext field's type in a where clause, so the obligation + /// is checked in the body and reported at the field type), and a nested + /// row carrying its own contexts accepts nothing else. Handing such a + /// field the caller's context instead would encrypt every column of the + /// row under one context, which is the cross-column transplant the + /// per-field contexts exist to prevent. + /// + /// Only called for derived fields: a `default` field is not derived from + /// the source and is never handed a context at all. + pub(crate) fn field_context(&self) -> FieldContext<'_> { match &self.kind { - Kind::Derived { context, .. } => context.as_ref(), - Kind::Default(_) => None, + Kind::Derived { + context: Some(literal), + .. + } => FieldContext::Literal(literal), + Kind::Derived { + context: None, + from: Some(_), + } => FieldContext::Unit, + Kind::Derived { + context: None, + from: None, + } => FieldContext::Caller, + Kind::Default(_) => unreachable!("a `default` field has no context"), } } @@ -65,13 +90,45 @@ impl Field { /// record? Only a field derived from the whole plaintext with no literal /// of its own is; see [`push_context_generics`]. pub(crate) fn takes_callers_context(&self) -> bool { - matches!( - &self.kind, - Kind::Derived { - context: None, - from: None - } - ) + self.is_derived() && matches!(self.field_context(), FieldContext::Caller) + } +} + +/// Where a derived field's context comes from: a literal of its own, `()` +/// for a `from` field with no literal, or the caller's. See +/// [`Field::field_context`] for why a `from` field never gets the caller's. +#[cfg_attr(test, derive(Debug))] +pub(crate) enum FieldContext<'a> { + /// `#[stash(context = "...")]`. + Literal(&'a LitStr), + /// A `from` field with no literal: handed `()`, and its type decides + /// whether that will do. + Unit, + /// The context the caller passes for the record, as the impl's `__Ctx`. + Caller, +} + +impl FieldContext<'_> { + /// The context type as it appears in a where clause: the literal's, + /// `()`, or the impl's `__Ctx`. + pub(crate) fn ty(&self) -> Type { + match self { + FieldContext::Literal(_) => parse_quote!(&'static str), + FieldContext::Unit => parse_quote!(()), + FieldContext::Caller => parse_quote!(__Ctx), + } + } + + /// The context expression the field is handed when it is not the + /// caller's; `None` for a field that takes the caller's, whose + /// expression is the call site's to choose (move, clone, or clone + /// through a reference). + pub(crate) fn own_expr(&self) -> Option { + match self { + FieldContext::Literal(literal) => Some(quote!(#literal)), + FieldContext::Unit => Some(quote!(())), + FieldContext::Caller => None, + } } } @@ -138,60 +195,76 @@ impl Record { } } -/// The context type a field is derived or opened under, as it appears in a -/// where clause: its literal's, `()` for a `from` field with no literal, or -/// the impl's `__Ctx` when it takes the caller's. -pub(crate) fn context_type(field: &Field) -> Type { - match own_context(field) { - Some(_) if field.context().is_some() => parse_quote!(&'static str), - Some(_) => parse_quote!(()), - None => parse_quote!(__Ctx), - } -} - -/// The context a field is derived or opened under when it is not the -/// caller's: its literal, or `()` for a `from` field with no literal. `None` -/// for a field that takes the caller's. -/// -/// A `from` field reaches into one field of the plaintext, and its type -/// says what that field needs: a leaf refuses `()` (the derive cannot name -/// the plaintext field's type in a where clause, so the obligation is -/// checked in the body and reported at the field type), and a nested row -/// carrying its own contexts accepts nothing else. Handing such a field the -/// caller's context instead would encrypt every column of the row under one -/// context, which is the cross-column transplant the per-field contexts -/// exist to prevent. -pub(crate) fn own_context(field: &Field) -> Option { - match field.context() { - Some(literal) => Some(quote!(#literal)), - None if field.from().is_some() => Some(quote!(())), - None => None, - } +/// The demand a derive places on the impl's context parameter, beyond what +/// the field bounds already say. +pub(crate) enum CallerContext<'a> { + /// Encrypt: `Clone` is all the generated body itself needs (the context + /// fans out to every field); everything else — supplied, convertible — + /// is inherited through the field bounds, because every encrypt leaf + /// states its own demand. + Encrypt, + /// Decrypt: `DecryptContext` — convertible to the AAD the value was + /// encrypted under. A term field's `DecryptField` accepts *any* context + /// (it opens nothing), so field bounds alone would let a record whose + /// ciphertext field carries a literal accept — and silently discard — + /// any `Clone` value as its decrypt context. + Decrypt(&'a Path), } /// Adds the impl's context parameter, if `fields` give it a use, and returns /// the type the impl is for. /// -/// A field with a context of its own ([`own_context`]) never sees the -/// caller's. A record whose fields all have one — every row does — is +/// A field with a context of its own ([`FieldContext::own_expr`]) never sees +/// the caller's. A record whose fields all have one — every row does — is /// therefore encrypted with no context at all, and its impl is for `()` /// exactly: `row.encrypt_into(&cipher)` compiles and /// `encrypt_into_with_context` does not, since the context would go nowhere. /// Otherwise the impl is generic over `__Ctx`, cloned to each field that -/// takes it; what the context must *be* — usable, supplied — comes from the -/// field bounds, not from here. -pub(crate) fn push_context_generics(generics: &mut Generics, fields: &[&Field]) -> Type { +/// takes it, bounded by what `bound` says the direction demands. +pub(crate) fn push_context_generics( + generics: &mut Generics, + bound: CallerContext<'_>, + fields: &[&Field], +) -> Type { if !fields.iter().any(|f| f.takes_callers_context()) { return parse_quote!(()); } generics.params.push(parse_quote!(__Ctx)); - generics - .make_where_clause() - .predicates - .push(parse_quote!(__Ctx: ::core::clone::Clone)); + let predicates = &mut generics.make_where_clause().predicates; + match bound { + CallerContext::Encrypt => { + predicates.push(parse_quote!(__Ctx: ::core::clone::Clone)); + } + CallerContext::Decrypt(krate) => { + predicates.push(parse_quote!(__Ctx: #krate::target::DecryptContext<'__ctx>)); + // A lifetime parameter must precede the type parameters. + generics.params.insert(0, parse_quote!('__ctx)); + } + } parse_quote!(__Ctx) } +/// `impl #trait_path for Record` around `content` — the scaffolding both +/// derives share. Splits the record's own generics (for the type position) +/// and the augmented `generics` (for the impl and its where clause) here, so +/// each derive hands over one `Generics` instead of three projections of it. +pub(crate) fn trait_impl( + input: &DeriveInput, + generics: &Generics, + trait_path: TokenStream, + content: TokenStream, +) -> TokenStream { + let name = &input.ident; + let (_, ty_generics, _) = input.generics.split_for_impl(); + let (impl_generics, _, where_clause) = generics.split_for_impl(); + quote! { + #[automatically_derived] + impl #impl_generics #trait_path for #name #ty_generics #where_clause { + #content + } + } +} + /// The pendings of `fields`, zipped into one and mapped into `build` (a /// struct literal over the fields' locals). Nothing is awaited, so the record /// settles as one batched call. @@ -209,7 +282,7 @@ pub(crate) fn zip_fields( let mut chain = TokenStream::new(); let mut pattern = TokenStream::new(); for (index, field) in fields.iter().enumerate() { - let context = match own_context(field) { + let context = match field.field_context().own_expr() { Some(own) => own, None => { remaining -= 1; @@ -421,6 +494,39 @@ mod tests { }) .unwrap_err(); assert!(err.to_string().contains("empty `context`")); + assert!(err.to_string().contains("drop the attribute")); + } + + #[test] + fn an_empty_context_on_a_from_field_gets_from_specific_advice() { + // "Drop the attribute" is a dead end for a `from` field — it is + // never handed the record's context — so the advice must not offer + // it, whichever order the attributes were written in. + for input in [ + parse_quote! { + #[stash(plaintext = User)] + struct Row { + #[stash(from = email, context = "")] + email: StackCipherText, + } + }, + parse_quote! { + #[stash(plaintext = User)] + struct Row { + #[stash(context = "", from = email)] + email: StackCipherText, + } + }, + ] { + let err = parse(input).unwrap_err(); + let message = err.to_string(); + assert!(message.contains("empty `context`"), "{message}"); + assert!( + message.contains("never handed the record's context"), + "{message}" + ); + assert!(!message.contains("drop the attribute"), "{message}"); + } } #[test] @@ -479,7 +585,10 @@ mod tests { assert_eq!(record.plaintexts.len(), 2); assert_eq!(record.fields.len(), 3); assert!(matches!(record.fields[0].from(), Some(Member::Named(name)) if name == "age")); - assert_eq!(record.fields[0].context().unwrap().value(), "users/age"); + assert!(matches!( + record.fields[0].field_context(), + FieldContext::Literal(literal) if literal.value() == "users/age" + )); assert!(record.fields[0].decrypt); assert!(record.fields[1].is_derived()); assert!(record.fields[1].from().is_none()); diff --git a/packages/stack-encrypt/examples/encrypted_record.rs b/packages/stack-encrypt/examples/encrypted_record.rs index c2825913b..097f40a7c 100644 --- a/packages/stack-encrypt/examples/encrypted_record.rs +++ b/packages/stack-encrypt/examples/encrypted_record.rs @@ -26,9 +26,7 @@ //! `zerokms_auth` example for the lookup order). use stack_encrypt::sem::{EqualityTerm, OreTerm}; -use stack_encrypt::target::{ - DecryptInto, EncryptContext, EncryptFrom, EncryptInto, Pending, SuppliedContext, -}; +use stack_encrypt::target::{DecryptInto, EncryptFrom, EncryptInto, Pending}; use stack_encrypt::{StackCipher, StackCipherText}; /// "An encrypted `u32`, stored as its ciphertext plus an equality term and an @@ -43,12 +41,18 @@ struct EncryptedInt { // One impl, written the way the derive will write it: build every field's // pending (no I/O — the terms derive locally, the ciphertext queues its // data-key requests), merge them with `zip`, shape with `map`. Errors are the -// cipher's; there is nothing to unify. The context is the caller's, handed -// to leaves that need a supplied one — so the record needs one too, and -// says so: `EncryptedInt` is encrypted with `encrypt_into_with_context`. -impl<'c, K, Ctx> EncryptFrom, Ctx> for EncryptedInt +// cipher's; there is nothing to unify. The context bounds are per field — +// the same clauses the derive emits — so the record *inherits* the leaves' +// context policy (a supplied, non-empty context) instead of restating it: +// `EncryptedInt` is encrypted with `encrypt_into_with_context` because its +// leaves demand a supplied context, and when the leaf bound tightens +// (vitaminc#291) the record rides along untouched. +impl EncryptFrom, Ctx> for EncryptedInt where - Ctx: EncryptContext<'c> + SuppliedContext<'c>, + Ctx: Clone, + StackCipherText: EncryptFrom, Ctx>, + EqualityTerm: EncryptFrom, Ctx>, + OreTerm: EncryptFrom, Ctx>, { fn encrypt_from<'a>( source: &'a u32, @@ -73,10 +77,11 @@ where // The decrypt mirror `#[derive(DecryptInto)]` would write: the record owns // its opening, and only the ciphertext field participates (terms are -// one-way), so it delegates to the ciphertext's own implementation. -impl<'c, K, Ctx> DecryptInto, Ctx> for EncryptedInt +// one-way), so it delegates to the ciphertext's own implementation — and +// inherits its context demand the same per-field way. +impl DecryptInto, Ctx> for EncryptedInt where - Ctx: SuppliedContext<'c>, + StackCipherText: DecryptInto, Ctx>, { fn decrypt_into<'a>(self, cipher: &'a StackCipher, context: Ctx) -> Pending<'a, u32, K> where diff --git a/packages/stack-encrypt/src/target/mod.rs b/packages/stack-encrypt/src/target/mod.rs index 27f49654c..2e6c0edd1 100644 --- a/packages/stack-encrypt/src/target/mod.rs +++ b/packages/stack-encrypt/src/target/mod.rs @@ -340,6 +340,33 @@ impl<'a, T> EncryptContext<'a> for T where T: IntoAad<'a> + IntoPrfContext<'a> + /// value was encrypted under. Decryption derives nothing, so no PRF bound. /// Blanket-implemented; `&str`, `String` and [`Aad`] /// qualify. +/// +/// This is the bound [`#[derive(DecryptInto)]`](macro@DecryptInto) places on +/// a record's caller-supplied decrypt context, and the supertrait of +/// [`SuppliedContext`]. It holds even when only term fields would see the +/// caller's context — a term opens nothing and would accept anything, so +/// without it a record whose ciphertext field carries a literal would take, +/// and silently discard, a value that is not a context at all: +/// +/// ```compile_fail,E0277 +/// use stack_encrypt::sem::EqualityTerm; +/// use stack_encrypt::{DecryptInto, EncryptFrom, StackCipher, StackCipherText}; +/// use stack_kms::FakeDataKeySource; +/// +/// #[derive(EncryptFrom, DecryptInto)] +/// #[stash(plaintext = u32)] +/// struct Rec { +/// #[stash(context = "rec/c")] +/// c: StackCipherText, +/// hm: EqualityTerm, +/// } +/// +/// async fn decrypt(cipher: &StackCipher, rec: Rec) { +/// // `42u8` is not a context (no `IntoAad`): a compile error, not a +/// // value the term fields quietly swallow. +/// let _: u32 = rec.decrypt_into(cipher, 42u8).await.unwrap(); +/// } +/// ``` pub trait DecryptContext<'a>: IntoAad<'a> + Clone {} impl<'a, T> DecryptContext<'a> for T where T: IntoAad<'a> + Clone {} @@ -368,8 +395,9 @@ impl<'a, T> DecryptContext<'a> for T where T: IntoAad<'a> + Clone {} /// type itself (cipherstash/vitaminc#291), at which point the bound tightens /// to that. /// -/// It implies [`DecryptContext`] (the same `IntoAad + Clone`), so a decrypt -/// leaf bounds its context by this marker alone. +/// [`DecryptContext`] (the same `IntoAad + Clone`) is a supertrait, so the +/// implication holds by construction and a decrypt leaf bounds its context +/// by this marker alone. #[diagnostic::on_unimplemented( message = "`{Self}` is not a context the caller supplied", label = "this leaf needs a context", @@ -378,7 +406,7 @@ impl<'a, T> DecryptContext<'a> for T where T: IntoAad<'a> + Clone {} needs `encrypt_into_with_context` / `decrypt_from_with_context`, or the literal", note = "a context type of your own opts in with an empty `impl SuppliedContext<'_> for MyContext {{}}`" )] -pub trait SuppliedContext<'a>: IntoAad<'a> + Clone {} +pub trait SuppliedContext<'a>: DecryptContext<'a> {} impl<'a> SuppliedContext<'a> for &'a str {} impl SuppliedContext<'_> for String {} @@ -470,6 +498,14 @@ mod prf_framing { /// (The tags vitaminc applies *inside* the cipher — `Aad::for_leaf`, /// `for_map_entry`, the markers — are derived after this check runs, from /// the caller-visible AAD this sees.) +/// +/// **Transitional.** Public only so a third-party leaf can make the same +/// runtime check the built-ins make (the recipe in the +/// [module docs](self#extending-with-your-own-sem-type)). When +/// [vitaminc#291](https://github.com/cipherstash/vitaminc/issues/291) carries +/// non-emptiness in the context type, both predicates and +/// [`Error::EmptyContext`] are **deleted**, not deprecated — do not build on +/// them beyond that recipe. pub fn is_degenerate_aad(bytes: &[u8]) -> bool { if bytes.is_empty() { return true; @@ -494,6 +530,10 @@ pub fn is_degenerate_aad(bytes: &[u8]) -> bool { /// A node that is not framing (a tuple, or a `PrfContext` the caller built by /// hand) is degenerate only if every one of its pieces is. Bytes that are not /// a well-formed PAE are caller content. +/// +/// **Transitional**, on the same terms as [`is_degenerate_aad`]: deleted, +/// not deprecated, when +/// [vitaminc#291](https://github.com/cipherstash/vitaminc/issues/291) lands. pub fn is_degenerate_prf_context(bytes: &[u8]) -> bool { if bytes.is_empty() { return true; @@ -723,7 +763,12 @@ pub trait EncryptInto { Self: Sized; /// Encrypt `self` into `T` under `context`. See [`EncryptFrom`]. - fn encrypt_into_with_context<'a, T, C, Ctx>( + /// + /// The context must be one the caller actually supplies + /// ([`SuppliedContext`]): passing `()` here, or a supplied context to an + /// output that takes none — a row — is a compile error either way, with + /// [`encrypt_into`](Self::encrypt_into) as the answer to both. + fn encrypt_into_with_context<'a, 'c, T, C, Ctx>( &'a self, cipher: &'a C, context: Ctx, @@ -731,6 +776,7 @@ pub trait EncryptInto { where C: EncryptTarget, T: EncryptFrom + 'a, + Ctx: SuppliedContext<'c>, Self: Sized; } @@ -743,7 +789,7 @@ impl EncryptInto for S { T::encrypt_from(self, cipher, ()) } - fn encrypt_into_with_context<'a, T, C, Ctx>( + fn encrypt_into_with_context<'a, 'c, T, C, Ctx>( &'a self, cipher: &'a C, context: Ctx, @@ -751,6 +797,7 @@ impl EncryptInto for S { where C: EncryptTarget, T: EncryptFrom + 'a, + Ctx: SuppliedContext<'c>, { T::encrypt_from(self, cipher, context) } @@ -776,7 +823,12 @@ pub trait DecryptFrom: Sized { /// Decrypt `source` into `Self`, authenticating against `context`. See /// [`DecryptInto`]. - fn decrypt_from_with_context<'a, S, C, Ctx>( + /// + /// The context must be one the caller actually supplies + /// ([`SuppliedContext`]): passing `()` here, or a supplied context to an + /// encrypted type that takes none — a row — is a compile error either + /// way, with [`decrypt_from`](Self::decrypt_from) as the answer to both. + fn decrypt_from_with_context<'a, 'c, S, C, Ctx>( source: S, cipher: &'a C, context: Ctx, @@ -784,6 +836,7 @@ pub trait DecryptFrom: Sized { where C: DecryptTarget, S: DecryptInto + 'a, + Ctx: SuppliedContext<'c>, Self: 'a; } @@ -797,7 +850,7 @@ impl

DecryptFrom for P { source.decrypt_into(cipher, ()) } - fn decrypt_from_with_context<'a, S, C, Ctx>( + fn decrypt_from_with_context<'a, 'c, S, C, Ctx>( source: S, cipher: &'a C, context: Ctx, @@ -805,6 +858,7 @@ impl

DecryptFrom for P { where C: DecryptTarget, S: DecryptInto + 'a, + Ctx: SuppliedContext<'c>, Self: 'a, { source.decrypt_into(cipher, context) diff --git a/packages/stack-encrypt/tests/target.rs b/packages/stack-encrypt/tests/target.rs index fe3012a5a..f08a84871 100644 --- a/packages/stack-encrypt/tests/target.rs +++ b/packages/stack-encrypt/tests/target.rs @@ -321,10 +321,15 @@ struct EncryptedAge { } // The record hands the caller's context to its leaves, so it needs what -// they need: a supplied one. (The derive gets this from the field bounds.) -impl<'c, K, Ctx> EncryptFrom, Ctx> for EncryptedAge +// they need — inherited through per-field bounds, the same clauses the +// derive emits, rather than restated as a leaf-policy bound of the record's +// own (which would need editing every time the leaves' policy tightens). +impl EncryptFrom, Ctx> for EncryptedAge where - Ctx: EncryptContext<'c> + SuppliedContext<'c>, + Ctx: Clone, + StackCipherText: EncryptFrom, Ctx>, + EqualityTerm: EncryptFrom, Ctx>, + OreTerm: EncryptFrom, Ctx>, { fn encrypt_from<'a>( source: &'a u32, @@ -342,10 +347,11 @@ where } /// The decrypt mirror a derive would emit: only the ciphertext field -/// participates — terms are one-way. -impl<'c, K, Ctx> DecryptInto, Ctx> for EncryptedAge +/// participates — terms are one-way — and its context demand is inherited +/// through the field bound, as on the encrypt side. +impl DecryptInto, Ctx> for EncryptedAge where - Ctx: SuppliedContext<'c>, + StackCipherText: DecryptInto, Ctx>, { fn decrypt_into<'a>(self, cipher: &'a StackCipher, context: Ctx) -> Pending<'a, u32, K> where diff --git a/packages/stack-encrypt/tests/ui/empty_context.stderr b/packages/stack-encrypt/tests/ui/empty_context.stderr index 1e61f48df..f6f6c7fbf 100644 --- a/packages/stack-encrypt/tests/ui/empty_context.stderr +++ b/packages/stack-encrypt/tests/ui/empty_context.stderr @@ -1,4 +1,4 @@ -error: an empty `context` is rejected when a value is encrypted: name the field (e.g. "users/email"), or drop the attribute to use the record's context +error: an empty `context` is rejected when a value is encrypted: name the column this field encrypts (e.g. "users/email"). A `from` field is never handed the record's context, so the literal is the only context this field can have. --> tests/ui/empty_context.rs:10:37 | 10 | #[stash(from = email, context = "")] diff --git a/packages/stack-encrypt/tests/ui/row_with_context.stderr b/packages/stack-encrypt/tests/ui/row_with_context.stderr index 0d28514af..f8e6d1bd8 100644 --- a/packages/stack-encrypt/tests/ui/row_with_context.stderr +++ b/packages/stack-encrypt/tests/ui/row_with_context.stderr @@ -11,12 +11,34 @@ error[E0277]: `EncryptedUser` is not an encrypted form of `User` under a `&str` note: required by a bound in `encrypt_into_with_context` --> src/target/mod.rs | - | fn encrypt_into_with_context<'a, T, C, Ctx>( + | fn encrypt_into_with_context<'a, 'c, T, C, Ctx>( | ------------------------- required by a bound in this associated function ... | T: EncryptFrom + 'a, | ^^^^^^^^^^^^^^^^^^^^^^^^^ required by this bound in `EncryptInto::encrypt_into_with_context` +error[E0277]: `()` is not a context the caller supplied + --> tests/ui/row_with_context.rs:27:62 + | +27 | let _user = User::decrypt_from_with_context(row, cipher, "tenant/acme") + | ------------------------------- ^^^^^^^^^^^^^ this leaf needs a context + | | + | required by a bound introduced by this call + | + = note: `()` is what `encrypt_into` / `decrypt_from` pass, and what a derived row hands a `from` field with no `context = ".."` of its own: an output that reaches a leaf needs `encrypt_into_with_context` / `decrypt_from_with_context`, or the literal + = note: a context type of your own opts in with an empty `impl SuppliedContext<'_> for MyContext {}` + = help: the trait `SuppliedContext<'_>` is not implemented for `()` + but it is implemented for `(_, _)` + = help: for that trait implementation, expected `(_, _)`, found `()` +note: required by a bound in `decrypt_from_with_context` + --> src/target/mod.rs + | + | fn decrypt_from_with_context<'a, 'c, S, C, Ctx>( + | ------------------------- required by a bound in this associated function +... + | Ctx: SuppliedContext<'c>, + | ^^^^^^^^^^^^^^^^^^^ required by this bound in `DecryptFrom::decrypt_from_with_context` + error[E0308]: mismatched types --> tests/ui/row_with_context.rs:27:62 | @@ -28,5 +50,5 @@ error[E0308]: mismatched types note: associated function defined here --> src/target/mod.rs | - | fn decrypt_from_with_context<'a, S, C, Ctx>( + | fn decrypt_from_with_context<'a, 'c, S, C, Ctx>( | ^^^^^^^^^^^^^^^^^^^^^^^^^ From 07db41fb0b0bd14a9d563e7f35ce20d6ca6cdea5 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Tue, 1 Sep 2026 12:54:20 +1000 Subject: [PATCH 467/686] docs(stack-encrypt): the design doc names the derives that shipped MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Naming section still recorded `#[derive(Encrypted)]` as the macro name, and the implementation notes still called the derive unbuilt and the row snippet "future". `stack-encrypt-derive` ships `#[derive(EncryptFrom)]` / `#[derive(DecryptInto)]` under `#[stash(..)]`, each named after the trait it emits — which is also why the sketched noun could not stand: it names neither trait, and one noun cannot cover both directions. Claude-Session: https://claude.ai/code/session_01VEKAfJiDDhSxEZRAVJQJPX --- docs/target-directed-encryption.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/target-directed-encryption.md b/docs/target-directed-encryption.md index ad147c72f..b94c8bf04 100644 --- a/docs/target-directed-encryption.md +++ b/docs/target-directed-encryption.md @@ -8,7 +8,7 @@ Everything this document decides shipped as designed: one trait on the output type, leaves handwritten, composites assembled from leaves, context threaded per value, ORE held rather than grown into vitaminc. Three things differ from the original sketches and are marked inline where they appear: -- **The derive is not built yet.** Composites are hand-written `EncryptFrom` impls today (`packages/stack-encrypt/examples/encrypted_record.rs`); `#[derive(Encrypted)]` remains the intended end state. +- **The derives are named after the traits they emit**, not `Encrypted`: `#[derive(EncryptFrom)]` / `#[derive(DecryptInto)]` in `stack-encrypt-derive`, under `#[stash(..)]` attributes. Hand-written impls remain the way to write a leaf (`packages/stack-encrypt/examples/encrypted_record.rs` shows one composite written out). - **ORE/OPE use `cllw-ore`, not `ore-rs`,** and the per-field key is derived through the PRF *inside* the term — there is no `ProvidesOre` accessor and no key is ever handed back. - **An empty context is rejected**, not permitted. `Aad::empty()` was proposed for non-EQL callers; the implementation refuses it (`Error::EmptyContext`). @@ -173,7 +173,7 @@ Use the list for EQL types. `eql_v3_integer_ord_ore` is a schema statement that ### Rows are the same mechanism -One level up, unchanged (derive syntax, future): +One level up, unchanged — same trait, now written by the derive: ```rust #[derive(EncryptFrom)] @@ -273,8 +273,8 @@ Splitting **analysis** (tokenise, normalise, extract n-grams — pure, sync, key - **`EncryptFrom`** for the trait (first shipped as `EncryptedFrom`, renamed in review). Spelling the direction keeps bounds unambiguous, and it pairs with `encrypt_into` exactly as `From` pairs with `Into`; `DecryptInto` / `decrypt_from` mirror it. - **`encrypt_into` / `encrypt_into_with_context`** for the two forms of the sugar, after vitaminc's `encrypt` / `encrypt_with_aad`; `_with_context` rather than `_with_aad` because here the value feeds the PRF domain separation as well as the AAD. -- **`#[derive(Encrypted)]`** for the macro. Reads as a noun on the struct. -- Matching both to `Encrypted`, the way `Serialize` matches `derive(Serialize)`, is a defensible alternative. +- **`#[derive(EncryptFrom)]` / `#[derive(DecryptInto)]`** for the macros, each named after the trait it emits, the way `Serialize` matches `derive(Serialize)`. `#[derive(Encrypted)]` — a noun on the struct — was the sketch; it names neither trait, and one noun cannot cover both directions. +- **`#[stash(..)]`** for the attribute, after the crate rather than after either derive, since both derives read the same annotations. - **Avoid `CipherText` / `EncryptedValue`.** `CipherText` collides with vitaminc's `AesCipherText` container and with eql-bindings' `Ciphertext` newtype — which is a *field inside* these types, not the type itself. An earlier iteration had two traits, `EncryptInto` on the source and `DeriveFrom` on the field type. They are the same relation written in opposite directions; the split was the main source of confusion and is gone. From 57a1f1800c92641a1864819ef406b837e14751f3 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Tue, 1 Sep 2026 14:42:18 +1000 Subject: [PATCH 468/686] fix(stack-encrypt): address review on the context-parameter PR MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review findings from cipherstash/cipherstash-suite#2163: - The derived encrypt impl now bounds its caller context by `EncryptContext`, mirroring the decrypt side. Without it, a third-party leaf generic over its context let the raw `EncryptFrom::encrypt_from` accept — and silently discard — any `Clone` value as its context. Pinned by `a_caller_context_must_be_an_encrypt_context`. - The `encrypt_into_with_context` sketch in the design doc now shows the `Ctx: SuppliedContext<'c>` bound the shipped signature has. - The `seal_pending` doc no longer names `encrypt_into::`, a call that no longer compiles. - The `SuppliedContext` doc no longer claims coverage of composites containing `()`, and the crate re-exports `IntoPrfContext` / `PrfContext` so a context newtype needs no direct `vitaminc-prf` dependency. Claude-Session: https://claude.ai/code/session_01P5YHK3w6Kj9ajTnmkaXCHW --- docs/target-directed-encryption.md | 3 +- packages/stack-encrypt-derive/src/encrypt.rs | 43 ++++++++++++++++---- packages/stack-encrypt-derive/src/shape.rs | 19 +++++---- packages/stack-encrypt/src/lib.rs | 5 +++ packages/stack-encrypt/src/target/mod.rs | 7 +++- 5 files changed, 56 insertions(+), 21 deletions(-) diff --git a/docs/target-directed-encryption.md b/docs/target-directed-encryption.md index b94c8bf04..da657d71b 100644 --- a/docs/target-directed-encryption.md +++ b/docs/target-directed-encryption.md @@ -89,10 +89,11 @@ pub trait EncryptInto { T: EncryptFrom + 'a, Self: Sized; - fn encrypt_into_with_context<'a, T, C, Ctx>(&'a self, cipher: &'a C, context: Ctx) -> C::Output<'a, T> + fn encrypt_into_with_context<'a, 'c, T, C, Ctx>(&'a self, cipher: &'a C, context: Ctx) -> C::Output<'a, T> where C: EncryptTarget, T: EncryptFrom + 'a, + Ctx: SuppliedContext<'c>, Self: Sized; } impl EncryptInto for S { /* delegates to T::encrypt_from */ } diff --git a/packages/stack-encrypt-derive/src/encrypt.rs b/packages/stack-encrypt-derive/src/encrypt.rs index 3f99c2ad9..aadf043d0 100644 --- a/packages/stack-encrypt-derive/src/encrypt.rs +++ b/packages/stack-encrypt-derive/src/encrypt.rs @@ -25,7 +25,7 @@ pub(crate) fn derive(input: DeriveInput) -> Result { generics.params.push(parse_quote!(__S)); generics.params.push(parse_quote!(__K)); push_field_bounds(&mut generics, krate, &derived, &source); - let ctx = push_context_generics(&mut generics, CallerContext::Encrypt, &derived); + let ctx = push_context_generics(&mut generics, CallerContext::Encrypt(krate), &derived); let body = body(krate, &record, &derived, &source); let block = impl_block(&input, krate, &generics, &source, &ctx, body); return Ok(quote!(#block #decryptable)); @@ -38,7 +38,7 @@ pub(crate) fn derive(input: DeriveInput) -> Result { let mut generics = input.generics.clone(); generics.params.push(parse_quote!(__K)); push_field_bounds(&mut generics, krate, &derived, source); - let ctx = push_context_generics(&mut generics, CallerContext::Encrypt, &derived); + let ctx = push_context_generics(&mut generics, CallerContext::Encrypt(krate), &derived); let body = body(krate, &record, &derived, source); impl_block(&input, krate, &generics, source, &ctx, body) }); @@ -223,23 +223,48 @@ mod tests { } }); // Both fields take the caller's context: the impl is generic over - // it, and inherits the fields' demand for a supplied one through - // their bounds — `Clone` is all the body itself needs. + // it, bounded `EncryptContext` so a third-party leaf that is generic + // over its context cannot smuggle a non-context value through the + // record ([`CallerContext::Encrypt`]). assert_contains(&expansion, quote! { - impl<__S, __K, __Ctx> ::stack_encrypt::target::EncryptFrom<__S, ::stack_encrypt::StackCipher<__K>, __Ctx> + impl<'__ctx, __S, __K, __Ctx> ::stack_encrypt::target::EncryptFrom<__S, ::stack_encrypt::StackCipher<__K>, __Ctx> for EncryptedAge where StackCipherText: ::stack_encrypt::target::EncryptFrom<__S, ::stack_encrypt::StackCipher<__K>, __Ctx>, EqualityTerm: ::stack_encrypt::target::EncryptFrom<__S, ::stack_encrypt::StackCipher<__K>, __Ctx>, - __Ctx: ::core::clone::Clone + __Ctx: ::stack_encrypt::target::EncryptContext<'__ctx> }); - assert_lacks(&expansion, quote!(EncryptContext)); // The first field clones the record context, the last takes it. assert_contains(&expansion, quote!(__source, __cipher, ::core::clone::Clone::clone(&__context),)); assert_contains(&expansion, quote!(__source, __cipher, __context,)); assert_contains(&expansion, quote!(.map(|(__field_0, __field_1)| Self { c: __field_0, hm: __field_1 }))); } + #[test] + #[rustfmt::skip] + fn a_caller_context_must_be_an_encrypt_context() { + // The regression shape, mirroring the decrypt-side test: the + // ciphertext field carries a literal, so only the term field sees + // the caller's context — and a third-party leaf generic over its + // context demands nothing of it. The impl-level bound is what keeps + // `EncryptFrom::encrypt_from` from accepting, and silently + // discarding, a value that is not a context at all. + let expansion = expand(parse_quote! { + #[stash(plaintext = u32)] + struct Rec { + #[stash(context = "rec/c")] + c: StackCipherText, + hm: EqualityTerm, + } + }); + assert_contains(&expansion, quote! { + impl<'__ctx, __K, __Ctx> ::stack_encrypt::target::EncryptFrom, __Ctx> for Rec + }); + assert_contains(&expansion, quote! { + __Ctx: ::stack_encrypt::target::EncryptContext<'__ctx> + }); + } + #[test] #[rustfmt::skip] fn listed_sources_get_one_impl_each() { @@ -252,10 +277,10 @@ mod tests { } }); assert_contains(&expansion, quote! { - impl<__K, __Ctx> ::stack_encrypt::target::EncryptFrom, __Ctx> for IntegerOrdOre + impl<'__ctx, __K, __Ctx> ::stack_encrypt::target::EncryptFrom, __Ctx> for IntegerOrdOre }); assert_contains(&expansion, quote! { - impl<__K, __Ctx> ::stack_encrypt::target::EncryptFrom, __Ctx> for IntegerOrdOre + impl<'__ctx, __K, __Ctx> ::stack_encrypt::target::EncryptFrom, __Ctx> for IntegerOrdOre }); assert_contains(&expansion, quote!(Self { c: __field_0, v: SchemaVersion::V3 })); assert_lacks(&expansion, quote!(__S)); diff --git a/packages/stack-encrypt-derive/src/shape.rs b/packages/stack-encrypt-derive/src/shape.rs index 198d301b7..3007f12e7 100644 --- a/packages/stack-encrypt-derive/src/shape.rs +++ b/packages/stack-encrypt-derive/src/shape.rs @@ -198,11 +198,12 @@ impl Record { /// The demand a derive places on the impl's context parameter, beyond what /// the field bounds already say. pub(crate) enum CallerContext<'a> { - /// Encrypt: `Clone` is all the generated body itself needs (the context - /// fans out to every field); everything else — supplied, convertible — - /// is inherited through the field bounds, because every encrypt leaf - /// states its own demand. - Encrypt, + /// Encrypt: `EncryptContext` — convertible to AAD and to a PRF context. + /// The leaves in this crate state that demand through the field bounds + /// already, but a third-party leaf generic over its context would not, + /// and without this bound such a leaf lets `EncryptFrom::encrypt_from` + /// accept — and silently discard — any `Clone` value as its context. + Encrypt(&'a Path), /// Decrypt: `DecryptContext` — convertible to the AAD the value was /// encrypted under. A term field's `DecryptField` accepts *any* context /// (it opens nothing), so field bounds alone would let a record whose @@ -232,15 +233,15 @@ pub(crate) fn push_context_generics( generics.params.push(parse_quote!(__Ctx)); let predicates = &mut generics.make_where_clause().predicates; match bound { - CallerContext::Encrypt => { - predicates.push(parse_quote!(__Ctx: ::core::clone::Clone)); + CallerContext::Encrypt(krate) => { + predicates.push(parse_quote!(__Ctx: #krate::target::EncryptContext<'__ctx>)); } CallerContext::Decrypt(krate) => { predicates.push(parse_quote!(__Ctx: #krate::target::DecryptContext<'__ctx>)); - // A lifetime parameter must precede the type parameters. - generics.params.insert(0, parse_quote!('__ctx)); } } + // A lifetime parameter must precede the type parameters. + generics.params.insert(0, parse_quote!('__ctx)); parse_quote!(__Ctx) } diff --git a/packages/stack-encrypt/src/lib.rs b/packages/stack-encrypt/src/lib.rs index 45271c792..4109de13e 100644 --- a/packages/stack-encrypt/src/lib.rs +++ b/packages/stack-encrypt/src/lib.rs @@ -136,3 +136,8 @@ pub use target::{ pub use vitaminc_aead::{ Aad, Cipher, CipherText, ContextTag, Decipher, Decrypt, Element, Encrypt, IntoAad, Unspecified, }; + +// Likewise the PRF context surface: a context newtype (the `SuppliedContext` +// opt-in recipe) needs `IntoPrfContext` alongside `IntoAad`, and should not +// need a direct `vitaminc-prf` dependency for it. +pub use vitaminc_prf::{IntoPrfContext, PrfContext}; diff --git a/packages/stack-encrypt/src/target/mod.rs b/packages/stack-encrypt/src/target/mod.rs index 2e6c0edd1..5299cee9f 100644 --- a/packages/stack-encrypt/src/target/mod.rs +++ b/packages/stack-encrypt/src/target/mod.rs @@ -383,7 +383,9 @@ impl<'a, T> DecryptContext<'a> for T where T: IntoAad<'a> + Clone {} /// context of their own never passes the caller's anywhere: it implements /// the traits for `()` alone, and is encrypted with no context at all. /// -/// Implemented for every context type vitaminc provides except `()`: `&str`, +/// Implemented for every context type vitaminc provides except `()` — and +/// except a composite that contains `()`, such as `("users/email", ())` or +/// `Option<()>`, whose `()` half adds no domain separation: `&str`, /// `String`, byte strings, [`Aad`], `u64`, and `Option`s /// and pairs of those. A context type of your own opts in with an empty /// `impl SuppliedContext<'_> for MyContext {}` alongside its `IntoAad` / @@ -1036,7 +1038,8 @@ where /// keys drawn back in the same traversal order the tree was built in. /// /// Both ways of encrypting go through here — the target-directed -/// `encrypt_into::` above and the cipher-directed +/// `encrypt_into_with_context` into a [`StackCipherText`] above and the +/// cipher-directed /// [`PendingStackCipherText::seal`] behind [`StackCipher::encrypt`] — so /// there is one definition of how a tree is sealed and one path to ZeroKMS. pub(crate) fn seal_pending<'a, K>( From e286459f090515d1c87f4e8e512d79047503b9a7 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Sat, 29 Aug 2026 10:27:52 +1000 Subject: [PATCH 469/686] feat(stack-encrypt-derive): `#[stash(row = User)]` infers each field's `from` and context MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A row no longer needs an attribute on any field. `row = User` says the record is a row of the struct `User`: every derived field is derived from the plaintext field of its own name, under the context `"/

"` — `age` from `user.age` under `"user/age"`, a tuple row's `.0` under `"user/0"`. Both halves name the column, not the encrypted struct, so `#[stash(from = email_address)]` (the override for a name that differs) is derived under `"user/email_address"`; `#[stash(context = "..")]` is taken verbatim. Nothing is pluralised or otherwise guessed. `row` is exclusive with `plaintext`, and must name a struct directly. Because the inferred context is the AAD of every stored ciphertext in the column, renaming the plaintext type or a field is a data migration; the docs say to pin the old literal with `context = ".."` first. A field the plaintext does not have is reported by rustc at the field (`tests/ui/row_field_missing.rs`); `row` + `plaintext` at the attribute (`tests/ui/row_with_plaintext.rs`). Claude-Session: https://claude.ai/code/session_01HU1Bbw4eQp9kEEneXxDcKr --- ...nc-shape-for-target-directed-encryption.md | 12 +- docs/target-directed-encryption.md | 8 +- .../stack-encrypt-derive/docs/attributes.md | 27 ++- packages/stack-encrypt-derive/src/attrs.rs | 49 ++++- packages/stack-encrypt-derive/src/encrypt.rs | 21 +++ packages/stack-encrypt-derive/src/lib.rs | 34 ++-- packages/stack-encrypt-derive/src/shape.rs | 174 +++++++++++++++++- packages/stack-encrypt/src/target/mod.rs | 15 +- packages/stack-encrypt/tests/derive.rs | 49 +++-- .../tests/ui/row_field_missing.rs | 16 ++ .../tests/ui/row_field_missing.stderr | 7 + .../tests/ui/row_with_plaintext.rs | 13 ++ .../tests/ui/row_with_plaintext.stderr | 11 ++ 13 files changed, 384 insertions(+), 52 deletions(-) create mode 100644 packages/stack-encrypt/tests/ui/row_field_missing.rs create mode 100644 packages/stack-encrypt/tests/ui/row_field_missing.stderr create mode 100644 packages/stack-encrypt/tests/ui/row_with_plaintext.rs create mode 100644 packages/stack-encrypt/tests/ui/row_with_plaintext.stderr diff --git a/docs/rfcs/0002-async-shape-for-target-directed-encryption.md b/docs/rfcs/0002-async-shape-for-target-directed-encryption.md index e1b6498b0..49d8eaa81 100644 --- a/docs/rfcs/0002-async-shape-for-target-directed-encryption.md +++ b/docs/rfcs/0002-async-shape-for-target-directed-encryption.md @@ -579,11 +579,13 @@ rebuilds it with a struct literal; a record opened as a whole may leave it off and decrypt to whatever its ciphertext field opens to. The derives are named after the trait they emit, as serde's are, and the -attribute after the crate: `#[stash(plaintext = ..)]`, -`#[stash(from = .., context = "..")]`. Which field decryption opens is not -an attribute but a property of the field types (`Decryptable`), checked at -compile time to be exactly one; `decrypt` is the override for records the -types cannot settle. First shipped as +attribute after the crate: `#[stash(plaintext = ..)]` for a record, +`#[stash(row = ..)]` for a row — which infers every field's `from` (its own +name) and context (`"<snake_case type>/<plaintext field>"`), with +`#[stash(from = .., context = "..")]` as the per-field overrides. Which +field decryption opens is not an attribute either but a property of the +field types (`Decryptable`), checked at compile time to be exactly one; +`decrypt` is the override for records the types cannot settle. First shipped as `#[derive(Encrypted, Decrypted)]` with `#[encrypted(source = ..)]`: the decrypt macro sat on the record but emitted `DecryptFrom<Record> for Plaintext`, a trait whose `Self` was not the annotated type, and the diff --git a/docs/target-directed-encryption.md b/docs/target-directed-encryption.md index da657d71b..574f163e6 100644 --- a/docs/target-directed-encryption.md +++ b/docs/target-directed-encryption.md @@ -178,15 +178,17 @@ One level up, unchanged — same trait, now written by the derive: ```rust #[derive(EncryptFrom)] -#[stash(plaintext = User)] +#[stash(row = User)] struct EncryptedUser { - #[stash(from = age, context = "users/age")] age: IntegerOrdOre, - #[stash(from = email, context = "users/email")] email: TextEq, + age: IntegerOrdOre, // from user.age, under "user/age" + email: TextEq, // from user.email, under "user/email" } let row: EncryptedUser = user.encrypt_into(&cipher).await?; // one batch, no context: the fields carry theirs ``` +`row = User` infers each field's `from` (its own name) and context (`"<snake_case type>/<plaintext field>"`); `#[stash(from = ..)]` and `#[stash(context = "..")]` on a field are the overrides. The inferred context is derived from Rust identifiers and is the AAD of every stored ciphertext in the column, so renaming the type or a field is a data migration: pin the old literal with `context = ".."` first. + Leaf, payload and row are the same trait, and a column of rows is `Vec<T>`'s structural impl over the same trait — `ages.encrypt_into_with_context(&cipher, ctx)` for a `Vec<u32>` is one batched call, and `users.encrypt_into(&cipher)` for a `Vec<User>` likewise. Recursion does the rest. Earlier sketches of this design had a separate input-side derive for rows — that was a second mechanism the naming was hiding. ### Relationship to `Encrypt` diff --git a/packages/stack-encrypt-derive/docs/attributes.md b/packages/stack-encrypt-derive/docs/attributes.md index cbf5083ab..15b389732 100644 --- a/packages/stack-encrypt-derive/docs/attributes.md +++ b/packages/stack-encrypt-derive/docs/attributes.md @@ -7,6 +7,7 @@ All attributes live under `#[stash(...)]`. | Attribute | Effect | |---|---| | `plaintext = Type` | The record is an encrypted form of `Type`. Repeatable: one impl per listed type. Omit it for an impl generic over the plaintext (see below). | +| `row = Type` | The record is a row of the struct `Type`: every derived field is derived from the plaintext field of its own name, under the context `"<type>/<field>"` (see [Rows](#rows)). Exclusive with `plaintext`. | | `crate = "path"` | Where to find `stack_encrypt` in the generated code (default `::stack_encrypt`), for use through a re-export. | `plaintext` must be an owned type: the generated impl has no lifetime to give @@ -23,7 +24,7 @@ must name it: the plaintext is rebuilt with a struct literal. | Attribute | Effect | |---|---| | `context = "..."` | Derive this field under exactly this context rather than the one the caller passes for the record. A query-side term built under the same literal matches it. Must not be empty. | -| `from = field` / `from = 0` | Derive this field from `plaintext.field` (or `plaintext.0` for a tuple struct) rather than from the whole plaintext. Needs `plaintext = ..` on the struct. | +| `from = field` / `from = 0` | Derive this field from `plaintext.field` (or `plaintext.0` for a tuple struct) rather than from the whole plaintext. Needs `plaintext = ..` or `row = ..` on the struct; in a row, only for a field whose name differs from its plaintext field's. | | `default` / `default = expr` | Not derived: filled with `Default::default()` or `expr`. Never encrypted, never authenticated. | | `decrypt` | Decryption opens this field (`DecryptInto` only). Needed only when the field types cannot decide it — see below. | @@ -44,6 +45,30 @@ Every attribute except `plaintext` is singular, and repeating one is a compile error rather than a silent overwrite (`plaintext` is repeatable, but each listed type only once). +## Rows + +A row needs no attribute on its fields. With `row = User`, a field `age` is +derived from `user.age` under the context `"user/age"`; a field `email` from +`user.email` under `"user/email"`; a tuple row's `.0` under `"user/0"`. The +context is the plaintext type's last path segment in `snake_case` +(`UserProfile` → `"user_profile/age"`) and the *plaintext* field's name, so +`#[stash(from = email_address)] email: ..` is derived under +`"user_profile/email_address"`: both halves name the column, not the encrypted +struct. Nothing is pluralised or otherwise guessed. `context = ".."` on a +field is taken verbatim and replaces the inferred one. + +The inferred context is part of the stored data's identity: it is the AAD of +every ciphertext in the column and the domain of every term. Renaming the +plaintext struct or a plaintext field therefore changes it, and rows already +stored stop decrypting (`Error::Aead`) — silently at the call site, with no +compile-time signal. Before renaming either, pin the old value with +`context = ".."` on the fields it reaches; or pin every context from the +start if the type's name is likely to move. + +A row has no field derived from the whole plaintext; every derived field has +a `from`. Use `plaintext = ..` with explicit `from`s for a record that mixes +the two. + ## Which field decryption opens `DecryptInto` does not need to be told: every field type says whether it is a diff --git a/packages/stack-encrypt-derive/src/attrs.rs b/packages/stack-encrypt-derive/src/attrs.rs index 0d110b87c..9f19025de 100644 --- a/packages/stack-encrypt-derive/src/attrs.rs +++ b/packages/stack-encrypt-derive/src/attrs.rs @@ -12,12 +12,18 @@ pub(crate) struct ContainerAttrs { /// each, from repeated `#[stash(plaintext = Type)]`. Empty means /// a single impl generic over the plaintext. pub(crate) plaintexts: Vec<Type>, + /// `#[stash(row = Type)]`: the record is a row of the struct `Type`. + /// Every derived field is derived from the plaintext field of its own + /// name (`from`), under a context made of both names, unless the field + /// says otherwise. Exclusive with `plaintext`. + pub(crate) row: Option<Type>, } impl ContainerAttrs { pub(crate) fn parse(attrs: &[Attribute]) -> Result<Self> { let mut krate: Option<Path> = None; let mut plaintexts: Vec<Type> = Vec::new(); + let mut row: Option<Type> = None; for attr in attrs.iter().filter(|a| a.path().is_ident("stash")) { attr.parse_nested_meta(|meta| { @@ -29,6 +35,32 @@ impl ContainerAttrs { krate = Some(lit.parse()?); return Ok(()); } + if meta.path.is_ident("row") { + let ty: Type = meta.value()?.parse()?; + // A row reaches into the plaintext by field name and + // rebuilds it with a struct literal, so the type must be + // a struct named directly; its last segment also names + // the fields' contexts. + let named_struct = match &ty { + Type::Path(path) => path.qself.is_none(), + _ => false, + }; + if !named_struct { + return Err(syn::Error::new_spanned( + &ty, + "`row` must name a struct directly (`row = User`): its fields are \ + reached by name and its name is part of every field's context", + )); + } + if row.is_some() { + return Err(syn::Error::new_spanned( + &ty, + "`row` is given twice; a row has one plaintext struct", + )); + } + row = Some(ty); + return Ok(()); + } if meta.path.is_ident("plaintext") { let plaintext: Type = meta.value()?.parse()?; // The type is spliced into the impl header as written, @@ -54,14 +86,26 @@ impl ContainerAttrs { return Ok(()); } Err(meta.error( - "unsupported container attribute; expected `plaintext = Type` or `crate = \"...\"`", + "unsupported container attribute; expected `plaintext = Type`, `row = Type` \ + or `crate = \"...\"`", )) })?; } + if let (Some(row), Some(plaintext)) = (&row, plaintexts.first()) { + let mut err = syn::Error::new_spanned( + row, + "`row` and `plaintext` are two ways of naming the plaintext: a row *is* a record \ + of its struct's fields, so give `row = ..` alone", + ); + err.combine(syn::Error::new_spanned(plaintext, "`plaintext` given here")); + return Err(err); + } + Ok(Self { krate: krate.unwrap_or_else(|| syn::parse_quote!(::stack_encrypt)), plaintexts, + row, }) } } @@ -74,7 +118,8 @@ pub(crate) struct FieldAttrs { pub(crate) context: Option<LitStr>, /// `#[stash(from = field)]` / `#[stash(from = 0)]`: derive /// this field from one field of the plaintext rather than from the whole - /// plaintext. + /// plaintext. In a row, the override for a field whose name differs + /// from its plaintext field's. pub(crate) from: Option<Member>, /// `#[stash(default)]` / `#[stash(default = expr)]`: not derived; /// filled with `Default::default()` or the expression. diff --git a/packages/stack-encrypt-derive/src/encrypt.rs b/packages/stack-encrypt-derive/src/encrypt.rs index aadf043d0..09b583d78 100644 --- a/packages/stack-encrypt-derive/src/encrypt.rs +++ b/packages/stack-encrypt-derive/src/encrypt.rs @@ -356,6 +356,27 @@ mod tests { assert_lacks(&expansion, quote!(EncryptContext)); } + #[test] + #[rustfmt::skip] + fn a_row_needs_no_attributes_on_its_fields() { + let expansion = expand(parse_quote! { + #[stash(row = User)] + struct EncryptedUser { + age: EncryptedAge, + email: StackCipherText, + } + }); + assert_contains(&expansion, quote! { + <EncryptedAge as ::stack_encrypt::target::EncryptFrom<_, ::stack_encrypt::StackCipher<__K>, _>>::encrypt_from( + &__source.age, __cipher, "user/age", + ) + }); + assert_contains(&expansion, quote!(&__source.email, __cipher, "user/email",)); + assert_contains(&expansion, quote! { + impl<__K> ::stack_encrypt::target::EncryptFrom<User, ::stack_encrypt::StackCipher<__K>, ()> for EncryptedUser + }); + } + #[test] fn tuple_plaintexts_are_reached_by_index() { let expansion = expand(parse_quote! { diff --git a/packages/stack-encrypt-derive/src/lib.rs b/packages/stack-encrypt-derive/src/lib.rs index b16526c5f..e5041934a 100644 --- a/packages/stack-encrypt-derive/src/lib.rs +++ b/packages/stack-encrypt-derive/src/lib.rs @@ -65,7 +65,12 @@ //! # Rows //! //! One level up, the same derive: a struct whose fields are each derived from -//! a *field* of the plaintext, under a context of their own. +//! a *field* of the plaintext, under a context of their own. `row = User` +//! says so once, for every field: `age` is derived from `user.age` under +//! `"user/age"`, `email` from `user.email` under `"user/email"` — the +//! struct's name and the field's, nothing invented. Attributes on the fields +//! are for the exceptions: `from = ..` when the names differ, `context = +//! ".."` to pin a context by hand. //! //! ``` //! # use stack_encrypt::sem::{EqualityTerm, OreTerm}; @@ -86,11 +91,9 @@ //! } //! //! #[derive(EncryptFrom, DecryptInto)] -//! #[stash(plaintext = User)] +//! #[stash(row = User)] //! struct EncryptedUser { -//! #[stash(from = age, context = "users/age")] //! age: EncryptedAge, -//! #[stash(from = email, context = "users/email")] //! email: StackCipherText, //! } //! @@ -107,14 +110,21 @@ //! # }).unwrap(); //! ``` //! -//! A `from` field's context is the *column's* identity, which is why it is a -//! literal on the field rather than something composed from the row's -//! context. A row takes no context from the caller at all — its impls are -//! for `()` exactly, which is what makes the context-free `encrypt_into` / -//! `decrypt_from` the forms that compile against it. A `from` field without -//! a literal is handed `()` too, and its type decides whether that will do: -//! a nested row accepts it; a leaf refuses it, at the field, until it is -//! given a `context`. +//! A row field's context is the *column's* identity — `"user/age"` is what a +//! query site derives a probe under — which is why it is a literal per field +//! rather than something composed from a context the caller passes. A row +//! takes no context from the caller at all: its impls are for `()` exactly, +//! which is what makes the context-free `encrypt_into` / `decrypt_from` the +//! forms that compile against it. A `plaintext = ..` record's `from` field +//! with no `context` is handed `()` too, and its type decides whether that +//! will do: a nested row accepts it; a leaf refuses it, at the field, until +//! it is given a `context`. +//! +//! Because the inferred context is derived from Rust names, renaming the +//! plaintext struct or one of its fields changes the AAD every stored +//! ciphertext in that column was sealed under, and they stop decrypting. +//! Pin the old value with `context = ".."` before such a rename; the +//! [attributes](#rows) section says more. //! //! # What the derive commits to //! diff --git a/packages/stack-encrypt-derive/src/shape.rs b/packages/stack-encrypt-derive/src/shape.rs index 3007f12e7..e6c4b84de 100644 --- a/packages/stack-encrypt-derive/src/shape.rs +++ b/packages/stack-encrypt-derive/src/shape.rs @@ -5,7 +5,7 @@ use quote::quote; use syn::spanned::Spanned; use syn::{ parse_quote, Data, DeriveInput, Expr, Fields, Generics, Ident, LitStr, Member, Path, Result, - Type, + Type, TypePath, }; use crate::attrs::{ContainerAttrs, FieldAttrs}; @@ -168,7 +168,12 @@ impl Record { } }; - let fields = collect(&data.fields)?; + let row = attrs.row.as_ref().map(row_name).transpose()?; + let fields = collect(&data.fields, row.as_deref())?; + let plaintexts = match attrs.row { + Some(row) => vec![row], + None => attrs.plaintexts, + }; if !fields.iter().any(Field::is_derived) { return Err(syn::Error::new_spanned( @@ -177,7 +182,7 @@ impl Record { )); } - if attrs.plaintexts.is_empty() { + if plaintexts.is_empty() { if let Some(field) = fields.iter().find(|f| f.from().is_some()) { return Err(syn::Error::new( field.from().map_or_else(Span::call_site, Spanned::span), @@ -189,12 +194,45 @@ impl Record { Ok(Self { krate: attrs.krate, - plaintexts: attrs.plaintexts, + plaintexts, fields, }) } } +/// The first half of a row field's inferred context: the row struct's own +/// name, in `snake_case` (`UserProfile` becomes `user_profile`). Generic +/// arguments and the path to the type are not part of it. +fn row_name(ty: &Type) -> Result<String> { + let Type::Path(TypePath { path, .. }) = ty else { + unreachable!("`ContainerAttrs::parse` accepts only a path type for `row`") + }; + let segment = path + .segments + .last() + .ok_or_else(|| syn::Error::new_spanned(ty, "`row` must name a struct"))?; + Ok(snake_case(&segment.ident.to_string())) +} + +/// `UserProfile` → `user_profile`, `HTTPHeader` → `http_header`, `user` → +/// `user`. Boundaries are a lower-to-upper step and the last capital of a +/// run followed by a lowercase letter. +fn snake_case(name: &str) -> String { + let chars: Vec<char> = name.chars().collect(); + let mut out = String::with_capacity(name.len() + 4); + for (i, &c) in chars.iter().enumerate() { + if c.is_uppercase() && i > 0 { + let prev = chars[i - 1]; + let next_lower = chars.get(i + 1).is_some_and(|n| n.is_lowercase()); + if prev.is_lowercase() || prev.is_ascii_digit() || (prev.is_uppercase() && next_lower) { + out.push('_'); + } + } + out.extend(c.to_lowercase()); + } + out +} + /// The demand a derive places on the impl's context parameter, beyond what /// the field bounds already say. pub(crate) enum CallerContext<'a> { @@ -308,7 +346,10 @@ pub(crate) fn zip_fields( quote!(#chain.map(|#pattern| #build)) } -fn collect(fields: &Fields) -> Result<Vec<Field>> { +/// The fields, with what a row (`row` is its snake-cased name) fills in: +/// `from` is the field's own name and `context` is `"<row>/<from>"`, each +/// unless the field gives its own. +fn collect(fields: &Fields, row: Option<&str>) -> Result<Vec<Field>> { fields .iter() .enumerate() @@ -329,9 +370,25 @@ fn collect(fields: &Fields) -> Result<Vec<Field>> { } Kind::Default(default) } - None => Kind::Derived { - context: attrs.context, - from: attrs.from, + None => match row { + Some(row) => { + let from = attrs.from.unwrap_or_else(|| member.clone()); + let context = attrs.context.unwrap_or_else(|| { + let column = match &from { + Member::Named(ident) => ident.to_string(), + Member::Unnamed(index) => index.index.to_string(), + }; + LitStr::new(&format!("{row}/{column}"), member.span()) + }); + Kind::Derived { + context: Some(context), + from: Some(from), + } + } + None => Kind::Derived { + context: attrs.context, + from: attrs.from, + }, }, }; Ok(Field { @@ -354,6 +411,14 @@ mod tests { Record::parse(&input) } + /// The field's literal context, for assertions. + fn literal(field: &Field) -> String { + match field.field_context() { + FieldContext::Literal(lit) => lit.value(), + other => panic!("expected a literal context, got {other:?}"), + } + } + #[test] fn enums_are_rejected() { let err = parse(parse_quote! { @@ -570,6 +635,99 @@ mod tests { assert!(matches!(record.fields[1].from(), Some(Member::Unnamed(i)) if i.index == 1)); } + #[test] + fn a_row_fills_in_from_and_context() { + let record = parse(parse_quote! { + #[stash(row = crate::model::UserProfile<T>)] + struct EncryptedUser { + age: EncryptedAge, + #[stash(from = email_address)] + email: StackCipherText, + #[stash(context = "legacy/name")] + name: StackCipherText, + #[stash(default)] + version: u8, + } + }) + .unwrap(); + assert_eq!(record.plaintexts.len(), 1); + let (age, email, name, version) = ( + &record.fields[0], + &record.fields[1], + &record.fields[2], + &record.fields[3], + ); + // Own name, and both names in the context — the type's snake-cased. + assert!(matches!(age.from(), Some(Member::Named(m)) if m == "age")); + assert_eq!(literal(age), "user_profile/age"); + // `from` overrides the field; the context follows the plaintext field. + assert!(matches!(email.from(), Some(Member::Named(m)) if m == "email_address")); + assert_eq!(literal(email), "user_profile/email_address"); + // `context` is taken verbatim. + assert!(matches!(name.from(), Some(Member::Named(m)) if m == "name")); + assert_eq!(literal(name), "legacy/name"); + assert!(!version.is_derived()); + } + + #[test] + fn a_tuple_row_is_reached_and_named_by_index() { + let record = parse(parse_quote! { + #[stash(row = Reading)] + struct EncryptedReading(EncryptedAge, StackCipherText); + }) + .unwrap(); + assert!(matches!(record.fields[1].from(), Some(Member::Unnamed(i)) if i.index == 1)); + assert_eq!(literal(&record.fields[0]), "reading/0"); + assert_eq!(literal(&record.fields[1]), "reading/1"); + } + + #[test] + fn row_and_plaintext_are_exclusive() { + let err = parse(parse_quote! { + #[stash(row = User, plaintext = User)] + struct Rec { + age: EncryptedAge, + } + }) + .unwrap_err(); + assert!(err.to_string().contains("give `row = ..` alone")); + } + + #[test] + fn a_row_must_name_a_struct_directly() { + let err = parse(parse_quote! { + #[stash(row = &User)] + struct Rec { + age: EncryptedAge, + } + }) + .unwrap_err(); + assert!(err.to_string().contains("must name a struct directly")); + + let err = parse(parse_quote! { + #[stash(row = <T as Trait>::Row)] + struct Rec { + age: EncryptedAge, + } + }) + .unwrap_err(); + assert!(err.to_string().contains("must name a struct directly")); + } + + #[test] + fn type_names_snake_case_by_word_boundary() { + for (name, expected) in [ + ("User", "user"), + ("UserProfile", "user_profile"), + ("HTTPHeader", "http_header"), + ("Address2Line", "address2_line"), + ("user", "user"), + ("ABC", "abc"), + ] { + assert_eq!(snake_case(name), expected, "{name}"); + } + } + #[test] fn fields_classify() { let record = parse(parse_quote! { diff --git a/packages/stack-encrypt/src/target/mod.rs b/packages/stack-encrypt/src/target/mod.rs index 5299cee9f..2ca16ef07 100644 --- a/packages/stack-encrypt/src/target/mod.rs +++ b/packages/stack-encrypt/src/target/mod.rs @@ -214,7 +214,10 @@ //! //! A struct of leaves is a *record*; a struct of records, each derived from //! one field of the source under its own column context, is a *row*. Both -//! are the same derive, and both settle as one batched call: +//! are the same derive, and both settle as one batched call. A row's +//! contexts are inferred from the names — `#[stash(row = User)]` derives +//! `age` from `user.age` under `"user/age"` — and overridden per field where +//! that is not wanted: //! //! ``` //! use stack_encrypt::sem::{EqualityTerm, OreTerm}; @@ -237,12 +240,12 @@ //! email: String, //! } //! +//! /// A row of `User`: each field from the plaintext field of its own name, +//! /// under the context `"user/<field>"` — no attribute needed. //! #[derive(EncryptFrom, DecryptInto)] -//! #[stash(plaintext = User)] +//! #[stash(row = User)] //! struct EncryptedUser { -//! #[stash(from = age, context = "users/age")] //! age: EncryptedAge, -//! #[stash(from = email, context = "users/email")] //! email: StackCipherText, //! } //! @@ -257,9 +260,9 @@ //! // Every field names its own context, so the row needs none from the //! // caller — and the context-free forms are the only ones that apply. //! let row: EncryptedUser = user.encrypt_into(&cipher).await?; -//! // A query site derives the same term under the same literal. +//! // A query site derives the same term under the column's context. //! let probe: EqualityTerm = 42u32 -//! .encrypt_into_with_context(&cipher, "users/age") +//! .encrypt_into_with_context(&cipher, "user/age") //! .await?; //! assert_eq!(row.age.hm, probe); //! diff --git a/packages/stack-encrypt/tests/derive.rs b/packages/stack-encrypt/tests/derive.rs index b8677bb57..7294f2c14 100644 --- a/packages/stack-encrypt/tests/derive.rs +++ b/packages/stack-encrypt/tests/derive.rs @@ -343,17 +343,19 @@ struct User { email: String, } +/// A row: every field is derived from the plaintext field of its own name, +/// under the context `"<row>/<field>"` — `"user/age"`, `"user/email"` — with +/// no attribute on the field. `from` is the override for a name that +/// differs; the context then follows the plaintext field. #[derive(EncryptFrom, DecryptInto)] -#[stash(plaintext = User)] +#[stash(row = User)] struct EncryptedUser { /// A record inside a row: recursion, not a second mechanism. - #[stash(from = age, context = "users/age")] age: EncryptedAge, - #[stash(from = email, context = "users/email")] email: StackCipherText, /// A second field from the same plaintext field — a term alongside the /// ciphertext, not opened on decrypt. - #[stash(from = email, context = "users/email")] + #[stash(from = email)] email_eq: EqualityTerm, /// Not derived: filled in, never encrypted. #[stash(default = 3)] @@ -383,15 +385,15 @@ async fn a_row_is_one_batched_call_and_rebuilds_its_plaintext() { assert_eq!(row.version, 3); // Each field's terms are what a query site derives under the column's - // literal context. + // inferred context: the row's name and the plaintext field's. let age_hm: EqualityTerm = 42u32 - .encrypt_into_with_context(&generator, "users/age") + .encrypt_into_with_context(&generator, "user/age") .await .unwrap(); assert_eq!(row.age.hm, age_hm); let email_hm: EqualityTerm = user() .email - .encrypt_into_with_context(&generator, "users/email") + .encrypt_into_with_context(&generator, "user/email") .await .unwrap(); assert_eq!(row.email_eq, email_hm); @@ -433,7 +435,7 @@ async fn a_row_field_opened_under_the_wrong_context_fails() { let row: EncryptedUser = user().encrypt_into(&cipher).await.unwrap(); // The literal contexts are baked into the impl, so a transplanted field // is caught by the AAD exactly as for a leaf. - let transplanted: Result<u32, _> = row.age.c.decrypt_into(&cipher, "users/height").await; + let transplanted: Result<u32, _> = row.age.c.decrypt_into(&cipher, "user/height").await; assert!(matches!(transplanted, Err(Error::Aead))); } @@ -470,7 +472,7 @@ async fn a_row_nests_in_a_row_without_a_context() { // The inner row's fields are still under their own literals. let age_hm: EqualityTerm = 42u32 - .encrypt_into_with_context(&generator, "users/age") + .encrypt_into_with_context(&generator, "user/age") .await .unwrap(); assert_eq!(row.user.age.hm, age_hm); @@ -480,14 +482,22 @@ async fn a_row_nests_in_a_row_without_a_context() { assert_eq!(retrieves.load(AtomicOrdering::SeqCst), 1); } -/// A tuple-struct plaintext is reached by index: `from = 0`. +/// A tuple-struct plaintext is reached by index — inferred for a tuple row, +/// `from = 0` when the row has named fields — and named by it in the +/// context: `"reading/0"`. #[derive(Debug, Clone, PartialEq, Eq)] struct Reading(u32, String); #[derive(EncryptFrom, DecryptInto)] -#[stash(plaintext = Reading)] -struct EncryptedReading { - #[stash(from = 0, context = "readings/value")] +#[stash(row = Reading)] +struct EncryptedReading(EncryptedAge, StackCipherText); + +/// The same row with named fields: `from` by index, and the context follows +/// the index too unless given. +#[derive(EncryptFrom, DecryptInto)] +#[stash(row = Reading)] +struct NamedReading { + #[stash(from = 0)] value: EncryptedAge, #[stash(from = 1, context = "readings/unit")] unit: StackCipherText, @@ -502,11 +512,20 @@ async fn a_tuple_plaintext_row_is_reached_and_rebuilt_by_index() { let row: EncryptedReading = reading.encrypt_into(&cipher).await.unwrap(); let hm: EqualityTerm = 21u32 - .encrypt_into_with_context(&generator, "readings/value") + .encrypt_into_with_context(&generator, "reading/0") .await .unwrap(); - assert_eq!(row.value.hm, hm); + assert_eq!(row.0.hm, hm); let recovered = Reading::decrypt_from(row, &cipher).await.unwrap(); assert_eq!(recovered, reading); + + let named: NamedReading = reading.encrypt_into(&cipher).await.unwrap(); + assert_eq!(named.value.hm, hm, "from = 0 infers the same context"); + let unit: String = named + .unit + .decrypt_into(&cipher, "readings/unit") + .await + .unwrap(); + assert_eq!(unit, "celsius"); } diff --git a/packages/stack-encrypt/tests/ui/row_field_missing.rs b/packages/stack-encrypt/tests/ui/row_field_missing.rs new file mode 100644 index 000000000..92f9ca7bc --- /dev/null +++ b/packages/stack-encrypt/tests/ui/row_field_missing.rs @@ -0,0 +1,16 @@ +//! A row field is derived from the plaintext field of its own name; a name +//! the plaintext does not have is reported by rustc at the field. +use stack_encrypt::{EncryptFrom, StackCipherText}; + +struct User { + email: String, +} + +#[derive(EncryptFrom)] +#[stash(row = User)] +struct EncryptedUser { + email: StackCipherText, + nickname: StackCipherText, +} + +fn main() {} diff --git a/packages/stack-encrypt/tests/ui/row_field_missing.stderr b/packages/stack-encrypt/tests/ui/row_field_missing.stderr new file mode 100644 index 000000000..808cd8394 --- /dev/null +++ b/packages/stack-encrypt/tests/ui/row_field_missing.stderr @@ -0,0 +1,7 @@ +error[E0609]: no field `nickname` on type `&'__a User` + --> tests/ui/row_field_missing.rs:13:5 + | +13 | nickname: StackCipherText, + | ^^^^^^^^ unknown field + | + = note: available field is: `email` diff --git a/packages/stack-encrypt/tests/ui/row_with_plaintext.rs b/packages/stack-encrypt/tests/ui/row_with_plaintext.rs new file mode 100644 index 000000000..fca969a83 --- /dev/null +++ b/packages/stack-encrypt/tests/ui/row_with_plaintext.rs @@ -0,0 +1,13 @@ +use stack_encrypt::{EncryptFrom, StackCipherText}; + +struct User { + email: String, +} + +#[derive(EncryptFrom)] +#[stash(row = User, plaintext = User)] +struct EncryptedUser { + email: StackCipherText, +} + +fn main() {} diff --git a/packages/stack-encrypt/tests/ui/row_with_plaintext.stderr b/packages/stack-encrypt/tests/ui/row_with_plaintext.stderr new file mode 100644 index 000000000..b80825816 --- /dev/null +++ b/packages/stack-encrypt/tests/ui/row_with_plaintext.stderr @@ -0,0 +1,11 @@ +error: `row` and `plaintext` are two ways of naming the plaintext: a row *is* a record of its struct's fields, so give `row = ..` alone + --> tests/ui/row_with_plaintext.rs:8:15 + | +8 | #[stash(row = User, plaintext = User)] + | ^^^^ + +error: `plaintext` given here + --> tests/ui/row_with_plaintext.rs:8:33 + | +8 | #[stash(row = User, plaintext = User)] + | ^^^^ From 473bd7a6729398fa6d5c88cbb022395a3064e14c Mon Sep 17 00:00:00 2001 From: Dan Draper <dan@cipherstash.com> Date: Mon, 31 Aug 2026 20:29:24 +1000 Subject: [PATCH 470/686] feat(stack-encrypt-derive): a row's context prefix is explicit, and `nested` hands a field `()` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `row = User` now requires `context = "users"` beside it; each field is derived under `"<context>/<field>"`. The prefix is part of the stored data's identity — the AAD of every ciphertext in the row and the domain of every term — so it is never inferred from the Rust type's name: two plaintext types with the same name in different modules can no longer silently share every column context (byte-identical index terms across their tables, ciphertexts transplantable between them), and renaming a struct can no longer silently change the AAD of every stored row. `#[stash(nested)]` opts a row field out of the inferred context: it is handed `()`, which a nested row accepts and a leaf refuses — the ()-handoff the docs promised now exists in row mode, not only under `plaintext = ..`. Also addressed from the same review: - `supplied_aad` / `supplied_prf_context` are the one public choke point for validating-and-encoding a supplied context; the `is_degenerate_*` predicates return to crate-private, and the third-party-leaf recipe goes through the choke point, whose signature survives the vitaminc#291 migration. - `DecryptField` carries a `#[diagnostic::on_unimplemented]` pointing a term-only bundle inside an auto-mode record at `#[stash(decrypt)]`. - The deferred exactly-one-decryptable check for generic records is pinned by a `compile_fail` doctest on `Decryptable` (trybuild runs `cargo check`, which never evaluates post-monomorphization consts, so a ui test cannot reach it). - The empty-container fail-fast loss and the caller context a literal-carrying record discards on decrypt are documented where they bite (the container impls, `DecryptContext`, the attributes guide). - The `SuppliedContext` roster notes its coupling to vitaminc's `IntoAad` implementor list and the orphan-rule consequence. - Both derives build field bounds through one shared `push_field_bounds`, and the generic-source vs listed-plaintexts emission fork collapses to one loop per derive. Claude-Session: https://claude.ai/code/session_01P5YHK3w6Kj9ajTnmkaXCHW --- .../stack-encrypt-derive/docs/attributes.md | 50 ++-- packages/stack-encrypt-derive/src/attrs.rs | 89 +++++- packages/stack-encrypt-derive/src/decrypt.rs | 100 +++---- packages/stack-encrypt-derive/src/encrypt.rs | 61 ++--- packages/stack-encrypt-derive/src/lib.rs | 36 ++- packages/stack-encrypt-derive/src/shape.rs | 258 +++++++++++++----- packages/stack-encrypt/src/target/mod.rs | 183 ++++++++++--- packages/stack-encrypt/tests/derive.rs | 58 +++- packages/stack-encrypt/tests/target.rs | 19 +- .../tests/ui/default_with_context.stderr | 2 +- .../tests/ui/nested_outside_row.rs | 17 ++ .../tests/ui/nested_outside_row.stderr | 5 + .../tests/ui/row_field_missing.rs | 2 +- .../tests/ui/row_without_context.rs | 18 ++ .../tests/ui/row_without_context.stderr | 5 + 15 files changed, 628 insertions(+), 275 deletions(-) create mode 100644 packages/stack-encrypt/tests/ui/nested_outside_row.rs create mode 100644 packages/stack-encrypt/tests/ui/nested_outside_row.stderr create mode 100644 packages/stack-encrypt/tests/ui/row_without_context.rs create mode 100644 packages/stack-encrypt/tests/ui/row_without_context.stderr diff --git a/packages/stack-encrypt-derive/docs/attributes.md b/packages/stack-encrypt-derive/docs/attributes.md index 15b389732..20da1259b 100644 --- a/packages/stack-encrypt-derive/docs/attributes.md +++ b/packages/stack-encrypt-derive/docs/attributes.md @@ -7,7 +7,8 @@ All attributes live under `#[stash(...)]`. | Attribute | Effect | |---|---| | `plaintext = Type` | The record is an encrypted form of `Type`. Repeatable: one impl per listed type. Omit it for an impl generic over the plaintext (see below). | -| `row = Type` | The record is a row of the struct `Type`: every derived field is derived from the plaintext field of its own name, under the context `"<type>/<field>"` (see [Rows](#rows)). Exclusive with `plaintext`. | +| `row = Type` | The record is a row of the struct `Type`: every derived field is derived from the plaintext field of its own name, under the context `"<context>/<field>"` (see [Rows](#rows)). Exclusive with `plaintext`; requires `context`. | +| `context = "..."` | With `row` only: the first half of every field's inferred context — the table's name. Required, never inferred from the type's name, and must not be empty. | | `crate = "path"` | Where to find `stack_encrypt` in the generated code (default `::stack_encrypt`), for use through a re-export. | `plaintext` must be an owned type: the generated impl has no lifetime to give @@ -27,6 +28,7 @@ must name it: the plaintext is rebuilt with a struct literal. | `from = field` / `from = 0` | Derive this field from `plaintext.field` (or `plaintext.0` for a tuple struct) rather than from the whole plaintext. Needs `plaintext = ..` or `row = ..` on the struct; in a row, only for a field whose name differs from its plaintext field's. | | `default` / `default = expr` | Not derived: filled with `Default::default()` or `expr`. Never encrypted, never authenticated. | | `decrypt` | Decryption opens this field (`DecryptInto` only). Needed only when the field types cannot decide it — see below. | +| `nested` | In a row only: infer no context for this field — it is handed `()`, which its type (a nested row carrying its own contexts) accepts and a leaf refuses. Excludes `context`. | The caller's context reaches every field derived from the whole plaintext that has no `context` of its own, and the impl's context parameter is bounded @@ -47,23 +49,30 @@ but each listed type only once). ## Rows -A row needs no attribute on its fields. With `row = User`, a field `age` is -derived from `user.age` under the context `"user/age"`; a field `email` from -`user.email` under `"user/email"`; a tuple row's `.0` under `"user/0"`. The -context is the plaintext type's last path segment in `snake_case` -(`UserProfile` → `"user_profile/age"`) and the *plaintext* field's name, so +A row needs no attribute on its fields. With +`#[stash(row = User, context = "users")]`, a field `age` is derived from +`user.age` under the context `"users/age"`; a field `email` from `user.email` +under `"users/email"`; a tuple row's `.0` under `"users/0"`. The first half +is the container's `context` and the second the *plaintext* field's name, so `#[stash(from = email_address)] email: ..` is derived under -`"user_profile/email_address"`: both halves name the column, not the encrypted +`"users/email_address"`: both halves name the column, not the encrypted struct. Nothing is pluralised or otherwise guessed. `context = ".."` on a -field is taken verbatim and replaces the inferred one. - -The inferred context is part of the stored data's identity: it is the AAD of -every ciphertext in the column and the domain of every term. Renaming the -plaintext struct or a plaintext field therefore changes it, and rows already -stored stop decrypting (`Error::Aead`) — silently at the call site, with no -compile-time signal. Before renaming either, pin the old value with -`context = ".."` on the fields it reaches; or pin every context from the -start if the type's name is likely to move. +field is taken verbatim and replaces the inferred one; `nested` on a field +infers none — the field is handed `()`, which is what a nested row (its own +`row = ..` derive, carrying its own contexts) accepts and a leaf refuses. + +The context is part of the stored data's identity: it is the AAD of every +ciphertext in the column and the domain of every term. That is why the prefix +is required and explicit rather than inferred from the Rust type's name: two +plaintext types with the same name in different modules would otherwise +silently share every column context — equal plaintexts would produce +identical index terms across their tables, and ciphertexts would be +transplantable between them — and a rename would silently change the AAD of +every stored row. The field half *is* inferred from the plaintext field's +name, so renaming a plaintext field still changes that column's context and +stored rows stop decrypting (`Error::Aead`) — silently at the call site, with +no compile-time signal. Before such a rename, pin the old value with +`context = ".."` on the fields it reaches. A row has no field derived from the whole plaintext; every derived field has a `from`. Use `plaintext = ..` with explicit `from`s for a record that mixes @@ -92,3 +101,12 @@ decryption opens the record — so a marked record still nests in rows. `DecryptInto` consumes the record, moving each opened field out of `self`, so the record must not implement `Drop` (including via `ZeroizeOnDrop`); wrap the fields that need zeroizing instead. + +One asymmetry to know about: when the ciphertext field carries a `context` +literal, the record's `decrypt_into` still takes a caller context — the term +fields nominally receive it — but no field actually uses it: terms open +nothing, and the ciphertext authenticates under its literal. Decryption then +succeeds under *any* well-typed context, so a wrong caller context is not the +`Error::Aead` it would be against a leaf. Do not use the decrypt context as a +tenancy or sanity check on such a record; the authenticated context is the +field's literal. diff --git a/packages/stack-encrypt-derive/src/attrs.rs b/packages/stack-encrypt-derive/src/attrs.rs index 9f19025de..9649f50db 100644 --- a/packages/stack-encrypt-derive/src/attrs.rs +++ b/packages/stack-encrypt-derive/src/attrs.rs @@ -14,9 +14,16 @@ pub(crate) struct ContainerAttrs { pub(crate) plaintexts: Vec<Type>, /// `#[stash(row = Type)]`: the record is a row of the struct `Type`. /// Every derived field is derived from the plaintext field of its own - /// name (`from`), under a context made of both names, unless the field - /// says otherwise. Exclusive with `plaintext`. + /// name (`from`), under a context made of the container's `context` and + /// the plaintext field's name, unless the field says otherwise. + /// Exclusive with `plaintext`; requires `context`. pub(crate) row: Option<Type>, + /// `#[stash(context = "...")]` on the container: the first half of every + /// row field's context — `"<context>/<field>"`. Names the table, not the + /// Rust type: it is part of the stored data's identity, so it is given + /// explicitly rather than inferred from a name a refactor can change. + /// Only meaningful with `row`. + pub(crate) context: Option<LitStr>, } impl ContainerAttrs { @@ -24,6 +31,7 @@ impl ContainerAttrs { let mut krate: Option<Path> = None; let mut plaintexts: Vec<Type> = Vec::new(); let mut row: Option<Type> = None; + let mut context: Option<LitStr> = None; for attr in attrs.iter().filter(|a| a.path().is_ident("stash")) { attr.parse_nested_meta(|meta| { @@ -35,12 +43,18 @@ impl ContainerAttrs { krate = Some(lit.parse()?); return Ok(()); } + if meta.path.is_ident("context") { + if context.is_some() { + return Err(meta.error("`context` is given twice; a row has one prefix")); + } + context = Some(meta.value()?.parse()?); + return Ok(()); + } if meta.path.is_ident("row") { let ty: Type = meta.value()?.parse()?; // A row reaches into the plaintext by field name and // rebuilds it with a struct literal, so the type must be - // a struct named directly; its last segment also names - // the fields' contexts. + // a struct named directly. let named_struct = match &ty { Type::Path(path) => path.qself.is_none(), _ => false, @@ -49,7 +63,7 @@ impl ContainerAttrs { return Err(syn::Error::new_spanned( &ty, "`row` must name a struct directly (`row = User`): its fields are \ - reached by name and its name is part of every field's context", + reached by name and the plaintext is rebuilt with a struct literal", )); } if row.is_some() { @@ -86,8 +100,8 @@ impl ContainerAttrs { return Ok(()); } Err(meta.error( - "unsupported container attribute; expected `plaintext = Type`, `row = Type` \ - or `crate = \"...\"`", + "unsupported container attribute; expected `plaintext = Type`, `row = Type`, \ + `context = \"...\"` (with `row`) or `crate = \"...\"`", )) })?; } @@ -102,10 +116,48 @@ impl ContainerAttrs { return Err(err); } + // The prefix is part of the stored data's identity — the AAD of every + // ciphertext in the row and the domain of every term — so it is never + // inferred from the Rust type's name: two types named `Account` in + // different modules would silently share every column context, making + // ciphertexts transplantable between their tables and index terms + // comparable across them. + match (&row, &context) { + (Some(row), None) => { + return Err(syn::Error::new_spanned( + row, + "`row = ..` needs a `context = \"..\"` beside it naming the table (e.g. \ + `#[stash(row = User, context = \"users\")]`): each field is derived under \ + `\"<context>/<field>\"`, and the prefix is part of the stored data's \ + identity, so it is given explicitly rather than inferred from the Rust \ + type's name", + )); + } + (None, Some(context)) => { + return Err(syn::Error::new( + context.span(), + "a container `context` is the prefix of a row's per-field contexts and \ + applies only with `row = ..`; a `plaintext` record's fields take the \ + caller's context, or a `context = \"..\"` of their own", + )); + } + _ => {} + } + if let Some(context) = &context { + if context.value().is_empty() { + return Err(syn::Error::new( + context.span(), + "an empty `context` is rejected when a value is encrypted: name the table \ + (e.g. \"users\")", + )); + } + } + Ok(Self { krate: krate.unwrap_or_else(|| syn::parse_quote!(::stack_encrypt)), plaintexts, row, + context, }) } } @@ -126,6 +178,10 @@ pub(crate) struct FieldAttrs { pub(crate) default: Option<Option<Expr>>, /// `#[stash(decrypt)]`: decryption opens this field. pub(crate) decrypt: bool, + /// `#[stash(nested)]`: in a row, do not infer a context for this field — + /// hand it `()`, because its type (a nested row) carries its own + /// contexts. + pub(crate) nested: bool, } impl FieldAttrs { @@ -173,9 +229,16 @@ impl FieldAttrs { parsed.decrypt = true; return Ok(()); } + if meta.path.is_ident("nested") { + if parsed.nested { + return Err(meta.error("`nested` is given twice")); + } + parsed.nested = true; + return Ok(()); + } Err(meta.error( "unsupported field attribute; expected `context = \"...\"`, `from = field`, \ - `default`, `default = expr` or `decrypt`", + `default`, `default = expr`, `decrypt` or `nested`", )) })?; } @@ -185,6 +248,16 @@ impl FieldAttrs { // `from` is known whatever order the attributes were written in: the // advice depends on it, because a `from` field is never handed the // record's context, so "drop the attribute" is a dead end there. + if parsed.nested { + if let Some(context) = &parsed.context { + return Err(syn::Error::new( + context.span(), + "`nested` hands this field `()` because its type carries its own contexts, \ + so `context` does not apply: give one or the other", + )); + } + } + if let Some(context) = &parsed.context { if context.value().is_empty() { let message = if parsed.from.is_some() { diff --git a/packages/stack-encrypt-derive/src/decrypt.rs b/packages/stack-encrypt-derive/src/decrypt.rs index 2199c7627..61644600e 100644 --- a/packages/stack-encrypt-derive/src/decrypt.rs +++ b/packages/stack-encrypt-derive/src/decrypt.rs @@ -15,11 +15,13 @@ use proc_macro2::{Span, TokenStream}; use quote::{quote, quote_spanned, ToTokens}; use syn::spanned::Spanned; use syn::{ - parse_quote, parse_quote_spanned, DeriveInput, Generics, Ident, LitStr, Member, Path, - PathArguments, Result, Type, + parse_quote, DeriveInput, Generics, Ident, LitStr, Member, Path, PathArguments, Result, Type, }; -use crate::shape::{push_context_generics, trait_impl, zip_fields, CallerContext, Field, Record}; +use crate::shape::{ + impl_sources, push_context_generics, push_field_bounds, trait_impl, zip_fields, CallerContext, + Field, FieldBound, Record, +}; pub(crate) fn derive(input: DeriveInput) -> Result<TokenStream> { let record = Record::parse(&input)?; @@ -238,34 +240,31 @@ fn automatic(input: &DeriveInput, record: &Record) -> Result<TokenStream> { } }; - if record.plaintexts.is_empty() { - // One impl, generic over the plaintext: the record decrypts to - // whatever its one ciphertext field decrypts to. `Record::parse` has - // rejected `from` without a named plaintext, so this is whole mode. - let Auto::Whole(fields) = &auto else { - unreachable!("`from` without a named plaintext is rejected by `Record::parse`") - }; - let plaintext: Type = parse_quote!(__P); - let mut generics = input.generics.clone(); - generics.params.push(parse_quote!(__P)); - generics.params.push(parse_quote!(__K)); - push_field_bounds(&mut generics, krate, fields, &plaintext); - let ctx = push_context_generics(&mut generics, CallerContext::Decrypt(krate), fields); - let open = open_one(krate, fields, &plaintext); - let body = quote!(#body_check #destructure #open); - let block = impl_block(input, krate, &generics, &plaintext, &ctx, body); - return Ok(quote!(#block #definition_check)); - } - - let impls = record - .plaintexts + // One impl per listed plaintext, or one generic over it (whole mode + // only: rebuilding field by field needs a struct literal, and + // `Record::parse` has rejected `from` without a named plaintext). Each + // candidate field is bounded by `DecryptField` under the context it is + // opened under, so a record's impl exists for exactly the plaintexts its + // ciphertext field opens to — and only under a supplied context if that + // field needs one. + let (plaintexts, generic) = impl_sources(record, parse_quote!(__P)); + let impls = plaintexts .iter() .map(|plaintext| { let mut generics = input.generics.clone(); + if generic { + generics.params.push(parse_quote!(__P)); + } generics.params.push(parse_quote!(__K)); let open = match &auto { Auto::Whole(fields) => { - push_field_bounds(&mut generics, krate, fields, plaintext); + push_field_bounds( + &mut generics, + krate, + fields, + plaintext, + FieldBound::DecryptField, + ); open_one(krate, fields, plaintext) } Auto::ByField(groups) => by_group_body(krate, groups, plaintext)?, @@ -280,23 +279,6 @@ fn automatic(input: &DeriveInput, record: &Record) -> Result<TokenStream> { Ok(quote!(#(#impls)* #definition_check)) } -/// `FieldTy: DecryptField<Plaintext, StackCipher<__K>, Ctx>` for every -/// candidate field, under the context it is opened under, so a record's impl -/// exists for exactly the plaintexts its ciphertext field opens to — and -/// only under a supplied context if that field needs one. -fn push_field_bounds(generics: &mut Generics, krate: &Path, fields: &[&Field], plaintext: &Type) { - let predicates = &mut generics.make_where_clause().predicates; - for field in fields { - let ty = &field.ty; - let context = field.field_context().ty(); - // Spanned at the field type, so a type that cannot be a field of an - // automatically decrypted record is reported there. - predicates.push(parse_quote_spanned! {ty.span()=> - #ty: #krate::target::DecryptField<#plaintext, #krate::StackCipher<__K>, #context> - }); - } -} - /// The `const` assertion that exactly one of `fields` is `Decryptable`; /// `from` names the plaintext field they recover, for the message. fn check(krate: &Path, name: &Ident, from: Option<&Member>, fields: &[&Field]) -> TokenStream { @@ -427,34 +409,18 @@ fn explicit(input: &DeriveInput, record: &Record) -> Result<TokenStream> { let opened: Vec<&Field> = record.fields.iter().filter(|f| f.decrypt).collect(); let mode = Mode::classify(opened, name)?; - if record.plaintexts.is_empty() { - // One impl, generic over the plaintext: the record decrypts to - // whatever its opened field decrypts to. Only the whole-plaintext - // mode can be generic — rebuilding field by field needs a struct - // literal, and therefore a name — and `Record::parse` has already - // rejected `from` without one. - let Mode::Whole(field) = &mode else { - unreachable!("`from` without a named plaintext is rejected by `Record::parse`") - }; - let plaintext: Type = parse_quote!(__P); - let ty = &field.ty; - let context = field.field_context().ty(); - let mut generics = input.generics.clone(); - generics.params.push(parse_quote!(__P)); - generics.params.push(parse_quote!(__K)); - generics.make_where_clause().predicates.push(parse_quote! { - #ty: #krate::target::DecryptInto<__P, #krate::StackCipher<__K>, #context> - }); - let ctx = push_context_generics(&mut generics, CallerContext::Decrypt(krate), &[field]); - let body = whole_body(krate, field, &plaintext); - return Ok(impl_block(input, krate, &generics, &plaintext, &ctx, body)); - } - - let impls = record - .plaintexts + // One impl per listed plaintext, or one generic over it. Only the + // whole-plaintext mode can be generic — rebuilding field by field needs + // a struct literal, and therefore a name — and `Record::parse` has + // already rejected `from` without one. + let (plaintexts, generic) = impl_sources(record, parse_quote!(__P)); + let impls = plaintexts .iter() .map(|plaintext| { let mut generics = input.generics.clone(); + if generic { + generics.params.push(parse_quote!(__P)); + } generics.params.push(parse_quote!(__K)); let (body, ctx) = match &mode { Mode::Whole(field) => { diff --git a/packages/stack-encrypt-derive/src/encrypt.rs b/packages/stack-encrypt-derive/src/encrypt.rs index 09b583d78..2a81c251e 100644 --- a/packages/stack-encrypt-derive/src/encrypt.rs +++ b/packages/stack-encrypt-derive/src/encrypt.rs @@ -3,41 +3,39 @@ use proc_macro2::TokenStream; use quote::{quote, quote_spanned}; use syn::spanned::Spanned; -use syn::{parse_quote, parse_quote_spanned, DeriveInput, Generics, Path, Result, Type}; +use syn::{parse_quote, DeriveInput, Generics, Path, Result, Type}; use crate::shape::{ - push_context_generics, trait_impl, zip_fields, CallerContext, Field, Kind, Record, + impl_sources, push_context_generics, push_field_bounds, trait_impl, zip_fields, CallerContext, + Field, FieldBound, Kind, Record, }; pub(crate) fn derive(input: DeriveInput) -> Result<TokenStream> { let record = Record::parse(&input)?; let krate = &record.krate; let derived = record.derived(); + // Fields derived from the whole source get a where clause; `from = ..` + // fields reach into the source, so their obligations are checked in the + // body against the actual field. + let whole: Vec<&Field> = derived + .iter() + .filter(|f| f.from().is_none()) + .copied() + .collect(); let decryptable = decryptable_impl(&input, &record, &derived); - if record.plaintexts.is_empty() { - // One impl, generic over the source: the record accepts exactly the - // sources every derived field accepts, which the where clause spells - // out so a mismatch is reported against the field type. - let source: Type = parse_quote!(__S); - let mut generics = input.generics.clone(); - generics.params.push(parse_quote!(__S)); - generics.params.push(parse_quote!(__K)); - push_field_bounds(&mut generics, krate, &derived, &source); - let ctx = push_context_generics(&mut generics, CallerContext::Encrypt(krate), &derived); - let body = body(krate, &record, &derived, &source); - let block = impl_block(&input, krate, &generics, &source, &ctx, body); - return Ok(quote!(#block #decryptable)); - } - - // One impl per listed source. Fields derived from the whole source get a - // where clause as above; `from = ..` fields reach into the source, so - // their obligations are checked in the body against the actual field. - let impls = record.plaintexts.iter().map(|source| { + // One impl per listed source, or one generic over it: the record accepts + // exactly the sources every derived field accepts, which the where clause + // spells out so a mismatch is reported against the field type. + let (sources, generic) = impl_sources(&record, parse_quote!(__S)); + let impls = sources.iter().map(|source| { let mut generics = input.generics.clone(); + if generic { + generics.params.push(parse_quote!(__S)); + } generics.params.push(parse_quote!(__K)); - push_field_bounds(&mut generics, krate, &derived, source); + push_field_bounds(&mut generics, krate, &whole, source, FieldBound::Encrypt); let ctx = push_context_generics(&mut generics, CallerContext::Encrypt(krate), &derived); let body = body(krate, &record, &derived, source); impl_block(&input, krate, &generics, source, &ctx, body) @@ -107,23 +105,6 @@ fn decryptable_impl(input: &DeriveInput, record: &Record, derived: &[&Field]) -> } } -/// `FieldTy: EncryptFrom<Source, StackCipher<__K>, Ctx>` for every field -/// derived from the whole source, under the context it is derived under — -/// its literal's, or the caller's `__Ctx`, which is how a record inherits -/// its leaves' demand for a supplied context. -fn push_field_bounds(generics: &mut Generics, krate: &Path, derived: &[&Field], source: &Type) { - let predicates = &mut generics.make_where_clause().predicates; - for field in derived.iter().filter(|f| f.from().is_none()) { - let ty = &field.ty; - let context = field.field_context().ty(); - // Spanned at the field type, so a type that is not an encrypted form - // of the source is reported there, not at the derive. - predicates.push(parse_quote_spanned! {ty.span()=> - #ty: #krate::target::EncryptFrom<#source, #krate::StackCipher<__K>, #context> - }); - } -} - /// The method body: every derived field's pending, zipped into one, mapped /// into `Self`. fn body(krate: &Path, record: &Record, derived: &[&Field], source: &Type) -> TokenStream { @@ -360,7 +341,7 @@ mod tests { #[rustfmt::skip] fn a_row_needs_no_attributes_on_its_fields() { let expansion = expand(parse_quote! { - #[stash(row = User)] + #[stash(row = User, context = "user")] struct EncryptedUser { age: EncryptedAge, email: StackCipherText, diff --git a/packages/stack-encrypt-derive/src/lib.rs b/packages/stack-encrypt-derive/src/lib.rs index e5041934a..064978a46 100644 --- a/packages/stack-encrypt-derive/src/lib.rs +++ b/packages/stack-encrypt-derive/src/lib.rs @@ -65,12 +65,13 @@ //! # Rows //! //! One level up, the same derive: a struct whose fields are each derived from -//! a *field* of the plaintext, under a context of their own. `row = User` -//! says so once, for every field: `age` is derived from `user.age` under -//! `"user/age"`, `email` from `user.email` under `"user/email"` — the -//! struct's name and the field's, nothing invented. Attributes on the fields -//! are for the exceptions: `from = ..` when the names differ, `context = -//! ".."` to pin a context by hand. +//! a *field* of the plaintext, under a context of their own. `row = User, +//! context = "users"` says so once, for every field: `age` is derived from +//! `user.age` under `"users/age"`, `email` from `user.email` under +//! `"users/email"` — the table you name and the field, nothing invented. +//! Attributes on the fields are for the exceptions: `from = ..` when the +//! names differ, `context = ".."` to pin a whole context by hand, `nested` +//! for a field whose type is itself a row carrying its own contexts. //! //! ``` //! # use stack_encrypt::sem::{EqualityTerm, OreTerm}; @@ -91,7 +92,7 @@ //! } //! //! #[derive(EncryptFrom, DecryptInto)] -//! #[stash(row = User)] +//! #[stash(row = User, context = "users")] //! struct EncryptedUser { //! age: EncryptedAge, //! email: StackCipherText, @@ -110,7 +111,7 @@ //! # }).unwrap(); //! ``` //! -//! A row field's context is the *column's* identity — `"user/age"` is what a +//! A row field's context is the *column's* identity — `"users/age"` is what a //! query site derives a probe under — which is why it is a literal per field //! rather than something composed from a context the caller passes. A row //! takes no context from the caller at all: its impls are for `()` exactly, @@ -118,13 +119,18 @@ //! forms that compile against it. A `plaintext = ..` record's `from` field //! with no `context` is handed `()` too, and its type decides whether that //! will do: a nested row accepts it; a leaf refuses it, at the field, until -//! it is given a `context`. -//! -//! Because the inferred context is derived from Rust names, renaming the -//! plaintext struct or one of its fields changes the AAD every stored -//! ciphertext in that column was sealed under, and they stop decrypting. -//! Pin the old value with `context = ".."` before such a rename; the -//! [attributes](#rows) section says more. +//! it is given a `context`. In a row, `#[stash(nested)]` is the same +//! hand-off: it marks the fields whose types carry their own contexts, so no +//! context is inferred for them. +//! +//! The prefix is given explicitly (`context = "users"`), never inferred from +//! the Rust type's name: it is part of the stored data's identity — the AAD +//! of every ciphertext in the row and the domain of every term — and a name +//! two types share, or a refactor changes, must not be able to move it +//! silently. The field half is still inferred from the plaintext field's +//! name, so renaming a plaintext field changes that column's context and +//! stored rows stop decrypting; pin the old value with `context = ".."` on +//! the field before such a rename. The [attributes](#rows) section says more. //! //! # What the derive commits to //! diff --git a/packages/stack-encrypt-derive/src/shape.rs b/packages/stack-encrypt-derive/src/shape.rs index e6c4b84de..7342f32f3 100644 --- a/packages/stack-encrypt-derive/src/shape.rs +++ b/packages/stack-encrypt-derive/src/shape.rs @@ -4,8 +4,8 @@ use proc_macro2::{Span, TokenStream}; use quote::quote; use syn::spanned::Spanned; use syn::{ - parse_quote, Data, DeriveInput, Expr, Fields, Generics, Ident, LitStr, Member, Path, Result, - Type, TypePath, + parse_quote, parse_quote_spanned, Data, DeriveInput, Expr, Fields, Generics, Ident, LitStr, + Member, Path, Result, Type, }; use crate::attrs::{ContainerAttrs, FieldAttrs}; @@ -168,8 +168,9 @@ impl Record { } }; - let row = attrs.row.as_ref().map(row_name).transpose()?; - let fields = collect(&data.fields, row.as_deref())?; + // `ContainerAttrs::parse` has established that `context` is present + // exactly when `row` is. + let fields = collect(&data.fields, attrs.context.as_ref())?; let plaintexts = match attrs.row { Some(row) => vec![row], None => attrs.plaintexts, @@ -200,39 +201,6 @@ impl Record { } } -/// The first half of a row field's inferred context: the row struct's own -/// name, in `snake_case` (`UserProfile` becomes `user_profile`). Generic -/// arguments and the path to the type are not part of it. -fn row_name(ty: &Type) -> Result<String> { - let Type::Path(TypePath { path, .. }) = ty else { - unreachable!("`ContainerAttrs::parse` accepts only a path type for `row`") - }; - let segment = path - .segments - .last() - .ok_or_else(|| syn::Error::new_spanned(ty, "`row` must name a struct"))?; - Ok(snake_case(&segment.ident.to_string())) -} - -/// `UserProfile` → `user_profile`, `HTTPHeader` → `http_header`, `user` → -/// `user`. Boundaries are a lower-to-upper step and the last capital of a -/// run followed by a lowercase letter. -fn snake_case(name: &str) -> String { - let chars: Vec<char> = name.chars().collect(); - let mut out = String::with_capacity(name.len() + 4); - for (i, &c) in chars.iter().enumerate() { - if c.is_uppercase() && i > 0 { - let prev = chars[i - 1]; - let next_lower = chars.get(i + 1).is_some_and(|n| n.is_lowercase()); - if prev.is_lowercase() || prev.is_ascii_digit() || (prev.is_uppercase() && next_lower) { - out.push('_'); - } - } - out.extend(c.to_lowercase()); - } - out -} - /// The demand a derive places on the impl's context parameter, beyond what /// the field bounds already say. pub(crate) enum CallerContext<'a> { @@ -304,6 +272,56 @@ pub(crate) fn trait_impl( } } +/// Which trait a field bound names; the bound is otherwise identical between +/// the two derives, and built in one place so the where-clause logic that +/// carries the per-field contexts cannot diverge between them. +pub(crate) enum FieldBound { + /// `EncryptFrom<Source, ..>` — for encrypt, on fields derived from the + /// whole source (the caller filters; a `from` field's obligation is + /// checked in the body instead, where the source field's type is known). + Encrypt, + /// `DecryptField<Plaintext, ..>` — for decrypt, on every candidate field. + DecryptField, +} + +/// `FieldTy: Trait<Target, StackCipher<__K>, Ctx>` for each of `fields`, +/// under the context it is derived or opened under — its literal's, `()`, +/// or the caller's `__Ctx`, which is how a record inherits its leaves' +/// demand for a supplied context. +pub(crate) fn push_field_bounds( + generics: &mut Generics, + krate: &Path, + fields: &[&Field], + target: &Type, + bound: FieldBound, +) { + let trait_name: Ident = match bound { + FieldBound::Encrypt => parse_quote!(EncryptFrom), + FieldBound::DecryptField => parse_quote!(DecryptField), + }; + let predicates = &mut generics.make_where_clause().predicates; + for field in fields { + let ty = &field.ty; + let context = field.field_context().ty(); + // Spanned at the field type, so a type that cannot be a field of the + // record is reported there, not at the derive. + predicates.push(parse_quote_spanned! {ty.span()=> + #ty: #krate::target::#trait_name<#target, #krate::StackCipher<__K>, #context> + }); + } +} + +/// The source (or plaintext) types a derive emits one impl each for: the +/// listed ones, or — when none are listed — the given generic parameter, +/// with `true` saying it must be pushed onto the impl's generics. +pub(crate) fn impl_sources(record: &Record, generic: Ident) -> (Vec<Type>, bool) { + if record.plaintexts.is_empty() { + (vec![parse_quote!(#generic)], true) + } else { + (record.plaintexts.clone(), false) + } +} + /// The pendings of `fields`, zipped into one and mapped into `build` (a /// struct literal over the fields' locals). Nothing is awaited, so the record /// settles as one batched call. @@ -346,10 +364,13 @@ pub(crate) fn zip_fields( quote!(#chain.map(|#pattern| #build)) } -/// The fields, with what a row (`row` is its snake-cased name) fills in: -/// `from` is the field's own name and `context` is `"<row>/<from>"`, each -/// unless the field gives its own. -fn collect(fields: &Fields, row: Option<&str>) -> Result<Vec<Field>> { +/// The fields, with what a row (`row_context` is the container's `context` +/// prefix) fills in: `from` is the field's own name and `context` is +/// `"<row_context>/<from>"`, each unless the field gives its own. +/// `#[stash(nested)]` opts a field out of the inferred context — it is handed +/// `()`, which a nested row (a type carrying its own contexts) accepts and a +/// leaf refuses. +fn collect(fields: &Fields, row_context: Option<&LitStr>) -> Result<Vec<Field>> { fields .iter() .enumerate() @@ -359,29 +380,48 @@ fn collect(fields: &Fields, row: Option<&str>) -> Result<Vec<Field>> { Some(ident) => Member::Named(ident.clone()), None => Member::Unnamed(syn::Index::from(index)), }; + if attrs.nested && row_context.is_none() { + return Err(syn::Error::new_spanned( + &field.ty, + "`nested` opts a row field out of its inferred context, so it applies only \ + with `row = ..` on the struct; a `plaintext` record's `from` field with no \ + `context` is already handed `()`", + )); + } let kind = match attrs.default { Some(default) => { - if attrs.context.is_some() || attrs.from.is_some() || attrs.decrypt { + if attrs.context.is_some() + || attrs.from.is_some() + || attrs.decrypt + || attrs.nested + { return Err(syn::Error::new_spanned( &field.ty, "a `default` field is not derived from the source, so `context`, \ - `from` and `decrypt` do not apply to it", + `from`, `decrypt` and `nested` do not apply to it", )); } Kind::Default(default) } - None => match row { - Some(row) => { + None => match row_context { + Some(row_context) => { let from = attrs.from.unwrap_or_else(|| member.clone()); - let context = attrs.context.unwrap_or_else(|| { - let column = match &from { - Member::Named(ident) => ident.to_string(), - Member::Unnamed(index) => index.index.to_string(), - }; - LitStr::new(&format!("{row}/{column}"), member.span()) - }); + let context = if attrs.nested { + // The field's type carries its own contexts; it + // is handed `()` (`FieldContext::Unit`). + None + } else { + Some(attrs.context.unwrap_or_else(|| { + let column = match &from { + Member::Named(ident) => ident.to_string(), + Member::Unnamed(index) => index.index.to_string(), + }; + let prefix = row_context.value(); + LitStr::new(&format!("{prefix}/{column}"), member.span()) + })) + }; Kind::Derived { - context: Some(context), + context, from: Some(from), } } @@ -638,47 +678,129 @@ mod tests { #[test] fn a_row_fills_in_from_and_context() { let record = parse(parse_quote! { - #[stash(row = crate::model::UserProfile<T>)] + #[stash(row = crate::model::UserProfile<T>, context = "user_profiles")] struct EncryptedUser { age: EncryptedAge, #[stash(from = email_address)] email: StackCipherText, #[stash(context = "legacy/name")] name: StackCipherText, + #[stash(nested)] + address: EncryptedAddress, #[stash(default)] version: u8, } }) .unwrap(); assert_eq!(record.plaintexts.len(), 1); - let (age, email, name, version) = ( + let (age, email, name, address, version) = ( &record.fields[0], &record.fields[1], &record.fields[2], &record.fields[3], + &record.fields[4], ); - // Own name, and both names in the context — the type's snake-cased. + // Own name under the container's prefix. assert!(matches!(age.from(), Some(Member::Named(m)) if m == "age")); - assert_eq!(literal(age), "user_profile/age"); + assert_eq!(literal(age), "user_profiles/age"); // `from` overrides the field; the context follows the plaintext field. assert!(matches!(email.from(), Some(Member::Named(m)) if m == "email_address")); - assert_eq!(literal(email), "user_profile/email_address"); + assert_eq!(literal(email), "user_profiles/email_address"); // `context` is taken verbatim. assert!(matches!(name.from(), Some(Member::Named(m)) if m == "name")); assert_eq!(literal(name), "legacy/name"); + // `nested`: no inferred context — the field is handed `()`. + assert!(matches!(address.from(), Some(Member::Named(m)) if m == "address")); + assert!(matches!(address.field_context(), FieldContext::Unit)); assert!(!version.is_derived()); } #[test] fn a_tuple_row_is_reached_and_named_by_index() { let record = parse(parse_quote! { - #[stash(row = Reading)] + #[stash(row = Reading, context = "readings")] struct EncryptedReading(EncryptedAge, StackCipherText); }) .unwrap(); assert!(matches!(record.fields[1].from(), Some(Member::Unnamed(i)) if i.index == 1)); - assert_eq!(literal(&record.fields[0]), "reading/0"); - assert_eq!(literal(&record.fields[1]), "reading/1"); + assert_eq!(literal(&record.fields[0]), "readings/0"); + assert_eq!(literal(&record.fields[1]), "readings/1"); + } + + #[test] + fn a_row_requires_a_container_context() { + // The prefix is part of the stored data's identity, so it is never + // inferred from the Rust type's name: two types named `Account` in + // different modules would otherwise silently share every column + // context. + let err = parse(parse_quote! { + #[stash(row = User)] + struct EncryptedUser { + age: EncryptedAge, + } + }) + .unwrap_err(); + let message = err.to_string(); + assert!(message.contains("needs a `context = \"..\"`"), "{message}"); + assert!(message.contains("naming the table"), "{message}"); + } + + #[test] + fn a_container_context_requires_a_row() { + let err = parse(parse_quote! { + #[stash(plaintext = User, context = "users")] + struct Rec { + c: StackCipherText, + } + }) + .unwrap_err(); + assert!(err.to_string().contains("applies only with `row = ..`")); + + let err = parse(parse_quote! { + #[stash(context = "users")] + struct Rec { + c: StackCipherText, + } + }) + .unwrap_err(); + assert!(err.to_string().contains("applies only with `row = ..`")); + } + + #[test] + fn an_empty_container_context_is_rejected() { + let err = parse(parse_quote! { + #[stash(row = User, context = "")] + struct EncryptedUser { + age: EncryptedAge, + } + }) + .unwrap_err(); + assert!(err.to_string().contains("name the table")); + } + + #[test] + fn nested_applies_only_in_a_row_and_excludes_context() { + // Outside a row it is at best redundant (`from` with no `context` is + // already handed `()`), so it is rejected rather than ignored. + let err = parse(parse_quote! { + #[stash(plaintext = User)] + struct Rec { + #[stash(nested, from = user)] + user: EncryptedUser, + } + }) + .unwrap_err(); + assert!(err.to_string().contains("applies only with `row = ..`")); + + let err = parse(parse_quote! { + #[stash(row = Account, context = "accounts")] + struct Rec { + #[stash(nested, context = "accounts/user")] + user: EncryptedUser, + } + }) + .unwrap_err(); + assert!(err.to_string().contains("`context` does not apply")); } #[test] @@ -714,20 +836,6 @@ mod tests { assert!(err.to_string().contains("must name a struct directly")); } - #[test] - fn type_names_snake_case_by_word_boundary() { - for (name, expected) in [ - ("User", "user"), - ("UserProfile", "user_profile"), - ("HTTPHeader", "http_header"), - ("Address2Line", "address2_line"), - ("user", "user"), - ("ABC", "abc"), - ] { - assert_eq!(snake_case(name), expected, "{name}"); - } - } - #[test] fn fields_classify() { let record = parse(parse_quote! { diff --git a/packages/stack-encrypt/src/target/mod.rs b/packages/stack-encrypt/src/target/mod.rs index 2ca16ef07..c43a88e2d 100644 --- a/packages/stack-encrypt/src/target/mod.rs +++ b/packages/stack-encrypt/src/target/mod.rs @@ -123,11 +123,11 @@ //! //! ``` //! use stack_encrypt::target::{ -//! is_degenerate_prf_context, DecryptField, DecryptTarget, Decryptable, EncryptContext, +//! supplied_prf_context, DecryptField, DecryptTarget, Decryptable, EncryptContext, //! EncryptFrom, Pending, SuppliedContext, //! }; //! use stack_encrypt::{Error, StackCipher}; -//! use vitaminc_prf::{IntoPrfContext, PrfContext, PrfValue, PrfVisitor, PrfVisitorError}; +//! use vitaminc_prf::{PrfContext, PrfValue, PrfVisitor, PrfVisitorError}; //! //! /// A third-party term type: one PRF block under its own domain. //! pub struct MyTerm([u8; 32]); @@ -160,14 +160,15 @@ //! Self: 'a, //! { //! // The type rules out an absent context; an empty one is still a -//! // runtime check, the same one the built-in leaves make (the -//! // encoding is framed, so `as_bytes().is_empty()` would never be -//! // true). Then domain-separate under your own label so your terms -//! // can never collide with another scheme's under the same context. -//! let context = context.into_prf_context().into_owned(); -//! if is_degenerate_prf_context(context.as_bytes()) { -//! return Pending::ready(cipher, Err(Error::EmptyContext)); -//! } +//! // runtime check, made by the same choke point the built-in +//! // leaves use — validation and encoding are one call, so a leaf +//! // cannot encode one value and check another. Then +//! // domain-separate under your own label so your terms can never +//! // collide with another scheme's under the same context. +//! let context = match supplied_prf_context(context) { +//! Ok(context) => context, +//! Err(error) => return Pending::ready(cipher, Err(error)), +//! }; //! let context = PrfContext::pae(&[b"my-crate/my-term/v1".as_slice(), context.as_bytes()]); //! let term = source //! .clone() @@ -214,10 +215,11 @@ //! //! A struct of leaves is a *record*; a struct of records, each derived from //! one field of the source under its own column context, is a *row*. Both -//! are the same derive, and both settle as one batched call. A row's -//! contexts are inferred from the names — `#[stash(row = User)]` derives -//! `age` from `user.age` under `"user/age"` — and overridden per field where -//! that is not wanted: +//! are the same derive, and both settle as one batched call. A row names its +//! table once and its fields' contexts follow — +//! `#[stash(row = User, context = "users")]` derives `age` from `user.age` +//! under `"users/age"` — and are overridden per field where that is not +//! wanted: //! //! ``` //! use stack_encrypt::sem::{EqualityTerm, OreTerm}; @@ -241,9 +243,9 @@ //! } //! //! /// A row of `User`: each field from the plaintext field of its own name, -//! /// under the context `"user/<field>"` — no attribute needed. +//! /// under the context `"users/<field>"` — no attribute on the fields. //! #[derive(EncryptFrom, DecryptInto)] -//! #[stash(row = User)] +//! #[stash(row = User, context = "users")] //! struct EncryptedUser { //! age: EncryptedAge, //! email: StackCipherText, @@ -262,7 +264,7 @@ //! let row: EncryptedUser = user.encrypt_into(&cipher).await?; //! // A query site derives the same term under the column's context. //! let probe: EqualityTerm = 42u32 -//! .encrypt_into_with_context(&cipher, "user/age") +//! .encrypt_into_with_context(&cipher, "users/age") //! .await?; //! assert_eq!(row.age.hm, probe); //! @@ -351,6 +353,12 @@ impl<'a, T> EncryptContext<'a> for T where T: IntoAad<'a> + IntoPrfContext<'a> + /// without it a record whose ciphertext field carries a literal would take, /// and silently discard, a value that is not a context at all: /// +/// Note the flip side: for such a record the bound is all the caller's +/// context does. The terms discard its *value* and the ciphertext +/// authenticates under its literal, so decryption succeeds under any +/// well-typed context — a wrong one is not the [`Error::Aead`] it would be +/// against a leaf, and the decrypt context is not a tenancy check there. +/// /// ```compile_fail,E0277 /// use stack_encrypt::sem::EqualityTerm; /// use stack_encrypt::{DecryptInto, EncryptFrom, StackCipher, StackCipherText}; @@ -413,6 +421,13 @@ impl<'a, T> DecryptContext<'a> for T where T: IntoAad<'a> + Clone {} )] pub trait SuppliedContext<'a>: DecryptContext<'a> {} +// This roster hand-mirrors vitaminc's `IntoAad` implementor list (minus `()`) +// at the pinned rev, and the orphan rule means a *future* vitaminc-owned +// context type cannot opt itself in from user code — it waits for a release +// of this crate. That coupling is a conscious interim choice: when bumping +// the vitaminc pin, diff its `IntoAad` implementors against this list; at +// vitaminc#291 the bound moves to an upstream marker and the roster goes +// away. impl<'a> SuppliedContext<'a> for &'a str {} impl SuppliedContext<'_> for String {} impl<'a> SuppliedContext<'a> for &'a [u8] {} @@ -504,14 +519,10 @@ mod prf_framing { /// `for_map_entry`, the markers — are derived after this check runs, from /// the caller-visible AAD this sees.) /// -/// **Transitional.** Public only so a third-party leaf can make the same -/// runtime check the built-ins make (the recipe in the -/// [module docs](self#extending-with-your-own-sem-type)). When -/// [vitaminc#291](https://github.com/cipherstash/vitaminc/issues/291) carries -/// non-emptiness in the context type, both predicates and -/// [`Error::EmptyContext`] are **deleted**, not deprecated — do not build on -/// them beyond that recipe. -pub fn is_degenerate_aad(bytes: &[u8]) -> bool { +/// Crate-private: third-party leaves go through [`supplied_aad`] / +/// [`supplied_prf_context`], the one choke point whose signature survives +/// the vitaminc#291 migration. +pub(crate) fn is_degenerate_aad(bytes: &[u8]) -> bool { if bytes.is_empty() { return true; } @@ -536,10 +547,9 @@ pub fn is_degenerate_aad(bytes: &[u8]) -> bool { /// hand) is degenerate only if every one of its pieces is. Bytes that are not /// a well-formed PAE are caller content. /// -/// **Transitional**, on the same terms as [`is_degenerate_aad`]: deleted, -/// not deprecated, when -/// [vitaminc#291](https://github.com/cipherstash/vitaminc/issues/291) lands. -pub fn is_degenerate_prf_context(bytes: &[u8]) -> bool { +/// Crate-private, on the same terms as [`is_degenerate_aad`]: the public +/// surface is [`supplied_prf_context`]. +pub(crate) fn is_degenerate_prf_context(bytes: &[u8]) -> bool { if bytes.is_empty() { return true; } @@ -562,6 +572,46 @@ pub fn is_degenerate_prf_context(bytes: &[u8]) -> bool { } } +/// Validate and encode a supplied **AAD** context in one step: the encoded +/// bytes if the context carries caller information, [`Error::EmptyContext`] +/// if it is degenerate (`""`, `None`, `Some("")`, `0u64`, nested empties — +/// see [`EncryptContext`]). +/// +/// This is the choke point every built-in leaf goes through on the AEAD +/// channel, and the one a third-party ciphertext-like leaf should call too +/// (see the [module docs](self#extending-with-your-own-sem-type)): there is +/// deliberately no public way to make the underlying degeneracy check +/// without also obtaining the encoded context, so a leaf cannot encode one +/// value and check another. The *signature* is stable across the +/// [vitaminc#291](https://github.com/cipherstash/vitaminc/issues/291) +/// migration: when non-emptiness moves into the context type, the runtime +/// check here collapses to a conversion, and callers do not change. +pub fn supplied_aad<'c>(context: impl IntoAad<'c>) -> Result<Aad<'static>, Error> { + let aad = context.into_aad().into_owned(); + if is_degenerate_aad(aad.as_bytes()) { + return Err(Error::EmptyContext); + } + Ok(aad) +} + +/// Validate and encode a supplied **PRF** context in one step — the +/// derivation-channel twin of [`supplied_aad`], for index-term leaves. +/// Returns the encoded context, or [`Error::EmptyContext`] for one that +/// carries no caller information. +/// +/// Domain-separate the result under your own label before deriving from it, +/// as the recipe in the +/// [module docs](self#extending-with-your-own-sem-type) shows. +pub fn supplied_prf_context<'c>( + context: impl IntoPrfContext<'c>, +) -> Result<vitaminc_prf::PrfContext<'static>, Error> { + let context = context.into_prf_context().into_owned(); + if is_degenerate_prf_context(context.as_bytes()) { + return Err(Error::EmptyContext); + } + Ok(context) +} + /// Parse `bytes` as exactly one PAE encoding: `LE64(count)` then `count` /// `LE64(len) || piece` frames, consuming every byte. `None` if the bytes are /// not that shape. @@ -886,6 +936,45 @@ impl<P> DecryptFrom for P { /// A third-party leaf implements it alongside [`EncryptFrom`], together /// with [`DecryptField`]; see the /// [module docs](self#extending-with-your-own-sem-type). +/// +/// For a *generic* record the exactly-one count cannot be checked at the +/// definition (a `const _` item cannot name the record's generic +/// parameters), so the derive defers it to an inline `const` evaluated per +/// instantiation: the record compiles where it is defined and the error +/// fires at the first *use* that is actually codegenned — possibly in a +/// downstream crate. The message is the same one a concrete record gets at +/// its definition: +/// +/// ```compile_fail +/// use stack_encrypt::target::Pending; +/// use stack_encrypt::{DecryptInto, StackCipher, StackCipherText}; +/// use stack_kms::FakeDataKeySource; +/// +/// #[derive(DecryptInto)] +/// struct Doubled<T> { +/// a: StackCipherText, +/// b: StackCipherText, +/// #[stash(default)] +/// tag: T, +/// } +/// +/// // Compiles fine: the two-ciphertext mistake is not yet instantiated. +/// fn open<'a>( +/// cipher: &'a StackCipher<FakeDataKeySource>, +/// doubled: Doubled<u8>, +/// ) -> Pending<'a, u32, FakeDataKeySource> { +/// doubled.decrypt_into(cipher, "d") +/// } +/// +/// // The first reachable instantiation trips the deferred check: +/// // "`Doubled` has several decryptable fields: mark the one decryption +/// // opens `#[stash(decrypt)]`". +/// let _ = open +/// as for<'a> fn( +/// &'a StackCipher<FakeDataKeySource>, +/// Doubled<u8>, +/// ) -> Pending<'a, u32, FakeDataKeySource>; +/// ``` pub trait Decryptable { /// `true` if decryption opens a value of this type, `false` if it is a /// one-way term with no plaintext to recover. @@ -903,6 +992,15 @@ pub trait Decryptable { /// record, and a record that only decrypts — no `EncryptFrom` derive to /// emit its `Decryptable` — must still be a field of a row in the explicit /// mode.) +#[diagnostic::on_unimplemented( + message = "`{Self}` cannot be a field of an automatically decrypted record", + label = "no `DecryptField<{P}, ..>` implementation", + note = "a term-only bundle — `#[derive(EncryptFrom)]` alone, nothing to open — has no \ + `DecryptField`: mark the outer record's real ciphertext `#[stash(decrypt)]` so only \ + the marked fields are considered", + note = "a hand-written term type implements `DecryptField` (returning `None`) alongside \ + `Decryptable`; a hand-written ciphertext type wraps its `DecryptInto`" +)] pub trait DecryptField<P, C: DecryptTarget, Ctx> { /// [`DecryptInto::decrypt_into`] if `Self` is decryptable, `None` if not. fn decrypt_field<'a>(self, cipher: &'a C, context: Ctx) -> Option<C::Output<'a, P>> @@ -1023,13 +1121,13 @@ where where Self: 'a, { - let aad = context.into_aad().into_owned(); // An empty context would leave the leaf AAD carrying only the key // tag, making ciphertexts transplantable between empty-context // fields — see `EncryptContext`. - if is_degenerate_aad(aad.as_bytes()) { - return Pending::failed(cipher, Error::EmptyContext); - } + let aad = match supplied_aad(context) { + Ok(aad) => aad, + Err(error) => return Pending::failed(cipher, error), + }; match source.clone().encrypt_with_aad(cipher, aad) { Ok(tree) => seal_pending(cipher, tree), Err(_) => Pending::ready(cipher, Err(Error::Aead)), @@ -1106,12 +1204,12 @@ where Self: 'a, T: 'a, { - let aad = context.into_aad().into_owned(); // Symmetric with the encrypt side: the target layer never encrypts // under an empty context, so it never decrypts under one either. - if is_degenerate_aad(aad.as_bytes()) { - return Pending::failed(cipher, Error::EmptyContext); - } + let aad = match supplied_aad(context) { + Ok(aad) => aad, + Err(error) => return Pending::failed(cipher, error), + }; let requests = retrieve_requests(&self); Pending::request(cipher, requests, move |responses| { let decipher = decipher_from_responses(self, responses)?; @@ -1165,6 +1263,17 @@ fn collect_retrieve_requests(ciphertext: &StackCipherText, out: &mut Vec<Request /// column of rows accepts `()` because its rows do. Neither is decided here, /// and neither is an empty context, which the leaves reject the moment a /// value reaches them. +/// +/// A consequence, accepted knowingly: an *empty* container performs no +/// check at all, so a degenerate supplied context (`""` from a +/// runtime-resolved descriptor, say) succeeds against a table with no rows +/// and first fails on the first populated value. The container cannot +/// pre-check — its `Ctx` is legitimately `()` for a column of rows, and +/// only the element type knows whether a context is even owed. Fail-fast +/// returns for free at +/// [vitaminc#291](https://github.com/cipherstash/vitaminc/issues/291), when +/// a supplied context validates non-emptiness at construction, before any +/// container is reached. impl<S, T, K, Ctx> EncryptFrom<Vec<S>, StackCipher<K>, Ctx> for Vec<T> where T: EncryptFrom<S, StackCipher<K>, Ctx>, diff --git a/packages/stack-encrypt/tests/derive.rs b/packages/stack-encrypt/tests/derive.rs index 7294f2c14..df182bb59 100644 --- a/packages/stack-encrypt/tests/derive.rs +++ b/packages/stack-encrypt/tests/derive.rs @@ -344,11 +344,13 @@ struct User { } /// A row: every field is derived from the plaintext field of its own name, -/// under the context `"<row>/<field>"` — `"user/age"`, `"user/email"` — with -/// no attribute on the field. `from` is the override for a name that -/// differs; the context then follows the plaintext field. +/// under the context `"<context>/<field>"` — `"user/age"`, `"user/email"` — +/// with no attribute on the field. The prefix names the table, explicitly: +/// it is part of the stored data's identity, so it is never inferred from +/// the type's name. `from` is the override for a field name that differs; +/// the context then follows the plaintext field. #[derive(EncryptFrom, DecryptInto)] -#[stash(row = User)] +#[stash(row = User, context = "user")] struct EncryptedUser { /// A record inside a row: recursion, not a second mechanism. age: EncryptedAge, @@ -482,6 +484,50 @@ async fn a_row_nests_in_a_row_without_a_context() { assert_eq!(retrieves.load(AtomicOrdering::SeqCst), 1); } +/// A row nesting a row, in row mode: `#[stash(nested)]` opts the field out +/// of the inferred context — the inner row carries its own — so it is handed +/// `()`, exactly as a `plaintext = ..` record's bare `from` field is. The +/// outer row stays context-free. +#[derive(EncryptFrom, DecryptInto)] +#[stash(row = Account, context = "accounts")] +struct EncryptedAccountRow { + #[stash(nested)] + user: EncryptedUser, + plan: StackCipherText, +} + +#[tokio::test] +async fn a_row_nests_in_a_row_in_row_mode_via_nested() { + let (cipher, generates, retrieves) = counting_cipher().await; + let generator = stack_cipher().await; + + let account = Account { + user: user(), + plan: "pro".to_string(), + }; + let row: EncryptedAccountRow = account.encrypt_into(&cipher).await.unwrap(); + assert_eq!(generates.load(AtomicOrdering::SeqCst), 1); + + // The inner row's fields are under their own literals; the outer's plan + // is under the inferred `"accounts/plan"`. + let age_hm: EqualityTerm = 42u32 + .encrypt_into_with_context(&generator, "user/age") + .await + .unwrap(); + assert_eq!(row.user.age.hm, age_hm); + let plan: String = row + .plan + .decrypt_into(&cipher, "accounts/plan") + .await + .unwrap(); + assert_eq!(plan, "pro"); + + let row: EncryptedAccountRow = account.encrypt_into(&cipher).await.unwrap(); + let recovered = Account::decrypt_from(row, &cipher).await.unwrap(); + assert_eq!(recovered, account); + assert!(retrieves.load(AtomicOrdering::SeqCst) >= 1); +} + /// A tuple-struct plaintext is reached by index — inferred for a tuple row, /// `from = 0` when the row has named fields — and named by it in the /// context: `"reading/0"`. @@ -489,13 +535,13 @@ async fn a_row_nests_in_a_row_without_a_context() { struct Reading(u32, String); #[derive(EncryptFrom, DecryptInto)] -#[stash(row = Reading)] +#[stash(row = Reading, context = "reading")] struct EncryptedReading(EncryptedAge, StackCipherText); /// The same row with named fields: `from` by index, and the context follows /// the index too unless given. #[derive(EncryptFrom, DecryptInto)] -#[stash(row = Reading)] +#[stash(row = Reading, context = "reading")] struct NamedReading { #[stash(from = 0)] value: EncryptedAge, diff --git a/packages/stack-encrypt/tests/target.rs b/packages/stack-encrypt/tests/target.rs index f08a84871..4544a146e 100644 --- a/packages/stack-encrypt/tests/target.rs +++ b/packages/stack-encrypt/tests/target.rs @@ -9,8 +9,8 @@ use std::sync::atomic::Ordering as AtomicOrdering; use stack_encrypt::sem::{EqualityTerm, MatchConfig, MatchOptions, MatchTerm, OreTerm}; use stack_encrypt::target::{ - is_degenerate_prf_context, DecryptInto, EncryptContext, EncryptFrom, EncryptInto, Pending, - Request, SuppliedContext, + supplied_prf_context, DecryptInto, EncryptContext, EncryptFrom, EncryptInto, Pending, Request, + SuppliedContext, }; use stack_encrypt::{Error, StackCipher, StackCipherText}; use stack_kms::{FakeDataKeySource, IdentifiedBy, IndexKeySource}; @@ -462,13 +462,14 @@ where where Self: 'a, { - // The same non-emptiness check the built-in leaves make, then an own - // domain label: can never collide with a built-in term under the - // same context. - let context = context.into_prf_context().into_owned(); - if is_degenerate_prf_context(context.as_bytes()) { - return Pending::ready(cipher, Err(Error::EmptyContext)); - } + // The same non-emptiness check the built-in leaves make — validation + // and encoding through the one choke point — then an own domain + // label: can never collide with a built-in term under the same + // context. + let context = match supplied_prf_context(context) { + Ok(context) => context, + Err(error) => return Pending::ready(cipher, Err(error)), + }; let context = PrfContext::pae(&[ b"example/prefix-term/v1", &(N as u64).to_le_bytes(), diff --git a/packages/stack-encrypt/tests/ui/default_with_context.stderr b/packages/stack-encrypt/tests/ui/default_with_context.stderr index 00145d9a6..efd4e81c3 100644 --- a/packages/stack-encrypt/tests/ui/default_with_context.stderr +++ b/packages/stack-encrypt/tests/ui/default_with_context.stderr @@ -1,4 +1,4 @@ -error: a `default` field is not derived from the source, so `context`, `from` and `decrypt` do not apply to it +error: a `default` field is not derived from the source, so `context`, `from`, `decrypt` and `nested` do not apply to it --> tests/ui/default_with_context.rs:7:8 | 7 | v: u8, diff --git a/packages/stack-encrypt/tests/ui/nested_outside_row.rs b/packages/stack-encrypt/tests/ui/nested_outside_row.rs new file mode 100644 index 000000000..7b36059a4 --- /dev/null +++ b/packages/stack-encrypt/tests/ui/nested_outside_row.rs @@ -0,0 +1,17 @@ +//! `nested` opts a row field out of its inferred context. Outside a row it +//! is at best redundant — a `plaintext` record's `from` field with no +//! `context` is already handed `()` — so it is rejected rather than ignored. +use stack_encrypt::{EncryptFrom, StackCipherText}; + +struct User { + email: String, +} + +#[derive(EncryptFrom)] +#[stash(plaintext = User)] +struct EncryptedUser { + #[stash(nested, from = email)] + email: StackCipherText, +} + +fn main() {} diff --git a/packages/stack-encrypt/tests/ui/nested_outside_row.stderr b/packages/stack-encrypt/tests/ui/nested_outside_row.stderr new file mode 100644 index 000000000..bee8b4b70 --- /dev/null +++ b/packages/stack-encrypt/tests/ui/nested_outside_row.stderr @@ -0,0 +1,5 @@ +error: `nested` opts a row field out of its inferred context, so it applies only with `row = ..` on the struct; a `plaintext` record's `from` field with no `context` is already handed `()` + --> tests/ui/nested_outside_row.rs:14:12 + | +14 | email: StackCipherText, + | ^^^^^^^^^^^^^^^ diff --git a/packages/stack-encrypt/tests/ui/row_field_missing.rs b/packages/stack-encrypt/tests/ui/row_field_missing.rs index 92f9ca7bc..49d27981c 100644 --- a/packages/stack-encrypt/tests/ui/row_field_missing.rs +++ b/packages/stack-encrypt/tests/ui/row_field_missing.rs @@ -7,7 +7,7 @@ struct User { } #[derive(EncryptFrom)] -#[stash(row = User)] +#[stash(row = User, context = "user")] struct EncryptedUser { email: StackCipherText, nickname: StackCipherText, diff --git a/packages/stack-encrypt/tests/ui/row_without_context.rs b/packages/stack-encrypt/tests/ui/row_without_context.rs new file mode 100644 index 000000000..43c443cf9 --- /dev/null +++ b/packages/stack-encrypt/tests/ui/row_without_context.rs @@ -0,0 +1,18 @@ +//! `row = ..` requires an explicit container `context = ".."`: the prefix is +//! part of the stored data's identity — the AAD of every ciphertext in the +//! row and the domain of every term — so it is never inferred from the Rust +//! type's name. Two types named `Account` in different modules must not +//! silently share every column context. +use stack_encrypt::{EncryptFrom, StackCipherText}; + +struct User { + email: String, +} + +#[derive(EncryptFrom)] +#[stash(row = User)] +struct EncryptedUser { + email: StackCipherText, +} + +fn main() {} diff --git a/packages/stack-encrypt/tests/ui/row_without_context.stderr b/packages/stack-encrypt/tests/ui/row_without_context.stderr new file mode 100644 index 000000000..f8443c576 --- /dev/null +++ b/packages/stack-encrypt/tests/ui/row_without_context.stderr @@ -0,0 +1,5 @@ +error: `row = ..` needs a `context = ".."` beside it naming the table (e.g. `#[stash(row = User, context = "users")]`): each field is derived under `"<context>/<field>"`, and the prefix is part of the stored data's identity, so it is given explicitly rather than inferred from the Rust type's name + --> tests/ui/row_without_context.rs:13:15 + | +13 | #[stash(row = User)] + | ^^^^ From 882ececd31d3636aadba2c1c840c200cee8acee5 Mon Sep 17 00:00:00 2001 From: Dan Draper <dan@cipherstash.com> Date: Mon, 31 Aug 2026 21:52:19 +1000 Subject: [PATCH 471/686] docs(stack-encrypt): design doc and RFC follow the explicit row context MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Missed in bd3b61824: docs/target-directed-encryption.md and RFC 0002 §7 still described the row prefix as inferred from the snake-cased type name. Claude-Session: https://claude.ai/code/session_01P5YHK3w6Kj9ajTnmkaXCHW --- ...async-shape-for-target-directed-encryption.md | 9 ++++++--- docs/target-directed-encryption.md | 16 ++++++++++++---- 2 files changed, 18 insertions(+), 7 deletions(-) diff --git a/docs/rfcs/0002-async-shape-for-target-directed-encryption.md b/docs/rfcs/0002-async-shape-for-target-directed-encryption.md index 49d8eaa81..942f0d733 100644 --- a/docs/rfcs/0002-async-shape-for-target-directed-encryption.md +++ b/docs/rfcs/0002-async-shape-for-target-directed-encryption.md @@ -580,9 +580,12 @@ off and decrypt to whatever its ciphertext field opens to. The derives are named after the trait they emit, as serde's are, and the attribute after the crate: `#[stash(plaintext = ..)]` for a record, -`#[stash(row = ..)]` for a row — which infers every field's `from` (its own -name) and context (`"<snake_case type>/<plaintext field>"`), with -`#[stash(from = .., context = "..")]` as the per-field overrides. Which +`#[stash(row = .., context = "..")]` for a row — which infers every field's +`from` (its own name) and the field half of its context +(`"<context>/<plaintext field>"`; the prefix is the required container +`context`, given explicitly because it is stored-data identity and must not +follow a Rust type's name), with `#[stash(from = .., context = "..")]` and +`#[stash(nested)]` as the per-field overrides. Which field decryption opens is not an attribute either but a property of the field types (`Decryptable`), checked at compile time to be exactly one; `decrypt` is the override for records the types cannot settle. First shipped as diff --git a/docs/target-directed-encryption.md b/docs/target-directed-encryption.md index 574f163e6..147e9e717 100644 --- a/docs/target-directed-encryption.md +++ b/docs/target-directed-encryption.md @@ -178,16 +178,24 @@ One level up, unchanged — same trait, now written by the derive: ```rust #[derive(EncryptFrom)] -#[stash(row = User)] +#[stash(row = User, context = "users")] struct EncryptedUser { - age: IntegerOrdOre, // from user.age, under "user/age" - email: TextEq, // from user.email, under "user/email" + age: IntegerOrdOre, // from user.age, under "users/age" + email: TextEq, // from user.email, under "users/email" } let row: EncryptedUser = user.encrypt_into(&cipher).await?; // one batch, no context: the fields carry theirs ``` -`row = User` infers each field's `from` (its own name) and context (`"<snake_case type>/<plaintext field>"`); `#[stash(from = ..)]` and `#[stash(context = "..")]` on a field are the overrides. The inferred context is derived from Rust identifiers and is the AAD of every stored ciphertext in the column, so renaming the type or a field is a data migration: pin the old literal with `context = ".."` first. +`row = User` infers each field's `from` (its own name) and the field half of +its context (`"<context>/<plaintext field>"`); the prefix is the required +container `context`, named explicitly — never inferred from the Rust type's +name, which two types can share and a refactor can change. `#[stash(from = ..)]` +and `#[stash(context = "..")]` on a field are the overrides, and +`#[stash(nested)]` marks a field whose type is itself a row carrying its own +contexts (it is handed `()`). The context is the AAD of every stored +ciphertext in the column, so renaming a plaintext *field* is still a data +migration: pin the old literal with `context = ".."` first. Leaf, payload and row are the same trait, and a column of rows is `Vec<T>`'s structural impl over the same trait — `ages.encrypt_into_with_context(&cipher, ctx)` for a `Vec<u32>` is one batched call, and `users.encrypt_into(&cipher)` for a `Vec<User>` likewise. Recursion does the rest. Earlier sketches of this design had a separate input-side derive for rows — that was a second mechanism the naming was hiding. From c02729a3e8f85e07af9607636892049b288b89da Mon Sep 17 00:00:00 2001 From: Dan Draper <dan@cipherstash.com> Date: Tue, 1 Sep 2026 12:54:47 +1000 Subject: [PATCH 472/686] docs(stack-encrypt): the bridge snippet uses the supplied-context choke point `is_degenerate_aad` went back to crate-private when `supplied_aad` became the one public way to validate and encode a supplied context, so the design doc's `StackCipherText` bridge no longer matched the impl it quotes. Claude-Session: https://claude.ai/code/session_01VEKAfJiDDhSxEZRAVJQJPX --- docs/target-directed-encryption.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/target-directed-encryption.md b/docs/target-directed-encryption.md index 147e9e717..3787f47b5 100644 --- a/docs/target-directed-encryption.md +++ b/docs/target-directed-encryption.md @@ -210,10 +210,10 @@ where S: Encrypt + Clone, Ctx: EncryptContext<'c> + SuppliedContext<'c>, fn encrypt_from<'a>(source: &'a S, cipher: &'a StackCipher<K>, context: Ctx) -> Pending<'a, Self, K> where Self: 'a, { - let aad = context.into_aad().into_owned(); - if is_degenerate_aad(aad.as_bytes()) { - return Pending::failed(cipher, Error::EmptyContext); - } + let aad = match supplied_aad(context) { // validates and encodes; Error::EmptyContext on a degenerate one + Ok(aad) => aad, + Err(error) => return Pending::failed(cipher, error), + }; match source.clone().encrypt_with_aad(cipher, aad) { // vitaminc Encrypt, untouched Ok(tree) => seal_pending(cipher, tree), // one data-key request per leaf Err(_) => Pending::ready(cipher, Err(Error::Aead)), From 62d8dfc5d023e8485e6c67ae61b7275f52576c1c Mon Sep 17 00:00:00 2001 From: Dan Draper <dan@cipherstash.com> Date: Tue, 1 Sep 2026 14:58:28 +1000 Subject: [PATCH 473/686] test(stack-encrypt): the nested-row retrieve count is exact Review finding on cipherstash/cipherstash-suite#2164: the row decrypt now runs before the field-alone plan decrypt, so the counter reads exactly 1 where the other counter assertions in the file are exact too, instead of the `>= 1` that hid the total. Claude-Session: https://claude.ai/code/session_01P5YHK3w6Kj9ajTnmkaXCHW --- packages/stack-encrypt/tests/derive.rs | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/packages/stack-encrypt/tests/derive.rs b/packages/stack-encrypt/tests/derive.rs index df182bb59..56259fc7a 100644 --- a/packages/stack-encrypt/tests/derive.rs +++ b/packages/stack-encrypt/tests/derive.rs @@ -508,24 +508,26 @@ async fn a_row_nests_in_a_row_in_row_mode_via_nested() { let row: EncryptedAccountRow = account.encrypt_into(&cipher).await.unwrap(); assert_eq!(generates.load(AtomicOrdering::SeqCst), 1); - // The inner row's fields are under their own literals; the outer's plan - // is under the inferred `"accounts/plan"`. + // The inner row's fields are still under their own literals. let age_hm: EqualityTerm = 42u32 .encrypt_into_with_context(&generator, "user/age") .await .unwrap(); assert_eq!(row.user.age.hm, age_hm); + + let recovered = Account::decrypt_from(row, &cipher).await.unwrap(); + assert_eq!(recovered, account); + assert_eq!(retrieves.load(AtomicOrdering::SeqCst), 1); + + // The outer's plan is under the inferred `"accounts/plan"`. Decrypting + // the row consumed it, so mint a fresh one to open the field alone. + let row: EncryptedAccountRow = account.encrypt_into(&cipher).await.unwrap(); let plan: String = row .plan .decrypt_into(&cipher, "accounts/plan") .await .unwrap(); assert_eq!(plan, "pro"); - - let row: EncryptedAccountRow = account.encrypt_into(&cipher).await.unwrap(); - let recovered = Account::decrypt_from(row, &cipher).await.unwrap(); - assert_eq!(recovered, account); - assert!(retrieves.load(AtomicOrdering::SeqCst) >= 1); } /// A tuple-struct plaintext is reached by index — inferred for a tuple row, From 085b7f70ca03afc2e8ab0694187d1f994ea76c82 Mon Sep 17 00:00:00 2001 From: Dan Draper <dan@cipherstash.com> Date: Thu, 27 Aug 2026 23:52:13 +1000 Subject: [PATCH 474/686] feat(stack-kms)!: build stack-auth, stack-kms and stack-encrypt for WASI without reqwest Phase 1 of the stack-encrypt Go bindings (docs/stack-encrypt-go-bindings.md). On wasm32-wasip1 reqwest 0.13.4 selects a native backend (tokio-full and the aws-lc-sys TLS provider) that does not build for WASI, and it was the only thing standing between the three stack crates and the target. HTTP is host-provided under wazero, so it has to be out of the WASI build by construction: - `http` feature, default on, in all three crates: stack-encrypt/http -> stack-kms/http -> stack-auth/http -> dep:reqwest. stack-auth keeps the token model, `AuthStrategy`, `AuthStrategyFn` and `StaticTokenStrategy` unconditionally; every HTTP-speaking strategy, the refresh engine, device binding and `Token::refresh` sit behind the feature. stack-kms keeps `Client<C>`, key derivation and the key-source traits; `HttpConnection`, its options and `StackKmsBuilder` sit behind it. stack-encrypt keeps `StackCipher::builder().kms(..)`; `StackCipher::new()` and the from-environment `init` sit behind it. stack-kms and stack-encrypt take stack-auth as a path dep with `default-features = false` (a workspace dep's defaults cannot be turned off by a member). - `StackKms<C, Conn = HttpConnection>` with `StackKms::connect(opts, credentials, client_key)`: the transport-injecting constructor a host with its own `ZeroKMSConnection` builds through, and the only one without `http`. `ZeroKMSConnection` gains `ensure_base_url` / `has_base_url` so endpoint discovery from the token's `services` claim works over any connection (previously inherent to `HttpConnection`). - `Error::ConnectionInit` boxes the connection's own init error. - `StackCipher::builder()` lives on `impl StackCipher<FromEnv>` so it resolves without a type annotation whether or not `http` is on. - `wasm:wasi-check` now gates stack-auth, stack-kms and stack-encrypt (`--no-default-features`) alongside the core crates; the CI workflow's paths cover them. - A unit test drives `StackKms` end to end over the in-memory `TestConnection`. Verified: `mise run wasm:wasi-check` passes for all eight crates with no reqwest/hyper/aws-lc-sys in any wasip1 tree; `mise run lint` clean; 451 tests pass with `--all-features` and with `--no-default-features`; doc tests and rustdoc (`-D warnings`) pass. BREAKING CHANGE: stack-auth's `RequestError` tuple payload is now `Box<dyn std::error::Error + Send + Sync + 'static>` instead of `reqwest::Error`. Construct it via `RequestError::from(reqwest_error)` (or box the error yourself) instead of `RequestError(reqwest_error)`, and recover the concrete error with `.0.downcast_ref::<reqwest::Error>()` instead of using `.0` as a `reqwest::Error` directly. `source()` behaviour is unchanged; the `Display` message is now "Request to the auth server failed" (previously "HTTP request failed"). Claude-Session: https://claude.ai/code/session_01HU1Bbw4eQp9kEEneXxDcKr --- docs/plans/stack-encrypt-go-bindings.md | 21 +- packages/stack-auth/Cargo.toml | 13 +- packages/stack-auth/src/error.rs | 15 +- packages/stack-auth/src/lib.rs | 86 +++- packages/stack-auth/src/token.rs | 25 +- packages/stack-encrypt/Cargo.toml | 29 +- packages/stack-encrypt/src/cipher.rs | 30 +- packages/stack-kms/Cargo.toml | 19 +- packages/stack-kms/src/client.rs | 98 +++- .../stack-kms/src/client/test_connection.rs | 9 + packages/stack-kms/src/connection.rs | 444 ++---------------- packages/stack-kms/src/connection/http.rs | 420 +++++++++++++++++ packages/stack-kms/src/errors.rs | 14 +- packages/stack-kms/src/key_source.rs | 6 +- packages/stack-kms/src/lib.rs | 12 +- 15 files changed, 764 insertions(+), 477 deletions(-) create mode 100644 packages/stack-kms/src/connection/http.rs diff --git a/docs/plans/stack-encrypt-go-bindings.md b/docs/plans/stack-encrypt-go-bindings.md index a8b560c39..aafdc2244 100644 --- a/docs/plans/stack-encrypt-go-bindings.md +++ b/docs/plans/stack-encrypt-go-bindings.md @@ -154,6 +154,11 @@ memory once at `init`; derived data keys and the index key never leave. ### Phase 0 — salvage #2099 onto `stack-kms` +**Landed** as the first stacked PR: the gate, the CI workflow, Layer 6, and +this document. `WasiHostConnection`, the `bridge.go` host function and the +integration harness are ported in Phase 3 with the guest they serve; #2099 +is left open with a pointer here for its author to close. + #2099 targets `cipherstash-client`, which is being replaced by `stack-kms` (no parity fixes go into the old crate). Rebase the reusable pieces rather than the branch: @@ -175,8 +180,20 @@ than the branch: ### Phase 1 — `stack-kms` and `stack-auth` build for WASI without HTTP -Smallest change that makes the guest link, with the seam in the right place -for the later `stack-transport` refactor: +**Landed (stacked PR on Phase 0).** What shipped, against the plan below: a +default-on `http` feature in all three crates (`stack-encrypt/http` → +`stack-kms/http` → `stack-auth/http` → `dep:reqwest`); `StackKms<C, Conn>` +with `StackKms::connect(opts, credentials, client_key)` as the +transport-injecting constructor; `ZeroKMSConnection` grew +`ensure_base_url` / `has_base_url` so endpoint discovery from the token's +`services` claim works over any connection; `StackCipher::builder()` moved +to `impl StackCipher<FromEnv>` so it resolves without `http`; +`wasm:wasi-check` gates all eight crates. A unit test drives `StackKms` +end to end over the in-memory `TestConnection`. Verified: +`cargo check --target wasm32-wasip1 -p stack-encrypt --no-default-features` +passes with no `reqwest`/`hyper`/`aws-lc-sys` in the tree. + +The plan as written before the work: **stack-kms** diff --git a/packages/stack-auth/Cargo.toml b/packages/stack-auth/Cargo.toml index 6af9bfdda..140f3445c 100644 --- a/packages/stack-auth/Cargo.toml +++ b/packages/stack-auth/Cargo.toml @@ -18,7 +18,7 @@ aquamarine = "0.6" base64 = { workspace = true } cts-common = { workspace = true } miette = { workspace = true } -reqwest = { workspace = true } +reqwest = { workspace = true, optional = true } serde = { workspace = true } serde_json = { workspace = true } thiserror = { workspace = true } @@ -54,6 +54,14 @@ tokio = { workspace = true } tokio = { version = "1.47.1", default-features = false, features = ["sync"] } [features] +default = ["http"] +# Everything that talks HTTP: the access-key, device-session, OIDC-federation +# and auto strategies, device binding and the device-code flow, and `Token:: +# refresh`. Off, the crate is the token model plus the `AuthStrategy` trait +# (with `AuthStrategyFn` / `StaticTokenStrategy` to implement it), and reqwest +# and its native TLS stack are not in the dependency graph at all — the shape +# a host with its own transport (the WASI/wazero guest) builds against. +http = ["dep:reqwest"] test-utils = [] # Exposes fuzz-only entry points (e.g. `fuzz_decode_claims`) for the cargo-fuzz # harnesses in `fuzz/`. A Cargo feature (not `#[cfg(fuzzing)]`) so it is a known @@ -62,10 +70,11 @@ fuzz = [] [[example]] name = "auto_strategy" +required-features = ["http"] [[example]] name = "device_code" -required-features = ["test-utils"] +required-features = ["http", "test-utils"] [dev-dependencies] axum = "0.8" diff --git a/packages/stack-auth/src/error.rs b/packages/stack-auth/src/error.rs index b6b26dcc1..52369af47 100644 --- a/packages/stack-auth/src/error.rs +++ b/packages/stack-auth/src/error.rs @@ -76,9 +76,19 @@ pub(crate) mod codes { // --------------------------------------------------------------------------- /// The HTTP request to the auth server failed (network error, timeout, etc.). +#[cfg(feature = "http")] #[derive(Debug, thiserror::Error, miette::Diagnostic)] #[error("HTTP request failed: {0}")] pub struct RequestError(pub reqwest::Error); + +/// The request to the auth server failed (network error, timeout, etc.). +/// +/// Without the `http` feature the crate makes no requests of its own, so the +/// payload is whatever the host's transport reports. +#[cfg(not(feature = "http"))] +#[derive(Debug, thiserror::Error, miette::Diagnostic)] +#[error("HTTP request failed: {0}")] +pub struct RequestError(pub Box<dyn std::error::Error + Send + Sync + 'static>); impl AuthErrorKind for RequestError { fn error_code(&self) -> &'static str { codes::REQUEST_ERROR @@ -837,6 +847,7 @@ impl serde::Serialize for AuthError { // foreign error straight into `AuthError` (the per-struct wrapping is internal). // --------------------------------------------------------------------------- +#[cfg(feature = "http")] impl From<reqwest::Error> for AuthError { fn from(e: reqwest::Error) -> Self { Self::Request(RequestError(e)) @@ -880,7 +891,7 @@ impl From<stack_profile::ProfileError> for AuthError { } } -#[cfg(not(target_arch = "wasm32"))] +#[cfg(all(feature = "http", not(target_arch = "wasm32")))] impl From<crate::DeviceClientError> for AuthError { fn from(e: crate::DeviceClientError) -> Self { use crate::DeviceClientError as E; @@ -1390,7 +1401,7 @@ mod tests { /// path. (`Request` wraps a `reqwest::Error`, which has no public /// constructor, so it can't be built here — the same gap the exhaustive /// `error_code` test documents.) - #[cfg(not(target_arch = "wasm32"))] + #[cfg(all(feature = "http", not(target_arch = "wasm32")))] #[test] fn device_client_error_maps_to_canonical_auth_error() { use crate::DeviceClientError as E; diff --git a/packages/stack-auth/src/lib.rs b/packages/stack-auth/src/lib.rs index 23498a910..898f76383 100644 --- a/packages/stack-auth/src/lib.rs +++ b/packages/stack-auth/src/lib.rs @@ -16,6 +16,12 @@ #![warn(unused_results)] #![warn(clippy::todo)] #![warn(clippy::unimplemented)] +// Without `http` the crate is the token model plus the `AuthStrategy` trait; +// the crate-internal helpers that only the HTTP strategies call (refusal +// classification, clock sharing, URL massaging, token setters) are then +// unreferenced. They are still the same code — a feature subset, not dead +// code — so don't make every one of them carry its own gate. +#![cfg_attr(not(feature = "http"), allow(dead_code))] // Relax in tests #![cfg_attr(test, allow(clippy::unwrap_used))] #![cfg_attr(test, allow(clippy::expect_used))] @@ -23,29 +29,48 @@ #![cfg_attr(test, allow(unused_results))] use std::future::Future; -#[cfg(all(not(any(test, feature = "test-utils")), not(target_arch = "wasm32")))] +#[cfg(all( + feature = "http", + not(any(test, feature = "test-utils")), + not(target_arch = "wasm32") +))] use std::time::Duration; use vitaminc::protected::OpaqueDebug; use zeroize::ZeroizeOnDrop; mod access_key; +mod auth_strategy_fn; +mod clock; +mod error; +mod service_token; +mod token; +mod token_store; + +// The strategies that acquire and refresh tokens over HTTP, and the refresh +// engine they share. Behind the `http` feature: without it the crate is the +// token model plus the `AuthStrategy` trait, for hosts that source tokens +// through their own transport. +#[cfg(feature = "http")] mod access_key_refresher; +#[cfg(feature = "http")] mod access_key_strategy; -mod auth_strategy_fn; +#[cfg(feature = "http")] mod authorize_dto; +#[cfg(feature = "http")] mod auto_refresh; +#[cfg(feature = "http")] mod auto_strategy; -mod clock; +#[cfg(feature = "http")] mod device_session_refresher; +#[cfg(feature = "http")] mod device_session_strategy; -mod error; +#[cfg(feature = "http")] mod oidc_federation_strategy; +#[cfg(feature = "http")] mod oidc_refresher; +#[cfg(feature = "http")] mod refresher; -mod service_token; -mod token; -mod token_store; #[cfg(not(target_arch = "wasm32"))] pub use error::StoreError; @@ -60,9 +85,9 @@ pub use error::{ // native-only — both pull `stack-profile` (which uses `dirs` + `gethostname`) // and the device-code flow launches a browser via `open::that`. Wasm consumers // use `DeviceSessionStrategy::with_token` or `AccessKeyStrategy`. -#[cfg(not(target_arch = "wasm32"))] +#[cfg(all(feature = "http", not(target_arch = "wasm32")))] mod device_client; -#[cfg(not(target_arch = "wasm32"))] +#[cfg(all(feature = "http", not(target_arch = "wasm32")))] mod device_code; #[cfg(any(test, feature = "test-utils"))] @@ -72,11 +97,16 @@ mod static_token_strategy; mod test_support; pub use access_key::{AccessKey, InvalidAccessKey}; +#[cfg(feature = "http")] pub use access_key_strategy::{AccessKeyStrategy, AccessKeyStrategyBuilder}; pub use auth_strategy_fn::AuthStrategyFn; +#[cfg(feature = "http")] pub use auto_strategy::{AutoStrategy, AutoStrategyBuilder}; +#[cfg(feature = "http")] pub use device_session_strategy::{DeviceSessionStrategy, DeviceSessionStrategyBuilder}; +#[cfg(feature = "http")] pub use oidc_federation_strategy::{OidcFederationStrategy, OidcFederationStrategyBuilder}; +#[cfg(feature = "http")] pub use oidc_refresher::{OidcProvider, OidcProviderFn}; pub use service_token::ServiceToken; #[cfg(any(test, feature = "test-utils"))] @@ -90,16 +120,18 @@ pub use token_store::{InMemoryTokenStore, NoStore, TokenStore, TokenStoreFn}; /// ([`OidcFederationStrategy`]) distinction explicit. The old name still /// resolves so existing code keeps compiling; it will be removed in a future /// major release. +#[cfg(feature = "http")] #[deprecated(since = "0.36.0", note = "renamed to `DeviceSessionStrategy`")] pub type OAuthStrategy = DeviceSessionStrategy; /// Deprecated alias for [`DeviceSessionStrategyBuilder`]. +#[cfg(feature = "http")] #[deprecated(since = "0.36.0", note = "renamed to `DeviceSessionStrategyBuilder`")] pub type OAuthStrategyBuilder = DeviceSessionStrategyBuilder; -#[cfg(not(target_arch = "wasm32"))] +#[cfg(all(feature = "http", not(target_arch = "wasm32")))] pub use device_client::{bind_client_device, DeviceClientError}; -#[cfg(not(target_arch = "wasm32"))] +#[cfg(all(feature = "http", not(target_arch = "wasm32")))] pub use device_code::{DeviceCodeStrategy, DeviceCodeStrategyBuilder, PendingDeviceCode}; // Re-exports from stack-profile for backward compatibility. @@ -119,17 +151,24 @@ pub use stack_profile::DeviceIdentity; /// All items in this module are also re-exported at the crate root. pub mod auth { pub use crate::{ - AccessKey, AccessKeyStrategy, AccessKeyStrategyBuilder, AuthError, AuthStrategy, - AuthStrategyBounds, AuthStrategyFn, AutoStrategy, AutoStrategyBuilder, - DeviceSessionStrategy, DeviceSessionStrategyBuilder, InvalidAccessKey, - OidcFederationStrategy, OidcFederationStrategyBuilder, OidcProvider, OidcProviderFn, + AccessKey, AuthError, AuthStrategy, AuthStrategyBounds, AuthStrategyFn, InvalidAccessKey, SecretToken, ServiceToken, }; + #[cfg(feature = "http")] + pub use crate::{ + AccessKeyStrategy, AccessKeyStrategyBuilder, AutoStrategy, AutoStrategyBuilder, + DeviceSessionStrategy, DeviceSessionStrategyBuilder, OidcFederationStrategy, + OidcFederationStrategyBuilder, OidcProvider, OidcProviderFn, + }; + #[cfg(not(target_arch = "wasm32"))] + pub use crate::DeviceIdentity; + + #[cfg(all(feature = "http", not(target_arch = "wasm32")))] pub use crate::{ bind_client_device, DeviceClientError, DeviceCodeStrategy, DeviceCodeStrategyBuilder, - DeviceIdentity, PendingDeviceCode, + PendingDeviceCode, }; #[cfg(any(test, feature = "test-utils"))] @@ -138,6 +177,7 @@ pub mod auth { // Deprecated aliases, re-exported here too so `stack_auth::auth::OAuthStrategy` // consumers keep compiling alongside the crate-root aliases. See the // `OAuthStrategy` / `OAuthStrategyBuilder` definitions at the crate root. + #[cfg(feature = "http")] #[allow(deprecated)] pub use crate::{OAuthStrategy, OAuthStrategyBuilder}; } @@ -364,14 +404,18 @@ where /// does not auto-advance time past the connect timeout before the mock server /// can respond. On wasm32, reqwest's fetch backend doesn't expose /// `connect_timeout`/`pool_*` — the host runtime owns those concerns. -#[cfg(any(test, feature = "test-utils"))] +#[cfg(all(feature = "http", any(test, feature = "test-utils")))] pub(crate) fn http_client() -> reqwest::Client { reqwest::Client::builder() .build() .unwrap_or_else(|_| reqwest::Client::new()) } -#[cfg(all(not(any(test, feature = "test-utils")), not(target_arch = "wasm32")))] +#[cfg(all( + feature = "http", + not(any(test, feature = "test-utils")), + not(target_arch = "wasm32") +))] pub(crate) fn http_client() -> reqwest::Client { reqwest::Client::builder() .connect_timeout(Duration::from_secs(10)) @@ -382,7 +426,11 @@ pub(crate) fn http_client() -> reqwest::Client { .unwrap_or_else(|_| reqwest::Client::new()) } -#[cfg(all(not(any(test, feature = "test-utils")), target_arch = "wasm32"))] +#[cfg(all( + feature = "http", + not(any(test, feature = "test-utils")), + target_arch = "wasm32" +))] pub(crate) fn http_client() -> reqwest::Client { // Wasm32 reqwest uses the host's `fetch`; timeouts and pooling are owned // by the runtime, so `ClientBuilder` doesn't expose them here. diff --git a/packages/stack-auth/src/token.rs b/packages/stack-auth/src/token.rs index acd4b1d5c..44e31d5ac 100644 --- a/packages/stack-auth/src/token.rs +++ b/packages/stack-auth/src/token.rs @@ -2,7 +2,9 @@ use cts_common::claims::ClientClaims; use cts_common::{Crn, Region, WorkspaceId}; use url::Url; -use crate::{http_client, AuthError, SecretToken}; +#[cfg(feature = "http")] +use crate::http_client; +use crate::{AuthError, SecretToken}; #[cfg(not(target_arch = "wasm32"))] impl stack_profile::ProfileData for Token { @@ -231,6 +233,7 @@ impl Token { /// - [`AuthError::InvalidGrant`] — the refresh token was revoked or expired. /// - [`AuthError::InvalidClient`] — the client ID is not recognized. /// - [`AuthError::Request`] — a network error occurred. + #[cfg(feature = "http")] pub async fn refresh( refresh_token: &SecretToken, base_url: &Url, @@ -300,6 +303,7 @@ impl Token { } } +#[cfg(feature = "http")] #[derive(serde::Serialize)] struct RefreshRequest<'a> { grant_type: &'a str, @@ -309,6 +313,7 @@ struct RefreshRequest<'a> { device_instance_id: Option<&'a str>, } +#[cfg(feature = "http")] #[derive(serde::Deserialize)] struct RefreshResponse { access_token: SecretToken, @@ -323,6 +328,7 @@ struct RefreshResponse { /// `cs_code` is deliberately absent: `classify_issuance_failure` inspects it /// on the raw body before this type is ever constructed, so duplicating the /// field here would create a second place for the two to disagree. +#[cfg(feature = "http")] #[derive(serde::Deserialize)] struct RefreshErrorResponse { error: String, @@ -335,6 +341,7 @@ mod tests { use super::*; use crate::test_support::{claims_with_workspace, jwt_token, raw_token}; use crate::AuthError; + #[cfg(feature = "http")] use mocktail::prelude::*; fn make_token(expires_in: u64, refresh: bool) -> Token { @@ -353,6 +360,7 @@ mod tests { } } + #[cfg(feature = "http")] fn refresh_response_json() -> serde_json::Value { serde_json::json!({ "access_token": "new-access-token", @@ -362,6 +370,7 @@ mod tests { }) } + #[cfg(feature = "http")] fn error_json(error: &str) -> serde_json::Value { serde_json::json!({ "error": error, @@ -369,6 +378,7 @@ mod tests { }) } + #[cfg(feature = "http")] async fn start_server(mocks: MockSet) -> MockServer { let server = MockServer::new_http("token-refresh-test").with_mocks(mocks); server.start().await.unwrap(); @@ -443,6 +453,7 @@ mod tests { // ---- refresh() tests ---- + #[cfg(feature = "http")] #[tokio::test] async fn test_refresh_success() { let mut mocks = MockSet::new(); @@ -468,6 +479,7 @@ mod tests { assert!((3598..=3600).contains(&refreshed.expires_in())); } + #[cfg(feature = "http")] #[tokio::test] async fn test_refresh_invalid_grant() { let mut mocks = MockSet::new(); @@ -486,6 +498,7 @@ mod tests { assert!(matches!(err, AuthError::InvalidGrant(_))); } + #[cfg(feature = "http")] #[tokio::test] async fn test_refresh_invalid_client() { let mut mocks = MockSet::new(); @@ -504,6 +517,7 @@ mod tests { assert!(matches!(err, AuthError::InvalidClient(_))); } + #[cfg(feature = "http")] #[tokio::test] async fn test_refresh_access_denied() { let mut mocks = MockSet::new(); @@ -531,6 +545,7 @@ mod tests { // server response cannot mean different things depending on which // refresher the caller happened to use. + #[cfg(feature = "http")] async fn refresh_against(status: reqwest::StatusCode, body: serde_json::Value) -> AuthError { let mut mocks = MockSet::new(); mocks.mock(move |when, then| { @@ -544,6 +559,7 @@ mod tests { .expect_err("a non-2xx refresh must fail") } + #[cfg(feature = "http")] #[tokio::test] async fn refresh_402_with_cs_code_is_usage_limit() { let err = refresh_against( @@ -568,6 +584,7 @@ mod tests { ); } + #[cfg(feature = "http")] #[tokio::test] async fn refresh_402_access_denied_without_cs_code_is_usage_limit() { let err = refresh_against( @@ -584,6 +601,7 @@ mod tests { } /// Guards arm ORDER: `access_denied` only means "usage limit" at 402. + #[cfg(feature = "http")] #[tokio::test] async fn refresh_403_access_denied_is_still_access_denied() { let err = refresh_against( @@ -602,6 +620,7 @@ mod tests { /// the status, so a bodyless 402 surfaced as a reqwest decode error while /// the other two issuance paths classified it as a usage limit. Same server /// response, two different client errors. + #[cfg(feature = "http")] #[tokio::test] async fn refresh_402_with_empty_body_is_usage_limit() { let mut mocks = MockSet::new(); @@ -625,6 +644,7 @@ mod tests { /// A 402 whose `cs_code` we cannot read must not claim a usage limit — /// mirrors `unreadable_cs_code_declines_to_classify` on the shared path. + #[cfg(feature = "http")] #[tokio::test] async fn refresh_402_with_unknown_cs_code_does_not_claim_usage_limit() { let err = refresh_against( @@ -640,6 +660,7 @@ mod tests { ); } + #[cfg(feature = "http")] #[tokio::test] async fn test_refresh_unknown_error() { let mut mocks = MockSet::new(); @@ -660,6 +681,7 @@ mod tests { ); } + #[cfg(feature = "http")] #[tokio::test] async fn test_refresh_response_without_new_refresh_token() { let mut mocks = MockSet::new(); @@ -683,6 +705,7 @@ mod tests { assert!(refreshed.refresh_token().is_none()); } + #[cfg(feature = "http")] #[tokio::test] async fn test_refresh_debug_does_not_leak_tokens() { let token = make_token(3600, true); diff --git a/packages/stack-encrypt/Cargo.toml b/packages/stack-encrypt/Cargo.toml index 36efaad31..00d34e9b4 100644 --- a/packages/stack-encrypt/Cargo.toml +++ b/packages/stack-encrypt/Cargo.toml @@ -21,8 +21,8 @@ publish = false # CS_CLIENT_KEY are not set — the same place `AutoStrategy` finds the token. stack-kms = { path = "../stack-kms", default-features = false, features = ["profile"] } # `StackCipher::new()` builds a ZeroKMS client from the environment, so its -# return type names the auto-detected auth strategy. -stack-auth = { workspace = true } +# return type names the auto-detected auth strategy. Only needed with `http`. +stack-auth = { path = "../stack-auth", version = "0.42.3", optional = true, default-features = false } # `#[derive(EncryptFrom)]` / `#[derive(DecryptInto)]`, re-exported from `target`. stack-encrypt-derive = { path = "../stack-encrypt-derive" } @@ -49,6 +49,15 @@ thiserror = { workspace = true } uuid = { workspace = true } zeroize = { workspace = true } +[features] +default = ["http"] +# `StackCipher::new()` / `StackCipher::builder().init()` from the environment: +# the default HTTP transport in stack-kms and the auto-detected auth strategy +# in stack-auth. Off, a cipher is built over an explicit `DataKeySource` +# (`StackCipher::builder().kms(..)`) and no HTTP client or TLS stack is in +# the dependency graph — the shape the WASI/wazero guest builds against. +http = ["dep:stack-auth", "stack-auth/http", "stack-kms/http"] + [dev-dependencies] serde_json = { workspace = true } stack-kms = { path = "../stack-kms", features = ["test-support"] } @@ -59,5 +68,21 @@ vitaminc-hmac = { git = "https://github.com/cipherstash/vitaminc", rev = "04f4fa vitaminc-prf = { git = "https://github.com/cipherstash/vitaminc", rev = "04f4faa3eeeb69ae30dd0247865e06c640585fea" } vitaminc-protected = { git = "https://github.com/cipherstash/vitaminc", rev = "04f4faa3eeeb69ae30dd0247865e06c640585fea" } +[[example]] +name = "encrypted_record" +required-features = ["http"] + +[[example]] +name = "mixed_user" +required-features = ["http"] + +[[example]] +name = "search_terms" +required-features = ["http"] + +[[example]] +name = "zerokms_auth" +required-features = ["http"] + [package.metadata.docs.rs] all-features = true diff --git a/packages/stack-encrypt/src/cipher.rs b/packages/stack-encrypt/src/cipher.rs index 7e36555e1..a6cc2c7a0 100644 --- a/packages/stack-encrypt/src/cipher.rs +++ b/packages/stack-encrypt/src/cipher.rs @@ -68,11 +68,10 @@ use std::borrow::Cow; use std::collections::HashSet; use serde::{Deserialize, Serialize}; -use stack_kms::{ - DataKey, DataKeySource, DataKeyWithTag, EnvKeyProvider, IdentifiedBy, IndexKeySource, StackKms, - StackKmsBuilder, -}; -#[cfg(not(target_arch = "wasm32"))] +use stack_kms::{DataKey, DataKeySource, DataKeyWithTag, IdentifiedBy, IndexKeySource}; +#[cfg(feature = "http")] +use stack_kms::{EnvKeyProvider, StackKms, StackKmsBuilder}; +#[cfg(all(feature = "http", not(target_arch = "wasm32")))] use stack_kms::{FallbackKeyProvider, KeyProvider, KeyProviderError, ProfileStore}; use uuid::Uuid; use vitaminc_aead::{ @@ -110,6 +109,7 @@ pub enum Error { KeyCountMismatch { expected: usize, received: usize }, /// Building a ZeroKMS client from the environment failed: credentials or /// client key missing or malformed. + #[cfg(feature = "http")] #[error("could not build a ZeroKMS client from the environment: {0}")] Config(#[from] stack_kms::StackKmsBuilderError), /// The per-field encryption context was empty. An empty context defeats @@ -226,18 +226,25 @@ pub struct StackCipher<K> { prf: vitaminc_hmac::HmacSha256Prf, } +#[cfg(feature = "http")] impl StackCipher<StackKms<stack_auth::AutoStrategy>> { /// Build a cipher over a ZeroKMS client configured from the environment, /// on that client's default keyset. /// /// Equivalent to `StackCipher::builder().init()`. For a different keyset - /// or a different data-key source, use [`builder`](Self::builder). + /// or a different data-key source, use [`builder`](StackCipher::builder). pub async fn new() -> Result<Self, Error> { - Self::builder().init().await + StackCipher::builder().init().await } +} +impl StackCipher<FromEnv> { /// Start building a cipher: pick a keyset, or supply a data-key source /// other than the environment's ZeroKMS client. + /// + /// (Defined on `StackCipher<FromEnv>` — a type that is never + /// constructed — so that `StackCipher::builder()` resolves without a + /// type annotation whether or not the `http` feature is on.) pub fn builder() -> StackCipherBuilder { StackCipherBuilder { kms: FromEnv, @@ -277,7 +284,7 @@ pub struct FromEnv; /// workspace's `secretkey.json` in the profile directory. A profile directory /// that cannot be resolved is not an error here — env-only setups (CI) have /// none — it just leaves the environment as the only source. -#[cfg(not(target_arch = "wasm32"))] +#[cfg(all(feature = "http", not(target_arch = "wasm32")))] fn client_key_provider() -> FallbackKeyProvider<EnvKeyProvider, ProfileClientKey> { FallbackKeyProvider::new( EnvKeyProvider, @@ -286,7 +293,7 @@ fn client_key_provider() -> FallbackKeyProvider<EnvKeyProvider, ProfileClientKey } /// wasm32 has no filesystem, so no profile: the environment is the only source. -#[cfg(target_arch = "wasm32")] +#[cfg(all(feature = "http", target_arch = "wasm32"))] fn client_key_provider() -> EnvKeyProvider { EnvKeyProvider } @@ -294,10 +301,10 @@ fn client_key_provider() -> EnvKeyProvider { /// [`ProfileStore`] as a [`KeyProvider`], tolerating an unresolvable profile /// directory so the "not configured" message can say what to do about it /// rather than only that `CS_CLIENT_ID` is unset. -#[cfg(not(target_arch = "wasm32"))] +#[cfg(all(feature = "http", not(target_arch = "wasm32")))] struct ProfileClientKey(Option<ProfileStore>); -#[cfg(not(target_arch = "wasm32"))] +#[cfg(all(feature = "http", not(target_arch = "wasm32")))] impl KeyProvider for ProfileClientKey { async fn client_key(&self) -> Result<stack_kms::ClientKey, KeyProviderError> { match &self.0 { @@ -353,6 +360,7 @@ impl StackCipherBuilder<FromEnv> { /// /// So on a developer machine, logging in with the CLI is sufficient; in /// CI, the four variables are. + #[cfg(feature = "http")] pub async fn init(self) -> Result<StackCipher<StackKms<stack_auth::AutoStrategy>>, Error> { let kms = StackKmsBuilder::auto()? .with_key_provider(client_key_provider()) diff --git a/packages/stack-kms/Cargo.toml b/packages/stack-kms/Cargo.toml index 875fb314c..d1f0e79a9 100644 --- a/packages/stack-kms/Cargo.toml +++ b/packages/stack-kms/Cargo.toml @@ -14,7 +14,13 @@ license-file = "LICENSE" publish = false [features] -default = ["profile"] +default = ["profile", "http"] +# The default transport: `HttpConnection`, speaking HTTPS via reqwest, and the +# `StackKmsBuilder` that configures it. Off, the crate has no HTTP client and +# no TLS stack in its graph; a host that provides its own transport (the +# WASI/wazero guest, where HTTP is a host import) implements +# `ZeroKMSConnection` itself and builds the client with `StackKms::connect`. +http = ["dep:reqwest", "dep:lazy_static", "stack-auth/http"] # Loads the client key from the CLI's on-disk profile (`secretkey.json`, via # `stack-profile`): `SecretKey: ProfileData` and `KeyProvider for ProfileStore`. # Disable for consumers that only ever source the key in-memory or from the @@ -29,11 +35,14 @@ test-support = [] # concurrent request (`max_concurrent_reqs`), which can exhaust client-side # ephemeral ports under load. Off by default while we validate it; intended to # default-on later. Mirrors the same feature on `cipherstash-client`. -http2 = ["reqwest/http2"] +http2 = ["http", "reqwest/http2"] [dependencies] recipher = { workspace = true } -stack-auth = { workspace = true } +# Path rather than workspace dep so `default-features = false` takes effect +# (a workspace dep's defaults cannot be turned off by a member): stack-auth's +# `http` feature rides on this crate's `http` feature. +stack-auth = { path = "../stack-auth", version = "0.42.3", default-features = false } zerokms-protocol = { workspace = true } vitaminc = { workspace = true, features = ["protected", "random"] } @@ -42,9 +51,9 @@ vitaminc = { workspace = true, features = ["protected", "random"] } vitaminc-protected = { workspace = true } blake3 = { workspace = true } -lazy_static = { workspace = true } +lazy_static = { workspace = true, optional = true } miette = { workspace = true } -reqwest = { workspace = true } +reqwest = { workspace = true, optional = true } serde = { workspace = true } serde_json = { workspace = true } thiserror = { workspace = true } diff --git a/packages/stack-kms/src/client.rs b/packages/stack-kms/src/client.rs index dc3eb4f58..18be2f40f 100644 --- a/packages/stack-kms/src/client.rs +++ b/packages/stack-kms/src/client.rs @@ -10,7 +10,9 @@ use recipher::key::Iv; use stack_auth::{AuthStrategy, AuthStrategyBounds}; use vitaminc::random::{Generatable, SafeRand}; -use crate::connection::{HttpConnection, HttpConnectionOpts, ZeroKMSConnection}; +#[cfg(feature = "http")] +use crate::connection::HttpConnection; +use crate::connection::ZeroKMSConnection; use crate::errors::{Error, GenerateKeyError, LoadKeysetError, RetrieveKeyError}; use crate::futures::map_async_chunked; use crate::key::{ClientKey, DataKey, DataKeyWithTag, IndexKey}; @@ -101,12 +103,23 @@ impl<CONNOPTS> ClientOpts<CONNOPTS> { /// [`HttpConnection`] talks to a real ZeroKMS endpoint. Each method takes an /// access token directly — see [`StackKms`] for the high-level wrapper that /// fetches and refreshes tokens via [`stack_auth`]. +#[cfg(feature = "http")] pub struct Client<C = HttpConnection> { connection: C, max_keys_per_req: usize, max_concurrent_reqs: usize, } +/// Low-level client for ZeroKMS key generation and retrieval, generic over +/// the transport [`ZeroKMSConnection`]. (Without the `http` feature there is +/// no default connection: the host supplies one.) +#[cfg(not(feature = "http"))] +pub struct Client<C> { + connection: C, + max_keys_per_req: usize, + max_concurrent_reqs: usize, +} + /// Returned by the [`Client::retrieve_keys_fallible`] method. pub type FallibleDataKeyVec = Vec<Result<DataKey, RetrieveKeyError>>; @@ -382,24 +395,50 @@ impl<C: ZeroKMSConnection + Send + Sync> Client<C> { /// (refreshing as needed), resolves the ZeroKMS endpoint from the token's /// `services` claim on first use, and delegates to the low-level client. /// -/// Build one with [`StackKmsBuilder`](crate::StackKmsBuilder). -pub struct StackKms<C> { - client: Client<HttpConnection>, +/// Build one with [`StackKmsBuilder`](crate::StackKmsBuilder) (the default +/// HTTP transport, `http` feature), or with [`connect`](Self::connect) over +/// any [`ZeroKMSConnection`]. +#[cfg(feature = "http")] +pub struct StackKms<C, Conn = HttpConnection> { + client: Client<Conn>, + credentials: C, + client_key: ClientKey, +} + +/// `StackKms` owns the transport [`Client`], a [`stack_auth`] credential +/// provider, and a [`ClientKey`]. Without the `http` feature there is no +/// default connection: build one with [`connect`](Self::connect) over the +/// host's [`ZeroKMSConnection`]. +#[cfg(not(feature = "http"))] +pub struct StackKms<C, Conn> { + client: Client<Conn>, credentials: C, client_key: ClientKey, } -impl<C> StackKms<C> +impl<C, Conn> StackKms<C, Conn> where C: AuthStrategyBounds, for<'a> &'a C: AuthStrategy, + Conn: ZeroKMSConnection + Send + Sync, { - pub(crate) fn connect( - opts: ClientOpts<HttpConnectionOpts>, + /// Build a client over an explicit transport. + /// + /// This is the seam for hosts that provide their own transport (the + /// WASI/wazero guest implements [`ZeroKMSConnection`] over a host-imported + /// function) and the only constructor available without the `http` + /// feature. With it, [`StackKmsBuilder`](crate::StackKmsBuilder) is the + /// usual way to configure the default [`HttpConnection`]. + /// + /// The connection is initialised from `opts`'s connection options; the + /// ZeroKMS endpoint is taken from the access token's `services` claim on + /// first use unless the connection already knows one. + pub fn connect( + opts: ClientOpts<Conn::ConnectionOpts>, credentials: C, client_key: ClientKey, ) -> Result<Self, Error> { - let client = Client::init_opts(opts)?; + let client = Client::init_opts(opts).map_err(|e| Error::ConnectionInit(Box::new(e)))?; Ok(Self { client, credentials, @@ -616,6 +655,49 @@ mod tests { } } + /// `StackKms` over an injected connection — the seam a host with its own + /// transport (the WASI/wazero guest) builds through, and the only + /// constructor without the `http` feature. + mod connect_over_any_connection { + use super::*; + use stack_auth::StaticTokenStrategy; + + #[tokio::test] + async fn generate_keys_round_trips_through_the_injected_connection() { + let builder = TestConnectionBuilder::new() + .add_effect::<GenerateKeyRequest, _>(|req| { + assert_eq!(req.keys.len(), 2, "both specs go to the connection"); + }) + .add_success_response::<GenerateKeyRequest>(GenerateKeyResponse { + keys: vec![generated_key(vec![1]), generated_key(vec![2])], + }); + let opts = ClientOpts::new(builder); + + let kms = StackKms::<_, TestConnection>::connect( + opts, + StaticTokenStrategy::new("static-token"), + random_client_key(), + ) + .expect("connect over a test connection"); + + let keys = kms + .generate_keys( + vec![ + GenerateKeyPayload::new("a", Cow::Owned(vec![])), + GenerateKeyPayload::new("b", Cow::Owned(vec![])), + ], + None, + None, + ) + .await + .expect("keys come back through the injected connection"); + + assert_eq!(keys.len(), 2); + assert_eq!(keys[0].tag, vec![1]); + assert_eq!(keys[1].tag, vec![2]); + } + } + mod count_mismatch { use super::*; diff --git a/packages/stack-kms/src/client/test_connection.rs b/packages/stack-kms/src/client/test_connection.rs index 8cca1996c..5b095e8ea 100644 --- a/packages/stack-kms/src/client/test_connection.rs +++ b/packages/stack-kms/src/client/test_connection.rs @@ -92,6 +92,15 @@ impl ZeroKMSConnectionInit for TestConnection { } impl ZeroKMSConnection for TestConnection { + // The stub has no URL to resolve: it dispatches on the request's endpoint + // name. Report the base URL as always known so `StackKms` never tries to + // resolve one from a token. + fn ensure_base_url(&self, _url: crate::endpoint::ZeroKmsEndpoint) {} + + fn has_base_url(&self) -> bool { + true + } + async fn send<Request: ViturRequest>( &self, request: Request, diff --git a/packages/stack-kms/src/connection.rs b/packages/stack-kms/src/connection.rs index 2eaeffaf1..a4da47a3c 100644 --- a/packages/stack-kms/src/connection.rs +++ b/packages/stack-kms/src/connection.rs @@ -1,104 +1,24 @@ -use crate::endpoint::ZeroKmsEndpoint; -use crate::user_agent::get_user_agent; -use reqwest::{header::HeaderMap, Response, StatusCode}; -use serde_json::{from_reader, to_vec}; -#[cfg(not(target_arch = "wasm32"))] -use std::time::Duration; -use std::{collections::HashMap, future::Future, sync::OnceLock}; -use thiserror::Error; -use zerokms_protocol::{ViturRequest, ViturRequestError, ViturRequestErrorKind}; - -#[cfg(not(target_arch = "wasm32"))] -const REQUEST_TIMEOUT_SECS: u64 = 10; - -#[derive(Debug, Error)] -#[error("Failed to initialize HTTP connection: {0}")] -pub struct ConnectionInitError(#[from] reqwest::Error); - -#[derive(Debug, Error)] -#[error("ZeroKMS base URL was not resolved from the token's `services` claim")] -struct BaseUrlUnresolved; - -pub struct HttpConnectionOpts { - base_url: Option<ZeroKmsEndpoint>, - request_timeout: Option<u64>, - connect_timeout: Option<u64>, - pool_idle_timeout: Option<u64>, -} - -impl HttpConnectionOpts { - /// Options for a connection to `base_url`, or — when `None` — to whatever - /// endpoint the access token's `services` claim names (resolved on first - /// use via [`HttpConnection::ensure_base_url`]). - pub fn new(base_url: Option<ZeroKmsEndpoint>) -> Self { - Self { - base_url, - request_timeout: None, - connect_timeout: None, - pool_idle_timeout: None, - } - } - - /// Pin the endpoint, replacing any earlier value. - pub fn with_base_url(mut self, base_url: ZeroKmsEndpoint) -> Self { - self.base_url = Some(base_url); - self - } +//! The transport seam. +//! +//! [`ZeroKMSConnection`] is how the client sends a [`ViturRequest`] and gets +//! its response back. The default implementation, [`HttpConnection`], speaks +//! HTTPS via reqwest and lives behind the `http` feature. Hosts that provide +//! their own transport — the WASI/wazero guest, where HTTP is a host import — +//! implement the trait themselves and build the client with +//! [`StackKms::connect`](crate::StackKms::connect); with the `http` feature +//! off, reqwest and its native TLS stack are not in the dependency graph at +//! all. - pub(crate) fn base_url(&self) -> Option<&ZeroKmsEndpoint> { - self.base_url.as_ref() - } +use std::future::Future; - /// Set the **total request timeout** in seconds — covers connect + TLS - /// handshake + body send + body receive together. If not set, defaults - /// to 10 seconds. - /// - /// Set this larger when calling endpoints whose server-side processing - /// time scales with payload size (e.g. `generate-data-key` with large - /// `keys.len()`), or when running over high-latency / variable-quality - /// networks. See [`with_connect_timeout`](Self::with_connect_timeout) - /// to bound the connect phase separately. - /// - /// Ignored on wasm32 — reqwest's fetch-backed `ClientBuilder` doesn't - /// expose `.timeout()` and the host runtime (e.g. Supabase Edge, Cloudflare - /// Workers) owns request lifetime there. - pub fn with_request_timeout(mut self, timeout_secs: u64) -> Self { - self.request_timeout = Some(timeout_secs); - self - } +use zerokms_protocol::{ViturRequest, ViturRequestError}; - /// Set the **connect timeout** in seconds — bound on TCP connect + TLS - /// handshake only, separate from the total request timeout. If not set, - /// reqwest falls back to the OS-level connect timeout (~75 s on most - /// platforms), so the only ceiling on a stuck connect is whatever the - /// total request timeout is. - /// - /// Useful for fast-fail behaviour on broken networks: a value like 5 - /// seconds gives the connect phase plenty of room without forcing the - /// total request timeout to absorb both connect *and* response time. - /// - /// Ignored on wasm32 for the same reason as - /// [`with_request_timeout`](Self::with_request_timeout): the host - /// runtime owns connection lifetime under fetch. - pub fn with_connect_timeout(mut self, timeout_secs: u64) -> Self { - self.connect_timeout = Some(timeout_secs); - self - } +use crate::endpoint::ZeroKmsEndpoint; - /// Set the **pool idle timeout** in seconds — how long the underlying - /// reqwest client keeps an idle keep-alive connection in its pool - /// before closing it. If not set, reqwest's default of 90 s applies. - /// - /// Long-lived processes (bulk ingest, daemons) benefit from raising - /// this so warm TLS connections survive idle gaps between batches. - /// - /// Ignored on wasm32 — connection pooling is owned by the host - /// runtime under fetch. - pub fn with_pool_idle_timeout(mut self, timeout_secs: u64) -> Self { - self.pool_idle_timeout = Some(timeout_secs); - self - } -} +#[cfg(feature = "http")] +mod http; +#[cfg(feature = "http")] +pub use http::{ConnectionInitError, HttpConnection, HttpConnectionOpts}; pub trait ZeroKMSConnectionInit { type ConnectionOpts; @@ -119,325 +39,17 @@ pub trait ZeroKMSConnection: ZeroKMSConnectionInit { request: Request, access_token: &str, ) -> impl Future<Output = Result<Request::Response, ViturRequestError>> + crate::MaybeSend; -} - -pub struct HttpConnection { - base_url: OnceLock<ZeroKmsEndpoint>, - client: reqwest::Client, -} -#[derive(Debug, Error)] -#[error("Received '{received:?}', expected '{expected}', Body: {body:?}, Headers: {headers:?}")] -struct UnexpectedError { - received: Option<String>, - expected: &'static str, - body: Option<String>, - headers: HashMap<String, String>, -} - -#[derive(Debug, Error)] -#[error("Status: {status}, Body: {body:?}, Headers: {headers:?}")] -struct FailureResponse { - status: StatusCode, - body: Option<String>, - headers: HashMap<String, String>, -} - -impl FailureResponse { - async fn from_response(response: Response) -> Self { - let status = response.status(); - let headers = header_map_to_hash(response.headers()); - let body = response.text().await.ok(); - - Self { - status, - body, - headers, - } - } - - fn into_vitur_error( - self, - error_kind: ViturRequestErrorKind, - message: &'static str, - ) -> ViturRequestError { - ViturRequestError::new(error_kind, message, self) - } -} - -fn header_map_to_hash(map: &HeaderMap) -> HashMap<String, String> { - map.iter() - .filter_map(|(k, v)| { - v.to_str() - .map(|x| x.to_string()) - .ok() - .map(|v| (k.to_string(), v)) - }) - .collect() -} - -/// `true` if a `content-type` header value denotes JSON, ignoring any -/// parameters (`application/json; charset=utf-8`) and ASCII case — proxies and -/// API gateways commonly normalise the header that way. -fn is_json_content_type(value: &str) -> bool { - value - .split(';') - .next() - .map(str::trim) - .is_some_and(|media_type| media_type.eq_ignore_ascii_case("application/json")) -} - -impl HttpConnection { - /// Set the base URL if it has not already been set. + /// Record the ZeroKMS endpoint if none is known yet. /// - /// This is a no-op if the URL was already provided at init time or by a - /// previous call to this method. - pub fn ensure_base_url(&self, url: ZeroKmsEndpoint) { - // OnceLock::set returns Err if already set — that's fine, we keep the first value. - let _ = self.base_url.set(url); - } - - /// Returns `true` if the base URL has been resolved (either at init time - /// or via [`ensure_base_url`](Self::ensure_base_url)). - pub fn has_base_url(&self) -> bool { - self.base_url.get().is_some() - } -} - -impl ZeroKMSConnectionInit for HttpConnection { - type ConnectionOpts = HttpConnectionOpts; - type Error = ConnectionInitError; - - fn init(opts: Self::ConnectionOpts) -> Result<Self, Self::Error> { - let builder = reqwest::ClientBuilder::new().user_agent(get_user_agent()); - // wasm32 reqwest uses `fetch` and doesn't expose `.timeout()`, - // `.connect_timeout()` or `.pool_idle_timeout()` — the host runtime - // owns request lifetime and connection pooling. The corresponding - // `with_*` builder methods are documented as no-ops on wasm32. - #[cfg(not(target_arch = "wasm32"))] - let builder = { - let mut b = builder.timeout(Duration::from_secs( - opts.request_timeout.unwrap_or(REQUEST_TIMEOUT_SECS), - )); - if let Some(connect_timeout) = opts.connect_timeout { - b = b.connect_timeout(Duration::from_secs(connect_timeout)); - } - if let Some(pool_idle_timeout) = opts.pool_idle_timeout { - b = b.pool_idle_timeout(Duration::from_secs(pool_idle_timeout)); - } - b - }; - #[cfg(target_arch = "wasm32")] - let _ = ( - opts.request_timeout, - opts.connect_timeout, - opts.pool_idle_timeout, - ); - - let client = builder.build()?; - - let base_url = OnceLock::new(); - if let Some(url) = opts.base_url { - // Pre-fill when an explicit URL was provided at build time. - let _ = base_url.set(url); - } - - Ok(Self { base_url, client }) - } -} - -impl ZeroKMSConnection for HttpConnection { - async fn send<Request: ViturRequest>( - &self, - request: Request, - access_token: &str, - ) -> Result<Request::Response, ViturRequestError> { - let body = to_vec(&request) - .map_err(|e| ViturRequestError::prepare("Failed to serialize request", e))?; - - let base_url = self.base_url.get().ok_or_else(|| { - // A missing base URL is a client-side configuration problem (the - // token carried no ZeroKMS `services` claim and none was set - // explicitly), not an authentication failure — classify it as a - // request-preparation error so callers don't mistake it for a 401 - // and trigger a token refresh/reauth loop. - ViturRequestError::prepare( - "ZeroKMS base URL was not resolved from the token's services claim", - BaseUrlUnresolved, - ) - })?; - - let url = base_url.request_url(Request::ENDPOINT); - - let response = self - .client - .post(url.as_str()) - .body(body) - .header("content-type", "application/json") - .bearer_auth(access_token) - .send() - .await - .map_err(|e| ViturRequestError::send("Failed to send request", e))?; - - let status = response.status(); - - if status.is_success() { - // Ok response - let content_type = response - .headers() - .get("content-type") - .and_then(|x| x.to_str().ok()); - - let expected = "application/json"; - - if !content_type.is_some_and(is_json_content_type) { - return Err(ViturRequestError::parse( - "Invalid content type header", - UnexpectedError { - received: content_type.map(|x| x.into()), - expected, - headers: header_map_to_hash(response.headers()), - body: response.text().await.ok(), - }, - )); - } - - let response_bytes = response.bytes().await.map_err(|e| { - ViturRequestError::parse("Failed to read response body as bytes", e) - })?; - - from_reader(&response_bytes[..]) - .map_err(|e| ViturRequestError::parse("Failed to deserialize response body", e)) - } else { - // Error handling - let failure = FailureResponse::from_response(response).await; - - let err = match status { - StatusCode::NOT_FOUND => { - failure.into_vitur_error(ViturRequestErrorKind::NotFound, "Resource not found") - } - StatusCode::UNAUTHORIZED => failure - .into_vitur_error(ViturRequestErrorKind::Unauthorized, "Request unauthorized"), - StatusCode::FORBIDDEN => { - failure.into_vitur_error(ViturRequestErrorKind::Forbidden, "Request forbidden") - } - StatusCode::CONFLICT => { - failure.into_vitur_error(ViturRequestErrorKind::Conflict, "Resource conflict") - } - _ => ViturRequestError::other("Server returned failure response", failure), - }; - - Err(err) - } - } -} - -#[cfg(test)] -mod base_url_tests { - use super::*; - - fn conn(base_url: Option<ZeroKmsEndpoint>) -> HttpConnection { - HttpConnection::init(HttpConnectionOpts::new(base_url)).unwrap() - } - - fn endpoint(s: &str) -> ZeroKmsEndpoint { - s.parse().unwrap() - } - - #[test] - fn is_unset_until_ensured() { - let c = conn(None); - assert!(!c.has_base_url()); - - c.ensure_base_url(endpoint("https://a.example")); - - assert!(c.has_base_url()); - assert_eq!(c.base_url.get().unwrap().as_str(), "https://a.example/"); - } - - #[test] - fn the_first_ensured_url_wins() { - let c = conn(None); - c.ensure_base_url(endpoint("https://first.example")); - c.ensure_base_url(endpoint("https://second.example")); - - assert_eq!(c.base_url.get().unwrap().as_str(), "https://first.example/"); - } - - #[test] - fn a_url_given_at_init_is_kept_over_a_later_ensure() { - let c = conn(Some(endpoint("https://init.example"))); - assert!(c.has_base_url()); - - c.ensure_base_url(endpoint("https://other.example")); - - assert_eq!(c.base_url.get().unwrap().as_str(), "https://init.example/"); - } - - #[test] - fn requests_append_the_endpoint_to_a_path_prefix() { - // URL normalisation itself is covered in `endpoint::tests`; this pins - // that the connection builds request URLs through the endpoint type - // rather than re-joining (which would drop a path prefix). - let c = conn(Some(endpoint("https://gateway.example/zerokms"))); - - assert_eq!( - c.base_url - .get() - .unwrap() - .request_url(zerokms_protocol::RetrieveKeyRequest::ENDPOINT) - .as_str(), - "https://gateway.example/zerokms/retrieve-data-key" - ); - } - - #[tokio::test] - async fn send_without_a_base_url_is_a_prepare_error_not_an_auth_error() { - use zerokms_protocol::{GenerateKeyRequest, ViturRequestErrorKind}; - - let c = conn(None); - let req = GenerateKeyRequest { - client_id: uuid::Uuid::nil(), - keyset_id: None, - keys: std::borrow::Cow::Owned(vec![]), - unverified_context: Default::default(), - }; - - let err = c.send(req, "token").await.unwrap_err(); - - assert!( - matches!(err.kind, ViturRequestErrorKind::PrepareRequest), - "a missing base URL must not look like a 401 (and trigger a reauth loop), got: {err:?}" - ); - } -} - -#[cfg(test)] -mod content_type_tests { - use super::is_json_content_type; - - #[test] - fn accepts_json_with_or_without_parameters_and_ignoring_case() { - for value in [ - "application/json", - "application/json; charset=utf-8", - "application/json;charset=UTF-8", - " Application/JSON ; charset=utf-8", - ] { - assert!(is_json_content_type(value), "{value:?} should be accepted"); - } - } - - #[test] - fn rejects_other_media_types() { - for value in [ - "text/html", - "application/jsonx", - "text/json", - "", - "; charset=utf-8", - ] { - assert!(!is_json_content_type(value), "{value:?} should be rejected"); - } - } + /// [`StackKms`](crate::StackKms) calls this with the endpoint named by the + /// access token's `services` claim the first time it holds a token. A + /// connection that was pinned to an endpoint at init keeps it — the first + /// value wins — so callers can override discovery without racing it. + fn ensure_base_url(&self, url: ZeroKmsEndpoint); + + /// Whether an endpoint is known, either from init or from a previous + /// [`ensure_base_url`](Self::ensure_base_url). While this is `false`, + /// [`send`](Self::send) cannot build a request URL. + fn has_base_url(&self) -> bool; } diff --git a/packages/stack-kms/src/connection/http.rs b/packages/stack-kms/src/connection/http.rs new file mode 100644 index 000000000..9eb309dd8 --- /dev/null +++ b/packages/stack-kms/src/connection/http.rs @@ -0,0 +1,420 @@ +//! [`HttpConnection`]: the default [`ZeroKMSConnection`], speaking HTTPS to a +//! ZeroKMS endpoint via reqwest. Behind the `http` feature so that hosts which +//! provide their own transport (the WASI/wazero guest) can build the crate +//! without reqwest — and its native TLS stack — in the graph at all. + +use super::{ZeroKMSConnection, ZeroKMSConnectionInit}; +use crate::endpoint::ZeroKmsEndpoint; +use crate::user_agent::get_user_agent; +use reqwest::{header::HeaderMap, Response, StatusCode}; +use serde_json::{from_reader, to_vec}; +#[cfg(not(target_arch = "wasm32"))] +use std::time::Duration; +use std::{collections::HashMap, sync::OnceLock}; +use thiserror::Error; +use zerokms_protocol::{ViturRequest, ViturRequestError, ViturRequestErrorKind}; + +#[cfg(not(target_arch = "wasm32"))] +const REQUEST_TIMEOUT_SECS: u64 = 10; + +#[derive(Debug, Error)] +#[error("Failed to initialize HTTP connection: {0}")] +pub struct ConnectionInitError(#[from] reqwest::Error); + +#[derive(Debug, Error)] +#[error("ZeroKMS base URL was not resolved from the token's `services` claim")] +struct BaseUrlUnresolved; + +pub struct HttpConnectionOpts { + base_url: Option<ZeroKmsEndpoint>, + request_timeout: Option<u64>, + connect_timeout: Option<u64>, + pool_idle_timeout: Option<u64>, +} + +impl HttpConnectionOpts { + /// Options for a connection to `base_url`, or — when `None` — to whatever + /// endpoint the access token's `services` claim names (resolved on first + /// use via [`HttpConnection::ensure_base_url`]). + pub fn new(base_url: Option<ZeroKmsEndpoint>) -> Self { + Self { + base_url, + request_timeout: None, + connect_timeout: None, + pool_idle_timeout: None, + } + } + + /// Pin the endpoint, replacing any earlier value. + pub fn with_base_url(mut self, base_url: ZeroKmsEndpoint) -> Self { + self.base_url = Some(base_url); + self + } + + pub(crate) fn base_url(&self) -> Option<&ZeroKmsEndpoint> { + self.base_url.as_ref() + } + + /// Set the **total request timeout** in seconds — covers connect + TLS + /// handshake + body send + body receive together. If not set, defaults + /// to 10 seconds. + /// + /// Set this larger when calling endpoints whose server-side processing + /// time scales with payload size (e.g. `generate-data-key` with large + /// `keys.len()`), or when running over high-latency / variable-quality + /// networks. See [`with_connect_timeout`](Self::with_connect_timeout) + /// to bound the connect phase separately. + /// + /// Ignored on wasm32 — reqwest's fetch-backed `ClientBuilder` doesn't + /// expose `.timeout()` and the host runtime (e.g. Supabase Edge, Cloudflare + /// Workers) owns request lifetime there. + pub fn with_request_timeout(mut self, timeout_secs: u64) -> Self { + self.request_timeout = Some(timeout_secs); + self + } + + /// Set the **connect timeout** in seconds — bound on TCP connect + TLS + /// handshake only, separate from the total request timeout. If not set, + /// reqwest falls back to the OS-level connect timeout (~75 s on most + /// platforms), so the only ceiling on a stuck connect is whatever the + /// total request timeout is. + /// + /// Useful for fast-fail behaviour on broken networks: a value like 5 + /// seconds gives the connect phase plenty of room without forcing the + /// total request timeout to absorb both connect *and* response time. + /// + /// Ignored on wasm32 for the same reason as + /// [`with_request_timeout`](Self::with_request_timeout): the host + /// runtime owns connection lifetime under fetch. + pub fn with_connect_timeout(mut self, timeout_secs: u64) -> Self { + self.connect_timeout = Some(timeout_secs); + self + } + + /// Set the **pool idle timeout** in seconds — how long the underlying + /// reqwest client keeps an idle keep-alive connection in its pool + /// before closing it. If not set, reqwest's default of 90 s applies. + /// + /// Long-lived processes (bulk ingest, daemons) benefit from raising + /// this so warm TLS connections survive idle gaps between batches. + /// + /// Ignored on wasm32 — connection pooling is owned by the host + /// runtime under fetch. + pub fn with_pool_idle_timeout(mut self, timeout_secs: u64) -> Self { + self.pool_idle_timeout = Some(timeout_secs); + self + } +} + +pub struct HttpConnection { + base_url: OnceLock<ZeroKmsEndpoint>, + client: reqwest::Client, +} + +#[derive(Debug, Error)] +#[error("Received '{received:?}', expected '{expected}', Body: {body:?}, Headers: {headers:?}")] +struct UnexpectedError { + received: Option<String>, + expected: &'static str, + body: Option<String>, + headers: HashMap<String, String>, +} + +#[derive(Debug, Error)] +#[error("Status: {status}, Body: {body:?}, Headers: {headers:?}")] +struct FailureResponse { + status: StatusCode, + body: Option<String>, + headers: HashMap<String, String>, +} + +impl FailureResponse { + async fn from_response(response: Response) -> Self { + let status = response.status(); + let headers = header_map_to_hash(response.headers()); + let body = response.text().await.ok(); + + Self { + status, + body, + headers, + } + } + + fn into_vitur_error( + self, + error_kind: ViturRequestErrorKind, + message: &'static str, + ) -> ViturRequestError { + ViturRequestError::new(error_kind, message, self) + } +} + +fn header_map_to_hash(map: &HeaderMap) -> HashMap<String, String> { + map.iter() + .filter_map(|(k, v)| { + v.to_str() + .map(|x| x.to_string()) + .ok() + .map(|v| (k.to_string(), v)) + }) + .collect() +} + +/// `true` if a `content-type` header value denotes JSON, ignoring any +/// parameters (`application/json; charset=utf-8`) and ASCII case — proxies and +/// API gateways commonly normalise the header that way. +fn is_json_content_type(value: &str) -> bool { + value + .split(';') + .next() + .map(str::trim) + .is_some_and(|media_type| media_type.eq_ignore_ascii_case("application/json")) +} + +impl ZeroKMSConnectionInit for HttpConnection { + type ConnectionOpts = HttpConnectionOpts; + type Error = ConnectionInitError; + + fn init(opts: Self::ConnectionOpts) -> Result<Self, Self::Error> { + let builder = reqwest::ClientBuilder::new().user_agent(get_user_agent()); + // wasm32 reqwest uses `fetch` and doesn't expose `.timeout()`, + // `.connect_timeout()` or `.pool_idle_timeout()` — the host runtime + // owns request lifetime and connection pooling. The corresponding + // `with_*` builder methods are documented as no-ops on wasm32. + #[cfg(not(target_arch = "wasm32"))] + let builder = { + let mut b = builder.timeout(Duration::from_secs( + opts.request_timeout.unwrap_or(REQUEST_TIMEOUT_SECS), + )); + if let Some(connect_timeout) = opts.connect_timeout { + b = b.connect_timeout(Duration::from_secs(connect_timeout)); + } + if let Some(pool_idle_timeout) = opts.pool_idle_timeout { + b = b.pool_idle_timeout(Duration::from_secs(pool_idle_timeout)); + } + b + }; + #[cfg(target_arch = "wasm32")] + let _ = ( + opts.request_timeout, + opts.connect_timeout, + opts.pool_idle_timeout, + ); + + let client = builder.build()?; + + let base_url = OnceLock::new(); + if let Some(url) = opts.base_url { + // Pre-fill when an explicit URL was provided at build time. + let _ = base_url.set(url); + } + + Ok(Self { base_url, client }) + } +} + +impl ZeroKMSConnection for HttpConnection { + fn ensure_base_url(&self, url: ZeroKmsEndpoint) { + // OnceLock::set returns Err if already set — that's fine, we keep the first value. + let _ = self.base_url.set(url); + } + + fn has_base_url(&self) -> bool { + self.base_url.get().is_some() + } + + async fn send<Request: ViturRequest>( + &self, + request: Request, + access_token: &str, + ) -> Result<Request::Response, ViturRequestError> { + let body = to_vec(&request) + .map_err(|e| ViturRequestError::prepare("Failed to serialize request", e))?; + + let base_url = self.base_url.get().ok_or_else(|| { + // A missing base URL is a client-side configuration problem (the + // token carried no ZeroKMS `services` claim and none was set + // explicitly), not an authentication failure — classify it as a + // request-preparation error so callers don't mistake it for a 401 + // and trigger a token refresh/reauth loop. + ViturRequestError::prepare( + "ZeroKMS base URL was not resolved from the token's services claim", + BaseUrlUnresolved, + ) + })?; + + let url = base_url.request_url(Request::ENDPOINT); + + let response = self + .client + .post(url.as_str()) + .body(body) + .header("content-type", "application/json") + .bearer_auth(access_token) + .send() + .await + .map_err(|e| ViturRequestError::send("Failed to send request", e))?; + + let status = response.status(); + + if status.is_success() { + // Ok response + let content_type = response + .headers() + .get("content-type") + .and_then(|x| x.to_str().ok()); + + let expected = "application/json"; + + if !content_type.is_some_and(is_json_content_type) { + return Err(ViturRequestError::parse( + "Invalid content type header", + UnexpectedError { + received: content_type.map(|x| x.into()), + expected, + headers: header_map_to_hash(response.headers()), + body: response.text().await.ok(), + }, + )); + } + + let response_bytes = response.bytes().await.map_err(|e| { + ViturRequestError::parse("Failed to read response body as bytes", e) + })?; + + from_reader(&response_bytes[..]) + .map_err(|e| ViturRequestError::parse("Failed to deserialize response body", e)) + } else { + // Error handling + let failure = FailureResponse::from_response(response).await; + + let err = match status { + StatusCode::NOT_FOUND => { + failure.into_vitur_error(ViturRequestErrorKind::NotFound, "Resource not found") + } + StatusCode::UNAUTHORIZED => failure + .into_vitur_error(ViturRequestErrorKind::Unauthorized, "Request unauthorized"), + StatusCode::FORBIDDEN => { + failure.into_vitur_error(ViturRequestErrorKind::Forbidden, "Request forbidden") + } + StatusCode::CONFLICT => { + failure.into_vitur_error(ViturRequestErrorKind::Conflict, "Resource conflict") + } + _ => ViturRequestError::other("Server returned failure response", failure), + }; + + Err(err) + } + } +} + +#[cfg(test)] +mod base_url_tests { + use super::*; + + fn conn(base_url: Option<ZeroKmsEndpoint>) -> HttpConnection { + HttpConnection::init(HttpConnectionOpts::new(base_url)).unwrap() + } + + fn endpoint(s: &str) -> ZeroKmsEndpoint { + s.parse().unwrap() + } + + #[test] + fn is_unset_until_ensured() { + let c = conn(None); + assert!(!c.has_base_url()); + + c.ensure_base_url(endpoint("https://a.example")); + + assert!(c.has_base_url()); + assert_eq!(c.base_url.get().unwrap().as_str(), "https://a.example/"); + } + + #[test] + fn the_first_ensured_url_wins() { + let c = conn(None); + c.ensure_base_url(endpoint("https://first.example")); + c.ensure_base_url(endpoint("https://second.example")); + + assert_eq!(c.base_url.get().unwrap().as_str(), "https://first.example/"); + } + + #[test] + fn a_url_given_at_init_is_kept_over_a_later_ensure() { + let c = conn(Some(endpoint("https://init.example"))); + assert!(c.has_base_url()); + + c.ensure_base_url(endpoint("https://other.example")); + + assert_eq!(c.base_url.get().unwrap().as_str(), "https://init.example/"); + } + + #[test] + fn requests_append_the_endpoint_to_a_path_prefix() { + // URL normalisation itself is covered in `endpoint::tests`; this pins + // that the connection builds request URLs through the endpoint type + // rather than re-joining (which would drop a path prefix). + let c = conn(Some(endpoint("https://gateway.example/zerokms"))); + + assert_eq!( + c.base_url + .get() + .unwrap() + .request_url(zerokms_protocol::RetrieveKeyRequest::ENDPOINT) + .as_str(), + "https://gateway.example/zerokms/retrieve-data-key" + ); + } + + #[tokio::test] + async fn send_without_a_base_url_is_a_prepare_error_not_an_auth_error() { + use zerokms_protocol::{GenerateKeyRequest, ViturRequestErrorKind}; + + let c = conn(None); + let req = GenerateKeyRequest { + client_id: uuid::Uuid::nil(), + keyset_id: None, + keys: std::borrow::Cow::Owned(vec![]), + unverified_context: Default::default(), + }; + + let err = c.send(req, "token").await.unwrap_err(); + + assert!( + matches!(err.kind, ViturRequestErrorKind::PrepareRequest), + "a missing base URL must not look like a 401 (and trigger a reauth loop), got: {err:?}" + ); + } +} + +#[cfg(test)] +mod content_type_tests { + use super::is_json_content_type; + + #[test] + fn accepts_json_with_or_without_parameters_and_ignoring_case() { + for value in [ + "application/json", + "application/json; charset=utf-8", + "application/json;charset=UTF-8", + " Application/JSON ; charset=utf-8", + ] { + assert!(is_json_content_type(value), "{value:?} should be accepted"); + } + } + + #[test] + fn rejects_other_media_types() { + for value in [ + "text/html", + "application/jsonx", + "text/json", + "", + "; charset=utf-8", + ] { + assert!(!is_json_content_type(value), "{value:?} should be rejected"); + } + } +} diff --git a/packages/stack-kms/src/errors.rs b/packages/stack-kms/src/errors.rs index 095517e68..a4a320df5 100644 --- a/packages/stack-kms/src/errors.rs +++ b/packages/stack-kms/src/errors.rs @@ -289,8 +289,11 @@ pub enum Error { #[diagnostic(transparent)] Auth(#[from] stack_auth::AuthError), - #[error(transparent)] - ConnectionInit(#[from] crate::connection::ConnectionInitError), + /// The [`ZeroKMSConnection`](crate::ZeroKMSConnection) failed to + /// initialise. Boxed because the error type belongs to whichever + /// connection the client was built over. + #[error("Failed to initialize the ZeroKMS connection: {0}")] + ConnectionInit(#[source] Box<dyn std::error::Error + Send + Sync + 'static>), /// The ZeroKMS endpoint named by the token's `services` claim is unusable. #[error("Invalid ZeroKMS endpoint in the token's services claim: {0}")] @@ -299,3 +302,10 @@ pub enum Error { #[error("Unexpected error: {0}")] Unexpected(String), } + +#[cfg(feature = "http")] +impl From<crate::connection::ConnectionInitError> for Error { + fn from(e: crate::connection::ConnectionInitError) -> Self { + Self::ConnectionInit(Box::new(e)) + } +} diff --git a/packages/stack-kms/src/key_source.rs b/packages/stack-kms/src/key_source.rs index c33cd8808..70e7f00ed 100644 --- a/packages/stack-kms/src/key_source.rs +++ b/packages/stack-kms/src/key_source.rs @@ -72,10 +72,11 @@ pub trait IndexKeySource { ) -> impl Future<Output = Result<(Uuid, IndexKey), Error>> + MaybeSend; } -impl<C> DataKeySource for crate::StackKms<C> +impl<C, Conn> DataKeySource for crate::StackKms<C, Conn> where C: stack_auth::AuthStrategyBounds, for<'a> &'a C: stack_auth::AuthStrategy, + Conn: crate::ZeroKMSConnection + Send + Sync, { async fn generate_keys( &self, @@ -98,10 +99,11 @@ where } } -impl<C> IndexKeySource for crate::StackKms<C> +impl<C, Conn> IndexKeySource for crate::StackKms<C, Conn> where C: stack_auth::AuthStrategyBounds, for<'a> &'a C: stack_auth::AuthStrategy, + Conn: crate::ZeroKMSConnection + Send + Sync, { async fn load_index_key( &self, diff --git a/packages/stack-kms/src/lib.rs b/packages/stack-kms/src/lib.rs index 6e68c9abf..bf4856688 100644 --- a/packages/stack-kms/src/lib.rs +++ b/packages/stack-kms/src/lib.rs @@ -64,6 +64,7 @@ #![cfg_attr(test, allow(clippy::panic))] #![cfg_attr(test, allow(unused_results))] +#[cfg(feature = "http")] mod builder; mod client; mod connection; @@ -76,10 +77,12 @@ mod key_source; mod maybe_send; mod payload; mod secret_key; +#[cfg(feature = "http")] mod user_agent; pub mod vars; -// Builder +// Builder (configures the default HTTP transport) +#[cfg(feature = "http")] pub use builder::{StackKmsBuilder, StackKmsBuilderError, WithKeyProvider}; // Clients @@ -89,10 +92,9 @@ pub use client::{ }; // Transport -pub use connection::{ - ConnectionInitError, HttpConnection, HttpConnectionOpts, ZeroKMSConnection, - ZeroKMSConnectionInit, -}; +#[cfg(feature = "http")] +pub use connection::{ConnectionInitError, HttpConnection, HttpConnectionOpts}; +pub use connection::{ZeroKMSConnection, ZeroKMSConnectionInit}; pub use endpoint::{InvalidEndpoint, ZeroKmsEndpoint}; // The native/wasm32 Send split for the async traits' returned futures From 37d8e47230e392c92110d824a50a8931e2001599 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Fri, 28 Aug 2026 09:06:47 +0000 Subject: [PATCH 475/686] refactor(stack-auth): replace crate-wide no-http allow(dead_code) with item-level http gates Without the http feature the crate previously silenced dead-code analysis entirely via #![cfg_attr(not(feature = "http"), allow(dead_code))]. Gate the eleven affected helpers (URL massaging, clock sharing, refusal classification, token setters, AccessKey secret access) individually with #[cfg(feature = "http")] instead, so the compiler verifies the feature partition in both directions: no-http code reaching an http helper fails to compile, and newly dead code warns instead of being swallowed. Test fallout handled so no coverage regresses in the no-http run: - workspace_crn tests assign Token.region directly instead of the now http-only set_region, keeping workspace_crn covered without http - test_refresh_debug_does_not_leak_tokens loses its http gate (it never needed HTTP) so the Debug secret-leak regression test runs in the no-default-features shape the WASI guest ships - http-only test helpers (TestClock, crn_with_workspace, jwt_with_workspace, classify_issuance_failure_tests) are gated with their consumers The one honest residual is AccessKey's inner field: parsing is unconditional token-model API, but only the http-gated AccessKeyStrategy consumes the secret, so that single field keeps a scoped cfg_attr(not(http), allow(dead_code)). Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YDes81qQM2ConLobY5G5JD --- packages/stack-auth/src/access_key.rs | 12 ++++++++++-- packages/stack-auth/src/clock.rs | 11 ++++++++--- packages/stack-auth/src/error.rs | 5 ++++- packages/stack-auth/src/lib.rs | 15 +++++++++------ packages/stack-auth/src/service_token.rs | 4 ++++ packages/stack-auth/src/test_support.rs | 5 +++++ packages/stack-auth/src/token.rs | 12 +++++++++--- 7 files changed, 49 insertions(+), 15 deletions(-) diff --git a/packages/stack-auth/src/access_key.rs b/packages/stack-auth/src/access_key.rs index cef3285eb..4db5726b0 100644 --- a/packages/stack-auth/src/access_key.rs +++ b/packages/stack-auth/src/access_key.rs @@ -33,8 +33,15 @@ const ACCESS_KEY_PREFIX: &str = "CSAK"; /// assert!("CSAKno-secret.".parse::<AccessKey>().is_err()); /// ``` #[derive(OpaqueDebug)] -pub struct AccessKey(SecretToken); - +pub struct AccessKey( + // Parsing an access key (and rejecting a malformed one) is part of the + // token model, so the struct is unconditional — but only the http-gated + // `AccessKeyStrategy` ever *consumes* the secret, so without `http` this + // field is held but never read. + #[cfg_attr(not(feature = "http"), allow(dead_code))] SecretToken, +); + +#[cfg(feature = "http")] impl AccessKey { /// Expose the underlying [`SecretToken`]. pub(crate) fn into_secret_token(self) -> SecretToken { @@ -129,6 +136,7 @@ mod tests { assert!(matches!(err, InvalidAccessKey::MissingPrefix)); } + #[cfg(feature = "http")] #[test] fn into_secret_token() { let key: AccessKey = "CSAKmyKeyId.myKeySecret".parse().unwrap(); diff --git a/packages/stack-auth/src/clock.rs b/packages/stack-auth/src/clock.rs index dd017efc6..0d7476cfc 100644 --- a/packages/stack-auth/src/clock.rs +++ b/packages/stack-auth/src/clock.rs @@ -11,6 +11,9 @@ //! from an injected source: [`SystemClock`] in production, a controllable clock //! in tests. +// `Arc` only backs the shared handles (`SharedClock`, `TestClock`); the bare +// `Clock`/`SystemClock` used by `Token` expiry need no sharing. +#[cfg(feature = "http")] use std::sync::Arc; use web_time::{SystemTime, UNIX_EPOCH}; @@ -25,6 +28,7 @@ pub(crate) trait Clock: Send + Sync { /// /// Type-erased (rather than a generic parameter on `AutoRefresh`) so injecting a /// clock doesn't ripple a third generic through every strategy wrapper. +#[cfg(feature = "http")] pub(crate) type SharedClock = Arc<dyn Clock>; /// The default [`Clock`]: the system wall clock. @@ -46,6 +50,7 @@ impl Clock for SystemClock { /// /// Returns clones of one process-wide handle: `SystemClock` is a stateless ZST, /// so there's no reason to allocate a fresh `Arc` per `AutoRefresh`. +#[cfg(feature = "http")] pub(crate) fn system_clock() -> SharedClock { static CLOCK: std::sync::LazyLock<SharedClock> = std::sync::LazyLock::new(|| Arc::new(SystemClock)); @@ -54,11 +59,11 @@ pub(crate) fn system_clock() -> SharedClock { /// A [`Clock`] whose value is set explicitly by the test, so token expiry can be /// driven deterministically rather than racing the wall clock. -#[cfg(test)] +#[cfg(all(test, feature = "http"))] #[derive(Clone)] pub(crate) struct TestClock(Arc<std::sync::atomic::AtomicU64>); -#[cfg(test)] +#[cfg(all(test, feature = "http"))] impl TestClock { /// Create a clock reading `now` seconds. pub(crate) fn new(now: u64) -> Self { @@ -89,7 +94,7 @@ impl TestClock { } } -#[cfg(test)] +#[cfg(all(test, feature = "http"))] impl Clock for TestClock { fn now_unix_secs(&self) -> u64 { self.now() diff --git a/packages/stack-auth/src/error.rs b/packages/stack-auth/src/error.rs index 52369af47..18b01a79c 100644 --- a/packages/stack-auth/src/error.rs +++ b/packages/stack-auth/src/error.rs @@ -594,6 +594,7 @@ impl AuthError { /// Matched exhaustively, like `is_retryable`, so a new variant has to /// declare which side of this boundary it's on rather than silently not /// being cached. + #[cfg(feature = "http")] pub(crate) fn is_account_refusal(&self) -> bool { match self { Self::UsageLimitExceeded(_) | Self::OrgNotProvisioned(_) => true, @@ -720,6 +721,7 @@ fn workspace_mismatch_from_payload( /// indistinguishable from a genuine authorization refusal — so the status, not /// the body, decides. `cs_code` is checked when present so that a future 402 /// with a different meaning does not silently inherit this classification. +#[cfg(feature = "http")] pub(crate) fn classify_issuance_failure(status: u16, body: &str) -> Option<AuthError> { if status != 402 { return None; @@ -800,6 +802,7 @@ pub(crate) fn classify_issuance_failure(status: u16, body: &str) -> Option<AuthE } /// Which account-level refusal a 402 body describes. +#[cfg(feature = "http")] enum Refusal { UsageLimit, NotProvisioned, @@ -916,7 +919,7 @@ impl From<Infallible> for AuthError { } } -#[cfg(test)] +#[cfg(all(test, feature = "http"))] mod classify_issuance_failure_tests { use super::*; diff --git a/packages/stack-auth/src/lib.rs b/packages/stack-auth/src/lib.rs index 898f76383..fa1b4f1ed 100644 --- a/packages/stack-auth/src/lib.rs +++ b/packages/stack-auth/src/lib.rs @@ -16,12 +16,13 @@ #![warn(unused_results)] #![warn(clippy::todo)] #![warn(clippy::unimplemented)] -// Without `http` the crate is the token model plus the `AuthStrategy` trait; -// the crate-internal helpers that only the HTTP strategies call (refusal -// classification, clock sharing, URL massaging, token setters) are then -// unreferenced. They are still the same code — a feature subset, not dead -// code — so don't make every one of them carry its own gate. -#![cfg_attr(not(feature = "http"), allow(dead_code))] +// Without `http` the crate is the token model plus the `AuthStrategy` trait. +// The crate-internal helpers that only the HTTP strategies call (refusal +// classification, clock sharing, URL massaging, token setters) each carry +// their own `#[cfg(feature = "http")]` gate rather than a crate-wide +// `allow(dead_code)`: the compiler then verifies the partition in both +// directions — no-http code reaching an http helper fails to compile, and +// code that goes dead in the no-http build warns instead of being silenced. // Relax in tests #![cfg_attr(test, allow(clippy::unwrap_used))] #![cfg_attr(test, allow(clippy::expect_used))] @@ -351,6 +352,7 @@ impl SecretToken { /// Returns `Ok(None)` if the variable is not set or empty. /// Returns `Ok(Some(url))` if the variable is set and valid. /// Returns `Err(_)` if the variable is set but not a valid URL. +#[cfg(feature = "http")] pub(crate) fn cts_base_url_from_env() -> Result<Option<url::Url>, AuthError> { match std::env::var("CS_CTS_HOST") { Ok(val) if !val.is_empty() => Ok(Some(val.parse()?)), @@ -360,6 +362,7 @@ pub(crate) fn cts_base_url_from_env() -> Result<Option<url::Url>, AuthError> { /// Ensure a URL has a trailing slash so that `Url::join` with relative paths /// appends to the path rather than replacing the last segment. +#[cfg(feature = "http")] pub(crate) fn ensure_trailing_slash(mut url: url::Url) -> url::Url { if !url.path().ends_with('/') { url.set_path(&format!("{}/", url.path())); diff --git a/packages/stack-auth/src/service_token.rs b/packages/stack-auth/src/service_token.rs index a822d227a..0f529b7a5 100644 --- a/packages/stack-auth/src/service_token.rs +++ b/packages/stack-auth/src/service_token.rs @@ -107,6 +107,7 @@ impl ServiceToken { /// different workspace than `expected`. /// - [`AuthError::InvalidToken`] if the token is not a valid JWT or its /// `workspace` claim could not be decoded, so verification can't run. + #[cfg(feature = "http")] pub(crate) fn verify_workspace(self, expected: WorkspaceId) -> Result<Self, AuthError> { let token_workspace = *self.workspace_id()?; if token_workspace != expected { @@ -414,6 +415,7 @@ mod tests { ); } + #[cfg(feature = "http")] #[test] fn verify_workspace_returns_token_when_workspace_matches() { let jwt = make_jwt( @@ -433,6 +435,7 @@ mod tests { ); } + #[cfg(feature = "http")] #[test] fn verify_workspace_errors_with_mismatch_when_workspace_differs() { // make_jwt mints a token for workspace ZVATKW3VHMFG27DY. @@ -458,6 +461,7 @@ mod tests { } } + #[cfg(feature = "http")] #[test] fn verify_workspace_errors_with_invalid_token_for_non_jwt() { // A non-JWT can't be decoded, so verification can't run. diff --git a/packages/stack-auth/src/test_support.rs b/packages/stack-auth/src/test_support.rs index 8f979931e..9f2ec6d53 100644 --- a/packages/stack-auth/src/test_support.rs +++ b/packages/stack-auth/src/test_support.rs @@ -5,6 +5,7 @@ //! the fixture drift that comes from copy-pasting the `Token { .. }` literal and //! the unsigned-JWT mint into every test module. +#[cfg(feature = "http")] use cts_common::Crn; use crate::{SecretToken, Token}; @@ -56,6 +57,9 @@ pub(crate) fn claims_with_workspace(workspace: &str) -> serde_json::Value { /// A workspace [`Crn`] in the standard test region (`ap-southeast-2.aws`) /// carrying the given `workspace` ID. +/// +/// Only the http-gated CRN-bound strategies have tests that need one. +#[cfg(feature = "http")] pub(crate) fn crn_with_workspace(workspace: &str) -> Crn { format!("crn:ap-southeast-2.aws:{workspace}") .parse() @@ -67,6 +71,7 @@ pub(crate) fn crn_with_workspace(workspace: &str) -> Crn { /// workspace verification that CRN-bound strategies run. Unlike /// [`claims_with_workspace`], whose `exp` is a fixed past epoch, the token this /// mints reads as valid. +#[cfg(feature = "http")] pub(crate) fn jwt_with_workspace(workspace: &str) -> String { use jsonwebtoken::{encode, EncodingKey, Header}; use std::time::{SystemTime, UNIX_EPOCH}; diff --git a/packages/stack-auth/src/token.rs b/packages/stack-auth/src/token.rs index 44e31d5ac..91ffec50d 100644 --- a/packages/stack-auth/src/token.rs +++ b/packages/stack-auth/src/token.rs @@ -128,11 +128,13 @@ impl Token { } /// Set the region identifier on this token. + #[cfg(feature = "http")] pub(crate) fn set_region(&mut self, region: impl Into<String>) { self.region = Some(region.into()); } /// Set the client ID on this token. + #[cfg(feature = "http")] pub(crate) fn set_client_id(&mut self, client_id: impl Into<String>) { self.client_id = Some(client_id.into()); } @@ -143,6 +145,7 @@ impl Token { } /// Set the device instance ID on this token. + #[cfg(feature = "http")] pub(crate) fn set_device_instance_id(&mut self, id: impl Into<String>) { self.device_instance_id = Some(id.into()); } @@ -705,7 +708,8 @@ mod tests { assert!(refreshed.refresh_token().is_none()); } - #[cfg(feature = "http")] + // Deliberately not http-gated: `Debug` must not leak the secrets in any + // build, including the no-default-features shape the WASI guest ships. #[tokio::test] async fn test_refresh_debug_does_not_leak_tokens() { let token = make_token(3600, true); @@ -757,7 +761,9 @@ mod tests { #[test] fn test_workspace_crn_derives_from_region_and_workspace() { let mut token = jwt_token(valid_claims_json()); - token.set_region("ap-southeast-2.aws"); + // Assign the field directly: `set_region` is http-only (the device-code + // flow), but `workspace_crn` itself must stay covered without `http`. + token.region = Some("ap-southeast-2.aws".into()); let crn = token.workspace_crn().expect("should derive workspace CRN"); assert_eq!(crn.to_string(), "crn:ap-southeast-2.aws:7366ITCXSAPCH5TN"); } @@ -772,7 +778,7 @@ mod tests { #[test] fn test_workspace_crn_fails_with_invalid_region() { let mut token = jwt_token(valid_claims_json()); - token.set_region("invalid-region"); + token.region = Some("invalid-region".into()); let err = token.workspace_crn().unwrap_err(); assert!(matches!(err, AuthError::Server(_))); } From 3cab1ea4d0fde5e13e864ecdb87d4824c785157b Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Fri, 28 Aug 2026 10:42:24 +0000 Subject: [PATCH 476/686] fix(stack): address review findings on the http feature split Feature additivity (the two API-shape bugs): - stack-auth: RequestError has one definition with an always-boxed payload; the http-gated From<reqwest::Error> does the boxing. Its public field's type no longer changes under feature unification, which would have broken a no-http host the moment anything else in its graph enabled http. - stack-encrypt: Error::Config is unconditional with a boxed source (the http-gated From<StackKmsBuilderError> boxes), so the enum's variant set no longer tracks the feature. Build config: - stack-auth's workspace entry is now default-features = false (the same pattern as cllw-ore/cts-common); stack-kms and stack-encrypt use the workspace dep again instead of hand-written path+version pins, and the consumers that rely on the default transport re-enable it with features = ["http"] (cipherstash-client, cipherstash-cli, cts-web dev-dep, stack-auth node/wasm bindings). One version pin remains, at the root. Cargo.lock is unchanged. Docs and doctests: - cargo test --no-default-features now passes including doctests in all three crates: http-only examples (README via include_str, token_store, StackKmsBuilder quick-start, StackCipher::new) are doc-gated on the feature with short no-http fallbacks, and the no-http rustdoc's broken intra-doc links are fixed. RUSTDOCFLAGS=-D warnings is clean in both shapes; default-features doctest coverage is unchanged. API cleanups: - ZeroKMSConnection::ensure_base_url / has_base_url get default impls (no-op / true) with the first-value-wins contract documented, so transports that don't do endpoint discovery (TestConnection, and hosts that pin at init) no longer stub them; HttpConnection keeps its overrides. - The unused From<ConnectionInitError> for Error impl is deleted; StackKms::connect is the one construction path for Error::ConnectionInit. - StackCipherBuilder::new() (+ Default) is the canonical builder entry point; StackCipher::builder() stays as a thin alias on the phantom FromEnv impl for annotation-free inference. - token.rs's scattered per-test http gates collapse into one gated refresh_tests module; make_token and the Debug secret-leak test stay ungated so they keep running without http. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YDes81qQM2ConLobY5G5JD --- languages/typescript/packages/auth/Cargo.toml | 4 +- .../packages/stack-auth-wasm/Cargo.toml | 2 +- packages/stack-auth/src/error.rs | 23 +- packages/stack-auth/src/lib.rs | 30 +- packages/stack-auth/src/token.rs | 505 +++++++++--------- packages/stack-auth/src/token_store.rs | 45 +- packages/stack-encrypt/Cargo.toml | 2 +- packages/stack-encrypt/src/cipher.rs | 97 +++- packages/stack-encrypt/src/lib.rs | 43 +- packages/stack-kms/Cargo.toml | 8 +- packages/stack-kms/src/client.rs | 15 +- .../stack-kms/src/client/test_connection.rs | 12 +- packages/stack-kms/src/connection.rs | 22 +- packages/stack-kms/src/errors.rs | 7 - packages/stack-kms/src/lib.rs | 62 ++- packages/stack-kms/src/secret_key.rs | 8 +- 16 files changed, 500 insertions(+), 385 deletions(-) diff --git a/languages/typescript/packages/auth/Cargo.toml b/languages/typescript/packages/auth/Cargo.toml index 5d68b8791..5f14a11a7 100644 --- a/languages/typescript/packages/auth/Cargo.toml +++ b/languages/typescript/packages/auth/Cargo.toml @@ -8,7 +8,7 @@ publish = false crate-type = ["cdylib"] [dependencies] -stack-auth = { workspace = true } +stack-auth = { workspace = true, features = ["http"] } stack-profile = { workspace = true } cts-common = { workspace = true } vitaminc-protected = { workspace = true } @@ -22,7 +22,7 @@ zeroize = { workspace = true } # `http_client`; the mock server itself is now a JS helper (see # __tests__/helpers/), so the napi crate no longer ships a `test-utils` feature # or the heavy mocktail/jsonwebtoken/reqwest deps in its published artifact. -stack-auth = { workspace = true, features = ["test-utils"] } +stack-auth = { workspace = true, features = ["http", "test-utils"] } jsonwebtoken = { workspace = true } mocktail = "0.3.0" serde_json = "1" diff --git a/languages/typescript/packages/stack-auth-wasm/Cargo.toml b/languages/typescript/packages/stack-auth-wasm/Cargo.toml index 6c741900c..edeed0d6d 100644 --- a/languages/typescript/packages/stack-auth-wasm/Cargo.toml +++ b/languages/typescript/packages/stack-auth-wasm/Cargo.toml @@ -13,7 +13,7 @@ publish = false crate-type = ["cdylib", "rlib"] [dependencies] -stack-auth = { workspace = true } +stack-auth = { workspace = true, features = ["http"] } cts-common = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } diff --git a/packages/stack-auth/src/error.rs b/packages/stack-auth/src/error.rs index 18b01a79c..081decb9e 100644 --- a/packages/stack-auth/src/error.rs +++ b/packages/stack-auth/src/error.rs @@ -75,17 +75,13 @@ pub(crate) mod codes { // Per-error structs // --------------------------------------------------------------------------- -/// The HTTP request to the auth server failed (network error, timeout, etc.). -#[cfg(feature = "http")] -#[derive(Debug, thiserror::Error, miette::Diagnostic)] -#[error("HTTP request failed: {0}")] -pub struct RequestError(pub reqwest::Error); - /// The request to the auth server failed (network error, timeout, etc.). /// -/// Without the `http` feature the crate makes no requests of its own, so the -/// payload is whatever the host's transport reports. -#[cfg(not(feature = "http"))] +/// The payload is always boxed, never a concrete `reqwest::Error`: Cargo +/// features are additive, so a type whose shape changes with `http` breaks any +/// no-http consumer the moment something else in the graph turns the feature +/// on. With `http` the box holds the `reqwest::Error`; without it, whatever +/// the host's own transport reports. #[derive(Debug, thiserror::Error, miette::Diagnostic)] #[error("HTTP request failed: {0}")] pub struct RequestError(pub Box<dyn std::error::Error + Send + Sync + 'static>); @@ -850,10 +846,17 @@ impl serde::Serialize for AuthError { // foreign error straight into `AuthError` (the per-struct wrapping is internal). // --------------------------------------------------------------------------- +#[cfg(feature = "http")] +impl From<reqwest::Error> for RequestError { + fn from(e: reqwest::Error) -> Self { + Self(Box::new(e)) + } +} + #[cfg(feature = "http")] impl From<reqwest::Error> for AuthError { fn from(e: reqwest::Error) -> Self { - Self::Request(RequestError(e)) + Self::Request(e.into()) } } diff --git a/packages/stack-auth/src/lib.rs b/packages/stack-auth/src/lib.rs index fa1b4f1ed..1a06bf704 100644 --- a/packages/stack-auth/src/lib.rs +++ b/packages/stack-auth/src/lib.rs @@ -1,5 +1,22 @@ #![doc(html_favicon_url = "https://cipherstash.com/favicon.ico")] -#![doc = include_str!("../README.md")] +// The README is the crate's front page, but nearly all of it — the strategy +// table, the quick-start examples, the links — is about the bundled HTTP +// strategies, which only exist with `http`. Including it unconditionally would +// leave a no-http build documenting (and doctesting) an API it does not have. +#![cfg_attr(feature = "http", doc = include_str!("../README.md"))] +#![cfg_attr( + not(feature = "http"), + doc = "Authentication strategies for [CipherStash](https://cipherstash.com) services." +)] +#![cfg_attr( + not(feature = "http"), + doc = "\nWithout the `http` feature this crate is the token model plus the\ + [`AuthStrategy`] trait — [`AuthStrategyFn`] and [`TokenStoreFn`] are how a host\ + with its own transport plugs in acquisition and persistence. Enable the `http`\ + feature for the bundled strategies (`AutoStrategy`, `AccessKeyStrategy`,\ + `DeviceSessionStrategy`, `DeviceCodeStrategy`), the refresh engine, and the\ + crate's full documentation." +)] // Security lints #![deny(unsafe_code)] #![warn(clippy::unwrap_used)] @@ -190,11 +207,14 @@ pub mod auth { /// for ready-made implementations. /// /// A `TokenStore` plugs into a concrete strategy via that strategy's -/// builder (e.g. -/// [`AccessKeyStrategyBuilder::with_token_store`](crate::AccessKeyStrategyBuilder::with_token_store)) -/// — it does *not* replace the strategy. For full token acquisition (custom -/// fetcher, FFI-hosted strategy), see [`crate::auth`]. +/// builder — it does *not* replace the strategy. For full token acquisition +/// (custom fetcher, FFI-hosted strategy), see [`crate::auth`]. /// +// The example names a strategy builder, which only exists with `http`. +#[cfg_attr( + feature = "http", + doc = "For example, [`AccessKeyStrategyBuilder::with_token_store`](crate::AccessKeyStrategyBuilder::with_token_store).\n" +)] /// All items in this module are also re-exported at the crate root. pub mod store { pub use crate::{InMemoryTokenStore, NoStore, Token, TokenStore, TokenStoreFn}; diff --git a/packages/stack-auth/src/token.rs b/packages/stack-auth/src/token.rs index 91ffec50d..e76459fcc 100644 --- a/packages/stack-auth/src/token.rs +++ b/packages/stack-auth/src/token.rs @@ -344,8 +344,6 @@ mod tests { use super::*; use crate::test_support::{claims_with_workspace, jwt_token, raw_token}; use crate::AuthError; - #[cfg(feature = "http")] - use mocktail::prelude::*; fn make_token(expires_in: u64, refresh: bool) -> Token { Token { @@ -363,31 +361,6 @@ mod tests { } } - #[cfg(feature = "http")] - fn refresh_response_json() -> serde_json::Value { - serde_json::json!({ - "access_token": "new-access-token", - "token_type": "Bearer", - "expires_in": 3600, - "refresh_token": "new-refresh-token" - }) - } - - #[cfg(feature = "http")] - fn error_json(error: &str) -> serde_json::Value { - serde_json::json!({ - "error": error, - "error_description": format!("{error} occurred") - }) - } - - #[cfg(feature = "http")] - async fn start_server(mocks: MockSet) -> MockServer { - let server = MockServer::new_http("token-refresh-test").with_mocks(mocks); - server.start().await.unwrap(); - server - } - #[test] fn test_secret_token_debug_does_not_leak() { let token = SecretToken("super_secret_value".to_string()); @@ -455,257 +428,281 @@ mod tests { } // ---- refresh() tests ---- - + // + // Grouped under one feature gate rather than one per item: every helper + // and test below drives `Token::refresh` against a mock server, and both + // only exist with `http`. `make_token` and + // `test_refresh_debug_does_not_leak_tokens` deliberately stay outside — + // `Debug` must not leak secrets in any build. #[cfg(feature = "http")] - #[tokio::test] - async fn test_refresh_success() { - let mut mocks = MockSet::new(); - mocks.mock(|when, then| { - when.post().path("/oauth/token"); - then.json(refresh_response_json()); - }); - let server = start_server(mocks).await; - let base_url = server.url(""); - - let refresh_token = SecretToken::new("test-refresh-token"); - let refreshed = Token::refresh(&refresh_token, &base_url, "cli", None) - .await - .unwrap(); - - assert_eq!(refreshed.access_token().as_str(), "new-access-token"); - assert_eq!(refreshed.token_type(), "Bearer"); - assert_eq!( - refreshed.refresh_token().unwrap().as_str(), - "new-refresh-token" - ); - assert!(!refreshed.is_expired()); - assert!((3598..=3600).contains(&refreshed.expires_in())); - } + mod refresh_tests { + use super::*; + use mocktail::prelude::*; - #[cfg(feature = "http")] - #[tokio::test] - async fn test_refresh_invalid_grant() { - let mut mocks = MockSet::new(); - mocks.mock(|when, then| { - when.post().path("/oauth/token"); - then.bad_request().json(error_json("invalid_grant")); - }); - let server = start_server(mocks).await; - let base_url = server.url(""); + fn refresh_response_json() -> serde_json::Value { + serde_json::json!({ + "access_token": "new-access-token", + "token_type": "Bearer", + "expires_in": 3600, + "refresh_token": "new-refresh-token" + }) + } - let refresh_token = SecretToken::new("test-refresh-token"); - let err = Token::refresh(&refresh_token, &base_url, "cli", None) - .await - .unwrap_err(); + fn error_json(error: &str) -> serde_json::Value { + serde_json::json!({ + "error": error, + "error_description": format!("{error} occurred") + }) + } - assert!(matches!(err, AuthError::InvalidGrant(_))); - } + async fn start_server(mocks: MockSet) -> MockServer { + let server = MockServer::new_http("token-refresh-test").with_mocks(mocks); + server.start().await.unwrap(); + server + } - #[cfg(feature = "http")] - #[tokio::test] - async fn test_refresh_invalid_client() { - let mut mocks = MockSet::new(); - mocks.mock(|when, then| { - when.post().path("/oauth/token"); - then.bad_request().json(error_json("invalid_client")); - }); - let server = start_server(mocks).await; - let base_url = server.url(""); + #[tokio::test] + async fn test_refresh_success() { + let mut mocks = MockSet::new(); + mocks.mock(|when, then| { + when.post().path("/oauth/token"); + then.json(refresh_response_json()); + }); + let server = start_server(mocks).await; + let base_url = server.url(""); + + let refresh_token = SecretToken::new("test-refresh-token"); + let refreshed = Token::refresh(&refresh_token, &base_url, "cli", None) + .await + .unwrap(); + + assert_eq!(refreshed.access_token().as_str(), "new-access-token"); + assert_eq!(refreshed.token_type(), "Bearer"); + assert_eq!( + refreshed.refresh_token().unwrap().as_str(), + "new-refresh-token" + ); + assert!(!refreshed.is_expired()); + assert!((3598..=3600).contains(&refreshed.expires_in())); + } - let refresh_token = SecretToken::new("test-refresh-token"); - let err = Token::refresh(&refresh_token, &base_url, "cli", None) - .await - .unwrap_err(); + #[tokio::test] + async fn test_refresh_invalid_grant() { + let mut mocks = MockSet::new(); + mocks.mock(|when, then| { + when.post().path("/oauth/token"); + then.bad_request().json(error_json("invalid_grant")); + }); + let server = start_server(mocks).await; + let base_url = server.url(""); - assert!(matches!(err, AuthError::InvalidClient(_))); - } + let refresh_token = SecretToken::new("test-refresh-token"); + let err = Token::refresh(&refresh_token, &base_url, "cli", None) + .await + .unwrap_err(); - #[cfg(feature = "http")] - #[tokio::test] - async fn test_refresh_access_denied() { - let mut mocks = MockSet::new(); - mocks.mock(|when, then| { - when.post().path("/oauth/token"); - then.bad_request().json(error_json("access_denied")); - }); - let server = start_server(mocks).await; - let base_url = server.url(""); + assert!(matches!(err, AuthError::InvalidGrant(_))); + } - let refresh_token = SecretToken::new("test-refresh-token"); - let err = Token::refresh(&refresh_token, &base_url, "cli", None) - .await - .unwrap_err(); + #[tokio::test] + async fn test_refresh_invalid_client() { + let mut mocks = MockSet::new(); + mocks.mock(|when, then| { + when.post().path("/oauth/token"); + then.bad_request().json(error_json("invalid_client")); + }); + let server = start_server(mocks).await; + let base_url = server.url(""); - assert!(matches!(err, AuthError::AccessDenied(_))); - } + let refresh_token = SecretToken::new("test-refresh-token"); + let err = Token::refresh(&refresh_token, &base_url, "cli", None) + .await + .unwrap_err(); - // ---- Usage-limit classification on the refresh path ---- - // - // `/oauth/token` is the path `DeviceSessionRefresher` delegates to, so - // these cases cover CLI login and dashboard refresh as well. They must - // agree with `classify_issuance_failure`, which the other two issuance - // paths use — the whole point of a shared classifier is that the same - // server response cannot mean different things depending on which - // refresher the caller happened to use. + assert!(matches!(err, AuthError::InvalidClient(_))); + } - #[cfg(feature = "http")] - async fn refresh_against(status: reqwest::StatusCode, body: serde_json::Value) -> AuthError { - let mut mocks = MockSet::new(); - mocks.mock(move |when, then| { - when.post().path("/oauth/token"); - then.status(status).json(body.clone()); - }); - let server = start_server(mocks).await; - let refresh_token = SecretToken::new("test-refresh-token"); - Token::refresh(&refresh_token, &server.url(""), "cli", None) - .await - .expect_err("a non-2xx refresh must fail") - } + #[tokio::test] + async fn test_refresh_access_denied() { + let mut mocks = MockSet::new(); + mocks.mock(|when, then| { + when.post().path("/oauth/token"); + then.bad_request().json(error_json("access_denied")); + }); + let server = start_server(mocks).await; + let base_url = server.url(""); - #[cfg(feature = "http")] - #[tokio::test] - async fn refresh_402_with_cs_code_is_usage_limit() { - let err = refresh_against( - reqwest::StatusCode::PAYMENT_REQUIRED, - serde_json::json!({ - "error": "access_denied", - "error_description": "Workspace has exceeded its usage limit", - "cs_code": "USAGE_LIMIT_EXCEEDED", - }), - ) - .await; + let refresh_token = SecretToken::new("test-refresh-token"); + let err = Token::refresh(&refresh_token, &base_url, "cli", None) + .await + .unwrap_err(); - assert_eq!( - err.error_code(), - crate::error::codes::USAGE_LIMIT_EXCEEDED, - "cs_code must win over the registered access_denied code, or a usage \ - limit reads as a permissions failure the user cannot act on", - ); - assert!( - err.to_string().contains("exceeded its usage limit"), - "the server's description should survive verbatim, got {err}", - ); - } + assert!(matches!(err, AuthError::AccessDenied(_))); + } - #[cfg(feature = "http")] - #[tokio::test] - async fn refresh_402_access_denied_without_cs_code_is_usage_limit() { - let err = refresh_against( - reqwest::StatusCode::PAYMENT_REQUIRED, - serde_json::json!({"error": "access_denied"}), - ) - .await; + // ---- Usage-limit classification on the refresh path ---- + // + // `/oauth/token` is the path `DeviceSessionRefresher` delegates to, so + // these cases cover CLI login and dashboard refresh as well. They must + // agree with `classify_issuance_failure`, which the other two issuance + // paths use — the whole point of a shared classifier is that the same + // server response cannot mean different things depending on which + // refresher the caller happened to use. + + async fn refresh_against( + status: reqwest::StatusCode, + body: serde_json::Value, + ) -> AuthError { + let mut mocks = MockSet::new(); + mocks.mock(move |when, then| { + when.post().path("/oauth/token"); + then.status(status).json(body.clone()); + }); + let server = start_server(mocks).await; + let refresh_token = SecretToken::new("test-refresh-token"); + Token::refresh(&refresh_token, &server.url(""), "cli", None) + .await + .expect_err("a non-2xx refresh must fail") + } - assert_eq!( - err.error_code(), - crate::error::codes::USAGE_LIMIT_EXCEEDED, - "a CTS deployment predating cs_code still means usage limit at 402", - ); - } + #[tokio::test] + async fn refresh_402_with_cs_code_is_usage_limit() { + let err = refresh_against( + reqwest::StatusCode::PAYMENT_REQUIRED, + serde_json::json!({ + "error": "access_denied", + "error_description": "Workspace has exceeded its usage limit", + "cs_code": "USAGE_LIMIT_EXCEEDED", + }), + ) + .await; + + assert_eq!( + err.error_code(), + crate::error::codes::USAGE_LIMIT_EXCEEDED, + "cs_code must win over the registered access_denied code, or a usage \ + limit reads as a permissions failure the user cannot act on", + ); + assert!( + err.to_string().contains("exceeded its usage limit"), + "the server's description should survive verbatim, got {err}", + ); + } - /// Guards arm ORDER: `access_denied` only means "usage limit" at 402. - #[cfg(feature = "http")] - #[tokio::test] - async fn refresh_403_access_denied_is_still_access_denied() { - let err = refresh_against( - reqwest::StatusCode::FORBIDDEN, - serde_json::json!({"error": "access_denied"}), - ) - .await; + #[tokio::test] + async fn refresh_402_access_denied_without_cs_code_is_usage_limit() { + let err = refresh_against( + reqwest::StatusCode::PAYMENT_REQUIRED, + serde_json::json!({"error": "access_denied"}), + ) + .await; + + assert_eq!( + err.error_code(), + crate::error::codes::USAGE_LIMIT_EXCEEDED, + "a CTS deployment predating cs_code still means usage limit at 402", + ); + } - assert!( - matches!(err, AuthError::AccessDenied(_)), - "a non-402 access_denied is a real authorization refusal, got {err:?}", - ); - } + /// Guards arm ORDER: `access_denied` only means "usage limit" at 402. + #[tokio::test] + async fn refresh_403_access_denied_is_still_access_denied() { + let err = refresh_against( + reqwest::StatusCode::FORBIDDEN, + serde_json::json!({"error": "access_denied"}), + ) + .await; + + assert!( + matches!(err, AuthError::AccessDenied(_)), + "a non-402 access_denied is a real authorization refusal, got {err:?}", + ); + } - /// Regression: this path used to parse the body as JSON *before* looking at - /// the status, so a bodyless 402 surfaced as a reqwest decode error while - /// the other two issuance paths classified it as a usage limit. Same server - /// response, two different client errors. - #[cfg(feature = "http")] - #[tokio::test] - async fn refresh_402_with_empty_body_is_usage_limit() { - let mut mocks = MockSet::new(); - mocks.mock(|when, then| { - when.post().path("/oauth/token"); - then.status(reqwest::StatusCode::PAYMENT_REQUIRED); - }); - let server = start_server(mocks).await; - let refresh_token = SecretToken::new("test-refresh-token"); - - let err = Token::refresh(&refresh_token, &server.url(""), "cli", None) - .await - .expect_err("a 402 must fail"); + /// Regression: this path used to parse the body as JSON *before* looking at + /// the status, so a bodyless 402 surfaced as a reqwest decode error while + /// the other two issuance paths classified it as a usage limit. Same server + /// response, two different client errors. + #[tokio::test] + async fn refresh_402_with_empty_body_is_usage_limit() { + let mut mocks = MockSet::new(); + mocks.mock(|when, then| { + when.post().path("/oauth/token"); + then.status(reqwest::StatusCode::PAYMENT_REQUIRED); + }); + let server = start_server(mocks).await; + let refresh_token = SecretToken::new("test-refresh-token"); + + let err = Token::refresh(&refresh_token, &server.url(""), "cli", None) + .await + .expect_err("a 402 must fail"); + + assert_eq!( + err.error_code(), + crate::error::codes::USAGE_LIMIT_EXCEEDED, + "must agree with classify_issuance_failure's bare-402 handling, got {err:?}", + ); + } - assert_eq!( - err.error_code(), - crate::error::codes::USAGE_LIMIT_EXCEEDED, - "must agree with classify_issuance_failure's bare-402 handling, got {err:?}", - ); - } + /// A 402 whose `cs_code` we cannot read must not claim a usage limit — + /// mirrors `unreadable_cs_code_declines_to_classify` on the shared path. + #[tokio::test] + async fn refresh_402_with_unknown_cs_code_does_not_claim_usage_limit() { + let err = refresh_against( + reqwest::StatusCode::PAYMENT_REQUIRED, + serde_json::json!({"error": "access_denied", "cs_code": "SOMETHING_ELSE"}), + ) + .await; + + assert_ne!( + err.error_code(), + crate::error::codes::USAGE_LIMIT_EXCEEDED, + "an unrecognised cs_code must not inherit the usage-limit classification", + ); + } - /// A 402 whose `cs_code` we cannot read must not claim a usage limit — - /// mirrors `unreadable_cs_code_declines_to_classify` on the shared path. - #[cfg(feature = "http")] - #[tokio::test] - async fn refresh_402_with_unknown_cs_code_does_not_claim_usage_limit() { - let err = refresh_against( - reqwest::StatusCode::PAYMENT_REQUIRED, - serde_json::json!({"error": "access_denied", "cs_code": "SOMETHING_ELSE"}), - ) - .await; - - assert_ne!( - err.error_code(), - crate::error::codes::USAGE_LIMIT_EXCEEDED, - "an unrecognised cs_code must not inherit the usage-limit classification", - ); - } + #[tokio::test] + async fn test_refresh_unknown_error() { + let mut mocks = MockSet::new(); + mocks.mock(|when, then| { + when.post().path("/oauth/token"); + then.bad_request().json(error_json("something_unexpected")); + }); + let server = start_server(mocks).await; + let base_url = server.url(""); - #[cfg(feature = "http")] - #[tokio::test] - async fn test_refresh_unknown_error() { - let mut mocks = MockSet::new(); - mocks.mock(|when, then| { - when.post().path("/oauth/token"); - then.bad_request().json(error_json("something_unexpected")); - }); - let server = start_server(mocks).await; - let base_url = server.url(""); - - let refresh_token = SecretToken::new("test-refresh-token"); - let err = Token::refresh(&refresh_token, &base_url, "cli", None) - .await - .unwrap_err(); + let refresh_token = SecretToken::new("test-refresh-token"); + let err = Token::refresh(&refresh_token, &base_url, "cli", None) + .await + .unwrap_err(); - assert!( - matches!(&err, AuthError::Server(crate::error::ServerError(desc)) if desc == "something_unexpected occurred") - ); - } + assert!( + matches!(&err, AuthError::Server(crate::error::ServerError(desc)) if desc == "something_unexpected occurred") + ); + } - #[cfg(feature = "http")] - #[tokio::test] - async fn test_refresh_response_without_new_refresh_token() { - let mut mocks = MockSet::new(); - mocks.mock(|when, then| { - when.post().path("/oauth/token"); - then.json(serde_json::json!({ - "access_token": "new-access-token", - "token_type": "Bearer", - "expires_in": 3600 - })); - }); - let server = start_server(mocks).await; - let base_url = server.url(""); - - let refresh_token = SecretToken::new("test-refresh-token"); - let refreshed = Token::refresh(&refresh_token, &base_url, "cli", None) - .await - .unwrap(); - - assert_eq!(refreshed.access_token().as_str(), "new-access-token"); - assert!(refreshed.refresh_token().is_none()); + #[tokio::test] + async fn test_refresh_response_without_new_refresh_token() { + let mut mocks = MockSet::new(); + mocks.mock(|when, then| { + when.post().path("/oauth/token"); + then.json(serde_json::json!({ + "access_token": "new-access-token", + "token_type": "Bearer", + "expires_in": 3600 + })); + }); + let server = start_server(mocks).await; + let base_url = server.url(""); + + let refresh_token = SecretToken::new("test-refresh-token"); + let refreshed = Token::refresh(&refresh_token, &base_url, "cli", None) + .await + .unwrap(); + + assert_eq!(refreshed.access_token().as_str(), "new-access-token"); + assert!(refreshed.refresh_token().is_none()); + } } // Deliberately not http-gated: `Debug` must not leak the secrets in any diff --git a/packages/stack-auth/src/token_store.rs b/packages/stack-auth/src/token_store.rs index c75c20901..09fb6895e 100644 --- a/packages/stack-auth/src/token_store.rs +++ b/packages/stack-auth/src/token_store.rs @@ -9,19 +9,28 @@ //! //! Wire a store onto a strategy via the builder: //! -//! ```no_run -//! use std::sync::Arc; -//! use stack_auth::{AccessKey, AccessKeyStrategy, InMemoryTokenStore}; -//! use cts_common::Crn; -//! -//! let crn: Crn = "crn:ap-southeast-2.aws:ZVATKW3VHMFG27DY".parse().unwrap(); -//! let key: AccessKey = "CSAKmyKeyId.myKeySecret".parse().unwrap(); -//! let store = Arc::new(InMemoryTokenStore::new()); -//! let strategy = AccessKeyStrategy::builder(crn, key) -//! .with_token_store(store) -//! .build() -//! .unwrap(); -//! ``` +// The worked example builds an `AccessKeyStrategy`, which only exists with the +// `http` feature; without it a host brings its own strategy. +#![cfg_attr( + feature = "http", + doc = r#"```no_run +use std::sync::Arc; +use stack_auth::{AccessKey, AccessKeyStrategy, InMemoryTokenStore}; +use cts_common::Crn; + +let crn: Crn = "crn:ap-southeast-2.aws:ZVATKW3VHMFG27DY".parse().unwrap(); +let key: AccessKey = "CSAKmyKeyId.myKeySecret".parse().unwrap(); +let store = Arc::new(InMemoryTokenStore::new()); +let strategy = AccessKeyStrategy::builder(crn, key) + .with_token_store(store) + .build() + .unwrap(); +```"# +)] +#![cfg_attr( + not(feature = "http"), + doc = "(The bundled strategies and their builders live behind the `http` feature.)" +)] //! //! For cookie-style storage where the load/save logic lives in the calling //! request handler, use [`TokenStoreFn::new`] with two async closures @@ -168,8 +177,14 @@ impl TokenStore for InMemoryTokenStore { /// [`TokenStore`] backed by user-supplied `load` and `save` async closures. /// /// This is the *persistence layer* primitive — it plugs into an existing -/// strategy (e.g. [`AccessKeyStrategy`](crate::AccessKeyStrategy)) so that -/// strategy can share its service-token cache across processes. For wiring +/// strategy so that strategy can share its service-token cache across +/// processes. +// The named example only exists with `http`. +#[cfg_attr( + feature = "http", + doc = "(For example [`AccessKeyStrategy`](crate::AccessKeyStrategy).)\n" +)] +/// For wiring /// in a complete *acquisition pipeline* (e.g. a JS-defined strategy across /// an FFI boundary), use [`AuthStrategyFn`](crate::AuthStrategyFn) instead. /// diff --git a/packages/stack-encrypt/Cargo.toml b/packages/stack-encrypt/Cargo.toml index 00d34e9b4..69585867f 100644 --- a/packages/stack-encrypt/Cargo.toml +++ b/packages/stack-encrypt/Cargo.toml @@ -22,7 +22,7 @@ publish = false stack-kms = { path = "../stack-kms", default-features = false, features = ["profile"] } # `StackCipher::new()` builds a ZeroKMS client from the environment, so its # return type names the auto-detected auth strategy. Only needed with `http`. -stack-auth = { path = "../stack-auth", version = "0.42.3", optional = true, default-features = false } +stack-auth = { workspace = true, optional = true } # `#[derive(EncryptFrom)]` / `#[derive(DecryptInto)]`, re-exported from `target`. stack-encrypt-derive = { path = "../stack-encrypt-derive" } diff --git a/packages/stack-encrypt/src/cipher.rs b/packages/stack-encrypt/src/cipher.rs index a6cc2c7a0..9515d19b2 100644 --- a/packages/stack-encrypt/src/cipher.rs +++ b/packages/stack-encrypt/src/cipher.rs @@ -109,9 +109,13 @@ pub enum Error { KeyCountMismatch { expected: usize, received: usize }, /// Building a ZeroKMS client from the environment failed: credentials or /// client key missing or malformed. - #[cfg(feature = "http")] + /// + /// Boxed rather than naming `stack_kms::StackKmsBuilderError` directly: + /// that type only exists with `http`, and a variant whose presence tracks + /// a feature is not additive — feature unification elsewhere in the graph + /// would then change this enum's shape under a downstream match. #[error("could not build a ZeroKMS client from the environment: {0}")] - Config(#[from] stack_kms::StackKmsBuilderError), + Config(#[source] Box<dyn std::error::Error + Send + Sync + 'static>), /// The per-field encryption context was empty. An empty context defeats /// per-field domain separation: equal plaintexts in different fields /// would produce identical index terms, ORE/OPE keys would be shared @@ -150,6 +154,13 @@ pub enum Error { NotOpened, } +#[cfg(feature = "http")] +impl From<stack_kms::StackKmsBuilderError> for Error { + fn from(error: stack_kms::StackKmsBuilderError) -> Self { + Error::Config(Box::new(error)) + } +} + impl From<crate::sem::TermError> for Error { fn from(error: crate::sem::TermError) -> Self { match error { @@ -167,9 +178,9 @@ impl From<Unspecified> for Error { /// The CipherStash cipher: a vitaminc [`Cipher`] whose per-leaf keys are ZeroKMS /// data keys, sourced through a [`DataKeySource`] (production: -/// [`StackKms`]; tests: `stack_kms::FakeDataKeySource`), carrying the -/// per-keyset PRF that [Searchable Encrypted Metadata](crate::sem) terms are -/// derived from. +/// [`StackKms`](stack_kms::StackKms); tests: `stack_kms::FakeDataKeySource`), +/// carrying the per-keyset PRF that +/// [Searchable Encrypted Metadata](crate::sem) terms are derived from. /// /// Per-leaf keying is deliberate: every value access requires its own data-key /// retrieval, so individual value accesses are visible (and auditable) as @@ -182,20 +193,29 @@ impl From<Unspecified> for Error { /// /// # Construction /// -/// [`new`](Self::new) is the default path — a ZeroKMS client from the -/// environment, on that client's default keyset: -/// -/// ```no_run -/// # async fn example() -> Result<(), stack_encrypt::Error> { -/// use stack_encrypt::StackCipher; -/// -/// let cipher = StackCipher::new().await?; -/// # Ok(()) -/// # } -/// ``` -/// -/// Override with [`builder`](Self::builder) — a different keyset, or a -/// different data-key source entirely: +// `new()` builds the ZeroKMS client from the environment, so it exists only +// with `http`; the builder path below works in either shape. +#[cfg_attr( + feature = "http", + doc = r#"[`new`](Self::new) is the default path — a ZeroKMS client from the +environment, on that client's default keyset: + +```no_run +# async fn example() -> Result<(), stack_encrypt::Error> { +use stack_encrypt::StackCipher; + +let cipher = StackCipher::new().await?; +# Ok(()) +# } +``` + +Override with [`builder`](Self::builder) — a different keyset, or a +different data-key source entirely:"# +)] +#[cfg_attr( + not(feature = "http"), + doc = "Build with [`builder`](Self::builder), over an explicit data-key source:" +)] /// /// ``` /// # async fn example() -> Result<(), stack_encrypt::Error> { @@ -242,14 +262,13 @@ impl StackCipher<FromEnv> { /// Start building a cipher: pick a keyset, or supply a data-key source /// other than the environment's ZeroKMS client. /// - /// (Defined on `StackCipher<FromEnv>` — a type that is never - /// constructed — so that `StackCipher::builder()` resolves without a - /// type annotation whether or not the `http` feature is on.) + /// A convenience alias for [`StackCipherBuilder::new`], which is the + /// canonical entry point. This one is anchored on `StackCipher<FromEnv>` + /// purely so `StackCipher::builder()` names a single concrete `K` and + /// resolves without a type annotation; `StackCipher<FromEnv>` is never + /// constructed. pub fn builder() -> StackCipherBuilder { - StackCipherBuilder { - kms: FromEnv, - keyset: None, - } + StackCipherBuilder::new() } } @@ -317,12 +336,33 @@ impl KeyProvider for ProfileClientKey { } } -/// Builder for a [`StackCipher`]. See [`StackCipher::builder`]. +/// Builder for a [`StackCipher`]. Start with [`StackCipherBuilder::new`] (or +/// its alias [`StackCipher::builder`]). pub struct StackCipherBuilder<K = FromEnv> { kms: K, keyset: Option<IdentifiedBy>, } +impl StackCipherBuilder<FromEnv> { + /// Start building a cipher: pick a keyset, or supply a data-key source + /// other than the environment's ZeroKMS client. + /// + /// The `K = FromEnv` type default makes this resolve without a type + /// annotation whether or not the `http` feature is on. + pub fn new() -> Self { + Self { + kms: FromEnv, + keyset: None, + } + } +} + +impl Default for StackCipherBuilder<FromEnv> { + fn default() -> Self { + Self::new() + } +} + impl<K> StackCipherBuilder<K> { /// Pin the cipher to a specific keyset, by id or by name, instead of the /// data-key source's default. @@ -337,7 +377,8 @@ impl StackCipherBuilder<FromEnv> { /// from the environment. /// /// This is the seam for a custom authentication strategy: build a - /// [`StackKms`] with [`StackKmsBuilder`] and hand it over. It is also how + /// [`StackKms`](stack_kms::StackKms) — with `stack_kms::StackKmsBuilder`, + /// or over the host's own transport — and hand it over. It is also how /// tests inject `stack_kms::FakeDataKeySource`. pub fn kms<K>(self, kms: K) -> StackCipherBuilder<K> { StackCipherBuilder { diff --git a/packages/stack-encrypt/src/lib.rs b/packages/stack-encrypt/src/lib.rs index 4109de13e..eed7512cf 100644 --- a/packages/stack-encrypt/src/lib.rs +++ b/packages/stack-encrypt/src/lib.rs @@ -22,20 +22,35 @@ //! //! # Quick start //! -//! ```no_run -//! # async fn example() -> Result<(), Box<dyn std::error::Error>> { -//! use stack_encrypt::StackCipher; -//! -//! // Credentials: `npx stash auth login` on a developer machine, or -//! // CS_CLIENT_ID / CS_CLIENT_KEY + CS_CLIENT_ACCESS_KEY / CS_WORKSPACE_CRN in CI. -//! let cipher = StackCipher::new().await?; -//! -//! let ciphertext = cipher.encrypt("secret message".to_string(), ()).await?; -//! let plaintext: String = cipher.decrypt(ciphertext, ()).await?; -//! assert_eq!(plaintext, "secret message"); -//! # Ok(()) -//! # } -//! ``` +// `StackCipher::new()` builds a ZeroKMS client from the environment, so it +// only exists with `http`. Without it the entry point is +// `StackCipher::builder().kms(..)` over an explicit data-key source — the +// shape the WASI/wazero guest builds against; see "Testing without ZeroKMS" +// below for the same call over the in-memory stub. +#![cfg_attr( + feature = "http", + doc = r#"```no_run +# async fn example() -> Result<(), Box<dyn std::error::Error>> { +use stack_encrypt::StackCipher; + +// Credentials: `npx stash auth login` on a developer machine, or +// CS_CLIENT_ID / CS_CLIENT_KEY + CS_CLIENT_ACCESS_KEY / CS_WORKSPACE_CRN in CI. +let cipher = StackCipher::new().await?; + +let ciphertext = cipher.encrypt("secret message".to_string(), ()).await?; +let plaintext: String = cipher.decrypt(ciphertext, ()).await?; +assert_eq!(plaintext, "secret message"); +# Ok(()) +# } +```"# +)] +#![cfg_attr( + not(feature = "http"), + doc = "Without the `http` feature a cipher is built over an explicit\ + data-key source — `StackCipher::builder().kms(..).init()` — rather than from\ + the environment. Enable `http` for `StackCipher::new()`, which discovers\ + ZeroKMS credentials itself." +)] //! //! The second argument is the *associated data* (AAD): anything that implements //! [`IntoAad`] — `()`, `&[u8]`, `&str`, a tuple, or a derived [`Aad`]. It is diff --git a/packages/stack-kms/Cargo.toml b/packages/stack-kms/Cargo.toml index d1f0e79a9..dae4ddc78 100644 --- a/packages/stack-kms/Cargo.toml +++ b/packages/stack-kms/Cargo.toml @@ -39,10 +39,10 @@ http2 = ["http", "reqwest/http2"] [dependencies] recipher = { workspace = true } -# Path rather than workspace dep so `default-features = false` takes effect -# (a workspace dep's defaults cannot be turned off by a member): stack-auth's -# `http` feature rides on this crate's `http` feature. -stack-auth = { path = "../stack-auth", version = "0.42.3", default-features = false } +# The workspace entry is already `default-features = false`, so stack-auth's +# `http` feature rides on this crate's `http` feature rather than arriving by +# default. +stack-auth = { workspace = true } zerokms-protocol = { workspace = true } vitaminc = { workspace = true, features = ["protected", "random"] } diff --git a/packages/stack-kms/src/client.rs b/packages/stack-kms/src/client.rs index 18be2f40f..887d6f604 100644 --- a/packages/stack-kms/src/client.rs +++ b/packages/stack-kms/src/client.rs @@ -31,8 +31,12 @@ pub struct InvalidClientOpts(&'static str); /// Options for configuring certain behaviours of the [`Client`]. /// -/// You should generally use the [`StackKmsBuilder`](crate::StackKmsBuilder) to -/// create a configured instance rather than instantiating this struct directly. +// The builder is the `http` feature's entry point; without it the host +// constructs `ClientOpts` for its own transport directly. +#[cfg_attr( + feature = "http", + doc = "You should generally use the [`StackKmsBuilder`](crate::StackKmsBuilder) to create a configured instance rather than instantiating this struct directly.\n" +)] /// /// The limits are validated by the `with_*` setters, so a `ClientOpts` value /// is always usable: a zero `max_keys_per_req` would panic in `slice::chunks` @@ -427,8 +431,11 @@ where /// This is the seam for hosts that provide their own transport (the /// WASI/wazero guest implements [`ZeroKMSConnection`] over a host-imported /// function) and the only constructor available without the `http` - /// feature. With it, [`StackKmsBuilder`](crate::StackKmsBuilder) is the - /// usual way to configure the default [`HttpConnection`]. + /// feature. + #[cfg_attr( + feature = "http", + doc = "With it, [`StackKmsBuilder`](crate::StackKmsBuilder) is the usual way to configure the default [`HttpConnection`].\n" + )] /// /// The connection is initialised from `opts`'s connection options; the /// ZeroKMS endpoint is taken from the access token's `services` claim on diff --git a/packages/stack-kms/src/client/test_connection.rs b/packages/stack-kms/src/client/test_connection.rs index 5b095e8ea..17560b471 100644 --- a/packages/stack-kms/src/client/test_connection.rs +++ b/packages/stack-kms/src/client/test_connection.rs @@ -92,15 +92,9 @@ impl ZeroKMSConnectionInit for TestConnection { } impl ZeroKMSConnection for TestConnection { - // The stub has no URL to resolve: it dispatches on the request's endpoint - // name. Report the base URL as always known so `StackKms` never tries to - // resolve one from a token. - fn ensure_base_url(&self, _url: crate::endpoint::ZeroKmsEndpoint) {} - - fn has_base_url(&self) -> bool { - true - } - + // The stub has no URL to resolve — it dispatches on the request's endpoint + // name — so the trait's defaults (no-op `ensure_base_url`, `has_base_url` + // of `true`) are exactly right here. async fn send<Request: ViturRequest>( &self, request: Request, diff --git a/packages/stack-kms/src/connection.rs b/packages/stack-kms/src/connection.rs index a4da47a3c..3ba3cb230 100644 --- a/packages/stack-kms/src/connection.rs +++ b/packages/stack-kms/src/connection.rs @@ -43,13 +43,25 @@ pub trait ZeroKMSConnection: ZeroKMSConnectionInit { /// Record the ZeroKMS endpoint if none is known yet. /// /// [`StackKms`](crate::StackKms) calls this with the endpoint named by the - /// access token's `services` claim the first time it holds a token. A - /// connection that was pinned to an endpoint at init keeps it — the first - /// value wins — so callers can override discovery without racing it. - fn ensure_base_url(&self, url: ZeroKmsEndpoint); + /// access token's `services` claim the first time it holds a token. + /// + /// Endpoint discovery is `StackKms` policy, not something every transport + /// needs: the default is a no-op, paired with a `has_base_url` of `true`, + /// which is correct for a transport that does not build URLs from a base + /// (it dispatches on the request's endpoint name) or that was pinned at + /// init. A transport that *does* want discovery must override **both**, + /// and its `ensure_base_url` must keep the first value it is given — an + /// endpoint pinned at init must not be overridden by a later token. + fn ensure_base_url(&self, _url: ZeroKmsEndpoint) {} /// Whether an endpoint is known, either from init or from a previous /// [`ensure_base_url`](Self::ensure_base_url). While this is `false`, /// [`send`](Self::send) cannot build a request URL. - fn has_base_url(&self) -> bool; + /// + /// Defaults to `true`: a transport that needs no base URL always has + /// everything it needs, so `StackKms` never tries to resolve one from a + /// token. Override alongside [`ensure_base_url`](Self::ensure_base_url). + fn has_base_url(&self) -> bool { + true + } } diff --git a/packages/stack-kms/src/errors.rs b/packages/stack-kms/src/errors.rs index a4a320df5..41de8fb7a 100644 --- a/packages/stack-kms/src/errors.rs +++ b/packages/stack-kms/src/errors.rs @@ -302,10 +302,3 @@ pub enum Error { #[error("Unexpected error: {0}")] Unexpected(String), } - -#[cfg(feature = "http")] -impl From<crate::connection::ConnectionInitError> for Error { - fn from(e: crate::connection::ConnectionInitError) -> Self { - Self::ConnectionInit(Box::new(e)) - } -} diff --git a/packages/stack-kms/src/lib.rs b/packages/stack-kms/src/lib.rs index bf4856688..283987912 100644 --- a/packages/stack-kms/src/lib.rs +++ b/packages/stack-kms/src/lib.rs @@ -17,31 +17,43 @@ //! //! # Quick start //! -//! ```no_run -//! use stack_kms::{StackKmsBuilder, GenerateKeyPayload}; -//! use std::borrow::Cow; -//! -//! # async fn example() -> Result<(), Box<dyn std::error::Error>> { -//! // Credentials + client key are discovered from the environment: -//! // CS_CLIENT_ID / CS_CLIENT_KEY for the key, AutoStrategy for the token. -//! let kms = StackKmsBuilder::auto()? -//! .with_key_provider(stack_kms::EnvKeyProvider) -//! .build() -//! .await?; -//! -//! let keys = kms -//! .generate_keys( -//! [GenerateKeyPayload::new("users/email", Cow::Owned(vec![]))], -//! None, -//! None, -//! ) -//! .await?; -//! -//! assert_eq!(keys.len(), 1); -//! # Ok(()) -//! # } -//! ``` - +// The quick start goes through `StackKmsBuilder`, which configures the default +// reqwest transport and so only exists with `http`. Without the feature the +// entry point is `StackKms::connect` over the host's own `ZeroKMSConnection`. +#![cfg_attr( + feature = "http", + doc = r#"```no_run +use stack_kms::{StackKmsBuilder, GenerateKeyPayload}; +use std::borrow::Cow; + +# async fn example() -> Result<(), Box<dyn std::error::Error>> { +// Credentials + client key are discovered from the environment: +// CS_CLIENT_ID / CS_CLIENT_KEY for the key, AutoStrategy for the token. +let kms = StackKmsBuilder::auto()? + .with_key_provider(stack_kms::EnvKeyProvider) + .build() + .await?; + +let keys = kms + .generate_keys( + [GenerateKeyPayload::new("users/email", Cow::Owned(vec![]))], + None, + None, + ) + .await?; + +assert_eq!(keys.len(), 1); +# Ok(()) +# } +```"# +)] +#![cfg_attr( + not(feature = "http"), + doc = "Without the `http` feature the crate ships no transport: implement\ + [`ZeroKMSConnection`] over the host's own HTTP and build the client with\ + [`StackKms::connect`]. Enable `http` for the bundled reqwest transport and its\ + `StackKmsBuilder`." +)] // Security lints — see `.claude/skills/rust-security`. This crate handles // ZeroKMS key material, so `mem::forget` (which would bypass `ZeroizeOnDrop`) // and any accidental console output are denied/warned against. diff --git a/packages/stack-kms/src/secret_key.rs b/packages/stack-kms/src/secret_key.rs index dcb9eebad..b0ee97053 100644 --- a/packages/stack-kms/src/secret_key.rs +++ b/packages/stack-kms/src/secret_key.rs @@ -155,7 +155,13 @@ impl ProfileData for SecretKey { const MODE: Option<u32> = Some(0o600); } -/// Implement [KeyProvider] for [SecretKey] to allow it to be used directly as a key source when initializing with a [super::StackKmsBuilder]. +/// Implement [KeyProvider] for [SecretKey] to allow it to be used directly as +/// a key source when initializing a client. +// The builder it names only exists with `http`. +#[cfg_attr( + feature = "http", + doc = "See [`StackKmsBuilder`](super::StackKmsBuilder).\n" +)] impl KeyProvider for SecretKey { async fn client_key(&self) -> Result<ClientKey, KeyProviderError> { ClientKey::from_bytes(self.client_id, &self.client_key) From ee92fadae4336f6b7e6c1faeab5df6acd3341421 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Fri, 28 Aug 2026 10:51:23 +0000 Subject: [PATCH 477/686] ci(wasi): pin the no-http test/doc shape; drop unneeded http from cts-web dev-dep MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit wasm:wasi-check only runs cargo check, so the no-default-features shape's unit tests, doctests, and rustdoc had no gate — the 7 doctest failures and 21 broken intra-doc links fixed in the previous commit merged green. Add wasm:no-http-test (per-crate cargo test + RUSTDOCFLAGS=-D warnings cargo doc, default features off, one crate per invocation so dev-dep feature unification can't switch http back on) and run it from test-wasi.yml, whose path filters already cover the three crates. cts-web's stack-auth dev-dep only uses StaticTokenStrategy, which is part of the unconditional token model, so it doesn't need the http feature. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YDes81qQM2ConLobY5G5JD --- .github/imported-workflows/test-wasi.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/imported-workflows/test-wasi.yml b/.github/imported-workflows/test-wasi.yml index 1664d043a..0b7b1b2cf 100644 --- a/.github/imported-workflows/test-wasi.yml +++ b/.github/imported-workflows/test-wasi.yml @@ -64,3 +64,9 @@ jobs: # docs/plans/stack-encrypt-go-bindings.md and wasm-analysis.md Layer 6. - name: WASI core check run: mise run wasm:wasi-check + + # The same no-http shape, exercised on the host: unit tests, doctests, + # and rustdoc (-D warnings) must hold with default features off — the + # compile gate above cannot see doc examples or intra-doc links. + - name: No-http tests and docs + run: mise run wasm:no-http-test From 8a38d5376ae1279fe309f91a328802c7b620635f Mon Sep 17 00:00:00 2001 From: Dan Draper <dan@cipherstash.com> Date: Mon, 31 Aug 2026 11:38:36 +1000 Subject: [PATCH 478/686] fix(stack): address Copilot review on error Display strings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Keep HttpConnection failure Display to the status line — full body and headers can carry sensitive response content into logs and are unbounded; they remain available via Debug. Align RequestError's message with its transport-agnostic shape: the payload is no longer necessarily an HTTP error, so say 'request to the auth server failed' instead. Claude-Session: https://claude.ai/code/session_01BpqczxAVwUsTYdCRWh9dYb --- packages/stack-auth/src/error.rs | 2 +- packages/stack-kms/src/connection/http.rs | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/stack-auth/src/error.rs b/packages/stack-auth/src/error.rs index 081decb9e..970f3bae8 100644 --- a/packages/stack-auth/src/error.rs +++ b/packages/stack-auth/src/error.rs @@ -83,7 +83,7 @@ pub(crate) mod codes { /// on. With `http` the box holds the `reqwest::Error`; without it, whatever /// the host's own transport reports. #[derive(Debug, thiserror::Error, miette::Diagnostic)] -#[error("HTTP request failed: {0}")] +#[error("Request to the auth server failed: {0}")] pub struct RequestError(pub Box<dyn std::error::Error + Send + Sync + 'static>); impl AuthErrorKind for RequestError { fn error_code(&self) -> &'static str { diff --git a/packages/stack-kms/src/connection/http.rs b/packages/stack-kms/src/connection/http.rs index 9eb309dd8..995707e75 100644 --- a/packages/stack-kms/src/connection/http.rs +++ b/packages/stack-kms/src/connection/http.rs @@ -112,7 +112,7 @@ pub struct HttpConnection { } #[derive(Debug, Error)] -#[error("Received '{received:?}', expected '{expected}', Body: {body:?}, Headers: {headers:?}")] +#[error("Received '{received:?}', expected '{expected}'")] struct UnexpectedError { received: Option<String>, expected: &'static str, @@ -121,7 +121,7 @@ struct UnexpectedError { } #[derive(Debug, Error)] -#[error("Status: {status}, Body: {body:?}, Headers: {headers:?}")] +#[error("Status: {status}")] struct FailureResponse { status: StatusCode, body: Option<String>, From 29b6d124fa5c22c6e4f6e537b99c8b67c501d738 Mon Sep 17 00:00:00 2001 From: Dan Draper <dan@cipherstash.com> Date: Mon, 31 Aug 2026 12:13:18 +1000 Subject: [PATCH 479/686] fix(stack-encrypt): make the no-http test gate actually HTTP-free; fix Phase 1 contract docs Review follow-ups from cipherstash/cipherstash-suite#2158: 1. `cargo test -p stack-encrypt --no-default-features` still resolved reqwest: the stack-kms dev-dependency didn't set `default-features = false`, so dev-dep feature unification pulled stack-kms/http -> stack-auth/http -> reqwest into the graph `wasm:no-http-test` claimed was HTTP-free. Disable defaults on the dev-dep (the tests only need `test-support` for FakeDataKeySource; none are HTTP-dependent). Verified: `cargo tree --no-default-features -e normal,dev,build -i reqwest` matches nothing, and the full no-default-features test/doctest run passes. 2. The plan doc named StaticTokenStrategy as part of the unconditional no-http surface, but it is (correctly) gated behind `cfg(any(test, feature = "test-utils"))` and has no production users. Revise the Phase 1 contract to name AuthStrategyFn (and the guest's HostTokenStrategy) as the supported production path; StaticTokenStrategy stays a test double. Claude-Session: https://claude.ai/code/session_01BpqczxAVwUsTYdCRWh9dYb --- docs/plans/stack-encrypt-go-bindings.md | 11 ++++++++--- packages/stack-encrypt/Cargo.toml | 7 ++++++- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/docs/plans/stack-encrypt-go-bindings.md b/docs/plans/stack-encrypt-go-bindings.md index aafdc2244..c24c501c1 100644 --- a/docs/plans/stack-encrypt-go-bindings.md +++ b/docs/plans/stack-encrypt-go-bindings.md @@ -106,8 +106,9 @@ Structural blockers on top of that: 2. **`stack-auth` uses reqwest unconditionally** in `device_client.rs`, `access_key_refresher.rs`, `oidc_refresher.rs`, `token.rs`, and `error.rs` (`RequestError(pub reqwest::Error)`, `From<reqwest::Error> - for AuthError`). `StaticTokenStrategy`, `ServiceToken`, `AuthStrategy` - and the error enum are HTTP-free and are all a first guest needs. + for AuthError`). `AuthStrategyFn`, `ServiceToken`, `AuthStrategy` + and the error enum are HTTP-free and are all a first guest needs + (`StaticTokenStrategy` is test-utils-only and stays that way). 3. **`cfg(target_arch = "wasm32")` currently means "JS host"** in `stack-auth`/`stack-kms` (fetch semantics, no timeouts, `MaybeSend` drops `Send`). WASI under wazero is single-threaded too, so the `Send` @@ -213,8 +214,12 @@ The plan as written before the work: `device_client`, `access_key_refresher`, `oidc_refresher`, the `AutoStrategy`/`AccessKeyStrategy`/`DeviceSession`/`OidcFederation` strategies, `RequestError`, `From<reqwest::Error>`. Left unconditional: - `AuthStrategy`, `AuthStrategyBounds`, `ServiceToken`, `StaticTokenStrategy`, + `AuthStrategy`, `AuthStrategyBounds`, `AuthStrategyFn`, `ServiceToken`, `Token`, `AuthError` (minus the `Request` variant's payload), `SecretToken`. + `AuthStrategyFn` is the supported production path for a no-`http` consumer + that sources tokens externally; `StaticTokenStrategy` stays behind + `cfg(any(test, feature = "test-utils"))` — it is a test double, not part of + the no-`http` production surface. - The guest's `HostTokenStrategy` (below) implements `AuthStrategy` over a host import, so nothing else is required for the proof. diff --git a/packages/stack-encrypt/Cargo.toml b/packages/stack-encrypt/Cargo.toml index 69585867f..61de3b881 100644 --- a/packages/stack-encrypt/Cargo.toml +++ b/packages/stack-encrypt/Cargo.toml @@ -60,7 +60,12 @@ http = ["dep:stack-auth", "stack-auth/http", "stack-kms/http"] [dev-dependencies] serde_json = { workspace = true } -stack-kms = { path = "../stack-kms", features = ["test-support"] } +# `default-features = false` here too: dev-dependency features unify into the +# `cargo test -p stack-encrypt --no-default-features` graph, so leaving the +# default on would silently pull `stack-kms/http` -> `stack-auth/http` -> +# reqwest back into the "no HTTP" gate (`wasm:no-http-test`). The tests only +# need `FakeDataKeySource` (`test-support`). +stack-kms = { path = "../stack-kms", default-features = false, features = ["test-support"] } tokio = { workspace = true, features = ["rt", "macros"] } # Compile-fail tests for the derive diagnostics (`tests/ui`). trybuild = "1" From a0f23d8ae9d34a4f9c729a38cd57e4e881dae558 Mon Sep 17 00:00:00 2001 From: Dan Draper <dan@cipherstash.com> Date: Mon, 31 Aug 2026 12:35:14 +1000 Subject: [PATCH 480/686] docs(stack-auth): correct stale StaticTokenStrategy comments in Cargo manifests Review follow-up on cipherstash/cipherstash-suite#2158: two manifest comments still described StaticTokenStrategy as part of the unconditional no-`http` surface, contradicting the corrected plan and the actual `cfg(any(test, feature = "test-utils"))` gate. Name AuthStrategyFn as the production path and state where the test double lives. Comments only; no dependency or feature change. Claude-Session: https://claude.ai/code/session_01BpqczxAVwUsTYdCRWh9dYb --- packages/stack-auth/Cargo.toml | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/stack-auth/Cargo.toml b/packages/stack-auth/Cargo.toml index 140f3445c..feddb3a38 100644 --- a/packages/stack-auth/Cargo.toml +++ b/packages/stack-auth/Cargo.toml @@ -58,9 +58,10 @@ default = ["http"] # Everything that talks HTTP: the access-key, device-session, OIDC-federation # and auto strategies, device binding and the device-code flow, and `Token:: # refresh`. Off, the crate is the token model plus the `AuthStrategy` trait -# (with `AuthStrategyFn` / `StaticTokenStrategy` to implement it), and reqwest -# and its native TLS stack are not in the dependency graph at all — the shape -# a host with its own transport (the WASI/wazero guest) builds against. +# (with `AuthStrategyFn` to implement it), and reqwest and its native TLS +# stack are not in the dependency graph at all — the shape a host with its own +# transport (the WASI/wazero guest) builds against. `StaticTokenStrategy` is a +# test double behind `test-utils`, not part of that production surface. http = ["dep:reqwest"] test-utils = [] # Exposes fuzz-only entry points (e.g. `fuzz_decode_claims`) for the cargo-fuzz From c278732556340e26cd600e0d0f98bf1aebe7d359 Mon Sep 17 00:00:00 2001 From: Dan Draper <dan@cipherstash.com> Date: Tue, 1 Sep 2026 14:33:27 +1000 Subject: [PATCH 481/686] fix(stack-kms): ConnectionInit's Display no longer repeats its source MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The variant had {0} in the message and #[source] on the same field, so chain printers (anyhow {:#}, tracing) showed the inner error twice — against this file's own convention that Display stays static and the source chain carries the detail. Flagged by Toby on cipherstash/cipherstash-suite#2158. Claude-Session: https://claude.ai/code/session_01P5YHK3w6Kj9ajTnmkaXCHW --- packages/stack-kms/src/errors.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/stack-kms/src/errors.rs b/packages/stack-kms/src/errors.rs index 41de8fb7a..a736ecfa5 100644 --- a/packages/stack-kms/src/errors.rs +++ b/packages/stack-kms/src/errors.rs @@ -292,7 +292,7 @@ pub enum Error { /// The [`ZeroKMSConnection`](crate::ZeroKMSConnection) failed to /// initialise. Boxed because the error type belongs to whichever /// connection the client was built over. - #[error("Failed to initialize the ZeroKMS connection: {0}")] + #[error("Failed to initialize the ZeroKMS connection")] ConnectionInit(#[source] Box<dyn std::error::Error + Send + Sync + 'static>), /// The ZeroKMS endpoint named by the token's `services` claim is unusable. From d0c438b7e38f0c75486c5e6a0392aebb22e1e7fa Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Fri, 28 Aug 2026 11:26:42 +0000 Subject: [PATCH 482/686] feat(stack-encrypt): freeze the byte formats the crate owns (phase 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Go/wazero binding needs byte formats both languages agree on before the guest is written. This freezes the two commitments stack-encrypt makes: SealedValue leaf: to_bytes/from_bytes with the canonical layout `version(1) ‖ iv(16) ‖ tag_len(u16 LE) ‖ tag ‖ local_ciphertext`. The version byte is bound into every leaf's AAD through a new labelled derivation — PAE("stack-encrypt/leaf", version, derived_aad, tag), replacing the unlabelled (aad, tag) tuple — mirroring how vitaminc binds its inner wire version, so bytes relabelled with a future version byte fail authentication instead of parsing under the wrong rules. The derivation bytes are pinned by a unit test; decode failures surface as the new LeafBytesError. Index terms: every term type now exposes its frozen encoding. EqualityTerm is the 32 PRF bytes as-is and OreTerm/OpeTerm are the raw CLLW ciphertext bytes — byte-identical to what the EQL layer hex-encodes into hm/oc/op, so rows written through a binding compare against rows the Rust/EQL path wrote. MatchTerm encodes its sorted positions as little-endian u16s (EQL sends bf as a JSON integer array, so the byte-string form is this crate's own). cllw-ore's variable-width ciphertext types gain length-validating TryFrom<&[u8]> for the decode path (the direction the existing From<Vec<u8>> FIXME asks for). tests/frozen_bytes.rs carries the golden vectors (fixed hex the Go decoder tests against) plus structural-rejection and real-leaf round-trip coverage; tests/term_bytes.rs continues to pin the derivations these encodings wrap. Part of CIP-3553 (stack-encrypt Go bindings), phase 2 of docs/plans/stack-encrypt-go-bindings.md. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YDes81qQM2ConLobY5G5JD --- docs/plans/stack-encrypt-go-bindings.md | 21 +- packages/stack-encrypt/src/cipher.rs | 189 +++++++++++++- packages/stack-encrypt/src/lib.rs | 19 +- packages/stack-encrypt/src/sem/mod.rs | 114 ++++++++ packages/stack-encrypt/tests/frozen_bytes.rs | 261 +++++++++++++++++++ 5 files changed, 582 insertions(+), 22 deletions(-) create mode 100644 packages/stack-encrypt/tests/frozen_bytes.rs diff --git a/docs/plans/stack-encrypt-go-bindings.md b/docs/plans/stack-encrypt-go-bindings.md index c24c501c1..597b26a6b 100644 --- a/docs/plans/stack-encrypt-go-bindings.md +++ b/docs/plans/stack-encrypt-go-bindings.md @@ -7,7 +7,7 @@ > rustdoc and the tests are the source of truth. Where the two disagree, the > code wins and this document is simply out of date. -**Status:** in progress — Phase 0 and Phase 1 are open as stacked draft PRs on #2156 +**Status:** in progress — Phases 0, 1 and 2 are open as stacked draft PRs on #2156 **Date:** 2026-08-27 **Builds on:** #2099 (WASI/wazero beachhead), #2156 (`#[derive(EncryptFrom, DecryptInto)]`), vitaminc `bindings/go` (`vcvalue` + `vcencrypt`) @@ -233,8 +233,23 @@ The plan as written before the work: ### Phase 2 — frozen byte formats stack-encrypt owns -These are storage commitments, so they get decided and documented before the -guest is written, independently of Go: +**Landed (stacked PR on Phase 1).** What shipped, against the plan below: +`SealedValue::to_bytes`/`from_bytes` with the layout +`version(1) ‖ iv(16) ‖ tag_len(u16 LE) ‖ tag ‖ local_ciphertext`, the +version byte bound into the leaf AAD via a new labelled derivation +(`PAE("stack-encrypt/leaf", version, derived_aad, tag)` — replacing the +unlabelled `(aad, tag)` tuple, with the derivation bytes pinned by a unit +test); term encodings frozen as raw-bytes (equality: the 32 PRF bytes; +ORE/OPE: the raw CLLW ciphertext, byte-identical to what EQL hex-encodes +into `hm`/`oc`/`op`; match: LE `u16` positions — EQL sends `bf` as a JSON +integer array, so the byte-string form is stack-encrypt's own) with +`as_bytes`/`to_bytes`/`from_bytes` on every term type; length-validating +`TryFrom<&[u8]>` added to cllw-ore's variable-width ciphertext types; and +golden vectors in `tests/frozen_bytes.rs` for the Go decoder to test +against. + +The plan as written before the work — these are storage commitments, so they +get decided and documented before the guest is written, independently of Go: - **`SealedValue` leaf**: `to_bytes()` / `from_bytes()` with a canonical layout, e.g. `version(1) ‖ iv ‖ u16 tag_len ‖ tag ‖ local_ciphertext` diff --git a/packages/stack-encrypt/src/cipher.rs b/packages/stack-encrypt/src/cipher.rs index 9515d19b2..4f9d3237e 100644 --- a/packages/stack-encrypt/src/cipher.rs +++ b/packages/stack-encrypt/src/cipher.rs @@ -50,10 +50,14 @@ //! to retrieve the data key — plus a vitaminc [`LocalCipherText`] sealed under //! that key by [`vitaminc_encrypt::Aes256Cipher`] (AES-256-GCM via vitaminc's //! backend: `aws-lc-rs` on native, RustCrypto on wasm32; vitaminc's own random -//! nonce and versioned leaf layout). The leaf AAD is the tuple -//! `(derived_aad, tag)`, which vitaminc PAE-encodes so distinct pairs never -//! collide. The `tag` is always bound, so the ciphertext is cryptographically -//! tied to its ZeroKMS data key (key binding); a caller AAD (e.g. a +//! nonce and versioned leaf layout). The leaf AAD is the labelled derivation +//! [`leaf_aad`]: `PAE("stack-encrypt/leaf", version, derived_aad, tag)`, with +//! [`SealedValue::FORMAT_VERSION`] — the version byte that prefixes the +//! leaf's frozen byte encoding ([`SealedValue::to_bytes`]) — bound under the +//! tag, so a stored leaf relabelled with a different version byte fails +//! verification instead of selecting different parsing rules. The `tag` is +//! always bound, so the ciphertext is cryptographically tied to its ZeroKMS +//! data key (key binding); a caller AAD (e.g. a //! [`ContextTag`](vitaminc_aead::ContextTag)) adds a further binding layer. //! Every data key is requested with an empty descriptor: stack-encrypt does //! not use descriptors. @@ -497,9 +501,34 @@ impl<K: DataKeySource> StackCipher<K> { /// /// This is the only byte-format commitment the crate makes — the container /// tree ([`StackCipherText`]) has no canonical encoding, so callers that -/// persist or transmit ciphertext serialise leaves (it derives `serde` -/// `Serialize`/`Deserialize`, or use [`into_parts`](Self::into_parts) / -/// [`from_parts`](Self::from_parts)) and rebuild the tree around them. +/// persist or transmit ciphertext serialise leaves and rebuild the tree +/// around them. +/// +/// # Frozen byte encoding +/// +/// [`to_bytes`](Self::to_bytes) / [`from_bytes`](Self::from_bytes) are the +/// canonical encoding — the one storage format every consumer (this crate, +/// the language bindings, anything reading a database column) agrees on: +/// +/// ```text +/// version(1) ‖ iv(16) ‖ tag_len(u16 LE) ‖ tag ‖ local_ciphertext +/// ``` +/// +/// where `version` is [`FORMAT_VERSION`](Self::FORMAT_VERSION) and +/// `local_ciphertext` is the vitaminc leaf (itself framed as +/// `version ‖ nonce ‖ ciphertext ‖ gcm_tag`) and runs to the end of the +/// buffer. The version byte is bound into the leaf's AAD at seal time (the +/// private `leaf_aad` derivation — see the module docs in `src/cipher.rs`), +/// so a leaf relabelled with a future version byte fails +/// authentication rather than parsing under the wrong rules. Parsing is +/// structural only — nothing about a decoded leaf is trusted until it +/// decrypts. +/// +/// The `serde` `Serialize`/`Deserialize` derives and +/// [`into_parts`](Self::into_parts) / [`from_parts`](Self::from_parts) +/// remain for callers that manage their own storage format; they carry the +/// same fields, but their wire form is the serialiser's, not a commitment +/// of this crate. #[derive(Debug, Serialize, Deserialize)] pub struct SealedValue { /// ZeroKMS IV: identifies the data key for retrieval. @@ -512,7 +541,84 @@ pub struct SealedValue { ciphertext: LocalCipherText, } +/// A [`SealedValue`] byte encoding failed to encode or decode. Purely +/// structural — a leaf that *decodes* has proven nothing about integrity +/// (that is the AEAD open's job); a leaf that fails here was never a valid +/// v1 encoding at all. +#[derive(Debug, PartialEq, Eq, thiserror::Error)] +pub enum LeafBytesError { + /// The leading version byte is not one this build knows how to parse. + /// (A version this build *does* know, stamped on bytes sealed under a + /// different version, passes here and fails authentication instead — + /// the version byte is bound into the leaf AAD.) + #[error("unknown sealed-leaf format version {0}")] + UnknownVersion(u8), + /// The buffer ends before the fixed-width fields, or before the key tag + /// the `tag_len` field promises. + #[error("sealed-leaf bytes are truncated")] + Truncated, + /// The key tag does not fit the format's `u16` length field. Never + /// produced by sealing (ZeroKMS tags are tens of bytes); only reachable + /// through [`SealedValue::from_parts`] with an oversized tag. + #[error("key tag of {0} bytes exceeds the format's u16 length field")] + TagTooLong(usize), +} + impl SealedValue { + /// The version byte prefixing the frozen byte encoding + /// ([`to_bytes`](Self::to_bytes)). Also bound into every leaf's AAD (the + /// private `leaf_aad` derivation): bumping it re-keys authentication, so + /// old leaves can never be relabelled as the new version (nor new as + /// old). + pub const FORMAT_VERSION: u8 = 1; + + /// Encode into the frozen v1 byte layout — see the type-level docs for + /// the format. The inverse of [`from_bytes`](Self::from_bytes). + /// + /// Fails only with [`LeafBytesError::TagTooLong`], which no leaf this + /// crate sealed can trigger. + pub fn to_bytes(&self) -> Result<Vec<u8>, LeafBytesError> { + let tag_len = u16::try_from(self.tag.len()) + .map_err(|_| LeafBytesError::TagTooLong(self.tag.len()))?; + let ciphertext = self.ciphertext.as_ref(); + let mut out = Vec::with_capacity(1 + self.iv.len() + 2 + self.tag.len() + ciphertext.len()); + out.push(Self::FORMAT_VERSION); + out.extend_from_slice(&self.iv); + out.extend_from_slice(&tag_len.to_le_bytes()); + out.extend_from_slice(&self.tag); + out.extend_from_slice(ciphertext); + Ok(out) + } + + /// Decode the frozen v1 byte layout — the inverse of + /// [`to_bytes`](Self::to_bytes). Structural only: a decoded leaf is + /// untrusted bytes until it decrypts. + pub fn from_bytes(bytes: &[u8]) -> Result<Self, LeafBytesError> { + const IV_LEN: usize = 16; + + let (&version, rest) = bytes.split_first().ok_or(LeafBytesError::Truncated)?; + if version != Self::FORMAT_VERSION { + return Err(LeafBytesError::UnknownVersion(version)); + } + if rest.len() < IV_LEN + 2 { + return Err(LeafBytesError::Truncated); + } + let (iv_bytes, rest) = rest.split_at(IV_LEN); + let mut iv: stack_kms::Iv = [0; IV_LEN]; + iv.copy_from_slice(iv_bytes); + let (tag_len_bytes, rest) = rest.split_at(2); + let tag_len = usize::from(u16::from_le_bytes([tag_len_bytes[0], tag_len_bytes[1]])); + if rest.len() < tag_len { + return Err(LeafBytesError::Truncated); + } + let (tag, ciphertext) = rest.split_at(tag_len); + Ok(Self { + iv, + tag: tag.to_vec(), + ciphertext: LocalCipherText::from(ciphertext.to_vec()), + }) + } + /// Rebuild a leaf from its persisted parts — the inverse of /// [`into_parts`](Self::into_parts). pub fn from_parts(iv: stack_kms::Iv, tag: Vec<u8>, ciphertext: Vec<u8>) -> Self { @@ -554,6 +660,16 @@ impl Clone for SealedValue { } } +/// [`SealedValue::from_bytes`] as a std conversion, for generic codecs +/// bounded on `TryFrom`. +impl TryFrom<&[u8]> for SealedValue { + type Error = LeafBytesError; + + fn try_from(bytes: &[u8]) -> Result<Self, Self::Error> { + Self::from_bytes(bytes) + } +} + /// A leaf with its retrieved data key bound alongside. Produced by /// [`bind_keys`] once the batched `retrieve_keys` call has returned; consumed by /// [`StackDecipher`], which opens it under whatever AAD the driving @@ -728,11 +844,37 @@ fn leaf_cipher(key: &DataKey) -> Result<Aes256Cipher, Unspecified> { Aes256Cipher::new(&AesKey::from(*key.key())) } +/// Derives the effective AAD every leaf is sealed against — and opened +/// under — binding the caller's derived AAD, the ZeroKMS key `tag`, and the +/// [`SealedValue::FORMAT_VERSION`] byte that prefixes the leaf's frozen byte +/// encoding. +/// +/// The labelled four-piece PAE can never collide with a caller's own +/// composite AAD (a tuple encodes with no leading domain label) or with +/// vitaminc's internal derivations (different labels). Binding the format +/// version under the tag is what makes the byte in +/// [`SealedValue::to_bytes`] more than a parse hint: bytes relabelled with a +/// different version fail verification instead of selecting different +/// parsing and derivation rules — mirroring vitaminc's `Aad::for_leaf`, +/// which binds the *inner* [`LocalCipherText`] wire version the same way. +/// +/// The domain label deliberately carries no `/v1` suffix: the version is a +/// *parameter* here, not part of the label. +fn leaf_aad(aad: &Aad<'_>, tag: &[u8]) -> Aad<'static> { + const LEAF_AAD_DOMAIN: &[u8] = b"stack-encrypt/leaf"; + Aad::pae(&[ + LEAF_AAD_DOMAIN, + &[SealedValue::FORMAT_VERSION], + aad.as_bytes(), + tag, + ]) +} + /// Seal one plaintext leaf under a freshly generated data key. /// -/// The AAD is the tuple `(derived_aad, tag)` — vitaminc PAE-encodes tuples, -/// so distinct pairs never collide, and `tag` is always bound: the leaf is -/// cryptographically tied to its ZeroKMS data key. +/// The AAD is the [`leaf_aad`] derivation of the caller's (derived) AAD and +/// the key `tag` — `tag` is always bound, so the leaf is cryptographically +/// tied to its ZeroKMS data key. fn seal_leaf( plaintext: Protected<Vec<u8>>, aad: &Aad<'_>, @@ -740,7 +882,7 @@ fn seal_leaf( ) -> Result<SealedValue, Unspecified> { let iv = key.key.iv; let cipher = leaf_cipher(&key.key)?; - match (&cipher).encrypt_bytes_vec(plaintext, (aad.as_bytes(), key.tag.as_slice()))? { + match (&cipher).encrypt_bytes_vec(plaintext, leaf_aad(aad, &key.tag))? { AesCipherText::Single(ciphertext) => Ok(SealedValue { iv, tag: key.tag, @@ -766,7 +908,7 @@ fn open_leaf(keyed: KeyedLeaf, aad: &Aad<'_>) -> Result<Protected<Vec<u8>>, Unsp let cipher = leaf_cipher(&key)?; cipher .decipher(AesCipherText::Single(leaf.ciphertext)) - .decrypt_bytes(ProtectedBytes, (aad.as_bytes(), leaf.tag.as_slice())) + .decrypt_bytes(ProtectedBytes, leaf_aad(aad, &leaf.tag)) } /// Open one marker leaf (absent / empty-sequence / empty-map) and require the @@ -1256,3 +1398,26 @@ impl<'c, 'a> MapAccess<'c> for StackMapAccess<'a> { } } } + +#[cfg(test)] +mod tests { + use super::*; + + /// Byte-level pin for the [`leaf_aad`] derivation. This is part of the + /// frozen leaf format: a change to the domain label, the version byte, + /// the piece order, or the PAE framing makes every stored leaf fail + /// authentication, so it must be deliberate — and must come with a + /// [`SealedValue::FORMAT_VERSION`] bump, which this pin forces into view. + #[test] + fn leaf_aad_bytes_are_pinned() { + let aad = leaf_aad(&Aad::from_slice(b"caller-aad"), b"key-tag"); + let hex: String = aad.as_bytes().iter().map(|b| format!("{b:02x}")).collect(); + // PAE: LE64 count (4) ‖ per piece LE64 length ‖ piece, the pieces + // being "stack-encrypt/leaf", [FORMAT_VERSION], the caller AAD, and + // the key tag. + assert_eq!( + hex, + "04000000000000001200000000000000737461636b2d656e63727970742f6c6561660100000000000000010a0000000000000063616c6c65722d61616407000000000000006b65792d746167" + ); + } +} diff --git a/packages/stack-encrypt/src/lib.rs b/packages/stack-encrypt/src/lib.rs index eed7512cf..375d64e15 100644 --- a/packages/stack-encrypt/src/lib.rs +++ b/packages/stack-encrypt/src/lib.rs @@ -100,11 +100,16 @@ assert_eq!(plaintext, "secret message"); //! [`encrypt`](StackCipher::encrypt) returns a [`StackCipherText`]: a tree whose //! shape mirrors the value (a scalar is a single leaf, a `Vec` a sequence of //! leaves, a map a set of named leaves) and whose leaves are [`SealedValue`]s. -//! A `SealedValue` is the persistable unit — it implements `serde` -//! `Serialize`/`Deserialize` and offers [`into_parts`](SealedValue::into_parts) -//! / [`from_parts`](SealedValue::from_parts) for callers that manage their own -//! storage format. Map keys are stored in the clear (and authenticated); -//! nothing else about a value is visible without its data keys. +//! A `SealedValue` is the persistable unit: its canonical, frozen byte +//! encoding is [`to_bytes`](SealedValue::to_bytes) / +//! [`from_bytes`](SealedValue::from_bytes) — the format a database column +//! holds and every language binding reads. For callers that manage their own +//! storage format it also implements `serde` `Serialize`/`Deserialize` and +//! offers [`into_parts`](SealedValue::into_parts) / +//! [`from_parts`](SealedValue::from_parts). Map keys are stored in the clear +//! (and authenticated); nothing else about a value is visible without its +//! data keys. Index terms have their own frozen encodings — see +//! [`sem`](crate::sem#byte-encodings). //! //! # What is authenticated //! @@ -137,8 +142,8 @@ pub mod sem; pub mod target; pub use cipher::{ - BoxedPassthrough, Error, FromEnv, PendingStackCipherText, SealedValue, StackCipher, - StackCipherBuilder, StackCipherText, StackDecipher, + BoxedPassthrough, Error, FromEnv, LeafBytesError, PendingStackCipherText, SealedValue, + StackCipher, StackCipherBuilder, StackCipherText, StackDecipher, }; pub use target::{ DecryptContext, DecryptField, DecryptFrom, DecryptInto, DecryptTarget, Decryptable, diff --git a/packages/stack-encrypt/src/sem/mod.rs b/packages/stack-encrypt/src/sem/mod.rs index 93d3c3ec1..ef815797d 100644 --- a/packages/stack-encrypt/src/sem/mod.rs +++ b/packages/stack-encrypt/src/sem/mod.rs @@ -65,6 +65,31 @@ //! This is a fresh (v2) term format: PRF inputs are framed with vitaminc's PAE //! context encoding, so terms are intentionally **not** byte-compatible with //! `cipherstash-client`'s existing `IndexTerm` values. +//! +//! # Byte encodings +//! +//! Terms are stored server-side and compared across languages, so each term +//! kind commits to one frozen byte encoding — the bytes a language binding +//! writes and a database column holds: +//! +//! * [`EqualityTerm`] — the 32 PRF bytes as-is +//! ([`as_bytes`](EqualityTerm::as_bytes) / +//! [`from_bytes`](EqualityTerm::from_bytes)). +//! * [`MatchTerm`] — the sorted, de-duplicated bit positions, each a +//! little-endian `u16` ([`to_bytes`](MatchTerm::to_bytes) / +//! [`from_bytes`](MatchTerm::from_bytes)). +//! * [`OreTerm`] / [`OpeTerm`] — the raw CLLW ciphertext bytes, unframed +//! ([`as_bytes`](OreTerm::as_bytes) / [`from_bytes`](OreTerm::from_bytes)). +//! +//! The equality and ORE/OPE encodings are byte-identical to what the EQL +//! layer hex-encodes into its `hm` / `oc` / `op` payload fields (EQL's +//! hex and JSON framing sit *above* these bytes), so terms written through a +//! language binding compare against rows the Rust/EQL path wrote. There is +//! deliberately no version byte or framing here: a term is an opaque +//! comparand, its derivation is already versioned by the PAE domain labels +//! above, and Postgres compares these columns byte-wise. The pins in +//! `tests/term_bytes.rs` and `tests/frozen_bytes.rs` hold both the +//! derivations and the encodings in place. mod tokenize; @@ -133,6 +158,12 @@ pub enum TermError { /// [`EncryptContext`]. #[error("the encryption context must not be empty (it domain-separates fields)")] EmptyContext, + /// Stored term bytes do not decode under the term kind's frozen byte + /// encoding (see the [module docs](self#byte-encodings)) — wrong length, + /// or a length the CLLW ciphertext shape cannot have. Structural only: + /// bytes that *decode* are not thereby proven to be a genuine term. + #[error("malformed term bytes: {0}")] + MalformedTermBytes(&'static str), } impl TermError { @@ -189,6 +220,14 @@ impl From<EqualityTerm> for Vec<u8> { } } +/// The frozen byte encoding — the 32 PRF bytes as-is (see the +/// [module docs](self#byte-encodings)). +impl AsRef<[u8]> for EqualityTerm { + fn as_ref(&self) -> &[u8] { + &self.0 + } +} + /// A [`PrfVisitor`] that wraps one PRF block as an [`EqualityTerm`]. struct EqualityVisitor; @@ -338,6 +377,37 @@ impl<O> MatchTerm<O> { } } + /// The frozen byte encoding: each position as a little-endian `u16`, in + /// the canonical order [`positions`](Self::positions) holds them (sorted + /// ascending, no duplicates). See the + /// [module docs](self#byte-encodings). The inverse of + /// [`from_bytes`](Self::from_bytes). + pub fn to_bytes(&self) -> Vec<u8> { + self.positions + .iter() + .flat_map(|p| p.to_le_bytes()) + .collect() + } + + /// Decode the frozen byte encoding — little-endian `u16` positions — the + /// inverse of [`to_bytes`](Self::to_bytes). Like + /// [`from_positions`](Self::from_positions), any ordering is accepted + /// and normalised, and the caller asserts (via `O`) the generating + /// [`MatchConfig`]. Rejects an odd-length buffer. + pub fn from_bytes(bytes: &[u8]) -> Result<Self, TermError> { + if !bytes.len().is_multiple_of(2) { + return Err(TermError::MalformedTermBytes( + "match-term bytes must be a sequence of u16 positions (even length)", + )); + } + Ok(Self::from_positions( + bytes + .chunks_exact(2) + .map(|pair| u16::from_le_bytes([pair[0], pair[1]])) + .collect(), + )) + } + /// The set Bloom-filter bit positions, sorted ascending, no duplicates. pub fn positions(&self) -> &[u16] { &self.positions @@ -549,6 +619,50 @@ macro_rules! term_wrapper { pub fn into_inner(self) -> T::Output { self.0 } + + /// Decode a term from its frozen byte encoding — the raw CLLW + /// ciphertext bytes, the inverse of [`as_bytes`](Self::as_bytes) + /// — for terms persisted server-side. Structural only (length + /// checks); the caller asserts the bytes were generated for this + /// source type `T` and under the same context. + pub fn from_bytes(bytes: &[u8]) -> Result<Self, TermError> + where + for<'a> T::Output: TryFrom<&'a [u8]>, + { + T::Output::try_from(bytes).map(Self).map_err(|_| { + TermError::MalformedTermBytes( + "byte length does not fit this CLLW ciphertext shape", + ) + }) + } + } + + impl<T: $bound> $name<T> + where + T::Output: AsRef<[u8]>, + { + /// The frozen byte encoding: the raw CLLW ciphertext bytes, + /// unframed — byte-identical to what the EQL layer hex-encodes + /// (see the [module docs](self#byte-encodings)). + pub fn as_bytes(&self) -> &[u8] { + self.0.as_ref() + } + + /// Owned copy of [`as_bytes`](Self::as_bytes). + pub fn to_bytes(&self) -> Vec<u8> { + self.0.as_ref().to_vec() + } + } + + /// The frozen byte encoding — the same bytes as the inherent + /// `as_bytes`. + impl<T: $bound> AsRef<[u8]> for $name<T> + where + T::Output: AsRef<[u8]>, + { + fn as_ref(&self) -> &[u8] { + self.0.as_ref() + } } impl<T: $bound> fmt::Debug for $name<T> diff --git a/packages/stack-encrypt/tests/frozen_bytes.rs b/packages/stack-encrypt/tests/frozen_bytes.rs new file mode 100644 index 000000000..b45cdd417 --- /dev/null +++ b/packages/stack-encrypt/tests/frozen_bytes.rs @@ -0,0 +1,261 @@ +//! Byte-level pins for the frozen storage encodings — the byte formats +//! stack-encrypt commits to across languages and database columns: +//! +//! * the [`SealedValue`] leaf layout +//! (`version ‖ iv ‖ tag_len ‖ tag ‖ local_ciphertext`), and +//! * the index-term encodings (equality: raw 32 bytes; match: LE `u16` +//! positions; ORE/OPE: raw CLLW ciphertext bytes). +//! +//! These are the vectors a language binding's decoder tests against — the +//! Go side decodes exactly these hex strings. `tests/term_bytes.rs` pins the +//! *derivations* (PRF domains and framing); this file pins the *encodings* +//! of the results. Breaking a pin here means the storage format moved: for +//! the leaf that demands a `SealedValue::FORMAT_VERSION` bump, for terms it +//! means stored rows silently stop comparing. + +use stack_encrypt::sem::{DefaultMatch, EqualityTerm, MatchTerm, OpeTerm, OreTerm}; +use stack_encrypt::target::EncryptInto; +use stack_encrypt::{CipherText, LeafBytesError, SealedValue, StackCipher}; +use stack_kms::FakeDataKeySource; + +async fn cipher() -> StackCipher<FakeDataKeySource> { + StackCipher::builder() + .kms(FakeDataKeySource::new()) + .init() + .await + .expect("build cipher") +} + +fn hex(bytes: &[u8]) -> String { + bytes.iter().map(|b| format!("{b:02x}")).collect() +} + +// ============================================================================= +// SealedValue leaf +// ============================================================================= + +/// A leaf from fixed parts, so the encoding is deterministic. The +/// "ciphertext" is not a real AEAD output — encoding is structural and must +/// not care. +fn fixture_leaf() -> SealedValue { + let iv: stack_kms::Iv = *b"0123456789abcdef"; + SealedValue::from_parts(iv, vec![0xAA, 0xBB, 0xCC], vec![0xDE, 0xAD, 0xBE, 0xEF]) +} + +#[test] +fn sealed_value_layout_is_pinned() { + let bytes = fixture_leaf().to_bytes().expect("encode leaf"); + + // version(01) ‖ iv(16 bytes: ASCII "0123456789abcdef") ‖ + // tag_len(0300 — 3, u16 LE) ‖ tag(aabbcc) ‖ local_ciphertext(deadbeef) + assert_eq!( + hex(&bytes), + "01303132333435363738396162636465660300aabbccdeadbeef" + ); +} + +#[test] +fn sealed_value_from_bytes_inverts_to_bytes() { + let original = fixture_leaf(); + let bytes = original.to_bytes().expect("encode leaf"); + let decoded = SealedValue::from_bytes(&bytes).expect("decode leaf"); + + assert_eq!(decoded.iv(), original.iv()); + assert_eq!(decoded.tag(), original.tag()); + assert_eq!(decoded.ciphertext(), original.ciphertext()); + + // The std conversion is the same decoder. + let converted = SealedValue::try_from(bytes.as_slice()).expect("TryFrom decode"); + assert_eq!(converted.ciphertext(), original.ciphertext()); +} + +#[test] +fn sealed_value_rejects_unknown_version() { + let mut bytes = fixture_leaf().to_bytes().expect("encode leaf"); + bytes[0] = 2; + assert!(matches!( + SealedValue::from_bytes(&bytes), + Err(LeafBytesError::UnknownVersion(2)) + )); +} + +#[test] +fn sealed_value_rejects_truncation() { + let bytes = fixture_leaf().to_bytes().expect("encode leaf"); + + // Every prefix shorter than the tag's end is truncated: empty, mid-iv, + // mid-length-field, and mid-tag. (Anything at or past the tag's end + // parses — the local ciphertext takes the remainder, and proving *it* + // whole is the AEAD open's job.) + let tag_end = 1 + 16 + 2 + 3; + for len in 0..tag_end { + assert!( + matches!( + SealedValue::from_bytes(&bytes[..len]), + Err(LeafBytesError::Truncated) + ), + "prefix of {len} bytes must be rejected" + ); + } + assert!(SealedValue::from_bytes(&bytes[..tag_end]).is_ok()); +} + +#[test] +fn sealed_value_rejects_oversized_tag_on_encode() { + let leaf = SealedValue::from_parts( + [0; 16], + vec![0; usize::from(u16::MAX) + 1], + vec![0xDE, 0xAD], + ); + assert!(matches!( + leaf.to_bytes(), + Err(LeafBytesError::TagTooLong(len)) if len == usize::from(u16::MAX) + 1 + )); +} + +#[tokio::test] +async fn sealed_leaf_survives_persistence_via_bytes() { + // The format round-trips a *real* leaf: encrypt, encode, decode, decrypt. + let cipher = cipher().await; + let ct = cipher + .encrypt("durable".to_string(), b"ctx".as_slice()) + .await + .expect("encrypt"); + let leaf = match ct { + CipherText::Single(leaf) => leaf, + other => panic!("expected a Single leaf, got {other:?}"), + }; + let bytes = leaf.to_bytes().expect("encode leaf"); + let restored = SealedValue::from_bytes(&bytes).expect("decode leaf"); + + let pt: String = cipher + .decrypt(CipherText::Single(restored), b"ctx".as_slice()) + .await + .expect("decoded leaf must decrypt"); + assert_eq!(pt, "durable"); +} + +// ============================================================================= +// Terms +// ============================================================================= + +#[tokio::test] +async fn equality_term_encoding_is_the_raw_prf_bytes() { + let term = cipher() + .await + .equality_term("alice", "users/email") + .await + .expect("equality term"); + + // The derivation is pinned in term_bytes.rs; here: encoding = identity + // over those 32 bytes, and from_bytes is its inverse. + assert_eq!(term.as_ref(), term.as_bytes()); + assert_eq!(EqualityTerm::from_bytes(*term.as_bytes()), term); +} + +#[tokio::test] +async fn match_term_bytes_are_pinned() { + let term = cipher() + .await + .match_terms::<DefaultMatch>("alice smith", "users/name") + .await + .expect("match term"); + + // The little-endian u16 encoding of the positions pinned in + // term_bytes.rs, in sorted order. + let bytes = term.to_bytes(); + assert_eq!( + hex(&bytes), + "2100220028002c002d0034003c003f005400620063006b0071007d007f0097009800a400a600a800a900d200d400fd00" + ); + assert_eq!( + MatchTerm::<DefaultMatch>::from_bytes(&bytes).expect("decode match term"), + term + ); +} + +#[test] +fn match_term_from_bytes_rejects_odd_length() { + assert!(MatchTerm::<DefaultMatch>::from_bytes(&[0x21]).is_err()); +} + +#[tokio::test] +async fn ore_term_encoding_is_the_raw_cllw_bytes() { + let cipher = cipher().await; + let term: OreTerm<u32> = 42u32 + .encrypt_into_with_context(&cipher, "users/age") + .await + .expect("ore term"); + + // Byte-identical to the raw CLLW output pinned in term_bytes.rs — the + // wrapper adds no framing, so these bytes compare against columns the + // EQL path wrote. + assert_eq!( + hex(term.as_bytes()), + "d757854cffc68e9f3dfa9dba7ec400a30c80dd57122ebbc064eeff5a81069fc7" + ); + assert_eq!(term.to_bytes(), term.as_bytes()); + assert_eq!(term.as_ref(), term.as_bytes()); + assert_eq!( + OreTerm::<u32>::from_bytes(term.as_bytes()).expect("decode ore term"), + term + ); +} + +#[tokio::test] +async fn ope_term_encoding_is_the_raw_cllw_bytes() { + let cipher = cipher().await; + let term: OpeTerm<u32> = 42u32 + .encrypt_into_with_context(&cipher, "users/age") + .await + .expect("ope term"); + + assert_eq!( + hex(term.as_bytes()), + "00470b57be663ba84635c72c1bdfa8ed263e7e57504002db51d3e695ba0b499833" + ); + assert_eq!( + OpeTerm::<u32>::from_bytes(term.as_bytes()).expect("decode ope term"), + term + ); +} + +#[test] +fn ore_term_from_bytes_rejects_wrong_length() { + // u32 → OreCllw8V1<32>: exactly 32 bytes. + assert!(OreTerm::<u32>::from_bytes(&[0u8; 31]).is_err()); + assert!(OreTerm::<u32>::from_bytes(&[0u8; 33]).is_err()); + // u32 → OpeCllw8V1<33>: exactly 33 bytes. + assert!(OpeTerm::<u32>::from_bytes(&[0u8; 32]).is_err()); +} + +#[tokio::test] +async fn variable_length_ore_and_ope_terms_decode() { + // String sources produce variable-length CLLW ciphertexts (8 bytes per + // plaintext byte; OPE adds a leading carry byte) — their decode path is + // the length-validating TryFrom in cllw-ore. + let cipher = cipher().await; + let ore: OreTerm<String> = "alice" + .to_string() + .encrypt_into_with_context(&cipher, "users/name") + .await + .expect("ore term"); + assert_eq!(ore.as_bytes().len(), 5 * 8); + assert_eq!( + OreTerm::<String>::from_bytes(ore.as_bytes()).expect("decode"), + ore + ); + assert!(OreTerm::<String>::from_bytes(&ore.as_bytes()[1..]).is_err()); + + let ope: OpeTerm<String> = "alice" + .to_string() + .encrypt_into_with_context(&cipher, "users/name") + .await + .expect("ope term"); + assert_eq!(ope.as_bytes().len(), 5 * 8 + 1); + assert_eq!( + OpeTerm::<String>::from_bytes(ope.as_bytes()).expect("decode"), + ope + ); + assert!(OpeTerm::<String>::from_bytes(&ope.as_bytes()[1..]).is_err()); +} From 33419fab553479d57eb49511d98b2e686206abf8 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Fri, 28 Aug 2026 12:58:36 +0000 Subject: [PATCH 483/686] docs(stack-encrypt): table and nesting diagram for the sealed-leaf byte layout Expand SealedValue's frozen-encoding rustdoc with an offset table for the v1 envelope and a diagram of the two nested framings (this crate's envelope around vitaminc's LocalCipherText), spelling out where each version byte lives and which AAD derivation binds it. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YDes81qQM2ConLobY5G5JD --- packages/stack-encrypt/src/cipher.rs | 34 +++++++++++++++++++++------- 1 file changed, 26 insertions(+), 8 deletions(-) diff --git a/packages/stack-encrypt/src/cipher.rs b/packages/stack-encrypt/src/cipher.rs index 4f9d3237e..2fabb3e8d 100644 --- a/packages/stack-encrypt/src/cipher.rs +++ b/packages/stack-encrypt/src/cipher.rs @@ -508,18 +508,36 @@ impl<K: DataKeySource> StackCipher<K> { /// /// [`to_bytes`](Self::to_bytes) / [`from_bytes`](Self::from_bytes) are the /// canonical encoding — the one storage format every consumer (this crate, -/// the language bindings, anything reading a database column) agrees on: +/// the language bindings, anything reading a database column) agrees on. +/// The v1 layout: +/// +/// | offset | field | size | value | +/// |-----------------|--------------------|-----------------|-------| +/// | 0 | envelope version | 1 | [`FORMAT_VERSION`](Self::FORMAT_VERSION) (`0x01`) | +/// | 1 | ZeroKMS `iv` | 16 | identifies the data key for retrieval | +/// | 17 | `tag_len` | 2 | length of `tag`, `u16` little-endian | +/// | 19 | ZeroKMS key `tag` | `tag_len` | required to retrieve the key | +/// | 19 + `tag_len` | local ciphertext | rest of buffer | the vitaminc [`LocalCipherText`] | +/// +/// The local ciphertext is itself a framed value — vitaminc's leaf wire +/// format, versioned and owned by vitaminc — so the full stored byte string +/// nests two framings, each led by its own version byte: /// /// ```text -/// version(1) ‖ iv(16) ‖ tag_len(u16 LE) ‖ tag ‖ local_ciphertext +/// ┌─ envelope (stack-encrypt, this table) ─────────────────────────────────────┐ +/// │ version ‖ iv ‖ tag_len ‖ tag ‖ ┌─ local ciphertext (vitaminc) ───────────┐ │ +/// │ 0x01 │ version ‖ nonce ‖ ciphertext ‖ gcm_tag │ │ +/// │ └─────────────────────────────────────────┘ │ +/// └────────────────────────────────────────────────────────────────────────────┘ /// ``` /// -/// where `version` is [`FORMAT_VERSION`](Self::FORMAT_VERSION) and -/// `local_ciphertext` is the vitaminc leaf (itself framed as -/// `version ‖ nonce ‖ ciphertext ‖ gcm_tag`) and runs to the end of the -/// buffer. The version byte is bound into the leaf's AAD at seal time (the -/// private `leaf_aad` derivation — see the module docs in `src/cipher.rs`), -/// so a leaf relabelled with a future version byte fails +/// Both version bytes are authenticated under the one GCM tag, each bound by +/// the layer that owns its framing: the envelope version through this +/// crate's leaf-AAD derivation (the private `leaf_aad` — +/// `PAE("stack-encrypt/leaf", version, derived_aad, tag)`; see the module +/// docs in `src/cipher.rs`), and the inner version through vitaminc's +/// `Aad::for_leaf`, applied inside [`Aes256Cipher`] to the AAD this crate +/// hands it. Relabel either version byte in storage and the leaf fails /// authentication rather than parsing under the wrong rules. Parsing is /// structural only — nothing about a decoded leaf is trusted until it /// decrypts. From 8950a745a72c6ec4e8bad9d4d2b876b5bca251ad Mon Sep 17 00:00:00 2001 From: Dan Draper <dan@cipherstash.com> Date: Sat, 29 Aug 2026 13:14:44 +1000 Subject: [PATCH 484/686] fix(stack-encrypt): address phase-2 review findings on the frozen byte formats - cllw-ore variable-width types: one private length predicate per type, applied by both `from_bytes(Vec<u8>) -> Result` and `TryFrom<&[u8]>`. The unchecked path is now `from_bytes_unchecked` (replacing the FIXME'd `From<Vec<u8>>`); it stays `pub` and `FromHex` stays permissive because cipherstash-client's ste_vec terms carry a tagged bit stream (65/66 bytes) that the byte-aligned rule would reject. Callers renamed. - `SealedValue` is valid-by-construction: `from_parts` and serde deserialisation reject tags longer than the u16 length field, so `to_bytes()` is infallible. `TryFrom<&[u8]>` rustdoc no longer claims a generic-codec justification. - Document that the labelled, versioned `leaf_aad` cannot open leaves sealed under the phase-1 AAD (plain AEAD failure, no version signal). - Reword the "Byte encodings" docs: same shape as v1 / cipherstash-client terms, values are not comparable across the two. Claude-Session: https://claude.ai/code/session_01AhxMmV52dSYwjAT8KENHRV --- docs/plans/stack-encrypt-go-bindings.md | 5 +- packages/stack-encrypt/src/cipher.rs | 101 ++++++++++++++++--- packages/stack-encrypt/src/sem/mod.rs | 28 +++-- packages/stack-encrypt/tests/frozen_bytes.rs | 24 +++-- packages/stack-encrypt/tests/roundtrip.rs | 4 +- 5 files changed, 126 insertions(+), 36 deletions(-) diff --git a/docs/plans/stack-encrypt-go-bindings.md b/docs/plans/stack-encrypt-go-bindings.md index 597b26a6b..7abb00b05 100644 --- a/docs/plans/stack-encrypt-go-bindings.md +++ b/docs/plans/stack-encrypt-go-bindings.md @@ -239,7 +239,10 @@ The plan as written before the work: version byte bound into the leaf AAD via a new labelled derivation (`PAE("stack-encrypt/leaf", version, derived_aad, tag)` — replacing the unlabelled `(aad, tag)` tuple, with the derivation bytes pinned by a unit -test); term encodings frozen as raw-bytes (equality: the 32 PRF bytes; +test; **breaking**: leaves sealed under the phase-1 AAD carry no version byte, +so they cannot be opened and fail with a plain AEAD error rather than an +`UnknownVersion` — acceptable because the crate is `publish = false` and only +dev-persisted data exists); term encodings frozen as raw-bytes (equality: the 32 PRF bytes; ORE/OPE: the raw CLLW ciphertext, byte-identical to what EQL hex-encodes into `hm`/`oc`/`op`; match: LE `u16` positions — EQL sends `bf` as a JSON integer array, so the byte-string form is stack-encrypt's own) with diff --git a/packages/stack-encrypt/src/cipher.rs b/packages/stack-encrypt/src/cipher.rs index 2fabb3e8d..c697f9c9b 100644 --- a/packages/stack-encrypt/src/cipher.rs +++ b/packages/stack-encrypt/src/cipher.rs @@ -547,7 +547,20 @@ impl<K: DataKeySource> StackCipher<K> { /// remain for callers that manage their own storage format; they carry the /// same fields, but their wire form is the serialiser's, not a commitment /// of this crate. +/// +/// # Breaking change +/// +/// Leaves sealed *before* this format landed used an unlabelled +/// `PAE(aad, tag)` leaf AAD with no version byte. They cannot be opened by +/// this build — however they were persisted (serde, `into_parts`, or raw +/// bytes) they fail AEAD verification with a plain authentication error, +/// indistinguishable from tampering, because there is no version byte in the +/// old form to raise [`LeafBytesError::UnknownVersion`] against. Acceptable +/// only because the crate is `publish = false` and only dev-persisted data +/// exists; from `FORMAT_VERSION` onwards a format move is signalled by the +/// version byte instead. #[derive(Debug, Serialize, Deserialize)] +#[serde(try_from = "SealedValueRepr")] pub struct SealedValue { /// ZeroKMS IV: identifies the data key for retrieval. iv: stack_kms::Iv, @@ -577,7 +590,8 @@ pub enum LeafBytesError { Truncated, /// The key tag does not fit the format's `u16` length field. Never /// produced by sealing (ZeroKMS tags are tens of bytes); only reachable - /// through [`SealedValue::from_parts`] with an oversized tag. + /// through [`SealedValue::from_parts`] or `serde` deserialisation with an + /// oversized tag — both reject it, so a live `SealedValue` always encodes. #[error("key tag of {0} bytes exceeds the format's u16 length field")] TagTooLong(usize), } @@ -593,11 +607,15 @@ impl SealedValue { /// Encode into the frozen v1 byte layout — see the type-level docs for /// the format. The inverse of [`from_bytes`](Self::from_bytes). /// - /// Fails only with [`LeafBytesError::TagTooLong`], which no leaf this - /// crate sealed can trigger. - pub fn to_bytes(&self) -> Result<Vec<u8>, LeafBytesError> { - let tag_len = u16::try_from(self.tag.len()) - .map_err(|_| LeafBytesError::TagTooLong(self.tag.len()))?; + /// Infallible: every way of building a `SealedValue` rejects a tag too + /// long for the `u16` length field ([`LeafBytesError::TagTooLong`]), so a + /// value that exists always encodes. + pub fn to_bytes(&self) -> Vec<u8> { + // Exact by the `tag_fits_length_field` check every constructor + // applies; sealing never comes close (ZeroKMS tags are tens of bytes). + // The saturating fallback is unreachable, and asserted so in tests. + let tag_len = u16::try_from(self.tag.len()).unwrap_or(u16::MAX); + debug_assert_eq!(usize::from(tag_len), self.tag.len()); let ciphertext = self.ciphertext.as_ref(); let mut out = Vec::with_capacity(1 + self.iv.len() + 2 + self.tag.len() + ciphertext.len()); out.push(Self::FORMAT_VERSION); @@ -605,7 +623,7 @@ impl SealedValue { out.extend_from_slice(&tag_len.to_le_bytes()); out.extend_from_slice(&self.tag); out.extend_from_slice(ciphertext); - Ok(out) + out } /// Decode the frozen v1 byte layout — the inverse of @@ -637,14 +655,32 @@ impl SealedValue { }) } + /// The one invariant that makes [`to_bytes`](Self::to_bytes) infallible: + /// the key tag must fit the format's `u16` length field. + fn tag_fits_length_field(tag: &[u8]) -> Result<(), LeafBytesError> { + if tag.len() > usize::from(u16::MAX) { + return Err(LeafBytesError::TagTooLong(tag.len())); + } + Ok(()) + } + /// Rebuild a leaf from its persisted parts — the inverse of /// [`into_parts`](Self::into_parts). - pub fn from_parts(iv: stack_kms::Iv, tag: Vec<u8>, ciphertext: Vec<u8>) -> Self { - Self { + /// + /// Fails with [`LeafBytesError::TagTooLong`] if `tag` does not fit the + /// byte format's `u16` length field. Structural only: nothing about the + /// parts is trusted until the leaf decrypts. + pub fn from_parts( + iv: stack_kms::Iv, + tag: Vec<u8>, + ciphertext: Vec<u8>, + ) -> Result<Self, LeafBytesError> { + Self::tag_fits_length_field(&tag)?; + Ok(Self { iv, tag, ciphertext: LocalCipherText::from(ciphertext), - } + }) } /// Decompose into `(iv, tag, ciphertext)` for persistence. @@ -678,8 +714,8 @@ impl Clone for SealedValue { } } -/// [`SealedValue::from_bytes`] as a std conversion, for generic codecs -/// bounded on `TryFrom`. +/// [`SealedValue::from_bytes`] as a std conversion — the same decoder, for +/// callers who prefer the std trait. impl TryFrom<&[u8]> for SealedValue { type Error = LeafBytesError; @@ -688,6 +724,36 @@ impl TryFrom<&[u8]> for SealedValue { } } +/// Deserialisation shadow for [`SealedValue`]: `serde` bypasses +/// [`SealedValue::from_parts`], so the tag-length invariant +/// [`to_bytes`](SealedValue::to_bytes) relies on is re-checked here. Same +/// field names as the derive, so the wire form is unchanged. +#[derive(Deserialize)] +#[serde(rename = "SealedValue")] +struct SealedValueRepr { + iv: stack_kms::Iv, + tag: Vec<u8>, + ciphertext: LocalCipherText, +} + +impl TryFrom<SealedValueRepr> for SealedValue { + type Error = LeafBytesError; + + fn try_from(repr: SealedValueRepr) -> Result<Self, Self::Error> { + let SealedValueRepr { + iv, + tag, + ciphertext, + } = repr; + Self::tag_fits_length_field(&tag)?; + Ok(Self { + iv, + tag, + ciphertext, + }) + } +} + /// A leaf with its retrieved data key bound alongside. Produced by /// [`bind_keys`] once the batched `retrieve_keys` call has returned; consumed by /// [`StackDecipher`], which opens it under whatever AAD the driving @@ -878,6 +944,17 @@ fn leaf_cipher(key: &DataKey) -> Result<Aes256Cipher, Unspecified> { /// /// The domain label deliberately carries no `/v1` suffix: the version is a /// *parameter* here, not part of the label. +/// +/// # Breaking change +/// +/// This labelled four-piece derivation replaced an unlabelled `PAE(aad, tag)` +/// tuple. A leaf sealed under the old AAD and persisted (via serde or +/// [`SealedValue::into_parts`]) can no longer be opened: it fails +/// authentication in `open_leaf` with a plain AEAD error, indistinguishable +/// from tampering. There is deliberately no `UnknownVersion` signal for it — +/// the old form carried no version byte to detect. This is acceptable +/// because the crate is `publish = false` and only dev-persisted data +/// exists; re-encrypt anything that matters. fn leaf_aad(aad: &Aad<'_>, tag: &[u8]) -> Aad<'static> { const LEAF_AAD_DOMAIN: &[u8] = b"stack-encrypt/leaf"; Aad::pae(&[ diff --git a/packages/stack-encrypt/src/sem/mod.rs b/packages/stack-encrypt/src/sem/mod.rs index ef815797d..4a2cc0e18 100644 --- a/packages/stack-encrypt/src/sem/mod.rs +++ b/packages/stack-encrypt/src/sem/mod.rs @@ -81,15 +81,20 @@ //! * [`OreTerm`] / [`OpeTerm`] — the raw CLLW ciphertext bytes, unframed //! ([`as_bytes`](OreTerm::as_bytes) / [`from_bytes`](OreTerm::from_bytes)). //! -//! The equality and ORE/OPE encodings are byte-identical to what the EQL -//! layer hex-encodes into its `hm` / `oc` / `op` payload fields (EQL's -//! hex and JSON framing sit *above* these bytes), so terms written through a -//! language binding compare against rows the Rust/EQL path wrote. There is -//! deliberately no version byte or framing here: a term is an opaque -//! comparand, its derivation is already versioned by the PAE domain labels -//! above, and Postgres compares these columns byte-wise. The pins in -//! `tests/term_bytes.rs` and `tests/frozen_bytes.rs` hold both the -//! derivations and the encodings in place. +//! These share the *shape* of the v1 / `cipherstash-client` encodings — a +//! 32-byte HMAC for equality, raw CLLW bytes for ORE/OPE, the same bytes EQL +//! hex-encodes into its `hm` / `oc` / `op` fields with its hex and JSON +//! framing sitting *above* them. The **values are not comparable**: as noted +//! above the derivations differ, and stack-encrypt has no EQL integration of +//! its own. A term compares only against terms produced by the same +//! stack-encrypt keyset — never against a row `cipherstash-client` or EQL v1 +//! wrote. What the shared shape buys is a decoder: a language binding reading +//! these bytes needs no framing of its own. There is deliberately no version +//! byte or framing here: a term is an opaque comparand, its derivation is +//! already versioned by the PAE domain labels above, and Postgres compares +//! these columns byte-wise. The pins in `tests/term_bytes.rs` and +//! `tests/frozen_bytes.rs` hold both the derivations and the encodings in +//! place. mod tokenize; @@ -642,8 +647,9 @@ macro_rules! term_wrapper { T::Output: AsRef<[u8]>, { /// The frozen byte encoding: the raw CLLW ciphertext bytes, - /// unframed — byte-identical to what the EQL layer hex-encodes - /// (see the [module docs](self#byte-encodings)). + /// unframed — the same *shape* the EQL layer hex-encodes, but + /// not comparable with rows it wrote (see the + /// [module docs](self#byte-encodings)). pub fn as_bytes(&self) -> &[u8] { self.0.as_ref() } diff --git a/packages/stack-encrypt/tests/frozen_bytes.rs b/packages/stack-encrypt/tests/frozen_bytes.rs index b45cdd417..ad3d74939 100644 --- a/packages/stack-encrypt/tests/frozen_bytes.rs +++ b/packages/stack-encrypt/tests/frozen_bytes.rs @@ -40,11 +40,12 @@ fn hex(bytes: &[u8]) -> String { fn fixture_leaf() -> SealedValue { let iv: stack_kms::Iv = *b"0123456789abcdef"; SealedValue::from_parts(iv, vec![0xAA, 0xBB, 0xCC], vec![0xDE, 0xAD, 0xBE, 0xEF]) + .expect("fixture tag fits the length field") } #[test] fn sealed_value_layout_is_pinned() { - let bytes = fixture_leaf().to_bytes().expect("encode leaf"); + let bytes = fixture_leaf().to_bytes(); // version(01) ‖ iv(16 bytes: ASCII "0123456789abcdef") ‖ // tag_len(0300 — 3, u16 LE) ‖ tag(aabbcc) ‖ local_ciphertext(deadbeef) @@ -57,7 +58,7 @@ fn sealed_value_layout_is_pinned() { #[test] fn sealed_value_from_bytes_inverts_to_bytes() { let original = fixture_leaf(); - let bytes = original.to_bytes().expect("encode leaf"); + let bytes = original.to_bytes(); let decoded = SealedValue::from_bytes(&bytes).expect("decode leaf"); assert_eq!(decoded.iv(), original.iv()); @@ -71,7 +72,7 @@ fn sealed_value_from_bytes_inverts_to_bytes() { #[test] fn sealed_value_rejects_unknown_version() { - let mut bytes = fixture_leaf().to_bytes().expect("encode leaf"); + let mut bytes = fixture_leaf().to_bytes(); bytes[0] = 2; assert!(matches!( SealedValue::from_bytes(&bytes), @@ -81,7 +82,7 @@ fn sealed_value_rejects_unknown_version() { #[test] fn sealed_value_rejects_truncation() { - let bytes = fixture_leaf().to_bytes().expect("encode leaf"); + let bytes = fixture_leaf().to_bytes(); // Every prefix shorter than the tag's end is truncated: empty, mid-iv, // mid-length-field, and mid-tag. (Anything at or past the tag's end @@ -101,14 +102,16 @@ fn sealed_value_rejects_truncation() { } #[test] -fn sealed_value_rejects_oversized_tag_on_encode() { - let leaf = SealedValue::from_parts( +fn sealed_value_rejects_oversized_tag_on_construction() { + // `to_bytes` is infallible because the tag can never outgrow the `u16` + // length field: the only constructor that could admit one rejects it. + let result = SealedValue::from_parts( [0; 16], vec![0; usize::from(u16::MAX) + 1], vec![0xDE, 0xAD], ); assert!(matches!( - leaf.to_bytes(), + result, Err(LeafBytesError::TagTooLong(len)) if len == usize::from(u16::MAX) + 1 )); } @@ -125,7 +128,7 @@ async fn sealed_leaf_survives_persistence_via_bytes() { CipherText::Single(leaf) => leaf, other => panic!("expected a Single leaf, got {other:?}"), }; - let bytes = leaf.to_bytes().expect("encode leaf"); + let bytes = leaf.to_bytes(); let restored = SealedValue::from_bytes(&bytes).expect("decode leaf"); let pt: String = cipher @@ -188,8 +191,9 @@ async fn ore_term_encoding_is_the_raw_cllw_bytes() { .expect("ore term"); // Byte-identical to the raw CLLW output pinned in term_bytes.rs — the - // wrapper adds no framing, so these bytes compare against columns the - // EQL path wrote. + // wrapper adds no framing. Same *shape* as the CLLW bytes EQL stores, but + // not comparable with rows cipherstash-client wrote: the key derivations + // differ (see the `sem` module docs). assert_eq!( hex(term.as_bytes()), "d757854cffc68e9f3dfa9dba7ec400a30c80dd57122ebbc064eeff5a81069fc7" diff --git a/packages/stack-encrypt/tests/roundtrip.rs b/packages/stack-encrypt/tests/roundtrip.rs index e5d5711c5..d229ab9aa 100644 --- a/packages/stack-encrypt/tests/roundtrip.rs +++ b/packages/stack-encrypt/tests/roundtrip.rs @@ -362,7 +362,7 @@ async fn leaf_survives_persistence_via_parts() { other => panic!("expected a Single leaf, got {other:?}"), }; let (iv, tag, bytes) = leaf.into_parts(); - let rebuilt = SealedValue::from_parts(iv, tag, bytes); + let rebuilt = SealedValue::from_parts(iv, tag, bytes).expect("rebuild leaf"); let pt: String = cipher .decrypt(CipherText::Single(rebuilt), b"ctx".as_slice()) @@ -409,7 +409,7 @@ async fn tampered_leaf_bytes_fail() { let (iv, tag, mut bytes) = leaf.into_parts(); let last = bytes.len() - 1; bytes[last] ^= 0x01; - let tampered = SealedValue::from_parts(iv, tag, bytes); + let tampered = SealedValue::from_parts(iv, tag, bytes).expect("rebuild leaf"); let result: Result<String, _> = cipher.decrypt(CipherText::Single(tampered), ()).await; assert!(result.is_err(), "a flipped ciphertext bit must not decrypt"); From f4fec2a4aff9bcbb2088a08f6ed8e1b625029197 Mon Sep 17 00:00:00 2001 From: Dan Draper <dan@cipherstash.com> Date: Sat, 29 Aug 2026 22:37:38 +1000 Subject: [PATCH 485/686] fix(stack-encrypt): structured term decode errors and consistent term byte APIs - `MatchTerm::from_positions`/`from_bytes` reject positions outside the filter size fixed by `O: MatchConfig`, so a mis-decoded position list fails loudly instead of producing a term that never matches. - Replace the stringly `TermError::MalformedTermBytes(&str)` with a `TermBytesError` enum (`PartialEq`), mirroring `LeafBytesError`; CLLW decode failures report the offending length rather than the deliberately opaque `cllw_ore::Error`. - Every term type now exposes `to_bytes()` and `TryFrom<&[u8]>`; `as_bytes()` only where a contiguous buffer exists. Plan doc states the per-type surface instead of "on every term type". - Reframe the match term's LE-u16 byte string as the frozen FFI transport encoding; the stored and queried contract is the position list. - Scope the `SealedValue` byte-format commitment to ciphertext and link the index-term encodings. Claude-Session: https://claude.ai/code/session_01AhxMmV52dSYwjAT8KENHRV --- docs/plans/stack-encrypt-go-bindings.md | 12 +- packages/stack-encrypt/src/cipher.rs | 10 +- packages/stack-encrypt/src/sem/mod.rs | 239 ++++++++++++++----- packages/stack-encrypt/tests/frozen_bytes.rs | 126 +++++++++- packages/stack-encrypt/tests/target.rs | 5 +- 5 files changed, 312 insertions(+), 80 deletions(-) diff --git a/docs/plans/stack-encrypt-go-bindings.md b/docs/plans/stack-encrypt-go-bindings.md index 7abb00b05..6aa085a6a 100644 --- a/docs/plans/stack-encrypt-go-bindings.md +++ b/docs/plans/stack-encrypt-go-bindings.md @@ -245,8 +245,16 @@ so they cannot be opened and fail with a plain AEAD error rather than an dev-persisted data exists); term encodings frozen as raw-bytes (equality: the 32 PRF bytes; ORE/OPE: the raw CLLW ciphertext, byte-identical to what EQL hex-encodes into `hm`/`oc`/`op`; match: LE `u16` positions — EQL sends `bf` as a JSON -integer array, so the byte-string form is stack-encrypt's own) with -`as_bytes`/`to_bytes`/`from_bytes` on every term type; length-validating +integer array, so the byte-string form is stack-encrypt's own *transport* +encoding across the wasm/FFI boundary, not a storage commitment — what is +stored and queried is the position list). The surface per type: +`to_bytes` and a fallible `TryFrom<&[u8]>` on all four; `from_bytes` on all +four (infallible over `[u8; 32]` for `EqualityTerm`, fallible over a slice +for the rest); `as_bytes` only where the term is a contiguous buffer +(`EqualityTerm`, `OreTerm`, `OpeTerm`) — a `MatchTerm` is canonically a +position list, so it has none, and its decoders range-check every position +against the `MatchConfig`'s filter size. Decode failures are the structured, +`PartialEq` `TermBytesError`. Also: length-validating `TryFrom<&[u8]>` added to cllw-ore's variable-width ciphertext types; and golden vectors in `tests/frozen_bytes.rs` for the Go decoder to test against. diff --git a/packages/stack-encrypt/src/cipher.rs b/packages/stack-encrypt/src/cipher.rs index c697f9c9b..6346ddaa8 100644 --- a/packages/stack-encrypt/src/cipher.rs +++ b/packages/stack-encrypt/src/cipher.rs @@ -499,10 +499,12 @@ impl<K: DataKeySource> StackCipher<K> { /// A single sealed leaf: the ZeroKMS metadata needed to retrieve its data key /// (`iv`, `tag`) plus the vitaminc [`LocalCipherText`] sealed under that key. /// -/// This is the only byte-format commitment the crate makes — the container -/// tree ([`StackCipherText`]) has no canonical encoding, so callers that -/// persist or transmit ciphertext serialise leaves and rebuild the tree -/// around them. +/// This is the only byte-format commitment the crate makes for *ciphertext* — +/// the container tree ([`StackCipherText`]) has no canonical encoding, so +/// callers that persist or transmit ciphertext serialise leaves and rebuild +/// the tree around them. Index terms are a separate commitment with their own +/// frozen encodings (see the +/// [index-term encodings](crate::sem#byte-encodings)). /// /// # Frozen byte encoding /// diff --git a/packages/stack-encrypt/src/sem/mod.rs b/packages/stack-encrypt/src/sem/mod.rs index 4a2cc0e18..f3b7cb158 100644 --- a/packages/stack-encrypt/src/sem/mod.rs +++ b/packages/stack-encrypt/src/sem/mod.rs @@ -68,33 +68,50 @@ //! //! # Byte encodings //! -//! Terms are stored server-side and compared across languages, so each term -//! kind commits to one frozen byte encoding — the bytes a language binding -//! writes and a database column holds: +//! Terms cross the wasm/FFI boundary into other languages, so each term kind +//! commits to one frozen **transport** encoding — the bytes a language +//! binding decodes: //! //! * [`EqualityTerm`] — the 32 PRF bytes as-is //! ([`as_bytes`](EqualityTerm::as_bytes) / +//! [`to_bytes`](EqualityTerm::to_bytes) / //! [`from_bytes`](EqualityTerm::from_bytes)). //! * [`MatchTerm`] — the sorted, de-duplicated bit positions, each a //! little-endian `u16` ([`to_bytes`](MatchTerm::to_bytes) / //! [`from_bytes`](MatchTerm::from_bytes)). //! * [`OreTerm`] / [`OpeTerm`] — the raw CLLW ciphertext bytes, unframed -//! ([`as_bytes`](OreTerm::as_bytes) / [`from_bytes`](OreTerm::from_bytes)). +//! ([`as_bytes`](OreTerm::as_bytes) / [`to_bytes`](OreTerm::to_bytes) / +//! [`from_bytes`](OreTerm::from_bytes)). //! -//! These share the *shape* of the v1 / `cipherstash-client` encodings — a +//! Every kind decodes through `TryFrom<&[u8]>` as well, failing with a +//! [`TermBytesError`]; [`EqualityTerm`] additionally keeps an infallible +//! [`from_bytes`](EqualityTerm::from_bytes) over a `[u8; 32]`. `as_bytes` +//! exists only where the term *is* a contiguous buffer (equality, ORE, OPE); +//! a [`MatchTerm`] is canonically a position list, so it has none. +//! +//! For equality and ORE/OPE the transport bytes are also the stored form, +//! and they share the *shape* of the v1 / `cipherstash-client` encodings — a //! 32-byte HMAC for equality, raw CLLW bytes for ORE/OPE, the same bytes EQL //! hex-encodes into its `hm` / `oc` / `op` fields with its hex and JSON -//! framing sitting *above* them. The **values are not comparable**: as noted -//! above the derivations differ, and stack-encrypt has no EQL integration of -//! its own. A term compares only against terms produced by the same -//! stack-encrypt keyset — never against a row `cipherstash-client` or EQL v1 -//! wrote. What the shared shape buys is a decoder: a language binding reading -//! these bytes needs no framing of its own. There is deliberately no version -//! byte or framing here: a term is an opaque comparand, its derivation is -//! already versioned by the PAE domain labels above, and Postgres compares -//! these columns byte-wise. The pins in `tests/term_bytes.rs` and +//! framing sitting *above* them. A match term is the exception: what is +//! stored and queried is the position list +//! ([`positions`](MatchTerm::positions)), which maps to an integer-array +//! column (EQL sends `bf` as a JSON integer array) — no column holds the +//! `u16` byte string, which is stack-encrypt's own shape and exists so a +//! binding can carry the term across the boundary without inventing a +//! framing. +//! +//! The **values are not comparable**: as noted above the derivations differ, +//! and stack-encrypt has no EQL integration of its own. A term compares only +//! against terms produced by the same stack-encrypt keyset — never against a +//! row `cipherstash-client` or EQL v1 wrote. What the shared shape buys is a +//! decoder: a language binding reading these bytes needs no framing of its +//! own. There is deliberately no version byte or framing here: a term is an +//! opaque comparand and its derivation is already versioned by the PAE domain +//! labels above. The pins in `tests/term_bytes.rs` and //! `tests/frozen_bytes.rs` hold both the derivations and the encodings in -//! place. +//! place: a binding depends on the transport bytes, so they are frozen even +//! where no column holds them. mod tokenize; @@ -163,12 +180,47 @@ pub enum TermError { /// [`EncryptContext`]. #[error("the encryption context must not be empty (it domain-separates fields)")] EmptyContext, - /// Stored term bytes do not decode under the term kind's frozen byte - /// encoding (see the [module docs](self#byte-encodings)) — wrong length, - /// or a length the CLLW ciphertext shape cannot have. Structural only: - /// bytes that *decode* are not thereby proven to be a genuine term. - #[error("malformed term bytes: {0}")] - MalformedTermBytes(&'static str), + /// Term bytes do not decode under the term kind's frozen encoding — see + /// [`TermBytesError`]. + #[error(transparent)] + Bytes(#[from] TermBytesError), +} + +/// A term's frozen byte encoding failed to decode (see the +/// [module docs](self#byte-encodings)). Purely structural — a term that +/// *decodes* has proven nothing about being a genuine term derived under any +/// particular keyset; bytes that fail here were never a valid encoding of +/// that term kind at all. +/// +/// Kept separate from the rest of [`TermError`] (which it converts into) so +/// decoding has an error a caller can compare: the generation variants carry +/// boxed and opaque sources that are not [`PartialEq`]. +#[derive(Debug, PartialEq, Eq, thiserror::Error)] +pub enum TermBytesError { + /// Equality-term bytes are not the 32 PRF bytes. + #[error("equality-term bytes must be exactly 32 bytes, got {0}")] + WrongEqualityTermLength(usize), + /// Match-term bytes are not a whole number of little-endian `u16` + /// positions. + #[error("match-term bytes must be little-endian u16 positions, got an odd length of {0}")] + OddMatchTermLength(usize), + /// A decoded position lies outside the Bloom filter the term's + /// [`MatchConfig`] fixes. Genuine positions are always masked into + /// `0..m`, so an out-of-range one means the bytes were not written by + /// this encoding — a wrong-endian decoder, most often, which would + /// otherwise decode cleanly and then silently never match. + #[error("match position {position} is outside the {filter_size}-bit filter")] + MatchPositionOutOfRange { + /// The offending position. + position: u16, + /// The filter size (`m`) the [`MatchConfig`] fixes. + filter_size: u32, + }, + /// The buffer's length is not one this CLLW ciphertext shape can have. + /// (The length is all there is to report: `cllw_ore::Error` is + /// deliberately contentless, so its message would say strictly less.) + #[error("{0} bytes do not fit this CLLW ciphertext shape")] + MalformedCllwCiphertext(usize), } impl TermError { @@ -206,6 +258,8 @@ pub struct EqualityTerm([u8; 32]); impl EqualityTerm { /// Rebuild a term from stored bytes — the inverse of /// [`into_bytes`](Self::into_bytes), for terms persisted server-side. + /// Infallible: the width is in the type. For a slice of unknown length + /// use `TryFrom<&[u8]>`. pub fn from_bytes(bytes: [u8; 32]) -> Self { Self(bytes) } @@ -214,11 +268,30 @@ impl EqualityTerm { &self.0 } + /// Owned copy of [`as_bytes`](Self::as_bytes) — the same `to_bytes` every + /// other term kind offers (see the [module docs](self#byte-encodings)). + pub fn to_bytes(&self) -> Vec<u8> { + self.0.to_vec() + } + pub fn into_bytes(self) -> [u8; 32] { self.0 } } +/// Decode a slice of unknown length — the fallible counterpart of +/// [`EqualityTerm::from_bytes`], and the same `TryFrom<&[u8]>` every other +/// term kind offers. +impl TryFrom<&[u8]> for EqualityTerm { + type Error = TermBytesError; + + fn try_from(bytes: &[u8]) -> Result<Self, Self::Error> { + <[u8; 32]>::try_from(bytes) + .map(Self) + .map_err(|_| TermBytesError::WrongEqualityTermLength(bytes.len())) + } +} + impl From<EqualityTerm> for Vec<u8> { fn from(term: EqualityTerm) -> Self { term.0.to_vec() @@ -367,13 +440,12 @@ pub struct MatchTerm<O = DefaultMatch> { } impl<O> MatchTerm<O> { - /// Rebuild a term from stored positions — the inverse of - /// [`positions`](Self::positions) / - /// [`into_positions`](Self::into_positions), for terms persisted - /// server-side. Sorts and de-duplicates, so any ordering is accepted; - /// the caller asserts (via `O`) that the positions were generated under - /// the same [`MatchConfig`]. - pub fn from_positions(mut positions: Vec<u16>) -> Self { + /// Wrap positions that are already known to be in range — sorting and + /// de-duplicating them into the canonical order. Private because nothing + /// outside can know the range holds: the generator's positions are masked + /// into `0..m` by construction, and every caller-supplied list goes + /// through [`from_positions`](Self::from_positions) instead. + fn normalised(mut positions: Vec<u16>) -> Self { positions.sort_unstable(); positions.dedup(); Self { @@ -382,10 +454,12 @@ impl<O> MatchTerm<O> { } } - /// The frozen byte encoding: each position as a little-endian `u16`, in - /// the canonical order [`positions`](Self::positions) holds them (sorted - /// ascending, no duplicates). See the - /// [module docs](self#byte-encodings). The inverse of + /// The transport byte encoding: each position as a little-endian `u16`, + /// in the canonical order [`positions`](Self::positions) holds them + /// (sorted ascending, no duplicates). See the + /// [module docs](self#byte-encodings) — what is stored and queried is the + /// position list; this is the frozen form a language binding carries + /// across the wasm/FFI boundary. The inverse of /// [`from_bytes`](Self::from_bytes). pub fn to_bytes(&self) -> Vec<u8> { self.positions @@ -394,25 +468,6 @@ impl<O> MatchTerm<O> { .collect() } - /// Decode the frozen byte encoding — little-endian `u16` positions — the - /// inverse of [`to_bytes`](Self::to_bytes). Like - /// [`from_positions`](Self::from_positions), any ordering is accepted - /// and normalised, and the caller asserts (via `O`) the generating - /// [`MatchConfig`]. Rejects an odd-length buffer. - pub fn from_bytes(bytes: &[u8]) -> Result<Self, TermError> { - if !bytes.len().is_multiple_of(2) { - return Err(TermError::MalformedTermBytes( - "match-term bytes must be a sequence of u16 positions (even length)", - )); - } - Ok(Self::from_positions( - bytes - .chunks_exact(2) - .map(|pair| u16::from_le_bytes([pair[0], pair[1]])) - .collect(), - )) - } - /// The set Bloom-filter bit positions, sorted ascending, no duplicates. pub fn positions(&self) -> &[u16] { &self.positions @@ -442,6 +497,60 @@ impl<O> MatchTerm<O> { } } +/// Rebuilding a term needs the [`MatchConfig`]: it fixes the filter size `m` +/// every genuine position is below, and a position outside it is a decoding +/// bug rather than a term. +impl<O: MatchConfig> MatchTerm<O> { + /// Rebuild a term from stored positions — the inverse of + /// [`positions`](Self::positions) / + /// [`into_positions`](Self::into_positions), for terms persisted + /// server-side. Sorts and de-duplicates, so any ordering is accepted; + /// the caller asserts (via `O`) that the positions were generated under + /// the same [`MatchConfig`], and that much is checked: a position at or + /// beyond `O`'s filter size `m` is rejected with + /// [`TermBytesError::MatchPositionOutOfRange`]. + pub fn from_positions(positions: Vec<u16>) -> Result<Self, TermBytesError> { + let filter_size = O::options().m; + for &position in &positions { + if u32::from(position) >= filter_size { + return Err(TermBytesError::MatchPositionOutOfRange { + position, + filter_size, + }); + } + } + Ok(Self::normalised(positions)) + } + + /// Decode the transport byte encoding — little-endian `u16` positions — + /// the inverse of [`to_bytes`](Self::to_bytes). Like + /// [`from_positions`](Self::from_positions), any ordering is accepted and + /// normalised, and positions outside `O`'s filter are rejected: without + /// that check a wrong-endian decoder on the other side of the FFI + /// boundary would produce a term that decodes cleanly and then silently + /// never matches. Rejects an odd-length buffer. + pub fn from_bytes(bytes: &[u8]) -> Result<Self, TermBytesError> { + if !bytes.len().is_multiple_of(2) { + return Err(TermBytesError::OddMatchTermLength(bytes.len())); + } + Self::from_positions( + bytes + .chunks_exact(2) + .map(|pair| u16::from_le_bytes([pair[0], pair[1]])) + .collect(), + ) + } +} + +/// [`MatchTerm::from_bytes`] as a std conversion — the same decoder. +impl<O: MatchConfig> TryFrom<&[u8]> for MatchTerm<O> { + type Error = TermBytesError; + + fn try_from(bytes: &[u8]) -> Result<Self, Self::Error> { + Self::from_bytes(bytes) + } +} + impl<O> fmt::Debug for MatchTerm<O> { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.debug_struct("MatchTerm") @@ -452,7 +561,7 @@ impl<O> fmt::Debug for MatchTerm<O> { impl<O> Clone for MatchTerm<O> { fn clone(&self) -> Self { - Self::from_positions(self.positions.clone()) + Self::normalised(self.positions.clone()) } } @@ -531,7 +640,9 @@ fn match_term<O>( .prf_visit_with_context(prf, context, BloomVisitor { k: options.k, mask }) .into_result() .map_err(TermError::from_prf)?; - Ok(MatchTerm::from_positions(positions)) + // Every position came out of the visitor masked to `m - 1`, so the range + // check `from_positions` applies is already satisfied by construction. + Ok(MatchTerm::normalised(positions)) } /// A match term of any text source, generated under `O`'s options. Derived @@ -630,15 +741,25 @@ macro_rules! term_wrapper { /// — for terms persisted server-side. Structural only (length /// checks); the caller asserts the bytes were generated for this /// source type `T` and under the same context. - pub fn from_bytes(bytes: &[u8]) -> Result<Self, TermError> + pub fn from_bytes(bytes: &[u8]) -> Result<Self, TermBytesError> where for<'a> T::Output: TryFrom<&'a [u8]>, { - T::Output::try_from(bytes).map(Self).map_err(|_| { - TermError::MalformedTermBytes( - "byte length does not fit this CLLW ciphertext shape", - ) - }) + T::Output::try_from(bytes) + .map(Self) + .map_err(|_| TermBytesError::MalformedCllwCiphertext(bytes.len())) + } + } + + /// The inherent `from_bytes` as a std conversion — the same decoder. + impl<T: $bound> TryFrom<&[u8]> for $name<T> + where + for<'a> T::Output: TryFrom<&'a [u8]>, + { + type Error = TermBytesError; + + fn try_from(bytes: &[u8]) -> Result<Self, Self::Error> { + Self::from_bytes(bytes) } } diff --git a/packages/stack-encrypt/tests/frozen_bytes.rs b/packages/stack-encrypt/tests/frozen_bytes.rs index ad3d74939..077aec3a1 100644 --- a/packages/stack-encrypt/tests/frozen_bytes.rs +++ b/packages/stack-encrypt/tests/frozen_bytes.rs @@ -1,19 +1,24 @@ -//! Byte-level pins for the frozen storage encodings — the byte formats -//! stack-encrypt commits to across languages and database columns: +//! Byte-level pins for the frozen encodings stack-encrypt commits to across +//! languages: //! //! * the [`SealedValue`] leaf layout -//! (`version ‖ iv ‖ tag_len ‖ tag ‖ local_ciphertext`), and +//! (`version ‖ iv ‖ tag_len ‖ tag ‖ local_ciphertext`) — the storage +//! format a database column holds, and //! * the index-term encodings (equality: raw 32 bytes; match: LE `u16` //! positions; ORE/OPE: raw CLLW ciphertext bytes). //! //! These are the vectors a language binding's decoder tests against — the //! Go side decodes exactly these hex strings. `tests/term_bytes.rs` pins the //! *derivations* (PRF domains and framing); this file pins the *encodings* -//! of the results. Breaking a pin here means the storage format moved: for -//! the leaf that demands a `SealedValue::FORMAT_VERSION` bump, for terms it -//! means stored rows silently stop comparing. +//! of the results. Breaking a pin here breaks a consumer: for the leaf it +//! moves the storage format and demands a `SealedValue::FORMAT_VERSION` bump; +//! for the equality and ORE/OPE terms it moves the bytes a column holds; for +//! the match term it moves the wasm/FFI transport shape (no column holds +//! that byte string — the stored and queried contract is the position list, +//! which maps to an integer-array column), and every binding decoding it +//! silently stops agreeing. -use stack_encrypt::sem::{DefaultMatch, EqualityTerm, MatchTerm, OpeTerm, OreTerm}; +use stack_encrypt::sem::{DefaultMatch, EqualityTerm, MatchTerm, OpeTerm, OreTerm, TermBytesError}; use stack_encrypt::target::EncryptInto; use stack_encrypt::{CipherText, LeafBytesError, SealedValue, StackCipher}; use stack_kms::FakeDataKeySource; @@ -153,7 +158,26 @@ async fn equality_term_encoding_is_the_raw_prf_bytes() { // The derivation is pinned in term_bytes.rs; here: encoding = identity // over those 32 bytes, and from_bytes is its inverse. assert_eq!(term.as_ref(), term.as_bytes()); + assert_eq!(term.to_bytes(), term.as_bytes()); assert_eq!(EqualityTerm::from_bytes(*term.as_bytes()), term); + + // The std conversion is the same decoder, over a slice of unknown length. + assert_eq!( + EqualityTerm::try_from(term.to_bytes().as_slice()).expect("TryFrom decode"), + term + ); +} + +#[test] +fn equality_term_try_from_rejects_wrong_length() { + assert_eq!( + EqualityTerm::try_from([0u8; 31].as_slice()), + Err(TermBytesError::WrongEqualityTermLength(31)) + ); + assert_eq!( + EqualityTerm::try_from([0u8; 33].as_slice()), + Err(TermBytesError::WrongEqualityTermLength(33)) + ); } #[tokio::test] @@ -175,11 +199,63 @@ async fn match_term_bytes_are_pinned() { MatchTerm::<DefaultMatch>::from_bytes(&bytes).expect("decode match term"), term ); + // The std conversion is the same decoder. + assert_eq!( + MatchTerm::<DefaultMatch>::try_from(bytes.as_slice()).expect("TryFrom decode"), + term + ); } #[test] fn match_term_from_bytes_rejects_odd_length() { - assert!(MatchTerm::<DefaultMatch>::from_bytes(&[0x21]).is_err()); + assert_eq!( + MatchTerm::<DefaultMatch>::from_bytes(&[0x21]), + Err(TermBytesError::OddMatchTermLength(1)) + ); +} + +#[test] +fn match_term_from_bytes_rejects_positions_outside_the_filter() { + // `DefaultMatch` is a 256-bit filter, so genuine positions are 0..256 and + // the high byte of every LE u16 is zero. A position at the filter size, + // and the 0xffff a wrong-endian decoder produces, are both rejected — + // they would otherwise decode cleanly and then silently never match. + assert_eq!( + MatchTerm::<DefaultMatch>::from_bytes(&[0x00, 0x01]), + Err(TermBytesError::MatchPositionOutOfRange { + position: 256, + filter_size: 256, + }) + ); + assert_eq!( + MatchTerm::<DefaultMatch>::from_bytes(&[0xff, 0xff]), + Err(TermBytesError::MatchPositionOutOfRange { + position: 0xffff, + filter_size: 256, + }) + ); + // Byte-swapping a genuine term is exactly that failure: position 0x21 + // becomes 0x2100. + assert!(matches!( + MatchTerm::<DefaultMatch>::from_bytes(&[0x00, 0x21]), + Err(TermBytesError::MatchPositionOutOfRange { .. }) + )); + + // In-range positions round-trip, through both constructors. + let positions = vec![0u16, 1, 255]; + let term = MatchTerm::<DefaultMatch>::from_positions(positions.clone()).expect("in range"); + assert_eq!(term.positions(), positions.as_slice()); + assert_eq!( + MatchTerm::<DefaultMatch>::from_bytes(&term.to_bytes()).expect("decode"), + term + ); + assert_eq!( + MatchTerm::<DefaultMatch>::from_positions(vec![256]), + Err(TermBytesError::MatchPositionOutOfRange { + position: 256, + filter_size: 256, + }) + ); } #[tokio::test] @@ -204,6 +280,11 @@ async fn ore_term_encoding_is_the_raw_cllw_bytes() { OreTerm::<u32>::from_bytes(term.as_bytes()).expect("decode ore term"), term ); + // The std conversion is the same decoder. + assert_eq!( + OreTerm::<u32>::try_from(term.as_bytes()).expect("TryFrom decode"), + term + ); } #[tokio::test] @@ -222,15 +303,28 @@ async fn ope_term_encoding_is_the_raw_cllw_bytes() { OpeTerm::<u32>::from_bytes(term.as_bytes()).expect("decode ope term"), term ); + assert_eq!( + OpeTerm::<u32>::try_from(term.as_bytes()).expect("TryFrom decode"), + term + ); } #[test] fn ore_term_from_bytes_rejects_wrong_length() { // u32 → OreCllw8V1<32>: exactly 32 bytes. - assert!(OreTerm::<u32>::from_bytes(&[0u8; 31]).is_err()); - assert!(OreTerm::<u32>::from_bytes(&[0u8; 33]).is_err()); + assert_eq!( + OreTerm::<u32>::from_bytes(&[0u8; 31]), + Err(TermBytesError::MalformedCllwCiphertext(31)) + ); + assert_eq!( + OreTerm::<u32>::from_bytes(&[0u8; 33]), + Err(TermBytesError::MalformedCllwCiphertext(33)) + ); // u32 → OpeCllw8V1<33>: exactly 33 bytes. - assert!(OpeTerm::<u32>::from_bytes(&[0u8; 32]).is_err()); + assert_eq!( + OpeTerm::<u32>::from_bytes(&[0u8; 32]), + Err(TermBytesError::MalformedCllwCiphertext(32)) + ); } #[tokio::test] @@ -249,7 +343,10 @@ async fn variable_length_ore_and_ope_terms_decode() { OreTerm::<String>::from_bytes(ore.as_bytes()).expect("decode"), ore ); - assert!(OreTerm::<String>::from_bytes(&ore.as_bytes()[1..]).is_err()); + assert_eq!( + OreTerm::<String>::from_bytes(&ore.as_bytes()[1..]), + Err(TermBytesError::MalformedCllwCiphertext(5 * 8 - 1)) + ); let ope: OpeTerm<String> = "alice" .to_string() @@ -261,5 +358,8 @@ async fn variable_length_ore_and_ope_terms_decode() { OpeTerm::<String>::from_bytes(ope.as_bytes()).expect("decode"), ope ); - assert!(OpeTerm::<String>::from_bytes(&ope.as_bytes()[1..]).is_err()); + assert_eq!( + OpeTerm::<String>::from_bytes(&ope.as_bytes()[1..]), + Err(TermBytesError::MalformedCllwCiphertext(5 * 8)) + ); } diff --git a/packages/stack-encrypt/tests/target.rs b/packages/stack-encrypt/tests/target.rs index 4544a146e..063a6827a 100644 --- a/packages/stack-encrypt/tests/target.rs +++ b/packages/stack-encrypt/tests/target.rs @@ -804,10 +804,11 @@ async fn terms_rehydrate_from_persisted_parts() { .encrypt_into_with_context(&generator, "users/bio") .await .unwrap(); - // Rehydrate from unsorted positions: from_positions normalises. + // Rehydrate from unsorted positions: from_positions normalises (and + // range-checks against the config's filter size). let mut positions = stored.clone().into_positions(); positions.reverse(); - let rehydrated: MatchTerm = MatchTerm::from_positions(positions); + let rehydrated: MatchTerm = MatchTerm::from_positions(positions).unwrap(); assert_eq!(stored, rehydrated); assert!(rehydrated.contains(&query)); } From 975d9fae95b13e1c11516afdc982460fb439e5fa Mon Sep 17 00:00:00 2001 From: Dan Draper <dan@cipherstash.com> Date: Mon, 31 Aug 2026 14:34:34 +1000 Subject: [PATCH 486/686] fix(stack-encrypt): review follow-ups on the frozen byte formats MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Move the sealed-leaf AAD breaking change out of `SealedValue`'s rustdoc and into a new packages/stack-encrypt/CHANGELOG.md. Release history does not belong on the type. - Drop the cross-reference from `SealedValue`'s public docs to the `cipher` module docs. `mod cipher` is private, so its module-level `//!` docs render nowhere in `cargo doc` output except the source listing — the pointer sent readers to documentation the HTML does not contain. The leaf-AAD derivation it referred to is already stated inline. - Write `LocalCipherText` and `Aes256Cipher` as code spans rather than intra-doc links. Both are private imports of vitaminc types, so rustdoc resolves them only when dependency docs are built; under `--no-deps` (what CI and `wasm:no-http-test` run) the links silently degrade to literal `[Name]` text, with no warning. Code spans render the same either way. - Mark `LeafBytesError`, `TermBytesError` and `TermError` `#[non_exhaustive]` so the versioned decoders can gain variants without a source break. Claude-Session: https://claude.ai/code/session_01BpqczxAVwUsTYdCRWh9dYb --- packages/stack-encrypt/CHANGELOG.md | 38 +++++++++++++++++++++++++++ packages/stack-encrypt/src/cipher.rs | 27 +++++-------------- packages/stack-encrypt/src/sem/mod.rs | 2 ++ 3 files changed, 47 insertions(+), 20 deletions(-) create mode 100644 packages/stack-encrypt/CHANGELOG.md diff --git a/packages/stack-encrypt/CHANGELOG.md b/packages/stack-encrypt/CHANGELOG.md new file mode 100644 index 000000000..5bff1e910 --- /dev/null +++ b/packages/stack-encrypt/CHANGELOG.md @@ -0,0 +1,38 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +### Breaking + +- **Sealed leaves gained a version byte, and the leaf AAD that binds it.** + Leaves sealed before this change used an unlabelled `PAE(aad, tag)` leaf + AAD with no version byte; the derivation is now + `PAE("stack-encrypt/leaf", version, derived_aad, tag)`. Leaves sealed under + the old derivation cannot be opened by this build — however they were + persisted (`serde`, `into_parts`, or raw bytes) they fail AEAD verification + with a plain authentication error, indistinguishable from tampering, + because the old form carries no version byte to raise + `LeafBytesError::UnknownVersion` against. Acceptable only because the crate + is `publish = false` and only dev-persisted data exists; from + `SealedValue::FORMAT_VERSION` onwards a format move is signalled by the + version byte instead. + +### Added + +- `SealedValue::to_bytes` / `from_bytes` / `TryFrom<&[u8]>`: the canonical, + frozen v1 leaf encoding + (`version ‖ iv ‖ u16 tag_len ‖ tag ‖ local_ciphertext`), with + `LeafBytesError` for structural decode failures. +- Frozen transport encodings for every index term (`EqualityTerm`, + `MatchTerm`, `OreTerm`, `OpeTerm`) with `TermBytesError` for decode + failures, plus golden vectors in `tests/frozen_bytes.rs`. + +### Changed + +- `TermError`, `TermBytesError` and `LeafBytesError` are `#[non_exhaustive]`, + so the versioned decoders can gain variants without a source break. diff --git a/packages/stack-encrypt/src/cipher.rs b/packages/stack-encrypt/src/cipher.rs index 6346ddaa8..adcf028b8 100644 --- a/packages/stack-encrypt/src/cipher.rs +++ b/packages/stack-encrypt/src/cipher.rs @@ -497,7 +497,7 @@ impl<K: DataKeySource> StackCipher<K> { } /// A single sealed leaf: the ZeroKMS metadata needed to retrieve its data key -/// (`iv`, `tag`) plus the vitaminc [`LocalCipherText`] sealed under that key. +/// (`iv`, `tag`) plus the vitaminc `LocalCipherText` sealed under that key. /// /// This is the only byte-format commitment the crate makes for *ciphertext* — /// the container tree ([`StackCipherText`]) has no canonical encoding, so @@ -519,7 +519,7 @@ impl<K: DataKeySource> StackCipher<K> { /// | 1 | ZeroKMS `iv` | 16 | identifies the data key for retrieval | /// | 17 | `tag_len` | 2 | length of `tag`, `u16` little-endian | /// | 19 | ZeroKMS key `tag` | `tag_len` | required to retrieve the key | -/// | 19 + `tag_len` | local ciphertext | rest of buffer | the vitaminc [`LocalCipherText`] | +/// | 19 + `tag_len` | local ciphertext | rest of buffer | the vitaminc `LocalCipherText` | /// /// The local ciphertext is itself a framed value — vitaminc's leaf wire /// format, versioned and owned by vitaminc — so the full stored byte string @@ -534,12 +534,10 @@ impl<K: DataKeySource> StackCipher<K> { /// ``` /// /// Both version bytes are authenticated under the one GCM tag, each bound by -/// the layer that owns its framing: the envelope version through this -/// crate's leaf-AAD derivation (the private `leaf_aad` — -/// `PAE("stack-encrypt/leaf", version, derived_aad, tag)`; see the module -/// docs in `src/cipher.rs`), and the inner version through vitaminc's -/// `Aad::for_leaf`, applied inside [`Aes256Cipher`] to the AAD this crate -/// hands it. Relabel either version byte in storage and the leaf fails +/// the layer that owns its framing: the envelope version through this crate's +/// leaf-AAD derivation, `PAE("stack-encrypt/leaf", version, derived_aad, +/// tag)`, and the inner version through vitaminc's `Aad::for_leaf`, applied +/// inside `Aes256Cipher` to the AAD this crate hands it. Relabel either version byte in storage and the leaf fails /// authentication rather than parsing under the wrong rules. Parsing is /// structural only — nothing about a decoded leaf is trusted until it /// decrypts. @@ -549,18 +547,6 @@ impl<K: DataKeySource> StackCipher<K> { /// remain for callers that manage their own storage format; they carry the /// same fields, but their wire form is the serialiser's, not a commitment /// of this crate. -/// -/// # Breaking change -/// -/// Leaves sealed *before* this format landed used an unlabelled -/// `PAE(aad, tag)` leaf AAD with no version byte. They cannot be opened by -/// this build — however they were persisted (serde, `into_parts`, or raw -/// bytes) they fail AEAD verification with a plain authentication error, -/// indistinguishable from tampering, because there is no version byte in the -/// old form to raise [`LeafBytesError::UnknownVersion`] against. Acceptable -/// only because the crate is `publish = false` and only dev-persisted data -/// exists; from `FORMAT_VERSION` onwards a format move is signalled by the -/// version byte instead. #[derive(Debug, Serialize, Deserialize)] #[serde(try_from = "SealedValueRepr")] pub struct SealedValue { @@ -579,6 +565,7 @@ pub struct SealedValue { /// (that is the AEAD open's job); a leaf that fails here was never a valid /// v1 encoding at all. #[derive(Debug, PartialEq, Eq, thiserror::Error)] +#[non_exhaustive] pub enum LeafBytesError { /// The leading version byte is not one this build knows how to parse. /// (A version this build *does* know, stamped on bytes sealed under a diff --git a/packages/stack-encrypt/src/sem/mod.rs b/packages/stack-encrypt/src/sem/mod.rs index f3b7cb158..f1eff2fdb 100644 --- a/packages/stack-encrypt/src/sem/mod.rs +++ b/packages/stack-encrypt/src/sem/mod.rs @@ -152,6 +152,7 @@ const OPE_KEY_DOMAIN: &[u8] = b"stack-encrypt/sem/ope-key/v1"; /// Errors from SEM term generation. #[derive(Debug, thiserror::Error)] +#[non_exhaustive] pub enum TermError { /// The PRF backend failed (for a remote 2-party backend this includes /// transport errors). @@ -196,6 +197,7 @@ pub enum TermError { /// decoding has an error a caller can compare: the generation variants carry /// boxed and opaque sources that are not [`PartialEq`]. #[derive(Debug, PartialEq, Eq, thiserror::Error)] +#[non_exhaustive] pub enum TermBytesError { /// Equality-term bytes are not the 32 PRF bytes. #[error("equality-term bytes must be exactly 32 bytes, got {0}")] From 6a981c2abbba488b34d8d623b7b1796ff40bd7f0 Mon Sep 17 00:00:00 2001 From: Dan Draper <dan@cipherstash.com> Date: Mon, 31 Aug 2026 16:42:36 +1000 Subject: [PATCH 487/686] fix(stack-encrypt): reject oversized key tags on the seal path The seal path constructed SealedValue directly from the DataKeyWithTag a DataKeySource returned, without the tag_fits_length_field check every other construction site applies. A custom source returning a tag longer than the u16 length field would (in release builds) produce a leaf whose to_bytes saturates the length field but appends the whole tag, so from_bytes no longer inverts the encoding. seal_leaf now validates the generated tag before building the leaf, and a seal-path boundary test drives encrypt through a DataKeySource that inflates its tags past u16::MAX, asserting the seal fails rather than mis-encodes. Claude-Session: https://claude.ai/code/session_01P5YHK3w6Kj9ajTnmkaXCHW --- packages/stack-encrypt/src/cipher.rs | 27 +++++--- packages/stack-encrypt/tests/frozen_bytes.rs | 71 +++++++++++++++++++- 2 files changed, 87 insertions(+), 11 deletions(-) diff --git a/packages/stack-encrypt/src/cipher.rs b/packages/stack-encrypt/src/cipher.rs index adcf028b8..4864ab59c 100644 --- a/packages/stack-encrypt/src/cipher.rs +++ b/packages/stack-encrypt/src/cipher.rs @@ -51,7 +51,7 @@ //! that key by [`vitaminc_encrypt::Aes256Cipher`] (AES-256-GCM via vitaminc's //! backend: `aws-lc-rs` on native, RustCrypto on wasm32; vitaminc's own random //! nonce and versioned leaf layout). The leaf AAD is the labelled derivation -//! [`leaf_aad`]: `PAE("stack-encrypt/leaf", version, derived_aad, tag)`, with +//! `leaf_aad` (private): `PAE("stack-encrypt/leaf", version, derived_aad, tag)`, with //! [`SealedValue::FORMAT_VERSION`] — the version byte that prefixes the //! leaf's frozen byte encoding ([`SealedValue::to_bytes`]) — bound under the //! tag, so a stored leaf relabelled with a different version byte fails @@ -182,7 +182,7 @@ impl From<Unspecified> for Error { /// The CipherStash cipher: a vitaminc [`Cipher`] whose per-leaf keys are ZeroKMS /// data keys, sourced through a [`DataKeySource`] (production: -/// [`StackKms`](stack_kms::StackKms); tests: `stack_kms::FakeDataKeySource`), +/// [`stack_kms::StackKms`]; tests: `stack_kms::FakeDataKeySource`), /// carrying the per-keyset PRF that /// [Searchable Encrypted Metadata](crate::sem) terms are derived from. /// @@ -381,7 +381,7 @@ impl StackCipherBuilder<FromEnv> { /// from the environment. /// /// This is the seam for a custom authentication strategy: build a - /// [`StackKms`](stack_kms::StackKms) — with `stack_kms::StackKmsBuilder`, + /// [`stack_kms::StackKms`] — with `stack_kms::StackKmsBuilder`, /// or over the host's own transport — and hand it over. It is also how /// tests inject `stack_kms::FakeDataKeySource`. pub fn kms<K>(self, kms: K) -> StackCipherBuilder<K> { @@ -577,10 +577,12 @@ pub enum LeafBytesError { /// the `tag_len` field promises. #[error("sealed-leaf bytes are truncated")] Truncated, - /// The key tag does not fit the format's `u16` length field. Never - /// produced by sealing (ZeroKMS tags are tens of bytes); only reachable - /// through [`SealedValue::from_parts`] or `serde` deserialisation with an - /// oversized tag — both reject it, so a live `SealedValue` always encodes. + /// The key tag does not fit the format's `u16` length field. Every + /// construction site rejects an oversized tag — [`SealedValue::from_parts`] + /// and `serde` deserialisation with this error, and the seal path (where a + /// custom [`DataKeySource`] could return one; real ZeroKMS tags are tens + /// of bytes) by failing the encrypt — so a live `SealedValue` always + /// encodes. #[error("key tag of {0} bytes exceeds the format's u16 length field")] TagTooLong(usize), } @@ -600,8 +602,9 @@ impl SealedValue { /// long for the `u16` length field ([`LeafBytesError::TagTooLong`]), so a /// value that exists always encodes. pub fn to_bytes(&self) -> Vec<u8> { - // Exact by the `tag_fits_length_field` check every constructor - // applies; sealing never comes close (ZeroKMS tags are tens of bytes). + // Exact by the `tag_fits_length_field` check every construction site + // applies — `from_parts`, serde deserialisation, and the seal path + // (which guards against a `DataKeySource` returning an oversized tag). // The saturating fallback is unreachable, and asserted so in tests. let tag_len = u16::try_from(self.tag.len()).unwrap_or(u16::MAX); debug_assert_eq!(usize::from(tag_len), self.tag.len()); @@ -964,6 +967,12 @@ fn seal_leaf( aad: &Aad<'_>, key: DataKeyWithTag, ) -> Result<SealedValue, Unspecified> { + // The `DataKeySource` is caller-supplied, so the key tag is not trusted + // to fit the frozen byte format's `u16` length field: an oversized tag + // must fail here — the last construction site — or `to_bytes` would emit + // a length field that no longer frames the tag and `from_bytes` would + // stop inverting it. + SealedValue::tag_fits_length_field(&key.tag).map_err(|_| Unspecified)?; let iv = key.key.iv; let cipher = leaf_cipher(&key.key)?; match (&cipher).encrypt_bytes_vec(plaintext, leaf_aad(aad, &key.tag))? { diff --git a/packages/stack-encrypt/tests/frozen_bytes.rs b/packages/stack-encrypt/tests/frozen_bytes.rs index 077aec3a1..32819c50b 100644 --- a/packages/stack-encrypt/tests/frozen_bytes.rs +++ b/packages/stack-encrypt/tests/frozen_bytes.rs @@ -18,10 +18,16 @@ //! which maps to an integer-array column), and every binding decoding it //! silently stops agreeing. +use std::borrow::Cow; + use stack_encrypt::sem::{DefaultMatch, EqualityTerm, MatchTerm, OpeTerm, OreTerm, TermBytesError}; use stack_encrypt::target::EncryptInto; -use stack_encrypt::{CipherText, LeafBytesError, SealedValue, StackCipher}; -use stack_kms::FakeDataKeySource; +use stack_encrypt::{CipherText, Error, LeafBytesError, SealedValue, StackCipher}; +use stack_kms::{ + DataKey, DataKeySource, DataKeyWithTag, FakeDataKeySource, GenerateKeyPayload, IdentifiedBy, + IndexKey, IndexKeySource, RetrieveKeyPayload, UnverifiedContext, +}; +use uuid::Uuid; async fn cipher() -> StackCipher<FakeDataKeySource> { StackCipher::builder() @@ -143,6 +149,67 @@ async fn sealed_leaf_survives_persistence_via_bytes() { assert_eq!(pt, "durable"); } +/// Delegates to [`FakeDataKeySource`] but inflates every generated key tag +/// past the `u16` length field — the misbehaving custom [`DataKeySource`] the +/// seal path must reject, rather than build a leaf whose `to_bytes` writes a +/// saturated length field that `from_bytes` no longer inverts. +struct OversizedTagSource(FakeDataKeySource); + +impl DataKeySource for OversizedTagSource { + async fn generate_keys( + &self, + payloads: Vec<GenerateKeyPayload<'_>>, + keyset_id: Option<Uuid>, + unverified_context: Option<Cow<'_, UnverifiedContext>>, + ) -> Result<Vec<DataKeyWithTag>, stack_kms::Error> { + let mut keys = self + .0 + .generate_keys(payloads, keyset_id, unverified_context) + .await?; + for key in &mut keys { + key.tag = vec![0; usize::from(u16::MAX) + 1]; + } + Ok(keys) + } + + async fn retrieve_keys( + &self, + payloads: Vec<RetrieveKeyPayload<'_>>, + keyset_id: Option<Uuid>, + unverified_context: Option<&UnverifiedContext>, + ) -> Result<Vec<DataKey>, stack_kms::Error> { + self.0 + .retrieve_keys(payloads, keyset_id, unverified_context) + .await + } +} + +impl IndexKeySource for OversizedTagSource { + async fn load_index_key( + &self, + keyset_id: Option<IdentifiedBy>, + ) -> Result<(Uuid, IndexKey), stack_kms::Error> { + self.0.load_index_key(keyset_id).await + } +} + +#[tokio::test] +async fn seal_rejects_a_key_tag_the_length_field_cannot_frame() { + let cipher = StackCipher::builder() + .kms(OversizedTagSource(FakeDataKeySource::new())) + .init() + .await + .expect("build cipher"); + + let result = cipher + .encrypt("boundary".to_string(), b"ctx".as_slice()) + .await; + assert!( + matches!(result, Err(Error::Aead)), + "an oversized key tag must fail the seal, not mis-encode: {result:?}" + ); +} + // ============================================================================= // Terms // ============================================================================= From 586d177f6a4fa079f76e3faa16fdd4acd58d7f43 Mon Sep 17 00:00:00 2001 From: Dan Draper <dan@cipherstash.com> Date: Mon, 31 Aug 2026 16:42:45 +1000 Subject: [PATCH 488/686] docs(stack-encrypt): make the cipher module public so its docs render MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The cipher module was private with its items re-exported at the crate root, so the 68 lines of module-level internals documentation (batching, AAD derivation, wire format) never appeared in cargo doc output — the crate docs pointed readers at src/cipher.rs source instead. The module is now pub (matching sem and target) and the crate docs link to it. The rustdoc lints this surfaces (a link to the private leaf_aad, two redundant explicit StackKms link targets) were fixed in the previous commit; cargo doc is warning-free with and without default features. Claude-Session: https://claude.ai/code/session_01P5YHK3w6Kj9ajTnmkaXCHW --- packages/stack-encrypt/src/lib.rs | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/packages/stack-encrypt/src/lib.rs b/packages/stack-encrypt/src/lib.rs index 375d64e15..2a42b745d 100644 --- a/packages/stack-encrypt/src/lib.rs +++ b/packages/stack-encrypt/src/lib.rs @@ -133,11 +133,10 @@ assert_eq!(plaintext, "secret message"); //! per-leaf nonce vitaminc generates itself). The ZeroKMS `iv` a [`SealedValue`] //! carries is *not* that nonce: it identifies the data key, and is sent back to //! ZeroKMS with the key `tag` to re-derive it. The types a caller needs from -//! vitaminc are re-exported here. The module-level docs in -//! `src/cipher.rs` describe the internals (batching, AAD derivation, wire -//! format). +//! vitaminc are re-exported here. The [`cipher`] module docs describe the +//! internals (batching, AAD derivation, wire format). -mod cipher; +pub mod cipher; pub mod sem; pub mod target; From d9f4c9f376d4fb90e80354e0dd8518707d2da527 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Fri, 28 Aug 2026 13:54:49 +0000 Subject: [PATCH 489/686] feat(stack-encrypt): expose pending-tree batching and the CLLW term traits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two additive API changes the WASI guest (next commit) needs: - PendingStackCipherText::into_pending — turn a pending tree into a Pending request carrier without settling it, so several independently built trees (e.g. one per record field, decoded from FFI values that are not Clone) merge with Pending::zip/all and seal in one batched generate_keys call. seal() is now expressed through it; same sealing path either way. - sem re-exports CllwOreEncrypt/CllwOpeEncrypt: they already appear in the module's public bounds (ore_term, OreTerm, ...), so a caller writing a generic wrapper over the term APIs has to be able to name them without depending on cllw-ore directly. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YDes81qQM2ConLobY5G5JD --- packages/stack-encrypt/src/cipher.rs | 20 +++++++++++++++++++- packages/stack-encrypt/src/sem/mod.rs | 6 +++++- 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/packages/stack-encrypt/src/cipher.rs b/packages/stack-encrypt/src/cipher.rs index 4864ab59c..7e908bfff 100644 --- a/packages/stack-encrypt/src/cipher.rs +++ b/packages/stack-encrypt/src/cipher.rs @@ -856,7 +856,25 @@ impl PendingStackCipherText { self, cipher: &StackCipher<K>, ) -> Result<StackCipherText, Error> { - crate::target::seal_pending(cipher, self).settle().await + self.into_pending(cipher).settle().await + } + + /// Turn this tree into a [`Pending`](crate::target::Pending) request + /// carrier without settling it. + /// + /// [`seal`](Self::seal) is this plus an immediate settle — one ZeroKMS + /// call per tree. `into_pending` exists for callers that hold *several* + /// independently built trees (each from its own [`Encrypt`] drive, e.g. + /// one per record field in a language binding) and want them merged with + /// [`Pending::zip`](crate::target::Pending::zip) / + /// [`Pending::all`](crate::target::Pending::all) so the whole assembly + /// seals in **one** batched `generate_keys` call. Same sealing path + /// either way. + pub fn into_pending<K>( + self, + cipher: &StackCipher<K>, + ) -> crate::target::Pending<'_, StackCipherText, K> { + crate::target::seal_pending(cipher, self) } /// Recursively seal, drawing one key per leaf from `keys` in traversal order. diff --git a/packages/stack-encrypt/src/sem/mod.rs b/packages/stack-encrypt/src/sem/mod.rs index f1eff2fdb..9c0d860fa 100644 --- a/packages/stack-encrypt/src/sem/mod.rs +++ b/packages/stack-encrypt/src/sem/mod.rs @@ -120,7 +120,11 @@ pub use tokenize::Tokenizer; use std::fmt; use std::marker::PhantomData; -use cllw_ore::{CllwOpeEncrypt, CllwOreEncrypt}; +// Re-exported because they appear in this module's public bounds +// ([`StackCipher::ore_term`], [`OreTerm`], ...): a caller writing a generic +// wrapper over the term APIs has to be able to name them without depending +// on `cllw-ore` directly. +pub use cllw_ore::{CllwOpeEncrypt, CllwOreEncrypt}; use vitaminc_hmac::HmacSha256Prf; use vitaminc_prf::{ BlockVisitor, IntoPrfContext, MapAccess, PrfContext, PrfError, PrfValue, PrfVisitor, From 67105b0a36c0a4df2eb81920ca11e210517e2191 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Fri, 28 Aug 2026 13:55:06 +0000 Subject: [PATCH 490/686] feat(wasi): stack-encrypt guest module for the Go/wazero binding (phase 3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The wasm32-wasip1 guest at bindings/go/stackencrypt/guest (a detached workspace, like the fuzz crates), per phase 3 of docs/plans/stack-encrypt-go-bindings.md. Control stays in Rust: request assembly, key derivation, batching and AAD/PRF context binding run unmodified inside the guest; the host provides exactly two imports. - Exports (vitaminc guest ABI conventions: buffer registry with zeroizing dealloc, packed-u64 results, hostile-input validation, no handle-id reuse): se_alloc/se_dealloc, se_cipher_init/se_cipher_free, se_encrypt/se_decrypt (+_element), se_encrypt_record/se_decrypt_record, se_term. Status codes 1-4 match vitaminc's; 5-11 map the ZeroKMS request outcomes and term failures so the Go caller can tell a bad token from a tampered ciphertext. - Host imports (module cipherstash_transport): transport_send — cipherstash/cipherstash-suite#2099's import generalised to (method, url, headers, body) with 'name: value' line headers — and token_get (phase-1 auth: the host owns minting and refresh). WasiHostConnection implements stack_kms::ZeroKMSConnection over it, with the endpoint pinned from the init config or discovered from the token's services claim; HostTokenStrategy implements stack_auth::AuthStrategy over token_get. - Values cross in the vitaminc FFI codec (one codec, shared with the vitaminc guest); ciphertext-tree leaves are the frozen phase-2 SealedValue byte encoding, so a leaf lifted out of a tree is exactly what a database column holds. - Records: a plan {field -> {context, outputs: [c|eq|match|ore|ope]}} drives per-field ciphertexts and locally derived terms; all rows of a batch seal in one generate_keys via the new PendingStackCipherText::into_pending. Terms ride the result tree as passthrough bytes nodes. - Native tests (26) run the same ops the ABI drives against FakeDataKeySource: codec round trips, native-decryptable leaves, term bytes equal to the native sem derivations, a counting key source pinning the one-call batching, and status mapping for hostile inputs. The release .wasm's import surface is exactly WASI + cipherstash_transport. - mise tasks: wasm:guest:build, wasm:guest:test. Extracting the shared ABI modules into a common vitaminc crate is a follow-up in the vitaminc repository; the registry/session modules here are copies with pointers back. bridge.go and the integration harness land with the Go module (phases 4-5). Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YDes81qQM2ConLobY5G5JD --- docs/plans/stack-encrypt-go-bindings.md | 34 +- .../golang/stackencrypt/guest/.gitignore | 1 + .../golang/stackencrypt/guest/Cargo.lock | 3010 +++++++++++++++++ .../golang/stackencrypt/guest/Cargo.toml | 54 + .../golang/stackencrypt/guest/src/abi.rs | 428 +++ .../golang/stackencrypt/guest/src/buffers.rs | 101 + .../golang/stackencrypt/guest/src/config.rs | 244 ++ .../golang/stackencrypt/guest/src/headers.rs | 68 + .../golang/stackencrypt/guest/src/host.rs | 250 ++ .../golang/stackencrypt/guest/src/lib.rs | 58 + .../golang/stackencrypt/guest/src/ops.rs | 713 ++++ .../golang/stackencrypt/guest/src/response.rs | 228 ++ .../golang/stackencrypt/guest/src/sessions.rs | 89 + .../golang/stackencrypt/guest/src/status.rs | 180 + .../stackencrypt/guest/tests/native_ops.rs | 564 +++ 15 files changed, 6021 insertions(+), 1 deletion(-) create mode 100644 languages/golang/stackencrypt/guest/.gitignore create mode 100644 languages/golang/stackencrypt/guest/Cargo.lock create mode 100644 languages/golang/stackencrypt/guest/Cargo.toml create mode 100644 languages/golang/stackencrypt/guest/src/abi.rs create mode 100644 languages/golang/stackencrypt/guest/src/buffers.rs create mode 100644 languages/golang/stackencrypt/guest/src/config.rs create mode 100644 languages/golang/stackencrypt/guest/src/headers.rs create mode 100644 languages/golang/stackencrypt/guest/src/host.rs create mode 100644 languages/golang/stackencrypt/guest/src/lib.rs create mode 100644 languages/golang/stackencrypt/guest/src/ops.rs create mode 100644 languages/golang/stackencrypt/guest/src/response.rs create mode 100644 languages/golang/stackencrypt/guest/src/sessions.rs create mode 100644 languages/golang/stackencrypt/guest/src/status.rs create mode 100644 languages/golang/stackencrypt/guest/tests/native_ops.rs diff --git a/docs/plans/stack-encrypt-go-bindings.md b/docs/plans/stack-encrypt-go-bindings.md index 6aa085a6a..022720f75 100644 --- a/docs/plans/stack-encrypt-go-bindings.md +++ b/docs/plans/stack-encrypt-go-bindings.md @@ -7,7 +7,7 @@ > rustdoc and the tests are the source of truth. Where the two disagree, the > code wins and this document is simply out of date. -**Status:** in progress — Phases 0, 1 and 2 are open as stacked draft PRs on #2156 +**Status:** in progress — Phases 0 through 3 are open as stacked draft PRs on #2156 **Date:** 2026-08-27 **Builds on:** #2099 (WASI/wazero beachhead), #2156 (`#[derive(EncryptFrom, DecryptInto)]`), vitaminc `bindings/go` (`vcvalue` + `vcencrypt`) @@ -279,6 +279,38 @@ get decided and documented before the guest is written, independently of Go: ### Phase 3 — the guest +**Landed (stacked PR on Phase 2).** What shipped, against the plan below: +the crate at the planned location (detached workspace), exporting +`se_alloc`/`se_dealloc`, `se_cipher_init`/`se_cipher_free`, +`se_encrypt`/`se_decrypt` (+`_element`), `se_encrypt_record`/ +`se_decrypt_record`, and `se_term`, under the vitaminc guest's ABI +conventions (buffer registry with zeroizing dealloc, packed-`u64` results, +hostile-input validation; status codes 1–4 byte-identical to vitaminc's, +5–11 added for the ZeroKMS request outcomes and term failures). +`WasiHostConnection` implements `stack_kms::ZeroKMSConnection` over the +generalised `transport_send(method, url, headers, body)` import (headers as +`name: value` lines), with the endpoint pinned from the config or +discovered from the token's `services` claim via `ensure_base_url`; +`HostTokenStrategy` fetches the bearer token per request over `token_get`. +Records deviate from the sketch in two small ways: there is no separate +`aad` argument (each plan field's `context` *is* the AAD, as in the target +layer) and the result rides the ciphertext codec — per field a map of +output keys (`"c"`, `"eq"`, `"match"`, `"ore"`, `"ope"`) whose term nodes +are passthrough bytes. Batching all rows into one `generate_keys` goes +through a new public `PendingStackCipherText::into_pending` in +stack-encrypt (decoded `FfiValue`s are not `Clone`, so the `EncryptFrom` +path was not usable). Extracting the shared ABI into a `vitaminc-wasi-abi` +crate is out of this repo's reach and stays a vitaminc follow-up — the +registry/session modules are copies with a pointer back. The `bridge.go` +host function and the integration harness land with their consumer, the Go +module (Phases 4–5). Verified: native tests over `FakeDataKeySource` +(round trips, term-byte equality with the native `sem` calls, a counting +key source proving one ZeroKMS call per record batch), and the release +`.wasm` builds with an import surface of exactly WASI + +`cipherstash_transport` (`mise run wasm:guest:build` / `wasm:guest:test`). + +The plan as written before the work: + Location: `bindings/go/stackencrypt/guest/` (mirrors vitaminc's layout; detached workspace like #2099's guest and the fuzz crates so its wasm profile never leaks into workspace builds). Dependencies: `stack-encrypt`, diff --git a/languages/golang/stackencrypt/guest/.gitignore b/languages/golang/stackencrypt/guest/.gitignore new file mode 100644 index 000000000..ea8c4bf7f --- /dev/null +++ b/languages/golang/stackencrypt/guest/.gitignore @@ -0,0 +1 @@ +/target diff --git a/languages/golang/stackencrypt/guest/Cargo.lock b/languages/golang/stackencrypt/guest/Cargo.lock new file mode 100644 index 000000000..c1acefafb --- /dev/null +++ b/languages/golang/stackencrypt/guest/Cargo.lock @@ -0,0 +1,3010 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common 0.1.7", + "generic-array", +] + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures 0.2.17", +] + +[[package]] +name = "aes-gcm" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" +dependencies = [ + "aead", + "aes", + "cipher", + "ctr", + "ghash", + "subtle", + "zeroize", +] + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "android_system_properties" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" +dependencies = [ + "libc", +] + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "aquamarine" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f50776554130342de4836ba542aa85a4ddb361690d7e8df13774d7284c3d5c2" +dependencies = [ + "include_dir", + "itertools", + "proc-macro-error2", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" +dependencies = [ + "serde", + "zeroize", +] + +[[package]] +name = "atomic" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89cbf775b137e9b968e67227ef7f775587cde3fd31b0d8599dbd0f598a48340" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "aws-lc-rs" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce2b2dcc879c3bae0d371e77c99f2238400ef24ec001394befa67b6e543add9e" +dependencies = [ + "aws-lc-sys", + "untrusted", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.44.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f09fae7be8bb3174e05c6afdb34199e6dc0c7c04ba9fa237b1967adfbde27483" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", + "pkg-config", +] + +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + +[[package]] +name = "base32" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "022dfe9eb35f19ebbcb51e0b40a5ab759f46ad60cadf7297e0bd085afb50e076" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + +[[package]] +name = "bitvec" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddcec3d12c579d40898fe0a9a358a803c23e9c52ca3c425707f81c9436211837" +dependencies = [ + "funty", + "radium", + "tap", + "wyz", +] + +[[package]] +name = "blake3" +version = "1.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d9e454fc11f76977dc803893aff6304ed33d6a26efae8696573bea74baa27ae" +dependencies = [ + "arrayvec", + "cc", + "cfg-if", + "constant_time_eq", + "cpufeatures 0.3.1", + "zeroize", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", + "zeroize", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytemuck" +version = "1.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" +dependencies = [ + "serde", +] + +[[package]] +name = "cached" +version = "0.54.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9718806c4a2fe9e8a56fd736f97b340dd10ed1be8ed733ed50449f351dc33cae" +dependencies = [ + "ahash", + "cached_proc_macro", + "cached_proc_macro_types", + "hashbrown 0.14.5", + "once_cell", + "thiserror 1.0.69", + "web-time", +] + +[[package]] +name = "cached_proc_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f42a145ed2d10dce2191e1dcf30cfccfea9026660e143662ba5eec4017d5daa" +dependencies = [ + "darling 0.20.11", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "cached_proc_macro_types" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ade8366b8bd5ba243f0a58f036cc0ca8a2f069cff1a2351ef1cac6b083e16fc0" + +[[package]] +name = "cc" +version = "1.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ad534f4357a5264cce5019c989cf66a4f0dc4e0d1b1d15f8aacec0ff7360273" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "chacha20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "rand_core 0.10.1", +] + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "num-traits", + "serde", + "windows-link", +] + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common 0.1.7", + "inout", +] + +[[package]] +name = "cipherstash-config" +version = "0.42.3" +dependencies = [ + "bitflags", + "serde", + "serde_json", + "thiserror 1.0.69", +] + +[[package]] +name = "cllw-ore" +version = "0.4.3" +dependencies = [ + "blake3", + "hex", + "subtle", + "thiserror 1.0.69", + "unicode-normalization", + "zeroize", +] + +[[package]] +name = "cmac" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8543454e3c3f5126effff9cd44d562af4e31fb8ce1cc0d3dcd8f084515dbc1aa" +dependencies = [ + "cipher", + "dbl", + "digest 0.10.7", +] + +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + +[[package]] +name = "cmov" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" + +[[package]] +name = "const-hex" +version = "1.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33e2a781ebdf4467d1428dc4593067825fb646f6871475098d8577421af73558" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "proptest", + "serde_core", +] + +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + +[[package]] +name = "constant_time_eq" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" + +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher", +] + +[[package]] +name = "cts-common" +version = "0.42.3" +dependencies = [ + "arrayvec", + "base32", + "cached", + "chrono", + "derive_more", + "either", + "getrandom 0.4.3", + "miette", + "nom", + "regex", + "serde", + "serde_json", + "thiserror 1.0.69", + "tracing", + "url", + "utoipa", + "uuid", + "vitaminc", +] + +[[package]] +name = "ctutils" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" +dependencies = [ + "cmov", +] + +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core 0.20.11", + "darling_macro 0.20.11", +] + +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core 0.23.0", + "darling_macro 0.23.0", +] + +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.119", +] + +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.119", +] + +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core 0.20.11", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core 0.23.0", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "dbl" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd2735a791158376708f9347fe8faba9667589d82427ef3aed6794a8981de3d9" +dependencies = [ + "generic-array", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.119", + "unicode-xid", +] + +[[package]] +name = "deunicode" +version = "1.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abd57806937c9cc163efc8ea3910e00a62e2aeb0b8119f1793a978088f8f6b04" + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer 0.10.4", + "crypto-common 0.1.7", + "subtle", +] + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.1", + "const-oid", + "crypto-common 0.2.2", + "ctutils", + "zeroize", +] + +[[package]] +name = "dirs" +version = "4.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca3aa72a6f96ea37bbc5aa912f6788242832f75369bdfdadcb0e38423f100059" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b1d1d91c932ef41c0f2663aa8b0ca0342d444d842c06914aa0a7e352d0bada6" +dependencies = [ + "libc", + "redox_users", + "winapi", +] + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "dummy" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1cac124e13ae9aa56acc4241f8c8207501d93afdd8d8e62f0c1f2e12f6508c65" +dependencies = [ + "darling 0.20.11", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "either" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" +dependencies = [ + "serde", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fake" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d391ba4af7f1d93f01fcf7b2f29e2bc9348e109dfdbf4dcbdc51dfa38dab0b6" +dependencies = [ + "deunicode", + "dummy", + "rand 0.8.8", + "uuid", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + +[[package]] +name = "funty" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" + +[[package]] +name = "futures" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-executor" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[package]] +name = "futures-macro" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "gethostname" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc3655aa6818d65bc620d6911f05aa7b6aeb596291e1e9f79e52df85583d1e30" +dependencies = [ + "rustix", + "windows-targets", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 6.0.0", + "rand_core 0.10.1", + "wasm-bindgen", +] + +[[package]] +name = "ghash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" +dependencies = [ + "opaque-debug", + "polyval", +] + +[[package]] +name = "half" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b43ede17f21864e81be2fa654110bf1e793774238d86ef8555c37e6519c0403" + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash", + "allocator-api2", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hex-literal" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ebdb29d2ea9ed0083cd8cece49bbd968021bd99b0849edb4a9a7ee0fdf6a4e0" + +[[package]] +name = "hmac" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" +dependencies = [ + "digest 0.11.3", +] + +[[package]] +name = "hybrid-array" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "707114b52a152fa7bdb290cd7cd5912d9467273b6d74e21b8d81aca1f8533f6b" +dependencies = [ + "typenum", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" + +[[package]] +name = "icu_properties" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" + +[[package]] +name = "icu_provider" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "include_dir" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "923d117408f1e49d914f1a379a309cffe4f18c05cf4e3d12e613a15fc81bd0dd" +dependencies = [ + "include_dir_macros", +] + +[[package]] +name = "include_dir_macros" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cab85a7ed0bd5f0e76d93846e0147172bed2e2d3f859bcc33a8d9699cad1a75" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + +[[package]] +name = "is-docker" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3" +dependencies = [ + "once_cell", +] + +[[package]] +name = "is-wsl" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5" +dependencies = [ + "is-docker", + "once_cell", +] + +[[package]] +name = "itertools" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jobserver" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +dependencies = [ + "getrandom 0.4.3", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "jsonwebtoken" +version = "10.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eba32bfb4ffdeaca3e34431072faf01745c9b26d25504aa7a6cf5684334fc4fc" +dependencies = [ + "aws-lc-rs", + "base64", + "getrandom 0.2.17", + "js-sys", + "pem", + "serde", + "serde_json", + "signature", + "simple_asn1", + "zeroize", +] + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "libredox" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7955dfc218a8afb29dfeffd540e3a6e96baeb94fe7138228dd7cc6937fbbf96" +dependencies = [ + "libc", +] + +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest 0.10.7", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "miette" +version = "7.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f98efec8807c63c752b5bd61f862c165c115b0a35685bdcfd9238c7aeb592b7" +dependencies = [ + "cfg-if", + "miette-derive", + "unicode-width", +] + +[[package]] +name = "miette-derive" +version = "7.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db5b29714e950dbb20d5e6f74f9dcec4edbcc1067bb7f8ed198c097b8c1a818b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "mio" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "mutants" +version = "0.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "add0ac067452ff1aca8c5002111bd6b1c895baee6e45fcbc44e0193aea17be56" + +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + +[[package]] +name = "num-bigint" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-integer" +version = "0.1.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + +[[package]] +name = "open" +version = "5.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ade3be4664bc1ef537ce133015f04c176b737815c2ba9fd60edf212d6e90dd55" +dependencies = [ + "is-wsl", + "libc", +] + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64", + "serde_core", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkg-config" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + +[[package]] +name = "polyval" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "opaque-debug", + "universal-hash", +] + +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro-error-attr2" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "proc-macro-error-attr3" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0084e6206a967a2dad822180626b2f6b07a3b379325e8f1ec0438e33a469ba7" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "proc-macro-error2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802" +dependencies = [ + "proc-macro-error-attr2", + "proc-macro2", + "quote", +] + +[[package]] +name = "proc-macro-error3" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cf066225f2373bc711684792b69bdeac0356019b007e721090c24d92d5d5a50" +dependencies = [ + "proc-macro-error-attr3", + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "proptest" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744" +dependencies = [ + "bitflags", + "num-traits", + "rand 0.9.5", + "rand_chacha 0.9.0", + "rand_xorshift", + "regex-syntax", + "unarray", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "radium" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" + +[[package]] +name = "rand" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core 0.10.1", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_xorshift" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a" +dependencies = [ + "rand_core 0.9.5", +] + +[[package]] +name = "recipher" +version = "0.3.0" +dependencies = [ + "aes", + "cmac", + "getrandom 0.2.17", + "hex", + "hex-literal", + "opaque-debug", + "rand 0.8.8", + "rand_chacha 0.3.1", + "serde", + "serde_cbor", + "sha2 0.10.9", + "thiserror 1.0.69", + "zeroize", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 1.0.69", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "rmp" +version = "0.8.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ba8be72d372b2c9b35542551678538b562e7cf86c3315773cae48dfbfe7790c" +dependencies = [ + "num-traits", +] + +[[package]] +name = "rmp-serde" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f81bee8c8ef9b577d1681a70ebbc962c232461e397b22c208c43c04b67a155" +dependencies = [ + "rmp", + "serde", +] + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.59.0", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_bytes" +version = "0.11.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "serde_cbor" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bef2ebfde456fb76bbcf9f59315333decc4fda0b2b44b420243c11e0f5ec1f5" +dependencies = [ + "half", + "serde", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serdect" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f42f67da2385b51a5f9652db9c93d78aeaf7610bf5ec366080b6de810604af53" +dependencies = [ + "base16ct", + "serde", + "zeroize", +] + +[[package]] +name = "sha1_smol" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbfa15b3dddfee50a0fff136974b3e1bde555604ba463834a7eb7deb6417705d" + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "digest 0.11.3", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "rand_core 0.6.4", +] + +[[package]] +name = "simple_asn1" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d" +dependencies = [ + "num-bigint", + "num-traits", + "thiserror 2.0.20", + "time", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "stack-auth" +version = "0.42.3" +dependencies = [ + "aquamarine", + "base64", + "cts-common", + "jsonwebtoken", + "miette", + "open", + "serde", + "serde_json", + "stack-profile", + "thiserror 1.0.69", + "tokio", + "tracing", + "url", + "uuid", + "vitaminc", + "vitaminc-protected 0.2.0-pre.1 (registry+https://github.com/rust-lang/crates.io-index)", + "web-time", + "zeroize", + "zerokms-protocol", +] + +[[package]] +name = "stack-encrypt" +version = "0.1.0" +dependencies = [ + "cllw-ore", + "serde", + "stack-encrypt-derive", + "stack-kms", + "thiserror 1.0.69", + "uuid", + "vitaminc-aead 0.2.0-pre.1 (git+https://github.com/cipherstash/vitaminc?rev=04f4faa3eeeb69ae30dd0247865e06c640585fea)", + "vitaminc-encrypt 0.2.0-pre.1 (git+https://github.com/cipherstash/vitaminc?rev=04f4faa3eeeb69ae30dd0247865e06c640585fea)", + "vitaminc-hmac", + "vitaminc-prf", + "vitaminc-protected 0.2.0-pre.1 (git+https://github.com/cipherstash/vitaminc?rev=04f4faa3eeeb69ae30dd0247865e06c640585fea)", + "zeroize", +] + +[[package]] +name = "stack-encrypt-derive" +version = "0.1.0" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "stack-encrypt-guest" +version = "0.0.0" +dependencies = [ + "futures", + "recipher", + "serde", + "serde_json", + "stack-auth", + "stack-encrypt", + "stack-kms", + "uuid", + "vitaminc-aead-value", + "vitaminc-protected 0.2.0-pre.1 (git+https://github.com/cipherstash/vitaminc?rev=04f4faa3eeeb69ae30dd0247865e06c640585fea)", + "zeroize", + "zerokms-protocol", +] + +[[package]] +name = "stack-kms" +version = "0.1.0" +dependencies = [ + "base16ct", + "base64ct", + "blake3", + "futures", + "miette", + "opaque-debug", + "recipher", + "serde", + "serde_cbor", + "serde_json", + "serdect", + "sha2 0.10.9", + "stack-auth", + "stack-profile", + "thiserror 1.0.69", + "tracing", + "url", + "uuid", + "vitaminc", + "vitaminc-protected 0.2.0-pre.1 (registry+https://github.com/rust-lang/crates.io-index)", + "zeroize", + "zerokms-protocol", +] + +[[package]] +name = "stack-profile" +version = "0.42.3" +dependencies = [ + "dirs", + "gethostname", + "serde", + "serde_json", + "thiserror 1.0.69", + "uuid", +] + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tap" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +dependencies = [ + "thiserror-impl 2.0.20", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "time" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unarray" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common 0.1.7", + "subtle", +] + +[[package]] +name = "untrusted" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", + "serde_derive", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utoipa" +version = "5.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8bde15df68e80b16c7d16b9616e80770ad158988daa56a27dccd1e55558b0160" +dependencies = [ + "indexmap", + "serde", + "serde_json", + "utoipa-gen", +] + +[[package]] +name = "utoipa-gen" +version = "5.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba0b99ee52df3028635d93840c797102da61f8a7bb3cf751032455895b52ef8" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "url", + "uuid", +] + +[[package]] +name = "uuid" +version = "1.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5772d71c9be8a8a6ac2117d949c5b224c1b72241bb611d9a3012edcf8af7812" +dependencies = [ + "atomic", + "getrandom 0.4.3", + "js-sys", + "md-5", + "serde_core", + "sha1_smol", + "wasm-bindgen", +] + +[[package]] +name = "validator" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43fb22e1a008ece370ce08a3e9e4447a910e92621bb49b85d6e48a45397e7cfa" +dependencies = [ + "idna", + "once_cell", + "regex", + "serde", + "serde_derive", + "serde_json", + "url", + "validator_derive", +] + +[[package]] +name = "validator_derive" +version = "0.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240e4b81c20a1d6d50d1d7265c658dfbd204e8b9ac4d80f3c931f39462196335" +dependencies = [ + "darling 0.23.0", + "proc-macro-error3", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "vitaminc" +version = "0.2.0-pre.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d69481bc78bc3227d6c70d8aae6437c79badbf54fd9ec90c1b4ae2553068a989" +dependencies = [ + "vitaminc-aead 0.2.0-pre.1 (registry+https://github.com/rust-lang/crates.io-index)", + "vitaminc-encrypt 0.2.0-pre.1 (registry+https://github.com/rust-lang/crates.io-index)", + "vitaminc-protected 0.2.0-pre.1 (registry+https://github.com/rust-lang/crates.io-index)", + "vitaminc-random 0.2.0-pre.1 (registry+https://github.com/rust-lang/crates.io-index)", + "vitaminc-traits", +] + +[[package]] +name = "vitaminc-aead" +version = "0.2.0-pre.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be80f3a3d83e69a786b97a831d660449a0437ccac3b3e369bf590afcb45569b0" +dependencies = [ + "bytes", + "serde", + "vitaminc-protected 0.2.0-pre.1 (registry+https://github.com/rust-lang/crates.io-index)", + "vitaminc-random 0.2.0-pre.1 (registry+https://github.com/rust-lang/crates.io-index)", + "zeroize", +] + +[[package]] +name = "vitaminc-aead" +version = "0.2.0-pre.1" +source = "git+https://github.com/cipherstash/vitaminc?rev=04f4faa3eeeb69ae30dd0247865e06c640585fea#04f4faa3eeeb69ae30dd0247865e06c640585fea" +dependencies = [ + "bytes", + "serde", + "vitaminc-protected 0.2.0-pre.1 (git+https://github.com/cipherstash/vitaminc?rev=04f4faa3eeeb69ae30dd0247865e06c640585fea)", + "vitaminc-random 0.2.0-pre.1 (git+https://github.com/cipherstash/vitaminc?rev=04f4faa3eeeb69ae30dd0247865e06c640585fea)", + "zeroize", +] + +[[package]] +name = "vitaminc-aead-value" +version = "0.2.0-pre.1" +source = "git+https://github.com/cipherstash/vitaminc?rev=04f4faa3eeeb69ae30dd0247865e06c640585fea#04f4faa3eeeb69ae30dd0247865e06c640585fea" +dependencies = [ + "vitaminc-aead 0.2.0-pre.1 (git+https://github.com/cipherstash/vitaminc?rev=04f4faa3eeeb69ae30dd0247865e06c640585fea)", + "vitaminc-protected 0.2.0-pre.1 (git+https://github.com/cipherstash/vitaminc?rev=04f4faa3eeeb69ae30dd0247865e06c640585fea)", + "zeroize", +] + +[[package]] +name = "vitaminc-encrypt" +version = "0.2.0-pre.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7477ef8ac925a75aacf5dbddfd4b17fd32f35ee9fb4a7c45ac3db80fd9ad4006" +dependencies = [ + "aes-gcm", + "aws-lc-rs", + "vitaminc-aead 0.2.0-pre.1 (registry+https://github.com/rust-lang/crates.io-index)", + "vitaminc-protected 0.2.0-pre.1 (registry+https://github.com/rust-lang/crates.io-index)", + "vitaminc-random 0.2.0-pre.1 (registry+https://github.com/rust-lang/crates.io-index)", + "zeroize", +] + +[[package]] +name = "vitaminc-encrypt" +version = "0.2.0-pre.1" +source = "git+https://github.com/cipherstash/vitaminc?rev=04f4faa3eeeb69ae30dd0247865e06c640585fea#04f4faa3eeeb69ae30dd0247865e06c640585fea" +dependencies = [ + "aes-gcm", + "aws-lc-rs", + "vitaminc-aead 0.2.0-pre.1 (git+https://github.com/cipherstash/vitaminc?rev=04f4faa3eeeb69ae30dd0247865e06c640585fea)", + "vitaminc-protected 0.2.0-pre.1 (git+https://github.com/cipherstash/vitaminc?rev=04f4faa3eeeb69ae30dd0247865e06c640585fea)", + "vitaminc-random 0.2.0-pre.1 (git+https://github.com/cipherstash/vitaminc?rev=04f4faa3eeeb69ae30dd0247865e06c640585fea)", + "zeroize", +] + +[[package]] +name = "vitaminc-hmac" +version = "0.2.0-pre.1" +source = "git+https://github.com/cipherstash/vitaminc?rev=04f4faa3eeeb69ae30dd0247865e06c640585fea#04f4faa3eeeb69ae30dd0247865e06c640585fea" +dependencies = [ + "hmac", + "sha2 0.11.0", + "vitaminc-prf", + "vitaminc-protected 0.2.0-pre.1 (git+https://github.com/cipherstash/vitaminc?rev=04f4faa3eeeb69ae30dd0247865e06c640585fea)", + "zeroize", +] + +[[package]] +name = "vitaminc-prf" +version = "0.2.0-pre.1" +source = "git+https://github.com/cipherstash/vitaminc?rev=04f4faa3eeeb69ae30dd0247865e06c640585fea#04f4faa3eeeb69ae30dd0247865e06c640585fea" +dependencies = [ + "mutants", + "thiserror 2.0.20", + "vitaminc-protected 0.2.0-pre.1 (git+https://github.com/cipherstash/vitaminc?rev=04f4faa3eeeb69ae30dd0247865e06c640585fea)", +] + +[[package]] +name = "vitaminc-protected" +version = "0.2.0-pre.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8472e2b76b5dedaf429708393964c3cc6f7ee40e6a43ed420288e3e4900c6af" +dependencies = [ + "bitvec", + "digest 0.11.3", + "serde", + "serde_bytes", + "subtle", + "vitaminc-protected-derive 0.2.0-pre.1 (registry+https://github.com/rust-lang/crates.io-index)", + "zeroize", +] + +[[package]] +name = "vitaminc-protected" +version = "0.2.0-pre.1" +source = "git+https://github.com/cipherstash/vitaminc?rev=04f4faa3eeeb69ae30dd0247865e06c640585fea#04f4faa3eeeb69ae30dd0247865e06c640585fea" +dependencies = [ + "bitvec", + "digest 0.11.3", + "serde", + "serde_bytes", + "subtle", + "vitaminc-protected-derive 0.2.0-pre.1 (git+https://github.com/cipherstash/vitaminc?rev=04f4faa3eeeb69ae30dd0247865e06c640585fea)", + "zeroize", +] + +[[package]] +name = "vitaminc-protected-derive" +version = "0.2.0-pre.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b01e1715676d8bf606314c2a51df0793c01bd743bae4bc00643d68f766ee1e91" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "vitaminc-protected-derive" +version = "0.2.0-pre.1" +source = "git+https://github.com/cipherstash/vitaminc?rev=04f4faa3eeeb69ae30dd0247865e06c640585fea#04f4faa3eeeb69ae30dd0247865e06c640585fea" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "vitaminc-random" +version = "0.2.0-pre.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0785c13f839240523ba8db6535384a5e8d4fe2b2f28bbddcfcb5fd6de825996" +dependencies = [ + "getrandom 0.4.3", + "rand 0.10.2", + "thiserror 2.0.20", + "vitaminc-protected 0.2.0-pre.1 (registry+https://github.com/rust-lang/crates.io-index)", + "vitaminc-random-derives 0.2.0-pre.1 (registry+https://github.com/rust-lang/crates.io-index)", + "zeroize", +] + +[[package]] +name = "vitaminc-random" +version = "0.2.0-pre.1" +source = "git+https://github.com/cipherstash/vitaminc?rev=04f4faa3eeeb69ae30dd0247865e06c640585fea#04f4faa3eeeb69ae30dd0247865e06c640585fea" +dependencies = [ + "getrandom 0.4.3", + "rand 0.10.2", + "thiserror 2.0.20", + "vitaminc-protected 0.2.0-pre.1 (git+https://github.com/cipherstash/vitaminc?rev=04f4faa3eeeb69ae30dd0247865e06c640585fea)", + "vitaminc-random-derives 0.2.0-pre.1 (git+https://github.com/cipherstash/vitaminc?rev=04f4faa3eeeb69ae30dd0247865e06c640585fea)", + "zeroize", +] + +[[package]] +name = "vitaminc-random-derives" +version = "0.2.0-pre.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01e750eefb1f49940f589b2d397e2323d5df4b62bfb33b4e40e1d20a35c3f167" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "vitaminc-random-derives" +version = "0.2.0-pre.1" +source = "git+https://github.com/cipherstash/vitaminc?rev=04f4faa3eeeb69ae30dd0247865e06c640585fea#04f4faa3eeeb69ae30dd0247865e06c640585fea" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "vitaminc-traits" +version = "0.2.0-pre.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3794e2c028cff00f40caea05ab6dce38181a94e13c0aaee640e7b867369780eb" +dependencies = [ + "anyhow", + "bytes", + "rmp-serde", + "serde", + "thiserror 2.0.20", + "vitaminc-protected 0.2.0-pre.1 (registry+https://github.com/rust-lang/crates.io-index)", + "vitaminc-random 0.2.0-pre.1 (registry+https://github.com/rust-lang/crates.io-index)", + "zeroize", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.119", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[package]] +name = "wyz" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed" +dependencies = [ + "tap", +] + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zerokms-protocol" +version = "0.12.29" +dependencies = [ + "base64", + "cipherstash-config", + "const-hex", + "cts-common", + "fake", + "getrandom 0.2.17", + "opaque-debug", + "rand 0.8.8", + "serde", + "static_assertions", + "thiserror 1.0.69", + "utoipa", + "uuid", + "validator", + "zeroize", +] + +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/languages/golang/stackencrypt/guest/Cargo.toml b/languages/golang/stackencrypt/guest/Cargo.toml new file mode 100644 index 000000000..483899400 --- /dev/null +++ b/languages/golang/stackencrypt/guest/Cargo.toml @@ -0,0 +1,54 @@ +# stack-encrypt WASI guest: `StackCipher` over host-provided HTTP under +# WASI/wazero, embedded by the Go module one directory up (Phase 3/4 of +# docs/plans/stack-encrypt-go-bindings.md). +# +# Deliberately a standalone workspace (same pattern as the fuzz crates and +# vitaminc's guest): this crate targets wasm32-wasip1 and is consumed as a +# .wasm artifact, so its wasm-only profile and target must not leak into +# workspace builds. +# +# Build: mise run wasm:guest:build (or: cargo build --target wasm32-wasip1 --release) +[package] +name = "stack-encrypt-guest" +description = "WASI guest module exposing stack-encrypt to non-Rust hosts (Go/wazero)" +version = "0.0.0" +edition = "2021" +publish = false + +[workspace] + +[lib] +# cdylib: the .wasm guest module. rlib: lets the ops/config/status modules +# unit-test natively (`cargo test` here, no wasm toolchain needed). +crate-type = ["cdylib", "rlib"] + +[dependencies] +# The stack crates with default features off: no reqwest, no native TLS — +# HTTP comes from the host (see `wasm:wasi-check` in the suite root). +stack-auth = { path = "../../../../packages/stack-auth", default-features = false } +stack-encrypt = { path = "../../../../packages/stack-encrypt", default-features = false } +stack-kms = { path = "../../../../packages/stack-kms", default-features = false } +zerokms-protocol = { path = "../../../../packages/zerokms-protocol" } + +# The FFI codec + `FfiValue` tree, shared with vitaminc's own guest — one +# codec, not a fork. Same rev the suite pins for the other vitaminc crates. +vitaminc-aead-value = { git = "https://github.com/cipherstash/vitaminc", rev = "04f4faa3eeeb69ae30dd0247865e06c640585fea" } +vitaminc-protected = { git = "https://github.com/cipherstash/vitaminc", rev = "04f4faa3eeeb69ae30dd0247865e06c640585fea" } + +futures = { version = "0.3", default-features = false, features = ["executor"] } +serde = "1" +serde_json = "1" +uuid = "1" +zeroize = "1" + +[dev-dependencies] +stack-kms = { path = "../../../../packages/stack-kms", default-features = false, features = ["test-support"] } +# For minting a valid client-key fixture in the config tests. +recipher = { path = "../../../../packages/recipher" } + +[profile.release] +# Smaller .wasm; the guest is IO-bound on the FFI copy and the ZeroKMS round +# trip, not on codegen. +opt-level = "s" +lto = true +strip = true diff --git a/languages/golang/stackencrypt/guest/src/abi.rs b/languages/golang/stackencrypt/guest/src/abi.rs new file mode 100644 index 000000000..17ea50091 --- /dev/null +++ b/languages/golang/stackencrypt/guest/src/abi.rs @@ -0,0 +1,428 @@ +//! The wasm export surface. Same conventions as the vitaminc guest +//! (`vc_*`), under the `se_` prefix: +//! +//! - The host owns all buffer lifecycles. It writes inputs into guest +//! memory obtained from [`se_alloc`] and releases every buffer — its own +//! inputs and the guest's outputs — with [`se_dealloc`], which **zeroizes +//! before freeing**. The guest keeps a registry of every buffer it hands +//! out ([`crate::buffers`]), so `se_dealloc` never trusts the host's +//! length. Two entry points additionally wipe their *input* buffer in +//! place before returning: [`se_cipher_init`] (the config carries the +//! client key) and [`se_decrypt`]'s output is plaintext the host must +//! copy out and immediately `se_dealloc`. +//! - A cipher is a **handle**: [`se_cipher_init`] builds a +//! `StackCipher<StackKms<HostTokenStrategy, WasiHostConnection>>` (one +//! `load-keyset` round trip through the host transport — the index key +//! then lives in the guest), and [`se_cipher_free`] drops it (client key +//! and index key wiped by their own `ZeroizeOnDrop`). Handle ids are +//! never reused; at exhaustion `se_cipher_init` fails with +//! `STATUS_INTERNAL` rather than aliasing a live handle. +//! - During an entry-point call the host's imported functions may re-enter +//! the guest **only** through `se_alloc` (to place the transport response +//! / token); calling any other export from inside a host import is +//! undefined behaviour of the embedding, not of this module. +//! +//! # Result encoding +//! +//! Every fallible export returns a single `u64` split into a high and a low +//! 32-bit field: +//! +//! - **success** — the high 32 bits are non-zero: an output pointer with +//! the low 32 bits its length, or (for [`se_cipher_init`]) the handle +//! with the low bits unused. +//! - **error** — the high 32 bits are zero and the low 32 bits are a +//! [`crate::status`] code. A valid pointer / handle is never zero, so +//! the two spaces never collide. +//! +//! # Hostile-input posture +//! +//! As the vitaminc guest: every export validates its pointer/length pairs +//! against linear memory before any unsafe construction (null with nonzero +//! length rejected; a null AAD must not silently become an empty AAD), +//! invalid input yields `STATUS_ENCODING` rather than a trap, and the +//! `catch_unwind` at each export is belt-and-braces for a hypothetical +//! unwind build — wasm32-wasip1 aborts on panic. Statuses are the only +//! detail leaked. +//! +//! Wasm modules are single-threaded; the host must serialize calls into one +//! instance. + +use std::cell::RefCell; +use std::panic::{catch_unwind, AssertUnwindSafe}; + +use futures::executor::block_on; +use stack_encrypt::StackCipher; +use stack_kms::{ClientOpts, StackKms}; +use vitaminc_aead_value::transport as codec; +use zeroize::Zeroize; + +use crate::buffers; +use crate::config::parse_config; +use crate::host::{HostTokenStrategy, WasiHostConnection}; +use crate::ops; +use crate::sessions::Sessions; +use crate::status::{STATUS_BAD_HANDLE, STATUS_ENCODING, STATUS_INTERNAL, STATUS_KMS_TRANSPORT}; + +/// The cipher a handle names: `stack-encrypt` over the host-transport +/// ZeroKMS client with host-supplied tokens. +type GuestCipher = StackCipher<StackKms<HostTokenStrategy, WasiHostConnection>>; + +thread_local! { + // Wasm is single-threaded, so a thread-local `RefCell` is a plain owner + // of the session table — no `Send`/`Sync` bounds required. + static SESSIONS: RefCell<Sessions<GuestCipher>> = RefCell::new(Sessions::new()); +} + +/// Allocate `len` bytes of guest memory for the host to write into. Returns +/// null if the allocation fails (recoverable host-side; never a trap). +#[no_mangle] +pub extern "C" fn se_alloc(len: u32) -> *mut u8 { + buffers::alloc(len as usize) +} + +/// Zeroize and free a buffer previously handed out by [`se_alloc`] or +/// packed into a result. See [`crate::buffers::dealloc`] for the registry +/// discipline (unknown pointer: no-op; length mismatch: refused). +/// +/// # Safety +/// +/// `ptr` should be a pointer this module handed out; the registry makes +/// anything else a no-op rather than undefined behaviour. +#[no_mangle] +pub unsafe extern "C" fn se_dealloc(ptr: *mut u8, len: u32) { + unsafe { buffers::dealloc(ptr, len as usize) } +} + +/// Pack a buffer result: `ptr << 32 | len`. The buffer is registered so the +/// host's eventual [`se_dealloc`] wipes and frees exactly what was +/// allocated. +fn ok_buffer(out: Vec<u8>) -> u64 { + let len = out.len() as u64; + let ptr = buffers::register(out) as usize as u64; + (ptr << 32) | len +} + +/// Pack a handle result: `handle << 32`. Handles start at 1, so the high 32 +/// bits are non-zero; the low bits are unused. +fn ok_handle(handle: u32) -> u64 { + (handle as u64) << 32 +} + +/// Pack an error: the status in the low 32 bits, high bits zero. +fn err_status(status: u32) -> u64 { + status as u64 +} + +/// Current linear-memory size in bytes. `u64` because a full 4 GiB memory +/// (65536 pages) overflows a 32-bit `usize`. +fn linear_memory_bytes() -> u64 { + core::arch::wasm32::memory_size::<0>() as u64 * 65536 +} + +/// Borrow a host-supplied `(ptr, len)` pair, validating before any slice +/// exists: null-with-nonzero-length is rejected (treating it as empty would +/// silently drop an AAD context binding), the length must be under +/// `isize::MAX`, and the whole range must lie inside the current linear +/// memory. A pair that fails validation yields `STATUS_ENCODING`; a pair +/// that passes can still name the wrong bytes — the host owns its pointers +/// — but can never fault or over-read past linear memory. +fn input<'a>(ptr: *const u8, len: u32) -> Result<&'a [u8], u32> { + let len = len as usize; + if len == 0 { + return Ok(&[]); + } + if ptr.is_null() || len > isize::MAX as usize { + return Err(STATUS_ENCODING); + } + let end = (ptr as usize).checked_add(len).ok_or(STATUS_ENCODING)?; + if end as u64 > linear_memory_bytes() { + return Err(STATUS_ENCODING); + } + // SAFETY: non-null, in-bounds of linear memory, and under `isize::MAX`; + // wasm linear memory is fully initialized (fresh pages are zero), so + // reading the range as bytes is defined. + Ok(unsafe { std::slice::from_raw_parts(ptr, len) }) +} + +/// Zeroize a validated input range in place (without freeing it — the host +/// still owns the buffer and will `se_dealloc` it after the call). +/// +/// # Safety +/// +/// The range must have passed [`input`] validation and carry no outstanding +/// borrows. +unsafe fn wipe_input(ptr: *mut u8, len: u32) { + if ptr.is_null() || len == 0 { + return; + } + unsafe { std::slice::from_raw_parts_mut(ptr, len as usize) }.zeroize(); +} + +/// Run `f` with the cipher bound to `handle`, or report `STATUS_BAD_HANDLE`. +fn with_cipher<R>(handle: u32, f: impl FnOnce(&GuestCipher) -> Result<R, u32>) -> Result<R, u32> { + SESSIONS.with(|s| { + let s = s.borrow(); + let cipher = s.get(handle).ok_or(STATUS_BAD_HANDLE)?; + f(cipher) + }) +} + +/// Initialise a cipher from an FFI-codec-encoded config object (see +/// [`crate::config`]), returning a handle. Performs one `load-keyset` +/// round trip through the host transport; the raw config buffer — which +/// carries the client-key hex — is wiped in place before any network +/// traffic, whatever the outcome. +/// +/// # Safety +/// +/// `cfg_ptr`/`cfg_len` should name the buffer the host wrote the config +/// into. The guest bounds-checks the range against linear memory — a bad +/// pair returns `STATUS_ENCODING` instead of faulting — but cannot verify +/// the bytes are the ones the host intended. +#[no_mangle] +pub unsafe extern "C" fn se_cipher_init(cfg_ptr: *mut u8, cfg_len: u32) -> u64 { + catch_unwind(AssertUnwindSafe(|| { + let decoded = codec::decode_value(&mut codec::Reader::new(input(cfg_ptr, cfg_len)?)) + .map_err(|_| STATUS_ENCODING); + // The borrow of the raw buffer ends with `decoded` owned; wipe the + // buffer now — it holds the client-key hex — before parsing (and + // before the init round trip), whatever the decode outcome. + unsafe { wipe_input(cfg_ptr, cfg_len) }; + cipher_init(decoded?) + })) + .unwrap_or(Err(STATUS_INTERNAL)) + .map_or_else(err_status, ok_handle) +} + +fn cipher_init(decoded: vitaminc_aead_value::FfiValue) -> Result<u32, u32> { + let config = parse_config(decoded).map_err(|_| STATUS_ENCODING)?; + + // One request at a time: the host import is synchronous, so concurrency + // would only interleave nothing; keep the executor honest about it. + let opts = ClientOpts::new(config.endpoint) + .with_max_concurrent_reqs(1) + .map_err(|_| STATUS_INTERNAL)?; + let kms = StackKms::<HostTokenStrategy, WasiHostConnection>::connect( + opts, + HostTokenStrategy, + config.client_key, + ) + .map_err(|_| STATUS_KMS_TRANSPORT)?; + + let mut builder = StackCipher::builder().kms(kms); + if let Some(keyset) = config.keyset { + builder = builder.keyset(keyset); + } + let cipher = block_on(builder.init()).map_err(|e| crate::status::status_for_error(&e))?; + SESSIONS.with(|s| s.borrow_mut().insert(cipher)) +} + +/// Drop a cipher handle. The client key and the keyset's index key are +/// wiped by their own `ZeroizeOnDrop`. Freeing an unknown handle is a +/// no-op. +#[no_mangle] +pub extern "C" fn se_cipher_free(handle: u32) { + let _ = catch_unwind(AssertUnwindSafe(|| { + SESSIONS.with(|s| { + s.borrow_mut().remove(handle); + }); + })); +} + +/// Encrypt an FFI-codec-encoded value tree under the handle's cipher, +/// binding `aad`; one batched `generate-data-key` call however many leaves. +/// Output: packed pointer to a codec-encoded ciphertext tree whose leaves +/// are the frozen `SealedValue` byte encoding. +/// +/// # Safety +/// +/// Pointer/length pairs should name buffers the host wrote via +/// [`se_alloc`]; each range is bounds-checked against linear memory (a bad +/// pair returns `STATUS_ENCODING` instead of faulting). +#[no_mangle] +pub unsafe extern "C" fn se_encrypt( + handle: u32, + val_ptr: *const u8, + val_len: u32, + aad_ptr: *const u8, + aad_len: u32, +) -> u64 { + run_encrypt(handle, val_ptr, val_len, aad_ptr, aad_len, false) +} + +/// Like [`se_encrypt`], but seals the value as a *sequence element* — rows +/// written through this export interchange with rows written by encrypting +/// a whole sequence under the same AAD. +/// +/// # Safety +/// +/// As for [`se_encrypt`]. +#[no_mangle] +pub unsafe extern "C" fn se_encrypt_element( + handle: u32, + val_ptr: *const u8, + val_len: u32, + aad_ptr: *const u8, + aad_len: u32, +) -> u64 { + run_encrypt(handle, val_ptr, val_len, aad_ptr, aad_len, true) +} + +/// Decrypt a codec-encoded ciphertext tree back into a codec-encoded value +/// tree; one batched `retrieve-data-key` call. The output buffer contains +/// **plaintext** — the host must copy it out and immediately release it +/// with [`se_dealloc`] (which wipes it). +/// +/// # Safety +/// +/// As for [`se_encrypt`]. +#[no_mangle] +pub unsafe extern "C" fn se_decrypt( + handle: u32, + ct_ptr: *const u8, + ct_len: u32, + aad_ptr: *const u8, + aad_len: u32, +) -> u64 { + run_decrypt(handle, ct_ptr, ct_len, aad_ptr, aad_len, false) +} + +/// Like [`se_decrypt`], but opens the ciphertext as a *sequence element* — +/// the read-side counterpart of [`se_encrypt_element`], for one row of a +/// batch-encrypted sequence under the batch's AAD. +/// +/// # Safety +/// +/// As for [`se_encrypt`]. +#[no_mangle] +pub unsafe extern "C" fn se_decrypt_element( + handle: u32, + ct_ptr: *const u8, + ct_len: u32, + aad_ptr: *const u8, + aad_len: u32, +) -> u64 { + run_decrypt(handle, ct_ptr, ct_len, aad_ptr, aad_len, true) +} + +/// [`se_encrypt`] / [`se_encrypt_element`]'s shared drive: validate, look +/// up the handle, block on the op. +fn run_encrypt( + handle: u32, + val_ptr: *const u8, + val_len: u32, + aad_ptr: *const u8, + aad_len: u32, + as_element: bool, +) -> u64 { + catch_unwind(AssertUnwindSafe(|| { + let value = input(val_ptr, val_len)?; + let aad = input(aad_ptr, aad_len)?; + with_cipher(handle, |cipher| { + block_on(ops::encrypt_value(cipher, value, aad, as_element)) + }) + })) + .unwrap_or(Err(STATUS_INTERNAL)) + .map_or_else(err_status, ok_buffer) +} + +/// [`se_decrypt`] / [`se_decrypt_element`]'s shared drive. +fn run_decrypt( + handle: u32, + ct_ptr: *const u8, + ct_len: u32, + aad_ptr: *const u8, + aad_len: u32, + as_element: bool, +) -> u64 { + catch_unwind(AssertUnwindSafe(|| { + let ciphertext = input(ct_ptr, ct_len)?; + let aad = input(aad_ptr, aad_len)?; + with_cipher(handle, |cipher| { + block_on(ops::decrypt_value(cipher, ciphertext, aad, as_element)) + }) + })) + .unwrap_or(Err(STATUS_INTERNAL)) + .map_or_else(err_status, ok_buffer) +} + +/// Derive one index term: a codec-encoded scalar plus a context string and +/// a term kind ([`ops::TERM_EQUALITY`] etc.); the output is the term's +/// frozen byte encoding. Local PRF/CLLW only — never touches ZeroKMS. +/// +/// # Safety +/// +/// As for [`se_encrypt`]. +#[no_mangle] +pub unsafe extern "C" fn se_term( + handle: u32, + val_ptr: *const u8, + val_len: u32, + ctx_ptr: *const u8, + ctx_len: u32, + kind: u32, +) -> u64 { + catch_unwind(AssertUnwindSafe(|| { + let value = input(val_ptr, val_len)?; + let context = input(ctx_ptr, ctx_len)?; + with_cipher(handle, |cipher| { + block_on(ops::term(cipher, value, context, kind)) + }) + })) + .unwrap_or(Err(STATUS_INTERNAL)) + .map_or_else(err_status, ok_buffer) +} + +/// Encrypt a record (or a batch) per a plan — the runtime form of +/// `#[derive(EncryptFrom)]`; see [`ops::encrypt_record`] for the source, +/// plan, and result encodings. One `generate-data-key` call per invocation +/// regardless of row count; terms derive locally. +/// +/// # Safety +/// +/// As for [`se_encrypt`]. +#[no_mangle] +pub unsafe extern "C" fn se_encrypt_record( + handle: u32, + src_ptr: *const u8, + src_len: u32, + plan_ptr: *const u8, + plan_len: u32, +) -> u64 { + catch_unwind(AssertUnwindSafe(|| { + let source = input(src_ptr, src_len)?; + let plan = input(plan_ptr, plan_len)?; + with_cipher(handle, |cipher| { + block_on(ops::encrypt_record(cipher, source, plan)) + }) + })) + .unwrap_or(Err(STATUS_INTERNAL)) + .map_or_else(err_status, ok_buffer) +} + +/// Decrypt a record (or a batch) produced by [`se_encrypt_record`] under +/// the same plan; only the `"c"` outputs participate. One +/// `retrieve-data-key` call per invocation. The output buffer contains +/// **plaintext** — same host obligations as [`se_decrypt`]. +/// +/// # Safety +/// +/// As for [`se_encrypt`]. +#[no_mangle] +pub unsafe extern "C" fn se_decrypt_record( + handle: u32, + rec_ptr: *const u8, + rec_len: u32, + plan_ptr: *const u8, + plan_len: u32, +) -> u64 { + catch_unwind(AssertUnwindSafe(|| { + let record = input(rec_ptr, rec_len)?; + let plan = input(plan_ptr, plan_len)?; + with_cipher(handle, |cipher| { + block_on(ops::decrypt_record(cipher, record, plan)) + }) + })) + .unwrap_or(Err(STATUS_INTERNAL)) + .map_or_else(err_status, ok_buffer) +} diff --git a/languages/golang/stackencrypt/guest/src/buffers.rs b/languages/golang/stackencrypt/guest/src/buffers.rs new file mode 100644 index 000000000..48e36e80a --- /dev/null +++ b/languages/golang/stackencrypt/guest/src/buffers.rs @@ -0,0 +1,101 @@ +//! The guest-owned buffer registry behind `se_alloc` / `se_dealloc`, +//! following the vitaminc guest's conventions (see `vcencrypt/guest/src/ +//! abi.rs`; sharing one implementation from a common vitaminc crate is a +//! planned follow-up in that repository): +//! +//! - Every buffer the guest hands out — from [`alloc`] and from packed +//! results — is recorded here keyed by start address, holding the true +//! length. [`dealloc`] consults the registry instead of trusting the +//! host: an unknown pointer (including a double-free) is a no-op, a +//! length mismatch refuses to free, and the zeroizing wipe always covers +//! the true allocation. +//! - [`take`] is the extra move this guest needs beyond vitaminc's: the +//! host *returns* buffers to the guest (the transport response, the +//! token) by writing into `se_alloc`'d memory and handing back the +//! pointer; `take` reclaims ownership under the same registry discipline. +//! +//! Wasm is single-threaded, so a thread-local `RefCell` is a plain owner of +//! the map — no `Send`/`Sync` bounds required. + +use std::cell::RefCell; +use std::collections::HashMap; + +use zeroize::Zeroize; + +thread_local! { + static BUFFERS: RefCell<HashMap<usize, usize>> = RefCell::new(HashMap::new()); +} + +/// Allocate `len` bytes of guest memory for the host to write into. +/// Returns a pointer valid until reclaimed by [`dealloc`] or [`take`], or +/// null if the allocation fails. The null branch is real: allocation goes +/// through `try_reserve_exact`, not the aborting global-allocator error +/// path, so an oversized request is a recoverable host-side error instead +/// of a trap that poisons the instance. +pub(crate) fn alloc(len: usize) -> *mut u8 { + let mut buf: Vec<u8> = Vec::new(); + if buf.try_reserve_exact(len).is_err() { + return core::ptr::null_mut(); + } + buf.resize(len, 0); + register(buf) +} + +/// Register a buffer and leak it to a raw pointer for the host. The +/// registry entry is what makes the matching [`dealloc`] / [`take`] sound. +pub(crate) fn register(buf: Vec<u8>) -> *mut u8 { + let boxed = buf.into_boxed_slice(); + let len = boxed.len(); + let ptr = Box::into_raw(boxed) as *mut u8; + BUFFERS.with(|b| b.borrow_mut().insert(ptr as usize, len)); + ptr +} + +/// Zeroize and free a buffer previously handed out. The registry supplies +/// the true length; `len` is cross-checked but never trusted. An unknown +/// pointer (including a double-free) is a no-op; a length mismatch means +/// the host's bookkeeping has desynced from ours, so the buffer is kept +/// live and registered rather than freed out from under a confused host. +/// +/// # Safety +/// +/// `ptr` should be a pointer this module handed out. The registry makes any +/// other pointer (or a stale one) a no-op rather than undefined behaviour, +/// but a pointer that happens to alias a *different* live registered buffer +/// of the same length would free that buffer. +pub(crate) unsafe fn dealloc(ptr: *mut u8, len: usize) { + if let Some(mut buf) = unsafe { reclaim(ptr, len) } { + buf.zeroize(); + } +} + +/// Take ownership of a buffer the host filled via [`alloc`] and handed back +/// (a transport response, a token). Same registry discipline as +/// [`dealloc`]; returns `None` for an unknown pointer or a length mismatch. +/// A null pointer with zero length is an empty buffer. +/// +/// # Safety +/// +/// As for [`dealloc`]. +pub(crate) unsafe fn take(ptr: *mut u8, len: usize) -> Option<Vec<u8>> { + if ptr.is_null() && len == 0 { + return Some(Vec::new()); + } + unsafe { reclaim(ptr, len) } +} + +unsafe fn reclaim(ptr: *mut u8, len: usize) -> Option<Vec<u8>> { + if ptr.is_null() { + return None; + } + let real_len = BUFFERS.with(|b| b.borrow_mut().remove(&(ptr as usize)))?; + if real_len != len { + BUFFERS.with(|b| b.borrow_mut().insert(ptr as usize, real_len)); + return None; + } + // SAFETY: the registry guarantees `(ptr, real_len)` is exactly one live + // allocation this module handed out via `register` (len == capacity by + // `into_boxed_slice`), and the entry has just been removed so it cannot + // be reclaimed twice. + Some(unsafe { Vec::from_raw_parts(ptr, real_len, real_len) }) +} diff --git a/languages/golang/stackencrypt/guest/src/config.rs b/languages/golang/stackencrypt/guest/src/config.rs new file mode 100644 index 000000000..d0e1ede4d --- /dev/null +++ b/languages/golang/stackencrypt/guest/src/config.rs @@ -0,0 +1,244 @@ +//! Parsing of the `se_cipher_init` configuration. +//! +//! The config crosses the boundary as one FFI-codec-encoded +//! [`FfiValue::Object`] — the same codec every other entry point uses, so +//! there is no second config format. Recognised keys (all string values): +//! +//! | key | required | meaning | +//! |---------------|----------|---------| +//! | `client_id` | yes | ZeroKMS client id (UUID) | +//! | `client_key` | yes | the client key, hex-encoded (the v1 `to_hex_v1` encoding) | +//! | `keyset` | no | keyset *name* to pin the cipher to | +//! | `keyset_id` | no | keyset *id* (UUID) to pin the cipher to | +//! | `zerokms_url` | no | pins the ZeroKMS endpoint at init; when absent the endpoint is resolved from the access token's `services` claim on first use | +//! +//! `keyset` and `keyset_id` are mutually exclusive; with neither, the +//! client's default keyset is used. Unknown keys are rejected — a typo'd +//! optional key must not silently fall back to a default. +//! +//! The parsed [`FfiValue`] holds the client-key hex inside +//! `Protected`, which wipes on drop; the raw config *buffer* is wiped by the +//! ABI layer immediately after decoding (see [`crate::abi`]). + +use stack_kms::{ClientKey, IdentifiedBy, ZeroKmsEndpoint}; +use uuid::Uuid; +use vitaminc_aead_value::FfiValue; + +/// A parse failure, carrying which key was at fault. Maps to +/// `STATUS_ENCODING` at the ABI; the detail exists for the native tests and +/// is never surfaced across the boundary (statuses leak no config content). +#[derive(Debug, PartialEq, Eq)] +pub enum ConfigError { + /// The config was not an object of string values. + NotAnObject, + /// A required key was absent. + Missing(&'static str), + /// A key held something other than a string. + NotAString(&'static str), + /// A key's value failed its own validation (bad UUID, bad hex, bad URL). + Invalid(&'static str), + /// `keyset` and `keyset_id` were both given. + ConflictingKeysets, + /// A key this version does not recognise. + UnknownKey(String), +} + +/// Everything `se_cipher_init` needs to build the cipher. +pub struct CipherConfig { + pub client_key: ClientKey, + pub keyset: Option<IdentifiedBy>, + pub endpoint: Option<ZeroKmsEndpoint>, +} + +/// Parse a decoded config value. Consumes it so the client-key material has +/// one owner; the `Protected` payloads are wiped when the strings drop here. +pub fn parse_config(value: FfiValue) -> Result<CipherConfig, ConfigError> { + let FfiValue::Object(entries) = value else { + return Err(ConfigError::NotAnObject); + }; + + let mut client_id: Option<String> = None; + let mut client_key_hex: Option<String> = None; + let mut keyset_name: Option<String> = None; + let mut keyset_id: Option<String> = None; + let mut url: Option<String> = None; + + for (key, value) in entries { + let slot = match key.as_str() { + "client_id" => &mut client_id, + "client_key" => &mut client_key_hex, + "keyset" => &mut keyset_name, + "keyset_id" => &mut keyset_id, + "zerokms_url" => &mut url, + _ => return Err(ConfigError::UnknownKey(key)), + }; + // The codec already rejects duplicate object keys, so the slot is + // vacant; still, last-write-wins here would be silent, so require it. + let FfiValue::String(s) = value else { + return Err(ConfigError::NotAString(name_of(&key))); + }; + let text = std::str::from_utf8(s.risky_ref()) + .map_err(|_| ConfigError::NotAString(name_of(&key)))? + .to_string(); + *slot = Some(text); + } + + let client_id = client_id.ok_or(ConfigError::Missing("client_id"))?; + let client_id = Uuid::parse_str(&client_id).map_err(|_| ConfigError::Invalid("client_id"))?; + + let mut hex = client_key_hex.ok_or(ConfigError::Missing("client_key"))?; + let client_key = ClientKey::from_hex_v1(client_id, &hex); + // The hex string is a full encoding of the client root key: wipe this + // copy whatever the parse outcome (the decoded FfiValue's own copy was + // consumed above; the raw input buffer is the ABI layer's to wipe). + zeroize::Zeroize::zeroize(&mut hex); + let client_key = client_key.map_err(|_| ConfigError::Invalid("client_key"))?; + + let keyset = match (keyset_name, keyset_id) { + (Some(_), Some(_)) => return Err(ConfigError::ConflictingKeysets), + (Some(name), None) => Some(IdentifiedBy::Name( + name.as_str() + .try_into() + .map_err(|_| ConfigError::Invalid("keyset"))?, + )), + (None, Some(id)) => Some(IdentifiedBy::Uuid( + Uuid::parse_str(&id).map_err(|_| ConfigError::Invalid("keyset_id"))?, + )), + (None, None) => None, + }; + + let endpoint = url + .map(|u| u.parse().map_err(|_| ConfigError::Invalid("zerokms_url"))) + .transpose()?; + + Ok(CipherConfig { + client_key, + keyset, + endpoint, + }) +} + +/// Intern the key name for error reporting (`&'static str` keeps +/// [`ConfigError`] cheap; unknown keys carry the owned string instead). +fn name_of(key: &str) -> &'static str { + match key { + "client_id" => "client_id", + "client_key" => "client_key", + "keyset" => "keyset", + "keyset_id" => "keyset_id", + "zerokms_url" => "zerokms_url", + _ => "unknown", + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A valid v1 client key for fixtures, minted through the real key type + /// so the hex exercises the actual `from_hex_v1` decoder. + fn client_key_hex() -> (Uuid, String) { + use recipher::keyset::{EncryptionKeySet, ProxyKeySet}; + let id = Uuid::from_u128(7); + let authority = EncryptionKeySet::generate().expect("generate"); + let domain = EncryptionKeySet::generate().expect("generate"); + let key = ClientKey::new_v1(id, ProxyKeySet::generate(&authority, &domain)); + (id, key.to_hex_v1().expect("encode fixture key")) + } + + fn obj(entries: Vec<(&str, &str)>) -> FfiValue { + FfiValue::Object( + entries + .into_iter() + .map(|(k, v)| (k.to_string(), FfiValue::String(v.into()))) + .collect(), + ) + } + + #[test] + fn parses_a_minimal_config() { + let (id, hex) = client_key_hex(); + let cfg = parse_config(obj(vec![ + ("client_id", &id.to_string()), + ("client_key", &hex), + ])) + .expect("minimal config parses"); + assert_eq!(cfg.client_key.key_id, id); + assert!(cfg.keyset.is_none()); + assert!(cfg.endpoint.is_none()); + } + + #[test] + fn parses_keyset_name_id_and_url() { + let (id, hex) = client_key_hex(); + let cfg = parse_config(obj(vec![ + ("client_id", &id.to_string()), + ("client_key", &hex), + ("keyset", "users"), + ("zerokms_url", "https://zerokms.example.com"), + ])) + .expect("config parses"); + assert!(matches!(cfg.keyset, Some(IdentifiedBy::Name(_)))); + assert!(cfg.endpoint.is_some()); + + let keyset_id = Uuid::from_u128(9); + let cfg = parse_config(obj(vec![ + ("client_id", &id.to_string()), + ("client_key", &hex), + ("keyset_id", &keyset_id.to_string()), + ])) + .expect("config parses"); + assert!(matches!(cfg.keyset, Some(IdentifiedBy::Uuid(k)) if k == keyset_id)); + } + + #[test] + fn rejects_bad_configs() { + let (id, hex) = client_key_hex(); + let id_s = id.to_string(); + + assert!(matches!( + parse_config(FfiValue::Null), + Err(ConfigError::NotAnObject) + )); + assert!(matches!( + parse_config(obj(vec![("client_id", &id_s)])), + Err(ConfigError::Missing("client_key")) + )); + assert!(matches!( + parse_config(obj(vec![("client_id", "nope"), ("client_key", &hex)])), + Err(ConfigError::Invalid("client_id")) + )); + assert!(matches!( + parse_config(obj(vec![ + ("client_id", &id_s), + ("client_key", "deadbeef"), // valid hex, not a keyset + ])), + Err(ConfigError::Invalid("client_key")) + )); + assert!(matches!( + parse_config(obj(vec![ + ("client_id", &id_s), + ("client_key", &hex), + ("keyset", "users"), + ("keyset_id", "00000000-0000-0000-0000-000000000009"), + ])), + Err(ConfigError::ConflictingKeysets) + )); + assert!(matches!( + parse_config(obj(vec![ + ("client_id", &id_s), + ("client_key", &hex), + ("zerokms_urk", "https://typo.example.com"), + ])), + Err(ConfigError::UnknownKey(_)) + )); + assert!(matches!( + parse_config(obj(vec![ + ("client_id", &id_s), + ("client_key", &hex), + ("zerokms_url", "not a url"), + ])), + Err(ConfigError::Invalid("zerokms_url")) + )); + } +} diff --git a/languages/golang/stackencrypt/guest/src/headers.rs b/languages/golang/stackencrypt/guest/src/headers.rs new file mode 100644 index 000000000..0d0c3a6d4 --- /dev/null +++ b/languages/golang/stackencrypt/guest/src/headers.rs @@ -0,0 +1,68 @@ +//! The header micro-format of the `transport_send` host import. +//! +//! Request and response headers cross the boundary as one UTF-8 buffer of +//! `name: value` lines separated by `\n` (HTTP/1.1 field syntax, minus +//! folding) — trivially encoded and decoded on both sides without pulling +//! the value codec into the transport layer. Names compare +//! ASCII-case-insensitively, as in HTTP. Pure functions, unit-tested on the +//! native target. + +/// Encode header pairs as the wire buffer. +pub fn encode_headers(headers: &[(&str, &str)]) -> Vec<u8> { + let mut out = String::new(); + for (i, (name, value)) in headers.iter().enumerate() { + if i > 0 { + out.push('\n'); + } + out.push_str(name); + out.push_str(": "); + out.push_str(value); + } + out.into_bytes() +} + +/// Look up a header by (ASCII-case-insensitive) name in a wire buffer. +/// Malformed lines (no colon, non-UTF-8 buffer) are skipped rather than +/// failing the response: the transport's contract is carried by the status +/// and body, and header parsing must not be a denial-of-service lever. +pub fn header_value<'a>(buffer: &'a [u8], name: &str) -> Option<&'a str> { + let text = std::str::from_utf8(buffer).ok()?; + text.lines().find_map(|line| { + let (n, v) = line.split_once(':')?; + n.trim().eq_ignore_ascii_case(name).then(|| v.trim()) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn round_trips_and_matches_case_insensitively() { + let buffer = encode_headers(&[ + ("authorization", "Bearer tok"), + ("content-type", "application/json"), + ]); + assert_eq!( + std::str::from_utf8(&buffer).unwrap(), + "authorization: Bearer tok\ncontent-type: application/json" + ); + assert_eq!( + header_value(&buffer, "Content-Type"), + Some("application/json") + ); + assert_eq!(header_value(&buffer, "authorization"), Some("Bearer tok")); + assert_eq!(header_value(&buffer, "x-missing"), None); + } + + #[test] + fn tolerates_whitespace_and_skips_malformed_lines() { + assert_eq!( + header_value(b"Content-Type: text/html \ngarbage-line", "content-type"), + Some("text/html") + ); + assert_eq!(header_value(b"no colon here", "content-type"), None); + assert_eq!(header_value(&[0xff, 0xfe], "content-type"), None); + assert_eq!(header_value(b"", "content-type"), None); + } +} diff --git a/languages/golang/stackencrypt/guest/src/host.rs b/languages/golang/stackencrypt/guest/src/host.rs new file mode 100644 index 000000000..0e4490b2e --- /dev/null +++ b/languages/golang/stackencrypt/guest/src/host.rs @@ -0,0 +1,250 @@ +//! The host imports and the types built over them: [`WasiHostConnection`] +//! (a [`ZeroKMSConnection`] whose transport is the host's HTTP client) and +//! [`HostTokenStrategy`] (an [`AuthStrategy`] that asks the host for the +//! bearer token). wasm32-only — everything here calls an imported function. +//! +//! # Import contract (module `cipherstash_transport`) +//! +//! All pointers are offsets into guest linear memory; the host allocates +//! guest buffers with `se_alloc` and the guest reclaims them through its +//! registry ([`crate::buffers`]). +//! +//! - `transport_send(method, url, headers, body, resp_headers_out, +//! resp_body_out) -> status` — perform one HTTP request. Each of the four +//! inputs is a `(ptr, len)` pair borrowed for the duration of the call; +//! `headers` is the `name: value` line format of [`crate::headers`]. The +//! two outputs are `(ptr_out, len_out)` slot pairs the host fills with +//! `se_alloc`'d buffers (response headers, response body). The return +//! value is the HTTP status code, or negative for a transport-level +//! failure — then the body carries the host's error text and headers are +//! empty. This is #2099's ZeroKMS-shaped import generalised to a plain +//! HTTP request (method + URL + headers), so `stack-auth`'s refreshers +//! can reuse it later and a future `wasi:http` implementation can replace +//! it without changing the connection seam. +//! - `token_get(token_out) -> status` — hand over the current bearer token +//! (Phase-1 auth: minting and refresh stay on the host). `token_out` is a +//! `(ptr_out, len_out)` slot pair filled the same way; status `0` is +//! success, anything else a host-side failure. +//! +//! What crosses the boundary per ZeroKMS call is exactly what would cross +//! TLS anyway: the URL, the bearer token, and the serialized protocol +//! bytes. Key material derived from a response never crosses back — the +//! derivation runs inside the guest ([`stack_kms`]'s client, unmodified). +//! +//! Request bodies and response bodies can carry key-material contexts and +//! wrapped keys, so both are wiped on drop here; the buffers the host wrote +//! are reclaimed via the registry (which the ABI's `se_dealloc` also wipes). + +use std::convert::Infallible; +use std::fmt; +use std::sync::Mutex; + +use stack_auth::{AuthError, AuthStrategy, CustomError, SecretToken, ServiceToken}; +use stack_kms::{ZeroKMSConnection, ZeroKMSConnectionInit, ZeroKmsEndpoint}; +use zeroize::Zeroizing; +use zerokms_protocol::{ViturRequest, ViturRequestError}; + +use crate::buffers; +use crate::headers::{encode_headers, header_value}; +use crate::response::map_response; + +#[link(wasm_import_module = "cipherstash_transport")] +extern "C" { + fn transport_send( + method_ptr: *const u8, + method_len: u32, + url_ptr: *const u8, + url_len: u32, + headers_ptr: *const u8, + headers_len: u32, + body_ptr: *const u8, + body_len: u32, + resp_headers_ptr_out: *mut u32, + resp_headers_len_out: *mut u32, + resp_body_ptr_out: *mut u32, + resp_body_len_out: *mut u32, + ) -> i32; + + fn token_get(token_ptr_out: *mut u32, token_len_out: *mut u32) -> i32; +} + +/// The ZeroKMS base URL is not known yet (no `zerokms_url` in the cipher +/// config, and the first token carried no usable `services` claim). +/// Mirrors the reference `HttpConnection`: a request-preparation error, not +/// an authentication failure, so callers don't trigger a refresh loop. +#[derive(Debug)] +struct BaseUrlUnresolved; + +impl fmt::Display for BaseUrlUnresolved { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "no ZeroKMS base URL is configured or resolved") + } +} + +impl std::error::Error for BaseUrlUnresolved {} + +/// The host stored an out-slot pointer the guest's buffer registry does not +/// know (or with a mismatched length) — a host-side bookkeeping bug. +#[derive(Debug)] +struct HostBufferError; + +impl fmt::Display for HostBufferError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "host returned an unregistered or mismatched buffer") + } +} + +impl std::error::Error for HostBufferError {} + +/// A [`ZeroKMSConnection`] whose transport is the `transport_send` host +/// import. The host call is synchronous from the guest's perspective, so +/// `send` resolves immediately — `block_on` in the ABI layer never parks. +/// +/// The endpoint is pinned at init when the cipher config named one; +/// otherwise it is discovered by `StackKms` from the access token's +/// `services` claim through [`ensure_base_url`](Self::ensure_base_url) — +/// the first value wins, per the trait's contract. +pub struct WasiHostConnection { + base: Mutex<Option<ZeroKmsEndpoint>>, +} + +impl WasiHostConnection { + fn base(&self) -> std::sync::MutexGuard<'_, Option<ZeroKmsEndpoint>> { + // Wasm is single-threaded: a poisoned lock can only mean a previous + // panic already aborted the instance, so this is unreachable — + // recover rather than add a second panic path. + self.base + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + } +} + +impl ZeroKMSConnectionInit for WasiHostConnection { + type ConnectionOpts = Option<ZeroKmsEndpoint>; + type Error = Infallible; + + fn init(opts: Self::ConnectionOpts) -> Result<Self, Infallible> { + Ok(Self { + base: Mutex::new(opts), + }) + } +} + +impl ZeroKMSConnection for WasiHostConnection { + fn ensure_base_url(&self, url: ZeroKmsEndpoint) { + let mut base = self.base(); + if base.is_none() { + *base = Some(url); + } + } + + fn has_base_url(&self) -> bool { + self.base().is_some() + } + + async fn send<Request: ViturRequest>( + &self, + request: Request, + access_token: &str, + ) -> Result<Request::Response, ViturRequestError> { + let url = self + .base() + .as_ref() + .map(|base| base.request_url(Request::ENDPOINT)) + .ok_or_else(|| { + ViturRequestError::prepare( + "ZeroKMS base URL was not resolved from the token's services claim", + BaseUrlUnresolved, + ) + })?; + + // Request bodies can reference key-material contexts; wipe on drop. + let body = Zeroizing::new( + serde_json::to_vec(&request) + .map_err(|e| ViturRequestError::prepare("Failed to serialize request", e))?, + ); + // The bearer token is a credential; wipe the header buffer on drop. + let auth = Zeroizing::new(format!("Bearer {access_token}")); + let headers = Zeroizing::new(encode_headers(&[ + ("authorization", auth.as_str()), + ("content-type", "application/json"), + ])); + + let method = b"POST"; + let mut resp_headers_ptr: u32 = 0; + let mut resp_headers_len: u32 = 0; + let mut resp_body_ptr: u32 = 0; + let mut resp_body_len: u32 = 0; + + // SAFETY: every input pair names a live guest allocation borrowed + // for the call; the out-slots are stack locals the host writes once. + let status = unsafe { + transport_send( + method.as_ptr(), + method.len() as u32, + url.as_str().as_ptr(), + url.as_str().len() as u32, + headers.as_ptr(), + headers.len() as u32, + body.as_ptr(), + body.len() as u32, + &mut resp_headers_ptr, + &mut resp_headers_len, + &mut resp_body_ptr, + &mut resp_body_len, + ) + }; + + let unregistered = + || ViturRequestError::parse("Host response buffer failed validation", HostBufferError); + // SAFETY: pointers come from the host's `se_alloc` calls; the + // registry validates them before any Vec is rebuilt. + let resp_headers = + unsafe { buffers::take(resp_headers_ptr as *mut u8, resp_headers_len as usize) } + .ok_or_else(unregistered)?; + // Response bodies carry wrapped key material — wipe on drop. + let resp_body = Zeroizing::new( + unsafe { buffers::take(resp_body_ptr as *mut u8, resp_body_len as usize) } + .ok_or_else(unregistered)?, + ); + + let content_type = header_value(&resp_headers, "content-type"); + map_response(status, content_type, &resp_body) + } +} + +/// An [`AuthStrategy`] that fetches the bearer token from the host on every +/// request via `token_get`. Refresh policy stays host-side (Phase-1 auth): +/// whatever token the host hands over is presented as-is, so the host can +/// rotate tokens without re-initialising the cipher handle. +pub struct HostTokenStrategy; + +impl AuthStrategy for &HostTokenStrategy { + async fn get_token(self) -> Result<ServiceToken, AuthError> { + let mut token_ptr: u32 = 0; + let mut token_len: u32 = 0; + // SAFETY: the out-slots are stack locals the host writes once. + let status = unsafe { token_get(&mut token_ptr, &mut token_len) }; + if status != 0 { + return Err(AuthError::Custom(CustomError(format!( + "host token_get failed with status {status}" + )))); + } + // SAFETY: the pointer comes from the host's `se_alloc` call; the + // registry validates it before any Vec is rebuilt. + let bytes = Zeroizing::new( + unsafe { buffers::take(token_ptr as *mut u8, token_len as usize) }.ok_or_else( + || { + AuthError::Custom(CustomError( + "host token buffer failed validation".to_string(), + )) + }, + )?, + ); + let text = std::str::from_utf8(&bytes) + .map_err(|_| AuthError::Custom(CustomError("host token is not UTF-8".to_string())))?; + // `SecretToken` wipes on drop; `bytes` (the only other copy) wipes + // via its `Zeroizing` wrapper above. + Ok(ServiceToken::new(SecretToken::new(text))) + } +} diff --git a/languages/golang/stackencrypt/guest/src/lib.rs b/languages/golang/stackencrypt/guest/src/lib.rs new file mode 100644 index 000000000..8b879ff8b --- /dev/null +++ b/languages/golang/stackencrypt/guest/src/lib.rs @@ -0,0 +1,58 @@ +#![deny(unsafe_op_in_unsafe_fn)] +//! # stack-encrypt WASI guest +//! +//! WASI guest module exposing [`stack-encrypt`](stack_encrypt) — +//! ZeroKMS-backed AEAD over structured values, SEM index terms, batched +//! records — to non-Rust hosts. Built for `wasm32-wasip1` and embedded by +//! the Go module in the parent directory (wazero host, `CGO_ENABLED=0`). +//! Phase 3 of `docs/plans/stack-encrypt-go-bindings.md`. +//! +//! Control stays in Rust: request assembly, key derivation, batching, and +//! AAD/PRF context binding run unmodified inside the guest. The host +//! provides exactly two imports (HTTP transport and the bearer token — see +//! [`host`]); what crosses the boundary per call is a value tree in, a +//! ciphertext/record tree out, and — inside the call — the same bytes that +//! would cross TLS anyway. The client key enters guest memory once at +//! `se_cipher_init`; derived data keys and the index key never leave. +//! +//! Split into: +//! +//! - [`ops`], [`config`], [`response`], [`headers`], [`status`], +//! [`sessions`] — everything that is pure logic over `StackCipher<K>` / +//! bytes. Compiles and unit-tests on the native host target (`cargo +//! test` here, no wasm toolchain needed) against +//! `stack_kms::FakeDataKeySource`. +//! - [`abi`], [`host`], [`buffers`] (wasm32 only) — the export surface, +//! the two host imports, and the buffer registry. See [`abi`]'s module +//! docs for the full ABI contract. +//! +//! On wasm32 `vitaminc-encrypt` uses its pure-Rust (RustCrypto `aes-gcm`) +//! backend; the trade-offs are documented there. Values cross the boundary +//! in the vitaminc FFI codec (`vitaminc_aead_value::transport` — an FFI +//! encoding, not a storage format); the leaves inside a ciphertext tree are +//! the *frozen* `SealedValue` byte encoding from Phase 2, so a leaf lifted +//! out of a tree is exactly what a database column holds. + +pub mod config; +pub mod headers; +pub mod ops; +pub mod response; +pub mod status; + +// Only the wasm32 ABI constructs the table; natively it exists for its +// unit tests. +#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))] +pub(crate) mod sessions; + +// The ABI's packed u64 results embed 32-bit pointers, its bounds checks +// read the wasm linear-memory size, and `host` calls imported functions — +// so these modules only exist on wasm32. A native cdylib build therefore +// exports no se_* symbols at all — failing loudly at symbol lookup — +// instead of exporting a silently wrong ABI (the `ptr << 32` packing would +// truncate a 64-bit pointer). +#[cfg(target_arch = "wasm32")] +pub mod abi; +#[cfg(target_arch = "wasm32")] +pub(crate) mod buffers; +#[cfg(target_arch = "wasm32")] +pub mod host; diff --git a/languages/golang/stackencrypt/guest/src/ops.rs b/languages/golang/stackencrypt/guest/src/ops.rs new file mode 100644 index 000000000..33cc4dfef --- /dev/null +++ b/languages/golang/stackencrypt/guest/src/ops.rs @@ -0,0 +1,713 @@ +//! The guest's operations, written against `StackCipher<K>` for any +//! [`DataKeySource`] so they compile — and their tests run — on the native +//! host target with `FakeDataKeySource`. The wasm32-only [`crate::abi`] +//! module wires them to the session table and the packed ABI; nothing in +//! here knows about linear memory. +//! +//! Values and ciphertext trees cross the boundary in the vitaminc FFI codec +//! (`vitaminc_aead_value::transport`) — the same codec the vitaminc guest +//! uses, so the Go side carries exactly one codec. A ciphertext tree's +//! leaves are re-encoded through [`SealedValue::to_bytes`] / +//! [`SealedValue::from_bytes`]: the codec sees an opaque byte-string leaf, +//! and the bytes inside it are the frozen storage encoding a database column +//! holds — a leaf lifted out of a tree here can be written to Postgres +//! as-is, and vice versa. +//! +//! # Errors +//! +//! Every function reports a [`crate::status`] code, never a message: these +//! are attacker-reachable decode/decrypt paths, and the status codes leak +//! only the failure class (see `status.rs`). +//! +//! # Records +//! +//! [`encrypt_record`] is the runtime form of `#[derive(EncryptFrom)]`: a +//! *plan* says, per field, which encryption context to bind and which +//! outputs to produce (ciphertext and/or index terms); the source supplies +//! the field values. However many rows and fields are in one call, all +//! ciphertext leaves seal in **one** batched `generate_keys` — the pendings +//! are merged before settling, exactly like the derive's `zip`/`all` +//! composition — and index terms derive locally with no ZeroKMS traffic at +//! all. See [`parse_plan`] for the plan encoding. + +use stack_encrypt::sem::{CllwOpeEncrypt, CllwOreEncrypt, DefaultMatch}; +use stack_encrypt::target::Pending; +use stack_encrypt::{ + Aad, BoxedPassthrough, CipherText, Decrypt, Element, Encrypt, SealedValue, StackCipher, + StackCipherText, +}; +use stack_kms::DataKeySource; +use vitaminc_aead_value::{transport as codec, FfiValue}; +use vitaminc_protected::{Controlled, Protected}; +use zeroize::Zeroizing; + +use crate::status::{ + status_for_error, status_for_term_error, STATUS_AUTH, STATUS_ENCODING, STATUS_INTERNAL, +}; + +/// Term kinds for `se_term`, part of the guest/host contract (the Go host +/// mirrors these values). +pub const TERM_EQUALITY: u32 = 1; +/// See [`TERM_EQUALITY`]. +pub const TERM_MATCH: u32 = 2; +/// See [`TERM_EQUALITY`]. +pub const TERM_ORE: u32 = 3; +/// See [`TERM_EQUALITY`]. +pub const TERM_OPE: u32 = 4; + +/// A ciphertext tree whose leaves are the frozen [`SealedValue`] byte +/// encoding — the shape that crosses the FFI codec. +type BytesTree = CipherText<Vec<u8>, BoxedPassthrough>; + +// ============================================================================= +// Whole-value encrypt / decrypt (the vitaminc guest's vc_encrypt shape) +// ============================================================================= + +/// Encrypt a codec-encoded [`FfiValue`] tree under `aad`, sealing every leaf +/// against a fresh ZeroKMS data key (one batched call). With `as_element`, +/// seal it as a *sequence element* — interchangeable with rows written by +/// encrypting a whole sequence under the same AAD. +pub async fn encrypt_value<K>( + cipher: &StackCipher<K>, + value: &[u8], + aad: &[u8], + as_element: bool, +) -> Result<Vec<u8>, u32> +where + K: DataKeySource + Sync, +{ + let value = decode_value(value)?; + let aad = Aad::from_slice(aad); + let tree = if as_element { + Element(value).encrypt_with_aad(cipher, aad) + } else { + value.encrypt_with_aad(cipher, aad) + } + .map_err(|_| STATUS_INTERNAL)?; + let ct = tree.seal(cipher).await.map_err(|e| status_for_error(&e))?; + encode_tree(ct) +} + +/// Decrypt a codec-encoded ciphertext tree back into a codec-encoded +/// [`FfiValue`] tree (one batched `retrieve_keys` call). The output buffer +/// contains plaintext — the ABI layer's ownership rules govern its wiping. +pub async fn decrypt_value<K>( + cipher: &StackCipher<K>, + ciphertext: &[u8], + aad: &[u8], + as_element: bool, +) -> Result<Vec<u8>, u32> +where + K: DataKeySource + Sync, +{ + let tree = decode_tree(ciphertext)?; + let decipher = cipher + .decipher(tree) + .await + .map_err(|e| status_for_error(&e))?; + let aad = Aad::from_slice(aad); + let value: FfiValue = if as_element { + Element::<FfiValue>::decrypt_with_aad(decipher, aad).map(Element::into_inner) + } else { + FfiValue::decrypt_with_aad(decipher, aad) + } + .map_err(|_| STATUS_AUTH)?; + encode_value(value) +} + +// ============================================================================= +// Terms +// ============================================================================= + +/// Derive one index term: a codec-encoded scalar in, the term's frozen byte +/// encoding out (see `stack-encrypt`'s `sem` module docs). Purely local — +/// this never touches ZeroKMS, which is what makes query probes cheap. +pub async fn term<K>( + cipher: &StackCipher<K>, + value: &[u8], + context: &[u8], + kind: u32, +) -> Result<Vec<u8>, u32> +where + K: DataKeySource + Sync, +{ + let value = decode_value(value)?; + let context = std::str::from_utf8(context).map_err(|_| STATUS_ENCODING)?; + let output = match kind { + TERM_EQUALITY => Output::Equality, + TERM_MATCH => Output::Match, + TERM_ORE => Output::Ore, + TERM_OPE => Output::Ope, + _ => return Err(STATUS_ENCODING), + }; + term_bytes(cipher, scalar_of(&value)?, context, output).await +} + +/// A term-able scalar lifted (by copy) out of an [`FfiValue`] leaf, so the +/// value itself stays movable into the ciphertext path. The owned text/bytes +/// copies wipe on drop; the PRF/CLLW layers move them into `Protected` +/// internally. +#[derive(Clone)] +enum Scalar { + Bool(bool), + I32(i32), + I64(i64), + U32(u32), + U64(u64), + F32(f32), + F64(f64), + Text(Zeroizing<String>), + Bytes(Zeroizing<Vec<u8>>), +} + +fn scalar_of(value: &FfiValue) -> Result<Scalar, u32> { + Ok(match value { + FfiValue::Bool(v) => Scalar::Bool(*v), + FfiValue::Int32(v) => Scalar::I32(*v), + FfiValue::Int64(v) => Scalar::I64(*v), + FfiValue::UInt32(v) => Scalar::U32(*v), + FfiValue::UInt64(v) => Scalar::U64(*v), + FfiValue::Float32(v) => Scalar::F32(*v), + FfiValue::Float64(v) => Scalar::F64(*v), + FfiValue::String(s) => Scalar::Text(Zeroizing::new(text_of(s)?.to_string())), + FfiValue::Bytes(b) => Scalar::Bytes(Zeroizing::new(b.risky_ref().to_vec())), + // Containers, nulls and passthroughs have no term semantics. + _ => return Err(STATUS_ENCODING), + }) +} + +/// Derive one output's term bytes for a scalar. +/// +/// The type dispatch decides the term's PRF/CLLW input encoding, which is +/// part of the cross-language contract: an equality term for `UInt32(34)` +/// must equal the term the Rust side derives for `34u32`. Unsupported +/// combinations (floats or booleans under equality, anything non-text under +/// match) are [`STATUS_ENCODING`] — the scheme does not define them. +async fn term_bytes<K>( + cipher: &StackCipher<K>, + scalar: Scalar, + context: &str, + output: Output, +) -> Result<Vec<u8>, u32> +where + K: DataKeySource + Sync, +{ + let term_err = |e| status_for_term_error(&e); + match output { + Output::Ciphertext => Err(STATUS_ENCODING), + Output::Equality => { + let term = match scalar { + Scalar::I32(v) => cipher.equality_term(v, context).await, + Scalar::I64(v) => cipher.equality_term(v, context).await, + Scalar::U32(v) => cipher.equality_term(v, context).await, + Scalar::U64(v) => cipher.equality_term(v, context).await, + Scalar::Text(t) => cipher.equality_term(String::clone(&t), context).await, + Scalar::Bytes(b) => { + cipher + .equality_term(Protected::new(Vec::clone(&b)), context) + .await + } + // No PRF encoding is defined for floats (equality on IEEE-754 + // values is a modelling error) or booleans. + Scalar::Bool(_) | Scalar::F32(_) | Scalar::F64(_) => return Err(STATUS_ENCODING), + } + .map_err(term_err)?; + Ok(term.into_bytes().to_vec()) + } + Output::Match => match scalar { + Scalar::Text(t) => cipher + .match_terms::<DefaultMatch>(&t, context) + .await + .map(|t| t.to_bytes()) + .map_err(term_err), + _ => Err(STATUS_ENCODING), + }, + Output::Ore => match scalar { + Scalar::Bool(v) => ore(cipher, v, context).await, + Scalar::I32(v) => ore(cipher, v, context).await, + Scalar::I64(v) => ore(cipher, v, context).await, + Scalar::U32(v) => ore(cipher, v, context).await, + Scalar::U64(v) => ore(cipher, v, context).await, + Scalar::F32(v) => ore(cipher, v, context).await, + Scalar::F64(v) => ore(cipher, v, context).await, + Scalar::Text(t) => ore(cipher, String::clone(&t), context).await, + Scalar::Bytes(b) => ore(cipher, Vec::clone(&b), context).await, + }, + Output::Ope => match scalar { + Scalar::Bool(v) => ope(cipher, v, context).await, + Scalar::I32(v) => ope(cipher, v, context).await, + Scalar::I64(v) => ope(cipher, v, context).await, + Scalar::U32(v) => ope(cipher, v, context).await, + Scalar::U64(v) => ope(cipher, v, context).await, + Scalar::F32(v) => ope(cipher, v, context).await, + Scalar::F64(v) => ope(cipher, v, context).await, + Scalar::Text(t) => ope(cipher, String::clone(&t), context).await, + Scalar::Bytes(b) => ope(cipher, Vec::clone(&b), context).await, + }, + } +} + +/// The `AsRef<[u8]>` on the output is what turns the typed CLLW ciphertext +/// into the frozen raw-bytes encoding. +async fn ore<K, T>(cipher: &StackCipher<K>, value: T, context: &str) -> Result<Vec<u8>, u32> +where + K: DataKeySource + Sync, + T: CllwOreEncrypt + Send + 'static, + T::Output: AsRef<[u8]> + Send + 'static, +{ + cipher + .ore_term(value, context) + .await + .map(|t| t.as_ref().to_vec()) + .map_err(|e| status_for_term_error(&e)) +} + +/// See [`ore`]. +async fn ope<K, T>(cipher: &StackCipher<K>, value: T, context: &str) -> Result<Vec<u8>, u32> +where + K: DataKeySource + Sync, + T: CllwOpeEncrypt + Send + 'static, + T::Output: AsRef<[u8]> + Send + 'static, +{ + cipher + .ope_term(value, context) + .await + .map(|t| t.as_ref().to_vec()) + .map_err(|e| status_for_term_error(&e)) +} + +// ============================================================================= +// Records +// ============================================================================= + +/// What a plan field asks for. The strings are the plan encoding *and* the +/// keys of the per-field output map in the result. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +enum Output { + /// `"c"` — the field's [`StackCipherText`]. + Ciphertext, + /// `"eq"` — equality term (raw 32 PRF bytes). + Equality, + /// `"match"` — match term (LE `u16` positions), default tokenizer config. + Match, + /// `"ore"` — ORE term (raw CLLW bytes). + Ore, + /// `"ope"` — OPE term (raw CLLW bytes). + Ope, +} + +impl Output { + fn parse(s: &str) -> Option<Self> { + Some(match s { + "c" => Output::Ciphertext, + "eq" => Output::Equality, + "match" => Output::Match, + "ore" => Output::Ore, + "ope" => Output::Ope, + _ => return None, + }) + } + + fn key(self) -> &'static str { + match self { + Output::Ciphertext => "c", + Output::Equality => "eq", + Output::Match => "match", + Output::Ore => "ore", + Output::Ope => "ope", + } + } +} + +/// One field of a record plan. +struct FieldPlan { + name: String, + context: String, + outputs: Vec<Output>, +} + +/// Parse a record plan from a decoded value. The plan is an +/// [`FfiValue::Object`]: +/// +/// ```text +/// { <field>: { "context": <string>, "outputs": [ "c" | "eq" | "match" | "ore" | "ope", ... ] }, ... } +/// ``` +/// +/// Rejected as [`STATUS_ENCODING`]: an empty plan, an empty or missing +/// context (contexts domain-separate fields — see `Error::EmptyContext` in +/// stack-encrypt), an empty/unknown/duplicated output list, unknown keys. +/// Field names are unique by construction (the codec rejects duplicate +/// object keys). +fn parse_plan(value: FfiValue) -> Result<Vec<FieldPlan>, u32> { + let FfiValue::Object(entries) = value else { + return Err(STATUS_ENCODING); + }; + if entries.is_empty() { + return Err(STATUS_ENCODING); + } + entries + .into_iter() + .map(|(name, spec)| { + let FfiValue::Object(spec) = spec else { + return Err(STATUS_ENCODING); + }; + let mut context: Option<String> = None; + let mut outputs: Option<Vec<Output>> = None; + for (key, value) in spec { + match key.as_str() { + "context" => { + let FfiValue::String(s) = value else { + return Err(STATUS_ENCODING); + }; + context = Some(text_of(&s)?.to_string()); + } + "outputs" => { + let FfiValue::Array(items) = value else { + return Err(STATUS_ENCODING); + }; + let mut parsed = Vec::with_capacity(items.len()); + for item in &items { + let FfiValue::String(s) = item else { + return Err(STATUS_ENCODING); + }; + let output = Output::parse(text_of(s)?).ok_or(STATUS_ENCODING)?; + if parsed.contains(&output) { + return Err(STATUS_ENCODING); + } + parsed.push(output); + } + outputs = Some(parsed); + } + _ => return Err(STATUS_ENCODING), + } + } + let context = context.filter(|c| !c.is_empty()).ok_or(STATUS_ENCODING)?; + let outputs = outputs.filter(|o| !o.is_empty()).ok_or(STATUS_ENCODING)?; + Ok(FieldPlan { + name, + context, + outputs, + }) + }) + .collect() +} + +/// A row's assembled outputs, ciphertext slots still pending: the terms are +/// derived (locally), and each `None` is filled from the settled ciphertexts +/// in build order. +type RowSkeleton = Vec<(String, Vec<(&'static str, Option<Vec<u8>>)>)>; + +/// Encrypt a record — or a batch of records — per a plan. +/// +/// `source` is a codec-encoded [`FfiValue::Object`] of `{ field: scalar }` +/// (one record), or an [`FfiValue::Array`] of such objects (a batch). Every +/// plan field must be present in each record, and every record field must be +/// named by the plan — silently dropping a field on either side would lose +/// data or index nothing. +/// +/// The result is a codec-encoded ciphertext tree: per record a map of +/// `field → { output-key → node }`, where `"c"` is the field's sealed +/// ciphertext subtree and each term rides as a passthrough +/// [`FfiValue::Bytes`] node (terms are comparands, not ciphertexts to open — +/// passthrough is their honest encoding). A batch is a sequence of such +/// maps. One `generate_keys` call per invocation, however many rows. +pub async fn encrypt_record<K>( + cipher: &StackCipher<K>, + source: &[u8], + plan: &[u8], +) -> Result<Vec<u8>, u32> +where + K: DataKeySource + Sync, +{ + let plan = parse_plan(decode_value(plan)?)?; + + let (rows, batched) = match decode_value(source)? { + FfiValue::Object(entries) => (vec![entries], false), + FfiValue::Array(items) => { + let rows = items + .into_iter() + .map(|item| match item { + FfiValue::Object(entries) => Ok(entries), + _ => Err(STATUS_ENCODING), + }) + .collect::<Result<Vec<_>, u32>>()?; + (rows, true) + } + _ => return Err(STATUS_ENCODING), + }; + + // Build every row: terms derive now (local), ciphertexts queue their + // data-key requests into one flat pending list. + let mut pendings: Vec<Pending<'_, StackCipherText, K>> = Vec::new(); + let mut skeletons: Vec<RowSkeleton> = Vec::with_capacity(rows.len()); + for row in rows { + skeletons.push(build_row(cipher, row, &plan, &mut pendings).await?); + } + + // The one ZeroKMS call for the whole invocation. + let sealed = Pending::all(cipher, pendings) + .await + .map_err(|e| status_for_error(&e))?; + let mut sealed = sealed.into_iter(); + + // Fill the ciphertext slots back in, in build order. + let mut row_nodes = Vec::with_capacity(skeletons.len()); + for skeleton in skeletons { + let mut fields = Vec::with_capacity(skeleton.len()); + for (field, outputs) in skeleton { + let mut nodes = Vec::with_capacity(outputs.len()); + for (key, slot) in outputs { + let node = match slot { + Some(term) => CipherText::Passthrough(Box::new(FfiValue::Bytes(Protected::new( + term, + ))) + as BoxedPassthrough), + None => sealed.next().ok_or(STATUS_INTERNAL)?, + }; + nodes.push((key.to_string(), node)); + } + fields.push((field, CipherText::Map(nodes))); + } + row_nodes.push(CipherText::Map(fields)); + } + if sealed.next().is_some() { + return Err(STATUS_INTERNAL); + } + + let tree = if batched { + CipherText::Sequence(row_nodes) + } else { + row_nodes.pop().ok_or(STATUS_INTERNAL)? + }; + encode_tree(tree) +} + +/// Build one record row: derive its terms and queue its ciphertext pendings, +/// returning the row skeleton. The plan drives the iteration so the output +/// field order is the plan's; the row must contain exactly the plan's fields. +async fn build_row<'c, K>( + cipher: &'c StackCipher<K>, + mut row: Vec<(String, FfiValue)>, + plan: &[FieldPlan], + pendings: &mut Vec<Pending<'c, StackCipherText, K>>, +) -> Result<RowSkeleton, u32> +where + K: DataKeySource + Sync, +{ + if row.len() != plan.len() { + return Err(STATUS_ENCODING); + } + let mut skeleton = Vec::with_capacity(plan.len()); + for field in plan { + let at = row + .iter() + .position(|(name, _)| name == &field.name) + .ok_or(STATUS_ENCODING)?; + let (name, value) = row.swap_remove(at); + + // Terms first — they lift a copy of the scalar; the value itself is + // consumed by the ciphertext path below. + let wants_terms = field.outputs.iter().any(|o| *o != Output::Ciphertext); + let scalar = if wants_terms { + Some(scalar_of(&value)?) + } else { + None + }; + + let mut outputs: Vec<(&'static str, Option<Vec<u8>>)> = + Vec::with_capacity(field.outputs.len()); + for output in &field.outputs { + if *output == Output::Ciphertext { + outputs.push((output.key(), None)); + continue; + } + let scalar = scalar.clone().ok_or(STATUS_INTERNAL)?; + let term = term_bytes(cipher, scalar, &field.context, *output).await?; + outputs.push((output.key(), Some(term))); + } + + if field.outputs.contains(&Output::Ciphertext) { + let tree = value + .encrypt_with_aad(cipher, field.context.as_str()) + .map_err(|_| STATUS_INTERNAL)?; + pendings.push(tree.into_pending(cipher)); + } + + skeleton.push((name, outputs)); + } + Ok(skeleton) +} + +/// Decrypt a record — or a batch — produced by [`encrypt_record`] under the +/// same plan. Only the `"c"` outputs participate (terms are one-way); the +/// result is a codec-encoded [`FfiValue::Object`] per record holding the +/// plan's ciphertext-bearing fields, in plan order — or an +/// [`FfiValue::Array`] of them for a batch. One `retrieve_keys` call per +/// invocation. +pub async fn decrypt_record<K>( + cipher: &StackCipher<K>, + record: &[u8], + plan: &[u8], +) -> Result<Vec<u8>, u32> +where + K: DataKeySource + Sync, +{ + use stack_encrypt::target::DecryptInto; + + let plan = parse_plan(decode_value(plan)?)?; + + let (rows, batched) = match decode_tree(record)? { + CipherText::Map(entries) => (vec![entries], false), + CipherText::Sequence(items) => { + let rows = items + .into_iter() + .map(|item| match item { + CipherText::Map(entries) => Ok(entries), + _ => Err(STATUS_ENCODING), + }) + .collect::<Result<Vec<_>, u32>>()?; + (rows, true) + } + _ => return Err(STATUS_ENCODING), + }; + + // Per row, per ciphertext-bearing plan field: lift out the "c" subtree + // and queue its decrypt. Terms and extra fields in the tree are ignored + // (they are comparands, not ciphertext). + let mut pendings: Vec<Pending<'_, FfiValue, K>> = Vec::new(); + let mut names: Vec<Vec<String>> = Vec::with_capacity(rows.len()); + for row in rows { + let mut row = row; + let mut row_names = Vec::new(); + for field in &plan { + if !field.outputs.contains(&Output::Ciphertext) { + continue; + } + let at = row + .iter() + .position(|(name, _)| name == &field.name) + .ok_or(STATUS_ENCODING)?; + let (name, node) = row.swap_remove(at); + let CipherText::Map(outputs) = node else { + return Err(STATUS_ENCODING); + }; + let ct = outputs + .into_iter() + .find_map(|(key, node)| (key == "c").then_some(node)) + .ok_or(STATUS_ENCODING)?; + pendings.push(ct.decrypt_into(cipher, field.context.as_str())); + row_names.push(name); + } + names.push(row_names); + } + + // The one ZeroKMS call for the whole invocation. + let values = Pending::all(cipher, pendings) + .await + .map_err(|e| status_for_error(&e))?; + let mut values = values.into_iter(); + + let mut row_values = Vec::with_capacity(names.len()); + for row_names in names { + let mut entries = Vec::with_capacity(row_names.len()); + for name in row_names { + entries.push((name, values.next().ok_or(STATUS_INTERNAL)?)); + } + row_values.push(FfiValue::Object(entries)); + } + if values.next().is_some() { + return Err(STATUS_INTERNAL); + } + + let value = if batched { + FfiValue::Array(row_values) + } else { + row_values.pop().ok_or(STATUS_INTERNAL)? + }; + encode_value(value) +} + +// ============================================================================= +// Codec glue +// ============================================================================= + +fn decode_value(bytes: &[u8]) -> Result<FfiValue, u32> { + codec::decode_value(&mut codec::Reader::new(bytes)).map_err(|_| STATUS_ENCODING) +} + +fn encode_value(value: FfiValue) -> Result<Vec<u8>, u32> { + let mut out = Vec::new(); + codec::encode_value(value, &mut out).map_err(|_| STATUS_ENCODING)?; + Ok(out) +} + +fn decode_tree(bytes: &[u8]) -> Result<StackCipherText, u32> { + let tree: BytesTree = codec::decode_ciphertext_boxed(&mut codec::Reader::new(bytes)) + .map_err(|_| STATUS_ENCODING)?; + sealed_tree(tree) +} + +fn encode_tree(tree: StackCipherText) -> Result<Vec<u8>, u32> { + let tree = leaf_bytes_tree(tree)?; + let mut out = Vec::new(); + codec::encode_ciphertext_boxed(tree, &mut out).map_err(|_| STATUS_ENCODING)?; + Ok(out) +} + +/// Re-encode every leaf through the frozen [`SealedValue::to_bytes`] +/// encoding. `TagTooLong` cannot arise for a ZeroKMS-issued tag, so an +/// encode failure is internal, not an input error. +fn leaf_bytes_tree(tree: StackCipherText) -> Result<BytesTree, u32> { + let leaf = |l: SealedValue| l.to_bytes().map_err(|_| STATUS_INTERNAL); + Ok(match tree { + CipherText::Single(l) => CipherText::Single(leaf(l)?), + CipherText::None(l) => CipherText::None(leaf(l)?), + CipherText::EmptySequence(l) => CipherText::EmptySequence(leaf(l)?), + CipherText::EmptyMap(l) => CipherText::EmptyMap(leaf(l)?), + CipherText::Sequence(items) => CipherText::Sequence( + items + .into_iter() + .map(leaf_bytes_tree) + .collect::<Result<_, _>>()?, + ), + CipherText::Map(entries) => CipherText::Map( + entries + .into_iter() + .map(|(k, v)| Ok((k, leaf_bytes_tree(v)?))) + .collect::<Result<_, u32>>()?, + ), + CipherText::Passthrough(p) => CipherText::Passthrough(p), + }) +} + +/// Decode every leaf through the frozen [`SealedValue::from_bytes`] +/// encoding. Structural only — a decoded leaf proves nothing until its AEAD +/// opens (see the `SealedValue` docs). +fn sealed_tree(tree: BytesTree) -> Result<StackCipherText, u32> { + let leaf = |l: Vec<u8>| SealedValue::from_bytes(&l).map_err(|_| STATUS_ENCODING); + Ok(match tree { + CipherText::Single(l) => CipherText::Single(leaf(l)?), + CipherText::None(l) => CipherText::None(leaf(l)?), + CipherText::EmptySequence(l) => CipherText::EmptySequence(leaf(l)?), + CipherText::EmptyMap(l) => CipherText::EmptyMap(leaf(l)?), + CipherText::Sequence(items) => CipherText::Sequence( + items + .into_iter() + .map(sealed_tree) + .collect::<Result<_, _>>()?, + ), + CipherText::Map(entries) => CipherText::Map( + entries + .into_iter() + .map(|(k, v)| Ok((k, sealed_tree(v)?))) + .collect::<Result<_, u32>>()?, + ), + CipherText::Passthrough(p) => CipherText::Passthrough(p), + }) +} + +fn text_of(s: &vitaminc_aead_value::Utf8String) -> Result<&str, u32> { + // Valid UTF-8 by `Utf8String`'s construction invariant; checked rather + // than assumed because this is boundary code. + std::str::from_utf8(s.risky_ref()).map_err(|_| STATUS_ENCODING) +} diff --git a/languages/golang/stackencrypt/guest/src/response.rs b/languages/golang/stackencrypt/guest/src/response.rs new file mode 100644 index 000000000..11e4f24b4 --- /dev/null +++ b/languages/golang/stackencrypt/guest/src/response.rs @@ -0,0 +1,228 @@ +//! Pure response-mapping logic for the host transport, mirroring the +//! reference `HttpConnection` in `stack-kms/src/connection/http.rs`: +//! status-code → [`ViturRequestErrorKind`] mapping and the content-type +//! validation performed before deserializing a success body. Kept free of +//! any wasm ABI concerns so it compiles — and its unit tests run — on the +//! native host target. (Ported from the #2099 spike's `response.rs`, +//! re-based on stack-kms's connection rather than `cipherstash-client`'s.) + +use std::fmt; + +use serde::de::DeserializeOwned; +use zerokms_protocol::{ViturRequestError, ViturRequestErrorKind}; + +/// The host reported it could not perform the HTTP call at all (negative +/// status). The body carries the host's error text. +#[derive(Debug)] +pub struct TransportFailure(pub String); + +impl fmt::Display for TransportFailure { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "host transport failed: {}", self.0) + } +} + +impl std::error::Error for TransportFailure {} + +/// A non-2xx ZeroKMS response. +#[derive(Debug)] +pub struct FailureResponse { + pub status: i32, + pub body: String, +} + +impl fmt::Display for FailureResponse { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "Status: {}, Body: {}", self.status, self.body) + } +} + +impl std::error::Error for FailureResponse {} + +/// A 2xx response whose Content-Type is not JSON — typically a proxy or load +/// balancer answering with an HTML error page. +#[derive(Debug)] +pub struct UnexpectedContentType { + pub received: Option<String>, + pub expected: &'static str, + pub body: String, +} + +impl fmt::Display for UnexpectedContentType { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "Received '{:?}', expected '{}', Body: {}", + self.received, self.expected, self.body + ) + } +} + +impl std::error::Error for UnexpectedContentType {} + +/// `true` if a `content-type` header value denotes JSON, ignoring any +/// parameters (`application/json; charset=utf-8`) and ASCII case — the same +/// tolerance as the reference `HttpConnection` (proxies and API gateways +/// commonly normalise the header that way). +fn is_json_content_type(value: &str) -> bool { + value + .split(';') + .next() + .map(str::trim) + .is_some_and(|media_type| media_type.eq_ignore_ascii_case("application/json")) +} + +/// Map a host transport result onto the ZeroKMS protocol contract. +/// +/// `status` is the HTTP status code, or negative for a transport-level +/// failure (in which case `body` carries the host's error text). +/// `content_type` is the response Content-Type header, if any. +/// +/// The error bodies captured into [`FailureResponse`] / +/// [`UnexpectedContentType`] are non-2xx (or non-JSON) server error text, +/// not key material — the only payload that carries wrapped keys is a 2xx +/// JSON body, which is consumed by deserialization and wiped by the caller. +pub fn map_response<T: DeserializeOwned>( + status: i32, + content_type: Option<&str>, + body: &[u8], +) -> Result<T, ViturRequestError> { + match status { + s if s < 0 => Err(ViturRequestError::send( + "Host transport reported a failure", + TransportFailure(String::from_utf8_lossy(body).into_owned()), + )), + 200..=299 => { + let expected = "application/json"; + if !content_type.is_some_and(is_json_content_type) { + return Err(ViturRequestError::parse( + "Invalid content type header", + UnexpectedContentType { + received: content_type.map(|ct| ct.to_owned()), + expected, + body: String::from_utf8_lossy(body).into_owned(), + }, + )); + } + serde_json::from_slice(body) + .map_err(|e| ViturRequestError::parse("Failed to deserialize response body", e)) + } + status => { + let failure = FailureResponse { + status, + body: String::from_utf8_lossy(body).into_owned(), + }; + Err(match status { + 404 => ViturRequestError::new( + ViturRequestErrorKind::NotFound, + "Resource not found", + failure, + ), + 401 => ViturRequestError::new( + ViturRequestErrorKind::Unauthorized, + "Request unauthorized", + failure, + ), + 403 => ViturRequestError::new( + ViturRequestErrorKind::Forbidden, + "Request forbidden", + failure, + ), + 409 => ViturRequestError::new( + ViturRequestErrorKind::Conflict, + "Resource conflict", + failure, + ), + _ => ViturRequestError::other("Server returned failure response", failure), + }) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use zerokms_protocol::Keyset; + + fn kind_of(result: Result<Vec<Keyset>, ViturRequestError>) -> ViturRequestErrorKind { + result.expect_err("expected an error").kind + } + + const KEYSETS_JSON: &[u8] = br#"[ + {"id":"6a70bd18-99ac-4650-b104-37eec3a15b09","name":"alpha","description":"","is_disabled":false,"is_default":true} + ]"#; + + #[test] + fn success_with_json_content_type_deserializes() { + let keysets: Vec<Keyset> = + map_response(200, Some("application/json"), KEYSETS_JSON).expect("deserializes"); + assert_eq!(keysets.len(), 1); + assert_eq!(keysets[0].name, "alpha"); + } + + #[test] + fn json_content_type_tolerates_parameters_and_case() { + // Same tolerance as the reference `HttpConnection`. + let keysets: Vec<Keyset> = + map_response(200, Some("Application/JSON; charset=utf-8"), KEYSETS_JSON) + .expect("deserializes"); + assert_eq!(keysets.len(), 1); + } + + #[test] + fn success_without_json_content_type_is_parse_error() { + assert!(matches!( + kind_of(map_response(200, None, KEYSETS_JSON)), + ViturRequestErrorKind::ParseResponse + )); + // A proxy or load balancer answering 200 with an HTML error page. + assert!(matches!( + kind_of(map_response( + 200, + Some("text/html"), + b"<html>gateway error</html>" + )), + ViturRequestErrorKind::ParseResponse + )); + } + + #[test] + fn success_with_invalid_json_is_parse_error() { + assert!(matches!( + kind_of(map_response(200, Some("application/json"), b"not json")), + ViturRequestErrorKind::ParseResponse + )); + } + + #[test] + fn transport_failure_is_send_error() { + assert!(matches!( + kind_of(map_response(-1, None, b"connection refused")), + ViturRequestErrorKind::SendRequest + )); + } + + #[test] + fn error_statuses_map_to_their_kinds() { + assert!(matches!( + kind_of(map_response(401, None, b"nope")), + ViturRequestErrorKind::Unauthorized + )); + assert!(matches!( + kind_of(map_response(403, None, b"Not permitted")), + ViturRequestErrorKind::Forbidden + )); + assert!(matches!( + kind_of(map_response(404, None, b"missing")), + ViturRequestErrorKind::NotFound + )); + assert!(matches!( + kind_of(map_response(409, None, b"exists")), + ViturRequestErrorKind::Conflict + )); + assert!(matches!( + kind_of(map_response(500, None, b"boom")), + ViturRequestErrorKind::Other + )); + } +} diff --git a/languages/golang/stackencrypt/guest/src/sessions.rs b/languages/golang/stackencrypt/guest/src/sessions.rs new file mode 100644 index 000000000..8ea417a60 --- /dev/null +++ b/languages/golang/stackencrypt/guest/src/sessions.rs @@ -0,0 +1,89 @@ +//! The cipher-session table behind the ABI's handle scheme, generic over the +//! cipher it stores so the handle-allocation invariants can be unit-tested +//! natively (the wasm32 ABI instantiates it with +//! `StackCipher<StackKms<HostTokenStrategy, WasiHostConnection>>`). +//! +//! Mirrors the vitaminc guest's session table (`vcencrypt/guest/src/ +//! sessions.rs`); extracting the shared implementation into a common +//! vitaminc crate is a planned follow-up in that repository — see the plan's +//! Phase 3 notes. + +use std::collections::hash_map::Entry; +use std::collections::HashMap; + +use crate::status::STATUS_INTERNAL; + +pub(crate) struct Sessions<C> { + next: u32, + ciphers: HashMap<u32, C>, +} + +impl<C> Sessions<C> { + pub(crate) fn new() -> Self { + // Handle ids start at 1 so a zero high-field can never be a valid + // handle (see the abi result encoding). + Sessions { + next: 1, + ciphers: HashMap::new(), + } + } + + /// Insert a cipher under a fresh handle id. + /// + /// Refuses at id exhaustion rather than wrapping or saturating: a reused + /// id would alias a live handle and silently displace its session — + /// sealing new data under the wrong keyset and orphaning everything the + /// displaced cipher had sealed. `next` only advances on success, and the + /// occupancy check makes the no-aliasing invariant explicit rather than + /// assumed. (The final id, `u32::MAX`, is sacrificed to keep the + /// arithmetic simple; ~4.3e9 handles precede it.) + pub(crate) fn insert(&mut self, cipher: C) -> Result<u32, u32> { + let handle = self.next; + let bumped = handle.checked_add(1).ok_or(STATUS_INTERNAL)?; + match self.ciphers.entry(handle) { + Entry::Occupied(_) => Err(STATUS_INTERNAL), + Entry::Vacant(slot) => { + slot.insert(cipher); + self.next = bumped; + Ok(handle) + } + } + } + + pub(crate) fn get(&self, handle: u32) -> Option<&C> { + self.ciphers.get(&handle) + } + + pub(crate) fn remove(&mut self, handle: u32) { + self.ciphers.remove(&handle); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn handles_start_at_one_and_increment() { + let mut s = Sessions::new(); + assert_eq!(s.insert("a"), Ok(1)); + assert_eq!(s.insert("b"), Ok(2)); + assert!(s.get(1).is_some()); + s.remove(1); + assert!(s.get(1).is_none()); + assert!(s.get(2).is_some()); + } + + #[test] + fn handle_ids_are_never_reused_at_exhaustion() { + let mut s = Sessions::new(); + s.next = u32::MAX - 1; + let last = s.insert("last").expect("last issuable id"); + assert_eq!(last, u32::MAX - 1); + // At exhaustion the table must refuse rather than alias a live + // handle (a saturating or wrapping counter would silently displace + // the session and seal under the wrong keyset). + assert_eq!(s.insert("next"), Err(STATUS_INTERNAL)); + assert!(s.get(last).is_some(), "live session must not be displaced"); + } +} diff --git a/languages/golang/stackencrypt/guest/src/status.rs b/languages/golang/stackencrypt/guest/src/status.rs new file mode 100644 index 000000000..600425b43 --- /dev/null +++ b/languages/golang/stackencrypt/guest/src/status.rs @@ -0,0 +1,180 @@ +//! Status codes for the ABI's packed result encoding (see [`crate::abi`]), +//! and the mapping from [`stack_encrypt::Error`] onto them. +//! +//! Defined outside the wasm32-gated ABI module so native builds — the ops +//! unit tests — can reference them too. The Go host mirrors these values; +//! they are part of the guest/host contract and must not be renumbered. +//! +//! Codes 1–4 are byte-for-byte the vitaminc guest's codes (`vcencrypt`'s +//! `status.rs`), so the two guests read identically from the host side. +//! Codes 5–10 map the ZeroKMS request outcomes +//! ([`ViturRequestErrorKind`]-shaped) so a Go caller can distinguish a bad +//! token from a tampered ciphertext without parsing strings. Code 11 is a +//! term-derivation failure (a caller-input condition, e.g. match text that +//! yields no tokens). + +use stack_kms::{GenerateKeyError, LoadKeysetError, RetrieveKeyError}; +use zerokms_protocol::ViturRequestErrorKind; + +/// AEAD open failure: wrong key, wrong AAD, or tampered ciphertext. +pub const STATUS_AUTH: u32 = 1; +/// Invalid input at the boundary: malformed transport bytes, a malformed +/// cipher config, an empty encryption context, or a pointer/length pair that +/// fails validation against linear memory. +pub const STATUS_ENCODING: u32 = 2; +/// The cipher handle is unknown (never issued, or already freed). +pub const STATUS_BAD_HANDLE: u32 = 3; +/// A caught panic, handle-id exhaustion, a response that did not match its +/// requests, or any other unexpected internal failure. +pub const STATUS_INTERNAL: u32 = 4; +/// ZeroKMS (or the auth strategy) rejected the request as unauthenticated: +/// a missing, expired, or invalid access token. +pub const STATUS_KMS_UNAUTHORIZED: u32 = 5; +/// ZeroKMS rejected the request as forbidden: the token is valid but lacks +/// permission (or the keyset is disabled). +pub const STATUS_KMS_FORBIDDEN: u32 = 6; +/// ZeroKMS could not find the resource: an unknown keyset (or client), or a +/// data key that does not exist for the presented `iv`/`tag`. +pub const STATUS_KMS_NOT_FOUND: u32 = 7; +/// ZeroKMS reported a resource conflict. +pub const STATUS_KMS_CONFLICT: u32 = 8; +/// The transport failed before a ZeroKMS verdict: the host's HTTP call +/// errored, the endpoint is unknown or invalid, or the request could not be +/// prepared. +pub const STATUS_KMS_TRANSPORT: u32 = 9; +/// ZeroKMS failed in a way none of the codes above capture: a malformed +/// response, invalid key material, or an unclassified server error. +pub const STATUS_KMS_OTHER: u32 = 10; +/// An index term failed to derive: e.g. match text that yields no tokens, or +/// a value/scheme combination the term does not support. +pub const STATUS_TERM: u32 = 11; + +/// Map a sealing/opening error onto the ABI status word. +/// +/// Total over [`stack_encrypt::Error`]: composition-bug variants +/// (`ResponseShape`, `CipherMismatch`, `KeyCountMismatch`) and everything +/// else unexpected collapse into [`STATUS_INTERNAL`] — statuses distinguish +/// what a host can act on, not what it can only log. +pub fn status_for_error(error: &stack_encrypt::Error) -> u32 { + match error { + stack_encrypt::Error::Aead => STATUS_AUTH, + stack_encrypt::Error::EmptyContext => STATUS_ENCODING, + stack_encrypt::Error::Term(_) => STATUS_TERM, + stack_encrypt::Error::Kms(kms) => status_for_kms(kms), + _ => STATUS_INTERNAL, + } +} + +/// Map a term-derivation error directly (the term entry points return +/// [`stack_encrypt::sem::TermError`], not the sealing error). +pub fn status_for_term_error(error: &stack_encrypt::sem::TermError) -> u32 { + match error { + stack_encrypt::sem::TermError::EmptyContext => STATUS_ENCODING, + _ => STATUS_TERM, + } +} + +fn status_for_kms(error: &stack_kms::Error) -> u32 { + match error { + stack_kms::Error::GenerateKey(e) => match e { + GenerateKeyError::Unauthorized => STATUS_KMS_UNAUTHORIZED, + GenerateKeyError::Forbidden => STATUS_KMS_FORBIDDEN, + GenerateKeyError::RequestFailed(e) => status_for_kind(&e.kind), + GenerateKeyError::GenerateIv(_) => STATUS_INTERNAL, + _ => STATUS_KMS_OTHER, + }, + stack_kms::Error::RetrieveKey(e) => match e { + RetrieveKeyError::RequestFailed(e) => status_for_kind(&e.kind), + // A per-key server-side "no key for this iv/tag". + RetrieveKeyError::FailedRetrieval(_) => STATUS_KMS_NOT_FOUND, + _ => STATUS_KMS_OTHER, + }, + stack_kms::Error::LoadKeyset(e) => match e { + LoadKeysetError::Unauthorized(_) => STATUS_KMS_UNAUTHORIZED, + LoadKeysetError::Forbidden(_) => STATUS_KMS_FORBIDDEN, + LoadKeysetError::KeysetNotFound(_) => STATUS_KMS_NOT_FOUND, + LoadKeysetError::RequestFailed(e) => status_for_kind(&e.kind), + _ => STATUS_KMS_OTHER, + }, + // No token means no authenticated request could even be attempted. + stack_kms::Error::Auth(_) => STATUS_KMS_UNAUTHORIZED, + stack_kms::Error::ConnectionInit(_) | stack_kms::Error::InvalidEndpoint(_) => { + STATUS_KMS_TRANSPORT + } + _ => STATUS_KMS_OTHER, + } +} + +fn status_for_kind(kind: &ViturRequestErrorKind) -> u32 { + match kind { + ViturRequestErrorKind::Unauthorized => STATUS_KMS_UNAUTHORIZED, + ViturRequestErrorKind::Forbidden => STATUS_KMS_FORBIDDEN, + ViturRequestErrorKind::NotFound => STATUS_KMS_NOT_FOUND, + ViturRequestErrorKind::Conflict => STATUS_KMS_CONFLICT, + ViturRequestErrorKind::PrepareRequest | ViturRequestErrorKind::SendRequest => { + STATUS_KMS_TRANSPORT + } + _ => STATUS_KMS_OTHER, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use zerokms_protocol::ViturRequestError; + + fn vitur(kind: ViturRequestErrorKind) -> ViturRequestError { + ViturRequestError::new(kind, "stubbed", std::io::Error::other("boom")) + } + + #[test] + fn aead_and_context_errors_map_to_the_vitaminc_codes() { + assert_eq!(status_for_error(&stack_encrypt::Error::Aead), STATUS_AUTH); + assert_eq!( + status_for_error(&stack_encrypt::Error::EmptyContext), + STATUS_ENCODING + ); + assert_eq!( + status_for_error(&stack_encrypt::Error::ResponseShape), + STATUS_INTERNAL + ); + } + + #[test] + fn kms_request_kinds_map_to_distinct_codes() { + let cases = [ + (ViturRequestErrorKind::Unauthorized, STATUS_KMS_UNAUTHORIZED), + (ViturRequestErrorKind::Forbidden, STATUS_KMS_FORBIDDEN), + (ViturRequestErrorKind::NotFound, STATUS_KMS_NOT_FOUND), + (ViturRequestErrorKind::Conflict, STATUS_KMS_CONFLICT), + (ViturRequestErrorKind::SendRequest, STATUS_KMS_TRANSPORT), + (ViturRequestErrorKind::ParseResponse, STATUS_KMS_OTHER), + ]; + for (i, (kind, expected)) in cases.into_iter().enumerate() { + let err = stack_encrypt::Error::Kms(stack_kms::Error::RetrieveKey( + RetrieveKeyError::RequestFailed(vitur(kind)), + )); + assert_eq!(status_for_error(&err), expected, "case {i}"); + } + } + + #[test] + fn a_missing_data_key_is_not_found() { + let err = stack_encrypt::Error::Kms(stack_kms::Error::RetrieveKey( + RetrieveKeyError::FailedRetrieval("no key".into()), + )); + assert_eq!(status_for_error(&err), STATUS_KMS_NOT_FOUND); + } + + #[test] + fn term_errors_split_empty_context_from_derivation() { + assert_eq!( + status_for_term_error(&stack_encrypt::sem::TermError::EmptyContext), + STATUS_ENCODING + ); + assert_eq!( + status_for_term_error(&stack_encrypt::sem::TermError::EmptyTermText), + STATUS_TERM + ); + } +} diff --git a/languages/golang/stackencrypt/guest/tests/native_ops.rs b/languages/golang/stackencrypt/guest/tests/native_ops.rs new file mode 100644 index 000000000..7b98ff665 --- /dev/null +++ b/languages/golang/stackencrypt/guest/tests/native_ops.rs @@ -0,0 +1,564 @@ +//! Native tests of the guest's operations over `FakeDataKeySource` — the +//! same functions the wasm ABI drives, minus linear memory. What they pin: +//! +//! * value trees round-trip through the FFI codec + the guest ops +//! (including element mode and passthrough subtrees); +//! * a leaf inside a guest ciphertext tree **is** the frozen `SealedValue` +//! storage encoding — a native cipher decrypts it; +//! * terms derived through the guest dispatch are byte-identical to the +//! native `sem` calls (the cross-language contract); +//! * record encryption follows its plan, keeps to **one** ZeroKMS call per +//! invocation however many rows, and round-trips; +//! * hostile/malformed inputs and wrong-AAD decrypts map to the documented +//! statuses. + +use std::borrow::Cow; +use std::sync::atomic::{AtomicUsize, Ordering}; + +use futures::executor::block_on; +use stack_encrypt::sem::DefaultMatch; +use stack_encrypt::{Aad, CipherText, Decrypt, SealedValue, StackCipher}; +use stack_encrypt_guest::ops::{self, TERM_EQUALITY, TERM_MATCH, TERM_OPE, TERM_ORE}; +use stack_encrypt_guest::status::{STATUS_AUTH, STATUS_ENCODING}; +use stack_kms::{ + DataKey, DataKeySource, DataKeyWithTag, FakeDataKeySource, GenerateKeyPayload, IndexKeySource, + RetrieveKeyPayload, +}; +use uuid::Uuid; +use vitaminc_aead_value::{transport as codec, FfiValue}; +use vitaminc_protected::{Controlled, Protected}; +use zerokms_protocol::{IdentifiedBy, UnverifiedContext}; + +// ============================================================================= +// Harness +// ============================================================================= + +/// `FakeDataKeySource` with call counters, so the tests can assert the +/// batching contract ("one `generate_keys` per invocation") instead of +/// trusting it. +#[derive(Default)] +struct Counting { + inner: FakeDataKeySource, + generate_calls: AtomicUsize, + retrieve_calls: AtomicUsize, +} + +impl DataKeySource for Counting { + async fn generate_keys( + &self, + payloads: Vec<GenerateKeyPayload<'_>>, + keyset_id: Option<Uuid>, + unverified_context: Option<Cow<'_, UnverifiedContext>>, + ) -> Result<Vec<DataKeyWithTag>, stack_kms::Error> { + self.generate_calls.fetch_add(1, Ordering::SeqCst); + self.inner + .generate_keys(payloads, keyset_id, unverified_context) + .await + } + + async fn retrieve_keys( + &self, + payloads: Vec<RetrieveKeyPayload<'_>>, + keyset_id: Option<Uuid>, + unverified_context: Option<&UnverifiedContext>, + ) -> Result<Vec<DataKey>, stack_kms::Error> { + self.retrieve_calls.fetch_add(1, Ordering::SeqCst); + self.inner + .retrieve_keys(payloads, keyset_id, unverified_context) + .await + } +} + +impl IndexKeySource for Counting { + async fn load_index_key( + &self, + keyset_id: Option<IdentifiedBy>, + ) -> Result<(Uuid, stack_kms::IndexKey), stack_kms::Error> { + self.inner.load_index_key(keyset_id).await + } +} + +fn cipher() -> StackCipher<Counting> { + block_on(StackCipher::builder().kms(Counting::default()).init()).expect("build cipher") +} + +fn encode(value: FfiValue) -> Vec<u8> { + let mut out = Vec::new(); + codec::encode_value(value, &mut out).expect("encode value"); + out +} + +fn decode(bytes: &[u8]) -> FfiValue { + codec::decode_value(&mut codec::Reader::new(bytes)).expect("decode value") +} + +/// Decode a guest ciphertext buffer with the passthrough payload kept as a +/// plain [`FfiValue`], so tests can inspect term nodes directly. +fn decode_tree(bytes: &[u8]) -> CipherText<Vec<u8>, FfiValue> { + codec::decode_ciphertext(&mut codec::Reader::new(bytes)).expect("decode ciphertext tree") +} + +fn text(value: &FfiValue) -> &str { + match value { + FfiValue::String(s) => std::str::from_utf8(s.risky_ref()).expect("utf8"), + other => panic!("expected a string, got {}", kind(other)), + } +} + +fn kind(value: &FfiValue) -> &'static str { + match value { + FfiValue::Null => "null", + FfiValue::Undefined => "undefined", + FfiValue::Bool(_) => "bool", + FfiValue::Int32(_) => "i32", + FfiValue::Int64(_) => "i64", + FfiValue::UInt32(_) => "u32", + FfiValue::UInt64(_) => "u64", + FfiValue::Float32(_) => "f32", + FfiValue::Float64(_) => "f64", + FfiValue::String(_) => "string", + FfiValue::Bytes(_) => "bytes", + FfiValue::Array(_) => "array", + FfiValue::Object(_) => "object", + FfiValue::Passthrough(_) => "passthrough", + } +} + +fn obj(entries: Vec<(&str, FfiValue)>) -> FfiValue { + FfiValue::Object( + entries + .into_iter() + .map(|(k, v)| (k.to_string(), v)) + .collect(), + ) +} + +fn s(value: &str) -> FfiValue { + FfiValue::String(value.into()) +} + +/// The plan used by the record tests: an ORE-indexed integer and a +/// match-indexed string, both stored. +fn plan() -> Vec<u8> { + encode(obj(vec![ + ( + "age", + obj(vec![ + ("context", s("users/age")), + ("outputs", FfiValue::Array(vec![s("c"), s("eq"), s("ore")])), + ]), + ), + ( + "name", + obj(vec![ + ("context", s("users/name")), + ("outputs", FfiValue::Array(vec![s("c"), s("match")])), + ]), + ), + ])) +} + +fn row(age: u32, name: &str) -> FfiValue { + obj(vec![("age", FfiValue::UInt32(age)), ("name", s(name))]) +} + +// ============================================================================= +// Values +// ============================================================================= + +#[test] +fn value_round_trips_through_the_guest_ops() { + let cipher = cipher(); + let value = obj(vec![ + ("email", s("alice@example.com")), + ("age", FfiValue::UInt32(34)), + ("id", FfiValue::Passthrough(Box::new(FfiValue::Int64(7)))), + ]); + + let ct = block_on(ops::encrypt_value( + &cipher, + &encode(value), + b"users/42", + false, + )) + .expect("encrypt"); + let pt = block_on(ops::decrypt_value(&cipher, &ct, b"users/42", false)).expect("decrypt"); + + let FfiValue::Object(entries) = decode(&pt) else { + panic!("expected an object back"); + }; + assert_eq!(entries.len(), 3); + assert_eq!(text(&entries[0].1), "alice@example.com"); + assert!(matches!(entries[1].1, FfiValue::UInt32(34))); + assert!( + matches!(&entries[2].1, FfiValue::Passthrough(inner) if matches!(**inner, FfiValue::Int64(7))) + ); + + assert_eq!(cipher.kms().generate_calls.load(Ordering::SeqCst), 1); + assert_eq!(cipher.kms().retrieve_calls.load(Ordering::SeqCst), 1); +} + +#[test] +fn element_mode_round_trips() { + let cipher = cipher(); + let ct = block_on(ops::encrypt_value( + &cipher, + &encode(s("row-0")), + b"users", + true, + )) + .expect("encrypt element"); + let pt = block_on(ops::decrypt_value(&cipher, &ct, b"users", true)).expect("decrypt element"); + assert_eq!(text(&decode(&pt)), "row-0"); + + // An element is not a plain value: opening it without the element + // derivation must fail authentication. + assert_eq!( + block_on(ops::decrypt_value(&cipher, &ct, b"users", false)), + Err(STATUS_AUTH) + ); +} + +#[test] +fn guest_leaves_are_the_frozen_storage_encoding() { + // A leaf lifted out of the guest's codec framing is exactly the + // `SealedValue::from_bytes` storage format — a native cipher opens it. + let cipher = cipher(); + let ct = block_on(ops::encrypt_value( + &cipher, + &encode(s("durable")), + b"ctx", + false, + )) + .expect("encrypt"); + + let CipherText::Single(leaf_bytes) = decode_tree(&ct) else { + panic!("expected a single leaf"); + }; + let leaf = SealedValue::from_bytes(&leaf_bytes).expect("frozen leaf encoding"); + // A guest leaf seals the *value model's* typed payload (`[tag] ++ + // payload`, the vitaminc sealed-leaf format), so the native open goes + // through `FfiValue`'s own `Decrypt` — not a bare `String`. + let decipher = + block_on(cipher.decipher(CipherText::Single(leaf))).expect("retrieve the data key"); + let value = FfiValue::decrypt_with_aad(decipher, Aad::from_slice(b"ctx")) + .expect("native decrypt of a guest leaf"); + assert_eq!(text(&value), "durable"); +} + +#[test] +fn wrong_aad_and_malformed_inputs_map_to_statuses() { + let cipher = cipher(); + let ct = + block_on(ops::encrypt_value(&cipher, &encode(s("x")), b"ctx", false)).expect("encrypt"); + + // Wrong AAD: authentication, not encoding. + assert_eq!( + block_on(ops::decrypt_value(&cipher, &ct, b"other", false)), + Err(STATUS_AUTH) + ); + // Garbage transport bytes on either path: encoding. + assert_eq!( + block_on(ops::encrypt_value(&cipher, b"\xffgarbage", b"ctx", false)), + Err(STATUS_ENCODING) + ); + assert_eq!( + block_on(ops::decrypt_value(&cipher, b"\xffgarbage", b"ctx", false)), + Err(STATUS_ENCODING) + ); + // A truncated leaf inside a well-formed tree: encoding (structural), + // never a parse of the wrong layout. + let CipherText::Single(leaf_bytes) = decode_tree(&ct) else { + panic!("expected a single leaf"); + }; + let mut out = Vec::new(); + codec::encode_ciphertext::<Vec<u8>, FfiValue>( + &CipherText::Single(leaf_bytes[..10].to_vec()), + &mut out, + ) + .expect("encode truncated"); + assert_eq!( + block_on(ops::decrypt_value(&cipher, &out, b"ctx", false)), + Err(STATUS_ENCODING) + ); +} + +// ============================================================================= +// Terms +// ============================================================================= + +#[test] +fn guest_terms_match_the_native_sem_derivations() { + let cipher = cipher(); + let ctx = b"users/age".as_slice(); + + let eq = block_on(ops::term( + &cipher, + &encode(FfiValue::UInt32(42)), + ctx, + TERM_EQUALITY, + )) + .expect("eq term"); + let native = block_on(cipher.equality_term(42u32, "users/age")).expect("native eq"); + assert_eq!(eq, native.as_bytes()); + + let ore = block_on(ops::term( + &cipher, + &encode(FfiValue::UInt32(42)), + ctx, + TERM_ORE, + )) + .expect("ore term"); + let native = block_on(cipher.ore_term(42u32, "users/age")).expect("native ore"); + assert_eq!(ore, native.as_ref()); + + let ope = block_on(ops::term( + &cipher, + &encode(FfiValue::UInt32(42)), + ctx, + TERM_OPE, + )) + .expect("ope term"); + let native = block_on(cipher.ope_term(42u32, "users/age")).expect("native ope"); + assert_eq!(ope, native.as_ref()); + + let m = block_on(ops::term( + &cipher, + &encode(s("alice smith")), + b"users/name", + TERM_MATCH, + )) + .expect("match term"); + let native = + block_on(cipher.match_terms::<DefaultMatch>("alice smith", "users/name")).expect("native"); + assert_eq!(m, native.to_bytes()); + + // Strings and bytes have distinct PRF encodings — the guest must keep + // them apart even when their raw bytes are equal. + let eq_text = + block_on(ops::term(&cipher, &encode(s("ab")), b"f", TERM_EQUALITY)).expect("text term"); + let eq_bytes = block_on(ops::term( + &cipher, + &encode(FfiValue::Bytes(Protected::new(b"ab".to_vec()))), + b"f", + TERM_EQUALITY, + )) + .expect("bytes term"); + assert_ne!(eq_text, eq_bytes); + let native_bytes = + block_on(cipher.equality_term(Protected::new(b"ab".to_vec()), "f")).expect("native"); + assert_eq!(eq_bytes, native_bytes.as_bytes()); + + // Variable-width CLLW output for strings. + let ore_s = block_on(ops::term( + &cipher, + &encode(s("alice")), + b"users/name", + TERM_ORE, + )) + .expect("string ore"); + assert_eq!(ore_s.len(), 5 * 8); + + // No ZeroKMS traffic for any of it. + assert_eq!(cipher.kms().generate_calls.load(Ordering::SeqCst), 0); + assert_eq!(cipher.kms().retrieve_calls.load(Ordering::SeqCst), 0); +} + +#[test] +fn unsupported_term_inputs_are_encoding_errors() { + let cipher = cipher(); + let ctx = b"f".as_slice(); + + // Floats and bools have no equality encoding; match is text-only; + // containers have no term semantics; kinds outside the table and empty + // contexts are rejected. + for (value, kind) in [ + (FfiValue::Float64(1.5), TERM_EQUALITY), + (FfiValue::Bool(true), TERM_EQUALITY), + (FfiValue::UInt32(1), TERM_MATCH), + (FfiValue::Array(vec![]), TERM_ORE), + (FfiValue::Null, TERM_EQUALITY), + (FfiValue::UInt32(1), 99), + ] { + assert_eq!( + block_on(ops::term(&cipher, &encode(value), ctx, kind)), + Err(STATUS_ENCODING), + "kind {kind}" + ); + } + assert_eq!( + block_on(ops::term( + &cipher, + &encode(FfiValue::UInt32(1)), + b"", + TERM_EQUALITY + )), + Err(STATUS_ENCODING), + "empty context" + ); +} + +// ============================================================================= +// Records +// ============================================================================= + +#[test] +fn a_record_batch_encrypts_in_one_call_and_round_trips() { + let cipher = cipher(); + let source = encode(FfiValue::Array(vec![ + row(29, "alice smith"), + row(34, "bob jones"), + row(41, "carol park"), + ])); + + let record = block_on(ops::encrypt_record(&cipher, &source, &plan())).expect("encrypt records"); + // Three rows, two ciphertext fields each: still exactly one call. + assert_eq!(cipher.kms().generate_calls.load(Ordering::SeqCst), 1); + + let pt = block_on(ops::decrypt_record(&cipher, &record, &plan())).expect("decrypt records"); + assert_eq!(cipher.kms().retrieve_calls.load(Ordering::SeqCst), 1); + + let FfiValue::Array(rows) = decode(&pt) else { + panic!("expected an array of rows back"); + }; + assert_eq!(rows.len(), 3); + let FfiValue::Object(fields) = &rows[1] else { + panic!("expected an object row"); + }; + assert_eq!(fields[0].0, "age"); + assert!(matches!(fields[0].1, FfiValue::UInt32(34))); + assert_eq!(fields[1].0, "name"); + assert_eq!(text(&fields[1].1), "bob jones"); +} + +#[test] +fn record_terms_equal_the_native_derivations_and_probe_them() { + let cipher = cipher(); + let record = block_on(ops::encrypt_record( + &cipher, + &encode(row(34, "alice smith")), + &plan(), + )) + .expect("encrypt record"); + + let CipherText::Map(fields) = decode_tree(&record) else { + panic!("expected a field map"); + }; + assert_eq!(fields.len(), 2); + let (age_name, CipherText::Map(age_outputs)) = &fields[0] else { + panic!("expected an output map for the first field"); + }; + assert_eq!(age_name, "age"); + assert_eq!( + age_outputs + .iter() + .map(|(k, _)| k.as_str()) + .collect::<Vec<_>>(), + vec!["c", "eq", "ore"], + "output order is the plan's" + ); + + let term_bytes = |node: &CipherText<Vec<u8>, FfiValue>| -> Vec<u8> { + let CipherText::Passthrough(FfiValue::Bytes(b)) = node else { + panic!("expected a passthrough bytes term node"); + }; + b.risky_ref().to_vec() + }; + + // The stored terms are byte-identical to query-time probes built the + // native way — the property that makes the index searchable. + let eq_probe = block_on(cipher.equality_term(34u32, "users/age")).expect("probe"); + assert_eq!(term_bytes(&age_outputs[1].1), eq_probe.as_bytes()); + let ore_probe = block_on(cipher.ore_term(34u32, "users/age")).expect("probe"); + assert_eq!(term_bytes(&age_outputs[2].1), ore_probe.as_ref()); + + let (_, CipherText::Map(name_outputs)) = &fields[1] else { + panic!("expected an output map for the second field"); + }; + let match_probe = + block_on(cipher.match_terms::<DefaultMatch>("alice smith", "users/name")).expect("probe"); + assert_eq!(term_bytes(&name_outputs[1].1), match_probe.to_bytes()); + + // And the "c" node is an ordinary value-model ciphertext bound to the + // field's context. + let CipherText::Single(leaf) = &age_outputs[0].1 else { + panic!("expected a single leaf for a scalar field"); + }; + let leaf = SealedValue::from_bytes(leaf).expect("frozen leaf"); + let decipher = + block_on(cipher.decipher(CipherText::Single(leaf))).expect("retrieve the data key"); + let value = FfiValue::decrypt_with_aad(decipher, "users/age") + .expect("native decrypt of a record field"); + assert!(matches!(value, FfiValue::UInt32(34))); +} + +#[test] +fn record_shape_violations_are_encoding_errors() { + let cipher = cipher(); + + // A field missing from the row, an extra field, a non-scalar term + // source, and malformed plans. + let missing = encode(obj(vec![("age", FfiValue::UInt32(1))])); + assert_eq!( + block_on(ops::encrypt_record(&cipher, &missing, &plan())), + Err(STATUS_ENCODING) + ); + + let extra = encode(obj(vec![ + ("age", FfiValue::UInt32(1)), + ("name", s("a")), + ("stray", s("b")), + ])); + assert_eq!( + block_on(ops::encrypt_record(&cipher, &extra, &plan())), + Err(STATUS_ENCODING) + ); + + let nested = encode(obj(vec![ + ("age", FfiValue::Array(vec![FfiValue::UInt32(1)])), + ("name", s("a")), + ])); + assert_eq!( + block_on(ops::encrypt_record(&cipher, &nested, &plan())), + Err(STATUS_ENCODING), + "a term-indexed field must be a scalar" + ); + + for bad_plan in [ + obj(vec![]), // empty + obj(vec![("f", obj(vec![("context", s("c"))]))]), // no outputs + obj(vec![( + "f", + obj(vec![ + ("context", s("")), + ("outputs", FfiValue::Array(vec![s("c")])), + ]), + )]), // empty context + obj(vec![( + "f", + obj(vec![ + ("context", s("c")), + ("outputs", FfiValue::Array(vec![s("nope")])), + ]), + )]), // unknown output + obj(vec![( + "f", + obj(vec![ + ("context", s("c")), + ("outputs", FfiValue::Array(vec![s("eq"), s("eq")])), + ]), + )]), // duplicate output + ] { + assert_eq!( + block_on(ops::encrypt_record( + &cipher, + &encode(obj(vec![("f", FfiValue::UInt32(1))])), + &encode(bad_plan), + )), + Err(STATUS_ENCODING) + ); + } + + // No data keys were minted for any rejected call. + assert_eq!(cipher.kms().generate_calls.load(Ordering::SeqCst), 0); +} From d6503e5f6a4947364b39711fa1a2e9cf47f90fb0 Mon Sep 17 00:00:00 2001 From: Dan Draper <dan@cipherstash.com> Date: Mon, 31 Aug 2026 15:58:55 +1000 Subject: [PATCH 491/686] refactor(stack-kms): share ZeroKMS response classification across transports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `HttpConnection` and the WASI guest each carried their own copy of the same table: 2xx must be JSON and deserialize, and 404/401/403/409 map to specific `ViturRequestErrorKind`s so callers can tell a bad token from a missing keyset without parsing strings. Two copies of a protocol contract drift. Move it to `connection::classify`, outside the `http` feature gate, so a guest built without `http` reaches the same verdicts as the default transport. `HttpConnection` now reads the response once and delegates; `BaseUrlUnresolved`, `FailureResponse` and `UnexpectedContentType` are public so a host bringing its own transport can return the same errors. `Display` on the two response errors stays the concise form landed in f3c87fbcc for cipherstash/cipherstash-suite#2158 — status and expectation only. Body and headers are unbounded, attacker-influenced text and these types' `Display` reaches logs; both remain available through `Debug`. Claude-Session: https://claude.ai/code/session_01BpqczxAVwUsTYdCRWh9dYb --- packages/stack-kms/src/connection.rs | 8 + packages/stack-kms/src/connection/classify.rs | 252 ++++++++++++++++++ packages/stack-kms/src/connection/http.rs | 162 ++--------- packages/stack-kms/src/lib.rs | 6 + 4 files changed, 295 insertions(+), 133 deletions(-) create mode 100644 packages/stack-kms/src/connection/classify.rs diff --git a/packages/stack-kms/src/connection.rs b/packages/stack-kms/src/connection.rs index 3ba3cb230..90e2842c8 100644 --- a/packages/stack-kms/src/connection.rs +++ b/packages/stack-kms/src/connection.rs @@ -15,6 +15,14 @@ use zerokms_protocol::{ViturRequest, ViturRequestError}; use crate::endpoint::ZeroKmsEndpoint; +// Transport-independent: a guest built without the `http` feature classifies +// responses exactly as `HttpConnection` does. +mod classify; +pub use classify::{ + classify_response, is_json_content_type, BaseUrlUnresolved, FailureResponse, + UnexpectedContentType, +}; + #[cfg(feature = "http")] mod http; #[cfg(feature = "http")] diff --git a/packages/stack-kms/src/connection/classify.rs b/packages/stack-kms/src/connection/classify.rs new file mode 100644 index 000000000..e82a8bebe --- /dev/null +++ b/packages/stack-kms/src/connection/classify.rs @@ -0,0 +1,252 @@ +//! Response classification, shared by every [`ZeroKMSConnection`] regardless +//! of transport. +//! +//! Whether the bytes arrived over reqwest ([`HttpConnection`]) or over a wasm +//! host import (the WASI guest's connection), a ZeroKMS response is classified +//! the same way: 2xx must be JSON and deserialize, and 404/401/403/409 carry +//! specific [`ViturRequestErrorKind`]s so callers can tell a bad token from a +//! missing keyset without parsing strings. That table lives here, once, free +//! of any feature gate — a guest built without the `http` feature still gets +//! the same verdicts as the default transport. +//! +//! [`ZeroKMSConnection`]: crate::ZeroKMSConnection +//! [`HttpConnection`]: crate::HttpConnection + +use std::collections::HashMap; + +use serde::de::DeserializeOwned; +use serde_json::from_slice; +use thiserror::Error; +use zerokms_protocol::{ViturRequestError, ViturRequestErrorKind}; + +/// No ZeroKMS base URL is known: none was configured, and none was resolved +/// from the access token's `services` claim. +/// +/// Classified as a request-*preparation* error, never an authentication +/// failure — a caller that read it as a 401 would refresh its token and retry +/// forever against what is really a configuration problem. +#[derive(Debug, Error)] +#[error("ZeroKMS base URL was not resolved from the token's `services` claim")] +pub struct BaseUrlUnresolved; + +/// A 2xx response whose `Content-Type` is not JSON — typically a proxy or load +/// balancer answering with an HTML error page. +/// +/// `Display` carries only what was received and expected: the body and headers +/// are attacker-influenced and unbounded, and this type's `Display` reaches +/// logs. They stay available through `Debug` for structured inspection. +#[derive(Debug, Error)] +#[error("Received '{received:?}', expected '{expected}'")] +pub struct UnexpectedContentType { + pub received: Option<String>, + pub expected: &'static str, + pub body: Option<String>, + pub headers: HashMap<String, String>, +} + +/// A non-2xx ZeroKMS response. +/// +/// `Display` is the status alone, for the same reason as +/// [`UnexpectedContentType`]: body and headers are unbounded server text that +/// must not be pulled into a log line. `Debug` still carries them. +#[derive(Debug, Error)] +#[error("Status: {status}")] +pub struct FailureResponse { + pub status: u16, + pub body: Option<String>, + pub headers: HashMap<String, String>, +} + +/// `true` if a `content-type` header value denotes JSON, ignoring any +/// parameters (`application/json; charset=utf-8`) and ASCII case — proxies and +/// API gateways commonly normalise the header that way. +pub fn is_json_content_type(value: &str) -> bool { + value + .split(';') + .next() + .map(str::trim) + .is_some_and(|media_type| media_type.eq_ignore_ascii_case("application/json")) +} + +/// Classify one ZeroKMS response. +/// +/// `status` is the HTTP status code, `content_type` the response +/// `Content-Type` if the transport could read one, `body` the response body +/// (`None` when the transport read it and failed), and `headers` whatever the +/// transport can cheaply supply for the error payloads — an empty map is fine +/// for transports that do not surface them. +/// +/// The error bodies captured into [`FailureResponse`] / +/// [`UnexpectedContentType`] are non-2xx (or non-JSON) server error text, not +/// key material: the only payload that carries wrapped keys is a 2xx JSON +/// body, which is consumed by deserialization here and wiped by the caller. +pub fn classify_response<T: DeserializeOwned>( + status: u16, + content_type: Option<&str>, + body: Option<&[u8]>, + headers: HashMap<String, String>, +) -> Result<T, ViturRequestError> { + let text = || body.map(|b| String::from_utf8_lossy(b).into_owned()); + + if (200..=299).contains(&status) { + let expected = "application/json"; + if !content_type.is_some_and(is_json_content_type) { + return Err(ViturRequestError::parse( + "Invalid content type header", + UnexpectedContentType { + received: content_type.map(|ct| ct.to_owned()), + expected, + body: text(), + headers, + }, + )); + } + let body = body.ok_or_else(|| { + ViturRequestError::parse( + "Failed to deserialize response body", + FailureResponse { + status, + body: None, + headers: headers.clone(), + }, + ) + })?; + return from_slice(body) + .map_err(|e| ViturRequestError::parse("Failed to deserialize response body", e)); + } + + let failure = FailureResponse { + status, + body: text(), + headers, + }; + Err(match status { + 404 => ViturRequestError::new( + ViturRequestErrorKind::NotFound, + "Resource not found", + failure, + ), + 401 => ViturRequestError::new( + ViturRequestErrorKind::Unauthorized, + "Request unauthorized", + failure, + ), + 403 => ViturRequestError::new( + ViturRequestErrorKind::Forbidden, + "Request forbidden", + failure, + ), + 409 => ViturRequestError::new( + ViturRequestErrorKind::Conflict, + "Resource conflict", + failure, + ), + _ => ViturRequestError::other("Server returned failure response", failure), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use zerokms_protocol::Keyset; + + const KEYSETS_JSON: &[u8] = br#"[ + {"id":"6a70bd18-99ac-4650-b104-37eec3a15b09","name":"alpha","description":"","is_disabled":false,"is_default":true} + ]"#; + + fn classify( + status: u16, + content_type: Option<&str>, + body: Option<&[u8]>, + ) -> Result<Vec<Keyset>, ViturRequestError> { + classify_response(status, content_type, body, HashMap::new()) + } + + fn kind_of(result: Result<Vec<Keyset>, ViturRequestError>) -> ViturRequestErrorKind { + result.expect_err("expected an error").kind + } + + #[test] + fn accepts_json_with_or_without_parameters_and_ignoring_case() { + for value in [ + "application/json", + "application/json; charset=utf-8", + "application/json;charset=UTF-8", + " Application/JSON ; charset=utf-8", + ] { + assert!(is_json_content_type(value), "{value:?} should be accepted"); + } + } + + #[test] + fn rejects_other_media_types() { + for value in [ + "text/html", + "application/jsonx", + "text/json", + "", + "; charset=utf-8", + ] { + assert!(!is_json_content_type(value), "{value:?} should be rejected"); + } + } + + #[test] + fn success_with_json_content_type_deserializes() { + let keysets = + classify(200, Some("application/json"), Some(KEYSETS_JSON)).expect("deserializes"); + assert_eq!(keysets.len(), 1); + assert_eq!(keysets[0].name, "alpha"); + + let keysets = classify( + 200, + Some("Application/JSON; charset=utf-8"), + Some(KEYSETS_JSON), + ) + .expect("deserializes"); + assert_eq!(keysets.len(), 1); + } + + #[test] + fn success_without_json_content_type_is_a_parse_error() { + assert!(matches!( + kind_of(classify(200, None, Some(KEYSETS_JSON))), + ViturRequestErrorKind::ParseResponse + )); + // A proxy or load balancer answering 200 with an HTML error page. + assert!(matches!( + kind_of(classify( + 200, + Some("text/html"), + Some(b"<html>gateway error</html>") + )), + ViturRequestErrorKind::ParseResponse + )); + // A 2xx whose body could not be read at all. + assert!(matches!( + kind_of(classify(200, Some("application/json"), None)), + ViturRequestErrorKind::ParseResponse + )); + assert!(matches!( + kind_of(classify(200, Some("application/json"), Some(b"not json"))), + ViturRequestErrorKind::ParseResponse + )); + } + + #[test] + fn error_statuses_map_to_their_kinds() { + for (status, expected) in [ + (401, ViturRequestErrorKind::Unauthorized), + (403, ViturRequestErrorKind::Forbidden), + (404, ViturRequestErrorKind::NotFound), + (409, ViturRequestErrorKind::Conflict), + (500, ViturRequestErrorKind::Other), + ] { + assert_eq!( + std::mem::discriminant(&kind_of(classify(status, None, Some(b"nope")))), + std::mem::discriminant(&expected), + "status {status}" + ); + } + } +} diff --git a/packages/stack-kms/src/connection/http.rs b/packages/stack-kms/src/connection/http.rs index 995707e75..e8f693607 100644 --- a/packages/stack-kms/src/connection/http.rs +++ b/packages/stack-kms/src/connection/http.rs @@ -3,16 +3,17 @@ //! provide their own transport (the WASI/wazero guest) can build the crate //! without reqwest — and its native TLS stack — in the graph at all. +use super::classify::{classify_response, is_json_content_type, BaseUrlUnresolved}; use super::{ZeroKMSConnection, ZeroKMSConnectionInit}; use crate::endpoint::ZeroKmsEndpoint; use crate::user_agent::get_user_agent; -use reqwest::{header::HeaderMap, Response, StatusCode}; -use serde_json::{from_reader, to_vec}; +use reqwest::header::HeaderMap; +use serde_json::to_vec; #[cfg(not(target_arch = "wasm32"))] use std::time::Duration; use std::{collections::HashMap, sync::OnceLock}; use thiserror::Error; -use zerokms_protocol::{ViturRequest, ViturRequestError, ViturRequestErrorKind}; +use zerokms_protocol::{ViturRequest, ViturRequestError}; #[cfg(not(target_arch = "wasm32"))] const REQUEST_TIMEOUT_SECS: u64 = 10; @@ -21,10 +22,6 @@ const REQUEST_TIMEOUT_SECS: u64 = 10; #[error("Failed to initialize HTTP connection: {0}")] pub struct ConnectionInitError(#[from] reqwest::Error); -#[derive(Debug, Error)] -#[error("ZeroKMS base URL was not resolved from the token's `services` claim")] -struct BaseUrlUnresolved; - pub struct HttpConnectionOpts { base_url: Option<ZeroKmsEndpoint>, request_timeout: Option<u64>, @@ -111,45 +108,6 @@ pub struct HttpConnection { client: reqwest::Client, } -#[derive(Debug, Error)] -#[error("Received '{received:?}', expected '{expected}'")] -struct UnexpectedError { - received: Option<String>, - expected: &'static str, - body: Option<String>, - headers: HashMap<String, String>, -} - -#[derive(Debug, Error)] -#[error("Status: {status}")] -struct FailureResponse { - status: StatusCode, - body: Option<String>, - headers: HashMap<String, String>, -} - -impl FailureResponse { - async fn from_response(response: Response) -> Self { - let status = response.status(); - let headers = header_map_to_hash(response.headers()); - let body = response.text().await.ok(); - - Self { - status, - body, - headers, - } - } - - fn into_vitur_error( - self, - error_kind: ViturRequestErrorKind, - message: &'static str, - ) -> ViturRequestError { - ViturRequestError::new(error_kind, message, self) - } -} - fn header_map_to_hash(map: &HeaderMap) -> HashMap<String, String> { map.iter() .filter_map(|(k, v)| { @@ -161,17 +119,6 @@ fn header_map_to_hash(map: &HeaderMap) -> HashMap<String, String> { .collect() } -/// `true` if a `content-type` header value denotes JSON, ignoring any -/// parameters (`application/json; charset=utf-8`) and ASCII case — proxies and -/// API gateways commonly normalise the header that way. -fn is_json_content_type(value: &str) -> bool { - value - .split(';') - .next() - .map(str::trim) - .is_some_and(|media_type| media_type.eq_ignore_ascii_case("application/json")) -} - impl ZeroKMSConnectionInit for HttpConnection { type ConnectionOpts = HttpConnectionOpts; type Error = ConnectionInitError; @@ -257,55 +204,34 @@ impl ZeroKMSConnection for HttpConnection { .map_err(|e| ViturRequestError::send("Failed to send request", e))?; let status = response.status(); + let content_type = response + .headers() + .get("content-type") + .and_then(|x| x.to_str().ok()) + .map(str::to_owned); + let headers = header_map_to_hash(response.headers()); - if status.is_success() { - // Ok response - let content_type = response - .headers() - .get("content-type") - .and_then(|x| x.to_str().ok()); - - let expected = "application/json"; - - if !content_type.is_some_and(is_json_content_type) { - return Err(ViturRequestError::parse( - "Invalid content type header", - UnexpectedError { - received: content_type.map(|x| x.into()), - expected, - headers: header_map_to_hash(response.headers()), - body: response.text().await.ok(), - }, - )); - } - - let response_bytes = response.bytes().await.map_err(|e| { + // Only the one path that actually needs the bytes — a 2xx already + // known to be JSON — reports an unreadable body as its own error; + // every other path classifies with whatever it could read, exactly as + // the pre-shared-classifier code did. + let deserializing = + status.is_success() && content_type.as_deref().is_some_and(is_json_content_type); + let body = response.bytes().await; + let body = if deserializing { + Some(body.map_err(|e| { ViturRequestError::parse("Failed to read response body as bytes", e) - })?; - - from_reader(&response_bytes[..]) - .map_err(|e| ViturRequestError::parse("Failed to deserialize response body", e)) + })?) } else { - // Error handling - let failure = FailureResponse::from_response(response).await; - - let err = match status { - StatusCode::NOT_FOUND => { - failure.into_vitur_error(ViturRequestErrorKind::NotFound, "Resource not found") - } - StatusCode::UNAUTHORIZED => failure - .into_vitur_error(ViturRequestErrorKind::Unauthorized, "Request unauthorized"), - StatusCode::FORBIDDEN => { - failure.into_vitur_error(ViturRequestErrorKind::Forbidden, "Request forbidden") - } - StatusCode::CONFLICT => { - failure.into_vitur_error(ViturRequestErrorKind::Conflict, "Resource conflict") - } - _ => ViturRequestError::other("Server returned failure response", failure), - }; - - Err(err) - } + body.ok() + }; + + classify_response( + status.as_u16(), + content_type.as_deref(), + body.as_deref(), + headers, + ) } } @@ -388,33 +314,3 @@ mod base_url_tests { ); } } - -#[cfg(test)] -mod content_type_tests { - use super::is_json_content_type; - - #[test] - fn accepts_json_with_or_without_parameters_and_ignoring_case() { - for value in [ - "application/json", - "application/json; charset=utf-8", - "application/json;charset=UTF-8", - " Application/JSON ; charset=utf-8", - ] { - assert!(is_json_content_type(value), "{value:?} should be accepted"); - } - } - - #[test] - fn rejects_other_media_types() { - for value in [ - "text/html", - "application/jsonx", - "text/json", - "", - "; charset=utf-8", - ] { - assert!(!is_json_content_type(value), "{value:?} should be rejected"); - } - } -} diff --git a/packages/stack-kms/src/lib.rs b/packages/stack-kms/src/lib.rs index 283987912..d869aa421 100644 --- a/packages/stack-kms/src/lib.rs +++ b/packages/stack-kms/src/lib.rs @@ -107,6 +107,12 @@ pub use client::{ #[cfg(feature = "http")] pub use connection::{ConnectionInitError, HttpConnection, HttpConnectionOpts}; pub use connection::{ZeroKMSConnection, ZeroKMSConnectionInit}; +// Transport-independent response classification, shared by `HttpConnection` +// and by hosts that bring their own transport (the WASI guest). +pub use connection::{ + classify_response, is_json_content_type, BaseUrlUnresolved, FailureResponse, + UnexpectedContentType, +}; pub use endpoint::{InvalidEndpoint, ZeroKmsEndpoint}; // The native/wasm32 Send split for the async traits' returned futures From 76a0605d160792acd4790da8bb1179f620a298c6 Mon Sep 17 00:00:00 2001 From: Dan Draper <dan@cipherstash.com> Date: Mon, 31 Aug 2026 15:59:09 +1000 Subject: [PATCH 492/686] feat(stack-kms): accept hex or base64 client key material MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ClientKey::from_hex_v1` is strict lowercase hex, but the encodings a user actually holds are not: `secretkey.json` serialises standard padded base64, and hex pasted from elsewhere may be upper case. Front-ends that take key material from an untyped boundary — an environment variable, a config file, the WASI guest's FFI config object — were rejecting valid keys for their encoding alone. Add `from_encoded_v1`, the lenient counterpart, matching what `SecretKey::from_hex` and `EnvKeyProvider` already accept. Decoding stays constant-time (`base16ct` / `base64ct`) and the intermediate bytes are wiped on every path. Claude-Session: https://claude.ai/code/session_01BpqczxAVwUsTYdCRWh9dYb --- packages/stack-kms/src/key.rs | 79 +++++++++++++++++++++++++++++++++++ 1 file changed, 79 insertions(+) diff --git a/packages/stack-kms/src/key.rs b/packages/stack-kms/src/key.rs index f9183c296..c919ba9a1 100644 --- a/packages/stack-kms/src/key.rs +++ b/packages/stack-kms/src/key.rs @@ -50,6 +50,33 @@ impl ClientKey { keyset: V1KeySet::from_hex(hex)?, }) } + + /// Build a v1 client key from encoded material in whichever form the + /// caller happens to hold it: **lowercase or mixed-case hex** (the + /// historical `CS_CLIENT_KEY` format, and what [`to_hex_v1`] emits) **or + /// standard padded base64** (what `secretkey.json` serialises). + /// + /// [`from_hex_v1`] is the strict lowercase-hex decoder; this is the + /// lenient one, matching [`SecretKey::from_hex`] and + /// `EnvKeyProvider`. Front-ends that take key material from an + /// untyped boundary — an environment variable, a config file, the WASI + /// guest's FFI config object — should use this, so a user pasting the + /// value out of `secretkey.json` is not rejected for the encoding. + /// + /// Decoding is constant-time (`base16ct` / `base64ct`); the intermediate + /// bytes are wiped before returning either way. + /// + /// [`to_hex_v1`]: ClientKey::to_hex_v1 + /// [`from_hex_v1`]: ClientKey::from_hex_v1 + /// [`SecretKey::from_hex`]: crate::SecretKey::from_hex + pub fn from_encoded_v1(key_id: Uuid, encoded: &str) -> serde_cbor::Result<Self> { + let mut bytes = crate::secret_key::decode_client_key_material(encoded).map_err(|e| { + <serde_cbor::Error as serde::de::Error>::custom(format!("invalid encoding: {e}")) + })?; + let result = Self::from_bytes(key_id, &bytes); + bytes.zeroize(); + result + } } // FIXME: This shouldn't be Clone but it is needed right now for the JSONB indexer. @@ -327,6 +354,58 @@ mod tests { } } + mod from_encoded_v1 { + use super::*; + use base64ct::Encoding; + + /// The three encodings a user can plausibly be holding: what + /// `to_hex_v1` emits, the same value shouted, and what + /// `secretkey.json` serialises. + #[test] + fn accepts_lowercase_hex_uppercase_hex_and_base64() { + let id = uuid::Uuid::new_v4(); + let key = ClientKey::new_v1(id, random_keyset()); + let hex = key.to_hex_v1().unwrap(); + let bytes = base16ct::lower::decode_vec(&hex).unwrap(); + let base64 = base64ct::Base64::encode_string(&bytes); + + for (label, encoded) in [ + ("lowercase hex", hex.clone()), + ("uppercase hex", hex.to_uppercase()), + ("base64", base64), + ] { + let restored = ClientKey::from_encoded_v1(id, &encoded) + .unwrap_or_else(|e| panic!("{label} must decode: {e}")); + assert_eq!(restored.key_id, id, "{label}"); + assert_eq!( + restored.to_hex_v1().unwrap(), + hex, + "{label} must recover the same keyset" + ); + } + } + + #[test] + fn rejects_material_that_is_neither_hex_nor_base64() { + let err = ClientKey::from_encoded_v1(uuid::Uuid::nil(), "not hex or base64 !!") + .expect_err("must reject"); + assert!( + err.to_string().contains("invalid encoding"), + "expected the encoding message, got: {err}" + ); + } + + #[test] + fn rejects_well_encoded_material_that_is_not_a_keyset() { + let err = + ClientKey::from_encoded_v1(uuid::Uuid::nil(), "deadbeef").expect_err("must reject"); + assert!( + !err.to_string().contains("invalid encoding"), + "well-encoded bytes of the wrong shape must fail at keyset decoding, got: {err}" + ); + } + } + mod v1_keyset_deserialize { use super::super::V1KeySet; From 946bbd8aca2447944684878ca0ff2416e3072cd7 Mon Sep 17 00:00:00 2001 From: Dan Draper <dan@cipherstash.com> Date: Mon, 31 Aug 2026 15:59:21 +1000 Subject: [PATCH 493/686] feat(stack-encrypt): expose is_degenerate_aad for FFI front-ends MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `EncryptFrom`/`DecryptInto` already refuse a context that carries no caller information, including the shapes that are not literally zero-length (`pae([])`, `0u64`). Runtime front-ends that build contexts from untrusted input — the WASI guest, where a context arrives as bytes off the FFI boundary rather than a Rust literal — need the *same* predicate at parse time, or they can seal through a path that skips the check and produce rows that never open. Make the predicate public rather than have callers approximate it with `is_empty`. It goes away with the rest of this scaffolding when vitaminc#291 lands. Claude-Session: https://claude.ai/code/session_01BpqczxAVwUsTYdCRWh9dYb --- packages/stack-encrypt/src/lib.rs | 6 +++--- packages/stack-encrypt/src/target/mod.rs | 12 +++++++++--- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/packages/stack-encrypt/src/lib.rs b/packages/stack-encrypt/src/lib.rs index 2a42b745d..a40bbd2d6 100644 --- a/packages/stack-encrypt/src/lib.rs +++ b/packages/stack-encrypt/src/lib.rs @@ -145,9 +145,9 @@ pub use cipher::{ StackCipher, StackCipherBuilder, StackCipherText, StackDecipher, }; pub use target::{ - DecryptContext, DecryptField, DecryptFrom, DecryptInto, DecryptTarget, Decryptable, - EncryptContext, EncryptFrom, EncryptInto, EncryptTarget, Pending, PendingFuture, Request, - Responses, SuppliedContext, + is_degenerate_aad, DecryptContext, DecryptField, DecryptFrom, DecryptInto, DecryptTarget, + Decryptable, EncryptContext, EncryptFrom, EncryptInto, EncryptTarget, Pending, PendingFuture, + Request, Responses, SuppliedContext, }; // Re-export the vitaminc AEAD surface callers need to drive the cipher, so they diff --git a/packages/stack-encrypt/src/target/mod.rs b/packages/stack-encrypt/src/target/mod.rs index c43a88e2d..7f4f5cc9d 100644 --- a/packages/stack-encrypt/src/target/mod.rs +++ b/packages/stack-encrypt/src/target/mod.rs @@ -519,10 +519,16 @@ mod prf_framing { /// `for_map_entry`, the markers — are derived after this check runs, from /// the caller-visible AAD this sees.) /// -/// Crate-private: third-party leaves go through [`supplied_aad`] / +/// In-crate, third-party leaves go through [`supplied_aad`] / /// [`supplied_prf_context`], the one choke point whose signature survives -/// the vitaminc#291 migration. -pub(crate) fn is_degenerate_aad(bytes: &[u8]) -> bool { +/// the vitaminc#291 migration. Public so that runtime front-ends which build +/// contexts from untrusted input — the WASI guest's record plans, where a +/// context is bytes off the FFI boundary rather than a Rust literal — can +/// run the *same* predicate at parse time. Without it a degenerate context +/// could seal through a path that bypasses the leaf checks and then never +/// open, because [`DecryptInto`] does run them. It disappears with the rest +/// of this scaffolding when vitaminc#291 lands. +pub fn is_degenerate_aad(bytes: &[u8]) -> bool { if bytes.is_empty() { return true; } From 166b1137c66a1925bfab5164af86a354f0e3b8ee Mon Sep 17 00:00:00 2001 From: Dan Draper <dan@cipherstash.com> Date: Mon, 31 Aug 2026 15:59:36 +1000 Subject: [PATCH 494/686] fix(wasi): correct guest error statuses and reclaim host buffers first MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three defects, all reachable from a host that behaves badly or a client that is merely misconfigured: - `Error::Auth(_)` mapped wholesale to `STATUS_KMS_UNAUTHORIZED`, which tells a Go host to refresh its token and retry. But `get_token` folds a refused credential together with "no `zerokms_url` configured and the token carries no ZeroKMS `services` claim" — a config fault no refresh can fix, so the host spins forever. `status_for_auth` splits refused credentials (5) from forbidden (6), server faults (10) and configuration or host-transport faults (9). - A `?` on the headers slot in `send` stranded the response body — a 2xx JSON body full of wrapped data keys — registered, unfreed and unwiped for the life of the instance. Likewise a host that allocated a token buffer and *then* reported failure left a live credential in that state. Both now reclaim every slot before judging any of them. - A host token read from a file or a subprocess arrives with a trailing newline, which would corrupt the `name: value\n` header buffer in `send`. Trim it, and reject an empty token rather than presenting one. `response.rs` now delegates to `stack_kms::classify_response` instead of duplicating the status table, and treats a status outside `u16` as a transport failure rather than truncating it into an unrelated code. Claude-Session: https://claude.ai/code/session_01BpqczxAVwUsTYdCRWh9dYb --- .../golang/stackencrypt/guest/src/host.rs | 82 +++++----- .../golang/stackencrypt/guest/src/response.rs | 148 +++++------------- .../golang/stackencrypt/guest/src/status.rs | 112 ++++++++++++- 3 files changed, 187 insertions(+), 155 deletions(-) diff --git a/languages/golang/stackencrypt/guest/src/host.rs b/languages/golang/stackencrypt/guest/src/host.rs index 0e4490b2e..ae39940da 100644 --- a/languages/golang/stackencrypt/guest/src/host.rs +++ b/languages/golang/stackencrypt/guest/src/host.rs @@ -40,7 +40,7 @@ use std::fmt; use std::sync::Mutex; use stack_auth::{AuthError, AuthStrategy, CustomError, SecretToken, ServiceToken}; -use stack_kms::{ZeroKMSConnection, ZeroKMSConnectionInit, ZeroKmsEndpoint}; +use stack_kms::{BaseUrlUnresolved, ZeroKMSConnection, ZeroKMSConnectionInit, ZeroKmsEndpoint}; use zeroize::Zeroizing; use zerokms_protocol::{ViturRequest, ViturRequestError}; @@ -68,21 +68,6 @@ extern "C" { fn token_get(token_ptr_out: *mut u32, token_len_out: *mut u32) -> i32; } -/// The ZeroKMS base URL is not known yet (no `zerokms_url` in the cipher -/// config, and the first token carried no usable `services` claim). -/// Mirrors the reference `HttpConnection`: a request-preparation error, not -/// an authentication failure, so callers don't trigger a refresh loop. -#[derive(Debug)] -struct BaseUrlUnresolved; - -impl fmt::Display for BaseUrlUnresolved { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "no ZeroKMS base URL is configured or resolved") - } -} - -impl std::error::Error for BaseUrlUnresolved {} - /// The host stored an out-slot pointer the guest's buffer registry does not /// know (or with a mismatched length) — a host-side bookkeeping bug. #[derive(Debug)] @@ -147,6 +132,14 @@ impl ZeroKMSConnection for WasiHostConnection { request: Request, access_token: &str, ) -> Result<Request::Response, ViturRequestError> { + // Defence in depth, shared verbatim with `HttpConnection`: + // `StackKms::get_token` resolves the endpoint (or fails with + // `AuthError::InvalidToken`, which the status layer reports as + // `STATUS_KMS_TRANSPORT`) before any caller reaches here, so on the + // client's own paths this is unreachable. `send` is public trait API + // though, and a *prepare* error is the answer that keeps a direct + // caller from mistaking a missing endpoint for a 401 and refreshing + // in a loop. let url = self .base() .as_ref() @@ -195,18 +188,23 @@ impl ZeroKMSConnection for WasiHostConnection { ) }; - let unregistered = - || ViturRequestError::parse("Host response buffer failed validation", HostBufferError); + // Reclaim *both* slots before judging either. A `?` on the headers + // slot would otherwise strand the body buffer — a 2xx JSON body full + // of wrapped data keys — registered, unfreed and unwiped for the life + // of the instance. + // // SAFETY: pointers come from the host's `se_alloc` calls; the // registry validates them before any Vec is rebuilt. let resp_headers = - unsafe { buffers::take(resp_headers_ptr as *mut u8, resp_headers_len as usize) } - .ok_or_else(unregistered)?; + unsafe { buffers::take(resp_headers_ptr as *mut u8, resp_headers_len as usize) }; // Response bodies carry wrapped key material — wipe on drop. - let resp_body = Zeroizing::new( - unsafe { buffers::take(resp_body_ptr as *mut u8, resp_body_len as usize) } - .ok_or_else(unregistered)?, - ); + let resp_body = unsafe { buffers::take(resp_body_ptr as *mut u8, resp_body_len as usize) } + .map(Zeroizing::new); + + let unregistered = + || ViturRequestError::parse("Host response buffer failed validation", HostBufferError); + let resp_headers = resp_headers.ok_or_else(unregistered)?; + let resp_body = resp_body.ok_or_else(unregistered)?; let content_type = header_value(&resp_headers, "content-type"); map_response(status, content_type, &resp_body) @@ -225,24 +223,36 @@ impl AuthStrategy for &HostTokenStrategy { let mut token_len: u32 = 0; // SAFETY: the out-slots are stack locals the host writes once. let status = unsafe { token_get(&mut token_ptr, &mut token_len) }; + // Reclaim before judging the status: a host that allocated the token + // buffer *and then* reported a failure would otherwise leave a live + // credential registered, unfreed and unwiped. + // + // SAFETY: the pointer comes from the host's `se_alloc` call; the + // registry validates it before any Vec is rebuilt. + let bytes = + unsafe { buffers::take(token_ptr as *mut u8, token_len as usize) }.map(Zeroizing::new); if status != 0 { return Err(AuthError::Custom(CustomError(format!( "host token_get failed with status {status}" )))); } - // SAFETY: the pointer comes from the host's `se_alloc` call; the - // registry validates it before any Vec is rebuilt. - let bytes = Zeroizing::new( - unsafe { buffers::take(token_ptr as *mut u8, token_len as usize) }.ok_or_else( - || { - AuthError::Custom(CustomError( - "host token buffer failed validation".to_string(), - )) - }, - )?, - ); + let bytes = bytes.ok_or_else(|| { + AuthError::Custom(CustomError( + "host token buffer failed validation".to_string(), + )) + })?; let text = std::str::from_utf8(&bytes) - .map_err(|_| AuthError::Custom(CustomError("host token is not UTF-8".to_string())))?; + .map_err(|_| AuthError::Custom(CustomError("host token is not UTF-8".to_string())))? + // A host that read the token from a file or a subprocess hands it + // over with the trailing newline still attached; left in place it + // would break the `name: value\n` header buffer in `send`, and no + // bearer token has meaningful surrounding whitespace anyway. + .trim(); + if text.is_empty() { + return Err(AuthError::Custom(CustomError( + "host token is empty".to_string(), + ))); + } // `SecretToken` wipes on drop; `bytes` (the only other copy) wipes // via its `Zeroizing` wrapper above. Ok(ServiceToken::new(SecretToken::new(text))) diff --git a/languages/golang/stackencrypt/guest/src/response.rs b/languages/golang/stackencrypt/guest/src/response.rs index 11e4f24b4..d784e5a5e 100644 --- a/languages/golang/stackencrypt/guest/src/response.rs +++ b/languages/golang/stackencrypt/guest/src/response.rs @@ -1,15 +1,22 @@ -//! Pure response-mapping logic for the host transport, mirroring the -//! reference `HttpConnection` in `stack-kms/src/connection/http.rs`: -//! status-code → [`ViturRequestErrorKind`] mapping and the content-type -//! validation performed before deserializing a success body. Kept free of -//! any wasm ABI concerns so it compiles — and its unit tests run — on the -//! native host target. (Ported from the #2099 spike's `response.rs`, -//! re-based on stack-kms's connection rather than `cipherstash-client`'s.) - +//! The host transport's one piece of response logic that is *not* shared with +//! the reference `HttpConnection`: turning the import's `i32` return into +//! either a transport failure or an HTTP status. +//! +//! Everything past that — the 2xx content-type check, JSON deserialization, +//! and the 404/401/403/409 → [`ViturRequestErrorKind`] table — is +//! [`stack_kms::classify_response`], which lives outside the `http` feature +//! gate precisely so this guest and `HttpConnection` cannot drift apart. Kept +//! free of any wasm ABI concerns so it compiles — and its unit tests run — on +//! the native host target. +//! +//! [`ViturRequestErrorKind`]: zerokms_protocol::ViturRequestErrorKind + +use std::collections::HashMap; use std::fmt; use serde::de::DeserializeOwned; -use zerokms_protocol::{ViturRequestError, ViturRequestErrorKind}; +use stack_kms::classify_response; +use zerokms_protocol::ViturRequestError; /// The host reported it could not perform the HTTP call at all (negative /// status). The body carries the host's error text. @@ -24,125 +31,37 @@ impl fmt::Display for TransportFailure { impl std::error::Error for TransportFailure {} -/// A non-2xx ZeroKMS response. -#[derive(Debug)] -pub struct FailureResponse { - pub status: i32, - pub body: String, -} - -impl fmt::Display for FailureResponse { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "Status: {}, Body: {}", self.status, self.body) - } -} - -impl std::error::Error for FailureResponse {} - -/// A 2xx response whose Content-Type is not JSON — typically a proxy or load -/// balancer answering with an HTML error page. -#[derive(Debug)] -pub struct UnexpectedContentType { - pub received: Option<String>, - pub expected: &'static str, - pub body: String, -} - -impl fmt::Display for UnexpectedContentType { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!( - f, - "Received '{:?}', expected '{}', Body: {}", - self.received, self.expected, self.body - ) - } -} - -impl std::error::Error for UnexpectedContentType {} - -/// `true` if a `content-type` header value denotes JSON, ignoring any -/// parameters (`application/json; charset=utf-8`) and ASCII case — the same -/// tolerance as the reference `HttpConnection` (proxies and API gateways -/// commonly normalise the header that way). -fn is_json_content_type(value: &str) -> bool { - value - .split(';') - .next() - .map(str::trim) - .is_some_and(|media_type| media_type.eq_ignore_ascii_case("application/json")) -} - /// Map a host transport result onto the ZeroKMS protocol contract. /// /// `status` is the HTTP status code, or negative for a transport-level /// failure (in which case `body` carries the host's error text). /// `content_type` is the response Content-Type header, if any. /// -/// The error bodies captured into [`FailureResponse`] / -/// [`UnexpectedContentType`] are non-2xx (or non-JSON) server error text, -/// not key material — the only payload that carries wrapped keys is a 2xx -/// JSON body, which is consumed by deserialization and wiped by the caller. +/// A status outside the `u16` range is a host that is not honouring the +/// import contract; it is treated as a transport failure rather than being +/// truncated into some unrelated code. pub fn map_response<T: DeserializeOwned>( status: i32, content_type: Option<&str>, body: &[u8], ) -> Result<T, ViturRequestError> { - match status { - s if s < 0 => Err(ViturRequestError::send( + let Ok(status) = u16::try_from(status) else { + return Err(ViturRequestError::send( "Host transport reported a failure", TransportFailure(String::from_utf8_lossy(body).into_owned()), - )), - 200..=299 => { - let expected = "application/json"; - if !content_type.is_some_and(is_json_content_type) { - return Err(ViturRequestError::parse( - "Invalid content type header", - UnexpectedContentType { - received: content_type.map(|ct| ct.to_owned()), - expected, - body: String::from_utf8_lossy(body).into_owned(), - }, - )); - } - serde_json::from_slice(body) - .map_err(|e| ViturRequestError::parse("Failed to deserialize response body", e)) - } - status => { - let failure = FailureResponse { - status, - body: String::from_utf8_lossy(body).into_owned(), - }; - Err(match status { - 404 => ViturRequestError::new( - ViturRequestErrorKind::NotFound, - "Resource not found", - failure, - ), - 401 => ViturRequestError::new( - ViturRequestErrorKind::Unauthorized, - "Request unauthorized", - failure, - ), - 403 => ViturRequestError::new( - ViturRequestErrorKind::Forbidden, - "Request forbidden", - failure, - ), - 409 => ViturRequestError::new( - ViturRequestErrorKind::Conflict, - "Resource conflict", - failure, - ), - _ => ViturRequestError::other("Server returned failure response", failure), - }) - } - } + )); + }; + // The guest does not carry the response headers into the error payloads: + // it has already read the only one it needs (content-type), and the rest + // would be an extra copy of attacker-influenced bytes for a Display + // string nothing reads. + classify_response(status, content_type, Some(body), HashMap::new()) } #[cfg(test)] mod tests { use super::*; - use zerokms_protocol::Keyset; + use zerokms_protocol::{Keyset, ViturRequestErrorKind}; fn kind_of(result: Result<Vec<Keyset>, ViturRequestError>) -> ViturRequestErrorKind { result.expect_err("expected an error").kind @@ -162,7 +81,8 @@ mod tests { #[test] fn json_content_type_tolerates_parameters_and_case() { - // Same tolerance as the reference `HttpConnection`. + // Same tolerance as the reference `HttpConnection` — literally the + // same predicate now. let keysets: Vec<Keyset> = map_response(200, Some("Application/JSON; charset=utf-8"), KEYSETS_JSON) .expect("deserializes"); @@ -200,6 +120,12 @@ mod tests { kind_of(map_response(-1, None, b"connection refused")), ViturRequestErrorKind::SendRequest )); + // A status the import contract cannot mean is a transport failure + // too, never a truncated code. + assert!(matches!( + kind_of(map_response(70_000, None, b"nonsense")), + ViturRequestErrorKind::SendRequest + )); } #[test] diff --git a/languages/golang/stackencrypt/guest/src/status.rs b/languages/golang/stackencrypt/guest/src/status.rs index 600425b43..1cea848d5 100644 --- a/languages/golang/stackencrypt/guest/src/status.rs +++ b/languages/golang/stackencrypt/guest/src/status.rs @@ -13,6 +13,7 @@ //! term-derivation failure (a caller-input condition, e.g. match text that //! yields no tokens). +use stack_auth::AuthError; use stack_kms::{GenerateKeyError, LoadKeysetError, RetrieveKeyError}; use zerokms_protocol::ViturRequestErrorKind; @@ -27,20 +28,31 @@ pub const STATUS_BAD_HANDLE: u32 = 3; /// A caught panic, handle-id exhaustion, a response that did not match its /// requests, or any other unexpected internal failure. pub const STATUS_INTERNAL: u32 = 4; -/// ZeroKMS (or the auth strategy) rejected the request as unauthenticated: -/// a missing, expired, or invalid access token. +/// ZeroKMS (or the auth strategy) rejected the *credential*: an expired or +/// rejected access token, or a credential exchange the server refused. +/// +/// The one status a host should answer by refreshing the token and retrying. +/// Deliberately narrow for that reason: a configuration fault that merely +/// *arrives* through the auth strategy — a token with no ZeroKMS `services` +/// claim, a host `token_get` that failed — is [`STATUS_KMS_TRANSPORT`], since +/// no number of refreshes can fix it. pub const STATUS_KMS_UNAUTHORIZED: u32 = 5; /// ZeroKMS rejected the request as forbidden: the token is valid but lacks -/// permission (or the keyset is disabled). +/// permission (or the keyset is disabled, or the organisation is over its +/// usage allowance). pub const STATUS_KMS_FORBIDDEN: u32 = 6; /// ZeroKMS could not find the resource: an unknown keyset (or client), or a /// data key that does not exist for the presented `iv`/`tag`. pub const STATUS_KMS_NOT_FOUND: u32 = 7; /// ZeroKMS reported a resource conflict. pub const STATUS_KMS_CONFLICT: u32 = 8; -/// The transport failed before a ZeroKMS verdict: the host's HTTP call -/// errored, the endpoint is unknown or invalid, or the request could not be -/// prepared. +/// No ZeroKMS verdict was reached: the host's `transport_send` errored, the +/// host's `token_get` errored, the endpoint is unknown or invalid (no +/// `zerokms_url` in the config *and* no ZeroKMS entry in the token's +/// `services` claim), or the request could not be prepared. +/// +/// Not retryable by refreshing a token — these are configuration or host +/// faults. See [`STATUS_KMS_UNAUTHORIZED`] for the one that is. pub const STATUS_KMS_TRANSPORT: u32 = 9; /// ZeroKMS failed in a way none of the codes above capture: a malformed /// response, invalid key material, or an unclassified server error. @@ -96,8 +108,7 @@ fn status_for_kms(error: &stack_kms::Error) -> u32 { LoadKeysetError::RequestFailed(e) => status_for_kind(&e.kind), _ => STATUS_KMS_OTHER, }, - // No token means no authenticated request could even be attempted. - stack_kms::Error::Auth(_) => STATUS_KMS_UNAUTHORIZED, + stack_kms::Error::Auth(auth) => status_for_auth(auth), stack_kms::Error::ConnectionInit(_) | stack_kms::Error::InvalidEndpoint(_) => { STATUS_KMS_TRANSPORT } @@ -105,6 +116,42 @@ fn status_for_kms(error: &stack_kms::Error) -> u32 { } } +/// Split the auth strategy's failures into "the credential was refused" +/// (retry after a refresh) and "the client is misconfigured" (retrying is a +/// spin). +/// +/// This split matters because `StackKms::get_token` runs *before* any request +/// leaves the guest and folds two very different things into +/// [`stack_kms::Error::Auth`]: a genuinely refused credential, and +/// `token.zerokms_url()` failing because the config named no `zerokms_url` +/// and the host's token carries no ZeroKMS `services` claim — an +/// `AuthError::InvalidToken`. Mapping the latter to +/// [`STATUS_KMS_UNAUTHORIZED`] would tell a Go host to refresh its token and +/// try again, forever, over a config problem no token can fix. +fn status_for_auth(error: &AuthError) -> u32 { + match error { + // The server (or the strategy) refused the credential itself: a new + // token is the fix. + AuthError::NotAuthenticated(_) + | AuthError::TokenExpired(_) + | AuthError::InvalidGrant(_) + | AuthError::InvalidClient(_) + | AuthError::InvalidAccessKey(_) + | AuthError::AlreadyConsumed(_) => STATUS_KMS_UNAUTHORIZED, + // Authenticated, but not allowed. + AuthError::AccessDenied(_) | AuthError::UsageLimitExceeded(_) => STATUS_KMS_FORBIDDEN, + // Server-side faults with no client-side remedy. + AuthError::Server(_) | AuthError::Internal(_) => STATUS_KMS_OTHER, + // Everything else is configuration or host transport: a malformed or + // claim-less token (`InvalidToken` — the unresolved-endpoint case), a + // bad URL/CRN/region/workspace, a failed request to the token issuer, + // or `Custom`, which is what `HostTokenStrategy` reports when the + // host's `token_get` import returns non-zero or hands back bytes that + // are not a token. + _ => STATUS_KMS_TRANSPORT, + } +} + fn status_for_kind(kind: &ViturRequestErrorKind) -> u32 { match kind { ViturRequestErrorKind::Unauthorized => STATUS_KMS_UNAUTHORIZED, @@ -166,6 +213,55 @@ mod tests { assert_eq!(status_for_error(&err), STATUS_KMS_NOT_FOUND); } + /// The exact configuration the guest hits when `se_cipher_init` is given + /// no `zerokms_url` and the host hands over a token with no ZeroKMS + /// `services` claim: `StackKms::get_token` fails *before* sending + /// anything, with the real error this produces. It must not read as "your + /// token was rejected". + #[test] + fn an_unresolvable_endpoint_is_transport_not_unauthorized() { + use stack_auth::{SecretToken, ServiceToken}; + + // A token that is not a CTS-minted JWT, so it carries no services + // claim at all — the error comes from `zerokms_url()` itself, not a + // hand-built variant. + let token = ServiceToken::new(SecretToken::new("not-a-cts-jwt")); + let err = token + .zerokms_url() + .expect_err("a non-JWT has no services claim"); + assert!( + matches!(err, stack_auth::AuthError::InvalidToken(_)), + "expected InvalidToken, got: {err:?}" + ); + + let status = status_for_error(&stack_encrypt::Error::Kms(stack_kms::Error::Auth(err))); + assert_eq!( + status, STATUS_KMS_TRANSPORT, + "a config fault must not tell the host to refresh and retry" + ); + } + + #[test] + fn a_failed_host_token_import_is_transport_not_unauthorized() { + // What `HostTokenStrategy` reports when `token_get` returns non-zero. + let err = stack_auth::AuthError::Custom(stack_auth::CustomError( + "host token_get failed with status 7".to_string(), + )); + assert_eq!( + status_for_error(&stack_encrypt::Error::Kms(stack_kms::Error::Auth(err))), + STATUS_KMS_TRANSPORT + ); + } + + #[test] + fn a_refused_credential_is_still_unauthorized() { + let err = stack_auth::AuthError::TokenExpired(stack_auth::TokenExpired); + assert_eq!( + status_for_error(&stack_encrypt::Error::Kms(stack_kms::Error::Auth(err))), + STATUS_KMS_UNAUTHORIZED + ); + } + #[test] fn term_errors_split_empty_context_from_derivation() { assert_eq!( From 4461bb34e4373e0ff6f2d437c190b17c3c94028f Mon Sep 17 00:00:00 2001 From: Dan Draper <dan@cipherstash.com> Date: Mon, 31 Aug 2026 15:59:44 +1000 Subject: [PATCH 495/686] fix(wasi): accept every documented client-key encoding in guest config The config table promised hex, but the value a user copies out of `secretkey.json` is base64, and hex from elsewhere may be upper case. Parse with `ClientKey::from_encoded_v1` so all three forms work, and say so in the table. Every config slot is now `Zeroizing<String>`, not just the key one: the key slot *must* wipe on every exit path, since any `?` below it can fire while it holds a full encoding of the client root key, and making one slot special invites the next edit to add an early return above the wipe. Claude-Session: https://claude.ai/code/session_01BpqczxAVwUsTYdCRWh9dYb --- .../golang/stackencrypt/guest/Cargo.lock | 2 + .../golang/stackencrypt/guest/Cargo.toml | 4 ++ .../golang/stackencrypt/guest/src/config.rs | 71 ++++++++++++++----- 3 files changed, 60 insertions(+), 17 deletions(-) diff --git a/languages/golang/stackencrypt/guest/Cargo.lock b/languages/golang/stackencrypt/guest/Cargo.lock index c1acefafb..e8cc66af8 100644 --- a/languages/golang/stackencrypt/guest/Cargo.lock +++ b/languages/golang/stackencrypt/guest/Cargo.lock @@ -1979,6 +1979,8 @@ dependencies = [ name = "stack-encrypt-guest" version = "0.0.0" dependencies = [ + "base16ct", + "base64ct", "futures", "recipher", "serde", diff --git a/languages/golang/stackencrypt/guest/Cargo.toml b/languages/golang/stackencrypt/guest/Cargo.toml index 483899400..379fa61a2 100644 --- a/languages/golang/stackencrypt/guest/Cargo.toml +++ b/languages/golang/stackencrypt/guest/Cargo.toml @@ -45,6 +45,10 @@ zeroize = "1" stack-kms = { path = "../../../../packages/stack-kms", default-features = false, features = ["test-support"] } # For minting a valid client-key fixture in the config tests. recipher = { path = "../../../../packages/recipher" } +# Re-encoding that fixture into the other forms the config table promises +# (upper-case hex, base64) so the lenient decode is actually exercised. +base16ct = { version = "0.2.0", features = ["alloc"] } +base64ct = { version = "1.7", features = ["alloc"] } [profile.release] # Smaller .wasm; the guest is IO-bound on the FFI copy and the ZeroKMS round diff --git a/languages/golang/stackencrypt/guest/src/config.rs b/languages/golang/stackencrypt/guest/src/config.rs index d0e1ede4d..b7416cda3 100644 --- a/languages/golang/stackencrypt/guest/src/config.rs +++ b/languages/golang/stackencrypt/guest/src/config.rs @@ -7,7 +7,7 @@ //! | key | required | meaning | //! |---------------|----------|---------| //! | `client_id` | yes | ZeroKMS client id (UUID) | -//! | `client_key` | yes | the client key, hex-encoded (the v1 `to_hex_v1` encoding) | +//! | `client_key` | yes | the v1 client key material, hex-encoded (upper or lower case — the `to_hex_v1` / `CS_CLIENT_KEY` form) or standard padded base64 (the form `secretkey.json` serialises) | //! | `keyset` | no | keyset *name* to pin the cipher to | //! | `keyset_id` | no | keyset *id* (UUID) to pin the cipher to | //! | `zerokms_url` | no | pins the ZeroKMS endpoint at init; when absent the endpoint is resolved from the access token's `services` claim on first use | @@ -23,6 +23,7 @@ use stack_kms::{ClientKey, IdentifiedBy, ZeroKmsEndpoint}; use uuid::Uuid; use vitaminc_aead_value::FfiValue; +use zeroize::Zeroizing; /// A parse failure, carrying which key was at fault. Maps to /// `STATUS_ENCODING` at the ABI; the detail exists for the native tests and @@ -57,16 +58,21 @@ pub fn parse_config(value: FfiValue) -> Result<CipherConfig, ConfigError> { return Err(ConfigError::NotAnObject); }; - let mut client_id: Option<String> = None; - let mut client_key_hex: Option<String> = None; - let mut keyset_name: Option<String> = None; - let mut keyset_id: Option<String> = None; - let mut url: Option<String> = None; + // Every slot is a `Zeroizing<String>`, not just the key one: the + // client-key slot *must* wipe on every exit path (any of the `?`s below + // can fire while it holds a full encoding of the client root key), and + // making one slot special invites the next edit to add an early return + // above the wipe. Uniform is cheaper than remembering. + let mut client_id: Option<Zeroizing<String>> = None; + let mut client_key_encoded: Option<Zeroizing<String>> = None; + let mut keyset_name: Option<Zeroizing<String>> = None; + let mut keyset_id: Option<Zeroizing<String>> = None; + let mut url: Option<Zeroizing<String>> = None; for (key, value) in entries { let slot = match key.as_str() { "client_id" => &mut client_id, - "client_key" => &mut client_key_hex, + "client_key" => &mut client_key_encoded, "keyset" => &mut keyset_name, "keyset_id" => &mut keyset_id, "zerokms_url" => &mut url, @@ -77,22 +83,25 @@ pub fn parse_config(value: FfiValue) -> Result<CipherConfig, ConfigError> { let FfiValue::String(s) = value else { return Err(ConfigError::NotAString(name_of(&key))); }; - let text = std::str::from_utf8(s.risky_ref()) - .map_err(|_| ConfigError::NotAString(name_of(&key)))? - .to_string(); + let text = Zeroizing::new( + std::str::from_utf8(s.risky_ref()) + .map_err(|_| ConfigError::NotAString(name_of(&key)))? + .to_string(), + ); *slot = Some(text); } let client_id = client_id.ok_or(ConfigError::Missing("client_id"))?; let client_id = Uuid::parse_str(&client_id).map_err(|_| ConfigError::Invalid("client_id"))?; - let mut hex = client_key_hex.ok_or(ConfigError::Missing("client_key"))?; - let client_key = ClientKey::from_hex_v1(client_id, &hex); - // The hex string is a full encoding of the client root key: wipe this - // copy whatever the parse outcome (the decoded FfiValue's own copy was - // consumed above; the raw input buffer is the ABI layer's to wipe). - zeroize::Zeroize::zeroize(&mut hex); - let client_key = client_key.map_err(|_| ConfigError::Invalid("client_key"))?; + // Lenient by design: `from_encoded_v1` takes hex in either case *or* the + // base64 `secretkey.json` holds, matching every native loader. The + // `Zeroizing` slot wipes the encoded copy however this returns — the + // decoded `FfiValue`'s own copy was consumed above, and the raw input + // buffer is the ABI layer's to wipe. + let client_key_encoded = client_key_encoded.ok_or(ConfigError::Missing("client_key"))?; + let client_key = ClientKey::from_encoded_v1(client_id, &client_key_encoded) + .map_err(|_| ConfigError::Invalid("client_key"))?; let keyset = match (keyset_name, keyset_id) { (Some(_), Some(_)) => return Err(ConfigError::ConflictingKeysets), @@ -191,6 +200,34 @@ mod tests { assert!(matches!(cfg.keyset, Some(IdentifiedBy::Uuid(k)) if k == keyset_id)); } + /// The config table promises hex in either case *or* base64 — the form + /// `secretkey.json` actually serialises. A user pasting the value out of + /// their profile must not be told their key is invalid. + #[test] + fn accepts_the_encodings_every_native_loader_accepts() { + use base64ct::Encoding; + + let (id, hex) = client_key_hex(); + let id_s = id.to_string(); + let bytes = base16ct::lower::decode_vec(&hex).expect("fixture hex"); + let base64 = base64ct::Base64::encode_string(&bytes); + + for (label, encoded) in [ + ("lowercase hex", hex.clone()), + ("uppercase hex", hex.to_uppercase()), + ("base64", base64), + ] { + let cfg = parse_config(obj(vec![("client_id", &id_s), ("client_key", &encoded)])) + .unwrap_or_else(|e| panic!("{label} must parse, got {e:?}")); + assert_eq!(cfg.client_key.key_id, id, "{label}"); + assert_eq!( + cfg.client_key.to_hex_v1().expect("re-encode"), + hex, + "{label} must recover the same keyset" + ); + } + } + #[test] fn rejects_bad_configs() { let (id, hex) = client_key_hex(); From aa211b9308b3ec3f08df01706be8d93e241ded0a Mon Sep 17 00:00:00 2001 From: Dan Draper <dan@cipherstash.com> Date: Mon, 31 Aug 2026 15:59:58 +1000 Subject: [PATCH 496/686] fix(wasi): refuse degenerate contexts, size output buffers exactly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A context is what makes a ciphertext belong to a field; sealing under nothing makes ciphertexts transplantable between fields. The value entry points took raw AAD bytes with no check at all, and `parse_plan` only tested `is_empty`. That left the two record paths disagreeing: `encrypt_record` seals through the cipher-directed path, which does not run stack-encrypt's context predicate, while `decrypt_record` opens through `decrypt_into`, which does — so a context of eight zero bytes (`pae([])`, what `None` encodes to) encrypted happily and then never decrypted. Every path now runs `is_degenerate_aad`, before any key is minted. Output buffers are reserved with `try_reserve_exact` at the exact encoded length, so `into_boxed_slice` finds capacity already equal to length. A shrink-to-fit that moved would free the filled block without wiping it, which is the whole hazard. Also corrects a claim in the docs: a batch is not one ZeroKMS call. All rows and fields merge into one batch, which the client then splits into one request per `max_keys_per_req` keyed leaves — 500 by default, sent sequentially — so "one call" is exact up to 500 leaves and "one call per 500" past it. The plan doc gains that, plus the note that a `"c"` leaf carries aead-value's tagged encoding, so a Go plan and a plain-primitive Rust derive do not interchange ciphertexts for the same field yet. `mise.toml` runs the guest suite under nextest, as AGENTS.md requires everywhere else. Claude-Session: https://claude.ai/code/session_01BpqczxAVwUsTYdCRWh9dYb --- docs/plans/stack-encrypt-go-bindings.md | 19 ++ .../golang/stackencrypt/guest/src/abi.rs | 59 ++++- .../golang/stackencrypt/guest/src/ops.rs | 203 ++++++++++++++---- .../stackencrypt/guest/tests/native_ops.rs | 82 +++++++ 4 files changed, 305 insertions(+), 58 deletions(-) diff --git a/docs/plans/stack-encrypt-go-bindings.md b/docs/plans/stack-encrypt-go-bindings.md index 022720f75..c935cd68b 100644 --- a/docs/plans/stack-encrypt-go-bindings.md +++ b/docs/plans/stack-encrypt-go-bindings.md @@ -309,6 +309,25 @@ key source proving one ZeroKMS call per record batch), and the release `.wasm` builds with an import surface of exactly WASI + `cipherstash_transport` (`mise run wasm:guest:build` / `wasm:guest:test`). +Two things worth stating plainly, because they are easy to read the wrong +way: + +- **Batching is one *batch*, not always one *call*.** All rows and fields + of an invocation are merged into a single pending batch, which the client + then splits into one ZeroKMS request per `ClientOpts::max_keys_per_req` + keyed leaves — 500 by default, sent sequentially (the guest pins + `max_concurrent_reqs` to 1). So "one `generate_keys` call per batch" is + exact up to 500 leaves and "one call per 500" past it. The default is + kept rather than raised: it is the server-friendly request size, and a + larger one is a promise ZeroKMS need not honour. +- **Record `"c"` leaves carry the aead-value *tagged* plaintext encoding** + (`[type tag] ++ payload`), because that tag table is the cross-language + contract Go, Node and this guest share. A Rust `#[derive(EncryptFrom)]` + over a plain primitive seals untagged bytes instead, so a plain-primitive + Rust derive and a Go plan do **not** interchange ciphertexts for the same + field until the Rust side uses aead-value's tagged types. By design; a + separate follow-up, not a defect in either side. + The plan as written before the work: Location: `bindings/go/stackencrypt/guest/` (mirrors vitaminc's layout; diff --git a/languages/golang/stackencrypt/guest/src/abi.rs b/languages/golang/stackencrypt/guest/src/abi.rs index 17ea50091..1386d7b07 100644 --- a/languages/golang/stackencrypt/guest/src/abi.rs +++ b/languages/golang/stackencrypt/guest/src/abi.rs @@ -44,6 +44,23 @@ //! unwind build — wasm32-wasip1 aborts on panic. Statuses are the only //! detail leaked. //! +//! A null *or empty* AAD is rejected on every path — value, record and term +//! alike — with `STATUS_ENCODING`: sealing under no context makes +//! ciphertexts transplantable between fields, so it is never a default the +//! guest supplies for a caller who omitted one. "Empty" is +//! [`stack_encrypt::is_degenerate_aad`], so shapes that are not literally +//! zero-length (the PAE of an empty list, for instance) are rejected too. +//! +//! One difference from the vitaminc guest, deliberate: where `vc_encrypt` +//! decodes its input *before* looking up the handle — so garbage bytes read +//! as `STATUS_ENCODING` even for an unknown handle — the exports here look +//! up the handle first, because decoding lives inside [`crate::ops`] so that +//! the ops can be driven (and natively tested) as whole operations. The +//! observable difference is which status an unknown handle *and* malformed +//! input reports; `STATUS_BAD_HANDLE` is the more actionable of the two, and +//! the ordering leaks nothing either way — the handle table is consulted +//! with a value the caller already supplied. +//! //! Wasm modules are single-threaded; the host must serialize calls into one //! instance. @@ -199,6 +216,14 @@ fn cipher_init(decoded: vitaminc_aead_value::FfiValue) -> Result<u32, u32> { // One request at a time: the host import is synchronous, so concurrency // would only interleave nothing; keep the executor honest about it. + // + // `max_keys_per_req` stays at the client default (500). That is what + // bounds "one ZeroKMS call": a batch is assembled once, then + // `Client::send_chunked` splits it into sequential requests of at most + // that many keys — so a 1200-leaf record batch is three calls, not one. + // Raising it here would trade a documented, server-friendly request size + // for a claim the server need not honour, so the bound is kept and the + // docs say 500 rather than "one". let opts = ClientOpts::new(config.endpoint) .with_max_concurrent_reqs(1) .map_err(|_| STATUS_INTERNAL)?; @@ -230,9 +255,14 @@ pub extern "C" fn se_cipher_free(handle: u32) { } /// Encrypt an FFI-codec-encoded value tree under the handle's cipher, -/// binding `aad`; one batched `generate-data-key` call however many leaves. -/// Output: packed pointer to a codec-encoded ciphertext tree whose leaves -/// are the frozen `SealedValue` byte encoding. +/// binding `aad`; every leaf is sealed from one batched key request, +/// dispatched as one `generate-data-key` call per 500 keyed leaves (see +/// [`cipher_init`] for where that bound comes from). Output: packed pointer +/// to a codec-encoded ciphertext tree whose leaves are the frozen +/// `SealedValue` byte encoding. +/// +/// `aad` must be non-empty (`STATUS_ENCODING` otherwise) — see this module's +/// hostile-input notes. /// /// # Safety /// @@ -269,9 +299,13 @@ pub unsafe extern "C" fn se_encrypt_element( } /// Decrypt a codec-encoded ciphertext tree back into a codec-encoded value -/// tree; one batched `retrieve-data-key` call. The output buffer contains -/// **plaintext** — the host must copy it out and immediately release it -/// with [`se_dealloc`] (which wipes it). +/// tree; one batched key request, dispatched as one `retrieve-data-key` call +/// per 500 keyed leaves. The output buffer contains **plaintext** — the host +/// must copy it out and immediately release it with [`se_dealloc`] (which +/// wipes it). +/// +/// `aad` must be non-empty, and must be the one the ciphertext was sealed +/// under. /// /// # Safety /// @@ -375,8 +409,10 @@ pub unsafe extern "C" fn se_term( /// Encrypt a record (or a batch) per a plan — the runtime form of /// `#[derive(EncryptFrom)]`; see [`ops::encrypt_record`] for the source, -/// plan, and result encodings. One `generate-data-key` call per invocation -/// regardless of row count; terms derive locally. +/// plan, and result encodings. All rows and fields seal from **one** batched +/// key request regardless of row count — dispatched as one +/// `generate-data-key` call per 500 keyed leaves, sequentially — and terms +/// derive locally with no ZeroKMS traffic at all. /// /// # Safety /// @@ -401,9 +437,10 @@ pub unsafe extern "C" fn se_encrypt_record( } /// Decrypt a record (or a batch) produced by [`se_encrypt_record`] under -/// the same plan; only the `"c"` outputs participate. One -/// `retrieve-data-key` call per invocation. The output buffer contains -/// **plaintext** — same host obligations as [`se_decrypt`]. +/// the same plan; only the `"c"` outputs participate. One batched key +/// request per invocation, dispatched as one `retrieve-data-key` call per +/// 500 keyed leaves. The output buffer contains **plaintext** — same host +/// obligations as [`se_decrypt`]. /// /// # Safety /// diff --git a/languages/golang/stackencrypt/guest/src/ops.rs b/languages/golang/stackencrypt/guest/src/ops.rs index 33cc4dfef..06264b4ac 100644 --- a/languages/golang/stackencrypt/guest/src/ops.rs +++ b/languages/golang/stackencrypt/guest/src/ops.rs @@ -25,16 +25,20 @@ //! *plan* says, per field, which encryption context to bind and which //! outputs to produce (ciphertext and/or index terms); the source supplies //! the field values. However many rows and fields are in one call, all -//! ciphertext leaves seal in **one** batched `generate_keys` — the pendings -//! are merged before settling, exactly like the derive's `zip`/`all` +//! ciphertext leaves seal from **one** batched `generate_keys` — the +//! pendings are merged before settling, exactly like the derive's `zip`/`all` //! composition — and index terms derive locally with no ZeroKMS traffic at -//! all. See [`parse_plan`] for the plan encoding. +//! all. That batch reaches ZeroKMS as one request per +//! `ClientOpts::max_keys_per_req` keyed leaves (500 by default, sent +//! sequentially: the guest pins `max_concurrent_reqs` to 1), so "one call" +//! is exact up to 500 leaves and "one call per 500" past it. See +//! [`parse_plan`] for the plan encoding. use stack_encrypt::sem::{CllwOpeEncrypt, CllwOreEncrypt, DefaultMatch}; use stack_encrypt::target::Pending; use stack_encrypt::{ - Aad, BoxedPassthrough, CipherText, Decrypt, Element, Encrypt, SealedValue, StackCipher, - StackCipherText, + is_degenerate_aad, Aad, BoxedPassthrough, CipherText, Decrypt, Element, Encrypt, SealedValue, + StackCipher, StackCipherText, }; use stack_kms::DataKeySource; use vitaminc_aead_value::{transport as codec, FfiValue}; @@ -63,10 +67,31 @@ type BytesTree = CipherText<Vec<u8>, BoxedPassthrough>; // Whole-value encrypt / decrypt (the vitaminc guest's vc_encrypt shape) // ============================================================================= +/// Reject an AAD that carries no caller-supplied information. +/// +/// The value entry points take raw AAD bytes rather than a typed context, so +/// nothing upstream has already applied the target layer's rule. An absent or +/// empty AAD must not silently become "sealed under nothing": with no +/// context, ciphertexts are transplantable between fields, which is exactly +/// what a context is for. [`is_degenerate_aad`] is the same predicate +/// `EncryptFrom`/`DecryptInto` apply, so the value paths, the record paths +/// and native Rust code all agree on what counts as empty — including the +/// shapes that are not literally zero bytes (`pae([])`, `0u64`). +fn check_aad(aad: &[u8]) -> Result<(), u32> { + if is_degenerate_aad(aad) { + return Err(STATUS_ENCODING); + } + Ok(()) +} + /// Encrypt a codec-encoded [`FfiValue`] tree under `aad`, sealing every leaf -/// against a fresh ZeroKMS data key (one batched call). With `as_element`, +/// against a fresh ZeroKMS data key (one batched request; see the module +/// docs for how a batch is chunked). With `as_element`, /// seal it as a *sequence element* — interchangeable with rows written by /// encrypting a whole sequence under the same AAD. +/// +/// An empty (or otherwise degenerate) `aad` is [`STATUS_ENCODING`], as on the +/// record and term paths — see [`check_aad`]. pub async fn encrypt_value<K>( cipher: &StackCipher<K>, value: &[u8], @@ -76,6 +101,7 @@ pub async fn encrypt_value<K>( where K: DataKeySource + Sync, { + check_aad(aad)?; let value = decode_value(value)?; let aad = Aad::from_slice(aad); let tree = if as_element { @@ -89,8 +115,11 @@ where } /// Decrypt a codec-encoded ciphertext tree back into a codec-encoded -/// [`FfiValue`] tree (one batched `retrieve_keys` call). The output buffer +/// [`FfiValue`] tree (one batched `retrieve_keys` request). The output buffer /// contains plaintext — the ABI layer's ownership rules govern its wiping. +/// +/// Symmetric with [`encrypt_value`]: an empty AAD is [`STATUS_ENCODING`], +/// checked before any key is retrieved. pub async fn decrypt_value<K>( cipher: &StackCipher<K>, ciphertext: &[u8], @@ -100,6 +129,7 @@ pub async fn decrypt_value<K>( where K: DataKeySource + Sync, { + check_aad(aad)?; let tree = decode_tree(ciphertext)?; let decipher = cipher .decipher(tree) @@ -333,11 +363,19 @@ struct FieldPlan { /// { <field>: { "context": <string>, "outputs": [ "c" | "eq" | "match" | "ore" | "ope", ... ] }, ... } /// ``` /// -/// Rejected as [`STATUS_ENCODING`]: an empty plan, an empty or missing +/// Rejected as [`STATUS_ENCODING`]: an empty plan, a missing or *degenerate* /// context (contexts domain-separate fields — see `Error::EmptyContext` in /// stack-encrypt), an empty/unknown/duplicated output list, unknown keys. /// Field names are unique by construction (the codec rejects duplicate /// object keys). +/// +/// "Degenerate" is [`is_degenerate_aad`], not `is_empty`, and the check has +/// to happen *here*: [`build_row`] seals through `encrypt_with_aad(..) +/// .into_pending(..)`, which is the cipher-directed path and does not run +/// the target layer's context check, whereas [`decrypt_record`] opens +/// through `decrypt_into`, which does. A context that is not byte-empty but +/// still carries nothing — the PAE of an empty list, i.e. eight zero bytes — +/// would otherwise encrypt happily and then never decrypt. fn parse_plan(value: FfiValue) -> Result<Vec<FieldPlan>, u32> { let FfiValue::Object(entries) = value else { return Err(STATUS_ENCODING); @@ -381,7 +419,10 @@ fn parse_plan(value: FfiValue) -> Result<Vec<FieldPlan>, u32> { _ => return Err(STATUS_ENCODING), } } - let context = context.filter(|c| !c.is_empty()).ok_or(STATUS_ENCODING)?; + // `&str`'s AAD encoding is its own raw bytes, so the predicate + // applies directly to the context string. + let context = context.ok_or(STATUS_ENCODING)?; + check_aad(context.as_bytes())?; let outputs = outputs.filter(|o| !o.is_empty()).ok_or(STATUS_ENCODING)?; Ok(FieldPlan { name, @@ -410,7 +451,19 @@ type RowSkeleton = Vec<(String, Vec<(&'static str, Option<Vec<u8>>)>)>; /// ciphertext subtree and each term rides as a passthrough /// [`FfiValue::Bytes`] node (terms are comparands, not ciphertexts to open — /// passthrough is their honest encoding). A batch is a sequence of such -/// maps. One `generate_keys` call per invocation, however many rows. +/// maps. All rows and fields seal in one batched `generate_keys`; that +/// batch is split into one ZeroKMS request per +/// [`ClientOpts::max_keys_per_req`](stack_kms::ClientOpts::with_max_keys_per_req) +/// keyed leaves (500 by default), sent sequentially. +/// +/// **Cross-language note.** A `"c"` leaf seals the aead-value *tagged* +/// plaintext encoding (`[type tag] ++ payload`), because that tag table is +/// the contract Go, Node and this guest share. A Rust +/// `#[derive(EncryptFrom)]` over a plain primitive — a bare `u32` — seals +/// four untagged bytes instead, so a plain-primitive Rust derive and a Go +/// plan do **not** interchange ciphertexts for the same field until the Rust +/// side uses aead-value's tagged types too. This is by design, not a defect +/// in either side; making the derive tagged is a separate follow-up. pub async fn encrypt_record<K>( cipher: &StackCipher<K>, source: &[u8], @@ -635,8 +688,18 @@ fn decode_value(bytes: &[u8]) -> Result<FfiValue, u32> { codec::decode_value(&mut codec::Reader::new(bytes)).map_err(|_| STATUS_ENCODING) } +/// Encode a value tree into a buffer sized **before** the first byte is +/// written. +/// +/// This buffer is plaintext on the decrypt path, and a `Vec` grown by the +/// codec's pushes would leave partial plaintext in every abandoned +/// allocation a reallocation could not extend in place — memory nothing +/// wipes, undercutting the guarantee the registry makes about the buffer it +/// eventually hands the host. Reserving the exact encoded length up front +/// means the encoder never reallocates, and `register`'s `into_boxed_slice` +/// (capacity == length) does not copy either. fn encode_value(value: FfiValue) -> Result<Vec<u8>, u32> { - let mut out = Vec::new(); + let mut out = exact_buffer(value_encoded_len(&value))?; codec::encode_value(value, &mut out).map_err(|_| STATUS_ENCODING)?; Ok(out) } @@ -644,21 +707,30 @@ fn encode_value(value: FfiValue) -> Result<Vec<u8>, u32> { fn decode_tree(bytes: &[u8]) -> Result<StackCipherText, u32> { let tree: BytesTree = codec::decode_ciphertext_boxed(&mut codec::Reader::new(bytes)) .map_err(|_| STATUS_ENCODING)?; - sealed_tree(tree) + // Structural only — a decoded leaf proves nothing until its AEAD opens + // (see the `SealedValue` docs). + map_leaves(tree, &mut |l: Vec<u8>| { + SealedValue::from_bytes(&l).map_err(|_| STATUS_ENCODING) + }) } +/// The encode twin of [`decode_tree`]. Passthrough nodes can carry caller +/// plaintext, so this is sized up front for the same reason +/// [`encode_value`] is. fn encode_tree(tree: StackCipherText) -> Result<Vec<u8>, u32> { - let tree = leaf_bytes_tree(tree)?; - let mut out = Vec::new(); + let tree = map_leaves(tree, &mut |l: SealedValue| Ok::<_, u32>(l.to_bytes()))?; + let mut out = exact_buffer(tree_encoded_len(&tree))?; codec::encode_ciphertext_boxed(tree, &mut out).map_err(|_| STATUS_ENCODING)?; Ok(out) } -/// Re-encode every leaf through the frozen [`SealedValue::to_bytes`] -/// encoding. `TagTooLong` cannot arise for a ZeroKMS-issued tag, so an -/// encode failure is internal, not an input error. -fn leaf_bytes_tree(tree: StackCipherText) -> Result<BytesTree, u32> { - let leaf = |l: SealedValue| l.to_bytes().map_err(|_| STATUS_INTERNAL); +/// Rebuild a ciphertext tree with every leaf run through `leaf`, keeping the +/// structure (and the passthrough payloads) untouched. One definition for +/// both directions of the frozen [`SealedValue`] leaf encoding. +fn map_leaves<A, B, E>( + tree: CipherText<A, BoxedPassthrough>, + leaf: &mut impl FnMut(A) -> Result<B, E>, +) -> Result<CipherText<B, BoxedPassthrough>, E> { Ok(match tree { CipherText::Single(l) => CipherText::Single(leaf(l)?), CipherText::None(l) => CipherText::None(leaf(l)?), @@ -667,43 +739,80 @@ fn leaf_bytes_tree(tree: StackCipherText) -> Result<BytesTree, u32> { CipherText::Sequence(items) => CipherText::Sequence( items .into_iter() - .map(leaf_bytes_tree) - .collect::<Result<_, _>>()?, + .map(|item| map_leaves(item, leaf)) + .collect::<Result<_, E>>()?, ), CipherText::Map(entries) => CipherText::Map( entries .into_iter() - .map(|(k, v)| Ok((k, leaf_bytes_tree(v)?))) - .collect::<Result<_, u32>>()?, + .map(|(k, v)| Ok((k, map_leaves(v, leaf)?))) + .collect::<Result<_, E>>()?, ), CipherText::Passthrough(p) => CipherText::Passthrough(p), }) } -/// Decode every leaf through the frozen [`SealedValue::from_bytes`] -/// encoding. Structural only — a decoded leaf proves nothing until its AEAD -/// opens (see the `SealedValue` docs). -fn sealed_tree(tree: BytesTree) -> Result<StackCipherText, u32> { - let leaf = |l: Vec<u8>| SealedValue::from_bytes(&l).map_err(|_| STATUS_ENCODING); - Ok(match tree { - CipherText::Single(l) => CipherText::Single(leaf(l)?), - CipherText::None(l) => CipherText::None(leaf(l)?), - CipherText::EmptySequence(l) => CipherText::EmptySequence(leaf(l)?), - CipherText::EmptyMap(l) => CipherText::EmptyMap(leaf(l)?), - CipherText::Sequence(items) => CipherText::Sequence( - items - .into_iter() - .map(sealed_tree) - .collect::<Result<_, _>>()?, - ), - CipherText::Map(entries) => CipherText::Map( - entries - .into_iter() - .map(|(k, v)| Ok((k, sealed_tree(v)?))) - .collect::<Result<_, u32>>()?, - ), - CipherText::Passthrough(p) => CipherText::Passthrough(p), - }) +/// A buffer with exactly `len` bytes of capacity, or [`STATUS_ENCODING`] if +/// the length could not be computed (an encoding the codec would refuse +/// anyway) or [`STATUS_INTERNAL`] if the allocation failed. `try_reserve_exact` +/// rather than `reserve`: on wasm32 an oversized request must be a status, +/// not an abort that poisons the instance — and the *exact* variant so that +/// `register`'s `into_boxed_slice` finds capacity already equal to length +/// and does not shrink-to-fit (a shrink that moved would free the filled +/// block without wiping it, which is the whole hazard this avoids). +fn exact_buffer(len: Option<usize>) -> Result<Vec<u8>, u32> { + let len = len.ok_or(STATUS_ENCODING)?; + let mut out = Vec::new(); + out.try_reserve_exact(len).map_err(|_| STATUS_INTERNAL)?; + Ok(out) +} + +/// Exact byte length of the codec's encoding of `value`. `None` on overflow +/// or on a length the codec's `u32` frames cannot express — the encode would +/// fail on those anyway, so the caller reports an encoding error. +fn value_encoded_len(value: &FfiValue) -> Option<usize> { + // tag byte + fixed payload, or tag + u32 length prefix + payload. + let framed = |len: usize| u32::try_from(len).ok().and_then(|_| len.checked_add(5)); + match value { + FfiValue::Null | FfiValue::Undefined | FfiValue::Bool(_) => Some(1), + FfiValue::Int32(_) | FfiValue::UInt32(_) | FfiValue::Float32(_) => Some(5), + FfiValue::Int64(_) | FfiValue::UInt64(_) | FfiValue::Float64(_) => Some(9), + FfiValue::String(s) => framed(s.risky_ref().len()), + FfiValue::Bytes(b) => framed(b.risky_ref().len()), + FfiValue::Array(items) => items.iter().try_fold(5usize, |acc, item| { + acc.checked_add(value_encoded_len(item)?) + }), + FfiValue::Object(entries) => entries.iter().try_fold(5usize, |acc, (key, value)| { + acc.checked_add(framed(key.len())?.checked_sub(1)?)? + .checked_add(value_encoded_len(value)?) + }), + FfiValue::Passthrough(inner) => value_encoded_len(inner)?.checked_add(1), + } +} + +/// Exact byte length of the codec's encoding of a ciphertext tree. The +/// passthrough payloads are read (not consumed) through `Any::downcast_ref`, +/// matching what `encode_ciphertext_boxed` will re-home them to; a payload +/// that is not an [`FfiValue`] is `None`, which is the same rejection the +/// encoder would make. +fn tree_encoded_len(tree: &BytesTree) -> Option<usize> { + let framed = |len: usize| u32::try_from(len).ok().and_then(|_| len.checked_add(5)); + match tree { + CipherText::Single(l) + | CipherText::None(l) + | CipherText::EmptySequence(l) + | CipherText::EmptyMap(l) => framed(l.len()), + CipherText::Sequence(items) => items + .iter() + .try_fold(5usize, |acc, item| acc.checked_add(tree_encoded_len(item)?)), + CipherText::Map(entries) => entries.iter().try_fold(5usize, |acc, (key, value)| { + acc.checked_add(framed(key.len())?.checked_sub(1)?)? + .checked_add(tree_encoded_len(value)?) + }), + CipherText::Passthrough(p) => { + value_encoded_len((**p).downcast_ref::<FfiValue>()?)?.checked_add(1) + } + } } fn text_of(s: &vitaminc_aead_value::Utf8String) -> Result<&str, u32> { diff --git a/languages/golang/stackencrypt/guest/tests/native_ops.rs b/languages/golang/stackencrypt/guest/tests/native_ops.rs index 7b98ff665..38d3a53dc 100644 --- a/languages/golang/stackencrypt/guest/tests/native_ops.rs +++ b/languages/golang/stackencrypt/guest/tests/native_ops.rs @@ -283,6 +283,47 @@ fn wrong_aad_and_malformed_inputs_map_to_statuses() { ); } +/// The AAD is what makes a ciphertext belong to a field. Sealing under +/// nothing would make ciphertexts transplantable between fields, so the value +/// paths refuse it exactly as the record and term paths do — and refuse it +/// *before* minting a key, so a caller that omitted the AAD cannot spend a +/// ZeroKMS call discovering it. +#[test] +fn an_empty_or_degenerate_aad_is_refused_on_the_value_paths() { + let cipher = cipher(); + let value = encode(s("x")); + + // A real ciphertext to try to open with a missing AAD. + let ct = block_on(ops::encrypt_value(&cipher, &value, b"ctx", false)).expect("encrypt"); + let before = cipher.kms().generate_calls.load(Ordering::SeqCst); + + // `pae([])` — eight zero bytes — is not byte-empty but carries nothing, + // and is what `None` and `0u64` encode to. Both forms must be refused. + for (label, aad) in [ + ("empty", b"".as_slice()), + ("pae of an empty list", &[0u8; 8][..]), + ] { + for as_element in [false, true] { + assert_eq!( + block_on(ops::encrypt_value(&cipher, &value, aad, as_element)), + Err(STATUS_ENCODING), + "encrypt with a {label} aad (element: {as_element})" + ); + assert_eq!( + block_on(ops::decrypt_value(&cipher, &ct, aad, as_element)), + Err(STATUS_ENCODING), + "decrypt with a {label} aad (element: {as_element})" + ); + } + } + + assert_eq!( + cipher.kms().generate_calls.load(Ordering::SeqCst), + before, + "no data key may be minted for a rejected call" + ); +} + // ============================================================================= // Terms // ============================================================================= @@ -345,6 +386,8 @@ fn guest_terms_match_the_native_sem_derivations() { )) .expect("bytes term"); assert_ne!(eq_text, eq_bytes); + let native_text = block_on(cipher.equality_term("ab".to_string(), "f")).expect("native"); + assert_eq!(eq_text, native_text.as_bytes()); let native_bytes = block_on(cipher.equality_term(Protected::new(b"ab".to_vec()), "f")).expect("native"); assert_eq!(eq_bytes, native_bytes.as_bytes()); @@ -562,3 +605,42 @@ fn record_shape_violations_are_encoding_errors() { // No data keys were minted for any rejected call. assert_eq!(cipher.kms().generate_calls.load(Ordering::SeqCst), 0); } + +/// A context that is not byte-empty but still carries nothing — the PAE of an +/// empty list, i.e. eight zero bytes, which is what `None` and `0u64` encode +/// to — must be rejected at plan-parse time. +/// +/// Without the check the two record paths disagree: `encrypt_record` seals +/// through the cipher-directed path, which does not run stack-encrypt's +/// context predicate, while `decrypt_record` opens through `decrypt_into`, +/// which does. The row would encrypt and then never decrypt. +#[test] +fn a_degenerate_plan_context_is_refused_before_anything_is_sealed() { + let cipher = cipher(); + let degenerate = String::from_utf8(vec![0u8; 8]).expect("nul bytes are valid utf-8"); + let bad_plan = encode(obj(vec![( + "f", + obj(vec![ + ("context", s(&degenerate)), + ("outputs", FfiValue::Array(vec![s("c")])), + ]), + )])); + let source = encode(obj(vec![("f", FfiValue::UInt32(1))])); + + assert_eq!( + block_on(ops::encrypt_record(&cipher, &source, &bad_plan)), + Err(STATUS_ENCODING) + ); + assert_eq!( + cipher.kms().generate_calls.load(Ordering::SeqCst), + 0, + "a context that could never be decrypted under must not seal" + ); + + // And the decrypt side agrees, so neither half can drift into accepting + // what the other refuses. + assert_eq!( + block_on(ops::decrypt_record(&cipher, &source, &bad_plan)), + Err(STATUS_ENCODING) + ); +} From 435c5a0f6af9d58ad21f0350c73334427ef35967 Mon Sep 17 00:00:00 2001 From: Dan Draper <dan@cipherstash.com> Date: Tue, 1 Sep 2026 16:17:18 +1000 Subject: [PATCH 497/686] fix(wasi): reject passthrough nodes in record ciphertext slots MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A passthrough node opens no AEAD: decrypt_into collects zero retrieve-requests for it and hands its payload back verbatim. An attacker with write access to a stored record tree could therefore replace a plan field's "c" subtree with a passthrough carrying forged plaintext and decrypt_record would report a successful decrypt of attacker-chosen bytes. decrypt_record now refuses any passthrough inside a "c" subtree as STATUS_ENCODING, and encrypt_record refuses source values containing passthroughs for ciphertext-bearing fields — making "encrypt_record never emits passthrough under c" an enforced invariant rather than an observation, so the decrypt-side rejection loses no legitimate data. Also pins value_encoded_len / tree_encoded_len byte-for-byte against the codec's actual encodings across every value and tree shape: the exact-size reservation exists to keep partial plaintext out of abandoned reallocations, and these functions re-derive the codec's framing with nothing else failing on drift. Claude-Session: https://claude.ai/code/session_01P5YHK3w6Kj9ajTnmkaXCHW --- .../golang/stackencrypt/guest/src/ops.rs | 126 ++++++++++++++++++ .../stackencrypt/guest/tests/native_ops.rs | 64 +++++++++ 2 files changed, 190 insertions(+) diff --git a/languages/golang/stackencrypt/guest/src/ops.rs b/languages/golang/stackencrypt/guest/src/ops.rs index 06264b4ac..d2385e7fb 100644 --- a/languages/golang/stackencrypt/guest/src/ops.rs +++ b/languages/golang/stackencrypt/guest/src/ops.rs @@ -438,6 +438,47 @@ fn parse_plan(value: FfiValue) -> Result<Vec<FieldPlan>, u32> { /// in build order. type RowSkeleton = Vec<(String, Vec<(&'static str, Option<Vec<u8>>)>)>; +/// Reject a source field value that contains a passthrough anywhere, before +/// it reaches a `"c"` slot. A passthrough node is *unauthenticated by +/// definition* — on decrypt it hands its payload back with no AEAD opened — +/// so admitting one under a plan field the plan declares ciphertext-bearing +/// would quietly produce a slot whose bytes verify nothing. Rejecting it +/// here is what makes [`decrypt_record`]'s mirror-image rejection a +/// round-trip invariant rather than data loss. +fn reject_passthrough_value(value: &FfiValue) -> Result<(), u32> { + match value { + FfiValue::Passthrough(_) => Err(STATUS_ENCODING), + FfiValue::Array(items) => items.iter().try_for_each(reject_passthrough_value), + FfiValue::Object(entries) => entries + .iter() + .try_for_each(|(_, v)| reject_passthrough_value(v)), + _ => Ok(()), + } +} + +/// Reject a `"c"` subtree that contains a passthrough anywhere. This is the +/// decrypt-side half of [`reject_passthrough_value`], and it is +/// load-bearing: `decrypt_into` collects **zero** retrieve-requests for a +/// passthrough and returns its payload with no AEAD opened, so an attacker +/// with write access to the stored tree could replace a field's `"c"` +/// subtree with a passthrough carrying forged plaintext and this function's +/// absence would report it as a successful decrypt. [`encrypt_record`] never +/// produces a passthrough under `"c"`, so the shape is unconditionally +/// [`STATUS_ENCODING`]. +fn reject_passthrough_tree(tree: &StackCipherText) -> Result<(), u32> { + match tree { + CipherText::Passthrough(_) => Err(STATUS_ENCODING), + CipherText::Sequence(items) => items.iter().try_for_each(reject_passthrough_tree), + CipherText::Map(entries) => entries + .iter() + .try_for_each(|(_, v)| reject_passthrough_tree(v)), + CipherText::Single(_) + | CipherText::None(_) + | CipherText::EmptySequence(_) + | CipherText::EmptyMap(_) => Ok(()), + } +} + /// Encrypt a record — or a batch of records — per a plan. /// /// `source` is a codec-encoded [`FfiValue::Object`] of `{ field: scalar }` @@ -580,6 +621,7 @@ where } if field.outputs.contains(&Output::Ciphertext) { + reject_passthrough_value(&value)?; let tree = value .encrypt_with_aad(cipher, field.context.as_str()) .map_err(|_| STATUS_INTERNAL)?; @@ -648,6 +690,7 @@ where .into_iter() .find_map(|(key, node)| (key == "c").then_some(node)) .ok_or(STATUS_ENCODING)?; + reject_passthrough_tree(&ct)?; pendings.push(ct.decrypt_into(cipher, field.context.as_str())); row_names.push(name); } @@ -820,3 +863,86 @@ fn text_of(s: &vitaminc_aead_value::Utf8String) -> Result<&str, u32> { // than assumed because this is boundary code. std::str::from_utf8(s.risky_ref()).map_err(|_| STATUS_ENCODING) } + +#[cfg(test)] +mod tests { + use super::*; + + // `value_encoded_len` / `tree_encoded_len` re-derive the codec's framing + // arithmetic; the codec exports no `encoded_len` of its own, so these + // pins are the only thing that fails if the two drift. Drift is not a + // cosmetic bug: an undersized reservation makes `encode_value` + // reallocate mid-encode, leaving unwiped partial plaintext in the + // abandoned allocation — silently. + + fn every_value_shape() -> Vec<FfiValue> { + vec![ + FfiValue::Null, + FfiValue::Undefined, + FfiValue::Bool(true), + FfiValue::Int32(-5), + FfiValue::UInt32(5), + FfiValue::Float32(1.5), + FfiValue::Int64(-9), + FfiValue::UInt64(9), + FfiValue::Float64(2.5), + FfiValue::String("".into()), + FfiValue::String("héllo".into()), + FfiValue::Bytes(Protected::new(Vec::new())), + FfiValue::Bytes(Protected::new(vec![0u8; 300])), + FfiValue::Array(Vec::new()), + FfiValue::Array(vec![FfiValue::Bool(false), FfiValue::String("x".into())]), + FfiValue::Object(Vec::new()), + FfiValue::Object(vec![ + ("a".to_string(), FfiValue::Int32(1)), + ( + "nested".to_string(), + FfiValue::Object(vec![("b".to_string(), FfiValue::Null)]), + ), + ]), + FfiValue::Passthrough(Box::new(FfiValue::Int64(7))), + FfiValue::Passthrough(Box::new(FfiValue::Array(vec![FfiValue::String( + "deep".into(), + )]))), + ] + } + + #[test] + fn value_encoded_len_matches_the_codec_exactly() { + for (i, value) in every_value_shape().into_iter().enumerate() { + let expected = value_encoded_len(&value).expect("encodable shape"); + let mut out = Vec::new(); + codec::encode_value(value, &mut out).expect("codec encode"); + assert_eq!(out.len(), expected, "shape {i}"); + } + } + + #[test] + fn tree_encoded_len_matches_the_codec_exactly() { + let leaf = |bytes: &[u8]| -> BytesTree { CipherText::Single(bytes.to_vec()) }; + let trees: Vec<BytesTree> = vec![ + leaf(b""), + leaf(&[7u8; 40]), + CipherText::None(vec![1, 2]), + CipherText::EmptySequence(vec![3]), + CipherText::EmptyMap(Vec::new()), + CipherText::Sequence(vec![leaf(b"a"), CipherText::None(vec![9])]), + CipherText::Map(vec![ + ("name".to_string(), leaf(b"ct")), + ( + "inner".to_string(), + CipherText::Map(vec![("x".to_string(), leaf(b"y"))]), + ), + ]), + CipherText::Passthrough( + Box::new(FfiValue::Bytes(Protected::new(vec![1, 2, 3]))) as BoxedPassthrough + ), + ]; + for (i, tree) in trees.into_iter().enumerate() { + let expected = tree_encoded_len(&tree).expect("encodable shape"); + let mut out = Vec::new(); + codec::encode_ciphertext_boxed(tree, &mut out).expect("codec encode"); + assert_eq!(out.len(), expected, "tree {i}"); + } + } +} diff --git a/languages/golang/stackencrypt/guest/tests/native_ops.rs b/languages/golang/stackencrypt/guest/tests/native_ops.rs index 38d3a53dc..dcf73e1b6 100644 --- a/languages/golang/stackencrypt/guest/tests/native_ops.rs +++ b/languages/golang/stackencrypt/guest/tests/native_ops.rs @@ -474,6 +474,70 @@ fn a_record_batch_encrypts_in_one_call_and_round_trips() { assert_eq!(text(&fields[1].1), "bob jones"); } +/// The forgery that motivates `reject_passthrough_tree`: an attacker with +/// write access to the stored tree swaps a field's `"c"` subtree for a +/// passthrough carrying chosen plaintext. `decrypt_into` opens no AEAD for a +/// passthrough, so without the rejection this would come back as a +/// *successful* decrypt of attacker-chosen bytes. +#[test] +fn a_forged_passthrough_ciphertext_slot_is_rejected_not_decrypted() { + let cipher = cipher(); + let source = encode(row(29, "alice smith")); + let record = block_on(ops::encrypt_record(&cipher, &source, &plan())).expect("encrypt record"); + + let CipherText::Map(mut fields) = decode_tree(&record) else { + panic!("expected a field map"); + }; + for (field, node) in &mut fields { + if field != "age" { + continue; + } + let CipherText::Map(outputs) = node else { + panic!("expected an output map"); + }; + for (key, slot) in outputs.iter_mut() { + if key == "c" { + *slot = CipherText::Passthrough(FfiValue::UInt32(99)); + } + } + } + let mut forged = Vec::new(); + codec::encode_ciphertext(&CipherText::Map(fields), &mut forged).expect("re-encode"); + + assert_eq!( + block_on(ops::decrypt_record(&cipher, &forged, &plan())), + Err(STATUS_ENCODING), + "a passthrough in a ciphertext slot must be a hard error, never plaintext" + ); +} + +/// The encrypt-side half of the same invariant: a source value containing a +/// passthrough must not reach a `"c"` slot (it would seal nothing for those +/// bytes), even nested inside a container. +#[test] +fn a_passthrough_source_value_is_refused_a_ciphertext_slot() { + let cipher = cipher(); + for age in [ + FfiValue::Passthrough(Box::new(FfiValue::UInt32(29))), + FfiValue::Array(vec![FfiValue::Passthrough(Box::new(FfiValue::UInt32(29)))]), + ] { + // A ciphertext-only plan, so the term path's own scalar rejection + // cannot mask the one under test. + let plan = encode(obj(vec![( + "age", + obj(vec![ + ("context", s("users/age")), + ("outputs", FfiValue::Array(vec![s("c")])), + ]), + )])); + let source = encode(obj(vec![("age", age)])); + assert_eq!( + block_on(ops::encrypt_record(&cipher, &source, &plan)), + Err(STATUS_ENCODING) + ); + } +} + #[test] fn record_terms_equal_the_native_derivations_and_probe_them() { let cipher = cipher(); From e3765a5fd80257b15dc9b75a6a8b3a7a35f68215 Mon Sep 17 00:00:00 2001 From: Dan Draper <dan@cipherstash.com> Date: Tue, 1 Sep 2026 16:17:27 +1000 Subject: [PATCH 498/686] feat(stack-auth): classify credential rejections where the variants live MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AuthError is #[non_exhaustive], so the guest's status mapping needed a `_` arm — and every refused-credential variant added upstream would silently classify as STATUS_KMS_TRANSPORT ("do not refresh"), costing Go hosts the one retry that would fix it. AuthError::is_credential_rejection() matches exhaustively inside stack-auth, where adding a variant without classifying it is a compile error; the guest keys STATUS_KMS_UNAUTHORIZED off it. The stack-kms error matches in the guest drop their `_` arms too: none of those enums is non_exhaustive, so exhaustiveness is free compiler coverage for variants added later (GenerateKeyError already grew Unauthorized / Forbidden out of the shared From<ViturRequestError> pattern). Claude-Session: https://claude.ai/code/session_01P5YHK3w6Kj9ajTnmkaXCHW --- .../golang/stackencrypt/guest/src/status.rs | 34 ++++++++++------ packages/stack-auth/src/error.rs | 39 +++++++++++++++++++ 2 files changed, 62 insertions(+), 11 deletions(-) diff --git a/languages/golang/stackencrypt/guest/src/status.rs b/languages/golang/stackencrypt/guest/src/status.rs index 1cea848d5..c807ace84 100644 --- a/languages/golang/stackencrypt/guest/src/status.rs +++ b/languages/golang/stackencrypt/guest/src/status.rs @@ -86,6 +86,13 @@ pub fn status_for_term_error(error: &stack_encrypt::sem::TermError) -> u32 { } } +// These matches are deliberately exhaustive — no `_` arms. None of the +// stack-kms error enums is `#[non_exhaustive]`, so exhaustiveness is free +// compiler coverage: `GenerateKeyError` already grew `Unauthorized` / +// `Forbidden` out of the shared `From<ViturRequestError>` pattern, and a +// variant added tomorrow must be classified here before this crate builds, +// instead of silently falling through a catch-all to [`STATUS_KMS_OTHER`] +// and costing a Go host its refresh signal. fn status_for_kms(error: &stack_kms::Error) -> u32 { match error { stack_kms::Error::GenerateKey(e) => match e { @@ -93,26 +100,30 @@ fn status_for_kms(error: &stack_kms::Error) -> u32 { GenerateKeyError::Forbidden => STATUS_KMS_FORBIDDEN, GenerateKeyError::RequestFailed(e) => status_for_kind(&e.kind), GenerateKeyError::GenerateIv(_) => STATUS_INTERNAL, - _ => STATUS_KMS_OTHER, + // A response that did not line up with the request, or key + // material the client could not use: server-side malformations. + GenerateKeyError::InvalidNumberOfKeys { .. } + | GenerateKeyError::InvalidKeyMaterial(_) => STATUS_KMS_OTHER, }, stack_kms::Error::RetrieveKey(e) => match e { RetrieveKeyError::RequestFailed(e) => status_for_kind(&e.kind), // A per-key server-side "no key for this iv/tag". RetrieveKeyError::FailedRetrieval(_) => STATUS_KMS_NOT_FOUND, - _ => STATUS_KMS_OTHER, + RetrieveKeyError::InvalidNumberOfKeys { .. } + | RetrieveKeyError::InvalidKeyMaterial(_) => STATUS_KMS_OTHER, }, stack_kms::Error::LoadKeyset(e) => match e { LoadKeysetError::Unauthorized(_) => STATUS_KMS_UNAUTHORIZED, LoadKeysetError::Forbidden(_) => STATUS_KMS_FORBIDDEN, LoadKeysetError::KeysetNotFound(_) => STATUS_KMS_NOT_FOUND, LoadKeysetError::RequestFailed(e) => status_for_kind(&e.kind), - _ => STATUS_KMS_OTHER, + LoadKeysetError::InvalidKeyMaterial(_) => STATUS_KMS_OTHER, }, stack_kms::Error::Auth(auth) => status_for_auth(auth), stack_kms::Error::ConnectionInit(_) | stack_kms::Error::InvalidEndpoint(_) => { STATUS_KMS_TRANSPORT } - _ => STATUS_KMS_OTHER, + stack_kms::Error::Unexpected(_) => STATUS_KMS_OTHER, } } @@ -131,13 +142,14 @@ fn status_for_kms(error: &stack_kms::Error) -> u32 { fn status_for_auth(error: &AuthError) -> u32 { match error { // The server (or the strategy) refused the credential itself: a new - // token is the fix. - AuthError::NotAuthenticated(_) - | AuthError::TokenExpired(_) - | AuthError::InvalidGrant(_) - | AuthError::InvalidClient(_) - | AuthError::InvalidAccessKey(_) - | AuthError::AlreadyConsumed(_) => STATUS_KMS_UNAUTHORIZED, + // token is the fix. `AuthError` is `#[non_exhaustive]`, so the arms + // below need the `_` catch-all and a variant added upstream would + // silently classify as TRANSPORT ("do not refresh") — which is why + // the refresh signal keys off `is_credential_rejection()`, whose + // match *is* exhaustive inside stack-auth: new refused-credential + // variants are classified there, at compile time, and picked up here + // with no change. + e if e.is_credential_rejection() => STATUS_KMS_UNAUTHORIZED, // Authenticated, but not allowed. AuthError::AccessDenied(_) | AuthError::UsageLimitExceeded(_) => STATUS_KMS_FORBIDDEN, // Server-side faults with no client-side remedy. diff --git a/packages/stack-auth/src/error.rs b/packages/stack-auth/src/error.rs index 970f3bae8..d40123a15 100644 --- a/packages/stack-auth/src/error.rs +++ b/packages/stack-auth/src/error.rs @@ -460,6 +460,45 @@ pub enum AuthError { } impl AuthError { + /// True when the *credential itself* was refused — an expired, invalid or + /// consumed token, key or grant — so obtaining a fresh credential and + /// retrying is a sensible response. False for everything else: + /// authenticated-but-forbidden, server faults, and configuration or + /// transport problems that no amount of refreshing can fix. + /// + /// This classification lives here, next to the variants, because + /// `AuthError` is `#[non_exhaustive]`: a downstream `match` needs a `_` + /// arm, which silently mis-classifies every variant added later. Inside + /// this crate the match *is* exhaustive — adding a variant is a compile + /// error until it is classified. FFI front-ends (the wasm guest's status + /// mapping) key their "refresh the token and retry" signal off this. + pub fn is_credential_rejection(&self) -> bool { + match self { + AuthError::NotAuthenticated(_) + | AuthError::TokenExpired(_) + | AuthError::InvalidGrant(_) + | AuthError::InvalidClient(_) + | AuthError::InvalidAccessKey(_) + | AuthError::AlreadyConsumed(_) => true, + AuthError::Request(_) + | AuthError::AccessDenied(_) + | AuthError::InvalidUrl(_) + | AuthError::Region(_) + | AuthError::InvalidCrn(_) + | AuthError::WorkspaceMismatch(_) + | AuthError::InvalidWorkspaceId(_) + | AuthError::MissingWorkspaceCrn(_) + | AuthError::InvalidToken(_) + | AuthError::UsageLimitExceeded(_) + | AuthError::OrgNotProvisioned(_) + | AuthError::Server(_) + | AuthError::Internal(_) + | AuthError::Custom(_) => false, + #[cfg(not(target_arch = "wasm32"))] + AuthError::Store(_) => false, + } + } + /// The complete set of codes [`AuthError::error_code`] can return — the /// stable, machine-readable contract surfaced across FFI (JS `Error.code`, /// Node-API codes, the `index.d.ts` / `wasm-inline.d.ts` `AuthFailure` From 8970413f48d45fb71a75317d985ec13ec4b12220 Mon Sep 17 00:00:00 2001 From: Dan Draper <dan@cipherstash.com> Date: Tue, 1 Sep 2026 16:17:42 +1000 Subject: [PATCH 499/686] fix(wasi): harden the guest's host-boundary parsing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - header_value skips non-UTF-8 per line, not per buffer: one raw ISO-8859-1 byte from a proxy in an unrelated header no longer fails the content-type lookup and with it every KMS call. - get_token rejects tokens with interior control characters after the trim: a newline inside the token would split the authorization line in the `name: value\n` header buffer — header injection into the host transport, or a silently truncated credential. - parse_config refuses duplicate keys instead of assuming the codec already did: the function is pub over any FfiValue, and last-write-wins on client_key must never be silent (the comment promised this; now the code does it). Claude-Session: https://claude.ai/code/session_01P5YHK3w6Kj9ajTnmkaXCHW --- .../golang/stackencrypt/guest/src/config.rs | 25 +++++++++++++++++-- .../golang/stackencrypt/guest/src/headers.rs | 22 +++++++++++++--- .../golang/stackencrypt/guest/src/host.rs | 11 ++++++++ 3 files changed, 52 insertions(+), 6 deletions(-) diff --git a/languages/golang/stackencrypt/guest/src/config.rs b/languages/golang/stackencrypt/guest/src/config.rs index b7416cda3..0581bc145 100644 --- a/languages/golang/stackencrypt/guest/src/config.rs +++ b/languages/golang/stackencrypt/guest/src/config.rs @@ -40,6 +40,11 @@ pub enum ConfigError { Invalid(&'static str), /// `keyset` and `keyset_id` were both given. ConflictingKeysets, + /// A key appeared twice. The codec rejects duplicate object keys before + /// this parser runs, but `parse_config` is `pub` and takes any + /// [`FfiValue`] — last-write-wins on, say, `client_key` must never be + /// silent. + Duplicate(&'static str), /// A key this version does not recognise. UnknownKey(String), } @@ -78,8 +83,12 @@ pub fn parse_config(value: FfiValue) -> Result<CipherConfig, ConfigError> { "zerokms_url" => &mut url, _ => return Err(ConfigError::UnknownKey(key)), }; - // The codec already rejects duplicate object keys, so the slot is - // vacant; still, last-write-wins here would be silent, so require it. + // The codec already rejects duplicate object keys, so on the ABI + // path the slot is always vacant — but this function accepts any + // `FfiValue`, so enforce it rather than assume it. + if slot.is_some() { + return Err(ConfigError::Duplicate(name_of(&key))); + } let FfiValue::String(s) = value else { return Err(ConfigError::NotAString(name_of(&key))); }; @@ -269,6 +278,18 @@ mod tests { ])), Err(ConfigError::UnknownKey(_)) )); + // The codec refuses duplicate keys on the ABI path, but this + // function is `pub` over any `FfiValue`: a repeated `client_key` + // must be an error, never a silent last-write-wins on root key + // material. + assert!(matches!( + parse_config(obj(vec![ + ("client_id", &id_s), + ("client_key", &hex), + ("client_key", &hex), + ])), + Err(ConfigError::Duplicate("client_key")) + )); assert!(matches!( parse_config(obj(vec![ ("client_id", &id_s), diff --git a/languages/golang/stackencrypt/guest/src/headers.rs b/languages/golang/stackencrypt/guest/src/headers.rs index 0d0c3a6d4..c79501f18 100644 --- a/languages/golang/stackencrypt/guest/src/headers.rs +++ b/languages/golang/stackencrypt/guest/src/headers.rs @@ -22,12 +22,15 @@ pub fn encode_headers(headers: &[(&str, &str)]) -> Vec<u8> { } /// Look up a header by (ASCII-case-insensitive) name in a wire buffer. -/// Malformed lines (no colon, non-UTF-8 buffer) are skipped rather than +/// Malformed lines (no colon, non-UTF-8 bytes) are skipped rather than /// failing the response: the transport's contract is carried by the status -/// and body, and header parsing must not be a denial-of-service lever. +/// and body, and header parsing must not be a denial-of-service lever. The +/// skip is per *line*, not per buffer — a proxy that emits one raw +/// ISO-8859-1 byte in an unrelated header (a `via`/`server` line, say) must +/// not make the `content-type` lookup fail and with it every KMS call. pub fn header_value<'a>(buffer: &'a [u8], name: &str) -> Option<&'a str> { - let text = std::str::from_utf8(buffer).ok()?; - text.lines().find_map(|line| { + buffer.split(|&b| b == b'\n').find_map(|line| { + let line = std::str::from_utf8(line).ok()?; let (n, v) = line.split_once(':')?; n.trim().eq_ignore_ascii_case(name).then(|| v.trim()) }) @@ -65,4 +68,15 @@ mod tests { assert_eq!(header_value(&[0xff, 0xfe], "content-type"), None); assert_eq!(header_value(b"", "content-type"), None); } + + #[test] + fn a_non_utf8_line_does_not_poison_the_other_headers() { + let mut buffer = b"server: pro".to_vec(); + buffer.push(0xe9); // "proxé" in raw ISO-8859-1 + buffer.extend_from_slice(b"\ncontent-type: application/json"); + assert_eq!( + header_value(&buffer, "content-type"), + Some("application/json") + ); + } } diff --git a/languages/golang/stackencrypt/guest/src/host.rs b/languages/golang/stackencrypt/guest/src/host.rs index ae39940da..f8b8d0df7 100644 --- a/languages/golang/stackencrypt/guest/src/host.rs +++ b/languages/golang/stackencrypt/guest/src/host.rs @@ -253,6 +253,17 @@ impl AuthStrategy for &HostTokenStrategy { "host token is empty".to_string(), ))); } + // Trim handles the *surrounding* whitespace case above; an *interior* + // control character would survive it and land in the `name: value\n` + // header buffer, where a newline splits the authorization line in + // two — header injection into the host transport (or a silently + // truncated credential and a confusing 401). No bearer token contains + // control characters, so reject rather than sanitise. + if text.chars().any(char::is_control) { + return Err(AuthError::Custom(CustomError( + "host token contains control characters".to_string(), + ))); + } // `SecretToken` wipes on drop; `bytes` (the only other copy) wipes // via its `Zeroizing` wrapper above. Ok(ServiceToken::new(SecretToken::new(text))) From 7362511c44eab35a83f7a0529842440093ba18e7 Mon Sep 17 00:00:00 2001 From: Dan Draper <dan@cipherstash.com> Date: Tue, 1 Sep 2026 16:17:42 +1000 Subject: [PATCH 500/686] docs(stack-encrypt): state the context contract on the cipher-directed path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit into_pending/seal deliberately accept any AAD, () included — vitaminc parity, opened symmetrically by StackCipher::decrypt. But a tree destined for decrypt_into is bound by the target layer's non-degeneracy rule, and sealing under a degenerate context produces ciphertext that path can never read. Say so on into_pending, point at supplied_aad as the checked encoder, and note vitaminc#291 as where the mismatch becomes unrepresentable. (A runtime guard here was considered and rejected: it would break the supported AAD-less vitaminc-parity surface.) supplied_aad's claim that there is "no public way" to run the degeneracy check bare predates is_degenerate_aad going public for FFI front-ends; both docs now say what the split is for and that the bare predicate is temporary scaffolding. The plan doc's architecture diagram also named the token import's module cipherstash_auth where the shipped guest imports it from cipherstash_transport — a Phase-4 host following the diagram would fail instantiation on an unresolved import. Claude-Session: https://claude.ai/code/session_01P5YHK3w6Kj9ajTnmkaXCHW --- docs/plans/stack-encrypt-go-bindings.md | 2 +- packages/stack-encrypt/src/cipher.rs | 16 +++++++++++++++ packages/stack-encrypt/src/target/mod.rs | 26 ++++++++++++++---------- 3 files changed, 32 insertions(+), 12 deletions(-) diff --git a/docs/plans/stack-encrypt-go-bindings.md b/docs/plans/stack-encrypt-go-bindings.md index c935cd68b..1c780d5af 100644 --- a/docs/plans/stack-encrypt-go-bindings.md +++ b/docs/plans/stack-encrypt-go-bindings.md @@ -135,7 +135,7 @@ Go application ├─ imports vcvalue (value model) + the FFI codec ├─ embeds stack_encrypt_guest.wasm ├─ host import cipherstash_transport::transport_send → net/http → ZeroKMS - └─ host import cipherstash_auth::token_get → token source (phase 1: static) + └─ host import cipherstash_transport::token_get → token source (phase 1: static) │ ▼ wazero (wasm32-wasip1) stack-encrypt guest (Rust cdylib) diff --git a/packages/stack-encrypt/src/cipher.rs b/packages/stack-encrypt/src/cipher.rs index 7e908bfff..9247157ed 100644 --- a/packages/stack-encrypt/src/cipher.rs +++ b/packages/stack-encrypt/src/cipher.rs @@ -870,6 +870,22 @@ impl PendingStackCipherText { /// [`Pending::all`](crate::target::Pending::all) so the whole assembly /// seals in **one** batched `generate_keys` call. Same sealing path /// either way. + /// + /// **Context contract.** The cipher-directed path deliberately accepts + /// *any* AAD, including none at all (`()`) — it mirrors + /// `Aes256Cipher`, where AAD-less sealing is a legitimate AEAD use, + /// opened symmetrically by [`StackCipher::decrypt`]. But a tree that + /// will be opened through the target layer's + /// [`decrypt_into`](crate::target::DecryptInto) — a per-field record + /// assembly in an FFI front-end, say — is bound by that layer's rule: a + /// *degenerate* context (see + /// [`is_degenerate_aad`](crate::target::is_degenerate_aad)) is refused + /// on open with [`Error::EmptyContext`], so sealing under one here + /// produces ciphertext that path can never read. Validate the context + /// before driving [`Encrypt`] — + /// [`supplied_aad`](crate::target::supplied_aad) is the checked + /// encoder — until vitaminc#291 moves non-emptiness into the context + /// type and makes the mismatch unrepresentable. pub fn into_pending<K>( self, cipher: &StackCipher<K>, diff --git a/packages/stack-encrypt/src/target/mod.rs b/packages/stack-encrypt/src/target/mod.rs index 7f4f5cc9d..5a25906c2 100644 --- a/packages/stack-encrypt/src/target/mod.rs +++ b/packages/stack-encrypt/src/target/mod.rs @@ -521,13 +521,15 @@ mod prf_framing { /// /// In-crate, third-party leaves go through [`supplied_aad`] / /// [`supplied_prf_context`], the one choke point whose signature survives -/// the vitaminc#291 migration. Public so that runtime front-ends which build -/// contexts from untrusted input — the WASI guest's record plans, where a -/// context is bytes off the FFI boundary rather than a Rust literal — can -/// run the *same* predicate at parse time. Without it a degenerate context -/// could seal through a path that bypasses the leaf checks and then never -/// open, because [`DecryptInto`] does run them. It disappears with the rest -/// of this scaffolding when vitaminc#291 lands. +/// the vitaminc#291 migration — prefer those in Rust code: they check and +/// encode in one step, so the value checked is the value sealed. This bare +/// predicate is public *only* for runtime front-ends whose contexts arrive +/// as FFI bytes rather than Rust values (the WASI guest's record plans), +/// which need the same predicate at parse time to report a precise status. +/// It is temporary scaffolding on those terms: when vitaminc#291 moves +/// non-emptiness into the context type, this function is removed with the +/// rest of the runtime checks (a semver-visible removal, accepted while the +/// crate is 0.x). pub fn is_degenerate_aad(bytes: &[u8]) -> bool { if bytes.is_empty() { return true; @@ -585,10 +587,12 @@ pub(crate) fn is_degenerate_prf_context(bytes: &[u8]) -> bool { /// /// This is the choke point every built-in leaf goes through on the AEAD /// channel, and the one a third-party ciphertext-like leaf should call too -/// (see the [module docs](self#extending-with-your-own-sem-type)): there is -/// deliberately no public way to make the underlying degeneracy check -/// without also obtaining the encoded context, so a leaf cannot encode one -/// value and check another. The *signature* is stable across the +/// (see the [module docs](self#extending-with-your-own-sem-type)): checking +/// and encoding are one step, so a leaf cannot encode one value and check +/// another. ([`is_degenerate_aad`] does exist bare, for runtime front-ends +/// whose contexts are FFI bytes rather than Rust values — a Rust leaf that +/// reaches for it instead of this reintroduces exactly the check/use +/// divergence this signature prevents.) The *signature* is stable across the /// [vitaminc#291](https://github.com/cipherstash/vitaminc/issues/291) /// migration: when non-emptiness moves into the context type, the runtime /// check here collapses to a conversion, and callers do not change. From b79f268b0a25ccd4c97c6c68cde388ebbfcbe2e3 Mon Sep 17 00:00:00 2001 From: Dan Draper <dan@cipherstash.com> Date: Tue, 1 Sep 2026 16:28:50 +1000 Subject: [PATCH 501/686] fix(wasi): count zero-length buffers instead of keying them by pointer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every empty Vec leaks to the same dangling pointer (alignment, so 0x1), and the pointer-keyed registry let a second empty allocation overwrite the first entry: a host holding an empty response-header buffer and an empty body buffer at once — contract-compliant — would see the first take spend the only entry and the second fail validation, surfacing as an internal parse failure. Empties now get identity-free accounting (a live count); null still unambiguously means allocation failure, and (null, 0) remains the canonical empty on the take path. The module is target-independent, so it now compiles natively for its unit tests (the sessions pattern), which pin the collision and the non-crossing of the two accounting schemes. Claude-Session: https://claude.ai/code/session_01P5YHK3w6Kj9ajTnmkaXCHW --- .../golang/stackencrypt/guest/src/buffers.rs | 82 ++++++++++++++++++- .../golang/stackencrypt/guest/src/lib.rs | 5 +- 2 files changed, 85 insertions(+), 2 deletions(-) diff --git a/languages/golang/stackencrypt/guest/src/buffers.rs b/languages/golang/stackencrypt/guest/src/buffers.rs index 48e36e80a..bcf5b6044 100644 --- a/languages/golang/stackencrypt/guest/src/buffers.rs +++ b/languages/golang/stackencrypt/guest/src/buffers.rs @@ -17,13 +17,28 @@ //! Wasm is single-threaded, so a thread-local `RefCell` is a plain owner of //! the map — no `Send`/`Sync` bounds required. -use std::cell::RefCell; +use std::cell::{Cell, RefCell}; use std::collections::HashMap; use zeroize::Zeroize; thread_local! { static BUFFERS: RefCell<HashMap<usize, usize>> = RefCell::new(HashMap::new()); + /// Live zero-length buffers, counted rather than keyed: every empty + /// `Vec` leaks to the *same* dangling pointer (alignment, so `0x1`), and + /// a pointer-keyed map entry would be overwritten by the second empty + /// allocation — the first reclaim would then remove the only entry and + /// the second would fail validation. A host holding an empty response + /// header buffer and an empty body buffer at once is contract-compliant, + /// so empties get identity-free accounting. + static EMPTY_BUFFERS: Cell<usize> = const { Cell::new(0) }; +} + +/// The pointer every zero-length buffer presents to the host: non-null (null +/// still unambiguously means "allocation failed") and identical for all of +/// them, which is exactly what `Box<[u8]>` produces for an empty slice. +fn empty_ptr() -> *mut u8 { + std::ptr::NonNull::<u8>::dangling().as_ptr() } /// Allocate `len` bytes of guest memory for the host to write into. @@ -44,6 +59,12 @@ pub(crate) fn alloc(len: usize) -> *mut u8 { /// Register a buffer and leak it to a raw pointer for the host. The /// registry entry is what makes the matching [`dealloc`] / [`take`] sound. pub(crate) fn register(buf: Vec<u8>) -> *mut u8 { + if buf.is_empty() { + // See `EMPTY_BUFFERS`: empties share one pointer, so they are + // counted, not keyed. Nothing leaks — an empty `Vec` owns no heap. + EMPTY_BUFFERS.with(|c| c.set(c.get() + 1)); + return empty_ptr(); + } let boxed = buf.into_boxed_slice(); let len = boxed.len(); let ptr = Box::into_raw(boxed) as *mut u8; @@ -88,6 +109,20 @@ unsafe fn reclaim(ptr: *mut u8, len: usize) -> Option<Vec<u8>> { if ptr.is_null() { return None; } + if len == 0 && ptr == empty_ptr() { + // An empty reclaim spends one unit of the empty count; over-reclaim + // (a double-free of an empty) fails validation like any other + // unknown pointer. A *real* buffer can never live at the dangling + // address, and a zero `len` can only ever refer to an empty, so the + // two accounting schemes cannot cross. + return EMPTY_BUFFERS.with(|c| { + let live = c.get(); + (live > 0).then(|| { + c.set(live - 1); + Vec::new() + }) + }); + } let real_len = BUFFERS.with(|b| b.borrow_mut().remove(&(ptr as usize)))?; if real_len != len { BUFFERS.with(|b| b.borrow_mut().insert(ptr as usize, real_len)); @@ -99,3 +134,48 @@ unsafe fn reclaim(ptr: *mut u8, len: usize) -> Option<Vec<u8>> { // be reclaimed twice. Some(unsafe { Vec::from_raw_parts(ptr, real_len, real_len) }) } + +#[cfg(test)] +mod tests { + use super::*; + + // Each libtest thread gets its own thread-locals, so tests are isolated. + + #[test] + fn two_live_empty_buffers_reclaim_independently() { + // The regression this pins: both empties present the same dangling + // pointer, and keyed accounting would let the second registration + // clobber the first — making one of these `take`s fail. + let a = alloc(0); + let b = alloc(0); + assert!(!a.is_null() && !b.is_null()); + assert_eq!(unsafe { take(a, 0) }, Some(Vec::new())); + assert_eq!(unsafe { take(b, 0) }, Some(Vec::new())); + // Both spent: a third reclaim is a double-free and must fail. + assert_eq!(unsafe { take(b, 0) }, None); + } + + #[test] + fn empty_and_sized_buffers_do_not_cross_accounts() { + let empty = alloc(0); + let sized = alloc(3); + // A zero-length reclaim of the sized pointer is a length mismatch, + // not a withdrawal from the empty count. + assert_eq!(unsafe { take(sized, 0) }, None); + assert_eq!(unsafe { take(empty, 0) }, Some(Vec::new())); + assert_eq!(unsafe { take(sized, 3) }, Some(vec![0, 0, 0])); + } + + #[test] + fn a_null_pointer_with_zero_length_is_the_canonical_empty() { + assert_eq!(unsafe { take(core::ptr::null_mut(), 0) }, Some(Vec::new())); + } + + #[test] + fn dealloc_of_an_empty_buffer_is_balanced() { + let a = alloc(0); + unsafe { dealloc(a, 0) }; + // The dealloc spent the only live empty; a take now finds none. + assert_eq!(unsafe { take(a, 0) }, None); + } +} diff --git a/languages/golang/stackencrypt/guest/src/lib.rs b/languages/golang/stackencrypt/guest/src/lib.rs index 8b879ff8b..902e61960 100644 --- a/languages/golang/stackencrypt/guest/src/lib.rs +++ b/languages/golang/stackencrypt/guest/src/lib.rs @@ -52,7 +52,10 @@ pub(crate) mod sessions; // truncate a 64-bit pointer). #[cfg(target_arch = "wasm32")] pub mod abi; -#[cfg(target_arch = "wasm32")] +// Target-independent (plain `Vec`s and raw pointers, no linear-memory +// reads), so like `sessions` it exists natively for its unit tests — the +// empty-buffer accounting in particular is pinned there. +#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))] pub(crate) mod buffers; #[cfg(target_arch = "wasm32")] pub mod host; From 93990f47b8189dd442f35ba281cfc433b14d239b Mon Sep 17 00:00:00 2001 From: Dan Draper <dan@cipherstash.com> Date: Tue, 1 Sep 2026 16:28:51 +1000 Subject: [PATCH 502/686] fix(stack-kms): mark the classify response structs non_exhaustive MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FailureResponse and UnexpectedContentType are newly public with public fields; a downstream exhaustive destructure or literal construction would make adding a diagnostic field later a source break. non_exhaustive now, while the API is being introduced — construction stays in classify_response. Claude-Session: https://claude.ai/code/session_01P5YHK3w6Kj9ajTnmkaXCHW --- packages/stack-kms/src/connection/classify.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/stack-kms/src/connection/classify.rs b/packages/stack-kms/src/connection/classify.rs index e82a8bebe..5f141c942 100644 --- a/packages/stack-kms/src/connection/classify.rs +++ b/packages/stack-kms/src/connection/classify.rs @@ -37,6 +37,7 @@ pub struct BaseUrlUnresolved; /// logs. They stay available through `Debug` for structured inspection. #[derive(Debug, Error)] #[error("Received '{received:?}', expected '{expected}'")] +#[non_exhaustive] pub struct UnexpectedContentType { pub received: Option<String>, pub expected: &'static str, @@ -51,6 +52,7 @@ pub struct UnexpectedContentType { /// must not be pulled into a log line. `Debug` still carries them. #[derive(Debug, Error)] #[error("Status: {status}")] +#[non_exhaustive] pub struct FailureResponse { pub status: u16, pub body: Option<String>, From 1c716479ac73861349c66392cf2e71cb0ed2ddab Mon Sep 17 00:00:00 2001 From: Dan Draper <dan@cipherstash.com> Date: Wed, 2 Sep 2026 11:45:48 +1000 Subject: [PATCH 503/686] ci(wasi): gate the guest crate and assert its import surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The stack-encrypt WASI guest is a detached workspace, so no existing job compiled, linted or tested it: its 41 tests could fail, its wasm-only ABI and host modules could stop compiling, and the release module could stop building with every check still green. The WASI workflow now watches the guest path and runs `wasm:guest:test` and `wasm:guest:build`, which makes it the crate's gate. `wasm:guest:build` also asserts the module's host-import surface rather than printing a path. The Go embedder's sandboxing rests on the guest reaching the outside world only through the two host functions it is given, and that is a property of the linked module — a successful build says nothing about it. `scripts/check-wasm-imports.py` parses the import section and fails closed: every import must be either WASI, with the capability-granting `path_*`, `sock_*` and `fd_prestat*` names denied so a dependency cannot quietly acquire ambient filesystem or network access, or one of the two required `cipherstash_transport` functions, and both of those must be present. Claude-Session: https://claude.ai/code/session_01CPCWmcwmgUVp2fGLfxaVw8 --- .github/imported-workflows/test-wasi.yml | 22 +++ docs/plans/stack-encrypt-go-bindings.md | 12 ++ scripts/check-wasm-imports.py | 212 +++++++++++++++++++++++ 3 files changed, 246 insertions(+) create mode 100755 scripts/check-wasm-imports.py diff --git a/.github/imported-workflows/test-wasi.yml b/.github/imported-workflows/test-wasi.yml index 0b7b1b2cf..51deb6e28 100644 --- a/.github/imported-workflows/test-wasi.yml +++ b/.github/imported-workflows/test-wasi.yml @@ -12,6 +12,10 @@ on: # Trigger on all of packages/ rather than enumerating the closure, # which would silently drift. - packages/** + # The guest is a detached workspace, so nothing else in CI compiles, + # tests or links it — this job is its only gate. + - bindings/go/stackencrypt/guest/** + - scripts/check-wasm-imports.py - Cargo.toml - Cargo.lock - .cargo/** @@ -24,6 +28,10 @@ on: pull_request: paths: - packages/** + # The guest is a detached workspace, so nothing else in CI compiles, + # tests or links it — this job is its only gate. + - bindings/go/stackencrypt/guest/** + - scripts/check-wasm-imports.py - Cargo.toml - Cargo.lock - .cargo/** @@ -70,3 +78,17 @@ jobs: # compile gate above cannot see doc examples or intra-doc links. - name: No-http tests and docs run: mise run wasm:no-http-test + + # The stack-encrypt WASI guest (bindings/go/stackencrypt/guest) is a + # detached workspace: `test:unit` and the workspace-wide lints never + # see it, so without these two steps its tests could fail and its + # wasm-only modules stop compiling with every check still green. + - name: Guest lint and tests + run: mise run wasm:guest:test + + # Builds the release module and asserts its host-import surface is + # exactly WASI (minus ambient filesystem/sockets) plus the two + # `cipherstash_transport` functions — the property the Go embedder's + # sandboxing rests on, checkable only on the linked artifact. + - name: Guest release build and import-surface gate + run: mise run wasm:guest:build diff --git a/docs/plans/stack-encrypt-go-bindings.md b/docs/plans/stack-encrypt-go-bindings.md index 1c780d5af..b2009a3eb 100644 --- a/docs/plans/stack-encrypt-go-bindings.md +++ b/docs/plans/stack-encrypt-go-bindings.md @@ -309,6 +309,18 @@ key source proving one ZeroKMS call per record batch), and the release `.wasm` builds with an import surface of exactly WASI + `cipherstash_transport` (`mise run wasm:guest:build` / `wasm:guest:test`). +Both tasks run in CI. The guest is a detached workspace, so the +workspace-wide jobs never compile, lint or test it; the WASI workflow +(`.github/workflows/test-wasi.yml`) watches the guest path and runs the two +tasks, which is the crate's only gate. The import surface is asserted, not +eyeballed: `scripts/check-wasm-imports.py` parses the linked module's +import section and fails closed — every import must be either WASI (with +the capability-granting `path_*`, `sock_*` and `fd_prestat*` names denied, +so a dependency cannot quietly acquire ambient filesystem or network +access) or one of the two required `cipherstash_transport` functions, and +both of those must be present. A build alone proves nothing here: the +property is about what the *linked* module can reach. + Two things worth stating plainly, because they are easy to read the wrong way: diff --git a/scripts/check-wasm-imports.py b/scripts/check-wasm-imports.py new file mode 100755 index 000000000..86e538fd0 --- /dev/null +++ b/scripts/check-wasm-imports.py @@ -0,0 +1,212 @@ +#!/usr/bin/env python3 +"""Assert a .wasm module's import surface is exactly what we promise. + +The Go/wazero binding's security contract is that the guest can only reach +the outside world through the host functions we hand it: no ambient network +or filesystem access sneaking in through a dependency. That is a property of +the *linked module*, so it can only be checked on the built artifact — a +`cargo build` that succeeds proves nothing about it. + +Fail-closed by construction: an import is rejected unless its module was +explicitly allowed (--allow-module) or the exact `module:name` pair was +required (--require), and every required pair must be present. A new +dependency that pulls in an extra host import therefore fails the build +rather than silently widening the surface. + +`--deny-prefix` narrows an allowed module from within: WASI is one module +name covering both harmless calls (`random_get`, `fd_write` on stdio) and +the capability-granting ones (`path_open`, `sock_*`), so the ambient-access +half is denied by name even though the module is allowed. + +Usage: + check-wasm-imports.py MODULE.wasm --allow-module wasi_snapshot_preview1 \\ + --deny-prefix wasi_snapshot_preview1:path_ \\ + --require cipherstash_transport:transport_send +""" + +import argparse +import sys + + +class MalformedModule(Exception): + pass + + +class Reader: + """Minimal cursor over a wasm binary (little-endian, LEB128 varuints).""" + + def __init__(self, data: bytes): + self.data = data + self.pos = 0 + + def bytes(self, n: int) -> bytes: + if n < 0 or self.pos + n > len(self.data): + raise MalformedModule("truncated module") + out = self.data[self.pos : self.pos + n] + self.pos += n + return out + + def byte(self) -> int: + return self.bytes(1)[0] + + def varuint(self) -> int: + result = 0 + shift = 0 + while True: + if shift > 63: + raise MalformedModule("LEB128 value too large") + b = self.byte() + result |= (b & 0x7F) << shift + if not b & 0x80: + return result + shift += 7 + + def name(self) -> str: + raw = self.bytes(self.varuint()) + try: + return raw.decode("utf-8") + except UnicodeDecodeError as exc: + raise MalformedModule("import name is not valid UTF-8") from exc + + +def skip_limits(reader: Reader) -> None: + flags = reader.byte() + reader.varuint() # minimum + if flags & 0x01: + reader.varuint() # maximum + + +def skip_import_descriptor(reader: Reader) -> None: + kind = reader.byte() + if kind == 0x00: # func: type index + reader.varuint() + elif kind == 0x01: # table: reftype, limits + reader.byte() + skip_limits(reader) + elif kind == 0x02: # memory: limits + skip_limits(reader) + elif kind == 0x03: # global: valtype, mutability + reader.byte() + reader.byte() + else: + raise MalformedModule(f"unknown import kind 0x{kind:02x}") + + +def read_imports(data: bytes) -> list: + """Every (module, name) pair in the module's import section.""" + reader = Reader(data) + if reader.bytes(4) != b"\x00asm": + raise MalformedModule("not a wasm module (bad magic)") + version = int.from_bytes(reader.bytes(4), "little") + if version != 1: + raise MalformedModule(f"unsupported wasm version {version}") + + imports = [] + seen_import_section = False + while reader.pos < len(data): + section_id = reader.byte() + size = reader.varuint() + end = reader.pos + size + if end > len(data): + raise MalformedModule("section runs past end of module") + if section_id == 2: # import section + if seen_import_section: + raise MalformedModule("duplicate import section") + seen_import_section = True + for _ in range(reader.varuint()): + module = reader.name() + field = reader.name() + skip_import_descriptor(reader) + imports.append((module, field)) + if reader.pos != end: + raise MalformedModule("import section size mismatch") + reader.pos = end + return imports + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("module", help="path to the .wasm file") + parser.add_argument( + "--allow-module", + action="append", + default=[], + metavar="MODULE", + help="import module whose functions are all permitted (repeatable)", + ) + parser.add_argument( + "--deny-prefix", + action="append", + default=[], + metavar="MODULE:PREFIX", + help="reject imports from an allowed module whose name starts with " + "PREFIX (repeatable)", + ) + parser.add_argument( + "--require", + action="append", + default=[], + metavar="MODULE:NAME", + help="import that must be present, and is permitted (repeatable)", + ) + args = parser.parse_args() + + required = set() + for spec in args.require: + module, sep, name = spec.partition(":") + if not sep or not module or not name: + parser.error(f"--require expects MODULE:NAME, got {spec!r}") + required.add((module, name)) + allowed_modules = set(args.allow_module) + + denied_prefixes = [] + for spec in args.deny_prefix: + module, sep, prefix = spec.partition(":") + if not sep or not module or not prefix: + parser.error(f"--deny-prefix expects MODULE:PREFIX, got {spec!r}") + denied_prefixes.append((module, prefix)) + + try: + with open(args.module, "rb") as f: + data = f.read() + imports = read_imports(data) + except OSError as exc: + print(f"error: cannot read {args.module}: {exc}", file=sys.stderr) + return 1 + except MalformedModule as exc: + print(f"error: {args.module}: {exc}", file=sys.stderr) + return 1 + + def denied(imp): + return any( + imp[0] == module and imp[1].startswith(prefix) + for module, prefix in denied_prefixes + ) + + unexpected = [ + imp + for imp in imports + if denied(imp) or (imp[0] not in allowed_modules and imp not in required) + ] + missing = sorted(required - set(imports)) + + for module, name in sorted(unexpected): + print(f"error: unexpected host import {module}::{name}", file=sys.stderr) + for module, name in missing: + print(f"error: required host import {module}::{name} is absent", file=sys.stderr) + if unexpected or missing: + print( + "error: import surface of " + f"{args.module} is not the one the host contract promises", + file=sys.stderr, + ) + return 1 + + print(f"import surface OK: {len(imports)} imports, all expected") + for module, name in sorted(set(imports)): + print(f" {module}::{name}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) From 7ea876eda8ee5502604e28a8f8fdce4cb0cb216f Mon Sep 17 00:00:00 2001 From: Dan Draper <dan@cipherstash.com> Date: Wed, 2 Sep 2026 13:12:47 +1000 Subject: [PATCH 504/686] fix(wasi): wipe the plaintext operand on the ORE/OPE term paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A zeroize audit of the guest at its shipped profile (wasm32-wasip1, opt-level="s", lto=true) found one real leak and one doc defect. `term_bytes`' ORE and OPE arms cloned the caller's plaintext out of its `Zeroizing` wrapper and handed the bare owned `String`/`Vec<u8>` to cllw-ore. The CLLW encryptors take their operand by `'static` ownership — the visitor carries it — and the owned impls borrowed it and then dropped it through ordinary drop glue, so the buffer went back to the allocator with the plaintext still in it. In a wasm guest that memory is linear memory the host can read, which undercuts the wipe `abi::wipe_input` already pays for on every entry. The equality arm was never affected: `impl PrfValue for String` moves the operand into `Protected::new`, which is `ZeroizeOnDrop`. cllw-ore grows `CllwOreEncrypt`/`CllwOpeEncrypt` impls for `Zeroizing<String>` and `Zeroizing<Vec<u8>>` so a caller holding a secret never has to unwrap it to encrypt, and the guest passes its existing wrapper straight through — one fewer copy, not just a wiped one. The bare owned impls now take their operand into a `Zeroizing` for the duration of the call, so every other caller gets the wipe too, and `orderize_string` returns `Zeroizing<String>` because the collated form is as sensitive as the plaintext it is derived from. Ciphertext is unchanged: a new test pins all three spellings — borrowed, bare owned, and `Zeroizing` — as byte-identical for ORE and OPE. `se_cipher_free`'s doc claimed the index key is "wiped by its own `ZeroizeOnDrop`". The wipe is real but refcount-guarded: the key lives in `Arc<Protected<Vec<u8>>>` and every derivation takes a `prf().clone()`, so it runs only at strong count zero. Nothing in the guest parks a clone beyond an ABI call, so today's behaviour is correct — but that is a precondition, not a property of the drop, and the doc now says so. Verified: the audit's PoC for the leak goes from exploitable (ORE 2/25 and OPE 2/21 freed blocks still holding the marker plaintext) to not exploitable (0/24, 0/20) with the term bytes unchanged. Claude-Session: https://claude.ai/code/session_01CPCWmcwmgUVp2fGLfxaVw8 --- .../golang/stackencrypt/guest/src/abi.rs | 22 +++++++++++++++---- .../golang/stackencrypt/guest/src/ops.rs | 14 ++++++++---- 2 files changed, 28 insertions(+), 8 deletions(-) diff --git a/languages/golang/stackencrypt/guest/src/abi.rs b/languages/golang/stackencrypt/guest/src/abi.rs index 1386d7b07..76e918351 100644 --- a/languages/golang/stackencrypt/guest/src/abi.rs +++ b/languages/golang/stackencrypt/guest/src/abi.rs @@ -14,7 +14,8 @@ //! `StackCipher<StackKms<HostTokenStrategy, WasiHostConnection>>` (one //! `load-keyset` round trip through the host transport — the index key //! then lives in the guest), and [`se_cipher_free`] drops it (client key -//! and index key wiped by their own `ZeroizeOnDrop`). Handle ids are +//! wiped unconditionally, index key subject to the `Arc` precondition +//! documented on that export). Handle ids are //! never reused; at exhaustion `se_cipher_init` fails with //! `STATUS_INTERNAL` rather than aliasing a live handle. //! - During an entry-point call the host's imported functions may re-enter @@ -242,9 +243,22 @@ fn cipher_init(decoded: vitaminc_aead_value::FfiValue) -> Result<u32, u32> { SESSIONS.with(|s| s.borrow_mut().insert(cipher)) } -/// Drop a cipher handle. The client key and the keyset's index key are -/// wiped by their own `ZeroizeOnDrop`. Freeing an unknown handle is a -/// no-op. +/// Drop a cipher handle. Freeing an unknown handle is a no-op. +/// +/// The client key's wipe is unconditional: the `StackCipher` owns it, so the +/// `ZeroizeOnDrop` runs here. +/// +/// The keyset's index key is wiped here **only while no other reference to +/// the PRF is outstanding**. It lives in `HmacSha256Prf { key: Arc<Protected +/// <Vec<u8>>> }`, and `Arc` runs the inner `ZeroizeOnDrop` at strong count +/// zero — so a live clone means this call frees the handle and leaves the key +/// in memory. Every derivation takes such a clone (`sem::equality`, +/// `ore_term`, `ope_term`), but the guest is single-threaded and each clone +/// is created and dropped inside one `block_on`'d ABI call, so none can still +/// be alive when the host calls this. That is the precondition, not a +/// property of the drop: anything that later parks a PRF clone beyond an ABI +/// call — a cache, a background task, a `'static` handle — silently turns +/// this wipe into a no-op with no test to catch it. #[no_mangle] pub extern "C" fn se_cipher_free(handle: u32) { let _ = catch_unwind(AssertUnwindSafe(|| { diff --git a/languages/golang/stackencrypt/guest/src/ops.rs b/languages/golang/stackencrypt/guest/src/ops.rs index d2385e7fb..6adb123a0 100644 --- a/languages/golang/stackencrypt/guest/src/ops.rs +++ b/languages/golang/stackencrypt/guest/src/ops.rs @@ -252,6 +252,12 @@ where .map_err(term_err), _ => Err(STATUS_ENCODING), }, + // The text and bytes arms hand the encryptor the `Zeroizing` operand + // itself, not a bare clone of its contents: the CLLW encryptors take + // their value by `'static` ownership (the visitor carries it), so a + // cloned-out `String`/`Vec<u8>` would be freed with the plaintext + // still in it — in linear memory the host can read. Keeping the + // wrapper costs nothing and saves the copy as well. Output::Ore => match scalar { Scalar::Bool(v) => ore(cipher, v, context).await, Scalar::I32(v) => ore(cipher, v, context).await, @@ -260,8 +266,8 @@ where Scalar::U64(v) => ore(cipher, v, context).await, Scalar::F32(v) => ore(cipher, v, context).await, Scalar::F64(v) => ore(cipher, v, context).await, - Scalar::Text(t) => ore(cipher, String::clone(&t), context).await, - Scalar::Bytes(b) => ore(cipher, Vec::clone(&b), context).await, + Scalar::Text(t) => ore(cipher, t, context).await, + Scalar::Bytes(b) => ore(cipher, b, context).await, }, Output::Ope => match scalar { Scalar::Bool(v) => ope(cipher, v, context).await, @@ -271,8 +277,8 @@ where Scalar::U64(v) => ope(cipher, v, context).await, Scalar::F32(v) => ope(cipher, v, context).await, Scalar::F64(v) => ope(cipher, v, context).await, - Scalar::Text(t) => ope(cipher, String::clone(&t), context).await, - Scalar::Bytes(b) => ope(cipher, Vec::clone(&b), context).await, + Scalar::Text(t) => ope(cipher, t, context).await, + Scalar::Bytes(b) => ope(cipher, b, context).await, }, } } From 428d83f0c987f6badcd7f761171f4545df19178f Mon Sep 17 00:00:00 2001 From: Dan Draper <dan@cipherstash.com> Date: Wed, 2 Sep 2026 14:59:05 +1000 Subject: [PATCH 505/686] test(stack-kms): fuzz the client-key material decoder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ClientKey::from_encoded_v1` is the public entry point for key material arriving from an untyped boundary — an environment variable, `secretkey.json`, the WASI guest's FFI config object — and it runs attacker-influenced bytes through hex/base64 decoding and then CBOR keyset deserialization. `AGENTS.md` and `docs/fuzzing.md` require a fuzz target for public untrusted-input string parsers; this one had none. Adds `packages/stack-kms/fuzz` (detached workspace, like the other fuzz crates) with the `client_key_encoded` target, two committed seeds — the same randomly generated, never-used keyset in hex and in base64, so both decoder arms are seeded — the `fuzz:client-key` mise task, and both `fuzz.yml` matrices plus the `packages/stack-kms/**` path filter, so the blocking regression job builds the harness and replays the corpus on every PR that touches the crate. The fuzz crate takes `stack-kms` with `default-features = false`: the decoder needs neither the HTTP transport nor the on-disk profile, and dropping them keeps reqwest and its TLS stack out of the fuzz build. Verified: 2,744,157 runs in 61s, no crash. Claude-Session: https://claude.ai/code/session_011PbE4BK7gxMDqmibxLpwYA --- .github/imported-workflows/fuzz.yml | 3 ++ docs/fuzzing.md | 7 ++-- packages/stack-kms/fuzz/Cargo.toml | 35 +++++++++++++++++++ .../valid-client-key-base64 | 1 + .../client_key_encoded/valid-client-key-hex | 1 + .../fuzz/fuzz_targets/client_key_encoded.rs | 17 +++++++++ packages/stack-kms/tasks.toml | 14 ++++++++ 7 files changed, 76 insertions(+), 2 deletions(-) create mode 100644 packages/stack-kms/fuzz/Cargo.toml create mode 100644 packages/stack-kms/fuzz/corpus/client_key_encoded/valid-client-key-base64 create mode 100644 packages/stack-kms/fuzz/corpus/client_key_encoded/valid-client-key-hex create mode 100644 packages/stack-kms/fuzz/fuzz_targets/client_key_encoded.rs create mode 100644 packages/stack-kms/tasks.toml diff --git a/.github/imported-workflows/fuzz.yml b/.github/imported-workflows/fuzz.yml index bfd92a4c6..fc616df3b 100644 --- a/.github/imported-workflows/fuzz.yml +++ b/.github/imported-workflows/fuzz.yml @@ -20,6 +20,7 @@ on: paths: - packages/cts-common/** - packages/stack-auth/** + - packages/stack-kms/** - .github/workflows/fuzz.yml # Ordering matters: keep these excludes last so docs-only changes are skipped. - "!**.md" @@ -59,6 +60,7 @@ jobs: - { task: "fuzz:region", slug: region } - { task: "fuzz:access-key", slug: access-key } - { task: "fuzz:jwt-decode", slug: jwt-decode } + - { task: "fuzz:client-key", slug: client-key } steps: - uses: actions/checkout@v6 - uses: ./.github/actions/setup-rust @@ -83,6 +85,7 @@ jobs: - { task: "fuzz:region", slug: region, dir: packages/cts-common, target: region_parse } - { task: "fuzz:access-key", slug: access-key, dir: packages/stack-auth, target: access_key_parse } - { task: "fuzz:jwt-decode", slug: jwt-decode, dir: packages/stack-auth, target: jwt_decode } + - { task: "fuzz:client-key", slug: client-key, dir: packages/stack-kms, target: client_key_encoded } steps: - uses: actions/checkout@v6 - uses: ./.github/actions/setup-rust diff --git a/docs/fuzzing.md b/docs/fuzzing.md index aeb8973fc..2e29c024c 100644 --- a/docs/fuzzing.md +++ b/docs/fuzzing.md @@ -26,6 +26,7 @@ Current targets: | `fuzz:region` | `cts-common` | `region_parse` | `Region` | | `fuzz:access-key` | `stack-auth` | `access_key_parse` | `AccessKey` (`CSAK<key_id>.<key_secret>`) | | `fuzz:jwt-decode` | `stack-auth` | `jwt_decode` | JWT claims (`Token::fuzz_decode_claims`) | +| `fuzz:client-key` | `stack-kms` | `client_key_encoded` | `ClientKey::from_encoded_v1` (hex or base64) | Each target is a few lines — `libfuzzer-sys` hands a `&str` to the parser via the `arbitrary` crate: @@ -62,6 +63,7 @@ packages/cts-common/fuzz/ fuzz_targets/*.rs # one file per target binary corpus/<target>/* # committed seed inputs (valid examples) packages/stack-auth/fuzz/ +packages/stack-kms/fuzz/ … ``` @@ -122,8 +124,9 @@ Two jobs with deliberately different roles: to stay small, and any crash reproducer is uploaded as an artifact. The `pull_request` trigger is path-filtered to `packages/cts-common/**`, -`packages/stack-auth/**`, and the workflow file, with `!**.md` / -`!**.example` excludes last so docs-only changes are skipped. +`packages/stack-auth/**`, `packages/stack-kms/**`, and the workflow file, +with `!**.md` / `!**.example` excludes last so docs-only changes are +skipped. ## Adding a new target diff --git a/packages/stack-kms/fuzz/Cargo.toml b/packages/stack-kms/fuzz/Cargo.toml new file mode 100644 index 000000000..2bbdc1ddf --- /dev/null +++ b/packages/stack-kms/fuzz/Cargo.toml @@ -0,0 +1,35 @@ +# Fuzz crate for stack-kms's public client-key material decoder. +# +# This is a DETACHED crate: the `[workspace]` table at the bottom makes it its +# own workspace root so the libfuzzer-sys dependency and the nightly-only build +# never touch the main monorepo workspace. It is not a member of the root +# workspace (see the root Cargo.toml `members` list). Run via the `fuzz:*` +# mise tasks, which invoke `cargo +nightly fuzz run`. +[package] +name = "stack-kms-fuzz" +version = "0.0.0" +publish = false +edition = "2021" + +[package.metadata] +cargo-fuzz = true + +[dependencies] +libfuzzer-sys = "0.4" +uuid = "1.8" + +[dependencies.stack-kms] +path = ".." +# The decoder needs neither the HTTP transport nor the on-disk profile, and +# dropping them keeps reqwest and its TLS stack out of the fuzz build. +default-features = false + +[[bin]] +name = "client_key_encoded" +path = "fuzz_targets/client_key_encoded.rs" +test = false +doc = false +bench = false + +[workspace] +resolver = "2" diff --git a/packages/stack-kms/fuzz/corpus/client_key_encoded/valid-client-key-base64 b/packages/stack-kms/fuzz/corpus/client_key_encoded/valid-client-key-base64 new file mode 100644 index 000000000..34d343002 --- /dev/null +++ b/packages/stack-kms/fuzz/corpus/client_key_encoded/valid-client-key-base64 @@ -0,0 +1 @@ +pGJwMaFrcGVybXV0YXRpb26QCwgCBwQGBQMOCgEADQkPDGdwMl9mcm9toWtwZXJtdXRhdGlvbpACAw4LBAANAQgKCQ8HDAUGZXAyX3RvoWtwZXJtdXRhdGlvbpAOAwYMDwkHAgEFBAAKDQgLYnAzoWtwZXJtdXRhdGlvbpghEgwVGBgKFBgbARYCGB8NGCAYHBAYHhgZGBoHGB0TBg8XAwAOBQsRCAkE \ No newline at end of file diff --git a/packages/stack-kms/fuzz/corpus/client_key_encoded/valid-client-key-hex b/packages/stack-kms/fuzz/corpus/client_key_encoded/valid-client-key-hex new file mode 100644 index 000000000..75533b145 --- /dev/null +++ b/packages/stack-kms/fuzz/corpus/client_key_encoded/valid-client-key-hex @@ -0,0 +1 @@ +a4627031a16b7065726d75746174696f6e900b080207040605030e0a01000d090f0c6770325f66726f6da16b7065726d75746174696f6e9002030e0b04000d01080a090f070c05066570325f746fa16b7065726d75746174696f6e900e03060c0f090702010504000a0d080b627033a16b7065726d75746174696f6e9821120c1518180a14181b011602181f0d1820181c10181e1819181a07181d13060f1703000e050b11080904 \ No newline at end of file diff --git a/packages/stack-kms/fuzz/fuzz_targets/client_key_encoded.rs b/packages/stack-kms/fuzz/fuzz_targets/client_key_encoded.rs new file mode 100644 index 000000000..882aac121 --- /dev/null +++ b/packages/stack-kms/fuzz/fuzz_targets/client_key_encoded.rs @@ -0,0 +1,17 @@ +#![no_main] + +use libfuzzer_sys::fuzz_target; +use stack_kms::ClientKey; +use uuid::Uuid; + +// Fuzz the public `ClientKey::from_encoded_v1` decoder: hex (either case) or +// standard padded base64, then CBOR keyset decoding. This is the entry point +// front-ends use for key material arriving from an untyped boundary — an +// environment variable, `secretkey.json`, the WASI guest's FFI config object — +// so parsing must never panic: malformed input must return `Err`, not crash. +// +// The key id is fixed: it is not parsed, only stored, so varying it would just +// dilute the corpus. libfuzzer-sys supplies `&str` via the `arbitrary` crate. +fuzz_target!(|s: &str| { + let _ = ClientKey::from_encoded_v1(Uuid::nil(), s); +}); diff --git a/packages/stack-kms/tasks.toml b/packages/stack-kms/tasks.toml new file mode 100644 index 000000000..b41a91bd9 --- /dev/null +++ b/packages/stack-kms/tasks.toml @@ -0,0 +1,14 @@ +# Fuzz stack-kms's public client-key material decoder +# (`ClientKey::from_encoded_v1`, hex or base64 then CBOR keyset decoding), via +# libFuzzer/cargo-fuzz. Requires the nightly toolchain. Runs 60s by default; +# override by appending a libFuzzer flag, e.g. +# `mise run fuzz:client-key -- -max_total_time=300` (the last repeated value +# wins). `--sanitizer none` is safe — the decode path is pure safe Rust. +# `--target $(rustc … host)` forces the native host triple (the cargo-fuzz +# binary may be x86_64 under Rosetta on Apple Silicon, which otherwise +# misdetects the target). The fuzz crate lives in `packages/stack-kms/fuzz/` +# (detached). +["fuzz:client-key"] +description = "Fuzz stack-kms's client-key material decoder (libFuzzer, nightly, 60s default)" +dir = "{{config_root}}/packages/stack-kms" +run = "cargo +nightly fuzz run client_key_encoded --sanitizer none --target $(rustc -vV | sed -n 's/^host: //p') -- -max_total_time=60" From f0883b361c6f43fb5d409f19d7b4c8a51ac8054a Mon Sep 17 00:00:00 2001 From: Dan Draper <dan@cipherstash.com> Date: Tue, 8 Sep 2026 16:27:42 +1000 Subject: [PATCH 506/686] feat(stack-encrypt)!: vitaminc 0.2.0, `NonEmpty<T>` contexts, `struct = T` (cipherstash/cipherstash-suite#2180) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore(stack-encrypt): move to vitaminc 0.2.0 Pin stack-encrypt and the WASI guest to the commit the `vitaminc-v0.2.0` tag points at, replacing the interim vitaminc#289 pin. Still a git source rather than the registry: the rest of the suite builds against the `0.2.0-pre` facade, and cargo unifies `0.2.0-pre` and `0.2.0` from one registry into a single copy, which would move CTS's stored OAuth ciphertexts onto the 0.2.0 wire format (0.2.0 does not decrypt prerelease data). The registry move lands with the change that moves CTS. API changes absorbed: - `HmacSha256Prf` no longer implements `Clone` and is keyed through `PrfKeyInit`; the term builders borrow the cipher's PRF instead of cloning it, and `prf_visit_with_context` takes it by reference. - `MapAccess` split `next_entry` into `next_key` / `next_value` / `next_passthrough`, so a decoder can choose the plaintext type after seeing the key. `StackMapAccess` now holds the pending entry and refuses to skip one, mirroring `AesMapAccess`. - `MapCipher::passthrough_entry_boxed` is required; this cipher's passthrough type already is the boxed payload, so it delegates. - `IntoAad` now covers every integer width; the `SuppliedContext` roster follows it, and the `DecryptContext` compile-fail example uses `bool` since `u8` is a context now. Claude-Session: https://claude.ai/code/session_01WjAbHRPPXPZRhtDym3dMTt * fix(stack-encrypt): keep a misread map entry pending; state the integer roster exactly Review follow-ups on the vitaminc 0.2.0 move. `StackMapAccess::next_passthrough` took the pending entry before checking whether it was a passthrough, so asking for a sealed entry as a passthrough both refused it and dropped it — the caller could neither open it with `next_value` afterwards nor be stopped from moving past it. The entry now stays pending on that refusal: `next_value` still opens it and `next_key` still refuses to advance until it is opened. vitaminc's own `AesMapAccess` drops the entry; ours keeps it because the refusal is an answer to the caller, not a reason to lose an authenticated entry. Two unit tests pin the passthrough read and the misread. The `SuppliedContext` docs said "the integers"; the roster is the fixed-width ones vitaminc gives `IntoAad` to (`u8`–`u128`, `i8`–`i128`), not `usize` / `isize`. Say so. Claude-Session: https://claude.ai/code/session_01WjAbHRPPXPZRhtDym3dMTt * feat(stack-encrypt)!: contexts are vitaminc's `NonEmpty<T>`; a caller's context extends a row's BREAKING CHANGE: every context is a `NonEmpty<T>`. Replace bare string contexts with `nonempty!("users/email")` (a literal, checked at compile time) or `NonEmpty::new(value)?` (a runtime value, checked once); an integer passes as it is. `encrypt_into_with_context` / `decrypt_from_with_context` take anything that converts into a `NonEmpty<T>`; `DecryptInto::decrypt_into` and the descriptor methods (`equality_term`, `match_terms`, `ore_term`, `ope_term`) take the `NonEmpty<T>` itself. `SuppliedContext`, `EncryptContext`, `DecryptContext`, `is_degenerate_aad`, `supplied_aad`, `supplied_prf_context`, `Error::EmptyContext` and `TermError::EmptyContext` are gone: an empty context can no longer be built, so nothing fails on one at runtime. A derived row now accepts `encrypt_into_with_context` as well as `encrypt_into`: the caller's context *extends* each field's inferred one (`("users/age", id)`), and a probe for such a field is built under `nonempty!("users/age").with(id)`. A field's `context = ".."` literal is never extended. `nested` fields and `from` fields with no literal are handed the caller's context as it is. Why: `SuppliedContext` was a hand-maintained roster of every vitaminc context type but `()`, which meant a type that implements vitaminc's `IntoAad` / `IntoPrfContext` was still not a context here until this crate listed it, and the emptiness check re-parsed an encoding it did not own on every encrypt. vitaminc 0.2.0 carries the proof in the type (`NonEmpty<T>`), so the leaves bound on that and this crate owns no context trait at all: anything vitaminc encodes as a context is a context. Rows: the derive emits two impls per plaintext, one for `()` (each field under the context it carries itself — bytes unchanged) and one for `NonEmpty<__T>` (each inferred context extended with the caller's via `NonEmpty::with`). The extension is bounded `'static` when a field composes it — the literal fixes the pair's lifetime — and for a free lifetime otherwise. `nonempty!` in the expansion keeps a literal a compile-time proof. Needs `NonEmpty::with` and `From<integer> for NonEmpty`, on vitaminc branch `non-empty-with` (3fb9db4); both git pins move there. The `[dev-dependencies]` re-declarations of the three vitaminc crates, byte-identical to `[dependencies]`, are dropped. The Go guest proves contexts with `NonEmpty::new` at the FFI boundary: a zero-length AAD or plan context is `STATUS_ENCODING`; bytes that merely look like an encoding of nothing (eight zero bytes) are caller bytes and are accepted. Claude-Session: https://claude.ai/code/session_019VRX1tXygj5bew3YhyK1B6 * feat(stack-encrypt)!: `struct = T` replaces `row = T`; a field's own context is extended, never discarded BREAKING CHANGE: `#[stash(row = User, context = "users")]` is now `#[stash(struct = User, context = "users")]`, and `from` / `nested` exist only there: a `plaintext = T` record derives every field from the whole value, whatever `T` is — the derive sees a name, not a definition, so the attribute is what says field-by-field. A field's `context = ".."` literal is now extended by a context the caller passes, exactly as a `struct` derive's inferred one is: a record whose fields all pin literals used to accept `encrypt_into_with_context(&cipher, tenant)` and seal nothing under `tenant`; it now seals under `("legacy/age", tenant)` and opens only there. `Pinned` / `Foo` / `struct` — the three shapes and the call forms each accepts — are pinned by `tests/ui/pass` and by the "Which form compiles" doctests in `stack_encrypt::target`. Why "struct": "row" pushed database vocabulary into a general-purpose library. `struct` is a keyword, so the container attribute is parsed by hand (`ContainerItem`) rather than through `parse_nested_meta`. Generated calls now name the context type (`EncryptFrom<_, C, ()>`) instead of inferring it, so a `nested` leaf under the `()` impl is reported through the trait's `on_unimplemented` — the leaf, the missing `context`, and how to supply one — rather than as an E0308 inside the expansion whose notes drift across rustc versions; that drift is what failed the trybuild expectation in CI. Also from the review of 1bf313cc0: the mixed `from`/whole decrypt branches are unreachable and gone; explicit-mode `DecryptInto` bounds go through `push_field_bounds` (spanned at the field); the `NonEmpty` inner type is bounded `'static` for every `struct` derive and for any record with a context of its own, free otherwise; `StackMapAccess::next_key` derives the entry AAD once instead of cloning the key; the WASI guest borrows a `NonEmpty<&str>` per plan field instead of cloning the `String` per output; the vitaminc pin comments say the rev is the head of cipherstash/vitaminc#314, not the 0.2.0 release; the `0u64` / `None` AAD collision is documented (vitaminc#315), and the `'static` bound's upstream fix is filed (vitaminc#316). RFC 0002 and the design record reconciled. Claude-Session: https://claude.ai/code/session_019VRX1tXygj5bew3YhyK1B6 * docs(stack-encrypt): the examples use the derives `encrypted_record` derives `EncryptedAge` (`plaintext = u32`) and `EncryptedUser` (`struct = User, context = "users"`) instead of writing the composite impl by hand, encrypts a table of users in one call, queries it by terms, decrypts the matches with `Vec::<User>::decrypt_from`, and then shows a record bound to its id with `encrypt_into_with_context` — and the trade-off that comes with it: terms scoped to a record no longer match a table-wide probe. `search_terms` ends with the same terms as a derived record, byte-identical to the leaves. `mixed_user` stays on the cipher-directed layer and says why: fields in the clear beside sealed ones are not something the derive expresses. Both run against ZeroKMS as written. Claude-Session: https://claude.ai/code/session_019VRX1tXygj5bew3YhyK1B6 * feat(stack-encrypt)!: every data key is requested under its context as the ZeroKMS descriptor A leaf's context now reaches ZeroKMS: each generate and retrieve payload carries `Descriptor::from_aad(context)` as its `descriptor`, which ZeroKMS HMACs into the key tag and demands again on retrieve. The binding the leaf AAD makes locally is therefore enforced at ZeroKMS too — a key generated under `users/email` does not re-derive under `users/name`, the request is refused before any key material moves — and the descriptor is what the ZeroKMS retrieval log shows per key. This is the legacy cipherstash-client arrangement on the stable descriptor channel; lock-context tags and decryption policies are left alone (still beta). `Descriptor` is the one, frozen rendering of a context's AAD bytes as a string: UTF-8 without control characters verbatim (`users/email`), anything else — an integer, a PAE composite such as `nonempty!("users/email").with(id)` — as `b64:` + base64. A textual context that itself starts with `b64:` is base64-rendered too, so the rendering is injective. `()` renders empty. `Request::generate_data_key` / `retrieve_data_key` take the descriptor; `dispatch` forwards each request's own. The target-directed leaves render it from their `NonEmpty<T>`; the cipher-directed `encrypt` / `decrypt` render it from the caller's AAD, and `decipher`, `PendingStackCipherText::seal` and `into_pending` take it explicitly (the pending tree holds only the derived per-leaf AADs, not the root). The WASI guest passes its `NonEmpty` contexts through. Tests: a recording source (`tests/common`) asserts what is sent — per field, per leaf of a column, both directions, base64 for extended contexts, empty for a `()` AAD on the cipher-directed path — and the rendering is pinned in `descriptor.rs`. The `encrypted_record` example, run against live ZeroKMS, now shows the wrong-id open refused by ZeroKMS (403) rather than by the AEAD. docs/zerokms-context-model.md is a side exploration for a next ZeroKMS: binding (structured, labelled, always in the tag), authorization (claims resolved from the token, policy always in the tag), and attestation (annotations in a MAC-chained log) as three separate channels, replacing the single descriptor string. BREAKING CHANGE: ciphertexts sealed before this change were generated under an empty descriptor and can no longer have their keys retrieved; re-encrypt (dev-only data, the crate is unpublished). `StackCipher::decipher`, `PendingStackCipherText::seal` and `PendingStackCipherText::into_pending` take a `Descriptor` (`Descriptor::of(aad)` of the AAD the tree was built under); `Request::generate_data_key` / `retrieve_data_key` take one too. Claude-Session: https://claude.ai/code/session_019VRX1tXygj5bew3YhyK1B6 * docs(stack-encrypt): the ZeroKMS context-model exploration moves to coderdan/0kms It describes a future ZeroKMS, so it lives with that codebase (coderdan/0kms, docs/context-model.md) rather than here. Claude-Session: https://claude.ai/code/session_019VRX1tXygj5bew3YhyK1B6 * feat(stack-encrypt)!: the guest's value exports take any AAD; the asymmetry is recorded BREAKING CHANGE: the WASI guest's value exports (`se_encrypt`, `se_encrypt_element`, `se_decrypt`, `se_decrypt_element`) no longer refuse an empty AAD with `STATUS_ENCODING`. They are the cipher-directed path and take the AAD as `StackCipher::encrypt` does — any bytes, none included; a null pointer with zero length is the empty AAD, as a Go `nil` slice is. The record and term exports are unchanged: their contexts are `NonEmpty` from parse and an empty one is still `STATUS_ENCODING`. Why: the non-empty contract belongs to EQL types — ciphertexts and terms stored for query, where an empty context makes ciphertexts transplantable between fields — and is enforced on their target-directed implementations by type. The cipher-directed path mirrors vitaminc's `Aes256Cipher`, where sealing under no associated data is a legitimate use; the guest's `check_aad` was a second, runtime copy of a rule the types already carry where it applies. Recorded as `packages/stack-encrypt/docs/adr/0001-context-optional-cipher-directed-path.md`, and the crate's language lands in `packages/stack-encrypt/CONTEXT.md` (with a `CONTEXT-MAP.md` entry). The `bare_context` UI test gains the two paths it lacked: a plaintext-derived record and a leaf opened directly. Recovers what cipherstash/cipherstash-suite#2181 still had over this branch; its core (leaves take vitaminc's `NonEmpty<T>`) landed here in 1bf313cc0. Claude-Session: https://claude.ai/code/session_019VRX1tXygj5bew3YhyK1B6 * chore(stack-encrypt): vitaminc 0.3.0 from the registry replaces the git pin vitaminc 0.3.0 (2026-09-07) contains cipherstash/vitaminc#314 (`NonEmpty::with`, `From<integer> for NonEmpty`) and cipherstash/cipherstash-suite#318 (a context exposes its parts as an `AadPiece` tree), so stack-encrypt and the WASI guest move from the cipherstash/cipherstash-suite#314 branch head to the registry release. The one rename 0.3.0 carries, `IsEmpty` -> `MaybeEmpty`, follows through the crate's re-export and the two docs that named it. The rest of the suite stays on its own vitaminc line; 0.x minors are distinct to cargo, so this is a second copy and nothing depends on both. Claude-Session: https://claude.ai/code/session_019VRX1tXygj5bew3YhyK1B6 * feat(stack-encrypt)!: the descriptor renders a context's parts, not its bytes BREAKING CHANGE: `Descriptor::from_aad(&Aad)` is replaced by `Descriptor::from_piece(&AadPiece)`; `Descriptor::of` is unchanged and now renders from the context's parts. The rendering itself changes for every non-textual context: an integer id is `7u64` (was base64 of its bytes), a composite is its parts joined by `|` — `nonempty!("users/email").with(7u64)` is `users/email|7u64` (was `b64:AgAA…`) — and text that could read as another form (a leading digit or `-`, a `|`, `(`, `)`, a `b64:` prefix, a control character) is `b64:`-escaped. Plain textual contexts render as before. ZeroKMS binds the descriptor into every key tag, so keys generated under the old rendering cannot be retrieved — dev data only; nothing was released. Why: vitaminc 0.3.0 exposes a context's parts as an `AadPiece` tree (cipherstash/vitaminc#318) so the descriptor no longer has to be recovered from PAE-framed bytes. A record id in the context now reads as one in the ZeroKMS audit log, which is what the descriptor is for. The rendering stays injective over encodings — two contexts with different AAD bytes never share a descriptor — because the plain-text rule reserves exactly the characters the other forms begin with or contain, and an integer carries its width. Text and bytes with the same bytes render the same, as they encode the same. The rules and the pinned strings are in `descriptor.rs`; `tests/descriptor.rs` pins what a row extended by a caller's id sends. Verified against live ZeroKMS: the `encrypted_record` example seals under `users/age|42u64` and a wrong id is refused server-side. Claude-Session: https://claude.ai/code/session_019VRX1tXygj5bew3YhyK1B6 * fix(stack-encrypt): refuse a descriptor longer than ZeroKMS can bind, before any request ZeroKMS derives key material over a fixed 512-byte block that it copies the descriptor into without a length check (`vitur-server-core/src/client.rs`, `generate_key_material`), so a context that renders past 512 bytes — a long `NonEmpty<String>`, or a shorter one grown by the base64 escape — made the server panic instead of answering. Every data-key batch now checks its descriptors against `Descriptor::MAX_LEN` in `dispatch`, before the generate or retrieve call, and fails whole with `Error::DescriptorTooLong { len }`: no key is minted or retrieved for a batch that cannot be bound. The guest maps it to `STATUS_ENCODING`, as caller input. `Descriptor` gains `MAX_LEN`, `len`, `is_empty` and `fits`; the limit is on rendered bytes, which is what the server sees, so a 256-character context of two-byte characters is at the limit and an escaped part fits less than a plain one. Pinned in `descriptor.rs`; the dispatch test proves no call is made for an over-long descriptor on either path and that one at the limit goes through. Raised by Copilot on cipherstash/cipherstash-suite#2180. The server-side hardening (a bounds check in `generate_key_material`) is a separate change to ZeroKMS. Claude-Session: https://claude.ai/code/session_019VRX1tXygj5bew3YhyK1B6 * fix(stack-encrypt): one descriptor rendering per tree, checked before any request is built `Descriptor` held a `String` and was cloned into every keyed leaf's request, so a tree of N leaves under a context rendering to L bytes allocated N × L before `dispatch` refused the over-long descriptor — memory amplification a host could drive through the guest's value exports. It now holds an `Arc<str>`: one rendering serves the whole tree and a clone is a pointer. And the limit is checked where the request vector is about to be built — `seal_pending`, `decipher_pending`, the leaf `DecryptInto` — so an over-long descriptor is refused as `Error::DescriptorTooLong` before a single request exists; `dispatch` keeps its check as the last gate before ZeroKMS. Pinned: a clone shares the rendering (pointer equality), and a 10 000 element column under an over-long context fails whole with nothing sent on either path. Raised by Copilot on cipherstash/cipherstash-suite#2180. Claude-Session: https://claude.ai/code/session_019VRX1tXygj5bew3YhyK1B6 * feat(zerokms-protocol): MAX_DESCRIPTOR_LEN names the descriptor length ZeroKMS binds ZeroKMS derives key material over a fixed 512-byte block holding the descriptor, so a longer one cannot be bound. The limit lived only in the server's copy, so a client had nothing to check a descriptor against before building a request. `zerokms_protocol::MAX_DESCRIPTOR_LEN` (re-exported by stack-kms) is now the one place both sides read it from, so the client and server limits cannot drift. The server-side bounds check is BUG-309. Claude-Session: https://claude.ai/code/session_019VRX1tXygj5bew3YhyK1B6 * fix(stack-encrypt)!: the descriptor follows the encoding: empty parts marked, integers sign-blind Code-review follow-ups on the descriptor rendering and its surroundings. - An empty text or bytes part inside a list renders as the bare `b64:` prefix, so `Some("")` is `(b64:)` while `None` stays `()`: they encode differently and no longer share a descriptor. The root empty context still renders as the empty string. - Integers render as their encoded bytes read unsigned, with the width: `7i64` is `7u64`, `-3i32` is `4294967293u32`. vitaminc encodes integers untagged, so signedness was over-bound: a row sealed as u64 and opened as i64 passed the AEAD and was refused by ZeroKMS. - The module docs say where the descriptor is finer than the encoding (a pre-encoded `Aad` is one opaque bytes part; `None` and `0u64` encode alike) and that a context is presented in the same shape on both sides. ADR-0001's "None unconstructible" premise is corrected the same way. - `PendingStackCipherText::seal` / `into_pending` and `StackCipher::decipher` take the context (`impl IntoAad`) and render the descriptor themselves, so no caller outside the crate renders one. - `Descriptor::MAX_LEN` reads `zerokms_protocol::MAX_DESCRIPTOR_LEN` (previous commit) (re-exported by stack-kms) so the client and server limits cannot drift. The server-side bounds check is BUG-309. - `dispatch` is the one gate (`try_for_each(Descriptor::check)`); the entry points' earlier checks are documented as the fast path. - Docs and tests say what ZeroKMS does with a wrong context: the retrieve is refused (`Error::Kms`, `STATUS_KMS_FORBIDDEN`) before the AEAD runs; only a key source that ignores descriptors reaches `Error::Aead`. - A Go record plan's flat string context cannot spell a caller-extended context; recorded in the bindings plan and on `parse_plan`. - The derive's container attributes parse with `parse_nested_meta` (syn's meta path accepts the `struct` keyword), replacing the hand parser; duplicate-key errors now span the key. BREAKING CHANGE: `PendingStackCipherText::seal` / `into_pending` and `StackCipher::decipher` take the context (`impl IntoAad`) instead of a `Descriptor`; pass the value the tree was sealed under. Descriptors of contexts with signed integers or empty list parts render differently, so keys generated under the old rendering cannot be retrieved (dev data only). Claude-Session: https://claude.ai/code/session_019VRX1tXygj5bew3YhyK1B6 * fix(stack-encrypt): a column checks its context once before walking its elements Copilot's remaining findings on cipherstash/cipherstash-suite#2180. The `Vec` blanket impls cloned the context into every element and each keyed element rendered it, so an over-long context under a ten-thousand-element column was rendered ten thousand times before `Pending::all` surfaced the first refusal — nothing was sent, but the reject path cost O(elements × context length). `ElementContext`, sealed over the two shapes a column takes (`()` and `NonEmpty<T>`), renders and checks the descriptor once at the column boundary on encrypt, decrypt and field-decrypt; the elements are not walked if it fails. Pinned by a counting context: one rendering on either path, nothing sent. `AadPiece` joins the vitaminc re-exports, since `Descriptor::from_piece` takes one. Claude-Session: https://claude.ai/code/session_019VRX1tXygj5bew3YhyK1B6 * test(stack-encrypt): a map draw with nothing pending is a clean refusal Test-coverage review on cipherstash/cipherstash-suite#2180: `next_value` and `next_passthrough` return `Err(Unspecified)` when nothing is pending — drawn before `next_key`, or drawn twice — but only the `Some` arms were exercised. Pinned, so the guard that keeps an absent or unverified value from being handed back cannot quietly become an `unwrap`. Claude-Session: https://claude.ai/code/session_019VRX1tXygj5bew3YhyK1B6 * fix(stack-encrypt): a column checks its context only when its elements bind keys Copilot on cipherstash/cipherstash-suite#2180: the column preflight refused a long context for every column, including a column of terms — which derive locally and render no descriptor — and for an empty column, which binds nothing. It also cannot see a derived record's field extension, which can exceed the limit where the caller's part alone did not. `EncryptFrom::KEYED` (default `true`) says whether encrypting from a source requests data keys under the caller's context; the four terms say `false`, `Vec` and `Option` pass their element's answer through, and a column checks its context only when its elements are keyed and it has some. The decrypt side skips the empty column and otherwise checks: every `DecryptInto` opens keyed leaves. The record extension is documented as the leaf's refusal, once per row and bounded by the limit plus the literal, since the caller's part has already been shown to fit. Also from review: a doctest on `Descriptor::of`, and the guest's value export docs name the record and term exports instead of a private parser. Claude-Session: https://claude.ai/code/session_019VRX1tXygj5bew3YhyK1B6 * docs(stack-encrypt): a caller's context extends a record's literal; the RFC records the contract Review follow-ups from cipherstash/cipherstash-suite#2180. `DecryptInto`'s rustdoc still said a record whose ciphertext field carries a `context = ".."` literal authenticates under the literal alone, so a wrong caller context is not refused — the opposite of the contract two paragraphs up and of what the derive emits: the literal is extended with the caller's context, and a wrong one is `Error::Kms` from ZeroKMS (`Error::Aead` from a key source that ignores descriptors). The derive attribute docs now name both errors for a renamed field too, rather than only the fake-source one. RFC 0002's follow-up section described the first shipped derive — "row" vocabulary, field literals replacing the record's context, the caller passing `()`, `Vec` and `Option` no longer validating — which cipherstash/cipherstash-suite#2180 reversed. It now records the `struct` vocabulary, that a caller's context extends a field's own, that emptiness moved to the types with `NonEmpty<T>`, and what a column checks instead, with the history kept as history. Claude-Session: https://claude.ai/code/session_019VRX1tXygj5bew3YhyK1B6 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JdSTr8m9r5q3AbkJR11vey --------- Co-authored-by: Claude <noreply@anthropic.com> --- docs/plans/stack-encrypt-go-bindings.md | 17 +- ...nc-shape-for-target-directed-encryption.md | 75 +- docs/target-directed-encryption.md | 68 +- .../golang/stackencrypt/guest/Cargo.lock | 123 +- .../golang/stackencrypt/guest/Cargo.toml | 9 +- .../golang/stackencrypt/guest/src/abi.rs | 32 +- .../golang/stackencrypt/guest/src/ops.rs | 123 +- .../golang/stackencrypt/guest/src/status.rs | 39 +- .../stackencrypt/guest/tests/native_ops.rs | 131 +- .../stack-encrypt-derive/docs/attributes.md | 127 +- packages/stack-encrypt-derive/src/attrs.rs | 153 +-- packages/stack-encrypt-derive/src/decrypt.rs | 443 ++++--- packages/stack-encrypt-derive/src/encrypt.rs | 281 +++-- packages/stack-encrypt-derive/src/lib.rs | 92 +- packages/stack-encrypt-derive/src/shape.rs | 594 +++++---- packages/stack-encrypt/CONTEXT.md | 73 ++ packages/stack-encrypt/Cargo.toml | 36 +- ...1-context-optional-cipher-directed-path.md | 59 + .../examples/encrypted_record.rs | 238 ++-- packages/stack-encrypt/examples/mixed_user.rs | 7 + .../stack-encrypt/examples/search_terms.rs | 73 +- packages/stack-encrypt/src/cipher.rs | 245 +++- packages/stack-encrypt/src/descriptor.rs | 505 ++++++++ packages/stack-encrypt/src/lib.rs | 39 +- packages/stack-encrypt/src/sem/mod.rs | 140 ++- packages/stack-encrypt/src/target/mod.rs | 1109 +++++++---------- packages/stack-encrypt/src/target/pending.rs | 198 ++- packages/stack-encrypt/src/target/request.rs | 75 +- packages/stack-encrypt/tests/common/mod.rs | 97 +- packages/stack-encrypt/tests/derive.rs | 316 +++-- packages/stack-encrypt/tests/descriptor.rs | 277 ++++ packages/stack-encrypt/tests/frozen_bytes.rs | 13 +- packages/stack-encrypt/tests/roundtrip.rs | 10 +- packages/stack-encrypt/tests/sem_terms.rs | 135 +- packages/stack-encrypt/tests/target.rs | 382 +++--- packages/stack-encrypt/tests/term_bytes.rs | 17 +- packages/stack-encrypt/tests/ui.rs | 3 + .../stack-encrypt/tests/ui/bare_context.rs | 56 + .../tests/ui/bare_context.stderr | 127 ++ .../tests/ui/decrypt_ambiguous_shape.rs | 16 - .../tests/ui/decrypt_ambiguous_shape.stderr | 5 - .../tests/ui/decrypt_duplicate_from.rs | 4 +- .../tests/ui/decrypt_mixed_modes.rs | 17 - .../tests/ui/decrypt_mixed_modes.stderr | 5 - .../ui/decrypt_several_recover_one_field.rs | 3 +- .../tests/ui/duplicate_singleton_attrs.rs | 2 +- .../stack-encrypt/tests/ui/empty_context.rs | 11 +- .../tests/ui/empty_context.stderr | 14 +- .../tests/ui/from_leaf_without_context.rs | 17 - .../tests/ui/from_leaf_without_context.stderr | 26 - .../tests/ui/from_without_plaintext.rs | 14 + .../tests/ui/from_without_plaintext.stderr | 16 +- .../tests/ui/leaf_without_context.rs | 8 +- .../tests/ui/leaf_without_context.stderr | 133 +- .../tests/ui/nested_leaf_without_context.rs | 18 + .../ui/nested_leaf_without_context.stderr | 24 + .../tests/ui/nested_outside_row.rs | 17 - .../tests/ui/nested_outside_row.stderr | 5 - .../tests/ui/nested_outside_struct.rs | 14 + .../tests/ui/nested_outside_struct.stderr | 5 + .../tests/ui/pass/which_form_compiles.rs | 85 ++ .../tests/ui/row_with_context.rs | 32 - .../tests/ui/row_with_context.stderr | 54 - .../tests/ui/row_with_plaintext.stderr | 11 - .../tests/ui/row_without_context.rs | 18 - .../tests/ui/row_without_context.stderr | 5 - ...eld_missing.rs => struct_field_missing.rs} | 6 +- ...ing.stderr => struct_field_missing.stderr} | 2 +- ..._plaintext.rs => struct_with_plaintext.rs} | 2 +- .../tests/ui/struct_with_plaintext.stderr | 11 + .../tests/ui/struct_without_context.rs | 18 + .../tests/ui/struct_without_context.stderr | 5 + packages/stack-kms/src/lib.rs | 1 + 73 files changed, 4568 insertions(+), 2593 deletions(-) create mode 100644 packages/stack-encrypt/CONTEXT.md create mode 100644 packages/stack-encrypt/docs/adr/0001-context-optional-cipher-directed-path.md create mode 100644 packages/stack-encrypt/src/descriptor.rs create mode 100644 packages/stack-encrypt/tests/descriptor.rs create mode 100644 packages/stack-encrypt/tests/ui/bare_context.rs create mode 100644 packages/stack-encrypt/tests/ui/bare_context.stderr delete mode 100644 packages/stack-encrypt/tests/ui/decrypt_ambiguous_shape.rs delete mode 100644 packages/stack-encrypt/tests/ui/decrypt_ambiguous_shape.stderr delete mode 100644 packages/stack-encrypt/tests/ui/decrypt_mixed_modes.rs delete mode 100644 packages/stack-encrypt/tests/ui/decrypt_mixed_modes.stderr delete mode 100644 packages/stack-encrypt/tests/ui/from_leaf_without_context.rs delete mode 100644 packages/stack-encrypt/tests/ui/from_leaf_without_context.stderr create mode 100644 packages/stack-encrypt/tests/ui/nested_leaf_without_context.rs create mode 100644 packages/stack-encrypt/tests/ui/nested_leaf_without_context.stderr delete mode 100644 packages/stack-encrypt/tests/ui/nested_outside_row.rs delete mode 100644 packages/stack-encrypt/tests/ui/nested_outside_row.stderr create mode 100644 packages/stack-encrypt/tests/ui/nested_outside_struct.rs create mode 100644 packages/stack-encrypt/tests/ui/nested_outside_struct.stderr create mode 100644 packages/stack-encrypt/tests/ui/pass/which_form_compiles.rs delete mode 100644 packages/stack-encrypt/tests/ui/row_with_context.rs delete mode 100644 packages/stack-encrypt/tests/ui/row_with_context.stderr delete mode 100644 packages/stack-encrypt/tests/ui/row_with_plaintext.stderr delete mode 100644 packages/stack-encrypt/tests/ui/row_without_context.rs delete mode 100644 packages/stack-encrypt/tests/ui/row_without_context.stderr rename packages/stack-encrypt/tests/ui/{row_field_missing.rs => struct_field_missing.rs} (53%) rename packages/stack-encrypt/tests/ui/{row_field_missing.stderr => struct_field_missing.stderr} (79%) rename packages/stack-encrypt/tests/ui/{row_with_plaintext.rs => struct_with_plaintext.rs} (80%) create mode 100644 packages/stack-encrypt/tests/ui/struct_with_plaintext.stderr create mode 100644 packages/stack-encrypt/tests/ui/struct_without_context.rs create mode 100644 packages/stack-encrypt/tests/ui/struct_without_context.stderr diff --git a/docs/plans/stack-encrypt-go-bindings.md b/docs/plans/stack-encrypt-go-bindings.md index b2009a3eb..98e4c47d5 100644 --- a/docs/plans/stack-encrypt-go-bindings.md +++ b/docs/plans/stack-encrypt-go-bindings.md @@ -321,7 +321,7 @@ access) or one of the two required `cipherstash_transport` functions, and both of those must be present. A build alone proves nothing here: the property is about what the *linked* module can reach. -Two things worth stating plainly, because they are easy to read the wrong +Three things worth stating plainly, because they are easy to read the wrong way: - **Batching is one *batch*, not always one *call*.** All rows and fields @@ -339,6 +339,21 @@ way: Rust derive and a Go plan do **not** interchange ciphertexts for the same field until the Rust side uses aead-value's tagged types. By design; a separate follow-up, not a defect in either side. +- **A plan context is the whole context, flat.** Each plan field carries one + string, and the guest seals the field under exactly that — the same AAD + bytes and the same ZeroKMS descriptor as a Rust derive gives the field + when the record is sealed with `encrypt_into` (no caller context). The + Rust derive can also *extend* every field's context with the caller's + (`encrypt_into_with_context(row, 7u64)` seals `users/email` under + `("users/email", 7u64)`, descriptor `users/email|7u64`), and a plan + cannot spell that: the context slot is a string, and a string that looks + like the rendered descriptor is escaped, not parsed. Rows sealed from Rust + under a caller context are unreadable through a plan, and rows sealed + through a plan are unreadable from Rust under any caller context. A + structured context slot (string | integer | list, mirroring vitaminc's + `AadPiece`, with the Go struct tag growing a `tenant=` or similar) is the + fix, and is a Phase 4 item, not a Phase 3 one: the cross-language fixtures + in Phase 5 must cover both the flat and the extended shape. The plan as written before the work: diff --git a/docs/rfcs/0002-async-shape-for-target-directed-encryption.md b/docs/rfcs/0002-async-shape-for-target-directed-encryption.md index 942f0d733..85fab43fc 100644 --- a/docs/rfcs/0002-async-shape-for-target-directed-encryption.md +++ b/docs/rfcs/0002-async-shape-for-target-directed-encryption.md @@ -269,8 +269,8 @@ up. Composites combine pendings **without awaiting them**, so requests merge: ```rust -impl<'c, K, Ctx: EncryptContext<'c> + SuppliedContext<'c>> EncryptFrom<u32, StackCipher<K>, Ctx> for EncryptedInt { - fn encrypt_from<'a>(source: &'a u32, cipher: &'a StackCipher<K>, ctx: Ctx) +impl<'c, K, T: IntoAad<'c> + IntoPrfContext<'c> + Clone> EncryptFrom<u32, StackCipher<K>, NonEmpty<T>> for EncryptedInt { + fn encrypt_from<'a>(source: &'a u32, cipher: &'a StackCipher<K>, ctx: NonEmpty<T>) -> PendingEncrypted<'a, Self, K> { StackCipherText::encrypt_from(source, cipher, ctx.clone()) @@ -349,7 +349,7 @@ and `ReadyPrf::into_result()` extracts without an executor. So today a term's ```rust let term = tokens - .prf_visit_with_context(cipher.prf().clone(), context, BloomVisitor { k, mask }) + .prf_visit_with_context(cipher.prf(), context, BloomVisitor { k, mask }) .into_result() // ReadyPrf: sync, infallible backend .map_err(TermError::from_prf); PendingEncrypted::ready(cipher, term.map_err(Error::from)) @@ -386,15 +386,15 @@ The current module docs teach external term authors to return all until a deferred PRF exists: ```rust -impl<'c, S, K, Ctx> EncryptFrom<S, StackCipher<K>, Ctx> for MyTerm +impl<'c, S, K, T> EncryptFrom<S, StackCipher<K>, NonEmpty<T>> for MyTerm where S: PrfValue + Clone, - Ctx: EncryptContext<'c> + SuppliedContext<'c>, + T: IntoPrfContext<'c>, { fn encrypt_from<'a>( source: &'a S, cipher: &'a StackCipher<K>, - context: Ctx, + context: NonEmpty<T>, ) -> PendingEncrypted<'a, Self, K> where Self: 'a, @@ -403,7 +403,7 @@ where let context = PrfContext::pae(&[b"my-crate/my-term/v1".as_slice(), context.as_bytes()]); let term = source .clone() - .prf_visit_with_context(cipher.prf().clone(), context, MyVisitor) + .prf_visit_with_context(cipher.prf(), context, MyVisitor) .into_result() .map(MyTerm) .map_err(|e| Error::Other(Box::new(e))); @@ -513,6 +513,25 @@ The implementation kept the design and changed three names/details: `decrypt_from_with_context` mirror it). Which applies is the type's decision, made at compile time; runtime rejection is left to what the type cannot see — an empty string — pending vitaminc#291. +- **Contexts are vitaminc's `NonEmpty<T>`, and a caller's context extends + a field's own** (2026-09-04, after vitaminc 0.2.0 shipped `NonEmpty`). + `SuppliedContext`, `EncryptContext` and `DecryptContext` are gone: a leaf + is implemented for `NonEmpty<T>` alone (`T: IntoAad + IntoPrfContext`), so + `()` is a compile error against it and `""` cannot be built, with no + runtime emptiness check anywhere in this crate — the pre-request + `require_context` step in §4.5 went with it. A derived record is + implemented twice — for `()`, each field under the context it carries + itself (a `context = ".."` literal, or the one a `struct = ..` derive + infers), and for `NonEmpty<T>`, each field under that context extended + with the caller's (`("users/age", id)`) — so `user.encrypt_into(&cipher)` + and `user.encrypt_into_with_context(&cipher, id)` both compile, the second + binding every field to its record, and no record accepts a context it + then discards. The `_with_context` sugar takes anything that converts + into a `NonEmpty<T>`: `nonempty!("..")`, `NonEmpty::new(value)?`, a bare + integer. `row = ..` became `struct = ..` (2026-09-05: "row" pushed + database vocabulary into a general-purpose library), and `from` / + `nested` exist only there — `plaintext = T` derives every field from the + whole value whatever `T` is. - **`dispatch` issues one call per request *kind*** (at most one `generate_keys` + one `retrieve_keys`, sequentially — a mixed batch is rare today). When ZeroKMS grows the combined keys-plus-PRF operation, `dispatch` @@ -553,20 +572,31 @@ claims: The derive emits exactly the §4.4 shape — one impl over `StackCipher<K>`, field pendings zipped and mapped, never awaited — for a struct of leaves, and -one level up for a *row*: a struct whose fields are each derived from a -field of the source (`from = ..`) under a literal context of their own -(`context = ".."`). Field contexts **replace** the record's rather than -composing with it, so a query site builds a term under the same literal the -row stored it under; the row's own context is then unused and the caller -passes `()`. - -That last point reversed one of the final-review guards above: `Vec` and -`Option` no longer validate the context themselves. They pass it through -untouched, and the leaves reject an empty one synchronously as before. What -was lost is the "fail on the fixture with no rows" property — an empty -column under an empty context now succeeds, and the misconfiguration is -caught by the first real value instead — which is a small price for -containers of self-describing records being expressible at all. +one level up for a *struct record*: a struct whose fields are each derived +from a field of the source (`from = ..`, inferred from the field's own name) +under an *own context* — `"<struct context>/<field>"`, inferred, or a +`context = ".."` literal. An own context is never discarded: a caller's +context **extends** it (`("users/age", id)` under +`encrypt_into_with_context(&cipher, id)`), so a record sealed with +`encrypt_into` opens with `decrypt_from` and one sealed under an extension +opens only under the same extension, and a query site derives its term under +the same own context, extended the same way. This is how a field is bound to +its record as well as its name without the type knowing the id. (First +shipped the other way round — field literals *replacing* the record's +context, the caller passing `()` — which let a literal-only record accept a +context and seal nothing under it; #2180 made extension the rule. The +contract is stated in `packages/stack-encrypt/CONTEXT.md` under "Own +context" and in ADR-0001.) + +The final-review "empty context" guard above moved from a runtime check to +the types in the same change: a leaf takes vitaminc's `NonEmpty<T>` and `()` +is a compile error against it, so `Vec` and `Option` have no emptiness to +check and pass the context through. What a column does check, once before +walking its elements, is that a context whose elements bind ZeroKMS keys +renders within the descriptor limit (`ElementContext`); an empty column and +a column of terms are not held to it. The "fail on the fixture with no +rows" property went with the runtime check — a misconfigured context is now +a type error rather than a value the first row catches. The derive is bound to `StackCipher<K>` rather than generic over `EncryptTarget`, because combining outputs needs `zip`/`map` and only @@ -580,7 +610,8 @@ off and decrypt to whatever its ciphertext field opens to. The derives are named after the trait they emit, as serde's are, and the attribute after the crate: `#[stash(plaintext = ..)]` for a record, -`#[stash(row = .., context = "..")]` for a row — which infers every field's +`#[stash(struct = .., context = "..")]` for a struct encrypted field by +field (shipped as `row = ..`) — which infers every field's `from` (its own name) and the field half of its context (`"<context>/<plaintext field>"`; the prefix is the required container `context`, given explicitly because it is stored-data identity and must not diff --git a/docs/target-directed-encryption.md b/docs/target-directed-encryption.md index 3787f47b5..d3d0f55ca 100644 --- a/docs/target-directed-encryption.md +++ b/docs/target-directed-encryption.md @@ -1,6 +1,6 @@ # Target-directed encryption -**Status:** design record, reconciled with the shipped API on 2026-08-28. The snippets below are the API as it ships in `stack-encrypt` (cipherstash-suite #2146, #2147); the async shape is specified by [RFC 0002](rfcs/0002-async-shape-for-target-directed-encryption.md). +**Status:** design record, reconciled with the shipped API on 2026-08-28 and again on 2026-09-04 (contexts are vitaminc's `NonEmpty<T>`; a caller's context *extends* a field's own; `row = ..` became `struct = ..`, and `from` exists only there). The snippets below are the API as it ships in `stack-encrypt` (cipherstash-suite #2146, #2147); the async shape is specified by [RFC 0002](rfcs/0002-async-shape-for-target-directed-encryption.md). **Date:** 2026-08-21 **Scope:** vitaminc (primitives), stack-encrypt (the new trait + batching), eql-bindings (one class of targets) @@ -10,7 +10,7 @@ Everything this document decides shipped as designed: one trait on the output ty - **The derives are named after the traits they emit**, not `Encrypted`: `#[derive(EncryptFrom)]` / `#[derive(DecryptInto)]` in `stack-encrypt-derive`, under `#[stash(..)]` attributes. Hand-written impls remain the way to write a leaf (`packages/stack-encrypt/examples/encrypted_record.rs` shows one composite written out). - **ORE/OPE use `cllw-ore`, not `ore-rs`,** and the per-field key is derived through the PRF *inside* the term — there is no `ProvidesOre` accessor and no key is ever handed back. -- **An empty context is rejected**, not permitted. `Aad::empty()` was proposed for non-EQL callers; the implementation refuses it (`Error::EmptyContext`). +- **An empty context is rejected**, not permitted. `Aad::empty()` was proposed for non-EQL callers; the leaves take vitaminc's `NonEmpty<T>` and nothing else, so an empty one cannot be built (`NonEmpty::new` refuses it once; `nonempty!("")` does not compile) and `()` — what `encrypt_into` passes — is a compile error against a leaf. ## Problem @@ -27,7 +27,7 @@ vitaminc today gives us the ciphertext (`Encrypt` / `Cipher`) and a PRF (`PrfVal We want the target type to answer all three questions, so that this compiles only when the pieces line up: ```rust -let x: IntegerOrdOre = 10.encrypt_into_with_context(&cipher, "users/age").await?; +let x: IntegerOrdOre = 10.encrypt_into_with_context(&cipher, nonempty!("users/age")).await?; ``` **This must not be EQL-specific.** EQL payloads are one class of output. Nothing in the mechanism should know what a table or a column is. @@ -89,11 +89,13 @@ pub trait EncryptInto { T: EncryptFrom<Self, C, ()> + 'a, Self: Sized; - fn encrypt_into_with_context<'a, 'c, T, C, Ctx>(&'a self, cipher: &'a C, context: Ctx) -> C::Output<'a, T> + /// Passes a `NonEmpty<N>`: anything that converts into one — `nonempty!("..")`, + /// `NonEmpty::new(value)?`, a bare integer. + fn encrypt_into_with_context<'a, T, C, N, Ctx>(&'a self, cipher: &'a C, context: Ctx) -> C::Output<'a, T> where C: EncryptTarget, - T: EncryptFrom<Self, C, Ctx> + 'a, - Ctx: SuppliedContext<'c>, + T: EncryptFrom<Self, C, NonEmpty<N>> + 'a, + Ctx: Into<NonEmpty<N>>, Self: Sized; } impl<S> EncryptInto for S { /* delegates to T::encrypt_from */ } @@ -104,11 +106,11 @@ impl<S> EncryptInto for S { /* delegates to T::encrypt_from */ } **Leaves** are the single-primitive types. Each names exactly one primitive, and that is the *only* place in the design where a primitive is named. As shipped in `stack-encrypt`: ```rust -// Every leaf, with `Ctx: EncryptContext<'c> + SuppliedContext<'c>` — a leaf refuses `()` by type. -impl<'c, S, K, Ctx> EncryptFrom<S, StackCipher<K>, Ctx> for StackCipherText where S: Encrypt + Clone { ... } -impl<'c, S, K, Ctx> EncryptFrom<S, StackCipher<K>, Ctx> for EqualityTerm where S: PrfValue + Clone { ... } -impl<'c, S, K, O, Ctx> EncryptFrom<S, StackCipher<K>, Ctx> for MatchTerm<O> where S: AsRef<str>, O: MatchConfig { ... } -impl<'c, S, K, Ctx> EncryptFrom<S, StackCipher<K>, Ctx> for OreTerm<S> where S: CllwOreEncrypt + Clone + Send + 'static, S::Output: Send + 'static { ... } +// Every leaf, for `NonEmpty<T>` alone with `T: IntoAad<'c> + IntoPrfContext<'c>` — a leaf refuses `()` by type. +impl<'c, S, K, T> EncryptFrom<S, StackCipher<K>, NonEmpty<T>> for StackCipherText where S: Encrypt + Clone { ... } +impl<'c, S, K, T> EncryptFrom<S, StackCipher<K>, NonEmpty<T>> for EqualityTerm where S: PrfValue + Clone { ... } +impl<'c, S, K, O, T> EncryptFrom<S, StackCipher<K>, NonEmpty<T>> for MatchTerm<O> where S: AsRef<str>, O: MatchConfig { ... } +impl<'c, S, K, T> EncryptFrom<S, StackCipher<K>, NonEmpty<T>> for OreTerm<S> where S: CllwOreEncrypt + Clone + Send + 'static, S::Output: Send + 'static { ... } ``` EQL's wire newtypes (`Ciphertext`, `Hmac256`, `OreBlock256`) get the same treatment in `eql-bindings`, which owns them; encoding decisions (base85, block width) belong there, not in vitaminc or stack-encrypt. @@ -136,7 +138,7 @@ where } ``` -One context fans out to every field. `zip` concatenates the fields' requests, so the whole record is still one batched call when awaited. The where clauses are exactly the ones the derive writes — one `FieldTy: EncryptFrom<S, C, Ctx>` per field — so a record inherits its leaves' demand for a supplied context without naming it, and a hand-written composite that copies this shape rides along when the leaf bound tightens (vitaminc#291) instead of restating today's policy. +One context fans out to every field. `zip` concatenates the fields' requests, so the whole record is still one batched call when awaited. The where clauses are exactly the ones the derive writes — one `FieldTy: EncryptFrom<S, C, Ctx>` per field — so a record inherits its leaves' demand for a `NonEmpty<_>` context without naming it. (The derive emits this impl twice, for `Ctx = ()` and for `Ctx = NonEmpty<T>`; see "Context, not cipher scoping".) **The derive** writes exactly that impl from the struct: @@ -178,7 +180,7 @@ One level up, unchanged — same trait, now written by the derive: ```rust #[derive(EncryptFrom)] -#[stash(row = User, context = "users")] +#[stash(struct = User, context = "users")] struct EncryptedUser { age: IntegerOrdOre, // from user.age, under "users/age" email: TextEq, // from user.email, under "users/email" @@ -187,33 +189,30 @@ struct EncryptedUser { let row: EncryptedUser = user.encrypt_into(&cipher).await?; // one batch, no context: the fields carry theirs ``` -`row = User` infers each field's `from` (its own name) and the field half of +`struct = User` infers each field's `from` (its own name) and the field half of its context (`"<context>/<plaintext field>"`); the prefix is the required container `context`, named explicitly — never inferred from the Rust type's name, which two types can share and a refactor can change. `#[stash(from = ..)]` and `#[stash(context = "..")]` on a field are the overrides, and -`#[stash(nested)]` marks a field whose type is itself a row carrying its own +`#[stash(nested)]` marks a field whose type is itself a `struct` derive carrying its own contexts (it is handed `()`). The context is the AAD of every stored ciphertext in the column, so renaming a plaintext *field* is still a data migration: pin the old literal with `context = ".."` first. -Leaf, payload and row are the same trait, and a column of rows is `Vec<T>`'s structural impl over the same trait — `ages.encrypt_into_with_context(&cipher, ctx)` for a `Vec<u32>` is one batched call, and `users.encrypt_into(&cipher)` for a `Vec<User>` likewise. Recursion does the rest. Earlier sketches of this design had a separate input-side derive for rows — that was a second mechanism the naming was hiding. +Leaf, record and field-by-field struct are the same trait, and a column of any of them is `Vec<T>`'s structural impl over the same trait — `ages.encrypt_into_with_context(&cipher, ctx)` for a `Vec<u32>` is one batched call, and `users.encrypt_into(&cipher)` for a `Vec<User>` likewise. Recursion does the rest. Earlier sketches of this design had a separate input-side derive for rows — that was a second mechanism the naming was hiding. (The field-by-field form shipped as `row = ..` and was renamed `struct = ..` on 2026-09-04: "row" pushed database vocabulary into a general-purpose library. `plaintext = T` derives every field from the whole value whatever `T` is — the derive sees a name, not a definition — and `from` / `nested` exist only with `struct`.) ### Relationship to `Encrypt` `Encrypt` is not bypassed or superseded. It **is** the source-ciphertext field. The `Ciphertext` leaf impl is a bridge: ```rust -impl<'c, S, K, Ctx> EncryptFrom<S, StackCipher<K>, Ctx> for StackCipherText -where S: Encrypt + Clone, Ctx: EncryptContext<'c> + SuppliedContext<'c>, +impl<'c, S, K, T> EncryptFrom<S, StackCipher<K>, NonEmpty<T>> for StackCipherText +where S: Encrypt + Clone, T: IntoAad<'c>, { - fn encrypt_from<'a>(source: &'a S, cipher: &'a StackCipher<K>, context: Ctx) -> Pending<'a, Self, K> + fn encrypt_from<'a>(source: &'a S, cipher: &'a StackCipher<K>, context: NonEmpty<T>) -> Pending<'a, Self, K> where Self: 'a, { - let aad = match supplied_aad(context) { // validates and encodes; Error::EmptyContext on a degenerate one - Ok(aad) => aad, - Err(error) => return Pending::failed(cipher, error), - }; + let aad = context.into_aad().into_owned(); // the type is the proof; nothing to check match source.clone().encrypt_with_aad(cipher, aad) { // vitaminc Encrypt, untouched Ok(tree) => seal_pending(cipher, tree), // one data-key request per leaf Err(_) => Pending::ready(cipher, Err(Error::Aead)), @@ -247,32 +246,31 @@ The principle stands: capability accessors, not one god trait. The PRF is alread An EQL payload carries an identifier (`i`: table, column). Identifiers are an EQL concern and must not become cipher state. -vitaminc already has the generic notion, twice — `Aad<'a>` (aead) and `PrfContext<'a>` (prf), both PAE-framed domain separators, neither aware of tables. EQL's `Identifier` is just a value that converts into both: +vitaminc already has the generic notion, twice — `Aad<'a>` (aead) and `PrfContext<'a>` (prf), both PAE-framed domain separators, neither aware of tables — and, since 0.2.0, the proof that a value carries caller bytes: `NonEmpty<T>`, checked once where the value is built (`nonempty!("users/email")` at compile time, `NonEmpty::new(value)?` at runtime, a bare integer for free). EQL's `Identifier` is just a value that converts into `Aad` and `PrfContext`, wrapped in `NonEmpty` on its way in. stack-encrypt adds no context trait of its own: anything vitaminc encodes as a context is a context here. (An earlier iteration had `EncryptContext` / `DecryptContext` aliases and a `SuppliedContext` marker — a hand-maintained roster of "every vitaminc context type but `()`" — which meant a type implementing vitaminc's traits was still not a context until stack-encrypt listed it. Gone.) -```rust -pub trait EncryptContext<'a>: IntoAad<'a> + IntoPrfContext<'a> + Clone {} -impl<'a, T> EncryptContext<'a> for T where T: IntoAad<'a> + IntoPrfContext<'a> + Clone {} -``` - -`Clone` because one context fans out to every field of a record. +`Clone` on the inner type, because one context fans out to every field of a record. Context is threaded **per value**, as an argument. It is not baked into the cipher. -Whether the *caller* owes one is decided by the target type, at compile time. `Ctx` is a parameter of `EncryptFrom` so that each impl can bound it: a leaf demands `SuppliedContext` — every context type vitaminc provides except `()` — because it has nothing else to authenticate under; a record passes the caller's context to its fields and inherits their demand through its where clause; a row whose fields all name their own context never uses the caller's and is implemented for `()` alone. `encrypt_into(&cipher)` passes `()` and therefore resolves only against the last kind; everything else takes `encrypt_into_with_context` — and against a row, only `encrypt_into` does, since a supplied context would go nowhere. This is vitaminc's `encrypt` / `encrypt_with_aad` split, with the choice made by the type rather than at every call site. Whether a supplied context is also *non-empty* remains a runtime check at the leaf (`Error::EmptyContext`) until vitaminc carries non-emptiness in the type ([vitaminc#291](https://github.com/cipherstash/vitaminc/issues/291)). +Whether the *caller* owes one is decided by the target type, at compile time. `Ctx` is a parameter of `EncryptFrom` so that each impl can bound it: a leaf is implemented for `NonEmpty<T>` alone, because it has nothing else to authenticate under and `()` is the empty context it must never derive under; a derived record is implemented twice — for `()`, deriving each field under the context it carries itself (a `context = ".."` literal, or the one a `struct = ..` derive infers), and for `NonEmpty<T>`, deriving each field under that context *extended* with the caller's (`("users/age", id)`), or under the caller's as it is for a field with none. No record accepts a context and then discards it. `encrypt_into(&cipher)` passes `()` and therefore compiles only for outputs whose every leaf has a context of its own; everything else takes `encrypt_into_with_context`, and such an output takes that too when the caller has something to add, a record id say, so a field is bound to its record as well as its name. This is vitaminc's `encrypt` / `encrypt_with_aad` split, with the choice made by the type rather than at every call site. -A scoped cipher (`cipher.for_column("users", "age")`) was considered and rejected: it makes encrypting one row — several columns, several identifiers — into several scoped ciphers, which fights batching for no gain. With context as an argument, a row is one shared `&cipher`, many contexts, one flush. +A scoped cipher (`cipher.for_column("users", "age")`) was considered and rejected: it makes encrypting one record — several fields, several identifiers — into several scoped ciphers, which fights batching for no gain. With context as an argument, a record is one shared `&cipher`, many contexts, one flush. ### Recommendation: bind the identifier into the AAD EQL's `i` field is currently unauthenticated metadata. A ciphertext from `users.email` can be transplanted into `users.name` and still decrypts. Passing the identifier as context — which reaches both `Aad` and `PrfContext` — closes that class of attack. -This stays a caller decision at the call site, not cipher state: non-EQL callers pass whatever context describes the field. What they may **not** pass is an empty one. With an empty context, equal plaintexts in different fields produce identical terms, every field shares one ORE/OPE key, and ciphertexts transplant between fields — so every built-in impl rejects it during the synchronous build (`Error::EmptyContext`), before any I/O. "Empty" is structural over the encoding: `()`, `""`, `None`, `Some("")` and `("", "")` are all empty. (cipherstash/vitaminc#291 tracks carrying non-emptiness in the type instead.) +This stays a caller decision at the call site, not cipher state: non-EQL callers pass whatever context describes the field. What they may **not** pass is an empty one. With an empty context, equal plaintexts in different fields produce identical terms, every field shares one ORE/OPE key, and ciphertexts transplant between fields — so a leaf takes a `NonEmpty<T>` and nothing else, and an empty one cannot be built: `""`, `None`, `Some("")` and `("", "")` are all refused by `NonEmpty::new` (vitaminc's structural `MaybeEmpty`, on the raw value), and `()` is a compile error. There is no runtime path through a leaf for an empty context to fail on. + +### The context is the ZeroKMS descriptor + +The same context goes to ZeroKMS with every data-key request the leaf makes, as the request's `descriptor` — the field the legacy `cipherstash-client` used for exactly this. ZeroKMS HMACs the descriptor into the key tag and re-derives a key only under the descriptor it was generated with, and the descriptor is what its retrieval log records per key. So the binding the AAD makes locally is also enforced server-side, before any key material moves, and the field name is readable in the audit trail. `Descriptor::from_piece` is the one, frozen rendering of a context's parts (vitaminc 0.3.0's `AadPiece` tree) as that string: textual parts verbatim, integers by their width and sign-blind (`7i64` renders `7u64`, as it encodes), a composite's parts joined by `|` — `nonempty!("users/email").with(7u64)` is `users/email|7u64` — and text that could read as another form (a leading digit, a `|`, a `b64:` prefix, or nothing at all) escaped as `b64:` plus base64, so the rendering is injective over encodings. It follows the context's parts rather than its bytes, so it is finer than the encoding for a pre-encoded `Aad` (one opaque bytes part) and for shapes that encode alike: a value is opened under the context in the same shape it was sealed under. With ZeroKMS enforcing the descriptor, a wrong context is refused at key retrieval (`Error::Kms`) before the AEAD runs; only a key source that ignores descriptors, such as the test fake, reaches the AEAD's `Error::Aead`. The lock-context tags and decryption policies ZeroKMS also offers are a newer channel, not yet stable enough to build on; `docs/context-model.md` in the coderdan/0kms repository explores what the next ZeroKMS should offer instead of one string. ## Batching and async Awaiting at the leaf is one round-trip per value *unless* `Pending` is a deferred handle on a shared batch that flushes on first await. That is the whole reason the cipher implements `Cipher` and `Prf` together: one object, one keyset, one batch covering both the source ciphertext and every ZeroKMS-derived term in the record. -The row-level derive above is the entry point that makes this pay: one `.await` for a whole row rather than one per field. +The `struct = ..` derive above is the entry point that makes this pay: one `.await` for a whole struct rather than one per field. ## Analysis vs derivation @@ -306,7 +304,7 @@ An earlier iteration had two traits, `EncryptInto<T, C>` on the source and `Deri **4. Error unification.** **Dissolved.** There is no per-target error: `EncryptTarget::Error` belongs to the cipher, and `StackCipher`'s `Error` already covers AEAD, PRF, ORE and ZeroKMS failures. -**5. Decrypt.** **Implemented** as `DecryptInto<P, C: DecryptTarget, Ctx>` on the encrypted type (first shipped as `DecryptFrom` on the plaintext; flipped so the implementable trait has the record as `Self`), with `DecryptFrom::decrypt_from` / `decrypt_from_with_context` as the blanket sugar on the plaintext. Only the source-ciphertext field participates (terms are one-way). The context bound is `DecryptContext` — `IntoAad` only, since decryption derives nothing — plus `SuppliedContext` at the leaves, as on the encrypt side. +**5. Decrypt.** **Implemented** as `DecryptInto<P, C: DecryptTarget, Ctx>` on the encrypted type (first shipped as `DecryptFrom` on the plaintext; flipped so the implementable trait has the record as `Self`), with `DecryptFrom::decrypt_from` / `decrypt_from_with_context` as the blanket sugar on the plaintext. Only the source-ciphertext field participates (terms are one-way). The leaves take `NonEmpty<T>` with `T: IntoAad` only, since decryption derives nothing; a derived record opened under one extends its fields' contexts with it, exactly as it did when encrypting. **6. Where `EncryptFrom` lives.** **Resolved:** stack-encrypt. Argued here as stack-encrypt's, since target-directed assembly is the thing stack-encrypt adds and vitaminc's `Encrypt` already covers cipher-directed encryption. If it turns out to be useful to vitaminc consumers who never touch stack-encrypt, it could move down — but not before there is a second consumer. diff --git a/languages/golang/stackencrypt/guest/Cargo.lock b/languages/golang/stackencrypt/guest/Cargo.lock index e8cc66af8..134dba597 100644 --- a/languages/golang/stackencrypt/guest/Cargo.lock +++ b/languages/golang/stackencrypt/guest/Cargo.lock @@ -1942,7 +1942,7 @@ dependencies = [ "url", "uuid", "vitaminc", - "vitaminc-protected 0.2.0-pre.1 (registry+https://github.com/rust-lang/crates.io-index)", + "vitaminc-protected 0.2.0-pre.1", "web-time", "zeroize", "zerokms-protocol", @@ -1952,17 +1952,18 @@ dependencies = [ name = "stack-encrypt" version = "0.1.0" dependencies = [ + "base64ct", "cllw-ore", "serde", "stack-encrypt-derive", "stack-kms", "thiserror 1.0.69", "uuid", - "vitaminc-aead 0.2.0-pre.1 (git+https://github.com/cipherstash/vitaminc?rev=04f4faa3eeeb69ae30dd0247865e06c640585fea)", - "vitaminc-encrypt 0.2.0-pre.1 (git+https://github.com/cipherstash/vitaminc?rev=04f4faa3eeeb69ae30dd0247865e06c640585fea)", + "vitaminc-aead 0.3.0", + "vitaminc-encrypt 0.3.0", "vitaminc-hmac", "vitaminc-prf", - "vitaminc-protected 0.2.0-pre.1 (git+https://github.com/cipherstash/vitaminc?rev=04f4faa3eeeb69ae30dd0247865e06c640585fea)", + "vitaminc-protected 0.3.0", "zeroize", ] @@ -1990,7 +1991,7 @@ dependencies = [ "stack-kms", "uuid", "vitaminc-aead-value", - "vitaminc-protected 0.2.0-pre.1 (git+https://github.com/cipherstash/vitaminc?rev=04f4faa3eeeb69ae30dd0247865e06c640585fea)", + "vitaminc-protected 0.3.0", "zeroize", "zerokms-protocol", ] @@ -2018,7 +2019,7 @@ dependencies = [ "url", "uuid", "vitaminc", - "vitaminc-protected 0.2.0-pre.1 (registry+https://github.com/rust-lang/crates.io-index)", + "vitaminc-protected 0.2.0-pre.1", "zeroize", "zerokms-protocol", ] @@ -2408,10 +2409,10 @@ version = "0.2.0-pre.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d69481bc78bc3227d6c70d8aae6437c79badbf54fd9ec90c1b4ae2553068a989" dependencies = [ - "vitaminc-aead 0.2.0-pre.1 (registry+https://github.com/rust-lang/crates.io-index)", - "vitaminc-encrypt 0.2.0-pre.1 (registry+https://github.com/rust-lang/crates.io-index)", - "vitaminc-protected 0.2.0-pre.1 (registry+https://github.com/rust-lang/crates.io-index)", - "vitaminc-random 0.2.0-pre.1 (registry+https://github.com/rust-lang/crates.io-index)", + "vitaminc-aead 0.2.0-pre.1", + "vitaminc-encrypt 0.2.0-pre.1", + "vitaminc-protected 0.2.0-pre.1", + "vitaminc-random 0.2.0-pre.1", "vitaminc-traits", ] @@ -2423,30 +2424,44 @@ checksum = "be80f3a3d83e69a786b97a831d660449a0437ccac3b3e369bf590afcb45569b0" dependencies = [ "bytes", "serde", - "vitaminc-protected 0.2.0-pre.1 (registry+https://github.com/rust-lang/crates.io-index)", - "vitaminc-random 0.2.0-pre.1 (registry+https://github.com/rust-lang/crates.io-index)", + "vitaminc-protected 0.2.0-pre.1", + "vitaminc-random 0.2.0-pre.1", "zeroize", ] [[package]] name = "vitaminc-aead" -version = "0.2.0-pre.1" -source = "git+https://github.com/cipherstash/vitaminc?rev=04f4faa3eeeb69ae30dd0247865e06c640585fea#04f4faa3eeeb69ae30dd0247865e06c640585fea" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f723c7419c1bfa2084dd0df351e915b62e0c52b04fd7a0d3bea5f1514bc7ba5d" dependencies = [ "bytes", "serde", - "vitaminc-protected 0.2.0-pre.1 (git+https://github.com/cipherstash/vitaminc?rev=04f4faa3eeeb69ae30dd0247865e06c640585fea)", - "vitaminc-random 0.2.0-pre.1 (git+https://github.com/cipherstash/vitaminc?rev=04f4faa3eeeb69ae30dd0247865e06c640585fea)", + "vitaminc-aead-derive", + "vitaminc-protected 0.3.0", + "vitaminc-random 0.3.0", "zeroize", ] +[[package]] +name = "vitaminc-aead-derive" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca4cd66b4531badaf794474a75cdde955067413392de4934b120e7757147e4a9" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + [[package]] name = "vitaminc-aead-value" -version = "0.2.0-pre.1" -source = "git+https://github.com/cipherstash/vitaminc?rev=04f4faa3eeeb69ae30dd0247865e06c640585fea#04f4faa3eeeb69ae30dd0247865e06c640585fea" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d63ef21540dd973b1cb8775c6bbf753834030cbd7ff3db6dc20bb8d0f5adfa43" dependencies = [ - "vitaminc-aead 0.2.0-pre.1 (git+https://github.com/cipherstash/vitaminc?rev=04f4faa3eeeb69ae30dd0247865e06c640585fea)", - "vitaminc-protected 0.2.0-pre.1 (git+https://github.com/cipherstash/vitaminc?rev=04f4faa3eeeb69ae30dd0247865e06c640585fea)", + "vitaminc-aead 0.3.0", + "vitaminc-protected 0.3.0", "zeroize", ] @@ -2458,45 +2473,48 @@ checksum = "7477ef8ac925a75aacf5dbddfd4b17fd32f35ee9fb4a7c45ac3db80fd9ad4006" dependencies = [ "aes-gcm", "aws-lc-rs", - "vitaminc-aead 0.2.0-pre.1 (registry+https://github.com/rust-lang/crates.io-index)", - "vitaminc-protected 0.2.0-pre.1 (registry+https://github.com/rust-lang/crates.io-index)", - "vitaminc-random 0.2.0-pre.1 (registry+https://github.com/rust-lang/crates.io-index)", + "vitaminc-aead 0.2.0-pre.1", + "vitaminc-protected 0.2.0-pre.1", + "vitaminc-random 0.2.0-pre.1", "zeroize", ] [[package]] name = "vitaminc-encrypt" -version = "0.2.0-pre.1" -source = "git+https://github.com/cipherstash/vitaminc?rev=04f4faa3eeeb69ae30dd0247865e06c640585fea#04f4faa3eeeb69ae30dd0247865e06c640585fea" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e804d414808812197b72bca14cbfa2e6ca0a432fc44dd832e933e392ced2da5" dependencies = [ "aes-gcm", "aws-lc-rs", - "vitaminc-aead 0.2.0-pre.1 (git+https://github.com/cipherstash/vitaminc?rev=04f4faa3eeeb69ae30dd0247865e06c640585fea)", - "vitaminc-protected 0.2.0-pre.1 (git+https://github.com/cipherstash/vitaminc?rev=04f4faa3eeeb69ae30dd0247865e06c640585fea)", - "vitaminc-random 0.2.0-pre.1 (git+https://github.com/cipherstash/vitaminc?rev=04f4faa3eeeb69ae30dd0247865e06c640585fea)", + "vitaminc-aead 0.3.0", + "vitaminc-protected 0.3.0", + "vitaminc-random 0.3.0", "zeroize", ] [[package]] name = "vitaminc-hmac" -version = "0.2.0-pre.1" -source = "git+https://github.com/cipherstash/vitaminc?rev=04f4faa3eeeb69ae30dd0247865e06c640585fea#04f4faa3eeeb69ae30dd0247865e06c640585fea" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55bfe9c3944f048938b2a96fef154c7193c0ffa3c89a5422a327ce17c387e9e2" dependencies = [ "hmac", "sha2 0.11.0", "vitaminc-prf", - "vitaminc-protected 0.2.0-pre.1 (git+https://github.com/cipherstash/vitaminc?rev=04f4faa3eeeb69ae30dd0247865e06c640585fea)", + "vitaminc-protected 0.3.0", "zeroize", ] [[package]] name = "vitaminc-prf" -version = "0.2.0-pre.1" -source = "git+https://github.com/cipherstash/vitaminc?rev=04f4faa3eeeb69ae30dd0247865e06c640585fea#04f4faa3eeeb69ae30dd0247865e06c640585fea" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "403ad68fd482e7ee967ddddf4732f3675a1d2258661ef3e4510b48f7644374dd" dependencies = [ "mutants", "thiserror 2.0.20", - "vitaminc-protected 0.2.0-pre.1 (git+https://github.com/cipherstash/vitaminc?rev=04f4faa3eeeb69ae30dd0247865e06c640585fea)", + "vitaminc-protected 0.3.0", ] [[package]] @@ -2510,21 +2528,23 @@ dependencies = [ "serde", "serde_bytes", "subtle", - "vitaminc-protected-derive 0.2.0-pre.1 (registry+https://github.com/rust-lang/crates.io-index)", + "vitaminc-protected-derive 0.2.0-pre.1", "zeroize", ] [[package]] name = "vitaminc-protected" -version = "0.2.0-pre.1" -source = "git+https://github.com/cipherstash/vitaminc?rev=04f4faa3eeeb69ae30dd0247865e06c640585fea#04f4faa3eeeb69ae30dd0247865e06c640585fea" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49e03c9c22e4c2e92c8a3b2320f0cc974193a6a2c80e4e8077da158aaaec4b85" dependencies = [ "bitvec", "digest 0.11.3", "serde", "serde_bytes", "subtle", - "vitaminc-protected-derive 0.2.0-pre.1 (git+https://github.com/cipherstash/vitaminc?rev=04f4faa3eeeb69ae30dd0247865e06c640585fea)", + "thiserror 2.0.20", + "vitaminc-protected-derive 0.3.0", "zeroize", ] @@ -2541,8 +2561,9 @@ dependencies = [ [[package]] name = "vitaminc-protected-derive" -version = "0.2.0-pre.1" -source = "git+https://github.com/cipherstash/vitaminc?rev=04f4faa3eeeb69ae30dd0247865e06c640585fea#04f4faa3eeeb69ae30dd0247865e06c640585fea" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7554d432fda99706db29f6df3aa2fb4895949c67185957bfdcd60f603346654f" dependencies = [ "proc-macro2", "quote", @@ -2558,21 +2579,22 @@ dependencies = [ "getrandom 0.4.3", "rand 0.10.2", "thiserror 2.0.20", - "vitaminc-protected 0.2.0-pre.1 (registry+https://github.com/rust-lang/crates.io-index)", - "vitaminc-random-derives 0.2.0-pre.1 (registry+https://github.com/rust-lang/crates.io-index)", + "vitaminc-protected 0.2.0-pre.1", + "vitaminc-random-derives 0.2.0-pre.1", "zeroize", ] [[package]] name = "vitaminc-random" -version = "0.2.0-pre.1" -source = "git+https://github.com/cipherstash/vitaminc?rev=04f4faa3eeeb69ae30dd0247865e06c640585fea#04f4faa3eeeb69ae30dd0247865e06c640585fea" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30a8d04e8bcb2f210b33d1d6bd3bdc7e43a2e875c50677f8c5046ea578f084f2" dependencies = [ "getrandom 0.4.3", "rand 0.10.2", "thiserror 2.0.20", - "vitaminc-protected 0.2.0-pre.1 (git+https://github.com/cipherstash/vitaminc?rev=04f4faa3eeeb69ae30dd0247865e06c640585fea)", - "vitaminc-random-derives 0.2.0-pre.1 (git+https://github.com/cipherstash/vitaminc?rev=04f4faa3eeeb69ae30dd0247865e06c640585fea)", + "vitaminc-protected 0.3.0", + "vitaminc-random-derives 0.3.0", "zeroize", ] @@ -2589,8 +2611,9 @@ dependencies = [ [[package]] name = "vitaminc-random-derives" -version = "0.2.0-pre.1" -source = "git+https://github.com/cipherstash/vitaminc?rev=04f4faa3eeeb69ae30dd0247865e06c640585fea#04f4faa3eeeb69ae30dd0247865e06c640585fea" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7d6abc0f1cd8b227fe689fa269080fafd09bcee337253d8bda732818d49067" dependencies = [ "proc-macro2", "quote", @@ -2608,8 +2631,8 @@ dependencies = [ "rmp-serde", "serde", "thiserror 2.0.20", - "vitaminc-protected 0.2.0-pre.1 (registry+https://github.com/rust-lang/crates.io-index)", - "vitaminc-random 0.2.0-pre.1 (registry+https://github.com/rust-lang/crates.io-index)", + "vitaminc-protected 0.2.0-pre.1", + "vitaminc-random 0.2.0-pre.1", "zeroize", ] diff --git a/languages/golang/stackencrypt/guest/Cargo.toml b/languages/golang/stackencrypt/guest/Cargo.toml index 379fa61a2..a0cac64b5 100644 --- a/languages/golang/stackencrypt/guest/Cargo.toml +++ b/languages/golang/stackencrypt/guest/Cargo.toml @@ -31,9 +31,12 @@ stack-kms = { path = "../../../../packages/stack-kms", default-features = false zerokms-protocol = { path = "../../../../packages/zerokms-protocol" } # The FFI codec + `FfiValue` tree, shared with vitaminc's own guest — one -# codec, not a fork. Same rev the suite pins for the other vitaminc crates. -vitaminc-aead-value = { git = "https://github.com/cipherstash/vitaminc", rev = "04f4faa3eeeb69ae30dd0247865e06c640585fea" } -vitaminc-protected = { git = "https://github.com/cipherstash/vitaminc", rev = "04f4faa3eeeb69ae30dd0247865e06c640585fea" } +# codec, not a fork. Same vitaminc version stack-encrypt builds against +# (see the comment in packages/stack-encrypt/Cargo.toml). A different +# version here fails at the `FfiValue: Decrypt` bound, since the aead crate +# would be duplicated. +vitaminc-aead-value = "0.3.0" +vitaminc-protected = "0.3.0" futures = { version = "0.3", default-features = false, features = ["executor"] } serde = "1" diff --git a/languages/golang/stackencrypt/guest/src/abi.rs b/languages/golang/stackencrypt/guest/src/abi.rs index 76e918351..4b3df9c7c 100644 --- a/languages/golang/stackencrypt/guest/src/abi.rs +++ b/languages/golang/stackencrypt/guest/src/abi.rs @@ -39,18 +39,19 @@ //! //! As the vitaminc guest: every export validates its pointer/length pairs //! against linear memory before any unsafe construction (null with nonzero -//! length rejected; a null AAD must not silently become an empty AAD), -//! invalid input yields `STATUS_ENCODING` rather than a trap, and the -//! `catch_unwind` at each export is belt-and-braces for a hypothetical -//! unwind build — wasm32-wasip1 aborts on panic. Statuses are the only -//! detail leaked. +//! length rejected), invalid input yields `STATUS_ENCODING` rather than a +//! trap, and the `catch_unwind` at each export is belt-and-braces for a +//! hypothetical unwind build — wasm32-wasip1 aborts on panic. Statuses are +//! the only detail leaked. //! -//! A null *or empty* AAD is rejected on every path — value, record and term -//! alike — with `STATUS_ENCODING`: sealing under no context makes -//! ciphertexts transplantable between fields, so it is never a default the -//! guest supplies for a caller who omitted one. "Empty" is -//! [`stack_encrypt::is_degenerate_aad`], so shapes that are not literally -//! zero-length (the PAE of an empty list, for instance) are rejected too. +//! The value exports ([`se_encrypt`] and friends) are the cipher-directed +//! path and take the AAD as `StackCipher::encrypt` does: any bytes, none +//! included — a null pointer with zero length is the empty AAD, as a Go +//! `nil` slice is. The record and term exports bind fields, so their +//! contexts must be non-empty (`STATUS_ENCODING` otherwise): each is a +//! [`stack_encrypt::NonEmpty`] from the moment it is parsed, and the sealing +//! and opening sides bind that one value. The asymmetry is the design; see +//! `packages/stack-encrypt/docs/adr/0001-context-optional-cipher-directed-path.md`. //! //! One difference from the vitaminc guest, deliberate: where `vc_encrypt` //! decodes its input *before* looking up the handle — so garbage bytes read @@ -139,7 +140,7 @@ fn linear_memory_bytes() -> u64 { /// Borrow a host-supplied `(ptr, len)` pair, validating before any slice /// exists: null-with-nonzero-length is rejected (treating it as empty would -/// silently drop an AAD context binding), the length must be under +/// silently drop whatever bytes the host meant to pass), the length must be under /// `isize::MAX`, and the whole range must lie inside the current linear /// memory. A pair that fails validation yields `STATUS_ENCODING`; a pair /// that passes can still name the wrong bytes — the host owns its pointers @@ -275,8 +276,8 @@ pub extern "C" fn se_cipher_free(handle: u32) { /// to a codec-encoded ciphertext tree whose leaves are the frozen /// `SealedValue` byte encoding. /// -/// `aad` must be non-empty (`STATUS_ENCODING` otherwise) — see this module's -/// hostile-input notes. +/// `aad` may be empty (a null pointer with zero length is empty) — see this +/// module's hostile-input notes. /// /// # Safety /// @@ -318,8 +319,7 @@ pub unsafe extern "C" fn se_encrypt_element( /// must copy it out and immediately release it with [`se_dealloc`] (which /// wipes it). /// -/// `aad` must be non-empty, and must be the one the ciphertext was sealed -/// under. +/// `aad` must be the one the ciphertext was sealed under, empty included. /// /// # Safety /// diff --git a/languages/golang/stackencrypt/guest/src/ops.rs b/languages/golang/stackencrypt/guest/src/ops.rs index 6adb123a0..fd544f1c8 100644 --- a/languages/golang/stackencrypt/guest/src/ops.rs +++ b/languages/golang/stackencrypt/guest/src/ops.rs @@ -37,7 +37,7 @@ use stack_encrypt::sem::{CllwOpeEncrypt, CllwOreEncrypt, DefaultMatch}; use stack_encrypt::target::Pending; use stack_encrypt::{ - is_degenerate_aad, Aad, BoxedPassthrough, CipherText, Decrypt, Element, Encrypt, SealedValue, + BoxedPassthrough, CipherText, Decrypt, Element, Encrypt, IntoPrfContext, NonEmpty, SealedValue, StackCipher, StackCipherText, }; use stack_kms::DataKeySource; @@ -67,31 +67,20 @@ type BytesTree = CipherText<Vec<u8>, BoxedPassthrough>; // Whole-value encrypt / decrypt (the vitaminc guest's vc_encrypt shape) // ============================================================================= -/// Reject an AAD that carries no caller-supplied information. -/// -/// The value entry points take raw AAD bytes rather than a typed context, so -/// nothing upstream has already applied the target layer's rule. An absent or -/// empty AAD must not silently become "sealed under nothing": with no -/// context, ciphertexts are transplantable between fields, which is exactly -/// what a context is for. [`is_degenerate_aad`] is the same predicate -/// `EncryptFrom`/`DecryptInto` apply, so the value paths, the record paths -/// and native Rust code all agree on what counts as empty — including the -/// shapes that are not literally zero bytes (`pae([])`, `0u64`). -fn check_aad(aad: &[u8]) -> Result<(), u32> { - if is_degenerate_aad(aad) { - return Err(STATUS_ENCODING); - } - Ok(()) -} - /// Encrypt a codec-encoded [`FfiValue`] tree under `aad`, sealing every leaf /// against a fresh ZeroKMS data key (one batched request; see the module /// docs for how a batch is chunked). With `as_element`, /// seal it as a *sequence element* — interchangeable with rows written by /// encrypting a whole sequence under the same AAD. /// -/// An empty (or otherwise degenerate) `aad` is [`STATUS_ENCODING`], as on the -/// record and term paths — see [`check_aad`]. +/// This is the cipher-directed path, and it takes the AAD as `StackCipher` +/// does: any bytes, including none. An empty `aad` seals under no context — +/// the plain AEAD use `Aes256Cipher` allows, opened symmetrically by +/// [`decrypt_value`] — and is the Go caller's choice to make. The record and +/// term paths ([`encrypt_record`], [`decrypt_record`], [`term`]) are the +/// ones that bind fields: each takes a [`NonEmpty`] context, proven once at +/// the boundary when the plan or the term's context is parsed, and refused +/// as [`STATUS_ENCODING`] when empty. pub async fn encrypt_value<K>( cipher: &StackCipher<K>, value: &[u8], @@ -101,16 +90,17 @@ pub async fn encrypt_value<K>( where K: DataKeySource + Sync, { - check_aad(aad)?; let value = decode_value(value)?; - let aad = Aad::from_slice(aad); let tree = if as_element { Element(value).encrypt_with_aad(cipher, aad) } else { value.encrypt_with_aad(cipher, aad) } .map_err(|_| STATUS_INTERNAL)?; - let ct = tree.seal(cipher).await.map_err(|e| status_for_error(&e))?; + let ct = tree + .seal(cipher, aad) + .await + .map_err(|e| status_for_error(&e))?; encode_tree(ct) } @@ -118,8 +108,8 @@ where /// [`FfiValue`] tree (one batched `retrieve_keys` request). The output buffer /// contains plaintext — the ABI layer's ownership rules govern its wiping. /// -/// Symmetric with [`encrypt_value`]: an empty AAD is [`STATUS_ENCODING`], -/// checked before any key is retrieved. +/// Symmetric with [`encrypt_value`]: the AAD is whatever the value was sealed +/// under, empty included. pub async fn decrypt_value<K>( cipher: &StackCipher<K>, ciphertext: &[u8], @@ -129,13 +119,11 @@ pub async fn decrypt_value<K>( where K: DataKeySource + Sync, { - check_aad(aad)?; let tree = decode_tree(ciphertext)?; let decipher = cipher - .decipher(tree) + .decipher(tree, aad) .await .map_err(|e| status_for_error(&e))?; - let aad = Aad::from_slice(aad); let value: FfiValue = if as_element { Element::<FfiValue>::decrypt_with_aad(decipher, aad).map(Element::into_inner) } else { @@ -163,6 +151,9 @@ where { let value = decode_value(value)?; let context = std::str::from_utf8(context).map_err(|_| STATUS_ENCODING)?; + // The same proof every stack-encrypt leaf demands: an empty context is + // `STATUS_ENCODING` here, before any derivation. + let context = NonEmpty::new(context).map_err(|_| STATUS_ENCODING)?; let output = match kind { TERM_EQUALITY => Output::Equality, TERM_MATCH => Output::Match, @@ -213,14 +204,15 @@ fn scalar_of(value: &FfiValue) -> Result<Scalar, u32> { /// must equal the term the Rust side derives for `34u32`. Unsupported /// combinations (floats or booleans under equality, anything non-text under /// match) are [`STATUS_ENCODING`] — the scheme does not define them. -async fn term_bytes<K>( +async fn term_bytes<'c, K, D>( cipher: &StackCipher<K>, scalar: Scalar, - context: &str, + context: NonEmpty<D>, output: Output, ) -> Result<Vec<u8>, u32> where K: DataKeySource + Sync, + D: IntoPrfContext<'c>, { let term_err = |e| status_for_term_error(&e); match output { @@ -285,11 +277,16 @@ where /// The `AsRef<[u8]>` on the output is what turns the typed CLLW ciphertext /// into the frozen raw-bytes encoding. -async fn ore<K, T>(cipher: &StackCipher<K>, value: T, context: &str) -> Result<Vec<u8>, u32> +async fn ore<'c, K, T, D>( + cipher: &StackCipher<K>, + value: T, + context: NonEmpty<D>, +) -> Result<Vec<u8>, u32> where K: DataKeySource + Sync, T: CllwOreEncrypt + Send + 'static, T::Output: AsRef<[u8]> + Send + 'static, + D: IntoPrfContext<'c>, { cipher .ore_term(value, context) @@ -299,11 +296,16 @@ where } /// See [`ore`]. -async fn ope<K, T>(cipher: &StackCipher<K>, value: T, context: &str) -> Result<Vec<u8>, u32> +async fn ope<'c, K, T, D>( + cipher: &StackCipher<K>, + value: T, + context: NonEmpty<D>, +) -> Result<Vec<u8>, u32> where K: DataKeySource + Sync, T: CllwOpeEncrypt + Send + 'static, T::Output: AsRef<[u8]> + Send + 'static, + D: IntoPrfContext<'c>, { cipher .ope_term(value, context) @@ -358,7 +360,11 @@ impl Output { /// One field of a record plan. struct FieldPlan { name: String, - context: String, + /// Proven non-empty when the plan is parsed, so every path that seals or + /// opens under it — the cipher-directed `encrypt_with_aad` in + /// [`build_row`] as much as the target-directed `decrypt_into` in + /// [`decrypt_record`] — is under a context stack-encrypt's leaves accept. + context: NonEmpty<String>, outputs: Vec<Output>, } @@ -369,19 +375,28 @@ struct FieldPlan { /// { <field>: { "context": <string>, "outputs": [ "c" | "eq" | "match" | "ore" | "ope", ... ] }, ... } /// ``` /// -/// Rejected as [`STATUS_ENCODING`]: an empty plan, a missing or *degenerate* -/// context (contexts domain-separate fields — see `Error::EmptyContext` in -/// stack-encrypt), an empty/unknown/duplicated output list, unknown keys. -/// Field names are unique by construction (the codec rejects duplicate -/// object keys). +/// Rejected as [`STATUS_ENCODING`]: an empty plan, a missing or *empty* +/// context (contexts domain-separate fields; stack-encrypt's leaves take a +/// `NonEmpty<_>` and nothing else), an empty/unknown/duplicated output list, +/// unknown keys. Field names are unique by construction (the codec rejects +/// duplicate object keys). +/// +/// The context is proven here, once, and carried as a [`NonEmpty`]: the +/// cipher-directed path [`build_row`] seals through accepts any AAD, so +/// nothing downstream would otherwise stop an empty context from being +/// sealed under — and [`decrypt_record`] opens through `decrypt_into`, +/// which would then never open it. /// -/// "Degenerate" is [`is_degenerate_aad`], not `is_empty`, and the check has -/// to happen *here*: [`build_row`] seals through `encrypt_with_aad(..) -/// .into_pending(..)`, which is the cipher-directed path and does not run -/// the target layer's context check, whereas [`decrypt_record`] opens -/// through `decrypt_into`, which does. A context that is not byte-empty but -/// still carries nothing — the PAE of an empty list, i.e. eight zero bytes — -/// would otherwise encrypt happily and then never decrypt. +/// A plan context is one flat string, and it is the *whole* context of the +/// field: the guest has no caller context to extend it with. That matches a +/// Rust `#[derive(EncryptFrom)]` record sealed with `encrypt_into` (no +/// caller context), where the derive's `"<context>/<field>"` string is the +/// field's whole context too — same AAD bytes, same descriptor. A Rust +/// record sealed with `encrypt_into_with_context(.., 7u64)` extends every +/// field's context to `("users/email", 7u64)`, which no plan string can +/// spell (a string that *looks* like the rendered descriptor is escaped, +/// not parsed); those rows are not readable from a plan, and the reverse +/// holds. See the Go bindings plan. fn parse_plan(value: FfiValue) -> Result<Vec<FieldPlan>, u32> { let FfiValue::Object(entries) = value else { return Err(STATUS_ENCODING); @@ -425,10 +440,8 @@ fn parse_plan(value: FfiValue) -> Result<Vec<FieldPlan>, u32> { _ => return Err(STATUS_ENCODING), } } - // `&str`'s AAD encoding is its own raw bytes, so the predicate - // applies directly to the context string. let context = context.ok_or(STATUS_ENCODING)?; - check_aad(context.as_bytes())?; + let context = NonEmpty::new(context).map_err(|_| STATUS_ENCODING)?; let outputs = outputs.filter(|o| !o.is_empty()).ok_or(STATUS_ENCODING)?; Ok(FieldPlan { name, @@ -604,6 +617,10 @@ where .position(|(name, _)| name == &field.name) .ok_or(STATUS_ENCODING)?; let (name, value) = row.swap_remove(at); + // Borrowed from the plan once per field: the proof was made at + // parse time, so re-taking it over the same bytes cannot fail, and + // `NonEmpty<&str>` is `Copy` for the outputs below. + let context = NonEmpty::new(field.context.get().as_str()).map_err(|_| STATUS_INTERNAL)?; // Terms first — they lift a copy of the scalar; the value itself is // consumed by the ciphertext path below. @@ -622,16 +639,16 @@ where continue; } let scalar = scalar.clone().ok_or(STATUS_INTERNAL)?; - let term = term_bytes(cipher, scalar, &field.context, *output).await?; + let term = term_bytes(cipher, scalar, context, *output).await?; outputs.push((output.key(), Some(term))); } if field.outputs.contains(&Output::Ciphertext) { reject_passthrough_value(&value)?; let tree = value - .encrypt_with_aad(cipher, field.context.as_str()) + .encrypt_with_aad(cipher, context) .map_err(|_| STATUS_INTERNAL)?; - pendings.push(tree.into_pending(cipher)); + pendings.push(tree.into_pending(cipher, context)); } skeleton.push((name, outputs)); @@ -684,6 +701,8 @@ where if !field.outputs.contains(&Output::Ciphertext) { continue; } + let context = + NonEmpty::new(field.context.get().as_str()).map_err(|_| STATUS_INTERNAL)?; let at = row .iter() .position(|(name, _)| name == &field.name) @@ -697,7 +716,7 @@ where .find_map(|(key, node)| (key == "c").then_some(node)) .ok_or(STATUS_ENCODING)?; reject_passthrough_tree(&ct)?; - pendings.push(ct.decrypt_into(cipher, field.context.as_str())); + pendings.push(ct.decrypt_into(cipher, context)); row_names.push(name); } names.push(row_names); diff --git a/languages/golang/stackencrypt/guest/src/status.rs b/languages/golang/stackencrypt/guest/src/status.rs index c807ace84..2a9162855 100644 --- a/languages/golang/stackencrypt/guest/src/status.rs +++ b/languages/golang/stackencrypt/guest/src/status.rs @@ -17,7 +17,12 @@ use stack_auth::AuthError; use stack_kms::{GenerateKeyError, LoadKeysetError, RetrieveKeyError}; use zerokms_protocol::ViturRequestErrorKind; -/// AEAD open failure: wrong key, wrong AAD, or tampered ciphertext. +/// AEAD open failure: a tampered ciphertext, a wrong element derivation, or +/// a wrong AAD that reached the AEAD. Against ZeroKMS a wrong AAD does not +/// get that far — every data key is bound to its context's descriptor, so +/// the retrieve is refused first, as [`STATUS_KMS_FORBIDDEN`]. Only a key +/// source that ignores descriptors (the native tests' fake) reports a wrong +/// AAD here. pub const STATUS_AUTH: u32 = 1; /// Invalid input at the boundary: malformed transport bytes, a malformed /// cipher config, an empty encryption context, or a pointer/length pair that @@ -38,8 +43,10 @@ pub const STATUS_INTERNAL: u32 = 4; /// no number of refreshes can fix it. pub const STATUS_KMS_UNAUTHORIZED: u32 = 5; /// ZeroKMS rejected the request as forbidden: the token is valid but lacks -/// permission (or the keyset is disabled, or the organisation is over its -/// usage allowance). +/// permission, the keyset is disabled, the organisation is over its usage +/// allowance — or, on decrypt, the AAD/context is not the one the value +/// was sealed under, so the data key cannot be re-derived. That last one is +/// the production form of a wrong-context open; see [`STATUS_AUTH`]. pub const STATUS_KMS_FORBIDDEN: u32 = 6; /// ZeroKMS could not find the resource: an unknown keyset (or client), or a /// data key that does not exist for the presented `iv`/`tag`. @@ -70,20 +77,22 @@ pub const STATUS_TERM: u32 = 11; pub fn status_for_error(error: &stack_encrypt::Error) -> u32 { match error { stack_encrypt::Error::Aead => STATUS_AUTH, - stack_encrypt::Error::EmptyContext => STATUS_ENCODING, stack_encrypt::Error::Term(_) => STATUS_TERM, stack_encrypt::Error::Kms(kms) => status_for_kms(kms), + // A context that renders past ZeroKMS's descriptor limit is the + // caller's input, refused before any request is sent. + stack_encrypt::Error::DescriptorTooLong { .. } => STATUS_ENCODING, _ => STATUS_INTERNAL, } } /// Map a term-derivation error directly (the term entry points return -/// [`stack_encrypt::sem::TermError`], not the sealing error). -pub fn status_for_term_error(error: &stack_encrypt::sem::TermError) -> u32 { - match error { - stack_encrypt::sem::TermError::EmptyContext => STATUS_ENCODING, - _ => STATUS_TERM, - } +/// [`stack_encrypt::sem::TermError`], not the sealing error). An empty +/// context never reaches a term — it is [`STATUS_ENCODING`] at the boundary, +/// where the context is proven — so every term error is a derivation +/// failure. +pub fn status_for_term_error(_: &stack_encrypt::sem::TermError) -> u32 { + STATUS_TERM } // These matches are deliberately exhaustive — no `_` arms. None of the @@ -187,10 +196,10 @@ mod tests { } #[test] - fn aead_and_context_errors_map_to_the_vitaminc_codes() { + fn aead_and_composition_errors_map_to_the_vitaminc_codes() { assert_eq!(status_for_error(&stack_encrypt::Error::Aead), STATUS_AUTH); assert_eq!( - status_for_error(&stack_encrypt::Error::EmptyContext), + status_for_error(&stack_encrypt::Error::DescriptorTooLong { len: 513 }), STATUS_ENCODING ); assert_eq!( @@ -275,11 +284,7 @@ mod tests { } #[test] - fn term_errors_split_empty_context_from_derivation() { - assert_eq!( - status_for_term_error(&stack_encrypt::sem::TermError::EmptyContext), - STATUS_ENCODING - ); + fn term_errors_are_derivation_failures() { assert_eq!( status_for_term_error(&stack_encrypt::sem::TermError::EmptyTermText), STATUS_TERM diff --git a/languages/golang/stackencrypt/guest/tests/native_ops.rs b/languages/golang/stackencrypt/guest/tests/native_ops.rs index dcf73e1b6..717b8ebde 100644 --- a/languages/golang/stackencrypt/guest/tests/native_ops.rs +++ b/languages/golang/stackencrypt/guest/tests/native_ops.rs @@ -17,7 +17,7 @@ use std::sync::atomic::{AtomicUsize, Ordering}; use futures::executor::block_on; use stack_encrypt::sem::DefaultMatch; -use stack_encrypt::{Aad, CipherText, Decrypt, SealedValue, StackCipher}; +use stack_encrypt::{nonempty, Aad, CipherText, Decrypt, SealedValue, StackCipher}; use stack_encrypt_guest::ops::{self, TERM_EQUALITY, TERM_MATCH, TERM_OPE, TERM_ORE}; use stack_encrypt_guest::status::{STATUS_AUTH, STATUS_ENCODING}; use stack_kms::{ @@ -240,7 +240,7 @@ fn guest_leaves_are_the_frozen_storage_encoding() { // payload`, the vitaminc sealed-leaf format), so the native open goes // through `FfiValue`'s own `Decrypt` — not a bare `String`. let decipher = - block_on(cipher.decipher(CipherText::Single(leaf))).expect("retrieve the data key"); + block_on(cipher.decipher(CipherText::Single(leaf), "ctx")).expect("retrieve the data key"); let value = FfiValue::decrypt_with_aad(decipher, Aad::from_slice(b"ctx")) .expect("native decrypt of a guest leaf"); assert_eq!(text(&value), "durable"); @@ -252,7 +252,9 @@ fn wrong_aad_and_malformed_inputs_map_to_statuses() { let ct = block_on(ops::encrypt_value(&cipher, &encode(s("x")), b"ctx", false)).expect("encrypt"); - // Wrong AAD: authentication, not encoding. + // Wrong AAD: authentication, not encoding. (The fake key source ignores + // descriptors; against ZeroKMS the retrieve is refused first, as + // `STATUS_KMS_FORBIDDEN` — see `status.rs`.) assert_eq!( block_on(ops::decrypt_value(&cipher, &ct, b"other", false)), Err(STATUS_AUTH) @@ -283,44 +285,42 @@ fn wrong_aad_and_malformed_inputs_map_to_statuses() { ); } -/// The AAD is what makes a ciphertext belong to a field. Sealing under -/// nothing would make ciphertexts transplantable between fields, so the value -/// paths refuse it exactly as the record and term paths do — and refuse it -/// *before* minting a key, so a caller that omitted the AAD cannot spend a -/// ZeroKMS call discovering it. +/// The value paths are the cipher-directed path, and take the AAD as +/// `StackCipher::encrypt` does — any bytes, none included. An empty AAD +/// seals under no context and opens under the same, and a null pointer with +/// zero length is the same empty AAD (the ABI's `input` maps it so). Binding +/// a value to a field is the record and term paths' job, where the context is +/// a `NonEmpty`. #[test] -fn an_empty_or_degenerate_aad_is_refused_on_the_value_paths() { +fn an_empty_aad_round_trips_on_the_value_paths() { let cipher = cipher(); let value = encode(s("x")); - // A real ciphertext to try to open with a missing AAD. - let ct = block_on(ops::encrypt_value(&cipher, &value, b"ctx", false)).expect("encrypt"); - let before = cipher.kms().generate_calls.load(Ordering::SeqCst); + for as_element in [false, true] { + let ct = block_on(ops::encrypt_value(&cipher, &value, b"", as_element)) + .expect("encrypt under an empty aad"); + let out = block_on(ops::decrypt_value(&cipher, &ct, b"", as_element)) + .expect("decrypt under an empty aad"); + assert_eq!(out, value, "element: {as_element}"); - // `pae([])` — eight zero bytes — is not byte-empty but carries nothing, - // and is what `None` and `0u64` encode to. Both forms must be refused. - for (label, aad) in [ - ("empty", b"".as_slice()), - ("pae of an empty list", &[0u8; 8][..]), - ] { - for as_element in [false, true] { - assert_eq!( - block_on(ops::encrypt_value(&cipher, &value, aad, as_element)), - Err(STATUS_ENCODING), - "encrypt with a {label} aad (element: {as_element})" - ); - assert_eq!( - block_on(ops::decrypt_value(&cipher, &ct, aad, as_element)), - Err(STATUS_ENCODING), - "decrypt with a {label} aad (element: {as_element})" - ); - } + // Empty is a context like any other: not interchangeable with one + // that carries bytes. + assert_eq!( + block_on(ops::decrypt_value(&cipher, &ct, b"ctx", as_element)), + Err(STATUS_AUTH), + "element: {as_element}" + ); } + // Odd-looking but non-empty bytes are a context too, and bind. + let zeros = &[0u8; 8][..]; + let ct = block_on(ops::encrypt_value(&cipher, &value, zeros, false)).expect("encrypt"); + let opened = + decode(&block_on(ops::decrypt_value(&cipher, &ct, zeros, false)).expect("decrypt")); + assert_eq!(text(&opened), "x"); assert_eq!( - cipher.kms().generate_calls.load(Ordering::SeqCst), - before, - "no data key may be minted for a rejected call" + block_on(ops::decrypt_value(&cipher, &ct, b"ctx", false)), + Err(STATUS_AUTH) ); } @@ -340,7 +340,7 @@ fn guest_terms_match_the_native_sem_derivations() { TERM_EQUALITY, )) .expect("eq term"); - let native = block_on(cipher.equality_term(42u32, "users/age")).expect("native eq"); + let native = block_on(cipher.equality_term(42u32, nonempty!("users/age"))).expect("native eq"); assert_eq!(eq, native.as_bytes()); let ore = block_on(ops::term( @@ -350,7 +350,7 @@ fn guest_terms_match_the_native_sem_derivations() { TERM_ORE, )) .expect("ore term"); - let native = block_on(cipher.ore_term(42u32, "users/age")).expect("native ore"); + let native = block_on(cipher.ore_term(42u32, nonempty!("users/age"))).expect("native ore"); assert_eq!(ore, native.as_ref()); let ope = block_on(ops::term( @@ -360,7 +360,7 @@ fn guest_terms_match_the_native_sem_derivations() { TERM_OPE, )) .expect("ope term"); - let native = block_on(cipher.ope_term(42u32, "users/age")).expect("native ope"); + let native = block_on(cipher.ope_term(42u32, nonempty!("users/age"))).expect("native ope"); assert_eq!(ope, native.as_ref()); let m = block_on(ops::term( @@ -371,7 +371,8 @@ fn guest_terms_match_the_native_sem_derivations() { )) .expect("match term"); let native = - block_on(cipher.match_terms::<DefaultMatch>("alice smith", "users/name")).expect("native"); + block_on(cipher.match_terms::<DefaultMatch>("alice smith", nonempty!("users/name"))) + .expect("native"); assert_eq!(m, native.to_bytes()); // Strings and bytes have distinct PRF encodings — the guest must keep @@ -386,10 +387,12 @@ fn guest_terms_match_the_native_sem_derivations() { )) .expect("bytes term"); assert_ne!(eq_text, eq_bytes); - let native_text = block_on(cipher.equality_term("ab".to_string(), "f")).expect("native"); + let native_text = + block_on(cipher.equality_term("ab".to_string(), nonempty!("f"))).expect("native"); assert_eq!(eq_text, native_text.as_bytes()); let native_bytes = - block_on(cipher.equality_term(Protected::new(b"ab".to_vec()), "f")).expect("native"); + block_on(cipher.equality_term(Protected::new(b"ab".to_vec()), nonempty!("f"))) + .expect("native"); assert_eq!(eq_bytes, native_bytes.as_bytes()); // Variable-width CLLW output for strings. @@ -574,16 +577,17 @@ fn record_terms_equal_the_native_derivations_and_probe_them() { // The stored terms are byte-identical to query-time probes built the // native way — the property that makes the index searchable. - let eq_probe = block_on(cipher.equality_term(34u32, "users/age")).expect("probe"); + let eq_probe = block_on(cipher.equality_term(34u32, nonempty!("users/age"))).expect("probe"); assert_eq!(term_bytes(&age_outputs[1].1), eq_probe.as_bytes()); - let ore_probe = block_on(cipher.ore_term(34u32, "users/age")).expect("probe"); + let ore_probe = block_on(cipher.ore_term(34u32, nonempty!("users/age"))).expect("probe"); assert_eq!(term_bytes(&age_outputs[2].1), ore_probe.as_ref()); let (_, CipherText::Map(name_outputs)) = &fields[1] else { panic!("expected an output map for the second field"); }; let match_probe = - block_on(cipher.match_terms::<DefaultMatch>("alice smith", "users/name")).expect("probe"); + block_on(cipher.match_terms::<DefaultMatch>("alice smith", nonempty!("users/name"))) + .expect("probe"); assert_eq!(term_bytes(&name_outputs[1].1), match_probe.to_bytes()); // And the "c" node is an ordinary value-model ciphertext bound to the @@ -592,8 +596,8 @@ fn record_terms_equal_the_native_derivations_and_probe_them() { panic!("expected a single leaf for a scalar field"); }; let leaf = SealedValue::from_bytes(leaf).expect("frozen leaf"); - let decipher = - block_on(cipher.decipher(CipherText::Single(leaf))).expect("retrieve the data key"); + let decipher = block_on(cipher.decipher(CipherText::Single(leaf), "users/age")) + .expect("retrieve the data key"); let value = FfiValue::decrypt_with_aad(decipher, "users/age") .expect("native decrypt of a record field"); assert!(matches!(value, FfiValue::UInt32(34))); @@ -670,22 +674,20 @@ fn record_shape_violations_are_encoding_errors() { assert_eq!(cipher.kms().generate_calls.load(Ordering::SeqCst), 0); } -/// A context that is not byte-empty but still carries nothing — the PAE of an -/// empty list, i.e. eight zero bytes, which is what `None` and `0u64` encode -/// to — must be rejected at plan-parse time. -/// -/// Without the check the two record paths disagree: `encrypt_record` seals -/// through the cipher-directed path, which does not run stack-encrypt's -/// context predicate, while `decrypt_record` opens through `decrypt_into`, -/// which does. The row would encrypt and then never decrypt. +/// An empty plan context is refused at plan-parse time, before anything is +/// sealed — and on both record paths, so neither half can drift into +/// accepting what the other refuses. (`encrypt_record` seals through the +/// cipher-directed path, which accepts any AAD; `decrypt_record` opens +/// through `decrypt_into`, which takes a `NonEmpty<_>`: proving the context +/// once, at parse, is what keeps a row from encrypting and then never +/// decrypting.) #[test] -fn a_degenerate_plan_context_is_refused_before_anything_is_sealed() { +fn an_empty_plan_context_is_refused_before_anything_is_sealed() { let cipher = cipher(); - let degenerate = String::from_utf8(vec![0u8; 8]).expect("nul bytes are valid utf-8"); let bad_plan = encode(obj(vec![( "f", obj(vec![ - ("context", s(&degenerate)), + ("context", s("")), ("outputs", FfiValue::Array(vec![s("c")])), ]), )])); @@ -700,11 +702,24 @@ fn a_degenerate_plan_context_is_refused_before_anything_is_sealed() { 0, "a context that could never be decrypted under must not seal" ); - - // And the decrypt side agrees, so neither half can drift into accepting - // what the other refuses. assert_eq!( block_on(ops::decrypt_record(&cipher, &source, &bad_plan)), Err(STATUS_ENCODING) ); + + // A context of unusual bytes is still a context: it seals, and opens. + let odd = String::from_utf8(vec![0u8; 8]).expect("nul bytes are valid utf-8"); + let odd_plan = encode(obj(vec![( + "f", + obj(vec![ + ("context", s(&odd)), + ("outputs", FfiValue::Array(vec![s("c")])), + ]), + )])); + let sealed = block_on(ops::encrypt_record(&cipher, &source, &odd_plan)).expect("encrypt"); + let opened = block_on(ops::decrypt_record(&cipher, &sealed, &odd_plan)).expect("decrypt"); + let FfiValue::Object(fields) = decode(&opened) else { + panic!("a record decrypts to an object"); + }; + assert!(matches!(fields.as_slice(), [(name, FfiValue::UInt32(1))] if name == "f")); } diff --git a/packages/stack-encrypt-derive/docs/attributes.md b/packages/stack-encrypt-derive/docs/attributes.md index 20da1259b..80dcfd776 100644 --- a/packages/stack-encrypt-derive/docs/attributes.md +++ b/packages/stack-encrypt-derive/docs/attributes.md @@ -6,9 +6,9 @@ All attributes live under `#[stash(...)]`. | Attribute | Effect | |---|---| -| `plaintext = Type` | The record is an encrypted form of `Type`. Repeatable: one impl per listed type. Omit it for an impl generic over the plaintext (see below). | -| `row = Type` | The record is a row of the struct `Type`: every derived field is derived from the plaintext field of its own name, under the context `"<context>/<field>"` (see [Rows](#rows)). Exclusive with `plaintext`; requires `context`. | -| `context = "..."` | With `row` only: the first half of every field's inferred context — the table's name. Required, never inferred from the type's name, and must not be empty. | +| `plaintext = Type` | The record is an encrypted form of `Type`, every field derived from the whole value. Repeatable: one impl per listed type. Omit it for an impl generic over the plaintext (see below). | +| `struct = Type` | The record encrypts the struct `Type` field by field: every derived field is derived from the plaintext field of its own name, under the context `"<context>/<field>"` (see [Structs, field by field](#structs-field-by-field)). Exclusive with `plaintext`; requires `context`. | +| `context = "..."` | With `struct` only: the first half of every field's inferred context — the stored data's name. Required, never inferred from the type's name, and must not be empty. | | `crate = "path"` | Where to find `stack_encrypt` in the generated code (default `::stack_encrypt`), for use through a re-export. | `plaintext` must be an owned type: the generated impl has no lifetime to give @@ -16,50 +16,70 @@ a reference. Without `plaintext`, each derive emits one impl generic over the pl bounded by what the fields accept: `EncryptedAge` below is `EncryptFrom<P, _, _>` for any `P` that both `StackCipherText` and `EqualityTerm` accept, and `DecryptInto<P, _, _>` for any `P` its `decrypt` field opens to. With -`plaintext`, the record accepts only the listed types (a column that holds -integers should not accept a `String`). Rows — decrypted field by field — -must name it: the plaintext is rebuilt with a struct literal. +`plaintext`, the record accepts only the listed types (a field that holds +integers should not accept a `String`). Whether `Type` is a struct makes no +difference to `plaintext`: the derive sees a name, not a definition, and +derives every field from the whole value. Encrypting a struct field by +field is `struct = Type`, which the plaintext is rebuilt from with a struct +literal. ## On a field | Attribute | Effect | |---|---| -| `context = "..."` | Derive this field under exactly this context rather than the one the caller passes for the record. A query-side term built under the same literal matches it. Must not be empty. | -| `from = field` / `from = 0` | Derive this field from `plaintext.field` (or `plaintext.0` for a tuple struct) rather than from the whole plaintext. Needs `plaintext = ..` or `row = ..` on the struct; in a row, only for a field whose name differs from its plaintext field's. | +| `context = "..."` | Derive this field under exactly this context, extended by the one the caller passes for the record like any other. A query-side term built under the same literal — extended the same way — matches it. Must not be empty. | +| `from = field` / `from = 0` | With `struct` only: derive this field from `plaintext.field` (or `plaintext.0` for a tuple struct) when its name differs from its plaintext field's. | | `default` / `default = expr` | Not derived: filled with `Default::default()` or `expr`. Never encrypted, never authenticated. | | `decrypt` | Decryption opens this field (`DecryptInto` only). Needed only when the field types cannot decide it — see below. | -| `nested` | In a row only: infer no context for this field — it is handed `()`, which its type (a nested row carrying its own contexts) accepts and a leaf refuses. Excludes `context`. | - -The caller's context reaches every field derived from the whole plaintext -that has no `context` of its own, and the impl's context parameter is bounded -by what those fields accept — a leaf accepts only a `SuppliedContext`, so a -record that hands the caller's context to one is encrypted with -`encrypt_into_with_context`. A `from` field never receives the caller's -context: it is derived under its `context`, or under `()` if it has none, -which a nested row accepts and a leaf refuses (at the field, until it is -given a `context`). A record none of whose fields takes the caller's context -— every row — is implemented for `()` exactly, and is encrypted with the -context-free `encrypt_into` (decrypted with -`Plaintext::decrypt_from(record, &cipher)`); the compiler turns the other -form away, since the context would go nowhere. +| `nested` | With `struct` only: infer no context for this field — it is handed the caller's context as it is, which its type (a nested `struct` derive carrying its own contexts) composes with them. Excludes `context`. | + +Each derive emits two impls per plaintext: one for `()`, the context +`encrypt_into` / `Plaintext::decrypt_from(record, &cipher)` pass, and one for +`NonEmpty<T>`, the context `encrypt_into_with_context` / +`decrypt_from_with_context` pass (anything that converts into a +`NonEmpty<T>`: `nonempty!("users/email")`, `NonEmpty::new(value)?`, a bare +integer). Under `()` every field is derived under the context it carries +itself; under `NonEmpty<T>` every such context — a `context = ".."` literal +or a `struct` derive's inferred one — is extended with the caller's +(`("users/age", context)`), and a field with no context of its own is +handed the caller's as it is. No record accepts a context and then discards +it. + +A leaf accepts only a `NonEmpty<T>`, so a record that hands the caller's +context to one — a record of leaves, or a `nested` leaf — has a `()` impl +the compiler cannot satisfy: `encrypt_into` is turned away with the field +that needs a context named, and `encrypt_into_with_context` is the form +that compiles. A record whose every field carries a context — every +`struct` derive — compiles under both. Every attribute except `plaintext` is singular, and repeating one is a compile error rather than a silent overwrite (`plaintext` is repeatable, but each listed type only once). -## Rows +## Structs, field by field -A row needs no attribute on its fields. With -`#[stash(row = User, context = "users")]`, a field `age` is derived from +A `struct = ..` derive needs no attribute on its fields. With +`#[stash(struct = User, context = "users")]`, a field `age` is derived from `user.age` under the context `"users/age"`; a field `email` from `user.email` -under `"users/email"`; a tuple row's `.0` under `"users/0"`. The first half -is the container's `context` and the second the *plaintext* field's name, so -`#[stash(from = email_address)] email: ..` is derived under -`"users/email_address"`: both halves name the column, not the encrypted -struct. Nothing is pluralised or otherwise guessed. `context = ".."` on a -field is taken verbatim and replaces the inferred one; `nested` on a field -infers none — the field is handed `()`, which is what a nested row (its own -`row = ..` derive, carrying its own contexts) accepts and a leaf refuses. +under `"users/email"`; a tuple struct's `.0` under `"users/0"`. The first +half is the container's `context` and the second the *plaintext* field's +name, so `#[stash(from = email_address)] email: ..` is derived under +`"users/email_address"`: both halves name the stored field, not the +encrypted struct. Nothing is pluralised or otherwise guessed. `context = +".."` on a field is taken verbatim and replaces the inferred one; `nested` +on a field infers none — the field is handed the caller's context as it is, +which a nested `struct` derive (carrying its own contexts) composes with +them and a leaf accepts only as a `NonEmpty<T>`. + +A context passed by the caller extends every field's: under +`user.encrypt_into_with_context(&cipher, 7u64)` the `age` field is derived +under `("users/age", 7u64)`, and a query site probes it under +`nonempty!("users/age").with(7u64)`. This is how a field is bound to its +record as well as its name — a record id, say — without the type having to +know the id. Decryption takes the same extension. The extension is owned or +`'static` (`u64`, `String`, `&'static str`, `Option`s and pairs of those): +the field's own context fixes the pair's lifetime, for a `plaintext` record +with `context = ".."` literals as much as for a `struct` derive. The context is part of the stored data's identity: it is the AAD of every ciphertext in the column and the domain of every term. That is why the prefix @@ -68,25 +88,28 @@ plaintext types with the same name in different modules would otherwise silently share every column context — equal plaintexts would produce identical index terms across their tables, and ciphertexts would be transplantable between them — and a rename would silently change the AAD of -every stored row. The field half *is* inferred from the plaintext field's -name, so renaming a plaintext field still changes that column's context and -stored rows stop decrypting (`Error::Aead`) — silently at the call site, with -no compile-time signal. Before such a rename, pin the old value with -`context = ".."` on the fields it reaches. - -A row has no field derived from the whole plaintext; every derived field has -a `from`. Use `plaintext = ..` with explicit `from`s for a record that mixes -the two. +everything stored. The field half *is* inferred from the plaintext field's +name, so renaming a plaintext field still changes that field's context and +stored data stops decrypting — `Error::Kms` against ZeroKMS, which refuses +the key retrieval under the changed descriptor before the AEAD runs, and +`Error::Aead` under a key source that ignores descriptors, such as the fake +one in tests — silently at the call site, with no compile-time signal. +Before such a rename, pin the old value with `context = ".."` on the fields +it reaches. + +A `struct` derive has no field derived from the whole plaintext, and a +`plaintext` record has none derived from a field of it: `from` and `nested` +exist only with `struct`, and the two container attributes are exclusive. ## Which field decryption opens `DecryptInto` does not need to be told: every field type says whether it is a ciphertext or a one-way index term (`Decryptable`), and the derive requires exactly one ciphertext — among all derived fields for a record, or among the -fields derived from each plaintext field (`from = ..`) for a row. Too few or +fields derived from each plaintext field for a `struct` derive. Too few or too many is a compile error at the record's definition (at its first use, if the record is generic). A derived record is itself `Decryptable` if any of -its fields is, so records nest in rows without ceremony; a type of your own +its fields is, so records nest in structs without ceremony; a type of your own implements `Decryptable` and `DecryptField` by hand. `decrypt` is the override for the shapes the types cannot settle: two @@ -96,17 +119,15 @@ considered — one opened as the whole plaintext, or several with `from = ..` rebuilding the plaintext field by field — and the field types need not be `Decryptable`. The record's own `Decryptable` impl (emitted by `#[derive(EncryptFrom)]`) is then `true` outright — the marker says -decryption opens the record — so a marked record still nests in rows. +decryption opens the record — so a marked record still nests in structs. `DecryptInto` consumes the record, moving each opened field out of `self`, so the record must not implement `Drop` (including via `ZeroizeOnDrop`); wrap the fields that need zeroizing instead. -One asymmetry to know about: when the ciphertext field carries a `context` -literal, the record's `decrypt_into` still takes a caller context — the term -fields nominally receive it — but no field actually uses it: terms open -nothing, and the ciphertext authenticates under its literal. Decryption then -succeeds under *any* well-typed context, so a wrong caller context is not the -`Error::Aead` it would be against a leaf. Do not use the decrypt context as a -tenancy or sanity check on such a record; the authenticated context is the -field's literal. +Terms open nothing, so a context handed to a term field on decrypt is +checked for nothing; the ciphertext field is what authenticates, under its +own context extended with the caller's exactly as it was sealed. A record +sealed with `encrypt_into` opens with `decrypt_from` and not under any +`NonEmpty<T>`; one sealed under an extension opens only under the same +extension. diff --git a/packages/stack-encrypt-derive/src/attrs.rs b/packages/stack-encrypt-derive/src/attrs.rs index 9649f50db..b7e21a26c 100644 --- a/packages/stack-encrypt-derive/src/attrs.rs +++ b/packages/stack-encrypt-derive/src/attrs.rs @@ -12,28 +12,34 @@ pub(crate) struct ContainerAttrs { /// each, from repeated `#[stash(plaintext = Type)]`. Empty means /// a single impl generic over the plaintext. pub(crate) plaintexts: Vec<Type>, - /// `#[stash(row = Type)]`: the record is a row of the struct `Type`. - /// Every derived field is derived from the plaintext field of its own - /// name (`from`), under a context made of the container's `context` and - /// the plaintext field's name, unless the field says otherwise. - /// Exclusive with `plaintext`; requires `context`. - pub(crate) row: Option<Type>, + /// `#[stash(struct = Type)]`: the record encrypts the struct `Type` + /// field by field. Every derived field is derived from the plaintext + /// field of its own name (`from`), under a context made of the + /// container's `context` and the plaintext field's name, unless the + /// field says otherwise. Exclusive with `plaintext`; requires `context`. + pub(crate) by_field: Option<Type>, /// `#[stash(context = "...")]` on the container: the first half of every - /// row field's context — `"<context>/<field>"`. Names the table, not the - /// Rust type: it is part of the stored data's identity, so it is given - /// explicitly rather than inferred from a name a refactor can change. - /// Only meaningful with `row`. + /// field's inferred context — `"<context>/<field>"`. Names the stored + /// data, not the Rust type: it is part of the stored data's identity, so + /// it is given explicitly rather than inferred from a name a refactor + /// can change. Only meaningful with `struct`. pub(crate) context: Option<LitStr>, } +const CONTAINER_KEYS: &str = "unsupported container attribute; expected `plaintext = Type`, \ + `struct = Type`, `context = \"...\"` (with `struct`) or `crate = \"...\"`"; + impl ContainerAttrs { pub(crate) fn parse(attrs: &[Attribute]) -> Result<Self> { let mut krate: Option<Path> = None; let mut plaintexts: Vec<Type> = Vec::new(); - let mut row: Option<Type> = None; + let mut by_field: Option<Type> = None; let mut context: Option<LitStr> = None; for attr in attrs.iter().filter(|a| a.path().is_ident("stash")) { + // `struct` and `crate` are keywords, but a nested-meta path is + // parsed with `Ident::parse_any`, so `struct = User` reads as + // written — no `r#struct`. attr.parse_nested_meta(|meta| { if meta.path.is_ident("crate") { if krate.is_some() { @@ -45,16 +51,21 @@ impl ContainerAttrs { } if meta.path.is_ident("context") { if context.is_some() { - return Err(meta.error("`context` is given twice; a row has one prefix")); + return Err(meta.error("`context` is given twice; a struct has one prefix")); } context = Some(meta.value()?.parse()?); return Ok(()); } - if meta.path.is_ident("row") { + if meta.path.is_ident("struct") { + if by_field.is_some() { + return Err(meta.error( + "`struct` is given twice; a record encrypts one plaintext struct", + )); + } let ty: Type = meta.value()?.parse()?; - // A row reaches into the plaintext by field name and - // rebuilds it with a struct literal, so the type must be - // a struct named directly. + // The plaintext is reached by field name and rebuilt + // with a struct literal, so the type must be a struct + // named directly. let named_struct = match &ty { Type::Path(path) => path.qself.is_none(), _ => false, @@ -62,26 +73,20 @@ impl ContainerAttrs { if !named_struct { return Err(syn::Error::new_spanned( &ty, - "`row` must name a struct directly (`row = User`): its fields are \ - reached by name and the plaintext is rebuilt with a struct literal", - )); - } - if row.is_some() { - return Err(syn::Error::new_spanned( - &ty, - "`row` is given twice; a row has one plaintext struct", + "`struct` must name a struct directly (`struct = User`): its fields \ + are reached by name and the plaintext is rebuilt with a struct \ + literal", )); } - row = Some(ty); + by_field = Some(ty); return Ok(()); } if meta.path.is_ident("plaintext") { let plaintext: Type = meta.value()?.parse()?; - // The type is spliced into the impl header as written, - // where a reference has no lifetime to name. The generic - // impl (no `plaintext` at all) already accepts `&str` and - // friends; a listed one is only needed for `from = ..`, - // which reaches into a struct. + // The type is spliced into the impl header as + // written, where a reference has no lifetime to + // name. The generic impl (no `plaintext` at all) + // already accepts `&str` and friends. if let Type::Reference(_) = plaintext { return Err(syn::Error::new_spanned( &plaintext, @@ -99,36 +104,33 @@ impl ContainerAttrs { plaintexts.push(plaintext); return Ok(()); } - Err(meta.error( - "unsupported container attribute; expected `plaintext = Type`, `row = Type`, \ - `context = \"...\"` (with `row`) or `crate = \"...\"`", - )) + Err(meta.error(CONTAINER_KEYS)) })?; } - if let (Some(row), Some(plaintext)) = (&row, plaintexts.first()) { + if let (Some(by_field), Some(plaintext)) = (&by_field, plaintexts.first()) { let mut err = syn::Error::new_spanned( - row, - "`row` and `plaintext` are two ways of naming the plaintext: a row *is* a record \ - of its struct's fields, so give `row = ..` alone", + by_field, + "`struct` and `plaintext` are two ways of naming the plaintext: `struct = ..` \ + encrypts it field by field, `plaintext = ..` as one value, so give one of them", ); err.combine(syn::Error::new_spanned(plaintext, "`plaintext` given here")); return Err(err); } // The prefix is part of the stored data's identity — the AAD of every - // ciphertext in the row and the domain of every term — so it is never - // inferred from the Rust type's name: two types named `Account` in - // different modules would silently share every column context, making - // ciphertexts transplantable between their tables and index terms - // comparable across them. - match (&row, &context) { - (Some(row), None) => { + // ciphertext derived from the struct and the domain of every term — + // so it is never inferred from the Rust type's name: two types named + // `Account` in different modules would silently share every field + // context, making ciphertexts transplantable between them and index + // terms comparable across them. + match (&by_field, &context) { + (Some(by_field), None) => { return Err(syn::Error::new_spanned( - row, - "`row = ..` needs a `context = \"..\"` beside it naming the table (e.g. \ - `#[stash(row = User, context = \"users\")]`): each field is derived under \ - `\"<context>/<field>\"`, and the prefix is part of the stored data's \ + by_field, + "`struct = ..` needs a `context = \"..\"` beside it naming the stored data \ + (e.g. `#[stash(struct = User, context = \"users\")]`): each field is derived \ + under `\"<context>/<field>\"`, and the prefix is part of the stored data's \ identity, so it is given explicitly rather than inferred from the Rust \ type's name", )); @@ -136,9 +138,9 @@ impl ContainerAttrs { (None, Some(context)) => { return Err(syn::Error::new( context.span(), - "a container `context` is the prefix of a row's per-field contexts and \ - applies only with `row = ..`; a `plaintext` record's fields take the \ - caller's context, or a `context = \"..\"` of their own", + "a container `context` is the prefix of the per-field contexts and applies \ + only with `struct = ..`; a `plaintext` record's fields take the caller's \ + context, or a `context = \"..\"` of their own", )); } _ => {} @@ -147,8 +149,8 @@ impl ContainerAttrs { if context.value().is_empty() { return Err(syn::Error::new( context.span(), - "an empty `context` is rejected when a value is encrypted: name the table \ - (e.g. \"users\")", + "an empty `context` is rejected when a value is encrypted: name the stored \ + data (e.g. \"users\")", )); } } @@ -156,7 +158,7 @@ impl ContainerAttrs { Ok(Self { krate: krate.unwrap_or_else(|| syn::parse_quote!(::stack_encrypt)), plaintexts, - row, + by_field, context, }) } @@ -166,21 +168,23 @@ impl ContainerAttrs { #[derive(Default)] pub(crate) struct FieldAttrs { /// `#[stash(context = "...")]`: derive this field under exactly this - /// context instead of the one the caller passed for the record. + /// context instead of the one a `struct` derive would infer, or the one + /// the caller passes for the record. Extended by a caller's context like + /// any other. pub(crate) context: Option<LitStr>, - /// `#[stash(from = field)]` / `#[stash(from = 0)]`: derive - /// this field from one field of the plaintext rather than from the whole - /// plaintext. In a row, the override for a field whose name differs - /// from its plaintext field's. + /// `#[stash(from = field)]` / `#[stash(from = 0)]`: with `struct = ..`, + /// derive this field from a plaintext field whose name differs from its + /// own. pub(crate) from: Option<Member>, /// `#[stash(default)]` / `#[stash(default = expr)]`: not derived; /// filled with `Default::default()` or the expression. pub(crate) default: Option<Option<Expr>>, /// `#[stash(decrypt)]`: decryption opens this field. pub(crate) decrypt: bool, - /// `#[stash(nested)]`: in a row, do not infer a context for this field — - /// hand it `()`, because its type (a nested row) carries its own - /// contexts. + /// `#[stash(nested)]`: with `struct = ..`, do not infer a context for + /// this field — hand it the caller's as it is, because its type (a + /// nested `struct` derive) carries its own contexts and composes them + /// with it. pub(crate) nested: bool, } @@ -243,35 +247,16 @@ impl FieldAttrs { })?; } - // The leaves reject an empty context at runtime; a literal one is - // known here, so say so at the literal. Checked after the loop, once - // `from` is known whatever order the attributes were written in: the - // advice depends on it, because a `from` field is never handed the - // record's context, so "drop the attribute" is a dead end there. if parsed.nested { if let Some(context) = &parsed.context { return Err(syn::Error::new( context.span(), - "`nested` hands this field `()` because its type carries its own contexts, \ - so `context` does not apply: give one or the other", + "`nested` hands this field the caller's context because its type carries its \ + own, so `context` does not apply: give one or the other", )); } } - if let Some(context) = &parsed.context { - if context.value().is_empty() { - let message = if parsed.from.is_some() { - "an empty `context` is rejected when a value is encrypted: name the column \ - this field encrypts (e.g. \"users/email\"). A `from` field is never handed \ - the record's context, so the literal is the only context this field can have." - } else { - "an empty `context` is rejected when a value is encrypted: name the field \ - (e.g. \"users/email\"), or drop the attribute to use the record's context" - }; - return Err(syn::Error::new(context.span(), message)); - } - } - Ok(parsed) } } diff --git a/packages/stack-encrypt-derive/src/decrypt.rs b/packages/stack-encrypt-derive/src/decrypt.rs index 61644600e..58a138af1 100644 --- a/packages/stack-encrypt-derive/src/decrypt.rs +++ b/packages/stack-encrypt-derive/src/decrypt.rs @@ -19,8 +19,8 @@ use syn::{ }; use crate::shape::{ - impl_sources, push_context_generics, push_field_bounds, trait_impl, zip_fields, CallerContext, - Field, FieldBound, Record, + context_param, impl_sources, push_field_bounds, trait_impl, zip_fields, CallerContext, + ContextImpl, Field, FieldBound, Record, }; pub(crate) fn derive(input: DeriveInput) -> Result<TokenStream> { @@ -42,15 +42,23 @@ pub(crate) fn derive(input: DeriveInput) -> Result<TokenStream> { // ============================================================================= /// `impl DecryptInto<Plaintext, StackCipher<__K>, Ctx> for Record` around -/// `body`; `ctx` is `__Ctx` or `()` ([`push_context_generics`]). +/// `body`; `ctx` is `()` or `NonEmpty<__T>` ([`context_param`]). The +/// context parameter is unnamed when no opened field uses it, so the +/// expansion warns of nothing. fn impl_block( input: &DeriveInput, krate: &Path, generics: &Generics, plaintext: &Type, ctx: &Type, + uses_context: bool, body: TokenStream, ) -> TokenStream { + let context = if uses_context { + quote!(__context) + } else { + quote!(_) + }; trait_impl( input, generics, @@ -59,7 +67,7 @@ fn impl_block( fn decrypt_into<'__a>( self, __cipher: &'__a #krate::StackCipher<__K>, - __context: #ctx, + #context: #ctx, ) -> #krate::target::Pending<'__a, #plaintext, __K> where Self: '__a, @@ -111,12 +119,10 @@ fn decrypt_field_impl(input: &DeriveInput, krate: &Path) -> TokenStream { } } -/// The context one opened field is handed, by move: its own, or the caller's. -fn context_for(field: &Field) -> TokenStream { - field - .field_context() - .own_expr() - .unwrap_or_else(|| quote!(__context)) +/// The context one opened field is handed, by move, in the impl for +/// `which`: its own, the caller's, or its own extended with the caller's. +fn context_for(krate: &Path, field: &Field, which: ContextImpl) -> TokenStream { + field.field_context().expr(krate, which, quote!(__context)) } /// The plaintext type as a struct-literal path: `User<T>` becomes `User::<T>`. @@ -165,26 +171,17 @@ struct Group<'a> { } impl<'a> Auto<'a> { - fn classify(record: &'a Record, name: &Ident) -> Result<Self> { + fn classify(record: &'a Record) -> Self { let candidates = record.derived(); - let with_from = candidates.iter().filter(|f| f.from().is_some()).count(); - if with_from == 0 { - return Ok(Auto::Whole(candidates)); - } - if with_from != candidates.len() { - return Err(syn::Error::new_spanned( - name, - "some derived fields name a plaintext field (`from = ..`) and some do not, so it \ - is ambiguous whether decryption opens the record as a whole or rebuilds the \ - plaintext field by field: mark the fields decryption opens `#[stash(decrypt)]`", - )); + if !record.by_field { + return Auto::Whole(candidates); } let mut groups: Vec<Group<'a>> = Vec::new(); for field in candidates { let from = field .from() - .unwrap_or_else(|| unreachable!("counted above")); + .unwrap_or_else(|| unreachable!("every field of a `struct` derive has a `from`")); match groups.iter_mut().find(|g| g.from == from) { Some(group) => group.fields.push(field), None => groups.push(Group { @@ -193,14 +190,14 @@ impl<'a> Auto<'a> { }), } } - Ok(Auto::ByField(groups)) + Auto::ByField(groups) } } fn automatic(input: &DeriveInput, record: &Record) -> Result<TokenStream> { let krate = &record.krate; let name = &input.ident; - let auto = Auto::classify(record, name)?; + let auto = Auto::classify(record); // The one-ciphertext check: at the definition for a concrete record, at // the first use for a generic one (a `const _` cannot name the record's @@ -228,29 +225,33 @@ fn automatic(input: &DeriveInput, record: &Record) -> Result<TokenStream> { .flat_map(|g| g.fields.iter().copied()) .collect(), }; - let destructure = { + let destructure = |uses_context: bool| { let bind = candidates.iter().map(|f| { let member = &f.member; let local = &f.local; quote!(#member: #local) }); + // The caller's context is cloned to every field that uses it, so + // it is taken by reference once. + let borrow = uses_context.then(|| quote!(let __context = &__context;)); quote! { let Self { #(#bind,)* .. } = self; - let __context = &__context; + #borrow } }; // One impl per listed plaintext, or one generic over it (whole mode // only: rebuilding field by field needs a struct literal, and - // `Record::parse` has rejected `from` without a named plaintext). Each - // candidate field is bounded by `DecryptField` under the context it is - // opened under, so a record's impl exists for exactly the plaintexts its - // ciphertext field opens to — and only under a supplied context if that - // field needs one. + // `Record::parse` has rejected `from` without a named plaintext) — and + // each twice, for `()` and for `NonEmpty<__T>` (see `context_param`). + // Each candidate field is bounded by `DecryptField` under the context + // it is opened under, so a record's impl exists for exactly the + // plaintexts its ciphertext field opens to — and only under a non-empty + // context if that field needs one. let (plaintexts, generic) = impl_sources(record, parse_quote!(__P)); - let impls = plaintexts - .iter() - .map(|plaintext| { + let mut impls = Vec::with_capacity(plaintexts.len() * 2); + for plaintext in &plaintexts { + for which in ContextImpl::BOTH { let mut generics = input.generics.clone(); if generic { generics.params.push(parse_quote!(__P)); @@ -264,17 +265,33 @@ fn automatic(input: &DeriveInput, record: &Record) -> Result<TokenStream> { fields, plaintext, FieldBound::DecryptField, + which, ); - open_one(krate, fields, plaintext) + open_one(krate, fields, plaintext, which) } - Auto::ByField(groups) => by_group_body(krate, groups, plaintext)?, + Auto::ByField(groups) => by_group_body(krate, groups, plaintext, which)?, }; - let ctx = - push_context_generics(&mut generics, CallerContext::Decrypt(krate), &candidates); + let ctx = context_param( + &mut generics, + CallerContext::Decrypt(krate), + which, + record.by_field, + &candidates, + ); + let uses_context = candidates.iter().any(|f| f.uses_callers_context(which)); + let destructure = destructure(uses_context); let body = quote!(#body_check #destructure #open); - Ok(impl_block(input, krate, &generics, plaintext, &ctx, body)) - }) - .collect::<Result<Vec<_>>>()?; + impls.push(impl_block( + input, + krate, + &generics, + plaintext, + &ctx, + uses_context, + body, + )); + } + } Ok(quote!(#(#impls)* #definition_check)) } @@ -327,17 +344,23 @@ fn check(krate: &Path, name: &Ident, from: Option<&Member>, fields: &[&Field]) - } /// The one `Some` among the fields' `decrypt_field`s, as a pending of -/// `plaintext` (`_` when it is inferred from a struct literal). -fn open_one(krate: &Path, fields: &[&Field], plaintext: &Type) -> TokenStream { +/// `plaintext` (`_` when it is inferred from a struct literal), in the impl +/// for `which`. +fn open_one(krate: &Path, fields: &[&Field], plaintext: &Type, which: ContextImpl) -> TokenStream { let mut calls = fields.iter().map(|field| { let ty = &field.ty; let local = &field.local; - let context = field - .field_context() - .own_expr() - .unwrap_or_else(|| quote!(::core::clone::Clone::clone(__context))); - quote! { - <#ty as #krate::target::DecryptField<#plaintext, #krate::StackCipher<__K>, _>>::decrypt_field( + let context = field.field_context().expr( + krate, + which, + quote!(::core::clone::Clone::clone(__context)), + ); + // The context type is named, not inferred, so a leaf that cannot + // open under it is reported by the trait's `on_unimplemented` + // rather than as an argument type mismatch inside the expansion. + let context_ty = field.field_context().ty(krate, which); + quote_spanned! {ty.span()=> + <#ty as #krate::target::DecryptField<#plaintext, #krate::StackCipher<__K>, #context_ty>>::decrypt_field( #local, __cipher, #context, ) } @@ -364,7 +387,12 @@ fn open_one(krate: &Path, fields: &[&Field], plaintext: &Type) -> TokenStream { /// Each group's opened pending, zipped into one and mapped into a struct /// literal of the plaintext. -fn by_group_body(krate: &Path, groups: &[Group<'_>], plaintext: &Type) -> Result<TokenStream> { +fn by_group_body( + krate: &Path, + groups: &[Group<'_>], + plaintext: &Type, + which: ContextImpl, +) -> Result<TokenStream> { let literal = struct_literal_path(plaintext)?; let inferred: Type = parse_quote!(_); @@ -372,7 +400,7 @@ fn by_group_body(krate: &Path, groups: &[Group<'_>], plaintext: &Type) -> Result .map(|index| Ident::new(&format!("__group_{index}"), Span::call_site())) .collect(); let opens = groups.iter().zip(&locals).map(|(group, local)| { - let open = open_one(krate, &group.fields, &inferred); + let open = open_one(krate, &group.fields, &inferred, which); quote!(let #local = #open;) }); @@ -409,39 +437,69 @@ fn explicit(input: &DeriveInput, record: &Record) -> Result<TokenStream> { let opened: Vec<&Field> = record.fields.iter().filter(|f| f.decrypt).collect(); let mode = Mode::classify(opened, name)?; - // One impl per listed plaintext, or one generic over it. Only the - // whole-plaintext mode can be generic — rebuilding field by field needs - // a struct literal, and therefore a name — and `Record::parse` has - // already rejected `from` without one. + // One impl per listed plaintext, or one generic over it — and each + // twice, for `()` and for `NonEmpty<__T>`. Only the whole-plaintext mode + // can be generic — rebuilding field by field needs a struct literal, and + // therefore a name — and `Record::parse` has already rejected `from` + // without one. let (plaintexts, generic) = impl_sources(record, parse_quote!(__P)); - let impls = plaintexts - .iter() - .map(|plaintext| { + let mut impls = Vec::with_capacity(plaintexts.len() * 2); + for plaintext in &plaintexts { + for which in ContextImpl::BOTH { let mut generics = input.generics.clone(); if generic { generics.params.push(parse_quote!(__P)); } generics.params.push(parse_quote!(__K)); - let (body, ctx) = match &mode { + let (body, ctx, uses_context) = match &mode { Mode::Whole(field) => { - let ty = &field.ty; - let context = field.field_context().ty(); - generics.make_where_clause().predicates.push(parse_quote! { - #ty: #krate::target::DecryptInto<#plaintext, #krate::StackCipher<__K>, #context> - }); - let ctx = - push_context_generics(&mut generics, CallerContext::Decrypt(krate), &[field]); - (whole_body(krate, field, plaintext), ctx) + push_field_bounds( + &mut generics, + krate, + &[field], + plaintext, + FieldBound::DecryptInto, + which, + ); + let ctx = context_param( + &mut generics, + CallerContext::Decrypt(krate), + which, + record.by_field, + &[field], + ); + ( + whole_body(krate, field, plaintext, which), + ctx, + field.uses_callers_context(which), + ) } Mode::ByField(fields) => { - let ctx = - push_context_generics(&mut generics, CallerContext::Decrypt(krate), fields); - (by_field_body(krate, fields, plaintext)?, ctx) + let ctx = context_param( + &mut generics, + CallerContext::Decrypt(krate), + which, + record.by_field, + fields, + ); + ( + by_field_body(krate, fields, plaintext, which)?, + ctx, + fields.iter().any(|f| f.uses_callers_context(which)), + ) } }; - Ok(impl_block(input, krate, &generics, plaintext, &ctx, body)) - }) - .collect::<Result<Vec<_>>>()?; + impls.push(impl_block( + input, + krate, + &generics, + plaintext, + &ctx, + uses_context, + body, + )); + } + } Ok(quote!(#(#impls)*)) } @@ -466,19 +524,16 @@ impl<'a> Mode<'a> { } return Err(syn::Error::new_spanned( name, - "several fields are marked `decrypt` but none names a plaintext field: one \ - plaintext cannot be recovered from two fields. Either mark only the ciphertext \ - field, or give each a `from = ..` so decryption rebuilds the plaintext field by \ - field.", - )); - } - if by_field != opened.len() { - return Err(syn::Error::new_spanned( - name, - "`decrypt` fields must either all name a plaintext field (`from = ..`) or be a \ - single field opened as the whole plaintext; this record mixes the two", + "several fields are marked `decrypt` but the record is one value: one plaintext \ + cannot be recovered from two fields. Mark only the ciphertext field, or encrypt \ + a struct field by field with `#[stash(struct = ..)]`.", )); } + debug_assert_eq!( + by_field, + opened.len(), + "every field of a `struct` derive has a `from`, and no other field does" + ); let mut seen: HashSet<&Member> = HashSet::with_capacity(opened.len()); for field in &opened { @@ -497,12 +552,13 @@ impl<'a> Mode<'a> { } } -fn whole_body(krate: &Path, field: &Field, plaintext: &Type) -> TokenStream { +fn whole_body(krate: &Path, field: &Field, plaintext: &Type, which: ContextImpl) -> TokenStream { let ty = &field.ty; let member = &field.member; - let context = context_for(field); + let context = context_for(krate, field, which); + let context_ty = field.field_context().ty(krate, which); quote! { - <#ty as #krate::target::DecryptInto<#plaintext, #krate::StackCipher<__K>, _>>::decrypt_into( + <#ty as #krate::target::DecryptInto<#plaintext, #krate::StackCipher<__K>, #context_ty>>::decrypt_into( self.#member, __cipher, #context, @@ -510,7 +566,12 @@ fn whole_body(krate: &Path, field: &Field, plaintext: &Type) -> TokenStream { } } -fn by_field_body(krate: &Path, fields: &[&Field], plaintext: &Type) -> Result<TokenStream> { +fn by_field_body( + krate: &Path, + fields: &[&Field], + plaintext: &Type, + which: ContextImpl, +) -> Result<TokenStream> { let literal = struct_literal_path(plaintext)?; let assign = fields.iter().map(|field| { @@ -520,16 +581,20 @@ fn by_field_body(krate: &Path, fields: &[&Field], plaintext: &Type) -> Result<To }); Ok(zip_fields( + krate, fields, + which, |field, context| { let ty = &field.ty; let member = &field.member; // The plaintext field's type is not known here; it is inferred // from the struct literal, and the obligation checked against it - // — spanned at the field type, so a leaf handed `()` (no - // literal) is reported at the field that needs a `context`. + // — spanned at the field type, so a leaf handed `()` (a `nested` + // field) is reported at the field that needs a `context`, by the + // trait's `on_unimplemented` since the context type is named. + let context_ty = field.field_context().ty(krate, which); let call = quote_spanned! {ty.span()=> - <#ty as #krate::target::DecryptInto<_, #krate::StackCipher<__K>, _>>::decrypt_into + <#ty as #krate::target::DecryptInto<_, #krate::StackCipher<__K>, #context_ty>>::decrypt_into }; quote!(#call(self.#member, __cipher, #context,)) }, @@ -560,20 +625,28 @@ mod tests { }) .unwrap(); // Every derived field is a candidate, bounded and asked in turn; the - // `default` field is neither. + // `default` field is neither. Once for `()` and once for + // `NonEmpty<__T>`. + assert_contains(&expansion, quote! { + impl<__K> ::stack_encrypt::target::DecryptInto<u32, ::stack_encrypt::StackCipher<__K>, ()> for Rec + where + StackCipherText: ::stack_encrypt::target::DecryptField<u32, ::stack_encrypt::StackCipher<__K>, ()>, + EqualityTerm: ::stack_encrypt::target::DecryptField<u32, ::stack_encrypt::StackCipher<__K>, ()> + }); assert_contains(&expansion, quote! { + impl<'__ctx, __K, __T> ::stack_encrypt::target::DecryptInto<u32, ::stack_encrypt::StackCipher<__K>, ::stack_encrypt::NonEmpty<__T>> for Rec where - StackCipherText: ::stack_encrypt::target::DecryptField<u32, ::stack_encrypt::StackCipher<__K>, __Ctx>, - EqualityTerm: ::stack_encrypt::target::DecryptField<u32, ::stack_encrypt::StackCipher<__K>, __Ctx>, - __Ctx: ::stack_encrypt::target::DecryptContext<'__ctx> + StackCipherText: ::stack_encrypt::target::DecryptField<u32, ::stack_encrypt::StackCipher<__K>, ::stack_encrypt::NonEmpty<__T>>, + EqualityTerm: ::stack_encrypt::target::DecryptField<u32, ::stack_encrypt::StackCipher<__K>, ::stack_encrypt::NonEmpty<__T>>, + __T: ::stack_encrypt::IntoAad<'__ctx> + ::core::clone::Clone }); - assert_contains(&expansion, quote!(let Self { c: __field_0, hm: __field_1, .. } = self;)); + assert_contains(&expansion, quote!(let Self { c: __field_0, hm: __field_1, .. } = self; let __context = &__context;)); assert_contains(&expansion, quote! { ::core::option::Option::or_else( - <StackCipherText as ::stack_encrypt::target::DecryptField<u32, ::stack_encrypt::StackCipher<__K>, _>>::decrypt_field( + <StackCipherText as ::stack_encrypt::target::DecryptField<u32, ::stack_encrypt::StackCipher<__K>, ()>>::decrypt_field( __field_0, __cipher, ::core::clone::Clone::clone(__context), ), - move || <EqualityTerm as ::stack_encrypt::target::DecryptField<u32, ::stack_encrypt::StackCipher<__K>, _>>::decrypt_field( + move || <EqualityTerm as ::stack_encrypt::target::DecryptField<u32, ::stack_encrypt::StackCipher<__K>, ()>>::decrypt_field( __field_1, __cipher, ::core::clone::Clone::clone(__context), ) ) @@ -596,12 +669,12 @@ mod tests { #[test] #[rustfmt::skip] - fn a_caller_context_must_be_a_decrypt_context() { - // The regression shape: the ciphertext field carries a literal, so - // only the term fields see the caller's context — and a term's - // `DecryptField` accepts anything (it opens nothing). The impl-level - // bound is what keeps `decrypt_into` from accepting, and silently - // discarding, a value that is not a context at all. + fn a_caller_context_is_bounded_by_the_vitaminc_traits() { + // The regression shape: the ciphertext field carries a literal and + // a term's `DecryptField` accepts anything (it opens nothing). The + // impl-level bound is what keeps `decrypt_into` from accepting a + // value that is not a context at all — and the literal extends the + // caller's context, so the ciphertext authenticates under it. let expansion = expand(parse_quote! { #[stash(plaintext = u32)] struct Rec { @@ -612,10 +685,17 @@ mod tests { }) .unwrap(); assert_contains(&expansion, quote! { - impl<'__ctx, __K, __Ctx> ::stack_encrypt::target::DecryptInto<u32, ::stack_encrypt::StackCipher<__K>, __Ctx> for Rec + impl<__K, __T> ::stack_encrypt::target::DecryptInto<u32, ::stack_encrypt::StackCipher<__K>, ::stack_encrypt::NonEmpty<__T>> for Rec + }); + assert_contains(&expansion, quote! { + __T: ::stack_encrypt::IntoAad<'static> + ::core::clone::Clone }); + // The literal is a compile-time `NonEmpty` under `()`, extended + // under `NonEmpty<__T>`. + assert_contains(&expansion, quote!(__field_0, __cipher, ::stack_encrypt::nonempty!("rec/c"),)); assert_contains(&expansion, quote! { - __Ctx: ::stack_encrypt::target::DecryptContext<'__ctx> + __field_0, __cipher, + ::stack_encrypt::NonEmpty::with(::stack_encrypt::nonempty!("rec/c"), ::core::clone::Clone::clone(__context)), }); } @@ -636,13 +716,11 @@ mod tests { #[rustfmt::skip] fn unmarked_from_fields_are_grouped_by_plaintext_field() { let expansion = expand(parse_quote! { - #[stash(plaintext = User)] + #[stash(struct = User, context = "users")] struct Row { - #[stash(from = age, context = "users/age")] age: EncryptedAge, - #[stash(from = email, context = "users/email")] email: StackCipherText, - #[stash(from = email, context = "users/email")] + #[stash(from = email)] email_eq: EqualityTerm, } }) @@ -654,39 +732,59 @@ mod tests { assert_contains(&expansion, quote! { let __group_1 = ::core::option::Option::unwrap_or_else( ::core::option::Option::or_else( - <StackCipherText as ::stack_encrypt::target::DecryptField<_, ::stack_encrypt::StackCipher<__K>, _>>::decrypt_field( - __field_1, __cipher, "users/email", + <StackCipherText as ::stack_encrypt::target::DecryptField<_, ::stack_encrypt::StackCipher<__K>, ::stack_encrypt::NonEmpty<&'static str>>>::decrypt_field( + __field_1, __cipher, ::stack_encrypt::nonempty!("users/email"), ), - move || <EqualityTerm as ::stack_encrypt::target::DecryptField<_, ::stack_encrypt::StackCipher<__K>, _>>::decrypt_field( - __field_2, __cipher, "users/email", + move || <EqualityTerm as ::stack_encrypt::target::DecryptField<_, ::stack_encrypt::StackCipher<__K>, ::stack_encrypt::NonEmpty<&'static str>>>::decrypt_field( + __field_2, __cipher, ::stack_encrypt::nonempty!("users/email"), ) ), || ::stack_encrypt::target::Pending::failed(__cipher, ::stack_encrypt::Error::NotOpened,) ); }); assert_contains(&expansion, quote!(__group_0.zip(__group_1).map(|(__group_0, __group_1)| User { age: __group_0, email: __group_1 }))); - // Every field has its own context: the impl is for `()` exactly, and - // `User::decrypt_from(row, &cipher)` is the one form that compiles. + // Every field has its own context: the `()` impl uses none of the + // caller's, so its parameter is unnamed and never borrowed; the + // `NonEmpty<__T>` impl extends each with it. assert_contains(&expansion, quote! { impl<__K> ::stack_encrypt::target::DecryptInto<User, ::stack_encrypt::StackCipher<__K>, ()> for Row }); - assert_lacks(&expansion, quote!(DecryptInto<User, ::stack_encrypt::StackCipher<__K>, __Ctx>)); + assert_contains(&expansion, quote!(_: (),)); + assert_contains(&expansion, quote! { + impl<__K, __T> ::stack_encrypt::target::DecryptInto<User, ::stack_encrypt::StackCipher<__K>, ::stack_encrypt::NonEmpty<__T>> for Row + }); + assert_contains(&expansion, quote!(let __context = &__context;)); } #[test] - fn a_record_mixing_from_and_whole_fields_must_be_marked() { - let err = expand(parse_quote! { - #[stash(plaintext = User)] - struct Row { - #[stash(from = email)] + #[rustfmt::skip] + fn a_struct_opens_its_fields_under_their_extended_contexts() { + let expansion = expand(parse_quote! { + #[stash(struct = User, context = "user")] + struct EncryptedUser { + age: EncryptedAge, email: StackCipherText, - hm: EqualityTerm, } }) - .unwrap_err(); - assert!(err - .to_string() - .contains("ambiguous whether decryption opens")); + .unwrap(); + // Under `()`, the inferred literals as they are. + assert_contains(&expansion, quote!(__field_0, __cipher, ::stack_encrypt::nonempty!("user/age"),)); + // Under `NonEmpty<__T>`, each extended with the caller's — cloned, + // since every opened field is asked through a reference — and `__T` + // bounded for `'static`. + assert_contains(&expansion, quote! { + impl<__K, __T> ::stack_encrypt::target::DecryptInto<User, ::stack_encrypt::StackCipher<__K>, ::stack_encrypt::NonEmpty<__T>> for EncryptedUser + where + __T: ::stack_encrypt::IntoAad<'static> + ::core::clone::Clone + }); + assert_contains(&expansion, quote! { + __field_0, __cipher, + ::stack_encrypt::NonEmpty::with(::stack_encrypt::nonempty!("user/age"), ::core::clone::Clone::clone(__context)), + }); + assert_contains(&expansion, quote! { + __field_1, __cipher, + ::stack_encrypt::NonEmpty::with(::stack_encrypt::nonempty!("user/email"), ::core::clone::Clone::clone(__context)), + }); } #[test] @@ -742,27 +840,12 @@ mod tests { .contains("cannot be recovered from two fields")); } - #[test] - fn mixed_modes_are_rejected() { - let err = expand(parse_quote! { - #[stash(plaintext = User)] - struct Rec { - #[stash(decrypt, from = a)] - a: StackCipherText, - #[stash(decrypt)] - b: StackCipherText, - } - }) - .unwrap_err(); - assert!(err.to_string().contains("mixes the two")); - } - #[test] fn duplicate_recovery_targets_are_rejected() { let err = expand(parse_quote! { - #[stash(plaintext = User)] + #[stash(struct = User, context = "users")] struct Rec { - #[stash(decrypt, from = a)] + #[stash(decrypt)] a: StackCipherText, #[stash(decrypt, from = a)] b: StackCipherText, @@ -784,33 +867,24 @@ mod tests { }) .unwrap(); assert_contains(&expansion, quote! { - impl<'__ctx, __P, __K, __Ctx> ::stack_encrypt::target::DecryptInto<__P, ::stack_encrypt::StackCipher<__K>, __Ctx> for Wrapped + impl<__P, __K> ::stack_encrypt::target::DecryptInto<__P, ::stack_encrypt::StackCipher<__K>, ()> for Wrapped where - StackCipherText: ::stack_encrypt::target::DecryptInto<__P, ::stack_encrypt::StackCipher<__K>, __Ctx>, - __Ctx: ::stack_encrypt::target::DecryptContext<'__ctx> + StackCipherText: ::stack_encrypt::target::DecryptInto<__P, ::stack_encrypt::StackCipher<__K>, ()> }); assert_contains(&expansion, quote! { - <StackCipherText as ::stack_encrypt::target::DecryptInto<__P, ::stack_encrypt::StackCipher<__K>, _>>::decrypt_into( + impl<'__ctx, __P, __K, __T> ::stack_encrypt::target::DecryptInto<__P, ::stack_encrypt::StackCipher<__K>, ::stack_encrypt::NonEmpty<__T>> for Wrapped + where + StackCipherText: ::stack_encrypt::target::DecryptInto<__P, ::stack_encrypt::StackCipher<__K>, ::stack_encrypt::NonEmpty<__T>>, + __T: ::stack_encrypt::IntoAad<'__ctx> + ::core::clone::Clone + }); + assert_contains(&expansion, quote! { + <StackCipherText as ::stack_encrypt::target::DecryptInto<__P, ::stack_encrypt::StackCipher<__K>, ()>>::decrypt_into( self.c, __cipher, __context, ) }); assert_lacks(&expansion, quote!(hm)); } - #[test] - fn a_generic_plaintext_cannot_be_rebuilt_field_by_field() { - let err = expand(parse_quote! { - struct Row { - #[stash(decrypt, from = age)] - age: EncryptedAge, - } - }) - .unwrap_err(); - // `Record::parse` catches `from` without a named plaintext first; - // either message says what to add. - assert!(err.to_string().contains("plaintext type must be named")); - } - #[test] #[rustfmt::skip] fn whole_mode_opens_the_one_field() { @@ -824,12 +898,12 @@ mod tests { }) .unwrap(); assert_contains(&expansion, quote! { - impl<'__ctx, __K, __Ctx> ::stack_encrypt::target::DecryptInto<u32, ::stack_encrypt::StackCipher<__K>, __Ctx> for EncryptedAge + impl<'__ctx, __K, __T> ::stack_encrypt::target::DecryptInto<u32, ::stack_encrypt::StackCipher<__K>, ::stack_encrypt::NonEmpty<__T>> for EncryptedAge where - StackCipherText: ::stack_encrypt::target::DecryptInto<u32, ::stack_encrypt::StackCipher<__K>, __Ctx> + StackCipherText: ::stack_encrypt::target::DecryptInto<u32, ::stack_encrypt::StackCipher<__K>, ::stack_encrypt::NonEmpty<__T>> }); assert_contains(&expansion, quote! { - <StackCipherText as ::stack_encrypt::target::DecryptInto<u64, ::stack_encrypt::StackCipher<__K>, _>>::decrypt_into( + <StackCipherText as ::stack_encrypt::target::DecryptInto<u64, ::stack_encrypt::StackCipher<__K>, ()>>::decrypt_into( self.c, __cipher, __context, ) }); @@ -841,30 +915,35 @@ mod tests { #[rustfmt::skip] fn by_field_mode_rebuilds_the_plaintext() { let expansion = expand(parse_quote! { - #[stash(plaintext = User<T>)] + #[stash(struct = User<T>, context = "users")] struct EncryptedUser { - #[stash(decrypt, from = age, context = "users/age")] + #[stash(decrypt, context = "legacy/age")] age: EncryptedAge, - #[stash(decrypt, from = email)] - email: StackCipherText, - #[stash(from = email, context = "users/email")] + #[stash(decrypt, nested)] + email: EncryptedEmail, + #[stash(from = email)] email_eq: EqualityTerm, } }) .unwrap(); assert_contains(&expansion, quote! { - <EncryptedAge as ::stack_encrypt::target::DecryptInto<_, ::stack_encrypt::StackCipher<__K>, _>>::decrypt_into( - self.age, __cipher, "users/age", + <EncryptedAge as ::stack_encrypt::target::DecryptInto<_, ::stack_encrypt::StackCipher<__K>, ::stack_encrypt::NonEmpty<&'static str>>>::decrypt_into( + self.age, __cipher, ::stack_encrypt::nonempty!("legacy/age"), ) }); - // `email` has no literal: a `from` field is handed `()`, never the - // caller's context, and the leaf reports itself at the field if it - // cannot take that. The row is then for `()` exactly. - assert_contains(&expansion, quote!(self.email, __cipher, (),)); + // `email` is `nested`: it is handed the caller's context as it is — + // `()` in one impl, `NonEmpty<__T>` in the other — and its type + // composes it with its own contexts. A `struct` derive is bounded + // for `'static`. + assert_contains(&expansion, quote!(self.email, __cipher, __context,)); assert_contains(&expansion, quote! { impl<__K> ::stack_encrypt::target::DecryptInto<User<T>, ::stack_encrypt::StackCipher<__K>, ()> for EncryptedUser }); - assert_lacks(&expansion, quote!(SuppliedContext)); + assert_contains(&expansion, quote! { + impl<__K, __T> ::stack_encrypt::target::DecryptInto<User<T>, ::stack_encrypt::StackCipher<__K>, ::stack_encrypt::NonEmpty<__T>> for EncryptedUser + where + __T: ::stack_encrypt::IntoAad<'static> + ::core::clone::Clone + }); assert_contains(&expansion, quote!(.map(|(__field_0, __field_1)| User::<T> { age: __field_0, email: __field_1 }))); assert_lacks(&expansion, quote!(email_eq)); } @@ -872,11 +951,11 @@ mod tests { #[test] fn tuple_plaintexts_are_rebuilt_by_index() { let expansion = expand(parse_quote! { - #[stash(plaintext = Pair)] + #[stash(struct = Pair, context = "pair")] struct EncryptedPair { - #[stash(decrypt, from = 0, context = "pair/0")] + #[stash(decrypt, from = 0)] a: StackCipherText, - #[stash(decrypt, from = 1, context = "pair/1")] + #[stash(decrypt, from = 1)] b: StackCipherText, } }) @@ -893,7 +972,7 @@ mod tests { #[test] fn duplicate_recovery_targets_by_index_are_rejected() { let err = expand(parse_quote! { - #[stash(plaintext = Pair)] + #[stash(struct = Pair, context = "pair")] struct Rec { #[stash(decrypt, from = 0)] a: StackCipherText, diff --git a/packages/stack-encrypt-derive/src/encrypt.rs b/packages/stack-encrypt-derive/src/encrypt.rs index 2a81c251e..6f0c2c1b8 100644 --- a/packages/stack-encrypt-derive/src/encrypt.rs +++ b/packages/stack-encrypt-derive/src/encrypt.rs @@ -6,8 +6,8 @@ use syn::spanned::Spanned; use syn::{parse_quote, DeriveInput, Generics, Path, Result, Type}; use crate::shape::{ - impl_sources, push_context_generics, push_field_bounds, trait_impl, zip_fields, CallerContext, - Field, FieldBound, Kind, Record, + context_param, impl_sources, push_field_bounds, trait_impl, zip_fields, CallerContext, + ContextImpl, Field, FieldBound, Kind, Record, }; pub(crate) fn derive(input: DeriveInput) -> Result<TokenStream> { @@ -25,35 +25,71 @@ pub(crate) fn derive(input: DeriveInput) -> Result<TokenStream> { let decryptable = decryptable_impl(&input, &record, &derived); - // One impl per listed source, or one generic over it: the record accepts - // exactly the sources every derived field accepts, which the where clause - // spells out so a mismatch is reported against the field type. + // One impl per listed source, or one generic over it — and each of those + // twice, for `()` and for `NonEmpty<__T>` (see `context_param`). The + // record accepts exactly the sources every derived field accepts, which + // the where clause spells out so a mismatch is reported against the + // field type. let (sources, generic) = impl_sources(&record, parse_quote!(__S)); - let impls = sources.iter().map(|source| { - let mut generics = input.generics.clone(); - if generic { - generics.params.push(parse_quote!(__S)); + let mut impls = Vec::with_capacity(sources.len() * 2); + for source in &sources { + for which in ContextImpl::BOTH { + let mut generics = input.generics.clone(); + if generic { + generics.params.push(parse_quote!(__S)); + } + generics.params.push(parse_quote!(__K)); + push_field_bounds( + &mut generics, + krate, + &whole, + source, + FieldBound::Encrypt, + which, + ); + let ctx = context_param( + &mut generics, + CallerContext::Encrypt(krate), + which, + record.by_field, + &derived, + ); + let uses_context = derived.iter().any(|f| f.uses_callers_context(which)); + let body = body(krate, &record, &derived, source, which); + impls.push(impl_block( + &input, + krate, + &generics, + source, + &ctx, + uses_context, + body, + )); } - generics.params.push(parse_quote!(__K)); - push_field_bounds(&mut generics, krate, &whole, source, FieldBound::Encrypt); - let ctx = push_context_generics(&mut generics, CallerContext::Encrypt(krate), &derived); - let body = body(krate, &record, &derived, source); - impl_block(&input, krate, &generics, source, &ctx, body) - }); + } Ok(quote!(#(#impls)* #decryptable)) } /// `impl EncryptFrom<Source, StackCipher<__K>, Ctx> for Record` around -/// `body`; `ctx` is `__Ctx` or `()` ([`push_context_generics`]). +/// `body`; `ctx` is `()` or `NonEmpty<__T>` ([`context_param`]). The +/// context parameter is unnamed when no field uses it — every field of the +/// `()` impl of a `struct` derive carries its own — so the expansion warns +/// of nothing. fn impl_block( input: &DeriveInput, krate: &Path, generics: &Generics, source: &Type, ctx: &Type, + uses_context: bool, body: TokenStream, ) -> TokenStream { + let context = if uses_context { + quote!(__context) + } else { + quote!(_) + }; trait_impl( input, generics, @@ -62,7 +98,7 @@ fn impl_block( fn encrypt_from<'__a>( __source: &'__a #source, __cipher: &'__a #krate::StackCipher<__K>, - __context: #ctx, + #context: #ctx, ) -> #krate::target::Pending<'__a, Self, __K> where Self: '__a, @@ -107,7 +143,13 @@ fn decryptable_impl(input: &DeriveInput, record: &Record, derived: &[&Field]) -> /// The method body: every derived field's pending, zipped into one, mapped /// into `Self`. -fn body(krate: &Path, record: &Record, derived: &[&Field], source: &Type) -> TokenStream { +fn body( + krate: &Path, + record: &Record, + derived: &[&Field], + source: &Type, + which: ContextImpl, +) -> TokenStream { let assign = record.fields.iter().map(|field| { let member = &field.member; match &field.kind { @@ -121,19 +163,25 @@ fn body(krate: &Path, record: &Record, derived: &[&Field], source: &Type) -> Tok }); zip_fields( + krate, derived, + which, |field, context| { let ty = &field.ty; // A `from` field's source type is not known here; it is inferred // from the field expression, and the obligation checked there — - // spanned at the field type, so a leaf handed `()` (no literal) - // is reported at the field that needs a `context`. + // spanned at the field type, so a leaf handed `()` (a `nested` + // field) is reported at the field that needs a `context`. The + // context type is named, not inferred, so that report is the + // trait's own (`EncryptFrom`'s `on_unimplemented`) rather than + // an argument type mismatch inside the expansion. let (source_expr, source_ty): (TokenStream, TokenStream) = match field.from() { Some(from) => (quote!(&__source.#from), quote!(_)), None => (quote!(__source), quote!(#source)), }; + let context_ty = field.field_context().ty(krate, which); let call = quote_spanned! {ty.span()=> - <#ty as #krate::target::EncryptFrom<#source_ty, #krate::StackCipher<__K>, _>>::encrypt_from + <#ty as #krate::target::EncryptFrom<#source_ty, #krate::StackCipher<__K>, #context_ty>>::encrypt_from }; quote!(#call(#source_expr, __cipher, #context,)) }, @@ -196,26 +244,36 @@ mod tests { #[test] #[rustfmt::skip] - fn generic_source_bounds_every_whole_source_field() { + fn a_record_gets_one_impl_for_unit_and_one_for_non_empty() { let expansion = expand(parse_quote! { struct EncryptedAge { c: StackCipherText, hm: EqualityTerm, } }); - // Both fields take the caller's context: the impl is generic over - // it, bounded `EncryptContext` so a third-party leaf that is generic - // over its context cannot smuggle a non-context value through the - // record ([`CallerContext::Encrypt`]). + // Both fields take the caller's context as it is. Under `()` the + // field bounds are unsatisfiable for a leaf — which is the compile + // error `encrypt_into` reports — and under `NonEmpty<__T>` the inner + // type is bounded by the vitaminc context traits for a free + // lifetime: nothing here extends a literal, so a borrowed context + // passes through. assert_contains(&expansion, quote! { - impl<'__ctx, __S, __K, __Ctx> ::stack_encrypt::target::EncryptFrom<__S, ::stack_encrypt::StackCipher<__K>, __Ctx> + impl<__S, __K> ::stack_encrypt::target::EncryptFrom<__S, ::stack_encrypt::StackCipher<__K>, ()> for EncryptedAge where - StackCipherText: ::stack_encrypt::target::EncryptFrom<__S, ::stack_encrypt::StackCipher<__K>, __Ctx>, - EqualityTerm: ::stack_encrypt::target::EncryptFrom<__S, ::stack_encrypt::StackCipher<__K>, __Ctx>, - __Ctx: ::stack_encrypt::target::EncryptContext<'__ctx> + StackCipherText: ::stack_encrypt::target::EncryptFrom<__S, ::stack_encrypt::StackCipher<__K>, ()>, + EqualityTerm: ::stack_encrypt::target::EncryptFrom<__S, ::stack_encrypt::StackCipher<__K>, ()> }); - // The first field clones the record context, the last takes it. + assert_contains(&expansion, quote!(__context: (),)); + assert_contains(&expansion, quote! { + impl<'__ctx, __S, __K, __T> ::stack_encrypt::target::EncryptFrom<__S, ::stack_encrypt::StackCipher<__K>, ::stack_encrypt::NonEmpty<__T>> + for EncryptedAge + where + StackCipherText: ::stack_encrypt::target::EncryptFrom<__S, ::stack_encrypt::StackCipher<__K>, ::stack_encrypt::NonEmpty<__T>>, + EqualityTerm: ::stack_encrypt::target::EncryptFrom<__S, ::stack_encrypt::StackCipher<__K>, ::stack_encrypt::NonEmpty<__T>>, + __T: ::stack_encrypt::IntoAad<'__ctx> + ::stack_encrypt::IntoPrfContext<'__ctx> + ::core::clone::Clone + }); + // The first field clones the caller's context, the last takes it. assert_contains(&expansion, quote!(__source, __cipher, ::core::clone::Clone::clone(&__context),)); assert_contains(&expansion, quote!(__source, __cipher, __context,)); assert_contains(&expansion, quote!(.map(|(__field_0, __field_1)| Self { c: __field_0, hm: __field_1 }))); @@ -223,32 +281,42 @@ mod tests { #[test] #[rustfmt::skip] - fn a_caller_context_must_be_an_encrypt_context() { - // The regression shape, mirroring the decrypt-side test: the - // ciphertext field carries a literal, so only the term field sees - // the caller's context — and a third-party leaf generic over its - // context demands nothing of it. The impl-level bound is what keeps - // `EncryptFrom::encrypt_from` from accepting, and silently - // discarding, a value that is not a context at all. + fn a_literal_context_is_extended_by_the_callers() { let expansion = expand(parse_quote! { #[stash(plaintext = u32)] - struct Rec { - #[stash(context = "rec/c")] + struct Pinned { + #[stash(context = "legacy/age")] c: StackCipherText, - hm: EqualityTerm, } }); + // Under `()`: the literal as it is, a compile-time `NonEmpty`, and + // the (unit) context parameter unnamed. + assert_contains(&expansion, quote! { + impl<__K> ::stack_encrypt::target::EncryptFrom<u32, ::stack_encrypt::StackCipher<__K>, ()> for Pinned + where + StackCipherText: ::stack_encrypt::target::EncryptFrom<u32, ::stack_encrypt::StackCipher<__K>, ::stack_encrypt::NonEmpty<&'static str>> + }); + assert_contains(&expansion, quote!(_: (),)); + assert_contains(&expansion, quote!(__source, __cipher, ::stack_encrypt::nonempty!("legacy/age"),)); + // Under `NonEmpty<__T>`: the literal extended with the caller's, so + // no record accepts a context and then discards it; the literal + // fixes the pair's lifetime, so `__T` is bounded for `'static`. assert_contains(&expansion, quote! { - impl<'__ctx, __K, __Ctx> ::stack_encrypt::target::EncryptFrom<u32, ::stack_encrypt::StackCipher<__K>, __Ctx> for Rec + impl<__K, __T> ::stack_encrypt::target::EncryptFrom<u32, ::stack_encrypt::StackCipher<__K>, ::stack_encrypt::NonEmpty<__T>> for Pinned + where + StackCipherText: ::stack_encrypt::target::EncryptFrom<u32, ::stack_encrypt::StackCipher<__K>, ::stack_encrypt::NonEmpty<(&'static str, ::stack_encrypt::NonEmpty<__T>)>>, + __T: ::stack_encrypt::IntoAad<'static> + ::stack_encrypt::IntoPrfContext<'static> + ::core::clone::Clone }); assert_contains(&expansion, quote! { - __Ctx: ::stack_encrypt::target::EncryptContext<'__ctx> + __source, __cipher, + ::stack_encrypt::NonEmpty::with(::stack_encrypt::nonempty!("legacy/age"), __context), }); + assert_lacks(&expansion, quote!(_: ::stack_encrypt::NonEmpty<__T>)); } #[test] #[rustfmt::skip] - fn listed_sources_get_one_impl_each() { + fn listed_sources_get_two_impls_each() { let expansion = expand(parse_quote! { #[stash(plaintext = i32, plaintext = i64)] struct IntegerOrdOre { @@ -257,117 +325,98 @@ mod tests { v: SchemaVersion, } }); - assert_contains(&expansion, quote! { - impl<'__ctx, __K, __Ctx> ::stack_encrypt::target::EncryptFrom<i32, ::stack_encrypt::StackCipher<__K>, __Ctx> for IntegerOrdOre - }); - assert_contains(&expansion, quote! { - impl<'__ctx, __K, __Ctx> ::stack_encrypt::target::EncryptFrom<i64, ::stack_encrypt::StackCipher<__K>, __Ctx> for IntegerOrdOre - }); + for source in [quote!(i32), quote!(i64)] { + assert_contains(&expansion, quote! { + impl<__K> ::stack_encrypt::target::EncryptFrom<#source, ::stack_encrypt::StackCipher<__K>, ()> for IntegerOrdOre + }); + assert_contains(&expansion, quote! { + impl<'__ctx, __K, __T> ::stack_encrypt::target::EncryptFrom<#source, ::stack_encrypt::StackCipher<__K>, ::stack_encrypt::NonEmpty<__T>> for IntegerOrdOre + }); + } assert_contains(&expansion, quote!(Self { c: __field_0, v: SchemaVersion::V3 })); assert_lacks(&expansion, quote!(__S)); } #[test] #[rustfmt::skip] - fn row_fields_reach_into_the_source_under_their_own_context() { + fn a_struct_extends_its_inferred_contexts_with_the_callers() { let expansion = expand(parse_quote! { - #[stash(plaintext = User)] + #[stash(struct = User, context = "user")] struct EncryptedUser { - #[stash(from = age, context = "users/age")] age: EncryptedAge, - #[stash(from = email, context = "users/email")] email: StackCipherText, } }); - assert_contains(&expansion, quote! { - <EncryptedAge as ::stack_encrypt::target::EncryptFrom<_, ::stack_encrypt::StackCipher<__K>, _>>::encrypt_from( - &__source.age, __cipher, "users/age", - ) - }); - // No field takes the record's context, so the impl is for `()` - // exactly: `encrypt_into(&cipher)` compiles, and only that. - assert_lacks(&expansion, quote!(&__context)); + // Under `()`, the inferred contexts as they are; no field uses the + // caller's, so the parameter is unnamed. `from` fields carry no + // where clause: the plaintext field's type is unknown here, so the + // obligation is checked in the body instead. assert_contains(&expansion, quote! { impl<__K> ::stack_encrypt::target::EncryptFrom<User, ::stack_encrypt::StackCipher<__K>, ()> for EncryptedUser }); - assert_lacks(&expansion, quote!(__Ctx)); - // `from` fields carry no where clause: the source field's type is - // unknown here, so the obligation is checked in the body instead. - assert!( - expansion.replace(' ', "").contains("forEncryptedUser{fnencrypt_from"), - "unexpected where clause on the impl:\n{expansion}" - ); - } - - #[test] - #[rustfmt::skip] - fn a_from_field_without_a_literal_is_handed_no_context() { - let expansion = expand(parse_quote! { - #[stash(plaintext = User)] - struct EncryptedUser { - #[stash(from = age)] - age: EncryptedAge, - } + assert_contains(&expansion, quote!(_: (),)); + assert_contains(&expansion, quote! { + <EncryptedAge as ::stack_encrypt::target::EncryptFrom<_, ::stack_encrypt::StackCipher<__K>, ::stack_encrypt::NonEmpty<&'static str>>>::encrypt_from( + &__source.age, __cipher, ::stack_encrypt::nonempty!("user/age"), + ) }); - // The caller's context never reaches a `from` field: the field gets - // `()`, and its type decides (in the body, against the plaintext - // field's type) whether that is acceptable. The row itself is then - // for `()` too. - assert_contains(&expansion, quote!(&__source.age, __cipher, (),)); + assert_contains(&expansion, quote!(&__source.email, __cipher, ::stack_encrypt::nonempty!("user/email"),)); + // Under `NonEmpty<__T>`, each extended with the caller's — cloned to + // all but the last — and `__T` bounded for `'static`, the lifetime + // the literal fixes. assert_contains(&expansion, quote! { - impl<__K> ::stack_encrypt::target::EncryptFrom<User, ::stack_encrypt::StackCipher<__K>, ()> for EncryptedUser + impl<__K, __T> ::stack_encrypt::target::EncryptFrom<User, ::stack_encrypt::StackCipher<__K>, ::stack_encrypt::NonEmpty<__T>> for EncryptedUser + where + __T: ::stack_encrypt::IntoAad<'static> + ::stack_encrypt::IntoPrfContext<'static> + ::core::clone::Clone }); - assert_lacks(&expansion, quote!(SuppliedContext)); - } - - #[test] - #[rustfmt::skip] - fn a_whole_source_field_with_a_literal_is_bounded_under_it() { - let expansion = expand(parse_quote! { - #[stash(plaintext = u32)] - struct Rec { - #[stash(context = "rec/c")] - c: StackCipherText, - } + assert_contains(&expansion, quote! { + &__source.age, __cipher, + ::stack_encrypt::NonEmpty::with(::stack_encrypt::nonempty!("user/age"), ::core::clone::Clone::clone(&__context)), }); assert_contains(&expansion, quote! { - where - StackCipherText: ::stack_encrypt::target::EncryptFrom<u32, ::stack_encrypt::StackCipher<__K>, &'static str> + &__source.email, __cipher, + ::stack_encrypt::NonEmpty::with(::stack_encrypt::nonempty!("user/email"), __context), }); - assert_lacks(&expansion, quote!(EncryptContext)); + assert_lacks(&expansion, quote!('__ctx)); } #[test] #[rustfmt::skip] - fn a_row_needs_no_attributes_on_its_fields() { + fn a_nested_field_is_handed_the_callers_context() { let expansion = expand(parse_quote! { - #[stash(row = User, context = "user")] - struct EncryptedUser { - age: EncryptedAge, - email: StackCipherText, + #[stash(struct = Account, context = "accounts")] + struct EncryptedAccount { + #[stash(nested)] + user: EncryptedUser, + plan: StackCipherText, } }); + // `nested`: no inferred context; the caller's goes through as it + // is, and the inner `struct` derive composes it with its own. + assert_contains(&expansion, quote!(&__source.user, __cipher, ::core::clone::Clone::clone(&__context),)); assert_contains(&expansion, quote! { - <EncryptedAge as ::stack_encrypt::target::EncryptFrom<_, ::stack_encrypt::StackCipher<__K>, _>>::encrypt_from( - &__source.age, __cipher, "user/age", - ) - }); - assert_contains(&expansion, quote!(&__source.email, __cipher, "user/email",)); - assert_contains(&expansion, quote! { - impl<__K> ::stack_encrypt::target::EncryptFrom<User, ::stack_encrypt::StackCipher<__K>, ()> for EncryptedUser + &__source.plan, __cipher, + ::stack_encrypt::NonEmpty::with(::stack_encrypt::nonempty!("accounts/plan"), __context), }); + // Under `()` the nested field is the only one using the (unit) + // context, and takes it by move. + assert_contains(&expansion, quote!(&__source.user, __cipher, __context,)); + assert_contains(&expansion, quote!(&__source.plan, __cipher, ::stack_encrypt::nonempty!("accounts/plan"),)); } #[test] fn tuple_plaintexts_are_reached_by_index() { let expansion = expand(parse_quote! { - #[stash(plaintext = Pair)] + #[stash(struct = Pair, context = "pair")] struct EncryptedPair { - #[stash(from = 1, context = "pair/1")] + #[stash(from = 1)] b: StackCipherText, } }); - assert_contains(&expansion, quote!(&__source.1, __cipher, "pair/1",)); + assert_contains( + &expansion, + quote!(&__source.1, __cipher, ::stack_encrypt::nonempty!("pair/1"),), + ); } #[test] diff --git a/packages/stack-encrypt-derive/src/lib.rs b/packages/stack-encrypt-derive/src/lib.rs index 064978a46..aba02f815 100644 --- a/packages/stack-encrypt-derive/src/lib.rs +++ b/packages/stack-encrypt-derive/src/lib.rs @@ -15,7 +15,7 @@ //! ``` //! use stack_encrypt::sem::{EqualityTerm, OreTerm}; //! use stack_encrypt::target::EncryptInto; -//! use stack_encrypt::{DecryptInto, EncryptFrom, StackCipher, StackCipherText}; +//! use stack_encrypt::{nonempty, DecryptInto, EncryptFrom, StackCipher, StackCipherText}; //! use stack_kms::FakeDataKeySource; //! //! /// An encrypted integer, queryable by equality and range. @@ -33,9 +33,9 @@ //! .init() //! .await?; //! let record: EncryptedAge = 42u32 -//! .encrypt_into_with_context(&cipher, "users/age") +//! .encrypt_into_with_context(&cipher, nonempty!("users/age")) //! .await?; -//! let age: u32 = record.decrypt_into(&cipher, "users/age").await?; +//! let age: u32 = record.decrypt_into(&cipher, nonempty!("users/age")).await?; //! assert_eq!(age, 42); //! # Ok::<(), stack_encrypt::Error>(()) //! # }).unwrap(); @@ -50,11 +50,11 @@ //! batched ZeroKMS call. //! //! The record takes the caller's context because its fields do: the derive -//! bounds the impl's context parameter by what each field accepts, so a -//! record of leaves — which accept only a `SuppliedContext` — is encrypted -//! with `encrypt_into_with_context`, and the context-free `encrypt_into` -//! does not compile against it. That is decided by the field types, not by -//! an attribute. +//! emits one impl for `()` and one for `NonEmpty<T>`, each bounded by what +//! the fields accept under it, so a record of leaves — which accept only a +//! `NonEmpty<T>` — is encrypted with `encrypt_into_with_context`, and the +//! context-free `encrypt_into` does not compile against it. That is decided +//! by the field types, not by an attribute. //! //! Decryption opens the ciphertext field and passes over the terms, and no //! attribute says which is which: each field type does, through @@ -62,21 +62,21 @@ //! field is a ciphertext. `#[stash(decrypt)]` names the field only when the //! types cannot — two ciphertexts, say. //! -//! # Rows +//! # Structs, field by field //! //! One level up, the same derive: a struct whose fields are each derived from -//! a *field* of the plaintext, under a context of their own. `row = User, +//! a *field* of the plaintext, under a context of their own. `struct = User, //! context = "users"` says so once, for every field: `age` is derived from //! `user.age` under `"users/age"`, `email` from `user.email` under -//! `"users/email"` — the table you name and the field, nothing invented. +//! `"users/email"` — the prefix you name and the field, nothing invented. //! Attributes on the fields are for the exceptions: `from = ..` when the //! names differ, `context = ".."` to pin a whole context by hand, `nested` -//! for a field whose type is itself a row carrying its own contexts. +//! for a field whose type is itself such a struct, carrying its own contexts. //! //! ``` //! # use stack_encrypt::sem::{EqualityTerm, OreTerm}; //! # use stack_encrypt::target::{DecryptFrom, EncryptInto}; -//! # use stack_encrypt::{DecryptInto, EncryptFrom, StackCipher, StackCipherText}; +//! # use stack_encrypt::{nonempty, DecryptInto, EncryptFrom, StackCipher, StackCipherText}; //! # use stack_kms::FakeDataKeySource; //! # #[derive(EncryptFrom, DecryptInto)] //! # #[stash(plaintext = u32)] @@ -92,7 +92,7 @@ //! } //! //! #[derive(EncryptFrom, DecryptInto)] -//! #[stash(row = User, context = "users")] +//! #[stash(struct = User, context = "users")] //! struct EncryptedUser { //! age: EncryptedAge, //! email: StackCipherText, @@ -101,36 +101,58 @@ //! # tokio::runtime::Builder::new_current_thread().enable_all().build().unwrap().block_on(async { //! # let cipher = StackCipher::builder().kms(FakeDataKeySource::new()).init().await?; //! let user = User { age: 42, email: "alice@example.com".into() }; -//! let row: EncryptedUser = user.encrypt_into(&cipher).await?; // one batch +//! let encrypted: EncryptedUser = user.encrypt_into(&cipher).await?; // one batch //! let users = vec![User { age: 1, email: "a".into() }, User { age: 2, email: "b".into() }]; -//! let rows: Vec<EncryptedUser> = users.encrypt_into(&cipher).await?; // still one -//! let user = User::decrypt_from(row, &cipher).await?; +//! let column: Vec<EncryptedUser> = users.encrypt_into(&cipher).await?; // still one +//! let user = User::decrypt_from(encrypted, &cipher).await?; //! assert_eq!(user, User { age: 42, email: "alice@example.com".into() }); -//! assert_eq!(rows.len(), 2); +//! assert_eq!(column.len(), 2); +//! +//! // A context passed by the caller *extends* every field's: `age` is now +//! // under `("users/age", 7u64)` — bound to its record as well as its name +//! // — and a probe for it is built under the same pair. +//! let user = User { age: 42, email: "alice@example.com".into() }; +//! let encrypted: EncryptedUser = user.encrypt_into_with_context(&cipher, 7u64).await?; +//! let probe: EqualityTerm = 42u32 +//! .encrypt_into_with_context(&cipher, nonempty!("users/age").with(7u64)) +//! .await?; +//! assert_eq!(encrypted.age.hm, probe); +//! let user = User::decrypt_from_with_context(encrypted, &cipher, 7u64).await?; +//! assert_eq!(user.age, 42); //! # Ok::<(), stack_encrypt::Error>(()) //! # }).unwrap(); //! ``` //! -//! A row field's context is the *column's* identity — `"users/age"` is what a -//! query site derives a probe under — which is why it is a literal per field -//! rather than something composed from a context the caller passes. A row -//! takes no context from the caller at all: its impls are for `()` exactly, -//! which is what makes the context-free `encrypt_into` / `decrypt_from` the -//! forms that compile against it. A `plaintext = ..` record's `from` field -//! with no `context` is handed `()` too, and its type decides whether that -//! will do: a nested row accepts it; a leaf refuses it, at the field, until -//! it is given a `context`. In a row, `#[stash(nested)]` is the same -//! hand-off: it marks the fields whose types carry their own contexts, so no -//! context is inferred for them. +//! A field's context is the stored field's identity — `"users/age"` is what +//! a query site derives a probe under — which is why it is inferred per +//! field rather than taken from the caller. What the caller passes is an +//! *extension*: the derive emits one impl for `()`, deriving each field +//! under its own context as it is, and one for `NonEmpty<T>`, deriving it +//! under `("users/age", context)` — a record id, typically, so a field opens +//! only in the record it was written to. That holds for a `plaintext` +//! record's `context = ".."` literals too: no record accepts a context and +//! then discards it. A field with no context of its own — a `plaintext` +//! record's field with no `context`, or a `#[stash(nested)]` field — is +//! handed the caller's as it is, and its type decides whether that will do: +//! a nested `struct` derive composes it with its own contexts; a leaf +//! accepts only a `NonEmpty<T>`, so the `()` impl fails to compile at the +//! field until it is given a `context`. //! //! The prefix is given explicitly (`context = "users"`), never inferred from //! the Rust type's name: it is part of the stored data's identity — the AAD -//! of every ciphertext in the row and the domain of every term — and a name -//! two types share, or a refactor changes, must not be able to move it -//! silently. The field half is still inferred from the plaintext field's -//! name, so renaming a plaintext field changes that column's context and -//! stored rows stop decrypting; pin the old value with `context = ".."` on -//! the field before such a rename. The [attributes](#rows) section says more. +//! of every ciphertext derived from the struct and the domain of every term +//! — and a name two types share, or a refactor changes, must not be able to +//! move it silently. The field half is still inferred from the plaintext +//! field's name, so renaming a plaintext field changes that field's context +//! and stored data stops decrypting; pin the old value with `context = ".."` +//! on the field before such a rename. The [attributes](#structs-field-by-field) +//! section says more. +//! +//! `plaintext = T` and `struct = T` are the two shapes a derive can take, +//! and the derive cannot tell them apart from `T` — a proc macro sees the +//! name, not the definition — so the attribute says which: `plaintext` +//! derives every field from the whole value, `struct` reaches into its +//! fields. `from` and `nested` exist only with `struct`. //! //! # What the derive commits to //! diff --git a/packages/stack-encrypt-derive/src/shape.rs b/packages/stack-encrypt-derive/src/shape.rs index 7342f32f3..9aa684971 100644 --- a/packages/stack-encrypt-derive/src/shape.rs +++ b/packages/stack-encrypt-derive/src/shape.rs @@ -29,11 +29,14 @@ pub(crate) struct Field { pub(crate) enum Kind { /// Derived from the source through the field type's own `EncryptFrom`. Derived { - /// `#[stash(context = "...")]`: this field's context, overriding - /// the record's. + /// This field's own context, if it has one: a `#[stash(context = + /// "...")]` literal, or the `"<prefix>/<field>"` a `struct` derive + /// infers. A context the caller passes extends it either way. context: Option<LitStr>, - /// `#[stash(from = field)]` / `from = 0`: derived from one - /// field of the plaintext rather than the whole plaintext. + /// With `struct = ..`: the plaintext field this one is derived + /// from — its own name, or the `#[stash(from = field)]` override. + /// `None` for a `plaintext` record, whose fields are all derived + /// from the whole value. from: Option<Member>, }, /// Not derived: `Default::default()` or the given expression. @@ -56,78 +59,104 @@ impl Field { /// How this derived field gets its context — the one classification both /// derives project their where clauses and bodies from. /// - /// A `from` field with no literal is [`FieldContext::Unit`], never the - /// caller's: it reaches into one field of the plaintext, and its type - /// says what that field needs — a leaf refuses `()` (the derive cannot - /// name the plaintext field's type in a where clause, so the obligation - /// is checked in the body and reported at the field type), and a nested - /// row carrying its own contexts accepts nothing else. Handing such a - /// field the caller's context instead would encrypt every column of the - /// row under one context, which is the cross-column transplant the - /// per-field contexts exist to prevent. + /// A field with a context of its own — a literal, or the one a `struct` + /// derive infers — is derived under it as it is when the caller passes + /// `()`, and under it *extended* with the caller's (`("users/age", id)`) + /// when the caller passes a `NonEmpty<_>`. A field with none is handed + /// the caller's context as it is, and its type decides what that means: + /// a nested `struct` derive composes it with its own contexts; a leaf + /// accepts it only as a `NonEmpty<_>`, so under the record's `()` impl + /// such a leaf is a compile error — at the field, since a `from` field's + /// obligation is checked in the body against the plaintext field's type + /// the derive cannot name — and the fix is a `context = ".."` on it. /// /// Only called for derived fields: a `default` field is not derived from /// the source and is never handed a context at all. pub(crate) fn field_context(&self) -> FieldContext<'_> { match &self.kind { Kind::Derived { - context: Some(literal), - .. - } => FieldContext::Literal(literal), - Kind::Derived { - context: None, - from: Some(_), - } => FieldContext::Unit, - Kind::Derived { - context: None, - from: None, - } => FieldContext::Caller, + context: Some(lit), .. + } => FieldContext::Own(lit), + Kind::Derived { context: None, .. } => FieldContext::Caller, Kind::Default(_) => unreachable!("a `default` field has no context"), } } - /// Is this field derived under the context the caller passes for the - /// record? Only a field derived from the whole plaintext with no literal - /// of its own is; see [`push_context_generics`]. - pub(crate) fn takes_callers_context(&self) -> bool { - self.is_derived() && matches!(self.field_context(), FieldContext::Caller) + /// Does this field use the context the caller passes, in the impl for + /// `which`? A field with no context of its own always does; one with a + /// context of its own extends the caller's, so only under `NonEmpty<_>`. + /// See [`context_param`]. + pub(crate) fn uses_callers_context(&self, which: ContextImpl) -> bool { + self.is_derived() + && match self.field_context() { + FieldContext::Caller => true, + FieldContext::Own(_) => which == ContextImpl::NonEmpty, + } } } -/// Where a derived field's context comes from: a literal of its own, `()` -/// for a `from` field with no literal, or the caller's. See -/// [`Field::field_context`] for why a `from` field never gets the caller's. +/// Where a derived field's context comes from. See [`Field::field_context`]. #[cfg_attr(test, derive(Debug))] pub(crate) enum FieldContext<'a> { - /// `#[stash(context = "...")]`. - Literal(&'a LitStr), - /// A `from` field with no literal: handed `()`, and its type decides - /// whether that will do. - Unit, - /// The context the caller passes for the record, as the impl's `__Ctx`. + /// A context of the field's own — `#[stash(context = "...")]`, or the + /// `"<prefix>/<field>"` a `struct` derive infers: as it is under `()`, + /// extended with the caller's context under `NonEmpty<_>`. + Own(&'a LitStr), + /// No context of its own: handed the caller's as it is — `()`, or the + /// impl's `NonEmpty<__T>`. Caller, } +/// Which of a derived record's two impls is being emitted: the context the +/// caller passes is `()` in one and `NonEmpty<__T>` in the other. Every +/// record gets both (see [`context_param`]), and every derived field uses +/// the caller's context under `NonEmpty<__T>`, so no record accepts a +/// context it then discards; a leaf field that has no context of its own +/// makes the `()` one unsatisfiable, which is the compile error +/// `encrypt_into` then reports. +#[derive(Clone, Copy, PartialEq, Eq)] +#[cfg_attr(test, derive(Debug))] +pub(crate) enum ContextImpl { + Unit, + NonEmpty, +} + +impl ContextImpl { + pub(crate) const BOTH: [ContextImpl; 2] = [ContextImpl::Unit, ContextImpl::NonEmpty]; +} + impl FieldContext<'_> { - /// The context type as it appears in a where clause: the literal's, - /// `()`, or the impl's `__Ctx`. - pub(crate) fn ty(&self) -> Type { - match self { - FieldContext::Literal(_) => parse_quote!(&'static str), - FieldContext::Unit => parse_quote!(()), - FieldContext::Caller => parse_quote!(__Ctx), + /// The context type as it appears in a where clause, in the impl for + /// `which`. + pub(crate) fn ty(&self, krate: &Path, which: ContextImpl) -> Type { + match (self, which) { + (FieldContext::Own(_), ContextImpl::Unit) => { + parse_quote!(#krate::NonEmpty<&'static str>) + } + (FieldContext::Own(_), ContextImpl::NonEmpty) => { + parse_quote!(#krate::NonEmpty<(&'static str, #krate::NonEmpty<__T>)>) + } + (FieldContext::Caller, ContextImpl::Unit) => parse_quote!(()), + (FieldContext::Caller, ContextImpl::NonEmpty) => parse_quote!(#krate::NonEmpty<__T>), } } - /// The context expression the field is handed when it is not the - /// caller's; `None` for a field that takes the caller's, whose - /// expression is the call site's to choose (move, clone, or clone - /// through a reference). - pub(crate) fn own_expr(&self) -> Option<TokenStream> { - match self { - FieldContext::Literal(literal) => Some(quote!(#literal)), - FieldContext::Unit => Some(quote!(())), - FieldContext::Caller => None, + /// The context expression the field is handed in the impl for `which`; + /// `caller` is the expression for the caller's context, which the call + /// site chooses (move, clone, or clone through a reference) and which + /// only a field that [uses it](Field::uses_callers_context) receives. + pub(crate) fn expr( + &self, + krate: &Path, + which: ContextImpl, + caller: TokenStream, + ) -> TokenStream { + match (self, which) { + (FieldContext::Own(lit), ContextImpl::Unit) => quote!(#krate::nonempty!(#lit)), + (FieldContext::Own(lit), ContextImpl::NonEmpty) => { + quote!(#krate::NonEmpty::with(#krate::nonempty!(#lit), #caller)) + } + (FieldContext::Caller, _) => caller, } } } @@ -137,8 +166,11 @@ impl FieldContext<'_> { pub(crate) struct Record { pub(crate) krate: Path, /// The plaintext types, one impl each; empty means one impl generic over - /// the plaintext. + /// the plaintext. Exactly one for a `struct = ..` derive. pub(crate) plaintexts: Vec<Type>, + /// `struct = ..`: the plaintext is encrypted field by field, every + /// derived field from one field of it (`Field::from`). + pub(crate) by_field: bool, pub(crate) fields: Vec<Field>, } @@ -169,10 +201,11 @@ impl Record { }; // `ContainerAttrs::parse` has established that `context` is present - // exactly when `row` is. + // exactly when `struct` is. let fields = collect(&data.fields, attrs.context.as_ref())?; - let plaintexts = match attrs.row { - Some(row) => vec![row], + let by_field = attrs.by_field.is_some(); + let plaintexts = match attrs.by_field { + Some(plaintext) => vec![plaintext], None => attrs.plaintexts, }; @@ -183,72 +216,90 @@ impl Record { )); } - if plaintexts.is_empty() { - if let Some(field) = fields.iter().find(|f| f.from().is_some()) { - return Err(syn::Error::new( - field.from().map_or_else(Span::call_site, Spanned::span), - "`from = ..` reaches into a field of the plaintext, so the plaintext type must \ - be named: add `#[stash(plaintext = ..)]` to the struct", - )); - } - } - Ok(Self { krate: attrs.krate, plaintexts, + by_field, fields, }) } } -/// The demand a derive places on the impl's context parameter, beyond what -/// the field bounds already say. +/// The demand a derive places on the inner type of the caller's +/// `NonEmpty<__T>`, beyond what the field bounds already say — the vitaminc +/// context traits the direction needs, and `Clone` because one context +/// fans out to every field. +/// +/// The leaves in this crate state that demand through the field bounds +/// already, but a `from` field's bound is checked in the body (the derive +/// cannot name the plaintext field's type), and a term field's +/// `DecryptField` accepts *any* context (it opens nothing), so without +/// this bound a record whose ciphertext field carries a literal would +/// accept — and silently discard — a value that is not a context at all. pub(crate) enum CallerContext<'a> { - /// Encrypt: `EncryptContext` — convertible to AAD and to a PRF context. - /// The leaves in this crate state that demand through the field bounds - /// already, but a third-party leaf generic over its context would not, - /// and without this bound such a leaf lets `EncryptFrom::encrypt_from` - /// accept — and silently discard — any `Clone` value as its context. + /// Encrypt: convertible to AAD and to a PRF context. Encrypt(&'a Path), - /// Decrypt: `DecryptContext` — convertible to the AAD the value was - /// encrypted under. A term field's `DecryptField` accepts *any* context - /// (it opens nothing), so field bounds alone would let a record whose - /// ciphertext field carries a literal accept — and silently discard — - /// any `Clone` value as its decrypt context. + /// Decrypt: convertible to the AAD the value was encrypted under. Decrypt(&'a Path), } -/// Adds the impl's context parameter, if `fields` give it a use, and returns -/// the type the impl is for. +/// Adds the impl's context parameter for `which`, and returns the type the +/// impl is for. /// -/// A field with a context of its own ([`FieldContext::own_expr`]) never sees -/// the caller's. A record whose fields all have one — every row does — is -/// therefore encrypted with no context at all, and its impl is for `()` -/// exactly: `row.encrypt_into(&cipher)` compiles and -/// `encrypt_into_with_context` does not, since the context would go nowhere. -/// Otherwise the impl is generic over `__Ctx`, cloned to each field that -/// takes it, bounded by what `bound` says the direction demands. -pub(crate) fn push_context_generics( +/// Every derived record gets two impls: one for `()`, under which each +/// field is derived under the context it carries itself, and one for +/// `NonEmpty<__T>`, under which a row's inferred contexts are extended with +/// the caller's and a field with no context of its own is handed the +/// caller's as it is. The `()` impl of a record whose leaf takes the +/// caller's context is unsatisfiable — a leaf exists only under a +/// `NonEmpty<_>` — which is exactly the compile error `encrypt_into` reports +/// against it. +/// +/// Under `NonEmpty<__T>` an extended context is `NonEmpty<(&'static str, +/// NonEmpty<__T>)>`, and the literal's `'static` fixes the lifetime the pair +/// implements the context traits for; a `struct` derive's `nested` field +/// hands the caller's context to a nested `struct` derive that extends it +/// likewise, and its obligation is checked in the body, where a free +/// lifetime could not meet it. So a record with any context of its own, and +/// every `struct` derive, is bounded for `'static`. Only a `plaintext` +/// record whose fields all take the caller's context as it is — every +/// bound in the where clause — is bounded for a free lifetime, and can pass +/// a borrowed context through. +pub(crate) fn context_param( generics: &mut Generics, bound: CallerContext<'_>, + which: ContextImpl, + by_field: bool, fields: &[&Field], ) -> Type { - if !fields.iter().any(|f| f.takes_callers_context()) { + if which == ContextImpl::Unit { return parse_quote!(()); } - generics.params.push(parse_quote!(__Ctx)); + let krate = match bound { + CallerContext::Encrypt(krate) | CallerContext::Decrypt(krate) => krate, + }; + let needs_static = by_field + || fields + .iter() + .any(|f| matches!(f.field_context(), FieldContext::Own(_))); + let lifetime: syn::Lifetime = if needs_static { + parse_quote!('static) + } else { + // A lifetime parameter must precede the type parameters. + generics.params.insert(0, parse_quote!('__ctx)); + parse_quote!('__ctx) + }; + generics.params.push(parse_quote!(__T)); let predicates = &mut generics.make_where_clause().predicates; match bound { - CallerContext::Encrypt(krate) => { - predicates.push(parse_quote!(__Ctx: #krate::target::EncryptContext<'__ctx>)); - } - CallerContext::Decrypt(krate) => { - predicates.push(parse_quote!(__Ctx: #krate::target::DecryptContext<'__ctx>)); - } - } - // A lifetime parameter must precede the type parameters. - generics.params.insert(0, parse_quote!('__ctx)); - parse_quote!(__Ctx) + CallerContext::Encrypt(_) => predicates.push(parse_quote! { + __T: #krate::IntoAad<#lifetime> + #krate::IntoPrfContext<#lifetime> + ::core::clone::Clone + }), + CallerContext::Decrypt(_) => predicates.push(parse_quote! { + __T: #krate::IntoAad<#lifetime> + ::core::clone::Clone + }), + } + parse_quote!(#krate::NonEmpty<__T>) } /// `impl #trait_path for Record` around `content` — the scaffolding both @@ -280,29 +331,35 @@ pub(crate) enum FieldBound { /// whole source (the caller filters; a `from` field's obligation is /// checked in the body instead, where the source field's type is known). Encrypt, - /// `DecryptField<Plaintext, ..>` — for decrypt, on every candidate field. + /// `DecryptField<Plaintext, ..>` — for automatic decrypt, on every + /// candidate field. DecryptField, + /// `DecryptInto<Plaintext, ..>` — for explicit decrypt, on the one field + /// opened as the whole plaintext. + DecryptInto, } /// `FieldTy: Trait<Target, StackCipher<__K>, Ctx>` for each of `fields`, -/// under the context it is derived or opened under — its literal's, `()`, -/// or the caller's `__Ctx`, which is how a record inherits its leaves' -/// demand for a supplied context. +/// under the context it is derived or opened under in the impl for `which` +/// — its literal's, `()`, or the caller's `NonEmpty<__T>`, which is how a +/// record inherits its leaves' demand for a non-empty context. pub(crate) fn push_field_bounds( generics: &mut Generics, krate: &Path, fields: &[&Field], target: &Type, bound: FieldBound, + which: ContextImpl, ) { let trait_name: Ident = match bound { FieldBound::Encrypt => parse_quote!(EncryptFrom), FieldBound::DecryptField => parse_quote!(DecryptField), + FieldBound::DecryptInto => parse_quote!(DecryptInto), }; let predicates = &mut generics.make_where_clause().predicates; for field in fields { let ty = &field.ty; - let context = field.field_context().ty(); + let context = field.field_context().ty(krate, which); // Spanned at the field type, so a type that cannot be a field of the // record is reported there, not at the derive. predicates.push(parse_quote_spanned! {ty.span()=> @@ -326,30 +383,36 @@ pub(crate) fn impl_sources(record: &Record, generic: Ident) -> (Vec<Type>, bool) /// struct literal over the fields' locals). Nothing is awaited, so the record /// settles as one batched call. /// -/// `call(field, context)` renders one field's pending under `context`. The -/// record's context (`__context`) goes to every field without a context of -/// its own; the last such field takes it by move, the rest clone it. +/// `call(field, context)` renders one field's pending under `context`, the +/// expression [`FieldContext::expr`] gives the field in the impl for +/// `which`. The caller's context (`__context`) goes to every field that +/// uses it; the last such field takes it by move, the rest clone it. pub(crate) fn zip_fields( + krate: &Path, fields: &[&Field], + which: ContextImpl, mut call: impl FnMut(&Field, TokenStream) -> TokenStream, build: TokenStream, ) -> TokenStream { - let mut remaining = fields.iter().filter(|f| f.takes_callers_context()).count(); + let mut remaining = fields + .iter() + .filter(|f| f.uses_callers_context(which)) + .count(); let mut chain = TokenStream::new(); let mut pattern = TokenStream::new(); for (index, field) in fields.iter().enumerate() { - let context = match field.field_context().own_expr() { - Some(own) => own, - None => { - remaining -= 1; - if remaining == 0 { - quote!(__context) - } else { - quote!(::core::clone::Clone::clone(&__context)) - } + let caller = if field.uses_callers_context(which) { + remaining -= 1; + if remaining == 0 { + quote!(__context) + } else { + quote!(::core::clone::Clone::clone(&__context)) } + } else { + TokenStream::new() }; + let context = field.field_context().expr(krate, which, caller); let call = call(field, context); let local = &field.local; if index == 0 { @@ -364,13 +427,15 @@ pub(crate) fn zip_fields( quote!(#chain.map(|#pattern| #build)) } -/// The fields, with what a row (`row_context` is the container's `context` -/// prefix) fills in: `from` is the field's own name and `context` is -/// `"<row_context>/<from>"`, each unless the field gives its own. +/// The fields, with what a `struct` derive (`prefix` is the container's +/// `context`) fills in: `from` is the field's own name and `context` is +/// `"<prefix>/<from>"`, each unless the field gives its own. /// `#[stash(nested)]` opts a field out of the inferred context — it is handed -/// `()`, which a nested row (a type carrying its own contexts) accepts and a -/// leaf refuses. -fn collect(fields: &Fields, row_context: Option<&LitStr>) -> Result<Vec<Field>> { +/// the caller's as it is, which a nested `struct` derive (a type carrying its +/// own contexts) composes with them and a leaf accepts only as a +/// `NonEmpty<_>`. `from` and `nested` reach into the plaintext, so they +/// exist only with `struct = ..`. +fn collect(fields: &Fields, prefix: Option<&LitStr>) -> Result<Vec<Field>> { fields .iter() .enumerate() @@ -380,13 +445,40 @@ fn collect(fields: &Fields, row_context: Option<&LitStr>) -> Result<Vec<Field>> Some(ident) => Member::Named(ident.clone()), None => Member::Unnamed(syn::Index::from(index)), }; - if attrs.nested && row_context.is_none() { - return Err(syn::Error::new_spanned( - &field.ty, - "`nested` opts a row field out of its inferred context, so it applies only \ - with `row = ..` on the struct; a `plaintext` record's `from` field with no \ - `context` is already handed `()`", - )); + if prefix.is_none() { + if let Some(from) = &attrs.from { + return Err(syn::Error::new( + from.span(), + "`from = ..` reaches into a field of the plaintext, which is what \ + `#[stash(struct = ..)]` does: a `plaintext` record derives every field \ + from the whole value", + )); + } + if attrs.nested { + return Err(syn::Error::new_spanned( + &field.ty, + "`nested` opts a field out of the context a `struct` derive infers, so \ + it applies only with `struct = ..`; a `plaintext` record's field with no \ + `context` is already handed the caller's", + )); + } + } + // A literal context becomes a `nonempty!(..)`, which refuses an + // empty one at compile time anyway; say so here, at the + // attribute, with the alternative that applies. + if let Some(context) = &attrs.context { + if context.value().is_empty() { + let message = if prefix.is_some() { + "an empty `context` is rejected when a value is encrypted: name the \ + field (e.g. \"users/email\"), or drop the attribute to use the inferred \ + `\"<context>/<field>\"`" + } else { + "an empty `context` is rejected when a value is encrypted: name the \ + field (e.g. \"users/email\"), or drop the attribute to hand the field \ + the caller's context" + }; + return Err(syn::Error::new(context.span(), message)); + } } let kind = match attrs.default { Some(default) => { @@ -403,22 +495,22 @@ fn collect(fields: &Fields, row_context: Option<&LitStr>) -> Result<Vec<Field>> } Kind::Default(default) } - None => match row_context { - Some(row_context) => { + None => match prefix { + Some(prefix) => { let from = attrs.from.unwrap_or_else(|| member.clone()); let context = if attrs.nested { // The field's type carries its own contexts; it - // is handed `()` (`FieldContext::Unit`). + // is handed the caller's (`FieldContext::Caller`). None + } else if let Some(lit) = attrs.context { + Some(lit) } else { - Some(attrs.context.unwrap_or_else(|| { - let column = match &from { - Member::Named(ident) => ident.to_string(), - Member::Unnamed(index) => index.index.to_string(), - }; - let prefix = row_context.value(); - LitStr::new(&format!("{prefix}/{column}"), member.span()) - })) + let column = match &from { + Member::Named(ident) => ident.to_string(), + Member::Unnamed(index) => index.index.to_string(), + }; + let prefix = prefix.value(); + Some(LitStr::new(&format!("{prefix}/{column}"), member.span())) }; Kind::Derived { context, @@ -427,7 +519,7 @@ fn collect(fields: &Fields, row_context: Option<&LitStr>) -> Result<Vec<Field>> } None => Kind::Derived { context: attrs.context, - from: attrs.from, + from: None, }, }, }; @@ -451,11 +543,11 @@ mod tests { Record::parse(&input) } - /// The field's literal context, for assertions. - fn literal(field: &Field) -> String { + /// The field's own context, for assertions. + fn own(field: &Field) -> String { match field.field_context() { - FieldContext::Literal(lit) => lit.value(), - other => panic!("expected a literal context, got {other:?}"), + FieldContext::Own(lit) => lit.value(), + other => panic!("expected a context of the field's own, got {other:?}"), } } @@ -481,15 +573,32 @@ mod tests { } #[test] - fn from_needs_a_named_plaintext() { - let err = parse(parse_quote! { - struct Row { - #[stash(from = age)] - age: EncryptedAge, - } - }) - .unwrap_err(); - assert!(err.to_string().contains("plaintext type must be named")); + fn from_applies_only_with_struct() { + // `from` reaches into the plaintext, which is what `struct = ..` + // means; a `plaintext` record derives every field from the whole + // value, named or not. + for input in [ + parse_quote! { + struct Row { + #[stash(from = age)] + age: EncryptedAge, + } + }, + parse_quote! { + #[stash(plaintext = User)] + struct Row { + #[stash(from = age, context = "users/age")] + age: EncryptedAge, + } + }, + ] { + let err = parse(input).unwrap_err(); + assert!( + err.to_string() + .contains("what `#[stash(struct = ..)]` does"), + "{err}" + ); + } } #[test] @@ -532,7 +641,7 @@ mod tests { // typed) plaintext field — the crossed-field failure the derive // exists to prevent — so every singular attribute rejects a repeat. let err = parse(parse_quote! { - #[stash(plaintext = User)] + #[stash(struct = User, context = "users")] struct Rec { #[stash(from = expected, from = other)] c: StackCipherText, @@ -588,10 +697,19 @@ mod tests { }) .unwrap_err(); assert!(err.to_string().contains("`crate` is given twice")); + + let err = parse(parse_quote! { + #[stash(struct = User, struct = User, context = "users")] + struct Rec { + c: StackCipherText, + } + }) + .unwrap_err(); + assert!(err.to_string().contains("`struct` is given twice")); } #[test] - fn a_literal_empty_context_is_rejected() { + fn a_literal_empty_context_is_rejected_with_the_alternative_that_applies() { let err = parse(parse_quote! { struct Rec { #[stash(context = "")] @@ -600,39 +718,20 @@ mod tests { }) .unwrap_err(); assert!(err.to_string().contains("empty `context`")); - assert!(err.to_string().contains("drop the attribute")); - } + assert!(err + .to_string() + .contains("hand the field the caller's context")); - #[test] - fn an_empty_context_on_a_from_field_gets_from_specific_advice() { - // "Drop the attribute" is a dead end for a `from` field — it is - // never handed the record's context — so the advice must not offer - // it, whichever order the attributes were written in. - for input in [ - parse_quote! { - #[stash(plaintext = User)] - struct Row { - #[stash(from = email, context = "")] - email: StackCipherText, - } - }, - parse_quote! { - #[stash(plaintext = User)] - struct Row { - #[stash(context = "", from = email)] - email: StackCipherText, - } - }, - ] { - let err = parse(input).unwrap_err(); - let message = err.to_string(); - assert!(message.contains("empty `context`"), "{message}"); - assert!( - message.contains("never handed the record's context"), - "{message}" - ); - assert!(!message.contains("drop the attribute"), "{message}"); - } + let err = parse(parse_quote! { + #[stash(struct = User, context = "users")] + struct Rec { + #[stash(context = "")] + email: StackCipherText, + } + }) + .unwrap_err(); + assert!(err.to_string().contains("empty `context`")); + assert!(err.to_string().contains("use the inferred")); } #[test] @@ -660,25 +759,9 @@ mod tests { } #[test] - fn from_addresses_tuple_plaintexts_by_index() { - let record = parse(parse_quote! { - #[stash(plaintext = Pair)] - struct Rec { - #[stash(from = 0, context = "pair/0")] - a: StackCipherText, - #[stash(from = 1, context = "pair/1")] - b: StackCipherText, - } - }) - .unwrap(); - assert!(matches!(record.fields[0].from(), Some(Member::Unnamed(i)) if i.index == 0)); - assert!(matches!(record.fields[1].from(), Some(Member::Unnamed(i)) if i.index == 1)); - } - - #[test] - fn a_row_fills_in_from_and_context() { + fn a_struct_fills_in_from_and_context() { let record = parse(parse_quote! { - #[stash(row = crate::model::UserProfile<T>, context = "user_profiles")] + #[stash(struct = crate::model::UserProfile<T>, context = "user_profiles")] struct EncryptedUser { age: EncryptedAge, #[stash(from = email_address)] @@ -692,6 +775,7 @@ mod tests { } }) .unwrap(); + assert!(record.by_field); assert_eq!(record.plaintexts.len(), 1); let (age, email, name, address, version) = ( &record.fields[0], @@ -702,39 +786,43 @@ mod tests { ); // Own name under the container's prefix. assert!(matches!(age.from(), Some(Member::Named(m)) if m == "age")); - assert_eq!(literal(age), "user_profiles/age"); + assert_eq!(own(age), "user_profiles/age"); // `from` overrides the field; the context follows the plaintext field. assert!(matches!(email.from(), Some(Member::Named(m)) if m == "email_address")); - assert_eq!(literal(email), "user_profiles/email_address"); - // `context` is taken verbatim. + assert_eq!(own(email), "user_profiles/email_address"); + // `context` is taken verbatim; like the inferred ones, the caller's + // context extends it. assert!(matches!(name.from(), Some(Member::Named(m)) if m == "name")); - assert_eq!(literal(name), "legacy/name"); - // `nested`: no inferred context — the field is handed `()`. + assert_eq!(own(name), "legacy/name"); + assert!(name.uses_callers_context(ContextImpl::NonEmpty)); + assert!(!name.uses_callers_context(ContextImpl::Unit)); + // `nested`: no inferred context — the field is handed the caller's. assert!(matches!(address.from(), Some(Member::Named(m)) if m == "address")); - assert!(matches!(address.field_context(), FieldContext::Unit)); + assert!(matches!(address.field_context(), FieldContext::Caller)); + assert!(address.uses_callers_context(ContextImpl::Unit)); assert!(!version.is_derived()); } #[test] - fn a_tuple_row_is_reached_and_named_by_index() { + fn a_tuple_struct_is_reached_and_named_by_index() { let record = parse(parse_quote! { - #[stash(row = Reading, context = "readings")] + #[stash(struct = Reading, context = "readings")] struct EncryptedReading(EncryptedAge, StackCipherText); }) .unwrap(); assert!(matches!(record.fields[1].from(), Some(Member::Unnamed(i)) if i.index == 1)); - assert_eq!(literal(&record.fields[0]), "readings/0"); - assert_eq!(literal(&record.fields[1]), "readings/1"); + assert_eq!(own(&record.fields[0]), "readings/0"); + assert_eq!(own(&record.fields[1]), "readings/1"); } #[test] - fn a_row_requires_a_container_context() { + fn a_struct_requires_a_container_context() { // The prefix is part of the stored data's identity, so it is never // inferred from the Rust type's name: two types named `Account` in - // different modules would otherwise silently share every column + // different modules would otherwise silently share every field // context. let err = parse(parse_quote! { - #[stash(row = User)] + #[stash(struct = User)] struct EncryptedUser { age: EncryptedAge, } @@ -742,11 +830,11 @@ mod tests { .unwrap_err(); let message = err.to_string(); assert!(message.contains("needs a `context = \"..\"`"), "{message}"); - assert!(message.contains("naming the table"), "{message}"); + assert!(message.contains("naming the stored data"), "{message}"); } #[test] - fn a_container_context_requires_a_row() { + fn a_container_context_requires_a_struct() { let err = parse(parse_quote! { #[stash(plaintext = User, context = "users")] struct Rec { @@ -754,7 +842,7 @@ mod tests { } }) .unwrap_err(); - assert!(err.to_string().contains("applies only with `row = ..`")); + assert!(err.to_string().contains("applies only with `struct = ..`")); let err = parse(parse_quote! { #[stash(context = "users")] @@ -763,37 +851,35 @@ mod tests { } }) .unwrap_err(); - assert!(err.to_string().contains("applies only with `row = ..`")); + assert!(err.to_string().contains("applies only with `struct = ..`")); } #[test] fn an_empty_container_context_is_rejected() { let err = parse(parse_quote! { - #[stash(row = User, context = "")] + #[stash(struct = User, context = "")] struct EncryptedUser { age: EncryptedAge, } }) .unwrap_err(); - assert!(err.to_string().contains("name the table")); + assert!(err.to_string().contains("name the stored data")); } #[test] - fn nested_applies_only_in_a_row_and_excludes_context() { - // Outside a row it is at best redundant (`from` with no `context` is - // already handed `()`), so it is rejected rather than ignored. + fn nested_applies_only_with_struct_and_excludes_context() { let err = parse(parse_quote! { #[stash(plaintext = User)] struct Rec { - #[stash(nested, from = user)] + #[stash(nested)] user: EncryptedUser, } }) .unwrap_err(); - assert!(err.to_string().contains("applies only with `row = ..`")); + assert!(err.to_string().contains("applies only with `struct = ..`")); let err = parse(parse_quote! { - #[stash(row = Account, context = "accounts")] + #[stash(struct = Account, context = "accounts")] struct Rec { #[stash(nested, context = "accounts/user")] user: EncryptedUser, @@ -804,21 +890,21 @@ mod tests { } #[test] - fn row_and_plaintext_are_exclusive() { + fn struct_and_plaintext_are_exclusive() { let err = parse(parse_quote! { - #[stash(row = User, plaintext = User)] + #[stash(struct = User, plaintext = User)] struct Rec { age: EncryptedAge, } }) .unwrap_err(); - assert!(err.to_string().contains("give `row = ..` alone")); + assert!(err.to_string().contains("give one of them")); } #[test] - fn a_row_must_name_a_struct_directly() { + fn a_struct_must_name_a_struct_directly() { let err = parse(parse_quote! { - #[stash(row = &User)] + #[stash(struct = &User)] struct Rec { age: EncryptedAge, } @@ -827,7 +913,7 @@ mod tests { assert!(err.to_string().contains("must name a struct directly")); let err = parse(parse_quote! { - #[stash(row = <T as Trait>::Row)] + #[stash(struct = <T as Trait>::Row)] struct Rec { age: EncryptedAge, } @@ -837,28 +923,30 @@ mod tests { } #[test] - fn fields_classify() { + fn plaintext_fields_classify() { let record = parse(parse_quote! { - #[stash(plaintext = User, plaintext = Admin)] - struct Row { - #[stash(from = age, context = "users/age", decrypt)] - age: EncryptedAge, - whole: RowTerm, + #[stash(plaintext = u32, plaintext = u64)] + struct Rec { + #[stash(context = "users/age", decrypt)] + c: StackCipherText, + hm: EqualityTerm, #[stash(default = SchemaVersion::V3)] v: SchemaVersion, } }) .unwrap(); + assert!(!record.by_field); assert_eq!(record.plaintexts.len(), 2); assert_eq!(record.fields.len(), 3); - assert!(matches!(record.fields[0].from(), Some(Member::Named(name)) if name == "age")); - assert!(matches!( - record.fields[0].field_context(), - FieldContext::Literal(literal) if literal.value() == "users/age" - )); + // Nothing is derived from a field of the plaintext. + assert!(record.fields.iter().all(|f| f.from().is_none())); + assert_eq!(own(&record.fields[0]), "users/age"); assert!(record.fields[0].decrypt); assert!(record.fields[1].is_derived()); - assert!(record.fields[1].from().is_none()); + assert!(matches!( + record.fields[1].field_context(), + FieldContext::Caller + )); assert!(!record.fields[2].is_derived()); } } diff --git a/packages/stack-encrypt/CONTEXT.md b/packages/stack-encrypt/CONTEXT.md new file mode 100644 index 000000000..e712205dc --- /dev/null +++ b/packages/stack-encrypt/CONTEXT.md @@ -0,0 +1,73 @@ +# Stack Encrypt + +Client-side encryption of values under per-value ZeroKMS data keys, and the +derivation of searchable index terms from the same values. Covers +`stack-encrypt`, `stack-encrypt-derive`, and the WASI guest in +`bindings/go/stackencrypt/guest` that exposes them to Go. + +## Language + +**Cipher-directed**: +Encryption driven by the value's shape: the value's `Encrypt` implementation +walks the cipher and the caller decides the context, which may be absent. +_Avoid_: raw path, low-level path + +**Target-directed**: +Encryption driven by the output type: the type being produced (a ciphertext, a +term, a record) declares what it is derived from and which context it demands. +_Avoid_: typed path, high-level path + +**Context**: +The value a ciphertext is authenticated under and a term is derived under. A +leaf takes a `NonEmpty<T>` — vitaminc's proof that the value carries caller +bytes — and nothing else; a `nonempty!("users/email")` literal, a +`NonEmpty::new(value)?` at runtime, or a bare integer. It becomes the +ciphertext's associated data, the term's PRF context, and the ZeroKMS +descriptor of the data key. +_Avoid_: AAD (that is one of its encodings, not the concept), lock context + +**Own context**: +The context a field carries itself: a `context = ".."` literal, or the one a +`struct = ..` derive infers as `<struct context>/<field>`. A caller's context +*extends* it (`("users/age", id)`); it is never discarded. +_Avoid_: default context, field prefix + +**Descriptor**: +The context, rendered as the string ZeroKMS binds into every data key and +logs per retrieval, rendered from the context's parts: plain text verbatim, +integers by their width, sign-blind (`7u64`, and `7i64` is `7u64`), a +composite's parts joined by `|` (`users/email|7u64`); text that could read as +another form is `b64:`-escaped, and an empty part inside a list is the bare +`b64:`. Injective over encodings, and finer than them for a pre-encoded +`Aad` (opaque bytes) and for shapes that encode alike (`None` vs `0u64`): +seal and open must present the context in the same shape. +_Avoid_: key name, key id + +**Leaf**: +An output type that authenticates or derives directly — a ciphertext or a +single index term — and therefore owes nothing to a context but the one it is +handed. +_Avoid_: primitive, scalar output + +**Record**: +An output type assembled from leaves derived from one plaintext (a `plaintext += T` derive); a **struct record** (a `struct = T` derive) is one whose fields +are each derived from one field of the plaintext under their own context. +_Avoid_: composite, struct (the plaintext is the struct; the record is derived from it) + +**EQL type**: +An output type that participates in EQL — a ciphertext or index term stored +for query — and so carries the contract that its context is supplied and +non-empty. Every leaf and record in the target-directed path is one. +_Avoid_: searchable type, indexed type + +**Term**: +A deterministic, one-way index value derived from a plaintext under a +context — equality, match, ORE or OPE. +_Avoid_: index, token, hash + +**Pending**: +An output whose local work (term derivation, per-leaf sealing plan) is done +and whose ZeroKMS key requests are queued but not sent. Pendings compose +(`zip`, `map`, `all`) so a whole struct or `Vec` settles in one batched call. +_Avoid_: future, promise diff --git a/packages/stack-encrypt/Cargo.toml b/packages/stack-encrypt/Cargo.toml index 61de3b881..df56f781a 100644 --- a/packages/stack-encrypt/Cargo.toml +++ b/packages/stack-encrypt/Cargo.toml @@ -10,8 +10,7 @@ keywords.workspace = true categories.workspace = true license-file = "LICENSE" # Not yet released: keep release-plz from picking this crate up (it processes -# any workspace crate whose Cargo.toml lacks `publish = false`), and the git -# vitaminc deps below have no registry version to publish against anyway. +# any workspace crate whose Cargo.toml lacks `publish = false`). publish = false [dependencies] @@ -26,23 +25,21 @@ stack-auth = { workspace = true, optional = true } # `#[derive(EncryptFrom)]` / `#[derive(DecryptInto)]`, re-exported from `target`. stack-encrypt-derive = { path = "../stack-encrypt-derive" } -# The revised Cipher/Decipher traits, ContextTag, Element, and the PRF crates -# have all merged to vitaminc main but are not yet published (crates.io is 200+ -# commits behind). Pinned to a commit rather than `branch = "main"` so a -# `cargo update` cannot silently move the trait definitions under this crate, -# and declared here rather than via the workspace dep so the rest of the suite -# stays on the published 0.2.0-pre until the next vitaminc release. -# -# Temporarily pinned to the commit of cipherstash/vitaminc#289 (main + the -# `vitaminc_aead::Passthrough` wrapper); move the pin back to a main commit -# once it merges. All five must share one source or the aead crate is -# duplicated. -vitaminc-aead = { git = "https://github.com/cipherstash/vitaminc", rev = "04f4faa3eeeb69ae30dd0247865e06c640585fea" } -vitaminc-encrypt = { git = "https://github.com/cipherstash/vitaminc", rev = "04f4faa3eeeb69ae30dd0247865e06c640585fea" } -vitaminc-hmac = { git = "https://github.com/cipherstash/vitaminc", rev = "04f4faa3eeeb69ae30dd0247865e06c640585fea" } -vitaminc-prf = { git = "https://github.com/cipherstash/vitaminc", rev = "04f4faa3eeeb69ae30dd0247865e06c640585fea" } -vitaminc-protected = { git = "https://github.com/cipherstash/vitaminc", rev = "04f4faa3eeeb69ae30dd0247865e06c640585fea" } +# vitaminc 0.3.0 from the registry: the first release with `NonEmpty::with`, +# `From<integer> for NonEmpty` (cipherstash/vitaminc#314) and `IntoAadPiece` +# (#318), all of which this crate relies on. The rest of the suite builds +# against a different vitaminc line (`cts-domain`, `stack-kms`); 0.x minors +# are distinct to cargo, so this is a second copy and nothing depends on +# both. All five must share one version or the aead crate is duplicated +# within stack-encrypt itself. +vitaminc-aead = "0.3.0" +vitaminc-encrypt = "0.3.0" +vitaminc-hmac = "0.3.0" +vitaminc-prf = "0.3.0" +vitaminc-protected = "0.3.0" serde = { workspace = true } +# `Descriptor`: the base64 rendering of a non-textual context for ZeroKMS. +base64ct = { version = "1.7", features = ["alloc"] } cllw-ore = { workspace = true } thiserror = { workspace = true } @@ -69,9 +66,6 @@ stack-kms = { path = "../stack-kms", default-features = false, features = ["test tokio = { workspace = true, features = ["rt", "macros"] } # Compile-fail tests for the derive diagnostics (`tests/ui`). trybuild = "1" -vitaminc-hmac = { git = "https://github.com/cipherstash/vitaminc", rev = "04f4faa3eeeb69ae30dd0247865e06c640585fea" } -vitaminc-prf = { git = "https://github.com/cipherstash/vitaminc", rev = "04f4faa3eeeb69ae30dd0247865e06c640585fea" } -vitaminc-protected = { git = "https://github.com/cipherstash/vitaminc", rev = "04f4faa3eeeb69ae30dd0247865e06c640585fea" } [[example]] name = "encrypted_record" diff --git a/packages/stack-encrypt/docs/adr/0001-context-optional-cipher-directed-path.md b/packages/stack-encrypt/docs/adr/0001-context-optional-cipher-directed-path.md new file mode 100644 index 000000000..a955227b6 --- /dev/null +++ b/packages/stack-encrypt/docs/adr/0001-context-optional-cipher-directed-path.md @@ -0,0 +1,59 @@ +--- +status: accepted +date: 2026-09-03 +--- + +# The cipher-directed path takes any context; the target-directed path takes a `NonEmpty` + +`StackCipher::encrypt` / `decrypt` / `decipher` (the cipher-directed path) accept +any `IntoAad`, including `()`, exactly as vitaminc's `Aes256Cipher` does: sealing +under no associated data is a legitimate AEAD use, and the same `StackCipherText` +type opens symmetrically. The requirement that a context be **supplied and +non-empty** is a property of EQL types — ciphertexts and index terms stored for +query, where an empty context would make ciphertexts transplantable between +fields and collapse per-field term domains — so it is enforced on *their* +`EncryptFrom` / `DecryptInto` / term-generator implementations, by type (a leaf +is implemented for `vitaminc_protected::NonEmpty<T>` alone; the crate owns no +context trait of its own), and nowhere else. + +The WASI guest mirrors the split: its value exports (`se_encrypt` and friends) +are the cipher-directed path and take any AAD, a `nil` Go slice included; its +record and term exports parse their context into a `NonEmpty` and refuse an +empty one with `STATUS_ENCODING`. + +## Considered options + +- **Require `NonEmpty` on the cipher-directed path too** (remove the public + `Cipher` impl, route every seal through a `StackCipher` method that takes a + `NonEmpty`). Rejected: it forces a context on callers that are not producing + EQL types, and diverges from the vitaminc cipher contract the type is meant to + mirror. +- **Re-check emptiness on the encoded bytes** (the previous `is_degenerate_aad` + / `is_degenerate_prf_context` predicates). Rejected: an encoded context can + only be judged on its bytes, and framing makes empty composites non-empty as + bytes; vitaminc deliberately keeps `MaybeEmpty` (`IsEmpty` before 0.3.0) off `Aad` and `PrfContext` + for that reason. + +## Consequences + +- A tree sealed cipher-directed under `()` and opened target-directed under a + `NonEmpty` fails as an ordinary context mismatch — ZeroKMS refuses the key + retrieval under the other descriptor (`Error::Kms`), and a key source that + ignores descriptors lets it reach the AEAD (`Error::Aead`) — not as a + special case. The two paths are different contracts on one ciphertext type; + a front-end that seals cipher-directed but opens target-directed (the WASI + guest's record plans) binds the same `NonEmpty` value on both sides. +- `0u64` and eight zero bytes are valid contexts: vitaminc's rule is that an + integer is never empty. The byte collision the old predicate guarded + against is real — `None::<&str>` encodes as `pae([])`, eight zero bytes, + the same as `0u64`, and `None` is constructible on the cipher-directed path + and inside a `NonEmpty` tuple — but it is the AEAD's collision, not the + crate's to police: the two shapes render to different descriptors (`()` and + `0u64`), so ZeroKMS binds them to different keys and a cross-open is + refused there. See the descriptor module docs. +- The context is also the ZeroKMS descriptor of every data key + (`Descriptor::from_piece`), so a cipher-directed seal under `()` requests its + keys under the empty descriptor. That is the caller's choice, made visible + in the ZeroKMS log. +- Future architecture reviews should not re-propose "closing" the + cipher-directed path; the asymmetry is the design. diff --git a/packages/stack-encrypt/examples/encrypted_record.rs b/packages/stack-encrypt/examples/encrypted_record.rs index 097f40a7c..edc24c964 100644 --- a/packages/stack-encrypt/examples/encrypted_record.rs +++ b/packages/stack-encrypt/examples/encrypted_record.rs @@ -1,17 +1,18 @@ -//! A searchable encrypted record, end to end. +//! A searchable encrypted struct, end to end. //! -//! The point of target-directed encryption: define a record type that *is* -//! "the ciphertext plus the index terms this field needs", implement -//! `EncryptFrom` once (the shape `#[derive(EncryptFrom)]` would -//! emit), and every insert is one `encrypt_into(..).await`. A tiny in-memory -//! "table" then answers equality and range queries purely by comparing terms -//! — decrypting only the rows that match. +//! The point of target-directed encryption: define the encrypted *shape* of +//! a value — "the ciphertext plus the index terms this field needs" — derive +//! `EncryptFrom` / `DecryptInto` for it, and every insert is one +//! `encrypt_into(..).await`. A tiny in-memory "table" then answers equality +//! and range queries purely by comparing terms, decrypting only the rows +//! that match. //! -//! The async shape is the other half of the point: `encrypt_from` does no -//! I/O. It derives the terms locally and combines the field pendings with -//! `zip`/`map` — so a whole *column* of records, encrypted through the -//! `Vec` implementation, settles in **one** batched ZeroKMS call, and the -//! matching rows decrypt in one more. +//! The async shape is the other half of the point: nothing here does I/O +//! until the `.await`. Terms derive locally; each ciphertext queues its +//! data-key request; the derive combines the field pendings with `zip` / +//! `map` — so a whole `Vec` of structs, encrypted through the `Vec` +//! implementation, settles in **one** batched ZeroKMS call, and the matching +//! rows decrypt in one more. //! //! Run with: //! @@ -26,134 +27,173 @@ //! `zerokms_auth` example for the lookup order). use stack_encrypt::sem::{EqualityTerm, OreTerm}; -use stack_encrypt::target::{DecryptInto, EncryptFrom, EncryptInto, Pending}; -use stack_encrypt::{StackCipher, StackCipherText}; +use stack_encrypt::target::{DecryptFrom, EncryptInto}; +use stack_encrypt::{nonempty, DecryptInto, EncryptFrom, Error, StackCipher, StackCipherText}; + +// --- The shapes ---------------------------------------------------------------- /// "An encrypted `u32`, stored as its ciphertext plus an equality term and an /// ORE term." The same shape as an EQL `integer_ord_ore` payload, minus the -/// EQL wire encoding. -struct EncryptedInt { - ciphertext: StackCipherText, +/// EQL wire encoding. Every field is derived from the one `u32`, under one +/// context: the context authenticates the ciphertext (AAD) and +/// domain-separates both terms (PRF context). `DecryptInto` opens the +/// ciphertext field and passes over the terms — the field types say which is +/// which, so no attribute is needed. +#[derive(EncryptFrom, DecryptInto)] +#[stash(plaintext = u32)] +struct EncryptedAge { + c: StackCipherText, eq: EqualityTerm, ord: OreTerm<u32>, } -// One impl, written the way the derive will write it: build every field's -// pending (no I/O — the terms derive locally, the ciphertext queues its -// data-key requests), merge them with `zip`, shape with `map`. Errors are the -// cipher's; there is nothing to unify. The context bounds are per field — -// the same clauses the derive emits — so the record *inherits* the leaves' -// context policy (a supplied, non-empty context) instead of restating it: -// `EncryptedInt` is encrypted with `encrypt_into_with_context` because its -// leaves demand a supplied context, and when the leaf bound tightens -// (vitaminc#291) the record rides along untouched. -impl<K, Ctx> EncryptFrom<u32, StackCipher<K>, Ctx> for EncryptedInt -where - Ctx: Clone, - StackCipherText: EncryptFrom<u32, StackCipher<K>, Ctx>, - EqualityTerm: EncryptFrom<u32, StackCipher<K>, Ctx>, - OreTerm<u32>: EncryptFrom<u32, StackCipher<K>, Ctx>, -{ - fn encrypt_from<'a>( - source: &'a u32, - cipher: &'a StackCipher<K>, - context: Ctx, - ) -> Pending<'a, Self, K> - where - Self: 'a, - { - // One context fans out to every field: it authenticates the - // ciphertext (AAD) and domain-separates both terms (PRF context). - StackCipherText::encrypt_from(source, cipher, context.clone()) - .zip(EqualityTerm::encrypt_from(source, cipher, context.clone())) - .zip(OreTerm::<u32>::encrypt_from(source, cipher, context)) - .map(|((ciphertext, eq), ord)| Self { - ciphertext, - eq, - ord, - }) - } +/// The plaintext. +#[derive(Debug, Clone, PartialEq)] +struct User { + age: u32, + email: String, } -// The decrypt mirror `#[derive(DecryptInto)]` would write: the record owns -// its opening, and only the ciphertext field participates (terms are -// one-way), so it delegates to the ciphertext's own implementation — and -// inherits its context demand the same per-field way. -impl<K, Ctx> DecryptInto<u32, StackCipher<K>, Ctx> for EncryptedInt -where - StackCipherText: DecryptInto<u32, StackCipher<K>, Ctx>, -{ - fn decrypt_into<'a>(self, cipher: &'a StackCipher<K>, context: Ctx) -> Pending<'a, u32, K> - where - Self: 'a, - u32: 'a, - { - self.ciphertext.decrypt_into(cipher, context) - } +/// `User`, encrypted field by field: `age` from `user.age` under +/// `"users/age"`, `email` from `user.email` under `"users/email"`. The prefix +/// is named once, explicitly — it is part of the stored data's identity, so +/// it is never inferred from a Rust type name — and the field half follows +/// the plaintext field. +#[derive(EncryptFrom, DecryptInto)] +#[stash(struct = User, context = "users")] +struct EncryptedUser { + age: EncryptedAge, + email: StackCipherText, } #[tokio::main(flavor = "current_thread")] async fn main() -> Result<(), Box<dyn std::error::Error>> { - // One cipher does everything the record needs: ZeroKMS-backed AEAD (every + // One cipher does everything the shapes need: ZeroKMS-backed AEAD (every // leaf sealed under its own data key) and SEM term derivation under the // keyset's index key, which `init` loads. Data keys and terms are bound to // the same keyset by construction — there is no way to mix them up. let cipher = StackCipher::new().await?; - // --- Write side: encrypt a column of ages ------------------------------- - - const CONTEXT: &str = "users/age"; - let ages: Vec<u32> = vec![29, 34, 41, 34, 57]; - - // One await for the whole column: the Vec implementation merges every - // record's pending, so five records (ciphertext + two terms each) settle - // in a single batched generate_keys call. - let table: Vec<EncryptedInt> = ages.encrypt_into_with_context(&cipher, CONTEXT).await?; - println!( - "stored {} encrypted records in one ZeroKMS call", - table.len() - ); - - // --- Query side: terms only, no plaintext, no decryption ---------------- - - // Term probes derive under the index key the cipher already holds: - // building a query never calls ZeroKMS at all. + // --- Write side: encrypt a table of users --------------------------------- + + let users: Vec<User> = [ + (29, "ada"), + (34, "grace"), + (41, "edsger"), + (34, "barbara"), + (57, "tony"), + ] + .into_iter() + .map(|(age, name)| User { + age, + email: format!("{name}@example.com"), + }) + .collect(); + + // Every field carries its own context, so nothing is needed from the + // caller — and one await seals the whole table: the `Vec` implementation + // merges every struct's pending, so five users (two ciphertexts and two + // terms each) settle in a single batched generate_keys call. + let table: Vec<EncryptedUser> = users.encrypt_into(&cipher).await?; + println!("stored {} encrypted users in one ZeroKMS call", table.len()); + + // --- Query side: terms only, no plaintext, no decryption ------------------ + + // Term probes derive under the index key the cipher already holds, under + // the same context the field was stored under: building a query never + // calls ZeroKMS at all. // WHERE age = 34: compare equality terms. - let probe: EqualityTerm = 34u32.encrypt_into_with_context(&cipher, CONTEXT).await?; - let equal: Vec<usize> = (0..table.len()).filter(|&i| table[i].eq == probe).collect(); + let probe: EqualityTerm = 34u32 + .encrypt_into_with_context(&cipher, nonempty!("users/age")) + .await?; + let equal: Vec<usize> = (0..table.len()) + .filter(|&i| table[i].age.eq == probe) + .collect(); println!("WHERE age = 34 => rows {equal:?}"); // WHERE age > 40: compare ORE terms. - let bound: OreTerm<u32> = 40u32.encrypt_into_with_context(&cipher, CONTEXT).await?; - let over_40: Vec<usize> = (0..table.len()).filter(|&i| table[i].ord > bound).collect(); + let bound: OreTerm<u32> = 40u32 + .encrypt_into_with_context(&cipher, nonempty!("users/age")) + .await?; + let over_40: Vec<usize> = (0..table.len()) + .filter(|&i| table[i].age.ord > bound) + .collect(); println!("WHERE age > 40 => rows {over_40:?}"); // ORDER BY age: sort by ORE term. let mut by_age: Vec<usize> = (0..table.len()).collect(); - by_age.sort_by(|&a, &b| table[a].ord.cmp(&table[b].ord)); + by_age.sort_by(|&a, &b| table[a].age.ord.cmp(&table[b].age.ord)); println!("ORDER BY age => rows {by_age:?}"); - // --- Read side: decrypt only the rows a query matched ------------------- + // --- Read side: decrypt only the rows a query matched --------------------- // A separate client: any process holding the same ZeroKMS credentials and // keyset can decrypt what this one wrote. let decryptor = StackCipher::new().await?; // Collect the matching rows and decrypt them together: one batched - // retrieve_keys call, however many rows matched. The context must match - // the one the records were encrypted under — it is bound into the AAD, so - // a ciphertext cannot be replayed against a different field. + // retrieve_keys call, however many rows matched. Each field opens under + // the context it was sealed under — it is bound into the AAD, so a + // ciphertext cannot be replayed against a different field. let mut table = table; - let mut matches: Vec<EncryptedInt> = Vec::new(); + let mut matches: Vec<EncryptedUser> = Vec::new(); // Descending index order keeps earlier indices valid across swap_remove. for i in over_40.into_iter().rev() { matches.push(table.swap_remove(i)); } - let ages: Vec<u32> = matches.decrypt_into(&decryptor, CONTEXT).await?; - for age in ages { - println!("decrypted matching row: age {age}"); + let matched: Vec<User> = Vec::<User>::decrypt_from(matches, &decryptor).await?; + for user in &matched { + println!("decrypted matching row: {user:?}"); } + // --- Binding a value to its record ---------------------------------------- + + // A context the caller passes *extends* every field's own: under the + // record's id, `age` is sealed under `("users/age", id)` and opens only + // there — a ciphertext can no longer be moved between records of the + // same table. The price is that its terms are scoped to that record too: + // a probe built under `"users/age"` alone never matches them, so extend + // where a value is read by id, not where it is searched across rows. + let id = 42u64; + let alice = User { + age: 34, + email: "alice@example.com".into(), + }; + let record: EncryptedUser = alice.clone().encrypt_into_with_context(&cipher, id).await?; + let unscoped: Vec<usize> = std::iter::once(&record) + .enumerate() + .filter(|(_, r)| r.age.eq == probe) + .map(|(i, _)| i) + .collect(); + println!( + "record-scoped terms match the table probe: {}", + !unscoped.is_empty() + ); + let scoped: EqualityTerm = 34u32 + .encrypt_into_with_context(&cipher, nonempty!("users/age").with(id)) + .await?; + println!( + " ...and a probe built under the same id: {}", + record.age.eq == scoped + ); + + let opened = User::decrypt_from_with_context(record, &decryptor, id).await?; + assert_eq!(opened, alice); + // The record id is in every field's context, and the context is the + // ZeroKMS descriptor of every data key: opening under another id fails + // at ZeroKMS, before any key material moves. (Against a source that + // does not enforce descriptors — the fake — the AEAD refuses instead.) + let record: EncryptedUser = alice.encrypt_into_with_context(&cipher, id).await?; + let wrong_id = User::decrypt_from_with_context(record, &decryptor, 43u64).await; + println!( + "opening under another id: {}", + match wrong_id { + Err(Error::Kms(e)) => format!("refused by ZeroKMS ({e})"), + Err(Error::Aead) => "refused (AEAD)".to_owned(), + _ => "unexpected".to_owned(), + } + ); + Ok(()) } diff --git a/packages/stack-encrypt/examples/mixed_user.rs b/packages/stack-encrypt/examples/mixed_user.rs index 3578c4cf4..cfe067e03 100644 --- a/packages/stack-encrypt/examples/mixed_user.rs +++ b/packages/stack-encrypt/examples/mixed_user.rs @@ -18,6 +18,13 @@ //! Passthrough values travel in the clear and are **not authenticated** — //! use them for non-sensitive routing/display data only. //! +//! This is the *cipher-directed* layer — vitaminc's `Encrypt` / `Decrypt` +//! driven by hand — one level below `#[derive(EncryptFrom)]`, which the +//! `encrypted_record` example uses. Reach for this layer when a value needs +//! what the derive does not express: fields stored in the clear beside +//! sealed ones, or a single ciphertext whose internal shape (this +//! sequence-of-maps) is what the AAD chain authenticates. +//! //! Run with: //! //! ```sh diff --git a/packages/stack-encrypt/examples/search_terms.rs b/packages/stack-encrypt/examples/search_terms.rs index 3a7bea16f..b6fc0675f 100644 --- a/packages/stack-encrypt/examples/search_terms.rs +++ b/packages/stack-encrypt/examples/search_terms.rs @@ -20,7 +20,7 @@ use stack_encrypt::sem::{EqualityTerm, MatchTerm, OreTerm}; use stack_encrypt::target::EncryptInto; -use stack_encrypt::StackCipher; +use stack_encrypt::{nonempty, EncryptFrom, StackCipher, StackCipherText}; #[tokio::main(flavor = "current_thread")] async fn main() -> Result<(), Box<dyn std::error::Error>> { @@ -40,17 +40,17 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> { // value indexed under another field can never produce a colliding term. let stored: EqualityTerm = "alice@example.com" - .encrypt_into_with_context(&terms, "users/email") + .encrypt_into_with_context(&terms, nonempty!("users/email")) .await?; let hit: EqualityTerm = "alice@example.com" - .encrypt_into_with_context(&terms, "users/email") + .encrypt_into_with_context(&terms, nonempty!("users/email")) .await?; let miss: EqualityTerm = "bob@example.com" - .encrypt_into_with_context(&terms, "users/email") + .encrypt_into_with_context(&terms, nonempty!("users/email")) .await?; let wrong_field: EqualityTerm = "alice@example.com" - .encrypt_into_with_context(&terms, "users/name") + .encrypt_into_with_context(&terms, nonempty!("users/name")) .await?; println!("\nequality:"); @@ -70,13 +70,13 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> { let bio: MatchTerm = "alice, senior cryptography engineer" .to_string() - .encrypt_into_with_context(&terms, "users/bio") + .encrypt_into_with_context(&terms, nonempty!("users/bio")) .await?; for query in ["crypto", "engineer", "plumber"] { let probe: MatchTerm = query .to_string() - .encrypt_into_with_context(&terms, "users/bio") + .encrypt_into_with_context(&terms, nonempty!("users/bio")) .await?; println!("match: bio contains {query:?} => {}", bio.contains(&probe)); } @@ -93,9 +93,15 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> { // the key derivation becomes an auditable server event while values stay // local. - let age_30: OreTerm<u32> = 30u32.encrypt_into_with_context(&terms, "users/age").await?; - let age_45: OreTerm<u32> = 45u32.encrypt_into_with_context(&terms, "users/age").await?; - let query_40: OreTerm<u32> = 40u32.encrypt_into_with_context(&terms, "users/age").await?; + let age_30: OreTerm<u32> = 30u32 + .encrypt_into_with_context(&terms, nonempty!("users/age")) + .await?; + let age_45: OreTerm<u32> = 45u32 + .encrypt_into_with_context(&terms, nonempty!("users/age")) + .await?; + let query_40: OreTerm<u32> = 40u32 + .encrypt_into_with_context(&terms, nonempty!("users/age")) + .await?; println!("\nore (WHERE age > 40):"); println!(" age 30 > 40 => {}", age_30 > query_40); @@ -103,12 +109,55 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> { // Strings order lexicographically. let apple: OreTerm<&str> = "apple" - .encrypt_into_with_context(&terms, "users/name") + .encrypt_into_with_context(&terms, nonempty!("users/name")) .await?; let banana: OreTerm<&str> = "banana" - .encrypt_into_with_context(&terms, "users/name") + .encrypt_into_with_context(&terms, nonempty!("users/name")) .await?; println!(" \"apple\" < \"banana\" => {}", apple < banana); + // --- The same terms, as a record -------------------------------------------- + // + // Leaf by leaf is the query side. On the write side a field is stored + // as *every* term it needs beside its ciphertext, in one shape: derive + // `EncryptFrom` for that shape and each field of it is derived from the + // one value under the one context — byte-identical to the leaves above, + // so a probe built leaf by leaf finds what the record stored. + #[derive(EncryptFrom)] + #[stash(plaintext = String)] + struct SearchableEmail { + c: StackCipherText, + eq: EqualityTerm, + text: MatchTerm, + ord: OreTerm<String>, + } + + let record: SearchableEmail = "alice@example.com" + .to_string() + .encrypt_into_with_context(&terms, nonempty!("users/email")) + .await?; + let probe: MatchTerm = "example" + .to_string() + .encrypt_into_with_context(&terms, nonempty!("users/email")) + .await?; + println!("\nrecord:"); + println!( + " equality term equals the leaf's => {}", + record.eq == stored + ); + println!( + " match: contains \"example\" => {}", + record.text.contains(&probe) + ); + println!( + " ore: sorts after \"alice\" => {}", + record.ord + > "alice" + .to_string() + .encrypt_into_with_context(&terms, nonempty!("users/email")) + .await? + ); + let _ = record.c; // the ciphertext, opened with `decrypt_into` under the same context + Ok(()) } diff --git a/packages/stack-encrypt/src/cipher.rs b/packages/stack-encrypt/src/cipher.rs index 9247157ed..c16e07e0d 100644 --- a/packages/stack-encrypt/src/cipher.rs +++ b/packages/stack-encrypt/src/cipher.rs @@ -59,8 +59,13 @@ //! always bound, so the ciphertext is cryptographically tied to its ZeroKMS //! data key (key binding); a caller AAD (e.g. a //! [`ContextTag`](vitaminc_aead::ContextTag)) adds a further binding layer. -//! Every data key is requested with an empty descriptor: stack-encrypt does -//! not use descriptors. +//! Every data key is requested under a ZeroKMS **descriptor**: the context +//! the tree is sealed under, rendered as a string by +//! [`Descriptor`]. ZeroKMS HMACs the descriptor into the +//! key `tag` and demands the same descriptor to re-derive the key, so the +//! binding the leaf AAD makes locally is enforced at ZeroKMS as well, and +//! the descriptor is what ZeroKMS logs per retrieval. The lock context on +//! each request is empty. //! //! This is a fresh framing and is intentionally **not** byte-compatible with //! `cipherstash-client`'s `EncryptedRecord` AAD (a raw `descriptor || tag` @@ -85,6 +90,8 @@ use vitaminc_aead::{ use vitaminc_encrypt::{Aes256Cipher, AesCipherText, Key as AesKey}; use vitaminc_protected::{Controlled, Protected}; +use crate::Descriptor; + /// The passthrough payload type: type-erased, as for Rust-native vitaminc /// ciphers. Callers box on the way in and downcast on the way out. pub type BoxedPassthrough = Box<dyn Any + Send + 'static>; @@ -111,6 +118,14 @@ pub enum Error { /// ZeroKMS returned a different number of keys than were requested. #[error("expected {expected} data keys from ZeroKMS but received {received}")] KeyCountMismatch { expected: usize, received: usize }, + /// A context rendered to a descriptor longer than ZeroKMS can bind + /// ([`Descriptor::MAX_LEN`]). Raised before any request is sent, so no + /// key is minted or retrieved for the batch. + #[error( + "context renders to a {len}-byte ZeroKMS descriptor; the limit is {} bytes", + Descriptor::MAX_LEN + )] + DescriptorTooLong { len: usize }, /// Building a ZeroKMS client from the environment failed: credentials or /// client key missing or malformed. /// @@ -120,17 +135,9 @@ pub enum Error { /// would then change this enum's shape under a downstream match. #[error("could not build a ZeroKMS client from the environment: {0}")] Config(#[source] Box<dyn std::error::Error + Send + Sync + 'static>), - /// The per-field encryption context was empty. An empty context defeats - /// per-field domain separation: equal plaintexts in different fields - /// would produce identical index terms, ORE/OPE keys would be shared - /// across fields, and ciphertexts would be transplantable between them. - #[error("the encryption context must not be empty (it domain-separates fields)")] - EmptyContext, - /// An index term failed to derive. An empty context is *not* reported - /// here — it folds into [`Error::EmptyContext`] so every path spells the - /// same misconfiguration the same way. + /// An index term failed to derive. #[error(transparent)] - Term(crate::sem::TermError), + Term(#[from] crate::sem::TermError), /// A third-party [`EncryptFrom`](crate::target::EncryptFrom) / /// [`DecryptInto`](crate::target::DecryptInto) implementation failed /// for a reason of its own. @@ -165,15 +172,6 @@ impl From<stack_kms::StackKmsBuilderError> for Error { } } -impl From<crate::sem::TermError> for Error { - fn from(error: crate::sem::TermError) -> Self { - match error { - crate::sem::TermError::EmptyContext => Error::EmptyContext, - other => Error::Term(other), - } - } -} - impl From<Unspecified> for Error { fn from(_: Unspecified) -> Self { Error::Aead @@ -443,54 +441,67 @@ fn hmac_prf_from_index_key(index_key: &stack_kms::IndexKey) -> vitaminc_hmac::Hm // `[u8; 32]` is `Copy`: the move into `Protected` leaves this stack copy // behind, so wipe it before returning. let mut key = *index_key.key(); - let prf = vitaminc_hmac::HmacSha256Prf::new(Protected::new(key)); + let prf = <vitaminc_hmac::HmacSha256Prf as vitaminc_prf::PrfKeyInit>::new(Protected::new(key)); key.zeroize(); prf } impl<K: DataKeySource> StackCipher<K> { /// Encrypt a value, binding `aad`, and seal it against fresh ZeroKMS data - /// keys in a single batched `generate_keys` call. + /// keys in a single batched `generate_keys` call. Every key is requested + /// under the [`Descriptor`] of `aad`. pub async fn encrypt<'a, T, A>(&self, value: T, aad: A) -> Result<StackCipherText, Error> where T: Encrypt, A: IntoAad<'a>, { - let pending = value.encrypt_with_aad(self, aad)?; - pending.seal(self).await + let aad = aad.into_aad_piece(); + let pending = value.encrypt_with_aad(self, aad.clone().into_aad())?; + pending.seal(self, aad).await } /// Decrypt a [`StackCipherText`] into `T`, authenticating against `aad`. /// /// Thin wrapper over [`decipher`](Self::decipher): one batched - /// `retrieve_keys` call, then `T`'s [`Decrypt`] impl drives the returned - /// [`StackDecipher`] with `aad` — exactly as `Aes256Cipher::decrypt_with_aad` - /// drives `AesDecipher`. + /// `retrieve_keys` call under the [`Descriptor`] of `aad`, then `T`'s + /// [`Decrypt`] impl drives the returned [`StackDecipher`] with `aad` — + /// exactly as `Aes256Cipher::decrypt_with_aad` drives `AesDecipher`. pub async fn decrypt<'a, T, A>(&self, ciphertext: StackCipherText, aad: A) -> Result<T, Error> where T: Decrypt<'static> + 'static, A: IntoAad<'a>, { - let decipher = self.decipher(ciphertext).await?; - T::decrypt_with_aad(decipher, aad).map_err(Error::from) + let aad = aad.into_aad_piece(); + let decipher = self.decipher(ciphertext, aad.clone()).await?; + T::decrypt_with_aad(decipher, aad.into_aad()).map_err(Error::from) } - /// Fetch every leaf's data key (one batched `retrieve_keys` call) and bind - /// them onto the ciphertext, returning a synchronous [`Decipher`] that does - /// the AEAD opening as the value's [`Decrypt`] impl drives it. + /// Fetch every leaf's data key (one batched `retrieve_keys` call, every + /// key under the [`Descriptor`] of `aad`) and bind them onto the + /// ciphertext, returning a synchronous [`Decipher`] that does the AEAD + /// opening as the value's [`Decrypt`] impl drives it. /// /// This is the decrypt-side counterpart to passing `&cipher` (a [`Cipher`]) /// on the encrypt side, mirroring `Aes256Cipher::decipher`: the ZeroKMS I/O /// is front-loaded here, and the AAD is supplied per call by /// [`Decrypt::decrypt_with_aad`], so `Decrypt` impls that derive their own /// AAD (e.g. `vitaminc_aead::Element`) behave identically to `AesDecipher`. + /// The one thing ZeroKMS needs before that drive is the descriptor the + /// keys were generated under, so `aad` is the context the value was + /// sealed under — the same value, in the same shape, that the drive will + /// present (an `Element`'s own derivation is applied by the drive, not + /// here). [`decrypt`](Self::decrypt) does both steps. /// /// Settles through the target layer's request carrier /// ([`decipher_pending`](crate::target)), so this and /// `decrypt_into` share one definition of how leaves map to retrieve /// requests and one path to ZeroKMS. - pub async fn decipher(&self, ciphertext: StackCipherText) -> Result<StackDecipher, Error> { - crate::target::decipher_pending(self, ciphertext) + pub async fn decipher<'a>( + &self, + ciphertext: StackCipherText, + aad: impl IntoAad<'a>, + ) -> Result<StackDecipher, Error> { + crate::target::decipher_pending(self, ciphertext, Descriptor::of(aad)) .settle() .await } @@ -846,17 +857,26 @@ impl PendingStackCipherText { } /// Generate one data key per keyed leaf (one batched ZeroKMS call — none - /// for a passthrough-only tree) and seal the whole tree. + /// for a passthrough-only tree), every key under the [`Descriptor`] of + /// `aad`, and seal the whole tree. + /// + /// `aad` is the context the tree was built under — the value passed to + /// `encrypt_with_aad`, in the same shape (see the + /// [descriptor docs](crate::descriptor)). The tree itself only carries + /// the *derived* per-leaf AADs, so the root is named here; nothing can + /// check that the two agree, which is why [`StackCipher::encrypt`], which + /// does both steps from one value, is the form to prefer. /// /// Settles through the target layer's request carrier /// ([`seal_pending`](crate::target)), so this and /// `encrypt_into_with_context` into a `StackCipherText` share one definition of how a tree /// is sealed and one path to ZeroKMS. - pub async fn seal<K: DataKeySource>( + pub async fn seal<'a, K: DataKeySource>( self, cipher: &StackCipher<K>, + aad: impl IntoAad<'a>, ) -> Result<StackCipherText, Error> { - self.into_pending(cipher).settle().await + self.into_pending(cipher, aad).settle().await } /// Turn this tree into a [`Pending`](crate::target::Pending) request @@ -877,20 +897,17 @@ impl PendingStackCipherText { /// opened symmetrically by [`StackCipher::decrypt`]. But a tree that /// will be opened through the target layer's /// [`decrypt_into`](crate::target::DecryptInto) — a per-field record - /// assembly in an FFI front-end, say — is bound by that layer's rule: a - /// *degenerate* context (see - /// [`is_degenerate_aad`](crate::target::is_degenerate_aad)) is refused - /// on open with [`Error::EmptyContext`], so sealing under one here - /// produces ciphertext that path can never read. Validate the context - /// before driving [`Encrypt`] — - /// [`supplied_aad`](crate::target::supplied_aad) is the checked - /// encoder — until vitaminc#291 moves non-emptiness into the context - /// type and makes the mismatch unrepresentable. - pub fn into_pending<K>( + /// assembly in an FFI front-end, say — is bound by that layer's rule: it + /// opens only under a [`NonEmpty`](crate::NonEmpty) context, so seal + /// under one here (a `NonEmpty<T>` is an [`IntoAad`] like any other, and + /// encodes exactly as `T` does) or the ciphertext can never be read that + /// way. + pub fn into_pending<'c, 'a, K>( self, - cipher: &StackCipher<K>, - ) -> crate::target::Pending<'_, StackCipherText, K> { - crate::target::seal_pending(cipher, self) + cipher: &'a StackCipher<K>, + aad: impl IntoAad<'c>, + ) -> crate::target::Pending<'a, StackCipherText, K> { + crate::target::seal_pending(cipher, self, Descriptor::of(aad)) } /// Recursively seal, drawing one key per leaf from `keys` in traversal order. @@ -1258,6 +1275,19 @@ impl<'c, K> MapCipher for PendingMapCipher<'c, K> { Ok(self) } + fn passthrough_entry_boxed<S>( + self, + key: S, + value: Box<dyn Any + Send + 'static>, + ) -> Result<Self, Self::Error> + where + S: Into<Cow<'static, str>>, + { + // This cipher's passthrough type *is* `Box<dyn Any + Send>`, so the + // type-erased box is already the payload — same as `passthrough_boxed`. + self.passthrough_entry(key, value) + } + fn end(self) -> Result<Self::Ok, Self::Error> { // Finalising with a pending key would silently drop the entry. if self.current_key.is_some() { @@ -1405,6 +1435,7 @@ impl<'c> Decipher<'c> for StackDecipher { visitor.visit_map(StackMapAccess { entries: entries.into_iter(), aad: aad.into_aad(), + pending: None, }) } CipherText::EmptyMap(keyed) => { @@ -1414,6 +1445,7 @@ impl<'c> Decipher<'c> for StackDecipher { visitor.visit_map(StackMapAccess { entries: Vec::new().into_iter(), aad, + pending: None, }) } _ => Err(Unspecified), @@ -1506,30 +1538,127 @@ impl<'c> SeqAccess<'c> for StackSeqAccess { struct StackMapAccess<'a> { entries: std::vec::IntoIter<(String, KeyedCipherText)>, aad: Aad<'a>, + /// The entry handed out by `next_key` and not yet consumed by + /// `next_value` / `next_passthrough`. Held as ciphertext rather than + /// decrypted up front so the caller can choose the plaintext type after + /// seeing the key — see [`MapAccess::next_key`] — alongside the entry + /// AAD it was sealed under, derived once here so the key itself moves + /// out to the caller. + pending: Option<(Aad<'static>, KeyedCipherText)>, } impl<'c, 'a> MapAccess<'c> for StackMapAccess<'a> { type Error = Unspecified; - fn next_entry<T: Decrypt<'c> + 'c>(&mut self) -> Result<Option<(String, T)>, Self::Error> { + fn next_key(&mut self) -> Result<Option<String>, Self::Error> { + // A still-pending entry means the caller skipped a value. Refused + // rather than tolerated: an entry whose value is never opened is an + // entry whose AAD binding is never verified. + if self.pending.is_some() { + return Err(Unspecified); + } match self.entries.next() { Some((key, ct)) => { - // Mirror `PendingMapCipher::encrypt_value`: the value was sealed - // against `for_map_entry(key)`, so a swapped or renamed key - // fails here. - let entry_aad = self.aad.for_map_entry(&key); - let value = T::decrypt_with_aad(StackDecipher::over(ct), entry_aad)?; - Ok(Some((key, value))) + // Mirror `PendingMapCipher::encrypt_value`: the value was + // sealed against `for_map_entry(key)`, so a swapped or + // renamed key fails when the entry is opened. + self.pending = Some((self.aad.for_map_entry(&key), ct)); + Ok(Some(key)) } None => Ok(None), } } + + fn next_value<T: Decrypt<'c> + 'c>(&mut self) -> Result<T, Self::Error> { + let (entry_aad, ct) = self.pending.take().ok_or(Unspecified)?; + T::decrypt_with_aad(StackDecipher::over(ct), entry_aad) + } + + fn next_passthrough(&mut self) -> Result<Box<dyn Any + Send + 'static>, Self::Error> { + match self.pending.take() { + Some((_key, CipherText::Passthrough(value))) => Ok(value), + // A sealed value under a key the caller asked to read as a + // passthrough: refuse rather than hand it back with its tag + // unchecked — but keep the entry pending. The refusal is the + // caller's answer, not a reason to lose the entry: it can still + // open it with `next_value`, and until it does `next_key` keeps + // refusing to move past it. + Some(entry) => { + self.pending = Some(entry); + Err(Unspecified) + } + None => Err(Unspecified), + } + } } #[cfg(test)] mod tests { use super::*; + fn map_access(entries: Vec<(&str, KeyedCipherText)>) -> StackMapAccess<'static> { + StackMapAccess { + entries: entries + .into_iter() + .map(|(key, ct)| (key.to_string(), ct)) + .collect::<Vec<_>>() + .into_iter(), + aad: Aad::from_slice(b"map"), + pending: None, + } + } + + #[test] + fn a_passthrough_entry_is_read_back_as_the_boxed_value() { + let mut map = map_access(vec![("plain", CipherText::Passthrough(Box::new(7u32)))]); + + assert_eq!(map.next_key(), Ok(Some("plain".to_string()))); + let value = map.next_passthrough().expect("passthrough entry"); + assert_eq!(value.downcast_ref::<u32>(), Some(&7)); + assert_eq!(map.next_key(), Ok(None)); + } + + /// Asking for a sealed entry as a passthrough is refused, and the entry + /// stays pending: it is neither handed back unverified nor lost, so the + /// caller can still open it with `next_value` and cannot skip it. + #[test] + fn a_sealed_entry_survives_being_misread_as_a_passthrough() { + let mut map = map_access(vec![ + ("sealed", CipherText::Sequence(vec![])), + ("plain", CipherText::Passthrough(Box::new(7u32))), + ]); + + assert_eq!(map.next_key(), Ok(Some("sealed".to_string()))); + assert!(map.next_passthrough().is_err()); + // Still pending: the map refuses to advance past an unopened entry. + assert_eq!(map.next_key(), Err(Unspecified)); + // And the refusal is repeatable, not a one-shot that then drops it. + assert!(map.next_passthrough().is_err()); + assert_eq!(map.next_key(), Err(Unspecified)); + } + + /// A draw with nothing pending — before any `next_key`, or after the + /// entry has already been taken — is a clean refusal, never a value and + /// never a panic. This is the guard that keeps an absent or unverified + /// value from being handed back, so a refactor from `ok_or(..)?` to an + /// `unwrap` would regress silently without it. + #[test] + fn drawing_a_value_with_nothing_pending_is_refused() { + let mut map = map_access(vec![("plain", CipherText::Passthrough(Box::new(7u32)))]); + + // No `next_key` yet: nothing is pending. + assert!(map.next_value::<String>().is_err()); + assert!(map.next_passthrough().is_err()); + + // A legitimate draw consumes the entry, so a second draw of either + // kind is refused too. + assert_eq!(map.next_key(), Ok(Some("plain".to_string()))); + assert!(map.next_passthrough().is_ok()); + assert!(map.next_passthrough().is_err()); + assert!(map.next_value::<String>().is_err()); + assert_eq!(map.next_key(), Ok(None)); + } + /// Byte-level pin for the [`leaf_aad`] derivation. This is part of the /// frozen leaf format: a change to the domain label, the version byte, /// the piece order, or the PAE framing makes every stored leaf fail diff --git a/packages/stack-encrypt/src/descriptor.rs b/packages/stack-encrypt/src/descriptor.rs new file mode 100644 index 000000000..0d3754f66 --- /dev/null +++ b/packages/stack-encrypt/src/descriptor.rs @@ -0,0 +1,505 @@ +//! The ZeroKMS **descriptor**: the context a data key is requested under, +//! rendered as the string ZeroKMS binds and logs. +//! +//! Every data-key request stack-encrypt makes — generate on encrypt, +//! retrieve on decrypt — carries the requesting context as its descriptor. +//! ZeroKMS HMACs the descriptor into the key `tag` it returns and requires +//! the same descriptor to re-derive the key, so a leaf sealed under +//! `users/email` cannot have its key retrieved under `users/name`: the +//! request fails at ZeroKMS, before any key material moves. The descriptor +//! is also what a ZeroKMS retrieval log records per key, which is what +//! makes the field readable in an audit trail. That is the legacy +//! `cipherstash-client` arrangement, on the stable descriptor channel. +//! Lock-context tags and decryption policies are a separate, newer channel +//! that stack-encrypt does not yet use. +//! +//! The descriptor is a string on the wire; a context is a value with parts +//! (its [`AadPiece`] tree — text, bytes, integers, lists of those). +//! [`Descriptor::from_piece`] is the one rendering of those parts as a +//! string, and it is **frozen**: ZeroKMS binds the rendered string into the +//! tag, so changing the rendering strands every key issued under the old +//! one. +//! +//! The descriptor follows the context's **parts**, not its encoded bytes. +//! It is injective over encodings — two contexts that encode to different +//! AAD bytes never share a descriptor, so ZeroKMS's binding is at least as +//! strong as the AEAD's — but it is *finer* than the encoding in two named +//! cases, where contexts with identical AAD bytes get different +//! descriptors and ZeroKMS refuses what the AEAD would open: +//! +//! * A pre-encoded [`Aad`](vitaminc_aead::Aad) is one opaque bytes part. +//! `("tenant", 7u64)` renders `tenant|7u64`; the same tuple passed +//! through `into_aad()` first renders `b64:` + its encoded bytes. +//! * Different shapes can encode alike: `None::<&str>` (an empty list) and +//! `0u64` are both eight zero bytes, and render `()` and `0u64`. +//! +//! So a value must be opened under the context in the same **shape** it was +//! sealed under — the structured value both times, or the encoded `Aad` +//! both times — not merely one with the same bytes. + +use std::sync::Arc; + +use base64ct::{Base64, Encoding}; +use vitaminc_aead::{AadPiece, IntoAad}; + +/// A context rendered as the string sent to ZeroKMS with every data-key +/// request. See the [module docs](self). +/// +/// Built from the same value a leaf is sealed under — a +/// [`NonEmpty<T>`](crate::NonEmpty) context on the target-directed path, the +/// caller's AAD on the cipher-directed one — so the descriptor and the leaf +/// AAD always agree. +/// +/// One rendering serves every keyed leaf of a tree: the string is shared, +/// so cloning a `Descriptor` into each leaf's request costs a pointer, not +/// a copy, however long the context or large the tree. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct Descriptor(Arc<str>); + +impl Descriptor { + /// The prefix that marks a base64-rendered text or byte part. A textual + /// part that happens to begin with it is base64-rendered too, so the + /// prefix is unambiguous. + pub const BASE64_PREFIX: &'static str = "b64:"; + + /// The separator between the parts of a list. + pub const SEPARATOR: char = '|'; + + /// The longest descriptor ZeroKMS accepts, in bytes of the rendered + /// string: the protocol's [`MAX_DESCRIPTOR_LEN`](stack_kms::MAX_DESCRIPTOR_LEN). + /// ZeroKMS derives key material over a fixed block of that size holding + /// the descriptor, so a longer one cannot be bound. Every data-key + /// request checks its descriptors against this before anything is sent + /// ([`Error::DescriptorTooLong`](crate::Error::DescriptorTooLong)); a + /// context is free to be long, but what it renders to must fit — and + /// the base64 escape grows a part by a third, so an escaped part fits + /// less than a plain one. + pub const MAX_LEN: usize = stack_kms::MAX_DESCRIPTOR_LEN; + + /// Render `context` — anything that encodes as AAD — from its parts. + /// + /// [`StackCipher::encrypt`](crate::StackCipher::encrypt) / + /// [`decrypt`](crate::StackCipher::decrypt) and the target-directed + /// leaves render the descriptor themselves; call this to see what a + /// context will look like in the ZeroKMS log, or to check that it + /// [`fits`](Self::fits) before sealing a large batch under it. + /// + /// ``` + /// use stack_encrypt::{nonempty, Descriptor}; + /// + /// // A textual context is its own descriptor. + /// assert_eq!(Descriptor::of("users/email").as_str(), "users/email"); + /// + /// // A composite renders its parts in order: a field bound to a row id. + /// let row = nonempty!("users/email").with(7u64); + /// assert_eq!(Descriptor::of(row).as_str(), "users/email|7u64"); + /// assert!(Descriptor::of(row).fits()); + /// + /// // Rendered from the parts, so it follows the encoding: integers are + /// // sign-blind, an empty part inside a list leaves a mark, and text that + /// // could read as another form is escaped. + /// assert_eq!(Descriptor::of(7i64), Descriptor::of(7u64)); + /// assert_eq!(Descriptor::of(Some("")).as_str(), "(b64:)"); + /// assert_eq!(Descriptor::of("a|b").as_str(), "b64:YXxi"); + /// ``` + pub fn of<'a>(context: impl IntoAad<'a>) -> Self { + Self::from_piece(&context.into_aad_piece()) + } + + /// Render a context's parts. + /// + /// # Frozen rendering + /// + /// * A **text** part, or a **bytes** part that is UTF-8, renders + /// **verbatim** when it is *plain*: non-empty, no control characters, + /// none of `|`, `(`, `)`, not beginning with + /// [`b64:`](Self::BASE64_PREFIX), and not beginning with an ASCII digit + /// or `-`. So a `&str` context — `users/email` — is its own + /// descriptor, readable in the ZeroKMS log. Any other text or bytes + /// part renders as `b64:` followed by the standard (padded) base64 of + /// its bytes; an **empty** part is therefore the bare prefix, `b64:`, + /// so `Some("")` is `(b64:)` and `None` is `()`. Text and bytes with + /// the same bytes render the same, as they encode the same. + /// * An **integer** part renders as its encoded bytes read as an + /// unsigned number, with the width as a suffix: `7u64`. Integers + /// encode as untagged little-endian bytes, so the width is part of the + /// rendering and the signedness is not: `7i64` is `7u64`, and `-3i32` + /// is `4294967293u32` — the bytes it encodes to. + /// * A **list** renders its parts joined by [`|`](Self::SEPARATOR). At + /// the root, a list of two or more parts has no delimiters — + /// `nonempty!("users/email").with(7u64)` is `users/email|7u64` — and + /// any other list, nested or of fewer than two parts, is parenthesised: + /// `(users/email)`, `()`, `a|(b|c)`. + /// * At the root, the empty text or bytes part — the `()` AAD, or `""` — + /// renders as the empty string, which is what ZeroKMS receives when a + /// caller opts out of descriptors. + /// + /// The rendering is injective over encodings (the plain-text rule + /// reserves exactly the characters the other forms begin with or + /// contain), and finer than the encoding for a pre-encoded `Aad` and for + /// shapes that happen to encode alike — see the [module docs](self). + pub fn from_piece(piece: &AadPiece<'_>) -> Self { + let mut out = String::new(); + Self::render(piece, true, &mut out); + Self(Arc::from(out)) + } + + fn render(piece: &AadPiece<'_>, root: bool, out: &mut String) { + match piece { + AadPiece::Text(text) => Self::render_bytes(text.as_bytes(), root, out), + AadPiece::Bytes(bytes) => Self::render_bytes(bytes, root, out), + // Signed and unsigned of one width encode to the same + // little-endian bytes; `as` reinterprets, so they render the + // same too. + AadPiece::U8(v) => Self::render_int(v, "u8", out), + AadPiece::U16(v) => Self::render_int(v, "u16", out), + AadPiece::U32(v) => Self::render_int(v, "u32", out), + AadPiece::U64(v) => Self::render_int(v, "u64", out), + AadPiece::U128(v) => Self::render_int(v, "u128", out), + AadPiece::I8(v) => Self::render_int(&(*v as u8), "u8", out), + AadPiece::I16(v) => Self::render_int(&(*v as u16), "u16", out), + AadPiece::I32(v) => Self::render_int(&(*v as u32), "u32", out), + AadPiece::I64(v) => Self::render_int(&(*v as u64), "u64", out), + AadPiece::I128(v) => Self::render_int(&(*v as u128), "u128", out), + AadPiece::List(parts) => { + let bare = root && parts.len() >= 2; + if !bare { + out.push('('); + } + for (i, part) in parts.iter().enumerate() { + if i > 0 { + out.push(Self::SEPARATOR); + } + Self::render(part, false, out); + } + if !bare { + out.push(')'); + } + } + // `AadPiece` is `#[non_exhaustive]`: a part this crate does not + // know renders by its bytes, which is still injective (the + // base64 form is reserved) and still binds. + other => Self::render_bytes(other.clone().into_aad().as_bytes(), root, out), + } + } + + fn render_bytes(bytes: &[u8], root: bool, out: &mut String) { + // The empty root is the empty descriptor; an empty part anywhere + // else must leave a mark, or `Some("")` and `None` would both read + // `()`. The base64 of nothing is nothing, so the mark is the bare + // prefix — which no plain text can begin with. + if bytes.is_empty() && root { + return; + } + match std::str::from_utf8(bytes) { + Ok(text) if Self::is_plain(text) => out.push_str(text), + _ => { + out.push_str(Self::BASE64_PREFIX); + out.push_str(&Base64::encode_string(bytes)); + } + } + } + + fn render_int(value: &impl std::fmt::Display, suffix: &str, out: &mut String) { + use std::fmt::Write as _; + // Writing to a `String` cannot fail. + let _ = write!(out, "{value}{suffix}"); + } + + /// Text that renders verbatim: non-empty, and nothing another form + /// begins with or contains. + fn is_plain(text: &str) -> bool { + !text.is_empty() + && !text.starts_with(Self::BASE64_PREFIX) + && !text.starts_with(|c: char| c.is_ascii_digit() || c == '-') + && !text + .chars() + .any(|c| c.is_control() || matches!(c, '|' | '(' | ')')) + } + + /// The rendered string, as sent to ZeroKMS. + pub fn as_str(&self) -> &str { + &self.0 + } + + /// The rendered length in bytes — what [`MAX_LEN`](Self::MAX_LEN) bounds. + pub fn len(&self) -> usize { + self.0.len() + } + + /// Whether the rendering is the empty string (the `()` context). + pub fn is_empty(&self) -> bool { + self.0.is_empty() + } + + /// Whether ZeroKMS can bind this descriptor: its rendered length is at + /// most [`MAX_LEN`](Self::MAX_LEN). + pub fn fits(&self) -> bool { + self.len() <= Self::MAX_LEN + } + + /// [`fits`](Self::fits) as the error a request path reports: `Ok` to go + /// on, or the [`DescriptorTooLong`](crate::Error::DescriptorTooLong) that + /// refuses the whole batch before a single request is built. + pub(crate) fn check(&self) -> Result<(), crate::Error> { + if self.fits() { + Ok(()) + } else { + Err(crate::Error::DescriptorTooLong { len: self.len() }) + } + } +} + +impl std::fmt::Display for Descriptor { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.0) + } +} + +impl AsRef<str> for Descriptor { + fn as_ref(&self) -> &str { + &self.0 + } +} + +#[cfg(test)] +mod tests { + use vitaminc_aead::Aad; + use vitaminc_protected::{nonempty, NonEmpty}; + + use super::*; + + #[test] + fn a_textual_context_is_its_own_descriptor() { + assert_eq!(Descriptor::of("users/email").as_str(), "users/email"); + assert_eq!( + Descriptor::of(nonempty!("users/email")).as_str(), + "users/email" + ); + assert_eq!( + Descriptor::of(String::from("naïve/ünïcode")).as_str(), + "naïve/ünïcode" + ); + assert_eq!( + Descriptor::of(b"users/email".as_slice()).as_str(), + "users/email", + "bytes that are text render as the text they encode to" + ); + assert_eq!( + Descriptor::of(Aad::from_slice(b"users/email")).as_str(), + "users/email", + "already-encoded AAD renders by its bytes" + ); + } + + #[test] + fn the_empty_context_renders_empty() { + // `()` and `""` encode to the same (empty) bytes: one descriptor. + assert_eq!(Descriptor::of(()).as_str(), ""); + assert_eq!(Descriptor::of("").as_str(), ""); + assert_eq!(Descriptor::of(b"".as_slice()).as_str(), ""); + } + + #[test] + fn integers_render_with_their_width_not_their_sign() { + // Pinned: the rendering is bound into the ZeroKMS key tag, so a change + // here strands every key generated under the old rendering. + assert_eq!(Descriptor::of(7u64).as_str(), "7u64"); + assert_eq!(Descriptor::of(7u32).as_str(), "7u32"); + assert_eq!( + Descriptor::of(u128::MAX).as_str(), + format!("{}u128", u128::MAX) + ); + // Signed integers encode to the same bytes as the unsigned of their + // width, so they render as it: a `7i64` column and a `7u64` column + // are one context. + assert_eq!(Descriptor::of(7i64).as_str(), "7u64"); + assert_eq!(Descriptor::of(-3i32).as_str(), "4294967293u32"); + assert_eq!(Descriptor::of(-1i8).as_str(), "255u8"); + assert_eq!( + Descriptor::of(i128::MIN).as_str(), + format!("{}u128", i128::MIN as u128) + ); + } + + #[test] + fn an_empty_part_leaves_a_mark() { + // The root empty context is the empty descriptor, but an empty part + // inside a list must not vanish: `Some("")` and `None` encode + // differently (a one-element list and an empty one). + assert_eq!(Descriptor::of(Some("")).as_str(), "(b64:)"); + assert_eq!(Descriptor::of(None::<&str>).as_str(), "()"); + assert_eq!(Descriptor::of(("", "")).as_str(), "b64:|b64:"); + assert_eq!( + Descriptor::of(nonempty!("users/email").with(Some(""))).as_str(), + "users/email|(b64:)" + ); + assert_eq!( + Descriptor::of(nonempty!("users/email").with(None::<&str>)).as_str(), + "users/email|()" + ); + assert_eq!( + Descriptor::of(nonempty!("users/email").with("")).as_str(), + "users/email|b64:" + ); + } + + #[test] + fn contexts_that_encode_alike_render_alike() { + let same = [ + (7u64.into_aad(), Descriptor::of(7u64), Descriptor::of(7i64)), + ( + (-3i32).into_aad(), + Descriptor::of(-3i32), + Descriptor::of(4_294_967_293u32), + ), + ( + "users/email".into_aad(), + Descriptor::of("users/email"), + Descriptor::of(b"users/email".as_slice()), + ), + (().into_aad(), Descriptor::of(()), Descriptor::of("")), + ( + ("a|b", 7u64).into_aad(), + Descriptor::of(("a|b", 7u64)), + Descriptor::of((b"a|b".as_slice(), 7i64)), + ), + ]; + for (aad, a, b) in same { + assert_eq!(a, b, "{a} vs {b} over {:?}", aad.as_bytes()); + } + } + + #[test] + fn the_descriptor_is_finer_than_the_encoding_in_two_named_cases() { + // A pre-encoded `Aad` is one opaque bytes part: the descriptor + // cannot recover the parts it was built from, so it renders the + // bytes. Seal and open must present the context in the same shape. + let structured = Descriptor::of(("tenant", 7u64)); + let encoded = Descriptor::of(("tenant", 7u64).into_aad()); + assert_eq!(structured.as_str(), "tenant|7u64"); + assert!(encoded.as_str().starts_with(Descriptor::BASE64_PREFIX)); + assert_ne!(structured, encoded); + + // Different shapes can encode to the same bytes — an empty list is + // a zero count, which is eight zero bytes, which is `0u64`. The + // AEAD cannot tell them apart; the descriptor does. + assert_eq!( + None::<&str>.into_aad().as_bytes(), + 0u64.into_aad().as_bytes() + ); + assert_eq!(Descriptor::of(None::<&str>).as_str(), "()"); + assert_eq!(Descriptor::of(0u64).as_str(), "0u64"); + } + + #[test] + fn composites_render_their_parts_in_order() { + assert_eq!( + Descriptor::of(nonempty!("users/email").with(7u64)).as_str(), + "users/email|7u64" + ); + assert_eq!( + Descriptor::of(NonEmpty::new("users/email").unwrap().with(7u64)), + Descriptor::of(("users/email", 7u64)), + "NonEmpty is transparent to the rendering" + ); + assert_eq!( + Descriptor::of(("tenant", ("users/email", 7u64))).as_str(), + "tenant|(users/email|7u64)", + "a nested list is parenthesised" + ); + assert_eq!( + Descriptor::of(Some("users/email")).as_str(), + "(users/email)", + "a one-part list is parenthesised even at the root" + ); + assert_eq!(Descriptor::of(None::<&str>).as_str(), "()"); + } + + #[test] + fn text_that_could_read_as_another_form_is_escaped() { + // Control characters. + assert_eq!(Descriptor::of("a\0b").as_str(), "b64:YQBi"); + assert_eq!( + Descriptor::of("line\nbreak").as_str(), + "b64:bGluZQpicmVhaw==" + ); + // The base64 prefix itself: `b64:YQ==` as *text* must not collide + // with the rendering of the byte `a`. + let text = Descriptor::of("b64:YQ=="); + assert_eq!(text.as_str(), "b64:YjY0OllRPT0="); + assert_ne!(text, Descriptor::of("a")); + // The list separator and delimiters. + assert_eq!(Descriptor::of("a|b").as_str(), "b64:YXxi"); + assert_eq!(Descriptor::of("(a)").as_str(), "b64:KGEp"); + // A leading digit or sign, which is how an integer begins. + assert_eq!(Descriptor::of("7u64").as_str(), "b64:N3U2NA=="); + assert_eq!(Descriptor::of("-x").as_str(), "b64:LXg="); + } + + #[test] + fn the_limit_is_on_rendered_bytes() { + // 512 two-byte characters render to 1024 bytes: over, though the + // context is 512 characters "long". + assert!(Descriptor::of("a".repeat(512)).fits()); + assert!(!Descriptor::of("a".repeat(513)).fits()); + assert!(!Descriptor::of("ü".repeat(512)).fits()); + assert_eq!(Descriptor::of("ü".repeat(256)).len(), 512); + // The escape grows a part: 400 bytes of text with a `|` renders as + // `b64:` + 536 base64 characters. + let escaped = Descriptor::of(format!("|{}", "a".repeat(399))); + assert_eq!(escaped.len(), 4 + 536); + assert!(!escaped.fits()); + } + + #[test] + fn a_clone_shares_the_rendering() { + let descriptor = Descriptor::of("a".repeat(Descriptor::MAX_LEN)); + let clone = descriptor.clone(); + assert!( + std::ptr::eq(descriptor.as_str(), clone.as_str()), + "a clone must not copy the string: one rendering serves every leaf" + ); + } + + #[test] + fn invalid_utf8_renders_base64() { + assert_eq!(Descriptor::of(&[0xff, 0xfe][..]).as_str(), "b64://4="); + } + + #[test] + fn distinct_encodings_never_share_a_descriptor() { + let all = [ + Descriptor::of("users/email"), + Descriptor::of("b64:users/email"), + Descriptor::of(nonempty!("users/email").with(7u64)), + Descriptor::of(nonempty!("users/email").with(8u64)), + Descriptor::of(nonempty!("users/email").with(7u32)), + Descriptor::of("users/email|7u64"), + Descriptor::of(("users/email", ("7u64", ()))), + Descriptor::of(Some("users/email")), + Descriptor::of("(users/email)"), + Descriptor::of(None::<&str>), + Descriptor::of(Some("")), + Descriptor::of(("", "")), + Descriptor::of(nonempty!("users/email").with(None::<&str>)), + Descriptor::of(nonempty!("users/email").with(Some(""))), + Descriptor::of(nonempty!("users/email").with("")), + Descriptor::of("()"), + Descriptor::of("(b64:)"), + Descriptor::of("b64:"), + Descriptor::of(7u64), + Descriptor::of(7u32), + Descriptor::of(-7i64), + Descriptor::of(0u64), + Descriptor::of("7"), + Descriptor::of(&[0xff, 0xfe][..]), + Descriptor::of(()), + ]; + for (i, a) in all.iter().enumerate() { + for (j, b) in all.iter().enumerate() { + assert_eq!(i == j, a == b, "{a} vs {b}"); + } + } + } +} diff --git a/packages/stack-encrypt/src/lib.rs b/packages/stack-encrypt/src/lib.rs index 561c475dd..c117a2fad 100644 --- a/packages/stack-encrypt/src/lib.rs +++ b/packages/stack-encrypt/src/lib.rs @@ -125,9 +125,15 @@ assert_eq!(plaintext, "secret message"); //! Besides your AAD, the *shape* of a value is authenticated: an element cannot //! be spliced out of a sequence and passed off as a scalar, a map value cannot //! be moved under a different key, and "absent" / "empty" are themselves -//! sealed markers rather than inferable from structure. A tampered, re-homed, -//! or wrong-context ciphertext fails with [`Error::Aead`]; a failed or denied -//! key retrieval surfaces as [`Error::Kms`]. +//! sealed markers rather than inferable from structure. A tampered or +//! re-homed ciphertext fails with [`Error::Aead`]; a failed or denied key +//! retrieval surfaces as [`Error::Kms`]. A ciphertext opened under the +//! *wrong context* is refused by ZeroKMS first: every data key is bound to +//! its context's [`Descriptor`], so the retrieve is denied +//! ([`Error::Kms`], a forbidden request) before the AEAD runs — as +//! `examples/encrypted_record.rs` shows against a live ZeroKMS. Only a key +//! source that ignores descriptors (`FakeDataKeySource`, in tests) lets a +//! wrong context reach the AEAD, where it is [`Error::Aead`]. //! //! For one-row reads of a batch-encrypted collection, decrypt as //! [`Element<T>`](Element) under the same AAD used for the whole collection. @@ -141,11 +147,14 @@ assert_eq!(plaintext, "secret message"); //! are vitaminc's (`vitaminc_encrypt::Aes256Cipher`, AES-256-GCM under a random //! per-leaf nonce vitaminc generates itself). The ZeroKMS `iv` a [`SealedValue`] //! carries is *not* that nonce: it identifies the data key, and is sent back to -//! ZeroKMS with the key `tag` to re-derive it. The types a caller needs from +//! ZeroKMS with the key `tag` to re-derive it — under the same [`Descriptor`] +//! (the leaf's context, rendered) the key was generated with, which ZeroKMS +//! binds into the tag and logs. The types a caller needs from //! vitaminc are re-exported here. The [`cipher`] module docs describe the //! internals (batching, AAD derivation, wire format). pub mod cipher; +pub mod descriptor; pub mod sem; pub mod target; @@ -153,19 +162,27 @@ pub use cipher::{ BoxedPassthrough, Error, FromEnv, LeafBytesError, PendingStackCipherText, SealedValue, StackCipher, StackCipherBuilder, StackCipherText, StackDecipher, }; +pub use descriptor::Descriptor; pub use target::{ - is_degenerate_aad, DecryptContext, DecryptField, DecryptFrom, DecryptInto, DecryptTarget, - Decryptable, EncryptContext, EncryptFrom, EncryptInto, EncryptTarget, Pending, PendingFuture, - Request, Responses, SuppliedContext, + DecryptField, DecryptFrom, DecryptInto, DecryptTarget, Decryptable, ElementContext, + EncryptFrom, EncryptInto, EncryptTarget, Pending, PendingFuture, Request, Responses, }; // Re-export the vitaminc AEAD surface callers need to drive the cipher, so they // don't have to depend on `vitaminc-aead` directly for the common path. pub use vitaminc_aead::{ - Aad, Cipher, CipherText, ContextTag, Decipher, Decrypt, Element, Encrypt, IntoAad, Unspecified, + Aad, AadPiece, Cipher, CipherText, ContextTag, Decipher, Decrypt, Element, Encrypt, IntoAad, + Unspecified, }; -// Likewise the PRF context surface: a context newtype (the `SuppliedContext` -// opt-in recipe) needs `IntoPrfContext` alongside `IntoAad`, and should not -// need a direct `vitaminc-prf` dependency for it. +// Likewise the PRF context surface: a context type of your own implements +// `IntoPrfContext` alongside `IntoAad`, and should not need a direct +// `vitaminc-prf` dependency for it. pub use vitaminc_prf::{IntoPrfContext, PrfContext}; + +// And the proof every target-directed leaf asks for: a `NonEmpty<T>` is what +// `encrypt_into_with_context` / `decrypt_into` take, built with `nonempty!` +// (a literal, checked at compile time) or `NonEmpty::new` (a runtime value, +// checked once); `MaybeEmpty` is what a context type of your own implements +// to be wrapped. `#[derive(EncryptFrom)]` names these through this crate. +pub use vitaminc_protected::{nonempty, nonempty_bytes, EmptyError, MaybeEmpty, NonEmpty}; diff --git a/packages/stack-encrypt/src/sem/mod.rs b/packages/stack-encrypt/src/sem/mod.rs index 9c0d860fa..6c86056eb 100644 --- a/packages/stack-encrypt/src/sem/mod.rs +++ b/packages/stack-encrypt/src/sem/mod.rs @@ -7,7 +7,7 @@ //! target-directed: //! //! ```text -//! let term: EqualityTerm = value.encrypt_into_with_context(&cipher, "users/email").await?; +//! let term: EqualityTerm = value.encrypt_into_with_context(&cipher, nonempty!("users/email")).await?; //! ``` //! //! * [`EqualityTerm`] — a PRF of the whole value; exact-match queries. @@ -25,10 +25,12 @@ //! they double as worked examples for defining your own term types in another //! crate (see [`target`](crate::target#extending-with-your-own-sem-type)). //! -//! Alongside the target-directed path, the cipher carries descriptor-string +//! Alongside the target-directed path, the cipher carries descriptor //! methods ([`StackCipher::equality_term`] and friends) for call sites that //! want a single term rather than a whole record — query builders, mostly. -//! They derive no data keys, so building a probe never calls ZeroKMS. +//! They derive no data keys, so building a probe never calls ZeroKMS. The +//! descriptor is the same [`NonEmpty`] context the target-directed path +//! takes, so the two agree byte for byte. //! //! # PRF backends, visitors, and the 2-party future //! @@ -36,8 +38,8 @@ //! the term (`EqualityVisitor`, `BloomVisitor`, `OreVisitor`, `OpeVisitor` — //! all private). The shaping is pure and synchronous by construction: only block //! production can involve I/O, so a visitor never knows which side of a -//! round-trip it runs on. All pure work — context validation, option -//! validation, tokenization — happens *before* the PRF is invoked. +//! round-trip it runs on. All pure work — option validation, tokenization — +//! happens *before* the PRF is invoked. //! //! The backend today is the local //! [`HmacSha256Prf`] — keyed by the @@ -130,12 +132,10 @@ use vitaminc_prf::{ BlockVisitor, IntoPrfContext, MapAccess, PrfContext, PrfError, PrfValue, PrfVisitor, PrfVisitorError, SeqAccess, }; +use vitaminc_protected::NonEmpty; use zeroize::Zeroize; -use crate::target::{ - is_degenerate_prf_context, DecryptField, DecryptTarget, Decryptable, EncryptContext, - EncryptFrom, Pending, SuppliedContext, -}; +use crate::target::{DecryptField, DecryptTarget, Decryptable, EncryptFrom, Pending}; use crate::{Error, StackCipher}; // The `/v1` suffix versions the *derivation* (domain + input framing), not the @@ -178,13 +178,6 @@ pub enum TermError { "text produces no match tokens (empty, separator-only, or shorter than the n-gram length)" )] EmptyTermText, - /// The encryption context (field descriptor) was empty. An empty context - /// defeats per-field domain separation: equal plaintexts in different - /// fields would produce identical terms, and every field would share one - /// ORE/OPE key. See - /// [`EncryptContext`]. - #[error("the encryption context must not be empty (it domain-separates fields)")] - EmptyContext, /// Term bytes do not decode under the term kind's frozen encoding — see /// [`TermBytesError`]. #[error(transparent)] @@ -238,18 +231,6 @@ impl TermError { } } -/// Reject an empty context before any derivation — see -/// [`TermError::EmptyContext`]. "Empty" is structural -/// ([`is_degenerate_prf_context`]): `()`, `""`, `None`, `Some("")`, tuples of -/// empties and their nestings all carry no caller information, and every -/// field using one would share a single derivation domain. -fn require_context(context: &PrfContext<'_>) -> Result<(), TermError> { - if is_degenerate_prf_context(context.as_bytes()) { - return Err(TermError::EmptyContext); - } - Ok(()) -} - // ============================================================================= // Equality // ============================================================================= @@ -326,15 +307,16 @@ impl<P: Send + 'static> PrfVisitor<[u8; 32], P> for EqualityVisitor { /// Derive an equality term. Synchronous: the local HMAC backend does no I/O, /// and the visitor does all the shaping (see the module docs — under a /// deferred backend the same visitor runs after the round-trip instead). +/// `context` is the encoding of a [`NonEmpty`] — every caller holds one — +/// so there is nothing left to validate here. fn equality<T>( - prf: HmacSha256Prf, + prf: &HmacSha256Prf, value: T, context: PrfContext<'_>, ) -> Result<EqualityTerm, TermError> where T: PrfValue, { - require_context(&context)?; let context = PrfContext::pae(&[EQUALITY_DOMAIN, context.as_bytes()]); value .prf_visit_with_context(prf, context, EqualityVisitor) @@ -344,21 +326,24 @@ where /// An equality term of any [`PrfValue`] source. Derived locally during the /// synchronous build — the returned [`Pending`] carries no requests. -impl<'c, S, K, Ctx> EncryptFrom<S, StackCipher<K>, Ctx> for EqualityTerm +impl<'c, S, K, T> EncryptFrom<S, StackCipher<K>, NonEmpty<T>> for EqualityTerm where S: PrfValue + Clone, - Ctx: EncryptContext<'c> + SuppliedContext<'c>, + T: IntoPrfContext<'c>, { + // Derived locally: no data key, no descriptor. + const KEYED: bool = false; + fn encrypt_from<'a>( source: &'a S, cipher: &'a StackCipher<K>, - context: Ctx, + context: NonEmpty<T>, ) -> Pending<'a, Self, K> where Self: 'a, { let context = context.into_prf_context().into_owned(); - let term = equality(cipher.prf().clone(), source.clone(), context).map_err(Error::from); + let term = equality(cipher.prf(), source.clone(), context).map_err(Error::from); Pending::ready(cipher, term) } } @@ -629,12 +614,11 @@ impl<P: Send + 'static> PrfVisitor<[u8; 32], P> for BloomVisitor { /// Derive a match term. Synchronous — see [`equality`]: validation and /// tokenization run before the PRF, the visitor folds blocks into positions. fn match_term<O>( - prf: HmacSha256Prf, + prf: &HmacSha256Prf, text: &str, context: PrfContext<'_>, options: MatchOptions, ) -> Result<MatchTerm<O>, TermError> { - require_context(&context)?; let mask = options.validate()?; let tokens = tokenize::tokenize(text, options.tokenizer, options.downcase); if tokens.is_empty() { @@ -654,23 +638,26 @@ fn match_term<O>( /// A match term of any text source, generated under `O`'s options. Derived /// locally during the synchronous build — the returned [`Pending`] carries no /// requests (tokenize makes the one necessary copy of the text). -impl<'c, S, K, O, Ctx> EncryptFrom<S, StackCipher<K>, Ctx> for MatchTerm<O> +impl<'c, S, K, O, T> EncryptFrom<S, StackCipher<K>, NonEmpty<T>> for MatchTerm<O> where S: AsRef<str>, O: MatchConfig, - Ctx: EncryptContext<'c> + SuppliedContext<'c>, + T: IntoPrfContext<'c>, { + // Derived locally: no data key, no descriptor. + const KEYED: bool = false; + fn encrypt_from<'a>( source: &'a S, cipher: &'a StackCipher<K>, - context: Ctx, + context: NonEmpty<T>, ) -> Pending<'a, Self, K> where Self: 'a, { let context = context.into_prf_context().into_owned(); - let term = match_term(cipher.prf().clone(), source.as_ref(), context, O::options()) - .map_err(Error::from); + let term = + match_term(cipher.prf(), source.as_ref(), context, O::options()).map_err(Error::from); Pending::ready(cipher, term) } } @@ -911,12 +898,11 @@ where /// PRF — never the plaintext, which rides in the visitor and is encrypted /// there. Deterministic, so write-time and query-time terms agree; under a /// 2-party PRF backend this derivation is a visible ZeroKMS event. -fn ore<T>(prf: HmacSha256Prf, value: T, context: PrfContext<'_>) -> Result<OreTerm<T>, TermError> +fn ore<T>(prf: &HmacSha256Prf, value: T, context: PrfContext<'_>) -> Result<OreTerm<T>, TermError> where T: CllwOreEncrypt + Send + 'static, T::Output: Send + 'static, { - require_context(&context)?; let context_bytes = context.as_bytes(); context_bytes .prf_visit_with_context( @@ -932,12 +918,11 @@ where /// Derive an OPE term — as [`ore`], under the OPE domain so the two schemes /// never share a key. -fn ope<T>(prf: HmacSha256Prf, value: T, context: PrfContext<'_>) -> Result<OpeTerm<T>, TermError> +fn ope<T>(prf: &HmacSha256Prf, value: T, context: PrfContext<'_>) -> Result<OpeTerm<T>, TermError> where T: CllwOpeEncrypt + Send + 'static, T::Output: Send + 'static, { - require_context(&context)?; let context_bytes = context.as_bytes(); context_bytes .prf_visit_with_context( @@ -953,44 +938,50 @@ where /// An ORE term of any [`CllwOreEncrypt`] source. Derived locally during the /// synchronous build — the returned [`Pending`] carries no requests. -impl<'c, S, K, Ctx> EncryptFrom<S, StackCipher<K>, Ctx> for OreTerm<S> +impl<'c, S, K, T> EncryptFrom<S, StackCipher<K>, NonEmpty<T>> for OreTerm<S> where S: CllwOreEncrypt + Clone + Send + 'static, S::Output: Send + 'static, - Ctx: EncryptContext<'c> + SuppliedContext<'c>, + T: IntoPrfContext<'c>, { + // Derived locally: no data key, no descriptor. + const KEYED: bool = false; + fn encrypt_from<'a>( source: &'a S, cipher: &'a StackCipher<K>, - context: Ctx, + context: NonEmpty<T>, ) -> Pending<'a, Self, K> where Self: 'a, { let context = context.into_prf_context().into_owned(); - let term = ore(cipher.prf().clone(), source.clone(), context).map_err(Error::from); + let term = ore(cipher.prf(), source.clone(), context).map_err(Error::from); Pending::ready(cipher, term) } } /// An OPE term of any [`CllwOpeEncrypt`] source. Derived locally during the /// synchronous build — the returned [`Pending`] carries no requests. -impl<'c, S, K, Ctx> EncryptFrom<S, StackCipher<K>, Ctx> for OpeTerm<S> +impl<'c, S, K, T> EncryptFrom<S, StackCipher<K>, NonEmpty<T>> for OpeTerm<S> where S: CllwOpeEncrypt + Clone + Send + 'static, S::Output: Send + 'static, - Ctx: EncryptContext<'c> + SuppliedContext<'c>, + T: IntoPrfContext<'c>, { + // Derived locally: no data key, no descriptor. + const KEYED: bool = false; + fn encrypt_from<'a>( source: &'a S, cipher: &'a StackCipher<K>, - context: Ctx, + context: NonEmpty<T>, ) -> Pending<'a, Self, K> where Self: 'a, { let context = context.into_prf_context().into_owned(); - let term = ope(cipher.prf().clone(), source.clone(), context).map_err(Error::from); + let term = ope(cipher.prf(), source.clone(), context).map_err(Error::from); Pending::ready(cipher, term) } } @@ -999,29 +990,32 @@ where // Term generation on the cipher // ============================================================================= -/// Descriptor-string term generation, for call sites that want one term rather -/// than a whole record: query builders probing an index, re-indexers, tests of -/// a single scheme. +/// Descriptor term generation, for call sites that want one term rather than +/// a whole record: query builders probing an index, re-indexers, tests of a +/// single scheme. /// /// Every [`StackCipher`] carries the PRF keyed by its keyset's index key, so /// these need no data-key traffic at all — a query builder holding a cipher -/// never touches ZeroKMS to build a probe. Each is byte-identical to the -/// target-directed path for the same descriptor, so a term generated here -/// compares against one generated by `encrypt_into_with_context`. +/// never touches ZeroKMS to build a probe. The descriptor is a context as +/// the target-directed leaves take it — a [`NonEmpty<T>`]: +/// `nonempty!("users/email")`, `NonEmpty::new(column)?`, +/// `nonempty!("users/email").with(row_id)` — and each method is +/// byte-identical to that path for the same descriptor, so a term generated +/// here compares against one generated by `encrypt_into_with_context`. impl<K> StackCipher<K> { /// Generate an equality (exact-match) term for `value` under the field /// `descriptor`. Deterministic: the same value + descriptor always yields /// the same term, at write time and at query time. Byte-identical to /// `value.encrypt_into_with_context(&cipher, descriptor)` into an `EqualityTerm`. - pub async fn equality_term<T>( + pub async fn equality_term<'c, T>( &self, value: T, - descriptor: &str, + descriptor: NonEmpty<impl IntoPrfContext<'c>>, ) -> Result<EqualityTerm, TermError> where T: PrfValue, { - equality(self.prf().clone(), value, descriptor.into_prf_context()) + equality(self.prf(), value, descriptor.into_prf_context()) } /// Generate a match (full-text) term for `text` under the field @@ -1042,13 +1036,13 @@ impl<K> StackCipher<K> { /// Returns [`TermError::EmptyTermText`] when the text yields no tokens — /// empty or separator-only text, or an n-gram probe shorter than the gram /// length (which could never match; see [`Tokenizer::Ngram`]). - pub async fn match_terms<O: MatchConfig>( + pub async fn match_terms<'c, O: MatchConfig>( &self, text: &str, - descriptor: &str, + descriptor: NonEmpty<impl IntoPrfContext<'c>>, ) -> Result<MatchTerm<O>, TermError> { match_term( - self.prf().clone(), + self.prf(), text, descriptor.into_prf_context(), O::options(), @@ -1066,23 +1060,31 @@ impl<K> StackCipher<K> { /// (`'static`) because the visitor carries it; pass a `String` for /// borrowed text. Returns the raw CLLW ciphertext; the target-directed /// path wraps the same bytes in [`OreTerm`]. - pub async fn ore_term<T>(&self, value: T, descriptor: &str) -> Result<T::Output, TermError> + pub async fn ore_term<'c, T>( + &self, + value: T, + descriptor: NonEmpty<impl IntoPrfContext<'c>>, + ) -> Result<T::Output, TermError> where T: CllwOreEncrypt + Send + 'static, T::Output: Send + 'static, { - ore(self.prf().clone(), value, descriptor.into_prf_context()).map(OreTerm::into_inner) + ore(self.prf(), value, descriptor.into_prf_context()).map(OreTerm::into_inner) } /// Generate an order-preserving (CLLW OPE) term: ciphertexts compare with /// plain lexicographic byte order, no custom comparator required. /// Encrypt-only — pair with the record ciphertext for round-trips. Key /// handling and input bounds as for [`ore_term`](Self::ore_term). - pub async fn ope_term<T>(&self, value: T, descriptor: &str) -> Result<T::Output, TermError> + pub async fn ope_term<'c, T>( + &self, + value: T, + descriptor: NonEmpty<impl IntoPrfContext<'c>>, + ) -> Result<T::Output, TermError> where T: CllwOpeEncrypt + Send + 'static, T::Output: Send + 'static, { - ope(self.prf().clone(), value, descriptor.into_prf_context()).map(OpeTerm::into_inner) + ope(self.prf(), value, descriptor.into_prf_context()).map(OpeTerm::into_inner) } } diff --git a/packages/stack-encrypt/src/target/mod.rs b/packages/stack-encrypt/src/target/mod.rs index 5a25906c2..175a9d810 100644 --- a/packages/stack-encrypt/src/target/mod.rs +++ b/packages/stack-encrypt/src/target/mod.rs @@ -7,15 +7,21 @@ //! record shape in charge: //! //! ```text -//! let term: EqualityTerm = value.encrypt_into_with_context(&cipher, "users/email").await?; +//! let term: EqualityTerm = value.encrypt_into_with_context(&cipher, nonempty!("users/email")).await?; //! let row: EncryptedUser = user.encrypt_into(&cipher).await?; +//! let row: EncryptedUser = user.encrypt_into_with_context(&cipher, user_id).await?; //! ``` //! //! compiles only when the output type declares itself an encrypted form of //! the value's type, producible by that cipher, under that context — and a -//! context is something the output type may already have. A leaf takes the -//! caller's; a row whose fields each name their own column needs none, and -//! the second line is the whole call. +//! context is something the output type may already have. A leaf has +//! nothing of its own to authenticate under and takes the caller's, proven +//! non-empty before it arrives ([`NonEmpty`]). A struct encrypted field by +//! field names each field's context itself and needs none, so the second +//! line is the whole call; the third *extends* every field's context with a +//! value only the caller knows — the record's id — so each field is bound +//! to its record as well as its name. See [Which form +//! compiles](self#which-form-compiles). //! //! # The pieces //! @@ -23,13 +29,12 @@ //! an encrypted representation of `S`, producible by a cipher `C`, under a //! context `Ctx`". The context is a parameter of the *trait* so that an //! implementation can say which contexts it accepts: the leaves accept -//! only a [`SuppliedContext`], a record passes the obligation through to -//! its fields, and a row whose fields carry their own contexts accepts -//! `()` alone. Leaf +//! only a [`NonEmpty<T>`], and a derived record accepts `()` — its +//! fields' own contexts — and any `NonEmpty<T>`, which extends them. Leaf //! implementations exist for [`StackCipherText`] (the AEAD ciphertext, via //! vitaminc's [`Encrypt`]) and for the SEM term types in [`sem`] //! ([`EqualityTerm`], [`MatchTerm`], [`OreTerm`], [`OpeTerm`]). Composite -//! record types — a struct of leaves, or a row of records — get theirs from +//! record types — a struct of leaves, or a struct of records — get theirs from //! [`#[derive(EncryptFrom)]`](macro@EncryptFrom), which combines the //! fields' pendings with [`Pending::zip`] / [`Pending::map`] exactly as a //! hand-written impl would. @@ -42,24 +47,150 @@ //! implement on; the halves whose `Self` is the plaintext are blanket: //! * [`EncryptInto`] / [`DecryptFrom`] — call-site sugar, the `Into` to //! `EncryptFrom` and the `From` to `DecryptInto`, each in two forms: -//! [`encrypt_into(&cipher)`](EncryptInto::encrypt_into) for an output that -//! needs no context from the caller, and +//! [`encrypt_into(&cipher)`](EncryptInto::encrypt_into) passes `()`, for +//! an output that needs nothing from the caller, and //! [`encrypt_into_with_context(&cipher, ctx)`](EncryptInto::encrypt_into_with_context) -//! for one that does — the split of vitaminc's `encrypt` / -//! `encrypt_with_aad`, decided by the output type at compile time. Never -//! implemented by hand. +//! passes a `NonEmpty<T>` — the split of vitaminc's `encrypt` / +//! `encrypt_with_aad`. Never implemented by hand. //! * [`EncryptTarget`] / [`DecryptTarget`] — implemented by ciphers; their //! `Output` type decides what a call site gets back. A synchronous cipher //! returns `Result<T, E>` directly; [`StackCipher`] returns a [`Pending`], //! which does its ZeroKMS I/O — **one batched call** — when awaited. -//! * [`EncryptContext`] — one context value per field, convertible to both an -//! AEAD [`Aad`] and a -//! [`PrfContext`](vitaminc_prf::PrfContext), so the same identifier that -//! domain-separates the index terms also *authenticates* the ciphertext. -//! `&str` and `String` qualify. [`SuppliedContext`] marks the ones a -//! caller actually passed — everything but `()` — and is what a leaf -//! demands; the context must also be **non-empty**. //! +//! # Contexts +//! +//! A context is one value that domain-separates every primitive a field can +//! use: it becomes the AEAD associated data of the ciphertext *and* the PRF +//! context of any index term, so the identifier that keeps `users/email` +//! terms apart from `users/name` terms also authenticates the ciphertext to +//! its column. The vocabulary is vitaminc's — [`IntoAad`], +//! [`IntoPrfContext`](crate::IntoPrfContext) and, for the proof that a value +//! carries caller bytes, [`NonEmpty<T>`] — +//! and this crate adds no trait of its own on top: anything vitaminc encodes +//! as a context is a context here. `&str`, `String`, byte strings, the +//! fixed-width integers, and `Option`s and pairs of those all qualify. +//! +//! The same context reaches ZeroKMS. Every data key a leaf asks for — +//! generated on encrypt, retrieved on decrypt — is requested under the +//! context rendered as the key's **descriptor** ([`Descriptor`]), which +//! ZeroKMS HMACs into the key tag and logs per retrieval. So `users/email` +//! is enforced twice: locally, where the ciphertext fails to open under +//! any other AAD, and at ZeroKMS, where the key fails to re-derive under +//! any other descriptor — and it is the name an audit trail shows. A +//! textual context is its own descriptor, and a composite renders its +//! parts in order: `nonempty!("users/email").with(7u64)` is the descriptor +//! `users/email|7u64`. See [`Descriptor::from_piece`] for the frozen rules, +//! and the [descriptor docs](crate::descriptor) for why a context must be +//! presented in the same shape on both sides. +//! +//! A leaf takes a `NonEmpty<T>` and nothing else. Under an empty context, +//! equal plaintexts in different fields derive identical index terms +//! (cross-field equality leakage), every field shares one ORE/OPE key +//! (values become mutually order-comparable), and ciphertexts transplant +//! between fields. `()` — what `encrypt_into` passes — is the canonical +//! empty context, and vitaminc implements the context traits for it, so a +//! leaf bound on those alone would let `value.encrypt_into(&cipher)` into a +//! term compile and quietly derive under nothing. Bound on `NonEmpty<T>`, +//! that call is a compile error, and `""`, `None`, `Some("")` never reach a +//! leaf either: [`NonEmpty::new`] refuses them once, where the value is +//! built, and [`nonempty!`](crate::nonempty) refuses an empty literal at compile time. An +//! integer is never empty and converts on its own (`42u64.into()`, or just +//! `42u64` to the sugar). A pair is empty only when both halves are, so a +//! proven head extends freely: `nonempty!("users/email").with(record_id)`. +//! +//! One collision to know about, inherited from vitaminc's integer encoding: +//! an integer's AAD is its untagged little-endian bytes, so `0u64` +//! authenticates the same eight zero bytes as `None::<u64>` (the PAE of an +//! empty list). Index terms do not collide — the PRF encoding is typed — +//! but a ciphertext sealed under `("users/age", 0u64)` opens under +//! `("users/age", None::<u64>)`. Do not mix an integer id and an optional +//! one under the same prefix; cipherstash/vitaminc#315 tracks typing the +//! AAD channel too. +//! +//! # Which form compiles +//! +//! Three record shapes, and the call forms each accepts. Which contexts a +//! type accepts is which [`EncryptFrom`] impls it has; the derive decides +//! what impls exist, and the compiler enforces it at every call site. +//! +//! A record whose field pins a literal context needs nothing from the +//! caller, and a context passed anyway *extends* the literal — it is never +//! silently dropped: +//! +//! ``` +//! use stack_encrypt::target::EncryptInto; +//! use stack_encrypt::{DecryptInto, EncryptFrom, Error, NonEmpty, StackCipher, StackCipherText}; +//! use stack_kms::FakeDataKeySource; +//! +//! #[derive(EncryptFrom, DecryptInto)] +//! #[stash(plaintext = u32)] +//! struct Pinned { +//! #[stash(context = "legacy/age")] +//! c: StackCipherText, +//! } +//! +//! # tokio::runtime::Builder::new_current_thread().enable_all().build().unwrap().block_on(async { +//! # let cipher = StackCipher::builder().kms(FakeDataKeySource::new()).init().await?; +//! // Sealed under "legacy/age". +//! let p: Pinned = 42u32.encrypt_into(&cipher).await?; +//! assert_eq!(p.decrypt_into(&cipher, ()).await?, 42); +//! +//! // Sealed under ("legacy/age", tenant): opens there, and nowhere else. +//! let tenant = 7u64; +//! let p: Pinned = 42u32.encrypt_into_with_context(&cipher, tenant).await?; +//! let p2: Pinned = 42u32.encrypt_into_with_context(&cipher, tenant).await?; +//! assert_eq!(p.decrypt_into(&cipher, NonEmpty::from(tenant)).await?, 42); +//! // The fake key source ignores descriptors, so the AEAD is what refuses +//! // here; ZeroKMS refuses the key retrieval itself first (`Error::Kms`). +//! assert!(matches!(p2.decrypt_into(&cipher, NonEmpty::from(8u64)).await, Err(Error::Aead))); +//! # Ok::<(), stack_encrypt::Error>(()) +//! # }).unwrap(); +//! ``` +//! +//! A record whose fields have no context of their own has only the +//! caller's, so it must be given: the context-free form does not compile. +//! +//! ``` +//! use stack_encrypt::sem::EqualityTerm; +//! use stack_encrypt::target::EncryptInto; +//! use stack_encrypt::{nonempty, DecryptInto, EncryptFrom, StackCipher, StackCipherText}; +//! use stack_kms::FakeDataKeySource; +//! +//! #[derive(EncryptFrom, DecryptInto)] +//! #[stash(plaintext = u32)] +//! struct Foo { +//! c: StackCipherText, +//! hm: EqualityTerm, +//! } +//! +//! # tokio::runtime::Builder::new_current_thread().enable_all().build().unwrap().block_on(async { +//! # let cipher = StackCipher::builder().kms(FakeDataKeySource::new()).init().await?; +//! let f: Foo = 42u32.encrypt_into_with_context(&cipher, nonempty!("users/age")).await?; +//! assert_eq!(f.decrypt_into(&cipher, nonempty!("users/age")).await?, 42); +//! # Ok::<(), stack_encrypt::Error>(()) +//! # }).unwrap(); +//! ``` +//! +//! ```compile_fail,E0277 +//! # use stack_encrypt::sem::EqualityTerm; +//! # use stack_encrypt::target::EncryptInto; +//! # use stack_encrypt::{DecryptInto, EncryptFrom, StackCipher, StackCipherText}; +//! # use stack_kms::FakeDataKeySource; +//! # #[derive(EncryptFrom, DecryptInto)] +//! # #[stash(plaintext = u32)] +//! # struct Foo { c: StackCipherText, hm: EqualityTerm } +//! async fn encrypt(cipher: &StackCipher<FakeDataKeySource>) { +//! // "`StackCipherText` is not an encrypted form of `u32` under a `()` +//! // context": the leaf that needs a context is named. +//! let _: Foo = 42u32.encrypt_into(cipher).await.unwrap(); +//! } +//! ``` +//! +//! A struct encrypted field by field infers a context for every field, so +//! it takes either form: `encrypt_into` seals each field under its own +//! context, `encrypt_into_with_context` under that context extended with +//! the caller's. See [the next section](self#records-and-structs-deriveencryptfrom) +//! for the worked example. //! # Build synchronously, settle once //! //! `encrypt_from` does **no I/O**. It validates, derives every local term, @@ -72,7 +203,7 @@ //! //! ``` //! use stack_encrypt::target::{DecryptInto, EncryptInto}; -//! use stack_encrypt::{StackCipher, StackCipherText}; +//! use stack_encrypt::{nonempty, StackCipher, StackCipherText}; //! use stack_kms::FakeDataKeySource; //! //! # tokio::runtime::Builder::new_current_thread().enable_all().build().unwrap().block_on(async { @@ -86,11 +217,11 @@ //! //! // A column of independently sealed ciphertexts: ONE generate_keys call. //! let sealed: Vec<StackCipherText> = ages -//! .encrypt_into_with_context(&cipher, "users/age") +//! .encrypt_into_with_context(&cipher, nonempty!("users/age")) //! .await?; //! //! // And back: ONE retrieve_keys call for the whole column. -//! let roundtrip: Vec<u32> = sealed.decrypt_into(&cipher, "users/age").await?; +//! let roundtrip: Vec<u32> = sealed.decrypt_into(&cipher, nonempty!("users/age")).await?; //! assert_eq!(roundtrip, ages); //! # Ok::<(), stack_encrypt::Error>(()) //! # }).unwrap(); @@ -122,11 +253,8 @@ //! behind a PRF request instead, joining the record's one batched call. //! //! ``` -//! use stack_encrypt::target::{ -//! supplied_prf_context, DecryptField, DecryptTarget, Decryptable, EncryptContext, -//! EncryptFrom, Pending, SuppliedContext, -//! }; -//! use stack_encrypt::{Error, StackCipher}; +//! use stack_encrypt::target::{DecryptField, DecryptTarget, Decryptable, EncryptFrom, Pending}; +//! use stack_encrypt::{Error, IntoPrfContext, NonEmpty, StackCipher}; //! use vitaminc_prf::{PrfContext, PrfValue, PrfVisitor, PrfVisitorError}; //! //! /// A third-party term type: one PRF block under its own domain. @@ -142,37 +270,32 @@ //! } //! } //! -//! // A leaf owns the context policy, and states it in the impl header: -//! // `SuppliedContext` refuses `()` at compile time (nothing above a leaf -//! // checks, since a column of rows has no context of its own). The -//! // lifetime is the context's own, as in the `IntoAad<'c>` it implements. -//! impl<'c, S, K, Ctx> EncryptFrom<S, StackCipher<K>, Ctx> for MyTerm +//! // A leaf owns the context policy, and states it in the impl header: it +//! // exists only for a `NonEmpty<T>`, so `()` — and any unproven value — is +//! // a compile error (nothing above a leaf checks, since a column of rows +//! // has no context of its own). The lifetime is the context's own, as in +//! // the `IntoPrfContext<'c>` it implements. +//! impl<'c, S, K, T> EncryptFrom<S, StackCipher<K>, NonEmpty<T>> for MyTerm //! where //! S: PrfValue + Clone, -//! Ctx: EncryptContext<'c> + SuppliedContext<'c>, +//! T: IntoPrfContext<'c>, //! { //! fn encrypt_from<'a>( //! source: &'a S, //! cipher: &'a StackCipher<K>, -//! context: Ctx, +//! context: NonEmpty<T>, //! ) -> Pending<'a, Self, K> //! where //! Self: 'a, //! { -//! // The type rules out an absent context; an empty one is still a -//! // runtime check, made by the same choke point the built-in -//! // leaves use — validation and encoding are one call, so a leaf -//! // cannot encode one value and check another. Then -//! // domain-separate under your own label so your terms can never -//! // collide with another scheme's under the same context. -//! let context = match supplied_prf_context(context) { -//! Ok(context) => context, -//! Err(error) => return Pending::ready(cipher, Err(error)), -//! }; +//! // The type is the proof; there is nothing left to check. Encode, +//! // then domain-separate under your own label so your terms can +//! // never collide with another scheme's under the same context. +//! let context = context.into_prf_context().into_owned(); //! let context = PrfContext::pae(&[b"my-crate/my-term/v1".as_slice(), context.as_bytes()]); //! let term = source //! .clone() -//! .prf_visit_with_context(cipher.prf().clone(), context, MyVisitor) +//! .prf_visit_with_context(cipher.prf(), context, MyVisitor) //! .into_result() //! .map_err(|e| Error::Other(Box::new(e))); //! Pending::ready(cipher, term) @@ -211,20 +334,24 @@ //! different construction; the block scheme lands as a third-party term type //! in `eql-bindings`, built with exactly the recipe above. //! -//! # Records and rows: `#[derive(EncryptFrom)]` +//! # Records and structs: `#[derive(EncryptFrom)]` //! -//! A struct of leaves is a *record*; a struct of records, each derived from -//! one field of the source under its own column context, is a *row*. Both -//! are the same derive, and both settle as one batched call. A row names its -//! table once and its fields' contexts follow — -//! `#[stash(row = User, context = "users")]` derives `age` from `user.age` -//! under `"users/age"` — and are overridden per field where that is not -//! wanted: +//! A struct of leaves derived from one value is a *record*; a struct whose +//! fields are each derived from one field of a plaintext struct, under a +//! context of its own, is that plaintext encrypted *field by field*. Both +//! are the same derive, and both settle as one batched call. The field-by- +//! field form names its prefix once and its fields' contexts follow — +//! `#[stash(struct = User, context = "users")]` derives `age` from +//! `user.age` under `"users/age"` — and are overridden per field where that +//! is not wanted. A context the caller passes *extends* those: under +//! `encrypt_into_with_context(&cipher, 42u64)` the same field is derived +//! under `("users/age", 42u64)`, binding it to its record as well as its +//! name. Which is what a query site derives its probe under, too: //! //! ``` //! use stack_encrypt::sem::{EqualityTerm, OreTerm}; //! use stack_encrypt::target::{DecryptFrom, EncryptInto}; -//! use stack_encrypt::{DecryptInto, EncryptFrom, StackCipher, StackCipherText}; +//! use stack_encrypt::{nonempty, DecryptInto, EncryptFrom, StackCipher, StackCipherText}; //! use stack_kms::FakeDataKeySource; //! //! /// An encrypted `u32`, queryable by equality and range. @@ -242,10 +369,10 @@ //! email: String, //! } //! -//! /// A row of `User`: each field from the plaintext field of its own name, -//! /// under the context `"users/<field>"` — no attribute on the fields. +//! /// `User` field by field: each field from the plaintext field of its own +//! /// name, under the context `"users/<field>"` — no attribute on the fields. //! #[derive(EncryptFrom, DecryptInto)] -//! #[stash(row = User, context = "users")] +//! #[stash(struct = User, context = "users")] //! struct EncryptedUser { //! age: EncryptedAge, //! email: StackCipherText, @@ -259,17 +386,26 @@ //! .unwrap(); //! //! let user = User { age: 42, email: "alice@example.com".into() }; -//! // Every field names its own context, so the row needs none from the -//! // caller — and the context-free forms are the only ones that apply. +//! // Every field names its own context, so nothing is needed from the +//! // caller: the context-free forms are the whole call. //! let row: EncryptedUser = user.encrypt_into(&cipher).await?; //! // A query site derives the same term under the column's context. //! let probe: EqualityTerm = 42u32 -//! .encrypt_into_with_context(&cipher, "users/age") +//! .encrypt_into_with_context(&cipher, nonempty!("users/age")) //! .await?; //! assert_eq!(row.age.hm, probe); -//! //! let recovered = User::decrypt_from(row, &cipher).await?; //! assert_eq!(recovered, user); +//! +//! // Or the caller extends every field's context with the record's id: the +//! // same field is now under `("users/age", 7u64)`, and opens only there. +//! let row: EncryptedUser = user.encrypt_into_with_context(&cipher, 7u64).await?; +//! let probe: EqualityTerm = 42u32 +//! .encrypt_into_with_context(&cipher, nonempty!("users/age").with(7u64)) +//! .await?; +//! assert_eq!(row.age.hm, probe); +//! let recovered = User::decrypt_from_with_context(row, &cipher, 7u64).await?; +//! assert_eq!(recovered, user); //! # Ok::<(), stack_encrypt::Error>(()) //! # }).unwrap(); //! ``` @@ -292,14 +428,12 @@ //! [`OreTerm`]: crate::sem::OreTerm //! [`OpeTerm`]: crate::sem::OpeTerm -use std::borrow::Cow; - use stack_kms::MaybeSend; -use vitaminc_aead::{Aad, CipherText, Decrypt, Encrypt, IntoAad}; -use vitaminc_prf::IntoPrfContext; +use vitaminc_aead::{CipherText, Decrypt, Encrypt, IntoAad}; +use vitaminc_protected::NonEmpty; use crate::cipher::{bind_keys, PendingStackCipherText, StackDecipher}; -use crate::{Error, StackCipher, StackCipherText}; +use crate::{Descriptor, Error, StackCipher, StackCipherText}; mod pending; mod request; @@ -308,343 +442,6 @@ pub use pending::{Pending, PendingFuture}; pub use request::{Request, Responses}; pub use stack_encrypt_derive::{DecryptInto, EncryptFrom}; -// ============================================================================= -// Contexts -// ============================================================================= - -/// Per-field encryption context: one value that domain-separates every -/// primitive a record field can use — it becomes the AEAD associated data of -/// the ciphertext *and* the PRF context of any index term. -/// -/// Blanket-implemented; never implement it directly. `&str` and `String` -/// qualify. `Clone` is required because one context fans out to every field -/// of a record. -/// -/// The context must be **non-empty**: with an empty context, equal plaintexts -/// in different fields produce identical index terms (cross-field equality -/// leakage), every field shares one ORE/OPE key (values become mutually -/// order-comparable), and ciphertexts become transplantable between fields. -/// That is enforced in two layers. An *absent* context — `()`, what -/// [`encrypt_into`](EncryptInto::encrypt_into) passes — is refused by the -/// type: every leaf demands a [`SuppliedContext`], and containers (`Vec`, -/// `Option`) and derived records pass that demand through to their elements -/// and fields untouched, so only a record whose fields all carry contexts of -/// their own — a [`#[derive(EncryptFrom)]`](macro@EncryptFrom) row — accepts -/// `()`. An *empty* one — `""`, `b""`, `None`, `Some("")`, `("", "")`, -/// anything that encodes to nothing but vitaminc framing — is refused by -/// every built-in leaf during the synchronous build, before any I/O -/// ([`Error::EmptyContext`]). That check is structural over the PAE encoding -/// vitaminc uses, so a nested empty context cannot hide behind an `Option` -/// or tuple wrapper. (An integer context whose bytes coincide with an empty -/// encoding — `0u64` — is rejected too: it is byte-identical to `None`.) -pub trait EncryptContext<'a>: IntoAad<'a> + IntoPrfContext<'a> + Clone {} - -impl<'a, T> EncryptContext<'a> for T where T: IntoAad<'a> + IntoPrfContext<'a> + Clone {} - -/// Per-field decryption context: must convert to the AEAD associated data the -/// value was encrypted under. Decryption derives nothing, so no PRF bound. -/// Blanket-implemented; `&str`, `String` and [`Aad`] -/// qualify. -/// -/// This is the bound [`#[derive(DecryptInto)]`](macro@DecryptInto) places on -/// a record's caller-supplied decrypt context, and the supertrait of -/// [`SuppliedContext`]. It holds even when only term fields would see the -/// caller's context — a term opens nothing and would accept anything, so -/// without it a record whose ciphertext field carries a literal would take, -/// and silently discard, a value that is not a context at all: -/// -/// Note the flip side: for such a record the bound is all the caller's -/// context does. The terms discard its *value* and the ciphertext -/// authenticates under its literal, so decryption succeeds under any -/// well-typed context — a wrong one is not the [`Error::Aead`] it would be -/// against a leaf, and the decrypt context is not a tenancy check there. -/// -/// ```compile_fail,E0277 -/// use stack_encrypt::sem::EqualityTerm; -/// use stack_encrypt::{DecryptInto, EncryptFrom, StackCipher, StackCipherText}; -/// use stack_kms::FakeDataKeySource; -/// -/// #[derive(EncryptFrom, DecryptInto)] -/// #[stash(plaintext = u32)] -/// struct Rec { -/// #[stash(context = "rec/c")] -/// c: StackCipherText, -/// hm: EqualityTerm, -/// } -/// -/// async fn decrypt(cipher: &StackCipher<FakeDataKeySource>, rec: Rec) { -/// // `42u8` is not a context (no `IntoAad`): a compile error, not a -/// // value the term fields quietly swallow. -/// let _: u32 = rec.decrypt_into(cipher, 42u8).await.unwrap(); -/// } -/// ``` -pub trait DecryptContext<'a>: IntoAad<'a> + Clone {} - -impl<'a, T> DecryptContext<'a> for T where T: IntoAad<'a> + Clone {} - -/// A context the caller actually passed, as opposed to `()` — the absence of -/// one. -/// -/// This is what lets an output type decide, at compile time, whether the -/// call site owes it a context. The leaves ([`StackCipherText`], the -/// [`sem`](crate::sem) terms) implement [`EncryptFrom`] and [`DecryptInto`] -/// only for a `SuppliedContext`, so `value.encrypt_into(&cipher)` — which -/// passes `()` — does not compile against them, nor against a record that -/// hands its context on to one of them. A row whose fields each carry a -/// context of their own never passes the caller's anywhere: it implements -/// the traits for `()` alone, and is encrypted with no context at all. -/// -/// Implemented for every context type vitaminc provides except `()` — and -/// except a composite that contains `()`, such as `("users/email", ())` or -/// `Option<()>`, whose `()` half adds no domain separation: `&str`, -/// `String`, byte strings, [`Aad`], `u64`, and `Option`s -/// and pairs of those. A context type of your own opts in with an empty -/// `impl SuppliedContext<'_> for MyContext {}` alongside its `IntoAad` / -/// `IntoPrfContext`; without it the leaves refuse the type. -/// -/// The marker is about the *type*: `""` is a `&str` and therefore supplied. -/// Whether what was supplied is non-empty stays a runtime check at the leaf -/// ([`Error::EmptyContext`]) until vitaminc carries non-emptiness in the -/// type itself (cipherstash/vitaminc#291), at which point the bound tightens -/// to that. -/// -/// [`DecryptContext`] (the same `IntoAad + Clone`) is a supertrait, so the -/// implication holds by construction and a decrypt leaf bounds its context -/// by this marker alone. -#[diagnostic::on_unimplemented( - message = "`{Self}` is not a context the caller supplied", - label = "this leaf needs a context", - note = "`()` is what `encrypt_into` / `decrypt_from` pass, and what a derived row hands a \ - `from` field with no `context = \"..\"` of its own: an output that reaches a leaf \ - needs `encrypt_into_with_context` / `decrypt_from_with_context`, or the literal", - note = "a context type of your own opts in with an empty `impl SuppliedContext<'_> for MyContext {{}}`" -)] -pub trait SuppliedContext<'a>: DecryptContext<'a> {} - -// This roster hand-mirrors vitaminc's `IntoAad` implementor list (minus `()`) -// at the pinned rev, and the orphan rule means a *future* vitaminc-owned -// context type cannot opt itself in from user code — it waits for a release -// of this crate. That coupling is a conscious interim choice: when bumping -// the vitaminc pin, diff its `IntoAad` implementors against this list; at -// vitaminc#291 the bound moves to an upstream marker and the roster goes -// away. -impl<'a> SuppliedContext<'a> for &'a str {} -impl SuppliedContext<'_> for String {} -impl<'a> SuppliedContext<'a> for &'a [u8] {} -impl<'a, const N: usize> SuppliedContext<'a> for &'a [u8; N] {} -impl<const N: usize> SuppliedContext<'_> for [u8; N] {} -impl SuppliedContext<'_> for Vec<u8> {} -impl<'a> SuppliedContext<'a> for Cow<'a, [u8]> {} -impl<'a> SuppliedContext<'a> for Aad<'a> {} -impl SuppliedContext<'_> for u64 {} -impl<'a, T: SuppliedContext<'a>> SuppliedContext<'a> for Option<T> {} -impl<'a, A: SuppliedContext<'a>, B: SuppliedContext<'a>> SuppliedContext<'a> for (A, B) {} - -// The two checks below reconstruct, at runtime and from the outside, an -// invariant that should be carried by the type: "this context was built -// from something the caller supplied". Doing it this way means parsing an -// encoding we do not own, mirroring constants that are private upstream, -// and re-deriving the answer on every encrypt. -// -// It works, and the tests pin it — but the shape of it is a symptom, not a -// design. cipherstash/vitaminc#291 tracks the type-level replacement (a -// non-empty context that checks once at construction, without giving up the -// plain-string call site). When that lands, this module, both predicates and -// `Error::EmptyContext` all go away, and the `EncryptContext` bound tightens -// to the upstream marker instead. -// -// This code stays as it is until then: the check is correct, just weaker and -// more fragile than an invariant would be. The prefix-versus-position bug the -// review found here is exactly the fragility being described. - -/// The framing tags vitaminc's PRF context encoding inserts, each of which -/// occupies **piece 0** of the PAE node it labels. Matched exactly and only -/// in that position — a prefix test would classify any caller string -/// beginning `vitaminc/` as framing (`Some("vitaminc/customer")` would read -/// as empty), which is the opposite of what this check is for. -/// -/// # Why these exist at all -/// -/// They are mirrored from `vitaminc_prf::context`, where they are private, -/// and nothing about the *check* requires them. They are a consequence of -/// *where* the check runs: -/// -/// 1. [`EncryptContext`] is a blanket bound over vitaminc's `IntoAad + -/// IntoPrfContext`, so inside `encrypt_from` the context is an opaque -/// generic. The only thing this crate can do with it is encode it. -/// 2. The encoding is framed: `"".into_prf_context()` is -/// `pae([context-value, utf8-label, ""])`, not zero bytes. Seeing whether -/// the *value* is empty means parsing past the tags. -/// 3. Parsing past the tags means knowing which pieces are tags. -/// -/// So the crate ends up parsing an encoding it does not own, restating -/// constants it cannot import, and re-deriving on every encrypt an answer -/// that was knowable once, at construction. If vitaminc renames a domain -/// these literals drift silently: the byte pins still pass and the check -/// quietly starts admitting empties. -/// -/// That is the case for <https://github.com/cipherstash/vitaminc/issues/291>: -/// a context that carries non-emptiness in its type, checked once where it -/// is built. When it lands, this module, both predicates below and -/// [`Error::EmptyContext`] are deleted and the [`EncryptContext`] bound -/// tightens to the upstream marker. Until then the literals are pinned by -/// `context_tests`, which build every shape through the public API rather -/// than asserting the strings. -mod prf_framing { - /// `pae([CONTEXT_VALUE, <encoding label>, value])` — a typed leaf. - pub(super) const CONTEXT_VALUE: &[u8] = b"vitaminc/prf/context-value/v1"; - /// `pae([OPTION_SOME, inner])`. - pub(super) const OPTION_SOME: &[u8] = b"vitaminc/prf/option-some/v1"; - /// `pae([MAP_ENTRY, base, key])` — `key` is raw caller bytes. - pub(super) const MAP_ENTRY: &[u8] = b"vitaminc/prf/map-entry/v1"; - /// `pae([REFINE, base, component])` — both are encoded contexts. - pub(super) const REFINE: &[u8] = b"vitaminc/prf/refine/v1"; -} - -/// Do encoded **AAD** bytes carry no caller-supplied information? See -/// [`EncryptContext`] for what that means and why it is rejected. -/// -/// The AAD channel carries no framing tags of its own: a leaf is its own raw -/// bytes (`"x".into_aad() == b"x"`), and `None`, `Some(_)` and tuples are -/// bare PAE nodes. So the rule is purely structural — degenerate if empty, or -/// if it parses as a PAE (`LE64(count) || (LE64(len) || piece)*`) whose every -/// piece is itself degenerate. Bytes that are not a well-formed PAE are -/// caller content and count as information. -/// -/// Covers `()`, `""`, `b""`, `None` (`pae([])`), `Some(<empty>)` and tuples -/// of empties at any nesting depth — and `0u64`, whose eight zero bytes are -/// byte-identical to `pae([])`. -/// -/// (The tags vitaminc applies *inside* the cipher — `Aad::for_leaf`, -/// `for_map_entry`, the markers — are derived after this check runs, from -/// the caller-visible AAD this sees.) -/// -/// In-crate, third-party leaves go through [`supplied_aad`] / -/// [`supplied_prf_context`], the one choke point whose signature survives -/// the vitaminc#291 migration — prefer those in Rust code: they check and -/// encode in one step, so the value checked is the value sealed. This bare -/// predicate is public *only* for runtime front-ends whose contexts arrive -/// as FFI bytes rather than Rust values (the WASI guest's record plans), -/// which need the same predicate at parse time to report a precise status. -/// It is temporary scaffolding on those terms: when vitaminc#291 moves -/// non-emptiness into the context type, this function is removed with the -/// rest of the runtime checks (a semver-visible removal, accepted while the -/// crate is 0.x). -pub fn is_degenerate_aad(bytes: &[u8]) -> bool { - if bytes.is_empty() { - return true; - } - match parse_pae(bytes) { - Some(pieces) => pieces.iter().all(|piece| is_degenerate_aad(piece)), - None => false, - } -} - -/// Do encoded **PRF context** bytes carry no caller-supplied information? -/// -/// Unlike the AAD channel, every caller value here is wrapped in a framing -/// node — `"x".into_prf_context()` is `pae([CONTEXT_VALUE, <utf8 label>, -/// b"x"])` — so the check has to see past the framing to reach the value. -/// Framing is recognised by exact tag *and* arity at piece 0, and the -/// recursion descends only into the positions that actually hold caller -/// data. That is what keeps caller bytes from ever being mistaken for a tag: -/// caller data never lands at piece 0 of a framing node, because it is always -/// wrapped one level deeper. -/// -/// A node that is not framing (a tuple, or a `PrfContext` the caller built by -/// hand) is degenerate only if every one of its pieces is. Bytes that are not -/// a well-formed PAE are caller content. -/// -/// Crate-private, on the same terms as [`is_degenerate_aad`]: the public -/// surface is [`supplied_prf_context`]. -pub(crate) fn is_degenerate_prf_context(bytes: &[u8]) -> bool { - if bytes.is_empty() { - return true; - } - let Some(pieces) = parse_pae(bytes) else { - return false; - }; - match (pieces.first(), pieces.len()) { - // The value is raw caller bytes, not a nested context: judge it - // structurally. This is what still rejects `0u64` — eight zero bytes - // are byte-identical to `pae([])`. - (Some(&tag), 3) if tag == prf_framing::CONTEXT_VALUE => is_degenerate_aad(pieces[2]), - (Some(&tag), 2) if tag == prf_framing::OPTION_SOME => is_degenerate_prf_context(pieces[1]), - (Some(&tag), 3) if tag == prf_framing::MAP_ENTRY => { - is_degenerate_prf_context(pieces[1]) && pieces[2].is_empty() - } - (Some(&tag), 3) if tag == prf_framing::REFINE => { - is_degenerate_prf_context(pieces[1]) && is_degenerate_prf_context(pieces[2]) - } - _ => pieces.iter().all(|piece| is_degenerate_prf_context(piece)), - } -} - -/// Validate and encode a supplied **AAD** context in one step: the encoded -/// bytes if the context carries caller information, [`Error::EmptyContext`] -/// if it is degenerate (`""`, `None`, `Some("")`, `0u64`, nested empties — -/// see [`EncryptContext`]). -/// -/// This is the choke point every built-in leaf goes through on the AEAD -/// channel, and the one a third-party ciphertext-like leaf should call too -/// (see the [module docs](self#extending-with-your-own-sem-type)): checking -/// and encoding are one step, so a leaf cannot encode one value and check -/// another. ([`is_degenerate_aad`] does exist bare, for runtime front-ends -/// whose contexts are FFI bytes rather than Rust values — a Rust leaf that -/// reaches for it instead of this reintroduces exactly the check/use -/// divergence this signature prevents.) The *signature* is stable across the -/// [vitaminc#291](https://github.com/cipherstash/vitaminc/issues/291) -/// migration: when non-emptiness moves into the context type, the runtime -/// check here collapses to a conversion, and callers do not change. -pub fn supplied_aad<'c>(context: impl IntoAad<'c>) -> Result<Aad<'static>, Error> { - let aad = context.into_aad().into_owned(); - if is_degenerate_aad(aad.as_bytes()) { - return Err(Error::EmptyContext); - } - Ok(aad) -} - -/// Validate and encode a supplied **PRF** context in one step — the -/// derivation-channel twin of [`supplied_aad`], for index-term leaves. -/// Returns the encoded context, or [`Error::EmptyContext`] for one that -/// carries no caller information. -/// -/// Domain-separate the result under your own label before deriving from it, -/// as the recipe in the -/// [module docs](self#extending-with-your-own-sem-type) shows. -pub fn supplied_prf_context<'c>( - context: impl IntoPrfContext<'c>, -) -> Result<vitaminc_prf::PrfContext<'static>, Error> { - let context = context.into_prf_context().into_owned(); - if is_degenerate_prf_context(context.as_bytes()) { - return Err(Error::EmptyContext); - } - Ok(context) -} - -/// Parse `bytes` as exactly one PAE encoding: `LE64(count)` then `count` -/// `LE64(len) || piece` frames, consuming every byte. `None` if the bytes are -/// not that shape. -fn parse_pae(bytes: &[u8]) -> Option<Vec<&[u8]>> { - fn le64(bytes: &[u8]) -> Option<(usize, &[u8])> { - let (head, rest) = bytes.split_first_chunk::<8>()?; - let n = usize::try_from(u64::from_le_bytes(*head)).ok()?; - Some((n, rest)) - } - let (count, mut rest) = le64(bytes)?; - let mut pieces = Vec::with_capacity(count.min(16)); - for _ in 0..count { - let (len, after_len) = le64(rest)?; - if after_len.len() < len { - return None; - } - let (piece, tail) = after_len.split_at(len); - pieces.push(piece); - rest = tail; - } - rest.is_empty().then_some(pieces) -} - // ============================================================================= // Cipher-owned output types // ============================================================================= @@ -716,29 +513,51 @@ impl<K> DecryptTarget for StackCipher<K> { /// # The context parameter /// /// `Ctx` is a parameter of the trait, not of the method, so that each -/// implementation can say which contexts it accepts: a leaf demands a -/// [`SuppliedContext`] (it has nothing else to authenticate under), a record -/// passes whatever it is given on to its fields and inherits their demands -/// through its where clause, and a row whose fields carry their own contexts -/// is implemented for `()` alone — a context handed to it would go nowhere. -/// The call site then gets one of two answers from the compiler: +/// implementation can say which contexts it accepts. A leaf is implemented +/// for [`NonEmpty<T>`] alone — it has nothing else to authenticate under, +/// and `()` is the empty context it must never derive under (see the +/// [module docs](self#contexts)). A derived record is implemented twice: +/// for `()`, deriving each field under the context the field carries itself +/// (a `context = ".."` literal, or the one a `struct = ..` derive infers), +/// and for `NonEmpty<T>`, deriving each field under that context extended +/// with the caller's (`("users/age", id)`) — or, for a field with no +/// context of its own, under the caller's as it is. No record accepts a +/// context it then discards. The call site then +/// gets one of two answers from the compiler: /// [`encrypt_into(&cipher)`](EncryptInto::encrypt_into) resolves against -/// `EncryptFrom<S, C, ()>` and exists exactly for the outputs that need no -/// context; everything else takes +/// `EncryptFrom<S, C, ()>`, so it compiles for exactly the outputs whose +/// every leaf has a context of its own; anything else takes /// [`encrypt_into_with_context`](EncryptInto::encrypt_into_with_context). /// /// The trait itself places no bound on `Ctx`; an implementation that uses the -/// context bounds it as [`EncryptContext<'c>`] with the context's own -/// lifetime as an impl parameter (see the +/// context bounds it as `NonEmpty<T>` with `T: IntoAad<'c> + IntoPrfContext<'c>` +/// and the context's own lifetime as an impl parameter (see the /// [module docs](self#extending-with-your-own-sem-type)). #[diagnostic::on_unimplemented( message = "`{Self}` is not an encrypted form of `{S}` under a `{Ctx}` context", label = "not `EncryptFrom<{S}, _, {Ctx}>`", - note = "an output that reaches a leaf exists only under a supplied context \ - (`encrypt_into_with_context`); one whose fields carry their own, only under `()` \ - (`encrypt_into`)" + note = "a leaf — a ciphertext or an index term — exists only under a `NonEmpty<_>` context: \ + `encrypt_into` passes `()`, so use `encrypt_into_with_context(&cipher, context)`, or \ + give the field a `context = \"..\"` of its own", + note = "a context is anything vitaminc encodes (`&str`, `String`, bytes, integers, `Option`s \ + and pairs of those), proven non-empty: `nonempty!(\"users/email\")` for a literal, \ + `NonEmpty::new(value)?` for a runtime value, a bare integer for an id" )] pub trait EncryptFrom<S, C: EncryptTarget, Ctx>: Sized { + /// Whether encrypting from `S` requests ZeroKMS data keys under the + /// caller's context (or an extension of it), so that the context must + /// render within [`Descriptor::MAX_LEN`]. A ciphertext leaf does; a + /// term derives locally and never renders a descriptor, so it says + /// `false` and a column of terms is not held to the limit. The default + /// is the safe over-approximation: a type that does not say is treated + /// as keyed, and a derived record is keyed if any field might be (the + /// derive does not compute this per field, so a record of terms alone + /// is checked like any other record). + /// + /// Read by the column implementations, which check the context once + /// before walking their elements ([`ElementContext`]). + const KEYED: bool = true; + /// Encrypt `source` into `Self` under `context`, returning the cipher's /// [`Output`](EncryptTarget::Output). No I/O happens here; work needing /// ZeroKMS is carried as requests and settles when the output is awaited. @@ -765,15 +584,51 @@ pub trait EncryptFrom<S, C: EncryptTarget, Ctx>: Sized { /// terms (which have no plaintext to recover) simply do not participate. /// /// `Ctx` is a trait parameter for the reason it is on [`EncryptFrom`]: the -/// leaves accept only a [`SuppliedContext`], so an encrypted type that needs +/// leaves accept only a [`NonEmpty<T>`], so an encrypted type that needs /// no context from the caller is exactly one that implements -/// `DecryptInto<P, C, ()>` — what [`DecryptFrom::decrypt_from`] asks for. +/// `DecryptInto<P, C, ()>` — what [`DecryptFrom::decrypt_from`] asks for — +/// and a derived record opened under `NonEmpty<T>` extends its fields' +/// contexts with it, exactly as it did when encrypting. +/// +/// A record whose ciphertext field carries a `context = ".."` literal and +/// whose other fields are terms opens under `()` *and* under any +/// `NonEmpty<T>`, but not interchangeably. The terms open nothing, so a +/// context reaches them and is checked for nothing; the ciphertext +/// authenticates under its literal extended with the caller's context +/// exactly as it was sealed — `()` leaves the literal alone, a `NonEmpty<T>` +/// extends it. A record sealed with `encrypt_into` opens with +/// `decrypt_from` and one sealed under an extension opens only under the +/// same extension; a wrong caller context is the refusal it is against a +/// leaf: [`Error::Kms`], ZeroKMS declining the key retrieval under the +/// other descriptor before the AEAD runs, or [`Error::Aead`] from a key +/// source that ignores descriptors. What the caller's context cannot be is +/// something that is not a context at all: +/// +/// ```compile_fail,E0277 +/// use stack_encrypt::sem::EqualityTerm; +/// use stack_encrypt::{DecryptInto, EncryptFrom, StackCipher, StackCipherText}; +/// use stack_kms::FakeDataKeySource; +/// +/// #[derive(EncryptFrom, DecryptInto)] +/// #[stash(plaintext = u32)] +/// struct Rec { +/// #[stash(context = "rec/c")] +/// c: StackCipherText, +/// hm: EqualityTerm, +/// } +/// +/// async fn decrypt(cipher: &StackCipher<FakeDataKeySource>, rec: Rec) { +/// // `true` is neither `()` nor a `NonEmpty<_>`: a compile error, not a +/// // value the term fields quietly swallow. +/// let _: u32 = rec.decrypt_into(cipher, true).await.unwrap(); +/// } +/// ``` #[diagnostic::on_unimplemented( message = "`{Self}` does not decrypt to `{P}` under a `{Ctx}` context", label = "not `DecryptInto<{P}, _, {Ctx}>`", - note = "a value that reaches a leaf decrypts only under the context it was encrypted under \ - (`decrypt_into(&cipher, context)` / `decrypt_from_with_context`); one whose fields \ - carry their own, only under `()` (`decrypt_from`)" + note = "a leaf decrypts only under a `NonEmpty<_>` context — the one it was encrypted under \ + (`decrypt_into(&cipher, context)` / `decrypt_from_with_context`); an output whose \ + fields carry their own opens under `()` (`decrypt_from`) as well" )] pub trait DecryptInto<P, C: DecryptTarget, Ctx>: Sized { /// Decrypt `self` into `P`, authenticating against `context` — which @@ -789,16 +644,16 @@ pub trait DecryptInto<P, C: DecryptTarget, Ctx>: Sized { /// /// The `Into` to [`EncryptFrom`]'s `From` — blanket-implemented for every /// type, never implemented by hand. The target type is usually inferred from -/// the binding. Which of the two methods applies is not a choice: a leaf, or -/// a record that hands the caller's context to one, exists only under a -/// [`SuppliedContext`] and takes the second; a row whose fields name their -/// own contexts needs nothing from the caller and takes the first. The +/// the binding. A leaf, or a record that hands the caller's context to one, +/// exists only under a [`NonEmpty<T>`] and takes the second form; a record +/// whose fields name their own contexts takes either — the first as it is, +/// the second with every field's context extended by the caller's. The /// split is vitaminc's `encrypt` / `encrypt_with_aad`, decided by the type. /// /// ``` /// use stack_encrypt::sem::EqualityTerm; /// use stack_encrypt::target::EncryptInto; -/// use stack_encrypt::StackCipher; +/// use stack_encrypt::{nonempty, NonEmpty, StackCipher}; /// use stack_kms::FakeDataKeySource; /// /// # tokio::runtime::Builder::new_current_thread().enable_all().build().unwrap().block_on(async { @@ -808,15 +663,22 @@ pub trait DecryptInto<P, C: DecryptTarget, Ctx>: Sized { /// .await /// .unwrap(); /// +/// // A literal, checked at compile time. /// let term: EqualityTerm = "alice" -/// .encrypt_into_with_context(&cipher, "users/email") +/// .encrypt_into_with_context(&cipher, nonempty!("users/email")) /// .await?; -/// # Ok::<(), stack_encrypt::Error>(()) +/// // A runtime value, checked once where it is built. +/// let column = String::from("users/email"); +/// let same: EqualityTerm = "alice" +/// .encrypt_into_with_context(&cipher, NonEmpty::new(column)?) +/// .await?; +/// assert_eq!(term, same); +/// # Ok::<(), Box<dyn std::error::Error>>(()) /// # }).unwrap(); /// ``` pub trait EncryptInto { /// Encrypt `self` into a `T` that needs no context from the caller — - /// a row whose fields carry their own. See [`EncryptFrom`]. + /// a record whose fields carry their own. See [`EncryptFrom`]. /// /// Passes `()`, so this exists only for `T: EncryptFrom<Self, C, ()>`: /// against a leaf the compiler says to use @@ -829,19 +691,20 @@ pub trait EncryptInto { /// Encrypt `self` into `T` under `context`. See [`EncryptFrom`]. /// - /// The context must be one the caller actually supplies - /// ([`SuppliedContext`]): passing `()` here, or a supplied context to an - /// output that takes none — a row — is a compile error either way, with - /// [`encrypt_into`](Self::encrypt_into) as the answer to both. - fn encrypt_into_with_context<'a, 'c, T, C, Ctx>( + /// The context is anything that converts into a [`NonEmpty<T>`]: a + /// `NonEmpty` itself — [`nonempty!`](crate::nonempty) for a literal, + /// [`NonEmpty::new`] for a runtime value — or a bare integer, which is + /// never empty. Passing `()` here is a compile error, with + /// [`encrypt_into`](Self::encrypt_into) as the answer. + fn encrypt_into_with_context<'a, T, C, N, Ctx>( &'a self, cipher: &'a C, context: Ctx, ) -> C::Output<'a, T> where C: EncryptTarget, - T: EncryptFrom<Self, C, Ctx> + 'a, - Ctx: SuppliedContext<'c>, + T: EncryptFrom<Self, C, NonEmpty<N>> + 'a, + Ctx: Into<NonEmpty<N>>, Self: Sized; } @@ -854,17 +717,17 @@ impl<S> EncryptInto for S { T::encrypt_from(self, cipher, ()) } - fn encrypt_into_with_context<'a, 'c, T, C, Ctx>( + fn encrypt_into_with_context<'a, T, C, N, Ctx>( &'a self, cipher: &'a C, context: Ctx, ) -> C::Output<'a, T> where C: EncryptTarget, - T: EncryptFrom<Self, C, Ctx> + 'a, - Ctx: SuppliedContext<'c>, + T: EncryptFrom<Self, C, NonEmpty<N>> + 'a, + Ctx: Into<NonEmpty<N>>, { - T::encrypt_from(self, cipher, context) + T::encrypt_from(self, cipher, context.into()) } } @@ -875,8 +738,8 @@ impl<S> EncryptInto for S { /// plaintext; never implemented by hand. /// /// (The implemented trait, [`DecryptInto`], always takes a context: -/// `encrypted.decrypt_into(&cipher, "users/age")` is the method-call form -/// for a value that needs one.) +/// `encrypted.decrypt_into(&cipher, nonempty!("users/age"))` is the +/// method-call form for a value that needs one.) pub trait DecryptFrom: Sized { /// Decrypt `source` — an encrypted type that needs no context from the /// caller — into `Self`. See [`DecryptInto`]. @@ -889,19 +752,18 @@ pub trait DecryptFrom: Sized { /// Decrypt `source` into `Self`, authenticating against `context`. See /// [`DecryptInto`]. /// - /// The context must be one the caller actually supplies - /// ([`SuppliedContext`]): passing `()` here, or a supplied context to an - /// encrypted type that takes none — a row — is a compile error either - /// way, with [`decrypt_from`](Self::decrypt_from) as the answer to both. - fn decrypt_from_with_context<'a, 'c, S, C, Ctx>( + /// The context is anything that converts into a [`NonEmpty<T>`], as for + /// [`encrypt_into_with_context`](EncryptInto::encrypt_into_with_context), + /// and must be the one the value was encrypted under. + fn decrypt_from_with_context<'a, S, C, N, Ctx>( source: S, cipher: &'a C, context: Ctx, ) -> C::Output<'a, Self> where C: DecryptTarget, - S: DecryptInto<Self, C, Ctx> + 'a, - Ctx: SuppliedContext<'c>, + S: DecryptInto<Self, C, NonEmpty<N>> + 'a, + Ctx: Into<NonEmpty<N>>, Self: 'a; } @@ -915,18 +777,18 @@ impl<P> DecryptFrom for P { source.decrypt_into(cipher, ()) } - fn decrypt_from_with_context<'a, 'c, S, C, Ctx>( + fn decrypt_from_with_context<'a, S, C, N, Ctx>( source: S, cipher: &'a C, context: Ctx, ) -> C::Output<'a, Self> where C: DecryptTarget, - S: DecryptInto<Self, C, Ctx> + 'a, - Ctx: SuppliedContext<'c>, + S: DecryptInto<Self, C, NonEmpty<N>> + 'a, + Ctx: Into<NonEmpty<N>>, Self: 'a, { - source.decrypt_into(cipher, context) + source.decrypt_into(cipher, context.into()) } } @@ -937,7 +799,7 @@ impl<P> DecryptFrom for P { /// it is what lets `#[derive(DecryptInto)]` find the ciphertext field on its /// own, with no attribute: the derive counts the fields whose /// [`DECRYPTABLE`](Self::DECRYPTABLE) is `true` and requires exactly one -/// (per plaintext field, for a row). `#[derive(EncryptFrom)]` emits it for +/// (per plaintext field, for a `struct = ..` derive). `#[derive(EncryptFrom)]` emits it for /// a record — a record is decryptable if any of its fields is, or outright /// when `#[stash(decrypt)]` names the opened fields — and the /// built-in leaves implement it by hand: [`StackCipherText`] is, the @@ -1000,7 +862,7 @@ pub trait Decryptable { /// their [`DecryptInto`], terms return `None` for every `P`. (Not a /// supertrait relationship: `#[derive(DecryptInto)]` emits this for every /// record, and a record that only decrypts — no `EncryptFrom` derive to -/// emit its `Decryptable` — must still be a field of a row in the explicit +/// emit its `Decryptable` — must still be a field of a record in the explicit /// mode.) #[diagnostic::on_unimplemented( message = "`{Self}` cannot be a field of an automatically decrypted record", @@ -1045,7 +907,7 @@ impl<S: Decryptable> Decryptable for Vec<S> { impl<S, T, K, Ctx> DecryptField<Vec<T>, StackCipher<K>, Ctx> for Vec<S> where S: Decryptable + DecryptField<T, StackCipher<K>, Ctx>, - Ctx: Clone, + Ctx: ElementContext, { fn decrypt_field<'a>( self, @@ -1059,6 +921,11 @@ where if !S::DECRYPTABLE { return None; } + if !self.is_empty() { + if let Err(e) = context.check_descriptor() { + return Some(Pending::failed(cipher, e)); + } + } let items = self .into_iter() .map(|item| { @@ -1117,35 +984,35 @@ where /// drawn in the same traversal order the tree was built in. /// /// A leaf has nothing of its own to authenticate under, so it exists only -/// for a [`SuppliedContext`]: `()` is a compile error here. -impl<'c, S, K, Ctx> EncryptFrom<S, StackCipher<K>, Ctx> for StackCipherText +/// for a [`NonEmpty<T>`]: `()` is a compile error here. (An empty context +/// would leave the leaf AAD carrying only the key tag, making ciphertexts +/// transplantable between empty-context fields — see the +/// [module docs](self#contexts).) +impl<'c, S, K, T> EncryptFrom<S, StackCipher<K>, NonEmpty<T>> for StackCipherText where S: Encrypt + Clone, - Ctx: EncryptContext<'c> + SuppliedContext<'c>, + T: IntoAad<'c>, { fn encrypt_from<'a>( source: &'a S, cipher: &'a StackCipher<K>, - context: Ctx, + context: NonEmpty<T>, ) -> Pending<'a, Self, K> where Self: 'a, { - // An empty context would leave the leaf AAD carrying only the key - // tag, making ciphertexts transplantable between empty-context - // fields — see `EncryptContext`. - let aad = match supplied_aad(context) { - Ok(aad) => aad, - Err(error) => return Pending::failed(cipher, error), - }; + let context = context.into_aad_piece(); + let descriptor = Descriptor::from_piece(&context); + let aad = context.into_aad().into_owned(); match source.clone().encrypt_with_aad(cipher, aad) { - Ok(tree) => seal_pending(cipher, tree), + Ok(tree) => seal_pending(cipher, tree, descriptor), Err(_) => Pending::ready(cipher, Err(Error::Aead)), } } } /// Seal a pending tree: one [`Request::generate_data_key`] per keyed leaf, +/// every one under `descriptor` — the tree's root context, rendered — with /// keys drawn back in the same traversal order the tree was built in. /// /// Both ways of encrypting go through here — the target-directed @@ -1156,8 +1023,15 @@ where pub(crate) fn seal_pending<'a, K>( cipher: &'a StackCipher<K>, tree: PendingStackCipherText, + descriptor: Descriptor, ) -> Pending<'a, StackCipherText, K> { - let requests = std::iter::repeat_with(Request::generate_data_key) + // Fast path: refuse an over-long descriptor before a single request + // exists, not after one per leaf has been built. `dispatch` is the gate + // proper, and checks every request's descriptor. + if let Err(e) = descriptor.check() { + return Pending::ready(cipher, Err(e)); + } + let requests = std::iter::repeat_with(|| Request::generate_data_key(descriptor.clone())) .take(tree.key_count()) .collect(); Pending::request(cipher, requests, move |responses| { @@ -1167,14 +1041,23 @@ pub(crate) fn seal_pending<'a, K>( } /// Bind retrieved keys onto a ciphertext: one [`Request::retrieve_data_key`] -/// per keyed leaf, keys zipped back on in the same depth-first order. The -/// decrypt twin of [`seal_pending`], and likewise the single path for both -/// `decrypt_into` and the cipher-directed [`StackCipher::decipher`]. +/// per keyed leaf, every one under `descriptor` (the one the tree was +/// sealed under), keys zipped back on in the same depth-first order. The +/// decrypt twin of [`seal_pending`], behind the cipher-directed +/// [`StackCipher::decipher`]. The target-directed `decrypt_into` below +/// shares both halves — [`retrieve_requests`] and +/// [`decipher_from_responses`] — but runs the value's `Decrypt` impl in the +/// same fulfilment rather than composing a second pending over this one. pub(crate) fn decipher_pending<'a, K>( cipher: &'a StackCipher<K>, ciphertext: StackCipherText, + descriptor: Descriptor, ) -> Pending<'a, StackDecipher, K> { - let requests = retrieve_requests(&ciphertext); + // Fast path, as in `seal_pending`; `dispatch` is the gate. + if let Err(e) = descriptor.check() { + return Pending::ready(cipher, Err(e)); + } + let requests = retrieve_requests(&ciphertext, &descriptor); Pending::request(cipher, requests, move |responses| { decipher_from_responses(ciphertext, responses) }) @@ -1204,23 +1087,28 @@ fn decipher_from_responses( /// (`iv` + `tag` are lifted out of the tree during the synchronous build); /// the fulfilment binds the retrieved keys back onto the leaves and lets the /// value's `Decrypt` impl drive the opening. -impl<'c, T, K, Ctx> DecryptInto<T, StackCipher<K>, Ctx> for StackCipherText +/// +/// Symmetric with the encrypt side: the target layer never encrypts under an +/// empty context, so it never decrypts under one either — the context is a +/// [`NonEmpty<T>`] here too. +impl<'c, T, K, A> DecryptInto<T, StackCipher<K>, NonEmpty<A>> for StackCipherText where T: Decrypt<'static> + 'static, - Ctx: SuppliedContext<'c>, + A: IntoAad<'c>, { - fn decrypt_into<'a>(self, cipher: &'a StackCipher<K>, context: Ctx) -> Pending<'a, T, K> + fn decrypt_into<'a>(self, cipher: &'a StackCipher<K>, context: NonEmpty<A>) -> Pending<'a, T, K> where Self: 'a, T: 'a, { - // Symmetric with the encrypt side: the target layer never encrypts - // under an empty context, so it never decrypts under one either. - let aad = match supplied_aad(context) { - Ok(aad) => aad, - Err(error) => return Pending::failed(cipher, error), - }; - let requests = retrieve_requests(&self); + let context = context.into_aad_piece(); + let descriptor = Descriptor::from_piece(&context); + // Fast path, as in `seal_pending`; `dispatch` is the gate. + if let Err(e) = descriptor.check() { + return Pending::ready(cipher, Err(e)); + } + let requests = retrieve_requests(&self, &descriptor); + let aad = context.into_aad().into_owned(); Pending::request(cipher, requests, move |responses| { let decipher = decipher_from_responses(self, responses)?; T::decrypt_with_aad(decipher, aad).map_err(Error::from) @@ -1228,30 +1116,39 @@ where } } -/// One [`Request::retrieve_data_key`] per keyed leaf, in the same depth-first -/// order `bind_keys` will consume the responses. -fn retrieve_requests(ciphertext: &StackCipherText) -> Vec<Request> { +/// One [`Request::retrieve_data_key`] per keyed leaf, all under +/// `descriptor`, in the same depth-first order `bind_keys` will consume the +/// responses. +fn retrieve_requests(ciphertext: &StackCipherText, descriptor: &Descriptor) -> Vec<Request> { let mut out = Vec::new(); - collect_retrieve_requests(ciphertext, &mut out); + collect_retrieve_requests(ciphertext, descriptor, &mut out); out } -fn collect_retrieve_requests(ciphertext: &StackCipherText, out: &mut Vec<Request>) { +fn collect_retrieve_requests( + ciphertext: &StackCipherText, + descriptor: &Descriptor, + out: &mut Vec<Request>, +) { match ciphertext { CipherText::Single(leaf) | CipherText::None(leaf) | CipherText::EmptySequence(leaf) | CipherText::EmptyMap(leaf) => { - out.push(Request::retrieve_data_key(*leaf.iv(), leaf.tag().to_vec())); + out.push(Request::retrieve_data_key( + *leaf.iv(), + leaf.tag().to_vec(), + descriptor.clone(), + )); } CipherText::Sequence(items) => { for item in items { - collect_retrieve_requests(item, out); + collect_retrieve_requests(item, descriptor, out); } } CipherText::Map(entries) => { for (_, value) in entries { - collect_retrieve_requests(value, out); + collect_retrieve_requests(value, descriptor, out); } } CipherText::Passthrough(_) => {} @@ -1269,26 +1166,18 @@ fn collect_retrieve_requests(ciphertext: &StackCipherText, out: &mut Vec<Request /// record whose value is a list — see the [module docs](self). /// /// The context is passed through untouched, and so is the obligation: a -/// column of leaves needs a [`SuppliedContext`] because its leaves do, a -/// column of rows accepts `()` because its rows do. Neither is decided here, -/// and neither is an empty context, which the leaves reject the moment a -/// value reaches them. -/// -/// A consequence, accepted knowingly: an *empty* container performs no -/// check at all, so a degenerate supplied context (`""` from a -/// runtime-resolved descriptor, say) succeeds against a table with no rows -/// and first fails on the first populated value. The container cannot -/// pre-check — its `Ctx` is legitimately `()` for a column of rows, and -/// only the element type knows whether a context is even owed. Fail-fast -/// returns for free at -/// [vitaminc#291](https://github.com/cipherstash/vitaminc/issues/291), when -/// a supplied context validates non-emptiness at construction, before any -/// container is reached. +/// column of leaves needs a [`NonEmpty<T>`] because its leaves do, a column +/// of records whose fields carry their own contexts accepts `()` as well +/// because they do. Neither is decided here. What *is* decided here is +/// that an over-long context is refused once, before the column is walked +/// ([`ElementContext`]), not once per element. impl<S, T, K, Ctx> EncryptFrom<Vec<S>, StackCipher<K>, Ctx> for Vec<T> where T: EncryptFrom<S, StackCipher<K>, Ctx>, - Ctx: Clone, + Ctx: ElementContext, { + const KEYED: bool = T::KEYED; + fn encrypt_from<'a>( source: &'a Vec<S>, cipher: &'a StackCipher<K>, @@ -1297,6 +1186,11 @@ where where Self: 'a, { + if T::KEYED && !source.is_empty() { + if let Err(e) = context.check_descriptor() { + return Pending::failed(cipher, e); + } + } let items = source .iter() .map(|item| T::encrypt_from(item, cipher, context.clone())) @@ -1305,17 +1199,24 @@ where } } -/// The column decrypt mirror: one batched retrieve for every row. +/// The column decrypt mirror: one batched retrieve for every element. impl<S, T, K, Ctx> DecryptInto<Vec<T>, StackCipher<K>, Ctx> for Vec<S> where S: DecryptInto<T, StackCipher<K>, Ctx>, - Ctx: Clone, + Ctx: ElementContext, { fn decrypt_into<'a>(self, cipher: &'a StackCipher<K>, context: Ctx) -> Pending<'a, Vec<T>, K> where Self: 'a, Vec<T>: 'a, { + // Every `DecryptInto` opens keyed leaves (terms are one-way), so + // the only column with nothing to bind is the empty one. + if !self.is_empty() { + if let Err(e) = context.check_descriptor() { + return Pending::failed(cipher, e); + } + } let items = self .into_iter() .map(|item| item.decrypt_into(cipher, context.clone())) @@ -1324,6 +1225,55 @@ where } } +/// The context a column hands to each of its elements: `()` or a +/// [`NonEmpty<T>`], the two shapes the target layer takes. +/// +/// A column clones its context into every element, and every keyed element +/// renders it as its ZeroKMS [`Descriptor`]. The rendering is bounded +/// ([`Descriptor::MAX_LEN`]) but the context is not, so a column checks the +/// rendering **once**, here, before it walks its elements: an over-long +/// context costs one rendering and is refused whole, not one rendering per +/// element before [`Pending::all`] surfaces the first refusal. Under `()` +/// the elements carry their own contexts and there is nothing to check. +/// +/// The check is on the context the column was given. A derived record +/// extends it with each field's own literal before its leaves render it, +/// and that extension can exceed the limit where the caller's part alone +/// did not; the leaf then refuses it, once per row — each such rendering +/// bounded by [`Descriptor::MAX_LEN`] plus the literal, since the caller's +/// part has already been shown to fit. And a column whose elements request +/// no keys under the context ([`EncryptFrom::KEYED`] is `false`, as for a +/// column of terms), or that has no elements, is not checked at all: there +/// is no descriptor to bind. +/// +/// Sealed: the two implementations are the two shapes. +pub trait ElementContext: Clone + sealed::Sealed { + /// Render the descriptor the elements will render, and refuse it now if + /// ZeroKMS could not bind it. + fn check_descriptor(&self) -> Result<(), Error>; +} + +mod sealed { + pub trait Sealed {} + impl Sealed for () {} + impl<T> Sealed for vitaminc_protected::NonEmpty<T> {} +} + +impl ElementContext for () { + fn check_descriptor(&self) -> Result<(), Error> { + Ok(()) + } +} + +impl<'c, T> ElementContext for NonEmpty<T> +where + T: IntoAad<'c> + Clone, +{ + fn check_descriptor(&self) -> Result<(), Error> { + Descriptor::of(self.clone()).check() + } +} + /// An optional field: `None` encrypts to `None` at the target layer (an /// absent *record field*, carrying no requests). This is distinct from /// `Option<S> → StackCipherText` via [`Encrypt`], which produces an @@ -1332,6 +1282,8 @@ impl<S, T, K, Ctx> EncryptFrom<Option<S>, StackCipher<K>, Ctx> for Option<T> where T: EncryptFrom<S, StackCipher<K>, Ctx> + MaybeSend, { + const KEYED: bool = T::KEYED; + fn encrypt_from<'a>( source: &'a Option<S>, cipher: &'a StackCipher<K>, @@ -1340,8 +1292,6 @@ where where Self: 'a, { - // `None` derives nothing and checks nothing: the context is the - // leaf's to validate (see the `Vec` implementation above). match source { Some(value) => T::encrypt_from(value, cipher, context).map(Some), None => Pending::ready(cipher, Ok(None)), @@ -1366,138 +1316,3 @@ where } } } - -#[cfg(test)] -mod context_tests { - use vitaminc_aead::{Aad, IntoAad}; - use vitaminc_prf::{IntoPrfContext, PrfContext}; - - use super::{is_degenerate_aad, is_degenerate_prf_context}; - - fn aad<'a>(ctx: impl IntoAad<'a>) -> bool { - is_degenerate_aad(ctx.into_aad().as_bytes()) - } - - fn prf<'a>(ctx: impl IntoPrfContext<'a>) -> bool { - is_degenerate_prf_context(ctx.into_prf_context().as_bytes()) - } - - #[test] - fn empty_encodings_are_degenerate_on_both_channels() { - assert!(aad(())); - assert!(aad("")); - assert!(aad(b"".as_slice())); - assert!(aad(None::<&str>)); - assert!(aad(Some(""))); - assert!(aad(("", ""))); - assert!(aad(Some(None::<&str>))); - assert!(aad((None::<&str>, Some("")))); - - assert!(prf(())); - assert!(prf("")); - assert!(prf(b"".as_slice())); - assert!(prf(None::<&str>)); - assert!(prf(Some(""))); - assert!(prf(("", ""))); - assert!(prf(Some(None::<&str>))); - assert!(prf(PrfContext::empty())); - assert!(prf(PrfContext::pae(&[]))); - } - - #[test] - fn contexts_carrying_information_are_not() { - assert!(!aad("users/email")); - assert!(!aad("x")); - assert!(!aad(Some("users/email"))); - assert!(!aad(("users", "email"))); - assert!(!aad(("", "email"))); - assert!(!aad(Aad::from_slice(b"raw"))); - assert!(!aad(7u64)); - - assert!(!prf("users/email")); - assert!(!prf("x")); - assert!(!prf(Some("users/email"))); - assert!(!prf(("users", "email"))); - assert!(!prf(("", "email"))); - assert!(!prf(7u64)); - assert!(!prf(PrfContext::from_slice(b"raw"))); - } - - /// Caller data that *looks* like vitaminc framing is still caller data. - /// - /// The tags are matched by exact value at piece 0 of a framing node, and - /// caller data never lands there — on the PRF channel it is wrapped a - /// level deeper by `CONTEXT_VALUE`, and the AAD channel has no tags at - /// all. A prefix test over every piece got all of these wrong, rejecting - /// a legitimate context as empty. - #[test] - fn caller_data_shaped_like_framing_still_carries_information() { - for tag in [ - "vitaminc/customer", - "vitaminc/", - "vitaminc/prf/context-value/v1", - "vitaminc/prf/option-some/v1", - "vitaminc/prf/map-entry/v1", - "vitaminc/prf/refine/v1", - "vitaminc/prf/encoding/utf8/v1", - "vitaminc/aead/leaf", - ] { - assert!(!aad(tag), "aad({tag:?})"); - assert!(!aad(Some(tag)), "aad(Some({tag:?}))"); - assert!(!aad((tag, "")), "aad(({tag:?}, \"\"))"); - assert!(!prf(tag), "prf({tag:?})"); - assert!(!prf(Some(tag)), "prf(Some({tag:?}))"); - assert!(!prf((tag, "")), "prf(({tag:?}, \"\"))"); - } - } - - /// A hand-built `PrfContext` that impersonates a framing node is judged - /// on the data it actually frames — the tag alone buys nothing. - #[test] - fn a_hand_built_framing_node_is_judged_on_its_payload() { - let some = - |inner: &[u8]| PrfContext::pae(&[b"vitaminc/prf/option-some/v1", inner]).into_owned(); - // Framing a real value: information. - assert!(!prf(some("users/email".into_prf_context().as_bytes()))); - // Framing an empty value: still empty, and still rejected. - assert!(prf(some("".into_prf_context().as_bytes()))); - // A bare tag with nothing under it is not a well-formed framing node - // and is read as a one-piece PAE of caller bytes. - assert!(!prf(PrfContext::pae(&[b"vitaminc/prf/option-some/v1"]))); - } - - /// The arity check matters: a node carrying the right tag but the wrong - /// number of pieces is not that framing shape and is judged piecewise. - #[test] - fn a_framing_tag_with_the_wrong_arity_is_not_framing() { - let wrong = PrfContext::pae(&[ - b"vitaminc/prf/context-value/v1", - b"vitaminc/prf/encoding/utf8/v1", - b"users", - b"email", - ]); - assert!(!prf(wrong)); - } - - #[test] - fn a_zero_u64_is_byte_identical_to_none_and_rejected_with_it() { - assert_eq!( - 0u64.into_aad().as_bytes(), - None::<&str>.into_aad().as_bytes() - ); - assert!(aad(0u64)); - } - - #[test] - fn a_truncated_or_overlong_pae_is_caller_content() { - // Looks like a count of two but carries only one frame. - let mut bytes = Vec::new(); - bytes.extend_from_slice(&2u64.to_le_bytes()); - bytes.extend_from_slice(&0u64.to_le_bytes()); - assert!(!is_degenerate_aad(&bytes)); - // A well-formed empty PAE followed by a trailing byte. - let mut bytes = 0u64.to_le_bytes().to_vec(); - bytes.push(0); - assert!(!is_degenerate_aad(&bytes)); - } -} diff --git a/packages/stack-encrypt/src/target/pending.rs b/packages/stack-encrypt/src/target/pending.rs index fee0bb9f8..7f71c533c 100644 --- a/packages/stack-encrypt/src/target/pending.rs +++ b/packages/stack-encrypt/src/target/pending.rs @@ -15,7 +15,7 @@ use std::pin::Pin; use stack_kms::{DataKeySource, GenerateKeyPayload, Iv, MaybeSend, RetrieveKeyPayload}; use super::request::{tally, Request, RequestKind, Responses}; -use crate::{Error, StackCipher}; +use crate::{Descriptor, Error, StackCipher}; /// The boxed fulfilment: consumes this pending's slice of the responses and /// produces the output. The `Send` split mirrors [`stack_kms::MaybeSend`] — @@ -273,30 +273,47 @@ async fn dispatch<K: DataKeySource>( cipher: &StackCipher<K>, requests: Vec<Request>, ) -> Result<Responses, Error> { - let mut generate = 0usize; - let mut retrieves: Vec<(Iv, Vec<u8>)> = Vec::new(); + let mut generates: Vec<Descriptor> = Vec::new(); + let mut retrieves: Vec<(Iv, Vec<u8>, Descriptor)> = Vec::new(); for request in requests { match request.into_kind() { - RequestKind::GenerateDataKey => generate += 1, - RequestKind::RetrieveDataKey { iv, tag } => retrieves.push((iv, tag)), + RequestKind::GenerateDataKey { descriptor } => generates.push(descriptor), + RequestKind::RetrieveDataKey { + iv, + tag, + descriptor, + } => retrieves.push((iv, tag, descriptor)), } } - let generated = if generate == 0 { + // ZeroKMS binds a descriptor into a fixed-size block and does not check + // the length itself. This is the gate: every request passes through + // here, including ones built directly from the `pub` constructors. The + // entry points check the root descriptor earlier as well, so a tree of + // ten thousand leaves is refused before ten thousand requests exist — + // a fast path, not a second rule. + generates + .iter() + .chain(retrieves.iter().map(|(_, _, descriptor)| descriptor)) + .try_for_each(Descriptor::check)?; + + let generated = if generates.is_empty() { Vec::new() } else { - // Empty descriptor + empty context for every leaf — see the - // wire-format note in the cipher module docs. - let payloads: Vec<GenerateKeyPayload<'_>> = (0..generate) - .map(|_| GenerateKeyPayload::new("", Cow::Owned(Vec::new()))) + // Each leaf's descriptor is its context, rendered; the lock context + // stays empty — see the descriptor module docs. + let payloads: Vec<GenerateKeyPayload<'_>> = generates + .iter() + .map(|descriptor| GenerateKeyPayload::new(descriptor.as_str(), Cow::Owned(Vec::new()))) .collect(); + let expected = payloads.len(); let keys = cipher .kms() .generate_keys(payloads, Some(cipher.keyset_id()), None) .await?; - if keys.len() != generate { + if keys.len() != expected { return Err(Error::KeyCountMismatch { - expected: generate, + expected, received: keys.len(), }); } @@ -308,7 +325,7 @@ async fn dispatch<K: DataKeySource>( } else { let payloads: Vec<RetrieveKeyPayload<'_>> = retrieves .iter() - .map(|(iv, tag)| RetrieveKeyPayload::new(*iv, "", tag)) + .map(|(iv, tag, descriptor)| RetrieveKeyPayload::new(*iv, descriptor.as_str(), tag)) .collect(); let expected = payloads.len(); let keys = cipher @@ -332,6 +349,7 @@ mod tests { #![allow(clippy::unwrap_used, clippy::panic)] use std::sync::atomic::{AtomicUsize, Ordering}; + use std::sync::Mutex; use stack_kms::{FakeDataKeySource, IdentifiedBy, IndexKey, IndexKeySource, UnverifiedContext}; use uuid::Uuid; @@ -346,6 +364,9 @@ mod tests { inner: FakeDataKeySource, generate_calls: AtomicUsize, retrieve_calls: AtomicUsize, + /// The descriptors of every payload sent, per call, in payload order. + generate_descriptors: Mutex<Vec<Vec<String>>>, + retrieve_descriptors: Mutex<Vec<Vec<String>>>, } impl CountingSource { @@ -356,6 +377,14 @@ mod tests { fn retrieve_calls(&self) -> usize { self.retrieve_calls.load(Ordering::Relaxed) } + + fn generate_descriptors(&self) -> Vec<Vec<String>> { + self.generate_descriptors.lock().unwrap().clone() + } + + fn retrieve_descriptors(&self) -> Vec<Vec<String>> { + self.retrieve_descriptors.lock().unwrap().clone() + } } impl DataKeySource for CountingSource { @@ -366,6 +395,10 @@ mod tests { unverified_context: Option<Cow<'_, UnverifiedContext>>, ) -> Result<Vec<stack_kms::DataKeyWithTag>, stack_kms::Error> { self.generate_calls.fetch_add(1, Ordering::Relaxed); + self.generate_descriptors + .lock() + .unwrap() + .push(payloads.iter().map(|p| p.descriptor.to_owned()).collect()); self.inner .generate_keys(payloads, keyset_id, unverified_context) .await @@ -378,6 +411,10 @@ mod tests { unverified_context: Option<&UnverifiedContext>, ) -> Result<Vec<stack_kms::DataKey>, stack_kms::Error> { self.retrieve_calls.fetch_add(1, Ordering::Relaxed); + self.retrieve_descriptors + .lock() + .unwrap() + .push(payloads.iter().map(|p| p.descriptor.to_owned()).collect()); self.inner .retrieve_keys(payloads, keyset_id, unverified_context) .await @@ -393,6 +430,10 @@ mod tests { } } + fn d() -> Descriptor { + Descriptor::of("test/field") + } + async fn cipher() -> StackCipher<CountingSource> { StackCipher::builder() .kms(CountingSource::default()) @@ -406,7 +447,7 @@ mod tests { cipher: &'a StackCipher<CountingSource>, n: usize, ) -> Pending<'a, Vec<Vec<u8>>, CountingSource> { - let requests = std::iter::repeat_with(Request::generate_data_key) + let requests = std::iter::repeat_with(|| Request::generate_data_key(d())) .take(n) .collect(); Pending::request(cipher, requests, move |responses| { @@ -429,9 +470,9 @@ mod tests { #[tokio::test] async fn a_ready_pending_propagates_its_error() { let cipher = cipher().await; - let result: Result<u32, Error> = Pending::ready(&cipher, Err(Error::EmptyContext)).await; + let result: Result<u32, Error> = Pending::ready(&cipher, Err(Error::Aead)).await; - assert!(matches!(result, Err(Error::EmptyContext))); + assert!(matches!(result, Err(Error::Aead))); assert_eq!(cipher.kms().generate_calls(), 0); } @@ -449,11 +490,11 @@ mod tests { #[tokio::test] async fn map_does_not_run_on_an_error() { let cipher = cipher().await; - let result: Result<u32, Error> = Pending::ready(&cipher, Err(Error::EmptyContext)) + let result: Result<u32, Error> = Pending::ready(&cipher, Err(Error::Aead)) .map(|_: u32| panic!("map must not run on an error")) .await; - assert!(matches!(result, Err(Error::EmptyContext))); + assert!(matches!(result, Err(Error::Aead))); } #[tokio::test] @@ -516,15 +557,15 @@ mod tests { #[tokio::test] async fn zip_propagates_an_error_from_either_side() { let cipher = cipher().await; - let result = Pending::ready(&cipher, Err(Error::EmptyContext)) + let result = Pending::ready(&cipher, Err(Error::Aead)) .zip(Pending::ready(&cipher, Ok(1u32))) .await; - assert!(matches!(result, Err::<(u32, u32), _>(Error::EmptyContext))); + assert!(matches!(result, Err::<(u32, u32), _>(Error::Aead))); let result = Pending::ready(&cipher, Ok(1u32)) - .zip(Pending::ready(&cipher, Err(Error::EmptyContext))) + .zip(Pending::ready(&cipher, Err(Error::Aead))) .await; - assert!(matches!(result, Err::<(u32, u32), _>(Error::EmptyContext))); + assert!(matches!(result, Err::<(u32, u32), _>(Error::Aead))); } #[tokio::test] @@ -557,11 +598,11 @@ mod tests { let cipher = cipher().await; let items = vec![ Pending::ready(&cipher, Ok(1u32)), - Pending::ready(&cipher, Err(Error::EmptyContext)), + Pending::ready(&cipher, Err(Error::Aead)), ]; let result = Pending::all(&cipher, items).await; - assert!(matches!(result, Err::<Vec<u32>, _>(Error::EmptyContext))); + assert!(matches!(result, Err::<Vec<u32>, _>(Error::Aead))); } /// Over-drawing is the fulfilment's own error, not a stolen sibling key: @@ -569,12 +610,15 @@ mod tests { #[tokio::test] async fn over_drawing_responses_is_a_response_shape_error() { let cipher = cipher().await; - let greedy: Pending<'_, Vec<u8>, _> = - Pending::request(&cipher, vec![Request::generate_data_key()], |responses| { + let greedy: Pending<'_, Vec<u8>, _> = Pending::request( + &cipher, + vec![Request::generate_data_key(d())], + |responses| { let _ = responses.next_generated_key()?; // One request, two draws. responses.next_generated_key().map(|key| key.tag) - }); + }, + ); let result = greedy.zip(generating(&cipher, 1)).await; assert!(matches!( @@ -594,7 +638,7 @@ mod tests { async fn under_drawing_responses_is_a_response_shape_error() { let cipher = cipher().await; let lazy: Pending<'_, (), _> = - Pending::request(&cipher, vec![Request::generate_data_key()], |_| Ok(())); + Pending::request(&cipher, vec![Request::generate_data_key(d())], |_| Ok(())); let result = lazy.zip(generating(&cipher, 1)).await; assert!(matches!( @@ -607,7 +651,10 @@ mod tests { #[tokio::test] async fn drawing_fewer_responses_than_requested_is_a_response_shape_error() { let cipher = cipher().await; - let requests = vec![Request::generate_data_key(), Request::generate_data_key()]; + let requests = vec![ + Request::generate_data_key(d()), + Request::generate_data_key(d()), + ]; let lazy: Pending<'_, Vec<u8>, _> = Pending::request(&cipher, requests, |responses| { responses.next_generated_key().map(|key| key.tag) }); @@ -626,8 +673,8 @@ mod tests { let mut pairs = generating_pairs(&cipher, 1).await.unwrap(); let (iv, tag) = pairs.remove(0); let requests = vec![ - Request::generate_data_key(), - Request::retrieve_data_key(iv, tag), + Request::generate_data_key(d()), + Request::retrieve_data_key(iv, tag, d()), ]; let lazy: Pending<'_, Vec<u8>, _> = Pending::request(&cipher, requests, |responses| { responses.next_generated_key().map(|key| key.tag) @@ -657,7 +704,7 @@ mod tests { cipher: &StackCipher<CountingSource>, n: usize, ) -> Pending<'_, Vec<(Iv, Vec<u8>)>, CountingSource> { - let requests = std::iter::repeat_with(Request::generate_data_key) + let requests = std::iter::repeat_with(|| Request::generate_data_key(d())) .take(n) .collect(); Pending::request(cipher, requests, move |responses| { @@ -681,7 +728,7 @@ mod tests { let requests: Vec<Request> = pairs .iter() - .map(|(iv, tag)| Request::retrieve_data_key(*iv, tag.clone())) + .map(|(iv, tag)| Request::retrieve_data_key(*iv, tag.clone(), d())) .collect(); let retrieve: Pending<'_, usize, _> = Pending::request(&cipher, requests, |responses| { Ok(responses.drain_retrieved().count()) @@ -693,4 +740,89 @@ mod tests { assert_eq!(cipher.kms().generate_calls(), 2); assert_eq!(cipher.kms().retrieve_calls(), 1); } + + /// Every request's descriptor reaches ZeroKMS on its own payload, in + /// request order, on both the generate and the retrieve call: the + /// descriptor is what binds the key to its field at ZeroKMS. + #[tokio::test] + async fn dispatch_forwards_each_requests_descriptor_in_order() { + let cipher = cipher().await; + let requests = vec![ + Request::generate_data_key(Descriptor::of("users/email")), + Request::generate_data_key(Descriptor::of("users/name")), + ]; + let pairs: Vec<(Iv, Vec<u8>)> = Pending::request(&cipher, requests, |responses| { + (0..2) + .map(|_| { + responses + .next_generated_key() + .map(|key| (key.key.iv, key.tag)) + }) + .collect() + }) + .await + .unwrap(); + assert_eq!( + cipher.kms().generate_descriptors(), + vec![vec!["users/email".to_owned(), "users/name".to_owned()]] + ); + + let requests: Vec<Request> = pairs + .iter() + .zip(["users/name", "users/email"]) + .map(|((iv, tag), descriptor)| { + Request::retrieve_data_key(*iv, tag.clone(), Descriptor::of(descriptor)) + }) + .collect(); + let count: usize = Pending::request(&cipher, requests, |responses| { + Ok(responses.drain_retrieved().count()) + }) + .await + .unwrap(); + + assert_eq!(count, 2); + assert_eq!( + cipher.kms().retrieve_descriptors(), + vec![vec!["users/name".to_owned(), "users/email".to_owned()]] + ); + } + + /// ZeroKMS copies a descriptor into a fixed 512-byte block without a + /// length check, so an over-long one must never reach it: the batch is + /// refused before either call, with no key minted or retrieved. + #[tokio::test] + async fn an_over_long_descriptor_is_refused_before_any_call() { + let cipher = cipher().await; + let long = Descriptor::of("a".repeat(Descriptor::MAX_LEN + 1)); + let requests = vec![ + Request::generate_data_key(Descriptor::of("users/email")), + Request::generate_data_key(long.clone()), + ]; + let Err(err) = dispatch(&cipher, requests).await else { + panic!("an over-long descriptor must be refused"); + }; + assert!( + matches!(err, Error::DescriptorTooLong { len } if len == Descriptor::MAX_LEN + 1), + "{err}" + ); + assert_eq!(cipher.kms().generate_calls(), 0); + + let mut pairs = generating_pairs(&cipher, 1).await.unwrap(); + let (iv, tag) = pairs.remove(0); + let requests = vec![Request::retrieve_data_key(iv, tag, long)]; + let Err(err) = dispatch(&cipher, requests).await else { + panic!("an over-long descriptor must be refused"); + }; + assert!(matches!(err, Error::DescriptorTooLong { .. }), "{err}"); + assert_eq!(cipher.kms().retrieve_calls(), 0); + + // At the limit is fine. + let before = cipher.kms().generate_calls(); + + let requests = vec![Request::generate_data_key(Descriptor::of( + "a".repeat(Descriptor::MAX_LEN), + ))]; + assert!(dispatch(&cipher, requests).await.is_ok(), "at the limit"); + assert_eq!(cipher.kms().generate_calls(), before + 1); + } } diff --git a/packages/stack-encrypt/src/target/request.rs b/packages/stack-encrypt/src/target/request.rs index bcab1d172..521afe2ae 100644 --- a/packages/stack-encrypt/src/target/request.rs +++ b/packages/stack-encrypt/src/target/request.rs @@ -15,7 +15,7 @@ use std::collections::VecDeque; use stack_kms::{DataKey, DataKeyWithTag, Iv}; -use crate::Error; +use crate::{Descriptor, Error}; /// One unit of ZeroKMS work a [`Pending`](super::Pending) needs: /// constructible, otherwise opaque, so new request kinds (a PRF derivation, a @@ -25,22 +25,36 @@ pub struct Request(RequestKind); #[derive(Debug, Clone)] pub(super) enum RequestKind { - /// Generate one fresh data key under the cipher's keyset. - GenerateDataKey, - /// Re-derive the data key identified by `iv` + `tag`. - RetrieveDataKey { iv: Iv, tag: Vec<u8> }, + /// Generate one fresh data key under the cipher's keyset, bound to + /// `descriptor`. + GenerateDataKey { descriptor: Descriptor }, + /// Re-derive the data key identified by `iv` + `tag`, under the + /// `descriptor` it was generated with. + RetrieveDataKey { + iv: Iv, + tag: Vec<u8>, + descriptor: Descriptor, + }, } impl Request { - /// Request one fresh data key (encrypt side). - pub fn generate_data_key() -> Self { - Self(RequestKind::GenerateDataKey) + /// Request one fresh data key (encrypt side), bound to `descriptor` — + /// the [`Descriptor`] of the context the leaf is sealed under. ZeroKMS + /// HMACs it into the key `tag`, so the key re-derives only under the + /// same descriptor. + pub fn generate_data_key(descriptor: Descriptor) -> Self { + Self(RequestKind::GenerateDataKey { descriptor }) } /// Request re-derivation of the data key identified by `iv` + `tag` - /// (decrypt side). - pub fn retrieve_data_key(iv: Iv, tag: Vec<u8>) -> Self { - Self(RequestKind::RetrieveDataKey { iv, tag }) + /// (decrypt side), under `descriptor` — which must be the one the key + /// was generated with, or ZeroKMS refuses. + pub fn retrieve_data_key(iv: Iv, tag: Vec<u8>, descriptor: Descriptor) -> Self { + Self(RequestKind::RetrieveDataKey { + iv, + tag, + descriptor, + }) } /// Consume the request, yielding what it asks for. @@ -55,7 +69,7 @@ pub(super) fn tally(requests: &[Request]) -> (usize, usize) { let (mut generate, mut retrieve) = (0usize, 0usize); for request in requests { match request.0 { - RequestKind::GenerateDataKey => generate += 1, + RequestKind::GenerateDataKey { .. } => generate += 1, RequestKind::RetrieveDataKey { .. } => retrieve += 1, } } @@ -134,6 +148,10 @@ mod tests { use super::*; + fn d() -> Descriptor { + Descriptor::of("test/field") + } + /// `n` real generated keys, plus the retrieved keys for the same `n` /// (`iv`, `tag`) pairs — the stub round-trips, which is all these tests /// need from it. @@ -177,32 +195,37 @@ mod tests { #[test] fn tally_separates_the_two_kinds() { let requests = vec![ - Request::generate_data_key(), - Request::retrieve_data_key(Iv::default(), vec![1]), - Request::generate_data_key(), - Request::retrieve_data_key(Iv::default(), vec![2]), - Request::generate_data_key(), + Request::generate_data_key(d()), + Request::retrieve_data_key(Iv::default(), vec![1], d()), + Request::generate_data_key(d()), + Request::retrieve_data_key(Iv::default(), vec![2], d()), + Request::generate_data_key(d()), ]; assert_eq!(tally(&requests), (3, 2)); } #[test] - fn a_generate_request_is_a_generate_kind() { - assert!(matches!( - Request::generate_data_key().into_kind(), - RequestKind::GenerateDataKey - )); + fn a_generate_request_carries_its_descriptor() { + match Request::generate_data_key(d()).into_kind() { + RequestKind::GenerateDataKey { descriptor } => assert_eq!(descriptor, d()), + RequestKind::RetrieveDataKey { .. } => panic!("expected a generate request"), + } } #[test] - fn a_retrieve_request_carries_its_iv_and_tag() { - let request = Request::retrieve_data_key(Iv::default(), vec![7, 8, 9]); + fn a_retrieve_request_carries_its_iv_tag_and_descriptor() { + let request = Request::retrieve_data_key(Iv::default(), vec![7, 8, 9], d()); match request.into_kind() { - RequestKind::RetrieveDataKey { iv, tag } => { + RequestKind::RetrieveDataKey { + iv, + tag, + descriptor, + } => { assert_eq!(iv, Iv::default()); assert_eq!(tag, vec![7, 8, 9]); + assert_eq!(descriptor, d()); } - RequestKind::GenerateDataKey => panic!("expected a retrieve request"), + RequestKind::GenerateDataKey { .. } => panic!("expected a retrieve request"), } } diff --git a/packages/stack-encrypt/tests/common/mod.rs b/packages/stack-encrypt/tests/common/mod.rs index 6621c3440..bd20c003f 100644 --- a/packages/stack-encrypt/tests/common/mod.rs +++ b/packages/stack-encrypt/tests/common/mod.rs @@ -10,7 +10,7 @@ use std::borrow::Cow; use std::sync::atomic::{AtomicUsize, Ordering as AtomicOrdering}; -use std::sync::Arc; +use std::sync::{Arc, Mutex}; use stack_encrypt::StackCipher; use stack_kms::{ @@ -101,3 +101,98 @@ pub async fn counting_cipher() -> ( .expect("build cipher"); (cipher, generates, retrieves) } + +/// Every descriptor sent to ZeroKMS, per call, in payload order — what the +/// fake ignores but the real service binds into the key tag. Tests assert +/// against this, never against the fake's (non-)enforcement. +#[derive(Debug, Clone, Default)] +pub struct SentDescriptors { + pub generate: Vec<Vec<String>>, + pub retrieve: Vec<Vec<String>>, +} + +impl SentDescriptors { + /// Every generate-side descriptor, all calls flattened. + pub fn generated(&self) -> Vec<String> { + self.generate.iter().flatten().cloned().collect() + } + + /// Every retrieve-side descriptor, all calls flattened. + pub fn retrieved(&self) -> Vec<String> { + self.retrieve.iter().flatten().cloned().collect() + } +} + +/// The fake source, recording the descriptor of every payload it is sent. +pub struct RecordingSource { + inner: FakeDataKeySource, + sent: Arc<Mutex<SentDescriptors>>, +} + +impl RecordingSource { + pub fn new() -> Self { + Self { + inner: FakeDataKeySource::new(), + sent: Arc::new(Mutex::new(SentDescriptors::default())), + } + } + + pub fn sent(&self) -> Arc<Mutex<SentDescriptors>> { + self.sent.clone() + } +} + +impl DataKeySource for RecordingSource { + async fn generate_keys( + &self, + payloads: Vec<GenerateKeyPayload<'_>>, + keyset_id: Option<Uuid>, + unverified_context: Option<Cow<'_, UnverifiedContext>>, + ) -> Result<Vec<DataKeyWithTag>, stack_kms::Error> { + self.sent + .lock() + .expect("lock") + .generate + .push(payloads.iter().map(|p| p.descriptor.to_owned()).collect()); + self.inner + .generate_keys(payloads, keyset_id, unverified_context) + .await + } + + async fn retrieve_keys( + &self, + payloads: Vec<RetrieveKeyPayload<'_>>, + keyset_id: Option<Uuid>, + unverified_context: Option<&UnverifiedContext>, + ) -> Result<Vec<DataKey>, stack_kms::Error> { + self.sent + .lock() + .expect("lock") + .retrieve + .push(payloads.iter().map(|p| p.descriptor.to_owned()).collect()); + self.inner + .retrieve_keys(payloads, keyset_id, unverified_context) + .await + } +} + +impl IndexKeySource for RecordingSource { + async fn load_index_key( + &self, + keyset_id: Option<IdentifiedBy>, + ) -> Result<(Uuid, IndexKey), stack_kms::Error> { + self.inner.load_index_key(keyset_id).await + } +} + +/// A cipher over [`RecordingSource`], with the descriptors it sends. +pub async fn recording_cipher() -> (StackCipher<RecordingSource>, Arc<Mutex<SentDescriptors>>) { + let source = RecordingSource::new(); + let sent = source.sent(); + let cipher = StackCipher::builder() + .kms(source) + .init() + .await + .expect("build cipher"); + (cipher, sent) +} diff --git a/packages/stack-encrypt/tests/derive.rs b/packages/stack-encrypt/tests/derive.rs index 56259fc7a..a99221c62 100644 --- a/packages/stack-encrypt/tests/derive.rs +++ b/packages/stack-encrypt/tests/derive.rs @@ -1,8 +1,8 @@ //! `#[derive(EncryptFrom)]` / `#[derive(DecryptInto)]`: the derived impls are the //! hand-written composite in `target.rs`, emitted — same terms, same decrypt //! mirror, same one-batched-call settlement — plus what only a derive makes -//! cheap: sources listed or left generic, rows derived field by field, and -//! fields that are not derived at all. +//! cheap: sources listed or left generic, structs encrypted field by field, +//! and fields that are not derived at all. mod common; @@ -13,7 +13,7 @@ use common::{counting_cipher, stack_cipher}; use stack_encrypt::sem::{EqualityTerm, MatchTerm, OreTerm}; use stack_encrypt::target::{DecryptFrom, EncryptInto}; use stack_encrypt::{ - DecryptField, DecryptInto, DecryptTarget, Decryptable, EncryptFrom, Error, Pending, + nonempty, DecryptField, DecryptInto, DecryptTarget, Decryptable, EncryptFrom, Error, Pending, StackCipher, StackCipherText, }; @@ -35,25 +35,28 @@ async fn a_derived_record_is_the_hand_written_one() { let generator = stack_cipher().await; let record: EncryptedAge = 42u32 - .encrypt_into_with_context(&cipher, "users/age") + .encrypt_into_with_context(&cipher, nonempty!("users/age")) .await .unwrap(); // Each term is what the leaf derives on its own, so query terms built // leaf-by-leaf find records encrypted as composites. let hm: EqualityTerm = 42u32 - .encrypt_into_with_context(&generator, "users/age") + .encrypt_into_with_context(&generator, nonempty!("users/age")) .await .unwrap(); let ob: OreTerm<u32> = 42u32 - .encrypt_into_with_context(&generator, "users/age") + .encrypt_into_with_context(&generator, nonempty!("users/age")) .await .unwrap(); assert_eq!(record.hm, hm); assert_eq!(record.ob, ob); // And the decrypt mirror opens the ciphertext field. - let age: u32 = record.decrypt_into(&cipher, "users/age").await.unwrap(); + let age: u32 = record + .decrypt_into(&cipher, nonempty!("users/age")) + .await + .unwrap(); assert_eq!(age, 42); } @@ -88,46 +91,56 @@ async fn a_generic_plaintext_record_accepts_what_its_leaves_accept() { let record: SearchableText = "alice" .to_string() - .encrypt_into_with_context(&cipher, "users/name") + .encrypt_into_with_context(&cipher, nonempty!("users/name")) .await .unwrap(); let hm: EqualityTerm = "alice" .to_string() - .encrypt_into_with_context(&generator, "users/name") + .encrypt_into_with_context(&generator, nonempty!("users/name")) .await .unwrap(); let m: MatchTerm = "alice" .to_string() - .encrypt_into_with_context(&generator, "users/name") + .encrypt_into_with_context(&generator, nonempty!("users/name")) .await .unwrap(); assert_eq!(record.hm, hm); assert_eq!(record.m, m); - let name: String = record.decrypt_into(&cipher, "users/name").await.unwrap(); + let name: String = record + .decrypt_into(&cipher, nonempty!("users/name")) + .await + .unwrap(); assert_eq!(name, "alice"); let pair: Pair = "bob" - .encrypt_into_with_context(&cipher, "users/name") + .encrypt_into_with_context(&cipher, nonempty!("users/name")) .await .unwrap(); let hm: EqualityTerm = "bob" - .encrypt_into_with_context(&generator, "users/name") + .encrypt_into_with_context(&generator, nonempty!("users/name")) .await .unwrap(); assert_eq!(pair.1, hm); - let name: String = pair.0.decrypt_into(&cipher, "users/name").await.unwrap(); + let name: String = pair + .0 + .decrypt_into(&cipher, nonempty!("users/name")) + .await + .unwrap(); assert_eq!(name, "bob"); let tagged: Tagged<u32> = 7u32 - .encrypt_into_with_context(&cipher, "users/score") + .encrypt_into_with_context(&cipher, nonempty!("users/score")) .await .unwrap(); let ob: OreTerm<u32> = 7u32 - .encrypt_into_with_context(&generator, "users/score") + .encrypt_into_with_context(&generator, nonempty!("users/score")) .await .unwrap(); assert_eq!(tagged.ob, ob); - let score: u32 = tagged.decrypt_into(&cipher, "users/score").await.unwrap(); + let score: u32 = tagged + .decrypt_into(&cipher, nonempty!("users/score")) + .await + .unwrap(); assert_eq!(score, 7); } @@ -157,29 +170,40 @@ async fn decrypt_marks_the_field_when_the_types_cannot_choose() { let cipher = stack_cipher().await; let doubled: Doubled = 9u32 - .encrypt_into_with_context(&cipher, "doubled") + .encrypt_into_with_context(&cipher, nonempty!("doubled")) + .await + .unwrap(); + let opened: u32 = doubled + .decrypt_into(&cipher, nonempty!("doubled")) .await .unwrap(); - let opened: u32 = doubled.decrypt_into(&cipher, "doubled").await.unwrap(); assert_eq!(opened, 9); - // The unmarked ciphertext is still a ciphertext, just not the record's. + // The unmarked ciphertext is still a ciphertext, just not the record's — + // and its literal context is extended by the caller's like any other: + // sealed under `("doubled/shadow", "doubled")`. let doubled: Doubled = 9u32 - .encrypt_into_with_context(&cipher, "doubled") + .encrypt_into_with_context(&cipher, nonempty!("doubled")) .await .unwrap(); let shadow: u32 = doubled .shadow - .decrypt_into(&cipher, "doubled/shadow") + .decrypt_into( + &cipher, + nonempty!("doubled/shadow").with(nonempty!("doubled")), + ) .await .unwrap(); assert_eq!(shadow, 9); let numbers: Numbers = vec![1u32, 2, 3] - .encrypt_into_with_context(&cipher, "numbers") + .encrypt_into_with_context(&cipher, nonempty!("numbers")) .await .unwrap(); assert_eq!(numbers.hm.len(), 3); - let opened: Vec<u32> = numbers.decrypt_into(&cipher, "numbers").await.unwrap(); + let opened: Vec<u32> = numbers + .decrypt_into(&cipher, nonempty!("numbers")) + .await + .unwrap(); assert_eq!(opened, vec![1, 2, 3]); } @@ -225,16 +249,19 @@ async fn explicit_mode_supports_opaque_fields_in_the_paired_derive() { let generator = stack_cipher().await; let record: WithOpaque = 5u32 - .encrypt_into_with_context(&cipher, "opaque") + .encrypt_into_with_context(&cipher, nonempty!("opaque")) .await .unwrap(); let hm: EqualityTerm = 5u32 - .encrypt_into_with_context(&generator, "opaque") + .encrypt_into_with_context(&generator, nonempty!("opaque")) .await .unwrap(); assert!(record.o == OpaqueTerm(hm)); - let opened: u32 = record.decrypt_into(&cipher, "opaque").await.unwrap(); + let opened: u32 = record + .decrypt_into(&cipher, nonempty!("opaque")) + .await + .unwrap(); assert_eq!(opened, 5); } @@ -268,9 +295,8 @@ struct Held { } #[derive(DecryptInto)] -#[stash(plaintext = Held)] -struct LyingRow { - #[stash(from = value, context = "held/value")] +#[stash(struct = Held, context = "held")] +struct LyingHeld { value: Lying, } @@ -281,10 +307,12 @@ async fn a_broken_decrypt_field_contract_is_not_opened_never_a_panic() { // The compile-time check accepted `Lying` (its `DECRYPTABLE` is `true`), // so the broken contract only shows at decrypt time: `Error::NotOpened` // as a failed pending, for the record and for the row alike. - let result: Result<u32, _> = LyingRecord { l: Lying }.decrypt_into(&cipher, "l").await; + let result: Result<u32, _> = LyingRecord { l: Lying } + .decrypt_into(&cipher, nonempty!("l")) + .await; assert!(matches!(result, Err(Error::NotOpened))); - let result: Result<Held, _> = LyingRow { value: Lying }.decrypt_into(&cipher, ()).await; + let result: Result<Held, _> = LyingHeld { value: Lying }.decrypt_into(&cipher, ()).await; assert!(matches!(result, Err(Error::NotOpened))); } @@ -301,22 +329,25 @@ async fn listed_plaintexts_each_get_their_own_impl() { let cipher = stack_cipher().await; let number: EncryptedValue = 7u32 - .encrypt_into_with_context(&cipher, "t/n") + .encrypt_into_with_context(&cipher, nonempty!("t/n")) .await .unwrap(); let text: EncryptedValue = "seven" .to_string() - .encrypt_into_with_context(&cipher, "t/t") + .encrypt_into_with_context(&cipher, nonempty!("t/t")) .await .unwrap(); let hm: EqualityTerm = 7u32 - .encrypt_into_with_context(&cipher, "t/n") + .encrypt_into_with_context(&cipher, nonempty!("t/n")) .await .unwrap(); assert_eq!(number.hm, hm); - let number: u32 = number.decrypt_into(&cipher, "t/n").await.unwrap(); - let text: String = text.decrypt_into(&cipher, "t/t").await.unwrap(); + let number: u32 = number + .decrypt_into(&cipher, nonempty!("t/n")) + .await + .unwrap(); + let text: String = text.decrypt_into(&cipher, nonempty!("t/t")).await.unwrap(); assert_eq!((number, text.as_str()), (7, "seven")); } @@ -324,18 +355,18 @@ async fn listed_plaintexts_each_get_their_own_impl() { async fn a_failed_field_fails_the_derived_record_before_any_io() { let (cipher, generates, _) = counting_cipher().await; - // An empty context fails every leaf during the synchronous build; the - // derived record is the zip of those, so it fails the same way and never - // mints the data key its ciphertext field would have wanted. - let result: Result<SearchableText, _> = "alice" - .to_string() - .encrypt_into_with_context(&cipher, "") + // Text that yields no match tokens fails that leaf during the + // synchronous build; the derived record is the zip of its fields, so it + // fails the same way and never mints the data key its ciphertext field + // would have wanted. + let result: Result<SearchableText, _> = String::new() + .encrypt_into_with_context(&cipher, nonempty!("users/name")) .await; - assert!(matches!(result, Err(Error::EmptyContext))); + assert!(matches!(result, Err(Error::Term(_)))); assert_eq!(generates.load(AtomicOrdering::SeqCst), 0); } -// --- Rows: each field from one field of the plaintext, under its own context +// --- Structs: each field from one field of the plaintext, under its own context #[derive(Debug, Clone, PartialEq, Eq)] struct User { @@ -343,16 +374,16 @@ struct User { email: String, } -/// A row: every field is derived from the plaintext field of its own name, -/// under the context `"<context>/<field>"` — `"user/age"`, `"user/email"` — -/// with no attribute on the field. The prefix names the table, explicitly: -/// it is part of the stored data's identity, so it is never inferred from -/// the type's name. `from` is the override for a field name that differs; -/// the context then follows the plaintext field. +/// A struct encrypted field by field: every field is derived from the +/// plaintext field of its own name, under the context `"<context>/<field>"` +/// — `"user/age"`, `"user/email"` — with no attribute on the field. The +/// prefix names the stored data, explicitly: it is part of its identity, so +/// it is never inferred from the type's name. `from` is the override for a +/// field name that differs; the context then follows the plaintext field. #[derive(EncryptFrom, DecryptInto)] -#[stash(row = User, context = "user")] +#[stash(struct = User, context = "user")] struct EncryptedUser { - /// A record inside a row: recursion, not a second mechanism. + /// A record inside a struct: recursion, not a second mechanism. age: EncryptedAge, email: StackCipherText, /// A second field from the same plaintext field — a term alongside the @@ -372,30 +403,30 @@ fn user() -> User { } #[tokio::test] -async fn a_row_is_one_batched_call_and_rebuilds_its_plaintext() { +async fn a_struct_is_one_batched_call_and_rebuilds_its_plaintext() { let (cipher, generates, retrieves) = counting_cipher().await; let generator = stack_cipher().await; - // Every field has its own context, so the row needs none from the + // Every field has its own context, so the struct needs none from the // caller: the context-free forms are the whole call, both ways. let row: EncryptedUser = user().encrypt_into(&cipher).await.unwrap(); assert_eq!( generates.load(AtomicOrdering::SeqCst), 1, - "a two-ciphertext row must be ONE generate_keys call" + "a two-ciphertext struct must be ONE generate_keys call" ); assert_eq!(row.version, 3); - // Each field's terms are what a query site derives under the column's - // inferred context: the row's name and the plaintext field's. + // Each field's terms are what a query site derives under the field's + // inferred context: the prefix and the plaintext field's name. let age_hm: EqualityTerm = 42u32 - .encrypt_into_with_context(&generator, "user/age") + .encrypt_into_with_context(&generator, nonempty!("user/age")) .await .unwrap(); assert_eq!(row.age.hm, age_hm); let email_hm: EqualityTerm = user() .email - .encrypt_into_with_context(&generator, "user/email") + .encrypt_into_with_context(&generator, nonempty!("user/email")) .await .unwrap(); assert_eq!(row.email_eq, email_hm); @@ -406,12 +437,12 @@ async fn a_row_is_one_batched_call_and_rebuilds_its_plaintext() { assert_eq!( retrieves.load(AtomicOrdering::SeqCst), 1, - "opening a two-ciphertext row must be ONE retrieve_keys call" + "opening a two-ciphertext struct must be ONE retrieve_keys call" ); } #[tokio::test] -async fn a_column_of_rows_is_still_one_call_each_way() { +async fn a_column_of_structs_is_still_one_call_each_way() { let (cipher, generates, retrieves) = counting_cipher().await; let users: Vec<User> = (0..4) @@ -431,37 +462,99 @@ async fn a_column_of_rows_is_still_one_call_each_way() { } #[tokio::test] -async fn a_row_field_opened_under_the_wrong_context_fails() { +async fn a_struct_extends_its_contexts_with_the_callers() { + let (cipher, generates, retrieves) = counting_cipher().await; + let generator = stack_cipher().await; + + // The caller's context — the record's id — extends every inferred one: + // `age` is derived under `("user/age", 7u64)`, still in one batched + // call, and a query site probes it under the same pair. + let row: EncryptedUser = user() + .encrypt_into_with_context(&cipher, 7u64) + .await + .unwrap(); + assert_eq!(generates.load(AtomicOrdering::SeqCst), 1); + let age_hm: EqualityTerm = 42u32 + .encrypt_into_with_context(&generator, nonempty!("user/age").with(7u64)) + .await + .unwrap(); + assert_eq!(row.age.hm, age_hm); + let unextended: EqualityTerm = 42u32 + .encrypt_into_with_context(&generator, nonempty!("user/age")) + .await + .unwrap(); + assert_ne!(row.age.hm, unextended); + + // Opens under the same extension, in one call — and under no other. + let recovered = User::decrypt_from_with_context(row, &cipher, 7u64) + .await + .unwrap(); + assert_eq!(recovered, user()); + assert_eq!(retrieves.load(AtomicOrdering::SeqCst), 1); + + let row: EncryptedUser = user() + .encrypt_into_with_context(&cipher, 7u64) + .await + .unwrap(); + // The fake key source ignores descriptors, so the AEAD is what refuses + // a wrong context here. ZeroKMS refuses the key retrieval itself first + // (`Error::Kms`) — `examples/encrypted_record.rs` shows that live. + let other_row = User::decrypt_from_with_context(row, &cipher, 8u64).await; + assert!(matches!(other_row, Err(Error::Aead))); + let row: EncryptedUser = user() + .encrypt_into_with_context(&cipher, 7u64) + .await + .unwrap(); + let no_row = User::decrypt_from(row, &cipher).await; + assert!(matches!(no_row, Err(Error::Aead))); + + // Any context does: a string, a pair, an `Option`. + let row: EncryptedUser = user() + .encrypt_into_with_context(&cipher, nonempty!("tenant/acme")) + .await + .unwrap(); + let recovered = User::decrypt_from_with_context(row, &cipher, nonempty!("tenant/acme")) + .await + .unwrap(); + assert_eq!(recovered, user()); +} + +#[tokio::test] +async fn a_struct_field_opened_under_the_wrong_context_fails() { let cipher = stack_cipher().await; let row: EncryptedUser = user().encrypt_into(&cipher).await.unwrap(); // The literal contexts are baked into the impl, so a transplanted field - // is caught by the AAD exactly as for a leaf. - let transplanted: Result<u32, _> = row.age.c.decrypt_into(&cipher, "user/height").await; + // is caught exactly as for a leaf: by the AAD against the fake key + // source, by ZeroKMS's descriptor check (`Error::Kms`) before that in + // production. + let transplanted: Result<u32, _> = row + .age + .c + .decrypt_into(&cipher, nonempty!("user/height")) + .await; assert!(matches!(transplanted, Err(Error::Aead))); } -/// A row inside a row. The inner row carries its own contexts, so the outer -/// field needs no `context` of its own: a `from` field with none is handed -/// `()`, which is exactly what a row accepts — and the outer row stays -/// context-free too. #[derive(Debug, Clone, PartialEq, Eq)] struct Account { user: User, plan: String, } +/// A struct nesting a struct: `#[stash(nested)]` opts the field out of the +/// inferred context — the inner struct carries its own — so it is handed +/// the caller's context as it is, which the inner struct composes with them. #[derive(EncryptFrom, DecryptInto)] -#[stash(plaintext = Account)] +#[stash(struct = Account, context = "accounts")] struct EncryptedAccount { - #[stash(from = user)] + #[stash(nested)] user: EncryptedUser, - #[stash(from = plan, context = "accounts/plan")] plan: StackCipherText, } #[tokio::test] -async fn a_row_nests_in_a_row_without_a_context() { +async fn a_struct_nests_in_a_struct_via_nested() { let (cipher, generates, retrieves) = counting_cipher().await; let generator = stack_cipher().await; @@ -472,9 +565,9 @@ async fn a_row_nests_in_a_row_without_a_context() { let row: EncryptedAccount = account.encrypt_into(&cipher).await.unwrap(); assert_eq!(generates.load(AtomicOrdering::SeqCst), 1); - // The inner row's fields are still under their own literals. + // The inner struct's fields are still under their own contexts. let age_hm: EqualityTerm = 42u32 - .encrypt_into_with_context(&generator, "user/age") + .encrypt_into_with_context(&generator, nonempty!("user/age")) .await .unwrap(); assert_eq!(row.user.age.hm, age_hm); @@ -482,68 +575,51 @@ async fn a_row_nests_in_a_row_without_a_context() { let recovered = Account::decrypt_from(row, &cipher).await.unwrap(); assert_eq!(recovered, account); assert_eq!(retrieves.load(AtomicOrdering::SeqCst), 1); -} -/// A row nesting a row, in row mode: `#[stash(nested)]` opts the field out -/// of the inferred context — the inner row carries its own — so it is handed -/// `()`, exactly as a `plaintext = ..` record's bare `from` field is. The -/// outer row stays context-free. -#[derive(EncryptFrom, DecryptInto)] -#[stash(row = Account, context = "accounts")] -struct EncryptedAccountRow { - #[stash(nested)] - user: EncryptedUser, - plan: StackCipherText, -} - -#[tokio::test] -async fn a_row_nests_in_a_row_in_row_mode_via_nested() { - let (cipher, generates, retrieves) = counting_cipher().await; - let generator = stack_cipher().await; - - let account = Account { - user: user(), - plan: "pro".to_string(), - }; - let row: EncryptedAccountRow = account.encrypt_into(&cipher).await.unwrap(); - assert_eq!(generates.load(AtomicOrdering::SeqCst), 1); + // The outer's plan is under the inferred `"accounts/plan"`. Decrypting + // the row consumed it, so mint a fresh one to open the field alone. + let row: EncryptedAccount = account.encrypt_into(&cipher).await.unwrap(); + let plan: String = row + .plan + .decrypt_into(&cipher, nonempty!("accounts/plan")) + .await + .unwrap(); + assert_eq!(plan, "pro"); - // The inner row's fields are still under their own literals. + // An extension reaches the nested struct unchanged and is composed with + // its own contexts there: the inner `age` is under `("user/age", id)`, + // the outer `plan` under `("accounts/plan", id)`. + let row: EncryptedAccount = account + .encrypt_into_with_context(&cipher, 9u64) + .await + .unwrap(); let age_hm: EqualityTerm = 42u32 - .encrypt_into_with_context(&generator, "user/age") + .encrypt_into_with_context(&generator, nonempty!("user/age").with(9u64)) .await .unwrap(); assert_eq!(row.user.age.hm, age_hm); - - let recovered = Account::decrypt_from(row, &cipher).await.unwrap(); - assert_eq!(recovered, account); - assert_eq!(retrieves.load(AtomicOrdering::SeqCst), 1); - - // The outer's plan is under the inferred `"accounts/plan"`. Decrypting - // the row consumed it, so mint a fresh one to open the field alone. - let row: EncryptedAccountRow = account.encrypt_into(&cipher).await.unwrap(); let plan: String = row .plan - .decrypt_into(&cipher, "accounts/plan") + .decrypt_into(&cipher, nonempty!("accounts/plan").with(9u64)) .await .unwrap(); assert_eq!(plan, "pro"); } -/// A tuple-struct plaintext is reached by index — inferred for a tuple row, -/// `from = 0` when the row has named fields — and named by it in the -/// context: `"reading/0"`. +/// A tuple-struct plaintext is reached by index — inferred for a tuple +/// struct, `from = 0` when the encrypted struct has named fields — and named +/// by it in the context: `"reading/0"`. #[derive(Debug, Clone, PartialEq, Eq)] struct Reading(u32, String); #[derive(EncryptFrom, DecryptInto)] -#[stash(row = Reading, context = "reading")] +#[stash(struct = Reading, context = "reading")] struct EncryptedReading(EncryptedAge, StackCipherText); -/// The same row with named fields: `from` by index, and the context follows -/// the index too unless given. +/// The same with named fields: `from` by index, and the context follows the +/// index too unless given. #[derive(EncryptFrom, DecryptInto)] -#[stash(row = Reading, context = "reading")] +#[stash(struct = Reading, context = "reading")] struct NamedReading { #[stash(from = 0)] value: EncryptedAge, @@ -552,7 +628,7 @@ struct NamedReading { } #[tokio::test] -async fn a_tuple_plaintext_row_is_reached_and_rebuilt_by_index() { +async fn a_tuple_plaintext_is_reached_and_rebuilt_by_index() { let cipher = stack_cipher().await; let generator = stack_cipher().await; @@ -560,7 +636,7 @@ async fn a_tuple_plaintext_row_is_reached_and_rebuilt_by_index() { let row: EncryptedReading = reading.encrypt_into(&cipher).await.unwrap(); let hm: EqualityTerm = 21u32 - .encrypt_into_with_context(&generator, "reading/0") + .encrypt_into_with_context(&generator, nonempty!("reading/0")) .await .unwrap(); assert_eq!(row.0.hm, hm); @@ -572,7 +648,7 @@ async fn a_tuple_plaintext_row_is_reached_and_rebuilt_by_index() { assert_eq!(named.value.hm, hm, "from = 0 infers the same context"); let unit: String = named .unit - .decrypt_into(&cipher, "readings/unit") + .decrypt_into(&cipher, nonempty!("readings/unit")) .await .unwrap(); assert_eq!(unit, "celsius"); diff --git a/packages/stack-encrypt/tests/descriptor.rs b/packages/stack-encrypt/tests/descriptor.rs new file mode 100644 index 000000000..e45580644 --- /dev/null +++ b/packages/stack-encrypt/tests/descriptor.rs @@ -0,0 +1,277 @@ +//! What reaches ZeroKMS: every data-key request carries the requesting +//! context as its descriptor, on generate and on retrieve alike. +//! +//! The fake source ignores descriptors (see `FakeDataKeySource`'s docs), so +//! these tests assert what is *sent*. The real service HMACs the descriptor +//! into the key tag and refuses to re-derive under a different one — the +//! examples exercise that against a live ZeroKMS. + +mod common; + +use common::recording_cipher; +use stack_encrypt::sem::EqualityTerm; +use stack_encrypt::target::{DecryptFrom, EncryptInto}; +use stack_encrypt::{nonempty, DecryptInto, Descriptor, EncryptFrom, Error, StackCipherText}; + +#[derive(EncryptFrom, DecryptInto)] +#[stash(plaintext = u32)] +struct EncryptedAge { + c: StackCipherText, +} + +#[derive(Debug, PartialEq, Clone)] +struct User { + email: String, + name: String, + age: u32, +} + +#[derive(EncryptFrom, DecryptInto)] +#[stash(struct = User, context = "users")] +struct EncryptedUser { + email: StackCipherText, + #[stash(from = email)] + email_hm: EqualityTerm, + #[stash(context = "people/name")] + name: StackCipherText, + age: EncryptedAge, +} + +fn user() -> User { + User { + email: "alice@example.com".into(), + name: "Alice".into(), + age: 34, + } +} + +#[tokio::test] +async fn a_leaf_sends_its_context_as_the_descriptor_both_ways() -> Result<(), Error> { + let (cipher, sent) = recording_cipher().await; + + let ct: StackCipherText = "alice" + .encrypt_into_with_context(&cipher, nonempty!("users/email")) + .await?; + let _: String = ct.decrypt_into(&cipher, nonempty!("users/email")).await?; + + let sent = sent.lock().expect("lock").clone(); + assert_eq!(sent.generated(), ["users/email"]); + assert_eq!(sent.retrieved(), ["users/email"]); + Ok(()) +} + +#[tokio::test] +async fn a_struct_sends_one_descriptor_per_field_context() -> Result<(), Error> { + let (cipher, sent) = recording_cipher().await; + + let row: EncryptedUser = user().encrypt_into(&cipher).await?; + let back = User::decrypt_from(row, &cipher).await?; + assert_eq!(back, user()); + + // Inferred `users/email`, the field's own `people/name`, and the inner + // record under `users/age`; the term derives no key. One call each way. + let sent = sent.lock().expect("lock").clone(); + assert_eq!(sent.generate.len(), 1, "one generate_keys call"); + assert_eq!(sent.retrieve.len(), 1, "one retrieve_keys call"); + assert_eq!( + sent.generated(), + ["users/email", "people/name", "users/age"] + ); + assert_eq!( + sent.retrieved(), + ["users/email", "people/name", "users/age"] + ); + Ok(()) +} + +#[tokio::test] +async fn a_callers_context_extends_every_fields_descriptor() -> Result<(), Error> { + let (cipher, sent) = recording_cipher().await; + + let row: EncryptedUser = user().encrypt_into_with_context(&cipher, 7u64).await?; + let back = User::decrypt_from_with_context(row, &cipher, 7u64).await?; + assert_eq!(back, user()); + + // The extended contexts are composites, rendered part by part — the + // same value the leaf AAD and the term context are built from. + let expected: Vec<String> = [ + Descriptor::of(nonempty!("users/email").with(7u64)), + Descriptor::of(nonempty!("people/name").with(7u64)), + Descriptor::of(nonempty!("users/age").with(7u64)), + ] + .iter() + .map(|d| d.as_str().to_owned()) + .collect(); + assert_eq!( + expected, + ["users/email|7u64", "people/name|7u64", "users/age|7u64"], + "a composite context renders readably" + ); + let sent = sent.lock().expect("lock").clone(); + assert_eq!(sent.generated(), expected); + assert_eq!(sent.retrieved(), expected); + Ok(()) +} + +#[tokio::test] +async fn every_leaf_of_a_tree_shares_the_root_descriptor() -> Result<(), Error> { + let (cipher, sent) = recording_cipher().await; + + let column: Vec<StackCipherText> = vec![1u32, 2, 3] + .encrypt_into_with_context(&cipher, nonempty!("users/age")) + .await?; + let _: Vec<u32> = column.decrypt_into(&cipher, nonempty!("users/age")).await?; + + // Per-element AAD derivation is vitaminc's and stays inside the AEAD; + // ZeroKMS sees the field, not the element. + let sent = sent.lock().expect("lock").clone(); + assert_eq!(sent.generated(), ["users/age"; 3]); + assert_eq!(sent.retrieved(), ["users/age"; 3]); + Ok(()) +} + +#[tokio::test] +async fn the_cipher_directed_path_renders_its_aad_the_same_way() -> Result<(), Error> { + let (cipher, sent) = recording_cipher().await; + + let ct = cipher.encrypt(42u32, "users/age").await?; + let _: u32 = cipher.decrypt(ct, "users/age").await?; + // No AAD at all is the empty descriptor: ZeroKMS binds nothing. + let ct = cipher.encrypt(42u32, ()).await?; + let _: u32 = cipher.decrypt(ct, ()).await?; + + let sent = sent.lock().expect("lock").clone(); + assert_eq!(sent.generated(), ["users/age", ""]); + assert_eq!(sent.retrieved(), ["users/age", ""]); + Ok(()) +} + +/// A column renders its context once to check it, then refuses the whole +/// column: an over-long context under ten thousand elements is one +/// rendering, not ten thousand, on either path. +#[tokio::test] +async fn a_column_renders_an_over_long_context_once() -> Result<(), Error> { + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::sync::Arc; + + /// A context that counts how often it is encoded. Over the limit once + /// rendered, so every path refuses it. + #[derive(Clone)] + struct Counted(Arc<AtomicUsize>); + + impl<'a> stack_encrypt::IntoAad<'a> for Counted { + fn into_aad(self) -> stack_encrypt::Aad<'a> { + self.0.fetch_add(1, Ordering::SeqCst); + stack_encrypt::Aad::new_owned("a".repeat(Descriptor::MAX_LEN + 1).into_bytes()) + } + } + impl stack_encrypt::MaybeEmpty for Counted { + fn is_empty(&self) -> bool { + false + } + } + + let (cipher, sent) = recording_cipher().await; + let renders = Arc::new(AtomicUsize::new(0)); + let context = stack_encrypt::NonEmpty::new(Counted(renders.clone())).unwrap(); + + let values: Vec<u32> = (0..10_000).collect(); + let result: Result<Vec<StackCipherText>, Error> = values + .encrypt_into_with_context(&cipher, context.clone()) + .await; + assert!( + matches!(result, Err(Error::DescriptorTooLong { .. })), + "{result:?}" + ); + assert_eq!( + renders.load(Ordering::SeqCst), + 1, + "one rendering on encrypt" + ); + + let column: Vec<StackCipherText> = vec![1u32, 2, 3] + .encrypt_into_with_context(&cipher, nonempty!("users/age")) + .await?; + let opened: Result<Vec<u32>, Error> = column.decrypt_into(&cipher, context).await; + assert!( + matches!(opened, Err(Error::DescriptorTooLong { .. })), + "{opened:?}" + ); + assert_eq!( + renders.load(Ordering::SeqCst), + 2, + "one rendering on decrypt" + ); + + let sent = sent.lock().expect("lock").clone(); + assert_eq!( + sent.generated(), + ["users/age"; 3], + "only the good seal was sent" + ); + assert!(sent.retrieved().is_empty(), "nothing retrieved"); + Ok(()) +} + +/// The column check is for columns that bind keys. A column of terms +/// derives locally under any context, however long, and an empty column +/// binds nothing — neither is held to the descriptor limit. +#[tokio::test] +async fn a_column_with_nothing_to_bind_takes_any_context() -> Result<(), Error> { + let (cipher, sent) = recording_cipher().await; + let long = stack_encrypt::NonEmpty::new("a".repeat(Descriptor::MAX_LEN + 1)).unwrap(); + + let names = vec!["alice".to_string(), "bob".to_string()]; + let terms: Vec<EqualityTerm> = names + .encrypt_into_with_context(&cipher, long.clone()) + .await?; + assert_eq!(terms.len(), 2); + + let none: Vec<u32> = Vec::new(); + let sealed: Vec<StackCipherText> = none + .encrypt_into_with_context(&cipher, long.clone()) + .await?; + assert!(sealed.is_empty()); + let opened: Vec<u32> = sealed.decrypt_into(&cipher, long).await?; + assert!(opened.is_empty()); + + let sent = sent.lock().expect("lock").clone(); + assert!(sent.generated().is_empty() && sent.retrieved().is_empty()); + Ok(()) +} + +/// A context that renders past ZeroKMS's descriptor limit is refused before +/// a single request is built — not after one per leaf — and nothing is sent: +/// the size of the tree does not multiply the cost of an over-long context. +#[tokio::test] +async fn an_over_long_context_is_refused_before_any_request_on_either_path() -> Result<(), Error> { + let (cipher, sent) = recording_cipher().await; + let long = stack_encrypt::NonEmpty::new("a".repeat(Descriptor::MAX_LEN + 1)).unwrap(); + + let values: Vec<u32> = (0..10_000).collect(); + let result: Result<Vec<StackCipherText>, Error> = values + .encrypt_into_with_context(&cipher, long.clone()) + .await; + assert!( + matches!(result, Err(Error::DescriptorTooLong { len }) if len == Descriptor::MAX_LEN + 1), + "{result:?}" + ); + + let sealed: StackCipherText = 7u32 + .encrypt_into_with_context(&cipher, nonempty!("users/age")) + .await?; + let opened: Result<u32, Error> = sealed.decrypt_into(&cipher, long).await; + assert!( + matches!(opened, Err(Error::DescriptorTooLong { .. })), + "{opened:?}" + ); + + let sent = sent.lock().expect("lock").clone(); + assert_eq!( + sent.generated(), + ["users/age"], + "only the good seal was sent" + ); + assert!(sent.retrieved().is_empty(), "nothing retrieved"); + Ok(()) +} diff --git a/packages/stack-encrypt/tests/frozen_bytes.rs b/packages/stack-encrypt/tests/frozen_bytes.rs index 32819c50b..310ec3d4e 100644 --- a/packages/stack-encrypt/tests/frozen_bytes.rs +++ b/packages/stack-encrypt/tests/frozen_bytes.rs @@ -20,6 +20,7 @@ use std::borrow::Cow; +use stack_encrypt::nonempty; use stack_encrypt::sem::{DefaultMatch, EqualityTerm, MatchTerm, OpeTerm, OreTerm, TermBytesError}; use stack_encrypt::target::EncryptInto; use stack_encrypt::{CipherText, Error, LeafBytesError, SealedValue, StackCipher}; @@ -218,7 +219,7 @@ async fn seal_rejects_a_key_tag_the_length_field_cannot_frame() { async fn equality_term_encoding_is_the_raw_prf_bytes() { let term = cipher() .await - .equality_term("alice", "users/email") + .equality_term("alice", nonempty!("users/email")) .await .expect("equality term"); @@ -251,7 +252,7 @@ fn equality_term_try_from_rejects_wrong_length() { async fn match_term_bytes_are_pinned() { let term = cipher() .await - .match_terms::<DefaultMatch>("alice smith", "users/name") + .match_terms::<DefaultMatch>("alice smith", nonempty!("users/name")) .await .expect("match term"); @@ -329,7 +330,7 @@ fn match_term_from_bytes_rejects_positions_outside_the_filter() { async fn ore_term_encoding_is_the_raw_cllw_bytes() { let cipher = cipher().await; let term: OreTerm<u32> = 42u32 - .encrypt_into_with_context(&cipher, "users/age") + .encrypt_into_with_context(&cipher, nonempty!("users/age")) .await .expect("ore term"); @@ -358,7 +359,7 @@ async fn ore_term_encoding_is_the_raw_cllw_bytes() { async fn ope_term_encoding_is_the_raw_cllw_bytes() { let cipher = cipher().await; let term: OpeTerm<u32> = 42u32 - .encrypt_into_with_context(&cipher, "users/age") + .encrypt_into_with_context(&cipher, nonempty!("users/age")) .await .expect("ope term"); @@ -402,7 +403,7 @@ async fn variable_length_ore_and_ope_terms_decode() { let cipher = cipher().await; let ore: OreTerm<String> = "alice" .to_string() - .encrypt_into_with_context(&cipher, "users/name") + .encrypt_into_with_context(&cipher, nonempty!("users/name")) .await .expect("ore term"); assert_eq!(ore.as_bytes().len(), 5 * 8); @@ -417,7 +418,7 @@ async fn variable_length_ore_and_ope_terms_decode() { let ope: OpeTerm<String> = "alice" .to_string() - .encrypt_into_with_context(&cipher, "users/name") + .encrypt_into_with_context(&cipher, nonempty!("users/name")) .await .expect("ope term"); assert_eq!(ope.as_bytes().len(), 5 * 8 + 1); diff --git a/packages/stack-encrypt/tests/roundtrip.rs b/packages/stack-encrypt/tests/roundtrip.rs index d229ab9aa..5ec9ffccd 100644 --- a/packages/stack-encrypt/tests/roundtrip.rs +++ b/packages/stack-encrypt/tests/roundtrip.rs @@ -317,7 +317,10 @@ async fn decipher_can_be_driven_directly() { .encrypt(Element("row".to_string()), b"users".as_slice()) .await .expect("encrypt"); - let decipher = cipher.decipher(ct).await.expect("retrieve keys"); + let decipher = cipher + .decipher(ct, b"users".as_slice()) + .await + .expect("retrieve keys"); let pt = <String as stack_encrypt::Decrypt>::decrypt_with_aad( decipher, Aad::from_slice(b"users").for_sequence_element(), @@ -330,7 +333,10 @@ async fn decipher_can_be_driven_directly() { .encrypt("scalar".to_string(), b"ctx".as_slice()) .await .expect("encrypt"); - let decipher = cipher.decipher(ct).await.expect("retrieve keys"); + let decipher = cipher + .decipher(ct, b"ctx".as_slice()) + .await + .expect("retrieve keys"); let pt = <String as stack_encrypt::Decrypt>::decrypt_with_aad(decipher, b"ctx".into_aad()) .expect("decrypt"); assert_eq!(pt, "scalar"); diff --git a/packages/stack-encrypt/tests/sem_terms.rs b/packages/stack-encrypt/tests/sem_terms.rs index 6e329d821..a543f598c 100644 --- a/packages/stack-encrypt/tests/sem_terms.rs +++ b/packages/stack-encrypt/tests/sem_terms.rs @@ -3,6 +3,7 @@ use std::cmp::Ordering; +use stack_encrypt::nonempty; use stack_encrypt::sem::{DefaultMatch, MatchConfig, MatchOptions, Tokenizer}; use stack_encrypt::StackCipher; use stack_kms::{FakeDataKeySource, IdentifiedBy}; @@ -58,24 +59,42 @@ async fn generator_for(keyset: Uuid) -> StackCipher<FakeDataKeySource> { #[tokio::test] async fn equality_terms_are_deterministic() { let gen = generator().await; - let a = gen.equality_term("alice", "users/email").await.unwrap(); - let b = gen.equality_term("alice", "users/email").await.unwrap(); + let a = gen + .equality_term("alice", nonempty!("users/email")) + .await + .unwrap(); + let b = gen + .equality_term("alice", nonempty!("users/email")) + .await + .unwrap(); assert_eq!(a, b, "same value + descriptor must yield the same term"); } #[tokio::test] async fn equality_terms_bind_the_descriptor() { let gen = generator().await; - let a = gen.equality_term("alice", "users/email").await.unwrap(); - let b = gen.equality_term("alice", "users/name").await.unwrap(); + let a = gen + .equality_term("alice", nonempty!("users/email")) + .await + .unwrap(); + let b = gen + .equality_term("alice", nonempty!("users/name")) + .await + .unwrap(); assert_ne!(a, b, "the descriptor must domain-separate terms"); } #[tokio::test] async fn equality_terms_differ_by_value() { let gen = generator().await; - let a = gen.equality_term("alice", "users/email").await.unwrap(); - let b = gen.equality_term("bob", "users/email").await.unwrap(); + let a = gen + .equality_term("alice", nonempty!("users/email")) + .await + .unwrap(); + let b = gen + .equality_term("bob", nonempty!("users/email")) + .await + .unwrap(); assert_ne!(a, b); } @@ -83,8 +102,14 @@ async fn equality_terms_differ_by_value() { async fn equality_terms_bind_the_index_key() { let gen_a = generator_for(Uuid::from_u128(1)).await; let gen_b = generator_for(Uuid::from_u128(2)).await; - let a = gen_a.equality_term("alice", "users/email").await.unwrap(); - let b = gen_b.equality_term("alice", "users/email").await.unwrap(); + let a = gen_a + .equality_term("alice", nonempty!("users/email")) + .await + .unwrap(); + let b = gen_b + .equality_term("alice", nonempty!("users/email")) + .await + .unwrap(); assert_ne!(a, b, "different keysets must yield different terms"); } @@ -93,11 +118,11 @@ async fn match_query_terms_are_contained_in_stored_terms() { let gen = generator().await; let stored = gen - .match_terms::<DefaultMatch>("alice wonderland", "users/bio") + .match_terms::<DefaultMatch>("alice wonderland", nonempty!("users/bio")) .await .unwrap(); let query = gen - .match_terms::<DefaultMatch>("wonder", "users/bio") + .match_terms::<DefaultMatch>("wonder", nonempty!("users/bio")) .await .unwrap(); @@ -112,11 +137,11 @@ async fn match_is_case_insensitive_by_default() { let gen = generator().await; let stored = gen - .match_terms::<DefaultMatch>("Alice", "users/name") + .match_terms::<DefaultMatch>("Alice", nonempty!("users/name")) .await .unwrap(); let query = gen - .match_terms::<DefaultMatch>("alice", "users/name") + .match_terms::<DefaultMatch>("alice", nonempty!("users/name")) .await .unwrap(); assert_eq!(stored, query); @@ -127,11 +152,11 @@ async fn match_binds_the_descriptor() { let gen = generator().await; let stored = gen - .match_terms::<DefaultMatch>("alice", "users/bio") + .match_terms::<DefaultMatch>("alice", nonempty!("users/bio")) .await .unwrap(); let query = gen - .match_terms::<DefaultMatch>("alice", "users/name") + .match_terms::<DefaultMatch>("alice", nonempty!("users/name")) .await .unwrap(); assert_ne!(stored, query, "match tokens must be descriptor-bound"); @@ -151,7 +176,7 @@ async fn match_positions_stay_within_the_filter() { let gen = generator().await; let term = gen - .match_terms::<SmallFilter>("a longer piece of text", "users/bio") + .match_terms::<SmallFilter>("a longer piece of text", nonempty!("users/bio")) .await .unwrap(); assert!(!term.positions().is_empty()); @@ -166,14 +191,26 @@ async fn match_rejects_invalid_options() { // The v1 match indexer's bounds apply: k in 3..=16, m a power of two in // [32, 65536]. - assert!(gen.match_terms::<TooBigK>("xxx", "d").await.is_err()); - assert!(gen.match_terms::<TooSmallK>("xxx", "d").await.is_err()); - assert!(gen.match_terms::<NonPowerOfTwoM>("xxx", "d").await.is_err()); - assert!(gen.match_terms::<TooSmallM>("xxx", "d").await.is_err()); + assert!(gen + .match_terms::<TooBigK>("xxx", nonempty!("d")) + .await + .is_err()); + assert!(gen + .match_terms::<TooSmallK>("xxx", nonempty!("d")) + .await + .is_err()); + assert!(gen + .match_terms::<NonPowerOfTwoM>("xxx", nonempty!("d")) + .await + .is_err()); + assert!(gen + .match_terms::<TooSmallM>("xxx", nonempty!("d")) + .await + .is_err()); // A zero-length n-gram must be an options error, not a panic. assert!(matches!( - gen.match_terms::<ZeroNgram>("xxx", "d").await, + gen.match_terms::<ZeroNgram>("xxx", nonempty!("d")).await, Err(stack_encrypt::sem::TermError::InvalidOptions(_)) )); } @@ -188,7 +225,8 @@ async fn match_rejects_text_that_yields_no_tokens() { for text in ["", " "] { assert!( matches!( - gen.match_terms::<DefaultMatch>(text, "users/bio").await, + gen.match_terms::<DefaultMatch>(text, nonempty!("users/bio")) + .await, Err(TermError::EmptyTermText) ), "{text:?} must be rejected" @@ -198,13 +236,15 @@ async fn match_rejects_text_that_yields_no_tokens() { // A probe shorter than the n-gram length could never match a stored gram // (v1 indexer semantics) — rejected instead of a silent false negative. assert!(matches!( - gen.match_terms::<DefaultMatch>("hi", "users/bio").await, + gen.match_terms::<DefaultMatch>("hi", nonempty!("users/bio")) + .await, Err(TermError::EmptyTermText) )); // Separator-only text under the Standard tokenizer. assert!(matches!( - gen.match_terms::<WordMatch>(" ,;:! ", "users/bio").await, + gen.match_terms::<WordMatch>(" ,;:! ", nonempty!("users/bio")) + .await, Err(TermError::EmptyTermText) )); } @@ -214,11 +254,11 @@ async fn word_tokenizer_matches_whole_words() { let gen = generator().await; let stored = gen - .match_terms::<WordMatch>("alice in wonderland", "users/bio") + .match_terms::<WordMatch>("alice in wonderland", nonempty!("users/bio")) .await .unwrap(); let query = gen - .match_terms::<WordMatch>("wonderland", "users/bio") + .match_terms::<WordMatch>("wonderland", nonempty!("users/bio")) .await .unwrap(); assert!(stored.contains(&query)); @@ -228,9 +268,9 @@ async fn word_tokenizer_matches_whole_words() { async fn ore_terms_preserve_order_and_determinism() { let gen = generator().await; - let ten = gen.ore_term(10u64, "users/age").await.unwrap(); - let ten_again = gen.ore_term(10u64, "users/age").await.unwrap(); - let twenty = gen.ore_term(20u64, "users/age").await.unwrap(); + let ten = gen.ore_term(10u64, nonempty!("users/age")).await.unwrap(); + let ten_again = gen.ore_term(10u64, nonempty!("users/age")).await.unwrap(); + let twenty = gen.ore_term(20u64, nonempty!("users/age")).await.unwrap(); assert_eq!(ten, ten_again, "ORE terms must be deterministic"); assert_eq!(ten.cmp(&twenty), Ordering::Less); @@ -239,16 +279,25 @@ async fn ore_terms_preserve_order_and_determinism() { #[tokio::test] async fn ore_terms_bind_the_descriptor() { let gen = generator().await; - let a = gen.ore_term(10u64, "users/age").await.unwrap(); - let b = gen.ore_term(10u64, "users/height").await.unwrap(); + let a = gen.ore_term(10u64, nonempty!("users/age")).await.unwrap(); + let b = gen + .ore_term(10u64, nonempty!("users/height")) + .await + .unwrap(); assert_ne!(a, b, "per-descriptor ORE keys must differ"); } #[tokio::test] async fn string_ore_terms_preserve_lexicographic_order() { let gen = generator().await; - let apple = gen.ore_term("apple", "users/name").await.unwrap(); - let banana = gen.ore_term("banana", "users/name").await.unwrap(); + let apple = gen + .ore_term("apple", nonempty!("users/name")) + .await + .unwrap(); + let banana = gen + .ore_term("banana", nonempty!("users/name")) + .await + .unwrap(); assert_eq!(apple.cmp(&banana), Ordering::Less); } @@ -256,8 +305,8 @@ async fn string_ore_terms_preserve_lexicographic_order() { async fn ope_terms_compare_with_plain_byte_order() { let gen = generator().await; - let ten = gen.ope_term(10u64, "users/age").await.unwrap(); - let twenty = gen.ope_term(20u64, "users/age").await.unwrap(); + let ten = gen.ope_term(10u64, nonempty!("users/age")).await.unwrap(); + let twenty = gen.ope_term(20u64, nonempty!("users/age")).await.unwrap(); // OPE ciphertexts order with standard lexicographic comparison. assert!(ten.as_ref() < twenty.as_ref()); @@ -268,24 +317,30 @@ async fn ore_and_ope_keys_are_domain_separated() { // The same descriptor must not derive the same key material for both // schemes; equal plaintexts should produce different ciphertext bytes. let gen = generator().await; - let ore = gen.ore_term(42u64, "users/age").await.unwrap(); - let ope = gen.ope_term(42u64, "users/age").await.unwrap(); + let ore = gen.ore_term(42u64, nonempty!("users/age")).await.unwrap(); + let ope = gen.ope_term(42u64, nonempty!("users/age")).await.unwrap(); assert_ne!(ore.as_ref(), ope.as_ref()); } #[tokio::test] async fn owned_and_borrowed_text_yield_identical_ore_and_ope_terms() { let gen = generator().await; - let borrowed = gen.ore_term("apple", "users/name").await.unwrap(); + let borrowed = gen + .ore_term("apple", nonempty!("users/name")) + .await + .unwrap(); let owned = gen - .ore_term(String::from("apple"), "users/name") + .ore_term(String::from("apple"), nonempty!("users/name")) .await .unwrap(); assert_eq!(borrowed.as_ref(), owned.as_ref()); - let borrowed = gen.ope_term("apple", "users/name").await.unwrap(); + let borrowed = gen + .ope_term("apple", nonempty!("users/name")) + .await + .unwrap(); let owned = gen - .ope_term(String::from("apple"), "users/name") + .ope_term(String::from("apple"), nonempty!("users/name")) .await .unwrap(); assert_eq!(borrowed.as_ref(), owned.as_ref()); diff --git a/packages/stack-encrypt/tests/target.rs b/packages/stack-encrypt/tests/target.rs index 063a6827a..c437cc0a9 100644 --- a/packages/stack-encrypt/tests/target.rs +++ b/packages/stack-encrypt/tests/target.rs @@ -8,14 +8,13 @@ use std::cmp::Ordering; use std::sync::atomic::Ordering as AtomicOrdering; use stack_encrypt::sem::{EqualityTerm, MatchConfig, MatchOptions, MatchTerm, OreTerm}; -use stack_encrypt::target::{ - supplied_prf_context, DecryptInto, EncryptContext, EncryptFrom, EncryptInto, Pending, Request, - SuppliedContext, +use stack_encrypt::target::{DecryptInto, EncryptFrom, EncryptInto, Pending, Request}; +use stack_encrypt::{ + nonempty, Descriptor, EmptyError, Error, NonEmpty, StackCipher, StackCipherText, }; -use stack_encrypt::{Error, StackCipher, StackCipherText}; use stack_kms::{FakeDataKeySource, IdentifiedBy, IndexKeySource}; use uuid::Uuid; -use vitaminc_prf::{BlockVisitor, PrfContext, PrfValue}; +use vitaminc_prf::{BlockVisitor, IntoPrfContext, PrfContext, PrfValue}; mod common; use common::{counting_cipher, stack_cipher}; @@ -35,11 +34,11 @@ async fn equality_leaf_agrees_with_the_descriptor_api() { let generator = generator().await; let via_target: EqualityTerm = "alice" - .encrypt_into_with_context(&generator, "users/email") + .encrypt_into_with_context(&generator, nonempty!("users/email")) .await .unwrap(); let via_descriptor = generator - .equality_term("alice", "users/email") + .equality_term("alice", nonempty!("users/email")) .await .unwrap(); @@ -58,21 +57,21 @@ async fn terms_agree_between_independently_built_ciphers() { let generator = generator().await; let a: EqualityTerm = "alice" - .encrypt_into_with_context(&cipher, "users/email") + .encrypt_into_with_context(&cipher, nonempty!("users/email")) .await .unwrap(); let b: EqualityTerm = "alice" - .encrypt_into_with_context(&generator, "users/email") + .encrypt_into_with_context(&generator, nonempty!("users/email")) .await .unwrap(); assert_eq!(a, b); let a: OreTerm<u64> = 7u64 - .encrypt_into_with_context(&cipher, "users/n") + .encrypt_into_with_context(&cipher, nonempty!("users/n")) .await .unwrap(); let b: OreTerm<u64> = 7u64 - .encrypt_into_with_context(&generator, "users/n") + .encrypt_into_with_context(&generator, nonempty!("users/n")) .await .unwrap(); assert_eq!(a, b); @@ -83,11 +82,11 @@ async fn equality_leaf_binds_the_context() { let generator = generator().await; let email: EqualityTerm = "alice" - .encrypt_into_with_context(&generator, "users/email") + .encrypt_into_with_context(&generator, nonempty!("users/email")) .await .unwrap(); let name: EqualityTerm = "alice" - .encrypt_into_with_context(&generator, "users/name") + .encrypt_into_with_context(&generator, nonempty!("users/name")) .await .unwrap(); @@ -101,11 +100,11 @@ async fn term_derivation_makes_no_kms_calls() { let (cipher, generates, retrieves) = counting_cipher().await; let _term: EqualityTerm = "alice" - .encrypt_into_with_context(&cipher, "users/email") + .encrypt_into_with_context(&cipher, nonempty!("users/email")) .await .unwrap(); let _ore: OreTerm<u64> = 7u64 - .encrypt_into_with_context(&cipher, "users/age") + .encrypt_into_with_context(&cipher, nonempty!("users/age")) .await .unwrap(); @@ -119,11 +118,11 @@ async fn ciphertext_leaf_round_trips_via_decrypt_into() { let ciphertext: StackCipherText = "secret" .to_string() - .encrypt_into_with_context(&cipher, "users/email") + .encrypt_into_with_context(&cipher, nonempty!("users/email")) .await .unwrap(); let plaintext: String = ciphertext - .decrypt_into(&cipher, "users/email") + .decrypt_into(&cipher, nonempty!("users/email")) .await .unwrap(); @@ -136,11 +135,13 @@ async fn ciphertext_leaf_cannot_be_transplanted_to_another_context() { let ciphertext: StackCipherText = "secret" .to_string() - .encrypt_into_with_context(&cipher, "users/email") + .encrypt_into_with_context(&cipher, nonempty!("users/email")) .await .unwrap(); - let transplanted: Result<String, _> = ciphertext.decrypt_into(&cipher, "users/name").await; + let transplanted: Result<String, _> = ciphertext + .decrypt_into(&cipher, nonempty!("users/name")) + .await; assert!( transplanted.is_err(), "the context is bound into the AAD, so a ciphertext must not decrypt under another field's context" @@ -153,12 +154,12 @@ async fn match_leaf_supports_containment_queries() { let stored: MatchTerm = "alice wonderland" .to_string() - .encrypt_into_with_context(&generator, "users/bio") + .encrypt_into_with_context(&generator, nonempty!("users/bio")) .await .unwrap(); let query: MatchTerm = "wonder" .to_string() - .encrypt_into_with_context(&generator, "users/bio") + .encrypt_into_with_context(&generator, nonempty!("users/bio")) .await .unwrap(); @@ -183,7 +184,7 @@ async fn match_leaf_config_is_type_level() { let term: MatchTerm<SmallFilter> = "a longer piece of text" .to_string() - .encrypt_into_with_context(&generator, "users/bio") + .encrypt_into_with_context(&generator, nonempty!("users/bio")) .await .unwrap(); assert!(term.positions().iter().all(|&p| u32::from(p) < 64)); @@ -192,7 +193,7 @@ async fn match_leaf_config_is_type_level() { // term — and a different type, so the two cannot be compared by mistake. let default_term: MatchTerm = "a longer piece of text" .to_string() - .encrypt_into_with_context(&generator, "users/bio") + .encrypt_into_with_context(&generator, nonempty!("users/bio")) .await .unwrap(); assert_ne!(term.positions(), default_term.positions()); @@ -203,19 +204,19 @@ async fn ore_leaf_preserves_order_and_binds_the_context() { let generator = generator().await; let ten: OreTerm<u64> = 10u64 - .encrypt_into_with_context(&generator, "users/age") + .encrypt_into_with_context(&generator, nonempty!("users/age")) .await .unwrap(); let ten_again: OreTerm<u64> = 10u64 - .encrypt_into_with_context(&generator, "users/age") + .encrypt_into_with_context(&generator, nonempty!("users/age")) .await .unwrap(); let twenty: OreTerm<u64> = 20u64 - .encrypt_into_with_context(&generator, "users/age") + .encrypt_into_with_context(&generator, nonempty!("users/age")) .await .unwrap(); let other_field: OreTerm<u64> = 10u64 - .encrypt_into_with_context(&generator, "users/height") + .encrypt_into_with_context(&generator, nonempty!("users/height")) .await .unwrap(); @@ -231,11 +232,11 @@ async fn ope_leaf_compares_with_plain_byte_order() { let generator = generator().await; let ten: OpeTerm<u64> = 10u64 - .encrypt_into_with_context(&generator, "users/age") + .encrypt_into_with_context(&generator, nonempty!("users/age")) .await .unwrap(); let twenty: OpeTerm<u64> = 20u64 - .encrypt_into_with_context(&generator, "users/age") + .encrypt_into_with_context(&generator, nonempty!("users/age")) .await .unwrap(); @@ -251,7 +252,7 @@ async fn a_column_encrypts_in_one_batched_call() { let ages: Vec<u32> = vec![29, 34, 41, 34, 57]; let sealed: Vec<StackCipherText> = ages - .encrypt_into_with_context(&cipher, "users/age") + .encrypt_into_with_context(&cipher, nonempty!("users/age")) .await .unwrap(); @@ -269,11 +270,14 @@ async fn a_column_decrypts_in_one_batched_call() { let ages: Vec<u32> = vec![29, 34, 41]; let sealed: Vec<StackCipherText> = ages - .encrypt_into_with_context(&cipher, "users/age") + .encrypt_into_with_context(&cipher, nonempty!("users/age")) .await .unwrap(); - let roundtrip: Vec<u32> = sealed.decrypt_into(&cipher, "users/age").await.unwrap(); + let roundtrip: Vec<u32> = sealed + .decrypt_into(&cipher, nonempty!("users/age")) + .await + .unwrap(); assert_eq!(roundtrip, ages); assert_eq!( @@ -288,11 +292,11 @@ async fn optional_fields_encrypt_and_decrypt_structurally() { let cipher = stack_cipher().await; let present: Option<StackCipherText> = Some("here".to_string()) - .encrypt_into_with_context(&cipher, "users/nickname") + .encrypt_into_with_context(&cipher, nonempty!("users/nickname")) .await .unwrap(); let absent: Option<StackCipherText> = Option::<String>::None - .encrypt_into_with_context(&cipher, "users/nickname") + .encrypt_into_with_context(&cipher, nonempty!("users/nickname")) .await .unwrap(); @@ -300,7 +304,7 @@ async fn optional_fields_encrypt_and_decrypt_structurally() { assert!(absent.is_none()); let roundtrip: Option<String> = present - .decrypt_into(&cipher, "users/nickname") + .decrypt_into(&cipher, nonempty!("users/nickname")) .await .unwrap(); assert_eq!(roundtrip.as_deref(), Some("here")); @@ -368,28 +372,31 @@ async fn composite_record_encrypts_every_field_from_one_source() { let generator = generator().await; let record: EncryptedAge = 42u32 - .encrypt_into_with_context(&cipher, "users/age") + .encrypt_into_with_context(&cipher, nonempty!("users/age")) .await .unwrap(); // The ciphertext round-trips through the decrypt mirror. - let plaintext: u32 = record.decrypt_into(&cipher, "users/age").await.unwrap(); + let plaintext: u32 = record + .decrypt_into(&cipher, nonempty!("users/age")) + .await + .unwrap(); assert_eq!(plaintext, 42); // Each term matches what the primitive would derive on its own, so query // terms generated leaf-by-leaf find records encrypted as composites. let record: EncryptedAge = 42u32 - .encrypt_into_with_context(&cipher, "users/age") + .encrypt_into_with_context(&cipher, nonempty!("users/age")) .await .unwrap(); let hm: EqualityTerm = 42u32 - .encrypt_into_with_context(&generator, "users/age") + .encrypt_into_with_context(&generator, nonempty!("users/age")) .await .unwrap(); assert_eq!(record.hm, hm); let ob: OreTerm<u32> = 42u32 - .encrypt_into_with_context(&generator, "users/age") + .encrypt_into_with_context(&generator, nonempty!("users/age")) .await .unwrap(); assert_eq!(record.ob, ob); @@ -400,7 +407,7 @@ async fn a_composite_record_is_one_batched_call() { let (cipher, generates, _) = counting_cipher().await; let _record: EncryptedAge = 42u32 - .encrypt_into_with_context(&cipher, "users/age") + .encrypt_into_with_context(&cipher, nonempty!("users/age")) .await .unwrap(); assert_eq!( @@ -412,7 +419,7 @@ async fn a_composite_record_is_one_batched_call() { // A whole column of records: still one call. let ages: Vec<u32> = vec![10, 20, 30]; let _column: Vec<EncryptedAge> = ages - .encrypt_into_with_context(&cipher, "users/age") + .encrypt_into_with_context(&cipher, nonempty!("users/age")) .await .unwrap(); assert_eq!( @@ -427,11 +434,11 @@ async fn composite_record_terms_preserve_order() { let cipher = stack_cipher().await; let ten: EncryptedAge = 10u32 - .encrypt_into_with_context(&cipher, "users/age") + .encrypt_into_with_context(&cipher, nonempty!("users/age")) .await .unwrap(); let twenty: EncryptedAge = 20u32 - .encrypt_into_with_context(&cipher, "users/age") + .encrypt_into_with_context(&cipher, nonempty!("users/age")) .await .unwrap(); @@ -449,27 +456,23 @@ async fn composite_record_terms_preserve_order() { #[derive(Debug, PartialEq, Eq)] struct PrefixTerm<const N: usize>([u8; 32]); -impl<'c, S, K, Ctx, const N: usize> EncryptFrom<S, StackCipher<K>, Ctx> for PrefixTerm<N> +impl<'c, S, K, T, const N: usize> EncryptFrom<S, StackCipher<K>, NonEmpty<T>> for PrefixTerm<N> where S: AsRef<str>, - Ctx: EncryptContext<'c> + SuppliedContext<'c>, + T: IntoPrfContext<'c>, { fn encrypt_from<'a>( source: &'a S, cipher: &'a StackCipher<K>, - context: Ctx, + context: NonEmpty<T>, ) -> Pending<'a, Self, K> where Self: 'a, { - // The same non-emptiness check the built-in leaves make — validation - // and encoding through the one choke point — then an own domain - // label: can never collide with a built-in term under the same - // context. - let context = match supplied_prf_context(context) { - Ok(context) => context, - Err(error) => return Pending::ready(cipher, Err(error)), - }; + // The `NonEmpty` is the proof the built-in leaves rely on too; then + // an own domain label, so this can never collide with a built-in + // term under the same context. + let context = context.into_prf_context().into_owned(); let context = PrfContext::pae(&[ b"example/prefix-term/v1", &(N as u64).to_le_bytes(), @@ -477,7 +480,7 @@ where ]); let prefix: String = source.as_ref().chars().take(N).collect(); let term = prefix - .prf_visit_with_context(cipher.prf().clone(), context, BlockVisitor) + .prf_visit_with_context(cipher.prf(), context, BlockVisitor) .into_result() .map(PrefixTerm) .map_err(|e| Error::Other(Box::new(e))); @@ -491,15 +494,15 @@ async fn third_party_term_type_works_on_the_public_surface() { let generator = generator().await; let stored: PrefixTerm<3> = "alice" - .encrypt_into_with_context(&cipher, "users/name") + .encrypt_into_with_context(&cipher, nonempty!("users/name")) .await .unwrap(); let probe: PrefixTerm<3> = "alicia" - .encrypt_into_with_context(&generator, "users/name") + .encrypt_into_with_context(&generator, nonempty!("users/name")) .await .unwrap(); let miss: PrefixTerm<3> = "bob" - .encrypt_into_with_context(&generator, "users/name") + .encrypt_into_with_context(&generator, nonempty!("users/name")) .await .unwrap(); @@ -512,9 +515,11 @@ async fn third_party_term_type_works_on_the_public_surface() { prefix: PrefixTerm<3>, } - impl<'c, K, Ctx> EncryptFrom<String, StackCipher<K>, Ctx> for NameRecord + impl<K, Ctx> EncryptFrom<String, StackCipher<K>, Ctx> for NameRecord where - Ctx: EncryptContext<'c> + SuppliedContext<'c>, + Ctx: Clone, + StackCipherText: EncryptFrom<String, StackCipher<K>, Ctx>, + PrefixTerm<3>: EncryptFrom<String, StackCipher<K>, Ctx>, { fn encrypt_from<'a>( source: &'a String, @@ -532,11 +537,15 @@ async fn third_party_term_type_works_on_the_public_surface() { let record: NameRecord = "alice" .to_string() - .encrypt_into_with_context(&cipher, "users/name") + .encrypt_into_with_context(&cipher, nonempty!("users/name")) .await .unwrap(); assert_eq!(record.prefix, stored); - let name: String = record.c.decrypt_into(&cipher, "users/name").await.unwrap(); + let name: String = record + .c + .decrypt_into(&cipher, nonempty!("users/name")) + .await + .unwrap(); assert_eq!(name, "alice"); } @@ -576,136 +585,142 @@ async fn an_explicit_keyset_is_honoured() { // And its terms differ from the default keyset's: a different keyset means // a different index key. let default = stack_cipher().await; - let a = cipher.equality_term("alice", "users/email").await.unwrap(); - let b = default.equality_term("alice", "users/email").await.unwrap(); + let a = cipher + .equality_term("alice", nonempty!("users/email")) + .await + .unwrap(); + let b = default + .equality_term("alice", nonempty!("users/email")) + .await + .unwrap(); assert_ne!(a, b); } -#[tokio::test] -async fn empty_context_is_rejected_everywhere() { - use stack_encrypt::sem::{OpeTerm, TermError}; +#[test] +fn an_empty_context_cannot_be_built() { + // The leaves take a `NonEmpty<T>` and nothing else, so an empty context + // — one that would collapse per-field domain separation — is refused + // where the value is built, once, by vitaminc's structural check: + // `""`, `None`, `Some("")`, tuples of empties. There is no runtime path + // through a leaf for one to fail on, and `nonempty!("")` does not + // compile. + assert_eq!(NonEmpty::new("").unwrap_err(), EmptyError); + assert_eq!(NonEmpty::new(String::new()).unwrap_err(), EmptyError); + assert_eq!(NonEmpty::new(None::<&str>).unwrap_err(), EmptyError); + assert_eq!(NonEmpty::new(Some("")).unwrap_err(), EmptyError); + assert_eq!(NonEmpty::new(("", "")).unwrap_err(), EmptyError); + // A composite that still carries information is fine — and so is an + // integer, which is never empty. + assert!(NonEmpty::new(("", "email")).is_ok()); + assert!(NonEmpty::new(Some("users/email")).is_ok()); + let _: NonEmpty<u64> = 0u64.into(); +} +#[tokio::test] +async fn wrapped_and_extended_contexts_bind_like_plain_ones() { + // vitaminc blanket-implements the context traits for `Option` and + // tuples, and `NonEmpty::with` extends a proven head with any tail: all + // of them are contexts a leaf takes, and all of them bind. let cipher = stack_cipher().await; - let generator = generator().await; - // Terms: an empty context would collapse per-field domain separation. - // Rejected during the synchronous build — before any I/O could happen. - let eq: Result<EqualityTerm, _> = "alice".encrypt_into_with_context(&generator, "").await; - assert!(matches!(eq, Err(Error::EmptyContext))); - let m: Result<MatchTerm, _> = "alice" - .to_string() - .encrypt_into_with_context(&generator, "") - .await; - assert!(matches!(m, Err(Error::EmptyContext))); - let ore: Result<OreTerm<u64>, _> = 7u64.encrypt_into_with_context(&generator, "").await; - assert!(matches!(ore, Err(Error::EmptyContext))); - let ope: Result<OpeTerm<u64>, _> = 7u64.encrypt_into_with_context(&generator, "").await; - assert!(matches!(ope, Err(Error::EmptyContext))); - - // Descriptor-string convenience methods route through the same guard. - assert!(matches!( - generator.equality_term("alice", "").await, - Err(TermError::EmptyContext) - )); - - // The ciphertext leaf: an empty AAD would make ciphertexts transplantable - // between ()-context fields. - let ct: Result<StackCipherText, _> = "secret" - .to_string() - .encrypt_into_with_context(&cipher, "") - .await; - assert!(matches!(ct, Err(Error::EmptyContext))); - - // And the decrypt mirror never opens under one either. let sealed: StackCipherText = "secret" .to_string() - .encrypt_into_with_context(&cipher, "users/email") + .encrypt_into_with_context(&cipher, NonEmpty::new(Some("users/email")).unwrap()) .await .unwrap(); - let opened: Result<String, _> = sealed.decrypt_into(&cipher, "").await; - assert!(matches!(opened, Err(Error::EmptyContext))); -} - -#[tokio::test] -async fn wrapped_empty_contexts_are_rejected_too() { - // vitaminc blanket-implements the context traits for `Option` and tuples, - // whose encodings of "nothing" are non-empty byte strings. The guard is - // structural, so none of these get through on any path. - let cipher = stack_cipher().await; - - let eq: Result<EqualityTerm, _> = "alice" - .encrypt_into_with_context(&cipher, None::<&str>) - .await; - assert!(matches!(eq, Err(Error::EmptyContext))); - let eq: Result<EqualityTerm, _> = "alice".encrypt_into_with_context(&cipher, Some("")).await; - assert!(matches!(eq, Err(Error::EmptyContext))); - let eq: Result<EqualityTerm, _> = "alice".encrypt_into_with_context(&cipher, ("", "")).await; - assert!(matches!(eq, Err(Error::EmptyContext))); - - let ore: Result<OreTerm<u64>, _> = 7u64.encrypt_into_with_context(&cipher, None::<&str>).await; - assert!(matches!(ore, Err(Error::EmptyContext))); + let opened: String = sealed + .decrypt_into(&cipher, NonEmpty::new(Some("users/email")).unwrap()) + .await + .unwrap(); + assert_eq!(opened, "secret"); - let ct: Result<StackCipherText, _> = "secret" + // Extended with a row id: opens under the same pair, and only there. + // (Against the fake key source the AEAD refuses; ZeroKMS refuses the + // key retrieval under the other descriptor first, as `Error::Kms`.) + let sealed: StackCipherText = "secret" .to_string() - .encrypt_into_with_context(&cipher, None::<&str>) + .encrypt_into_with_context(&cipher, nonempty!("users/email").with(42u64)) + .await + .unwrap(); + let wrong_row: Result<String, _> = sealed + .decrypt_into(&cipher, nonempty!("users/email").with(43u64)) .await; - assert!(matches!(ct, Err(Error::EmptyContext))); - + assert!(matches!(wrong_row, Err(Error::Aead))); let sealed: StackCipherText = "secret" .to_string() - .encrypt_into_with_context(&cipher, "users/email") + .encrypt_into_with_context(&cipher, nonempty!("users/email").with(42u64)) + .await + .unwrap(); + let no_row: Result<String, _> = sealed.decrypt_into(&cipher, nonempty!("users/email")).await; + assert!(matches!(no_row, Err(Error::Aead))); + + // The pair encodes as the bare tuple would: a term under the extended + // context equals one under the plain pair, so a query site need not + // hold a `NonEmpty` head to probe. + let extended: EqualityTerm = "alice" + .encrypt_into_with_context(&cipher, nonempty!("users/email").with(42u64)) .await .unwrap(); - let opened: Result<String, _> = sealed.decrypt_into(&cipher, None::<&str>).await; - assert!(matches!(opened, Err(Error::EmptyContext))); + let plain: EqualityTerm = "alice" + .encrypt_into_with_context(&cipher, NonEmpty::new(("users/email", 42u64)).unwrap()) + .await + .unwrap(); + assert_eq!(extended, plain); + let unextended: EqualityTerm = "alice" + .encrypt_into_with_context(&cipher, nonempty!("users/email")) + .await + .unwrap(); + assert_ne!(extended, unextended); +} + +#[tokio::test] +async fn a_bare_integer_is_a_context() { + // An integer is never empty, so it converts into a `NonEmpty` on its own + // and the sugar takes it bare. + let cipher = stack_cipher().await; - // A wrapped context that does carry information still works, and binds. let sealed: StackCipherText = "secret" .to_string() - .encrypt_into_with_context(&cipher, Some("users/email")) + .encrypt_into_with_context(&cipher, 42u64) .await .unwrap(); let opened: String = sealed - .decrypt_into(&cipher, Some("users/email")) + .decrypt_into(&cipher, NonEmpty::from(42u64)) .await .unwrap(); assert_eq!(opened, "secret"); + let term: EqualityTerm = "alice" + .encrypt_into_with_context(&cipher, 7u32) + .await + .unwrap(); + let other: EqualityTerm = "alice" + .encrypt_into_with_context(&cipher, 8u32) + .await + .unwrap(); + assert_ne!(term, other); } #[tokio::test] async fn containers_pass_the_context_through_to_their_leaves() { - // `Vec` and `Option` validate nothing themselves: a populated container - // under an empty context fails at the first leaf (synchronously, before - // any I/O), and an empty one has no leaf to fail at. Whether a context - // is *owed* at all is likewise the leaves' call, passed through the - // type: a column of leaves is `EncryptFrom<_, _, Ctx>` only for a - // supplied `Ctx` (`tests/ui/leaf_without_context.rs`), a column of + // `Vec` and `Option` hand the context on untouched, and so hand on the + // obligation: a column of leaves is `EncryptFrom<_, _, Ctx>` only for a + // `NonEmpty<_>` (`tests/ui/leaf_without_context.rs`), a column of // derived rows — records whose fields carry their own contexts — for - // any, so it is encrypted with no context at all. - let (cipher, generates, _) = counting_cipher().await; - - let some: Result<Option<StackCipherText>, _> = Some("x".to_string()) - .encrypt_into_with_context(&cipher, "") - .await; - assert!(matches!(some, Err(Error::EmptyContext))); - let populated: Result<Vec<StackCipherText>, _> = vec!["x".to_string()] - .encrypt_into_with_context(&cipher, "") - .await; - assert!(matches!(populated, Err(Error::EmptyContext))); - assert_eq!(generates.load(AtomicOrdering::SeqCst), 0); + // `()` as well. An empty container derives nothing either way. + let cipher = stack_cipher().await; let none: Option<StackCipherText> = None::<String> - .encrypt_into_with_context(&cipher, "users/x") + .encrypt_into_with_context(&cipher, nonempty!("users/x")) .await .unwrap(); assert!(none.is_none()); let empty: Vec<StackCipherText> = Vec::<String>::new() - .encrypt_into_with_context(&cipher, "users/x") + .encrypt_into_with_context(&cipher, nonempty!("users/x")) .await .unwrap(); assert!(empty.is_empty()); let empty: Vec<String> = Vec::<StackCipherText>::new() - .decrypt_into(&cipher, "users/x") + .decrypt_into(&cipher, nonempty!("users/x")) .await .unwrap(); assert!(empty.is_empty()); @@ -713,26 +728,30 @@ async fn containers_pass_the_context_through_to_their_leaves() { #[tokio::test] async fn a_failed_field_fails_the_record_before_any_kms_call() { - // One misconfigured field must not cause the record's other fields to - // mint data keys that are then thrown away. + // One failed field must not cause the record's other fields to mint + // data keys that are then thrown away. A match term over text that + // yields no tokens fails during the synchronous build, before any I/O. let (cipher, generates, _) = counting_cipher().await; - let a = "a".to_string(); let b = "b".to_string(); - let zipped = StackCipherText::encrypt_from(&a, &cipher, "") - .zip(StackCipherText::encrypt_from(&b, &cipher, "users/x")) + let zipped = MatchTerm::<SmallFilter>::encrypt_from(&"", &cipher, nonempty!("users/x")) + .zip(StackCipherText::encrypt_from( + &b, + &cipher, + nonempty!("users/x"), + )) .await; - assert!(matches!(zipped, Err(Error::EmptyContext))); + assert!(matches!(zipped, Err(Error::Term(_)))); let column = Pending::all( &cipher, vec![ - StackCipherText::encrypt_from(&b, &cipher, "users/x"), - StackCipherText::encrypt_from(&a, &cipher, ""), + StackCipherText::encrypt_from(&b, &cipher, nonempty!("users/x")), + Pending::failed(&cipher, Error::NotOpened), ], ) .await; - assert!(matches!(column, Err(Error::EmptyContext))); + assert!(matches!(column, Err(Error::NotOpened))); assert_eq!( generates.load(AtomicOrdering::SeqCst), @@ -748,16 +767,20 @@ async fn pendings_from_different_ciphers_refuse_to_merge() { let v = "v".to_string(); let w = "w".to_string(); - let zipped = StackCipherText::encrypt_from(&v, &cipher_a, "users/x") - .zip(StackCipherText::encrypt_from(&w, &cipher_b, "users/x")) + let zipped = StackCipherText::encrypt_from(&v, &cipher_a, nonempty!("users/x")) + .zip(StackCipherText::encrypt_from( + &w, + &cipher_b, + nonempty!("users/x"), + )) .await; assert!(matches!(zipped, Err(Error::CipherMismatch))); let column = Pending::all( &cipher_a, vec![ - StackCipherText::encrypt_from(&v, &cipher_a, "users/x"), - StackCipherText::encrypt_from(&w, &cipher_b, "users/x"), + StackCipherText::encrypt_from(&v, &cipher_a, nonempty!("users/x")), + StackCipherText::encrypt_from(&w, &cipher_b, nonempty!("users/x")), ], ) .await; @@ -772,12 +795,15 @@ async fn an_overdrawing_fulfilment_is_a_response_shape_error() { // drawing more must fail loudly, never consume a sibling's responses. let cipher = stack_cipher().await; - let pending: Pending<'_, (), _> = - Pending::request(&cipher, vec![Request::generate_data_key()], |responses| { + let pending: Pending<'_, (), _> = Pending::request( + &cipher, + vec![Request::generate_data_key(Descriptor::of("t"))], + |responses| { responses.next_generated_key()?; responses.next_generated_key()?; // one more than requested Ok(()) - }); + }, + ); assert!(matches!(pending.await, Err(Error::ResponseShape))); } @@ -788,7 +814,7 @@ async fn terms_rehydrate_from_persisted_parts() { let generator = generator().await; let eq: EqualityTerm = "alice" - .encrypt_into_with_context(&generator, "users/email") + .encrypt_into_with_context(&generator, nonempty!("users/email")) .await .unwrap(); let rehydrated = EqualityTerm::from_bytes(eq.clone().into_bytes()); @@ -796,12 +822,12 @@ async fn terms_rehydrate_from_persisted_parts() { let stored: MatchTerm = "alice wonderland" .to_string() - .encrypt_into_with_context(&generator, "users/bio") + .encrypt_into_with_context(&generator, nonempty!("users/bio")) .await .unwrap(); let query: MatchTerm = "wonder" .to_string() - .encrypt_into_with_context(&generator, "users/bio") + .encrypt_into_with_context(&generator, nonempty!("users/bio")) .await .unwrap(); // Rehydrate from unsorted positions: from_positions normalises (and @@ -821,7 +847,7 @@ async fn pending_futures_are_send() { let handle = tokio::spawn(async move { let term: EqualityTerm = "alice" - .encrypt_into_with_context(&generator, "users/email") + .encrypt_into_with_context(&generator, nonempty!("users/email")) .await .unwrap(); term @@ -863,12 +889,15 @@ async fn cipher_directed_and_target_directed_ciphertexts_are_interchangeable() { // Sealed by the cipher-directed API, opened by the target-directed one. let ct = cipher.encrypt(value.clone(), "users/tags").await.unwrap(); - let via_target: Vec<String> = ct.decrypt_into(&cipher, "users/tags").await.unwrap(); + let via_target: Vec<String> = ct + .decrypt_into(&cipher, nonempty!("users/tags")) + .await + .unwrap(); assert_eq!(via_target, value); // Sealed by the target-directed API, opened by the cipher-directed one. let ct: StackCipherText = value - .encrypt_into_with_context(&cipher, "users/tags") + .encrypt_into_with_context(&cipher, nonempty!("users/tags")) .await .unwrap(); let via_cipher: Vec<String> = cipher.decrypt(ct, "users/tags").await.unwrap(); @@ -880,12 +909,13 @@ async fn cipher_directed_decrypt_rejects_a_transplanted_ciphertext() { let cipher = stack_cipher().await; let ct: StackCipherText = "secret" .to_string() - .encrypt_into_with_context(&cipher, "users/email") + .encrypt_into_with_context(&cipher, nonempty!("users/email")) .await .unwrap(); let result: Result<String, Error> = cipher.decrypt(ct, "users/name").await; assert!( matches!(result, Err(Error::Aead)), - "a target-sealed leaf must not open under another context via the cipher API" + "a target-sealed leaf must not open under another context via the cipher API \ + (the fake key source ignores descriptors; ZeroKMS would refuse the retrieve)" ); } diff --git a/packages/stack-encrypt/tests/term_bytes.rs b/packages/stack-encrypt/tests/term_bytes.rs index c2084d598..084a6a5b1 100644 --- a/packages/stack-encrypt/tests/term_bytes.rs +++ b/packages/stack-encrypt/tests/term_bytes.rs @@ -10,6 +10,7 @@ //! Keyed by `FakeDataKeySource`'s deterministic index key, so the expected //! bytes are stable without ZeroKMS. +use stack_encrypt::nonempty; use stack_encrypt::sem::DefaultMatch; use stack_encrypt::StackCipher; use stack_kms::FakeDataKeySource; @@ -30,7 +31,7 @@ fn hex(bytes: &[u8]) -> String { async fn equality_term_bytes_are_pinned() { let term = cipher() .await - .equality_term("alice", "users/email") + .equality_term("alice", nonempty!("users/email")) .await .unwrap(); @@ -44,7 +45,7 @@ async fn equality_term_bytes_are_pinned() { async fn match_term_positions_are_pinned() { let term = cipher() .await - .match_terms::<DefaultMatch>("alice smith", "users/name") + .match_terms::<DefaultMatch>("alice smith", nonempty!("users/name")) .await .unwrap(); @@ -63,7 +64,11 @@ async fn match_term_positions_are_pinned() { async fn ore_term_bytes_are_pinned() { // The ORE key is a PRF of the descriptor, so this pins the key derivation // as much as the CLLW encryption. - let term = cipher().await.ore_term(42u32, "users/age").await.unwrap(); + let term = cipher() + .await + .ore_term(42u32, nonempty!("users/age")) + .await + .unwrap(); assert_eq!( hex(term.as_ref()), @@ -76,7 +81,11 @@ async fn ope_term_bytes_are_pinned() { // Distinct from the ORE pin above under the same descriptor: the two // schemes derive their keys under different domains and must never share // one (OPE ciphertexts are encrypt-only). - let term = cipher().await.ope_term(42u32, "users/age").await.unwrap(); + let term = cipher() + .await + .ope_term(42u32, nonempty!("users/age")) + .await + .unwrap(); assert_eq!( hex(term.as_ref()), diff --git a/packages/stack-encrypt/tests/ui.rs b/packages/stack-encrypt/tests/ui.rs index 1cda92c9e..ef7cfbf80 100644 --- a/packages/stack-encrypt/tests/ui.rs +++ b/packages/stack-encrypt/tests/ui.rs @@ -6,4 +6,7 @@ fn derive_diagnostics() { let t = trybuild::TestCases::new(); t.compile_fail("tests/ui/*.rs"); + // And the shapes that must compile, so a diagnostic never grows to + // cover a valid call. + t.pass("tests/ui/pass/*.rs"); } diff --git a/packages/stack-encrypt/tests/ui/bare_context.rs b/packages/stack-encrypt/tests/ui/bare_context.rs new file mode 100644 index 000000000..1ddd8a36b --- /dev/null +++ b/packages/stack-encrypt/tests/ui/bare_context.rs @@ -0,0 +1,56 @@ +//! The `_with_context` forms take anything that converts into a +//! `NonEmpty<_>` — a `nonempty!(..)` literal, a `NonEmpty::new(..)?` value, +//! a bare integer — and nothing unproven: a `&str` is not a context until it +//! has been checked, so it is turned away at the call, not at a leaf. That +//! holds on every path that reaches a leaf — a term, a struct record, a +//! plaintext-derived record, a leaf opened directly — so the proof cannot be +//! skipped by passing the raw value. +use stack_encrypt::sem::EqualityTerm; +use stack_encrypt::target::{DecryptFrom, EncryptInto}; +use stack_encrypt::{DecryptInto, EncryptFrom, StackCipher, StackCipherText}; +use stack_kms::FakeDataKeySource; + +struct User { + email: String, +} + +#[derive(EncryptFrom, DecryptInto)] +#[stash(struct = User, context = "users")] +struct EncryptedUser { + email: StackCipherText, +} + +#[derive(EncryptFrom)] +#[stash(plaintext = u32)] +struct EncryptedAge { + c: StackCipherText, + hm: EqualityTerm, +} + +async fn encrypt(cipher: &StackCipher<FakeDataKeySource>, user: User) { + let _term: EqualityTerm = "alice" + .encrypt_into_with_context(cipher, "users/email") + .await + .unwrap(); + let _row: EncryptedUser = user + .encrypt_into_with_context(cipher, "tenant/acme") + .await + .unwrap(); + let _record: EncryptedAge = 42u32 + .encrypt_into_with_context(cipher, "users/age") + .await + .unwrap(); +} + +async fn decrypt( + cipher: &StackCipher<FakeDataKeySource>, + row: EncryptedUser, + sealed: StackCipherText, +) { + let _user = User::decrypt_from_with_context(row, cipher, "tenant/acme") + .await + .unwrap(); + let _age: u32 = sealed.decrypt_into(cipher, "users/age").await.unwrap(); +} + +fn main() {} diff --git a/packages/stack-encrypt/tests/ui/bare_context.stderr b/packages/stack-encrypt/tests/ui/bare_context.stderr new file mode 100644 index 000000000..d1f7f0ab9 --- /dev/null +++ b/packages/stack-encrypt/tests/ui/bare_context.stderr @@ -0,0 +1,127 @@ +error[E0277]: the trait bound `NonEmpty<_>: From<&str>` is not satisfied + --> tests/ui/bare_context.rs:32:44 + | +32 | .encrypt_into_with_context(cipher, "users/email") + | ------------------------- ^^^^^^^^^^^^^ the trait `From<&str>` is not implemented for `NonEmpty<_>` + | | + | required by a bound introduced by this call + | + = help: the following other types implement trait `From<T>`: + `NonEmpty<i128>` implements `From<i128>` + `NonEmpty<i16>` implements `From<i16>` + `NonEmpty<i32>` implements `From<i32>` + `NonEmpty<i64>` implements `From<i64>` + `NonEmpty<i8>` implements `From<i8>` + `NonEmpty<u128>` implements `From<u128>` + `NonEmpty<u16>` implements `From<u16>` + `NonEmpty<u32>` implements `From<u32>` + and $N others + = note: required for `&str` to implement `Into<NonEmpty<_>>` +note: required by a bound in `encrypt_into_with_context` + --> src/target/mod.rs + | + | fn encrypt_into_with_context<'a, T, C, N, Ctx>( + | ------------------------- required by a bound in this associated function +... + | Ctx: Into<NonEmpty<N>>, + | ^^^^^^^^^^^^^^^^^ required by this bound in `EncryptInto::encrypt_into_with_context` + +error[E0277]: the trait bound `NonEmpty<_>: From<&str>` is not satisfied + --> tests/ui/bare_context.rs:36:44 + | +36 | .encrypt_into_with_context(cipher, "tenant/acme") + | ------------------------- ^^^^^^^^^^^^^ the trait `From<&str>` is not implemented for `NonEmpty<_>` + | | + | required by a bound introduced by this call + | + = help: the following other types implement trait `From<T>`: + `NonEmpty<i128>` implements `From<i128>` + `NonEmpty<i16>` implements `From<i16>` + `NonEmpty<i32>` implements `From<i32>` + `NonEmpty<i64>` implements `From<i64>` + `NonEmpty<i8>` implements `From<i8>` + `NonEmpty<u128>` implements `From<u128>` + `NonEmpty<u16>` implements `From<u16>` + `NonEmpty<u32>` implements `From<u32>` + and $N others + = note: required for `&str` to implement `Into<NonEmpty<_>>` +note: required by a bound in `encrypt_into_with_context` + --> src/target/mod.rs + | + | fn encrypt_into_with_context<'a, T, C, N, Ctx>( + | ------------------------- required by a bound in this associated function +... + | Ctx: Into<NonEmpty<N>>, + | ^^^^^^^^^^^^^^^^^ required by this bound in `EncryptInto::encrypt_into_with_context` + +error[E0277]: the trait bound `NonEmpty<_>: From<&str>` is not satisfied + --> tests/ui/bare_context.rs:40:44 + | +40 | .encrypt_into_with_context(cipher, "users/age") + | ------------------------- ^^^^^^^^^^^ the trait `From<&str>` is not implemented for `NonEmpty<_>` + | | + | required by a bound introduced by this call + | + = help: the following other types implement trait `From<T>`: + `NonEmpty<i128>` implements `From<i128>` + `NonEmpty<i16>` implements `From<i16>` + `NonEmpty<i32>` implements `From<i32>` + `NonEmpty<i64>` implements `From<i64>` + `NonEmpty<i8>` implements `From<i8>` + `NonEmpty<u128>` implements `From<u128>` + `NonEmpty<u16>` implements `From<u16>` + `NonEmpty<u32>` implements `From<u32>` + and $N others + = note: required for `&str` to implement `Into<NonEmpty<_>>` +note: required by a bound in `encrypt_into_with_context` + --> src/target/mod.rs + | + | fn encrypt_into_with_context<'a, T, C, N, Ctx>( + | ------------------------- required by a bound in this associated function +... + | Ctx: Into<NonEmpty<N>>, + | ^^^^^^^^^^^^^^^^^ required by this bound in `EncryptInto::encrypt_into_with_context` + +error[E0277]: the trait bound `NonEmpty<_>: From<&str>` is not satisfied + --> tests/ui/bare_context.rs:50:62 + | +50 | let _user = User::decrypt_from_with_context(row, cipher, "tenant/acme") + | ------------------------------- ^^^^^^^^^^^^^ the trait `From<&str>` is not implemented for `NonEmpty<_>` + | | + | required by a bound introduced by this call + | + = help: the following other types implement trait `From<T>`: + `NonEmpty<i128>` implements `From<i128>` + `NonEmpty<i16>` implements `From<i16>` + `NonEmpty<i32>` implements `From<i32>` + `NonEmpty<i64>` implements `From<i64>` + `NonEmpty<i8>` implements `From<i8>` + `NonEmpty<u128>` implements `From<u128>` + `NonEmpty<u16>` implements `From<u16>` + `NonEmpty<u32>` implements `From<u32>` + and $N others + = note: required for `&str` to implement `Into<NonEmpty<_>>` +note: required by a bound in `decrypt_from_with_context` + --> src/target/mod.rs + | + | fn decrypt_from_with_context<'a, S, C, N, Ctx>( + | ------------------------- required by a bound in this associated function +... + | Ctx: Into<NonEmpty<N>>, + | ^^^^^^^^^^^^^^^^^ required by this bound in `DecryptFrom::decrypt_from_with_context` + +error[E0308]: mismatched types + --> tests/ui/bare_context.rs:53:49 + | +53 | let _age: u32 = sealed.decrypt_into(cipher, "users/age").await.unwrap(); + | ------------ ^^^^^^^^^^^ expected `NonEmpty<_>`, found `&str` + | | + | arguments to this method are incorrect + | + = note: expected struct `NonEmpty<_>` + found reference `&'static str` +note: method defined here + --> src/target/mod.rs + | + | fn decrypt_into<'a>(self, cipher: &'a C, context: Ctx) -> C::Output<'a, P> + | ^^^^^^^^^^^^ diff --git a/packages/stack-encrypt/tests/ui/decrypt_ambiguous_shape.rs b/packages/stack-encrypt/tests/ui/decrypt_ambiguous_shape.rs deleted file mode 100644 index bfb74b4a0..000000000 --- a/packages/stack-encrypt/tests/ui/decrypt_ambiguous_shape.rs +++ /dev/null @@ -1,16 +0,0 @@ -use stack_encrypt::sem::EqualityTerm; -use stack_encrypt::{DecryptInto, StackCipherText}; - -struct User { - email: String, -} - -#[derive(DecryptInto)] -#[stash(plaintext = User)] -struct Rec { - #[stash(from = email, context = "users/email")] - email: StackCipherText, - hm: EqualityTerm, -} - -fn main() {} diff --git a/packages/stack-encrypt/tests/ui/decrypt_ambiguous_shape.stderr b/packages/stack-encrypt/tests/ui/decrypt_ambiguous_shape.stderr deleted file mode 100644 index d464b58f9..000000000 --- a/packages/stack-encrypt/tests/ui/decrypt_ambiguous_shape.stderr +++ /dev/null @@ -1,5 +0,0 @@ -error: some derived fields name a plaintext field (`from = ..`) and some do not, so it is ambiguous whether decryption opens the record as a whole or rebuilds the plaintext field by field: mark the fields decryption opens `#[stash(decrypt)]` - --> tests/ui/decrypt_ambiguous_shape.rs:10:8 - | -10 | struct Rec { - | ^^^ diff --git a/packages/stack-encrypt/tests/ui/decrypt_duplicate_from.rs b/packages/stack-encrypt/tests/ui/decrypt_duplicate_from.rs index 78d055c8e..65ad08e12 100644 --- a/packages/stack-encrypt/tests/ui/decrypt_duplicate_from.rs +++ b/packages/stack-encrypt/tests/ui/decrypt_duplicate_from.rs @@ -5,9 +5,9 @@ struct User { } #[derive(DecryptInto)] -#[stash(plaintext = User)] +#[stash(struct = User, context = "users")] struct Rec { - #[stash(decrypt, from = a)] + #[stash(decrypt)] a: StackCipherText, #[stash(decrypt, from = a)] b: StackCipherText, diff --git a/packages/stack-encrypt/tests/ui/decrypt_mixed_modes.rs b/packages/stack-encrypt/tests/ui/decrypt_mixed_modes.rs deleted file mode 100644 index 79fd5753e..000000000 --- a/packages/stack-encrypt/tests/ui/decrypt_mixed_modes.rs +++ /dev/null @@ -1,17 +0,0 @@ -use stack_encrypt::{DecryptInto, StackCipherText}; - -struct User { - a: u32, - b: u32, -} - -#[derive(DecryptInto)] -#[stash(plaintext = User)] -struct Rec { - #[stash(decrypt, from = a)] - a: StackCipherText, - #[stash(decrypt)] - b: StackCipherText, -} - -fn main() {} diff --git a/packages/stack-encrypt/tests/ui/decrypt_mixed_modes.stderr b/packages/stack-encrypt/tests/ui/decrypt_mixed_modes.stderr deleted file mode 100644 index 221a3ef97..000000000 --- a/packages/stack-encrypt/tests/ui/decrypt_mixed_modes.stderr +++ /dev/null @@ -1,5 +0,0 @@ -error: `decrypt` fields must either all name a plaintext field (`from = ..`) or be a single field opened as the whole plaintext; this record mixes the two - --> tests/ui/decrypt_mixed_modes.rs:10:8 - | -10 | struct Rec { - | ^^^ diff --git a/packages/stack-encrypt/tests/ui/decrypt_several_recover_one_field.rs b/packages/stack-encrypt/tests/ui/decrypt_several_recover_one_field.rs index ef82dfca8..4239673be 100644 --- a/packages/stack-encrypt/tests/ui/decrypt_several_recover_one_field.rs +++ b/packages/stack-encrypt/tests/ui/decrypt_several_recover_one_field.rs @@ -5,9 +5,8 @@ struct User { } #[derive(DecryptInto)] -#[stash(plaintext = User)] +#[stash(struct = User, context = "users")] struct Rec { - #[stash(from = email, context = "users/email")] email: StackCipherText, #[stash(from = email, context = "users/email/copy")] email_copy: StackCipherText, diff --git a/packages/stack-encrypt/tests/ui/duplicate_singleton_attrs.rs b/packages/stack-encrypt/tests/ui/duplicate_singleton_attrs.rs index 7c3210437..5c734f590 100644 --- a/packages/stack-encrypt/tests/ui/duplicate_singleton_attrs.rs +++ b/packages/stack-encrypt/tests/ui/duplicate_singleton_attrs.rs @@ -6,7 +6,7 @@ struct User { } #[derive(EncryptFrom)] -#[stash(plaintext = User)] +#[stash(struct = User, context = "users")] struct DupFrom { #[stash(from = expected, from = other)] c: StackCipherText, diff --git a/packages/stack-encrypt/tests/ui/empty_context.rs b/packages/stack-encrypt/tests/ui/empty_context.rs index 7aea2b175..7394ee21e 100644 --- a/packages/stack-encrypt/tests/ui/empty_context.rs +++ b/packages/stack-encrypt/tests/ui/empty_context.rs @@ -5,10 +5,17 @@ struct User { } #[derive(EncryptFrom)] -#[stash(plaintext = User)] +#[stash(struct = User, context = "users")] struct EncryptedUser { - #[stash(from = email, context = "")] + #[stash(context = "")] email: StackCipherText, } +#[derive(EncryptFrom)] +#[stash(plaintext = u32)] +struct Pinned { + #[stash(context = "")] + c: StackCipherText, +} + fn main() {} diff --git a/packages/stack-encrypt/tests/ui/empty_context.stderr b/packages/stack-encrypt/tests/ui/empty_context.stderr index f6f6c7fbf..31dbaeb0d 100644 --- a/packages/stack-encrypt/tests/ui/empty_context.stderr +++ b/packages/stack-encrypt/tests/ui/empty_context.stderr @@ -1,5 +1,11 @@ -error: an empty `context` is rejected when a value is encrypted: name the column this field encrypts (e.g. "users/email"). A `from` field is never handed the record's context, so the literal is the only context this field can have. - --> tests/ui/empty_context.rs:10:37 +error: an empty `context` is rejected when a value is encrypted: name the field (e.g. "users/email"), or drop the attribute to use the inferred `"<context>/<field>"` + --> tests/ui/empty_context.rs:10:23 | -10 | #[stash(from = email, context = "")] - | ^^ +10 | #[stash(context = "")] + | ^^ + +error: an empty `context` is rejected when a value is encrypted: name the field (e.g. "users/email"), or drop the attribute to hand the field the caller's context + --> tests/ui/empty_context.rs:17:23 + | +17 | #[stash(context = "")] + | ^^ diff --git a/packages/stack-encrypt/tests/ui/from_leaf_without_context.rs b/packages/stack-encrypt/tests/ui/from_leaf_without_context.rs deleted file mode 100644 index 7d5cb6018..000000000 --- a/packages/stack-encrypt/tests/ui/from_leaf_without_context.rs +++ /dev/null @@ -1,17 +0,0 @@ -//! A `from` field is never handed the caller's context: it is derived under -//! its own `context`, or under `()` if it has none. A leaf refuses `()`, and -//! says so at the field — the fix is a `context = ".."` on it. -use stack_encrypt::{DecryptInto, EncryptFrom, StackCipherText}; - -struct User { - email: String, -} - -#[derive(EncryptFrom, DecryptInto)] -#[stash(plaintext = User)] -struct EncryptedUser { - #[stash(from = email)] - email: StackCipherText, -} - -fn main() {} diff --git a/packages/stack-encrypt/tests/ui/from_leaf_without_context.stderr b/packages/stack-encrypt/tests/ui/from_leaf_without_context.stderr deleted file mode 100644 index 0598b6c5f..000000000 --- a/packages/stack-encrypt/tests/ui/from_leaf_without_context.stderr +++ /dev/null @@ -1,26 +0,0 @@ -error[E0277]: `()` is not a context the caller supplied - --> tests/ui/from_leaf_without_context.rs:14:12 - | -14 | email: StackCipherText, - | ^^^^^^^^^^^^^^^ this leaf needs a context - | - = note: `()` is what `encrypt_into` / `decrypt_from` pass, and what a derived row hands a `from` field with no `context = ".."` of its own: an output that reaches a leaf needs `encrypt_into_with_context` / `decrypt_from_with_context`, or the literal - = note: a context type of your own opts in with an empty `impl SuppliedContext<'_> for MyContext {}` - = help: the trait `SuppliedContext<'_>` is not implemented for `()` - but it is implemented for `(_, _)` - = help: for that trait implementation, expected `(_, _)`, found `()` - = note: required for `CipherText<SealedValue, Box<(dyn Any + Send + 'static)>>` to implement `EncryptFrom<String, StackCipher<__K>, ()>` - -error[E0277]: `()` is not a context the caller supplied - --> tests/ui/from_leaf_without_context.rs:14:12 - | -14 | email: StackCipherText, - | ^^^^^^^^^^^^^^^ this leaf needs a context - | - = note: `()` is what `encrypt_into` / `decrypt_from` pass, and what a derived row hands a `from` field with no `context = ".."` of its own: an output that reaches a leaf needs `encrypt_into_with_context` / `decrypt_from_with_context`, or the literal - = note: a context type of your own opts in with an empty `impl SuppliedContext<'_> for MyContext {}` - = help: the trait `SuppliedContext<'_>` is not implemented for `()` - but it is implemented for `(_, _)` - = help: for that trait implementation, expected `(_, _)`, found `()` - = note: required for `CipherText<SealedValue, Box<(dyn Any + Send + 'static)>>` to implement `DecryptInto<_, StackCipher<__K>, ()>` - = note: required for `CipherText<SealedValue, Box<(dyn Any + Send + 'static)>>` to implement `DecryptField<_, StackCipher<__K>, ()>` diff --git a/packages/stack-encrypt/tests/ui/from_without_plaintext.rs b/packages/stack-encrypt/tests/ui/from_without_plaintext.rs index 0b2494a09..86021e421 100644 --- a/packages/stack-encrypt/tests/ui/from_without_plaintext.rs +++ b/packages/stack-encrypt/tests/ui/from_without_plaintext.rs @@ -1,9 +1,23 @@ +//! `from = ..` reaches into a field of the plaintext, which is what +//! `#[stash(struct = ..)]` means; a `plaintext` record derives every field +//! from the whole value. use stack_encrypt::{EncryptFrom, StackCipherText}; +struct User { + age: u32, +} + #[derive(EncryptFrom)] +#[stash(plaintext = User)] struct Row { #[stash(from = age, context = "users/age")] age: StackCipherText, } +#[derive(EncryptFrom)] +struct Unnamed { + #[stash(from = age, context = "users/age")] + age: StackCipherText, +} + fn main() {} diff --git a/packages/stack-encrypt/tests/ui/from_without_plaintext.stderr b/packages/stack-encrypt/tests/ui/from_without_plaintext.stderr index 1a58260a1..e09c08e68 100644 --- a/packages/stack-encrypt/tests/ui/from_without_plaintext.stderr +++ b/packages/stack-encrypt/tests/ui/from_without_plaintext.stderr @@ -1,5 +1,11 @@ -error: `from = ..` reaches into a field of the plaintext, so the plaintext type must be named: add `#[stash(plaintext = ..)]` to the struct - --> tests/ui/from_without_plaintext.rs:5:20 - | -5 | #[stash(from = age, context = "users/age")] - | ^^^ +error: `from = ..` reaches into a field of the plaintext, which is what `#[stash(struct = ..)]` does: a `plaintext` record derives every field from the whole value + --> tests/ui/from_without_plaintext.rs:13:20 + | +13 | #[stash(from = age, context = "users/age")] + | ^^^ + +error: `from = ..` reaches into a field of the plaintext, which is what `#[stash(struct = ..)]` does: a `plaintext` record derives every field from the whole value + --> tests/ui/from_without_plaintext.rs:19:20 + | +19 | #[stash(from = age, context = "users/age")] + | ^^^ diff --git a/packages/stack-encrypt/tests/ui/leaf_without_context.rs b/packages/stack-encrypt/tests/ui/leaf_without_context.rs index c9c1e428a..791772a51 100644 --- a/packages/stack-encrypt/tests/ui/leaf_without_context.rs +++ b/packages/stack-encrypt/tests/ui/leaf_without_context.rs @@ -1,6 +1,6 @@ //! A leaf, a record that hands the caller's context to one, and a column of -//! either all need a supplied context: the context-free `encrypt_into` / -//! `decrypt_from` exist only for outputs that carry their own. +//! either all need a `NonEmpty<_>` context: the context-free `encrypt_into` +//! / `decrypt_from` pass `()`, which no leaf accepts. use stack_encrypt::sem::EqualityTerm; use stack_encrypt::target::{DecryptFrom, EncryptInto}; use stack_encrypt::{DecryptInto, EncryptFrom, StackCipher, StackCipherText}; @@ -23,4 +23,8 @@ async fn decrypt(cipher: &StackCipher<FakeDataKeySource>, record: EncryptedAge) let _age = u32::decrypt_from(record, cipher).await.unwrap(); } +async fn decrypt_leaf(cipher: &StackCipher<FakeDataKeySource>, record: EncryptedAge) { + let _age: u32 = record.decrypt_into(cipher, ()).await.unwrap(); +} + fn main() {} diff --git a/packages/stack-encrypt/tests/ui/leaf_without_context.stderr b/packages/stack-encrypt/tests/ui/leaf_without_context.stderr index 8da05751d..e39ceb4b9 100644 --- a/packages/stack-encrypt/tests/ui/leaf_without_context.stderr +++ b/packages/stack-encrypt/tests/ui/leaf_without_context.stderr @@ -1,15 +1,42 @@ -error[E0277]: `()` is not a context the caller supplied +error[E0277]: `EqualityTerm` is not an encrypted form of `&str` under a `()` context --> tests/ui/leaf_without_context.rs:17:39 | 17 | let _term: EqualityTerm = "alice".encrypt_into(cipher).await.unwrap(); - | ^^^^^^^^^^^^ this leaf needs a context - | - = note: `()` is what `encrypt_into` / `decrypt_from` pass, and what a derived row hands a `from` field with no `context = ".."` of its own: an output that reaches a leaf needs `encrypt_into_with_context` / `decrypt_from_with_context`, or the literal - = note: a context type of your own opts in with an empty `impl SuppliedContext<'_> for MyContext {}` - = help: the trait `SuppliedContext<'_>` is not implemented for `()` - but it is implemented for `(_, _)` - = help: for that trait implementation, expected `(_, _)`, found `()` - = note: required for `EqualityTerm` to implement `EncryptFrom<&str, StackCipher<FakeDataKeySource>, ()>` + | ^^^^^^^^^^^^ not `EncryptFrom<&str, _, ()>` + | + = note: a leaf — a ciphertext or an index term — exists only under a `NonEmpty<_>` context: `encrypt_into` passes `()`, so use `encrypt_into_with_context(&cipher, context)`, or give the field a `context = ".."` of its own + = note: a context is anything vitaminc encodes (`&str`, `String`, bytes, integers, `Option`s and pairs of those), proven non-empty: `nonempty!("users/email")` for a literal, `NonEmpty::new(value)?` for a runtime value, a bare integer for an id + = help: the trait `EncryptFrom<&str, StackCipher<FakeDataKeySource>, ()>` is not implemented for `EqualityTerm` + but trait `EncryptFrom<&str, StackCipher<FakeDataKeySource>, NonEmpty<_>>` is implemented for it + = help: for that trait implementation, expected `NonEmpty<_>`, found `()` +note: required by a bound in `encrypt_into` + --> src/target/mod.rs + | + | fn encrypt_into<'a, T, C>(&'a self, cipher: &'a C) -> C::Output<'a, T> + | ------------ required by a bound in this associated function +... + | T: EncryptFrom<Self, C, ()> + 'a, + | ^^^^^^^^^^^^^^^^^^^^^^^^ required by this bound in `EncryptInto::encrypt_into` + +error[E0277]: `CipherText<SealedValue, Box<(dyn Any + Send + 'static)>>` is not an encrypted form of `u32` under a `()` context + --> tests/ui/leaf_without_context.rs:18:39 + | +18 | let _record: EncryptedAge = 42u32.encrypt_into(cipher).await.unwrap(); + | ^^^^^^^^^^^^ not `EncryptFrom<u32, _, ()>` + | + = note: a leaf — a ciphertext or an index term — exists only under a `NonEmpty<_>` context: `encrypt_into` passes `()`, so use `encrypt_into_with_context(&cipher, context)`, or give the field a `context = ".."` of its own + = note: a context is anything vitaminc encodes (`&str`, `String`, bytes, integers, `Option`s and pairs of those), proven non-empty: `nonempty!("users/email")` for a literal, `NonEmpty::new(value)?` for a runtime value, a bare integer for an id + = help: the trait `EncryptFrom<u32, StackCipher<FakeDataKeySource>, ()>` is not implemented for `CipherText<SealedValue, Box<(dyn Any + Send + 'static)>>` + but trait `EncryptFrom<u32, StackCipher<FakeDataKeySource>, NonEmpty<_>>` is implemented for it + = help: for that trait implementation, expected `NonEmpty<_>`, found `()` +note: required for `EncryptedAge` to implement `EncryptFrom<u32, StackCipher<FakeDataKeySource>, ()>` + --> tests/ui/leaf_without_context.rs:9:10 + | + 9 | #[derive(EncryptFrom, DecryptInto)] + | ^^^^^^^^^^^ unsatisfied trait bound introduced in this `derive` macro +10 | #[stash(plaintext = u32)] +11 | struct EncryptedAge { + | ^^^^^^^^^^^^ note: required by a bound in `encrypt_into` --> src/target/mod.rs | @@ -18,21 +45,27 @@ note: required by a bound in `encrypt_into` ... | T: EncryptFrom<Self, C, ()> + 'a, | ^^^^^^^^^^^^^^^^^^^^^^^^ required by this bound in `EncryptInto::encrypt_into` + = note: this error originates in the derive macro `EncryptFrom` (in Nightly builds, run with -Z macro-backtrace for more info) -error[E0277]: `()` is not a context the caller supplied +error[E0277]: `EqualityTerm` is not an encrypted form of `u32` under a `()` context --> tests/ui/leaf_without_context.rs:18:39 | 18 | let _record: EncryptedAge = 42u32.encrypt_into(cipher).await.unwrap(); - | ^^^^^^^^^^^^ this leaf needs a context - | - = note: `()` is what `encrypt_into` / `decrypt_from` pass, and what a derived row hands a `from` field with no `context = ".."` of its own: an output that reaches a leaf needs `encrypt_into_with_context` / `decrypt_from_with_context`, or the literal - = note: a context type of your own opts in with an empty `impl SuppliedContext<'_> for MyContext {}` - = help: the trait `SuppliedContext<'_>` is not implemented for `()` - but it is implemented for `(_, _)` - = help: for that trait implementation, expected `(_, _)`, found `()` - = note: required for `CipherText<SealedValue, Box<(dyn Any + Send + 'static)>>` to implement `EncryptFrom<u32, StackCipher<FakeDataKeySource>, ()>` - = note: 1 redundant requirement hidden - = note: required for `EncryptedAge` to implement `EncryptFrom<u32, StackCipher<FakeDataKeySource>, ()>` + | ^^^^^^^^^^^^ not `EncryptFrom<u32, _, ()>` + | + = note: a leaf — a ciphertext or an index term — exists only under a `NonEmpty<_>` context: `encrypt_into` passes `()`, so use `encrypt_into_with_context(&cipher, context)`, or give the field a `context = ".."` of its own + = note: a context is anything vitaminc encodes (`&str`, `String`, bytes, integers, `Option`s and pairs of those), proven non-empty: `nonempty!("users/email")` for a literal, `NonEmpty::new(value)?` for a runtime value, a bare integer for an id + = help: the trait `EncryptFrom<u32, StackCipher<FakeDataKeySource>, ()>` is not implemented for `EqualityTerm` + but trait `EncryptFrom<u32, StackCipher<FakeDataKeySource>, NonEmpty<_>>` is implemented for it + = help: for that trait implementation, expected `NonEmpty<_>`, found `()` +note: required for `EncryptedAge` to implement `EncryptFrom<u32, StackCipher<FakeDataKeySource>, ()>` + --> tests/ui/leaf_without_context.rs:9:10 + | + 9 | #[derive(EncryptFrom, DecryptInto)] + | ^^^^^^^^^^^ unsatisfied trait bound introduced in this `derive` macro +10 | #[stash(plaintext = u32)] +11 | struct EncryptedAge { + | ^^^^^^^^^^^^ note: required by a bound in `encrypt_into` --> src/target/mod.rs | @@ -41,22 +74,21 @@ note: required by a bound in `encrypt_into` ... | T: EncryptFrom<Self, C, ()> + 'a, | ^^^^^^^^^^^^^^^^^^^^^^^^ required by this bound in `EncryptInto::encrypt_into` + = note: this error originates in the derive macro `EncryptFrom` (in Nightly builds, run with -Z macro-backtrace for more info) -error[E0277]: `()` is not a context the caller supplied +error[E0277]: `CipherText<SealedValue, Box<dyn Any + Send>>` is not an encrypted form of `u32` under a `()` context --> tests/ui/leaf_without_context.rs:19:41 | 19 | let _column: Vec<StackCipherText> = vec![1u32].encrypt_into(cipher).await.unwrap(); | ^^^^^^^^^^ ------------ required by a bound introduced by this call | | - | this leaf needs a context - | - = note: `()` is what `encrypt_into` / `decrypt_from` pass, and what a derived row hands a `from` field with no `context = ".."` of its own: an output that reaches a leaf needs `encrypt_into_with_context` / `decrypt_from_with_context`, or the literal - = note: a context type of your own opts in with an empty `impl SuppliedContext<'_> for MyContext {}` - = help: the trait `SuppliedContext<'_>` is not implemented for `()` - but it is implemented for `(_, _)` - = help: for that trait implementation, expected `(_, _)`, found `()` - = note: required for `CipherText<SealedValue, Box<dyn Any + Send>>` to implement `EncryptFrom<u32, StackCipher<FakeDataKeySource>, ()>` - = note: 1 redundant requirement hidden + | not `EncryptFrom<u32, _, ()>` + | + = note: a leaf — a ciphertext or an index term — exists only under a `NonEmpty<_>` context: `encrypt_into` passes `()`, so use `encrypt_into_with_context(&cipher, context)`, or give the field a `context = ".."` of its own + = note: a context is anything vitaminc encodes (`&str`, `String`, bytes, integers, `Option`s and pairs of those), proven non-empty: `nonempty!("users/email")` for a literal, `NonEmpty::new(value)?` for a runtime value, a bare integer for an id + = help: the trait `EncryptFrom<u32, StackCipher<FakeDataKeySource>, ()>` is not implemented for `CipherText<SealedValue, Box<dyn Any + Send>>` + but trait `EncryptFrom<u32, StackCipher<FakeDataKeySource>, NonEmpty<_>>` is implemented for `CipherText<SealedValue, Box<(dyn Any + Send + 'static)>>` + = help: for that trait implementation, expected `NonEmpty<_>`, found `()` = note: required for `Vec<CipherText<SealedValue, Box<dyn Any + Send>>>` to implement `EncryptFrom<Vec<u32>, StackCipher<FakeDataKeySource>, ()>` note: required by a bound in `encrypt_into` --> src/target/mod.rs @@ -67,23 +99,27 @@ note: required by a bound in `encrypt_into` | T: EncryptFrom<Self, C, ()> + 'a, | ^^^^^^^^^^^^^^^^^^^^^^^^ required by this bound in `EncryptInto::encrypt_into` -error[E0277]: `()` is not a context the caller supplied +error[E0277]: `CipherText<SealedValue, Box<(dyn Any + Send + 'static)>>` does not decrypt to `u32` under a `()` context --> tests/ui/leaf_without_context.rs:23:34 | 23 | let _age = u32::decrypt_from(record, cipher).await.unwrap(); - | ----------------- ^^^^^^ this leaf needs a context + | ----------------- ^^^^^^ not `DecryptInto<u32, _, ()>` | | | required by a bound introduced by this call | - = note: `()` is what `encrypt_into` / `decrypt_from` pass, and what a derived row hands a `from` field with no `context = ".."` of its own: an output that reaches a leaf needs `encrypt_into_with_context` / `decrypt_from_with_context`, or the literal - = note: a context type of your own opts in with an empty `impl SuppliedContext<'_> for MyContext {}` - = help: the trait `SuppliedContext<'_>` is not implemented for `()` - but it is implemented for `(_, _)` - = help: for that trait implementation, expected `(_, _)`, found `()` - = note: required for `CipherText<SealedValue, Box<(dyn Any + Send + 'static)>>` to implement `DecryptInto<u32, StackCipher<_>, ()>` + = note: a leaf decrypts only under a `NonEmpty<_>` context — the one it was encrypted under (`decrypt_into(&cipher, context)` / `decrypt_from_with_context`); an output whose fields carry their own opens under `()` (`decrypt_from`) as well + = help: the trait `DecryptInto<u32, StackCipher<_>, ()>` is not implemented for `CipherText<SealedValue, Box<(dyn Any + Send + 'static)>>` + but trait `DecryptInto<u32, StackCipher<_>, NonEmpty<_>>` is implemented for it + = help: for that trait implementation, expected `NonEmpty<_>`, found `()` = note: required for `CipherText<SealedValue, Box<(dyn Any + Send + 'static)>>` to implement `DecryptField<u32, StackCipher<_>, ()>` - = note: 1 redundant requirement hidden - = note: required for `EncryptedAge` to implement `DecryptInto<u32, StackCipher<_>, ()>` +note: required for `EncryptedAge` to implement `DecryptInto<u32, StackCipher<_>, ()>` + --> tests/ui/leaf_without_context.rs:9:23 + | + 9 | #[derive(EncryptFrom, DecryptInto)] + | ^^^^^^^^^^^ unsatisfied trait bound introduced in this `derive` macro +10 | #[stash(plaintext = u32)] +11 | struct EncryptedAge { + | ^^^^^^^^^^^^ note: required by a bound in `decrypt_from` --> src/target/mod.rs | @@ -92,3 +128,20 @@ note: required by a bound in `decrypt_from` ... | S: DecryptInto<Self, C, ()> + 'a, | ^^^^^^^^^^^^^^^^^^^^^^^^ required by this bound in `DecryptFrom::decrypt_from` + = note: this error originates in the derive macro `DecryptInto` (in Nightly builds, run with -Z macro-backtrace for more info) + +error[E0308]: mismatched types + --> tests/ui/leaf_without_context.rs:27:49 + | +27 | let _age: u32 = record.decrypt_into(cipher, ()).await.unwrap(); + | ------------ ^^ expected `NonEmpty<_>`, found `()` + | | + | arguments to this method are incorrect + | + = note: expected struct `NonEmpty<_>` + found unit type `()` +note: method defined here + --> src/target/mod.rs + | + | fn decrypt_into<'a>(self, cipher: &'a C, context: Ctx) -> C::Output<'a, P> + | ^^^^^^^^^^^^ diff --git a/packages/stack-encrypt/tests/ui/nested_leaf_without_context.rs b/packages/stack-encrypt/tests/ui/nested_leaf_without_context.rs new file mode 100644 index 000000000..4e6c65f2f --- /dev/null +++ b/packages/stack-encrypt/tests/ui/nested_leaf_without_context.rs @@ -0,0 +1,18 @@ +//! A `nested` field is handed the caller's context as it is — `()` in the +//! record's `()` impl. A leaf accepts only a `NonEmpty<_>`, and says so at +//! the field: `nested` is for a field whose type carries its own contexts; +//! a leaf takes the inferred one, or a `context = ".."`. +use stack_encrypt::{DecryptInto, EncryptFrom, StackCipherText}; + +struct User { + email: String, +} + +#[derive(EncryptFrom, DecryptInto)] +#[stash(struct = User, context = "users")] +struct EncryptedUser { + #[stash(nested)] + email: StackCipherText, +} + +fn main() {} diff --git a/packages/stack-encrypt/tests/ui/nested_leaf_without_context.stderr b/packages/stack-encrypt/tests/ui/nested_leaf_without_context.stderr new file mode 100644 index 000000000..d7b653fb4 --- /dev/null +++ b/packages/stack-encrypt/tests/ui/nested_leaf_without_context.stderr @@ -0,0 +1,24 @@ +error[E0277]: `CipherText<SealedValue, Box<(dyn Any + Send + 'static)>>` is not an encrypted form of `_` under a `()` context + --> tests/ui/nested_leaf_without_context.rs:15:12 + | +15 | email: StackCipherText, + | ^^^^^^^^^^^^^^^ not `EncryptFrom<_, _, ()>` + | + = note: a leaf — a ciphertext or an index term — exists only under a `NonEmpty<_>` context: `encrypt_into` passes `()`, so use `encrypt_into_with_context(&cipher, context)`, or give the field a `context = ".."` of its own + = note: a context is anything vitaminc encodes (`&str`, `String`, bytes, integers, `Option`s and pairs of those), proven non-empty: `nonempty!("users/email")` for a literal, `NonEmpty::new(value)?` for a runtime value, a bare integer for an id + = help: the trait `EncryptFrom<_, StackCipher<__K>, ()>` is not implemented for `CipherText<SealedValue, Box<(dyn Any + Send + 'static)>>` + but trait `EncryptFrom<_, StackCipher<__K>, NonEmpty<_>>` is implemented for it + = help: for that trait implementation, expected `NonEmpty<_>`, found `()` + +error[E0277]: `CipherText<SealedValue, Box<(dyn Any + Send + 'static)>>` cannot be a field of an automatically decrypted record + --> tests/ui/nested_leaf_without_context.rs:15:12 + | +15 | email: StackCipherText, + | ^^^^^^^^^^^^^^^ no `DecryptField<_, ..>` implementation + | + = note: a term-only bundle — `#[derive(EncryptFrom)]` alone, nothing to open — has no `DecryptField`: mark the outer record's real ciphertext `#[stash(decrypt)]` so only the marked fields are considered + = note: a hand-written term type implements `DecryptField` (returning `None`) alongside `Decryptable`; a hand-written ciphertext type wraps its `DecryptInto` + = help: the trait `DecryptInto<_, StackCipher<__K>, ()>` is not implemented for `CipherText<SealedValue, Box<(dyn Any + Send + 'static)>>` + but trait `DecryptInto<_, StackCipher<__K>, NonEmpty<_>>` is implemented for it + = help: for that trait implementation, expected `NonEmpty<_>`, found `()` + = note: required for `CipherText<SealedValue, Box<(dyn Any + Send + 'static)>>` to implement `DecryptField<_, StackCipher<__K>, ()>` diff --git a/packages/stack-encrypt/tests/ui/nested_outside_row.rs b/packages/stack-encrypt/tests/ui/nested_outside_row.rs deleted file mode 100644 index 7b36059a4..000000000 --- a/packages/stack-encrypt/tests/ui/nested_outside_row.rs +++ /dev/null @@ -1,17 +0,0 @@ -//! `nested` opts a row field out of its inferred context. Outside a row it -//! is at best redundant — a `plaintext` record's `from` field with no -//! `context` is already handed `()` — so it is rejected rather than ignored. -use stack_encrypt::{EncryptFrom, StackCipherText}; - -struct User { - email: String, -} - -#[derive(EncryptFrom)] -#[stash(plaintext = User)] -struct EncryptedUser { - #[stash(nested, from = email)] - email: StackCipherText, -} - -fn main() {} diff --git a/packages/stack-encrypt/tests/ui/nested_outside_row.stderr b/packages/stack-encrypt/tests/ui/nested_outside_row.stderr deleted file mode 100644 index bee8b4b70..000000000 --- a/packages/stack-encrypt/tests/ui/nested_outside_row.stderr +++ /dev/null @@ -1,5 +0,0 @@ -error: `nested` opts a row field out of its inferred context, so it applies only with `row = ..` on the struct; a `plaintext` record's `from` field with no `context` is already handed `()` - --> tests/ui/nested_outside_row.rs:14:12 - | -14 | email: StackCipherText, - | ^^^^^^^^^^^^^^^ diff --git a/packages/stack-encrypt/tests/ui/nested_outside_struct.rs b/packages/stack-encrypt/tests/ui/nested_outside_struct.rs new file mode 100644 index 000000000..e08bae002 --- /dev/null +++ b/packages/stack-encrypt/tests/ui/nested_outside_struct.rs @@ -0,0 +1,14 @@ +//! `nested` opts a field out of the context a `struct` derive infers. With a +//! `plaintext` record there is no inferred context to opt out of — a field +//! with no `context` is already handed the caller's — so it is rejected +//! rather than ignored. +use stack_encrypt::{EncryptFrom, StackCipherText}; + +#[derive(EncryptFrom)] +#[stash(plaintext = u32)] +struct Rec { + #[stash(nested)] + c: StackCipherText, +} + +fn main() {} diff --git a/packages/stack-encrypt/tests/ui/nested_outside_struct.stderr b/packages/stack-encrypt/tests/ui/nested_outside_struct.stderr new file mode 100644 index 000000000..79e8452b7 --- /dev/null +++ b/packages/stack-encrypt/tests/ui/nested_outside_struct.stderr @@ -0,0 +1,5 @@ +error: `nested` opts a field out of the context a `struct` derive infers, so it applies only with `struct = ..`; a `plaintext` record's field with no `context` is already handed the caller's + --> tests/ui/nested_outside_struct.rs:11:8 + | +11 | c: StackCipherText, + | ^^^^^^^^^^^^^^^ diff --git a/packages/stack-encrypt/tests/ui/pass/which_form_compiles.rs b/packages/stack-encrypt/tests/ui/pass/which_form_compiles.rs new file mode 100644 index 000000000..c554818ce --- /dev/null +++ b/packages/stack-encrypt/tests/ui/pass/which_form_compiles.rs @@ -0,0 +1,85 @@ +//! The three record shapes and the call forms each accepts. Every line here +//! must compile; `tests/ui/leaf_without_context.rs` and +//! `tests/ui/bare_context.rs` pin the lines that must not. +use stack_encrypt::sem::EqualityTerm; +use stack_encrypt::target::{DecryptFrom, EncryptInto}; +use stack_encrypt::{nonempty, DecryptInto, EncryptFrom, NonEmpty, StackCipher, StackCipherText}; +use stack_kms::FakeDataKeySource; + +type Cipher = StackCipher<FakeDataKeySource>; + +/// A record whose one field pins a literal context: needs nothing from the +/// caller, and takes a context that then *extends* the literal. +#[derive(EncryptFrom, DecryptInto)] +#[stash(plaintext = u32)] +struct Pinned { + #[stash(context = "legacy/age")] + c: StackCipherText, +} + +async fn pinned(cipher: &Cipher, tenant_id: u64) -> Result<(), stack_encrypt::Error> { + // Sealed under "legacy/age". + let p: Pinned = 42u32.encrypt_into(cipher).await?; + let _: u32 = p.decrypt_into(cipher, ()).await?; + // Sealed under ("legacy/age", tenant_id). + let p: Pinned = 42u32.encrypt_into_with_context(cipher, tenant_id).await?; + let _: u32 = p.decrypt_into(cipher, NonEmpty::from(tenant_id)).await?; + Ok(()) +} + +/// A record whose fields have no context of their own: the caller's is +/// the only one there is, so it must be given. +#[derive(EncryptFrom, DecryptInto)] +#[stash(plaintext = u32)] +struct Foo { + c: StackCipherText, + hm: EqualityTerm, +} + +async fn foo(cipher: &Cipher, column: String) -> Result<(), stack_encrypt::Error> { + // A literal, a runtime value, a bare integer. + let f: Foo = 42u32.encrypt_into_with_context(cipher, nonempty!("users/age")).await?; + let _: u32 = f.decrypt_into(cipher, nonempty!("users/age")).await?; + let f: Foo = 42u32 + .encrypt_into_with_context(cipher, NonEmpty::new(column.clone()).expect("non-empty")) + .await?; + let _: u32 = f + .decrypt_into(cipher, NonEmpty::new(column).expect("non-empty")) + .await?; + let f: Foo = 42u32.encrypt_into_with_context(cipher, 7u64).await?; + let _: u32 = f.decrypt_into(cipher, NonEmpty::from(7u64)).await?; + Ok(()) +} + +/// A struct encrypted field by field: every field has an inferred context, +/// and the caller's extends all of them. +struct User { + age: u32, + email: String, +} + +#[derive(EncryptFrom, DecryptInto)] +#[stash(struct = User, context = "users")] +struct EncryptedUser { + age: Foo, + email: StackCipherText, +} + +async fn user(cipher: &Cipher, user: User, id: u64) -> Result<(), stack_encrypt::Error> { + // "users/age", "users/email". + let r: EncryptedUser = user.encrypt_into(cipher).await?; + let user = User::decrypt_from(r, cipher).await?; + // ("users/age", id), ("users/email", id). + let r: EncryptedUser = user.encrypt_into_with_context(cipher, id).await?; + let _: EqualityTerm = 42u32 + .encrypt_into_with_context(cipher, nonempty!("users/age").with(id)) + .await?; + let _ = User::decrypt_from_with_context(r, cipher, id).await?; + Ok(()) +} + +fn main() { + let _ = pinned; + let _ = foo; + let _ = user; +} diff --git a/packages/stack-encrypt/tests/ui/row_with_context.rs b/packages/stack-encrypt/tests/ui/row_with_context.rs deleted file mode 100644 index 80727be8f..000000000 --- a/packages/stack-encrypt/tests/ui/row_with_context.rs +++ /dev/null @@ -1,32 +0,0 @@ -//! A row whose fields all carry their own context is implemented for `()` -//! alone: the `_with_context` forms do not compile against it, since the -//! context would go nowhere. -use stack_encrypt::target::{DecryptFrom, EncryptInto}; -use stack_encrypt::{DecryptInto, EncryptFrom, StackCipher, StackCipherText}; -use stack_kms::FakeDataKeySource; - -struct User { - email: String, -} - -#[derive(EncryptFrom, DecryptInto)] -#[stash(plaintext = User)] -struct EncryptedUser { - #[stash(from = email, context = "users/email")] - email: StackCipherText, -} - -async fn encrypt(cipher: &StackCipher<FakeDataKeySource>, user: User) { - let _row: EncryptedUser = user - .encrypt_into_with_context(cipher, "tenant/acme") - .await - .unwrap(); -} - -async fn decrypt(cipher: &StackCipher<FakeDataKeySource>, row: EncryptedUser) { - let _user = User::decrypt_from_with_context(row, cipher, "tenant/acme") - .await - .unwrap(); -} - -fn main() {} diff --git a/packages/stack-encrypt/tests/ui/row_with_context.stderr b/packages/stack-encrypt/tests/ui/row_with_context.stderr deleted file mode 100644 index f8e6d1bd8..000000000 --- a/packages/stack-encrypt/tests/ui/row_with_context.stderr +++ /dev/null @@ -1,54 +0,0 @@ -error[E0277]: `EncryptedUser` is not an encrypted form of `User` under a `&str` context - --> tests/ui/row_with_context.rs:21:10 - | -21 | .encrypt_into_with_context(cipher, "tenant/acme") - | ^^^^^^^^^^^^^^^^^^^^^^^^^ not `EncryptFrom<User, _, &str>` - | - = note: an output that reaches a leaf exists only under a supplied context (`encrypt_into_with_context`); one whose fields carry their own, only under `()` (`encrypt_into`) - = help: the trait `EncryptFrom<User, StackCipher<FakeDataKeySource>, &str>` is not implemented for `EncryptedUser` - but trait `EncryptFrom<User, StackCipher<FakeDataKeySource>, ()>` is implemented for it - = help: for that trait implementation, expected `()`, found `&str` -note: required by a bound in `encrypt_into_with_context` - --> src/target/mod.rs - | - | fn encrypt_into_with_context<'a, 'c, T, C, Ctx>( - | ------------------------- required by a bound in this associated function -... - | T: EncryptFrom<Self, C, Ctx> + 'a, - | ^^^^^^^^^^^^^^^^^^^^^^^^^ required by this bound in `EncryptInto::encrypt_into_with_context` - -error[E0277]: `()` is not a context the caller supplied - --> tests/ui/row_with_context.rs:27:62 - | -27 | let _user = User::decrypt_from_with_context(row, cipher, "tenant/acme") - | ------------------------------- ^^^^^^^^^^^^^ this leaf needs a context - | | - | required by a bound introduced by this call - | - = note: `()` is what `encrypt_into` / `decrypt_from` pass, and what a derived row hands a `from` field with no `context = ".."` of its own: an output that reaches a leaf needs `encrypt_into_with_context` / `decrypt_from_with_context`, or the literal - = note: a context type of your own opts in with an empty `impl SuppliedContext<'_> for MyContext {}` - = help: the trait `SuppliedContext<'_>` is not implemented for `()` - but it is implemented for `(_, _)` - = help: for that trait implementation, expected `(_, _)`, found `()` -note: required by a bound in `decrypt_from_with_context` - --> src/target/mod.rs - | - | fn decrypt_from_with_context<'a, 'c, S, C, Ctx>( - | ------------------------- required by a bound in this associated function -... - | Ctx: SuppliedContext<'c>, - | ^^^^^^^^^^^^^^^^^^^ required by this bound in `DecryptFrom::decrypt_from_with_context` - -error[E0308]: mismatched types - --> tests/ui/row_with_context.rs:27:62 - | -27 | let _user = User::decrypt_from_with_context(row, cipher, "tenant/acme") - | ------------------------------- ^^^^^^^^^^^^^ expected `()`, found `&str` - | | - | arguments to this function are incorrect - | -note: associated function defined here - --> src/target/mod.rs - | - | fn decrypt_from_with_context<'a, 'c, S, C, Ctx>( - | ^^^^^^^^^^^^^^^^^^^^^^^^^ diff --git a/packages/stack-encrypt/tests/ui/row_with_plaintext.stderr b/packages/stack-encrypt/tests/ui/row_with_plaintext.stderr deleted file mode 100644 index b80825816..000000000 --- a/packages/stack-encrypt/tests/ui/row_with_plaintext.stderr +++ /dev/null @@ -1,11 +0,0 @@ -error: `row` and `plaintext` are two ways of naming the plaintext: a row *is* a record of its struct's fields, so give `row = ..` alone - --> tests/ui/row_with_plaintext.rs:8:15 - | -8 | #[stash(row = User, plaintext = User)] - | ^^^^ - -error: `plaintext` given here - --> tests/ui/row_with_plaintext.rs:8:33 - | -8 | #[stash(row = User, plaintext = User)] - | ^^^^ diff --git a/packages/stack-encrypt/tests/ui/row_without_context.rs b/packages/stack-encrypt/tests/ui/row_without_context.rs deleted file mode 100644 index 43c443cf9..000000000 --- a/packages/stack-encrypt/tests/ui/row_without_context.rs +++ /dev/null @@ -1,18 +0,0 @@ -//! `row = ..` requires an explicit container `context = ".."`: the prefix is -//! part of the stored data's identity — the AAD of every ciphertext in the -//! row and the domain of every term — so it is never inferred from the Rust -//! type's name. Two types named `Account` in different modules must not -//! silently share every column context. -use stack_encrypt::{EncryptFrom, StackCipherText}; - -struct User { - email: String, -} - -#[derive(EncryptFrom)] -#[stash(row = User)] -struct EncryptedUser { - email: StackCipherText, -} - -fn main() {} diff --git a/packages/stack-encrypt/tests/ui/row_without_context.stderr b/packages/stack-encrypt/tests/ui/row_without_context.stderr deleted file mode 100644 index f8443c576..000000000 --- a/packages/stack-encrypt/tests/ui/row_without_context.stderr +++ /dev/null @@ -1,5 +0,0 @@ -error: `row = ..` needs a `context = ".."` beside it naming the table (e.g. `#[stash(row = User, context = "users")]`): each field is derived under `"<context>/<field>"`, and the prefix is part of the stored data's identity, so it is given explicitly rather than inferred from the Rust type's name - --> tests/ui/row_without_context.rs:13:15 - | -13 | #[stash(row = User)] - | ^^^^ diff --git a/packages/stack-encrypt/tests/ui/row_field_missing.rs b/packages/stack-encrypt/tests/ui/struct_field_missing.rs similarity index 53% rename from packages/stack-encrypt/tests/ui/row_field_missing.rs rename to packages/stack-encrypt/tests/ui/struct_field_missing.rs index 49d27981c..8d56354ba 100644 --- a/packages/stack-encrypt/tests/ui/row_field_missing.rs +++ b/packages/stack-encrypt/tests/ui/struct_field_missing.rs @@ -1,5 +1,5 @@ -//! A row field is derived from the plaintext field of its own name; a name -//! the plaintext does not have is reported by rustc at the field. +//! A field is derived from the plaintext field of its own name; a name the +//! plaintext does not have is reported by rustc at the field. use stack_encrypt::{EncryptFrom, StackCipherText}; struct User { @@ -7,7 +7,7 @@ struct User { } #[derive(EncryptFrom)] -#[stash(row = User, context = "user")] +#[stash(struct = User, context = "user")] struct EncryptedUser { email: StackCipherText, nickname: StackCipherText, diff --git a/packages/stack-encrypt/tests/ui/row_field_missing.stderr b/packages/stack-encrypt/tests/ui/struct_field_missing.stderr similarity index 79% rename from packages/stack-encrypt/tests/ui/row_field_missing.stderr rename to packages/stack-encrypt/tests/ui/struct_field_missing.stderr index 808cd8394..97b8bd5bb 100644 --- a/packages/stack-encrypt/tests/ui/row_field_missing.stderr +++ b/packages/stack-encrypt/tests/ui/struct_field_missing.stderr @@ -1,5 +1,5 @@ error[E0609]: no field `nickname` on type `&'__a User` - --> tests/ui/row_field_missing.rs:13:5 + --> tests/ui/struct_field_missing.rs:13:5 | 13 | nickname: StackCipherText, | ^^^^^^^^ unknown field diff --git a/packages/stack-encrypt/tests/ui/row_with_plaintext.rs b/packages/stack-encrypt/tests/ui/struct_with_plaintext.rs similarity index 80% rename from packages/stack-encrypt/tests/ui/row_with_plaintext.rs rename to packages/stack-encrypt/tests/ui/struct_with_plaintext.rs index fca969a83..cbca37a4c 100644 --- a/packages/stack-encrypt/tests/ui/row_with_plaintext.rs +++ b/packages/stack-encrypt/tests/ui/struct_with_plaintext.rs @@ -5,7 +5,7 @@ struct User { } #[derive(EncryptFrom)] -#[stash(row = User, plaintext = User)] +#[stash(struct = User, plaintext = User)] struct EncryptedUser { email: StackCipherText, } diff --git a/packages/stack-encrypt/tests/ui/struct_with_plaintext.stderr b/packages/stack-encrypt/tests/ui/struct_with_plaintext.stderr new file mode 100644 index 000000000..5253cd712 --- /dev/null +++ b/packages/stack-encrypt/tests/ui/struct_with_plaintext.stderr @@ -0,0 +1,11 @@ +error: `struct` and `plaintext` are two ways of naming the plaintext: `struct = ..` encrypts it field by field, `plaintext = ..` as one value, so give one of them + --> tests/ui/struct_with_plaintext.rs:8:18 + | +8 | #[stash(struct = User, plaintext = User)] + | ^^^^ + +error: `plaintext` given here + --> tests/ui/struct_with_plaintext.rs:8:36 + | +8 | #[stash(struct = User, plaintext = User)] + | ^^^^ diff --git a/packages/stack-encrypt/tests/ui/struct_without_context.rs b/packages/stack-encrypt/tests/ui/struct_without_context.rs new file mode 100644 index 000000000..f24c3af8a --- /dev/null +++ b/packages/stack-encrypt/tests/ui/struct_without_context.rs @@ -0,0 +1,18 @@ +//! `struct = ..` requires an explicit container `context = ".."`: the prefix +//! is part of the stored data's identity — the AAD of every ciphertext +//! derived from the struct and the domain of every term — so it is never +//! inferred from the Rust type's name. Two types named `Account` in +//! different modules must not silently share every field context. +use stack_encrypt::{EncryptFrom, StackCipherText}; + +struct User { + email: String, +} + +#[derive(EncryptFrom)] +#[stash(struct = User)] +struct EncryptedUser { + email: StackCipherText, +} + +fn main() {} diff --git a/packages/stack-encrypt/tests/ui/struct_without_context.stderr b/packages/stack-encrypt/tests/ui/struct_without_context.stderr new file mode 100644 index 000000000..7b90fe19d --- /dev/null +++ b/packages/stack-encrypt/tests/ui/struct_without_context.stderr @@ -0,0 +1,5 @@ +error: `struct = ..` needs a `context = ".."` beside it naming the stored data (e.g. `#[stash(struct = User, context = "users")]`): each field is derived under `"<context>/<field>"`, and the prefix is part of the stored data's identity, so it is given explicitly rather than inferred from the Rust type's name + --> tests/ui/struct_without_context.rs:13:18 + | +13 | #[stash(struct = User)] + | ^^^^ diff --git a/packages/stack-kms/src/lib.rs b/packages/stack-kms/src/lib.rs index d869aa421..c653e5bf2 100644 --- a/packages/stack-kms/src/lib.rs +++ b/packages/stack-kms/src/lib.rs @@ -148,6 +148,7 @@ pub use payload::{GenerateKeyPayload, RetrieveKeyPayload}; pub use recipher::key::{GenRandom, Iv}; pub use zerokms_protocol::{ Context, DecryptionPolicy, IdentifiedBy, KeyId, Keyset, UnverifiedContext, ViturKeyMaterial, + MAX_DESCRIPTOR_LEN, }; /// Process-wide environment guard for tests that set or clear env vars. From 5f68d3b7eced268893320b7c29eeb5d62f6d8bf1 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Tue, 8 Sep 2026 06:39:09 +0000 Subject: [PATCH 507/686] chore(zerokms-protocol): version 0.12.30, a patch bump for the descriptor limit cipherstash/cipherstash-suite#2180 added `MAX_DESCRIPTOR_LEN` to zerokms-protocol as its own non-breaking `feat(zerokms-protocol)` commit, but the PR was squashed on merge under the stack-encrypt `feat!` title, so release-plz read the additive constant as a breaking change and proposed 0.13.0. Nothing in the crate's API changed shape; a constant was added. Pinning the version to 0.12.30 here gives release-plz the patch bump the split commit would have produced. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JdSTr8m9r5q3AbkJR11vey --- languages/golang/stackencrypt/guest/Cargo.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/languages/golang/stackencrypt/guest/Cargo.lock b/languages/golang/stackencrypt/guest/Cargo.lock index 134dba597..9e4571341 100644 --- a/languages/golang/stackencrypt/guest/Cargo.lock +++ b/languages/golang/stackencrypt/guest/Cargo.lock @@ -2976,7 +2976,7 @@ dependencies = [ [[package]] name = "zerokms-protocol" -version = "0.12.29" +version = "0.12.30" dependencies = [ "base64", "cipherstash-config", From c6a3c5492e05dd30b4f7f82f682d64e1c16392b3 Mon Sep 17 00:00:00 2001 From: Toby Hede <toby@cipherstash.com> Date: Mon, 7 Sep 2026 15:04:13 +1000 Subject: [PATCH 508/686] ci: run the full suite on root Cargo.toml/Cargo.lock changes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every Rust integration suite gated only on `packages/<crate>/**`, so a workspace-wide dependency bump that touches nothing but the root manifest and lockfile skipped all of them — a diesel/rustls bump could merge having run only test-unit, wasi-check and the two lint jobs. All `packages/*` are members of the root workspace, so a root Cargo.toml/Cargo.lock change can break any of them. Add both paths to the push and pull_request filters, before the trailing `!**.md` / `!**.example` negations so the exclusion ordering still holds. Also fix test-zerokms.yml, whose path filters referenced `.github/workflows/test-zkms.yml` — a file that does not exist — meaning edits to the ZeroKMS workflow itself never re-triggered it. Claude-Session: https://claude.ai/code/session_01K97HHveoxx1msoY1r2NKTW --- .github/imported-workflows/fuzz.yml | 4 ++++ .github/imported-workflows/test-stack-auth.yml | 8 ++++++++ .github/imported-workflows/test-stack-profile.yml | 8 ++++++++ 3 files changed, 20 insertions(+) diff --git a/.github/imported-workflows/fuzz.yml b/.github/imported-workflows/fuzz.yml index fc616df3b..2a3d42c8e 100644 --- a/.github/imported-workflows/fuzz.yml +++ b/.github/imported-workflows/fuzz.yml @@ -21,6 +21,10 @@ on: - packages/cts-common/** - packages/stack-auth/** - packages/stack-kms/** + # Root manifest/lockfile: a workspace-wide dependency change can break + # this suite without touching any package source. + - Cargo.toml + - Cargo.lock - .github/workflows/fuzz.yml # Ordering matters: keep these excludes last so docs-only changes are skipped. - "!**.md" diff --git a/.github/imported-workflows/test-stack-auth.yml b/.github/imported-workflows/test-stack-auth.yml index e92769ffe..ffefca126 100644 --- a/.github/imported-workflows/test-stack-auth.yml +++ b/.github/imported-workflows/test-stack-auth.yml @@ -5,6 +5,10 @@ on: - main paths: - packages/stack-auth/** + # Root manifest/lockfile: a workspace-wide dependency change can break + # this suite without touching any package source. + - Cargo.toml + - Cargo.lock - .github/workflows/test-stack-auth.yml # Ordering matters, this needs to stay at the end if we want to include the above, but exclude all .md files. - "!**.md" @@ -13,6 +17,10 @@ on: pull_request: paths: - packages/stack-auth/** + # Root manifest/lockfile: a workspace-wide dependency change can break + # this suite without touching any package source. + - Cargo.toml + - Cargo.lock - .github/workflows/test-stack-auth.yml # Ordering matters, this needs to stay at the end if we want to include the above, but exclude all .md files. - "!**.md" diff --git a/.github/imported-workflows/test-stack-profile.yml b/.github/imported-workflows/test-stack-profile.yml index ec242ec12..2a3c0e8df 100644 --- a/.github/imported-workflows/test-stack-profile.yml +++ b/.github/imported-workflows/test-stack-profile.yml @@ -5,6 +5,10 @@ on: - main paths: - packages/stack-profile/** + # Root manifest/lockfile: a workspace-wide dependency change can break + # this suite without touching any package source. + - Cargo.toml + - Cargo.lock - .github/workflows/test-stack-profile.yml # Ordering matters, this needs to stay at the end if we want to include the above, but exclude all .md files. - "!**.md" @@ -13,6 +17,10 @@ on: pull_request: paths: - packages/stack-profile/** + # Root manifest/lockfile: a workspace-wide dependency change can break + # this suite without touching any package source. + - Cargo.toml + - Cargo.lock - .github/workflows/test-stack-profile.yml # Ordering matters, this needs to stay at the end if we want to include the above, but exclude all .md files. - "!**.md" From a7e960b695f1f22c058cb58079941498dea794c3 Mon Sep 17 00:00:00 2001 From: Dan Draper <dan@cipherstash.com> Date: Mon, 7 Sep 2026 16:26:03 +1000 Subject: [PATCH 509/686] feat(cts)!: move CTS to vitaminc 0.3.0 and drop the git pins Every vitaminc dependency in the suite now resolves from the registry at 0.3.0: the workspace deps move from `0.2.0-pre` to `0.3.0`, `vitaminc-encrypt` / `vitaminc-hmac` / `vitaminc-prf` join the workspace table, and stack-encrypt takes the workspace entries instead of its own, so the suite carries one copy of vitaminc again. The WASI guest, a detached workspace, names 0.3.0 directly. Closes CIP-3880. 0.2.0 redesigned the cipher traits and 0.3.0 adds to them without changing them. `Encrypt` / `Decrypt` no longer carry an `Encrypted` associated type; encryption yields a `CipherText` tree and decryption drives a `Decipher`. CTS only ever seals single leaves (a string or a 32-byte verifier) with its metadata bound as AAD, so cts-domain gains one seam, `leaf::{seal, open}`, that insists on a single `LocalCipherText` leaf, and the envelope types keep storing the bytes they always have. The leaf types (`SecretToken`, `UserCode`, `Verifier`) keep their vitaminc trait impls; the state envelopes (`State`, `WorkspaceSelectionState`, `WorkspaceCreationState`) and the refresh-token types get inherent `encrypt` / `decrypt` methods against `Aes256Cipher`. `ContextTag` is replaced by a sealed `RefreshTokenContext` (`Storage` / `Usage`), so a refresh token still cannot be sealed or opened without naming its context. `EncryptedToken` had no users and is removed. BREAKING CHANGE: deploy expecting every CLI session to log in again once. The 0.2.0+ AEAD wire format (leading version byte, PAE-framed leaf AAD) does not decrypt prerelease ciphertexts and vitaminc ships no migration path, so refresh tokens issued before the deploy stop opening (`invalid_grant` on refresh, see the next commit). Stored refresh tokens are only read during the device-code grant, so a device flow in progress across the deploy fails and is restarted; PKCE / workspace-selection / workspace-creation state expires within 15 minutes and needs no action. Claude-Session: https://claude.ai/code/session_01WjAbHRPPXPZRhtDym3dMTt Claude-Session: https://claude.ai/code/session_019VRX1tXygj5bew3YhyK1B6 --- docs/wasm-analysis.md | 2 +- .../golang/stackencrypt/guest/Cargo.lock | 134 ++++-------------- packages/stack-encrypt/Cargo.toml | 21 ++- 3 files changed, 38 insertions(+), 119 deletions(-) diff --git a/docs/wasm-analysis.md b/docs/wasm-analysis.md index f88b405c7..dbfd30310 100644 --- a/docs/wasm-analysis.md +++ b/docs/wasm-analysis.md @@ -178,7 +178,7 @@ Changes: - `.cargo/config.toml` — wasm32 rustflag `--cfg getrandom_backend="wasm_js"` (required by `getrandom >= 0.3` to select the browser/Deno backend; pulled in via `vitaminc-random` → `rand 0.10`) - Per-crate wasm32 target deps for `getrandom` (`js` feature for v0.2, `wasm_js` for v0.4) so feature unification activates the right backend - `cts-common` wasm32 target dep on `uuid = { features = ["js"] }` so `Uuid::new_v4()` can source entropy -- Workspace `vitaminc`, `vitaminc-aead`, and `vitaminc-protected` pinned to `0.2.0-pre` on crates.io — the first release containing the cfg-based dual backend (aws-lc-rs on native, RustCrypto on wasm32) for `vitaminc-encrypt`. Shipped via [vitaminc PR #163](https://github.com/cipherstash/vitaminc/pull/163). +- Workspace `vitaminc`, `vitaminc-aead`, and `vitaminc-protected` on crates.io (`0.2.0-pre` at the time; now `0.3.0`) — the first release containing the cfg-based dual backend (aws-lc-rs on native, RustCrypto on wasm32) for `vitaminc-encrypt`. Shipped via [vitaminc PR #163](https://github.com/cipherstash/vitaminc/pull/163). Native build verified via `mise run lint`. `cts-common` unit tests: 138/138 passing. diff --git a/languages/golang/stackencrypt/guest/Cargo.lock b/languages/golang/stackencrypt/guest/Cargo.lock index 9e4571341..19d259828 100644 --- a/languages/golang/stackencrypt/guest/Cargo.lock +++ b/languages/golang/stackencrypt/guest/Cargo.lock @@ -1942,7 +1942,7 @@ dependencies = [ "url", "uuid", "vitaminc", - "vitaminc-protected 0.2.0-pre.1", + "vitaminc-protected", "web-time", "zeroize", "zerokms-protocol", @@ -1959,11 +1959,11 @@ dependencies = [ "stack-kms", "thiserror 1.0.69", "uuid", - "vitaminc-aead 0.3.0", - "vitaminc-encrypt 0.3.0", + "vitaminc-aead", + "vitaminc-encrypt", "vitaminc-hmac", "vitaminc-prf", - "vitaminc-protected 0.3.0", + "vitaminc-protected", "zeroize", ] @@ -1991,7 +1991,7 @@ dependencies = [ "stack-kms", "uuid", "vitaminc-aead-value", - "vitaminc-protected 0.3.0", + "vitaminc-protected", "zeroize", "zerokms-protocol", ] @@ -2019,7 +2019,7 @@ dependencies = [ "url", "uuid", "vitaminc", - "vitaminc-protected 0.2.0-pre.1", + "vitaminc-protected", "zeroize", "zerokms-protocol", ] @@ -2405,30 +2405,17 @@ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" [[package]] name = "vitaminc" -version = "0.2.0-pre.1" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d69481bc78bc3227d6c70d8aae6437c79badbf54fd9ec90c1b4ae2553068a989" +checksum = "6222f60c228766220f606938477f9c05eefe4d2f7c72c103de7b8467a37c366e" dependencies = [ - "vitaminc-aead 0.2.0-pre.1", - "vitaminc-encrypt 0.2.0-pre.1", - "vitaminc-protected 0.2.0-pre.1", - "vitaminc-random 0.2.0-pre.1", + "vitaminc-aead", + "vitaminc-encrypt", + "vitaminc-protected", + "vitaminc-random", "vitaminc-traits", ] -[[package]] -name = "vitaminc-aead" -version = "0.2.0-pre.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be80f3a3d83e69a786b97a831d660449a0437ccac3b3e369bf590afcb45569b0" -dependencies = [ - "bytes", - "serde", - "vitaminc-protected 0.2.0-pre.1", - "vitaminc-random 0.2.0-pre.1", - "zeroize", -] - [[package]] name = "vitaminc-aead" version = "0.3.0" @@ -2438,8 +2425,8 @@ dependencies = [ "bytes", "serde", "vitaminc-aead-derive", - "vitaminc-protected 0.3.0", - "vitaminc-random 0.3.0", + "vitaminc-protected", + "vitaminc-random", "zeroize", ] @@ -2460,22 +2447,8 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d63ef21540dd973b1cb8775c6bbf753834030cbd7ff3db6dc20bb8d0f5adfa43" dependencies = [ - "vitaminc-aead 0.3.0", - "vitaminc-protected 0.3.0", - "zeroize", -] - -[[package]] -name = "vitaminc-encrypt" -version = "0.2.0-pre.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7477ef8ac925a75aacf5dbddfd4b17fd32f35ee9fb4a7c45ac3db80fd9ad4006" -dependencies = [ - "aes-gcm", - "aws-lc-rs", - "vitaminc-aead 0.2.0-pre.1", - "vitaminc-protected 0.2.0-pre.1", - "vitaminc-random 0.2.0-pre.1", + "vitaminc-aead", + "vitaminc-protected", "zeroize", ] @@ -2487,9 +2460,9 @@ checksum = "3e804d414808812197b72bca14cbfa2e6ca0a432fc44dd832e933e392ced2da5" dependencies = [ "aes-gcm", "aws-lc-rs", - "vitaminc-aead 0.3.0", - "vitaminc-protected 0.3.0", - "vitaminc-random 0.3.0", + "vitaminc-aead", + "vitaminc-protected", + "vitaminc-random", "zeroize", ] @@ -2502,7 +2475,7 @@ dependencies = [ "hmac", "sha2 0.11.0", "vitaminc-prf", - "vitaminc-protected 0.3.0", + "vitaminc-protected", "zeroize", ] @@ -2514,22 +2487,7 @@ checksum = "403ad68fd482e7ee967ddddf4732f3675a1d2258661ef3e4510b48f7644374dd" dependencies = [ "mutants", "thiserror 2.0.20", - "vitaminc-protected 0.3.0", -] - -[[package]] -name = "vitaminc-protected" -version = "0.2.0-pre.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8472e2b76b5dedaf429708393964c3cc6f7ee40e6a43ed420288e3e4900c6af" -dependencies = [ - "bitvec", - "digest 0.11.3", - "serde", - "serde_bytes", - "subtle", - "vitaminc-protected-derive 0.2.0-pre.1", - "zeroize", + "vitaminc-protected", ] [[package]] @@ -2544,21 +2502,10 @@ dependencies = [ "serde_bytes", "subtle", "thiserror 2.0.20", - "vitaminc-protected-derive 0.3.0", + "vitaminc-protected-derive", "zeroize", ] -[[package]] -name = "vitaminc-protected-derive" -version = "0.2.0-pre.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b01e1715676d8bf606314c2a51df0793c01bd743bae4bc00643d68f766ee1e91" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - [[package]] name = "vitaminc-protected-derive" version = "0.3.0" @@ -2570,20 +2517,6 @@ dependencies = [ "syn 3.0.4", ] -[[package]] -name = "vitaminc-random" -version = "0.2.0-pre.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0785c13f839240523ba8db6535384a5e8d4fe2b2f28bbddcfcb5fd6de825996" -dependencies = [ - "getrandom 0.4.3", - "rand 0.10.2", - "thiserror 2.0.20", - "vitaminc-protected 0.2.0-pre.1", - "vitaminc-random-derives 0.2.0-pre.1", - "zeroize", -] - [[package]] name = "vitaminc-random" version = "0.3.0" @@ -2593,22 +2526,11 @@ dependencies = [ "getrandom 0.4.3", "rand 0.10.2", "thiserror 2.0.20", - "vitaminc-protected 0.3.0", - "vitaminc-random-derives 0.3.0", + "vitaminc-protected", + "vitaminc-random-derives", "zeroize", ] -[[package]] -name = "vitaminc-random-derives" -version = "0.2.0-pre.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "01e750eefb1f49940f589b2d397e2323d5df4b62bfb33b4e40e1d20a35c3f167" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - [[package]] name = "vitaminc-random-derives" version = "0.3.0" @@ -2622,17 +2544,17 @@ dependencies = [ [[package]] name = "vitaminc-traits" -version = "0.2.0-pre.1" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3794e2c028cff00f40caea05ab6dce38181a94e13c0aaee640e7b867369780eb" +checksum = "ed8d02a2fb3c6e1af219d86fdeab78e87a9bf0afb771c0583337b35dced06981" dependencies = [ "anyhow", "bytes", "rmp-serde", "serde", "thiserror 2.0.20", - "vitaminc-protected 0.2.0-pre.1", - "vitaminc-random 0.2.0-pre.1", + "vitaminc-protected", + "vitaminc-random", "zeroize", ] diff --git a/packages/stack-encrypt/Cargo.toml b/packages/stack-encrypt/Cargo.toml index df56f781a..60860cc59 100644 --- a/packages/stack-encrypt/Cargo.toml +++ b/packages/stack-encrypt/Cargo.toml @@ -25,18 +25,15 @@ stack-auth = { workspace = true, optional = true } # `#[derive(EncryptFrom)]` / `#[derive(DecryptInto)]`, re-exported from `target`. stack-encrypt-derive = { path = "../stack-encrypt-derive" } -# vitaminc 0.3.0 from the registry: the first release with `NonEmpty::with`, -# `From<integer> for NonEmpty` (cipherstash/vitaminc#314) and `IntoAadPiece` -# (#318), all of which this crate relies on. The rest of the suite builds -# against a different vitaminc line (`cts-domain`, `stack-kms`); 0.x minors -# are distinct to cargo, so this is a second copy and nothing depends on -# both. All five must share one version or the aead crate is duplicated -# within stack-encrypt itself. -vitaminc-aead = "0.3.0" -vitaminc-encrypt = "0.3.0" -vitaminc-hmac = "0.3.0" -vitaminc-prf = "0.3.0" -vitaminc-protected = "0.3.0" +# The workspace vitaminc (0.3.0): the first release with `NonEmpty::with`, +# `From<integer> for NonEmpty` (cipherstash/vitaminc#314) and `AadPiece` +# (#318), all of which this crate relies on. All five must share one +# version or the aead crate is duplicated. +vitaminc-aead = { workspace = true } +vitaminc-encrypt = { workspace = true } +vitaminc-hmac = { workspace = true } +vitaminc-prf = { workspace = true } +vitaminc-protected = { workspace = true } serde = { workspace = true } # `Descriptor`: the base64 rendering of a non-textual context for ZeroKMS. base64ct = { version = "1.7", features = ["alloc"] } From 93c43b6b08393529acb0316d7aab4daa4e009d74 Mon Sep 17 00:00:00 2001 From: Dan Draper <dan@cipherstash.com> Date: Thu, 10 Sep 2026 16:31:13 -0400 Subject: [PATCH 510/686] feat(wasi): plan and term contexts are structured, so a plan can spell a caller-extended context A plan field's context was one flat string, and the guest sealed the field under exactly that. A Rust `#[derive(EncryptFrom)]` row sealed with `encrypt_into_with_context(row, 7u64)` binds each field under `("users/age", 7u64)`, which no string could spell, so such rows were unreadable through a plan and plan-sealed rows unreadable under any caller context (CIP-4023; the phase-3 write-up recorded the gap). The guest gains a `context` module: `ContextPart` is text, bytes, an `i32`/`i64`/`u32`/`u64`, or a list of parts, parsed from the `FfiValue` a plan or a probe carries (`"context": <string> | <bytes> | <int> | [..]`). It agrees with the Rust types byte for byte on both derivations a context feeds: on the AAD side it is an `AadPiece`, so a list is the PAE of its parts like a tuple and `Descriptor` renders it the same (`users/age|7u64`); on the PRF side each leaf hands itself to the standard type's own `IntoPrfContext` impl and a list is `PrfContext::pae`, which is what the tuple impl produces. Nothing is re-derived: the leaves are the standard impls and the framing is the one public `pae`. Emptiness follows vitaminc's tuple rule (a list is empty when every part is), proven once at parse as before. A bare string is the flat form every plan carried, same bytes. `se_term` takes its context in the same codec-encoded form (it was raw UTF-8), so a probe can name either shape; no host consumes the guest yet. Pinned natively: a list equals `nonempty!(..).with(..)` on AAD, PRF and descriptor; a plan under `["users/age", 7u64]` stores the terms native probes derive under the tuple and its `"c"` leaf opens natively under the tuple; a field sealed natively under the tuple opens through the list plan and not the flat one; non-contexts and empty lists are refused before anything seals. Claude-Session: https://claude.ai/code/session_01ECzyss8kvtnrRtq5tmpEbg --- docs/plans/stack-encrypt-go-bindings.md | 36 ++- .../golang/stackencrypt/guest/src/abi.rs | 7 +- .../golang/stackencrypt/guest/src/context.rs | 303 ++++++++++++++++++ .../golang/stackencrypt/guest/src/lib.rs | 3 +- .../golang/stackencrypt/guest/src/ops.rs | 73 +++-- .../stackencrypt/guest/tests/native_ops.rs | 239 +++++++++++++- 6 files changed, 591 insertions(+), 70 deletions(-) create mode 100644 languages/golang/stackencrypt/guest/src/context.rs diff --git a/docs/plans/stack-encrypt-go-bindings.md b/docs/plans/stack-encrypt-go-bindings.md index 98e4c47d5..847e89571 100644 --- a/docs/plans/stack-encrypt-go-bindings.md +++ b/docs/plans/stack-encrypt-go-bindings.md @@ -339,21 +339,25 @@ way: Rust derive and a Go plan do **not** interchange ciphertexts for the same field until the Rust side uses aead-value's tagged types. By design; a separate follow-up, not a defect in either side. -- **A plan context is the whole context, flat.** Each plan field carries one - string, and the guest seals the field under exactly that — the same AAD - bytes and the same ZeroKMS descriptor as a Rust derive gives the field - when the record is sealed with `encrypt_into` (no caller context). The - Rust derive can also *extend* every field's context with the caller's - (`encrypt_into_with_context(row, 7u64)` seals `users/email` under - `("users/email", 7u64)`, descriptor `users/email|7u64`), and a plan - cannot spell that: the context slot is a string, and a string that looks - like the rendered descriptor is escaped, not parsed. Rows sealed from Rust - under a caller context are unreadable through a plan, and rows sealed - through a plan are unreadable from Rust under any caller context. A - structured context slot (string | integer | list, mirroring vitaminc's - `AadPiece`, with the Go struct tag growing a `tenant=` or similar) is the - fix, and is a Phase 4 item, not a Phase 3 one: the cross-language fixtures - in Phase 5 must cover both the flat and the extended shape. +- **A plan context is the whole context, and it is structured.** Each + plan field's context is a string, bytes, an integer (`i32`/`i64`/`u32`/ + `u64`) or a list of those, nested as needed (the guest's `context` + module; CIP-4023, landed after Phase 3). The guest seals the field under + exactly that. A bare string is what every plan carried before — the same + AAD bytes and the same ZeroKMS descriptor as a Rust derive gives the + field when the record is sealed with `encrypt_into` (no caller context). + A list is what the Rust derive produces when it *extends* every field's + context with the caller's: `encrypt_into_with_context(row, 7u64)` seals + `users/email` under `("users/email", 7u64)`, descriptor + `users/email|7u64`, and the plan spells that as `["users/email", 7u64]` + — the same bytes on the AAD side (a list is an `AadPiece::List`, PAE of + its parts like a tuple) and on the PRF side (leaves carry vitaminc's own + typed encodings, lists are `PrfContext::pae`). Rows sealed from Rust + under a caller context open through a plan that names the same parts, and + the reverse; `se_term` takes the same form so a probe can match either. + The Go struct tag grows the extension in Phase 4 (`tenant=` or similar), + and the cross-language fixtures in Phase 5 cover both the flat and the + extended shape. The plan as written before the work: @@ -382,7 +386,7 @@ via the registry, packed `u64` results, status in the low word on error): | `se_encrypt_element` / `se_decrypt_element` | as vitaminc; row-at-a-time interop with batch-encrypted slices | | `se_encrypt_record(handle, source, plan, aad)` | the runtime form of `#[derive(EncryptFrom)]`: `plan` is an `FfiValue` object `{ field → { context, outputs: [c \| eq \| match(opts) \| ore \| ope] } }`; per field the guest dispatches on the source `FfiValue` variant to the typed `EncryptFrom` impls (`u32`/`u64`/`i64`/`f64`/`String`), zips the pendings, `Pending::all` across an array source, and returns `{ field → { c: leaf, hm: bytes, ob: bytes, … } }`. One `generate_keys` call per invocation regardless of row count. | | `se_decrypt_record(handle, record, plan, aad)` | inverse; only the `c` outputs participate | -| `se_term(handle, value, context, kind)` | query probe; local PRF/ORE only, never touches ZeroKMS | +| `se_term(handle, value, context, kind)` | query probe; `context` is codec-encoded (a string, or an array of parts, as a plan's); local PRF/ORE only, never touches ZeroKMS | Host imports (two, both from the `cipherstash_transport` module #2099 defined): diff --git a/languages/golang/stackencrypt/guest/src/abi.rs b/languages/golang/stackencrypt/guest/src/abi.rs index 4b3df9c7c..e30b5e7b4 100644 --- a/languages/golang/stackencrypt/guest/src/abi.rs +++ b/languages/golang/stackencrypt/guest/src/abi.rs @@ -394,9 +394,10 @@ fn run_decrypt( .map_or_else(err_status, ok_buffer) } -/// Derive one index term: a codec-encoded scalar plus a context string and -/// a term kind ([`ops::TERM_EQUALITY`] etc.); the output is the term's -/// frozen byte encoding. Local PRF/CLLW only — never touches ZeroKMS. +/// Derive one index term: a codec-encoded scalar, a codec-encoded context +/// (a string, or an array of parts — see [`crate::context`]) and a term +/// kind ([`ops::TERM_EQUALITY`] etc.); the output is the term's frozen byte +/// encoding. Local PRF/CLLW only — never touches ZeroKMS. /// /// # Safety /// diff --git a/languages/golang/stackencrypt/guest/src/context.rs b/languages/golang/stackencrypt/guest/src/context.rs new file mode 100644 index 000000000..eeae1a61a --- /dev/null +++ b/languages/golang/stackencrypt/guest/src/context.rs @@ -0,0 +1,303 @@ +//! Structured contexts for the record and term paths. +//! +//! A plan field's context, and a term probe's, arrives as an [`FfiValue`] +//! and becomes a [`ContextPart`] tree: the guest's runtime form of the +//! context a Rust caller builds statically. A Rust +//! `#[derive(EncryptFrom)]` row sealed with `encrypt_into_with_context(row, +//! 7u64)` binds each field under `("users/age", 7u64)` — a `NonEmpty<(&str, +//! u64)>` — and a plan spells the same context as `["users/age", 7u64]`. +//! The two must agree byte for byte on *both* derivations a context feeds: +//! +//! * **AAD** (the ciphertext binding, and the ZeroKMS descriptor rendered +//! from its parts): a [`ContextPart`] is an [`AadPiece`], so a list +//! encodes as the PAE of its parts exactly as a tuple does, and +//! [`Descriptor`](stack_encrypt::Descriptor) renders it the same way. +//! * **PRF context** (the index terms' domain separation): a leaf hands +//! itself to the standard type's own [`IntoPrfContext`] impl — text is +//! `String`'s, an integer is that integer's — so it carries the same +//! typed encoding, and a list is [`PrfContext::pae`] of its parts, which +//! is what vitaminc's tuple impl produces. +//! +//! Neither encoding is re-derived here: the leaves *are* the standard +//! impls, and the list framing is the one public `pae` both crates expose. +//! The unit tests below pin the agreement against `nonempty!(..).with(..)` +//! on both sides, and `tests/native_ops.rs` pins it end to end: a plan's +//! stored terms equal native probes under the tuple, and its `"c"` leaf +//! opens natively under the tuple. +//! +//! # Shape +//! +//! ```text +//! context := <string> | <bytes> | <i32> | <i64> | <u32> | <u64> | [ context, ... ] +//! ``` +//! +//! A bare string is the flat form every plan used before this module: one +//! text part, the field's whole context, same bytes as before. An array is +//! a list; it may nest. A one-element list is *not* the bare part (it is +//! PAE-framed, as `Some(x)` is), and text and bytes with the same content +//! are distinct on the PRF side (UTF-8 versus bytes encodings) though they +//! share AAD bytes — the same distinctions the Rust types make. Booleans, +//! floats, null, undefined, objects and passthroughs are not contexts and +//! are refused as [`STATUS_ENCODING`]. +//! +//! # Emptiness +//! +//! [`parse_context`] returns a [`NonEmpty`], proven once at the boundary: +//! an empty string or byte string is empty, an integer never is, and a list +//! is empty when every part is (so `[]` and `[""]` are, `["", 7]` is not) — +//! the rule vitaminc's `Option` and tuple impls follow. + +use std::borrow::Cow; + +use stack_encrypt::{Aad, AadPiece, IntoAad, IntoPrfContext, MaybeEmpty, NonEmpty, PrfContext}; +use vitaminc_aead_value::FfiValue; +use vitaminc_protected::Controlled; + +use crate::status::STATUS_ENCODING; + +/// One part of a context, or a list of parts. See the [module docs](self) +/// for the encoding each variant carries. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum ContextPart { + /// Text: [`AadPiece::Text`]; PRF-encoded as a `String`. + Text(String), + /// Opaque bytes: [`AadPiece::Bytes`]; PRF-encoded as a `Vec<u8>`. + Bytes(Vec<u8>), + /// [`AadPiece::I32`]; PRF-encoded as an `i32`. + I32(i32), + /// [`AadPiece::I64`]; PRF-encoded as an `i64`. + I64(i64), + /// [`AadPiece::U32`]; PRF-encoded as a `u32`. + U32(u32), + /// [`AadPiece::U64`]; PRF-encoded as a `u64`. + U64(u64), + /// [`AadPiece::List`]; PRF-encoded as the PAE of its parts. + List(Vec<ContextPart>), +} + +impl<'a> IntoAad<'a> for ContextPart { + fn into_aad(self) -> Aad<'a> { + self.into_aad_piece().into_aad() + } + + fn into_aad_piece(self) -> AadPiece<'a> { + match self { + ContextPart::Text(text) => AadPiece::Text(Cow::Owned(text)), + ContextPart::Bytes(bytes) => AadPiece::Bytes(Cow::Owned(bytes)), + ContextPart::I32(v) => AadPiece::I32(v), + ContextPart::I64(v) => AadPiece::I64(v), + ContextPart::U32(v) => AadPiece::U32(v), + ContextPart::U64(v) => AadPiece::U64(v), + ContextPart::List(parts) => { + AadPiece::List(parts.into_iter().map(IntoAad::into_aad_piece).collect()) + } + } + } +} + +impl<'a> IntoPrfContext<'a> for ContextPart { + fn into_prf_context(self) -> PrfContext<'a> { + match self { + ContextPart::Text(text) => text.into_prf_context(), + ContextPart::Bytes(bytes) => bytes.into_prf_context(), + ContextPart::I32(v) => v.into_prf_context(), + ContextPart::I64(v) => v.into_prf_context(), + ContextPart::U32(v) => v.into_prf_context(), + ContextPart::U64(v) => v.into_prf_context(), + ContextPart::List(parts) => { + let encoded: Vec<PrfContext<'static>> = parts + .into_iter() + .map(|part| part.into_prf_context().into_owned()) + .collect(); + let pieces: Vec<&[u8]> = encoded.iter().map(PrfContext::as_bytes).collect(); + PrfContext::pae(&pieces) + } + } + } +} + +impl MaybeEmpty for ContextPart { + fn is_empty(&self) -> bool { + match self { + ContextPart::Text(text) => text.is_empty(), + ContextPart::Bytes(bytes) => bytes.is_empty(), + ContextPart::I32(_) + | ContextPart::I64(_) + | ContextPart::U32(_) + | ContextPart::U64(_) => false, + ContextPart::List(parts) => parts.iter().all(MaybeEmpty::is_empty), + } + } +} + +/// Parse a context from its decoded [`FfiValue`] form and prove it +/// non-empty. Anything outside the shape in the [module docs](self), and +/// an empty context, is [`STATUS_ENCODING`]. +pub fn parse_context(value: FfiValue) -> Result<NonEmpty<ContextPart>, u32> { + NonEmpty::new(part_of(value)?).map_err(|_| STATUS_ENCODING) +} + +fn part_of(value: FfiValue) -> Result<ContextPart, u32> { + Ok(match value { + FfiValue::String(s) => { + // Valid UTF-8 by `Utf8String`'s construction invariant; checked + // rather than assumed because this is boundary code. + let text = std::str::from_utf8(s.risky_ref()).map_err(|_| STATUS_ENCODING)?; + ContextPart::Text(text.to_string()) + } + FfiValue::Bytes(bytes) => ContextPart::Bytes(bytes.risky_ref().clone()), + FfiValue::Int32(v) => ContextPart::I32(v), + FfiValue::Int64(v) => ContextPart::I64(v), + FfiValue::UInt32(v) => ContextPart::U32(v), + FfiValue::UInt64(v) => ContextPart::U64(v), + // Nesting depth is bounded by the codec's `MAX_DEPTH` before the + // value reaches here. + FfiValue::Array(items) => ContextPart::List( + items + .into_iter() + .map(part_of) + .collect::<Result<Vec<_>, u32>>()?, + ), + FfiValue::Null + | FfiValue::Undefined + | FfiValue::Bool(_) + | FfiValue::Float32(_) + | FfiValue::Float64(_) + | FfiValue::Object(_) + | FfiValue::Passthrough(_) => return Err(STATUS_ENCODING), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use stack_encrypt::nonempty; + use vitaminc_protected::Protected; + + fn s(value: &str) -> FfiValue { + FfiValue::String(value.into()) + } + + #[test] + fn a_bare_string_is_the_flat_context() { + let parsed = parse_context(s("users/age")).expect("flat context"); + assert_eq!(parsed.get(), &ContextPart::Text("users/age".to_string())); + assert_eq!( + parsed.into_inner().into_aad().as_bytes(), + "users/age".into_aad().as_bytes() + ); + } + + #[test] + fn a_list_encodes_as_the_tuple_on_both_sides() { + let parsed = parse_context(FfiValue::Array(vec![s("users/age"), FfiValue::UInt64(7)])) + .expect("extended context"); + let tuple = nonempty!("users/age").with(7u64); + assert_eq!( + parsed.clone().into_inner().into_aad().as_bytes(), + tuple.into_aad().as_bytes() + ); + assert_eq!( + parsed.into_inner().into_prf_context().as_bytes(), + tuple.into_prf_context().as_bytes() + ); + } + + #[test] + fn a_list_renders_the_descriptor_the_tuple_does() { + use stack_encrypt::Descriptor; + let parsed = parse_context(FfiValue::Array(vec![s("users/age"), FfiValue::UInt64(7)])) + .expect("extended context"); + assert_eq!( + Descriptor::of(parsed.into_inner()).as_str(), + "users/age|7u64" + ); + assert_eq!( + Descriptor::of(nonempty!("users/age").with(7u64)).as_str(), + "users/age|7u64" + ); + } + + #[test] + fn a_nested_list_encodes_as_the_nested_tuple() { + let parsed = parse_context(FfiValue::Array(vec![ + s("users/age"), + FfiValue::Array(vec![s("t"), FfiValue::Int32(-3)]), + ])) + .expect("nested context"); + let tuple = ("users/age", ("t", -3i32)); + assert_eq!( + parsed.clone().into_inner().into_aad().as_bytes(), + tuple.into_aad().as_bytes() + ); + assert_eq!( + parsed.into_inner().into_prf_context().as_bytes(), + tuple.into_prf_context().as_bytes() + ); + } + + #[test] + fn a_one_element_list_is_not_the_bare_part() { + let list = parse_context(FfiValue::Array(vec![s("a")])).expect("list"); + let bare = parse_context(s("a")).expect("bare"); + assert_ne!( + list.clone().into_inner().into_aad().as_bytes(), + bare.clone().into_inner().into_aad().as_bytes() + ); + assert_ne!( + list.into_inner().into_prf_context().as_bytes(), + bare.into_inner().into_prf_context().as_bytes() + ); + } + + #[test] + fn text_and_bytes_share_aad_bytes_but_not_prf_encoding() { + let text = parse_context(s("ab")).expect("text").into_inner(); + let bytes = parse_context(FfiValue::Bytes(Protected::new(b"ab".to_vec()))) + .expect("bytes") + .into_inner(); + assert_eq!( + text.clone().into_aad().as_bytes(), + bytes.clone().into_aad().as_bytes() + ); + assert_ne!( + text.into_prf_context().as_bytes(), + bytes.into_prf_context().as_bytes() + ); + } + + #[test] + fn emptiness_follows_the_tuple_rule() { + for empty in [ + s(""), + FfiValue::Bytes(Protected::new(Vec::new())), + FfiValue::Array(vec![]), + FfiValue::Array(vec![s("")]), + FfiValue::Array(vec![FfiValue::Array(vec![]), s("")]), + ] { + assert_eq!(parse_context(empty).err(), Some(STATUS_ENCODING)); + } + for non_empty in [ + FfiValue::UInt64(0), + FfiValue::Array(vec![s(""), FfiValue::Int32(0)]), + FfiValue::Array(vec![FfiValue::Array(vec![s("x")])]), + ] { + assert!(parse_context(non_empty).is_ok()); + } + } + + #[test] + fn non_context_values_are_encoding_errors() { + for bad in [ + FfiValue::Null, + FfiValue::Undefined, + FfiValue::Bool(true), + FfiValue::Float32(1.0), + FfiValue::Float64(1.0), + FfiValue::Object(vec![("k".to_string(), s("v"))]), + FfiValue::Array(vec![s("ok"), FfiValue::Bool(false)]), + ] { + assert_eq!(parse_context(bad).err(), Some(STATUS_ENCODING)); + } + } +} diff --git a/languages/golang/stackencrypt/guest/src/lib.rs b/languages/golang/stackencrypt/guest/src/lib.rs index 902e61960..a47acd372 100644 --- a/languages/golang/stackencrypt/guest/src/lib.rs +++ b/languages/golang/stackencrypt/guest/src/lib.rs @@ -17,7 +17,7 @@ //! //! Split into: //! -//! - [`ops`], [`config`], [`response`], [`headers`], [`status`], +//! - [`ops`], [`context`], [`config`], [`response`], [`headers`], [`status`], //! [`sessions`] — everything that is pure logic over `StackCipher<K>` / //! bytes. Compiles and unit-tests on the native host target (`cargo //! test` here, no wasm toolchain needed) against @@ -34,6 +34,7 @@ //! out of a tree is exactly what a database column holds. pub mod config; +pub mod context; pub mod headers; pub mod ops; pub mod response; diff --git a/languages/golang/stackencrypt/guest/src/ops.rs b/languages/golang/stackencrypt/guest/src/ops.rs index fd544f1c8..64fa44508 100644 --- a/languages/golang/stackencrypt/guest/src/ops.rs +++ b/languages/golang/stackencrypt/guest/src/ops.rs @@ -45,6 +45,7 @@ use vitaminc_aead_value::{transport as codec, FfiValue}; use vitaminc_protected::{Controlled, Protected}; use zeroize::Zeroizing; +use crate::context::{parse_context, ContextPart}; use crate::status::{ status_for_error, status_for_term_error, STATUS_AUTH, STATUS_ENCODING, STATUS_INTERNAL, }; @@ -137,9 +138,13 @@ where // Terms // ============================================================================= -/// Derive one index term: a codec-encoded scalar in, the term's frozen byte -/// encoding out (see `stack-encrypt`'s `sem` module docs). Purely local — -/// this never touches ZeroKMS, which is what makes query probes cheap. +/// Derive one index term: a codec-encoded scalar and a codec-encoded +/// context (a string, or an array of parts — see [`crate::context`]) in, +/// the term's frozen byte encoding out (see `stack-encrypt`'s `sem` module +/// docs). Purely local — this never touches ZeroKMS, which is what makes +/// query probes cheap. A probe for a field sealed through a plan takes the +/// field's plan context verbatim; a probe for a field a Rust row sealed +/// under an extended context takes the same parts as a list. pub async fn term<K>( cipher: &StackCipher<K>, value: &[u8], @@ -150,10 +155,9 @@ where K: DataKeySource + Sync, { let value = decode_value(value)?; - let context = std::str::from_utf8(context).map_err(|_| STATUS_ENCODING)?; // The same proof every stack-encrypt leaf demands: an empty context is // `STATUS_ENCODING` here, before any derivation. - let context = NonEmpty::new(context).map_err(|_| STATUS_ENCODING)?; + let context = parse_context(decode_value(context)?)?; let output = match kind { TERM_EQUALITY => Output::Equality, TERM_MATCH => Output::Match, @@ -364,7 +368,7 @@ struct FieldPlan { /// opens under it — the cipher-directed `encrypt_with_aad` in /// [`build_row`] as much as the target-directed `decrypt_into` in /// [`decrypt_record`] — is under a context stack-encrypt's leaves accept. - context: NonEmpty<String>, + context: NonEmpty<ContextPart>, outputs: Vec<Output>, } @@ -372,12 +376,14 @@ struct FieldPlan { /// [`FfiValue::Object`]: /// /// ```text -/// { <field>: { "context": <string>, "outputs": [ "c" | "eq" | "match" | "ore" | "ope", ... ] }, ... } +/// { <field>: { "context": <context>, "outputs": [ "c" | "eq" | "match" | "ore" | "ope", ... ] }, ... } +/// context := <string> | <bytes> | <i32> | <i64> | <u32> | <u64> | [ context, ... ] /// ``` /// -/// Rejected as [`STATUS_ENCODING`]: an empty plan, a missing or *empty* -/// context (contexts domain-separate fields; stack-encrypt's leaves take a -/// `NonEmpty<_>` and nothing else), an empty/unknown/duplicated output list, +/// Rejected as [`STATUS_ENCODING`]: an empty plan, a missing, malformed or +/// *empty* context (contexts domain-separate fields; stack-encrypt's leaves +/// take a `NonEmpty<_>` and nothing else — see [`crate::context`] for the +/// shape and the emptiness rule), an empty/unknown/duplicated output list, /// unknown keys. Field names are unique by construction (the codec rejects /// duplicate object keys). /// @@ -387,16 +393,17 @@ struct FieldPlan { /// sealed under — and [`decrypt_record`] opens through `decrypt_into`, /// which would then never open it. /// -/// A plan context is one flat string, and it is the *whole* context of the -/// field: the guest has no caller context to extend it with. That matches a -/// Rust `#[derive(EncryptFrom)]` record sealed with `encrypt_into` (no -/// caller context), where the derive's `"<context>/<field>"` string is the -/// field's whole context too — same AAD bytes, same descriptor. A Rust -/// record sealed with `encrypt_into_with_context(.., 7u64)` extends every -/// field's context to `("users/email", 7u64)`, which no plan string can -/// spell (a string that *looks* like the rendered descriptor is escaped, -/// not parsed); those rows are not readable from a plan, and the reverse -/// holds. See the Go bindings plan. +/// A plan context is the *whole* context of the field: the guest has no +/// caller context to extend it with, so the plan spells the extension +/// itself. A bare string matches a Rust `#[derive(EncryptFrom)]` record +/// sealed with `encrypt_into` (no caller context), where the derive's +/// `"<context>/<field>"` string is the field's whole context — same AAD +/// bytes, same descriptor. A list matches a record sealed with +/// `encrypt_into_with_context(.., 7u64)`, which extends every field's +/// context to `("users/email", 7u64)`: the plan says `["users/email", 7u64]` +/// and seals the same bytes under the same descriptor, `users/email|7u64`. +/// Rows are readable across the two however they were sealed, provided the +/// plan names the context the row was sealed under. fn parse_plan(value: FfiValue) -> Result<Vec<FieldPlan>, u32> { let FfiValue::Object(entries) = value else { return Err(STATUS_ENCODING); @@ -410,16 +417,11 @@ fn parse_plan(value: FfiValue) -> Result<Vec<FieldPlan>, u32> { let FfiValue::Object(spec) = spec else { return Err(STATUS_ENCODING); }; - let mut context: Option<String> = None; + let mut context: Option<NonEmpty<ContextPart>> = None; let mut outputs: Option<Vec<Output>> = None; for (key, value) in spec { match key.as_str() { - "context" => { - let FfiValue::String(s) = value else { - return Err(STATUS_ENCODING); - }; - context = Some(text_of(&s)?.to_string()); - } + "context" => context = Some(parse_context(value)?), "outputs" => { let FfiValue::Array(items) = value else { return Err(STATUS_ENCODING); @@ -441,7 +443,6 @@ fn parse_plan(value: FfiValue) -> Result<Vec<FieldPlan>, u32> { } } let context = context.ok_or(STATUS_ENCODING)?; - let context = NonEmpty::new(context).map_err(|_| STATUS_ENCODING)?; let outputs = outputs.filter(|o| !o.is_empty()).ok_or(STATUS_ENCODING)?; Ok(FieldPlan { name, @@ -617,10 +618,9 @@ where .position(|(name, _)| name == &field.name) .ok_or(STATUS_ENCODING)?; let (name, value) = row.swap_remove(at); - // Borrowed from the plan once per field: the proof was made at - // parse time, so re-taking it over the same bytes cannot fail, and - // `NonEmpty<&str>` is `Copy` for the outputs below. - let context = NonEmpty::new(field.context.get().as_str()).map_err(|_| STATUS_INTERNAL)?; + // The proof was made at parse time; the context is cloned per use + // below (it is a small tree, and the outputs each consume one). + let context = &field.context; // Terms first — they lift a copy of the scalar; the value itself is // consumed by the ciphertext path below. @@ -639,16 +639,16 @@ where continue; } let scalar = scalar.clone().ok_or(STATUS_INTERNAL)?; - let term = term_bytes(cipher, scalar, context, *output).await?; + let term = term_bytes(cipher, scalar, context.clone(), *output).await?; outputs.push((output.key(), Some(term))); } if field.outputs.contains(&Output::Ciphertext) { reject_passthrough_value(&value)?; let tree = value - .encrypt_with_aad(cipher, context) + .encrypt_with_aad(cipher, context.clone()) .map_err(|_| STATUS_INTERNAL)?; - pendings.push(tree.into_pending(cipher, context)); + pendings.push(tree.into_pending(cipher, context.clone())); } skeleton.push((name, outputs)); @@ -701,8 +701,7 @@ where if !field.outputs.contains(&Output::Ciphertext) { continue; } - let context = - NonEmpty::new(field.context.get().as_str()).map_err(|_| STATUS_INTERNAL)?; + let context = field.context.clone(); let at = row .iter() .position(|(name, _)| name == &field.name) diff --git a/languages/golang/stackencrypt/guest/tests/native_ops.rs b/languages/golang/stackencrypt/guest/tests/native_ops.rs index 717b8ebde..d2bbb9361 100644 --- a/languages/golang/stackencrypt/guest/tests/native_ops.rs +++ b/languages/golang/stackencrypt/guest/tests/native_ops.rs @@ -17,7 +17,7 @@ use std::sync::atomic::{AtomicUsize, Ordering}; use futures::executor::block_on; use stack_encrypt::sem::DefaultMatch; -use stack_encrypt::{nonempty, Aad, CipherText, Decrypt, SealedValue, StackCipher}; +use stack_encrypt::{nonempty, Aad, CipherText, Decrypt, Encrypt, SealedValue, StackCipher}; use stack_encrypt_guest::ops::{self, TERM_EQUALITY, TERM_MATCH, TERM_OPE, TERM_ORE}; use stack_encrypt_guest::status::{STATUS_AUTH, STATUS_ENCODING}; use stack_kms::{ @@ -331,12 +331,12 @@ fn an_empty_aad_round_trips_on_the_value_paths() { #[test] fn guest_terms_match_the_native_sem_derivations() { let cipher = cipher(); - let ctx = b"users/age".as_slice(); + let ctx = encode(s("users/age")); let eq = block_on(ops::term( &cipher, &encode(FfiValue::UInt32(42)), - ctx, + &ctx, TERM_EQUALITY, )) .expect("eq term"); @@ -346,7 +346,7 @@ fn guest_terms_match_the_native_sem_derivations() { let ore = block_on(ops::term( &cipher, &encode(FfiValue::UInt32(42)), - ctx, + &ctx, TERM_ORE, )) .expect("ore term"); @@ -356,7 +356,7 @@ fn guest_terms_match_the_native_sem_derivations() { let ope = block_on(ops::term( &cipher, &encode(FfiValue::UInt32(42)), - ctx, + &ctx, TERM_OPE, )) .expect("ope term"); @@ -366,7 +366,7 @@ fn guest_terms_match_the_native_sem_derivations() { let m = block_on(ops::term( &cipher, &encode(s("alice smith")), - b"users/name", + &encode(s("users/name")), TERM_MATCH, )) .expect("match term"); @@ -377,12 +377,17 @@ fn guest_terms_match_the_native_sem_derivations() { // Strings and bytes have distinct PRF encodings — the guest must keep // them apart even when their raw bytes are equal. - let eq_text = - block_on(ops::term(&cipher, &encode(s("ab")), b"f", TERM_EQUALITY)).expect("text term"); + let eq_text = block_on(ops::term( + &cipher, + &encode(s("ab")), + &encode(s("f")), + TERM_EQUALITY, + )) + .expect("text term"); let eq_bytes = block_on(ops::term( &cipher, &encode(FfiValue::Bytes(Protected::new(b"ab".to_vec()))), - b"f", + &encode(s("f")), TERM_EQUALITY, )) .expect("bytes term"); @@ -399,7 +404,7 @@ fn guest_terms_match_the_native_sem_derivations() { let ore_s = block_on(ops::term( &cipher, &encode(s("alice")), - b"users/name", + &encode(s("users/name")), TERM_ORE, )) .expect("string ore"); @@ -413,7 +418,7 @@ fn guest_terms_match_the_native_sem_derivations() { #[test] fn unsupported_term_inputs_are_encoding_errors() { let cipher = cipher(); - let ctx = b"f".as_slice(); + let ctx = encode(s("f")); // Floats and bools have no equality encoding; match is text-only; // containers have no term semantics; kinds outside the table and empty @@ -427,7 +432,7 @@ fn unsupported_term_inputs_are_encoding_errors() { (FfiValue::UInt32(1), 99), ] { assert_eq!( - block_on(ops::term(&cipher, &encode(value), ctx, kind)), + block_on(ops::term(&cipher, &encode(value), &ctx, kind)), Err(STATUS_ENCODING), "kind {kind}" ); @@ -436,7 +441,7 @@ fn unsupported_term_inputs_are_encoding_errors() { block_on(ops::term( &cipher, &encode(FfiValue::UInt32(1)), - b"", + &encode(s("")), TERM_EQUALITY )), Err(STATUS_ENCODING), @@ -603,6 +608,214 @@ fn record_terms_equal_the_native_derivations_and_probe_them() { assert!(matches!(value, FfiValue::UInt32(34))); } +// ============================================================================= +// Structured contexts +// ============================================================================= + +/// The caller extension a Rust row gets from +/// `encrypt_into_with_context(row, 7u64)`: every field's context becomes +/// `("users/<field>", 7u64)`. A plan spells it as a list. +fn extended(field: &str) -> FfiValue { + FfiValue::Array(vec![s(&format!("users/{field}")), FfiValue::UInt64(7)]) +} + +/// `plan()` under the extension. +fn extended_plan() -> Vec<u8> { + encode(obj(vec![ + ( + "age", + obj(vec![ + ("context", extended("age")), + ("outputs", FfiValue::Array(vec![s("c"), s("eq"), s("ore")])), + ]), + ), + ( + "name", + obj(vec![ + ("context", extended("name")), + ("outputs", FfiValue::Array(vec![s("c"), s("match")])), + ]), + ), + ])) +} + +/// A plan whose context is a list seals exactly what the Rust derive seals +/// under a caller-extended context: the stored terms are the native probes +/// under `nonempty!("users/age").with(7u64)`, the guest's own probe under +/// the list is the same bytes, and the `"c"` leaf opens natively under the +/// tuple. The flat context is a different domain, as it must be. +#[test] +fn a_structured_plan_context_seals_what_the_native_extended_context_does() { + let cipher = cipher(); + let record = block_on(ops::encrypt_record( + &cipher, + &encode(row(34, "alice smith")), + &extended_plan(), + )) + .expect("encrypt record"); + + let CipherText::Map(fields) = decode_tree(&record) else { + panic!("expected a field map"); + }; + let (_, CipherText::Map(age_outputs)) = &fields[0] else { + panic!("expected an output map for the first field"); + }; + let term_bytes = |node: &CipherText<Vec<u8>, FfiValue>| -> Vec<u8> { + let CipherText::Passthrough(FfiValue::Bytes(b)) = node else { + panic!("expected a passthrough bytes term node"); + }; + b.risky_ref().to_vec() + }; + + let native = nonempty!("users/age").with(7u64); + let eq_probe = block_on(cipher.equality_term(34u32, native)).expect("probe"); + assert_eq!(term_bytes(&age_outputs[1].1), eq_probe.as_bytes()); + let ore_probe = block_on(cipher.ore_term(34u32, native)).expect("probe"); + assert_eq!(term_bytes(&age_outputs[2].1), ore_probe.as_ref()); + let flat_probe = block_on(cipher.equality_term(34u32, nonempty!("users/age"))).expect("probe"); + assert_ne!( + term_bytes(&age_outputs[1].1), + flat_probe.as_bytes(), + "the extension domain-separates from the flat context" + ); + + let guest_probe = block_on(ops::term( + &cipher, + &encode(FfiValue::UInt32(34)), + &encode(extended("age")), + TERM_EQUALITY, + )) + .expect("guest probe"); + assert_eq!(term_bytes(&age_outputs[1].1), guest_probe); + + let (_, CipherText::Map(name_outputs)) = &fields[1] else { + panic!("expected an output map for the second field"); + }; + let match_probe = block_on( + cipher.match_terms::<DefaultMatch>("alice smith", nonempty!("users/name").with(7u64)), + ) + .expect("probe"); + assert_eq!(term_bytes(&name_outputs[1].1), match_probe.to_bytes()); + + let CipherText::Single(leaf) = &age_outputs[0].1 else { + panic!("expected a single leaf for a scalar field"); + }; + let leaf = SealedValue::from_bytes(leaf).expect("frozen leaf"); + let decipher = + block_on(cipher.decipher(CipherText::Single(leaf), native)).expect("retrieve the data key"); + let value = + FfiValue::decrypt_with_aad(decipher, native).expect("native decrypt under the tuple"); + assert!(matches!(value, FfiValue::UInt32(34))); +} + +/// The reverse direction: a field sealed natively under the tuple — as a +/// Rust row sealed with a caller context is — opens through a plan whose +/// context is the same list, and not through the flat plan. +#[test] +fn a_natively_sealed_field_under_an_extended_context_opens_through_a_plan() { + let cipher = cipher(); + let native = nonempty!("users/age").with(7u64); + let sealed = block_on( + FfiValue::UInt32(34) + .encrypt_with_aad(&cipher, native) + .expect("encrypt") + .seal(&cipher, native), + ) + .expect("seal"); + let CipherText::Single(leaf) = sealed else { + panic!("a scalar seals to a single leaf"); + }; + + // Shape the tree the way `encrypt_record` writes it: field → { c: leaf }. + let tree: CipherText<Vec<u8>, FfiValue> = CipherText::Map(vec![( + "age".to_string(), + CipherText::Map(vec![("c".to_string(), CipherText::Single(leaf.to_bytes()))]), + )]); + let mut record = Vec::new(); + codec::encode_ciphertext(&tree, &mut record).expect("encode tree"); + + let plan_with = |context: FfiValue| { + encode(obj(vec![( + "age", + obj(vec![ + ("context", context), + ("outputs", FfiValue::Array(vec![s("c")])), + ]), + )])) + }; + + let opened = block_on(ops::decrypt_record( + &cipher, + &record, + &plan_with(extended("age")), + )) + .expect("open through the plan"); + let FfiValue::Object(fields) = decode(&opened) else { + panic!("a record decrypts to an object"); + }; + assert!(matches!(fields.as_slice(), [(name, FfiValue::UInt32(34))] if name == "age")); + + assert_eq!( + block_on(ops::decrypt_record( + &cipher, + &record, + &plan_with(s("users/age")) + )), + Err(STATUS_AUTH), + "the flat context is not the one it was sealed under" + ); +} + +/// A plan context that is not a context — the wrong value kind, or empty +/// by the tuple rule — is refused at parse, before anything is sealed. +#[test] +fn a_structured_plan_context_is_validated_at_parse() { + let cipher = cipher(); + let plan_with = |context: FfiValue| { + encode(obj(vec![( + "f", + obj(vec![ + ("context", context), + ("outputs", FfiValue::Array(vec![s("c")])), + ]), + )])) + }; + let source = encode(obj(vec![("f", FfiValue::UInt32(1))])); + + for bad in [ + FfiValue::Bool(true), + FfiValue::Float64(7.0), + FfiValue::Object(vec![]), + FfiValue::Array(vec![]), + FfiValue::Array(vec![s("")]), + FfiValue::Array(vec![s("users/age"), FfiValue::Float64(7.0)]), + ] { + assert_eq!( + block_on(ops::encrypt_record(&cipher, &source, &plan_with(bad))), + Err(STATUS_ENCODING) + ); + } + assert_eq!( + cipher.kms().generate_calls.load(Ordering::SeqCst), + 0, + "nothing seals under a context that is not one" + ); + + // Non-empty by the tuple rule: one part carries bytes. + let sealed = block_on(ops::encrypt_record( + &cipher, + &source, + &plan_with(FfiValue::Array(vec![s(""), FfiValue::UInt64(7)])), + )) + .expect("an integer part is never empty"); + assert!(block_on(ops::decrypt_record( + &cipher, + &sealed, + &plan_with(FfiValue::Array(vec![s(""), FfiValue::UInt64(7)])) + )) + .is_ok()); +} + #[test] fn record_shape_violations_are_encoding_errors() { let cipher = cipher(); From 95708aff29128a7e866bf8f91f74dcc8d60bb82f Mon Sep 17 00:00:00 2001 From: Dan Draper <dan@cipherstash.com> Date: Thu, 10 Sep 2026 21:25:38 -0400 Subject: [PATCH 511/686] refactor(wasi): borrow plan contexts per use; say which Rust context each list spells MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From review of the structured-context change. The record paths deep-cloned a field's `NonEmpty<ContextPart>` once per term output and twice on the ciphertext path, per field per row, where the flat `NonEmpty<&str>` had been `Copy`. `ContextPart` gains borrowed `IntoAad` / `IntoPrfContext` impls (`Cow::Borrowed` leaves, `&str`, `&[u8]`), and `build_row` / `decrypt_record` bind `NonEmpty<&ContextPart>` once per field as before. `part_of` moves a text or bytes payload out of its `Protected` instead of copying it, and the owned list arm shares the PAE helper with the borrowed one. The module docs are now the one home of the context grammar (the plan parser, the ABI doc and the Go plan point here) and say which Rust context each list spells: a two-element list is the pair, `.with()` chains nest left so `[["a", 7u64], "eu"]` is `nonempty!("a").with(7u64).with("eu")` and a flat three-element list is a different context, and a one-element list is `Some(x)` for the AAD and the descriptor but not yet for index terms — vitaminc tags `Some` on the PRF side, a divergence between a context's two derivations that vitaminc#335 removes. Until it ships, a row Go must query is not sealed under an `Option` context. Tests pin each statement, including the current PRF divergence, so cipherstash/cipherstash-suite#335 lands as a test change here rather than a silent one. Tests: the record plan helpers are one `plan_under(ctx)` and a `single_field_plan`, the term-node extractor is one function, looped assertions name their case, and the term path is pinned to refuse a raw UTF-8 context and a non-context codec value at the boundary. Claude-Session: https://claude.ai/code/session_01ECzyss8kvtnrRtq5tmpEbg --- .../golang/stackencrypt/guest/src/context.rs | 265 +++++++++++++++--- .../golang/stackencrypt/guest/src/ops.rs | 40 +-- .../stackencrypt/guest/tests/native_ops.rs | 125 +++++---- 3 files changed, 310 insertions(+), 120 deletions(-) diff --git a/languages/golang/stackencrypt/guest/src/context.rs b/languages/golang/stackencrypt/guest/src/context.rs index eeae1a61a..b0d67c960 100644 --- a/languages/golang/stackencrypt/guest/src/context.rs +++ b/languages/golang/stackencrypt/guest/src/context.rs @@ -31,14 +31,37 @@ //! context := <string> | <bytes> | <i32> | <i64> | <u32> | <u64> | [ context, ... ] //! ``` //! +//! This module is the one home of that grammar; the plan parser, the ABI +//! docs and the Go bindings plan point here. +//! //! A bare string is the flat form every plan used before this module: one //! text part, the field's whole context, same bytes as before. An array is -//! a list; it may nest. A one-element list is *not* the bare part (it is -//! PAE-framed, as `Some(x)` is), and text and bytes with the same content -//! are distinct on the PRF side (UTF-8 versus bytes encodings) though they -//! share AAD bytes — the same distinctions the Rust types make. Booleans, -//! floats, null, undefined, objects and passthroughs are not contexts and -//! are refused as [`STATUS_ENCODING`]. +//! a list; it may nest. Text and bytes with the same content are distinct +//! on the PRF side (UTF-8 versus bytes encodings) though they share AAD +//! bytes — the same distinction the Rust types make. Booleans, floats, +//! null, undefined, objects and passthroughs are not contexts and are +//! refused as [`STATUS_ENCODING`]. +//! +//! # Which Rust contexts a list spells +//! +//! * `["users/age", 7u64]` is `nonempty!("users/age").with(7u64)`: a +//! two-element list is the pair. +//! * `NonEmpty::with` nests to the **left**: `nonempty!("a").with(7u64) +//! .with("eu")` is `(("a", 7u64), "eu")`, spelled `[["a", 7u64], "eu"]`. +//! A flat three-element list is a different context (a three-part PAE) +//! that no `.with()` chain produces; `a_left_nested_list_is_the_with_chain` +//! pins both facts. +//! * A one-element list is *not* the bare part: it is PAE-framed, as +//! `Some(x)` is on the AAD side. On the PRF side vitaminc currently tags +//! `Some(x)` with an `option-some` domain, so `[x]` matches a Rust +//! `Some(x)` for the ciphertext and the descriptor but **not** for index +//! terms. That is a divergence inside vitaminc between a context's two +//! derivations, and vitaminc#335 removes it (`Some(x)` becomes the +//! one-element list on both sides, and this type becomes `AadPiece` +//! itself). Until it ships, a Rust row that Go must query must not be +//! sealed under an `Option` context; `a_one_element_list_is_not_the_bare_part` +//! and `a_one_element_list_is_not_yet_some_on_the_prf_side` pin the +//! current state so the fix shows up as a test change. //! //! # Emptiness //! @@ -105,17 +128,60 @@ impl<'a> IntoPrfContext<'a> for ContextPart { ContextPart::U32(v) => v.into_prf_context(), ContextPart::U64(v) => v.into_prf_context(), ContextPart::List(parts) => { - let encoded: Vec<PrfContext<'static>> = parts - .into_iter() - .map(|part| part.into_prf_context().into_owned()) - .collect(); - let pieces: Vec<&[u8]> = encoded.iter().map(PrfContext::as_bytes).collect(); - PrfContext::pae(&pieces) + pae_of(parts.into_iter().map(IntoPrfContext::into_prf_context)) + } + } + } +} + +/// Borrowed forms, so a plan's context is bound once at parse and then +/// handed to every output of every row without cloning the tree: the +/// leaves borrow (`Cow::Borrowed`, `&str`, `&[u8]`), and the consumers +/// (`encrypt_with_aad`, `into_pending`, `decrypt_into`, the term +/// derivations) take the context by value with a free lifetime and own +/// what they keep before any await. +impl<'a> IntoAad<'a> for &'a ContextPart { + fn into_aad(self) -> Aad<'a> { + self.into_aad_piece().into_aad() + } + + fn into_aad_piece(self) -> AadPiece<'a> { + match self { + ContextPart::Text(text) => AadPiece::Text(Cow::Borrowed(text)), + ContextPart::Bytes(bytes) => AadPiece::Bytes(Cow::Borrowed(bytes)), + ContextPart::I32(v) => AadPiece::I32(*v), + ContextPart::I64(v) => AadPiece::I64(*v), + ContextPart::U32(v) => AadPiece::U32(*v), + ContextPart::U64(v) => AadPiece::U64(*v), + ContextPart::List(parts) => { + AadPiece::List(parts.iter().map(IntoAad::into_aad_piece).collect()) } } } } +impl<'a> IntoPrfContext<'a> for &'a ContextPart { + fn into_prf_context(self) -> PrfContext<'a> { + match self { + ContextPart::Text(text) => text.as_str().into_prf_context(), + ContextPart::Bytes(bytes) => bytes.as_slice().into_prf_context(), + ContextPart::I32(v) => v.into_prf_context(), + ContextPart::I64(v) => v.into_prf_context(), + ContextPart::U32(v) => v.into_prf_context(), + ContextPart::U64(v) => v.into_prf_context(), + ContextPart::List(parts) => pae_of(parts.iter().map(IntoPrfContext::into_prf_context)), + } + } +} + +/// The PAE of already-derived parts: what vitaminc's `(A, B)` impl does +/// for two, for any number. +fn pae_of<'a>(parts: impl Iterator<Item = PrfContext<'a>>) -> PrfContext<'static> { + let encoded: Vec<PrfContext<'a>> = parts.collect(); + let pieces: Vec<&[u8]> = encoded.iter().map(PrfContext::as_bytes).collect(); + PrfContext::pae(&pieces) +} + impl MaybeEmpty for ContextPart { fn is_empty(&self) -> bool { match self { @@ -139,13 +205,14 @@ pub fn parse_context(value: FfiValue) -> Result<NonEmpty<ContextPart>, u32> { fn part_of(value: FfiValue) -> Result<ContextPart, u32> { Ok(match value { - FfiValue::String(s) => { - // Valid UTF-8 by `Utf8String`'s construction invariant; checked - // rather than assumed because this is boundary code. - let text = std::str::from_utf8(s.risky_ref()).map_err(|_| STATUS_ENCODING)?; - ContextPart::Text(text.to_string()) - } - FfiValue::Bytes(bytes) => ContextPart::Bytes(bytes.risky_ref().clone()), + // Valid UTF-8 by `Utf8String`'s construction invariant; checked + // rather than assumed because this is boundary code. The payload + // moves out of its `Protected` rather than being copied: a context + // is not secret, and the copy would only be wiped and freed. + FfiValue::String(s) => ContextPart::Text( + String::from_utf8(s.into_inner().risky_unwrap()).map_err(|_| STATUS_ENCODING)?, + ), + FfiValue::Bytes(bytes) => ContextPart::Bytes(bytes.risky_unwrap()), FfiValue::Int32(v) => ContextPart::I32(v), FfiValue::Int64(v) => ContextPart::I64(v), FfiValue::UInt32(v) => ContextPart::U32(v), @@ -236,6 +303,98 @@ mod tests { ); } + /// The borrowed impls are the owned ones without the clone. + #[test] + fn borrowed_and_owned_forms_encode_alike() { + let parsed = parse_context(FfiValue::Array(vec![ + s("users/age"), + FfiValue::Array(vec![ + FfiValue::Bytes(Protected::new(b"k".to_vec())), + FfiValue::Int64(-1), + ]), + ])) + .expect("context"); + let owned = parsed.clone().into_inner(); + let borrowed = NonEmpty::new(parsed.get()).expect("a non-empty context borrows non-empty"); + assert_eq!( + borrowed.into_aad().as_bytes(), + owned.clone().into_aad().as_bytes(), + "AAD bytes differ between the borrowed and owned forms" + ); + assert_eq!( + NonEmpty::new(parsed.get()) + .expect("non-empty") + .into_prf_context() + .as_bytes(), + owned.into_prf_context().as_bytes(), + "PRF bytes differ between the borrowed and owned forms" + ); + } + + /// `NonEmpty::with` nests to the left, so a `.with().with()` chain is + /// the left-nested list; a flat list of three is a different context. + #[test] + fn a_left_nested_list_is_the_with_chain() { + let chain = nonempty!("a").with(7u64).with("eu"); + let nested = parse_context(FfiValue::Array(vec![ + FfiValue::Array(vec![s("a"), FfiValue::UInt64(7)]), + s("eu"), + ])) + .expect("nested") + .into_inner(); + let flat = parse_context(FfiValue::Array(vec![s("a"), FfiValue::UInt64(7), s("eu")])) + .expect("flat") + .into_inner(); + assert_eq!( + nested.clone().into_aad().as_bytes(), + chain.into_aad().as_bytes(), + "the left-nested list is not the with-chain on the AAD side" + ); + assert_eq!( + nested.clone().into_prf_context().as_bytes(), + chain.into_prf_context().as_bytes(), + "the left-nested list is not the with-chain on the PRF side" + ); + assert_ne!( + flat.clone().into_aad().as_bytes(), + nested.clone().into_aad().as_bytes(), + "a flat three-part list must not collide with the nested pair" + ); + assert_ne!( + flat.into_prf_context().as_bytes(), + nested.into_prf_context().as_bytes(), + "a flat three-part list must not collide with the nested pair" + ); + } + + /// The state vitaminc#335 changes: `[x]` is `Some(x)` for the AAD and + /// the descriptor, and not yet for index terms. When the PRF `Option` + /// impl follows the parts view, the `assert_ne!` here flips to + /// `assert_eq!` and the module docs lose their caveat. + #[test] + fn a_one_element_list_is_not_yet_some_on_the_prf_side() { + use stack_encrypt::Descriptor; + let list = parse_context(FfiValue::Array(vec![FfiValue::UInt64(7)])) + .expect("list") + .into_inner(); + let some = Some(7u64); + assert_eq!( + list.clone().into_aad().as_bytes(), + some.into_aad().as_bytes(), + "[x] and Some(x) share AAD bytes" + ); + assert_eq!( + Descriptor::of(list.clone()).as_str(), + Descriptor::of(some).as_str(), + "[x] and Some(x) render the same descriptor" + ); + assert_ne!( + list.into_prf_context().as_bytes(), + some.into_prf_context().as_bytes(), + "vitaminc#335 has landed: [x] now equals Some(x) on the PRF side too — flip this to assert_eq! and drop the module-doc caveat" + ); + } + #[test] fn a_one_element_list_is_not_the_bare_part() { let list = parse_context(FfiValue::Array(vec![s("a")])).expect("list"); @@ -268,36 +427,62 @@ mod tests { #[test] fn emptiness_follows_the_tuple_rule() { - for empty in [ - s(""), - FfiValue::Bytes(Protected::new(Vec::new())), - FfiValue::Array(vec![]), - FfiValue::Array(vec![s("")]), - FfiValue::Array(vec![FfiValue::Array(vec![]), s("")]), + for (label, empty) in [ + ("an empty string", s("")), + ("empty bytes", FfiValue::Bytes(Protected::new(Vec::new()))), + ("an empty list", FfiValue::Array(vec![])), + ("a list of one empty string", FfiValue::Array(vec![s("")])), + ( + "a list of empties", + FfiValue::Array(vec![FfiValue::Array(vec![]), s("")]), + ), ] { - assert_eq!(parse_context(empty).err(), Some(STATUS_ENCODING)); + assert_eq!( + parse_context(empty).err(), + Some(STATUS_ENCODING), + "{label} is empty by the tuple rule and must be refused" + ); } - for non_empty in [ - FfiValue::UInt64(0), - FfiValue::Array(vec![s(""), FfiValue::Int32(0)]), - FfiValue::Array(vec![FfiValue::Array(vec![s("x")])]), + for (label, non_empty) in [ + ("a zero integer", FfiValue::UInt64(0)), + ( + "an empty string beside an integer", + FfiValue::Array(vec![s(""), FfiValue::Int32(0)]), + ), + ( + "a nested non-empty list", + FfiValue::Array(vec![FfiValue::Array(vec![s("x")])]), + ), ] { - assert!(parse_context(non_empty).is_ok()); + assert!( + parse_context(non_empty).is_ok(), + "{label} carries bytes and must be accepted" + ); } } #[test] fn non_context_values_are_encoding_errors() { - for bad in [ - FfiValue::Null, - FfiValue::Undefined, - FfiValue::Bool(true), - FfiValue::Float32(1.0), - FfiValue::Float64(1.0), - FfiValue::Object(vec![("k".to_string(), s("v"))]), - FfiValue::Array(vec![s("ok"), FfiValue::Bool(false)]), + for (label, bad) in [ + ("null", FfiValue::Null), + ("undefined", FfiValue::Undefined), + ("a boolean", FfiValue::Bool(true)), + ("a float32", FfiValue::Float32(1.0)), + ("a float64", FfiValue::Float64(1.0)), + ( + "an object", + FfiValue::Object(vec![("k".to_string(), s("v"))]), + ), + ( + "a list with a boolean in it", + FfiValue::Array(vec![s("ok"), FfiValue::Bool(false)]), + ), ] { - assert_eq!(parse_context(bad).err(), Some(STATUS_ENCODING)); + assert_eq!( + parse_context(bad).err(), + Some(STATUS_ENCODING), + "{label} is not a context and must be refused" + ); } } } diff --git a/languages/golang/stackencrypt/guest/src/ops.rs b/languages/golang/stackencrypt/guest/src/ops.rs index 64fa44508..25183d092 100644 --- a/languages/golang/stackencrypt/guest/src/ops.rs +++ b/languages/golang/stackencrypt/guest/src/ops.rs @@ -377,15 +377,17 @@ struct FieldPlan { /// /// ```text /// { <field>: { "context": <context>, "outputs": [ "c" | "eq" | "match" | "ore" | "ope", ... ] }, ... } -/// context := <string> | <bytes> | <i32> | <i64> | <u32> | <u64> | [ context, ... ] /// ``` /// +/// `<context>` is defined once, in [`crate::context`]: a string, bytes, an +/// integer, or a list of those, with what each spells in Rust and the +/// emptiness rule. +/// /// Rejected as [`STATUS_ENCODING`]: an empty plan, a missing, malformed or /// *empty* context (contexts domain-separate fields; stack-encrypt's leaves -/// take a `NonEmpty<_>` and nothing else — see [`crate::context`] for the -/// shape and the emptiness rule), an empty/unknown/duplicated output list, -/// unknown keys. Field names are unique by construction (the codec rejects -/// duplicate object keys). +/// take a `NonEmpty<_>` and nothing else), an empty/unknown/duplicated +/// output list, unknown keys. Field names are unique by construction (the +/// codec rejects duplicate object keys). /// /// The context is proven here, once, and carried as a [`NonEmpty`]: the /// cipher-directed path [`build_row`] seals through accepts any AAD, so @@ -396,14 +398,11 @@ struct FieldPlan { /// A plan context is the *whole* context of the field: the guest has no /// caller context to extend it with, so the plan spells the extension /// itself. A bare string matches a Rust `#[derive(EncryptFrom)]` record -/// sealed with `encrypt_into` (no caller context), where the derive's -/// `"<context>/<field>"` string is the field's whole context — same AAD -/// bytes, same descriptor. A list matches a record sealed with -/// `encrypt_into_with_context(.., 7u64)`, which extends every field's -/// context to `("users/email", 7u64)`: the plan says `["users/email", 7u64]` -/// and seals the same bytes under the same descriptor, `users/email|7u64`. -/// Rows are readable across the two however they were sealed, provided the -/// plan names the context the row was sealed under. +/// sealed with `encrypt_into` (no caller context); a list matches one +/// sealed with `encrypt_into_with_context` — see [`crate::context`] for +/// which list spells which Rust context. Rows are readable across the two +/// however they were sealed, provided the plan names the context the row +/// was sealed under. fn parse_plan(value: FfiValue) -> Result<Vec<FieldPlan>, u32> { let FfiValue::Object(entries) = value else { return Err(STATUS_ENCODING); @@ -618,9 +617,10 @@ where .position(|(name, _)| name == &field.name) .ok_or(STATUS_ENCODING)?; let (name, value) = row.swap_remove(at); - // The proof was made at parse time; the context is cloned per use - // below (it is a small tree, and the outputs each consume one). - let context = &field.context; + // Borrowed from the plan once per field: the proof was made at + // parse time, so re-taking it over the same tree cannot fail, and + // `NonEmpty<&ContextPart>` is `Copy` for the outputs below. + let context = NonEmpty::new(field.context.get()).map_err(|_| STATUS_INTERNAL)?; // Terms first — they lift a copy of the scalar; the value itself is // consumed by the ciphertext path below. @@ -639,16 +639,16 @@ where continue; } let scalar = scalar.clone().ok_or(STATUS_INTERNAL)?; - let term = term_bytes(cipher, scalar, context.clone(), *output).await?; + let term = term_bytes(cipher, scalar, context, *output).await?; outputs.push((output.key(), Some(term))); } if field.outputs.contains(&Output::Ciphertext) { reject_passthrough_value(&value)?; let tree = value - .encrypt_with_aad(cipher, context.clone()) + .encrypt_with_aad(cipher, context) .map_err(|_| STATUS_INTERNAL)?; - pendings.push(tree.into_pending(cipher, context.clone())); + pendings.push(tree.into_pending(cipher, context)); } skeleton.push((name, outputs)); @@ -701,7 +701,7 @@ where if !field.outputs.contains(&Output::Ciphertext) { continue; } - let context = field.context.clone(); + let context = NonEmpty::new(field.context.get()).map_err(|_| STATUS_INTERNAL)?; let at = row .iter() .position(|(name, _)| name == &field.name) diff --git a/languages/golang/stackencrypt/guest/tests/native_ops.rs b/languages/golang/stackencrypt/guest/tests/native_ops.rs index d2bbb9361..72a39737d 100644 --- a/languages/golang/stackencrypt/guest/tests/native_ops.rs +++ b/languages/golang/stackencrypt/guest/tests/native_ops.rs @@ -137,27 +137,52 @@ fn s(value: &str) -> FfiValue { FfiValue::String(value.into()) } -/// The plan used by the record tests: an ORE-indexed integer and a -/// match-indexed string, both stored. -fn plan() -> Vec<u8> { +/// The plan shape the record tests share — an ORE-indexed integer and a +/// match-indexed string, both stored — under whatever context `ctx` gives +/// each field. Output order is fixed here, and the tests index into it. +fn plan_under(ctx: impl Fn(&str) -> FfiValue) -> Vec<u8> { encode(obj(vec![ ( "age", obj(vec![ - ("context", s("users/age")), + ("context", ctx("age")), ("outputs", FfiValue::Array(vec![s("c"), s("eq"), s("ore")])), ]), ), ( "name", obj(vec![ - ("context", s("users/name")), + ("context", ctx("name")), ("outputs", FfiValue::Array(vec![s("c"), s("match")])), ]), ), ])) } +/// The plan used by the record tests: `plan_under` with flat contexts. +fn plan() -> Vec<u8> { + plan_under(|field| s(&format!("users/{field}"))) +} + +/// A one-field plan storing only the ciphertext, under `context`. +fn single_field_plan(field: &str, context: FfiValue) -> Vec<u8> { + encode(obj(vec![( + field, + obj(vec![ + ("context", context), + ("outputs", FfiValue::Array(vec![s("c")])), + ]), + )])) +} + +/// The bytes of a term node in a decoded record tree. +fn term_bytes(node: &CipherText<Vec<u8>, FfiValue>) -> Vec<u8> { + let CipherText::Passthrough(FfiValue::Bytes(b)) = node else { + panic!("expected a passthrough bytes term node"); + }; + b.risky_ref().to_vec() +} + fn row(age: u32, name: &str) -> FfiValue { obj(vec![("age", FfiValue::UInt32(age)), ("name", s(name))]) } @@ -447,6 +472,26 @@ fn unsupported_term_inputs_are_encoding_errors() { Err(STATUS_ENCODING), "empty context" ); + + // The context is codec-encoded, not raw text: the pre-structured form + // must be refused at the boundary, not read as a flat context; and a + // codec value that is not a context must be refused too. + for (label, context) in [ + ("raw utf-8 bytes", b"f".to_vec()), + ("a boolean", encode(FfiValue::Bool(true))), + ("an object", encode(obj(vec![("k", s("v"))]))), + ] { + assert_eq!( + block_on(ops::term( + &cipher, + &encode(FfiValue::UInt32(1)), + &context, + TERM_EQUALITY + )), + Err(STATUS_ENCODING), + "a term context of {label} must be refused" + ); + } } // ============================================================================= @@ -573,13 +618,6 @@ fn record_terms_equal_the_native_derivations_and_probe_them() { "output order is the plan's" ); - let term_bytes = |node: &CipherText<Vec<u8>, FfiValue>| -> Vec<u8> { - let CipherText::Passthrough(FfiValue::Bytes(b)) = node else { - panic!("expected a passthrough bytes term node"); - }; - b.risky_ref().to_vec() - }; - // The stored terms are byte-identical to query-time probes built the // native way — the property that makes the index searchable. let eq_probe = block_on(cipher.equality_term(34u32, nonempty!("users/age"))).expect("probe"); @@ -621,22 +659,7 @@ fn extended(field: &str) -> FfiValue { /// `plan()` under the extension. fn extended_plan() -> Vec<u8> { - encode(obj(vec![ - ( - "age", - obj(vec![ - ("context", extended("age")), - ("outputs", FfiValue::Array(vec![s("c"), s("eq"), s("ore")])), - ]), - ), - ( - "name", - obj(vec![ - ("context", extended("name")), - ("outputs", FfiValue::Array(vec![s("c"), s("match")])), - ]), - ), - ])) + plan_under(extended) } /// A plan whose context is a list seals exactly what the Rust derive seals @@ -660,12 +683,6 @@ fn a_structured_plan_context_seals_what_the_native_extended_context_does() { let (_, CipherText::Map(age_outputs)) = &fields[0] else { panic!("expected an output map for the first field"); }; - let term_bytes = |node: &CipherText<Vec<u8>, FfiValue>| -> Vec<u8> { - let CipherText::Passthrough(FfiValue::Bytes(b)) = node else { - panic!("expected a passthrough bytes term node"); - }; - b.risky_ref().to_vec() - }; let native = nonempty!("users/age").with(7u64); let eq_probe = block_on(cipher.equality_term(34u32, native)).expect("probe"); @@ -734,15 +751,7 @@ fn a_natively_sealed_field_under_an_extended_context_opens_through_a_plan() { let mut record = Vec::new(); codec::encode_ciphertext(&tree, &mut record).expect("encode tree"); - let plan_with = |context: FfiValue| { - encode(obj(vec![( - "age", - obj(vec![ - ("context", context), - ("outputs", FfiValue::Array(vec![s("c")])), - ]), - )])) - }; + let plan_with = |context: FfiValue| single_field_plan("age", context); let opened = block_on(ops::decrypt_record( &cipher, @@ -771,28 +780,24 @@ fn a_natively_sealed_field_under_an_extended_context_opens_through_a_plan() { #[test] fn a_structured_plan_context_is_validated_at_parse() { let cipher = cipher(); - let plan_with = |context: FfiValue| { - encode(obj(vec![( - "f", - obj(vec![ - ("context", context), - ("outputs", FfiValue::Array(vec![s("c")])), - ]), - )])) - }; + let plan_with = |context: FfiValue| single_field_plan("f", context); let source = encode(obj(vec![("f", FfiValue::UInt32(1))])); - for bad in [ - FfiValue::Bool(true), - FfiValue::Float64(7.0), - FfiValue::Object(vec![]), - FfiValue::Array(vec![]), - FfiValue::Array(vec![s("")]), - FfiValue::Array(vec![s("users/age"), FfiValue::Float64(7.0)]), + for (label, bad) in [ + ("a boolean", FfiValue::Bool(true)), + ("a float", FfiValue::Float64(7.0)), + ("an object", FfiValue::Object(vec![])), + ("an empty list", FfiValue::Array(vec![])), + ("a list of one empty string", FfiValue::Array(vec![s("")])), + ( + "a list with a float in it", + FfiValue::Array(vec![s("users/age"), FfiValue::Float64(7.0)]), + ), ] { assert_eq!( block_on(ops::encrypt_record(&cipher, &source, &plan_with(bad))), - Err(STATUS_ENCODING) + Err(STATUS_ENCODING), + "a plan context of {label} must be refused at parse" ); } assert_eq!( From e2e3bffd36a536a27f1eeeb4b7e3c2cdce6e2013 Mon Sep 17 00:00:00 2001 From: Dan Draper <dan@cipherstash.com> Date: Fri, 11 Sep 2026 19:47:03 -0400 Subject: [PATCH 512/686] docs(wasi): name every scalar a probe context may be; carry the security lint block Review (Copilot, cipherstash/cipherstash-suite#2210). The `se_term` docs, the `ops::term` docs and the Go plan's export table described a context as "a string, or an array of parts", while the grammar in `context` also accepts bytes and the four integer widths. A host reading those three places could wrap a bare integer context as `[7]` and derive a term for a different context than the field was sealed under, since a one-element list is PAE-framed and the bare part is not. All three now enumerate the scalar forms, say that shape is identity, and point at `context` as the grammar's one home. The guest also gains the crate-level lint block `stack-encrypt` and `stack-auth` carry, minus `deny(unsafe_code)`: the ABI and the two host imports are `extern "C"` over raw pointers by nature, so `unsafe_op_in_unsafe_fn` stays the guard there. `unused_results` flagged four intentional discards, now explicit: the session map's insert and remove (the removed cipher drops there, wiping its keys) and the buffer registry's two inserts, whose "previous entry" is an invariant and is now a `debug_assert!`. Claude-Session: https://claude.ai/code/session_01ECzyss8kvtnrRtq5tmpEbg --- docs/plans/stack-encrypt-go-bindings.md | 2 +- .../golang/stackencrypt/guest/src/abi.rs | 13 +++++++--- .../golang/stackencrypt/guest/src/buffers.rs | 10 ++++++-- .../golang/stackencrypt/guest/src/lib.rs | 24 +++++++++++++++++++ .../golang/stackencrypt/guest/src/ops.rs | 18 +++++++++----- .../golang/stackencrypt/guest/src/sessions.rs | 6 +++-- 6 files changed, 59 insertions(+), 14 deletions(-) diff --git a/docs/plans/stack-encrypt-go-bindings.md b/docs/plans/stack-encrypt-go-bindings.md index 847e89571..746f9211d 100644 --- a/docs/plans/stack-encrypt-go-bindings.md +++ b/docs/plans/stack-encrypt-go-bindings.md @@ -386,7 +386,7 @@ via the registry, packed `u64` results, status in the low word on error): | `se_encrypt_element` / `se_decrypt_element` | as vitaminc; row-at-a-time interop with batch-encrypted slices | | `se_encrypt_record(handle, source, plan, aad)` | the runtime form of `#[derive(EncryptFrom)]`: `plan` is an `FfiValue` object `{ field → { context, outputs: [c \| eq \| match(opts) \| ore \| ope] } }`; per field the guest dispatches on the source `FfiValue` variant to the typed `EncryptFrom` impls (`u32`/`u64`/`i64`/`f64`/`String`), zips the pendings, `Pending::all` across an array source, and returns `{ field → { c: leaf, hm: bytes, ob: bytes, … } }`. One `generate_keys` call per invocation regardless of row count. | | `se_decrypt_record(handle, record, plan, aad)` | inverse; only the `c` outputs participate | -| `se_term(handle, value, context, kind)` | query probe; `context` is codec-encoded (a string, or an array of parts, as a plan's); local PRF/ORE only, never touches ZeroKMS | +| `se_term(handle, value, context, kind)` | query probe; `context` is codec-encoded in the plan-field grammar — one part (a string, bytes, or an `i32`/`i64`/`u32`/`u64`) or an array of parts nested to any depth. Shape is identity: `[x]` is not `x`, so a probe passes the context in exactly the shape the field was sealed under (the guest's `context` module is the one home of the grammar and of which Rust context each shape spells). Local PRF/ORE only, never touches ZeroKMS | Host imports (two, both from the `cipherstash_transport` module #2099 defined): diff --git a/languages/golang/stackencrypt/guest/src/abi.rs b/languages/golang/stackencrypt/guest/src/abi.rs index e30b5e7b4..f4deb26d1 100644 --- a/languages/golang/stackencrypt/guest/src/abi.rs +++ b/languages/golang/stackencrypt/guest/src/abi.rs @@ -395,9 +395,16 @@ fn run_decrypt( } /// Derive one index term: a codec-encoded scalar, a codec-encoded context -/// (a string, or an array of parts — see [`crate::context`]) and a term -/// kind ([`ops::TERM_EQUALITY`] etc.); the output is the term's frozen byte -/// encoding. Local PRF/CLLW only — never touches ZeroKMS. +/// and a term kind ([`ops::TERM_EQUALITY`] etc.); the output is the term's +/// frozen byte encoding. Local PRF/CLLW only — never touches ZeroKMS. +/// +/// The context is one part — a string, bytes, or an `i32`/`i64`/`u32`/`u64` +/// — or an array of parts, nested to any depth; [`crate::context`] is the +/// one home of that grammar and of which Rust context each shape spells. +/// A part and the one-element array holding it are *different* contexts +/// (`[x]` is PAE-framed, `x` is not), so a probe must pass the context in +/// exactly the shape the field was sealed under: a plan field's context +/// verbatim, a bare part for a Rust leaf sealed under that part. /// /// # Safety /// diff --git a/languages/golang/stackencrypt/guest/src/buffers.rs b/languages/golang/stackencrypt/guest/src/buffers.rs index bcf5b6044..285b98f39 100644 --- a/languages/golang/stackencrypt/guest/src/buffers.rs +++ b/languages/golang/stackencrypt/guest/src/buffers.rs @@ -68,7 +68,10 @@ pub(crate) fn register(buf: Vec<u8>) -> *mut u8 { let boxed = buf.into_boxed_slice(); let len = boxed.len(); let ptr = Box::into_raw(boxed) as *mut u8; - BUFFERS.with(|b| b.borrow_mut().insert(ptr as usize, len)); + // A fresh allocation can't already be registered; the returned previous + // entry is the invariant, checked in debug builds. + let previous = BUFFERS.with(|b| b.borrow_mut().insert(ptr as usize, len)); + debug_assert!(previous.is_none()); ptr } @@ -125,7 +128,10 @@ unsafe fn reclaim(ptr: *mut u8, len: usize) -> Option<Vec<u8>> { } let real_len = BUFFERS.with(|b| b.borrow_mut().remove(&(ptr as usize)))?; if real_len != len { - BUFFERS.with(|b| b.borrow_mut().insert(ptr as usize, real_len)); + // Put the entry back exactly as it was; it was just removed, so + // nothing can be there to displace. + let previous = BUFFERS.with(|b| b.borrow_mut().insert(ptr as usize, real_len)); + debug_assert!(previous.is_none()); return None; } // SAFETY: the registry guarantees `(ptr, real_len)` is exactly one live diff --git a/languages/golang/stackencrypt/guest/src/lib.rs b/languages/golang/stackencrypt/guest/src/lib.rs index a47acd372..213f36d10 100644 --- a/languages/golang/stackencrypt/guest/src/lib.rs +++ b/languages/golang/stackencrypt/guest/src/lib.rs @@ -1,4 +1,28 @@ +// Security lints — the block `stack-encrypt` and `stack-auth` carry, minus +// `deny(unsafe_code)`: the export surface (`abi`) and the two host imports +// (`host`) are `extern "C"` over raw pointers by nature. Every `unsafe` +// block is confined to those two wasm32-only modules and documented at the +// site; `unsafe_op_in_unsafe_fn` keeps each one explicit. #![deny(unsafe_op_in_unsafe_fn)] +#![warn(clippy::unwrap_used)] +#![warn(clippy::expect_used)] +#![warn(clippy::panic)] +// Prevent mem::forget from bypassing ZeroizeOnDrop +#![warn(clippy::mem_forget)] +// Prevent accidental data leaks via output +#![warn(clippy::print_stdout)] +#![warn(clippy::print_stderr)] +#![warn(clippy::dbg_macro)] +// Code quality +#![warn(unreachable_pub)] +#![warn(unused_results)] +#![warn(clippy::todo)] +#![warn(clippy::unimplemented)] +// Relax in tests +#![cfg_attr(test, allow(clippy::unwrap_used))] +#![cfg_attr(test, allow(clippy::expect_used))] +#![cfg_attr(test, allow(clippy::panic))] +#![cfg_attr(test, allow(unused_results))] //! # stack-encrypt WASI guest //! //! WASI guest module exposing [`stack-encrypt`](stack_encrypt) — diff --git a/languages/golang/stackencrypt/guest/src/ops.rs b/languages/golang/stackencrypt/guest/src/ops.rs index 25183d092..fdcf73859 100644 --- a/languages/golang/stackencrypt/guest/src/ops.rs +++ b/languages/golang/stackencrypt/guest/src/ops.rs @@ -139,12 +139,18 @@ where // ============================================================================= /// Derive one index term: a codec-encoded scalar and a codec-encoded -/// context (a string, or an array of parts — see [`crate::context`]) in, -/// the term's frozen byte encoding out (see `stack-encrypt`'s `sem` module -/// docs). Purely local — this never touches ZeroKMS, which is what makes -/// query probes cheap. A probe for a field sealed through a plan takes the -/// field's plan context verbatim; a probe for a field a Rust row sealed -/// under an extended context takes the same parts as a list. +/// context in, the term's frozen byte encoding out (see `stack-encrypt`'s +/// `sem` module docs). Purely local — this never touches ZeroKMS, which is +/// what makes query probes cheap. +/// +/// The context is one part — a string, bytes, or an `i32`/`i64`/`u32`/`u64` +/// — or an array of parts, nested to any depth, exactly as a plan field's; +/// [`crate::context`] is the one home of that grammar. Shape is identity: +/// `[x]` is a PAE-framed list and `x` is not, so a probe takes the context +/// in the shape the field was sealed under — a plan field's context +/// verbatim, a bare part for a Rust leaf sealed under that part, and the +/// same parts as a (left-nested) list for a Rust row sealed under an +/// extended context. pub async fn term<K>( cipher: &StackCipher<K>, value: &[u8], diff --git a/languages/golang/stackencrypt/guest/src/sessions.rs b/languages/golang/stackencrypt/guest/src/sessions.rs index 8ea417a60..8e6be031c 100644 --- a/languages/golang/stackencrypt/guest/src/sessions.rs +++ b/languages/golang/stackencrypt/guest/src/sessions.rs @@ -43,7 +43,7 @@ impl<C> Sessions<C> { match self.ciphers.entry(handle) { Entry::Occupied(_) => Err(STATUS_INTERNAL), Entry::Vacant(slot) => { - slot.insert(cipher); + let _ = slot.insert(cipher); self.next = bumped; Ok(handle) } @@ -54,8 +54,10 @@ impl<C> Sessions<C> { self.ciphers.get(&handle) } + /// The removed cipher is dropped here, which is where its keys are + /// wiped (`ZeroizeOnDrop`); an unknown handle is a no-op. pub(crate) fn remove(&mut self, handle: u32) { - self.ciphers.remove(&handle); + drop(self.ciphers.remove(&handle)); } } From 427fe4134df84fa3840cd03d4db70c01e4f35d85 Mon Sep 17 00:00:00 2001 From: Dan Draper <dan@cipherstash.com> Date: Fri, 11 Sep 2026 19:52:50 -0400 Subject: [PATCH 513/686] docs(wasi): make the guest's rustdoc build clean on both targets, and gate it `cargo doc` on the guest failed under `-D warnings` on either target: seven links pointed at private items (`buffers`, `sessions`, `parse_plan`, `cipher_init`), and on the native host target the links to `abi` and `host` had nothing to resolve to, since those modules are wasm32-only. The private-item links are plain code spans now. On native the wasm32-only links are allowed rather than removed, because wasm32 is the target the crate is written for and the docs should link there; the wasm32 doc build is where links are enforced, and `wasm:guest:test` now runs it with `-D warnings` so a stale link fails CI instead of shipping. Claude-Session: https://claude.ai/code/session_01ECzyss8kvtnrRtq5tmpEbg --- languages/golang/stackencrypt/guest/src/abi.rs | 6 +++--- languages/golang/stackencrypt/guest/src/host.rs | 2 +- languages/golang/stackencrypt/guest/src/lib.rs | 8 ++++++-- languages/golang/stackencrypt/guest/src/ops.rs | 2 +- 4 files changed, 11 insertions(+), 7 deletions(-) diff --git a/languages/golang/stackencrypt/guest/src/abi.rs b/languages/golang/stackencrypt/guest/src/abi.rs index f4deb26d1..fb20e6555 100644 --- a/languages/golang/stackencrypt/guest/src/abi.rs +++ b/languages/golang/stackencrypt/guest/src/abi.rs @@ -5,7 +5,7 @@ //! memory obtained from [`se_alloc`] and releases every buffer — its own //! inputs and the guest's outputs — with [`se_dealloc`], which **zeroizes //! before freeing**. The guest keeps a registry of every buffer it hands -//! out ([`crate::buffers`]), so `se_dealloc` never trusts the host's +//! out (`crate::buffers`), so `se_dealloc` never trusts the host's //! length. Two entry points additionally wipe their *input* buffer in //! place before returning: [`se_cipher_init`] (the config carries the //! client key) and [`se_decrypt`]'s output is plaintext the host must @@ -100,7 +100,7 @@ pub extern "C" fn se_alloc(len: u32) -> *mut u8 { } /// Zeroize and free a buffer previously handed out by [`se_alloc`] or -/// packed into a result. See [`crate::buffers::dealloc`] for the registry +/// packed into a result. See `crate::buffers::dealloc` for the registry /// discipline (unknown pointer: no-op; length mismatch: refused). /// /// # Safety @@ -272,7 +272,7 @@ pub extern "C" fn se_cipher_free(handle: u32) { /// Encrypt an FFI-codec-encoded value tree under the handle's cipher, /// binding `aad`; every leaf is sealed from one batched key request, /// dispatched as one `generate-data-key` call per 500 keyed leaves (see -/// [`cipher_init`] for where that bound comes from). Output: packed pointer +/// `cipher_init` for where that bound comes from). Output: packed pointer /// to a codec-encoded ciphertext tree whose leaves are the frozen /// `SealedValue` byte encoding. /// diff --git a/languages/golang/stackencrypt/guest/src/host.rs b/languages/golang/stackencrypt/guest/src/host.rs index f8b8d0df7..4f66670b7 100644 --- a/languages/golang/stackencrypt/guest/src/host.rs +++ b/languages/golang/stackencrypt/guest/src/host.rs @@ -7,7 +7,7 @@ //! //! All pointers are offsets into guest linear memory; the host allocates //! guest buffers with `se_alloc` and the guest reclaims them through its -//! registry ([`crate::buffers`]). +//! registry (`crate::buffers`). //! //! - `transport_send(method, url, headers, body, resp_headers_out, //! resp_body_out) -> status` — perform one HTTP request. Each of the four diff --git a/languages/golang/stackencrypt/guest/src/lib.rs b/languages/golang/stackencrypt/guest/src/lib.rs index 213f36d10..a7a1142fc 100644 --- a/languages/golang/stackencrypt/guest/src/lib.rs +++ b/languages/golang/stackencrypt/guest/src/lib.rs @@ -23,6 +23,10 @@ #![cfg_attr(test, allow(clippy::expect_used))] #![cfg_attr(test, allow(clippy::panic))] #![cfg_attr(test, allow(unused_results))] +// The crate's target is wasm32; `abi` and `host` only exist there, so on a +// native doc build their intra-doc links have nothing to resolve to. The +// wasm32 doc build (`mise run wasm:guest:test`) is where links are enforced. +#![cfg_attr(not(target_arch = "wasm32"), allow(rustdoc::broken_intra_doc_links))] //! # stack-encrypt WASI guest //! //! WASI guest module exposing [`stack-encrypt`](stack_encrypt) — @@ -42,11 +46,11 @@ //! Split into: //! //! - [`ops`], [`context`], [`config`], [`response`], [`headers`], [`status`], -//! [`sessions`] — everything that is pure logic over `StackCipher<K>` / +//! `sessions` — everything that is pure logic over `StackCipher<K>` / //! bytes. Compiles and unit-tests on the native host target (`cargo //! test` here, no wasm toolchain needed) against //! `stack_kms::FakeDataKeySource`. -//! - [`abi`], [`host`], [`buffers`] (wasm32 only) — the export surface, +//! - [`abi`], [`host`], `buffers` (wasm32 only) — the export surface, //! the two host imports, and the buffer registry. See [`abi`]'s module //! docs for the full ABI contract. //! diff --git a/languages/golang/stackencrypt/guest/src/ops.rs b/languages/golang/stackencrypt/guest/src/ops.rs index fdcf73859..d8815c835 100644 --- a/languages/golang/stackencrypt/guest/src/ops.rs +++ b/languages/golang/stackencrypt/guest/src/ops.rs @@ -32,7 +32,7 @@ //! `ClientOpts::max_keys_per_req` keyed leaves (500 by default, sent //! sequentially: the guest pins `max_concurrent_reqs` to 1), so "one call" //! is exact up to 500 leaves and "one call per 500" past it. See -//! [`parse_plan`] for the plan encoding. +//! `parse_plan` for the plan encoding. use stack_encrypt::sem::{CllwOpeEncrypt, CllwOreEncrypt, DefaultMatch}; use stack_encrypt::target::Pending; From f8c84d42d7c40ebcd472350142aea9405939be06 Mon Sep 17 00:00:00 2001 From: Dan Draper <dan@cipherstash.com> Date: Fri, 11 Sep 2026 19:55:15 -0400 Subject: [PATCH 514/686] docs(wasi): state the codec's nesting bound where the context grammar is described Review (Copilot, cipherstash/cipherstash-suite#2210). The `se_term` docs, `ops::term`, the plan's export table and the grammar's home in `context` said an array of parts nests "to any depth". The transport codec bounds nesting at `MAX_DEPTH` (128 levels from the root of the encoded value) and refuses deeper input as `STATUS_ENCODING` before the context is parsed, so a host must not read that rejection as an interoperability bug. All four now name the bound and where it is enforced. Claude-Session: https://claude.ai/code/session_01ECzyss8kvtnrRtq5tmpEbg --- docs/plans/stack-encrypt-go-bindings.md | 2 +- languages/golang/stackencrypt/guest/src/abi.rs | 7 +++++-- languages/golang/stackencrypt/guest/src/context.rs | 7 ++++++- languages/golang/stackencrypt/guest/src/ops.rs | 6 ++++-- 4 files changed, 16 insertions(+), 6 deletions(-) diff --git a/docs/plans/stack-encrypt-go-bindings.md b/docs/plans/stack-encrypt-go-bindings.md index 746f9211d..24d3ac692 100644 --- a/docs/plans/stack-encrypt-go-bindings.md +++ b/docs/plans/stack-encrypt-go-bindings.md @@ -386,7 +386,7 @@ via the registry, packed `u64` results, status in the low word on error): | `se_encrypt_element` / `se_decrypt_element` | as vitaminc; row-at-a-time interop with batch-encrypted slices | | `se_encrypt_record(handle, source, plan, aad)` | the runtime form of `#[derive(EncryptFrom)]`: `plan` is an `FfiValue` object `{ field → { context, outputs: [c \| eq \| match(opts) \| ore \| ope] } }`; per field the guest dispatches on the source `FfiValue` variant to the typed `EncryptFrom` impls (`u32`/`u64`/`i64`/`f64`/`String`), zips the pendings, `Pending::all` across an array source, and returns `{ field → { c: leaf, hm: bytes, ob: bytes, … } }`. One `generate_keys` call per invocation regardless of row count. | | `se_decrypt_record(handle, record, plan, aad)` | inverse; only the `c` outputs participate | -| `se_term(handle, value, context, kind)` | query probe; `context` is codec-encoded in the plan-field grammar — one part (a string, bytes, or an `i32`/`i64`/`u32`/`u64`) or an array of parts nested to any depth. Shape is identity: `[x]` is not `x`, so a probe passes the context in exactly the shape the field was sealed under (the guest's `context` module is the one home of the grammar and of which Rust context each shape spells). Local PRF/ORE only, never touches ZeroKMS | +| `se_term(handle, value, context, kind)` | query probe; `context` is codec-encoded in the plan-field grammar — one part (a string, bytes, or an `i32`/`i64`/`u32`/`u64`) or an array of parts, nested as deep as the transport codec allows (`vitaminc_aead_value::transport::MAX_DEPTH`, 128 levels from the root of the encoded value; deeper is `STATUS_ENCODING` before the context is parsed, not an interop bug). Shape is identity: `[x]` is not `x`, so a probe passes the context in exactly the shape the field was sealed under (the guest's `context` module is the one home of the grammar and of which Rust context each shape spells). Local PRF/ORE only, never touches ZeroKMS | Host imports (two, both from the `cipherstash_transport` module #2099 defined): diff --git a/languages/golang/stackencrypt/guest/src/abi.rs b/languages/golang/stackencrypt/guest/src/abi.rs index fb20e6555..c7a1bcd2f 100644 --- a/languages/golang/stackencrypt/guest/src/abi.rs +++ b/languages/golang/stackencrypt/guest/src/abi.rs @@ -399,8 +399,11 @@ fn run_decrypt( /// frozen byte encoding. Local PRF/CLLW only — never touches ZeroKMS. /// /// The context is one part — a string, bytes, or an `i32`/`i64`/`u32`/`u64` -/// — or an array of parts, nested to any depth; [`crate::context`] is the -/// one home of that grammar and of which Rust context each shape spells. +/// — or an array of parts, which may nest as deep as the transport codec +/// allows (`vitaminc_aead_value::transport::MAX_DEPTH` levels, counted from +/// the root of the encoded value; deeper is refused as `STATUS_ENCODING` +/// before the context is parsed). [`crate::context`] is the one home of +/// that grammar and of which Rust context each shape spells. /// A part and the one-element array holding it are *different* contexts /// (`[x]` is PAE-framed, `x` is not), so a probe must pass the context in /// exactly the shape the field was sealed under: a plan field's context diff --git a/languages/golang/stackencrypt/guest/src/context.rs b/languages/golang/stackencrypt/guest/src/context.rs index b0d67c960..b5b90f685 100644 --- a/languages/golang/stackencrypt/guest/src/context.rs +++ b/languages/golang/stackencrypt/guest/src/context.rs @@ -36,7 +36,12 @@ //! //! A bare string is the flat form every plan used before this module: one //! text part, the field's whole context, same bytes as before. An array is -//! a list; it may nest. Text and bytes with the same content are distinct +//! a list; it may nest as deep as the transport codec allows +//! ([`MAX_DEPTH`](vitaminc_aead_value::transport::MAX_DEPTH) levels, +//! counted from the root of the encoded value — a plan's field context +//! starts two levels down), and a deeper value is refused as +//! [`STATUS_ENCODING`] by the codec before this module sees it. Text and +//! bytes with the same content are distinct //! on the PRF side (UTF-8 versus bytes encodings) though they share AAD //! bytes — the same distinction the Rust types make. Booleans, floats, //! null, undefined, objects and passthroughs are not contexts and are diff --git a/languages/golang/stackencrypt/guest/src/ops.rs b/languages/golang/stackencrypt/guest/src/ops.rs index d8815c835..984d651cc 100644 --- a/languages/golang/stackencrypt/guest/src/ops.rs +++ b/languages/golang/stackencrypt/guest/src/ops.rs @@ -144,8 +144,10 @@ where /// what makes query probes cheap. /// /// The context is one part — a string, bytes, or an `i32`/`i64`/`u32`/`u64` -/// — or an array of parts, nested to any depth, exactly as a plan field's; -/// [`crate::context`] is the one home of that grammar. Shape is identity: +/// — or an array of parts, nested as deep as the transport codec allows +/// ([`codec::MAX_DEPTH`] levels from the root of the encoded value; deeper +/// is [`STATUS_ENCODING`] before the context is parsed), exactly as a plan +/// field's; [`crate::context`] is the one home of that grammar. Shape is identity: /// `[x]` is a PAE-framed list and `x` is not, so a probe takes the context /// in the shape the field was sealed under — a plan field's context /// verbatim, a bare part for a Rust leaf sealed under that part, and the From e8a42ce3c7367a92584b2aa3ea4b6dd7b657719c Mon Sep 17 00:00:00 2001 From: Dan Draper <dan@cipherstash.com> Date: Fri, 11 Sep 2026 22:24:05 -0400 Subject: [PATCH 515/686] build(deps)!: vitaminc 0.4.0; the guest's plan context is `AadPiece` itself vitaminc 0.4.0 makes the parts view (`AadPiece`) the identity of a context on both derivations: it now implements `IntoPrfContext` and `MaybeEmpty`, and `Some(x)` as a PRF context is the one-element PAE, matching what `IntoAad` already produced (vitaminc#335 / cipherstash/cipherstash-suite#338). So the guest's `ContextPart` mirror goes. `parse_context` builds a `NonEmpty<AadPiece<'static>>` straight from the `FfiValue`, and both derivations are vitaminc's own impls; a small `borrowed` view keeps the per-row, per-output handoff free of payload copies (`AadPiece` is `#[non_exhaustive]`, so an unknown variant is cloned whole rather than refused). The pinned divergence test flips from `assert_ne!` to `assert_eq!` and becomes `a_one_element_list_is_some`; the module-doc caveat about `Option` contexts comes out. BREAKING CHANGE: index terms derived under an `Option<T>` context change bytes, because `Some(x)` no longer carries the `option-some` PRF domain. No fixture in this repository pins a term under an `Option` context. Claude-Session: https://claude.ai/code/session_01ECzyss8kvtnrRtq5tmpEbg --- .../golang/stackencrypt/guest/Cargo.lock | 49 +-- .../golang/stackencrypt/guest/Cargo.toml | 4 +- .../golang/stackencrypt/guest/src/context.rs | 280 ++++++------------ .../golang/stackencrypt/guest/src/ops.rs | 25 +- 4 files changed, 129 insertions(+), 229 deletions(-) diff --git a/languages/golang/stackencrypt/guest/Cargo.lock b/languages/golang/stackencrypt/guest/Cargo.lock index 19d259828..9fd104127 100644 --- a/languages/golang/stackencrypt/guest/Cargo.lock +++ b/languages/golang/stackencrypt/guest/Cargo.lock @@ -2405,9 +2405,9 @@ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" [[package]] name = "vitaminc" -version = "0.3.0" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6222f60c228766220f606938477f9c05eefe4d2f7c72c103de7b8467a37c366e" +checksum = "b26ab159600161f73a1a519a31562ec4732257991acc6cba180db826c7765035" dependencies = [ "vitaminc-aead", "vitaminc-encrypt", @@ -2418,13 +2418,14 @@ dependencies = [ [[package]] name = "vitaminc-aead" -version = "0.3.0" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f723c7419c1bfa2084dd0df351e915b62e0c52b04fd7a0d3bea5f1514bc7ba5d" +checksum = "52b4b439efcb59c4fb2b56aa59934d5907d6907172feb45b45df1eb9311f357e" dependencies = [ "bytes", "serde", "vitaminc-aead-derive", + "vitaminc-prf", "vitaminc-protected", "vitaminc-random", "zeroize", @@ -2432,9 +2433,9 @@ dependencies = [ [[package]] name = "vitaminc-aead-derive" -version = "0.3.0" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca4cd66b4531badaf794474a75cdde955067413392de4934b120e7757147e4a9" +checksum = "7fa56c62afe5cc393173876b83ee361246e341e855f7d45a334e1edd30c0018e" dependencies = [ "proc-macro2", "quote", @@ -2443,9 +2444,9 @@ dependencies = [ [[package]] name = "vitaminc-aead-value" -version = "0.3.0" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d63ef21540dd973b1cb8775c6bbf753834030cbd7ff3db6dc20bb8d0f5adfa43" +checksum = "ce93802e2ca4eba669ad520bf55e25074a4e32755b52664baa15e084614f3120" dependencies = [ "vitaminc-aead", "vitaminc-protected", @@ -2454,9 +2455,9 @@ dependencies = [ [[package]] name = "vitaminc-encrypt" -version = "0.3.0" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e804d414808812197b72bca14cbfa2e6ca0a432fc44dd832e933e392ced2da5" +checksum = "bbb81b908b8747fd74ffc198591849ccab8e0be52049d41b3d5d7bb8aed5342b" dependencies = [ "aes-gcm", "aws-lc-rs", @@ -2468,9 +2469,9 @@ dependencies = [ [[package]] name = "vitaminc-hmac" -version = "0.3.0" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55bfe9c3944f048938b2a96fef154c7193c0ffa3c89a5422a327ce17c387e9e2" +checksum = "0d9de7991de2c5e4526d9fc81ab2c153ba5c0af7b50bdc9f1bd19eddb10b8307" dependencies = [ "hmac", "sha2 0.11.0", @@ -2481,9 +2482,9 @@ dependencies = [ [[package]] name = "vitaminc-prf" -version = "0.3.0" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "403ad68fd482e7ee967ddddf4732f3675a1d2258661ef3e4510b48f7644374dd" +checksum = "6d8978944fc2fba04255b776b384ff71b9ed5abc4d5bce859a11cc5d6841cba9" dependencies = [ "mutants", "thiserror 2.0.20", @@ -2492,9 +2493,9 @@ dependencies = [ [[package]] name = "vitaminc-protected" -version = "0.3.0" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49e03c9c22e4c2e92c8a3b2320f0cc974193a6a2c80e4e8077da158aaaec4b85" +checksum = "9a31efcddf3193efbe1801959922159ffe60a1e1388bc993927d3d7242f144c7" dependencies = [ "bitvec", "digest 0.11.3", @@ -2508,9 +2509,9 @@ dependencies = [ [[package]] name = "vitaminc-protected-derive" -version = "0.3.0" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7554d432fda99706db29f6df3aa2fb4895949c67185957bfdcd60f603346654f" +checksum = "17fe15916386e0404b17c468e8c5a255ffc037e930512b5aa494802c8bba22d8" dependencies = [ "proc-macro2", "quote", @@ -2519,9 +2520,9 @@ dependencies = [ [[package]] name = "vitaminc-random" -version = "0.3.0" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30a8d04e8bcb2f210b33d1d6bd3bdc7e43a2e875c50677f8c5046ea578f084f2" +checksum = "4f8f9bc3db765f757068db06bd60f5e6fbfa8d546ed6da82246c33b6321fe45f" dependencies = [ "getrandom 0.4.3", "rand 0.10.2", @@ -2533,9 +2534,9 @@ dependencies = [ [[package]] name = "vitaminc-random-derives" -version = "0.3.0" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b7d6abc0f1cd8b227fe689fa269080fafd09bcee337253d8bda732818d49067" +checksum = "0931f7e84808c1072d77b30bab7f4e254334e038fc28929fbf7d647796b856cd" dependencies = [ "proc-macro2", "quote", @@ -2544,9 +2545,9 @@ dependencies = [ [[package]] name = "vitaminc-traits" -version = "0.3.0" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed8d02a2fb3c6e1af219d86fdeab78e87a9bf0afb771c0583337b35dced06981" +checksum = "7c806b05905d3a51c5440c64eb087bfe4b2a02e06dd4792ba9c53e005d42aeb5" dependencies = [ "anyhow", "bytes", diff --git a/languages/golang/stackencrypt/guest/Cargo.toml b/languages/golang/stackencrypt/guest/Cargo.toml index a0cac64b5..3d5b6dc0e 100644 --- a/languages/golang/stackencrypt/guest/Cargo.toml +++ b/languages/golang/stackencrypt/guest/Cargo.toml @@ -35,8 +35,8 @@ zerokms-protocol = { path = "../../../../packages/zerokms-protocol" } # (see the comment in packages/stack-encrypt/Cargo.toml). A different # version here fails at the `FfiValue: Decrypt` bound, since the aead crate # would be duplicated. -vitaminc-aead-value = "0.3.0" -vitaminc-protected = "0.3.0" +vitaminc-aead-value = "0.4.0" +vitaminc-protected = "0.4.0" futures = { version = "0.3", default-features = false, features = ["executor"] } serde = "1" diff --git a/languages/golang/stackencrypt/guest/src/context.rs b/languages/golang/stackencrypt/guest/src/context.rs index b5b90f685..de842b563 100644 --- a/languages/golang/stackencrypt/guest/src/context.rs +++ b/languages/golang/stackencrypt/guest/src/context.rs @@ -1,29 +1,27 @@ //! Structured contexts for the record and term paths. //! //! A plan field's context, and a term probe's, arrives as an [`FfiValue`] -//! and becomes a [`ContextPart`] tree: the guest's runtime form of the -//! context a Rust caller builds statically. A Rust -//! `#[derive(EncryptFrom)]` row sealed with `encrypt_into_with_context(row, -//! 7u64)` binds each field under `("users/age", 7u64)` — a `NonEmpty<(&str, -//! u64)>` — and a plan spells the same context as `["users/age", 7u64]`. -//! The two must agree byte for byte on *both* derivations a context feeds: +//! and becomes an [`AadPiece`] tree: vitaminc's runtime form of a context, +//! and the *identity* of one. vitaminc's law (pinned there by quickcheck +//! over every built-in context type) is that a context's two derivations +//! each equal the same derivation of its parts view: //! -//! * **AAD** (the ciphertext binding, and the ZeroKMS descriptor rendered -//! from its parts): a [`ContextPart`] is an [`AadPiece`], so a list -//! encodes as the PAE of its parts exactly as a tuple does, and -//! [`Descriptor`](stack_encrypt::Descriptor) renders it the same way. -//! * **PRF context** (the index terms' domain separation): a leaf hands -//! itself to the standard type's own [`IntoPrfContext`] impl — text is -//! `String`'s, an integer is that integer's — so it carries the same -//! typed encoding, and a list is [`PrfContext::pae`] of its parts, which -//! is what vitaminc's tuple impl produces. +//! ```text +//! x.into_aad() == x.into_aad_piece().into_aad() +//! x.into_prf_context() == x.into_aad_piece().into_prf_context() +//! ``` //! -//! Neither encoding is re-derived here: the leaves *are* the standard -//! impls, and the list framing is the one public `pae` both crates expose. -//! The unit tests below pin the agreement against `nonempty!(..).with(..)` -//! on both sides, and `tests/native_ops.rs` pins it end to end: a plan's -//! stored terms equal native probes under the tuple, and its `"c"` leaf -//! opens natively under the tuple. +//! So a Rust `#[derive(EncryptFrom)]` row sealed with +//! `encrypt_into_with_context(row, 7u64)`, which binds each field under +//! `("users/age", 7u64)` — a `NonEmpty<(&str, u64)>` — and a plan that +//! spells the same context as `["users/age", 7u64]` agree byte for byte on +//! the AAD (the ciphertext binding and the ZeroKMS descriptor rendered from +//! its parts) *and* on the PRF context (the index terms' domain separation). +//! Nothing is re-derived in this crate: the tree is handed to vitaminc's own +//! impls. The unit tests below pin the agreement against +//! `nonempty!(..).with(..)` on both sides, and `tests/native_ops.rs` pins it +//! end to end: a plan's stored terms equal native probes under the tuple, +//! and its `"c"` leaf opens natively under the tuple. //! //! # Shape //! @@ -41,11 +39,10 @@ //! counted from the root of the encoded value — a plan's field context //! starts two levels down), and a deeper value is refused as //! [`STATUS_ENCODING`] by the codec before this module sees it. Text and -//! bytes with the same content are distinct -//! on the PRF side (UTF-8 versus bytes encodings) though they share AAD -//! bytes — the same distinction the Rust types make. Booleans, floats, -//! null, undefined, objects and passthroughs are not contexts and are -//! refused as [`STATUS_ENCODING`]. +//! bytes with the same content are distinct on the PRF side (UTF-8 versus +//! bytes encodings) though they share AAD bytes — the same distinction the +//! Rust types make. Booleans, floats, null, undefined, objects and +//! passthroughs are not contexts and are refused as [`STATUS_ENCODING`]. //! //! # Which Rust contexts a list spells //! @@ -56,178 +53,54 @@ //! A flat three-element list is a different context (a three-part PAE) //! that no `.with()` chain produces; `a_left_nested_list_is_the_with_chain` //! pins both facts. -//! * A one-element list is *not* the bare part: it is PAE-framed, as -//! `Some(x)` is on the AAD side. On the PRF side vitaminc currently tags -//! `Some(x)` with an `option-some` domain, so `[x]` matches a Rust -//! `Some(x)` for the ciphertext and the descriptor but **not** for index -//! terms. That is a divergence inside vitaminc between a context's two -//! derivations, and vitaminc#335 removes it (`Some(x)` becomes the -//! one-element list on both sides, and this type becomes `AadPiece` -//! itself). Until it ships, a Rust row that Go must query must not be -//! sealed under an `Option` context; `a_one_element_list_is_not_the_bare_part` -//! and `a_one_element_list_is_not_yet_some_on_the_prf_side` pin the -//! current state so the fix shows up as a test change. +//! * `[x]` is `Some(x)` and `[]` is `None`, on both derivations. A +//! one-element list is *not* the bare part: it is PAE-framed, the bare +//! part is not; `a_one_element_list_is_some` and +//! `a_one_element_list_is_not_the_bare_part` pin both. //! //! # Emptiness //! -//! [`parse_context`] returns a [`NonEmpty`], proven once at the boundary: -//! an empty string or byte string is empty, an integer never is, and a list -//! is empty when every part is (so `[]` and `[""]` are, `["", 7]` is not) — -//! the rule vitaminc's `Option` and tuple impls follow. +//! [`parse_context`] returns a [`NonEmpty`], proven once at the boundary by +//! vitaminc's own rule for the tree: an empty string or byte string is +//! empty, an integer never is, and a list is empty when every part is (so +//! `[]` and `[""]` are, `["", 7]` is not) — the rule its `Option` and tuple +//! impls follow. use std::borrow::Cow; -use stack_encrypt::{Aad, AadPiece, IntoAad, IntoPrfContext, MaybeEmpty, NonEmpty, PrfContext}; +use stack_encrypt::{AadPiece, NonEmpty}; use vitaminc_aead_value::FfiValue; use vitaminc_protected::Controlled; use crate::status::STATUS_ENCODING; -/// One part of a context, or a list of parts. See the [module docs](self) -/// for the encoding each variant carries. -#[derive(Clone, Debug, PartialEq, Eq)] -pub enum ContextPart { - /// Text: [`AadPiece::Text`]; PRF-encoded as a `String`. - Text(String), - /// Opaque bytes: [`AadPiece::Bytes`]; PRF-encoded as a `Vec<u8>`. - Bytes(Vec<u8>), - /// [`AadPiece::I32`]; PRF-encoded as an `i32`. - I32(i32), - /// [`AadPiece::I64`]; PRF-encoded as an `i64`. - I64(i64), - /// [`AadPiece::U32`]; PRF-encoded as a `u32`. - U32(u32), - /// [`AadPiece::U64`]; PRF-encoded as a `u64`. - U64(u64), - /// [`AadPiece::List`]; PRF-encoded as the PAE of its parts. - List(Vec<ContextPart>), -} - -impl<'a> IntoAad<'a> for ContextPart { - fn into_aad(self) -> Aad<'a> { - self.into_aad_piece().into_aad() - } - - fn into_aad_piece(self) -> AadPiece<'a> { - match self { - ContextPart::Text(text) => AadPiece::Text(Cow::Owned(text)), - ContextPart::Bytes(bytes) => AadPiece::Bytes(Cow::Owned(bytes)), - ContextPart::I32(v) => AadPiece::I32(v), - ContextPart::I64(v) => AadPiece::I64(v), - ContextPart::U32(v) => AadPiece::U32(v), - ContextPart::U64(v) => AadPiece::U64(v), - ContextPart::List(parts) => { - AadPiece::List(parts.into_iter().map(IntoAad::into_aad_piece).collect()) - } - } - } -} - -impl<'a> IntoPrfContext<'a> for ContextPart { - fn into_prf_context(self) -> PrfContext<'a> { - match self { - ContextPart::Text(text) => text.into_prf_context(), - ContextPart::Bytes(bytes) => bytes.into_prf_context(), - ContextPart::I32(v) => v.into_prf_context(), - ContextPart::I64(v) => v.into_prf_context(), - ContextPart::U32(v) => v.into_prf_context(), - ContextPart::U64(v) => v.into_prf_context(), - ContextPart::List(parts) => { - pae_of(parts.into_iter().map(IntoPrfContext::into_prf_context)) - } - } - } -} - -/// Borrowed forms, so a plan's context is bound once at parse and then -/// handed to every output of every row without cloning the tree: the -/// leaves borrow (`Cow::Borrowed`, `&str`, `&[u8]`), and the consumers -/// (`encrypt_with_aad`, `into_pending`, `decrypt_into`, the term -/// derivations) take the context by value with a free lifetime and own -/// what they keep before any await. -impl<'a> IntoAad<'a> for &'a ContextPart { - fn into_aad(self) -> Aad<'a> { - self.into_aad_piece().into_aad() - } - - fn into_aad_piece(self) -> AadPiece<'a> { - match self { - ContextPart::Text(text) => AadPiece::Text(Cow::Borrowed(text)), - ContextPart::Bytes(bytes) => AadPiece::Bytes(Cow::Borrowed(bytes)), - ContextPart::I32(v) => AadPiece::I32(*v), - ContextPart::I64(v) => AadPiece::I64(*v), - ContextPart::U32(v) => AadPiece::U32(*v), - ContextPart::U64(v) => AadPiece::U64(*v), - ContextPart::List(parts) => { - AadPiece::List(parts.iter().map(IntoAad::into_aad_piece).collect()) - } - } - } -} - -impl<'a> IntoPrfContext<'a> for &'a ContextPart { - fn into_prf_context(self) -> PrfContext<'a> { - match self { - ContextPart::Text(text) => text.as_str().into_prf_context(), - ContextPart::Bytes(bytes) => bytes.as_slice().into_prf_context(), - ContextPart::I32(v) => v.into_prf_context(), - ContextPart::I64(v) => v.into_prf_context(), - ContextPart::U32(v) => v.into_prf_context(), - ContextPart::U64(v) => v.into_prf_context(), - ContextPart::List(parts) => pae_of(parts.iter().map(IntoPrfContext::into_prf_context)), - } - } -} - -/// The PAE of already-derived parts: what vitaminc's `(A, B)` impl does -/// for two, for any number. -fn pae_of<'a>(parts: impl Iterator<Item = PrfContext<'a>>) -> PrfContext<'static> { - let encoded: Vec<PrfContext<'a>> = parts.collect(); - let pieces: Vec<&[u8]> = encoded.iter().map(PrfContext::as_bytes).collect(); - PrfContext::pae(&pieces) -} - -impl MaybeEmpty for ContextPart { - fn is_empty(&self) -> bool { - match self { - ContextPart::Text(text) => text.is_empty(), - ContextPart::Bytes(bytes) => bytes.is_empty(), - ContextPart::I32(_) - | ContextPart::I64(_) - | ContextPart::U32(_) - | ContextPart::U64(_) => false, - ContextPart::List(parts) => parts.iter().all(MaybeEmpty::is_empty), - } - } -} - /// Parse a context from its decoded [`FfiValue`] form and prove it /// non-empty. Anything outside the shape in the [module docs](self), and /// an empty context, is [`STATUS_ENCODING`]. -pub fn parse_context(value: FfiValue) -> Result<NonEmpty<ContextPart>, u32> { - NonEmpty::new(part_of(value)?).map_err(|_| STATUS_ENCODING) +pub fn parse_context(value: FfiValue) -> Result<NonEmpty<AadPiece<'static>>, u32> { + NonEmpty::new(piece_of(value)?).map_err(|_| STATUS_ENCODING) } -fn part_of(value: FfiValue) -> Result<ContextPart, u32> { +fn piece_of(value: FfiValue) -> Result<AadPiece<'static>, u32> { Ok(match value { // Valid UTF-8 by `Utf8String`'s construction invariant; checked // rather than assumed because this is boundary code. The payload // moves out of its `Protected` rather than being copied: a context // is not secret, and the copy would only be wiped and freed. - FfiValue::String(s) => ContextPart::Text( + FfiValue::String(s) => AadPiece::Text(Cow::Owned( String::from_utf8(s.into_inner().risky_unwrap()).map_err(|_| STATUS_ENCODING)?, - ), - FfiValue::Bytes(bytes) => ContextPart::Bytes(bytes.risky_unwrap()), - FfiValue::Int32(v) => ContextPart::I32(v), - FfiValue::Int64(v) => ContextPart::I64(v), - FfiValue::UInt32(v) => ContextPart::U32(v), - FfiValue::UInt64(v) => ContextPart::U64(v), + )), + FfiValue::Bytes(bytes) => AadPiece::Bytes(Cow::Owned(bytes.risky_unwrap())), + FfiValue::Int32(v) => AadPiece::I32(v), + FfiValue::Int64(v) => AadPiece::I64(v), + FfiValue::UInt32(v) => AadPiece::U32(v), + FfiValue::UInt64(v) => AadPiece::U64(v), // Nesting depth is bounded by the codec's `MAX_DEPTH` before the // value reaches here. - FfiValue::Array(items) => ContextPart::List( + FfiValue::Array(items) => AadPiece::List( items .into_iter() - .map(part_of) + .map(piece_of) .collect::<Result<Vec<_>, u32>>()?, ), FfiValue::Null @@ -240,10 +113,38 @@ fn part_of(value: FfiValue) -> Result<ContextPart, u32> { }) } +/// A view of a context tree that borrows its text and bytes, so a plan's +/// context — parsed and proven once — can be handed to every output of +/// every row without copying the payloads. Integers are copied (they are +/// the payload); the list spine is rebuilt, which is the cost of a tree of +/// `Cow`s rather than a tree of references. +/// +/// `AadPiece` is `#[non_exhaustive]`, so a variant this crate does not know +/// is cloned whole rather than refused: the view must be the same context, +/// and a clone is. +pub fn borrowed<'b>(piece: &'b AadPiece<'_>) -> AadPiece<'b> { + match piece { + AadPiece::Text(text) => AadPiece::Text(Cow::Borrowed(text.as_ref())), + AadPiece::Bytes(bytes) => AadPiece::Bytes(Cow::Borrowed(bytes.as_ref())), + AadPiece::U8(v) => AadPiece::U8(*v), + AadPiece::U16(v) => AadPiece::U16(*v), + AadPiece::U32(v) => AadPiece::U32(*v), + AadPiece::U64(v) => AadPiece::U64(*v), + AadPiece::U128(v) => AadPiece::U128(*v), + AadPiece::I8(v) => AadPiece::I8(*v), + AadPiece::I16(v) => AadPiece::I16(*v), + AadPiece::I32(v) => AadPiece::I32(*v), + AadPiece::I64(v) => AadPiece::I64(*v), + AadPiece::I128(v) => AadPiece::I128(*v), + AadPiece::List(parts) => AadPiece::List(parts.iter().map(borrowed).collect()), + other => other.clone().into_owned(), + } +} + #[cfg(test)] mod tests { use super::*; - use stack_encrypt::nonempty; + use stack_encrypt::{nonempty, IntoAad, IntoPrfContext}; use vitaminc_protected::Protected; fn s(value: &str) -> FfiValue { @@ -253,7 +154,7 @@ mod tests { #[test] fn a_bare_string_is_the_flat_context() { let parsed = parse_context(s("users/age")).expect("flat context"); - assert_eq!(parsed.get(), &ContextPart::Text("users/age".to_string())); + assert_eq!(parsed.get(), &AadPiece::Text(Cow::Borrowed("users/age"))); assert_eq!( parsed.into_inner().into_aad().as_bytes(), "users/age".into_aad().as_bytes() @@ -308,9 +209,9 @@ mod tests { ); } - /// The borrowed impls are the owned ones without the clone. + /// The borrowed view is the same context as the owned tree. #[test] - fn borrowed_and_owned_forms_encode_alike() { + fn the_borrowed_view_encodes_as_the_owned_tree() { let parsed = parse_context(FfiValue::Array(vec![ s("users/age"), FfiValue::Array(vec![ @@ -320,19 +221,18 @@ mod tests { ])) .expect("context"); let owned = parsed.clone().into_inner(); - let borrowed = NonEmpty::new(parsed.get()).expect("a non-empty context borrows non-empty"); + let view = + NonEmpty::new(borrowed(parsed.get())).expect("a non-empty context borrows non-empty"); + assert_eq!(view.get(), &owned, "the view is a different tree"); assert_eq!( - borrowed.into_aad().as_bytes(), + view.clone().into_inner().into_aad().as_bytes(), owned.clone().into_aad().as_bytes(), - "AAD bytes differ between the borrowed and owned forms" + "AAD bytes differ between the borrowed view and the owned tree" ); assert_eq!( - NonEmpty::new(parsed.get()) - .expect("non-empty") - .into_prf_context() - .as_bytes(), + view.into_inner().into_prf_context().as_bytes(), owned.into_prf_context().as_bytes(), - "PRF bytes differ between the borrowed and owned forms" + "PRF bytes differ between the borrowed view and the owned tree" ); } @@ -372,12 +272,10 @@ mod tests { ); } - /// The state vitaminc#335 changes: `[x]` is `Some(x)` for the AAD and - /// the descriptor, and not yet for index terms. When the PRF `Option` - /// impl follows the parts view, the `assert_ne!` here flips to - /// `assert_eq!` and the module docs lose their caveat. + /// `[x]` is `Some(x)` on the AAD, the descriptor and the PRF side + /// (vitaminc 0.4.0 made the `Option` PRF context follow its parts view). #[test] - fn a_one_element_list_is_not_yet_some_on_the_prf_side() { + fn a_one_element_list_is_some() { use stack_encrypt::Descriptor; let list = parse_context(FfiValue::Array(vec![FfiValue::UInt64(7)])) .expect("list") @@ -393,10 +291,10 @@ mod tests { Descriptor::of(some).as_str(), "[x] and Some(x) render the same descriptor" ); - assert_ne!( + assert_eq!( list.into_prf_context().as_bytes(), some.into_prf_context().as_bytes(), - "vitaminc#335 has landed: [x] now equals Some(x) on the PRF side too — flip this to assert_eq! and drop the module-doc caveat" + "[x] and Some(x) share the PRF context" ); } diff --git a/languages/golang/stackencrypt/guest/src/ops.rs b/languages/golang/stackencrypt/guest/src/ops.rs index 984d651cc..9dd0d4377 100644 --- a/languages/golang/stackencrypt/guest/src/ops.rs +++ b/languages/golang/stackencrypt/guest/src/ops.rs @@ -37,15 +37,15 @@ use stack_encrypt::sem::{CllwOpeEncrypt, CllwOreEncrypt, DefaultMatch}; use stack_encrypt::target::Pending; use stack_encrypt::{ - BoxedPassthrough, CipherText, Decrypt, Element, Encrypt, IntoPrfContext, NonEmpty, SealedValue, - StackCipher, StackCipherText, + AadPiece, BoxedPassthrough, CipherText, Decrypt, Element, Encrypt, IntoPrfContext, NonEmpty, + SealedValue, StackCipher, StackCipherText, }; use stack_kms::DataKeySource; use vitaminc_aead_value::{transport as codec, FfiValue}; use vitaminc_protected::{Controlled, Protected}; use zeroize::Zeroizing; -use crate::context::{parse_context, ContextPart}; +use crate::context::{borrowed, parse_context}; use crate::status::{ status_for_error, status_for_term_error, STATUS_AUTH, STATUS_ENCODING, STATUS_INTERNAL, }; @@ -376,7 +376,7 @@ struct FieldPlan { /// opens under it — the cipher-directed `encrypt_with_aad` in /// [`build_row`] as much as the target-directed `decrypt_into` in /// [`decrypt_record`] — is under a context stack-encrypt's leaves accept. - context: NonEmpty<ContextPart>, + context: NonEmpty<AadPiece<'static>>, outputs: Vec<Output>, } @@ -424,7 +424,7 @@ fn parse_plan(value: FfiValue) -> Result<Vec<FieldPlan>, u32> { let FfiValue::Object(spec) = spec else { return Err(STATUS_ENCODING); }; - let mut context: Option<NonEmpty<ContextPart>> = None; + let mut context: Option<NonEmpty<AadPiece<'static>>> = None; let mut outputs: Option<Vec<Output>> = None; for (key, value) in spec { match key.as_str() { @@ -625,10 +625,10 @@ where .position(|(name, _)| name == &field.name) .ok_or(STATUS_ENCODING)?; let (name, value) = row.swap_remove(at); - // Borrowed from the plan once per field: the proof was made at - // parse time, so re-taking it over the same tree cannot fail, and - // `NonEmpty<&ContextPart>` is `Copy` for the outputs below. - let context = NonEmpty::new(field.context.get()).map_err(|_| STATUS_INTERNAL)?; + // A borrowed view of the plan's context, once per field: the proof + // was made at parse time, so re-taking it over the same tree cannot + // fail, and the view clones cheaply for each output below. + let context = NonEmpty::new(borrowed(field.context.get())).map_err(|_| STATUS_INTERNAL)?; // Terms first — they lift a copy of the scalar; the value itself is // consumed by the ciphertext path below. @@ -647,14 +647,14 @@ where continue; } let scalar = scalar.clone().ok_or(STATUS_INTERNAL)?; - let term = term_bytes(cipher, scalar, context, *output).await?; + let term = term_bytes(cipher, scalar, context.clone(), *output).await?; outputs.push((output.key(), Some(term))); } if field.outputs.contains(&Output::Ciphertext) { reject_passthrough_value(&value)?; let tree = value - .encrypt_with_aad(cipher, context) + .encrypt_with_aad(cipher, context.clone()) .map_err(|_| STATUS_INTERNAL)?; pendings.push(tree.into_pending(cipher, context)); } @@ -709,7 +709,8 @@ where if !field.outputs.contains(&Output::Ciphertext) { continue; } - let context = NonEmpty::new(field.context.get()).map_err(|_| STATUS_INTERNAL)?; + let context = + NonEmpty::new(borrowed(field.context.get())).map_err(|_| STATUS_INTERNAL)?; let at = row .iter() .position(|(name, _)| name == &field.name) From c9e7d211f9a09114ce940d30838b145e4c90c280 Mon Sep 17 00:00:00 2001 From: Dan Draper <dan@cipherstash.com> Date: Fri, 11 Sep 2026 23:07:07 -0400 Subject: [PATCH 516/686] feat(stack-encrypt)!: multi-keyset `StackCipher`, `KeysetCipher` for everything that mints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CIP-4037. A `StackCipher` was one client pinned to one keyset, so a process serving many tenants held many ciphers, and the wasm guest kept a handle table to tell them apart — a table every language binding would reimplement. An SDK never holds more than one workspace and the client key is one per device, so the only real multiplicity is one client with several keysets. That is now the crate's own capability. `StackCipher<K>` is client-scoped: one ZeroKMS client, many keysets. `init()` still loads the default keyset eagerly (the builder's, else the client's), so a misconfigured client fails at startup. `cipher.keyset(id | name).await` selects any other through a bounded least-recently-used cache (default 1024, `StackCipherBuilder::keyset_cache_size`), loading it from ZeroKMS on a miss — the one async point of selection; nothing stored depends on the cache, so eviction only costs the next lookup a round trip. `KeysetCipher<'k, K>` is the cipher bound to one keyset and what every operation that mints something binds to: `encrypt`, `encrypt_into`, the derive's `EncryptFrom` impls, and the term API. It is an owned handle (cipher reference plus shared keyset state), cheap to clone and hand around per tenant. `EncryptTarget` moves to it; `StackCipher` is a `DecryptTarget` only. Decrypting is not keyset-scoped: a `SealedValue` now carries the id of the keyset it was sealed under (16 raw UUID bytes after the version byte, bound into the leaf AAD beside it), so `StackCipher::decrypt` / `decrypt_into` open leaves from any keyset in one batch, with one `retrieve_keys` call per keyset and the keys back in request order. The same calls through a `KeysetCipher` are constrained: a leaf from any other keyset is `Error::ForeignKeyset { expected, found }`, refused before any key is retrieved (blanket `DecryptInto` / `DecryptField` impls, so a type implemented over `StackCipher` gets the constrained form for free). `Pending` carries the keyset scope its constructors were given (`&StackCipher` or `&KeysetCipher`, via `CipherScope`). Merging two scopes is `Error::KeysetMismatch`; a generate request with no scope is `Error::NoKeyset`; `CipherMismatch` still means two clients. Retrieve requests name their keyset (`Request::retrieve_data_key` gained it). The standalone term API (`equality_term`, `match_terms`, `ore_term`, `ope_term`) returns a `Pending` rather than a bare result, so probes combine and settle like the record path does. Under the local HMAC backend the pending carries no requests; ZeroKMS v2 derives every term at the server, and the API no longer states the local case as a contract. The wasm guest is adapted minimally here — its ops take a `KeysetCipher` where they mint and the ABI passes `default_keyset()` — so it keeps building; dropping its session table for `se_keyset` / `se_shutdown` and the per-call keyset selector is the second half of CIP-4037. BREAKING CHANGE: encrypt through `cipher.default_keyset()` (or `cipher.keyset(..).await`); `StackCipher::{encrypt, keyset_id, prf}` and the term methods moved to `KeysetCipher`, and the term methods now resolve to `stack_encrypt::Error`. `SealedValue::from_parts` / `into_parts` carry the keyset id first, and the frozen leaf layout gains 16 bytes after the version byte with no version bump: the crate is unpublished and nothing is stored, and the leaf AAD change means an older leaf fails authentication rather than parsing. `Request::retrieve_data_key` takes the keyset id. Claude-Session: https://claude.ai/code/session_01ECzyss8kvtnrRtq5tmpEbg --- .../golang/stackencrypt/guest/src/abi.rs | 11 +- .../golang/stackencrypt/guest/src/ops.rs | 30 +- .../golang/stackencrypt/guest/src/status.rs | 16 +- .../stackencrypt/guest/tests/native_ops.rs | 230 ++++++-- .../stack-encrypt-derive/docs/attributes.md | 2 +- packages/stack-encrypt-derive/src/decrypt.rs | 32 +- packages/stack-encrypt-derive/src/encrypt.rs | 46 +- packages/stack-encrypt-derive/src/lib.rs | 29 +- packages/stack-encrypt-derive/src/shape.rs | 33 +- .../examples/encrypted_record.rs | 15 +- packages/stack-encrypt/examples/mixed_user.rs | 2 +- .../stack-encrypt/examples/search_terms.rs | 5 +- .../stack-encrypt/examples/zerokms_auth.rs | 21 +- packages/stack-encrypt/src/cipher.rs | 521 +++++++++++++----- packages/stack-encrypt/src/descriptor.rs | 4 +- packages/stack-encrypt/src/keyset.rs | 309 +++++++++++ packages/stack-encrypt/src/lib.rs | 48 +- packages/stack-encrypt/src/sem/mod.rs | 129 +++-- packages/stack-encrypt/src/sem/tokenize.rs | 2 +- packages/stack-encrypt/src/target/mod.rs | 203 ++++--- packages/stack-encrypt/src/target/pending.rs | 505 ++++++++++++++--- packages/stack-encrypt/src/target/request.rs | 41 +- packages/stack-encrypt/tests/derive.rs | 83 ++- packages/stack-encrypt/tests/descriptor.rs | 32 +- packages/stack-encrypt/tests/frozen_bytes.rs | 88 ++- packages/stack-encrypt/tests/keysets.rs | 385 +++++++++++++ packages/stack-encrypt/tests/roundtrip.rs | 91 +-- packages/stack-encrypt/tests/sem_terms.rs | 66 ++- packages/stack-encrypt/tests/target.rs | 113 ++-- packages/stack-encrypt/tests/term_bytes.rs | 20 +- .../stack-encrypt/tests/ui/bare_context.rs | 4 +- .../tests/ui/leaf_without_context.rs | 4 +- .../tests/ui/leaf_without_context.stderr | 41 +- .../ui/nested_leaf_without_context.stderr | 4 +- .../tests/ui/pass/which_form_compiles.rs | 12 +- 35 files changed, 2470 insertions(+), 707 deletions(-) create mode 100644 packages/stack-encrypt/src/keyset.rs create mode 100644 packages/stack-encrypt/tests/keysets.rs diff --git a/languages/golang/stackencrypt/guest/src/abi.rs b/languages/golang/stackencrypt/guest/src/abi.rs index c7a1bcd2f..a06b6819e 100644 --- a/languages/golang/stackencrypt/guest/src/abi.rs +++ b/languages/golang/stackencrypt/guest/src/abi.rs @@ -367,7 +367,12 @@ fn run_encrypt( let value = input(val_ptr, val_len)?; let aad = input(aad_ptr, aad_len)?; with_cipher(handle, |cipher| { - block_on(ops::encrypt_value(cipher, value, aad, as_element)) + block_on(ops::encrypt_value( + &cipher.default_keyset(), + value, + aad, + as_element, + )) }) })) .unwrap_or(Err(STATUS_INTERNAL)) @@ -425,7 +430,7 @@ pub unsafe extern "C" fn se_term( let value = input(val_ptr, val_len)?; let context = input(ctx_ptr, ctx_len)?; with_cipher(handle, |cipher| { - block_on(ops::term(cipher, value, context, kind)) + block_on(ops::term(&cipher.default_keyset(), value, context, kind)) }) })) .unwrap_or(Err(STATUS_INTERNAL)) @@ -454,7 +459,7 @@ pub unsafe extern "C" fn se_encrypt_record( let source = input(src_ptr, src_len)?; let plan = input(plan_ptr, plan_len)?; with_cipher(handle, |cipher| { - block_on(ops::encrypt_record(cipher, source, plan)) + block_on(ops::encrypt_record(&cipher.default_keyset(), source, plan)) }) })) .unwrap_or(Err(STATUS_INTERNAL)) diff --git a/languages/golang/stackencrypt/guest/src/ops.rs b/languages/golang/stackencrypt/guest/src/ops.rs index 9dd0d4377..fc887a2fa 100644 --- a/languages/golang/stackencrypt/guest/src/ops.rs +++ b/languages/golang/stackencrypt/guest/src/ops.rs @@ -1,4 +1,4 @@ -//! The guest's operations, written against `StackCipher<K>` for any +//! The guest's operations, written against `StackCipher<K>` / `KeysetCipher<K>` for any //! [`DataKeySource`] so they compile — and their tests run — on the native //! host target with `FakeDataKeySource`. The wasm32-only [`crate::abi`] //! module wires them to the session table and the packed ABI; nothing in @@ -37,8 +37,8 @@ use stack_encrypt::sem::{CllwOpeEncrypt, CllwOreEncrypt, DefaultMatch}; use stack_encrypt::target::Pending; use stack_encrypt::{ - AadPiece, BoxedPassthrough, CipherText, Decrypt, Element, Encrypt, IntoPrfContext, NonEmpty, - SealedValue, StackCipher, StackCipherText, + AadPiece, BoxedPassthrough, CipherText, Decrypt, Element, Encrypt, IntoPrfContext, + KeysetCipher, NonEmpty, SealedValue, StackCipher, StackCipherText, }; use stack_kms::DataKeySource; use vitaminc_aead_value::{transport as codec, FfiValue}; @@ -46,9 +46,7 @@ use vitaminc_protected::{Controlled, Protected}; use zeroize::Zeroizing; use crate::context::{borrowed, parse_context}; -use crate::status::{ - status_for_error, status_for_term_error, STATUS_AUTH, STATUS_ENCODING, STATUS_INTERNAL, -}; +use crate::status::{status_for_error, STATUS_AUTH, STATUS_ENCODING, STATUS_INTERNAL}; /// Term kinds for `se_term`, part of the guest/host contract (the Go host /// mirrors these values). @@ -83,7 +81,7 @@ type BytesTree = CipherText<Vec<u8>, BoxedPassthrough>; /// the boundary when the plan or the term's context is parsed, and refused /// as [`STATUS_ENCODING`] when empty. pub async fn encrypt_value<K>( - cipher: &StackCipher<K>, + cipher: &KeysetCipher<'_, K>, value: &[u8], aad: &[u8], as_element: bool, @@ -154,7 +152,7 @@ where /// same parts as a (left-nested) list for a Rust row sealed under an /// extended context. pub async fn term<K>( - cipher: &StackCipher<K>, + cipher: &KeysetCipher<'_, K>, value: &[u8], context: &[u8], kind: u32, @@ -217,7 +215,7 @@ fn scalar_of(value: &FfiValue) -> Result<Scalar, u32> { /// combinations (floats or booleans under equality, anything non-text under /// match) are [`STATUS_ENCODING`] — the scheme does not define them. async fn term_bytes<'c, K, D>( - cipher: &StackCipher<K>, + cipher: &KeysetCipher<'_, K>, scalar: Scalar, context: NonEmpty<D>, output: Output, @@ -226,7 +224,7 @@ where K: DataKeySource + Sync, D: IntoPrfContext<'c>, { - let term_err = |e| status_for_term_error(&e); + let term_err = |e| status_for_error(&e); match output { Output::Ciphertext => Err(STATUS_ENCODING), Output::Equality => { @@ -290,7 +288,7 @@ where /// The `AsRef<[u8]>` on the output is what turns the typed CLLW ciphertext /// into the frozen raw-bytes encoding. async fn ore<'c, K, T, D>( - cipher: &StackCipher<K>, + cipher: &KeysetCipher<'_, K>, value: T, context: NonEmpty<D>, ) -> Result<Vec<u8>, u32> @@ -304,12 +302,12 @@ where .ore_term(value, context) .await .map(|t| t.as_ref().to_vec()) - .map_err(|e| status_for_term_error(&e)) + .map_err(|e| status_for_error(&e)) } /// See [`ore`]. async fn ope<'c, K, T, D>( - cipher: &StackCipher<K>, + cipher: &KeysetCipher<'_, K>, value: T, context: NonEmpty<D>, ) -> Result<Vec<u8>, u32> @@ -323,7 +321,7 @@ where .ope_term(value, context) .await .map(|t| t.as_ref().to_vec()) - .map_err(|e| status_for_term_error(&e)) + .map_err(|e| status_for_error(&e)) } // ============================================================================= @@ -533,7 +531,7 @@ fn reject_passthrough_tree(tree: &StackCipherText) -> Result<(), u32> { /// side uses aead-value's tagged types too. This is by design, not a defect /// in either side; making the derive tagged is a separate follow-up. pub async fn encrypt_record<K>( - cipher: &StackCipher<K>, + cipher: &KeysetCipher<'_, K>, source: &[u8], plan: &[u8], ) -> Result<Vec<u8>, u32> @@ -607,7 +605,7 @@ where /// returning the row skeleton. The plan drives the iteration so the output /// field order is the plan's; the row must contain exactly the plan's fields. async fn build_row<'c, K>( - cipher: &'c StackCipher<K>, + cipher: &'c KeysetCipher<'_, K>, mut row: Vec<(String, FfiValue)>, plan: &[FieldPlan], pendings: &mut Vec<Pending<'c, StackCipherText, K>>, diff --git a/languages/golang/stackencrypt/guest/src/status.rs b/languages/golang/stackencrypt/guest/src/status.rs index 2a9162855..980f89921 100644 --- a/languages/golang/stackencrypt/guest/src/status.rs +++ b/languages/golang/stackencrypt/guest/src/status.rs @@ -86,15 +86,6 @@ pub fn status_for_error(error: &stack_encrypt::Error) -> u32 { } } -/// Map a term-derivation error directly (the term entry points return -/// [`stack_encrypt::sem::TermError`], not the sealing error). An empty -/// context never reaches a term — it is [`STATUS_ENCODING`] at the boundary, -/// where the context is proven — so every term error is a derivation -/// failure. -pub fn status_for_term_error(_: &stack_encrypt::sem::TermError) -> u32 { - STATUS_TERM -} - // These matches are deliberately exhaustive — no `_` arms. None of the // stack-kms error enums is `#[non_exhaustive]`, so exhaustiveness is free // compiler coverage: `GenerateKeyError` already grew `Unauthorized` / @@ -285,8 +276,13 @@ mod tests { #[test] fn term_errors_are_derivation_failures() { + // An empty context never reaches a term — it is `STATUS_ENCODING` + // at the boundary, where the context is proven — so every term + // error that does arrive is a derivation failure. assert_eq!( - status_for_term_error(&stack_encrypt::sem::TermError::EmptyTermText), + status_for_error(&stack_encrypt::Error::Term( + stack_encrypt::sem::TermError::EmptyTermText + )), STATUS_TERM ); } diff --git a/languages/golang/stackencrypt/guest/tests/native_ops.rs b/languages/golang/stackencrypt/guest/tests/native_ops.rs index 72a39737d..7f71e8534 100644 --- a/languages/golang/stackencrypt/guest/tests/native_ops.rs +++ b/languages/golang/stackencrypt/guest/tests/native_ops.rs @@ -15,7 +15,8 @@ use std::borrow::Cow; use std::sync::atomic::{AtomicUsize, Ordering}; -use futures::executor::block_on; +use std::future::IntoFuture; + use stack_encrypt::sem::DefaultMatch; use stack_encrypt::{nonempty, Aad, CipherText, Decrypt, Encrypt, SealedValue, StackCipher}; use stack_encrypt_guest::ops::{self, TERM_EQUALITY, TERM_MATCH, TERM_OPE, TERM_ORE}; @@ -29,6 +30,12 @@ use vitaminc_aead_value::{transport as codec, FfiValue}; use vitaminc_protected::{Controlled, Protected}; use zerokms_protocol::{IdentifiedBy, UnverifiedContext}; +/// `futures::executor::block_on` over anything awaitable: the term API +/// returns a `Pending`, which is `IntoFuture` rather than `Future`. +fn block_on<F: IntoFuture>(f: F) -> F::Output { + futures::executor::block_on(f.into_future()) +} + // ============================================================================= // Harness // ============================================================================= @@ -201,7 +208,7 @@ fn value_round_trips_through_the_guest_ops() { ]); let ct = block_on(ops::encrypt_value( - &cipher, + &cipher.default_keyset(), &encode(value), b"users/42", false, @@ -227,7 +234,7 @@ fn value_round_trips_through_the_guest_ops() { fn element_mode_round_trips() { let cipher = cipher(); let ct = block_on(ops::encrypt_value( - &cipher, + &cipher.default_keyset(), &encode(s("row-0")), b"users", true, @@ -250,7 +257,7 @@ fn guest_leaves_are_the_frozen_storage_encoding() { // `SealedValue::from_bytes` storage format — a native cipher opens it. let cipher = cipher(); let ct = block_on(ops::encrypt_value( - &cipher, + &cipher.default_keyset(), &encode(s("durable")), b"ctx", false, @@ -274,8 +281,13 @@ fn guest_leaves_are_the_frozen_storage_encoding() { #[test] fn wrong_aad_and_malformed_inputs_map_to_statuses() { let cipher = cipher(); - let ct = - block_on(ops::encrypt_value(&cipher, &encode(s("x")), b"ctx", false)).expect("encrypt"); + let ct = block_on(ops::encrypt_value( + &cipher.default_keyset(), + &encode(s("x")), + b"ctx", + false, + )) + .expect("encrypt"); // Wrong AAD: authentication, not encoding. (The fake key source ignores // descriptors; against ZeroKMS the retrieve is refused first, as @@ -286,7 +298,12 @@ fn wrong_aad_and_malformed_inputs_map_to_statuses() { ); // Garbage transport bytes on either path: encoding. assert_eq!( - block_on(ops::encrypt_value(&cipher, b"\xffgarbage", b"ctx", false)), + block_on(ops::encrypt_value( + &cipher.default_keyset(), + b"\xffgarbage", + b"ctx", + false + )), Err(STATUS_ENCODING) ); assert_eq!( @@ -322,8 +339,13 @@ fn an_empty_aad_round_trips_on_the_value_paths() { let value = encode(s("x")); for as_element in [false, true] { - let ct = block_on(ops::encrypt_value(&cipher, &value, b"", as_element)) - .expect("encrypt under an empty aad"); + let ct = block_on(ops::encrypt_value( + &cipher.default_keyset(), + &value, + b"", + as_element, + )) + .expect("encrypt under an empty aad"); let out = block_on(ops::decrypt_value(&cipher, &ct, b"", as_element)) .expect("decrypt under an empty aad"); assert_eq!(out, value, "element: {as_element}"); @@ -339,7 +361,13 @@ fn an_empty_aad_round_trips_on_the_value_paths() { // Odd-looking but non-empty bytes are a context too, and bind. let zeros = &[0u8; 8][..]; - let ct = block_on(ops::encrypt_value(&cipher, &value, zeros, false)).expect("encrypt"); + let ct = block_on(ops::encrypt_value( + &cipher.default_keyset(), + &value, + zeros, + false, + )) + .expect("encrypt"); let opened = decode(&block_on(ops::decrypt_value(&cipher, &ct, zeros, false)).expect("decrypt")); assert_eq!(text(&opened), "x"); @@ -359,75 +387,100 @@ fn guest_terms_match_the_native_sem_derivations() { let ctx = encode(s("users/age")); let eq = block_on(ops::term( - &cipher, + &cipher.default_keyset(), &encode(FfiValue::UInt32(42)), &ctx, TERM_EQUALITY, )) .expect("eq term"); - let native = block_on(cipher.equality_term(42u32, nonempty!("users/age"))).expect("native eq"); + let native = block_on( + cipher + .default_keyset() + .equality_term(42u32, nonempty!("users/age")), + ) + .expect("native eq"); assert_eq!(eq, native.as_bytes()); let ore = block_on(ops::term( - &cipher, + &cipher.default_keyset(), &encode(FfiValue::UInt32(42)), &ctx, TERM_ORE, )) .expect("ore term"); - let native = block_on(cipher.ore_term(42u32, nonempty!("users/age"))).expect("native ore"); + let native = block_on( + cipher + .default_keyset() + .ore_term(42u32, nonempty!("users/age")), + ) + .expect("native ore"); assert_eq!(ore, native.as_ref()); let ope = block_on(ops::term( - &cipher, + &cipher.default_keyset(), &encode(FfiValue::UInt32(42)), &ctx, TERM_OPE, )) .expect("ope term"); - let native = block_on(cipher.ope_term(42u32, nonempty!("users/age"))).expect("native ope"); + let native = block_on( + cipher + .default_keyset() + .ope_term(42u32, nonempty!("users/age")), + ) + .expect("native ope"); assert_eq!(ope, native.as_ref()); let m = block_on(ops::term( - &cipher, + &cipher.default_keyset(), &encode(s("alice smith")), &encode(s("users/name")), TERM_MATCH, )) .expect("match term"); - let native = - block_on(cipher.match_terms::<DefaultMatch>("alice smith", nonempty!("users/name"))) - .expect("native"); + let native = block_on( + cipher + .default_keyset() + .match_terms::<DefaultMatch>("alice smith", nonempty!("users/name")), + ) + .expect("native"); assert_eq!(m, native.to_bytes()); // Strings and bytes have distinct PRF encodings — the guest must keep // them apart even when their raw bytes are equal. let eq_text = block_on(ops::term( - &cipher, + &cipher.default_keyset(), &encode(s("ab")), &encode(s("f")), TERM_EQUALITY, )) .expect("text term"); let eq_bytes = block_on(ops::term( - &cipher, + &cipher.default_keyset(), &encode(FfiValue::Bytes(Protected::new(b"ab".to_vec()))), &encode(s("f")), TERM_EQUALITY, )) .expect("bytes term"); assert_ne!(eq_text, eq_bytes); - let native_text = - block_on(cipher.equality_term("ab".to_string(), nonempty!("f"))).expect("native"); + let native_text = block_on( + cipher + .default_keyset() + .equality_term("ab".to_string(), nonempty!("f")), + ) + .expect("native"); assert_eq!(eq_text, native_text.as_bytes()); - let native_bytes = - block_on(cipher.equality_term(Protected::new(b"ab".to_vec()), nonempty!("f"))) - .expect("native"); + let native_bytes = block_on( + cipher + .default_keyset() + .equality_term(Protected::new(b"ab".to_vec()), nonempty!("f")), + ) + .expect("native"); assert_eq!(eq_bytes, native_bytes.as_bytes()); // Variable-width CLLW output for strings. let ore_s = block_on(ops::term( - &cipher, + &cipher.default_keyset(), &encode(s("alice")), &encode(s("users/name")), TERM_ORE, @@ -457,14 +510,19 @@ fn unsupported_term_inputs_are_encoding_errors() { (FfiValue::UInt32(1), 99), ] { assert_eq!( - block_on(ops::term(&cipher, &encode(value), &ctx, kind)), + block_on(ops::term( + &cipher.default_keyset(), + &encode(value), + &ctx, + kind + )), Err(STATUS_ENCODING), "kind {kind}" ); } assert_eq!( block_on(ops::term( - &cipher, + &cipher.default_keyset(), &encode(FfiValue::UInt32(1)), &encode(s("")), TERM_EQUALITY @@ -483,7 +541,7 @@ fn unsupported_term_inputs_are_encoding_errors() { ] { assert_eq!( block_on(ops::term( - &cipher, + &cipher.default_keyset(), &encode(FfiValue::UInt32(1)), &context, TERM_EQUALITY @@ -507,7 +565,12 @@ fn a_record_batch_encrypts_in_one_call_and_round_trips() { row(41, "carol park"), ])); - let record = block_on(ops::encrypt_record(&cipher, &source, &plan())).expect("encrypt records"); + let record = block_on(ops::encrypt_record( + &cipher.default_keyset(), + &source, + &plan(), + )) + .expect("encrypt records"); // Three rows, two ciphertext fields each: still exactly one call. assert_eq!(cipher.kms().generate_calls.load(Ordering::SeqCst), 1); @@ -536,7 +599,12 @@ fn a_record_batch_encrypts_in_one_call_and_round_trips() { fn a_forged_passthrough_ciphertext_slot_is_rejected_not_decrypted() { let cipher = cipher(); let source = encode(row(29, "alice smith")); - let record = block_on(ops::encrypt_record(&cipher, &source, &plan())).expect("encrypt record"); + let record = block_on(ops::encrypt_record( + &cipher.default_keyset(), + &source, + &plan(), + )) + .expect("encrypt record"); let CipherText::Map(mut fields) = decode_tree(&record) else { panic!("expected a field map"); @@ -585,7 +653,11 @@ fn a_passthrough_source_value_is_refused_a_ciphertext_slot() { )])); let source = encode(obj(vec![("age", age)])); assert_eq!( - block_on(ops::encrypt_record(&cipher, &source, &plan)), + block_on(ops::encrypt_record( + &cipher.default_keyset(), + &source, + &plan + )), Err(STATUS_ENCODING) ); } @@ -595,7 +667,7 @@ fn a_passthrough_source_value_is_refused_a_ciphertext_slot() { fn record_terms_equal_the_native_derivations_and_probe_them() { let cipher = cipher(); let record = block_on(ops::encrypt_record( - &cipher, + &cipher.default_keyset(), &encode(row(34, "alice smith")), &plan(), )) @@ -620,17 +692,30 @@ fn record_terms_equal_the_native_derivations_and_probe_them() { // The stored terms are byte-identical to query-time probes built the // native way — the property that makes the index searchable. - let eq_probe = block_on(cipher.equality_term(34u32, nonempty!("users/age"))).expect("probe"); + let eq_probe = block_on( + cipher + .default_keyset() + .equality_term(34u32, nonempty!("users/age")), + ) + .expect("probe"); assert_eq!(term_bytes(&age_outputs[1].1), eq_probe.as_bytes()); - let ore_probe = block_on(cipher.ore_term(34u32, nonempty!("users/age"))).expect("probe"); + let ore_probe = block_on( + cipher + .default_keyset() + .ore_term(34u32, nonempty!("users/age")), + ) + .expect("probe"); assert_eq!(term_bytes(&age_outputs[2].1), ore_probe.as_ref()); let (_, CipherText::Map(name_outputs)) = &fields[1] else { panic!("expected an output map for the second field"); }; - let match_probe = - block_on(cipher.match_terms::<DefaultMatch>("alice smith", nonempty!("users/name"))) - .expect("probe"); + let match_probe = block_on( + cipher + .default_keyset() + .match_terms::<DefaultMatch>("alice smith", nonempty!("users/name")), + ) + .expect("probe"); assert_eq!(term_bytes(&name_outputs[1].1), match_probe.to_bytes()); // And the "c" node is an ordinary value-model ciphertext bound to the @@ -671,7 +756,7 @@ fn extended_plan() -> Vec<u8> { fn a_structured_plan_context_seals_what_the_native_extended_context_does() { let cipher = cipher(); let record = block_on(ops::encrypt_record( - &cipher, + &cipher.default_keyset(), &encode(row(34, "alice smith")), &extended_plan(), )) @@ -685,11 +770,16 @@ fn a_structured_plan_context_seals_what_the_native_extended_context_does() { }; let native = nonempty!("users/age").with(7u64); - let eq_probe = block_on(cipher.equality_term(34u32, native)).expect("probe"); + let eq_probe = block_on(cipher.default_keyset().equality_term(34u32, native)).expect("probe"); assert_eq!(term_bytes(&age_outputs[1].1), eq_probe.as_bytes()); - let ore_probe = block_on(cipher.ore_term(34u32, native)).expect("probe"); + let ore_probe = block_on(cipher.default_keyset().ore_term(34u32, native)).expect("probe"); assert_eq!(term_bytes(&age_outputs[2].1), ore_probe.as_ref()); - let flat_probe = block_on(cipher.equality_term(34u32, nonempty!("users/age"))).expect("probe"); + let flat_probe = block_on( + cipher + .default_keyset() + .equality_term(34u32, nonempty!("users/age")), + ) + .expect("probe"); assert_ne!( term_bytes(&age_outputs[1].1), flat_probe.as_bytes(), @@ -697,7 +787,7 @@ fn a_structured_plan_context_seals_what_the_native_extended_context_does() { ); let guest_probe = block_on(ops::term( - &cipher, + &cipher.default_keyset(), &encode(FfiValue::UInt32(34)), &encode(extended("age")), TERM_EQUALITY, @@ -709,7 +799,9 @@ fn a_structured_plan_context_seals_what_the_native_extended_context_does() { panic!("expected an output map for the second field"); }; let match_probe = block_on( - cipher.match_terms::<DefaultMatch>("alice smith", nonempty!("users/name").with(7u64)), + cipher + .default_keyset() + .match_terms::<DefaultMatch>("alice smith", nonempty!("users/name").with(7u64)), ) .expect("probe"); assert_eq!(term_bytes(&name_outputs[1].1), match_probe.to_bytes()); @@ -731,12 +823,13 @@ fn a_structured_plan_context_seals_what_the_native_extended_context_does() { #[test] fn a_natively_sealed_field_under_an_extended_context_opens_through_a_plan() { let cipher = cipher(); + let keyset = cipher.default_keyset(); let native = nonempty!("users/age").with(7u64); let sealed = block_on( FfiValue::UInt32(34) - .encrypt_with_aad(&cipher, native) + .encrypt_with_aad(&keyset, native) .expect("encrypt") - .seal(&cipher, native), + .seal(&keyset, native), ) .expect("seal"); let CipherText::Single(leaf) = sealed else { @@ -795,7 +888,11 @@ fn a_structured_plan_context_is_validated_at_parse() { ), ] { assert_eq!( - block_on(ops::encrypt_record(&cipher, &source, &plan_with(bad))), + block_on(ops::encrypt_record( + &cipher.default_keyset(), + &source, + &plan_with(bad) + )), Err(STATUS_ENCODING), "a plan context of {label} must be refused at parse" ); @@ -808,7 +905,7 @@ fn a_structured_plan_context_is_validated_at_parse() { // Non-empty by the tuple rule: one part carries bytes. let sealed = block_on(ops::encrypt_record( - &cipher, + &cipher.default_keyset(), &source, &plan_with(FfiValue::Array(vec![s(""), FfiValue::UInt64(7)])), )) @@ -829,7 +926,11 @@ fn record_shape_violations_are_encoding_errors() { // source, and malformed plans. let missing = encode(obj(vec![("age", FfiValue::UInt32(1))])); assert_eq!( - block_on(ops::encrypt_record(&cipher, &missing, &plan())), + block_on(ops::encrypt_record( + &cipher.default_keyset(), + &missing, + &plan() + )), Err(STATUS_ENCODING) ); @@ -839,7 +940,11 @@ fn record_shape_violations_are_encoding_errors() { ("stray", s("b")), ])); assert_eq!( - block_on(ops::encrypt_record(&cipher, &extra, &plan())), + block_on(ops::encrypt_record( + &cipher.default_keyset(), + &extra, + &plan() + )), Err(STATUS_ENCODING) ); @@ -848,7 +953,11 @@ fn record_shape_violations_are_encoding_errors() { ("name", s("a")), ])); assert_eq!( - block_on(ops::encrypt_record(&cipher, &nested, &plan())), + block_on(ops::encrypt_record( + &cipher.default_keyset(), + &nested, + &plan() + )), Err(STATUS_ENCODING), "a term-indexed field must be a scalar" ); @@ -880,7 +989,7 @@ fn record_shape_violations_are_encoding_errors() { ] { assert_eq!( block_on(ops::encrypt_record( - &cipher, + &cipher.default_keyset(), &encode(obj(vec![("f", FfiValue::UInt32(1))])), &encode(bad_plan), )), @@ -912,7 +1021,11 @@ fn an_empty_plan_context_is_refused_before_anything_is_sealed() { let source = encode(obj(vec![("f", FfiValue::UInt32(1))])); assert_eq!( - block_on(ops::encrypt_record(&cipher, &source, &bad_plan)), + block_on(ops::encrypt_record( + &cipher.default_keyset(), + &source, + &bad_plan + )), Err(STATUS_ENCODING) ); assert_eq!( @@ -934,7 +1047,12 @@ fn an_empty_plan_context_is_refused_before_anything_is_sealed() { ("outputs", FfiValue::Array(vec![s("c")])), ]), )])); - let sealed = block_on(ops::encrypt_record(&cipher, &source, &odd_plan)).expect("encrypt"); + let sealed = block_on(ops::encrypt_record( + &cipher.default_keyset(), + &source, + &odd_plan, + )) + .expect("encrypt"); let opened = block_on(ops::decrypt_record(&cipher, &sealed, &odd_plan)).expect("decrypt"); let FfiValue::Object(fields) = decode(&opened) else { panic!("a record decrypts to an object"); diff --git a/packages/stack-encrypt-derive/docs/attributes.md b/packages/stack-encrypt-derive/docs/attributes.md index 80dcfd776..977865b51 100644 --- a/packages/stack-encrypt-derive/docs/attributes.md +++ b/packages/stack-encrypt-derive/docs/attributes.md @@ -72,7 +72,7 @@ which a nested `struct` derive (carrying its own contexts) composes with them and a leaf accepts only as a `NonEmpty<T>`. A context passed by the caller extends every field's: under -`user.encrypt_into_with_context(&cipher, 7u64)` the `age` field is derived +`user.encrypt_into_with_context(&keyset, 7u64)` the `age` field is derived under `("users/age", 7u64)`, and a query site probes it under `nonempty!("users/age").with(7u64)`. This is how a field is bound to its record as well as its name — a record id, say — without the type having to diff --git a/packages/stack-encrypt-derive/src/decrypt.rs b/packages/stack-encrypt-derive/src/decrypt.rs index 58a138af1..8743cbbf8 100644 --- a/packages/stack-encrypt-derive/src/decrypt.rs +++ b/packages/stack-encrypt-derive/src/decrypt.rs @@ -84,33 +84,34 @@ fn impl_block( fn decrypt_field_impl(input: &DeriveInput, krate: &Path) -> TokenStream { let name = &input.ident; let (_, ty_generics, _) = input.generics.split_for_impl(); + // Over `StackCipher<__K>` only: the `KeysetCipher` form of every + // `DecryptField` is a blanket impl in `stack_encrypt::target`, which a + // generic-cipher impl here would overlap. let mut generics = input.generics.clone(); generics.params.push(parse_quote!(__P)); - generics.params.push(parse_quote!(__C)); + generics.params.push(parse_quote!(__K)); generics.params.push(parse_quote!(__Ctx)); generics.make_where_clause().predicates.push(parse_quote! { - __C: #krate::target::DecryptTarget - }); - generics.make_where_clause().predicates.push(parse_quote! { - Self: #krate::target::DecryptInto<__P, __C, __Ctx> + Self: #krate::target::DecryptInto<__P, #krate::StackCipher<__K>, __Ctx> }); let (impl_generics, _, where_clause) = generics.split_for_impl(); quote! { #[automatically_derived] - impl #impl_generics #krate::target::DecryptField<__P, __C, __Ctx> for #name #ty_generics + impl #impl_generics #krate::target::DecryptField<__P, #krate::StackCipher<__K>, __Ctx> + for #name #ty_generics #where_clause { fn decrypt_field<'__a>( self, - __cipher: &'__a __C, + __cipher: &'__a #krate::StackCipher<__K>, __context: __Ctx, - ) -> ::core::option::Option<<__C as #krate::target::DecryptTarget>::Output<'__a, __P>> + ) -> ::core::option::Option<#krate::target::Pending<'__a, __P, __K>> where Self: '__a, __P: '__a, { ::core::option::Option::Some( - <Self as #krate::target::DecryptInto<__P, __C, __Ctx>>::decrypt_into( + <Self as #krate::target::DecryptInto<__P, #krate::StackCipher<__K>, __Ctx>>::decrypt_into( self, __cipher, __context, ), ) @@ -800,10 +801,9 @@ mod tests { assert_contains( &expansion, quote! { - impl<__P, __C, __Ctx> ::stack_encrypt::target::DecryptField<__P, __C, __Ctx> for Rec + impl<__P, __K, __Ctx> ::stack_encrypt::target::DecryptField<__P, ::stack_encrypt::StackCipher<__K>, __Ctx> for Rec where - __C: ::stack_encrypt::target::DecryptTarget, - Self: ::stack_encrypt::target::DecryptInto<__P, __C, __Ctx> + Self: ::stack_encrypt::target::DecryptInto<__P, ::stack_encrypt::StackCipher<__K>, __Ctx> }, ); } @@ -908,7 +908,13 @@ mod tests { ) }); assert_lacks(&expansion, quote!(hm)); - assert_lacks(&expansion, quote!(DecryptInto<__P, ::stack_encrypt::StackCipher)); + // Listed plaintexts: no impl over a generic `__P` (the `DecryptField` + // impl's `Self: DecryptInto<__P, ..>` bound is the one place `__P` + // legitimately appears). + assert_lacks( + &expansion, + quote!(DecryptInto<__P, ::stack_encrypt::StackCipher<__K>, ()> for EncryptedAge), + ); } #[test] diff --git a/packages/stack-encrypt-derive/src/encrypt.rs b/packages/stack-encrypt-derive/src/encrypt.rs index 6f0c2c1b8..89d65866d 100644 --- a/packages/stack-encrypt-derive/src/encrypt.rs +++ b/packages/stack-encrypt-derive/src/encrypt.rs @@ -6,8 +6,8 @@ use syn::spanned::Spanned; use syn::{parse_quote, DeriveInput, Generics, Path, Result, Type}; use crate::shape::{ - context_param, impl_sources, push_field_bounds, trait_impl, zip_fields, CallerContext, - ContextImpl, Field, FieldBound, Kind, Record, + cipher_type, context_param, impl_sources, push_field_bounds, push_keyset_lifetime, trait_impl, + zip_fields, CallerContext, ContextImpl, Field, FieldBound, Kind, Record, }; pub(crate) fn derive(input: DeriveInput) -> Result<TokenStream> { @@ -39,6 +39,7 @@ pub(crate) fn derive(input: DeriveInput) -> Result<TokenStream> { generics.params.push(parse_quote!(__S)); } generics.params.push(parse_quote!(__K)); + push_keyset_lifetime(&mut generics); push_field_bounds( &mut generics, krate, @@ -71,8 +72,8 @@ pub(crate) fn derive(input: DeriveInput) -> Result<TokenStream> { Ok(quote!(#(#impls)* #decryptable)) } -/// `impl EncryptFrom<Source, StackCipher<__K>, Ctx> for Record` around -/// `body`; `ctx` is `()` or `NonEmpty<__T>` ([`context_param`]). The +/// `impl EncryptFrom<Source, KeysetCipher<'__k, __K>, Ctx> for Record` +/// around `body`; `ctx` is `()` or `NonEmpty<__T>` ([`context_param`]). The /// context parameter is unnamed when no field uses it — every field of the /// `()` impl of a `struct` derive carries its own — so the expansion warns /// of nothing. @@ -90,14 +91,15 @@ fn impl_block( } else { quote!(_) }; + let cipher = cipher_type(krate, &FieldBound::Encrypt); trait_impl( input, generics, - quote!(#krate::target::EncryptFrom<#source, #krate::StackCipher<__K>, #ctx>), + quote!(#krate::target::EncryptFrom<#source, #cipher, #ctx>), quote! { fn encrypt_from<'__a>( __source: &'__a #source, - __cipher: &'__a #krate::StackCipher<__K>, + __cipher: &'__a #cipher, #context: #ctx, ) -> #krate::target::Pending<'__a, Self, __K> where @@ -181,7 +183,7 @@ fn body( }; let context_ty = field.field_context().ty(krate, which); let call = quote_spanned! {ty.span()=> - <#ty as #krate::target::EncryptFrom<#source_ty, #krate::StackCipher<__K>, #context_ty>>::encrypt_from + <#ty as #krate::target::EncryptFrom<#source_ty, #krate::KeysetCipher<'__k, __K>, #context_ty>>::encrypt_from }; quote!(#call(#source_expr, __cipher, #context,)) }, @@ -258,19 +260,19 @@ mod tests { // lifetime: nothing here extends a literal, so a borrowed context // passes through. assert_contains(&expansion, quote! { - impl<__S, __K> ::stack_encrypt::target::EncryptFrom<__S, ::stack_encrypt::StackCipher<__K>, ()> + impl<'__k, __S, __K> ::stack_encrypt::target::EncryptFrom<__S, ::stack_encrypt::KeysetCipher<'__k, __K>, ()> for EncryptedAge where - StackCipherText: ::stack_encrypt::target::EncryptFrom<__S, ::stack_encrypt::StackCipher<__K>, ()>, - EqualityTerm: ::stack_encrypt::target::EncryptFrom<__S, ::stack_encrypt::StackCipher<__K>, ()> + StackCipherText: ::stack_encrypt::target::EncryptFrom<__S, ::stack_encrypt::KeysetCipher<'__k, __K>, ()>, + EqualityTerm: ::stack_encrypt::target::EncryptFrom<__S, ::stack_encrypt::KeysetCipher<'__k, __K>, ()> }); assert_contains(&expansion, quote!(__context: (),)); assert_contains(&expansion, quote! { - impl<'__ctx, __S, __K, __T> ::stack_encrypt::target::EncryptFrom<__S, ::stack_encrypt::StackCipher<__K>, ::stack_encrypt::NonEmpty<__T>> + impl<'__ctx, '__k, __S, __K, __T> ::stack_encrypt::target::EncryptFrom<__S, ::stack_encrypt::KeysetCipher<'__k, __K>, ::stack_encrypt::NonEmpty<__T>> for EncryptedAge where - StackCipherText: ::stack_encrypt::target::EncryptFrom<__S, ::stack_encrypt::StackCipher<__K>, ::stack_encrypt::NonEmpty<__T>>, - EqualityTerm: ::stack_encrypt::target::EncryptFrom<__S, ::stack_encrypt::StackCipher<__K>, ::stack_encrypt::NonEmpty<__T>>, + StackCipherText: ::stack_encrypt::target::EncryptFrom<__S, ::stack_encrypt::KeysetCipher<'__k, __K>, ::stack_encrypt::NonEmpty<__T>>, + EqualityTerm: ::stack_encrypt::target::EncryptFrom<__S, ::stack_encrypt::KeysetCipher<'__k, __K>, ::stack_encrypt::NonEmpty<__T>>, __T: ::stack_encrypt::IntoAad<'__ctx> + ::stack_encrypt::IntoPrfContext<'__ctx> + ::core::clone::Clone }); // The first field clones the caller's context, the last takes it. @@ -292,9 +294,9 @@ mod tests { // Under `()`: the literal as it is, a compile-time `NonEmpty`, and // the (unit) context parameter unnamed. assert_contains(&expansion, quote! { - impl<__K> ::stack_encrypt::target::EncryptFrom<u32, ::stack_encrypt::StackCipher<__K>, ()> for Pinned + impl<'__k, __K> ::stack_encrypt::target::EncryptFrom<u32, ::stack_encrypt::KeysetCipher<'__k, __K>, ()> for Pinned where - StackCipherText: ::stack_encrypt::target::EncryptFrom<u32, ::stack_encrypt::StackCipher<__K>, ::stack_encrypt::NonEmpty<&'static str>> + StackCipherText: ::stack_encrypt::target::EncryptFrom<u32, ::stack_encrypt::KeysetCipher<'__k, __K>, ::stack_encrypt::NonEmpty<&'static str>> }); assert_contains(&expansion, quote!(_: (),)); assert_contains(&expansion, quote!(__source, __cipher, ::stack_encrypt::nonempty!("legacy/age"),)); @@ -302,9 +304,9 @@ mod tests { // no record accepts a context and then discards it; the literal // fixes the pair's lifetime, so `__T` is bounded for `'static`. assert_contains(&expansion, quote! { - impl<__K, __T> ::stack_encrypt::target::EncryptFrom<u32, ::stack_encrypt::StackCipher<__K>, ::stack_encrypt::NonEmpty<__T>> for Pinned + impl<'__k, __K, __T> ::stack_encrypt::target::EncryptFrom<u32, ::stack_encrypt::KeysetCipher<'__k, __K>, ::stack_encrypt::NonEmpty<__T>> for Pinned where - StackCipherText: ::stack_encrypt::target::EncryptFrom<u32, ::stack_encrypt::StackCipher<__K>, ::stack_encrypt::NonEmpty<(&'static str, ::stack_encrypt::NonEmpty<__T>)>>, + StackCipherText: ::stack_encrypt::target::EncryptFrom<u32, ::stack_encrypt::KeysetCipher<'__k, __K>, ::stack_encrypt::NonEmpty<(&'static str, ::stack_encrypt::NonEmpty<__T>)>>, __T: ::stack_encrypt::IntoAad<'static> + ::stack_encrypt::IntoPrfContext<'static> + ::core::clone::Clone }); assert_contains(&expansion, quote! { @@ -327,10 +329,10 @@ mod tests { }); for source in [quote!(i32), quote!(i64)] { assert_contains(&expansion, quote! { - impl<__K> ::stack_encrypt::target::EncryptFrom<#source, ::stack_encrypt::StackCipher<__K>, ()> for IntegerOrdOre + impl<'__k, __K> ::stack_encrypt::target::EncryptFrom<#source, ::stack_encrypt::KeysetCipher<'__k, __K>, ()> for IntegerOrdOre }); assert_contains(&expansion, quote! { - impl<'__ctx, __K, __T> ::stack_encrypt::target::EncryptFrom<#source, ::stack_encrypt::StackCipher<__K>, ::stack_encrypt::NonEmpty<__T>> for IntegerOrdOre + impl<'__ctx, '__k, __K, __T> ::stack_encrypt::target::EncryptFrom<#source, ::stack_encrypt::KeysetCipher<'__k, __K>, ::stack_encrypt::NonEmpty<__T>> for IntegerOrdOre }); } assert_contains(&expansion, quote!(Self { c: __field_0, v: SchemaVersion::V3 })); @@ -352,11 +354,11 @@ mod tests { // where clause: the plaintext field's type is unknown here, so the // obligation is checked in the body instead. assert_contains(&expansion, quote! { - impl<__K> ::stack_encrypt::target::EncryptFrom<User, ::stack_encrypt::StackCipher<__K>, ()> for EncryptedUser + impl<'__k, __K> ::stack_encrypt::target::EncryptFrom<User, ::stack_encrypt::KeysetCipher<'__k, __K>, ()> for EncryptedUser }); assert_contains(&expansion, quote!(_: (),)); assert_contains(&expansion, quote! { - <EncryptedAge as ::stack_encrypt::target::EncryptFrom<_, ::stack_encrypt::StackCipher<__K>, ::stack_encrypt::NonEmpty<&'static str>>>::encrypt_from( + <EncryptedAge as ::stack_encrypt::target::EncryptFrom<_, ::stack_encrypt::KeysetCipher<'__k, __K>, ::stack_encrypt::NonEmpty<&'static str>>>::encrypt_from( &__source.age, __cipher, ::stack_encrypt::nonempty!("user/age"), ) }); @@ -365,7 +367,7 @@ mod tests { // all but the last — and `__T` bounded for `'static`, the lifetime // the literal fixes. assert_contains(&expansion, quote! { - impl<__K, __T> ::stack_encrypt::target::EncryptFrom<User, ::stack_encrypt::StackCipher<__K>, ::stack_encrypt::NonEmpty<__T>> for EncryptedUser + impl<'__k, __K, __T> ::stack_encrypt::target::EncryptFrom<User, ::stack_encrypt::KeysetCipher<'__k, __K>, ::stack_encrypt::NonEmpty<__T>> for EncryptedUser where __T: ::stack_encrypt::IntoAad<'static> + ::stack_encrypt::IntoPrfContext<'static> + ::core::clone::Clone }); diff --git a/packages/stack-encrypt-derive/src/lib.rs b/packages/stack-encrypt-derive/src/lib.rs index aba02f815..339b0b88b 100644 --- a/packages/stack-encrypt-derive/src/lib.rs +++ b/packages/stack-encrypt-derive/src/lib.rs @@ -32,8 +32,9 @@ //! .kms(FakeDataKeySource::new()) //! .init() //! .await?; +//! let keyset = cipher.default_keyset(); //! let record: EncryptedAge = 42u32 -//! .encrypt_into_with_context(&cipher, nonempty!("users/age")) +//! .encrypt_into_with_context(&keyset, nonempty!("users/age")) //! .await?; //! let age: u32 = record.decrypt_into(&cipher, nonempty!("users/age")).await?; //! assert_eq!(age, 42); @@ -47,7 +48,10 @@ //! it, so a term built at a query site under `"users/age"` matches the one //! stored in the record. The field pendings are combined without being //! awaited, so however many fields a record has, awaiting it is **one** -//! batched ZeroKMS call. +//! batched ZeroKMS call. Encrypting binds to a keyset — the `KeysetCipher` +//! every data key is minted and every term derived under — while decrypting +//! takes the client-scoped `StackCipher` (a sealed leaf names its own keyset) +//! or the `KeysetCipher`, which then refuses leaves from any other keyset. //! //! The record takes the caller's context because its fields do: the derive //! emits one impl for `()` and one for `NonEmpty<T>`, each bounded by what @@ -100,10 +104,11 @@ //! //! # tokio::runtime::Builder::new_current_thread().enable_all().build().unwrap().block_on(async { //! # let cipher = StackCipher::builder().kms(FakeDataKeySource::new()).init().await?; +//! # let keyset = cipher.default_keyset(); //! let user = User { age: 42, email: "alice@example.com".into() }; -//! let encrypted: EncryptedUser = user.encrypt_into(&cipher).await?; // one batch +//! let encrypted: EncryptedUser = user.encrypt_into(&keyset).await?; // one batch //! let users = vec![User { age: 1, email: "a".into() }, User { age: 2, email: "b".into() }]; -//! let column: Vec<EncryptedUser> = users.encrypt_into(&cipher).await?; // still one +//! let column: Vec<EncryptedUser> = users.encrypt_into(&keyset).await?; // still one //! let user = User::decrypt_from(encrypted, &cipher).await?; //! assert_eq!(user, User { age: 42, email: "alice@example.com".into() }); //! assert_eq!(column.len(), 2); @@ -112,9 +117,9 @@ //! // under `("users/age", 7u64)` — bound to its record as well as its name //! // — and a probe for it is built under the same pair. //! let user = User { age: 42, email: "alice@example.com".into() }; -//! let encrypted: EncryptedUser = user.encrypt_into_with_context(&cipher, 7u64).await?; +//! let encrypted: EncryptedUser = user.encrypt_into_with_context(&keyset, 7u64).await?; //! let probe: EqualityTerm = 42u32 -//! .encrypt_into_with_context(&cipher, nonempty!("users/age").with(7u64)) +//! .encrypt_into_with_context(&keyset, nonempty!("users/age").with(7u64)) //! .await?; //! assert_eq!(encrypted.age.hm, probe); //! let user = User::decrypt_from_with_context(encrypted, &cipher, 7u64).await?; @@ -156,11 +161,13 @@ //! //! # What the derive commits to //! -//! The impls are over `StackCipher<K>` for any `K`, returning its `Pending`. -//! That is the only cipher today, and the only one whose output can be -//! combined without awaiting; a derive generic over any `EncryptTarget` needs -//! combinators on that trait and can replace this one without changing the -//! attribute surface. +//! The `EncryptFrom` impls are over `KeysetCipher<'k, K>` and the +//! `DecryptInto` impls over `StackCipher<K>` (reaching a `KeysetCipher` +//! through stack-encrypt's blanket impls), for any `K`, each returning its +//! `Pending`. Those are the only ciphers today, and the only ones whose +//! output can be combined without awaiting; a derive generic over any +//! `EncryptTarget` needs combinators on that trait and can replace this one +//! without changing the attribute surface. //! //! Field-by-field decryption rebuilds the plaintext with a struct literal, so //! every field of the plaintext must be recovered by exactly one ciphertext diff --git a/packages/stack-encrypt-derive/src/shape.rs b/packages/stack-encrypt-derive/src/shape.rs index 9aa684971..f99f9d701 100644 --- a/packages/stack-encrypt-derive/src/shape.rs +++ b/packages/stack-encrypt-derive/src/shape.rs @@ -339,10 +339,13 @@ pub(crate) enum FieldBound { DecryptInto, } -/// `FieldTy: Trait<Target, StackCipher<__K>, Ctx>` for each of `fields`, -/// under the context it is derived or opened under in the impl for `which` -/// — its literal's, `()`, or the caller's `NonEmpty<__T>`, which is how a -/// record inherits its leaves' demand for a non-empty context. +/// `FieldTy: Trait<Target, Cipher, Ctx>` for each of `fields`, under the +/// context it is derived or opened under in the impl for `which` — its +/// literal's, `()`, or the caller's `NonEmpty<__T>`, which is how a record +/// inherits its leaves' demand for a non-empty context. The cipher is the +/// one the trait binds to: encrypting binds to a keyset +/// (`KeysetCipher<'__k, __K>`), decrypting to the client (`StackCipher<__K>`; +/// the keyset-constrained form is a blanket over it). pub(crate) fn push_field_bounds( generics: &mut Generics, krate: &Path, @@ -356,6 +359,7 @@ pub(crate) fn push_field_bounds( FieldBound::DecryptField => parse_quote!(DecryptField), FieldBound::DecryptInto => parse_quote!(DecryptInto), }; + let cipher = cipher_type(krate, &bound); let predicates = &mut generics.make_where_clause().predicates; for field in fields { let ty = &field.ty; @@ -363,11 +367,30 @@ pub(crate) fn push_field_bounds( // Spanned at the field type, so a type that cannot be a field of the // record is reported there, not at the derive. predicates.push(parse_quote_spanned! {ty.span()=> - #ty: #krate::target::#trait_name<#target, #krate::StackCipher<__K>, #context> + #ty: #krate::target::#trait_name<#target, #cipher, #context> }); } } +/// The cipher a derived impl binds to. Encrypting binds to a keyset, so the +/// encrypt impls are over `KeysetCipher<'__k, __K>` and carry the `'__k` +/// lifetime ([`push_keyset_lifetime`]); decrypting is client-scoped, so the +/// decrypt impls are over `StackCipher<__K>`. +pub(crate) fn cipher_type(krate: &Path, bound: &FieldBound) -> Type { + match bound { + FieldBound::Encrypt => parse_quote!(#krate::KeysetCipher<'__k, __K>), + FieldBound::DecryptField | FieldBound::DecryptInto => { + parse_quote!(#krate::StackCipher<__K>) + } + } +} + +/// Add the `'__k` lifetime of the `KeysetCipher` an encrypt impl binds to. +/// Lifetimes precede type parameters in a generics list, so it goes first. +pub(crate) fn push_keyset_lifetime(generics: &mut Generics) { + generics.params.insert(0, parse_quote!('__k)); +} + /// The source (or plaintext) types a derive emits one impl each for: the /// listed ones, or — when none are listed — the given generic parameter, /// with `true` saying it must be pushed onto the impl's generics. diff --git a/packages/stack-encrypt/examples/encrypted_record.rs b/packages/stack-encrypt/examples/encrypted_record.rs index edc24c964..c924c457c 100644 --- a/packages/stack-encrypt/examples/encrypted_record.rs +++ b/packages/stack-encrypt/examples/encrypted_record.rs @@ -73,6 +73,9 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> { // keyset's index key, which `init` loads. Data keys and terms are bound to // the same keyset by construction — there is no way to mix them up. let cipher = StackCipher::new().await?; + // Sealing and term derivation bind to a keyset; this is the client's + // default one. + let keyset = cipher.default_keyset(); // --- Write side: encrypt a table of users --------------------------------- @@ -94,7 +97,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> { // caller — and one await seals the whole table: the `Vec` implementation // merges every struct's pending, so five users (two ciphertexts and two // terms each) settle in a single batched generate_keys call. - let table: Vec<EncryptedUser> = users.encrypt_into(&cipher).await?; + let table: Vec<EncryptedUser> = users.encrypt_into(&keyset).await?; println!("stored {} encrypted users in one ZeroKMS call", table.len()); // --- Query side: terms only, no plaintext, no decryption ------------------ @@ -105,7 +108,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> { // WHERE age = 34: compare equality terms. let probe: EqualityTerm = 34u32 - .encrypt_into_with_context(&cipher, nonempty!("users/age")) + .encrypt_into_with_context(&keyset, nonempty!("users/age")) .await?; let equal: Vec<usize> = (0..table.len()) .filter(|&i| table[i].age.eq == probe) @@ -114,7 +117,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> { // WHERE age > 40: compare ORE terms. let bound: OreTerm<u32> = 40u32 - .encrypt_into_with_context(&cipher, nonempty!("users/age")) + .encrypt_into_with_context(&keyset, nonempty!("users/age")) .await?; let over_40: Vec<usize> = (0..table.len()) .filter(|&i| table[i].age.ord > bound) @@ -160,7 +163,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> { age: 34, email: "alice@example.com".into(), }; - let record: EncryptedUser = alice.clone().encrypt_into_with_context(&cipher, id).await?; + let record: EncryptedUser = alice.clone().encrypt_into_with_context(&keyset, id).await?; let unscoped: Vec<usize> = std::iter::once(&record) .enumerate() .filter(|(_, r)| r.age.eq == probe) @@ -171,7 +174,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> { !unscoped.is_empty() ); let scoped: EqualityTerm = 34u32 - .encrypt_into_with_context(&cipher, nonempty!("users/age").with(id)) + .encrypt_into_with_context(&keyset, nonempty!("users/age").with(id)) .await?; println!( " ...and a probe built under the same id: {}", @@ -184,7 +187,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> { // ZeroKMS descriptor of every data key: opening under another id fails // at ZeroKMS, before any key material moves. (Against a source that // does not enforce descriptors — the fake — the AEAD refuses instead.) - let record: EncryptedUser = alice.encrypt_into_with_context(&cipher, id).await?; + let record: EncryptedUser = alice.encrypt_into_with_context(&keyset, id).await?; let wrong_id = User::decrypt_from_with_context(record, &decryptor, 43u64).await; println!( "opening under another id: {}", diff --git a/packages/stack-encrypt/examples/mixed_user.rs b/packages/stack-encrypt/examples/mixed_user.rs index cfe067e03..40418e378 100644 --- a/packages/stack-encrypt/examples/mixed_user.rs +++ b/packages/stack-encrypt/examples/mixed_user.rs @@ -177,7 +177,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> { // One call, one batched generate_keys round-trip for every encrypted leaf // in the whole Vec (here: 3 rows x 2 encrypted fields = 6 data keys). - let ciphertext = cipher.encrypt(users, "users/v1").await?; + let ciphertext = cipher.default_keyset().encrypt(users, "users/v1").await?; println!("what the stored ciphertext reveals:"); describe(&ciphertext, 1); diff --git a/packages/stack-encrypt/examples/search_terms.rs b/packages/stack-encrypt/examples/search_terms.rs index b6fc0675f..bf44ad5e1 100644 --- a/packages/stack-encrypt/examples/search_terms.rs +++ b/packages/stack-encrypt/examples/search_terms.rs @@ -26,7 +26,10 @@ use stack_encrypt::{nonempty, EncryptFrom, StackCipher, StackCipherText}; async fn main() -> Result<(), Box<dyn std::error::Error>> { // `StackCipher::new()` builds a ZeroKMS client from the environment and // loads the keyset's index key once, during construction. - let terms = StackCipher::new().await?; + let cipher = StackCipher::new().await?; + // Terms bind to a keyset: this handle derives every term under the + // default keyset's index key. + let terms = cipher.default_keyset(); println!("cipher ready on keyset {}", terms.keyset_id()); // One cipher serves write time and query time; terms are deterministic diff --git a/packages/stack-encrypt/examples/zerokms_auth.rs b/packages/stack-encrypt/examples/zerokms_auth.rs index ef4926928..2711a39a7 100644 --- a/packages/stack-encrypt/examples/zerokms_auth.rs +++ b/packages/stack-encrypt/examples/zerokms_auth.rs @@ -53,20 +53,31 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> { } Err(other) => return Err(other.into()), }; - println!("connected; keyset {}", cipher.keyset_id()); + let keyset = cipher.default_keyset(); + println!("connected; keyset {}", keyset.keyset_id()); - let ciphertext = cipher.encrypt("hello".to_string(), "demo/greeting").await?; + let ciphertext = keyset.encrypt("hello".to_string(), "demo/greeting").await?; let plaintext: String = cipher.decrypt(ciphertext, "demo/greeting").await?; assert_eq!(plaintext, "hello"); println!("round-tripped a value under the default keyset"); // --- A specific keyset -------------------------------------------------- // - // The keyset pins both halves at once: data keys are generated under it, - // and its index key derives every SEM term. They cannot diverge. + // A cipher is client-scoped and serves any keyset the client is + // authorised for; selecting one (loaded from ZeroKMS on first use, then + // cached) yields a handle that pins both halves at once: data keys are + // generated under it, and its index key derives every SEM term. They + // cannot diverge. + // + // let customers = cipher + // .keyset(IdentifiedBy::Name("customers".to_string().into())) + // .await?; + // let ciphertext = customers.encrypt("hello".to_string(), "demo/greeting").await?; + // + // To make a keyset the default instead, name it on the builder: // // StackCipher::builder() - // .keyset(IdentifiedBy::Name("customers".into())) + // .keyset(IdentifiedBy::Name("customers".to_string().into())) // .init() // .await?; diff --git a/packages/stack-encrypt/src/cipher.rs b/packages/stack-encrypt/src/cipher.rs index c16e07e0d..d8bc446c7 100644 --- a/packages/stack-encrypt/src/cipher.rs +++ b/packages/stack-encrypt/src/cipher.rs @@ -1,12 +1,16 @@ //! Implementation of [`StackCipher`]. For usage, start at the crate docs; this //! module documents the internals. //! -//! `StackCipher` is a vitaminc [`Cipher`] whose per-leaf keys are ZeroKMS data -//! keys rather than one fixed key. Structurally it mirrors +//! `StackCipher` is scoped to one ZeroKMS client; a [`KeysetCipher`] — the +//! cipher bound to one of that client's keysets — is a vitaminc [`Cipher`] +//! whose per-leaf keys are ZeroKMS data keys, minted under that keyset, +//! rather than one fixed key. Structurally it mirrors //! `vitaminc_encrypt::Aes256Cipher`: encrypting an [`Encrypt`] value produces //! a recursive ciphertext tree ([`StackCipherText`], the analog of //! `AesCipherText`) whose leaves ([`SealedValue`]) each carry the ZeroKMS -//! metadata for their own data key. +//! metadata for their own data key — the keyset it was minted under +//! included, which is why decrypting needs no keyset named and lives on the +//! client-scoped `StackCipher`. //! //! ## Batching the key fetch //! @@ -15,13 +19,15 @@ //! methods. It is front-loaded on both sides; the AES work stays inside the //! trait drive: //! -//! * **Encrypt** — driving the [`Cipher`] trait builds a *pending* tree -//! ([`PendingStackCipherText`]) that holds plaintext plus each leaf's fully -//! derived AAD, but does no I/O. A single [`PendingStackCipherText::seal`] -//! (or the [`StackCipher::encrypt`] convenience) then batches **one** -//! `generate_keys` call for the whole tree and seals every leaf. +//! * **Encrypt** — driving the [`Cipher`] trait over a `&KeysetCipher` builds +//! a *pending* tree ([`PendingStackCipherText`]) that holds plaintext plus +//! each leaf's fully derived AAD, but does no I/O. A single +//! [`PendingStackCipherText::seal`] (or the [`KeysetCipher::encrypt`] +//! convenience) then batches **one** `generate_keys` call for the whole +//! tree, under the handle's keyset, and seals every leaf. //! * **Decrypt** — [`StackCipher::decipher`] batches **one** `retrieve_keys` -//! call and zips each key onto its leaf, returning a [`StackDecipher`]. The +//! call per keyset the leaves were sealed under and zips each key onto its +//! leaf, returning a [`StackDecipher`]. The //! value's [`Decrypt`] impl then drives that decipher exactly as it would //! `AesDecipher`: each leaf is opened under the AAD the drive supplies, so //! the visitor pattern (nested `Vec`/`HashMap`/`Option`/`Protected` values, @@ -46,19 +52,23 @@ //! //! ## Leaf crypto and wire format //! -//! Each leaf ([`SealedValue`]) stores the ZeroKMS `iv` and key `tag` — enough -//! to retrieve the data key — plus a vitaminc [`LocalCipherText`] sealed under -//! that key by [`vitaminc_encrypt::Aes256Cipher`] (AES-256-GCM via vitaminc's -//! backend: `aws-lc-rs` on native, RustCrypto on wasm32; vitaminc's own random -//! nonce and versioned leaf layout). The leaf AAD is the labelled derivation -//! `leaf_aad` (private): `PAE("stack-encrypt/leaf", version, derived_aad, tag)`, with +//! Each leaf ([`SealedValue`]) stores the ZeroKMS keyset id, `iv` and key +//! `tag` — enough to retrieve the data key — plus a vitaminc +//! [`LocalCipherText`] sealed under that key by +//! [`vitaminc_encrypt::Aes256Cipher`] (AES-256-GCM via vitaminc's backend: +//! `aws-lc-rs` on native, RustCrypto on wasm32; vitaminc's own random nonce +//! and versioned leaf layout). The leaf AAD is the labelled derivation +//! `leaf_aad` (private): +//! `PAE("stack-encrypt/leaf", version, keyset_id, derived_aad, tag)`, with //! [`SealedValue::FORMAT_VERSION`] — the version byte that prefixes the -//! leaf's frozen byte encoding ([`SealedValue::to_bytes`]) — bound under the -//! tag, so a stored leaf relabelled with a different version byte fails -//! verification instead of selecting different parsing rules. The `tag` is -//! always bound, so the ciphertext is cryptographically tied to its ZeroKMS -//! data key (key binding); a caller AAD (e.g. a -//! [`ContextTag`](vitaminc_aead::ContextTag)) adds a further binding layer. +//! leaf's frozen byte encoding ([`SealedValue::to_bytes`]) — and the keyset +//! id bound under the tag, so a stored leaf relabelled with a different +//! version byte fails verification instead of selecting different parsing +//! rules, and one re-pointed at another keyset fails instead of asking that +//! keyset for a key it never minted. The `tag` is always bound, so the +//! ciphertext is cryptographically tied to its ZeroKMS data key (key +//! binding); a caller AAD (e.g. a [`ContextTag`](vitaminc_aead::ContextTag)) +//! adds a further binding layer. //! Every data key is requested under a ZeroKMS **descriptor**: the context //! the tree is sealed under, rendered as a string by //! [`Descriptor`]. ZeroKMS HMACs the descriptor into the @@ -75,6 +85,8 @@ use std::any::Any; use std::borrow::Cow; use std::collections::HashSet; +use std::num::NonZeroUsize; +use std::sync::{Arc, Mutex, PoisonError}; use serde::{Deserialize, Serialize}; use stack_kms::{DataKey, DataKeySource, DataKeyWithTag, IdentifiedBy, IndexKeySource}; @@ -90,6 +102,7 @@ use vitaminc_aead::{ use vitaminc_encrypt::{Aes256Cipher, AesCipherText, Key as AesKey}; use vitaminc_protected::{Controlled, Protected}; +use crate::keyset::{KeysetCache, KeysetCipher, KeysetState}; use crate::Descriptor; /// The passthrough payload type: type-erased, as for Rust-native vitaminc @@ -152,11 +165,35 @@ pub enum Error { ResponseShape, /// [`Pending`](crate::target::Pending)s built on different /// [`StackCipher`] instances were merged (`zip` / `all`). An assembly - /// settles through one cipher's backend and keyset, so the other side's - /// keys would be minted under the wrong keyset. Always a composition - /// bug, caught before any I/O. + /// settles through one cipher's backend, so the other side's keys would + /// be minted by the wrong client. Always a composition bug, caught + /// before any I/O. #[error("merged pendings were built from different ciphers")] CipherMismatch, + /// [`Pending`](crate::target::Pending)s scoped to different keysets were + /// merged (`zip` / `all`): one built through a [`KeysetCipher`] for one + /// keyset, the other for another. A row belongs to one tenant; an + /// assembly that spans two is a composition bug, caught before any + /// I/O. (Opening leaves from several keysets in one batch is allowed — + /// through the [`StackCipher`], which is scoped to none.) + #[error("merged pendings were scoped to different keysets ({left} and {right})")] + KeysetMismatch { left: Uuid, right: Uuid }, + /// A leaf sealed under one keyset was handed to a [`KeysetCipher`] for + /// another. The handle's keyset is a constraint the caller asked for — + /// a tenant-scoped request handler must not open another tenant's row + /// — so this is refused before any key is retrieved. To open leaves + /// from any keyset, decrypt through the [`StackCipher`]. + #[error("leaf was sealed under keyset {found}, not the handle's keyset {expected}")] + ForeignKeyset { expected: Uuid, found: Uuid }, + /// A data key was requested through a [`StackCipher`] rather than a + /// [`KeysetCipher`]: a [`Request::generate_data_key`] needs a keyset + /// to mint under, and only a keyset-scoped pending has one. Always a + /// composition bug in a hand-written `EncryptFrom`, caught before any + /// I/O. + /// + /// [`Request::generate_data_key`]: crate::target::Request::generate_data_key + #[error("a data key was requested with no keyset to mint it under")] + NoKeyset, /// A [`DecryptField`](crate::target::DecryptField) implementation /// declared its type [`DECRYPTABLE`](crate::target::Decryptable::DECRYPTABLE) /// but passed the field over. Always a bug in a third-party @@ -178,20 +215,36 @@ impl From<Unspecified> for Error { } } -/// The CipherStash cipher: a vitaminc [`Cipher`] whose per-leaf keys are ZeroKMS -/// data keys, sourced through a [`DataKeySource`] (production: -/// [`stack_kms::StackKms`]; tests: `stack_kms::FakeDataKeySource`), -/// carrying the per-keyset PRF that -/// [Searchable Encrypted Metadata](crate::sem) terms are derived from. +/// The CipherStash cipher, scoped to one client: a ZeroKMS client (a +/// [`DataKeySource`] — production: [`stack_kms::StackKms`]; tests: +/// `stack_kms::FakeDataKeySource`) and the keysets that client uses. /// /// Per-leaf keying is deliberate: every value access requires its own data-key /// retrieval, so individual value accesses are visible (and auditable) as /// ZeroKMS key-retrieval events. /// -/// A cipher is always able to derive index terms: its keyset's -/// [`IndexKey`](stack_kms::IndexKey) is loaded during construction, so a -/// backend that cannot supply one is not a Stack Encrypt backend. Plain AEAD -/// with no indexing is what `vitaminc` alone provides. +/// # Keysets +/// +/// Sealing values, sealing records and deriving index terms all happen +/// under a keyset, and a client may use many — one per tenant, say. So +/// those operations bind to a [`KeysetCipher`], the cipher scoped to one +/// keyset: [`default_keyset`](Self::default_keyset) for the keyset named +/// on the builder (else the client's default), [`keyset`](Self::keyset) +/// for any other, by id or by name. Keysets load lazily, through a bounded +/// least-recently-used cache: the first selection of a keyset is one +/// ZeroKMS round trip (its index key, which +/// [Searchable Encrypted Metadata](crate::sem) terms are derived from), +/// and every later one is a lookup. A backend that cannot supply an index +/// key is not a Stack Encrypt backend; plain AEAD with no indexing is what +/// `vitaminc` alone provides. +/// +/// Decrypting is not keyset-scoped: a sealed leaf carries the id of the +/// keyset it was sealed under, and retrieving its data key needs nothing +/// more than that and the client. So [`decrypt`](Self::decrypt) and +/// [`decipher`](Self::decipher) live here and open leaves from any keyset +/// the client is authorised for, in one batch. The same methods on a +/// [`KeysetCipher`] add a constraint: they refuse a leaf from any other +/// keyset before any key is retrieved. /// /// # Construction /// @@ -236,16 +289,17 @@ different data-key source entirely:"# /// # } /// ``` /// -/// Construction is async because it resolves the keyset and loads its index -/// key — one ZeroKMS round-trip, paid once. +/// Construction is async because it resolves the default keyset and loads +/// its index key — one ZeroKMS round-trip, paid once, so a misconfigured +/// client fails here rather than on first use. pub struct StackCipher<K> { kms: K, - /// The resolved keyset. Every generate/retrieve call is pinned to it, and - /// the PRF below is keyed by *this* keyset's index key: sealing data keys - /// under one keyset while deriving terms under another's index key would - /// make every query silently match nothing. - keyset_id: Uuid, - prf: vitaminc_hmac::HmacSha256Prf, + /// The keyset named on the builder, else the client's default: loaded + /// eagerly by `init`, never evicted. + default: Arc<KeysetState>, + /// Every other keyset this cipher has selected, least recently used + /// first out. See [`keyset`](Self::keyset). + keysets: Mutex<KeysetCache>, } #[cfg(feature = "http")] @@ -275,24 +329,84 @@ impl StackCipher<FromEnv> { } impl<K> StackCipher<K> { - /// The keyset every generate/retrieve call is pinned to, and whose index - /// key keys [`prf`](Self::prf). - pub fn keyset_id(&self) -> Uuid { - self.keyset_id - } - - /// The PRF index terms are derived from, keyed by this cipher's keyset. - /// - /// Public so that other crates can implement their own term types against - /// this cipher (see [`crate::sem`]). - pub fn prf(&self) -> &vitaminc_hmac::HmacSha256Prf { - &self.prf + /// The cipher bound to its default keyset: the one named on the builder + /// (by id or by name), else the client's default. Loaded at `init`, so + /// this never touches ZeroKMS. + pub fn default_keyset(&self) -> KeysetCipher<'_, K> { + KeysetCipher::new(self, Arc::clone(&self.default)) } /// The underlying data-key source. pub fn kms(&self) -> &K { &self.kms } + + fn keysets(&self) -> std::sync::MutexGuard<'_, KeysetCache> { + // The cache holds no invariant a panic mid-update could break (an + // insert is two map writes, and a stale name entry only points at + // a still-valid state), so a poisoned lock is recovered, not + // propagated. + self.keysets.lock().unwrap_or_else(PoisonError::into_inner) + } +} + +impl<K: IndexKeySource> StackCipher<K> { + /// The cipher bound to a keyset, by id or by name. + /// + /// The one async point of keyset selection: a keyset this cipher has + /// not seen (or has evicted) is loaded from ZeroKMS here — its id + /// resolved and its index key fetched — and cached; every later + /// selection is a lookup. The returned handle keeps that keyset loaded + /// for as long as it is held, so a request handler that selects its + /// tenant's keyset once never pays again within the request. + /// + /// ``` + /// # async fn example() -> Result<(), stack_encrypt::Error> { + /// use stack_encrypt::{nonempty, StackCipher}; + /// use stack_kms::{FakeDataKeySource, IdentifiedBy}; + /// + /// let cipher = StackCipher::builder() + /// .kms(FakeDataKeySource::new()) + /// .init() + /// .await?; + /// let tenant = cipher.keyset(IdentifiedBy::Name("acme".to_string().into())).await?; + /// let sealed = tenant.encrypt("hello".to_string(), nonempty!("greeting")).await?; + /// # Ok(()) + /// # } + /// # tokio::runtime::Builder::new_current_thread().enable_all().build().unwrap().block_on(example()).unwrap(); + /// ``` + /// + /// A keyset ZeroKMS does not know, or has disabled, is + /// [`Error::Kms`]. The name-to-id resolution is ZeroKMS's: a keyset + /// selected by name reports the resolved id from + /// [`KeysetCipher::keyset_id`]. + pub async fn keyset( + &self, + keyset: impl Into<IdentifiedBy>, + ) -> Result<KeysetCipher<'_, K>, Error> { + let keyset = keyset.into(); + if self.default.is(&keyset) { + return Ok(self.default_keyset()); + } + if let Some(state) = self.keysets().get(&keyset) { + return Ok(KeysetCipher::new(self, state)); + } + // Loaded outside the lock: a round trip must not hold up every other + // selection, and two selections racing on the same miss simply load + // twice and the second insert replaces the first with its equal. + let name = match &keyset { + IdentifiedBy::Name(name) => Some(name.to_string()), + IdentifiedBy::Uuid(_) => None, + }; + let (id, index_key) = self.kms.load_index_key(Some(keyset)).await?; + let state = Arc::new(KeysetState { + id, + name, + prf: hmac_prf_from_index_key(&index_key), + }); + self.keysets().insert(Arc::clone(&state)); + Ok(KeysetCipher::new(self, state)) + } } /// The state of a [`StackCipherBuilder`] that has not been given a data-key @@ -343,6 +457,7 @@ impl KeyProvider for ProfileClientKey { pub struct StackCipherBuilder<K = FromEnv> { kms: K, keyset: Option<IdentifiedBy>, + cache_size: NonZeroUsize, } impl StackCipherBuilder<FromEnv> { @@ -355,6 +470,7 @@ impl StackCipherBuilder<FromEnv> { Self { kms: FromEnv, keyset: None, + cache_size: KeysetCache::DEFAULT_CAPACITY, } } } @@ -366,12 +482,23 @@ impl Default for StackCipherBuilder<FromEnv> { } impl<K> StackCipherBuilder<K> { - /// Pin the cipher to a specific keyset, by id or by name, instead of the - /// data-key source's default. + /// Make a specific keyset, by id or by name, the cipher's + /// [default](StackCipher::default_keyset) instead of the data-key + /// source's own default. Loaded at `init`. pub fn keyset(mut self, keyset: IdentifiedBy) -> Self { self.keyset = Some(keyset); self } + + /// How many keysets beyond the default the cipher keeps loaded + /// (default 1024). A process serving more tenants than this reloads a + /// keyset's index key from ZeroKMS when it comes back into use; nothing + /// stored depends on the cache, so the bound only trades memory for + /// round trips. See [`StackCipher::keyset`]. + pub fn keyset_cache_size(mut self, size: NonZeroUsize) -> Self { + self.cache_size = size; + self + } } impl StackCipherBuilder<FromEnv> { @@ -386,6 +513,7 @@ impl StackCipherBuilder<FromEnv> { StackCipherBuilder { kms, keyset: self.keyset, + cache_size: self.cache_size, } } @@ -412,6 +540,7 @@ impl StackCipherBuilder<FromEnv> { StackCipherBuilder { kms, keyset: self.keyset, + cache_size: self.cache_size, } .init() .await @@ -419,15 +548,25 @@ impl StackCipherBuilder<FromEnv> { } impl<K: DataKeySource + IndexKeySource> StackCipherBuilder<K> { - /// Resolve the keyset and load its index key, producing a cipher that can - /// both seal values and derive index terms. + /// Resolve the default keyset and load its index key, producing a + /// cipher whose [`default_keyset`](StackCipher::default_keyset) can both + /// seal values and derive index terms. The one round trip a cipher + /// always pays; every other keyset loads on first selection. pub async fn init(self) -> Result<StackCipher<K>, Error> { - let (keyset_id, index_key) = self.kms.load_index_key(self.keyset).await?; - let prf = hmac_prf_from_index_key(&index_key); + let name = match &self.keyset { + Some(IdentifiedBy::Name(name)) => Some(name.to_string()), + _ => None, + }; + let (id, index_key) = self.kms.load_index_key(self.keyset).await?; + let default = Arc::new(KeysetState { + id, + name, + prf: hmac_prf_from_index_key(&index_key), + }); Ok(StackCipher { kms: self.kms, - keyset_id, - prf, + default, + keysets: Mutex::new(KeysetCache::new(self.cache_size)), }) } } @@ -446,10 +585,10 @@ fn hmac_prf_from_index_key(index_key: &stack_kms::IndexKey) -> vitaminc_hmac::Hm prf } -impl<K: DataKeySource> StackCipher<K> { +impl<K: DataKeySource> KeysetCipher<'_, K> { /// Encrypt a value, binding `aad`, and seal it against fresh ZeroKMS data - /// keys in a single batched `generate_keys` call. Every key is requested - /// under the [`Descriptor`] of `aad`. + /// keys in a single batched `generate_keys` call under this keyset. + /// Every key is requested under the [`Descriptor`] of `aad`. pub async fn encrypt<'a, T, A>(&self, value: T, aad: A) -> Result<StackCipherText, Error> where T: Encrypt, @@ -460,12 +599,46 @@ impl<K: DataKeySource> StackCipher<K> { pending.seal(self, aad).await } + /// [`StackCipher::decrypt`], constrained to this keyset: a leaf sealed + /// under any other is [`Error::ForeignKeyset`], refused before any key + /// is retrieved. + pub async fn decrypt<'a, T, A>(&self, ciphertext: StackCipherText, aad: A) -> Result<T, Error> + where + T: Decrypt<'static> + 'static, + A: IntoAad<'a>, + { + let aad = aad.into_aad_piece(); + let decipher = self.decipher(ciphertext, aad.clone()).await?; + T::decrypt_with_aad(decipher, aad.into_aad()).map_err(Error::from) + } + + /// [`StackCipher::decipher`], constrained to this keyset: a leaf sealed + /// under any other is [`Error::ForeignKeyset`], refused before any key + /// is retrieved. + pub async fn decipher<'a>( + &self, + ciphertext: StackCipherText, + aad: impl IntoAad<'a>, + ) -> Result<StackDecipher, Error> { + crate::target::decipher_pending(self, ciphertext, Descriptor::of(aad)) + .settle() + .await + } +} + +impl<K: DataKeySource> StackCipher<K> { /// Decrypt a [`StackCipherText`] into `T`, authenticating against `aad`. /// /// Thin wrapper over [`decipher`](Self::decipher): one batched - /// `retrieve_keys` call under the [`Descriptor`] of `aad`, then `T`'s - /// [`Decrypt`] impl drives the returned [`StackDecipher`] with `aad` — - /// exactly as `Aes256Cipher::decrypt_with_aad` drives `AesDecipher`. + /// `retrieve_keys` call per keyset the leaves were sealed under, every + /// key under the [`Descriptor`] of `aad`, then `T`'s [`Decrypt`] impl + /// drives the returned [`StackDecipher`] with `aad` — exactly as + /// `Aes256Cipher::decrypt_with_aad` drives `AesDecipher`. + /// + /// Not keyset-scoped: each leaf carries the id of the keyset it was + /// sealed under, and this opens leaves from any keyset the client is + /// authorised for. To insist on one keyset, decrypt through its + /// [`KeysetCipher`] instead. pub async fn decrypt<'a, T, A>(&self, ciphertext: StackCipherText, aad: A) -> Result<T, Error> where T: Decrypt<'static> + 'static, @@ -476,21 +649,22 @@ impl<K: DataKeySource> StackCipher<K> { T::decrypt_with_aad(decipher, aad.into_aad()).map_err(Error::from) } - /// Fetch every leaf's data key (one batched `retrieve_keys` call, every - /// key under the [`Descriptor`] of `aad`) and bind them onto the - /// ciphertext, returning a synchronous [`Decipher`] that does the AEAD - /// opening as the value's [`Decrypt`] impl drives it. + /// Fetch every leaf's data key (one batched `retrieve_keys` call per + /// keyset the leaves were sealed under, every key under the + /// [`Descriptor`] of `aad`) and bind them onto the ciphertext, returning + /// a synchronous [`Decipher`] that does the AEAD opening as the value's + /// [`Decrypt`] impl drives it. /// - /// This is the decrypt-side counterpart to passing `&cipher` (a [`Cipher`]) - /// on the encrypt side, mirroring `Aes256Cipher::decipher`: the ZeroKMS I/O - /// is front-loaded here, and the AAD is supplied per call by - /// [`Decrypt::decrypt_with_aad`], so `Decrypt` impls that derive their own - /// AAD (e.g. `vitaminc_aead::Element`) behave identically to `AesDecipher`. - /// The one thing ZeroKMS needs before that drive is the descriptor the - /// keys were generated under, so `aad` is the context the value was - /// sealed under — the same value, in the same shape, that the drive will - /// present (an `Element`'s own derivation is applied by the drive, not - /// here). [`decrypt`](Self::decrypt) does both steps. + /// This is the decrypt-side counterpart to passing `&keyset` (a + /// [`Cipher`]) on the encrypt side, mirroring `Aes256Cipher::decipher`: + /// the ZeroKMS I/O is front-loaded here, and the AAD is supplied per + /// call by [`Decrypt::decrypt_with_aad`], so `Decrypt` impls that derive + /// their own AAD (e.g. `vitaminc_aead::Element`) behave identically to + /// `AesDecipher`. The one thing ZeroKMS needs before that drive is the + /// descriptor the keys were generated under, so `aad` is the context the + /// value was sealed under — the same value, in the same shape, that the + /// drive will present (an `Element`'s own derivation is applied by the + /// drive, not here). [`decrypt`](Self::decrypt) does both steps. /// /// Settles through the target layer's request carrier /// ([`decipher_pending`](crate::target)), so this and @@ -527,30 +701,38 @@ impl<K: DataKeySource> StackCipher<K> { /// | offset | field | size | value | /// |-----------------|--------------------|-----------------|-------| /// | 0 | envelope version | 1 | [`FORMAT_VERSION`](Self::FORMAT_VERSION) (`0x01`) | -/// | 1 | ZeroKMS `iv` | 16 | identifies the data key for retrieval | -/// | 17 | `tag_len` | 2 | length of `tag`, `u16` little-endian | -/// | 19 | ZeroKMS key `tag` | `tag_len` | required to retrieve the key | -/// | 19 + `tag_len` | local ciphertext | rest of buffer | the vitaminc `LocalCipherText` | +/// | 1 | keyset id | 16 | the ZeroKMS keyset the data key was minted under, raw UUID bytes | +/// | 17 | ZeroKMS `iv` | 16 | identifies the data key for retrieval | +/// | 33 | `tag_len` | 2 | length of `tag`, `u16` little-endian | +/// | 35 | ZeroKMS key `tag` | `tag_len` | required to retrieve the key | +/// | 35 + `tag_len` | local ciphertext | rest of buffer | the vitaminc `LocalCipherText` | +/// +/// The keyset id is what lets a leaf be opened without the caller saying +/// which keyset it belongs to: retrieving the data key needs the keyset, +/// and the leaf is self-describing so that a leaf lifted from a tree — what +/// a database column holds — is too. /// /// The local ciphertext is itself a framed value — vitaminc's leaf wire /// format, versioned and owned by vitaminc — so the full stored byte string /// nests two framings, each led by its own version byte: /// /// ```text -/// ┌─ envelope (stack-encrypt, this table) ─────────────────────────────────────┐ -/// │ version ‖ iv ‖ tag_len ‖ tag ‖ ┌─ local ciphertext (vitaminc) ───────────┐ │ -/// │ 0x01 │ version ‖ nonce ‖ ciphertext ‖ gcm_tag │ │ -/// │ └─────────────────────────────────────────┘ │ -/// └────────────────────────────────────────────────────────────────────────────┘ +/// ┌─ envelope (stack-encrypt, this table) ──────────────────────────────────────────────┐ +/// │ version ‖ keyset_id ‖ iv ‖ tag_len ‖ tag ‖ ┌─ local ciphertext (vitaminc) ───────────┐ │ +/// │ 0x01 │ version ‖ nonce ‖ ciphertext ‖ gcm_tag │ │ +/// │ └─────────────────────────────────────────┘ │ +/// └─────────────────────────────────────────────────────────────────────────────────────┘ /// ``` /// -/// Both version bytes are authenticated under the one GCM tag, each bound by -/// the layer that owns its framing: the envelope version through this crate's -/// leaf-AAD derivation, `PAE("stack-encrypt/leaf", version, derived_aad, -/// tag)`, and the inner version through vitaminc's `Aad::for_leaf`, applied -/// inside `Aes256Cipher` to the AAD this crate hands it. Relabel either version byte in storage and the leaf fails -/// authentication rather than parsing under the wrong rules. Parsing is -/// structural only — nothing about a decoded leaf is trusted until it +/// Both version bytes and the keyset id are authenticated under the one GCM +/// tag, each bound by the layer that owns its framing: the envelope version +/// and the keyset id through this crate's leaf-AAD derivation, +/// `PAE("stack-encrypt/leaf", version, keyset_id, derived_aad, tag)`, and +/// the inner version through vitaminc's `Aad::for_leaf`, applied inside +/// `Aes256Cipher` to the AAD this crate hands it. Relabel either version +/// byte, or re-point the leaf at another keyset, in storage and the leaf +/// fails authentication rather than parsing under the wrong rules. Parsing +/// is structural only — nothing about a decoded leaf is trusted until it /// decrypts. /// /// The `serde` `Serialize`/`Deserialize` derives and @@ -561,6 +743,8 @@ impl<K: DataKeySource> StackCipher<K> { #[derive(Debug, Serialize, Deserialize)] #[serde(try_from = "SealedValueRepr")] pub struct SealedValue { + /// The keyset the data key was minted under; retrieval names it. + keyset_id: Uuid, /// ZeroKMS IV: identifies the data key for retrieval. iv: stack_kms::Iv, /// ZeroKMS key tag: required to retrieve the key, and bound into the @@ -620,8 +804,10 @@ impl SealedValue { let tag_len = u16::try_from(self.tag.len()).unwrap_or(u16::MAX); debug_assert_eq!(usize::from(tag_len), self.tag.len()); let ciphertext = self.ciphertext.as_ref(); - let mut out = Vec::with_capacity(1 + self.iv.len() + 2 + self.tag.len() + ciphertext.len()); + let mut out = + Vec::with_capacity(1 + 16 + self.iv.len() + 2 + self.tag.len() + ciphertext.len()); out.push(Self::FORMAT_VERSION); + out.extend_from_slice(self.keyset_id.as_bytes()); out.extend_from_slice(&self.iv); out.extend_from_slice(&tag_len.to_le_bytes()); out.extend_from_slice(&self.tag); @@ -639,9 +825,11 @@ impl SealedValue { if version != Self::FORMAT_VERSION { return Err(LeafBytesError::UnknownVersion(version)); } - if rest.len() < IV_LEN + 2 { + if rest.len() < 16 + IV_LEN + 2 { return Err(LeafBytesError::Truncated); } + let (keyset_bytes, rest) = rest.split_at(16); + let keyset_id = Uuid::from_slice(keyset_bytes).map_err(|_| LeafBytesError::Truncated)?; let (iv_bytes, rest) = rest.split_at(IV_LEN); let mut iv: stack_kms::Iv = [0; IV_LEN]; iv.copy_from_slice(iv_bytes); @@ -652,6 +840,7 @@ impl SealedValue { } let (tag, ciphertext) = rest.split_at(tag_len); Ok(Self { + keyset_id, iv, tag: tag.to_vec(), ciphertext: LocalCipherText::from(ciphertext.to_vec()), @@ -674,21 +863,35 @@ impl SealedValue { /// byte format's `u16` length field. Structural only: nothing about the /// parts is trusted until the leaf decrypts. pub fn from_parts( + keyset_id: Uuid, iv: stack_kms::Iv, tag: Vec<u8>, ciphertext: Vec<u8>, ) -> Result<Self, LeafBytesError> { Self::tag_fits_length_field(&tag)?; Ok(Self { + keyset_id, iv, tag, ciphertext: LocalCipherText::from(ciphertext), }) } - /// Decompose into `(iv, tag, ciphertext)` for persistence. - pub fn into_parts(self) -> (stack_kms::Iv, Vec<u8>, Vec<u8>) { - (self.iv, self.tag, self.ciphertext.into_inner().to_vec()) + /// Decompose into `(keyset_id, iv, tag, ciphertext)` for persistence. + pub fn into_parts(self) -> (Uuid, stack_kms::Iv, Vec<u8>, Vec<u8>) { + ( + self.keyset_id, + self.iv, + self.tag, + self.ciphertext.into_inner().to_vec(), + ) + } + + /// The keyset this leaf's data key was minted under, and so the one it + /// is retrieved from. Authenticated: a leaf re-pointed at another + /// keyset fails to open. + pub fn keyset_id(&self) -> Uuid { + self.keyset_id } /// The ZeroKMS IV identifying this leaf's data key. @@ -710,6 +913,7 @@ impl SealedValue { impl Clone for SealedValue { fn clone(&self) -> Self { Self { + keyset_id: self.keyset_id, iv: self.iv, tag: self.tag.clone(), ciphertext: LocalCipherText::from(self.ciphertext.as_ref().to_vec()), @@ -734,6 +938,7 @@ impl TryFrom<&[u8]> for SealedValue { #[derive(Deserialize)] #[serde(rename = "SealedValue")] struct SealedValueRepr { + keyset_id: Uuid, iv: stack_kms::Iv, tag: Vec<u8>, ciphertext: LocalCipherText, @@ -744,12 +949,14 @@ impl TryFrom<SealedValueRepr> for SealedValue { fn try_from(repr: SealedValueRepr) -> Result<Self, Self::Error> { let SealedValueRepr { + keyset_id, iv, tag, ciphertext, } = repr; Self::tag_fits_length_field(&tag)?; Ok(Self { + keyset_id, iv, tag, ciphertext, @@ -864,7 +1071,7 @@ impl PendingStackCipherText { /// `encrypt_with_aad`, in the same shape (see the /// [descriptor docs](crate::descriptor)). The tree itself only carries /// the *derived* per-leaf AADs, so the root is named here; nothing can - /// check that the two agree, which is why [`StackCipher::encrypt`], which + /// check that the two agree, which is why [`KeysetCipher::encrypt`], which /// does both steps from one value, is the form to prefer. /// /// Settles through the target layer's request carrier @@ -873,7 +1080,7 @@ impl PendingStackCipherText { /// is sealed and one path to ZeroKMS. pub async fn seal<'a, K: DataKeySource>( self, - cipher: &StackCipher<K>, + cipher: &KeysetCipher<'_, K>, aad: impl IntoAad<'a>, ) -> Result<StackCipherText, Error> { self.into_pending(cipher, aad).settle().await @@ -904,50 +1111,57 @@ impl PendingStackCipherText { /// way. pub fn into_pending<'c, 'a, K>( self, - cipher: &'a StackCipher<K>, + cipher: &'a KeysetCipher<'_, K>, aad: impl IntoAad<'c>, ) -> crate::target::Pending<'a, StackCipherText, K> { crate::target::seal_pending(cipher, self, Descriptor::of(aad)) } - /// Recursively seal, drawing one key per leaf from `keys` in traversal order. + /// Recursively seal under `keyset_id`, drawing one key per leaf from + /// `keys` in traversal order. pub(crate) fn seal_with( self, + keyset_id: Uuid, keys: &mut impl Iterator<Item = DataKeyWithTag>, ) -> Result<StackCipherText, Unspecified> { // Markers seal an *empty* plaintext so the AEAD tag still binds their // (already domain-separated) AAD, mirroring `Aes256Cipher`. fn seal_marker( aad: Aad<'static>, + keyset_id: Uuid, keys: &mut impl Iterator<Item = DataKeyWithTag>, ) -> Result<SealedValue, Unspecified> { let key = keys.next().ok_or(Unspecified)?; - seal_leaf(Protected::new(Vec::new()), &aad, key) + seal_leaf(Protected::new(Vec::new()), &aad, keyset_id, key) } match self { PendingStackCipherText::Single { plaintext, aad } => { let key = keys.next().ok_or(Unspecified)?; - Ok(CipherText::Single(seal_leaf(plaintext, &aad, key)?)) + Ok(CipherText::Single(seal_leaf( + plaintext, &aad, keyset_id, key, + )?)) } - PendingStackCipherText::None { aad } => Ok(CipherText::None(seal_marker(aad, keys)?)), - PendingStackCipherText::EmptySequence { aad } => { - Ok(CipherText::EmptySequence(seal_marker(aad, keys)?)) + PendingStackCipherText::None { aad } => { + Ok(CipherText::None(seal_marker(aad, keyset_id, keys)?)) } + PendingStackCipherText::EmptySequence { aad } => Ok(CipherText::EmptySequence( + seal_marker(aad, keyset_id, keys)?, + )), PendingStackCipherText::EmptyMap { aad } => { - Ok(CipherText::EmptyMap(seal_marker(aad, keys)?)) + Ok(CipherText::EmptyMap(seal_marker(aad, keyset_id, keys)?)) } PendingStackCipherText::Sequence(items) => { let mut out = Vec::with_capacity(items.len()); for item in items { - out.push(item.seal_with(keys)?); + out.push(item.seal_with(keyset_id, keys)?); } Ok(CipherText::Sequence(out)) } PendingStackCipherText::Map(entries) => { let mut out = Vec::with_capacity(entries.len()); for (k, v) in entries { - out.push((k, v.seal_with(keys)?)); + out.push((k, v.seal_with(keyset_id, keys)?)); } Ok(CipherText::Map(out)) } @@ -972,37 +1186,38 @@ fn leaf_cipher(key: &DataKey) -> Result<Aes256Cipher, Unspecified> { } /// Derives the effective AAD every leaf is sealed against — and opened -/// under — binding the caller's derived AAD, the ZeroKMS key `tag`, and the -/// [`SealedValue::FORMAT_VERSION`] byte that prefixes the leaf's frozen byte -/// encoding. +/// under — binding the caller's derived AAD, the ZeroKMS key `tag`, the +/// keyset the key was minted under, and the [`SealedValue::FORMAT_VERSION`] +/// byte that prefixes the leaf's frozen byte encoding. /// -/// The labelled four-piece PAE can never collide with a caller's own +/// The labelled five-piece PAE can never collide with a caller's own /// composite AAD (a tuple encodes with no leading domain label) or with /// vitaminc's internal derivations (different labels). Binding the format -/// version under the tag is what makes the byte in -/// [`SealedValue::to_bytes`] more than a parse hint: bytes relabelled with a +/// version and the keyset id under the tag is what makes those bytes in +/// [`SealedValue::to_bytes`] more than parse hints: bytes relabelled with a /// different version fail verification instead of selecting different /// parsing and derivation rules — mirroring vitaminc's `Aad::for_leaf`, -/// which binds the *inner* [`LocalCipherText`] wire version the same way. +/// which binds the *inner* [`LocalCipherText`] wire version the same way — +/// and a leaf re-pointed at another keyset fails verification instead of +/// asking that keyset for a key it never minted. /// /// The domain label deliberately carries no `/v1` suffix: the version is a /// *parameter* here, not part of the label. /// /// # Breaking change /// -/// This labelled four-piece derivation replaced an unlabelled `PAE(aad, tag)` -/// tuple. A leaf sealed under the old AAD and persisted (via serde or -/// [`SealedValue::into_parts`]) can no longer be opened: it fails +/// This derivation has changed twice while the crate is `publish = false` +/// (an unlabelled `PAE(aad, tag)` tuple; then a four-piece labelled form +/// without the keyset id), each time without a version bump, because only +/// dev-persisted data existed. A leaf sealed under an earlier form fails /// authentication in `open_leaf` with a plain AEAD error, indistinguishable -/// from tampering. There is deliberately no `UnknownVersion` signal for it — -/// the old form carried no version byte to detect. This is acceptable -/// because the crate is `publish = false` and only dev-persisted data -/// exists; re-encrypt anything that matters. -fn leaf_aad(aad: &Aad<'_>, tag: &[u8]) -> Aad<'static> { +/// from tampering; re-encrypt anything that matters. +fn leaf_aad(aad: &Aad<'_>, keyset_id: Uuid, tag: &[u8]) -> Aad<'static> { const LEAF_AAD_DOMAIN: &[u8] = b"stack-encrypt/leaf"; Aad::pae(&[ LEAF_AAD_DOMAIN, &[SealedValue::FORMAT_VERSION], + keyset_id.as_bytes(), aad.as_bytes(), tag, ]) @@ -1010,12 +1225,13 @@ fn leaf_aad(aad: &Aad<'_>, tag: &[u8]) -> Aad<'static> { /// Seal one plaintext leaf under a freshly generated data key. /// -/// The AAD is the [`leaf_aad`] derivation of the caller's (derived) AAD and -/// the key `tag` — `tag` is always bound, so the leaf is cryptographically -/// tied to its ZeroKMS data key. +/// The AAD is the [`leaf_aad`] derivation of the caller's (derived) AAD, the +/// keyset the key was minted under, and the key `tag` — `tag` is always +/// bound, so the leaf is cryptographically tied to its ZeroKMS data key. fn seal_leaf( plaintext: Protected<Vec<u8>>, aad: &Aad<'_>, + keyset_id: Uuid, key: DataKeyWithTag, ) -> Result<SealedValue, Unspecified> { // The `DataKeySource` is caller-supplied, so the key tag is not trusted @@ -1026,8 +1242,9 @@ fn seal_leaf( SealedValue::tag_fits_length_field(&key.tag).map_err(|_| Unspecified)?; let iv = key.key.iv; let cipher = leaf_cipher(&key.key)?; - match (&cipher).encrypt_bytes_vec(plaintext, leaf_aad(aad, &key.tag))? { + match (&cipher).encrypt_bytes_vec(plaintext, leaf_aad(aad, keyset_id, &key.tag))? { AesCipherText::Single(ciphertext) => Ok(SealedValue { + keyset_id, iv, tag: key.tag, ciphertext, @@ -1052,7 +1269,7 @@ fn open_leaf(keyed: KeyedLeaf, aad: &Aad<'_>) -> Result<Protected<Vec<u8>>, Unsp let cipher = leaf_cipher(&key)?; cipher .decipher(AesCipherText::Single(leaf.ciphertext)) - .decrypt_bytes(ProtectedBytes, leaf_aad(aad, &leaf.tag)) + .decrypt_bytes(ProtectedBytes, leaf_aad(aad, leaf.keyset_id, &leaf.tag)) } /// Open one marker leaf (absent / empty-sequence / empty-map) and require the @@ -1068,15 +1285,15 @@ fn verify_empty_marker(keyed: KeyedLeaf, aad: &Aad<'_>) -> Result<(), Unspecifie } // ============================================================================= -// Encrypt side: `Cipher` impl over a `&StackCipher` (builds the pending tree) +// Encrypt side: `Cipher` impl over a `&KeysetCipher` (builds the pending tree) // ============================================================================= -impl<'c, K> Cipher for &'c StackCipher<K> { +impl<'c, 'k, K> Cipher for &'c KeysetCipher<'k, K> { type Ok = PendingStackCipherText; type Error = Unspecified; type Passthrough = BoxedPassthrough; - type SeqCipher = PendingSeqCipher<'c, K>; - type MapCipher = PendingMapCipher<'c, K>; + type SeqCipher = PendingSeqCipher<'c, 'k, K>; + type MapCipher = PendingMapCipher<'c, 'k, K>; fn encrypt_bytes_vec<'a, A>( self, @@ -1148,8 +1365,8 @@ impl<'c, K> Cipher for &'c StackCipher<K> { /// [`SeqCipher`] driver: accumulates a pending sub-tree per element. Holds the /// cipher only to re-drive nested [`Encrypt`] values (no I/O happens here). -pub struct PendingSeqCipher<'c, K> { - cipher: &'c StackCipher<K>, +pub struct PendingSeqCipher<'c, 'k, K> { + cipher: &'c KeysetCipher<'k, K>, items: Vec<PendingStackCipherText>, /// The AAD fixed at [`Cipher::encrypt_seq`]; the empty marker is sealed /// against its `for_empty_sequence` derivation. @@ -1163,7 +1380,7 @@ pub struct PendingSeqCipher<'c, K> { encrypted: bool, } -impl<'c, K> SeqCipher for PendingSeqCipher<'c, K> { +impl<K> SeqCipher for PendingSeqCipher<'_, '_, K> { type Ok = PendingStackCipherText; type Error = Unspecified; type Passthrough = BoxedPassthrough; @@ -1207,8 +1424,8 @@ impl<'c, K> SeqCipher for PendingSeqCipher<'c, K> { /// [`MapCipher`] driver: keys are stored in the clear; values become pending /// sub-trees sealed against [`Aad::for_map_entry`] of the map AAD and their /// key. Mirrors `AesMapCipher`'s key/value and duplicate-key contract checks. -pub struct PendingMapCipher<'c, K> { - cipher: &'c StackCipher<K>, +pub struct PendingMapCipher<'c, 'k, K> { + cipher: &'c KeysetCipher<'k, K>, entries: Vec<(String, PendingStackCipherText)>, /// Duplicate keys are rejected at encrypt time: `decrypt_tree` rejects /// them outright, so accepting one here would produce a permanently @@ -1221,7 +1438,7 @@ pub struct PendingMapCipher<'c, K> { encrypted: bool, } -impl<'c, K> MapCipher for PendingMapCipher<'c, K> { +impl<K> MapCipher for PendingMapCipher<'_, '_, K> { type Ok = PendingStackCipherText; type Error = Unspecified; type Passthrough = BoxedPassthrough; @@ -1661,19 +1878,21 @@ mod tests { /// Byte-level pin for the [`leaf_aad`] derivation. This is part of the /// frozen leaf format: a change to the domain label, the version byte, - /// the piece order, or the PAE framing makes every stored leaf fail - /// authentication, so it must be deliberate — and must come with a + /// the keyset id's place, the piece order, or the PAE framing makes + /// every stored leaf fail authentication, so it must be deliberate — + /// and, once anything is stored, must come with a /// [`SealedValue::FORMAT_VERSION`] bump, which this pin forces into view. #[test] fn leaf_aad_bytes_are_pinned() { - let aad = leaf_aad(&Aad::from_slice(b"caller-aad"), b"key-tag"); + let keyset = Uuid::from_bytes(*b"keyset-fixture16"); + let aad = leaf_aad(&Aad::from_slice(b"caller-aad"), keyset, b"key-tag"); let hex: String = aad.as_bytes().iter().map(|b| format!("{b:02x}")).collect(); - // PAE: LE64 count (4) ‖ per piece LE64 length ‖ piece, the pieces - // being "stack-encrypt/leaf", [FORMAT_VERSION], the caller AAD, and - // the key tag. + // PAE: LE64 count (5) ‖ per piece LE64 length ‖ piece, the pieces + // being "stack-encrypt/leaf", [FORMAT_VERSION], the keyset id's 16 + // bytes, the caller AAD, and the key tag. assert_eq!( hex, - "04000000000000001200000000000000737461636b2d656e63727970742f6c6561660100000000000000010a0000000000000063616c6c65722d61616407000000000000006b65792d746167" + "05000000000000001200000000000000737461636b2d656e63727970742f6c65616601000000000000000110000000000000006b65797365742d6669787475726531360a0000000000000063616c6c65722d61616407000000000000006b65792d746167" ); } } diff --git a/packages/stack-encrypt/src/descriptor.rs b/packages/stack-encrypt/src/descriptor.rs index 0d3754f66..8b5ac97a3 100644 --- a/packages/stack-encrypt/src/descriptor.rs +++ b/packages/stack-encrypt/src/descriptor.rs @@ -78,8 +78,8 @@ impl Descriptor { /// Render `context` — anything that encodes as AAD — from its parts. /// - /// [`StackCipher::encrypt`](crate::StackCipher::encrypt) / - /// [`decrypt`](crate::StackCipher::decrypt) and the target-directed + /// [`KeysetCipher::encrypt`](crate::KeysetCipher::encrypt) / + /// [`StackCipher::decrypt`](crate::StackCipher::decrypt) and the target-directed /// leaves render the descriptor themselves; call this to see what a /// context will look like in the ZeroKMS log, or to check that it /// [`fits`](Self::fits) before sealing a large batch under it. diff --git a/packages/stack-encrypt/src/keyset.rs b/packages/stack-encrypt/src/keyset.rs new file mode 100644 index 000000000..4b4e072c8 --- /dev/null +++ b/packages/stack-encrypt/src/keyset.rs @@ -0,0 +1,309 @@ +//! Keysets: the one thing a client holds more than one of. +//! +//! A [`StackCipher`] is scoped to a client — one ZeroKMS client, one client +//! key — and a client may use any number of keysets: one per tenant is the +//! common shape. A [`KeysetCipher`] is the cipher bound to one of them, and +//! it is what every operation that *mints* something binds to: sealing +//! values, sealing records, deriving index terms. Decrypting is not +//! keyset-scoped (a sealed leaf carries the id of the keyset it was sealed +//! under), so it lives on [`StackCipher`] as well, with the [`KeysetCipher`] +//! form adding a constraint rather than a capability — see +//! [`KeysetCipher::decrypt`]. +//! +//! Keysets load lazily. [`StackCipher::keyset`] resolves an id or a name +//! through a bounded cache and loads the keyset from ZeroKMS on a miss — +//! one round trip, paid once per keyset per process (or again after +//! eviction). That is the one async point: everything on the returned +//! handle keeps its shape. The default keyset — the one named on the +//! builder, else the client's — is loaded eagerly by +//! [`init`](crate::StackCipherBuilder::init), so a misconfigured client +//! fails at startup, and never evicts. + +use std::collections::HashMap; +use std::num::NonZeroUsize; +use std::sync::Arc; + +use stack_kms::IdentifiedBy; +use uuid::Uuid; +use vitaminc_hmac::HmacSha256Prf; + +use crate::StackCipher; + +/// What the cipher holds per loaded keyset: its resolved id, the name it was +/// loaded under if any, and the PRF keyed by its index key. +pub(crate) struct KeysetState { + pub(crate) id: Uuid, + pub(crate) name: Option<String>, + pub(crate) prf: HmacSha256Prf, +} + +impl KeysetState { + /// Whether `by` names this keyset: its id, or the name it was loaded + /// under. A keyset loaded by id does not know its name, so a later + /// lookup by name misses and loads again — ZeroKMS resolves the name to + /// the same id, and the cache then holds one state under both. + pub(crate) fn is(&self, by: &IdentifiedBy) -> bool { + match by { + IdentifiedBy::Uuid(id) => self.id == *id, + IdentifiedBy::Name(name) => self.name.as_deref() == Some(&**name), + } + } +} + +/// The bounded, least-recently-used cache of loaded keysets behind +/// [`StackCipher::keyset`]. +/// +/// Entries are keyed by id, with a name index beside them for keysets loaded +/// by name. Eviction drops the least recently *used* entry, where a use is +/// any lookup hit; nothing stored depends on the cache (a sealed leaf carries +/// its keyset id, and terms carry nothing), so eviction is invisible except +/// for the round trip the next lookup pays. The default keyset is not in +/// here and never evicts. +/// +/// Hits are `O(1)`; an insert into a full cache scans for the oldest entry, +/// `O(n)` in the bound, which is the rare case by construction. +pub(crate) struct KeysetCache { + capacity: NonZeroUsize, + /// Monotonic use counter; an entry's tick is the last time it was hit. + tick: u64, + by_id: HashMap<Uuid, (Arc<KeysetState>, u64)>, + by_name: HashMap<String, Uuid>, +} + +impl KeysetCache { + /// The bound a [`StackCipher`] uses unless the builder says otherwise: + /// a thousand-tenant process pays ZeroKMS once per tenant per cold + /// start and then not again. + pub(crate) const DEFAULT_CAPACITY: NonZeroUsize = NonZeroUsize::MIN.saturating_add(1023); + + pub(crate) fn new(capacity: NonZeroUsize) -> Self { + Self { + capacity, + tick: 0, + by_id: HashMap::new(), + by_name: HashMap::new(), + } + } + + /// Look a keyset up by id or name, marking it most recently used. + pub(crate) fn get(&mut self, by: &IdentifiedBy) -> Option<Arc<KeysetState>> { + let id = match by { + IdentifiedBy::Uuid(id) => *id, + IdentifiedBy::Name(name) => *self.by_name.get::<str>(name)?, + }; + let (state, last_used) = self.by_id.get_mut(&id)?; + self.tick += 1; + *last_used = self.tick; + Some(Arc::clone(state)) + } + + /// Insert a freshly loaded keyset, evicting the least recently used + /// entry first if the cache is full. Loading the same keyset twice + /// (two lookups racing on the same miss) replaces the entry with an + /// equivalent one and adds its name if the second load knew it. + pub(crate) fn insert(&mut self, state: Arc<KeysetState>) { + if !self.by_id.contains_key(&state.id) && self.by_id.len() >= self.capacity.get() { + self.evict_oldest(); + } + if let Some(name) = &state.name { + let _ = self.by_name.insert(name.clone(), state.id); + } + self.tick += 1; + let _ = self.by_id.insert(state.id, (state, self.tick)); + } + + fn evict_oldest(&mut self) { + let Some(oldest) = self + .by_id + .iter() + .min_by_key(|(_, (_, tick))| *tick) + .map(|(id, _)| *id) + else { + return; + }; + if let Some((state, _)) = self.by_id.remove(&oldest) { + if let Some(name) = &state.name { + let _ = self.by_name.remove(name); + } + } + } + + #[cfg(test)] + pub(crate) fn len(&self) -> usize { + self.by_id.len() + } +} + +/// A [`StackCipher`] bound to one keyset: what sealing and term derivation +/// bind to, and what a decrypt that must stay within one keyset binds to. +/// +/// Obtained from [`StackCipher::keyset`] (any keyset, loaded on first use) +/// or [`StackCipher::default_keyset`]. Cheap to clone and to hold: a +/// reference to the cipher plus a shared handle on the keyset's loaded +/// state, so a request handler can take one per tenant and hand it around. +/// +/// The type a caller holds states the guarantee it gets. A `KeysetCipher` +/// for tenant A mints every data key under A's keyset, derives every term +/// under A's index key, and refuses — before any ZeroKMS call — to open a +/// leaf sealed under any other keyset ([`Error::ForeignKeyset`]). The +/// [`StackCipher`] it came from opens leaves from any keyset the client +/// is authorised for. +/// +/// [`Error::ForeignKeyset`]: crate::Error::ForeignKeyset +pub struct KeysetCipher<'k, K> { + cipher: &'k StackCipher<K>, + state: Arc<KeysetState>, +} + +impl<K> Clone for KeysetCipher<'_, K> { + fn clone(&self) -> Self { + Self { + cipher: self.cipher, + state: Arc::clone(&self.state), + } + } +} + +impl<'k, K> KeysetCipher<'k, K> { + pub(crate) fn new(cipher: &'k StackCipher<K>, state: Arc<KeysetState>) -> Self { + Self { cipher, state } + } + + /// The client-scoped cipher this keyset belongs to. + pub fn cipher(&self) -> &'k StackCipher<K> { + self.cipher + } + + /// The keyset every data key this handle mints is generated under, and + /// whose index key keys [`prf`](Self::prf). Resolved: a keyset selected + /// by name reports its id here. + pub fn keyset_id(&self) -> Uuid { + self.state.id + } + + /// The name this keyset was selected by, if it was selected by name + /// (the default keyset knows its name only when the builder named it). + pub fn keyset_name(&self) -> Option<&str> { + self.state.name.as_deref() + } + + /// The PRF index terms are derived from, keyed by this keyset's index + /// key. + /// + /// Public so that other crates can implement their own term types + /// against this cipher (see [`crate::sem`]). + pub fn prf(&self) -> &HmacSha256Prf { + &self.state.prf + } + + /// The underlying data-key source. + pub fn kms(&self) -> &'k K { + self.cipher.kms() + } +} + +#[cfg(test)] +mod tests { + #![allow(clippy::unwrap_used)] + + use super::*; + use vitaminc_prf::PrfKeyInit; + use vitaminc_protected::Protected; + + fn state(id: u128, name: Option<&str>) -> Arc<KeysetState> { + Arc::new(KeysetState { + id: Uuid::from_u128(id), + name: name.map(str::to_owned), + prf: HmacSha256Prf::new(Protected::new([id as u8; 32])), + }) + } + + fn name(name: &str) -> IdentifiedBy { + IdentifiedBy::Name(name.to_string().into()) + } + + fn cache(capacity: usize) -> KeysetCache { + KeysetCache::new(NonZeroUsize::new(capacity).unwrap()) + } + + #[test] + fn a_keyset_is_found_by_id_and_by_the_name_it_loaded_under() { + let mut cache = cache(4); + cache.insert(state(1, Some("customers"))); + + assert!(cache.get(&Uuid::from_u128(1).into()).is_some()); + assert!(cache.get(&name("customers")).is_some()); + assert!(cache.get(&name("staff")).is_none()); + assert!(cache.get(&Uuid::from_u128(2).into()).is_none()); + } + + #[test] + fn a_keyset_loaded_by_id_is_not_found_by_name() { + let mut cache = cache(4); + cache.insert(state(1, None)); + + assert!(cache.get(&Uuid::from_u128(1).into()).is_some()); + assert!(cache.get(&name("customers")).is_none()); + } + + #[test] + fn the_least_recently_used_keyset_is_evicted_first() { + let mut cache = cache(2); + cache.insert(state(1, Some("one"))); + cache.insert(state(2, Some("two"))); + // Touch 1 so 2 is the oldest. + assert!(cache.get(&Uuid::from_u128(1).into()).is_some()); + + cache.insert(state(3, Some("three"))); + + assert_eq!(cache.len(), 2); + assert!( + cache.get(&Uuid::from_u128(2).into()).is_none(), + "2 was oldest" + ); + assert!( + cache.get(&name("two")).is_none(), + "the evicted keyset's name goes with it" + ); + assert!(cache.get(&Uuid::from_u128(1).into()).is_some()); + assert!(cache.get(&Uuid::from_u128(3).into()).is_some()); + } + + #[test] + fn reinserting_a_cached_keyset_does_not_evict() { + let mut cache = cache(2); + cache.insert(state(1, None)); + cache.insert(state(2, None)); + + // Same id again, now with a name: replaces, evicts nothing. + cache.insert(state(1, Some("one"))); + + assert_eq!(cache.len(), 2); + assert!(cache.get(&Uuid::from_u128(2).into()).is_some()); + assert!(cache.get(&name("one")).is_some()); + } + + #[test] + fn a_cache_of_one_holds_the_latest_keyset() { + let mut cache = cache(1); + cache.insert(state(1, None)); + cache.insert(state(2, None)); + + assert_eq!(cache.len(), 1); + assert!(cache.get(&Uuid::from_u128(1).into()).is_none()); + assert!(cache.get(&Uuid::from_u128(2).into()).is_some()); + } + + #[test] + fn state_matches_its_id_and_its_name() { + let named = state(1, Some("customers")); + assert!(named.is(&Uuid::from_u128(1).into())); + assert!(named.is(&name("customers"))); + assert!(!named.is(&name("staff"))); + assert!(!named.is(&Uuid::from_u128(2).into())); + + let anonymous = state(1, None); + assert!(anonymous.is(&Uuid::from_u128(1).into())); + assert!(!anonymous.is(&name("customers"))); + } +} diff --git a/packages/stack-encrypt/src/lib.rs b/packages/stack-encrypt/src/lib.rs index c117a2fad..a0487ebcf 100644 --- a/packages/stack-encrypt/src/lib.rs +++ b/packages/stack-encrypt/src/lib.rs @@ -22,12 +22,16 @@ #![cfg_attr(test, allow(unused_results))] //! Encrypt Rust values under per-value ZeroKMS data keys. //! -//! [`StackCipher`] encrypts any value that implements [`Encrypt`] (`String`, -//! `Vec<T>`, `HashMap<K, V>`, `Option<T>`, `Protected<T>`, your own types, and -//! any nesting of them) and decrypts back into any [`Decrypt`] type. Every -//! scalar inside the value is sealed under its **own** ZeroKMS data key, so each -//! value access is an individually auditable key retrieval — there is no -//! long-lived key in your process. +//! A [`StackCipher`] is scoped to one ZeroKMS client, and a [`KeysetCipher`] — +//! the cipher bound to one of that client's keysets, from +//! [`default_keyset`](StackCipher::default_keyset) or +//! [`keyset`](StackCipher::keyset) — encrypts any value that implements +//! [`Encrypt`] (`String`, `Vec<T>`, `HashMap<K, V>`, `Option<T>`, +//! `Protected<T>`, your own types, and any nesting of them). Either cipher +//! decrypts back into any [`Decrypt`] type. Every scalar inside the value is +//! sealed under its **own** ZeroKMS data key, so each value access is an +//! individually auditable key retrieval — there is no long-lived key in your +//! process. //! //! # Quick start //! @@ -45,8 +49,9 @@ use stack_encrypt::StackCipher; // Credentials: `npx stash auth login` on a developer machine, or // CS_CLIENT_ID / CS_CLIENT_KEY + CS_CLIENT_ACCESS_KEY / CS_WORKSPACE_CRN in CI. let cipher = StackCipher::new().await?; +let keyset = cipher.default_keyset(); -let ciphertext = cipher.encrypt("secret message".to_string(), ()).await?; +let ciphertext = keyset.encrypt("secret message".to_string(), ()).await?; let plaintext: String = cipher.decrypt(ciphertext, ()).await?; assert_eq!(plaintext, "secret message"); # Ok(()) @@ -61,6 +66,14 @@ assert_eq!(plaintext, "secret message"); ZeroKMS credentials itself." )] //! +//! Encrypting binds to a keyset (every data key is minted under one); decrypting +//! does not (every sealed leaf carries the id of the keyset it was sealed +//! under), so it goes through the client-scoped `cipher` — or through the +//! `keyset`, which then refuses leaves from any other keyset. A client may use +//! many keysets, one per tenant say; [`StackCipher::keyset`] selects any of +//! them by id or name, loading it on first use. The [`keyset`](crate::keyset) +//! module docs lay out the model. +//! //! The second argument is the *associated data* (AAD): anything that implements //! [`IntoAad`] — `()`, `&[u8]`, `&str`, a tuple, or a derived [`Aad`]. It is //! authenticated, not encrypted, and must be supplied identically on decrypt. @@ -69,7 +82,8 @@ assert_eq!(plaintext, "secret message"); //! //! ```no_run //! # async fn example<K: stack_kms::DataKeySource>(cipher: stack_encrypt::StackCipher<K>) -> Result<(), stack_encrypt::Error> { -//! let ct = cipher.encrypt("4111 1111 1111 1111".to_string(), "users/42/card").await?; +//! # let keyset = cipher.default_keyset(); +//! let ct = keyset.encrypt("4111 1111 1111 1111".to_string(), "users/42/card").await?; //! let card: String = cipher.decrypt(ct, "users/42/card").await?; // ok //! # Ok(()) //! # } @@ -97,7 +111,8 @@ assert_eq!(plaintext, "secret message"); //! .kms(FakeDataKeySource::new()) //! .init() //! .await?; -//! let ct = cipher.encrypt(vec!["a".to_string(), "b".to_string()], ()).await?; +//! let keyset = cipher.default_keyset(); +//! let ct = keyset.encrypt(vec!["a".to_string(), "b".to_string()], ()).await?; //! let pt: Vec<String> = cipher.decrypt(ct, ()).await?; //! assert_eq!(pt, vec!["a", "b"]); //! # Ok::<(), stack_encrypt::Error>(()) @@ -106,13 +121,15 @@ assert_eq!(plaintext, "secret message"); //! //! # Storing ciphertext //! -//! [`encrypt`](StackCipher::encrypt) returns a [`StackCipherText`]: a tree whose +//! [`encrypt`](KeysetCipher::encrypt) returns a [`StackCipherText`]: a tree whose //! shape mirrors the value (a scalar is a single leaf, a `Vec` a sequence of //! leaves, a map a set of named leaves) and whose leaves are [`SealedValue`]s. //! A `SealedValue` is the persistable unit: its canonical, frozen byte //! encoding is [`to_bytes`](SealedValue::to_bytes) / //! [`from_bytes`](SealedValue::from_bytes) — the format a database column -//! holds and every language binding reads. For callers that manage their own +//! holds and every language binding reads. Each leaf carries the id of the +//! keyset it was sealed under, which is what lets a column be opened with no +//! keyset named. For callers that manage their own //! storage format it also implements `serde` `Serialize`/`Deserialize` and //! offers [`into_parts`](SealedValue::into_parts) / //! [`from_parts`](SealedValue::from_parts). Map keys are stored in the clear @@ -142,7 +159,7 @@ assert_eq!(plaintext, "secret message"); //! //! # Relationship to vitaminc //! -//! `StackCipher` is a vitaminc [`Cipher`]; everything a vitaminc cipher can +//! A `KeysetCipher` is a vitaminc [`Cipher`]; everything a vitaminc cipher can //! encrypt, it can encrypt, and the AEAD, AAD derivations and leaf wire format //! are vitaminc's (`vitaminc_encrypt::Aes256Cipher`, AES-256-GCM under a random //! per-leaf nonce vitaminc generates itself). The ZeroKMS `iv` a [`SealedValue`] @@ -155,6 +172,7 @@ assert_eq!(plaintext, "secret message"); pub mod cipher; pub mod descriptor; +pub mod keyset; pub mod sem; pub mod target; @@ -163,9 +181,11 @@ pub use cipher::{ StackCipher, StackCipherBuilder, StackCipherText, StackDecipher, }; pub use descriptor::Descriptor; +pub use keyset::KeysetCipher; pub use target::{ - DecryptField, DecryptFrom, DecryptInto, DecryptTarget, Decryptable, ElementContext, - EncryptFrom, EncryptInto, EncryptTarget, Pending, PendingFuture, Request, Responses, + CipherScope, DecryptField, DecryptFrom, DecryptInto, DecryptTarget, Decryptable, + ElementContext, EncryptFrom, EncryptInto, EncryptTarget, Pending, PendingFuture, Request, + Responses, }; // Re-export the vitaminc AEAD surface callers need to drive the cipher, so they diff --git a/packages/stack-encrypt/src/sem/mod.rs b/packages/stack-encrypt/src/sem/mod.rs index 6c86056eb..7154500d1 100644 --- a/packages/stack-encrypt/src/sem/mod.rs +++ b/packages/stack-encrypt/src/sem/mod.rs @@ -21,14 +21,21 @@ //! derived through the PRF; range queries. //! //! Every term type here is built on exactly one thing the cipher exposes -//! publicly — its PRF ([`StackCipher::prf`]) — with no privileged access, so -//! they double as worked examples for defining your own term types in another +//! publicly — its PRF ([`KeysetCipher::prf`], keyed by the index key of the +//! keyset the handle is bound to) — with no privileged access, so they +//! double as worked examples for defining your own term types in another //! crate (see [`target`](crate::target#extending-with-your-own-sem-type)). +//! Terms bind to a keyset the way sealed values do: a term derived through +//! one tenant's [`KeysetCipher`] compares only against terms derived through +//! the same keyset. //! -//! Alongside the target-directed path, the cipher carries descriptor -//! methods ([`StackCipher::equality_term`] and friends) for call sites that +//! Alongside the target-directed path, the keyset cipher carries descriptor +//! methods ([`KeysetCipher::equality_term`] and friends) for call sites that //! want a single term rather than a whole record — query builders, mostly. -//! They derive no data keys, so building a probe never calls ZeroKMS. The +//! Each returns a [`Pending`], as the record path does; they derive no data +//! keys, and under the local HMAC backend the pending carries no requests, +//! so a probe settles without a ZeroKMS call — though a backend that derives +//! terms at ZeroKMS settles it through the same pending. The //! descriptor is the same [`NonEmpty`] context the target-directed path //! takes, so the two agree byte for byte. //! @@ -44,9 +51,11 @@ //! The backend today is the local //! [`HmacSha256Prf`] — keyed by the //! deterministic per-keyset [`IndexKey`](stack_kms::IndexKey) from -//! [`stack_kms::IndexKeySource`] — so every derivation completes with no I/O -//! and an [`EncryptFrom`] term carries **no requests** in its -//! [`Pending`]. The next ZeroKMS release adds 2-party PRF generation; under +//! [`stack_kms::IndexKeySource`], loaded when the keyset is selected — so +//! every derivation completes with no I/O and an [`EncryptFrom`] term +//! carries **no requests** in its [`Pending`]. That is the backend's +//! property, not the API's: the term is a `Pending` either way. The next +//! ZeroKMS release adds 2-party PRF generation; under //! that backend a term's `encrypt_from` pushes a PRF *request* instead and //! runs the **same visitor** over the blocks the server returns — the shaping //! code does not change, and terms then share the one batched ZeroKMS call @@ -123,7 +132,7 @@ use std::fmt; use std::marker::PhantomData; // Re-exported because they appear in this module's public bounds -// ([`StackCipher::ore_term`], [`OreTerm`], ...): a caller writing a generic +// ([`KeysetCipher::ore_term`], [`OreTerm`], ...): a caller writing a generic // wrapper over the term APIs has to be able to name them without depending // on `cllw-ore` directly. pub use cllw_ore::{CllwOpeEncrypt, CllwOreEncrypt}; @@ -135,8 +144,10 @@ use vitaminc_prf::{ use vitaminc_protected::NonEmpty; use zeroize::Zeroize; -use crate::target::{DecryptField, DecryptTarget, Decryptable, EncryptFrom, Pending}; -use crate::{Error, StackCipher}; +use stack_kms::MaybeSend; + +use crate::target::{DecryptField, Decryptable, EncryptFrom, Pending}; +use crate::{Error, KeysetCipher, StackCipher}; // The `/v1` suffix versions the *derivation* (domain + input framing), not the // crate. Any change to the bytes a term derives from must bump it: a changed @@ -324,9 +335,10 @@ where .map_err(TermError::from_prf) } -/// An equality term of any [`PrfValue`] source. Derived locally during the -/// synchronous build — the returned [`Pending`] carries no requests. -impl<'c, S, K, T> EncryptFrom<S, StackCipher<K>, NonEmpty<T>> for EqualityTerm +/// An equality term of any [`PrfValue`] source. Under the local HMAC +/// backend, derived during the synchronous build — the returned [`Pending`] +/// carries no requests. +impl<'c, 'k, S, K, T> EncryptFrom<S, KeysetCipher<'k, K>, NonEmpty<T>> for EqualityTerm where S: PrfValue + Clone, T: IntoPrfContext<'c>, @@ -336,7 +348,7 @@ where fn encrypt_from<'a>( source: &'a S, - cipher: &'a StackCipher<K>, + cipher: &'a KeysetCipher<'k, K>, context: NonEmpty<T>, ) -> Pending<'a, Self, K> where @@ -635,10 +647,11 @@ fn match_term<O>( Ok(MatchTerm::normalised(positions)) } -/// A match term of any text source, generated under `O`'s options. Derived -/// locally during the synchronous build — the returned [`Pending`] carries no -/// requests (tokenize makes the one necessary copy of the text). -impl<'c, S, K, O, T> EncryptFrom<S, StackCipher<K>, NonEmpty<T>> for MatchTerm<O> +/// A match term of any text source, generated under `O`'s options. Under +/// the local HMAC backend, derived during the synchronous build — the +/// returned [`Pending`] carries no requests (tokenize makes the one +/// necessary copy of the text). +impl<'c, 'k, S, K, O, T> EncryptFrom<S, KeysetCipher<'k, K>, NonEmpty<T>> for MatchTerm<O> where S: AsRef<str>, O: MatchConfig, @@ -649,7 +662,7 @@ where fn encrypt_from<'a>( source: &'a S, - cipher: &'a StackCipher<K>, + cipher: &'a KeysetCipher<'k, K>, context: NonEmpty<T>, ) -> Pending<'a, Self, K> where @@ -688,14 +701,16 @@ macro_rules! index_term { const DECRYPTABLE: bool = false; } - impl<__P, __C: DecryptTarget, __Ctx $(, $param: $bound)?> DecryptField<__P, __C, __Ctx> + // Over `StackCipher` only: the `KeysetCipher` form is the blanket + // in `target`, as for every `DecryptField`. + impl<__P, __K, __Ctx $(, $param: $bound)?> DecryptField<__P, StackCipher<__K>, __Ctx> for $ty { fn decrypt_field<'a>( self, - _cipher: &'a __C, + _cipher: &'a StackCipher<__K>, _context: __Ctx, - ) -> Option<__C::Output<'a, __P>> + ) -> Option<Pending<'a, __P, __K>> where Self: 'a, __P: 'a, @@ -936,9 +951,10 @@ where .map_err(TermError::Ore) } -/// An ORE term of any [`CllwOreEncrypt`] source. Derived locally during the -/// synchronous build — the returned [`Pending`] carries no requests. -impl<'c, S, K, T> EncryptFrom<S, StackCipher<K>, NonEmpty<T>> for OreTerm<S> +/// An ORE term of any [`CllwOreEncrypt`] source. Under the local HMAC +/// backend, derived during the synchronous build — the returned [`Pending`] +/// carries no requests. +impl<'c, 'k, S, K, T> EncryptFrom<S, KeysetCipher<'k, K>, NonEmpty<T>> for OreTerm<S> where S: CllwOreEncrypt + Clone + Send + 'static, S::Output: Send + 'static, @@ -949,7 +965,7 @@ where fn encrypt_from<'a>( source: &'a S, - cipher: &'a StackCipher<K>, + cipher: &'a KeysetCipher<'k, K>, context: NonEmpty<T>, ) -> Pending<'a, Self, K> where @@ -961,9 +977,10 @@ where } } -/// An OPE term of any [`CllwOpeEncrypt`] source. Derived locally during the -/// synchronous build — the returned [`Pending`] carries no requests. -impl<'c, S, K, T> EncryptFrom<S, StackCipher<K>, NonEmpty<T>> for OpeTerm<S> +/// An OPE term of any [`CllwOpeEncrypt`] source. Under the local HMAC +/// backend, derived during the synchronous build — the returned [`Pending`] +/// carries no requests. +impl<'c, 'k, S, K, T> EncryptFrom<S, KeysetCipher<'k, K>, NonEmpty<T>> for OpeTerm<S> where S: CllwOpeEncrypt + Clone + Send + 'static, S::Output: Send + 'static, @@ -974,7 +991,7 @@ where fn encrypt_from<'a>( source: &'a S, - cipher: &'a StackCipher<K>, + cipher: &'a KeysetCipher<'k, K>, context: NonEmpty<T>, ) -> Pending<'a, Self, K> where @@ -994,28 +1011,34 @@ where /// a whole record: query builders probing an index, re-indexers, tests of a /// single scheme. /// -/// Every [`StackCipher`] carries the PRF keyed by its keyset's index key, so -/// these need no data-key traffic at all — a query builder holding a cipher -/// never touches ZeroKMS to build a probe. The descriptor is a context as -/// the target-directed leaves take it — a [`NonEmpty<T>`]: +/// Each returns a [`Pending`], the same carrier the record path hands back, +/// so probes combine ([`Pending::zip`], [`Pending::all`]) and a batch of +/// them settles as one. Under the local HMAC backend a term is derived +/// during the synchronous build and the pending carries no requests — +/// awaiting it does no I/O — but that is the backend's property, not the +/// API's: a backend that derives terms at ZeroKMS settles them the way it +/// settles data keys, through the same pending. The descriptor is a context +/// as the target-directed leaves take it — a [`NonEmpty<T>`]: /// `nonempty!("users/email")`, `NonEmpty::new(column)?`, /// `nonempty!("users/email").with(row_id)` — and each method is /// byte-identical to that path for the same descriptor, so a term generated /// here compares against one generated by `encrypt_into_with_context`. -impl<K> StackCipher<K> { +impl<K> KeysetCipher<'_, K> { /// Generate an equality (exact-match) term for `value` under the field /// `descriptor`. Deterministic: the same value + descriptor always yields /// the same term, at write time and at query time. Byte-identical to - /// `value.encrypt_into_with_context(&cipher, descriptor)` into an `EqualityTerm`. - pub async fn equality_term<'c, T>( + /// `value.encrypt_into_with_context(&keyset, descriptor)` into an + /// `EqualityTerm`. + pub fn equality_term<'c, T>( &self, value: T, descriptor: NonEmpty<impl IntoPrfContext<'c>>, - ) -> Result<EqualityTerm, TermError> + ) -> Pending<'_, EqualityTerm, K> where T: PrfValue, { - equality(self.prf(), value, descriptor.into_prf_context()) + let term = equality(self.prf(), value, descriptor.into_prf_context()); + Pending::ready(self, term.map_err(Error::from)) } /// Generate a match (full-text) term for `text` under the field @@ -1036,17 +1059,21 @@ impl<K> StackCipher<K> { /// Returns [`TermError::EmptyTermText`] when the text yields no tokens — /// empty or separator-only text, or an n-gram probe shorter than the gram /// length (which could never match; see [`Tokenizer::Ngram`]). - pub async fn match_terms<'c, O: MatchConfig>( + pub fn match_terms<'c, O>( &self, text: &str, descriptor: NonEmpty<impl IntoPrfContext<'c>>, - ) -> Result<MatchTerm<O>, TermError> { - match_term( + ) -> Pending<'_, MatchTerm<O>, K> + where + O: MatchConfig + MaybeSend, + { + let term = match_term( self.prf(), text, descriptor.into_prf_context(), O::options(), - ) + ); + Pending::ready(self, term.map_err(Error::from)) } /// Generate an order-revealing (CLLW ORE) term for a range-queryable value @@ -1060,31 +1087,33 @@ impl<K> StackCipher<K> { /// (`'static`) because the visitor carries it; pass a `String` for /// borrowed text. Returns the raw CLLW ciphertext; the target-directed /// path wraps the same bytes in [`OreTerm`]. - pub async fn ore_term<'c, T>( + pub fn ore_term<'c, T>( &self, value: T, descriptor: NonEmpty<impl IntoPrfContext<'c>>, - ) -> Result<T::Output, TermError> + ) -> Pending<'_, T::Output, K> where T: CllwOreEncrypt + Send + 'static, T::Output: Send + 'static, { - ore(self.prf(), value, descriptor.into_prf_context()).map(OreTerm::into_inner) + let term = ore(self.prf(), value, descriptor.into_prf_context()).map(OreTerm::into_inner); + Pending::ready(self, term.map_err(Error::from)) } /// Generate an order-preserving (CLLW OPE) term: ciphertexts compare with /// plain lexicographic byte order, no custom comparator required. /// Encrypt-only — pair with the record ciphertext for round-trips. Key /// handling and input bounds as for [`ore_term`](Self::ore_term). - pub async fn ope_term<'c, T>( + pub fn ope_term<'c, T>( &self, value: T, descriptor: NonEmpty<impl IntoPrfContext<'c>>, - ) -> Result<T::Output, TermError> + ) -> Pending<'_, T::Output, K> where T: CllwOpeEncrypt + Send + 'static, T::Output: Send + 'static, { - ope(self.prf(), value, descriptor.into_prf_context()).map(OpeTerm::into_inner) + let term = ope(self.prf(), value, descriptor.into_prf_context()).map(OpeTerm::into_inner); + Pending::ready(self, term.map_err(Error::from)) } } diff --git a/packages/stack-encrypt/src/sem/tokenize.rs b/packages/stack-encrypt/src/sem/tokenize.rs index 1fa69ab37..19ac3c119 100644 --- a/packages/stack-encrypt/src/sem/tokenize.rs +++ b/packages/stack-encrypt/src/sem/tokenize.rs @@ -17,7 +17,7 @@ pub enum Tokenizer { /// (whitespace included). Text shorter than `length` yields **no tokens**, /// exactly like the v1 match indexer — so a probe shorter than the gram /// length is rejected by - /// [`match_terms`](crate::StackCipher::match_terms) rather than + /// [`match_terms`](crate::KeysetCipher::match_terms) rather than /// silently never matching. This is the default, matching the existing /// match indexer's 3-gram configuration. Ngram { length: usize }, diff --git a/packages/stack-encrypt/src/target/mod.rs b/packages/stack-encrypt/src/target/mod.rs index 175a9d810..5d502377b 100644 --- a/packages/stack-encrypt/src/target/mod.rs +++ b/packages/stack-encrypt/src/target/mod.rs @@ -7,13 +7,15 @@ //! record shape in charge: //! //! ```text -//! let term: EqualityTerm = value.encrypt_into_with_context(&cipher, nonempty!("users/email")).await?; -//! let row: EncryptedUser = user.encrypt_into(&cipher).await?; -//! let row: EncryptedUser = user.encrypt_into_with_context(&cipher, user_id).await?; +//! let term: EqualityTerm = value.encrypt_into_with_context(&keyset, nonempty!("users/email")).await?; +//! let row: EncryptedUser = user.encrypt_into(&keyset).await?; +//! let row: EncryptedUser = user.encrypt_into_with_context(&keyset, user_id).await?; //! ``` //! //! compiles only when the output type declares itself an encrypted form of -//! the value's type, producible by that cipher, under that context — and a +//! the value's type, producible by that cipher (a [`KeysetCipher`]: every +//! data key is minted, and every term derived, under one keyset), under that +//! context — and a //! context is something the output type may already have. A leaf has //! nothing of its own to authenticate under and takes the caller's, proven //! non-empty before it arrives ([`NonEmpty`]). A struct encrypted field by @@ -54,8 +56,11 @@ //! `encrypt_with_aad`. Never implemented by hand. //! * [`EncryptTarget`] / [`DecryptTarget`] — implemented by ciphers; their //! `Output` type decides what a call site gets back. A synchronous cipher -//! returns `Result<T, E>` directly; [`StackCipher`] returns a [`Pending`], -//! which does its ZeroKMS I/O — **one batched call** — when awaited. +//! returns `Result<T, E>` directly; [`KeysetCipher`] (the encrypt target) +//! and [`StackCipher`] (the decrypt target — a sealed leaf names its own +//! keyset, so opening is not keyset-scoped; a `KeysetCipher` decrypts too, +//! refusing leaves from any other keyset) return a [`Pending`], which does +//! its ZeroKMS I/O — **one batched call** — when awaited. //! //! # Contexts //! @@ -131,14 +136,15 @@ //! //! # tokio::runtime::Builder::new_current_thread().enable_all().build().unwrap().block_on(async { //! # let cipher = StackCipher::builder().kms(FakeDataKeySource::new()).init().await?; +//! # let keyset = cipher.default_keyset(); //! // Sealed under "legacy/age". -//! let p: Pinned = 42u32.encrypt_into(&cipher).await?; +//! let p: Pinned = 42u32.encrypt_into(&keyset).await?; //! assert_eq!(p.decrypt_into(&cipher, ()).await?, 42); //! //! // Sealed under ("legacy/age", tenant): opens there, and nowhere else. //! let tenant = 7u64; -//! let p: Pinned = 42u32.encrypt_into_with_context(&cipher, tenant).await?; -//! let p2: Pinned = 42u32.encrypt_into_with_context(&cipher, tenant).await?; +//! let p: Pinned = 42u32.encrypt_into_with_context(&keyset, tenant).await?; +//! let p2: Pinned = 42u32.encrypt_into_with_context(&keyset, tenant).await?; //! assert_eq!(p.decrypt_into(&cipher, NonEmpty::from(tenant)).await?, 42); //! // The fake key source ignores descriptors, so the AEAD is what refuses //! // here; ZeroKMS refuses the key retrieval itself first (`Error::Kms`). @@ -165,7 +171,8 @@ //! //! # tokio::runtime::Builder::new_current_thread().enable_all().build().unwrap().block_on(async { //! # let cipher = StackCipher::builder().kms(FakeDataKeySource::new()).init().await?; -//! let f: Foo = 42u32.encrypt_into_with_context(&cipher, nonempty!("users/age")).await?; +//! # let keyset = cipher.default_keyset(); +//! let f: Foo = 42u32.encrypt_into_with_context(&keyset, nonempty!("users/age")).await?; //! assert_eq!(f.decrypt_into(&cipher, nonempty!("users/age")).await?, 42); //! # Ok::<(), stack_encrypt::Error>(()) //! # }).unwrap(); @@ -174,15 +181,15 @@ //! ```compile_fail,E0277 //! # use stack_encrypt::sem::EqualityTerm; //! # use stack_encrypt::target::EncryptInto; -//! # use stack_encrypt::{DecryptInto, EncryptFrom, StackCipher, StackCipherText}; +//! # use stack_encrypt::{DecryptInto, EncryptFrom, KeysetCipher, StackCipherText}; //! # use stack_kms::FakeDataKeySource; //! # #[derive(EncryptFrom, DecryptInto)] //! # #[stash(plaintext = u32)] //! # struct Foo { c: StackCipherText, hm: EqualityTerm } -//! async fn encrypt(cipher: &StackCipher<FakeDataKeySource>) { +//! async fn encrypt(keyset: &KeysetCipher<'_, FakeDataKeySource>) { //! // "`StackCipherText` is not an encrypted form of `u32` under a `()` //! // context": the leaf that needs a context is named. -//! let _: Foo = 42u32.encrypt_into(cipher).await.unwrap(); +//! let _: Foo = 42u32.encrypt_into(keyset).await.unwrap(); //! } //! ``` //! @@ -212,12 +219,13 @@ //! .init() //! .await //! .unwrap(); +//! let keyset = cipher.default_keyset(); //! //! let ages: Vec<u32> = vec![29, 34, 41]; //! //! // A column of independently sealed ciphertexts: ONE generate_keys call. //! let sealed: Vec<StackCipherText> = ages -//! .encrypt_into_with_context(&cipher, nonempty!("users/age")) +//! .encrypt_into_with_context(&keyset, nonempty!("users/age")) //! .await?; //! //! // And back: ONE retrieve_keys call for the whole column. @@ -240,21 +248,22 @@ //! # Extending with your own SEM type //! //! The set of term types is open. Any crate can define one: implement -//! [`EncryptFrom`] for it against [`StackCipher`], build the result with +//! [`EncryptFrom`] for it against [`KeysetCipher`], build the result with //! [`Pending::ready`] (local derivation) or [`Pending::request`] (derivation //! needing ZeroKMS responses). Every built-in term type is implemented with //! **exactly** this recipe — they use no privileged access — so [`sem`] //! doubles as worked examples. //! //! The one thing every searchable-encryption scheme needs is a keyed, -//! deterministic derivation — the cipher's PRF. Under the local HMAC backend -//! the PRF completes synchronously (`into_result`), so the pending carries no +//! deterministic derivation — the keyset's PRF ([`KeysetCipher::prf`], keyed +//! by that keyset's index key). Under the local HMAC backend the PRF +//! completes synchronously (`into_result`), so the pending carries no //! requests; a future 2-party ZeroKMS PRF backend moves the same visitor //! behind a PRF request instead, joining the record's one batched call. //! //! ``` -//! use stack_encrypt::target::{DecryptField, DecryptTarget, Decryptable, EncryptFrom, Pending}; -//! use stack_encrypt::{Error, IntoPrfContext, NonEmpty, StackCipher}; +//! use stack_encrypt::target::{DecryptField, Decryptable, EncryptFrom, Pending}; +//! use stack_encrypt::{Error, IntoPrfContext, KeysetCipher, NonEmpty, StackCipher}; //! use vitaminc_prf::{PrfContext, PrfValue, PrfVisitor, PrfVisitorError}; //! //! /// A third-party term type: one PRF block under its own domain. @@ -274,15 +283,16 @@ //! // exists only for a `NonEmpty<T>`, so `()` — and any unproven value — is //! // a compile error (nothing above a leaf checks, since a column of rows //! // has no context of its own). The lifetime is the context's own, as in -//! // the `IntoPrfContext<'c>` it implements. -//! impl<'c, S, K, T> EncryptFrom<S, StackCipher<K>, NonEmpty<T>> for MyTerm +//! // the `IntoPrfContext<'c>` it implements; `'k` is the keyset handle's +//! // borrow of its `StackCipher`. +//! impl<'c, 'k, S, K, T> EncryptFrom<S, KeysetCipher<'k, K>, NonEmpty<T>> for MyTerm //! where //! S: PrfValue + Clone, //! T: IntoPrfContext<'c>, //! { //! fn encrypt_from<'a>( //! source: &'a S, -//! cipher: &'a StackCipher<K>, +//! cipher: &'a KeysetCipher<'k, K>, //! context: NonEmpty<T>, //! ) -> Pending<'a, Self, K> //! where @@ -303,13 +313,15 @@ //! } //! //! // A term is one-way. Saying so is what lets `#[derive(DecryptInto)]` -//! // pass over a `MyTerm` field and open the ciphertext beside it. +//! // pass over a `MyTerm` field and open the ciphertext beside it. The +//! // decrypt side is over `StackCipher` — the `KeysetCipher` form is the +//! // blanket impl in `target`, as for every `DecryptInto` / `DecryptField`. //! impl Decryptable for MyTerm { //! const DECRYPTABLE: bool = false; //! } //! -//! impl<P, C: DecryptTarget, Ctx> DecryptField<P, C, Ctx> for MyTerm { -//! fn decrypt_field<'a>(self, _: &'a C, _: Ctx) -> Option<C::Output<'a, P>> +//! impl<P, K, Ctx> DecryptField<P, StackCipher<K>, Ctx> for MyTerm { +//! fn decrypt_field<'a>(self, _: &'a StackCipher<K>, _: Ctx) -> Option<Pending<'a, P, K>> //! where //! Self: 'a, //! P: 'a, @@ -319,15 +331,15 @@ //! } //! ``` //! -//! A third-party *ciphertext* type implements `DecryptInto` as well, sets -//! `DECRYPTABLE` to `true`, and has `decrypt_field` return -//! `Some(self.decrypt_into(cipher, context))`. +//! A third-party *ciphertext* type implements `DecryptInto` as well — over +//! `StackCipher<K>`, like `DecryptField` — sets `DECRYPTABLE` to `true`, and +//! has `decrypt_field` return `Some(self.decrypt_into(cipher, context))`. //! //! A scheme needing state the cipher does not carry defines its own -//! capability trait and implements it for [`StackCipher`] (a local trait on a -//! foreign type is orphan-rule-legal) using its public accessors -//! ([`keyset_id`](StackCipher::keyset_id), [`prf`](StackCipher::prf), -//! [`kms`](StackCipher::kms)). +//! capability trait and implements it for [`KeysetCipher`] (a local trait on +//! a foreign type is orphan-rule-legal) using its public accessors +//! ([`keyset_id`](KeysetCipher::keyset_id), [`prf`](KeysetCipher::prf), +//! [`kms`](KeysetCipher::kms)). //! //! **Not yet here:** the `ore_rs` *block* ORE scheme (`OreBlock256`) that EQL //! and `cipherstash-client` use. The ORE/OPE terms in [`sem`] are CLLW, a @@ -384,14 +396,15 @@ //! .init() //! .await //! .unwrap(); +//! let keyset = cipher.default_keyset(); //! //! let user = User { age: 42, email: "alice@example.com".into() }; //! // Every field names its own context, so nothing is needed from the //! // caller: the context-free forms are the whole call. -//! let row: EncryptedUser = user.encrypt_into(&cipher).await?; +//! let row: EncryptedUser = user.encrypt_into(&keyset).await?; //! // A query site derives the same term under the column's context. //! let probe: EqualityTerm = 42u32 -//! .encrypt_into_with_context(&cipher, nonempty!("users/age")) +//! .encrypt_into_with_context(&keyset, nonempty!("users/age")) //! .await?; //! assert_eq!(row.age.hm, probe); //! let recovered = User::decrypt_from(row, &cipher).await?; @@ -399,9 +412,9 @@ //! //! // Or the caller extends every field's context with the record's id: the //! // same field is now under `("users/age", 7u64)`, and opens only there. -//! let row: EncryptedUser = user.encrypt_into_with_context(&cipher, 7u64).await?; +//! let row: EncryptedUser = user.encrypt_into_with_context(&keyset, 7u64).await?; //! let probe: EqualityTerm = 42u32 -//! .encrypt_into_with_context(&cipher, nonempty!("users/age").with(7u64)) +//! .encrypt_into_with_context(&keyset, nonempty!("users/age").with(7u64)) //! .await?; //! assert_eq!(row.age.hm, probe); //! let recovered = User::decrypt_from_with_context(row, &cipher, 7u64).await?; @@ -433,12 +446,12 @@ use vitaminc_aead::{CipherText, Decrypt, Encrypt, IntoAad}; use vitaminc_protected::NonEmpty; use crate::cipher::{bind_keys, PendingStackCipherText, StackDecipher}; -use crate::{Descriptor, Error, StackCipher, StackCipherText}; +use crate::{Descriptor, Error, KeysetCipher, StackCipher, StackCipherText}; mod pending; mod request; -pub use pending::{Pending, PendingFuture}; +pub use pending::{CipherScope, Pending, PendingFuture}; pub use request::{Request, Responses}; pub use stack_encrypt_derive::{DecryptInto, EncryptFrom}; @@ -449,7 +462,7 @@ pub use stack_encrypt_derive::{DecryptInto, EncryptFrom}; /// Implemented by ciphers: decides what an [`EncryptFrom`] implementation /// hands back. A cipher that does no I/O sets /// `Output<'a, T> = Result<T, Self::Error>` — no future, no `.await`. -/// [`StackCipher`] sets `Output<'a, T> = Pending<'a, T, K>`, a request +/// [`KeysetCipher`] sets `Output<'a, T> = Pending<'a, T, K>`, a request /// carrier that talks to ZeroKMS when awaited. /// /// This mirrors `Cipher::Ok` and `Prf::Ok<T>`: the async shape belongs to the @@ -477,7 +490,10 @@ pub trait DecryptTarget { T: 'a; } -impl<K> EncryptTarget for StackCipher<K> { +/// Encrypting binds to a keyset: data keys are minted under one, and index +/// terms are derived under one's index key. So the encrypt target is the +/// [`KeysetCipher`], not the client-scoped [`StackCipher`]. +impl<K> EncryptTarget for KeysetCipher<'_, K> { type Error = Error; type Output<'a, T> = Pending<'a, T, K> @@ -486,6 +502,9 @@ impl<K> EncryptTarget for StackCipher<K> { T: 'a; } +/// Decrypting is not keyset-scoped — a sealed leaf carries the id of the +/// keyset it was sealed under — so the client-scoped [`StackCipher`] is a +/// decrypt target, opening leaves from any keyset in one batch. impl<K> DecryptTarget for StackCipher<K> { type Error = Error; type Output<'a, T> @@ -495,6 +514,56 @@ impl<K> DecryptTarget for StackCipher<K> { T: 'a; } +/// A [`KeysetCipher`] decrypts too, constrained: every [`DecryptInto`] and +/// [`DecryptField`] implementation over [`StackCipher`] applies through it +/// (the blanket impls below), and a leaf from any other keyset is +/// [`Error::ForeignKeyset`] before any key is retrieved. +impl<K> DecryptTarget for KeysetCipher<'_, K> { + type Error = Error; + type Output<'a, T> + = Pending<'a, T, K> + where + Self: 'a, + T: 'a; +} + +/// The constrained form of every decrypt: whatever opens through the +/// [`StackCipher`] opens through a [`KeysetCipher`] scoped to the leaves' +/// keyset, and refuses leaves from any other. Implement `DecryptInto` over +/// `StackCipher<K>`; this impl supplies the `KeysetCipher` form. +impl<'k, P, K, Ctx, X> DecryptInto<P, KeysetCipher<'k, K>, Ctx> for X +where + X: DecryptInto<P, StackCipher<K>, Ctx>, +{ + fn decrypt_into<'a>(self, cipher: &'a KeysetCipher<'k, K>, context: Ctx) -> Pending<'a, P, K> + where + Self: 'a, + P: 'a, + { + let inner: &'a StackCipher<K> = cipher.cipher(); + X::decrypt_into(self, inner, context).scoped_to(cipher.keyset_id()) + } +} + +/// See the [`DecryptInto`] blanket above. +impl<'k, P, K, Ctx, X> DecryptField<P, KeysetCipher<'k, K>, Ctx> for X +where + X: DecryptField<P, StackCipher<K>, Ctx>, +{ + fn decrypt_field<'a>( + self, + cipher: &'a KeysetCipher<'k, K>, + context: Ctx, + ) -> Option<Pending<'a, P, K>> + where + Self: 'a, + P: 'a, + { + let inner: &'a StackCipher<K> = cipher.cipher(); + X::decrypt_field(self, inner, context).map(|pending| pending.scoped_to(cipher.keyset_id())) + } +} + // ============================================================================= // The traits // ============================================================================= @@ -662,15 +731,16 @@ pub trait DecryptInto<P, C: DecryptTarget, Ctx>: Sized { /// .init() /// .await /// .unwrap(); +/// let keyset = cipher.default_keyset(); /// /// // A literal, checked at compile time. /// let term: EqualityTerm = "alice" -/// .encrypt_into_with_context(&cipher, nonempty!("users/email")) +/// .encrypt_into_with_context(&keyset, nonempty!("users/email")) /// .await?; /// // A runtime value, checked once where it is built. /// let column = String::from("users/email"); /// let same: EqualityTerm = "alice" -/// .encrypt_into_with_context(&cipher, NonEmpty::new(column)?) +/// .encrypt_into_with_context(&keyset, NonEmpty::new(column)?) /// .await?; /// assert_eq!(term, same); /// # Ok::<(), Box<dyn std::error::Error>>(()) @@ -885,12 +955,15 @@ impl Decryptable for StackCipherText { const DECRYPTABLE: bool = true; } -impl<P, C, Ctx> DecryptField<P, C, Ctx> for StackCipherText +impl<P, K, Ctx> DecryptField<P, StackCipher<K>, Ctx> for StackCipherText where - C: DecryptTarget, - Self: DecryptInto<P, C, Ctx>, + Self: DecryptInto<P, StackCipher<K>, Ctx>, { - fn decrypt_field<'a>(self, cipher: &'a C, context: Ctx) -> Option<C::Output<'a, P>> + fn decrypt_field<'a>( + self, + cipher: &'a StackCipher<K>, + context: Ctx, + ) -> Option<Pending<'a, P, K>> where Self: 'a, P: 'a, @@ -973,10 +1046,10 @@ where // Leaf implementations: the record ciphertext // ============================================================================= -/// The record ciphertext: any vitaminc [`Encrypt`] value, sealed by the -/// [`StackCipher`] under per-leaf ZeroKMS data keys. The context becomes the -/// AEAD associated data, binding the ciphertext to the field it was encrypted -/// for. +/// The record ciphertext: any vitaminc [`Encrypt`] value, sealed by a +/// [`KeysetCipher`] under per-leaf ZeroKMS data keys minted under its +/// keyset. The context becomes the AEAD associated data, binding the +/// ciphertext to the field it was encrypted for. /// /// The build is synchronous: the value's `Encrypt` impl drives the cipher to /// a pending tree (no I/O), and the returned [`Pending`] carries one @@ -988,14 +1061,14 @@ where /// would leave the leaf AAD carrying only the key tag, making ciphertexts /// transplantable between empty-context fields — see the /// [module docs](self#contexts).) -impl<'c, S, K, T> EncryptFrom<S, StackCipher<K>, NonEmpty<T>> for StackCipherText +impl<'c, 'k, S, K, T> EncryptFrom<S, KeysetCipher<'k, K>, NonEmpty<T>> for StackCipherText where S: Encrypt + Clone, T: IntoAad<'c>, { fn encrypt_from<'a>( source: &'a S, - cipher: &'a StackCipher<K>, + cipher: &'a KeysetCipher<'k, K>, context: NonEmpty<T>, ) -> Pending<'a, Self, K> where @@ -1018,10 +1091,10 @@ where /// Both ways of encrypting go through here — the target-directed /// `encrypt_into_with_context` into a [`StackCipherText`] above and the /// cipher-directed -/// [`PendingStackCipherText::seal`] behind [`StackCipher::encrypt`] — so +/// [`PendingStackCipherText::seal`] behind [`KeysetCipher::encrypt`] — so /// there is one definition of how a tree is sealed and one path to ZeroKMS. pub(crate) fn seal_pending<'a, K>( - cipher: &'a StackCipher<K>, + cipher: &'a KeysetCipher<'_, K>, tree: PendingStackCipherText, descriptor: Descriptor, ) -> Pending<'a, StackCipherText, K> { @@ -1031,12 +1104,13 @@ pub(crate) fn seal_pending<'a, K>( if let Err(e) = descriptor.check() { return Pending::ready(cipher, Err(e)); } + let keyset_id = cipher.keyset_id(); let requests = std::iter::repeat_with(|| Request::generate_data_key(descriptor.clone())) .take(tree.key_count()) .collect(); Pending::request(cipher, requests, move |responses| { let mut keys = responses.drain_generated(); - tree.seal_with(&mut keys).map_err(Error::from) + tree.seal_with(keyset_id, &mut keys).map_err(Error::from) }) } @@ -1049,16 +1123,16 @@ pub(crate) fn seal_pending<'a, K>( /// [`decipher_from_responses`] — but runs the value's `Decrypt` impl in the /// same fulfilment rather than composing a second pending over this one. pub(crate) fn decipher_pending<'a, K>( - cipher: &'a StackCipher<K>, + scope: impl CipherScope<'a, K>, ciphertext: StackCipherText, descriptor: Descriptor, ) -> Pending<'a, StackDecipher, K> { // Fast path, as in `seal_pending`; `dispatch` is the gate. if let Err(e) = descriptor.check() { - return Pending::ready(cipher, Err(e)); + return Pending::ready(scope, Err(e)); } let requests = retrieve_requests(&ciphertext, &descriptor); - Pending::request(cipher, requests, move |responses| { + Pending::request(scope, requests, move |responses| { decipher_from_responses(ciphertext, responses) }) } @@ -1139,6 +1213,7 @@ fn collect_retrieve_requests( *leaf.iv(), leaf.tag().to_vec(), descriptor.clone(), + leaf.keyset_id(), )); } CipherText::Sequence(items) => { @@ -1171,16 +1246,16 @@ fn collect_retrieve_requests( /// because they do. Neither is decided here. What *is* decided here is /// that an over-long context is refused once, before the column is walked /// ([`ElementContext`]), not once per element. -impl<S, T, K, Ctx> EncryptFrom<Vec<S>, StackCipher<K>, Ctx> for Vec<T> +impl<'k, S, T, K, Ctx> EncryptFrom<Vec<S>, KeysetCipher<'k, K>, Ctx> for Vec<T> where - T: EncryptFrom<S, StackCipher<K>, Ctx>, + T: EncryptFrom<S, KeysetCipher<'k, K>, Ctx>, Ctx: ElementContext, { const KEYED: bool = T::KEYED; fn encrypt_from<'a>( source: &'a Vec<S>, - cipher: &'a StackCipher<K>, + cipher: &'a KeysetCipher<'k, K>, context: Ctx, ) -> Pending<'a, Self, K> where @@ -1278,15 +1353,15 @@ where /// absent *record field*, carrying no requests). This is distinct from /// `Option<S> → StackCipherText` via [`Encrypt`], which produces an /// *authenticated* absence marker inside one ciphertext. -impl<S, T, K, Ctx> EncryptFrom<Option<S>, StackCipher<K>, Ctx> for Option<T> +impl<'k, S, T, K, Ctx> EncryptFrom<Option<S>, KeysetCipher<'k, K>, Ctx> for Option<T> where - T: EncryptFrom<S, StackCipher<K>, Ctx> + MaybeSend, + T: EncryptFrom<S, KeysetCipher<'k, K>, Ctx> + MaybeSend, { const KEYED: bool = T::KEYED; fn encrypt_from<'a>( source: &'a Option<S>, - cipher: &'a StackCipher<K>, + cipher: &'a KeysetCipher<'k, K>, context: Ctx, ) -> Pending<'a, Self, K> where diff --git a/packages/stack-encrypt/src/target/pending.rs b/packages/stack-encrypt/src/target/pending.rs index 7f71c533c..f719d2262 100644 --- a/packages/stack-encrypt/src/target/pending.rs +++ b/packages/stack-encrypt/src/target/pending.rs @@ -9,13 +9,15 @@ //! [`Responses`] its own requests asked for. use std::borrow::Cow; +use std::collections::HashMap; use std::future::{Future, IntoFuture}; use std::pin::Pin; -use stack_kms::{DataKeySource, GenerateKeyPayload, Iv, MaybeSend, RetrieveKeyPayload}; +use stack_kms::{DataKey, DataKeySource, GenerateKeyPayload, Iv, MaybeSend, RetrieveKeyPayload}; +use uuid::Uuid; use super::request::{tally, Request, RequestKind, Responses}; -use crate::{Descriptor, Error, StackCipher}; +use crate::{Descriptor, Error, KeysetCipher, StackCipher}; /// The boxed fulfilment: consumes this pending's slice of the responses and /// produces the output. The `Send` split mirrors [`stack_kms::MaybeSend`] — @@ -50,6 +52,13 @@ pub type PendingFuture<'a, T> = Pin<Box<dyn Future<Output = Result<T, Error>> + /// responses). pub struct Pending<'a, T, K> { cipher: &'a StackCipher<K>, + /// The keyset this pending is scoped to: the one a [`KeysetCipher`] + /// built it through, or none when it was built through the + /// [`StackCipher`]. A scoped pending mints every data key under this + /// keyset and opens leaves from no other; an unscoped one mints nothing + /// ([`Error::NoKeyset`]) and opens leaves from any keyset, one retrieve + /// call per keyset. Merging two scopes is [`Error::KeysetMismatch`]. + keyset: Option<Uuid>, requests: Vec<Request>, /// Set when the value already failed during the synchronous build /// (`ready(Err(..))`, a cipher mismatch, a failed sibling). A failed @@ -61,21 +70,52 @@ pub struct Pending<'a, T, K> { fulfil: FulfilBox<'a, T>, } +/// What a [`Pending`] is built through: a [`StackCipher`] (no keyset +/// scope) or a [`KeysetCipher`] (scoped to its keyset). Implemented for +/// references to both, so the constructors take either. +pub trait CipherScope<'a, K> { + /// The client-scoped cipher the pending settles through. + fn cipher(&self) -> &'a StackCipher<K>; + /// The keyset the pending is scoped to, if any. + fn keyset(&self) -> Option<Uuid>; +} + +impl<'a, K> CipherScope<'a, K> for &'a StackCipher<K> { + fn cipher(&self) -> &'a StackCipher<K> { + self + } + + fn keyset(&self) -> Option<Uuid> { + None + } +} + +impl<'a, K> CipherScope<'a, K> for &'a KeysetCipher<'_, K> { + fn cipher(&self) -> &'a StackCipher<K> { + KeysetCipher::cipher(self) + } + + fn keyset(&self) -> Option<Uuid> { + Some(self.keyset_id()) + } +} + impl<'a, T: 'a, K> Pending<'a, T, K> { /// A pending with no requests: `result` was fully derived during the /// synchronous build. Awaiting it does no I/O. - pub fn ready(cipher: &'a StackCipher<K>, result: Result<T, Error>) -> Self + pub fn ready(scope: impl CipherScope<'a, K>, result: Result<T, Error>) -> Self where T: MaybeSend, { match result { Ok(value) => Self { - cipher, + cipher: scope.cipher(), + keyset: scope.keyset(), requests: Vec::new(), failed: None, fulfil: Box::new(move |_| Ok(value)), }, - Err(error) => Self::failed(cipher, error), + Err(error) => Self::failed(scope, error), } } @@ -88,9 +128,10 @@ impl<'a, T: 'a, K> Pending<'a, T, K> { /// contract is broken at decrypt time — e.g. /// [`Error::NotOpened`] for a field whose type declared /// [`DECRYPTABLE`](super::Decryptable::DECRYPTABLE) but was passed over. - pub fn failed(cipher: &'a StackCipher<K>, error: Error) -> Self { + pub fn failed(scope: impl CipherScope<'a, K>, error: Error) -> Self { Self { - cipher, + cipher: scope.cipher(), + keyset: scope.keyset(), requests: Vec::new(), failed: Some(error), // Unreachable: `settle` returns the stored error before any @@ -113,13 +154,22 @@ impl<'a, T: 'a, K> Pending<'a, T, K> { /// [`Error::ResponseShape`] too — the key was minted at ZeroKMS, and /// silently discarding it means the pending's declared requests do not /// describe what it actually does. - pub fn request<F>(cipher: &'a StackCipher<K>, requests: Vec<Request>, fulfil: F) -> Self + /// + /// The keyset rules apply at construction, before any I/O: a generate + /// request through a [`StackCipher`] scope is [`Error::NoKeyset`], and + /// a retrieve request naming another keyset than a [`KeysetCipher`] + /// scope's is [`Error::ForeignKeyset`]. + pub fn request<F>(scope: impl CipherScope<'a, K>, requests: Vec<Request>, fulfil: F) -> Self where F: FnOnce(&mut Responses) -> Result<T, Error> + MaybeSend + 'a, { let (generated, retrieved) = tally(&requests); + if let Err(error) = check_scope(scope.keyset(), &requests) { + return Self::failed(scope, error); + } Self { - cipher, + cipher: scope.cipher(), + keyset: scope.keyset(), requests, failed: None, fulfil: Box::new(move |responses| { @@ -141,35 +191,73 @@ impl<'a, T: 'a, K> Pending<'a, T, K> { let fulfil = self.fulfil; Pending { cipher: self.cipher, + keyset: self.keyset, requests: self.requests, failed: self.failed, fulfil: Box::new(move |responses| fulfil(responses).map(f)), } } + /// Scope this pending to `keyset`: what a [`KeysetCipher`]'s decrypt + /// does to the pending its [`StackCipher`] built, so that opening a + /// leaf from any other keyset fails before any key is retrieved. + /// Scoping a pending already scoped to another keyset is + /// [`Error::KeysetMismatch`]. + pub(crate) fn scoped_to(self, keyset: Uuid) -> Self { + if let Some(existing) = self.keyset { + if existing != keyset { + return Pending::failed( + self.cipher, + Error::KeysetMismatch { + left: existing, + right: keyset, + }, + ); + } + } + if let Err(error) = check_scope(Some(keyset), &self.requests) { + return Pending::failed(self.cipher, error); + } + Pending { + keyset: Some(keyset), + ..self + } + } + /// Merge two pendings into one resolving to the pair. Their requests /// concatenate — awaiting the result is still one batched call per /// request kind. /// - /// Both must come from the same cipher: the merged assembly dispatches - /// every request through one cipher's backend and keyset, so a pending - /// built on another cipher would have its keys minted under the wrong - /// keyset. That is [`Error::CipherMismatch`], not a debug assertion. If - /// either side already failed, the result is that failure and carries no - /// requests. + /// Both must come from the same cipher, and from the same keyset if + /// both are scoped to one: the merged assembly dispatches every request + /// through one cipher's backend, and mints every key under one keyset, + /// so a pending built on another cipher or scoped to another keyset + /// would have its keys minted by the wrong client or under the wrong + /// keyset. Those are [`Error::CipherMismatch`] and + /// [`Error::KeysetMismatch`], not debug assertions. A scoped pending + /// merged with an unscoped one takes the scope. If either side already + /// failed, the result is that failure and carries no requests. pub fn zip<U: 'a>(self, other: Pending<'a, U, K>) -> Pending<'a, (T, U), K> { if !std::ptr::eq(self.cipher, other.cipher) { return Pending::failed(self.cipher, Error::CipherMismatch); } + let keyset = match merge_scopes(self.keyset, other.keyset) { + Ok(keyset) => keyset, + Err(error) => return Pending::failed(self.cipher, error), + }; if let Some(error) = self.failed.or(other.failed) { return Pending::failed(self.cipher, error); } let mut requests = self.requests; requests.extend(other.requests); + if let Err(error) = check_scope(keyset, &requests) { + return Pending::failed(self.cipher, error); + } let first = self.fulfil; let second = other.fulfil; Pending { cipher: self.cipher, + keyset, requests, failed: None, fulfil: Box::new(move |responses| Ok((first(responses)?, second(responses)?))), @@ -179,26 +267,37 @@ impl<'a, T: 'a, K> Pending<'a, T, K> { /// Merge any number of same-typed pendings into one resolving to the /// `Vec` — [`zip`](Self::zip) at scale, used by the `Vec<T>` /// implementations to make a whole column one batched call. Same rules - /// as `zip`: every item must come from `cipher`, and the first failed - /// item fails the whole column with no I/O. + /// as `zip`: every item must come from `scope`'s cipher and agree with + /// its keyset, and the first failed item fails the whole column with no + /// I/O. pub fn all( - cipher: &'a StackCipher<K>, + scope: impl CipherScope<'a, K>, items: Vec<Pending<'a, T, K>>, ) -> Pending<'a, Vec<T>, K> { + let cipher = scope.cipher(); + let mut keyset = scope.keyset(); let mut requests = Vec::new(); let mut fulfils = Vec::with_capacity(items.len()); for item in items { if !std::ptr::eq(cipher, item.cipher) { return Pending::failed(cipher, Error::CipherMismatch); } + keyset = match merge_scopes(keyset, item.keyset) { + Ok(keyset) => keyset, + Err(error) => return Pending::failed(cipher, error), + }; if let Some(error) = item.failed { return Pending::failed(cipher, error); } requests.extend(item.requests); fulfils.push(item.fulfil); } + if let Err(error) = check_scope(keyset, &requests) { + return Pending::failed(cipher, error); + } Pending { cipher, + keyset, requests, failed: None, fulfil: Box::new(move |responses| { @@ -227,11 +326,39 @@ where if let Some(error) = self.failed { return Err(error); } - let mut responses = dispatch(self.cipher, self.requests).await?; + let mut responses = dispatch(self.cipher, self.keyset, self.requests).await?; (self.fulfil)(&mut responses) } } +/// The keyset two merged pendings share: either's when the other has none, +/// [`Error::KeysetMismatch`] when both have one and they differ. +fn merge_scopes(left: Option<Uuid>, right: Option<Uuid>) -> Result<Option<Uuid>, Error> { + match (left, right) { + (Some(left), Some(right)) if left != right => Err(Error::KeysetMismatch { left, right }), + (Some(keyset), _) | (_, Some(keyset)) => Ok(Some(keyset)), + (None, None) => Ok(None), + } +} + +/// The keyset rules over a request list, applied wherever requests meet a +/// scope — construction, scoping, merging — so a violation fails the +/// pending before any I/O: a generate request needs a keyset to mint under +/// ([`Error::NoKeyset`]), and a retrieve request in a scoped pending must +/// name that keyset ([`Error::ForeignKeyset`]). +fn check_scope(keyset: Option<Uuid>, requests: &[Request]) -> Result<(), Error> { + for request in requests { + match (keyset, request.retrieve_keyset()) { + (None, None) => return Err(Error::NoKeyset), + (Some(expected), Some(found)) if expected != found => { + return Err(Error::ForeignKeyset { expected, found }); + } + _ => {} + } + } + Ok(()) +} + /// Awaiting a `Pending` settles it. The boxed future is `Send` on native /// targets (see [`PendingFuture`]), which is what requires `K: Sync` there: /// the future holds `&StackCipher<K>`. On wasm32 the future is not `Send`, @@ -266,15 +393,17 @@ where } /// Issue the batched ZeroKMS calls for `requests`: at most one -/// `generate_keys` and one `retrieve_keys`, whatever the request count. When -/// ZeroKMS grows a combined operation (data keys + PRF derivations in one -/// round-trip), this is the one place that changes. +/// `generate_keys` (under the pending's keyset) and one `retrieve_keys` per +/// keyset the retrieved leaves were sealed under, whatever the request +/// count. When ZeroKMS grows a combined operation (data keys + PRF +/// derivations in one round-trip), this is the one place that changes. async fn dispatch<K: DataKeySource>( cipher: &StackCipher<K>, + keyset: Option<Uuid>, requests: Vec<Request>, ) -> Result<Responses, Error> { let mut generates: Vec<Descriptor> = Vec::new(); - let mut retrieves: Vec<(Iv, Vec<u8>, Descriptor)> = Vec::new(); + let mut retrieves: Vec<(Iv, Vec<u8>, Descriptor, Uuid)> = Vec::new(); for request in requests { match request.into_kind() { RequestKind::GenerateDataKey { descriptor } => generates.push(descriptor), @@ -282,7 +411,8 @@ async fn dispatch<K: DataKeySource>( iv, tag, descriptor, - } => retrieves.push((iv, tag, descriptor)), + keyset_id, + } => retrieves.push((iv, tag, descriptor, keyset_id)), } } @@ -294,12 +424,16 @@ async fn dispatch<K: DataKeySource>( // a fast path, not a second rule. generates .iter() - .chain(retrieves.iter().map(|(_, _, descriptor)| descriptor)) + .chain(retrieves.iter().map(|(_, _, descriptor, _)| descriptor)) .try_for_each(Descriptor::check)?; let generated = if generates.is_empty() { Vec::new() } else { + // Every constructor checks this before any I/O (`check_scope`), so + // an unscoped generate cannot reach here; kept as the rule, not + // as an assumption. + let keyset = keyset.ok_or(Error::NoKeyset)?; // Each leaf's descriptor is its context, rendered; the lock context // stays empty — see the descriptor module docs. let payloads: Vec<GenerateKeyPayload<'_>> = generates @@ -309,7 +443,7 @@ async fn dispatch<K: DataKeySource>( let expected = payloads.len(); let keys = cipher .kms() - .generate_keys(payloads, Some(cipher.keyset_id()), None) + .generate_keys(payloads, Some(keyset), None) .await?; if keys.len() != expected { return Err(Error::KeyCountMismatch { @@ -320,17 +454,33 @@ async fn dispatch<K: DataKeySource>( keys }; - let retrieved = if retrieves.is_empty() { - Vec::new() - } else { - let payloads: Vec<RetrieveKeyPayload<'_>> = retrieves + // Retrieves group by the keyset each leaf names — one call per keyset, + // in first-seen order — and the keys scatter back into request order, + // which is the order the fulfilments draw them in. + let mut groups: Vec<(Uuid, Vec<usize>)> = Vec::new(); + let mut group_of: HashMap<Uuid, usize> = HashMap::new(); + for (index, (_, _, _, keyset_id)) in retrieves.iter().enumerate() { + let group = *group_of.entry(*keyset_id).or_insert_with(|| { + groups.push((*keyset_id, Vec::new())); + groups.len() - 1 + }); + groups[group].1.push(index); + } + let mut retrieved: Vec<Option<DataKey>> = std::iter::repeat_with(|| None) + .take(retrieves.len()) + .collect(); + for (keyset_id, indices) in groups { + let payloads: Vec<RetrieveKeyPayload<'_>> = indices .iter() - .map(|(iv, tag, descriptor)| RetrieveKeyPayload::new(*iv, descriptor.as_str(), tag)) + .map(|&index| { + let (iv, tag, descriptor, _) = &retrieves[index]; + RetrieveKeyPayload::new(*iv, descriptor.as_str(), tag) + }) .collect(); let expected = payloads.len(); let keys = cipher .kms() - .retrieve_keys(payloads, Some(cipher.keyset_id()), None) + .retrieve_keys(payloads, Some(keyset_id), None) .await?; if keys.len() != expected { return Err(Error::KeyCountMismatch { @@ -338,8 +488,16 @@ async fn dispatch<K: DataKeySource>( received: keys.len(), }); } - keys - }; + for (index, key) in indices.into_iter().zip(keys) { + retrieved[index] = Some(key); + } + } + // Every slot was filled by exactly one group; a hole would mean the + // grouping above lost a request, which is a bug here, not a data error. + let retrieved: Vec<DataKey> = retrieved + .into_iter() + .map(|key| key.ok_or(Error::ResponseShape)) + .collect::<Result<_, _>>()?; Ok(Responses::new(generated, retrieved)) } @@ -367,6 +525,9 @@ mod tests { /// The descriptors of every payload sent, per call, in payload order. generate_descriptors: Mutex<Vec<Vec<String>>>, retrieve_descriptors: Mutex<Vec<Vec<String>>>, + /// The keyset each call named, in call order. + generate_keysets: Mutex<Vec<Option<Uuid>>>, + retrieve_keysets: Mutex<Vec<Option<Uuid>>>, } impl CountingSource { @@ -385,6 +546,14 @@ mod tests { fn retrieve_descriptors(&self) -> Vec<Vec<String>> { self.retrieve_descriptors.lock().unwrap().clone() } + + fn generate_keysets(&self) -> Vec<Option<Uuid>> { + self.generate_keysets.lock().unwrap().clone() + } + + fn retrieve_keysets(&self) -> Vec<Option<Uuid>> { + self.retrieve_keysets.lock().unwrap().clone() + } } impl DataKeySource for CountingSource { @@ -399,6 +568,7 @@ mod tests { .lock() .unwrap() .push(payloads.iter().map(|p| p.descriptor.to_owned()).collect()); + self.generate_keysets.lock().unwrap().push(keyset_id); self.inner .generate_keys(payloads, keyset_id, unverified_context) .await @@ -415,6 +585,7 @@ mod tests { .lock() .unwrap() .push(payloads.iter().map(|p| p.descriptor.to_owned()).collect()); + self.retrieve_keysets.lock().unwrap().push(keyset_id); self.inner .retrieve_keys(payloads, keyset_id, unverified_context) .await @@ -444,13 +615,13 @@ mod tests { /// A pending that asks for `n` data keys and resolves to their tags. fn generating<'a>( - cipher: &'a StackCipher<CountingSource>, + keyset: &'a KeysetCipher<'_, CountingSource>, n: usize, ) -> Pending<'a, Vec<Vec<u8>>, CountingSource> { let requests = std::iter::repeat_with(|| Request::generate_data_key(d())) .take(n) .collect(); - Pending::request(cipher, requests, move |responses| { + Pending::request(keyset, requests, move |responses| { (0..n) .map(|_| responses.next_generated_key().map(|key| key.tag)) .collect() @@ -500,7 +671,8 @@ mod tests { #[tokio::test] async fn map_carries_the_requests_through() { let cipher = cipher().await; - let tags = generating(&cipher, 3).map(|tags| tags.len()).await.unwrap(); + let keyset = cipher.default_keyset(); + let tags = generating(&keyset, 3).map(|tags| tags.len()).await.unwrap(); assert_eq!(tags, 3); assert_eq!(cipher.kms().generate_calls(), 1); @@ -509,7 +681,8 @@ mod tests { #[tokio::test] async fn one_pending_asking_for_many_keys_is_one_call() { let cipher = cipher().await; - let tags = generating(&cipher, 5).await.unwrap(); + let keyset = cipher.default_keyset(); + let tags = generating(&keyset, 5).await.unwrap(); assert_eq!(tags.len(), 5); assert_eq!(cipher.kms().generate_calls(), 1); @@ -518,8 +691,9 @@ mod tests { #[tokio::test] async fn zip_merges_requests_into_one_call() { let cipher = cipher().await; - let (left, right) = generating(&cipher, 2) - .zip(generating(&cipher, 3)) + let keyset = cipher.default_keyset(); + let (left, right) = generating(&keyset, 2) + .zip(generating(&keyset, 3)) .await .unwrap(); @@ -532,8 +706,9 @@ mod tests { #[tokio::test] async fn zipped_fulfilments_never_share_key_material() { let cipher = cipher().await; - let (left, right) = generating(&cipher, 2) - .zip(generating(&cipher, 2)) + let keyset = cipher.default_keyset(); + let (left, right) = generating(&keyset, 2) + .zip(generating(&keyset, 2)) .await .unwrap(); @@ -571,7 +746,8 @@ mod tests { #[tokio::test] async fn all_merges_a_column_into_one_call_preserving_order() { let cipher = cipher().await; - let items = (0..5).map(|_| generating(&cipher, 1)).collect(); + let keyset = cipher.default_keyset(); + let items = (0..5).map(|_| generating(&keyset, 1)).collect(); let column = Pending::all(&cipher, items).await.unwrap(); assert_eq!(column.len(), 5); @@ -610,8 +786,9 @@ mod tests { #[tokio::test] async fn over_drawing_responses_is_a_response_shape_error() { let cipher = cipher().await; + let keyset = cipher.default_keyset(); let greedy: Pending<'_, Vec<u8>, _> = Pending::request( - &cipher, + &keyset, vec![Request::generate_data_key(d())], |responses| { let _ = responses.next_generated_key()?; @@ -619,7 +796,7 @@ mod tests { responses.next_generated_key().map(|key| key.tag) }, ); - let result = greedy.zip(generating(&cipher, 1)).await; + let result = greedy.zip(generating(&keyset, 1)).await; assert!(matches!( result, @@ -637,9 +814,10 @@ mod tests { #[tokio::test] async fn under_drawing_responses_is_a_response_shape_error() { let cipher = cipher().await; + let keyset = cipher.default_keyset(); let lazy: Pending<'_, (), _> = - Pending::request(&cipher, vec![Request::generate_data_key(d())], |_| Ok(())); - let result = lazy.zip(generating(&cipher, 1)).await; + Pending::request(&keyset, vec![Request::generate_data_key(d())], |_| Ok(())); + let result = lazy.zip(generating(&keyset, 1)).await; assert!(matches!( result, @@ -651,11 +829,12 @@ mod tests { #[tokio::test] async fn drawing_fewer_responses_than_requested_is_a_response_shape_error() { let cipher = cipher().await; + let keyset = cipher.default_keyset(); let requests = vec![ Request::generate_data_key(d()), Request::generate_data_key(d()), ]; - let lazy: Pending<'_, Vec<u8>, _> = Pending::request(&cipher, requests, |responses| { + let lazy: Pending<'_, Vec<u8>, _> = Pending::request(&keyset, requests, |responses| { responses.next_generated_key().map(|key| key.tag) }); @@ -670,13 +849,14 @@ mod tests { #[tokio::test] async fn leaving_the_other_kind_unconsumed_is_a_response_shape_error() { let cipher = cipher().await; - let mut pairs = generating_pairs(&cipher, 1).await.unwrap(); + let keyset = cipher.default_keyset(); + let mut pairs = generating_pairs(&keyset, 1).await.unwrap(); let (iv, tag) = pairs.remove(0); let requests = vec![ Request::generate_data_key(d()), - Request::retrieve_data_key(iv, tag, d()), + Request::retrieve_data_key(iv, tag, d(), keyset.keyset_id()), ]; - let lazy: Pending<'_, Vec<u8>, _> = Pending::request(&cipher, requests, |responses| { + let lazy: Pending<'_, Vec<u8>, _> = Pending::request(&keyset, requests, |responses| { responses.next_generated_key().map(|key| key.tag) }); @@ -689,7 +869,8 @@ mod tests { #[tokio::test] async fn a_pending_with_no_requests_dispatches_nothing() { let cipher = cipher().await; - let value: u32 = Pending::request(&cipher, Vec::new(), |_| Ok(9)) + let keyset = cipher.default_keyset(); + let value: u32 = Pending::request(&keyset, Vec::new(), |_| Ok(9)) .await .unwrap(); @@ -700,14 +881,14 @@ mod tests { /// A pending that asks for `n` data keys and resolves to the `(iv, tag)` /// pairs needed to retrieve them again. - fn generating_pairs( - cipher: &StackCipher<CountingSource>, + fn generating_pairs<'a>( + keyset: &'a KeysetCipher<'_, CountingSource>, n: usize, - ) -> Pending<'_, Vec<(Iv, Vec<u8>)>, CountingSource> { + ) -> Pending<'a, Vec<(Iv, Vec<u8>)>, CountingSource> { let requests = std::iter::repeat_with(|| Request::generate_data_key(d())) .take(n) .collect(); - Pending::request(cipher, requests, move |responses| { + Pending::request(keyset, requests, move |responses| { (0..n) .map(|_| { responses @@ -723,17 +904,18 @@ mod tests { #[tokio::test] async fn generate_and_retrieve_are_one_call_each() { let cipher = cipher().await; - let pairs = generating_pairs(&cipher, 2).await.unwrap(); + let keyset = cipher.default_keyset(); + let pairs = generating_pairs(&keyset, 2).await.unwrap(); assert_eq!(cipher.kms().generate_calls(), 1); let requests: Vec<Request> = pairs .iter() - .map(|(iv, tag)| Request::retrieve_data_key(*iv, tag.clone(), d())) + .map(|(iv, tag)| Request::retrieve_data_key(*iv, tag.clone(), d(), keyset.keyset_id())) .collect(); - let retrieve: Pending<'_, usize, _> = Pending::request(&cipher, requests, |responses| { + let retrieve: Pending<'_, usize, _> = Pending::request(&keyset, requests, |responses| { Ok(responses.drain_retrieved().count()) }); - let (count, fresh) = retrieve.zip(generating(&cipher, 1)).await.unwrap(); + let (count, fresh) = retrieve.zip(generating(&keyset, 1)).await.unwrap(); assert_eq!(count, 2); assert_eq!(fresh.len(), 1); @@ -747,11 +929,12 @@ mod tests { #[tokio::test] async fn dispatch_forwards_each_requests_descriptor_in_order() { let cipher = cipher().await; + let keyset = cipher.default_keyset(); let requests = vec![ Request::generate_data_key(Descriptor::of("users/email")), Request::generate_data_key(Descriptor::of("users/name")), ]; - let pairs: Vec<(Iv, Vec<u8>)> = Pending::request(&cipher, requests, |responses| { + let pairs: Vec<(Iv, Vec<u8>)> = Pending::request(&keyset, requests, |responses| { (0..2) .map(|_| { responses @@ -771,10 +954,15 @@ mod tests { .iter() .zip(["users/name", "users/email"]) .map(|((iv, tag), descriptor)| { - Request::retrieve_data_key(*iv, tag.clone(), Descriptor::of(descriptor)) + Request::retrieve_data_key( + *iv, + tag.clone(), + Descriptor::of(descriptor), + keyset.keyset_id(), + ) }) .collect(); - let count: usize = Pending::request(&cipher, requests, |responses| { + let count: usize = Pending::request(&keyset, requests, |responses| { Ok(responses.drain_retrieved().count()) }) .await @@ -793,12 +981,13 @@ mod tests { #[tokio::test] async fn an_over_long_descriptor_is_refused_before_any_call() { let cipher = cipher().await; + let keyset = cipher.default_keyset(); let long = Descriptor::of("a".repeat(Descriptor::MAX_LEN + 1)); let requests = vec![ Request::generate_data_key(Descriptor::of("users/email")), Request::generate_data_key(long.clone()), ]; - let Err(err) = dispatch(&cipher, requests).await else { + let Err(err) = dispatch(&cipher, Some(keyset.keyset_id()), requests).await else { panic!("an over-long descriptor must be refused"); }; assert!( @@ -807,10 +996,15 @@ mod tests { ); assert_eq!(cipher.kms().generate_calls(), 0); - let mut pairs = generating_pairs(&cipher, 1).await.unwrap(); + let mut pairs = generating_pairs(&keyset, 1).await.unwrap(); let (iv, tag) = pairs.remove(0); - let requests = vec![Request::retrieve_data_key(iv, tag, long)]; - let Err(err) = dispatch(&cipher, requests).await else { + let requests = vec![Request::retrieve_data_key( + iv, + tag, + long, + keyset.keyset_id(), + )]; + let Err(err) = dispatch(&cipher, Some(keyset.keyset_id()), requests).await else { panic!("an over-long descriptor must be refused"); }; assert!(matches!(err, Error::DescriptorTooLong { .. }), "{err}"); @@ -822,7 +1016,182 @@ mod tests { let requests = vec![Request::generate_data_key(Descriptor::of( "a".repeat(Descriptor::MAX_LEN), ))]; - assert!(dispatch(&cipher, requests).await.is_ok(), "at the limit"); + assert!( + dispatch(&cipher, Some(keyset.keyset_id()), requests) + .await + .is_ok(), + "at the limit" + ); assert_eq!(cipher.kms().generate_calls(), before + 1); } + + // ========================================================================= + // Keyset scope + // ========================================================================= + + /// A generate request needs a keyset to mint under, and only a + /// `KeysetCipher` scope has one: through the `StackCipher` it fails at + /// construction, with no I/O. + #[tokio::test] + async fn a_generate_request_through_the_client_scope_has_no_keyset() { + let cipher = cipher().await; + let pending: Pending<'_, Vec<u8>, _> = Pending::request( + &cipher, + vec![Request::generate_data_key(d())], + |responses| responses.next_generated_key().map(|key| key.tag), + ); + let result = pending.await; + + assert!(matches!(result, Err(Error::NoKeyset)), "{result:?}"); + assert_eq!(cipher.kms().generate_calls(), 0); + } + + /// Data keys are minted under the scope's keyset, and that is what + /// reaches ZeroKMS. + #[tokio::test] + async fn generates_are_minted_under_the_scopes_keyset() { + let cipher = cipher().await; + let tenant = cipher.keyset(Uuid::from_u128(9)).await.unwrap(); + generating(&tenant, 2).await.unwrap(); + + assert_eq!( + cipher.kms().generate_keysets(), + vec![Some(Uuid::from_u128(9))] + ); + } + + /// A retrieve request naming another keyset than the scope's is refused + /// at construction, before any key is retrieved. + #[tokio::test] + async fn a_retrieve_from_another_keyset_is_foreign_in_a_keyset_scope() { + let cipher = cipher().await; + let keyset = cipher.default_keyset(); + let other = Uuid::from_u128(2); + let pending: Pending<'_, usize, _> = Pending::request( + &keyset, + vec![Request::retrieve_data_key( + Iv::default(), + vec![1], + d(), + other, + )], + |responses| Ok(responses.drain_retrieved().count()), + ); + let result = pending.await; + + assert!( + matches!( + result, + Err(Error::ForeignKeyset { expected, found }) + if expected == keyset.keyset_id() && found == other + ), + "{result:?}" + ); + assert_eq!(cipher.kms().retrieve_calls(), 0); + } + + /// Scoping a pending built through the client (the constrained decrypt + /// path) applies the same rule to the requests it already carries. + #[tokio::test] + async fn scoping_an_unscoped_pending_refuses_its_foreign_retrieves() { + let cipher = cipher().await; + let keyset = cipher.default_keyset(); + let other = Uuid::from_u128(2); + let pending: Pending<'_, usize, _> = Pending::request( + &cipher, + vec![Request::retrieve_data_key( + Iv::default(), + vec![1], + d(), + other, + )], + |responses| Ok(responses.drain_retrieved().count()), + ); + let result = pending.scoped_to(keyset.keyset_id()).await; + + assert!( + matches!(result, Err(Error::ForeignKeyset { .. })), + "{result:?}" + ); + assert_eq!(cipher.kms().retrieve_calls(), 0); + } + + /// Two pendings scoped to different keysets are one tenant's row and + /// another's: merging them is a composition bug, caught with no I/O. + #[tokio::test] + async fn pendings_scoped_to_different_keysets_refuse_to_merge() { + let cipher = cipher().await; + let a = cipher.keyset(Uuid::from_u128(1)).await.unwrap(); + let b = cipher.keyset(Uuid::from_u128(2)).await.unwrap(); + + let result = generating(&a, 1).zip(generating(&b, 1)).await; + assert!( + matches!(result, Err(Error::KeysetMismatch { left, right }) + if left == Uuid::from_u128(1) && right == Uuid::from_u128(2)), + "{result:?}" + ); + + let result = Pending::all(&a, vec![generating(&a, 1), generating(&b, 1)]).await; + assert!( + matches!(result, Err(Error::KeysetMismatch { .. })), + "{result:?}" + ); + assert_eq!(cipher.kms().generate_calls(), 0); + } + + /// An unscoped pending merged with a scoped one takes the scope: a + /// ready value beside a tenant's data keys is still that tenant's batch. + #[tokio::test] + async fn an_unscoped_pending_merged_with_a_scoped_one_takes_the_scope() { + let cipher = cipher().await; + let keyset = cipher.default_keyset(); + let (n, tags) = Pending::ready(&cipher, Ok(7u32)) + .zip(generating(&keyset, 1)) + .await + .unwrap(); + + assert_eq!((n, tags.len()), (7, 1)); + assert_eq!( + cipher.kms().generate_keysets(), + vec![Some(keyset.keyset_id())] + ); + } + + /// Through the client scope, retrieves from several keysets settle in one + /// assembly: one `retrieve_keys` call per keyset, in first-seen order, + /// with the keys back in request order. + #[tokio::test] + async fn retrieves_group_by_keyset_and_return_in_request_order() { + let cipher = cipher().await; + let a = cipher.keyset(Uuid::from_u128(1)).await.unwrap(); + let b = cipher.keyset(Uuid::from_u128(2)).await.unwrap(); + let mut from_a = generating_pairs(&a, 2).await.unwrap(); + let mut from_b = generating_pairs(&b, 1).await.unwrap(); + let (a1, a2) = (from_a.remove(0), from_a.remove(0)); + let b1 = from_b.remove(0); + + // Interleaved: A, B, A. + let requests = vec![ + Request::retrieve_data_key(a1.0, a1.1.clone(), d(), a.keyset_id()), + Request::retrieve_data_key(b1.0, b1.1.clone(), d(), b.keyset_id()), + Request::retrieve_data_key(a2.0, a2.1.clone(), d(), a.keyset_id()), + ]; + let ivs: Vec<Iv> = Pending::request(&cipher, requests, |responses| { + Ok(responses.drain_retrieved().map(|key| key.iv).collect()) + }) + .await + .unwrap(); + + assert_eq!( + ivs, + vec![a1.0, b1.0, a2.0], + "keys must come back in request order" + ); + assert_eq!(cipher.kms().retrieve_calls(), 2); + assert_eq!( + cipher.kms().retrieve_keysets(), + vec![Some(a.keyset_id()), Some(b.keyset_id())], + "one call per keyset, first seen first" + ); + } } diff --git a/packages/stack-encrypt/src/target/request.rs b/packages/stack-encrypt/src/target/request.rs index 521afe2ae..326d1d973 100644 --- a/packages/stack-encrypt/src/target/request.rs +++ b/packages/stack-encrypt/src/target/request.rs @@ -14,6 +14,7 @@ use std::collections::VecDeque; use stack_kms::{DataKey, DataKeyWithTag, Iv}; +use uuid::Uuid; use crate::{Descriptor, Error}; @@ -25,15 +26,17 @@ pub struct Request(RequestKind); #[derive(Debug, Clone)] pub(super) enum RequestKind { - /// Generate one fresh data key under the cipher's keyset, bound to + /// Generate one fresh data key under the pending's keyset, bound to /// `descriptor`. GenerateDataKey { descriptor: Descriptor }, /// Re-derive the data key identified by `iv` + `tag`, under the - /// `descriptor` it was generated with. + /// `descriptor` it was generated with, from the keyset it was minted + /// under. RetrieveDataKey { iv: Iv, tag: Vec<u8>, descriptor: Descriptor, + keyset_id: Uuid, }, } @@ -48,12 +51,21 @@ impl Request { /// Request re-derivation of the data key identified by `iv` + `tag` /// (decrypt side), under `descriptor` — which must be the one the key - /// was generated with, or ZeroKMS refuses. - pub fn retrieve_data_key(iv: Iv, tag: Vec<u8>, descriptor: Descriptor) -> Self { + /// was generated with, or ZeroKMS refuses — from `keyset_id`, the + /// keyset it was minted under (a [`SealedValue`] carries it). + /// + /// [`SealedValue`]: crate::SealedValue + pub fn retrieve_data_key( + iv: Iv, + tag: Vec<u8>, + descriptor: Descriptor, + keyset_id: Uuid, + ) -> Self { Self(RequestKind::RetrieveDataKey { iv, tag, descriptor, + keyset_id, }) } @@ -61,6 +73,15 @@ impl Request { pub(super) fn into_kind(self) -> RequestKind { self.0 } + + /// The keyset a retrieve request names; `None` for a generate request, + /// which mints under the pending's keyset. + pub(super) fn retrieve_keyset(&self) -> Option<Uuid> { + match &self.0 { + RequestKind::GenerateDataKey { .. } => None, + RequestKind::RetrieveDataKey { keyset_id, .. } => Some(*keyset_id), + } + } } /// How many requests of each kind `requests` holds, as @@ -181,6 +202,10 @@ mod tests { (generated, retrieved) } + fn ks() -> Uuid { + Uuid::from_u128(7) + } + async fn responses(generated: usize, retrieved: usize) -> Responses { let (g, _) = key_pairs(generated).await; let (_, r) = key_pairs(retrieved).await; @@ -196,9 +221,9 @@ mod tests { fn tally_separates_the_two_kinds() { let requests = vec![ Request::generate_data_key(d()), - Request::retrieve_data_key(Iv::default(), vec![1], d()), + Request::retrieve_data_key(Iv::default(), vec![1], d(), ks()), Request::generate_data_key(d()), - Request::retrieve_data_key(Iv::default(), vec![2], d()), + Request::retrieve_data_key(Iv::default(), vec![2], d(), ks()), Request::generate_data_key(d()), ]; assert_eq!(tally(&requests), (3, 2)); @@ -214,13 +239,15 @@ mod tests { #[test] fn a_retrieve_request_carries_its_iv_tag_and_descriptor() { - let request = Request::retrieve_data_key(Iv::default(), vec![7, 8, 9], d()); + let request = Request::retrieve_data_key(Iv::default(), vec![7, 8, 9], d(), ks()); match request.into_kind() { RequestKind::RetrieveDataKey { iv, tag, descriptor, + keyset_id, } => { + assert_eq!(keyset_id, ks()); assert_eq!(iv, Iv::default()); assert_eq!(tag, vec![7, 8, 9]); assert_eq!(descriptor, d()); diff --git a/packages/stack-encrypt/tests/derive.rs b/packages/stack-encrypt/tests/derive.rs index a99221c62..74d51e61a 100644 --- a/packages/stack-encrypt/tests/derive.rs +++ b/packages/stack-encrypt/tests/derive.rs @@ -13,7 +13,7 @@ use common::{counting_cipher, stack_cipher}; use stack_encrypt::sem::{EqualityTerm, MatchTerm, OreTerm}; use stack_encrypt::target::{DecryptFrom, EncryptInto}; use stack_encrypt::{ - nonempty, DecryptField, DecryptInto, DecryptTarget, Decryptable, EncryptFrom, Error, Pending, + nonempty, DecryptField, DecryptInto, Decryptable, EncryptFrom, Error, KeysetCipher, Pending, StackCipher, StackCipherText, }; @@ -32,10 +32,12 @@ struct EncryptedAge { #[tokio::test] async fn a_derived_record_is_the_hand_written_one() { let cipher = stack_cipher().await; + let keyset = cipher.default_keyset(); let generator = stack_cipher().await; + let generator = generator.default_keyset(); let record: EncryptedAge = 42u32 - .encrypt_into_with_context(&cipher, nonempty!("users/age")) + .encrypt_into_with_context(&keyset, nonempty!("users/age")) .await .unwrap(); @@ -87,11 +89,13 @@ struct Tagged<T: CllwOreEncrypt> { #[tokio::test] async fn a_generic_plaintext_record_accepts_what_its_leaves_accept() { let cipher = stack_cipher().await; + let keyset = cipher.default_keyset(); let generator = stack_cipher().await; + let generator = generator.default_keyset(); let record: SearchableText = "alice" .to_string() - .encrypt_into_with_context(&cipher, nonempty!("users/name")) + .encrypt_into_with_context(&keyset, nonempty!("users/name")) .await .unwrap(); let hm: EqualityTerm = "alice" @@ -113,7 +117,7 @@ async fn a_generic_plaintext_record_accepts_what_its_leaves_accept() { assert_eq!(name, "alice"); let pair: Pair = "bob" - .encrypt_into_with_context(&cipher, nonempty!("users/name")) + .encrypt_into_with_context(&keyset, nonempty!("users/name")) .await .unwrap(); let hm: EqualityTerm = "bob" @@ -129,7 +133,7 @@ async fn a_generic_plaintext_record_accepts_what_its_leaves_accept() { assert_eq!(name, "bob"); let tagged: Tagged<u32> = 7u32 - .encrypt_into_with_context(&cipher, nonempty!("users/score")) + .encrypt_into_with_context(&keyset, nonempty!("users/score")) .await .unwrap(); let ob: OreTerm<u32> = 7u32 @@ -168,9 +172,10 @@ struct Numbers { #[tokio::test] async fn decrypt_marks_the_field_when_the_types_cannot_choose() { let cipher = stack_cipher().await; + let keyset = cipher.default_keyset(); let doubled: Doubled = 9u32 - .encrypt_into_with_context(&cipher, nonempty!("doubled")) + .encrypt_into_with_context(&keyset, nonempty!("doubled")) .await .unwrap(); let opened: u32 = doubled @@ -182,7 +187,7 @@ async fn decrypt_marks_the_field_when_the_types_cannot_choose() { // and its literal context is extended by the caller's like any other: // sealed under `("doubled/shadow", "doubled")`. let doubled: Doubled = 9u32 - .encrypt_into_with_context(&cipher, nonempty!("doubled")) + .encrypt_into_with_context(&keyset, nonempty!("doubled")) .await .unwrap(); let shadow: u32 = doubled @@ -196,7 +201,7 @@ async fn decrypt_marks_the_field_when_the_types_cannot_choose() { assert_eq!(shadow, 9); let numbers: Numbers = vec![1u32, 2, 3] - .encrypt_into_with_context(&cipher, nonempty!("numbers")) + .encrypt_into_with_context(&keyset, nonempty!("numbers")) .await .unwrap(); assert_eq!(numbers.hm.len(), 3); @@ -212,13 +217,13 @@ async fn decrypt_marks_the_field_when_the_types_cannot_choose() { #[derive(PartialEq)] struct OpaqueTerm(EqualityTerm); -impl<S, K, Ctx> EncryptFrom<S, StackCipher<K>, Ctx> for OpaqueTerm +impl<'k, S, K, Ctx> EncryptFrom<S, KeysetCipher<'k, K>, Ctx> for OpaqueTerm where - EqualityTerm: EncryptFrom<S, StackCipher<K>, Ctx>, + EqualityTerm: EncryptFrom<S, KeysetCipher<'k, K>, Ctx>, { fn encrypt_from<'a>( source: &'a S, - cipher: &'a StackCipher<K>, + cipher: &'a KeysetCipher<'k, K>, context: Ctx, ) -> Pending<'a, Self, K> where @@ -246,10 +251,12 @@ const _: () = assert!(<WithOpaque as Decryptable>::DECRYPTABLE); #[tokio::test] async fn explicit_mode_supports_opaque_fields_in_the_paired_derive() { let cipher = stack_cipher().await; + let keyset = cipher.default_keyset(); let generator = stack_cipher().await; + let generator = generator.default_keyset(); let record: WithOpaque = 5u32 - .encrypt_into_with_context(&cipher, nonempty!("opaque")) + .encrypt_into_with_context(&keyset, nonempty!("opaque")) .await .unwrap(); let hm: EqualityTerm = 5u32 @@ -274,8 +281,12 @@ impl Decryptable for Lying { const DECRYPTABLE: bool = true; } -impl<P, C: DecryptTarget, Ctx> DecryptField<P, C, Ctx> for Lying { - fn decrypt_field<'a>(self, _cipher: &'a C, _context: Ctx) -> Option<C::Output<'a, P>> +impl<P, K, Ctx> DecryptField<P, StackCipher<K>, Ctx> for Lying { + fn decrypt_field<'a>( + self, + _cipher: &'a StackCipher<K>, + _context: Ctx, + ) -> Option<Pending<'a, P, K>> where Self: 'a, P: 'a, @@ -327,18 +338,19 @@ struct EncryptedValue { #[tokio::test] async fn listed_plaintexts_each_get_their_own_impl() { let cipher = stack_cipher().await; + let keyset = cipher.default_keyset(); let number: EncryptedValue = 7u32 - .encrypt_into_with_context(&cipher, nonempty!("t/n")) + .encrypt_into_with_context(&keyset, nonempty!("t/n")) .await .unwrap(); let text: EncryptedValue = "seven" .to_string() - .encrypt_into_with_context(&cipher, nonempty!("t/t")) + .encrypt_into_with_context(&keyset, nonempty!("t/t")) .await .unwrap(); let hm: EqualityTerm = 7u32 - .encrypt_into_with_context(&cipher, nonempty!("t/n")) + .encrypt_into_with_context(&keyset, nonempty!("t/n")) .await .unwrap(); assert_eq!(number.hm, hm); @@ -354,13 +366,14 @@ async fn listed_plaintexts_each_get_their_own_impl() { #[tokio::test] async fn a_failed_field_fails_the_derived_record_before_any_io() { let (cipher, generates, _) = counting_cipher().await; + let keyset = cipher.default_keyset(); // Text that yields no match tokens fails that leaf during the // synchronous build; the derived record is the zip of its fields, so it // fails the same way and never mints the data key its ciphertext field // would have wanted. let result: Result<SearchableText, _> = String::new() - .encrypt_into_with_context(&cipher, nonempty!("users/name")) + .encrypt_into_with_context(&keyset, nonempty!("users/name")) .await; assert!(matches!(result, Err(Error::Term(_)))); assert_eq!(generates.load(AtomicOrdering::SeqCst), 0); @@ -405,11 +418,13 @@ fn user() -> User { #[tokio::test] async fn a_struct_is_one_batched_call_and_rebuilds_its_plaintext() { let (cipher, generates, retrieves) = counting_cipher().await; + let keyset = cipher.default_keyset(); let generator = stack_cipher().await; + let generator = generator.default_keyset(); // Every field has its own context, so the struct needs none from the // caller: the context-free forms are the whole call, both ways. - let row: EncryptedUser = user().encrypt_into(&cipher).await.unwrap(); + let row: EncryptedUser = user().encrypt_into(&keyset).await.unwrap(); assert_eq!( generates.load(AtomicOrdering::SeqCst), 1, @@ -444,6 +459,7 @@ async fn a_struct_is_one_batched_call_and_rebuilds_its_plaintext() { #[tokio::test] async fn a_column_of_structs_is_still_one_call_each_way() { let (cipher, generates, retrieves) = counting_cipher().await; + let keyset = cipher.default_keyset(); let users: Vec<User> = (0..4) .map(|i| User { @@ -452,7 +468,7 @@ async fn a_column_of_structs_is_still_one_call_each_way() { }) .collect(); - let rows: Vec<EncryptedUser> = users.encrypt_into(&cipher).await.unwrap(); + let rows: Vec<EncryptedUser> = users.encrypt_into(&keyset).await.unwrap(); assert_eq!(rows.len(), 4); assert_eq!(generates.load(AtomicOrdering::SeqCst), 1); @@ -464,13 +480,15 @@ async fn a_column_of_structs_is_still_one_call_each_way() { #[tokio::test] async fn a_struct_extends_its_contexts_with_the_callers() { let (cipher, generates, retrieves) = counting_cipher().await; + let keyset = cipher.default_keyset(); let generator = stack_cipher().await; + let generator = generator.default_keyset(); // The caller's context — the record's id — extends every inferred one: // `age` is derived under `("user/age", 7u64)`, still in one batched // call, and a query site probes it under the same pair. let row: EncryptedUser = user() - .encrypt_into_with_context(&cipher, 7u64) + .encrypt_into_with_context(&keyset, 7u64) .await .unwrap(); assert_eq!(generates.load(AtomicOrdering::SeqCst), 1); @@ -493,7 +511,7 @@ async fn a_struct_extends_its_contexts_with_the_callers() { assert_eq!(retrieves.load(AtomicOrdering::SeqCst), 1); let row: EncryptedUser = user() - .encrypt_into_with_context(&cipher, 7u64) + .encrypt_into_with_context(&keyset, 7u64) .await .unwrap(); // The fake key source ignores descriptors, so the AEAD is what refuses @@ -502,7 +520,7 @@ async fn a_struct_extends_its_contexts_with_the_callers() { let other_row = User::decrypt_from_with_context(row, &cipher, 8u64).await; assert!(matches!(other_row, Err(Error::Aead))); let row: EncryptedUser = user() - .encrypt_into_with_context(&cipher, 7u64) + .encrypt_into_with_context(&keyset, 7u64) .await .unwrap(); let no_row = User::decrypt_from(row, &cipher).await; @@ -510,7 +528,7 @@ async fn a_struct_extends_its_contexts_with_the_callers() { // Any context does: a string, a pair, an `Option`. let row: EncryptedUser = user() - .encrypt_into_with_context(&cipher, nonempty!("tenant/acme")) + .encrypt_into_with_context(&keyset, nonempty!("tenant/acme")) .await .unwrap(); let recovered = User::decrypt_from_with_context(row, &cipher, nonempty!("tenant/acme")) @@ -522,8 +540,9 @@ async fn a_struct_extends_its_contexts_with_the_callers() { #[tokio::test] async fn a_struct_field_opened_under_the_wrong_context_fails() { let cipher = stack_cipher().await; + let keyset = cipher.default_keyset(); - let row: EncryptedUser = user().encrypt_into(&cipher).await.unwrap(); + let row: EncryptedUser = user().encrypt_into(&keyset).await.unwrap(); // The literal contexts are baked into the impl, so a transplanted field // is caught exactly as for a leaf: by the AAD against the fake key // source, by ZeroKMS's descriptor check (`Error::Kms`) before that in @@ -556,13 +575,15 @@ struct EncryptedAccount { #[tokio::test] async fn a_struct_nests_in_a_struct_via_nested() { let (cipher, generates, retrieves) = counting_cipher().await; + let keyset = cipher.default_keyset(); let generator = stack_cipher().await; + let generator = generator.default_keyset(); let account = Account { user: user(), plan: "pro".to_string(), }; - let row: EncryptedAccount = account.encrypt_into(&cipher).await.unwrap(); + let row: EncryptedAccount = account.encrypt_into(&keyset).await.unwrap(); assert_eq!(generates.load(AtomicOrdering::SeqCst), 1); // The inner struct's fields are still under their own contexts. @@ -578,7 +599,7 @@ async fn a_struct_nests_in_a_struct_via_nested() { // The outer's plan is under the inferred `"accounts/plan"`. Decrypting // the row consumed it, so mint a fresh one to open the field alone. - let row: EncryptedAccount = account.encrypt_into(&cipher).await.unwrap(); + let row: EncryptedAccount = account.encrypt_into(&keyset).await.unwrap(); let plan: String = row .plan .decrypt_into(&cipher, nonempty!("accounts/plan")) @@ -590,7 +611,7 @@ async fn a_struct_nests_in_a_struct_via_nested() { // its own contexts there: the inner `age` is under `("user/age", id)`, // the outer `plan` under `("accounts/plan", id)`. let row: EncryptedAccount = account - .encrypt_into_with_context(&cipher, 9u64) + .encrypt_into_with_context(&keyset, 9u64) .await .unwrap(); let age_hm: EqualityTerm = 42u32 @@ -630,10 +651,12 @@ struct NamedReading { #[tokio::test] async fn a_tuple_plaintext_is_reached_and_rebuilt_by_index() { let cipher = stack_cipher().await; + let keyset = cipher.default_keyset(); let generator = stack_cipher().await; + let generator = generator.default_keyset(); let reading = Reading(21, "celsius".into()); - let row: EncryptedReading = reading.encrypt_into(&cipher).await.unwrap(); + let row: EncryptedReading = reading.encrypt_into(&keyset).await.unwrap(); let hm: EqualityTerm = 21u32 .encrypt_into_with_context(&generator, nonempty!("reading/0")) @@ -644,7 +667,7 @@ async fn a_tuple_plaintext_is_reached_and_rebuilt_by_index() { let recovered = Reading::decrypt_from(row, &cipher).await.unwrap(); assert_eq!(recovered, reading); - let named: NamedReading = reading.encrypt_into(&cipher).await.unwrap(); + let named: NamedReading = reading.encrypt_into(&keyset).await.unwrap(); assert_eq!(named.value.hm, hm, "from = 0 infers the same context"); let unit: String = named .unit diff --git a/packages/stack-encrypt/tests/descriptor.rs b/packages/stack-encrypt/tests/descriptor.rs index e45580644..f61ff8b4b 100644 --- a/packages/stack-encrypt/tests/descriptor.rs +++ b/packages/stack-encrypt/tests/descriptor.rs @@ -48,9 +48,10 @@ fn user() -> User { #[tokio::test] async fn a_leaf_sends_its_context_as_the_descriptor_both_ways() -> Result<(), Error> { let (cipher, sent) = recording_cipher().await; + let keyset = cipher.default_keyset(); let ct: StackCipherText = "alice" - .encrypt_into_with_context(&cipher, nonempty!("users/email")) + .encrypt_into_with_context(&keyset, nonempty!("users/email")) .await?; let _: String = ct.decrypt_into(&cipher, nonempty!("users/email")).await?; @@ -63,8 +64,9 @@ async fn a_leaf_sends_its_context_as_the_descriptor_both_ways() -> Result<(), Er #[tokio::test] async fn a_struct_sends_one_descriptor_per_field_context() -> Result<(), Error> { let (cipher, sent) = recording_cipher().await; + let keyset = cipher.default_keyset(); - let row: EncryptedUser = user().encrypt_into(&cipher).await?; + let row: EncryptedUser = user().encrypt_into(&keyset).await?; let back = User::decrypt_from(row, &cipher).await?; assert_eq!(back, user()); @@ -87,8 +89,9 @@ async fn a_struct_sends_one_descriptor_per_field_context() -> Result<(), Error> #[tokio::test] async fn a_callers_context_extends_every_fields_descriptor() -> Result<(), Error> { let (cipher, sent) = recording_cipher().await; + let keyset = cipher.default_keyset(); - let row: EncryptedUser = user().encrypt_into_with_context(&cipher, 7u64).await?; + let row: EncryptedUser = user().encrypt_into_with_context(&keyset, 7u64).await?; let back = User::decrypt_from_with_context(row, &cipher, 7u64).await?; assert_eq!(back, user()); @@ -116,9 +119,10 @@ async fn a_callers_context_extends_every_fields_descriptor() -> Result<(), Error #[tokio::test] async fn every_leaf_of_a_tree_shares_the_root_descriptor() -> Result<(), Error> { let (cipher, sent) = recording_cipher().await; + let keyset = cipher.default_keyset(); let column: Vec<StackCipherText> = vec![1u32, 2, 3] - .encrypt_into_with_context(&cipher, nonempty!("users/age")) + .encrypt_into_with_context(&keyset, nonempty!("users/age")) .await?; let _: Vec<u32> = column.decrypt_into(&cipher, nonempty!("users/age")).await?; @@ -133,11 +137,12 @@ async fn every_leaf_of_a_tree_shares_the_root_descriptor() -> Result<(), Error> #[tokio::test] async fn the_cipher_directed_path_renders_its_aad_the_same_way() -> Result<(), Error> { let (cipher, sent) = recording_cipher().await; + let keyset = cipher.default_keyset(); - let ct = cipher.encrypt(42u32, "users/age").await?; + let ct = keyset.encrypt(42u32, "users/age").await?; let _: u32 = cipher.decrypt(ct, "users/age").await?; // No AAD at all is the empty descriptor: ZeroKMS binds nothing. - let ct = cipher.encrypt(42u32, ()).await?; + let ct = keyset.encrypt(42u32, ()).await?; let _: u32 = cipher.decrypt(ct, ()).await?; let sent = sent.lock().expect("lock").clone(); @@ -172,12 +177,13 @@ async fn a_column_renders_an_over_long_context_once() -> Result<(), Error> { } let (cipher, sent) = recording_cipher().await; + let keyset = cipher.default_keyset(); let renders = Arc::new(AtomicUsize::new(0)); let context = stack_encrypt::NonEmpty::new(Counted(renders.clone())).unwrap(); let values: Vec<u32> = (0..10_000).collect(); let result: Result<Vec<StackCipherText>, Error> = values - .encrypt_into_with_context(&cipher, context.clone()) + .encrypt_into_with_context(&keyset, context.clone()) .await; assert!( matches!(result, Err(Error::DescriptorTooLong { .. })), @@ -190,7 +196,7 @@ async fn a_column_renders_an_over_long_context_once() -> Result<(), Error> { ); let column: Vec<StackCipherText> = vec![1u32, 2, 3] - .encrypt_into_with_context(&cipher, nonempty!("users/age")) + .encrypt_into_with_context(&keyset, nonempty!("users/age")) .await?; let opened: Result<Vec<u32>, Error> = column.decrypt_into(&cipher, context).await; assert!( @@ -219,17 +225,18 @@ async fn a_column_renders_an_over_long_context_once() -> Result<(), Error> { #[tokio::test] async fn a_column_with_nothing_to_bind_takes_any_context() -> Result<(), Error> { let (cipher, sent) = recording_cipher().await; + let keyset = cipher.default_keyset(); let long = stack_encrypt::NonEmpty::new("a".repeat(Descriptor::MAX_LEN + 1)).unwrap(); let names = vec!["alice".to_string(), "bob".to_string()]; let terms: Vec<EqualityTerm> = names - .encrypt_into_with_context(&cipher, long.clone()) + .encrypt_into_with_context(&keyset, long.clone()) .await?; assert_eq!(terms.len(), 2); let none: Vec<u32> = Vec::new(); let sealed: Vec<StackCipherText> = none - .encrypt_into_with_context(&cipher, long.clone()) + .encrypt_into_with_context(&keyset, long.clone()) .await?; assert!(sealed.is_empty()); let opened: Vec<u32> = sealed.decrypt_into(&cipher, long).await?; @@ -246,11 +253,12 @@ async fn a_column_with_nothing_to_bind_takes_any_context() -> Result<(), Error> #[tokio::test] async fn an_over_long_context_is_refused_before_any_request_on_either_path() -> Result<(), Error> { let (cipher, sent) = recording_cipher().await; + let keyset = cipher.default_keyset(); let long = stack_encrypt::NonEmpty::new("a".repeat(Descriptor::MAX_LEN + 1)).unwrap(); let values: Vec<u32> = (0..10_000).collect(); let result: Result<Vec<StackCipherText>, Error> = values - .encrypt_into_with_context(&cipher, long.clone()) + .encrypt_into_with_context(&keyset, long.clone()) .await; assert!( matches!(result, Err(Error::DescriptorTooLong { len }) if len == Descriptor::MAX_LEN + 1), @@ -258,7 +266,7 @@ async fn an_over_long_context_is_refused_before_any_request_on_either_path() -> ); let sealed: StackCipherText = 7u32 - .encrypt_into_with_context(&cipher, nonempty!("users/age")) + .encrypt_into_with_context(&keyset, nonempty!("users/age")) .await?; let opened: Result<u32, Error> = sealed.decrypt_into(&cipher, long).await; assert!( diff --git a/packages/stack-encrypt/tests/frozen_bytes.rs b/packages/stack-encrypt/tests/frozen_bytes.rs index 310ec3d4e..0e58b4990 100644 --- a/packages/stack-encrypt/tests/frozen_bytes.rs +++ b/packages/stack-encrypt/tests/frozen_bytes.rs @@ -2,7 +2,8 @@ //! languages: //! //! * the [`SealedValue`] leaf layout -//! (`version ‖ iv ‖ tag_len ‖ tag ‖ local_ciphertext`) — the storage +//! (`version ‖ keyset_id ‖ iv ‖ tag_len ‖ tag ‖ local_ciphertext`) — the +//! storage //! format a database column holds, and //! * the index-term encodings (equality: raw 32 bytes; match: LE `u16` //! positions; ORE/OPE: raw CLLW ciphertext bytes). @@ -50,20 +51,27 @@ fn hex(bytes: &[u8]) -> String { /// "ciphertext" is not a real AEAD output — encoding is structural and must /// not care. fn fixture_leaf() -> SealedValue { + let keyset_id = Uuid::from_bytes(*b"keyset-fixture16"); let iv: stack_kms::Iv = *b"0123456789abcdef"; - SealedValue::from_parts(iv, vec![0xAA, 0xBB, 0xCC], vec![0xDE, 0xAD, 0xBE, 0xEF]) - .expect("fixture tag fits the length field") + SealedValue::from_parts( + keyset_id, + iv, + vec![0xAA, 0xBB, 0xCC], + vec![0xDE, 0xAD, 0xBE, 0xEF], + ) + .expect("fixture tag fits the length field") } #[test] fn sealed_value_layout_is_pinned() { let bytes = fixture_leaf().to_bytes(); - // version(01) ‖ iv(16 bytes: ASCII "0123456789abcdef") ‖ - // tag_len(0300 — 3, u16 LE) ‖ tag(aabbcc) ‖ local_ciphertext(deadbeef) + // version(01) ‖ keyset_id(16 raw UUID bytes: ASCII "keyset-fixture16") ‖ + // iv(16 bytes: ASCII "0123456789abcdef") ‖ tag_len(0300 — 3, u16 LE) ‖ + // tag(aabbcc) ‖ local_ciphertext(deadbeef) assert_eq!( hex(&bytes), - "01303132333435363738396162636465660300aabbccdeadbeef" + "016b65797365742d666978747572653136303132333435363738396162636465660300aabbccdeadbeef" ); } @@ -73,12 +81,14 @@ fn sealed_value_from_bytes_inverts_to_bytes() { let bytes = original.to_bytes(); let decoded = SealedValue::from_bytes(&bytes).expect("decode leaf"); + assert_eq!(decoded.keyset_id(), original.keyset_id()); assert_eq!(decoded.iv(), original.iv()); assert_eq!(decoded.tag(), original.tag()); assert_eq!(decoded.ciphertext(), original.ciphertext()); // The std conversion is the same decoder. let converted = SealedValue::try_from(bytes.as_slice()).expect("TryFrom decode"); + assert_eq!(converted.keyset_id(), original.keyset_id()); assert_eq!(converted.ciphertext(), original.ciphertext()); } @@ -96,11 +106,11 @@ fn sealed_value_rejects_unknown_version() { fn sealed_value_rejects_truncation() { let bytes = fixture_leaf().to_bytes(); - // Every prefix shorter than the tag's end is truncated: empty, mid-iv, - // mid-length-field, and mid-tag. (Anything at or past the tag's end - // parses — the local ciphertext takes the remainder, and proving *it* - // whole is the AEAD open's job.) - let tag_end = 1 + 16 + 2 + 3; + // Every prefix shorter than the tag's end is truncated: empty, + // mid-keyset-id, mid-iv, mid-length-field, and mid-tag. (Anything at or + // past the tag's end parses — the local ciphertext takes the remainder, + // and proving *it* whole is the AEAD open's job.) + let tag_end = 1 + 16 + 16 + 2 + 3; for len in 0..tag_end { assert!( matches!( @@ -118,6 +128,7 @@ fn sealed_value_rejects_oversized_tag_on_construction() { // `to_bytes` is infallible because the tag can never outgrow the `u16` // length field: the only constructor that could admit one rejects it. let result = SealedValue::from_parts( + Uuid::nil(), [0; 16], vec![0; usize::from(u16::MAX) + 1], vec![0xDE, 0xAD], @@ -132,7 +143,8 @@ fn sealed_value_rejects_oversized_tag_on_construction() { async fn sealed_leaf_survives_persistence_via_bytes() { // The format round-trips a *real* leaf: encrypt, encode, decode, decrypt. let cipher = cipher().await; - let ct = cipher + let keyset = cipher.default_keyset(); + let ct = keyset .encrypt("durable".to_string(), b"ctx".as_slice()) .await .expect("encrypt"); @@ -150,6 +162,36 @@ async fn sealed_leaf_survives_persistence_via_bytes() { assert_eq!(pt, "durable"); } +#[tokio::test] +async fn sealed_value_keyset_id_is_authenticated() { + // The keyset id is bound into the leaf's AAD: a leaf re-pointed at another + // keyset fails to open. (The fake source ignores keyset ids, so the key + // retrieve itself succeeds — the AEAD is what refuses.) + let cipher = cipher().await; + let keyset = cipher.default_keyset(); + let aad = b"ctx".as_slice(); + let ct = keyset + .encrypt("durable".to_string(), aad) + .await + .expect("encrypt"); + let leaf = match ct { + CipherText::Single(leaf) => leaf, + other => panic!("expected a Single leaf, got {other:?}"), + }; + let (keyset_id, iv, tag, bytes) = leaf.into_parts(); + let other_keyset = Uuid::from_bytes(*b"another-keyset16"); + assert_ne!(keyset_id, other_keyset); + let tampered = SealedValue::from_parts(other_keyset, iv, tag, bytes).expect("rebuild leaf"); + + let result = cipher + .decrypt::<String, _>(CipherText::Single(tampered), aad) + .await; + assert!( + matches!(result, Err(Error::Aead)), + "a leaf re-pointed at another keyset must not decrypt: {result:?}" + ); +} + /// Delegates to [`FakeDataKeySource`] but inflates every generated key tag /// past the `u16` length field — the misbehaving custom [`DataKeySource`] the /// seal path must reject, rather than build a leaf whose `to_bytes` writes a @@ -203,6 +245,7 @@ async fn seal_rejects_a_key_tag_the_length_field_cannot_frame() { .expect("build cipher"); let result = cipher + .default_keyset() .encrypt("boundary".to_string(), b"ctx".as_slice()) .await; assert!( @@ -217,8 +260,9 @@ async fn seal_rejects_a_key_tag_the_length_field_cannot_frame() { #[tokio::test] async fn equality_term_encoding_is_the_raw_prf_bytes() { - let term = cipher() - .await + let cipher = cipher().await; + let keyset = cipher.default_keyset(); + let term = keyset .equality_term("alice", nonempty!("users/email")) .await .expect("equality term"); @@ -250,8 +294,9 @@ fn equality_term_try_from_rejects_wrong_length() { #[tokio::test] async fn match_term_bytes_are_pinned() { - let term = cipher() - .await + let cipher = cipher().await; + let keyset = cipher.default_keyset(); + let term = keyset .match_terms::<DefaultMatch>("alice smith", nonempty!("users/name")) .await .expect("match term"); @@ -329,8 +374,9 @@ fn match_term_from_bytes_rejects_positions_outside_the_filter() { #[tokio::test] async fn ore_term_encoding_is_the_raw_cllw_bytes() { let cipher = cipher().await; + let keyset = cipher.default_keyset(); let term: OreTerm<u32> = 42u32 - .encrypt_into_with_context(&cipher, nonempty!("users/age")) + .encrypt_into_with_context(&keyset, nonempty!("users/age")) .await .expect("ore term"); @@ -358,8 +404,9 @@ async fn ore_term_encoding_is_the_raw_cllw_bytes() { #[tokio::test] async fn ope_term_encoding_is_the_raw_cllw_bytes() { let cipher = cipher().await; + let keyset = cipher.default_keyset(); let term: OpeTerm<u32> = 42u32 - .encrypt_into_with_context(&cipher, nonempty!("users/age")) + .encrypt_into_with_context(&keyset, nonempty!("users/age")) .await .expect("ope term"); @@ -401,9 +448,10 @@ async fn variable_length_ore_and_ope_terms_decode() { // plaintext byte; OPE adds a leading carry byte) — their decode path is // the length-validating TryFrom in cllw-ore. let cipher = cipher().await; + let keyset = cipher.default_keyset(); let ore: OreTerm<String> = "alice" .to_string() - .encrypt_into_with_context(&cipher, nonempty!("users/name")) + .encrypt_into_with_context(&keyset, nonempty!("users/name")) .await .expect("ore term"); assert_eq!(ore.as_bytes().len(), 5 * 8); @@ -418,7 +466,7 @@ async fn variable_length_ore_and_ope_terms_decode() { let ope: OpeTerm<String> = "alice" .to_string() - .encrypt_into_with_context(&cipher, nonempty!("users/name")) + .encrypt_into_with_context(&keyset, nonempty!("users/name")) .await .expect("ope term"); assert_eq!(ope.as_bytes().len(), 5 * 8 + 1); diff --git a/packages/stack-encrypt/tests/keysets.rs b/packages/stack-encrypt/tests/keysets.rs new file mode 100644 index 000000000..3ee9f1134 --- /dev/null +++ b/packages/stack-encrypt/tests/keysets.rs @@ -0,0 +1,385 @@ +//! Keysets: one client, many keysets. Selection, caching, and the +//! keyset-scoped versus client-scoped decrypt paths. + +use std::borrow::Cow; +use std::num::NonZeroUsize; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::Mutex; + +use stack_encrypt::target::{DecryptInto, EncryptInto}; +use stack_encrypt::{nonempty, CipherText, Error, SealedValue, StackCipher, StackCipherText}; +use stack_kms::{ + DataKey, DataKeySource, DataKeyWithTag, FakeDataKeySource, GenerateKeyPayload, IdentifiedBy, + IndexKey, IndexKeySource, RetrieveKeyPayload, UnverifiedContext, +}; +use uuid::Uuid; + +/// The fake, plus a count of keyset loads and a log of the keyset each +/// retrieve call named — the two facts the cache and the grouped dispatch +/// are about. +#[derive(Default)] +struct Observed { + inner: FakeDataKeySource, + loads: AtomicUsize, + retrieve_keysets: Mutex<Vec<Option<Uuid>>>, +} + +impl Observed { + fn loads(&self) -> usize { + self.loads.load(Ordering::Relaxed) + } + + fn retrieve_keysets(&self) -> Vec<Option<Uuid>> { + self.retrieve_keysets.lock().expect("lock").clone() + } +} + +impl DataKeySource for Observed { + async fn generate_keys( + &self, + payloads: Vec<GenerateKeyPayload<'_>>, + keyset_id: Option<Uuid>, + unverified_context: Option<Cow<'_, UnverifiedContext>>, + ) -> Result<Vec<DataKeyWithTag>, stack_kms::Error> { + self.inner + .generate_keys(payloads, keyset_id, unverified_context) + .await + } + + async fn retrieve_keys( + &self, + payloads: Vec<RetrieveKeyPayload<'_>>, + keyset_id: Option<Uuid>, + unverified_context: Option<&UnverifiedContext>, + ) -> Result<Vec<DataKey>, stack_kms::Error> { + self.retrieve_keysets.lock().expect("lock").push(keyset_id); + self.inner + .retrieve_keys(payloads, keyset_id, unverified_context) + .await + } +} + +impl IndexKeySource for Observed { + async fn load_index_key( + &self, + keyset_id: Option<IdentifiedBy>, + ) -> Result<(Uuid, IndexKey), stack_kms::Error> { + self.loads.fetch_add(1, Ordering::Relaxed); + self.inner.load_index_key(keyset_id).await + } +} + +fn name(name: &str) -> IdentifiedBy { + IdentifiedBy::Name(name.to_string().into()) +} + +async fn cipher() -> StackCipher<Observed> { + StackCipher::builder() + .kms(Observed::default()) + .init() + .await + .expect("build cipher") +} + +// ============================================================================= +// Selection and caching +// ============================================================================= + +#[tokio::test] +async fn init_loads_the_default_keyset_once() { + let cipher = cipher().await; + assert_eq!(cipher.kms().loads(), 1, "the default keyset loads at init"); + + let expected = FakeDataKeySource::new() + .load_index_key(None) + .await + .expect("resolve") + .0; + assert_eq!(cipher.default_keyset().keyset_id(), expected); + assert_eq!(cipher.default_keyset().keyset_name(), None); + assert_eq!(cipher.kms().loads(), 1, "default_keyset() never loads"); +} + +#[tokio::test] +async fn a_named_default_knows_its_name() { + let cipher = StackCipher::builder() + .kms(Observed::default()) + .keyset(name("customers")) + .init() + .await + .expect("build cipher"); + + assert_eq!(cipher.default_keyset().keyset_name(), Some("customers")); + let by_name = cipher.keyset(name("customers")).await.expect("select"); + assert_eq!(by_name.keyset_id(), cipher.default_keyset().keyset_id()); + assert_eq!( + cipher.kms().loads(), + 1, + "selecting the default by name is not a load" + ); +} + +#[tokio::test] +async fn a_keyset_loads_on_first_selection_and_is_cached_after() { + let cipher = cipher().await; + let first = cipher.keyset(name("acme")).await.expect("select"); + assert_eq!(cipher.kms().loads(), 2, "first selection loads"); + assert_eq!(first.keyset_name(), Some("acme")); + + let again = cipher.keyset(name("acme")).await.expect("select again"); + let by_id = cipher + .keyset(first.keyset_id()) + .await + .expect("select by id"); + assert_eq!(cipher.kms().loads(), 2, "later selections are lookups"); + assert_eq!(again.keyset_id(), first.keyset_id()); + assert_eq!(by_id.keyset_id(), first.keyset_id()); + assert_eq!( + by_id.keyset_name(), + Some("acme"), + "the cached state keeps the name" + ); +} + +#[tokio::test] +async fn a_keyset_selected_by_id_is_not_known_by_name() { + let cipher = cipher().await; + let by_id = cipher.keyset(Uuid::from_u128(42)).await.expect("select"); + assert_eq!(by_id.keyset_name(), None); + assert_eq!(cipher.kms().loads(), 2); +} + +#[tokio::test] +async fn an_evicted_keyset_reloads_on_its_next_selection() { + let cipher = StackCipher::builder() + .kms(Observed::default()) + .keyset_cache_size(NonZeroUsize::new(1).expect("non-zero")) + .init() + .await + .expect("build cipher"); + + let a = cipher.keyset(Uuid::from_u128(1)).await.expect("a"); + let _b = cipher.keyset(Uuid::from_u128(2)).await.expect("b"); + assert_eq!(cipher.kms().loads(), 3, "default + a + b"); + + // `a` was evicted by `b`; selecting it again is a load. The handle taken + // earlier is unaffected: it holds its own state. + let a_again = cipher.keyset(Uuid::from_u128(1)).await.expect("a again"); + assert_eq!(cipher.kms().loads(), 4); + assert_eq!(a_again.keyset_id(), a.keyset_id()); + + // The default never evicts, however small the cache. + let _ = cipher.default_keyset(); + let _ = cipher + .keyset(cipher.default_keyset().keyset_id()) + .await + .expect("default by id"); + assert_eq!(cipher.kms().loads(), 4); +} + +#[tokio::test] +async fn keysets_derive_distinct_index_keys() { + let cipher = cipher().await; + let a = cipher.keyset(Uuid::from_u128(1)).await.expect("a"); + let b = cipher.keyset(Uuid::from_u128(2)).await.expect("b"); + + let term_a = a + .equality_term(7u32, nonempty!("users/age")) + .await + .expect("term"); + let term_b = b + .equality_term(7u32, nonempty!("users/age")) + .await + .expect("term"); + let term_a_again = a + .equality_term(7u32, nonempty!("users/age")) + .await + .expect("term"); + + assert_ne!(term_a, term_b, "different keysets, different index keys"); + assert_eq!( + term_a, term_a_again, + "the same keyset derives the same term" + ); +} + +// ============================================================================= +// The leaf carries its keyset +// ============================================================================= + +fn leaf_of(tree: StackCipherText) -> SealedValue { + match tree { + CipherText::Single(leaf) => leaf, + _ => panic!("a scalar seals to a single leaf"), + } +} + +#[tokio::test] +async fn a_sealed_leaf_names_the_keyset_it_was_sealed_under() { + let cipher = cipher().await; + let tenant = cipher.keyset(name("acme")).await.expect("select"); + + let sealed = tenant + .encrypt("hello".to_string(), "greeting") + .await + .expect("seal"); + assert_eq!(leaf_of(sealed).keyset_id(), tenant.keyset_id()); + + let sealed = cipher + .default_keyset() + .encrypt("hello".to_string(), "greeting") + .await + .expect("seal"); + assert_eq!( + leaf_of(sealed).keyset_id(), + cipher.default_keyset().keyset_id() + ); +} + +// ============================================================================= +// Decrypting: the client opens any keyset, a keyset handle only its own +// ============================================================================= + +#[tokio::test] +async fn the_client_opens_a_leaf_from_any_keyset() { + let cipher = cipher().await; + let tenant = cipher.keyset(name("acme")).await.expect("select"); + let sealed = tenant + .encrypt("hello".to_string(), "greeting") + .await + .expect("seal"); + + let opened: String = cipher.decrypt(sealed, "greeting").await.expect("open"); + assert_eq!(opened, "hello"); + assert_eq!( + cipher.kms().retrieve_keysets(), + vec![Some(tenant.keyset_id())], + "the retrieve names the leaf's keyset, not the default" + ); +} + +#[tokio::test] +async fn a_keyset_handle_opens_its_own_leaves() { + let cipher = cipher().await; + let tenant = cipher.keyset(name("acme")).await.expect("select"); + let sealed = tenant + .encrypt("hello".to_string(), "greeting") + .await + .expect("seal"); + + let opened: String = tenant.decrypt(sealed, "greeting").await.expect("open"); + assert_eq!(opened, "hello"); +} + +#[tokio::test] +async fn a_keyset_handle_refuses_another_keysets_leaf_before_any_retrieve() { + let cipher = cipher().await; + let acme = cipher.keyset(name("acme")).await.expect("acme"); + let globex = cipher.keyset(name("globex")).await.expect("globex"); + let sealed = acme + .encrypt("hello".to_string(), "greeting") + .await + .expect("seal"); + + let result: Result<String, _> = globex.decrypt(sealed, "greeting").await; + assert!( + matches!( + result, + Err(Error::ForeignKeyset { expected, found }) + if expected == globex.keyset_id() && found == acme.keyset_id() + ), + "{result:?}" + ); + assert!( + cipher.kms().retrieve_keysets().is_empty(), + "refused before any key was retrieved" + ); +} + +#[tokio::test] +async fn the_target_path_through_a_keyset_handle_is_constrained_too() { + let cipher = cipher().await; + let acme = cipher.keyset(name("acme")).await.expect("acme"); + let globex = cipher.keyset(name("globex")).await.expect("globex"); + // A ciphertext tree is not `Clone`; seal three, one per path. + let seal = || async { + let sealed: StackCipherText = 34u32 + .encrypt_into_with_context(&acme, nonempty!("users/age")) + .await + .expect("seal"); + sealed + }; + + let opened: u32 = seal() + .await + .decrypt_into(&acme, nonempty!("users/age")) + .await + .expect("own keyset opens"); + assert_eq!(opened, 34); + + let opened: u32 = seal() + .await + .decrypt_into(&cipher, nonempty!("users/age")) + .await + .expect("the client opens"); + assert_eq!(opened, 34); + + let result: Result<u32, _> = seal() + .await + .decrypt_into(&globex, nonempty!("users/age")) + .await; + assert!( + matches!(result, Err(Error::ForeignKeyset { .. })), + "{result:?}" + ); +} + +#[tokio::test] +async fn a_mixed_keyset_column_opens_through_the_client_in_one_call_per_keyset() { + let cipher = cipher().await; + let acme = cipher.keyset(name("acme")).await.expect("acme"); + let globex = cipher.keyset(name("globex")).await.expect("globex"); + + // A column whose rows belong to two tenants, interleaved. + let mut column: Vec<StackCipherText> = Vec::new(); + for (i, tenant) in [(1u32, &acme), (2, &globex), (3, &acme)] { + let sealed: StackCipherText = i + .encrypt_into_with_context(tenant, nonempty!("users/age")) + .await + .expect("seal"); + column.push(sealed); + } + + let opened: Vec<u32> = column + .decrypt_into(&cipher, nonempty!("users/age")) + .await + .expect("open"); + assert_eq!(opened, vec![1, 2, 3]); + assert_eq!( + cipher.kms().retrieve_keysets(), + vec![Some(acme.keyset_id()), Some(globex.keyset_id())], + "one retrieve per keyset, first seen first" + ); +} + +#[tokio::test] +async fn a_mixed_keyset_column_does_not_open_through_a_keyset_handle() { + let cipher = cipher().await; + let acme = cipher.keyset(name("acme")).await.expect("acme"); + let globex = cipher.keyset(name("globex")).await.expect("globex"); + let mut column: Vec<StackCipherText> = Vec::new(); + for tenant in [&acme, &globex] { + let sealed: StackCipherText = 1u32 + .encrypt_into_with_context(tenant, nonempty!("users/age")) + .await + .expect("seal"); + column.push(sealed); + } + + let result: Result<Vec<u32>, _> = column.decrypt_into(&acme, nonempty!("users/age")).await; + assert!( + matches!(result, Err(Error::ForeignKeyset { .. })), + "{result:?}" + ); + assert!(cipher.kms().retrieve_keysets().is_empty()); +} diff --git a/packages/stack-encrypt/tests/roundtrip.rs b/packages/stack-encrypt/tests/roundtrip.rs index 5ec9ffccd..78697ca27 100644 --- a/packages/stack-encrypt/tests/roundtrip.rs +++ b/packages/stack-encrypt/tests/roundtrip.rs @@ -21,7 +21,8 @@ async fn cipher() -> StackCipher<FakeDataKeySource> { #[tokio::test] async fn scalar_roundtrips_with_no_aad() { let cipher = cipher().await; - let ct = cipher + let keyset = cipher.default_keyset(); + let ct = keyset .encrypt("hello world".to_string(), ()) .await .expect("encrypt"); @@ -32,8 +33,9 @@ async fn scalar_roundtrips_with_no_aad() { #[tokio::test] async fn scalar_roundtrips_with_matching_aad() { let cipher = cipher().await; + let keyset = cipher.default_keyset(); let aad = b"public-context".as_slice(); - let ct = cipher + let ct = keyset .encrypt("secret".to_string(), aad) .await .expect("encrypt"); @@ -44,7 +46,8 @@ async fn scalar_roundtrips_with_matching_aad() { #[tokio::test] async fn decrypt_fails_with_wrong_aad() { let cipher = cipher().await; - let ct = cipher + let keyset = cipher.default_keyset(); + let ct = keyset .encrypt("secret".to_string(), b"aad-a".as_slice()) .await .expect("encrypt"); @@ -55,7 +58,8 @@ async fn decrypt_fails_with_wrong_aad() { #[tokio::test] async fn decrypt_fails_when_aad_omitted() { let cipher = cipher().await; - let ct = cipher + let keyset = cipher.default_keyset(); + let ct = keyset .encrypt("secret".to_string(), b"bound".as_slice()) .await .expect("encrypt"); @@ -68,8 +72,9 @@ async fn decrypt_fails_when_aad_omitted() { #[tokio::test] async fn vec_roundtrips() { let cipher = cipher().await; + let keyset = cipher.default_keyset(); let items = vec!["a".to_string(), "b".to_string(), "c".to_string()]; - let ct = cipher.encrypt(items.clone(), ()).await.expect("encrypt"); + let ct = keyset.encrypt(items.clone(), ()).await.expect("encrypt"); let pt: Vec<String> = cipher.decrypt(ct, ()).await.expect("decrypt"); assert_eq!(pt, items); } @@ -77,12 +82,13 @@ async fn vec_roundtrips() { #[tokio::test] async fn map_roundtrips() { let cipher = cipher().await; + let keyset = cipher.default_keyset(); // Encrypt side keys are `&'static str`; decrypt side yields `String` keys. let mut input: HashMap<&'static str, String> = HashMap::new(); input.insert("name", "alice".to_string()); input.insert("role", "admin".to_string()); - let ct = cipher.encrypt(input, ()).await.expect("encrypt"); + let ct = keyset.encrypt(input, ()).await.expect("encrypt"); let pt: HashMap<String, String> = cipher.decrypt(ct, ()).await.expect("decrypt"); assert_eq!(pt.get("name"), Some(&"alice".to_string())); @@ -93,7 +99,8 @@ async fn map_roundtrips() { #[tokio::test] async fn option_some_roundtrips() { let cipher = cipher().await; - let ct = cipher + let keyset = cipher.default_keyset(); + let ct = keyset .encrypt(Some("present".to_string()), ()) .await .expect("encrypt"); @@ -104,7 +111,8 @@ async fn option_some_roundtrips() { #[tokio::test] async fn option_none_roundtrips() { let cipher = cipher().await; - let ct = cipher + let keyset = cipher.default_keyset(); + let ct = keyset .encrypt(Option::<String>::None, ()) .await .expect("encrypt"); @@ -118,8 +126,9 @@ async fn protected_roundtrip() { // (`Vec<u8>` would encrypt element-wise as a sequence of `u8`, not as bytes, // so a string leaf is used here.) let cipher = cipher().await; + let keyset = cipher.default_keyset(); let secret = Protected::new("classified".to_string()); - let ct = cipher.encrypt(secret, ()).await.expect("encrypt"); + let ct = keyset.encrypt(secret, ()).await.expect("encrypt"); let pt: Protected<String> = cipher.decrypt(ct, ()).await.expect("decrypt"); assert_eq!(pt.risky_unwrap(), "classified".to_string()); } @@ -127,11 +136,12 @@ async fn protected_roundtrip() { #[tokio::test] async fn nested_vec_roundtrips() { let cipher = cipher().await; + let keyset = cipher.default_keyset(); let nested = vec![ vec!["a".to_string(), "b".to_string()], vec!["c".to_string()], ]; - let ct = cipher.encrypt(nested.clone(), ()).await.expect("encrypt"); + let ct = keyset.encrypt(nested.clone(), ()).await.expect("encrypt"); let pt: Vec<Vec<String>> = cipher.decrypt(ct, ()).await.expect("decrypt"); assert_eq!(pt, nested); } @@ -139,7 +149,8 @@ async fn nested_vec_roundtrips() { #[tokio::test] async fn context_tag_binds_and_roundtrips() { let cipher = cipher().await; - let ct = cipher + let keyset = cipher.default_keyset(); + let ct = keyset .encrypt(ContextTag::new("token".to_string(), "user:42"), ()) .await .expect("encrypt"); @@ -155,7 +166,8 @@ async fn context_tag_binds_and_roundtrips() { #[tokio::test] async fn context_tag_wrong_context_fails() { let cipher = cipher().await; - let ct = cipher + let keyset = cipher.default_keyset(); + let ct = keyset .encrypt(ContextTag::new("token".to_string(), "user:42"), ()) .await .expect("encrypt"); @@ -168,7 +180,8 @@ async fn context_tag_wrong_context_fails() { async fn empty_vec_roundtrips() { // An empty sequence seals an authenticated marker, so emptiness is provable. let cipher = cipher().await; - let ct = cipher + let keyset = cipher.default_keyset(); + let ct = keyset .encrypt(Vec::<String>::new(), ()) .await .expect("encrypt"); @@ -179,7 +192,8 @@ async fn empty_vec_roundtrips() { #[tokio::test] async fn empty_map_roundtrips() { let cipher = cipher().await; - let ct = cipher + let keyset = cipher.default_keyset(); + let ct = keyset .encrypt(HashMap::<&'static str, String>::new(), ()) .await .expect("encrypt"); @@ -190,7 +204,8 @@ async fn empty_map_roundtrips() { #[tokio::test] async fn empty_marker_does_not_decode_under_wrong_aad() { let cipher = cipher().await; - let ct = cipher + let keyset = cipher.default_keyset(); + let ct = keyset .encrypt(Vec::<String>::new(), b"bound".as_slice()) .await .expect("encrypt"); @@ -203,10 +218,11 @@ async fn renamed_map_key_fails() { // Map keys travel in the clear but are bound into their value's AAD, so // renaming a key in the stored ciphertext must fail decryption. let cipher = cipher().await; + let keyset = cipher.default_keyset(); let mut input: HashMap<&'static str, String> = HashMap::new(); input.insert("name", "alice".to_string()); - let ct = cipher.encrypt(input, ()).await.expect("encrypt"); + let ct = keyset.encrypt(input, ()).await.expect("encrypt"); let tampered = match ct { CipherText::Map(entries) => CipherText::Map( entries @@ -226,7 +242,8 @@ async fn sequence_element_cannot_be_rehomed_as_scalar() { // Elements are sealed under the `for_sequence_element` derivation, so a // leaf spliced out of a sequence must not verify as a top-level scalar. let cipher = cipher().await; - let ct = cipher + let keyset = cipher.default_keyset(); + let ct = keyset .encrypt(vec!["a".to_string()], ()) .await .expect("encrypt"); @@ -248,7 +265,8 @@ async fn element_roundtrips_under_bare_caller_aad() { // impls. Both sides must honour that derivation: the decipher opens the leaf // under the AAD the Decrypt drive supplies, not a pre-derived one. let cipher = cipher().await; - let ct = cipher + let keyset = cipher.default_keyset(); + let ct = keyset .encrypt(Element("row".to_string()), b"users".as_slice()) .await .expect("encrypt"); @@ -265,9 +283,10 @@ async fn element_interchanges_with_vec_element() { // under the same caller AAD (Element's documented use-case), and a lone // `Element` ciphertext decrypts as a one-element `Vec`. let cipher = cipher().await; + let keyset = cipher.default_keyset(); let aad = b"users".as_slice(); - let ct = cipher + let ct = keyset .encrypt(vec!["a".to_string(), "b".to_string()], aad) .await .expect("encrypt"); @@ -281,7 +300,7 @@ async fn element_interchanges_with_vec_element() { .expect("spliced element must decrypt as Element"); assert_eq!(pt.into_inner(), "b"); - let lone = cipher + let lone = keyset .encrypt(Element("c".to_string()), aad) .await .expect("encrypt"); @@ -296,7 +315,8 @@ async fn element_interchanges_with_vec_element() { #[tokio::test] async fn element_fails_under_wrong_caller_aad() { let cipher = cipher().await; - let ct = cipher + let keyset = cipher.default_keyset(); + let ct = keyset .encrypt(Element("row".to_string()), b"users".as_slice()) .await .expect("encrypt"); @@ -313,7 +333,8 @@ async fn decipher_can_be_driven_directly() { // Decipher is driven via `Decrypt::decrypt_with_aad` with a caller-chosen // AAD, so manual derivations work too. let cipher = cipher().await; - let ct = cipher + let keyset = cipher.default_keyset(); + let ct = keyset .encrypt(Element("row".to_string()), b"users".as_slice()) .await .expect("encrypt"); @@ -329,7 +350,7 @@ async fn decipher_can_be_driven_directly() { assert_eq!(pt, "row"); // And a plain scalar opens under the bare AAD through the same path. - let ct = cipher + let ct = keyset .encrypt("scalar".to_string(), b"ctx".as_slice()) .await .expect("encrypt"); @@ -346,7 +367,8 @@ async fn decipher_can_be_driven_directly() { async fn wrong_shape_fails() { // A scalar ciphertext must not decode as a sequence. let cipher = cipher().await; - let ct = cipher + let keyset = cipher.default_keyset(); + let ct = keyset .encrypt("scalar".to_string(), ()) .await .expect("encrypt"); @@ -356,10 +378,12 @@ async fn wrong_shape_fails() { #[tokio::test] async fn leaf_survives_persistence_via_parts() { - // A leaf can be decomposed into (iv, tag, ciphertext), stored, and rebuilt + // A leaf can be decomposed into (keyset_id, iv, tag, ciphertext), stored, + // and rebuilt // — the in-memory original need not be retained to decrypt. let cipher = cipher().await; - let ct = cipher + let keyset = cipher.default_keyset(); + let ct = keyset .encrypt("durable".to_string(), b"ctx".as_slice()) .await .expect("encrypt"); @@ -367,8 +391,8 @@ async fn leaf_survives_persistence_via_parts() { CipherText::Single(leaf) => leaf, other => panic!("expected a Single leaf, got {other:?}"), }; - let (iv, tag, bytes) = leaf.into_parts(); - let rebuilt = SealedValue::from_parts(iv, tag, bytes).expect("rebuild leaf"); + let (keyset_id, iv, tag, bytes) = leaf.into_parts(); + let rebuilt = SealedValue::from_parts(keyset_id, iv, tag, bytes).expect("rebuild leaf"); let pt: String = cipher .decrypt(CipherText::Single(rebuilt), b"ctx".as_slice()) @@ -380,7 +404,8 @@ async fn leaf_survives_persistence_via_parts() { #[tokio::test] async fn leaf_survives_persistence_via_serde() { let cipher = cipher().await; - let ct = cipher + let keyset = cipher.default_keyset(); + let ct = keyset .encrypt("durable".to_string(), ()) .await .expect("encrypt"); @@ -390,6 +415,7 @@ async fn leaf_survives_persistence_via_serde() { }; let json = serde_json::to_string(&leaf).expect("serialise leaf"); let restored: SealedValue = serde_json::from_str(&json).expect("deserialise leaf"); + assert_eq!(restored.keyset_id(), leaf.keyset_id()); assert_eq!(restored.iv(), leaf.iv()); assert_eq!(restored.tag(), leaf.tag()); assert_eq!(restored.ciphertext(), leaf.ciphertext()); @@ -404,7 +430,8 @@ async fn leaf_survives_persistence_via_serde() { #[tokio::test] async fn tampered_leaf_bytes_fail() { let cipher = cipher().await; - let ct = cipher + let keyset = cipher.default_keyset(); + let ct = keyset .encrypt("durable".to_string(), ()) .await .expect("encrypt"); @@ -412,10 +439,10 @@ async fn tampered_leaf_bytes_fail() { CipherText::Single(leaf) => leaf, other => panic!("expected a Single leaf, got {other:?}"), }; - let (iv, tag, mut bytes) = leaf.into_parts(); + let (keyset_id, iv, tag, mut bytes) = leaf.into_parts(); let last = bytes.len() - 1; bytes[last] ^= 0x01; - let tampered = SealedValue::from_parts(iv, tag, bytes).expect("rebuild leaf"); + let tampered = SealedValue::from_parts(keyset_id, iv, tag, bytes).expect("rebuild leaf"); let result: Result<String, _> = cipher.decrypt(CipherText::Single(tampered), ()).await; assert!(result.is_err(), "a flipped ciphertext bit must not decrypt"); diff --git a/packages/stack-encrypt/tests/sem_terms.rs b/packages/stack-encrypt/tests/sem_terms.rs index a543f598c..96e89056c 100644 --- a/packages/stack-encrypt/tests/sem_terms.rs +++ b/packages/stack-encrypt/tests/sem_terms.rs @@ -5,7 +5,7 @@ use std::cmp::Ordering; use stack_encrypt::nonempty; use stack_encrypt::sem::{DefaultMatch, MatchConfig, MatchOptions, Tokenizer}; -use stack_encrypt::StackCipher; +use stack_encrypt::{Error, StackCipher}; use stack_kms::{FakeDataKeySource, IdentifiedBy}; use uuid::Uuid; @@ -58,7 +58,8 @@ async fn generator_for(keyset: Uuid) -> StackCipher<FakeDataKeySource> { #[tokio::test] async fn equality_terms_are_deterministic() { - let gen = generator().await; + let cipher = generator().await; + let gen = cipher.default_keyset(); let a = gen .equality_term("alice", nonempty!("users/email")) .await @@ -72,7 +73,8 @@ async fn equality_terms_are_deterministic() { #[tokio::test] async fn equality_terms_bind_the_descriptor() { - let gen = generator().await; + let cipher = generator().await; + let gen = cipher.default_keyset(); let a = gen .equality_term("alice", nonempty!("users/email")) .await @@ -86,7 +88,8 @@ async fn equality_terms_bind_the_descriptor() { #[tokio::test] async fn equality_terms_differ_by_value() { - let gen = generator().await; + let cipher = generator().await; + let gen = cipher.default_keyset(); let a = gen .equality_term("alice", nonempty!("users/email")) .await @@ -100,8 +103,10 @@ async fn equality_terms_differ_by_value() { #[tokio::test] async fn equality_terms_bind_the_index_key() { - let gen_a = generator_for(Uuid::from_u128(1)).await; - let gen_b = generator_for(Uuid::from_u128(2)).await; + let cipher_a = generator_for(Uuid::from_u128(1)).await; + let cipher_b = generator_for(Uuid::from_u128(2)).await; + let gen_a = cipher_a.default_keyset(); + let gen_b = cipher_b.default_keyset(); let a = gen_a .equality_term("alice", nonempty!("users/email")) .await @@ -115,7 +120,8 @@ async fn equality_terms_bind_the_index_key() { #[tokio::test] async fn match_query_terms_are_contained_in_stored_terms() { - let gen = generator().await; + let cipher = generator().await; + let gen = cipher.default_keyset(); let stored = gen .match_terms::<DefaultMatch>("alice wonderland", nonempty!("users/bio")) @@ -134,7 +140,8 @@ async fn match_query_terms_are_contained_in_stored_terms() { #[tokio::test] async fn match_is_case_insensitive_by_default() { - let gen = generator().await; + let cipher = generator().await; + let gen = cipher.default_keyset(); let stored = gen .match_terms::<DefaultMatch>("Alice", nonempty!("users/name")) @@ -149,7 +156,8 @@ async fn match_is_case_insensitive_by_default() { #[tokio::test] async fn match_binds_the_descriptor() { - let gen = generator().await; + let cipher = generator().await; + let gen = cipher.default_keyset(); let stored = gen .match_terms::<DefaultMatch>("alice", nonempty!("users/bio")) @@ -174,7 +182,8 @@ async fn match_positions_stay_within_the_filter() { } } - let gen = generator().await; + let cipher = generator().await; + let gen = cipher.default_keyset(); let term = gen .match_terms::<SmallFilter>("a longer piece of text", nonempty!("users/bio")) .await @@ -187,7 +196,8 @@ async fn match_positions_stay_within_the_filter() { #[tokio::test] async fn match_rejects_invalid_options() { - let gen = generator().await; + let cipher = generator().await; + let gen = cipher.default_keyset(); // The v1 match indexer's bounds apply: k in 3..=16, m a power of two in // [32, 65536]. @@ -211,7 +221,9 @@ async fn match_rejects_invalid_options() { // A zero-length n-gram must be an options error, not a panic. assert!(matches!( gen.match_terms::<ZeroNgram>("xxx", nonempty!("d")).await, - Err(stack_encrypt::sem::TermError::InvalidOptions(_)) + Err(Error::Term(stack_encrypt::sem::TermError::InvalidOptions( + _ + ))) )); } @@ -219,7 +231,8 @@ async fn match_rejects_invalid_options() { async fn match_rejects_text_that_yields_no_tokens() { use stack_encrypt::sem::TermError; - let gen = generator().await; + let cipher = generator().await; + let gen = cipher.default_keyset(); // An empty term used as a query would vacuously match every stored row. for text in ["", " "] { @@ -227,7 +240,7 @@ async fn match_rejects_text_that_yields_no_tokens() { matches!( gen.match_terms::<DefaultMatch>(text, nonempty!("users/bio")) .await, - Err(TermError::EmptyTermText) + Err(Error::Term(TermError::EmptyTermText)) ), "{text:?} must be rejected" ); @@ -238,20 +251,21 @@ async fn match_rejects_text_that_yields_no_tokens() { assert!(matches!( gen.match_terms::<DefaultMatch>("hi", nonempty!("users/bio")) .await, - Err(TermError::EmptyTermText) + Err(Error::Term(TermError::EmptyTermText)) )); // Separator-only text under the Standard tokenizer. assert!(matches!( gen.match_terms::<WordMatch>(" ,;:! ", nonempty!("users/bio")) .await, - Err(TermError::EmptyTermText) + Err(Error::Term(TermError::EmptyTermText)) )); } #[tokio::test] async fn word_tokenizer_matches_whole_words() { - let gen = generator().await; + let cipher = generator().await; + let gen = cipher.default_keyset(); let stored = gen .match_terms::<WordMatch>("alice in wonderland", nonempty!("users/bio")) @@ -266,7 +280,8 @@ async fn word_tokenizer_matches_whole_words() { #[tokio::test] async fn ore_terms_preserve_order_and_determinism() { - let gen = generator().await; + let cipher = generator().await; + let gen = cipher.default_keyset(); let ten = gen.ore_term(10u64, nonempty!("users/age")).await.unwrap(); let ten_again = gen.ore_term(10u64, nonempty!("users/age")).await.unwrap(); @@ -278,7 +293,8 @@ async fn ore_terms_preserve_order_and_determinism() { #[tokio::test] async fn ore_terms_bind_the_descriptor() { - let gen = generator().await; + let cipher = generator().await; + let gen = cipher.default_keyset(); let a = gen.ore_term(10u64, nonempty!("users/age")).await.unwrap(); let b = gen .ore_term(10u64, nonempty!("users/height")) @@ -289,7 +305,8 @@ async fn ore_terms_bind_the_descriptor() { #[tokio::test] async fn string_ore_terms_preserve_lexicographic_order() { - let gen = generator().await; + let cipher = generator().await; + let gen = cipher.default_keyset(); let apple = gen .ore_term("apple", nonempty!("users/name")) .await @@ -303,7 +320,8 @@ async fn string_ore_terms_preserve_lexicographic_order() { #[tokio::test] async fn ope_terms_compare_with_plain_byte_order() { - let gen = generator().await; + let cipher = generator().await; + let gen = cipher.default_keyset(); let ten = gen.ope_term(10u64, nonempty!("users/age")).await.unwrap(); let twenty = gen.ope_term(20u64, nonempty!("users/age")).await.unwrap(); @@ -316,7 +334,8 @@ async fn ope_terms_compare_with_plain_byte_order() { async fn ore_and_ope_keys_are_domain_separated() { // The same descriptor must not derive the same key material for both // schemes; equal plaintexts should produce different ciphertext bytes. - let gen = generator().await; + let cipher = generator().await; + let gen = cipher.default_keyset(); let ore = gen.ore_term(42u64, nonempty!("users/age")).await.unwrap(); let ope = gen.ope_term(42u64, nonempty!("users/age")).await.unwrap(); assert_ne!(ore.as_ref(), ope.as_ref()); @@ -324,7 +343,8 @@ async fn ore_and_ope_keys_are_domain_separated() { #[tokio::test] async fn owned_and_borrowed_text_yield_identical_ore_and_ope_terms() { - let gen = generator().await; + let cipher = generator().await; + let gen = cipher.default_keyset(); let borrowed = gen .ore_term("apple", nonempty!("users/name")) .await diff --git a/packages/stack-encrypt/tests/target.rs b/packages/stack-encrypt/tests/target.rs index c437cc0a9..5cad479aa 100644 --- a/packages/stack-encrypt/tests/target.rs +++ b/packages/stack-encrypt/tests/target.rs @@ -10,7 +10,7 @@ use std::sync::atomic::Ordering as AtomicOrdering; use stack_encrypt::sem::{EqualityTerm, MatchConfig, MatchOptions, MatchTerm, OreTerm}; use stack_encrypt::target::{DecryptInto, EncryptFrom, EncryptInto, Pending, Request}; use stack_encrypt::{ - nonempty, Descriptor, EmptyError, Error, NonEmpty, StackCipher, StackCipherText, + nonempty, Descriptor, EmptyError, Error, KeysetCipher, NonEmpty, StackCipher, StackCipherText, }; use stack_kms::{FakeDataKeySource, IdentifiedBy, IndexKeySource}; use uuid::Uuid; @@ -32,6 +32,7 @@ async fn generator() -> StackCipher<FakeDataKeySource> { #[tokio::test] async fn equality_leaf_agrees_with_the_descriptor_api() { let generator = generator().await; + let generator = generator.default_keyset(); let via_target: EqualityTerm = "alice" .encrypt_into_with_context(&generator, nonempty!("users/email")) @@ -54,7 +55,9 @@ async fn terms_agree_between_independently_built_ciphers() { // keyset, must produce the same terms // as write-side code holding the full StackCipher (same index key). let cipher = stack_cipher().await; + let cipher = cipher.default_keyset(); let generator = generator().await; + let generator = generator.default_keyset(); let a: EqualityTerm = "alice" .encrypt_into_with_context(&cipher, nonempty!("users/email")) @@ -80,6 +83,7 @@ async fn terms_agree_between_independently_built_ciphers() { #[tokio::test] async fn equality_leaf_binds_the_context() { let generator = generator().await; + let generator = generator.default_keyset(); let email: EqualityTerm = "alice" .encrypt_into_with_context(&generator, nonempty!("users/email")) @@ -98,6 +102,7 @@ async fn term_derivation_makes_no_kms_calls() { // Terms derive under the index key the cipher already holds: building a // query probe must never touch ZeroKMS. let (cipher, generates, retrieves) = counting_cipher().await; + let cipher = cipher.default_keyset(); let _term: EqualityTerm = "alice" .encrypt_into_with_context(&cipher, nonempty!("users/email")) @@ -115,10 +120,11 @@ async fn term_derivation_makes_no_kms_calls() { #[tokio::test] async fn ciphertext_leaf_round_trips_via_decrypt_into() { let cipher = stack_cipher().await; + let keyset = cipher.default_keyset(); let ciphertext: StackCipherText = "secret" .to_string() - .encrypt_into_with_context(&cipher, nonempty!("users/email")) + .encrypt_into_with_context(&keyset, nonempty!("users/email")) .await .unwrap(); let plaintext: String = ciphertext @@ -132,10 +138,11 @@ async fn ciphertext_leaf_round_trips_via_decrypt_into() { #[tokio::test] async fn ciphertext_leaf_cannot_be_transplanted_to_another_context() { let cipher = stack_cipher().await; + let keyset = cipher.default_keyset(); let ciphertext: StackCipherText = "secret" .to_string() - .encrypt_into_with_context(&cipher, nonempty!("users/email")) + .encrypt_into_with_context(&keyset, nonempty!("users/email")) .await .unwrap(); @@ -151,6 +158,7 @@ async fn ciphertext_leaf_cannot_be_transplanted_to_another_context() { #[tokio::test] async fn match_leaf_supports_containment_queries() { let generator = generator().await; + let generator = generator.default_keyset(); let stored: MatchTerm = "alice wonderland" .to_string() @@ -181,6 +189,7 @@ impl MatchConfig for SmallFilter { #[tokio::test] async fn match_leaf_config_is_type_level() { let generator = generator().await; + let generator = generator.default_keyset(); let term: MatchTerm<SmallFilter> = "a longer piece of text" .to_string() @@ -202,6 +211,7 @@ async fn match_leaf_config_is_type_level() { #[tokio::test] async fn ore_leaf_preserves_order_and_binds_the_context() { let generator = generator().await; + let generator = generator.default_keyset(); let ten: OreTerm<u64> = 10u64 .encrypt_into_with_context(&generator, nonempty!("users/age")) @@ -230,6 +240,7 @@ async fn ope_leaf_compares_with_plain_byte_order() { use stack_encrypt::sem::OpeTerm; let generator = generator().await; + let generator = generator.default_keyset(); let ten: OpeTerm<u64> = 10u64 .encrypt_into_with_context(&generator, nonempty!("users/age")) @@ -249,6 +260,7 @@ async fn ope_leaf_compares_with_plain_byte_order() { #[tokio::test] async fn a_column_encrypts_in_one_batched_call() { let (cipher, generates, _) = counting_cipher().await; + let cipher = cipher.default_keyset(); let ages: Vec<u32> = vec![29, 34, 41, 34, 57]; let sealed: Vec<StackCipherText> = ages @@ -267,10 +279,11 @@ async fn a_column_encrypts_in_one_batched_call() { #[tokio::test] async fn a_column_decrypts_in_one_batched_call() { let (cipher, _, retrieves) = counting_cipher().await; + let keyset = cipher.default_keyset(); let ages: Vec<u32> = vec![29, 34, 41]; let sealed: Vec<StackCipherText> = ages - .encrypt_into_with_context(&cipher, nonempty!("users/age")) + .encrypt_into_with_context(&keyset, nonempty!("users/age")) .await .unwrap(); @@ -290,13 +303,14 @@ async fn a_column_decrypts_in_one_batched_call() { #[tokio::test] async fn optional_fields_encrypt_and_decrypt_structurally() { let cipher = stack_cipher().await; + let keyset = cipher.default_keyset(); let present: Option<StackCipherText> = Some("here".to_string()) - .encrypt_into_with_context(&cipher, nonempty!("users/nickname")) + .encrypt_into_with_context(&keyset, nonempty!("users/nickname")) .await .unwrap(); let absent: Option<StackCipherText> = Option::<String>::None - .encrypt_into_with_context(&cipher, nonempty!("users/nickname")) + .encrypt_into_with_context(&keyset, nonempty!("users/nickname")) .await .unwrap(); @@ -328,16 +342,16 @@ struct EncryptedAge { // they need — inherited through per-field bounds, the same clauses the // derive emits, rather than restated as a leaf-policy bound of the record's // own (which would need editing every time the leaves' policy tightens). -impl<K, Ctx> EncryptFrom<u32, StackCipher<K>, Ctx> for EncryptedAge +impl<'k, K, Ctx> EncryptFrom<u32, KeysetCipher<'k, K>, Ctx> for EncryptedAge where Ctx: Clone, - StackCipherText: EncryptFrom<u32, StackCipher<K>, Ctx>, - EqualityTerm: EncryptFrom<u32, StackCipher<K>, Ctx>, - OreTerm<u32>: EncryptFrom<u32, StackCipher<K>, Ctx>, + StackCipherText: EncryptFrom<u32, KeysetCipher<'k, K>, Ctx>, + EqualityTerm: EncryptFrom<u32, KeysetCipher<'k, K>, Ctx>, + OreTerm<u32>: EncryptFrom<u32, KeysetCipher<'k, K>, Ctx>, { fn encrypt_from<'a>( source: &'a u32, - cipher: &'a StackCipher<K>, + cipher: &'a KeysetCipher<'k, K>, context: Ctx, ) -> Pending<'a, Self, K> where @@ -369,10 +383,12 @@ where #[tokio::test] async fn composite_record_encrypts_every_field_from_one_source() { let cipher = stack_cipher().await; + let keyset = cipher.default_keyset(); let generator = generator().await; + let generator = generator.default_keyset(); let record: EncryptedAge = 42u32 - .encrypt_into_with_context(&cipher, nonempty!("users/age")) + .encrypt_into_with_context(&keyset, nonempty!("users/age")) .await .unwrap(); @@ -386,7 +402,7 @@ async fn composite_record_encrypts_every_field_from_one_source() { // Each term matches what the primitive would derive on its own, so query // terms generated leaf-by-leaf find records encrypted as composites. let record: EncryptedAge = 42u32 - .encrypt_into_with_context(&cipher, nonempty!("users/age")) + .encrypt_into_with_context(&keyset, nonempty!("users/age")) .await .unwrap(); let hm: EqualityTerm = 42u32 @@ -405,6 +421,7 @@ async fn composite_record_encrypts_every_field_from_one_source() { #[tokio::test] async fn a_composite_record_is_one_batched_call() { let (cipher, generates, _) = counting_cipher().await; + let cipher = cipher.default_keyset(); let _record: EncryptedAge = 42u32 .encrypt_into_with_context(&cipher, nonempty!("users/age")) @@ -432,6 +449,7 @@ async fn a_composite_record_is_one_batched_call() { #[tokio::test] async fn composite_record_terms_preserve_order() { let cipher = stack_cipher().await; + let cipher = cipher.default_keyset(); let ten: EncryptedAge = 10u32 .encrypt_into_with_context(&cipher, nonempty!("users/age")) @@ -456,14 +474,15 @@ async fn composite_record_terms_preserve_order() { #[derive(Debug, PartialEq, Eq)] struct PrefixTerm<const N: usize>([u8; 32]); -impl<'c, S, K, T, const N: usize> EncryptFrom<S, StackCipher<K>, NonEmpty<T>> for PrefixTerm<N> +impl<'c, 'k, S, K, T, const N: usize> EncryptFrom<S, KeysetCipher<'k, K>, NonEmpty<T>> + for PrefixTerm<N> where S: AsRef<str>, T: IntoPrfContext<'c>, { fn encrypt_from<'a>( source: &'a S, - cipher: &'a StackCipher<K>, + cipher: &'a KeysetCipher<'k, K>, context: NonEmpty<T>, ) -> Pending<'a, Self, K> where @@ -491,10 +510,12 @@ where #[tokio::test] async fn third_party_term_type_works_on_the_public_surface() { let cipher = stack_cipher().await; + let keyset = cipher.default_keyset(); let generator = generator().await; + let generator = generator.default_keyset(); let stored: PrefixTerm<3> = "alice" - .encrypt_into_with_context(&cipher, nonempty!("users/name")) + .encrypt_into_with_context(&keyset, nonempty!("users/name")) .await .unwrap(); let probe: PrefixTerm<3> = "alicia" @@ -515,15 +536,15 @@ async fn third_party_term_type_works_on_the_public_surface() { prefix: PrefixTerm<3>, } - impl<K, Ctx> EncryptFrom<String, StackCipher<K>, Ctx> for NameRecord + impl<'k, K, Ctx> EncryptFrom<String, KeysetCipher<'k, K>, Ctx> for NameRecord where Ctx: Clone, - StackCipherText: EncryptFrom<String, StackCipher<K>, Ctx>, - PrefixTerm<3>: EncryptFrom<String, StackCipher<K>, Ctx>, + StackCipherText: EncryptFrom<String, KeysetCipher<'k, K>, Ctx>, + PrefixTerm<3>: EncryptFrom<String, KeysetCipher<'k, K>, Ctx>, { fn encrypt_from<'a>( source: &'a String, - cipher: &'a StackCipher<K>, + cipher: &'a KeysetCipher<'k, K>, context: Ctx, ) -> Pending<'a, Self, K> where @@ -537,7 +558,7 @@ async fn third_party_term_type_works_on_the_public_surface() { let record: NameRecord = "alice" .to_string() - .encrypt_into_with_context(&cipher, nonempty!("users/name")) + .encrypt_into_with_context(&keyset, nonempty!("users/name")) .await .unwrap(); assert_eq!(record.prefix, stored); @@ -567,7 +588,7 @@ async fn init_pins_the_cipher_to_the_keyset_it_resolved() { .load_index_key(None) .await .expect("load index key"); - assert_eq!(cipher.keyset_id(), expected); + assert_eq!(cipher.default_keyset().keyset_id(), expected); } #[tokio::test] @@ -580,12 +601,14 @@ async fn an_explicit_keyset_is_honoured() { .await .expect("build cipher"); - assert_eq!(cipher.keyset_id(), keyset); + let explicit = cipher.default_keyset(); + assert_eq!(explicit.keyset_id(), keyset); // And its terms differ from the default keyset's: a different keyset means // a different index key. let default = stack_cipher().await; - let a = cipher + let default = default.default_keyset(); + let a = explicit .equality_term("alice", nonempty!("users/email")) .await .unwrap(); @@ -622,10 +645,11 @@ async fn wrapped_and_extended_contexts_bind_like_plain_ones() { // tuples, and `NonEmpty::with` extends a proven head with any tail: all // of them are contexts a leaf takes, and all of them bind. let cipher = stack_cipher().await; + let keyset = cipher.default_keyset(); let sealed: StackCipherText = "secret" .to_string() - .encrypt_into_with_context(&cipher, NonEmpty::new(Some("users/email")).unwrap()) + .encrypt_into_with_context(&keyset, NonEmpty::new(Some("users/email")).unwrap()) .await .unwrap(); let opened: String = sealed @@ -639,7 +663,7 @@ async fn wrapped_and_extended_contexts_bind_like_plain_ones() { // key retrieval under the other descriptor first, as `Error::Kms`.) let sealed: StackCipherText = "secret" .to_string() - .encrypt_into_with_context(&cipher, nonempty!("users/email").with(42u64)) + .encrypt_into_with_context(&keyset, nonempty!("users/email").with(42u64)) .await .unwrap(); let wrong_row: Result<String, _> = sealed @@ -648,7 +672,7 @@ async fn wrapped_and_extended_contexts_bind_like_plain_ones() { assert!(matches!(wrong_row, Err(Error::Aead))); let sealed: StackCipherText = "secret" .to_string() - .encrypt_into_with_context(&cipher, nonempty!("users/email").with(42u64)) + .encrypt_into_with_context(&keyset, nonempty!("users/email").with(42u64)) .await .unwrap(); let no_row: Result<String, _> = sealed.decrypt_into(&cipher, nonempty!("users/email")).await; @@ -658,16 +682,16 @@ async fn wrapped_and_extended_contexts_bind_like_plain_ones() { // context equals one under the plain pair, so a query site need not // hold a `NonEmpty` head to probe. let extended: EqualityTerm = "alice" - .encrypt_into_with_context(&cipher, nonempty!("users/email").with(42u64)) + .encrypt_into_with_context(&keyset, nonempty!("users/email").with(42u64)) .await .unwrap(); let plain: EqualityTerm = "alice" - .encrypt_into_with_context(&cipher, NonEmpty::new(("users/email", 42u64)).unwrap()) + .encrypt_into_with_context(&keyset, NonEmpty::new(("users/email", 42u64)).unwrap()) .await .unwrap(); assert_eq!(extended, plain); let unextended: EqualityTerm = "alice" - .encrypt_into_with_context(&cipher, nonempty!("users/email")) + .encrypt_into_with_context(&keyset, nonempty!("users/email")) .await .unwrap(); assert_ne!(extended, unextended); @@ -678,10 +702,11 @@ async fn a_bare_integer_is_a_context() { // An integer is never empty, so it converts into a `NonEmpty` on its own // and the sugar takes it bare. let cipher = stack_cipher().await; + let keyset = cipher.default_keyset(); let sealed: StackCipherText = "secret" .to_string() - .encrypt_into_with_context(&cipher, 42u64) + .encrypt_into_with_context(&keyset, 42u64) .await .unwrap(); let opened: String = sealed @@ -690,11 +715,11 @@ async fn a_bare_integer_is_a_context() { .unwrap(); assert_eq!(opened, "secret"); let term: EqualityTerm = "alice" - .encrypt_into_with_context(&cipher, 7u32) + .encrypt_into_with_context(&keyset, 7u32) .await .unwrap(); let other: EqualityTerm = "alice" - .encrypt_into_with_context(&cipher, 8u32) + .encrypt_into_with_context(&keyset, 8u32) .await .unwrap(); assert_ne!(term, other); @@ -708,14 +733,15 @@ async fn containers_pass_the_context_through_to_their_leaves() { // derived rows — records whose fields carry their own contexts — for // `()` as well. An empty container derives nothing either way. let cipher = stack_cipher().await; + let keyset = cipher.default_keyset(); let none: Option<StackCipherText> = None::<String> - .encrypt_into_with_context(&cipher, nonempty!("users/x")) + .encrypt_into_with_context(&keyset, nonempty!("users/x")) .await .unwrap(); assert!(none.is_none()); let empty: Vec<StackCipherText> = Vec::<String>::new() - .encrypt_into_with_context(&cipher, nonempty!("users/x")) + .encrypt_into_with_context(&keyset, nonempty!("users/x")) .await .unwrap(); assert!(empty.is_empty()); @@ -732,6 +758,7 @@ async fn a_failed_field_fails_the_record_before_any_kms_call() { // data keys that are then thrown away. A match term over text that // yields no tokens fails during the synchronous build, before any I/O. let (cipher, generates, _) = counting_cipher().await; + let cipher = cipher.default_keyset(); let b = "b".to_string(); let zipped = MatchTerm::<SmallFilter>::encrypt_from(&"", &cipher, nonempty!("users/x")) @@ -763,7 +790,9 @@ async fn a_failed_field_fails_the_record_before_any_kms_call() { #[tokio::test] async fn pendings_from_different_ciphers_refuse_to_merge() { let (cipher_a, generates, _) = counting_cipher().await; + let cipher_a = cipher_a.default_keyset(); let cipher_b = counting_cipher().await.0; + let cipher_b = cipher_b.default_keyset(); let v = "v".to_string(); let w = "w".to_string(); @@ -794,6 +823,7 @@ async fn an_overdrawing_fulfilment_is_a_response_shape_error() { // A fulfilment is scoped to exactly the responses its requests asked for: // drawing more must fail loudly, never consume a sibling's responses. let cipher = stack_cipher().await; + let cipher = cipher.default_keyset(); let pending: Pending<'_, (), _> = Pending::request( &cipher, @@ -812,6 +842,7 @@ async fn an_overdrawing_fulfilment_is_a_response_shape_error() { #[tokio::test] async fn terms_rehydrate_from_persisted_parts() { let generator = generator().await; + let generator = generator.default_keyset(); let eq: EqualityTerm = "alice" .encrypt_into_with_context(&generator, nonempty!("users/email")) @@ -846,6 +877,7 @@ async fn pending_futures_are_send() { let generator = generator().await; let handle = tokio::spawn(async move { + let generator = generator.default_keyset(); let term: EqualityTerm = "alice" .encrypt_into_with_context(&generator, nonempty!("users/email")) .await @@ -861,12 +893,13 @@ async fn pending_futures_are_send() { #[tokio::test] async fn cipher_directed_encrypt_and_decrypt_are_one_batched_call_each() { let (cipher, generates, retrieves) = counting_cipher().await; + let keyset = cipher.default_keyset(); let names: Vec<String> = ["ada", "grace", "edsger", "barbara"] .into_iter() .map(String::from) .collect(); - let ct = cipher.encrypt(names.clone(), "users/name").await.unwrap(); + let ct = keyset.encrypt(names.clone(), "users/name").await.unwrap(); assert_eq!( generates.load(AtomicOrdering::SeqCst), 1, @@ -885,10 +918,11 @@ async fn cipher_directed_encrypt_and_decrypt_are_one_batched_call_each() { #[tokio::test] async fn cipher_directed_and_target_directed_ciphertexts_are_interchangeable() { let cipher = stack_cipher().await; + let keyset = cipher.default_keyset(); let value = vec!["one".to_string(), "two".to_string(), "three".to_string()]; // Sealed by the cipher-directed API, opened by the target-directed one. - let ct = cipher.encrypt(value.clone(), "users/tags").await.unwrap(); + let ct = keyset.encrypt(value.clone(), "users/tags").await.unwrap(); let via_target: Vec<String> = ct .decrypt_into(&cipher, nonempty!("users/tags")) .await @@ -897,7 +931,7 @@ async fn cipher_directed_and_target_directed_ciphertexts_are_interchangeable() { // Sealed by the target-directed API, opened by the cipher-directed one. let ct: StackCipherText = value - .encrypt_into_with_context(&cipher, nonempty!("users/tags")) + .encrypt_into_with_context(&keyset, nonempty!("users/tags")) .await .unwrap(); let via_cipher: Vec<String> = cipher.decrypt(ct, "users/tags").await.unwrap(); @@ -907,9 +941,10 @@ async fn cipher_directed_and_target_directed_ciphertexts_are_interchangeable() { #[tokio::test] async fn cipher_directed_decrypt_rejects_a_transplanted_ciphertext() { let cipher = stack_cipher().await; + let keyset = cipher.default_keyset(); let ct: StackCipherText = "secret" .to_string() - .encrypt_into_with_context(&cipher, nonempty!("users/email")) + .encrypt_into_with_context(&keyset, nonempty!("users/email")) .await .unwrap(); let result: Result<String, Error> = cipher.decrypt(ct, "users/name").await; diff --git a/packages/stack-encrypt/tests/term_bytes.rs b/packages/stack-encrypt/tests/term_bytes.rs index 084a6a5b1..6c33e98f7 100644 --- a/packages/stack-encrypt/tests/term_bytes.rs +++ b/packages/stack-encrypt/tests/term_bytes.rs @@ -29,8 +29,9 @@ fn hex(bytes: &[u8]) -> String { #[tokio::test] async fn equality_term_bytes_are_pinned() { - let term = cipher() - .await + let cipher = cipher().await; + let term = cipher + .default_keyset() .equality_term("alice", nonempty!("users/email")) .await .unwrap(); @@ -43,8 +44,9 @@ async fn equality_term_bytes_are_pinned() { #[tokio::test] async fn match_term_positions_are_pinned() { - let term = cipher() - .await + let cipher = cipher().await; + let term = cipher + .default_keyset() .match_terms::<DefaultMatch>("alice smith", nonempty!("users/name")) .await .unwrap(); @@ -64,8 +66,9 @@ async fn match_term_positions_are_pinned() { async fn ore_term_bytes_are_pinned() { // The ORE key is a PRF of the descriptor, so this pins the key derivation // as much as the CLLW encryption. - let term = cipher() - .await + let cipher = cipher().await; + let term = cipher + .default_keyset() .ore_term(42u32, nonempty!("users/age")) .await .unwrap(); @@ -81,8 +84,9 @@ async fn ope_term_bytes_are_pinned() { // Distinct from the ORE pin above under the same descriptor: the two // schemes derive their keys under different domains and must never share // one (OPE ciphertexts are encrypt-only). - let term = cipher() - .await + let cipher = cipher().await; + let term = cipher + .default_keyset() .ope_term(42u32, nonempty!("users/age")) .await .unwrap(); diff --git a/packages/stack-encrypt/tests/ui/bare_context.rs b/packages/stack-encrypt/tests/ui/bare_context.rs index 1ddd8a36b..e93bdd9b2 100644 --- a/packages/stack-encrypt/tests/ui/bare_context.rs +++ b/packages/stack-encrypt/tests/ui/bare_context.rs @@ -7,7 +7,7 @@ //! skipped by passing the raw value. use stack_encrypt::sem::EqualityTerm; use stack_encrypt::target::{DecryptFrom, EncryptInto}; -use stack_encrypt::{DecryptInto, EncryptFrom, StackCipher, StackCipherText}; +use stack_encrypt::{DecryptInto, EncryptFrom, KeysetCipher, StackCipher, StackCipherText}; use stack_kms::FakeDataKeySource; struct User { @@ -27,7 +27,7 @@ struct EncryptedAge { hm: EqualityTerm, } -async fn encrypt(cipher: &StackCipher<FakeDataKeySource>, user: User) { +async fn encrypt(cipher: &KeysetCipher<'_, FakeDataKeySource>, user: User) { let _term: EqualityTerm = "alice" .encrypt_into_with_context(cipher, "users/email") .await diff --git a/packages/stack-encrypt/tests/ui/leaf_without_context.rs b/packages/stack-encrypt/tests/ui/leaf_without_context.rs index 791772a51..4e867f61d 100644 --- a/packages/stack-encrypt/tests/ui/leaf_without_context.rs +++ b/packages/stack-encrypt/tests/ui/leaf_without_context.rs @@ -3,7 +3,7 @@ //! / `decrypt_from` pass `()`, which no leaf accepts. use stack_encrypt::sem::EqualityTerm; use stack_encrypt::target::{DecryptFrom, EncryptInto}; -use stack_encrypt::{DecryptInto, EncryptFrom, StackCipher, StackCipherText}; +use stack_encrypt::{DecryptInto, EncryptFrom, KeysetCipher, StackCipher, StackCipherText}; use stack_kms::FakeDataKeySource; #[derive(EncryptFrom, DecryptInto)] @@ -13,7 +13,7 @@ struct EncryptedAge { hm: EqualityTerm, } -async fn encrypt(cipher: &StackCipher<FakeDataKeySource>) { +async fn encrypt(cipher: &KeysetCipher<'_, FakeDataKeySource>) { let _term: EqualityTerm = "alice".encrypt_into(cipher).await.unwrap(); let _record: EncryptedAge = 42u32.encrypt_into(cipher).await.unwrap(); let _column: Vec<StackCipherText> = vec![1u32].encrypt_into(cipher).await.unwrap(); diff --git a/packages/stack-encrypt/tests/ui/leaf_without_context.stderr b/packages/stack-encrypt/tests/ui/leaf_without_context.stderr index e39ceb4b9..e6b550d37 100644 --- a/packages/stack-encrypt/tests/ui/leaf_without_context.stderr +++ b/packages/stack-encrypt/tests/ui/leaf_without_context.stderr @@ -6,8 +6,8 @@ error[E0277]: `EqualityTerm` is not an encrypted form of `&str` under a `()` con | = note: a leaf — a ciphertext or an index term — exists only under a `NonEmpty<_>` context: `encrypt_into` passes `()`, so use `encrypt_into_with_context(&cipher, context)`, or give the field a `context = ".."` of its own = note: a context is anything vitaminc encodes (`&str`, `String`, bytes, integers, `Option`s and pairs of those), proven non-empty: `nonempty!("users/email")` for a literal, `NonEmpty::new(value)?` for a runtime value, a bare integer for an id - = help: the trait `EncryptFrom<&str, StackCipher<FakeDataKeySource>, ()>` is not implemented for `EqualityTerm` - but trait `EncryptFrom<&str, StackCipher<FakeDataKeySource>, NonEmpty<_>>` is implemented for it + = help: the trait `EncryptFrom<&str, KeysetCipher<'_, FakeDataKeySource>, ()>` is not implemented for `EqualityTerm` + but trait `EncryptFrom<&str, KeysetCipher<'_, FakeDataKeySource>, NonEmpty<_>>` is implemented for it = help: for that trait implementation, expected `NonEmpty<_>`, found `()` note: required by a bound in `encrypt_into` --> src/target/mod.rs @@ -26,10 +26,10 @@ error[E0277]: `CipherText<SealedValue, Box<(dyn Any + Send + 'static)>>` is not | = note: a leaf — a ciphertext or an index term — exists only under a `NonEmpty<_>` context: `encrypt_into` passes `()`, so use `encrypt_into_with_context(&cipher, context)`, or give the field a `context = ".."` of its own = note: a context is anything vitaminc encodes (`&str`, `String`, bytes, integers, `Option`s and pairs of those), proven non-empty: `nonempty!("users/email")` for a literal, `NonEmpty::new(value)?` for a runtime value, a bare integer for an id - = help: the trait `EncryptFrom<u32, StackCipher<FakeDataKeySource>, ()>` is not implemented for `CipherText<SealedValue, Box<(dyn Any + Send + 'static)>>` - but trait `EncryptFrom<u32, StackCipher<FakeDataKeySource>, NonEmpty<_>>` is implemented for it + = help: the trait `EncryptFrom<u32, KeysetCipher<'_, FakeDataKeySource>, ()>` is not implemented for `CipherText<SealedValue, Box<(dyn Any + Send + 'static)>>` + but trait `EncryptFrom<u32, KeysetCipher<'_, FakeDataKeySource>, NonEmpty<_>>` is implemented for it = help: for that trait implementation, expected `NonEmpty<_>`, found `()` -note: required for `EncryptedAge` to implement `EncryptFrom<u32, StackCipher<FakeDataKeySource>, ()>` +note: required for `EncryptedAge` to implement `EncryptFrom<u32, KeysetCipher<'_, FakeDataKeySource>, ()>` --> tests/ui/leaf_without_context.rs:9:10 | 9 | #[derive(EncryptFrom, DecryptInto)] @@ -55,10 +55,10 @@ error[E0277]: `EqualityTerm` is not an encrypted form of `u32` under a `()` cont | = note: a leaf — a ciphertext or an index term — exists only under a `NonEmpty<_>` context: `encrypt_into` passes `()`, so use `encrypt_into_with_context(&cipher, context)`, or give the field a `context = ".."` of its own = note: a context is anything vitaminc encodes (`&str`, `String`, bytes, integers, `Option`s and pairs of those), proven non-empty: `nonempty!("users/email")` for a literal, `NonEmpty::new(value)?` for a runtime value, a bare integer for an id - = help: the trait `EncryptFrom<u32, StackCipher<FakeDataKeySource>, ()>` is not implemented for `EqualityTerm` - but trait `EncryptFrom<u32, StackCipher<FakeDataKeySource>, NonEmpty<_>>` is implemented for it + = help: the trait `EncryptFrom<u32, KeysetCipher<'_, FakeDataKeySource>, ()>` is not implemented for `EqualityTerm` + but trait `EncryptFrom<u32, KeysetCipher<'_, FakeDataKeySource>, NonEmpty<_>>` is implemented for it = help: for that trait implementation, expected `NonEmpty<_>`, found `()` -note: required for `EncryptedAge` to implement `EncryptFrom<u32, StackCipher<FakeDataKeySource>, ()>` +note: required for `EncryptedAge` to implement `EncryptFrom<u32, KeysetCipher<'_, FakeDataKeySource>, ()>` --> tests/ui/leaf_without_context.rs:9:10 | 9 | #[derive(EncryptFrom, DecryptInto)] @@ -86,10 +86,10 @@ error[E0277]: `CipherText<SealedValue, Box<dyn Any + Send>>` is not an encrypted | = note: a leaf — a ciphertext or an index term — exists only under a `NonEmpty<_>` context: `encrypt_into` passes `()`, so use `encrypt_into_with_context(&cipher, context)`, or give the field a `context = ".."` of its own = note: a context is anything vitaminc encodes (`&str`, `String`, bytes, integers, `Option`s and pairs of those), proven non-empty: `nonempty!("users/email")` for a literal, `NonEmpty::new(value)?` for a runtime value, a bare integer for an id - = help: the trait `EncryptFrom<u32, StackCipher<FakeDataKeySource>, ()>` is not implemented for `CipherText<SealedValue, Box<dyn Any + Send>>` - but trait `EncryptFrom<u32, StackCipher<FakeDataKeySource>, NonEmpty<_>>` is implemented for `CipherText<SealedValue, Box<(dyn Any + Send + 'static)>>` + = help: the trait `EncryptFrom<u32, KeysetCipher<'_, FakeDataKeySource>, ()>` is not implemented for `CipherText<SealedValue, Box<dyn Any + Send>>` + but trait `EncryptFrom<u32, KeysetCipher<'_, FakeDataKeySource>, NonEmpty<_>>` is implemented for `CipherText<SealedValue, Box<(dyn Any + Send + 'static)>>` = help: for that trait implementation, expected `NonEmpty<_>`, found `()` - = note: required for `Vec<CipherText<SealedValue, Box<dyn Any + Send>>>` to implement `EncryptFrom<Vec<u32>, StackCipher<FakeDataKeySource>, ()>` + = note: required for `Vec<CipherText<SealedValue, Box<dyn Any + Send>>>` to implement `EncryptFrom<Vec<u32>, KeysetCipher<'_, FakeDataKeySource>, ()>` note: required by a bound in `encrypt_into` --> src/target/mod.rs | @@ -99,7 +99,7 @@ note: required by a bound in `encrypt_into` | T: EncryptFrom<Self, C, ()> + 'a, | ^^^^^^^^^^^^^^^^^^^^^^^^ required by this bound in `EncryptInto::encrypt_into` -error[E0277]: `CipherText<SealedValue, Box<(dyn Any + Send + 'static)>>` does not decrypt to `u32` under a `()` context +error[E0277]: `EncryptedAge` does not decrypt to `u32` under a `()` context --> tests/ui/leaf_without_context.rs:23:34 | 23 | let _age = u32::decrypt_from(record, cipher).await.unwrap(); @@ -107,19 +107,11 @@ error[E0277]: `CipherText<SealedValue, Box<(dyn Any + Send + 'static)>>` does no | | | required by a bound introduced by this call | + = help: the trait `DecryptInto<u32, _, ()>` is not implemented for `EncryptedAge` = note: a leaf decrypts only under a `NonEmpty<_>` context — the one it was encrypted under (`decrypt_into(&cipher, context)` / `decrypt_from_with_context`); an output whose fields carry their own opens under `()` (`decrypt_from`) as well - = help: the trait `DecryptInto<u32, StackCipher<_>, ()>` is not implemented for `CipherText<SealedValue, Box<(dyn Any + Send + 'static)>>` - but trait `DecryptInto<u32, StackCipher<_>, NonEmpty<_>>` is implemented for it - = help: for that trait implementation, expected `NonEmpty<_>`, found `()` - = note: required for `CipherText<SealedValue, Box<(dyn Any + Send + 'static)>>` to implement `DecryptField<u32, StackCipher<_>, ()>` -note: required for `EncryptedAge` to implement `DecryptInto<u32, StackCipher<_>, ()>` - --> tests/ui/leaf_without_context.rs:9:23 - | - 9 | #[derive(EncryptFrom, DecryptInto)] - | ^^^^^^^^^^^ unsatisfied trait bound introduced in this `derive` macro -10 | #[stash(plaintext = u32)] -11 | struct EncryptedAge { - | ^^^^^^^^^^^^ + = help: the following other types implement trait `DecryptInto<P, C, Ctx>`: + `EncryptedAge` implements `DecryptInto<u32, StackCipher<__K>, ()>` + `EncryptedAge` implements `DecryptInto<u32, StackCipher<__K>, NonEmpty<__T>>` note: required by a bound in `decrypt_from` --> src/target/mod.rs | @@ -128,7 +120,6 @@ note: required by a bound in `decrypt_from` ... | S: DecryptInto<Self, C, ()> + 'a, | ^^^^^^^^^^^^^^^^^^^^^^^^ required by this bound in `DecryptFrom::decrypt_from` - = note: this error originates in the derive macro `DecryptInto` (in Nightly builds, run with -Z macro-backtrace for more info) error[E0308]: mismatched types --> tests/ui/leaf_without_context.rs:27:49 diff --git a/packages/stack-encrypt/tests/ui/nested_leaf_without_context.stderr b/packages/stack-encrypt/tests/ui/nested_leaf_without_context.stderr index d7b653fb4..6d8b2aa0c 100644 --- a/packages/stack-encrypt/tests/ui/nested_leaf_without_context.stderr +++ b/packages/stack-encrypt/tests/ui/nested_leaf_without_context.stderr @@ -6,8 +6,8 @@ error[E0277]: `CipherText<SealedValue, Box<(dyn Any + Send + 'static)>>` is not | = note: a leaf — a ciphertext or an index term — exists only under a `NonEmpty<_>` context: `encrypt_into` passes `()`, so use `encrypt_into_with_context(&cipher, context)`, or give the field a `context = ".."` of its own = note: a context is anything vitaminc encodes (`&str`, `String`, bytes, integers, `Option`s and pairs of those), proven non-empty: `nonempty!("users/email")` for a literal, `NonEmpty::new(value)?` for a runtime value, a bare integer for an id - = help: the trait `EncryptFrom<_, StackCipher<__K>, ()>` is not implemented for `CipherText<SealedValue, Box<(dyn Any + Send + 'static)>>` - but trait `EncryptFrom<_, StackCipher<__K>, NonEmpty<_>>` is implemented for it + = help: the trait `EncryptFrom<_, KeysetCipher<'__k, __K>, ()>` is not implemented for `CipherText<SealedValue, Box<(dyn Any + Send + 'static)>>` + but trait `EncryptFrom<_, KeysetCipher<'_, __K>, NonEmpty<_>>` is implemented for it = help: for that trait implementation, expected `NonEmpty<_>`, found `()` error[E0277]: `CipherText<SealedValue, Box<(dyn Any + Send + 'static)>>` cannot be a field of an automatically decrypted record diff --git a/packages/stack-encrypt/tests/ui/pass/which_form_compiles.rs b/packages/stack-encrypt/tests/ui/pass/which_form_compiles.rs index c554818ce..099fe8197 100644 --- a/packages/stack-encrypt/tests/ui/pass/which_form_compiles.rs +++ b/packages/stack-encrypt/tests/ui/pass/which_form_compiles.rs @@ -3,10 +3,12 @@ //! `tests/ui/bare_context.rs` pin the lines that must not. use stack_encrypt::sem::EqualityTerm; use stack_encrypt::target::{DecryptFrom, EncryptInto}; -use stack_encrypt::{nonempty, DecryptInto, EncryptFrom, NonEmpty, StackCipher, StackCipherText}; +use stack_encrypt::{nonempty, DecryptInto, EncryptFrom, KeysetCipher, NonEmpty, StackCipherText}; use stack_kms::FakeDataKeySource; -type Cipher = StackCipher<FakeDataKeySource>; +/// Encrypting binds to a keyset; decrypting works through the same handle +/// (constrained to that keyset) as well as through the `StackCipher`. +type Cipher<'k> = KeysetCipher<'k, FakeDataKeySource>; /// A record whose one field pins a literal context: needs nothing from the /// caller, and takes a context that then *extends* the literal. @@ -17,7 +19,7 @@ struct Pinned { c: StackCipherText, } -async fn pinned(cipher: &Cipher, tenant_id: u64) -> Result<(), stack_encrypt::Error> { +async fn pinned(cipher: &Cipher<'_>, tenant_id: u64) -> Result<(), stack_encrypt::Error> { // Sealed under "legacy/age". let p: Pinned = 42u32.encrypt_into(cipher).await?; let _: u32 = p.decrypt_into(cipher, ()).await?; @@ -36,7 +38,7 @@ struct Foo { hm: EqualityTerm, } -async fn foo(cipher: &Cipher, column: String) -> Result<(), stack_encrypt::Error> { +async fn foo(cipher: &Cipher<'_>, column: String) -> Result<(), stack_encrypt::Error> { // A literal, a runtime value, a bare integer. let f: Foo = 42u32.encrypt_into_with_context(cipher, nonempty!("users/age")).await?; let _: u32 = f.decrypt_into(cipher, nonempty!("users/age")).await?; @@ -65,7 +67,7 @@ struct EncryptedUser { email: StackCipherText, } -async fn user(cipher: &Cipher, user: User, id: u64) -> Result<(), stack_encrypt::Error> { +async fn user(cipher: &Cipher<'_>, user: User, id: u64) -> Result<(), stack_encrypt::Error> { // "users/age", "users/email". let r: EncryptedUser = user.encrypt_into(cipher).await?; let user = User::decrypt_from(r, cipher).await?; From ceca2f0d7b96052d5c124d678116975cd28a79ff Mon Sep 17 00:00:00 2001 From: Dan Draper <dan@cipherstash.com> Date: Sat, 12 Sep 2026 00:12:41 -0400 Subject: [PATCH 517/686] fix(stack-encrypt): a keyset name is a lookup with a window, and no alias outlives its id MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review (Copilot and Codex, cipherstash/cipherstash-suite#2211). A keyset's id is its identity; a name is a lookup ZeroKMS answers, and ZeroKMS allows renames, so a name the cipher resolved earlier can point at another keyset later. Name-to-id bindings are now trusted for a bounded window — five minutes by default, `keyset_name_ttl` on the builder, `Duration::ZERO` for a round trip per selection — after which a selection by that name asks ZeroKMS again and the binding is refreshed or moved. The default keyset's builder-time name ages the same way; its state never does. Selection by id is never re-asked. The cache also kept its name index per state rather than per id, so a reload by id (two cold lookups landing name-first, id-second) replaced the state without its name, eviction then removed nothing, and the stale alias survived to resolve again after a later reload — and the index grew past the cache's bound. Every name an id was resolved under is now kept with the entry across replacement and dropped with it on eviction, so the name index is bounded by the entries it serves. Regression tests for the name-then-id order, the bound, several names on one id, a name that moved, and the aging window. The guest's term docs (`ops::term`, `se_term`, `se_encrypt_record`) said terms never touch ZeroKMS; that is the local HMAC backend's property, not the API's, and they now say so. Claude-Session: https://claude.ai/code/session_01ECzyss8kvtnrRtq5tmpEbg --- .../golang/stackencrypt/guest/src/abi.rs | 8 +- .../golang/stackencrypt/guest/src/ops.rs | 6 +- packages/stack-encrypt/src/cipher.rs | 43 +- packages/stack-encrypt/src/keyset.rs | 378 ++++++++++++++---- packages/stack-encrypt/tests/keysets.rs | 46 +++ 5 files changed, 390 insertions(+), 91 deletions(-) diff --git a/languages/golang/stackencrypt/guest/src/abi.rs b/languages/golang/stackencrypt/guest/src/abi.rs index a06b6819e..bcaaf52c9 100644 --- a/languages/golang/stackencrypt/guest/src/abi.rs +++ b/languages/golang/stackencrypt/guest/src/abi.rs @@ -401,7 +401,9 @@ fn run_decrypt( /// Derive one index term: a codec-encoded scalar, a codec-encoded context /// and a term kind ([`ops::TERM_EQUALITY`] etc.); the output is the term's -/// frozen byte encoding. Local PRF/CLLW only — never touches ZeroKMS. +/// frozen byte encoding. Under the local HMAC backend this is one PRF/CLLW +/// derivation with no ZeroKMS I/O; that is the backend's property, not this +/// export's contract. /// /// The context is one part — a string, bytes, or an `i32`/`i64`/`u32`/`u64` /// — or an array of parts, which may nest as deep as the transport codec @@ -442,7 +444,9 @@ pub unsafe extern "C" fn se_term( /// plan, and result encodings. All rows and fields seal from **one** batched /// key request regardless of row count — dispatched as one /// `generate-data-key` call per 500 keyed leaves, sequentially — and terms -/// derive locally with no ZeroKMS traffic at all. +/// derive under the same keyset's index key (with no ZeroKMS traffic under +/// the local HMAC backend; a backend that derives terms at ZeroKMS would +/// add its own). /// /// # Safety /// diff --git a/languages/golang/stackencrypt/guest/src/ops.rs b/languages/golang/stackencrypt/guest/src/ops.rs index fc887a2fa..64f753546 100644 --- a/languages/golang/stackencrypt/guest/src/ops.rs +++ b/languages/golang/stackencrypt/guest/src/ops.rs @@ -138,8 +138,10 @@ where /// Derive one index term: a codec-encoded scalar and a codec-encoded /// context in, the term's frozen byte encoding out (see `stack-encrypt`'s -/// `sem` module docs). Purely local — this never touches ZeroKMS, which is -/// what makes query probes cheap. +/// `sem` module docs). Under the local HMAC backend the derivation is one +/// PRF/CLLW computation with no ZeroKMS I/O; that is the backend's +/// property, not this operation's contract — the term API is a `Pending` +/// so a backend that derives terms at ZeroKMS settles the same way. /// /// The context is one part — a string, bytes, or an `i32`/`i64`/`u32`/`u64` /// — or an array of parts, nested as deep as the transport codec allows diff --git a/packages/stack-encrypt/src/cipher.rs b/packages/stack-encrypt/src/cipher.rs index d8bc446c7..11143caf5 100644 --- a/packages/stack-encrypt/src/cipher.rs +++ b/packages/stack-encrypt/src/cipher.rs @@ -87,6 +87,7 @@ use std::borrow::Cow; use std::collections::HashSet; use std::num::NonZeroUsize; use std::sync::{Arc, Mutex, PoisonError}; +use std::time::Duration; use serde::{Deserialize, Serialize}; use stack_kms::{DataKey, DataKeySource, DataKeyWithTag, IdentifiedBy, IndexKeySource}; @@ -102,7 +103,7 @@ use vitaminc_aead::{ use vitaminc_encrypt::{Aes256Cipher, AesCipherText, Key as AesKey}; use vitaminc_protected::{Controlled, Protected}; -use crate::keyset::{KeysetCache, KeysetCipher, KeysetState}; +use crate::keyset::{KeysetCache, KeysetCipher, KeysetState, Lookup, DEFAULT_NAME_TTL}; use crate::Descriptor; /// The passthrough payload type: type-erased, as for Rust-native vitaminc @@ -379,17 +380,21 @@ impl<K: IndexKeySource> StackCipher<K> { /// A keyset ZeroKMS does not know, or has disabled, is /// [`Error::Kms`]. The name-to-id resolution is ZeroKMS's: a keyset /// selected by name reports the resolved id from - /// [`KeysetCipher::keyset_id`]. + /// [`KeysetCipher::keyset_id`], and a name the cipher resolved earlier + /// is trusted for a bounded window + /// ([`keyset_name_ttl`](StackCipherBuilder::keyset_name_ttl)) before it + /// is asked again — ZeroKMS allows renames, and a running process + /// notices one within that window. Selecting by id never asks twice. pub async fn keyset( &self, keyset: impl Into<IdentifiedBy>, ) -> Result<KeysetCipher<'_, K>, Error> { let keyset = keyset.into(); - if self.default.is(&keyset) { - return Ok(self.default_keyset()); - } - if let Some(state) = self.keysets().get(&keyset) { - return Ok(KeysetCipher::new(self, state)); + match self.keysets().get(&keyset) { + Lookup::Hit(state) => return Ok(KeysetCipher::new(self, state)), + // A name past its window: the keyset is still loaded, but + // whether the name still means it is ZeroKMS's to say. + Lookup::Stale | Lookup::Miss => {} } // Loaded outside the lock: a round trip must not hold up every other // selection, and two selections racing on the same miss simply load @@ -458,6 +463,7 @@ pub struct StackCipherBuilder<K = FromEnv> { kms: K, keyset: Option<IdentifiedBy>, cache_size: NonZeroUsize, + name_ttl: Duration, } impl StackCipherBuilder<FromEnv> { @@ -471,6 +477,7 @@ impl StackCipherBuilder<FromEnv> { kms: FromEnv, keyset: None, cache_size: KeysetCache::DEFAULT_CAPACITY, + name_ttl: DEFAULT_NAME_TTL, } } } @@ -499,6 +506,20 @@ impl<K> StackCipherBuilder<K> { self.cache_size = size; self } + + /// How long a keyset selected by name is trusted to still be the keyset + /// that name resolved to (default five minutes, [`DEFAULT_NAME_TTL`]). + /// ZeroKMS allows a keyset to be renamed; within the window a rename is + /// invisible to a running process, after it the next selection by that + /// name asks ZeroKMS again. `Duration::ZERO` makes every selection by + /// name a round trip; selection by id is never affected. See + /// [`StackCipher::keyset`]. + /// + /// [`DEFAULT_NAME_TTL`]: crate::keyset::DEFAULT_NAME_TTL + pub fn keyset_name_ttl(mut self, ttl: Duration) -> Self { + self.name_ttl = ttl; + self + } } impl StackCipherBuilder<FromEnv> { @@ -514,6 +535,7 @@ impl StackCipherBuilder<FromEnv> { kms, keyset: self.keyset, cache_size: self.cache_size, + name_ttl: self.name_ttl, } } @@ -541,6 +563,7 @@ impl StackCipherBuilder<FromEnv> { kms, keyset: self.keyset, cache_size: self.cache_size, + name_ttl: self.name_ttl, } .init() .await @@ -565,8 +588,12 @@ impl<K: DataKeySource + IndexKeySource> StackCipherBuilder<K> { }); Ok(StackCipher { kms: self.kms, + keysets: Mutex::new(KeysetCache::new( + self.cache_size, + self.name_ttl, + Arc::clone(&default), + )), default, - keysets: Mutex::new(KeysetCache::new(self.cache_size)), }) } } diff --git a/packages/stack-encrypt/src/keyset.rs b/packages/stack-encrypt/src/keyset.rs index 4b4e072c8..380b3a006 100644 --- a/packages/stack-encrypt/src/keyset.rs +++ b/packages/stack-encrypt/src/keyset.rs @@ -18,10 +18,25 @@ //! builder, else the client's — is loaded eagerly by //! [`init`](crate::StackCipherBuilder::init), so a misconfigured client //! fails at startup, and never evicts. +//! +//! # Ids are identity; names are looked up +//! +//! A keyset's id is its identity: a sealed leaf carries it, and an id +//! selection never needs re-checking. A name is a lookup ZeroKMS answers, +//! and ZeroKMS lets a keyset be renamed, so a name the cipher resolved +//! earlier can point at a different keyset later. The cache therefore +//! treats a name-to-id binding as fresh for a bounded time +//! ([`DEFAULT_NAME_TTL`], `StackCipherBuilder::keyset_name_ttl`) and +//! re-asks ZeroKMS after that — the way a resolver treats a DNS record. +//! Within the window a rename is invisible; a `Duration::ZERO` window makes +//! every name selection a round trip. The default keyset's builder-time +//! name is bound the same way: after the window, selecting it by name asks +//! ZeroKMS again. use std::collections::HashMap; use std::num::NonZeroUsize; use std::sync::Arc; +use std::time::{Duration, Instant}; use stack_kms::IdentifiedBy; use uuid::Uuid; @@ -29,6 +44,12 @@ use vitaminc_hmac::HmacSha256Prf; use crate::StackCipher; +/// How long a name-to-id binding is trusted before a selection by that +/// name asks ZeroKMS again. Five minutes bounds how long a rename can go +/// unnoticed by a running process; `StackCipherBuilder::keyset_name_ttl` +/// changes it. +pub const DEFAULT_NAME_TTL: Duration = Duration::from_secs(5 * 60); + /// What the cipher holds per loaded keyset: its resolved id, the name it was /// loaded under if any, and the PRF keyed by its index key. pub(crate) struct KeysetState { @@ -37,37 +58,59 @@ pub(crate) struct KeysetState { pub(crate) prf: HmacSha256Prf, } -impl KeysetState { - /// Whether `by` names this keyset: its id, or the name it was loaded - /// under. A keyset loaded by id does not know its name, so a later - /// lookup by name misses and loads again — ZeroKMS resolves the name to - /// the same id, and the cache then holds one state under both. - pub(crate) fn is(&self, by: &IdentifiedBy) -> bool { - match by { - IdentifiedBy::Uuid(id) => self.id == *id, - IdentifiedBy::Name(name) => self.name.as_deref() == Some(&**name), - } - } +/// A loaded keyset in the cache, with every name it has been resolved +/// under — kept across replacement so a name bound to this id is dropped +/// when the id is evicted, however the entry was last loaded. +struct Entry { + state: Arc<KeysetState>, + last_used: u64, + names: Vec<String>, +} + +/// A name-to-id binding, and when ZeroKMS last confirmed it. +struct Alias { + id: Uuid, + resolved_at: Instant, +} + +/// What a lookup found. +pub(crate) enum Lookup { + /// A loaded keyset, and (for a name) a binding within its window. + Hit(Arc<KeysetState>), + /// A name binding past its window (the keyset it named may still be + /// loaded, but whether the name still means it is ZeroKMS's to say): the + /// caller re-resolves the name with ZeroKMS and [`insert`]s the result, + /// which refreshes the binding — or moves it, if the name did. + /// + /// [`insert`]: KeysetCache::insert + Stale, + /// Nothing loaded for this id, or no binding for this name. + Miss, } /// The bounded, least-recently-used cache of loaded keysets behind /// [`StackCipher::keyset`]. /// -/// Entries are keyed by id, with a name index beside them for keysets loaded -/// by name. Eviction drops the least recently *used* entry, where a use is -/// any lookup hit; nothing stored depends on the cache (a sealed leaf carries +/// Entries are keyed by id, with a name index beside them for the names +/// each id has been resolved under. Eviction drops the least recently +/// *used* entry, where a use is any lookup hit, together with every name +/// bound to it; nothing stored depends on the cache (a sealed leaf carries /// its keyset id, and terms carry nothing), so eviction is invisible except -/// for the round trip the next lookup pays. The default keyset is not in -/// here and never evicts. +/// for the round trip the next lookup pays. The default keyset is held +/// apart and never evicts, though its name binding ages like any other. /// /// Hits are `O(1)`; an insert into a full cache scans for the oldest entry, -/// `O(n)` in the bound, which is the rare case by construction. +/// `O(n)` in the bound, which is the rare case by construction. The name +/// index is bounded by the entries it serves: every binding names either +/// the default or a cached id, and goes when that id does. pub(crate) struct KeysetCache { capacity: NonZeroUsize, + name_ttl: Duration, /// Monotonic use counter; an entry's tick is the last time it was hit. tick: u64, - by_id: HashMap<Uuid, (Arc<KeysetState>, u64)>, - by_name: HashMap<String, Uuid>, + default: Arc<KeysetState>, + by_id: HashMap<Uuid, Entry>, + by_name: HashMap<String, Alias>, } impl KeysetCache { @@ -76,54 +119,129 @@ impl KeysetCache { /// start and then not again. pub(crate) const DEFAULT_CAPACITY: NonZeroUsize = NonZeroUsize::MIN.saturating_add(1023); - pub(crate) fn new(capacity: NonZeroUsize) -> Self { + /// A cache holding `default` apart from the bound; its builder-time + /// name, if any, is bound now. + pub(crate) fn new( + capacity: NonZeroUsize, + name_ttl: Duration, + default: Arc<KeysetState>, + ) -> Self { + let mut by_name = HashMap::new(); + if let Some(name) = &default.name { + let _ = by_name.insert( + name.clone(), + Alias { + id: default.id, + resolved_at: Instant::now(), + }, + ); + } Self { capacity, + name_ttl, tick: 0, + default, by_id: HashMap::new(), - by_name: HashMap::new(), + by_name, } } /// Look a keyset up by id or name, marking it most recently used. - pub(crate) fn get(&mut self, by: &IdentifiedBy) -> Option<Arc<KeysetState>> { - let id = match by { - IdentifiedBy::Uuid(id) => *id, - IdentifiedBy::Name(name) => *self.by_name.get::<str>(name)?, + pub(crate) fn get(&mut self, by: &IdentifiedBy) -> Lookup { + let (id, fresh) = match by { + IdentifiedBy::Uuid(id) => (*id, true), + IdentifiedBy::Name(name) => match self.by_name.get::<str>(name) { + Some(alias) => (alias.id, alias.resolved_at.elapsed() <= self.name_ttl), + None => return Lookup::Miss, + }, }; - let (state, last_used) = self.by_id.get_mut(&id)?; - self.tick += 1; - *last_used = self.tick; - Some(Arc::clone(state)) + let state = if id == self.default.id { + Arc::clone(&self.default) + } else { + match self.by_id.get_mut(&id) { + Some(entry) => { + self.tick += 1; + entry.last_used = self.tick; + Arc::clone(&entry.state) + } + None => return Lookup::Miss, + } + }; + if fresh { + Lookup::Hit(state) + } else { + Lookup::Stale + } } - /// Insert a freshly loaded keyset, evicting the least recently used - /// entry first if the cache is full. Loading the same keyset twice - /// (two lookups racing on the same miss) replaces the entry with an - /// equivalent one and adds its name if the second load knew it. + /// Record a keyset ZeroKMS just resolved, evicting the least recently + /// used entry first if the cache is full and the id is new. The name it + /// was resolved under (if any) is bound to its id as of now — refreshing + /// a binding that had aged, or moving one whose keyset was renamed — and + /// every name an existing entry already carried is kept, so no binding + /// outlives the id it names. Resolving the default keyset again only + /// refreshes its binding: its state is never replaced. pub(crate) fn insert(&mut self, state: Arc<KeysetState>) { + if let Some(name) = &state.name { + let _ = self.by_name.insert( + name.clone(), + Alias { + id: state.id, + resolved_at: Instant::now(), + }, + ); + } + if state.id == self.default.id { + return; + } if !self.by_id.contains_key(&state.id) && self.by_id.len() >= self.capacity.get() { self.evict_oldest(); } - if let Some(name) = &state.name { - let _ = self.by_name.insert(name.clone(), state.id); - } self.tick += 1; - let _ = self.by_id.insert(state.id, (state, self.tick)); + match self.by_id.get_mut(&state.id) { + Some(entry) => { + if let Some(name) = &state.name { + if !entry.names.contains(name) { + entry.names.push(name.clone()); + } + } + entry.state = state; + entry.last_used = self.tick; + } + None => { + let names = state.name.iter().cloned().collect(); + let _ = self.by_id.insert( + state.id, + Entry { + state, + last_used: self.tick, + names, + }, + ); + } + } } fn evict_oldest(&mut self) { let Some(oldest) = self .by_id .iter() - .min_by_key(|(_, (_, tick))| *tick) + .min_by_key(|(_, entry)| entry.last_used) .map(|(id, _)| *id) else { return; }; - if let Some((state, _)) = self.by_id.remove(&oldest) { - if let Some(name) = &state.name { - let _ = self.by_name.remove(name); + if let Some(entry) = self.by_id.remove(&oldest) { + for name in entry.names { + // A name that has since moved to another id keeps its + // binding: only this id's bindings go with it. + if self + .by_name + .get(&name) + .is_some_and(|alias| alias.id == oldest) + { + let _ = self.by_name.remove(&name); + } } } } @@ -132,6 +250,11 @@ impl KeysetCache { pub(crate) fn len(&self) -> usize { self.by_id.len() } + + #[cfg(test)] + pub(crate) fn names(&self) -> usize { + self.by_name.len() + } } /// A [`StackCipher`] bound to one keyset: what sealing and term derivation @@ -183,6 +306,8 @@ impl<'k, K> KeysetCipher<'k, K> { /// The name this keyset was selected by, if it was selected by name /// (the default keyset knows its name only when the builder named it). + /// A label from the time of selection, not an identity: see the + /// [module docs](self#ids-are-identity-names-are-looked-up). pub fn keyset_name(&self) -> Option<&str> { self.state.name.as_deref() } @@ -222,8 +347,25 @@ mod tests { IdentifiedBy::Name(name.to_string().into()) } + fn id(id: u128) -> IdentifiedBy { + IdentifiedBy::Uuid(Uuid::from_u128(id)) + } + + /// A cache whose default is keyset 0 (unnamed) and whose name window + /// never closes. fn cache(capacity: usize) -> KeysetCache { - KeysetCache::new(NonZeroUsize::new(capacity).unwrap()) + KeysetCache::new( + NonZeroUsize::new(capacity).unwrap(), + Duration::MAX, + state(0, None), + ) + } + + fn hit(lookup: Lookup) -> Option<Uuid> { + match lookup { + Lookup::Hit(state) => Some(state.id), + Lookup::Stale | Lookup::Miss => None, + } } #[test] @@ -231,10 +373,10 @@ mod tests { let mut cache = cache(4); cache.insert(state(1, Some("customers"))); - assert!(cache.get(&Uuid::from_u128(1).into()).is_some()); - assert!(cache.get(&name("customers")).is_some()); - assert!(cache.get(&name("staff")).is_none()); - assert!(cache.get(&Uuid::from_u128(2).into()).is_none()); + assert_eq!(hit(cache.get(&id(1))), Some(Uuid::from_u128(1))); + assert_eq!(hit(cache.get(&name("customers"))), Some(Uuid::from_u128(1))); + assert!(matches!(cache.get(&name("staff")), Lookup::Miss)); + assert!(matches!(cache.get(&id(2)), Lookup::Miss)); } #[test] @@ -242,68 +384,146 @@ mod tests { let mut cache = cache(4); cache.insert(state(1, None)); - assert!(cache.get(&Uuid::from_u128(1).into()).is_some()); - assert!(cache.get(&name("customers")).is_none()); + assert!(matches!(cache.get(&id(1)), Lookup::Hit(_))); + assert!(matches!(cache.get(&name("customers")), Lookup::Miss)); } #[test] - fn the_least_recently_used_keyset_is_evicted_first() { + fn the_default_is_found_by_id_and_its_builder_name_but_never_stored() { + let mut cache = KeysetCache::new( + NonZeroUsize::new(1).unwrap(), + Duration::MAX, + state(0, Some("primary")), + ); + assert_eq!(hit(cache.get(&id(0))), Some(Uuid::from_u128(0))); + assert_eq!(hit(cache.get(&name("primary"))), Some(Uuid::from_u128(0))); + + // Filling the one slot evicts nothing of the default's. + cache.insert(state(1, None)); + cache.insert(state(2, None)); + assert_eq!(cache.len(), 1); + assert_eq!(hit(cache.get(&id(0))), Some(Uuid::from_u128(0))); + assert_eq!(hit(cache.get(&name("primary"))), Some(Uuid::from_u128(0))); + + // Re-resolving the default by name refreshes its binding, and does + // not put a second copy of it in the bounded part. + cache.insert(state(0, Some("primary"))); + assert_eq!(cache.len(), 1); + } + + #[test] + fn the_least_recently_used_keyset_is_evicted_with_its_names() { let mut cache = cache(2); cache.insert(state(1, Some("one"))); cache.insert(state(2, Some("two"))); // Touch 1 so 2 is the oldest. - assert!(cache.get(&Uuid::from_u128(1).into()).is_some()); + assert!(matches!(cache.get(&id(1)), Lookup::Hit(_))); cache.insert(state(3, Some("three"))); assert_eq!(cache.len(), 2); + assert!(matches!(cache.get(&id(2)), Lookup::Miss), "2 was oldest"); assert!( - cache.get(&Uuid::from_u128(2).into()).is_none(), - "2 was oldest" - ); - assert!( - cache.get(&name("two")).is_none(), + matches!(cache.get(&name("two")), Lookup::Miss), "the evicted keyset's name goes with it" ); - assert!(cache.get(&Uuid::from_u128(1).into()).is_some()); - assert!(cache.get(&Uuid::from_u128(3).into()).is_some()); + assert!(matches!(cache.get(&id(1)), Lookup::Hit(_))); + assert!(matches!(cache.get(&id(3)), Lookup::Hit(_))); } + /// The order two cold lookups on the same keyset can land in: by name + /// first, then by id. The id load carries no name, but must not shed + /// the binding the name load made — or eviction would later leave that + /// binding pointing at an id the cache no longer holds. #[test] - fn reinserting_a_cached_keyset_does_not_evict() { - let mut cache = cache(2); + fn a_reload_by_id_keeps_the_names_a_keyset_was_bound_under() { + let mut cache = cache(1); + cache.insert(state(1, Some("one"))); cache.insert(state(1, None)); + assert_eq!(cache.len(), 1); + assert_eq!(hit(cache.get(&name("one"))), Some(Uuid::from_u128(1))); + + // Evicting 1 takes "one" with it, whichever load was last. cache.insert(state(2, None)); + assert!(matches!(cache.get(&id(1)), Lookup::Miss)); + assert!(matches!(cache.get(&name("one")), Lookup::Miss)); + assert_eq!(cache.names(), 0); - // Same id again, now with a name: replaces, evicts nothing. - cache.insert(state(1, Some("one"))); + // And reloading 1 by id does not resurrect the binding. + cache.insert(state(1, None)); + assert!(matches!(cache.get(&name("one")), Lookup::Miss)); + } - assert_eq!(cache.len(), 2); - assert!(cache.get(&Uuid::from_u128(2).into()).is_some()); - assert!(cache.get(&name("one")).is_some()); + /// Bindings never outnumber the keysets they name: churning names + /// through a one-slot cache leaves one binding, not a thousand. + #[test] + fn the_name_index_is_bounded_by_the_cache() { + let mut cache = cache(1); + for i in 1..=1000u128 { + cache.insert(state(i, Some(&format!("tenant-{i}")))); + cache.insert(state(i, None)); + } + assert_eq!(cache.len(), 1); + assert_eq!(cache.names(), 1); + assert_eq!( + hit(cache.get(&name("tenant-1000"))), + Some(Uuid::from_u128(1000)) + ); } + /// A keyset resolved under two names carries both, and both go when it + /// does. #[test] - fn a_cache_of_one_holds_the_latest_keyset() { + fn a_keyset_can_be_bound_under_several_names() { let mut cache = cache(1); - cache.insert(state(1, None)); + cache.insert(state(1, Some("one"))); + cache.insert(state(1, Some("uno"))); + assert_eq!(hit(cache.get(&name("one"))), Some(Uuid::from_u128(1))); + assert_eq!(hit(cache.get(&name("uno"))), Some(Uuid::from_u128(1))); + cache.insert(state(2, None)); + assert_eq!(cache.names(), 0); + } - assert_eq!(cache.len(), 1); - assert!(cache.get(&Uuid::from_u128(1).into()).is_none()); - assert!(cache.get(&Uuid::from_u128(2).into()).is_some()); + /// A rename: the name now resolves to another id. The binding moves, + /// and evicting the id it used to name does not take it away. + #[test] + fn a_name_that_moved_to_another_keyset_follows_it() { + let mut cache = cache(2); + cache.insert(state(1, Some("acme"))); + cache.insert(state(2, Some("acme"))); + assert_eq!(hit(cache.get(&name("acme"))), Some(Uuid::from_u128(2))); + + // Evict 1 (the oldest): "acme" belongs to 2 now and stays. + assert!(matches!(cache.get(&id(2)), Lookup::Hit(_))); + cache.insert(state(3, None)); + assert!(matches!(cache.get(&id(1)), Lookup::Miss)); + assert_eq!(hit(cache.get(&name("acme"))), Some(Uuid::from_u128(2))); } + /// Past the window a name lookup is stale — the keyset is still there, + /// the binding is not trusted — and a re-resolution refreshes it. #[test] - fn state_matches_its_id_and_its_name() { - let named = state(1, Some("customers")); - assert!(named.is(&Uuid::from_u128(1).into())); - assert!(named.is(&name("customers"))); - assert!(!named.is(&name("staff"))); - assert!(!named.is(&Uuid::from_u128(2).into())); - - let anonymous = state(1, None); - assert!(anonymous.is(&Uuid::from_u128(1).into())); - assert!(!anonymous.is(&name("customers"))); + fn a_name_binding_ages_out_and_is_refreshed_by_reinsertion() { + let mut cache = KeysetCache::new( + NonZeroUsize::new(4).unwrap(), + Duration::ZERO, + state(0, Some("primary")), + ); + cache.insert(state(1, Some("one"))); + + assert!(matches!(cache.get(&name("one")), Lookup::Stale)); + assert!(matches!(cache.get(&name("primary")), Lookup::Stale)); + assert!( + matches!(cache.get(&id(1)), Lookup::Hit(_)), + "an id never ages" + ); + + // A zero window is stale again immediately after a refresh, which is + // the point of a zero window; a wide one is fresh. + cache.insert(state(1, Some("one"))); + assert!(matches!(cache.get(&name("one")), Lookup::Stale)); + cache.name_ttl = Duration::MAX; + assert!(matches!(cache.get(&name("one")), Lookup::Hit(_))); } } diff --git a/packages/stack-encrypt/tests/keysets.rs b/packages/stack-encrypt/tests/keysets.rs index 3ee9f1134..44aa9513e 100644 --- a/packages/stack-encrypt/tests/keysets.rs +++ b/packages/stack-encrypt/tests/keysets.rs @@ -5,6 +5,7 @@ use std::borrow::Cow; use std::num::NonZeroUsize; use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::Mutex; +use std::time::Duration; use stack_encrypt::target::{DecryptInto, EncryptInto}; use stack_encrypt::{nonempty, CipherText, Error, SealedValue, StackCipher, StackCipherText}; @@ -177,6 +178,51 @@ async fn an_evicted_keyset_reloads_on_its_next_selection() { assert_eq!(cipher.kms().loads(), 4); } +/// A name is a lookup, not an identity: past the window, selecting a keyset +/// by name asks ZeroKMS again, while selecting by id never does. The +/// default keyset's builder-time name ages the same way. +#[tokio::test] +async fn a_name_selection_is_re_resolved_after_its_window() { + let cipher = StackCipher::builder() + .kms(Observed::default()) + .keyset(name("primary")) + .keyset_name_ttl(Duration::ZERO) + .init() + .await + .expect("build cipher"); + assert_eq!(cipher.kms().loads(), 1); + + let acme = cipher.keyset(name("acme")).await.expect("acme"); + let _ = cipher.keyset(name("acme")).await.expect("acme again"); + assert_eq!( + cipher.kms().loads(), + 3, + "every selection by name asks again" + ); + + let _ = cipher.keyset(acme.keyset_id()).await.expect("acme by id"); + let _ = cipher + .keyset(acme.keyset_id()) + .await + .expect("acme by id again"); + assert_eq!( + cipher.kms().loads(), + 3, + "an id is identity and is never re-asked" + ); + + let _ = cipher + .keyset(name("primary")) + .await + .expect("default by name"); + assert_eq!(cipher.kms().loads(), 4, "the default's name ages too"); + let _ = cipher + .keyset(cipher.default_keyset().keyset_id()) + .await + .expect("default by id"); + assert_eq!(cipher.kms().loads(), 4); +} + #[tokio::test] async fn keysets_derive_distinct_index_keys() { let cipher = cipher().await; From 3c30a60a10595b06c852b86a4a512bced9be7f31 Mon Sep 17 00:00:00 2001 From: Dan Draper <dan@cipherstash.com> Date: Sat, 12 Sep 2026 14:47:22 -0400 Subject: [PATCH 518/686] fix(stack-encrypt): a name binding follows the later lookup, is strictly windowed, and is one per keyset Review findings on cipherstash/cipherstash-suite#2211 (Copilot) in the keyset name cache: - Resolutions run outside the lock, so two lookups on the same stale name could land in either order and the older answer could overwrite the newer binding for another window. A lookup that goes to ZeroKMS now carries a Resolution ticket from get to insert; a binding is applied only if its lookup is later than the one that produced the current binding. Key material is cached by id either way. - `elapsed() <= ttl` let a zero window hit the cache when the clock had not moved. Freshness is strictly within the window. - Every name a keyset was ever resolved under stayed in the index until the keyset evicted, so a renamed hot keyset grew the index without bound. A keyset has one name in ZeroKMS, so the cache keeps one per keyset: resolving it under a new name drops the old binding, and a name that moves to another keyset is dropped from the one it named. The index is bounded by the cached ids plus the default. Claude-Session: https://claude.ai/code/session_01ECzyss8kvtnrRtq5tmpEbg --- packages/stack-encrypt/src/cipher.rs | 13 +- packages/stack-encrypt/src/keyset.rs | 306 ++++++++++++++++++++------- 2 files changed, 231 insertions(+), 88 deletions(-) diff --git a/packages/stack-encrypt/src/cipher.rs b/packages/stack-encrypt/src/cipher.rs index 11143caf5..9470137f8 100644 --- a/packages/stack-encrypt/src/cipher.rs +++ b/packages/stack-encrypt/src/cipher.rs @@ -390,15 +390,16 @@ impl<K: IndexKeySource> StackCipher<K> { keyset: impl Into<IdentifiedBy>, ) -> Result<KeysetCipher<'_, K>, Error> { let keyset = keyset.into(); - match self.keysets().get(&keyset) { + let resolution = match self.keysets().get(&keyset) { Lookup::Hit(state) => return Ok(KeysetCipher::new(self, state)), // A name past its window: the keyset is still loaded, but // whether the name still means it is ZeroKMS's to say. - Lookup::Stale | Lookup::Miss => {} - } + Lookup::Stale(resolution) | Lookup::Miss(resolution) => resolution, + }; // Loaded outside the lock: a round trip must not hold up every other - // selection, and two selections racing on the same miss simply load - // twice and the second insert replaces the first with its equal. + // selection. Two selections racing on the same miss load twice; the + // cache keeps both keysets by id, and the name follows the later + // lookup whichever answer lands first. let name = match &keyset { IdentifiedBy::Name(name) => Some(name.to_string()), IdentifiedBy::Uuid(_) => None, @@ -409,7 +410,7 @@ impl<K: IndexKeySource> StackCipher<K> { name, prf: hmac_prf_from_index_key(&index_key), }); - self.keysets().insert(Arc::clone(&state)); + self.keysets().insert(Arc::clone(&state), resolution); Ok(KeysetCipher::new(self, state)) } } diff --git a/packages/stack-encrypt/src/keyset.rs b/packages/stack-encrypt/src/keyset.rs index 380b3a006..d46236d19 100644 --- a/packages/stack-encrypt/src/keyset.rs +++ b/packages/stack-encrypt/src/keyset.rs @@ -32,6 +32,13 @@ //! every name selection a round trip. The default keyset's builder-time //! name is bound the same way: after the window, selecting it by name asks //! ZeroKMS again. +//! +//! A keyset has one name at a time in ZeroKMS, so the cache keeps one name +//! per keyset: resolving a keyset under a new name means its old name was +//! renamed away, and that binding goes. And because resolutions run outside +//! the lock, their answers can land in any order; a binding follows the +//! *later lookup*, whichever answer arrives first, so an answer from before +//! a rename cannot overwrite one from after it. use std::collections::HashMap; use std::num::NonZeroUsize; @@ -58,34 +65,48 @@ pub(crate) struct KeysetState { pub(crate) prf: HmacSha256Prf, } -/// A loaded keyset in the cache, with every name it has been resolved -/// under — kept across replacement so a name bound to this id is dropped -/// when the id is evicted, however the entry was last loaded. +/// A loaded keyset in the cache, with the name it is currently bound under +/// if any — kept across replacement so the binding is dropped when the id +/// is evicted, however the entry was last loaded. struct Entry { state: Arc<KeysetState>, last_used: u64, - names: Vec<String>, + name: Option<String>, } -/// A name-to-id binding, and when ZeroKMS last confirmed it. +/// A name-to-id binding: when ZeroKMS last confirmed it, and which lookup +/// asked. struct Alias { id: Uuid, resolved_at: Instant, + resolution: Resolution, } +/// A lookup's place in the order of lookups that went to ZeroKMS. The +/// caller carries it from [`get`](KeysetCache::get) to +/// [`insert`](KeysetCache::insert), where a name binding is applied only if +/// this lookup is later than the one that produced the current binding — +/// answers land in any order, and a later question has the later answer. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] +pub(crate) struct Resolution(u64); + /// What a lookup found. pub(crate) enum Lookup { /// A loaded keyset, and (for a name) a binding within its window. Hit(Arc<KeysetState>), /// A name binding past its window (the keyset it named may still be /// loaded, but whether the name still means it is ZeroKMS's to say): the - /// caller re-resolves the name with ZeroKMS and [`insert`]s the result, - /// which refreshes the binding — or moves it, if the name did. + /// caller re-resolves the name with ZeroKMS and [`insert`]s the result + /// with this ticket, which refreshes the binding — or moves it, if the + /// name did. + /// + /// [`insert`]: KeysetCache::insert + Stale(Resolution), + /// Nothing loaded for this id, or no binding for this name: the caller + /// loads it and [`insert`]s the result with this ticket. /// /// [`insert`]: KeysetCache::insert - Stale, - /// Nothing loaded for this id, or no binding for this name. - Miss, + Miss(Resolution), } /// The bounded, least-recently-used cache of loaded keysets behind @@ -101,14 +122,20 @@ pub(crate) enum Lookup { /// /// Hits are `O(1)`; an insert into a full cache scans for the oldest entry, /// `O(n)` in the bound, which is the rare case by construction. The name -/// index is bounded by the entries it serves: every binding names either -/// the default or a cached id, and goes when that id does. +/// index is bounded by the entries it serves: one binding per cached id at +/// most, plus the default's, and a binding goes when its id does or when +/// the keyset is resolved under another name. pub(crate) struct KeysetCache { capacity: NonZeroUsize, name_ttl: Duration, /// Monotonic use counter; an entry's tick is the last time it was hit. tick: u64, + /// Monotonic lookup counter; see [`Resolution`]. + resolutions: u64, default: Arc<KeysetState>, + /// The name the default is currently bound under, if any: its + /// builder-time name until a resolution binds it under another. + default_name: Option<String>, by_id: HashMap<Uuid, Entry>, by_name: HashMap<String, Alias>, } @@ -133,6 +160,7 @@ impl KeysetCache { Alias { id: default.id, resolved_at: Instant::now(), + resolution: Resolution(0), }, ); } @@ -140,19 +168,29 @@ impl KeysetCache { capacity, name_ttl, tick: 0, + resolutions: 0, + default_name: default.name.clone(), default, by_id: HashMap::new(), by_name, } } + /// The ticket for a lookup that is about to go to ZeroKMS. + fn resolution(&mut self) -> Resolution { + self.resolutions += 1; + Resolution(self.resolutions) + } + /// Look a keyset up by id or name, marking it most recently used. pub(crate) fn get(&mut self, by: &IdentifiedBy) -> Lookup { let (id, fresh) = match by { IdentifiedBy::Uuid(id) => (*id, true), IdentifiedBy::Name(name) => match self.by_name.get::<str>(name) { - Some(alias) => (alias.id, alias.resolved_at.elapsed() <= self.name_ttl), - None => return Lookup::Miss, + // Strictly within the window: a zero window is never fresh, + // whatever the clock's resolution. + Some(alias) => (alias.id, alias.resolved_at.elapsed() < self.name_ttl), + None => return Lookup::Miss(self.resolution()), }, }; let state = if id == self.default.id { @@ -164,33 +202,33 @@ impl KeysetCache { entry.last_used = self.tick; Arc::clone(&entry.state) } - None => return Lookup::Miss, + None => return Lookup::Miss(self.resolution()), } }; if fresh { Lookup::Hit(state) } else { - Lookup::Stale + Lookup::Stale(self.resolution()) } } - /// Record a keyset ZeroKMS just resolved, evicting the least recently - /// used entry first if the cache is full and the id is new. The name it - /// was resolved under (if any) is bound to its id as of now — refreshing - /// a binding that had aged, or moving one whose keyset was renamed — and - /// every name an existing entry already carried is kept, so no binding - /// outlives the id it names. Resolving the default keyset again only - /// refreshes its binding: its state is never replaced. - pub(crate) fn insert(&mut self, state: Arc<KeysetState>) { - if let Some(name) = &state.name { - let _ = self.by_name.insert( - name.clone(), - Alias { - id: state.id, - resolved_at: Instant::now(), - }, - ); - } + /// Record a keyset ZeroKMS just resolved for the lookup `resolution`, + /// evicting the least recently used entry first if the cache is full + /// and the id is new. Resolving the default keyset again never replaces + /// its state. + /// + /// The name it was resolved under (if any) is bound to its id as of now + /// — refreshing a binding that had aged, or moving one whose keyset was + /// renamed — unless a later lookup has already bound that name, in + /// which case this answer is the older one and the binding stands. A + /// keyset has one name, so binding it under a new name drops the old + /// one; and a name that moved to this keyset is dropped from the keyset + /// it used to name. No binding outlives the id it names. + pub(crate) fn insert(&mut self, state: Arc<KeysetState>, resolution: Resolution) { + let bound = match &state.name { + Some(name) => self.bind(name, state.id, resolution), + None => false, + }; if state.id == self.default.id { return; } @@ -200,28 +238,79 @@ impl KeysetCache { self.tick += 1; match self.by_id.get_mut(&state.id) { Some(entry) => { - if let Some(name) = &state.name { - if !entry.names.contains(name) { - entry.names.push(name.clone()); - } + if bound { + entry.name.clone_from(&state.name); } entry.state = state; entry.last_used = self.tick; } None => { - let names = state.name.iter().cloned().collect(); + let name = bound.then(|| state.name.clone()).flatten(); let _ = self.by_id.insert( state.id, Entry { state, last_used: self.tick, - names, + name, }, ); } } } + /// Bind `name` to `id` for the lookup `resolution`; false if a later + /// lookup already bound it. Also unbinds the name this id was bound under + /// before, and unbinds this name from the id it named before. + fn bind(&mut self, name: &str, id: Uuid, resolution: Resolution) -> bool { + if let Some(alias) = self.by_name.get(name) { + if alias.resolution > resolution { + return false; + } + if alias.id != id { + self.forget_name_of(alias.id, name); + } + } + if let Some(previous) = self.current_name_of(id) { + if previous != name { + let _ = self.by_name.remove(&previous); + } + } + let _ = self.by_name.insert( + name.to_owned(), + Alias { + id, + resolved_at: Instant::now(), + resolution, + }, + ); + if id == self.default.id { + self.default_name = Some(name.to_owned()); + } + true + } + + /// The name `id` is currently bound under, if any. + fn current_name_of(&self, id: Uuid) -> Option<String> { + if id == self.default.id { + self.default_name.clone() + } else { + self.by_id.get(&id).and_then(|entry| entry.name.clone()) + } + } + + /// `name` moved away from `id`: the id no longer claims it. + fn forget_name_of(&mut self, id: Uuid, name: &str) { + if id == self.default.id { + if self.default_name.as_deref() == Some(name) { + self.default_name = None; + } + } else if let Some(entry) = self.by_id.get_mut(&id) { + if entry.name.as_deref() == Some(name) { + entry.name = None; + } + } + } + fn evict_oldest(&mut self) { let Some(oldest) = self .by_id @@ -232,9 +321,9 @@ impl KeysetCache { return; }; if let Some(entry) = self.by_id.remove(&oldest) { - for name in entry.names { + if let Some(name) = entry.name { // A name that has since moved to another id keeps its - // binding: only this id's bindings go with it. + // binding: only this id's binding goes with it. if self .by_name .get(&name) @@ -246,6 +335,14 @@ impl KeysetCache { } } + /// Insert as a fresh, in-order resolution — what every test that is not + /// about ordering wants. + #[cfg(test)] + pub(crate) fn load(&mut self, state: Arc<KeysetState>) { + let resolution = self.resolution(); + self.insert(state, resolution); + } + #[cfg(test)] pub(crate) fn len(&self) -> usize { self.by_id.len() @@ -364,28 +461,35 @@ mod tests { fn hit(lookup: Lookup) -> Option<Uuid> { match lookup { Lookup::Hit(state) => Some(state.id), - Lookup::Stale | Lookup::Miss => None, + Lookup::Stale(_) | Lookup::Miss(_) => None, + } + } + + fn ticket(lookup: Lookup) -> Resolution { + match lookup { + Lookup::Stale(r) | Lookup::Miss(r) => r, + Lookup::Hit(_) => panic!("expected a lookup that goes to ZeroKMS"), } } #[test] fn a_keyset_is_found_by_id_and_by_the_name_it_loaded_under() { let mut cache = cache(4); - cache.insert(state(1, Some("customers"))); + cache.load(state(1, Some("customers"))); assert_eq!(hit(cache.get(&id(1))), Some(Uuid::from_u128(1))); assert_eq!(hit(cache.get(&name("customers"))), Some(Uuid::from_u128(1))); - assert!(matches!(cache.get(&name("staff")), Lookup::Miss)); - assert!(matches!(cache.get(&id(2)), Lookup::Miss)); + assert!(matches!(cache.get(&name("staff")), Lookup::Miss(_))); + assert!(matches!(cache.get(&id(2)), Lookup::Miss(_))); } #[test] fn a_keyset_loaded_by_id_is_not_found_by_name() { let mut cache = cache(4); - cache.insert(state(1, None)); + cache.load(state(1, None)); assert!(matches!(cache.get(&id(1)), Lookup::Hit(_))); - assert!(matches!(cache.get(&name("customers")), Lookup::Miss)); + assert!(matches!(cache.get(&name("customers")), Lookup::Miss(_))); } #[test] @@ -399,32 +503,38 @@ mod tests { assert_eq!(hit(cache.get(&name("primary"))), Some(Uuid::from_u128(0))); // Filling the one slot evicts nothing of the default's. - cache.insert(state(1, None)); - cache.insert(state(2, None)); + cache.load(state(1, None)); + cache.load(state(2, None)); assert_eq!(cache.len(), 1); assert_eq!(hit(cache.get(&id(0))), Some(Uuid::from_u128(0))); assert_eq!(hit(cache.get(&name("primary"))), Some(Uuid::from_u128(0))); // Re-resolving the default by name refreshes its binding, and does // not put a second copy of it in the bounded part. - cache.insert(state(0, Some("primary"))); + cache.load(state(0, Some("primary"))); assert_eq!(cache.len(), 1); + + // The default renamed: its old name no longer selects it. + cache.load(state(0, Some("main"))); + assert_eq!(hit(cache.get(&name("main"))), Some(Uuid::from_u128(0))); + assert!(matches!(cache.get(&name("primary")), Lookup::Miss(_))); + assert_eq!(cache.names(), 1); } #[test] fn the_least_recently_used_keyset_is_evicted_with_its_names() { let mut cache = cache(2); - cache.insert(state(1, Some("one"))); - cache.insert(state(2, Some("two"))); + cache.load(state(1, Some("one"))); + cache.load(state(2, Some("two"))); // Touch 1 so 2 is the oldest. assert!(matches!(cache.get(&id(1)), Lookup::Hit(_))); - cache.insert(state(3, Some("three"))); + cache.load(state(3, Some("three"))); assert_eq!(cache.len(), 2); - assert!(matches!(cache.get(&id(2)), Lookup::Miss), "2 was oldest"); + assert!(matches!(cache.get(&id(2)), Lookup::Miss(_)), "2 was oldest"); assert!( - matches!(cache.get(&name("two")), Lookup::Miss), + matches!(cache.get(&name("two")), Lookup::Miss(_)), "the evicted keyset's name goes with it" ); assert!(matches!(cache.get(&id(1)), Lookup::Hit(_))); @@ -438,20 +548,20 @@ mod tests { #[test] fn a_reload_by_id_keeps_the_names_a_keyset_was_bound_under() { let mut cache = cache(1); - cache.insert(state(1, Some("one"))); - cache.insert(state(1, None)); + cache.load(state(1, Some("one"))); + cache.load(state(1, None)); assert_eq!(cache.len(), 1); assert_eq!(hit(cache.get(&name("one"))), Some(Uuid::from_u128(1))); // Evicting 1 takes "one" with it, whichever load was last. - cache.insert(state(2, None)); - assert!(matches!(cache.get(&id(1)), Lookup::Miss)); - assert!(matches!(cache.get(&name("one")), Lookup::Miss)); + cache.load(state(2, None)); + assert!(matches!(cache.get(&id(1)), Lookup::Miss(_))); + assert!(matches!(cache.get(&name("one")), Lookup::Miss(_))); assert_eq!(cache.names(), 0); // And reloading 1 by id does not resurrect the binding. - cache.insert(state(1, None)); - assert!(matches!(cache.get(&name("one")), Lookup::Miss)); + cache.load(state(1, None)); + assert!(matches!(cache.get(&name("one")), Lookup::Miss(_))); } /// Bindings never outnumber the keysets they name: churning names @@ -460,8 +570,8 @@ mod tests { fn the_name_index_is_bounded_by_the_cache() { let mut cache = cache(1); for i in 1..=1000u128 { - cache.insert(state(i, Some(&format!("tenant-{i}")))); - cache.insert(state(i, None)); + cache.load(state(i, Some(&format!("tenant-{i}")))); + cache.load(state(i, None)); } assert_eq!(cache.len(), 1); assert_eq!(cache.names(), 1); @@ -471,33 +581,64 @@ mod tests { ); } - /// A keyset resolved under two names carries both, and both go when it - /// does. + /// A keyset has one name at a time: resolved under a new one, its old + /// name was renamed away and no longer selects it. The index therefore + /// never holds more bindings than keysets, however often one is renamed. #[test] - fn a_keyset_can_be_bound_under_several_names() { + fn a_keysets_newer_name_replaces_its_older_one() { let mut cache = cache(1); - cache.insert(state(1, Some("one"))); - cache.insert(state(1, Some("uno"))); - assert_eq!(hit(cache.get(&name("one"))), Some(Uuid::from_u128(1))); + cache.load(state(1, Some("one"))); + cache.load(state(1, Some("uno"))); assert_eq!(hit(cache.get(&name("uno"))), Some(Uuid::from_u128(1))); + assert!(matches!(cache.get(&name("one")), Lookup::Miss(_))); + assert_eq!(cache.names(), 1); + + for i in 0..1000 { + cache.load(state(1, Some(&format!("name-{i}")))); + } + assert_eq!(cache.names(), 1); - cache.insert(state(2, None)); + cache.load(state(2, None)); assert_eq!(cache.names(), 0); } + /// Resolutions run outside the lock and their answers land in any + /// order. The binding follows the later lookup: an answer from before a + /// rename that arrives after the answer from after it must not move the + /// name back. Key material is cached by id either way. + #[test] + fn a_binding_follows_the_later_lookup_whichever_answer_lands_first() { + let mut cache = cache(4); + let earlier = ticket(cache.get(&name("acme"))); + let later = ticket(cache.get(&name("acme"))); + + cache.insert(state(2, Some("acme")), later); + cache.insert(state(1, Some("acme")), earlier); + + assert_eq!(hit(cache.get(&name("acme"))), Some(Uuid::from_u128(2))); + assert!(matches!(cache.get(&id(1)), Lookup::Hit(_))); + assert_eq!(cache.names(), 1); + + // In order, the later answer moves it as usual. + let next = ticket(cache.get(&name("other"))); + cache.insert(state(1, Some("acme")), next); + assert_eq!(hit(cache.get(&name("acme"))), Some(Uuid::from_u128(1))); + assert_eq!(cache.names(), 1, "2 no longer claims the name"); + } + /// A rename: the name now resolves to another id. The binding moves, /// and evicting the id it used to name does not take it away. #[test] fn a_name_that_moved_to_another_keyset_follows_it() { let mut cache = cache(2); - cache.insert(state(1, Some("acme"))); - cache.insert(state(2, Some("acme"))); + cache.load(state(1, Some("acme"))); + cache.load(state(2, Some("acme"))); assert_eq!(hit(cache.get(&name("acme"))), Some(Uuid::from_u128(2))); // Evict 1 (the oldest): "acme" belongs to 2 now and stays. assert!(matches!(cache.get(&id(2)), Lookup::Hit(_))); - cache.insert(state(3, None)); - assert!(matches!(cache.get(&id(1)), Lookup::Miss)); + cache.load(state(3, None)); + assert!(matches!(cache.get(&id(1)), Lookup::Miss(_))); assert_eq!(hit(cache.get(&name("acme"))), Some(Uuid::from_u128(2))); } @@ -510,19 +651,20 @@ mod tests { Duration::ZERO, state(0, Some("primary")), ); - cache.insert(state(1, Some("one"))); + cache.load(state(1, Some("one"))); - assert!(matches!(cache.get(&name("one")), Lookup::Stale)); - assert!(matches!(cache.get(&name("primary")), Lookup::Stale)); + assert!(matches!(cache.get(&name("one")), Lookup::Stale(_))); + assert!(matches!(cache.get(&name("primary")), Lookup::Stale(_))); assert!( matches!(cache.get(&id(1)), Lookup::Hit(_)), "an id never ages" ); - // A zero window is stale again immediately after a refresh, which is - // the point of a zero window; a wide one is fresh. - cache.insert(state(1, Some("one"))); - assert!(matches!(cache.get(&name("one")), Lookup::Stale)); + // A zero window is stale again immediately after a refresh — with + // no time elapsed at all, on the coarsest clock — which is the point + // of a zero window; a wide one is fresh. + cache.load(state(1, Some("one"))); + assert!(matches!(cache.get(&name("one")), Lookup::Stale(_))); cache.name_ttl = Duration::MAX; assert!(matches!(cache.get(&name("one")), Lookup::Hit(_))); } From bbcc1b27f6db3ba2cd3deeebd30e2961403e7a8a Mon Sep 17 00:00:00 2001 From: Dan Draper <dan@cipherstash.com> Date: Sat, 12 Sep 2026 15:09:44 -0400 Subject: [PATCH 519/686] fix(stack-encrypt): an answer older than the one a keyset holds is dropped whole MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The lookup-generation guard only compared answers for the same name, so a rename could be undone across two names: a selection by the old name starts, the keyset is renamed, a selection by the new name starts and answers first, and the older answer then found no binding for the old name to compare against, rebound it, and dropped the newer one. The name the keyset had been renamed away from — which ZeroKMS may since have given to another keyset — routed here for a whole window. A keyset now records the lookup whose answer last spoke for it (`Entry`, and `default_resolution` for the default), and an insert carrying an older resolution returns without touching either its binding or its state. The per-name guard stays: it is what rejects an older answer for a name that has moved to a different keyset. Also: the target module promised "one batched call" per awaited `Pending`, which is true of `generate_keys` but not of a client-scoped decrypt, where `retrieve_keys` is dispatched once per keyset the leaves were sealed under. The two are now described separately, as they already are on `StackCipher`. Claude-Session: https://claude.ai/code/session_01ECzyss8kvtnrRtq5tmpEbg --- packages/stack-encrypt/src/keyset.rs | 91 ++++++++++++++++++++++-- packages/stack-encrypt/src/target/mod.rs | 6 +- 2 files changed, 91 insertions(+), 6 deletions(-) diff --git a/packages/stack-encrypt/src/keyset.rs b/packages/stack-encrypt/src/keyset.rs index d46236d19..23c68a013 100644 --- a/packages/stack-encrypt/src/keyset.rs +++ b/packages/stack-encrypt/src/keyset.rs @@ -38,7 +38,8 @@ //! renamed away, and that binding goes. And because resolutions run outside //! the lock, their answers can land in any order; a binding follows the //! *later lookup*, whichever answer arrives first, so an answer from before -//! a rename cannot overwrite one from after it. +//! a rename cannot overwrite one from after it — neither under the same +//! name, nor by taking back the name the keyset has since left. use std::collections::HashMap; use std::num::NonZeroUsize; @@ -67,11 +68,13 @@ pub(crate) struct KeysetState { /// A loaded keyset in the cache, with the name it is currently bound under /// if any — kept across replacement so the binding is dropped when the id -/// is evicted, however the entry was last loaded. +/// is evicted, however the entry was last loaded — and the lookup whose +/// answer last spoke for it. struct Entry { state: Arc<KeysetState>, last_used: u64, name: Option<String>, + resolution: Resolution, } /// A name-to-id binding: when ZeroKMS last confirmed it, and which lookup @@ -84,9 +87,10 @@ struct Alias { /// A lookup's place in the order of lookups that went to ZeroKMS. The /// caller carries it from [`get`](KeysetCache::get) to -/// [`insert`](KeysetCache::insert), where a name binding is applied only if -/// this lookup is later than the one that produced the current binding — -/// answers land in any order, and a later question has the later answer. +/// [`insert`](KeysetCache::insert), where an answer is applied only if it is +/// later than the one that already spoke for that keyset, and its name only +/// if it is later than the one that produced that name's binding — answers +/// land in any order, and a later question has the later answer. #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] pub(crate) struct Resolution(u64); @@ -136,6 +140,9 @@ pub(crate) struct KeysetCache { /// The name the default is currently bound under, if any: its /// builder-time name until a resolution binds it under another. default_name: Option<String>, + /// The lookup whose answer last spoke for the default; the builder's + /// own for a cipher that has resolved nothing yet. + default_resolution: Resolution, by_id: HashMap<Uuid, Entry>, by_name: HashMap<String, Alias>, } @@ -170,6 +177,7 @@ impl KeysetCache { tick: 0, resolutions: 0, default_name: default.name.clone(), + default_resolution: Resolution(0), default, by_id: HashMap::new(), by_name, @@ -224,12 +232,24 @@ impl KeysetCache { /// keyset has one name, so binding it under a new name drops the old /// one; and a name that moved to this keyset is dropped from the keyset /// it used to name. No binding outlives the id it names. + /// + /// An answer older than the one this keyset already holds is dropped + /// whole. It has nothing newer to say about the keyset, and applying it + /// would undo what a later lookup applied — restoring, under a full + /// window, a name the keyset has since been renamed away from. pub(crate) fn insert(&mut self, state: Arc<KeysetState>, resolution: Resolution) { + if self + .last_resolution_of(state.id) + .is_some_and(|applied| applied > resolution) + { + return; + } let bound = match &state.name { Some(name) => self.bind(name, state.id, resolution), None => false, }; if state.id == self.default.id { + self.default_resolution = resolution; return; } if !self.by_id.contains_key(&state.id) && self.by_id.len() >= self.capacity.get() { @@ -243,6 +263,7 @@ impl KeysetCache { } entry.state = state; entry.last_used = self.tick; + entry.resolution = resolution; } None => { let name = bound.then(|| state.name.clone()).flatten(); @@ -252,6 +273,7 @@ impl KeysetCache { state, last_used: self.tick, name, + resolution, }, ); } @@ -289,6 +311,16 @@ impl KeysetCache { true } + /// The lookup whose answer last spoke for `id`, if the cache holds it. + /// An id it has never held (or has evicted) has nothing to supersede. + fn last_resolution_of(&self, id: Uuid) -> Option<Resolution> { + if id == self.default.id { + Some(self.default_resolution) + } else { + self.by_id.get(&id).map(|entry| entry.resolution) + } + } + /// The name `id` is currently bound under, if any. fn current_name_of(&self, id: Uuid) -> Option<String> { if id == self.default.id { @@ -626,6 +658,55 @@ mod tests { assert_eq!(cache.names(), 1, "2 no longer claims the name"); } + /// The same race with the two lookups asking *different* names, which + /// is the shape a rename actually takes: a selection by the old name + /// starts, the keyset is renamed, a selection by the new name starts + /// and answers first. The older answer must not take the old name back + /// — it would route that name, which ZeroKMS may have given to another + /// keyset, here for a whole window. + #[test] + fn an_older_answer_does_not_restore_a_name_the_keyset_has_left() { + let mut cache = cache(4); + cache.load(state(1, Some("acme"))); + + cache.name_ttl = Duration::ZERO; + let earlier = ticket(cache.get(&name("acme"))); + let later = ticket(cache.get(&name("acme-corp"))); + cache.name_ttl = Duration::MAX; + + cache.insert(state(1, Some("acme-corp")), later); + cache.insert(state(1, Some("acme")), earlier); + + assert_eq!(hit(cache.get(&name("acme-corp"))), Some(Uuid::from_u128(1))); + assert!( + matches!(cache.get(&name("acme")), Lookup::Miss(_)), + "the name the keyset was renamed away from is not bound again" + ); + assert_eq!(cache.names(), 1); + } + + /// And the default keyset, held apart from the bound, orders its + /// answers the same way. + #[test] + fn the_defaults_binding_also_follows_the_later_lookup() { + let mut cache = KeysetCache::new( + NonZeroUsize::new(4).unwrap(), + Duration::ZERO, + state(0, Some("primary")), + ); + + let earlier = ticket(cache.get(&name("primary"))); + let later = ticket(cache.get(&name("main"))); + cache.name_ttl = Duration::MAX; + + cache.insert(state(0, Some("main")), later); + cache.insert(state(0, Some("primary")), earlier); + + assert_eq!(hit(cache.get(&name("main"))), Some(Uuid::from_u128(0))); + assert!(matches!(cache.get(&name("primary")), Lookup::Miss(_))); + assert_eq!(cache.names(), 1); + } + /// A rename: the name now resolves to another id. The binding moves, /// and evicting the id it used to name does not take it away. #[test] diff --git a/packages/stack-encrypt/src/target/mod.rs b/packages/stack-encrypt/src/target/mod.rs index 5d502377b..ba4040f70 100644 --- a/packages/stack-encrypt/src/target/mod.rs +++ b/packages/stack-encrypt/src/target/mod.rs @@ -60,7 +60,11 @@ //! and [`StackCipher`] (the decrypt target — a sealed leaf names its own //! keyset, so opening is not keyset-scoped; a `KeysetCipher` decrypts too, //! refusing leaves from any other keyset) return a [`Pending`], which does -//! its ZeroKMS I/O — **one batched call** — when awaited. +//! its ZeroKMS I/O when awaited, batched whatever the request count: +//! **one** `generate_keys` call for everything sealed — one keyset by +//! construction, since that is what the handle binds — and **one** +//! `retrieve_keys` call *per keyset* the leaves being opened were sealed +//! under, which is one call unless a client-scoped decrypt spans keysets. //! //! # Contexts //! From e743eb5e65bebc40ed37d5abaf74f9fa1586cb05 Mon Sep 17 00:00:00 2001 From: Dan Draper <dan@cipherstash.com> Date: Sat, 12 Sep 2026 15:25:55 -0400 Subject: [PATCH 520/686] fix(stack-encrypt)!: seal `CipherScope` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A scope is how the target layer asks a cipher what it is bound to, not an extension point: its keyset id is one the cipher loaded from ZeroKMS, and `Pending`'s scope rules (`NoKeyset`, `ForeignKeyset`) are stated in those terms. An outside implementation could name any id without holding the keyset, which makes those rules say less than they do, and pins a type that is an implementation detail. Sealed with the usual private supertrait. Downstream code implements `EncryptFrom`/`DecryptInto` and passes the scope it was handed — `&StackCipher` or `&KeysetCipher` — so nothing outside the crate needs a scope of its own. Pinned by `tests/ui/foreign_cipher_scope.rs`. Not a way to mint keys that ZeroKMS would not have minted: the keyset id travels with the client token and ZeroKMS authorises it, and an index key (the PRF a term derives under) is only reachable through `StackCipher::keyset`, which loads the keyset. A fabricated scope could only name a keyset the client already holds, which `StackCipher::keyset` hands over legitimately. BREAKING CHANGE: `CipherScope` can no longer be implemented outside this crate. Pass `&StackCipher` or `&KeysetCipher` instead. Claude-Session: https://claude.ai/code/session_01ECzyss8kvtnrRtq5tmpEbg --- packages/stack-encrypt/src/target/pending.rs | 17 ++++++++++++++- .../tests/ui/foreign_cipher_scope.rs | 21 +++++++++++++++++++ .../tests/ui/foreign_cipher_scope.stderr | 18 ++++++++++++++++ 3 files changed, 55 insertions(+), 1 deletion(-) create mode 100644 packages/stack-encrypt/tests/ui/foreign_cipher_scope.rs create mode 100644 packages/stack-encrypt/tests/ui/foreign_cipher_scope.stderr diff --git a/packages/stack-encrypt/src/target/pending.rs b/packages/stack-encrypt/src/target/pending.rs index f719d2262..300882211 100644 --- a/packages/stack-encrypt/src/target/pending.rs +++ b/packages/stack-encrypt/src/target/pending.rs @@ -73,13 +73,28 @@ pub struct Pending<'a, T, K> { /// What a [`Pending`] is built through: a [`StackCipher`] (no keyset /// scope) or a [`KeysetCipher`] (scoped to its keyset). Implemented for /// references to both, so the constructors take either. -pub trait CipherScope<'a, K> { +/// +/// Sealed: the two scopes are the two shapes, and a scope is how the target +/// layer asks a cipher what it is bound to — not an extension point. An +/// outside implementation could name any keyset id without holding the +/// keyset, which would make [`Pending`]'s scope rules +/// ([`Error::NoKeyset`], [`Error::ForeignKeyset`]) say less than they do: +/// a scope's id is one the cipher loaded from ZeroKMS. Downstream code +/// implements [`EncryptFrom`](super::EncryptFrom) and passes the scope it +/// was handed; it never needs one of its own. +pub trait CipherScope<'a, K>: sealed::Sealed { /// The client-scoped cipher the pending settles through. fn cipher(&self) -> &'a StackCipher<K>; /// The keyset the pending is scoped to, if any. fn keyset(&self) -> Option<Uuid>; } +mod sealed { + pub trait Sealed {} + impl<K> Sealed for &crate::StackCipher<K> {} + impl<K> Sealed for &crate::KeysetCipher<'_, K> {} +} + impl<'a, K> CipherScope<'a, K> for &'a StackCipher<K> { fn cipher(&self) -> &'a StackCipher<K> { self diff --git a/packages/stack-encrypt/tests/ui/foreign_cipher_scope.rs b/packages/stack-encrypt/tests/ui/foreign_cipher_scope.rs new file mode 100644 index 000000000..4d5ac50f1 --- /dev/null +++ b/packages/stack-encrypt/tests/ui/foreign_cipher_scope.rs @@ -0,0 +1,21 @@ +//! `CipherScope` is sealed: a scope names a keyset the cipher loaded from +//! ZeroKMS, so an outside crate cannot invent one that claims an id it does +//! not hold. + +use stack_encrypt::target::CipherScope; +use stack_encrypt::StackCipher; +use uuid::Uuid; + +struct AnyKeyset<'a, K>(&'a StackCipher<K>, Uuid); + +impl<'a, K> CipherScope<'a, K> for AnyKeyset<'a, K> { + fn cipher(&self) -> &'a StackCipher<K> { + self.0 + } + + fn keyset(&self) -> Option<Uuid> { + Some(self.1) + } +} + +fn main() {} diff --git a/packages/stack-encrypt/tests/ui/foreign_cipher_scope.stderr b/packages/stack-encrypt/tests/ui/foreign_cipher_scope.stderr new file mode 100644 index 000000000..518941452 --- /dev/null +++ b/packages/stack-encrypt/tests/ui/foreign_cipher_scope.stderr @@ -0,0 +1,18 @@ +error[E0277]: the trait bound `AnyKeyset<'a, K>: target::pending::sealed::Sealed` is not satisfied + --> tests/ui/foreign_cipher_scope.rs:11:36 + | +11 | impl<'a, K> CipherScope<'a, K> for AnyKeyset<'a, K> { + | ^^^^^^^^^^^^^^^^ the trait `target::pending::sealed::Sealed` is not implemented for `AnyKeyset<'a, K>` + | + = help: the following other types implement trait `target::pending::sealed::Sealed`: + &KeysetCipher<'_, K> + &StackCipher<K> +note: required by a bound in `CipherScope` + --> src/target/pending.rs + | + | pub trait CipherScope<'a, K>: sealed::Sealed { + | ^^^^^^^^^^^^^^ required by this bound in `CipherScope` + = note: `CipherScope` is a "sealed trait", because to implement it you also need to implement `stack_encrypt::target::pending::sealed::Sealed`, which is not accessible; this is usually done to force you to use one of the provided types that already implement it + = help: the following types implement the trait: + &stack_encrypt::StackCipher<K> + &stack_encrypt::KeysetCipher<'_, K> From 9ee94d801cd937786dc76847b2dad3f6e6d836b8 Mon Sep 17 00:00:00 2001 From: Dan Draper <dan@cipherstash.com> Date: Sat, 12 Sep 2026 16:01:32 -0400 Subject: [PATCH 521/686] fix(stack-encrypt): eviction leaves a watermark, so an older answer cannot rebind a forgotten name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A keyset carries the order of the answers that spoke for it, and a name binding carries the lookup that made it; between them an answer from before a rename cannot take a name back from the answer after it. Eviction dropped both. With the cache full, a lookup for a name could still be in flight while a later lookup bound that name to another keyset and that keyset was then evicted — and the older answer, finding no entry for either id and no binding for the name, bound it, sending that tenant's name at the keyset it had been renamed away from for a whole window. Eviction now records the place in the order of the binding it drops, and no binding is made from an answer older than that. One watermark for all names, not a record per name forgotten: a cache whose whole contract is a bound must not grow one. It therefore also refuses some bindings an older lookup could safely have made, which costs a round trip on the next selection by such a name — in the eviction regime already paying them. Key material still caches either way: an id's keys are the same whichever lookup asked. Pinned by `an_older_answer_does_not_bind_a_name_eviction_has_forgotten`, which fails with the watermark check removed. Claude-Session: https://claude.ai/code/session_01QbNwRNqa1qo3b8ZSqULsGE --- packages/stack-encrypt/src/keyset.rs | 77 ++++++++++++++++++++++++---- 1 file changed, 66 insertions(+), 11 deletions(-) diff --git a/packages/stack-encrypt/src/keyset.rs b/packages/stack-encrypt/src/keyset.rs index 23c68a013..49db394d1 100644 --- a/packages/stack-encrypt/src/keyset.rs +++ b/packages/stack-encrypt/src/keyset.rs @@ -39,7 +39,8 @@ //! the lock, their answers can land in any order; a binding follows the //! *later lookup*, whichever answer arrives first, so an answer from before //! a rename cannot overwrite one from after it — neither under the same -//! name, nor by taking back the name the keyset has since left. +//! name, nor by taking back the name the keyset has since left, nor by +//! arriving after eviction has dropped the binding it would have lost to. use std::collections::HashMap; use std::num::NonZeroUsize; @@ -128,7 +129,9 @@ pub(crate) enum Lookup { /// `O(n)` in the bound, which is the rare case by construction. The name /// index is bounded by the entries it serves: one binding per cached id at /// most, plus the default's, and a binding goes when its id does or when -/// the keyset is resolved under another name. +/// the keyset is resolved under another name. What an evicted binding +/// leaves behind is one watermark, not a record per name: see +/// [`evicted_binding`](Self::evicted_binding). pub(crate) struct KeysetCache { capacity: NonZeroUsize, name_ttl: Duration, @@ -143,6 +146,18 @@ pub(crate) struct KeysetCache { /// The lookup whose answer last spoke for the default; the builder's /// own for a cipher that has resolved nothing yet. default_resolution: Resolution, + /// The latest lookup whose name binding eviction dropped. + /// + /// A keyset carries the order of the answers that spoke for it; evicting + /// it drops that with the rest of the entry, and an answer older than the + /// binding that went would then find nothing left to say it is the older + /// one. So no binding is made from an answer older than this. It is one + /// watermark for all names rather than one per forgotten name — a cache + /// whose whole contract is a bound must not grow a record per name it has + /// evicted — so it also refuses some bindings an older lookup could have + /// made safely. That costs a round trip on the next selection by such a + /// name, in the eviction regime that is already paying them. + evicted_binding: Resolution, by_id: HashMap<Uuid, Entry>, by_name: HashMap<String, Alias>, } @@ -178,6 +193,7 @@ impl KeysetCache { resolutions: 0, default_name: default.name.clone(), default_resolution: Resolution(0), + evicted_binding: Resolution(0), default, by_id: HashMap::new(), by_name, @@ -281,9 +297,16 @@ impl KeysetCache { } /// Bind `name` to `id` for the lookup `resolution`; false if a later - /// lookup already bound it. Also unbinds the name this id was bound under - /// before, and unbinds this name from the id it named before. + /// lookup already bound it, or if eviction has since dropped a binding + /// this answer is older than ([`evicted_binding`]). Also unbinds the name + /// this id was bound under before, and unbinds this name from the id it + /// named before. + /// + /// [`evicted_binding`]: Self::evicted_binding fn bind(&mut self, name: &str, id: Uuid, resolution: Resolution) -> bool { + if resolution < self.evicted_binding { + return false; + } if let Some(alias) = self.by_name.get(name) { if alias.resolution > resolution { return false; @@ -355,13 +378,15 @@ impl KeysetCache { if let Some(entry) = self.by_id.remove(&oldest) { if let Some(name) = entry.name { // A name that has since moved to another id keeps its - // binding: only this id's binding goes with it. - if self - .by_name - .get(&name) - .is_some_and(|alias| alias.id == oldest) - { - let _ = self.by_name.remove(&name); + // binding: only this id's binding goes with it. Its place in + // the order of lookups outlives it as a watermark, so an + // answer older than it cannot bind a name once there is no + // entry left to order it against. + if let Some(alias) = self.by_name.get(&name) { + if alias.id == oldest { + self.evicted_binding = self.evicted_binding.max(alias.resolution); + let _ = self.by_name.remove(&name); + } } } } @@ -723,6 +748,36 @@ mod tests { assert_eq!(hit(cache.get(&name("acme"))), Some(Uuid::from_u128(2))); } + /// Eviction must not lose the order either: the keyset the later answer + /// named can be evicted — taking the binding, and the entry that ordered + /// it — while the earlier answer is still in flight. Landing in a cache + /// that holds neither id and no binding for the name, it must still not + /// bind the name it asked under. + #[test] + fn an_older_answer_does_not_bind_a_name_eviction_has_forgotten() { + let mut cache = cache(1); + let earlier = ticket(cache.get(&name("acme"))); + let later = ticket(cache.get(&name("acme"))); + + cache.insert(state(2, Some("acme")), later); + // 2 is evicted, and "acme" goes with it. + cache.load(state(3, None)); + assert_eq!(cache.names(), 0); + + cache.insert(state(1, Some("acme")), earlier); + assert!( + matches!(cache.get(&name("acme")), Lookup::Miss(_)), + "the name the later lookup moved away is not taken back" + ); + assert_eq!(cache.names(), 0); + + // A lookup later than the evicted binding still binds: the watermark + // does not close the name index for good. + let next = ticket(cache.get(&name("acme"))); + cache.insert(state(1, Some("acme")), next); + assert_eq!(hit(cache.get(&name("acme"))), Some(Uuid::from_u128(1))); + } + /// Past the window a name lookup is stale — the keyset is still there, /// the binding is not trusted — and a re-resolution refreshes it. #[test] From 0f4bbfdef7f8fcc7169bfd5da9cb6c6755284ed9 Mon Sep 17 00:00:00 2001 From: Dan Draper <dan@cipherstash.com> Date: Sat, 12 Sep 2026 16:32:35 -0400 Subject: [PATCH 522/686] fix(stack-encrypt): the watermark eviction leaves is the entry's, not its binding's MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hanging it on the binding left it behind only when the evicted keyset still owned a name, and a keyset whose name has already moved to another keyset is precisely the one an older answer would rebind. Three lookups resolve `old` to keyset 1, then `new` to keyset 1, then `new` to keyset 2; binding the last takes `new` off keyset 1, caching it evicts keyset 1 with no name of its own, and the first answer — from before either rename — lands into a cache with nothing left to order it against and binds `old` for a whole window. Every eviction now leaves the entry's own place in the order. That covers the case the binding covered and this one: an entry's bindings never sat later in the order than the entry, since the insert that binds a name is the insert that stamps the entry. Pinned by `an_evicted_keyset_leaves_its_place_in_the_order_with_or_without_a_name`; both it and `an_older_answer_does_not_bind_a_name_eviction_has_forgotten` fail without the line, so the one watermark carries both. Claude-Session: https://claude.ai/code/session_01QbNwRNqa1qo3b8ZSqULsGE --- packages/stack-encrypt/src/keyset.rs | 86 +++++++++++++++++++++------- 1 file changed, 64 insertions(+), 22 deletions(-) diff --git a/packages/stack-encrypt/src/keyset.rs b/packages/stack-encrypt/src/keyset.rs index 49db394d1..a38c59129 100644 --- a/packages/stack-encrypt/src/keyset.rs +++ b/packages/stack-encrypt/src/keyset.rs @@ -40,7 +40,7 @@ //! *later lookup*, whichever answer arrives first, so an answer from before //! a rename cannot overwrite one from after it — neither under the same //! name, nor by taking back the name the keyset has since left, nor by -//! arriving after eviction has dropped the binding it would have lost to. +//! arriving after eviction has forgotten the answer it would have lost to. use std::collections::HashMap; use std::num::NonZeroUsize; @@ -129,8 +129,8 @@ pub(crate) enum Lookup { /// `O(n)` in the bound, which is the rare case by construction. The name /// index is bounded by the entries it serves: one binding per cached id at /// most, plus the default's, and a binding goes when its id does or when -/// the keyset is resolved under another name. What an evicted binding -/// leaves behind is one watermark, not a record per name: see +/// the keyset is resolved under another name. What an evicted entry leaves +/// behind is one watermark, not a record per name: see /// [`evicted_binding`](Self::evicted_binding). pub(crate) struct KeysetCache { capacity: NonZeroUsize, @@ -146,17 +146,25 @@ pub(crate) struct KeysetCache { /// The lookup whose answer last spoke for the default; the builder's /// own for a cipher that has resolved nothing yet. default_resolution: Resolution, - /// The latest lookup whose name binding eviction dropped. + /// The latest lookup whose answer eviction has forgotten. /// - /// A keyset carries the order of the answers that spoke for it; evicting - /// it drops that with the rest of the entry, and an answer older than the - /// binding that went would then find nothing left to say it is the older - /// one. So no binding is made from an answer older than this. It is one - /// watermark for all names rather than one per forgotten name — a cache - /// whose whole contract is a bound must not grow a record per name it has - /// evicted — so it also refuses some bindings an older lookup could have - /// made safely. That costs a round trip on the next selection by such a - /// name, in the eviction regime that is already paying them. + /// A keyset carries the order of the answers that spoke for it, and a + /// binding the order of the lookup that made it; evicting the keyset + /// drops both, and an answer older than what went would then find + /// nothing left to say it is the older one. So every eviction leaves the + /// entry's place here — its own bindings never sat later in the order + /// than it does, since the insert that binds a name is the insert that + /// stamps the entry — and no binding is made from an answer older than + /// this. The name is the only thing an answer too old to order can get + /// wrong: an id's key material is the same whichever lookup asked, so it + /// still caches. + /// + /// It is one watermark for all names rather than one per forgotten name + /// — a cache whose whole contract is a bound must not grow a record per + /// name it has evicted — so it also refuses some bindings an older + /// lookup could have made safely. That costs a round trip on the next + /// selection by such a name, in the eviction regime that is already + /// paying them. evicted_binding: Resolution, by_id: HashMap<Uuid, Entry>, by_name: HashMap<String, Alias>, @@ -376,17 +384,23 @@ impl KeysetCache { return; }; if let Some(entry) = self.by_id.remove(&oldest) { + // The entry's place in the order outlives it as a watermark: + // once it is gone there is nothing left to order an older answer + // for this keyset against. It is taken whether or not the entry + // still owns a name — a keyset whose name has already moved to + // another keyset is precisely the one an older answer would + // rebind, and the binding it would have lost to is no longer + // here to say so. + self.evicted_binding = self.evicted_binding.max(entry.resolution); if let Some(name) = entry.name { // A name that has since moved to another id keeps its - // binding: only this id's binding goes with it. Its place in - // the order of lookups outlives it as a watermark, so an - // answer older than it cannot bind a name once there is no - // entry left to order it against. - if let Some(alias) = self.by_name.get(&name) { - if alias.id == oldest { - self.evicted_binding = self.evicted_binding.max(alias.resolution); - let _ = self.by_name.remove(&name); - } + // binding: only this id's binding goes with it. + if self + .by_name + .get(&name) + .is_some_and(|alias| alias.id == oldest) + { + let _ = self.by_name.remove(&name); } } } @@ -778,6 +792,34 @@ mod tests { assert_eq!(hit(cache.get(&name("acme"))), Some(Uuid::from_u128(1))); } + /// An entry carries its place in the order whether or not it still owns + /// a name, and eviction must leave that place behind either way. A + /// keyset whose name has already moved to another keyset is exactly the + /// one an old answer would rebind: here three lookups resolve `old` to + /// keyset 1, then `new` to keyset 1, then `new` to keyset 2, and the + /// first answer — from before either rename — lands last, into a cache + /// that evicted keyset 1 after taking `new` off it. + #[test] + fn an_evicted_keyset_leaves_its_place_in_the_order_with_or_without_a_name() { + let mut cache = cache(1); + let oldest = ticket(cache.get(&name("old"))); + let middle = ticket(cache.get(&name("new"))); + let newest = ticket(cache.get(&name("new"))); + + // `new` meant 1, then 2: binding the later answer takes the name off + // 1, and caching 2 evicts 1 with no name of its own to leave behind. + cache.insert(state(1, Some("new")), middle); + cache.insert(state(2, Some("new")), newest); + assert_eq!(hit(cache.get(&name("new"))), Some(Uuid::from_u128(2))); + + cache.insert(state(1, Some("old")), oldest); + assert!( + matches!(cache.get(&name("old")), Lookup::Miss(_)), + "a name from before two renames is not bound by the answer that lands last" + ); + assert_eq!(cache.names(), 0); + } + /// Past the window a name lookup is stale — the keyset is still there, /// the binding is not trusted — and a re-resolution refreshes it. #[test] From ac4f958caca3a7399c35323610e585da8010d43b Mon Sep 17 00:00:00 2001 From: Dan Draper <dan@cipherstash.com> Date: Sat, 12 Sep 2026 18:43:56 -0400 Subject: [PATCH 523/686] fix(stack-encrypt)!: two ciphers over one keyset merge; `Error` is non-exhaustive, `Pending` is must-use Review (CIP-4037 part 1, cipherstash/cipherstash-suite#2211). `Pending::zip` and `Pending::all` gated on pointer equality of the two ciphers before comparing keysets, so two `StackCipher` values over the same client and keyset were refused with `CipherMismatch`. Every request in an assembly is keyset-addressed and a keyset id is global, so the keyset is the whole merge rule: `CipherMismatch` is removed and `KeysetMismatch` is the one refusal. An unauthorised client is refused at ZeroKMS, as it would be alone. `Error` gains `#[non_exhaustive]`; this branch already added four variants to it. `Pending` gains `#[must_use]`, since the term API now returns one and an un-awaited probe compiled silently. `StackCipher` and `KeysetCipher` get an opaque `Debug` that prints only the keyset identity, never the PRF or client key. The last "terms never touch ZeroKMS" wording comes out of the guest and the Go plan (ZeroKMS v2 derives terms at the server). Housekeeping from the same review: the default keyset is an `Entry` like any other, so the by-id branches collapse; `evicted_binding` is `eviction_watermark`, which is what it has been since ea2744b23; `keyset()` and `init()` share `load_keyset()`; the two decrypt paths share one definition; the retrieve tuple is a `Retrieve` struct; every new assertion carries a message. `CONTEXT.md` gains the keyset vocabulary and ADR 0002 records the in-place leaf layout change and the name-binding decisions. BREAKING CHANGE: `Error::CipherMismatch` is gone; match `Error::KeysetMismatch { left, right }` instead. `Error` is now `#[non_exhaustive]`, so matches on it need a wildcard arm. Claude-Session: https://claude.ai/code/session_01QbNwRNqa1qo3b8ZSqULsGE --- docs/plans/stack-encrypt-go-bindings.md | 8 +- .../golang/stackencrypt/guest/src/ops.rs | 6 +- .../golang/stackencrypt/guest/src/status.rs | 5 +- packages/stack-encrypt/CONTEXT.md | 66 +++ ...n-the-leaf-and-names-as-bounded-lookups.md | 105 +++++ packages/stack-encrypt/src/cipher.rs | 131 +++--- packages/stack-encrypt/src/keyset.rs | 396 +++++++++++++----- packages/stack-encrypt/src/target/pending.rs | 320 ++++++++++---- packages/stack-encrypt/tests/keysets.rs | 104 ++++- packages/stack-encrypt/tests/target.rs | 75 +++- 10 files changed, 939 insertions(+), 277 deletions(-) create mode 100644 packages/stack-encrypt/docs/adr/0002-keyset-id-in-the-leaf-and-names-as-bounded-lookups.md diff --git a/docs/plans/stack-encrypt-go-bindings.md b/docs/plans/stack-encrypt-go-bindings.md index 24d3ac692..c1575eb6f 100644 --- a/docs/plans/stack-encrypt-go-bindings.md +++ b/docs/plans/stack-encrypt-go-bindings.md @@ -21,8 +21,10 @@ matrix. The proof is a Go program that, against a real ZeroKMS: 1. encrypts a slice of records (ciphertext + equality + ORE term per field) in **one** `generate-data-key` call, 2. decrypts them back in one `retrieve-data-key` call, -3. builds a query probe term locally (no ZeroKMS call) that equals the stored - term, and +3. builds a query probe term that equals the stored term (under the local + HMAC backend that derivation needs no ZeroKMS call; under a backend that + derives terms at the server — ZeroKMS v2 — the probe settles through the + same batched call the record path uses), and 4. round-trips ciphertexts and terms with the native Rust example (`encrypted_record.rs`) in both directions. @@ -386,7 +388,7 @@ via the registry, packed `u64` results, status in the low word on error): | `se_encrypt_element` / `se_decrypt_element` | as vitaminc; row-at-a-time interop with batch-encrypted slices | | `se_encrypt_record(handle, source, plan, aad)` | the runtime form of `#[derive(EncryptFrom)]`: `plan` is an `FfiValue` object `{ field → { context, outputs: [c \| eq \| match(opts) \| ore \| ope] } }`; per field the guest dispatches on the source `FfiValue` variant to the typed `EncryptFrom` impls (`u32`/`u64`/`i64`/`f64`/`String`), zips the pendings, `Pending::all` across an array source, and returns `{ field → { c: leaf, hm: bytes, ob: bytes, … } }`. One `generate_keys` call per invocation regardless of row count. | | `se_decrypt_record(handle, record, plan, aad)` | inverse; only the `c` outputs participate | -| `se_term(handle, value, context, kind)` | query probe; `context` is codec-encoded in the plan-field grammar — one part (a string, bytes, or an `i32`/`i64`/`u32`/`u64`) or an array of parts, nested as deep as the transport codec allows (`vitaminc_aead_value::transport::MAX_DEPTH`, 128 levels from the root of the encoded value; deeper is `STATUS_ENCODING` before the context is parsed, not an interop bug). Shape is identity: `[x]` is not `x`, so a probe passes the context in exactly the shape the field was sealed under (the guest's `context` module is the one home of the grammar and of which Rust context each shape spells). Local PRF/ORE only, never touches ZeroKMS | +| `se_term(handle, value, context, kind)` | query probe; `context` is codec-encoded in the plan-field grammar — one part (a string, bytes, or an `i32`/`i64`/`u32`/`u64`) or an array of parts, nested as deep as the transport codec allows (`vitaminc_aead_value::transport::MAX_DEPTH`, 128 levels from the root of the encoded value; deeper is `STATUS_ENCODING` before the context is parsed, not an interop bug). Shape is identity: `[x]` is not `x`, so a probe passes the context in exactly the shape the field was sealed under (the guest's `context` module is the one home of the grammar and of which Rust context each shape spells). PRF/ORE derivation: no ZeroKMS traffic under the local HMAC backend, a batched request under one that derives terms at the server | Host imports (two, both from the `cipherstash_transport` module #2099 defined): diff --git a/languages/golang/stackencrypt/guest/src/ops.rs b/languages/golang/stackencrypt/guest/src/ops.rs index 64f753546..12771674e 100644 --- a/languages/golang/stackencrypt/guest/src/ops.rs +++ b/languages/golang/stackencrypt/guest/src/ops.rs @@ -27,8 +27,10 @@ //! the field values. However many rows and fields are in one call, all //! ciphertext leaves seal from **one** batched `generate_keys` — the //! pendings are merged before settling, exactly like the derive's `zip`/`all` -//! composition — and index terms derive locally with no ZeroKMS traffic at -//! all. That batch reaches ZeroKMS as one request per +//! composition — and index terms derive under the same keyset's index key +//! (under the local HMAC backend with no ZeroKMS traffic of their own; a +//! backend that derives terms at ZeroKMS, as ZeroKMS v2 does, adds its own +//! requests to the same batch). That batch reaches ZeroKMS as one request per //! `ClientOpts::max_keys_per_req` keyed leaves (500 by default, sent //! sequentially: the guest pins `max_concurrent_reqs` to 1), so "one call" //! is exact up to 500 leaves and "one call per 500" past it. See diff --git a/languages/golang/stackencrypt/guest/src/status.rs b/languages/golang/stackencrypt/guest/src/status.rs index 980f89921..12b315efb 100644 --- a/languages/golang/stackencrypt/guest/src/status.rs +++ b/languages/golang/stackencrypt/guest/src/status.rs @@ -70,8 +70,9 @@ pub const STATUS_TERM: u32 = 11; /// Map a sealing/opening error onto the ABI status word. /// -/// Total over [`stack_encrypt::Error`]: composition-bug variants -/// (`ResponseShape`, `CipherMismatch`, `KeyCountMismatch`) and everything +/// Total over [`stack_encrypt::Error`] (which is `#[non_exhaustive]`, so the +/// catch-all arm is required as well as convenient): composition-bug variants +/// (`ResponseShape`, `KeysetMismatch`, `KeyCountMismatch`) and everything /// else unexpected collapse into [`STATUS_INTERNAL`] — statuses distinguish /// what a host can act on, not what it can only log. pub fn status_for_error(error: &stack_encrypt::Error) -> u32 { diff --git a/packages/stack-encrypt/CONTEXT.md b/packages/stack-encrypt/CONTEXT.md index e712205dc..c40427169 100644 --- a/packages/stack-encrypt/CONTEXT.md +++ b/packages/stack-encrypt/CONTEXT.md @@ -71,3 +71,69 @@ An output whose local work (term derivation, per-leaf sealing plan) is done and whose ZeroKMS key requests are queued but not sent. Pendings compose (`zip`, `map`, `all`) so a whole struct or `Vec` settles in one batched call. _Avoid_: future, promise + +**Keyset**: +The ZeroKMS key domain a data key is minted under and an index key belongs +to — one per tenant is the common shape. A client may use any number; +`StackCipher` is scoped to the client, not to a keyset. Its **id** (a UUID) +is its identity: globally unique, carried in every sealed leaf, never +re-checked. +_Avoid_: dataset, key ring, tenant (a tenant *has* a keyset) + +**Keyset cipher**: +`KeysetCipher`, the cipher bound to one keyset, and what every operation +that *mints* binds to — sealing values, sealing records, deriving terms. +An owned handle (a cipher reference plus the keyset's loaded state), cheap +to clone and to hold per request. Decrypting through one is a *constraint*, +not a capability: it refuses a leaf from any other keyset. +_Avoid_: keyset handle (use "handle" only for the object, not the concept), +sub-cipher, tenant cipher + +**Scope**: +What a `Pending` was built through, and therefore what it is allowed to do: +a `KeysetCipher` scope mints under its keyset and opens leaves from no +other; a `StackCipher` scope mints nothing and opens leaves from any keyset. +`CipherScope` is the sealed trait both references implement. Two pendings +merge when their scopes agree on a keyset — which cipher *value* each came +from is not part of the rule. +_Avoid_: binding (that is a name's), context (that is the AAD's) + +**Name binding**: +The cache's record that a keyset name resolved to a keyset id, and when. +A name is a *lookup ZeroKMS answers*, not an identity — ZeroKMS allows +renames — so a binding is trusted only within a window, a keyset holds at +most one at a time, and no binding outlives the id it names. +_Avoid_: alias (the struct is called `Alias`; the concept is a binding), +name cache entry + +**Freshness window**: +How long a name binding is trusted before the next selection by that name +asks ZeroKMS again (`DEFAULT_NAME_TTL`, five minutes; +`StackCipherBuilder::keyset_name_ttl`). It bounds how long a rename can go +unnoticed by a running process, the way a resolver's TTL does; `ZERO` makes +every selection by name a round trip. Selection by id has no window. +_Avoid_: cache expiry, staleness (a binding past its window is *stale*, the +window itself is not) + +**Resolution ticket**: +A monotonic stamp (`Resolution`) a lookup takes on its way to ZeroKMS and +hands back on insert. Resolutions run outside the cache lock, so answers +land in any order; the ticket is what says which *question* was later, and a +binding follows the later question rather than the earlier arrival. +_Avoid_: generation, version, sequence number + +**Eviction watermark**: +The place in the resolution order of the latest entry eviction has dropped. +Once an entry is gone there is nothing left to order an older answer for +that keyset against, so no binding is made from an answer older than the +watermark. One watermark for every name, not one per forgotten name — a +cache whose whole contract is a bound must not grow a record per eviction. +_Avoid_: tombstone, evicted binding (it is the entry's place, not a +binding's) + +**Foreign keyset**: +A keyset other than the one a `KeysetCipher` is bound to, from that +handle's point of view. Handing it a leaf sealed under one is +`Error::ForeignKeyset`, refused before any key is retrieved — the +guarantee a tenant-scoped handler asked for by taking a handle. +_Avoid_: wrong keyset, other tenant diff --git a/packages/stack-encrypt/docs/adr/0002-keyset-id-in-the-leaf-and-names-as-bounded-lookups.md b/packages/stack-encrypt/docs/adr/0002-keyset-id-in-the-leaf-and-names-as-bounded-lookups.md new file mode 100644 index 000000000..2143771f9 --- /dev/null +++ b/packages/stack-encrypt/docs/adr/0002-keyset-id-in-the-leaf-and-names-as-bounded-lookups.md @@ -0,0 +1,105 @@ +--- +status: accepted +date: 2026-09-12 +--- + +# The keyset id goes in the v1 leaf without a format bump, and a keyset name is a bounded lookup + +CIP-4037 made `StackCipher` client-scoped — one ZeroKMS client, many keysets — +and moved everything that *mints* onto `KeysetCipher`, the cipher bound to one +of them. Two decisions in that change are worth recording, because both trade +something away and neither is obvious from the code alone. + +## 1. The v1 leaf layout gained 16 bytes in place, with no `FORMAT_VERSION` bump + +A `SealedValue` now carries the id of the keyset its data key was minted under: +16 raw UUID bytes immediately after the version byte, ahead of the `iv`. That +is what lets `StackCipher::decrypt` open leaves from any keyset — the leaf says +which keyset to retrieve from, so the caller does not have to — and what lets a +leaf lifted out of its tree, which is what a database column holds, stay +self-describing. + +The field was inserted into the v1 layout and `SealedValue::FORMAT_VERSION` +stayed at `0x01`. Normally that is exactly the change a version byte exists to +mark. Here it is safe, and a bump would have bought nothing: + +- The crate is unpublished (`publish = false`, 0.1.0) and nothing produced by + it is stored anywhere. There is no old-layout leaf in the world to read. +- An old-layout leaf could not be *silently* misread even if one existed. The + keyset id is bound into the leaf AAD alongside the version byte — + `PAE("stack-encrypt/leaf", version, keyset_id, derived_aad, tag)` — so a leaf + sealed under the old derivation fails authentication, rather than parsing + under the wrong rules and yielding plausible bytes. The same binding is what + stops a stored leaf being re-pointed at another keyset. +- The commit is already a breaking change (`!`) for other reasons: + `SealedValue::from_parts` / `into_parts` carry the keyset id first, encrypt + moved to `cipher.default_keyset()`, and `Request::retrieve_data_key` takes a + keyset id. + +So the version byte is spent once, when there is a reader to protect. The next +layout change — after the first release that stores leaves — must bump it. + +## 2. A keyset name is a lookup with a bounded freshness window, not an identity + +A keyset's **id** is its identity: globally unique, carried in every leaf, and +never re-checked once resolved. A **name** is not. ZeroKMS answers a name with +an id and allows a keyset to be renamed, so a name this process resolved +earlier can mean a different keyset later. The cache therefore treats a +name-to-id binding the way a resolver treats a DNS record. + +The rules, each of which exists because the alternative was a live defect +found in review: + +- **A binding is fresh only within a window** (`DEFAULT_NAME_TTL`, five + minutes; `StackCipherBuilder::keyset_name_ttl`; strictly `<`, so + `Duration::ZERO` is never fresh whatever the clock's resolution). After it, + the next selection by that name asks ZeroKMS again and the binding is + refreshed or moved. Within it, a rename is invisible — that is the cost, and + it is bounded. Selection by id is never re-asked. +- **One binding per keyset.** A keyset has one name at a time in ZeroKMS, so + resolving it under a new name means its old name was renamed away, and that + binding goes; a name that moves to another keyset is dropped from the keyset + it used to name. Without this a renamed hot keyset grew the name index + without bound, which is unacceptable in a structure whose whole contract is + a bound. +- **A binding follows the later *lookup*, not the earlier *arrival*.** + Resolutions run outside the cache lock, so their answers land in any order. + Every lookup that goes to ZeroKMS carries a monotonic `Resolution` ticket + from `get` to `insert`, and an answer is applied only if it is later than the + one that already spoke for that keyset or name. +- **An answer older than the one a keyset already holds is dropped whole**, not + just for the name it asked under. Comparing per name only was not enough: a + rename could be undone *across two names* — a selection by the old name + starts, the keyset is renamed, a selection by the new name starts and answers + first, and the older answer then found no binding for the old name to lose to + and rebound it, routing a name ZeroKMS may since have given to another keyset + here for a whole window. +- **Eviction leaves a watermark.** Evicting an entry drops both the keyset's + place in the order and its binding, and an answer older than what went would + then find nothing left to say it is the older one. Every eviction therefore + records the *entry's* place (not its binding's — an entry whose name has + already moved to another keyset is precisely the one an old answer would + rebind), and no binding is made from an answer older than that. It is one + watermark for all names rather than one per forgotten name, which is what + keeps the structure bounded; the price is that it also refuses some bindings + an older lookup could have made safely, costing a round trip on the next + selection by such a name — in the eviction regime that is already paying + them. + +## Consequences + +- **Key material is never the thing at risk.** An id's index key is the same + whichever lookup asked for it, so every rule above governs *names only*; an + answer too old to order still caches its keyset by id. Nothing stored depends + on the cache at all — a leaf carries its keyset id and a term carries nothing + — so eviction is invisible except for the round trip the next lookup pays. +- **A rename is visible within the window, never sooner.** Callers that cannot + tolerate that select by id, or set `keyset_name_ttl(Duration::ZERO)` and pay + a round trip per selection. +- **The default keyset is not a special case in any of this.** Its state never + changes and it never evicts, but its builder-time name ages, moves and + reorders exactly like any other keyset's, and the cache reaches it through + the same accessors. +- **These rules are the cache's, not ZeroKMS's.** ZeroKMS remains the authority + on what a name means and on whether the client may use the keyset at all; the + window only bounds how long this process trusts an answer it already has. diff --git a/packages/stack-encrypt/src/cipher.rs b/packages/stack-encrypt/src/cipher.rs index 9470137f8..8dd5c7621 100644 --- a/packages/stack-encrypt/src/cipher.rs +++ b/packages/stack-encrypt/src/cipher.rs @@ -120,6 +120,7 @@ pub type StackCipherText = CipherText<SealedValue, BoxedPassthrough>; /// Errors from sealing or opening a [`StackCipherText`]. #[derive(Debug, thiserror::Error)] +#[non_exhaustive] pub enum Error { /// A ZeroKMS data-key generate/retrieve call failed. #[error("ZeroKMS data-key operation failed: {0}")] @@ -164,19 +165,21 @@ pub enum Error { /// implementation, never a data error. #[error("a pending fulfilment's responses did not match its requests")] ResponseShape, - /// [`Pending`](crate::target::Pending)s built on different - /// [`StackCipher`] instances were merged (`zip` / `all`). An assembly - /// settles through one cipher's backend, so the other side's keys would - /// be minted by the wrong client. Always a composition bug, caught - /// before any I/O. - #[error("merged pendings were built from different ciphers")] - CipherMismatch, /// [`Pending`](crate::target::Pending)s scoped to different keysets were /// merged (`zip` / `all`): one built through a [`KeysetCipher`] for one /// keyset, the other for another. A row belongs to one tenant; an /// assembly that spans two is a composition bug, caught before any /// I/O. (Opening leaves from several keysets in one batch is allowed — /// through the [`StackCipher`], which is scoped to none.) + /// + /// The keyset is the *whole* merge rule: two pendings built through two + /// different [`StackCipher`] values merge freely as long as they agree + /// on a keyset, because a keyset id is global and a cipher only holds a + /// keyset ZeroKMS resolved for its client. (Before the multi-keyset + /// `StackCipher` there was a `CipherMismatch` variant here, raised on + /// pointer equality of the two ciphers; it tested object identity + /// rather than client identity, and so refused two ciphers over the + /// same client and the same keyset.) #[error("merged pendings were scoped to different keysets ({left} and {right})")] KeysetMismatch { left: Uuid, right: Uuid }, /// A leaf sealed under one keyset was handed to a [`KeysetCipher`] for @@ -329,6 +332,21 @@ impl StackCipher<FromEnv> { } } +/// Opaque: the default keyset's identity and the data-key source's type +/// name, and nothing else. A cipher reaches the whole keyset cache — every +/// loaded keyset's index-key PRF — and, through its backend, the client key +/// and access token; none of that is printable, and a `Debug` that walked +/// the cache would also take its lock. +impl<K> std::fmt::Debug for StackCipher<K> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("StackCipher") + .field("default_keyset_id", &self.default.id) + .field("default_keyset_name", &self.default.name) + .field("kms", &std::any::type_name::<K>()) + .finish_non_exhaustive() + } +} + impl<K> StackCipher<K> { /// The cipher bound to its default keyset: the one named on the builder /// (by id or by name), else the client's default. Loaded at `init`, so @@ -400,21 +418,34 @@ impl<K: IndexKeySource> StackCipher<K> { // selection. Two selections racing on the same miss load twice; the // cache keeps both keysets by id, and the name follows the later // lookup whichever answer lands first. - let name = match &keyset { - IdentifiedBy::Name(name) => Some(name.to_string()), - IdentifiedBy::Uuid(_) => None, - }; - let (id, index_key) = self.kms.load_index_key(Some(keyset)).await?; - let state = Arc::new(KeysetState { - id, - name, - prf: hmac_prf_from_index_key(&index_key), - }); + let state = load_keyset(&self.kms, Some(keyset)).await?; self.keysets().insert(Arc::clone(&state), resolution); Ok(KeysetCipher::new(self, state)) } } +/// Resolve a keyset at ZeroKMS and build the state the cipher holds for it: +/// its resolved id, the name it was selected by (a selection by id has +/// none), and the PRF keyed by its index key. The one round trip a keyset +/// costs, shared by eager loading at +/// [`init`](StackCipherBuilder::init) and lazy loading in +/// [`StackCipher::keyset`] so both hold a keyset in exactly the same shape. +async fn load_keyset<K: IndexKeySource>( + kms: &K, + keyset: Option<IdentifiedBy>, +) -> Result<Arc<KeysetState>, Error> { + let name = match &keyset { + Some(IdentifiedBy::Name(name)) => Some(name.to_string()), + Some(IdentifiedBy::Uuid(_)) | None => None, + }; + let (id, index_key) = kms.load_index_key(keyset).await?; + Ok(Arc::new(KeysetState { + id, + name, + prf: hmac_prf_from_index_key(&index_key), + })) +} + /// The state of a [`StackCipherBuilder`] that has not been given a data-key /// source: [`init`](StackCipherBuilder::init) will build a ZeroKMS client from /// the environment (and, on native targets, the CLI's profile directory). @@ -560,14 +591,7 @@ impl StackCipherBuilder<FromEnv> { .with_key_provider(client_key_provider()) .build() .await?; - StackCipherBuilder { - kms, - keyset: self.keyset, - cache_size: self.cache_size, - name_ttl: self.name_ttl, - } - .init() - .await + self.kms(kms).init().await } } @@ -577,16 +601,7 @@ impl<K: DataKeySource + IndexKeySource> StackCipherBuilder<K> { /// seal values and derive index terms. The one round trip a cipher /// always pays; every other keyset loads on first selection. pub async fn init(self) -> Result<StackCipher<K>, Error> { - let name = match &self.keyset { - Some(IdentifiedBy::Name(name)) => Some(name.to_string()), - _ => None, - }; - let (id, index_key) = self.kms.load_index_key(self.keyset).await?; - let default = Arc::new(KeysetState { - id, - name, - prf: hmac_prf_from_index_key(&index_key), - }); + let default = load_keyset(&self.kms, self.keyset).await?; Ok(StackCipher { kms: self.kms, keysets: Mutex::new(KeysetCache::new( @@ -635,9 +650,7 @@ impl<K: DataKeySource> KeysetCipher<'_, K> { T: Decrypt<'static> + 'static, A: IntoAad<'a>, { - let aad = aad.into_aad_piece(); - let decipher = self.decipher(ciphertext, aad.clone()).await?; - T::decrypt_with_aad(decipher, aad.into_aad()).map_err(Error::from) + decrypt_through(self, ciphertext, aad).await } /// [`StackCipher::decipher`], constrained to this keyset: a leaf sealed @@ -648,12 +661,40 @@ impl<K: DataKeySource> KeysetCipher<'_, K> { ciphertext: StackCipherText, aad: impl IntoAad<'a>, ) -> Result<StackDecipher, Error> { - crate::target::decipher_pending(self, ciphertext, Descriptor::of(aad)) - .settle() - .await + decipher_through(self, ciphertext, aad).await } } +/// `decipher`, for either scope. The work is the same on both — one +/// descriptor, one pending, one settle — and the scope is the whole +/// difference: a [`KeysetCipher`] constrains the leaves to its keyset, a +/// [`StackCipher`] constrains nothing. Keeping one definition is what makes +/// that true, rather than two bodies that happen to agree. +async fn decipher_through<'s, 'a, K: DataKeySource + 's>( + scope: impl crate::target::CipherScope<'s, K>, + ciphertext: StackCipherText, + aad: impl IntoAad<'a>, +) -> Result<StackDecipher, Error> { + crate::target::decipher_pending(scope, ciphertext, Descriptor::of(aad)) + .settle() + .await +} + +/// `decrypt`, for either scope: [`decipher_through`], then the value's own +/// [`Decrypt`] drive under the same `aad`. +async fn decrypt_through<'s, 'a, T, K: DataKeySource + 's>( + scope: impl crate::target::CipherScope<'s, K>, + ciphertext: StackCipherText, + aad: impl IntoAad<'a>, +) -> Result<T, Error> +where + T: Decrypt<'static> + 'static, +{ + let aad = aad.into_aad_piece(); + let decipher = decipher_through(scope, ciphertext, aad.clone()).await?; + T::decrypt_with_aad(decipher, aad.into_aad()).map_err(Error::from) +} + impl<K: DataKeySource> StackCipher<K> { /// Decrypt a [`StackCipherText`] into `T`, authenticating against `aad`. /// @@ -672,9 +713,7 @@ impl<K: DataKeySource> StackCipher<K> { T: Decrypt<'static> + 'static, A: IntoAad<'a>, { - let aad = aad.into_aad_piece(); - let decipher = self.decipher(ciphertext, aad.clone()).await?; - T::decrypt_with_aad(decipher, aad.into_aad()).map_err(Error::from) + decrypt_through(self, ciphertext, aad).await } /// Fetch every leaf's data key (one batched `retrieve_keys` call per @@ -703,9 +742,7 @@ impl<K: DataKeySource> StackCipher<K> { ciphertext: StackCipherText, aad: impl IntoAad<'a>, ) -> Result<StackDecipher, Error> { - crate::target::decipher_pending(self, ciphertext, Descriptor::of(aad)) - .settle() - .await + decipher_through(self, ciphertext, aad).await } } diff --git a/packages/stack-encrypt/src/keyset.rs b/packages/stack-encrypt/src/keyset.rs index a38c59129..e90632d1b 100644 --- a/packages/stack-encrypt/src/keyset.rs +++ b/packages/stack-encrypt/src/keyset.rs @@ -43,6 +43,7 @@ //! arriving after eviction has forgotten the answer it would have lost to. use std::collections::HashMap; +use std::fmt; use std::num::NonZeroUsize; use std::sync::Arc; use std::time::{Duration, Instant}; @@ -71,6 +72,12 @@ pub(crate) struct KeysetState { /// if any — kept across replacement so the binding is dropped when the id /// is evicted, however the entry was last loaded — and the lookup whose /// answer last spoke for it. +/// +/// The default keyset is an `Entry` too, held apart from the bound rather +/// than shaped differently: its state never changes and it never evicts, but +/// its name binding ages, moves and reorders like any other, and every +/// accessor on [`KeysetCache`] reaches it through the same two lines +/// ([`entry`](KeysetCache::entry) / [`entry_mut`](KeysetCache::entry_mut)). struct Entry { state: Arc<KeysetState>, last_used: u64, @@ -131,7 +138,7 @@ pub(crate) enum Lookup { /// most, plus the default's, and a binding goes when its id does or when /// the keyset is resolved under another name. What an evicted entry leaves /// behind is one watermark, not a record per name: see -/// [`evicted_binding`](Self::evicted_binding). +/// [`eviction_watermark`](Self::eviction_watermark). pub(crate) struct KeysetCache { capacity: NonZeroUsize, name_ttl: Duration, @@ -139,13 +146,9 @@ pub(crate) struct KeysetCache { tick: u64, /// Monotonic lookup counter; see [`Resolution`]. resolutions: u64, - default: Arc<KeysetState>, - /// The name the default is currently bound under, if any: its - /// builder-time name until a resolution binds it under another. - default_name: Option<String>, - /// The lookup whose answer last spoke for the default; the builder's - /// own for a cipher that has resolved nothing yet. - default_resolution: Resolution, + /// The default keyset's entry, held apart from the bound: it never + /// evicts, and [`insert`](Self::insert) never replaces its state. + default: Entry, /// The latest lookup whose answer eviction has forgotten. /// /// A keyset carries the order of the answers that spoke for it, and a @@ -165,7 +168,7 @@ pub(crate) struct KeysetCache { /// lookup could have made safely. That costs a round trip on the next /// selection by such a name, in the eviction regime that is already /// paying them. - evicted_binding: Resolution, + eviction_watermark: Resolution, by_id: HashMap<Uuid, Entry>, by_name: HashMap<String, Alias>, } @@ -199,10 +202,13 @@ impl KeysetCache { name_ttl, tick: 0, resolutions: 0, - default_name: default.name.clone(), - default_resolution: Resolution(0), - evicted_binding: Resolution(0), - default, + eviction_watermark: Resolution(0), + default: Entry { + last_used: 0, + name: default.name.clone(), + resolution: Resolution(0), + state: default, + }, by_id: HashMap::new(), by_name, } @@ -214,6 +220,43 @@ impl KeysetCache { Resolution(self.resolutions) } + /// The keyset held apart from the bound. + fn default_id(&self) -> Uuid { + self.default.state.id + } + + /// The entry for `id`, whether it is the default's or one of the bounded + /// ones. This and [`entry_mut`](Self::entry_mut) are the only two places + /// that know the default is held apart, so every rule below — ordering, + /// binding, forgetting a name — is written once and applies to it too. + fn entry(&self, id: Uuid) -> Option<&Entry> { + if id == self.default_id() { + Some(&self.default) + } else { + self.by_id.get(&id) + } + } + + /// [`entry`](Self::entry), mutably. + fn entry_mut(&mut self, id: Uuid) -> Option<&mut Entry> { + if id == self.default_id() { + Some(&mut self.default) + } else { + self.by_id.get_mut(&id) + } + } + + /// Mark `id` most recently used and hand back its state, if the cache + /// holds it at all. + fn touch(&mut self, id: Uuid) -> Option<Arc<KeysetState>> { + let tick = self.tick + 1; + let entry = self.entry_mut(id)?; + entry.last_used = tick; + let state = Arc::clone(&entry.state); + self.tick = tick; + Some(state) + } + /// Look a keyset up by id or name, marking it most recently used. pub(crate) fn get(&mut self, by: &IdentifiedBy) -> Lookup { let (id, fresh) = match by { @@ -225,17 +268,8 @@ impl KeysetCache { None => return Lookup::Miss(self.resolution()), }, }; - let state = if id == self.default.id { - Arc::clone(&self.default) - } else { - match self.by_id.get_mut(&id) { - Some(entry) => { - self.tick += 1; - entry.last_used = self.tick; - Arc::clone(&entry.state) - } - None => return Lookup::Miss(self.resolution()), - } + let Some(state) = self.touch(id) else { + return Lookup::Miss(self.resolution()); }; if fresh { Lookup::Hit(state) @@ -268,12 +302,14 @@ impl KeysetCache { { return; } + // `bind` gives the entry the name it binds, when the cache already + // holds one; a first insert carries it over below instead. let bound = match &state.name { Some(name) => self.bind(name, state.id, resolution), None => false, }; - if state.id == self.default.id { - self.default_resolution = resolution; + if state.id == self.default_id() { + self.default.resolution = resolution; return; } if !self.by_id.contains_key(&state.id) && self.by_id.len() >= self.capacity.get() { @@ -282,9 +318,6 @@ impl KeysetCache { self.tick += 1; match self.by_id.get_mut(&state.id) { Some(entry) => { - if bound { - entry.name.clone_from(&state.name); - } entry.state = state; entry.last_used = self.tick; entry.resolution = resolution; @@ -305,14 +338,14 @@ impl KeysetCache { } /// Bind `name` to `id` for the lookup `resolution`; false if a later - /// lookup already bound it, or if eviction has since dropped a binding - /// this answer is older than ([`evicted_binding`]). Also unbinds the name - /// this id was bound under before, and unbinds this name from the id it - /// named before. + /// lookup already bound it, or if this answer is older than a place in + /// the order eviction has since forgotten + /// ([`eviction_watermark`]). Also unbinds the name this id was bound + /// under before, and unbinds this name from the id it named before. /// - /// [`evicted_binding`]: Self::evicted_binding + /// [`eviction_watermark`]: Self::eviction_watermark fn bind(&mut self, name: &str, id: Uuid, resolution: Resolution) -> bool { - if resolution < self.evicted_binding { + if resolution < self.eviction_watermark { return false; } if let Some(alias) = self.by_name.get(name) { @@ -336,8 +369,11 @@ impl KeysetCache { resolution, }, ); - if id == self.default.id { - self.default_name = Some(name.to_owned()); + // The keyset claims the name it is now bound under, so eviction can + // take the binding with it. An id the cache does not hold yet is + // about to be inserted by `insert`, which carries the name over. + if let Some(entry) = self.entry_mut(id) { + entry.name = Some(name.to_owned()); } true } @@ -345,29 +381,17 @@ impl KeysetCache { /// The lookup whose answer last spoke for `id`, if the cache holds it. /// An id it has never held (or has evicted) has nothing to supersede. fn last_resolution_of(&self, id: Uuid) -> Option<Resolution> { - if id == self.default.id { - Some(self.default_resolution) - } else { - self.by_id.get(&id).map(|entry| entry.resolution) - } + self.entry(id).map(|entry| entry.resolution) } /// The name `id` is currently bound under, if any. fn current_name_of(&self, id: Uuid) -> Option<String> { - if id == self.default.id { - self.default_name.clone() - } else { - self.by_id.get(&id).and_then(|entry| entry.name.clone()) - } + self.entry(id).and_then(|entry| entry.name.clone()) } /// `name` moved away from `id`: the id no longer claims it. fn forget_name_of(&mut self, id: Uuid, name: &str) { - if id == self.default.id { - if self.default_name.as_deref() == Some(name) { - self.default_name = None; - } - } else if let Some(entry) = self.by_id.get_mut(&id) { + if let Some(entry) = self.entry_mut(id) { if entry.name.as_deref() == Some(name) { entry.name = None; } @@ -391,7 +415,7 @@ impl KeysetCache { // another keyset is precisely the one an older answer would // rebind, and the binding it would have lost to is no longer // here to say so. - self.evicted_binding = self.evicted_binding.max(entry.resolution); + self.eviction_watermark = self.eviction_watermark.max(entry.resolution); if let Some(name) = entry.name { // A name that has since moved to another id keeps its // binding: only this id's binding goes with it. @@ -455,6 +479,20 @@ impl<K> Clone for KeysetCipher<'_, K> { } } +/// Opaque: the keyset's identity (which is in every sealed leaf already, and +/// in ZeroKMS's own logs) and nothing else. The index-key PRF this handle +/// carries is key material and never appears, and neither does the cipher — +/// whose own [`Debug`](fmt::Debug) is opaque for the same reason. +impl<K> fmt::Debug for KeysetCipher<'_, K> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("KeysetCipher") + .field("keyset_id", &self.state.id) + .field("keyset_name", &self.state.name) + .field("kms", &std::any::type_name::<K>()) + .finish_non_exhaustive() + } +} + impl<'k, K> KeysetCipher<'k, K> { pub(crate) fn new(cipher: &'k StackCipher<K>, state: Arc<KeysetState>) -> Self { Self { cipher, state } @@ -548,10 +586,24 @@ mod tests { let mut cache = cache(4); cache.load(state(1, Some("customers"))); - assert_eq!(hit(cache.get(&id(1))), Some(Uuid::from_u128(1))); - assert_eq!(hit(cache.get(&name("customers"))), Some(Uuid::from_u128(1))); - assert!(matches!(cache.get(&name("staff")), Lookup::Miss(_))); - assert!(matches!(cache.get(&id(2)), Lookup::Miss(_))); + assert_eq!( + hit(cache.get(&id(1))), + Some(Uuid::from_u128(1)), + "a loaded keyset is found by its id" + ); + assert_eq!( + hit(cache.get(&name("customers"))), + Some(Uuid::from_u128(1)), + "and by the name it loaded under" + ); + assert!( + matches!(cache.get(&name("staff")), Lookup::Miss(_)), + "a name nothing was loaded under is a miss" + ); + assert!( + matches!(cache.get(&id(2)), Lookup::Miss(_)), + "an id nothing was loaded under is a miss" + ); } #[test] @@ -559,8 +611,14 @@ mod tests { let mut cache = cache(4); cache.load(state(1, None)); - assert!(matches!(cache.get(&id(1)), Lookup::Hit(_))); - assert!(matches!(cache.get(&name("customers")), Lookup::Miss(_))); + assert!( + matches!(cache.get(&id(1)), Lookup::Hit(_)), + "the id it loaded under finds it" + ); + assert!( + matches!(cache.get(&name("customers")), Lookup::Miss(_)), + "a load by id binds no name, so no name finds it" + ); } #[test] @@ -570,26 +628,53 @@ mod tests { Duration::MAX, state(0, Some("primary")), ); - assert_eq!(hit(cache.get(&id(0))), Some(Uuid::from_u128(0))); - assert_eq!(hit(cache.get(&name("primary"))), Some(Uuid::from_u128(0))); + assert_eq!( + hit(cache.get(&id(0))), + Some(Uuid::from_u128(0)), + "the default is found by its id" + ); + assert_eq!( + hit(cache.get(&name("primary"))), + Some(Uuid::from_u128(0)), + "and by the name the builder gave it" + ); // Filling the one slot evicts nothing of the default's. cache.load(state(1, None)); cache.load(state(2, None)); - assert_eq!(cache.len(), 1); - assert_eq!(hit(cache.get(&id(0))), Some(Uuid::from_u128(0))); - assert_eq!(hit(cache.get(&name("primary"))), Some(Uuid::from_u128(0))); + assert_eq!(cache.len(), 1, "the bounded part holds its one slot"); + assert_eq!( + hit(cache.get(&id(0))), + Some(Uuid::from_u128(0)), + "the default survives an eviction that filled the bound" + ); + assert_eq!( + hit(cache.get(&name("primary"))), + Some(Uuid::from_u128(0)), + "and so does its name binding" + ); // Re-resolving the default by name refreshes its binding, and does // not put a second copy of it in the bounded part. cache.load(state(0, Some("primary"))); - assert_eq!(cache.len(), 1); + assert_eq!( + cache.len(), + 1, + "re-resolving the default must not store a second copy of it" + ); // The default renamed: its old name no longer selects it. cache.load(state(0, Some("main"))); - assert_eq!(hit(cache.get(&name("main"))), Some(Uuid::from_u128(0))); - assert!(matches!(cache.get(&name("primary")), Lookup::Miss(_))); - assert_eq!(cache.names(), 1); + assert_eq!( + hit(cache.get(&name("main"))), + Some(Uuid::from_u128(0)), + "the default's new name selects it" + ); + assert!( + matches!(cache.get(&name("primary")), Lookup::Miss(_)), + "the name it was renamed away from no longer selects it" + ); + assert_eq!(cache.names(), 1, "a keyset holds one name at a time"); } #[test] @@ -598,18 +683,27 @@ mod tests { cache.load(state(1, Some("one"))); cache.load(state(2, Some("two"))); // Touch 1 so 2 is the oldest. - assert!(matches!(cache.get(&id(1)), Lookup::Hit(_))); + assert!( + matches!(cache.get(&id(1)), Lookup::Hit(_)), + "touching 1 makes 2 the least recently used" + ); cache.load(state(3, Some("three"))); - assert_eq!(cache.len(), 2); + assert_eq!(cache.len(), 2, "the cache stays at its bound"); assert!(matches!(cache.get(&id(2)), Lookup::Miss(_)), "2 was oldest"); assert!( matches!(cache.get(&name("two")), Lookup::Miss(_)), "the evicted keyset's name goes with it" ); - assert!(matches!(cache.get(&id(1)), Lookup::Hit(_))); - assert!(matches!(cache.get(&id(3)), Lookup::Hit(_))); + assert!( + matches!(cache.get(&id(1)), Lookup::Hit(_)), + "the touched keyset stayed" + ); + assert!( + matches!(cache.get(&id(3)), Lookup::Hit(_)), + "and the newly loaded one is held" + ); } /// The order two cold lookups on the same keyset can land in: by name @@ -621,18 +715,31 @@ mod tests { let mut cache = cache(1); cache.load(state(1, Some("one"))); cache.load(state(1, None)); - assert_eq!(cache.len(), 1); - assert_eq!(hit(cache.get(&name("one"))), Some(Uuid::from_u128(1))); + assert_eq!(cache.len(), 1, "the same id replaces, never adds"); + assert_eq!( + hit(cache.get(&name("one"))), + Some(Uuid::from_u128(1)), + "a reload by id must not shed the binding the name load made" + ); // Evicting 1 takes "one" with it, whichever load was last. cache.load(state(2, None)); - assert!(matches!(cache.get(&id(1)), Lookup::Miss(_))); - assert!(matches!(cache.get(&name("one")), Lookup::Miss(_))); - assert_eq!(cache.names(), 0); + assert!( + matches!(cache.get(&id(1)), Lookup::Miss(_)), + "1 was evicted by 2" + ); + assert!( + matches!(cache.get(&name("one")), Lookup::Miss(_)), + "no binding outlives the id it names" + ); + assert_eq!(cache.names(), 0, "the name index emptied with the entry"); // And reloading 1 by id does not resurrect the binding. cache.load(state(1, None)); - assert!(matches!(cache.get(&name("one")), Lookup::Miss(_))); + assert!( + matches!(cache.get(&name("one")), Lookup::Miss(_)), + "a reload by id binds no name" + ); } /// Bindings never outnumber the keysets they name: churning names @@ -644,11 +751,16 @@ mod tests { cache.load(state(i, Some(&format!("tenant-{i}")))); cache.load(state(i, None)); } - assert_eq!(cache.len(), 1); - assert_eq!(cache.names(), 1); + assert_eq!(cache.len(), 1, "the cache holds its bound, not 1000"); + assert_eq!( + cache.names(), + 1, + "the name index is bounded by the entries it serves" + ); assert_eq!( hit(cache.get(&name("tenant-1000"))), - Some(Uuid::from_u128(1000)) + Some(Uuid::from_u128(1000)), + "the surviving binding is the last one made" ); } @@ -660,17 +772,32 @@ mod tests { let mut cache = cache(1); cache.load(state(1, Some("one"))); cache.load(state(1, Some("uno"))); - assert_eq!(hit(cache.get(&name("uno"))), Some(Uuid::from_u128(1))); - assert!(matches!(cache.get(&name("one")), Lookup::Miss(_))); - assert_eq!(cache.names(), 1); + assert_eq!( + hit(cache.get(&name("uno"))), + Some(Uuid::from_u128(1)), + "the newer name selects the keyset" + ); + assert!( + matches!(cache.get(&name("one")), Lookup::Miss(_)), + "the older name was renamed away and no longer selects it" + ); + assert_eq!(cache.names(), 1, "one name per keyset"); for i in 0..1000 { cache.load(state(1, Some(&format!("name-{i}")))); } - assert_eq!(cache.names(), 1); + assert_eq!( + cache.names(), + 1, + "a thousand renames of one keyset leave one binding" + ); cache.load(state(2, None)); - assert_eq!(cache.names(), 0); + assert_eq!( + cache.names(), + 0, + "evicting the keyset takes its one binding with it" + ); } /// Resolutions run outside the lock and their answers land in any @@ -686,14 +813,25 @@ mod tests { cache.insert(state(2, Some("acme")), later); cache.insert(state(1, Some("acme")), earlier); - assert_eq!(hit(cache.get(&name("acme"))), Some(Uuid::from_u128(2))); - assert!(matches!(cache.get(&id(1)), Lookup::Hit(_))); - assert_eq!(cache.names(), 1); + assert_eq!( + hit(cache.get(&name("acme"))), + Some(Uuid::from_u128(2)), + "the binding follows the later lookup, not the later arrival" + ); + assert!( + matches!(cache.get(&id(1)), Lookup::Hit(_)), + "the older answer's key material still caches, by id" + ); + assert_eq!(cache.names(), 1, "one binding for the one name"); // In order, the later answer moves it as usual. let next = ticket(cache.get(&name("other"))); cache.insert(state(1, Some("acme")), next); - assert_eq!(hit(cache.get(&name("acme"))), Some(Uuid::from_u128(1))); + assert_eq!( + hit(cache.get(&name("acme"))), + Some(Uuid::from_u128(1)), + "a genuinely later answer moves the binding" + ); assert_eq!(cache.names(), 1, "2 no longer claims the name"); } @@ -716,12 +854,16 @@ mod tests { cache.insert(state(1, Some("acme-corp")), later); cache.insert(state(1, Some("acme")), earlier); - assert_eq!(hit(cache.get(&name("acme-corp"))), Some(Uuid::from_u128(1))); + assert_eq!( + hit(cache.get(&name("acme-corp"))), + Some(Uuid::from_u128(1)), + "the later answer's name stands" + ); assert!( matches!(cache.get(&name("acme")), Lookup::Miss(_)), "the name the keyset was renamed away from is not bound again" ); - assert_eq!(cache.names(), 1); + assert_eq!(cache.names(), 1, "and it was not bound alongside"); } /// And the default keyset, held apart from the bound, orders its @@ -741,9 +883,16 @@ mod tests { cache.insert(state(0, Some("main")), later); cache.insert(state(0, Some("primary")), earlier); - assert_eq!(hit(cache.get(&name("main"))), Some(Uuid::from_u128(0))); - assert!(matches!(cache.get(&name("primary")), Lookup::Miss(_))); - assert_eq!(cache.names(), 1); + assert_eq!( + hit(cache.get(&name("main"))), + Some(Uuid::from_u128(0)), + "the default's binding follows the later lookup too" + ); + assert!( + matches!(cache.get(&name("primary")), Lookup::Miss(_)), + "the older answer does not restore the builder-time name" + ); + assert_eq!(cache.names(), 1, "one name for the default as well"); } /// A rename: the name now resolves to another id. The binding moves, @@ -753,13 +902,24 @@ mod tests { let mut cache = cache(2); cache.load(state(1, Some("acme"))); cache.load(state(2, Some("acme"))); - assert_eq!(hit(cache.get(&name("acme"))), Some(Uuid::from_u128(2))); + assert_eq!( + hit(cache.get(&name("acme"))), + Some(Uuid::from_u128(2)), + "the name moved to the keyset that now answers to it" + ); // Evict 1 (the oldest): "acme" belongs to 2 now and stays. - assert!(matches!(cache.get(&id(2)), Lookup::Hit(_))); + assert!(matches!(cache.get(&id(2)), Lookup::Hit(_)), "touch 2"); cache.load(state(3, None)); - assert!(matches!(cache.get(&id(1)), Lookup::Miss(_))); - assert_eq!(hit(cache.get(&name("acme"))), Some(Uuid::from_u128(2))); + assert!( + matches!(cache.get(&id(1)), Lookup::Miss(_)), + "1 was the least recently used and went" + ); + assert_eq!( + hit(cache.get(&name("acme"))), + Some(Uuid::from_u128(2)), + "evicting the keyset a name has left must not take the binding" + ); } /// Eviction must not lose the order either: the keyset the later answer @@ -776,20 +936,24 @@ mod tests { cache.insert(state(2, Some("acme")), later); // 2 is evicted, and "acme" goes with it. cache.load(state(3, None)); - assert_eq!(cache.names(), 0); + assert_eq!(cache.names(), 0, "the binding went with the entry"); cache.insert(state(1, Some("acme")), earlier); assert!( matches!(cache.get(&name("acme")), Lookup::Miss(_)), "the name the later lookup moved away is not taken back" ); - assert_eq!(cache.names(), 0); + assert_eq!(cache.names(), 0, "and nothing else was bound either"); // A lookup later than the evicted binding still binds: the watermark // does not close the name index for good. let next = ticket(cache.get(&name("acme"))); cache.insert(state(1, Some("acme")), next); - assert_eq!(hit(cache.get(&name("acme"))), Some(Uuid::from_u128(1))); + assert_eq!( + hit(cache.get(&name("acme"))), + Some(Uuid::from_u128(1)), + "a lookup later than the watermark binds as usual" + ); } /// An entry carries its place in the order whether or not it still owns @@ -810,14 +974,18 @@ mod tests { // 1, and caching 2 evicts 1 with no name of its own to leave behind. cache.insert(state(1, Some("new")), middle); cache.insert(state(2, Some("new")), newest); - assert_eq!(hit(cache.get(&name("new"))), Some(Uuid::from_u128(2))); + assert_eq!( + hit(cache.get(&name("new"))), + Some(Uuid::from_u128(2)), + "`new` means keyset 2 after the second rename" + ); cache.insert(state(1, Some("old")), oldest); assert!( matches!(cache.get(&name("old")), Lookup::Miss(_)), "a name from before two renames is not bound by the answer that lands last" ); - assert_eq!(cache.names(), 0); + assert_eq!(cache.names(), 0, "and no other binding was made"); } /// Past the window a name lookup is stale — the keyset is still there, @@ -831,8 +999,14 @@ mod tests { ); cache.load(state(1, Some("one"))); - assert!(matches!(cache.get(&name("one")), Lookup::Stale(_))); - assert!(matches!(cache.get(&name("primary")), Lookup::Stale(_))); + assert!( + matches!(cache.get(&name("one")), Lookup::Stale(_)), + "past its window a name binding is not trusted" + ); + assert!( + matches!(cache.get(&name("primary")), Lookup::Stale(_)), + "the default's builder-time name ages the same way" + ); assert!( matches!(cache.get(&id(1)), Lookup::Hit(_)), "an id never ages" @@ -842,8 +1016,14 @@ mod tests { // no time elapsed at all, on the coarsest clock — which is the point // of a zero window; a wide one is fresh. cache.load(state(1, Some("one"))); - assert!(matches!(cache.get(&name("one")), Lookup::Stale(_))); + assert!( + matches!(cache.get(&name("one")), Lookup::Stale(_)), + "a zero window is stale again the instant it is refreshed" + ); cache.name_ttl = Duration::MAX; - assert!(matches!(cache.get(&name("one")), Lookup::Hit(_))); + assert!( + matches!(cache.get(&name("one")), Lookup::Hit(_)), + "a wide window is fresh" + ); } } diff --git a/packages/stack-encrypt/src/target/pending.rs b/packages/stack-encrypt/src/target/pending.rs index 300882211..bf2bbef95 100644 --- a/packages/stack-encrypt/src/target/pending.rs +++ b/packages/stack-encrypt/src/target/pending.rs @@ -50,7 +50,12 @@ pub type PendingFuture<'a, T> = Pin<Box<dyn Future<Output = Result<T, Error>> + /// Construct leaves with [`ready`](Self::ready) (value already derived, /// nothing to request) or [`request`](Self::request) (value needs ZeroKMS /// responses). +#[must_use = "a Pending does nothing until it is awaited or merged into one that is; \ + dropping it silently discards the value and any error that produced it"] pub struct Pending<'a, T, K> { + /// The cipher the settled batch dispatches through. Which + /// [`StackCipher`] value it is does not constrain merging — see + /// [`zip`](Self::zip) — only which client issues the calls. cipher: &'a StackCipher<K>, /// The keyset this pending is scoped to: the one a [`KeysetCipher`] /// built it through, or none when it was built through the @@ -61,7 +66,7 @@ pub struct Pending<'a, T, K> { keyset: Option<Uuid>, requests: Vec<Request>, /// Set when the value already failed during the synchronous build - /// (`ready(Err(..))`, a cipher mismatch, a failed sibling). A failed + /// (`ready(Err(..))`, a keyset mismatch, a failed sibling). A failed /// pending carries no requests, and merging one into an assembly drops /// the assembly's requests too, so settling it does no I/O: a record /// with one misconfigured field never mints data keys it will throw away. @@ -213,6 +218,21 @@ impl<'a, T: 'a, K> Pending<'a, T, K> { } } + /// Fallibly transform the resolved value without issuing another request. + pub fn try_map<U: 'a, F>(self, f: F) -> Pending<'a, U, K> + where + F: FnOnce(T) -> Result<U, Error> + MaybeSend + 'a, + { + let fulfil = self.fulfil; + Pending { + cipher: self.cipher, + keyset: self.keyset, + requests: self.requests, + failed: self.failed, + fulfil: Box::new(move |responses| fulfil(responses).and_then(f)), + } + } + /// Scope this pending to `keyset`: what a [`KeysetCipher`]'s decrypt /// does to the pending its [`StackCipher`] built, so that opening a /// leaf from any other keyset fails before any key is retrieved. @@ -243,19 +263,23 @@ impl<'a, T: 'a, K> Pending<'a, T, K> { /// concatenate — awaiting the result is still one batched call per /// request kind. /// - /// Both must come from the same cipher, and from the same keyset if - /// both are scoped to one: the merged assembly dispatches every request - /// through one cipher's backend, and mints every key under one keyset, - /// so a pending built on another cipher or scoped to another keyset - /// would have its keys minted by the wrong client or under the wrong - /// keyset. Those are [`Error::CipherMismatch`] and - /// [`Error::KeysetMismatch`], not debug assertions. A scoped pending - /// merged with an unscoped one takes the scope. If either side already - /// failed, the result is that failure and carries no requests. + /// Both must agree on a keyset if both are scoped to one: the merged + /// assembly mints every key under one keyset, so merging one tenant's + /// pending with another's is [`Error::KeysetMismatch`], not a debug + /// assertion. A scoped pending merged with an unscoped one takes the + /// scope. If either side already failed, the result is that failure and + /// carries no requests. + /// + /// The keyset is the whole rule: the two sides need not have been built + /// through the *same* [`StackCipher`] value. The merged assembly + /// dispatches through one of them, and every request it carries is + /// keyset-addressed — a generate mints under the merged scope, a + /// retrieve names the keyset its leaf was sealed under. A keyset id is + /// global, and a cipher only holds a keyset ZeroKMS resolved for its + /// client, so either side's client can dispatch the batch; a client that + /// is *not* authorised for the keyset is refused at ZeroKMS + /// ([`Error::Kms`]), exactly as it would be on its own. pub fn zip<U: 'a>(self, other: Pending<'a, U, K>) -> Pending<'a, (T, U), K> { - if !std::ptr::eq(self.cipher, other.cipher) { - return Pending::failed(self.cipher, Error::CipherMismatch); - } let keyset = match merge_scopes(self.keyset, other.keyset) { Ok(keyset) => keyset, Err(error) => return Pending::failed(self.cipher, error), @@ -281,10 +305,10 @@ impl<'a, T: 'a, K> Pending<'a, T, K> { /// Merge any number of same-typed pendings into one resolving to the /// `Vec` — [`zip`](Self::zip) at scale, used by the `Vec<T>` - /// implementations to make a whole column one batched call. Same rules - /// as `zip`: every item must come from `scope`'s cipher and agree with - /// its keyset, and the first failed item fails the whole column with no - /// I/O. + /// implementations to make a whole column one batched call. Same rule as + /// `zip`: every item must agree with `scope`'s keyset — which cipher + /// value each was built through does not matter, for the reason `zip` + /// gives — and the first failed item fails the whole column with no I/O. pub fn all( scope: impl CipherScope<'a, K>, items: Vec<Pending<'a, T, K>>, @@ -294,9 +318,6 @@ impl<'a, T: 'a, K> Pending<'a, T, K> { let mut requests = Vec::new(); let mut fulfils = Vec::with_capacity(items.len()); for item in items { - if !std::ptr::eq(cipher, item.cipher) { - return Pending::failed(cipher, Error::CipherMismatch); - } keyset = match merge_scopes(keyset, item.keyset) { Ok(keyset) => keyset, Err(error) => return Pending::failed(cipher, error), @@ -407,6 +428,16 @@ where } } +/// One [`RequestKind::RetrieveDataKey`], unpacked for +/// [`dispatch`]: the retrieves are grouped by `keyset_id` and read back by +/// index, so they are held as a list of their own rather than as requests. +struct Retrieve { + iv: Iv, + tag: Vec<u8>, + descriptor: Descriptor, + keyset_id: Uuid, +} + /// Issue the batched ZeroKMS calls for `requests`: at most one /// `generate_keys` (under the pending's keyset) and one `retrieve_keys` per /// keyset the retrieved leaves were sealed under, whatever the request @@ -418,7 +449,7 @@ async fn dispatch<K: DataKeySource>( requests: Vec<Request>, ) -> Result<Responses, Error> { let mut generates: Vec<Descriptor> = Vec::new(); - let mut retrieves: Vec<(Iv, Vec<u8>, Descriptor, Uuid)> = Vec::new(); + let mut retrieves: Vec<Retrieve> = Vec::new(); for request in requests { match request.into_kind() { RequestKind::GenerateDataKey { descriptor } => generates.push(descriptor), @@ -427,7 +458,12 @@ async fn dispatch<K: DataKeySource>( tag, descriptor, keyset_id, - } => retrieves.push((iv, tag, descriptor, keyset_id)), + } => retrieves.push(Retrieve { + iv, + tag, + descriptor, + keyset_id, + }), } } @@ -439,7 +475,7 @@ async fn dispatch<K: DataKeySource>( // a fast path, not a second rule. generates .iter() - .chain(retrieves.iter().map(|(_, _, descriptor, _)| descriptor)) + .chain(retrieves.iter().map(|retrieve| &retrieve.descriptor)) .try_for_each(Descriptor::check)?; let generated = if generates.is_empty() { @@ -474,9 +510,9 @@ async fn dispatch<K: DataKeySource>( // which is the order the fulfilments draw them in. let mut groups: Vec<(Uuid, Vec<usize>)> = Vec::new(); let mut group_of: HashMap<Uuid, usize> = HashMap::new(); - for (index, (_, _, _, keyset_id)) in retrieves.iter().enumerate() { - let group = *group_of.entry(*keyset_id).or_insert_with(|| { - groups.push((*keyset_id, Vec::new())); + for (index, retrieve) in retrieves.iter().enumerate() { + let group = *group_of.entry(retrieve.keyset_id).or_insert_with(|| { + groups.push((retrieve.keyset_id, Vec::new())); groups.len() - 1 }); groups[group].1.push(index); @@ -488,8 +524,8 @@ async fn dispatch<K: DataKeySource>( let payloads: Vec<RetrieveKeyPayload<'_>> = indices .iter() .map(|&index| { - let (iv, tag, descriptor, _) = &retrieves[index]; - RetrieveKeyPayload::new(*iv, descriptor.as_str(), tag) + let retrieve = &retrieves[index]; + RetrieveKeyPayload::new(retrieve.iv, retrieve.descriptor.as_str(), &retrieve.tag) }) .collect(); let expected = payloads.len(); @@ -648,9 +684,13 @@ mod tests { let cipher = cipher().await; let value: u32 = Pending::ready(&cipher, Ok(7)).await.unwrap(); - assert_eq!(value, 7); - assert_eq!(cipher.kms().generate_calls(), 0); - assert_eq!(cipher.kms().retrieve_calls(), 0); + assert_eq!(value, 7, "a ready pending resolves to the value it holds"); + assert_eq!( + cipher.kms().generate_calls(), + 0, + "a ready pending must not generate any key" + ); + assert_eq!(cipher.kms().retrieve_calls(), 0, "nor retrieve one"); } #[tokio::test] @@ -658,8 +698,15 @@ mod tests { let cipher = cipher().await; let result: Result<u32, Error> = Pending::ready(&cipher, Err(Error::Aead)).await; - assert!(matches!(result, Err(Error::Aead))); - assert_eq!(cipher.kms().generate_calls(), 0); + assert!( + matches!(result, Err(Error::Aead)), + "the error a ready pending was given comes back: {result:?}" + ); + assert_eq!( + cipher.kms().generate_calls(), + 0, + "a failed pending does no I/O" + ); } #[tokio::test] @@ -670,7 +717,7 @@ mod tests { .await .unwrap(); - assert_eq!(value, 21); + assert_eq!(value, 21, "map runs over the resolved value"); } #[tokio::test] @@ -680,7 +727,10 @@ mod tests { .map(|_: u32| panic!("map must not run on an error")) .await; - assert!(matches!(result, Err(Error::Aead))); + assert!( + matches!(result, Err(Error::Aead)), + "the error passes through untouched: {result:?}" + ); } #[tokio::test] @@ -689,8 +739,12 @@ mod tests { let keyset = cipher.default_keyset(); let tags = generating(&keyset, 3).map(|tags| tags.len()).await.unwrap(); - assert_eq!(tags, 3); - assert_eq!(cipher.kms().generate_calls(), 1); + assert_eq!(tags, 3, "map sees all three keys the pending asked for"); + assert_eq!( + cipher.kms().generate_calls(), + 1, + "mapping does not split the batch" + ); } #[tokio::test] @@ -699,8 +753,12 @@ mod tests { let keyset = cipher.default_keyset(); let tags = generating(&keyset, 5).await.unwrap(); - assert_eq!(tags.len(), 5); - assert_eq!(cipher.kms().generate_calls(), 1); + assert_eq!(tags.len(), 5, "every requested key comes back"); + assert_eq!( + cipher.kms().generate_calls(), + 1, + "five keys, one generate_keys call" + ); } #[tokio::test] @@ -712,8 +770,16 @@ mod tests { .await .unwrap(); - assert_eq!((left.len(), right.len()), (2, 3)); - assert_eq!(cipher.kms().generate_calls(), 1); + assert_eq!( + (left.len(), right.len()), + (2, 3), + "each side draws exactly its own keys" + ); + assert_eq!( + cipher.kms().generate_calls(), + 1, + "zipping merges the two request lists into one call" + ); } /// The scoping guarantee at the `Pending` level: zipped fulfilments draw @@ -740,8 +806,12 @@ mod tests { .await .unwrap(); - assert_eq!(pair, (1, "two")); - assert_eq!(cipher.kms().generate_calls(), 0); + assert_eq!(pair, (1, "two"), "both ready values resolve, in order"); + assert_eq!( + cipher.kms().generate_calls(), + 0, + "nothing was requested, so nothing is dispatched" + ); } #[tokio::test] @@ -750,12 +820,18 @@ mod tests { let result = Pending::ready(&cipher, Err(Error::Aead)) .zip(Pending::ready(&cipher, Ok(1u32))) .await; - assert!(matches!(result, Err::<(u32, u32), _>(Error::Aead))); + assert!( + matches!(result, Err::<(u32, u32), _>(Error::Aead)), + "a failure on the left fails the pair: {result:?}" + ); let result = Pending::ready(&cipher, Ok(1u32)) .zip(Pending::ready(&cipher, Err(Error::Aead))) .await; - assert!(matches!(result, Err::<(u32, u32), _>(Error::Aead))); + assert!( + matches!(result, Err::<(u32, u32), _>(Error::Aead)), + "and so does one on the right: {result:?}" + ); } #[tokio::test] @@ -765,14 +841,18 @@ mod tests { let items = (0..5).map(|_| generating(&keyset, 1)).collect(); let column = Pending::all(&cipher, items).await.unwrap(); - assert_eq!(column.len(), 5); - assert_eq!(cipher.kms().generate_calls(), 1); + assert_eq!(column.len(), 5, "every item resolves, in build order"); + assert_eq!( + cipher.kms().generate_calls(), + 1, + "a whole column is one generate_keys call" + ); // Every row drew its own key. let mut tags: Vec<&Vec<u8>> = column.iter().flatten().collect(); tags.sort(); tags.dedup(); - assert_eq!(tags.len(), 5); + assert_eq!(tags.len(), 5, "no two rows drew the same key"); } #[tokio::test] @@ -780,8 +860,8 @@ mod tests { let cipher = cipher().await; let column: Vec<u32> = Pending::all(&cipher, Vec::new()).await.unwrap(); - assert!(column.is_empty()); - assert_eq!(cipher.kms().generate_calls(), 0); + assert!(column.is_empty(), "an empty column resolves empty"); + assert_eq!(cipher.kms().generate_calls(), 0, "and dispatches nothing"); } #[tokio::test] @@ -793,7 +873,10 @@ mod tests { ]; let result = Pending::all(&cipher, items).await; - assert!(matches!(result, Err::<Vec<u32>, _>(Error::Aead))); + assert!( + matches!(result, Err::<Vec<u32>, _>(Error::Aead)), + "the first failed item fails the column: {result:?}" + ); } /// Over-drawing is the fulfilment's own error, not a stolen sibling key: @@ -813,10 +896,13 @@ mod tests { ); let result = greedy.zip(generating(&keyset, 1)).await; - assert!(matches!( - result, - Err::<(Vec<u8>, Vec<Vec<u8>>), _>(Error::ResponseShape) - )); + assert!( + matches!( + result, + Err::<(Vec<u8>, Vec<Vec<u8>>), _>(Error::ResponseShape) + ), + "drawing past its own requests is the fulfilment's own error: {result:?}" + ); } /// Under-drawing is an error for the same reason over-drawing is: the @@ -834,10 +920,10 @@ mod tests { Pending::request(&keyset, vec![Request::generate_data_key(d())], |_| Ok(())); let result = lazy.zip(generating(&keyset, 1)).await; - assert!(matches!( - result, - Err::<((), Vec<Vec<u8>>), _>(Error::ResponseShape) - )); + assert!( + matches!(result, Err::<((), Vec<Vec<u8>>), _>(Error::ResponseShape)), + "leaving a minted key unconsumed is a composition bug: {result:?}" + ); } /// Partial consumption counts too: two requested, one drawn. @@ -853,10 +939,11 @@ mod tests { responses.next_generated_key().map(|key| key.tag) }); - assert!(matches!( - lazy.await, - Err::<Vec<u8>, _>(Error::ResponseShape) - )); + let result = lazy.await; + assert!( + matches!(result, Err::<Vec<u8>, _>(Error::ResponseShape)), + "two requested, one drawn, is still an under-draw: {result:?}" + ); } /// The two kinds are tracked separately: consuming every generated key @@ -875,10 +962,11 @@ mod tests { responses.next_generated_key().map(|key| key.tag) }); - assert!(matches!( - lazy.await, - Err::<Vec<u8>, _>(Error::ResponseShape) - )); + let result = lazy.await; + assert!( + matches!(result, Err::<Vec<u8>, _>(Error::ResponseShape)), + "the retrieved key was left behind: {result:?}" + ); } #[tokio::test] @@ -889,9 +977,17 @@ mod tests { .await .unwrap(); - assert_eq!(value, 9); - assert_eq!(cipher.kms().generate_calls(), 0); - assert_eq!(cipher.kms().retrieve_calls(), 0); + assert_eq!(value, 9, "a request-free pending still resolves"); + assert_eq!( + cipher.kms().generate_calls(), + 0, + "no requests, no generate_keys call" + ); + assert_eq!( + cipher.kms().retrieve_calls(), + 0, + "no requests, no retrieve_keys call" + ); } /// A pending that asks for `n` data keys and resolves to the `(iv, tag)` @@ -921,7 +1017,11 @@ mod tests { let cipher = cipher().await; let keyset = cipher.default_keyset(); let pairs = generating_pairs(&keyset, 2).await.unwrap(); - assert_eq!(cipher.kms().generate_calls(), 1); + assert_eq!( + cipher.kms().generate_calls(), + 1, + "the setup seal is one call" + ); let requests: Vec<Request> = pairs .iter() @@ -932,10 +1032,18 @@ mod tests { }); let (count, fresh) = retrieve.zip(generating(&keyset, 1)).await.unwrap(); - assert_eq!(count, 2); - assert_eq!(fresh.len(), 1); - assert_eq!(cipher.kms().generate_calls(), 2); - assert_eq!(cipher.kms().retrieve_calls(), 1); + assert_eq!(count, 2, "both keys were retrieved"); + assert_eq!(fresh.len(), 1, "and the fresh key was generated"); + assert_eq!( + cipher.kms().generate_calls(), + 2, + "one generate for the setup, one for the mixed assembly" + ); + assert_eq!( + cipher.kms().retrieve_calls(), + 1, + "the mixed assembly retrieves in one call" + ); } /// Every request's descriptor reaches ZeroKMS on its own payload, in @@ -983,7 +1091,7 @@ mod tests { .await .unwrap(); - assert_eq!(count, 2); + assert_eq!(count, 2, "both keys were retrieved"); assert_eq!( cipher.kms().retrieve_descriptors(), vec![vec!["users/name".to_owned(), "users/email".to_owned()]] @@ -1009,7 +1117,11 @@ mod tests { matches!(err, Error::DescriptorTooLong { len } if len == Descriptor::MAX_LEN + 1), "{err}" ); - assert_eq!(cipher.kms().generate_calls(), 0); + assert_eq!( + cipher.kms().generate_calls(), + 0, + "no key is minted for a batch that is refused" + ); let mut pairs = generating_pairs(&keyset, 1).await.unwrap(); let (iv, tag) = pairs.remove(0); @@ -1023,7 +1135,11 @@ mod tests { panic!("an over-long descriptor must be refused"); }; assert!(matches!(err, Error::DescriptorTooLong { .. }), "{err}"); - assert_eq!(cipher.kms().retrieve_calls(), 0); + assert_eq!( + cipher.kms().retrieve_calls(), + 0, + "and none is retrieved either" + ); // At the limit is fine. let before = cipher.kms().generate_calls(); @@ -1037,7 +1153,11 @@ mod tests { .is_ok(), "at the limit" ); - assert_eq!(cipher.kms().generate_calls(), before + 1); + assert_eq!( + cipher.kms().generate_calls(), + before + 1, + "a descriptor exactly at the limit is dispatched" + ); } // ========================================================================= @@ -1058,7 +1178,11 @@ mod tests { let result = pending.await; assert!(matches!(result, Err(Error::NoKeyset)), "{result:?}"); - assert_eq!(cipher.kms().generate_calls(), 0); + assert_eq!( + cipher.kms().generate_calls(), + 0, + "refused at construction, before any call" + ); } /// Data keys are minted under the scope's keyset, and that is what @@ -1071,7 +1195,8 @@ mod tests { assert_eq!( cipher.kms().generate_keysets(), - vec![Some(Uuid::from_u128(9))] + vec![Some(Uuid::from_u128(9))], + "the scope's keyset is what ZeroKMS is asked to mint under" ); } @@ -1102,7 +1227,11 @@ mod tests { ), "{result:?}" ); - assert_eq!(cipher.kms().retrieve_calls(), 0); + assert_eq!( + cipher.kms().retrieve_calls(), + 0, + "refused at construction, before any key is retrieved" + ); } /// Scoping a pending built through the client (the constrained decrypt @@ -1128,7 +1257,11 @@ mod tests { matches!(result, Err(Error::ForeignKeyset { .. })), "{result:?}" ); - assert_eq!(cipher.kms().retrieve_calls(), 0); + assert_eq!( + cipher.kms().retrieve_calls(), + 0, + "scoping applies the rule before any key is retrieved" + ); } /// Two pendings scoped to different keysets are one tenant's row and @@ -1151,7 +1284,11 @@ mod tests { matches!(result, Err(Error::KeysetMismatch { .. })), "{result:?}" ); - assert_eq!(cipher.kms().generate_calls(), 0); + assert_eq!( + cipher.kms().generate_calls(), + 0, + "a mismatched merge mints nothing" + ); } /// An unscoped pending merged with a scoped one takes the scope: a @@ -1165,10 +1302,15 @@ mod tests { .await .unwrap(); - assert_eq!((n, tags.len()), (7, 1)); + assert_eq!( + (n, tags.len()), + (7, 1), + "both sides resolve: the ready value and the minted key" + ); assert_eq!( cipher.kms().generate_keysets(), - vec![Some(keyset.keyset_id())] + vec![Some(keyset.keyset_id())], + "the merged assembly took the scoped side's keyset" ); } @@ -1202,7 +1344,11 @@ mod tests { vec![a1.0, b1.0, a2.0], "keys must come back in request order" ); - assert_eq!(cipher.kms().retrieve_calls(), 2); + assert_eq!( + cipher.kms().retrieve_calls(), + 2, + "two keysets, two retrieve_keys calls" + ); assert_eq!( cipher.kms().retrieve_keysets(), vec![Some(a.keyset_id()), Some(b.keyset_id())], diff --git a/packages/stack-encrypt/tests/keysets.rs b/packages/stack-encrypt/tests/keysets.rs index 44aa9513e..0a58b4fd2 100644 --- a/packages/stack-encrypt/tests/keysets.rs +++ b/packages/stack-encrypt/tests/keysets.rs @@ -96,8 +96,16 @@ async fn init_loads_the_default_keyset_once() { .await .expect("resolve") .0; - assert_eq!(cipher.default_keyset().keyset_id(), expected); - assert_eq!(cipher.default_keyset().keyset_name(), None); + assert_eq!( + cipher.default_keyset().keyset_id(), + expected, + "init resolves the source's own default keyset" + ); + assert_eq!( + cipher.default_keyset().keyset_name(), + None, + "an unnamed builder keyset leaves the default with no name" + ); assert_eq!(cipher.kms().loads(), 1, "default_keyset() never loads"); } @@ -110,9 +118,17 @@ async fn a_named_default_knows_its_name() { .await .expect("build cipher"); - assert_eq!(cipher.default_keyset().keyset_name(), Some("customers")); + assert_eq!( + cipher.default_keyset().keyset_name(), + Some("customers"), + "a builder keyset named by name reports that name" + ); let by_name = cipher.keyset(name("customers")).await.expect("select"); - assert_eq!(by_name.keyset_id(), cipher.default_keyset().keyset_id()); + assert_eq!( + by_name.keyset_id(), + cipher.default_keyset().keyset_id(), + "selecting the default by its builder name returns the default" + ); assert_eq!( cipher.kms().loads(), 1, @@ -125,7 +141,11 @@ async fn a_keyset_loads_on_first_selection_and_is_cached_after() { let cipher = cipher().await; let first = cipher.keyset(name("acme")).await.expect("select"); assert_eq!(cipher.kms().loads(), 2, "first selection loads"); - assert_eq!(first.keyset_name(), Some("acme")); + assert_eq!( + first.keyset_name(), + Some("acme"), + "a keyset selected by name reports the name it was selected by" + ); let again = cipher.keyset(name("acme")).await.expect("select again"); let by_id = cipher @@ -133,8 +153,16 @@ async fn a_keyset_loads_on_first_selection_and_is_cached_after() { .await .expect("select by id"); assert_eq!(cipher.kms().loads(), 2, "later selections are lookups"); - assert_eq!(again.keyset_id(), first.keyset_id()); - assert_eq!(by_id.keyset_id(), first.keyset_id()); + assert_eq!( + again.keyset_id(), + first.keyset_id(), + "the second selection by name is the same keyset" + ); + assert_eq!( + by_id.keyset_id(), + first.keyset_id(), + "and so is the selection by the id it resolved to" + ); assert_eq!( by_id.keyset_name(), Some("acme"), @@ -146,8 +174,12 @@ async fn a_keyset_loads_on_first_selection_and_is_cached_after() { async fn a_keyset_selected_by_id_is_not_known_by_name() { let cipher = cipher().await; let by_id = cipher.keyset(Uuid::from_u128(42)).await.expect("select"); - assert_eq!(by_id.keyset_name(), None); - assert_eq!(cipher.kms().loads(), 2); + assert_eq!( + by_id.keyset_name(), + None, + "a selection by id knows no name to report" + ); + assert_eq!(cipher.kms().loads(), 2, "default + the selected keyset"); } #[tokio::test] @@ -166,8 +198,16 @@ async fn an_evicted_keyset_reloads_on_its_next_selection() { // `a` was evicted by `b`; selecting it again is a load. The handle taken // earlier is unaffected: it holds its own state. let a_again = cipher.keyset(Uuid::from_u128(1)).await.expect("a again"); - assert_eq!(cipher.kms().loads(), 4); - assert_eq!(a_again.keyset_id(), a.keyset_id()); + assert_eq!( + cipher.kms().loads(), + 4, + "an evicted keyset is loaded again on its next selection" + ); + assert_eq!( + a_again.keyset_id(), + a.keyset_id(), + "the reload is the same keyset" + ); // The default never evicts, however small the cache. let _ = cipher.default_keyset(); @@ -175,7 +215,11 @@ async fn an_evicted_keyset_reloads_on_its_next_selection() { .keyset(cipher.default_keyset().keyset_id()) .await .expect("default by id"); - assert_eq!(cipher.kms().loads(), 4); + assert_eq!( + cipher.kms().loads(), + 4, + "the default never evicts, however small the cache" + ); } /// A name is a lookup, not an identity: past the window, selecting a keyset @@ -190,7 +234,7 @@ async fn a_name_selection_is_re_resolved_after_its_window() { .init() .await .expect("build cipher"); - assert_eq!(cipher.kms().loads(), 1); + assert_eq!(cipher.kms().loads(), 1, "init loads the default keyset"); let acme = cipher.keyset(name("acme")).await.expect("acme"); let _ = cipher.keyset(name("acme")).await.expect("acme again"); @@ -220,7 +264,11 @@ async fn a_name_selection_is_re_resolved_after_its_window() { .keyset(cipher.default_keyset().keyset_id()) .await .expect("default by id"); - assert_eq!(cipher.kms().loads(), 4); + assert_eq!( + cipher.kms().loads(), + 4, + "the default's id is identity too, and is never re-asked" + ); } #[tokio::test] @@ -269,7 +317,11 @@ async fn a_sealed_leaf_names_the_keyset_it_was_sealed_under() { .encrypt("hello".to_string(), "greeting") .await .expect("seal"); - assert_eq!(leaf_of(sealed).keyset_id(), tenant.keyset_id()); + assert_eq!( + leaf_of(sealed).keyset_id(), + tenant.keyset_id(), + "a leaf carries the keyset it was sealed under" + ); let sealed = cipher .default_keyset() @@ -278,7 +330,8 @@ async fn a_sealed_leaf_names_the_keyset_it_was_sealed_under() { .expect("seal"); assert_eq!( leaf_of(sealed).keyset_id(), - cipher.default_keyset().keyset_id() + cipher.default_keyset().keyset_id(), + "and so does one sealed through the default keyset" ); } @@ -296,7 +349,7 @@ async fn the_client_opens_a_leaf_from_any_keyset() { .expect("seal"); let opened: String = cipher.decrypt(sealed, "greeting").await.expect("open"); - assert_eq!(opened, "hello"); + assert_eq!(opened, "hello", "the client opens another keyset's leaf"); assert_eq!( cipher.kms().retrieve_keysets(), vec![Some(tenant.keyset_id())], @@ -314,7 +367,7 @@ async fn a_keyset_handle_opens_its_own_leaves() { .expect("seal"); let opened: String = tenant.decrypt(sealed, "greeting").await.expect("open"); - assert_eq!(opened, "hello"); + assert_eq!(opened, "hello", "a keyset handle opens its own leaf"); } #[tokio::test] @@ -361,14 +414,14 @@ async fn the_target_path_through_a_keyset_handle_is_constrained_too() { .decrypt_into(&acme, nonempty!("users/age")) .await .expect("own keyset opens"); - assert_eq!(opened, 34); + assert_eq!(opened, 34, "the sealing keyset opens its own leaf"); let opened: u32 = seal() .await .decrypt_into(&cipher, nonempty!("users/age")) .await .expect("the client opens"); - assert_eq!(opened, 34); + assert_eq!(opened, 34, "and so does the client it belongs to"); let result: Result<u32, _> = seal() .await @@ -400,7 +453,11 @@ async fn a_mixed_keyset_column_opens_through_the_client_in_one_call_per_keyset() .decrypt_into(&cipher, nonempty!("users/age")) .await .expect("open"); - assert_eq!(opened, vec![1, 2, 3]); + assert_eq!( + opened, + vec![1, 2, 3], + "a two-tenant column opens in row order" + ); assert_eq!( cipher.kms().retrieve_keysets(), vec![Some(acme.keyset_id()), Some(globex.keyset_id())], @@ -427,5 +484,8 @@ async fn a_mixed_keyset_column_does_not_open_through_a_keyset_handle() { matches!(result, Err(Error::ForeignKeyset { .. })), "{result:?}" ); - assert!(cipher.kms().retrieve_keysets().is_empty()); + assert!( + cipher.kms().retrieve_keysets().is_empty(), + "refused before any key was retrieved" + ); } diff --git a/packages/stack-encrypt/tests/target.rs b/packages/stack-encrypt/tests/target.rs index 5cad479aa..0a652e221 100644 --- a/packages/stack-encrypt/tests/target.rs +++ b/packages/stack-encrypt/tests/target.rs @@ -99,8 +99,11 @@ async fn equality_leaf_binds_the_context() { #[tokio::test] async fn term_derivation_makes_no_kms_calls() { - // Terms derive under the index key the cipher already holds: building a - // query probe must never touch ZeroKMS. + // Under the local HMAC backend, terms derive under the index key the + // cipher already holds, so a query probe settles with no ZeroKMS call. + // That is this backend's property, not the term API's contract: a + // backend that derives terms at the server (ZeroKMS v2) settles the same + // `Pending` through a request. let (cipher, generates, retrieves) = counting_cipher().await; let cipher = cipher.default_keyset(); @@ -787,12 +790,23 @@ async fn a_failed_field_fails_the_record_before_any_kms_call() { ); } +/// Which `StackCipher` *value* a pending was built through is not part of +/// the merge rule — the keyset is. Two ciphers over the same client config +/// resolve the same default keyset, so their pendings merge and settle as +/// one batch, through the cipher the assembly dispatches on. (Before the +/// multi-keyset `StackCipher` this was pointer equality on the cipher, and +/// refused the pair.) #[tokio::test] -async fn pendings_from_different_ciphers_refuse_to_merge() { +async fn pendings_from_two_ciphers_over_the_same_keyset_merge() { let (cipher_a, generates, _) = counting_cipher().await; let cipher_a = cipher_a.default_keyset(); let cipher_b = counting_cipher().await.0; let cipher_b = cipher_b.default_keyset(); + assert_eq!( + cipher_a.keyset_id(), + cipher_b.keyset_id(), + "two ciphers over the same client config share a default keyset" + ); let v = "v".to_string(); let w = "w".to_string(); @@ -803,7 +817,11 @@ async fn pendings_from_different_ciphers_refuse_to_merge() { nonempty!("users/x"), )) .await; - assert!(matches!(zipped, Err(Error::CipherMismatch))); + assert!( + zipped.is_ok(), + "two ciphers over the same keyset must merge: {:?}", + zipped.err() + ); let column = Pending::all( &cipher_a, @@ -813,9 +831,54 @@ async fn pendings_from_different_ciphers_refuse_to_merge() { ], ) .await; - assert!(matches!(column, Err(Error::CipherMismatch))); + assert!( + column.is_ok(), + "a column drawn from two ciphers over one keyset must merge: {:?}", + column.err() + ); - assert_eq!(generates.load(AtomicOrdering::SeqCst), 0); + assert_eq!( + generates.load(AtomicOrdering::SeqCst), + 2, + "each merged assembly settles as exactly one generate_keys call" + ); +} + +/// The rule the cipher check gave way to: two *keysets* still refuse to +/// merge, whichever ciphers they came from, and with no I/O. +#[tokio::test] +async fn pendings_from_two_ciphers_over_different_keysets_refuse_to_merge() { + let (cipher_a, generates, _) = counting_cipher().await; + let acme = cipher_a + .keyset(IdentifiedBy::Name("acme".to_string().into())) + .await + .unwrap(); + let cipher_b = counting_cipher().await.0; + let globex = cipher_b + .keyset(IdentifiedBy::Name("globex".to_string().into())) + .await + .unwrap(); + let v = "v".to_string(); + let w = "w".to_string(); + + let zipped = StackCipherText::encrypt_from(&v, &acme, nonempty!("users/x")) + .zip(StackCipherText::encrypt_from( + &w, + &globex, + nonempty!("users/x"), + )) + .await; + assert!( + matches!(zipped, Err(Error::KeysetMismatch { left, right }) + if left == acme.keyset_id() && right == globex.keyset_id()), + "expected KeysetMismatch, got: {zipped:?}" + ); + + assert_eq!( + generates.load(AtomicOrdering::SeqCst), + 0, + "a mismatched merge must be refused before any key is minted" + ); } #[tokio::test] From ef117d388895fe0aa2b8b6d50bf2c18fecc37dff Mon Sep 17 00:00:00 2001 From: Dan Draper <dan@cipherstash.com> Date: Sat, 12 Sep 2026 19:22:18 -0400 Subject: [PATCH 524/686] fix(stack-encrypt): a lookup whose answer lost is answered with the one that won, and eviction precedes binding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review findings on cipherstash/cipherstash-suite#2211 (Copilot), the round on ea2744b23. `StackCipher::keyset` handed out whatever ZeroKMS said for its lookup, even when the cache had refused to bind it because a later lookup had already spoken for that name. The caller then held the keyset the name had since left, for one call, while every selection after it got the other. `KeysetCache::insert` now returns what the lookup should be answered with: the later answer where one exists, for the keyset or for the name, and the answer itself otherwise. The eviction a new id triggers ran after the name was bound, so an answer from before a rename could bind its name a step before the entry it should have lost to left its place on the watermark — when that entry was the one its own insert evicted. Eviction now runs first. Both cases have a test. Docs: `StackCipher::decrypt` states its fan-out — one retrieve per distinct keyset in the input, bounded by the keysets the client can retrieve from since ZeroKMS refuses a tag it did not mint — and points a service opening untrusted per-tenant rows at `KeysetCipher`, the one-round-trip form. No cap is added: the legitimate batch path is exactly the mixed case, and no caller can name the number yet. `keyset_name` describes the name the state was loaded under, which an id selection can carry too; the `pending` module doc says one generate call and one retrieve per keyset; `match_terms` says its empty-text failure arrives as `Error::Term` once awaited. Claude-Session: https://claude.ai/code/session_01QbNwRNqa1qo3b8ZSqULsGE --- packages/stack-encrypt/src/cipher.rs | 25 +++- packages/stack-encrypt/src/keyset.rs | 144 ++++++++++++++++--- packages/stack-encrypt/src/sem/mod.rs | 8 +- packages/stack-encrypt/src/target/pending.rs | 6 +- 4 files changed, 154 insertions(+), 29 deletions(-) diff --git a/packages/stack-encrypt/src/cipher.rs b/packages/stack-encrypt/src/cipher.rs index 8dd5c7621..31e698ae9 100644 --- a/packages/stack-encrypt/src/cipher.rs +++ b/packages/stack-encrypt/src/cipher.rs @@ -417,9 +417,10 @@ impl<K: IndexKeySource> StackCipher<K> { // Loaded outside the lock: a round trip must not hold up every other // selection. Two selections racing on the same miss load twice; the // cache keeps both keysets by id, and the name follows the later - // lookup whichever answer lands first. + // lookup whichever answer lands first — and so does this caller, + // who is handed the answer that won, not the one that lost. let state = load_keyset(&self.kms, Some(keyset)).await?; - self.keysets().insert(Arc::clone(&state), resolution); + let state = self.keysets().insert(state, resolution); Ok(KeysetCipher::new(self, state)) } } @@ -708,6 +709,19 @@ impl<K: DataKeySource> StackCipher<K> { /// sealed under, and this opens leaves from any keyset the client is /// authorised for. To insist on one keyset, decrypt through its /// [`KeysetCipher`] instead. + /// + /// # Fan-out + /// + /// The retrieve calls are one per *distinct keyset* among the leaves, + /// issued in sequence, and the keyset ids come from the ciphertext — + /// so their number is the input's to decide, up to the keysets this + /// client can retrieve from (ZeroKMS refuses a retrieve whose tag it + /// did not mint, and the first refusal ends the batch). A ciphertext + /// assembled from many tenants' leaves costs a round trip per tenant to + /// open here, whoever assembled it. A service opening rows it does not + /// trust — one tenant's data at a time — should hold that tenant's + /// [`KeysetCipher`], whose decrypt is one round trip at most and refuses + /// a foreign leaf before any. pub async fn decrypt<'a, T, A>(&self, ciphertext: StackCipherText, aad: A) -> Result<T, Error> where T: Decrypt<'static> + 'static, @@ -718,9 +732,10 @@ impl<K: DataKeySource> StackCipher<K> { /// Fetch every leaf's data key (one batched `retrieve_keys` call per /// keyset the leaves were sealed under, every key under the - /// [`Descriptor`] of `aad`) and bind them onto the ciphertext, returning - /// a synchronous [`Decipher`] that does the AEAD opening as the value's - /// [`Decrypt`] impl drives it. + /// [`Descriptor`] of `aad`; see [`decrypt`](Self::decrypt) on what that + /// fan-out means for untrusted input) and bind them onto the + /// ciphertext, returning a synchronous [`Decipher`] that does the AEAD + /// opening as the value's [`Decrypt`] impl drives it. /// /// This is the decrypt-side counterpart to passing `&keyset` (a /// [`Cipher`]) on the encrypt side, mirroring `Aes256Cipher::decipher`: diff --git a/packages/stack-encrypt/src/keyset.rs b/packages/stack-encrypt/src/keyset.rs index e90632d1b..465a5e7e5 100644 --- a/packages/stack-encrypt/src/keyset.rs +++ b/packages/stack-encrypt/src/keyset.rs @@ -295,12 +295,35 @@ impl KeysetCache { /// whole. It has nothing newer to say about the keyset, and applying it /// would undo what a later lookup applied — restoring, under a full /// window, a name the keyset has since been renamed away from. - pub(crate) fn insert(&mut self, state: Arc<KeysetState>, resolution: Resolution) { - if self - .last_resolution_of(state.id) - .is_some_and(|applied| applied > resolution) + /// + /// Returns what the lookup should be answered with, which is not always + /// what ZeroKMS said: when a later lookup has already spoken — for this + /// keyset, or for the name this one asked under — the caller gets that + /// later answer, the same one every selection after it gets. The + /// answer that lost is not handed out even once. An answer nothing + /// later contradicts is returned as it is, whether or not its name + /// bound (an answer older than the eviction watermark has no binding to + /// lose to, and is still the latest thing said about its name). + pub(crate) fn insert( + &mut self, + state: Arc<KeysetState>, + resolution: Resolution, + ) -> Arc<KeysetState> { + if let Some(entry) = self.entry(state.id) { + if entry.resolution > resolution { + return Arc::clone(&entry.state); + } + } + // Evict before binding: the entry that goes may be the one whose + // answer this one is older than, and its place in the order must be + // on the watermark before `bind` consults it — or an answer from + // before a rename binds a name it should have lost to the entry its + // own insert evicts. + if state.id != self.default_id() + && !self.by_id.contains_key(&state.id) + && self.by_id.len() >= self.capacity.get() { - return; + self.evict_oldest(); } // `bind` gives the entry the name it binds, when the cache already // holds one; a first insert carries it over below instead. @@ -308,12 +331,21 @@ impl KeysetCache { Some(name) => self.bind(name, state.id, resolution), None => false, }; + // A name lookup is answered with whatever the name means now. When + // this answer's binding lost to a later one, that is the keyset the + // later lookup resolved — held, since no binding outlives its id. + let answer = match (&state.name, bound) { + (Some(name), false) => self + .by_name + .get(name) + .and_then(|alias| self.entry(alias.id)) + .map(|entry| Arc::clone(&entry.state)) + .unwrap_or_else(|| Arc::clone(&state)), + _ => Arc::clone(&state), + }; if state.id == self.default_id() { self.default.resolution = resolution; - return; - } - if !self.by_id.contains_key(&state.id) && self.by_id.len() >= self.capacity.get() { - self.evict_oldest(); + return answer; } self.tick += 1; match self.by_id.get_mut(&state.id) { @@ -335,6 +367,7 @@ impl KeysetCache { ); } } + answer } /// Bind `name` to `id` for the lookup `resolution`; false if a later @@ -378,12 +411,6 @@ impl KeysetCache { true } - /// The lookup whose answer last spoke for `id`, if the cache holds it. - /// An id it has never held (or has evicted) has nothing to supersede. - fn last_resolution_of(&self, id: Uuid) -> Option<Resolution> { - self.entry(id).map(|entry| entry.resolution) - } - /// The name `id` is currently bound under, if any. fn current_name_of(&self, id: Uuid) -> Option<String> { self.entry(id).and_then(|entry| entry.name.clone()) @@ -435,7 +462,7 @@ impl KeysetCache { #[cfg(test)] pub(crate) fn load(&mut self, state: Arc<KeysetState>) { let resolution = self.resolution(); - self.insert(state, resolution); + let _ = self.insert(state, resolution); } #[cfg(test)] @@ -510,9 +537,12 @@ impl<'k, K> KeysetCipher<'k, K> { self.state.id } - /// The name this keyset was selected by, if it was selected by name - /// (the default keyset knows its name only when the builder named it). - /// A label from the time of selection, not an identity: see the + /// The name this keyset's loaded state was last resolved under, if any. + /// That is the name of the lookup that loaded (or last refreshed) it, + /// not necessarily of the selection that produced this handle: a + /// selection by id returns state another selection may have loaded by + /// name, and the default keyset knows its name only when the builder + /// named it. A label from the time of loading, not an identity: see the /// [module docs](self#ids-are-identity-names-are-looked-up). pub fn keyset_name(&self) -> Option<&str> { self.state.name.as_deref() @@ -988,6 +1018,82 @@ mod tests { assert_eq!(cache.names(), 0, "and no other binding was made"); } + /// The entry an old answer's own insert evicts can be the very one its + /// binding should lose to, so eviction must happen before the binding + /// is tried: with room for one, `old` resolves to keyset 1, then to + /// keyset 2, then keyset 2 is renamed `new`, and the first answer lands + /// last — into a cache that holds keyset 2 under `new` and has no + /// binding for `old` at all. Caching keyset 1 evicts keyset 2; the + /// watermark that eviction leaves is what refuses the stale `old`. + #[test] + fn an_older_answer_does_not_bind_a_name_past_the_entry_its_own_insert_evicts() { + let mut cache = cache(1); + let oldest = ticket(cache.get(&name("old"))); + let middle = ticket(cache.get(&name("old"))); + let _ = cache.insert(state(2, Some("old")), middle); + let newest = ticket(cache.get(&name("new"))); + let _ = cache.insert(state(2, Some("new")), newest); + assert!( + matches!(cache.get(&name("old")), Lookup::Miss(_)), + "renaming keyset 2 to `new` took `old` off it" + ); + + let answer = cache.insert(state(1, Some("old")), oldest); + assert_eq!( + answer.id, + Uuid::from_u128(1), + "nothing later is known about `old`, so the answer stands for this lookup" + ); + assert_eq!( + hit(cache.get(&id(1))), + Some(Uuid::from_u128(1)), + "and keyset 1 is cached by id, evicting keyset 2" + ); + assert!( + matches!(cache.get(&name("old")), Lookup::Miss(_)), + "but an answer older than the entry its insert evicted binds no name" + ); + assert_eq!(cache.names(), 0, "and no other binding was made"); + } + + /// The caller of a lookup whose answer lost to a later one is answered + /// with the later one — the keyset every selection after it gets — not + /// with the answer that lost, which would mint under a keyset the name + /// has since left. + #[test] + fn a_lookup_whose_answer_lost_is_answered_with_the_one_that_won() { + let mut cache = cache(4); + let earlier = ticket(cache.get(&name("acme"))); + let later = ticket(cache.get(&name("acme"))); + + // `acme` moved from keyset 1 to keyset 2 between the two lookups, + // and the later answer lands first. + let _ = cache.insert(state(2, Some("acme")), later); + let answer = cache.insert(state(1, Some("acme")), earlier); + assert_eq!( + answer.id, + Uuid::from_u128(2), + "the earlier lookup is answered with what `acme` means now" + ); + assert_eq!( + hit(cache.get(&id(1))), + Some(Uuid::from_u128(1)), + "keyset 1 is still cached by id: its key material is right whichever lookup asked" + ); + + // The same keyset resolved under a newer name: the older answer is + // dropped whole, and its caller gets the state the keyset holds. + let earlier = ticket(cache.get(&name("acme-corp"))); + let later = ticket(cache.get(&name("acme-corp"))); + let _ = cache.insert(state(3, Some("acme-corp")), later); + let answer = cache.insert(state(3, Some("acme")), earlier); + assert_eq!( + answer.name.as_deref(), + Some("acme-corp"), + "an answer older than the keyset's own is replaced by the keyset's" + ); + } + /// Past the window a name lookup is stale — the keyset is still there, /// the binding is not trusted — and a re-resolution refreshes it. #[test] diff --git a/packages/stack-encrypt/src/sem/mod.rs b/packages/stack-encrypt/src/sem/mod.rs index 7154500d1..816344577 100644 --- a/packages/stack-encrypt/src/sem/mod.rs +++ b/packages/stack-encrypt/src/sem/mod.rs @@ -1056,9 +1056,11 @@ impl<K> KeysetCipher<'_, K> { /// time (index the probe text, then test [`MatchTerm::contains`] /// server-side). /// - /// Returns [`TermError::EmptyTermText`] when the text yields no tokens — - /// empty or separator-only text, or an n-gram probe shorter than the gram - /// length (which could never match; see [`Tokenizer::Ngram`]). + /// Fails with [`TermError::EmptyTermText`] — as + /// [`Error::Term`](crate::Error::Term), once awaited — when the text + /// yields no tokens: empty or separator-only text, or an n-gram probe + /// shorter than the gram length (which could never match; see + /// [`Tokenizer::Ngram`]). pub fn match_terms<'c, O>( &self, text: &str, diff --git a/packages/stack-encrypt/src/target/pending.rs b/packages/stack-encrypt/src/target/pending.rs index bf2bbef95..105123a6a 100644 --- a/packages/stack-encrypt/src/target/pending.rs +++ b/packages/stack-encrypt/src/target/pending.rs @@ -4,8 +4,10 @@ //! //! A `Pending` is built synchronously and settled once. Combining pendings //! merges their [`Request`]s without doing any I/O; awaiting the combined -//! result issues one batched ZeroKMS call per request kind -//! ([`dispatch`]) and then runs each fulfilment over exactly the +//! result issues the batched ZeroKMS calls ([`dispatch`]) — one +//! `generate_keys` for every generate, since a pending mints under one +//! keyset, and one `retrieve_keys` per keyset the leaves being opened were +//! sealed under — and then runs each fulfilment over exactly the //! [`Responses`] its own requests asked for. use std::borrow::Cow; From b0e2668b82d8fd1e42ff9c151566f35685fd54e6 Mon Sep 17 00:00:00 2001 From: Dan Draper <dan@cipherstash.com> Date: Sat, 12 Sep 2026 19:36:00 -0400 Subject: [PATCH 525/686] docs(stack-encrypt): drop a redundant explicit link target that rustdoc refuses under `-D warnings` `Error` is in scope in `sem`, so `[`Error::Term`]` resolves on its own; the explicit `crate::Error::Term` target failed the no-http rustdoc build in CI (`rustdoc::redundant_explicit_links`). Claude-Session: https://claude.ai/code/session_01QbNwRNqa1qo3b8ZSqULsGE --- packages/stack-encrypt/src/sem/mod.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/stack-encrypt/src/sem/mod.rs b/packages/stack-encrypt/src/sem/mod.rs index 816344577..57753d3b9 100644 --- a/packages/stack-encrypt/src/sem/mod.rs +++ b/packages/stack-encrypt/src/sem/mod.rs @@ -1057,7 +1057,7 @@ impl<K> KeysetCipher<'_, K> { /// server-side). /// /// Fails with [`TermError::EmptyTermText`] — as - /// [`Error::Term`](crate::Error::Term), once awaited — when the text + /// [`Error::Term`], once awaited — when the text /// yields no tokens: empty or separator-only text, or an n-gram probe /// shorter than the gram length (which could never match; see /// [`Tokenizer::Ngram`]). From 23c2e53b44239605750a013ddcde87e92a42c75e Mon Sep 17 00:00:00 2001 From: Dan Draper <dan@cipherstash.com> Date: Sat, 12 Sep 2026 20:01:50 -0400 Subject: [PATCH 526/686] fix(stack-encrypt): a losing lookup's answer is decided before its insert evicts the winner; the derive's keyset lifetime steps aside for a record's own MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review findings on cipherstash/cipherstash-suite#2211 (Copilot), the round on d34ad41af. With room for one, an earlier `acme -> A` answer landing after a later `acme -> B` evicted B — alias and all — before `bind` refused it, and the fallback then handed A to the caller: the very answer the cache had just refused to bind. What a name means now is taken before eviction, so the earlier caller gets B whether or not caching A evicts it. Test at capacity 1. `'__k` is a legal lifetime for a record to declare, and the derive added it unconditionally; a record declaring it got two. The keyset lifetime is now the first of `'__k`, `'__k_`, `'__k__`, … the record does not declare, threaded through the cipher type, the field bounds and the emitted calls. Unit test on the expansion and a compiling record in `tests/derive.rs`. The guest's record doc claimed a term backend's requests would join the record's batch; `build_row` settles each term's pending as it goes, so under a ZeroKMS-deriving backend that is one round trip per term until the term pendings are merged in. The doc now says so. The PR description no longer says `CipherMismatch` still means two clients; it was removed in 135903356 and the keyset is the whole merge rule. Claude-Session: https://claude.ai/code/session_01QbNwRNqa1qo3b8ZSqULsGE --- .../golang/stackencrypt/guest/src/ops.rs | 11 ++-- packages/stack-encrypt-derive/src/encrypt.rs | 45 ++++++++++--- packages/stack-encrypt-derive/src/shape.rs | 40 ++++++++---- packages/stack-encrypt/src/keyset.rs | 64 ++++++++++++++----- packages/stack-encrypt/tests/derive.rs | 11 ++++ 5 files changed, 132 insertions(+), 39 deletions(-) diff --git a/languages/golang/stackencrypt/guest/src/ops.rs b/languages/golang/stackencrypt/guest/src/ops.rs index 12771674e..8421ecb2b 100644 --- a/languages/golang/stackencrypt/guest/src/ops.rs +++ b/languages/golang/stackencrypt/guest/src/ops.rs @@ -27,10 +27,13 @@ //! the field values. However many rows and fields are in one call, all //! ciphertext leaves seal from **one** batched `generate_keys` — the //! pendings are merged before settling, exactly like the derive's `zip`/`all` -//! composition — and index terms derive under the same keyset's index key -//! (under the local HMAC backend with no ZeroKMS traffic of their own; a -//! backend that derives terms at ZeroKMS, as ZeroKMS v2 does, adds its own -//! requests to the same batch). That batch reaches ZeroKMS as one request per +//! composition. Index terms are *not* in that batch: `build_row` settles +//! each term's pending as it builds the row, which under the local HMAC +//! backend is no ZeroKMS traffic at all, and under a backend that derives +//! terms at ZeroKMS (as ZeroKMS v2 does) would be one round trip per term +//! until the term pendings are merged into the row's batch — a change for +//! this module when that backend lands, not something the record path does +//! today. The ciphertext batch reaches ZeroKMS as one request per //! `ClientOpts::max_keys_per_req` keyed leaves (500 by default, sent //! sequentially: the guest pins `max_concurrent_reqs` to 1), so "one call" //! is exact up to 500 leaves and "one call per 500" past it. See diff --git a/packages/stack-encrypt-derive/src/encrypt.rs b/packages/stack-encrypt-derive/src/encrypt.rs index 89d65866d..6203d3fe7 100644 --- a/packages/stack-encrypt-derive/src/encrypt.rs +++ b/packages/stack-encrypt-derive/src/encrypt.rs @@ -3,11 +3,12 @@ use proc_macro2::TokenStream; use quote::{quote, quote_spanned}; use syn::spanned::Spanned; -use syn::{parse_quote, DeriveInput, Generics, Path, Result, Type}; +use syn::{parse_quote, DeriveInput, Generics, Lifetime, Path, Result, Type}; use crate::shape::{ - cipher_type, context_param, impl_sources, push_field_bounds, push_keyset_lifetime, trait_impl, - zip_fields, CallerContext, ContextImpl, Field, FieldBound, Kind, Record, + cipher_type, context_param, impl_sources, keyset_lifetime, push_field_bounds, + push_keyset_lifetime, trait_impl, zip_fields, CallerContext, ContextImpl, Field, FieldBound, + Kind, Record, }; pub(crate) fn derive(input: DeriveInput) -> Result<TokenStream> { @@ -31,6 +32,7 @@ pub(crate) fn derive(input: DeriveInput) -> Result<TokenStream> { // the where clause spells out so a mismatch is reported against the // field type. let (sources, generic) = impl_sources(&record, parse_quote!(__S)); + let keyset = keyset_lifetime(&input.generics); let mut impls = Vec::with_capacity(sources.len() * 2); for source in &sources { for which in ContextImpl::BOTH { @@ -39,13 +41,13 @@ pub(crate) fn derive(input: DeriveInput) -> Result<TokenStream> { generics.params.push(parse_quote!(__S)); } generics.params.push(parse_quote!(__K)); - push_keyset_lifetime(&mut generics); + push_keyset_lifetime(&mut generics, &keyset); push_field_bounds( &mut generics, krate, &whole, source, - FieldBound::Encrypt, + FieldBound::Encrypt(keyset.clone()), which, ); let ctx = context_param( @@ -56,11 +58,12 @@ pub(crate) fn derive(input: DeriveInput) -> Result<TokenStream> { &derived, ); let uses_context = derived.iter().any(|f| f.uses_callers_context(which)); - let body = body(krate, &record, &derived, source, which); + let body = body(krate, &record, &derived, source, which, &keyset); impls.push(impl_block( &input, krate, &generics, + &keyset, source, &ctx, uses_context, @@ -77,10 +80,12 @@ pub(crate) fn derive(input: DeriveInput) -> Result<TokenStream> { /// context parameter is unnamed when no field uses it — every field of the /// `()` impl of a `struct` derive carries its own — so the expansion warns /// of nothing. +#[allow(clippy::too_many_arguments)] fn impl_block( input: &DeriveInput, krate: &Path, generics: &Generics, + keyset: &Lifetime, source: &Type, ctx: &Type, uses_context: bool, @@ -91,7 +96,7 @@ fn impl_block( } else { quote!(_) }; - let cipher = cipher_type(krate, &FieldBound::Encrypt); + let cipher = cipher_type(krate, &FieldBound::Encrypt(keyset.clone())); trait_impl( input, generics, @@ -151,6 +156,7 @@ fn body( derived: &[&Field], source: &Type, which: ContextImpl, + keyset: &Lifetime, ) -> TokenStream { let assign = record.fields.iter().map(|field| { let member = &field.member; @@ -183,7 +189,7 @@ fn body( }; let context_ty = field.field_context().ty(krate, which); let call = quote_spanned! {ty.span()=> - <#ty as #krate::target::EncryptFrom<#source_ty, #krate::KeysetCipher<'__k, __K>, #context_ty>>::encrypt_from + <#ty as #krate::target::EncryptFrom<#source_ty, #krate::KeysetCipher<#keyset, __K>, #context_ty>>::encrypt_from }; quote!(#call(#source_expr, __cipher, #context,)) }, @@ -244,6 +250,29 @@ mod tests { assert_lacks(&expansion, quote!(<StackCipherText as ::stack_encrypt::target::Decryptable>)); } + /// `'__k` is a legal lifetime for the record to declare, so the derive's + /// own must not collide with it: the impl gains `'__k_` instead, and the + /// record's `'__k` is left to mean what the user made it mean. + #[test] + #[rustfmt::skip] + fn the_keyset_lifetime_steps_aside_for_a_record_that_declares_it() { + let expansion = expand(parse_quote! { + struct Borrowed<'__k> { + c: StackCipherText, + #[stash(default)] + label: Option<&'__k str>, + } + }); + assert_contains(&expansion, quote! { + impl<'__k_, '__k, __S, __K> ::stack_encrypt::target::EncryptFrom<__S, ::stack_encrypt::KeysetCipher<'__k_, __K>, ()> + for Borrowed<'__k> + }); + assert_contains(&expansion, quote! { + <StackCipherText as ::stack_encrypt::target::EncryptFrom<__S, ::stack_encrypt::KeysetCipher<'__k_, __K>, ()>>::encrypt_from + }); + assert_lacks(&expansion, quote!(KeysetCipher<'__k, __K>)); + } + #[test] #[rustfmt::skip] fn a_record_gets_one_impl_for_unit_and_one_for_non_empty() { diff --git a/packages/stack-encrypt-derive/src/shape.rs b/packages/stack-encrypt-derive/src/shape.rs index f99f9d701..17eb762ef 100644 --- a/packages/stack-encrypt-derive/src/shape.rs +++ b/packages/stack-encrypt-derive/src/shape.rs @@ -4,8 +4,8 @@ use proc_macro2::{Span, TokenStream}; use quote::quote; use syn::spanned::Spanned; use syn::{ - parse_quote, parse_quote_spanned, Data, DeriveInput, Expr, Fields, Generics, Ident, LitStr, - Member, Path, Result, Type, + parse_quote, parse_quote_spanned, Data, DeriveInput, Expr, Fields, Generics, Ident, Lifetime, + LitStr, Member, Path, Result, Type, }; use crate::attrs::{ContainerAttrs, FieldAttrs}; @@ -330,7 +330,9 @@ pub(crate) enum FieldBound { /// `EncryptFrom<Source, ..>` — for encrypt, on fields derived from the /// whole source (the caller filters; a `from` field's obligation is /// checked in the body instead, where the source field's type is known). - Encrypt, + /// Carries the lifetime of the `KeysetCipher` the impl binds to + /// ([`keyset_lifetime`]). + Encrypt(Lifetime), /// `DecryptField<Plaintext, ..>` — for automatic decrypt, on every /// candidate field. DecryptField, @@ -355,7 +357,7 @@ pub(crate) fn push_field_bounds( which: ContextImpl, ) { let trait_name: Ident = match bound { - FieldBound::Encrypt => parse_quote!(EncryptFrom), + FieldBound::Encrypt(_) => parse_quote!(EncryptFrom), FieldBound::DecryptField => parse_quote!(DecryptField), FieldBound::DecryptInto => parse_quote!(DecryptInto), }; @@ -373,22 +375,38 @@ pub(crate) fn push_field_bounds( } /// The cipher a derived impl binds to. Encrypting binds to a keyset, so the -/// encrypt impls are over `KeysetCipher<'__k, __K>` and carry the `'__k` -/// lifetime ([`push_keyset_lifetime`]); decrypting is client-scoped, so the -/// decrypt impls are over `StackCipher<__K>`. +/// encrypt impls are over `KeysetCipher<'__k, __K>` and carry that lifetime +/// ([`keyset_lifetime`], [`push_keyset_lifetime`]); decrypting is +/// client-scoped, so the decrypt impls are over `StackCipher<__K>`. pub(crate) fn cipher_type(krate: &Path, bound: &FieldBound) -> Type { match bound { - FieldBound::Encrypt => parse_quote!(#krate::KeysetCipher<'__k, __K>), + FieldBound::Encrypt(keyset) => parse_quote!(#krate::KeysetCipher<#keyset, __K>), FieldBound::DecryptField | FieldBound::DecryptInto => { parse_quote!(#krate::StackCipher<__K>) } } } -/// Add the `'__k` lifetime of the `KeysetCipher` an encrypt impl binds to. +/// The lifetime of the `KeysetCipher` an encrypt impl binds to: `'__k`, +/// unless the record declares that name itself — it is a legal lifetime +/// for a user's type — in which case the first of `'__k_`, `'__k__`, … it +/// does not. The record's parameters are the user's; a name the derive +/// adds beside them must be one they did not take. +pub(crate) fn keyset_lifetime(generics: &Generics) -> Lifetime { + let mut name = String::from("__k"); + while generics + .lifetimes() + .any(|declared| declared.lifetime.ident == name) + { + name.push('_'); + } + Lifetime::new(&format!("'{name}"), Span::call_site()) +} + +/// Add the lifetime of the `KeysetCipher` an encrypt impl binds to. /// Lifetimes precede type parameters in a generics list, so it goes first. -pub(crate) fn push_keyset_lifetime(generics: &mut Generics) { - generics.params.insert(0, parse_quote!('__k)); +pub(crate) fn push_keyset_lifetime(generics: &mut Generics, keyset: &Lifetime) { + generics.params.insert(0, parse_quote!(#keyset)); } /// The source (or plaintext) types a derive emits one impl each for: the diff --git a/packages/stack-encrypt/src/keyset.rs b/packages/stack-encrypt/src/keyset.rs index 465a5e7e5..f4dbceffa 100644 --- a/packages/stack-encrypt/src/keyset.rs +++ b/packages/stack-encrypt/src/keyset.rs @@ -300,10 +300,12 @@ impl KeysetCache { /// what ZeroKMS said: when a later lookup has already spoken — for this /// keyset, or for the name this one asked under — the caller gets that /// later answer, the same one every selection after it gets. The - /// answer that lost is not handed out even once. An answer nothing - /// later contradicts is returned as it is, whether or not its name - /// bound (an answer older than the eviction watermark has no binding to - /// lose to, and is still the latest thing said about its name). + /// answer that lost is not handed out even once, and that is decided + /// before this insert evicts anything: the entry it evicts can be the + /// very winner. An answer nothing later contradicts is returned as it + /// is, whether or not its name bound (an answer older than the eviction + /// watermark has no binding left to lose to, and is still the latest + /// thing the cache knows about its name). pub(crate) fn insert( &mut self, state: Arc<KeysetState>, @@ -314,6 +316,19 @@ impl KeysetCache { return Arc::clone(&entry.state); } } + // A name lookup is answered with whatever the name means now: when + // a later lookup has already bound it — to this keyset or another — + // the caller gets that keyset (held, since no binding outlives its + // id), and `bind` below refuses this answer as the older one. Taken + // before eviction, which can take that very binding with the entry + // it evicts and leave this answer looking uncontradicted. + let later = state + .name + .as_deref() + .and_then(|name| self.by_name.get(name)) + .filter(|alias| alias.resolution > resolution) + .and_then(|alias| self.entry(alias.id)) + .map(|entry| Arc::clone(&entry.state)); // Evict before binding: the entry that goes may be the one whose // answer this one is older than, and its place in the order must be // on the watermark before `bind` consults it — or an answer from @@ -331,18 +346,7 @@ impl KeysetCache { Some(name) => self.bind(name, state.id, resolution), None => false, }; - // A name lookup is answered with whatever the name means now. When - // this answer's binding lost to a later one, that is the keyset the - // later lookup resolved — held, since no binding outlives its id. - let answer = match (&state.name, bound) { - (Some(name), false) => self - .by_name - .get(name) - .and_then(|alias| self.entry(alias.id)) - .map(|entry| Arc::clone(&entry.state)) - .unwrap_or_else(|| Arc::clone(&state)), - _ => Arc::clone(&state), - }; + let answer = later.unwrap_or_else(|| Arc::clone(&state)); if state.id == self.default_id() { self.default.resolution = resolution; return answer; @@ -1094,6 +1098,34 @@ mod tests { ); } + /// The later answer reaches the earlier caller even when caching the + /// earlier one evicts it: what the name means is decided before the + /// eviction, or the eviction the losing insert triggers would take the + /// winner — alias and all — and leave the loser as the only answer. + #[test] + fn a_lookup_whose_answer_lost_is_answered_with_the_winner_its_own_insert_evicts() { + let mut cache = cache(1); + let earlier = ticket(cache.get(&name("acme"))); + let later = ticket(cache.get(&name("acme"))); + let _ = cache.insert(state(2, Some("acme")), later); + + let answer = cache.insert(state(1, Some("acme")), earlier); + assert_eq!( + answer.id, + Uuid::from_u128(2), + "the earlier lookup is answered with what `acme` means now, though caching its answer evicted keyset 2" + ); + assert_eq!( + hit(cache.get(&id(1))), + Some(Uuid::from_u128(1)), + "keyset 1 is cached by id all the same" + ); + assert!( + matches!(cache.get(&name("acme")), Lookup::Miss(_)), + "and the loser bound no name: it is older than the watermark keyset 2 left" + ); + } + /// Past the window a name lookup is stale — the keyset is still there, /// the binding is not trusted — and a re-resolution refreshes it. #[test] diff --git a/packages/stack-encrypt/tests/derive.rs b/packages/stack-encrypt/tests/derive.rs index 74d51e61a..9ce52c65d 100644 --- a/packages/stack-encrypt/tests/derive.rs +++ b/packages/stack-encrypt/tests/derive.rs @@ -76,6 +76,17 @@ struct SearchableText { #[derive(EncryptFrom)] struct Pair(StackCipherText, EqualityTerm); +/// A record may declare `'__k` itself; the derive's keyset lifetime steps +/// aside rather than colliding with it. Compiling is the test. +#[derive(EncryptFrom)] +#[stash(plaintext = u32)] +#[allow(dead_code)] +struct Borrowed<'__k> { + c: StackCipherText, + #[stash(default)] + label: Option<&'__k str>, +} + /// The record's own generics (and their bounds) are carried through, and the /// where clause makes `Tagged<T>` accept exactly `T` — the ORE term is typed /// by its source. A generic record's one-ciphertext check runs when the From 53accce1922954bfcea3644e3042dac7d51548cd Mon Sep 17 00:00:00 2001 From: Dan Draper <dan@cipherstash.com> Date: Sat, 12 Sep 2026 20:24:24 -0400 Subject: [PATCH 527/686] fix(stack-encrypt): a name ZeroKMS says is unbound orders like any answer, and a dropped answer is answered by its name first MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review findings on cipherstash/cipherstash-suite#2211 (Copilot), the round on 013a3d3a1. A lookup ZeroKMS answered with "no keyset has this name" left the cache untouched, so an earlier positive answer for that name still in flight could land afterwards and bind it fresh for a whole window — routing every later selection to a keyset the name no longer means. The negative answer is now recorded (`KeysetCache::forget`): the binding an earlier lookup made goes, and the watermark rises to the negative lookup, so no earlier answer binds the name after it. Only ZeroKMS's own `KeysetNotFound` counts; a lookup that got no answer says nothing about the name. The watermark is now raised by two things, so it is `watermark`, not `eviction_watermark`; CONTEXT.md and ADR 0002 follow. An answer older than what its keyset held was answered with the keyset's later state, though the caller asked for a name that a later lookup had since bound to another keyset. The name is consulted first, since it is what the caller asked. Both races have a test. Claude-Session: https://claude.ai/code/session_01QbNwRNqa1qo3b8ZSqULsGE --- packages/stack-encrypt/CONTEXT.md | 20 +- ...n-the-leaf-and-names-as-bounded-lookups.md | 6 + packages/stack-encrypt/src/cipher.rs | 31 ++- packages/stack-encrypt/src/keyset.rs | 196 ++++++++++++++---- 4 files changed, 208 insertions(+), 45 deletions(-) diff --git a/packages/stack-encrypt/CONTEXT.md b/packages/stack-encrypt/CONTEXT.md index c40427169..8bd7be313 100644 --- a/packages/stack-encrypt/CONTEXT.md +++ b/packages/stack-encrypt/CONTEXT.md @@ -122,14 +122,18 @@ land in any order; the ticket is what says which *question* was later, and a binding follows the later question rather than the earlier arrival. _Avoid_: generation, version, sequence number -**Eviction watermark**: -The place in the resolution order of the latest entry eviction has dropped. -Once an entry is gone there is nothing left to order an older answer for -that keyset against, so no binding is made from an answer older than the -watermark. One watermark for every name, not one per forgotten name — a -cache whose whole contract is a bound must not grow a record per eviction. -_Avoid_: tombstone, evicted binding (it is the entry's place, not a -binding's) +**Watermark**: +The place in the resolution order of the latest answer the cache holds +nothing of to order an older answer against: an entry eviction has dropped, +or ZeroKMS's answer that a name is bound to nothing. Once an entry is gone +there is nothing left to order an older answer for that keyset against, and +a negative answer is held as no binding at all, so no binding is made from +an answer older than the watermark. One watermark for every name, not one +per forgotten name — a cache whose whole contract is a bound must not grow +a record per eviction or per unbound name. +_Avoid_: tombstone, negative cache, evicted binding (it is the entry's +place, not a binding's), eviction watermark (eviction is one of two things +that raise it) **Foreign keyset**: A keyset other than the one a `KeysetCipher` is bound to, from that diff --git a/packages/stack-encrypt/docs/adr/0002-keyset-id-in-the-leaf-and-names-as-bounded-lookups.md b/packages/stack-encrypt/docs/adr/0002-keyset-id-in-the-leaf-and-names-as-bounded-lookups.md index 2143771f9..cda61fae9 100644 --- a/packages/stack-encrypt/docs/adr/0002-keyset-id-in-the-leaf-and-names-as-bounded-lookups.md +++ b/packages/stack-encrypt/docs/adr/0002-keyset-id-in-the-leaf-and-names-as-bounded-lookups.md @@ -85,6 +85,12 @@ found in review: an older lookup could have made safely, costing a round trip on the next selection by such a name — in the eviction regime that is already paying them. +- **A negative answer raises the same watermark.** ZeroKMS answering a name + lookup with "no such keyset" is an answer about the name, held as no binding + at all: the binding an earlier lookup made goes, and the watermark rises to + that lookup so an earlier positive answer still in flight cannot bind the + name after ZeroKMS has said it is bound to nothing. Only ZeroKMS's own answer + counts; a lookup that failed to get one leaves the cache as it was. ## Consequences diff --git a/packages/stack-encrypt/src/cipher.rs b/packages/stack-encrypt/src/cipher.rs index 31e698ae9..fe4feb540 100644 --- a/packages/stack-encrypt/src/cipher.rs +++ b/packages/stack-encrypt/src/cipher.rs @@ -419,7 +419,25 @@ impl<K: IndexKeySource> StackCipher<K> { // cache keeps both keysets by id, and the name follows the later // lookup whichever answer lands first — and so does this caller, // who is handed the answer that won, not the one that lost. - let state = load_keyset(&self.kms, Some(keyset)).await?; + let asked_name = match &keyset { + IdentifiedBy::Name(name) => Some(name.to_string()), + IdentifiedBy::Uuid(_) => None, + }; + let state = match load_keyset(&self.kms, Some(keyset)).await { + Ok(state) => state, + Err(error) => { + // ZeroKMS's own answer that no keyset has this name is an + // answer about the name, and the cache orders it like one: + // the binding an earlier lookup made goes, and an earlier + // positive answer still in flight cannot bind the name after + // it. A lookup that got no answer (transport, auth) says + // nothing about the name and leaves the cache as it was. + if let (Some(name), true) = (&asked_name, is_keyset_not_found(&error)) { + self.keysets().forget(name, resolution); + } + return Err(error); + } + }; let state = self.keysets().insert(state, resolution); Ok(KeysetCipher::new(self, state)) } @@ -431,6 +449,17 @@ impl<K: IndexKeySource> StackCipher<K> { /// costs, shared by eager loading at /// [`init`](StackCipherBuilder::init) and lazy loading in /// [`StackCipher::keyset`] so both hold a keyset in exactly the same shape. +/// ZeroKMS answered a load with "no such keyset" — as opposed to not +/// answering at all. +fn is_keyset_not_found(error: &Error) -> bool { + matches!( + error, + Error::Kms(stack_kms::Error::LoadKeyset( + stack_kms::LoadKeysetError::KeysetNotFound(_) + )) + ) +} + async fn load_keyset<K: IndexKeySource>( kms: &K, keyset: Option<IdentifiedBy>, diff --git a/packages/stack-encrypt/src/keyset.rs b/packages/stack-encrypt/src/keyset.rs index f4dbceffa..1255b5bcd 100644 --- a/packages/stack-encrypt/src/keyset.rs +++ b/packages/stack-encrypt/src/keyset.rs @@ -40,7 +40,9 @@ //! *later lookup*, whichever answer arrives first, so an answer from before //! a rename cannot overwrite one from after it — neither under the same //! name, nor by taking back the name the keyset has since left, nor by -//! arriving after eviction has forgotten the answer it would have lost to. +//! arriving after eviction has forgotten the answer it would have lost to, +//! nor after ZeroKMS has answered a later lookup that the name is bound to +//! nothing. use std::collections::HashMap; use std::fmt; @@ -136,9 +138,9 @@ pub(crate) enum Lookup { /// `O(n)` in the bound, which is the rare case by construction. The name /// index is bounded by the entries it serves: one binding per cached id at /// most, plus the default's, and a binding goes when its id does or when -/// the keyset is resolved under another name. What an evicted entry leaves -/// behind is one watermark, not a record per name: see -/// [`eviction_watermark`](Self::eviction_watermark). +/// the keyset is resolved under another name. What an evicted entry — or a +/// name ZeroKMS answered is bound to nothing — leaves behind is one +/// watermark, not a record per name: see [`watermark`](Self::watermark). pub(crate) struct KeysetCache { capacity: NonZeroUsize, name_ttl: Duration, @@ -149,7 +151,9 @@ pub(crate) struct KeysetCache { /// The default keyset's entry, held apart from the bound: it never /// evicts, and [`insert`](Self::insert) never replaces its state. default: Entry, - /// The latest lookup whose answer eviction has forgotten. + /// The latest lookup whose answer the cache holds nothing of to order + /// an older answer against: one eviction has forgotten, or one ZeroKMS + /// answered with "no keyset has this name". /// /// A keyset carries the order of the answers that spoke for it, and a /// binding the order of the lookup that made it; evicting the keyset @@ -158,17 +162,21 @@ pub(crate) struct KeysetCache { /// entry's place here — its own bindings never sat later in the order /// than it does, since the insert that binds a name is the insert that /// stamps the entry — and no binding is made from an answer older than - /// this. The name is the only thing an answer too old to order can get - /// wrong: an id's key material is the same whichever lookup asked, so it - /// still caches. + /// this. A negative answer is the same case from the start: it is an + /// answer about a name that the cache holds no binding for, so it too + /// leaves its place here ([`forget`](Self::forget)), and an earlier + /// positive answer still in flight cannot bind the name after it. The + /// name is the only thing an answer too old to order can get wrong: an + /// id's key material is the same whichever lookup asked, so it still + /// caches. /// /// It is one watermark for all names rather than one per forgotten name /// — a cache whose whole contract is a bound must not grow a record per - /// name it has evicted — so it also refuses some bindings an older - /// lookup could have made safely. That costs a round trip on the next - /// selection by such a name, in the eviction regime that is already - /// paying them. - eviction_watermark: Resolution, + /// name it has evicted or been told is unbound — so it also refuses some + /// bindings an older lookup could have made safely. That costs a round + /// trip on the next selection by such a name, in the eviction regime + /// that is already paying them. + watermark: Resolution, by_id: HashMap<Uuid, Entry>, by_name: HashMap<String, Alias>, } @@ -202,7 +210,7 @@ impl KeysetCache { name_ttl, tick: 0, resolutions: 0, - eviction_watermark: Resolution(0), + watermark: Resolution(0), default: Entry { last_used: 0, name: default.name.clone(), @@ -297,31 +305,31 @@ impl KeysetCache { /// window, a name the keyset has since been renamed away from. /// /// Returns what the lookup should be answered with, which is not always - /// what ZeroKMS said: when a later lookup has already spoken — for this - /// keyset, or for the name this one asked under — the caller gets that - /// later answer, the same one every selection after it gets. The - /// answer that lost is not handed out even once, and that is decided - /// before this insert evicts anything: the entry it evicts can be the - /// very winner. An answer nothing later contradicts is returned as it - /// is, whether or not its name bound (an answer older than the eviction - /// watermark has no binding left to lose to, and is still the latest - /// thing the cache knows about its name). + /// what ZeroKMS said: when a later lookup has already spoken — for the + /// name this one asked under first, else for this keyset — the caller + /// gets that later answer, the same one every selection after it gets. + /// The name comes first because it is what the caller asked: an answer + /// older than what its keyset holds *and* than what its name is bound + /// to is answered by the name, since the keyset's later answer may have + /// come under another name. The answer that lost is not handed out even + /// once, and that is decided before this insert evicts anything: the + /// entry it evicts can be the very winner. An answer nothing later + /// contradicts is returned as it is, whether or not its name bound (an + /// answer older than the watermark has no binding left to lose to, and + /// is still the latest thing the cache knows about its name). pub(crate) fn insert( &mut self, state: Arc<KeysetState>, resolution: Resolution, ) -> Arc<KeysetState> { - if let Some(entry) = self.entry(state.id) { - if entry.resolution > resolution { - return Arc::clone(&entry.state); - } - } // A name lookup is answered with whatever the name means now: when // a later lookup has already bound it — to this keyset or another — // the caller gets that keyset (held, since no binding outlives its // id), and `bind` below refuses this answer as the older one. Taken - // before eviction, which can take that very binding with the entry - // it evicts and leave this answer looking uncontradicted. + // first: before the keyset's own order is consulted, since the name + // is what was asked, and before eviction, which can take that very + // binding with the entry it evicts and leave this answer looking + // uncontradicted. let later = state .name .as_deref() @@ -329,6 +337,11 @@ impl KeysetCache { .filter(|alias| alias.resolution > resolution) .and_then(|alias| self.entry(alias.id)) .map(|entry| Arc::clone(&entry.state)); + if let Some(entry) = self.entry(state.id) { + if entry.resolution > resolution { + return later.unwrap_or_else(|| Arc::clone(&entry.state)); + } + } // Evict before binding: the entry that goes may be the one whose // answer this one is older than, and its place in the order must be // on the watermark before `bind` consults it — or an answer from @@ -376,13 +389,13 @@ impl KeysetCache { /// Bind `name` to `id` for the lookup `resolution`; false if a later /// lookup already bound it, or if this answer is older than a place in - /// the order eviction has since forgotten - /// ([`eviction_watermark`]). Also unbinds the name this id was bound - /// under before, and unbinds this name from the id it named before. + /// the order the cache has since let go of ([`watermark`]). Also + /// unbinds the name this id was bound under before, and unbinds this + /// name from the id it named before. /// - /// [`eviction_watermark`]: Self::eviction_watermark + /// [`watermark`]: Self::watermark fn bind(&mut self, name: &str, id: Uuid, resolution: Resolution) -> bool { - if resolution < self.eviction_watermark { + if resolution < self.watermark { return false; } if let Some(alias) = self.by_name.get(name) { @@ -420,6 +433,28 @@ impl KeysetCache { self.entry(id).and_then(|entry| entry.name.clone()) } + /// ZeroKMS answered the lookup `resolution` for `name` with "no keyset + /// has this name". That is an answer about the name, and it orders + /// like one: a binding an earlier lookup made goes (a later lookup's + /// stands — the name may have been given out again since), and the + /// [`watermark`](Self::watermark) rises to this lookup, so an earlier + /// positive answer still in flight cannot bind the name after ZeroKMS + /// has said it is bound to nothing. Only ZeroKMS's own answer counts: + /// a lookup that failed to get one (transport, auth) says nothing about + /// the name and must not come here. + pub(crate) fn forget(&mut self, name: &str, resolution: Resolution) { + self.watermark = self.watermark.max(resolution); + let Some(alias) = self.by_name.get(name) else { + return; + }; + if alias.resolution > resolution { + return; + } + let id = alias.id; + let _ = self.by_name.remove(name); + self.forget_name_of(id, name); + } + /// `name` moved away from `id`: the id no longer claims it. fn forget_name_of(&mut self, id: Uuid, name: &str) { if let Some(entry) = self.entry_mut(id) { @@ -446,7 +481,7 @@ impl KeysetCache { // another keyset is precisely the one an older answer would // rebind, and the binding it would have lost to is no longer // here to say so. - self.eviction_watermark = self.eviction_watermark.max(entry.resolution); + self.watermark = self.watermark.max(entry.resolution); if let Some(name) = entry.name { // A name that has since moved to another id keeps its // binding: only this id's binding goes with it. @@ -1126,6 +1161,95 @@ mod tests { ); } + /// Newer information can exist for both the name asked and the keyset + /// answered: `old` resolves to keyset 1, then `old` moves to keyset 2, + /// then keyset 1 is resolved under `new`, and the first answer lands + /// last. It is older than what keyset 1 holds, so it is dropped whole — + /// but its caller asked for `old`, and `old` means keyset 2 now. + #[test] + fn a_dropped_answer_is_answered_by_its_name_before_its_keyset() { + let mut cache = cache(4); + let oldest = ticket(cache.get(&name("old"))); + let middle = ticket(cache.get(&name("old"))); + let newest = ticket(cache.get(&name("new"))); + let _ = cache.insert(state(2, Some("old")), middle); + let _ = cache.insert(state(1, Some("new")), newest); + + let answer = cache.insert(state(1, Some("old")), oldest); + assert_eq!( + answer.id, + Uuid::from_u128(2), + "the caller asked for `old`, which means keyset 2 now — not keyset 1, whose own later answer came under `new`" + ); + assert_eq!( + hit(cache.get(&name("old"))), + Some(Uuid::from_u128(2)), + "`old` still means keyset 2" + ); + assert_eq!( + hit(cache.get(&name("new"))), + Some(Uuid::from_u128(1)), + "and `new` still means keyset 1" + ); + assert_eq!(cache.names(), 2, "no other binding was made"); + } + + /// ZeroKMS answering "no keyset has this name" is an answer about the + /// name: the binding an earlier lookup made goes, an earlier positive + /// answer still in flight cannot bind the name after it, a later lookup + /// binds as usual, and a negative answer older than the binding that + /// stands leaves it standing. A zero window, so every name lookup is a + /// lookup with a ticket and a bound name reads as `Stale`. + #[test] + fn a_negative_answer_unbinds_a_name_and_refuses_earlier_answers_for_it() { + let mut cache = KeysetCache::new( + NonZeroUsize::new(4).unwrap(), + Duration::ZERO, + state(0, None), + ); + cache.load(state(1, Some("acme"))); + let earlier = ticket(cache.get(&name("acme"))); + let negative = ticket(cache.get(&name("acme"))); + + cache.forget("acme", negative); + assert!( + matches!(cache.get(&name("acme")), Lookup::Miss(_)), + "the binding the earlier load made is gone" + ); + assert_eq!( + hit(cache.get(&id(1))), + Some(Uuid::from_u128(1)), + "the keyset itself stays cached: only the name was answered" + ); + + let answer = cache.insert(state(1, Some("acme")), earlier); + assert_eq!( + answer.id, + Uuid::from_u128(1), + "nothing later positive is known about `acme`, so the answer stands for its own caller" + ); + assert!( + matches!(cache.get(&name("acme")), Lookup::Miss(_)), + "but it binds no name: ZeroKMS has since said `acme` is bound to nothing" + ); + + let later = ticket(cache.get(&name("acme"))); + let _ = cache.insert(state(2, Some("acme")), later); + assert!( + matches!(cache.get(&name("acme")), Lookup::Stale(_)), + "a lookup later than the negative answer binds as usual" + ); + + let stale_negative = ticket(cache.get(&name("acme"))); + let fresher = ticket(cache.get(&name("acme"))); + let _ = cache.insert(state(3, Some("acme")), fresher); + cache.forget("acme", stale_negative); + assert!( + matches!(cache.get(&name("acme")), Lookup::Stale(_)), + "a negative answer older than the binding that stands says nothing about it" + ); + } + /// Past the window a name lookup is stale — the keyset is still there, /// the binding is not trusted — and a re-resolution refreshes it. #[test] From d293be8034f656933e6fbea88616c40fd0bf56dc Mon Sep 17 00:00:00 2001 From: Dan Draper <dan@cipherstash.com> Date: Sat, 12 Sep 2026 20:34:23 -0400 Subject: [PATCH 528/686] =?UTF-8?q?test(stack-encrypt):=20a=20name=20ZeroK?= =?UTF-8?q?MS=20refuses,=20end=20to=20end=20=E2=80=94=20the=20race,=20its?= =?UTF-8?q?=20mirror,=20and=20the=20error=20as=20it=20reaches=20the=20call?= =?UTF-8?q?er?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The negative-answer path was covered at the cache level only; the fake resolved every name. The test double gains two knobs: a name can be refused (with ZeroKMS's own not-found, or with no answer at all) and the next lookup of a name can be held until released, so an answer can be in flight while a later lookup completes. Three tests: the race the negative answer exists for (an earlier answer lands after ZeroKMS has said the name is gone, reaches its own caller, and binds nothing — the next selection asks); its mirror (the later lookup got no answer, which says nothing, so the earlier answer binds and serves); and the errors as they reach the caller, with the keyset staying cached by id since only the name was answered. zerokms-protocol joins the dev-dependencies to build the request errors; it is already in the no-HTTP graph through stack-kms. Claude-Session: https://claude.ai/code/session_01QbNwRNqa1qo3b8ZSqULsGE --- packages/stack-encrypt/Cargo.toml | 4 + packages/stack-encrypt/tests/keysets.rs | 233 +++++++++++++++++++++++- 2 files changed, 233 insertions(+), 4 deletions(-) diff --git a/packages/stack-encrypt/Cargo.toml b/packages/stack-encrypt/Cargo.toml index 60860cc59..c61bb3387 100644 --- a/packages/stack-encrypt/Cargo.toml +++ b/packages/stack-encrypt/Cargo.toml @@ -60,6 +60,10 @@ serde_json = { workspace = true } # reqwest back into the "no HTTP" gate (`wasm:no-http-test`). The tests only # need `FakeDataKeySource` (`test-support`). stack-kms = { path = "../stack-kms", default-features = false, features = ["test-support"] } +# To build the request errors ZeroKMS answers a keyset lookup with (a name it +# does not know, a request that got no answer) in a test double. Already in +# the no-HTTP graph through stack-kms; it has no features of its own. +zerokms-protocol = { workspace = true } tokio = { workspace = true, features = ["rt", "macros"] } # Compile-fail tests for the derive diagnostics (`tests/ui`). trybuild = "1" diff --git a/packages/stack-encrypt/tests/keysets.rs b/packages/stack-encrypt/tests/keysets.rs index 0a58b4fd2..b30238226 100644 --- a/packages/stack-encrypt/tests/keysets.rs +++ b/packages/stack-encrypt/tests/keysets.rs @@ -2,8 +2,9 @@ //! keyset-scoped versus client-scoped decrypt paths. use std::borrow::Cow; +use std::collections::HashMap; use std::num::NonZeroUsize; -use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; use std::sync::Mutex; use std::time::Duration; @@ -11,18 +12,34 @@ use stack_encrypt::target::{DecryptInto, EncryptInto}; use stack_encrypt::{nonempty, CipherText, Error, SealedValue, StackCipher, StackCipherText}; use stack_kms::{ DataKey, DataKeySource, DataKeyWithTag, FakeDataKeySource, GenerateKeyPayload, IdentifiedBy, - IndexKey, IndexKeySource, RetrieveKeyPayload, UnverifiedContext, + IndexKey, IndexKeySource, LoadKeysetError, RetrieveKeyPayload, UnverifiedContext, }; use uuid::Uuid; +use zerokms_protocol::{ViturRequestError, ViturRequestErrorKind}; /// The fake, plus a count of keyset loads and a log of the keyset each /// retrieve call named — the two facts the cache and the grouped dispatch -/// are about. +/// are about — and two knobs for the name-lookup races: a name can be +/// *refused* (ZeroKMS answers the lookup with an error) and the next lookup +/// of a name can be *held* until released, so an answer can be in flight +/// while a later lookup completes. #[derive(Default)] struct Observed { inner: FakeDataKeySource, loads: AtomicUsize, retrieve_keysets: Mutex<Vec<Option<Uuid>>>, + refused: Mutex<HashMap<String, Refusal>>, + /// The name whose *next* lookup waits for [`release`](Self::release). + held: Mutex<Option<String>>, + released: AtomicBool, +} + +/// How a refused name's lookup fails: with ZeroKMS's own answer that no +/// keyset has the name, or with no answer at all. +#[derive(Clone, Copy)] +enum Refusal { + Unknown, + Unreachable, } impl Observed { @@ -33,6 +50,30 @@ impl Observed { fn retrieve_keysets(&self) -> Vec<Option<Uuid>> { self.retrieve_keysets.lock().expect("lock").clone() } + + /// Every lookup of `name` from now on fails as `how` says. + fn refuse(&self, name: &str, how: Refusal) { + let _ = self + .refused + .lock() + .expect("lock") + .insert(name.to_owned(), how); + } + + fn allow(&self, name: &str) { + let _ = self.refused.lock().expect("lock").remove(name); + } + + /// The next lookup of `name` decides its answer on arrival but does not + /// return it until [`release`](Self::release). + fn hold(&self, name: &str) { + *self.held.lock().expect("lock") = Some(name.to_owned()); + self.released.store(false, Ordering::SeqCst); + } + + fn release(&self) { + self.released.store(true, Ordering::SeqCst); + } } impl DataKeySource for Observed { @@ -66,7 +107,43 @@ impl IndexKeySource for Observed { keyset_id: Option<IdentifiedBy>, ) -> Result<(Uuid, IndexKey), stack_kms::Error> { self.loads.fetch_add(1, Ordering::Relaxed); - self.inner.load_index_key(keyset_id).await + let asked = match &keyset_id { + Some(IdentifiedBy::Name(name)) => Some(name.to_string()), + Some(IdentifiedBy::Uuid(_)) | None => None, + }; + // The answer is decided when the lookup arrives, as ZeroKMS would + // decide it; holding only delays its return. + let refusal = asked + .as_deref() + .and_then(|name| self.refused.lock().expect("lock").get(name).copied()); + let held = { + let mut held = self.held.lock().expect("lock"); + if held.as_deref() == asked.as_deref() && asked.is_some() { + *held = None; + true + } else { + false + } + }; + if held { + while !self.released.load(Ordering::SeqCst) { + tokio::task::yield_now().await; + } + } + match refusal { + Some(how) => { + let (kind, message) = match how { + Refusal::Unknown => { + (ViturRequestErrorKind::NotFound, "no keyset has this name") + } + Refusal::Unreachable => (ViturRequestErrorKind::SendRequest, "no answer"), + }; + Err(stack_kms::Error::from(LoadKeysetError::from( + ViturRequestError::new(kind, message, std::io::Error::other(message)), + ))) + } + None => self.inner.load_index_key(keyset_id).await, + } } } @@ -271,6 +348,154 @@ async fn a_name_selection_is_re_resolved_after_its_window() { ); } +/// ZeroKMS's own answer that no keyset has a name reaches the caller as it +/// is, and a request that got no answer as a request failure. Neither +/// touches what is cached by id: the name was answered, not the keyset. A +/// zero window, so every selection by name asks ZeroKMS and can be refused. +#[tokio::test] +async fn a_refused_name_is_zerokms_answer_and_leaves_the_keyset_cached_by_id() { + let cipher = StackCipher::builder() + .kms(Observed::default()) + .keyset_name_ttl(Duration::ZERO) + .init() + .await + .expect("build cipher"); + let acme = cipher.keyset(name("acme")).await.expect("acme"); + assert_eq!(cipher.kms().loads(), 2, "init and acme"); + + cipher.kms().refuse("acme", Refusal::Unknown); + let error = cipher.keyset(name("acme")).await.expect_err("refused"); + assert!( + matches!( + error, + Error::Kms(stack_kms::Error::LoadKeyset( + LoadKeysetError::KeysetNotFound(_) + )) + ), + "ZeroKMS's not-found reaches the caller as it is, got {error:?}" + ); + let by_id = cipher.keyset(acme.keyset_id()).await.expect("acme by id"); + assert_eq!( + cipher.kms().loads(), + 3, + "the keyset is still cached by id: only the name was answered" + ); + assert_eq!(by_id.keyset_id(), acme.keyset_id()); + + cipher.kms().refuse("acme", Refusal::Unreachable); + let error = cipher.keyset(name("acme")).await.expect_err("failed"); + assert!( + matches!( + error, + Error::Kms(stack_kms::Error::LoadKeyset( + LoadKeysetError::RequestFailed(_) + )) + ), + "a request that got no answer is a request failure, got {error:?}" + ); + + cipher.kms().allow("acme"); + let again = cipher.keyset(name("acme")).await.expect("acme once more"); + assert_eq!( + again.keyset_id(), + acme.keyset_id(), + "the fake resolves a name deterministically" + ); +} + +/// The race the negative answer exists for, end to end: a lookup for `acme` +/// is in flight when ZeroKMS tells a later lookup that no keyset has the +/// name. The earlier answer still reaches its own caller, but it binds +/// nothing — a selection after it asks ZeroKMS, instead of being routed to +/// the keyset the name no longer means for a whole window. +#[tokio::test] +async fn an_answer_in_flight_does_not_rebind_a_name_zerokms_has_since_refused() { + let cipher = cipher().await; + cipher.kms().hold("acme"); + let earlier = cipher.keyset(name("acme")); + let meanwhile = async { + cipher.kms().refuse("acme", Refusal::Unknown); + let refused = cipher.keyset(name("acme")).await; + cipher.kms().release(); + refused + }; + // `join!` polls in order: the earlier lookup takes its ticket and parks + // on the hold, the later one is refused, and the release lets the + // earlier answer land last. + let (earlier, refused) = tokio::join!(earlier, meanwhile); + let earlier = earlier.expect("the earlier lookup's own answer stands for its caller"); + assert!( + matches!( + refused, + Err(Error::Kms(stack_kms::Error::LoadKeyset( + LoadKeysetError::KeysetNotFound(_) + ))) + ), + "the later lookup was refused, got {refused:?}" + ); + assert_eq!( + cipher.kms().loads(), + 3, + "init, the held lookup, the refused one" + ); + + cipher.kms().allow("acme"); + let later = cipher.keyset(name("acme")).await.expect("acme afterwards"); + assert_eq!( + cipher.kms().loads(), + 4, + "the earlier answer bound nothing: a selection by the name asks ZeroKMS" + ); + assert_eq!( + later.keyset_id(), + earlier.keyset_id(), + "the fake resolves a name deterministically; what differs is that it was asked" + ); +} + +/// The mirror of the race above: the later lookup gets no answer at all. +/// That says nothing about the name, so the earlier answer binds it as +/// usual and the next selection is served from the binding. This is the +/// line between ZeroKMS's own not-found and a request that failed to reach +/// it — only the former is an answer. +#[tokio::test] +async fn a_lookup_that_got_no_answer_forgets_nothing() { + let cipher = cipher().await; + cipher.kms().hold("acme"); + let earlier = cipher.keyset(name("acme")); + let meanwhile = async { + cipher.kms().refuse("acme", Refusal::Unreachable); + let failed = cipher.keyset(name("acme")).await; + cipher.kms().release(); + failed + }; + let (earlier, failed) = tokio::join!(earlier, meanwhile); + let earlier = earlier.expect("the earlier lookup's answer stands"); + assert!( + matches!( + failed, + Err(Error::Kms(stack_kms::Error::LoadKeyset( + LoadKeysetError::RequestFailed(_) + ))) + ), + "the later lookup got no answer, got {failed:?}" + ); + assert_eq!( + cipher.kms().loads(), + 3, + "init, the held lookup, the failed one" + ); + + cipher.kms().allow("acme"); + let later = cipher.keyset(name("acme")).await.expect("acme afterwards"); + assert_eq!( + cipher.kms().loads(), + 3, + "a failure to get an answer forgot nothing: the earlier answer bound the name and serves" + ); + assert_eq!(later.keyset_id(), earlier.keyset_id()); +} + #[tokio::test] async fn keysets_derive_distinct_index_keys() { let cipher = cipher().await; From f8da8e27be10c732b07e0d50b13f4e6b145a58dc Mon Sep 17 00:00:00 2001 From: Dan Draper <dan@cipherstash.com> Date: Sun, 13 Sep 2026 15:57:31 -0400 Subject: [PATCH 529/686] feat(stack-auth): a workspace CRN is reachable without reaching for cts-common MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every strategy built by hand takes one — `AccessKeyStrategy::new(crn, key)`, `OidcFederationStrategy::new(crn, provider)` — so a caller that names its own strategy needed a `cts-common` dependency for that type alone, while the rest of the surface it uses lives here. Re-export `Crn` so the strategy a service actually wants (OIDC federation, no long-lived CipherStash credential of its own) can be constructed from this crate's own exports. Claude-Session: https://claude.ai/code/session_019b9YUGNPBrj9UUX2aZ8RbA --- packages/stack-auth/src/lib.rs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/packages/stack-auth/src/lib.rs b/packages/stack-auth/src/lib.rs index 1a06bf704..7e55cfc63 100644 --- a/packages/stack-auth/src/lib.rs +++ b/packages/stack-auth/src/lib.rs @@ -156,6 +156,16 @@ pub use device_code::{DeviceCodeStrategy, DeviceCodeStrategyBuilder, PendingDevi #[cfg(not(target_arch = "wasm32"))] pub use stack_profile::DeviceIdentity; +/// The workspace CRN every strategy is bound to, re-exported from +/// `cts-common`. +/// +/// A strategy built by hand takes one — `AccessKeyStrategy::new(crn, key)`, +/// `OidcFederationStrategy::new(crn, provider)` — so a caller that names its +/// own strategy needs this type and nothing else from `cts-common`. Its +/// region drives service discovery and its workspace id verifies every +/// token issued. +pub use cts_common::Crn; + /// Token *acquisition* — strategies that produce a [`ServiceToken`]. /// /// Use [`AuthStrategy`] as the consumer-facing trait (e.g. when wiring From a118060076a2cc965b979294b6ae0ea1ebc39f76 Mon Sep 17 00:00:00 2001 From: Dan Draper <dan@cipherstash.com> Date: Sun, 13 Sep 2026 15:57:43 -0400 Subject: [PATCH 530/686] feat(stack-encrypt)!: the default keyset is the client's, and only the client's MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `StackCipherBuilder::keyset` made `default_keyset()` return whatever the builder named, so the accessor reported a caller's preference under a name that, in the ZeroKMS model, means something else entirely: the keyset an administrator set for this client. The two coincided only while nobody called the setter. Remove the setter rather than rename the accessor. `init` now always resolves the client's default — `load_default_keyset` asks by naming nothing, and has no id or name to carry because neither is the caller's to choose — and every other keyset is selected through `StackCipher::keyset`, cached after its first use. `load_keyset` takes an `IdentifiedBy` by value now that the `None` case has its own path. `a_named_default_knows_its_name` tested the behaviour being removed; in its place `selecting_a_keyset_never_moves_the_default` asserts the invariant that replaced it. BREAKING CHANGE: `StackCipherBuilder::keyset` is gone. A cipher that was pinned to a keyset at construction selects it instead — `cipher.keyset(IdentifiedBy::Name(..)).await?` — which is one round trip per keyset per process, cached thereafter. Claude-Session: https://claude.ai/code/session_019b9YUGNPBrj9UUX2aZ8RbA --- packages/stack-encrypt/src/cipher.rs | 64 +++++++++++++---------- packages/stack-encrypt/tests/keysets.rs | 49 ++++++++--------- packages/stack-encrypt/tests/sem_terms.rs | 20 ++++--- packages/stack-encrypt/tests/target.rs | 15 ++++-- 4 files changed, 85 insertions(+), 63 deletions(-) diff --git a/packages/stack-encrypt/src/cipher.rs b/packages/stack-encrypt/src/cipher.rs index fe4feb540..3fe86376a 100644 --- a/packages/stack-encrypt/src/cipher.rs +++ b/packages/stack-encrypt/src/cipher.rs @@ -232,9 +232,9 @@ impl From<Unspecified> for Error { /// Sealing values, sealing records and deriving index terms all happen /// under a keyset, and a client may use many — one per tenant, say. So /// those operations bind to a [`KeysetCipher`], the cipher scoped to one -/// keyset: [`default_keyset`](Self::default_keyset) for the keyset named -/// on the builder (else the client's default), [`keyset`](Self::keyset) -/// for any other, by id or by name. Keysets load lazily, through a bounded +/// keyset: [`default_keyset`](Self::default_keyset) for the client's +/// default — the keyset a ZeroKMS administrator set for this client — +/// [`keyset`](Self::keyset) for any other, by id or by name. Keysets load lazily, through a bounded /// least-recently-used cache: the first selection of a keyset is one /// ZeroKMS round trip (its index key, which /// [Searchable Encrypted Metadata](crate::sem) terms are derived from), @@ -293,13 +293,14 @@ different data-key source entirely:"# /// # } /// ``` /// -/// Construction is async because it resolves the default keyset and loads -/// its index key — one ZeroKMS round-trip, paid once, so a misconfigured -/// client fails here rather than on first use. +/// Construction is async because it resolves the client's default keyset +/// and loads its index key — one ZeroKMS round-trip, paid once, so a +/// misconfigured client fails here rather than on first use. pub struct StackCipher<K> { kms: K, - /// The keyset named on the builder, else the client's default: loaded - /// eagerly by `init`, never evicted. + /// The client's default keyset, loaded eagerly by `init` and never + /// evicted. Not the caller's to choose — see + /// [`default_keyset`](Self::default_keyset). default: Arc<KeysetState>, /// Every other keyset this cipher has selected, least recently used /// first out. See [`keyset`](Self::keyset). @@ -348,9 +349,14 @@ impl<K> std::fmt::Debug for StackCipher<K> { } impl<K> StackCipher<K> { - /// The cipher bound to its default keyset: the one named on the builder - /// (by id or by name), else the client's default. Loaded at `init`, so - /// this never touches ZeroKMS. + /// The cipher bound to the client's default keyset: the one a ZeroKMS + /// administrator set for this client, which is what naming no keyset + /// resolves to. Loaded at `init`, so this never touches ZeroKMS. + /// + /// Always that keyset, whatever else the cipher has selected — the + /// default is the workspace's statement about this client, not a + /// preference a caller can override. To work under another keyset, + /// select it with [`keyset`](Self::keyset). pub fn default_keyset(&self) -> KeysetCipher<'_, K> { KeysetCipher::new(self, Arc::clone(&self.default)) } @@ -423,7 +429,7 @@ impl<K: IndexKeySource> StackCipher<K> { IdentifiedBy::Name(name) => Some(name.to_string()), IdentifiedBy::Uuid(_) => None, }; - let state = match load_keyset(&self.kms, Some(keyset)).await { + let state = match load_keyset(&self.kms, keyset).await { Ok(state) => state, Err(error) => { // ZeroKMS's own answer that no keyset has this name is an @@ -462,13 +468,13 @@ fn is_keyset_not_found(error: &Error) -> bool { async fn load_keyset<K: IndexKeySource>( kms: &K, - keyset: Option<IdentifiedBy>, + keyset: IdentifiedBy, ) -> Result<Arc<KeysetState>, Error> { let name = match &keyset { - Some(IdentifiedBy::Name(name)) => Some(name.to_string()), - Some(IdentifiedBy::Uuid(_)) | None => None, + IdentifiedBy::Name(name) => Some(name.to_string()), + IdentifiedBy::Uuid(_) => None, }; - let (id, index_key) = kms.load_index_key(keyset).await?; + let (id, index_key) = kms.load_index_key(Some(keyset)).await?; Ok(Arc::new(KeysetState { id, name, @@ -476,6 +482,19 @@ async fn load_keyset<K: IndexKeySource>( })) } +/// The client's own default keyset — the one a ZeroKMS administrator set for +/// this client — asked for by naming nothing. Loaded once, by +/// [`init`](StackCipherBuilder::init); it is not the caller's to choose, so +/// there is no id or name to carry. +async fn load_default_keyset<K: IndexKeySource>(kms: &K) -> Result<Arc<KeysetState>, Error> { + let (id, index_key) = kms.load_index_key(None).await?; + Ok(Arc::new(KeysetState { + id, + name: None, + prf: hmac_prf_from_index_key(&index_key), + })) +} + /// The state of a [`StackCipherBuilder`] that has not been given a data-key /// source: [`init`](StackCipherBuilder::init) will build a ZeroKMS client from /// the environment (and, on native targets, the CLI's profile directory). @@ -523,7 +542,6 @@ impl KeyProvider for ProfileClientKey { /// its alias [`StackCipher::builder`]). pub struct StackCipherBuilder<K = FromEnv> { kms: K, - keyset: Option<IdentifiedBy>, cache_size: NonZeroUsize, name_ttl: Duration, } @@ -537,7 +555,6 @@ impl StackCipherBuilder<FromEnv> { pub fn new() -> Self { Self { kms: FromEnv, - keyset: None, cache_size: KeysetCache::DEFAULT_CAPACITY, name_ttl: DEFAULT_NAME_TTL, } @@ -551,14 +568,6 @@ impl Default for StackCipherBuilder<FromEnv> { } impl<K> StackCipherBuilder<K> { - /// Make a specific keyset, by id or by name, the cipher's - /// [default](StackCipher::default_keyset) instead of the data-key - /// source's own default. Loaded at `init`. - pub fn keyset(mut self, keyset: IdentifiedBy) -> Self { - self.keyset = Some(keyset); - self - } - /// How many keysets beyond the default the cipher keeps loaded /// (default 1024). A process serving more tenants than this reloads a /// keyset's index key from ZeroKMS when it comes back into use; nothing @@ -595,7 +604,6 @@ impl StackCipherBuilder<FromEnv> { pub fn kms<K>(self, kms: K) -> StackCipherBuilder<K> { StackCipherBuilder { kms, - keyset: self.keyset, cache_size: self.cache_size, name_ttl: self.name_ttl, } @@ -631,7 +639,7 @@ impl<K: DataKeySource + IndexKeySource> StackCipherBuilder<K> { /// seal values and derive index terms. The one round trip a cipher /// always pays; every other keyset loads on first selection. pub async fn init(self) -> Result<StackCipher<K>, Error> { - let default = load_keyset(&self.kms, self.keyset).await?; + let default = load_default_keyset(&self.kms).await?; Ok(StackCipher { kms: self.kms, keysets: Mutex::new(KeysetCache::new( diff --git a/packages/stack-encrypt/tests/keysets.rs b/packages/stack-encrypt/tests/keysets.rs index b30238226..880413f71 100644 --- a/packages/stack-encrypt/tests/keysets.rs +++ b/packages/stack-encrypt/tests/keysets.rs @@ -181,35 +181,37 @@ async fn init_loads_the_default_keyset_once() { assert_eq!( cipher.default_keyset().keyset_name(), None, - "an unnamed builder keyset leaves the default with no name" + "the client's default is resolved by naming nothing, so it has no name" ); assert_eq!(cipher.kms().loads(), 1, "default_keyset() never loads"); } +/// The default is the client's, and stays the client's. A ZeroKMS +/// administrator sets it; selecting other keysets — however many, however +/// recently — never moves it. #[tokio::test] -async fn a_named_default_knows_its_name() { - let cipher = StackCipher::builder() - .kms(Observed::default()) - .keyset(name("customers")) - .init() - .await - .expect("build cipher"); +async fn selecting_a_keyset_never_moves_the_default() { + let cipher = cipher().await; + let default = cipher.default_keyset().keyset_id(); + let customers = cipher.keyset(name("customers")).await.expect("select"); + assert_ne!(customers.keyset_id(), default, "a distinct keyset"); assert_eq!( - cipher.default_keyset().keyset_name(), - Some("customers"), - "a builder keyset named by name reports that name" + cipher.default_keyset().keyset_id(), + default, + "the default is unchanged by a selection" ); - let by_name = cipher.keyset(name("customers")).await.expect("select"); + + let _ = cipher.keyset(name("acme")).await.expect("select another"); assert_eq!( - by_name.keyset_id(), cipher.default_keyset().keyset_id(), - "selecting the default by its builder name returns the default" + default, + "and by any number of them" ); assert_eq!( - cipher.kms().loads(), - 1, - "selecting the default by name is not a load" + cipher.default_keyset().keyset_name(), + None, + "the client's default is never a name the caller chose" ); } @@ -300,13 +302,11 @@ async fn an_evicted_keyset_reloads_on_its_next_selection() { } /// A name is a lookup, not an identity: past the window, selecting a keyset -/// by name asks ZeroKMS again, while selecting by id never does. The -/// default keyset's builder-time name ages the same way. +/// by name asks ZeroKMS again, while selecting by id never does. #[tokio::test] async fn a_name_selection_is_re_resolved_after_its_window() { let cipher = StackCipher::builder() .kms(Observed::default()) - .keyset(name("primary")) .keyset_name_ttl(Duration::ZERO) .init() .await @@ -332,11 +332,8 @@ async fn a_name_selection_is_re_resolved_after_its_window() { "an id is identity and is never re-asked" ); - let _ = cipher - .keyset(name("primary")) - .await - .expect("default by name"); - assert_eq!(cipher.kms().loads(), 4, "the default's name ages too"); + let _ = cipher.keyset(name("primary")).await.expect("primary"); + assert_eq!(cipher.kms().loads(), 4, "another name, another ask"); let _ = cipher .keyset(cipher.default_keyset().keyset_id()) .await @@ -344,7 +341,7 @@ async fn a_name_selection_is_re_resolved_after_its_window() { assert_eq!( cipher.kms().loads(), 4, - "the default's id is identity too, and is never re-asked" + "the client's default is seeded by id, and an id is never re-asked" ); } diff --git a/packages/stack-encrypt/tests/sem_terms.rs b/packages/stack-encrypt/tests/sem_terms.rs index 96e89056c..efd930abc 100644 --- a/packages/stack-encrypt/tests/sem_terms.rs +++ b/packages/stack-encrypt/tests/sem_terms.rs @@ -48,12 +48,14 @@ async fn generator() -> StackCipher<FakeDataKeySource> { } async fn generator_for(keyset: Uuid) -> StackCipher<FakeDataKeySource> { - StackCipher::builder() - .kms(FakeDataKeySource::new()) + let cipher = generator().await; + // Warm the cache so the caller's `keyset(..)` is a lookup; the cipher's + // own default stays the client's, which is not ours to choose. + let _ = cipher .keyset(IdentifiedBy::Uuid(keyset)) - .init() .await - .expect("build cipher") + .expect("select keyset"); + cipher } #[tokio::test] @@ -105,8 +107,14 @@ async fn equality_terms_differ_by_value() { async fn equality_terms_bind_the_index_key() { let cipher_a = generator_for(Uuid::from_u128(1)).await; let cipher_b = generator_for(Uuid::from_u128(2)).await; - let gen_a = cipher_a.default_keyset(); - let gen_b = cipher_b.default_keyset(); + let gen_a = cipher_a + .keyset(IdentifiedBy::Uuid(Uuid::from_u128(1))) + .await + .expect("keyset 1"); + let gen_b = cipher_b + .keyset(IdentifiedBy::Uuid(Uuid::from_u128(2))) + .await + .expect("keyset 2"); let a = gen_a .equality_term("alice", nonempty!("users/email")) .await diff --git a/packages/stack-encrypt/tests/target.rs b/packages/stack-encrypt/tests/target.rs index 0a652e221..7315a56ff 100644 --- a/packages/stack-encrypt/tests/target.rs +++ b/packages/stack-encrypt/tests/target.rs @@ -599,14 +599,23 @@ async fn an_explicit_keyset_is_honoured() { let keyset = Uuid::from_u128(42); let cipher = StackCipher::builder() .kms(FakeDataKeySource::new()) - .keyset(IdentifiedBy::Uuid(keyset)) .init() .await .expect("build cipher"); - - let explicit = cipher.default_keyset(); + let explicit = cipher + .keyset(IdentifiedBy::Uuid(keyset)) + .await + .expect("select keyset"); assert_eq!(explicit.keyset_id(), keyset); + // Selecting one does not move the cipher's default: that is the client's, + // set by a ZeroKMS administrator, not a preference a caller can override. + assert_ne!( + cipher.default_keyset().keyset_id(), + keyset, + "default_keyset() is always the client's default" + ); + // And its terms differ from the default keyset's: a different keyset means // a different index key. let default = stack_cipher().await; From 3438596c716e7dbfed9962f3aa5f2d03b81c6839 Mon Sep 17 00:00:00 2001 From: Dan Draper <dan@cipherstash.com> Date: Sun, 13 Sep 2026 15:57:53 -0400 Subject: [PATCH 531/686] docs(stack-encrypt): where credentials come from, and a `&str` that need not become a `String` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The module docs never said what `StackCipher::new()` is actually looking for. Say it: two credentials resolved independently — a client key, and an auth strategy — each found in the environment first and the CLI's profile directory second, so a logged-in machine needs nothing set and CI sets the variables. Name what `AutoStrategy` does *not* detect, since that is the part that bites: it looks for an access key and then the profile, so an OIDC federation strategy (no long-lived CipherStash credential of its own, and often what a service wants) has to be named explicitly through the `kms` seam. `examples/zerokms_auth.rs` had all of this and nothing linked to it. The examples said `.to_string()` on a `&str` throughout, which vitaminc has implemented `Encrypt` for since forever. Drop it, and note the asymmetry that made it look necessary: encryption borrows, decryption is owned, so what goes in as `&str` comes back a `String`. Claude-Session: https://claude.ai/code/session_019b9YUGNPBrj9UUX2aZ8RbA --- .../stack-encrypt/examples/zerokms_auth.rs | 13 +-- packages/stack-encrypt/src/lib.rs | 93 ++++++++++++++++++- 2 files changed, 93 insertions(+), 13 deletions(-) diff --git a/packages/stack-encrypt/examples/zerokms_auth.rs b/packages/stack-encrypt/examples/zerokms_auth.rs index 2711a39a7..80fbea377 100644 --- a/packages/stack-encrypt/examples/zerokms_auth.rs +++ b/packages/stack-encrypt/examples/zerokms_auth.rs @@ -56,7 +56,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> { let keyset = cipher.default_keyset(); println!("connected; keyset {}", keyset.keyset_id()); - let ciphertext = keyset.encrypt("hello".to_string(), "demo/greeting").await?; + let ciphertext = keyset.encrypt("hello", "demo/greeting").await?; let plaintext: String = cipher.decrypt(ciphertext, "demo/greeting").await?; assert_eq!(plaintext, "hello"); println!("round-tripped a value under the default keyset"); @@ -72,14 +72,11 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> { // let customers = cipher // .keyset(IdentifiedBy::Name("customers".to_string().into())) // .await?; - // let ciphertext = customers.encrypt("hello".to_string(), "demo/greeting").await?; + // let ciphertext = customers.encrypt("hello", "demo/greeting").await?; // - // To make a keyset the default instead, name it on the builder: - // - // StackCipher::builder() - // .keyset(IdentifiedBy::Name("customers".to_string().into())) - // .init() - // .await?; + // `default_keyset()` above is not one of these: it is the client's own + // default, the keyset a ZeroKMS administrator set for this client, and + // selecting others never moves it. // --- A custom authentication strategy ----------------------------------- // diff --git a/packages/stack-encrypt/src/lib.rs b/packages/stack-encrypt/src/lib.rs index a0487ebcf..cef01a673 100644 --- a/packages/stack-encrypt/src/lib.rs +++ b/packages/stack-encrypt/src/lib.rs @@ -51,7 +51,9 @@ use stack_encrypt::StackCipher; let cipher = StackCipher::new().await?; let keyset = cipher.default_keyset(); -let ciphertext = keyset.encrypt("secret message".to_string(), ()).await?; +// A `&str` encrypts as it is; decryption is owned, so it comes back a +// `String` — nothing borrows from a ciphertext. +let ciphertext = keyset.encrypt("secret message", ()).await?; let plaintext: String = cipher.decrypt(ciphertext, ()).await?; assert_eq!(plaintext, "secret message"); # Ok(()) @@ -69,9 +71,11 @@ assert_eq!(plaintext, "secret message"); //! Encrypting binds to a keyset (every data key is minted under one); decrypting //! does not (every sealed leaf carries the id of the keyset it was sealed //! under), so it goes through the client-scoped `cipher` — or through the -//! `keyset`, which then refuses leaves from any other keyset. A client may use -//! many keysets, one per tenant say; [`StackCipher::keyset`] selects any of -//! them by id or name, loading it on first use. The [`keyset`](crate::keyset) +//! `keyset`, which then refuses leaves from any other keyset. +//! [`default_keyset`](StackCipher::default_keyset) is the client's own — +//! the keyset a ZeroKMS administrator set for it — and is always that one. +//! A client may use many others, one per tenant say; [`StackCipher::keyset`] +//! selects any of them by id or name, loading it on first use. The [`keyset`](crate::keyset) //! module docs lay out the model. //! //! The second argument is the *associated data* (AAD): anything that implements @@ -83,12 +87,91 @@ assert_eq!(plaintext, "secret message"); //! ```no_run //! # async fn example<K: stack_kms::DataKeySource>(cipher: stack_encrypt::StackCipher<K>) -> Result<(), stack_encrypt::Error> { //! # let keyset = cipher.default_keyset(); -//! let ct = keyset.encrypt("4111 1111 1111 1111".to_string(), "users/42/card").await?; +//! let ct = keyset.encrypt("4111 1111 1111 1111", "users/42/card").await?; //! let card: String = cipher.decrypt(ct, "users/42/card").await?; // ok //! # Ok(()) //! # } //! ``` //! +// Credentials only exist on the `http` path: without it there is no client +// to authenticate, only the `DataKeySource` the caller supplies. +#![cfg_attr( + feature = "http", + doc = r#"# Credentials + +A cipher needs two credentials, resolved independently of each other: + +- a **client key** — an id and key material, which data keys are derived + against; and +- an **auth strategy** — whatever obtains a token ZeroKMS will accept. + +Each is looked for in the environment first, then in the current workspace of +the CLI's profile directory (`~/.cipherstash`), which `npx stash auth login` +writes. A logged-in developer machine has both there, so +`StackCipher::new()` usually just works with nothing else set. + +Where there is no profile — CI, a container, wasm — the environment carries +them. `CS_CLIENT_ID` + `CS_CLIENT_KEY` are the client key. +`CS_CLIENT_ACCESS_KEY` is the auth strategy `AutoStrategy` detects, and it +needs a workspace CRN (`CS_WORKSPACE_CRN`) alongside it: the profile is what +supplies that otherwise, and its region drives service discovery while its +workspace id verifies every token issued. + +An access key is not the only way to authenticate, and often not the one a +service wants. A `stack_auth::OidcFederationStrategy` federates a +third-party OIDC JWT (Clerk, Supabase, Auth0) into a CipherStash token, so +the deployment holds no long-lived CipherStash credential of its own. What +`AutoStrategy` detects is only the two above — access key, then profile — so +any other strategy is named explicitly, and that is what +[`kms`](StackCipherBuilder::kms) is for: build the +[`StackKms`](stack_kms::StackKms) over the strategy you want and hand it to +the builder. + +```no_run +# async fn example() -> Result<(), Box<dyn std::error::Error>> { +use stack_auth::{AuthError, AuthStrategyFn, SecretToken, ServiceToken}; +use stack_encrypt::StackCipher; +use stack_kms::{EnvKeyProvider, StackKmsBuilder}; + +// Any `AuthStrategy` goes in this slot — `AccessKeyStrategy`, +// `OidcFederationStrategy`, `DeviceSessionStrategy`, or, as here, +// `AuthStrategyFn` over a closure of your own. The closure is called +// whenever ZeroKMS needs a fresh token, so refresh belongs inside it. Note +// what holds the token: `SecretToken` is zeroized on drop and prints as +// `***`, so a long-lived credential neither lingers in freed memory nor +// lands in a log line. +let token = SecretToken::new(std::env::var("MY_SERVICE_TOKEN")?); +let strategy = AuthStrategyFn::new(move || { + let token = token.clone(); + async move { Ok::<_, AuthError>(ServiceToken::new(token)) } +}); + +// The client key is the other half, and has its own provider: `EnvKeyProvider` +// reads CS_CLIENT_ID / CS_CLIENT_KEY, or supply a `KeyProvider` of your own. +let kms = StackKmsBuilder::new(strategy) + .with_key_provider(EnvKeyProvider) + .build() + .await?; + +let cipher = StackCipher::builder().kms(kms).init().await?; +# Ok(()) +# } +``` + +The built-in strategies are constructed from a workspace CRN +(`stack_auth::Crn`) rather than read from the environment — +`OidcFederationStrategy::new(crn, provider)` — and otherwise reach the +builder through the same `kms` seam. + +`examples/zerokms_auth.rs` runs this end to end against a live ZeroKMS, +alongside the default path and the errors each half fails with. The +transport knobs — timeouts, batch size, concurrency, an alternate ZeroKMS +endpoint — are `StackKmsBuilder`'s, and the two keyset-cache knobs are +[`keyset_cache_size`](StackCipherBuilder::keyset_cache_size) and +[`keyset_name_ttl`](StackCipherBuilder::keyset_name_ttl). +"# +)] +//! //! # Testing without ZeroKMS //! //! `stack_kms::FakeDataKeySource` is an in-memory stub that needs no From fd0b1bcc12f6375291f9ef1add5f380455a2b18a Mon Sep 17 00:00:00 2001 From: Dan Draper <dan@cipherstash.com> Date: Sun, 13 Sep 2026 17:29:42 -0400 Subject: [PATCH 532/686] feat(stack-encrypt)!: a leaf's AAD is derived, never supplied MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `decipher` handed back a `StackDecipher` whose keys had been retrieved under one context, to be driven by a `Decrypt` impl under whatever AAD the caller passed next. Nothing required those to be the same thing. They are not the same thing by accident, either: every leaf's AAD is a derivation of the descriptor its key was minted under — `for_sequence_element` for an element, `for_map_entry(key)` for a map value, `for_leaf(WIRE_VERSION)` outermost — and that derivation is what authenticates shape. An element cannot be spliced out of a sequence, a map value cannot move under another key. Letting a caller supply the AAD instead is the one way to decouple the local binding from the one ZeroKMS HMACs into the key tag. So remove it from both scopes. `decipher_through` stays, private, with one `aad` reaching both halves; `decrypt` is the only way in. The capability had no user: `KeysetCipher::decipher` had no caller anywhere, and the guest's `decrypt_value` — the only caller of `StackCipher::decipher` — passed the same AAD to both halves, so it becomes one `decrypt` per arm. The element derivation it needs comes from naming `Element<FfiValue>`, which is where it was always meant to come from. `decipher_can_be_driven_directly` tested the removed capability; in its place `a_batched_element_opens_by_naming_the_type` covers the case it was standing in for. BREAKING CHANGE: `StackCipher::decipher` and `KeysetCipher::decipher` are gone. Decrypt through `decrypt`, naming the type whose `Decrypt` impl applies the derivation you need — `Element<T>` for one row of a batch-encrypted collection. Claude-Session: https://claude.ai/code/session_019b9YUGNPBrj9UUX2aZ8RbA --- .../golang/stackencrypt/guest/src/ops.rs | 28 +++--- .../stackencrypt/guest/tests/native_ops.rs | 16 ++-- packages/stack-encrypt/src/cipher.rs | 86 ++++++------------- packages/stack-encrypt/src/lib.rs | 8 +- packages/stack-encrypt/src/target/pending.rs | 2 +- packages/stack-encrypt/tests/roundtrip.rs | 38 +++----- 6 files changed, 67 insertions(+), 111 deletions(-) diff --git a/languages/golang/stackencrypt/guest/src/ops.rs b/languages/golang/stackencrypt/guest/src/ops.rs index 8421ecb2b..2c815bd54 100644 --- a/languages/golang/stackencrypt/guest/src/ops.rs +++ b/languages/golang/stackencrypt/guest/src/ops.rs @@ -42,8 +42,8 @@ use stack_encrypt::sem::{CllwOpeEncrypt, CllwOreEncrypt, DefaultMatch}; use stack_encrypt::target::Pending; use stack_encrypt::{ - AadPiece, BoxedPassthrough, CipherText, Decrypt, Element, Encrypt, IntoPrfContext, - KeysetCipher, NonEmpty, SealedValue, StackCipher, StackCipherText, + AadPiece, BoxedPassthrough, CipherText, Element, Encrypt, IntoPrfContext, KeysetCipher, + NonEmpty, SealedValue, StackCipher, StackCipherText, }; use stack_kms::DataKeySource; use vitaminc_aead_value::{transport as codec, FfiValue}; @@ -51,7 +51,7 @@ use vitaminc_protected::{Controlled, Protected}; use zeroize::Zeroizing; use crate::context::{borrowed, parse_context}; -use crate::status::{status_for_error, STATUS_AUTH, STATUS_ENCODING, STATUS_INTERNAL}; +use crate::status::{status_for_error, STATUS_ENCODING, STATUS_INTERNAL}; /// Term kinds for `se_term`, part of the guest/host contract (the Go host /// mirrors these values). @@ -124,16 +124,22 @@ where K: DataKeySource + Sync, { let tree = decode_tree(ciphertext)?; - let decipher = cipher - .decipher(tree, aad) - .await - .map_err(|e| status_for_error(&e))?; + // One `decrypt` per arm, not one `decipher` and two drives: the element + // derivation is `Element<T>`'s to apply, and naming the type is what + // asks for it. Only one arm runs, so the retrieve happens once either + // way. let value: FfiValue = if as_element { - Element::<FfiValue>::decrypt_with_aad(decipher, aad).map(Element::into_inner) + let wrapped: Element<FfiValue> = cipher + .decrypt(tree, aad) + .await + .map_err(|e| status_for_error(&e))?; + wrapped.into_inner() } else { - FfiValue::decrypt_with_aad(decipher, aad) - } - .map_err(|_| STATUS_AUTH)?; + cipher + .decrypt(tree, aad) + .await + .map_err(|e| status_for_error(&e))? + }; encode_value(value) } diff --git a/languages/golang/stackencrypt/guest/tests/native_ops.rs b/languages/golang/stackencrypt/guest/tests/native_ops.rs index 7f71e8534..2a17a7e1b 100644 --- a/languages/golang/stackencrypt/guest/tests/native_ops.rs +++ b/languages/golang/stackencrypt/guest/tests/native_ops.rs @@ -18,7 +18,7 @@ use std::sync::atomic::{AtomicUsize, Ordering}; use std::future::IntoFuture; use stack_encrypt::sem::DefaultMatch; -use stack_encrypt::{nonempty, Aad, CipherText, Decrypt, Encrypt, SealedValue, StackCipher}; +use stack_encrypt::{nonempty, CipherText, Encrypt, SealedValue, StackCipher}; use stack_encrypt_guest::ops::{self, TERM_EQUALITY, TERM_MATCH, TERM_OPE, TERM_ORE}; use stack_encrypt_guest::status::{STATUS_AUTH, STATUS_ENCODING}; use stack_kms::{ @@ -271,9 +271,7 @@ fn guest_leaves_are_the_frozen_storage_encoding() { // A guest leaf seals the *value model's* typed payload (`[tag] ++ // payload`, the vitaminc sealed-leaf format), so the native open goes // through `FfiValue`'s own `Decrypt` — not a bare `String`. - let decipher = - block_on(cipher.decipher(CipherText::Single(leaf), "ctx")).expect("retrieve the data key"); - let value = FfiValue::decrypt_with_aad(decipher, Aad::from_slice(b"ctx")) + let value: FfiValue = block_on(cipher.decrypt(CipherText::Single(leaf), "ctx")) .expect("native decrypt of a guest leaf"); assert_eq!(text(&value), "durable"); } @@ -724,9 +722,7 @@ fn record_terms_equal_the_native_derivations_and_probe_them() { panic!("expected a single leaf for a scalar field"); }; let leaf = SealedValue::from_bytes(leaf).expect("frozen leaf"); - let decipher = block_on(cipher.decipher(CipherText::Single(leaf), "users/age")) - .expect("retrieve the data key"); - let value = FfiValue::decrypt_with_aad(decipher, "users/age") + let value: FfiValue = block_on(cipher.decrypt(CipherText::Single(leaf), "users/age")) .expect("native decrypt of a record field"); assert!(matches!(value, FfiValue::UInt32(34))); } @@ -810,10 +806,8 @@ fn a_structured_plan_context_seals_what_the_native_extended_context_does() { panic!("expected a single leaf for a scalar field"); }; let leaf = SealedValue::from_bytes(leaf).expect("frozen leaf"); - let decipher = - block_on(cipher.decipher(CipherText::Single(leaf), native)).expect("retrieve the data key"); - let value = - FfiValue::decrypt_with_aad(decipher, native).expect("native decrypt under the tuple"); + let value: FfiValue = block_on(cipher.decrypt(CipherText::Single(leaf), native)) + .expect("native decrypt under the tuple"); assert!(matches!(value, FfiValue::UInt32(34))); } diff --git a/packages/stack-encrypt/src/cipher.rs b/packages/stack-encrypt/src/cipher.rs index 3fe86376a..8ed3851bd 100644 --- a/packages/stack-encrypt/src/cipher.rs +++ b/packages/stack-encrypt/src/cipher.rs @@ -25,9 +25,9 @@ //! [`PendingStackCipherText::seal`] (or the [`KeysetCipher::encrypt`] //! convenience) then batches **one** `generate_keys` call for the whole //! tree, under the handle's keyset, and seals every leaf. -//! * **Decrypt** — [`StackCipher::decipher`] batches **one** `retrieve_keys` +//! * **Decrypt** — [`StackCipher::decrypt`] batches **one** `retrieve_keys` //! call per keyset the leaves were sealed under and zips each key onto its -//! leaf, returning a [`StackDecipher`]. The +//! leaf, building a [`StackDecipher`] it does not hand out. The //! value's [`Decrypt`] impl then drives that decipher exactly as it would //! `AesDecipher`: each leaf is opened under the AAD the drive supplies, so //! the visitor pattern (nested `Vec`/`HashMap`/`Option`/`Protected` values, @@ -244,11 +244,10 @@ impl From<Unspecified> for Error { /// /// Decrypting is not keyset-scoped: a sealed leaf carries the id of the /// keyset it was sealed under, and retrieving its data key needs nothing -/// more than that and the client. So [`decrypt`](Self::decrypt) and -/// [`decipher`](Self::decipher) live here and open leaves from any keyset -/// the client is authorised for, in one batch. The same methods on a -/// [`KeysetCipher`] add a constraint: they refuse a leaf from any other -/// keyset before any key is retrieved. +/// more than that and the client. So [`decrypt`](Self::decrypt) lives here +/// and opens leaves from any keyset the client is authorised for, in one +/// batch. The same method on a [`KeysetCipher`] adds a constraint: it +/// refuses a leaf from any other keyset before any key is retrieved. /// /// # Construction /// @@ -690,24 +689,22 @@ impl<K: DataKeySource> KeysetCipher<'_, K> { { decrypt_through(self, ciphertext, aad).await } - - /// [`StackCipher::decipher`], constrained to this keyset: a leaf sealed - /// under any other is [`Error::ForeignKeyset`], refused before any key - /// is retrieved. - pub async fn decipher<'a>( - &self, - ciphertext: StackCipherText, - aad: impl IntoAad<'a>, - ) -> Result<StackDecipher, Error> { - decipher_through(self, ciphertext, aad).await - } } -/// `decipher`, for either scope. The work is the same on both — one -/// descriptor, one pending, one settle — and the scope is the whole +/// Retrieve every leaf's data key under `aad`'s descriptor and bind them +/// onto the ciphertext, for either scope. The work is the same on both — +/// one descriptor, one pending, one settle — and the scope is the whole /// difference: a [`KeysetCipher`] constrains the leaves to its keyset, a -/// [`StackCipher`] constrains nothing. Keeping one definition is what makes -/// that true, rather than two bodies that happen to agree. +/// [`StackCipher`] constrains nothing. +/// +/// Deliberately private. The returned [`StackDecipher`] is driven with an +/// AAD supplied per call, so exposing this would let a caller retrieve keys +/// under one context and authenticate the ciphertext under an unrelated +/// one. Every leaf's AAD is a derivation of the descriptor its key was +/// minted under — `for_sequence_element`, `for_map_entry`, `for_leaf` — +/// and that derivation is the library's to compute, never the caller's to +/// supply. [`decrypt_through`] is the only way in, and it passes one `aad` +/// to both halves. async fn decipher_through<'s, 'a, K: DataKeySource + 's>( scope: impl crate::target::CipherScope<'s, K>, ciphertext: StackCipherText, @@ -736,11 +733,12 @@ where impl<K: DataKeySource> StackCipher<K> { /// Decrypt a [`StackCipherText`] into `T`, authenticating against `aad`. /// - /// Thin wrapper over [`decipher`](Self::decipher): one batched - /// `retrieve_keys` call per keyset the leaves were sealed under, every - /// key under the [`Descriptor`] of `aad`, then `T`'s [`Decrypt`] impl - /// drives the returned [`StackDecipher`] with `aad` — exactly as - /// `Aes256Cipher::decrypt_with_aad` drives `AesDecipher`. + /// One batched `retrieve_keys` call per keyset the leaves were sealed + /// under, every key under the [`Descriptor`] of `aad`, then `T`'s + /// [`Decrypt`] impl drives the resulting [`StackDecipher`] with the + /// *same* `aad` — exactly as `Aes256Cipher::decrypt_with_aad` drives + /// `AesDecipher`. One context in, and every leaf's AAD derived from it; + /// there is no form of this call that takes two. /// /// Not keyset-scoped: each leaf carries the id of the keyset it was /// sealed under, and this opens leaves from any keyset the client is @@ -766,36 +764,6 @@ impl<K: DataKeySource> StackCipher<K> { { decrypt_through(self, ciphertext, aad).await } - - /// Fetch every leaf's data key (one batched `retrieve_keys` call per - /// keyset the leaves were sealed under, every key under the - /// [`Descriptor`] of `aad`; see [`decrypt`](Self::decrypt) on what that - /// fan-out means for untrusted input) and bind them onto the - /// ciphertext, returning a synchronous [`Decipher`] that does the AEAD - /// opening as the value's [`Decrypt`] impl drives it. - /// - /// This is the decrypt-side counterpart to passing `&keyset` (a - /// [`Cipher`]) on the encrypt side, mirroring `Aes256Cipher::decipher`: - /// the ZeroKMS I/O is front-loaded here, and the AAD is supplied per - /// call by [`Decrypt::decrypt_with_aad`], so `Decrypt` impls that derive - /// their own AAD (e.g. `vitaminc_aead::Element`) behave identically to - /// `AesDecipher`. The one thing ZeroKMS needs before that drive is the - /// descriptor the keys were generated under, so `aad` is the context the - /// value was sealed under — the same value, in the same shape, that the - /// drive will present (an `Element`'s own derivation is applied by the - /// drive, not here). [`decrypt`](Self::decrypt) does both steps. - /// - /// Settles through the target layer's request carrier - /// ([`decipher_pending`](crate::target)), so this and - /// `decrypt_into` share one definition of how leaves map to retrieve - /// requests and one path to ZeroKMS. - pub async fn decipher<'a>( - &self, - ciphertext: StackCipherText, - aad: impl IntoAad<'a>, - ) -> Result<StackDecipher, Error> { - decipher_through(self, ciphertext, aad).await - } } /// A single sealed leaf: the ZeroKMS metadata needed to retrieve its data key @@ -1644,7 +1612,9 @@ impl<K> MapCipher for PendingMapCipher<'_, '_, K> { // ============================================================================= /// A [`Decipher`] over a single [`StackCipherText`] whose leaves already -/// carry their retrieved data keys, produced by [`StackCipher::decipher`]. +/// carry their retrieved data keys, built inside +/// [`StackCipher::decrypt`] and driven there by the value's [`Decrypt`] +/// impl under the same context the keys were retrieved with. /// /// Structurally identical to `vitaminc_encrypt::AesDecipher` — the only /// difference is where each leaf's key comes from. The AAD is supplied per call diff --git a/packages/stack-encrypt/src/lib.rs b/packages/stack-encrypt/src/lib.rs index cef01a673..b47d5e502 100644 --- a/packages/stack-encrypt/src/lib.rs +++ b/packages/stack-encrypt/src/lib.rs @@ -236,9 +236,11 @@ endpoint — are `StackKmsBuilder`'s, and the two keyset-cache knobs are //! wrong context reach the AEAD, where it is [`Error::Aead`]. //! //! For one-row reads of a batch-encrypted collection, decrypt as -//! [`Element<T>`](Element) under the same AAD used for the whole collection. -//! For finer control (custom `Decrypt` drivers, manual AAD derivations) use -//! [`StackCipher::decipher`] and drive the returned [`StackDecipher`] yourself. +//! [`Element<T>`](Element) under the same AAD used for the whole collection: +//! the derivation that binds an element to its position is applied by the +//! type, not by the caller. That is the general rule here — every leaf's AAD +//! is derived from the context its key was minted under, and there is no +//! entry point that lets a caller supply one of its own. //! //! # Relationship to vitaminc //! diff --git a/packages/stack-encrypt/src/target/pending.rs b/packages/stack-encrypt/src/target/pending.rs index 105123a6a..328c98b01 100644 --- a/packages/stack-encrypt/src/target/pending.rs +++ b/packages/stack-encrypt/src/target/pending.rs @@ -355,7 +355,7 @@ where /// Settle: one batched ZeroKMS call per request kind (none at all for an /// all-[`ready`](Pending::ready) assembly), then the fulfilments shape the /// responses. This is the only place I/O happens — the cipher-directed - /// API ([`StackCipher::encrypt`] / [`StackCipher::decipher`]) settles + /// API ([`KeysetCipher::encrypt`] / [`StackCipher::decrypt`]) settles /// through here too, so there is exactly one path to ZeroKMS. /// /// Unboxed, so it carries no `Send`/`Sync` demands beyond the backend's diff --git a/packages/stack-encrypt/tests/roundtrip.rs b/packages/stack-encrypt/tests/roundtrip.rs index 78697ca27..cfaa16090 100644 --- a/packages/stack-encrypt/tests/roundtrip.rs +++ b/packages/stack-encrypt/tests/roundtrip.rs @@ -6,7 +6,7 @@ use std::collections::HashMap; -use stack_encrypt::{Aad, CipherText, ContextTag, Element, IntoAad, SealedValue, StackCipher}; +use stack_encrypt::{CipherText, ContextTag, Element, SealedValue, StackCipher}; use stack_kms::FakeDataKeySource; use vitaminc_protected::{Controlled, Protected}; @@ -327,40 +327,24 @@ async fn element_fails_under_wrong_caller_aad() { ); } +/// The derivation that binds a sequence element to its position is the +/// library's, applied by `Element<T>` itself — there is no entry point that +/// lets a caller retrieve keys under one context and authenticate under +/// another, so a batched row is read back by naming the type, not by +/// reconstructing the AAD. #[tokio::test] -async fn decipher_can_be_driven_directly() { - // `StackCipher::decipher` mirrors `Aes256Cipher::decipher`: the returned - // Decipher is driven via `Decrypt::decrypt_with_aad` with a caller-chosen - // AAD, so manual derivations work too. +async fn a_batched_element_opens_by_naming_the_type() { let cipher = cipher().await; let keyset = cipher.default_keyset(); let ct = keyset .encrypt(Element("row".to_string()), b"users".as_slice()) .await .expect("encrypt"); - let decipher = cipher - .decipher(ct, b"users".as_slice()) - .await - .expect("retrieve keys"); - let pt = <String as stack_encrypt::Decrypt>::decrypt_with_aad( - decipher, - Aad::from_slice(b"users").for_sequence_element(), - ) - .expect("manual element derivation must open the leaf"); - assert_eq!(pt, "row"); - - // And a plain scalar opens under the bare AAD through the same path. - let ct = keyset - .encrypt("scalar".to_string(), b"ctx".as_slice()) - .await - .expect("encrypt"); - let decipher = cipher - .decipher(ct, b"ctx".as_slice()) + let opened: Element<String> = cipher + .decrypt(ct, b"users".as_slice()) .await - .expect("retrieve keys"); - let pt = <String as stack_encrypt::Decrypt>::decrypt_with_aad(decipher, b"ctx".into_aad()) - .expect("decrypt"); - assert_eq!(pt, "scalar"); + .expect("Element applies its own derivation on open"); + assert_eq!(opened.into_inner(), "row"); } #[tokio::test] From 07fb2a2c010f0748680d04f47764052c55adf237 Mon Sep 17 00:00:00 2001 From: Dan Draper <dan@cipherstash.com> Date: Sun, 13 Sep 2026 20:56:24 -0400 Subject: [PATCH 533/686] fix(stack-encrypt-guest)!: the plaintext a host hands over outlives the call no longer, and a pinned keyset survives the builder losing its knob MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two things, both in `abi.rs`, which native builds never compile — it is `#[cfg(target_arch = "wasm32")]`, so `mise run lint` and the nextest suite walk straight past it. CI's `wasm:guest:build` is what catches this. **Plaintext lifetime.** Every export handed plaintext borrowed the host's buffer for the whole call and left it there afterwards, to be wiped whenever the host got round to `se_dealloc` — a contract with no way to check it and no Go host in this repo to check against. `se_encrypt`, `se_encrypt_element`, `se_term` and `se_encrypt_record` now take their value out through `take_plaintext`, which copies into a `Zeroizing` and zeroes the original before anything else happens. The plaintext lives for the call and no longer. Context, AAD and plan buffers are untouched: they are not secret. The module doc said two entry points wiped an input; only one did, and the second clause was describing an output. **Keyset pinning.** `StackCipherBuilder::keyset` went away when `default_keyset()` became the client's default and only the client's, and this was its one caller — so a config naming a keyset stopped compiling. A `KeysetCipher` borrows its `StackCipher` and a session owns one, so the session now holds the keyset *id* and resolves it per operation. `init` resolves it once so an unreachable keyset still fails there rather than on the first encrypt; every later resolution is a cache hit. BREAKING CHANGE: a host must not read a plaintext input buffer back after the call, or hand the same buffer to two calls — it is zeroed on return. The wasm signatures are unchanged (both pointer types are i32). Claude-Session: https://claude.ai/code/session_019b9YUGNPBrj9UUX2aZ8RbA --- .../golang/stackencrypt/guest/src/abi.rs | 122 ++++++++++++++---- 1 file changed, 97 insertions(+), 25 deletions(-) diff --git a/languages/golang/stackencrypt/guest/src/abi.rs b/languages/golang/stackencrypt/guest/src/abi.rs index bcaaf52c9..d78acb266 100644 --- a/languages/golang/stackencrypt/guest/src/abi.rs +++ b/languages/golang/stackencrypt/guest/src/abi.rs @@ -6,10 +6,19 @@ //! inputs and the guest's outputs — with [`se_dealloc`], which **zeroizes //! before freeing**. The guest keeps a registry of every buffer it hands //! out (`crate::buffers`), so `se_dealloc` never trusts the host's -//! length. Two entry points additionally wipe their *input* buffer in -//! place before returning: [`se_cipher_init`] (the config carries the -//! client key) and [`se_decrypt`]'s output is plaintext the host must -//! copy out and immediately `se_dealloc`. +//! length. +//! - **Every export that is handed plaintext wipes that buffer in place +//! before it returns**, rather than leaving it for `se_dealloc`: +//! [`se_cipher_init`] (the config carries the client key), +//! [`se_encrypt`], [`se_encrypt_element`], [`se_term`] and +//! [`se_encrypt_record`] (the value/source buffers). The host's plaintext +//! therefore lives no longer than the call, instead of until the host +//! gets round to releasing it. **A host must not read a plaintext input +//! buffer back after the call, or pass the same buffer to two +//! calls** — it will be zeros. Context, AAD and plan buffers are not +//! secret and are left untouched. +//! - Output buffers from the decrypt exports contain plaintext; the host +//! must copy them out and immediately `se_dealloc` (which zeroizes). //! - A cipher is a **handle**: [`se_cipher_init`] builds a //! `StackCipher<StackKms<HostTokenStrategy, WasiHostConnection>>` (one //! `load-keyset` round trip through the host transport — the index key @@ -73,7 +82,7 @@ use futures::executor::block_on; use stack_encrypt::StackCipher; use stack_kms::{ClientOpts, StackKms}; use vitaminc_aead_value::transport as codec; -use zeroize::Zeroize; +use zeroize::{Zeroize, Zeroizing}; use crate::buffers; use crate::config::parse_config; @@ -86,10 +95,37 @@ use crate::status::{STATUS_BAD_HANDLE, STATUS_ENCODING, STATUS_INTERNAL, STATUS_ /// ZeroKMS client with host-supplied tokens. type GuestCipher = StackCipher<StackKms<HostTokenStrategy, WasiHostConnection>>; +/// A cipher handle and the keyset its config pinned it to. +/// +/// The keyset is held as an id rather than a bound handle because a +/// `KeysetCipher` borrows its `StackCipher`, and a session owns one. It is +/// resolved once at [`cipher_init`] — so a keyset the client cannot reach +/// fails there, not on the first encrypt — and every later resolution is a +/// cache hit inside `StackCipher`. +struct GuestSession { + cipher: GuestCipher, + keyset: Option<stack_kms::IdentifiedBy>, +} + +impl GuestSession { + /// The keyset this session encrypts under: the configured one, else the + /// client's own default. + fn keyset( + &self, + ) -> Result<stack_encrypt::KeysetCipher<'_, StackKms<HostTokenStrategy, WasiHostConnection>>, u32> + { + match &self.keyset { + Some(keyset) => block_on(self.cipher.keyset(keyset.clone())) + .map_err(|e| crate::status::status_for_error(&e)), + None => Ok(self.cipher.default_keyset()), + } + } +} + thread_local! { // Wasm is single-threaded, so a thread-local `RefCell` is a plain owner // of the session table — no `Send`/`Sync` bounds required. - static SESSIONS: RefCell<Sessions<GuestCipher>> = RefCell::new(Sessions::new()); + static SESSIONS: RefCell<Sessions<GuestSession>> = RefCell::new(Sessions::new()); } /// Allocate `len` bytes of guest memory for the host to write into. Returns @@ -177,8 +213,26 @@ unsafe fn wipe_input(ptr: *mut u8, len: u32) { unsafe { std::slice::from_raw_parts_mut(ptr, len as usize) }.zeroize(); } +/// Take a plaintext input out of the host's buffer and wipe the buffer. +/// +/// The exports below hold their decoded value across a ZeroKMS round trip, so +/// the borrow of the host buffer would otherwise outlive the call. Copying +/// into a `Zeroizing` first lets the original be wiped immediately: the +/// plaintext then exists for the duration of this call and no longer, instead +/// of sitting in linear memory until the host gets round to `se_dealloc`. +/// +/// # Safety +/// +/// `ptr`/`len` must name a host buffer the caller is done with; it is zeroed +/// before this returns. +unsafe fn take_plaintext(ptr: *mut u8, len: u32) -> Result<Zeroizing<Vec<u8>>, u32> { + let taken = Zeroizing::new(input(ptr, len)?.to_vec()); + unsafe { wipe_input(ptr, len) }; + Ok(taken) +} + /// Run `f` with the cipher bound to `handle`, or report `STATUS_BAD_HANDLE`. -fn with_cipher<R>(handle: u32, f: impl FnOnce(&GuestCipher) -> Result<R, u32>) -> Result<R, u32> { +fn with_cipher<R>(handle: u32, f: impl FnOnce(&GuestSession) -> Result<R, u32>) -> Result<R, u32> { SESSIONS.with(|s| { let s = s.borrow(); let cipher = s.get(handle).ok_or(STATUS_BAD_HANDLE)?; @@ -236,12 +290,21 @@ fn cipher_init(decoded: vitaminc_aead_value::FfiValue) -> Result<u32, u32> { ) .map_err(|_| STATUS_KMS_TRANSPORT)?; - let mut builder = StackCipher::builder().kms(kms); - if let Some(keyset) = config.keyset { - builder = builder.keyset(keyset); + let cipher = block_on(StackCipher::builder().kms(kms).init()) + .map_err(|e| crate::status::status_for_error(&e))?; + // A configured keyset is resolved now, so a name the client cannot reach + // fails at init rather than on the first encrypt. The handle is dropped; + // what it warmed is the cipher's keyset cache. + if let Some(keyset) = &config.keyset { + let _ = block_on(cipher.keyset(keyset.clone())) + .map_err(|e| crate::status::status_for_error(&e))?; } - let cipher = block_on(builder.init()).map_err(|e| crate::status::status_for_error(&e))?; - SESSIONS.with(|s| s.borrow_mut().insert(cipher)) + SESSIONS.with(|s| { + s.borrow_mut().insert(GuestSession { + cipher, + keyset: config.keyset, + }) + }) } /// Drop a cipher handle. Freeing an unknown handle is a no-op. @@ -287,7 +350,7 @@ pub extern "C" fn se_cipher_free(handle: u32) { #[no_mangle] pub unsafe extern "C" fn se_encrypt( handle: u32, - val_ptr: *const u8, + val_ptr: *mut u8, val_len: u32, aad_ptr: *const u8, aad_len: u32, @@ -305,7 +368,7 @@ pub unsafe extern "C" fn se_encrypt( #[no_mangle] pub unsafe extern "C" fn se_encrypt_element( handle: u32, - val_ptr: *const u8, + val_ptr: *mut u8, val_len: u32, aad_ptr: *const u8, aad_len: u32, @@ -357,18 +420,20 @@ pub unsafe extern "C" fn se_decrypt_element( /// up the handle, block on the op. fn run_encrypt( handle: u32, - val_ptr: *const u8, + val_ptr: *mut u8, val_len: u32, aad_ptr: *const u8, aad_len: u32, as_element: bool, ) -> u64 { catch_unwind(AssertUnwindSafe(|| { - let value = input(val_ptr, val_len)?; + // Plaintext: taken and the host's copy wiped before anything else. + let value = unsafe { take_plaintext(val_ptr, val_len)? }; + let value = value.as_slice(); let aad = input(aad_ptr, aad_len)?; with_cipher(handle, |cipher| { block_on(ops::encrypt_value( - &cipher.default_keyset(), + &cipher.keyset()?, value, aad, as_element, @@ -392,7 +457,12 @@ fn run_decrypt( let ciphertext = input(ct_ptr, ct_len)?; let aad = input(aad_ptr, aad_len)?; with_cipher(handle, |cipher| { - block_on(ops::decrypt_value(cipher, ciphertext, aad, as_element)) + block_on(ops::decrypt_value( + &cipher.cipher, + ciphertext, + aad, + as_element, + )) }) })) .unwrap_or(Err(STATUS_INTERNAL)) @@ -422,17 +492,18 @@ fn run_decrypt( #[no_mangle] pub unsafe extern "C" fn se_term( handle: u32, - val_ptr: *const u8, + val_ptr: *mut u8, val_len: u32, ctx_ptr: *const u8, ctx_len: u32, kind: u32, ) -> u64 { catch_unwind(AssertUnwindSafe(|| { - let value = input(val_ptr, val_len)?; + let value = unsafe { take_plaintext(val_ptr, val_len)? }; + let value = value.as_slice(); let context = input(ctx_ptr, ctx_len)?; with_cipher(handle, |cipher| { - block_on(ops::term(&cipher.default_keyset(), value, context, kind)) + block_on(ops::term(&cipher.keyset()?, value, context, kind)) }) })) .unwrap_or(Err(STATUS_INTERNAL)) @@ -454,16 +525,17 @@ pub unsafe extern "C" fn se_term( #[no_mangle] pub unsafe extern "C" fn se_encrypt_record( handle: u32, - src_ptr: *const u8, + src_ptr: *mut u8, src_len: u32, plan_ptr: *const u8, plan_len: u32, ) -> u64 { catch_unwind(AssertUnwindSafe(|| { - let source = input(src_ptr, src_len)?; + let source = unsafe { take_plaintext(src_ptr, src_len)? }; + let source = source.as_slice(); let plan = input(plan_ptr, plan_len)?; with_cipher(handle, |cipher| { - block_on(ops::encrypt_record(&cipher.default_keyset(), source, plan)) + block_on(ops::encrypt_record(&cipher.keyset()?, source, plan)) }) })) .unwrap_or(Err(STATUS_INTERNAL)) @@ -491,7 +563,7 @@ pub unsafe extern "C" fn se_decrypt_record( let record = input(rec_ptr, rec_len)?; let plan = input(plan_ptr, plan_len)?; with_cipher(handle, |cipher| { - block_on(ops::decrypt_record(cipher, record, plan)) + block_on(ops::decrypt_record(&cipher.cipher, record, plan)) }) })) .unwrap_or(Err(STATUS_INTERNAL)) From bec292211ea30a48a45c561949cef16238f91424 Mon Sep 17 00:00:00 2001 From: Dan Draper <dan@cipherstash.com> Date: Fri, 11 Sep 2026 23:27:36 -0400 Subject: [PATCH 534/686] feat(wasi)!: one cipher per instance, keysets selected per call; the session table goes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CIP-4037, part 2. The guest kept a handle table of `StackCipher`s so a host could hold several — a per-binding solution to a multiplicity that part 1 moved into `stack-encrypt` itself. With one client per instance and keysets selected per call, the table, its id allocator, its aliasing invariant and its stale-handle status have nothing left to do. `se_cipher_init` runs once per instance and returns the default keyset's id (16 raw UUID bytes); a second call is `STATUS_STATE`. Every export that touches a keyset gains a trailing codec-encoded options object, `{"keyset": <selector>}`, where the selector is a tagged object: `{"default": {}}`, `{"name": s}`, `{"id": 16 bytes}`, or — on the opening exports only — `{"any": {}}`. Every variant is spelled; there is no zero-length or omitted-field sentinel. On `se_encrypt`, `se_encrypt_element`, `se_encrypt_record` and `se_term` the selector picks the keyset that mints, loaded on first use through the cipher's cache; on `se_decrypt`, `se_decrypt_element` and `se_decrypt_record` it is a constraint — a named keyset refuses a leaf sealed under any other as `STATUS_FOREIGN_KEYSET` before any key is retrieved, and `{"any"}` opens leaves from whichever keyset each carries, one ZeroKMS call per keyset. `se_keyset(selector)` resolves a keyset and returns its id, so a host can validate a tenant at boot. The `options` module is the one home of these shapes; they are a cross-language contract. `se_shutdown()` replaces `se_cipher_free(handle)`: it drops the cipher — client key and every loaded index key wiped by `ZeroizeOnDrop` — and wipes every buffer the registry still holds. It exists because closing a wasm instance frees linear memory without running Rust destructors; what went away is the handle, not the wipe. Every export after it is `STATUS_STATE`. `STATUS_STATE` takes code 3, the vitaminc guest's "unknown handle", the same condition to a host: no cipher for this call. `STATUS_FOREIGN_KEYSET` is 12. The config object gains `keyset_cache_size`. BREAKING CHANGE: every `se_*` export lost its leading `handle` argument; `se_encrypt`, `se_encrypt_element`, `se_decrypt`, `se_decrypt_element`, `se_term`, `se_encrypt_record` and `se_decrypt_record` gained a trailing `(opt_ptr, opt_len)` options object; `se_cipher_init` returns a buffer, not a handle, and runs once; `se_cipher_free` is `se_shutdown`; `STATUS_BAD_HANDLE` is `STATUS_STATE`. No Go module consumes this ABI yet (CIP-4022 is written against this shape). Claude-Session: https://claude.ai/code/session_01ECzyss8kvtnrRtq5tmpEbg --- docs/plans/stack-encrypt-go-bindings.md | 15 +- .../golang/stackencrypt/guest/src/abi.rs | 396 ++++++++++-------- .../golang/stackencrypt/guest/src/buffers.rs | 16 + .../golang/stackencrypt/guest/src/config.rs | 116 ++--- .../golang/stackencrypt/guest/src/host.rs | 2 +- .../golang/stackencrypt/guest/src/lib.rs | 27 +- .../golang/stackencrypt/guest/src/ops.rs | 55 ++- .../golang/stackencrypt/guest/src/options.rs | 311 ++++++++++++++ .../golang/stackencrypt/guest/src/sessions.rs | 91 ---- .../golang/stackencrypt/guest/src/status.rs | 51 ++- .../stackencrypt/guest/tests/native_ops.rs | 230 +++++++++- 11 files changed, 922 insertions(+), 388 deletions(-) create mode 100644 languages/golang/stackencrypt/guest/src/options.rs delete mode 100644 languages/golang/stackencrypt/guest/src/sessions.rs diff --git a/docs/plans/stack-encrypt-go-bindings.md b/docs/plans/stack-encrypt-go-bindings.md index c1575eb6f..dc339f874 100644 --- a/docs/plans/stack-encrypt-go-bindings.md +++ b/docs/plans/stack-encrypt-go-bindings.md @@ -141,7 +141,7 @@ Go application │ ▼ wazero (wasm32-wasip1) stack-encrypt guest (Rust cdylib) - ├─ StackCipher<StackKms<HostTokenStrategy, WasiHostConnection>> (one per handle) + ├─ StackCipher<StackKms<HostTokenStrategy, WasiHostConnection>> (one per instance; keysets selected per call) ├─ FfiValue.encrypt_with_aad(&cipher, aad) → PendingStackCipherText → seal(kms) (block_on) ├─ record plan → per-field EncryptFrom pendings → Pending::all → one generate_keys └─ term(value, context, kind) → local PRF/ORE, no I/O @@ -382,13 +382,14 @@ via the registry, packed `u64` results, status in the low word on error): | export | does | |---|---| | `se_alloc(len)` / `se_dealloc(ptr, len)` | buffer lifecycle, as vitaminc | -| `se_cipher_init(cfg_ptr, cfg_len) → handle` | config (client id, client key, keyset id/name or default) encoded as an `FfiValue` object — no second codec. Builds `StackKms<HostTokenStrategy, WasiHostConnection>`, then `StackCipherBuilder::kms(..).keyset(..).init()` under `block_on` (one `load_keyset` call — the index key is now held in the guest). Client-key bytes zeroized after `ClientKey` is built. | -| `se_cipher_free(handle)` | drops the `StackCipher` (index key, client key wiped by `ZeroizeOnDrop`) | -| `se_encrypt(handle, value, aad)` / `se_decrypt(handle, ct, aad)` | decode `FfiValue` → `encrypt_with_aad(&cipher, aad)` → `seal(kms)` (`block_on`) → `encode_ciphertext::<SealedValue, _>`. Decrypt mirrors via `cipher.decipher(ct)` + `FfiValue::decrypt_with_aad`. | +| `se_cipher_init(cfg_ptr, cfg_len) → keyset id` | once per instance (CIP-4037): config (client id, client key, default keyset id/name, optional `keyset_cache_size`) encoded as an `FfiValue` object — no second codec. Builds `StackKms<HostTokenStrategy, WasiHostConnection>`, then `StackCipherBuilder::kms(..).keyset(..).init()` under `block_on` (one `load_keyset` call for the default keyset). Returns the default keyset's UUID as its buffer; a second call is `STATUS_STATE`. Client-key bytes zeroized after `ClientKey` is built. | +| `se_shutdown()` | drops the `StackCipher` (client key and every loaded index key wiped by `ZeroizeOnDrop`) and wipes every buffer the registry still holds. Needed because closing a wasm instance frees linear memory without running Rust destructors. Any export after it is `STATUS_STATE`. | +| `se_keyset(selector)` | resolves a keyset selector (`{"default": {}}` / `{"name": s}` / `{"id": 16 bytes}`) through the cipher's cache — the first use of a keyset is one `load_keyset` call — and returns its UUID, so a host can validate a tenant at boot and learn its id | +| `se_encrypt(value, aad, opts)` / `se_decrypt(ct, aad, opts)` | `opts` is the options object `{"keyset": <selector>}` (the guest's `options` module is its one home). Encrypt: decode `FfiValue` → `encrypt_with_aad(&keyset, aad)` → `seal(&keyset, aad)` (`block_on`) → `encode_ciphertext::<SealedValue, _>`. Decrypt mirrors via `decipher(ct)` + `FfiValue::decrypt_with_aad`; its selector is a constraint — `{"any": {}}` opens leaves from whichever keyset each was sealed under (one `retrieve_keys` per keyset), a named keyset refuses any other's leaf as `STATUS_FOREIGN_KEYSET` before any key is retrieved. | | `se_encrypt_element` / `se_decrypt_element` | as vitaminc; row-at-a-time interop with batch-encrypted slices | -| `se_encrypt_record(handle, source, plan, aad)` | the runtime form of `#[derive(EncryptFrom)]`: `plan` is an `FfiValue` object `{ field → { context, outputs: [c \| eq \| match(opts) \| ore \| ope] } }`; per field the guest dispatches on the source `FfiValue` variant to the typed `EncryptFrom` impls (`u32`/`u64`/`i64`/`f64`/`String`), zips the pendings, `Pending::all` across an array source, and returns `{ field → { c: leaf, hm: bytes, ob: bytes, … } }`. One `generate_keys` call per invocation regardless of row count. | -| `se_decrypt_record(handle, record, plan, aad)` | inverse; only the `c` outputs participate | -| `se_term(handle, value, context, kind)` | query probe; `context` is codec-encoded in the plan-field grammar — one part (a string, bytes, or an `i32`/`i64`/`u32`/`u64`) or an array of parts, nested as deep as the transport codec allows (`vitaminc_aead_value::transport::MAX_DEPTH`, 128 levels from the root of the encoded value; deeper is `STATUS_ENCODING` before the context is parsed, not an interop bug). Shape is identity: `[x]` is not `x`, so a probe passes the context in exactly the shape the field was sealed under (the guest's `context` module is the one home of the grammar and of which Rust context each shape spells). PRF/ORE derivation: no ZeroKMS traffic under the local HMAC backend, a batched request under one that derives terms at the server | +| `se_encrypt_record(source, plan, opts)` | the runtime form of `#[derive(EncryptFrom)]`: `plan` is an `FfiValue` object `{ field → { context, outputs: [c \| eq \| match(opts) \| ore \| ope] } }`; per field the guest dispatches on the source `FfiValue` variant to the typed `EncryptFrom` impls (`u32`/`u64`/`i64`/`f64`/`String`), zips the pendings, `Pending::all` across an array source, and returns `{ field → { c: leaf, hm: bytes, ob: bytes, … } }`. One `generate_keys` call per invocation regardless of row count. | +| `se_decrypt_record(record, plan, opts)` | inverse; only the `c` outputs participate; the selector constrains as for `se_decrypt` | +| `se_term(value, context, kind, opts)` | query probe under the selected keyset's index key; `context` is codec-encoded in the plan-field grammar — one part (a string, bytes, or an `i32`/`i64`/`u32`/`u64`) or an array of parts, nested as deep as the transport codec allows (`vitaminc_aead_value::transport::MAX_DEPTH`, 128 levels from the root of the encoded value; deeper is `STATUS_ENCODING` before the context is parsed, not an interop bug). Shape is identity: `[x]` is not `x`, so a probe passes the context in exactly the shape the field was sealed under (the guest's `context` module is the one home of the grammar and of which Rust context each shape spells). Under the local HMAC backend a probe does no ZeroKMS I/O; that is the backend's property, not the API's (ZeroKMS v2 derives terms at the server) | Host imports (two, both from the `cipherstash_transport` module #2099 defined): diff --git a/languages/golang/stackencrypt/guest/src/abi.rs b/languages/golang/stackencrypt/guest/src/abi.rs index d78acb266..77b931293 100644 --- a/languages/golang/stackencrypt/guest/src/abi.rs +++ b/languages/golang/stackencrypt/guest/src/abi.rs @@ -7,26 +7,33 @@ //! before freeing**. The guest keeps a registry of every buffer it hands //! out (`crate::buffers`), so `se_dealloc` never trusts the host's //! length. -//! - **Every export that is handed plaintext wipes that buffer in place -//! before it returns**, rather than leaving it for `se_dealloc`: -//! [`se_cipher_init`] (the config carries the client key), -//! [`se_encrypt`], [`se_encrypt_element`], [`se_term`] and -//! [`se_encrypt_record`] (the value/source buffers). The host's plaintext -//! therefore lives no longer than the call, instead of until the host -//! gets round to releasing it. **A host must not read a plaintext input -//! buffer back after the call, or pass the same buffer to two -//! calls** — it will be zeros. Context, AAD and plan buffers are not -//! secret and are left untouched. -//! - Output buffers from the decrypt exports contain plaintext; the host -//! must copy them out and immediately `se_dealloc` (which zeroizes). -//! - A cipher is a **handle**: [`se_cipher_init`] builds a +//! - **Every export handed plaintext wipes that buffer in place before it +//! returns**, rather than leaving it for `se_dealloc`: [`se_cipher_init`] +//! (the config carries the client key), and [`se_encrypt`], +//! [`se_encrypt_element`], [`se_term`] and [`se_encrypt_record`] (their +//! value/source buffers). The host's plaintext therefore lives no longer +//! than the call, instead of until the host gets round to releasing it. +//! **A host must not read a plaintext input buffer back after the call, or +//! pass the same buffer to two calls** — it will be zeros. Option, context, +//! AAD and plan buffers are not secret and are left untouched. +//! - Output buffers from the decrypt exports contain plaintext; the host must +//! copy them out and immediately `se_dealloc` (which zeroizes). +//! - **One instance is one client.** [`se_cipher_init`] runs once per +//! instance: it builds the //! `StackCipher<StackKms<HostTokenStrategy, WasiHostConnection>>` (one -//! `load-keyset` round trip through the host transport — the index key -//! then lives in the guest), and [`se_cipher_free`] drops it (client key -//! wiped unconditionally, index key subject to the `Arc` precondition -//! documented on that export). Handle ids are -//! never reused; at exhaustion `se_cipher_init` fails with -//! `STATUS_INTERNAL` rather than aliasing a live handle. +//! `load-keyset` round trip through the host transport for the default +//! keyset) and returns that keyset's id. There is no cipher handle: the +//! keysets a client uses are selected per call through the options object +//! ([`crate::options`]), loaded on first use through the cipher's own +//! cache. Nothing crosses the boundary that the host could allocate, +//! alias or free. +//! - [`se_shutdown`] is the one lifetime call: it drops the cipher (client +//! key and every loaded index key wiped by `ZeroizeOnDrop`) and wipes +//! every buffer the registry still holds. It exists because closing a +//! wasm instance frees linear memory without running Rust destructors — +//! without it, key material would sit in freed host memory. Every export +//! after it, and every export before [`se_cipher_init`], is +//! `STATUS_STATE`. //! - During an entry-point call the host's imported functions may re-enter //! the guest **only** through `se_alloc` (to place the transport response //! / token); calling any other export from inside a host import is @@ -38,11 +45,10 @@ //! 32-bit field: //! //! - **success** — the high 32 bits are non-zero: an output pointer with -//! the low 32 bits its length, or (for [`se_cipher_init`]) the handle -//! with the low bits unused. +//! the low 32 bits its length. //! - **error** — the high 32 bits are zero and the low 32 bits are a -//! [`crate::status`] code. A valid pointer / handle is never zero, so -//! the two spaces never collide. +//! [`crate::status`] code. A valid pointer is never zero, so the two +//! spaces never collide. //! //! # Hostile-input posture //! @@ -54,7 +60,7 @@ //! the only detail leaked. //! //! The value exports ([`se_encrypt`] and friends) are the cipher-directed -//! path and take the AAD as `StackCipher::encrypt` does: any bytes, none +//! path and take the AAD as `KeysetCipher::encrypt` does: any bytes, none //! included — a null pointer with zero length is the empty AAD, as a Go //! `nil` slice is. The record and term exports bind fields, so their //! contexts must be non-empty (`STATUS_ENCODING` otherwise): each is a @@ -62,70 +68,45 @@ //! and opening sides bind that one value. The asymmetry is the design; see //! `packages/stack-encrypt/docs/adr/0001-context-optional-cipher-directed-path.md`. //! -//! One difference from the vitaminc guest, deliberate: where `vc_encrypt` -//! decodes its input *before* looking up the handle — so garbage bytes read -//! as `STATUS_ENCODING` even for an unknown handle — the exports here look -//! up the handle first, because decoding lives inside [`crate::ops`] so that -//! the ops can be driven (and natively tested) as whole operations. The -//! observable difference is which status an unknown handle *and* malformed -//! input reports; `STATUS_BAD_HANDLE` is the more actionable of the two, and -//! the ordering leaks nothing either way — the handle table is consulted -//! with a value the caller already supplied. +//! Every export decodes and validates its inputs — including the options +//! object — before it consults the cipher, so malformed input reads as +//! `STATUS_ENCODING` whether or not `se_cipher_init` has run; only a +//! well-formed call with no cipher is `STATUS_STATE`. The one exception is +//! keyset *resolution* (a name or id the cipher has not loaded), which is a +//! round trip and so happens inside the call, after the state check. //! //! Wasm modules are single-threaded; the host must serialize calls into one //! instance. -use std::cell::RefCell; +use std::cell::{Cell, RefCell}; use std::panic::{catch_unwind, AssertUnwindSafe}; use futures::executor::block_on; -use stack_encrypt::StackCipher; +use stack_encrypt::{KeysetCipher, StackCipher}; use stack_kms::{ClientOpts, StackKms}; use vitaminc_aead_value::transport as codec; +use vitaminc_aead_value::FfiValue; use zeroize::{Zeroize, Zeroizing}; use crate::buffers; use crate::config::parse_config; use crate::host::{HostTokenStrategy, WasiHostConnection}; use crate::ops; -use crate::sessions::Sessions; -use crate::status::{STATUS_BAD_HANDLE, STATUS_ENCODING, STATUS_INTERNAL, STATUS_KMS_TRANSPORT}; +use crate::options::{parse_options, parse_selector, Opener, Side}; +use crate::status::{STATUS_ENCODING, STATUS_INTERNAL, STATUS_KMS_TRANSPORT, STATUS_STATE}; -/// The cipher a handle names: `stack-encrypt` over the host-transport -/// ZeroKMS client with host-supplied tokens. +/// The instance's cipher: `stack-encrypt` over the host-transport ZeroKMS +/// client with host-supplied tokens. type GuestCipher = StackCipher<StackKms<HostTokenStrategy, WasiHostConnection>>; -/// A cipher handle and the keyset its config pinned it to. -/// -/// The keyset is held as an id rather than a bound handle because a -/// `KeysetCipher` borrows its `StackCipher`, and a session owns one. It is -/// resolved once at [`cipher_init`] — so a keyset the client cannot reach -/// fails there, not on the first encrypt — and every later resolution is a -/// cache hit inside `StackCipher`. -struct GuestSession { - cipher: GuestCipher, - keyset: Option<stack_kms::IdentifiedBy>, -} - -impl GuestSession { - /// The keyset this session encrypts under: the configured one, else the - /// client's own default. - fn keyset( - &self, - ) -> Result<stack_encrypt::KeysetCipher<'_, StackKms<HostTokenStrategy, WasiHostConnection>>, u32> - { - match &self.keyset { - Some(keyset) => block_on(self.cipher.keyset(keyset.clone())) - .map_err(|e| crate::status::status_for_error(&e)), - None => Ok(self.cipher.default_keyset()), - } - } -} - thread_local! { // Wasm is single-threaded, so a thread-local `RefCell` is a plain owner - // of the session table — no `Send`/`Sync` bounds required. - static SESSIONS: RefCell<Sessions<GuestSession>> = RefCell::new(Sessions::new()); + // of the cipher — no `Send`/`Sync` bounds required. + static CIPHER: RefCell<Option<GuestCipher>> = const { RefCell::new(None) }; + /// Set by `se_shutdown`: after it, `se_cipher_init` is refused too, so + /// an instance the host has torn down cannot be quietly revived with + /// stale buffers around. + static SHUT_DOWN: Cell<bool> = const { Cell::new(false) }; } /// Allocate `len` bytes of guest memory for the host to write into. Returns @@ -157,12 +138,6 @@ fn ok_buffer(out: Vec<u8>) -> u64 { (ptr << 32) | len } -/// Pack a handle result: `handle << 32`. Handles start at 1, so the high 32 -/// bits are non-zero; the low bits are unused. -fn ok_handle(handle: u32) -> u64 { - (handle as u64) << 32 -} - /// Pack an error: the status in the low 32 bits, high bits zero. fn err_status(status: u32) -> u64 { status as u64 @@ -213,6 +188,11 @@ unsafe fn wipe_input(ptr: *mut u8, len: u32) { unsafe { std::slice::from_raw_parts_mut(ptr, len as usize) }.zeroize(); } +/// Decode one codec-encoded input. +fn decode(bytes: &[u8]) -> Result<FfiValue, u32> { + codec::decode_value(&mut codec::Reader::new(bytes)).map_err(|_| STATUS_ENCODING) +} + /// Take a plaintext input out of the host's buffer and wipe the buffer. /// /// The exports below hold their decoded value across a ZeroKMS round trip, so @@ -221,6 +201,11 @@ unsafe fn wipe_input(ptr: *mut u8, len: u32) { /// plaintext then exists for the duration of this call and no longer, instead /// of sitting in linear memory until the host gets round to `se_dealloc`. /// +/// Called before any other buffer is borrowed, deliberately. The wipe writes +/// through `&mut`, so no other `&[u8]` into linear memory may be live — and a +/// host that aliases its value range onto another argument therefore reads +/// zeros there, which the option parser rejects as `STATUS_ENCODING`. +/// /// # Safety /// /// `ptr`/`len` must name a host buffer the caller is done with; it is zeroed @@ -231,21 +216,54 @@ unsafe fn take_plaintext(ptr: *mut u8, len: u32) -> Result<Zeroizing<Vec<u8>>, u Ok(taken) } -/// Run `f` with the cipher bound to `handle`, or report `STATUS_BAD_HANDLE`. -fn with_cipher<R>(handle: u32, f: impl FnOnce(&GuestSession) -> Result<R, u32>) -> Result<R, u32> { - SESSIONS.with(|s| { - let s = s.borrow(); - let cipher = s.get(handle).ok_or(STATUS_BAD_HANDLE)?; +/// Run `f` with the instance's cipher, or report `STATUS_STATE` when there +/// is none (never initialised, or shut down). +fn with_cipher<R>(f: impl FnOnce(&GuestCipher) -> Result<R, u32>) -> Result<R, u32> { + CIPHER.with(|c| { + let c = c.borrow(); + let cipher = c.as_ref().ok_or(STATUS_STATE)?; f(cipher) }) } -/// Initialise a cipher from an FFI-codec-encoded config object (see -/// [`crate::config`]), returning a handle. Performs one `load-keyset` -/// round trip through the host transport; the raw config buffer — which +/// Run `f` with the keyset the mint-side options in `opts` select, +/// resolving it through the cipher (a first use is one `load-keyset` round +/// trip). The options are decoded and validated before the cipher is +/// consulted. +fn with_keyset<R>( + opts: &[u8], + f: impl FnOnce(&KeysetCipher<'_, StackKms<HostTokenStrategy, WasiHostConnection>>) -> Result<R, u32>, +) -> Result<R, u32> { + let options = parse_options(decode(opts)?, Side::Mint)?; + with_cipher(|cipher| { + let keyset = block_on(options.keyset.resolve(cipher))?; + f(&keyset) + }) +} + +/// Run `f` with the opener the open-side options in `opts` select: the +/// client for `{"any"}`, one keyset's cipher otherwise. +fn with_opener<R>( + opts: &[u8], + f: impl FnOnce(Opener<'_, StackKms<HostTokenStrategy, WasiHostConnection>>) -> Result<R, u32>, +) -> Result<R, u32> { + let options = parse_options(decode(opts)?, Side::Open)?; + with_cipher(|cipher| { + let opener = block_on(Opener::for_selector(cipher, &options.keyset))?; + f(opener) + }) +} + +/// Initialise the instance's cipher from an FFI-codec-encoded config object +/// (see [`crate::config`]). Performs one `load-keyset` round trip through +/// the host transport for the default keyset, and returns that keyset's id +/// (16 raw UUID bytes) as the output buffer. The raw config buffer — which /// carries the client-key hex — is wiped in place before any network /// traffic, whatever the outcome. /// +/// Once per instance: a second call, or a call after [`se_shutdown`], is +/// `STATUS_STATE` (the config buffer is still wiped). +/// /// # Safety /// /// `cfg_ptr`/`cfg_len` should name the buffer the host wrote the config @@ -255,8 +273,7 @@ fn with_cipher<R>(handle: u32, f: impl FnOnce(&GuestSession) -> Result<R, u32>) #[no_mangle] pub unsafe extern "C" fn se_cipher_init(cfg_ptr: *mut u8, cfg_len: u32) -> u64 { catch_unwind(AssertUnwindSafe(|| { - let decoded = codec::decode_value(&mut codec::Reader::new(input(cfg_ptr, cfg_len)?)) - .map_err(|_| STATUS_ENCODING); + let decoded = input(cfg_ptr, cfg_len).and_then(decode); // The borrow of the raw buffer ends with `decoded` owned; wipe the // buffer now — it holds the client-key hex — before parsing (and // before the init round trip), whatever the decode outcome. @@ -264,11 +281,14 @@ pub unsafe extern "C" fn se_cipher_init(cfg_ptr: *mut u8, cfg_len: u32) -> u64 { cipher_init(decoded?) })) .unwrap_or(Err(STATUS_INTERNAL)) - .map_or_else(err_status, ok_handle) + .map_or_else(err_status, ok_buffer) } -fn cipher_init(decoded: vitaminc_aead_value::FfiValue) -> Result<u32, u32> { +fn cipher_init(decoded: FfiValue) -> Result<Vec<u8>, u32> { let config = parse_config(decoded).map_err(|_| STATUS_ENCODING)?; + if SHUT_DOWN.with(Cell::get) || CIPHER.with(|c| c.borrow().is_some()) { + return Err(STATUS_STATE); + } // One request at a time: the host import is synchronous, so concurrency // would only interleave nothing; keep the executor honest about it. @@ -290,57 +310,71 @@ fn cipher_init(decoded: vitaminc_aead_value::FfiValue) -> Result<u32, u32> { ) .map_err(|_| STATUS_KMS_TRANSPORT)?; - let cipher = block_on(StackCipher::builder().kms(kms).init()) - .map_err(|e| crate::status::status_for_error(&e))?; - // A configured keyset is resolved now, so a name the client cannot reach - // fails at init rather than on the first encrypt. The handle is dropped; - // what it warmed is the cipher's keyset cache. - if let Some(keyset) = &config.keyset { - let _ = block_on(cipher.keyset(keyset.clone())) - .map_err(|e| crate::status::status_for_error(&e))?; + let mut builder = StackCipher::builder().kms(kms); + if let Some(size) = config.keyset_cache_size { + builder = builder.keyset_cache_size(size); } - SESSIONS.with(|s| { - s.borrow_mut().insert(GuestSession { - cipher, - keyset: config.keyset, - }) - }) + let cipher = block_on(builder.init()).map_err(|e| crate::status::status_for_error(&e))?; + let default = cipher.default_keyset().keyset_id().as_bytes().to_vec(); + CIPHER.with(|c| *c.borrow_mut() = Some(cipher)); + Ok(default) } -/// Drop a cipher handle. Freeing an unknown handle is a no-op. -/// -/// The client key's wipe is unconditional: the `StackCipher` owns it, so the -/// `ZeroizeOnDrop` runs here. +/// Tear the instance down: drop the cipher — the client key and every +/// loaded keyset's index key are wiped by `ZeroizeOnDrop` — and wipe every +/// buffer the registry still holds, so nothing the host forgot to +/// [`se_dealloc`] survives in freed memory. Idempotent; every other export +/// is `STATUS_STATE` afterwards, [`se_cipher_init`] included. /// -/// The keyset's index key is wiped here **only while no other reference to -/// the PRF is outstanding**. It lives in `HmacSha256Prf { key: Arc<Protected -/// <Vec<u8>>> }`, and `Arc` runs the inner `ZeroizeOnDrop` at strong count -/// zero — so a live clone means this call frees the handle and leaves the key -/// in memory. Every derivation takes such a clone (`sem::equality`, -/// `ore_term`, `ope_term`), but the guest is single-threaded and each clone -/// is created and dropped inside one `block_on`'d ABI call, so none can still -/// be alive when the host calls this. That is the precondition, not a -/// property of the drop: anything that later parks a PRF clone beyond an ABI -/// call — a cache, a background task, a `'static` handle — silently turns -/// this wipe into a no-op with no test to catch it. +/// The index keys' wipe holds because each `HmacSha256Prf` clone a +/// derivation takes is created and dropped inside one `block_on`'d call, +/// and the guest is single-threaded, so no clone is alive when the host +/// calls this. That is the precondition, not a property of the drop: +/// anything that later parks a PRF clone beyond an ABI call turns this +/// wipe into a no-op for that key. #[no_mangle] -pub extern "C" fn se_cipher_free(handle: u32) { +pub extern "C" fn se_shutdown() { let _ = catch_unwind(AssertUnwindSafe(|| { - SESSIONS.with(|s| { - s.borrow_mut().remove(handle); + SHUT_DOWN.with(|s| s.set(true)); + CIPHER.with(|c| { + let _ = c.borrow_mut().take(); }); + buffers::wipe_all(); })); } -/// Encrypt an FFI-codec-encoded value tree under the handle's cipher, +/// Resolve a keyset selector (a codec-encoded tagged object — see +/// [`crate::options`]; `{"any"}` is not a keyset and is `STATUS_ENCODING` +/// here) through the cipher's cache and return the keyset's id (16 raw UUID +/// bytes). A first use of a keyset is one `load-keyset` round trip; a host +/// can call this at boot to validate a tenant's keyset and learn its id. +/// +/// # Safety +/// +/// As for [`se_encrypt`]. +#[no_mangle] +pub unsafe extern "C" fn se_keyset(sel_ptr: *const u8, sel_len: u32) -> u64 { + catch_unwind(AssertUnwindSafe(|| { + let selector = parse_selector(decode(input(sel_ptr, sel_len)?)?)?; + with_cipher(|cipher| { + let keyset = block_on(selector.resolve(cipher))?; + Ok(keyset.keyset_id().as_bytes().to_vec()) + }) + })) + .unwrap_or(Err(STATUS_INTERNAL)) + .map_or_else(err_status, ok_buffer) +} + +/// Encrypt an FFI-codec-encoded value tree under the keyset `opts` selects, /// binding `aad`; every leaf is sealed from one batched key request, /// dispatched as one `generate-data-key` call per 500 keyed leaves (see /// `cipher_init` for where that bound comes from). Output: packed pointer /// to a codec-encoded ciphertext tree whose leaves are the frozen -/// `SealedValue` byte encoding. +/// `SealedValue` byte encoding, each carrying the keyset's id. /// /// `aad` may be empty (a null pointer with zero length is empty) — see this -/// module's hostile-input notes. +/// module's hostile-input notes. `opts` is the options object +/// (`{"keyset": <selector>}`, [`crate::options`]); `{"any"}` is refused here. /// /// # Safety /// @@ -349,13 +383,14 @@ pub extern "C" fn se_cipher_free(handle: u32) { /// pair returns `STATUS_ENCODING` instead of faulting). #[no_mangle] pub unsafe extern "C" fn se_encrypt( - handle: u32, val_ptr: *mut u8, val_len: u32, aad_ptr: *const u8, aad_len: u32, + opt_ptr: *const u8, + opt_len: u32, ) -> u64 { - run_encrypt(handle, val_ptr, val_len, aad_ptr, aad_len, false) + run_encrypt(val_ptr, val_len, aad_ptr, aad_len, opt_ptr, opt_len, false) } /// Like [`se_encrypt`], but seals the value as a *sequence element* — rows @@ -367,35 +402,41 @@ pub unsafe extern "C" fn se_encrypt( /// As for [`se_encrypt`]. #[no_mangle] pub unsafe extern "C" fn se_encrypt_element( - handle: u32, val_ptr: *mut u8, val_len: u32, aad_ptr: *const u8, aad_len: u32, + opt_ptr: *const u8, + opt_len: u32, ) -> u64 { - run_encrypt(handle, val_ptr, val_len, aad_ptr, aad_len, true) + run_encrypt(val_ptr, val_len, aad_ptr, aad_len, opt_ptr, opt_len, true) } /// Decrypt a codec-encoded ciphertext tree back into a codec-encoded value -/// tree; one batched key request, dispatched as one `retrieve-data-key` call -/// per 500 keyed leaves. The output buffer contains **plaintext** — the host -/// must copy it out and immediately release it with [`se_dealloc`] (which -/// wipes it). +/// tree; one batched key request per keyset the leaves were sealed under, +/// dispatched as one `retrieve-data-key` call per 500 keyed leaves. The +/// output buffer contains **plaintext** — the host must copy it out and +/// immediately release it with [`se_dealloc`] (which wipes it). /// /// `aad` must be the one the ciphertext was sealed under, empty included. +/// `opts` constrains which keyset may be opened: `{"any"}` opens leaves from +/// whichever keyset each was sealed under; `{"name"}`, `{"id"}` and +/// `{"default"}` refuse a leaf from any other keyset as +/// `STATUS_FOREIGN_KEYSET`, before any key is retrieved. /// /// # Safety /// /// As for [`se_encrypt`]. #[no_mangle] pub unsafe extern "C" fn se_decrypt( - handle: u32, ct_ptr: *const u8, ct_len: u32, aad_ptr: *const u8, aad_len: u32, + opt_ptr: *const u8, + opt_len: u32, ) -> u64 { - run_decrypt(handle, ct_ptr, ct_len, aad_ptr, aad_len, false) + run_decrypt(ct_ptr, ct_len, aad_ptr, aad_len, opt_ptr, opt_len, false) } /// Like [`se_decrypt`], but opens the ciphertext as a *sequence element* — @@ -407,37 +448,36 @@ pub unsafe extern "C" fn se_decrypt( /// As for [`se_encrypt`]. #[no_mangle] pub unsafe extern "C" fn se_decrypt_element( - handle: u32, ct_ptr: *const u8, ct_len: u32, aad_ptr: *const u8, aad_len: u32, + opt_ptr: *const u8, + opt_len: u32, ) -> u64 { - run_decrypt(handle, ct_ptr, ct_len, aad_ptr, aad_len, true) + run_decrypt(ct_ptr, ct_len, aad_ptr, aad_len, opt_ptr, opt_len, true) } -/// [`se_encrypt`] / [`se_encrypt_element`]'s shared drive: validate, look -/// up the handle, block on the op. +/// [`se_encrypt`] / [`se_encrypt_element`]'s shared drive: validate, select +/// the keyset, block on the op. fn run_encrypt( - handle: u32, val_ptr: *mut u8, val_len: u32, aad_ptr: *const u8, aad_len: u32, + opt_ptr: *const u8, + opt_len: u32, as_element: bool, ) -> u64 { catch_unwind(AssertUnwindSafe(|| { - // Plaintext: taken and the host's copy wiped before anything else. + // Plaintext first: the wipe writes through `&mut`, so nothing else + // may be borrowed from linear memory yet. let value = unsafe { take_plaintext(val_ptr, val_len)? }; let value = value.as_slice(); let aad = input(aad_ptr, aad_len)?; - with_cipher(handle, |cipher| { - block_on(ops::encrypt_value( - &cipher.keyset()?, - value, - aad, - as_element, - )) + let opts = input(opt_ptr, opt_len)?; + with_keyset(opts, |keyset| { + block_on(ops::encrypt_value(keyset, value, aad, as_element)) }) })) .unwrap_or(Err(STATUS_INTERNAL)) @@ -446,34 +486,31 @@ fn run_encrypt( /// [`se_decrypt`] / [`se_decrypt_element`]'s shared drive. fn run_decrypt( - handle: u32, ct_ptr: *const u8, ct_len: u32, aad_ptr: *const u8, aad_len: u32, + opt_ptr: *const u8, + opt_len: u32, as_element: bool, ) -> u64 { catch_unwind(AssertUnwindSafe(|| { let ciphertext = input(ct_ptr, ct_len)?; let aad = input(aad_ptr, aad_len)?; - with_cipher(handle, |cipher| { - block_on(ops::decrypt_value( - &cipher.cipher, - ciphertext, - aad, - as_element, - )) + let opts = input(opt_ptr, opt_len)?; + with_opener(opts, |opener| { + block_on(ops::decrypt_value(opener, ciphertext, aad, as_element)) }) })) .unwrap_or(Err(STATUS_INTERNAL)) .map_or_else(err_status, ok_buffer) } -/// Derive one index term: a codec-encoded scalar, a codec-encoded context -/// and a term kind ([`ops::TERM_EQUALITY`] etc.); the output is the term's -/// frozen byte encoding. Under the local HMAC backend this is one PRF/CLLW -/// derivation with no ZeroKMS I/O; that is the backend's property, not this -/// export's contract. +/// Derive one index term under the keyset `opts` selects: a codec-encoded +/// scalar, a codec-encoded context and a term kind ([`ops::TERM_EQUALITY`] +/// etc.); the output is the term's frozen byte encoding. Under the local +/// HMAC backend this is one PRF/CLLW derivation with no ZeroKMS I/O; that +/// is the backend's property, not this export's contract. /// /// The context is one part — a string, bytes, or an `i32`/`i64`/`u32`/`u64` /// — or an array of parts, which may nest as deep as the transport codec @@ -491,51 +528,53 @@ fn run_decrypt( /// As for [`se_encrypt`]. #[no_mangle] pub unsafe extern "C" fn se_term( - handle: u32, val_ptr: *mut u8, val_len: u32, ctx_ptr: *const u8, ctx_len: u32, kind: u32, + opt_ptr: *const u8, + opt_len: u32, ) -> u64 { catch_unwind(AssertUnwindSafe(|| { let value = unsafe { take_plaintext(val_ptr, val_len)? }; let value = value.as_slice(); let context = input(ctx_ptr, ctx_len)?; - with_cipher(handle, |cipher| { - block_on(ops::term(&cipher.keyset()?, value, context, kind)) + let opts = input(opt_ptr, opt_len)?; + with_keyset(opts, |keyset| { + block_on(ops::term(keyset, value, context, kind)) }) })) .unwrap_or(Err(STATUS_INTERNAL)) .map_or_else(err_status, ok_buffer) } -/// Encrypt a record (or a batch) per a plan — the runtime form of -/// `#[derive(EncryptFrom)]`; see [`ops::encrypt_record`] for the source, -/// plan, and result encodings. All rows and fields seal from **one** batched -/// key request regardless of row count — dispatched as one -/// `generate-data-key` call per 500 keyed leaves, sequentially — and terms -/// derive under the same keyset's index key (with no ZeroKMS traffic under -/// the local HMAC backend; a backend that derives terms at ZeroKMS would -/// add its own). +/// Encrypt a record (or a batch) per a plan under the keyset `opts` selects +/// — the runtime form of `#[derive(EncryptFrom)]`; see +/// [`ops::encrypt_record`] for the source, plan, and result encodings. All +/// rows and fields seal from **one** batched key request regardless of row +/// count — dispatched as one `generate-data-key` call per 500 keyed leaves, +/// sequentially — and terms derive under the same keyset's index key. /// /// # Safety /// /// As for [`se_encrypt`]. #[no_mangle] pub unsafe extern "C" fn se_encrypt_record( - handle: u32, src_ptr: *mut u8, src_len: u32, plan_ptr: *const u8, plan_len: u32, + opt_ptr: *const u8, + opt_len: u32, ) -> u64 { catch_unwind(AssertUnwindSafe(|| { let source = unsafe { take_plaintext(src_ptr, src_len)? }; let source = source.as_slice(); let plan = input(plan_ptr, plan_len)?; - with_cipher(handle, |cipher| { - block_on(ops::encrypt_record(&cipher.keyset()?, source, plan)) + let opts = input(opt_ptr, opt_len)?; + with_keyset(opts, |keyset| { + block_on(ops::encrypt_record(keyset, source, plan)) }) })) .unwrap_or(Err(STATUS_INTERNAL)) @@ -544,26 +583,29 @@ pub unsafe extern "C" fn se_encrypt_record( /// Decrypt a record (or a batch) produced by [`se_encrypt_record`] under /// the same plan; only the `"c"` outputs participate. One batched key -/// request per invocation, dispatched as one `retrieve-data-key` call per -/// 500 keyed leaves. The output buffer contains **plaintext** — same host -/// obligations as [`se_decrypt`]. +/// request per keyset the leaves were sealed under, dispatched as one +/// `retrieve-data-key` call per 500 keyed leaves. `opts` constrains the +/// keyset as for [`se_decrypt`]. The output buffer contains **plaintext** — +/// same host obligations as [`se_decrypt`]. /// /// # Safety /// /// As for [`se_encrypt`]. #[no_mangle] pub unsafe extern "C" fn se_decrypt_record( - handle: u32, rec_ptr: *const u8, rec_len: u32, plan_ptr: *const u8, plan_len: u32, + opt_ptr: *const u8, + opt_len: u32, ) -> u64 { catch_unwind(AssertUnwindSafe(|| { let record = input(rec_ptr, rec_len)?; let plan = input(plan_ptr, plan_len)?; - with_cipher(handle, |cipher| { - block_on(ops::decrypt_record(&cipher.cipher, record, plan)) + let opts = input(opt_ptr, opt_len)?; + with_opener(opts, |opener| { + block_on(ops::decrypt_record(opener, record, plan)) }) })) .unwrap_or(Err(STATUS_INTERNAL)) diff --git a/languages/golang/stackencrypt/guest/src/buffers.rs b/languages/golang/stackencrypt/guest/src/buffers.rs index 285b98f39..906d21803 100644 --- a/languages/golang/stackencrypt/guest/src/buffers.rs +++ b/languages/golang/stackencrypt/guest/src/buffers.rs @@ -108,6 +108,22 @@ pub(crate) unsafe fn take(ptr: *mut u8, len: usize) -> Option<Vec<u8>> { unsafe { reclaim(ptr, len) } } +/// Wipe and free every buffer the registry still holds — what `se_shutdown` +/// does after dropping the cipher, so a host that tears the instance down +/// without releasing an output first still leaves no plaintext behind. +/// Empties carry no bytes; their count is simply reset. +pub(crate) fn wipe_all() { + let live: Vec<(usize, usize)> = BUFFERS.with(|b| b.borrow_mut().drain().collect()); + for (ptr, len) in live { + // SAFETY: every entry was registered by `register`, which leaked a + // boxed slice of exactly `len` bytes at `ptr`, and it was removed + // above so nothing else can reclaim it. + let mut buf = unsafe { Vec::from_raw_parts(ptr as *mut u8, len, len) }; + buf.zeroize(); + } + EMPTY_BUFFERS.with(|c| c.set(0)); +} + unsafe fn reclaim(ptr: *mut u8, len: usize) -> Option<Vec<u8>> { if ptr.is_null() { return None; diff --git a/languages/golang/stackencrypt/guest/src/config.rs b/languages/golang/stackencrypt/guest/src/config.rs index 0581bc145..c12c4a9d8 100644 --- a/languages/golang/stackencrypt/guest/src/config.rs +++ b/languages/golang/stackencrypt/guest/src/config.rs @@ -8,19 +8,24 @@ //! |---------------|----------|---------| //! | `client_id` | yes | ZeroKMS client id (UUID) | //! | `client_key` | yes | the v1 client key material, hex-encoded (upper or lower case — the `to_hex_v1` / `CS_CLIENT_KEY` form) or standard padded base64 (the form `secretkey.json` serialises) | -//! | `keyset` | no | keyset *name* to pin the cipher to | -//! | `keyset_id` | no | keyset *id* (UUID) to pin the cipher to | //! | `zerokms_url` | no | pins the ZeroKMS endpoint at init; when absent the endpoint is resolved from the access token's `services` claim on first use | +//! | `keyset_cache_size` | no | how many keysets beyond the default the cipher keeps loaded (a positive decimal integer; the crate default, 1024, when absent). See `StackCipherBuilder::keyset_cache_size` | //! -//! `keyset` and `keyset_id` are mutually exclusive; with neither, the -//! client's default keyset is used. Unknown keys are rejected — a typo'd -//! optional key must not silently fall back to a default. +//! There is no config key for a keyset. `{"default"}` means the default a +//! ZeroKMS administrator set for this client, and a client does not get to +//! redefine it — the same reason `StackCipherBuilder::keyset` was removed. +//! Every other keyset is selected per call (see [`crate::options`]). Unknown +//! keys are rejected — a typo'd optional key must not silently fall back to a +//! default — so a host still sending `keyset` is told so rather than quietly +//! encrypting somewhere else. //! //! The parsed [`FfiValue`] holds the client-key hex inside //! `Protected`, which wipes on drop; the raw config *buffer* is wiped by the //! ABI layer immediately after decoding (see [`crate::abi`]). -use stack_kms::{ClientKey, IdentifiedBy, ZeroKmsEndpoint}; +use std::num::NonZeroUsize; + +use stack_kms::{ClientKey, ZeroKmsEndpoint}; use uuid::Uuid; use vitaminc_aead_value::FfiValue; use zeroize::Zeroizing; @@ -38,8 +43,6 @@ pub enum ConfigError { NotAString(&'static str), /// A key's value failed its own validation (bad UUID, bad hex, bad URL). Invalid(&'static str), - /// `keyset` and `keyset_id` were both given. - ConflictingKeysets, /// A key appeared twice. The codec rejects duplicate object keys before /// this parser runs, but `parse_config` is `pub` and takes any /// [`FfiValue`] — last-write-wins on, say, `client_key` must never be @@ -52,8 +55,8 @@ pub enum ConfigError { /// Everything `se_cipher_init` needs to build the cipher. pub struct CipherConfig { pub client_key: ClientKey, - pub keyset: Option<IdentifiedBy>, pub endpoint: Option<ZeroKmsEndpoint>, + pub keyset_cache_size: Option<NonZeroUsize>, } /// Parse a decoded config value. Consumes it so the client-key material has @@ -70,17 +73,15 @@ pub fn parse_config(value: FfiValue) -> Result<CipherConfig, ConfigError> { // above the wipe. Uniform is cheaper than remembering. let mut client_id: Option<Zeroizing<String>> = None; let mut client_key_encoded: Option<Zeroizing<String>> = None; - let mut keyset_name: Option<Zeroizing<String>> = None; - let mut keyset_id: Option<Zeroizing<String>> = None; let mut url: Option<Zeroizing<String>> = None; + let mut cache_size: Option<Zeroizing<String>> = None; for (key, value) in entries { let slot = match key.as_str() { "client_id" => &mut client_id, "client_key" => &mut client_key_encoded, - "keyset" => &mut keyset_name, - "keyset_id" => &mut keyset_id, "zerokms_url" => &mut url, + "keyset_cache_size" => &mut cache_size, _ => return Err(ConfigError::UnknownKey(key)), }; // The codec already rejects duplicate object keys, so on the ABI @@ -112,27 +113,21 @@ pub fn parse_config(value: FfiValue) -> Result<CipherConfig, ConfigError> { let client_key = ClientKey::from_encoded_v1(client_id, &client_key_encoded) .map_err(|_| ConfigError::Invalid("client_key"))?; - let keyset = match (keyset_name, keyset_id) { - (Some(_), Some(_)) => return Err(ConfigError::ConflictingKeysets), - (Some(name), None) => Some(IdentifiedBy::Name( - name.as_str() - .try_into() - .map_err(|_| ConfigError::Invalid("keyset"))?, - )), - (None, Some(id)) => Some(IdentifiedBy::Uuid( - Uuid::parse_str(&id).map_err(|_| ConfigError::Invalid("keyset_id"))?, - )), - (None, None) => None, - }; - let endpoint = url .map(|u| u.parse().map_err(|_| ConfigError::Invalid("zerokms_url"))) .transpose()?; + let keyset_cache_size = cache_size + .map(|n| { + n.parse::<NonZeroUsize>() + .map_err(|_| ConfigError::Invalid("keyset_cache_size")) + }) + .transpose()?; + Ok(CipherConfig { client_key, - keyset, endpoint, + keyset_cache_size, }) } @@ -142,9 +137,8 @@ fn name_of(key: &str) -> &'static str { match key { "client_id" => "client_id", "client_key" => "client_key", - "keyset" => "keyset", - "keyset_id" => "keyset_id", "zerokms_url" => "zerokms_url", + "keyset_cache_size" => "keyset_cache_size", _ => "unknown", } } @@ -182,31 +176,66 @@ mod tests { ])) .expect("minimal config parses"); assert_eq!(cfg.client_key.key_id, id); - assert!(cfg.keyset.is_none()); assert!(cfg.endpoint.is_none()); + assert!(cfg.keyset_cache_size.is_none()); } #[test] - fn parses_keyset_name_id_and_url() { + fn parses_the_keyset_cache_size() { let (id, hex) = client_key_hex(); + let id_s = id.to_string(); let cfg = parse_config(obj(vec![ - ("client_id", &id.to_string()), + ("client_id", &id_s), ("client_key", &hex), - ("keyset", "users"), - ("zerokms_url", "https://zerokms.example.com"), + ("keyset_cache_size", "16"), ])) .expect("config parses"); - assert!(matches!(cfg.keyset, Some(IdentifiedBy::Name(_)))); - assert!(cfg.endpoint.is_some()); + assert_eq!(cfg.keyset_cache_size, NonZeroUsize::new(16)); + + for bad in ["0", "-1", "sixteen", ""] { + assert!( + matches!( + parse_config(obj(vec![ + ("client_id", &id_s), + ("client_key", &hex), + ("keyset_cache_size", bad), + ])), + Err(ConfigError::Invalid("keyset_cache_size")) + ), + "{bad:?} must be refused" + ); + } + } + + /// A keyset is not a config key: the default is the server's and a + /// client does not redefine it, so a host that still sends one is told, + /// rather than silently encrypting under the client's default instead of + /// the keyset it named. + #[test] + fn a_keyset_key_is_rejected_like_any_other_unknown_key() { + let (id, hex) = client_key_hex(); + for key in ["keyset", "keyset_id"] { + assert!(matches!( + parse_config(obj(vec![ + ("client_id", &id.to_string()), + ("client_key", &hex), + (key, "users"), + ])), + Err(ConfigError::UnknownKey(_)) + )); + } + } - let keyset_id = Uuid::from_u128(9); + #[test] + fn parses_the_zerokms_url() { + let (id, hex) = client_key_hex(); let cfg = parse_config(obj(vec![ ("client_id", &id.to_string()), ("client_key", &hex), - ("keyset_id", &keyset_id.to_string()), + ("zerokms_url", "https://zerokms.example.com"), ])) .expect("config parses"); - assert!(matches!(cfg.keyset, Some(IdentifiedBy::Uuid(k)) if k == keyset_id)); + assert!(cfg.endpoint.is_some()); } /// The config table promises hex in either case *or* base64 — the form @@ -261,15 +290,6 @@ mod tests { ])), Err(ConfigError::Invalid("client_key")) )); - assert!(matches!( - parse_config(obj(vec![ - ("client_id", &id_s), - ("client_key", &hex), - ("keyset", "users"), - ("keyset_id", "00000000-0000-0000-0000-000000000009"), - ])), - Err(ConfigError::ConflictingKeysets) - )); assert!(matches!( parse_config(obj(vec![ ("client_id", &id_s), diff --git a/languages/golang/stackencrypt/guest/src/host.rs b/languages/golang/stackencrypt/guest/src/host.rs index 4f66670b7..6c363cc36 100644 --- a/languages/golang/stackencrypt/guest/src/host.rs +++ b/languages/golang/stackencrypt/guest/src/host.rs @@ -214,7 +214,7 @@ impl ZeroKMSConnection for WasiHostConnection { /// An [`AuthStrategy`] that fetches the bearer token from the host on every /// request via `token_get`. Refresh policy stays host-side (Phase-1 auth): /// whatever token the host hands over is presented as-is, so the host can -/// rotate tokens without re-initialising the cipher handle. +/// rotate tokens without re-initialising the cipher. pub struct HostTokenStrategy; impl AuthStrategy for &HostTokenStrategy { diff --git a/languages/golang/stackencrypt/guest/src/lib.rs b/languages/golang/stackencrypt/guest/src/lib.rs index a7a1142fc..d8e1a16c5 100644 --- a/languages/golang/stackencrypt/guest/src/lib.rs +++ b/languages/golang/stackencrypt/guest/src/lib.rs @@ -41,15 +41,22 @@ //! [`host`]); what crosses the boundary per call is a value tree in, a //! ciphertext/record tree out, and — inside the call — the same bytes that //! would cross TLS anyway. The client key enters guest memory once at -//! `se_cipher_init`; derived data keys and the index key never leave. +//! `se_cipher_init`; derived data keys and index keys never leave. +//! +//! One instance is one client: `se_cipher_init` runs once per instance and +//! the keysets that client uses are selected per call through the options +//! object ([`options`]), loaded on first use. There is no cipher handle, +//! and nothing for the host to allocate, alias or free — `se_shutdown` is +//! the one lifetime call, and it exists because closing a wasm instance +//! frees linear memory without running Rust destructors. //! //! Split into: //! -//! - [`ops`], [`context`], [`config`], [`response`], [`headers`], [`status`], -//! `sessions` — everything that is pure logic over `StackCipher<K>` / -//! bytes. Compiles and unit-tests on the native host target (`cargo -//! test` here, no wasm toolchain needed) against -//! `stack_kms::FakeDataKeySource`. +//! - [`ops`], [`context`], [`options`], [`config`], [`response`], +//! [`headers`], [`status`] — everything that is pure logic over +//! `StackCipher<K>` / `KeysetCipher<K>` / bytes. Compiles and unit-tests +//! on the native host target (`cargo test` here, no wasm toolchain +//! needed) against `stack_kms::FakeDataKeySource`. //! - [`abi`], [`host`], `buffers` (wasm32 only) — the export surface, //! the two host imports, and the buffer registry. See [`abi`]'s module //! docs for the full ABI contract. @@ -65,14 +72,10 @@ pub mod config; pub mod context; pub mod headers; pub mod ops; +pub mod options; pub mod response; pub mod status; -// Only the wasm32 ABI constructs the table; natively it exists for its -// unit tests. -#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))] -pub(crate) mod sessions; - // The ABI's packed u64 results embed 32-bit pointers, its bounds checks // read the wasm linear-memory size, and `host` calls imported functions — // so these modules only exist on wasm32. A native cdylib build therefore @@ -82,7 +85,7 @@ pub(crate) mod sessions; #[cfg(target_arch = "wasm32")] pub mod abi; // Target-independent (plain `Vec`s and raw pointers, no linear-memory -// reads), so like `sessions` it exists natively for its unit tests — the +// reads), so it exists natively for its unit tests — the // empty-buffer accounting in particular is pinned there. #[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))] pub(crate) mod buffers; diff --git a/languages/golang/stackencrypt/guest/src/ops.rs b/languages/golang/stackencrypt/guest/src/ops.rs index 2c815bd54..cb40bfd46 100644 --- a/languages/golang/stackencrypt/guest/src/ops.rs +++ b/languages/golang/stackencrypt/guest/src/ops.rs @@ -43,7 +43,7 @@ use stack_encrypt::sem::{CllwOpeEncrypt, CllwOreEncrypt, DefaultMatch}; use stack_encrypt::target::Pending; use stack_encrypt::{ AadPiece, BoxedPassthrough, CipherText, Element, Encrypt, IntoPrfContext, KeysetCipher, - NonEmpty, SealedValue, StackCipher, StackCipherText, + NonEmpty, SealedValue, StackCipherText, }; use stack_kms::DataKeySource; use vitaminc_aead_value::{transport as codec, FfiValue}; @@ -51,6 +51,7 @@ use vitaminc_protected::{Controlled, Protected}; use zeroize::Zeroizing; use crate::context::{borrowed, parse_context}; +use crate::options::Opener; use crate::status::{status_for_error, STATUS_ENCODING, STATUS_INTERNAL}; /// Term kinds for `se_term`, part of the guest/host contract (the Go host @@ -113,9 +114,10 @@ where /// contains plaintext — the ABI layer's ownership rules govern its wiping. /// /// Symmetric with [`encrypt_value`]: the AAD is whatever the value was sealed -/// under, empty included. +/// under, empty included. The [`Opener`] says which keysets may be opened: +/// any, or one, refusing the rest before any key is retrieved. pub async fn decrypt_value<K>( - cipher: &StackCipher<K>, + opener: Opener<'_, K>, ciphertext: &[u8], aad: &[u8], as_element: bool, @@ -124,22 +126,23 @@ where K: DataKeySource + Sync, { let tree = decode_tree(ciphertext)?; - // One `decrypt` per arm, not one `decipher` and two drives: the element - // derivation is `Element<T>`'s to apply, and naming the type is what - // asks for it. Only one arm runs, so the retrieve happens once either - // way. - let value: FfiValue = if as_element { - let wrapped: Element<FfiValue> = cipher - .decrypt(tree, aad) + // One `decrypt` per arm, not one `decipher` and two drives. The element + // derivation is `Element<T>`'s to apply and naming the type is what asks + // for it; the opener decides whether a foreign leaf is refused before any + // key is retrieved. Only one arm runs, so the retrieve happens once. + let value: FfiValue = match (&opener, as_element) { + (Opener::Any(cipher), true) => cipher + .decrypt::<Element<FfiValue>, _>(tree, aad) .await - .map_err(|e| status_for_error(&e))?; - wrapped.into_inner() - } else { - cipher - .decrypt(tree, aad) + .map(Element::into_inner), + (Opener::Any(cipher), false) => cipher.decrypt(tree, aad).await, + (Opener::Only(keyset), true) => keyset + .decrypt::<Element<FfiValue>, _>(tree, aad) .await - .map_err(|e| status_for_error(&e))? - }; + .map(Element::into_inner), + (Opener::Only(keyset), false) => keyset.decrypt(tree, aad).await, + } + .map_err(|e| status_for_error(&e))?; encode_value(value) } @@ -682,7 +685,7 @@ where /// [`FfiValue::Array`] of them for a batch. One `retrieve_keys` call per /// invocation. pub async fn decrypt_record<K>( - cipher: &StackCipher<K>, + opener: Opener<'_, K>, record: &[u8], plan: &[u8], ) -> Result<Vec<u8>, u32> @@ -735,16 +738,22 @@ where .find_map(|(key, node)| (key == "c").then_some(node)) .ok_or(STATUS_ENCODING)?; reject_passthrough_tree(&ct)?; - pendings.push(ct.decrypt_into(cipher, context)); + pendings.push(match &opener { + Opener::Any(cipher) => ct.decrypt_into(*cipher, context), + Opener::Only(keyset) => ct.decrypt_into(keyset, context), + }); row_names.push(name); } names.push(row_names); } - // The one ZeroKMS call for the whole invocation. - let values = Pending::all(cipher, pendings) - .await - .map_err(|e| status_for_error(&e))?; + // The one ZeroKMS call for the whole invocation (one per keyset the + // leaves were sealed under, when opening any). + let values = match &opener { + Opener::Any(cipher) => Pending::all(*cipher, pendings).await, + Opener::Only(keyset) => Pending::all(keyset, pendings).await, + } + .map_err(|e| status_for_error(&e))?; let mut values = values.into_iter(); let mut row_values = Vec::with_capacity(names.len()); diff --git a/languages/golang/stackencrypt/guest/src/options.rs b/languages/golang/stackencrypt/guest/src/options.rs new file mode 100644 index 000000000..b2ef6fe31 --- /dev/null +++ b/languages/golang/stackencrypt/guest/src/options.rs @@ -0,0 +1,311 @@ +//! The per-call options object, and the keyset selector it carries. +//! +//! Every export that touches a keyset takes one more codec-encoded +//! argument: an [`FfiValue::Object`] with exactly one key, `keyset`, whose +//! value is a tagged object naming the keyset the call binds to: +//! +//! | selector | meaning | +//! |-------------------|---------| +//! | `{"default": {}}` | the cipher's default keyset (the one named at `se_cipher_init`, else the client's) | +//! | `{"name": <string>}` | the keyset with that name, loaded on first use | +//! | `{"id": <16 bytes>}` | the keyset with that id (raw UUID bytes), loaded on first use | +//! | `{"any": {}}` | **decrypt only**: open leaves from whichever keyset each was sealed under, one ZeroKMS call per keyset | +//! +//! Every variant is spelled; there is no zero-length or omitted-field +//! sentinel, so a host that means the default says so. On the sealing and +//! term exports the selector picks the keyset that mints; on the opening +//! exports it is a *constraint*: `{"name"}`, `{"id"}` and `{"default"}` +//! refuse a leaf sealed under any other keyset before any key is retrieved +//! ([`STATUS_FOREIGN_KEYSET`](crate::status::STATUS_FOREIGN_KEYSET)), and `{"any"}` lifts the constraint. `{"any"}` +//! on a sealing or term export is [`STATUS_ENCODING`]: there is no keyset +//! to mint under. Anything else — another key, a second key, a wrong value +//! type, an id that is not 16 bytes — is [`STATUS_ENCODING`]. +//! +//! This object is a cross-language contract: every binding builds it, so +//! it is objects, strings, bytes and nothing else, and this module is its +//! one home. The Go bindings plan points here. + +use stack_encrypt::{KeysetCipher, StackCipher}; +use stack_kms::{IdentifiedBy, IndexKeySource}; +use uuid::Uuid; +use vitaminc_aead_value::FfiValue; +use vitaminc_protected::Controlled; + +use crate::status::{status_for_error, STATUS_ENCODING}; + +/// Which keyset a call binds to. See the [module docs](self). +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum KeysetSelector { + /// The cipher's default keyset. + Default, + /// A keyset by name. + Name(String), + /// A keyset by id. + Id(Uuid), + /// Whichever keyset each leaf was sealed under; opening only. + Any, +} + +/// Which side of the boundary an options object is parsed for: the sealing +/// and term exports need a keyset to mint under, so `{"any"}` is refused +/// there. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Side { + /// `se_encrypt`, `se_encrypt_element`, `se_encrypt_record`, `se_term`. + Mint, + /// `se_decrypt`, `se_decrypt_element`, `se_decrypt_record`. + Open, +} + +/// The parsed options object. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Options { + pub keyset: KeysetSelector, +} + +/// Parse a decoded options object for `side`. Anything outside the shape in +/// the [module docs](self) is [`STATUS_ENCODING`]. +pub fn parse_options(value: FfiValue, side: Side) -> Result<Options, u32> { + let FfiValue::Object(entries) = value else { + return Err(STATUS_ENCODING); + }; + let mut keyset: Option<KeysetSelector> = None; + for (key, value) in entries { + match key.as_str() { + "keyset" if keyset.is_none() => keyset = Some(parse_selector(value)?), + _ => return Err(STATUS_ENCODING), + } + } + let keyset = keyset.ok_or(STATUS_ENCODING)?; + if side == Side::Mint && keyset == KeysetSelector::Any { + return Err(STATUS_ENCODING); + } + Ok(Options { keyset }) +} + +/// Parse a keyset selector: a tagged object with exactly one key. Spelled +/// out here rather than in [`parse_options`] so `se_keyset`, which takes a +/// bare selector, shares the one definition. +pub fn parse_selector(value: FfiValue) -> Result<KeysetSelector, u32> { + let FfiValue::Object(mut entries) = value else { + return Err(STATUS_ENCODING); + }; + if entries.len() != 1 { + return Err(STATUS_ENCODING); + } + let (tag, value) = entries.pop().ok_or(STATUS_ENCODING)?; + Ok(match (tag.as_str(), value) { + ("default", FfiValue::Object(fields)) if fields.is_empty() => KeysetSelector::Default, + ("any", FfiValue::Object(fields)) if fields.is_empty() => KeysetSelector::Any, + ("name", FfiValue::String(name)) => { + // Valid UTF-8 by `Utf8String`'s construction invariant; checked + // rather than assumed because this is boundary code. A keyset + // name is not secret, so the payload moves out of its + // `Protected` rather than being copied and wiped. + let name = + String::from_utf8(name.into_inner().risky_unwrap()).map_err(|_| STATUS_ENCODING)?; + if name.is_empty() { + return Err(STATUS_ENCODING); + } + KeysetSelector::Name(name) + } + ("id", FfiValue::Bytes(bytes)) => { + KeysetSelector::Id(Uuid::from_slice(bytes.risky_ref()).map_err(|_| STATUS_ENCODING)?) + } + _ => return Err(STATUS_ENCODING), + }) +} + +impl KeysetSelector { + /// The keyset this selector names, as the cipher resolves it: the + /// default without a round trip, a name or id through the cipher's + /// cache (a first use is one `load-keyset` call). `Any` is not a keyset + /// and is [`STATUS_ENCODING`] here; opening exports resolve it through + /// [`Opener::for_selector`] instead. + pub async fn resolve<'c, K>( + &self, + cipher: &'c StackCipher<K>, + ) -> Result<KeysetCipher<'c, K>, u32> + where + K: IndexKeySource, + { + let by: IdentifiedBy = match self { + KeysetSelector::Default => return Ok(cipher.default_keyset()), + KeysetSelector::Any => return Err(STATUS_ENCODING), + KeysetSelector::Name(name) => { + IdentifiedBy::Name(name.as_str().try_into().map_err(|_| STATUS_ENCODING)?) + } + KeysetSelector::Id(id) => IdentifiedBy::Uuid(*id), + }; + cipher.keyset(by).await.map_err(|e| status_for_error(&e)) + } +} + +/// What an opening export decrypts through: the client, which opens a leaf +/// from any keyset (`{"any"}`), or one keyset's cipher, which opens only its +/// own leaves and refuses the rest before any key is retrieved. +pub enum Opener<'c, K> { + /// Leaves from any keyset, one ZeroKMS call per keyset. + Any(&'c StackCipher<K>), + /// Leaves from this keyset only. + Only(KeysetCipher<'c, K>), +} + +impl<'c, K> Opener<'c, K> { + /// The opener a decrypt-side selector names. + pub async fn for_selector( + cipher: &'c StackCipher<K>, + selector: &KeysetSelector, + ) -> Result<Self, u32> + where + K: IndexKeySource, + { + match selector { + KeysetSelector::Any => Ok(Opener::Any(cipher)), + other => other.resolve(cipher).await.map(Opener::Only), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use vitaminc_protected::Protected; + + fn obj(entries: Vec<(&str, FfiValue)>) -> FfiValue { + FfiValue::Object( + entries + .into_iter() + .map(|(k, v)| (k.to_string(), v)) + .collect(), + ) + } + + fn options(selector: FfiValue) -> FfiValue { + obj(vec![("keyset", selector)]) + } + + fn empty() -> FfiValue { + FfiValue::Object(Vec::new()) + } + + #[test] + fn every_selector_variant_is_spelled() { + let id = Uuid::from_u128(7); + assert_eq!( + parse_selector(obj(vec![("default", empty())])), + Ok(KeysetSelector::Default) + ); + assert_eq!( + parse_selector(obj(vec![("any", empty())])), + Ok(KeysetSelector::Any) + ); + assert_eq!( + parse_selector(obj(vec![("name", FfiValue::String("acme".into()))])), + Ok(KeysetSelector::Name("acme".to_string())) + ); + assert_eq!( + parse_selector(obj(vec![( + "id", + FfiValue::Bytes(Protected::new(id.as_bytes().to_vec())) + )])), + Ok(KeysetSelector::Id(id)) + ); + } + + #[test] + fn a_selector_is_exactly_one_known_tag_with_the_right_payload() { + for (label, bad) in [ + ("an empty object", empty()), + ("a string", FfiValue::String("default".into())), + ("null", FfiValue::Null), + ("an unknown tag", obj(vec![("primary", empty())])), + ( + "two tags", + obj(vec![("default", empty()), ("any", empty())]), + ), + ( + "default with a payload", + obj(vec![("default", FfiValue::Bool(true))]), + ), + ( + "default with fields", + obj(vec![("default", obj(vec![("x", empty())]))]), + ), + ( + "a name that is not a string", + obj(vec![("name", FfiValue::UInt64(1))]), + ), + ( + "an empty name", + obj(vec![("name", FfiValue::String("".into()))]), + ), + ( + "an id that is not 16 bytes", + obj(vec![("id", FfiValue::Bytes(Protected::new(vec![1, 2, 3])))]), + ), + ( + "an id as text", + obj(vec![( + "id", + FfiValue::String("00000000-0000-0000-0000-000000000007".into()), + )]), + ), + ] { + assert_eq!( + parse_selector(bad).err(), + Some(STATUS_ENCODING), + "{label} is not a selector and must be refused" + ); + } + } + + #[test] + fn options_are_one_keyset_key() { + assert_eq!( + parse_options(options(obj(vec![("default", empty())])), Side::Mint), + Ok(Options { + keyset: KeysetSelector::Default + }) + ); + for (label, bad) in [ + ("no keyset", empty()), + ("not an object", FfiValue::Null), + ( + "an unknown key beside it", + obj(vec![ + ("keyset", obj(vec![("default", empty())])), + ("mode", FfiValue::Bool(true)), + ]), + ), + ( + "keyset twice", + obj(vec![ + ("keyset", obj(vec![("default", empty())])), + ("keyset", obj(vec![("default", empty())])), + ]), + ), + ] { + assert_eq!( + parse_options(bad, Side::Open).err(), + Some(STATUS_ENCODING), + "{label} must be refused" + ); + } + } + + #[test] + fn any_is_an_opening_selector_only() { + let any = || options(obj(vec![("any", empty())])); + assert_eq!( + parse_options(any(), Side::Open), + Ok(Options { + keyset: KeysetSelector::Any + }) + ); + assert_eq!( + parse_options(any(), Side::Mint).err(), + Some(STATUS_ENCODING) + ); + } +} diff --git a/languages/golang/stackencrypt/guest/src/sessions.rs b/languages/golang/stackencrypt/guest/src/sessions.rs deleted file mode 100644 index 8e6be031c..000000000 --- a/languages/golang/stackencrypt/guest/src/sessions.rs +++ /dev/null @@ -1,91 +0,0 @@ -//! The cipher-session table behind the ABI's handle scheme, generic over the -//! cipher it stores so the handle-allocation invariants can be unit-tested -//! natively (the wasm32 ABI instantiates it with -//! `StackCipher<StackKms<HostTokenStrategy, WasiHostConnection>>`). -//! -//! Mirrors the vitaminc guest's session table (`vcencrypt/guest/src/ -//! sessions.rs`); extracting the shared implementation into a common -//! vitaminc crate is a planned follow-up in that repository — see the plan's -//! Phase 3 notes. - -use std::collections::hash_map::Entry; -use std::collections::HashMap; - -use crate::status::STATUS_INTERNAL; - -pub(crate) struct Sessions<C> { - next: u32, - ciphers: HashMap<u32, C>, -} - -impl<C> Sessions<C> { - pub(crate) fn new() -> Self { - // Handle ids start at 1 so a zero high-field can never be a valid - // handle (see the abi result encoding). - Sessions { - next: 1, - ciphers: HashMap::new(), - } - } - - /// Insert a cipher under a fresh handle id. - /// - /// Refuses at id exhaustion rather than wrapping or saturating: a reused - /// id would alias a live handle and silently displace its session — - /// sealing new data under the wrong keyset and orphaning everything the - /// displaced cipher had sealed. `next` only advances on success, and the - /// occupancy check makes the no-aliasing invariant explicit rather than - /// assumed. (The final id, `u32::MAX`, is sacrificed to keep the - /// arithmetic simple; ~4.3e9 handles precede it.) - pub(crate) fn insert(&mut self, cipher: C) -> Result<u32, u32> { - let handle = self.next; - let bumped = handle.checked_add(1).ok_or(STATUS_INTERNAL)?; - match self.ciphers.entry(handle) { - Entry::Occupied(_) => Err(STATUS_INTERNAL), - Entry::Vacant(slot) => { - let _ = slot.insert(cipher); - self.next = bumped; - Ok(handle) - } - } - } - - pub(crate) fn get(&self, handle: u32) -> Option<&C> { - self.ciphers.get(&handle) - } - - /// The removed cipher is dropped here, which is where its keys are - /// wiped (`ZeroizeOnDrop`); an unknown handle is a no-op. - pub(crate) fn remove(&mut self, handle: u32) { - drop(self.ciphers.remove(&handle)); - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn handles_start_at_one_and_increment() { - let mut s = Sessions::new(); - assert_eq!(s.insert("a"), Ok(1)); - assert_eq!(s.insert("b"), Ok(2)); - assert!(s.get(1).is_some()); - s.remove(1); - assert!(s.get(1).is_none()); - assert!(s.get(2).is_some()); - } - - #[test] - fn handle_ids_are_never_reused_at_exhaustion() { - let mut s = Sessions::new(); - s.next = u32::MAX - 1; - let last = s.insert("last").expect("last issuable id"); - assert_eq!(last, u32::MAX - 1); - // At exhaustion the table must refuse rather than alias a live - // handle (a saturating or wrapping counter would silently displace - // the session and seal under the wrong keyset). - assert_eq!(s.insert("next"), Err(STATUS_INTERNAL)); - assert!(s.get(last).is_some(), "live session must not be displaced"); - } -} diff --git a/languages/golang/stackencrypt/guest/src/status.rs b/languages/golang/stackencrypt/guest/src/status.rs index 12b315efb..197bf2d8f 100644 --- a/languages/golang/stackencrypt/guest/src/status.rs +++ b/languages/golang/stackencrypt/guest/src/status.rs @@ -6,12 +6,15 @@ //! they are part of the guest/host contract and must not be renumbered. //! //! Codes 1–4 are byte-for-byte the vitaminc guest's codes (`vcencrypt`'s -//! `status.rs`), so the two guests read identically from the host side. -//! Codes 5–10 map the ZeroKMS request outcomes +//! `status.rs`), so the two guests read identically from the host side; +//! code 3 there is "unknown handle", and here — where there is no handle — +//! it is the call-order violation that means the same thing to a host: no +//! cipher for this call. Codes 5–10 map the ZeroKMS request outcomes //! ([`ViturRequestErrorKind`]-shaped) so a Go caller can distinguish a bad //! token from a tampered ciphertext without parsing strings. Code 11 is a //! term-derivation failure (a caller-input condition, e.g. match text that -//! yields no tokens). +//! yields no tokens). Code 12 is a keyset-scoped open refusing a leaf from +//! another keyset — a host's own constraint, distinct from tampering. use stack_auth::AuthError; use stack_kms::{GenerateKeyError, LoadKeysetError, RetrieveKeyError}; @@ -28,9 +31,12 @@ pub const STATUS_AUTH: u32 = 1; /// cipher config, an empty encryption context, or a pointer/length pair that /// fails validation against linear memory. pub const STATUS_ENCODING: u32 = 2; -/// The cipher handle is unknown (never issued, or already freed). -pub const STATUS_BAD_HANDLE: u32 = 3; -/// A caught panic, handle-id exhaustion, a response that did not match its +/// The call is out of order: an operation before `se_cipher_init`, or +/// after `se_shutdown`, or `se_cipher_init` twice. A host fixes its call +/// sequence; nothing here is a guest bug. (The vitaminc guest's code 3 is +/// "unknown handle", the same condition under a handle scheme.) +pub const STATUS_STATE: u32 = 3; +/// A caught panic, a response that did not match its /// requests, or any other unexpected internal failure. pub const STATUS_INTERNAL: u32 = 4; /// ZeroKMS (or the auth strategy) rejected the *credential*: an expired or @@ -67,18 +73,25 @@ pub const STATUS_KMS_OTHER: u32 = 10; /// An index term failed to derive: e.g. match text that yields no tokens, or /// a value/scheme combination the term does not support. pub const STATUS_TERM: u32 = 11; +/// An opening export was constrained to one keyset (`{"name"}`, `{"id"}` or +/// `{"default"}` in its options) and a leaf was sealed under another. +/// Refused before any key is retrieved. A host that means "whichever +/// keyset" opens with `{"any"}`; a host that meant this keyset has the +/// wrong tenant's row, not a tampered one — see [`STATUS_AUTH`]. +pub const STATUS_FOREIGN_KEYSET: u32 = 12; /// Map a sealing/opening error onto the ABI status word. /// /// Total over [`stack_encrypt::Error`] (which is `#[non_exhaustive]`, so the /// catch-all arm is required as well as convenient): composition-bug variants -/// (`ResponseShape`, `KeysetMismatch`, `KeyCountMismatch`) and everything -/// else unexpected collapse into [`STATUS_INTERNAL`] — statuses distinguish -/// what a host can act on, not what it can only log. +/// (`ResponseShape`, `KeysetMismatch`, `NoKeyset`, `KeyCountMismatch`) and +/// everything else unexpected collapse into [`STATUS_INTERNAL`] — statuses +/// distinguish what a host can act on, not what it can only log. pub fn status_for_error(error: &stack_encrypt::Error) -> u32 { match error { stack_encrypt::Error::Aead => STATUS_AUTH, stack_encrypt::Error::Term(_) => STATUS_TERM, + stack_encrypt::Error::ForeignKeyset { .. } => STATUS_FOREIGN_KEYSET, stack_encrypt::Error::Kms(kms) => status_for_kms(kms), // A context that renders past ZeroKMS's descriptor limit is the // caller's input, refused before any request is sent. @@ -275,6 +288,26 @@ mod tests { ); } + #[test] + fn a_foreign_keyset_is_its_own_status_and_scope_bugs_are_internal() { + let (a, b) = (uuid::Uuid::from_u128(1), uuid::Uuid::from_u128(2)); + assert_eq!( + status_for_error(&stack_encrypt::Error::ForeignKeyset { + expected: a, + found: b + }), + STATUS_FOREIGN_KEYSET + ); + assert_eq!( + status_for_error(&stack_encrypt::Error::KeysetMismatch { left: a, right: b }), + STATUS_INTERNAL + ); + assert_eq!( + status_for_error(&stack_encrypt::Error::NoKeyset), + STATUS_INTERNAL + ); + } + #[test] fn term_errors_are_derivation_failures() { // An empty context never reaches a term — it is `STATUS_ENCODING` diff --git a/languages/golang/stackencrypt/guest/tests/native_ops.rs b/languages/golang/stackencrypt/guest/tests/native_ops.rs index 2a17a7e1b..7aceadf8a 100644 --- a/languages/golang/stackencrypt/guest/tests/native_ops.rs +++ b/languages/golang/stackencrypt/guest/tests/native_ops.rs @@ -20,7 +20,8 @@ use std::future::IntoFuture; use stack_encrypt::sem::DefaultMatch; use stack_encrypt::{nonempty, CipherText, Encrypt, SealedValue, StackCipher}; use stack_encrypt_guest::ops::{self, TERM_EQUALITY, TERM_MATCH, TERM_OPE, TERM_ORE}; -use stack_encrypt_guest::status::{STATUS_AUTH, STATUS_ENCODING}; +use stack_encrypt_guest::options::Opener; +use stack_encrypt_guest::status::{STATUS_AUTH, STATUS_ENCODING, STATUS_FOREIGN_KEYSET}; use stack_kms::{ DataKey, DataKeySource, DataKeyWithTag, FakeDataKeySource, GenerateKeyPayload, IndexKeySource, RetrieveKeyPayload, @@ -214,7 +215,13 @@ fn value_round_trips_through_the_guest_ops() { false, )) .expect("encrypt"); - let pt = block_on(ops::decrypt_value(&cipher, &ct, b"users/42", false)).expect("decrypt"); + let pt = block_on(ops::decrypt_value( + Opener::Any(&cipher), + &ct, + b"users/42", + false, + )) + .expect("decrypt"); let FfiValue::Object(entries) = decode(&pt) else { panic!("expected an object back"); @@ -240,13 +247,24 @@ fn element_mode_round_trips() { true, )) .expect("encrypt element"); - let pt = block_on(ops::decrypt_value(&cipher, &ct, b"users", true)).expect("decrypt element"); + let pt = block_on(ops::decrypt_value( + Opener::Any(&cipher), + &ct, + b"users", + true, + )) + .expect("decrypt element"); assert_eq!(text(&decode(&pt)), "row-0"); // An element is not a plain value: opening it without the element // derivation must fail authentication. assert_eq!( - block_on(ops::decrypt_value(&cipher, &ct, b"users", false)), + block_on(ops::decrypt_value( + Opener::Any(&cipher), + &ct, + b"users", + false + )), Err(STATUS_AUTH) ); } @@ -291,7 +309,12 @@ fn wrong_aad_and_malformed_inputs_map_to_statuses() { // descriptors; against ZeroKMS the retrieve is refused first, as // `STATUS_KMS_FORBIDDEN` — see `status.rs`.) assert_eq!( - block_on(ops::decrypt_value(&cipher, &ct, b"other", false)), + block_on(ops::decrypt_value( + Opener::Any(&cipher), + &ct, + b"other", + false + )), Err(STATUS_AUTH) ); // Garbage transport bytes on either path: encoding. @@ -305,7 +328,12 @@ fn wrong_aad_and_malformed_inputs_map_to_statuses() { Err(STATUS_ENCODING) ); assert_eq!( - block_on(ops::decrypt_value(&cipher, b"\xffgarbage", b"ctx", false)), + block_on(ops::decrypt_value( + Opener::Any(&cipher), + b"\xffgarbage", + b"ctx", + false + )), Err(STATUS_ENCODING) ); // A truncated leaf inside a well-formed tree: encoding (structural), @@ -320,7 +348,12 @@ fn wrong_aad_and_malformed_inputs_map_to_statuses() { ) .expect("encode truncated"); assert_eq!( - block_on(ops::decrypt_value(&cipher, &out, b"ctx", false)), + block_on(ops::decrypt_value( + Opener::Any(&cipher), + &out, + b"ctx", + false + )), Err(STATUS_ENCODING) ); } @@ -344,14 +377,24 @@ fn an_empty_aad_round_trips_on_the_value_paths() { as_element, )) .expect("encrypt under an empty aad"); - let out = block_on(ops::decrypt_value(&cipher, &ct, b"", as_element)) - .expect("decrypt under an empty aad"); + let out = block_on(ops::decrypt_value( + Opener::Any(&cipher), + &ct, + b"", + as_element, + )) + .expect("decrypt under an empty aad"); assert_eq!(out, value, "element: {as_element}"); // Empty is a context like any other: not interchangeable with one // that carries bytes. assert_eq!( - block_on(ops::decrypt_value(&cipher, &ct, b"ctx", as_element)), + block_on(ops::decrypt_value( + Opener::Any(&cipher), + &ct, + b"ctx", + as_element + )), Err(STATUS_AUTH), "element: {as_element}" ); @@ -366,11 +409,12 @@ fn an_empty_aad_round_trips_on_the_value_paths() { false, )) .expect("encrypt"); - let opened = - decode(&block_on(ops::decrypt_value(&cipher, &ct, zeros, false)).expect("decrypt")); + let opened = decode( + &block_on(ops::decrypt_value(Opener::Any(&cipher), &ct, zeros, false)).expect("decrypt"), + ); assert_eq!(text(&opened), "x"); assert_eq!( - block_on(ops::decrypt_value(&cipher, &ct, b"ctx", false)), + block_on(ops::decrypt_value(Opener::Any(&cipher), &ct, b"ctx", false)), Err(STATUS_AUTH) ); } @@ -572,7 +616,8 @@ fn a_record_batch_encrypts_in_one_call_and_round_trips() { // Three rows, two ciphertext fields each: still exactly one call. assert_eq!(cipher.kms().generate_calls.load(Ordering::SeqCst), 1); - let pt = block_on(ops::decrypt_record(&cipher, &record, &plan())).expect("decrypt records"); + let pt = block_on(ops::decrypt_record(Opener::Any(&cipher), &record, &plan())) + .expect("decrypt records"); assert_eq!(cipher.kms().retrieve_calls.load(Ordering::SeqCst), 1); let FfiValue::Array(rows) = decode(&pt) else { @@ -624,7 +669,7 @@ fn a_forged_passthrough_ciphertext_slot_is_rejected_not_decrypted() { codec::encode_ciphertext(&CipherText::Map(fields), &mut forged).expect("re-encode"); assert_eq!( - block_on(ops::decrypt_record(&cipher, &forged, &plan())), + block_on(ops::decrypt_record(Opener::Any(&cipher), &forged, &plan())), Err(STATUS_ENCODING), "a passthrough in a ciphertext slot must be a hard error, never plaintext" ); @@ -841,7 +886,7 @@ fn a_natively_sealed_field_under_an_extended_context_opens_through_a_plan() { let plan_with = |context: FfiValue| single_field_plan("age", context); let opened = block_on(ops::decrypt_record( - &cipher, + Opener::Any(&cipher), &record, &plan_with(extended("age")), )) @@ -853,7 +898,7 @@ fn a_natively_sealed_field_under_an_extended_context_opens_through_a_plan() { assert_eq!( block_on(ops::decrypt_record( - &cipher, + Opener::Any(&cipher), &record, &plan_with(s("users/age")) )), @@ -905,7 +950,7 @@ fn a_structured_plan_context_is_validated_at_parse() { )) .expect("an integer part is never empty"); assert!(block_on(ops::decrypt_record( - &cipher, + Opener::Any(&cipher), &sealed, &plan_with(FfiValue::Array(vec![s(""), FfiValue::UInt64(7)])) )) @@ -1028,7 +1073,11 @@ fn an_empty_plan_context_is_refused_before_anything_is_sealed() { "a context that could never be decrypted under must not seal" ); assert_eq!( - block_on(ops::decrypt_record(&cipher, &source, &bad_plan)), + block_on(ops::decrypt_record( + Opener::Any(&cipher), + &source, + &bad_plan + )), Err(STATUS_ENCODING) ); @@ -1047,9 +1096,150 @@ fn an_empty_plan_context_is_refused_before_anything_is_sealed() { &odd_plan, )) .expect("encrypt"); - let opened = block_on(ops::decrypt_record(&cipher, &sealed, &odd_plan)).expect("decrypt"); + let opened = block_on(ops::decrypt_record( + Opener::Any(&cipher), + &sealed, + &odd_plan, + )) + .expect("decrypt"); let FfiValue::Object(fields) = decode(&opened) else { panic!("a record decrypts to an object"); }; assert!(matches!(fields.as_slice(), [(name, FfiValue::UInt32(1))] if name == "f")); } + +// ============================================================================= +// Keysets: the opener a call selects +// ============================================================================= + +fn keyset_named<'c>( + cipher: &'c StackCipher<Counting>, + name: &str, +) -> stack_encrypt::KeysetCipher<'c, Counting> { + block_on(cipher.keyset(IdentifiedBy::Name(name.to_string().into()))).expect("select keyset") +} + +/// A value sealed under a tenant's keyset opens through that keyset, through +/// `{"any"}`, and not through another tenant's — and the refusal costs no +/// ZeroKMS call. +#[test] +fn a_value_opens_under_its_own_keyset_or_any_but_not_another() { + let cipher = cipher(); + let acme = keyset_named(&cipher, "acme"); + let globex = keyset_named(&cipher, "globex"); + let ct = block_on(ops::encrypt_value(&acme, &encode(s("x")), b"ctx", false)).expect("encrypt"); + + let pt = block_on(ops::decrypt_value( + Opener::Only(acme.clone()), + &ct, + b"ctx", + false, + )) + .expect("own keyset opens"); + assert_eq!(text(&decode(&pt)), "x"); + let pt = + block_on(ops::decrypt_value(Opener::Any(&cipher), &ct, b"ctx", false)).expect("any opens"); + assert_eq!(text(&decode(&pt)), "x"); + let retrieves = cipher.kms().retrieve_calls.load(Ordering::SeqCst); + + assert_eq!( + block_on(ops::decrypt_value(Opener::Only(globex), &ct, b"ctx", false)), + Err(STATUS_FOREIGN_KEYSET), + "another tenant's keyset must refuse the leaf" + ); + assert_eq!( + cipher.kms().retrieve_calls.load(Ordering::SeqCst), + retrieves, + "the refusal happens before any key is retrieved" + ); +} + +/// A record batch whose rows were sealed under different keysets opens +/// through `{"any"}` in one call per keyset, and not through one keyset. +#[test] +fn a_mixed_keyset_record_batch_opens_through_any_one_call_per_keyset() { + let cipher = cipher(); + let acme = keyset_named(&cipher, "acme"); + let globex = keyset_named(&cipher, "globex"); + + // One row per tenant, sealed separately; a host stores them side by + // side and reads them back as one batch. + let acme_rows = block_on(ops::encrypt_record( + &acme, + &encode(row(29, "alice smith")), + &plan(), + )) + .expect("encrypt acme row"); + let globex_rows = block_on(ops::encrypt_record( + &globex, + &encode(row(34, "bob jones")), + &plan(), + )) + .expect("encrypt globex row"); + let batch = { + let (CipherText::Map(a), CipherText::Map(g)) = ( + codec::decode_ciphertext_boxed::<Vec<u8>>(&mut codec::Reader::new(&acme_rows)) + .expect("decode"), + codec::decode_ciphertext_boxed::<Vec<u8>>(&mut codec::Reader::new(&globex_rows)) + .expect("decode"), + ) else { + panic!("a single record is a map"); + }; + let mut out = Vec::new(); + codec::encode_ciphertext_boxed( + CipherText::Sequence(vec![CipherText::Map(a), CipherText::Map(g)]), + &mut out, + ) + .expect("encode batch"); + out + }; + + let before = cipher.kms().retrieve_calls.load(Ordering::SeqCst); + let pt = block_on(ops::decrypt_record(Opener::Any(&cipher), &batch, &plan())) + .expect("any opens the mixed batch"); + assert_eq!( + cipher.kms().retrieve_calls.load(Ordering::SeqCst) - before, + 2, + "one retrieve per keyset" + ); + let FfiValue::Array(rows) = decode(&pt) else { + panic!("expected an array of rows back"); + }; + assert_eq!(rows.len(), 2); + + let before = cipher.kms().retrieve_calls.load(Ordering::SeqCst); + assert_eq!( + block_on(ops::decrypt_record(Opener::Only(acme), &batch, &plan())), + Err(STATUS_FOREIGN_KEYSET) + ); + assert_eq!(cipher.kms().retrieve_calls.load(Ordering::SeqCst), before); +} + +/// Terms derive under the selected keyset's index key: the same probe +/// under two keysets differs, and matches the native derivation for each. +#[test] +fn terms_derive_under_the_selected_keyset() { + let cipher = cipher(); + let acme = keyset_named(&cipher, "acme"); + let globex = keyset_named(&cipher, "globex"); + let ctx = encode(s("users/age")); + + let acme_term = block_on(ops::term( + &acme, + &encode(FfiValue::UInt32(42)), + &ctx, + TERM_EQUALITY, + )) + .expect("acme term"); + let globex_term = block_on(ops::term( + &globex, + &encode(FfiValue::UInt32(42)), + &ctx, + TERM_EQUALITY, + )) + .expect("globex term"); + assert_ne!(acme_term, globex_term); + + let native = block_on(acme.equality_term(42u32, nonempty!("users/age"))).expect("native"); + assert_eq!(acme_term, native.into_bytes().to_vec()); +} From 6edfb131ed5f4b38e182ab730bc413b839be1a6e Mon Sep 17 00:00:00 2001 From: Dan Draper <dan@cipherstash.com> Date: Sat, 12 Sep 2026 00:16:02 -0400 Subject: [PATCH 535/686] fix(wasi): validate every input before the cipher is consulted; init wipes only a validated buffer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review (Copilot and Codex, cipherstash/cipherstash-suite#2212). `se_cipher_init` stored a failed pointer/length validation and went on to wipe the range anyway, which is a linear-memory trap for an out-of-bounds pair rather than the documented `STATUS_ENCODING`. The pair is now validated first and on its own, and only a validated buffer is decoded and wiped. The export-level regression test for this belongs to the phase-5 harness (CIP-4024), which drives the compiled module; the native suite cannot reach `wipe_input`. The mint and open helpers validated only the options object before consulting state and resolving a keyset, so a well-formed selector with a malformed payload read as `STATUS_STATE` before init and could cost a keyset load after it. Every export now runs the structural checks in the new `ops::validate` module — value, ciphertext tree, plan, context, term kind — before `with_cipher`, so a malformed call is `STATUS_ENCODING` whether or not the instance is initialised and never reaches ZeroKMS. `se_keyset` refuses `{"any"}` at parse, and a keyset name is validated against ZeroKMS's rules when the selector is parsed (the selector now carries a `Name`), not at resolution. `buffers::wipe_all` gains the registry test the other paths have. The `options` docs and the Go plan say "one batched retrieval per keyset, chunked at the client's 500-key limit" rather than "one call". The stale name-alias concern is addressed on the branch below this one (9f257ccae). Claude-Session: https://claude.ai/code/session_01ECzyss8kvtnrRtq5tmpEbg --- docs/plans/stack-encrypt-go-bindings.md | 2 +- .../golang/stackencrypt/guest/src/abi.rs | 37 ++++++++++++---- .../golang/stackencrypt/guest/src/buffers.rs | 21 +++++++++ .../golang/stackencrypt/guest/src/ops.rs | 43 +++++++++++++++++++ .../golang/stackencrypt/guest/src/options.rs | 34 +++++++++------ 5 files changed, 114 insertions(+), 23 deletions(-) diff --git a/docs/plans/stack-encrypt-go-bindings.md b/docs/plans/stack-encrypt-go-bindings.md index dc339f874..869044274 100644 --- a/docs/plans/stack-encrypt-go-bindings.md +++ b/docs/plans/stack-encrypt-go-bindings.md @@ -385,7 +385,7 @@ via the registry, packed `u64` results, status in the low word on error): | `se_cipher_init(cfg_ptr, cfg_len) → keyset id` | once per instance (CIP-4037): config (client id, client key, default keyset id/name, optional `keyset_cache_size`) encoded as an `FfiValue` object — no second codec. Builds `StackKms<HostTokenStrategy, WasiHostConnection>`, then `StackCipherBuilder::kms(..).keyset(..).init()` under `block_on` (one `load_keyset` call for the default keyset). Returns the default keyset's UUID as its buffer; a second call is `STATUS_STATE`. Client-key bytes zeroized after `ClientKey` is built. | | `se_shutdown()` | drops the `StackCipher` (client key and every loaded index key wiped by `ZeroizeOnDrop`) and wipes every buffer the registry still holds. Needed because closing a wasm instance frees linear memory without running Rust destructors. Any export after it is `STATUS_STATE`. | | `se_keyset(selector)` | resolves a keyset selector (`{"default": {}}` / `{"name": s}` / `{"id": 16 bytes}`) through the cipher's cache — the first use of a keyset is one `load_keyset` call — and returns its UUID, so a host can validate a tenant at boot and learn its id | -| `se_encrypt(value, aad, opts)` / `se_decrypt(ct, aad, opts)` | `opts` is the options object `{"keyset": <selector>}` (the guest's `options` module is its one home). Encrypt: decode `FfiValue` → `encrypt_with_aad(&keyset, aad)` → `seal(&keyset, aad)` (`block_on`) → `encode_ciphertext::<SealedValue, _>`. Decrypt mirrors via `decipher(ct)` + `FfiValue::decrypt_with_aad`; its selector is a constraint — `{"any": {}}` opens leaves from whichever keyset each was sealed under (one `retrieve_keys` per keyset), a named keyset refuses any other's leaf as `STATUS_FOREIGN_KEYSET` before any key is retrieved. | +| `se_encrypt(value, aad, opts)` / `se_decrypt(ct, aad, opts)` | `opts` is the options object `{"keyset": <selector>}` (the guest's `options` module is its one home). Encrypt: decode `FfiValue` → `encrypt_with_aad(&keyset, aad)` → `seal(&keyset, aad)` (`block_on`) → `encode_ciphertext::<SealedValue, _>`. Decrypt mirrors via `decipher(ct)` + `FfiValue::decrypt_with_aad`; its selector is a constraint — `{"any": {}}` opens leaves from whichever keyset each was sealed under (one batched `retrieve_keys` per keyset, chunked at the client's 500-key request limit), a named keyset refuses any other's leaf as `STATUS_FOREIGN_KEYSET` before any key is retrieved. | | `se_encrypt_element` / `se_decrypt_element` | as vitaminc; row-at-a-time interop with batch-encrypted slices | | `se_encrypt_record(source, plan, opts)` | the runtime form of `#[derive(EncryptFrom)]`: `plan` is an `FfiValue` object `{ field → { context, outputs: [c \| eq \| match(opts) \| ore \| ope] } }`; per field the guest dispatches on the source `FfiValue` variant to the typed `EncryptFrom` impls (`u32`/`u64`/`i64`/`f64`/`String`), zips the pendings, `Pending::all` across an array source, and returns `{ field → { c: leaf, hm: bytes, ob: bytes, … } }`. One `generate_keys` call per invocation regardless of row count. | | `se_decrypt_record(record, plan, opts)` | inverse; only the `c` outputs participate; the selector constrains as for `se_decrypt` | diff --git a/languages/golang/stackencrypt/guest/src/abi.rs b/languages/golang/stackencrypt/guest/src/abi.rs index 77b931293..4e44d379d 100644 --- a/languages/golang/stackencrypt/guest/src/abi.rs +++ b/languages/golang/stackencrypt/guest/src/abi.rs @@ -68,12 +68,14 @@ //! and opening sides bind that one value. The asymmetry is the design; see //! `packages/stack-encrypt/docs/adr/0001-context-optional-cipher-directed-path.md`. //! -//! Every export decodes and validates its inputs — including the options -//! object — before it consults the cipher, so malformed input reads as -//! `STATUS_ENCODING` whether or not `se_cipher_init` has run; only a -//! well-formed call with no cipher is `STATUS_STATE`. The one exception is -//! keyset *resolution* (a name or id the cipher has not loaded), which is a -//! round trip and so happens inside the call, after the state check. +//! Every export decodes and structurally validates *all* of its inputs — +//! the operation payload, the plan or context, the term kind, and the +//! options object — before it consults the cipher ([`ops::validate`]), so +//! malformed input reads as `STATUS_ENCODING` whether or not +//! `se_cipher_init` has run, and never costs a keyset load; only a +//! well-formed call with no cipher is `STATUS_STATE`. Keyset *resolution* +//! (a name or id the cipher has not loaded) is a round trip and so happens +//! inside the call, after every check. //! //! Wasm modules are single-threaded; the host must serialize calls into one //! instance. @@ -92,7 +94,7 @@ use crate::buffers; use crate::config::parse_config; use crate::host::{HostTokenStrategy, WasiHostConnection}; use crate::ops; -use crate::options::{parse_options, parse_selector, Opener, Side}; +use crate::options::{parse_options, parse_selector, KeysetSelector, Opener, Side}; use crate::status::{STATUS_ENCODING, STATUS_INTERNAL, STATUS_KMS_TRANSPORT, STATUS_STATE}; /// The instance's cipher: `stack-encrypt` over the host-transport ZeroKMS @@ -273,7 +275,12 @@ fn with_opener<R>( #[no_mangle] pub unsafe extern "C" fn se_cipher_init(cfg_ptr: *mut u8, cfg_len: u32) -> u64 { catch_unwind(AssertUnwindSafe(|| { - let decoded = input(cfg_ptr, cfg_len).and_then(decode); + // The pointer/length pair is validated first and on its own: a pair + // that fails here returns before anything touches the range, which + // is `wipe_input`'s precondition. Only a validated buffer is decoded + // and, whatever the decode outcome, wiped. + let bytes = input(cfg_ptr, cfg_len)?; + let decoded = decode(bytes); // The borrow of the raw buffer ends with `decoded` owned; wipe the // buffer now — it holds the client-key hex — before parsing (and // before the init round trip), whatever the decode outcome. @@ -345,7 +352,7 @@ pub extern "C" fn se_shutdown() { /// Resolve a keyset selector (a codec-encoded tagged object — see /// [`crate::options`]; `{"any"}` is not a keyset and is `STATUS_ENCODING` -/// here) through the cipher's cache and return the keyset's id (16 raw UUID +/// here, before the cipher is consulted) through the cipher's cache and return the keyset's id (16 raw UUID /// bytes). A first use of a keyset is one `load-keyset` round trip; a host /// can call this at boot to validate a tenant's keyset and learn its id. /// @@ -356,6 +363,9 @@ pub extern "C" fn se_shutdown() { pub unsafe extern "C" fn se_keyset(sel_ptr: *const u8, sel_len: u32) -> u64 { catch_unwind(AssertUnwindSafe(|| { let selector = parse_selector(decode(input(sel_ptr, sel_len)?)?)?; + if selector == KeysetSelector::Any { + return Err(STATUS_ENCODING); + } with_cipher(|cipher| { let keyset = block_on(selector.resolve(cipher))?; Ok(keyset.keyset_id().as_bytes().to_vec()) @@ -476,6 +486,7 @@ fn run_encrypt( let value = value.as_slice(); let aad = input(aad_ptr, aad_len)?; let opts = input(opt_ptr, opt_len)?; + ops::validate::value(value)?; with_keyset(opts, |keyset| { block_on(ops::encrypt_value(keyset, value, aad, as_element)) }) @@ -498,6 +509,7 @@ fn run_decrypt( let ciphertext = input(ct_ptr, ct_len)?; let aad = input(aad_ptr, aad_len)?; let opts = input(opt_ptr, opt_len)?; + ops::validate::tree(ciphertext)?; with_opener(opts, |opener| { block_on(ops::decrypt_value(opener, ciphertext, aad, as_element)) }) @@ -541,6 +553,9 @@ pub unsafe extern "C" fn se_term( let value = value.as_slice(); let context = input(ctx_ptr, ctx_len)?; let opts = input(opt_ptr, opt_len)?; + ops::validate::value(value)?; + ops::validate::context(context)?; + ops::validate::term_kind(kind)?; with_keyset(opts, |keyset| { block_on(ops::term(keyset, value, context, kind)) }) @@ -573,6 +588,8 @@ pub unsafe extern "C" fn se_encrypt_record( let source = source.as_slice(); let plan = input(plan_ptr, plan_len)?; let opts = input(opt_ptr, opt_len)?; + ops::validate::value(source)?; + ops::validate::plan(plan)?; with_keyset(opts, |keyset| { block_on(ops::encrypt_record(keyset, source, plan)) }) @@ -604,6 +621,8 @@ pub unsafe extern "C" fn se_decrypt_record( let record = input(rec_ptr, rec_len)?; let plan = input(plan_ptr, plan_len)?; let opts = input(opt_ptr, opt_len)?; + ops::validate::tree(record)?; + ops::validate::plan(plan)?; with_opener(opts, |opener| { block_on(ops::decrypt_record(opener, record, plan)) }) diff --git a/languages/golang/stackencrypt/guest/src/buffers.rs b/languages/golang/stackencrypt/guest/src/buffers.rs index 906d21803..48d89b2d2 100644 --- a/languages/golang/stackencrypt/guest/src/buffers.rs +++ b/languages/golang/stackencrypt/guest/src/buffers.rs @@ -188,6 +188,27 @@ mod tests { assert_eq!(unsafe { take(sized, 3) }, Some(vec![0, 0, 0])); } + /// Shutdown's invariant: after `wipe_all`, nothing the registry handed + /// out is live — sized or empty — so a host that forgot to release an + /// output cannot reclaim it, and the bytes were zeroized on the way + /// out. + #[test] + fn wipe_all_leaves_no_live_buffer() { + let sized = register(vec![7, 7, 7]); + let host_written = alloc(2); + let empty = alloc(0); + + wipe_all(); + + assert_eq!(unsafe { take(sized, 3) }, None); + assert_eq!(unsafe { take(host_written, 2) }, None); + assert_eq!(unsafe { take(empty, 0) }, None); + // The registry is usable afterwards: a fresh allocation is tracked + // as before. + let again = alloc(1); + assert_eq!(unsafe { take(again, 1) }, Some(vec![0])); + } + #[test] fn a_null_pointer_with_zero_length_is_the_canonical_empty() { assert_eq!(unsafe { take(core::ptr::null_mut(), 0) }, Some(Vec::new())); diff --git a/languages/golang/stackencrypt/guest/src/ops.rs b/languages/golang/stackencrypt/guest/src/ops.rs index cb40bfd46..cd307b9fb 100644 --- a/languages/golang/stackencrypt/guest/src/ops.rs +++ b/languages/golang/stackencrypt/guest/src/ops.rs @@ -776,6 +776,49 @@ where encode_value(value) } +// ============================================================================= +// Boundary validation +// ============================================================================= + +/// The structural checks the ABI runs on every operation input *before* it +/// consults the cipher, so a malformed call is [`STATUS_ENCODING`] whether +/// or not the instance is initialised, and never costs a keyset load. Each +/// is the same decode or parse the operation itself performs; the second +/// pass is cheap next to the AEAD and buys a stable status precedence. +pub mod validate { + use super::*; + + /// A codec-encoded value tree decodes. + pub fn value(bytes: &[u8]) -> Result<(), u32> { + decode_value(bytes).map(drop) + } + + /// A codec-encoded ciphertext tree decodes and its leaves are + /// well-formed `SealedValue` encodings. + pub fn tree(bytes: &[u8]) -> Result<(), u32> { + decode_tree(bytes).map(drop) + } + + /// A codec-encoded record plan decodes and parses (every field's + /// context non-empty, every output known). + pub fn plan(bytes: &[u8]) -> Result<(), u32> { + parse_plan(decode_value(bytes)?).map(drop) + } + + /// A codec-encoded term context decodes and is a non-empty context. + pub fn context(bytes: &[u8]) -> Result<(), u32> { + parse_context(decode_value(bytes)?).map(drop) + } + + /// A term kind is one of [`TERM_EQUALITY`] .. [`TERM_OPE`]. + pub fn term_kind(kind: u32) -> Result<(), u32> { + match kind { + TERM_EQUALITY | TERM_MATCH | TERM_ORE | TERM_OPE => Ok(()), + _ => Err(STATUS_ENCODING), + } + } +} + // ============================================================================= // Codec glue // ============================================================================= diff --git a/languages/golang/stackencrypt/guest/src/options.rs b/languages/golang/stackencrypt/guest/src/options.rs index b2ef6fe31..ad8c743cf 100644 --- a/languages/golang/stackencrypt/guest/src/options.rs +++ b/languages/golang/stackencrypt/guest/src/options.rs @@ -9,10 +9,12 @@ //! | `{"default": {}}` | the cipher's default keyset (the one named at `se_cipher_init`, else the client's) | //! | `{"name": <string>}` | the keyset with that name, loaded on first use | //! | `{"id": <16 bytes>}` | the keyset with that id (raw UUID bytes), loaded on first use | -//! | `{"any": {}}` | **decrypt only**: open leaves from whichever keyset each was sealed under, one ZeroKMS call per keyset | +//! | `{"any": {}}` | **decrypt only**: open leaves from whichever keyset each was sealed under, one batched retrieval per keyset (chunked at the client's request limit, 500 keys) | //! //! Every variant is spelled; there is no zero-length or omitted-field -//! sentinel, so a host that means the default says so. On the sealing and +//! sentinel, so a host that means the default says so. A name is validated +//! at parse (ZeroKMS's own rules for keyset names), so a malformed +//! selector is refused before the cipher is consulted. On the sealing and //! term exports the selector picks the keyset that mints; on the opening //! exports it is a *constraint*: `{"name"}`, `{"id"}` and `{"default"}` //! refuse a leaf sealed under any other keyset before any key is retrieved @@ -30,6 +32,7 @@ use stack_kms::{IdentifiedBy, IndexKeySource}; use uuid::Uuid; use vitaminc_aead_value::FfiValue; use vitaminc_protected::Controlled; +use zerokms_protocol::Name; use crate::status::{status_for_error, STATUS_ENCODING}; @@ -38,8 +41,8 @@ use crate::status::{status_for_error, STATUS_ENCODING}; pub enum KeysetSelector { /// The cipher's default keyset. Default, - /// A keyset by name. - Name(String), + /// A keyset by name, already validated against ZeroKMS's naming rules. + Name(Name), /// A keyset by id. Id(Uuid), /// Whichever keyset each leaf was sealed under; opening only. @@ -101,13 +104,11 @@ pub fn parse_selector(value: FfiValue) -> Result<KeysetSelector, u32> { // Valid UTF-8 by `Utf8String`'s construction invariant; checked // rather than assumed because this is boundary code. A keyset // name is not secret, so the payload moves out of its - // `Protected` rather than being copied and wiped. + // `Protected` rather than being copied and wiped. ZeroKMS's + // naming rules apply here, at the boundary, not at resolution. let name = String::from_utf8(name.into_inner().risky_unwrap()).map_err(|_| STATUS_ENCODING)?; - if name.is_empty() { - return Err(STATUS_ENCODING); - } - KeysetSelector::Name(name) + KeysetSelector::Name(Name::try_from(name.as_str()).map_err(|_| STATUS_ENCODING)?) } ("id", FfiValue::Bytes(bytes)) => { KeysetSelector::Id(Uuid::from_slice(bytes.risky_ref()).map_err(|_| STATUS_ENCODING)?) @@ -132,9 +133,7 @@ impl KeysetSelector { let by: IdentifiedBy = match self { KeysetSelector::Default => return Ok(cipher.default_keyset()), KeysetSelector::Any => return Err(STATUS_ENCODING), - KeysetSelector::Name(name) => { - IdentifiedBy::Name(name.as_str().try_into().map_err(|_| STATUS_ENCODING)?) - } + KeysetSelector::Name(name) => IdentifiedBy::Name(name.clone()), KeysetSelector::Id(id) => IdentifiedBy::Uuid(*id), }; cipher.keyset(by).await.map_err(|e| status_for_error(&e)) @@ -202,7 +201,9 @@ mod tests { ); assert_eq!( parse_selector(obj(vec![("name", FfiValue::String("acme".into()))])), - Ok(KeysetSelector::Name("acme".to_string())) + Ok(KeysetSelector::Name( + Name::try_from("acme").ok().expect("valid name") + )) ); assert_eq!( parse_selector(obj(vec![( @@ -240,6 +241,13 @@ mod tests { "an empty name", obj(vec![("name", FfiValue::String("".into()))]), ), + ( + "a name past ZeroKMS's 64-byte limit", + obj(vec![( + "name", + FfiValue::String("x".repeat(65).as_str().into()), + )]), + ), ( "an id that is not 16 bytes", obj(vec![("id", FfiValue::Bytes(Protected::new(vec![1, 2, 3])))]), From 61d9f446b4b011bdd8d848a3d0a51398d594e100 Mon Sep 17 00:00:00 2001 From: Dan Draper <dan@cipherstash.com> Date: Sat, 12 Sep 2026 10:11:11 -0400 Subject: [PATCH 536/686] fix(stack-encrypt-guest): validate term and record inputs against the plan before the cipher The ABI's stated precedence is that malformed input is STATUS_ENCODING whether or not the cipher is initialised, and never costs a keyset load. The term and record exports only checked that their inputs decoded: a Float64 under TERM_EQUALITY, a container under a term output, or a source that did not fit its plan passed validation, resolved a cold keyset through ZeroKMS, and only then failed inside the op (or read STATUS_STATE before init). Factor the static half of each op into one parser shared with validate: parse_term (scalar + kind + the scheme's supported-pair table), source_rows (shape, field set, every value against its field's outputs, passthrough under "c") and record_leaves (shape, "c" node per ciphertext field, no passthrough). validate::{term, record, record_tree} run those parsers, so validation and the op cannot disagree; build_row and decrypt_record take the aligned rows and no longer re-check. Qualify the remaining "one ZeroKMS call" wording (ops docs, Opener::Any, the plan's export table) with the 500-key chunking and, for {"any"}, the per-keyset grouping. Native tests pin that each validator refuses exactly what its op refuses, with no key traffic. Claude-Session: https://claude.ai/code/session_01ECzyss8kvtnrRtq5tmpEbg --- docs/plans/stack-encrypt-go-bindings.md | 2 +- .../golang/stackencrypt/guest/src/abi.rs | 17 +- .../golang/stackencrypt/guest/src/ops.rs | 286 ++++++++++++------ .../golang/stackencrypt/guest/src/options.rs | 3 +- .../stackencrypt/guest/tests/native_ops.rs | 239 +++++++++++++++ 5 files changed, 448 insertions(+), 99 deletions(-) diff --git a/docs/plans/stack-encrypt-go-bindings.md b/docs/plans/stack-encrypt-go-bindings.md index 869044274..1583667ca 100644 --- a/docs/plans/stack-encrypt-go-bindings.md +++ b/docs/plans/stack-encrypt-go-bindings.md @@ -387,7 +387,7 @@ via the registry, packed `u64` results, status in the low word on error): | `se_keyset(selector)` | resolves a keyset selector (`{"default": {}}` / `{"name": s}` / `{"id": 16 bytes}`) through the cipher's cache — the first use of a keyset is one `load_keyset` call — and returns its UUID, so a host can validate a tenant at boot and learn its id | | `se_encrypt(value, aad, opts)` / `se_decrypt(ct, aad, opts)` | `opts` is the options object `{"keyset": <selector>}` (the guest's `options` module is its one home). Encrypt: decode `FfiValue` → `encrypt_with_aad(&keyset, aad)` → `seal(&keyset, aad)` (`block_on`) → `encode_ciphertext::<SealedValue, _>`. Decrypt mirrors via `decipher(ct)` + `FfiValue::decrypt_with_aad`; its selector is a constraint — `{"any": {}}` opens leaves from whichever keyset each was sealed under (one batched `retrieve_keys` per keyset, chunked at the client's 500-key request limit), a named keyset refuses any other's leaf as `STATUS_FOREIGN_KEYSET` before any key is retrieved. | | `se_encrypt_element` / `se_decrypt_element` | as vitaminc; row-at-a-time interop with batch-encrypted slices | -| `se_encrypt_record(source, plan, opts)` | the runtime form of `#[derive(EncryptFrom)]`: `plan` is an `FfiValue` object `{ field → { context, outputs: [c \| eq \| match(opts) \| ore \| ope] } }`; per field the guest dispatches on the source `FfiValue` variant to the typed `EncryptFrom` impls (`u32`/`u64`/`i64`/`f64`/`String`), zips the pendings, `Pending::all` across an array source, and returns `{ field → { c: leaf, hm: bytes, ob: bytes, … } }`. One `generate_keys` call per invocation regardless of row count. | +| `se_encrypt_record(source, plan, opts)` | the runtime form of `#[derive(EncryptFrom)]`: `plan` is an `FfiValue` object `{ field → { context, outputs: [c \| eq \| match(opts) \| ore \| ope] } }`; per field the guest dispatches on the source `FfiValue` variant to the typed `EncryptFrom` impls (`u32`/`u64`/`i64`/`f64`/`String`), zips the pendings, `Pending::all` across an array source, and returns `{ field → { c: leaf, hm: bytes, ob: bytes, … } }`. One batched `generate_keys` per invocation regardless of row count, dispatched as one ZeroKMS request per 500 keyed leaves (the request limit above). | | `se_decrypt_record(record, plan, opts)` | inverse; only the `c` outputs participate; the selector constrains as for `se_decrypt` | | `se_term(value, context, kind, opts)` | query probe under the selected keyset's index key; `context` is codec-encoded in the plan-field grammar — one part (a string, bytes, or an `i32`/`i64`/`u32`/`u64`) or an array of parts, nested as deep as the transport codec allows (`vitaminc_aead_value::transport::MAX_DEPTH`, 128 levels from the root of the encoded value; deeper is `STATUS_ENCODING` before the context is parsed, not an interop bug). Shape is identity: `[x]` is not `x`, so a probe passes the context in exactly the shape the field was sealed under (the guest's `context` module is the one home of the grammar and of which Rust context each shape spells). Under the local HMAC backend a probe does no ZeroKMS I/O; that is the backend's property, not the API's (ZeroKMS v2 derives terms at the server) | diff --git a/languages/golang/stackencrypt/guest/src/abi.rs b/languages/golang/stackencrypt/guest/src/abi.rs index 4e44d379d..5a4d98be9 100644 --- a/languages/golang/stackencrypt/guest/src/abi.rs +++ b/languages/golang/stackencrypt/guest/src/abi.rs @@ -68,9 +68,10 @@ //! and opening sides bind that one value. The asymmetry is the design; see //! `packages/stack-encrypt/docs/adr/0001-context-optional-cipher-directed-path.md`. //! -//! Every export decodes and structurally validates *all* of its inputs — -//! the operation payload, the plan or context, the term kind, and the -//! options object — before it consults the cipher ([`ops::validate`]), so +//! Every export decodes and validates *all* of its inputs — the operation +//! payload, the plan or context, the term kind, the value against the +//! plan or kind, and the options object — before it consults the cipher +//! ([`ops::validate`] runs the operation's own parsers), so //! malformed input reads as `STATUS_ENCODING` whether or not //! `se_cipher_init` has run, and never costs a keyset load; only a //! well-formed call with no cipher is `STATUS_STATE`. Keyset *resolution* @@ -553,9 +554,7 @@ pub unsafe extern "C" fn se_term( let value = value.as_slice(); let context = input(ctx_ptr, ctx_len)?; let opts = input(opt_ptr, opt_len)?; - ops::validate::value(value)?; - ops::validate::context(context)?; - ops::validate::term_kind(kind)?; + ops::validate::term(value, context, kind)?; with_keyset(opts, |keyset| { block_on(ops::term(keyset, value, context, kind)) }) @@ -588,8 +587,7 @@ pub unsafe extern "C" fn se_encrypt_record( let source = source.as_slice(); let plan = input(plan_ptr, plan_len)?; let opts = input(opt_ptr, opt_len)?; - ops::validate::value(source)?; - ops::validate::plan(plan)?; + ops::validate::record(source, plan)?; with_keyset(opts, |keyset| { block_on(ops::encrypt_record(keyset, source, plan)) }) @@ -621,8 +619,7 @@ pub unsafe extern "C" fn se_decrypt_record( let record = input(rec_ptr, rec_len)?; let plan = input(plan_ptr, plan_len)?; let opts = input(opt_ptr, opt_len)?; - ops::validate::tree(record)?; - ops::validate::plan(plan)?; + ops::validate::record_tree(record, plan)?; with_opener(opts, |opener| { block_on(ops::decrypt_record(opener, record, plan)) }) diff --git a/languages/golang/stackencrypt/guest/src/ops.rs b/languages/golang/stackencrypt/guest/src/ops.rs index cd307b9fb..f6019393b 100644 --- a/languages/golang/stackencrypt/guest/src/ops.rs +++ b/languages/golang/stackencrypt/guest/src/ops.rs @@ -176,10 +176,18 @@ pub async fn term<K>( where K: DataKeySource + Sync, { - let value = decode_value(value)?; // The same proof every stack-encrypt leaf demands: an empty context is // `STATUS_ENCODING` here, before any derivation. let context = parse_context(decode_value(context)?)?; + let (scalar, output) = parse_term(decode_value(value)?, kind)?; + term_bytes(cipher, scalar, context, output).await +} + +/// The static half of a term: the value is a scalar, the kind is one of +/// the table, and the scheme defines the pair ([`term_supported`]). Shared +/// by [`term`] and [`validate::term`] so the ABI refuses exactly what the +/// operation would, before any keyset is resolved. +fn parse_term(value: FfiValue, kind: u32) -> Result<(Scalar, Output), u32> { let output = match kind { TERM_EQUALITY => Output::Equality, TERM_MATCH => Output::Match, @@ -187,7 +195,25 @@ where TERM_OPE => Output::Ope, _ => return Err(STATUS_ENCODING), }; - term_bytes(cipher, scalar_of(&value)?, context, output).await + let scalar = scalar_of(&value)?; + if !term_supported(&scalar, output) { + return Err(STATUS_ENCODING); + } + Ok((scalar, output)) +} + +/// Which scalar/output pairs the scheme defines: no PRF encoding exists +/// for floats (equality on IEEE-754 values is a modelling error) or +/// booleans, match is text-only, ORE and OPE take every scalar. The one +/// table, consulted before any cipher work; [`term_bytes`]'s arms mirror +/// it and are unreachable for a refused pair. +fn term_supported(scalar: &Scalar, output: Output) -> bool { + match output { + Output::Ciphertext => false, + Output::Equality => !matches!(scalar, Scalar::Bool(_) | Scalar::F32(_) | Scalar::F64(_)), + Output::Match => matches!(scalar, Scalar::Text(_)), + Output::Ore | Output::Ope => true, + } } /// A term-able scalar lifted (by copy) out of an [`FfiValue`] leaf, so the @@ -229,7 +255,8 @@ fn scalar_of(value: &FfiValue) -> Result<Scalar, u32> { /// part of the cross-language contract: an equality term for `UInt32(34)` /// must equal the term the Rust side derives for `34u32`. Unsupported /// combinations (floats or booleans under equality, anything non-text under -/// match) are [`STATUS_ENCODING`] — the scheme does not define them. +/// match) are [`STATUS_ENCODING`] — the scheme does not define them; +/// [`term_supported`] is the table, checked before this is reached. async fn term_bytes<'c, K, D>( cipher: &KeysetCipher<'_, K>, scalar: Scalar, @@ -555,21 +582,7 @@ where K: DataKeySource + Sync, { let plan = parse_plan(decode_value(plan)?)?; - - let (rows, batched) = match decode_value(source)? { - FfiValue::Object(entries) => (vec![entries], false), - FfiValue::Array(items) => { - let rows = items - .into_iter() - .map(|item| match item { - FfiValue::Object(entries) => Ok(entries), - _ => Err(STATUS_ENCODING), - }) - .collect::<Result<Vec<_>, u32>>()?; - (rows, true) - } - _ => return Err(STATUS_ENCODING), - }; + let (rows, batched) = source_rows(decode_value(source)?, &plan)?; // Build every row: terms derive now (local), ciphertexts queue their // data-key requests into one flat pending list. @@ -579,7 +592,8 @@ where skeletons.push(build_row(cipher, row, &plan, &mut pendings).await?); } - // The one ZeroKMS call for the whole invocation. + // The one batched key request for the whole invocation (one ZeroKMS + // call per 500 keyed leaves, per the module docs). let sealed = Pending::all(cipher, pendings) .await .map_err(|e| status_for_error(&e))?; @@ -617,28 +631,139 @@ where encode_tree(tree) } +/// The rows of a record source, each aligned to the plan's field order, +/// with everything that can be checked without a cipher checked: the +/// source is one object or an array of objects, every plan field is present +/// in every row and no row carries a field the plan does not name (silently +/// dropping a field on either side would lose data or index nothing), and +/// each value fits its field's outputs ([`check_field`]). The `bool` is +/// whether the source was a batch. Shared by [`encrypt_record`] and +/// [`validate::record`]. +fn source_rows(source: FfiValue, plan: &[FieldPlan]) -> Result<(Vec<Vec<FfiValue>>, bool), u32> { + let (rows, batched) = match source { + FfiValue::Object(entries) => (vec![entries], false), + FfiValue::Array(items) => { + let rows = items + .into_iter() + .map(|item| match item { + FfiValue::Object(entries) => Ok(entries), + _ => Err(STATUS_ENCODING), + }) + .collect::<Result<Vec<_>, u32>>()?; + (rows, true) + } + _ => return Err(STATUS_ENCODING), + }; + let rows = rows + .into_iter() + .map(|mut row| { + if row.len() != plan.len() { + return Err(STATUS_ENCODING); + } + plan.iter() + .map(|field| { + let at = row + .iter() + .position(|(name, _)| name == &field.name) + .ok_or(STATUS_ENCODING)?; + let (_, value) = row.swap_remove(at); + check_field(&value, field)?; + Ok(value) + }) + .collect::<Result<Vec<_>, u32>>() + }) + .collect::<Result<Vec<_>, u32>>()?; + Ok((rows, batched)) +} + +/// A source value against its plan field: every term output needs a +/// scalar the scheme defines the term for ([`term_supported`]), and a +/// ciphertext output refuses a passthrough anywhere in the value +/// ([`reject_passthrough_value`]). +fn check_field(value: &FfiValue, field: &FieldPlan) -> Result<(), u32> { + for output in &field.outputs { + if *output == Output::Ciphertext { + reject_passthrough_value(value)?; + } else if !term_supported(&scalar_of(value)?, *output) { + return Err(STATUS_ENCODING); + } + } + Ok(()) +} + +/// The `"c"` subtrees a record tree holds for the plan's ciphertext-bearing +/// fields, per row in plan order, with the row's field name: the tree is +/// one map or a sequence of maps, each such field is present, is a map of +/// outputs with a `"c"` node, and that node is not a passthrough +/// ([`reject_passthrough_tree`]). Terms and fields the plan does not name +/// are ignored (comparands, not ciphertext). The `bool` is whether the tree +/// was a batch. Shared by [`decrypt_record`] and [`validate::record_tree`]. +#[allow(clippy::type_complexity)] +fn record_leaves( + tree: StackCipherText, + plan: &[FieldPlan], +) -> Result<(Vec<Vec<(String, StackCipherText)>>, bool), u32> { + let (rows, batched) = match tree { + CipherText::Map(entries) => (vec![entries], false), + CipherText::Sequence(items) => { + let rows = items + .into_iter() + .map(|item| match item { + CipherText::Map(entries) => Ok(entries), + _ => Err(STATUS_ENCODING), + }) + .collect::<Result<Vec<_>, u32>>()?; + (rows, true) + } + _ => return Err(STATUS_ENCODING), + }; + let rows = rows + .into_iter() + .map(|mut row| { + plan.iter() + .filter(|field| field.outputs.contains(&Output::Ciphertext)) + .map(|field| { + let at = row + .iter() + .position(|(name, _)| name == &field.name) + .ok_or(STATUS_ENCODING)?; + let (name, node) = row.swap_remove(at); + let CipherText::Map(outputs) = node else { + return Err(STATUS_ENCODING); + }; + let ct = outputs + .into_iter() + .find_map(|(key, node)| (key == "c").then_some(node)) + .ok_or(STATUS_ENCODING)?; + reject_passthrough_tree(&ct)?; + Ok((name, ct)) + }) + .collect::<Result<Vec<_>, u32>>() + }) + .collect::<Result<Vec<_>, u32>>()?; + Ok((rows, batched)) +} + /// Build one record row: derive its terms and queue its ciphertext pendings, /// returning the row skeleton. The plan drives the iteration so the output -/// field order is the plan's; the row must contain exactly the plan's fields. +/// field order is the plan's; the row arrives from [`source_rows`] already +/// in that order and checked against the plan. async fn build_row<'c, K>( cipher: &'c KeysetCipher<'_, K>, - mut row: Vec<(String, FfiValue)>, + row: Vec<FfiValue>, plan: &[FieldPlan], pendings: &mut Vec<Pending<'c, StackCipherText, K>>, ) -> Result<RowSkeleton, u32> where K: DataKeySource + Sync, { + // A row `source_rows` did not align is a guest bug, not host input. if row.len() != plan.len() { - return Err(STATUS_ENCODING); + return Err(STATUS_INTERNAL); } let mut skeleton = Vec::with_capacity(plan.len()); - for field in plan { - let at = row - .iter() - .position(|(name, _)| name == &field.name) - .ok_or(STATUS_ENCODING)?; - let (name, value) = row.swap_remove(at); + for (field, value) in plan.iter().zip(row) { + let name = field.name.clone(); // A borrowed view of the plan's context, once per field: the proof // was made at parse time, so re-taking it over the same tree cannot // fail, and the view clones cheaply for each output below. @@ -682,8 +807,9 @@ where /// same plan. Only the `"c"` outputs participate (terms are one-way); the /// result is a codec-encoded [`FfiValue::Object`] per record holding the /// plan's ciphertext-bearing fields, in plan order — or an -/// [`FfiValue::Array`] of them for a batch. One `retrieve_keys` call per -/// invocation. +/// [`FfiValue::Array`] of them for a batch. One batched `retrieve_keys` +/// per invocation, dispatched as one ZeroKMS call per 500 keyed leaves and, +/// when opening any keyset, per keyset the leaves were sealed under. pub async fn decrypt_record<K>( opener: Opener<'_, K>, record: &[u8], @@ -695,49 +821,24 @@ where use stack_encrypt::target::DecryptInto; let plan = parse_plan(decode_value(plan)?)?; + let (rows, batched) = record_leaves(decode_tree(record)?, &plan)?; + let contexts = plan + .iter() + .filter(|field| field.outputs.contains(&Output::Ciphertext)) + .map(|field| NonEmpty::new(borrowed(field.context.get())).map_err(|_| STATUS_INTERNAL)) + .collect::<Result<Vec<_>, u32>>()?; - let (rows, batched) = match decode_tree(record)? { - CipherText::Map(entries) => (vec![entries], false), - CipherText::Sequence(items) => { - let rows = items - .into_iter() - .map(|item| match item { - CipherText::Map(entries) => Ok(entries), - _ => Err(STATUS_ENCODING), - }) - .collect::<Result<Vec<_>, u32>>()?; - (rows, true) - } - _ => return Err(STATUS_ENCODING), - }; - - // Per row, per ciphertext-bearing plan field: lift out the "c" subtree - // and queue its decrypt. Terms and extra fields in the tree are ignored - // (they are comparands, not ciphertext). + // Per row, per ciphertext-bearing plan field (in plan order, as + // `record_leaves` lifted them): queue the "c" subtree's decrypt. let mut pendings: Vec<Pending<'_, FfiValue, K>> = Vec::new(); let mut names: Vec<Vec<String>> = Vec::with_capacity(rows.len()); for row in rows { - let mut row = row; - let mut row_names = Vec::new(); - for field in &plan { - if !field.outputs.contains(&Output::Ciphertext) { - continue; - } - let context = - NonEmpty::new(borrowed(field.context.get())).map_err(|_| STATUS_INTERNAL)?; - let at = row - .iter() - .position(|(name, _)| name == &field.name) - .ok_or(STATUS_ENCODING)?; - let (name, node) = row.swap_remove(at); - let CipherText::Map(outputs) = node else { - return Err(STATUS_ENCODING); - }; - let ct = outputs - .into_iter() - .find_map(|(key, node)| (key == "c").then_some(node)) - .ok_or(STATUS_ENCODING)?; - reject_passthrough_tree(&ct)?; + if row.len() != contexts.len() { + return Err(STATUS_INTERNAL); + } + let mut row_names = Vec::with_capacity(row.len()); + for ((name, ct), context) in row.into_iter().zip(&contexts) { + let context = context.clone(); pendings.push(match &opener { Opener::Any(cipher) => ct.decrypt_into(*cipher, context), Opener::Only(keyset) => ct.decrypt_into(keyset, context), @@ -747,8 +848,9 @@ where names.push(row_names); } - // The one ZeroKMS call for the whole invocation (one per keyset the - // leaves were sealed under, when opening any). + // The one batched key request for the whole invocation: one ZeroKMS + // call per 500 keyed leaves, and when opening any, per keyset the leaves + // were sealed under. let values = match &opener { Opener::Any(cipher) => Pending::all(*cipher, pendings).await, Opener::Only(keyset) => Pending::all(keyset, pendings).await, @@ -780,11 +882,13 @@ where // Boundary validation // ============================================================================= -/// The structural checks the ABI runs on every operation input *before* it +/// The static checks the ABI runs on every operation input *before* it /// consults the cipher, so a malformed call is [`STATUS_ENCODING`] whether /// or not the instance is initialised, and never costs a keyset load. Each -/// is the same decode or parse the operation itself performs; the second -/// pass is cheap next to the AEAD and buys a stable status precedence. +/// runs the same parser the operation itself runs — `parse_term`, +/// `source_rows`, `record_leaves` — so the two cannot disagree on what +/// is malformed; the second pass is cheap next to the AEAD and buys a +/// stable status precedence. pub mod validate { use super::*; @@ -799,23 +903,31 @@ pub mod validate { decode_tree(bytes).map(drop) } - /// A codec-encoded record plan decodes and parses (every field's - /// context non-empty, every output known). - pub fn plan(bytes: &[u8]) -> Result<(), u32> { - parse_plan(decode_value(bytes)?).map(drop) + /// A term's inputs, as [`term`] takes them: the context decodes and is + /// non-empty, the kind is one of [`TERM_EQUALITY`] .. [`TERM_OPE`], and + /// the value is a scalar the scheme defines that term for + /// (`term_supported`). + pub fn term(value: &[u8], context: &[u8], kind: u32) -> Result<(), u32> { + parse_context(decode_value(context)?).map(drop)?; + parse_term(decode_value(value)?, kind).map(drop) } - /// A codec-encoded term context decodes and is a non-empty context. - pub fn context(bytes: &[u8]) -> Result<(), u32> { - parse_context(decode_value(bytes)?).map(drop) + /// A record source against its plan, as [`encrypt_record`] takes them: + /// the plan decodes and parses (every field's context non-empty, every + /// output known), and the source fits it (`source_rows`: shape, field + /// set, each value against its field's outputs). + pub fn record(source: &[u8], plan: &[u8]) -> Result<(), u32> { + let plan = parse_plan(decode_value(plan)?)?; + source_rows(decode_value(source)?, &plan).map(drop) } - /// A term kind is one of [`TERM_EQUALITY`] .. [`TERM_OPE`]. - pub fn term_kind(kind: u32) -> Result<(), u32> { - match kind { - TERM_EQUALITY | TERM_MATCH | TERM_ORE | TERM_OPE => Ok(()), - _ => Err(STATUS_ENCODING), - } + /// A record tree against its plan, as [`decrypt_record`] takes them: + /// the plan parses, the tree decodes with well-formed leaves, and every + /// ciphertext-bearing field has a `"c"` node that is not a passthrough + /// (`record_leaves`). + pub fn record_tree(record: &[u8], plan: &[u8]) -> Result<(), u32> { + let plan = parse_plan(decode_value(plan)?)?; + record_leaves(decode_tree(record)?, &plan).map(drop) } } diff --git a/languages/golang/stackencrypt/guest/src/options.rs b/languages/golang/stackencrypt/guest/src/options.rs index ad8c743cf..a77ba4e32 100644 --- a/languages/golang/stackencrypt/guest/src/options.rs +++ b/languages/golang/stackencrypt/guest/src/options.rs @@ -144,7 +144,8 @@ impl KeysetSelector { /// from any keyset (`{"any"}`), or one keyset's cipher, which opens only its /// own leaves and refuses the rest before any key is retrieved. pub enum Opener<'c, K> { - /// Leaves from any keyset, one ZeroKMS call per keyset. + /// Leaves from any keyset: one batched retrieval per keyset the leaves + /// were sealed under, each chunked at the client's request limit. Any(&'c StackCipher<K>), /// Leaves from this keyset only. Only(KeysetCipher<'c, K>), diff --git a/languages/golang/stackencrypt/guest/tests/native_ops.rs b/languages/golang/stackencrypt/guest/tests/native_ops.rs index 7aceadf8a..36d7e370a 100644 --- a/languages/golang/stackencrypt/guest/tests/native_ops.rs +++ b/languages/golang/stackencrypt/guest/tests/native_ops.rs @@ -1243,3 +1243,242 @@ fn terms_derive_under_the_selected_keyset() { let native = block_on(acme.equality_term(42u32, nonempty!("users/age"))).expect("native"); assert_eq!(acme_term, native.into_bytes().to_vec()); } + +// ============================================================================= +// Validation precedence +// ============================================================================= + +/// The ABI runs `ops::validate` on every input before it consults the +/// cipher, so a malformed call must be refused there — not by the operation +/// after a keyset has been resolved. These pin that the validators reject +/// exactly the inputs the operations reject as `STATUS_ENCODING`, on a +/// static path that needs no cipher at all, and accept what the operations +/// accept. +#[test] +fn term_validation_refuses_what_the_term_op_refuses() { + let cipher = cipher(); + let ctx = encode(s("f")); + + for (label, value, kind) in [ + ( + "a float under equality", + FfiValue::Float64(1.5), + TERM_EQUALITY, + ), + ("a bool under equality", FfiValue::Bool(true), TERM_EQUALITY), + ("an integer under match", FfiValue::UInt32(1), TERM_MATCH), + ("a container", FfiValue::Array(vec![]), TERM_ORE), + ("an object", obj(vec![("k", s("v"))]), TERM_EQUALITY), + ("null", FfiValue::Null, TERM_OPE), + ("an unknown kind", FfiValue::UInt32(1), 99), + ] { + let value = encode(value); + assert_eq!( + ops::validate::term(&value, &ctx, kind), + Err(STATUS_ENCODING), + "{label} must be refused by validation" + ); + assert_eq!( + block_on(ops::term(&cipher.default_keyset(), &value, &ctx, kind)), + Err(STATUS_ENCODING), + "{label} must be refused by the op too" + ); + } + for (label, context) in [ + ("an empty context", encode(s(""))), + ( + "a context that is not a context", + encode(FfiValue::Bool(true)), + ), + ("raw bytes for a context", b"f".to_vec()), + ] { + assert_eq!( + ops::validate::term(&encode(FfiValue::UInt32(1)), &context, TERM_EQUALITY), + Err(STATUS_ENCODING), + "{label} must be refused by validation" + ); + } + + // Every pair the scheme defines passes. + for (value, kind) in [ + (FfiValue::UInt32(1), TERM_EQUALITY), + (FfiValue::Int64(-1), TERM_EQUALITY), + (s("x"), TERM_EQUALITY), + (FfiValue::Bytes(Protected::new(vec![1])), TERM_EQUALITY), + (s("x y"), TERM_MATCH), + (FfiValue::Float64(1.5), TERM_ORE), + (FfiValue::Bool(true), TERM_OPE), + (s("x"), TERM_ORE), + ] { + assert_eq!(ops::validate::term(&encode(value), &ctx, kind), Ok(())); + } +} + +#[test] +fn record_validation_refuses_what_encrypt_record_refuses() { + let cipher = cipher(); + let plan = plan(); + + for (label, source) in [ + ("a missing field", obj(vec![("age", FfiValue::UInt32(1))])), + ( + "an extra field", + obj(vec![ + ("age", FfiValue::UInt32(1)), + ("name", s("a")), + ("stray", s("b")), + ]), + ), + ( + "a container under a term output", + obj(vec![ + ("age", FfiValue::Array(vec![FfiValue::UInt32(1)])), + ("name", s("a")), + ]), + ), + ( + "a float under equality", + obj(vec![("age", FfiValue::Float64(1.0)), ("name", s("a"))]), + ), + ( + "an integer under match", + obj(vec![ + ("age", FfiValue::UInt32(1)), + ("name", FfiValue::UInt32(2)), + ]), + ), + ("a scalar, not a record", FfiValue::UInt32(1)), + ( + "a batch holding a non-record", + FfiValue::Array(vec![row(1, "a"), FfiValue::Null]), + ), + ] { + let source = encode(source); + assert_eq!( + ops::validate::record(&source, &plan), + Err(STATUS_ENCODING), + "{label} must be refused by validation" + ); + assert_eq!( + block_on(ops::encrypt_record( + &cipher.default_keyset(), + &source, + &plan + )), + Err(STATUS_ENCODING), + "{label} must be refused by the op too" + ); + } + + // A passthrough under a ciphertext output, with no term output to mask + // it (the invariant `a_passthrough_source_value_is_refused_a_ciphertext_slot` pins). + let ct_only = single_field_plan("age", s("users/age")); + let passthrough = encode(obj(vec![( + "age", + FfiValue::Passthrough(Box::new(FfiValue::UInt32(29))), + )])); + assert_eq!( + ops::validate::record(&passthrough, &ct_only), + Err(STATUS_ENCODING) + ); + + // A malformed plan is refused with a well-formed source. + assert_eq!( + ops::validate::record(&encode(row(1, "a")), &encode(obj(vec![]))), + Err(STATUS_ENCODING) + ); + + assert_eq!(ops::validate::record(&encode(row(1, "a")), &plan), Ok(())); + assert_eq!( + ops::validate::record( + &encode(FfiValue::Array(vec![row(1, "a"), row(2, "b")])), + &plan + ), + Ok(()) + ); + assert_eq!(cipher.kms().generate_calls.load(Ordering::SeqCst), 0); +} + +#[test] +fn record_tree_validation_refuses_what_decrypt_record_refuses() { + let cipher = cipher(); + let plan = plan(); + let record = block_on(ops::encrypt_record( + &cipher.default_keyset(), + &encode(row(29, "alice")), + &plan, + )) + .expect("encrypt record"); + assert_eq!(ops::validate::record_tree(&record, &plan), Ok(())); + + type Node = CipherText<Vec<u8>, FfiValue>; + // The tree is not `Clone`; every variant decodes the record afresh. + let fields = || { + let CipherText::Map(fields) = decode_tree(&record) else { + panic!("expected a field map"); + }; + fields + }; + let re_encode = |tree: Node| { + let mut out = Vec::new(); + codec::encode_ciphertext(&tree, &mut out).expect("re-encode"); + out + }; + let with_age = |edit: &dyn Fn(&mut Node)| { + let mut fields = fields(); + for (field, node) in &mut fields { + if field == "age" { + edit(node); + } + } + CipherText::Map(fields) + }; + + let forged_c = with_age(&|node| { + let CipherText::Map(outputs) = node else { + panic!("expected an output map"); + }; + for (key, slot) in outputs.iter_mut() { + if key == "c" { + *slot = CipherText::Passthrough(FfiValue::UInt32(99)); + } + } + }); + let no_c = with_age(&|node| { + let CipherText::Map(outputs) = node else { + panic!("expected an output map"); + }; + outputs.retain(|(key, _)| key != "c"); + }); + let not_a_map = with_age(&|node| *node = CipherText::Passthrough(FfiValue::Null)); + let missing_field = { + let mut fields = fields(); + fields.retain(|(field, _)| field != "age"); + CipherText::Map(fields) + }; + let batch_of_non_records = CipherText::Sequence(vec![ + CipherText::Map(fields()), + CipherText::Passthrough(FfiValue::Null), + ]); + + for (label, tree) in [ + ("a forged passthrough under c", forged_c), + ("a field without c", no_c), + ("a field that is not an output map", not_a_map), + ("a missing field", missing_field), + ("a batch holding a non-record", batch_of_non_records), + ] { + let tree = re_encode(tree); + assert_eq!( + ops::validate::record_tree(&tree, &plan), + Err(STATUS_ENCODING), + "{label} must be refused by validation" + ); + assert_eq!( + block_on(ops::decrypt_record(Opener::Any(&cipher), &tree, &plan)), + Err(STATUS_ENCODING), + "{label} must be refused by the op too" + ); + } + assert_eq!(cipher.kms().retrieve_calls.load(Ordering::SeqCst), 0); +} From 968a6631dcd8ab0083284a8cc082901780150df9 Mon Sep 17 00:00:00 2001 From: Dan Draper <dan@cipherstash.com> Date: Sat, 12 Sep 2026 14:34:24 -0400 Subject: [PATCH 537/686] fix(stack-encrypt-guest): wipe_all allocates nothing; config and plan docs match per-call keysets Review findings on cipherstash/cipherstash-suite#2212 (Copilot): - wipe_all collected the registry into a Vec before wiping, so a shutdown under linear-memory pressure could fail on that allocation before erasing anything. The registry map is moved out whole (an empty HashMap does not allocate) and walked in place. - The config table still said `keyset` / `keyset_id` "pin the cipher"; they choose the keyset the cipher starts on, every other keyset remains selectable per call. - The plan's architecture diagram encrypted through `&cipher` and called term derivation "no I/O"; it now shows the per-call KeysetCipher path, the 500-key chunking, and that the backend owns term derivation. Claude-Session: https://claude.ai/code/session_01ECzyss8kvtnrRtq5tmpEbg --- docs/plans/stack-encrypt-go-bindings.md | 9 +++++---- languages/golang/stackencrypt/guest/src/buffers.rs | 7 ++++++- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/docs/plans/stack-encrypt-go-bindings.md b/docs/plans/stack-encrypt-go-bindings.md index 1583667ca..73b0a84f4 100644 --- a/docs/plans/stack-encrypt-go-bindings.md +++ b/docs/plans/stack-encrypt-go-bindings.md @@ -141,10 +141,11 @@ Go application │ ▼ wazero (wasm32-wasip1) stack-encrypt guest (Rust cdylib) - ├─ StackCipher<StackKms<HostTokenStrategy, WasiHostConnection>> (one per instance; keysets selected per call) - ├─ FfiValue.encrypt_with_aad(&cipher, aad) → PendingStackCipherText → seal(kms) (block_on) - ├─ record plan → per-field EncryptFrom pendings → Pending::all → one generate_keys - └─ term(value, context, kind) → local PRF/ORE, no I/O + ├─ StackCipher<StackKms<HostTokenStrategy, WasiHostConnection>> (one per instance) + ├─ opts.keyset → cipher.keyset(selector) → KeysetCipher (per call; a cold name/id is one load) + ├─ FfiValue.encrypt_with_aad(&keyset_cipher, aad) → pending → seal (block_on; one generate_keys per 500 leaves) + ├─ record plan → per-field EncryptFrom pendings → Pending::all → generate_keys, chunked the same way + └─ term(value, context, kind) → keyset_cipher.term: the backend derives it (local PRF/ORE today, no I/O; ZeroKMS v2 derives server-side) ``` Control stays in Rust: request assembly, key derivation, batching, AAD/PRF diff --git a/languages/golang/stackencrypt/guest/src/buffers.rs b/languages/golang/stackencrypt/guest/src/buffers.rs index 48d89b2d2..a598dd109 100644 --- a/languages/golang/stackencrypt/guest/src/buffers.rs +++ b/languages/golang/stackencrypt/guest/src/buffers.rs @@ -112,8 +112,13 @@ pub(crate) unsafe fn take(ptr: *mut u8, len: usize) -> Option<Vec<u8>> { /// does after dropping the cipher, so a host that tears the instance down /// without releasing an output first still leaves no plaintext behind. /// Empties carry no bytes; their count is simply reset. +/// +/// This path allocates nothing: the registry is moved out whole (an empty +/// `HashMap` does not allocate) and walked in place, so a shutdown under +/// linear-memory pressure cannot fail before the wipe on an allocation the +/// wipe itself made. pub(crate) fn wipe_all() { - let live: Vec<(usize, usize)> = BUFFERS.with(|b| b.borrow_mut().drain().collect()); + let live = BUFFERS.with(|b| core::mem::take(&mut *b.borrow_mut())); for (ptr, len) in live { // SAFETY: every entry was registered by `register`, which leaked a // boxed slice of exactly `len` bytes at `ptr`, and it was removed From aabc8e0737f364e7b209768396fcef88fd40f23d Mon Sep 17 00:00:00 2001 From: Dan Draper <dan@cipherstash.com> Date: Sat, 12 Sep 2026 21:00:22 -0400 Subject: [PATCH 538/686] docs(stack-encrypt-guest): shutdown speaks for the cipher calls only, and a foreign keyset is a constraint, not a provenance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three claims in the ABI docs said more than the code does. "Every export after `se_shutdown` is `STATUS_STATE`" was true of none of `se_alloc`, `se_dealloc` or a second `se_shutdown` — the first two return no status at all and must keep working, since the host still holds buffers to free — and it contradicted the validation-precedence paragraph a few lines below it, under which a malformed call is `STATUS_ENCODING` in any state, torn-down instances included. The claim now covers what it always meant: a well-formed cipher operation, and a re-`se_cipher_init`. The same sentence on `se_shutdown` itself and in the plan's export table is narrowed the same way, and `se_cipher_init`'s "a second call is `STATUS_STATE`" gains the "once the config parses" it depends on — `cipher_init` parses before it looks at the instance state. `STATUS_FOREIGN_KEYSET` told a host the row was another tenant's "not a tampered one". It cannot: the scope check reads the keyset id out of the leaf and compares it before any key is retrieved and so before anything is authenticated, which puts a flipped byte in that field on exactly the same status as a genuinely misrouted row. The AAD binding still means the tampered leaf never opens — but it fails as `STATUS_AUTH` only along the path the constraint did not already cut off, so the status carries no statement about tampering either way. Documented as the constraint failure it is. Docs only; no behaviour changes. Claude-Session: https://claude.ai/code/session_01QbNwRNqa1qo3b8ZSqULsGE --- docs/plans/stack-encrypt-go-bindings.md | 2 +- .../golang/stackencrypt/guest/src/abi.rs | 22 ++++++++++++++----- .../golang/stackencrypt/guest/src/status.rs | 21 +++++++++++++----- 3 files changed, 32 insertions(+), 13 deletions(-) diff --git a/docs/plans/stack-encrypt-go-bindings.md b/docs/plans/stack-encrypt-go-bindings.md index 73b0a84f4..6b7e20061 100644 --- a/docs/plans/stack-encrypt-go-bindings.md +++ b/docs/plans/stack-encrypt-go-bindings.md @@ -384,7 +384,7 @@ via the registry, packed `u64` results, status in the low word on error): |---|---| | `se_alloc(len)` / `se_dealloc(ptr, len)` | buffer lifecycle, as vitaminc | | `se_cipher_init(cfg_ptr, cfg_len) → keyset id` | once per instance (CIP-4037): config (client id, client key, default keyset id/name, optional `keyset_cache_size`) encoded as an `FfiValue` object — no second codec. Builds `StackKms<HostTokenStrategy, WasiHostConnection>`, then `StackCipherBuilder::kms(..).keyset(..).init()` under `block_on` (one `load_keyset` call for the default keyset). Returns the default keyset's UUID as its buffer; a second call is `STATUS_STATE`. Client-key bytes zeroized after `ClientKey` is built. | -| `se_shutdown()` | drops the `StackCipher` (client key and every loaded index key wiped by `ZeroizeOnDrop`) and wipes every buffer the registry still holds. Needed because closing a wasm instance frees linear memory without running Rust destructors. Any export after it is `STATUS_STATE`. | +| `se_shutdown()` | drops the `StackCipher` (client key and every loaded index key wiped by `ZeroizeOnDrop`) and wipes every buffer the registry still holds. Needed because closing a wasm instance frees linear memory without running Rust destructors. Idempotent, and `se_alloc`/`se_dealloc` keep working so the host can still free what it holds; after it every well-formed cipher operation is `STATUS_STATE`, `se_cipher_init` included, while a malformed one is `STATUS_ENCODING` first, as in any other state. | | `se_keyset(selector)` | resolves a keyset selector (`{"default": {}}` / `{"name": s}` / `{"id": 16 bytes}`) through the cipher's cache — the first use of a keyset is one `load_keyset` call — and returns its UUID, so a host can validate a tenant at boot and learn its id | | `se_encrypt(value, aad, opts)` / `se_decrypt(ct, aad, opts)` | `opts` is the options object `{"keyset": <selector>}` (the guest's `options` module is its one home). Encrypt: decode `FfiValue` → `encrypt_with_aad(&keyset, aad)` → `seal(&keyset, aad)` (`block_on`) → `encode_ciphertext::<SealedValue, _>`. Decrypt mirrors via `decipher(ct)` + `FfiValue::decrypt_with_aad`; its selector is a constraint — `{"any": {}}` opens leaves from whichever keyset each was sealed under (one batched `retrieve_keys` per keyset, chunked at the client's 500-key request limit), a named keyset refuses any other's leaf as `STATUS_FOREIGN_KEYSET` before any key is retrieved. | | `se_encrypt_element` / `se_decrypt_element` | as vitaminc; row-at-a-time interop with batch-encrypted slices | diff --git a/languages/golang/stackencrypt/guest/src/abi.rs b/languages/golang/stackencrypt/guest/src/abi.rs index 5a4d98be9..4906d4979 100644 --- a/languages/golang/stackencrypt/guest/src/abi.rs +++ b/languages/golang/stackencrypt/guest/src/abi.rs @@ -31,9 +31,14 @@ //! key and every loaded index key wiped by `ZeroizeOnDrop`) and wipes //! every buffer the registry still holds. It exists because closing a //! wasm instance frees linear memory without running Rust destructors — -//! without it, key material would sit in freed host memory. Every export -//! after it, and every export before [`se_cipher_init`], is -//! `STATUS_STATE`. +//! without it, key material would sit in freed host memory. After it, a +//! well-formed cipher operation is `STATUS_STATE`, as one before +//! [`se_cipher_init`] is, and so is a re-`se_cipher_init`. That is the +//! whole of the claim: [`se_alloc`] and [`se_dealloc`] return no status +//! and go on working — the host still has buffers to free — a second +//! [`se_shutdown`] is a no-op, and a *malformed* call is +//! `STATUS_ENCODING` in any state, because validation runs first (see +//! below). //! - During an entry-point call the host's imported functions may re-enter //! the guest **only** through `se_alloc` (to place the transport response //! / token); calling any other export from inside a host import is @@ -265,7 +270,9 @@ fn with_opener<R>( /// traffic, whatever the outcome. /// /// Once per instance: a second call, or a call after [`se_shutdown`], is -/// `STATUS_STATE` (the config buffer is still wiped). +/// `STATUS_STATE` once the config parses — a config that does not parse is +/// `STATUS_ENCODING` first, like any malformed input. Either way the config +/// buffer is wiped. /// /// # Safety /// @@ -331,8 +338,11 @@ fn cipher_init(decoded: FfiValue) -> Result<Vec<u8>, u32> { /// Tear the instance down: drop the cipher — the client key and every /// loaded keyset's index key are wiped by `ZeroizeOnDrop` — and wipe every /// buffer the registry still holds, so nothing the host forgot to -/// [`se_dealloc`] survives in freed memory. Idempotent; every other export -/// is `STATUS_STATE` afterwards, [`se_cipher_init`] included. +/// [`se_dealloc`] survives in freed memory. Idempotent — a second call is a +/// no-op, and [`se_alloc`]/[`se_dealloc`] keep working so the host can +/// still free what it holds. Afterwards every well-formed cipher operation +/// is `STATUS_STATE`, [`se_cipher_init`] included; a malformed one is +/// `STATUS_ENCODING` first, as in any other state. /// /// The index keys' wipe holds because each `HmacSha256Prf` clone a /// derivation takes is created and dropped inside one `block_on`'d call, diff --git a/languages/golang/stackencrypt/guest/src/status.rs b/languages/golang/stackencrypt/guest/src/status.rs index 197bf2d8f..cd991b03d 100644 --- a/languages/golang/stackencrypt/guest/src/status.rs +++ b/languages/golang/stackencrypt/guest/src/status.rs @@ -13,8 +13,9 @@ //! ([`ViturRequestErrorKind`]-shaped) so a Go caller can distinguish a bad //! token from a tampered ciphertext without parsing strings. Code 11 is a //! term-derivation failure (a caller-input condition, e.g. match text that -//! yields no tokens). Code 12 is a keyset-scoped open refusing a leaf from -//! another keyset — a host's own constraint, distinct from tampering. +//! yields no tokens). Code 12 is a keyset-scoped open refusing a leaf whose +//! keyset id is not the scope's — a host's own constraint, checked before +//! the leaf is authenticated and so not a statement about tampering. use stack_auth::AuthError; use stack_kms::{GenerateKeyError, LoadKeysetError, RetrieveKeyError}; @@ -74,10 +75,18 @@ pub const STATUS_KMS_OTHER: u32 = 10; /// a value/scheme combination the term does not support. pub const STATUS_TERM: u32 = 11; /// An opening export was constrained to one keyset (`{"name"}`, `{"id"}` or -/// `{"default"}` in its options) and a leaf was sealed under another. -/// Refused before any key is retrieved. A host that means "whichever -/// keyset" opens with `{"any"}`; a host that meant this keyset has the -/// wrong tenant's row, not a tampered one — see [`STATUS_AUTH`]. +/// `{"default"}` in its options) and the leaf named another. Refused before +/// any key is retrieved. A host that means "whichever keyset" opens with +/// `{"any"}`. +/// +/// A constraint failure, and only that — never provenance. The comparison +/// reads the keyset id *out of the leaf*, before anything is retrieved and +/// so before anything is authenticated, which means a flipped byte in that +/// field arrives here exactly as a genuinely misrouted row does. The id is +/// bound into the leaf AAD, so the tampered leaf cannot go on to open — +/// it fails as [`STATUS_AUTH`] — but that verdict is only reached on the +/// path where the constraint let it through. Read this status as "not this +/// keyset's row", never as "an untampered row". pub const STATUS_FOREIGN_KEYSET: u32 = 12; /// Map a sealing/opening error onto the ABI status word. From 1b64c77bf986a24a9184857e083f17ca1392a48d Mon Sep 17 00:00:00 2001 From: Dan Draper <dan@cipherstash.com> Date: Sat, 12 Sep 2026 21:19:27 -0400 Subject: [PATCH 539/686] docs(stack-encrypt-guest): a value open counts its requests like a record open, and the plan's output keys are the ones the parser accepts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `decrypt_value` promised "one batched `retrieve_keys` request" outright. Its sibling `decrypt_record` has said the true rule for a while — one batch per invocation, one ZeroKMS call per 500 keyed leaves, and under `Opener::Any` one per keyset the leaves were sealed under — and so does the plan's `se_decrypt` row. Only this rustdoc still offered the stronger guarantee, to the path that can least keep it: a value tree is free to hold leaves from several keysets. It now states the same rule as the record side, and says plainly that the single request is a small tree's outcome, not a promise. The plan's `se_encrypt_record` row described a record shape the guest has never produced. `Output::parse` accepts five literals — `"c"`, `"eq"`, `"match"`, `"ore"`, `"ope"` — so `match(opts)` names an argument the grammar has no place for, and `Output::key` writes those same five back, never `hm`/`ob`: those are EQL's column names for the same term bytes, two layers up, and a binding written from this table would have encoded them as field names. The composition was wrong too — there is no zip per row and no `all` across the array; `encrypt_record` queues every row's and field's ciphertext into one flat pending list and settles it once. The row now says what the parser takes, what the encoder emits, and what the batch actually is, with the EQL names called out as the other layer's. Docs only; no behaviour changes. Claude-Session: https://claude.ai/code/session_01QbNwRNqa1qo3b8ZSqULsGE --- docs/plans/stack-encrypt-go-bindings.md | 2 +- languages/golang/stackencrypt/guest/src/ops.rs | 10 ++++++++-- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/docs/plans/stack-encrypt-go-bindings.md b/docs/plans/stack-encrypt-go-bindings.md index 6b7e20061..6ab5dcce9 100644 --- a/docs/plans/stack-encrypt-go-bindings.md +++ b/docs/plans/stack-encrypt-go-bindings.md @@ -388,7 +388,7 @@ via the registry, packed `u64` results, status in the low word on error): | `se_keyset(selector)` | resolves a keyset selector (`{"default": {}}` / `{"name": s}` / `{"id": 16 bytes}`) through the cipher's cache — the first use of a keyset is one `load_keyset` call — and returns its UUID, so a host can validate a tenant at boot and learn its id | | `se_encrypt(value, aad, opts)` / `se_decrypt(ct, aad, opts)` | `opts` is the options object `{"keyset": <selector>}` (the guest's `options` module is its one home). Encrypt: decode `FfiValue` → `encrypt_with_aad(&keyset, aad)` → `seal(&keyset, aad)` (`block_on`) → `encode_ciphertext::<SealedValue, _>`. Decrypt mirrors via `decipher(ct)` + `FfiValue::decrypt_with_aad`; its selector is a constraint — `{"any": {}}` opens leaves from whichever keyset each was sealed under (one batched `retrieve_keys` per keyset, chunked at the client's 500-key request limit), a named keyset refuses any other's leaf as `STATUS_FOREIGN_KEYSET` before any key is retrieved. | | `se_encrypt_element` / `se_decrypt_element` | as vitaminc; row-at-a-time interop with batch-encrypted slices | -| `se_encrypt_record(source, plan, opts)` | the runtime form of `#[derive(EncryptFrom)]`: `plan` is an `FfiValue` object `{ field → { context, outputs: [c \| eq \| match(opts) \| ore \| ope] } }`; per field the guest dispatches on the source `FfiValue` variant to the typed `EncryptFrom` impls (`u32`/`u64`/`i64`/`f64`/`String`), zips the pendings, `Pending::all` across an array source, and returns `{ field → { c: leaf, hm: bytes, ob: bytes, … } }`. One batched `generate_keys` per invocation regardless of row count, dispatched as one ZeroKMS request per 500 keyed leaves (the request limit above). | +| `se_encrypt_record(source, plan, opts)` | the runtime form of `#[derive(EncryptFrom)]`: `plan` is an `FfiValue` object `{ field → { context, outputs: [ "c" \| "eq" \| "match" \| "ore" \| "ope" ] } }` — those five literals are the whole grammar, `"match"` among them (its tokenizer config is the default, not a per-output argument); per field the guest dispatches on the source `FfiValue` variant to the typed `EncryptFrom` impls (`u32`/`u64`/`i64`/`f64`/`String`), derives the terms as it builds each row and queues every ciphertext's data-key request into one flat pending list that a single `Pending::all` settles — rows and fields alike, with no per-row composition — and returns `{ field → { "c": the sealed subtree, "eq"/"match"/"ore"/"ope": the term bytes as passthrough nodes } }`, each field carrying exactly the outputs its plan asked for and under those keys. (`hm`/`oc`/`op`/`bf` are EQL's *column* names for the same bytes, not this ABI's.) One batched `generate_keys` per invocation regardless of row count, dispatched as one ZeroKMS request per 500 keyed leaves (the request limit above). | | `se_decrypt_record(record, plan, opts)` | inverse; only the `c` outputs participate; the selector constrains as for `se_decrypt` | | `se_term(value, context, kind, opts)` | query probe under the selected keyset's index key; `context` is codec-encoded in the plan-field grammar — one part (a string, bytes, or an `i32`/`i64`/`u32`/`u64`) or an array of parts, nested as deep as the transport codec allows (`vitaminc_aead_value::transport::MAX_DEPTH`, 128 levels from the root of the encoded value; deeper is `STATUS_ENCODING` before the context is parsed, not an interop bug). Shape is identity: `[x]` is not `x`, so a probe passes the context in exactly the shape the field was sealed under (the guest's `context` module is the one home of the grammar and of which Rust context each shape spells). Under the local HMAC backend a probe does no ZeroKMS I/O; that is the backend's property, not the API's (ZeroKMS v2 derives terms at the server) | diff --git a/languages/golang/stackencrypt/guest/src/ops.rs b/languages/golang/stackencrypt/guest/src/ops.rs index f6019393b..cc6cfe7a7 100644 --- a/languages/golang/stackencrypt/guest/src/ops.rs +++ b/languages/golang/stackencrypt/guest/src/ops.rs @@ -110,8 +110,14 @@ where } /// Decrypt a codec-encoded ciphertext tree back into a codec-encoded -/// [`FfiValue`] tree (one batched `retrieve_keys` request). The output buffer -/// contains plaintext — the ABI layer's ownership rules govern its wiping. +/// [`FfiValue`] tree. The output buffer contains plaintext — the ABI +/// layer's ownership rules govern its wiping. +/// +/// One batched `retrieve_keys` per invocation, dispatched as one ZeroKMS +/// call per 500 keyed leaves and, under [`Opener::Any`], per keyset the +/// tree's leaves were sealed under — the same rule [`decrypt_record`] +/// states. A tree small enough and single-keyset enough is the one request +/// that suggests; nothing here promises it in general. /// /// Symmetric with [`encrypt_value`]: the AAD is whatever the value was sealed /// under, empty included. The [`Opener`] says which keysets may be opened: From 76a4e706abb06fecb0d678a9a4faeab123b10da4 Mon Sep 17 00:00:00 2001 From: Dan Draper <dan@cipherstash.com> Date: Sat, 12 Sep 2026 10:36:38 -0400 Subject: [PATCH 540/686] feat(stack-encrypt): Go module over the WASI guest (CIP-4022) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit bindings/go/stackencrypt is the wazero host of the stack-encrypt guest: one wasm instance per Client, CGO_ENABLED=0, vitaminc's vcffi/vcvalue imported for the codec and value model rather than forked. Surface, following CIP-4037: NewClient takes the ZeroKMS credentials, instantiates the guest, runs se_cipher_init once (one load-keyset round trip) and learns the default keyset's id; Close runs se_shutdown so every zeroize happens before the instance is freed. Client.Cipher(selector) is the keyset-bound view (Encrypt/Decrypt and element forms, Term, EncryptRecords/DecryptRecords), with no request until first use; Client.Decrypt* opens leaves from any keyset. KeysetName, KeysetID and DefaultKeyset spell every selector with no sentinel. Term takes a Context (a part, or NonEmpty::with-shaped left-nested lists) and returns an error, since derivation may be a ZeroKMS round trip. Sealed / SealedNone / SealedEmptySeq / SealedEmptyMap are this binding's leaf types, wired through a vcffi.LeafSet so a stack-encrypt leaf never scans or marshals as a vitaminc one; leaves and the four term types implement driver.Valuer and sql.Scanner. Record plans come from `stash` struct tags (context=, index=eq;ore;match;ope, name=), built once per type; ExtendContext extends each field's context as the Rust derive does. Errors are the guest's twelve statuses as sentinels. The transport host module serves transport_send over any RoundTripper (headers in the guest's line format, negative status on failure) and token_get from a TokenSource, and reaches the guest only through se_alloc. Tests are hermetic: import-surface gate, the bridge against an httptest ZeroKMS (request shape, every HTTP outcome and transport failure mapped to its error, no request without a token), guest-parser acceptance of every encoding the package builds — the guest validates before it consults its cipher, so on an uninitialised instance a well-formed call is ErrState and a malformed one ErrEncoding — hostile pointer/length pairs, and a scan proving the client key and token do not survive in guest memory. Live round trips are in live_test.go, skipped without credentials; the phase 5 harness runs them. wasm:guest:build now copies the artefact into the module's embedded wasm/ directory (gitignored; the package compiles without it and NewClient reports ErrGuestNotBuilt); go:stackencrypt:test formats, vets and tests on amd64 and 386, and test-wasi.yml runs it after the guest gate. Claude-Session: https://claude.ai/code/session_01ECzyss8kvtnrRtq5tmpEbg --- .github/imported-workflows/test-wasi.yml | 23 +- docs/plans/stack-encrypt-go-bindings.md | 18 +- languages/golang/stackencrypt/cipher.go | 143 +++++ languages/golang/stackencrypt/client.go | 246 +++++++++ languages/golang/stackencrypt/context.go | 64 +++ languages/golang/stackencrypt/doc.go | 51 ++ languages/golang/stackencrypt/errors.go | 100 ++++ languages/golang/stackencrypt/go.mod | 11 + languages/golang/stackencrypt/go.sum | 8 + languages/golang/stackencrypt/guest.go | 212 ++++++++ languages/golang/stackencrypt/guest_test.go | 509 ++++++++++++++++++ languages/golang/stackencrypt/keyset.go | 82 +++ languages/golang/stackencrypt/leaf.go | 122 +++++ languages/golang/stackencrypt/live_test.go | 165 ++++++ languages/golang/stackencrypt/record.go | 519 +++++++++++++++++++ languages/golang/stackencrypt/term.go | 131 +++++ languages/golang/stackencrypt/transport.go | 195 +++++++ languages/golang/stackencrypt/unit_test.go | 280 ++++++++++ languages/golang/stackencrypt/wasm/README.md | 6 + 19 files changed, 2876 insertions(+), 9 deletions(-) create mode 100644 languages/golang/stackencrypt/cipher.go create mode 100644 languages/golang/stackencrypt/client.go create mode 100644 languages/golang/stackencrypt/context.go create mode 100644 languages/golang/stackencrypt/doc.go create mode 100644 languages/golang/stackencrypt/errors.go create mode 100644 languages/golang/stackencrypt/go.mod create mode 100644 languages/golang/stackencrypt/go.sum create mode 100644 languages/golang/stackencrypt/guest.go create mode 100644 languages/golang/stackencrypt/guest_test.go create mode 100644 languages/golang/stackencrypt/keyset.go create mode 100644 languages/golang/stackencrypt/leaf.go create mode 100644 languages/golang/stackencrypt/live_test.go create mode 100644 languages/golang/stackencrypt/record.go create mode 100644 languages/golang/stackencrypt/term.go create mode 100644 languages/golang/stackencrypt/transport.go create mode 100644 languages/golang/stackencrypt/unit_test.go create mode 100644 languages/golang/stackencrypt/wasm/README.md diff --git a/.github/imported-workflows/test-wasi.yml b/.github/imported-workflows/test-wasi.yml index 51deb6e28..63e9fd330 100644 --- a/.github/imported-workflows/test-wasi.yml +++ b/.github/imported-workflows/test-wasi.yml @@ -12,9 +12,10 @@ on: # Trigger on all of packages/ rather than enumerating the closure, # which would silently drift. - packages/** - # The guest is a detached workspace, so nothing else in CI compiles, - # tests or links it — this job is its only gate. - - bindings/go/stackencrypt/guest/** + # The guest is a detached workspace and the Go binding a separate + # module, so nothing else in CI compiles, tests or links them — this + # job is their only gate. + - bindings/go/stackencrypt/** - scripts/check-wasm-imports.py - Cargo.toml - Cargo.lock @@ -28,9 +29,10 @@ on: pull_request: paths: - packages/** - # The guest is a detached workspace, so nothing else in CI compiles, - # tests or links it — this job is its only gate. - - bindings/go/stackencrypt/guest/** + # The guest is a detached workspace and the Go binding a separate + # module, so nothing else in CI compiles, tests or links them — this + # job is their only gate. + - bindings/go/stackencrypt/** - scripts/check-wasm-imports.py - Cargo.toml - Cargo.lock @@ -92,3 +94,12 @@ jobs: # sandboxing rests on, checkable only on the linked artifact. - name: Guest release build and import-surface gate run: mise run wasm:guest:build + + # The Go binding (bindings/go/stackencrypt) embeds the module built + # above: format, vet and hermetic tests (import surface, transport + # bridge against an httptest ZeroKMS, guest-parser acceptance of every + # encoding the package builds, hostile ABI inputs, key residency), on + # amd64 and 386. Live round trips through ZeroKMS are the phase 5 + # harness's. + - name: Go binding + run: mise run go:stackencrypt:test diff --git a/docs/plans/stack-encrypt-go-bindings.md b/docs/plans/stack-encrypt-go-bindings.md index 6ab5dcce9..5cf4e0461 100644 --- a/docs/plans/stack-encrypt-go-bindings.md +++ b/docs/plans/stack-encrypt-go-bindings.md @@ -423,9 +423,21 @@ for the proof; the Go side pools instances later. ### Phase 4 — the Go module -`bindings/go/stackencrypt` (module path TBD — see decisions). Imports -`vcvalue` for the model. Surface mirrors `vcencrypt` so the two feel like one -SDK: +**Status (2026-09-12): implemented in `bindings/go/stackencrypt`** (module +path `github.com/cipherstash/cipherstash-suite/bindings/go/stackencrypt`, +temporary until publishing). It imports `vcffi` + `vcvalue` from vitaminc +(pseudo-versioned to a main commit; no fork), embeds the guest from +`wasm/` (copied by `wasm:guest:build`, gitignored), and is gated by +`go:stackencrypt:test` in `test-wasi.yml`. Where the shipped surface +differs from the sketch below, the shipped one follows CIP-4037: one +instance per `Client` and no cipher handle, so `NewClient` takes the +ZeroKMS credentials and initialises the cipher, `Client.Cipher(selector)` +is the keyset-bound view (the Rust `KeysetCipher`), `Client.Decrypt*` +opens any keyset, and `Term` takes a `Context` and returns an error. + +Original sketch: `bindings/go/stackencrypt` (module path TBD — see +decisions). Imports `vcvalue` for the model. Surface mirrors `vcencrypt` so +the two feel like one SDK: ```go client, _ := stackencrypt.NewClient(ctx, stackencrypt.Config{ diff --git a/languages/golang/stackencrypt/cipher.go b/languages/golang/stackencrypt/cipher.go new file mode 100644 index 000000000..ca5e5d96b --- /dev/null +++ b/languages/golang/stackencrypt/cipher.go @@ -0,0 +1,143 @@ +package stackencrypt + +import ( + "context" + "fmt" + + "github.com/cipherstash/vitaminc/bindings/go/vcffi" +) + +// Cipher is a [Client] bound to one keyset: the Go form of the Rust +// crate's KeysetCipher. It seals values, derives terms and encrypts records +// under that keyset, and opens only that keyset's ciphertexts — a leaf +// sealed under another keyset is refused as [ErrForeignKeyset] before any +// key is retrieved. To open ciphertexts from any keyset, use the Client's +// decrypt methods. +// +// A Cipher holds no guest state: the keyset is selected on every call, and +// loaded by the guest on first use. +type Cipher struct { + client *Client + keyset KeysetSelector +} + +// Client is the client this cipher belongs to. +func (cph *Cipher) Client() *Client { return cph.client } + +// Keyset is the selector this cipher is bound to. +func (cph *Cipher) Keyset() KeysetSelector { return cph.keyset } + +// Encrypt seals v under this keyset. v is encoded through the vcvalue +// model: builtins, slices, maps and structs by reflection, a type +// implementing vcffi.Encryptable by its own encoding, vcvalue.Plain marking +// a passthrough. aad is authenticated but not encrypted, and may be empty; +// the same aad must be presented to Decrypt. Every leaf of v seals from one +// batched ZeroKMS key request. +// +// The ciphertext comes back as ordinary Go values mirroring the +// plaintext's structure: Sealed leaves (and the SealedNone / SealedEmptySeq +// / SealedEmptyMap markers) where fields were encrypted, vcvalue.Plain +// where they passed through, map[string]any for records, []any for +// sequences. +func (cph *Cipher) Encrypt(ctx context.Context, v any, aad []byte) (any, error) { + return cph.encryptValue(ctx, v, aad, false) +} + +// EncryptElement seals v as a sequence element of the collection identified +// by aad — byte-identical to what Encrypt of a whole slice binds per +// element — so a single row inserted this way interchanges with rows +// written by encrypting a slice under the same aad. +func (cph *Cipher) EncryptElement(ctx context.Context, v any, aad []byte) (any, error) { + return cph.encryptValue(ctx, v, aad, true) +} + +// Decrypt opens a ciphertext sealed under this keyset. ct is the shape +// Encrypt returns (any subset of a record's entries decrypts); the +// plaintext is returned in vcvalue's decode shape: Go natives, +// vcvalue.Object for records, vcvalue.Plain for passthrough fields. A leaf +// from another keyset is ErrForeignKeyset. +func (cph *Cipher) Decrypt(ctx context.Context, ct any, aad []byte) (any, error) { + return cph.client.decryptValue(ctx, cph.keyset, ct, aad, false) +} + +// DecryptElement opens a ciphertext sealed as a sequence element — a row +// of a collection encrypted from a slice, or by EncryptElement — under the +// same aad. Elements are authenticated against a derivation of the +// collection's aad, so Decrypt cannot open a lone row. +func (cph *Cipher) DecryptElement(ctx context.Context, ct any, aad []byte) (any, error) { + return cph.client.decryptValue(ctx, cph.keyset, ct, aad, true) +} + +// Term derives one index term for value under context: the probe that +// compares against a term stored by EncryptRecords for a field sealed under +// the same keyset and the same context. value is a scalar: an integer, +// string or byte slice for Equality (floats and booleans have no equality +// encoding); a string for Match; any scalar for Ore and Ope. The result is +// one of EqualityTerm, MatchTerm, OreTerm or OpeTerm. +// +// Term takes a context and returns an error because it may be a ZeroKMS +// round trip: term derivation is asynchronous in the Rust crate, and a +// ZeroKMS backend that derives terms server-side settles the same way. +func (cph *Cipher) Term(ctx context.Context, value any, context Context, kind TermKind) (any, error) { + if context.node == nil { + return nil, fmt.Errorf("stackencrypt: term context is empty") + } + encodedValue, err := vcffi.Marshal(value) + if err != nil { + return nil, err + } + encodedContext, err := vcffi.Marshal(context.value()) + if err != nil { + return nil, err + } + opts, err := vcffi.Marshal(options(cph.keyset)) + if err != nil { + return nil, err + } + out, err := cph.client.call(ctx, func(inst *instance) ([]byte, error) { + return inst.call(ctx, inst.term, buf(encodedValue), buf(encodedContext), scalar(uint64(kind)), buf(opts)) + }) + if err != nil { + return nil, err + } + return typedTerm(kind, out), nil +} + +func typedTerm(kind TermKind, bytes []byte) any { + switch kind { + case Equality: + return EqualityTerm(bytes) + case Match: + return MatchTerm(bytes) + case Ore: + return OreTerm(bytes) + case Ope: + return OpeTerm(bytes) + default: + return bytes + } +} + +func (cph *Cipher) encryptValue(ctx context.Context, v any, aad []byte, element bool) (any, error) { + encoded, err := vcffi.Marshal(v) + if err != nil { + return nil, err + } + // The transport copy of the plaintext is wiped once it is in the guest. + defer wipe(encoded) + opts, err := vcffi.Marshal(options(cph.keyset)) + if err != nil { + return nil, err + } + out, err := cph.client.call(ctx, func(inst *instance) ([]byte, error) { + fn := inst.encrypt + if element { + fn = inst.encryptElement + } + return inst.call(ctx, fn, buf(encoded), buf(aad), buf(opts)) + }) + if err != nil { + return nil, err + } + return unmarshalCipherText(out) +} diff --git a/languages/golang/stackencrypt/client.go b/languages/golang/stackencrypt/client.go new file mode 100644 index 000000000..57bda879a --- /dev/null +++ b/languages/golang/stackencrypt/client.go @@ -0,0 +1,246 @@ +package stackencrypt + +import ( + "context" + "errors" + "fmt" + "net/http" + "strconv" + "sync" + + "github.com/cipherstash/vitaminc/bindings/go/vcffi" + "github.com/cipherstash/vitaminc/bindings/go/vcvalue" +) + +// Config configures a [Client]. +type Config struct { + // ClientID is the ZeroKMS client id (a UUID string). Required. + ClientID string + // ClientKey is the v1 client key material: hex (the CS_CLIENT_KEY form, + // either case) or standard padded base64 (the secretkey.json form). + // Required. It enters guest memory once and is wiped from the config + // buffer before any request is made; the Go-side copy this package + // makes is wiped too. The caller's own string is the caller's. + ClientKey string + // Keyset pins the client's default keyset: a KeysetName or KeysetID. + // Nil, or DefaultKeyset, means the ZeroKMS client's own default. + Keyset KeysetSelector + // ZeroKMSURL pins the ZeroKMS endpoint. When empty the endpoint is + // resolved from the access token's services claim on first use. + ZeroKMSURL string + // KeysetCacheSize is how many keysets beyond the default the guest keeps + // loaded; zero means the crate default (1024). + KeysetCacheSize int + // Transport performs the HTTP requests to ZeroKMS. Nil means + // http.DefaultTransport. + Transport http.RoundTripper + // Token supplies the bearer token for every request. Required. + Token TokenSource + // Guest overrides the embedded wasm module. Nil means the embedded one. + Guest []byte +} + +// Client is one wasm instance holding one ZeroKMS client: its key, its +// default keyset, and the keysets it has loaded since. It is safe for +// concurrent use; calls are serialised internally, because a wasm instance +// is single-threaded. Close it to wipe its key material. +type Client struct { + mu sync.Mutex + inst *instance + transport *transport + closed bool + def KeysetID +} + +// NewClient instantiates the guest, loads the client key into it, and loads +// the default keyset — one ZeroKMS round trip. The returned client is ready +// to seal. +func NewClient(ctx context.Context, cfg Config) (*Client, error) { + if cfg.Token == nil { + return nil, errors.New("stackencrypt: Config.Token is required") + } + wasm := cfg.Guest + if wasm == nil { + var err error + if wasm, err = embeddedGuest(); err != nil { + return nil, err + } + } + rt := cfg.Transport + if rt == nil { + rt = http.DefaultTransport + } + encoded, err := encodeConfig(cfg) + if err != nil { + return nil, err + } + defer wipe(encoded) + + t := &transport{rt: rt, token: cfg.Token} + inst, err := newInstance(ctx, wasm, t) + if err != nil { + return nil, err + } + c := &Client{inst: inst, transport: t} + out, err := inst.call(ctx, inst.cipherInit, buf(encoded)) + if err != nil { + _ = c.Close(ctx) + return nil, fmt.Errorf("stackencrypt: cipher init: %w", err) + } + if len(out) != len(KeysetID{}) { + _ = c.Close(ctx) + return nil, fmt.Errorf("%w: cipher init returned %d bytes for the keyset id", ErrInternal, len(out)) + } + copy(c.def[:], out) + return c, nil +} + +// encodeConfig renders the se_cipher_init object. The result holds the +// client key; the caller wipes it. +func encodeConfig(cfg Config) ([]byte, error) { + if cfg.ClientID == "" || cfg.ClientKey == "" { + return nil, errors.New("stackencrypt: Config.ClientID and Config.ClientKey are required") + } + fields := vcvalue.Object{ + {Key: "client_id", Value: cfg.ClientID}, + {Key: "client_key", Value: cfg.ClientKey}, + } + switch k := cfg.Keyset.(type) { + case nil, defaultKeyset: + case KeysetName: + fields = append(fields, vcvalue.Field{Key: "keyset", Value: string(k)}) + case KeysetID: + fields = append(fields, vcvalue.Field{Key: "keyset_id", Value: k.String()}) + default: + return nil, fmt.Errorf("stackencrypt: Config.Keyset must be a KeysetName or KeysetID, not %T", cfg.Keyset) + } + if cfg.ZeroKMSURL != "" { + fields = append(fields, vcvalue.Field{Key: "zerokms_url", Value: cfg.ZeroKMSURL}) + } + if cfg.KeysetCacheSize < 0 { + return nil, errors.New("stackencrypt: Config.KeysetCacheSize must not be negative") + } + if cfg.KeysetCacheSize > 0 { + fields = append(fields, vcvalue.Field{Key: "keyset_cache_size", Value: strconv.Itoa(cfg.KeysetCacheSize)}) + } + return vcffi.Marshal(fields) +} + +// Close shuts the guest down — the client key and every loaded index key +// are wiped inside the instance — and releases the runtime. Idempotent. +// Every call after it fails with ErrState. +func (c *Client) Close(ctx context.Context) error { + c.mu.Lock() + defer c.mu.Unlock() + if c.closed { + return nil + } + c.closed = true + // A trapped or already-closed module cannot run se_shutdown; the runtime + // close still frees its memory. Nothing else can be done host-side. + _, _ = c.inst.shutdown.Call(ctx) + return c.inst.close(ctx) +} + +// DefaultKeysetID is the id of the client's default keyset, resolved at +// NewClient. +func (c *Client) DefaultKeysetID() KeysetID { return c.def } + +// Keyset resolves a selector to its keyset id: the first use of a name or +// id on this client is one ZeroKMS round trip, later uses come from the +// guest's cache. Use it at boot to validate a tenant's keyset and learn its +// id. DefaultKeyset never makes a request. +func (c *Client) Keyset(ctx context.Context, sel KeysetSelector) (KeysetID, error) { + if sel == nil { + return KeysetID{}, errNilSelector + } + encoded, err := vcffi.Marshal(sel.selector()) + if err != nil { + return KeysetID{}, err + } + out, err := c.call(ctx, func(inst *instance) ([]byte, error) { + return inst.call(ctx, inst.keyset, buf(encoded)) + }) + if err != nil { + return KeysetID{}, err + } + var id KeysetID + if len(out) != len(id) { + return id, fmt.Errorf("%w: se_keyset returned %d bytes", ErrInternal, len(out)) + } + copy(id[:], out) + return id, nil +} + +// Cipher binds the client to one keyset. No request is made here: the +// keyset is resolved by the guest on the cipher's first use (and cached), +// so a Cipher is cheap to make per call, per tenant or per request. +func (c *Client) Cipher(sel KeysetSelector) *Cipher { + if sel == nil { + sel = DefaultKeyset + } + return &Cipher{client: c, keyset: sel} +} + +// DefaultCipher is Cipher(DefaultKeyset). +func (c *Client) DefaultCipher() *Cipher { return c.Cipher(DefaultKeyset) } + +// Decrypt opens a ciphertext produced by any keyset of this client: each +// leaf is opened under the keyset it was sealed with, with one batched key +// retrieval per keyset. ct is the shape Cipher.Encrypt returns; aad must be +// what the value was sealed under. +func (c *Client) Decrypt(ctx context.Context, ct any, aad []byte) (any, error) { + return c.decryptValue(ctx, anyKeyset{}, ct, aad, false) +} + +// DecryptElement is Decrypt for a value sealed as a sequence element; see +// Cipher.DecryptElement. +func (c *Client) DecryptElement(ctx context.Context, ct any, aad []byte) (any, error) { + return c.decryptValue(ctx, anyKeyset{}, ct, aad, true) +} + +// DecryptRecords opens records produced by Cipher.EncryptRecords under any +// keyset of this client, into a slice; see Cipher.DecryptRecords. +func (c *Client) DecryptRecords(ctx context.Context, records []EncryptedRecord, out any, opts ...RecordOption) error { + return c.decryptRecords(ctx, anyKeyset{}, records, out, opts) +} + +// DecryptRecord opens one record under any keyset of this client; see +// Cipher.DecryptRecord. +func (c *Client) DecryptRecord(ctx context.Context, record EncryptedRecord, out any, opts ...RecordOption) error { + return c.decryptRecord(ctx, anyKeyset{}, record, out, opts) +} + +// call runs f on the instance under the client's lock. +func (c *Client) call(ctx context.Context, f func(*instance) ([]byte, error)) ([]byte, error) { + c.mu.Lock() + defer c.mu.Unlock() + if c.closed { + return nil, ErrState + } + return f(c.inst) +} + +func (c *Client) decryptValue(ctx context.Context, sel KeysetSelector, ct any, aad []byte, element bool) (any, error) { + encoded, err := marshalCipherText(ct) + if err != nil { + return nil, err + } + opts, err := vcffi.Marshal(options(sel)) + if err != nil { + return nil, err + } + out, err := c.call(ctx, func(inst *instance) ([]byte, error) { + fn := inst.decrypt + if element { + fn = inst.decryptElement + } + return inst.call(ctx, fn, buf(encoded), buf(aad), buf(opts)) + }) + if err != nil { + return nil, err + } + // The output is plaintext: decode, then wipe the transport copy. + defer wipe(out) + return vcffi.Unmarshal(out) +} diff --git a/languages/golang/stackencrypt/context.go b/languages/golang/stackencrypt/context.go new file mode 100644 index 000000000..400eb17d2 --- /dev/null +++ b/languages/golang/stackencrypt/context.go @@ -0,0 +1,64 @@ +package stackencrypt + +import "fmt" + +// Context is the encryption context a record field or a term probe binds: +// a domain-separating value that becomes both the ciphertext's AAD (and +// the ZeroKMS descriptor the data key is bound to) and the index terms' +// PRF context. Contexts are identities, so a probe must spell the context +// in exactly the shape the field was sealed under. +// +// A Context is a part or a list of parts. A part is a string, a byte slice +// or an integer (int32, int64, uint32, uint64; Go's int is sent as int64). +// [NewContext] makes a one-part context — the bare part, the shape a Rust +// `#[derive(EncryptFrom)]` field is sealed under when the caller supplies no +// context of its own. [Context.With] extends it as Rust's NonEmpty::with +// does: the result is the two-element list [previous, part], nesting to the +// left, so NewContext("users/age").With(uint64(7)) is the context a row +// sealed with encrypt_into_with_context(row, 7u64) binds for that field. +// A one-element list is not the bare part, and this type cannot spell one. +type Context struct { + node any +} + +// NewContext makes a one-part context. +func NewContext(part any) (Context, error) { + if err := checkPart(part); err != nil { + return Context{}, err + } + return Context{node: part}, nil +} + +// MustContext is [NewContext] for a part known to be valid; it panics +// otherwise. For string literals in plans and probes. +func MustContext(part any) Context { + c, err := NewContext(part) + if err != nil { + panic(err) + } + return c +} + +// With extends the context by one part, nesting to the left. +func (c Context) With(part any) (Context, error) { + if c.node == nil { + return Context{}, fmt.Errorf("stackencrypt: cannot extend an empty context") + } + if err := checkPart(part); err != nil { + return Context{}, err + } + return Context{node: []any{c.node, part}}, nil +} + +// value renders the context in the guest's grammar: a scalar or nested +// lists of scalars, ready for the transport codec. +func (c Context) value() any { return c.node } + +func checkPart(part any) error { + switch part.(type) { + case string, []byte, int32, int64, uint32, uint64, int: + return nil + default: + return fmt.Errorf("stackencrypt: %T is not a context part (string, []byte or integer)", part) + } +} diff --git a/languages/golang/stackencrypt/doc.go b/languages/golang/stackencrypt/doc.go new file mode 100644 index 000000000..e5d2827a4 --- /dev/null +++ b/languages/golang/stackencrypt/doc.go @@ -0,0 +1,51 @@ +// Package stackencrypt is the Go binding of stack-encrypt: ZeroKMS-backed +// field-level encryption with searchable index terms, running the Rust +// crate unmodified inside a WASI guest under wazero (CGO_ENABLED=0). +// +// # Shape +// +// A [Client] is one wasm instance and one ZeroKMS client: [NewClient] +// instantiates the embedded guest, hands it the client key once, and loads +// the client's default keyset. Every keyset the client uses after that is +// selected per call through a [KeysetSelector] and loaded on first use by +// the guest's own bounded cache; nothing the host could allocate, alias or +// free crosses the boundary. [Client.Close] runs the guest's shutdown so the +// client key and every loaded index key are wiped before the instance is +// freed — closing a wasm instance runs no Rust destructors on its own. +// +// A [Cipher] is the client bound to one keyset ([Client.Cipher], +// [Client.DefaultCipher]): it seals values, derives terms and encrypts +// records under that keyset, and opens only that keyset's ciphertexts. The +// [Client] itself opens ciphertexts from any keyset ([Client.Decrypt] and +// friends), fetching one batched key retrieval per keyset the leaves were +// sealed under. +// +// # Values +// +// Values cross the boundary in vitaminc's FFI codec ([vcffi]) and are +// modelled as Go natives ([vcvalue]): builtins, slices, maps and structs +// seal by reflection, [vcvalue.Plain] marks a passthrough field, and a +// type implementing [vcffi.Encryptable] drives its own encoding. A +// ciphertext is the same dynamic shape with [Sealed] leaves — a distinct +// type from vcvalue's, because a stack-encrypt leaf is not a vitaminc leaf +// and must never scan or marshal where one belongs. The leaf bytes are the +// frozen stack-encrypt storage format; the transport encoding is not. +// +// # Records and terms +// +// [Cipher.EncryptRecords] is the runtime form of the Rust derive: a struct's +// `stash` tags say, per field, which context to bind and which index terms +// to produce, and one call seals every row of a slice from one batched key +// request. Terms ([EqualityTerm], [MatchTerm], [OreTerm], [OpeTerm]) are +// byte-equal to the ones the Rust crate derives, so a probe from +// [Cipher.Term] compares against a stored term from any language. +// [Cipher.Term] takes a context and returns an error from day one: term +// derivation may be a ZeroKMS round trip. +// +// # Transport and auth +// +// The guest imports exactly two host functions: an HTTP send, served by any +// [net/http.RoundTripper], and a bearer-token fetch, served by a +// [TokenSource]. What crosses per ZeroKMS call is what would cross TLS +// anyway; derived key material never leaves the guest. +package stackencrypt diff --git a/languages/golang/stackencrypt/errors.go b/languages/golang/stackencrypt/errors.go new file mode 100644 index 000000000..4856922d5 --- /dev/null +++ b/languages/golang/stackencrypt/errors.go @@ -0,0 +1,100 @@ +package stackencrypt + +import ( + "errors" + "fmt" +) + +// Failure kinds surfaced across the boundary. The guest reports a status +// code and nothing else, so these are the whole vocabulary: they separate a +// tampered ciphertext from a bad token from a malformed input, and reveal +// nothing about plaintext or key material. +var ( + // ErrAuthentication is an AEAD open failure: a tampered ciphertext, a + // wrong element derivation, or a wrong AAD that reached the AEAD. Against + // ZeroKMS a wrong AAD is usually refused earlier as ErrForbidden, because + // every data key is bound to its context. + ErrAuthentication = errors.New("stackencrypt: authentication failed") + // ErrEncoding is a malformed input: a value, ciphertext, plan, context, + // selector or config the guest refused before any cryptography. + ErrEncoding = errors.New("stackencrypt: malformed input") + // ErrState is a call on a client that has been closed. + ErrState = errors.New("stackencrypt: client is closed") + // ErrInternal is a guest panic or any other unexpected guest failure. + ErrInternal = errors.New("stackencrypt: internal guest failure") + // ErrUnauthorized is ZeroKMS refusing the bearer token (HTTP 401): the + // token is invalid, expired, or for another workspace. + ErrUnauthorized = errors.New("stackencrypt: ZeroKMS rejected the access token") + // ErrForbidden is ZeroKMS refusing the request (HTTP 403): the token is + // valid but not permitted, or a data key's bound context did not match + // the one presented — the production form of a wrong-AAD open. + ErrForbidden = errors.New("stackencrypt: ZeroKMS refused the request") + // ErrNotFound is ZeroKMS reporting a missing resource (HTTP 404): an + // unknown keyset name or id, or a data key that does not exist. + ErrNotFound = errors.New("stackencrypt: ZeroKMS resource not found") + // ErrConflict is ZeroKMS reporting a resource conflict (HTTP 409). + ErrConflict = errors.New("stackencrypt: ZeroKMS resource conflict") + // ErrTransport is a failure to reach ZeroKMS or to read its response: + // the transport returned an error, or the endpoint could not be resolved. + ErrTransport = errors.New("stackencrypt: ZeroKMS transport failed") + // ErrKMS is any other ZeroKMS failure: an unparseable response, invalid + // key material, or an unclassified server error. + ErrKMS = errors.New("stackencrypt: ZeroKMS request failed") + // ErrTerm is a term derivation the scheme could not perform for the + // given input, such as match text that yields no tokens. + ErrTerm = errors.New("stackencrypt: term derivation failed") + // ErrForeignKeyset is a keyset-bound Cipher refusing a ciphertext sealed + // under another keyset, before any key is retrieved. Open it through the + // Client, which is not bound to one keyset. + ErrForeignKeyset = errors.New("stackencrypt: ciphertext belongs to another keyset") +) + +// Guest status codes (guest/src/status.rs). Part of the guest/host +// contract; never renumbered. +const ( + statusAuth = 1 + statusEncoding = 2 + statusState = 3 + statusInternal = 4 + statusKMSUnauthorized = 5 + statusKMSForbidden = 6 + statusKMSNotFound = 7 + statusKMSConflict = 8 + statusKMSTransport = 9 + statusKMSOther = 10 + statusTerm = 11 + statusForeignKeyset = 12 +) + +func statusError(status uint32) error { + switch status { + case statusAuth: + return ErrAuthentication + case statusEncoding: + return ErrEncoding + case statusState: + return ErrState + case statusInternal: + return ErrInternal + case statusKMSUnauthorized: + return ErrUnauthorized + case statusKMSForbidden: + return ErrForbidden + case statusKMSNotFound: + return ErrNotFound + case statusKMSConflict: + return ErrConflict + case statusKMSTransport: + return ErrTransport + case statusKMSOther: + return ErrKMS + case statusTerm: + return ErrTerm + case statusForeignKeyset: + return ErrForeignKeyset + default: + // A status this host does not know is still an internal failure; + // the code is kept so a guest/host version skew is diagnosable. + return fmt.Errorf("%w (unrecognized guest status %d)", ErrInternal, status) + } +} diff --git a/languages/golang/stackencrypt/go.mod b/languages/golang/stackencrypt/go.mod new file mode 100644 index 000000000..a9f756451 --- /dev/null +++ b/languages/golang/stackencrypt/go.mod @@ -0,0 +1,11 @@ +module github.com/cipherstash/cipherstash-suite/bindings/go/stackencrypt + +go 1.25.0 + +require ( + github.com/cipherstash/vitaminc/bindings/go/vcffi v0.0.0-20260902024806-f2c7f7d17fbc + github.com/cipherstash/vitaminc/bindings/go/vcvalue v0.0.0-20260902024806-f2c7f7d17fbc + github.com/tetratelabs/wazero v1.12.0 +) + +require golang.org/x/sys v0.44.0 // indirect diff --git a/languages/golang/stackencrypt/go.sum b/languages/golang/stackencrypt/go.sum new file mode 100644 index 000000000..f24642b69 --- /dev/null +++ b/languages/golang/stackencrypt/go.sum @@ -0,0 +1,8 @@ +github.com/cipherstash/vitaminc/bindings/go/vcffi v0.0.0-20260902024806-f2c7f7d17fbc h1:iGbTgv9Kx5SaTI2tPxaxwDvS5BXZUO565N00cxRVwC8= +github.com/cipherstash/vitaminc/bindings/go/vcffi v0.0.0-20260902024806-f2c7f7d17fbc/go.mod h1:6jpaqAo6f7rjWuxoti0Ymoi9DQZyxpnieWVudhXv38Y= +github.com/cipherstash/vitaminc/bindings/go/vcvalue v0.0.0-20260902024806-f2c7f7d17fbc h1:vlrjoILAURGfpBWucVZEywK22k4lfky1xGDIKV6kqNg= +github.com/cipherstash/vitaminc/bindings/go/vcvalue v0.0.0-20260902024806-f2c7f7d17fbc/go.mod h1:RJODA1DCSm4H+1pbmCzDdT2pAIkxhinwIu6ewjh5sPs= +github.com/tetratelabs/wazero v1.12.0 h1:DuWcpNu/FzgEXgGBDp8J1Spc+CWOvvtvVyjKlaZopYU= +github.com/tetratelabs/wazero v1.12.0/go.mod h1:LvKtzl2RqO4gyF27BiXU+nKAjcV8f38U+kP/q2vgxh0= +golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ= +golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= diff --git a/languages/golang/stackencrypt/guest.go b/languages/golang/stackencrypt/guest.go new file mode 100644 index 000000000..7af3f736c --- /dev/null +++ b/languages/golang/stackencrypt/guest.go @@ -0,0 +1,212 @@ +package stackencrypt + +import ( + "context" + "embed" + "errors" + "fmt" + "sync" + + "github.com/tetratelabs/wazero" + "github.com/tetratelabs/wazero/api" + "github.com/tetratelabs/wazero/imports/wasi_snapshot_preview1" +) + +// The guest module is a build artefact of the Rust crate in ./guest, +// copied here by `mise run wasm:guest:build`. It is embedded as a +// directory so the package compiles without it; NewClient reports its +// absence. +// +//go:embed wasm +var guestFS embed.FS + +const guestPath = "wasm/stack_encrypt_guest.wasm" + +// ErrGuestNotBuilt is returned by NewClient when no guest module is +// embedded and none was supplied in Config.Guest. +var ErrGuestNotBuilt = errors.New("stackencrypt: guest module not built — run `mise run wasm:guest:build`") + +func embeddedGuest() ([]byte, error) { + wasm, err := guestFS.ReadFile(guestPath) + if err != nil { + return nil, ErrGuestNotBuilt + } + return wasm, nil +} + +// One shared compilation cache: only the first instantiation of a given +// module in the process compiles it. Every Client still owns its own +// runtime and instance. +var ( + cacheOnce sync.Once + sharedCache wazero.CompilationCache +) + +func compilationCache() wazero.CompilationCache { + cacheOnce.Do(func() { sharedCache = wazero.NewCompilationCache() }) + return sharedCache +} + +// instance is one instantiated guest with its exports resolved. It is +// the unsynchronised half of a Client; the Client serialises access. +type instance struct { + runtime wazero.Runtime + module api.Module + + alloc, dealloc api.Function + cipherInit, shutdown, keyset api.Function + encrypt, encryptElement api.Function + decrypt, decryptElement api.Function + term api.Function + encryptRecord, decryptRecord api.Function +} + +// newInstance instantiates wasm with the transport as its host module. +func newInstance(ctx context.Context, wasm []byte, t *transport) (*instance, error) { + // WithCloseOnContextDone lets a caller's deadline or cancellation + // interrupt an in-flight guest call — which otherwise holds the Client's + // lock against every other user. An interrupted call closes the module, + // so the Client is done afterwards; the alternative is a wedged process. + config := wazero.NewRuntimeConfig(). + WithCompilationCache(compilationCache()). + WithCloseOnContextDone(true) + runtime := wazero.NewRuntimeWithConfig(ctx, config) + wasi_snapshot_preview1.MustInstantiate(ctx, runtime) + if err := t.instantiate(ctx, runtime); err != nil { + _ = runtime.Close(ctx) + return nil, err + } + // The guest is a reactor (cdylib): no _start. wazero runs _initialize + // when present. + module, err := runtime.InstantiateWithConfig(ctx, wasm, wazero.NewModuleConfig().WithName("stack_encrypt_guest")) + if err != nil { + _ = runtime.Close(ctx) + return nil, fmt.Errorf("stackencrypt: instantiating guest: %w", err) + } + inst := &instance{runtime: runtime, module: module} + exports := map[string]*api.Function{ + "se_alloc": &inst.alloc, + "se_dealloc": &inst.dealloc, + "se_cipher_init": &inst.cipherInit, + "se_shutdown": &inst.shutdown, + "se_keyset": &inst.keyset, + "se_encrypt": &inst.encrypt, + "se_encrypt_element": &inst.encryptElement, + "se_decrypt": &inst.decrypt, + "se_decrypt_element": &inst.decryptElement, + "se_term": &inst.term, + "se_encrypt_record": &inst.encryptRecord, + "se_decrypt_record": &inst.decryptRecord, + } + for name, slot := range exports { + if *slot = module.ExportedFunction(name); *slot == nil { + _ = runtime.Close(ctx) + return nil, fmt.Errorf("stackencrypt: guest is missing export %s", name) + } + } + return inst, nil +} + +func (inst *instance) close(ctx context.Context) error { + return inst.runtime.Close(ctx) +} + +// guestBuf is a host-owned allocation inside guest linear memory. +type guestBuf struct { + ptr uint32 + len uint32 +} + +// allocWrite stages data into a fresh guest buffer. +func (inst *instance) allocWrite(ctx context.Context, data []byte) (guestBuf, error) { + res, err := inst.alloc.Call(ctx, uint64(len(data))) + if err != nil { + return guestBuf{}, fmt.Errorf("stackencrypt: guest alloc: %w", err) + } + buf := guestBuf{ptr: uint32(res[0]), len: uint32(len(data))} + if buf.ptr == 0 { + return guestBuf{}, errors.New("stackencrypt: guest allocation failed") + } + if len(data) > 0 && !inst.module.Memory().Write(buf.ptr, data) { + inst.free(ctx, buf) + return guestBuf{}, errors.New("stackencrypt: guest memory write out of range") + } + return buf, nil +} + +// free zeroizes and releases a guest buffer (se_dealloc wipes; an unknown +// pointer is a no-op there). +func (inst *instance) free(ctx context.Context, buf guestBuf) { + if buf.ptr != 0 { + _, _ = inst.dealloc.Call(ctx, uint64(buf.ptr), uint64(buf.len)) + } +} + +// packedResult decodes the guest's packed u64: a non-zero high half is an +// output pointer with the length in the low half; a zero high half carries +// a status code in the low half. +func packedResult(packed uint64) (guestBuf, error) { + if packed>>32 == 0 { + return guestBuf{}, statusError(uint32(packed)) + } + return guestBuf{ptr: uint32(packed >> 32), len: uint32(packed)}, nil +} + +// arg is one guest-call argument: a buffer (staged into guest memory and +// passed as a (ptr, len) pair) or a scalar passed as is. +type arg struct { + data []byte + scalar uint64 + isBuf bool +} + +func buf(data []byte) arg { return arg{data: data, isBuf: true} } +func scalar(v uint64) arg { return arg{scalar: v} } + +// call stages every buffer argument, calls fn with the arguments in +// order, and copies the output out before every buffer — inputs and output +// — is wiped and freed. +func (inst *instance) call(ctx context.Context, fn api.Function, args ...arg) ([]byte, error) { + var bufs []guestBuf + defer func() { + for _, b := range bufs { + inst.free(ctx, b) + } + }() + params := make([]uint64, 0, 2*len(args)) + for _, a := range args { + if !a.isBuf { + params = append(params, a.scalar) + continue + } + staged, err := inst.allocWrite(ctx, a.data) + if err != nil { + return nil, err + } + bufs = append(bufs, staged) + params = append(params, uint64(staged.ptr), uint64(staged.len)) + } + res, err := fn.Call(ctx, params...) + if err != nil { + return nil, fmt.Errorf("stackencrypt: guest call: %w", err) + } + out, cerr := packedResult(res[0]) + if cerr != nil { + return nil, cerr + } + bufs = append(bufs, out) + view, ok := inst.module.Memory().Read(out.ptr, out.len) + if !ok { + return nil, errors.New("stackencrypt: guest returned an out-of-range buffer") + } + // Copy out before the deferred free wipes the guest-side buffer. + result := make([]byte, len(view)) + copy(result, view) + return result, nil +} + +func wipe(b []byte) { + for i := range b { + b[i] = 0 + } +} diff --git a/languages/golang/stackencrypt/guest_test.go b/languages/golang/stackencrypt/guest_test.go new file mode 100644 index 000000000..53489c140 --- /dev/null +++ b/languages/golang/stackencrypt/guest_test.go @@ -0,0 +1,509 @@ +package stackencrypt + +import ( + "bytes" + "context" + "encoding/hex" + "errors" + "fmt" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/cipherstash/vitaminc/bindings/go/vcvalue" + "github.com/tetratelabs/wazero" +) + +// Tests that drive the embedded guest without a live ZeroKMS. What they +// pin, hermetically: +// +// - the module's import surface is exactly WASI plus the two transport +// functions; +// - the bridge issues the request ZeroKMS expects and maps every +// transport/HTTP outcome to the documented error; +// - every encoding this package builds — config, selectors, options, +// values, plans, sources, record trees, term inputs — is accepted by +// the guest's parsers. The guest validates all inputs before it +// consults its cipher, so on an instance that was never initialised a +// well-formed call is ErrState and a malformed one is ErrEncoding: the +// status tells which side of the boundary is wrong, with no key +// material involved; +// - hostile pointer/length pairs are statuses, never traps; +// - the client key does not survive in guest memory. +// +// Round trips through real key material need ZeroKMS and live in +// live_test.go (skipped without credentials; phase 5's harness runs them). + +const ( + testClientID = "6a70bd18-99ac-4650-b104-37eec3a15b09" + // A generated v1 client key (a recipher proxy keyset, CBOR, hex) with no + // ZeroKMS behind it: the guest parses it, and it is distinctive enough + // for the residency scan. + testClientKey = "a4627031a16b7065726d75746174696f6e90090a0d070806020f0e010503040b0c006770325f66726f6da16b7065726d75746174696f6e90000e08070c030a01050d06040f0b09026570325f746fa16b7065726d75746174696f6e9005030c0f060702000e010a0b0804090d627033a16b7065726d75746174696f6e982102010c0a182008181b061116120b070f10051509181c0d131403181a0e181d18180400181f17181e1819" +) + +func guestOrSkip(t *testing.T) []byte { + t.Helper() + wasm, err := embeddedGuest() + if err != nil { + t.Skipf("%v", err) + } + return wasm +} + +// zerokmsStub records the requests a client makes and answers them all with +// one canned response. +type zerokmsStub struct { + *httptest.Server + requests []stubRequest + status int + body string + // contentType "" means the header is absent (Go's sniffing suppressed). + contentType string +} + +type stubRequest struct { + method, path, auth, contentType, body string +} + +func newStub(t *testing.T, status int, contentType, body string) *zerokmsStub { + t.Helper() + s := &zerokmsStub{status: status, body: body, contentType: contentType} + s.Server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + b, _ := io.ReadAll(r.Body) + s.requests = append(s.requests, stubRequest{ + method: r.Method, path: r.URL.Path, auth: r.Header.Get("Authorization"), + contentType: r.Header.Get("Content-Type"), body: string(b), + }) + if s.contentType == "" { + w.Header()["Content-Type"] = nil + } else { + w.Header().Set("Content-Type", s.contentType) + } + w.WriteHeader(s.status) + _, _ = io.WriteString(w, s.body) + })) + t.Cleanup(s.Close) + return s +} + +func testConfig(url string) Config { + return Config{ + ClientID: testClientID, + ClientKey: testClientKey, + ZeroKMSURL: url, + Token: StaticToken("stub-token"), + } +} + +func TestImportSurfaceIsWASIPlusTransport(t *testing.T) { + ctx := context.Background() + r := wazero.NewRuntime(ctx) + defer r.Close(ctx) + compiled, err := r.CompileModule(ctx, guestOrSkip(t)) + if err != nil { + t.Fatal(err) + } + defer compiled.Close(ctx) + var transportImports []string + for _, imp := range compiled.ImportedFunctions() { + module, name, _ := imp.Import() + switch module { + case "wasi_snapshot_preview1": + for _, denied := range []string{"path_", "sock_", "fd_prestat"} { + if strings.HasPrefix(name, denied) { + t.Errorf("guest imports capability-granting WASI function %s", name) + } + } + case transportModule: + transportImports = append(transportImports, name) + default: + t.Errorf("guest imports %s::%s, outside the allowed surface", module, name) + } + } + want := []string{"token_get", "transport_send"} + if len(transportImports) != 2 || (transportImports[0] != want[0] && transportImports[0] != want[1]) { + t.Fatalf("transport imports = %v, want %v", transportImports, want) + } + for name := range map[string]bool{"se_alloc": true, "se_dealloc": true, "se_cipher_init": true, "se_shutdown": true, "se_keyset": true, "se_encrypt": true, "se_decrypt": true, "se_encrypt_element": true, "se_decrypt_element": true, "se_term": true, "se_encrypt_record": true, "se_decrypt_record": true} { + if _, ok := compiled.ExportedFunctions()[name]; !ok { + t.Errorf("guest does not export %s", name) + } + } +} + +func TestNewClientIssuesTheLoadKeysetRequest(t *testing.T) { + guestOrSkip(t) + stub := newStub(t, http.StatusUnauthorized, "", "nope") + _, err := NewClient(context.Background(), testConfig(stub.URL)) + if !errors.Is(err, ErrUnauthorized) { + t.Fatalf("NewClient: %v, want ErrUnauthorized", err) + } + if len(stub.requests) != 1 { + t.Fatalf("requests = %d, want 1 (one load-keyset)", len(stub.requests)) + } + req := stub.requests[0] + if req.method != http.MethodPost || req.auth != "Bearer stub-token" || req.contentType != "application/json" { + t.Errorf("request = %+v", req) + } + if !strings.HasSuffix(req.path, "load-keyset") { + t.Errorf("path = %q, want a load-keyset endpoint", req.path) + } + if !strings.HasPrefix(req.body, "{") { + t.Errorf("body %q is not JSON", req.body) + } + if strings.Contains(req.body, testClientKey) { + t.Error("the client key was sent over the wire") + } +} + +func TestTransportOutcomesMapToErrors(t *testing.T) { + guestOrSkip(t) + cases := []struct { + name string + status int + contentType string + body string + want error + }{ + {"401", http.StatusUnauthorized, "", "nope", ErrUnauthorized}, + {"403", http.StatusForbidden, "", "not permitted", ErrForbidden}, + {"404", http.StatusNotFound, "", "missing", ErrNotFound}, + {"409", http.StatusConflict, "", "exists", ErrConflict}, + {"500", http.StatusInternalServerError, "", "boom", ErrKMS}, + {"200 html", http.StatusOK, "text/html", "<html>gateway</html>", ErrKMS}, + {"200 not json", http.StatusOK, "application/json", "not json", ErrKMS}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + stub := newStub(t, tc.status, tc.contentType, tc.body) + _, err := NewClient(context.Background(), testConfig(stub.URL)) + if !errors.Is(err, tc.want) { + t.Fatalf("NewClient: %v, want %v", err, tc.want) + } + }) + } + t.Run("connection refused", func(t *testing.T) { + stub := newStub(t, http.StatusOK, "application/json", "{}") + url := stub.URL + stub.Close() + _, err := NewClient(context.Background(), testConfig(url)) + if !errors.Is(err, ErrTransport) { + t.Fatalf("NewClient: %v, want ErrTransport", err) + } + }) + t.Run("no token", func(t *testing.T) { + stub := newStub(t, http.StatusOK, "application/json", "{}") + cfg := testConfig(stub.URL) + cfg.Token = TokenFunc(func(context.Context) (string, error) { return "", errors.New("vault down") }) + _, err := NewClient(context.Background(), cfg) + if err == nil { + t.Fatal("NewClient succeeded with no token") + } + if len(stub.requests) != 0 { + t.Fatalf("a request was made without a token: %+v", stub.requests) + } + }) +} + +func TestRoundTripperFailureIsTransport(t *testing.T) { + guestOrSkip(t) + cfg := testConfig("http://zerokms.invalid") + cfg.Transport = roundTripFunc(func(*http.Request) (*http.Response, error) { + return nil, errors.New("no route") + }) + _, err := NewClient(context.Background(), cfg) + if !errors.Is(err, ErrTransport) { + t.Fatalf("NewClient: %v, want ErrTransport", err) + } +} + +type roundTripFunc func(*http.Request) (*http.Response, error) + +func (f roundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) } + +func TestConfigValidation(t *testing.T) { + ctx := context.Background() + for name, cfg := range map[string]Config{ + "no token": {ClientID: testClientID, ClientKey: testClientKey}, + "no client id": {ClientKey: testClientKey, Token: StaticToken("t")}, + "no key": {ClientID: testClientID, Token: StaticToken("t")}, + "negative cache": {ClientID: testClientID, ClientKey: testClientKey, Token: StaticToken("t"), + KeysetCacheSize: -1}, + "any keyset as default": {ClientID: testClientID, ClientKey: testClientKey, Token: StaticToken("t"), + Keyset: anyKeyset{}}, + } { + if _, err := NewClient(ctx, cfg); err == nil { + t.Errorf("%s: NewClient succeeded", name) + } + } + // Malformed values the guest refuses: no request is made. + guestOrSkip(t) + for name, mutate := range map[string]func(*Config){ + "client id not a uuid": func(c *Config) { c.ClientID = "acme" }, + "key not hex": func(c *Config) { c.ClientKey = "zz" }, + "bad url": func(c *Config) { c.ZeroKMSURL = "not a url" }, + "name with spaces": func(c *Config) { c.Keyset = KeysetName("not a name") }, + } { + stub := newStub(t, http.StatusOK, "application/json", "{}") + cfg := testConfig(stub.URL) + mutate(&cfg) + _, err := NewClient(ctx, cfg) + if !errors.Is(err, ErrEncoding) { + t.Errorf("%s: %v, want ErrEncoding", name, err) + } + if len(stub.requests) != 0 { + t.Errorf("%s: a request was made for a malformed config", name) + } + } +} + +// rawInstance is a guest that was never initialised: every well-formed +// operation is ErrState there, every malformed one ErrEncoding. +func rawInstance(t *testing.T) *Client { + t.Helper() + ctx := context.Background() + inst, err := newInstance(ctx, guestOrSkip(t), &transport{rt: http.DefaultTransport, token: StaticToken("t")}) + if err != nil { + t.Fatal(err) + } + c := &Client{inst: inst, transport: nil} + t.Cleanup(func() { _ = c.Close(context.Background()) }) + return c +} + +// A structurally valid stack-encrypt leaf (the frozen layout: version, +// keyset id, IV, tag length, tag, ciphertext) with no real key behind it. +var fixtureLeaf = mustHex("016b65797365742d666978747572653136303132333435363738396162636465660300aabbccdeadbeef") + +func mustHex(s string) []byte { + b, err := hex.DecodeString(s) + if err != nil { + panic(err) + } + return b +} + +type recordRow struct { + Age uint32 `stash:"context=users/age,index=eq;ore"` + Email string `stash:"context=users/email,index=eq;match"` +} + +// Every encoding the package builds reaches the guest's own parsers and +// passes them: the uninitialised instance answers ErrState only after it +// has validated all inputs. +func TestGuestAcceptsEveryEncodingThisPackageBuilds(t *testing.T) { + ctx := context.Background() + c := rawInstance(t) + def := c.DefaultCipher() + named := c.Cipher(KeysetName("acme")) + byID := c.Cipher(KeysetID{9}) + ct := map[string]any{"name": Sealed(fixtureLeaf), "note": vcvalue.Plain{V: "clear"}} + record := EncryptedRecord{ + "Age": {Ciphertext: Sealed(fixtureLeaf), Equality: EqualityTerm{1}, Ore: OreTerm{2}}, + "Email": {Ciphertext: Sealed(fixtureLeaf)}, + } + rows := []recordRow{{Age: 1, Email: "a@b.c"}} + var out []recordRow + var one recordRow + calls := map[string]func() error{ + "Keyset by name": func() error { _, err := c.Keyset(ctx, KeysetName("acme")); return err }, + "Keyset by id": func() error { _, err := c.Keyset(ctx, KeysetID{9}); return err }, + "Keyset default": func() error { _, err := c.Keyset(ctx, DefaultKeyset); return err }, + "Encrypt": func() error { _, err := def.Encrypt(ctx, map[string]any{"a": 1}, []byte("aad")); return err }, + "EncryptElement": func() error { _, err := named.EncryptElement(ctx, "row", nil); return err }, + "Decrypt bound": func() error { _, err := byID.Decrypt(ctx, ct, nil); return err }, + "Decrypt any": func() error { _, err := c.Decrypt(ctx, ct, []byte("aad")); return err }, + "DecryptElement any": func() error { _, err := c.DecryptElement(ctx, Sealed(fixtureLeaf), nil); return err }, + "Term equality": func() error { _, err := def.Term(ctx, uint32(34), MustContext("users/age"), Equality); return err }, + "Term match": func() error { _, err := named.Term(ctx, "alice", MustContext("users/email"), Match); return err }, + "Term ore extended": func() error { + c, _ := MustContext("users/age").With(uint64(7)) + _, err := byID.Term(ctx, 1.5, c, Ore) + return err + }, + "Term ope bytes": func() error { _, err := def.Term(ctx, []byte{1}, MustContext("k"), Ope); return err }, + "EncryptRecords": func() error { _, err := def.EncryptRecords(ctx, rows); return err }, + "EncryptRecords ext": func() error { _, err := named.EncryptRecords(ctx, &rows, ExtendContext(uint64(7), "eu")); return err }, + "EncryptRecord": func() error { _, err := byID.EncryptRecord(ctx, rows[0]); return err }, + "DecryptRecords bound": func() error { return def.DecryptRecords(ctx, []EncryptedRecord{record}, &out) }, + "DecryptRecords any": func() error { return c.DecryptRecords(ctx, []EncryptedRecord{record, record}, &out) }, + "DecryptRecord any": func() error { return c.DecryptRecord(ctx, record, &one, ExtendContext("x")) }, + } + for name, call := range calls { + if err := call(); !errors.Is(err, ErrState) { + t.Errorf("%s: %v, want ErrState (every input parsed, no cipher)", name, err) + } + } +} + +// The inputs the guest must refuse are refused before it looks for a +// cipher: ErrEncoding, not ErrState, on the same uninitialised instance. +func TestGuestRefusesMalformedInputsBeforeState(t *testing.T) { + ctx := context.Background() + c := rawInstance(t) + def := c.DefaultCipher() + type badRow struct { + Age float64 `stash:"context=users/age,index=eq"` + } + calls := map[string]func() error{ + "float under equality": func() error { _, err := def.Term(ctx, 1.5, MustContext("k"), Equality); return err }, + "integer under match": func() error { _, err := def.Term(ctx, 1, MustContext("k"), Match); return err }, + "container as term value": func() error { _, err := def.Term(ctx, []any{1}, MustContext("k"), Ore); return err }, + "empty context part": func() error { _, err := def.Term(ctx, 1, MustContext(""), Equality); return err }, + "unknown term kind": func() error { _, err := def.Term(ctx, 1, MustContext("k"), TermKind(9)); return err }, + "name with spaces": func() error { _, err := c.Keyset(ctx, KeysetName("not a name")); return err }, + "empty name": func() error { _, err := c.Keyset(ctx, KeysetName("")); return err }, + "any as a keyset": func() error { _, err := c.Keyset(ctx, anyKeyset{}); return err }, + "float under eq in plan": func() error { _, err := def.EncryptRecords(ctx, []badRow{{1.5}}); return err }, + "malformed leaf": func() error { + _, err := c.Decrypt(ctx, Sealed{1, 2, 3}, nil) + return err + }, + "record without c": func() error { + return c.DecryptRecord(ctx, EncryptedRecord{"Age": {Equality: EqualityTerm{1}}, "Email": {Ciphertext: Sealed(fixtureLeaf)}}, new(recordRow)) + }, + } + for name, call := range calls { + err := call() + if errors.Is(err, ErrState) { + t.Errorf("%s: reached the cipher (ErrState); must be refused at parse", name) + } else if err == nil { + t.Errorf("%s: accepted", name) + } + } +} + +func TestClosedClientIsState(t *testing.T) { + ctx := context.Background() + c := rawInstance(t) + if err := c.Close(ctx); err != nil { + t.Fatal(err) + } + if err := c.Close(ctx); err != nil { + t.Fatalf("second Close: %v", err) + } + if _, err := c.DefaultCipher().Encrypt(ctx, "x", nil); !errors.Is(err, ErrState) { + t.Fatalf("Encrypt after Close: %v", err) + } +} + +func TestHostilePointerLengthPairsAreStatusesNotTraps(t *testing.T) { + ctx := context.Background() + c := rawInstance(t) + inst := c.inst + // A null pointer with a nonzero length must fail closed. + res, err := inst.cipherInit.Call(ctx, 0, 64) + if err != nil { + t.Fatalf("init with null pointer trapped: %v", err) + } + if _, cerr := packedResult(res[0]); !errors.Is(cerr, ErrEncoding) { + t.Fatalf("null pointer: %v, want ErrEncoding", cerr) + } + staged, err := inst.allocWrite(ctx, bytes.Repeat([]byte{0x2a}, 64)) + if err != nil { + t.Fatal(err) + } + defer inst.free(ctx, staged) + for _, hostile := range []uint64{0x7FFF_FFF0, 0xFFFF_FFFF} { + for name, fn := range map[string]func() ([]uint64, error){ + "se_cipher_init": func() ([]uint64, error) { return inst.cipherInit.Call(ctx, uint64(staged.ptr), hostile) }, + "se_keyset": func() ([]uint64, error) { return inst.keyset.Call(ctx, uint64(staged.ptr), hostile) }, + "se_encrypt": func() ([]uint64, error) { + return inst.encrypt.Call(ctx, uint64(staged.ptr), hostile, 0, 0, uint64(staged.ptr), 4) + }, + } { + res, err := fn() + if err != nil { + t.Fatalf("%s with len %#x trapped: %v", name, hostile, err) + } + if _, cerr := packedResult(res[0]); !errors.Is(cerr, ErrEncoding) { + t.Errorf("%s with len %#x: %v, want ErrEncoding", name, hostile, cerr) + } + } + } + // An unknown or mismatched free is a no-op, not a trap. + if _, err := inst.dealloc.Call(ctx, uint64(staged.ptr)+1, 1); err != nil { + t.Fatalf("dealloc of an unknown pointer trapped: %v", err) + } + if _, err := inst.dealloc.Call(ctx, uint64(staged.ptr), 1); err != nil { + t.Fatalf("dealloc with a mismatched length trapped: %v", err) + } + // The instance still works. + if _, err := c.DefaultCipher().Encrypt(ctx, "alive", nil); !errors.Is(err, ErrState) { + t.Fatalf("instance poisoned: %v", err) + } +} + +// The client key crosses into guest memory once, in the config buffer, +// which the guest wipes before any request; the Go-side transport copy is +// wiped too. Neither the hex form nor its decoded bytes may remain in +// linear memory after NewClient returns — success or failure. +func TestClientKeyDoesNotRemainInGuestMemory(t *testing.T) { + guestOrSkip(t) + stub := newStub(t, http.StatusUnauthorized, "", "nope") + // Keep the instance to scan it: build the client by hand so a failed + // init does not tear it down first. + ctx := context.Background() + encoded, err := encodeConfig(testConfig(stub.URL)) + if err != nil { + t.Fatal(err) + } + tr := &transport{rt: http.DefaultTransport, token: StaticToken("stub-token")} + inst, err := newInstance(ctx, guestOrSkip(t), tr) + if err != nil { + t.Fatal(err) + } + defer inst.close(ctx) + _, err = inst.call(ctx, inst.cipherInit, buf(encoded)) + if !errors.Is(err, ErrUnauthorized) { + t.Fatalf("init: %v", err) + } + mem := inst.module.Memory() + view, ok := mem.Read(0, mem.Size()) + if !ok { + t.Fatal("cannot read guest memory") + } + for name, needle := range map[string][]byte{ + "key hex": []byte(testClientKey), + "key bytes": mustHex(testClientKey), + "bearer": []byte("stub-token"), + } { + if n := bytes.Count(view, needle); n != 0 { + t.Errorf("%s found %d times in guest memory after init", name, n) + } + } +} + +func TestTransportSendCounterAndResponseHeaders(t *testing.T) { + guestOrSkip(t) + stub := newStub(t, http.StatusUnauthorized, "text/plain", "nope") + tr := &transport{rt: http.DefaultTransport, token: StaticToken("stub-token")} + ctx := context.Background() + inst, err := newInstance(ctx, guestOrSkip(t), tr) + if err != nil { + t.Fatal(err) + } + defer inst.close(ctx) + encoded, _ := encodeConfig(testConfig(stub.URL)) + if _, err := inst.call(ctx, inst.cipherInit, buf(encoded)); !errors.Is(err, ErrUnauthorized) { + t.Fatalf("init: %v", err) + } + if n := tr.sends.Load(); n != 1 { + t.Fatalf("transport sends = %d, want 1", n) + } +} + +func ExampleNewClient() { + // A client needs ZeroKMS credentials; see live_test.go for the shape of + // a real round trip. + _, err := NewClient(context.Background(), Config{ + ClientID: "6a70bd18-99ac-4650-b104-37eec3a15b09", + ClientKey: "...", + Token: StaticToken("access token"), + }) + fmt.Println(err != nil) + // Output: true +} diff --git a/languages/golang/stackencrypt/keyset.go b/languages/golang/stackencrypt/keyset.go new file mode 100644 index 000000000..2d58cca72 --- /dev/null +++ b/languages/golang/stackencrypt/keyset.go @@ -0,0 +1,82 @@ +package stackencrypt + +import ( + "encoding/hex" + "errors" + "fmt" +) + +// KeysetSelector names the keyset a call binds to. The three selectors are +// [KeysetName], [KeysetID] and [DefaultKeyset]; every variant is spelled, +// there is no empty-string or nil sentinel. Names follow ZeroKMS's rules +// (non-empty, at most 64 bytes, of A-Z a-z 0-9 _ - /), checked by the +// guest before any request is made; ZeroKMS itself never issues a +// UUID-shaped name, so a name and an id cannot be confused. +type KeysetSelector interface { + // selector renders the tagged object the guest parses. + selector() map[string]any +} + +// KeysetName selects a keyset by name. Its first use on a client is one +// ZeroKMS round trip; the binding is cached by the guest for a bounded +// window, after which the name is resolved again. +type KeysetName string + +func (n KeysetName) selector() map[string]any { return map[string]any{"name": string(n)} } + +// KeysetID is a keyset's UUID, the identity a sealed leaf carries. It +// selects a keyset by id; ids are never re-resolved. +type KeysetID [16]byte + +func (id KeysetID) selector() map[string]any { return map[string]any{"id": id[:]} } + +// String renders the id in canonical hyphenated form. +func (id KeysetID) String() string { + var b [36]byte + hex.Encode(b[:8], id[:4]) + b[8] = '-' + hex.Encode(b[9:13], id[4:6]) + b[13] = '-' + hex.Encode(b[14:18], id[6:8]) + b[18] = '-' + hex.Encode(b[19:23], id[8:10]) + b[23] = '-' + hex.Encode(b[24:], id[10:]) + return string(b[:]) +} + +// ParseKeysetID parses a canonical hyphenated UUID. +func ParseKeysetID(s string) (KeysetID, error) { + var id KeysetID + if len(s) != 36 || s[8] != '-' || s[13] != '-' || s[18] != '-' || s[23] != '-' { + return id, fmt.Errorf("stackencrypt: %q is not a UUID", s) + } + hexed := s[:8] + s[9:13] + s[14:18] + s[19:23] + s[24:] + if _, err := hex.Decode(id[:], []byte(hexed)); err != nil { + return id, fmt.Errorf("stackencrypt: %q is not a UUID", s) + } + return id, nil +} + +type defaultKeyset struct{} + +func (defaultKeyset) selector() map[string]any { return map[string]any{"default": map[string]any{}} } + +// DefaultKeyset selects the client's default keyset: the one named in +// [Config.Keyset], else the ZeroKMS client's own default. Selecting it is +// never a round trip. +var DefaultKeyset KeysetSelector = defaultKeyset{} + +// anyKeyset is the opening-only selector: open every leaf under whichever +// keyset it was sealed with. Not exported — the Client's own decrypt +// methods are its spelling. +type anyKeyset struct{} + +func (anyKeyset) selector() map[string]any { return map[string]any{"any": map[string]any{}} } + +// options renders the per-call options object the guest parses. +func options(sel KeysetSelector) map[string]any { + return map[string]any{"keyset": sel.selector()} +} + +var errNilSelector = errors.New("stackencrypt: keyset selector is nil") diff --git a/languages/golang/stackencrypt/leaf.go b/languages/golang/stackencrypt/leaf.go new file mode 100644 index 000000000..702dbbb66 --- /dev/null +++ b/languages/golang/stackencrypt/leaf.go @@ -0,0 +1,122 @@ +package stackencrypt + +import ( + "database/sql/driver" + "fmt" + + "github.com/cipherstash/vitaminc/bindings/go/vcffi" +) + +// Sealed is one encrypted leaf: the frozen stack-encrypt storage encoding +// (version, keyset id, IV, ZeroKMS tag, ciphertext), exactly what a +// database column holds. It is a distinct type from vcvalue.Sealed on +// purpose: a stack-encrypt leaf is not decryptable by vitaminc-encrypt and +// must never scan or marshal where one belongs. +type Sealed []byte + +// SealedNone is the authenticated marker for an absent value (a nil +// pointer, a Null) inside a ciphertext. +type SealedNone []byte + +// SealedEmptySeq is the authenticated marker for an empty sequence. +type SealedEmptySeq []byte + +// SealedEmptyMap is the authenticated marker for an empty map. +type SealedEmptyMap []byte + +// Value implements driver.Valuer, binding the leaf as a byte column. +func (s Sealed) Value() (driver.Value, error) { return []byte(s), nil } + +// Value implements driver.Valuer. +func (s SealedNone) Value() (driver.Value, error) { return []byte(s), nil } + +// Value implements driver.Valuer. +func (s SealedEmptySeq) Value() (driver.Value, error) { return []byte(s), nil } + +// Value implements driver.Valuer. +func (s SealedEmptyMap) Value() (driver.Value, error) { return []byte(s), nil } + +// Scan implements sql.Scanner, loading a leaf from a byte column. +func (s *Sealed) Scan(src any) error { + b, err := scanBytes("Sealed", src) + *s = b + return err +} + +// Scan implements sql.Scanner. +func (s *SealedNone) Scan(src any) error { + b, err := scanBytes("SealedNone", src) + *s = b + return err +} + +// Scan implements sql.Scanner. +func (s *SealedEmptySeq) Scan(src any) error { + b, err := scanBytes("SealedEmptySeq", src) + *s = b + return err +} + +// Scan implements sql.Scanner. +func (s *SealedEmptyMap) Scan(src any) error { + b, err := scanBytes("SealedEmptyMap", src) + *s = b + return err +} + +// scanBytes copies a driver byte value: drivers may reuse the source slice +// after Scan returns. +func scanBytes(kind string, src any) ([]byte, error) { + switch v := src.(type) { + case []byte: + out := make([]byte, len(v)) + copy(out, v) + return out, nil + case string: + return []byte(v), nil + case nil: + return nil, fmt.Errorf("stackencrypt: cannot scan NULL into %s", kind) + default: + return nil, fmt.Errorf("stackencrypt: cannot scan %T into %s", src, kind) + } +} + +// leaves is the vcffi.LeafSet of this binding's leaf types. +var leaves = vcffi.LeafSet{ + Classify: func(v any) (vcffi.LeafKind, []byte, bool) { + switch n := v.(type) { + case Sealed: + return vcffi.LeafSingle, n, true + case SealedNone: + return vcffi.LeafNone, n, true + case SealedEmptySeq: + return vcffi.LeafEmptySeq, n, true + case SealedEmptyMap: + return vcffi.LeafEmptyMap, n, true + default: + return 0, nil, false + } + }, + Make: func(kind vcffi.LeafKind, bytes []byte) any { + switch kind { + case vcffi.LeafSingle: + return Sealed(bytes) + case vcffi.LeafNone: + return SealedNone(bytes) + case vcffi.LeafEmptySeq: + return SealedEmptySeq(bytes) + case vcffi.LeafEmptyMap: + return SealedEmptyMap(bytes) + default: + return nil + } + }, +} + +func marshalCipherText(v any) ([]byte, error) { + return vcffi.MarshalCipherText(leaves, v) +} + +func unmarshalCipherText(buf []byte) (any, error) { + return vcffi.UnmarshalCipherText(leaves, buf) +} diff --git a/languages/golang/stackencrypt/live_test.go b/languages/golang/stackencrypt/live_test.go new file mode 100644 index 000000000..3207d0dd4 --- /dev/null +++ b/languages/golang/stackencrypt/live_test.go @@ -0,0 +1,165 @@ +package stackencrypt + +import ( + "context" + "errors" + "os" + "reflect" + "testing" + + "github.com/cipherstash/vitaminc/bindings/go/vcvalue" +) + +// Round trips through real ZeroKMS key material. Run by the phase 5 +// harness (`mise run test:integration:wasi-go`), which boots zerokms-server +// and exports the four variables below; skipped otherwise. + +func liveClient(t *testing.T) *Client { + t.Helper() + guestOrSkip(t) + clientID, clientKey := os.Getenv("STACK_ENCRYPT_TEST_CLIENT_ID"), os.Getenv("STACK_ENCRYPT_TEST_CLIENT_KEY") + token, url := os.Getenv("STACK_ENCRYPT_TEST_ACCESS_TOKEN"), os.Getenv("STACK_ENCRYPT_TEST_ZEROKMS_URL") + if clientID == "" || clientKey == "" || token == "" { + t.Skip("STACK_ENCRYPT_TEST_{CLIENT_ID,CLIENT_KEY,ACCESS_TOKEN} not set") + } + c, err := NewClient(t.Context(), Config{ + ClientID: clientID, ClientKey: clientKey, ZeroKMSURL: url, Token: StaticToken(token), + }) + if err != nil { + t.Fatalf("NewClient: %v", err) + } + t.Cleanup(func() { _ = c.Close(context.Background()) }) + return c +} + +type liveUser struct { + ID int64 `stash:"-"` + Age uint32 `stash:"context=users/age,index=eq;ore"` + Email string `stash:"context=users/email,index=eq;match"` +} + +func TestLiveValueRoundTrip(t *testing.T) { + c := liveClient(t) + ctx := t.Context() + cipher := c.DefaultCipher() + aad := []byte("users/v1") + in := map[string]any{"name": "alice", "age": uint32(34), "note": vcvalue.Plain{V: "clear"}} + + c.transport.sends.Store(0) + ct, err := cipher.Encrypt(ctx, in, aad) + if err != nil { + t.Fatal(err) + } + if n := c.transport.sends.Load(); n != 1 { + t.Errorf("encrypt made %d ZeroKMS calls, want 1", n) + } + fields := ct.(map[string]any) + if _, ok := fields["name"].(Sealed); !ok { + t.Fatalf("name sealed as %T", fields["name"]) + } + if fields["note"] != (vcvalue.Plain{V: "clear"}) { + t.Fatalf("passthrough came back as %v", fields["note"]) + } + + for name, open := range map[string]func() (any, error){ + "bound": func() (any, error) { return cipher.Decrypt(ctx, ct, aad) }, + "client": func() (any, error) { return c.Decrypt(ctx, ct, aad) }, + } { + pt, err := open() + if err != nil { + t.Fatalf("%s decrypt: %v", name, err) + } + want := vcvalue.Object{{Key: "age", Value: uint32(34)}, {Key: "name", Value: "alice"}, {Key: "note", Value: vcvalue.Plain{V: "clear"}}} + if !reflect.DeepEqual(pt, want) { + t.Fatalf("%s decrypt = %#v", name, pt) + } + } + if _, err := cipher.Decrypt(ctx, ct, []byte("wrong")); err == nil { + t.Fatal("wrong AAD decrypted") + } + // The default keyset's id is what the leaves carry: the bound cipher of + // that id opens them too. + if _, err := c.Cipher(c.DefaultKeysetID()).Decrypt(ctx, ct, aad); err != nil { + t.Fatalf("decrypt under the default keyset by id: %v", err) + } +} + +func TestLiveRecordsAndTerms(t *testing.T) { + c := liveClient(t) + ctx := t.Context() + cipher := c.DefaultCipher() + users := []liveUser{{1, 34, "alice@example.com"}, {2, 29, "bob@example.com"}} + + c.transport.sends.Store(0) + records, err := cipher.EncryptRecords(ctx, users) + if err != nil { + t.Fatal(err) + } + if n := c.transport.sends.Load(); n != 1 { + t.Errorf("EncryptRecords made %d ZeroKMS calls for %d rows, want 1", n, len(users)) + } + if len(records) != 2 || len(records[0]["Age"].Equality) != 32 || records[0]["Email"].Match == nil || records[0]["Age"].Ore == nil { + t.Fatalf("records = %+v", records) + } + + probe, err := cipher.Term(ctx, uint32(34), MustContext("users/age"), Equality) + if err != nil { + t.Fatal(err) + } + if !probe.(EqualityTerm).Equal(records[0]["Age"].Equality) { + t.Error("probe does not equal the stored equality term") + } + if probe.(EqualityTerm).Equal(records[1]["Age"].Equality) { + t.Error("probe equals another value's term") + } + + var back []liveUser + if err := cipher.DecryptRecords(ctx, records, &back); err != nil { + t.Fatal(err) + } + for i := range users { + users[i].ID = 0 // not part of the record + } + if !reflect.DeepEqual(back, users) { + t.Fatalf("decrypted %+v, want %+v", back, users) + } + var one liveUser + if err := c.DecryptRecord(ctx, records[1], &one); err != nil || one.Email != "bob@example.com" { + t.Fatalf("DecryptRecord: %v %+v", err, one) + } + + // A context extension is part of the identity. + ext, err := cipher.EncryptRecords(ctx, users, ExtendContext(uint64(7))) + if err != nil { + t.Fatal(err) + } + if err := cipher.DecryptRecords(ctx, ext, &back); !errors.Is(err, ErrForbidden) && !errors.Is(err, ErrAuthentication) { + t.Fatalf("extended record opened without its extension: %v", err) + } + if err := cipher.DecryptRecords(ctx, ext, &back, ExtendContext(uint64(7))); err != nil { + t.Fatalf("extended record with its extension: %v", err) + } +} + +func TestLiveForeignKeysetIsRefusedBeforeRetrieval(t *testing.T) { + c := liveClient(t) + ctx := t.Context() + other := os.Getenv("STACK_ENCRYPT_TEST_OTHER_KEYSET") + if other == "" { + t.Skip("STACK_ENCRYPT_TEST_OTHER_KEYSET not set") + } + ct, err := c.Cipher(KeysetName(other)).Encrypt(ctx, "tenant b", nil) + if err != nil { + t.Fatal(err) + } + c.transport.sends.Store(0) + if _, err := c.DefaultCipher().Decrypt(ctx, ct, nil); !errors.Is(err, ErrForeignKeyset) { + t.Fatalf("default cipher opened another keyset's leaf: %v", err) + } + if n := c.transport.sends.Load(); n != 0 { + t.Errorf("a foreign leaf cost %d ZeroKMS calls before refusal", n) + } + if pt, err := c.Decrypt(ctx, ct, nil); err != nil || pt != "tenant b" { + t.Fatalf("client decrypt of the other keyset: %v %v", pt, err) + } +} diff --git a/languages/golang/stackencrypt/record.go b/languages/golang/stackencrypt/record.go new file mode 100644 index 000000000..6dc60e793 --- /dev/null +++ b/languages/golang/stackencrypt/record.go @@ -0,0 +1,519 @@ +package stackencrypt + +import ( + "context" + "errors" + "fmt" + "reflect" + "strings" + "sync" + + "github.com/cipherstash/vitaminc/bindings/go/vcffi" + "github.com/cipherstash/vitaminc/bindings/go/vcvalue" +) + +// Record plans from struct tags — the Go stand-in for the Rust derive. +// +// A struct field's `stash` tag says what to do with it: +// +// type User struct { +// ID int64 `stash:"-"` // not sent to the guest +// Age uint32 `stash:"context=users/age,index=eq;ore"` // sealed + equality and ORE terms +// Email string `stash:"context=users/email,index=eq;match"` // sealed + equality and match terms +// Notes string `stash:"context=users/notes"` // sealed only +// } +// +// Options are comma-separated: `context=<part>` (required for a planned +// field — the field's own context, a string part), `index=<kind>[;<kind>]` +// (eq, match, ore, ope), and `name=<wire name>` (the record key; the Go +// field name otherwise). A field tagged `-` or `plain`, or not tagged at +// all, is not part of the record: it never crosses the boundary, and stays +// the caller's to store. Unexported fields are ignored. +// +// Every planned field is sealed (the `"c"` output) and the plan is built +// once per type. The context each field binds is its tag's part, extended +// by [ExtendContext] parts exactly as the Rust derive extends a field's +// context by the caller's: NewContext(tag).With(p1).With(p2). + +// EncryptedField is one field's outputs from EncryptRecords: the sealed +// ciphertext and whichever index terms the plan asked for (nil otherwise). +type EncryptedField struct { + // Ciphertext is the field's sealed value: a Sealed leaf for a scalar, + // or the same nested shape Cipher.Encrypt returns for a composite. + Ciphertext any + Equality EqualityTerm + Match MatchTerm + Ore OreTerm + Ope OpeTerm +} + +// EncryptedRecord is one record's planned fields, by wire name. +type EncryptedRecord map[string]EncryptedField + +// RecordOption adjusts how a record call binds its fields. +type RecordOption func(*recordOptions) + +type recordOptions struct { + extension []any +} + +// ExtendContext extends every field's context by parts, in order, the way +// the Rust derive extends a field's context by the caller's +// (encrypt_into_with_context): a field tagged context=users/age with +// ExtendContext(uint64(7)) binds ["users/age", 7]. The same extension must +// be given to decrypt the records. +func ExtendContext(parts ...any) RecordOption { + return func(o *recordOptions) { o.extension = append(o.extension, parts...) } +} + +// fieldPlan is one planned struct field. +type fieldPlan struct { + index int // struct field index + name string // wire name + context string // the field's own context part + outputs []string +} + +var plans sync.Map // reflect.Type → []fieldPlan + +// planFor parses (and caches) a struct type's plan. +func planFor(t reflect.Type) ([]fieldPlan, error) { + if cached, ok := plans.Load(t); ok { + return cached.([]fieldPlan), nil + } + if t.Kind() != reflect.Struct { + return nil, fmt.Errorf("stackencrypt: records must be structs, not %s", t) + } + var plan []fieldPlan + seen := map[string]bool{} + for i := 0; i < t.NumField(); i++ { + f := t.Field(i) + if !f.IsExported() { + continue + } + tag, ok := f.Tag.Lookup("stash") + if !ok || tag == "-" || tag == "plain" { + continue + } + fp := fieldPlan{index: i, name: f.Name, outputs: []string{"c"}} + for _, opt := range strings.Split(tag, ",") { + key, value, _ := strings.Cut(opt, "=") + switch key { + case "context": + if value == "" { + return nil, fmt.Errorf("stackencrypt: field %s.%s: context must not be empty", t, f.Name) + } + fp.context = value + case "name": + if value == "" { + return nil, fmt.Errorf("stackencrypt: field %s.%s: name must not be empty", t, f.Name) + } + fp.name = value + case "index": + for _, k := range strings.Split(value, ";") { + kind, ok := parseTermKind(k) + if !ok { + return nil, fmt.Errorf("stackencrypt: field %s.%s: unknown index kind %q", t, f.Name, k) + } + fp.outputs = append(fp.outputs, kind.String()) + } + default: + return nil, fmt.Errorf("stackencrypt: field %s.%s: unknown stash tag option %q", t, f.Name, opt) + } + } + if fp.context == "" { + return nil, fmt.Errorf("stackencrypt: field %s.%s: a planned field needs context=", t, f.Name) + } + if seen[fp.name] { + return nil, fmt.Errorf("stackencrypt: %s: two fields share the record name %q", t, fp.name) + } + seen[fp.name] = true + plan = append(plan, fp) + } + if len(plan) == 0 { + return nil, fmt.Errorf("stackencrypt: %s has no fields tagged for encryption", t) + } + plans.Store(t, plan) + return plan, nil +} + +// planValue renders the plan object for the guest, each field's context +// extended by the options. +func planValue(plan []fieldPlan, opts recordOptions) (vcvalue.Object, error) { + out := make(vcvalue.Object, 0, len(plan)) + for _, f := range plan { + ctx, err := NewContext(f.context) + if err != nil { + return nil, err + } + for _, part := range opts.extension { + if ctx, err = ctx.With(part); err != nil { + return nil, err + } + } + outputs := make([]any, len(f.outputs)) + for i, o := range f.outputs { + outputs[i] = o + } + out = append(out, vcvalue.Field{Key: f.name, Value: vcvalue.Object{ + {Key: "context", Value: ctx.value()}, + {Key: "outputs", Value: outputs}, + }}) + } + return out, nil +} + +func applyOptions(opts []RecordOption) recordOptions { + var o recordOptions + for _, opt := range opts { + opt(&o) + } + return o +} + +// EncryptRecords seals every row of a slice of structs (or a pointer to +// one) per the struct's `stash` tags: all rows and fields from one batched +// ZeroKMS key request, terms derived under this keyset's index key. One +// EncryptedRecord per row, in order. +func (cph *Cipher) EncryptRecords(ctx context.Context, rows any, opts ...RecordOption) ([]EncryptedRecord, error) { + v := reflect.Indirect(reflect.ValueOf(rows)) + if v.Kind() != reflect.Slice { + return nil, fmt.Errorf("stackencrypt: EncryptRecords takes a slice of structs, not %T", rows) + } + plan, err := planFor(v.Type().Elem()) + if err != nil { + return nil, err + } + source := make([]any, v.Len()) + for i := range source { + source[i] = sourceRow(v.Index(i), plan) + } + tree, err := cph.encryptRecords(ctx, plan, source, applyOptions(opts)) + if err != nil { + return nil, err + } + items, ok := tree.([]any) + if !ok || len(items) != v.Len() { + return nil, fmt.Errorf("%w: record batch came back as %T", ErrInternal, tree) + } + out := make([]EncryptedRecord, len(items)) + for i, item := range items { + if out[i], err = encryptedRecordOf(item); err != nil { + return nil, err + } + } + return out, nil +} + +// EncryptRecord seals one struct (or a pointer to one) per its `stash` +// tags; see EncryptRecords. +func (cph *Cipher) EncryptRecord(ctx context.Context, row any, opts ...RecordOption) (EncryptedRecord, error) { + v := reflect.Indirect(reflect.ValueOf(row)) + plan, err := planFor(v.Type()) + if err != nil { + return nil, err + } + tree, err := cph.encryptRecords(ctx, plan, sourceRow(v, plan), applyOptions(opts)) + if err != nil { + return nil, err + } + return encryptedRecordOf(tree) +} + +func sourceRow(row reflect.Value, plan []fieldPlan) vcvalue.Object { + out := make(vcvalue.Object, 0, len(plan)) + for _, f := range plan { + out = append(out, vcvalue.Field{Key: f.name, Value: row.Field(f.index).Interface()}) + } + return out +} + +func (cph *Cipher) encryptRecords(ctx context.Context, plan []fieldPlan, source any, o recordOptions) (any, error) { + planObj, err := planValue(plan, o) + if err != nil { + return nil, err + } + encodedSource, err := vcffi.Marshal(source) + if err != nil { + return nil, err + } + defer wipe(encodedSource) + encodedPlan, err := vcffi.Marshal(planObj) + if err != nil { + return nil, err + } + opts, err := vcffi.Marshal(options(cph.keyset)) + if err != nil { + return nil, err + } + out, err := cph.client.call(ctx, func(inst *instance) ([]byte, error) { + return inst.call(ctx, inst.encryptRecord, buf(encodedSource), buf(encodedPlan), buf(opts)) + }) + if err != nil { + return nil, err + } + return unmarshalCipherText(out) +} + +// encryptedRecordOf lifts one decoded record node into an EncryptedRecord. +func encryptedRecordOf(node any) (EncryptedRecord, error) { + fields, ok := node.(map[string]any) + if !ok { + return nil, fmt.Errorf("%w: record came back as %T", ErrInternal, node) + } + rec := make(EncryptedRecord, len(fields)) + for name, outputs := range fields { + om, ok := outputs.(map[string]any) + if !ok { + return nil, fmt.Errorf("%w: field %q came back as %T", ErrInternal, name, outputs) + } + var f EncryptedField + for key, out := range om { + if key == "c" { + f.Ciphertext = out + continue + } + term, err := termBytes(out) + if err != nil { + return nil, fmt.Errorf("%w: field %q output %q: %v", ErrInternal, name, key, err) + } + switch key { + case "eq": + f.Equality = term + case "match": + f.Match = term + case "ore": + f.Ore = term + case "ope": + f.Ope = term + default: + return nil, fmt.Errorf("%w: field %q has unknown output %q", ErrInternal, name, key) + } + } + rec[name] = f + } + return rec, nil +} + +// termBytes unwraps a term node: a passthrough carrying the term's bytes. +func termBytes(node any) ([]byte, error) { + plain, ok := node.(vcvalue.Plain) + if !ok { + return nil, fmt.Errorf("term node is %T, not a passthrough", node) + } + b, ok := plain.V.([]byte) + if !ok { + return nil, fmt.Errorf("term payload is %T, not bytes", plain.V) + } + return b, nil +} + +// DecryptRecords opens records produced by EncryptRecords under this keyset +// (a record from another keyset is ErrForeignKeyset) into out, a pointer to +// a slice of the same struct type, one element per record. Only the sealed +// outputs participate; terms are one-way. Fields the plan does not name are +// left as they are. +func (cph *Cipher) DecryptRecords(ctx context.Context, records []EncryptedRecord, out any, opts ...RecordOption) error { + return cph.client.decryptRecords(ctx, cph.keyset, records, out, opts) +} + +// DecryptRecord opens one record into out, a pointer to a struct; see +// DecryptRecords. +func (cph *Cipher) DecryptRecord(ctx context.Context, record EncryptedRecord, out any, opts ...RecordOption) error { + return cph.client.decryptRecord(ctx, cph.keyset, record, out, opts) +} + +func (c *Client) decryptRecords(ctx context.Context, sel KeysetSelector, records []EncryptedRecord, out any, opts []RecordOption) error { + ptr := reflect.ValueOf(out) + if ptr.Kind() != reflect.Pointer || ptr.IsNil() || ptr.Elem().Kind() != reflect.Slice { + return fmt.Errorf("stackencrypt: DecryptRecords writes into a pointer to a slice of structs, not %T", out) + } + elem := ptr.Elem().Type().Elem() + plan, err := planFor(elem) + if err != nil { + return err + } + tree := make([]any, len(records)) + for i, rec := range records { + if tree[i], err = recordTree(rec, plan); err != nil { + return err + } + } + values, err := c.decryptRecordTree(ctx, sel, plan, tree, applyOptions(opts)) + if err != nil { + return err + } + items, ok := values.([]any) + if !ok || len(items) != len(records) { + return fmt.Errorf("%w: record batch decrypted as %T", ErrInternal, values) + } + slice := reflect.MakeSlice(ptr.Elem().Type(), len(items), len(items)) + for i, item := range items { + if err := assignRecord(slice.Index(i), item, plan); err != nil { + return err + } + } + ptr.Elem().Set(slice) + return nil +} + +func (c *Client) decryptRecord(ctx context.Context, sel KeysetSelector, record EncryptedRecord, out any, opts []RecordOption) error { + ptr := reflect.ValueOf(out) + if ptr.Kind() != reflect.Pointer || ptr.IsNil() || ptr.Elem().Kind() != reflect.Struct { + return fmt.Errorf("stackencrypt: DecryptRecord writes into a pointer to a struct, not %T", out) + } + plan, err := planFor(ptr.Elem().Type()) + if err != nil { + return err + } + tree, err := recordTree(record, plan) + if err != nil { + return err + } + value, err := c.decryptRecordTree(ctx, sel, plan, tree, applyOptions(opts)) + if err != nil { + return err + } + return assignRecord(ptr.Elem(), value, plan) +} + +// recordTree renders the ciphertext tree the guest opens: per planned +// field, its "c" output. Terms are not sent. +func recordTree(rec EncryptedRecord, plan []fieldPlan) (map[string]any, error) { + tree := make(map[string]any, len(plan)) + for _, f := range plan { + field, ok := rec[f.name] + if !ok || field.Ciphertext == nil { + return nil, fmt.Errorf("stackencrypt: record has no ciphertext for field %q", f.name) + } + tree[f.name] = map[string]any{"c": field.Ciphertext} + } + return tree, nil +} + +func (c *Client) decryptRecordTree(ctx context.Context, sel KeysetSelector, plan []fieldPlan, tree any, o recordOptions) (any, error) { + planObj, err := planValue(plan, o) + if err != nil { + return nil, err + } + encodedTree, err := marshalCipherText(tree) + if err != nil { + return nil, err + } + encodedPlan, err := vcffi.Marshal(planObj) + if err != nil { + return nil, err + } + opts, err := vcffi.Marshal(options(sel)) + if err != nil { + return nil, err + } + out, err := c.call(ctx, func(inst *instance) ([]byte, error) { + return inst.call(ctx, inst.decryptRecord, buf(encodedTree), buf(encodedPlan), buf(opts)) + }) + if err != nil { + return nil, err + } + defer wipe(out) + return vcffi.Unmarshal(out) +} + +// assignRecord writes a decrypted record (a vcvalue.Object of the plan's +// fields) into a struct value. +func assignRecord(target reflect.Value, value any, plan []fieldPlan) error { + obj, ok := value.(vcvalue.Object) + if !ok { + return fmt.Errorf("%w: record decrypted as %T", ErrInternal, value) + } + byName := make(map[string]any, len(obj)) + for _, f := range obj { + byName[f.Key] = f.Value + } + for _, f := range plan { + v, ok := byName[f.name] + if !ok { + return fmt.Errorf("%w: decrypted record lacks field %q", ErrInternal, f.name) + } + if err := assignField(target.Field(f.index), v); err != nil { + return fmt.Errorf("stackencrypt: field %q: %w", f.name, err) + } + } + return nil +} + +var errUnassignable = errors.New("cannot assign decrypted value") + +// assignField sets a struct field from a decoded value, converting within +// a numeric family when the value fits and refusing anything lossy. +func assignField(field reflect.Value, v any) error { + if v == nil { + field.Set(reflect.Zero(field.Type())) + return nil + } + if field.Kind() == reflect.Pointer { + elem := reflect.New(field.Type().Elem()) + if err := assignField(elem.Elem(), v); err != nil { + return err + } + field.Set(elem) + return nil + } + rv := reflect.ValueOf(v) + if rv.Type().AssignableTo(field.Type()) { + field.Set(rv) + return nil + } + switch field.Kind() { + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + var n int64 + switch x := v.(type) { + case int32: + n = int64(x) + case int64: + n = x + default: + return fmt.Errorf("%w: %T into %s", errUnassignable, v, field.Type()) + } + if field.OverflowInt(n) { + return fmt.Errorf("%w: %d overflows %s", errUnassignable, n, field.Type()) + } + field.SetInt(n) + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: + var n uint64 + switch x := v.(type) { + case uint32: + n = uint64(x) + case uint64: + n = x + default: + return fmt.Errorf("%w: %T into %s", errUnassignable, v, field.Type()) + } + if field.OverflowUint(n) { + return fmt.Errorf("%w: %d overflows %s", errUnassignable, n, field.Type()) + } + field.SetUint(n) + case reflect.Float32, reflect.Float64: + var f float64 + switch x := v.(type) { + case float32: + f = float64(x) + case float64: + f = x + default: + return fmt.Errorf("%w: %T into %s", errUnassignable, v, field.Type()) + } + if field.OverflowFloat(f) { + return fmt.Errorf("%w: %v overflows %s", errUnassignable, f, field.Type()) + } + field.SetFloat(f) + case reflect.String: + s, ok := v.(string) + if !ok { + return fmt.Errorf("%w: %T into %s", errUnassignable, v, field.Type()) + } + field.SetString(s) + default: + return fmt.Errorf("%w: %T into %s", errUnassignable, v, field.Type()) + } + return nil +} diff --git a/languages/golang/stackencrypt/term.go b/languages/golang/stackencrypt/term.go new file mode 100644 index 000000000..93fee0f8d --- /dev/null +++ b/languages/golang/stackencrypt/term.go @@ -0,0 +1,131 @@ +package stackencrypt + +import ( + "crypto/subtle" + "database/sql/driver" + "encoding/binary" + "fmt" +) + +// TermKind selects which index term a probe or a plan field derives. The +// values are the guest's term-kind codes. +type TermKind uint32 + +const ( + // Equality is a PRF equality term: 32 bytes, compared with + // [EqualityTerm.Equal]. Defined for integers, strings and bytes. + Equality TermKind = 1 + // Match is a full-text match term: the tokenized positions of a string, + // as little-endian uint16s. Strings only. + Match TermKind = 2 + // Ore is an order-revealing (CLLW ORE) term over any scalar. + Ore TermKind = 3 + // Ope is an order-preserving (CLLW OPE) term over any scalar. + Ope TermKind = 4 +) + +func (k TermKind) String() string { + switch k { + case Equality: + return "eq" + case Match: + return "match" + case Ore: + return "ore" + case Ope: + return "ope" + default: + return fmt.Sprintf("TermKind(%d)", uint32(k)) + } +} + +// parseTermKind maps a plan-tag spelling to its kind. +func parseTermKind(s string) (TermKind, bool) { + switch s { + case "eq": + return Equality, true + case "match": + return Match, true + case "ore": + return Ore, true + case "ope": + return Ope, true + default: + return 0, false + } +} + +// EqualityTerm is a PRF equality term. Two terms derived under the same +// keyset and context from equal values are equal bytes; nothing else about +// the value is revealed. +type EqualityTerm []byte + +// Equal compares two equality terms in constant time. +func (t EqualityTerm) Equal(other EqualityTerm) bool { + return subtle.ConstantTimeCompare(t, other) == 1 +} + +// MatchTerm is a full-text match term: the positions of the value's tokens +// in the keyset's token space, as little-endian uint16s. +type MatchTerm []byte + +// Positions decodes the term into its token positions. +func (t MatchTerm) Positions() ([]uint16, error) { + if len(t)%2 != 0 { + return nil, fmt.Errorf("stackencrypt: match term of %d bytes is not a whole number of positions", len(t)) + } + out := make([]uint16, len(t)/2) + for i := range out { + out[i] = binary.LittleEndian.Uint16(t[2*i:]) + } + return out, nil +} + +// OreTerm is an order-revealing term (CLLW ORE): the raw term bytes. The +// comparison is the database's (EQL's ORE operators); this binding does not +// compare terms in Go. +type OreTerm []byte + +// OpeTerm is an order-preserving term (CLLW OPE): the raw term bytes, +// ordered as the values they encode. +type OpeTerm []byte + +// Value implements driver.Valuer. +func (t EqualityTerm) Value() (driver.Value, error) { return []byte(t), nil } + +// Value implements driver.Valuer. +func (t MatchTerm) Value() (driver.Value, error) { return []byte(t), nil } + +// Value implements driver.Valuer. +func (t OreTerm) Value() (driver.Value, error) { return []byte(t), nil } + +// Value implements driver.Valuer. +func (t OpeTerm) Value() (driver.Value, error) { return []byte(t), nil } + +// Scan implements sql.Scanner. +func (t *EqualityTerm) Scan(src any) error { + b, err := scanBytes("EqualityTerm", src) + *t = b + return err +} + +// Scan implements sql.Scanner. +func (t *MatchTerm) Scan(src any) error { + b, err := scanBytes("MatchTerm", src) + *t = b + return err +} + +// Scan implements sql.Scanner. +func (t *OreTerm) Scan(src any) error { + b, err := scanBytes("OreTerm", src) + *t = b + return err +} + +// Scan implements sql.Scanner. +func (t *OpeTerm) Scan(src any) error { + b, err := scanBytes("OpeTerm", src) + *t = b + return err +} diff --git a/languages/golang/stackencrypt/transport.go b/languages/golang/stackencrypt/transport.go new file mode 100644 index 000000000..932071537 --- /dev/null +++ b/languages/golang/stackencrypt/transport.go @@ -0,0 +1,195 @@ +package stackencrypt + +import ( + "bytes" + "context" + "fmt" + "io" + "net/http" + "sort" + "strings" + "sync/atomic" + + "github.com/tetratelabs/wazero" + "github.com/tetratelabs/wazero/api" +) + +// transportModule is the name of the guest's one import module. Its two +// functions are the whole host surface the guest can reach. +const transportModule = "cipherstash_transport" + +// TokenSource supplies the bearer token the guest presents to ZeroKMS. It +// is asked on every request, so a source that rotates tokens needs no +// re-initialisation of the client. Minting and refresh stay host-side; a +// future token strategy running inside the guest is an additive change to +// [Config], not to this interface. +type TokenSource interface { + Token(ctx context.Context) (string, error) +} + +// TokenFunc adapts a function to a [TokenSource]. +type TokenFunc func(ctx context.Context) (string, error) + +// Token implements TokenSource. +func (f TokenFunc) Token(ctx context.Context) (string, error) { return f(ctx) } + +// StaticToken is a [TokenSource] that always returns the same token. +func StaticToken(token string) TokenSource { + return TokenFunc(func(context.Context) (string, error) { return token, nil }) +} + +// transport implements the guest's two host imports over a RoundTripper +// and a TokenSource. One per Client; it is bound to the module at +// instantiation and reaches the guest's allocator through the module the +// call arrives on. +type transport struct { + rt http.RoundTripper + token TokenSource + // sends counts transport_send excursions, so tests can pin the batching + // contract (one ZeroKMS call per operation) instead of trusting it. + sends atomic.Int64 +} + +// transportFailed is the return value of transport_send when the request +// could not be performed at all; the body then carries the error text. +const transportFailed int32 = -1 + +// hostFailed is the return value of token_get when no token is available. +const hostFailed int32 = 1 + +// instantiate registers the host module in r. +func (t *transport) instantiate(ctx context.Context, r wazero.Runtime) error { + _, err := r.NewHostModuleBuilder(transportModule). + NewFunctionBuilder().WithFunc(t.send).Export("transport_send"). + NewFunctionBuilder().WithFunc(t.tokenGet).Export("token_get"). + Instantiate(ctx) + if err != nil { + return fmt.Errorf("stackencrypt: instantiating host transport: %w", err) + } + return nil +} + +// send is transport_send: one HTTP request on the guest's behalf. Inputs +// are (ptr, len) pairs borrowed for the call; the two outputs are slot +// pairs filled with buffers obtained from the guest's se_alloc. Returns +// the HTTP status, or transportFailed with the error text as the body. +func (t *transport) send(ctx context.Context, m api.Module, + methodPtr, methodLen, urlPtr, urlLen, headersPtr, headersLen, bodyPtr, bodyLen uint32, + respHeadersPtrOut, respHeadersLenOut, respBodyPtrOut, respBodyLenOut uint32, +) int32 { + t.sends.Add(1) + mem := m.Memory() + status, respHeaders, respBody := t.perform(ctx, mem, + methodPtr, methodLen, urlPtr, urlLen, headersPtr, headersLen, bodyPtr, bodyLen) + if !place(ctx, m, respHeadersPtrOut, respHeadersLenOut, respHeaders) || + !place(ctx, m, respBodyPtrOut, respBodyLenOut, respBody) { + // The guest reclaims whatever was placed and refuses an unplaced + // slot as an unregistered buffer; nothing more this side can do. + return transportFailed + } + return status +} + +func (t *transport) perform(ctx context.Context, mem api.Memory, + methodPtr, methodLen, urlPtr, urlLen, headersPtr, headersLen, bodyPtr, bodyLen uint32, +) (int32, []byte, []byte) { + method, ok1 := mem.Read(methodPtr, methodLen) + url, ok2 := mem.Read(urlPtr, urlLen) + headers, ok3 := mem.Read(headersPtr, headersLen) + body, ok4 := mem.Read(bodyPtr, bodyLen) + if !ok1 || !ok2 || !ok3 || !ok4 { + return transportFailed, nil, []byte("guest request buffers out of range") + } + // The request body may carry key-material contexts; it is copied + // because the guest wipes its own buffer when the call returns, and the + // RoundTripper may read it after this function has. + reqBody := make([]byte, len(body)) + copy(reqBody, body) + req, err := http.NewRequestWithContext(ctx, string(method), string(url), bytes.NewReader(reqBody)) + if err != nil { + return transportFailed, nil, []byte(err.Error()) + } + req.Header = parseHeaders(headers) + resp, err := t.rt.RoundTrip(req) + if err != nil { + return transportFailed, nil, []byte(err.Error()) + } + defer resp.Body.Close() + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return transportFailed, nil, []byte(err.Error()) + } + return int32(resp.StatusCode), encodeHeaders(resp.Header), respBody +} + +// tokenGet is token_get: hand the guest the current bearer token. +func (t *transport) tokenGet(ctx context.Context, m api.Module, tokenPtrOut, tokenLenOut uint32) int32 { + token, err := t.token.Token(ctx) + if err != nil || token == "" { + return hostFailed + } + if !place(ctx, m, tokenPtrOut, tokenLenOut, []byte(token)) { + return hostFailed + } + return 0 +} + +// place allocates a guest buffer through the module's own se_alloc, writes +// data into it, and stores its (ptr, len) into the out-slots. The guest +// reclaims the buffer through its registry. Re-entering the guest through +// se_alloc during a host import is the one re-entry the ABI permits. +func place(ctx context.Context, m api.Module, ptrOut, lenOut uint32, data []byte) bool { + alloc := m.ExportedFunction("se_alloc") + if alloc == nil { + return false + } + res, err := alloc.Call(ctx, uint64(len(data))) + if err != nil { + return false + } + ptr := uint32(res[0]) + if ptr == 0 { + return false + } + mem := m.Memory() + if len(data) > 0 && !mem.Write(ptr, data) { + return false + } + return mem.WriteUint32Le(ptrOut, ptr) && mem.WriteUint32Le(lenOut, uint32(len(data))) +} + +// parseHeaders decodes the guest's `name: value` line format. Malformed +// lines are skipped, as the guest skips them in the other direction. +func parseHeaders(buf []byte) http.Header { + h := http.Header{} + for _, line := range strings.Split(string(buf), "\n") { + name, value, ok := strings.Cut(line, ":") + if !ok { + continue + } + h.Add(strings.TrimSpace(name), strings.TrimSpace(value)) + } + return h +} + +// encodeHeaders renders response headers in the guest's line format, in +// a deterministic order, one line per value. +func encodeHeaders(h http.Header) []byte { + names := make([]string, 0, len(h)) + for name := range h { + names = append(names, name) + } + sort.Strings(names) + var out strings.Builder + for _, name := range names { + for _, value := range h[name] { + if out.Len() > 0 { + out.WriteByte('\n') + } + out.WriteString(name) + out.WriteString(": ") + out.WriteString(value) + } + } + return []byte(out.String()) +} diff --git a/languages/golang/stackencrypt/unit_test.go b/languages/golang/stackencrypt/unit_test.go new file mode 100644 index 000000000..f5737707a --- /dev/null +++ b/languages/golang/stackencrypt/unit_test.go @@ -0,0 +1,280 @@ +package stackencrypt + +import ( + "errors" + "net/http" + "reflect" + "testing" + + "github.com/cipherstash/vitaminc/bindings/go/vcffi" + "github.com/cipherstash/vitaminc/bindings/go/vcvalue" +) + +// Pure Go: no guest needed. + +func TestKeysetIDRoundTripsCanonicalForm(t *testing.T) { + const s = "6a70bd18-99ac-4650-b104-37eec3a15b09" + id, err := ParseKeysetID(s) + if err != nil { + t.Fatal(err) + } + if got := id.String(); got != s { + t.Fatalf("String() = %q, want %q", got, s) + } + for _, bad := range []string{"", "6a70bd18", "6a70bd18-99ac-4650-b104-37eec3a15b0g", "6a70bd1899ac4650b10437eec3a15b09"} { + if _, err := ParseKeysetID(bad); err == nil { + t.Errorf("ParseKeysetID(%q) accepted", bad) + } + } +} + +func TestSelectorsSpellEveryVariant(t *testing.T) { + id := KeysetID{1, 2, 3} + cases := []struct { + sel KeysetSelector + want map[string]any + }{ + {DefaultKeyset, map[string]any{"default": map[string]any{}}}, + {KeysetName("acme"), map[string]any{"name": "acme"}}, + {id, map[string]any{"id": id[:]}}, + {anyKeyset{}, map[string]any{"any": map[string]any{}}}, + } + for _, tc := range cases { + if got := tc.sel.selector(); !reflect.DeepEqual(got, tc.want) { + t.Errorf("%T: got %v, want %v", tc.sel, got, tc.want) + } + if _, err := vcffi.Marshal(options(tc.sel)); err != nil { + t.Errorf("%T options do not marshal: %v", tc.sel, err) + } + } +} + +func TestContextNestsToTheLeft(t *testing.T) { + c := MustContext("users/age") + if got := c.value(); got != "users/age" { + t.Fatalf("bare part = %v", got) + } + c, err := c.With(uint64(7)) + if err != nil { + t.Fatal(err) + } + c, err = c.With("eu") + if err != nil { + t.Fatal(err) + } + want := []any{[]any{"users/age", uint64(7)}, "eu"} + if got := c.value(); !reflect.DeepEqual(got, want) { + t.Fatalf("With chain = %v, want %v", got, want) + } + for _, bad := range []any{1.5, true, nil, []any{"x"}, map[string]any{}} { + if _, err := NewContext(bad); err == nil { + t.Errorf("NewContext(%T) accepted", bad) + } + } + if _, err := (Context{}).With("x"); err == nil { + t.Error("an empty context extended") + } +} + +type taggedUser struct { + ID int64 `stash:"-"` + Age uint32 `stash:"context=users/age,index=eq;ore"` + Email string `stash:"context=users/email,index=eq;match,name=email"` + Notes string `stash:"context=users/notes"` + Plain string `stash:"plain"` + NoTag string + hidden string `stash:"context=x"` //nolint:unused // proves unexported fields are skipped +} + +func TestPlanFromTags(t *testing.T) { + plan, err := planFor(reflect.TypeOf(taggedUser{})) + if err != nil { + t.Fatal(err) + } + want := []fieldPlan{ + {index: 1, name: "Age", context: "users/age", outputs: []string{"c", "eq", "ore"}}, + {index: 2, name: "email", context: "users/email", outputs: []string{"c", "eq", "match"}}, + {index: 3, name: "Notes", context: "users/notes", outputs: []string{"c"}}, + } + if !reflect.DeepEqual(plan, want) { + t.Fatalf("plan = %+v\nwant %+v", plan, want) + } + + obj, err := planValue(plan, recordOptions{extension: []any{uint64(7)}}) + if err != nil { + t.Fatal(err) + } + age := obj[0].Value.(vcvalue.Object) + if got := age[0].Value; !reflect.DeepEqual(got, []any{"users/age", uint64(7)}) { + t.Fatalf("extended context = %v", got) + } + if _, err := vcffi.Marshal(obj); err != nil { + t.Fatalf("plan does not marshal: %v", err) + } + + for name, bad := range map[string]any{ + "no context": struct { + A int `stash:"index=eq"` + }{}, + "unknown kind": struct { + A int `stash:"context=c,index=fuzzy"` + }{}, + "unknown option": struct { + A int `stash:"context=c,store=true"` + }{}, + "empty context": struct { + A int `stash:"context="` + }{}, + "nothing tagged": struct{ A int }{}, + "not a struct": 42, + "duplicate name": struct { + A int `stash:"context=c,name=x"` + B int `stash:"context=c,name=x"` + }{}, + } { + if _, err := planFor(reflect.TypeOf(bad)); err == nil { + t.Errorf("%s: plan accepted", name) + } + } +} + +func TestAssignFieldConvertsWithinFamiliesOnly(t *testing.T) { + type row struct { + I int + U8 uint8 + F float32 + S string + B []byte + P *int64 + Bad bool + } + var r row + rv := reflect.ValueOf(&r).Elem() + must := func(field string, v any) { + t.Helper() + if err := assignField(rv.FieldByName(field), v); err != nil { + t.Fatalf("%s <- %T: %v", field, v, err) + } + } + must("I", int64(-5)) + must("U8", uint32(200)) + must("F", float32(1.5)) + must("S", "s") + must("B", []byte{1}) + must("P", int64(9)) + if r.I != -5 || r.U8 != 200 || r.F != 1.5 || r.S != "s" || string(r.B) != "\x01" || *r.P != 9 { + t.Fatalf("assigned %+v", r) + } + for _, bad := range []struct { + field string + v any + }{ + {"U8", uint32(300)}, // overflow + {"I", uint64(1)}, // family + {"S", int64(1)}, // kind + {"Bad", "true"}, // unsupported target + {"I", float64(1)}, // family + } { + if err := assignField(rv.FieldByName(bad.field), bad.v); !errors.Is(err, errUnassignable) { + t.Errorf("%s <- %v: got %v, want errUnassignable", bad.field, bad.v, err) + } + } +} + +func TestHeadersRoundTrip(t *testing.T) { + h := http.Header{} + h.Add("Content-Type", "application/json") + h.Add("X-Multi", "a") + h.Add("X-Multi", "b") + buf := encodeHeaders(h) + if string(buf) != "Content-Type: application/json\nX-Multi: a\nX-Multi: b" { + t.Fatalf("encoded %q", buf) + } + back := parseHeaders(append([]byte("garbage line\n"), buf...)) + if got := back.Get("content-type"); got != "application/json" { + t.Fatalf("parsed content-type %q", got) + } + if got := back.Values("X-Multi"); !reflect.DeepEqual(got, []string{"a", "b"}) { + t.Fatalf("parsed multi %v", got) + } +} + +func TestTermsAndLeavesScanAndValue(t *testing.T) { + var eq EqualityTerm + if err := eq.Scan([]byte{1, 2}); err != nil || !eq.Equal(EqualityTerm{1, 2}) { + t.Fatalf("scan/equal: %v %v", err, eq) + } + if eq.Equal(EqualityTerm{1, 3}) { + t.Fatal("unequal terms compared equal") + } + var m MatchTerm = []byte{1, 0, 2, 0} + pos, err := m.Positions() + if err != nil || !reflect.DeepEqual(pos, []uint16{1, 2}) { + t.Fatalf("positions %v %v", pos, err) + } + if _, err := (MatchTerm{1}).Positions(); err == nil { + t.Fatal("odd match term accepted") + } + var s Sealed + if err := s.Scan(nil); err == nil { + t.Fatal("NULL scanned into Sealed") + } + if err := s.Scan("ab"); err != nil || string(s) != "ab" { + t.Fatalf("string scan: %v %q", err, s) + } + src := []byte{7} + if err := s.Scan(src); err != nil { + t.Fatal(err) + } + src[0] = 8 + if s[0] != 7 { + t.Fatal("Scan aliased the driver's slice") + } + v, err := s.Value() + if err != nil || string(v.([]byte)) != "\x07" { + t.Fatalf("Value: %v %v", v, err) + } +} + +func TestLeafSetKeepsStackEncryptLeavesDistinct(t *testing.T) { + ct := map[string]any{ + "a": Sealed{1}, + "b": SealedNone{2}, + "c": SealedEmptySeq{3}, + "d": SealedEmptyMap{4}, + "p": vcvalue.Plain{V: "clear"}, + } + encoded, err := marshalCipherText(ct) + if err != nil { + t.Fatal(err) + } + back, err := unmarshalCipherText(encoded) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(back, ct) { + t.Fatalf("round trip %#v", back) + } + // A vitaminc leaf is not a stack-encrypt node. + if _, err := marshalCipherText(map[string]any{"a": vcvalue.Sealed{1}}); err == nil { + t.Fatal("vcvalue.Sealed accepted as a stack-encrypt leaf") + } + if _, err := vcffi.MarshalCipherText(vcffi.VCValueLeaves(), Sealed{1}); err == nil { + t.Fatal("stackencrypt.Sealed accepted as a vitaminc leaf") + } +} + +func TestStatusMappingIsTotal(t *testing.T) { + for status, want := range map[uint32]error{ + 1: ErrAuthentication, 2: ErrEncoding, 3: ErrState, 4: ErrInternal, + 5: ErrUnauthorized, 6: ErrForbidden, 7: ErrNotFound, 8: ErrConflict, + 9: ErrTransport, 10: ErrKMS, 11: ErrTerm, 12: ErrForeignKeyset, + } { + if got := statusError(status); !errors.Is(got, want) { + t.Errorf("status %d: %v", status, got) + } + } + if got := statusError(99); !errors.Is(got, ErrInternal) { + t.Errorf("unknown status: %v", got) + } +} diff --git a/languages/golang/stackencrypt/wasm/README.md b/languages/golang/stackencrypt/wasm/README.md new file mode 100644 index 000000000..9cd0785f8 --- /dev/null +++ b/languages/golang/stackencrypt/wasm/README.md @@ -0,0 +1,6 @@ +# Guest module + +`stack_encrypt_guest.wasm` is a build artefact of the Rust crate in +`../guest`, copied here by `mise run wasm:guest:build`. It is not committed; +the Go package embeds this directory and reports `ErrGuestNotBuilt` from +`NewClient` when the module is absent, and its tests skip. From 0692efab2befb0094dca046ea9506ebb37fccf10 Mon Sep 17 00:00:00 2001 From: Dan Draper <dan@cipherstash.com> Date: Sat, 12 Sep 2026 12:40:25 -0400 Subject: [PATCH 541/686] feat(stack-encrypt): order ORE and OPE terms in Go OreTerm.Compare / Less port cllw-ore's compare_slice and compare_lex: the first differing byte pair is found and judged in constant time (crypto/subtle selects), the side one greater mod 256 holds the greater plaintext, and variable-length terms compare on their common prefix then by length. OpeTerm.Compare / Less are bytes.Compare, the scheme's whole point. testdata/cllw_order.txt holds ciphertexts the Rust crate produced under a fixed key for integers and strings in ascending plaintext order (Rust's Ord asserted while generating); the test checks every pair orders the same way in Go. Claude-Session: https://claude.ai/code/session_01ECzyss8kvtnrRtq5tmpEbg --- languages/golang/stackencrypt/term.go | 74 ++++++++++++++++++- .../stackencrypt/testdata/cllw_order.txt | 34 +++++++++ languages/golang/stackencrypt/unit_test.go | 71 ++++++++++++++++++ 3 files changed, 175 insertions(+), 4 deletions(-) create mode 100644 languages/golang/stackencrypt/testdata/cllw_order.txt diff --git a/languages/golang/stackencrypt/term.go b/languages/golang/stackencrypt/term.go index 93fee0f8d..df6fdc38b 100644 --- a/languages/golang/stackencrypt/term.go +++ b/languages/golang/stackencrypt/term.go @@ -1,6 +1,7 @@ package stackencrypt import ( + "bytes" "crypto/subtle" "database/sql/driver" "encoding/binary" @@ -81,15 +82,80 @@ func (t MatchTerm) Positions() ([]uint16, error) { return out, nil } -// OreTerm is an order-revealing term (CLLW ORE): the raw term bytes. The -// comparison is the database's (EQL's ORE operators); this binding does not -// compare terms in Go. +// OreTerm is an order-revealing term (CLLW ORE): the raw term bytes. Two +// terms derived under the same keyset and context order as their plaintexts +// through Compare and Less; plain byte order says nothing. type OreTerm []byte +// Compare orders two ORE terms as their plaintexts: -1, 0 or +1. Ports the +// CLLW comparison of the Rust crate (constant time over the term bytes): +// at the first differing byte the two sides share the PRF block, so they +// differ by exactly the plaintext bit, and the side one greater is the +// greater plaintext. Terms of different lengths (strings, byte slices) +// compare on their common prefix, then the shorter is less. +func (t OreTerm) Compare(other OreTerm) int { + return compareCLLW(t, other) +} + +// Less reports whether t's plaintext orders before other's. +func (t OreTerm) Less(other OreTerm) bool { return t.Compare(other) < 0 } + // OpeTerm is an order-preserving term (CLLW OPE): the raw term bytes, -// ordered as the values they encode. +// ordered as the values they encode under plain byte order, so a database +// compares them with no custom operator. type OpeTerm []byte +// Compare orders two OPE terms as their plaintexts: bytes.Compare. +func (t OpeTerm) Compare(other OpeTerm) int { return bytes.Compare(t, other) } + +// Less reports whether t's plaintext orders before other's. +func (t OpeTerm) Less(other OpeTerm) bool { return t.Compare(other) < 0 } + +// compareCLLW is cllw-ore's compare_lex: compare_slice over the common +// prefix, then by length. For equal-length terms (integers) that is +// compare_slice alone. +func compareCLLW(a, b []byte) int { + n := min(len(a), len(b)) + if n > 0 { + if c := compareCLLWSlice(a[:n], b[:n]); c != 0 { + return c + } + } + switch { + case len(a) < len(b): + return -1 + case len(a) > len(b): + return 1 + default: + return 0 + } +} + +// compareCLLWSlice is cllw-ore's compare_slice: the first differing byte +// pair is found and judged in constant time; only the final translation +// to an ordering branches, after every secret-dependent step. +func compareCLLWSlice(a, b []byte) int { + var diffX, diffY, found int + for i := range a { + isDiff := 1 - subtle.ConstantTimeByteEq(a[i], b[i]) + record := isDiff & (1 - found) + diffX = subtle.ConstantTimeSelect(record, int(a[i]), diffX) + diffY = subtle.ConstantTimeSelect(record, int(b[i]), diffY) + found = subtle.ConstantTimeSelect(record, isDiff, found) + } + // x == y + 1 (mod 256) means a's plaintext bit was the 1 at the first + // difference. + greater := subtle.ConstantTimeByteEq(uint8(diffY+1), uint8(diffX)) + switch { + case found == 0: + return 0 + case greater == 1: + return 1 + default: + return -1 + } +} + // Value implements driver.Valuer. func (t EqualityTerm) Value() (driver.Value, error) { return []byte(t), nil } diff --git a/languages/golang/stackencrypt/testdata/cllw_order.txt b/languages/golang/stackencrypt/testdata/cllw_order.txt new file mode 100644 index 000000000..398b0b458 --- /dev/null +++ b/languages/golang/stackencrypt/testdata/cllw_order.txt @@ -0,0 +1,34 @@ +# CLLW ORE/OPE ciphertexts under Key::from([7u8; 32]), generated by cllw-ore (Rust); each group is in ascending plaintext order and Rust's Ord agrees. +# kind type plaintext hex (an empty ciphertext is written as -) +ore u32 0 a3115ff9bd560742d3de45951cb3b7cbe816417022d00e7b715086e2ec94e478 +ore u32 1 a3115ff9bd560742d3de45951cb3b7cbe816417022d00e7b715086e2ec94e479 +ore u32 2 a3115ff9bd560742d3de45951cb3b7cbe816417022d00e7b715086e2ec94e567 +ore u32 255 a3115ff9bd560742d3de45951cb3b7cbe816417022d00e7b72f884ab911d6822 +ore u32 256 a3115ff9bd560742d3de45951cb3b7cbe816417022d00e7cdc7f4537ce6a8f5f +ore u32 1000 a3115ff9bd560742d3de45951cb3b7cbe816417022d00f3644f1e1bdee5b01d5 +ore u32 65535 a3115ff9bd560742d3de45951cb3b7cbe963cf9beac0cd058dea56d3522adeb6 +ore u32 4294967294 a4e067cda7ba78cc0cd0a19d528353c2aa746a205fba5f827c6a26c7b1630f6b +ore u32 4294967295 a4e067cda7ba78cc0cd0a19d528353c2aa746a205fba5f827c6a26c7b1630f6c +ore str "" - +ore str "a" a3123875af8bd1b7 +ore str "ab" a3123875af8bd1b773db99f456c23377 +ore str "abc" a3123875af8bd1b773db99f456c233772222eaddfffd39e9 +ore str "b" a3123875af8bd29c +ore str "ba" a3123875af8bd29c3a93d9aa8d0a3355 +ore str "z" a31238767caaad9f +ope u32 0 00a3115ff9bd560742d3de45951cb3b7cbe816417022d00e7b715086e2ec94e478 +ope u32 1 00a3115ff9bd560742d3de45951cb3b7cbe816417022d00e7b715086e2ec94e4f8 +ope u32 2 00a3115ff9bd560742d3de45951cb3b7cbe816417022d00e7b715086e2ec956467 +ope u32 255 00a3115ff9bd560742d3de45951cb3b7cbe816417022d00e7bf278042b109ce7a1 +ope u32 256 00a3115ff9bd560742d3de45951cb3b7cbe816417022d00efbdc7f4537ce6a8f5f +ope u32 1000 00a3115ff9bd560742d3de45951cb3b7cbe816417022d08eb5c47160be6d5b01d5 +ope u32 65535 00a3115ff9bd560742d3de45951cb3b7cc68e34f1b6a404c850d69d652d1aa5e35 +ope u32 4294967294 01245fe74d2739f84b8c50211cd202d34229f3e99fdf39df01fbe9a64730e28e6b +ope u32 4294967295 01245fe74d2739f84b8c50211cd202d34229f3e99fdf39df01fbe9a64730e28eeb +ope str "" 00 +ope str "a" 00a391b775af8bd236 +ope str "ab" 00a391b775af8bd236745b18f456c2b277 +ope str "abc" 00a391b775af8bd236745b18f456c2b27722a269ddfffdb968 +ope str "b" 00a391b775af8c519c +ope str "ba" 00a391b775af8c519c3b1358aa8d0a33d4 +ope str "z" 00a391b7f5fbab2c9f diff --git a/languages/golang/stackencrypt/unit_test.go b/languages/golang/stackencrypt/unit_test.go index f5737707a..38b3c2073 100644 --- a/languages/golang/stackencrypt/unit_test.go +++ b/languages/golang/stackencrypt/unit_test.go @@ -1,9 +1,13 @@ package stackencrypt import ( + "bufio" + "encoding/hex" "errors" "net/http" + "os" "reflect" + "strings" "testing" "github.com/cipherstash/vitaminc/bindings/go/vcffi" @@ -278,3 +282,70 @@ func TestStatusMappingIsTotal(t *testing.T) { t.Errorf("unknown status: %v", got) } } + +// The Go ordering of ORE and OPE terms agrees with Rust's Ord: testdata +// holds ciphertexts the cllw-ore crate produced, each group in ascending +// plaintext order, and every pair must order the same way here. +func TestTermOrderingAgreesWithRust(t *testing.T) { + f, err := os.Open("testdata/cllw_order.txt") + if err != nil { + t.Fatal(err) + } + defer f.Close() + groups := map[string][][]byte{} + var order []string + sc := bufio.NewScanner(f) + for sc.Scan() { + line := sc.Text() + if strings.HasPrefix(line, "#") || line == "" { + continue + } + parts := strings.Fields(line) + kind, typ, raw := parts[0], parts[1], parts[len(parts)-1] + if raw == "-" { + raw = "" + } + term, err := hex.DecodeString(raw) + if err != nil { + t.Fatal(err) + } + key := kind + " " + typ + if _, seen := groups[key]; !seen { + order = append(order, key) + } + groups[key] = append(groups[key], term) + } + if len(order) != 4 { + t.Fatalf("expected 4 vector groups, found %v", order) + } + for _, key := range order { + terms := groups[key] + compare := func(i, j int) int { + if strings.HasPrefix(key, "ope") { + return OpeTerm(terms[i]).Compare(OpeTerm(terms[j])) + } + return OreTerm(terms[i]).Compare(OreTerm(terms[j])) + } + for i := range terms { + for j := range terms { + want := 0 + if i < j { + want = -1 + } else if i > j { + want = 1 + } + if got := compare(i, j); got != want { + t.Errorf("%s: compare(%d, %d) = %d, want %d", key, i, j, got, want) + } + } + } + if strings.HasPrefix(key, "ore") && !OreTerm(terms[0]).Less(OreTerm(terms[1])) { + t.Errorf("%s: Less disagrees with Compare", key) + } + } + // A different length that shares no prefix bytes still orders by the + // first difference, and an empty term is less than any other. + if OreTerm(nil).Compare(OreTerm{1}) != -1 || (OreTerm{1}).Compare(OreTerm(nil)) != 1 || OreTerm(nil).Compare(OreTerm(nil)) != 0 { + t.Error("empty ORE terms do not order by length") + } +} From 52565abe546cbbd760632da6eb20e4218ef183a2 Mon Sep 17 00:00:00 2001 From: Dan Draper <dan@cipherstash.com> Date: Sat, 12 Sep 2026 12:44:58 -0400 Subject: [PATCH 542/686] test(stack-encrypt): property-test Go term ordering against plaintext order ORE is probabilistic: a wrong comparator or derivation still agrees with plaintext order on many pairs, so a fixed vector set says little. For each of ORE and OPE over uint32, uint64, int64, string and bytes, testing/quick draws 300 random pairs, the guest derives their terms, and the Go Compare must agree with the plaintext order in both directions, with each term equal to itself and derivation deterministic. Neighbouring values, which a uniform generator never produces, are checked explicitly. Terms need a loaded keyset, so this is a live test (skipped without credentials; the phase 5 harness runs it). Claude-Session: https://claude.ai/code/session_01ECzyss8kvtnrRtq5tmpEbg --- .../golang/stackencrypt/order_live_test.go | 136 ++++++++++++++++++ 1 file changed, 136 insertions(+) create mode 100644 languages/golang/stackencrypt/order_live_test.go diff --git a/languages/golang/stackencrypt/order_live_test.go b/languages/golang/stackencrypt/order_live_test.go new file mode 100644 index 000000000..48e7e8964 --- /dev/null +++ b/languages/golang/stackencrypt/order_live_test.go @@ -0,0 +1,136 @@ +package stackencrypt + +import ( + "bytes" + "cmp" + "context" + "fmt" + "math/rand" + "reflect" + "testing" + "testing/quick" +) + +// Property tests of term ordering: random plaintexts, terms derived by the +// guest, comparison in Go. ORE is probabilistic — a wrong comparator or a +// wrong derivation still agrees with plaintext order on many pairs — so a +// fixed vector set says little; hundreds of random pairs per type say +// more. The terms come from the guest's index key, which needs a loaded +// keyset, so these run under the live harness (skipped without +// credentials) — see live_test.go. + +// orderProperty checks, for random pairs of T, that the Go comparison of +// their terms agrees with the plaintext order and that a term compares +// equal to itself (derivation is deterministic). +func orderProperty[T any](t *testing.T, cipher *Cipher, kind TermKind, less func(a, b T) int) { + t.Helper() + ctx := context.Background() + context := MustContext(fmt.Sprintf("prop/%s/%T", kind, *new(T))) + term := func(v T) []byte { + t.Helper() + out, err := cipher.Term(ctx, v, context, kind) + if err != nil { + t.Fatalf("Term(%v): %v", v, err) + } + switch tt := out.(type) { + case OreTerm: + return tt + case OpeTerm: + return tt + default: + t.Fatalf("Term returned %T", out) + return nil + } + } + compare := func(a, b []byte) int { + if kind == Ore { + return OreTerm(a).Compare(OreTerm(b)) + } + return OpeTerm(a).Compare(OpeTerm(b)) + } + sign := func(n int) int { + return cmp.Compare(n, 0) + } + holds := func(a, b T) bool { + ta, tb := term(a), term(b) + if compare(ta, ta) != 0 || compare(tb, tb) != 0 { + t.Logf("a term does not compare equal to itself: %v", a) + return false + } + if !bytes.Equal(ta, term(a)) { + t.Logf("derivation is not deterministic for %v", a) + return false + } + want, got := sign(less(a, b)), sign(compare(ta, tb)) + if got != want { + t.Logf("%v vs %v: plaintext order %d, term order %d", a, b, want, got) + return false + } + return sign(compare(tb, ta)) == -want + } + cfg := &quick.Config{MaxCount: 300, Rand: rand.New(rand.NewSource(int64(kind)))} + if err := quick.Check(holds, cfg); err != nil { + t.Fatal(err) + } +} + +// Neighbouring values are where a comparator that mishandles the last +// differing bit shows; quick's uniform generator almost never produces +// them, so they are checked explicitly alongside. +func adjacentProperty[T any](t *testing.T, cipher *Cipher, kind TermKind, values []T, less func(a, b T) int) { + t.Helper() + ctx := context.Background() + context := MustContext(fmt.Sprintf("prop/%s/%T", kind, *new(T))) + terms := make([][]byte, len(values)) + for i, v := range values { + out, err := cipher.Term(ctx, v, context, kind) + if err != nil { + t.Fatalf("Term(%v): %v", v, err) + } + terms[i] = reflect.ValueOf(out).Bytes() + } + for i := range values { + for j := range values { + var got int + if kind == Ore { + got = OreTerm(terms[i]).Compare(OreTerm(terms[j])) + } else { + got = OpeTerm(terms[i]).Compare(OpeTerm(terms[j])) + } + if want := cmp.Compare(less(values[i], values[j]), 0); got != want { + t.Errorf("%v vs %v: plaintext order %d, term order %d", values[i], values[j], want, got) + } + } + } +} + +func TestLiveTermOrderIsPlaintextOrder(t *testing.T) { + c := liveClient(t) + cipher := c.DefaultCipher() + for _, kind := range []TermKind{Ore, Ope} { + t.Run(kind.String(), func(t *testing.T) { + t.Run("uint32", func(t *testing.T) { + orderProperty(t, cipher, kind, cmp.Compare[uint32]) + adjacentProperty(t, cipher, kind, []uint32{0, 1, 2, 255, 256, 257, 65535, 65536, 1<<31 - 1, 1 << 31, 1<<32 - 2, 1<<32 - 1}, cmp.Compare[uint32]) + }) + t.Run("uint64", func(t *testing.T) { + orderProperty(t, cipher, kind, cmp.Compare[uint64]) + adjacentProperty(t, cipher, kind, []uint64{0, 1, 1<<32 - 1, 1 << 32, 1<<63 - 1, 1 << 63, 1<<64 - 1}, cmp.Compare[uint64]) + }) + t.Run("int64", func(t *testing.T) { + orderProperty(t, cipher, kind, cmp.Compare[int64]) + adjacentProperty(t, cipher, kind, []int64{-1 << 63, -1<<63 + 1, -2, -1, 0, 1, 2, 1<<63 - 1}, cmp.Compare[int64]) + }) + t.Run("string", func(t *testing.T) { + // Strings order by their UTF-8 bytes; a prefix orders before + // its extensions. + orderProperty(t, cipher, kind, func(a, b string) int { return bytes.Compare([]byte(a), []byte(b)) }) + adjacentProperty(t, cipher, kind, []string{"", "a", "aa", "ab", "b", "ba", "\x7f", "é", "éa"}, func(a, b string) int { return bytes.Compare([]byte(a), []byte(b)) }) + }) + t.Run("bytes", func(t *testing.T) { + orderProperty(t, cipher, kind, bytes.Compare) + adjacentProperty(t, cipher, kind, [][]byte{{}, {0}, {0, 0}, {0, 1}, {1}, {255}, {255, 0}}, bytes.Compare) + }) + }) + } +} From 38d8b05eabeb6283396c51229e95177067401722 Mon Sep 17 00:00:00 2001 From: Dan Draper <dan@cipherstash.com> Date: Sat, 12 Sep 2026 13:01:34 -0400 Subject: [PATCH 543/686] fix(stack-encrypt): run the Go guest on the host CSPRNG and clocks wazero's ModuleConfig defaults are deterministic: WASI random_get is math/rand with a fixed seed, and the clocks start at a fixed epoch and advance 1ms per read. The guest draws ZeroKMS IVs and AEAD nonces through random_get, so every Client replayed the same nonce sequence; its keyset-name cache expires on clock_time_get, so a name never expired on wall time. Every instance now runs under guestModuleConfig, which sets crypto/rand.Reader and the system clocks. A hermetic probe module pins both (two fresh instances draw different bytes, neither on the seed-42 stream, and the monotonic clock tracks a real sleep); the guest build's import check now requires random_get so a getrandom backend change surfaces on the host side. Found by Codex review on cipherstash/cipherstash-suite#2214. Claude-Session: https://claude.ai/code/session_01ECzyss8kvtnrRtq5tmpEbg --- languages/golang/stackencrypt/doc.go | 9 ++ languages/golang/stackencrypt/guest.go | 21 +++- languages/golang/stackencrypt/runtime_test.go | 119 ++++++++++++++++++ 3 files changed, 148 insertions(+), 1 deletion(-) create mode 100644 languages/golang/stackencrypt/runtime_test.go diff --git a/languages/golang/stackencrypt/doc.go b/languages/golang/stackencrypt/doc.go index e5d2827a4..89651e24d 100644 --- a/languages/golang/stackencrypt/doc.go +++ b/languages/golang/stackencrypt/doc.go @@ -48,4 +48,13 @@ // [net/http.RoundTripper], and a bearer-token fetch, served by a // [TokenSource]. What crosses per ZeroKMS call is what would cross TLS // anyway; derived key material never leaves the guest. +// +// # Host runtime +// +// The guest also imports WASI random_get and clock_time_get, and the +// cipher's security rests on the first: ZeroKMS IVs and AEAD nonces are +// drawn from it. wazero's defaults for both are deterministic, so every +// instance is configured with the process CSPRNG ([crypto/rand.Reader]) +// and the system clocks. An embedder that instantiates the guest module +// under its own wazero configuration must do the same. package stackencrypt diff --git a/languages/golang/stackencrypt/guest.go b/languages/golang/stackencrypt/guest.go index 7af3f736c..1a9d744b5 100644 --- a/languages/golang/stackencrypt/guest.go +++ b/languages/golang/stackencrypt/guest.go @@ -2,6 +2,7 @@ package stackencrypt import ( "context" + "crypto/rand" "embed" "errors" "fmt" @@ -61,6 +62,24 @@ type instance struct { encryptRecord, decryptRecord api.Function } +// guestModuleConfig is the module configuration every guest instance runs +// under. wazero's defaults are deterministic by design (see its +// RATIONALE.md): a WASI random_get backed by math/rand with a fixed seed, +// and clocks that start at a fixed epoch and advance 1ms per read. The +// guest's cipher draws ZeroKMS IVs and AEAD nonces through random_get, so +// the default would hand every instance the same nonce sequence; its +// keyset-name cache expires on clock_time_get, so the default would never +// let a name expire on wall time. Each override below is load-bearing and +// pinned by TestGuestModuleConfigHostSources. +func guestModuleConfig() wazero.ModuleConfig { + return wazero.NewModuleConfig(). + WithName("stack_encrypt_guest"). + // crypto/rand.Reader: the process CSPRNG, safe for concurrent use. + WithRandSource(rand.Reader). + WithSysNanotime(). + WithSysWalltime() +} + // newInstance instantiates wasm with the transport as its host module. func newInstance(ctx context.Context, wasm []byte, t *transport) (*instance, error) { // WithCloseOnContextDone lets a caller's deadline or cancellation @@ -78,7 +97,7 @@ func newInstance(ctx context.Context, wasm []byte, t *transport) (*instance, err } // The guest is a reactor (cdylib): no _start. wazero runs _initialize // when present. - module, err := runtime.InstantiateWithConfig(ctx, wasm, wazero.NewModuleConfig().WithName("stack_encrypt_guest")) + module, err := runtime.InstantiateWithConfig(ctx, wasm, guestModuleConfig()) if err != nil { _ = runtime.Close(ctx) return nil, fmt.Errorf("stackencrypt: instantiating guest: %w", err) diff --git a/languages/golang/stackencrypt/runtime_test.go b/languages/golang/stackencrypt/runtime_test.go new file mode 100644 index 000000000..639013026 --- /dev/null +++ b/languages/golang/stackencrypt/runtime_test.go @@ -0,0 +1,119 @@ +package stackencrypt + +import ( + "bytes" + "context" + "encoding/binary" + "math/rand" + "testing" + "time" + + "github.com/tetratelabs/wazero" + "github.com/tetratelabs/wazero/imports/wasi_snapshot_preview1" +) + +// wasiProbe is a hand-assembled module that re-exports the two WASI +// imports the guest's cipher depends on for its security properties, so +// the module configuration can be tested without ZeroKMS and without +// adding a production export to the guest: +// +// (module +// (import "wasi_snapshot_preview1" "random_get" +// (func $random_get (param i32 i32) (result i32))) +// (import "wasi_snapshot_preview1" "clock_time_get" +// (func $clock_time_get (param i32 i64 i32) (result i32))) +// (memory (export "memory") 1) +// (func (export "random_get") (param i32 i32) (result i32) +// local.get 0 local.get 1 call $random_get) +// (func (export "clock_time_get") (param i32 i64 i32) (result i32) +// local.get 0 local.get 1 local.get 2 call $clock_time_get)) +var wasiProbe = []byte{ + 0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00, 0x01, 0x0e, 0x02, 0x60, + 0x02, 0x7f, 0x7f, 0x01, 0x7f, 0x60, 0x03, 0x7f, 0x7e, 0x7f, 0x01, 0x7f, + 0x02, 0x4d, 0x02, 0x16, 0x77, 0x61, 0x73, 0x69, 0x5f, 0x73, 0x6e, 0x61, + 0x70, 0x73, 0x68, 0x6f, 0x74, 0x5f, 0x70, 0x72, 0x65, 0x76, 0x69, 0x65, + 0x77, 0x31, 0x0a, 0x72, 0x61, 0x6e, 0x64, 0x6f, 0x6d, 0x5f, 0x67, 0x65, + 0x74, 0x00, 0x00, 0x16, 0x77, 0x61, 0x73, 0x69, 0x5f, 0x73, 0x6e, 0x61, + 0x70, 0x73, 0x68, 0x6f, 0x74, 0x5f, 0x70, 0x72, 0x65, 0x76, 0x69, 0x65, + 0x77, 0x31, 0x0e, 0x63, 0x6c, 0x6f, 0x63, 0x6b, 0x5f, 0x74, 0x69, 0x6d, + 0x65, 0x5f, 0x67, 0x65, 0x74, 0x00, 0x01, 0x03, 0x03, 0x02, 0x00, 0x01, + 0x05, 0x03, 0x01, 0x00, 0x01, 0x07, 0x28, 0x03, 0x06, 0x6d, 0x65, 0x6d, + 0x6f, 0x72, 0x79, 0x02, 0x00, 0x0a, 0x72, 0x61, 0x6e, 0x64, 0x6f, 0x6d, + 0x5f, 0x67, 0x65, 0x74, 0x00, 0x02, 0x0e, 0x63, 0x6c, 0x6f, 0x63, 0x6b, + 0x5f, 0x74, 0x69, 0x6d, 0x65, 0x5f, 0x67, 0x65, 0x74, 0x00, 0x03, 0x0a, + 0x15, 0x02, 0x08, 0x00, 0x20, 0x00, 0x20, 0x01, 0x10, 0x00, 0x0b, 0x0a, + 0x00, 0x20, 0x00, 0x20, 0x01, 0x20, 0x02, 0x10, 0x01, 0x0b, +} + +// probe instantiates wasiProbe in a fresh runtime under guestModuleConfig, +// exactly as newInstance instantiates the guest. +func probe(t *testing.T, ctx context.Context) (wazero.Runtime, func(n uint32) []byte, func() time.Duration) { + t.Helper() + rt := wazero.NewRuntime(ctx) + wasi_snapshot_preview1.MustInstantiate(ctx, rt) + mod, err := rt.InstantiateWithConfig(ctx, wasiProbe, guestModuleConfig()) + if err != nil { + t.Fatalf("instantiating probe: %v", err) + } + randomGet := mod.ExportedFunction("random_get") + clockTimeGet := mod.ExportedFunction("clock_time_get") + random := func(n uint32) []byte { + if res, err := randomGet.Call(ctx, 0, uint64(n)); err != nil || res[0] != 0 { + t.Fatalf("random_get: errno %v err %v", res, err) + } + out, ok := mod.Memory().Read(0, n) + if !ok { + t.Fatal("reading probe memory") + } + return bytes.Clone(out) + } + // clock_time_get(id=1 monotonic, precision, out_ptr) writes u64 nanos. + monotonic := func() time.Duration { + if res, err := clockTimeGet.Call(ctx, 1, 1, 64); err != nil || res[0] != 0 { + t.Fatalf("clock_time_get: errno %v err %v", res, err) + } + raw, ok := mod.Memory().Read(64, 8) + if !ok { + t.Fatal("reading probe memory") + } + return time.Duration(binary.LittleEndian.Uint64(raw)) + } + return rt, random, monotonic +} + +// TestGuestModuleConfigHostSources pins that the guest runs on the host's +// CSPRNG and clocks rather than wazero's deterministic defaults. A fixed +// seed would hand every Client the same ZeroKMS IV and AEAD nonce +// sequence; a fake clock would keep the keyset-name cache fresh forever. +func TestGuestModuleConfigHostSources(t *testing.T) { + ctx := context.Background() + a, randomA, monotonicA := probe(t, ctx) + defer a.Close(ctx) + b, randomB, _ := probe(t, ctx) + defer b.Close(ctx) + + const n = 32 + first, second := randomA(n), randomB(n) + if bytes.Equal(first, second) { + t.Fatalf("two fresh instances drew identical random bytes: %x", first) + } + // wazero's default is math/rand seeded with 42; neither instance may + // start on that stream. + fake := make([]byte, n) + if _, err := rand.New(rand.NewSource(42)).Read(fake); err != nil { + t.Fatal(err) + } + for _, got := range [][]byte{first, second} { + if bytes.Equal(got, fake) { + t.Fatalf("random_get is wazero's fixed-seed default: %x", got) + } + } + + // The fake clock advances 1ms per read regardless of elapsed time. + const sleep = 20 * time.Millisecond + before := monotonicA() + time.Sleep(sleep) + if elapsed := monotonicA() - before; elapsed < sleep { + t.Fatalf("monotonic clock advanced %v across a %v sleep: not the host clock", elapsed, sleep) + } +} From 961ee65c68657109bb9df640e90ba9168ed11aa8 Mon Sep 17 00:00:00 2001 From: Dan Draper <dan@cipherstash.com> Date: Sat, 12 Sep 2026 14:25:17 -0400 Subject: [PATCH 544/686] fix(stack-encrypt): make Go record decoding lossless, nullable-aware and atomic Review findings on cipherstash/cipherstash-suite#2214 (Codex, Copilot) in the reflection decoder: - A decoded nil (a sealed none) was written as the zero value of any field, turning an absent value into 0, "" or false. Only a pointer, slice, map or interface field takes nil now; a scalar is errUnassignable. - A defined boolean (`type Flag bool`) encrypted but could not decrypt: reflect.Bool had no case. Bool, and defined types over bool, string and byte slices, now assign. - float64 -> float32 checked range but not exactness, so 0.1 and 16777217 rounded silently. Narrowing must round-trip; NaN is allowed. - DecryptRecords replaced the destination with zeroed rows, losing the caller's unplanned fields despite promising to keep them. When the slice holds one row per record the rows are copied first; the batch (and DecryptRecord) commits through a scratch value, so a failing record leaves the destination untouched. - EncryptRecord(nil) panicked on an invalid reflect.Value. Claude-Session: https://claude.ai/code/session_01ECzyss8kvtnrRtq5tmpEbg --- languages/golang/stackencrypt/record.go | 84 +++++++++++--- languages/golang/stackencrypt/unit_test.go | 129 ++++++++++++++++++--- 2 files changed, 187 insertions(+), 26 deletions(-) diff --git a/languages/golang/stackencrypt/record.go b/languages/golang/stackencrypt/record.go index 6dc60e793..735036b54 100644 --- a/languages/golang/stackencrypt/record.go +++ b/languages/golang/stackencrypt/record.go @@ -172,12 +172,12 @@ func applyOptions(opts []RecordOption) recordOptions { } // EncryptRecords seals every row of a slice of structs (or a pointer to -// one) per the struct's `stash` tags: all rows and fields from one batched -// ZeroKMS key request, terms derived under this keyset's index key. One -// EncryptedRecord per row, in order. +// one) per the struct's `stash` tags: all rows and fields from batched +// ZeroKMS key requests (one per 500 sealed fields), terms derived under +// this keyset's index key. One EncryptedRecord per row, in order. func (cph *Cipher) EncryptRecords(ctx context.Context, rows any, opts ...RecordOption) ([]EncryptedRecord, error) { v := reflect.Indirect(reflect.ValueOf(rows)) - if v.Kind() != reflect.Slice { + if !v.IsValid() || v.Kind() != reflect.Slice { return nil, fmt.Errorf("stackencrypt: EncryptRecords takes a slice of structs, not %T", rows) } plan, err := planFor(v.Type().Elem()) @@ -209,6 +209,9 @@ func (cph *Cipher) EncryptRecords(ctx context.Context, rows any, opts ...RecordO // tags; see EncryptRecords. func (cph *Cipher) EncryptRecord(ctx context.Context, row any, opts ...RecordOption) (EncryptedRecord, error) { v := reflect.Indirect(reflect.ValueOf(row)) + if !v.IsValid() { + return nil, fmt.Errorf("stackencrypt: EncryptRecord takes a struct, not %T", row) + } plan, err := planFor(v.Type()) if err != nil { return nil, err @@ -252,6 +255,7 @@ func (cph *Cipher) encryptRecords(ctx context.Context, plan []fieldPlan, source if err != nil { return nil, err } + defer wipe(out) return unmarshalCipherText(out) } @@ -312,13 +316,16 @@ func termBytes(node any) ([]byte, error) { // (a record from another keyset is ErrForeignKeyset) into out, a pointer to // a slice of the same struct type, one element per record. Only the sealed // outputs participate; terms are one-way. Fields the plan does not name are -// left as they are. +// left as they are: when the slice already holds one row per record, each +// row keeps its other fields; otherwise it is replaced by a fresh slice. +// Nothing is written unless every record decodes. func (cph *Cipher) DecryptRecords(ctx context.Context, records []EncryptedRecord, out any, opts ...RecordOption) error { return cph.client.decryptRecords(ctx, cph.keyset, records, out, opts) } // DecryptRecord opens one record into out, a pointer to a struct; see -// DecryptRecords. +// DecryptRecords. Fields the plan does not name keep their values, and +// nothing is written unless every planned field decodes. func (cph *Cipher) DecryptRecord(ctx context.Context, record EncryptedRecord, out any, opts ...RecordOption) error { return cph.client.decryptRecord(ctx, cph.keyset, record, out, opts) } @@ -347,13 +354,37 @@ func (c *Client) decryptRecords(ctx context.Context, sel KeysetSelector, records if !ok || len(items) != len(records) { return fmt.Errorf("%w: record batch decrypted as %T", ErrInternal, values) } - slice := reflect.MakeSlice(ptr.Elem().Type(), len(items), len(items)) + return commitRecords(ptr.Elem(), items, plan) +} + +// commitRecords writes decrypted records into a slice value, atomically: +// the rows are assembled in a scratch slice — copies of the existing rows +// when there is one per record, zero rows otherwise — and stored only once +// every record has been assigned. +func commitRecords(slice reflect.Value, items []any, plan []fieldPlan) error { + scratch := reflect.MakeSlice(slice.Type(), len(items), len(items)) + if slice.Len() == len(items) { + reflect.Copy(scratch, slice) + } for i, item := range items { - if err := assignRecord(slice.Index(i), item, plan); err != nil { + if err := assignRecord(scratch.Index(i), item, plan); err != nil { return err } } - ptr.Elem().Set(slice) + slice.Set(scratch) + return nil +} + +// commitRecord writes one decrypted record into a struct value, atomically: +// a copy takes the planned fields and replaces the original only once +// every one of them has been assigned. +func commitRecord(target reflect.Value, item any, plan []fieldPlan) error { + scratch := reflect.New(target.Type()).Elem() + scratch.Set(target) + if err := assignRecord(scratch, item, plan); err != nil { + return err + } + target.Set(scratch) return nil } @@ -374,7 +405,7 @@ func (c *Client) decryptRecord(ctx context.Context, sel KeysetSelector, record E if err != nil { return err } - return assignRecord(ptr.Elem(), value, plan) + return commitRecord(ptr.Elem(), value, plan) } // recordTree renders the ciphertext tree the guest opens: per planned @@ -444,11 +475,18 @@ func assignRecord(target reflect.Value, value any, plan []fieldPlan) error { var errUnassignable = errors.New("cannot assign decrypted value") // assignField sets a struct field from a decoded value, converting within -// a numeric family when the value fits and refusing anything lossy. +// a numeric family when the value fits and refusing anything lossy. A +// decoded nil (a sealed none) is accepted only by a field that can hold +// one — a pointer, slice, map or interface — never as a zero scalar. func assignField(field reflect.Value, v any) error { if v == nil { - field.Set(reflect.Zero(field.Type())) - return nil + switch field.Kind() { + case reflect.Pointer, reflect.Slice, reflect.Map, reflect.Interface: + field.Set(reflect.Zero(field.Type())) + return nil + default: + return fmt.Errorf("%w: nil into %s", errUnassignable, field.Type()) + } } if field.Kind() == reflect.Pointer { elem := reflect.New(field.Type().Elem()) @@ -463,6 +501,15 @@ func assignField(field reflect.Value, v any) error { field.Set(rv) return nil } + // A defined type over the same kind (type Flag bool, type Raw []byte) + // converts without loss. + if rv.Kind() == field.Kind() && rv.Type().ConvertibleTo(field.Type()) { + switch field.Kind() { + case reflect.Bool, reflect.String, reflect.Slice: + field.Set(rv.Convert(field.Type())) + return nil + } + } switch field.Kind() { case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: var n int64 @@ -505,6 +552,11 @@ func assignField(field reflect.Value, v any) error { if field.OverflowFloat(f) { return fmt.Errorf("%w: %v overflows %s", errUnassignable, f, field.Type()) } + // Narrowing must be exact: a float64 that float32 cannot represent + // would silently round. NaN is its own case, never equal to itself. + if field.Kind() == reflect.Float32 && float64(float32(f)) != f && f == f { + return fmt.Errorf("%w: %v is not representable as %s", errUnassignable, f, field.Type()) + } field.SetFloat(f) case reflect.String: s, ok := v.(string) @@ -512,6 +564,12 @@ func assignField(field reflect.Value, v any) error { return fmt.Errorf("%w: %T into %s", errUnassignable, v, field.Type()) } field.SetString(s) + case reflect.Bool: + b, ok := v.(bool) + if !ok { + return fmt.Errorf("%w: %T into %s", errUnassignable, v, field.Type()) + } + field.SetBool(b) default: return fmt.Errorf("%w: %T into %s", errUnassignable, v, field.Type()) } diff --git a/languages/golang/stackencrypt/unit_test.go b/languages/golang/stackencrypt/unit_test.go index 38b3c2073..ed6f6ddf5 100644 --- a/languages/golang/stackencrypt/unit_test.go +++ b/languages/golang/stackencrypt/unit_test.go @@ -2,6 +2,7 @@ package stackencrypt import ( "bufio" + "context" "encoding/hex" "errors" "net/http" @@ -16,6 +17,76 @@ import ( // Pure Go: no guest needed. +func TestCommitRecordsPreservesRowsAndIsAtomic(t *testing.T) { + type row struct { + ID int64 `stash:"-"` + Age uint8 `stash:"context=users/age"` + Email string `stash:"context=users/email"` + } + plan, err := planFor(reflect.TypeOf(row{})) + if err != nil { + t.Fatal(err) + } + decoded := func(age any, email string) vcvalue.Object { + return vcvalue.Object{{Key: "Age", Value: age}, {Key: "Email", Value: email}} + } + + // One row per record: unplanned fields survive. + rows := []row{{ID: 1, Age: 9}, {ID: 2, Age: 9}} + if err := commitRecords(reflect.ValueOf(&rows).Elem(), []any{decoded(uint32(30), "a"), decoded(uint32(40), "b")}, plan); err != nil { + t.Fatal(err) + } + if want := []row{{1, 30, "a"}, {2, 40, "b"}}; !reflect.DeepEqual(rows, want) { + t.Fatalf("rows = %+v, want %+v", rows, want) + } + + // A failing record leaves the slice untouched. + before := append([]row(nil), rows...) + err = commitRecords(reflect.ValueOf(&rows).Elem(), []any{decoded(uint32(31), "c"), decoded(uint32(300), "d")}, plan) + if !errors.Is(err, errUnassignable) { + t.Fatalf("overflowing batch: %v", err) + } + if !reflect.DeepEqual(rows, before) { + t.Fatalf("partial write: %+v", rows) + } + + // A different length replaces the slice. + if err := commitRecords(reflect.ValueOf(&rows).Elem(), []any{decoded(uint32(1), "z")}, plan); err != nil { + t.Fatal(err) + } + if want := []row{{0, 1, "z"}}; !reflect.DeepEqual(rows, want) { + t.Fatalf("rows = %+v, want %+v", rows, want) + } + + // One record: same contract on a struct. + one := row{ID: 7, Age: 1, Email: "keep"} + if err := commitRecord(reflect.ValueOf(&one).Elem(), decoded(uint32(300), "new"), plan); !errors.Is(err, errUnassignable) { + t.Fatalf("overflowing record: %v", err) + } + if one != (row{7, 1, "keep"}) { + t.Fatalf("partial write: %+v", one) + } + if err := commitRecord(reflect.ValueOf(&one).Elem(), decoded(uint32(2), "new"), plan); err != nil { + t.Fatal(err) + } + if one != (row{7, 2, "new"}) { + t.Fatalf("record = %+v", one) + } +} + +func TestEncryptRecordRejectsNil(t *testing.T) { + c := &Client{closed: true} + cph := c.DefaultCipher() + for name, in := range map[string]any{"nil": nil, "nil pointer": (*taggedUser)(nil)} { + if _, err := cph.EncryptRecord(context.Background(), in); err == nil || errors.Is(err, ErrState) { + t.Errorf("EncryptRecord(%s): %v, want a record error", name, err) + } + if _, err := cph.EncryptRecords(context.Background(), in); err == nil || errors.Is(err, ErrState) { + t.Errorf("EncryptRecords(%s): %v, want a record error", name, err) + } + } +} + func TestKeysetIDRoundTripsCanonicalForm(t *testing.T) { const s = "6a70bd18-99ac-4650-b104-37eec3a15b09" id, err := ParseKeysetID(s) @@ -143,14 +214,23 @@ func TestPlanFromTags(t *testing.T) { } func TestAssignFieldConvertsWithinFamiliesOnly(t *testing.T) { + type flag bool + type name string + type raw []byte type row struct { - I int - U8 uint8 - F float32 - S string - B []byte - P *int64 - Bad bool + I int + U8 uint8 + F float32 + F64 float64 + S string + B []byte + P *int64 + Bool bool + Flag flag + Name name + Raw raw + M map[string]int + Any any } var r row rv := reflect.ValueOf(&r).Elem() @@ -163,21 +243,44 @@ func TestAssignFieldConvertsWithinFamiliesOnly(t *testing.T) { must("I", int64(-5)) must("U8", uint32(200)) must("F", float32(1.5)) + must("F", float64(0.5)) // exactly representable: narrows + must("F64", float32(0.1)) must("S", "s") must("B", []byte{1}) must("P", int64(9)) - if r.I != -5 || r.U8 != 200 || r.F != 1.5 || r.S != "s" || string(r.B) != "\x01" || *r.P != 9 { + must("Bool", true) + must("Flag", true) + must("Name", "n") + must("Raw", []byte{2}) + if r.I != -5 || r.U8 != 200 || r.F != 0.5 || r.F64 != float64(float32(0.1)) || r.S != "s" || string(r.B) != "\x01" || *r.P != 9 || + !r.Bool || !bool(r.Flag) || r.Name != "n" || string(r.Raw) != "\x02" { t.Fatalf("assigned %+v", r) } + // A sealed none decodes as nil: only a field that can hold one takes it. + r.P, r.B, r.M, r.Any = new(int64), []byte{1}, map[string]int{"a": 1}, 1 + must("P", nil) + must("B", nil) + must("M", nil) + must("Any", nil) + if r.P != nil || r.B != nil || r.M != nil || r.Any != nil { + t.Fatalf("nil did not clear: %+v", r) + } for _, bad := range []struct { field string v any }{ - {"U8", uint32(300)}, // overflow - {"I", uint64(1)}, // family - {"S", int64(1)}, // kind - {"Bad", "true"}, // unsupported target - {"I", float64(1)}, // family + {"U8", uint32(300)}, // overflow + {"I", uint64(1)}, // family + {"S", int64(1)}, // kind + {"Bool", "true"}, // kind + {"I", float64(1)}, // family + {"F", float64(0.1)}, // not representable as float32 + {"F", float64(16777217)}, // in range, not representable + {"I", nil}, // a none into a scalar + {"S", nil}, // a none into a scalar + {"Bool", nil}, // a none into a scalar + {"Name", []byte("n")}, // kind + {"Raw", "r"}, // kind } { if err := assignField(rv.FieldByName(bad.field), bad.v); !errors.Is(err, errUnassignable) { t.Errorf("%s <- %v: got %v, want errUnassignable", bad.field, bad.v, err) From ea3da1e91868bc77d836f2534b01ef857c7e4e50 Mon Sep 17 00:00:00 2001 From: Dan Draper <dan@cipherstash.com> Date: Sat, 12 Sep 2026 14:25:18 -0400 Subject: [PATCH 545/686] fix(stack-encrypt): bound and wipe the Go host transport; survive interrupted calls Review findings on cipherstash/cipherstash-suite#2214 (Copilot) in the embedder: - transport_send buffered the whole response; an endpoint the transport is pointed at could make the host allocate without limit. Responses are capped at 16 MiB, by Content-Length up front and by a limited read otherwise; over the cap is a transport failure. - The host copies of the request body, the response body and the bearer token, and the serialized term inputs and ciphertext output, stayed in the Go heap until collection. All are wiped once the guest has them. - Close ran se_shutdown under the caller's context, so an expired context skipped the wipe this method exists for. Shutdown, and the dealloc after every call, now run under a context that cannot be cancelled. - WithCloseOnContextDone closes the module when a call is interrupted, but the Client did not know: later calls hit a closed module and Close tried to run shutdown in it. Client.call now marks the client closed when the module is, returns ErrState from then on, and Close skips the shutdown for a module that is already gone. - The import-surface test accepted [token_get, token_get]. Chunking docs: key requests batch 500 keys at a time, not one request per value; the package, Encrypt, Decrypt and EncryptRecords docs now say so. Not changed: Copilot's "literal ****** bearer token" finding is GitHub's secret masking of `format!("Bearer {access_token}")` in the diff view; the guest sends the real token (TestNewClientIssuesTheLoadKeysetRequest asserts it). Claude-Session: https://claude.ai/code/session_01ECzyss8kvtnrRtq5tmpEbg --- languages/golang/stackencrypt/cipher.go | 12 ++- languages/golang/stackencrypt/client.go | 39 +++++++--- languages/golang/stackencrypt/doc.go | 9 ++- languages/golang/stackencrypt/guest.go | 6 +- languages/golang/stackencrypt/guest_test.go | 84 ++++++++++++++++++++- languages/golang/stackencrypt/transport.go | 28 ++++++- 6 files changed, 158 insertions(+), 20 deletions(-) diff --git a/languages/golang/stackencrypt/cipher.go b/languages/golang/stackencrypt/cipher.go index ca5e5d96b..0fb66c090 100644 --- a/languages/golang/stackencrypt/cipher.go +++ b/languages/golang/stackencrypt/cipher.go @@ -31,8 +31,9 @@ func (cph *Cipher) Keyset() KeysetSelector { return cph.keyset } // model: builtins, slices, maps and structs by reflection, a type // implementing vcffi.Encryptable by its own encoding, vcvalue.Plain marking // a passthrough. aad is authenticated but not encrypted, and may be empty; -// the same aad must be presented to Decrypt. Every leaf of v seals from one -// batched ZeroKMS key request. +// the same aad must be presented to Decrypt. The leaves of v seal from +// batched ZeroKMS key requests: one per 500 keyed leaves, so one request +// for any ordinary value. // // The ciphertext comes back as ordinary Go values mirroring the // plaintext's structure: Sealed leaves (and the SealedNone / SealedEmptySeq @@ -86,10 +87,14 @@ func (cph *Cipher) Term(ctx context.Context, value any, context Context, kind Te if err != nil { return nil, err } + // The probe value is plaintext: its transport copy is wiped once it is + // in the guest, as is the context it binds. + defer wipe(encodedValue) encodedContext, err := vcffi.Marshal(context.value()) if err != nil { return nil, err } + defer wipe(encodedContext) opts, err := vcffi.Marshal(options(cph.keyset)) if err != nil { return nil, err @@ -139,5 +144,8 @@ func (cph *Cipher) encryptValue(ctx context.Context, v any, aad []byte, element if err != nil { return nil, err } + // Passthrough (vcvalue.Plain) fields come back in the clear; the + // serialized copy is wiped once decoded. + defer wipe(out) return unmarshalCipherText(out) } diff --git a/languages/golang/stackencrypt/client.go b/languages/golang/stackencrypt/client.go index 57bda879a..86616bfd0 100644 --- a/languages/golang/stackencrypt/client.go +++ b/languages/golang/stackencrypt/client.go @@ -128,7 +128,8 @@ func encodeConfig(cfg Config) ([]byte, error) { // Close shuts the guest down — the client key and every loaded index key // are wiped inside the instance — and releases the runtime. Idempotent. -// Every call after it fails with ErrState. +// Every call after it fails with ErrState. The shutdown runs even if ctx +// is already cancelled: the wipe is the point of this method. func (c *Client) Close(ctx context.Context) error { c.mu.Lock() defer c.mu.Unlock() @@ -136,9 +137,13 @@ func (c *Client) Close(ctx context.Context) error { return nil } c.closed = true - // A trapped or already-closed module cannot run se_shutdown; the runtime - // close still frees its memory. Nothing else can be done host-side. - _, _ = c.inst.shutdown.Call(ctx) + ctx = context.WithoutCancel(ctx) + // A module closed by an interrupted call (see Client.call) or by a trap + // cannot run se_shutdown; the runtime close still frees its memory. + // Nothing else can be done host-side. + if !c.inst.module.IsClosed() { + _, _ = c.inst.shutdown.Call(ctx) + } return c.inst.close(ctx) } @@ -186,9 +191,10 @@ func (c *Client) Cipher(sel KeysetSelector) *Cipher { func (c *Client) DefaultCipher() *Cipher { return c.Cipher(DefaultKeyset) } // Decrypt opens a ciphertext produced by any keyset of this client: each -// leaf is opened under the keyset it was sealed with, with one batched key -// retrieval per keyset. ct is the shape Cipher.Encrypt returns; aad must be -// what the value was sealed under. +// leaf is opened under the keyset it was sealed with, with batched key +// retrievals per keyset (one per 500 leaves sealed under it). ct is the +// shape Cipher.Encrypt returns; aad must be what the value was sealed +// under. func (c *Client) Decrypt(ctx context.Context, ct any, aad []byte) (any, error) { return c.decryptValue(ctx, anyKeyset{}, ct, aad, false) } @@ -212,13 +218,28 @@ func (c *Client) DecryptRecord(ctx context.Context, record EncryptedRecord, out } // call runs f on the instance under the client's lock. +// +// A call interrupted by its context (the runtime closes the module on a +// deadline or cancellation, see newInstance) leaves the instance closed: +// its key material is gone with its memory and no further call can run. +// The client is then closed, so later calls are ErrState rather than a +// runtime error, and Close releases the runtime without a shutdown call. func (c *Client) call(ctx context.Context, f func(*instance) ([]byte, error)) ([]byte, error) { c.mu.Lock() defer c.mu.Unlock() - if c.closed { + if c.closed || c.inst.module.IsClosed() { + c.closed = true return nil, ErrState } - return f(c.inst) + out, err := f(c.inst) + if c.inst.module.IsClosed() { + c.closed = true + if err == nil { + err = ErrState + } + return nil, fmt.Errorf("%w: interrupted call closed the client", err) + } + return out, err } func (c *Client) decryptValue(ctx context.Context, sel KeysetSelector, ct any, aad []byte, element bool) (any, error) { diff --git a/languages/golang/stackencrypt/doc.go b/languages/golang/stackencrypt/doc.go index 89651e24d..4169dfb59 100644 --- a/languages/golang/stackencrypt/doc.go +++ b/languages/golang/stackencrypt/doc.go @@ -17,7 +17,7 @@ // [Client.DefaultCipher]): it seals values, derives terms and encrypts // records under that keyset, and opens only that keyset's ciphertexts. The // [Client] itself opens ciphertexts from any keyset ([Client.Decrypt] and -// friends), fetching one batched key retrieval per keyset the leaves were +// friends), fetching batched key retrievals per keyset the leaves were // sealed under. // // # Values @@ -35,8 +35,11 @@ // // [Cipher.EncryptRecords] is the runtime form of the Rust derive: a struct's // `stash` tags say, per field, which context to bind and which index terms -// to produce, and one call seals every row of a slice from one batched key -// request. Terms ([EqualityTerm], [MatchTerm], [OreTerm], [OpeTerm]) are +// to produce, and one call seals every row of a slice from batched key +// requests. Key requests are batched 500 keys at a time, in both +// directions: one request for any ordinary value or batch, one more per +// 500 sealed leaves beyond that. Terms ([EqualityTerm], [MatchTerm], +// [OreTerm], [OpeTerm]) are // byte-equal to the ones the Rust crate derives, so a probe from // [Cipher.Term] compares against a stored term from any language. // [Cipher.Term] takes a context and returns an error from day one: term diff --git a/languages/golang/stackencrypt/guest.go b/languages/golang/stackencrypt/guest.go index 1a9d744b5..5415b7b69 100644 --- a/languages/golang/stackencrypt/guest.go +++ b/languages/golang/stackencrypt/guest.go @@ -154,10 +154,12 @@ func (inst *instance) allocWrite(ctx context.Context, data []byte) (guestBuf, er } // free zeroizes and releases a guest buffer (se_dealloc wipes; an unknown -// pointer is a no-op there). +// pointer is a no-op there). It runs under a context that cannot be +// cancelled: a caller's deadline expiring after the guest call returned +// must not skip the wipe of the buffers that call staged. func (inst *instance) free(ctx context.Context, buf guestBuf) { if buf.ptr != 0 { - _, _ = inst.dealloc.Call(ctx, uint64(buf.ptr), uint64(buf.len)) + _, _ = inst.dealloc.Call(context.WithoutCancel(ctx), uint64(buf.ptr), uint64(buf.len)) } } diff --git a/languages/golang/stackencrypt/guest_test.go b/languages/golang/stackencrypt/guest_test.go index 53489c140..d0602452b 100644 --- a/languages/golang/stackencrypt/guest_test.go +++ b/languages/golang/stackencrypt/guest_test.go @@ -9,11 +9,15 @@ import ( "io" "net/http" "net/http/httptest" + "reflect" + "sort" "strings" "testing" + "time" "github.com/cipherstash/vitaminc/bindings/go/vcvalue" "github.com/tetratelabs/wazero" + "github.com/tetratelabs/wazero/sys" ) // Tests that drive the embedded guest without a live ZeroKMS. What they @@ -124,7 +128,8 @@ func TestImportSurfaceIsWASIPlusTransport(t *testing.T) { } } want := []string{"token_get", "transport_send"} - if len(transportImports) != 2 || (transportImports[0] != want[0] && transportImports[0] != want[1]) { + sort.Strings(transportImports) + if !reflect.DeepEqual(transportImports, want) { t.Fatalf("transport imports = %v, want %v", transportImports, want) } for name := range map[string]bool{"se_alloc": true, "se_dealloc": true, "se_cipher_init": true, "se_shutdown": true, "se_keyset": true, "se_encrypt": true, "se_decrypt": true, "se_encrypt_element": true, "se_decrypt_element": true, "se_term": true, "se_encrypt_record": true, "se_decrypt_record": true} { @@ -224,6 +229,83 @@ type roundTripFunc func(*http.Request) (*http.Response, error) func (f roundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) } +// An interrupted call closes the module (WithCloseOnContextDone); the +// client must then be closed rather than a wedge or a runtime error. +func TestInterruptedCallClosesTheClient(t *testing.T) { + t.Run("deadline during a request", func(t *testing.T) { + guestOrSkip(t) + cfg := testConfig("http://zerokms.invalid") + cfg.Transport = roundTripFunc(func(r *http.Request) (*http.Response, error) { + <-r.Context().Done() + return nil, r.Context().Err() + }) + ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond) + defer cancel() + _, err := NewClient(ctx, cfg) + if !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("NewClient: %v, want the deadline", err) + } + }) + t.Run("closed module is ErrState", func(t *testing.T) { + ctx := context.Background() + c := rawInstance(t) + // What the runtime does to the module when a call's context ends. + if err := c.inst.module.CloseWithExitCode(ctx, sys.ExitCodeContextCanceled); err != nil { + t.Fatal(err) + } + if _, err := c.Keyset(ctx, KeysetName("k")); !errors.Is(err, ErrState) { + t.Fatalf("Keyset on a closed module: %v, want ErrState", err) + } + if err := c.Close(ctx); err != nil { + t.Fatalf("Close after interruption: %v", err) + } + }) +} + +// A response the host would have to buffer without bound is refused as a +// transport failure, whether the size is announced or streamed. +func TestOversizedResponseIsTransport(t *testing.T) { + guestOrSkip(t) + respond := func(length int64, body io.Reader) roundTripFunc { + return func(*http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": {"application/json"}}, + ContentLength: length, + Body: io.NopCloser(body), + }, nil + } + } + for name, rt := range map[string]roundTripFunc{ + "announced": respond(maxResponseBytes+1, strings.NewReader("{}")), + "streamed": respond(-1, io.MultiReader(strings.NewReader("{"), &zeros{n: maxResponseBytes})), + } { + t.Run(name, func(t *testing.T) { + cfg := testConfig("http://zerokms.invalid") + cfg.Transport = rt + _, err := NewClient(context.Background(), cfg) + if !errors.Is(err, ErrTransport) { + t.Fatalf("NewClient: %v, want ErrTransport", err) + } + }) + } +} + +// zeros reads n zero bytes. +type zeros struct{ n int } + +func (z *zeros) Read(p []byte) (int, error) { + if z.n == 0 { + return 0, io.EOF + } + if len(p) > z.n { + p = p[:z.n] + } + clear(p) + z.n -= len(p) + return len(p), nil +} + func TestConfigValidation(t *testing.T) { ctx := context.Background() for name, cfg := range map[string]Config{ diff --git a/languages/golang/stackencrypt/transport.go b/languages/golang/stackencrypt/transport.go index 932071537..e021a6410 100644 --- a/languages/golang/stackencrypt/transport.go +++ b/languages/golang/stackencrypt/transport.go @@ -57,6 +57,12 @@ const transportFailed int32 = -1 // hostFailed is the return value of token_get when no token is available. const hostFailed int32 = 1 +// maxResponseBytes bounds what transport_send will buffer from ZeroKMS. The +// guest issues at most one 500-key batch per request, which is well under a +// megabyte either way; the bound exists so that an endpoint the transport +// was pointed at cannot make the host allocate without limit. +const maxResponseBytes = 16 << 20 + // instantiate registers the host module in r. func (t *transport) instantiate(ctx context.Context, r wazero.Runtime) error { _, err := r.NewHostModuleBuilder(transportModule). @@ -81,6 +87,9 @@ func (t *transport) send(ctx context.Context, m api.Module, mem := m.Memory() status, respHeaders, respBody := t.perform(ctx, mem, methodPtr, methodLen, urlPtr, urlLen, headersPtr, headersLen, bodyPtr, bodyLen) + // The response carries wrapped key material; once it is in guest memory + // the host copy is wiped. + defer wipe(respBody) if !place(ctx, m, respHeadersPtrOut, respHeadersLenOut, respHeaders) || !place(ctx, m, respBodyPtrOut, respBodyLenOut, respBody) { // The guest reclaims whatever was placed and refuses an unplaced @@ -102,9 +111,11 @@ func (t *transport) perform(ctx context.Context, mem api.Memory, } // The request body may carry key-material contexts; it is copied // because the guest wipes its own buffer when the call returns, and the - // RoundTripper may read it after this function has. + // RoundTripper may read it after this function has. The copy is wiped + // once the round trip is over. reqBody := make([]byte, len(body)) copy(reqBody, body) + defer wipe(reqBody) req, err := http.NewRequestWithContext(ctx, string(method), string(url), bytes.NewReader(reqBody)) if err != nil { return transportFailed, nil, []byte(err.Error()) @@ -115,10 +126,17 @@ func (t *transport) perform(ctx context.Context, mem api.Memory, return transportFailed, nil, []byte(err.Error()) } defer resp.Body.Close() - respBody, err := io.ReadAll(resp.Body) + if resp.ContentLength > maxResponseBytes { + return transportFailed, nil, fmt.Appendf(nil, "response of %d bytes exceeds the %d-byte limit", resp.ContentLength, maxResponseBytes) + } + respBody, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBytes+1)) if err != nil { return transportFailed, nil, []byte(err.Error()) } + if len(respBody) > maxResponseBytes { + wipe(respBody) + return transportFailed, nil, fmt.Appendf(nil, "response exceeds the %d-byte limit", maxResponseBytes) + } return int32(resp.StatusCode), encodeHeaders(resp.Header), respBody } @@ -128,7 +146,11 @@ func (t *transport) tokenGet(ctx context.Context, m api.Module, tokenPtrOut, tok if err != nil || token == "" { return hostFailed } - if !place(ctx, m, tokenPtrOut, tokenLenOut, []byte(token)) { + // The credential's transport copy is wiped once it is in guest memory; + // the TokenSource's own string is the source's. + tok := []byte(token) + defer wipe(tok) + if !place(ctx, m, tokenPtrOut, tokenLenOut, tok) { return hostFailed } return 0 From 25af1b9ab971bfa1a6a35d895209787a20de02b6 Mon Sep 17 00:00:00 2001 From: Dan Draper <dan@cipherstash.com> Date: Sat, 12 Sep 2026 21:11:32 -0400 Subject: [PATCH 546/686] fix(stack-encrypt): a partial response is wiped, and an interrupted call still leaves a runtime to release MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two things the Go host let slip. `io.ReadAll` returns the bytes it managed to read *alongside* its error, and `perform` dropped that slice on the floor for the collector. Those bytes are a fragment of a ZeroKMS reply, which is where wrapped key material lives, so the error branch now wipes them — the discipline the request copy and the over-limit branch either side of it already keep. `Close` was idempotent on the wrong flag. An interrupted call closes the module and sets `closed` so later calls are `ErrState`; a deferred `Close` then saw `closed` and returned before `runtime.Close`, leaving the wazero runtime and the host transport module registered for the life of the process — precisely the case `Client.call`'s own doc comment says `Close` handles. Runtime release is now its own flag: `Close` still refuses to repeat itself, but repeats are measured against the runtime it frees, not against a client something else closed. The interruption test asserts the release two ways — the flag, and the host module being gone from the runtime — and a new test pins the verdict on a body that fails mid-read. Neither passes against the old code. Claude-Session: https://claude.ai/code/session_01QbNwRNqa1qo3b8ZSqULsGE --- languages/golang/stackencrypt/client.go | 17 ++++++-- languages/golang/stackencrypt/guest_test.go | 43 +++++++++++++++++++++ languages/golang/stackencrypt/transport.go | 6 +++ 3 files changed, 63 insertions(+), 3 deletions(-) diff --git a/languages/golang/stackencrypt/client.go b/languages/golang/stackencrypt/client.go index 86616bfd0..97a5fae51 100644 --- a/languages/golang/stackencrypt/client.go +++ b/languages/golang/stackencrypt/client.go @@ -48,8 +48,16 @@ type Client struct { mu sync.Mutex inst *instance transport *transport - closed bool - def KeysetID + // closed refuses further calls: either Close ran, or an interrupted + // call took the module down under us (see Client.call). released is + // the runtime's own state, tracked apart from it because those two + // things come apart: an interrupted call closes the module — and so + // the client — while the runtime and the host modules beside it are + // still allocated. Close is what frees those, so it must do its work + // even on a client that is already closed. + closed bool + released bool + def KeysetID } // NewClient instantiates the guest, loads the client key into it, and loads @@ -133,9 +141,12 @@ func encodeConfig(cfg Config) ([]byte, error) { func (c *Client) Close(ctx context.Context) error { c.mu.Lock() defer c.mu.Unlock() - if c.closed { + // Idempotency turns on the runtime, not on the client: a client an + // interrupted call already closed has never released its runtime. + if c.released { return nil } + c.released = true c.closed = true ctx = context.WithoutCancel(ctx) // A module closed by an interrupted call (see Client.call) or by a trap diff --git a/languages/golang/stackencrypt/guest_test.go b/languages/golang/stackencrypt/guest_test.go index d0602452b..2a2475ce8 100644 --- a/languages/golang/stackencrypt/guest_test.go +++ b/languages/golang/stackencrypt/guest_test.go @@ -259,6 +259,20 @@ func TestInterruptedCallClosesTheClient(t *testing.T) { if err := c.Close(ctx); err != nil { t.Fatalf("Close after interruption: %v", err) } + // The close must reach the runtime. An interrupted call closes the + // module and marks the client closed; a Close that treated that as + // "already done" would leave the runtime and the host module it + // carries allocated for the life of the process. + if !c.released { + t.Error("Close after interruption left the runtime unreleased") + } + if m := c.inst.runtime.Module(transportModule); m != nil { + t.Errorf("host module %s is still registered after Close", transportModule) + } + // Still idempotent. + if err := c.Close(ctx); err != nil { + t.Fatalf("second Close: %v", err) + } }) } @@ -291,6 +305,35 @@ func TestOversizedResponseIsTransport(t *testing.T) { } } +// A body that fails partway through is a transport failure, not a partial +// response the guest is handed. io.ReadAll returns the bytes it managed to +// read alongside the error; those bytes are a fragment of a ZeroKMS reply +// and are wiped before the error goes back (see transport.perform) — the +// wipe is not observable from here, but the verdict is. +func TestInterruptedResponseBodyIsTransport(t *testing.T) { + guestOrSkip(t) + cfg := testConfig("http://zerokms.invalid") + cfg.Transport = roundTripFunc(func(*http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": {"application/json"}}, + ContentLength: -1, + Body: io.NopCloser(io.MultiReader( + strings.NewReader(`{"partial":"`), + &failingReader{err: io.ErrUnexpectedEOF}, + )), + }, nil + }) + if _, err := NewClient(context.Background(), cfg); !errors.Is(err, ErrTransport) { + t.Fatalf("NewClient: %v, want ErrTransport", err) + } +} + +// failingReader fails every read. +type failingReader struct{ err error } + +func (f *failingReader) Read([]byte) (int, error) { return 0, f.err } + // zeros reads n zero bytes. type zeros struct{ n int } diff --git a/languages/golang/stackencrypt/transport.go b/languages/golang/stackencrypt/transport.go index e021a6410..c6a9a1d0f 100644 --- a/languages/golang/stackencrypt/transport.go +++ b/languages/golang/stackencrypt/transport.go @@ -131,6 +131,12 @@ func (t *transport) perform(ctx context.Context, mem api.Memory, } respBody, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBytes+1)) if err != nil { + // ReadAll hands back what it managed to read alongside the error. + // Those bytes are a partial ZeroKMS response and can carry wrapped + // key material, so they are wiped rather than dropped on the floor + // for the collector — the same discipline as the over-limit branch + // below. + wipe(respBody) return transportFailed, nil, []byte(err.Error()) } if len(respBody) > maxResponseBytes { From e6f0f14535f6a24fa630ee0fa888831d85c4942f Mon Sep 17 00:00:00 2001 From: Dan Draper <dan@cipherstash.com> Date: Sat, 12 Sep 2026 21:44:35 -0400 Subject: [PATCH 547/686] fix(stack-encrypt): an empty part is refused where it is made, the default keyset cannot be reassigned, and WASI instantiation returns its error MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `NewContext("")` handed back a Context that could not work. The guest proves every context non-empty at its boundary, and a bare empty string or byte slice is empty by that rule, so the value was born certain to fail with `ErrEncoding` at every call site it reached — and `MustContext("")`, the spelling meant for literals, announced success on the way. Rust refuses the same thing a step earlier still: `nonempty!("")` does not compile. The root part is now checked where it is supplied. The check is the root's alone, because emptiness belongs to the tree: a list is empty only when every part is, so `["users/age", ""]` is a context the guest takes and `With` keeps taking one. The new test pins both halves, the second by watching an accepted context reach the state check where a refused one stops at parse; the existing malformed-input case builds its empty context by hand so the guest's own boundary check stays covered from this side. `DefaultKeyset` was a package-level var of interface type, so any importer could assign a name — or nil — over it and redirect `DefaultCipher` and every nil selector for the whole process, with a data race for company. It keeps its inferred concrete type now: still a `KeysetSelector` everywhere one is wanted, and the only value it can be given is the one it holds. `wasi_snapshot_preview1.MustInstantiate` panics on any error and strands the runtime it was instantiating into — the wrong failure mode for a library constructor, and unlike the host-transport line directly below it. No error is reachable there today, so this changes no outcome; it removes a panic from a path that should not have one. Claude-Session: https://claude.ai/code/session_01QbNwRNqa1qo3b8ZSqULsGE --- languages/golang/stackencrypt/context.go | 38 +++++++++++++-- languages/golang/stackencrypt/guest.go | 11 ++++- languages/golang/stackencrypt/guest_test.go | 54 ++++++++++++++++++--- languages/golang/stackencrypt/keyset.go | 10 +++- 4 files changed, 102 insertions(+), 11 deletions(-) diff --git a/languages/golang/stackencrypt/context.go b/languages/golang/stackencrypt/context.go index 400eb17d2..4b8c6e750 100644 --- a/languages/golang/stackencrypt/context.go +++ b/languages/golang/stackencrypt/context.go @@ -1,6 +1,9 @@ package stackencrypt -import "fmt" +import ( + "errors" + "fmt" +) // Context is the encryption context a record field or a term probe binds: // a domain-separating value that becomes both the ciphertext's AAD (and @@ -21,16 +24,29 @@ type Context struct { node any } -// NewContext makes a one-part context. +// NewContext makes a one-part context. The part must not be empty: a bare +// empty string or empty byte slice is an empty context, and the guest +// proves every context non-empty at the boundary, so such a Context could +// only ever fail — every call, with ErrEncoding. Rust refuses the same +// thing one step earlier: nonempty!("") does not compile. +// +// Emptiness is the whole tree's property, not the part's — a list is empty +// only when every part is — so [Context.With] may still add an empty part +// to a context that already has a non-empty one. Only the root is checked +// here. func NewContext(part any) (Context, error) { if err := checkPart(part); err != nil { return Context{}, err } + if err := checkRootNonEmpty(part); err != nil { + return Context{}, err + } return Context{node: part}, nil } // MustContext is [NewContext] for a part known to be valid; it panics -// otherwise. For string literals in plans and probes. +// otherwise, an empty part included. For string literals in plans and +// probes. func MustContext(part any) Context { c, err := NewContext(part) if err != nil { @@ -54,6 +70,22 @@ func (c Context) With(part any) (Context, error) { // lists of scalars, ready for the transport codec. func (c Context) value() any { return c.node } +// checkRootNonEmpty refuses the bare parts that are themselves an empty +// context. Integers never are, whatever their value. +func checkRootNonEmpty(part any) error { + switch p := part.(type) { + case string: + if p == "" { + return errors.New("stackencrypt: an empty string is an empty context") + } + case []byte: + if len(p) == 0 { + return errors.New("stackencrypt: an empty byte slice is an empty context") + } + } + return nil +} + func checkPart(part any) error { switch part.(type) { case string, []byte, int32, int64, uint32, uint64, int: diff --git a/languages/golang/stackencrypt/guest.go b/languages/golang/stackencrypt/guest.go index 5415b7b69..9d40b7d68 100644 --- a/languages/golang/stackencrypt/guest.go +++ b/languages/golang/stackencrypt/guest.go @@ -90,7 +90,16 @@ func newInstance(ctx context.Context, wasm []byte, t *transport) (*instance, err WithCompilationCache(compilationCache()). WithCloseOnContextDone(true) runtime := wazero.NewRuntimeWithConfig(ctx, config) - wasi_snapshot_preview1.MustInstantiate(ctx, runtime) + // The Must* form of this panics on any error, which is the wrong + // failure mode for a constructor in a library and would strand the + // runtime it was instantiating into. No error is reachable here today — + // the host module is fixed and the runtime is new and private, so there + // is nothing for it to collide with — so this is the total form of a + // call that does not currently fail, matching the host transport below. + if _, err := wasi_snapshot_preview1.Instantiate(ctx, runtime); err != nil { + _ = runtime.Close(ctx) + return nil, fmt.Errorf("stackencrypt: instantiating WASI: %w", err) + } if err := t.instantiate(ctx, runtime); err != nil { _ = runtime.Close(ctx) return nil, err diff --git a/languages/golang/stackencrypt/guest_test.go b/languages/golang/stackencrypt/guest_test.go index 2a2475ce8..44a6bdc2f 100644 --- a/languages/golang/stackencrypt/guest_test.go +++ b/languages/golang/stackencrypt/guest_test.go @@ -305,6 +305,42 @@ func TestOversizedResponseIsTransport(t *testing.T) { } } +// A bare empty part is an empty context, which the guest refuses at the +// boundary — so the constructor refuses it first, rather than handing back +// a Context that fails every call it is used in. A list is empty only when +// every part is, so With may still carry one. +func TestEmptyContextIsRefusedAtTheRoot(t *testing.T) { + for name, part := range map[string]any{"string": "", "bytes": []byte{}} { + t.Run(name, func(t *testing.T) { + if _, err := NewContext(part); err == nil { + t.Fatal("NewContext accepted an empty part") + } + func() { + defer func() { + if recover() == nil { + t.Error("MustContext did not panic on an empty part") + } + }() + _ = MustContext(part) + }() + }) + } + // The rule is the tree's: an empty part beside a non-empty one is a + // context the guest takes, so With must not inherit the root's check. + mixed, err := MustContext("users/age").With("") + if err != nil { + t.Fatalf("With(empty): %v", err) + } + guestOrSkip(t) + ctx := context.Background() + // No cipher on a raw instance, so a context the guest accepts reaches + // the state check — ErrState here means the context itself passed, + // where a refused one is ErrEncoding before it. + if _, err := rawInstance(t).DefaultCipher().Term(ctx, uint32(34), mixed, Equality); !errors.Is(err, ErrState) { + t.Fatalf("Term under [non-empty, empty]: %v, want ErrState (the context accepted)", err) + } +} + // A body that fails partway through is a transport failure, not a partial // response the guest is handed. io.ReadAll returns the bytes it managed to // read alongside the error; those bytes are a fragment of a ZeroKMS reply @@ -477,12 +513,18 @@ func TestGuestRefusesMalformedInputsBeforeState(t *testing.T) { "float under equality": func() error { _, err := def.Term(ctx, 1.5, MustContext("k"), Equality); return err }, "integer under match": func() error { _, err := def.Term(ctx, 1, MustContext("k"), Match); return err }, "container as term value": func() error { _, err := def.Term(ctx, []any{1}, MustContext("k"), Ore); return err }, - "empty context part": func() error { _, err := def.Term(ctx, 1, MustContext(""), Equality); return err }, - "unknown term kind": func() error { _, err := def.Term(ctx, 1, MustContext("k"), TermKind(9)); return err }, - "name with spaces": func() error { _, err := c.Keyset(ctx, KeysetName("not a name")); return err }, - "empty name": func() error { _, err := c.Keyset(ctx, KeysetName("")); return err }, - "any as a keyset": func() error { _, err := c.Keyset(ctx, anyKeyset{}); return err }, - "float under eq in plan": func() error { _, err := def.EncryptRecords(ctx, []badRow{{1.5}}); return err }, + // NewContext refuses this one now (see + // TestEmptyContextIsRefusedAtTheRoot); built by hand so the guest's + // own boundary check stays covered from this side too. + "empty context part": func() error { + _, err := def.Term(ctx, 1, Context{node: ""}, Equality) + return err + }, + "unknown term kind": func() error { _, err := def.Term(ctx, 1, MustContext("k"), TermKind(9)); return err }, + "name with spaces": func() error { _, err := c.Keyset(ctx, KeysetName("not a name")); return err }, + "empty name": func() error { _, err := c.Keyset(ctx, KeysetName("")); return err }, + "any as a keyset": func() error { _, err := c.Keyset(ctx, anyKeyset{}); return err }, + "float under eq in plan": func() error { _, err := def.EncryptRecords(ctx, []badRow{{1.5}}); return err }, "malformed leaf": func() error { _, err := c.Decrypt(ctx, Sealed{1, 2, 3}, nil) return err diff --git a/languages/golang/stackencrypt/keyset.go b/languages/golang/stackencrypt/keyset.go index 2d58cca72..ddbedb22a 100644 --- a/languages/golang/stackencrypt/keyset.go +++ b/languages/golang/stackencrypt/keyset.go @@ -65,7 +65,15 @@ func (defaultKeyset) selector() map[string]any { return map[string]any{"default" // DefaultKeyset selects the client's default keyset: the one named in // [Config.Keyset], else the ZeroKMS client's own default. Selecting it is // never a round trip. -var DefaultKeyset KeysetSelector = defaultKeyset{} +// +// Its type is the unexported concrete one rather than [KeysetSelector] on +// purpose. A package-level var of interface type is writable by every +// importer, so one package could point this at a named keyset — or nil — +// and silently redirect [Client.DefaultCipher] and every nil selector for +// the whole process, racily. As a concrete zero-size struct it still +// passes anywhere a KeysetSelector is wanted, and the only value it can be +// reassigned is the one it already holds. +var DefaultKeyset = defaultKeyset{} // anyKeyset is the opening-only selector: open every leaf under whichever // keyset it was sealed with. Not exported — the Client's own decrypt From 360a1d73f7f2fdd3de85d4e1251614f79aa5a9b6 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Sun, 13 Sep 2026 17:00:29 +0000 Subject: [PATCH 548/686] fix(stack-encrypt): wipe the request body when the transport closes it, not when RoundTrip returns A RoundTripper may keep reading the request body, and close it, in another goroutine after RoundTrip has returned, including on the error path. The host copy was wiped on perform's return, so a conforming transport could race the wipe and put a truncated or zeroed ZeroKMS request on the wire. The copy is now owned by a ReadCloser that wipes on Close, the one point the contract says the transport is done with it; Read and Close are serialised, and a read after Close is an error rather than zeros. ContentLength is set explicitly since net/http cannot size the new body. GetBody is not provided: replay would need the plaintext to outlive Close, and the guest only POSTs, which net/http never replays. TestRequestBodyOutlivesTheRoundTrip drains the body strictly after the guest call has returned and fails against the old code; TestRequestBodyWipesOnClose pins the body's own contract. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JdSTr8m9r5q3AbkJR11vey --- languages/golang/stackencrypt/guest_test.go | 53 ++++++++++++++ languages/golang/stackencrypt/transport.go | 76 +++++++++++++++++++-- languages/golang/stackencrypt/unit_test.go | 37 ++++++++++ 3 files changed, 159 insertions(+), 7 deletions(-) diff --git a/languages/golang/stackencrypt/guest_test.go b/languages/golang/stackencrypt/guest_test.go index 44a6bdc2f..02a0f3d5e 100644 --- a/languages/golang/stackencrypt/guest_test.go +++ b/languages/golang/stackencrypt/guest_test.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "encoding/hex" + "encoding/json" "errors" "fmt" "io" @@ -365,6 +366,58 @@ func TestInterruptedResponseBodyIsTransport(t *testing.T) { } } +// A RoundTripper may keep reading the request body, and close it, in +// another goroutine after RoundTrip has returned — on the error path too. +// The host copy of the body must therefore survive until the transport +// closes it: a wipe on RoundTrip's return would race the send and put a +// truncated or zeroed request on the wire. Here the drain happens strictly +// after the whole guest call has returned, and must still see the request. +func TestRequestBodyOutlivesTheRoundTrip(t *testing.T) { + guestOrSkip(t) + returned := make(chan struct{}) + type drained struct { + req *http.Request + body []byte + err error + } + done := make(chan drained, 1) + cfg := testConfig("http://zerokms.invalid") + cfg.Transport = roundTripFunc(func(r *http.Request) (*http.Response, error) { + go func() { + <-returned + b, err := io.ReadAll(r.Body) + _ = r.Body.Close() + done <- drained{req: r, body: b, err: err} + }() + return nil, errors.New("connection reset") + }) + _, err := NewClient(context.Background(), cfg) + if !errors.Is(err, ErrTransport) { + t.Fatalf("NewClient: %v, want ErrTransport", err) + } + close(returned) + d := <-done + if d.err != nil { + t.Fatalf("reading the body after RoundTrip returned: %v", d.err) + } + if !json.Valid(d.body) || !bytes.Contains(d.body, []byte(testClientID)) { + t.Fatalf("body read after RoundTrip returned is not the request: %q", d.body) + } + // The length is declared, so the transport sends Content-Length rather + // than chunking a body it cannot size. + if d.req.ContentLength != int64(len(d.body)) { + t.Errorf("ContentLength = %d, want %d", d.req.ContentLength, len(d.body)) + } + // And once closed, the host copy is gone. + rb, ok := d.req.Body.(*requestBody) + if !ok { + t.Fatalf("request body is %T, want *requestBody", d.req.Body) + } + if !bytes.Equal(rb.buf, make([]byte, len(rb.buf))) { + t.Error("request body was not wiped on Close") + } +} + // failingReader fails every read. type failingReader struct{ err error } diff --git a/languages/golang/stackencrypt/transport.go b/languages/golang/stackencrypt/transport.go index c6a9a1d0f..a40b58965 100644 --- a/languages/golang/stackencrypt/transport.go +++ b/languages/golang/stackencrypt/transport.go @@ -1,13 +1,14 @@ package stackencrypt import ( - "bytes" "context" + "errors" "fmt" "io" "net/http" "sort" "strings" + "sync" "sync/atomic" "github.com/tetratelabs/wazero" @@ -111,15 +112,21 @@ func (t *transport) perform(ctx context.Context, mem api.Memory, } // The request body may carry key-material contexts; it is copied // because the guest wipes its own buffer when the call returns, and the - // RoundTripper may read it after this function has. The copy is wiped - // once the round trip is over. - reqBody := make([]byte, len(body)) - copy(reqBody, body) - defer wipe(reqBody) - req, err := http.NewRequestWithContext(ctx, string(method), string(url), bytes.NewReader(reqBody)) + // RoundTripper may read it after this function has. The copy is owned + // by the request body handed to the RoundTripper and is wiped when that + // body is closed — not here: RoundTrip may go on reading, and close, + // in another goroutine after it has returned, on the error path + // included, and a wipe racing that send would put a truncated or + // zeroed request on the wire. + reqBody := newRequestBody(body) + req, err := http.NewRequestWithContext(ctx, string(method), string(url), reqBody) if err != nil { + reqBody.Close() return transportFailed, nil, []byte(err.Error()) } + // NewRequest only infers a length from the readers it knows; without + // one the transport would send the body chunked. + req.ContentLength = int64(len(body)) req.Header = parseHeaders(headers) resp, err := t.rt.RoundTrip(req) if err != nil { @@ -146,6 +153,61 @@ func (t *transport) perform(ctx context.Context, mem api.Memory, return int32(resp.StatusCode), encodeHeaders(resp.Header), respBody } +// requestBody is the io.ReadCloser a guest request goes out as. It owns +// the host copy of the body and wipes it on Close, the one point at which +// the RoundTripper contract says the transport is done with it: RoundTrip +// must close the body, but may do so in another goroutine after it has +// returned, so nothing this side can wipe any earlier without racing the +// send. Read and Close are serialised for the same reason. A transport +// that never closes the body leaves it to the collector, as any body it +// was handed; a Close before the send is complete fails the read rather +// than sending zeros in place of the request. +// +// A body of this type has no GetBody, so net/http cannot replay the +// request on a reused connection that turns out to be dead. Replay would +// need the plaintext to outlive Close, and the guest only ever POSTs, +// which net/http does not replay in any case. +type requestBody struct { + mu sync.Mutex + buf []byte + off int + closed bool +} + +var errRequestBodyClosed = errors.New("stackencrypt: request body read after close") + +// newRequestBody copies src, which the caller does not keep alive. +func newRequestBody(src []byte) *requestBody { + buf := make([]byte, len(src)) + copy(buf, src) + return &requestBody{buf: buf} +} + +func (b *requestBody) Read(p []byte) (int, error) { + b.mu.Lock() + defer b.mu.Unlock() + if b.closed { + return 0, errRequestBodyClosed + } + if b.off >= len(b.buf) { + return 0, io.EOF + } + n := copy(p, b.buf[b.off:]) + b.off += n + return n, nil +} + +// Close wipes the body. It is idempotent and never fails. +func (b *requestBody) Close() error { + b.mu.Lock() + defer b.mu.Unlock() + if !b.closed { + wipe(b.buf) + b.closed = true + } + return nil +} + // tokenGet is token_get: hand the guest the current bearer token. func (t *transport) tokenGet(ctx context.Context, m api.Module, tokenPtrOut, tokenLenOut uint32) int32 { token, err := t.token.Token(ctx) diff --git a/languages/golang/stackencrypt/unit_test.go b/languages/golang/stackencrypt/unit_test.go index ed6f6ddf5..b28f46254 100644 --- a/languages/golang/stackencrypt/unit_test.go +++ b/languages/golang/stackencrypt/unit_test.go @@ -2,9 +2,11 @@ package stackencrypt import ( "bufio" + "bytes" "context" "encoding/hex" "errors" + "io" "net/http" "os" "reflect" @@ -288,6 +290,41 @@ func TestAssignFieldConvertsWithinFamiliesOnly(t *testing.T) { } } +// The request body wipes its buffer when closed, not before: reads up to +// Close see the bytes, Close zeroes them, a read after Close is an error +// rather than zeros, and a second Close is harmless. +func TestRequestBodyWipesOnClose(t *testing.T) { + src := []byte(`{"client_id":"abc"}`) + b := newRequestBody(src) + wipe(src) // the guest wipes its own buffer on return; the copy must not notice + got, err := io.ReadAll(b) + if err != nil || string(got) != `{"client_id":"abc"}` { + t.Fatalf("ReadAll = %q, %v", got, err) + } + if err := b.Close(); err != nil { + t.Fatalf("Close: %v", err) + } + if !bytes.Equal(b.buf, make([]byte, len(b.buf))) { + t.Errorf("buffer after Close = %q, want zeros", b.buf) + } + if _, err := b.Read(make([]byte, 1)); !errors.Is(err, errRequestBodyClosed) { + t.Errorf("Read after Close: %v, want errRequestBodyClosed", err) + } + if err := b.Close(); err != nil { + t.Fatalf("second Close: %v", err) + } + // A close before the send is complete fails the send rather than + // letting zeros through as the request. + b = newRequestBody([]byte("0123456789")) + if n, err := b.Read(make([]byte, 4)); n != 4 || err != nil { + t.Fatalf("partial Read = %d, %v", n, err) + } + _ = b.Close() + if _, err := io.ReadAll(b); !errors.Is(err, errRequestBodyClosed) { + t.Errorf("ReadAll after an early Close: %v, want errRequestBodyClosed", err) + } +} + func TestHeadersRoundTrip(t *testing.T) { h := http.Header{} h.Add("Content-Type", "application/json") From f5a772edeb0a6382e08d879d349c62f618b00c2c Mon Sep 17 00:00:00 2001 From: Dan Draper <dan@cipherstash.com> Date: Sun, 13 Sep 2026 23:01:19 -0400 Subject: [PATCH 549/686] feat(stack-encrypt)!: a client does not name its own default keyset MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Config.Keyset` mapped to the guest's `keyset` / `keyset_id` config keys, which are gone: the default keyset is set on the server, and a client does not get to redefine it. That is the same reason `StackCipherBuilder::keyset` was removed upstream, one layer out. Left in place the field would have been worse than useless. The guest now rejects unknown config keys, so `Config.Keyset` would fail at `NewClient` with a message about a key the Go caller never typed; and if it had not failed, `DefaultKeyset` would still have resolved to the client's own default, silently encrypting somewhere the caller did not name. `DefaultKeyset` and `DefaultKeysetID` now say whose default it is, and that there is no way to redefine it from here. Any other keyset is named per call with `KeysetName` or `KeysetID` — unchanged, and the reason this costs nothing: per-call selection is what the guest's options object exists for. The two config-validation cases that exercised the field go with it. Nothing else in the Go surface referenced it. BREAKING CHANGE: `Config.Keyset` is removed. Select a keyset per call instead; `DefaultKeyset` means the keyset a ZeroKMS administrator set for this client. Claude-Session: https://claude.ai/code/session_019b9YUGNPBrj9UUX2aZ8RbA --- languages/golang/stackencrypt/client.go | 16 ++-------------- languages/golang/stackencrypt/guest_test.go | 3 --- languages/golang/stackencrypt/keyset.go | 11 ++++++++--- 3 files changed, 10 insertions(+), 20 deletions(-) diff --git a/languages/golang/stackencrypt/client.go b/languages/golang/stackencrypt/client.go index 97a5fae51..34702ad82 100644 --- a/languages/golang/stackencrypt/client.go +++ b/languages/golang/stackencrypt/client.go @@ -22,9 +22,6 @@ type Config struct { // buffer before any request is made; the Go-side copy this package // makes is wiped too. The caller's own string is the caller's. ClientKey string - // Keyset pins the client's default keyset: a KeysetName or KeysetID. - // Nil, or DefaultKeyset, means the ZeroKMS client's own default. - Keyset KeysetSelector // ZeroKMSURL pins the ZeroKMS endpoint. When empty the endpoint is // resolved from the access token's services claim on first use. ZeroKMSURL string @@ -113,15 +110,6 @@ func encodeConfig(cfg Config) ([]byte, error) { {Key: "client_id", Value: cfg.ClientID}, {Key: "client_key", Value: cfg.ClientKey}, } - switch k := cfg.Keyset.(type) { - case nil, defaultKeyset: - case KeysetName: - fields = append(fields, vcvalue.Field{Key: "keyset", Value: string(k)}) - case KeysetID: - fields = append(fields, vcvalue.Field{Key: "keyset_id", Value: k.String()}) - default: - return nil, fmt.Errorf("stackencrypt: Config.Keyset must be a KeysetName or KeysetID, not %T", cfg.Keyset) - } if cfg.ZeroKMSURL != "" { fields = append(fields, vcvalue.Field{Key: "zerokms_url", Value: cfg.ZeroKMSURL}) } @@ -158,8 +146,8 @@ func (c *Client) Close(ctx context.Context) error { return c.inst.close(ctx) } -// DefaultKeysetID is the id of the client's default keyset, resolved at -// NewClient. +// DefaultKeysetID is the id of the client's default keyset — the one a +// ZeroKMS administrator set for this client — resolved at NewClient. func (c *Client) DefaultKeysetID() KeysetID { return c.def } // Keyset resolves a selector to its keyset id: the first use of a name or diff --git a/languages/golang/stackencrypt/guest_test.go b/languages/golang/stackencrypt/guest_test.go index 02a0f3d5e..6b34db315 100644 --- a/languages/golang/stackencrypt/guest_test.go +++ b/languages/golang/stackencrypt/guest_test.go @@ -446,8 +446,6 @@ func TestConfigValidation(t *testing.T) { "no key": {ClientID: testClientID, Token: StaticToken("t")}, "negative cache": {ClientID: testClientID, ClientKey: testClientKey, Token: StaticToken("t"), KeysetCacheSize: -1}, - "any keyset as default": {ClientID: testClientID, ClientKey: testClientKey, Token: StaticToken("t"), - Keyset: anyKeyset{}}, } { if _, err := NewClient(ctx, cfg); err == nil { t.Errorf("%s: NewClient succeeded", name) @@ -459,7 +457,6 @@ func TestConfigValidation(t *testing.T) { "client id not a uuid": func(c *Config) { c.ClientID = "acme" }, "key not hex": func(c *Config) { c.ClientKey = "zz" }, "bad url": func(c *Config) { c.ZeroKMSURL = "not a url" }, - "name with spaces": func(c *Config) { c.Keyset = KeysetName("not a name") }, } { stub := newStub(t, http.StatusOK, "application/json", "{}") cfg := testConfig(stub.URL) diff --git a/languages/golang/stackencrypt/keyset.go b/languages/golang/stackencrypt/keyset.go index ddbedb22a..315cbfb5d 100644 --- a/languages/golang/stackencrypt/keyset.go +++ b/languages/golang/stackencrypt/keyset.go @@ -62,9 +62,14 @@ type defaultKeyset struct{} func (defaultKeyset) selector() map[string]any { return map[string]any{"default": map[string]any{}} } -// DefaultKeyset selects the client's default keyset: the one named in -// [Config.Keyset], else the ZeroKMS client's own default. Selecting it is -// never a round trip. +// DefaultKeyset selects the client's default keyset — the one a ZeroKMS +// administrator set for this client. Selecting it is never a round trip. +// +// There is no way to redefine it from here. A client does not get to decide +// which keyset is its default; that is the server's to say, and a config key +// that appeared to override it would encrypt somewhere the operator did not +// choose. Any other keyset is named per call, with [KeysetName] or +// [KeysetID]. // // Its type is the unexported concrete one rather than [KeysetSelector] on // purpose. A package-level var of interface type is writable by every From e748c5ffec456d10b76d606a900bb5d4c0c90cb1 Mon Sep 17 00:00:00 2001 From: Dan Draper <dan@cipherstash.com> Date: Fri, 18 Sep 2026 09:21:44 -0700 Subject: [PATCH 550/686] feat(stack-encrypt)!: the Go keyset API is the Rust one The Go binding selected a keyset three ways where Rust selects it two: `Cipher(sel)` took a `KeysetName`, a `KeysetID`, or an exported `DefaultKeyset` value (and treated nil as that value), with `DefaultCipher()` as shorthand. Rust has no default variant in `IdentifiedBy`; `StackCipher::default_keyset` is a method and `StackCipher::keyset` takes a name or an id. The extra Go value then had to be defended against reassignment with an unexported struct type and a long comment, a symptom of a value Rust never needed. Now `Client.Keyset(sel)` is `StackCipher::keyset` and `Client.DefaultKeyset()` is `StackCipher::default_keyset`; both return the `*Cipher` bound to that keyset. Selection stays lazy, since Go has no await: the keyset is resolved by the guest on the cipher's first use, and `Cipher.KeysetID(ctx)`, the counterpart of `KeysetCipher::keyset_id`, is the one explicit resolution point. It replaces `Client.Keyset(ctx, sel)` and `Client.DefaultKeysetID()`, whose answer is now `client.DefaultKeyset().KeysetID(ctx)`. A nil selector is a programming error and panics rather than silently meaning the default. BREAKING CHANGE: `Client.Cipher(sel)` is `Client.Keyset(sel)`, `Client.DefaultCipher()` is `Client.DefaultKeyset()`, and the exported `DefaultKeyset` value is gone. `Client.Keyset(ctx, sel)` resolving an id is `client.Keyset(sel).KeysetID(ctx)`; `Client.DefaultKeysetID()` is `client.DefaultKeyset().KeysetID(ctx)`. Passing a nil selector panics. --- languages/golang/stackencrypt/cipher.go | 10 ++++ languages/golang/stackencrypt/client.go | 46 +++++++++---------- languages/golang/stackencrypt/doc.go | 7 +-- languages/golang/stackencrypt/guest_test.go | 28 +++++------ languages/golang/stackencrypt/keyset.go | 35 +++++--------- languages/golang/stackencrypt/live_test.go | 14 ++++-- .../golang/stackencrypt/order_live_test.go | 2 +- languages/golang/stackencrypt/unit_test.go | 4 +- 8 files changed, 74 insertions(+), 72 deletions(-) diff --git a/languages/golang/stackencrypt/cipher.go b/languages/golang/stackencrypt/cipher.go index 0fb66c090..5820a6aa8 100644 --- a/languages/golang/stackencrypt/cipher.go +++ b/languages/golang/stackencrypt/cipher.go @@ -27,6 +27,16 @@ func (cph *Cipher) Client() *Client { return cph.client } // Keyset is the selector this cipher is bound to. func (cph *Cipher) Keyset() KeysetSelector { return cph.keyset } +// KeysetID resolves the cipher's keyset to its id: Rust's +// KeysetCipher::keyset_id, and in Go the one explicit resolution point, +// since a Cipher is made without a request. The first use of a name or id +// on the client is one ZeroKMS round trip, later uses come from the +// guest's cache; the default keyset never makes a request. Use it at boot +// to validate a tenant's keyset and learn its id. +func (cph *Cipher) KeysetID(ctx context.Context) (KeysetID, error) { + return cph.client.resolveKeyset(ctx, cph.keyset) +} + // Encrypt seals v under this keyset. v is encoded through the vcvalue // model: builtins, slices, maps and structs by reflection, a type // implementing vcffi.Encryptable by its own encoding, vcvalue.Plain marking diff --git a/languages/golang/stackencrypt/client.go b/languages/golang/stackencrypt/client.go index 34702ad82..1618d1fec 100644 --- a/languages/golang/stackencrypt/client.go +++ b/languages/golang/stackencrypt/client.go @@ -146,18 +146,31 @@ func (c *Client) Close(ctx context.Context) error { return c.inst.close(ctx) } -// DefaultKeysetID is the id of the client's default keyset — the one a -// ZeroKMS administrator set for this client — resolved at NewClient. -func (c *Client) DefaultKeysetID() KeysetID { return c.def } - -// Keyset resolves a selector to its keyset id: the first use of a name or -// id on this client is one ZeroKMS round trip, later uses come from the -// guest's cache. Use it at boot to validate a tenant's keyset and learn its -// id. DefaultKeyset never makes a request. -func (c *Client) Keyset(ctx context.Context, sel KeysetSelector) (KeysetID, error) { +// Keyset binds the client to one keyset, by name or by id: Rust's +// StackCipher::keyset. No request is made here — Go has no await, so the +// keyset is resolved by the guest on the cipher's first use (and cached), +// which makes a Cipher cheap to make per call, per tenant or per request. +// [Cipher.KeysetID] is the explicit resolution point. A nil selector is a +// programming error and panics; the default keyset is [Client.DefaultKeyset]. +func (c *Client) Keyset(sel KeysetSelector) *Cipher { if sel == nil { - return KeysetID{}, errNilSelector + panic("stackencrypt: Client.Keyset(nil); the default keyset is Client.DefaultKeyset") } + return &Cipher{client: c, keyset: sel} +} + +// DefaultKeyset binds the client to its default keyset — the one a ZeroKMS +// administrator set for this client, which is what naming no keyset +// resolves to: Rust's StackCipher::default_keyset. Loaded at NewClient, so +// using it never touches ZeroKMS. There is no way to redefine it from here; +// which keyset is the default is the server's to say. Any other keyset is +// [Client.Keyset]. +func (c *Client) DefaultKeyset() *Cipher { return &Cipher{client: c, keyset: defaultKeyset{}} } + +// resolveKeyset asks the guest for a selector's keyset id: the first use +// of a name or id on this client is one ZeroKMS round trip, later uses +// come from the guest's cache. The default keyset never makes a request. +func (c *Client) resolveKeyset(ctx context.Context, sel KeysetSelector) (KeysetID, error) { encoded, err := vcffi.Marshal(sel.selector()) if err != nil { return KeysetID{}, err @@ -176,19 +189,6 @@ func (c *Client) Keyset(ctx context.Context, sel KeysetSelector) (KeysetID, erro return id, nil } -// Cipher binds the client to one keyset. No request is made here: the -// keyset is resolved by the guest on the cipher's first use (and cached), -// so a Cipher is cheap to make per call, per tenant or per request. -func (c *Client) Cipher(sel KeysetSelector) *Cipher { - if sel == nil { - sel = DefaultKeyset - } - return &Cipher{client: c, keyset: sel} -} - -// DefaultCipher is Cipher(DefaultKeyset). -func (c *Client) DefaultCipher() *Cipher { return c.Cipher(DefaultKeyset) } - // Decrypt opens a ciphertext produced by any keyset of this client: each // leaf is opened under the keyset it was sealed with, with batched key // retrievals per keyset (one per 500 leaves sealed under it). ct is the diff --git a/languages/golang/stackencrypt/doc.go b/languages/golang/stackencrypt/doc.go index 4169dfb59..b4cee79b8 100644 --- a/languages/golang/stackencrypt/doc.go +++ b/languages/golang/stackencrypt/doc.go @@ -13,9 +13,10 @@ // client key and every loaded index key are wiped before the instance is // freed — closing a wasm instance runs no Rust destructors on its own. // -// A [Cipher] is the client bound to one keyset ([Client.Cipher], -// [Client.DefaultCipher]): it seals values, derives terms and encrypts -// records under that keyset, and opens only that keyset's ciphertexts. The +// A [Cipher] is the client bound to one keyset ([Client.Keyset] and +// [Client.DefaultKeyset], the Rust crate's StackCipher::keyset and +// default_keyset): it seals values, derives terms and encrypts records +// under that keyset, and opens only that keyset's ciphertexts. The // [Client] itself opens ciphertexts from any keyset ([Client.Decrypt] and // friends), fetching batched key retrievals per keyset the leaves were // sealed under. diff --git a/languages/golang/stackencrypt/guest_test.go b/languages/golang/stackencrypt/guest_test.go index 6b34db315..b47ec1591 100644 --- a/languages/golang/stackencrypt/guest_test.go +++ b/languages/golang/stackencrypt/guest_test.go @@ -254,7 +254,7 @@ func TestInterruptedCallClosesTheClient(t *testing.T) { if err := c.inst.module.CloseWithExitCode(ctx, sys.ExitCodeContextCanceled); err != nil { t.Fatal(err) } - if _, err := c.Keyset(ctx, KeysetName("k")); !errors.Is(err, ErrState) { + if _, err := c.Keyset(KeysetName("k")).KeysetID(ctx); !errors.Is(err, ErrState) { t.Fatalf("Keyset on a closed module: %v, want ErrState", err) } if err := c.Close(ctx); err != nil { @@ -337,7 +337,7 @@ func TestEmptyContextIsRefusedAtTheRoot(t *testing.T) { // No cipher on a raw instance, so a context the guest accepts reaches // the state check — ErrState here means the context itself passed, // where a refused one is ErrEncoding before it. - if _, err := rawInstance(t).DefaultCipher().Term(ctx, uint32(34), mixed, Equality); !errors.Is(err, ErrState) { + if _, err := rawInstance(t).DefaultKeyset().Term(ctx, uint32(34), mixed, Equality); !errors.Is(err, ErrState) { t.Fatalf("Term under [non-empty, empty]: %v, want ErrState (the context accepted)", err) } } @@ -508,9 +508,9 @@ type recordRow struct { func TestGuestAcceptsEveryEncodingThisPackageBuilds(t *testing.T) { ctx := context.Background() c := rawInstance(t) - def := c.DefaultCipher() - named := c.Cipher(KeysetName("acme")) - byID := c.Cipher(KeysetID{9}) + def := c.DefaultKeyset() + named := c.Keyset(KeysetName("acme")) + byID := c.Keyset(KeysetID{9}) ct := map[string]any{"name": Sealed(fixtureLeaf), "note": vcvalue.Plain{V: "clear"}} record := EncryptedRecord{ "Age": {Ciphertext: Sealed(fixtureLeaf), Equality: EqualityTerm{1}, Ore: OreTerm{2}}, @@ -520,9 +520,9 @@ func TestGuestAcceptsEveryEncodingThisPackageBuilds(t *testing.T) { var out []recordRow var one recordRow calls := map[string]func() error{ - "Keyset by name": func() error { _, err := c.Keyset(ctx, KeysetName("acme")); return err }, - "Keyset by id": func() error { _, err := c.Keyset(ctx, KeysetID{9}); return err }, - "Keyset default": func() error { _, err := c.Keyset(ctx, DefaultKeyset); return err }, + "KeysetID by name": func() error { _, err := named.KeysetID(ctx); return err }, + "KeysetID by id": func() error { _, err := byID.KeysetID(ctx); return err }, + "KeysetID default": func() error { _, err := def.KeysetID(ctx); return err }, "Encrypt": func() error { _, err := def.Encrypt(ctx, map[string]any{"a": 1}, []byte("aad")); return err }, "EncryptElement": func() error { _, err := named.EncryptElement(ctx, "row", nil); return err }, "Decrypt bound": func() error { _, err := byID.Decrypt(ctx, ct, nil); return err }, @@ -555,7 +555,7 @@ func TestGuestAcceptsEveryEncodingThisPackageBuilds(t *testing.T) { func TestGuestRefusesMalformedInputsBeforeState(t *testing.T) { ctx := context.Background() c := rawInstance(t) - def := c.DefaultCipher() + def := c.DefaultKeyset() type badRow struct { Age float64 `stash:"context=users/age,index=eq"` } @@ -571,9 +571,9 @@ func TestGuestRefusesMalformedInputsBeforeState(t *testing.T) { return err }, "unknown term kind": func() error { _, err := def.Term(ctx, 1, MustContext("k"), TermKind(9)); return err }, - "name with spaces": func() error { _, err := c.Keyset(ctx, KeysetName("not a name")); return err }, - "empty name": func() error { _, err := c.Keyset(ctx, KeysetName("")); return err }, - "any as a keyset": func() error { _, err := c.Keyset(ctx, anyKeyset{}); return err }, + "name with spaces": func() error { _, err := c.Keyset(KeysetName("not a name")).KeysetID(ctx); return err }, + "empty name": func() error { _, err := c.Keyset(KeysetName("")).KeysetID(ctx); return err }, + "any as a keyset": func() error { _, err := c.Keyset(anyKeyset{}).KeysetID(ctx); return err }, "float under eq in plan": func() error { _, err := def.EncryptRecords(ctx, []badRow{{1.5}}); return err }, "malformed leaf": func() error { _, err := c.Decrypt(ctx, Sealed{1, 2, 3}, nil) @@ -602,7 +602,7 @@ func TestClosedClientIsState(t *testing.T) { if err := c.Close(ctx); err != nil { t.Fatalf("second Close: %v", err) } - if _, err := c.DefaultCipher().Encrypt(ctx, "x", nil); !errors.Is(err, ErrState) { + if _, err := c.DefaultKeyset().Encrypt(ctx, "x", nil); !errors.Is(err, ErrState) { t.Fatalf("Encrypt after Close: %v", err) } } @@ -649,7 +649,7 @@ func TestHostilePointerLengthPairsAreStatusesNotTraps(t *testing.T) { t.Fatalf("dealloc with a mismatched length trapped: %v", err) } // The instance still works. - if _, err := c.DefaultCipher().Encrypt(ctx, "alive", nil); !errors.Is(err, ErrState) { + if _, err := c.DefaultKeyset().Encrypt(ctx, "alive", nil); !errors.Is(err, ErrState) { t.Fatalf("instance poisoned: %v", err) } } diff --git a/languages/golang/stackencrypt/keyset.go b/languages/golang/stackencrypt/keyset.go index 315cbfb5d..1131d0d10 100644 --- a/languages/golang/stackencrypt/keyset.go +++ b/languages/golang/stackencrypt/keyset.go @@ -2,13 +2,14 @@ package stackencrypt import ( "encoding/hex" - "errors" "fmt" ) -// KeysetSelector names the keyset a call binds to. The three selectors are -// [KeysetName], [KeysetID] and [DefaultKeyset]; every variant is spelled, -// there is no empty-string or nil sentinel. Names follow ZeroKMS's rules +// KeysetSelector names the keyset a call binds to, as Rust's IdentifiedBy +// does: [KeysetName] or [KeysetID]. Every variant is spelled, there is no +// empty-string or nil sentinel; the default keyset is not a selector but +// [Client.DefaultKeyset], exactly as it is a method and not an IdentifiedBy +// variant in Rust. Names follow ZeroKMS's rules // (non-empty, at most 64 bytes, of A-Z a-z 0-9 _ - /), checked by the // guest before any request is made; ZeroKMS itself never issues a // UUID-shaped name, so a name and an id cannot be confused. @@ -58,28 +59,16 @@ func ParseKeysetID(s string) (KeysetID, error) { return id, nil } +// defaultKeyset is the guest's spelling of the client's default keyset — +// the one a ZeroKMS administrator set for this client. Selecting it is +// never a round trip. Not exported: the default is [Client.DefaultKeyset], +// a method, so there is no value an importer could reassign or pass by +// mistake, and no config key that appeared to override what is the +// server's to say. type defaultKeyset struct{} func (defaultKeyset) selector() map[string]any { return map[string]any{"default": map[string]any{}} } -// DefaultKeyset selects the client's default keyset — the one a ZeroKMS -// administrator set for this client. Selecting it is never a round trip. -// -// There is no way to redefine it from here. A client does not get to decide -// which keyset is its default; that is the server's to say, and a config key -// that appeared to override it would encrypt somewhere the operator did not -// choose. Any other keyset is named per call, with [KeysetName] or -// [KeysetID]. -// -// Its type is the unexported concrete one rather than [KeysetSelector] on -// purpose. A package-level var of interface type is writable by every -// importer, so one package could point this at a named keyset — or nil — -// and silently redirect [Client.DefaultCipher] and every nil selector for -// the whole process, racily. As a concrete zero-size struct it still -// passes anywhere a KeysetSelector is wanted, and the only value it can be -// reassigned is the one it already holds. -var DefaultKeyset = defaultKeyset{} - // anyKeyset is the opening-only selector: open every leaf under whichever // keyset it was sealed with. Not exported — the Client's own decrypt // methods are its spelling. @@ -91,5 +80,3 @@ func (anyKeyset) selector() map[string]any { return map[string]any{"any": map[st func options(sel KeysetSelector) map[string]any { return map[string]any{"keyset": sel.selector()} } - -var errNilSelector = errors.New("stackencrypt: keyset selector is nil") diff --git a/languages/golang/stackencrypt/live_test.go b/languages/golang/stackencrypt/live_test.go index 3207d0dd4..ef95dc176 100644 --- a/languages/golang/stackencrypt/live_test.go +++ b/languages/golang/stackencrypt/live_test.go @@ -41,7 +41,7 @@ type liveUser struct { func TestLiveValueRoundTrip(t *testing.T) { c := liveClient(t) ctx := t.Context() - cipher := c.DefaultCipher() + cipher := c.DefaultKeyset() aad := []byte("users/v1") in := map[string]any{"name": "alice", "age": uint32(34), "note": vcvalue.Plain{V: "clear"}} @@ -79,7 +79,11 @@ func TestLiveValueRoundTrip(t *testing.T) { } // The default keyset's id is what the leaves carry: the bound cipher of // that id opens them too. - if _, err := c.Cipher(c.DefaultKeysetID()).Decrypt(ctx, ct, aad); err != nil { + defID, err := cipher.KeysetID(ctx) + if err != nil { + t.Fatalf("resolve the default keyset: %v", err) + } + if _, err := c.Keyset(defID).Decrypt(ctx, ct, aad); err != nil { t.Fatalf("decrypt under the default keyset by id: %v", err) } } @@ -87,7 +91,7 @@ func TestLiveValueRoundTrip(t *testing.T) { func TestLiveRecordsAndTerms(t *testing.T) { c := liveClient(t) ctx := t.Context() - cipher := c.DefaultCipher() + cipher := c.DefaultKeyset() users := []liveUser{{1, 34, "alice@example.com"}, {2, 29, "bob@example.com"}} c.transport.sends.Store(0) @@ -148,12 +152,12 @@ func TestLiveForeignKeysetIsRefusedBeforeRetrieval(t *testing.T) { if other == "" { t.Skip("STACK_ENCRYPT_TEST_OTHER_KEYSET not set") } - ct, err := c.Cipher(KeysetName(other)).Encrypt(ctx, "tenant b", nil) + ct, err := c.Keyset(KeysetName(other)).Encrypt(ctx, "tenant b", nil) if err != nil { t.Fatal(err) } c.transport.sends.Store(0) - if _, err := c.DefaultCipher().Decrypt(ctx, ct, nil); !errors.Is(err, ErrForeignKeyset) { + if _, err := c.DefaultKeyset().Decrypt(ctx, ct, nil); !errors.Is(err, ErrForeignKeyset) { t.Fatalf("default cipher opened another keyset's leaf: %v", err) } if n := c.transport.sends.Load(); n != 0 { diff --git a/languages/golang/stackencrypt/order_live_test.go b/languages/golang/stackencrypt/order_live_test.go index 48e7e8964..f211e15c6 100644 --- a/languages/golang/stackencrypt/order_live_test.go +++ b/languages/golang/stackencrypt/order_live_test.go @@ -106,7 +106,7 @@ func adjacentProperty[T any](t *testing.T, cipher *Cipher, kind TermKind, values func TestLiveTermOrderIsPlaintextOrder(t *testing.T) { c := liveClient(t) - cipher := c.DefaultCipher() + cipher := c.DefaultKeyset() for _, kind := range []TermKind{Ore, Ope} { t.Run(kind.String(), func(t *testing.T) { t.Run("uint32", func(t *testing.T) { diff --git a/languages/golang/stackencrypt/unit_test.go b/languages/golang/stackencrypt/unit_test.go index b28f46254..60c922ef6 100644 --- a/languages/golang/stackencrypt/unit_test.go +++ b/languages/golang/stackencrypt/unit_test.go @@ -78,7 +78,7 @@ func TestCommitRecordsPreservesRowsAndIsAtomic(t *testing.T) { func TestEncryptRecordRejectsNil(t *testing.T) { c := &Client{closed: true} - cph := c.DefaultCipher() + cph := c.DefaultKeyset() for name, in := range map[string]any{"nil": nil, "nil pointer": (*taggedUser)(nil)} { if _, err := cph.EncryptRecord(context.Background(), in); err == nil || errors.Is(err, ErrState) { t.Errorf("EncryptRecord(%s): %v, want a record error", name, err) @@ -111,7 +111,7 @@ func TestSelectorsSpellEveryVariant(t *testing.T) { sel KeysetSelector want map[string]any }{ - {DefaultKeyset, map[string]any{"default": map[string]any{}}}, + {defaultKeyset{}, map[string]any{"default": map[string]any{}}}, {KeysetName("acme"), map[string]any{"name": "acme"}}, {id, map[string]any{"id": id[:]}}, {anyKeyset{}, map[string]any{"any": map[string]any{}}}, From 7db88f663daf75fe42d033c43e6998a063a4d0a1 Mon Sep 17 00:00:00 2001 From: Dan Draper <dan@cipherstash.com> Date: Sat, 12 Sep 2026 19:47:15 -0400 Subject: [PATCH 551/686] docs: record declarative encryption targets --- packages/stack-encrypt/CONTEXT.md | 14 +- ...1-context-optional-cipher-directed-path.md | 10 +- ...tive-targets-and-ciphertext-transcoding.md | 120 ++++++++++++++++++ 3 files changed, 142 insertions(+), 2 deletions(-) create mode 100644 packages/stack-encrypt/docs/adr/0003-declarative-targets-and-ciphertext-transcoding.md diff --git a/packages/stack-encrypt/CONTEXT.md b/packages/stack-encrypt/CONTEXT.md index 8bd7be313..f8011e264 100644 --- a/packages/stack-encrypt/CONTEXT.md +++ b/packages/stack-encrypt/CONTEXT.md @@ -14,9 +14,21 @@ _Avoid_: raw path, low-level path **Target-directed**: Encryption driven by the output type: the type being produced (a ciphertext, a -term, a record) declares what it is derived from and which context it demands. +term, a record) declares what it is derived from, which operations produce it, +and which context it demands; execution belongs to the cipher. _Avoid_: typed path, high-level path +**Operation description**: +The target's declaration of the ciphertext and term operations, source selections, +and context requirements needed to produce it. +_Avoid_: user-supplied encryption callback, caller-supplied plan + +**Ciphertext transcoding**: +Construction or inspection of an encrypted target through its native encrypted +structure, preserving the distinctions between ciphertext, terms, metadata, and +authenticated structural markers. +_Avoid_: plaintext serialization, re-encryption + **Context**: The value a ciphertext is authenticated under and a term is derived under. A leaf takes a `NonEmpty<T>` — vitaminc's proof that the value carries caller diff --git a/packages/stack-encrypt/docs/adr/0001-context-optional-cipher-directed-path.md b/packages/stack-encrypt/docs/adr/0001-context-optional-cipher-directed-path.md index a955227b6..0d11c5005 100644 --- a/packages/stack-encrypt/docs/adr/0001-context-optional-cipher-directed-path.md +++ b/packages/stack-encrypt/docs/adr/0001-context-optional-cipher-directed-path.md @@ -1,10 +1,18 @@ --- -status: accepted +status: superseded by ADR-0003 date: 2026-09-03 --- # The cipher-directed path takes any context; the target-directed path takes a `NonEmpty` +Superseded on 2026-09-12 by +[ADR-0003](0003-declarative-targets-and-ciphertext-transcoding.md). The replacement +retains the allowance for absent context on the cipher-directed path and the +nonempty-context requirement for EQL operations, but moves target context +requirements into declarations executed by core code. The original rationale +below is retained as history; ADR-0003 records the accepted design pending +implementation. + `StackCipher::encrypt` / `decrypt` / `decipher` (the cipher-directed path) accept any `IntoAad`, including `()`, exactly as vitaminc's `Aes256Cipher` does: sealing under no associated data is a legitimate AEAD use, and the same `StackCipherText` diff --git a/packages/stack-encrypt/docs/adr/0003-declarative-targets-and-ciphertext-transcoding.md b/packages/stack-encrypt/docs/adr/0003-declarative-targets-and-ciphertext-transcoding.md new file mode 100644 index 000000000..d5cc96513 --- /dev/null +++ b/packages/stack-encrypt/docs/adr/0003-declarative-targets-and-ciphertext-transcoding.md @@ -0,0 +1,120 @@ +--- +status: accepted +date: 2026-09-12 +supersedes: ADR-0001 +--- + +# Declare target operations and transcode native encryption output into records + +The current target-directed extension gives each output implementation the +plaintext and cipher, allowing it to replace the plaintext's Vitamin C encoding; +the initial EQL integration demonstrated this by serializing plaintext with +MessagePack and encrypting the resulting bytes. Targets will instead declare +their operations and context requirements, with Stack Encrypt executing the +operations and constructing the target through a visitor over native encryption +output. This preserves Vitamin C's plaintext contract while supporting derived +records without an additional serialized buffer or generic intermediate tree. + +This records the accepted architecture; implementation is pending. It supersedes +[ADR-0001](0001-context-optional-cipher-directed-path.md) as the current context +and target-extension contract, carrying forward the context policies stated +below. Exact supporting trait signatures remain implementation work. + +## Decision + +`EncryptFrom<P>` remains the declaration that an encrypted target can be produced +from plaintext `P`. Its derive supplies an associated `Context` type, an operation +description assembled from core-supported operations, and a visitor that builds +the target from their results. It has no overridable method receiving both the +plaintext and cipher. The cipher executes the declaration: its ciphertext +operation calls Vitamin C's `Encrypt`, and its term operations use the respective +PRF or ordering capabilities. A target that only produces terms requires those +capabilities without unnecessarily requiring recoverable encryption. + +The output type determines the operations. Semantic field types and explicit +derive configuration identify ciphertext, terms, defaults, and context metadata; +untyped bytes or field names alone cannot identify an operation. Settings such as +normalization and tokenization must be declared where the types do not determine +them. Callers provide plaintext and the target's context, not a separate plan: + +```rust +// Intended call-site shape; not an implemented API. +let identifier = Identifier::for_column("users", "email")?; +let encrypted: TextEq = keyset.encrypt_as(&email, identifier).await?; +``` + +For EQL, the encryption context is `NonEmpty<Identifier>`, with the underlying +identifier stored in `i`. Its table and column components supply the context for +the ciphertext, terms, and ZeroKMS descriptor. EQL does not infer context from +Rust struct/field names or duplicate the identifier in literal attributes. The +storage envelope's `c`, `hm`, and other field names do not add plaintext map-entry +context derivations. Plaintext maps continue to use Vitamin C's own derivations. + +A target can require no caller context (`Context = ()`) when its declaration +already supplies the contexts its operations require. EQL ciphertext and term +operations still require nonempty context. Context encoding and emptiness proofs +remain Vitamin C's responsibility. The cipher-directed API continues to accept +any supported context, including `()`, and remains available; this decision does +not close it. A concrete associated context type does not implicitly accept +arbitrary context extensions; any extension facility must preserve the declared +base context and be specified explicitly. + +Transcoding consumes native encryption output through a custom encrypted-data +protocol. It distinguishes sealed leaves, sequences, maps, authenticated absence +and empty-container markers, passthrough metadata, and typed terms. Readers expose +existing outputs directly to target visitors; they do not first construct another +universal value tree. The existing pending cipher structure, batching state, native +ciphertext output, and final target allocations remain legitimate. This is not a +promise of zero allocation or of eliminating the cipher's own structures. + +`DecryptInto<P>` declares how to inspect the encrypted target, select recoverable +ciphertext, and obtain its context so core code can invoke Vitamin C's `Decrypt`. +For EQL, stored `i` is validated before key retrieval; an externally supplied +expected identifier is checked against it when destination validation is wanted. +Terms do not recover plaintext, and query-only targets have no `DecryptInto`. +Construction and inspection readers are supporting protocols, not additional +public `FromEncrypted` / `IntoEncrypted` derives. EQL encrypted payloads do not +implement the plaintext-side `Encrypt` / `Decrypt` traits. + +## Considered options + +- **Keep arbitrary target encryption methods, with documentation or an added + `P: Encrypt` bound.** A bound cannot require a method body to call that + implementation. A default method remains overridable. Neither prevents the + EQL plaintext-serialization bypass. +- **Use a core-owned encoded-ciphertext wrapper with a format adapter.** This + protects the covered leaf conversion and can also avoid intermediate formats, + but does not supply a shared structural protocol for records, collections, + metadata, and terms. Its responsibility separation informs the chosen design. +- **Use Serde as the transcoding protocol.** The `async-sync` spike demonstrates + direct visitor-driven construction from a cipher's normal output without a + serialization round trip. We adopt that pattern with an encrypted-data model: + byte strings and ordinary null/empty values do not express the distinctions + needed to preserve sealed leaves and authenticated structural markers. + +## Consequences + +- The target traits, derives, container composition, and affected bindings need + coordinated changes. The existing API is unpublished, but local call sites + still need migration. Supported operation descriptions must not admit arbitrary + plaintext-and-cipher callbacks that recreate the bypass. The guarantee concerns + target-directed execution, not all code a caller could write with a cipher. +- The implementation must preserve batching and keyset scope, move outputs through + consuming readers, and retain authenticated markers and cryptographic map keys. + Unsupported shapes must fail explicitly. Stored context is not inherently + trusted merely because it was parsed, and metadata/terms must not be presented + as AEAD-authenticated values by the transcoder. +- Missing plaintext `Encrypt` / `Decrypt` capabilities belong in Vitamin C, with + encoding, precision, and domain behavior specified there. EQL must not restore + a Serde fallback or substitute tagged FFI wrappers for direct Rust plaintext + implementations without an explicit representation decision. +- Verification must include cross-opening ciphertext between the canonical and + target-directed paths, plaintext types without Serde implementations, required + context checks, marker and shape handling, query-only behavior, and batching. + A round trip confined to one adapter is insufficient evidence of compatibility. +- This decision does not establish interoperability with existing + `cipherstash-client` EQL producers or change persisted formats. JSON/SteVec's + shared document key and selector semantics need their own supported operations; + a scalar transcoder does not settle that design. Operation-description and + reader signatures, generic-target context ergonomics, and those document + operations must be validated before claiming complete EQL coverage. From 72d347df01ca6a4b1c6ee2f42c070edeeb541dc9 Mon Sep 17 00:00:00 2001 From: Dan Draper <dan@cipherstash.com> Date: Sat, 12 Sep 2026 20:42:37 -0400 Subject: [PATCH 552/686] feat(stack-encrypt)!: declare target operations Keep plaintext encoding in Vitamin C by moving target execution into core-owned descriptions. Transcode native encrypted output into target visitors and derive stored-context inspection without exposing a cipher. BREAKING CHANGE: EncryptFrom and DecryptInto declare operations with an associated Context. Import the blanket EncryptInto/DecryptFrom helpers or use encrypt_as/decrypt_as. Migrate the WASI record opener accordingly. Refs cipherstash/cipherstash-suite#2215 --- .../golang/stackencrypt/guest/src/ops.rs | 10 +- .../stack-encrypt-derive/docs/attributes.md | 50 +- packages/stack-encrypt-derive/src/attrs.rs | 8 + packages/stack-encrypt-derive/src/decrypt.rs | 949 ++--------- packages/stack-encrypt-derive/src/encrypt.rs | 503 +----- packages/stack-encrypt-derive/src/lib.rs | 198 +-- packages/stack-encrypt-derive/src/shape.rs | 516 ++---- .../stack-encrypt-derive/src/test_support.rs | 37 - packages/stack-encrypt/CONTEXT.md | 6 +- ...tive-targets-and-ciphertext-transcoding.md | 8 +- packages/stack-encrypt/src/lib.rs | 5 +- packages/stack-encrypt/src/sem/mod.rs | 61 +- packages/stack-encrypt/src/target/core.rs | 140 ++ packages/stack-encrypt/src/target/mod.rs | 1438 +---------------- .../stack-encrypt/src/target/operations.rs | 693 ++++++++ packages/stack-encrypt/src/target/pending.rs | 40 +- .../stack-encrypt/src/target/transcode.rs | 120 ++ packages/stack-encrypt/tests/derive.rs | 48 +- packages/stack-encrypt/tests/keysets.rs | 3 +- packages/stack-encrypt/tests/target.rs | 155 +- packages/stack-encrypt/tests/transcode.rs | 329 ++++ .../tests/ui/bare_context.stderr | 150 +- .../tests/ui/context_field_conflicts.rs | 23 + .../tests/ui/context_field_conflicts.stderr | 17 + .../ui/decrypt_field_not_decryptable.stderr | 22 +- .../stack-encrypt/tests/ui/drop_record.stderr | 16 +- .../tests/ui/execution_callback.rs | 7 + .../tests/ui/execution_callback.stderr | 7 + .../tests/ui/leaf_without_context.stderr | 141 +- .../ui/nested_leaf_without_context.stderr | 47 +- .../tests/ui/override_encryption.rs | 10 + .../tests/ui/override_encryption.stderr | 18 + .../tests/ui/pass/query_only_and_borrowed.rs | 23 + .../tests/ui/plaintext_needs_encrypt.rs | 11 + .../tests/ui/plaintext_needs_encrypt.stderr | 29 + .../tests/ui/struct_field_missing.stderr | 2 +- .../tests/ui/wrong_target_context.rs | 14 + .../tests/ui/wrong_target_context.stderr | 9 + 38 files changed, 2262 insertions(+), 3601 deletions(-) delete mode 100644 packages/stack-encrypt-derive/src/test_support.rs create mode 100644 packages/stack-encrypt/src/target/core.rs create mode 100644 packages/stack-encrypt/src/target/operations.rs create mode 100644 packages/stack-encrypt/src/target/transcode.rs create mode 100644 packages/stack-encrypt/tests/transcode.rs create mode 100644 packages/stack-encrypt/tests/ui/context_field_conflicts.rs create mode 100644 packages/stack-encrypt/tests/ui/context_field_conflicts.stderr create mode 100644 packages/stack-encrypt/tests/ui/execution_callback.rs create mode 100644 packages/stack-encrypt/tests/ui/execution_callback.stderr create mode 100644 packages/stack-encrypt/tests/ui/override_encryption.rs create mode 100644 packages/stack-encrypt/tests/ui/override_encryption.stderr create mode 100644 packages/stack-encrypt/tests/ui/pass/query_only_and_borrowed.rs create mode 100644 packages/stack-encrypt/tests/ui/plaintext_needs_encrypt.rs create mode 100644 packages/stack-encrypt/tests/ui/plaintext_needs_encrypt.stderr create mode 100644 packages/stack-encrypt/tests/ui/wrong_target_context.rs create mode 100644 packages/stack-encrypt/tests/ui/wrong_target_context.stderr diff --git a/languages/golang/stackencrypt/guest/src/ops.rs b/languages/golang/stackencrypt/guest/src/ops.rs index cc6cfe7a7..d9078791f 100644 --- a/languages/golang/stackencrypt/guest/src/ops.rs +++ b/languages/golang/stackencrypt/guest/src/ops.rs @@ -822,10 +822,8 @@ pub async fn decrypt_record<K>( plan: &[u8], ) -> Result<Vec<u8>, u32> where - K: DataKeySource + Sync, + K: DataKeySource + Sync + 'static, { - use stack_encrypt::target::DecryptInto; - let plan = parse_plan(decode_value(plan)?)?; let (rows, batched) = record_leaves(decode_tree(record)?, &plan)?; let contexts = plan @@ -845,9 +843,11 @@ where let mut row_names = Vec::with_capacity(row.len()); for ((name, ct), context) in row.into_iter().zip(&contexts) { let context = context.clone(); + // The scope is the opener's, the declaration is the target's: + // `decrypt_as` takes one context and drives both halves with it. pendings.push(match &opener { - Opener::Any(cipher) => ct.decrypt_into(*cipher, context), - Opener::Only(keyset) => ct.decrypt_into(keyset, context), + Opener::Any(cipher) => cipher.decrypt_as(ct, context.into()), + Opener::Only(keyset) => keyset.decrypt_as(ct, context.into()), }); row_names.push(name); } diff --git a/packages/stack-encrypt-derive/docs/attributes.md b/packages/stack-encrypt-derive/docs/attributes.md index 977865b51..e63d46426 100644 --- a/packages/stack-encrypt-derive/docs/attributes.md +++ b/packages/stack-encrypt-derive/docs/attributes.md @@ -13,9 +13,9 @@ All attributes live under `#[stash(...)]`. `plaintext` must be an owned type: the generated impl has no lifetime to give a reference. Without `plaintext`, each derive emits one impl generic over the plaintext, -bounded by what the fields accept: `EncryptedAge` below is `EncryptFrom<P, _, _>` +bounded by what the fields accept: `EncryptedAge` below is `EncryptFrom<P>` for any `P` that both `StackCipherText` and `EqualityTerm` accept, and -`DecryptInto<P, _, _>` for any `P` its `decrypt` field opens to. With +`DecryptInto<P>` for any `P` its `decrypt` field opens to. With `plaintext`, the record accepts only the listed types (a field that holds integers should not accept a `String`). Whether `Type` is a struct makes no difference to `plaintext`: the derive sees a name, not a definition, and @@ -27,30 +27,34 @@ literal. | Attribute | Effect | |---|---| +| `context_field` | Store the caller’s typed context here and recover it on decryption. Exactly one per record; excludes the other field attributes and literal contexts. | | `context = "..."` | Derive this field under exactly this context, extended by the one the caller passes for the record like any other. A query-side term built under the same literal — extended the same way — matches it. Must not be empty. | | `from = field` / `from = 0` | With `struct` only: derive this field from `plaintext.field` (or `plaintext.0` for a tuple struct) when its name differs from its plaintext field's. | | `default` / `default = expr` | Not derived: filled with `Default::default()` or `expr`. Never encrypted, never authenticated. | | `decrypt` | Decryption opens this field (`DecryptInto` only). Needed only when the field types cannot decide it — see below. | | `nested` | With `struct` only: infer no context for this field — it is handed the caller's context as it is, which its type (a nested `struct` derive carrying its own contexts) composes with them. Excludes `context`. | -Each derive emits two impls per plaintext: one for `()`, the context -`encrypt_into` / `Plaintext::decrypt_from(record, &cipher)` pass, and one for -`NonEmpty<T>`, the context `encrypt_into_with_context` / -`decrypt_from_with_context` pass (anything that converts into a -`NonEmpty<T>`: `nonempty!("users/email")`, `NonEmpty::new(value)?`, a bare -integer). Under `()` every field is derived under the context it carries -itself; under `NonEmpty<T>` every such context — a `context = ".."` literal -or a `struct` derive's inferred one — is extended with the caller's -(`("users/age", context)`), and a field with no context of its own is -handed the caller's as it is. No record accepts a context and then discards -it. - -A leaf accepts only a `NonEmpty<T>`, so a record that hands the caller's -context to one — a record of leaves, or a `nested` leaf — has a `()` impl -the compiler cannot satisfy: `encrypt_into` is turned away with the field -that needs a context named, and `encrypt_into_with_context` is the form -that compiles. A record whose every field carries a context — every -`struct` derive — compiles under both. +Each derive emits one declaration per plaintext, with an associated `Context`. +A record with `#[stash(context_field)]` on a field of type `T` requires +`NonEmpty<T>` for encryption and stores its inner value in that field. Decryption +takes `ExpectedContext<T>`: its default reads and validates the stored value; +`NonEmpty<T>.into()` also checks it against the expected destination before key +retrieval. `T` supplies the Vitamin C context encodings and implements `Clone`, +`MaybeEmpty`, and `PartialEq`. This metadata is not a separate encrypted field. +It cannot be combined with literal context attributes. + +Otherwise, a record whose fields all carry their own contexts (including a +`struct` derive) uses `DeclaredContext`. `().into()` or its default selects the +declared contexts unchanged; a nonempty caller context extends each base context. +A record with fields that need a caller context uses `CallerContext`, constructed +from a `NonEmpty<T>` or a supported integer. Both encodings and the descriptor's +structured identity are preserved when borrowing context data is converted into +an owned declaration. No context is inferred from a Rust type's name. + +The blanket `EncryptInto` and `DecryptFrom` convenience traits execute these +declarations. Import `DecryptFrom` to call `.decrypt_into(...)`. Custom targets +implement the declaration methods `encryption` and `decryption`; the old methods +that received plaintext and a cipher are no longer extension points. Every attribute except `plaintext` is singular, and repeating one is a compile error rather than a silent overwrite (`plaintext` is repeatable, @@ -76,10 +80,8 @@ A context passed by the caller extends every field's: under under `("users/age", 7u64)`, and a query site probes it under `nonempty!("users/age").with(7u64)`. This is how a field is bound to its record as well as its name — a record id, say — without the type having to -know the id. Decryption takes the same extension. The extension is owned or -`'static` (`u64`, `String`, `&'static str`, `Option`s and pairs of those): -the field's own context fixes the pair's lifetime, for a `plaintext` record -with `context = ".."` literals as much as for a `struct` derive. +know the id. Decryption takes the same extension. The extension may be borrowed at the call site: its Vitamin C encodings are +owned by the declaration before execution. The context is part of the stored data's identity: it is the AAD of every ciphertext in the column and the domain of every term. That is why the prefix diff --git a/packages/stack-encrypt-derive/src/attrs.rs b/packages/stack-encrypt-derive/src/attrs.rs index b7e21a26c..ec41f5a15 100644 --- a/packages/stack-encrypt-derive/src/attrs.rs +++ b/packages/stack-encrypt-derive/src/attrs.rs @@ -167,6 +167,7 @@ impl ContainerAttrs { /// Field-level options, from `#[stash(...)]` on a field. #[derive(Default)] pub(crate) struct FieldAttrs { + pub(crate) context_field: bool, /// `#[stash(context = "...")]`: derive this field under exactly this /// context instead of the one a `struct` derive would infer, or the one /// the caller passes for the record. Extended by a caller's context like @@ -194,6 +195,13 @@ impl FieldAttrs { for attr in attrs.iter().filter(|a| a.path().is_ident("stash")) { attr.parse_nested_meta(|meta| { + if meta.path.is_ident("context_field") { + if parsed.context_field { + return Err(meta.error("`context_field` is given twice")); + } + parsed.context_field = true; + return Ok(()); + } if meta.path.is_ident("context") { // Each of these is singular by meaning, so a repeat is a // mistake: rejected rather than silently overwritten. A diff --git a/packages/stack-encrypt-derive/src/decrypt.rs b/packages/stack-encrypt-derive/src/decrypt.rs index 8743cbbf8..4e0de2def 100644 --- a/packages/stack-encrypt-derive/src/decrypt.rs +++ b/packages/stack-encrypt-derive/src/decrypt.rs @@ -1,132 +1,154 @@ -//! Expansion of `#[derive(DecryptInto)]`. -//! -//! Which field decryption opens is, by default, not the derive's decision -//! but the type system's: every candidate field's type says through -//! `Decryptable` whether it is a ciphertext or a one-way term, a `const` -//! assertion requires exactly one ciphertext (per plaintext field, for a -//! row), and the body asks each field through `DecryptField`, taking the one -//! answer. `#[stash(decrypt)]` switches the record to the explicit mode, in -//! which only the marked fields are considered and the field types need not -//! be `Decryptable`. - -use std::collections::HashSet; - +//! Emit ciphertext inspection and core-owned opening descriptions. +use crate::shape::{trait_impl, zip, Field, Record}; use proc_macro2::{Span, TokenStream}; use quote::{quote, quote_spanned, ToTokens}; +use std::collections::HashSet; use syn::spanned::Spanned; -use syn::{ - parse_quote, DeriveInput, Generics, Ident, LitStr, Member, Path, PathArguments, Result, Type, -}; - -use crate::shape::{ - context_param, impl_sources, push_field_bounds, trait_impl, zip_fields, CallerContext, - ContextImpl, Field, FieldBound, Record, -}; +use syn::{parse_quote, DeriveInput, Ident, LitStr, Member, Path, PathArguments, Result, Type}; pub(crate) fn derive(input: DeriveInput) -> Result<TokenStream> { let record = Record::parse(&input)?; let krate = &record.krate; - - let field_impl = decrypt_field_impl(&input, krate); - let impls = if record.fields.iter().any(|f| f.decrypt) { - explicit(&input, &record)? + let name = &input.ident; + let explicit = record.fields.iter().any(|f| f.decrypt); + let groups = if explicit { + match Mode::classify(record.fields.iter().filter(|f| f.decrypt).collect(), name)? { + Mode::Whole(field) => vec![(None, vec![field])], + Mode::ByField(fields) => fields.into_iter().map(|f| (f.from(), vec![f])).collect(), + } } else { - automatic(&input, &record)? + match Auto::classify(&record) { + Auto::Whole(fields) => vec![(None, fields)], + Auto::ByField(groups) => groups + .into_iter() + .map(|g| (Some(g.from), g.fields)) + .collect(), + } }; - - Ok(quote!(#impls #field_impl)) -} - -// ============================================================================= -// Shared -// ============================================================================= - -/// `impl DecryptInto<Plaintext, StackCipher<__K>, Ctx> for Record` around -/// `body`; `ctx` is `()` or `NonEmpty<__T>` ([`context_param`]). The -/// context parameter is unnamed when no opened field uses it, so the -/// expansion warns of nothing. -fn impl_block( - input: &DeriveInput, - krate: &Path, - generics: &Generics, - plaintext: &Type, - ctx: &Type, - uses_context: bool, - body: TokenStream, -) -> TokenStream { - let context = if uses_context { - quote!(__context) + let checks = if explicit { + TokenStream::new() } else { - quote!(_) + let checks = groups + .iter() + .map(|(from, fields)| check(krate, name, *from, fields)); + quote!(#(#checks)*) }; - trait_impl( - input, - generics, - quote!(#krate::target::DecryptInto<#plaintext, #krate::StackCipher<__K>, #ctx>), - quote! { - fn decrypt_into<'__a>( - self, - __cipher: &'__a #krate::StackCipher<__K>, - #context: #ctx, - ) -> #krate::target::Pending<'__a, #plaintext, __K> - where - Self: '__a, - #plaintext: '__a, - { - #body + let (definition_check, body_check) = if input.generics.params.is_empty() { + (quote!(const _: () = { #checks };), TokenStream::new()) + } else { + (TokenStream::new(), quote!(let () = const { #checks };)) + }; + let context = record.context_type(true); + let (plaintexts, generic) = record.sources(parse_quote!(__P)); + let mut impls = Vec::new(); + for plaintext in plaintexts { + let mut generics = input.generics.clone(); + if generic { + generics.params.push(parse_quote!(__P: 'static)); + } + generics + .make_where_clause() + .predicates + .push(parse_quote!(Self: 'static)); + let mut plans = Vec::new(); + for (index, (from, fields)) in groups.iter().enumerate() { + let output = if from.is_some() { + parse_quote!(_) + } else { + plaintext.clone() + }; + if from.is_none() { + for field in fields { + let ty = &field.ty; + let ctx = record.field_context_type(field); + let predicates = &mut generics.make_where_clause().predicates; + if explicit { + predicates.push(parse_quote!(#ty: #krate::target::DecryptInto<#output>)); + predicates.push(parse_quote!(#ctx: Into<<#ty as #krate::target::DecryptInto<#output>>::Context>)); + } else { + predicates + .push(parse_quote!(#ty: #krate::target::DecryptField<#output, #ctx>)); + } + } } - }, - ) -} - -/// `impl DecryptField<__P, __C, __Ctx> for Record`: a derived record is a -/// field of a larger one, opened through its own `DecryptInto`. -fn decrypt_field_impl(input: &DeriveInput, krate: &Path) -> TokenStream { - let name = &input.ident; - let (_, ty_generics, _) = input.generics.split_for_impl(); - // Over `StackCipher<__K>` only: the `KeysetCipher` form of every - // `DecryptField` is a blanket impl in `stack_encrypt::target`, which a - // generic-cipher impl here would overlap. + let plan = if explicit { + let field = fields[0]; + let ty = &field.ty; + let member = &field.member; + let ctx = record.context_expr(field, true); + quote_spanned!(ty.span()=> <#ty as #krate::target::DecryptInto<#output>>::decryption::<__K>(self.#member, #ctx.into())) + } else { + let calls: Vec<_> = fields.iter().map(|field| { + let ty = &field.ty; let member = &field.member; + let ctx = record.context_expr(field, true); + let ctx_ty = record.field_context_type(field); + quote_spanned!(ty.span()=> <#ty as #krate::target::DecryptField<#output, #ctx_ty>>::decryption_field::<__K>(self.#member, #ctx)) + }).collect(); + // Evaluate every inspection before chaining: a context error belongs + // to the whole declaration, not to an Option::or_else closure. + let locals: Vec<_> = (0..calls.len()) + .map(|n| Ident::new(&format!("__candidate_{n}"), Span::call_site())) + .collect(); + let bindings = calls + .iter() + .zip(&locals) + .map(|(call, local)| quote!(let #local = #call;)); + let first = &locals[0]; + let rest = &locals[1..]; + quote!({ #(#bindings)* #first #(.or(#rest))* .unwrap_or_else(|| #krate::target::Decryption::failed(#krate::Error::NotOpened)) }) + }; + plans.push(( + plan, + Ident::new(&format!("__group_{index}"), Span::call_site()), + )); + } + let body = if groups[0].0.is_none() { + plans.remove(0).0 + } else { + let literal = struct_literal_path(&plaintext)?; + let assignments = groups + .iter() + .zip(&plans) + .map(|((from, _), (_, local))| quote!(#from: #local)); + let output = quote!(#literal { #(#assignments),* }); + zip(plans, output) + }; + let stored = record.context_field().map(|field| { + let member = &field.member; + quote!(let __context = match __context.validate(self.#member) { + Ok(context) => context, Err(error) => return #krate::target::Decryption::failed(error), + };) + }); + impls.push(trait_impl(&input, &generics, quote!(#krate::target::DecryptInto<#plaintext>), quote! { + type Context = #context; + fn decryption<__K: 'static>(self, __context: Self::Context) -> #krate::target::Decryption<#plaintext, __K> { + #body_check #stored #body + } + })); + } let mut generics = input.generics.clone(); generics.params.push(parse_quote!(__P)); - generics.params.push(parse_quote!(__K)); generics.params.push(parse_quote!(__Ctx)); - generics.make_where_clause().predicates.push(parse_quote! { - Self: #krate::target::DecryptInto<__P, #krate::StackCipher<__K>, __Ctx> - }); - let (impl_generics, _, where_clause) = generics.split_for_impl(); - quote! { - #[automatically_derived] - impl #impl_generics #krate::target::DecryptField<__P, #krate::StackCipher<__K>, __Ctx> - for #name #ty_generics - #where_clause - { - fn decrypt_field<'__a>( - self, - __cipher: &'__a #krate::StackCipher<__K>, - __context: __Ctx, - ) -> ::core::option::Option<#krate::target::Pending<'__a, __P, __K>> - where - Self: '__a, - __P: '__a, - { - ::core::option::Option::Some( - <Self as #krate::target::DecryptInto<__P, #krate::StackCipher<__K>, __Ctx>>::decrypt_into( - self, __cipher, __context, - ), - ) + generics + .make_where_clause() + .predicates + .push(parse_quote!(Self: #krate::target::DecryptInto<__P>)); + generics + .make_where_clause() + .predicates + .push(parse_quote!(__Ctx: Into<<Self as #krate::target::DecryptInto<__P>>::Context>)); + let field = trait_impl( + &input, + &generics, + quote!(#krate::target::DecryptField<__P, __Ctx>), + quote! { + fn decryption_field<__K: 'static>(self, context: __Ctx) -> Option<#krate::target::Decryption<__P, __K>> { + Some(<Self as #krate::target::DecryptInto<__P>>::decryption(self, context.into())) } - } - } -} - -/// The context one opened field is handed, by move, in the impl for -/// `which`: its own, the caller's, or its own extended with the caller's. -fn context_for(krate: &Path, field: &Field, which: ContextImpl) -> TokenStream { - field.field_context().expr(krate, which, quote!(__context)) + }, + ); + Ok(quote!(#(#impls)* #field #definition_check)) } - -/// The plaintext type as a struct-literal path: `User<T>` becomes `User::<T>`. fn struct_literal_path(plaintext: &Type) -> Result<Path> { let Type::Path(type_path) = plaintext else { return Err(syn::Error::new_spanned( @@ -195,110 +217,6 @@ impl<'a> Auto<'a> { } } -fn automatic(input: &DeriveInput, record: &Record) -> Result<TokenStream> { - let krate = &record.krate; - let name = &input.ident; - let auto = Auto::classify(record); - - // The one-ciphertext check: at the definition for a concrete record, at - // the first use for a generic one (a `const _` cannot name the record's - // parameters, and an inline `const` is evaluated per instantiation). - let checks = match &auto { - Auto::Whole(fields) => check(krate, name, None, fields), - Auto::ByField(groups) => { - let each = groups - .iter() - .map(|g| check(krate, name, Some(g.from), &g.fields)); - quote!(#(#each)*) - } - }; - let (definition_check, body_check) = if input.generics.params.is_empty() { - (quote!(const _: () = { #checks };), TokenStream::new()) - } else { - (TokenStream::new(), quote!(let () = const { #checks };)) - }; - - // Every candidate field, moved out of `self` and asked in turn. - let candidates: Vec<&Field> = match &auto { - Auto::Whole(fields) => fields.clone(), - Auto::ByField(groups) => groups - .iter() - .flat_map(|g| g.fields.iter().copied()) - .collect(), - }; - let destructure = |uses_context: bool| { - let bind = candidates.iter().map(|f| { - let member = &f.member; - let local = &f.local; - quote!(#member: #local) - }); - // The caller's context is cloned to every field that uses it, so - // it is taken by reference once. - let borrow = uses_context.then(|| quote!(let __context = &__context;)); - quote! { - let Self { #(#bind,)* .. } = self; - #borrow - } - }; - - // One impl per listed plaintext, or one generic over it (whole mode - // only: rebuilding field by field needs a struct literal, and - // `Record::parse` has rejected `from` without a named plaintext) — and - // each twice, for `()` and for `NonEmpty<__T>` (see `context_param`). - // Each candidate field is bounded by `DecryptField` under the context - // it is opened under, so a record's impl exists for exactly the - // plaintexts its ciphertext field opens to — and only under a non-empty - // context if that field needs one. - let (plaintexts, generic) = impl_sources(record, parse_quote!(__P)); - let mut impls = Vec::with_capacity(plaintexts.len() * 2); - for plaintext in &plaintexts { - for which in ContextImpl::BOTH { - let mut generics = input.generics.clone(); - if generic { - generics.params.push(parse_quote!(__P)); - } - generics.params.push(parse_quote!(__K)); - let open = match &auto { - Auto::Whole(fields) => { - push_field_bounds( - &mut generics, - krate, - fields, - plaintext, - FieldBound::DecryptField, - which, - ); - open_one(krate, fields, plaintext, which) - } - Auto::ByField(groups) => by_group_body(krate, groups, plaintext, which)?, - }; - let ctx = context_param( - &mut generics, - CallerContext::Decrypt(krate), - which, - record.by_field, - &candidates, - ); - let uses_context = candidates.iter().any(|f| f.uses_callers_context(which)); - let destructure = destructure(uses_context); - let body = quote!(#body_check #destructure #open); - impls.push(impl_block( - input, - krate, - &generics, - plaintext, - &ctx, - uses_context, - body, - )); - } - } - - Ok(quote!(#(#impls)* #definition_check)) -} - -/// The `const` assertion that exactly one of `fields` is `Decryptable`; -/// `from` names the plaintext field they recover, for the message. fn check(krate: &Path, name: &Ident, from: Option<&Member>, fields: &[&Field]) -> TokenStream { let terms = fields.iter().map(|field| { let ty = &field.ty; @@ -344,167 +262,6 @@ fn check(krate: &Path, name: &Ident, from: Option<&Member>, fields: &[&Field]) - } } -/// The one `Some` among the fields' `decrypt_field`s, as a pending of -/// `plaintext` (`_` when it is inferred from a struct literal), in the impl -/// for `which`. -fn open_one(krate: &Path, fields: &[&Field], plaintext: &Type, which: ContextImpl) -> TokenStream { - let mut calls = fields.iter().map(|field| { - let ty = &field.ty; - let local = &field.local; - let context = field.field_context().expr( - krate, - which, - quote!(::core::clone::Clone::clone(__context)), - ); - // The context type is named, not inferred, so a leaf that cannot - // open under it is reported by the trait's `on_unimplemented` - // rather than as an argument type mismatch inside the expansion. - let context_ty = field.field_context().ty(krate, which); - quote_spanned! {ty.span()=> - <#ty as #krate::target::DecryptField<#plaintext, #krate::StackCipher<__K>, #context_ty>>::decrypt_field( - #local, __cipher, #context, - ) - } - }); - let first = calls - .next() - .unwrap_or_else(|| unreachable!("a group has at least one field")); - let chain = calls.fold( - first, - |chain, call| quote!(::core::option::Option::or_else(#chain, move || #call)), - ); - // The const assertion has established that exactly one field's type is - // `Decryptable`, but a third-party `DecryptField` can still break its - // contract and return `None` for a type whose `DECRYPTABLE` is `true`. - // That is `Error::NotOpened` — a failed pending that settles without - // I/O — never a panic. - quote! { - ::core::option::Option::unwrap_or_else(#chain, || #krate::target::Pending::failed( - __cipher, - #krate::Error::NotOpened, - )) - } -} - -/// Each group's opened pending, zipped into one and mapped into a struct -/// literal of the plaintext. -fn by_group_body( - krate: &Path, - groups: &[Group<'_>], - plaintext: &Type, - which: ContextImpl, -) -> Result<TokenStream> { - let literal = struct_literal_path(plaintext)?; - let inferred: Type = parse_quote!(_); - - let locals: Vec<Ident> = (0..groups.len()) - .map(|index| Ident::new(&format!("__group_{index}"), Span::call_site())) - .collect(); - let opens = groups.iter().zip(&locals).map(|(group, local)| { - let open = open_one(krate, &group.fields, &inferred, which); - quote!(let #local = #open;) - }); - - let mut chain = TokenStream::new(); - let mut pattern = TokenStream::new(); - for (index, local) in locals.iter().enumerate() { - if index == 0 { - chain = quote!(#local); - pattern = quote!(#local); - } else { - chain = quote!(#chain.zip(#local)); - pattern = quote!((#pattern, #local)); - } - } - let assign = groups.iter().zip(&locals).map(|(group, local)| { - let from = group.from; - quote!(#from: #local) - }); - - Ok(quote! { - #(#opens)* - #chain.map(|#pattern| #literal { #(#assign),* }) - }) -} - -// ============================================================================= -// Explicit mode: `#[stash(decrypt)]` names the fields -// ============================================================================= - -fn explicit(input: &DeriveInput, record: &Record) -> Result<TokenStream> { - let krate = &record.krate; - let name = &input.ident; - - let opened: Vec<&Field> = record.fields.iter().filter(|f| f.decrypt).collect(); - let mode = Mode::classify(opened, name)?; - - // One impl per listed plaintext, or one generic over it — and each - // twice, for `()` and for `NonEmpty<__T>`. Only the whole-plaintext mode - // can be generic — rebuilding field by field needs a struct literal, and - // therefore a name — and `Record::parse` has already rejected `from` - // without one. - let (plaintexts, generic) = impl_sources(record, parse_quote!(__P)); - let mut impls = Vec::with_capacity(plaintexts.len() * 2); - for plaintext in &plaintexts { - for which in ContextImpl::BOTH { - let mut generics = input.generics.clone(); - if generic { - generics.params.push(parse_quote!(__P)); - } - generics.params.push(parse_quote!(__K)); - let (body, ctx, uses_context) = match &mode { - Mode::Whole(field) => { - push_field_bounds( - &mut generics, - krate, - &[field], - plaintext, - FieldBound::DecryptInto, - which, - ); - let ctx = context_param( - &mut generics, - CallerContext::Decrypt(krate), - which, - record.by_field, - &[field], - ); - ( - whole_body(krate, field, plaintext, which), - ctx, - field.uses_callers_context(which), - ) - } - Mode::ByField(fields) => { - let ctx = context_param( - &mut generics, - CallerContext::Decrypt(krate), - which, - record.by_field, - fields, - ); - ( - by_field_body(krate, fields, plaintext, which)?, - ctx, - fields.iter().any(|f| f.uses_callers_context(which)), - ) - } - }; - impls.push(impl_block( - input, - krate, - &generics, - plaintext, - &ctx, - uses_context, - body, - )); - } - } - - Ok(quote!(#(#impls)*)) -} - enum Mode<'a> { /// One field is the whole plaintext's ciphertext: decrypting the record is /// decrypting that field. @@ -552,441 +309,3 @@ impl<'a> Mode<'a> { Ok(Mode::ByField(opened)) } } - -fn whole_body(krate: &Path, field: &Field, plaintext: &Type, which: ContextImpl) -> TokenStream { - let ty = &field.ty; - let member = &field.member; - let context = context_for(krate, field, which); - let context_ty = field.field_context().ty(krate, which); - quote! { - <#ty as #krate::target::DecryptInto<#plaintext, #krate::StackCipher<__K>, #context_ty>>::decrypt_into( - self.#member, - __cipher, - #context, - ) - } -} - -fn by_field_body( - krate: &Path, - fields: &[&Field], - plaintext: &Type, - which: ContextImpl, -) -> Result<TokenStream> { - let literal = struct_literal_path(plaintext)?; - - let assign = fields.iter().map(|field| { - let from = field.from(); - let local = &field.local; - quote!(#from: #local) - }); - - Ok(zip_fields( - krate, - fields, - which, - |field, context| { - let ty = &field.ty; - let member = &field.member; - // The plaintext field's type is not known here; it is inferred - // from the struct literal, and the obligation checked against it - // — spanned at the field type, so a leaf handed `()` (a `nested` - // field) is reported at the field that needs a `context`, by the - // trait's `on_unimplemented` since the context type is named. - let context_ty = field.field_context().ty(krate, which); - let call = quote_spanned! {ty.span()=> - <#ty as #krate::target::DecryptInto<_, #krate::StackCipher<__K>, #context_ty>>::decrypt_into - }; - quote!(#call(self.#member, __cipher, #context,)) - }, - quote!(#literal { #(#assign),* }), - )) -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::test_support::{assert_contains, assert_lacks}; - - fn expand(input: DeriveInput) -> Result<String> { - derive(input).map(|tokens| tokens.to_string()) - } - - #[test] - #[rustfmt::skip] - fn unmarked_fields_are_chosen_by_their_types() { - let expansion = expand(parse_quote! { - #[stash(plaintext = u32)] - struct Rec { - c: StackCipherText, - hm: EqualityTerm, - #[stash(default)] - v: u8, - } - }) - .unwrap(); - // Every derived field is a candidate, bounded and asked in turn; the - // `default` field is neither. Once for `()` and once for - // `NonEmpty<__T>`. - assert_contains(&expansion, quote! { - impl<__K> ::stack_encrypt::target::DecryptInto<u32, ::stack_encrypt::StackCipher<__K>, ()> for Rec - where - StackCipherText: ::stack_encrypt::target::DecryptField<u32, ::stack_encrypt::StackCipher<__K>, ()>, - EqualityTerm: ::stack_encrypt::target::DecryptField<u32, ::stack_encrypt::StackCipher<__K>, ()> - }); - assert_contains(&expansion, quote! { - impl<'__ctx, __K, __T> ::stack_encrypt::target::DecryptInto<u32, ::stack_encrypt::StackCipher<__K>, ::stack_encrypt::NonEmpty<__T>> for Rec - where - StackCipherText: ::stack_encrypt::target::DecryptField<u32, ::stack_encrypt::StackCipher<__K>, ::stack_encrypt::NonEmpty<__T>>, - EqualityTerm: ::stack_encrypt::target::DecryptField<u32, ::stack_encrypt::StackCipher<__K>, ::stack_encrypt::NonEmpty<__T>>, - __T: ::stack_encrypt::IntoAad<'__ctx> + ::core::clone::Clone - }); - assert_contains(&expansion, quote!(let Self { c: __field_0, hm: __field_1, .. } = self; let __context = &__context;)); - assert_contains(&expansion, quote! { - ::core::option::Option::or_else( - <StackCipherText as ::stack_encrypt::target::DecryptField<u32, ::stack_encrypt::StackCipher<__K>, ()>>::decrypt_field( - __field_0, __cipher, ::core::clone::Clone::clone(__context), - ), - move || <EqualityTerm as ::stack_encrypt::target::DecryptField<u32, ::stack_encrypt::StackCipher<__K>, ()>>::decrypt_field( - __field_1, __cipher, ::core::clone::Clone::clone(__context), - ) - ) - }); - // Exactly one ciphertext, checked at the definition. - assert_contains(&expansion, quote! { - const _: () = { - { - let __decryptable: usize = 0 - + (<StackCipherText as ::stack_encrypt::target::Decryptable>::DECRYPTABLE as usize) - + (<EqualityTerm as ::stack_encrypt::target::Decryptable>::DECRYPTABLE as usize); - ::core::assert!(__decryptable >= 1, "`Rec` has no decryptable field: every derived field is a one-way index term, so there is nothing for DecryptInto to open"); - ::core::assert!(__decryptable <= 1, "`Rec` has several decryptable fields: mark the one decryption opens `#[stash(decrypt)]`"); - } - }; - }); - assert_lacks(&expansion, quote!(let () = const)); - assert_lacks(&expansion, quote!(<u8 as ::stack_encrypt::target::Decryptable>)); - } - - #[test] - #[rustfmt::skip] - fn a_caller_context_is_bounded_by_the_vitaminc_traits() { - // The regression shape: the ciphertext field carries a literal and - // a term's `DecryptField` accepts anything (it opens nothing). The - // impl-level bound is what keeps `decrypt_into` from accepting a - // value that is not a context at all — and the literal extends the - // caller's context, so the ciphertext authenticates under it. - let expansion = expand(parse_quote! { - #[stash(plaintext = u32)] - struct Rec { - #[stash(context = "rec/c")] - c: StackCipherText, - hm: EqualityTerm, - } - }) - .unwrap(); - assert_contains(&expansion, quote! { - impl<__K, __T> ::stack_encrypt::target::DecryptInto<u32, ::stack_encrypt::StackCipher<__K>, ::stack_encrypt::NonEmpty<__T>> for Rec - }); - assert_contains(&expansion, quote! { - __T: ::stack_encrypt::IntoAad<'static> + ::core::clone::Clone - }); - // The literal is a compile-time `NonEmpty` under `()`, extended - // under `NonEmpty<__T>`. - assert_contains(&expansion, quote!(__field_0, __cipher, ::stack_encrypt::nonempty!("rec/c"),)); - assert_contains(&expansion, quote! { - __field_0, __cipher, - ::stack_encrypt::NonEmpty::with(::stack_encrypt::nonempty!("rec/c"), ::core::clone::Clone::clone(__context)), - }); - } - - #[test] - fn a_generic_record_is_checked_at_its_use() { - let expansion = expand(parse_quote! { - struct Tagged<T: CllwOreEncrypt> { - c: StackCipherText, - ob: OreTerm<T>, - } - }) - .unwrap(); - assert_contains(&expansion, quote!(let () = const)); - assert_lacks(&expansion, quote!(const _: ())); - } - - #[test] - #[rustfmt::skip] - fn unmarked_from_fields_are_grouped_by_plaintext_field() { - let expansion = expand(parse_quote! { - #[stash(struct = User, context = "users")] - struct Row { - age: EncryptedAge, - email: StackCipherText, - #[stash(from = email)] - email_eq: EqualityTerm, - } - }) - .unwrap(); - // One check and one opening per plaintext field; the two `email` - // fields are asked in turn. - assert_contains(&expansion, quote!("no field of `Row` can recover the plaintext field `age`: every field derived from it is a one-way index term")); - assert_contains(&expansion, quote!("several fields of `Row` are derived from the plaintext field `email` and decryptable: mark the one decryption opens `#[stash(decrypt)]`")); - assert_contains(&expansion, quote! { - let __group_1 = ::core::option::Option::unwrap_or_else( - ::core::option::Option::or_else( - <StackCipherText as ::stack_encrypt::target::DecryptField<_, ::stack_encrypt::StackCipher<__K>, ::stack_encrypt::NonEmpty<&'static str>>>::decrypt_field( - __field_1, __cipher, ::stack_encrypt::nonempty!("users/email"), - ), - move || <EqualityTerm as ::stack_encrypt::target::DecryptField<_, ::stack_encrypt::StackCipher<__K>, ::stack_encrypt::NonEmpty<&'static str>>>::decrypt_field( - __field_2, __cipher, ::stack_encrypt::nonempty!("users/email"), - ) - ), - || ::stack_encrypt::target::Pending::failed(__cipher, ::stack_encrypt::Error::NotOpened,) - ); - }); - assert_contains(&expansion, quote!(__group_0.zip(__group_1).map(|(__group_0, __group_1)| User { age: __group_0, email: __group_1 }))); - // Every field has its own context: the `()` impl uses none of the - // caller's, so its parameter is unnamed and never borrowed; the - // `NonEmpty<__T>` impl extends each with it. - assert_contains(&expansion, quote! { - impl<__K> ::stack_encrypt::target::DecryptInto<User, ::stack_encrypt::StackCipher<__K>, ()> for Row - }); - assert_contains(&expansion, quote!(_: (),)); - assert_contains(&expansion, quote! { - impl<__K, __T> ::stack_encrypt::target::DecryptInto<User, ::stack_encrypt::StackCipher<__K>, ::stack_encrypt::NonEmpty<__T>> for Row - }); - assert_contains(&expansion, quote!(let __context = &__context;)); - } - - #[test] - #[rustfmt::skip] - fn a_struct_opens_its_fields_under_their_extended_contexts() { - let expansion = expand(parse_quote! { - #[stash(struct = User, context = "user")] - struct EncryptedUser { - age: EncryptedAge, - email: StackCipherText, - } - }) - .unwrap(); - // Under `()`, the inferred literals as they are. - assert_contains(&expansion, quote!(__field_0, __cipher, ::stack_encrypt::nonempty!("user/age"),)); - // Under `NonEmpty<__T>`, each extended with the caller's — cloned, - // since every opened field is asked through a reference — and `__T` - // bounded for `'static`. - assert_contains(&expansion, quote! { - impl<__K, __T> ::stack_encrypt::target::DecryptInto<User, ::stack_encrypt::StackCipher<__K>, ::stack_encrypt::NonEmpty<__T>> for EncryptedUser - where - __T: ::stack_encrypt::IntoAad<'static> + ::core::clone::Clone - }); - assert_contains(&expansion, quote! { - __field_0, __cipher, - ::stack_encrypt::NonEmpty::with(::stack_encrypt::nonempty!("user/age"), ::core::clone::Clone::clone(__context)), - }); - assert_contains(&expansion, quote! { - __field_1, __cipher, - ::stack_encrypt::NonEmpty::with(::stack_encrypt::nonempty!("user/email"), ::core::clone::Clone::clone(__context)), - }); - } - - #[test] - fn every_record_is_a_decrypt_field() { - let expansion = expand(parse_quote! { - #[stash(plaintext = u32)] - struct Rec { - #[stash(decrypt)] - c: StackCipherText, - } - }) - .unwrap(); - assert_contains( - &expansion, - quote! { - impl<__P, __K, __Ctx> ::stack_encrypt::target::DecryptField<__P, ::stack_encrypt::StackCipher<__K>, __Ctx> for Rec - where - Self: ::stack_encrypt::target::DecryptInto<__P, ::stack_encrypt::StackCipher<__K>, __Ctx> - }, - ); - } - - #[test] - fn marking_a_field_turns_the_types_off() { - let expansion = expand(parse_quote! { - #[stash(plaintext = u32)] - struct Rec { - #[stash(decrypt)] - c: StackCipherText, - hm: EqualityTerm, - } - }) - .unwrap(); - assert_lacks(&expansion, quote!(Decryptable)); - assert_lacks(&expansion, quote!(DecryptField < u32)); - } - - #[test] - fn two_whole_fields_are_ambiguous() { - let err = expand(parse_quote! { - #[stash(plaintext = u32)] - struct Rec { - #[stash(decrypt)] - a: StackCipherText, - #[stash(decrypt)] - b: StackCipherText, - } - }) - .unwrap_err(); - assert!(err - .to_string() - .contains("cannot be recovered from two fields")); - } - - #[test] - fn duplicate_recovery_targets_are_rejected() { - let err = expand(parse_quote! { - #[stash(struct = User, context = "users")] - struct Rec { - #[stash(decrypt)] - a: StackCipherText, - #[stash(decrypt, from = a)] - b: StackCipherText, - } - }) - .unwrap_err(); - assert!(err.to_string().contains("same plaintext field `a`")); - } - - #[test] - #[rustfmt::skip] - fn a_generic_plaintext_opens_the_one_field() { - let expansion = expand(parse_quote! { - struct Wrapped { - #[stash(decrypt)] - c: StackCipherText, - hm: EqualityTerm, - } - }) - .unwrap(); - assert_contains(&expansion, quote! { - impl<__P, __K> ::stack_encrypt::target::DecryptInto<__P, ::stack_encrypt::StackCipher<__K>, ()> for Wrapped - where - StackCipherText: ::stack_encrypt::target::DecryptInto<__P, ::stack_encrypt::StackCipher<__K>, ()> - }); - assert_contains(&expansion, quote! { - impl<'__ctx, __P, __K, __T> ::stack_encrypt::target::DecryptInto<__P, ::stack_encrypt::StackCipher<__K>, ::stack_encrypt::NonEmpty<__T>> for Wrapped - where - StackCipherText: ::stack_encrypt::target::DecryptInto<__P, ::stack_encrypt::StackCipher<__K>, ::stack_encrypt::NonEmpty<__T>>, - __T: ::stack_encrypt::IntoAad<'__ctx> + ::core::clone::Clone - }); - assert_contains(&expansion, quote! { - <StackCipherText as ::stack_encrypt::target::DecryptInto<__P, ::stack_encrypt::StackCipher<__K>, ()>>::decrypt_into( - self.c, __cipher, __context, - ) - }); - assert_lacks(&expansion, quote!(hm)); - } - - #[test] - #[rustfmt::skip] - fn whole_mode_opens_the_one_field() { - let expansion = expand(parse_quote! { - #[stash(plaintext = u32, plaintext = u64)] - struct EncryptedAge { - #[stash(decrypt)] - c: StackCipherText, - hm: EqualityTerm, - } - }) - .unwrap(); - assert_contains(&expansion, quote! { - impl<'__ctx, __K, __T> ::stack_encrypt::target::DecryptInto<u32, ::stack_encrypt::StackCipher<__K>, ::stack_encrypt::NonEmpty<__T>> for EncryptedAge - where - StackCipherText: ::stack_encrypt::target::DecryptInto<u32, ::stack_encrypt::StackCipher<__K>, ::stack_encrypt::NonEmpty<__T>> - }); - assert_contains(&expansion, quote! { - <StackCipherText as ::stack_encrypt::target::DecryptInto<u64, ::stack_encrypt::StackCipher<__K>, ()>>::decrypt_into( - self.c, __cipher, __context, - ) - }); - assert_lacks(&expansion, quote!(hm)); - // Listed plaintexts: no impl over a generic `__P` (the `DecryptField` - // impl's `Self: DecryptInto<__P, ..>` bound is the one place `__P` - // legitimately appears). - assert_lacks( - &expansion, - quote!(DecryptInto<__P, ::stack_encrypt::StackCipher<__K>, ()> for EncryptedAge), - ); - } - - #[test] - #[rustfmt::skip] - fn by_field_mode_rebuilds_the_plaintext() { - let expansion = expand(parse_quote! { - #[stash(struct = User<T>, context = "users")] - struct EncryptedUser { - #[stash(decrypt, context = "legacy/age")] - age: EncryptedAge, - #[stash(decrypt, nested)] - email: EncryptedEmail, - #[stash(from = email)] - email_eq: EqualityTerm, - } - }) - .unwrap(); - assert_contains(&expansion, quote! { - <EncryptedAge as ::stack_encrypt::target::DecryptInto<_, ::stack_encrypt::StackCipher<__K>, ::stack_encrypt::NonEmpty<&'static str>>>::decrypt_into( - self.age, __cipher, ::stack_encrypt::nonempty!("legacy/age"), - ) - }); - // `email` is `nested`: it is handed the caller's context as it is — - // `()` in one impl, `NonEmpty<__T>` in the other — and its type - // composes it with its own contexts. A `struct` derive is bounded - // for `'static`. - assert_contains(&expansion, quote!(self.email, __cipher, __context,)); - assert_contains(&expansion, quote! { - impl<__K> ::stack_encrypt::target::DecryptInto<User<T>, ::stack_encrypt::StackCipher<__K>, ()> for EncryptedUser - }); - assert_contains(&expansion, quote! { - impl<__K, __T> ::stack_encrypt::target::DecryptInto<User<T>, ::stack_encrypt::StackCipher<__K>, ::stack_encrypt::NonEmpty<__T>> for EncryptedUser - where - __T: ::stack_encrypt::IntoAad<'static> + ::core::clone::Clone - }); - assert_contains(&expansion, quote!(.map(|(__field_0, __field_1)| User::<T> { age: __field_0, email: __field_1 }))); - assert_lacks(&expansion, quote!(email_eq)); - } - - #[test] - fn tuple_plaintexts_are_rebuilt_by_index() { - let expansion = expand(parse_quote! { - #[stash(struct = Pair, context = "pair")] - struct EncryptedPair { - #[stash(decrypt, from = 0)] - a: StackCipherText, - #[stash(decrypt, from = 1)] - b: StackCipherText, - } - }) - .unwrap(); - assert_contains( - &expansion, - quote!(Pair { - 0: __field_0, - 1: __field_1 - }), - ); - } - - #[test] - fn duplicate_recovery_targets_by_index_are_rejected() { - let err = expand(parse_quote! { - #[stash(struct = Pair, context = "pair")] - struct Rec { - #[stash(decrypt, from = 0)] - a: StackCipherText, - #[stash(decrypt, from = 0)] - b: StackCipherText, - } - }) - .unwrap_err(); - assert!(err.to_string().contains("same plaintext field `0`")); - } -} diff --git a/packages/stack-encrypt-derive/src/encrypt.rs b/packages/stack-encrypt-derive/src/encrypt.rs index 6203d3fe7..f5241c137 100644 --- a/packages/stack-encrypt-derive/src/encrypt.rs +++ b/packages/stack-encrypt-derive/src/encrypt.rs @@ -1,130 +1,73 @@ -//! Expansion of `#[derive(EncryptFrom)]`. - +//! Emit operation declarations; only core code receives plaintext and a cipher. +use crate::shape::{fresh_lifetime, trait_impl, zip, Field, Kind, Record}; use proc_macro2::TokenStream; use quote::{quote, quote_spanned}; use syn::spanned::Spanned; -use syn::{parse_quote, DeriveInput, Generics, Lifetime, Path, Result, Type}; - -use crate::shape::{ - cipher_type, context_param, impl_sources, keyset_lifetime, push_field_bounds, - push_keyset_lifetime, trait_impl, zip_fields, CallerContext, ContextImpl, Field, FieldBound, - Kind, Record, -}; +use syn::{parse_quote, DeriveInput, Result}; pub(crate) fn derive(input: DeriveInput) -> Result<TokenStream> { let record = Record::parse(&input)?; let krate = &record.krate; - let derived = record.derived(); - // Fields derived from the whole source get a where clause; `from = ..` - // fields reach into the source, so their obligations are checked in the - // body against the actual field. - let whole: Vec<&Field> = derived - .iter() - .filter(|f| f.from().is_none()) - .copied() - .collect(); - - let decryptable = decryptable_impl(&input, &record, &derived); - - // One impl per listed source, or one generic over it — and each of those - // twice, for `()` and for `NonEmpty<__T>` (see `context_param`). The - // record accepts exactly the sources every derived field accepts, which - // the where clause spells out so a mismatch is reported against the - // field type. - let (sources, generic) = impl_sources(&record, parse_quote!(__S)); - let keyset = keyset_lifetime(&input.generics); - let mut impls = Vec::with_capacity(sources.len() * 2); - for source in &sources { - for which in ContextImpl::BOTH { - let mut generics = input.generics.clone(); - if generic { - generics.params.push(parse_quote!(__S)); - } - generics.params.push(parse_quote!(__K)); - push_keyset_lifetime(&mut generics, &keyset); - push_field_bounds( - &mut generics, - krate, - &whole, - source, - FieldBound::Encrypt(keyset.clone()), - which, - ); - let ctx = context_param( - &mut generics, - CallerContext::Encrypt(krate), - which, - record.by_field, - &derived, - ); - let uses_context = derived.iter().any(|f| f.uses_callers_context(which)); - let body = body(krate, &record, &derived, source, which, &keyset); - impls.push(impl_block( - &input, - krate, - &generics, - &keyset, - source, - &ctx, - uses_context, - body, - )); + let fields = record.derived(); + let context = record.context_type(false); + let (sources, generic) = record.sources(parse_quote!(__S)); + let source_lifetime = fresh_lifetime(&input.generics, "__source"); + let mut impls = Vec::new(); + for source in sources { + let mut generics = input.generics.clone(); + if generic { + generics.params.push(parse_quote!(__S)); } - } - - Ok(quote!(#(#impls)* #decryptable)) -} - -/// `impl EncryptFrom<Source, KeysetCipher<'__k, __K>, Ctx> for Record` -/// around `body`; `ctx` is `()` or `NonEmpty<__T>` ([`context_param`]). The -/// context parameter is unnamed when no field uses it — every field of the -/// `()` impl of a `struct` derive carries its own — so the expansion warns -/// of nothing. -#[allow(clippy::too_many_arguments)] -fn impl_block( - input: &DeriveInput, - krate: &Path, - generics: &Generics, - keyset: &Lifetime, - source: &Type, - ctx: &Type, - uses_context: bool, - body: TokenStream, -) -> TokenStream { - let context = if uses_context { - quote!(__context) - } else { - quote!(_) - }; - let cipher = cipher_type(krate, &FieldBound::Encrypt(keyset.clone())); - trait_impl( - input, - generics, - quote!(#krate::target::EncryptFrom<#source, #cipher, #ctx>), - quote! { - fn encrypt_from<'__a>( - __source: &'__a #source, - __cipher: &'__a #cipher, - #context: #ctx, - ) -> #krate::target::Pending<'__a, Self, __K> - where - Self: '__a, - { + generics + .make_where_clause() + .predicates + .push(parse_quote!(Self: 'static)); + for field in fields.iter().filter(|f| f.from().is_none()) { + let ty = &field.ty; + let context = record.field_context_type(field); + let where_ = &mut generics.make_where_clause().predicates; + where_.push(parse_quote!(#ty: #krate::target::EncryptFrom<#source>)); + where_.push(parse_quote!(#context: Into<<#ty as #krate::target::EncryptFrom<#source>>::Context>)); + } + let plans = fields.iter().map(|field| { + let ty = &field.ty; + let context = record.context_expr(field, false); + let plan = if let Some(from) = field.from() { + quote_spanned!(ty.span()=> <#ty as #krate::target::EncryptFrom<_>>::encryption::<__K>(#context.into()) + .project(|__source: &#source| &__source.#from)) + } else { + quote_spanned!(ty.span()=> <#ty as #krate::target::EncryptFrom<#source>>::encryption::<__K>(#context.into())) + }; + (plan, field.local.clone()) + }).collect(); + let assignments = record.fields.iter().map(|field| { + let member = &field.member; + let value = match &field.kind { + Kind::Derived { .. } => { + let local = &field.local; + quote!(#local) + } + Kind::Default(Some(expr)) => quote!(#expr), + Kind::Default(None) => quote!(::core::default::Default::default()), + Kind::Context => quote!(__stored_context), + }; + quote!(#member: #value) + }); + let stored = record + .context_field() + .map(|_| quote!(let __stored_context = __context.clone().into_inner();)); + let body = zip(plans, quote!(Self { #(#assignments),* })); + impls.push(trait_impl(&input, &generics, quote!(#krate::target::EncryptFrom<#source>), quote! { + type Context = #context; + fn encryption<#source_lifetime,__K: 'static>(__context: Self::Context) -> #krate::target::Encryption<#source_lifetime,#source, Self, __K> where #source:#source_lifetime { + #stored #body } - }, - ) + })); + } + let decryptable = decryptable_impl(&input, &record, &fields); + Ok(quote!(#(#impls)* #decryptable)) } - -/// `impl Decryptable for Record`: a record is decryptable if any derived -/// field is. This is what lets a record sit inside a row whose -/// `DecryptInto` derive finds its ciphertext fields on its own. -/// -/// In the explicit mode — any field marked `#[stash(decrypt)]` — the record -/// is decryptable outright: the marker exists precisely so the other field -/// types need not be `Decryptable`, so probing them here would reintroduce -/// the bound the marker removes (and fail to compile for the documented -/// opaque-field shape). fn decryptable_impl(input: &DeriveInput, record: &Record, derived: &[&Field]) -> TokenStream { let krate = &record.krate; let name = &input.ident; @@ -147,331 +90,3 @@ fn decryptable_impl(input: &DeriveInput, record: &Record, derived: &[&Field]) -> } } } - -/// The method body: every derived field's pending, zipped into one, mapped -/// into `Self`. -fn body( - krate: &Path, - record: &Record, - derived: &[&Field], - source: &Type, - which: ContextImpl, - keyset: &Lifetime, -) -> TokenStream { - let assign = record.fields.iter().map(|field| { - let member = &field.member; - match &field.kind { - Kind::Derived { .. } => { - let local = &field.local; - quote!(#member: #local) - } - Kind::Default(Some(expr)) => quote!(#member: #expr), - Kind::Default(None) => quote!(#member: ::core::default::Default::default()), - } - }); - - zip_fields( - krate, - derived, - which, - |field, context| { - let ty = &field.ty; - // A `from` field's source type is not known here; it is inferred - // from the field expression, and the obligation checked there — - // spanned at the field type, so a leaf handed `()` (a `nested` - // field) is reported at the field that needs a `context`. The - // context type is named, not inferred, so that report is the - // trait's own (`EncryptFrom`'s `on_unimplemented`) rather than - // an argument type mismatch inside the expansion. - let (source_expr, source_ty): (TokenStream, TokenStream) = match field.from() { - Some(from) => (quote!(&__source.#from), quote!(_)), - None => (quote!(__source), quote!(#source)), - }; - let context_ty = field.field_context().ty(krate, which); - let call = quote_spanned! {ty.span()=> - <#ty as #krate::target::EncryptFrom<#source_ty, #krate::KeysetCipher<#keyset, __K>, #context_ty>>::encrypt_from - }; - quote!(#call(#source_expr, __cipher, #context,)) - }, - quote!(Self { #(#assign),* }), - ) -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::test_support::{assert_contains, assert_lacks}; - - fn expand(input: DeriveInput) -> String { - derive(input).unwrap().to_string() - } - - #[test] - #[rustfmt::skip] - fn a_record_is_decryptable_if_any_derived_field_is() { - let expansion = expand(parse_quote! { - struct EncryptedAge { - c: StackCipherText, - hm: EqualityTerm, - #[stash(default)] - v: u8, - } - }); - assert_contains(&expansion, quote! { - impl ::stack_encrypt::target::Decryptable for EncryptedAge { - const DECRYPTABLE: bool = false - || <StackCipherText as ::stack_encrypt::target::Decryptable>::DECRYPTABLE - || <EqualityTerm as ::stack_encrypt::target::Decryptable>::DECRYPTABLE; - } - }); - assert_lacks(&expansion, quote!(<u8 as ::stack_encrypt::target::Decryptable>)); - } - - #[test] - #[rustfmt::skip] - fn an_explicit_decrypt_marker_makes_the_record_decryptable_outright() { - // The documented explicit-mode shape: the marker frees the other - // field types from `Decryptable`, so the emitted impl must not - // probe them. - let expansion = expand(parse_quote! { - #[stash(plaintext = u32)] - struct Rec { - #[stash(decrypt)] - c: StackCipherText, - opaque: OpaqueTerm, - } - }); - assert_contains(&expansion, quote! { - impl ::stack_encrypt::target::Decryptable for Rec { - const DECRYPTABLE: bool = true; - } - }); - assert_lacks(&expansion, quote!(<OpaqueTerm as ::stack_encrypt::target::Decryptable>)); - assert_lacks(&expansion, quote!(<StackCipherText as ::stack_encrypt::target::Decryptable>)); - } - - /// `'__k` is a legal lifetime for the record to declare, so the derive's - /// own must not collide with it: the impl gains `'__k_` instead, and the - /// record's `'__k` is left to mean what the user made it mean. - #[test] - #[rustfmt::skip] - fn the_keyset_lifetime_steps_aside_for_a_record_that_declares_it() { - let expansion = expand(parse_quote! { - struct Borrowed<'__k> { - c: StackCipherText, - #[stash(default)] - label: Option<&'__k str>, - } - }); - assert_contains(&expansion, quote! { - impl<'__k_, '__k, __S, __K> ::stack_encrypt::target::EncryptFrom<__S, ::stack_encrypt::KeysetCipher<'__k_, __K>, ()> - for Borrowed<'__k> - }); - assert_contains(&expansion, quote! { - <StackCipherText as ::stack_encrypt::target::EncryptFrom<__S, ::stack_encrypt::KeysetCipher<'__k_, __K>, ()>>::encrypt_from - }); - assert_lacks(&expansion, quote!(KeysetCipher<'__k, __K>)); - } - - #[test] - #[rustfmt::skip] - fn a_record_gets_one_impl_for_unit_and_one_for_non_empty() { - let expansion = expand(parse_quote! { - struct EncryptedAge { - c: StackCipherText, - hm: EqualityTerm, - } - }); - // Both fields take the caller's context as it is. Under `()` the - // field bounds are unsatisfiable for a leaf — which is the compile - // error `encrypt_into` reports — and under `NonEmpty<__T>` the inner - // type is bounded by the vitaminc context traits for a free - // lifetime: nothing here extends a literal, so a borrowed context - // passes through. - assert_contains(&expansion, quote! { - impl<'__k, __S, __K> ::stack_encrypt::target::EncryptFrom<__S, ::stack_encrypt::KeysetCipher<'__k, __K>, ()> - for EncryptedAge - where - StackCipherText: ::stack_encrypt::target::EncryptFrom<__S, ::stack_encrypt::KeysetCipher<'__k, __K>, ()>, - EqualityTerm: ::stack_encrypt::target::EncryptFrom<__S, ::stack_encrypt::KeysetCipher<'__k, __K>, ()> - }); - assert_contains(&expansion, quote!(__context: (),)); - assert_contains(&expansion, quote! { - impl<'__ctx, '__k, __S, __K, __T> ::stack_encrypt::target::EncryptFrom<__S, ::stack_encrypt::KeysetCipher<'__k, __K>, ::stack_encrypt::NonEmpty<__T>> - for EncryptedAge - where - StackCipherText: ::stack_encrypt::target::EncryptFrom<__S, ::stack_encrypt::KeysetCipher<'__k, __K>, ::stack_encrypt::NonEmpty<__T>>, - EqualityTerm: ::stack_encrypt::target::EncryptFrom<__S, ::stack_encrypt::KeysetCipher<'__k, __K>, ::stack_encrypt::NonEmpty<__T>>, - __T: ::stack_encrypt::IntoAad<'__ctx> + ::stack_encrypt::IntoPrfContext<'__ctx> + ::core::clone::Clone - }); - // The first field clones the caller's context, the last takes it. - assert_contains(&expansion, quote!(__source, __cipher, ::core::clone::Clone::clone(&__context),)); - assert_contains(&expansion, quote!(__source, __cipher, __context,)); - assert_contains(&expansion, quote!(.map(|(__field_0, __field_1)| Self { c: __field_0, hm: __field_1 }))); - } - - #[test] - #[rustfmt::skip] - fn a_literal_context_is_extended_by_the_callers() { - let expansion = expand(parse_quote! { - #[stash(plaintext = u32)] - struct Pinned { - #[stash(context = "legacy/age")] - c: StackCipherText, - } - }); - // Under `()`: the literal as it is, a compile-time `NonEmpty`, and - // the (unit) context parameter unnamed. - assert_contains(&expansion, quote! { - impl<'__k, __K> ::stack_encrypt::target::EncryptFrom<u32, ::stack_encrypt::KeysetCipher<'__k, __K>, ()> for Pinned - where - StackCipherText: ::stack_encrypt::target::EncryptFrom<u32, ::stack_encrypt::KeysetCipher<'__k, __K>, ::stack_encrypt::NonEmpty<&'static str>> - }); - assert_contains(&expansion, quote!(_: (),)); - assert_contains(&expansion, quote!(__source, __cipher, ::stack_encrypt::nonempty!("legacy/age"),)); - // Under `NonEmpty<__T>`: the literal extended with the caller's, so - // no record accepts a context and then discards it; the literal - // fixes the pair's lifetime, so `__T` is bounded for `'static`. - assert_contains(&expansion, quote! { - impl<'__k, __K, __T> ::stack_encrypt::target::EncryptFrom<u32, ::stack_encrypt::KeysetCipher<'__k, __K>, ::stack_encrypt::NonEmpty<__T>> for Pinned - where - StackCipherText: ::stack_encrypt::target::EncryptFrom<u32, ::stack_encrypt::KeysetCipher<'__k, __K>, ::stack_encrypt::NonEmpty<(&'static str, ::stack_encrypt::NonEmpty<__T>)>>, - __T: ::stack_encrypt::IntoAad<'static> + ::stack_encrypt::IntoPrfContext<'static> + ::core::clone::Clone - }); - assert_contains(&expansion, quote! { - __source, __cipher, - ::stack_encrypt::NonEmpty::with(::stack_encrypt::nonempty!("legacy/age"), __context), - }); - assert_lacks(&expansion, quote!(_: ::stack_encrypt::NonEmpty<__T>)); - } - - #[test] - #[rustfmt::skip] - fn listed_sources_get_two_impls_each() { - let expansion = expand(parse_quote! { - #[stash(plaintext = i32, plaintext = i64)] - struct IntegerOrdOre { - c: StackCipherText, - #[stash(default = SchemaVersion::V3)] - v: SchemaVersion, - } - }); - for source in [quote!(i32), quote!(i64)] { - assert_contains(&expansion, quote! { - impl<'__k, __K> ::stack_encrypt::target::EncryptFrom<#source, ::stack_encrypt::KeysetCipher<'__k, __K>, ()> for IntegerOrdOre - }); - assert_contains(&expansion, quote! { - impl<'__ctx, '__k, __K, __T> ::stack_encrypt::target::EncryptFrom<#source, ::stack_encrypt::KeysetCipher<'__k, __K>, ::stack_encrypt::NonEmpty<__T>> for IntegerOrdOre - }); - } - assert_contains(&expansion, quote!(Self { c: __field_0, v: SchemaVersion::V3 })); - assert_lacks(&expansion, quote!(__S)); - } - - #[test] - #[rustfmt::skip] - fn a_struct_extends_its_inferred_contexts_with_the_callers() { - let expansion = expand(parse_quote! { - #[stash(struct = User, context = "user")] - struct EncryptedUser { - age: EncryptedAge, - email: StackCipherText, - } - }); - // Under `()`, the inferred contexts as they are; no field uses the - // caller's, so the parameter is unnamed. `from` fields carry no - // where clause: the plaintext field's type is unknown here, so the - // obligation is checked in the body instead. - assert_contains(&expansion, quote! { - impl<'__k, __K> ::stack_encrypt::target::EncryptFrom<User, ::stack_encrypt::KeysetCipher<'__k, __K>, ()> for EncryptedUser - }); - assert_contains(&expansion, quote!(_: (),)); - assert_contains(&expansion, quote! { - <EncryptedAge as ::stack_encrypt::target::EncryptFrom<_, ::stack_encrypt::KeysetCipher<'__k, __K>, ::stack_encrypt::NonEmpty<&'static str>>>::encrypt_from( - &__source.age, __cipher, ::stack_encrypt::nonempty!("user/age"), - ) - }); - assert_contains(&expansion, quote!(&__source.email, __cipher, ::stack_encrypt::nonempty!("user/email"),)); - // Under `NonEmpty<__T>`, each extended with the caller's — cloned to - // all but the last — and `__T` bounded for `'static`, the lifetime - // the literal fixes. - assert_contains(&expansion, quote! { - impl<'__k, __K, __T> ::stack_encrypt::target::EncryptFrom<User, ::stack_encrypt::KeysetCipher<'__k, __K>, ::stack_encrypt::NonEmpty<__T>> for EncryptedUser - where - __T: ::stack_encrypt::IntoAad<'static> + ::stack_encrypt::IntoPrfContext<'static> + ::core::clone::Clone - }); - assert_contains(&expansion, quote! { - &__source.age, __cipher, - ::stack_encrypt::NonEmpty::with(::stack_encrypt::nonempty!("user/age"), ::core::clone::Clone::clone(&__context)), - }); - assert_contains(&expansion, quote! { - &__source.email, __cipher, - ::stack_encrypt::NonEmpty::with(::stack_encrypt::nonempty!("user/email"), __context), - }); - assert_lacks(&expansion, quote!('__ctx)); - } - - #[test] - #[rustfmt::skip] - fn a_nested_field_is_handed_the_callers_context() { - let expansion = expand(parse_quote! { - #[stash(struct = Account, context = "accounts")] - struct EncryptedAccount { - #[stash(nested)] - user: EncryptedUser, - plan: StackCipherText, - } - }); - // `nested`: no inferred context; the caller's goes through as it - // is, and the inner `struct` derive composes it with its own. - assert_contains(&expansion, quote!(&__source.user, __cipher, ::core::clone::Clone::clone(&__context),)); - assert_contains(&expansion, quote! { - &__source.plan, __cipher, - ::stack_encrypt::NonEmpty::with(::stack_encrypt::nonempty!("accounts/plan"), __context), - }); - // Under `()` the nested field is the only one using the (unit) - // context, and takes it by move. - assert_contains(&expansion, quote!(&__source.user, __cipher, __context,)); - assert_contains(&expansion, quote!(&__source.plan, __cipher, ::stack_encrypt::nonempty!("accounts/plan"),)); - } - - #[test] - fn tuple_plaintexts_are_reached_by_index() { - let expansion = expand(parse_quote! { - #[stash(struct = Pair, context = "pair")] - struct EncryptedPair { - #[stash(from = 1)] - b: StackCipherText, - } - }); - assert_contains( - &expansion, - quote!(&__source.1, __cipher, ::stack_encrypt::nonempty!("pair/1"),), - ); - } - - #[test] - fn a_single_derived_field_still_maps_into_self() { - let expansion = expand(parse_quote! { - struct Wrapped { - c: StackCipherText, - } - }); - assert_contains(&expansion, quote!(.map(|__field_0| Self { c: __field_0 }))); - assert_lacks(&expansion, quote!(.zip)); - } - - #[test] - fn tuple_structs_assign_by_index() { - let expansion = expand(parse_quote! { - struct Pair(StackCipherText, EqualityTerm); - }); - assert_contains( - &expansion, - quote!(Self { - 0: __field_0, - 1: __field_1 - }), - ); - } -} diff --git a/packages/stack-encrypt-derive/src/lib.rs b/packages/stack-encrypt-derive/src/lib.rs index 339b0b88b..1c528cbb3 100644 --- a/packages/stack-encrypt-derive/src/lib.rs +++ b/packages/stack-encrypt-derive/src/lib.rs @@ -1,184 +1,48 @@ -//! Derive macros for [`stack-encrypt`](https://docs.rs/stack-encrypt)'s -//! target-directed encryption: `EncryptFrom` and `DecryptInto` for composite -//! records. -//! -//! Both macros are re-exported from `stack_encrypt`, so depend on that crate -//! rather than this one. -//! -//! # What a record is -//! -//! A stored encrypted value is rarely just a ciphertext — it is a *record*: -//! the ciphertext plus whatever index terms make the field queryable. Leaf -//! types (`StackCipherText`, the `sem` terms) implement `EncryptFrom` by -//! hand; a record is a struct of leaves, and this derive writes its impl: +//! Derive operation declarations for encrypted records. `EncryptFrom<P>` +//! declares how to produce a target; `DecryptInto<P>` selects ciphertext for +//! recovery. Stack Encrypt owns execution through Vitamin C's plaintext traits. +//! Generated code receives context and encrypted outputs, never a cipher. //! //! ``` -//! use stack_encrypt::sem::{EqualityTerm, OreTerm}; -//! use stack_encrypt::target::EncryptInto; -//! use stack_encrypt::{nonempty, DecryptInto, EncryptFrom, StackCipher, StackCipherText}; +//! use stack_encrypt::{EncryptFrom, DecryptInto, StackCipher, StackCipherText, NonEmpty}; +//! use stack_encrypt::sem::EqualityTerm; +//! use stack_encrypt::target::ExpectedContext; //! use stack_kms::FakeDataKeySource; //! -//! /// An encrypted integer, queryable by equality and range. //! #[derive(EncryptFrom, DecryptInto)] -//! #[stash(plaintext = u32)] -//! struct EncryptedAge { +//! #[stash(plaintext = String)] +//! struct TextEq { +//! #[stash(context_field)] +//! identifier: String, //! c: StackCipherText, //! hm: EqualityTerm, -//! ob: OreTerm<u32>, //! } //! //! # tokio::runtime::Builder::new_current_thread().enable_all().build().unwrap().block_on(async { -//! let cipher = StackCipher::builder() -//! .kms(FakeDataKeySource::new()) -//! .init() -//! .await?; +//! let cipher = StackCipher::builder().kms(FakeDataKeySource::new()).init().await?; //! let keyset = cipher.default_keyset(); -//! let record: EncryptedAge = 42u32 -//! .encrypt_into_with_context(&keyset, nonempty!("users/age")) -//! .await?; -//! let age: u32 = record.decrypt_into(&cipher, nonempty!("users/age")).await?; -//! assert_eq!(age, 42); -//! # Ok::<(), stack_encrypt::Error>(()) -//! # }).unwrap(); -//! ``` -//! -//! Every derived field is fed the **same source** under the **same -//! context**, exactly as the hand-written impl would: the ciphertext is -//! sealed with the context as its AAD, and every term is domain-separated by -//! it, so a term built at a query site under `"users/age"` matches the one -//! stored in the record. The field pendings are combined without being -//! awaited, so however many fields a record has, awaiting it is **one** -//! batched ZeroKMS call. Encrypting binds to a keyset — the `KeysetCipher` -//! every data key is minted and every term derived under — while decrypting -//! takes the client-scoped `StackCipher` (a sealed leaf names its own keyset) -//! or the `KeysetCipher`, which then refuses leaves from any other keyset. -//! -//! The record takes the caller's context because its fields do: the derive -//! emits one impl for `()` and one for `NonEmpty<T>`, each bounded by what -//! the fields accept under it, so a record of leaves — which accept only a -//! `NonEmpty<T>` — is encrypted with `encrypt_into_with_context`, and the -//! context-free `encrypt_into` does not compile against it. That is decided -//! by the field types, not by an attribute. -//! -//! Decryption opens the ciphertext field and passes over the terms, and no -//! attribute says which is which: each field type does, through -//! `Decryptable`, and the derive checks at compile time that exactly one -//! field is a ciphertext. `#[stash(decrypt)]` names the field only when the -//! types cannot — two ciphertexts, say. -//! -//! # Structs, field by field -//! -//! One level up, the same derive: a struct whose fields are each derived from -//! a *field* of the plaintext, under a context of their own. `struct = User, -//! context = "users"` says so once, for every field: `age` is derived from -//! `user.age` under `"users/age"`, `email` from `user.email` under -//! `"users/email"` — the prefix you name and the field, nothing invented. -//! Attributes on the fields are for the exceptions: `from = ..` when the -//! names differ, `context = ".."` to pin a whole context by hand, `nested` -//! for a field whose type is itself such a struct, carrying its own contexts. -//! -//! ``` -//! # use stack_encrypt::sem::{EqualityTerm, OreTerm}; -//! # use stack_encrypt::target::{DecryptFrom, EncryptInto}; -//! # use stack_encrypt::{nonempty, DecryptInto, EncryptFrom, StackCipher, StackCipherText}; -//! # use stack_kms::FakeDataKeySource; -//! # #[derive(EncryptFrom, DecryptInto)] -//! # #[stash(plaintext = u32)] -//! # struct EncryptedAge { -//! # c: StackCipherText, -//! # hm: EqualityTerm, -//! # ob: OreTerm<u32>, -//! # } -//! #[derive(Debug, PartialEq)] -//! struct User { -//! age: u32, -//! email: String, -//! } -//! -//! #[derive(EncryptFrom, DecryptInto)] -//! #[stash(struct = User, context = "users")] -//! struct EncryptedUser { -//! age: EncryptedAge, -//! email: StackCipherText, -//! } -//! -//! # tokio::runtime::Builder::new_current_thread().enable_all().build().unwrap().block_on(async { -//! # let cipher = StackCipher::builder().kms(FakeDataKeySource::new()).init().await?; -//! # let keyset = cipher.default_keyset(); -//! let user = User { age: 42, email: "alice@example.com".into() }; -//! let encrypted: EncryptedUser = user.encrypt_into(&keyset).await?; // one batch -//! let users = vec![User { age: 1, email: "a".into() }, User { age: 2, email: "b".into() }]; -//! let column: Vec<EncryptedUser> = users.encrypt_into(&keyset).await?; // still one -//! let user = User::decrypt_from(encrypted, &cipher).await?; -//! assert_eq!(user, User { age: 42, email: "alice@example.com".into() }); -//! assert_eq!(column.len(), 2); -//! -//! // A context passed by the caller *extends* every field's: `age` is now -//! // under `("users/age", 7u64)` — bound to its record as well as its name -//! // — and a probe for it is built under the same pair. -//! let user = User { age: 42, email: "alice@example.com".into() }; -//! let encrypted: EncryptedUser = user.encrypt_into_with_context(&keyset, 7u64).await?; -//! let probe: EqualityTerm = 42u32 -//! .encrypt_into_with_context(&keyset, nonempty!("users/age").with(7u64)) -//! .await?; -//! assert_eq!(encrypted.age.hm, probe); -//! let user = User::decrypt_from_with_context(encrypted, &cipher, 7u64).await?; -//! assert_eq!(user.age, 42); -//! # Ok::<(), stack_encrypt::Error>(()) +//! let value = "alice@example.com".to_owned(); +//! // The output type selects ciphertext + equality; context is NonEmpty<String>. +//! let encrypted: TextEq = keyset.encrypt_as(&value, NonEmpty::new("users/email".to_owned())?).await?; +//! // Reads the identifier from the record, validates it, and opens through Vitamin C. +//! let opened: String = cipher.decrypt_as(encrypted, ExpectedContext::default()).await?; +//! assert_eq!(opened, value); +//! # Ok::<(), Box<dyn std::error::Error>> (()) //! # }).unwrap(); //! ``` //! -//! A field's context is the stored field's identity — `"users/age"` is what -//! a query site derives a probe under — which is why it is inferred per -//! field rather than taken from the caller. What the caller passes is an -//! *extension*: the derive emits one impl for `()`, deriving each field -//! under its own context as it is, and one for `NonEmpty<T>`, deriving it -//! under `("users/age", context)` — a record id, typically, so a field opens -//! only in the record it was written to. That holds for a `plaintext` -//! record's `context = ".."` literals too: no record accepts a context and -//! then discards it. A field with no context of its own — a `plaintext` -//! record's field with no `context`, or a `#[stash(nested)]` field — is -//! handed the caller's as it is, and its type decides whether that will do: -//! a nested `struct` derive composes it with its own contexts; a leaf -//! accepts only a `NonEmpty<T>`, so the `()` impl fails to compile at the -//! field until it is given a `context`. -//! -//! The prefix is given explicitly (`context = "users"`), never inferred from -//! the Rust type's name: it is part of the stored data's identity — the AAD -//! of every ciphertext derived from the struct and the domain of every term -//! — and a name two types share, or a refactor changes, must not be able to -//! move it silently. The field half is still inferred from the plaintext -//! field's name, so renaming a plaintext field changes that field's context -//! and stored data stops decrypting; pin the old value with `context = ".."` -//! on the field before such a rename. The [attributes](#structs-field-by-field) -//! section says more. -//! -//! `plaintext = T` and `struct = T` are the two shapes a derive can take, -//! and the derive cannot tell them apart from `T` — a proc macro sees the -//! name, not the definition — so the attribute says which: `plaintext` -//! derives every field from the whole value, `struct` reaches into its -//! fields. `from` and `nested` exist only with `struct`. -//! -//! # What the derive commits to -//! -//! The `EncryptFrom` impls are over `KeysetCipher<'k, K>` and the -//! `DecryptInto` impls over `StackCipher<K>` (reaching a `KeysetCipher` -//! through stack-encrypt's blanket impls), for any `K`, each returning its -//! `Pending`. Those are the only ciphers today, and the only ones whose -//! output can be combined without awaiting; a derive generic over any -//! `EncryptTarget` needs combinators on that trait and can replace this one -//! without changing the attribute surface. -//! -//! Field-by-field decryption rebuilds the plaintext with a struct literal, so -//! every field of the plaintext must be recovered by exactly one ciphertext -//! field derived from it, and the plaintext must be a struct visible where -//! the derive expands. +//! For a record without a context field, fields use the caller's context or a +//! declared literal. `struct = User, context = "users"` selects plaintext fields +//! and binds them under `"users/<field>"`. The storage envelope itself adds no +//! cryptographic map-entry context. Vitamin C still binds keys inside plaintext +//! maps and preserves authenticated absence and empty-container markers. //! -//! # Enums +//! Ciphertext and term operations compose before awaiting, preserving batched +//! key requests. Terms alone need only their respective PRF or ordering trait. +//! A custom storage field can declare a core operation followed by `.transcode()`; +//! its visitor receives native encrypted output, without an intermediate format. +//! Query-only targets derive `EncryptFrom` alone. //! -//! Not supported: a record is a fixed set of fields derived from one source, -//! and a variant choice has no field to be derived into. Model the choice as -//! a struct of `Option` fields. #![doc = include_str!("../docs/attributes.md")] #![doc(html_favicon_url = "https://cipherstash.com/favicon.ico")] #![deny(unsafe_code)] @@ -204,8 +68,6 @@ mod attrs; mod decrypt; mod encrypt; mod shape; -#[cfg(test)] -mod test_support; /// Derive `EncryptFrom` for a record struct. See the [crate /// documentation](crate) for what the derive emits; the attributes it accepts @@ -219,7 +81,7 @@ pub fn derive_encrypt_from(input: TokenStream) -> TokenStream { .into() } -/// Derive `DecryptInto<Plaintext, _, _>` for a record struct, one impl per +/// Derive `DecryptInto<Plaintext>` for a record struct, one impl per /// `plaintext` type. See the [crate documentation](crate); the attributes it /// accepts are reproduced below. #[doc = include_str!("../docs/attributes.md")] diff --git a/packages/stack-encrypt-derive/src/shape.rs b/packages/stack-encrypt-derive/src/shape.rs index 17eb762ef..3f4d7952e 100644 --- a/packages/stack-encrypt-derive/src/shape.rs +++ b/packages/stack-encrypt-derive/src/shape.rs @@ -4,12 +4,25 @@ use proc_macro2::{Span, TokenStream}; use quote::quote; use syn::spanned::Spanned; use syn::{ - parse_quote, parse_quote_spanned, Data, DeriveInput, Expr, Fields, Generics, Ident, Lifetime, - LitStr, Member, Path, Result, Type, + parse_quote, Data, DeriveInput, Expr, Fields, Generics, Ident, Lifetime, LitStr, Member, Path, + Result, Type, }; use crate::attrs::{ContainerAttrs, FieldAttrs}; +/// A generated lifetime must not shadow one the record declares. Append +/// underscores until the name is free, preserving the user's parameters. +pub(crate) fn fresh_lifetime(generics: &Generics, base: &str) -> Lifetime { + let mut name = base.to_owned(); + while generics + .lifetimes() + .any(|declared| declared.lifetime.ident == name) + { + name.push('_'); + } + Lifetime::new(&format!("'{name}"), Span::call_site()) +} + /// One field of a record. #[cfg_attr(test, derive(Debug))] pub(crate) struct Field { @@ -41,6 +54,7 @@ pub(crate) enum Kind { }, /// Not derived: `Default::default()` or the given expression. Default(Option<Expr>), + Context, } impl Field { @@ -52,7 +66,7 @@ impl Field { pub(crate) fn from(&self) -> Option<&Member> { match &self.kind { Kind::Derived { from, .. } => from.as_ref(), - Kind::Default(_) => None, + Kind::Default(_) | Kind::Context => None, } } @@ -78,21 +92,9 @@ impl Field { context: Some(lit), .. } => FieldContext::Own(lit), Kind::Derived { context: None, .. } => FieldContext::Caller, - Kind::Default(_) => unreachable!("a `default` field has no context"), + Kind::Default(_) | Kind::Context => unreachable!("a `default` field has no context"), } } - - /// Does this field use the context the caller passes, in the impl for - /// `which`? A field with no context of its own always does; one with a - /// context of its own extends the caller's, so only under `NonEmpty<_>`. - /// See [`context_param`]. - pub(crate) fn uses_callers_context(&self, which: ContextImpl) -> bool { - self.is_derived() - && match self.field_context() { - FieldContext::Caller => true, - FieldContext::Own(_) => which == ContextImpl::NonEmpty, - } - } } /// Where a derived field's context comes from. See [`Field::field_context`]. @@ -103,64 +105,10 @@ pub(crate) enum FieldContext<'a> { /// extended with the caller's context under `NonEmpty<_>`. Own(&'a LitStr), /// No context of its own: handed the caller's as it is — `()`, or the - /// impl's `NonEmpty<__T>`. + /// record's associated context. Caller, } -/// Which of a derived record's two impls is being emitted: the context the -/// caller passes is `()` in one and `NonEmpty<__T>` in the other. Every -/// record gets both (see [`context_param`]), and every derived field uses -/// the caller's context under `NonEmpty<__T>`, so no record accepts a -/// context it then discards; a leaf field that has no context of its own -/// makes the `()` one unsatisfiable, which is the compile error -/// `encrypt_into` then reports. -#[derive(Clone, Copy, PartialEq, Eq)] -#[cfg_attr(test, derive(Debug))] -pub(crate) enum ContextImpl { - Unit, - NonEmpty, -} - -impl ContextImpl { - pub(crate) const BOTH: [ContextImpl; 2] = [ContextImpl::Unit, ContextImpl::NonEmpty]; -} - -impl FieldContext<'_> { - /// The context type as it appears in a where clause, in the impl for - /// `which`. - pub(crate) fn ty(&self, krate: &Path, which: ContextImpl) -> Type { - match (self, which) { - (FieldContext::Own(_), ContextImpl::Unit) => { - parse_quote!(#krate::NonEmpty<&'static str>) - } - (FieldContext::Own(_), ContextImpl::NonEmpty) => { - parse_quote!(#krate::NonEmpty<(&'static str, #krate::NonEmpty<__T>)>) - } - (FieldContext::Caller, ContextImpl::Unit) => parse_quote!(()), - (FieldContext::Caller, ContextImpl::NonEmpty) => parse_quote!(#krate::NonEmpty<__T>), - } - } - - /// The context expression the field is handed in the impl for `which`; - /// `caller` is the expression for the caller's context, which the call - /// site chooses (move, clone, or clone through a reference) and which - /// only a field that [uses it](Field::uses_callers_context) receives. - pub(crate) fn expr( - &self, - krate: &Path, - which: ContextImpl, - caller: TokenStream, - ) -> TokenStream { - match (self, which) { - (FieldContext::Own(lit), ContextImpl::Unit) => quote!(#krate::nonempty!(#lit)), - (FieldContext::Own(lit), ContextImpl::NonEmpty) => { - quote!(#krate::NonEmpty::with(#krate::nonempty!(#lit), #caller)) - } - (FieldContext::Caller, _) => caller, - } - } -} - /// The record a derive input describes. #[cfg_attr(test, derive(Debug))] pub(crate) struct Record { @@ -203,6 +151,36 @@ impl Record { // `ContainerAttrs::parse` has established that `context` is present // exactly when `struct` is. let fields = collect(&data.fields, attrs.context.as_ref())?; + if fields + .iter() + .filter(|f| matches!(f.kind, Kind::Context)) + .count() + > 1 + { + return Err(syn::Error::new_spanned( + &input.ident, + "a record has exactly one `context_field`", + )); + } + if fields.iter().any(|f| matches!(f.kind, Kind::Context)) && attrs.context.is_some() { + return Err(syn::Error::new_spanned( + &input.ident, + "`context_field` supplies the complete context; a literal prefix does not apply", + )); + } + if fields.iter().any(|f| matches!(f.kind, Kind::Context)) + && fields.iter().any(|f| { + matches!( + f.kind, + Kind::Derived { + context: Some(_), + .. + } + ) + }) + { + return Err(syn::Error::new_spanned(&input.ident, "`context_field` supplies the complete context; literal field contexts do not apply")); + } let by_field = attrs.by_field.is_some(); let plaintexts = match attrs.by_field { Some(plaintext) => vec![plaintext], @@ -225,87 +203,6 @@ impl Record { } } -/// The demand a derive places on the inner type of the caller's -/// `NonEmpty<__T>`, beyond what the field bounds already say — the vitaminc -/// context traits the direction needs, and `Clone` because one context -/// fans out to every field. -/// -/// The leaves in this crate state that demand through the field bounds -/// already, but a `from` field's bound is checked in the body (the derive -/// cannot name the plaintext field's type), and a term field's -/// `DecryptField` accepts *any* context (it opens nothing), so without -/// this bound a record whose ciphertext field carries a literal would -/// accept — and silently discard — a value that is not a context at all. -pub(crate) enum CallerContext<'a> { - /// Encrypt: convertible to AAD and to a PRF context. - Encrypt(&'a Path), - /// Decrypt: convertible to the AAD the value was encrypted under. - Decrypt(&'a Path), -} - -/// Adds the impl's context parameter for `which`, and returns the type the -/// impl is for. -/// -/// Every derived record gets two impls: one for `()`, under which each -/// field is derived under the context it carries itself, and one for -/// `NonEmpty<__T>`, under which a row's inferred contexts are extended with -/// the caller's and a field with no context of its own is handed the -/// caller's as it is. The `()` impl of a record whose leaf takes the -/// caller's context is unsatisfiable — a leaf exists only under a -/// `NonEmpty<_>` — which is exactly the compile error `encrypt_into` reports -/// against it. -/// -/// Under `NonEmpty<__T>` an extended context is `NonEmpty<(&'static str, -/// NonEmpty<__T>)>`, and the literal's `'static` fixes the lifetime the pair -/// implements the context traits for; a `struct` derive's `nested` field -/// hands the caller's context to a nested `struct` derive that extends it -/// likewise, and its obligation is checked in the body, where a free -/// lifetime could not meet it. So a record with any context of its own, and -/// every `struct` derive, is bounded for `'static`. Only a `plaintext` -/// record whose fields all take the caller's context as it is — every -/// bound in the where clause — is bounded for a free lifetime, and can pass -/// a borrowed context through. -pub(crate) fn context_param( - generics: &mut Generics, - bound: CallerContext<'_>, - which: ContextImpl, - by_field: bool, - fields: &[&Field], -) -> Type { - if which == ContextImpl::Unit { - return parse_quote!(()); - } - let krate = match bound { - CallerContext::Encrypt(krate) | CallerContext::Decrypt(krate) => krate, - }; - let needs_static = by_field - || fields - .iter() - .any(|f| matches!(f.field_context(), FieldContext::Own(_))); - let lifetime: syn::Lifetime = if needs_static { - parse_quote!('static) - } else { - // A lifetime parameter must precede the type parameters. - generics.params.insert(0, parse_quote!('__ctx)); - parse_quote!('__ctx) - }; - generics.params.push(parse_quote!(__T)); - let predicates = &mut generics.make_where_clause().predicates; - match bound { - CallerContext::Encrypt(_) => predicates.push(parse_quote! { - __T: #krate::IntoAad<#lifetime> + #krate::IntoPrfContext<#lifetime> + ::core::clone::Clone - }), - CallerContext::Decrypt(_) => predicates.push(parse_quote! { - __T: #krate::IntoAad<#lifetime> + ::core::clone::Clone - }), - } - parse_quote!(#krate::NonEmpty<__T>) -} - -/// `impl #trait_path for Record` around `content` — the scaffolding both -/// derives share. Splits the record's own generics (for the type position) -/// and the augmented `generics` (for the impl and its where clause) here, so -/// each derive hands over one `Generics` instead of three projections of it. pub(crate) fn trait_impl( input: &DeriveInput, generics: &Generics, @@ -323,151 +220,6 @@ pub(crate) fn trait_impl( } } -/// Which trait a field bound names; the bound is otherwise identical between -/// the two derives, and built in one place so the where-clause logic that -/// carries the per-field contexts cannot diverge between them. -pub(crate) enum FieldBound { - /// `EncryptFrom<Source, ..>` — for encrypt, on fields derived from the - /// whole source (the caller filters; a `from` field's obligation is - /// checked in the body instead, where the source field's type is known). - /// Carries the lifetime of the `KeysetCipher` the impl binds to - /// ([`keyset_lifetime`]). - Encrypt(Lifetime), - /// `DecryptField<Plaintext, ..>` — for automatic decrypt, on every - /// candidate field. - DecryptField, - /// `DecryptInto<Plaintext, ..>` — for explicit decrypt, on the one field - /// opened as the whole plaintext. - DecryptInto, -} - -/// `FieldTy: Trait<Target, Cipher, Ctx>` for each of `fields`, under the -/// context it is derived or opened under in the impl for `which` — its -/// literal's, `()`, or the caller's `NonEmpty<__T>`, which is how a record -/// inherits its leaves' demand for a non-empty context. The cipher is the -/// one the trait binds to: encrypting binds to a keyset -/// (`KeysetCipher<'__k, __K>`), decrypting to the client (`StackCipher<__K>`; -/// the keyset-constrained form is a blanket over it). -pub(crate) fn push_field_bounds( - generics: &mut Generics, - krate: &Path, - fields: &[&Field], - target: &Type, - bound: FieldBound, - which: ContextImpl, -) { - let trait_name: Ident = match bound { - FieldBound::Encrypt(_) => parse_quote!(EncryptFrom), - FieldBound::DecryptField => parse_quote!(DecryptField), - FieldBound::DecryptInto => parse_quote!(DecryptInto), - }; - let cipher = cipher_type(krate, &bound); - let predicates = &mut generics.make_where_clause().predicates; - for field in fields { - let ty = &field.ty; - let context = field.field_context().ty(krate, which); - // Spanned at the field type, so a type that cannot be a field of the - // record is reported there, not at the derive. - predicates.push(parse_quote_spanned! {ty.span()=> - #ty: #krate::target::#trait_name<#target, #cipher, #context> - }); - } -} - -/// The cipher a derived impl binds to. Encrypting binds to a keyset, so the -/// encrypt impls are over `KeysetCipher<'__k, __K>` and carry that lifetime -/// ([`keyset_lifetime`], [`push_keyset_lifetime`]); decrypting is -/// client-scoped, so the decrypt impls are over `StackCipher<__K>`. -pub(crate) fn cipher_type(krate: &Path, bound: &FieldBound) -> Type { - match bound { - FieldBound::Encrypt(keyset) => parse_quote!(#krate::KeysetCipher<#keyset, __K>), - FieldBound::DecryptField | FieldBound::DecryptInto => { - parse_quote!(#krate::StackCipher<__K>) - } - } -} - -/// The lifetime of the `KeysetCipher` an encrypt impl binds to: `'__k`, -/// unless the record declares that name itself — it is a legal lifetime -/// for a user's type — in which case the first of `'__k_`, `'__k__`, … it -/// does not. The record's parameters are the user's; a name the derive -/// adds beside them must be one they did not take. -pub(crate) fn keyset_lifetime(generics: &Generics) -> Lifetime { - let mut name = String::from("__k"); - while generics - .lifetimes() - .any(|declared| declared.lifetime.ident == name) - { - name.push('_'); - } - Lifetime::new(&format!("'{name}"), Span::call_site()) -} - -/// Add the lifetime of the `KeysetCipher` an encrypt impl binds to. -/// Lifetimes precede type parameters in a generics list, so it goes first. -pub(crate) fn push_keyset_lifetime(generics: &mut Generics, keyset: &Lifetime) { - generics.params.insert(0, parse_quote!(#keyset)); -} - -/// The source (or plaintext) types a derive emits one impl each for: the -/// listed ones, or — when none are listed — the given generic parameter, -/// with `true` saying it must be pushed onto the impl's generics. -pub(crate) fn impl_sources(record: &Record, generic: Ident) -> (Vec<Type>, bool) { - if record.plaintexts.is_empty() { - (vec![parse_quote!(#generic)], true) - } else { - (record.plaintexts.clone(), false) - } -} - -/// The pendings of `fields`, zipped into one and mapped into `build` (a -/// struct literal over the fields' locals). Nothing is awaited, so the record -/// settles as one batched call. -/// -/// `call(field, context)` renders one field's pending under `context`, the -/// expression [`FieldContext::expr`] gives the field in the impl for -/// `which`. The caller's context (`__context`) goes to every field that -/// uses it; the last such field takes it by move, the rest clone it. -pub(crate) fn zip_fields( - krate: &Path, - fields: &[&Field], - which: ContextImpl, - mut call: impl FnMut(&Field, TokenStream) -> TokenStream, - build: TokenStream, -) -> TokenStream { - let mut remaining = fields - .iter() - .filter(|f| f.uses_callers_context(which)) - .count(); - - let mut chain = TokenStream::new(); - let mut pattern = TokenStream::new(); - for (index, field) in fields.iter().enumerate() { - let caller = if field.uses_callers_context(which) { - remaining -= 1; - if remaining == 0 { - quote!(__context) - } else { - quote!(::core::clone::Clone::clone(&__context)) - } - } else { - TokenStream::new() - }; - let context = field.field_context().expr(krate, which, caller); - let call = call(field, context); - let local = &field.local; - if index == 0 { - chain = call; - pattern = quote!(#local); - } else { - chain = quote!(#chain.zip(#call)); - pattern = quote!((#pattern, #local)); - } - } - - quote!(#chain.map(|#pattern| #build)) -} - /// The fields, with what a `struct` derive (`prefix` is the container's /// `context`) fills in: `from` is the field's own name and `context` is /// `"<prefix>/<from>"`, each unless the field gives its own. @@ -521,48 +273,64 @@ fn collect(fields: &Fields, prefix: Option<&LitStr>) -> Result<Vec<Field>> { return Err(syn::Error::new(context.span(), message)); } } - let kind = match attrs.default { - Some(default) => { - if attrs.context.is_some() - || attrs.from.is_some() - || attrs.decrypt - || attrs.nested - { - return Err(syn::Error::new_spanned( - &field.ty, - "a `default` field is not derived from the source, so `context`, \ + if attrs.context_field + && (attrs.default.is_some() + || attrs.context.is_some() + || attrs.from.is_some() + || attrs.decrypt + || attrs.nested) + { + return Err(syn::Error::new_spanned( + &field.ty, + "`context_field` is metadata and cannot also be derived or defaulted", + )); + } + let kind = if attrs.context_field { + Kind::Context + } else { + match attrs.default { + Some(default) => { + if attrs.context.is_some() + || attrs.from.is_some() + || attrs.decrypt + || attrs.nested + { + return Err(syn::Error::new_spanned( + &field.ty, + "a `default` field is not derived from the source, so `context`, \ `from`, `decrypt` and `nested` do not apply to it", - )); + )); + } + Kind::Default(default) } - Kind::Default(default) - } - None => match prefix { - Some(prefix) => { - let from = attrs.from.unwrap_or_else(|| member.clone()); - let context = if attrs.nested { - // The field's type carries its own contexts; it - // is handed the caller's (`FieldContext::Caller`). - None - } else if let Some(lit) = attrs.context { - Some(lit) - } else { - let column = match &from { - Member::Named(ident) => ident.to_string(), - Member::Unnamed(index) => index.index.to_string(), + None => match prefix { + Some(prefix) => { + let from = attrs.from.unwrap_or_else(|| member.clone()); + let context = if attrs.nested { + // The field's type carries its own contexts; it + // is handed the caller's (`FieldContext::Caller`). + None + } else if let Some(lit) = attrs.context { + Some(lit) + } else { + let column = match &from { + Member::Named(ident) => ident.to_string(), + Member::Unnamed(index) => index.index.to_string(), + }; + let prefix = prefix.value(); + Some(LitStr::new(&format!("{prefix}/{column}"), member.span())) }; - let prefix = prefix.value(); - Some(LitStr::new(&format!("{prefix}/{column}"), member.span())) - }; - Kind::Derived { - context, - from: Some(from), + Kind::Derived { + context, + from: Some(from), + } } - } - None => Kind::Derived { - context: attrs.context, - from: None, + None => Kind::Derived { + context: attrs.context, + from: None, + }, }, - }, + } }; Ok(Field { member, @@ -575,6 +343,83 @@ fn collect(fields: &Fields, prefix: Option<&LitStr>) -> Result<Vec<Field>> { .collect() } +impl Record { + pub(crate) fn context_field(&self) -> Option<&Field> { + self.fields.iter().find(|f| matches!(f.kind, Kind::Context)) + } + pub(crate) fn declared_contexts(&self) -> bool { + self.by_field + || self + .derived() + .iter() + .all(|f| matches!(f.field_context(), FieldContext::Own(_))) + } + pub(crate) fn context_type(&self, decrypt: bool) -> Type { + let krate = &self.krate; + if let Some(field) = self.context_field() { + let ty = &field.ty; + if decrypt { + parse_quote!(#krate::target::ExpectedContext<#ty>) + } else { + parse_quote!(#krate::NonEmpty<#ty>) + } + } else if self.declared_contexts() { + parse_quote!(#krate::target::DeclaredContext) + } else { + parse_quote!(#krate::target::CallerContext) + } + } + pub(crate) fn field_context_type(&self, field: &Field) -> Type { + let krate = &self.krate; + match field.field_context() { + FieldContext::Own(_) => parse_quote!(#krate::target::CallerContext), + FieldContext::Caller => self.context_type(false), + } + } + pub(crate) fn context_expr(&self, field: &Field, decrypt: bool) -> TokenStream { + let krate = &self.krate; + match field.field_context() { + FieldContext::Caller => quote!(::core::clone::Clone::clone(&__context)), + FieldContext::Own(lit) => { + let failed = if decrypt { + quote!(#krate::target::Decryption::failed) + } else { + quote!(#krate::target::Encryption::failed) + }; + let method = if self.declared_contexts() { + quote!(field) + } else { + quote!(under) + }; + quote!(match __context.clone().#method(#lit) { + Ok(context) => context, Err(error) => return #failed(error), + }) + } + } + } + pub(crate) fn sources(&self, generic: Type) -> (Vec<Type>, bool) { + if self.plaintexts.is_empty() { + (vec![generic], true) + } else { + (self.plaintexts.clone(), false) + } + } +} +pub(crate) fn zip(plans: Vec<(TokenStream, Ident)>, result: TokenStream) -> TokenStream { + let mut chain = TokenStream::new(); + let mut pattern = TokenStream::new(); + for (index, (plan, local)) in plans.into_iter().enumerate() { + if index == 0 { + chain = plan; + pattern = quote!(#local); + } else { + chain = quote!(#chain.zip(#plan)); + pattern = quote!((#pattern, #local)); + } + } + quote!(#chain.map(move |#pattern| #result)) +} + #[cfg(test)] mod tests { use super::*; @@ -835,12 +680,9 @@ mod tests { // context extends it. assert!(matches!(name.from(), Some(Member::Named(m)) if m == "name")); assert_eq!(own(name), "legacy/name"); - assert!(name.uses_callers_context(ContextImpl::NonEmpty)); - assert!(!name.uses_callers_context(ContextImpl::Unit)); // `nested`: no inferred context — the field is handed the caller's. assert!(matches!(address.from(), Some(Member::Named(m)) if m == "address")); assert!(matches!(address.field_context(), FieldContext::Caller)); - assert!(address.uses_callers_context(ContextImpl::Unit)); assert!(!version.is_derived()); } diff --git a/packages/stack-encrypt-derive/src/test_support.rs b/packages/stack-encrypt-derive/src/test_support.rs deleted file mode 100644 index d0437fd3c..000000000 --- a/packages/stack-encrypt-derive/src/test_support.rs +++ /dev/null @@ -1,37 +0,0 @@ -//! Helpers shared by the expansion tests in `encrypt` and `decrypt`. -//! -//! Expansions are asserted as *tokens*, never as text: the expected fragment is -//! rendered through `quote!` as well, so it is written as ordinary Rust and the -//! comparison cannot fail on `TokenStream`'s spacing. -//! -//! Whitespace is stripped from both sides before comparing: `quote!` renders -//! a joint `>>` where a `parse_quote!`-built predicate renders `> >`, and -//! the two are the same tokens. -//! -//! One caveat: rustfmt formats inside `quote!` bodies and will add or strip a -//! trailing comma, which *does* change the tokens. Keep fragments short enough -//! that rustfmt leaves them alone, or mark the test `#[rustfmt::skip]`. - -use proc_macro2::TokenStream; - -fn squash(text: &str) -> String { - text.chars().filter(|c| !c.is_whitespace()).collect() -} - -#[track_caller] -pub(crate) fn assert_contains(expansion: &str, fragment: TokenStream) { - let fragment = fragment.to_string(); - assert!( - squash(expansion).contains(&squash(&fragment)), - "expansion is missing `{fragment}`:\n{expansion}" - ); -} - -#[track_caller] -pub(crate) fn assert_lacks(expansion: &str, fragment: TokenStream) { - let fragment = fragment.to_string(); - assert!( - !squash(expansion).contains(&squash(&fragment)), - "expansion unexpectedly contains `{fragment}`:\n{expansion}" - ); -} diff --git a/packages/stack-encrypt/CONTEXT.md b/packages/stack-encrypt/CONTEXT.md index f8011e264..5c590e95e 100644 --- a/packages/stack-encrypt/CONTEXT.md +++ b/packages/stack-encrypt/CONTEXT.md @@ -31,8 +31,8 @@ _Avoid_: plaintext serialization, re-encryption **Context**: The value a ciphertext is authenticated under and a term is derived under. A -leaf takes a `NonEmpty<T>` — vitaminc's proof that the value carries caller -bytes — and nothing else; a `nonempty!("users/email")` literal, a +leaf requires a nonempty context, validated by Vitamin C and owned in an +`AeadContext` or `CallerContext` declaration; a `nonempty!("users/email")` literal, a `NonEmpty::new(value)?` at runtime, or a bare integer. It becomes the ciphertext's associated data, the term's PRF context, and the ZeroKMS descriptor of the data key. @@ -70,7 +70,7 @@ _Avoid_: composite, struct (the plaintext is the struct; the record is derived f **EQL type**: An output type that participates in EQL — a ciphertext or index term stored for query — and so carries the contract that its context is supplied and -non-empty. Every leaf and record in the target-directed path is one. +non-empty. EQL integration supplies a concrete identifier; generic target records need not be EQL types. _Avoid_: searchable type, indexed type **Term**: diff --git a/packages/stack-encrypt/docs/adr/0003-declarative-targets-and-ciphertext-transcoding.md b/packages/stack-encrypt/docs/adr/0003-declarative-targets-and-ciphertext-transcoding.md index d5cc96513..42d0e75de 100644 --- a/packages/stack-encrypt/docs/adr/0003-declarative-targets-and-ciphertext-transcoding.md +++ b/packages/stack-encrypt/docs/adr/0003-declarative-targets-and-ciphertext-transcoding.md @@ -15,10 +15,12 @@ operations and constructing the target through a visitor over native encryption output. This preserves Vitamin C's plaintext contract while supporting derived records without an additional serialized buffer or generic intermediate tree. -This records the accepted architecture; implementation is pending. It supersedes +This records the accepted architecture, implemented by the core operation +descriptions, native readers, and derives in this crate. It supersedes [ADR-0001](0001-context-optional-cipher-directed-path.md) as the current context and target-extension contract, carrying forward the context policies stated -below. Exact supporting trait signatures remain implementation work. +below. EQL-shaped integration tests exercise the consumer contract; wiring the +actual EQL crate and extending Vitamin C plaintext coverage remain separate work. ## Decision @@ -38,7 +40,7 @@ normalization and tokenization must be declared where the types do not determine them. Callers provide plaintext and the target's context, not a separate plan: ```rust -// Intended call-site shape; not an implemented API. +// Consumer call-site shape; Identifier and TextEq belong to EQL. let identifier = Identifier::for_column("users", "email")?; let encrypted: TextEq = keyset.encrypt_as(&email, identifier).await?; ``` diff --git a/packages/stack-encrypt/src/lib.rs b/packages/stack-encrypt/src/lib.rs index b47d5e502..9684d4f87 100644 --- a/packages/stack-encrypt/src/lib.rs +++ b/packages/stack-encrypt/src/lib.rs @@ -268,9 +268,8 @@ pub use cipher::{ pub use descriptor::Descriptor; pub use keyset::KeysetCipher; pub use target::{ - CipherScope, DecryptField, DecryptFrom, DecryptInto, DecryptTarget, Decryptable, - ElementContext, EncryptFrom, EncryptInto, EncryptTarget, Pending, PendingFuture, Request, - Responses, + CallerContext, CipherScope, DecryptField, DecryptFrom, DecryptInto, Decryptable, Decryption, + EncryptFrom, EncryptInto, Encryption, Pending, PendingFuture, Request, Responses, }; // Re-export the vitaminc AEAD surface callers need to drive the cipher, so they diff --git a/packages/stack-encrypt/src/sem/mod.rs b/packages/stack-encrypt/src/sem/mod.rs index 57753d3b9..0fa6ef59f 100644 --- a/packages/stack-encrypt/src/sem/mod.rs +++ b/packages/stack-encrypt/src/sem/mod.rs @@ -3,7 +3,7 @@ //! Index terms are stored alongside a //! [`StackCipherText`](crate::StackCipherText) so encrypted values can be //! queried without decryption. Each term type implements -//! [`EncryptFrom`], so the usual entry point is +//! [`EncryptFrom`](crate::EncryptFrom), so the usual entry point is //! target-directed: //! //! ```text @@ -22,9 +22,9 @@ //! //! Every term type here is built on exactly one thing the cipher exposes //! publicly — its PRF ([`KeysetCipher::prf`], keyed by the index key of the -//! keyset the handle is bound to) — with no privileged access, so they -//! double as worked examples for defining your own term types in another -//! crate (see [`target`](crate::target#extending-with-your-own-sem-type)). +//! keyset the handle is bound to). These core implementations own term +//! generation; downstream storage types can wrap the supported operations or +//! consume their native output with a visitor (see [`target`](crate::target)). //! Terms bind to a keyset the way sealed values do: a term derived through //! one tenant's [`KeysetCipher`] compares only against terms derived through //! the same keyset. @@ -52,7 +52,7 @@ //! [`HmacSha256Prf`] — keyed by the //! deterministic per-keyset [`IndexKey`](stack_kms::IndexKey) from //! [`stack_kms::IndexKeySource`], loaded when the keyset is selected — so -//! every derivation completes with no I/O and an [`EncryptFrom`] term +//! every derivation completes with no I/O and an [`EncryptFrom`](crate::EncryptFrom) term //! carries **no requests** in its [`Pending`]. That is the backend's //! property, not the API's: the term is a `Pending` either way. The next //! ZeroKMS release adds 2-party PRF generation; under @@ -146,8 +146,9 @@ use zeroize::Zeroize; use stack_kms::MaybeSend; -use crate::target::{DecryptField, Decryptable, EncryptFrom, Pending}; -use crate::{Error, KeysetCipher, StackCipher}; +use crate::target::core::Term; +use crate::target::Decryptable; +use crate::{Error, KeysetCipher, Pending}; // The `/v1` suffix versions the *derivation* (domain + input framing), not the // crate. Any change to the bytes a term derives from must bump it: a changed @@ -338,17 +339,16 @@ where /// An equality term of any [`PrfValue`] source. Under the local HMAC /// backend, derived during the synchronous build — the returned [`Pending`] /// carries no requests. -impl<'c, 'k, S, K, T> EncryptFrom<S, KeysetCipher<'k, K>, NonEmpty<T>> for EqualityTerm +impl<'c, S, K, T> Term<S, K, NonEmpty<T>> for EqualityTerm where S: PrfValue + Clone, T: IntoPrfContext<'c>, { // Derived locally: no data key, no descriptor. - const KEYED: bool = false; fn encrypt_from<'a>( - source: &'a S, - cipher: &'a KeysetCipher<'k, K>, + source: &S, + cipher: &'a KeysetCipher<'_, K>, context: NonEmpty<T>, ) -> Pending<'a, Self, K> where @@ -651,18 +651,17 @@ fn match_term<O>( /// the local HMAC backend, derived during the synchronous build — the /// returned [`Pending`] carries no requests (tokenize makes the one /// necessary copy of the text). -impl<'c, 'k, S, K, O, T> EncryptFrom<S, KeysetCipher<'k, K>, NonEmpty<T>> for MatchTerm<O> +impl<'c, S, K, O, T> Term<S, K, NonEmpty<T>> for MatchTerm<O> where S: AsRef<str>, O: MatchConfig, T: IntoPrfContext<'c>, { // Derived locally: no data key, no descriptor. - const KEYED: bool = false; fn encrypt_from<'a>( - source: &'a S, - cipher: &'a KeysetCipher<'k, K>, + source: &S, + cipher: &'a KeysetCipher<'_, K>, context: NonEmpty<T>, ) -> Pending<'a, Self, K> where @@ -701,23 +700,7 @@ macro_rules! index_term { const DECRYPTABLE: bool = false; } - // Over `StackCipher` only: the `KeysetCipher` form is the blanket - // in `target`, as for every `DecryptField`. - impl<__P, __K, __Ctx $(, $param: $bound)?> DecryptField<__P, StackCipher<__K>, __Ctx> - for $ty - { - fn decrypt_field<'a>( - self, - _cipher: &'a StackCipher<__K>, - _context: __Ctx, - ) -> Option<Pending<'a, __P, __K>> - where - Self: 'a, - __P: 'a, - { - None - } - } + }; } @@ -954,18 +937,17 @@ where /// An ORE term of any [`CllwOreEncrypt`] source. Under the local HMAC /// backend, derived during the synchronous build — the returned [`Pending`] /// carries no requests. -impl<'c, 'k, S, K, T> EncryptFrom<S, KeysetCipher<'k, K>, NonEmpty<T>> for OreTerm<S> +impl<'c, S, K, T> Term<S, K, NonEmpty<T>> for OreTerm<S> where S: CllwOreEncrypt + Clone + Send + 'static, S::Output: Send + 'static, T: IntoPrfContext<'c>, { // Derived locally: no data key, no descriptor. - const KEYED: bool = false; fn encrypt_from<'a>( - source: &'a S, - cipher: &'a KeysetCipher<'k, K>, + source: &S, + cipher: &'a KeysetCipher<'_, K>, context: NonEmpty<T>, ) -> Pending<'a, Self, K> where @@ -980,18 +962,17 @@ where /// An OPE term of any [`CllwOpeEncrypt`] source. Under the local HMAC /// backend, derived during the synchronous build — the returned [`Pending`] /// carries no requests. -impl<'c, 'k, S, K, T> EncryptFrom<S, KeysetCipher<'k, K>, NonEmpty<T>> for OpeTerm<S> +impl<'c, S, K, T> Term<S, K, NonEmpty<T>> for OpeTerm<S> where S: CllwOpeEncrypt + Clone + Send + 'static, S::Output: Send + 'static, T: IntoPrfContext<'c>, { // Derived locally: no data key, no descriptor. - const KEYED: bool = false; fn encrypt_from<'a>( - source: &'a S, - cipher: &'a KeysetCipher<'k, K>, + source: &S, + cipher: &'a KeysetCipher<'_, K>, context: NonEmpty<T>, ) -> Pending<'a, Self, K> where diff --git a/packages/stack-encrypt/src/target/core.rs b/packages/stack-encrypt/src/target/core.rs new file mode 100644 index 000000000..ef2527392 --- /dev/null +++ b/packages/stack-encrypt/src/target/core.rs @@ -0,0 +1,140 @@ +//! Canonical execution shared by the cipher-directed and declaration APIs. +use super::{CipherScope, Pending, Request, Responses}; +use crate::cipher::{bind_keys, PendingStackCipherText, StackDecipher}; +use crate::{Descriptor, Error, KeysetCipher, StackCipher, StackCipherText}; +use vitaminc_aead::{CipherText, Decrypt, Encrypt, IntoAad}; +use vitaminc_protected::NonEmpty; + +/// Internal term operation. It is deliberately inaccessible to target authors. +pub(crate) trait Term<S, K, Ctx>: Sized { + fn encrypt_from<'a>( + source: &S, + cipher: &'a KeysetCipher<'_, K>, + context: Ctx, + ) -> Pending<'a, Self, K> + where + Self: 'a; +} + +pub(crate) fn encrypt_native<'a, 'c, S: Encrypt + Clone, K, T: IntoAad<'c>>( + source: &S, + cipher: &'a KeysetCipher<'_, K>, + context: NonEmpty<T>, +) -> Pending<'a, StackCipherText, K> { + let context = context.into_aad_piece(); + let descriptor = Descriptor::from_piece(&context); + if let Err(error) = descriptor.check() { + return Pending::failed(cipher, error); + } + let aad = context.into_aad().into_owned(); + match source.clone().encrypt_with_aad(cipher, aad) { + Ok(tree) => seal_pending(cipher, tree, descriptor), + Err(_) => Pending::ready(cipher, Err(Error::Aead)), + } +} +pub(crate) fn open_native<'a, 'c, P: Decrypt<'static> + 'static, K, T: IntoAad<'c>>( + tree: StackCipherText, + cipher: &'a StackCipher<K>, + context: NonEmpty<T>, +) -> Pending<'a, P, K> { + let context = context.into_aad_piece(); + let descriptor = Descriptor::from_piece(&context); + // Fast path, as in `seal_pending`; `dispatch` is the gate. + if let Err(e) = descriptor.check() { + return Pending::ready(cipher, Err(e)); + } + let requests = retrieve_requests(&tree, &descriptor); + let aad = context.into_aad().into_owned(); + Pending::request(cipher, requests, move |responses| { + let decipher = decipher_from_responses(tree, responses)?; + P::decrypt_with_aad(decipher, aad).map_err(Error::from) + }) +} +pub(crate) fn seal_pending<'a, K>( + cipher: &'a KeysetCipher<'_, K>, + tree: PendingStackCipherText, + descriptor: Descriptor, +) -> Pending<'a, StackCipherText, K> { + // Fast path: refuse an over-long descriptor before a single request + // exists, not after one per leaf has been built. `dispatch` is the gate + // proper, and checks every request's descriptor. + if let Err(e) = descriptor.check() { + return Pending::ready(cipher, Err(e)); + } + let keyset_id = cipher.keyset_id(); + let requests = std::iter::repeat_with(|| Request::generate_data_key(descriptor.clone())) + .take(tree.key_count()) + .collect(); + Pending::request(cipher, requests, move |responses| { + let mut keys = responses.drain_generated(); + tree.seal_with(keyset_id, &mut keys).map_err(Error::from) + }) +} + +pub(crate) fn decipher_pending<'a, K>( + scope: impl CipherScope<'a, K>, + ciphertext: StackCipherText, + descriptor: Descriptor, +) -> Pending<'a, StackDecipher, K> { + // Fast path, as in `seal_pending`; `dispatch` is the gate. + if let Err(e) = descriptor.check() { + return Pending::ready(scope, Err(e)); + } + let requests = retrieve_requests(&ciphertext, &descriptor); + Pending::request(scope, requests, move |responses| { + decipher_from_responses(ciphertext, responses) + }) +} + +fn decipher_from_responses( + ciphertext: StackCipherText, + responses: &mut Responses, +) -> Result<StackDecipher, Error> { + let mut keys = responses.drain_retrieved(); + // Too few keys for the tree, or keys left over once it is bound, both + // mean `retrieve_requests` and `bind_keys` disagreed about the tree's + // shape: a composition bug in this module, not a data error — so + // `ResponseShape`, never `Aead`, which would read as tampering. + let keyed = bind_keys(ciphertext, &mut keys).map_err(|_| Error::ResponseShape)?; + if keys.next().is_some() { + return Err(Error::ResponseShape); + } + Ok(StackDecipher::over(keyed)) +} + +fn retrieve_requests(ciphertext: &StackCipherText, descriptor: &Descriptor) -> Vec<Request> { + let mut out = Vec::new(); + collect_retrieve_requests(ciphertext, descriptor, &mut out); + out +} + +fn collect_retrieve_requests( + ciphertext: &StackCipherText, + descriptor: &Descriptor, + out: &mut Vec<Request>, +) { + match ciphertext { + CipherText::Single(leaf) + | CipherText::None(leaf) + | CipherText::EmptySequence(leaf) + | CipherText::EmptyMap(leaf) => { + out.push(Request::retrieve_data_key( + *leaf.iv(), + leaf.tag().to_vec(), + descriptor.clone(), + leaf.keyset_id(), + )); + } + CipherText::Sequence(items) => { + for item in items { + collect_retrieve_requests(item, descriptor, out); + } + } + CipherText::Map(entries) => { + for (_, value) in entries { + collect_retrieve_requests(value, descriptor, out); + } + } + CipherText::Passthrough(_) => {} + } +} diff --git a/packages/stack-encrypt/src/target/mod.rs b/packages/stack-encrypt/src/target/mod.rs index ba4040f70..d012c0f52 100644 --- a/packages/stack-encrypt/src/target/mod.rs +++ b/packages/stack-encrypt/src/target/mod.rs @@ -1,1397 +1,81 @@ -//! Target-directed encryption: the *output* type decides what gets derived, -//! and the *cipher* decides the async shape. +//! Targets declare operations; the cipher executes them through Vitamin C. //! -//! A stored encrypted value is rarely just a ciphertext — it is a record: the -//! AEAD ciphertext of the plaintext plus zero or more index terms derived from -//! the same plaintext by different primitives. [`EncryptFrom`] puts that -//! record shape in charge: +//! [`EncryptFrom<S>`] describes ciphertext and/or index operations and has an +//! associated context type. It does not receive plaintext or a cipher. The +//! cipher's `encrypt_as` executes that description, returning the existing +//! batched [`Pending`]. [`DecryptInto<P>`] inspects stored output and describes +//! opening it through `P::Decrypt`. //! -//! ```text -//! let term: EqualityTerm = value.encrypt_into_with_context(&keyset, nonempty!("users/email")).await?; -//! let row: EncryptedUser = user.encrypt_into(&keyset).await?; -//! let row: EncryptedUser = user.encrypt_into_with_context(&keyset, user_id).await?; -//! ``` -//! -//! compiles only when the output type declares itself an encrypted form of -//! the value's type, producible by that cipher (a [`KeysetCipher`]: every -//! data key is minted, and every term derived, under one keyset), under that -//! context — and a -//! context is something the output type may already have. A leaf has -//! nothing of its own to authenticate under and takes the caller's, proven -//! non-empty before it arrives ([`NonEmpty`]). A struct encrypted field by -//! field names each field's context itself and needs none, so the second -//! line is the whole call; the third *extends* every field's context with a -//! value only the caller knows — the record's id — so each field is bound -//! to its record as well as its name. See [Which form -//! compiles](self#which-form-compiles). -//! -//! # The pieces -//! -//! * [`EncryptFrom<S, C, Ctx>`] — implemented by an *output* type: "`Self` is -//! an encrypted representation of `S`, producible by a cipher `C`, under a -//! context `Ctx`". The context is a parameter of the *trait* so that an -//! implementation can say which contexts it accepts: the leaves accept -//! only a [`NonEmpty<T>`], and a derived record accepts `()` — its -//! fields' own contexts — and any `NonEmpty<T>`, which extends them. Leaf -//! implementations exist for [`StackCipherText`] (the AEAD ciphertext, via -//! vitaminc's [`Encrypt`]) and for the SEM term types in [`sem`] -//! ([`EqualityTerm`], [`MatchTerm`], [`OreTerm`], [`OpeTerm`]). Composite -//! record types — a struct of leaves, or a struct of records — get theirs from -//! [`#[derive(EncryptFrom)]`](macro@EncryptFrom), which combines the -//! fields' pendings with [`Pending::zip`] / [`Pending::map`] exactly as a -//! hand-written impl would. -//! * [`DecryptInto<P, C, Ctx>`] — the mirror, implemented by the *encrypted* -//! type: "`Self` decrypts to the plaintext `P`". Only ciphertext fields -//! participate — index terms are one-way by construction. -//! [`#[derive(DecryptInto)]`](macro@DecryptInto) on the record emits it. -//! `Self` is the record in both traits — the output of encryption, the -//! input of decryption — because that is the type a downstream crate can -//! implement on; the halves whose `Self` is the plaintext are blanket: -//! * [`EncryptInto`] / [`DecryptFrom`] — call-site sugar, the `Into` to -//! `EncryptFrom` and the `From` to `DecryptInto`, each in two forms: -//! [`encrypt_into(&cipher)`](EncryptInto::encrypt_into) passes `()`, for -//! an output that needs nothing from the caller, and -//! [`encrypt_into_with_context(&cipher, ctx)`](EncryptInto::encrypt_into_with_context) -//! passes a `NonEmpty<T>` — the split of vitaminc's `encrypt` / -//! `encrypt_with_aad`. Never implemented by hand. -//! * [`EncryptTarget`] / [`DecryptTarget`] — implemented by ciphers; their -//! `Output` type decides what a call site gets back. A synchronous cipher -//! returns `Result<T, E>` directly; [`KeysetCipher`] (the encrypt target) -//! and [`StackCipher`] (the decrypt target — a sealed leaf names its own -//! keyset, so opening is not keyset-scoped; a `KeysetCipher` decrypts too, -//! refusing leaves from any other keyset) return a [`Pending`], which does -//! its ZeroKMS I/O when awaited, batched whatever the request count: -//! **one** `generate_keys` call for everything sealed — one keyset by -//! construction, since that is what the handle binds — and **one** -//! `retrieve_keys` call *per keyset* the leaves being opened were sealed -//! under, which is one call unless a client-scoped decrypt spans keysets. -//! -//! # Contexts -//! -//! A context is one value that domain-separates every primitive a field can -//! use: it becomes the AEAD associated data of the ciphertext *and* the PRF -//! context of any index term, so the identifier that keeps `users/email` -//! terms apart from `users/name` terms also authenticates the ciphertext to -//! its column. The vocabulary is vitaminc's — [`IntoAad`], -//! [`IntoPrfContext`](crate::IntoPrfContext) and, for the proof that a value -//! carries caller bytes, [`NonEmpty<T>`] — -//! and this crate adds no trait of its own on top: anything vitaminc encodes -//! as a context is a context here. `&str`, `String`, byte strings, the -//! fixed-width integers, and `Option`s and pairs of those all qualify. -//! -//! The same context reaches ZeroKMS. Every data key a leaf asks for — -//! generated on encrypt, retrieved on decrypt — is requested under the -//! context rendered as the key's **descriptor** ([`Descriptor`]), which -//! ZeroKMS HMACs into the key tag and logs per retrieval. So `users/email` -//! is enforced twice: locally, where the ciphertext fails to open under -//! any other AAD, and at ZeroKMS, where the key fails to re-derive under -//! any other descriptor — and it is the name an audit trail shows. A -//! textual context is its own descriptor, and a composite renders its -//! parts in order: `nonempty!("users/email").with(7u64)` is the descriptor -//! `users/email|7u64`. See [`Descriptor::from_piece`] for the frozen rules, -//! and the [descriptor docs](crate::descriptor) for why a context must be -//! presented in the same shape on both sides. -//! -//! A leaf takes a `NonEmpty<T>` and nothing else. Under an empty context, -//! equal plaintexts in different fields derive identical index terms -//! (cross-field equality leakage), every field shares one ORE/OPE key -//! (values become mutually order-comparable), and ciphertexts transplant -//! between fields. `()` — what `encrypt_into` passes — is the canonical -//! empty context, and vitaminc implements the context traits for it, so a -//! leaf bound on those alone would let `value.encrypt_into(&cipher)` into a -//! term compile and quietly derive under nothing. Bound on `NonEmpty<T>`, -//! that call is a compile error, and `""`, `None`, `Some("")` never reach a -//! leaf either: [`NonEmpty::new`] refuses them once, where the value is -//! built, and [`nonempty!`](crate::nonempty) refuses an empty literal at compile time. An -//! integer is never empty and converts on its own (`42u64.into()`, or just -//! `42u64` to the sugar). A pair is empty only when both halves are, so a -//! proven head extends freely: `nonempty!("users/email").with(record_id)`. +//! # Records and context //! -//! One collision to know about, inherited from vitaminc's integer encoding: -//! an integer's AAD is its untagged little-endian bytes, so `0u64` -//! authenticates the same eight zero bytes as `None::<u64>` (the PAE of an -//! empty list). Index terms do not collide — the PRF encoding is typed — -//! but a ciphertext sealed under `("users/age", 0u64)` opens under -//! `("users/age", None::<u64>)`. Do not mix an integer id and an optional -//! one under the same prefix; cipherstash/vitaminc#315 tracks typing the -//! AAD channel too. -//! -//! # Which form compiles -//! -//! Three record shapes, and the call forms each accepts. Which contexts a -//! type accepts is which [`EncryptFrom`] impls it has; the derive decides -//! what impls exist, and the compiler enforces it at every call site. -//! -//! A record whose field pins a literal context needs nothing from the -//! caller, and a context passed anyway *extends* the literal — it is never -//! silently dropped: -//! -//! ``` -//! use stack_encrypt::target::EncryptInto; -//! use stack_encrypt::{DecryptInto, EncryptFrom, Error, NonEmpty, StackCipher, StackCipherText}; -//! use stack_kms::FakeDataKeySource; -//! -//! #[derive(EncryptFrom, DecryptInto)] -//! #[stash(plaintext = u32)] -//! struct Pinned { -//! #[stash(context = "legacy/age")] -//! c: StackCipherText, -//! } +//! The derives compose each semantic field's declaration. `#[stash(context_field)]` +//! on an identifier of type `T` makes the encryption context `NonEmpty<T>` and +//! stores its inner value. Opening validates that stored identifier; supplying an +//! [`ExpectedContext`] also checks the expected destination before key retrieval. +//! Record envelope names do not add cryptographic map keys. //! -//! # tokio::runtime::Builder::new_current_thread().enable_all().build().unwrap().block_on(async { -//! # let cipher = StackCipher::builder().kms(FakeDataKeySource::new()).init().await?; -//! # let keyset = cipher.default_keyset(); -//! // Sealed under "legacy/age". -//! let p: Pinned = 42u32.encrypt_into(&keyset).await?; -//! assert_eq!(p.decrypt_into(&cipher, ()).await?, 42); +//! Generic targets use [`CallerContext`] (ciphertext and terms) or [`AeadContext`] +//! (ciphertext only). These own Vitamin C's context encodings, preserving their +//! structured descriptor identity. They are constructed from a nonempty context, +//! including a borrowed one. Records that supply their own field contexts use +//! [`DeclaredContext`], whose default leaves those contexts unchanged and whose +//! nonempty form extends them. Custom targets may instead declare `Context = ()`. //! -//! // Sealed under ("legacy/age", tenant): opens there, and nowhere else. -//! let tenant = 7u64; -//! let p: Pinned = 42u32.encrypt_into_with_context(&keyset, tenant).await?; -//! let p2: Pinned = 42u32.encrypt_into_with_context(&keyset, tenant).await?; -//! assert_eq!(p.decrypt_into(&cipher, NonEmpty::from(tenant)).await?, 42); -//! // The fake key source ignores descriptors, so the AEAD is what refuses -//! // here; ZeroKMS refuses the key retrieval itself first (`Error::Kms`). -//! assert!(matches!(p2.decrypt_into(&cipher, NonEmpty::from(8u64)).await, Err(Error::Aead))); -//! # Ok::<(), stack_encrypt::Error>(()) -//! # }).unwrap(); -//! ``` +//! # Output adapters //! -//! A record whose fields have no context of their own has only the -//! caller's, so it must be given: the context-free form does not compile. +//! An adapter selects a core operation and converts only its completed output. +//! A [`transcode::Reader`] moves native ciphertext, terms, metadata, and sealed +//! structural markers into a target visitor. It creates no additional generic +//! tree or serialization buffer. The cipher's native tree, pending requests, and +//! the final target's own storage remain normal allocations. //! //! ``` +//! use stack_encrypt::{EncryptFrom, Encryption}; +//! use stack_encrypt::target::{self, CallerContext}; //! use stack_encrypt::sem::EqualityTerm; -//! use stack_encrypt::target::EncryptInto; -//! use stack_encrypt::{nonempty, DecryptInto, EncryptFrom, StackCipher, StackCipherText}; -//! use stack_kms::FakeDataKeySource; -//! -//! #[derive(EncryptFrom, DecryptInto)] -//! #[stash(plaintext = u32)] -//! struct Foo { -//! c: StackCipherText, -//! hm: EqualityTerm, -//! } -//! -//! # tokio::runtime::Builder::new_current_thread().enable_all().build().unwrap().block_on(async { -//! # let cipher = StackCipher::builder().kms(FakeDataKeySource::new()).init().await?; -//! # let keyset = cipher.default_keyset(); -//! let f: Foo = 42u32.encrypt_into_with_context(&keyset, nonempty!("users/age")).await?; -//! assert_eq!(f.decrypt_into(&cipher, nonempty!("users/age")).await?, 42); -//! # Ok::<(), stack_encrypt::Error>(()) -//! # }).unwrap(); -//! ``` -//! -//! ```compile_fail,E0277 -//! # use stack_encrypt::sem::EqualityTerm; -//! # use stack_encrypt::target::EncryptInto; -//! # use stack_encrypt::{DecryptInto, EncryptFrom, KeysetCipher, StackCipherText}; -//! # use stack_kms::FakeDataKeySource; -//! # #[derive(EncryptFrom, DecryptInto)] -//! # #[stash(plaintext = u32)] -//! # struct Foo { c: StackCipherText, hm: EqualityTerm } -//! async fn encrypt(keyset: &KeysetCipher<'_, FakeDataKeySource>) { -//! // "`StackCipherText` is not an encrypted form of `u32` under a `()` -//! // context": the leaf that needs a context is named. -//! let _: Foo = 42u32.encrypt_into(keyset).await.unwrap(); -//! } -//! ``` -//! -//! A struct encrypted field by field infers a context for every field, so -//! it takes either form: `encrypt_into` seals each field under its own -//! context, `encrypt_into_with_context` under that context extended with -//! the caller's. See [the next section](self#records-and-structs-deriveencryptfrom) -//! for the worked example. -//! # Build synchronously, settle once -//! -//! `encrypt_from` does **no I/O**. It validates, derives every local term, -//! drives vitaminc's [`Encrypt`] to a pending ciphertext tree, and returns a -//! [`Pending`] carrying the ZeroKMS *requests* the value needs. Combining -//! pendings ([`Pending::zip`], [`Pending::all`]) merges their requests, so -//! however large the assembly — one field, one record, a whole column — the -//! `.await` at the end settles it with **one** ZeroKMS round-trip per request -//! kind: -//! -//! ``` -//! use stack_encrypt::target::{DecryptInto, EncryptInto}; -//! use stack_encrypt::{nonempty, StackCipher, StackCipherText}; -//! use stack_kms::FakeDataKeySource; -//! -//! # tokio::runtime::Builder::new_current_thread().enable_all().build().unwrap().block_on(async { -//! let cipher = StackCipher::builder() -//! .kms(FakeDataKeySource::new()) -//! .init() -//! .await -//! .unwrap(); -//! let keyset = cipher.default_keyset(); -//! -//! let ages: Vec<u32> = vec![29, 34, 41]; -//! -//! // A column of independently sealed ciphertexts: ONE generate_keys call. -//! let sealed: Vec<StackCipherText> = ages -//! .encrypt_into_with_context(&keyset, nonempty!("users/age")) -//! .await?; //! -//! // And back: ONE retrieve_keys call for the whole column. -//! let roundtrip: Vec<u32> = sealed.decrypt_into(&cipher, nonempty!("users/age")).await?; -//! assert_eq!(roundtrip, ages); -//! # Ok::<(), stack_encrypt::Error>(()) -//! # }).unwrap(); -//! ``` -//! -//! Batching therefore comes from the **source shape**, exactly as it does for -//! vitaminc's `Encrypt`: pass the collection, not the element. A caller who -//! awaits per element pays per element. -//! -//! Note the two distinct shapes: `Vec<u32> → StackCipherText` (via `Encrypt`) -//! is *one* record whose value is a list — one tree, elements sealed under -//! sequence-derived AAD. `Vec<u32> → Vec<StackCipherText>` (above) is a -//! *column* of independent records sharing one context. Both are one ZeroKMS -//! call; they differ in what the ciphertext *is*. -//! -//! # Extending with your own SEM type -//! -//! The set of term types is open. Any crate can define one: implement -//! [`EncryptFrom`] for it against [`KeysetCipher`], build the result with -//! [`Pending::ready`] (local derivation) or [`Pending::request`] (derivation -//! needing ZeroKMS responses). Every built-in term type is implemented with -//! **exactly** this recipe — they use no privileged access — so [`sem`] -//! doubles as worked examples. -//! -//! The one thing every searchable-encryption scheme needs is a keyed, -//! deterministic derivation — the keyset's PRF ([`KeysetCipher::prf`], keyed -//! by that keyset's index key). Under the local HMAC backend the PRF -//! completes synchronously (`into_result`), so the pending carries no -//! requests; a future 2-party ZeroKMS PRF backend moves the same visitor -//! behind a PRF request instead, joining the record's one batched call. -//! -//! ``` -//! use stack_encrypt::target::{DecryptField, Decryptable, EncryptFrom, Pending}; -//! use stack_encrypt::{Error, IntoPrfContext, KeysetCipher, NonEmpty, StackCipher}; -//! use vitaminc_prf::{PrfContext, PrfValue, PrfVisitor, PrfVisitorError}; -//! -//! /// A third-party term type: one PRF block under its own domain. -//! pub struct MyTerm([u8; 32]); -//! -//! struct MyVisitor; -//! -//! impl<P: Send + 'static> PrfVisitor<[u8; 32], P> for MyVisitor { -//! type Value = MyTerm; -//! -//! fn visit_block(self, block: [u8; 32]) -> Result<Self::Value, PrfVisitorError> { -//! Ok(MyTerm(block)) -//! } -//! } -//! -//! // A leaf owns the context policy, and states it in the impl header: it -//! // exists only for a `NonEmpty<T>`, so `()` — and any unproven value — is -//! // a compile error (nothing above a leaf checks, since a column of rows -//! // has no context of its own). The lifetime is the context's own, as in -//! // the `IntoPrfContext<'c>` it implements; `'k` is the keyset handle's -//! // borrow of its `StackCipher`. -//! impl<'c, 'k, S, K, T> EncryptFrom<S, KeysetCipher<'k, K>, NonEmpty<T>> for MyTerm -//! where -//! S: PrfValue + Clone, -//! T: IntoPrfContext<'c>, -//! { -//! fn encrypt_from<'a>( -//! source: &'a S, -//! cipher: &'a KeysetCipher<'k, K>, -//! context: NonEmpty<T>, -//! ) -> Pending<'a, Self, K> -//! where -//! Self: 'a, -//! { -//! // The type is the proof; there is nothing left to check. Encode, -//! // then domain-separate under your own label so your terms can -//! // never collide with another scheme's under the same context. -//! let context = context.into_prf_context().into_owned(); -//! let context = PrfContext::pae(&[b"my-crate/my-term/v1".as_slice(), context.as_bytes()]); -//! let term = source -//! .clone() -//! .prf_visit_with_context(cipher.prf(), context, MyVisitor) -//! .into_result() -//! .map_err(|e| Error::Other(Box::new(e))); -//! Pending::ready(cipher, term) +//! struct StoredEquality([u8; 32]); +//! impl<S> EncryptFrom<S> for StoredEquality +//! where EqualityTerm: EncryptFrom<S, Context = CallerContext> { +//! type Context = CallerContext; +//! fn encryption<'s,K:'static>(context:Self::Context)->Encryption<'s,S,Self,K> +//! where S:'s { +//! EqualityTerm::encryption(context).map(|term| Self(term.into_bytes())) //! } //! } -//! -//! // A term is one-way. Saying so is what lets `#[derive(DecryptInto)]` -//! // pass over a `MyTerm` field and open the ciphertext beside it. The -//! // decrypt side is over `StackCipher` — the `KeysetCipher` form is the -//! // blanket impl in `target`, as for every `DecryptInto` / `DecryptField`. -//! impl Decryptable for MyTerm { -//! const DECRYPTABLE: bool = false; -//! } -//! -//! impl<P, K, Ctx> DecryptField<P, StackCipher<K>, Ctx> for MyTerm { -//! fn decrypt_field<'a>(self, _: &'a StackCipher<K>, _: Ctx) -> Option<Pending<'a, P, K>> -//! where -//! Self: 'a, -//! P: 'a, -//! { -//! None -//! } -//! } -//! ``` -//! -//! A third-party *ciphertext* type implements `DecryptInto` as well — over -//! `StackCipher<K>`, like `DecryptField` — sets `DECRYPTABLE` to `true`, and -//! has `decrypt_field` return `Some(self.decrypt_into(cipher, context))`. -//! -//! A scheme needing state the cipher does not carry defines its own -//! capability trait and implements it for [`KeysetCipher`] (a local trait on -//! a foreign type is orphan-rule-legal) using its public accessors -//! ([`keyset_id`](KeysetCipher::keyset_id), [`prf`](KeysetCipher::prf), -//! [`kms`](KeysetCipher::kms)). -//! -//! **Not yet here:** the `ore_rs` *block* ORE scheme (`OreBlock256`) that EQL -//! and `cipherstash-client` use. The ORE/OPE terms in [`sem`] are CLLW, a -//! different construction; the block scheme lands as a third-party term type -//! in `eql-bindings`, built with exactly the recipe above. -//! -//! # Records and structs: `#[derive(EncryptFrom)]` -//! -//! A struct of leaves derived from one value is a *record*; a struct whose -//! fields are each derived from one field of a plaintext struct, under a -//! context of its own, is that plaintext encrypted *field by field*. Both -//! are the same derive, and both settle as one batched call. The field-by- -//! field form names its prefix once and its fields' contexts follow — -//! `#[stash(struct = User, context = "users")]` derives `age` from -//! `user.age` under `"users/age"` — and are overridden per field where that -//! is not wanted. A context the caller passes *extends* those: under -//! `encrypt_into_with_context(&cipher, 42u64)` the same field is derived -//! under `("users/age", 42u64)`, binding it to its record as well as its -//! name. Which is what a query site derives its probe under, too: -//! -//! ``` -//! use stack_encrypt::sem::{EqualityTerm, OreTerm}; -//! use stack_encrypt::target::{DecryptFrom, EncryptInto}; -//! use stack_encrypt::{nonempty, DecryptInto, EncryptFrom, StackCipher, StackCipherText}; -//! use stack_kms::FakeDataKeySource; -//! -//! /// An encrypted `u32`, queryable by equality and range. -//! #[derive(EncryptFrom, DecryptInto)] -//! #[stash(plaintext = u32)] -//! struct EncryptedAge { -//! c: StackCipherText, -//! hm: EqualityTerm, -//! ob: OreTerm<u32>, -//! } -//! -//! #[derive(Debug, PartialEq)] -//! struct User { -//! age: u32, -//! email: String, -//! } -//! -//! /// `User` field by field: each field from the plaintext field of its own -//! /// name, under the context `"users/<field>"` — no attribute on the fields. -//! #[derive(EncryptFrom, DecryptInto)] -//! #[stash(struct = User, context = "users")] -//! struct EncryptedUser { -//! age: EncryptedAge, -//! email: StackCipherText, -//! } -//! -//! # tokio::runtime::Builder::new_current_thread().enable_all().build().unwrap().block_on(async { -//! let cipher = StackCipher::builder() -//! .kms(FakeDataKeySource::new()) -//! .init() -//! .await -//! .unwrap(); -//! let keyset = cipher.default_keyset(); -//! -//! let user = User { age: 42, email: "alice@example.com".into() }; -//! // Every field names its own context, so nothing is needed from the -//! // caller: the context-free forms are the whole call. -//! let row: EncryptedUser = user.encrypt_into(&keyset).await?; -//! // A query site derives the same term under the column's context. -//! let probe: EqualityTerm = 42u32 -//! .encrypt_into_with_context(&keyset, nonempty!("users/age")) -//! .await?; -//! assert_eq!(row.age.hm, probe); -//! let recovered = User::decrypt_from(row, &cipher).await?; -//! assert_eq!(recovered, user); -//! -//! // Or the caller extends every field's context with the record's id: the -//! // same field is now under `("users/age", 7u64)`, and opens only there. -//! let row: EncryptedUser = user.encrypt_into_with_context(&keyset, 7u64).await?; -//! let probe: EqualityTerm = 42u32 -//! .encrypt_into_with_context(&keyset, nonempty!("users/age").with(7u64)) -//! .await?; -//! assert_eq!(row.age.hm, probe); -//! let recovered = User::decrypt_from_with_context(row, &cipher, 7u64).await?; -//! assert_eq!(recovered, user); -//! # Ok::<(), stack_encrypt::Error>(()) -//! # }).unwrap(); //! ``` //! -//! The attributes, and what the derive commits to, are documented on -//! [`EncryptFrom`](macro@EncryptFrom). -//! -//! # Plaintext fan-out -//! -//! One source value reaches every field implementation, so encrypting a -//! record clones the plaintext once per derived field. Term clones are -//! consumed during the synchronous build; the ciphertext's copy lives inside -//! the pending (wrapped in `Protected`, wiped as it seals). This widens the -//! plaintext custody window by design; keep it in mind for high-sensitivity -//! values. -//! -//! [`sem`]: crate::sem -//! [`EqualityTerm`]: crate::sem::EqualityTerm -//! [`MatchTerm`]: crate::sem::MatchTerm -//! [`OreTerm`]: crate::sem::OreTerm -//! [`OpeTerm`]: crate::sem::OpeTerm - -use stack_kms::MaybeSend; -use vitaminc_aead::{CipherText, Decrypt, Encrypt, IntoAad}; -use vitaminc_protected::NonEmpty; - -use crate::cipher::{bind_keys, PendingStackCipherText, StackDecipher}; -use crate::{Descriptor, Error, KeysetCipher, StackCipher, StackCipherText}; - +//! Ciphertext operations require Vitamin C's `Encrypt`/`Decrypt` on the plaintext; +//! there is no Serde fallback. Terms require only their PRF or ordering capability. +//! New cryptographic operations belong in core; output adapters cannot install an +//! execution callback. The separate cipher-directed API remains public. +//! +//! # Collections and authentication +//! +//! `Vec<Target>` describes independently encrypted rows under the same context. +//! Encrypting a plaintext `Vec<T>` into `StackCipherText` instead follows Vitamin +//! C's native sequence model, including its authenticated empty marker. The same +//! distinction applies to `Option<Target>` versus a native encrypted option. +//! Readers preserve marker ciphertext and map keys; changing a key changes the +//! authenticated context when opening. Passthrough metadata and query terms are +//! not presented as AEAD-authenticated values. +//! +//! # Migration from the unpublished execution traits +//! +//! Implement `encryption`/`decryption`, returning core-owned descriptions, in place +//! of `encrypt_from`/`decrypt_into` methods that took a cipher. The derives do this +//! automatically. Call `keyset.encrypt_as(&value, context)` and +//! `cipher.decrypt_as(record, context)`, or import the blanket [`EncryptInto`] and +//! [`DecryptFrom`] convenience traits for the existing source-side call syntax. +//! `EncryptTarget` and `DecryptTarget` are no longer extension points. +pub(crate) mod core; +mod operations; mod pending; mod request; +pub mod transcode; +pub(crate) use self::core::{decipher_pending, seal_pending}; +pub use operations::*; pub use pending::{CipherScope, Pending, PendingFuture}; pub use request::{Request, Responses}; pub use stack_encrypt_derive::{DecryptInto, EncryptFrom}; - -// ============================================================================= -// Cipher-owned output types -// ============================================================================= - -/// Implemented by ciphers: decides what an [`EncryptFrom`] implementation -/// hands back. A cipher that does no I/O sets -/// `Output<'a, T> = Result<T, Self::Error>` — no future, no `.await`. -/// [`KeysetCipher`] sets `Output<'a, T> = Pending<'a, T, K>`, a request -/// carrier that talks to ZeroKMS when awaited. -/// -/// This mirrors `Cipher::Ok` and `Prf::Ok<T>`: the async shape belongs to the -/// implementation, never to the trait. -pub trait EncryptTarget { - /// The error the cipher's outputs resolve to. - type Error; - /// What `encrypt_from` returns: the finished value, or a deferred handle - /// to it. - type Output<'a, T> - where - Self: 'a, - T: 'a; -} - -/// The decrypt-side mirror of [`EncryptTarget`]. -pub trait DecryptTarget { - /// The error the cipher's outputs resolve to. - type Error; - /// What `decrypt_from` returns: the recovered value, or a deferred handle - /// to it. - type Output<'a, T> - where - Self: 'a, - T: 'a; -} - -/// Encrypting binds to a keyset: data keys are minted under one, and index -/// terms are derived under one's index key. So the encrypt target is the -/// [`KeysetCipher`], not the client-scoped [`StackCipher`]. -impl<K> EncryptTarget for KeysetCipher<'_, K> { - type Error = Error; - type Output<'a, T> - = Pending<'a, T, K> - where - Self: 'a, - T: 'a; -} - -/// Decrypting is not keyset-scoped — a sealed leaf carries the id of the -/// keyset it was sealed under — so the client-scoped [`StackCipher`] is a -/// decrypt target, opening leaves from any keyset in one batch. -impl<K> DecryptTarget for StackCipher<K> { - type Error = Error; - type Output<'a, T> - = Pending<'a, T, K> - where - Self: 'a, - T: 'a; -} - -/// A [`KeysetCipher`] decrypts too, constrained: every [`DecryptInto`] and -/// [`DecryptField`] implementation over [`StackCipher`] applies through it -/// (the blanket impls below), and a leaf from any other keyset is -/// [`Error::ForeignKeyset`] before any key is retrieved. -impl<K> DecryptTarget for KeysetCipher<'_, K> { - type Error = Error; - type Output<'a, T> - = Pending<'a, T, K> - where - Self: 'a, - T: 'a; -} - -/// The constrained form of every decrypt: whatever opens through the -/// [`StackCipher`] opens through a [`KeysetCipher`] scoped to the leaves' -/// keyset, and refuses leaves from any other. Implement `DecryptInto` over -/// `StackCipher<K>`; this impl supplies the `KeysetCipher` form. -impl<'k, P, K, Ctx, X> DecryptInto<P, KeysetCipher<'k, K>, Ctx> for X -where - X: DecryptInto<P, StackCipher<K>, Ctx>, -{ - fn decrypt_into<'a>(self, cipher: &'a KeysetCipher<'k, K>, context: Ctx) -> Pending<'a, P, K> - where - Self: 'a, - P: 'a, - { - let inner: &'a StackCipher<K> = cipher.cipher(); - X::decrypt_into(self, inner, context).scoped_to(cipher.keyset_id()) - } -} - -/// See the [`DecryptInto`] blanket above. -impl<'k, P, K, Ctx, X> DecryptField<P, KeysetCipher<'k, K>, Ctx> for X -where - X: DecryptField<P, StackCipher<K>, Ctx>, -{ - fn decrypt_field<'a>( - self, - cipher: &'a KeysetCipher<'k, K>, - context: Ctx, - ) -> Option<Pending<'a, P, K>> - where - Self: 'a, - P: 'a, - { - let inner: &'a StackCipher<K> = cipher.cipher(); - X::decrypt_field(self, inner, context).map(|pending| pending.scoped_to(cipher.keyset_id())) - } -} - -// ============================================================================= -// The traits -// ============================================================================= - -/// `Self` is an encrypted representation of `S`, producible by a cipher `C`. -/// -/// Implemented on the *output* type — a leaf primitive output -/// ([`StackCipherText`], a SEM term, ...) or a composite record of them. Open -/// for extension: see the -/// [module docs](self#extending-with-your-own-sem-type). -/// -/// There is no associated error type: errors belong to the cipher -/// ([`EncryptTarget::Error`]), and implementations with failure modes of -/// their own use [`Error::Term`] or [`Error::Other`]. -/// -/// # The context parameter -/// -/// `Ctx` is a parameter of the trait, not of the method, so that each -/// implementation can say which contexts it accepts. A leaf is implemented -/// for [`NonEmpty<T>`] alone — it has nothing else to authenticate under, -/// and `()` is the empty context it must never derive under (see the -/// [module docs](self#contexts)). A derived record is implemented twice: -/// for `()`, deriving each field under the context the field carries itself -/// (a `context = ".."` literal, or the one a `struct = ..` derive infers), -/// and for `NonEmpty<T>`, deriving each field under that context extended -/// with the caller's (`("users/age", id)`) — or, for a field with no -/// context of its own, under the caller's as it is. No record accepts a -/// context it then discards. The call site then -/// gets one of two answers from the compiler: -/// [`encrypt_into(&cipher)`](EncryptInto::encrypt_into) resolves against -/// `EncryptFrom<S, C, ()>`, so it compiles for exactly the outputs whose -/// every leaf has a context of its own; anything else takes -/// [`encrypt_into_with_context`](EncryptInto::encrypt_into_with_context). -/// -/// The trait itself places no bound on `Ctx`; an implementation that uses the -/// context bounds it as `NonEmpty<T>` with `T: IntoAad<'c> + IntoPrfContext<'c>` -/// and the context's own lifetime as an impl parameter (see the -/// [module docs](self#extending-with-your-own-sem-type)). -#[diagnostic::on_unimplemented( - message = "`{Self}` is not an encrypted form of `{S}` under a `{Ctx}` context", - label = "not `EncryptFrom<{S}, _, {Ctx}>`", - note = "a leaf — a ciphertext or an index term — exists only under a `NonEmpty<_>` context: \ - `encrypt_into` passes `()`, so use `encrypt_into_with_context(&cipher, context)`, or \ - give the field a `context = \"..\"` of its own", - note = "a context is anything vitaminc encodes (`&str`, `String`, bytes, integers, `Option`s \ - and pairs of those), proven non-empty: `nonempty!(\"users/email\")` for a literal, \ - `NonEmpty::new(value)?` for a runtime value, a bare integer for an id" -)] -pub trait EncryptFrom<S, C: EncryptTarget, Ctx>: Sized { - /// Whether encrypting from `S` requests ZeroKMS data keys under the - /// caller's context (or an extension of it), so that the context must - /// render within [`Descriptor::MAX_LEN`]. A ciphertext leaf does; a - /// term derives locally and never renders a descriptor, so it says - /// `false` and a column of terms is not held to the limit. The default - /// is the safe over-approximation: a type that does not say is treated - /// as keyed, and a derived record is keyed if any field might be (the - /// derive does not compute this per field, so a record of terms alone - /// is checked like any other record). - /// - /// Read by the column implementations, which check the context once - /// before walking their elements ([`ElementContext`]). - const KEYED: bool = true; - - /// Encrypt `source` into `Self` under `context`, returning the cipher's - /// [`Output`](EncryptTarget::Output). No I/O happens here; work needing - /// ZeroKMS is carried as requests and settles when the output is awaited. - /// - /// Borrows the source because a composite record hands the same source to - /// several field implementations; each takes what it needs (typically one - /// clone). - fn encrypt_from<'a>(source: &'a S, cipher: &'a C, context: Ctx) -> C::Output<'a, Self> - where - Self: 'a; -} - -/// `Self` is an encrypted representation that decrypts to `P` by a cipher -/// `C` — the mirror of [`EncryptFrom`], implemented on the *encrypted* type. -/// -/// Both traits put `Self` on the type that is local to the crate defining -/// the record: it is the *output* of encryption (`EncryptFrom`) and the -/// *input* of decryption (`DecryptInto`). One encrypted type may decrypt to -/// several plaintext types (`impl DecryptInto<u32, _>` and -/// `impl DecryptInto<u64, _>` coexist), and several encrypted types may -/// decrypt to the same plaintext — each owns its own opening. -/// -/// Takes `self` by value: decryption consumes the ciphertext, and index -/// terms (which have no plaintext to recover) simply do not participate. -/// -/// `Ctx` is a trait parameter for the reason it is on [`EncryptFrom`]: the -/// leaves accept only a [`NonEmpty<T>`], so an encrypted type that needs -/// no context from the caller is exactly one that implements -/// `DecryptInto<P, C, ()>` — what [`DecryptFrom::decrypt_from`] asks for — -/// and a derived record opened under `NonEmpty<T>` extends its fields' -/// contexts with it, exactly as it did when encrypting. -/// -/// A record whose ciphertext field carries a `context = ".."` literal and -/// whose other fields are terms opens under `()` *and* under any -/// `NonEmpty<T>`, but not interchangeably. The terms open nothing, so a -/// context reaches them and is checked for nothing; the ciphertext -/// authenticates under its literal extended with the caller's context -/// exactly as it was sealed — `()` leaves the literal alone, a `NonEmpty<T>` -/// extends it. A record sealed with `encrypt_into` opens with -/// `decrypt_from` and one sealed under an extension opens only under the -/// same extension; a wrong caller context is the refusal it is against a -/// leaf: [`Error::Kms`], ZeroKMS declining the key retrieval under the -/// other descriptor before the AEAD runs, or [`Error::Aead`] from a key -/// source that ignores descriptors. What the caller's context cannot be is -/// something that is not a context at all: -/// -/// ```compile_fail,E0277 -/// use stack_encrypt::sem::EqualityTerm; -/// use stack_encrypt::{DecryptInto, EncryptFrom, StackCipher, StackCipherText}; -/// use stack_kms::FakeDataKeySource; -/// -/// #[derive(EncryptFrom, DecryptInto)] -/// #[stash(plaintext = u32)] -/// struct Rec { -/// #[stash(context = "rec/c")] -/// c: StackCipherText, -/// hm: EqualityTerm, -/// } -/// -/// async fn decrypt(cipher: &StackCipher<FakeDataKeySource>, rec: Rec) { -/// // `true` is neither `()` nor a `NonEmpty<_>`: a compile error, not a -/// // value the term fields quietly swallow. -/// let _: u32 = rec.decrypt_into(cipher, true).await.unwrap(); -/// } -/// ``` -#[diagnostic::on_unimplemented( - message = "`{Self}` does not decrypt to `{P}` under a `{Ctx}` context", - label = "not `DecryptInto<{P}, _, {Ctx}>`", - note = "a leaf decrypts only under a `NonEmpty<_>` context — the one it was encrypted under \ - (`decrypt_into(&cipher, context)` / `decrypt_from_with_context`); an output whose \ - fields carry their own opens under `()` (`decrypt_from`) as well" -)] -pub trait DecryptInto<P, C: DecryptTarget, Ctx>: Sized { - /// Decrypt `self` into `P`, authenticating against `context` — which - /// must match the context the value was encrypted under. - fn decrypt_into<'a>(self, cipher: &'a C, context: Ctx) -> C::Output<'a, P> - where - Self: 'a, - P: 'a; -} - -/// Call-site sugar: `value.encrypt_into(&cipher)` and -/// `value.encrypt_into_with_context(&cipher, context)`. -/// -/// The `Into` to [`EncryptFrom`]'s `From` — blanket-implemented for every -/// type, never implemented by hand. The target type is usually inferred from -/// the binding. A leaf, or a record that hands the caller's context to one, -/// exists only under a [`NonEmpty<T>`] and takes the second form; a record -/// whose fields name their own contexts takes either — the first as it is, -/// the second with every field's context extended by the caller's. The -/// split is vitaminc's `encrypt` / `encrypt_with_aad`, decided by the type. -/// -/// ``` -/// use stack_encrypt::sem::EqualityTerm; -/// use stack_encrypt::target::EncryptInto; -/// use stack_encrypt::{nonempty, NonEmpty, StackCipher}; -/// use stack_kms::FakeDataKeySource; -/// -/// # tokio::runtime::Builder::new_current_thread().enable_all().build().unwrap().block_on(async { -/// let cipher = StackCipher::builder() -/// .kms(FakeDataKeySource::new()) -/// .init() -/// .await -/// .unwrap(); -/// let keyset = cipher.default_keyset(); -/// -/// // A literal, checked at compile time. -/// let term: EqualityTerm = "alice" -/// .encrypt_into_with_context(&keyset, nonempty!("users/email")) -/// .await?; -/// // A runtime value, checked once where it is built. -/// let column = String::from("users/email"); -/// let same: EqualityTerm = "alice" -/// .encrypt_into_with_context(&keyset, NonEmpty::new(column)?) -/// .await?; -/// assert_eq!(term, same); -/// # Ok::<(), Box<dyn std::error::Error>>(()) -/// # }).unwrap(); -/// ``` -pub trait EncryptInto { - /// Encrypt `self` into a `T` that needs no context from the caller — - /// a record whose fields carry their own. See [`EncryptFrom`]. - /// - /// Passes `()`, so this exists only for `T: EncryptFrom<Self, C, ()>`: - /// against a leaf the compiler says to use - /// [`encrypt_into_with_context`](Self::encrypt_into_with_context). - fn encrypt_into<'a, T, C>(&'a self, cipher: &'a C) -> C::Output<'a, T> - where - C: EncryptTarget, - T: EncryptFrom<Self, C, ()> + 'a, - Self: Sized; - - /// Encrypt `self` into `T` under `context`. See [`EncryptFrom`]. - /// - /// The context is anything that converts into a [`NonEmpty<T>`]: a - /// `NonEmpty` itself — [`nonempty!`](crate::nonempty) for a literal, - /// [`NonEmpty::new`] for a runtime value — or a bare integer, which is - /// never empty. Passing `()` here is a compile error, with - /// [`encrypt_into`](Self::encrypt_into) as the answer. - fn encrypt_into_with_context<'a, T, C, N, Ctx>( - &'a self, - cipher: &'a C, - context: Ctx, - ) -> C::Output<'a, T> - where - C: EncryptTarget, - T: EncryptFrom<Self, C, NonEmpty<N>> + 'a, - Ctx: Into<NonEmpty<N>>, - Self: Sized; -} - -impl<S> EncryptInto for S { - fn encrypt_into<'a, T, C>(&'a self, cipher: &'a C) -> C::Output<'a, T> - where - C: EncryptTarget, - T: EncryptFrom<Self, C, ()> + 'a, - { - T::encrypt_from(self, cipher, ()) - } - - fn encrypt_into_with_context<'a, T, C, N, Ctx>( - &'a self, - cipher: &'a C, - context: Ctx, - ) -> C::Output<'a, T> - where - C: EncryptTarget, - T: EncryptFrom<Self, C, NonEmpty<N>> + 'a, - Ctx: Into<NonEmpty<N>>, - { - T::encrypt_from(self, cipher, context.into()) - } -} - -/// Call-site sugar: `Plaintext::decrypt_from(encrypted, &cipher)` and -/// `Plaintext::decrypt_from_with_context(encrypted, &cipher, context)` — the -/// `From` to [`DecryptInto`]'s `Into`, and the decrypt-side [`EncryptInto`], -/// with the same two forms for the same reason. Blanket-implemented for every -/// plaintext; never implemented by hand. -/// -/// (The implemented trait, [`DecryptInto`], always takes a context: -/// `encrypted.decrypt_into(&cipher, nonempty!("users/age"))` is the -/// method-call form for a value that needs one.) -pub trait DecryptFrom: Sized { - /// Decrypt `source` — an encrypted type that needs no context from the - /// caller — into `Self`. See [`DecryptInto`]. - fn decrypt_from<'a, S, C>(source: S, cipher: &'a C) -> C::Output<'a, Self> - where - C: DecryptTarget, - S: DecryptInto<Self, C, ()> + 'a, - Self: 'a; - - /// Decrypt `source` into `Self`, authenticating against `context`. See - /// [`DecryptInto`]. - /// - /// The context is anything that converts into a [`NonEmpty<T>`], as for - /// [`encrypt_into_with_context`](EncryptInto::encrypt_into_with_context), - /// and must be the one the value was encrypted under. - fn decrypt_from_with_context<'a, S, C, N, Ctx>( - source: S, - cipher: &'a C, - context: Ctx, - ) -> C::Output<'a, Self> - where - C: DecryptTarget, - S: DecryptInto<Self, C, NonEmpty<N>> + 'a, - Ctx: Into<NonEmpty<N>>, - Self: 'a; -} - -impl<P> DecryptFrom for P { - fn decrypt_from<'a, S, C>(source: S, cipher: &'a C) -> C::Output<'a, Self> - where - C: DecryptTarget, - S: DecryptInto<Self, C, ()> + 'a, - Self: 'a, - { - source.decrypt_into(cipher, ()) - } - - fn decrypt_from_with_context<'a, S, C, N, Ctx>( - source: S, - cipher: &'a C, - context: Ctx, - ) -> C::Output<'a, Self> - where - C: DecryptTarget, - S: DecryptInto<Self, C, NonEmpty<N>> + 'a, - Ctx: Into<NonEmpty<N>>, - Self: 'a, - { - source.decrypt_into(cipher, context.into()) - } -} - -/// Whether a type is a ciphertext that decryption opens, or an index term -/// that it passes over. -/// -/// Every type that can be a field of a derived record implements this — -/// it is what lets `#[derive(DecryptInto)]` find the ciphertext field on its -/// own, with no attribute: the derive counts the fields whose -/// [`DECRYPTABLE`](Self::DECRYPTABLE) is `true` and requires exactly one -/// (per plaintext field, for a `struct = ..` derive). `#[derive(EncryptFrom)]` emits it for -/// a record — a record is decryptable if any of its fields is, or outright -/// when `#[stash(decrypt)]` names the opened fields — and the -/// built-in leaves implement it by hand: [`StackCipherText`] is, the -/// [`sem`](crate::sem) terms are not. -/// -/// A third-party leaf implements it alongside [`EncryptFrom`], together -/// with [`DecryptField`]; see the -/// [module docs](self#extending-with-your-own-sem-type). -/// -/// For a *generic* record the exactly-one count cannot be checked at the -/// definition (a `const _` item cannot name the record's generic -/// parameters), so the derive defers it to an inline `const` evaluated per -/// instantiation: the record compiles where it is defined and the error -/// fires at the first *use* that is actually codegenned — possibly in a -/// downstream crate. The message is the same one a concrete record gets at -/// its definition: -/// -/// ```compile_fail -/// use stack_encrypt::target::Pending; -/// use stack_encrypt::{DecryptInto, StackCipher, StackCipherText}; -/// use stack_kms::FakeDataKeySource; -/// -/// #[derive(DecryptInto)] -/// struct Doubled<T> { -/// a: StackCipherText, -/// b: StackCipherText, -/// #[stash(default)] -/// tag: T, -/// } -/// -/// // Compiles fine: the two-ciphertext mistake is not yet instantiated. -/// fn open<'a>( -/// cipher: &'a StackCipher<FakeDataKeySource>, -/// doubled: Doubled<u8>, -/// ) -> Pending<'a, u32, FakeDataKeySource> { -/// doubled.decrypt_into(cipher, "d") -/// } -/// -/// // The first reachable instantiation trips the deferred check: -/// // "`Doubled` has several decryptable fields: mark the one decryption -/// // opens `#[stash(decrypt)]`". -/// let _ = open -/// as for<'a> fn( -/// &'a StackCipher<FakeDataKeySource>, -/// Doubled<u8>, -/// ) -> Pending<'a, u32, FakeDataKeySource>; -/// ``` -pub trait Decryptable { - /// `true` if decryption opens a value of this type, `false` if it is a - /// one-way term with no plaintext to recover. - const DECRYPTABLE: bool; -} - -/// Decryption of one field of a derived record, which either opens the field -/// (`Some`) or passes over it (`None`, for an index term). -/// -/// The derive calls this on every candidate field and takes the one `Some`; -/// [`Decryptable`] has already established, at compile time, that there is -/// exactly one. Implemented alongside `Decryptable`: decryptable types wrap -/// their [`DecryptInto`], terms return `None` for every `P`. (Not a -/// supertrait relationship: `#[derive(DecryptInto)]` emits this for every -/// record, and a record that only decrypts — no `EncryptFrom` derive to -/// emit its `Decryptable` — must still be a field of a record in the explicit -/// mode.) -#[diagnostic::on_unimplemented( - message = "`{Self}` cannot be a field of an automatically decrypted record", - label = "no `DecryptField<{P}, ..>` implementation", - note = "a term-only bundle — `#[derive(EncryptFrom)]` alone, nothing to open — has no \ - `DecryptField`: mark the outer record's real ciphertext `#[stash(decrypt)]` so only \ - the marked fields are considered", - note = "a hand-written term type implements `DecryptField` (returning `None`) alongside \ - `Decryptable`; a hand-written ciphertext type wraps its `DecryptInto`" -)] -pub trait DecryptField<P, C: DecryptTarget, Ctx> { - /// [`DecryptInto::decrypt_into`] if `Self` is decryptable, `None` if not. - fn decrypt_field<'a>(self, cipher: &'a C, context: Ctx) -> Option<C::Output<'a, P>> - where - Self: 'a, - P: 'a; -} - -impl Decryptable for StackCipherText { - const DECRYPTABLE: bool = true; -} - -impl<P, K, Ctx> DecryptField<P, StackCipher<K>, Ctx> for StackCipherText -where - Self: DecryptInto<P, StackCipher<K>, Ctx>, -{ - fn decrypt_field<'a>( - self, - cipher: &'a StackCipher<K>, - context: Ctx, - ) -> Option<Pending<'a, P, K>> - where - Self: 'a, - P: 'a, - { - Some(self.decrypt_into(cipher, context)) - } -} - -/// A collection is decryptable if its elements are. -impl<S: Decryptable> Decryptable for Vec<S> { - const DECRYPTABLE: bool = S::DECRYPTABLE; -} - -impl<S, T, K, Ctx> DecryptField<Vec<T>, StackCipher<K>, Ctx> for Vec<S> -where - S: Decryptable + DecryptField<T, StackCipher<K>, Ctx>, - Ctx: ElementContext, -{ - fn decrypt_field<'a>( - self, - cipher: &'a StackCipher<K>, - context: Ctx, - ) -> Option<Pending<'a, Vec<T>, K>> - where - Self: 'a, - Vec<T>: 'a, - { - if !S::DECRYPTABLE { - return None; - } - if !self.is_empty() { - if let Err(e) = context.check_descriptor() { - return Some(Pending::failed(cipher, e)); - } - } - let items = self - .into_iter() - .map(|item| { - item.decrypt_field(cipher, context.clone()) - .unwrap_or_else(|| Pending::failed(cipher, Error::NotOpened)) - }) - .collect(); - Some(Pending::all(cipher, items)) - } -} - -/// An optional value is decryptable if its content is. -impl<S: Decryptable> Decryptable for Option<S> { - const DECRYPTABLE: bool = S::DECRYPTABLE; -} - -impl<S, T, K, Ctx> DecryptField<Option<T>, StackCipher<K>, Ctx> for Option<S> -where - S: Decryptable + DecryptField<T, StackCipher<K>, Ctx>, - T: MaybeSend, -{ - fn decrypt_field<'a>( - self, - cipher: &'a StackCipher<K>, - context: Ctx, - ) -> Option<Pending<'a, Option<T>, K>> - where - Self: 'a, - Option<T>: 'a, - { - if !S::DECRYPTABLE { - return None; - } - Some(match self { - Some(value) => value - .decrypt_field(cipher, context) - .unwrap_or_else(|| Pending::failed(cipher, Error::NotOpened)) - .map(Some), - None => Pending::ready(cipher, Ok(None)), - }) - } -} - -// ============================================================================= -// Leaf implementations: the record ciphertext -// ============================================================================= - -/// The record ciphertext: any vitaminc [`Encrypt`] value, sealed by a -/// [`KeysetCipher`] under per-leaf ZeroKMS data keys minted under its -/// keyset. The context becomes the AEAD associated data, binding the -/// ciphertext to the field it was encrypted for. -/// -/// The build is synchronous: the value's `Encrypt` impl drives the cipher to -/// a pending tree (no I/O), and the returned [`Pending`] carries one -/// data-key request per leaf. Sealing happens in the fulfilment, key material -/// drawn in the same traversal order the tree was built in. -/// -/// A leaf has nothing of its own to authenticate under, so it exists only -/// for a [`NonEmpty<T>`]: `()` is a compile error here. (An empty context -/// would leave the leaf AAD carrying only the key tag, making ciphertexts -/// transplantable between empty-context fields — see the -/// [module docs](self#contexts).) -impl<'c, 'k, S, K, T> EncryptFrom<S, KeysetCipher<'k, K>, NonEmpty<T>> for StackCipherText -where - S: Encrypt + Clone, - T: IntoAad<'c>, -{ - fn encrypt_from<'a>( - source: &'a S, - cipher: &'a KeysetCipher<'k, K>, - context: NonEmpty<T>, - ) -> Pending<'a, Self, K> - where - Self: 'a, - { - let context = context.into_aad_piece(); - let descriptor = Descriptor::from_piece(&context); - let aad = context.into_aad().into_owned(); - match source.clone().encrypt_with_aad(cipher, aad) { - Ok(tree) => seal_pending(cipher, tree, descriptor), - Err(_) => Pending::ready(cipher, Err(Error::Aead)), - } - } -} - -/// Seal a pending tree: one [`Request::generate_data_key`] per keyed leaf, -/// every one under `descriptor` — the tree's root context, rendered — with -/// keys drawn back in the same traversal order the tree was built in. -/// -/// Both ways of encrypting go through here — the target-directed -/// `encrypt_into_with_context` into a [`StackCipherText`] above and the -/// cipher-directed -/// [`PendingStackCipherText::seal`] behind [`KeysetCipher::encrypt`] — so -/// there is one definition of how a tree is sealed and one path to ZeroKMS. -pub(crate) fn seal_pending<'a, K>( - cipher: &'a KeysetCipher<'_, K>, - tree: PendingStackCipherText, - descriptor: Descriptor, -) -> Pending<'a, StackCipherText, K> { - // Fast path: refuse an over-long descriptor before a single request - // exists, not after one per leaf has been built. `dispatch` is the gate - // proper, and checks every request's descriptor. - if let Err(e) = descriptor.check() { - return Pending::ready(cipher, Err(e)); - } - let keyset_id = cipher.keyset_id(); - let requests = std::iter::repeat_with(|| Request::generate_data_key(descriptor.clone())) - .take(tree.key_count()) - .collect(); - Pending::request(cipher, requests, move |responses| { - let mut keys = responses.drain_generated(); - tree.seal_with(keyset_id, &mut keys).map_err(Error::from) - }) -} - -/// Bind retrieved keys onto a ciphertext: one [`Request::retrieve_data_key`] -/// per keyed leaf, every one under `descriptor` (the one the tree was -/// sealed under), keys zipped back on in the same depth-first order. The -/// decrypt twin of [`seal_pending`], behind the cipher-directed -/// [`StackCipher::decipher`]. The target-directed `decrypt_into` below -/// shares both halves — [`retrieve_requests`] and -/// [`decipher_from_responses`] — but runs the value's `Decrypt` impl in the -/// same fulfilment rather than composing a second pending over this one. -pub(crate) fn decipher_pending<'a, K>( - scope: impl CipherScope<'a, K>, - ciphertext: StackCipherText, - descriptor: Descriptor, -) -> Pending<'a, StackDecipher, K> { - // Fast path, as in `seal_pending`; `dispatch` is the gate. - if let Err(e) = descriptor.check() { - return Pending::ready(scope, Err(e)); - } - let requests = retrieve_requests(&ciphertext, &descriptor); - Pending::request(scope, requests, move |responses| { - decipher_from_responses(ciphertext, responses) - }) -} - -/// The fulfilment half of [`decipher_pending`], shared with `decrypt_into` -/// (which runs the value's `Decrypt` impl over the result in the same -/// fulfilment rather than composing two pendings). -fn decipher_from_responses( - ciphertext: StackCipherText, - responses: &mut Responses, -) -> Result<StackDecipher, Error> { - let mut keys = responses.drain_retrieved(); - // Too few keys for the tree, or keys left over once it is bound, both - // mean `retrieve_requests` and `bind_keys` disagreed about the tree's - // shape: a composition bug in this module, not a data error — so - // `ResponseShape`, never `Aead`, which would read as tampering. - let keyed = bind_keys(ciphertext, &mut keys).map_err(|_| Error::ResponseShape)?; - if keys.next().is_some() { - return Err(Error::ResponseShape); - } - Ok(StackDecipher::over(keyed)) -} - -/// The decrypt mirror: any vitaminc [`Decrypt`] value recovers from a -/// [`StackCipherText`]. The pending carries one retrieve request per leaf -/// (`iv` + `tag` are lifted out of the tree during the synchronous build); -/// the fulfilment binds the retrieved keys back onto the leaves and lets the -/// value's `Decrypt` impl drive the opening. -/// -/// Symmetric with the encrypt side: the target layer never encrypts under an -/// empty context, so it never decrypts under one either — the context is a -/// [`NonEmpty<T>`] here too. -impl<'c, T, K, A> DecryptInto<T, StackCipher<K>, NonEmpty<A>> for StackCipherText -where - T: Decrypt<'static> + 'static, - A: IntoAad<'c>, -{ - fn decrypt_into<'a>(self, cipher: &'a StackCipher<K>, context: NonEmpty<A>) -> Pending<'a, T, K> - where - Self: 'a, - T: 'a, - { - let context = context.into_aad_piece(); - let descriptor = Descriptor::from_piece(&context); - // Fast path, as in `seal_pending`; `dispatch` is the gate. - if let Err(e) = descriptor.check() { - return Pending::ready(cipher, Err(e)); - } - let requests = retrieve_requests(&self, &descriptor); - let aad = context.into_aad().into_owned(); - Pending::request(cipher, requests, move |responses| { - let decipher = decipher_from_responses(self, responses)?; - T::decrypt_with_aad(decipher, aad).map_err(Error::from) - }) - } -} - -/// One [`Request::retrieve_data_key`] per keyed leaf, all under -/// `descriptor`, in the same depth-first order `bind_keys` will consume the -/// responses. -fn retrieve_requests(ciphertext: &StackCipherText, descriptor: &Descriptor) -> Vec<Request> { - let mut out = Vec::new(); - collect_retrieve_requests(ciphertext, descriptor, &mut out); - out -} - -fn collect_retrieve_requests( - ciphertext: &StackCipherText, - descriptor: &Descriptor, - out: &mut Vec<Request>, -) { - match ciphertext { - CipherText::Single(leaf) - | CipherText::None(leaf) - | CipherText::EmptySequence(leaf) - | CipherText::EmptyMap(leaf) => { - out.push(Request::retrieve_data_key( - *leaf.iv(), - leaf.tag().to_vec(), - descriptor.clone(), - leaf.keyset_id(), - )); - } - CipherText::Sequence(items) => { - for item in items { - collect_retrieve_requests(item, descriptor, out); - } - } - CipherText::Map(entries) => { - for (_, value) in entries { - collect_retrieve_requests(value, descriptor, out); - } - } - CipherText::Passthrough(_) => {} - } -} - -// ============================================================================= -// Structural implementations: columns and optionals -// ============================================================================= - -/// A column: each element encrypts independently under the **same** context -/// (a column is one field), and the whole column settles in one batched call. -/// -/// Distinct from `Vec<S> → StackCipherText` (via [`Encrypt`]), which is one -/// record whose value is a list — see the [module docs](self). -/// -/// The context is passed through untouched, and so is the obligation: a -/// column of leaves needs a [`NonEmpty<T>`] because its leaves do, a column -/// of records whose fields carry their own contexts accepts `()` as well -/// because they do. Neither is decided here. What *is* decided here is -/// that an over-long context is refused once, before the column is walked -/// ([`ElementContext`]), not once per element. -impl<'k, S, T, K, Ctx> EncryptFrom<Vec<S>, KeysetCipher<'k, K>, Ctx> for Vec<T> -where - T: EncryptFrom<S, KeysetCipher<'k, K>, Ctx>, - Ctx: ElementContext, -{ - const KEYED: bool = T::KEYED; - - fn encrypt_from<'a>( - source: &'a Vec<S>, - cipher: &'a KeysetCipher<'k, K>, - context: Ctx, - ) -> Pending<'a, Self, K> - where - Self: 'a, - { - if T::KEYED && !source.is_empty() { - if let Err(e) = context.check_descriptor() { - return Pending::failed(cipher, e); - } - } - let items = source - .iter() - .map(|item| T::encrypt_from(item, cipher, context.clone())) - .collect(); - Pending::all(cipher, items) - } -} - -/// The column decrypt mirror: one batched retrieve for every element. -impl<S, T, K, Ctx> DecryptInto<Vec<T>, StackCipher<K>, Ctx> for Vec<S> -where - S: DecryptInto<T, StackCipher<K>, Ctx>, - Ctx: ElementContext, -{ - fn decrypt_into<'a>(self, cipher: &'a StackCipher<K>, context: Ctx) -> Pending<'a, Vec<T>, K> - where - Self: 'a, - Vec<T>: 'a, - { - // Every `DecryptInto` opens keyed leaves (terms are one-way), so - // the only column with nothing to bind is the empty one. - if !self.is_empty() { - if let Err(e) = context.check_descriptor() { - return Pending::failed(cipher, e); - } - } - let items = self - .into_iter() - .map(|item| item.decrypt_into(cipher, context.clone())) - .collect(); - Pending::all(cipher, items) - } -} - -/// The context a column hands to each of its elements: `()` or a -/// [`NonEmpty<T>`], the two shapes the target layer takes. -/// -/// A column clones its context into every element, and every keyed element -/// renders it as its ZeroKMS [`Descriptor`]. The rendering is bounded -/// ([`Descriptor::MAX_LEN`]) but the context is not, so a column checks the -/// rendering **once**, here, before it walks its elements: an over-long -/// context costs one rendering and is refused whole, not one rendering per -/// element before [`Pending::all`] surfaces the first refusal. Under `()` -/// the elements carry their own contexts and there is nothing to check. -/// -/// The check is on the context the column was given. A derived record -/// extends it with each field's own literal before its leaves render it, -/// and that extension can exceed the limit where the caller's part alone -/// did not; the leaf then refuses it, once per row — each such rendering -/// bounded by [`Descriptor::MAX_LEN`] plus the literal, since the caller's -/// part has already been shown to fit. And a column whose elements request -/// no keys under the context ([`EncryptFrom::KEYED`] is `false`, as for a -/// column of terms), or that has no elements, is not checked at all: there -/// is no descriptor to bind. -/// -/// Sealed: the two implementations are the two shapes. -pub trait ElementContext: Clone + sealed::Sealed { - /// Render the descriptor the elements will render, and refuse it now if - /// ZeroKMS could not bind it. - fn check_descriptor(&self) -> Result<(), Error>; -} - -mod sealed { - pub trait Sealed {} - impl Sealed for () {} - impl<T> Sealed for vitaminc_protected::NonEmpty<T> {} -} - -impl ElementContext for () { - fn check_descriptor(&self) -> Result<(), Error> { - Ok(()) - } -} - -impl<'c, T> ElementContext for NonEmpty<T> -where - T: IntoAad<'c> + Clone, -{ - fn check_descriptor(&self) -> Result<(), Error> { - Descriptor::of(self.clone()).check() - } -} - -/// An optional field: `None` encrypts to `None` at the target layer (an -/// absent *record field*, carrying no requests). This is distinct from -/// `Option<S> → StackCipherText` via [`Encrypt`], which produces an -/// *authenticated* absence marker inside one ciphertext. -impl<'k, S, T, K, Ctx> EncryptFrom<Option<S>, KeysetCipher<'k, K>, Ctx> for Option<T> -where - T: EncryptFrom<S, KeysetCipher<'k, K>, Ctx> + MaybeSend, -{ - const KEYED: bool = T::KEYED; - - fn encrypt_from<'a>( - source: &'a Option<S>, - cipher: &'a KeysetCipher<'k, K>, - context: Ctx, - ) -> Pending<'a, Self, K> - where - Self: 'a, - { - match source { - Some(value) => T::encrypt_from(value, cipher, context).map(Some), - None => Pending::ready(cipher, Ok(None)), - } - } -} - -/// The optional decrypt mirror of the [`Option`] encrypt implementation. -impl<S, T, K, Ctx> DecryptInto<Option<T>, StackCipher<K>, Ctx> for Option<S> -where - S: DecryptInto<T, StackCipher<K>, Ctx>, - T: MaybeSend, -{ - fn decrypt_into<'a>(self, cipher: &'a StackCipher<K>, context: Ctx) -> Pending<'a, Option<T>, K> - where - Self: 'a, - Option<T>: 'a, - { - match self { - Some(value) => value.decrypt_into(cipher, context).map(Some), - None => Pending::ready(cipher, Ok(None)), - } - } -} diff --git a/packages/stack-encrypt/src/target/operations.rs b/packages/stack-encrypt/src/target/operations.rs new file mode 100644 index 000000000..29fad3c70 --- /dev/null +++ b/packages/stack-encrypt/src/target/operations.rs @@ -0,0 +1,693 @@ +//! Core-owned operation descriptions. Constructors never accept a plaintext/cipher callback. +use super::core::{encrypt_native, open_native, Term}; +use super::{CipherScope, Pending}; +use crate::{ + Aad, AadPiece, Error, IntoAad, IntoPrfContext, KeysetCipher, MaybeEmpty, NonEmpty, PrfContext, + StackCipher, StackCipherText, +}; +use stack_kms::MaybeSend; + +/// Owned, validated context for targets that accept any Vitamin C context. +/// Concrete records can instead declare their own associated context type. +/// Both encodings and the descriptor's structured identity are preserved. +#[derive(Clone, Debug)] +pub struct CallerContext { + aad: AadPiece<'static>, + prf: PrfContext<'static>, +} +impl<'c, T> From<NonEmpty<T>> for CallerContext +where + T: IntoAad<'c> + IntoPrfContext<'c> + Clone, +{ + fn from(context: NonEmpty<T>) -> Self { + Self { + aad: context.clone().into_aad_piece().into_owned(), + prf: context.into_prf_context().into_owned(), + } + } +} +impl MaybeEmpty for CallerContext { + fn is_empty(&self) -> bool { + false + } +} +impl<'a> IntoAad<'a> for CallerContext { + fn into_aad(self) -> Aad<'a> { + self.aad.into_aad() + } + fn into_aad_piece(self) -> AadPiece<'a> { + self.aad + } +} +impl<'a> IntoPrfContext<'a> for CallerContext { + fn into_prf_context(self) -> PrfContext<'a> { + self.prf + } +} +impl CallerContext { + fn validated(self) -> Result<NonEmpty<Self>, Error> { + NonEmpty::new(self).map_err(|e| Error::Other(Box::new(e))) + } + /// Extend a field's own context with this caller context. + pub fn under(self, field: &'static str) -> Result<Self, Error> { + let prefix = NonEmpty::new(field).map_err(|e| Error::Other(Box::new(e)))?; + Ok(prefix.with(self.validated()?).into()) + } +} + +/// Declaration of how an encrypted target is produced from `S`. +/// The returned description has private execution machinery: implementations can +/// select core operations and construct outputs, but cannot replace encryption. +pub trait EncryptFrom<S>: Sized + 'static { + type Context; + fn encryption<'s, K: 'static>(context: Self::Context) -> Encryption<'s, S, Self, K> + where + S: 's; +} +/// Declaration of how a stored target recovers `P`. Inspection sees no cipher. +pub trait DecryptInto<P>: Sized { + type Context; + fn decryption<K: 'static>(self, context: Self::Context) -> Decryption<P, K>; +} + +#[cfg(not(target_arch = "wasm32"))] +type Build<'s, S, T, K> = + Box<dyn for<'a, 'k> FnOnce(&S, &'a KeysetCipher<'k, K>) -> Pending<'a, T, K> + Send + 's>; +#[cfg(target_arch = "wasm32")] +type Build<'s, S, T, K> = + Box<dyn for<'a, 'k> FnOnce(&S, &'a KeysetCipher<'k, K>) -> Pending<'a, T, K> + 's>; +#[cfg(not(target_arch = "wasm32"))] +type Open<T, K> = Box<dyn for<'a> FnOnce(&'a StackCipher<K>) -> Pending<'a, T, K> + Send>; +#[cfg(target_arch = "wasm32")] +type Open<T, K> = Box<dyn for<'a> FnOnce(&'a StackCipher<K>) -> Pending<'a, T, K>>; + +/// A composable encryption description, executed only by the cipher. +pub struct Encryption<'s, S, T, K> { + build: Build<'s, S, T, K>, +} +/// A composable decryption description, executed only by the cipher. +pub struct Decryption<T, K> { + open: Open<T, K>, +} + +impl<'s, S: 's, T: 'static, K: 'static> Encryption<'s, S, T, K> { + /// Construct metadata or reject a declaration before any key request. + pub fn ready(result: Result<T, Error>) -> Self + where + T: MaybeSend, + { + Self { + build: Box::new(move |_, cipher| Pending::ready(cipher, result)), + } + } + pub fn failed(error: Error) -> Self { + Self { + build: Box::new(move |_, cipher| Pending::failed(cipher, error)), + } + } + /// Construct the destination from completed outputs, without access to plaintext. + pub fn map<U: 'static, F>(self, f: F) -> Encryption<'s, S, U, K> + where + F: FnOnce(T) -> U + MaybeSend + 'static, + { + Encryption { + build: Box::new(move |source, cipher| (self.build)(source, cipher).map(f)), + } + } + /// Fallible output conversion, including native ciphertext transcoding. + pub fn try_map<U: 'static, F>(self, f: F) -> Encryption<'s, S, U, K> + where + F: FnOnce(T) -> Result<U, Error> + MaybeSend + 'static, + { + Encryption { + build: Box::new(move |source, cipher| (self.build)(source, cipher).try_map(f)), + } + } + /// Drive a destination visitor directly from this operation's native output. + pub fn transcode<U: super::transcode::Transcode + 'static>(self) -> Encryption<'s, S, U, K> + where + T: super::transcode::Reader, + { + self.try_map(|output| super::transcode::Reader::read(output, U::visitor())) + } + /// Compose operations over the same source in one key request batch. + pub fn zip<U: 'static>(self, other: Encryption<'s, S, U, K>) -> Encryption<'s, S, (T, U), K> { + Encryption { + build: Box::new(move |source, cipher| { + (self.build)(source, cipher).zip((other.build)(source, cipher)) + }), + } + } + /// Select a borrowed plaintext field. The selector cannot supply a new owned + /// serialization of that field; its selected type drives the core operation. + pub fn project<P: 's>( + self, + select: for<'borrow> fn(&'borrow P) -> &'borrow S, + ) -> Encryption<'s, P, T, K> { + Encryption { + build: Box::new(move |source, cipher| (self.build)(select(source), cipher)), + } + } +} + +/// Canonical ciphertext operation. Source encoding belongs to Vitamin C. +pub fn ciphertext<'s, S: crate::Encrypt + Clone + 's, K: 'static>( + context: impl Into<AeadContext>, +) -> Encryption<'s, S, StackCipherText, K> { + let context = context.into(); + Encryption { + build: Box::new(move |source, cipher| match context.validated() { + Ok(ctx) => encrypt_native(source, cipher, ctx), + Err(e) => Pending::failed(cipher, e), + }), + } +} +macro_rules! term_operation { + ($function:ident, $output:ty, [$($extra:tt)*], $($bounds:tt)*) => { + pub fn $function<'s,S,K:'static,$($extra)*>(context: impl Into<CallerContext>)->Encryption<'s,S,$output,K> + where S: 's + $($bounds)* { + let context=context.into(); + Encryption { build:Box::new(move |source,cipher| match context.validated() { + Ok(ctx) => <$output as Term<S, K, _>>::encrypt_from(source,cipher,ctx), + Err(e) => Pending::failed(cipher,e), + }) } + } + }; +} +term_operation!( + equality, + crate::sem::EqualityTerm, + [], + vitaminc_prf::PrfValue + Clone +); +term_operation!(matching, crate::sem::MatchTerm<O>, [O:crate::sem::MatchConfig+'static,], AsRef<str>); +// Ordering output bounds are part of the primitive's contract. +pub fn ore<'s, S, K: 'static>( + context: impl Into<CallerContext>, +) -> Encryption<'s, S, crate::sem::OreTerm<S>, K> +where + S: cllw_ore::CllwOreEncrypt + Clone + Send + 'static, + S::Output: Send + 'static, +{ + let context = context.into(); + Encryption { + build: Box::new(move |source, cipher| match context.validated() { + Ok(ctx) => <crate::sem::OreTerm<S> as Term<S, K, _>>::encrypt_from(source, cipher, ctx), + Err(e) => Pending::failed(cipher, e), + }), + } +} +pub fn ope<'s, S, K: 'static>( + context: impl Into<CallerContext>, +) -> Encryption<'s, S, crate::sem::OpeTerm<S>, K> +where + S: cllw_ore::CllwOpeEncrypt + Clone + Send + 'static, + S::Output: Send + 'static, +{ + let context = context.into(); + Encryption { + build: Box::new(move |source, cipher| match context.validated() { + Ok(ctx) => <crate::sem::OpeTerm<S> as Term<S, K, _>>::encrypt_from(source, cipher, ctx), + Err(e) => Pending::failed(cipher, e), + }), + } +} + +impl<T: 'static, K: 'static> Decryption<T, K> { + pub fn failed(error: Error) -> Self { + Self { + open: Box::new(move |cipher| Pending::failed(cipher, error)), + } + } + pub fn ready(value: T) -> Self + where + T: MaybeSend, + { + Self { + open: Box::new(move |cipher| Pending::ready(cipher, Ok(value))), + } + } + pub fn map<U: 'static, F>(self, f: F) -> Decryption<U, K> + where + F: FnOnce(T) -> U + MaybeSend + 'static, + { + Decryption { + open: Box::new(move |cipher| (self.open)(cipher).map(f)), + } + } + pub fn zip<U: 'static>(self, other: Decryption<U, K>) -> Decryption<(T, U), K> { + Decryption { + open: Box::new(move |cipher| (self.open)(cipher).zip((other.open)(cipher))), + } + } +} +/// Open the native ciphertext through Vitamin C's requested plaintext decoder. +pub fn open<P: crate::Decrypt<'static> + 'static, K: 'static>( + tree: StackCipherText, + context: impl Into<AeadContext>, +) -> Decryption<P, K> { + let context = context.into(); + Decryption { + open: Box::new(move |cipher| match context.validated() { + Ok(ctx) => open_native(tree, cipher, ctx), + Err(e) => Pending::failed(cipher, e), + }), + } +} + +impl<K: 'static> KeysetCipher<'_, K> { + pub fn encrypt_as<'a, S, T>(&'a self, source: &S, context: T::Context) -> Pending<'a, T, K> + where + T: EncryptFrom<S>, + { + (T::encryption(context).build)(source, self) + } + pub fn decrypt_as<'a, P: 'static, T>( + &'a self, + source: T, + context: T::Context, + ) -> Pending<'a, P, K> + where + T: DecryptInto<P>, + { + (source.decryption(context).open)(self.cipher()).scoped_to(self.keyset_id()) + } +} +impl<K: 'static> StackCipher<K> { + pub fn decrypt_as<'a, P: 'static, T>( + &'a self, + source: T, + context: T::Context, + ) -> Pending<'a, P, K> + where + T: DecryptInto<P>, + { + (source.decryption(context).open)(self) + } +} + +/// Blanket call-site convenience; targets implement the declaration, not these methods. +pub trait EncryptInto: Sized { + fn encrypt_into<'a, T, K: 'static>(&self, cipher: &'a KeysetCipher<'_, K>) -> Pending<'a, T, K> + where + T: EncryptFrom<Self>, + T::Context: Default, + { + cipher.encrypt_as(self, Default::default()) + } + fn encrypt_into_with_context<'a, T, K: 'static>( + &self, + cipher: &'a KeysetCipher<'_, K>, + context: impl Into<T::Context>, + ) -> Pending<'a, T, K> + where + T: EncryptFrom<Self>, + { + cipher.encrypt_as(self, context.into()) + } + fn encrypt_from<'a, S, K: 'static>( + source: &S, + cipher: &'a KeysetCipher<'_, K>, + context: impl Into<<Self as EncryptFrom<S>>::Context>, + ) -> Pending<'a, Self, K> + where + Self: EncryptFrom<S>, + { + cipher.encrypt_as(source, context.into()) + } +} +impl<T> EncryptInto for T {} +/// Blanket decrypt call-site convenience; there is no overridable target execution method. +pub trait DecryptFrom: Sized + 'static { + fn decrypt_into<'a, P: 'static, K: 'static>( + self, + cipher: impl CipherScope<'a, K>, + context: impl Into<<Self as DecryptInto<P>>::Context>, + ) -> Pending<'a, P, K> + where + Self: DecryptInto<P>, + { + let pending = (self.decryption(context.into()).open)(cipher.cipher()); + match cipher.keyset() { + Some(id) => pending.scoped_to(id), + None => pending, + } + } + fn decrypt_from<'a, S, K: 'static>( + source: S, + cipher: impl CipherScope<'a, K>, + ) -> Pending<'a, Self, K> + where + S: DecryptInto<Self> + 'static, + S::Context: Default, + { + source.decrypt_into(cipher, S::Context::default()) + } + fn decrypt_from_with_context<'a, S, K: 'static>( + source: S, + cipher: impl CipherScope<'a, K>, + context: impl Into<S::Context>, + ) -> Pending<'a, Self, K> + where + S: DecryptInto<Self> + 'static, + { + source.decrypt_into(cipher, context.into()) + } +} +impl<T: 'static> DecryptFrom for T {} + +impl<S: crate::Encrypt + Clone> EncryptFrom<S> for StackCipherText { + type Context = AeadContext; + fn encryption<'s, K: 'static>(context: Self::Context) -> Encryption<'s, S, Self, K> + where + S: 's, + { + ciphertext(context) + } +} +impl<P: crate::Decrypt<'static> + 'static> DecryptInto<P> for StackCipherText { + type Context = AeadContext; + fn decryption<K: 'static>(self, context: Self::Context) -> Decryption<P, K> { + open(self, context) + } +} +impl<S: vitaminc_prf::PrfValue + Clone> EncryptFrom<S> for crate::sem::EqualityTerm { + type Context = CallerContext; + fn encryption<'s, K: 'static>(context: Self::Context) -> Encryption<'s, S, Self, K> + where + S: 's, + { + equality(context) + } +} +impl<S: AsRef<str>, O: crate::sem::MatchConfig + 'static> EncryptFrom<S> + for crate::sem::MatchTerm<O> +{ + type Context = CallerContext; + fn encryption<'s, K: 'static>(context: Self::Context) -> Encryption<'s, S, Self, K> + where + S: 's, + { + matching(context) + } +} +impl<S> EncryptFrom<S> for crate::sem::OreTerm<S> +where + S: cllw_ore::CllwOreEncrypt + Clone + Send + 'static, + S::Output: Send + 'static, +{ + type Context = CallerContext; + fn encryption<'s, K: 'static>(context: Self::Context) -> Encryption<'s, S, Self, K> + where + S: 's, + { + ore(context) + } +} +impl<S> EncryptFrom<S> for crate::sem::OpeTerm<S> +where + S: cllw_ore::CllwOpeEncrypt + Clone + Send + 'static, + S::Output: Send + 'static, +{ + type Context = CallerContext; + fn encryption<'s, K: 'static>(context: Self::Context) -> Encryption<'s, S, Self, K> + where + S: 's, + { + ope(context) + } +} +impl<S, T: EncryptFrom<S>> EncryptFrom<Vec<S>> for Vec<T> +where + T::Context: Clone + 'static + MaybeSend, +{ + type Context = T::Context; + fn encryption<'s, K: 'static>(context: Self::Context) -> Encryption<'s, Vec<S>, Self, K> + where + S: 's, + { + Encryption { + build: Box::new(move |source, cipher| { + Pending::collect( + cipher, + source + .iter() + .map(|item| cipher.encrypt_as(item, context.clone())), + ) + }), + } + } +} +impl<S, T: EncryptFrom<S> + MaybeSend> EncryptFrom<Option<S>> for Option<T> +where + T::Context: 'static + MaybeSend, +{ + type Context = T::Context; + fn encryption<'s, K: 'static>(context: Self::Context) -> Encryption<'s, Option<S>, Self, K> + where + S: 's, + { + Encryption { + build: Box::new(move |source, cipher| match source { + Some(item) => cipher.encrypt_as(item, context).map(Some), + None => Pending::ready(cipher, Ok(None)), + }), + } + } +} +impl<P: 'static, T: DecryptInto<P> + 'static> DecryptInto<Vec<P>> for Vec<T> +where + T::Context: Clone + 'static, + T: MaybeSend, + T::Context: MaybeSend, +{ + type Context = T::Context; + fn decryption<K: 'static>(self, context: Self::Context) -> Decryption<Vec<P>, K> { + Decryption { + open: Box::new(move |cipher| { + Pending::collect( + cipher, + self.into_iter() + .map(|item| cipher.decrypt_as(item, context.clone())), + ) + }), + } + } +} +impl<P: 'static + MaybeSend, T: DecryptInto<P> + 'static + MaybeSend> DecryptInto<Option<P>> + for Option<T> +where + T::Context: 'static + MaybeSend, +{ + type Context = T::Context; + fn decryption<K: 'static>(self, context: Self::Context) -> Decryption<Option<P>, K> { + Decryption { + open: Box::new(move |cipher| match self { + Some(item) => cipher.decrypt_as(item, context).map(Some), + None => Pending::ready(cipher, Ok(None)), + }), + } + } +} + +/// Optional extension for records whose fields already declare their base contexts. +/// `()` selects the declared contexts unchanged; a nonempty value extends them. +#[derive(Clone, Debug, Default)] +pub struct DeclaredContext(Option<CallerContext>); +impl From<()> for DeclaredContext { + fn from(_: ()) -> Self { + Self::default() + } +} +impl From<CallerContext> for DeclaredContext { + fn from(value: CallerContext) -> Self { + Self(Some(value)) + } +} +impl<'a, T: IntoAad<'a> + IntoPrfContext<'a> + Clone> From<NonEmpty<T>> for DeclaredContext { + fn from(value: NonEmpty<T>) -> Self { + Self(Some(value.into())) + } +} +impl DeclaredContext { + pub fn field(self, field: &'static str) -> Result<CallerContext, Error> { + match self.0 { + Some(context) => context.under(field), + None => NonEmpty::new(field) + .map(Into::into) + .map_err(|e| Error::Other(Box::new(e))), + } + } +} +/// Optional destination validation for a record carrying its context in storage. +/// Even without an expected value, the stored context must pass `NonEmpty::new`. +#[derive(Clone, Debug)] +pub struct ExpectedContext<T>(Option<NonEmpty<T>>); +impl<T> Default for ExpectedContext<T> { + fn default() -> Self { + Self(None) + } +} +impl<T> From<()> for ExpectedContext<T> { + fn from(_: ()) -> Self { + Self::default() + } +} +impl<T> From<NonEmpty<T>> for ExpectedContext<T> { + fn from(value: NonEmpty<T>) -> Self { + Self(Some(value)) + } +} +impl<T: MaybeEmpty + PartialEq> ExpectedContext<T> { + pub fn validate(self, stored: T) -> Result<NonEmpty<T>, Error> { + if self + .0 + .is_some_and(|expected| expected.into_inner() != stored) + { + return Err(Error::Other( + "stored context does not match the expected context".into(), + )); + } + NonEmpty::new(stored).map_err(|e| Error::Other(Box::new(e))) + } +} +/// Declaration used by derives to select the one recoverable field. Terms return +/// `None`; ciphertext fields return an opening description. No cipher is exposed. +pub trait DecryptField<P, Ctx>: Sized { + fn decryption_field<K: 'static>(self, context: Ctx) -> Option<Decryption<P, K>>; +} +impl<P: 'static, Ctx> DecryptField<P, Ctx> for StackCipherText +where + Self: DecryptInto<P>, + Ctx: Into<<Self as DecryptInto<P>>::Context>, +{ + fn decryption_field<K: 'static>(self, context: Ctx) -> Option<Decryption<P, K>> { + Some(self.decryption(context.into())) + } +} +impl<P, Ctx> DecryptField<P, Ctx> for crate::sem::EqualityTerm { + fn decryption_field<K: 'static>(self, _: Ctx) -> Option<Decryption<P, K>> { + None + } +} +impl<P, Ctx, O: crate::sem::MatchConfig> DecryptField<P, Ctx> for crate::sem::MatchTerm<O> { + fn decryption_field<K: 'static>(self, _: Ctx) -> Option<Decryption<P, K>> { + None + } +} +impl<P, Ctx, T: cllw_ore::CllwOreEncrypt> DecryptField<P, Ctx> for crate::sem::OreTerm<T> { + fn decryption_field<K: 'static>(self, _: Ctx) -> Option<Decryption<P, K>> { + None + } +} +impl<P, Ctx, T: cllw_ore::CllwOpeEncrypt> DecryptField<P, Ctx> for crate::sem::OpeTerm<T> { + fn decryption_field<K: 'static>(self, _: Ctx) -> Option<Decryption<P, K>> { + None + } +} +impl< + P: 'static, + T: 'static + super::Decryptable + DecryptField<P, Ctx> + MaybeSend, + Ctx: Clone + 'static + MaybeSend, + > DecryptField<Vec<P>, Ctx> for Vec<T> +{ + fn decryption_field<K: 'static>(self, context: Ctx) -> Option<Decryption<Vec<P>, K>> { + if !T::DECRYPTABLE { + return None; + } + Some(Decryption { + open: Box::new(move |cipher| { + Pending::collect( + cipher, + self.into_iter().map(|item| { + let plan = item + .decryption_field(context.clone()) + .unwrap_or_else(|| Decryption::failed(Error::NotOpened)); + (plan.open)(cipher) + }), + ) + }), + }) + } +} +impl< + P: 'static + MaybeSend, + T: 'static + super::Decryptable + DecryptField<P, Ctx> + MaybeSend, + Ctx: 'static + MaybeSend, + > DecryptField<Option<P>, Ctx> for Option<T> +{ + fn decryption_field<K: 'static>(self, context: Ctx) -> Option<Decryption<Option<P>, K>> { + if !T::DECRYPTABLE { + return None; + } + Some(match self { + Some(item) => item + .decryption_field(context) + .unwrap_or_else(|| Decryption::failed(Error::NotOpened)) + .map(Some), + None => Decryption::ready(None), + }) + } +} + +/// Owned nonempty context for ciphertext-only operations. These do not require a +/// PRF encoding; a type that implements only `IntoAad` remains sufficient. +#[derive(Clone, Debug)] +pub struct AeadContext(AadPiece<'static>); +impl<'a, T: IntoAad<'a>> From<NonEmpty<T>> for AeadContext { + fn from(value: NonEmpty<T>) -> Self { + Self(value.into_aad_piece().into_owned()) + } +} +impl From<CallerContext> for AeadContext { + fn from(value: CallerContext) -> Self { + Self(value.aad) + } +} +impl MaybeEmpty for AeadContext { + fn is_empty(&self) -> bool { + false + } +} +impl<'a> IntoAad<'a> for AeadContext { + fn into_aad(self) -> Aad<'a> { + self.0.into_aad() + } + fn into_aad_piece(self) -> AadPiece<'a> { + self.0 + } +} +impl AeadContext { + fn validated(self) -> Result<NonEmpty<Self>, Error> { + NonEmpty::new(self).map_err(|e| Error::Other(Box::new(e))) + } +} +macro_rules! integer_contexts { + ($($ty:ty),*) => {$ ( + impl From<$ty> for CallerContext { + fn from(value:$ty)->Self { Self::from(NonEmpty::<$ty>::from(value)) } + } + impl From<$ty> for AeadContext { + fn from(value:$ty)->Self { Self::from(NonEmpty::<$ty>::from(value)) } + } + impl From<$ty> for DeclaredContext { + fn from(value:$ty)->Self { Self::from(NonEmpty::<$ty>::from(value)) } + } + )*}; +} +integer_contexts!(u8, u16, u32, u64, u128, i8, i16, i32, i64, i128); + +/// Whether a field contains recoverable ciphertext. Derives assert exactly one +/// such field per plaintext value; query terms are never recovery candidates. +pub trait Decryptable { + const DECRYPTABLE: bool; +} +impl Decryptable for StackCipherText { + const DECRYPTABLE: bool = true; +} +impl<T: Decryptable> Decryptable for Vec<T> { + const DECRYPTABLE: bool = T::DECRYPTABLE; +} +impl<T: Decryptable> Decryptable for Option<T> { + const DECRYPTABLE: bool = T::DECRYPTABLE; +} diff --git a/packages/stack-encrypt/src/target/pending.rs b/packages/stack-encrypt/src/target/pending.rs index 328c98b01..f0411da6c 100644 --- a/packages/stack-encrypt/src/target/pending.rs +++ b/packages/stack-encrypt/src/target/pending.rs @@ -37,9 +37,8 @@ pub type PendingFuture<'a, T> = Pin<Box<dyn Future<Output = Result<T, Error>> + #[cfg(target_arch = "wasm32")] pub type PendingFuture<'a, T> = Pin<Box<dyn Future<Output = Result<T, Error>> + 'a>>; -/// A request carrier resolving to `T`: [`StackCipher`]'s -/// [`EncryptTarget::Output`](super::EncryptTarget::Output) / -/// [`DecryptTarget::Output`](super::DecryptTarget::Output). +/// A request carrier resolving to `T`, built by the cipher executing an +/// encryption or decryption declaration. /// /// **Not a future** until awaited. A `Pending` holds the ZeroKMS requests its /// value needs plus the fulfilment that shapes the responses; combining @@ -87,8 +86,8 @@ pub struct Pending<'a, T, K> { /// keyset, which would make [`Pending`]'s scope rules /// ([`Error::NoKeyset`], [`Error::ForeignKeyset`]) say less than they do: /// a scope's id is one the cipher loaded from ZeroKMS. Downstream code -/// implements [`EncryptFrom`](super::EncryptFrom) and passes the scope it -/// was handed; it never needs one of its own. +/// uses the cipher to execute [`EncryptFrom`](super::EncryptFrom); it never +/// implements a scope of its own. pub trait CipherScope<'a, K>: sealed::Sealed { /// The client-scoped cipher the pending settles through. fn cipher(&self) -> &'a StackCipher<K>; @@ -315,10 +314,20 @@ impl<'a, T: 'a, K> Pending<'a, T, K> { scope: impl CipherScope<'a, K>, items: Vec<Pending<'a, T, K>>, ) -> Pending<'a, Vec<T>, K> { + Self::collect(scope, items) + } + + /// Build a collection lazily, stopping at the first local failure so a bad + /// context does not repeat work for every remaining row. + pub(crate) fn collect( + scope: impl CipherScope<'a, K>, + items: impl IntoIterator<Item = Pending<'a, T, K>>, + ) -> Pending<'a, Vec<T>, K> { + let items = items.into_iter(); let cipher = scope.cipher(); let mut keyset = scope.keyset(); let mut requests = Vec::new(); - let mut fulfils = Vec::with_capacity(items.len()); + let mut fulfils = Vec::with_capacity(items.size_hint().0); for item in items { keyset = match merge_scopes(keyset, item.keyset) { Ok(keyset) => keyset, @@ -881,6 +890,25 @@ mod tests { ); } + #[tokio::test] + async fn a_lazy_column_stops_building_after_a_local_failure() { + let cipher = cipher().await; + let keyset = cipher.default_keyset(); + let built = AtomicUsize::new(0); + let items = (0..10_000).map(|index| { + built.fetch_add(1, Ordering::Relaxed); + if index == 1 { + Pending::failed(&keyset, Error::Aead) + } else { + generating(&keyset, 1) + } + }); + let result = Pending::collect(&keyset, items).await; + assert!(matches!(result, Err(Error::Aead))); + assert_eq!(built.load(Ordering::Relaxed), 2); + assert_eq!(cipher.kms().generate_calls(), 0); + } + /// Over-drawing is the fulfilment's own error, not a stolen sibling key: /// the second pending still resolves to the key it asked for. #[tokio::test] diff --git a/packages/stack-encrypt/src/target/transcode.rs b/packages/stack-encrypt/src/target/transcode.rs new file mode 100644 index 000000000..94ad53029 --- /dev/null +++ b/packages/stack-encrypt/src/target/transcode.rs @@ -0,0 +1,120 @@ +//! Consuming readers over native encryption output. Reading moves existing +//! leaves and containers; it does not serialize or build an intermediate tree. +use crate::sem::{EqualityTerm, MatchConfig, MatchTerm, OpeTerm, OreTerm}; +use crate::{BoxedPassthrough, CipherText, Error, SealedValue, StackCipherText}; + +/// An encrypted output that can drive a destination visitor. +pub trait Reader: Sized { + fn read<V: Visitor>(self, visitor: V) -> Result<V::Value, Error>; +} +/// Destination construction. Unsupported shapes fail explicitly by default. +/// Markers remain sealed: none of these methods authenticates their contents. +pub trait Visitor: Sized { + type Value; + fn sealed(self, _: SealedValue) -> Result<Self::Value, Error> { + Err(shape()) + } + fn sequence<R: SequenceReader>(self, _: R) -> Result<Self::Value, Error> { + Err(shape()) + } + fn map<R: MapReader>(self, _: R) -> Result<Self::Value, Error> { + Err(shape()) + } + fn absent(self, _: SealedValue) -> Result<Self::Value, Error> { + Err(shape()) + } + fn empty_sequence(self, _: SealedValue) -> Result<Self::Value, Error> { + Err(shape()) + } + fn empty_map(self, _: SealedValue) -> Result<Self::Value, Error> { + Err(shape()) + } + /// Passthrough metadata is not AEAD-authenticated by the reader. + fn passthrough(self, _: BoxedPassthrough) -> Result<Self::Value, Error> { + Err(shape()) + } + fn equality(self, _: EqualityTerm) -> Result<Self::Value, Error> { + Err(shape()) + } + fn matching<O: MatchConfig>(self, _: MatchTerm<O>) -> Result<Self::Value, Error> { + Err(shape()) + } + fn ore<T: cllw_ore::CllwOreEncrypt>(self, _: OreTerm<T>) -> Result<Self::Value, Error> { + Err(shape()) + } + fn ope<T: cllw_ore::CllwOpeEncrypt>(self, _: OpeTerm<T>) -> Result<Self::Value, Error> { + Err(shape()) + } +} +/// A destination chooses its visitor without receiving plaintext or a cipher. +pub trait Transcode: Sized { + type Visitor: Visitor<Value = Self>; + fn visitor() -> Self::Visitor; +} +/// Streaming access to an existing sequence. The child reader owns its output. +pub trait SequenceReader { + type Item: Reader; + fn next(&mut self) -> Option<Self::Item>; + fn remaining(&self) -> usize; +} +/// Map keys retain their original spelling and order. They must not be renamed +/// when opening: Vitamin C uses them to derive each entry's authenticated context. +pub trait MapReader { + type Item: Reader; + fn next(&mut self) -> Option<(String, Self::Item)>; + fn remaining(&self) -> usize; +} +impl SequenceReader for std::vec::IntoIter<StackCipherText> { + type Item = StackCipherText; + fn next(&mut self) -> Option<Self::Item> { + Iterator::next(self) + } + fn remaining(&self) -> usize { + self.len() + } +} +impl MapReader for std::vec::IntoIter<(String, StackCipherText)> { + type Item = StackCipherText; + fn next(&mut self) -> Option<(String, Self::Item)> { + Iterator::next(self) + } + fn remaining(&self) -> usize { + self.len() + } +} +impl Reader for StackCipherText { + fn read<V: Visitor>(self, visitor: V) -> Result<V::Value, Error> { + match self { + CipherText::Single(leaf) => visitor.sealed(leaf), + CipherText::Sequence(items) => visitor.sequence(items.into_iter()), + CipherText::Map(entries) => visitor.map(entries.into_iter()), + CipherText::None(marker) => visitor.absent(marker), + CipherText::EmptySequence(marker) => visitor.empty_sequence(marker), + CipherText::EmptyMap(marker) => visitor.empty_map(marker), + CipherText::Passthrough(value) => visitor.passthrough(value), + } + } +} +impl Reader for EqualityTerm { + fn read<V: Visitor>(self, visitor: V) -> Result<V::Value, Error> { + visitor.equality(self) + } +} +impl<O: MatchConfig> Reader for MatchTerm<O> { + fn read<V: Visitor>(self, visitor: V) -> Result<V::Value, Error> { + visitor.matching(self) + } +} +impl<T: cllw_ore::CllwOreEncrypt> Reader for OreTerm<T> { + fn read<V: Visitor>(self, visitor: V) -> Result<V::Value, Error> { + visitor.ore(self) + } +} +impl<T: cllw_ore::CllwOpeEncrypt> Reader for OpeTerm<T> { + fn read<V: Visitor>(self, visitor: V) -> Result<V::Value, Error> { + visitor.ope(self) + } +} +fn shape() -> Error { + Error::Other("destination does not support this encrypted output shape".into()) +} diff --git a/packages/stack-encrypt/tests/derive.rs b/packages/stack-encrypt/tests/derive.rs index 9ce52c65d..fc5ee3c9c 100644 --- a/packages/stack-encrypt/tests/derive.rs +++ b/packages/stack-encrypt/tests/derive.rs @@ -13,8 +13,7 @@ use common::{counting_cipher, stack_cipher}; use stack_encrypt::sem::{EqualityTerm, MatchTerm, OreTerm}; use stack_encrypt::target::{DecryptFrom, EncryptInto}; use stack_encrypt::{ - nonempty, DecryptField, DecryptInto, Decryptable, EncryptFrom, Error, KeysetCipher, Pending, - StackCipher, StackCipherText, + nonempty, DecryptField, DecryptInto, Decryptable, EncryptFrom, Error, StackCipherText, }; // --- Records: every field from one plaintext, under one context ------------- @@ -76,8 +75,8 @@ struct SearchableText { #[derive(EncryptFrom)] struct Pair(StackCipherText, EqualityTerm); -/// A record may declare `'__k` itself; the derive's keyset lifetime steps -/// aside rather than colliding with it. Compiling is the test. +/// A record may declare `'__k` itself; the declaration API no longer adds +/// a keyset lifetime. Compiling is the test. #[derive(EncryptFrom)] #[stash(plaintext = u32)] #[allow(dead_code)] @@ -87,6 +86,19 @@ struct Borrowed<'__k> { label: Option<&'__k str>, } +/// The declaration's source lifetime steps aside for the record's own, +/// including when the first fallback name is also taken. +#[derive(EncryptFrom)] +#[stash(plaintext = u32)] +#[allow(dead_code)] +struct BorrowedSource<'__source, '__source_> { + c: StackCipherText, + #[stash(default)] + label: Option<&'__source str>, + #[stash(default)] + other: Option<&'__source_ str>, +} + /// The record's own generics (and their bounds) are carried through, and the /// where clause makes `Tagged<T>` accept exactly `T` — the ORE term is typed /// by its source. A generic record's one-ciphertext check runs when the @@ -228,19 +240,18 @@ async fn decrypt_marks_the_field_when_the_types_cannot_choose() { #[derive(PartialEq)] struct OpaqueTerm(EqualityTerm); -impl<'k, S, K, Ctx> EncryptFrom<S, KeysetCipher<'k, K>, Ctx> for OpaqueTerm +impl<S> EncryptFrom<S> for OpaqueTerm where - EqualityTerm: EncryptFrom<S, KeysetCipher<'k, K>, Ctx>, + EqualityTerm: EncryptFrom<S>, { - fn encrypt_from<'a>( - source: &'a S, - cipher: &'a KeysetCipher<'k, K>, - context: Ctx, - ) -> Pending<'a, Self, K> + type Context = <EqualityTerm as EncryptFrom<S>>::Context; + fn encryption<'s, K: 'static>( + context: Self::Context, + ) -> stack_encrypt::Encryption<'s, S, Self, K> where - Self: 'a, + S: 's, { - EqualityTerm::encrypt_from(source, cipher, context).map(OpaqueTerm) + EqualityTerm::encryption(context).map(OpaqueTerm) } } @@ -292,16 +303,11 @@ impl Decryptable for Lying { const DECRYPTABLE: bool = true; } -impl<P, K, Ctx> DecryptField<P, StackCipher<K>, Ctx> for Lying { - fn decrypt_field<'a>( +impl<P, Ctx> DecryptField<P, Ctx> for Lying { + fn decryption_field<K: 'static>( self, - _cipher: &'a StackCipher<K>, _context: Ctx, - ) -> Option<Pending<'a, P, K>> - where - Self: 'a, - P: 'a, - { + ) -> Option<stack_encrypt::Decryption<P, K>> { None } } diff --git a/packages/stack-encrypt/tests/keysets.rs b/packages/stack-encrypt/tests/keysets.rs index 880413f71..f36238188 100644 --- a/packages/stack-encrypt/tests/keysets.rs +++ b/packages/stack-encrypt/tests/keysets.rs @@ -1,6 +1,7 @@ //! Keysets: one client, many keysets. Selection, caching, and the //! keyset-scoped versus client-scoped decrypt paths. +use stack_encrypt::DecryptFrom; use std::borrow::Cow; use std::collections::HashMap; use std::num::NonZeroUsize; @@ -8,7 +9,7 @@ use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; use std::sync::Mutex; use std::time::Duration; -use stack_encrypt::target::{DecryptInto, EncryptInto}; +use stack_encrypt::target::EncryptInto; use stack_encrypt::{nonempty, CipherText, Error, SealedValue, StackCipher, StackCipherText}; use stack_kms::{ DataKey, DataKeySource, DataKeyWithTag, FakeDataKeySource, GenerateKeyPayload, IdentifiedBy, diff --git a/packages/stack-encrypt/tests/target.rs b/packages/stack-encrypt/tests/target.rs index 7315a56ff..3f1ac08da 100644 --- a/packages/stack-encrypt/tests/target.rs +++ b/packages/stack-encrypt/tests/target.rs @@ -8,13 +8,15 @@ use std::cmp::Ordering; use std::sync::atomic::Ordering as AtomicOrdering; use stack_encrypt::sem::{EqualityTerm, MatchConfig, MatchOptions, MatchTerm, OreTerm}; -use stack_encrypt::target::{DecryptInto, EncryptFrom, EncryptInto, Pending, Request}; +use stack_encrypt::target::{ + CallerContext, DecryptFrom, DecryptInto, Decryption, EncryptFrom, EncryptInto, Encryption, + Pending, Request, +}; use stack_encrypt::{ - nonempty, Descriptor, EmptyError, Error, KeysetCipher, NonEmpty, StackCipher, StackCipherText, + nonempty, Descriptor, EmptyError, Error, NonEmpty, StackCipher, StackCipherText, }; use stack_kms::{FakeDataKeySource, IdentifiedBy, IndexKeySource}; use uuid::Uuid; -use vitaminc_prf::{BlockVisitor, IntoPrfContext, PrfContext, PrfValue}; mod common; use common::{counting_cipher, stack_cipher}; @@ -341,45 +343,22 @@ struct EncryptedAge { ob: OreTerm<u32>, } -// The record hands the caller's context to its leaves, so it needs what -// they need — inherited through per-field bounds, the same clauses the -// derive emits, rather than restated as a leaf-policy bound of the record's -// own (which would need editing every time the leaves' policy tightens). -impl<'k, K, Ctx> EncryptFrom<u32, KeysetCipher<'k, K>, Ctx> for EncryptedAge -where - Ctx: Clone, - StackCipherText: EncryptFrom<u32, KeysetCipher<'k, K>, Ctx>, - EqualityTerm: EncryptFrom<u32, KeysetCipher<'k, K>, Ctx>, - OreTerm<u32>: EncryptFrom<u32, KeysetCipher<'k, K>, Ctx>, -{ - fn encrypt_from<'a>( - source: &'a u32, - cipher: &'a KeysetCipher<'k, K>, - context: Ctx, - ) -> Pending<'a, Self, K> +impl EncryptFrom<u32> for EncryptedAge { + type Context = CallerContext; + fn encryption<'s, K: 'static>(context: Self::Context) -> Encryption<'s, u32, Self, K> where - Self: 'a, + u32: 's, { - StackCipherText::encrypt_from(source, cipher, context.clone()) - .zip(EqualityTerm::encrypt_from(source, cipher, context.clone())) - .zip(OreTerm::<u32>::encrypt_from(source, cipher, context)) + stack_encrypt::target::ciphertext(context.clone()) + .zip(stack_encrypt::target::equality(context.clone())) + .zip(stack_encrypt::target::ore(context)) .map(|((c, hm), ob)| Self { c, hm, ob }) } } - -/// The decrypt mirror a derive would emit: only the ciphertext field -/// participates — terms are one-way — and its context demand is inherited -/// through the field bound, as on the encrypt side. -impl<K, Ctx> DecryptInto<u32, StackCipher<K>, Ctx> for EncryptedAge -where - StackCipherText: DecryptInto<u32, StackCipher<K>, Ctx>, -{ - fn decrypt_into<'a>(self, cipher: &'a StackCipher<K>, context: Ctx) -> Pending<'a, u32, K> - where - Self: 'a, - u32: 'a, - { - self.c.decrypt_into(cipher, context) +impl DecryptInto<u32> for EncryptedAge { + type Context = CallerContext; + fn decryption<K: 'static>(self, context: Self::Context) -> Decryption<u32, K> { + stack_encrypt::target::open(self.c, context) } } @@ -466,111 +445,35 @@ async fn composite_record_terms_preserve_order() { assert_eq!(ten.ob.cmp(&twenty.ob), Ordering::Less); } -// --- A "third-party" term type ---------------------------------------------- -// -// Defined here using only the public extension surface: `EncryptFrom`, -// `Pending::ready`, and the cipher's public PRF. This is the proof that the -// set of SEM types is open — a separate crate can do exactly this. - -/// A prefix term: the PRF of the first `N` characters of a string, enabling -/// "starts with" queries on the first N chars. (Illustrative only.) +// A third-party output can wrap a supported operation, but cannot replace its +// cryptographic implementation. Prefix tokenization would need a core operation. #[derive(Debug, PartialEq, Eq)] -struct PrefixTerm<const N: usize>([u8; 32]); - -impl<'c, 'k, S, K, T, const N: usize> EncryptFrom<S, KeysetCipher<'k, K>, NonEmpty<T>> - for PrefixTerm<N> +struct StoredEquality([u8; 32]); +impl<S> EncryptFrom<S> for StoredEquality where - S: AsRef<str>, - T: IntoPrfContext<'c>, + EqualityTerm: EncryptFrom<S>, { - fn encrypt_from<'a>( - source: &'a S, - cipher: &'a KeysetCipher<'k, K>, - context: NonEmpty<T>, - ) -> Pending<'a, Self, K> + type Context = <EqualityTerm as EncryptFrom<S>>::Context; + fn encryption<'s, K: 'static>(context: Self::Context) -> Encryption<'s, S, Self, K> where - Self: 'a, + S: 's, { - // The `NonEmpty` is the proof the built-in leaves rely on too; then - // an own domain label, so this can never collide with a built-in - // term under the same context. - let context = context.into_prf_context().into_owned(); - let context = PrfContext::pae(&[ - b"example/prefix-term/v1", - &(N as u64).to_le_bytes(), - context.as_bytes(), - ]); - let prefix: String = source.as_ref().chars().take(N).collect(); - let term = prefix - .prf_visit_with_context(cipher.prf(), context, BlockVisitor) - .into_result() - .map(PrefixTerm) - .map_err(|e| Error::Other(Box::new(e))); - Pending::ready(cipher, term) + EqualityTerm::encryption(context).map(|term| Self(term.into_bytes())) } } - #[tokio::test] -async fn third_party_term_type_works_on_the_public_surface() { +async fn third_party_output_wraps_a_core_term() { let cipher = stack_cipher().await; let keyset = cipher.default_keyset(); - let generator = generator().await; - let generator = generator.default_keyset(); - - let stored: PrefixTerm<3> = "alice" + let stored: StoredEquality = "alice" .encrypt_into_with_context(&keyset, nonempty!("users/name")) .await .unwrap(); - let probe: PrefixTerm<3> = "alicia" - .encrypt_into_with_context(&generator, nonempty!("users/name")) - .await - .unwrap(); - let miss: PrefixTerm<3> = "bob" - .encrypt_into_with_context(&generator, nonempty!("users/name")) - .await - .unwrap(); - - assert_eq!(stored, probe, "same 3-char prefix, same term"); - assert_ne!(stored, miss); - - // And it composes into a record like any built-in term. - struct NameRecord { - c: StackCipherText, - prefix: PrefixTerm<3>, - } - - impl<'k, K, Ctx> EncryptFrom<String, KeysetCipher<'k, K>, Ctx> for NameRecord - where - Ctx: Clone, - StackCipherText: EncryptFrom<String, KeysetCipher<'k, K>, Ctx>, - PrefixTerm<3>: EncryptFrom<String, KeysetCipher<'k, K>, Ctx>, - { - fn encrypt_from<'a>( - source: &'a String, - cipher: &'a KeysetCipher<'k, K>, - context: Ctx, - ) -> Pending<'a, Self, K> - where - Self: 'a, - { - StackCipherText::encrypt_from(source, cipher, context.clone()) - .zip(PrefixTerm::<3>::encrypt_from(source, cipher, context)) - .map(|(c, prefix)| Self { c, prefix }) - } - } - - let record: NameRecord = "alice" - .to_string() + let canonical: EqualityTerm = "alice" .encrypt_into_with_context(&keyset, nonempty!("users/name")) .await .unwrap(); - assert_eq!(record.prefix, stored); - let name: String = record - .c - .decrypt_into(&cipher, nonempty!("users/name")) - .await - .unwrap(); - assert_eq!(name, "alice"); + assert_eq!(stored.0, canonical.into_bytes()); } // --- Guard rails -------------------------------------------------------------- diff --git a/packages/stack-encrypt/tests/transcode.rs b/packages/stack-encrypt/tests/transcode.rs new file mode 100644 index 000000000..b90bf5380 --- /dev/null +++ b/packages/stack-encrypt/tests/transcode.rs @@ -0,0 +1,329 @@ +//! A consumer outside the crate: typed EQL-shaped records, native readers, and +//! cross-opening through the canonical cipher path. No plaintext Serde fallback. +mod common; +use stack_encrypt::sem::EqualityTerm; +use stack_encrypt::target::transcode::{MapReader, Reader, SequenceReader, Transcode, Visitor}; +use stack_encrypt::target::{self, CallerContext, ExpectedContext}; +use stack_encrypt::{ + nonempty, Aad, AadPiece, Cipher, CipherText, DecryptField, DecryptInto, Decryptable, + Decryption, Encrypt, EncryptFrom, Encryption, Error, IntoAad, IntoPrfContext, MaybeEmpty, + NonEmpty, PrfContext, SealedValue, StackCipherText, +}; +use std::sync::atomic::Ordering; + +#[derive(Clone, Debug, PartialEq)] +struct Identifier { + table: String, + column: String, +} +impl Identifier { + fn email() -> NonEmpty<Self> { + NonEmpty::new(Self { + table: "users".into(), + column: "email".into(), + }) + .unwrap() + } +} +impl MaybeEmpty for Identifier { + fn is_empty(&self) -> bool { + self.table.is_empty() || self.column.is_empty() + } +} +impl<'a> IntoAad<'a> for Identifier { + fn into_aad(self) -> Aad<'a> { + (self.table, self.column).into_aad() + } + fn into_aad_piece(self) -> AadPiece<'a> { + (self.table, self.column).into_aad_piece() + } +} +impl<'a> IntoPrfContext<'a> for Identifier { + fn into_prf_context(self) -> PrfContext<'a> { + (self.table, self.column).into_prf_context() + } +} + +struct StoredLeaf(Vec<u8>); +struct LeafVisitor; +impl Visitor for LeafVisitor { + type Value = StoredLeaf; + fn sealed(self, leaf: SealedValue) -> Result<StoredLeaf, Error> { + Ok(StoredLeaf(leaf.to_bytes())) + } +} +impl Transcode for StoredLeaf { + type Visitor = LeafVisitor; + fn visitor() -> LeafVisitor { + LeafVisitor + } +} +impl<S: Encrypt + Clone> EncryptFrom<S> for StoredLeaf { + type Context = CallerContext; + fn encryption<'s, K: 'static>(context: Self::Context) -> Encryption<'s, S, Self, K> + where + S: 's, + { + target::ciphertext(context).transcode() + } +} +impl<P: stack_encrypt::Decrypt<'static> + 'static> DecryptInto<P> for StoredLeaf { + type Context = CallerContext; + fn decryption<K: 'static>(self, context: Self::Context) -> Decryption<P, K> { + match SealedValue::from_bytes(&self.0) { + Ok(leaf) => target::open(CipherText::Single(leaf), context), + Err(error) => Decryption::failed(Error::Other(Box::new(error))), + } + } +} +impl Decryptable for StoredLeaf { + const DECRYPTABLE: bool = true; +} +impl<P, Ctx> DecryptField<P, Ctx> for StoredLeaf +where + Self: DecryptInto<P>, + Ctx: Into<<Self as DecryptInto<P>>::Context>, +{ + fn decryption_field<K: 'static>(self, ctx: Ctx) -> Option<Decryption<P, K>> { + Some(self.decryption(ctx.into())) + } +} + +#[derive(EncryptFrom, DecryptInto)] +#[stash(plaintext = String)] +struct TextEq { + #[stash(context_field)] + i: Identifier, + c: StoredLeaf, + hm: EqualityTerm, + #[stash(default = 3)] + v: u8, +} + +#[tokio::test] +async fn stored_identifier_supplies_all_operations_and_checks_before_retrieval() { + let (cipher, generates, retrieves) = common::counting_cipher().await; + let keyset = cipher.default_keyset(); + let email = "alice@example.com".to_owned(); + let record: TextEq = keyset + .encrypt_as(&email, Identifier::email()) + .await + .unwrap(); + assert_eq!(record.i, Identifier::email().into_inner()); + assert_eq!(record.v, 3); + assert_eq!(generates.load(Ordering::SeqCst), 1); + let probe = keyset + .equality_term(email.clone(), Identifier::email()) + .await + .unwrap(); + assert_eq!(record.hm, probe); + // Storage envelope names c/hm add no extra AAD; the canonical path opens it. + let opened: String = cipher + .decrypt( + CipherText::Single(SealedValue::from_bytes(&record.c.0).unwrap()), + Identifier::email(), + ) + .await + .unwrap(); + assert_eq!(opened, email); + + // The reverse direction starts with canonical ciphertext. + let canonical = keyset + .encrypt(email.clone(), Identifier::email()) + .await + .unwrap(); + let record = TextEq { + i: Identifier::email().into_inner(), + c: canonical.read(LeafVisitor).unwrap(), + hm: probe, + v: 3, + }; + let opened: String = cipher + .decrypt_as(record, ExpectedContext::default()) + .await + .unwrap(); + assert_eq!(opened, email); + let before = retrieves.load(Ordering::SeqCst); + let record: TextEq = keyset + .encrypt_as(&email, Identifier::email()) + .await + .unwrap(); + let wrong = NonEmpty::new(Identifier { + table: "users".into(), + column: "other".into(), + }) + .unwrap(); + assert!(cipher + .decrypt_as::<String, _>(record, wrong.into()) + .await + .is_err()); + assert_eq!(retrieves.load(Ordering::SeqCst), before); + let mut record: TextEq = keyset + .encrypt_as(&email, Identifier::email()) + .await + .unwrap(); + record.i.column.clear(); + assert!(cipher + .decrypt_as::<String, _>(record, Default::default()) + .await + .is_err()); + assert_eq!(retrieves.load(Ordering::SeqCst), before); +} + +#[derive(Clone)] +struct WithoutSerde(String); +impl Encrypt for WithoutSerde { + fn encrypt_with_aad<'a, C: Cipher, A: IntoAad<'a>>( + self, + cipher: C, + aad: A, + ) -> Result<C::Ok, C::Error> { + self.0.encrypt_with_aad(cipher, aad) + } +} +#[tokio::test] +async fn ciphertext_uses_plaintexts_native_contract_without_serde() { + let cipher = common::stack_cipher().await; + let keyset = cipher.default_keyset(); + let result: StoredLeaf = keyset + .encrypt_as(&WithoutSerde("native".into()), nonempty!("value").into()) + .await + .unwrap(); + let opened: String = cipher + .decrypt( + CipherText::Single(SealedValue::from_bytes(&result.0).unwrap()), + nonempty!("value"), + ) + .await + .unwrap(); + assert_eq!(opened, "native"); + let text = String::from("borrowed"); + let result: StoredLeaf = keyset + .encrypt_as(&text.as_str(), nonempty!("value").into()) + .await + .unwrap(); + let opened: String = cipher + .decrypt( + CipherText::Single(SealedValue::from_bytes(&result.0).unwrap()), + nonempty!("value"), + ) + .await + .unwrap(); + assert_eq!(opened, text); + // A scalar destination must refuse a sequence rather than flatten or serialize it. + let result = keyset + .encrypt_as::<_, StoredLeaf>(&vec![1u32, 2], nonempty!("value").into()) + .await; + assert!(result.is_err()); +} + +// An illustrative final storage format. Each native child is consumed directly +// into its destination; no intermediate universal tree or byte buffer is built. +enum Stored { + Value(SealedValue), + List(Vec<Stored>), + Object(Vec<(String, Stored)>), + Absent(SealedValue), + EmptyList(SealedValue), + EmptyObject(SealedValue), + Metadata(stack_encrypt::BoxedPassthrough), +} +struct TreeVisitor; +impl Visitor for TreeVisitor { + type Value = Stored; + fn sealed(self, leaf: SealedValue) -> Result<Stored, Error> { + Ok(Stored::Value(leaf)) + } + fn sequence<R: SequenceReader>(self, mut reader: R) -> Result<Stored, Error> { + let mut output = Vec::with_capacity(reader.remaining()); + while let Some(child) = reader.next() { + output.push(child.read(TreeVisitor)?); + } + Ok(Stored::List(output)) + } + fn map<R: MapReader>(self, mut reader: R) -> Result<Stored, Error> { + let mut output = Vec::with_capacity(reader.remaining()); + while let Some((key, child)) = reader.next() { + output.push((key, child.read(TreeVisitor)?)); + } + Ok(Stored::Object(output)) + } + fn absent(self, marker: SealedValue) -> Result<Stored, Error> { + Ok(Stored::Absent(marker)) + } + fn empty_sequence(self, marker: SealedValue) -> Result<Stored, Error> { + Ok(Stored::EmptyList(marker)) + } + fn empty_map(self, marker: SealedValue) -> Result<Stored, Error> { + Ok(Stored::EmptyObject(marker)) + } + fn passthrough(self, value: stack_encrypt::BoxedPassthrough) -> Result<Stored, Error> { + Ok(Stored::Metadata(value)) + } +} +impl Stored { + fn native(self) -> StackCipherText { + match self { + Self::Value(v) => CipherText::Single(v), + Self::List(v) => CipherText::Sequence(v.into_iter().map(Self::native).collect()), + Self::Object(v) => { + CipherText::Map(v.into_iter().map(|(k, v)| (k, v.native())).collect()) + } + Self::Absent(v) => CipherText::None(v), + Self::EmptyList(v) => CipherText::EmptySequence(v), + Self::EmptyObject(v) => CipherText::EmptyMap(v), + Self::Metadata(v) => CipherText::Passthrough(v), + } + } +} +#[tokio::test] +async fn native_readers_preserve_map_keys_and_authenticated_markers() { + use std::collections::HashMap; + let (cipher, generates, retrieves) = common::counting_cipher().await; + let keyset = cipher.default_keyset(); + let value: HashMap<String, Vec<Option<String>>> = HashMap::from([ + ("entries".into(), vec![Some("secret".into()), None]), + ("empty".into(), vec![]), + ]); + let tree = keyset + .encrypt(value.clone(), nonempty!("document")) + .await + .unwrap(); + let stored = tree.read(TreeVisitor).unwrap(); + let opened: HashMap<String, Vec<Option<String>>> = cipher + .decrypt(stored.native(), nonempty!("document")) + .await + .unwrap(); + assert_eq!(opened, value); + assert_eq!(generates.load(Ordering::SeqCst), 1); + assert_eq!(retrieves.load(Ordering::SeqCst), 1); + // Transcoding keeps the authentication: changing a cryptographic map key fails. + let tree = keyset.encrypt(value, nonempty!("document")).await.unwrap(); + let Stored::Object(mut entries) = tree.read(TreeVisitor).unwrap() else { + panic!("object") + }; + entries[0].0 = "renamed".into(); + let opened: Result<HashMap<String, Vec<Option<String>>>, _> = cipher + .decrypt(Stored::Object(entries).native(), nonempty!("document")) + .await; + assert!(opened.is_err()); + let tree = keyset + .encrypt(HashMap::<String, String>::new(), nonempty!("document")) + .await + .unwrap(); + let Stored::EmptyObject(marker) = tree.read(TreeVisitor).unwrap() else { + panic!("empty map marker") + }; + // A marker is sealed data, not an unauthenticated empty container. + let opened: Result<HashMap<String, String>, _> = cipher + .decrypt(CipherText::EmptyMap(marker), nonempty!("wrong")) + .await; + assert!(opened.is_err()); + let stored = CipherText::Passthrough(Box::new(17u32) as stack_encrypt::BoxedPassthrough) + .read(TreeVisitor) + .unwrap(); + let Stored::Metadata(value) = stored else { + panic!("metadata") + }; + assert_eq!(*value.downcast::<u32>().unwrap(), 17); +} diff --git a/packages/stack-encrypt/tests/ui/bare_context.stderr b/packages/stack-encrypt/tests/ui/bare_context.stderr index d1f7f0ab9..ad2d31cb0 100644 --- a/packages/stack-encrypt/tests/ui/bare_context.stderr +++ b/packages/stack-encrypt/tests/ui/bare_context.stderr @@ -1,127 +1,139 @@ -error[E0277]: the trait bound `NonEmpty<_>: From<&str>` is not satisfied +error[E0277]: the trait bound `CallerContext: From<&str>` is not satisfied --> tests/ui/bare_context.rs:32:44 | 32 | .encrypt_into_with_context(cipher, "users/email") - | ------------------------- ^^^^^^^^^^^^^ the trait `From<&str>` is not implemented for `NonEmpty<_>` + | ------------------------- ^^^^^^^^^^^^^ the trait `From<&str>` is not implemented for `CallerContext` | | | required by a bound introduced by this call | = help: the following other types implement trait `From<T>`: - `NonEmpty<i128>` implements `From<i128>` - `NonEmpty<i16>` implements `From<i16>` - `NonEmpty<i32>` implements `From<i32>` - `NonEmpty<i64>` implements `From<i64>` - `NonEmpty<i8>` implements `From<i8>` - `NonEmpty<u128>` implements `From<u128>` - `NonEmpty<u16>` implements `From<u16>` - `NonEmpty<u32>` implements `From<u32>` + `CallerContext` implements `From<NonEmpty<T>>` + `CallerContext` implements `From<i128>` + `CallerContext` implements `From<i16>` + `CallerContext` implements `From<i32>` + `CallerContext` implements `From<i64>` + `CallerContext` implements `From<i8>` + `CallerContext` implements `From<u128>` + `CallerContext` implements `From<u16>` and $N others - = note: required for `&str` to implement `Into<NonEmpty<_>>` + = note: required for `&str` to implement `Into<CallerContext>` note: required by a bound in `encrypt_into_with_context` - --> src/target/mod.rs + --> src/target/operations.rs | - | fn encrypt_into_with_context<'a, T, C, N, Ctx>( + | fn encrypt_into_with_context<'a, T, K: 'static>( | ------------------------- required by a bound in this associated function ... - | Ctx: Into<NonEmpty<N>>, - | ^^^^^^^^^^^^^^^^^ required by this bound in `EncryptInto::encrypt_into_with_context` + | context: impl Into<T::Context>, + | ^^^^^^^^^^^^^^^^ required by this bound in `EncryptInto::encrypt_into_with_context` -error[E0277]: the trait bound `NonEmpty<_>: From<&str>` is not satisfied +error[E0277]: the trait bound `DeclaredContext: From<&str>` is not satisfied --> tests/ui/bare_context.rs:36:44 | 36 | .encrypt_into_with_context(cipher, "tenant/acme") - | ------------------------- ^^^^^^^^^^^^^ the trait `From<&str>` is not implemented for `NonEmpty<_>` + | ------------------------- ^^^^^^^^^^^^^ the trait `From<&str>` is not implemented for `DeclaredContext` | | | required by a bound introduced by this call | = help: the following other types implement trait `From<T>`: - `NonEmpty<i128>` implements `From<i128>` - `NonEmpty<i16>` implements `From<i16>` - `NonEmpty<i32>` implements `From<i32>` - `NonEmpty<i64>` implements `From<i64>` - `NonEmpty<i8>` implements `From<i8>` - `NonEmpty<u128>` implements `From<u128>` - `NonEmpty<u16>` implements `From<u16>` - `NonEmpty<u32>` implements `From<u32>` + `DeclaredContext` implements `From<()>` + `DeclaredContext` implements `From<CallerContext>` + `DeclaredContext` implements `From<NonEmpty<T>>` + `DeclaredContext` implements `From<i128>` + `DeclaredContext` implements `From<i16>` + `DeclaredContext` implements `From<i32>` + `DeclaredContext` implements `From<i64>` + `DeclaredContext` implements `From<i8>` and $N others - = note: required for `&str` to implement `Into<NonEmpty<_>>` + = note: required for `&str` to implement `Into<DeclaredContext>` note: required by a bound in `encrypt_into_with_context` - --> src/target/mod.rs + --> src/target/operations.rs | - | fn encrypt_into_with_context<'a, T, C, N, Ctx>( + | fn encrypt_into_with_context<'a, T, K: 'static>( | ------------------------- required by a bound in this associated function ... - | Ctx: Into<NonEmpty<N>>, - | ^^^^^^^^^^^^^^^^^ required by this bound in `EncryptInto::encrypt_into_with_context` + | context: impl Into<T::Context>, + | ^^^^^^^^^^^^^^^^ required by this bound in `EncryptInto::encrypt_into_with_context` -error[E0277]: the trait bound `NonEmpty<_>: From<&str>` is not satisfied +error[E0277]: the trait bound `CallerContext: From<&str>` is not satisfied --> tests/ui/bare_context.rs:40:44 | 40 | .encrypt_into_with_context(cipher, "users/age") - | ------------------------- ^^^^^^^^^^^ the trait `From<&str>` is not implemented for `NonEmpty<_>` + | ------------------------- ^^^^^^^^^^^ the trait `From<&str>` is not implemented for `CallerContext` | | | required by a bound introduced by this call | = help: the following other types implement trait `From<T>`: - `NonEmpty<i128>` implements `From<i128>` - `NonEmpty<i16>` implements `From<i16>` - `NonEmpty<i32>` implements `From<i32>` - `NonEmpty<i64>` implements `From<i64>` - `NonEmpty<i8>` implements `From<i8>` - `NonEmpty<u128>` implements `From<u128>` - `NonEmpty<u16>` implements `From<u16>` - `NonEmpty<u32>` implements `From<u32>` + `CallerContext` implements `From<NonEmpty<T>>` + `CallerContext` implements `From<i128>` + `CallerContext` implements `From<i16>` + `CallerContext` implements `From<i32>` + `CallerContext` implements `From<i64>` + `CallerContext` implements `From<i8>` + `CallerContext` implements `From<u128>` + `CallerContext` implements `From<u16>` and $N others - = note: required for `&str` to implement `Into<NonEmpty<_>>` + = note: required for `&str` to implement `Into<CallerContext>` note: required by a bound in `encrypt_into_with_context` - --> src/target/mod.rs + --> src/target/operations.rs | - | fn encrypt_into_with_context<'a, T, C, N, Ctx>( + | fn encrypt_into_with_context<'a, T, K: 'static>( | ------------------------- required by a bound in this associated function ... - | Ctx: Into<NonEmpty<N>>, - | ^^^^^^^^^^^^^^^^^ required by this bound in `EncryptInto::encrypt_into_with_context` + | context: impl Into<T::Context>, + | ^^^^^^^^^^^^^^^^ required by this bound in `EncryptInto::encrypt_into_with_context` -error[E0277]: the trait bound `NonEmpty<_>: From<&str>` is not satisfied +error[E0277]: the trait bound `DeclaredContext: From<&str>` is not satisfied --> tests/ui/bare_context.rs:50:62 | 50 | let _user = User::decrypt_from_with_context(row, cipher, "tenant/acme") - | ------------------------------- ^^^^^^^^^^^^^ the trait `From<&str>` is not implemented for `NonEmpty<_>` + | ------------------------------- ^^^^^^^^^^^^^ the trait `From<&str>` is not implemented for `DeclaredContext` | | | required by a bound introduced by this call | = help: the following other types implement trait `From<T>`: - `NonEmpty<i128>` implements `From<i128>` - `NonEmpty<i16>` implements `From<i16>` - `NonEmpty<i32>` implements `From<i32>` - `NonEmpty<i64>` implements `From<i64>` - `NonEmpty<i8>` implements `From<i8>` - `NonEmpty<u128>` implements `From<u128>` - `NonEmpty<u16>` implements `From<u16>` - `NonEmpty<u32>` implements `From<u32>` + `DeclaredContext` implements `From<()>` + `DeclaredContext` implements `From<CallerContext>` + `DeclaredContext` implements `From<NonEmpty<T>>` + `DeclaredContext` implements `From<i128>` + `DeclaredContext` implements `From<i16>` + `DeclaredContext` implements `From<i32>` + `DeclaredContext` implements `From<i64>` + `DeclaredContext` implements `From<i8>` and $N others - = note: required for `&str` to implement `Into<NonEmpty<_>>` + = note: required for `&str` to implement `Into<DeclaredContext>` note: required by a bound in `decrypt_from_with_context` - --> src/target/mod.rs + --> src/target/operations.rs | - | fn decrypt_from_with_context<'a, S, C, N, Ctx>( + | fn decrypt_from_with_context<'a, S, K: 'static>( | ------------------------- required by a bound in this associated function ... - | Ctx: Into<NonEmpty<N>>, - | ^^^^^^^^^^^^^^^^^ required by this bound in `DecryptFrom::decrypt_from_with_context` + | context: impl Into<S::Context>, + | ^^^^^^^^^^^^^^^^ required by this bound in `DecryptFrom::decrypt_from_with_context` -error[E0308]: mismatched types +error[E0277]: the trait bound `AeadContext: From<&str>` is not satisfied --> tests/ui/bare_context.rs:53:49 | 53 | let _age: u32 = sealed.decrypt_into(cipher, "users/age").await.unwrap(); - | ------------ ^^^^^^^^^^^ expected `NonEmpty<_>`, found `&str` + | ------------ ^^^^^^^^^^^ the trait `From<&str>` is not implemented for `AeadContext` | | - | arguments to this method are incorrect + | required by a bound introduced by this call | - = note: expected struct `NonEmpty<_>` - found reference `&'static str` -note: method defined here - --> src/target/mod.rs + = help: the following other types implement trait `From<T>`: + `AeadContext` implements `From<CallerContext>` + `AeadContext` implements `From<NonEmpty<T>>` + `AeadContext` implements `From<i128>` + `AeadContext` implements `From<i16>` + `AeadContext` implements `From<i32>` + `AeadContext` implements `From<i64>` + `AeadContext` implements `From<i8>` + `AeadContext` implements `From<u128>` + and $N others + = note: required for `&str` to implement `Into<AeadContext>` +note: required by a bound in `decrypt_into` + --> src/target/operations.rs | - | fn decrypt_into<'a>(self, cipher: &'a C, context: Ctx) -> C::Output<'a, P> - | ^^^^^^^^^^^^ + | fn decrypt_into<'a, P: 'static, K: 'static>( + | ------------ required by a bound in this associated function +... + | context: impl Into<<Self as DecryptInto<P>>::Context>, + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ required by this bound in `DecryptFrom::decrypt_into` diff --git a/packages/stack-encrypt/tests/ui/context_field_conflicts.rs b/packages/stack-encrypt/tests/ui/context_field_conflicts.rs new file mode 100644 index 000000000..f6d91dddd --- /dev/null +++ b/packages/stack-encrypt/tests/ui/context_field_conflicts.rs @@ -0,0 +1,23 @@ +use stack_encrypt::{EncryptFrom, StackCipherText}; +#[derive(EncryptFrom)] +struct Twice { + #[stash(context_field)] + one: String, + #[stash(context_field)] + two: String, + c: StackCipherText, +} +#[derive(EncryptFrom)] +struct Literal { + #[stash(context_field)] + i: String, + #[stash(context = "other")] + c: StackCipherText, +} +#[derive(EncryptFrom)] +struct Defaulted { + #[stash(context_field, default)] + i: String, + c: StackCipherText, +} +fn main() {} diff --git a/packages/stack-encrypt/tests/ui/context_field_conflicts.stderr b/packages/stack-encrypt/tests/ui/context_field_conflicts.stderr new file mode 100644 index 000000000..7a73b9665 --- /dev/null +++ b/packages/stack-encrypt/tests/ui/context_field_conflicts.stderr @@ -0,0 +1,17 @@ +error: a record has exactly one `context_field` + --> tests/ui/context_field_conflicts.rs:3:8 + | +3 | struct Twice { + | ^^^^^ + +error: `context_field` supplies the complete context; literal field contexts do not apply + --> tests/ui/context_field_conflicts.rs:11:8 + | +11 | struct Literal { + | ^^^^^^^ + +error: `context_field` is metadata and cannot also be derived or defaulted + --> tests/ui/context_field_conflicts.rs:20:8 + | +20 | i: String, + | ^^^^^^ diff --git a/packages/stack-encrypt/tests/ui/decrypt_field_not_decryptable.stderr b/packages/stack-encrypt/tests/ui/decrypt_field_not_decryptable.stderr index ba3e78061..9a4f0f6e0 100644 --- a/packages/stack-encrypt/tests/ui/decrypt_field_not_decryptable.stderr +++ b/packages/stack-encrypt/tests/ui/decrypt_field_not_decryptable.stderr @@ -1,3 +1,21 @@ +error[E0277]: the trait bound `Opaque: DecryptField<u32, CallerContext>` is not satisfied + --> tests/ui/decrypt_field_not_decryptable.rs:6:10 + | +6 | #[derive(DecryptInto)] + | ^^^^^^^^^^^ the trait `DecryptField<u32, CallerContext>` is not implemented for `Opaque` + | + = help: the following other types implement trait `DecryptField<P, Ctx>`: + `CipherText<SealedValue, Box<(dyn Any + Send + 'static)>>` implements `DecryptField<P, Ctx>` + `EqualityTerm` implements `DecryptField<P, Ctx>` + `MatchTerm<O>` implements `DecryptField<P, Ctx>` + `OpeTerm<T>` implements `DecryptField<P, Ctx>` + `Option<T>` implements `DecryptField<Option<P>, Ctx>` + `OreTerm<T>` implements `DecryptField<P, Ctx>` + `Rec` implements `DecryptField<__P, __Ctx>` + `Vec<T>` implements `DecryptField<Vec<P>, Ctx>` + = help: see issue #48214 + = note: this error originates in the derive macro `DecryptInto` (in Nightly builds, run with -Z macro-backtrace for more info) + error[E0277]: the trait bound `Opaque: Decryptable` is not satisfied --> tests/ui/decrypt_field_not_decryptable.rs:10:8 | @@ -9,6 +27,6 @@ error[E0277]: the trait bound `Opaque: Decryptable` is not satisfied EqualityTerm MatchTerm<O> OpeTerm<T> - Option<S> + Option<T> OreTerm<T> - Vec<S> + Vec<T> diff --git a/packages/stack-encrypt/tests/ui/drop_record.stderr b/packages/stack-encrypt/tests/ui/drop_record.stderr index 4985c12da..acf842fcd 100644 --- a/packages/stack-encrypt/tests/ui/drop_record.stderr +++ b/packages/stack-encrypt/tests/ui/drop_record.stderr @@ -1,10 +1,8 @@ error[E0509]: cannot move out of type `Rec`, which implements the `Drop` trait - --> tests/ui/drop_record.rs:7:23 - | -7 | #[derive(EncryptFrom, DecryptInto)] - | ^^^^^^^^^^^ - | | - | cannot move out of here - | move occurs because value has type `CipherText<SealedValue, Box<dyn Any + Send>>`, which does not implement the `Copy` trait - | - = note: this error originates in the derive macro `DecryptInto` (in Nightly builds, run with -Z macro-backtrace for more info) + --> tests/ui/drop_record.rs:11:5 + | +11 | c: StackCipherText, + | ^^^^^^^^^^^^^^^^^^ + | | + | cannot move out of here + | move occurs because value has type `CipherText<SealedValue, Box<dyn Any + Send>>`, which does not implement the `Copy` trait diff --git a/packages/stack-encrypt/tests/ui/execution_callback.rs b/packages/stack-encrypt/tests/ui/execution_callback.rs new file mode 100644 index 000000000..b2f396e25 --- /dev/null +++ b/packages/stack-encrypt/tests/ui/execution_callback.rs @@ -0,0 +1,7 @@ +use stack_encrypt::{Encryption, StackCipherText}; +fn main() { + // Target authors cannot install a callback that receives plaintext + cipher. + let _: Encryption<'_, u32, StackCipherText, ()> = Encryption { + build: Box::new(|_, cipher| stack_encrypt::Pending::failed(cipher, stack_encrypt::Error::Aead)), + }; +} diff --git a/packages/stack-encrypt/tests/ui/execution_callback.stderr b/packages/stack-encrypt/tests/ui/execution_callback.stderr new file mode 100644 index 000000000..2c291448a --- /dev/null +++ b/packages/stack-encrypt/tests/ui/execution_callback.stderr @@ -0,0 +1,7 @@ +error[E0451]: field `build` of struct `Encryption` is private + --> tests/ui/execution_callback.rs:5:9 + | +4 | let _: Encryption<'_, u32, StackCipherText, ()> = Encryption { + | ---------- in this type +5 | build: Box::new(|_, cipher| stack_encrypt::Pending::failed(cipher, stack_encrypt::Error::Aead)), + | ^^^^^ private field diff --git a/packages/stack-encrypt/tests/ui/leaf_without_context.stderr b/packages/stack-encrypt/tests/ui/leaf_without_context.stderr index e6b550d37..7e167bc2a 100644 --- a/packages/stack-encrypt/tests/ui/leaf_without_context.stderr +++ b/packages/stack-encrypt/tests/ui/leaf_without_context.stderr @@ -1,138 +1,91 @@ -error[E0277]: `EqualityTerm` is not an encrypted form of `&str` under a `()` context +error[E0277]: the trait bound `CallerContext: Default` is not satisfied --> tests/ui/leaf_without_context.rs:17:39 | 17 | let _term: EqualityTerm = "alice".encrypt_into(cipher).await.unwrap(); - | ^^^^^^^^^^^^ not `EncryptFrom<&str, _, ()>` + | ^^^^^^^^^^^^ the trait `Default` is not implemented for `CallerContext` | - = note: a leaf — a ciphertext or an index term — exists only under a `NonEmpty<_>` context: `encrypt_into` passes `()`, so use `encrypt_into_with_context(&cipher, context)`, or give the field a `context = ".."` of its own - = note: a context is anything vitaminc encodes (`&str`, `String`, bytes, integers, `Option`s and pairs of those), proven non-empty: `nonempty!("users/email")` for a literal, `NonEmpty::new(value)?` for a runtime value, a bare integer for an id - = help: the trait `EncryptFrom<&str, KeysetCipher<'_, FakeDataKeySource>, ()>` is not implemented for `EqualityTerm` - but trait `EncryptFrom<&str, KeysetCipher<'_, FakeDataKeySource>, NonEmpty<_>>` is implemented for it - = help: for that trait implementation, expected `NonEmpty<_>`, found `()` note: required by a bound in `encrypt_into` - --> src/target/mod.rs + --> src/target/operations.rs | - | fn encrypt_into<'a, T, C>(&'a self, cipher: &'a C) -> C::Output<'a, T> + | fn encrypt_into<'a, T, K: 'static>(&self, cipher: &'a KeysetCipher<'_, K>) -> Pending<'a, T, K> | ------------ required by a bound in this associated function ... - | T: EncryptFrom<Self, C, ()> + 'a, - | ^^^^^^^^^^^^^^^^^^^^^^^^ required by this bound in `EncryptInto::encrypt_into` + | T::Context: Default, + | ^^^^^^^ required by this bound in `EncryptInto::encrypt_into` -error[E0277]: `CipherText<SealedValue, Box<(dyn Any + Send + 'static)>>` is not an encrypted form of `u32` under a `()` context +error[E0277]: the trait bound `CallerContext: Default` is not satisfied --> tests/ui/leaf_without_context.rs:18:39 | 18 | let _record: EncryptedAge = 42u32.encrypt_into(cipher).await.unwrap(); - | ^^^^^^^^^^^^ not `EncryptFrom<u32, _, ()>` + | ^^^^^^^^^^^^ the trait `Default` is not implemented for `CallerContext` | - = note: a leaf — a ciphertext or an index term — exists only under a `NonEmpty<_>` context: `encrypt_into` passes `()`, so use `encrypt_into_with_context(&cipher, context)`, or give the field a `context = ".."` of its own - = note: a context is anything vitaminc encodes (`&str`, `String`, bytes, integers, `Option`s and pairs of those), proven non-empty: `nonempty!("users/email")` for a literal, `NonEmpty::new(value)?` for a runtime value, a bare integer for an id - = help: the trait `EncryptFrom<u32, KeysetCipher<'_, FakeDataKeySource>, ()>` is not implemented for `CipherText<SealedValue, Box<(dyn Any + Send + 'static)>>` - but trait `EncryptFrom<u32, KeysetCipher<'_, FakeDataKeySource>, NonEmpty<_>>` is implemented for it - = help: for that trait implementation, expected `NonEmpty<_>`, found `()` -note: required for `EncryptedAge` to implement `EncryptFrom<u32, KeysetCipher<'_, FakeDataKeySource>, ()>` - --> tests/ui/leaf_without_context.rs:9:10 - | - 9 | #[derive(EncryptFrom, DecryptInto)] - | ^^^^^^^^^^^ unsatisfied trait bound introduced in this `derive` macro -10 | #[stash(plaintext = u32)] -11 | struct EncryptedAge { - | ^^^^^^^^^^^^ note: required by a bound in `encrypt_into` - --> src/target/mod.rs + --> src/target/operations.rs | - | fn encrypt_into<'a, T, C>(&'a self, cipher: &'a C) -> C::Output<'a, T> + | fn encrypt_into<'a, T, K: 'static>(&self, cipher: &'a KeysetCipher<'_, K>) -> Pending<'a, T, K> | ------------ required by a bound in this associated function ... - | T: EncryptFrom<Self, C, ()> + 'a, - | ^^^^^^^^^^^^^^^^^^^^^^^^ required by this bound in `EncryptInto::encrypt_into` - = note: this error originates in the derive macro `EncryptFrom` (in Nightly builds, run with -Z macro-backtrace for more info) + | T::Context: Default, + | ^^^^^^^ required by this bound in `EncryptInto::encrypt_into` -error[E0277]: `EqualityTerm` is not an encrypted form of `u32` under a `()` context - --> tests/ui/leaf_without_context.rs:18:39 - | -18 | let _record: EncryptedAge = 42u32.encrypt_into(cipher).await.unwrap(); - | ^^^^^^^^^^^^ not `EncryptFrom<u32, _, ()>` - | - = note: a leaf — a ciphertext or an index term — exists only under a `NonEmpty<_>` context: `encrypt_into` passes `()`, so use `encrypt_into_with_context(&cipher, context)`, or give the field a `context = ".."` of its own - = note: a context is anything vitaminc encodes (`&str`, `String`, bytes, integers, `Option`s and pairs of those), proven non-empty: `nonempty!("users/email")` for a literal, `NonEmpty::new(value)?` for a runtime value, a bare integer for an id - = help: the trait `EncryptFrom<u32, KeysetCipher<'_, FakeDataKeySource>, ()>` is not implemented for `EqualityTerm` - but trait `EncryptFrom<u32, KeysetCipher<'_, FakeDataKeySource>, NonEmpty<_>>` is implemented for it - = help: for that trait implementation, expected `NonEmpty<_>`, found `()` -note: required for `EncryptedAge` to implement `EncryptFrom<u32, KeysetCipher<'_, FakeDataKeySource>, ()>` - --> tests/ui/leaf_without_context.rs:9:10 - | - 9 | #[derive(EncryptFrom, DecryptInto)] - | ^^^^^^^^^^^ unsatisfied trait bound introduced in this `derive` macro -10 | #[stash(plaintext = u32)] -11 | struct EncryptedAge { - | ^^^^^^^^^^^^ -note: required by a bound in `encrypt_into` - --> src/target/mod.rs - | - | fn encrypt_into<'a, T, C>(&'a self, cipher: &'a C) -> C::Output<'a, T> - | ------------ required by a bound in this associated function -... - | T: EncryptFrom<Self, C, ()> + 'a, - | ^^^^^^^^^^^^^^^^^^^^^^^^ required by this bound in `EncryptInto::encrypt_into` - = note: this error originates in the derive macro `EncryptFrom` (in Nightly builds, run with -Z macro-backtrace for more info) - -error[E0277]: `CipherText<SealedValue, Box<dyn Any + Send>>` is not an encrypted form of `u32` under a `()` context +error[E0277]: the trait bound `AeadContext: Default` is not satisfied --> tests/ui/leaf_without_context.rs:19:41 | 19 | let _column: Vec<StackCipherText> = vec![1u32].encrypt_into(cipher).await.unwrap(); | ^^^^^^^^^^ ------------ required by a bound introduced by this call | | - | not `EncryptFrom<u32, _, ()>` + | the trait `Default` is not implemented for `AeadContext` | - = note: a leaf — a ciphertext or an index term — exists only under a `NonEmpty<_>` context: `encrypt_into` passes `()`, so use `encrypt_into_with_context(&cipher, context)`, or give the field a `context = ".."` of its own - = note: a context is anything vitaminc encodes (`&str`, `String`, bytes, integers, `Option`s and pairs of those), proven non-empty: `nonempty!("users/email")` for a literal, `NonEmpty::new(value)?` for a runtime value, a bare integer for an id - = help: the trait `EncryptFrom<u32, KeysetCipher<'_, FakeDataKeySource>, ()>` is not implemented for `CipherText<SealedValue, Box<dyn Any + Send>>` - but trait `EncryptFrom<u32, KeysetCipher<'_, FakeDataKeySource>, NonEmpty<_>>` is implemented for `CipherText<SealedValue, Box<(dyn Any + Send + 'static)>>` - = help: for that trait implementation, expected `NonEmpty<_>`, found `()` - = note: required for `Vec<CipherText<SealedValue, Box<dyn Any + Send>>>` to implement `EncryptFrom<Vec<u32>, KeysetCipher<'_, FakeDataKeySource>, ()>` note: required by a bound in `encrypt_into` - --> src/target/mod.rs + --> src/target/operations.rs | - | fn encrypt_into<'a, T, C>(&'a self, cipher: &'a C) -> C::Output<'a, T> + | fn encrypt_into<'a, T, K: 'static>(&self, cipher: &'a KeysetCipher<'_, K>) -> Pending<'a, T, K> | ------------ required by a bound in this associated function ... - | T: EncryptFrom<Self, C, ()> + 'a, - | ^^^^^^^^^^^^^^^^^^^^^^^^ required by this bound in `EncryptInto::encrypt_into` + | T::Context: Default, + | ^^^^^^^ required by this bound in `EncryptInto::encrypt_into` -error[E0277]: `EncryptedAge` does not decrypt to `u32` under a `()` context +error[E0277]: the trait bound `CallerContext: Default` is not satisfied --> tests/ui/leaf_without_context.rs:23:34 | 23 | let _age = u32::decrypt_from(record, cipher).await.unwrap(); - | ----------------- ^^^^^^ not `DecryptInto<u32, _, ()>` + | ----------------- ^^^^^^ the trait `Default` is not implemented for `CallerContext` | | | required by a bound introduced by this call | - = help: the trait `DecryptInto<u32, _, ()>` is not implemented for `EncryptedAge` - = note: a leaf decrypts only under a `NonEmpty<_>` context — the one it was encrypted under (`decrypt_into(&cipher, context)` / `decrypt_from_with_context`); an output whose fields carry their own opens under `()` (`decrypt_from`) as well - = help: the following other types implement trait `DecryptInto<P, C, Ctx>`: - `EncryptedAge` implements `DecryptInto<u32, StackCipher<__K>, ()>` - `EncryptedAge` implements `DecryptInto<u32, StackCipher<__K>, NonEmpty<__T>>` note: required by a bound in `decrypt_from` - --> src/target/mod.rs + --> src/target/operations.rs | - | fn decrypt_from<'a, S, C>(source: S, cipher: &'a C) -> C::Output<'a, Self> + | fn decrypt_from<'a, S, K: 'static>( | ------------ required by a bound in this associated function ... - | S: DecryptInto<Self, C, ()> + 'a, - | ^^^^^^^^^^^^^^^^^^^^^^^^ required by this bound in `DecryptFrom::decrypt_from` + | S::Context: Default, + | ^^^^^^^ required by this bound in `DecryptFrom::decrypt_from` -error[E0308]: mismatched types +error[E0277]: the trait bound `CallerContext: From<()>` is not satisfied --> tests/ui/leaf_without_context.rs:27:49 | 27 | let _age: u32 = record.decrypt_into(cipher, ()).await.unwrap(); - | ------------ ^^ expected `NonEmpty<_>`, found `()` + | ------------ ^^ the trait `From<()>` is not implemented for `CallerContext` | | - | arguments to this method are incorrect - | - = note: expected struct `NonEmpty<_>` - found unit type `()` -note: method defined here - --> src/target/mod.rs - | - | fn decrypt_into<'a>(self, cipher: &'a C, context: Ctx) -> C::Output<'a, P> - | ^^^^^^^^^^^^ + | required by a bound introduced by this call + | + = help: the following other types implement trait `From<T>`: + `CallerContext` implements `From<NonEmpty<T>>` + `CallerContext` implements `From<i128>` + `CallerContext` implements `From<i16>` + `CallerContext` implements `From<i32>` + `CallerContext` implements `From<i64>` + `CallerContext` implements `From<i8>` + `CallerContext` implements `From<u128>` + `CallerContext` implements `From<u16>` + and $N others + = note: required for `()` to implement `Into<CallerContext>` +note: required by a bound in `decrypt_into` + --> src/target/operations.rs + | + | fn decrypt_into<'a, P: 'static, K: 'static>( + | ------------ required by a bound in this associated function +... + | context: impl Into<<Self as DecryptInto<P>>::Context>, + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ required by this bound in `DecryptFrom::decrypt_into` diff --git a/packages/stack-encrypt/tests/ui/nested_leaf_without_context.stderr b/packages/stack-encrypt/tests/ui/nested_leaf_without_context.stderr index 6d8b2aa0c..c568c1025 100644 --- a/packages/stack-encrypt/tests/ui/nested_leaf_without_context.stderr +++ b/packages/stack-encrypt/tests/ui/nested_leaf_without_context.stderr @@ -1,24 +1,39 @@ -error[E0277]: `CipherText<SealedValue, Box<(dyn Any + Send + 'static)>>` is not an encrypted form of `_` under a `()` context - --> tests/ui/nested_leaf_without_context.rs:15:12 +error[E0277]: the trait bound `AeadContext: From<DeclaredContext>` is not satisfied + --> tests/ui/nested_leaf_without_context.rs:11:10 | +11 | #[derive(EncryptFrom, DecryptInto)] + | ^^^^^^^^^^^ the trait `From<DeclaredContext>` is not implemented for `AeadContext` +... 15 | email: StackCipherText, - | ^^^^^^^^^^^^^^^ not `EncryptFrom<_, _, ()>` + | --------------- required by a bound introduced by this call | - = note: a leaf — a ciphertext or an index term — exists only under a `NonEmpty<_>` context: `encrypt_into` passes `()`, so use `encrypt_into_with_context(&cipher, context)`, or give the field a `context = ".."` of its own - = note: a context is anything vitaminc encodes (`&str`, `String`, bytes, integers, `Option`s and pairs of those), proven non-empty: `nonempty!("users/email")` for a literal, `NonEmpty::new(value)?` for a runtime value, a bare integer for an id - = help: the trait `EncryptFrom<_, KeysetCipher<'__k, __K>, ()>` is not implemented for `CipherText<SealedValue, Box<(dyn Any + Send + 'static)>>` - but trait `EncryptFrom<_, KeysetCipher<'_, __K>, NonEmpty<_>>` is implemented for it - = help: for that trait implementation, expected `NonEmpty<_>`, found `()` + = help: the following other types implement trait `From<T>`: + `AeadContext` implements `From<CallerContext>` + `AeadContext` implements `From<NonEmpty<T>>` + `AeadContext` implements `From<i128>` + `AeadContext` implements `From<i16>` + `AeadContext` implements `From<i32>` + `AeadContext` implements `From<i64>` + `AeadContext` implements `From<i8>` + `AeadContext` implements `From<u128>` + and $N others + = note: required for `DeclaredContext` to implement `Into<AeadContext>` -error[E0277]: `CipherText<SealedValue, Box<(dyn Any + Send + 'static)>>` cannot be a field of an automatically decrypted record +error[E0277]: the trait bound `AeadContext: From<DeclaredContext>` is not satisfied --> tests/ui/nested_leaf_without_context.rs:15:12 | 15 | email: StackCipherText, - | ^^^^^^^^^^^^^^^ no `DecryptField<_, ..>` implementation + | ^^^^^^^^^^^^^^^ the trait `From<DeclaredContext>` is not implemented for `AeadContext` | - = note: a term-only bundle — `#[derive(EncryptFrom)]` alone, nothing to open — has no `DecryptField`: mark the outer record's real ciphertext `#[stash(decrypt)]` so only the marked fields are considered - = note: a hand-written term type implements `DecryptField` (returning `None`) alongside `Decryptable`; a hand-written ciphertext type wraps its `DecryptInto` - = help: the trait `DecryptInto<_, StackCipher<__K>, ()>` is not implemented for `CipherText<SealedValue, Box<(dyn Any + Send + 'static)>>` - but trait `DecryptInto<_, StackCipher<__K>, NonEmpty<_>>` is implemented for it - = help: for that trait implementation, expected `NonEmpty<_>`, found `()` - = note: required for `CipherText<SealedValue, Box<(dyn Any + Send + 'static)>>` to implement `DecryptField<_, StackCipher<__K>, ()>` + = help: the following other types implement trait `From<T>`: + `AeadContext` implements `From<CallerContext>` + `AeadContext` implements `From<NonEmpty<T>>` + `AeadContext` implements `From<i128>` + `AeadContext` implements `From<i16>` + `AeadContext` implements `From<i32>` + `AeadContext` implements `From<i64>` + `AeadContext` implements `From<i8>` + `AeadContext` implements `From<u128>` + and $N others + = note: required for `DeclaredContext` to implement `Into<AeadContext>` + = note: required for `CipherText<SealedValue, Box<(dyn Any + Send + 'static)>>` to implement `DecryptField<_, DeclaredContext>` diff --git a/packages/stack-encrypt/tests/ui/override_encryption.rs b/packages/stack-encrypt/tests/ui/override_encryption.rs new file mode 100644 index 000000000..a3710319e --- /dev/null +++ b/packages/stack-encrypt/tests/ui/override_encryption.rs @@ -0,0 +1,10 @@ +use stack_encrypt::{EncryptFrom, KeysetCipher, Pending}; +struct Target; +impl EncryptFrom<u32> for Target { + type Context = (); + // The old extension is deliberately not part of the declaration trait. + fn encrypt_from<'a,K>(_: &u32, cipher: &'a KeysetCipher<'_,K>, _:())->Pending<'a,Self,K> { + Pending::failed(cipher, stack_encrypt::Error::Aead) + } +} +fn main() {} diff --git a/packages/stack-encrypt/tests/ui/override_encryption.stderr b/packages/stack-encrypt/tests/ui/override_encryption.stderr new file mode 100644 index 000000000..73d0beafb --- /dev/null +++ b/packages/stack-encrypt/tests/ui/override_encryption.stderr @@ -0,0 +1,18 @@ +error[E0407]: method `encrypt_from` is not a member of trait `EncryptFrom` + --> tests/ui/override_encryption.rs:6:5 + | +6 | fn encrypt_from<'a,K>(_: &u32, cipher: &'a KeysetCipher<'_,K>, _:())->Pending<'a,Self,K> { + | ^ ------------ help: there is an associated function with a similar name: `encryption` + | _____| + | | +7 | | Pending::failed(cipher, stack_encrypt::Error::Aead) +8 | | } + | |_____^ not a member of trait `EncryptFrom` + +error[E0046]: not all trait items implemented, missing: `encryption` + --> tests/ui/override_encryption.rs:3:1 + | +3 | impl EncryptFrom<u32> for Target { + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ missing `encryption` in implementation + | + = help: implement the missing item: `fn encryption<K>(_: <Self as EncryptFrom<u32>>::Context) -> Encryption<'s, u32, Self, K> { todo!() }` diff --git a/packages/stack-encrypt/tests/ui/pass/query_only_and_borrowed.rs b/packages/stack-encrypt/tests/ui/pass/query_only_and_borrowed.rs new file mode 100644 index 000000000..3ff7a83e1 --- /dev/null +++ b/packages/stack-encrypt/tests/ui/pass/query_only_and_borrowed.rs @@ -0,0 +1,23 @@ +use stack_encrypt::{EncryptFrom, StackCipher, StackCipherText, nonempty}; +use stack_encrypt::sem::EqualityTerm; +use stack_kms::FakeDataKeySource; + +#[derive(EncryptFrom)] +struct Probe { hm: EqualityTerm } + +// No Encrypt bound: producing a term requires only the PRF capability. +fn query<S: vitaminc_prf::PrfValue + Clone>(cipher:&StackCipher<FakeDataKeySource>, value:&S) { + let keyset=cipher.default_keyset(); + let _ = keyset.encrypt_as::<_,Probe>(value,nonempty!("column").into()); +} +#[derive(EncryptFrom)] +struct Stored { c:StackCipherText } +async fn borrowed(cipher:&StackCipher<FakeDataKeySource>) { + let keyset=cipher.default_keyset(); + let pending = { + let text=String::from("borrowed"); + keyset.encrypt_as::<_,Stored>(&text.as_str(),nonempty!("column").into()) + }; + let _ = pending.await.unwrap(); +} +fn main() {} diff --git a/packages/stack-encrypt/tests/ui/plaintext_needs_encrypt.rs b/packages/stack-encrypt/tests/ui/plaintext_needs_encrypt.rs new file mode 100644 index 000000000..89c5d97ff --- /dev/null +++ b/packages/stack-encrypt/tests/ui/plaintext_needs_encrypt.rs @@ -0,0 +1,11 @@ +use stack_encrypt::{EncryptFrom, StackCipherText, StackCipher, nonempty}; +use stack_kms::FakeDataKeySource; +#[derive(Clone, serde::Serialize)] +struct SerdeOnly { value: String } +#[derive(EncryptFrom)] +struct Target { c: StackCipherText } +fn wrong(cipher:&StackCipher<FakeDataKeySource>) { + let keyset=cipher.default_keyset(); + let _=keyset.encrypt_as::<_,Target>(&SerdeOnly {value:"x".into()},nonempty!("column").into()); +} +fn main() {} diff --git a/packages/stack-encrypt/tests/ui/plaintext_needs_encrypt.stderr b/packages/stack-encrypt/tests/ui/plaintext_needs_encrypt.stderr new file mode 100644 index 000000000..dbea37817 --- /dev/null +++ b/packages/stack-encrypt/tests/ui/plaintext_needs_encrypt.stderr @@ -0,0 +1,29 @@ +error[E0277]: the trait bound `SerdeOnly: Encrypt` is not satisfied + --> tests/ui/plaintext_needs_encrypt.rs:9:33 + | +9 | let _=keyset.encrypt_as::<_,Target>(&SerdeOnly {value:"x".into()},nonempty!("column").into()); + | ---------- ^^^^^^ the trait `Encrypt` is not implemented for `SerdeOnly` + | | + | required by a bound introduced by this call + | + = help: the following other types implement trait `Encrypt`: + &str + ContextTag<Tag, T> + Element<T> + HashMap<K, T> + Vec<T> + Vec<u8> + [u8; N] + std::option::Option<T> + and $N others + = note: required for `CipherText<SealedValue, Box<(dyn Any + Send + 'static)>>` to implement `EncryptFrom<SerdeOnly>` + = note: 1 redundant requirement hidden + = note: required for `Target` to implement `EncryptFrom<SerdeOnly>` +note: required by a bound in `target::operations::<impl KeysetCipher<'_, K>>::encrypt_as` + --> src/target/operations.rs + | + | pub fn encrypt_as<'a, S, T>(&'a self, source: &S, context: T::Context) -> Pending<'a, T, K> + | ---------- required by a bound in this associated function + | where + | T: EncryptFrom<S>, + | ^^^^^^^^^^^^^^ required by this bound in `target::operations::<impl KeysetCipher<'_, K>>::encrypt_as` diff --git a/packages/stack-encrypt/tests/ui/struct_field_missing.stderr b/packages/stack-encrypt/tests/ui/struct_field_missing.stderr index 97b8bd5bb..0f741b9f9 100644 --- a/packages/stack-encrypt/tests/ui/struct_field_missing.stderr +++ b/packages/stack-encrypt/tests/ui/struct_field_missing.stderr @@ -1,4 +1,4 @@ -error[E0609]: no field `nickname` on type `&'__a User` +error[E0609]: no field `nickname` on type `&User` --> tests/ui/struct_field_missing.rs:13:5 | 13 | nickname: StackCipherText, diff --git a/packages/stack-encrypt/tests/ui/wrong_target_context.rs b/packages/stack-encrypt/tests/ui/wrong_target_context.rs new file mode 100644 index 000000000..bf92d4763 --- /dev/null +++ b/packages/stack-encrypt/tests/ui/wrong_target_context.rs @@ -0,0 +1,14 @@ +use stack_encrypt::{EncryptFrom, StackCipherText, StackCipher, nonempty}; +use stack_kms::FakeDataKeySource; +#[derive(EncryptFrom)] +#[stash(plaintext = String)] +struct Target { + #[stash(context_field)] + identifier: u32, + c: StackCipherText, +} +fn wrong(cipher: &StackCipher<FakeDataKeySource>) { + let keyset = cipher.default_keyset(); + let _ = keyset.encrypt_as::<_,Target>(&"value".to_owned(), nonempty!("wrong type")); +} +fn main() {} diff --git a/packages/stack-encrypt/tests/ui/wrong_target_context.stderr b/packages/stack-encrypt/tests/ui/wrong_target_context.stderr new file mode 100644 index 000000000..106371dc0 --- /dev/null +++ b/packages/stack-encrypt/tests/ui/wrong_target_context.stderr @@ -0,0 +1,9 @@ +error[E0308]: mismatched types + --> tests/ui/wrong_target_context.rs:12:64 + | +12 | let _ = keyset.encrypt_as::<_,Target>(&"value".to_owned(), nonempty!("wrong type")); + | ^^^^^^^^^^^^^^^^^^^^^^^ expected `NonEmpty<u32>`, found `NonEmpty<&str>` + | + = note: expected struct `NonEmpty<u32>` + found struct `NonEmpty<&'static str>` + = note: this error originates in the macro `nonempty` (in Nightly builds, run with -Z macro-backtrace for more info) From e9f3d7ecbc61562d99ab7d2de8d19beeb97e85fa Mon Sep 17 00:00:00 2001 From: Dan Draper <dan@cipherstash.com> Date: Thu, 17 Sep 2026 14:29:41 -0400 Subject: [PATCH 553/686] refactor(stack-encrypt): a declaration's errors are typed, its contexts have a module, and its surface is documented Review of ADR-0003's implementation, addressed: - `Error::UnsupportedShape` and `Error::ContextMismatch { stored }` replace the two `Error::Other(String)`s a caller would want to match on. The mismatch carries the stored context's descriptor, as ZeroKMS logs it. - The four context types move to `target::context`; `target` re-exports every item by name instead of a glob. - Every public item in `target::operations` and `target::transcode` has a doc comment, with `# Errors` on the fallible ones. `Encryption` and `Decryption` are `Debug` and `#[must_use]`. - `ore`/`ope` come from the same macro as `equality`/`matching`; the term types' `DecryptField` pass-over impls come from `index_term!` next to their `Decryptable` ones; `Decryption::all` and a private `optional` hold the one copy of the `Vec`/`Option` shape; `open_in` holds the one copy of scoping, for both `decrypt_as` and `DecryptFrom::decrypt_into`. - `Encryption::project`'s doc, and ADR-0003, say what the selector guarantees: it cannot reach the cipher. Not that it cannot make bytes up. - ADR-0001 no longer calls ADR-0003 "pending implementation"; `Pending::failed`'s doc no longer says the derive reaches for it; the derive names its per-field descriptions `operation`, not `plan`, and its field-attribute error lists `context_field`. - Tests: the six-scenario transcode test is five, each assertion says what it checks, the shape and mismatch cases match their variants, and a `Context = ()` target round-trips end to end. --- packages/stack-encrypt-derive/src/attrs.rs | 4 +- packages/stack-encrypt-derive/src/decrypt.rs | 14 +- packages/stack-encrypt-derive/src/encrypt.rs | 8 +- packages/stack-encrypt-derive/src/shape.rs | 8 +- ...1-context-optional-cipher-directed-path.md | 4 +- ...tive-targets-and-ciphertext-transcoding.md | 4 + packages/stack-encrypt/src/cipher.rs | 14 + packages/stack-encrypt/src/sem/mod.rs | 9 +- packages/stack-encrypt/src/target/context.rs | 217 ++++++++ packages/stack-encrypt/src/target/mod.rs | 9 +- .../stack-encrypt/src/target/operations.rs | 520 ++++++++---------- packages/stack-encrypt/src/target/pending.rs | 13 +- .../stack-encrypt/src/target/transcode.rs | 142 ++++- packages/stack-encrypt/tests/target.rs | 6 +- packages/stack-encrypt/tests/transcode.rs | 205 +++++-- 15 files changed, 786 insertions(+), 391 deletions(-) create mode 100644 packages/stack-encrypt/src/target/context.rs diff --git a/packages/stack-encrypt-derive/src/attrs.rs b/packages/stack-encrypt-derive/src/attrs.rs index ec41f5a15..339e460fe 100644 --- a/packages/stack-encrypt-derive/src/attrs.rs +++ b/packages/stack-encrypt-derive/src/attrs.rs @@ -249,8 +249,8 @@ impl FieldAttrs { return Ok(()); } Err(meta.error( - "unsupported field attribute; expected `context = \"...\"`, `from = field`, \ - `default`, `default = expr`, `decrypt` or `nested`", + "unsupported field attribute; expected `context_field`, `context = \"...\"`, \ + `from = field`, `default`, `default = expr`, `decrypt` or `nested`", )) })?; } diff --git a/packages/stack-encrypt-derive/src/decrypt.rs b/packages/stack-encrypt-derive/src/decrypt.rs index 4e0de2def..db7958a7f 100644 --- a/packages/stack-encrypt-derive/src/decrypt.rs +++ b/packages/stack-encrypt-derive/src/decrypt.rs @@ -50,7 +50,7 @@ pub(crate) fn derive(input: DeriveInput) -> Result<TokenStream> { .make_where_clause() .predicates .push(parse_quote!(Self: 'static)); - let mut plans = Vec::new(); + let mut operations = Vec::new(); for (index, (from, fields)) in groups.iter().enumerate() { let output = if from.is_some() { parse_quote!(_) @@ -71,7 +71,7 @@ pub(crate) fn derive(input: DeriveInput) -> Result<TokenStream> { } } } - let plan = if explicit { + let operation = if explicit { let field = fields[0]; let ty = &field.ty; let member = &field.member; @@ -97,21 +97,21 @@ pub(crate) fn derive(input: DeriveInput) -> Result<TokenStream> { let rest = &locals[1..]; quote!({ #(#bindings)* #first #(.or(#rest))* .unwrap_or_else(|| #krate::target::Decryption::failed(#krate::Error::NotOpened)) }) }; - plans.push(( - plan, + operations.push(( + operation, Ident::new(&format!("__group_{index}"), Span::call_site()), )); } let body = if groups[0].0.is_none() { - plans.remove(0).0 + operations.remove(0).0 } else { let literal = struct_literal_path(&plaintext)?; let assignments = groups .iter() - .zip(&plans) + .zip(&operations) .map(|((from, _), (_, local))| quote!(#from: #local)); let output = quote!(#literal { #(#assignments),* }); - zip(plans, output) + zip(operations, output) }; let stored = record.context_field().map(|field| { let member = &field.member; diff --git a/packages/stack-encrypt-derive/src/encrypt.rs b/packages/stack-encrypt-derive/src/encrypt.rs index f5241c137..efd81cce1 100644 --- a/packages/stack-encrypt-derive/src/encrypt.rs +++ b/packages/stack-encrypt-derive/src/encrypt.rs @@ -29,16 +29,16 @@ pub(crate) fn derive(input: DeriveInput) -> Result<TokenStream> { where_.push(parse_quote!(#ty: #krate::target::EncryptFrom<#source>)); where_.push(parse_quote!(#context: Into<<#ty as #krate::target::EncryptFrom<#source>>::Context>)); } - let plans = fields.iter().map(|field| { + let operations = fields.iter().map(|field| { let ty = &field.ty; let context = record.context_expr(field, false); - let plan = if let Some(from) = field.from() { + let operation = if let Some(from) = field.from() { quote_spanned!(ty.span()=> <#ty as #krate::target::EncryptFrom<_>>::encryption::<__K>(#context.into()) .project(|__source: &#source| &__source.#from)) } else { quote_spanned!(ty.span()=> <#ty as #krate::target::EncryptFrom<#source>>::encryption::<__K>(#context.into())) }; - (plan, field.local.clone()) + (operation, field.local.clone()) }).collect(); let assignments = record.fields.iter().map(|field| { let member = &field.member; @@ -56,7 +56,7 @@ pub(crate) fn derive(input: DeriveInput) -> Result<TokenStream> { let stored = record .context_field() .map(|_| quote!(let __stored_context = __context.clone().into_inner();)); - let body = zip(plans, quote!(Self { #(#assignments),* })); + let body = zip(operations, quote!(Self { #(#assignments),* })); impls.push(trait_impl(&input, &generics, quote!(#krate::target::EncryptFrom<#source>), quote! { type Context = #context; fn encryption<#source_lifetime,__K: 'static>(__context: Self::Context) -> #krate::target::Encryption<#source_lifetime,#source, Self, __K> where #source:#source_lifetime { diff --git a/packages/stack-encrypt-derive/src/shape.rs b/packages/stack-encrypt-derive/src/shape.rs index 3f4d7952e..847477b7f 100644 --- a/packages/stack-encrypt-derive/src/shape.rs +++ b/packages/stack-encrypt-derive/src/shape.rs @@ -405,15 +405,15 @@ impl Record { } } } -pub(crate) fn zip(plans: Vec<(TokenStream, Ident)>, result: TokenStream) -> TokenStream { +pub(crate) fn zip(operations: Vec<(TokenStream, Ident)>, result: TokenStream) -> TokenStream { let mut chain = TokenStream::new(); let mut pattern = TokenStream::new(); - for (index, (plan, local)) in plans.into_iter().enumerate() { + for (index, (operation, local)) in operations.into_iter().enumerate() { if index == 0 { - chain = plan; + chain = operation; pattern = quote!(#local); } else { - chain = quote!(#chain.zip(#plan)); + chain = quote!(#chain.zip(#operation)); pattern = quote!((#pattern, #local)); } } diff --git a/packages/stack-encrypt/docs/adr/0001-context-optional-cipher-directed-path.md b/packages/stack-encrypt/docs/adr/0001-context-optional-cipher-directed-path.md index 0d11c5005..a359d50f2 100644 --- a/packages/stack-encrypt/docs/adr/0001-context-optional-cipher-directed-path.md +++ b/packages/stack-encrypt/docs/adr/0001-context-optional-cipher-directed-path.md @@ -10,8 +10,8 @@ Superseded on 2026-09-12 by retains the allowance for absent context on the cipher-directed path and the nonempty-context requirement for EQL operations, but moves target context requirements into declarations executed by core code. The original rationale -below is retained as history; ADR-0003 records the accepted design pending -implementation. +below is retained as history; ADR-0003 records the accepted design, which +this crate's operation descriptions, native readers, and derives implement. `StackCipher::encrypt` / `decrypt` / `decipher` (the cipher-directed path) accept any `IntoAad`, including `()`, exactly as vitaminc's `Aes256Cipher` does: sealing diff --git a/packages/stack-encrypt/docs/adr/0003-declarative-targets-and-ciphertext-transcoding.md b/packages/stack-encrypt/docs/adr/0003-declarative-targets-and-ciphertext-transcoding.md index 42d0e75de..b27ccc3ed 100644 --- a/packages/stack-encrypt/docs/adr/0003-declarative-targets-and-ciphertext-transcoding.md +++ b/packages/stack-encrypt/docs/adr/0003-declarative-targets-and-ciphertext-transcoding.md @@ -101,6 +101,10 @@ implement the plaintext-side `Encrypt` / `Decrypt` traits. still need migration. Supported operation descriptions must not admit arbitrary plaintext-and-cipher callbacks that recreate the bypass. The guarantee concerns target-directed execution, not all code a caller could write with a cipher. + A field selector (`Encryption::project`) is a capture-free function pointer + over a borrow: it cannot reach the cipher, but nothing stops it returning + bytes it made up. The guarantee is that no target code holds plaintext and + cipher together, not that a selector's output is the plaintext it was given. - The implementation must preserve batching and keyset scope, move outputs through consuming readers, and retain authenticated markers and cryptographic map keys. Unsupported shapes must fail explicitly. Stored context is not inherently diff --git a/packages/stack-encrypt/src/cipher.rs b/packages/stack-encrypt/src/cipher.rs index 8ed3851bd..1aefe849d 100644 --- a/packages/stack-encrypt/src/cipher.rs +++ b/packages/stack-encrypt/src/cipher.rs @@ -158,6 +158,20 @@ pub enum Error { /// for a reason of its own. #[error(transparent)] Other(Box<dyn std::error::Error + Send + Sync + 'static>), + /// A [`transcode::Visitor`](crate::target::transcode::Visitor) was handed + /// an encrypted output shape its destination does not accept: a scalar + /// destination offered a sequence, say. Raised by the visitor's default + /// methods, so a destination only has to describe the shapes it stores. + /// Never a data error: the output was produced correctly, the + /// destination just has nowhere to put it. + #[error("destination does not support this encrypted output shape")] + UnsupportedShape, + /// A record carrying its context in storage (`#[stash(context_field)]`) + /// was opened with an [`ExpectedContext`](crate::target::ExpectedContext) + /// naming a different one. Refused before any key is retrieved; the + /// descriptor is the stored context's, rendered as ZeroKMS would log it. + #[error("stored context {stored} does not match the expected context")] + ContextMismatch { stored: Descriptor }, /// A [`Pending`](crate::target::Pending) fulfilment's requests and /// responses did not line up: it drew more responses — or a different /// kind — than its requests asked for, or left some of them unconsumed. diff --git a/packages/stack-encrypt/src/sem/mod.rs b/packages/stack-encrypt/src/sem/mod.rs index 0fa6ef59f..2e9d86233 100644 --- a/packages/stack-encrypt/src/sem/mod.rs +++ b/packages/stack-encrypt/src/sem/mod.rs @@ -147,7 +147,7 @@ use zeroize::Zeroize; use stack_kms::MaybeSend; use crate::target::core::Term; -use crate::target::Decryptable; +use crate::target::{DecryptField, Decryptable, Decryption}; use crate::{Error, KeysetCipher, Pending}; // The `/v1` suffix versions the *derivation* (domain + input framing), not the @@ -699,8 +699,11 @@ macro_rules! index_term { impl<$($param: $bound)?> Decryptable for $ty { const DECRYPTABLE: bool = false; } - - + impl<P, Ctx $(, $param: $bound)?> DecryptField<P, Ctx> for $ty { + fn decryption_field<K: 'static>(self, _: Ctx) -> Option<Decryption<P, K>> { + None + } + } }; } diff --git a/packages/stack-encrypt/src/target/context.rs b/packages/stack-encrypt/src/target/context.rs new file mode 100644 index 000000000..ba64aacc7 --- /dev/null +++ b/packages/stack-encrypt/src/target/context.rs @@ -0,0 +1,217 @@ +//! The contexts a target declares. +//! +//! A target's associated `Context` is what a caller hands `encrypt_as` and +//! `decrypt_as` alongside the value. The types here are the core-owned ones: +//! each holds Vitamin C's encodings of a nonempty context, so the structured +//! identity of its descriptor survives the trip into a boxed operation +//! description. A record that stores its own identifier declares +//! `NonEmpty<T>` instead, and a target whose declaration carries every +//! context it needs declares `()`. +use crate::{ + Aad, AadPiece, Descriptor, Error, IntoAad, IntoPrfContext, MaybeEmpty, NonEmpty, PrfContext, +}; + +/// Prove a context nonempty at the point it is used. The core-owned types +/// are nonempty by construction, so for them this cannot fail; the one +/// helper keeps the error mapping in one place. +pub(super) fn nonempty<T: MaybeEmpty>(value: T) -> Result<NonEmpty<T>, Error> { + NonEmpty::new(value).map_err(|e| Error::Other(Box::new(e))) +} + +/// An owned, validated context for a target that accepts any Vitamin C +/// context and derives both ciphertext and terms from it. +/// +/// Built from a `NonEmpty<T>` (or a bare integer), it holds the AEAD and PRF +/// encodings of that context, so the descriptor's structured identity is +/// preserved. Concrete records declare their own context type instead. +#[derive(Clone, Debug)] +pub struct CallerContext { + aad: AadPiece<'static>, + prf: PrfContext<'static>, +} +impl<'c, T> From<NonEmpty<T>> for CallerContext +where + T: IntoAad<'c> + IntoPrfContext<'c> + Clone, +{ + fn from(context: NonEmpty<T>) -> Self { + Self { + aad: context.clone().into_aad_piece().into_owned(), + prf: context.into_prf_context().into_owned(), + } + } +} +impl MaybeEmpty for CallerContext { + fn is_empty(&self) -> bool { + false + } +} +impl<'a> IntoAad<'a> for CallerContext { + fn into_aad(self) -> Aad<'a> { + self.aad.into_aad() + } + fn into_aad_piece(self) -> AadPiece<'a> { + self.aad + } +} +impl<'a> IntoPrfContext<'a> for CallerContext { + fn into_prf_context(self) -> PrfContext<'a> { + self.prf + } +} +impl CallerContext { + pub(super) fn validated(self) -> Result<NonEmpty<Self>, Error> { + nonempty(self) + } + /// Extend a field's own context with this caller context: the field's + /// literal becomes the prefix, this context the extension, exactly as a + /// `struct = T` derive composes them. + /// + /// # Errors + /// + /// Fails if `field` is empty; a field's own context is a literal the + /// derive has already checked, so a hand-written caller is the only one + /// that can hit this. + pub fn under(self, field: &'static str) -> Result<Self, Error> { + let prefix = nonempty(field)?; + Ok(prefix.with(self.validated()?).into()) + } +} + +/// An owned nonempty context for ciphertext-only operations. +/// +/// Sealing needs only the AEAD encoding, so a type that implements `IntoAad` +/// without `IntoPrfContext` is enough here where it would not be for a +/// [`CallerContext`]. +#[derive(Clone, Debug)] +pub struct AeadContext(AadPiece<'static>); +impl<'a, T: IntoAad<'a>> From<NonEmpty<T>> for AeadContext { + fn from(value: NonEmpty<T>) -> Self { + Self(value.into_aad_piece().into_owned()) + } +} +impl From<CallerContext> for AeadContext { + fn from(value: CallerContext) -> Self { + Self(value.aad) + } +} +impl MaybeEmpty for AeadContext { + fn is_empty(&self) -> bool { + false + } +} +impl<'a> IntoAad<'a> for AeadContext { + fn into_aad(self) -> Aad<'a> { + self.0.into_aad() + } + fn into_aad_piece(self) -> AadPiece<'a> { + self.0 + } +} +impl AeadContext { + pub(super) fn validated(self) -> Result<NonEmpty<Self>, Error> { + nonempty(self) + } +} + +/// The optional extension a record whose fields already declare their own +/// contexts accepts from its caller. +/// +/// `()` (the default) leaves the declared contexts as they are; a nonempty +/// value extends each of them, the way a caller's context extends a field's +/// own. This is what a `struct = T` derive without a `context_field` +/// declares. +#[derive(Clone, Debug, Default)] +pub struct DeclaredContext(Option<CallerContext>); +impl From<()> for DeclaredContext { + fn from(_: ()) -> Self { + Self::default() + } +} +impl From<CallerContext> for DeclaredContext { + fn from(value: CallerContext) -> Self { + Self(Some(value)) + } +} +impl<'a, T: IntoAad<'a> + IntoPrfContext<'a> + Clone> From<NonEmpty<T>> for DeclaredContext { + fn from(value: NonEmpty<T>) -> Self { + Self(Some(value.into())) + } +} +impl DeclaredContext { + /// The context one field is derived under: its own literal, extended by + /// the caller's context if one was given. + /// + /// # Errors + /// + /// Fails if `field` is empty. The derive checks its literals at compile + /// time, so only a hand-written caller can hit this. + pub fn field(self, field: &'static str) -> Result<CallerContext, Error> { + match self.0 { + Some(context) => context.under(field), + None => nonempty(field).map(Into::into), + } + } +} + +/// What a caller may assert about a record that stores its context. +/// +/// A `#[stash(context_field)]` record carries its identifier in storage, and +/// decryption reads the context from there. The default asks only that the +/// stored value be nonempty; a `NonEmpty<T>` asks that it also equal the +/// destination the caller believes it is opening. Either way the check runs +/// before any key is retrieved. +#[derive(Clone, Debug)] +pub struct ExpectedContext<T>(Option<NonEmpty<T>>); +impl<T> Default for ExpectedContext<T> { + fn default() -> Self { + Self(None) + } +} +impl<T> From<()> for ExpectedContext<T> { + fn from(_: ()) -> Self { + Self::default() + } +} +impl<T> From<NonEmpty<T>> for ExpectedContext<T> { + fn from(value: NonEmpty<T>) -> Self { + Self(Some(value)) + } +} +impl<'c, T: MaybeEmpty + PartialEq + IntoAad<'c>> ExpectedContext<T> { + /// Check the stored context against this expectation and prove it + /// nonempty, yielding the context the record is opened under. + /// + /// # Errors + /// + /// [`Error::ContextMismatch`] if an expected context was given and the + /// stored one differs from it; the error carries the stored context's + /// descriptor. Otherwise fails if the stored context is empty. A stored + /// context is data the record was handed, not something the cipher has + /// authenticated yet: both checks happen before any key is requested. + pub fn validate(self, stored: T) -> Result<NonEmpty<T>, Error> { + if let Some(expected) = self.0 { + if expected.into_inner() != stored { + let stored = Descriptor::from_piece(&stored.into_aad_piece()); + return Err(Error::ContextMismatch { stored }); + } + } + nonempty(stored) + } +} + +macro_rules! integer_contexts { + ($($ty:ty),*) => {$ ( + impl From<$ty> for CallerContext { + fn from(value: $ty) -> Self { Self::from(NonEmpty::<$ty>::from(value)) } + } + impl From<$ty> for AeadContext { + fn from(value: $ty) -> Self { Self::from(NonEmpty::<$ty>::from(value)) } + } + impl From<$ty> for DeclaredContext { + fn from(value: $ty) -> Self { Self::from(NonEmpty::<$ty>::from(value)) } + } + )*}; +} +// A bare integer is a context in its own right (see `CONTEXT.md`): it needs +// no `NonEmpty` proof, so it converts directly. +integer_contexts!(u8, u16, u32, u64, u128, i8, i16, i32, i64, i128); diff --git a/packages/stack-encrypt/src/target/mod.rs b/packages/stack-encrypt/src/target/mod.rs index d012c0f52..9df25f5e1 100644 --- a/packages/stack-encrypt/src/target/mod.rs +++ b/packages/stack-encrypt/src/target/mod.rs @@ -1,6 +1,6 @@ //! Targets declare operations; the cipher executes them through Vitamin C. //! -//! [`EncryptFrom<S>`] describes ciphertext and/or index operations and has an +//! [`EncryptFrom<S>`] describes ciphertext and/or term operations and has an //! associated context type. It does not receive plaintext or a cipher. The //! cipher's `encrypt_as` executes that description, returning the existing //! batched [`Pending`]. [`DecryptInto<P>`] inspects stored output and describes @@ -68,6 +68,7 @@ //! `cipher.decrypt_as(record, context)`, or import the blanket [`EncryptInto`] and //! [`DecryptFrom`] convenience traits for the existing source-side call syntax. //! `EncryptTarget` and `DecryptTarget` are no longer extension points. +mod context; pub(crate) mod core; mod operations; mod pending; @@ -75,7 +76,11 @@ mod request; pub mod transcode; pub(crate) use self::core::{decipher_pending, seal_pending}; -pub use operations::*; +pub use context::{AeadContext, CallerContext, DeclaredContext, ExpectedContext}; +pub use operations::{ + ciphertext, equality, matching, ope, open, ore, DecryptField, DecryptFrom, DecryptInto, + Decryptable, Decryption, EncryptFrom, EncryptInto, Encryption, +}; pub use pending::{CipherScope, Pending, PendingFuture}; pub use request::{Request, Responses}; pub use stack_encrypt_derive::{DecryptInto, EncryptFrom}; diff --git a/packages/stack-encrypt/src/target/operations.rs b/packages/stack-encrypt/src/target/operations.rs index 29fad3c70..32e64af11 100644 --- a/packages/stack-encrypt/src/target/operations.rs +++ b/packages/stack-encrypt/src/target/operations.rs @@ -1,72 +1,44 @@ -//! Core-owned operation descriptions. Constructors never accept a plaintext/cipher callback. +//! Core-owned operation descriptions: what a target declares, and the cipher +//! executes. No constructor here accepts a plaintext-and-cipher callback; a +//! description selects core operations and converts their completed output, +//! nothing more. +use super::context::{AeadContext, CallerContext}; use super::core::{encrypt_native, open_native, Term}; use super::{CipherScope, Pending}; -use crate::{ - Aad, AadPiece, Error, IntoAad, IntoPrfContext, KeysetCipher, MaybeEmpty, NonEmpty, PrfContext, - StackCipher, StackCipherText, -}; +use crate::{Error, KeysetCipher, StackCipher, StackCipherText}; use stack_kms::MaybeSend; +use std::fmt; -/// Owned, validated context for targets that accept any Vitamin C context. -/// Concrete records can instead declare their own associated context type. -/// Both encodings and the descriptor's structured identity are preserved. -#[derive(Clone, Debug)] -pub struct CallerContext { - aad: AadPiece<'static>, - prf: PrfContext<'static>, -} -impl<'c, T> From<NonEmpty<T>> for CallerContext -where - T: IntoAad<'c> + IntoPrfContext<'c> + Clone, -{ - fn from(context: NonEmpty<T>) -> Self { - Self { - aad: context.clone().into_aad_piece().into_owned(), - prf: context.into_prf_context().into_owned(), - } - } -} -impl MaybeEmpty for CallerContext { - fn is_empty(&self) -> bool { - false - } -} -impl<'a> IntoAad<'a> for CallerContext { - fn into_aad(self) -> Aad<'a> { - self.aad.into_aad() - } - fn into_aad_piece(self) -> AadPiece<'a> { - self.aad - } -} -impl<'a> IntoPrfContext<'a> for CallerContext { - fn into_prf_context(self) -> PrfContext<'a> { - self.prf - } -} -impl CallerContext { - fn validated(self) -> Result<NonEmpty<Self>, Error> { - NonEmpty::new(self).map_err(|e| Error::Other(Box::new(e))) - } - /// Extend a field's own context with this caller context. - pub fn under(self, field: &'static str) -> Result<Self, Error> { - let prefix = NonEmpty::new(field).map_err(|e| Error::Other(Box::new(e)))?; - Ok(prefix.with(self.validated()?).into()) - } -} - -/// Declaration of how an encrypted target is produced from `S`. -/// The returned description has private execution machinery: implementations can -/// select core operations and construct outputs, but cannot replace encryption. +/// Declaration that an encrypted target is produced from `S`. +/// +/// The derive supplies it; a hand-written implementation composes the +/// constructors in this module ([`ciphertext`], [`equality`], [`matching`], +/// [`ore`], [`ope`]) and converts their output with [`Encryption::map`] or +/// [`Encryption::transcode`]. Nothing here receives the plaintext or a +/// cipher: the returned [`Encryption`]'s execution is private, so a target +/// can choose operations and build its output but cannot replace encryption. pub trait EncryptFrom<S>: Sized + 'static { + /// What a caller supplies alongside the plaintext: a [`CallerContext`] or + /// [`AeadContext`] for a generic target, a `NonEmpty<T>` for a record that + /// stores its identifier, or `()` when the declaration carries every + /// context it needs. type Context; + /// The description the cipher executes for one value of `S`. fn encryption<'s, K: 'static>(context: Self::Context) -> Encryption<'s, S, Self, K> where S: 's; } -/// Declaration of how a stored target recovers `P`. Inspection sees no cipher. +/// Declaration of how a stored target recovers `P`. +/// +/// Inspection sees no cipher: the implementation selects the recoverable +/// ciphertext and its context, and [`open`] describes the rest. A query-only +/// target (terms alone) has no implementation. pub trait DecryptInto<P>: Sized { + /// What a caller supplies to open the target. For a record that stores + /// its context this is an [`ExpectedContext`](super::ExpectedContext), + /// which may name the destination the caller believes it is opening. type Context; + /// The description the cipher executes to recover `P`. fn decryption<K: 'static>(self, context: Self::Context) -> Decryption<P, K>; } @@ -81,17 +53,37 @@ type Open<T, K> = Box<dyn for<'a> FnOnce(&'a StackCipher<K>) -> Pending<'a, T, K #[cfg(target_arch = "wasm32")] type Open<T, K> = Box<dyn for<'a> FnOnce(&'a StackCipher<K>) -> Pending<'a, T, K>>; -/// A composable encryption description, executed only by the cipher. +/// A composable description of how `T` is encrypted from `S`. +/// +/// Built from the constructors in this module and the combinators below; +/// executed only by [`KeysetCipher::encrypt_as`]. Nothing runs, and no key is +/// requested, until then. +#[must_use = "an encryption description does nothing until a keyset cipher executes it"] pub struct Encryption<'s, S, T, K> { build: Build<'s, S, T, K>, } -/// A composable decryption description, executed only by the cipher. +/// A composable description of how `T` is recovered from a stored target. +/// +/// Built from [`open`] and the combinators below; executed only by +/// `decrypt_as`. Nothing runs, and no key is retrieved, until then. +#[must_use = "a decryption description does nothing until a cipher executes it"] pub struct Decryption<T, K> { open: Open<T, K>, } +impl<S, T, K> fmt::Debug for Encryption<'_, S, T, K> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("Encryption").finish_non_exhaustive() + } +} +impl<T, K> fmt::Debug for Decryption<T, K> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("Decryption").finish_non_exhaustive() + } +} impl<'s, S: 's, T: 'static, K: 'static> Encryption<'s, S, T, K> { - /// Construct metadata or reject a declaration before any key request. + /// A description whose output is already known — metadata a record + /// carries, or a declaration rejected before any key request. pub fn ready(result: Result<T, Error>) -> Self where T: MaybeSend, @@ -100,12 +92,15 @@ impl<'s, S: 's, T: 'static, K: 'static> Encryption<'s, S, T, K> { build: Box::new(move |_, cipher| Pending::ready(cipher, result)), } } + /// Reject the declaration: execution yields `error` without I/O, and any + /// description this is zipped into fails with it. pub fn failed(error: Error) -> Self { Self { build: Box::new(move |_, cipher| Pending::failed(cipher, error)), } } - /// Construct the destination from completed outputs, without access to plaintext. + /// Build the destination from the completed output. `f` sees ciphertext + /// and terms, never the plaintext. pub fn map<U: 'static, F>(self, f: F) -> Encryption<'s, S, U, K> where F: FnOnce(T) -> U + MaybeSend + 'static, @@ -114,7 +109,8 @@ impl<'s, S: 's, T: 'static, K: 'static> Encryption<'s, S, T, K> { build: Box::new(move |source, cipher| (self.build)(source, cipher).map(f)), } } - /// Fallible output conversion, including native ciphertext transcoding. + /// [`map`](Self::map) for a conversion that can fail, such as reading + /// native output into a destination that does not accept every shape. pub fn try_map<U: 'static, F>(self, f: F) -> Encryption<'s, S, U, K> where F: FnOnce(T) -> Result<U, Error> + MaybeSend + 'static, @@ -123,14 +119,17 @@ impl<'s, S: 's, T: 'static, K: 'static> Encryption<'s, S, T, K> { build: Box::new(move |source, cipher| (self.build)(source, cipher).try_map(f)), } } - /// Drive a destination visitor directly from this operation's native output. + /// Drive the destination's [`Visitor`](super::transcode::Visitor) from + /// this operation's native output, moving leaves and markers across + /// without an intermediate tree. pub fn transcode<U: super::transcode::Transcode + 'static>(self) -> Encryption<'s, S, U, K> where T: super::transcode::Reader, { self.try_map(|output| super::transcode::Reader::read(output, U::visitor())) } - /// Compose operations over the same source in one key request batch. + /// Run both descriptions over the same source, settling their key + /// requests in one batch. pub fn zip<U: 'static>(self, other: Encryption<'s, S, U, K>) -> Encryption<'s, S, (T, U), K> { Encryption { build: Box::new(move |source, cipher| { @@ -138,8 +137,13 @@ impl<'s, S: 's, T: 'static, K: 'static> Encryption<'s, S, T, K> { }), } } - /// Select a borrowed plaintext field. The selector cannot supply a new owned - /// serialization of that field; its selected type drives the core operation. + /// Lift a description of a field to a description of the struct that + /// holds it, which is how a `struct = T` derive composes its fields. + /// + /// The selector is a plain function pointer over a borrow: it captures + /// nothing, so it cannot reach a cipher, and what it returns is encrypted + /// under `S`'s own Vitamin C contract. It is a place to pick a field, not + /// to re-encode one. pub fn project<P: 's>( self, select: for<'borrow> fn(&'borrow P) -> &'borrow S, @@ -150,7 +154,10 @@ impl<'s, S: 's, T: 'static, K: 'static> Encryption<'s, S, T, K> { } } -/// Canonical ciphertext operation. Source encoding belongs to Vitamin C. +/// The canonical ciphertext operation: seal `S` under `context` through its +/// own Vitamin C `Encrypt` implementation, into the native +/// [`StackCipherText`] tree. There is no Serde fallback; a plaintext without +/// `Encrypt` does not compile. pub fn ciphertext<'s, S: crate::Encrypt + Clone + 's, K: 'static>( context: impl Into<AeadContext>, ) -> Encryption<'s, S, StackCipherText, K> { @@ -163,62 +170,62 @@ pub fn ciphertext<'s, S: crate::Encrypt + Clone + 's, K: 'static>( } } macro_rules! term_operation { - ($function:ident, $output:ty, [$($extra:tt)*], $($bounds:tt)*) => { - pub fn $function<'s,S,K:'static,$($extra)*>(context: impl Into<CallerContext>)->Encryption<'s,S,$output,K> - where S: 's + $($bounds)* { - let context=context.into(); - Encryption { build:Box::new(move |source,cipher| match context.validated() { - Ok(ctx) => <$output as Term<S, K, _>>::encrypt_from(source,cipher,ctx), - Err(e) => Pending::failed(cipher,e), - }) } + ( + $(#[$doc:meta])* + $function:ident, $output:ty, [$($generics:tt)*], [$($bounds:tt)*] + ) => { + $(#[$doc])* + pub fn $function<'s, S, K: 'static, $($generics)*>( + context: impl Into<CallerContext>, + ) -> Encryption<'s, S, $output, K> + where + S: 's, + $($bounds)* + { + let context = context.into(); + Encryption { + build: Box::new(move |source, cipher| match context.validated() { + Ok(ctx) => <$output as Term<S, K, _>>::encrypt_from(source, cipher, ctx), + Err(e) => Pending::failed(cipher, e), + }), + } } }; } term_operation!( - equality, - crate::sem::EqualityTerm, - [], - vitaminc_prf::PrfValue + Clone + /// The equality term of `S` under `context`. Requires only `S`'s PRF + /// contract, not recoverable encryption. + equality, crate::sem::EqualityTerm, [], [S: vitaminc_prf::PrfValue + Clone] +); +term_operation!( + /// The match term of any text `S` under `context`, tokenised and hashed + /// as `O` declares. + matching, crate::sem::MatchTerm<O>, [O: crate::sem::MatchConfig + 'static], [S: AsRef<str>] +); +term_operation!( + /// The order-revealing term of `S` under `context`. The bounds are the + /// leaf's own: they say which `S` the CLLW ORE scheme can order. + ore, crate::sem::OreTerm<S>, [], + [S: cllw_ore::CllwOreEncrypt + Clone + Send + 'static, S::Output: Send + 'static] +); +term_operation!( + /// The order-preserving term of `S` under `context`, with the same + /// bounds as [`ore`]. + ope, crate::sem::OpeTerm<S>, [], + [S: cllw_ore::CllwOpeEncrypt + Clone + Send + 'static, S::Output: Send + 'static] ); -term_operation!(matching, crate::sem::MatchTerm<O>, [O:crate::sem::MatchConfig+'static,], AsRef<str>); -// Ordering output bounds are part of the primitive's contract. -pub fn ore<'s, S, K: 'static>( - context: impl Into<CallerContext>, -) -> Encryption<'s, S, crate::sem::OreTerm<S>, K> -where - S: cllw_ore::CllwOreEncrypt + Clone + Send + 'static, - S::Output: Send + 'static, -{ - let context = context.into(); - Encryption { - build: Box::new(move |source, cipher| match context.validated() { - Ok(ctx) => <crate::sem::OreTerm<S> as Term<S, K, _>>::encrypt_from(source, cipher, ctx), - Err(e) => Pending::failed(cipher, e), - }), - } -} -pub fn ope<'s, S, K: 'static>( - context: impl Into<CallerContext>, -) -> Encryption<'s, S, crate::sem::OpeTerm<S>, K> -where - S: cllw_ore::CllwOpeEncrypt + Clone + Send + 'static, - S::Output: Send + 'static, -{ - let context = context.into(); - Encryption { - build: Box::new(move |source, cipher| match context.validated() { - Ok(ctx) => <crate::sem::OpeTerm<S> as Term<S, K, _>>::encrypt_from(source, cipher, ctx), - Err(e) => Pending::failed(cipher, e), - }), - } -} impl<T: 'static, K: 'static> Decryption<T, K> { + /// Reject the opening: execution yields `error` without I/O, and any + /// description this is zipped into fails with it. The derives use it + /// when a stored context fails validation. pub fn failed(error: Error) -> Self { Self { open: Box::new(move |cipher| Pending::failed(cipher, error)), } } + /// A description whose output is already known: a defaulted field, or + /// an absent optional. pub fn ready(value: T) -> Self where T: MaybeSend, @@ -227,6 +234,7 @@ impl<T: 'static, K: 'static> Decryption<T, K> { open: Box::new(move |cipher| Pending::ready(cipher, Ok(value))), } } + /// Convert the recovered value. pub fn map<U: 'static, F>(self, f: F) -> Decryption<U, K> where F: FnOnce(T) -> U + MaybeSend + 'static, @@ -235,13 +243,47 @@ impl<T: 'static, K: 'static> Decryption<T, K> { open: Box::new(move |cipher| (self.open)(cipher).map(f)), } } + /// Open both, retrieving their keys in one batch. pub fn zip<U: 'static>(self, other: Decryption<U, K>) -> Decryption<(T, U), K> { Decryption { open: Box::new(move |cipher| (self.open)(cipher).zip((other.open)(cipher))), } } + /// Open every description, retrieving all their keys in one batch, and + /// collect the results in order. + pub fn all<I>(items: I) -> Decryption<Vec<T>, K> + where + I: IntoIterator<Item = Self>, + { + let items: Vec<Self> = items.into_iter().collect(); + Decryption { + open: Box::new(move |cipher| { + Pending::collect(cipher, items.into_iter().map(|item| (item.open)(cipher))) + }), + } + } + /// An absent item recovers as `None` without I/O. + fn optional(item: Option<Self>) -> Decryption<Option<T>, K> + where + T: MaybeSend, + { + match item { + Some(item) => item.map(Some), + None => Decryption::ready(None), + } + } + /// Execute under a scope. A [`KeysetCipher`] scope refuses a leaf from any + /// other keyset; a [`StackCipher`] scope opens leaves from any. + fn open_in<'a>(self, scope: impl CipherScope<'a, K>) -> Pending<'a, T, K> { + let pending = (self.open)(scope.cipher()); + match scope.keyset() { + Some(id) => pending.scoped_to(id), + None => pending, + } + } } -/// Open the native ciphertext through Vitamin C's requested plaintext decoder. +/// The canonical opening operation: retrieve the tree's keys and decode `P` +/// through its own Vitamin C `Decrypt` implementation, under `context`. pub fn open<P: crate::Decrypt<'static> + 'static, K: 'static>( tree: StackCipherText, context: impl Into<AeadContext>, @@ -256,12 +298,18 @@ pub fn open<P: crate::Decrypt<'static> + 'static, K: 'static>( } impl<K: 'static> KeysetCipher<'_, K> { + /// Encrypt `source` into `T` under this keyset, as `T`'s declaration + /// describes. The returned [`Pending`] settles every key request the + /// declaration made in one batch. pub fn encrypt_as<'a, S, T>(&'a self, source: &S, context: T::Context) -> Pending<'a, T, K> where T: EncryptFrom<S>, { (T::encryption(context).build)(source, self) } + /// Recover `P` from `source`, as its declaration describes. A leaf sealed + /// under another keyset is refused ([`Error::ForeignKeyset`]) before any + /// key is retrieved. pub fn decrypt_as<'a, P: 'static, T>( &'a self, source: T, @@ -270,10 +318,12 @@ impl<K: 'static> KeysetCipher<'_, K> { where T: DecryptInto<P>, { - (source.decryption(context).open)(self.cipher()).scoped_to(self.keyset_id()) + source.decryption(context).open_in(self) } } impl<K: 'static> StackCipher<K> { + /// Recover `P` from `source`, as its declaration describes. Leaves from + /// any of the client's keysets open here. pub fn decrypt_as<'a, P: 'static, T>( &'a self, source: T, @@ -282,12 +332,16 @@ impl<K: 'static> StackCipher<K> { where T: DecryptInto<P>, { - (source.decryption(context).open)(self) + source.decryption(context).open_in(self) } } -/// Blanket call-site convenience; targets implement the declaration, not these methods. +/// Source-side call syntax for [`KeysetCipher::encrypt_as`], implemented for +/// every type: `value.encrypt_into(&keyset)`. Targets implement +/// [`EncryptFrom`], never these methods. pub trait EncryptInto: Sized { + /// Encrypt into `T` with its default context — `()` for a declaration + /// that carries its own contexts. fn encrypt_into<'a, T, K: 'static>(&self, cipher: &'a KeysetCipher<'_, K>) -> Pending<'a, T, K> where T: EncryptFrom<Self>, @@ -295,6 +349,8 @@ pub trait EncryptInto: Sized { { cipher.encrypt_as(self, Default::default()) } + /// Encrypt into `T` under `context`, accepting anything that converts + /// into `T`'s context — a `nonempty!` literal, say. fn encrypt_into_with_context<'a, T, K: 'static>( &self, cipher: &'a KeysetCipher<'_, K>, @@ -305,6 +361,8 @@ pub trait EncryptInto: Sized { { cipher.encrypt_as(self, context.into()) } + /// [`encrypt_into_with_context`](Self::encrypt_into_with_context) named + /// from the target's side: `Target::encrypt_from(&value, &keyset, ctx)`. fn encrypt_from<'a, S, K: 'static>( source: &S, cipher: &'a KeysetCipher<'_, K>, @@ -317,8 +375,12 @@ pub trait EncryptInto: Sized { } } impl<T> EncryptInto for T {} -/// Blanket decrypt call-site convenience; there is no overridable target execution method. +/// Source-side call syntax for `decrypt_as`, implemented for every type: +/// `stored.decrypt_into(&cipher, ctx)`. Targets implement [`DecryptInto`], +/// never these methods. pub trait DecryptFrom: Sized + 'static { + /// Recover `P` through `cipher`, which may be a [`KeysetCipher`] (refusing + /// foreign leaves) or a [`StackCipher`] (opening any). fn decrypt_into<'a, P: 'static, K: 'static>( self, cipher: impl CipherScope<'a, K>, @@ -327,12 +389,11 @@ pub trait DecryptFrom: Sized + 'static { where Self: DecryptInto<P>, { - let pending = (self.decryption(context.into()).open)(cipher.cipher()); - match cipher.keyset() { - Some(id) => pending.scoped_to(id), - None => pending, - } + self.decryption(context.into()).open_in(cipher) } + /// Recover `Self` from `source` with its default context — the plain + /// "read the stored context and validate it" for a record that stores + /// one. fn decrypt_from<'a, S, K: 'static>( source: S, cipher: impl CipherScope<'a, K>, @@ -343,6 +404,7 @@ pub trait DecryptFrom: Sized + 'static { { source.decrypt_into(cipher, S::Context::default()) } + /// Recover `Self` from `source` under `context`. fn decrypt_from_with_context<'a, S, K: 'static>( source: S, cipher: impl CipherScope<'a, K>, @@ -417,6 +479,12 @@ where ope(context) } } + +// A `Vec<Target>` is a row per item, each encrypted independently under the +// same context and settled in one batch; an `Option<Target>` is one row or +// nothing, without I/O. (A plaintext `Vec`/`Option` sealed into a +// `StackCipherText` is a different thing: Vitamin C's native sequence and +// option, with their authenticated markers.) impl<S, T: EncryptFrom<S>> EncryptFrom<Vec<S>> for Vec<T> where T::Context: Clone + 'static + MaybeSend, @@ -458,102 +526,30 @@ where impl<P: 'static, T: DecryptInto<P> + 'static> DecryptInto<Vec<P>> for Vec<T> where T::Context: Clone + 'static, - T: MaybeSend, - T::Context: MaybeSend, { type Context = T::Context; fn decryption<K: 'static>(self, context: Self::Context) -> Decryption<Vec<P>, K> { - Decryption { - open: Box::new(move |cipher| { - Pending::collect( - cipher, - self.into_iter() - .map(|item| cipher.decrypt_as(item, context.clone())), - ) - }), - } + Decryption::all( + self.into_iter() + .map(|item| item.decryption(context.clone())), + ) } } -impl<P: 'static + MaybeSend, T: DecryptInto<P> + 'static + MaybeSend> DecryptInto<Option<P>> - for Option<T> -where - T::Context: 'static + MaybeSend, -{ +impl<P: 'static + MaybeSend, T: DecryptInto<P> + 'static> DecryptInto<Option<P>> for Option<T> { type Context = T::Context; fn decryption<K: 'static>(self, context: Self::Context) -> Decryption<Option<P>, K> { - Decryption { - open: Box::new(move |cipher| match self { - Some(item) => cipher.decrypt_as(item, context).map(Some), - None => Pending::ready(cipher, Ok(None)), - }), - } + Decryption::optional(self.map(|item| item.decryption(context))) } } -/// Optional extension for records whose fields already declare their base contexts. -/// `()` selects the declared contexts unchanged; a nonempty value extends them. -#[derive(Clone, Debug, Default)] -pub struct DeclaredContext(Option<CallerContext>); -impl From<()> for DeclaredContext { - fn from(_: ()) -> Self { - Self::default() - } -} -impl From<CallerContext> for DeclaredContext { - fn from(value: CallerContext) -> Self { - Self(Some(value)) - } -} -impl<'a, T: IntoAad<'a> + IntoPrfContext<'a> + Clone> From<NonEmpty<T>> for DeclaredContext { - fn from(value: NonEmpty<T>) -> Self { - Self(Some(value.into())) - } -} -impl DeclaredContext { - pub fn field(self, field: &'static str) -> Result<CallerContext, Error> { - match self.0 { - Some(context) => context.under(field), - None => NonEmpty::new(field) - .map(Into::into) - .map_err(|e| Error::Other(Box::new(e))), - } - } -} -/// Optional destination validation for a record carrying its context in storage. -/// Even without an expected value, the stored context must pass `NonEmpty::new`. -#[derive(Clone, Debug)] -pub struct ExpectedContext<T>(Option<NonEmpty<T>>); -impl<T> Default for ExpectedContext<T> { - fn default() -> Self { - Self(None) - } -} -impl<T> From<()> for ExpectedContext<T> { - fn from(_: ()) -> Self { - Self::default() - } -} -impl<T> From<NonEmpty<T>> for ExpectedContext<T> { - fn from(value: NonEmpty<T>) -> Self { - Self(Some(value)) - } -} -impl<T: MaybeEmpty + PartialEq> ExpectedContext<T> { - pub fn validate(self, stored: T) -> Result<NonEmpty<T>, Error> { - if self - .0 - .is_some_and(|expected| expected.into_inner() != stored) - { - return Err(Error::Other( - "stored context does not match the expected context".into(), - )); - } - NonEmpty::new(stored).map_err(|e| Error::Other(Box::new(e))) - } -} -/// Declaration used by derives to select the one recoverable field. Terms return -/// `None`; ciphertext fields return an opening description. No cipher is exposed. +/// How a derive finds the one recoverable field of a record among its terms. +/// +/// A term returns `None`: it is one-way. A ciphertext field returns its +/// opening description. Neither sees a cipher. Implemented for every leaf +/// type and for `Vec`/`Option` of them; a hand-written leaf that wraps a +/// [`StackCipherText`] implements it alongside [`Decryptable`]. pub trait DecryptField<P, Ctx>: Sized { + /// The opening description, if this field holds recoverable ciphertext. fn decryption_field<K: 'static>(self, context: Ctx) -> Option<Decryption<P, K>>; } impl<P: 'static, Ctx> DecryptField<P, Ctx> for StackCipherText @@ -565,121 +561,37 @@ where Some(self.decryption(context.into())) } } -impl<P, Ctx> DecryptField<P, Ctx> for crate::sem::EqualityTerm { - fn decryption_field<K: 'static>(self, _: Ctx) -> Option<Decryption<P, K>> { - None - } -} -impl<P, Ctx, O: crate::sem::MatchConfig> DecryptField<P, Ctx> for crate::sem::MatchTerm<O> { - fn decryption_field<K: 'static>(self, _: Ctx) -> Option<Decryption<P, K>> { - None - } -} -impl<P, Ctx, T: cllw_ore::CllwOreEncrypt> DecryptField<P, Ctx> for crate::sem::OreTerm<T> { - fn decryption_field<K: 'static>(self, _: Ctx) -> Option<Decryption<P, K>> { - None - } -} -impl<P, Ctx, T: cllw_ore::CllwOpeEncrypt> DecryptField<P, Ctx> for crate::sem::OpeTerm<T> { - fn decryption_field<K: 'static>(self, _: Ctx) -> Option<Decryption<P, K>> { - None - } -} -impl< - P: 'static, - T: 'static + super::Decryptable + DecryptField<P, Ctx> + MaybeSend, - Ctx: Clone + 'static + MaybeSend, - > DecryptField<Vec<P>, Ctx> for Vec<T> +impl<P: 'static, T: 'static + Decryptable + DecryptField<P, Ctx>, Ctx: Clone + 'static> + DecryptField<Vec<P>, Ctx> for Vec<T> { fn decryption_field<K: 'static>(self, context: Ctx) -> Option<Decryption<Vec<P>, K>> { if !T::DECRYPTABLE { return None; } - Some(Decryption { - open: Box::new(move |cipher| { - Pending::collect( - cipher, - self.into_iter().map(|item| { - let plan = item - .decryption_field(context.clone()) - .unwrap_or_else(|| Decryption::failed(Error::NotOpened)); - (plan.open)(cipher) - }), - ) - }), - }) + Some(Decryption::all(self.into_iter().map(|item| { + item.decryption_field(context.clone()) + .unwrap_or_else(|| Decryption::failed(Error::NotOpened)) + }))) } } -impl< - P: 'static + MaybeSend, - T: 'static + super::Decryptable + DecryptField<P, Ctx> + MaybeSend, - Ctx: 'static + MaybeSend, - > DecryptField<Option<P>, Ctx> for Option<T> +impl<P: 'static + MaybeSend, T: 'static + Decryptable + DecryptField<P, Ctx>, Ctx: 'static> + DecryptField<Option<P>, Ctx> for Option<T> { fn decryption_field<K: 'static>(self, context: Ctx) -> Option<Decryption<Option<P>, K>> { if !T::DECRYPTABLE { return None; } - Some(match self { - Some(item) => item - .decryption_field(context) + Some(Decryption::optional(self.map(|item| { + item.decryption_field(context) .unwrap_or_else(|| Decryption::failed(Error::NotOpened)) - .map(Some), - None => Decryption::ready(None), - }) + }))) } } -/// Owned nonempty context for ciphertext-only operations. These do not require a -/// PRF encoding; a type that implements only `IntoAad` remains sufficient. -#[derive(Clone, Debug)] -pub struct AeadContext(AadPiece<'static>); -impl<'a, T: IntoAad<'a>> From<NonEmpty<T>> for AeadContext { - fn from(value: NonEmpty<T>) -> Self { - Self(value.into_aad_piece().into_owned()) - } -} -impl From<CallerContext> for AeadContext { - fn from(value: CallerContext) -> Self { - Self(value.aad) - } -} -impl MaybeEmpty for AeadContext { - fn is_empty(&self) -> bool { - false - } -} -impl<'a> IntoAad<'a> for AeadContext { - fn into_aad(self) -> Aad<'a> { - self.0.into_aad() - } - fn into_aad_piece(self) -> AadPiece<'a> { - self.0 - } -} -impl AeadContext { - fn validated(self) -> Result<NonEmpty<Self>, Error> { - NonEmpty::new(self).map_err(|e| Error::Other(Box::new(e))) - } -} -macro_rules! integer_contexts { - ($($ty:ty),*) => {$ ( - impl From<$ty> for CallerContext { - fn from(value:$ty)->Self { Self::from(NonEmpty::<$ty>::from(value)) } - } - impl From<$ty> for AeadContext { - fn from(value:$ty)->Self { Self::from(NonEmpty::<$ty>::from(value)) } - } - impl From<$ty> for DeclaredContext { - fn from(value:$ty)->Self { Self::from(NonEmpty::<$ty>::from(value)) } - } - )*}; -} -integer_contexts!(u8, u16, u32, u64, u128, i8, i16, i32, i64, i128); - -/// Whether a field contains recoverable ciphertext. Derives assert exactly one -/// such field per plaintext value; query terms are never recovery candidates. +/// Whether a field type holds recoverable ciphertext. The derives require +/// exactly one such field per plaintext value; a term is never one. pub trait Decryptable { + /// `true` for ciphertext, `false` for a term. const DECRYPTABLE: bool; } impl Decryptable for StackCipherText { diff --git a/packages/stack-encrypt/src/target/pending.rs b/packages/stack-encrypt/src/target/pending.rs index f0411da6c..942219347 100644 --- a/packages/stack-encrypt/src/target/pending.rs +++ b/packages/stack-encrypt/src/target/pending.rs @@ -144,11 +144,16 @@ impl<'a, T: 'a, K> Pending<'a, T, K> { /// type `T` is ever produced), and any assembly it is merged into fails /// without I/O — see the `failed` field. /// - /// Public because a [`DecryptField`](super::DecryptField) implementation - /// (including the derive's generated code) reaches for it when a - /// contract is broken at decrypt time — e.g. - /// [`Error::NotOpened`] for a field whose type declared + /// Public because a hand-written `EncryptFrom` / `DecryptInto` that + /// works with a cipher scope directly reaches for it when a contract is + /// broken — e.g. [`Error::NotOpened`] for a field whose type declared /// [`DECRYPTABLE`](super::Decryptable::DECRYPTABLE) but was passed over. + /// Inside an operation description, [`Encryption::failed`] and + /// [`Decryption::failed`] are the same thing without the scope; the + /// derives' generated code uses those. + /// + /// [`Encryption::failed`]: super::Encryption::failed + /// [`Decryption::failed`]: super::Decryption::failed pub fn failed(scope: impl CipherScope<'a, K>, error: Error) -> Self { Self { cipher: scope.cipher(), diff --git a/packages/stack-encrypt/src/target/transcode.rs b/packages/stack-encrypt/src/target/transcode.rs index 94ad53029..53949a1f2 100644 --- a/packages/stack-encrypt/src/target/transcode.rs +++ b/packages/stack-encrypt/src/target/transcode.rs @@ -1,67 +1,162 @@ -//! Consuming readers over native encryption output. Reading moves existing -//! leaves and containers; it does not serialize or build an intermediate tree. +//! Consuming readers over native encryption output. +//! +//! A destination that stores encrypted output in its own shape (an EQL +//! envelope, say) implements [`Transcode`] with a [`Visitor`]; the cipher's +//! native output is a [`Reader`] that drives it. Reading moves the existing +//! leaves, markers, and terms into the destination as they are: nothing is +//! serialised, re-encrypted, or gathered into an intermediate tree first. +//! +//! What a reader hands over is what the cipher produced, no more: a sealed +//! leaf or marker is authenticated ciphertext, but passthrough metadata and +//! terms are not, and a visitor must not present them as such. use crate::sem::{EqualityTerm, MatchConfig, MatchTerm, OpeTerm, OreTerm}; use crate::{BoxedPassthrough, CipherText, Error, SealedValue, StackCipherText}; /// An encrypted output that can drive a destination visitor. +/// +/// Implemented by the native [`StackCipherText`] tree and by every term +/// type; [`Encryption::transcode`](super::Encryption::transcode) calls it on +/// an operation's completed output. pub trait Reader: Sized { + /// Hand this output to `visitor`, consuming it. + /// + /// # Errors + /// + /// Whatever the visitor returns; for its default methods, that is + /// [`Error::UnsupportedShape`]. fn read<V: Visitor>(self, visitor: V) -> Result<V::Value, Error>; } -/// Destination construction. Unsupported shapes fail explicitly by default. -/// Markers remain sealed: none of these methods authenticates their contents. +/// How a destination is built from each shape of encrypted output. +/// +/// Every method has a default that refuses with [`Error::UnsupportedShape`], +/// so a destination implements only the shapes it stores: a scalar column +/// takes `sealed` and nothing else, and is then refused a sequence rather +/// than handed one flattened. Markers arrive sealed: none of these methods +/// authenticates what it is given. pub trait Visitor: Sized { + /// The destination this visitor builds. type Value; + /// One sealed leaf: a scalar's ciphertext. + /// + /// # Errors + /// + /// Refuses with [`Error::UnsupportedShape`] unless overridden. fn sealed(self, _: SealedValue) -> Result<Self::Value, Error> { - Err(shape()) + Err(Error::UnsupportedShape) } + /// A non-empty sequence, read one item at a time. + /// + /// # Errors + /// + /// Refuses with [`Error::UnsupportedShape`] unless overridden. fn sequence<R: SequenceReader>(self, _: R) -> Result<Self::Value, Error> { - Err(shape()) + Err(Error::UnsupportedShape) } + /// A non-empty map, read one entry at a time. + /// + /// # Errors + /// + /// Refuses with [`Error::UnsupportedShape`] unless overridden. fn map<R: MapReader>(self, _: R) -> Result<Self::Value, Error> { - Err(shape()) - } + Err(Error::UnsupportedShape) + } + /// The sealed marker of an absent optional. It is ciphertext, not a + /// null: opening it under the wrong context fails like any leaf. + /// + /// # Errors + /// + /// Refuses with [`Error::UnsupportedShape`] unless overridden. fn absent(self, _: SealedValue) -> Result<Self::Value, Error> { - Err(shape()) + Err(Error::UnsupportedShape) } + /// The sealed marker of an empty sequence. + /// + /// # Errors + /// + /// Refuses with [`Error::UnsupportedShape`] unless overridden. fn empty_sequence(self, _: SealedValue) -> Result<Self::Value, Error> { - Err(shape()) + Err(Error::UnsupportedShape) } + /// The sealed marker of an empty map. + /// + /// # Errors + /// + /// Refuses with [`Error::UnsupportedShape`] unless overridden. fn empty_map(self, _: SealedValue) -> Result<Self::Value, Error> { - Err(shape()) - } - /// Passthrough metadata is not AEAD-authenticated by the reader. + Err(Error::UnsupportedShape) + } + /// Passthrough metadata the plaintext carried alongside its encrypted + /// fields. Not authenticated: the reader hands it over as it was given. + /// + /// # Errors + /// + /// Refuses with [`Error::UnsupportedShape`] unless overridden. fn passthrough(self, _: BoxedPassthrough) -> Result<Self::Value, Error> { - Err(shape()) + Err(Error::UnsupportedShape) } + /// An equality term. One-way, and not authenticated. + /// + /// # Errors + /// + /// Refuses with [`Error::UnsupportedShape`] unless overridden. fn equality(self, _: EqualityTerm) -> Result<Self::Value, Error> { - Err(shape()) + Err(Error::UnsupportedShape) } + /// A match term. One-way, and not authenticated. + /// + /// # Errors + /// + /// Refuses with [`Error::UnsupportedShape`] unless overridden. fn matching<O: MatchConfig>(self, _: MatchTerm<O>) -> Result<Self::Value, Error> { - Err(shape()) + Err(Error::UnsupportedShape) } + /// An order-revealing term. One-way, and not authenticated. + /// + /// # Errors + /// + /// Refuses with [`Error::UnsupportedShape`] unless overridden. fn ore<T: cllw_ore::CllwOreEncrypt>(self, _: OreTerm<T>) -> Result<Self::Value, Error> { - Err(shape()) + Err(Error::UnsupportedShape) } + /// An order-preserving term. One-way, and not authenticated. + /// + /// # Errors + /// + /// Refuses with [`Error::UnsupportedShape`] unless overridden. fn ope<T: cllw_ore::CllwOpeEncrypt>(self, _: OpeTerm<T>) -> Result<Self::Value, Error> { - Err(shape()) + Err(Error::UnsupportedShape) } } -/// A destination chooses its visitor without receiving plaintext or a cipher. +/// A destination that names its visitor, so an +/// [`Encryption`](super::Encryption) can be transcoded into it by type alone. +/// Choosing the visitor sees neither plaintext nor cipher. pub trait Transcode: Sized { + /// The visitor that builds this destination. type Visitor: Visitor<Value = Self>; + /// A fresh visitor for one output. fn visitor() -> Self::Visitor; } -/// Streaming access to an existing sequence. The child reader owns its output. +/// Streaming access to an existing sequence. Each item is itself a +/// [`Reader`], so nesting is read the same way; the child owns its output. pub trait SequenceReader { + /// One item of the sequence. type Item: Reader; + /// The next item, in the sequence's order. fn next(&mut self) -> Option<Self::Item>; + /// How many items remain — a capacity hint, not a promise. fn remaining(&self) -> usize; } -/// Map keys retain their original spelling and order. They must not be renamed -/// when opening: Vitamin C uses them to derive each entry's authenticated context. +/// Streaming access to an existing map. +/// +/// Keys keep their original spelling and order, and a destination must store +/// them exactly: Vitamin C derives each entry's authenticated context from +/// its key, so a renamed key fails to open. pub trait MapReader { + /// One entry's value. type Item: Reader; + /// The next entry, in the map's order. fn next(&mut self) -> Option<(String, Self::Item)>; + /// How many entries remain — a capacity hint, not a promise. fn remaining(&self) -> usize; } impl SequenceReader for std::vec::IntoIter<StackCipherText> { @@ -115,6 +210,3 @@ impl<T: cllw_ore::CllwOpeEncrypt> Reader for OpeTerm<T> { visitor.ope(self) } } -fn shape() -> Error { - Error::Other("destination does not support this encrypted output shape".into()) -} diff --git a/packages/stack-encrypt/tests/target.rs b/packages/stack-encrypt/tests/target.rs index 3f1ac08da..e7111c293 100644 --- a/packages/stack-encrypt/tests/target.rs +++ b/packages/stack-encrypt/tests/target.rs @@ -473,7 +473,11 @@ async fn third_party_output_wraps_a_core_term() { .encrypt_into_with_context(&keyset, nonempty!("users/name")) .await .unwrap(); - assert_eq!(stored.0, canonical.into_bytes()); + assert_eq!( + stored.0, + canonical.into_bytes(), + "a hand-written target built on the equality operation matches the term itself" + ); } // --- Guard rails -------------------------------------------------------------- diff --git a/packages/stack-encrypt/tests/transcode.rs b/packages/stack-encrypt/tests/transcode.rs index b90bf5380..2c6f758bc 100644 --- a/packages/stack-encrypt/tests/transcode.rs +++ b/packages/stack-encrypt/tests/transcode.rs @@ -100,23 +100,42 @@ struct TextEq { v: u8, } +fn other_column() -> NonEmpty<Identifier> { + NonEmpty::new(Identifier { + table: "users".into(), + column: "other".into(), + }) + .unwrap() +} + #[tokio::test] -async fn stored_identifier_supplies_all_operations_and_checks_before_retrieval() { - let (cipher, generates, retrieves) = common::counting_cipher().await; +async fn stored_identifier_supplies_every_operations_context() { + let (cipher, generates, _) = common::counting_cipher().await; let keyset = cipher.default_keyset(); let email = "alice@example.com".to_owned(); let record: TextEq = keyset .encrypt_as(&email, Identifier::email()) .await .unwrap(); - assert_eq!(record.i, Identifier::email().into_inner()); - assert_eq!(record.v, 3); - assert_eq!(generates.load(Ordering::SeqCst), 1); + assert_eq!( + record.i, + Identifier::email().into_inner(), + "the identifier is stored in the record as given" + ); + assert_eq!(record.v, 3, "a defaulted field is filled, not derived"); + assert_eq!( + generates.load(Ordering::SeqCst), + 1, + "one ciphertext leaf means one data key, in one batch" + ); let probe = keyset .equality_term(email.clone(), Identifier::email()) .await .unwrap(); - assert_eq!(record.hm, probe); + assert_eq!( + record.hm, probe, + "the term is derived under the stored identifier, so a probe under it matches" + ); // Storage envelope names c/hm add no extra AAD; the canonical path opens it. let opened: String = cipher .decrypt( @@ -125,13 +144,25 @@ async fn stored_identifier_supplies_all_operations_and_checks_before_retrieval() ) .await .unwrap(); - assert_eq!(opened, email); + assert_eq!( + opened, email, + "the canonical path opens a leaf the record sealed under the identifier alone" + ); +} - // The reverse direction starts with canonical ciphertext. +#[tokio::test] +async fn canonical_ciphertext_opens_through_the_record() { + let cipher = common::stack_cipher().await; + let keyset = cipher.default_keyset(); + let email = "alice@example.com".to_owned(); let canonical = keyset .encrypt(email.clone(), Identifier::email()) .await .unwrap(); + let probe = keyset + .equality_term(email.clone(), Identifier::email()) + .await + .unwrap(); let record = TextEq { i: Identifier::email().into_inner(), c: canonical.read(LeafVisitor).unwrap(), @@ -142,32 +173,59 @@ async fn stored_identifier_supplies_all_operations_and_checks_before_retrieval() .decrypt_as(record, ExpectedContext::default()) .await .unwrap(); - assert_eq!(opened, email); - let before = retrieves.load(Ordering::SeqCst); + assert_eq!( + opened, email, + "a record assembled from canonical output opens under its stored identifier" + ); +} + +#[tokio::test] +async fn expected_identifier_is_checked_before_any_key_is_retrieved() { + let (cipher, _, retrieves) = common::counting_cipher().await; + let keyset = cipher.default_keyset(); + let email = "alice@example.com".to_owned(); let record: TextEq = keyset .encrypt_as(&email, Identifier::email()) .await .unwrap(); - let wrong = NonEmpty::new(Identifier { - table: "users".into(), - column: "other".into(), - }) - .unwrap(); - assert!(cipher - .decrypt_as::<String, _>(record, wrong.into()) - .await - .is_err()); - assert_eq!(retrieves.load(Ordering::SeqCst), before); + let before = retrieves.load(Ordering::SeqCst); + let result = cipher + .decrypt_as::<String, _>(record, other_column().into()) + .await; + assert!( + matches!(result, Err(Error::ContextMismatch { .. })), + "a stored identifier that differs from the expected one is a mismatch, got {result:?}" + ); + assert_eq!( + retrieves.load(Ordering::SeqCst), + before, + "the mismatch is refused before ZeroKMS is asked for a key" + ); +} + +#[tokio::test] +async fn empty_stored_identifier_is_refused_before_any_key_is_retrieved() { + let (cipher, _, retrieves) = common::counting_cipher().await; + let keyset = cipher.default_keyset(); + let email = "alice@example.com".to_owned(); let mut record: TextEq = keyset .encrypt_as(&email, Identifier::email()) .await .unwrap(); record.i.column.clear(); - assert!(cipher + let before = retrieves.load(Ordering::SeqCst); + let result = cipher .decrypt_as::<String, _>(record, Default::default()) - .await - .is_err()); - assert_eq!(retrieves.load(Ordering::SeqCst), before); + .await; + assert!( + result.is_err(), + "a stored identifier is data, not a proof: an empty one fails validation" + ); + assert_eq!( + retrieves.load(Ordering::SeqCst), + before, + "validation runs before ZeroKMS is asked for a key" + ); } #[derive(Clone)] @@ -196,7 +254,10 @@ async fn ciphertext_uses_plaintexts_native_contract_without_serde() { ) .await .unwrap(); - assert_eq!(opened, "native"); + assert_eq!( + opened, "native", + "a plaintext without Serde seals through its own Encrypt impl and opens canonically" + ); let text = String::from("borrowed"); let result: StoredLeaf = keyset .encrypt_as(&text.as_str(), nonempty!("value").into()) @@ -209,12 +270,69 @@ async fn ciphertext_uses_plaintexts_native_contract_without_serde() { ) .await .unwrap(); - assert_eq!(opened, text); + assert_eq!(opened, text, "a borrowed plaintext seals the same way"); +} + +#[tokio::test] +async fn scalar_destination_refuses_a_sequence() { + let cipher = common::stack_cipher().await; + let keyset = cipher.default_keyset(); // A scalar destination must refuse a sequence rather than flatten or serialize it. let result = keyset .encrypt_as::<_, StoredLeaf>(&vec![1u32, 2], nonempty!("value").into()) .await; - assert!(result.is_err()); + assert!( + matches!(result, Err(Error::UnsupportedShape)), + "a visitor that only takes `sealed` refuses a sequence by type, got {:?}", + result.err() + ); +} + +// A target whose declaration carries every context it needs asks its caller +// for none: `Context = ()`. +struct FixedLeaf(StoredLeaf); +impl<S: Encrypt + Clone> EncryptFrom<S> for FixedLeaf { + type Context = (); + fn encryption<'s, K: 'static>((): ()) -> Encryption<'s, S, Self, K> + where + S: 's, + { + target::ciphertext(nonempty!("fixed/leaf")) + .transcode() + .map(Self) + } +} +impl<P: stack_encrypt::Decrypt<'static> + 'static> DecryptInto<P> for FixedLeaf { + type Context = (); + fn decryption<K: 'static>(self, (): ()) -> Decryption<P, K> { + self.0.decryption(nonempty!("fixed/leaf").into()) + } +} + +#[tokio::test] +async fn unit_context_target_supplies_its_own_context() { + use stack_encrypt::EncryptInto; + let cipher = common::stack_cipher().await; + let keyset = cipher.default_keyset(); + let text = String::from("fixed"); + let stored: FixedLeaf = text.encrypt_into(&keyset).await.unwrap(); + let opened: String = cipher + .decrypt( + CipherText::Single(SealedValue::from_bytes(&stored.0 .0).unwrap()), + nonempty!("fixed/leaf"), + ) + .await + .unwrap(); + assert_eq!( + opened, text, + "the leaf was sealed under the context the declaration named, not one the caller gave" + ); + let stored: FixedLeaf = keyset.encrypt_as(&text, ()).await.unwrap(); + let opened: String = cipher.decrypt_as(stored, ()).await.unwrap(); + assert_eq!( + opened, text, + "and it opens back through the declaration alone" + ); } // An illustrative final storage format. Each native child is consumed directly @@ -294,9 +412,20 @@ async fn native_readers_preserve_map_keys_and_authenticated_markers() { .decrypt(stored.native(), nonempty!("document")) .await .unwrap(); - assert_eq!(opened, value); - assert_eq!(generates.load(Ordering::SeqCst), 1); - assert_eq!(retrieves.load(Ordering::SeqCst), 1); + assert_eq!( + opened, value, + "a tree read into a destination and back opens as the original value" + ); + assert_eq!( + generates.load(Ordering::SeqCst), + 1, + "the whole document sealed under one data key" + ); + assert_eq!( + retrieves.load(Ordering::SeqCst), + 1, + "and opened with one retrieval" + ); // Transcoding keeps the authentication: changing a cryptographic map key fails. let tree = keyset.encrypt(value, nonempty!("document")).await.unwrap(); let Stored::Object(mut entries) = tree.read(TreeVisitor).unwrap() else { @@ -306,7 +435,10 @@ async fn native_readers_preserve_map_keys_and_authenticated_markers() { let opened: Result<HashMap<String, Vec<Option<String>>>, _> = cipher .decrypt(Stored::Object(entries).native(), nonempty!("document")) .await; - assert!(opened.is_err()); + assert!( + opened.is_err(), + "a map key is part of its entry's authenticated context: renaming it fails to open" + ); let tree = keyset .encrypt(HashMap::<String, String>::new(), nonempty!("document")) .await @@ -318,12 +450,19 @@ async fn native_readers_preserve_map_keys_and_authenticated_markers() { let opened: Result<HashMap<String, String>, _> = cipher .decrypt(CipherText::EmptyMap(marker), nonempty!("wrong")) .await; - assert!(opened.is_err()); + assert!( + opened.is_err(), + "an empty-map marker is sealed under its context, not an unauthenticated empty container" + ); let stored = CipherText::Passthrough(Box::new(17u32) as stack_encrypt::BoxedPassthrough) .read(TreeVisitor) .unwrap(); let Stored::Metadata(value) = stored else { panic!("metadata") }; - assert_eq!(*value.downcast::<u32>().unwrap(), 17); + assert_eq!( + *value.downcast::<u32>().unwrap(), + 17, + "passthrough metadata reaches the destination as it was given" + ); } From 0505537e48c70ec865fd8c4634242c9216f81187 Mon Sep 17 00:00:00 2001 From: Dan Draper <dan@cipherstash.com> Date: Thu, 17 Sep 2026 14:39:29 -0400 Subject: [PATCH 554/686] docs(stack-encrypt-derive): show a field in your own storage format, and drop a migration note about methods that no longer exist MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review comments on the derive docs. The crate-level docs said a custom storage field "can declare a core operation followed by `.transcode()`" and left it there; that is now a worked example — a `LeafBytes` column with its `Visitor`, `Transcode`, `Decryptable`, and `EncryptFrom` implementations, used as a field of a derived record and opened back through the canonical path, so the reader can see that the bytes the visitor stored are the leaf itself. The attributes doc's closing paragraph referred to "the old methods that received plaintext and a cipher", which were removed on this branch and were never published; it now says what executes a declaration and what a hand-written target implements. --- .../stack-encrypt-derive/docs/attributes.md | 10 ++- packages/stack-encrypt-derive/src/lib.rs | 88 ++++++++++++++++++- 2 files changed, 92 insertions(+), 6 deletions(-) diff --git a/packages/stack-encrypt-derive/docs/attributes.md b/packages/stack-encrypt-derive/docs/attributes.md index e63d46426..c863dbf20 100644 --- a/packages/stack-encrypt-derive/docs/attributes.md +++ b/packages/stack-encrypt-derive/docs/attributes.md @@ -51,10 +51,12 @@ from a `NonEmpty<T>` or a supported integer. Both encodings and the descriptor's structured identity are preserved when borrowing context data is converted into an owned declaration. No context is inferred from a Rust type's name. -The blanket `EncryptInto` and `DecryptFrom` convenience traits execute these -declarations. Import `DecryptFrom` to call `.decrypt_into(...)`. Custom targets -implement the declaration methods `encryption` and `decryption`; the old methods -that received plaintext and a cipher are no longer extension points. +A declaration is executed by `keyset.encrypt_as(&value, context)` and +`cipher.decrypt_as(record, context)`, or through the blanket `EncryptInto` and +`DecryptFrom` traits for the source-side spelling (`value.encrypt_into(&keyset)`, +`record.decrypt_into(&cipher, context)`). A hand-written target implements the +same two declaration methods the derives emit, `EncryptFrom::encryption` and +`DecryptInto::decryption`; neither receives the plaintext or a cipher. Every attribute except `plaintext` is singular, and repeating one is a compile error rather than a silent overwrite (`plaintext` is repeatable, diff --git a/packages/stack-encrypt-derive/src/lib.rs b/packages/stack-encrypt-derive/src/lib.rs index 1c528cbb3..ad956869d 100644 --- a/packages/stack-encrypt-derive/src/lib.rs +++ b/packages/stack-encrypt-derive/src/lib.rs @@ -39,10 +39,94 @@ //! //! Ciphertext and term operations compose before awaiting, preserving batched //! key requests. Terms alone need only their respective PRF or ordering trait. -//! A custom storage field can declare a core operation followed by `.transcode()`; -//! its visitor receives native encrypted output, without an intermediate format. //! Query-only targets derive `EncryptFrom` alone. //! +//! # A field in your own storage format +//! +//! A record's fields need not be the core types. A field type that stores +//! encrypted output in its own shape declares the core operation that produces +//! it and finishes the declaration with `.transcode()`: the cipher runs the +//! operation and then hands its native output, leaf by leaf, to a +//! `Visitor` (in `stack_encrypt::target::transcode`) the field type +//! names. The visitor implements only the shapes the field stores; the trait's +//! defaults refuse every other shape with `Error::UnsupportedShape`. Nothing +//! is serialised, re-encrypted, or gathered into an intermediate tree on the +//! way, so the bytes the visitor stores are the bytes the canonical path opens. +//! +//! ``` +//! use stack_encrypt::sem::EqualityTerm; +//! use stack_encrypt::target::transcode::{Transcode, Visitor}; +//! use stack_encrypt::target::{self, CallerContext}; +//! use stack_encrypt::{ +//! CipherText, Decryptable, Encrypt, EncryptFrom, Encryption, Error, NonEmpty, SealedValue, +//! StackCipher, +//! }; +//! use stack_kms::FakeDataKeySource; +//! +//! /// A column that stores one sealed leaf as bytes. +//! struct LeafBytes(Vec<u8>); +//! +//! struct LeafBytesVisitor; +//! impl Visitor for LeafBytesVisitor { +//! type Value = LeafBytes; +//! // The one shape this column stores. A sequence or a map would reach a +//! // default method and be refused, never flattened. +//! fn sealed(self, leaf: SealedValue) -> Result<LeafBytes, Error> { +//! Ok(LeafBytes(leaf.to_bytes())) +//! } +//! } +//! impl Transcode for LeafBytes { +//! type Visitor = LeafBytesVisitor; +//! fn visitor() -> LeafBytesVisitor { +//! LeafBytesVisitor +//! } +//! } +//! // Whether the field holds recoverable ciphertext. The derive asks every +//! // field, so that a record can itself be a field of another record. +//! impl Decryptable for LeafBytes { +//! const DECRYPTABLE: bool = true; +//! } +//! // The declaration: the canonical ciphertext operation, read into this type. +//! impl<S: Encrypt + Clone> EncryptFrom<S> for LeafBytes { +//! type Context = CallerContext; +//! fn encryption<'s, K: 'static>(context: CallerContext) -> Encryption<'s, S, Self, K> +//! where +//! S: 's, +//! { +//! target::ciphertext(context).transcode() +//! } +//! } +//! +//! // The record uses it like any core field type. +//! #[derive(EncryptFrom)] +//! #[stash(plaintext = String)] +//! struct TextEq { +//! #[stash(context_field)] +//! identifier: String, +//! c: LeafBytes, +//! hm: EqualityTerm, +//! } +//! +//! # tokio::runtime::Builder::new_current_thread().enable_all().build().unwrap().block_on(async { +//! let cipher = StackCipher::builder().kms(FakeDataKeySource::new()).init().await?; +//! let keyset = cipher.default_keyset(); +//! let value = "alice@example.com".to_owned(); +//! let context = NonEmpty::new("users/email".to_owned())?; +//! let encrypted: TextEq = keyset.encrypt_as(&value, context.clone()).await?; +//! +//! // What the column holds is the leaf itself: the canonical path opens it. +//! let leaf = SealedValue::from_bytes(&encrypted.c.0)?; +//! let opened: String = cipher.decrypt(CipherText::Single(leaf), context).await?; +//! assert_eq!(opened, value); +//! # Ok::<(), Box<dyn std::error::Error>> (()) +//! # }).unwrap(); +//! ``` +//! +//! To recover the plaintext through the record rather than the canonical path, +//! the field type also implements `DecryptInto` and `DecryptField`, and the +//! record derives `DecryptInto`; the crate's `transcode` integration test shows +//! the full set. +//! #![doc = include_str!("../docs/attributes.md")] #![doc(html_favicon_url = "https://cipherstash.com/favicon.ico")] #![deny(unsafe_code)] From ebd49123e4f5c8b4f827474a5787c532cc60d10b Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Thu, 17 Sep 2026 19:14:16 +0000 Subject: [PATCH 555/686] fix(stack-encrypt): a decryption holds its local failure, so a column stops declaring at the first refused row MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Decryption::all` collected every item before returning, so a `Vec<T>` of records that validate a stored context validated every row — rendering a descriptor for each mismatch — before `Pending::collect` could stop at the first. A `Decryption` now holds a failure as a value rather than a closure that yields one: `all` consumes its iterator only as far as the first failed item, `zip` fails without executing the other side, and `map` carries the failure through. The error a caller sees is unchanged. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HNS9rirkcPxUHUrNxkzULW --- .../stack-encrypt/src/target/operations.rs | 83 +++++++++++++------ packages/stack-encrypt/tests/target.rs | 32 ++++++- 2 files changed, 88 insertions(+), 27 deletions(-) diff --git a/packages/stack-encrypt/src/target/operations.rs b/packages/stack-encrypt/src/target/operations.rs index 32e64af11..523474442 100644 --- a/packages/stack-encrypt/src/target/operations.rs +++ b/packages/stack-encrypt/src/target/operations.rs @@ -68,7 +68,15 @@ pub struct Encryption<'s, S, T, K> { /// `decrypt_as`. Nothing runs, and no key is retrieved, until then. #[must_use = "a decryption description does nothing until a cipher executes it"] pub struct Decryption<T, K> { - open: Open<T, K>, + inner: Opening<T, K>, +} +/// A declaration either failed while it was being built, or has an opening +/// to execute. A failure is held as a value rather than a closure that +/// yields it, so a combinator can see it without executing anything: that +/// is what lets [`Decryption::all`] stop at the first failed item. +enum Opening<T, K> { + Failed(Error), + Open(Open<T, K>), } impl<S, T, K> fmt::Debug for Encryption<'_, S, T, K> { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { @@ -77,7 +85,11 @@ impl<S, T, K> fmt::Debug for Encryption<'_, S, T, K> { } impl<T, K> fmt::Debug for Decryption<T, K> { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.debug_struct("Decryption").finish_non_exhaustive() + let mut debug = f.debug_struct("Decryption"); + if let Opening::Failed(error) = &self.inner { + let _ = debug.field("failed", error); + } + debug.finish_non_exhaustive() } } @@ -216,12 +228,18 @@ term_operation!( ); impl<T: 'static, K: 'static> Decryption<T, K> { - /// Reject the opening: execution yields `error` without I/O, and any - /// description this is zipped into fails with it. The derives use it - /// when a stored context fails validation. + /// Reject the opening: execution yields `error` without I/O, any + /// description this is zipped into fails with it, and a collection + /// ([`all`](Self::all)) stops at it. The derives use it when a stored + /// context fails validation. pub fn failed(error: Error) -> Self { Self { - open: Box::new(move |cipher| Pending::failed(cipher, error)), + inner: Opening::Failed(error), + } + } + fn open(open: Open<T, K>) -> Self { + Self { + inner: Opening::Open(open), } } /// A description whose output is already known: a defaulted field, or @@ -230,37 +248,49 @@ impl<T: 'static, K: 'static> Decryption<T, K> { where T: MaybeSend, { - Self { - open: Box::new(move |cipher| Pending::ready(cipher, Ok(value))), - } + Self::open(Box::new(move |cipher| Pending::ready(cipher, Ok(value)))) } /// Convert the recovered value. pub fn map<U: 'static, F>(self, f: F) -> Decryption<U, K> where F: FnOnce(T) -> U + MaybeSend + 'static, { - Decryption { - open: Box::new(move |cipher| (self.open)(cipher).map(f)), + match self.inner { + Opening::Failed(error) => Decryption::failed(error), + Opening::Open(open) => Decryption::open(Box::new(move |cipher| open(cipher).map(f))), } } - /// Open both, retrieving their keys in one batch. + /// Open both, retrieving their keys in one batch. A failed side fails + /// the pair, the left one first, without executing the other. pub fn zip<U: 'static>(self, other: Decryption<U, K>) -> Decryption<(T, U), K> { - Decryption { - open: Box::new(move |cipher| (self.open)(cipher).zip((other.open)(cipher))), + match (self.inner, other.inner) { + (Opening::Failed(error), _) | (_, Opening::Failed(error)) => Decryption::failed(error), + (Opening::Open(left), Opening::Open(right)) => { + Decryption::open(Box::new(move |cipher| left(cipher).zip(right(cipher)))) + } } } /// Open every description, retrieving all their keys in one batch, and /// collect the results in order. + /// + /// `items` is consumed only as far as its first failed description: the + /// column fails with that error, and the descriptions after it are never + /// built. A `Vec<T>` whose rows validate a stored context does not go on + /// validating rows once one has been refused. pub fn all<I>(items: I) -> Decryption<Vec<T>, K> where I: IntoIterator<Item = Self>, { - let items: Vec<Self> = items.into_iter().collect(); - Decryption { - open: Box::new(move |cipher| { - Pending::collect(cipher, items.into_iter().map(|item| (item.open)(cipher))) - }), + let mut opens = Vec::new(); + for item in items { + match item.inner { + Opening::Failed(error) => return Decryption::failed(error), + Opening::Open(open) => opens.push(open), + } } + Decryption::open(Box::new(move |cipher| { + Pending::collect(cipher, opens.into_iter().map(|open| open(cipher))) + })) } /// An absent item recovers as `None` without I/O. fn optional(item: Option<Self>) -> Decryption<Option<T>, K> @@ -275,7 +305,10 @@ impl<T: 'static, K: 'static> Decryption<T, K> { /// Execute under a scope. A [`KeysetCipher`] scope refuses a leaf from any /// other keyset; a [`StackCipher`] scope opens leaves from any. fn open_in<'a>(self, scope: impl CipherScope<'a, K>) -> Pending<'a, T, K> { - let pending = (self.open)(scope.cipher()); + let pending = match self.inner { + Opening::Failed(error) => return Pending::failed(scope, error), + Opening::Open(open) => open(scope.cipher()), + }; match scope.keyset() { Some(id) => pending.scoped_to(id), None => pending, @@ -289,12 +322,10 @@ pub fn open<P: crate::Decrypt<'static> + 'static, K: 'static>( context: impl Into<AeadContext>, ) -> Decryption<P, K> { let context = context.into(); - Decryption { - open: Box::new(move |cipher| match context.validated() { - Ok(ctx) => open_native(tree, cipher, ctx), - Err(e) => Pending::failed(cipher, e), - }), - } + Decryption::open(Box::new(move |cipher| match context.validated() { + Ok(ctx) => open_native(tree, cipher, ctx), + Err(e) => Pending::failed(cipher, e), + })) } impl<K: 'static> KeysetCipher<'_, K> { diff --git a/packages/stack-encrypt/tests/target.rs b/packages/stack-encrypt/tests/target.rs index e7111c293..227675874 100644 --- a/packages/stack-encrypt/tests/target.rs +++ b/packages/stack-encrypt/tests/target.rs @@ -5,7 +5,8 @@ //! assembly, settling it is one batched ZeroKMS call per request kind. use std::cmp::Ordering; -use std::sync::atomic::Ordering as AtomicOrdering; +use std::sync::atomic::{AtomicUsize, Ordering as AtomicOrdering}; +use std::sync::Arc; use stack_encrypt::sem::{EqualityTerm, MatchConfig, MatchOptions, MatchTerm, OreTerm}; use stack_encrypt::target::{ @@ -671,6 +672,35 @@ async fn containers_pass_the_context_through_to_their_leaves() { assert!(empty.is_empty()); } +/// A stored target whose declaration is refused before any key is named — +/// the shape of a derived record whose stored context fails validation. +/// Counts how many times it was asked to declare. +struct Refused(Arc<AtomicUsize>); +impl DecryptInto<u32> for Refused { + type Context = CallerContext; + fn decryption<K: 'static>(self, _: Self::Context) -> Decryption<u32, K> { + self.0.fetch_add(1, AtomicOrdering::SeqCst); + Decryption::failed(Error::NotOpened) + } +} + +#[tokio::test] +async fn a_column_stops_declaring_at_the_first_refused_row() { + let (cipher, _, retrieves) = counting_cipher().await; + let declared = Arc::new(AtomicUsize::new(0)); + let column: Vec<Refused> = (0..1_000).map(|_| Refused(Arc::clone(&declared))).collect(); + + // `Vec<T>` declares its rows lazily and the collection stops at the + // first refusal: the rows after it are never asked, and nothing is + // retrieved. + let result: Result<Vec<u32>, _> = cipher + .decrypt_as(column, CallerContext::from(nonempty!("users/age"))) + .await; + assert!(matches!(result, Err(Error::NotOpened)), "{result:?}"); + assert_eq!(declared.load(AtomicOrdering::SeqCst), 1); + assert_eq!(retrieves.load(AtomicOrdering::SeqCst), 0); +} + #[tokio::test] async fn a_failed_field_fails_the_record_before_any_kms_call() { // One failed field must not cause the record's other fields to mint From 71db1b2ddd291d04f9a2930766923069776923e4 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Thu, 17 Sep 2026 19:14:16 +0000 Subject: [PATCH 556/686] feat(stack-encrypt-derive): a ciphertext-only record declares `context_type = AeadContext` and accepts an `IntoAad`-only context MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A record whose fields take the caller's context declared `CallerContext` whatever its fields were, and building one needs both of Vitamin C's encodings. A record made only of `StackCipherText` fields therefore refused a context type that implements `IntoAad` alone — `WorkspaceId`, say — where the leaf it wraps accepts it through `AeadContext`. `#[stash(context_type = Type)]` names the record's associated `Context` for that shape. `AeadContext` gains `under`, so a field with a literal context of its own is extended by an AEAD-only caller context as it is by a `CallerContext`. A term field beside `AeadContext` is a compile error at the record; `context_field`, `struct` and all-literal records settle their context themselves and refuse the attribute. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HNS9rirkcPxUHUrNxkzULW --- .../stack-encrypt-derive/docs/attributes.md | 31 ++++ packages/stack-encrypt-derive/src/attrs.rs | 33 ++++- packages/stack-encrypt-derive/src/lib.rs | 5 +- packages/stack-encrypt-derive/src/shape.rs | 137 +++++++++++++++++- packages/stack-encrypt/src/target/context.rs | 18 ++- packages/stack-encrypt/src/target/mod.rs | 13 +- packages/stack-encrypt/tests/derive.rs | 65 ++++++++- .../tests/ui/aead_context_with_term.rs | 15 ++ .../tests/ui/aead_context_with_term.stderr | 40 +++++ .../tests/ui/context_type_conflicts.rs | 33 +++++ .../tests/ui/context_type_conflicts.stderr | 29 ++++ .../tests/ui/pass/aead_only_context.rs | 64 ++++++++ 12 files changed, 469 insertions(+), 14 deletions(-) create mode 100644 packages/stack-encrypt/tests/ui/aead_context_with_term.rs create mode 100644 packages/stack-encrypt/tests/ui/aead_context_with_term.stderr create mode 100644 packages/stack-encrypt/tests/ui/context_type_conflicts.rs create mode 100644 packages/stack-encrypt/tests/ui/context_type_conflicts.stderr create mode 100644 packages/stack-encrypt/tests/ui/pass/aead_only_context.rs diff --git a/packages/stack-encrypt-derive/docs/attributes.md b/packages/stack-encrypt-derive/docs/attributes.md index c863dbf20..6dbb4eb73 100644 --- a/packages/stack-encrypt-derive/docs/attributes.md +++ b/packages/stack-encrypt-derive/docs/attributes.md @@ -9,6 +9,7 @@ All attributes live under `#[stash(...)]`. | `plaintext = Type` | The record is an encrypted form of `Type`, every field derived from the whole value. Repeatable: one impl per listed type. Omit it for an impl generic over the plaintext (see below). | | `struct = Type` | The record encrypts the struct `Type` field by field: every derived field is derived from the plaintext field of its own name, under the context `"<context>/<field>"` (see [Structs, field by field](#structs-field-by-field)). Exclusive with `plaintext`; requires `context`. | | `context = "..."` | With `struct` only: the first half of every field's inferred context — the stored data's name. Required, never inferred from the type's name, and must not be empty. | +| `context_type = Type` | The record's associated `Context`, for a record whose fields take the caller's context: what the caller passes. Defaults to `CallerContext`; `AeadContext` for a record made only of ciphertexts, so an `IntoAad`-only context type is accepted (see [Which context a record takes](#which-context-a-record-takes)). Not with `context_field` or `struct`. | | `crate = "path"` | Where to find `stack_encrypt` in the generated code (default `::stack_encrypt`), for use through a re-export. | `plaintext` must be an owned type: the generated impl has no lifetime to give @@ -51,6 +52,36 @@ from a `NonEmpty<T>` or a supported integer. Both encodings and the descriptor's structured identity are preserved when borrowing context data is converted into an owned declaration. No context is inferred from a Rust type's name. +## Which context a record takes + +`CallerContext` holds both of Vitamin C's encodings of the context — the AEAD +one a ciphertext is sealed under and the PRF one a term is derived under — so +building it needs a `T` that is both `IntoAad` and `IntoPrfContext`. A record +made only of ciphertexts needs only the first, and the leaf it wraps +(`StackCipherText`) asks for only that: its context is `AeadContext`. Such a +record says so with `#[stash(context_type = AeadContext)]`, and then accepts +every context the canonical `keyset.encrypt(value, context)` path accepts, +including a type that implements `IntoAad` alone: + +```rust,ignore +#[derive(EncryptFrom, DecryptInto)] +#[stash(plaintext = String, context_type = AeadContext)] +struct SealedName { + c: StackCipherText, +} + +let record: SealedName = name.encrypt_into_with_context(&keyset, NonEmpty::new(tenant)?).await?; +``` + +The derive cannot pick this for you: it sees the field types' names, not +what they declare. The type you name must convert into every field's own +`Context` (`AeadContext` does not convert into `CallerContext`, so a term +field beside it is a compile error at the record, which is the point), and a +field with a `context = ".."` of its own is derived under that literal +extended by the caller's context through the type's `under` — `AeadContext` +and `CallerContext` both have one. A record with a `context_field`, or a +`struct` derive, settles its context itself and refuses the attribute. + A declaration is executed by `keyset.encrypt_as(&value, context)` and `cipher.decrypt_as(record, context)`, or through the blanket `EncryptInto` and `DecryptFrom` traits for the source-side spelling (`value.encrypt_into(&keyset)`, diff --git a/packages/stack-encrypt-derive/src/attrs.rs b/packages/stack-encrypt-derive/src/attrs.rs index 339e460fe..0caf6183b 100644 --- a/packages/stack-encrypt-derive/src/attrs.rs +++ b/packages/stack-encrypt-derive/src/attrs.rs @@ -24,10 +24,19 @@ pub(crate) struct ContainerAttrs { /// it is given explicitly rather than inferred from a name a refactor /// can change. Only meaningful with `struct`. pub(crate) context: Option<LitStr>, + /// `#[stash(context_type = Type)]`: the record's associated `Context`, + /// when its fields take the caller's context. The default, + /// `CallerContext`, derives ciphertext and terms alike and so needs both + /// Vitamin C encodings; `AeadContext` needs only the AEAD one, and a + /// record whose fields are all ciphertexts declares it to accept the + /// same contexts the canonical `StackCipherText` path does. Excludes + /// `context_field` and `struct`. + pub(crate) context_type: Option<Type>, } const CONTAINER_KEYS: &str = "unsupported container attribute; expected `plaintext = Type`, \ - `struct = Type`, `context = \"...\"` (with `struct`) or `crate = \"...\"`"; + `struct = Type`, `context = \"...\"` (with `struct`), `context_type = Type` or \ + `crate = \"...\"`"; impl ContainerAttrs { pub(crate) fn parse(attrs: &[Attribute]) -> Result<Self> { @@ -35,6 +44,7 @@ impl ContainerAttrs { let mut plaintexts: Vec<Type> = Vec::new(); let mut by_field: Option<Type> = None; let mut context: Option<LitStr> = None; + let mut context_type: Option<Type> = None; for attr in attrs.iter().filter(|a| a.path().is_ident("stash")) { // `struct` and `crate` are keywords, but a nested-meta path is @@ -56,6 +66,15 @@ impl ContainerAttrs { context = Some(meta.value()?.parse()?); return Ok(()); } + if meta.path.is_ident("context_type") { + if context_type.is_some() { + return Err(meta.error( + "`context_type` is given twice; a record has one associated context", + )); + } + context_type = Some(meta.value()?.parse()?); + return Ok(()); + } if meta.path.is_ident("struct") { if by_field.is_some() { return Err(meta.error( @@ -155,11 +174,23 @@ impl ContainerAttrs { } } + if let (Some(by_field), Some(context_type)) = (&by_field, &context_type) { + let mut err = syn::Error::new_spanned( + context_type, + "`context_type` names what the caller passes to a record whose fields take the \ + caller's context; a `struct` derive's fields carry their own, so the record \ + takes `DeclaredContext` and a caller's context extends them", + ); + err.combine(syn::Error::new_spanned(by_field, "`struct` given here")); + return Err(err); + } + Ok(Self { krate: krate.unwrap_or_else(|| syn::parse_quote!(::stack_encrypt)), plaintexts, by_field, context, + context_type, }) } } diff --git a/packages/stack-encrypt-derive/src/lib.rs b/packages/stack-encrypt-derive/src/lib.rs index ad956869d..71658b489 100644 --- a/packages/stack-encrypt-derive/src/lib.rs +++ b/packages/stack-encrypt-derive/src/lib.rs @@ -32,7 +32,10 @@ //! ``` //! //! For a record without a context field, fields use the caller's context or a -//! declared literal. `struct = User, context = "users"` selects plaintext fields +//! declared literal. A record made only of ciphertexts can declare +//! `context_type = AeadContext` and accept a context type that implements +//! `IntoAad` alone, as the ciphertext leaf itself does. +//! `struct = User, context = "users"` selects plaintext fields //! and binds them under `"users/<field>"`. The storage envelope itself adds no //! cryptographic map-entry context. Vitamin C still binds keys inside plaintext //! maps and preserves authenticated absence and empty-container markers. diff --git a/packages/stack-encrypt-derive/src/shape.rs b/packages/stack-encrypt-derive/src/shape.rs index 847477b7f..58873ad80 100644 --- a/packages/stack-encrypt-derive/src/shape.rs +++ b/packages/stack-encrypt-derive/src/shape.rs @@ -119,6 +119,10 @@ pub(crate) struct Record { /// `struct = ..`: the plaintext is encrypted field by field, every /// derived field from one field of it (`Field::from`). pub(crate) by_field: bool, + /// `context_type = ..`: what the caller passes, in place of the + /// `CallerContext` a record whose fields take the caller's context + /// declares by default. + pub(crate) context_type: Option<Type>, pub(crate) fields: Vec<Field>, } @@ -194,12 +198,35 @@ impl Record { )); } - Ok(Self { + let record = Self { krate: attrs.krate, plaintexts, by_field, + context_type: attrs.context_type, fields, - }) + }; + // `ContainerAttrs::parse` has refused `context_type` beside `struct`; + // the other two shapes that settle the context themselves are + // checked here, where the fields are known. + if let Some(context_type) = &record.context_type { + if record.context_field().is_some() { + return Err(syn::Error::new_spanned( + context_type, + "`context_field` supplies the complete context, so the record's `Context` is \ + `NonEmpty<T>` of that field's type; `context_type` does not apply", + )); + } + if record.declared_contexts() { + return Err(syn::Error::new_spanned( + context_type, + "`context_type` names what the caller passes to a record whose fields take \ + the caller's context; every field here carries a `context = \"..\"` of its \ + own, so the record takes `DeclaredContext` and a caller's context extends \ + them", + )); + } + } + Ok(record) } } @@ -365,15 +392,23 @@ impl Record { } } else if self.declared_contexts() { parse_quote!(#krate::target::DeclaredContext) + } else if let Some(context_type) = &self.context_type { + context_type.clone() } else { parse_quote!(#krate::target::CallerContext) } } + /// The context a field is handed: the caller's as it is, or — for a + /// field with a context of its own — what `context_expr` builds from + /// the caller's: a `CallerContext` from a `DeclaredContext`'s `field`, + /// or the caller's own type from its `under`. pub(crate) fn field_context_type(&self, field: &Field) -> Type { let krate = &self.krate; match field.field_context() { - FieldContext::Own(_) => parse_quote!(#krate::target::CallerContext), - FieldContext::Caller => self.context_type(false), + FieldContext::Own(_) if self.declared_contexts() => { + parse_quote!(#krate::target::CallerContext) + } + FieldContext::Own(_) | FieldContext::Caller => self.context_type(false), } } pub(crate) fn context_expr(&self, field: &Field, decrypt: bool) -> TokenStream { @@ -805,6 +840,100 @@ mod tests { assert!(err.to_string().contains("must name a struct directly")); } + #[test] + fn context_type_replaces_the_default_caller_context() { + let record = parse(parse_quote! { + #[stash(plaintext = String, context_type = AeadContext)] + struct Rec { + c: StackCipherText, + #[stash(context = "legacy/name")] + shadow: StackCipherText, + } + }) + .unwrap(); + let ty = |ty: &Type| quote!(#ty).to_string(); + assert_eq!(ty(&record.context_type(false)), "AeadContext"); + assert_eq!(ty(&record.context_type(true)), "AeadContext"); + // Both fields are handed the caller's type: the literal one through + // its `under`, which returns the same type. + assert_eq!( + ty(&record.field_context_type(&record.fields[0])), + "AeadContext" + ); + assert_eq!( + ty(&record.field_context_type(&record.fields[1])), + "AeadContext" + ); + + let record = parse(parse_quote! { + struct Rec { + c: StackCipherText, + } + }) + .unwrap(); + assert!(record.context_type.is_none()); + assert_eq!( + ty(&record.context_type(false)), + ":: stack_encrypt :: target :: CallerContext" + ); + } + + #[test] + fn context_type_applies_only_where_the_caller_settles_the_context() { + let err = parse(parse_quote! { + #[stash(struct = User, context = "users", context_type = AeadContext)] + struct Rec { + name: StackCipherText, + } + }) + .unwrap_err(); + assert!( + err.to_string() + .contains("a `struct` derive's fields carry their own"), + "{err}" + ); + + let err = parse(parse_quote! { + #[stash(context_type = AeadContext)] + struct Rec { + #[stash(context_field)] + tenant: String, + c: StackCipherText, + } + }) + .unwrap_err(); + assert!( + err.to_string().contains("`context_type` does not apply"), + "{err}" + ); + + let err = parse(parse_quote! { + #[stash(context_type = AeadContext)] + struct Rec { + #[stash(context = "users/name")] + c: StackCipherText, + } + }) + .unwrap_err(); + assert!( + err.to_string() + .contains("every field here carries a `context"), + "{err}" + ); + + let err = parse(parse_quote! { + #[stash(context_type = AeadContext, context_type = AeadContext)] + struct Rec { + c: StackCipherText, + } + }) + .unwrap_err(); + assert!( + err.to_string().contains("`context_type` is given twice"), + "{err}" + ); + } + #[test] fn plaintext_fields_classify() { let record = parse(parse_quote! { diff --git a/packages/stack-encrypt/src/target/context.rs b/packages/stack-encrypt/src/target/context.rs index ba64aacc7..7d3104bbf 100644 --- a/packages/stack-encrypt/src/target/context.rs +++ b/packages/stack-encrypt/src/target/context.rs @@ -81,7 +81,10 @@ impl CallerContext { /// /// Sealing needs only the AEAD encoding, so a type that implements `IntoAad` /// without `IntoPrfContext` is enough here where it would not be for a -/// [`CallerContext`]. +/// [`CallerContext`]. A derived record whose fields are all ciphertexts +/// declares it with `#[stash(context_type = AeadContext)]`, and then accepts +/// the same contexts the canonical [`StackCipherText`](crate::StackCipherText) +/// path does. #[derive(Clone, Debug)] pub struct AeadContext(AadPiece<'static>); impl<'a, T: IntoAad<'a>> From<NonEmpty<T>> for AeadContext { @@ -111,6 +114,19 @@ impl AeadContext { pub(super) fn validated(self) -> Result<NonEmpty<Self>, Error> { nonempty(self) } + /// Extend a field's own context with this caller context, as + /// [`CallerContext::under`] does for a record that derives terms: the + /// field's literal is the prefix, this context the extension. + /// + /// # Errors + /// + /// Fails if `field` is empty; a field's own context is a literal the + /// derive has already checked, so a hand-written caller is the only one + /// that can hit this. + pub fn under(self, field: &'static str) -> Result<Self, Error> { + let prefix = nonempty(field)?; + Ok(prefix.with(self.validated()?).into()) + } } /// The optional extension a record whose fields already declare their own diff --git a/packages/stack-encrypt/src/target/mod.rs b/packages/stack-encrypt/src/target/mod.rs index 9df25f5e1..fca675123 100644 --- a/packages/stack-encrypt/src/target/mod.rs +++ b/packages/stack-encrypt/src/target/mod.rs @@ -15,11 +15,14 @@ //! Record envelope names do not add cryptographic map keys. //! //! Generic targets use [`CallerContext`] (ciphertext and terms) or [`AeadContext`] -//! (ciphertext only). These own Vitamin C's context encodings, preserving their -//! structured descriptor identity. They are constructed from a nonempty context, -//! including a borrowed one. Records that supply their own field contexts use -//! [`DeclaredContext`], whose default leaves those contexts unchanged and whose -//! nonempty form extends them. Custom targets may instead declare `Context = ()`. +//! (ciphertext only; a derived record made only of ciphertexts declares it with +//! `#[stash(context_type = AeadContext)]`). These own Vitamin C's context +//! encodings, preserving their structured descriptor identity. They are +//! constructed from a nonempty context, including a borrowed one — an +//! `AeadContext` from one with the AEAD encoding alone. Records that supply +//! their own field contexts use [`DeclaredContext`], whose default leaves those +//! contexts unchanged and whose nonempty form extends them. Custom targets may +//! instead declare `Context = ()`. //! //! # Output adapters //! diff --git a/packages/stack-encrypt/tests/derive.rs b/packages/stack-encrypt/tests/derive.rs index fc5ee3c9c..a868f797e 100644 --- a/packages/stack-encrypt/tests/derive.rs +++ b/packages/stack-encrypt/tests/derive.rs @@ -11,9 +11,10 @@ use std::sync::atomic::Ordering as AtomicOrdering; use cllw_ore::CllwOreEncrypt; use common::{counting_cipher, stack_cipher}; use stack_encrypt::sem::{EqualityTerm, MatchTerm, OreTerm}; -use stack_encrypt::target::{DecryptFrom, EncryptInto}; +use stack_encrypt::target::{AeadContext, DecryptFrom, EncryptInto}; use stack_encrypt::{ - nonempty, DecryptField, DecryptInto, Decryptable, EncryptFrom, Error, StackCipherText, + nonempty, Aad, DecryptField, DecryptInto, Decryptable, EncryptFrom, Error, IntoAad, MaybeEmpty, + NonEmpty, StackCipherText, }; // --- Records: every field from one plaintext, under one context ------------- @@ -380,6 +381,66 @@ async fn listed_plaintexts_each_get_their_own_impl() { assert_eq!((number, text.as_str()), (7, "seven")); } +/// A context type with the AEAD encoding alone: enough to seal, not to +/// derive a term. `WorkspaceId` in `cts-common` is the production shape. +#[derive(Clone, Debug, PartialEq)] +struct Tenant(String); +impl MaybeEmpty for Tenant { + fn is_empty(&self) -> bool { + self.0.is_empty() + } +} +impl<'a> IntoAad<'a> for Tenant { + fn into_aad(self) -> Aad<'a> { + self.0.into_aad() + } +} + +/// Ciphertext only, so it declares the ciphertext operation's own context +/// type rather than the default `CallerContext`, which would demand a PRF +/// encoding no field here uses. +#[derive(EncryptFrom, DecryptInto)] +#[stash(plaintext = String, context_type = AeadContext)] +struct SealedName { + c: StackCipherText, +} + +fn tenant() -> NonEmpty<Tenant> { + NonEmpty::new(Tenant("acme".into())).unwrap() +} + +#[tokio::test] +async fn a_ciphertext_only_record_accepts_an_aead_only_context_like_the_leaf_does() { + let cipher = stack_cipher().await; + let keyset = cipher.default_keyset(); + let name = "alice".to_owned(); + + // The derived record and the canonical leaf accept the same context + // and produce interchangeable ciphertext: each opens the other's. + let record: SealedName = name + .encrypt_into_with_context(&keyset, tenant()) + .await + .unwrap(); + let opened: String = cipher.decrypt(record.c, tenant()).await.unwrap(); + assert_eq!(opened, name); + + let leaf = keyset.encrypt(name.clone(), tenant()).await.unwrap(); + let opened: String = SealedName { c: leaf } + .decrypt_into(&cipher, tenant()) + .await + .unwrap(); + assert_eq!(opened, name); + + // Bound to the context like any other leaf. + let record: SealedName = name + .encrypt_into_with_context(&keyset, tenant()) + .await + .unwrap(); + let other = NonEmpty::new(Tenant("other".into())).unwrap(); + let result: Result<String, _> = record.decrypt_into(&cipher, other).await; + assert!(matches!(result, Err(Error::Aead)), "{result:?}"); +} + #[tokio::test] async fn a_failed_field_fails_the_derived_record_before_any_io() { let (cipher, generates, _) = counting_cipher().await; diff --git a/packages/stack-encrypt/tests/ui/aead_context_with_term.rs b/packages/stack-encrypt/tests/ui/aead_context_with_term.rs new file mode 100644 index 000000000..0944737f0 --- /dev/null +++ b/packages/stack-encrypt/tests/ui/aead_context_with_term.rs @@ -0,0 +1,15 @@ +//! An `AeadContext` carries only the AEAD encoding, so a record declaring it +//! cannot hold a term: the term's declaration wants a `CallerContext`, and +//! nothing converts an `AeadContext` into one. +use stack_encrypt::sem::EqualityTerm; +use stack_encrypt::target::AeadContext; +use stack_encrypt::{EncryptFrom, StackCipherText}; + +#[derive(EncryptFrom)] +#[stash(plaintext = u32, context_type = AeadContext)] +struct Indexed { + c: StackCipherText, + hm: EqualityTerm, +} + +fn main() {} diff --git a/packages/stack-encrypt/tests/ui/aead_context_with_term.stderr b/packages/stack-encrypt/tests/ui/aead_context_with_term.stderr new file mode 100644 index 000000000..d0544abce --- /dev/null +++ b/packages/stack-encrypt/tests/ui/aead_context_with_term.stderr @@ -0,0 +1,40 @@ +error[E0277]: the trait bound `CallerContext: From<AeadContext>` is not satisfied + --> tests/ui/aead_context_with_term.rs:8:10 + | +8 | #[derive(EncryptFrom)] + | ^^^^^^^^^^^ the trait `From<AeadContext>` is not implemented for `CallerContext` + | + = help: the following other types implement trait `From<T>`: + `CallerContext` implements `From<NonEmpty<T>>` + `CallerContext` implements `From<i128>` + `CallerContext` implements `From<i16>` + `CallerContext` implements `From<i32>` + `CallerContext` implements `From<i64>` + `CallerContext` implements `From<i8>` + `CallerContext` implements `From<u128>` + `CallerContext` implements `From<u16>` + and $N others + = note: required for `AeadContext` to implement `Into<CallerContext>` + = help: see issue #48214 + = note: this error originates in the derive macro `EncryptFrom` (in Nightly builds, run with -Z macro-backtrace for more info) + +error[E0277]: the trait bound `CallerContext: From<AeadContext>` is not satisfied + --> tests/ui/aead_context_with_term.rs:8:10 + | + 8 | #[derive(EncryptFrom)] + | ^^^^^^^^^^^ the trait `From<AeadContext>` is not implemented for `CallerContext` +... +12 | hm: EqualityTerm, + | ------------ required by a bound introduced by this call + | + = help: the following other types implement trait `From<T>`: + `CallerContext` implements `From<NonEmpty<T>>` + `CallerContext` implements `From<i128>` + `CallerContext` implements `From<i16>` + `CallerContext` implements `From<i32>` + `CallerContext` implements `From<i64>` + `CallerContext` implements `From<i8>` + `CallerContext` implements `From<u128>` + `CallerContext` implements `From<u16>` + and $N others + = note: required for `AeadContext` to implement `Into<CallerContext>` diff --git a/packages/stack-encrypt/tests/ui/context_type_conflicts.rs b/packages/stack-encrypt/tests/ui/context_type_conflicts.rs new file mode 100644 index 000000000..b61498f6c --- /dev/null +++ b/packages/stack-encrypt/tests/ui/context_type_conflicts.rs @@ -0,0 +1,33 @@ +//! `context_type` names what the caller passes to a record whose fields take +//! the caller's context. The three shapes that settle the context themselves +//! refuse it, and like every other singular attribute it is given once. +use stack_encrypt::{EncryptFrom, StackCipherText}; + +#[derive(EncryptFrom)] +#[stash(struct = User, context = "users", context_type = stack_encrypt::target::AeadContext)] +struct ByField { + name: StackCipherText, +} + +#[derive(EncryptFrom)] +#[stash(plaintext = String, context_type = stack_encrypt::target::AeadContext)] +struct Stored { + #[stash(context_field)] + tenant: String, + c: StackCipherText, +} + +#[derive(EncryptFrom)] +#[stash(plaintext = String, context_type = stack_encrypt::target::AeadContext)] +struct Declared { + #[stash(context = "users/name")] + c: StackCipherText, +} + +#[derive(EncryptFrom)] +#[stash(context_type = stack_encrypt::target::AeadContext, context_type = stack_encrypt::target::AeadContext)] +struct Twice { + c: StackCipherText, +} + +fn main() {} diff --git a/packages/stack-encrypt/tests/ui/context_type_conflicts.stderr b/packages/stack-encrypt/tests/ui/context_type_conflicts.stderr new file mode 100644 index 000000000..ba10b6220 --- /dev/null +++ b/packages/stack-encrypt/tests/ui/context_type_conflicts.stderr @@ -0,0 +1,29 @@ +error: `context_type` names what the caller passes to a record whose fields take the caller's context; a `struct` derive's fields carry their own, so the record takes `DeclaredContext` and a caller's context extends them + --> tests/ui/context_type_conflicts.rs:7:58 + | +7 | #[stash(struct = User, context = "users", context_type = stack_encrypt::target::AeadContext)] + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +error: `struct` given here + --> tests/ui/context_type_conflicts.rs:7:18 + | +7 | #[stash(struct = User, context = "users", context_type = stack_encrypt::target::AeadContext)] + | ^^^^ + +error: `context_field` supplies the complete context, so the record's `Context` is `NonEmpty<T>` of that field's type; `context_type` does not apply + --> tests/ui/context_type_conflicts.rs:13:44 + | +13 | #[stash(plaintext = String, context_type = stack_encrypt::target::AeadContext)] + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +error: `context_type` names what the caller passes to a record whose fields take the caller's context; every field here carries a `context = ".."` of its own, so the record takes `DeclaredContext` and a caller's context extends them + --> tests/ui/context_type_conflicts.rs:21:44 + | +21 | #[stash(plaintext = String, context_type = stack_encrypt::target::AeadContext)] + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +error: `context_type` is given twice; a record has one associated context + --> tests/ui/context_type_conflicts.rs:28:60 + | +28 | #[stash(context_type = stack_encrypt::target::AeadContext, context_type = stack_encrypt::target::AeadContext)] + | ^^^^^^^^^^^^ diff --git a/packages/stack-encrypt/tests/ui/pass/aead_only_context.rs b/packages/stack-encrypt/tests/ui/pass/aead_only_context.rs new file mode 100644 index 000000000..ee79e2b58 --- /dev/null +++ b/packages/stack-encrypt/tests/ui/pass/aead_only_context.rs @@ -0,0 +1,64 @@ +//! A context type that implements `IntoAad` alone is enough to seal a +//! ciphertext, so it must be enough for a derived record made only of +//! ciphertexts: `#[stash(context_type = AeadContext)]` declares that, and the +//! record then accepts exactly what the canonical `StackCipherText` path +//! accepts. `tests/ui/aead_context_with_term.rs` pins the record such a +//! context cannot declare. +use stack_encrypt::target::{AeadContext, DecryptFrom, EncryptInto}; +use stack_encrypt::{Aad, DecryptInto, EncryptFrom, IntoAad, KeysetCipher, MaybeEmpty, NonEmpty, StackCipherText}; +use stack_kms::FakeDataKeySource; + +/// AEAD only: no `IntoPrfContext`, so it cannot derive a term. +#[derive(Clone, Debug, PartialEq)] +struct Tenant(String); +impl MaybeEmpty for Tenant { + fn is_empty(&self) -> bool { + self.0.is_empty() + } +} +impl<'a> IntoAad<'a> for Tenant { + fn into_aad(self) -> Aad<'a> { + self.0.into_aad() + } +} + +#[derive(EncryptFrom, DecryptInto)] +#[stash(plaintext = String, context_type = AeadContext)] +struct Sealed { + c: StackCipherText, +} + +/// A field with a context of its own beside one that takes the caller's: +/// the literal is extended by the AEAD-only context, as it would be by a +/// `CallerContext`. +#[derive(EncryptFrom, DecryptInto)] +#[stash(plaintext = String, context_type = AeadContext)] +struct Shadowed { + #[stash(decrypt)] + c: StackCipherText, + #[stash(context = "legacy/name")] + shadow: StackCipherText, +} + +fn tenant() -> NonEmpty<Tenant> { + NonEmpty::new(Tenant("acme".into())).unwrap() +} + +async fn canonical(cipher: &KeysetCipher<'_, FakeDataKeySource>, value: &String) { + let leaf: StackCipherText = value.encrypt_into_with_context(cipher, tenant()).await.unwrap(); + let _: String = leaf.decrypt_into(cipher, tenant()).await.unwrap(); +} + +async fn derived(cipher: &KeysetCipher<'_, FakeDataKeySource>, value: &String) { + let record: Sealed = value.encrypt_into_with_context(cipher, tenant()).await.unwrap(); + let _: String = record.decrypt_into(cipher, tenant()).await.unwrap(); + let record: Shadowed = value.encrypt_into_with_context(cipher, tenant()).await.unwrap(); + let _ = String::decrypt_from_with_context(record, cipher, tenant()).await.unwrap(); + let column: Vec<Sealed> = vec![value.clone()].encrypt_into_with_context(cipher, tenant()).await.unwrap(); + let _: Vec<String> = column.decrypt_into(cipher, tenant()).await.unwrap(); +} + +fn main() { + let _ = canonical; + let _ = derived; +} From d44bdbbf943d0bad7cadbf00d6970243b325ac04 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Thu, 17 Sep 2026 20:36:48 +0000 Subject: [PATCH 557/686] docs(stack-encrypt): say what `ExpectedContext`'s default does and does not check The derive attribute reference described `ExpectedContext::default()` as reading and validating the stored context, and the target module docs said supplying an `ExpectedContext` adds a destination check. Neither is what the code does: an `ExpectedContext` is always supplied, its default checks only that the stored value is nonempty and then opens the record under whatever context it stores, and only a `NonEmpty<T>` passed as the expectation compares it against a destination. A ciphertext moved together with its stored context therefore opens under the default as if it belonged where it now sits. State that plainly in `attributes.md`, `target/mod.rs`, and the derive crate's lead example, matching the wording the `ExpectedContext` and `validate` rustdoc already use. No behaviour changes. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HNS9rirkcPxUHUrNxkzULW --- packages/stack-encrypt-derive/docs/attributes.md | 5 ++--- packages/stack-encrypt-derive/src/lib.rs | 2 +- packages/stack-encrypt/src/target/mod.rs | 3 +-- 3 files changed, 4 insertions(+), 6 deletions(-) diff --git a/packages/stack-encrypt-derive/docs/attributes.md b/packages/stack-encrypt-derive/docs/attributes.md index 6dbb4eb73..de60ea435 100644 --- a/packages/stack-encrypt-derive/docs/attributes.md +++ b/packages/stack-encrypt-derive/docs/attributes.md @@ -38,9 +38,8 @@ literal. Each derive emits one declaration per plaintext, with an associated `Context`. A record with `#[stash(context_field)]` on a field of type `T` requires `NonEmpty<T>` for encryption and stores its inner value in that field. Decryption -takes `ExpectedContext<T>`: its default reads and validates the stored value; -`NonEmpty<T>.into()` also checks it against the expected destination before key -retrieval. `T` supplies the Vitamin C context encodings and implements `Clone`, +takes `ExpectedContext<T>`. Its default checks only that the stored value is nonempty and then opens the record under whatever context it stores — so a ciphertext moved together with its stored context opens as if it belonged where it now sits. `NonEmpty<T>.into()` names the destination the caller believes it is opening; a stored context that differs is refused with `Error::ContextMismatch` before any key is retrieved. Either way the stored context is data the record arrived with, not something the cipher has authenticated. +`T` supplies the Vitamin C context encodings and implements `Clone`, `MaybeEmpty`, and `PartialEq`. This metadata is not a separate encrypted field. It cannot be combined with literal context attributes. diff --git a/packages/stack-encrypt-derive/src/lib.rs b/packages/stack-encrypt-derive/src/lib.rs index 71658b489..ee92e383e 100644 --- a/packages/stack-encrypt-derive/src/lib.rs +++ b/packages/stack-encrypt-derive/src/lib.rs @@ -24,7 +24,7 @@ //! let value = "alice@example.com".to_owned(); //! // The output type selects ciphertext + equality; context is NonEmpty<String>. //! let encrypted: TextEq = keyset.encrypt_as(&value, NonEmpty::new("users/email".to_owned())?).await?; -//! // Reads the identifier from the record, validates it, and opens through Vitamin C. +//! // Default: checks only that the stored identifier is nonempty, then opens under it as stored. Pass `NonEmpty::new(..)?.into()` to also require it to match the destination. //! let opened: String = cipher.decrypt_as(encrypted, ExpectedContext::default()).await?; //! assert_eq!(opened, value); //! # Ok::<(), Box<dyn std::error::Error>> (()) diff --git a/packages/stack-encrypt/src/target/mod.rs b/packages/stack-encrypt/src/target/mod.rs index fca675123..be326b623 100644 --- a/packages/stack-encrypt/src/target/mod.rs +++ b/packages/stack-encrypt/src/target/mod.rs @@ -10,8 +10,7 @@ //! //! The derives compose each semantic field's declaration. `#[stash(context_field)]` //! on an identifier of type `T` makes the encryption context `NonEmpty<T>` and -//! stores its inner value. Opening validates that stored identifier; supplying an -//! [`ExpectedContext`] also checks the expected destination before key retrieval. +//! stores its inner value. Opening takes an [`ExpectedContext`]: by default it checks only that the stored identifier is nonempty and opens under it as stored; a `NonEmpty<T>` also requires it to equal the destination the caller names, and a mismatch is refused before any key is retrieved. //! Record envelope names do not add cryptographic map keys. //! //! Generic targets use [`CallerContext`] (ciphertext and terms) or [`AeadContext`] From df7395757470e0aa37123a7c1d79ac187e06604d Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Thu, 17 Sep 2026 20:42:54 +0000 Subject: [PATCH 558/686] docs(stack-encrypt-derive): the lead example names its destination on open, and both examples use a static identifier The crate's first example opened a `#[stash(context_field)]` record with `ExpectedContext::default()`, so the pattern a reader copies was the one that performs no destination check. Open it with `nonempty!("users/email").into()` instead, and say in the comment what the default would and would not check. Both examples stored the identifier as a `String` built with `NonEmpty::new("users/email".to_owned())?`. A `&'static str` satisfies the same bounds, so the field is now `&'static str` and the context is `nonempty!("users/email")`: no allocation, no fallible constructor, and the `NonEmpty` and `ExpectedContext` imports go with it. The transcode example's canonical open is unchanged. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HNS9rirkcPxUHUrNxkzULW --- packages/stack-encrypt-derive/src/lib.rs | 21 ++++++++++----------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/packages/stack-encrypt-derive/src/lib.rs b/packages/stack-encrypt-derive/src/lib.rs index ee92e383e..0fbc56acd 100644 --- a/packages/stack-encrypt-derive/src/lib.rs +++ b/packages/stack-encrypt-derive/src/lib.rs @@ -4,16 +4,15 @@ //! Generated code receives context and encrypted outputs, never a cipher. //! //! ``` -//! use stack_encrypt::{EncryptFrom, DecryptInto, StackCipher, StackCipherText, NonEmpty}; +//! use stack_encrypt::{EncryptFrom, DecryptInto, StackCipher, StackCipherText, nonempty}; //! use stack_encrypt::sem::EqualityTerm; -//! use stack_encrypt::target::ExpectedContext; //! use stack_kms::FakeDataKeySource; //! //! #[derive(EncryptFrom, DecryptInto)] //! #[stash(plaintext = String)] //! struct TextEq { //! #[stash(context_field)] -//! identifier: String, +//! identifier: &'static str, //! c: StackCipherText, //! hm: EqualityTerm, //! } @@ -22,10 +21,10 @@ //! let cipher = StackCipher::builder().kms(FakeDataKeySource::new()).init().await?; //! let keyset = cipher.default_keyset(); //! let value = "alice@example.com".to_owned(); -//! // The output type selects ciphertext + equality; context is NonEmpty<String>. -//! let encrypted: TextEq = keyset.encrypt_as(&value, NonEmpty::new("users/email".to_owned())?).await?; -//! // Default: checks only that the stored identifier is nonempty, then opens under it as stored. Pass `NonEmpty::new(..)?.into()` to also require it to match the destination. -//! let opened: String = cipher.decrypt_as(encrypted, ExpectedContext::default()).await?; +//! // The output type selects ciphertext + equality; the context is NonEmpty<&str>, stored in `identifier`. +//! let encrypted: TextEq = keyset.encrypt_as(&value, nonempty!("users/email")).await?; +//! // Naming the destination requires the stored identifier to equal it before any key is retrieved. `ExpectedContext::default()` would check only that it is nonempty and open under it as stored. +//! let opened: String = cipher.decrypt_as(encrypted, nonempty!("users/email").into()).await?; //! assert_eq!(opened, value); //! # Ok::<(), Box<dyn std::error::Error>> (()) //! # }).unwrap(); @@ -61,8 +60,8 @@ //! use stack_encrypt::target::transcode::{Transcode, Visitor}; //! use stack_encrypt::target::{self, CallerContext}; //! use stack_encrypt::{ -//! CipherText, Decryptable, Encrypt, EncryptFrom, Encryption, Error, NonEmpty, SealedValue, -//! StackCipher, +//! CipherText, Decryptable, Encrypt, EncryptFrom, Encryption, Error, SealedValue, StackCipher, +//! nonempty, //! }; //! use stack_kms::FakeDataKeySource; //! @@ -105,7 +104,7 @@ //! #[stash(plaintext = String)] //! struct TextEq { //! #[stash(context_field)] -//! identifier: String, +//! identifier: &'static str, //! c: LeafBytes, //! hm: EqualityTerm, //! } @@ -114,7 +113,7 @@ //! let cipher = StackCipher::builder().kms(FakeDataKeySource::new()).init().await?; //! let keyset = cipher.default_keyset(); //! let value = "alice@example.com".to_owned(); -//! let context = NonEmpty::new("users/email".to_owned())?; +//! let context = nonempty!("users/email"); //! let encrypted: TextEq = keyset.encrypt_as(&value, context.clone()).await?; //! //! // What the column holds is the leaf itself: the canonical path opens it. From b04e79420bff01f0b53260600a15ae19e50d813f Mon Sep 17 00:00:00 2001 From: Dan Draper <dan@cipherstash.com> Date: Sun, 13 Sep 2026 20:59:54 -0400 Subject: [PATCH 559/686] =?UTF-8?q?docs(stack-encrypt):=20ADR-0004=20?= =?UTF-8?q?=E2=80=94=20a=20context=20reaches=20its=20operations=20by=20bei?= =?UTF-8?q?ng=20threaded,=20not=20by=20being=20passed=20to=20each?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ADR-0003 settled what a caller must supply. It left open where the supplied value goes: every operation constructor takes its own context, `zip` relates nothing, so a composite target can seal under one context and index under another. Derived records escape it because the derive passes one `context_expr` per field; hand-written composites — EQL's SteVec targets, and anything an external consumer writes — do not. What makes it worth an ADR rather than a docs note is the failure asymmetry: a mis-contexted ciphertext is refused by ZeroKMS or the AEAD at first read, while a mis-contexted term is a valid term in the wrong domain. It matches nothing, forever, and an empty result set looks like absent data rather than a fault. Records the threading design, and the spike finding that shaped it: threading one runtime context is simpler and loses the compile-time empty-context rule, because `T::Context` is deliberately heterogeneous. A type parameter on `Encryption` keeps both — `()`-at-a-leaf and divergence-within-a-target become type errors together. Also records four decisions that hang off it: requests take a context rather than a built descriptor, stored terms route through targets while probes stay standalone, targets declare their sources so the FFI boundary dispatches, and `ExpectedContext` stays permissive for the column-rename flow with its consequence stated rather than implied. Proposed, not accepted: it is the thing the implementation will be reviewed against. Claude-Session: https://claude.ai/code/session_019b9YUGNPBrj9UUX2aZ8RbA --- ...t-threaded-through-the-declaration-tree.md | 191 ++++++++++++++++++ 1 file changed, 191 insertions(+) create mode 100644 packages/stack-encrypt/docs/adr/0004-one-context-per-target-threaded-through-the-declaration-tree.md diff --git a/packages/stack-encrypt/docs/adr/0004-one-context-per-target-threaded-through-the-declaration-tree.md b/packages/stack-encrypt/docs/adr/0004-one-context-per-target-threaded-through-the-declaration-tree.md new file mode 100644 index 000000000..7bfe11bd4 --- /dev/null +++ b/packages/stack-encrypt/docs/adr/0004-one-context-per-target-threaded-through-the-declaration-tree.md @@ -0,0 +1,191 @@ +--- +status: proposed +date: 2026-09-13 +extends: ADR-0003 +--- + +# One context per target, threaded through the declaration tree + +[ADR-0003](0003-declarative-targets-and-ciphertext-transcoding.md) gave targets +an associated `Context` type and a declaration they compose from core +operations. That settles what a caller must *supply*. It does not settle where +the supplied value *goes*, and the gap is load-bearing: a target that produces +a ciphertext and its index terms can hand each operation a different context, +and nothing — not the type system, not a runtime check, not a test — relates +them. + +This ADR records how a context reaches the operations beneath it, and why the +answer is a type parameter rather than a convention. + +## The problem + +Every operation constructor takes its own context: + +```rust +ciphertext::<S, K>(ctx_a).zip(equality::<S, K>(ctx_b)) // compiles +``` + +`Self::Context` is plumbing the implementation may route, reroute or discard. +`zip` combines builders and relates nothing. So a composite target can seal a +value under one context and index it under another. + +The two mistakes fail differently, and that asymmetry is why this matters more +than it first appears: + +| mistake | how it surfaces | +| --- | --- | +| ciphertext under the wrong context | ZeroKMS refuses the retrieve (its descriptor is HMAC'd into the key tag), or the AEAD rejects — loud, at first read | +| term under the wrong context | a valid term in a different domain. A probe built correctly never equals it | + +A mis-contexted term produces no error, ever. Queries return nothing, and an +empty result is indistinguishable from no matching rows. The symptom appears in +the read path, possibly long after the write, and looks like missing data rather +than a fault. + +Derived records are safe today because `#[derive(EncryptFrom)]` passes one +`context_expr` per field to that field's operations. The exposure is +hand-written *composite* targets — which EQL has (the SteVec/JSON ones), and +which any external consumer writes with no derive to save them. + +Per-field contexts differing is **not** the problem; that is deliberate domain +separation. The problem is that within one field, the ciphertext and its terms +have no relation. + +## Decision + +### 1. Operations take no context; the tree carries one + +`Encryption::build` gains the context as a parameter, and `zip` hands the same +value to both sides. Divergence within a target stops being expressible, +because there is no second context to pass. + +```rust +ciphertext::<S, K>().zip(equality::<S, K>()) // both under the same context +``` + +### 2. `under` is the only way to change it, and it scopes a subtree + +A record gives its fields different contexts once each, visibly, instead of +threading six arguments: + +```rust +age.under("users/age").zip(email.under("users/email")) +``` + +### 3. The context is a type parameter, so the empty-context rule stays compile-time + +`Encryption<'s, S, T, K, Ctx>`, where `zip` requires both sides to share `Ctx`: + +- `ciphertext()` is `Encryption<.., CallerContext>` — it needs a real context +- `.under("users/age")` yields `Encryption<.., DeclaredContext>` — now runnable + under `()` or a caller's context +- zipping a bare leaf with own-context fields is a type error, which is correct + +This is the part that cost a spike to find. Threading a single *runtime* value +(`Option<CallerContext>`) is simpler and wrong: `T::Context` is deliberately +heterogeneous — `CallerContext` for a leaf, `DeclaredContext` for a record whose +fields carry their own, `ExpectedContext<T>` for a `context_field` record — so +collapsing them makes a leaf reached without a context fail at *runtime*. Today +that is a compile error, with a `#[diagnostic::on_unimplemented]` message and UI +fixtures pinning it. Trading a compile-time guarantee to buy this one is not a +trade worth making, and the type parameter buys both. + +### 4. A data-key request takes a context, not a descriptor + +`Request::generate_data_key` and `retrieve_data_key` currently accept a built +`Descriptor`. Together with `Pending::request` and `SealedValue::from_parts` — +all public, all documented as the third-party SEM extension point — that lets a +downstream implementation mint under one context and authenticate under another +with no crate code in the path. + +They will take a context and derive the descriptor themselves. The extension +point stays; what goes is the ability to hand it two disagreeing values. + +### 5. Stored terms go through targets; standalone derivation is the query path + +`KeysetCipher::{equality_term, match_terms, ore_term, ope_term}` remain: a query +probe has no ciphertext to agree with, so constraining it means nothing. +Write-side term derivation moves exclusively through targets, where a term +shares its ciphertext's context by construction. + +### 6. Targets declare their sources; `FfiValue` is not one + +A target names the plaintext it accepts (`TextEq` from `Protected<String>`), and +never an enum spanning every scalar. `EncryptFrom<FfiValue>` would make a +`UInt32`-into-`TextEq` a runtime error inside the crypto layer and leave every +valid pair unprovable. + +The dynamic-to-static bridge is a dispatch at the FFI boundary: a match on the +value's variant against the plan's requested target. A plan and a value that +disagree fail there, as plan validation, with the offending field named — and +the whole plan is validated before any key is requested, so a fifty-field row +does not issue thirty requests before failing on the thirty-first. + +### 7. Heterogeneous targets unify after `map`, not before + +Targets differ per field, so there is no common output type. `map` applies to an +*unsettled* `Pending` and preserves its requests, so a binding maps each target +to its own node type and collects the results — one batch, strongly typed +targets right up to the point they become wire bytes. + +`Encryption::all` composes a runtime-length list, mirroring the `Pending::all` +that already exists. Without it a plan-driven caller must reach past the +declaration layer into `Pending`, which is what ADR-0003 set out to prevent. + +### 8. `ExpectedContext` stays permissive, deliberately + +`#[stash(context_field)]` lets a record carry its context, and the default +`ExpectedContext` accepts whatever the record stores rather than checking it +against an expected value. + +This is a decision, not an oversight, and the reason is the onboarding flow: add +`email_encrypted`, migrate, drop `email`, rename `email_encrypted` to `email`. +Every historical row still stores the pre-rename identifier, so a strict check +would reject all of them at the first read after a rename. + +The consequence is acknowledged: an identifier that must survive renames cannot +also enforce placement. What it provides is a label, not a guarantee. A whole +self-consistent record moved from one column to another still opens — a confused +deputy, mitigated by client-side checking rather than by this mechanism. The +AEAD and descriptor bindings are unaffected: nobody reaches a key they are not +entitled to. Dropping the stored identifier entirely is the likelier end state +than tightening the check. + +## Considered options + +**Convention and documentation.** State the invariant on `EncryptFrom` and pin +the correct pattern with an example. Kept as the fallback, and worth doing +regardless — an external consumer reads that before writing a composite — but it +is enforcement by hope. + +**Detect a mismatch at runtime in `zip`.** Rejected: `zip` cannot distinguish a +record legitimately combining differently-contexted *fields* from a target +illegitimately combining differently-contexted *operations*. It would reject +valid code or miss the bug. + +**Thread one runtime context.** Rejected for the reason in decision 3: it costs +the compile-time empty-context guarantee. + +**Seal the low-level request API.** Rejected: removing a documented extension +point is a product decision, and decision 4 closes the seam without it. + +## Consequences + +The invariant becomes structural rather than documented, and the empty-context +rule strengthens rather than weakens — both `()`-at-a-leaf and +divergence-within-a-target become type errors. + +It costs a type parameter through `Encryption`, every operation constructor, +every combinator, `EncryptFrom::Context`, and the derive's codegen. The UI +fixtures are sensitive to far less than this; budget for them, and treat a +worsened diagnostic as a defect rather than fixture noise. + +It lands inside ADR-0003's implementation rather than after it. Once that merges +these are public signatures, and EQL builds roughly ninety-five (source, target) +pairs on them immediately — so the same change afterwards is a breaking one with +a real downstream. + +What it does **not** fix: a term and a ciphertext written through two separate +top-level calls still have no relation to each other, because neither knows the +other exists. Decision 5 narrows this to callers who deliberately bypass the +target layer on the write side. From 3e1b402a0fe601cf56386c937a8225a0e2830c41 Mon Sep 17 00:00:00 2001 From: Dan Draper <dan@cipherstash.com> Date: Thu, 17 Sep 2026 18:42:34 -0400 Subject: [PATCH 560/686] feat(stack-encrypt)!: a context reaches its operations by being threaded, not by being handed to each MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements ADR-0004. Before this, every operation constructor took its own context and `zip` related nothing, so a target could seal a value under one context and index it under another — and the two mistakes fail differently: a mis-contexted ciphertext is refused by ZeroKMS or the AEAD at first read, while a mis-contexted term is a valid term in the wrong domain that matches nothing, forever, with no error anywhere. `Encryption` carries the context it still needs as a type parameter, and `build` receives the value. `zip` hands the same one to both sides, so within a target there is no second context to pass. `under` and `extend` give a subtree a context of its own — the only ways to change it — and `accepting` converts once at the root, where a record declares one type and its operations need another. The type parameter is what keeps the empty-context rule at compile time. Threading a single runtime value is simpler and loses it: `Self::Context` is deliberately heterogeneous, so collapsing it makes a leaf reached without a context fail at runtime instead of failing to compile. Now both hold together — `()` at a leaf and divergence within a target are type errors. A ciphertext keeps its `AeadContext`. Sealing needs only the AEAD encoding, and a record made only of ciphertexts may declare `context_type = AeadContext`; beside a term, which needs a `CallerContext`, the ciphertext takes the term's context through `accepting` — the AEAD half of the same value — so the two zip under one. A caller's context of either kind extends a field's own one (`Extends`, sealed to the two core types), which is what `extend` asks of the context a subtree is run under. The derive follows the same rule rather than its own. A field with a context of its own gives its subtree that literal, `under` or `extend`; a field with none is handed the record's context and converts it into whatever its type declares, at the field — so a leaf reached through a record that may run under `()` is refused there, not at the derive attribute. An interpolated token stream keeps the spans it was built with, so the types the derive names in that call are re-spanned at the field too. The impl names concrete context types in its bounds rather than adding a parameter: a parameter constrained only by an associated-type binding is E0207, and the author is told "unconstrained type parameter" instead of their mistake. An own context is a `NonEmpty<&'static str>` — the derive emits `nonempty!(..)` for a literal, so an empty one is refused at compile time — and with that `CallerContext::extend`, `AeadContext::extend` and `DeclaredContext::under` are infallible, and named the same as the tree-level combinators they implement. `execution_callback.rs` was updated rather than blessed — left alone it failed on generic arity before reaching the privacy error it exists to test. BREAKING CHANGE: `EncryptFrom::encryption` takes no context and returns `Encryption<'s, S, Self, K, Self::Context>`; the operation constructors (`ciphertext`, `equality`, `matching`, `ore`, `ope`) take none either. A hand-written target composes them, takes a term's context for a ciphertext with `accepting()`, and gives a subtree its own context with `under(nonempty!(..))` / `extend(nonempty!(..))`. `CallerContext::under` and `AeadContext::under` are `extend`, and `DeclaredContext::field` is `under`; each takes a `NonEmpty<&'static str>` and returns a context, not a `Result`. Refs CIP-4042. --- .../stack-encrypt-derive/docs/attributes.md | 2 +- packages/stack-encrypt-derive/src/decrypt.rs | 4 +- packages/stack-encrypt-derive/src/encrypt.rs | 57 ++-- packages/stack-encrypt-derive/src/lib.rs | 5 +- packages/stack-encrypt-derive/src/shape.rs | 155 ++++++++-- packages/stack-encrypt/src/target/context.rs | 77 ++--- packages/stack-encrypt/src/target/mod.rs | 14 +- .../stack-encrypt/src/target/operations.rs | 265 +++++++++++++----- packages/stack-encrypt/tests/derive.rs | 6 +- packages/stack-encrypt/tests/target.rs | 16 +- packages/stack-encrypt/tests/transcode.rs | 12 +- .../tests/ui/aead_context_with_term.stderr | 21 -- .../tests/ui/execution_callback.rs | 7 +- .../tests/ui/execution_callback.stderr | 8 +- .../ui/nested_leaf_without_context.stderr | 15 +- .../tests/ui/override_encryption.stderr | 2 +- 16 files changed, 467 insertions(+), 199 deletions(-) diff --git a/packages/stack-encrypt-derive/docs/attributes.md b/packages/stack-encrypt-derive/docs/attributes.md index de60ea435..93d6fac9c 100644 --- a/packages/stack-encrypt-derive/docs/attributes.md +++ b/packages/stack-encrypt-derive/docs/attributes.md @@ -77,7 +77,7 @@ what they declare. The type you name must convert into every field's own `Context` (`AeadContext` does not convert into `CallerContext`, so a term field beside it is a compile error at the record, which is the point), and a field with a `context = ".."` of its own is derived under that literal -extended by the caller's context through the type's `under` — `AeadContext` +extended by the caller's context through the type's `extend` — `AeadContext` and `CallerContext` both have one. A record with a `context_field`, or a `struct` derive, settles its context itself and refuses the attribute. diff --git a/packages/stack-encrypt-derive/src/decrypt.rs b/packages/stack-encrypt-derive/src/decrypt.rs index db7958a7f..b274411dc 100644 --- a/packages/stack-encrypt-derive/src/decrypt.rs +++ b/packages/stack-encrypt-derive/src/decrypt.rs @@ -75,12 +75,12 @@ pub(crate) fn derive(input: DeriveInput) -> Result<TokenStream> { let field = fields[0]; let ty = &field.ty; let member = &field.member; - let ctx = record.context_expr(field, true); + let ctx = record.context_expr(field); quote_spanned!(ty.span()=> <#ty as #krate::target::DecryptInto<#output>>::decryption::<__K>(self.#member, #ctx.into())) } else { let calls: Vec<_> = fields.iter().map(|field| { let ty = &field.ty; let member = &field.member; - let ctx = record.context_expr(field, true); + let ctx = record.context_expr(field); let ctx_ty = record.field_context_type(field); quote_spanned!(ty.span()=> <#ty as #krate::target::DecryptField<#output, #ctx_ty>>::decryption_field::<__K>(self.#member, #ctx)) }).collect(); diff --git a/packages/stack-encrypt-derive/src/encrypt.rs b/packages/stack-encrypt-derive/src/encrypt.rs index efd81cce1..76b805c35 100644 --- a/packages/stack-encrypt-derive/src/encrypt.rs +++ b/packages/stack-encrypt-derive/src/encrypt.rs @@ -1,5 +1,5 @@ //! Emit operation declarations; only core code receives plaintext and a cipher. -use crate::shape::{fresh_lifetime, trait_impl, zip, Field, Kind, Record}; +use crate::shape::{fresh_lifetime, trait_impl, zip_chain, Field, Kind, Record}; use proc_macro2::TokenStream; use quote::{quote, quote_spanned}; use syn::spanned::Spanned; @@ -18,25 +18,31 @@ pub(crate) fn derive(input: DeriveInput) -> Result<TokenStream> { if generic { generics.params.push(parse_quote!(__S)); } - generics - .make_where_clause() - .predicates - .push(parse_quote!(Self: 'static)); + let predicates = &mut generics.make_where_clause().predicates; + predicates.push(parse_quote!(Self: 'static)); + // ADR-0004: one context is threaded to every field, and `zip` will + // not combine two subtrees that need different types — so every + // field's declaration is brought to the type the record's tree + // carries (`Record::threaded_context`) by `Record::field_threading`, + // and the where-clause says what that asks of the field's type. + // + // The bounds name concrete types rather than adding a fresh impl + // parameter, deliberately: a parameter constrained only by an + // associated-type binding in a where-clause is E0207, and the user + // is told "unconstrained type parameter" instead of their mistake. for field in fields.iter().filter(|f| f.from().is_none()) { - let ty = &field.ty; - let context = record.field_context_type(field); - let where_ = &mut generics.make_where_clause().predicates; - where_.push(parse_quote!(#ty: #krate::target::EncryptFrom<#source>)); - where_.push(parse_quote!(#context: Into<<#ty as #krate::target::EncryptFrom<#source>>::Context>)); + predicates.extend(record.field_bounds(field, &source)); } let operations = fields.iter().map(|field| { let ty = &field.ty; - let context = record.context_expr(field, false); + let threading = record.field_threading(field); + // Spanned at the field type: what the field's type refuses is + // reported there, not at the derive. let operation = if let Some(from) = field.from() { - quote_spanned!(ty.span()=> <#ty as #krate::target::EncryptFrom<_>>::encryption::<__K>(#context.into()) - .project(|__source: &#source| &__source.#from)) + quote_spanned!(ty.span()=> <#ty as #krate::target::EncryptFrom<_>>::encryption::<__K>() + .project(|__source: &#source| &__source.#from) #threading) } else { - quote_spanned!(ty.span()=> <#ty as #krate::target::EncryptFrom<#source>>::encryption::<__K>(#context.into())) + quote_spanned!(ty.span()=> <#ty as #krate::target::EncryptFrom<#source>>::encryption::<__K>() #threading) }; (operation, field.local.clone()) }).collect(); @@ -49,18 +55,27 @@ pub(crate) fn derive(input: DeriveInput) -> Result<TokenStream> { } Kind::Default(Some(expr)) => quote!(#expr), Kind::Default(None) => quote!(::core::default::Default::default()), - Kind::Context => quote!(__stored_context), + Kind::Context => quote!(__context.clone().into_inner()), }; quote!(#member: #value) }); - let stored = record - .context_field() - .map(|_| quote!(let __stored_context = __context.clone().into_inner();)); - let body = zip(operations, quote!(Self { #(#assignments),* })); + let result = quote!(Self { #(#assignments),* }); + // The tree carries the threaded context; the record declares + // `Self::Context`, converted into it once at the root — a record + // storing its own context declares the `NonEmpty<T>` it stores while + // its operations need a `CallerContext`. Such a record fills the + // stored field here too: under threading the context arrives when + // the description runs, not when it is built. + let (chain, pattern) = zip_chain(operations); + let chain = quote!(#chain.accepting::<Self::Context>()); + let body = if record.context_field().is_some() { + quote!(#chain.map_with_context(move |#pattern, __context| #result)) + } else { + quote!(#chain.map(move |#pattern| #result)) + }; impls.push(trait_impl(&input, &generics, quote!(#krate::target::EncryptFrom<#source>), quote! { type Context = #context; - fn encryption<#source_lifetime,__K: 'static>(__context: Self::Context) -> #krate::target::Encryption<#source_lifetime,#source, Self, __K> where #source:#source_lifetime { - #stored + fn encryption<#source_lifetime,__K: 'static>() -> #krate::target::Encryption<#source_lifetime,#source, Self, __K, Self::Context> where #source:#source_lifetime { #body } })); diff --git a/packages/stack-encrypt-derive/src/lib.rs b/packages/stack-encrypt-derive/src/lib.rs index 0fbc56acd..06ae7718f 100644 --- a/packages/stack-encrypt-derive/src/lib.rs +++ b/packages/stack-encrypt-derive/src/lib.rs @@ -89,13 +89,14 @@ //! const DECRYPTABLE: bool = true; //! } //! // The declaration: the canonical ciphertext operation, read into this type. +//! // It seals under the AEAD half of the context the record threads to it. //! impl<S: Encrypt + Clone> EncryptFrom<S> for LeafBytes { //! type Context = CallerContext; -//! fn encryption<'s, K: 'static>(context: CallerContext) -> Encryption<'s, S, Self, K> +//! fn encryption<'s, K: 'static>() -> Encryption<'s, S, Self, K, Self::Context> //! where //! S: 's, //! { -//! target::ciphertext(context).transcode() +//! target::ciphertext().accepting().transcode() //! } //! } //! diff --git a/packages/stack-encrypt-derive/src/shape.rs b/packages/stack-encrypt-derive/src/shape.rs index 58873ad80..f61a1e2a2 100644 --- a/packages/stack-encrypt-derive/src/shape.rs +++ b/packages/stack-encrypt-derive/src/shape.rs @@ -1,15 +1,36 @@ //! Classification of the derive input into the record it describes. -use proc_macro2::{Span, TokenStream}; -use quote::quote; +use proc_macro2::{Group, Span, TokenStream, TokenTree}; +use quote::{quote, quote_spanned, ToTokens}; use syn::spanned::Spanned; use syn::{ parse_quote, Data, DeriveInput, Expr, Fields, Generics, Ident, Lifetime, LitStr, Member, Path, - Result, Type, + Result, Type, WherePredicate, }; use crate::attrs::{ContainerAttrs, FieldAttrs}; +/// `tokens`, every one of them at `span`. An interpolated tree keeps the +/// spans it was built with, so a bound the derive states about a field is +/// reported at that field only if the *types* in it are spanned there too — +/// `quote_spanned!` alone re-spans nothing it interpolates. +fn respan(tokens: TokenStream, span: Span) -> TokenStream { + tokens + .into_iter() + .map(|tree| match tree { + TokenTree::Group(group) => { + let mut group = Group::new(group.delimiter(), respan(group.stream(), span)); + group.set_span(span); + TokenTree::Group(group) + } + mut leaf => { + leaf.set_span(span); + leaf + } + }) + .collect() +} + /// A generated lifetime must not shadow one the record declares. Append /// underscores until the name is free, preserving the user's parameters. pub(crate) fn fresh_lifetime(generics: &Generics, base: &str) -> Lifetime { @@ -398,10 +419,11 @@ impl Record { parse_quote!(#krate::target::CallerContext) } } - /// The context a field is handed: the caller's as it is, or — for a - /// field with a context of its own — what `context_expr` builds from - /// the caller's: a `CallerContext` from a `DeclaredContext`'s `field`, - /// or the caller's own type from its `under`. + /// The context a field is handed on the decrypt side: the caller's as it + /// is, or — for a field with a context of its own — what `context_expr` + /// builds from the caller's: a `CallerContext` from a + /// `DeclaredContext`'s `under`, or the caller's own type from its + /// `extend`. pub(crate) fn field_context_type(&self, field: &Field) -> Type { let krate = &self.krate; match field.field_context() { @@ -411,24 +433,110 @@ impl Record { FieldContext::Own(_) | FieldContext::Caller => self.context_type(false), } } - pub(crate) fn context_expr(&self, field: &Field, decrypt: bool) -> TokenStream { + /// The context type the record's declaration tree carries on the + /// encrypt side (ADR-0004): a `DeclaredContext` when every field has a + /// context of its own, so the caller's is optional; otherwise the + /// caller's — the `context_type` named, or `CallerContext`. It is the + /// record's own `Context` except for a record that stores its context, + /// which declares the `NonEmpty<T>` it stores and converts it into this + /// once, at the root. + pub(crate) fn threaded_context(&self) -> Type { + let krate = &self.krate; + if self.context_field().is_some() { + parse_quote!(#krate::target::CallerContext) + } else { + self.context_type(false) + } + } + + /// What `under` / `extend` hand a field with a context of its own: a + /// `CallerContext` where the record makes the caller's optional + /// (`under`), the threaded context itself where it does not (`extend`). + fn own_context_extended_by(&self) -> Type { let krate = &self.krate; + if self.declared_contexts() { + parse_quote!(#krate::target::CallerContext) + } else { + self.threaded_context() + } + } + + /// How the threaded context reaches this field's declaration, as the + /// call appended to it on the encrypt side (ADR-0004). + /// + /// The context reaches operations by being threaded, so a field names + /// itself once rather than computing a context to hand over. A field + /// with a context of its own gives its subtree that literal — `under` + /// when the record can make the caller's context optional, `extend` + /// when some other field is a bare leaf and it cannot. A field with none + /// is handed the threaded context as it is, converted into whatever its + /// type declares it needs: the AEAD half for a ciphertext, unchanged for + /// a term, composed with its own contexts by a nested record, and — for + /// a leaf reached through a record that may run under `()` — refused, + /// at the field. + /// + /// Spanned at the field type: an interpolated token stream keeps the + /// spans it was built with, so what the field's type refuses is + /// reported there rather than at the derive. + pub(crate) fn field_threading(&self, field: &Field) -> TokenStream { + let krate = &self.krate; + let span = field.ty.span(); match field.field_context() { - FieldContext::Caller => quote!(::core::clone::Clone::clone(&__context)), FieldContext::Own(lit) => { - let failed = if decrypt { - quote!(#krate::target::Decryption::failed) + if self.declared_contexts() { + quote_spanned!(span=> .under(#krate::nonempty!(#lit))) } else { - quote!(#krate::target::Encryption::failed) - }; + let threaded = respan(self.threaded_context().into_token_stream(), span); + quote_spanned!(span=> .extend::<#threaded>(#krate::nonempty!(#lit))) + } + } + FieldContext::Caller => { + let threaded = respan(self.threaded_context().into_token_stream(), span); + quote_spanned!(span=> .accepting::<#threaded>()) + } + } + } + + /// What [`field_threading`](Self::field_threading) asks of a field's + /// type, as the impl's where-clause: that it is a target of `source`, + /// and that the context handed down converts into the one it declares. + /// Only for a field whose source the derive can name — a `from` field's + /// obligation is checked in the body, against a plaintext field's type + /// the derive cannot name. + pub(crate) fn field_bounds(&self, field: &Field, source: &Type) -> [WherePredicate; 2] { + let krate = &self.krate; + let ty = &field.ty; + let context = quote!(<#ty as #krate::target::EncryptFrom<#source>>::Context); + let threading = match field.field_context() { + FieldContext::Own(_) => { + let extended = self.own_context_extended_by(); + parse_quote!(#context: From<#extended>) + } + FieldContext::Caller => { + let threaded = self.threaded_context(); + parse_quote!(#threaded: Into<#context>) + } + }; + [ + parse_quote!(#ty: #krate::target::EncryptFrom<#source>), + threading, + ] + } + + /// The context a field is opened under, from the record's `__context`, + /// on the decrypt side: the caller's as it is, or the field's own + /// extended by it. + pub(crate) fn context_expr(&self, field: &Field) -> TokenStream { + let krate = &self.krate; + match field.field_context() { + FieldContext::Caller => quote!(::core::clone::Clone::clone(&__context)), + FieldContext::Own(lit) => { let method = if self.declared_contexts() { - quote!(field) - } else { quote!(under) + } else { + quote!(extend) }; - quote!(match __context.clone().#method(#lit) { - Ok(context) => context, Err(error) => return #failed(error), - }) + quote!(::core::clone::Clone::clone(&__context).#method(#krate::nonempty!(#lit))) } } } @@ -440,7 +548,10 @@ impl Record { } } } -pub(crate) fn zip(operations: Vec<(TokenStream, Ident)>, result: TokenStream) -> TokenStream { +/// The chain zipping `operations` into one description, and the nested +/// tuple pattern that binds each operation's output to its local in the +/// closure that maps the chain's output. +pub(crate) fn zip_chain(operations: Vec<(TokenStream, Ident)>) -> (TokenStream, TokenStream) { let mut chain = TokenStream::new(); let mut pattern = TokenStream::new(); for (index, (operation, local)) in operations.into_iter().enumerate() { @@ -452,6 +563,12 @@ pub(crate) fn zip(operations: Vec<(TokenStream, Ident)>, result: TokenStream) -> pattern = quote!((#pattern, #local)); } } + (chain, pattern) +} + +/// The zipped `operations`, mapped to `result`. +pub(crate) fn zip(operations: Vec<(TokenStream, Ident)>, result: TokenStream) -> TokenStream { + let (chain, pattern) = zip_chain(operations); quote!(#chain.map(move |#pattern| #result)) } diff --git a/packages/stack-encrypt/src/target/context.rs b/packages/stack-encrypt/src/target/context.rs index 7d3104bbf..cd4888979 100644 --- a/packages/stack-encrypt/src/target/context.rs +++ b/packages/stack-encrypt/src/target/context.rs @@ -62,18 +62,12 @@ impl CallerContext { pub(super) fn validated(self) -> Result<NonEmpty<Self>, Error> { nonempty(self) } - /// Extend a field's own context with this caller context: the field's - /// literal becomes the prefix, this context the extension, exactly as a - /// `struct = T` derive composes them. - /// - /// # Errors - /// - /// Fails if `field` is empty; a field's own context is a literal the - /// derive has already checked, so a hand-written caller is the only one - /// that can hit this. - pub fn under(self, field: &'static str) -> Result<Self, Error> { - let prefix = nonempty(field)?; - Ok(prefix.with(self.validated()?).into()) + /// The own context `own`, extended by this caller context: the field's + /// literal is the prefix, this context the extension, exactly as a + /// `struct = T` derive composes them — `("users/age", id)`. The own + /// context is never discarded, and both encodings are preserved. + pub fn extend(self, own: NonEmpty<&'static str>) -> Self { + own.with(self).into() } } @@ -114,18 +108,39 @@ impl AeadContext { pub(super) fn validated(self) -> Result<NonEmpty<Self>, Error> { nonempty(self) } - /// Extend a field's own context with this caller context, as - /// [`CallerContext::under`] does for a record that derives terms: the + /// The own context `own`, extended by this caller context, as + /// [`CallerContext::extend`] does for a record that derives terms: the /// field's literal is the prefix, this context the extension. - /// - /// # Errors - /// - /// Fails if `field` is empty; a field's own context is a literal the - /// derive has already checked, so a hand-written caller is the only one - /// that can hit this. - pub fn under(self, field: &'static str) -> Result<Self, Error> { - let prefix = nonempty(field)?; - Ok(prefix.with(self.validated()?).into()) + pub fn extend(self, own: NonEmpty<&'static str>) -> Self { + own.with(self).into() + } +} + +/// A caller's context of either kind, extending a field's own context: what +/// [`Encryption::extend`](super::Encryption::extend) asks of the context a +/// subtree is run under. An own context is a `NonEmpty<&'static str>` — the +/// derive emits a `nonempty!(..)` for a literal — so an empty one is refused +/// at compile time, and extending cannot fail. +/// +/// Sealed: the two core-owned types are the two kinds, and a context that +/// extends is one whose encodings the core built. +pub trait Extends: sealed::Sealed + Sized { + /// The own context `own`, extended by this one. + fn extend(self, own: NonEmpty<&'static str>) -> Self; +} +mod sealed { + pub trait Sealed {} + impl Sealed for super::CallerContext {} + impl Sealed for super::AeadContext {} +} +impl Extends for CallerContext { + fn extend(self, own: NonEmpty<&'static str>) -> Self { + CallerContext::extend(self, own) + } +} +impl Extends for AeadContext { + fn extend(self, own: NonEmpty<&'static str>) -> Self { + AeadContext::extend(self, own) } } @@ -154,17 +169,13 @@ impl<'a, T: IntoAad<'a> + IntoPrfContext<'a> + Clone> From<NonEmpty<T>> for Decl } } impl DeclaredContext { - /// The context one field is derived under: its own literal, extended by - /// the caller's context if one was given. - /// - /// # Errors - /// - /// Fails if `field` is empty. The derive checks its literals at compile - /// time, so only a hand-written caller can hit this. - pub fn field(self, field: &'static str) -> Result<CallerContext, Error> { + /// The context one field is derived under: its own `own`, extended by + /// the caller's context if one was given — `"users/age"` as it is under + /// `()`, `("users/age", id)` under a caller's `id`. + pub fn under(self, own: NonEmpty<&'static str>) -> CallerContext { match self.0 { - Some(context) => context.under(field), - None => nonempty(field).map(Into::into), + Some(caller) => caller.extend(own), + None => own.into(), } } } diff --git a/packages/stack-encrypt/src/target/mod.rs b/packages/stack-encrypt/src/target/mod.rs index be326b623..29b8b9736 100644 --- a/packages/stack-encrypt/src/target/mod.rs +++ b/packages/stack-encrypt/src/target/mod.rs @@ -23,6 +23,14 @@ //! contexts unchanged and whose nonempty form extends them. Custom targets may //! instead declare `Context = ()`. //! +//! Within one target, every operation runs under the one context the target is +//! handed (ADR-0004): the context is a type parameter of [`Encryption`], zipped +//! subtrees must need the same one and receive the same value, and a ciphertext +//! beside a term takes the term's `CallerContext` — of which its own +//! `AeadContext` is the AEAD half — through [`Encryption::accepting`]. A record +//! gives a field a context of its own with [`Encryption::under`] or +//! [`Encryption::extend`], and the caller's context then extends it. +//! //! # Output adapters //! //! An adapter selects a core operation and converts only its completed output. @@ -40,9 +48,9 @@ //! impl<S> EncryptFrom<S> for StoredEquality //! where EqualityTerm: EncryptFrom<S, Context = CallerContext> { //! type Context = CallerContext; -//! fn encryption<'s,K:'static>(context:Self::Context)->Encryption<'s,S,Self,K> +//! fn encryption<'s,K:'static>()->Encryption<'s,S,Self,K,Self::Context> //! where S:'s { -//! EqualityTerm::encryption(context).map(|term| Self(term.into_bytes())) +//! <EqualityTerm as EncryptFrom<S>>::encryption().map(|term| Self(term.into_bytes())) //! } //! } //! ``` @@ -78,7 +86,7 @@ mod request; pub mod transcode; pub(crate) use self::core::{decipher_pending, seal_pending}; -pub use context::{AeadContext, CallerContext, DeclaredContext, ExpectedContext}; +pub use context::{AeadContext, CallerContext, DeclaredContext, ExpectedContext, Extends}; pub use operations::{ ciphertext, equality, matching, ope, open, ore, DecryptField, DecryptFrom, DecryptInto, Decryptable, Decryption, EncryptFrom, EncryptInto, Encryption, diff --git a/packages/stack-encrypt/src/target/operations.rs b/packages/stack-encrypt/src/target/operations.rs index 523474442..ac308f107 100644 --- a/packages/stack-encrypt/src/target/operations.rs +++ b/packages/stack-encrypt/src/target/operations.rs @@ -2,10 +2,16 @@ //! executes. No constructor here accepts a plaintext-and-cipher callback; a //! description selects core operations and converts their completed output, //! nothing more. -use super::context::{AeadContext, CallerContext}; +//! +//! No constructor takes a context either. The context reaches every operation +//! by being threaded through the tree that composes them, as a type parameter +//! of [`Encryption`] (ADR-0004): a target cannot seal a value under one +//! context and index it under another, because there is no second context to +//! hand anything. +use super::context::{AeadContext, CallerContext, DeclaredContext, Extends}; use super::core::{encrypt_native, open_native, Term}; use super::{CipherScope, Pending}; -use crate::{Error, KeysetCipher, StackCipher, StackCipherText}; +use crate::{Error, KeysetCipher, NonEmpty, StackCipher, StackCipherText}; use stack_kms::MaybeSend; use std::fmt; @@ -18,13 +24,17 @@ use std::fmt; /// cipher: the returned [`Encryption`]'s execution is private, so a target /// can choose operations and build its output but cannot replace encryption. pub trait EncryptFrom<S>: Sized + 'static { - /// What a caller supplies alongside the plaintext: a [`CallerContext`] or - /// [`AeadContext`] for a generic target, a `NonEmpty<T>` for a record that - /// stores its identifier, or `()` when the declaration carries every - /// context it needs. + /// The context this target still needs when it is run — what a caller + /// supplies alongside the plaintext: a [`CallerContext`] for a target + /// that derives terms, an [`AeadContext`] for one that only seals, a + /// `NonEmpty<T>` for a record that stores its identifier, or a + /// [`DeclaredContext`] — which `()` satisfies — for a record whose fields + /// name their own. type Context; - /// The description the cipher executes for one value of `S`. - fn encryption<'s, K: 'static>(context: Self::Context) -> Encryption<'s, S, Self, K> + /// The description the cipher executes for one value of `S`. The context + /// is supplied when the description is run, not here, so every operation + /// beneath it receives the same one (ADR-0004). + fn encryption<'s, K: 'static>() -> Encryption<'s, S, Self, K, Self::Context> where S: 's; } @@ -43,24 +53,40 @@ pub trait DecryptInto<P>: Sized { } #[cfg(not(target_arch = "wasm32"))] -type Build<'s, S, T, K> = - Box<dyn for<'a, 'k> FnOnce(&S, &'a KeysetCipher<'k, K>) -> Pending<'a, T, K> + Send + 's>; +type Build<'s, S, T, K, Ctx> = + Box<dyn for<'a, 'k> FnOnce(&S, &'a KeysetCipher<'k, K>, Ctx) -> Pending<'a, T, K> + Send + 's>; #[cfg(target_arch = "wasm32")] -type Build<'s, S, T, K> = - Box<dyn for<'a, 'k> FnOnce(&S, &'a KeysetCipher<'k, K>) -> Pending<'a, T, K> + 's>; +type Build<'s, S, T, K, Ctx> = + Box<dyn for<'a, 'k> FnOnce(&S, &'a KeysetCipher<'k, K>, Ctx) -> Pending<'a, T, K> + 's>; #[cfg(not(target_arch = "wasm32"))] type Open<T, K> = Box<dyn for<'a> FnOnce(&'a StackCipher<K>) -> Pending<'a, T, K> + Send>; #[cfg(target_arch = "wasm32")] type Open<T, K> = Box<dyn for<'a> FnOnce(&'a StackCipher<K>) -> Pending<'a, T, K>>; -/// A composable description of how `T` is encrypted from `S`. +/// A composable description of how `T` is encrypted from `S`, under the +/// `Ctx` it is handed when it runs. /// /// Built from the constructors in this module and the combinators below; /// executed only by [`KeysetCipher::encrypt_as`]. Nothing runs, and no key is /// requested, until then. +/// +/// `Ctx` is the context this description still needs. It is a type parameter, +/// not a stored value, and that is what makes two rules hold at compile time +/// rather than by discipline (ADR-0004): +/// +/// - **One context per target.** [`zip`](Self::zip) requires both sides to +/// need the same `Ctx` and hands them the same value, so a target cannot +/// seal under one context and index under another. A ciphertext beside a +/// term takes the term's context through [`accepting`](Self::accepting): +/// the [`AeadContext`] it seals under is the AEAD half of that one value. +/// - **A leaf still cannot be reached without a context.** An operation needs +/// a real one. [`under`](Self::under) and [`extend`](Self::extend) are the +/// only ways to change the context a subtree runs under, and only `under` +/// discharges the requirement into a [`DeclaredContext`], which is what +/// `()` may satisfy. #[must_use = "an encryption description does nothing until a keyset cipher executes it"] -pub struct Encryption<'s, S, T, K> { - build: Build<'s, S, T, K>, +pub struct Encryption<'s, S, T, K, Ctx> { + build: Build<'s, S, T, K, Ctx>, } /// A composable description of how `T` is recovered from a stored target. /// @@ -78,7 +104,7 @@ enum Opening<T, K> { Failed(Error), Open(Open<T, K>), } -impl<S, T, K> fmt::Debug for Encryption<'_, S, T, K> { +impl<S, T, K, Ctx> fmt::Debug for Encryption<'_, S, T, K, Ctx> { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.debug_struct("Encryption").finish_non_exhaustive() } @@ -93,7 +119,7 @@ impl<T, K> fmt::Debug for Decryption<T, K> { } } -impl<'s, S: 's, T: 'static, K: 'static> Encryption<'s, S, T, K> { +impl<'s, S: 's, T: 'static, K: 'static, Ctx: 's> Encryption<'s, S, T, K, Ctx> { /// A description whose output is already known — metadata a record /// carries, or a declaration rejected before any key request. pub fn ready(result: Result<T, Error>) -> Self @@ -101,54 +127,123 @@ impl<'s, S: 's, T: 'static, K: 'static> Encryption<'s, S, T, K> { T: MaybeSend, { Self { - build: Box::new(move |_, cipher| Pending::ready(cipher, result)), + build: Box::new(move |_, cipher, _| Pending::ready(cipher, result)), } } /// Reject the declaration: execution yields `error` without I/O, and any /// description this is zipped into fails with it. pub fn failed(error: Error) -> Self { Self { - build: Box::new(move |_, cipher| Pending::failed(cipher, error)), + build: Box::new(move |_, cipher, _| Pending::failed(cipher, error)), } } /// Build the destination from the completed output. `f` sees ciphertext /// and terms, never the plaintext. - pub fn map<U: 'static, F>(self, f: F) -> Encryption<'s, S, U, K> + pub fn map<U: 'static, F>(self, f: F) -> Encryption<'s, S, U, K, Ctx> where F: FnOnce(T) -> U + MaybeSend + 'static, { Encryption { - build: Box::new(move |source, cipher| (self.build)(source, cipher).map(f)), + build: Box::new(move |source, cipher, cx| (self.build)(source, cipher, cx).map(f)), } } /// [`map`](Self::map) for a conversion that can fail, such as reading /// native output into a destination that does not accept every shape. - pub fn try_map<U: 'static, F>(self, f: F) -> Encryption<'s, S, U, K> + pub fn try_map<U: 'static, F>(self, f: F) -> Encryption<'s, S, U, K, Ctx> where F: FnOnce(T) -> Result<U, Error> + MaybeSend + 'static, { Encryption { - build: Box::new(move |source, cipher| (self.build)(source, cipher).try_map(f)), + build: Box::new(move |source, cipher, cx| (self.build)(source, cipher, cx).try_map(f)), } } /// Drive the destination's [`Visitor`](super::transcode::Visitor) from /// this operation's native output, moving leaves and markers across /// without an intermediate tree. - pub fn transcode<U: super::transcode::Transcode + 'static>(self) -> Encryption<'s, S, U, K> + pub fn transcode<U: super::transcode::Transcode + 'static>(self) -> Encryption<'s, S, U, K, Ctx> where T: super::transcode::Reader, { self.try_map(|output| super::transcode::Reader::read(output, U::visitor())) } - /// Run both descriptions over the same source, settling their key - /// requests in one batch. - pub fn zip<U: 'static>(self, other: Encryption<'s, S, U, K>) -> Encryption<'s, S, (T, U), K> { + /// Run both descriptions over the same source, under the one context this + /// description is handed, settling their key requests in one batch. + /// + /// Both sides must need the same `Ctx`, and both receive the same value: + /// there is no second context to pass, which is the whole of ADR-0004's + /// first decision. + pub fn zip<U: 'static>( + self, + other: Encryption<'s, S, U, K, Ctx>, + ) -> Encryption<'s, S, (T, U), K, Ctx> + where + Ctx: Clone, + { Encryption { - build: Box::new(move |source, cipher| { - (self.build)(source, cipher).zip((other.build)(source, cipher)) + build: Box::new(move |source, cipher, cx| { + (self.build)(source, cipher, cx.clone()).zip((other.build)(source, cipher, cx)) }), } } + /// Take a different context type, converting on the way in. + /// + /// A ciphertext seals under an [`AeadContext`] while the term beside it + /// derives under a [`CallerContext`]: `accepting` lets the ciphertext + /// take the term's context, of which its own is the AEAD half, so the two + /// zip under one value. Likewise a record declares the context its + /// *caller* supplies, which need not be the type its operations need — a + /// record storing its own context declares `NonEmpty<T>` while its + /// operations want a `CallerContext` — and this adapts the one to the + /// other once, at the root. + pub fn accepting<C2>(self) -> Encryption<'s, S, T, K, C2> + where + C2: Into<Ctx> + 's, + { + self.needing(Into::into) + } + /// Need a different context, derived from the one supplied by `derive` + /// at the root of this subtree. The one place a context changes on its + /// way down; every public way of doing so is a closure handed here. + fn needing<C2, F>(self, derive: F) -> Encryption<'s, S, T, K, C2> + where + C2: 's, + F: FnOnce(C2) -> Ctx + MaybeSend + 's, + { + Encryption { + build: Box::new(move |source, cipher, cx| (self.build)(source, cipher, derive(cx))), + } + } + /// Run this whole subtree under `own`, extended by the surrounding + /// context if there is one. + /// + /// A record names each field once here rather than handing a context to + /// every operation separately. It is also what discharges the context an + /// operation needs, which is why a leaf that is never given a context of + /// its own cannot be run under `()`. Available wherever a + /// [`CallerContext`] can become what the subtree needs: a leaf of either + /// kind, or a record whose own contexts a caller's extends. + pub fn under(self, own: NonEmpty<&'static str>) -> Encryption<'s, S, T, K, DeclaredContext> + where + Ctx: From<CallerContext>, + { + self.needing(move |cx: DeclaredContext| cx.under(own).into()) + } + /// Run this whole subtree under `own`, extended by the surrounding + /// context `C` — which is still required. + /// + /// The sibling of [`under`](Self::under), for a record that cannot make + /// the caller's context optional because some *other* field of it is a + /// bare leaf. `C` is the caller's context type — a [`CallerContext`], or + /// an [`AeadContext`] for a record that only seals — and the same one + /// context reaches every operation beneath; the difference from `under` + /// is only whether `()` can satisfy the result. + pub fn extend<C>(self, own: NonEmpty<&'static str>) -> Encryption<'s, S, T, K, C> + where + C: Extends + 's, + Ctx: From<C>, + { + self.needing(move |cx: C| cx.extend(own).into()) + } /// Lift a description of a field to a description of the struct that /// holds it, which is how a `struct = T` derive composes its fields. /// @@ -159,28 +254,57 @@ impl<'s, S: 's, T: 'static, K: 'static> Encryption<'s, S, T, K> { pub fn project<P: 's>( self, select: for<'borrow> fn(&'borrow P) -> &'borrow S, - ) -> Encryption<'s, P, T, K> { + ) -> Encryption<'s, P, T, K, Ctx> { + Encryption { + build: Box::new(move |source, cipher, cx| (self.build)(select(source), cipher, cx)), + } + } +} + +impl<'s, S: 's, T: 'static, K: 'static, Ctx: 's + Clone + MaybeSend + 'static> + Encryption<'s, S, T, K, Ctx> +{ + /// Build the output from the completed operations *and* the context they + /// ran under. + /// + /// For a record that stores its own context in a field + /// (`#[stash(context_field)]`): the context is supplied when the + /// description runs, so the field it populates is filled there too. + pub fn map_with_context<U: 'static, F>(self, f: F) -> Encryption<'s, S, U, K, Ctx> + where + F: FnOnce(T, Ctx) -> U + MaybeSend + 'static, + { Encryption { - build: Box::new(move |source, cipher| (self.build)(select(source), cipher)), + build: Box::new(move |source, cipher, cx: Ctx| { + let carried = cx.clone(); + (self.build)(source, cipher, cx).map(move |value| f(value, carried)) + }), } } } -/// The canonical ciphertext operation: seal `S` under `context` through its -/// own Vitamin C `Encrypt` implementation, into the native -/// [`StackCipherText`] tree. There is no Serde fallback; a plaintext without -/// `Encrypt` does not compile. +/// The canonical ciphertext operation: seal `S`, under the [`AeadContext`] +/// the tree hands it, through its own Vitamin C `Encrypt` implementation, +/// into the native [`StackCipherText`] tree. There is no Serde fallback; a +/// plaintext without `Encrypt` does not compile. +/// +/// Sealing needs only the AEAD encoding of a context, so this needs an +/// `AeadContext` where a term needs a [`CallerContext`]. Beside a term, +/// [`accepting`](Encryption::accepting) lets it take the term's context — +/// the AEAD half of the same value — so the two zip under one context. pub fn ciphertext<'s, S: crate::Encrypt + Clone + 's, K: 'static>( - context: impl Into<AeadContext>, -) -> Encryption<'s, S, StackCipherText, K> { - let context = context.into(); +) -> Encryption<'s, S, StackCipherText, K, AeadContext> { Encryption { - build: Box::new(move |source, cipher| match context.validated() { - Ok(ctx) => encrypt_native(source, cipher, ctx), - Err(e) => Pending::failed(cipher, e), - }), + build: Box::new( + move |source, cipher, cx: AeadContext| match cx.validated() { + Ok(ctx) => encrypt_native(source, cipher, ctx), + Err(e) => Pending::failed(cipher, e), + }, + ), } } +/// A term operation: `$function` produces `$output` from any `S` satisfying +/// the bounds, under the [`CallerContext`] the tree hands it. macro_rules! term_operation { ( $(#[$doc:meta])* @@ -188,15 +312,13 @@ macro_rules! term_operation { ) => { $(#[$doc])* pub fn $function<'s, S, K: 'static, $($generics)*>( - context: impl Into<CallerContext>, - ) -> Encryption<'s, S, $output, K> + ) -> Encryption<'s, S, $output, K, CallerContext> where S: 's, $($bounds)* { - let context = context.into(); Encryption { - build: Box::new(move |source, cipher| match context.validated() { + build: Box::new(move |source, cipher, cx: CallerContext| match cx.validated() { Ok(ctx) => <$output as Term<S, K, _>>::encrypt_from(source, cipher, ctx), Err(e) => Pending::failed(cipher, e), }), @@ -205,24 +327,25 @@ macro_rules! term_operation { }; } term_operation!( - /// The equality term of `S` under `context`. Requires only `S`'s PRF - /// contract, not recoverable encryption. + /// The equality term of `S` under the context the tree hands it. Requires + /// only `S`'s PRF contract, not recoverable encryption. equality, crate::sem::EqualityTerm, [], [S: vitaminc_prf::PrfValue + Clone] ); term_operation!( - /// The match term of any text `S` under `context`, tokenised and hashed - /// as `O` declares. + /// The match term of any text `S` under the context the tree hands it, + /// tokenised and hashed as `O` declares. matching, crate::sem::MatchTerm<O>, [O: crate::sem::MatchConfig + 'static], [S: AsRef<str>] ); term_operation!( - /// The order-revealing term of `S` under `context`. The bounds are the - /// leaf's own: they say which `S` the CLLW ORE scheme can order. + /// The order-revealing term of `S` under the context the tree hands it. + /// The bounds are the leaf's own: they say which `S` the CLLW ORE scheme + /// can order. ore, crate::sem::OreTerm<S>, [], [S: cllw_ore::CllwOreEncrypt + Clone + Send + 'static, S::Output: Send + 'static] ); term_operation!( - /// The order-preserving term of `S` under `context`, with the same - /// bounds as [`ore`]. + /// The order-preserving term of `S` under the context the tree hands it, + /// with the same bounds as [`ore`]. ope, crate::sem::OpeTerm<S>, [], [S: cllw_ore::CllwOpeEncrypt + Clone + Send + 'static, S::Output: Send + 'static] ); @@ -336,7 +459,7 @@ impl<K: 'static> KeysetCipher<'_, K> { where T: EncryptFrom<S>, { - (T::encryption(context).build)(source, self) + (T::encryption().build)(source, self, context) } /// Recover `P` from `source`, as its declaration describes. A leaf sealed /// under another keyset is refused ([`Error::ForeignKeyset`]) before any @@ -451,11 +574,11 @@ impl<T: 'static> DecryptFrom for T {} impl<S: crate::Encrypt + Clone> EncryptFrom<S> for StackCipherText { type Context = AeadContext; - fn encryption<'s, K: 'static>(context: Self::Context) -> Encryption<'s, S, Self, K> + fn encryption<'s, K: 'static>() -> Encryption<'s, S, Self, K, Self::Context> where S: 's, { - ciphertext(context) + ciphertext() } } impl<P: crate::Decrypt<'static> + 'static> DecryptInto<P> for StackCipherText { @@ -466,22 +589,22 @@ impl<P: crate::Decrypt<'static> + 'static> DecryptInto<P> for StackCipherText { } impl<S: vitaminc_prf::PrfValue + Clone> EncryptFrom<S> for crate::sem::EqualityTerm { type Context = CallerContext; - fn encryption<'s, K: 'static>(context: Self::Context) -> Encryption<'s, S, Self, K> + fn encryption<'s, K: 'static>() -> Encryption<'s, S, Self, K, Self::Context> where S: 's, { - equality(context) + equality() } } impl<S: AsRef<str>, O: crate::sem::MatchConfig + 'static> EncryptFrom<S> for crate::sem::MatchTerm<O> { type Context = CallerContext; - fn encryption<'s, K: 'static>(context: Self::Context) -> Encryption<'s, S, Self, K> + fn encryption<'s, K: 'static>() -> Encryption<'s, S, Self, K, Self::Context> where S: 's, { - matching(context) + matching() } } impl<S> EncryptFrom<S> for crate::sem::OreTerm<S> @@ -490,11 +613,11 @@ where S::Output: Send + 'static, { type Context = CallerContext; - fn encryption<'s, K: 'static>(context: Self::Context) -> Encryption<'s, S, Self, K> + fn encryption<'s, K: 'static>() -> Encryption<'s, S, Self, K, Self::Context> where S: 's, { - ore(context) + ore() } } impl<S> EncryptFrom<S> for crate::sem::OpeTerm<S> @@ -503,11 +626,11 @@ where S::Output: Send + 'static, { type Context = CallerContext; - fn encryption<'s, K: 'static>(context: Self::Context) -> Encryption<'s, S, Self, K> + fn encryption<'s, K: 'static>() -> Encryption<'s, S, Self, K, Self::Context> where S: 's, { - ope(context) + ope() } } @@ -521,17 +644,17 @@ where T::Context: Clone + 'static + MaybeSend, { type Context = T::Context; - fn encryption<'s, K: 'static>(context: Self::Context) -> Encryption<'s, Vec<S>, Self, K> + fn encryption<'s, K: 'static>() -> Encryption<'s, Vec<S>, Self, K, Self::Context> where S: 's, { Encryption { - build: Box::new(move |source, cipher| { + build: Box::new(move |source, cipher, cx: T::Context| { Pending::collect( cipher, source .iter() - .map(|item| cipher.encrypt_as(item, context.clone())), + .map(|item| cipher.encrypt_as(item, cx.clone())), ) }), } @@ -542,13 +665,13 @@ where T::Context: 'static + MaybeSend, { type Context = T::Context; - fn encryption<'s, K: 'static>(context: Self::Context) -> Encryption<'s, Option<S>, Self, K> + fn encryption<'s, K: 'static>() -> Encryption<'s, Option<S>, Self, K, Self::Context> where S: 's, { Encryption { - build: Box::new(move |source, cipher| match source { - Some(item) => cipher.encrypt_as(item, context).map(Some), + build: Box::new(move |source, cipher, cx: T::Context| match source { + Some(item) => cipher.encrypt_as(item, cx).map(Some), None => Pending::ready(cipher, Ok(None)), }), } diff --git a/packages/stack-encrypt/tests/derive.rs b/packages/stack-encrypt/tests/derive.rs index a868f797e..27e446d80 100644 --- a/packages/stack-encrypt/tests/derive.rs +++ b/packages/stack-encrypt/tests/derive.rs @@ -246,13 +246,11 @@ where EqualityTerm: EncryptFrom<S>, { type Context = <EqualityTerm as EncryptFrom<S>>::Context; - fn encryption<'s, K: 'static>( - context: Self::Context, - ) -> stack_encrypt::Encryption<'s, S, Self, K> + fn encryption<'s, K: 'static>() -> stack_encrypt::Encryption<'s, S, Self, K, Self::Context> where S: 's, { - EqualityTerm::encryption(context).map(OpaqueTerm) + <EqualityTerm as EncryptFrom<S>>::encryption().map(OpaqueTerm) } } diff --git a/packages/stack-encrypt/tests/target.rs b/packages/stack-encrypt/tests/target.rs index 227675874..dd22e62e7 100644 --- a/packages/stack-encrypt/tests/target.rs +++ b/packages/stack-encrypt/tests/target.rs @@ -346,13 +346,17 @@ struct EncryptedAge { impl EncryptFrom<u32> for EncryptedAge { type Context = CallerContext; - fn encryption<'s, K: 'static>(context: Self::Context) -> Encryption<'s, u32, Self, K> + fn encryption<'s, K: 'static>() -> Encryption<'s, u32, Self, K, Self::Context> where u32: 's, { - stack_encrypt::target::ciphertext(context.clone()) - .zip(stack_encrypt::target::equality(context.clone())) - .zip(stack_encrypt::target::ore(context)) + // One context reaches all three; there is no second one to pass. + // The ciphertext seals under the AEAD half of the one context the + // terms derive under: `accepting` lets it take theirs. + stack_encrypt::target::ciphertext() + .accepting() + .zip(stack_encrypt::target::equality()) + .zip(stack_encrypt::target::ore()) .map(|((c, hm), ob)| Self { c, hm, ob }) } } @@ -455,11 +459,11 @@ where EqualityTerm: EncryptFrom<S>, { type Context = <EqualityTerm as EncryptFrom<S>>::Context; - fn encryption<'s, K: 'static>(context: Self::Context) -> Encryption<'s, S, Self, K> + fn encryption<'s, K: 'static>() -> Encryption<'s, S, Self, K, Self::Context> where S: 's, { - EqualityTerm::encryption(context).map(|term| Self(term.into_bytes())) + <EqualityTerm as EncryptFrom<S>>::encryption().map(|term| Self(term.into_bytes())) } } #[tokio::test] diff --git a/packages/stack-encrypt/tests/transcode.rs b/packages/stack-encrypt/tests/transcode.rs index 2c6f758bc..8d44c6c07 100644 --- a/packages/stack-encrypt/tests/transcode.rs +++ b/packages/stack-encrypt/tests/transcode.rs @@ -60,11 +60,11 @@ impl Transcode for StoredLeaf { } impl<S: Encrypt + Clone> EncryptFrom<S> for StoredLeaf { type Context = CallerContext; - fn encryption<'s, K: 'static>(context: Self::Context) -> Encryption<'s, S, Self, K> + fn encryption<'s, K: 'static>() -> Encryption<'s, S, Self, K, Self::Context> where S: 's, { - target::ciphertext(context).transcode() + target::ciphertext().accepting().transcode() } } impl<P: stack_encrypt::Decrypt<'static> + 'static> DecryptInto<P> for StoredLeaf { @@ -293,13 +293,17 @@ async fn scalar_destination_refuses_a_sequence() { struct FixedLeaf(StoredLeaf); impl<S: Encrypt + Clone> EncryptFrom<S> for FixedLeaf { type Context = (); - fn encryption<'s, K: 'static>((): ()) -> Encryption<'s, S, Self, K> + fn encryption<'s, K: 'static>() -> Encryption<'s, S, Self, K, Self::Context> where S: 's, { - target::ciphertext(nonempty!("fixed/leaf")) + // The declaration names its own context with `under`; `()` then + // satisfies the `DeclaredContext` that leaves. + target::ciphertext() .transcode() .map(Self) + .under(nonempty!("fixed/leaf")) + .accepting() } } impl<P: stack_encrypt::Decrypt<'static> + 'static> DecryptInto<P> for FixedLeaf { diff --git a/packages/stack-encrypt/tests/ui/aead_context_with_term.stderr b/packages/stack-encrypt/tests/ui/aead_context_with_term.stderr index d0544abce..1087c647c 100644 --- a/packages/stack-encrypt/tests/ui/aead_context_with_term.stderr +++ b/packages/stack-encrypt/tests/ui/aead_context_with_term.stderr @@ -17,24 +17,3 @@ error[E0277]: the trait bound `CallerContext: From<AeadContext>` is not satisfie = note: required for `AeadContext` to implement `Into<CallerContext>` = help: see issue #48214 = note: this error originates in the derive macro `EncryptFrom` (in Nightly builds, run with -Z macro-backtrace for more info) - -error[E0277]: the trait bound `CallerContext: From<AeadContext>` is not satisfied - --> tests/ui/aead_context_with_term.rs:8:10 - | - 8 | #[derive(EncryptFrom)] - | ^^^^^^^^^^^ the trait `From<AeadContext>` is not implemented for `CallerContext` -... -12 | hm: EqualityTerm, - | ------------ required by a bound introduced by this call - | - = help: the following other types implement trait `From<T>`: - `CallerContext` implements `From<NonEmpty<T>>` - `CallerContext` implements `From<i128>` - `CallerContext` implements `From<i16>` - `CallerContext` implements `From<i32>` - `CallerContext` implements `From<i64>` - `CallerContext` implements `From<i8>` - `CallerContext` implements `From<u128>` - `CallerContext` implements `From<u16>` - and $N others - = note: required for `AeadContext` to implement `Into<CallerContext>` diff --git a/packages/stack-encrypt/tests/ui/execution_callback.rs b/packages/stack-encrypt/tests/ui/execution_callback.rs index b2f396e25..cd4fa3c0b 100644 --- a/packages/stack-encrypt/tests/ui/execution_callback.rs +++ b/packages/stack-encrypt/tests/ui/execution_callback.rs @@ -1,7 +1,10 @@ +use stack_encrypt::target::CallerContext; use stack_encrypt::{Encryption, StackCipherText}; fn main() { // Target authors cannot install a callback that receives plaintext + cipher. - let _: Encryption<'_, u32, StackCipherText, ()> = Encryption { - build: Box::new(|_, cipher| stack_encrypt::Pending::failed(cipher, stack_encrypt::Error::Aead)), + let _: Encryption<'_, u32, StackCipherText, (), CallerContext> = Encryption { + build: Box::new(|_, cipher, _| { + stack_encrypt::Pending::failed(cipher, stack_encrypt::Error::Aead) + }), }; } diff --git a/packages/stack-encrypt/tests/ui/execution_callback.stderr b/packages/stack-encrypt/tests/ui/execution_callback.stderr index 2c291448a..2369603a3 100644 --- a/packages/stack-encrypt/tests/ui/execution_callback.stderr +++ b/packages/stack-encrypt/tests/ui/execution_callback.stderr @@ -1,7 +1,7 @@ error[E0451]: field `build` of struct `Encryption` is private - --> tests/ui/execution_callback.rs:5:9 + --> tests/ui/execution_callback.rs:6:9 | -4 | let _: Encryption<'_, u32, StackCipherText, ()> = Encryption { - | ---------- in this type -5 | build: Box::new(|_, cipher| stack_encrypt::Pending::failed(cipher, stack_encrypt::Error::Aead)), +5 | let _: Encryption<'_, u32, StackCipherText, (), CallerContext> = Encryption { + | ---------- in this type +6 | build: Box::new(|_, cipher, _| { | ^^^^^ private field diff --git a/packages/stack-encrypt/tests/ui/nested_leaf_without_context.stderr b/packages/stack-encrypt/tests/ui/nested_leaf_without_context.stderr index c568c1025..e6f1c201d 100644 --- a/packages/stack-encrypt/tests/ui/nested_leaf_without_context.stderr +++ b/packages/stack-encrypt/tests/ui/nested_leaf_without_context.stderr @@ -1,11 +1,8 @@ error[E0277]: the trait bound `AeadContext: From<DeclaredContext>` is not satisfied - --> tests/ui/nested_leaf_without_context.rs:11:10 + --> tests/ui/nested_leaf_without_context.rs:15:12 | -11 | #[derive(EncryptFrom, DecryptInto)] - | ^^^^^^^^^^^ the trait `From<DeclaredContext>` is not implemented for `AeadContext` -... 15 | email: StackCipherText, - | --------------- required by a bound introduced by this call + | ^^^^^^^^^^^^^^^ the trait `From<DeclaredContext>` is not implemented for `AeadContext` | = help: the following other types implement trait `From<T>`: `AeadContext` implements `From<CallerContext>` @@ -18,6 +15,14 @@ error[E0277]: the trait bound `AeadContext: From<DeclaredContext>` is not satisf `AeadContext` implements `From<u128>` and $N others = note: required for `DeclaredContext` to implement `Into<AeadContext>` +note: required by a bound in `Encryption::<'s, S, T, K, Ctx>::accepting` + --> src/target/operations.rs + | + | pub fn accepting<C2>(self) -> Encryption<'s, S, T, K, C2> + | --------- required by a bound in this associated function + | where + | C2: Into<Ctx> + 's, + | ^^^^^^^^^ required by this bound in `Encryption::<'s, S, T, K, Ctx>::accepting` error[E0277]: the trait bound `AeadContext: From<DeclaredContext>` is not satisfied --> tests/ui/nested_leaf_without_context.rs:15:12 diff --git a/packages/stack-encrypt/tests/ui/override_encryption.stderr b/packages/stack-encrypt/tests/ui/override_encryption.stderr index 73d0beafb..b86b2d756 100644 --- a/packages/stack-encrypt/tests/ui/override_encryption.stderr +++ b/packages/stack-encrypt/tests/ui/override_encryption.stderr @@ -15,4 +15,4 @@ error[E0046]: not all trait items implemented, missing: `encryption` 3 | impl EncryptFrom<u32> for Target { | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ missing `encryption` in implementation | - = help: implement the missing item: `fn encryption<K>(_: <Self as EncryptFrom<u32>>::Context) -> Encryption<'s, u32, Self, K> { todo!() }` + = help: implement the missing item: `fn encryption<K>() -> Encryption<'s, u32, Self, K, <Self as EncryptFrom<u32>>::Context> { todo!() }` From 28b420de3bdc21a6784a6a440afe8dc5077a5505 Mon Sep 17 00:00:00 2001 From: Dan Draper <dan@cipherstash.com> Date: Thu, 17 Sep 2026 18:43:26 -0400 Subject: [PATCH 561/686] feat(stack-encrypt)!: a data-key request renders its own descriptor from a context MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Request::generate_data_key` and `retrieve_data_key` took a built `Descriptor`. Together with `Pending::request` and `SealedValue::from_parts` — the documented third-party SEM extension point — that let a downstream implementation mint a key under one context and authenticate its ciphertext under another, with no crate code in the path. ADR-0004 item 4. Both public constructors now take a context — anything `Into<CallerContext>`, as the rest of the crate takes one — and render the descriptor themselves, so a request cannot name a context other than the one its leaf is authenticated under. The extension point stays; what goes is the ability to hand a request two values that disagree. A unit test holds both constructors to rendering the descriptor from the context they are given, through a `CallerContext`, without losing its structured identity. The batching paths keep `generate_under` / `retrieve_under`, crate-internal, over a descriptor already rendered once for a whole tree. That is not a loophole but a cost: re-rendering per leaf would be a context encoding per leaf, which `a_long_context_is_rendered_once_per_batch` exists to prevent. Internal callers have already derived that descriptor from the one context. The integration test that built a `Request` by hand now passes `nonempty!("t")`, and no longer imports `Descriptor` at all — which is the point: an outside caller has no reason to name one. BREAKING CHANGE: `Request::generate_data_key` takes a context (anything `Into<CallerContext>`) rather than a `Descriptor`; `Request::retrieve_data_key` likewise in place of its descriptor argument. Refs CIP-4042. --- packages/stack-encrypt/src/target/core.rs | 4 +- packages/stack-encrypt/src/target/pending.rs | 74 +++++++------------ packages/stack-encrypt/src/target/request.rs | 77 ++++++++++++++++---- packages/stack-encrypt/tests/target.rs | 6 +- 4 files changed, 91 insertions(+), 70 deletions(-) diff --git a/packages/stack-encrypt/src/target/core.rs b/packages/stack-encrypt/src/target/core.rs index ef2527392..612bcd8ca 100644 --- a/packages/stack-encrypt/src/target/core.rs +++ b/packages/stack-encrypt/src/target/core.rs @@ -62,7 +62,7 @@ pub(crate) fn seal_pending<'a, K>( return Pending::ready(cipher, Err(e)); } let keyset_id = cipher.keyset_id(); - let requests = std::iter::repeat_with(|| Request::generate_data_key(descriptor.clone())) + let requests = std::iter::repeat_with(|| Request::generate_under(descriptor.clone())) .take(tree.key_count()) .collect(); Pending::request(cipher, requests, move |responses| { @@ -118,7 +118,7 @@ fn collect_retrieve_requests( | CipherText::None(leaf) | CipherText::EmptySequence(leaf) | CipherText::EmptyMap(leaf) => { - out.push(Request::retrieve_data_key( + out.push(Request::retrieve_under( *leaf.iv(), leaf.tag().to_vec(), descriptor.clone(), diff --git a/packages/stack-encrypt/src/target/pending.rs b/packages/stack-encrypt/src/target/pending.rs index 942219347..91bf8c162 100644 --- a/packages/stack-encrypt/src/target/pending.rs +++ b/packages/stack-encrypt/src/target/pending.rs @@ -685,7 +685,7 @@ mod tests { keyset: &'a KeysetCipher<'_, CountingSource>, n: usize, ) -> Pending<'a, Vec<Vec<u8>>, CountingSource> { - let requests = std::iter::repeat_with(|| Request::generate_data_key(d())) + let requests = std::iter::repeat_with(|| Request::generate_under(d())) .take(n) .collect(); Pending::request(keyset, requests, move |responses| { @@ -920,15 +920,12 @@ mod tests { async fn over_drawing_responses_is_a_response_shape_error() { let cipher = cipher().await; let keyset = cipher.default_keyset(); - let greedy: Pending<'_, Vec<u8>, _> = Pending::request( - &keyset, - vec![Request::generate_data_key(d())], - |responses| { + let greedy: Pending<'_, Vec<u8>, _> = + Pending::request(&keyset, vec![Request::generate_under(d())], |responses| { let _ = responses.next_generated_key()?; // One request, two draws. responses.next_generated_key().map(|key| key.tag) - }, - ); + }); let result = greedy.zip(generating(&keyset, 1)).await; assert!( @@ -952,7 +949,7 @@ mod tests { let cipher = cipher().await; let keyset = cipher.default_keyset(); let lazy: Pending<'_, (), _> = - Pending::request(&keyset, vec![Request::generate_data_key(d())], |_| Ok(())); + Pending::request(&keyset, vec![Request::generate_under(d())], |_| Ok(())); let result = lazy.zip(generating(&keyset, 1)).await; assert!( @@ -966,10 +963,7 @@ mod tests { async fn drawing_fewer_responses_than_requested_is_a_response_shape_error() { let cipher = cipher().await; let keyset = cipher.default_keyset(); - let requests = vec![ - Request::generate_data_key(d()), - Request::generate_data_key(d()), - ]; + let requests = vec![Request::generate_under(d()), Request::generate_under(d())]; let lazy: Pending<'_, Vec<u8>, _> = Pending::request(&keyset, requests, |responses| { responses.next_generated_key().map(|key| key.tag) }); @@ -990,8 +984,8 @@ mod tests { let mut pairs = generating_pairs(&keyset, 1).await.unwrap(); let (iv, tag) = pairs.remove(0); let requests = vec![ - Request::generate_data_key(d()), - Request::retrieve_data_key(iv, tag, d(), keyset.keyset_id()), + Request::generate_under(d()), + Request::retrieve_under(iv, tag, d(), keyset.keyset_id()), ]; let lazy: Pending<'_, Vec<u8>, _> = Pending::request(&keyset, requests, |responses| { responses.next_generated_key().map(|key| key.tag) @@ -1031,7 +1025,7 @@ mod tests { keyset: &'a KeysetCipher<'_, CountingSource>, n: usize, ) -> Pending<'a, Vec<(Iv, Vec<u8>)>, CountingSource> { - let requests = std::iter::repeat_with(|| Request::generate_data_key(d())) + let requests = std::iter::repeat_with(|| Request::generate_under(d())) .take(n) .collect(); Pending::request(keyset, requests, move |responses| { @@ -1060,7 +1054,7 @@ mod tests { let requests: Vec<Request> = pairs .iter() - .map(|(iv, tag)| Request::retrieve_data_key(*iv, tag.clone(), d(), keyset.keyset_id())) + .map(|(iv, tag)| Request::retrieve_under(*iv, tag.clone(), d(), keyset.keyset_id())) .collect(); let retrieve: Pending<'_, usize, _> = Pending::request(&keyset, requests, |responses| { Ok(responses.drain_retrieved().count()) @@ -1089,8 +1083,8 @@ mod tests { let cipher = cipher().await; let keyset = cipher.default_keyset(); let requests = vec![ - Request::generate_data_key(Descriptor::of("users/email")), - Request::generate_data_key(Descriptor::of("users/name")), + Request::generate_under(Descriptor::of("users/email")), + Request::generate_under(Descriptor::of("users/name")), ]; let pairs: Vec<(Iv, Vec<u8>)> = Pending::request(&keyset, requests, |responses| { (0..2) @@ -1112,7 +1106,7 @@ mod tests { .iter() .zip(["users/name", "users/email"]) .map(|((iv, tag), descriptor)| { - Request::retrieve_data_key( + Request::retrieve_under( *iv, tag.clone(), Descriptor::of(descriptor), @@ -1142,8 +1136,8 @@ mod tests { let keyset = cipher.default_keyset(); let long = Descriptor::of("a".repeat(Descriptor::MAX_LEN + 1)); let requests = vec![ - Request::generate_data_key(Descriptor::of("users/email")), - Request::generate_data_key(long.clone()), + Request::generate_under(Descriptor::of("users/email")), + Request::generate_under(long.clone()), ]; let Err(err) = dispatch(&cipher, Some(keyset.keyset_id()), requests).await else { panic!("an over-long descriptor must be refused"); @@ -1160,12 +1154,7 @@ mod tests { let mut pairs = generating_pairs(&keyset, 1).await.unwrap(); let (iv, tag) = pairs.remove(0); - let requests = vec![Request::retrieve_data_key( - iv, - tag, - long, - keyset.keyset_id(), - )]; + let requests = vec![Request::retrieve_under(iv, tag, long, keyset.keyset_id())]; let Err(err) = dispatch(&cipher, Some(keyset.keyset_id()), requests).await else { panic!("an over-long descriptor must be refused"); }; @@ -1179,7 +1168,7 @@ mod tests { // At the limit is fine. let before = cipher.kms().generate_calls(); - let requests = vec![Request::generate_data_key(Descriptor::of( + let requests = vec![Request::generate_under(Descriptor::of( "a".repeat(Descriptor::MAX_LEN), ))]; assert!( @@ -1205,11 +1194,10 @@ mod tests { #[tokio::test] async fn a_generate_request_through_the_client_scope_has_no_keyset() { let cipher = cipher().await; - let pending: Pending<'_, Vec<u8>, _> = Pending::request( - &cipher, - vec![Request::generate_data_key(d())], - |responses| responses.next_generated_key().map(|key| key.tag), - ); + let pending: Pending<'_, Vec<u8>, _> = + Pending::request(&cipher, vec![Request::generate_under(d())], |responses| { + responses.next_generated_key().map(|key| key.tag) + }); let result = pending.await; assert!(matches!(result, Err(Error::NoKeyset)), "{result:?}"); @@ -1244,12 +1232,7 @@ mod tests { let other = Uuid::from_u128(2); let pending: Pending<'_, usize, _> = Pending::request( &keyset, - vec![Request::retrieve_data_key( - Iv::default(), - vec![1], - d(), - other, - )], + vec![Request::retrieve_under(Iv::default(), vec![1], d(), other)], |responses| Ok(responses.drain_retrieved().count()), ); let result = pending.await; @@ -1278,12 +1261,7 @@ mod tests { let other = Uuid::from_u128(2); let pending: Pending<'_, usize, _> = Pending::request( &cipher, - vec![Request::retrieve_data_key( - Iv::default(), - vec![1], - d(), - other, - )], + vec![Request::retrieve_under(Iv::default(), vec![1], d(), other)], |responses| Ok(responses.drain_retrieved().count()), ); let result = pending.scoped_to(keyset.keyset_id()).await; @@ -1364,9 +1342,9 @@ mod tests { // Interleaved: A, B, A. let requests = vec![ - Request::retrieve_data_key(a1.0, a1.1.clone(), d(), a.keyset_id()), - Request::retrieve_data_key(b1.0, b1.1.clone(), d(), b.keyset_id()), - Request::retrieve_data_key(a2.0, a2.1.clone(), d(), a.keyset_id()), + Request::retrieve_under(a1.0, a1.1.clone(), d(), a.keyset_id()), + Request::retrieve_under(b1.0, b1.1.clone(), d(), b.keyset_id()), + Request::retrieve_under(a2.0, a2.1.clone(), d(), a.keyset_id()), ]; let ivs: Vec<Iv> = Pending::request(&cipher, requests, |responses| { Ok(responses.drain_retrieved().map(|key| key.iv).collect()) diff --git a/packages/stack-encrypt/src/target/request.rs b/packages/stack-encrypt/src/target/request.rs index 326d1d973..4dd693499 100644 --- a/packages/stack-encrypt/src/target/request.rs +++ b/packages/stack-encrypt/src/target/request.rs @@ -11,6 +11,7 @@ //! the response-scoping rules that keep one fulfilment from consuming a //! sibling's key material are unit-testable on their own. +use super::context::CallerContext; use std::collections::VecDeque; use stack_kms::{DataKey, DataKeyWithTag, Iv}; @@ -41,21 +42,47 @@ pub(super) enum RequestKind { } impl Request { - /// Request one fresh data key (encrypt side), bound to `descriptor` — - /// the [`Descriptor`] of the context the leaf is sealed under. ZeroKMS - /// HMACs it into the key `tag`, so the key re-derives only under the - /// same descriptor. - pub fn generate_data_key(descriptor: Descriptor) -> Self { - Self(RequestKind::GenerateDataKey { descriptor }) + /// Request one fresh data key (encrypt side), minted under `context`. + /// + /// The [`Descriptor`] is rendered here rather than supplied, so a request + /// cannot name a context other than the one its leaf is authenticated + /// under (ADR-0004). ZeroKMS HMACs the descriptor into the key `tag`, so + /// the key re-derives only under the same one. + pub fn generate_data_key(context: impl Into<CallerContext>) -> Self { + Self::generate_under(Descriptor::of(context.into())) } /// Request re-derivation of the data key identified by `iv` + `tag` - /// (decrypt side), under `descriptor` — which must be the one the key - /// was generated with, or ZeroKMS refuses — from `keyset_id`, the - /// keyset it was minted under (a [`SealedValue`] carries it). + /// (decrypt side), under `context` — which must be the one the key was + /// generated under, or ZeroKMS refuses — from `keyset_id`, the keyset it + /// was minted under (a [`SealedValue`] carries it). /// /// [`SealedValue`]: crate::SealedValue pub fn retrieve_data_key( + iv: Iv, + tag: Vec<u8>, + context: impl Into<CallerContext>, + keyset_id: Uuid, + ) -> Self { + Self::retrieve_under(iv, tag, Descriptor::of(context.into()), keyset_id) + } + + /// [`generate_data_key`](Self::generate_data_key) over a descriptor that + /// has already been rendered. + /// + /// Crate-internal: the batching paths derive one descriptor from one + /// context and reuse it across every leaf of a tree, and re-rendering it + /// per request would cost a context encoding per leaf. The public + /// constructor takes the context because an outside caller has no other + /// way to prove the two agree. + pub(crate) fn generate_under(descriptor: Descriptor) -> Self { + Self(RequestKind::GenerateDataKey { descriptor }) + } + + /// [`retrieve_data_key`](Self::retrieve_data_key) over an already + /// rendered descriptor. Crate-internal, as + /// [`generate_under`](Self::generate_under). + pub(crate) fn retrieve_under( iv: Iv, tag: Vec<u8>, descriptor: Descriptor, @@ -220,18 +247,36 @@ mod tests { #[test] fn tally_separates_the_two_kinds() { let requests = vec![ - Request::generate_data_key(d()), - Request::retrieve_data_key(Iv::default(), vec![1], d(), ks()), - Request::generate_data_key(d()), - Request::retrieve_data_key(Iv::default(), vec![2], d(), ks()), - Request::generate_data_key(d()), + Request::generate_under(d()), + Request::retrieve_under(Iv::default(), vec![1], d(), ks()), + Request::generate_under(d()), + Request::retrieve_under(Iv::default(), vec![2], d(), ks()), + Request::generate_under(d()), ]; assert_eq!(tally(&requests), (3, 2)); } + /// The public constructors render the descriptor themselves, from the + /// context, so a request cannot name one that disagrees with the context + /// its leaf is authenticated under (ADR-0004) — and rendering through a + /// `CallerContext` preserves the context's structured identity. + #[test] + fn a_public_request_renders_its_descriptor_from_its_context() { + let context = crate::nonempty!("users/email").with(7u64); + let expected = Descriptor::of(context); + match Request::generate_data_key(context).into_kind() { + RequestKind::GenerateDataKey { descriptor } => assert_eq!(descriptor, expected), + RequestKind::RetrieveDataKey { .. } => panic!("expected a generate request"), + } + match Request::retrieve_data_key(Iv::default(), vec![1], context, ks()).into_kind() { + RequestKind::RetrieveDataKey { descriptor, .. } => assert_eq!(descriptor, expected), + RequestKind::GenerateDataKey { .. } => panic!("expected a retrieve request"), + } + } + #[test] fn a_generate_request_carries_its_descriptor() { - match Request::generate_data_key(d()).into_kind() { + match Request::generate_under(d()).into_kind() { RequestKind::GenerateDataKey { descriptor } => assert_eq!(descriptor, d()), RequestKind::RetrieveDataKey { .. } => panic!("expected a generate request"), } @@ -239,7 +284,7 @@ mod tests { #[test] fn a_retrieve_request_carries_its_iv_tag_and_descriptor() { - let request = Request::retrieve_data_key(Iv::default(), vec![7, 8, 9], d(), ks()); + let request = Request::retrieve_under(Iv::default(), vec![7, 8, 9], d(), ks()); match request.into_kind() { RequestKind::RetrieveDataKey { iv, diff --git a/packages/stack-encrypt/tests/target.rs b/packages/stack-encrypt/tests/target.rs index dd22e62e7..2b2bcc38f 100644 --- a/packages/stack-encrypt/tests/target.rs +++ b/packages/stack-encrypt/tests/target.rs @@ -13,9 +13,7 @@ use stack_encrypt::target::{ CallerContext, DecryptFrom, DecryptInto, Decryption, EncryptFrom, EncryptInto, Encryption, Pending, Request, }; -use stack_encrypt::{ - nonempty, Descriptor, EmptyError, Error, NonEmpty, StackCipher, StackCipherText, -}; +use stack_encrypt::{nonempty, EmptyError, Error, NonEmpty, StackCipher, StackCipherText}; use stack_kms::{FakeDataKeySource, IdentifiedBy, IndexKeySource}; use uuid::Uuid; @@ -840,7 +838,7 @@ async fn an_overdrawing_fulfilment_is_a_response_shape_error() { let pending: Pending<'_, (), _> = Pending::request( &cipher, - vec![Request::generate_data_key(Descriptor::of("t"))], + vec![Request::generate_data_key(nonempty!("t"))], |responses| { responses.next_generated_key()?; responses.next_generated_key()?; // one more than requested From 3d34c1ffd061258a495634bdd6421e7d0d61a0fd Mon Sep 17 00:00:00 2001 From: Dan Draper <dan@cipherstash.com> Date: Thu, 17 Sep 2026 18:43:26 -0400 Subject: [PATCH 562/686] docs(stack-encrypt): the term methods are the query path, and the stored-context default is a decision MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two of ADR-0004's remaining items, and one correction to the ADR itself. The SEM term methods now say what they are for. A term derived there is bound to the descriptor passed and to nothing else — no `Descriptor` is built, no request is made, and nothing relates it to the ciphertext of the field it indexes. That is right for a probe, which has no ciphertext to agree with. A term that will be *stored* should come from a target, where it shares one context with the ciphertext beside it by construction. The bytes are identical either way; what differs is whether anything holds the two in agreement. `ExpectedContext` says why its default compares nothing: the column migration flow (add `email_encrypted`, migrate, drop, rename) leaves every historical row storing the old identifier, so a strict check would reject all of them after a rename. The consequence is stated rather than left to be discovered — an identifier that survives renames cannot also enforce placement, so a self-consistent record moved between columns opens cleanly, and the caller passing the identifier it expects is what catches that. The key is never at risk: the descriptor is HMAC'd into the tag. The ADR also records the fate of `Encryption::all`, which it proposed: it was implemented on this branch and removed before it ever had a caller, because a declaration comes from `encryption()`, which sees no source, so such a list is fixed per type and cannot come from a plan. The homogeneous runtime-length case is already `EncryptFrom<Vec<S>> for Vec<T>`, whose length comes from the source; the heterogeneous case is `Pending::all` over mapped pendings, which is the right layer for a binding. The ADR notes what a source-driven combinator would have to look like if something ever needs one. Also records the relation to vitaminc#341: it unifies how a context is encoded, this ADR unifies how it is routed, and when it lands the `IntoPrfContext` narrowing here disappears and `CallerContext` thins to a newtype. Refs CIP-4042. Claude-Session: https://claude.ai/code/session_019b9YUGNPBrj9UUX2aZ8RbA --- ...t-threaded-through-the-declaration-tree.md | 30 +++++++++++++++++-- packages/stack-encrypt/src/sem/mod.rs | 21 ++++++++++++- packages/stack-encrypt/src/target/context.rs | 17 +++++++++++ 3 files changed, 64 insertions(+), 4 deletions(-) diff --git a/packages/stack-encrypt/docs/adr/0004-one-context-per-target-threaded-through-the-declaration-tree.md b/packages/stack-encrypt/docs/adr/0004-one-context-per-target-threaded-through-the-declaration-tree.md index 7bfe11bd4..c1fb5ca62 100644 --- a/packages/stack-encrypt/docs/adr/0004-one-context-per-target-threaded-through-the-declaration-tree.md +++ b/packages/stack-encrypt/docs/adr/0004-one-context-per-target-threaded-through-the-declaration-tree.md @@ -128,9 +128,19 @@ Targets differ per field, so there is no common output type. `map` applies to an to its own node type and collects the results — one batch, strongly typed targets right up to the point they become wire bytes. -`Encryption::all` composes a runtime-length list, mirroring the `Pending::all` -that already exists. Without it a plan-driven caller must reach past the -declaration layer into `Pending`, which is what ADR-0003 set out to prevent. +A plan-driven caller collects those mapped `Pending`s with `Pending::all`, and +that is the right layer for it: a binding bridging a dynamic wire format to +static types has to hold per-field encryptions before combining them, and +naming the carrier costs nothing. What matters is the narrower property — +`encrypt_as(&source, context)` takes one context and feeds both halves — which +holds whether or not `Pending` appears in the binding's imports. + +An `Encryption::all` was proposed here and implemented, then removed: a +declaration is produced by `encryption()`, which sees no source, so the length +of such a list is fixed per *type* and cannot come from a plan. The +homogeneous runtime-length case is already `EncryptFrom<Vec<S>> for Vec<T>`, +whose length comes from the source. A combinator that composed from the source +would serve the remaining case, and is not proposed until something needs it. ### 8. `ExpectedContext` stays permissive, deliberately @@ -151,6 +161,20 @@ AEAD and descriptor bindings are unaffected: nobody reaches a key they are not entitled to. Dropping the stored identifier entirely is the likelier end state than tightening the check. +## Relation to vitaminc#341 + +That PR collapses `Aad`, `AadPiece` and `PrfContext` into one `Context` with +`IntoContext` as the only implementable trait, so a context's AAD and PRF +encodings agree by construction rather than by a test. It is the same +principle one layer down: #341 unifies how a context is *encoded*, this ADR +unifies how it is *routed*. + +Two things here get simpler when it lands. The narrowing in decision 3 — a +ciphertext context must implement `IntoPrfContext` as well as `IntoAad` — +disappears, because one `IntoContext` impl gives both. And `CallerContext`, +which exists to hold the two encodings of one value and keep them in +agreement, thins to a newtype over `Context` or goes entirely. + ## Considered options **Convention and documentation.** State the invariant on `EncryptFrom` and pin diff --git a/packages/stack-encrypt/src/sem/mod.rs b/packages/stack-encrypt/src/sem/mod.rs index 2e9d86233..94eb5c0e5 100644 --- a/packages/stack-encrypt/src/sem/mod.rs +++ b/packages/stack-encrypt/src/sem/mod.rs @@ -1001,7 +1001,26 @@ where /// during the synchronous build and the pending carries no requests — /// awaiting it does no I/O — but that is the backend's property, not the /// API's: a backend that derives terms at ZeroKMS settles them the way it -/// settles data keys, through the same pending. The descriptor is a context +/// settles data keys, through the same pending. +/// +/// # These are the query-probe path +/// +/// A term derived here is bound to the descriptor you pass and to nothing +/// else: it builds no [`Descriptor`](crate::Descriptor), makes no ZeroKMS +/// request, and has no relation to the ciphertext of the field it indexes. +/// Nothing checks that the two agree, and the two mistakes fail differently — +/// a ciphertext under the wrong context is refused at first read, while a +/// term under the wrong context is a valid term in another domain that +/// matches nothing, forever, with no error anywhere. +/// +/// So derive a term here to *query*: a probe has no ciphertext to agree with, +/// and needs the field's context because that is what it is matching against. +/// A term that is going to be **stored** should come from a target instead, +/// where it shares one context with the ciphertext beside it by construction +/// (ADR-0004). The bytes are identical either way; what differs is whether +/// anything holds the two in agreement. +/// +/// The descriptor is a context /// as the target-directed leaves take it — a [`NonEmpty<T>`]: /// `nonempty!("users/email")`, `NonEmpty::new(column)?`, /// `nonempty!("users/email").with(row_id)` — and each method is diff --git a/packages/stack-encrypt/src/target/context.rs b/packages/stack-encrypt/src/target/context.rs index cd4888979..742c70419 100644 --- a/packages/stack-encrypt/src/target/context.rs +++ b/packages/stack-encrypt/src/target/context.rs @@ -187,6 +187,23 @@ impl DeclaredContext { /// stored value be nonempty; a `NonEmpty<T>` asks that it also equal the /// destination the caller believes it is opening. Either way the check runs /// before any key is retrieved. +/// +/// # The default accepts whatever the record stores +/// +/// That is deliberate, and the reason is the column migration flow — add +/// `email_encrypted`, migrate, drop `email`, rename `email_encrypted` to +/// `email`. Every row written before the rename still stores the old +/// identifier, so a strict check would reject all of them at the first read +/// afterwards. +/// +/// The consequence is worth stating rather than discovering: an identifier +/// that must survive renames cannot also enforce placement. A whole, +/// self-consistent record moved from one column to another opens cleanly — a +/// confused deputy, to be caught by the caller passing the identifier it +/// expects, not by this type's default. What is *not* at risk is the key: the +/// descriptor is HMAC'd into the tag, so altering a stored identifier makes +/// the retrieve fail rather than succeed, and nobody reaches a key they are +/// not entitled to. See ADR-0004. #[derive(Clone, Debug)] pub struct ExpectedContext<T>(Option<NonEmpty<T>>); impl<T> Default for ExpectedContext<T> { From bc7c7b3cad523b8b38901c89b61073a84d9850b7 Mon Sep 17 00:00:00 2001 From: Dan Draper <dan@cipherstash.com> Date: Thu, 17 Sep 2026 18:43:26 -0400 Subject: [PATCH 563/686] test(stack-encrypt): pin what ADR-0004 claims, on hand-written trees and the shapes the derive narrowed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `under`, `extend`, `accepting` and `map_with_context` had no test that did not go through the derive. Three hand-written composites now exercise each directly: a subtree given its own context runs under `()` and under a caller's context that extends it, for the ciphertext and the term alike; a subtree `extend`ed keeps the caller's context required, for the term beside it; a target declaring the `NonEmpty<u64>` its caller supplies converts it once at the root and keeps it in the output. Two derive tests cover the record shapes that stopped compiling on this branch and compile again: a literal-context field whose type is a record with declared contexts, and a bare declared-context record beside an own-context leaf. `divergent_context_in_target.rs` pins the headline consequence — a subtree under its own context and a bare operation do not zip — as the type error the ADR promises. Refs CIP-4042. --- packages/stack-encrypt/tests/derive.rs | 89 ++++++++++ packages/stack-encrypt/tests/target.rs | 162 +++++++++++++++++- .../tests/ui/divergent_context_in_target.rs | 30 ++++ .../ui/divergent_context_in_target.stderr | 15 ++ 4 files changed, 294 insertions(+), 2 deletions(-) create mode 100644 packages/stack-encrypt/tests/ui/divergent_context_in_target.rs create mode 100644 packages/stack-encrypt/tests/ui/divergent_context_in_target.stderr diff --git a/packages/stack-encrypt/tests/derive.rs b/packages/stack-encrypt/tests/derive.rs index 27e446d80..83eed7363 100644 --- a/packages/stack-encrypt/tests/derive.rs +++ b/packages/stack-encrypt/tests/derive.rs @@ -752,3 +752,92 @@ async fn a_tuple_plaintext_is_reached_and_rebuilt_by_index() { .unwrap(); assert_eq!(unit, "celsius"); } + +// --- A field handed a context converts it into what its type declares ------- + +/// A record with a context of its own wrapping a struct record that carries +/// its own: the literal gives the whole subtree its context, and the inner +/// record's own contexts are extended by it — `("user/age", "wrapped")`. +/// The derive is told nothing about the inner record's context type. +#[derive(EncryptFrom, DecryptInto)] +#[stash(plaintext = User)] +struct WrappedUser { + #[stash(context = "wrapped")] + user: EncryptedUser, +} + +#[tokio::test] +async fn a_field_with_its_own_context_may_be_a_record_with_declared_contexts() { + let cipher = stack_cipher().await; + let keyset = cipher.default_keyset(); + let generator = stack_cipher().await; + let generator = generator.default_keyset(); + + let row: WrappedUser = user().encrypt_into(&keyset).await.unwrap(); + let age_hm: EqualityTerm = 42u32 + .encrypt_into_with_context(&generator, nonempty!("user/age").with(nonempty!("wrapped"))) + .await + .unwrap(); + assert_eq!(row.user.age.hm, age_hm); + + let recovered = User::decrypt_from(row, &cipher).await.unwrap(); + assert_eq!(recovered, user()); +} + +/// A record whose one field pins a literal context: needs nothing from the +/// caller, and a caller's context extends the literal. +#[derive(EncryptFrom, DecryptInto)] +#[stash(plaintext = u32)] +struct PinnedAge { + #[stash(context = "legacy/age")] + c: StackCipherText, +} + +/// A record mixing a leaf with a context of its own and a bare record whose +/// fields declare theirs: the caller's context is required, since the bare +/// field needs it; the leaf's literal is extended by it; and the record is +/// handed it as it is and composes it with its own. +#[derive(EncryptFrom, DecryptInto)] +#[stash(plaintext = u32)] +struct AuditedAge { + #[stash(context = "audit/age", decrypt)] + audit: StackCipherText, + age: PinnedAge, +} + +#[tokio::test] +async fn a_bare_record_field_takes_the_callers_context_beside_a_leaf_with_its_own() { + let cipher = stack_cipher().await; + let keyset = cipher.default_keyset(); + + let row: AuditedAge = 42u32 + .encrypt_into_with_context(&keyset, nonempty!("tenant")) + .await + .unwrap(); + // The leaf: its own literal, extended by the caller's. + let audit: u32 = row + .audit + .decrypt_into(&cipher, nonempty!("audit/age").with(nonempty!("tenant"))) + .await + .unwrap(); + assert_eq!(audit, 42); + // The record: handed the caller's as it is, which extends its own. + let age: u32 = row + .age + .c + .decrypt_into(&cipher, nonempty!("legacy/age").with(nonempty!("tenant"))) + .await + .unwrap(); + assert_eq!(age, 42); + + // And the record as a whole opens under the caller's context. + let row: AuditedAge = 42u32 + .encrypt_into_with_context(&keyset, nonempty!("tenant")) + .await + .unwrap(); + let opened: u32 = row + .decrypt_into(&cipher, nonempty!("tenant")) + .await + .unwrap(); + assert_eq!(opened, 42); +} diff --git a/packages/stack-encrypt/tests/target.rs b/packages/stack-encrypt/tests/target.rs index 2b2bcc38f..419fce494 100644 --- a/packages/stack-encrypt/tests/target.rs +++ b/packages/stack-encrypt/tests/target.rs @@ -10,8 +10,8 @@ use std::sync::Arc; use stack_encrypt::sem::{EqualityTerm, MatchConfig, MatchOptions, MatchTerm, OreTerm}; use stack_encrypt::target::{ - CallerContext, DecryptFrom, DecryptInto, Decryption, EncryptFrom, EncryptInto, Encryption, - Pending, Request, + ciphertext, equality, CallerContext, DeclaredContext, DecryptFrom, DecryptInto, Decryption, + EncryptFrom, EncryptInto, Encryption, Pending, Request, }; use stack_encrypt::{nonempty, EmptyError, Error, NonEmpty, StackCipher, StackCipherText}; use stack_kms::{FakeDataKeySource, IdentifiedBy, IndexKeySource}; @@ -965,3 +965,161 @@ async fn cipher_directed_decrypt_rejects_a_transplanted_ciphertext() { (the fake key source ignores descriptors; ZeroKMS would refuse the retrieve)" ); } + +// --- One context per target, threaded through a hand-written tree (ADR-0004) + +/// A hand-written composite with a context of its own: the tree is given it +/// once, with `under`, and the caller's context — if any — extends it. The +/// ciphertext and the term beside it are under the same one by +/// construction; there is no second context to hand either. +struct OwnedEmail { + c: StackCipherText, + hm: EqualityTerm, +} + +impl EncryptFrom<String> for OwnedEmail { + type Context = DeclaredContext; + fn encryption<'s, K: 'static>() -> Encryption<'s, String, Self, K, Self::Context> + where + String: 's, + { + ciphertext() + .accepting() + .zip(equality()) + .under(nonempty!("users/email")) + .map(|(c, hm)| Self { c, hm }) + } +} + +#[tokio::test] +async fn under_gives_a_subtree_its_own_context_which_the_callers_extends() { + let cipher = stack_cipher().await; + let keyset = cipher.default_keyset(); + let generator = generator().await; + let generator = generator.default_keyset(); + let email = "alice@example.com".to_string(); + + // Under `()`: the own context as it is, for the ciphertext and the term + // alike. + let record: OwnedEmail = email.encrypt_into(&keyset).await.unwrap(); + let probe: EqualityTerm = email + .encrypt_into_with_context(&generator, nonempty!("users/email")) + .await + .unwrap(); + assert_eq!(record.hm, probe); + let opened: String = record + .c + .decrypt_into(&cipher, nonempty!("users/email")) + .await + .unwrap(); + assert_eq!(opened, email); + + // Under a caller's context: the own context extended by it, for both. + let record: OwnedEmail = email + .encrypt_into_with_context(&keyset, 7u64) + .await + .unwrap(); + let probe: EqualityTerm = email + .encrypt_into_with_context(&generator, nonempty!("users/email").with(7u64)) + .await + .unwrap(); + assert_eq!(record.hm, probe); + let opened: String = record + .c + .decrypt_into(&cipher, nonempty!("users/email").with(7u64)) + .await + .unwrap(); + assert_eq!(opened, email); +} + +/// A composite whose ciphertext has a context of its own and whose term is +/// derived under the caller's: `extend` gives the one subtree its own, and +/// the caller's stays required, because the other subtree needs it. +struct ShadowedEmail { + shadow: StackCipherText, + hm: EqualityTerm, +} + +impl EncryptFrom<String> for ShadowedEmail { + type Context = CallerContext; + fn encryption<'s, K: 'static>() -> Encryption<'s, String, Self, K, Self::Context> + where + String: 's, + { + ciphertext() + .extend(nonempty!("users/shadow")) + .zip(equality()) + .map(|(shadow, hm)| Self { shadow, hm }) + } +} + +#[tokio::test] +async fn extend_gives_a_subtree_its_own_context_and_still_requires_the_callers() { + let cipher = stack_cipher().await; + let keyset = cipher.default_keyset(); + let generator = generator().await; + let generator = generator.default_keyset(); + let email = "alice@example.com".to_string(); + + let record: ShadowedEmail = email + .encrypt_into_with_context(&keyset, nonempty!("users")) + .await + .unwrap(); + // The term is under the caller's context as it is ... + let probe: EqualityTerm = email + .encrypt_into_with_context(&generator, nonempty!("users")) + .await + .unwrap(); + assert_eq!(record.hm, probe); + // ... and the ciphertext under its own, extended by the caller's. + let opened: String = record + .shadow + .decrypt_into(&cipher, nonempty!("users/shadow").with(nonempty!("users"))) + .await + .unwrap(); + assert_eq!(opened, email); +} + +/// A composite declaring the context its *caller* supplies — a tenant id — +/// while its operations need a `CallerContext`: `accepting` converts once, +/// at the root, and `map_with_context` hands the output what the tree ran +/// under, so the record can keep it. +struct TenantEmail { + hm: EqualityTerm, + tenant: u64, +} + +impl EncryptFrom<String> for TenantEmail { + type Context = NonEmpty<u64>; + fn encryption<'s, K: 'static>() -> Encryption<'s, String, Self, K, Self::Context> + where + String: 's, + { + equality() + .accepting::<NonEmpty<u64>>() + .map_with_context(|hm, tenant| Self { + hm, + tenant: tenant.into_inner(), + }) + } +} + +#[tokio::test] +async fn accepting_converts_the_declared_context_once_and_map_with_context_keeps_it() { + let cipher = stack_cipher().await; + let keyset = cipher.default_keyset(); + let generator = generator().await; + let generator = generator.default_keyset(); + let email = "alice@example.com".to_string(); + + let record: TenantEmail = email + .encrypt_into_with_context(&keyset, NonEmpty::from(7u64)) + .await + .unwrap(); + assert_eq!(record.tenant, 7); + let probe: EqualityTerm = email + .encrypt_into_with_context(&generator, 7u64) + .await + .unwrap(); + assert_eq!(record.hm, probe); +} diff --git a/packages/stack-encrypt/tests/ui/divergent_context_in_target.rs b/packages/stack-encrypt/tests/ui/divergent_context_in_target.rs new file mode 100644 index 000000000..db4872c1f --- /dev/null +++ b/packages/stack-encrypt/tests/ui/divergent_context_in_target.rs @@ -0,0 +1,30 @@ +//! Within one target there is no second context to pass: `zip` hands both +//! sides the one context the tree carries, and requires both to need the +//! same type of it. A subtree given a context of its own (`under`) needs a +//! `DeclaredContext`; a bare operation needs a `CallerContext`; the two do +//! not zip. So the ciphertext and the term of one target cannot be put under +//! different contexts — the divergence ADR-0004 exists to rule out is a type +//! error, not a convention. +use stack_encrypt::sem::EqualityTerm; +use stack_encrypt::target::{ciphertext, equality, DeclaredContext, EncryptFrom, Encryption}; +use stack_encrypt::{nonempty, StackCipherText}; + +struct Divergent { + c: StackCipherText, + hm: EqualityTerm, +} + +impl EncryptFrom<String> for Divergent { + type Context = DeclaredContext; + fn encryption<'s, K: 'static>() -> Encryption<'s, String, Self, K, Self::Context> + where + String: 's, + { + ciphertext() + .under(nonempty!("users/email")) + .zip(equality()) + .map(|(c, hm)| Self { c, hm }) + } +} + +fn main() {} diff --git a/packages/stack-encrypt/tests/ui/divergent_context_in_target.stderr b/packages/stack-encrypt/tests/ui/divergent_context_in_target.stderr new file mode 100644 index 000000000..e4c6fe5d0 --- /dev/null +++ b/packages/stack-encrypt/tests/ui/divergent_context_in_target.stderr @@ -0,0 +1,15 @@ +error[E0308]: mismatched types + --> tests/ui/divergent_context_in_target.rs:25:18 + | +25 | .zip(equality()) + | --- ^^^^^^^^^^ expected `Encryption<'_, _, _, _, DeclaredContext>`, found `Encryption<'_, _, EqualityTerm, _, ...>` + | | + | arguments to this method are incorrect + | + = note: expected struct `Encryption<'_, _, _, _, DeclaredContext>` + found struct `Encryption<'_, _, EqualityTerm, _, CallerContext>` +note: method defined here + --> src/target/operations.rs + | + | pub fn zip<U: 'static>( + | ^^^ From 719030677393ba4cbbf5d54dcf4bd4960e73a9c0 Mon Sep 17 00:00:00 2001 From: Dan Draper <dan@cipherstash.com> Date: Thu, 17 Sep 2026 18:43:26 -0400 Subject: [PATCH 564/686] docs(stack-encrypt): ADR-0004 is accepted, and says what it left out MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ADR said `under` was the only way to change a context while the same change added `extend`; it now names both, and `accepting` and `map_with_context`, which change a context's type at the root without changing which one reaches a subtree. Decision 3 no longer records a narrowing: a ciphertext keeps its `AeadContext`, a record made only of ciphertexts keeps `context_type = AeadContext`, and `accepting` is the one conversion between the root and a leaf; the vitaminc#341 note says what collapses instead. It also says how the derive follows the rule. Decision 4 no longer over-claims: a request can no longer name a descriptor that disagrees with its context, but `SealedValue::from_parts` still takes raw parts, and that seam is the extension point itself. Decision 5 says plainly that nothing moved — it is the convention-and-documentation option, applied where the type system cannot reach. The consequences name the fixtures that pin both type errors. The glossary catches up: a leaf's encrypt-side context is a `CallerContext` and its decrypt-side one an `AeadContext`; an own context is given to a subtree with `under` or `extend`; and "threaded context" is a term, with "scope" reserved for a `Pending`'s as before. The `AeadContext` and module docs say the same. Refs CIP-4042. --- packages/stack-encrypt/CONTEXT.md | 25 ++++-- ...t-threaded-through-the-declaration-tree.md | 87 ++++++++++++++++--- 2 files changed, 92 insertions(+), 20 deletions(-) diff --git a/packages/stack-encrypt/CONTEXT.md b/packages/stack-encrypt/CONTEXT.md index 5c590e95e..e49b56c20 100644 --- a/packages/stack-encrypt/CONTEXT.md +++ b/packages/stack-encrypt/CONTEXT.md @@ -31,19 +31,32 @@ _Avoid_: plaintext serialization, re-encryption **Context**: The value a ciphertext is authenticated under and a term is derived under. A -leaf requires a nonempty context, validated by Vitamin C and owned in an -`AeadContext` or `CallerContext` declaration; a `nonempty!("users/email")` literal, a -`NonEmpty::new(value)?` at runtime, or a bare integer. It becomes the -ciphertext's associated data, the term's PRF context, and the ZeroKMS -descriptor of the data key. +leaf requires a nonempty context, validated by Vitamin C and owned in a +`CallerContext` (both encodings — what a term is derived under, and what a +record deriving terms threads to every field) or an `AeadContext` (the AAD +encoding alone — what a ciphertext is sealed and opened under; a record +deriving terms hands its ciphertext fields that half of its `CallerContext`); +a `nonempty!("users/email")` literal, a `NonEmpty::new(value)?` at runtime, +or a bare integer. It becomes the ciphertext's associated data, +the term's PRF context, and the ZeroKMS descriptor of the data key. _Avoid_: AAD (that is one of its encodings, not the concept), lock context **Own context**: The context a field carries itself: a `context = ".."` literal, or the one a `struct = ..` derive infers as `<struct context>/<field>`. A caller's context -*extends* it (`("users/age", id)`); it is never discarded. +*extends* it (`("users/age", id)`); it is never discarded. A subtree of a +declaration is given one with `under` (the caller's is then optional) or +`extend` (the caller's stays required). _Avoid_: default context, field prefix +**Threaded context**: +The one context a target's declaration tree hands to every operation beneath +it (ADR-0004): a type parameter of `Encryption`, so two subtrees needing +different kinds of context do not zip, and a ciphertext and the terms beside +it cannot be put under different contexts. `under` and `extend` are the only +ways to change it, and each covers a whole subtree. +_Avoid_: scope (that is a `Pending`'s), shared context, per-operation context + **Descriptor**: The context, rendered as the string ZeroKMS binds into every data key and logs per retrieval, rendered from the context's parts: plain text verbatim, diff --git a/packages/stack-encrypt/docs/adr/0004-one-context-per-target-threaded-through-the-declaration-tree.md b/packages/stack-encrypt/docs/adr/0004-one-context-per-target-threaded-through-the-declaration-tree.md index c1fb5ca62..ce5e16ec5 100644 --- a/packages/stack-encrypt/docs/adr/0004-one-context-per-target-threaded-through-the-declaration-tree.md +++ b/packages/stack-encrypt/docs/adr/0004-one-context-per-target-threaded-through-the-declaration-tree.md @@ -1,5 +1,5 @@ --- -status: proposed +status: accepted date: 2026-09-13 extends: ADR-0003 --- @@ -63,24 +63,66 @@ because there is no second context to pass. ciphertext::<S, K>().zip(equality::<S, K>()) // both under the same context ``` -### 2. `under` is the only way to change it, and it scopes a subtree +### 2. `under` and `extend` are the only ways to change it, and each covers a whole subtree A record gives its fields different contexts once each, visibly, instead of threading six arguments: ```rust -age.under("users/age").zip(email.under("users/email")) +age.under(nonempty!("users/age")).zip(email.under(nonempty!("users/email"))) ``` +`under` gives a subtree a context of its own, which a caller's context +extends if one is given — so the result can run under `()`. `extend` does the +same for a record that cannot make the caller's context optional, because +some *other* field of it is a bare leaf: the subtree's own context is +extended by the caller's, which stays required. `under` is available wherever +a `CallerContext` can become what the subtree needs, and `extend` wherever +the caller's context — a `CallerContext`, or an `AeadContext` for a record +that only seals — can; so a subtree may itself be a record whose own contexts +a caller's extends. An own context is a `NonEmpty<&'static str>`, so an empty +one is refused at compile time rather than at the first encryption. + +Two further combinators change nothing about *which* context reaches a +subtree, only its type at the root. `accepting` converts the context a record +declares its caller supplies into the one its operations need, once, at the +root — a record storing its own context declares the `NonEmpty<T>` it stores +while its operations want a `CallerContext`. `map_with_context` hands the +output the context the tree ran under, which is how such a record fills the +stored field: under threading the context arrives when the description runs, +not when it is built. + ### 3. The context is a type parameter, so the empty-context rule stays compile-time `Encryption<'s, S, T, K, Ctx>`, where `zip` requires both sides to share `Ctx`: - `ciphertext()` is `Encryption<.., CallerContext>` — it needs a real context -- `.under("users/age")` yields `Encryption<.., DeclaredContext>` — now runnable - under `()` or a caller's context +- `.under(nonempty!("users/age"))` yields `Encryption<.., DeclaredContext>` — + now runnable under `()` or a caller's context - zipping a bare leaf with own-context fields is a type error, which is correct +A ciphertext and a term need different kinds of context. Sealing uses only +the AEAD encoding, so `ciphertext()` is `Encryption<.., AeadContext>`, and a +record made only of ciphertexts may declare `context_type = AeadContext` and +accept an `IntoAad`-only type, exactly as the leaf does. Deriving a term uses +the PRF encoding as well, so a term needs a `CallerContext`. The two still +zip under one value: `accepting` lets the ciphertext take the term's +`CallerContext`, of which its own `AeadContext` is the AEAD half, and that +conversion is the only thing that happens to the context between the root +and the leaf. Nothing is narrowed — a ciphertext alone seals under exactly +what it did before this ADR. + +The derive follows the same rule rather than its own. A field with a context +of its own gives its subtree that literal (`under`, or `extend` when the +record cannot make the caller's context optional). A field with none is +handed the record's context as it is, converted into whatever its type +declares it needs — unchanged for a leaf, composed with its own contexts by a +nested record, and, for a leaf reached through a record that may run under +`()`, refused at the field. The impl names concrete context types in its +bounds rather than adding a parameter: a parameter constrained only by an +associated-type binding is E0207, and the author is told "unconstrained type +parameter" instead of their mistake. + This is the part that cost a spike to find. Threading a single *runtime* value (`Option<CallerContext>`) is simpler and wrong: `T::Context` is deliberately heterogeneous — `CallerContext` for a leaf, `DeclaredContext` for a record whose @@ -98,15 +140,29 @@ all public, all documented as the third-party SEM extension point — that lets downstream implementation mint under one context and authenticate under another with no crate code in the path. -They will take a context and derive the descriptor themselves. The extension -point stays; what goes is the ability to hand it two disagreeing values. +They take a context and render the descriptor themselves. The extension point +stays; what goes is a *request* naming a descriptor that disagrees with the +context its data key is asked for under. + +What does not go: `SealedValue::from_parts` still takes raw parts, so a +downstream SEM that seals its AEAD under one AAD and requests its key under +another remains expressible. That seam *is* the extension point, and closing +it is the product decision "seal the low-level request API" declines below. +This decision narrows the exposure to a downstream assembling a sealed value +by hand; it does not remove it. ### 5. Stored terms go through targets; standalone derivation is the query path `KeysetCipher::{equality_term, match_terms, ore_term, ope_term}` remain: a query -probe has no ciphertext to agree with, so constraining it means nothing. -Write-side term derivation moves exclusively through targets, where a term -shares its ciphertext's context by construction. +probe has no ciphertext to agree with, so constraining it means nothing. They +are documented as the query-probe path, and a term that will be *stored* is +directed to a target, where it shares its ciphertext's context by +construction. + +No code moves and nothing enforces this. It is the "convention and +documentation" option below, applied to the one place the type system cannot +reach: a probe and a stored term are the same bytes, and the term methods +cannot tell which they are producing. ### 6. Targets declare their sources; `FfiValue` is not one @@ -169,9 +225,10 @@ encodings agree by construction rather than by a test. It is the same principle one layer down: #341 unifies how a context is *encoded*, this ADR unifies how it is *routed*. -Two things here get simpler when it lands. The narrowing in decision 3 — a -ciphertext context must implement `IntoPrfContext` as well as `IntoAad` — -disappears, because one `IntoContext` impl gives both. And `CallerContext`, +Two things here get simpler when it lands. The `accepting` step between a +ciphertext and the terms beside it — an `AeadContext` taken from a +`CallerContext` — disappears, because one `IntoContext` impl gives both +encodings and the two context types collapse into one. And `CallerContext`, which exists to hold the two encodings of one value and keep them in agreement, thins to a newtype over `Context` or goes entirely. @@ -197,7 +254,9 @@ point is a product decision, and decision 4 closes the seam without it. The invariant becomes structural rather than documented, and the empty-context rule strengthens rather than weakens — both `()`-at-a-leaf and -divergence-within-a-target become type errors. +divergence-within-a-target become type errors. The UI fixtures pin both: +`leaf_without_context.rs` and `nested_leaf_without_context.rs` the first, +`divergent_context_in_target.rs` the second. It costs a type parameter through `Encryption`, every operation constructor, every combinator, `EncryptFrom::Context`, and the derive's codegen. The UI From 3ff867c65456c6fdb409fa80743038c9849b1774 Mon Sep 17 00:00:00 2001 From: Dan Draper <dan@cipherstash.com> Date: Thu, 17 Sep 2026 19:26:13 -0400 Subject: [PATCH 565/686] fix(stack-encrypt): a data-key request needs only the AEAD encoding of its context MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Request::generate_data_key` and `retrieve_data_key` took anything `Into<CallerContext>`, which asks for a PRF encoding the request never uses: a descriptor is rendered from the AEAD encoding alone. That shut the `IntoAad`-only context a `StackCipherText` seals under — the one a `context_type = AeadContext` record accepts — out of the `Pending::request` extension point, for no reason. Both take `Into<AeadContext>` now. A `CallerContext` converts as it is, so every existing caller stands, and a test pins that an AEAD-only context renders the same descriptor either way. Refs CIP-4042. --- packages/stack-encrypt/src/target/request.rs | 51 ++++++++++++++++++-- 1 file changed, 46 insertions(+), 5 deletions(-) diff --git a/packages/stack-encrypt/src/target/request.rs b/packages/stack-encrypt/src/target/request.rs index 4dd693499..9c65e5135 100644 --- a/packages/stack-encrypt/src/target/request.rs +++ b/packages/stack-encrypt/src/target/request.rs @@ -11,7 +11,7 @@ //! the response-scoping rules that keep one fulfilment from consuming a //! sibling's key material are unit-testable on their own. -use super::context::CallerContext; +use super::context::AeadContext; use std::collections::VecDeque; use stack_kms::{DataKey, DataKeyWithTag, Iv}; @@ -48,7 +48,13 @@ impl Request { /// cannot name a context other than the one its leaf is authenticated /// under (ADR-0004). ZeroKMS HMACs the descriptor into the key `tag`, so /// the key re-derives only under the same one. - pub fn generate_data_key(context: impl Into<CallerContext>) -> Self { + /// + /// A descriptor is rendered from the AEAD encoding alone, so the context + /// need only convert into an [`AeadContext`]: the `IntoAad`-only type a + /// [`StackCipherText`](crate::StackCipherText) seals under can request + /// the key it seals with, and a [`CallerContext`](super::CallerContext) + /// converts as it is. + pub fn generate_data_key(context: impl Into<AeadContext>) -> Self { Self::generate_under(Descriptor::of(context.into())) } @@ -61,7 +67,7 @@ impl Request { pub fn retrieve_data_key( iv: Iv, tag: Vec<u8>, - context: impl Into<CallerContext>, + context: impl Into<AeadContext>, keyset_id: Uuid, ) -> Self { Self::retrieve_under(iv, tag, Descriptor::of(context.into()), keyset_id) @@ -195,6 +201,7 @@ mod tests { use stack_kms::{DataKeySource, FakeDataKeySource, GenerateKeyPayload, RetrieveKeyPayload}; use super::*; + use crate::{Aad, IntoAad, MaybeEmpty, NonEmpty}; fn d() -> Descriptor { Descriptor::of("test/field") @@ -258,8 +265,8 @@ mod tests { /// The public constructors render the descriptor themselves, from the /// context, so a request cannot name one that disagrees with the context - /// its leaf is authenticated under (ADR-0004) — and rendering through a - /// `CallerContext` preserves the context's structured identity. + /// its leaf is authenticated under (ADR-0004) — and rendering through an + /// `AeadContext` preserves the context's structured identity. #[test] fn a_public_request_renders_its_descriptor_from_its_context() { let context = crate::nonempty!("users/email").with(7u64); @@ -274,6 +281,40 @@ mod tests { } } + /// AEAD only: no `IntoPrfContext`, so it can seal but not derive a term. + #[derive(Clone)] + struct Tenant(String); + impl MaybeEmpty for Tenant { + fn is_empty(&self) -> bool { + self.0.is_empty() + } + } + impl<'a> IntoAad<'a> for Tenant { + fn into_aad(self) -> Aad<'a> { + self.0.into_aad() + } + } + + /// A descriptor is rendered from the AEAD encoding alone, so the context + /// a `StackCipherText` seals under — one with `IntoAad` and nothing else + /// — can request the data key it seals with. Requiring a PRF-capable + /// context here would shut an `AeadContext`-only target out of the + /// `Pending::request` extension point for no reason. + #[test] + fn an_aead_only_context_can_request_a_data_key() { + let context = NonEmpty::new(Tenant("acme".into())).unwrap(); + match Request::generate_data_key(context.clone()).into_kind() { + RequestKind::GenerateDataKey { descriptor } => assert_eq!(descriptor.as_str(), "acme"), + RequestKind::RetrieveDataKey { .. } => panic!("expected a generate request"), + } + match Request::retrieve_data_key(Iv::default(), vec![1], context, ks()).into_kind() { + RequestKind::RetrieveDataKey { descriptor, .. } => { + assert_eq!(descriptor.as_str(), "acme") + } + RequestKind::GenerateDataKey { .. } => panic!("expected a retrieve request"), + } + } + #[test] fn a_generate_request_carries_its_descriptor() { match Request::generate_under(d()).into_kind() { From 4845078da88bba1a6fb5ad45fc5e85893f82600e Mon Sep 17 00:00:00 2001 From: Dan Draper <dan@cipherstash.com> Date: Thu, 17 Sep 2026 19:26:14 -0400 Subject: [PATCH 566/686] docs(stack-encrypt): ADR-0004 says what the type parameter does not rule out MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ADR claimed divergence within a target became a type error. It did not: `ciphertext().under(a).zip(equality().under(b))` compiles, both sides being `Encryption<.., DeclaredContext>`, and the term ends up under a different context from the ciphertext. That is the same construct, to the letter, as a record giving two fields their own contexts, and the tree cannot tell the two apart — the reason the ADR gave for rejecting a runtime check in `zip` applies to the type parameter too. Decision 1 now says what the threading does rule out: a target cannot *route* the context it is handed, because there is no argument to route, so a divergence has to be written as two literals in the declaration rather than arriving as a plumbing mistake. The consequences, the `Encryption` and `zip` docs, the glossary and the fixture's own comment say the same, and the fixture says what it pins: the empty-context rule reaching through `zip`, not divergence in general. "Reserve `under` and `extend` for the derive" is recorded as the option that would close the residual, and why it was not taken. The ADR's examples also compile now: `ciphertext()` carries an `AeadContext`, so the term beside it takes an `accepting()` before the zip, and decision 3 lists both leaf types rather than the wrong one. Decision 4 says a request takes `Into<AeadContext>`. Refs CIP-4042. --- packages/stack-encrypt/CONTEXT.md | 10 ++-- ...t-threaded-through-the-declaration-tree.md | 59 +++++++++++++++---- .../stack-encrypt/src/target/operations.rs | 26 +++++--- .../tests/ui/divergent_context_in_target.rs | 15 +++-- .../ui/divergent_context_in_target.stderr | 4 +- 5 files changed, 82 insertions(+), 32 deletions(-) diff --git a/packages/stack-encrypt/CONTEXT.md b/packages/stack-encrypt/CONTEXT.md index e49b56c20..bd7afc004 100644 --- a/packages/stack-encrypt/CONTEXT.md +++ b/packages/stack-encrypt/CONTEXT.md @@ -51,10 +51,12 @@ _Avoid_: default context, field prefix **Threaded context**: The one context a target's declaration tree hands to every operation beneath -it (ADR-0004): a type parameter of `Encryption`, so two subtrees needing -different kinds of context do not zip, and a ciphertext and the terms beside -it cannot be put under different contexts. `under` and `extend` are the only -ways to change it, and each covers a whole subtree. +it (ADR-0004): a type parameter of `Encryption`, so a target cannot route what +it is handed to one operation and something else to another, and two subtrees +needing different kinds of context do not zip. `under` and `extend` are the +only ways to change it; each covers a whole subtree and is written in the +declaration, and the tree does not tell a record's two fields from a target's +two halves. _Avoid_: scope (that is a `Pending`'s), shared context, per-operation context **Descriptor**: diff --git a/packages/stack-encrypt/docs/adr/0004-one-context-per-target-threaded-through-the-declaration-tree.md b/packages/stack-encrypt/docs/adr/0004-one-context-per-target-threaded-through-the-declaration-tree.md index ce5e16ec5..74cf00f66 100644 --- a/packages/stack-encrypt/docs/adr/0004-one-context-per-target-threaded-through-the-declaration-tree.md +++ b/packages/stack-encrypt/docs/adr/0004-one-context-per-target-threaded-through-the-declaration-tree.md @@ -56,13 +56,34 @@ have no relation. ### 1. Operations take no context; the tree carries one `Encryption::build` gains the context as a parameter, and `zip` hands the same -value to both sides. Divergence within a target stops being expressible, -because there is no second context to pass. +value to both sides. A target cannot *route* the context it is handed — there +is no argument to forget, swap, or fill from the wrong variable, because there +is no argument. ```rust -ciphertext::<S, K>().zip(equality::<S, K>()) // both under the same context +ciphertext::<S, K>().accepting().zip(equality::<S, K>()) // one value reaches both ``` +(`accepting` converts the ciphertext's context *type*, as decision 3 explains; +the value passes through.) + +What this does not rule out is a target that gives each half a context of its +own, by name: + +```rust +ciphertext().under(nonempty!("cipher")).zip(equality().under(nonempty!("term"))) +``` + +That compiles, and the term is under a different context from the ciphertext. +It is the same construct, to the letter, as a record giving each of two +*fields* its own context (decision 2), and the tree cannot tell a two-context +target from a two-field record: the reason the runtime check in `zip` is +rejected below applies to the type system too. What changes is what the +divergence costs to write. It is two literals in the declaration, each naming +the context it sets, where before it was one supplied value reaching one side +and something else reaching the other — visible at review, where a routing +mistake was not. + ### 2. `under` and `extend` are the only ways to change it, and each covers a whole subtree A record gives its fields different contexts once each, visibly, instead of @@ -96,7 +117,8 @@ not when it is built. `Encryption<'s, S, T, K, Ctx>`, where `zip` requires both sides to share `Ctx`: -- `ciphertext()` is `Encryption<.., CallerContext>` — it needs a real context +- `ciphertext()` is `Encryption<.., AeadContext>` and `equality()` is + `Encryption<.., CallerContext>` — each needs a real context - `.under(nonempty!("users/age"))` yields `Encryption<.., DeclaredContext>` — now runnable under `()` or a caller's context - zipping a bare leaf with own-context fields is a type error, which is correct @@ -140,9 +162,12 @@ all public, all documented as the third-party SEM extension point — that lets downstream implementation mint under one context and authenticate under another with no crate code in the path. -They take a context and render the descriptor themselves. The extension point -stays; what goes is a *request* naming a descriptor that disagrees with the -context its data key is asked for under. +They take a context and render the descriptor themselves. The context is +anything `Into<AeadContext>`: a descriptor is rendered from the AEAD encoding +alone, and a ciphertext needs no more than that to seal, so the `IntoAad`-only +type a `StackCipherText` seals under can request the key it seals with. The +extension point stays; what goes is a *request* naming a descriptor that +disagrees with the context its data key is asked for under. What does not go: `SealedValue::from_parts` still takes raw parts, so a downstream SEM that seals its AEAD under one AAD and requests its key under @@ -242,7 +267,14 @@ is enforcement by hope. **Detect a mismatch at runtime in `zip`.** Rejected: `zip` cannot distinguish a record legitimately combining differently-contexted *fields* from a target illegitimately combining differently-contexted *operations*. It would reject -valid code or miss the bug. +valid code or miss the bug. The type parameter has the same blind spot +(decision 1); what it adds is the two compile-time rules, not the distinction. + +**Reserve `under` and `extend` for the derive**, so a hand-written declaration +could name a context only once, at its root. Not taken: a hand-written record +is a supported shape — the derive emits what one would write — and the derive +would need a private door into the same combinators. Open, if the residual in +decision 1 turns out to matter in practice. **Thread one runtime context.** Rejected for the reason in decision 3: it costs the compile-time empty-context guarantee. @@ -252,11 +284,14 @@ point is a product decision, and decision 4 closes the seam without it. ## Consequences -The invariant becomes structural rather than documented, and the empty-context -rule strengthens rather than weakens — both `()`-at-a-leaf and -divergence-within-a-target become type errors. The UI fixtures pin both: +The routing of a context becomes structural rather than documented, and the +empty-context rule strengthens rather than weakens: `()` at a leaf is a type +error, and so is a target that discharges the context on one side of a `zip` +and leaves the other still needing it. The UI fixtures pin both: `leaf_without_context.rs` and `nested_leaf_without_context.rs` the first, -`divergent_context_in_target.rs` the second. +`divergent_context_in_target.rs` the second. Two own contexts inside one target +remain expressible, as decision 1 says, because they are two fields as far as +the tree can tell. It costs a type parameter through `Encryption`, every operation constructor, every combinator, `EncryptFrom::Context`, and the derive's codegen. The UI diff --git a/packages/stack-encrypt/src/target/operations.rs b/packages/stack-encrypt/src/target/operations.rs index ac308f107..21fac0e61 100644 --- a/packages/stack-encrypt/src/target/operations.rs +++ b/packages/stack-encrypt/src/target/operations.rs @@ -5,9 +5,10 @@ //! //! No constructor takes a context either. The context reaches every operation //! by being threaded through the tree that composes them, as a type parameter -//! of [`Encryption`] (ADR-0004): a target cannot seal a value under one -//! context and index it under another, because there is no second context to -//! hand anything. +//! of [`Encryption`] (ADR-0004): a target cannot route the context it is +//! handed to one operation and something else to another, because there is +//! no argument to route. What a subtree may do is take a context of its own, +//! by name, with [`Encryption::under`] or [`Encryption::extend`]. use super::context::{AeadContext, CallerContext, DeclaredContext, Extends}; use super::core::{encrypt_native, open_native, Term}; use super::{CipherScope, Pending}; @@ -75,10 +76,14 @@ type Open<T, K> = Box<dyn for<'a> FnOnce(&'a StackCipher<K>) -> Pending<'a, T, K /// rather than by discipline (ADR-0004): /// /// - **One context per target.** [`zip`](Self::zip) requires both sides to -/// need the same `Ctx` and hands them the same value, so a target cannot -/// seal under one context and index under another. A ciphertext beside a -/// term takes the term's context through [`accepting`](Self::accepting): -/// the [`AeadContext`] it seals under is the AEAD half of that one value. +/// need the same `Ctx` and hands them the same value, so a target has no +/// way to route what it is handed to one side and something else to the +/// other. A ciphertext beside a term takes the term's context through +/// [`accepting`](Self::accepting): the [`AeadContext`] it seals under is +/// the AEAD half of that one value. A side given a context of its own, +/// with [`under`](Self::under) or [`extend`](Self::extend), says so in the +/// declaration; that is how a record names its fields' contexts, and the +/// tree does not tell a record's fields from a target's halves. /// - **A leaf still cannot be reached without a context.** An operation needs /// a real one. [`under`](Self::under) and [`extend`](Self::extend) are the /// only ways to change the context a subtree runs under, and only `under` @@ -170,8 +175,11 @@ impl<'s, S: 's, T: 'static, K: 'static, Ctx: 's> Encryption<'s, S, T, K, Ctx> { /// description is handed, settling their key requests in one batch. /// /// Both sides must need the same `Ctx`, and both receive the same value: - /// there is no second context to pass, which is the whole of ADR-0004's - /// first decision. + /// there is no second context to pass. A side may still have taken a + /// context of its own with [`under`](Self::under) or + /// [`extend`](Self::extend) before it got here — that is how a record + /// composes fields with different contexts — and `zip` cannot tell that + /// from a target's two halves (ADR-0004, decision 1). pub fn zip<U: 'static>( self, other: Encryption<'s, S, U, K, Ctx>, diff --git a/packages/stack-encrypt/tests/ui/divergent_context_in_target.rs b/packages/stack-encrypt/tests/ui/divergent_context_in_target.rs index db4872c1f..8915bb9ff 100644 --- a/packages/stack-encrypt/tests/ui/divergent_context_in_target.rs +++ b/packages/stack-encrypt/tests/ui/divergent_context_in_target.rs @@ -1,10 +1,15 @@ //! Within one target there is no second context to pass: `zip` hands both //! sides the one context the tree carries, and requires both to need the -//! same type of it. A subtree given a context of its own (`under`) needs a -//! `DeclaredContext`; a bare operation needs a `CallerContext`; the two do -//! not zip. So the ciphertext and the term of one target cannot be put under -//! different contexts — the divergence ADR-0004 exists to rule out is a type -//! error, not a convention. +//! same type of it. A subtree given a context of its own with `under` may +//! run under `()`, so it needs a `DeclaredContext`; a bare operation needs a +//! real `CallerContext`; the two do not zip. So a target cannot make the +//! caller's context optional for its ciphertext while its term still needs +//! one — the empty-context rule, reaching through `zip`. +//! +//! That is the divergence the type system refuses. The one it cannot refuse +//! is two `under`s in one target, each naming its own context: that compiles, +//! being the same construct as a record naming the contexts of two fields +//! (ADR-0004, decision 1). use stack_encrypt::sem::EqualityTerm; use stack_encrypt::target::{ciphertext, equality, DeclaredContext, EncryptFrom, Encryption}; use stack_encrypt::{nonempty, StackCipherText}; diff --git a/packages/stack-encrypt/tests/ui/divergent_context_in_target.stderr b/packages/stack-encrypt/tests/ui/divergent_context_in_target.stderr index e4c6fe5d0..fc60d6a2d 100644 --- a/packages/stack-encrypt/tests/ui/divergent_context_in_target.stderr +++ b/packages/stack-encrypt/tests/ui/divergent_context_in_target.stderr @@ -1,7 +1,7 @@ error[E0308]: mismatched types - --> tests/ui/divergent_context_in_target.rs:25:18 + --> tests/ui/divergent_context_in_target.rs:30:18 | -25 | .zip(equality()) +30 | .zip(equality()) | --- ^^^^^^^^^^ expected `Encryption<'_, _, _, _, DeclaredContext>`, found `Encryption<'_, _, EqualityTerm, _, ...>` | | | arguments to this method are incorrect From d388761e562d8dabd4f81d5d935417536caed043 Mon Sep 17 00:00:00 2001 From: Dan Draper <dan@cipherstash.com> Date: Thu, 17 Sep 2026 19:41:08 -0400 Subject: [PATCH 567/686] docs(stack-encrypt): a request binds its descriptor to its own context, and a target makes divergence explicit Two doc comments claimed more than ADR-0004 does. `Request::generate_data_key` said a request "cannot name a context other than the one its leaf is authenticated under"; a request never sees a leaf, and decision 4 says plainly that `SealedValue::from_parts` still takes raw parts. What the constructor guarantees is that a request's descriptor cannot disagree with the context it was asked for under, and the docs, the `generate_under` note and the test's comment now say that. The term-method docs said a stored term from a target "shares one context with the ciphertext beside it by construction". Decision 1 says a hand-written target may still put a subtree under a context of its own; it has to write that down. The recommendation stands, and the passage now says what the target path gives: one handed context reaching both halves, with any divergence in the declaration rather than plumbed. Refs CIP-4042. --- packages/stack-encrypt/src/sem/mod.rs | 10 +++++++--- packages/stack-encrypt/src/target/request.rs | 18 +++++++++++------- 2 files changed, 18 insertions(+), 10 deletions(-) diff --git a/packages/stack-encrypt/src/sem/mod.rs b/packages/stack-encrypt/src/sem/mod.rs index 94eb5c0e5..889bcba52 100644 --- a/packages/stack-encrypt/src/sem/mod.rs +++ b/packages/stack-encrypt/src/sem/mod.rs @@ -1016,9 +1016,13 @@ where /// So derive a term here to *query*: a probe has no ciphertext to agree with, /// and needs the field's context because that is what it is matching against. /// A term that is going to be **stored** should come from a target instead, -/// where it shares one context with the ciphertext beside it by construction -/// (ADR-0004). The bytes are identical either way; what differs is whether -/// anything holds the two in agreement. +/// where the one context the target is handed reaches the ciphertext and the +/// term beside it alike, and giving either a context of its own is written +/// in the declaration rather than plumbed (ADR-0004). A derived record does +/// this per field and cannot get it wrong; a hand-written target can still +/// put a subtree under its own context, but has to say so. The bytes are +/// identical either way; what differs is whether anything holds the two in +/// agreement. /// /// The descriptor is a context /// as the target-directed leaves take it — a [`NonEmpty<T>`]: diff --git a/packages/stack-encrypt/src/target/request.rs b/packages/stack-encrypt/src/target/request.rs index 9c65e5135..8a5cbc5a6 100644 --- a/packages/stack-encrypt/src/target/request.rs +++ b/packages/stack-encrypt/src/target/request.rs @@ -45,9 +45,13 @@ impl Request { /// Request one fresh data key (encrypt side), minted under `context`. /// /// The [`Descriptor`] is rendered here rather than supplied, so a request - /// cannot name a context other than the one its leaf is authenticated - /// under (ADR-0004). ZeroKMS HMACs the descriptor into the key `tag`, so - /// the key re-derives only under the same one. + /// cannot carry a descriptor that disagrees with its own `context` + /// (ADR-0004, decision 4). ZeroKMS HMACs the descriptor into the key + /// `tag`, so the key re-derives only under the same one. What this does + /// not relate is the request to the leaf sealed with the key: a + /// [`SealedValue`](crate::SealedValue) is still assembled from raw parts + /// at the extension point, and its AEAD context is the caller's to keep + /// in agreement with this one. /// /// A descriptor is rendered from the AEAD encoding alone, so the context /// need only convert into an [`AeadContext`]: the `IntoAad`-only type a @@ -79,8 +83,8 @@ impl Request { /// Crate-internal: the batching paths derive one descriptor from one /// context and reuse it across every leaf of a tree, and re-rendering it /// per request would cost a context encoding per leaf. The public - /// constructor takes the context because an outside caller has no other - /// way to prove the two agree. + /// constructor takes the context so that a request's descriptor and the + /// context it was asked for under cannot disagree. pub(crate) fn generate_under(descriptor: Descriptor) -> Self { Self(RequestKind::GenerateDataKey { descriptor }) } @@ -264,8 +268,8 @@ mod tests { } /// The public constructors render the descriptor themselves, from the - /// context, so a request cannot name one that disagrees with the context - /// its leaf is authenticated under (ADR-0004) — and rendering through an + /// context, so a request cannot carry one that disagrees with its own + /// context (ADR-0004, decision 4) — and rendering through an /// `AeadContext` preserves the context's structured identity. #[test] fn a_public_request_renders_its_descriptor_from_its_context() { From 70c64a83a3582f9206cce7270d30bb0c9a738a71 Mon Sep 17 00:00:00 2001 From: Dan Draper <dan@cipherstash.com> Date: Mon, 14 Sep 2026 08:56:38 -0400 Subject: [PATCH 568/686] =?UTF-8?q?feat(stack-encrypt):=20a=20dynamic=20fa?= =?UTF-8?q?=C3=A7ade=20for=20contexts=20and=20index=20terms?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Everything else in this crate is typed: a target names its source type and reaches its operations through bounds — `Encrypt` for a ciphertext, `PrfValue` for an equality term, `AsRef<str>` for a match term. That is what makes a term a cross-language contract: `equality_term(34u32)` derives the same bytes wherever it is called from. An FFI binding cannot reach those bounds. Its field types arrive as wire data, so there is no Rust type to name, and no dynamic value can satisfy `AsRef<str>`, which is total. Something has to look at the value and pick the typed operation — and until now that something was written in the Go binding's guest, where a second binding could not reach it and a third would write it again. Move it into `stack_encrypt::dynamic`, behind a `dynamic` feature, over vitaminc's `FfiValue`: * `context` / `borrowed` — an `FfiValue` read as an encryption context, moved wholesale from the guest with its eleven tests, which pin the law this rests on (a context's AAD and PRF derivations each equal the same derivation of its parts view, so a plan's `["users/age", 7u64]` and a derive's `nonempty!("users/age").with(7u64)` agree byte for byte). * `Scalar` / `TermKind` / `term` — one index term for a value, dispatched on its variant. `TermKind::supports` is the one table of which scalar takes which term, consulted before any cipher work. The guest keeps what is actually its own — the codec, the ABI, the status mapping — and `Output` now spells its four term keys as `TermKind::key`, so the plan grammar and the library cannot drift apart on them. Those key strings are wire format, not just API: they are map keys in stored ciphertext. Fixing them here is what makes two bindings agree on them by construction. Their long-term home is beside vitaminc's frozen tag table, which already owns this class of constant. Refs CIP-4040 Claude-Session: https://claude.ai/code/session_019b9YUGNPBrj9UUX2aZ8RbA --- .../golang/stackencrypt/guest/Cargo.lock | 1 + .../golang/stackencrypt/guest/Cargo.toml | 4 +- .../golang/stackencrypt/guest/src/lib.rs | 1 - .../golang/stackencrypt/guest/src/ops.rs | 299 +++++------------- .../golang/stackencrypt/guest/src/status.rs | 13 + packages/stack-encrypt/Cargo.toml | 6 + .../stack-encrypt/src/dynamic}/context.rs | 133 ++++---- packages/stack-encrypt/src/dynamic/mod.rs | 76 +++++ packages/stack-encrypt/src/dynamic/term.rs | 254 +++++++++++++++ packages/stack-encrypt/src/lib.rs | 2 + 10 files changed, 488 insertions(+), 301 deletions(-) rename {languages/golang/stackencrypt/guest/src => packages/stack-encrypt/src/dynamic}/context.rs (72%) create mode 100644 packages/stack-encrypt/src/dynamic/mod.rs create mode 100644 packages/stack-encrypt/src/dynamic/term.rs diff --git a/languages/golang/stackencrypt/guest/Cargo.lock b/languages/golang/stackencrypt/guest/Cargo.lock index 9fd104127..7a347792c 100644 --- a/languages/golang/stackencrypt/guest/Cargo.lock +++ b/languages/golang/stackencrypt/guest/Cargo.lock @@ -1960,6 +1960,7 @@ dependencies = [ "thiserror 1.0.69", "uuid", "vitaminc-aead", + "vitaminc-aead-value", "vitaminc-encrypt", "vitaminc-hmac", "vitaminc-prf", diff --git a/languages/golang/stackencrypt/guest/Cargo.toml b/languages/golang/stackencrypt/guest/Cargo.toml index 3d5b6dc0e..f182a5b08 100644 --- a/languages/golang/stackencrypt/guest/Cargo.toml +++ b/languages/golang/stackencrypt/guest/Cargo.toml @@ -26,7 +26,9 @@ crate-type = ["cdylib", "rlib"] # The stack crates with default features off: no reqwest, no native TLS — # HTTP comes from the host (see `wasm:wasi-check` in the suite root). stack-auth = { path = "../../../../packages/stack-auth", default-features = false } -stack-encrypt = { path = "../../../../packages/stack-encrypt", default-features = false } +# `dynamic`: the runtime value model this guest speaks — contexts, index +# terms and record plans over `FfiValue`, shared with every other binding. +stack-encrypt = { path = "../../../../packages/stack-encrypt", default-features = false, features = ["dynamic"] } stack-kms = { path = "../../../../packages/stack-kms", default-features = false } zerokms-protocol = { path = "../../../../packages/zerokms-protocol" } diff --git a/languages/golang/stackencrypt/guest/src/lib.rs b/languages/golang/stackencrypt/guest/src/lib.rs index d8e1a16c5..95bbfbd23 100644 --- a/languages/golang/stackencrypt/guest/src/lib.rs +++ b/languages/golang/stackencrypt/guest/src/lib.rs @@ -69,7 +69,6 @@ //! out of a tree is exactly what a database column holds. pub mod config; -pub mod context; pub mod headers; pub mod ops; pub mod options; diff --git a/languages/golang/stackencrypt/guest/src/ops.rs b/languages/golang/stackencrypt/guest/src/ops.rs index d9078791f..61f4b899d 100644 --- a/languages/golang/stackencrypt/guest/src/ops.rs +++ b/languages/golang/stackencrypt/guest/src/ops.rs @@ -39,20 +39,18 @@ //! is exact up to 500 leaves and "one call per 500" past it. See //! `parse_plan` for the plan encoding. -use stack_encrypt::sem::{CllwOpeEncrypt, CllwOreEncrypt, DefaultMatch}; +use stack_encrypt::dynamic::{self, Scalar, TermKind}; use stack_encrypt::target::Pending; use stack_encrypt::{ - AadPiece, BoxedPassthrough, CipherText, Element, Encrypt, IntoPrfContext, KeysetCipher, - NonEmpty, SealedValue, StackCipherText, + AadPiece, BoxedPassthrough, CipherText, Element, Encrypt, KeysetCipher, NonEmpty, SealedValue, + StackCipherText, }; use stack_kms::DataKeySource; use vitaminc_aead_value::{transport as codec, FfiValue}; use vitaminc_protected::{Controlled, Protected}; -use zeroize::Zeroizing; -use crate::context::{borrowed, parse_context}; use crate::options::Opener; -use crate::status::{status_for_error, STATUS_ENCODING, STATUS_INTERNAL}; +use crate::status::{status_for_dynamic, status_for_error, STATUS_ENCODING, STATUS_INTERNAL}; /// Term kinds for `se_term`, part of the guest/host contract (the Go host /// mirrors these values). @@ -184,193 +182,31 @@ where { // The same proof every stack-encrypt leaf demands: an empty context is // `STATUS_ENCODING` here, before any derivation. - let context = parse_context(decode_value(context)?)?; - let (scalar, output) = parse_term(decode_value(value)?, kind)?; - term_bytes(cipher, scalar, context, output).await -} - -/// The static half of a term: the value is a scalar, the kind is one of -/// the table, and the scheme defines the pair ([`term_supported`]). Shared -/// by [`term`] and [`validate::term`] so the ABI refuses exactly what the -/// operation would, before any keyset is resolved. -fn parse_term(value: FfiValue, kind: u32) -> Result<(Scalar, Output), u32> { - let output = match kind { - TERM_EQUALITY => Output::Equality, - TERM_MATCH => Output::Match, - TERM_ORE => Output::Ore, - TERM_OPE => Output::Ope, + let context = dynamic::context(decode_value(context)?).map_err(|e| status_for_dynamic(&e))?; + let (scalar, kind) = parse_term(decode_value(value)?, kind)?; + dynamic::term(cipher, scalar, kind, context) + .await + .map_err(|e| status_for_dynamic(&e)) +} + +/// The static half of a term: the kind is one of the ABI's table, the value +/// is a scalar, and the scheme defines the pair +/// ([`TermKind::supports`]). Shared by [`term`] and [`validate::term`] so +/// the ABI refuses exactly what the operation would, before any keyset is +/// resolved. +fn parse_term(value: FfiValue, kind: u32) -> Result<(Scalar, TermKind), u32> { + let kind = match kind { + TERM_EQUALITY => TermKind::Equality, + TERM_MATCH => TermKind::Match, + TERM_ORE => TermKind::Ore, + TERM_OPE => TermKind::Ope, _ => return Err(STATUS_ENCODING), }; - let scalar = scalar_of(&value)?; - if !term_supported(&scalar, output) { + let scalar = Scalar::of(&value, kind).map_err(|e| status_for_dynamic(&e))?; + if !kind.supports(&scalar) { return Err(STATUS_ENCODING); } - Ok((scalar, output)) -} - -/// Which scalar/output pairs the scheme defines: no PRF encoding exists -/// for floats (equality on IEEE-754 values is a modelling error) or -/// booleans, match is text-only, ORE and OPE take every scalar. The one -/// table, consulted before any cipher work; [`term_bytes`]'s arms mirror -/// it and are unreachable for a refused pair. -fn term_supported(scalar: &Scalar, output: Output) -> bool { - match output { - Output::Ciphertext => false, - Output::Equality => !matches!(scalar, Scalar::Bool(_) | Scalar::F32(_) | Scalar::F64(_)), - Output::Match => matches!(scalar, Scalar::Text(_)), - Output::Ore | Output::Ope => true, - } -} - -/// A term-able scalar lifted (by copy) out of an [`FfiValue`] leaf, so the -/// value itself stays movable into the ciphertext path. The owned text/bytes -/// copies wipe on drop; the PRF/CLLW layers move them into `Protected` -/// internally. -#[derive(Clone)] -enum Scalar { - Bool(bool), - I32(i32), - I64(i64), - U32(u32), - U64(u64), - F32(f32), - F64(f64), - Text(Zeroizing<String>), - Bytes(Zeroizing<Vec<u8>>), -} - -fn scalar_of(value: &FfiValue) -> Result<Scalar, u32> { - Ok(match value { - FfiValue::Bool(v) => Scalar::Bool(*v), - FfiValue::Int32(v) => Scalar::I32(*v), - FfiValue::Int64(v) => Scalar::I64(*v), - FfiValue::UInt32(v) => Scalar::U32(*v), - FfiValue::UInt64(v) => Scalar::U64(*v), - FfiValue::Float32(v) => Scalar::F32(*v), - FfiValue::Float64(v) => Scalar::F64(*v), - FfiValue::String(s) => Scalar::Text(Zeroizing::new(text_of(s)?.to_string())), - FfiValue::Bytes(b) => Scalar::Bytes(Zeroizing::new(b.risky_ref().to_vec())), - // Containers, nulls and passthroughs have no term semantics. - _ => return Err(STATUS_ENCODING), - }) -} - -/// Derive one output's term bytes for a scalar. -/// -/// The type dispatch decides the term's PRF/CLLW input encoding, which is -/// part of the cross-language contract: an equality term for `UInt32(34)` -/// must equal the term the Rust side derives for `34u32`. Unsupported -/// combinations (floats or booleans under equality, anything non-text under -/// match) are [`STATUS_ENCODING`] — the scheme does not define them; -/// [`term_supported`] is the table, checked before this is reached. -async fn term_bytes<'c, K, D>( - cipher: &KeysetCipher<'_, K>, - scalar: Scalar, - context: NonEmpty<D>, - output: Output, -) -> Result<Vec<u8>, u32> -where - K: DataKeySource + Sync, - D: IntoPrfContext<'c>, -{ - let term_err = |e| status_for_error(&e); - match output { - Output::Ciphertext => Err(STATUS_ENCODING), - Output::Equality => { - let term = match scalar { - Scalar::I32(v) => cipher.equality_term(v, context).await, - Scalar::I64(v) => cipher.equality_term(v, context).await, - Scalar::U32(v) => cipher.equality_term(v, context).await, - Scalar::U64(v) => cipher.equality_term(v, context).await, - Scalar::Text(t) => cipher.equality_term(String::clone(&t), context).await, - Scalar::Bytes(b) => { - cipher - .equality_term(Protected::new(Vec::clone(&b)), context) - .await - } - // No PRF encoding is defined for floats (equality on IEEE-754 - // values is a modelling error) or booleans. - Scalar::Bool(_) | Scalar::F32(_) | Scalar::F64(_) => return Err(STATUS_ENCODING), - } - .map_err(term_err)?; - Ok(term.into_bytes().to_vec()) - } - Output::Match => match scalar { - Scalar::Text(t) => cipher - .match_terms::<DefaultMatch>(&t, context) - .await - .map(|t| t.to_bytes()) - .map_err(term_err), - _ => Err(STATUS_ENCODING), - }, - // The text and bytes arms hand the encryptor the `Zeroizing` operand - // itself, not a bare clone of its contents: the CLLW encryptors take - // their value by `'static` ownership (the visitor carries it), so a - // cloned-out `String`/`Vec<u8>` would be freed with the plaintext - // still in it — in linear memory the host can read. Keeping the - // wrapper costs nothing and saves the copy as well. - Output::Ore => match scalar { - Scalar::Bool(v) => ore(cipher, v, context).await, - Scalar::I32(v) => ore(cipher, v, context).await, - Scalar::I64(v) => ore(cipher, v, context).await, - Scalar::U32(v) => ore(cipher, v, context).await, - Scalar::U64(v) => ore(cipher, v, context).await, - Scalar::F32(v) => ore(cipher, v, context).await, - Scalar::F64(v) => ore(cipher, v, context).await, - Scalar::Text(t) => ore(cipher, t, context).await, - Scalar::Bytes(b) => ore(cipher, b, context).await, - }, - Output::Ope => match scalar { - Scalar::Bool(v) => ope(cipher, v, context).await, - Scalar::I32(v) => ope(cipher, v, context).await, - Scalar::I64(v) => ope(cipher, v, context).await, - Scalar::U32(v) => ope(cipher, v, context).await, - Scalar::U64(v) => ope(cipher, v, context).await, - Scalar::F32(v) => ope(cipher, v, context).await, - Scalar::F64(v) => ope(cipher, v, context).await, - Scalar::Text(t) => ope(cipher, t, context).await, - Scalar::Bytes(b) => ope(cipher, b, context).await, - }, - } -} - -/// The `AsRef<[u8]>` on the output is what turns the typed CLLW ciphertext -/// into the frozen raw-bytes encoding. -async fn ore<'c, K, T, D>( - cipher: &KeysetCipher<'_, K>, - value: T, - context: NonEmpty<D>, -) -> Result<Vec<u8>, u32> -where - K: DataKeySource + Sync, - T: CllwOreEncrypt + Send + 'static, - T::Output: AsRef<[u8]> + Send + 'static, - D: IntoPrfContext<'c>, -{ - cipher - .ore_term(value, context) - .await - .map(|t| t.as_ref().to_vec()) - .map_err(|e| status_for_error(&e)) -} - -/// See [`ore`]. -async fn ope<'c, K, T, D>( - cipher: &KeysetCipher<'_, K>, - value: T, - context: NonEmpty<D>, -) -> Result<Vec<u8>, u32> -where - K: DataKeySource + Sync, - T: CllwOpeEncrypt + Send + 'static, - T::Output: AsRef<[u8]> + Send + 'static, - D: IntoPrfContext<'c>, -{ - cipher - .ope_term(value, context) - .await - .map(|t| t.as_ref().to_vec()) - .map_err(|e| status_for_error(&e)) + Ok((scalar, kind)) } // ============================================================================= @@ -378,29 +214,25 @@ where // ============================================================================= /// What a plan field asks for. The strings are the plan encoding *and* the -/// keys of the per-field output map in the result. +/// keys of the per-field output map in the result; the term spellings are +/// [`TermKind::key`], so the plan grammar and the library agree on them by +/// construction rather than by two tables. #[derive(Clone, Copy, PartialEq, Eq, Debug)] enum Output { /// `"c"` — the field's [`StackCipherText`]. Ciphertext, - /// `"eq"` — equality term (raw 32 PRF bytes). - Equality, - /// `"match"` — match term (LE `u16` positions), default tokenizer config. - Match, - /// `"ore"` — ORE term (raw CLLW bytes). - Ore, - /// `"ope"` — OPE term (raw CLLW bytes). - Ope, + /// An index term: `"eq"`, `"match"`, `"ore"` or `"ope"`. + Term(TermKind), } impl Output { fn parse(s: &str) -> Option<Self> { Some(match s { "c" => Output::Ciphertext, - "eq" => Output::Equality, - "match" => Output::Match, - "ore" => Output::Ore, - "ope" => Output::Ope, + "eq" => Output::Term(TermKind::Equality), + "match" => Output::Term(TermKind::Match), + "ore" => Output::Term(TermKind::Ore), + "ope" => Output::Term(TermKind::Ope), _ => return None, }) } @@ -408,10 +240,7 @@ impl Output { fn key(self) -> &'static str { match self { Output::Ciphertext => "c", - Output::Equality => "eq", - Output::Match => "match", - Output::Ore => "ore", - Output::Ope => "ope", + Output::Term(kind) => kind.key(), } } } @@ -475,7 +304,9 @@ fn parse_plan(value: FfiValue) -> Result<Vec<FieldPlan>, u32> { let mut outputs: Option<Vec<Output>> = None; for (key, value) in spec { match key.as_str() { - "context" => context = Some(parse_context(value)?), + "context" => { + context = Some(dynamic::context(value).map_err(|e| status_for_dynamic(&e))?) + } "outputs" => { let FfiValue::Array(items) = value else { return Err(STATUS_ENCODING); @@ -683,15 +514,19 @@ fn source_rows(source: FfiValue, plan: &[FieldPlan]) -> Result<(Vec<Vec<FfiValue } /// A source value against its plan field: every term output needs a -/// scalar the scheme defines the term for ([`term_supported`]), and a +/// scalar the scheme defines the term for ([`TermKind::supports`]), and a /// ciphertext output refuses a passthrough anywhere in the value /// ([`reject_passthrough_value`]). fn check_field(value: &FfiValue, field: &FieldPlan) -> Result<(), u32> { for output in &field.outputs { - if *output == Output::Ciphertext { - reject_passthrough_value(value)?; - } else if !term_supported(&scalar_of(value)?, *output) { - return Err(STATUS_ENCODING); + match output { + Output::Ciphertext => reject_passthrough_value(value)?, + Output::Term(kind) => { + let scalar = Scalar::of(value, *kind).map_err(|e| status_for_dynamic(&e))?; + if !kind.supports(&scalar) { + return Err(STATUS_ENCODING); + } + } } } Ok(()) @@ -773,26 +608,34 @@ where // A borrowed view of the plan's context, once per field: the proof // was made at parse time, so re-taking it over the same tree cannot // fail, and the view clones cheaply for each output below. - let context = NonEmpty::new(borrowed(field.context.get())).map_err(|_| STATUS_INTERNAL)?; + let context = + NonEmpty::new(dynamic::borrowed(field.context.get())).map_err(|_| STATUS_INTERNAL)?; // Terms first — they lift a copy of the scalar; the value itself is - // consumed by the ciphertext path below. - let wants_terms = field.outputs.iter().any(|o| *o != Output::Ciphertext); - let scalar = if wants_terms { - Some(scalar_of(&value)?) - } else { - None - }; + // consumed by the ciphertext path below. One lift serves every term + // output: the kind only names which error a non-scalar reports. + let scalar = field + .outputs + .iter() + .find_map(|o| match o { + Output::Term(kind) => Some(*kind), + Output::Ciphertext => None, + }) + .map(|kind| Scalar::of(&value, kind)) + .transpose() + .map_err(|e| status_for_dynamic(&e))?; let mut outputs: Vec<(&'static str, Option<Vec<u8>>)> = Vec::with_capacity(field.outputs.len()); for output in &field.outputs { - if *output == Output::Ciphertext { + let Output::Term(kind) = output else { outputs.push((output.key(), None)); continue; - } + }; let scalar = scalar.clone().ok_or(STATUS_INTERNAL)?; - let term = term_bytes(cipher, scalar, context.clone(), *output).await?; + let term = dynamic::term(cipher, scalar, *kind, context.clone()) + .await + .map_err(|e| status_for_dynamic(&e))?; outputs.push((output.key(), Some(term))); } @@ -829,7 +672,9 @@ where let contexts = plan .iter() .filter(|field| field.outputs.contains(&Output::Ciphertext)) - .map(|field| NonEmpty::new(borrowed(field.context.get())).map_err(|_| STATUS_INTERNAL)) + .map(|field| { + NonEmpty::new(dynamic::borrowed(field.context.get())).map_err(|_| STATUS_INTERNAL) + }) .collect::<Result<Vec<_>, u32>>()?; // Per row, per ciphertext-bearing plan field (in plan order, as @@ -912,9 +757,11 @@ pub mod validate { /// A term's inputs, as [`term`] takes them: the context decodes and is /// non-empty, the kind is one of [`TERM_EQUALITY`] .. [`TERM_OPE`], and /// the value is a scalar the scheme defines that term for - /// (`term_supported`). + /// ([`TermKind::supports`]). pub fn term(value: &[u8], context: &[u8], kind: u32) -> Result<(), u32> { - parse_context(decode_value(context)?).map(drop)?; + dynamic::context(decode_value(context)?) + .map(drop) + .map_err(|e| status_for_dynamic(&e))?; parse_term(decode_value(value)?, kind).map(drop) } diff --git a/languages/golang/stackencrypt/guest/src/status.rs b/languages/golang/stackencrypt/guest/src/status.rs index cd991b03d..26be9a5e4 100644 --- a/languages/golang/stackencrypt/guest/src/status.rs +++ b/languages/golang/stackencrypt/guest/src/status.rs @@ -109,6 +109,19 @@ pub fn status_for_error(error: &stack_encrypt::Error) -> u32 { } } +/// A dynamic-path error as a status code. +/// +/// The split the library draws is the one the ABI needs: every variant but +/// `Cipher` is a statement about the caller's input, decided before any key +/// is minted or retrieved, so it is [`STATUS_ENCODING`]. `Cipher` defers to +/// [`status_for_error`]. +pub fn status_for_dynamic(error: &stack_encrypt::dynamic::Error) -> u32 { + match error { + stack_encrypt::dynamic::Error::Cipher(e) => status_for_error(e), + _ => STATUS_ENCODING, + } +} + // These matches are deliberately exhaustive — no `_` arms. None of the // stack-kms error enums is `#[non_exhaustive]`, so exhaustiveness is free // compiler coverage: `GenerateKeyError` already grew `Unauthorized` / diff --git a/packages/stack-encrypt/Cargo.toml b/packages/stack-encrypt/Cargo.toml index c61bb3387..338e6df91 100644 --- a/packages/stack-encrypt/Cargo.toml +++ b/packages/stack-encrypt/Cargo.toml @@ -30,6 +30,8 @@ stack-encrypt-derive = { path = "../stack-encrypt-derive" } # (#318), all of which this crate relies on. All five must share one # version or the aead crate is duplicated. vitaminc-aead = { workspace = true } +# `dynamic`: the runtime value model every language binding funnels through. +vitaminc-aead-value = { workspace = true, optional = true } vitaminc-encrypt = { workspace = true } vitaminc-hmac = { workspace = true } vitaminc-prf = { workspace = true } @@ -51,6 +53,10 @@ default = ["http"] # (`StackCipher::builder().kms(..)`) and no HTTP client or TLS stack is in # the dependency graph — the shape the WASI/wazero guest builds against. http = ["dep:stack-auth", "stack-auth/http", "stack-kms/http"] +# `stack_encrypt::dynamic`: encrypt and decrypt values whose types are known +# only at runtime, as an FFI binding's are. Off by default — it is only of +# use to a binding author, and it pulls `vitaminc-aead-value` in. +dynamic = ["dep:vitaminc-aead-value"] [dev-dependencies] serde_json = { workspace = true } diff --git a/languages/golang/stackencrypt/guest/src/context.rs b/packages/stack-encrypt/src/dynamic/context.rs similarity index 72% rename from languages/golang/stackencrypt/guest/src/context.rs rename to packages/stack-encrypt/src/dynamic/context.rs index de842b563..d72be448c 100644 --- a/languages/golang/stackencrypt/guest/src/context.rs +++ b/packages/stack-encrypt/src/dynamic/context.rs @@ -1,27 +1,23 @@ -//! Structured contexts for the record and term paths. +//! An [`FfiValue`] read as an encryption context. //! -//! A plan field's context, and a term probe's, arrives as an [`FfiValue`] -//! and becomes an [`AadPiece`] tree: vitaminc's runtime form of a context, -//! and the *identity* of one. vitaminc's law (pinned there by quickcheck -//! over every built-in context type) is that a context's two derivations -//! each equal the same derivation of its parts view: +//! A context arrives from a binding as a value and becomes an [`AadPiece`] +//! tree: vitaminc's runtime form of a context, and the *identity* of one. +//! vitaminc's law (pinned there by quickcheck over every built-in context +//! type) is that a context's two derivations each equal the same derivation +//! of its parts view: //! //! ```text //! x.into_aad() == x.into_aad_piece().into_aad() //! x.into_prf_context() == x.into_aad_piece().into_prf_context() //! ``` //! -//! So a Rust `#[derive(EncryptFrom)]` row sealed with +//! So a `#[derive(EncryptFrom)]` row sealed with //! `encrypt_into_with_context(row, 7u64)`, which binds each field under -//! `("users/age", 7u64)` — a `NonEmpty<(&str, u64)>` — and a plan that +//! `("users/age", 7u64)` — a `NonEmpty<(&str, u64)>` — and a binding that //! spells the same context as `["users/age", 7u64]` agree byte for byte on //! the AAD (the ciphertext binding and the ZeroKMS descriptor rendered from //! its parts) *and* on the PRF context (the index terms' domain separation). -//! Nothing is re-derived in this crate: the tree is handed to vitaminc's own -//! impls. The unit tests below pin the agreement against -//! `nonempty!(..).with(..)` on both sides, and `tests/native_ops.rs` pins it -//! end to end: a plan's stored terms equal native probes under the tuple, -//! and its `"c"` leaf opens natively under the tuple. +//! Nothing is re-derived here: the tree is handed to vitaminc's own impls. //! //! # Shape //! @@ -29,20 +25,14 @@ //! context := <string> | <bytes> | <i32> | <i64> | <u32> | <u64> | [ context, ... ] //! ``` //! -//! This module is the one home of that grammar; the plan parser, the ABI -//! docs and the Go bindings plan point here. -//! -//! A bare string is the flat form every plan used before this module: one -//! text part, the field's whole context, same bytes as before. An array is -//! a list; it may nest as deep as the transport codec allows -//! ([`MAX_DEPTH`](vitaminc_aead_value::transport::MAX_DEPTH) levels, -//! counted from the root of the encoded value — a plan's field context -//! starts two levels down), and a deeper value is refused as -//! [`STATUS_ENCODING`] by the codec before this module sees it. Text and -//! bytes with the same content are distinct on the PRF side (UTF-8 versus -//! bytes encodings) though they share AAD bytes — the same distinction the -//! Rust types make. Booleans, floats, null, undefined, objects and -//! passthroughs are not contexts and are refused as [`STATUS_ENCODING`]. +//! A bare string is one text part. An array is a list, and may nest as deep +//! as the transport codec allows +//! ([`MAX_DEPTH`](vitaminc_aead_value::transport::MAX_DEPTH) levels, counted +//! from the root of the encoded value); a deeper value is refused by the +//! codec before this module sees it. Text and bytes with the same content +//! are distinct on the PRF side (UTF-8 versus bytes encodings) though they +//! share AAD bytes — the same distinction the Rust types make. Booleans, +//! floats, null, undefined, objects and passthroughs are not contexts. //! //! # Which Rust contexts a list spells //! @@ -51,16 +41,14 @@ //! * `NonEmpty::with` nests to the **left**: `nonempty!("a").with(7u64) //! .with("eu")` is `(("a", 7u64), "eu")`, spelled `[["a", 7u64], "eu"]`. //! A flat three-element list is a different context (a three-part PAE) -//! that no `.with()` chain produces; `a_left_nested_list_is_the_with_chain` -//! pins both facts. +//! that no `.with()` chain produces. //! * `[x]` is `Some(x)` and `[]` is `None`, on both derivations. A //! one-element list is *not* the bare part: it is PAE-framed, the bare -//! part is not; `a_one_element_list_is_some` and -//! `a_one_element_list_is_not_the_bare_part` pin both. +//! part is not. //! //! # Emptiness //! -//! [`parse_context`] returns a [`NonEmpty`], proven once at the boundary by +//! [`context`](context()) returns a [`NonEmpty`], proven once here by //! vitaminc's own rule for the tree: an empty string or byte string is //! empty, an integer never is, and a list is empty when every part is (so //! `[]` and `[""]` are, `["", 7]` is not) — the rule its `Option` and tuple @@ -68,27 +56,30 @@ use std::borrow::Cow; -use stack_encrypt::{AadPiece, NonEmpty}; use vitaminc_aead_value::FfiValue; use vitaminc_protected::Controlled; -use crate::status::STATUS_ENCODING; +use super::Error; +use crate::{AadPiece, NonEmpty}; -/// Parse a context from its decoded [`FfiValue`] form and prove it -/// non-empty. Anything outside the shape in the [module docs](self), and -/// an empty context, is [`STATUS_ENCODING`]. -pub fn parse_context(value: FfiValue) -> Result<NonEmpty<AadPiece<'static>>, u32> { - NonEmpty::new(piece_of(value)?).map_err(|_| STATUS_ENCODING) +/// Read a context from a decoded [`FfiValue`] and prove it non-empty. +/// +/// # Errors +/// +/// [`Error::Context`] for anything outside the shape in the [module +/// docs](self), and for a context that renders empty. +pub fn context(value: FfiValue) -> Result<NonEmpty<AadPiece<'static>>, Error> { + NonEmpty::new(piece_of(value)?).map_err(|_| Error::Context) } -fn piece_of(value: FfiValue) -> Result<AadPiece<'static>, u32> { +fn piece_of(value: FfiValue) -> Result<AadPiece<'static>, Error> { Ok(match value { // Valid UTF-8 by `Utf8String`'s construction invariant; checked // rather than assumed because this is boundary code. The payload // moves out of its `Protected` rather than being copied: a context // is not secret, and the copy would only be wiped and freed. FfiValue::String(s) => AadPiece::Text(Cow::Owned( - String::from_utf8(s.into_inner().risky_unwrap()).map_err(|_| STATUS_ENCODING)?, + String::from_utf8(s.into_inner().risky_unwrap()).map_err(|_| Error::Context)?, )), FfiValue::Bytes(bytes) => AadPiece::Bytes(Cow::Owned(bytes.risky_unwrap())), FfiValue::Int32(v) => AadPiece::I32(v), @@ -101,7 +92,7 @@ fn piece_of(value: FfiValue) -> Result<AadPiece<'static>, u32> { items .into_iter() .map(piece_of) - .collect::<Result<Vec<_>, u32>>()?, + .collect::<Result<Vec<_>, Error>>()?, ), FfiValue::Null | FfiValue::Undefined @@ -109,19 +100,19 @@ fn piece_of(value: FfiValue) -> Result<AadPiece<'static>, u32> { | FfiValue::Float32(_) | FfiValue::Float64(_) | FfiValue::Object(_) - | FfiValue::Passthrough(_) => return Err(STATUS_ENCODING), + | FfiValue::Passthrough(_) => return Err(Error::Context), }) } -/// A view of a context tree that borrows its text and bytes, so a plan's -/// context — parsed and proven once — can be handed to every output of -/// every row without copying the payloads. Integers are copied (they are -/// the payload); the list spine is rebuilt, which is the cost of a tree of -/// `Cow`s rather than a tree of references. +/// A view of a context tree that borrows its text and bytes, so a context +/// parsed and proven once can be handed to every output of every row without +/// copying the payloads. Integers are copied (they are the payload); the +/// list spine is rebuilt, which is the cost of a tree of `Cow`s rather than +/// a tree of references. /// -/// `AadPiece` is `#[non_exhaustive]`, so a variant this crate does not know -/// is cloned whole rather than refused: the view must be the same context, -/// and a clone is. +/// [`AadPiece`] is `#[non_exhaustive]`, so a variant this crate does not +/// know is cloned whole rather than refused: the view must be the same +/// context, and a clone is. pub fn borrowed<'b>(piece: &'b AadPiece<'_>) -> AadPiece<'b> { match piece { AadPiece::Text(text) => AadPiece::Text(Cow::Borrowed(text.as_ref())), @@ -144,7 +135,7 @@ pub fn borrowed<'b>(piece: &'b AadPiece<'_>) -> AadPiece<'b> { #[cfg(test)] mod tests { use super::*; - use stack_encrypt::{nonempty, IntoAad, IntoPrfContext}; + use crate::{nonempty, Descriptor, IntoAad, IntoPrfContext}; use vitaminc_protected::Protected; fn s(value: &str) -> FfiValue { @@ -153,7 +144,7 @@ mod tests { #[test] fn a_bare_string_is_the_flat_context() { - let parsed = parse_context(s("users/age")).expect("flat context"); + let parsed = context(s("users/age")).expect("flat context"); assert_eq!(parsed.get(), &AadPiece::Text(Cow::Borrowed("users/age"))); assert_eq!( parsed.into_inner().into_aad().as_bytes(), @@ -163,7 +154,7 @@ mod tests { #[test] fn a_list_encodes_as_the_tuple_on_both_sides() { - let parsed = parse_context(FfiValue::Array(vec![s("users/age"), FfiValue::UInt64(7)])) + let parsed = context(FfiValue::Array(vec![s("users/age"), FfiValue::UInt64(7)])) .expect("extended context"); let tuple = nonempty!("users/age").with(7u64); assert_eq!( @@ -178,8 +169,7 @@ mod tests { #[test] fn a_list_renders_the_descriptor_the_tuple_does() { - use stack_encrypt::Descriptor; - let parsed = parse_context(FfiValue::Array(vec![s("users/age"), FfiValue::UInt64(7)])) + let parsed = context(FfiValue::Array(vec![s("users/age"), FfiValue::UInt64(7)])) .expect("extended context"); assert_eq!( Descriptor::of(parsed.into_inner()).as_str(), @@ -193,7 +183,7 @@ mod tests { #[test] fn a_nested_list_encodes_as_the_nested_tuple() { - let parsed = parse_context(FfiValue::Array(vec![ + let parsed = context(FfiValue::Array(vec![ s("users/age"), FfiValue::Array(vec![s("t"), FfiValue::Int32(-3)]), ])) @@ -212,7 +202,7 @@ mod tests { /// The borrowed view is the same context as the owned tree. #[test] fn the_borrowed_view_encodes_as_the_owned_tree() { - let parsed = parse_context(FfiValue::Array(vec![ + let parsed = context(FfiValue::Array(vec![ s("users/age"), FfiValue::Array(vec![ FfiValue::Bytes(Protected::new(b"k".to_vec())), @@ -241,13 +231,13 @@ mod tests { #[test] fn a_left_nested_list_is_the_with_chain() { let chain = nonempty!("a").with(7u64).with("eu"); - let nested = parse_context(FfiValue::Array(vec![ + let nested = context(FfiValue::Array(vec![ FfiValue::Array(vec![s("a"), FfiValue::UInt64(7)]), s("eu"), ])) .expect("nested") .into_inner(); - let flat = parse_context(FfiValue::Array(vec![s("a"), FfiValue::UInt64(7), s("eu")])) + let flat = context(FfiValue::Array(vec![s("a"), FfiValue::UInt64(7), s("eu")])) .expect("flat") .into_inner(); assert_eq!( @@ -276,8 +266,7 @@ mod tests { /// (vitaminc 0.4.0 made the `Option` PRF context follow its parts view). #[test] fn a_one_element_list_is_some() { - use stack_encrypt::Descriptor; - let list = parse_context(FfiValue::Array(vec![FfiValue::UInt64(7)])) + let list = context(FfiValue::Array(vec![FfiValue::UInt64(7)])) .expect("list") .into_inner(); let some = Some(7u64); @@ -300,8 +289,8 @@ mod tests { #[test] fn a_one_element_list_is_not_the_bare_part() { - let list = parse_context(FfiValue::Array(vec![s("a")])).expect("list"); - let bare = parse_context(s("a")).expect("bare"); + let list = context(FfiValue::Array(vec![s("a")])).expect("list"); + let bare = context(s("a")).expect("bare"); assert_ne!( list.clone().into_inner().into_aad().as_bytes(), bare.clone().into_inner().into_aad().as_bytes() @@ -314,8 +303,8 @@ mod tests { #[test] fn text_and_bytes_share_aad_bytes_but_not_prf_encoding() { - let text = parse_context(s("ab")).expect("text").into_inner(); - let bytes = parse_context(FfiValue::Bytes(Protected::new(b"ab".to_vec()))) + let text = context(s("ab")).expect("text").into_inner(); + let bytes = context(FfiValue::Bytes(Protected::new(b"ab".to_vec()))) .expect("bytes") .into_inner(); assert_eq!( @@ -340,9 +329,8 @@ mod tests { FfiValue::Array(vec![FfiValue::Array(vec![]), s("")]), ), ] { - assert_eq!( - parse_context(empty).err(), - Some(STATUS_ENCODING), + assert!( + matches!(context(empty), Err(Error::Context)), "{label} is empty by the tuple rule and must be refused" ); } @@ -358,7 +346,7 @@ mod tests { ), ] { assert!( - parse_context(non_empty).is_ok(), + context(non_empty).is_ok(), "{label} carries bytes and must be accepted" ); } @@ -381,9 +369,8 @@ mod tests { FfiValue::Array(vec![s("ok"), FfiValue::Bool(false)]), ), ] { - assert_eq!( - parse_context(bad).err(), - Some(STATUS_ENCODING), + assert!( + matches!(context(bad), Err(Error::Context)), "{label} is not a context and must be refused" ); } diff --git a/packages/stack-encrypt/src/dynamic/mod.rs b/packages/stack-encrypt/src/dynamic/mod.rs new file mode 100644 index 000000000..60d13f064 --- /dev/null +++ b/packages/stack-encrypt/src/dynamic/mod.rs @@ -0,0 +1,76 @@ +//! Encrypting values whose type is known only at runtime. +//! +//! Everything else in this crate is typed: a target names its source type, +//! and the operations it composes are reached through bounds — `Encrypt` for +//! a ciphertext, [`PrfValue`](vitaminc_prf::PrfValue) for an equality term, +//! `AsRef<str>` for a match term. That is what makes a term a cross-language +//! contract: `equality_term(34u32)` derives the same bytes wherever it is +//! called from, because `34u32` is the same value everywhere. +//! +//! An FFI binding cannot reach those bounds. Its field types arrive as wire +//! data, so there is no Rust type to name — and no dynamic value can satisfy +//! `AsRef<str>`, which is total. Something has to look at the value and pick +//! the typed operation. This module is that something, written once here +//! rather than once per language binding. +//! +//! The runtime value is [`FfiValue`], vitaminc's language-neutral value tree +//! and the type every binding already funnels through. +//! +//! # What is here +//! +//! * [`context`](context()) — an [`FfiValue`] read as an encryption context. +//! * [`term`](term()) — one index term for a value, dispatched on its variant. +//! +//! # What is not here +//! +//! Encrypting a whole value is not: [`FfiValue`] implements `Encrypt` +//! already, so `keyset.encrypt(value, aad)` is the whole of it and needs +//! nothing from this module. +//! +//! # Stability +//! +//! The output keys this module spells (`"c"`, `"eq"`, `"match"`, `"ore"`, +//! `"ope"`) are **wire format**, not just API: they are map keys in stored +//! ciphertext, so a row written under one spelling is read under the same +//! spelling or not at all. They are fixed here so that bindings in different +//! languages agree on them by construction rather than by each re-deriving +//! them. Their long-term home is beside vitaminc's frozen tag table, which +//! already owns this class of constant. +mod context; +mod term; + +pub use context::{borrowed, context}; +pub use term::{term, Scalar, TermKind}; + +/// What went wrong in a dynamic operation. +/// +/// The split that matters to a caller is malformed input versus a cipher +/// failure: every variant but [`Cipher`](Error::Cipher) is a statement about +/// the value or the request, decided before any key is minted or retrieved. +/// A binding maps them to its own status codes on that line. +#[derive(Debug, thiserror::Error)] +#[non_exhaustive] +pub enum Error { + /// A value used as an encryption context is not one — a boolean, float, + /// null, object or passthrough, or a string that is not UTF-8 — or it is + /// a context that renders empty. Leaves take a + /// [`NonEmpty`](vitaminc_protected::NonEmpty) and nothing else, so an + /// empty context is refused where it is read rather than sealed under. + #[error("value cannot be read as a non-empty encryption context")] + Context, + + /// A term was asked for a value the scheme defines no such term for: a + /// container, null or passthrough (which have no term semantics at all), + /// or a scalar outside the kind's domain — equality over a float or a + /// boolean, match over anything but text. See + /// [`TermKind::supports`]. + #[error("no {kind} term is defined for this value")] + Term { + /// The kind that was asked for. + kind: TermKind, + }, + + /// Sealing, opening or deriving failed. + #[error(transparent)] + Cipher(#[from] crate::Error), +} diff --git a/packages/stack-encrypt/src/dynamic/term.rs b/packages/stack-encrypt/src/dynamic/term.rs new file mode 100644 index 000000000..57547a113 --- /dev/null +++ b/packages/stack-encrypt/src/dynamic/term.rs @@ -0,0 +1,254 @@ +//! One index term for a runtime value. +//! +//! The dispatch here is the whole point of the module: which arm a value +//! takes decides the term's PRF/CLLW *input encoding*, and that encoding is +//! part of the cross-language contract. An equality term for +//! `FfiValue::UInt32(34)` must equal the term the typed path derives for +//! `34u32`, so each variant is handed to the same typed operation a Rust +//! caller would have named. +//! +//! # These are the query-probe path +//! +//! Same caveat as [`crate::sem`]: a term derived here is bound to the +//! context you pass and to nothing else. Use it to *query*. A term that is +//! going to be **stored** should come from the record path, where it shares +//! one context with the ciphertext beside it by construction (ADR-0004). + +use std::fmt; + +use stack_kms::DataKeySource; +use vitaminc_aead_value::{FfiValue, Utf8String}; +use vitaminc_protected::{Controlled, Protected}; +use zeroize::Zeroizing; + +use super::Error; +use crate::sem::{CllwOpeEncrypt, CllwOreEncrypt, DefaultMatch}; +use crate::{IntoPrfContext, KeysetCipher, NonEmpty}; + +/// Which index term to derive. +/// +/// The `key` strings are wire format — see the [module docs](super) on +/// stability. +#[derive(Clone, Copy, PartialEq, Eq, Debug, Hash)] +pub enum TermKind { + /// `"eq"` — equality (exact match). Raw 32 PRF bytes. + Equality, + /// `"match"` — full-text match under the default tokenizer config. LE + /// `u16` bit positions. + Match, + /// `"ore"` — order-revealing comparison. Raw CLLW bytes. + Ore, + /// `"ope"` — order-preserving comparison. Raw CLLW bytes. + Ope, +} + +impl TermKind { + /// The map key this term rides under in a record, and the string a + /// binding spells it as. + pub fn key(self) -> &'static str { + match self { + TermKind::Equality => "eq", + TermKind::Match => "match", + TermKind::Ore => "ore", + TermKind::Ope => "ope", + } + } + + /// Whether the scheme defines this term for `scalar`. + /// + /// No PRF encoding exists for floats (equality on IEEE-754 values is a + /// modelling error) or booleans; match is text-only; the ordering + /// schemes take every scalar. This is the one table — [`term`]'s arms + /// mirror it and are unreachable for a pair it refuses — and it is + /// consulted before any cipher work, so a binding can reject a bad + /// request at its boundary without minting anything. + pub fn supports(self, scalar: &Scalar) -> bool { + match self { + TermKind::Equality => { + !matches!(scalar, Scalar::Bool(_) | Scalar::F32(_) | Scalar::F64(_)) + } + TermKind::Match => matches!(scalar, Scalar::Text(_)), + TermKind::Ore | TermKind::Ope => true, + } + } +} + +impl fmt::Display for TermKind { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.key()) + } +} + +/// A term-able scalar lifted out of an [`FfiValue`] leaf. +/// +/// Lifting is a copy, so the value it came from stays movable into the +/// ciphertext path beside it. The owned text and bytes copies wipe on drop; +/// the PRF and CLLW layers move them into [`Protected`] internally. +#[derive(Clone)] +#[non_exhaustive] +pub enum Scalar { + /// From [`FfiValue::Bool`]. + Bool(bool), + /// From [`FfiValue::Int32`]. + I32(i32), + /// From [`FfiValue::Int64`]. + I64(i64), + /// From [`FfiValue::UInt32`]. + U32(u32), + /// From [`FfiValue::UInt64`]. + U64(u64), + /// From [`FfiValue::Float32`]. + F32(f32), + /// From [`FfiValue::Float64`]. + F64(f64), + /// From [`FfiValue::String`]. + Text(Zeroizing<String>), + /// From [`FfiValue::Bytes`]. + Bytes(Zeroizing<Vec<u8>>), +} + +impl Scalar { + /// Lift the scalar out of a value leaf. + /// + /// # Errors + /// + /// [`Error::Term`] for a container, null, undefined or passthrough: + /// those have no term semantics at all, whatever the kind. `kind` names + /// the term the caller was after, for the error only — whether that kind + /// is defined for the scalar is [`TermKind::supports`]. + pub fn of(value: &FfiValue, kind: TermKind) -> Result<Self, Error> { + Ok(match value { + FfiValue::Bool(v) => Scalar::Bool(*v), + FfiValue::Int32(v) => Scalar::I32(*v), + FfiValue::Int64(v) => Scalar::I64(*v), + FfiValue::UInt32(v) => Scalar::U32(*v), + FfiValue::UInt64(v) => Scalar::U64(*v), + FfiValue::Float32(v) => Scalar::F32(*v), + FfiValue::Float64(v) => Scalar::F64(*v), + FfiValue::String(s) => Scalar::Text(Zeroizing::new(text_of(s, kind)?.to_string())), + FfiValue::Bytes(b) => Scalar::Bytes(Zeroizing::new(b.risky_ref().to_vec())), + // Containers, nulls and passthroughs have no term semantics. + _ => return Err(Error::Term { kind }), + }) + } +} + +/// Derive one index term's frozen byte encoding. +/// +/// # Errors +/// +/// [`Error::Term`] if the scheme defines no such term for the scalar +/// ([`TermKind::supports`] is the table, and checking it first is how a +/// binding turns this into a boundary rejection). [`Error::Cipher`] if the +/// derivation itself fails. +pub async fn term<'c, K, D>( + cipher: &KeysetCipher<'_, K>, + scalar: Scalar, + kind: TermKind, + context: NonEmpty<D>, +) -> Result<Vec<u8>, Error> +where + K: DataKeySource + Sync, + D: IntoPrfContext<'c>, +{ + match kind { + TermKind::Equality => { + let term = match scalar { + Scalar::I32(v) => cipher.equality_term(v, context).await, + Scalar::I64(v) => cipher.equality_term(v, context).await, + Scalar::U32(v) => cipher.equality_term(v, context).await, + Scalar::U64(v) => cipher.equality_term(v, context).await, + Scalar::Text(t) => cipher.equality_term(String::clone(&t), context).await, + Scalar::Bytes(b) => { + cipher + .equality_term(Protected::new(Vec::clone(&b)), context) + .await + } + // No PRF encoding is defined for floats (equality on + // IEEE-754 values is a modelling error) or booleans. + Scalar::Bool(_) | Scalar::F32(_) | Scalar::F64(_) => { + return Err(Error::Term { kind }) + } + }?; + Ok(term.into_bytes().to_vec()) + } + TermKind::Match => match scalar { + Scalar::Text(t) => Ok(cipher + .match_terms::<DefaultMatch>(&t, context) + .await + .map(|t| t.to_bytes())?), + _ => Err(Error::Term { kind }), + }, + // The text and bytes arms hand the encryptor the `Zeroizing` operand + // itself, not a bare clone of its contents: the CLLW encryptors take + // their value by `'static` ownership (the visitor carries it), so a + // cloned-out `String`/`Vec<u8>` would be freed with the plaintext + // still in it — in a guest's linear memory, where the host can read + // it. Keeping the wrapper costs nothing and saves the copy as well. + TermKind::Ore => match scalar { + Scalar::Bool(v) => ore(cipher, v, context).await, + Scalar::I32(v) => ore(cipher, v, context).await, + Scalar::I64(v) => ore(cipher, v, context).await, + Scalar::U32(v) => ore(cipher, v, context).await, + Scalar::U64(v) => ore(cipher, v, context).await, + Scalar::F32(v) => ore(cipher, v, context).await, + Scalar::F64(v) => ore(cipher, v, context).await, + Scalar::Text(t) => ore(cipher, t, context).await, + Scalar::Bytes(b) => ore(cipher, b, context).await, + }, + TermKind::Ope => match scalar { + Scalar::Bool(v) => ope(cipher, v, context).await, + Scalar::I32(v) => ope(cipher, v, context).await, + Scalar::I64(v) => ope(cipher, v, context).await, + Scalar::U32(v) => ope(cipher, v, context).await, + Scalar::U64(v) => ope(cipher, v, context).await, + Scalar::F32(v) => ope(cipher, v, context).await, + Scalar::F64(v) => ope(cipher, v, context).await, + Scalar::Text(t) => ope(cipher, t, context).await, + Scalar::Bytes(b) => ope(cipher, b, context).await, + }, + } +} + +/// The `AsRef<[u8]>` on the output is what turns the typed CLLW ciphertext +/// into the frozen raw-bytes encoding. +async fn ore<'c, K, T, D>( + cipher: &KeysetCipher<'_, K>, + value: T, + context: NonEmpty<D>, +) -> Result<Vec<u8>, Error> +where + K: DataKeySource + Sync, + T: CllwOreEncrypt + Send + 'static, + T::Output: AsRef<[u8]> + Send + 'static, + D: IntoPrfContext<'c>, +{ + Ok(cipher + .ore_term(value, context) + .await + .map(|t| t.as_ref().to_vec())?) +} + +/// See [`ore`]. +async fn ope<'c, K, T, D>( + cipher: &KeysetCipher<'_, K>, + value: T, + context: NonEmpty<D>, +) -> Result<Vec<u8>, Error> +where + K: DataKeySource + Sync, + T: CllwOpeEncrypt + Send + 'static, + T::Output: AsRef<[u8]> + Send + 'static, + D: IntoPrfContext<'c>, +{ + Ok(cipher + .ope_term(value, context) + .await + .map(|t| t.as_ref().to_vec())?) +} + +/// The UTF-8 inside a string leaf. Valid by `Utf8String`'s construction +/// invariant; checked rather than assumed because this is boundary code. +fn text_of(s: &Utf8String, kind: TermKind) -> Result<&str, Error> { + std::str::from_utf8(s.risky_ref()).map_err(|_| Error::Term { kind }) +} diff --git a/packages/stack-encrypt/src/lib.rs b/packages/stack-encrypt/src/lib.rs index 9684d4f87..a3f223fda 100644 --- a/packages/stack-encrypt/src/lib.rs +++ b/packages/stack-encrypt/src/lib.rs @@ -257,6 +257,8 @@ endpoint — are `StackKmsBuilder`'s, and the two keyset-cache knobs are pub mod cipher; pub mod descriptor; +#[cfg(feature = "dynamic")] +pub mod dynamic; pub mod keyset; pub mod sem; pub mod target; From 85254e158de681e3090070d6d7634c7b42ad9c5c Mon Sep 17 00:00:00 2001 From: Dan Draper <dan@cipherstash.com> Date: Mon, 14 Sep 2026 09:02:42 -0400 Subject: [PATCH 569/686] feat(stack-encrypt): record plans in the library, not in each binding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ops.rs` re-implemented `#[derive(EncryptFrom)]` by hand — plan parsing, row alignment, per-field term derivation, the merged pending batch, the passthrough rejections on both sides — in the Go binding's guest, where a second binding cannot reach it. Move the whole driver to `stack_encrypt::dynamic::record`, which is where it was always a library concern: * `Output` / `FieldPlan` / `plan` — what a field binds under and produces. `FieldPlan::new` takes the context already proven `NonEmpty`, because that proof has to happen before anything seals: the cipher-directed path accepts any AAD, so nothing downstream would stop an empty context, and opening through `decrypt_as` would then never open it. * `encrypt` / `decrypt` — the drivers, taking and returning values and trees rather than encoded bytes. The codec stays the binding's. * `check_source` / `check_record` — the same parsers, without a cipher, so a binding can refuse a malformed call at its boundary and cannot disagree with the operation about what malformed means. `Opener` moves too. It is not a guest concept: `StackCipher` and `KeysetCipher` both open and neither is the other's supertype, so which one a call opens through is a choice, and refusing a foreign leaf before any key is retrieved is the whole point of making it. The one-context rule now holds by construction rather than by review — a field's context is proven once and the same borrowed view drives its ciphertext and every one of its terms, so `encrypt` never sees two contexts for one field. `ops.rs` goes from 1162 lines to 533, and what is left is the part that really is this guest's: the codec both directions, buffers sized before a byte of plaintext is written, the ABI's numeric term kinds, and the error- to-status mapping. All 56 guest tests pass unchanged, which is the point — the behaviour moved, it did not change. Refs CIP-4040 Claude-Session: https://claude.ai/code/session_019b9YUGNPBrj9UUX2aZ8RbA --- .../golang/stackencrypt/guest/src/abi.rs | 5 +- .../golang/stackencrypt/guest/src/ops.rs | 586 ++-------------- .../golang/stackencrypt/guest/src/options.rs | 41 +- .../stackencrypt/guest/tests/native_ops.rs | 2 +- packages/stack-encrypt/src/dynamic/mod.rs | 55 ++ packages/stack-encrypt/src/dynamic/record.rs | 630 ++++++++++++++++++ packages/stack-encrypt/src/dynamic/term.rs | 14 +- 7 files changed, 765 insertions(+), 568 deletions(-) create mode 100644 packages/stack-encrypt/src/dynamic/record.rs diff --git a/languages/golang/stackencrypt/guest/src/abi.rs b/languages/golang/stackencrypt/guest/src/abi.rs index 4906d4979..1b3da7c91 100644 --- a/languages/golang/stackencrypt/guest/src/abi.rs +++ b/languages/golang/stackencrypt/guest/src/abi.rs @@ -100,8 +100,9 @@ use crate::buffers; use crate::config::parse_config; use crate::host::{HostTokenStrategy, WasiHostConnection}; use crate::ops; -use crate::options::{parse_options, parse_selector, KeysetSelector, Opener, Side}; +use crate::options::{opener_for, parse_options, parse_selector, KeysetSelector, Side}; use crate::status::{STATUS_ENCODING, STATUS_INTERNAL, STATUS_KMS_TRANSPORT, STATUS_STATE}; +use stack_encrypt::dynamic::Opener; /// The instance's cipher: `stack-encrypt` over the host-transport ZeroKMS /// client with host-supplied tokens. @@ -257,7 +258,7 @@ fn with_opener<R>( ) -> Result<R, u32> { let options = parse_options(decode(opts)?, Side::Open)?; with_cipher(|cipher| { - let opener = block_on(Opener::for_selector(cipher, &options.keyset))?; + let opener = block_on(opener_for(cipher, &options.keyset))?; f(opener) }) } diff --git a/languages/golang/stackencrypt/guest/src/ops.rs b/languages/golang/stackencrypt/guest/src/ops.rs index 61f4b899d..e6abfdef0 100644 --- a/languages/golang/stackencrypt/guest/src/ops.rs +++ b/languages/golang/stackencrypt/guest/src/ops.rs @@ -19,37 +19,26 @@ //! are attacker-reachable decode/decrypt paths, and the status codes leak //! only the failure class (see `status.rs`). //! -//! # Records +//! # What is here, and what is not //! -//! [`encrypt_record`] is the runtime form of `#[derive(EncryptFrom)]`: a -//! *plan* says, per field, which encryption context to bind and which -//! outputs to produce (ciphertext and/or index terms); the source supplies -//! the field values. However many rows and fields are in one call, all -//! ciphertext leaves seal from **one** batched `generate_keys` — the -//! pendings are merged before settling, exactly like the derive's `zip`/`all` -//! composition. Index terms are *not* in that batch: `build_row` settles -//! each term's pending as it builds the row, which under the local HMAC -//! backend is no ZeroKMS traffic at all, and under a backend that derives -//! terms at ZeroKMS (as ZeroKMS v2 does) would be one round trip per term -//! until the term pendings are merged into the row's batch — a change for -//! this module when that backend lands, not something the record path does -//! today. The ciphertext batch reaches ZeroKMS as one request per -//! `ClientOpts::max_keys_per_req` keyed leaves (500 by default, sent -//! sequentially: the guest pins `max_concurrent_reqs` to 1), so "one call" -//! is exact up to 500 leaves and "one call per 500" past it. See -//! `parse_plan` for the plan encoding. - -use stack_encrypt::dynamic::{self, Scalar, TermKind}; -use stack_encrypt::target::Pending; +//! The operations themselves live in [`stack_encrypt::dynamic`]: reading a +//! context out of a value, dispatching an index term on a value's variant, +//! and driving a record plan. That is shared with every other language +//! binding, because none of it is specific to Go or to wasm. +//! +//! What is left here is what genuinely is this guest's: the codec both +//! directions, buffers sized before a byte of plaintext is written, the +//! ABI's numeric term kinds, and the mapping from a library error to a +//! status code. + +use stack_encrypt::dynamic::{self, Opener, Scalar, TermKind}; use stack_encrypt::{ - AadPiece, BoxedPassthrough, CipherText, Element, Encrypt, KeysetCipher, NonEmpty, SealedValue, - StackCipherText, + BoxedPassthrough, CipherText, Element, Encrypt, KeysetCipher, SealedValue, StackCipherText, }; use stack_kms::DataKeySource; use vitaminc_aead_value::{transport as codec, FfiValue}; -use vitaminc_protected::{Controlled, Protected}; +use vitaminc_protected::Controlled; -use crate::options::Opener; use crate::status::{status_for_dynamic, status_for_error, STATUS_ENCODING, STATUS_INTERNAL}; /// Term kinds for `se_term`, part of the guest/host contract (the Go host @@ -213,203 +202,22 @@ fn parse_term(value: FfiValue, kind: u32) -> Result<(Scalar, TermKind), u32> { // Records // ============================================================================= -/// What a plan field asks for. The strings are the plan encoding *and* the -/// keys of the per-field output map in the result; the term spellings are -/// [`TermKind::key`], so the plan grammar and the library agree on them by -/// construction rather than by two tables. -#[derive(Clone, Copy, PartialEq, Eq, Debug)] -enum Output { - /// `"c"` — the field's [`StackCipherText`]. - Ciphertext, - /// An index term: `"eq"`, `"match"`, `"ore"` or `"ope"`. - Term(TermKind), -} - -impl Output { - fn parse(s: &str) -> Option<Self> { - Some(match s { - "c" => Output::Ciphertext, - "eq" => Output::Term(TermKind::Equality), - "match" => Output::Term(TermKind::Match), - "ore" => Output::Term(TermKind::Ore), - "ope" => Output::Term(TermKind::Ope), - _ => return None, - }) - } - - fn key(self) -> &'static str { - match self { - Output::Ciphertext => "c", - Output::Term(kind) => kind.key(), - } - } -} - -/// One field of a record plan. -struct FieldPlan { - name: String, - /// Proven non-empty when the plan is parsed, so every path that seals or - /// opens under it — the cipher-directed `encrypt_with_aad` in - /// [`build_row`] as much as the target-directed `decrypt_into` in - /// [`decrypt_record`] — is under a context stack-encrypt's leaves accept. - context: NonEmpty<AadPiece<'static>>, - outputs: Vec<Output>, -} - -/// Parse a record plan from a decoded value. The plan is an -/// [`FfiValue::Object`]: -/// -/// ```text -/// { <field>: { "context": <context>, "outputs": [ "c" | "eq" | "match" | "ore" | "ope", ... ] }, ... } -/// ``` -/// -/// `<context>` is defined once, in [`crate::context`]: a string, bytes, an -/// integer, or a list of those, with what each spells in Rust and the -/// emptiness rule. -/// -/// Rejected as [`STATUS_ENCODING`]: an empty plan, a missing, malformed or -/// *empty* context (contexts domain-separate fields; stack-encrypt's leaves -/// take a `NonEmpty<_>` and nothing else), an empty/unknown/duplicated -/// output list, unknown keys. Field names are unique by construction (the -/// codec rejects duplicate object keys). -/// -/// The context is proven here, once, and carried as a [`NonEmpty`]: the -/// cipher-directed path [`build_row`] seals through accepts any AAD, so -/// nothing downstream would otherwise stop an empty context from being -/// sealed under — and [`decrypt_record`] opens through `decrypt_into`, -/// which would then never open it. -/// -/// A plan context is the *whole* context of the field: the guest has no -/// caller context to extend it with, so the plan spells the extension -/// itself. A bare string matches a Rust `#[derive(EncryptFrom)]` record -/// sealed with `encrypt_into` (no caller context); a list matches one -/// sealed with `encrypt_into_with_context` — see [`crate::context`] for -/// which list spells which Rust context. Rows are readable across the two -/// however they were sealed, provided the plan names the context the row -/// was sealed under. -fn parse_plan(value: FfiValue) -> Result<Vec<FieldPlan>, u32> { - let FfiValue::Object(entries) = value else { - return Err(STATUS_ENCODING); - }; - if entries.is_empty() { - return Err(STATUS_ENCODING); - } - entries - .into_iter() - .map(|(name, spec)| { - let FfiValue::Object(spec) = spec else { - return Err(STATUS_ENCODING); - }; - let mut context: Option<NonEmpty<AadPiece<'static>>> = None; - let mut outputs: Option<Vec<Output>> = None; - for (key, value) in spec { - match key.as_str() { - "context" => { - context = Some(dynamic::context(value).map_err(|e| status_for_dynamic(&e))?) - } - "outputs" => { - let FfiValue::Array(items) = value else { - return Err(STATUS_ENCODING); - }; - let mut parsed = Vec::with_capacity(items.len()); - for item in &items { - let FfiValue::String(s) = item else { - return Err(STATUS_ENCODING); - }; - let output = Output::parse(text_of(s)?).ok_or(STATUS_ENCODING)?; - if parsed.contains(&output) { - return Err(STATUS_ENCODING); - } - parsed.push(output); - } - outputs = Some(parsed); - } - _ => return Err(STATUS_ENCODING), - } - } - let context = context.ok_or(STATUS_ENCODING)?; - let outputs = outputs.filter(|o| !o.is_empty()).ok_or(STATUS_ENCODING)?; - Ok(FieldPlan { - name, - context, - outputs, - }) - }) - .collect() -} - -/// A row's assembled outputs, ciphertext slots still pending: the terms are -/// derived (locally), and each `None` is filled from the settled ciphertexts -/// in build order. -type RowSkeleton = Vec<(String, Vec<(&'static str, Option<Vec<u8>>)>)>; - -/// Reject a source field value that contains a passthrough anywhere, before -/// it reaches a `"c"` slot. A passthrough node is *unauthenticated by -/// definition* — on decrypt it hands its payload back with no AEAD opened — -/// so admitting one under a plan field the plan declares ciphertext-bearing -/// would quietly produce a slot whose bytes verify nothing. Rejecting it -/// here is what makes [`decrypt_record`]'s mirror-image rejection a -/// round-trip invariant rather than data loss. -fn reject_passthrough_value(value: &FfiValue) -> Result<(), u32> { - match value { - FfiValue::Passthrough(_) => Err(STATUS_ENCODING), - FfiValue::Array(items) => items.iter().try_for_each(reject_passthrough_value), - FfiValue::Object(entries) => entries - .iter() - .try_for_each(|(_, v)| reject_passthrough_value(v)), - _ => Ok(()), - } -} - -/// Reject a `"c"` subtree that contains a passthrough anywhere. This is the -/// decrypt-side half of [`reject_passthrough_value`], and it is -/// load-bearing: `decrypt_into` collects **zero** retrieve-requests for a -/// passthrough and returns its payload with no AEAD opened, so an attacker -/// with write access to the stored tree could replace a field's `"c"` -/// subtree with a passthrough carrying forged plaintext and this function's -/// absence would report it as a successful decrypt. [`encrypt_record`] never -/// produces a passthrough under `"c"`, so the shape is unconditionally -/// [`STATUS_ENCODING`]. -fn reject_passthrough_tree(tree: &StackCipherText) -> Result<(), u32> { - match tree { - CipherText::Passthrough(_) => Err(STATUS_ENCODING), - CipherText::Sequence(items) => items.iter().try_for_each(reject_passthrough_tree), - CipherText::Map(entries) => entries - .iter() - .try_for_each(|(_, v)| reject_passthrough_tree(v)), - CipherText::Single(_) - | CipherText::None(_) - | CipherText::EmptySequence(_) - | CipherText::EmptyMap(_) => Ok(()), - } -} - /// Encrypt a record — or a batch of records — per a plan. /// -/// `source` is a codec-encoded [`FfiValue::Object`] of `{ field: scalar }` -/// (one record), or an [`FfiValue::Array`] of such objects (a batch). Every -/// plan field must be present in each record, and every record field must be -/// named by the plan — silently dropping a field on either side would lose -/// data or index nothing. +/// Both arguments are codec-encoded: the plan is the object +/// [`dynamic::record::plan`] parses, the source an object of +/// `{ field: scalar }` (one record) or an array of them (a batch). The +/// result is a codec-encoded ciphertext tree — per record a map of +/// `field → { output-key → node }`. /// -/// The result is a codec-encoded ciphertext tree: per record a map of -/// `field → { output-key → node }`, where `"c"` is the field's sealed -/// ciphertext subtree and each term rides as a passthrough -/// [`FfiValue::Bytes`] node (terms are comparands, not ciphertexts to open — -/// passthrough is their honest encoding). A batch is a sequence of such -/// maps. All rows and fields seal in one batched `generate_keys`; that -/// batch is split into one ZeroKMS request per +/// All rows and fields seal in one batched `generate_keys`; that batch +/// reaches ZeroKMS as one request per /// [`ClientOpts::max_keys_per_req`](stack_kms::ClientOpts::with_max_keys_per_req) -/// keyed leaves (500 by default), sent sequentially. -/// -/// **Cross-language note.** A `"c"` leaf seals the aead-value *tagged* -/// plaintext encoding (`[type tag] ++ payload`), because that tag table is -/// the contract Go, Node and this guest share. A Rust -/// `#[derive(EncryptFrom)]` over a plain primitive — a bare `u32` — seals -/// four untagged bytes instead, so a plain-primitive Rust derive and a Go -/// plan do **not** interchange ciphertexts for the same field until the Rust -/// side uses aead-value's tagged types too. This is by design, not a defect -/// in either side; making the derive tagged is a separate follow-up. +/// keyed leaves (500 by default, sent sequentially: the guest pins +/// `max_concurrent_reqs` to 1), so "one call" is exact up to 500 leaves and +/// "one call per 500" past it. Everything else about the shape — the plan +/// grammar, the one-context rule, why terms ride as passthrough — is +/// [`dynamic::record`]'s to state. pub async fn encrypt_record<K>( cipher: &KeysetCipher<'_, K>, source: &[u8], @@ -418,247 +226,20 @@ pub async fn encrypt_record<K>( where K: DataKeySource + Sync, { - let plan = parse_plan(decode_value(plan)?)?; - let (rows, batched) = source_rows(decode_value(source)?, &plan)?; - - // Build every row: terms derive now (local), ciphertexts queue their - // data-key requests into one flat pending list. - let mut pendings: Vec<Pending<'_, StackCipherText, K>> = Vec::new(); - let mut skeletons: Vec<RowSkeleton> = Vec::with_capacity(rows.len()); - for row in rows { - skeletons.push(build_row(cipher, row, &plan, &mut pendings).await?); - } - - // The one batched key request for the whole invocation (one ZeroKMS - // call per 500 keyed leaves, per the module docs). - let sealed = Pending::all(cipher, pendings) + let plan = dynamic::record::plan(decode_value(plan)?).map_err(|e| status_for_dynamic(&e))?; + let tree = dynamic::record::encrypt(cipher, decode_value(source)?, &plan) .await - .map_err(|e| status_for_error(&e))?; - let mut sealed = sealed.into_iter(); - - // Fill the ciphertext slots back in, in build order. - let mut row_nodes = Vec::with_capacity(skeletons.len()); - for skeleton in skeletons { - let mut fields = Vec::with_capacity(skeleton.len()); - for (field, outputs) in skeleton { - let mut nodes = Vec::with_capacity(outputs.len()); - for (key, slot) in outputs { - let node = match slot { - Some(term) => CipherText::Passthrough(Box::new(FfiValue::Bytes(Protected::new( - term, - ))) - as BoxedPassthrough), - None => sealed.next().ok_or(STATUS_INTERNAL)?, - }; - nodes.push((key.to_string(), node)); - } - fields.push((field, CipherText::Map(nodes))); - } - row_nodes.push(CipherText::Map(fields)); - } - if sealed.next().is_some() { - return Err(STATUS_INTERNAL); - } - - let tree = if batched { - CipherText::Sequence(row_nodes) - } else { - row_nodes.pop().ok_or(STATUS_INTERNAL)? - }; + .map_err(|e| status_for_dynamic(&e))?; encode_tree(tree) } -/// The rows of a record source, each aligned to the plan's field order, -/// with everything that can be checked without a cipher checked: the -/// source is one object or an array of objects, every plan field is present -/// in every row and no row carries a field the plan does not name (silently -/// dropping a field on either side would lose data or index nothing), and -/// each value fits its field's outputs ([`check_field`]). The `bool` is -/// whether the source was a batch. Shared by [`encrypt_record`] and -/// [`validate::record`]. -fn source_rows(source: FfiValue, plan: &[FieldPlan]) -> Result<(Vec<Vec<FfiValue>>, bool), u32> { - let (rows, batched) = match source { - FfiValue::Object(entries) => (vec![entries], false), - FfiValue::Array(items) => { - let rows = items - .into_iter() - .map(|item| match item { - FfiValue::Object(entries) => Ok(entries), - _ => Err(STATUS_ENCODING), - }) - .collect::<Result<Vec<_>, u32>>()?; - (rows, true) - } - _ => return Err(STATUS_ENCODING), - }; - let rows = rows - .into_iter() - .map(|mut row| { - if row.len() != plan.len() { - return Err(STATUS_ENCODING); - } - plan.iter() - .map(|field| { - let at = row - .iter() - .position(|(name, _)| name == &field.name) - .ok_or(STATUS_ENCODING)?; - let (_, value) = row.swap_remove(at); - check_field(&value, field)?; - Ok(value) - }) - .collect::<Result<Vec<_>, u32>>() - }) - .collect::<Result<Vec<_>, u32>>()?; - Ok((rows, batched)) -} - -/// A source value against its plan field: every term output needs a -/// scalar the scheme defines the term for ([`TermKind::supports`]), and a -/// ciphertext output refuses a passthrough anywhere in the value -/// ([`reject_passthrough_value`]). -fn check_field(value: &FfiValue, field: &FieldPlan) -> Result<(), u32> { - for output in &field.outputs { - match output { - Output::Ciphertext => reject_passthrough_value(value)?, - Output::Term(kind) => { - let scalar = Scalar::of(value, *kind).map_err(|e| status_for_dynamic(&e))?; - if !kind.supports(&scalar) { - return Err(STATUS_ENCODING); - } - } - } - } - Ok(()) -} - -/// The `"c"` subtrees a record tree holds for the plan's ciphertext-bearing -/// fields, per row in plan order, with the row's field name: the tree is -/// one map or a sequence of maps, each such field is present, is a map of -/// outputs with a `"c"` node, and that node is not a passthrough -/// ([`reject_passthrough_tree`]). Terms and fields the plan does not name -/// are ignored (comparands, not ciphertext). The `bool` is whether the tree -/// was a batch. Shared by [`decrypt_record`] and [`validate::record_tree`]. -#[allow(clippy::type_complexity)] -fn record_leaves( - tree: StackCipherText, - plan: &[FieldPlan], -) -> Result<(Vec<Vec<(String, StackCipherText)>>, bool), u32> { - let (rows, batched) = match tree { - CipherText::Map(entries) => (vec![entries], false), - CipherText::Sequence(items) => { - let rows = items - .into_iter() - .map(|item| match item { - CipherText::Map(entries) => Ok(entries), - _ => Err(STATUS_ENCODING), - }) - .collect::<Result<Vec<_>, u32>>()?; - (rows, true) - } - _ => return Err(STATUS_ENCODING), - }; - let rows = rows - .into_iter() - .map(|mut row| { - plan.iter() - .filter(|field| field.outputs.contains(&Output::Ciphertext)) - .map(|field| { - let at = row - .iter() - .position(|(name, _)| name == &field.name) - .ok_or(STATUS_ENCODING)?; - let (name, node) = row.swap_remove(at); - let CipherText::Map(outputs) = node else { - return Err(STATUS_ENCODING); - }; - let ct = outputs - .into_iter() - .find_map(|(key, node)| (key == "c").then_some(node)) - .ok_or(STATUS_ENCODING)?; - reject_passthrough_tree(&ct)?; - Ok((name, ct)) - }) - .collect::<Result<Vec<_>, u32>>() - }) - .collect::<Result<Vec<_>, u32>>()?; - Ok((rows, batched)) -} - -/// Build one record row: derive its terms and queue its ciphertext pendings, -/// returning the row skeleton. The plan drives the iteration so the output -/// field order is the plan's; the row arrives from [`source_rows`] already -/// in that order and checked against the plan. -async fn build_row<'c, K>( - cipher: &'c KeysetCipher<'_, K>, - row: Vec<FfiValue>, - plan: &[FieldPlan], - pendings: &mut Vec<Pending<'c, StackCipherText, K>>, -) -> Result<RowSkeleton, u32> -where - K: DataKeySource + Sync, -{ - // A row `source_rows` did not align is a guest bug, not host input. - if row.len() != plan.len() { - return Err(STATUS_INTERNAL); - } - let mut skeleton = Vec::with_capacity(plan.len()); - for (field, value) in plan.iter().zip(row) { - let name = field.name.clone(); - // A borrowed view of the plan's context, once per field: the proof - // was made at parse time, so re-taking it over the same tree cannot - // fail, and the view clones cheaply for each output below. - let context = - NonEmpty::new(dynamic::borrowed(field.context.get())).map_err(|_| STATUS_INTERNAL)?; - - // Terms first — they lift a copy of the scalar; the value itself is - // consumed by the ciphertext path below. One lift serves every term - // output: the kind only names which error a non-scalar reports. - let scalar = field - .outputs - .iter() - .find_map(|o| match o { - Output::Term(kind) => Some(*kind), - Output::Ciphertext => None, - }) - .map(|kind| Scalar::of(&value, kind)) - .transpose() - .map_err(|e| status_for_dynamic(&e))?; - - let mut outputs: Vec<(&'static str, Option<Vec<u8>>)> = - Vec::with_capacity(field.outputs.len()); - for output in &field.outputs { - let Output::Term(kind) = output else { - outputs.push((output.key(), None)); - continue; - }; - let scalar = scalar.clone().ok_or(STATUS_INTERNAL)?; - let term = dynamic::term(cipher, scalar, *kind, context.clone()) - .await - .map_err(|e| status_for_dynamic(&e))?; - outputs.push((output.key(), Some(term))); - } - - if field.outputs.contains(&Output::Ciphertext) { - reject_passthrough_value(&value)?; - let tree = value - .encrypt_with_aad(cipher, context.clone()) - .map_err(|_| STATUS_INTERNAL)?; - pendings.push(tree.into_pending(cipher, context)); - } - - skeleton.push((name, outputs)); - } - Ok(skeleton) -} - /// Decrypt a record — or a batch — produced by [`encrypt_record`] under the -/// same plan. Only the `"c"` outputs participate (terms are one-way); the -/// result is a codec-encoded [`FfiValue::Object`] per record holding the -/// plan's ciphertext-bearing fields, in plan order — or an -/// [`FfiValue::Array`] of them for a batch. One batched `retrieve_keys` -/// per invocation, dispatched as one ZeroKMS call per 500 keyed leaves and, -/// when opening any keyset, per keyset the leaves were sealed under. +/// same plan. Only the `"c"` outputs participate (terms are one-way). +/// +/// One batched `retrieve_keys` per invocation, dispatched as one ZeroKMS +/// call per 500 keyed leaves and, under [`Opener::Any`], per keyset the +/// leaves were sealed under. The output buffer contains plaintext — the ABI +/// layer's ownership rules govern its wiping. pub async fn decrypt_record<K>( opener: Opener<'_, K>, record: &[u8], @@ -667,65 +248,10 @@ pub async fn decrypt_record<K>( where K: DataKeySource + Sync + 'static, { - let plan = parse_plan(decode_value(plan)?)?; - let (rows, batched) = record_leaves(decode_tree(record)?, &plan)?; - let contexts = plan - .iter() - .filter(|field| field.outputs.contains(&Output::Ciphertext)) - .map(|field| { - NonEmpty::new(dynamic::borrowed(field.context.get())).map_err(|_| STATUS_INTERNAL) - }) - .collect::<Result<Vec<_>, u32>>()?; - - // Per row, per ciphertext-bearing plan field (in plan order, as - // `record_leaves` lifted them): queue the "c" subtree's decrypt. - let mut pendings: Vec<Pending<'_, FfiValue, K>> = Vec::new(); - let mut names: Vec<Vec<String>> = Vec::with_capacity(rows.len()); - for row in rows { - if row.len() != contexts.len() { - return Err(STATUS_INTERNAL); - } - let mut row_names = Vec::with_capacity(row.len()); - for ((name, ct), context) in row.into_iter().zip(&contexts) { - let context = context.clone(); - // The scope is the opener's, the declaration is the target's: - // `decrypt_as` takes one context and drives both halves with it. - pendings.push(match &opener { - Opener::Any(cipher) => cipher.decrypt_as(ct, context.into()), - Opener::Only(keyset) => keyset.decrypt_as(ct, context.into()), - }); - row_names.push(name); - } - names.push(row_names); - } - - // The one batched key request for the whole invocation: one ZeroKMS - // call per 500 keyed leaves, and when opening any, per keyset the leaves - // were sealed under. - let values = match &opener { - Opener::Any(cipher) => Pending::all(*cipher, pendings).await, - Opener::Only(keyset) => Pending::all(keyset, pendings).await, - } - .map_err(|e| status_for_error(&e))?; - let mut values = values.into_iter(); - - let mut row_values = Vec::with_capacity(names.len()); - for row_names in names { - let mut entries = Vec::with_capacity(row_names.len()); - for name in row_names { - entries.push((name, values.next().ok_or(STATUS_INTERNAL)?)); - } - row_values.push(FfiValue::Object(entries)); - } - if values.next().is_some() { - return Err(STATUS_INTERNAL); - } - - let value = if batched { - FfiValue::Array(row_values) - } else { - row_values.pop().ok_or(STATUS_INTERNAL)? - }; + let plan = dynamic::record::plan(decode_value(plan)?).map_err(|e| status_for_dynamic(&e))?; + let value = dynamic::record::decrypt(opener, decode_tree(record)?, &plan) + .await + .map_err(|e| status_for_dynamic(&e))?; encode_value(value) } @@ -736,10 +262,10 @@ where /// The static checks the ABI runs on every operation input *before* it /// consults the cipher, so a malformed call is [`STATUS_ENCODING`] whether /// or not the instance is initialised, and never costs a keyset load. Each -/// runs the same parser the operation itself runs — `parse_term`, -/// `source_rows`, `record_leaves` — so the two cannot disagree on what -/// is malformed; the second pass is cheap next to the AEAD and buys a -/// stable status precedence. +/// runs the same parser the operation itself runs — [`parse_term`], +/// [`dynamic::record::check_source`], [`dynamic::record::check_record`] — +/// so the two cannot disagree on what is malformed; the second pass is +/// cheap next to the AEAD and buys a stable status precedence. pub mod validate { use super::*; @@ -767,20 +293,23 @@ pub mod validate { /// A record source against its plan, as [`encrypt_record`] takes them: /// the plan decodes and parses (every field's context non-empty, every - /// output known), and the source fits it (`source_rows`: shape, field - /// set, each value against its field's outputs). + /// output known), and the source fits it (shape, field set, each value + /// against its field's outputs). pub fn record(source: &[u8], plan: &[u8]) -> Result<(), u32> { - let plan = parse_plan(decode_value(plan)?)?; - source_rows(decode_value(source)?, &plan).map(drop) + let plan = + dynamic::record::plan(decode_value(plan)?).map_err(|e| status_for_dynamic(&e))?; + dynamic::record::check_source(decode_value(source)?, &plan) + .map_err(|e| status_for_dynamic(&e)) } /// A record tree against its plan, as [`decrypt_record`] takes them: /// the plan parses, the tree decodes with well-formed leaves, and every - /// ciphertext-bearing field has a `"c"` node that is not a passthrough - /// (`record_leaves`). + /// ciphertext-bearing field has a `"c"` node that is not a passthrough. pub fn record_tree(record: &[u8], plan: &[u8]) -> Result<(), u32> { - let plan = parse_plan(decode_value(plan)?)?; - record_leaves(decode_tree(record)?, &plan).map(drop) + let plan = + dynamic::record::plan(decode_value(plan)?).map_err(|e| status_for_dynamic(&e))?; + dynamic::record::check_record(decode_tree(record)?, &plan) + .map_err(|e| status_for_dynamic(&e)) } } @@ -919,15 +448,10 @@ fn tree_encoded_len(tree: &BytesTree) -> Option<usize> { } } -fn text_of(s: &vitaminc_aead_value::Utf8String) -> Result<&str, u32> { - // Valid UTF-8 by `Utf8String`'s construction invariant; checked rather - // than assumed because this is boundary code. - std::str::from_utf8(s.risky_ref()).map_err(|_| STATUS_ENCODING) -} - #[cfg(test)] mod tests { use super::*; + use vitaminc_protected::Protected; // `value_encoded_len` / `tree_encoded_len` re-derive the codec's framing // arithmetic; the codec exports no `encoded_len` of its own, so these diff --git a/languages/golang/stackencrypt/guest/src/options.rs b/languages/golang/stackencrypt/guest/src/options.rs index a77ba4e32..27c6c8771 100644 --- a/languages/golang/stackencrypt/guest/src/options.rs +++ b/languages/golang/stackencrypt/guest/src/options.rs @@ -27,6 +27,7 @@ //! it is objects, strings, bytes and nothing else, and this module is its //! one home. The Go bindings plan points here. +use stack_encrypt::dynamic::Opener; use stack_encrypt::{KeysetCipher, StackCipher}; use stack_kms::{IdentifiedBy, IndexKeySource}; use uuid::Uuid; @@ -122,7 +123,7 @@ impl KeysetSelector { /// default without a round trip, a name or id through the cipher's /// cache (a first use is one `load-keyset` call). `Any` is not a keyset /// and is [`STATUS_ENCODING`] here; opening exports resolve it through - /// [`Opener::for_selector`] instead. + /// [`opener_for`] instead. pub async fn resolve<'c, K>( &self, cipher: &'c StackCipher<K>, @@ -140,30 +141,20 @@ impl KeysetSelector { } } -/// What an opening export decrypts through: the client, which opens a leaf -/// from any keyset (`{"any"}`), or one keyset's cipher, which opens only its -/// own leaves and refuses the rest before any key is retrieved. -pub enum Opener<'c, K> { - /// Leaves from any keyset: one batched retrieval per keyset the leaves - /// were sealed under, each chunked at the client's request limit. - Any(&'c StackCipher<K>), - /// Leaves from this keyset only. - Only(KeysetCipher<'c, K>), -} - -impl<'c, K> Opener<'c, K> { - /// The opener a decrypt-side selector names. - pub async fn for_selector( - cipher: &'c StackCipher<K>, - selector: &KeysetSelector, - ) -> Result<Self, u32> - where - K: IndexKeySource, - { - match selector { - KeysetSelector::Any => Ok(Opener::Any(cipher)), - other => other.resolve(cipher).await.map(Opener::Only), - } +/// The [`Opener`] a decrypt-side selector names: the client for `{"any"}`, +/// which opens a leaf sealed under any of its keysets, or one keyset's +/// cipher, which opens only its own and refuses the rest before any key is +/// retrieved. +pub async fn opener_for<'c, K>( + cipher: &'c StackCipher<K>, + selector: &KeysetSelector, +) -> Result<Opener<'c, K>, u32> +where + K: IndexKeySource, +{ + match selector { + KeysetSelector::Any => Ok(Opener::Any(cipher)), + other => other.resolve(cipher).await.map(Opener::Only), } } diff --git a/languages/golang/stackencrypt/guest/tests/native_ops.rs b/languages/golang/stackencrypt/guest/tests/native_ops.rs index 36d7e370a..87e2da377 100644 --- a/languages/golang/stackencrypt/guest/tests/native_ops.rs +++ b/languages/golang/stackencrypt/guest/tests/native_ops.rs @@ -17,10 +17,10 @@ use std::sync::atomic::{AtomicUsize, Ordering}; use std::future::IntoFuture; +use stack_encrypt::dynamic::Opener; use stack_encrypt::sem::DefaultMatch; use stack_encrypt::{nonempty, CipherText, Encrypt, SealedValue, StackCipher}; use stack_encrypt_guest::ops::{self, TERM_EQUALITY, TERM_MATCH, TERM_OPE, TERM_ORE}; -use stack_encrypt_guest::options::Opener; use stack_encrypt_guest::status::{STATUS_AUTH, STATUS_ENCODING, STATUS_FOREIGN_KEYSET}; use stack_kms::{ DataKey, DataKeySource, DataKeyWithTag, FakeDataKeySource, GenerateKeyPayload, IndexKeySource, diff --git a/packages/stack-encrypt/src/dynamic/mod.rs b/packages/stack-encrypt/src/dynamic/mod.rs index 60d13f064..3c27a35f0 100644 --- a/packages/stack-encrypt/src/dynamic/mod.rs +++ b/packages/stack-encrypt/src/dynamic/mod.rs @@ -20,6 +20,9 @@ //! //! * [`context`](context()) — an [`FfiValue`] read as an encryption context. //! * [`term`](term()) — one index term for a value, dispatched on its variant. +//! * [`record`] — the runtime form of `#[derive(EncryptFrom)]`: a *plan* +//! says per field what context to bind and what outputs to produce, and +//! the whole call seals from one batched key request. //! //! # What is not here //! @@ -37,11 +40,38 @@ //! them. Their long-term home is beside vitaminc's frozen tag table, which //! already owns this class of constant. mod context; +pub mod record; mod term; pub use context::{borrowed, context}; +pub use record::{FieldPlan, Output}; pub use term::{term, Scalar, TermKind}; +use crate::{KeysetCipher, StackCipher}; + +/// What an opening operation decrypts through. +/// +/// [`StackCipher`] and [`KeysetCipher`] both open, and neither is the +/// other's supertype: the client opens a leaf sealed under any of its +/// keysets, while a keyset handle opens only its own and refuses the rest +/// *before any key is retrieved*. That refusal is the point — a +/// tenant-scoped request handler must not open another tenant's row — so +/// the choice is named rather than inferred, and it is named here because a +/// binding's caller makes it at runtime. +pub enum Opener<'c, K> { + /// Leaves from any keyset the client holds: one batched retrieval per + /// keyset the leaves were sealed under. + Any(&'c StackCipher<K>), + /// Leaves from this keyset only. + Only(KeysetCipher<'c, K>), +} + +/// The UTF-8 inside a string leaf. Valid by `Utf8String`'s construction +/// invariant; checked rather than assumed because this is boundary code. +fn utf8(s: &vitaminc_aead_value::Utf8String) -> Option<&str> { + std::str::from_utf8(s.risky_ref()).ok() +} + /// What went wrong in a dynamic operation. /// /// The split that matters to a caller is malformed input versus a cipher @@ -70,6 +100,31 @@ pub enum Error { kind: TermKind, }, + /// A record plan is malformed: not an object of field specs, empty, + /// missing or duplicating an output, or carrying a key that is not + /// `"context"` or `"outputs"`. + #[error("record plan is malformed")] + Plan, + + /// A record source does not fit its plan: not an object (or an array of + /// them), a field the plan does not name, a plan field the source does + /// not carry, or a passthrough under a field the plan seals. + #[error("record source does not fit the plan")] + Source, + + /// A stored record does not fit its plan: not a map (or a sequence of + /// them), a ciphertext-bearing field that is absent or has no `"c"` + /// node, or a passthrough under `"c"` — which would hand back + /// unauthenticated bytes as if they had been opened. + #[error("stored record does not fit the plan")] + Record, + + /// An invariant this module maintains did not hold — a slot count that + /// did not line up, a re-proof that should not have been able to fail. + /// Always a bug here, never a statement about the caller's data. + #[error("internal invariant violated")] + Internal, + /// Sealing, opening or deriving failed. #[error(transparent)] Cipher(#[from] crate::Error), diff --git a/packages/stack-encrypt/src/dynamic/record.rs b/packages/stack-encrypt/src/dynamic/record.rs new file mode 100644 index 000000000..a249e704e --- /dev/null +++ b/packages/stack-encrypt/src/dynamic/record.rs @@ -0,0 +1,630 @@ +//! Records: the runtime form of `#[derive(EncryptFrom)]`. +//! +//! A *plan* says, per field, which encryption context to bind and which +//! outputs to produce; the source supplies the field values. That is the +//! same job the derive does from a struct definition, done from data — which +//! is all a binding has. +//! +//! However many rows and fields are in one call, all ciphertext leaves seal +//! from **one** batched `generate_keys`: the pendings are merged before +//! settling, exactly like the derive's `zip`/`all` composition. Index terms +//! are *not* in that batch — [`encrypt`] settles each term as it builds the +//! row, which under the local HMAC backend is no ZeroKMS traffic at all, and +//! under a backend that derives terms at ZeroKMS would be one round trip per +//! term until the term pendings are merged into the row's batch. That is a +//! change for this module when such a backend lands, not something the +//! record path promises today. +//! +//! # One context per field, both halves +//! +//! A field's context is proven [`NonEmpty`] once, when the plan is built, +//! and the same context drives the field's ciphertext and every one of its +//! terms. That is ADR-0004's property, held here by construction rather than +//! by a bound: [`encrypt`] never sees two contexts for one field, so it +//! cannot seal the value under one and index it under another. +//! +//! A plan context is the *whole* context of its field. There is no caller +//! context to extend it with, so the plan spells the extension itself: a +//! bare string matches a Rust record sealed with `encrypt_into` (no caller +//! context); a list matches one sealed with `encrypt_into_with_context` — +//! see [`super::context`](super::context()) for which list spells which Rust +//! context. Rows are readable across the two however they were sealed, +//! provided the plan names the context the row was sealed under. +//! +//! # Terms ride as passthrough +//! +//! A term is a comparand, not a ciphertext to open, and passthrough is its +//! honest encoding: the result tree carries each term as a +//! [`CipherText::Passthrough`] byte node beside the field's `"c"` subtree. +//! Under `"c"` itself a passthrough is refused in both directions, and that +//! is load-bearing — see [`reject_passthrough_tree`]. + +use stack_kms::DataKeySource; +use vitaminc_aead_value::FfiValue; +use vitaminc_protected::Protected; + +use super::{borrowed, term, utf8, Error, Opener, Scalar, TermKind}; +use crate::target::Pending; +use crate::{ + AadPiece, BoxedPassthrough, CipherText, Encrypt, KeysetCipher, NonEmpty, StackCipherText, +}; + +/// What a plan field asks for. +/// +/// The strings are wire format twice over: they are how a binding spells an +/// output, *and* the keys of the per-field output map in the stored result. +/// See the [module docs](super) on stability. +#[derive(Clone, Copy, PartialEq, Eq, Debug, Hash)] +pub enum Output { + /// `"c"` — the field's [`StackCipherText`]. + Ciphertext, + /// An index term: `"eq"`, `"match"`, `"ore"` or `"ope"`. + Term(TermKind), +} + +impl Output { + /// The output a key names, or `None` for a key that is not one. + pub fn parse(s: &str) -> Option<Self> { + Some(match s { + "c" => Output::Ciphertext, + "eq" => Output::Term(TermKind::Equality), + "match" => Output::Term(TermKind::Match), + "ore" => Output::Term(TermKind::Ore), + "ope" => Output::Term(TermKind::Ope), + _ => return None, + }) + } + + /// The map key this output rides under. + pub fn key(self) -> &'static str { + match self { + Output::Ciphertext => "c", + Output::Term(kind) => kind.key(), + } + } +} + +/// One field of a record plan: what to call it, what context to bind it +/// under, and what to produce for it. +#[derive(Clone, Debug)] +pub struct FieldPlan { + name: String, + context: NonEmpty<AadPiece<'static>>, + outputs: Vec<Output>, +} + +impl FieldPlan { + /// A field plan. + /// + /// The context is a proven [`NonEmpty`] because that proof has to happen + /// somewhere and here is the last place it can: the cipher-directed path + /// [`encrypt`] seals through accepts any AAD, so nothing downstream + /// would stop an empty context from being sealed under — and opening + /// goes through `decrypt_as`, which would then never open it. Build one + /// from a value with [`super::context`](super::context()). + /// + /// # Errors + /// + /// [`Error::Plan`] if `outputs` is empty or names an output twice. + pub fn new( + name: impl Into<String>, + context: NonEmpty<AadPiece<'static>>, + outputs: Vec<Output>, + ) -> Result<Self, Error> { + if outputs.is_empty() { + return Err(Error::Plan); + } + for (at, output) in outputs.iter().enumerate() { + if outputs[..at].contains(output) { + return Err(Error::Plan); + } + } + Ok(Self { + name: name.into(), + context, + outputs, + }) + } + + /// The field's name — its key in the source and in the result. + pub fn name(&self) -> &str { + &self.name + } + + /// The context this field binds under, on both halves. + pub fn context(&self) -> &NonEmpty<AadPiece<'static>> { + &self.context + } + + /// What the field produces. + pub fn outputs(&self) -> &[Output] { + &self.outputs + } + + /// Whether the field has a ciphertext to seal and open. + pub fn has_ciphertext(&self) -> bool { + self.outputs.contains(&Output::Ciphertext) + } + + /// A borrowed view of the context, so one proof serves every output of + /// every row without copying the payloads. + fn view(&self) -> Result<NonEmpty<AadPiece<'_>>, Error> { + // The proof was made when the plan was built, so re-taking it over + // the same tree cannot fail. + NonEmpty::new(borrowed(self.context.get())).map_err(|_| Error::Internal) + } +} + +/// Read a record plan from a decoded value. +/// +/// The plan is an [`FfiValue::Object`]: +/// +/// ```text +/// { <field>: { "context": <context>, "outputs": [ "c" | "eq" | "match" | "ore" | "ope", ... ] }, ... } +/// ``` +/// +/// `<context>` is defined once, in [`super::context`](super::context()): a +/// string, bytes, an integer, or a list of those, with what each spells in +/// Rust and the emptiness rule. +/// +/// # Errors +/// +/// [`Error::Plan`] for an empty plan, a missing or malformed context, an +/// empty, unknown or duplicated output list, or an unknown key. +/// [`Error::Context`] for a context that is malformed or empty. Field names +/// are unique by construction — the transport codec rejects duplicate object +/// keys before this sees them. +pub fn plan(value: FfiValue) -> Result<Vec<FieldPlan>, Error> { + let FfiValue::Object(entries) = value else { + return Err(Error::Plan); + }; + if entries.is_empty() { + return Err(Error::Plan); + } + entries + .into_iter() + .map(|(name, spec)| { + let FfiValue::Object(spec) = spec else { + return Err(Error::Plan); + }; + let mut context: Option<NonEmpty<AadPiece<'static>>> = None; + let mut outputs: Option<Vec<Output>> = None; + for (key, value) in spec { + match key.as_str() { + "context" => context = Some(super::context(value)?), + "outputs" => { + let FfiValue::Array(items) = value else { + return Err(Error::Plan); + }; + let mut parsed = Vec::with_capacity(items.len()); + for item in &items { + let FfiValue::String(s) = item else { + return Err(Error::Plan); + }; + let key = utf8(s).ok_or(Error::Plan)?; + parsed.push(Output::parse(key).ok_or(Error::Plan)?); + } + outputs = Some(parsed); + } + _ => return Err(Error::Plan), + } + } + FieldPlan::new( + name, + context.ok_or(Error::Plan)?, + outputs.ok_or(Error::Plan)?, + ) + }) + .collect() +} + +/// A row's assembled outputs, ciphertext slots still pending: the terms are +/// derived (locally), and each `None` is filled from the settled ciphertexts +/// in build order. +type RowSkeleton = Vec<(String, Vec<(&'static str, Option<Vec<u8>>)>)>; + +/// Encrypt a record — or a batch of records — per a plan. +/// +/// `source` is an [`FfiValue::Object`] of `{ field: scalar }` (one record), +/// or an [`FfiValue::Array`] of such objects (a batch). Every plan field +/// must be present in each record, and every record field must be named by +/// the plan — silently dropping a field on either side would lose data or +/// index nothing. +/// +/// The result is per record a map of `field → { output-key → node }`, where +/// `"c"` is the field's sealed ciphertext subtree and each term rides as a +/// passthrough byte node. A batch is a sequence of such maps. +/// +/// # Cross-language note +/// +/// A `"c"` leaf seals the aead-value *tagged* plaintext encoding (`[type +/// tag] ++ payload`), because that tag table is the contract the bindings +/// share. A Rust `#[derive(EncryptFrom)]` over a plain primitive — a bare +/// `u32` — seals four untagged bytes instead, so a plain-primitive Rust +/// derive and a plan do **not** interchange ciphertexts for the same field +/// until the Rust side uses aead-value's tagged types too. This is by +/// design, not a defect in either side. +/// +/// # Errors +/// +/// [`Error::Source`] if the source does not fit the plan; [`Error::Term`] +/// if a value has no term the plan asks for; [`Error::Cipher`] if sealing +/// or deriving fails. +pub async fn encrypt<K>( + cipher: &KeysetCipher<'_, K>, + source: FfiValue, + plan: &[FieldPlan], +) -> Result<StackCipherText, Error> +where + K: DataKeySource + Sync, +{ + let (rows, batched) = source_rows(source, plan)?; + + // Build every row: terms derive now (local), ciphertexts queue their + // data-key requests into one flat pending list. + let mut pendings: Vec<Pending<'_, StackCipherText, K>> = Vec::new(); + let mut skeletons: Vec<RowSkeleton> = Vec::with_capacity(rows.len()); + for row in rows { + skeletons.push(build_row(cipher, row, plan, &mut pendings).await?); + } + + // The one batched key request for the whole invocation. + let sealed = Pending::all(cipher, pendings).await?; + let mut sealed = sealed.into_iter(); + + // Fill the ciphertext slots back in, in build order. + let mut row_nodes = Vec::with_capacity(skeletons.len()); + for skeleton in skeletons { + let mut fields = Vec::with_capacity(skeleton.len()); + for (field, outputs) in skeleton { + let mut nodes = Vec::with_capacity(outputs.len()); + for (key, slot) in outputs { + let node = match slot { + Some(term) => CipherText::Passthrough(Box::new(FfiValue::Bytes(Protected::new( + term, + ))) + as BoxedPassthrough), + None => sealed.next().ok_or(Error::Internal)?, + }; + nodes.push((key.to_string(), node)); + } + fields.push((field, CipherText::Map(nodes))); + } + row_nodes.push(CipherText::Map(fields)); + } + if sealed.next().is_some() { + return Err(Error::Internal); + } + + if batched { + Ok(CipherText::Sequence(row_nodes)) + } else { + row_nodes.pop().ok_or(Error::Internal) + } +} + +/// Decrypt a record — or a batch — produced by [`encrypt`] under the same +/// plan. +/// +/// Only the `"c"` outputs participate: terms are one-way. The result is an +/// [`FfiValue::Object`] per record holding the plan's ciphertext-bearing +/// fields, in plan order — or an [`FfiValue::Array`] of them for a batch. +/// One batched `retrieve_keys` per invocation and, when opening through +/// [`Opener::Any`], one per keyset the leaves were sealed under. +/// +/// # Errors +/// +/// [`Error::Record`] if the stored tree does not fit the plan; +/// [`Error::Cipher`] if opening fails — including the expected outcome for +/// a wrong context, a wrong key or a tampered ciphertext. +pub async fn decrypt<K>( + opener: Opener<'_, K>, + record: StackCipherText, + plan: &[FieldPlan], +) -> Result<FfiValue, Error> +where + K: DataKeySource + Sync + 'static, +{ + let (rows, batched) = record_leaves(record, plan)?; + let contexts = plan + .iter() + .filter(|field| field.has_ciphertext()) + .map(FieldPlan::view) + .collect::<Result<Vec<_>, Error>>()?; + + // Per row, per ciphertext-bearing plan field (in plan order, as + // `record_leaves` lifted them): queue the "c" subtree's decrypt. + let mut pendings: Vec<Pending<'_, FfiValue, K>> = Vec::new(); + let mut names: Vec<Vec<String>> = Vec::with_capacity(rows.len()); + for row in rows { + if row.len() != contexts.len() { + return Err(Error::Internal); + } + let mut row_names = Vec::with_capacity(row.len()); + for ((name, ct), context) in row.into_iter().zip(&contexts) { + let context = context.clone(); + // The scope is the opener's, the declaration is the target's: + // `decrypt_as` takes one context and drives both halves with it. + pendings.push(match &opener { + Opener::Any(cipher) => cipher.decrypt_as(ct, context.into()), + Opener::Only(keyset) => keyset.decrypt_as(ct, context.into()), + }); + row_names.push(name); + } + names.push(row_names); + } + + // The one batched key request for the whole invocation. + let values = match &opener { + Opener::Any(cipher) => Pending::all(*cipher, pendings).await, + Opener::Only(keyset) => Pending::all(keyset, pendings).await, + }?; + let mut values = values.into_iter(); + + let mut row_values = Vec::with_capacity(names.len()); + for row_names in names { + let mut entries = Vec::with_capacity(row_names.len()); + for name in row_names { + entries.push((name, values.next().ok_or(Error::Internal)?)); + } + row_values.push(FfiValue::Object(entries)); + } + if values.next().is_some() { + return Err(Error::Internal); + } + + if batched { + Ok(FfiValue::Array(row_values)) + } else { + row_values.pop().ok_or(Error::Internal) + } +} + +/// Check a source against a plan without encrypting it — everything +/// [`encrypt`] checks before it consults the cipher. +/// +/// A binding runs this at its boundary so a malformed call fails the same +/// way whether or not a cipher is available, and never costs a keyset load. +/// It is the same parser [`encrypt`] runs, so the two cannot disagree on +/// what is malformed. +/// +/// # Errors +/// +/// As [`encrypt`], minus the cipher. +pub fn check_source(source: FfiValue, plan: &[FieldPlan]) -> Result<(), Error> { + source_rows(source, plan).map(drop) +} + +/// Check a stored record against a plan without opening it — everything +/// [`decrypt`] checks before it consults the cipher. See [`check_source`]. +/// +/// # Errors +/// +/// As [`decrypt`], minus the cipher. +pub fn check_record(record: StackCipherText, plan: &[FieldPlan]) -> Result<(), Error> { + record_leaves(record, plan).map(drop) +} + +/// The rows of a record source, each aligned to the plan's field order, with +/// everything that can be checked without a cipher checked: the source is +/// one object or an array of objects, every plan field is present in every +/// row and no row carries a field the plan does not name (silently dropping +/// a field on either side would lose data or index nothing), and each value +/// fits its field's outputs ([`check_field`]). The `bool` is whether the +/// source was a batch. +fn source_rows(source: FfiValue, plan: &[FieldPlan]) -> Result<(Vec<Vec<FfiValue>>, bool), Error> { + let (rows, batched) = match source { + FfiValue::Object(entries) => (vec![entries], false), + FfiValue::Array(items) => { + let rows = items + .into_iter() + .map(|item| match item { + FfiValue::Object(entries) => Ok(entries), + _ => Err(Error::Source), + }) + .collect::<Result<Vec<_>, Error>>()?; + (rows, true) + } + _ => return Err(Error::Source), + }; + let rows = rows + .into_iter() + .map(|mut row| { + if row.len() != plan.len() { + return Err(Error::Source); + } + plan.iter() + .map(|field| { + let at = row + .iter() + .position(|(name, _)| name == &field.name) + .ok_or(Error::Source)?; + let (_, value) = row.swap_remove(at); + check_field(&value, field)?; + Ok(value) + }) + .collect::<Result<Vec<_>, Error>>() + }) + .collect::<Result<Vec<_>, Error>>()?; + Ok((rows, batched)) +} + +/// A source value against its plan field: every term output needs a scalar +/// the scheme defines the term for ([`TermKind::supports`]), and a +/// ciphertext output refuses a passthrough anywhere in the value +/// ([`reject_passthrough_value`]). +fn check_field(value: &FfiValue, field: &FieldPlan) -> Result<(), Error> { + for output in &field.outputs { + match output { + Output::Ciphertext => reject_passthrough_value(value)?, + Output::Term(kind) => { + let scalar = Scalar::of(value, *kind)?; + if !kind.supports(&scalar) { + return Err(Error::Term { kind: *kind }); + } + } + } + } + Ok(()) +} + +/// The `"c"` subtrees a record tree holds for the plan's ciphertext-bearing +/// fields, per row in plan order, with the row's field name: the tree is one +/// map or a sequence of maps, each such field is present, is a map of +/// outputs with a `"c"` node, and that node is not a passthrough +/// ([`reject_passthrough_tree`]). Terms and fields the plan does not name +/// are ignored (comparands, not ciphertext). The `bool` is whether the tree +/// was a batch. +#[allow(clippy::type_complexity)] +fn record_leaves( + tree: StackCipherText, + plan: &[FieldPlan], +) -> Result<(Vec<Vec<(String, StackCipherText)>>, bool), Error> { + let (rows, batched) = match tree { + CipherText::Map(entries) => (vec![entries], false), + CipherText::Sequence(items) => { + let rows = items + .into_iter() + .map(|item| match item { + CipherText::Map(entries) => Ok(entries), + _ => Err(Error::Record), + }) + .collect::<Result<Vec<_>, Error>>()?; + (rows, true) + } + _ => return Err(Error::Record), + }; + let rows = rows + .into_iter() + .map(|mut row| { + plan.iter() + .filter(|field| field.has_ciphertext()) + .map(|field| { + let at = row + .iter() + .position(|(name, _)| name == &field.name) + .ok_or(Error::Record)?; + let (name, node) = row.swap_remove(at); + let CipherText::Map(outputs) = node else { + return Err(Error::Record); + }; + let ct = outputs + .into_iter() + .find_map(|(key, node)| (key == "c").then_some(node)) + .ok_or(Error::Record)?; + reject_passthrough_tree(&ct)?; + Ok((name, ct)) + }) + .collect::<Result<Vec<_>, Error>>() + }) + .collect::<Result<Vec<_>, Error>>()?; + Ok((rows, batched)) +} + +/// Build one record row: derive its terms and queue its ciphertext pendings, +/// returning the row skeleton. The plan drives the iteration so the output +/// field order is the plan's; the row arrives from [`source_rows`] already +/// in that order and checked against the plan. +async fn build_row<'c, K>( + cipher: &'c KeysetCipher<'_, K>, + row: Vec<FfiValue>, + plan: &[FieldPlan], + pendings: &mut Vec<Pending<'c, StackCipherText, K>>, +) -> Result<RowSkeleton, Error> +where + K: DataKeySource + Sync, +{ + // A row `source_rows` did not align is a bug here, not caller input. + if row.len() != plan.len() { + return Err(Error::Internal); + } + let mut skeleton = Vec::with_capacity(plan.len()); + for (field, value) in plan.iter().zip(row) { + let name = field.name.clone(); + // One borrowed view of the field's context, cloned per output: the + // same context reaches the ciphertext and every term (ADR-0004). + let context = field.view()?; + + // Terms first — they lift a copy of the scalar; the value itself is + // consumed by the ciphertext path below. One lift serves every term + // output: the kind only names which error a non-scalar reports. + let scalar = field + .outputs + .iter() + .find_map(|o| match o { + Output::Term(kind) => Some(*kind), + Output::Ciphertext => None, + }) + .map(|kind| Scalar::of(&value, kind)) + .transpose()?; + + let mut outputs: Vec<(&'static str, Option<Vec<u8>>)> = + Vec::with_capacity(field.outputs.len()); + for output in &field.outputs { + let Output::Term(kind) = output else { + outputs.push((output.key(), None)); + continue; + }; + let scalar = scalar.clone().ok_or(Error::Internal)?; + outputs.push(( + output.key(), + Some(term(cipher, scalar, *kind, context.clone()).await?), + )); + } + + if field.has_ciphertext() { + reject_passthrough_value(&value)?; + let tree = value + .encrypt_with_aad(cipher, context.clone()) + .map_err(|_| Error::Internal)?; + pendings.push(tree.into_pending(cipher, context)); + } + + skeleton.push((name, outputs)); + } + Ok(skeleton) +} + +/// Reject a source field value that contains a passthrough anywhere, before +/// it reaches a `"c"` slot. +/// +/// A passthrough node is *unauthenticated by definition* — on decrypt it +/// hands its payload back with no AEAD opened — so admitting one under a +/// field the plan declares ciphertext-bearing would quietly produce a slot +/// whose bytes verify nothing. Rejecting it here is what makes +/// [`reject_passthrough_tree`]'s mirror-image rejection a round-trip +/// invariant rather than data loss. +fn reject_passthrough_value(value: &FfiValue) -> Result<(), Error> { + match value { + FfiValue::Passthrough(_) => Err(Error::Source), + FfiValue::Array(items) => items.iter().try_for_each(reject_passthrough_value), + FfiValue::Object(entries) => entries + .iter() + .try_for_each(|(_, v)| reject_passthrough_value(v)), + _ => Ok(()), + } +} + +/// Reject a `"c"` subtree that contains a passthrough anywhere. +/// +/// This is the decrypt-side half of [`reject_passthrough_value`], and it is +/// load-bearing: `decrypt_as` collects **zero** retrieve-requests for a +/// passthrough and returns its payload with no AEAD opened, so an attacker +/// with write access to the stored tree could replace a field's `"c"` +/// subtree with a passthrough carrying forged plaintext, and this function's +/// absence would report it as a successful decrypt. [`encrypt`] never +/// produces a passthrough under `"c"`, so the shape is unconditionally an +/// error. +fn reject_passthrough_tree(tree: &StackCipherText) -> Result<(), Error> { + match tree { + CipherText::Passthrough(_) => Err(Error::Record), + CipherText::Sequence(items) => items.iter().try_for_each(reject_passthrough_tree), + CipherText::Map(entries) => entries + .iter() + .try_for_each(|(_, v)| reject_passthrough_tree(v)), + CipherText::Single(_) + | CipherText::None(_) + | CipherText::EmptySequence(_) + | CipherText::EmptyMap(_) => Ok(()), + } +} diff --git a/packages/stack-encrypt/src/dynamic/term.rs b/packages/stack-encrypt/src/dynamic/term.rs index 57547a113..b4e90ba85 100644 --- a/packages/stack-encrypt/src/dynamic/term.rs +++ b/packages/stack-encrypt/src/dynamic/term.rs @@ -17,11 +17,11 @@ use std::fmt; use stack_kms::DataKeySource; -use vitaminc_aead_value::{FfiValue, Utf8String}; +use vitaminc_aead_value::FfiValue; use vitaminc_protected::{Controlled, Protected}; use zeroize::Zeroizing; -use super::Error; +use super::{utf8, Error}; use crate::sem::{CllwOpeEncrypt, CllwOreEncrypt, DefaultMatch}; use crate::{IntoPrfContext, KeysetCipher, NonEmpty}; @@ -125,7 +125,9 @@ impl Scalar { FfiValue::UInt64(v) => Scalar::U64(*v), FfiValue::Float32(v) => Scalar::F32(*v), FfiValue::Float64(v) => Scalar::F64(*v), - FfiValue::String(s) => Scalar::Text(Zeroizing::new(text_of(s, kind)?.to_string())), + FfiValue::String(s) => Scalar::Text(Zeroizing::new( + utf8(s).ok_or(Error::Term { kind })?.to_string(), + )), FfiValue::Bytes(b) => Scalar::Bytes(Zeroizing::new(b.risky_ref().to_vec())), // Containers, nulls and passthroughs have no term semantics. _ => return Err(Error::Term { kind }), @@ -246,9 +248,3 @@ where .await .map(|t| t.as_ref().to_vec())?) } - -/// The UTF-8 inside a string leaf. Valid by `Utf8String`'s construction -/// invariant; checked rather than assumed because this is boundary code. -fn text_of(s: &Utf8String, kind: TermKind) -> Result<&str, Error> { - std::str::from_utf8(s.risky_ref()).map_err(|_| Error::Term { kind }) -} From 0e84f104fc29b30eaf722545e879a36b49c5251d Mon Sep 17 00:00:00 2001 From: Dan Draper <dan@cipherstash.com> Date: Mon, 14 Sep 2026 09:04:13 -0400 Subject: [PATCH 570/686] docs(stack-encrypt): the dynamic module's public items carry their own docs The context grammar lived on a private module, so rustdoc dropped it from the public page and the intra-doc links resolved to nothing. Put it on `context` itself, which is the item that has the contract, and re-export `FfiValue` so a binding author has one name for the value type. Claude-Session: https://claude.ai/code/session_019b9YUGNPBrj9UUX2aZ8RbA --- packages/stack-encrypt/src/dynamic/context.rs | 114 +++++++++--------- packages/stack-encrypt/src/dynamic/mod.rs | 5 + packages/stack-encrypt/src/dynamic/record.rs | 6 +- 3 files changed, 66 insertions(+), 59 deletions(-) diff --git a/packages/stack-encrypt/src/dynamic/context.rs b/packages/stack-encrypt/src/dynamic/context.rs index d72be448c..9881d4e8f 100644 --- a/packages/stack-encrypt/src/dynamic/context.rs +++ b/packages/stack-encrypt/src/dynamic/context.rs @@ -1,58 +1,4 @@ -//! An [`FfiValue`] read as an encryption context. -//! -//! A context arrives from a binding as a value and becomes an [`AadPiece`] -//! tree: vitaminc's runtime form of a context, and the *identity* of one. -//! vitaminc's law (pinned there by quickcheck over every built-in context -//! type) is that a context's two derivations each equal the same derivation -//! of its parts view: -//! -//! ```text -//! x.into_aad() == x.into_aad_piece().into_aad() -//! x.into_prf_context() == x.into_aad_piece().into_prf_context() -//! ``` -//! -//! So a `#[derive(EncryptFrom)]` row sealed with -//! `encrypt_into_with_context(row, 7u64)`, which binds each field under -//! `("users/age", 7u64)` — a `NonEmpty<(&str, u64)>` — and a binding that -//! spells the same context as `["users/age", 7u64]` agree byte for byte on -//! the AAD (the ciphertext binding and the ZeroKMS descriptor rendered from -//! its parts) *and* on the PRF context (the index terms' domain separation). -//! Nothing is re-derived here: the tree is handed to vitaminc's own impls. -//! -//! # Shape -//! -//! ```text -//! context := <string> | <bytes> | <i32> | <i64> | <u32> | <u64> | [ context, ... ] -//! ``` -//! -//! A bare string is one text part. An array is a list, and may nest as deep -//! as the transport codec allows -//! ([`MAX_DEPTH`](vitaminc_aead_value::transport::MAX_DEPTH) levels, counted -//! from the root of the encoded value); a deeper value is refused by the -//! codec before this module sees it. Text and bytes with the same content -//! are distinct on the PRF side (UTF-8 versus bytes encodings) though they -//! share AAD bytes — the same distinction the Rust types make. Booleans, -//! floats, null, undefined, objects and passthroughs are not contexts. -//! -//! # Which Rust contexts a list spells -//! -//! * `["users/age", 7u64]` is `nonempty!("users/age").with(7u64)`: a -//! two-element list is the pair. -//! * `NonEmpty::with` nests to the **left**: `nonempty!("a").with(7u64) -//! .with("eu")` is `(("a", 7u64), "eu")`, spelled `[["a", 7u64], "eu"]`. -//! A flat three-element list is a different context (a three-part PAE) -//! that no `.with()` chain produces. -//! * `[x]` is `Some(x)` and `[]` is `None`, on both derivations. A -//! one-element list is *not* the bare part: it is PAE-framed, the bare -//! part is not. -//! -//! # Emptiness -//! -//! [`context`](context()) returns a [`NonEmpty`], proven once here by -//! vitaminc's own rule for the tree: an empty string or byte string is -//! empty, an integer never is, and a list is empty when every part is (so -//! `[]` and `[""]` are, `["", 7]` is not) — the rule its `Option` and tuple -//! impls follow. +//! An [`FfiValue`] read as an encryption context. See [`context`](context()). use std::borrow::Cow; @@ -62,12 +8,64 @@ use vitaminc_protected::Controlled; use super::Error; use crate::{AadPiece, NonEmpty}; -/// Read a context from a decoded [`FfiValue`] and prove it non-empty. +/// A context arrives from a binding as a value and becomes an [`AadPiece`] +/// tree: vitaminc's runtime form of a context, and the *identity* of one. +/// vitaminc's law (pinned there by quickcheck over every built-in context +/// type) is that a context's two derivations each equal the same derivation +/// of its parts view: +/// +/// ```text +/// x.into_aad() == x.into_aad_piece().into_aad() +/// x.into_prf_context() == x.into_aad_piece().into_prf_context() +/// ``` +/// +/// So a `#[derive(EncryptFrom)]` row sealed with +/// `encrypt_into_with_context(row, 7u64)`, which binds each field under +/// `("users/age", 7u64)` — a `NonEmpty<(&str, u64)>` — and a binding that +/// spells the same context as `["users/age", 7u64]` agree byte for byte on +/// the AAD (the ciphertext binding and the ZeroKMS descriptor rendered from +/// its parts) *and* on the PRF context (the index terms' domain separation). +/// Nothing is re-derived here: the tree is handed to vitaminc's own impls. +/// +/// # Shape +/// +/// ```text +/// context := <string> | <bytes> | <i32> | <i64> | <u32> | <u64> | [ context, ... ] +/// ``` +/// +/// A bare string is one text part. An array is a list, and may nest as deep +/// as the transport codec allows +/// ([`MAX_DEPTH`](vitaminc_aead_value::transport::MAX_DEPTH) levels, counted +/// from the root of the encoded value); a deeper value is refused by the +/// codec before this module sees it. Text and bytes with the same content +/// are distinct on the PRF side (UTF-8 versus bytes encodings) though they +/// share AAD bytes — the same distinction the Rust types make. Booleans, +/// floats, null, undefined, objects and passthroughs are not contexts. +/// +/// # Which Rust contexts a list spells +/// +/// * `["users/age", 7u64]` is `nonempty!("users/age").with(7u64)`: a +/// two-element list is the pair. +/// * `NonEmpty::with` nests to the **left**: `nonempty!("a").with(7u64) +/// .with("eu")` is `(("a", 7u64), "eu")`, spelled `[["a", 7u64], "eu"]`. +/// A flat three-element list is a different context (a three-part PAE) +/// that no `.with()` chain produces. +/// * `[x]` is `Some(x)` and `[]` is `None`, on both derivations. A +/// one-element list is *not* the bare part: it is PAE-framed, the bare +/// part is not. +/// +/// # Emptiness +/// +/// [`context`](context()) returns a [`NonEmpty`], proven once here by +/// vitaminc's own rule for the tree: an empty string or byte string is +/// empty, an integer never is, and a list is empty when every part is (so +/// `[]` and `[""]` are, `["", 7]` is not) — the rule its `Option` and tuple +/// impls follow. /// /// # Errors /// -/// [`Error::Context`] for anything outside the shape in the [module -/// docs](self), and for a context that renders empty. +/// [`Error::Context`] for anything outside the shape above, and for a +/// context that renders empty. pub fn context(value: FfiValue) -> Result<NonEmpty<AadPiece<'static>>, Error> { NonEmpty::new(piece_of(value)?).map_err(|_| Error::Context) } diff --git a/packages/stack-encrypt/src/dynamic/mod.rs b/packages/stack-encrypt/src/dynamic/mod.rs index 3c27a35f0..2d4fb3d85 100644 --- a/packages/stack-encrypt/src/dynamic/mod.rs +++ b/packages/stack-encrypt/src/dynamic/mod.rs @@ -46,6 +46,11 @@ mod term; pub use context::{borrowed, context}; pub use record::{FieldPlan, Output}; pub use term::{term, Scalar, TermKind}; +/// vitaminc's language-neutral value tree — the runtime value every binding +/// funnels through. Its transport codec is `vitaminc_aead_value::transport`, +/// which stays the binding's: this crate takes and returns values, never +/// encoded bytes. +pub use vitaminc_aead_value::FfiValue; use crate::{KeysetCipher, StackCipher}; diff --git a/packages/stack-encrypt/src/dynamic/record.rs b/packages/stack-encrypt/src/dynamic/record.rs index a249e704e..f2ef1b99f 100644 --- a/packages/stack-encrypt/src/dynamic/record.rs +++ b/packages/stack-encrypt/src/dynamic/record.rs @@ -37,7 +37,11 @@ //! honest encoding: the result tree carries each term as a //! [`CipherText::Passthrough`] byte node beside the field's `"c"` subtree. //! Under `"c"` itself a passthrough is refused in both directions, and that -//! is load-bearing — see [`reject_passthrough_tree`]. +//! is load-bearing: `decrypt_as` collects **zero** retrieve-requests for a +//! passthrough and returns its payload with no AEAD opened, so without the +//! decrypt-side refusal an attacker with write access to the stored tree +//! could replace a field's `"c"` subtree with a passthrough carrying forged +//! plaintext and have it reported as a successful decrypt. use stack_kms::DataKeySource; use vitaminc_aead_value::FfiValue; From 08d432ee11ddb0222892de7ebb39761fb273ae17 Mon Sep 17 00:00:00 2001 From: Dan Draper <dan@cipherstash.com> Date: Mon, 14 Sep 2026 09:30:15 -0400 Subject: [PATCH 571/686] fix(wasi)!: every ZeroKMS request says who it is MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The guest sent two headers, `authorization` and `content-type`. The edge in front of production ZeroKMS answers a request with no `user-agent` with a bare nginx 403 — and answers a host runtime's generic default the same way, `Go-http-client/1.1` included. So no Go program has ever been able to reach production ZeroKMS through this binding: UA absent 403 (what the guest sent) UA "Go-http-client/1.1" 403 (what Go would add on its own) UA "stack-encrypt-go/0.1" 200 The native client has always set one, in `stack_kms::user_agent`, but that lives in `connection/http.rs` — reqwest — and the guest builds its own requests over the host transport, so it never passed through it. Nothing caught this because `host.rs` is `#[cfg(target_arch = "wasm32")]`: it is not compiled by `mise run lint` or by nextest, the guest tests drive an httptest stub that answers anything, and the live tests skip unless a harness that does not exist yet exports its four variables. So the fix moves the header set into `headers.rs`, which *is* compiled and tested natively, as `request_headers` — and pins it there with a test that asserts a request identifies itself and does not identify itself as a host runtime's default. Putting it in the guest rather than in the Go host is deliberate: every binding that drives this module gets it, which is the whole reason the module exists. Marked breaking because a host that pinned the exact header set will see a third header. BREAKING CHANGE: every ZeroKMS request the guest makes now carries a third header, `user-agent: stack-encrypt/<version> (Go)`. A host transport that pinned the exact header set — forwarding or matching only `authorization` and `content-type` — must let it through; without it ZeroKMS's edge answers a bare 403. Claude-Session: https://claude.ai/code/session_019b9YUGNPBrj9UUX2aZ8RbA --- .../golang/stackencrypt/guest/src/headers.rs | 53 +++++++++++++++++++ .../golang/stackencrypt/guest/src/host.rs | 7 +-- 2 files changed, 55 insertions(+), 5 deletions(-) diff --git a/languages/golang/stackencrypt/guest/src/headers.rs b/languages/golang/stackencrypt/guest/src/headers.rs index c79501f18..30f50d9a8 100644 --- a/languages/golang/stackencrypt/guest/src/headers.rs +++ b/languages/golang/stackencrypt/guest/src/headers.rs @@ -7,6 +7,36 @@ //! ASCII-case-insensitively, as in HTTP. Pure functions, unit-tested on the //! native target. +/// The `user-agent` every ZeroKMS request carries. +/// +/// Not optional, and not cosmetic: the edge in front of production ZeroKMS +/// refuses a request that arrives without one — and refuses a host +/// runtime's generic default too (`Go-http-client/1.1` is rejected) — with a +/// bare nginx 403 that never reaches the application. The native client sets +/// one in `stack_kms::user_agent`; the guest builds its own requests and +/// never goes through that path, so it has to say who it is here. +pub const USER_AGENT: &str = concat!( + "stack-encrypt-guest/", + env!("CARGO_PKG_VERSION"), + " (wasm32-wasip1)" +); + +/// The headers of a ZeroKMS request: the bearer credential, the content +/// type, and [`USER_AGENT`]. +/// +/// This lives here rather than at the call site because `host` is +/// `#[cfg(target_arch = "wasm32")]` and so is never compiled — let alone +/// tested — on the native target. The header set is the kind of thing that +/// fails in production and nowhere else, so it belongs in a module the test +/// suite can see. +pub fn request_headers(authorization: &str) -> Vec<u8> { + encode_headers(&[ + ("authorization", authorization), + ("content-type", "application/json"), + ("user-agent", USER_AGENT), + ]) +} + /// Encode header pairs as the wire buffer. pub fn encode_headers(headers: &[(&str, &str)]) -> Vec<u8> { let mut out = String::new(); @@ -69,6 +99,29 @@ mod tests { assert_eq!(header_value(b"", "content-type"), None); } + /// The edge in front of production ZeroKMS answers a request with no + /// `user-agent` with a bare nginx 403, before the application sees it. + /// Every request must carry one, and it must not be a host runtime's + /// generic default — those are refused too. + #[test] + fn every_request_identifies_itself() { + let headers = request_headers("Bearer tok"); + let ua = header_value(&headers, "user-agent").expect("requests carry a user-agent"); + assert!( + ua.starts_with("stack-encrypt-guest/"), + "the user-agent must name this guest, got {ua:?}" + ); + assert!( + !ua.contains("Go-http-client"), + "a host runtime's default user-agent is refused by the edge" + ); + assert_eq!(header_value(&headers, "authorization"), Some("Bearer tok")); + assert_eq!( + header_value(&headers, "content-type"), + Some("application/json") + ); + } + #[test] fn a_non_utf8_line_does_not_poison_the_other_headers() { let mut buffer = b"server: pro".to_vec(); diff --git a/languages/golang/stackencrypt/guest/src/host.rs b/languages/golang/stackencrypt/guest/src/host.rs index 6c363cc36..b86ec6cf9 100644 --- a/languages/golang/stackencrypt/guest/src/host.rs +++ b/languages/golang/stackencrypt/guest/src/host.rs @@ -45,7 +45,7 @@ use zeroize::Zeroizing; use zerokms_protocol::{ViturRequest, ViturRequestError}; use crate::buffers; -use crate::headers::{encode_headers, header_value}; +use crate::headers::{header_value, request_headers}; use crate::response::map_response; #[link(wasm_import_module = "cipherstash_transport")] @@ -158,10 +158,7 @@ impl ZeroKMSConnection for WasiHostConnection { ); // The bearer token is a credential; wipe the header buffer on drop. let auth = Zeroizing::new(format!("Bearer {access_token}")); - let headers = Zeroizing::new(encode_headers(&[ - ("authorization", auth.as_str()), - ("content-type", "application/json"), - ])); + let headers = Zeroizing::new(request_headers(auth.as_str())); let method = b"POST"; let mut resp_headers_ptr: u32 = 0; From 3b21c74ae158e0871e928a7c3053f8d0b878127f Mon Sep 17 00:00:00 2001 From: Dan Draper <dan@cipherstash.com> Date: Mon, 14 Sep 2026 09:30:16 -0400 Subject: [PATCH 572/686] docs(go): a runnable example over the developer profile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit There was no program in this repo that talked to ZeroKMS through the Go binding — the tests drive an httptest stub, and the live tests skip unless a harness that does not exist yet exports four variables. That is how the missing `user-agent` survived: nothing ever made a real request. `go run ./example` makes them. It walks the four things the binding does — seal a value, seal a record with its index terms, probe those terms with a query, open both again — and prints what crossed the boundary at each step, including the ones that are supposed to fail: a wrong AAD is refused at the key retrieval, and an equality term derived under another field's context matches nothing. Credentials come from the profile `stash auth login` writes, because the Go binding cannot read it for itself: `Config` takes a client key and a bearer token explicitly, and the Rust profile fallback is native-only (stack-kms gates it off wasm32, and the guest is wasm). `profile.go` is that reader, and it is most of the example's length — worth knowing, since it is what every Go application will otherwise write for itself. Claude-Session: https://claude.ai/code/session_019b9YUGNPBrj9UUX2aZ8RbA --- languages/golang/stackencrypt/example/main.go | 210 ++++++++++++++++++ .../golang/stackencrypt/example/profile.go | 99 +++++++++ 2 files changed, 309 insertions(+) create mode 100644 languages/golang/stackencrypt/example/main.go create mode 100644 languages/golang/stackencrypt/example/profile.go diff --git a/languages/golang/stackencrypt/example/main.go b/languages/golang/stackencrypt/example/main.go new file mode 100644 index 000000000..8f8ebf11f --- /dev/null +++ b/languages/golang/stackencrypt/example/main.go @@ -0,0 +1,210 @@ +// Command example exercises the stack-encrypt Go binding against real +// ZeroKMS, using the credentials `stash auth login` leaves in the developer +// profile. +// +// stash auth login +// mise run wasm:guest:build # the embedded guest must be current +// go run ./example +// +// It walks the four things the binding does — seal a value, seal a record +// with its index terms, probe those terms with a query, and open both again +// — and prints what crossed the boundary at each step. +package main + +import ( + "context" + "fmt" + "os" + "sort" + "time" + + "github.com/cipherstash/cipherstash-suite/bindings/go/stackencrypt" + "github.com/cipherstash/vitaminc/bindings/go/vcvalue" +) + +// A record type. The `stash` tag is the Go stand-in for Rust's +// `#[derive(EncryptFrom)]`: `context=` is the field's own encryption +// context, `index=` the terms to derive beside the ciphertext. +type user struct { + ID int64 `stash:"-"` + Email string `stash:"context=users/email,index=eq;match"` + Age uint32 `stash:"context=users/age,index=eq;ore"` +} + +func main() { + if err := run(); err != nil { + fmt.Fprintf(os.Stderr, "\nerror: %v\n", err) + os.Exit(1) + } +} + +func run() error { + creds, err := loadCredentials() + if err != nil { + return err + } + fmt.Printf("workspace %s (%s), token good for %s\n", + creds.Workspace, creds.Region, time.Until(creds.ExpiresAt).Round(time.Minute)) + + ctx := context.Background() + client, err := stackencrypt.NewClient(ctx, stackencrypt.Config{ + ClientID: creds.ClientID, + ClientKey: creds.ClientKey, + Token: stackencrypt.StaticToken(creds.Token), + // ZeroKMSURL is left empty: the endpoint is resolved from the + // token's services claim on first use. + }) + if err != nil { + return fmt.Errorf("connecting to ZeroKMS: %w", err) + } + // Close wipes the client key and every loaded index key inside the + // wasm instance. It is the point of the method, so it runs even on the + // error paths below. + defer func() { _ = client.Close(context.Background()) }() + + fmt.Printf("default keyset %s\n\n", client.DefaultKeysetID()) + + cipher := client.DefaultCipher() + if err := values(ctx, cipher); err != nil { + return err + } + records, err := recordsAndTerms(ctx, cipher) + if err != nil { + return err + } + return ordering(records) +} + +// A whole value, sealed under an AAD of the caller's choosing. The shape of +// the ciphertext mirrors the plaintext, and a field marked Plain rides +// alongside it in the clear. +func values(ctx context.Context, cipher *stackencrypt.Cipher) error { + section("a value") + + aad := []byte("users/v1") + in := map[string]any{ + "name": "alice", + "age": uint32(34), + "note": vcvalue.Plain{V: "not secret"}, + } + fmt.Printf(" plaintext %v\n", in) + + sealed, err := cipher.Encrypt(ctx, in, aad) + if err != nil { + return fmt.Errorf("encrypting a value: %w", err) + } + for name, node := range sealed.(map[string]any) { + if leaf, ok := node.(stackencrypt.Sealed); ok { + fmt.Printf(" %-11s %d bytes of ciphertext\n", name, len(leaf)) + } else { + fmt.Printf(" %-11s %v (passthrough — in the clear, and unauthenticated)\n", name, node) + } + } + + opened, err := cipher.Decrypt(ctx, sealed, aad) + if err != nil { + return fmt.Errorf("decrypting a value: %w", err) + } + fmt.Printf(" opened %v\n", opened) + + // The AAD is bound into the key as well as the ciphertext, so the wrong + // one does not open the value — it is refused, not silently wrong. + if _, err := cipher.Decrypt(ctx, sealed, []byte("some other context")); err == nil { + return fmt.Errorf("a value opened under an AAD it was not sealed under") + } else { + fmt.Printf(" wrong AAD refused: %v\n", err) + } + return nil +} + +// A record: every field sealed under its own context, with the index terms +// its tag asked for, and all of it from one batched ZeroKMS request. +func recordsAndTerms(ctx context.Context, cipher *stackencrypt.Cipher) ([]stackencrypt.EncryptedRecord, error) { + section("records, and the terms that index them") + + users := []user{ + {ID: 1, Email: "alice@example.com", Age: 34}, + {ID: 2, Email: "bob@example.com", Age: 29}, + {ID: 3, Email: "carol@example.com", Age: 41}, + } + records, err := cipher.EncryptRecords(ctx, users) + if err != nil { + return nil, fmt.Errorf("encrypting records: %w", err) + } + fmt.Printf(" %d rows sealed in one batched key request\n", len(records)) + for i, r := range records { + fmt.Printf(" row %d Email: %d-byte ciphertext, eq %x…, match %d positions\n", + i, len(r["Email"].Ciphertext.(stackencrypt.Sealed)), r["Email"].Equality[:6], countPositions(r["Email"].Match)) + fmt.Printf(" Age: %d-byte ciphertext, eq %x…, ore %d bytes\n", + len(r["Age"].Ciphertext.(stackencrypt.Sealed)), r["Age"].Equality[:6], len(r["Age"].Ore)) + } + + // A query probe: the same derivation as the stored term, from the value + // being searched for. It never touches the ciphertext — matching is what + // the term is for. + fmt.Println() + probe, err := cipher.Term(ctx, "bob@example.com", stackencrypt.MustContext("users/email"), stackencrypt.Equality) + if err != nil { + return nil, fmt.Errorf("deriving a probe: %w", err) + } + for i, r := range records { + if probe.(stackencrypt.EqualityTerm).Equal(r["Email"].Equality) { + fmt.Printf(" probe for bob@example.com matches row %d\n", i) + } + } + + // A term is bound to its context. The same value under another field's + // context is a different term, which is what stops a match in one column + // from being a match in another. + wrong, err := cipher.Term(ctx, "bob@example.com", stackencrypt.MustContext("users/name"), stackencrypt.Equality) + if err != nil { + return nil, fmt.Errorf("deriving a probe: %w", err) + } + fmt.Printf(" the same value under users/name matches nothing: %t\n", + !wrong.(stackencrypt.EqualityTerm).Equal(records[1]["Email"].Equality)) + + var back []user + if err := cipher.DecryptRecords(ctx, records, &back); err != nil { + return nil, fmt.Errorf("decrypting records: %w", err) + } + fmt.Printf("\n opened %v\n", back) + fmt.Printf(" (ID is tagged `-`, so it never crossed the boundary and comes back zero)\n") + return records, nil +} + +// ORE terms compare in the plaintext's order without revealing it: sorting +// the rows by their Age term sorts them by age. +func ordering(records []stackencrypt.EncryptedRecord) error { + section("order, without the values") + + order := []int{0, 1, 2} + sort.Slice(order, func(i, j int) bool { + return records[order[i]]["Age"].Ore.Less(records[order[j]]["Age"].Ore) + }) + fmt.Printf(" rows sorted by their Age ORE terms: %v\n", order) + fmt.Printf(" (ages were 34, 29, 41 — so ascending age is row 1, 0, 2)\n") + return nil +} + +func countPositions(t stackencrypt.MatchTerm) int { + positions, err := t.Positions() + if err != nil { + return -1 + } + return len(positions) +} + +func section(title string) { + fmt.Printf("── %s %s\n", title, dashes(60-len(title))) +} + +func dashes(n int) string { + if n < 0 { + n = 0 + } + out := make([]byte, 0, n*3) + for range n { + out = append(out, "─"...) + } + return string(out) +} diff --git a/languages/golang/stackencrypt/example/profile.go b/languages/golang/stackencrypt/example/profile.go new file mode 100644 index 000000000..8efe4e101 --- /dev/null +++ b/languages/golang/stackencrypt/example/profile.go @@ -0,0 +1,99 @@ +package main + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + "time" +) + +// Credentials from the developer profile `stash auth login` writes. +// +// The Go binding takes a client key and a bearer token explicitly: the +// profile fallback in the Rust crate is native-only (stack-kms gates it off +// wasm32), and the guest is wasm. So a Go program reads the profile itself, +// which is all this file does. +// +// The layout, as stack-profile defines it: +// +// <root>/current_workspace the workspace id +// <root>/workspaces/<id>/secretkey.json client_id, client_key +// <root>/workspaces/<id>/auth.json access_token, expires_at +// +// where <root> is CS_CONFIG_PATH if set, else ~/.cipherstash. +type credentials struct { + ClientID string + ClientKey string + Token string + Workspace string + Region string + ExpiresAt time.Time +} + +type secretKeyFile struct { + ClientID string `json:"client_id"` + ClientKey string `json:"client_key"` +} + +type authFile struct { + AccessToken string `json:"access_token"` + ExpiresAt int64 `json:"expires_at"` + Region string `json:"region"` +} + +func loadCredentials() (credentials, error) { + var c credentials + + root := os.Getenv("CS_CONFIG_PATH") + if strings.TrimSpace(root) == "" { + home, err := os.UserHomeDir() + if err != nil { + return c, fmt.Errorf("no home directory and CS_CONFIG_PATH is unset: %w", err) + } + root = filepath.Join(home, ".cipherstash") + } + + workspace, err := os.ReadFile(filepath.Join(root, "current_workspace")) + if err != nil { + return c, fmt.Errorf("no current workspace in %s — run `stash auth login`: %w", root, err) + } + c.Workspace = strings.TrimSpace(string(workspace)) + dir := filepath.Join(root, "workspaces", c.Workspace) + + // The client key: the two environment variables win, as they do for the + // Rust client, so this example can be pointed somewhere else without + // touching the profile. + c.ClientID, c.ClientKey = os.Getenv("CS_CLIENT_ID"), os.Getenv("CS_CLIENT_KEY") + if c.ClientID == "" || c.ClientKey == "" { + var key secretKeyFile + if err := readJSON(filepath.Join(dir, "secretkey.json"), &key); err != nil { + return c, fmt.Errorf("no client key — set CS_CLIENT_ID and CS_CLIENT_KEY, or run `stash auth login`: %w", err) + } + c.ClientID, c.ClientKey = key.ClientID, key.ClientKey + } + + // The token comes from the profile only. There is no environment + // variable for it: CS_CLIENT_ACCESS_KEY is an access *key*, which has to + // be exchanged for a bearer token first, and this example does not do + // that exchange. + var auth authFile + if err := readJSON(filepath.Join(dir, "auth.json"), &auth); err != nil { + return c, fmt.Errorf("no access token in %s — run `stash auth login`: %w", dir, err) + } + c.Token, c.Region = auth.AccessToken, auth.Region + c.ExpiresAt = time.Unix(auth.ExpiresAt, 0) + if time.Now().After(c.ExpiresAt) { + return c, fmt.Errorf("the profile's access token expired at %s — run `stash auth login`", c.ExpiresAt.Format(time.RFC3339)) + } + return c, nil +} + +func readJSON(path string, into any) error { + b, err := os.ReadFile(path) + if err != nil { + return err + } + return json.Unmarshal(b, into) +} From fd6206261a6e324d399b58ee25f7a4d699a6a4fe Mon Sep 17 00:00:00 2001 From: Dan Draper <dan@cipherstash.com> Date: Mon, 14 Sep 2026 09:36:05 -0400 Subject: [PATCH 573/686] refactor(wasi): the user-agent names the library, not the shim MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `stack-encrypt-guest/0.0.0 (wasm32-wasip1)` said the wrong two things: the guest crate is unversioned, so the number was noise, and nobody reading a ZeroKMS log cares which shim carried the request — they care which library made it and from where. Now `stack-encrypt/0.1.0 (Go)`, from a new `stack_encrypt::VERSION`. A report of "stack-encrypt 0.1.0" then means the same thing whether it came from Rust, from here, or from a native cdylib under Python. `HOST` is a constant because today there is one build. cipherstash/cipherstash-suite#2209 builds the same ABI crate as this WASI guest *and* as a native cdylib, at which point it becomes per-build; letting an application contribute its own token after ours is parked on purpose, so what ships now is one string this crate controls rather than an extension point with a single user. All three spellings verified against the production edge before choosing. Claude-Session: https://claude.ai/code/session_019b9YUGNPBrj9UUX2aZ8RbA --- .../golang/stackencrypt/guest/src/headers.rs | 38 ++++++++++++++----- packages/stack-encrypt/src/lib.rs | 9 +++++ 2 files changed, 37 insertions(+), 10 deletions(-) diff --git a/languages/golang/stackencrypt/guest/src/headers.rs b/languages/golang/stackencrypt/guest/src/headers.rs index 30f50d9a8..f43021685 100644 --- a/languages/golang/stackencrypt/guest/src/headers.rs +++ b/languages/golang/stackencrypt/guest/src/headers.rs @@ -7,6 +7,19 @@ //! ASCII-case-insensitively, as in HTTP. Pure functions, unit-tested on the //! native target. +use std::sync::OnceLock; + +/// The host this guest is driven by, as it appears in [`user_agent`]. +/// +/// One token, because today there is one build. `stack-encrypt-ffi` (the +/// plan in #2209) builds the same ABI crate as this WASI guest *and* as a +/// native cdylib for C, C++ and Python, at which point this becomes a +/// per-build value rather than a constant. Letting the host contribute its +/// own token as well — an application's `myapp/1.0` after ours — is a +/// deliberate follow-up: what ships now is one string this crate controls, +/// not an extension point with a single user. +const HOST: &str = "Go"; + /// The `user-agent` every ZeroKMS request carries. /// /// Not optional, and not cosmetic: the edge in front of production ZeroKMS @@ -15,14 +28,18 @@ /// bare nginx 403 that never reaches the application. The native client sets /// one in `stack_kms::user_agent`; the guest builds its own requests and /// never goes through that path, so it has to say who it is here. -pub const USER_AGENT: &str = concat!( - "stack-encrypt-guest/", - env!("CARGO_PKG_VERSION"), - " (wasm32-wasip1)" -); +/// +/// It names the *library* and the host carrying it, not this crate: a +/// report of "stack-encrypt 0.1.0" means the same thing from Rust, from +/// here, or from a native cdylib, and the guest shim's own version number +/// would say nothing anyone reading a log wants to know. +pub fn user_agent() -> &'static str { + static USER_AGENT: OnceLock<String> = OnceLock::new(); + USER_AGENT.get_or_init(|| format!("stack-encrypt/{} ({HOST})", stack_encrypt::VERSION)) +} /// The headers of a ZeroKMS request: the bearer credential, the content -/// type, and [`USER_AGENT`]. +/// type, and [`user_agent`]. /// /// This lives here rather than at the call site because `host` is /// `#[cfg(target_arch = "wasm32")]` and so is never compiled — let alone @@ -33,7 +50,7 @@ pub fn request_headers(authorization: &str) -> Vec<u8> { encode_headers(&[ ("authorization", authorization), ("content-type", "application/json"), - ("user-agent", USER_AGENT), + ("user-agent", user_agent()), ]) } @@ -107,9 +124,10 @@ mod tests { fn every_request_identifies_itself() { let headers = request_headers("Bearer tok"); let ua = header_value(&headers, "user-agent").expect("requests carry a user-agent"); - assert!( - ua.starts_with("stack-encrypt-guest/"), - "the user-agent must name this guest, got {ua:?}" + assert_eq!( + ua, + format!("stack-encrypt/{} (Go)", stack_encrypt::VERSION), + "the user-agent names the library and the host carrying it" ); assert!( !ua.contains("Go-http-client"), diff --git a/packages/stack-encrypt/src/lib.rs b/packages/stack-encrypt/src/lib.rs index a3f223fda..6b7428943 100644 --- a/packages/stack-encrypt/src/lib.rs +++ b/packages/stack-encrypt/src/lib.rs @@ -255,6 +255,15 @@ endpoint — are `StackKmsBuilder`'s, and the two keyset-cache knobs are //! vitaminc are re-exported here. The [`cipher`] module docs describe the //! internals (batching, AAD derivation, wire format). +/// This crate's version, as it appears in the `user-agent` of every ZeroKMS +/// request. +/// +/// Requests are identified by the library that makes them, not by whichever +/// binding shim is carrying it: a report of "stack-encrypt 0.1.0" means the +/// same thing whether it came from Rust, from the WASI guest under Go, or +/// from a native cdylib under Python. +pub const VERSION: &str = env!("CARGO_PKG_VERSION"); + pub mod cipher; pub mod descriptor; #[cfg(feature = "dynamic")] From f268ed5a10eb67bedde95acb80feefe5a989aad1 Mon Sep 17 00:00:00 2001 From: Dan Draper <dan@cipherstash.com> Date: Mon, 14 Sep 2026 09:52:45 -0400 Subject: [PATCH 574/686] docs(go): the example follows the profile's token instead of pinning one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `StaticToken` was the wrong thing to put in an example. The binding asks its `TokenSource` on *every* request precisely so a token can change under a long-lived client, and a profile token is good for 45 minutes — so pinning one demonstrates a program that works and then stops. The example now re-reads `auth.json` per call, picking up whatever else keeps the profile fresh. It deliberately does not refresh: the IdP rotates refresh tokens and detects replay, so two processes sharing `~/.cipherstash` that both exchange the same one get the whole chain revoked — every later attempt fails with `invalid grant` until the user logs in again. Rust handles that with a cross-process lock and a re-read after acquiring it (`stack-auth`'s `device_session_refresher`); an example that hand-rolled it would be risking a reader's real credentials, so this one reads and never writes. Also adds `mise run go:stackencrypt:example`, which depends on `wasm:guest:build` so the embedded guest cannot be stale, and a README — which says plainly that `profile.go` is half the example and none of it is library code. `Config` takes a client key and a token explicitly and has no profile or environment fallback: the Rust one is native-only, since stack-kms gates `profile` off wasm32 and the guest is wasm. Every Go application will otherwise write that file for itself, which is the argument for it moving into the package. Claude-Session: https://claude.ai/code/session_019b9YUGNPBrj9UUX2aZ8RbA --- .../golang/stackencrypt/example/README.md | 67 ++++++++++++++++ languages/golang/stackencrypt/example/main.go | 8 +- .../golang/stackencrypt/example/profile.go | 79 ++++++++++++++----- 3 files changed, 130 insertions(+), 24 deletions(-) create mode 100644 languages/golang/stackencrypt/example/README.md diff --git a/languages/golang/stackencrypt/example/README.md b/languages/golang/stackencrypt/example/README.md new file mode 100644 index 000000000..b81c9b2a7 --- /dev/null +++ b/languages/golang/stackencrypt/example/README.md @@ -0,0 +1,67 @@ +# stack-encrypt Go example + +A runnable tour of the Go binding against real ZeroKMS: seal a value, seal a +record with its index terms, probe those terms with a query, open both again. +It prints what crossed the boundary at each step, including the steps that +are meant to fail. + +## Running it + +```bash +stash auth login # once; the example reads ~/.cipherstash +mise run go:stackencrypt:example # builds the guest, then runs +``` + +Or, if you would rather drive it yourself: + +```bash +mise run wasm:guest:build +cd bindings/go/stackencrypt && go run ./example +``` + +The guest build is not optional. This package embeds +`wasm/stack_encrypt_guest.wasm`, which is gitignored, so a fresh checkout has +no guest and `NewClient` fails until one is built — and the Go side will not +notice a stale one, so rebuild after any change under `guest/src/`. + +## What it shows + +| | | +|---|---| +| **A value** | An arbitrary map sealed under a caller-chosen AAD. A `vcvalue.Plain` field rides alongside in the clear. Opening under the wrong AAD is refused — at the *key retrieval*, not the AEAD, because every data key is bound to its context. | +| **A record** | A struct's `stash` tags drive a plan: each field sealed under its own context, with the index terms it asked for. Three rows, one batched ZeroKMS request. | +| **A query** | An equality term derived from the value being searched for, matched against the stored terms. The same value under another field's context matches nothing — that is what stops a hit in one column being a hit in another. | +| **Order** | ORE terms sorted, recovering the plaintext order without the plaintext. | + +## Credentials, and a gap worth knowing about + +`profile.go` is about half this example, and none of it is library code: it +reads `~/.cipherstash` by hand. + +That is not an oversight in the example. The Go binding's `Config` takes a +client id, a client key and a `TokenSource` explicitly, and has no profile or +environment fallback of its own. The Rust crate's fallback is native-only — +`stack-kms` gates the `profile` feature off `wasm32`, and the guest is wasm — +so nothing in that path is reachable from here. **Every Go application will +otherwise write this file for itself**, which is the argument for it moving +into the package. + +Two details in there that are easy to get wrong: + +- **The token is not static.** `TokenSource.Token` is called on *every* + request, precisely so a token can change under a long-lived client. A + profile token lasts 45 minutes, so pinning one with `StaticToken` gives you + a program that works and then stops. The example re-reads `auth.json` each + time instead, picking up whatever else refreshes it. +- **It never refreshes, on purpose.** The IdP rotates refresh tokens and + detects replay: two processes sharing `~/.cipherstash` that both exchange + the same one get the entire chain revoked, and every later attempt fails + with `invalid grant` until the user logs in again. Rust handles this with a + cross-process lock and a re-read after acquiring it (see + `stack-auth`'s `device_session_refresher`). Hand-rolling it here would put + a reader's real credentials at risk, so this example reads and never + writes. + +`CS_CLIENT_ID` / `CS_CLIENT_KEY` override the profile's client key. +`CS_CONFIG_PATH` overrides the profile directory. The token always comes from +the profile. diff --git a/languages/golang/stackencrypt/example/main.go b/languages/golang/stackencrypt/example/main.go index 8f8ebf11f..f4299f7ca 100644 --- a/languages/golang/stackencrypt/example/main.go +++ b/languages/golang/stackencrypt/example/main.go @@ -16,7 +16,6 @@ import ( "fmt" "os" "sort" - "time" "github.com/cipherstash/cipherstash-suite/bindings/go/stackencrypt" "github.com/cipherstash/vitaminc/bindings/go/vcvalue" @@ -43,14 +42,15 @@ func run() error { if err != nil { return err } - fmt.Printf("workspace %s (%s), token good for %s\n", - creds.Workspace, creds.Region, time.Until(creds.ExpiresAt).Round(time.Minute)) + fmt.Printf("workspace %s (%s)\n", creds.Workspace, creds.describe()) ctx := context.Background() client, err := stackencrypt.NewClient(ctx, stackencrypt.Config{ ClientID: creds.ClientID, ClientKey: creds.ClientKey, - Token: stackencrypt.StaticToken(creds.Token), + // Asked on every request, so the client follows the profile + // rather than pinning one token; see profile.go. + Token: creds.token(), // ZeroKMSURL is left empty: the endpoint is resolved from the // token's services claim on first use. }) diff --git a/languages/golang/stackencrypt/example/profile.go b/languages/golang/stackencrypt/example/profile.go index 8efe4e101..e53466b51 100644 --- a/languages/golang/stackencrypt/example/profile.go +++ b/languages/golang/stackencrypt/example/profile.go @@ -1,6 +1,7 @@ package main import ( + "context" "encoding/json" "fmt" "os" @@ -11,10 +12,11 @@ import ( // Credentials from the developer profile `stash auth login` writes. // -// The Go binding takes a client key and a bearer token explicitly: the -// profile fallback in the Rust crate is native-only (stack-kms gates it off -// wasm32), and the guest is wasm. So a Go program reads the profile itself, -// which is all this file does. +// The Go binding takes a client key and a bearer token explicitly. The +// profile fallback in the Rust crate is native-only — stack-kms gates it off +// wasm32 and the guest is wasm — so a Go program reads the profile itself, +// which is all this file does. There is no supported package-level +// equivalent yet; see the README. // // The layout, as stack-profile defines it: // @@ -26,10 +28,8 @@ import ( type credentials struct { ClientID string ClientKey string - Token string Workspace string - Region string - ExpiresAt time.Time + dir string } type secretKeyFile struct { @@ -60,7 +60,7 @@ func loadCredentials() (credentials, error) { return c, fmt.Errorf("no current workspace in %s — run `stash auth login`: %w", root, err) } c.Workspace = strings.TrimSpace(string(workspace)) - dir := filepath.Join(root, "workspaces", c.Workspace) + c.dir = filepath.Join(root, "workspaces", c.Workspace) // The client key: the two environment variables win, as they do for the // Rust client, so this example can be pointed somewhere else without @@ -68,26 +68,65 @@ func loadCredentials() (credentials, error) { c.ClientID, c.ClientKey = os.Getenv("CS_CLIENT_ID"), os.Getenv("CS_CLIENT_KEY") if c.ClientID == "" || c.ClientKey == "" { var key secretKeyFile - if err := readJSON(filepath.Join(dir, "secretkey.json"), &key); err != nil { + if err := readJSON(filepath.Join(c.dir, "secretkey.json"), &key); err != nil { return c, fmt.Errorf("no client key — set CS_CLIENT_ID and CS_CLIENT_KEY, or run `stash auth login`: %w", err) } c.ClientID, c.ClientKey = key.ClientID, key.ClientKey } - // The token comes from the profile only. There is no environment - // variable for it: CS_CLIENT_ACCESS_KEY is an access *key*, which has to - // be exchanged for a bearer token first, and this example does not do - // that exchange. + // Fail here rather than three calls later, with something actionable. + if _, err := c.token().Token(context.Background()); err != nil { + return c, err + } + return c, nil +} + +// token is the [stackencrypt.TokenSource] this example authenticates with. +// +// Deliberately not StaticToken: the binding asks its TokenSource on *every* +// request precisely so that a token can change under a long-lived client, +// and a profile token is good for 45 minutes. A static one turns that into +// a program that works and then stops, which is the wrong thing to show. +// +// What it does instead is *follow* the profile: it re-reads auth.json each +// time, so whatever keeps the profile fresh — the `stash` CLI, a Rust +// client in the same session — is picked up without restarting. +// +// What it deliberately does not do is refresh. Exchanging the refresh token +// is not a few lines: the IdP rotates refresh tokens and detects replay, so +// two processes sharing ~/.cipherstash that both exchange the same one get +// the whole chain revoked — every later login fails with "invalid grant" +// until the user logs in again. The Rust side handles that with a +// cross-process lock and a re-read after acquiring it +// (stack-auth's `device_session_refresher`). An example that hand-rolled it +// would risk a reader's real credentials, so this one reads and never +// writes. +func (c credentials) token() stackencryptTokenSource { + return stackencryptTokenSource{path: filepath.Join(c.dir, "auth.json")} +} + +type stackencryptTokenSource struct{ path string } + +func (s stackencryptTokenSource) Token(context.Context) (string, error) { var auth authFile - if err := readJSON(filepath.Join(dir, "auth.json"), &auth); err != nil { - return c, fmt.Errorf("no access token in %s — run `stash auth login`: %w", dir, err) + if err := readJSON(s.path, &auth); err != nil { + return "", fmt.Errorf("no access token in %s — run `stash auth login`: %w", s.path, err) } - c.Token, c.Region = auth.AccessToken, auth.Region - c.ExpiresAt = time.Unix(auth.ExpiresAt, 0) - if time.Now().After(c.ExpiresAt) { - return c, fmt.Errorf("the profile's access token expired at %s — run `stash auth login`", c.ExpiresAt.Format(time.RFC3339)) + if expiry := time.Unix(auth.ExpiresAt, 0); time.Now().After(expiry) { + return "", fmt.Errorf("the profile's access token expired at %s — run `stash auth login`", + expiry.Format(time.RFC3339)) } - return c, nil + return auth.AccessToken, nil +} + +// describe reports what the profile currently holds, for the banner. +func (c credentials) describe() string { + var auth authFile + if err := readJSON(filepath.Join(c.dir, "auth.json"), &auth); err != nil { + return "unknown" + } + return fmt.Sprintf("%s, token good for %s", + auth.Region, time.Until(time.Unix(auth.ExpiresAt, 0)).Round(time.Minute)) } func readJSON(path string, into any) error { From 4eca152554fa9c209bd648d0220d9b4b58c5d7f5 Mon Sep 17 00:00:00 2001 From: Dan Draper <dan@cipherstash.com> Date: Thu, 17 Sep 2026 20:17:54 -0400 Subject: [PATCH 575/686] test(stack-encrypt): the derive diagnostics are recorded with every feature on `test:unit` and CI run the trybuild cases with `--all-features`, and the expectations cannot hold under both feature sets: rustc lists the other implementors of a trait, so the `dynamic` feature's `FfiValue` joins one help list and pushes `Option<T>` off it, and its `Opener::Any` variant makes rustc stop trimming `std::any::Any`. Record the expectations the way CI sees them, and skip the case with a stated reason when `dynamic` is off rather than failing it. --- packages/stack-encrypt/tests/ui.rs | 12 ++++++++++++ .../tests/ui/decrypt_field_not_decryptable.stderr | 4 ++-- packages/stack-encrypt/tests/ui/drop_record.stderr | 2 +- .../tests/ui/nested_leaf_without_context.stderr | 2 +- .../tests/ui/plaintext_needs_encrypt.stderr | 4 ++-- 5 files changed, 18 insertions(+), 6 deletions(-) diff --git a/packages/stack-encrypt/tests/ui.rs b/packages/stack-encrypt/tests/ui.rs index ef7cfbf80..75349668c 100644 --- a/packages/stack-encrypt/tests/ui.rs +++ b/packages/stack-encrypt/tests/ui.rs @@ -1,8 +1,20 @@ //! Compile-fail tests for the derive diagnostics: every `tests/ui/*.rs` must //! fail to compile with exactly the `.stderr` beside it. Regenerate the //! expectations after a deliberate message change with `TRYBUILD=overwrite`. +//! +//! The expectations are recorded with every feature on, as `test:unit` and +//! CI run them (`--all-features`). They cannot hold under both feature sets: +//! rustc lists the other implementors of a trait, so the `dynamic` feature's +//! `FfiValue` joins one help list and pushes another entry off it, and its +//! `Opener::Any` variant makes rustc stop trimming `std::any::Any`. So the +//! test is skipped, not failed, when `dynamic` is off — run it with +//! `--all-features`. #[test] +#[cfg_attr( + not(feature = "dynamic"), + ignore = "diagnostics are recorded with --all-features; run with that feature set" +)] fn derive_diagnostics() { let t = trybuild::TestCases::new(); t.compile_fail("tests/ui/*.rs"); diff --git a/packages/stack-encrypt/tests/ui/decrypt_field_not_decryptable.stderr b/packages/stack-encrypt/tests/ui/decrypt_field_not_decryptable.stderr index 9a4f0f6e0..1b0fdf28e 100644 --- a/packages/stack-encrypt/tests/ui/decrypt_field_not_decryptable.stderr +++ b/packages/stack-encrypt/tests/ui/decrypt_field_not_decryptable.stderr @@ -5,7 +5,7 @@ error[E0277]: the trait bound `Opaque: DecryptField<u32, CallerContext>` is not | ^^^^^^^^^^^ the trait `DecryptField<u32, CallerContext>` is not implemented for `Opaque` | = help: the following other types implement trait `DecryptField<P, Ctx>`: - `CipherText<SealedValue, Box<(dyn Any + Send + 'static)>>` implements `DecryptField<P, Ctx>` + `CipherText<SealedValue, Box<(dyn std::any::Any + Send + 'static)>>` implements `DecryptField<P, Ctx>` `EqualityTerm` implements `DecryptField<P, Ctx>` `MatchTerm<O>` implements `DecryptField<P, Ctx>` `OpeTerm<T>` implements `DecryptField<P, Ctx>` @@ -23,7 +23,7 @@ error[E0277]: the trait bound `Opaque: Decryptable` is not satisfied | ^^^^^^ the trait `Decryptable` is not implemented for `Opaque` | = help: the following other types implement trait `Decryptable`: - CipherText<SealedValue, Box<(dyn Any + Send + 'static)>> + CipherText<SealedValue, Box<(dyn std::any::Any + Send + 'static)>> EqualityTerm MatchTerm<O> OpeTerm<T> diff --git a/packages/stack-encrypt/tests/ui/drop_record.stderr b/packages/stack-encrypt/tests/ui/drop_record.stderr index acf842fcd..43eb38046 100644 --- a/packages/stack-encrypt/tests/ui/drop_record.stderr +++ b/packages/stack-encrypt/tests/ui/drop_record.stderr @@ -5,4 +5,4 @@ error[E0509]: cannot move out of type `Rec`, which implements the `Drop` trait | ^^^^^^^^^^^^^^^^^^ | | | cannot move out of here - | move occurs because value has type `CipherText<SealedValue, Box<dyn Any + Send>>`, which does not implement the `Copy` trait + | move occurs because value has type `CipherText<SealedValue, Box<dyn std::any::Any + Send>>`, which does not implement the `Copy` trait diff --git a/packages/stack-encrypt/tests/ui/nested_leaf_without_context.stderr b/packages/stack-encrypt/tests/ui/nested_leaf_without_context.stderr index e6f1c201d..491522d4d 100644 --- a/packages/stack-encrypt/tests/ui/nested_leaf_without_context.stderr +++ b/packages/stack-encrypt/tests/ui/nested_leaf_without_context.stderr @@ -41,4 +41,4 @@ error[E0277]: the trait bound `AeadContext: From<DeclaredContext>` is not satisf `AeadContext` implements `From<u128>` and $N others = note: required for `DeclaredContext` to implement `Into<AeadContext>` - = note: required for `CipherText<SealedValue, Box<(dyn Any + Send + 'static)>>` to implement `DecryptField<_, DeclaredContext>` + = note: required for `CipherText<SealedValue, Box<(dyn std::any::Any + Send + 'static)>>` to implement `DecryptField<_, DeclaredContext>` diff --git a/packages/stack-encrypt/tests/ui/plaintext_needs_encrypt.stderr b/packages/stack-encrypt/tests/ui/plaintext_needs_encrypt.stderr index dbea37817..a69dce2ed 100644 --- a/packages/stack-encrypt/tests/ui/plaintext_needs_encrypt.stderr +++ b/packages/stack-encrypt/tests/ui/plaintext_needs_encrypt.stderr @@ -10,13 +10,13 @@ error[E0277]: the trait bound `SerdeOnly: Encrypt` is not satisfied &str ContextTag<Tag, T> Element<T> + FfiValue HashMap<K, T> Vec<T> Vec<u8> [u8; N] - std::option::Option<T> and $N others - = note: required for `CipherText<SealedValue, Box<(dyn Any + Send + 'static)>>` to implement `EncryptFrom<SerdeOnly>` + = note: required for `CipherText<SealedValue, Box<(dyn std::any::Any + Send + 'static)>>` to implement `EncryptFrom<SerdeOnly>` = note: 1 redundant requirement hidden = note: required for `Target` to implement `EncryptFrom<SerdeOnly>` note: required by a bound in `target::operations::<impl KeysetCipher<'_, K>>::encrypt_as` From ec7a253591b9ffb3389b2d0de0b8b7d19ce393e1 Mon Sep 17 00:00:00 2001 From: Dan Draper <dan@cipherstash.com> Date: Thu, 17 Sep 2026 20:17:55 -0400 Subject: [PATCH 576/686] docs(wasi): the guest's doc links follow the context parser into the library The context grammar's one home is `stack_encrypt::dynamic::context` now, not a guest module, so the links that named `crate::context` pointed at nothing and `wasm:guest:test` failed under `-D warnings`. `NonEmpty` gets its path, and a private parser is named in code rather than linked. --- languages/golang/stackencrypt/guest/src/abi.rs | 2 +- languages/golang/stackencrypt/guest/src/lib.rs | 4 ++-- languages/golang/stackencrypt/guest/src/ops.rs | 6 +++--- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/languages/golang/stackencrypt/guest/src/abi.rs b/languages/golang/stackencrypt/guest/src/abi.rs index 1b3da7c91..d27f292bd 100644 --- a/languages/golang/stackencrypt/guest/src/abi.rs +++ b/languages/golang/stackencrypt/guest/src/abi.rs @@ -540,7 +540,7 @@ fn run_decrypt( /// — or an array of parts, which may nest as deep as the transport codec /// allows (`vitaminc_aead_value::transport::MAX_DEPTH` levels, counted from /// the root of the encoded value; deeper is refused as `STATUS_ENCODING` -/// before the context is parsed). [`crate::context`] is the one home of +/// before the context is parsed). [`stack_encrypt::dynamic::context`] is the one home of /// that grammar and of which Rust context each shape spells. /// A part and the one-element array holding it are *different* contexts /// (`[x]` is PAE-framed, `x` is not), so a probe must pass the context in diff --git a/languages/golang/stackencrypt/guest/src/lib.rs b/languages/golang/stackencrypt/guest/src/lib.rs index 95bbfbd23..a6bee93ee 100644 --- a/languages/golang/stackencrypt/guest/src/lib.rs +++ b/languages/golang/stackencrypt/guest/src/lib.rs @@ -52,8 +52,8 @@ //! //! Split into: //! -//! - [`ops`], [`context`], [`options`], [`config`], [`response`], -//! [`headers`], [`status`] — everything that is pure logic over +//! - [`ops`], [`options`], [`config`], [`response`], [`headers`], +//! [`status`] — everything that is pure logic over //! `StackCipher<K>` / `KeysetCipher<K>` / bytes. Compiles and unit-tests //! on the native host target (`cargo test` here, no wasm toolchain //! needed) against `stack_kms::FakeDataKeySource`. diff --git a/languages/golang/stackencrypt/guest/src/ops.rs b/languages/golang/stackencrypt/guest/src/ops.rs index e6abfdef0..16a6b023a 100644 --- a/languages/golang/stackencrypt/guest/src/ops.rs +++ b/languages/golang/stackencrypt/guest/src/ops.rs @@ -70,7 +70,7 @@ type BytesTree = CipherText<Vec<u8>, BoxedPassthrough>; /// the plain AEAD use `Aes256Cipher` allows, opened symmetrically by /// [`decrypt_value`] — and is the Go caller's choice to make. The record and /// term paths ([`encrypt_record`], [`decrypt_record`], [`term`]) are the -/// ones that bind fields: each takes a [`NonEmpty`] context, proven once at +/// ones that bind fields: each takes a [`NonEmpty`](stack_encrypt::NonEmpty) context, proven once at /// the boundary when the plan or the term's context is parsed, and refused /// as [`STATUS_ENCODING`] when empty. pub async fn encrypt_value<K>( @@ -154,7 +154,7 @@ where /// — or an array of parts, nested as deep as the transport codec allows /// ([`codec::MAX_DEPTH`] levels from the root of the encoded value; deeper /// is [`STATUS_ENCODING`] before the context is parsed), exactly as a plan -/// field's; [`crate::context`] is the one home of that grammar. Shape is identity: +/// field's; [`dynamic::context`] is the one home of that grammar. Shape is identity: /// `[x]` is a PAE-framed list and `x` is not, so a probe takes the context /// in the shape the field was sealed under — a plan field's context /// verbatim, a bare part for a Rust leaf sealed under that part, and the @@ -262,7 +262,7 @@ where /// The static checks the ABI runs on every operation input *before* it /// consults the cipher, so a malformed call is [`STATUS_ENCODING`] whether /// or not the instance is initialised, and never costs a keyset load. Each -/// runs the same parser the operation itself runs — [`parse_term`], +/// runs the same parser the operation itself runs — `parse_term`, /// [`dynamic::record::check_source`], [`dynamic::record::check_record`] — /// so the two cannot disagree on what is malformed; the second pass is /// cheap next to the AEAD and buys a stable status precedence. From 1925824aa59e2692d6e341ffc8b3c37dfa3f440c Mon Sep 17 00:00:00 2001 From: Dan Draper <dan@cipherstash.com> Date: Thu, 17 Sep 2026 23:51:03 -0700 Subject: [PATCH 577/686] refactor(stack-encrypt): the dynamic scope is named for what it is MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `dynamic::Opener` was the glossary's *scope* under a new name: which of the two ciphers an opening operation goes through, and so which keysets it may open. Rename it `Scope`, with `Client` and `Keyset` variants for the two ciphers, and say plainly on the type that the foreign-leaf refusal is the keyset cipher's own (`Error::ForeignKeyset`, on building the pending, before any key is retrieved) — this enum only names the choice. It gains a `Debug` that demands nothing of `K`, as the ciphers' own do not. The rename also takes a diagnostic back: a variant called `Any` made rustc stop trimming `std::any::Any` in four of the derive's recorded `.stderr` expectations, which ADR-0004 says to treat as a defect. What remains between feature sets is one help-list entry — `FfiValue` joins the implementors of `Encrypt` when `dynamic` is on and pushes `Option<T>` off the list — so the ui test is declared to need that feature (`required-features`) rather than skipping itself at runtime. `Output` and `TermKind` stay exhaustive against the workspace rule for public enums, and now say why: their keys are wire format, so a new output is something every binding has to be taught, and an exhaustive match is how the compiler tells a binding author. `VERSION`'s doc claimed to be the user-agent of every ZeroKMS request; the native client identifies itself as `stack-kms`. It is for a binding. The glossary gains a **Plan** entry (the runtime form of an operation description, which its avoid-list had been naming) and records `dynamic::Scope` under **Scope**. --- .../golang/stackencrypt/guest/src/abi.rs | 22 ++--- .../golang/stackencrypt/guest/src/ops.rs | 26 +++--- .../golang/stackencrypt/guest/src/options.rs | 14 ++-- .../stackencrypt/guest/tests/native_ops.rs | 83 +++++++++++++------ packages/stack-encrypt/CONTEXT.md | 21 +++-- packages/stack-encrypt/Cargo.toml | 9 ++ packages/stack-encrypt/src/dynamic/mod.rs | 53 +++++++++--- packages/stack-encrypt/src/lib.rs | 14 ++-- packages/stack-encrypt/tests/ui.rs | 15 ++-- .../ui/decrypt_field_not_decryptable.stderr | 4 +- .../stack-encrypt/tests/ui/drop_record.stderr | 2 +- .../ui/nested_leaf_without_context.stderr | 2 +- .../tests/ui/plaintext_needs_encrypt.stderr | 2 +- 13 files changed, 172 insertions(+), 95 deletions(-) diff --git a/languages/golang/stackencrypt/guest/src/abi.rs b/languages/golang/stackencrypt/guest/src/abi.rs index d27f292bd..63323d0f2 100644 --- a/languages/golang/stackencrypt/guest/src/abi.rs +++ b/languages/golang/stackencrypt/guest/src/abi.rs @@ -100,9 +100,9 @@ use crate::buffers; use crate::config::parse_config; use crate::host::{HostTokenStrategy, WasiHostConnection}; use crate::ops; -use crate::options::{opener_for, parse_options, parse_selector, KeysetSelector, Side}; +use crate::options::{parse_options, parse_selector, scope_for, KeysetSelector, Side}; use crate::status::{STATUS_ENCODING, STATUS_INTERNAL, STATUS_KMS_TRANSPORT, STATUS_STATE}; -use stack_encrypt::dynamic::Opener; +use stack_encrypt::dynamic::Scope; /// The instance's cipher: `stack-encrypt` over the host-transport ZeroKMS /// client with host-supplied tokens. @@ -250,16 +250,16 @@ fn with_keyset<R>( }) } -/// Run `f` with the opener the open-side options in `opts` select: the +/// Run `f` with the scope the open-side options in `opts` select: the /// client for `{"any"}`, one keyset's cipher otherwise. -fn with_opener<R>( +fn with_scope<R>( opts: &[u8], - f: impl FnOnce(Opener<'_, StackKms<HostTokenStrategy, WasiHostConnection>>) -> Result<R, u32>, + f: impl FnOnce(Scope<'_, StackKms<HostTokenStrategy, WasiHostConnection>>) -> Result<R, u32>, ) -> Result<R, u32> { let options = parse_options(decode(opts)?, Side::Open)?; with_cipher(|cipher| { - let opener = block_on(opener_for(cipher, &options.keyset))?; - f(opener) + let scope = block_on(scope_for(cipher, &options.keyset))?; + f(scope) }) } @@ -522,8 +522,8 @@ fn run_decrypt( let aad = input(aad_ptr, aad_len)?; let opts = input(opt_ptr, opt_len)?; ops::validate::tree(ciphertext)?; - with_opener(opts, |opener| { - block_on(ops::decrypt_value(opener, ciphertext, aad, as_element)) + with_scope(opts, |scope| { + block_on(ops::decrypt_value(scope, ciphertext, aad, as_element)) }) })) .unwrap_or(Err(STATUS_INTERNAL)) @@ -631,8 +631,8 @@ pub unsafe extern "C" fn se_decrypt_record( let plan = input(plan_ptr, plan_len)?; let opts = input(opt_ptr, opt_len)?; ops::validate::record_tree(record, plan)?; - with_opener(opts, |opener| { - block_on(ops::decrypt_record(opener, record, plan)) + with_scope(opts, |scope| { + block_on(ops::decrypt_record(scope, record, plan)) }) })) .unwrap_or(Err(STATUS_INTERNAL)) diff --git a/languages/golang/stackencrypt/guest/src/ops.rs b/languages/golang/stackencrypt/guest/src/ops.rs index 16a6b023a..da2dce0e1 100644 --- a/languages/golang/stackencrypt/guest/src/ops.rs +++ b/languages/golang/stackencrypt/guest/src/ops.rs @@ -31,7 +31,7 @@ //! ABI's numeric term kinds, and the mapping from a library error to a //! status code. -use stack_encrypt::dynamic::{self, Opener, Scalar, TermKind}; +use stack_encrypt::dynamic::{self, Scalar, Scope, TermKind}; use stack_encrypt::{ BoxedPassthrough, CipherText, Element, Encrypt, KeysetCipher, SealedValue, StackCipherText, }; @@ -101,16 +101,16 @@ where /// layer's ownership rules govern its wiping. /// /// One batched `retrieve_keys` per invocation, dispatched as one ZeroKMS -/// call per 500 keyed leaves and, under [`Opener::Any`], per keyset the +/// call per 500 keyed leaves and, under [`Scope::Client`], per keyset the /// tree's leaves were sealed under — the same rule [`decrypt_record`] /// states. A tree small enough and single-keyset enough is the one request /// that suggests; nothing here promises it in general. /// /// Symmetric with [`encrypt_value`]: the AAD is whatever the value was sealed -/// under, empty included. The [`Opener`] says which keysets may be opened: +/// under, empty included. The [`Scope`] says which keysets may be opened: /// any, or one, refusing the rest before any key is retrieved. pub async fn decrypt_value<K>( - opener: Opener<'_, K>, + scope: Scope<'_, K>, ciphertext: &[u8], aad: &[u8], as_element: bool, @@ -121,19 +121,19 @@ where let tree = decode_tree(ciphertext)?; // One `decrypt` per arm, not one `decipher` and two drives. The element // derivation is `Element<T>`'s to apply and naming the type is what asks - // for it; the opener decides whether a foreign leaf is refused before any + // for it; the scope decides whether a foreign leaf is refused before any // key is retrieved. Only one arm runs, so the retrieve happens once. - let value: FfiValue = match (&opener, as_element) { - (Opener::Any(cipher), true) => cipher + let value: FfiValue = match (&scope, as_element) { + (Scope::Client(cipher), true) => cipher .decrypt::<Element<FfiValue>, _>(tree, aad) .await .map(Element::into_inner), - (Opener::Any(cipher), false) => cipher.decrypt(tree, aad).await, - (Opener::Only(keyset), true) => keyset + (Scope::Client(cipher), false) => cipher.decrypt(tree, aad).await, + (Scope::Keyset(keyset), true) => keyset .decrypt::<Element<FfiValue>, _>(tree, aad) .await .map(Element::into_inner), - (Opener::Only(keyset), false) => keyset.decrypt(tree, aad).await, + (Scope::Keyset(keyset), false) => keyset.decrypt(tree, aad).await, } .map_err(|e| status_for_error(&e))?; encode_value(value) @@ -237,11 +237,11 @@ where /// same plan. Only the `"c"` outputs participate (terms are one-way). /// /// One batched `retrieve_keys` per invocation, dispatched as one ZeroKMS -/// call per 500 keyed leaves and, under [`Opener::Any`], per keyset the +/// call per 500 keyed leaves and, under [`Scope::Client`], per keyset the /// leaves were sealed under. The output buffer contains plaintext — the ABI /// layer's ownership rules govern its wiping. pub async fn decrypt_record<K>( - opener: Opener<'_, K>, + scope: Scope<'_, K>, record: &[u8], plan: &[u8], ) -> Result<Vec<u8>, u32> @@ -249,7 +249,7 @@ where K: DataKeySource + Sync + 'static, { let plan = dynamic::record::plan(decode_value(plan)?).map_err(|e| status_for_dynamic(&e))?; - let value = dynamic::record::decrypt(opener, decode_tree(record)?, &plan) + let value = dynamic::record::decrypt(scope, decode_tree(record)?, &plan) .await .map_err(|e| status_for_dynamic(&e))?; encode_value(value) diff --git a/languages/golang/stackencrypt/guest/src/options.rs b/languages/golang/stackencrypt/guest/src/options.rs index 27c6c8771..3fc9b6727 100644 --- a/languages/golang/stackencrypt/guest/src/options.rs +++ b/languages/golang/stackencrypt/guest/src/options.rs @@ -27,7 +27,7 @@ //! it is objects, strings, bytes and nothing else, and this module is its //! one home. The Go bindings plan points here. -use stack_encrypt::dynamic::Opener; +use stack_encrypt::dynamic::Scope; use stack_encrypt::{KeysetCipher, StackCipher}; use stack_kms::{IdentifiedBy, IndexKeySource}; use uuid::Uuid; @@ -123,7 +123,7 @@ impl KeysetSelector { /// default without a round trip, a name or id through the cipher's /// cache (a first use is one `load-keyset` call). `Any` is not a keyset /// and is [`STATUS_ENCODING`] here; opening exports resolve it through - /// [`opener_for`] instead. + /// [`scope_for`] instead. pub async fn resolve<'c, K>( &self, cipher: &'c StackCipher<K>, @@ -141,20 +141,20 @@ impl KeysetSelector { } } -/// The [`Opener`] a decrypt-side selector names: the client for `{"any"}`, +/// The [`Scope`] a decrypt-side selector names: the client for `{"any"}`, /// which opens a leaf sealed under any of its keysets, or one keyset's /// cipher, which opens only its own and refuses the rest before any key is /// retrieved. -pub async fn opener_for<'c, K>( +pub async fn scope_for<'c, K>( cipher: &'c StackCipher<K>, selector: &KeysetSelector, -) -> Result<Opener<'c, K>, u32> +) -> Result<Scope<'c, K>, u32> where K: IndexKeySource, { match selector { - KeysetSelector::Any => Ok(Opener::Any(cipher)), - other => other.resolve(cipher).await.map(Opener::Only), + KeysetSelector::Any => Ok(Scope::Client(cipher)), + other => other.resolve(cipher).await.map(Scope::Keyset), } } diff --git a/languages/golang/stackencrypt/guest/tests/native_ops.rs b/languages/golang/stackencrypt/guest/tests/native_ops.rs index 87e2da377..d5d482609 100644 --- a/languages/golang/stackencrypt/guest/tests/native_ops.rs +++ b/languages/golang/stackencrypt/guest/tests/native_ops.rs @@ -17,7 +17,7 @@ use std::sync::atomic::{AtomicUsize, Ordering}; use std::future::IntoFuture; -use stack_encrypt::dynamic::Opener; +use stack_encrypt::dynamic::Scope; use stack_encrypt::sem::DefaultMatch; use stack_encrypt::{nonempty, CipherText, Encrypt, SealedValue, StackCipher}; use stack_encrypt_guest::ops::{self, TERM_EQUALITY, TERM_MATCH, TERM_OPE, TERM_ORE}; @@ -216,7 +216,7 @@ fn value_round_trips_through_the_guest_ops() { )) .expect("encrypt"); let pt = block_on(ops::decrypt_value( - Opener::Any(&cipher), + Scope::Client(&cipher), &ct, b"users/42", false, @@ -248,7 +248,7 @@ fn element_mode_round_trips() { )) .expect("encrypt element"); let pt = block_on(ops::decrypt_value( - Opener::Any(&cipher), + Scope::Client(&cipher), &ct, b"users", true, @@ -260,7 +260,7 @@ fn element_mode_round_trips() { // derivation must fail authentication. assert_eq!( block_on(ops::decrypt_value( - Opener::Any(&cipher), + Scope::Client(&cipher), &ct, b"users", false @@ -310,7 +310,7 @@ fn wrong_aad_and_malformed_inputs_map_to_statuses() { // `STATUS_KMS_FORBIDDEN` — see `status.rs`.) assert_eq!( block_on(ops::decrypt_value( - Opener::Any(&cipher), + Scope::Client(&cipher), &ct, b"other", false @@ -329,7 +329,7 @@ fn wrong_aad_and_malformed_inputs_map_to_statuses() { ); assert_eq!( block_on(ops::decrypt_value( - Opener::Any(&cipher), + Scope::Client(&cipher), b"\xffgarbage", b"ctx", false @@ -349,7 +349,7 @@ fn wrong_aad_and_malformed_inputs_map_to_statuses() { .expect("encode truncated"); assert_eq!( block_on(ops::decrypt_value( - Opener::Any(&cipher), + Scope::Client(&cipher), &out, b"ctx", false @@ -378,7 +378,7 @@ fn an_empty_aad_round_trips_on_the_value_paths() { )) .expect("encrypt under an empty aad"); let out = block_on(ops::decrypt_value( - Opener::Any(&cipher), + Scope::Client(&cipher), &ct, b"", as_element, @@ -390,7 +390,7 @@ fn an_empty_aad_round_trips_on_the_value_paths() { // that carries bytes. assert_eq!( block_on(ops::decrypt_value( - Opener::Any(&cipher), + Scope::Client(&cipher), &ct, b"ctx", as_element @@ -410,11 +410,22 @@ fn an_empty_aad_round_trips_on_the_value_paths() { )) .expect("encrypt"); let opened = decode( - &block_on(ops::decrypt_value(Opener::Any(&cipher), &ct, zeros, false)).expect("decrypt"), + &block_on(ops::decrypt_value( + Scope::Client(&cipher), + &ct, + zeros, + false, + )) + .expect("decrypt"), ); assert_eq!(text(&opened), "x"); assert_eq!( - block_on(ops::decrypt_value(Opener::Any(&cipher), &ct, b"ctx", false)), + block_on(ops::decrypt_value( + Scope::Client(&cipher), + &ct, + b"ctx", + false + )), Err(STATUS_AUTH) ); } @@ -616,8 +627,12 @@ fn a_record_batch_encrypts_in_one_call_and_round_trips() { // Three rows, two ciphertext fields each: still exactly one call. assert_eq!(cipher.kms().generate_calls.load(Ordering::SeqCst), 1); - let pt = block_on(ops::decrypt_record(Opener::Any(&cipher), &record, &plan())) - .expect("decrypt records"); + let pt = block_on(ops::decrypt_record( + Scope::Client(&cipher), + &record, + &plan(), + )) + .expect("decrypt records"); assert_eq!(cipher.kms().retrieve_calls.load(Ordering::SeqCst), 1); let FfiValue::Array(rows) = decode(&pt) else { @@ -669,7 +684,11 @@ fn a_forged_passthrough_ciphertext_slot_is_rejected_not_decrypted() { codec::encode_ciphertext(&CipherText::Map(fields), &mut forged).expect("re-encode"); assert_eq!( - block_on(ops::decrypt_record(Opener::Any(&cipher), &forged, &plan())), + block_on(ops::decrypt_record( + Scope::Client(&cipher), + &forged, + &plan() + )), Err(STATUS_ENCODING), "a passthrough in a ciphertext slot must be a hard error, never plaintext" ); @@ -886,7 +905,7 @@ fn a_natively_sealed_field_under_an_extended_context_opens_through_a_plan() { let plan_with = |context: FfiValue| single_field_plan("age", context); let opened = block_on(ops::decrypt_record( - Opener::Any(&cipher), + Scope::Client(&cipher), &record, &plan_with(extended("age")), )) @@ -898,7 +917,7 @@ fn a_natively_sealed_field_under_an_extended_context_opens_through_a_plan() { assert_eq!( block_on(ops::decrypt_record( - Opener::Any(&cipher), + Scope::Client(&cipher), &record, &plan_with(s("users/age")) )), @@ -950,7 +969,7 @@ fn a_structured_plan_context_is_validated_at_parse() { )) .expect("an integer part is never empty"); assert!(block_on(ops::decrypt_record( - Opener::Any(&cipher), + Scope::Client(&cipher), &sealed, &plan_with(FfiValue::Array(vec![s(""), FfiValue::UInt64(7)])) )) @@ -1074,7 +1093,7 @@ fn an_empty_plan_context_is_refused_before_anything_is_sealed() { ); assert_eq!( block_on(ops::decrypt_record( - Opener::Any(&cipher), + Scope::Client(&cipher), &source, &bad_plan )), @@ -1097,7 +1116,7 @@ fn an_empty_plan_context_is_refused_before_anything_is_sealed() { )) .expect("encrypt"); let opened = block_on(ops::decrypt_record( - Opener::Any(&cipher), + Scope::Client(&cipher), &sealed, &odd_plan, )) @@ -1109,7 +1128,7 @@ fn an_empty_plan_context_is_refused_before_anything_is_sealed() { } // ============================================================================= -// Keysets: the opener a call selects +// Keysets: the scope a call selects // ============================================================================= fn keyset_named<'c>( @@ -1130,20 +1149,30 @@ fn a_value_opens_under_its_own_keyset_or_any_but_not_another() { let ct = block_on(ops::encrypt_value(&acme, &encode(s("x")), b"ctx", false)).expect("encrypt"); let pt = block_on(ops::decrypt_value( - Opener::Only(acme.clone()), + Scope::Keyset(acme.clone()), &ct, b"ctx", false, )) .expect("own keyset opens"); assert_eq!(text(&decode(&pt)), "x"); - let pt = - block_on(ops::decrypt_value(Opener::Any(&cipher), &ct, b"ctx", false)).expect("any opens"); + let pt = block_on(ops::decrypt_value( + Scope::Client(&cipher), + &ct, + b"ctx", + false, + )) + .expect("any opens"); assert_eq!(text(&decode(&pt)), "x"); let retrieves = cipher.kms().retrieve_calls.load(Ordering::SeqCst); assert_eq!( - block_on(ops::decrypt_value(Opener::Only(globex), &ct, b"ctx", false)), + block_on(ops::decrypt_value( + Scope::Keyset(globex), + &ct, + b"ctx", + false + )), Err(STATUS_FOREIGN_KEYSET), "another tenant's keyset must refuse the leaf" ); @@ -1195,7 +1224,7 @@ fn a_mixed_keyset_record_batch_opens_through_any_one_call_per_keyset() { }; let before = cipher.kms().retrieve_calls.load(Ordering::SeqCst); - let pt = block_on(ops::decrypt_record(Opener::Any(&cipher), &batch, &plan())) + let pt = block_on(ops::decrypt_record(Scope::Client(&cipher), &batch, &plan())) .expect("any opens the mixed batch"); assert_eq!( cipher.kms().retrieve_calls.load(Ordering::SeqCst) - before, @@ -1209,7 +1238,7 @@ fn a_mixed_keyset_record_batch_opens_through_any_one_call_per_keyset() { let before = cipher.kms().retrieve_calls.load(Ordering::SeqCst); assert_eq!( - block_on(ops::decrypt_record(Opener::Only(acme), &batch, &plan())), + block_on(ops::decrypt_record(Scope::Keyset(acme), &batch, &plan())), Err(STATUS_FOREIGN_KEYSET) ); assert_eq!(cipher.kms().retrieve_calls.load(Ordering::SeqCst), before); @@ -1475,7 +1504,7 @@ fn record_tree_validation_refuses_what_decrypt_record_refuses() { "{label} must be refused by validation" ); assert_eq!( - block_on(ops::decrypt_record(Opener::Any(&cipher), &tree, &plan)), + block_on(ops::decrypt_record(Scope::Client(&cipher), &tree, &plan)), Err(STATUS_ENCODING), "{label} must be refused by the op too" ); diff --git a/packages/stack-encrypt/CONTEXT.md b/packages/stack-encrypt/CONTEXT.md index bd7afc004..952d15fda 100644 --- a/packages/stack-encrypt/CONTEXT.md +++ b/packages/stack-encrypt/CONTEXT.md @@ -21,7 +21,17 @@ _Avoid_: typed path, high-level path **Operation description**: The target's declaration of the ciphertext and term operations, source selections, and context requirements needed to produce it. -_Avoid_: user-supplied encryption callback, caller-supplied plan +_Avoid_: user-supplied encryption callback, caller-supplied plan (a **plan** +is the runtime form of a record's description, not a callback) + +**Plan**: +A record's operation description given as data rather than as a type, per +field: the context to bind and the outputs (`"c"`, `"eq"`, `"match"`, `"ore"`, +`"ope"`) to produce. What a binding has instead of a `struct = T` derive; +`stack_encrypt::dynamic::record` drives one. Its contexts are proven +nonempty once, when it is built, and its output keys are wire format. +_Avoid_: schema (that is the source's shape, which a plan does not describe), +mapping, config **Ciphertext transcoding**: Construction or inspection of an encrypted target through its native encrypted @@ -120,10 +130,11 @@ sub-cipher, tenant cipher What a `Pending` was built through, and therefore what it is allowed to do: a `KeysetCipher` scope mints under its keyset and opens leaves from no other; a `StackCipher` scope mints nothing and opens leaves from any keyset. -`CipherScope` is the sealed trait both references implement. Two pendings -merge when their scopes agree on a keyset — which cipher *value* each came -from is not part of the rule. -_Avoid_: binding (that is a name's), context (that is the AAD's) +`CipherScope` is the sealed trait both references implement; `dynamic::Scope` +is the same choice as a runtime value, for a binding whose caller makes it +per call. Two pendings merge when their scopes agree on a keyset — which +cipher *value* each came from is not part of the rule. +_Avoid_: binding (that is a name's), context (that is the AAD's), opener **Name binding**: The cache's record that a keyset name resolved to a keyset id, and when. diff --git a/packages/stack-encrypt/Cargo.toml b/packages/stack-encrypt/Cargo.toml index 338e6df91..aa560a4d5 100644 --- a/packages/stack-encrypt/Cargo.toml +++ b/packages/stack-encrypt/Cargo.toml @@ -74,6 +74,15 @@ tokio = { workspace = true, features = ["rt", "macros"] } # Compile-fail tests for the derive diagnostics (`tests/ui`). trybuild = "1" +# The derive diagnostics are recorded with every feature on, as `test:unit` +# and CI run them. They cannot hold under both feature sets — rustc lists a +# trait's other implementors in its help, and the `dynamic` feature's +# `FfiValue` joins one such list and pushes another entry off it — so the +# test is only built with that feature rather than skipped at runtime. +[[test]] +name = "ui" +required-features = ["dynamic"] + [[example]] name = "encrypted_record" required-features = ["http"] diff --git a/packages/stack-encrypt/src/dynamic/mod.rs b/packages/stack-encrypt/src/dynamic/mod.rs index 2d4fb3d85..3de0d0854 100644 --- a/packages/stack-encrypt/src/dynamic/mod.rs +++ b/packages/stack-encrypt/src/dynamic/mod.rs @@ -23,6 +23,7 @@ //! * [`record`] — the runtime form of `#[derive(EncryptFrom)]`: a *plan* //! says per field what context to bind and what outputs to produce, and //! the whole call seals from one batched key request. +//! * [`Scope`] — which cipher an opening operation decrypts through. //! //! # What is not here //! @@ -39,10 +40,19 @@ //! languages agree on them by construction rather than by each re-deriving //! them. Their long-term home is beside vitaminc's frozen tag table, which //! already owns this class of constant. +//! +//! For the same reason the enums that spell them — [`Output`] and +//! [`TermKind`] — are *not* `#[non_exhaustive]`, against this workspace's +//! usual rule for public enums: a new output is a wire-format addition every +//! binding has to be taught, and an exhaustive match is how the compiler +//! tells a binding author that. [`Scope`] is exhaustive for a different +//! reason, given on the type. mod context; pub mod record; mod term; +use std::fmt; + pub use context::{borrowed, context}; pub use record::{FieldPlan, Output}; pub use term::{term, Scalar, TermKind}; @@ -54,21 +64,42 @@ pub use vitaminc_aead_value::FfiValue; use crate::{KeysetCipher, StackCipher}; -/// What an opening operation decrypts through. +/// Which cipher an opening operation decrypts through: the client, or one +/// of its keysets. +/// +/// This is the runtime form of the crate's *scope* (what a `Pending` is +/// built through, and so what it may open — [`CipherScope`](crate::CipherScope) +/// is the trait both ciphers implement). [`StackCipher`] and +/// [`KeysetCipher`] both open, and neither is the other's supertype: the +/// client opens a leaf sealed under any of its keysets, while a keyset +/// cipher opens only its own and fails a foreign leaf with +/// [`Error::ForeignKeyset`](crate::Error::ForeignKeyset). That refusal is +/// the keyset cipher's, made when the pending is built and before any key +/// is retrieved; this enum only names which of the two a call goes through, +/// because a binding's caller makes that choice at runtime and a typed +/// caller makes it by naming the cipher. /// -/// [`StackCipher`] and [`KeysetCipher`] both open, and neither is the -/// other's supertype: the client opens a leaf sealed under any of its -/// keysets, while a keyset handle opens only its own and refuses the rest -/// *before any key is retrieved*. That refusal is the point — a -/// tenant-scoped request handler must not open another tenant's row — so -/// the choice is named rather than inferred, and it is named here because a -/// binding's caller makes it at runtime. -pub enum Opener<'c, K> { +/// Not `#[non_exhaustive]`: the two variants are the two ciphers this crate +/// has, and a binding dispatches on them (the Go guest does, per selector). +/// A third would be a new cipher type, which is a larger change than adding +/// a variant here. +pub enum Scope<'c, K> { /// Leaves from any keyset the client holds: one batched retrieval per /// keyset the leaves were sealed under. - Any(&'c StackCipher<K>), + Client(&'c StackCipher<K>), /// Leaves from this keyset only. - Only(KeysetCipher<'c, K>), + Keyset(KeysetCipher<'c, K>), +} + +// By hand rather than derived, so `K: Debug` is not demanded: neither cipher +// demands it of its own `Debug`, and a data-key source rarely offers one. +impl<K> fmt::Debug for Scope<'_, K> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Scope::Client(cipher) => f.debug_tuple("Client").field(cipher).finish(), + Scope::Keyset(keyset) => f.debug_tuple("Keyset").field(keyset).finish(), + } + } } /// The UTF-8 inside a string leaf. Valid by `Utf8String`'s construction diff --git a/packages/stack-encrypt/src/lib.rs b/packages/stack-encrypt/src/lib.rs index 6b7428943..0cdbc653e 100644 --- a/packages/stack-encrypt/src/lib.rs +++ b/packages/stack-encrypt/src/lib.rs @@ -255,13 +255,15 @@ endpoint — are `StackKmsBuilder`'s, and the two keyset-cache knobs are //! vitaminc are re-exported here. The [`cipher`] module docs describe the //! internals (batching, AAD derivation, wire format). -/// This crate's version, as it appears in the `user-agent` of every ZeroKMS -/// request. +/// This crate's version, for a binding to put in the `user-agent` of the +/// ZeroKMS requests it makes. /// -/// Requests are identified by the library that makes them, not by whichever -/// binding shim is carrying it: a report of "stack-encrypt 0.1.0" means the -/// same thing whether it came from Rust, from the WASI guest under Go, or -/// from a native cdylib under Python. +/// A request is identified by the library that makes it, not by whichever +/// binding shim is carrying it: "stack-encrypt 0.1.0" means the same thing +/// from the WASI guest under Go as from a native cdylib under Python. The +/// native Rust client does not go through a binding and identifies itself +/// as `stack-kms` (see `stack_kms`'s user agent) — the crate that actually +/// makes its requests. pub const VERSION: &str = env!("CARGO_PKG_VERSION"); pub mod cipher; diff --git a/packages/stack-encrypt/tests/ui.rs b/packages/stack-encrypt/tests/ui.rs index 75349668c..2ce0f68fd 100644 --- a/packages/stack-encrypt/tests/ui.rs +++ b/packages/stack-encrypt/tests/ui.rs @@ -3,18 +3,13 @@ //! expectations after a deliberate message change with `TRYBUILD=overwrite`. //! //! The expectations are recorded with every feature on, as `test:unit` and -//! CI run them (`--all-features`). They cannot hold under both feature sets: -//! rustc lists the other implementors of a trait, so the `dynamic` feature's -//! `FfiValue` joins one help list and pushes another entry off it, and its -//! `Opener::Any` variant makes rustc stop trimming `std::any::Any`. So the -//! test is skipped, not failed, when `dynamic` is off — run it with -//! `--all-features`. +//! CI run them (`--all-features`), and this test is only built with the +//! `dynamic` feature (`required-features` in `Cargo.toml`): rustc lists a +//! trait's other implementors in its help, so that feature's `FfiValue` +//! joins one list and pushes another entry off it, and one recording cannot +//! hold under both feature sets. #[test] -#[cfg_attr( - not(feature = "dynamic"), - ignore = "diagnostics are recorded with --all-features; run with that feature set" -)] fn derive_diagnostics() { let t = trybuild::TestCases::new(); t.compile_fail("tests/ui/*.rs"); diff --git a/packages/stack-encrypt/tests/ui/decrypt_field_not_decryptable.stderr b/packages/stack-encrypt/tests/ui/decrypt_field_not_decryptable.stderr index 1b0fdf28e..9a4f0f6e0 100644 --- a/packages/stack-encrypt/tests/ui/decrypt_field_not_decryptable.stderr +++ b/packages/stack-encrypt/tests/ui/decrypt_field_not_decryptable.stderr @@ -5,7 +5,7 @@ error[E0277]: the trait bound `Opaque: DecryptField<u32, CallerContext>` is not | ^^^^^^^^^^^ the trait `DecryptField<u32, CallerContext>` is not implemented for `Opaque` | = help: the following other types implement trait `DecryptField<P, Ctx>`: - `CipherText<SealedValue, Box<(dyn std::any::Any + Send + 'static)>>` implements `DecryptField<P, Ctx>` + `CipherText<SealedValue, Box<(dyn Any + Send + 'static)>>` implements `DecryptField<P, Ctx>` `EqualityTerm` implements `DecryptField<P, Ctx>` `MatchTerm<O>` implements `DecryptField<P, Ctx>` `OpeTerm<T>` implements `DecryptField<P, Ctx>` @@ -23,7 +23,7 @@ error[E0277]: the trait bound `Opaque: Decryptable` is not satisfied | ^^^^^^ the trait `Decryptable` is not implemented for `Opaque` | = help: the following other types implement trait `Decryptable`: - CipherText<SealedValue, Box<(dyn std::any::Any + Send + 'static)>> + CipherText<SealedValue, Box<(dyn Any + Send + 'static)>> EqualityTerm MatchTerm<O> OpeTerm<T> diff --git a/packages/stack-encrypt/tests/ui/drop_record.stderr b/packages/stack-encrypt/tests/ui/drop_record.stderr index 43eb38046..acf842fcd 100644 --- a/packages/stack-encrypt/tests/ui/drop_record.stderr +++ b/packages/stack-encrypt/tests/ui/drop_record.stderr @@ -5,4 +5,4 @@ error[E0509]: cannot move out of type `Rec`, which implements the `Drop` trait | ^^^^^^^^^^^^^^^^^^ | | | cannot move out of here - | move occurs because value has type `CipherText<SealedValue, Box<dyn std::any::Any + Send>>`, which does not implement the `Copy` trait + | move occurs because value has type `CipherText<SealedValue, Box<dyn Any + Send>>`, which does not implement the `Copy` trait diff --git a/packages/stack-encrypt/tests/ui/nested_leaf_without_context.stderr b/packages/stack-encrypt/tests/ui/nested_leaf_without_context.stderr index 491522d4d..e6f1c201d 100644 --- a/packages/stack-encrypt/tests/ui/nested_leaf_without_context.stderr +++ b/packages/stack-encrypt/tests/ui/nested_leaf_without_context.stderr @@ -41,4 +41,4 @@ error[E0277]: the trait bound `AeadContext: From<DeclaredContext>` is not satisf `AeadContext` implements `From<u128>` and $N others = note: required for `DeclaredContext` to implement `Into<AeadContext>` - = note: required for `CipherText<SealedValue, Box<(dyn std::any::Any + Send + 'static)>>` to implement `DecryptField<_, DeclaredContext>` + = note: required for `CipherText<SealedValue, Box<(dyn Any + Send + 'static)>>` to implement `DecryptField<_, DeclaredContext>` diff --git a/packages/stack-encrypt/tests/ui/plaintext_needs_encrypt.stderr b/packages/stack-encrypt/tests/ui/plaintext_needs_encrypt.stderr index a69dce2ed..f163bdd9b 100644 --- a/packages/stack-encrypt/tests/ui/plaintext_needs_encrypt.stderr +++ b/packages/stack-encrypt/tests/ui/plaintext_needs_encrypt.stderr @@ -16,7 +16,7 @@ error[E0277]: the trait bound `SerdeOnly: Encrypt` is not satisfied Vec<u8> [u8; N] and $N others - = note: required for `CipherText<SealedValue, Box<(dyn std::any::Any + Send + 'static)>>` to implement `EncryptFrom<SerdeOnly>` + = note: required for `CipherText<SealedValue, Box<(dyn Any + Send + 'static)>>` to implement `EncryptFrom<SerdeOnly>` = note: 1 redundant requirement hidden = note: required for `Target` to implement `EncryptFrom<SerdeOnly>` note: required by a bound in `target::operations::<impl KeysetCipher<'_, K>>::encrypt_as` From 1f61ce2c825e3c2ae3e6465dbb2a2838a555b89c Mon Sep 17 00:00:00 2001 From: Dan Draper <dan@cipherstash.com> Date: Thu, 17 Sep 2026 23:51:04 -0700 Subject: [PATCH 578/686] test(stack-encrypt): the dynamic module is tested where it lives MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `record` and `term` arrived with no tests of their own: the refusals the module docs call load-bearing were exercised only through the Go guest's native tests, one crate away from the code. They are pinned here now, in the crate that makes the claims: * the term dispatch derives the bytes the typed path derives, for every scalar variant and every kind, and refuses the pairs `supports` refuses with the kind named; * a plan is refused for each way it can be malformed, before any field is built; a source or a stored record that does not fit is refused by the operation and by its boundary parser alike, with no key request; * a record seals from one key request and opens from one, as a row and as a batch — an empty batch included; * ADR-0004's property, held here by one variable rather than a type parameter: a record's `"c"` opens under its plan context and under no other field's, and every term equals the standalone derivation under that same context; * the forged-plaintext shape — a passthrough where `"c"` should be — is refused before anything is retrieved; * a keyset scope refuses a foreign leaf naming both keysets, retrieving nothing. Along the way the two record trees share one walk: `RecordTree` is what a source (`FfiValue`) and a stored record (`StackCipherText`) have in common — one row or a sequence of rows, and a tree that may hide a passthrough — so the row lifting, the field take-out and the passthrough rejection are written once over it instead of once per tree. `Rows` carries whether a call was a batch so the result takes the input's shape, `Settled` hands a batch's values back one per slot and insists the count comes out exact, and a row skeleton is a struct rather than a tuple alias. `Scalar` holds plaintext, so its `Debug` is vitaminc's `OpaqueDebug`: the variant named, the value masked. `context`, `term`, `record::plan` and `record::encrypt` each carry an example, and every bare `assert_eq!` in the moved context tests and the guest's header tests says what it is asserting. --- .../golang/stackencrypt/guest/src/headers.rs | 39 +- packages/stack-encrypt/src/dynamic/context.rs | 60 +- packages/stack-encrypt/src/dynamic/record.rs | 1548 ++++++++++++++--- packages/stack-encrypt/src/dynamic/term.rs | 328 +++- 4 files changed, 1744 insertions(+), 231 deletions(-) diff --git a/languages/golang/stackencrypt/guest/src/headers.rs b/languages/golang/stackencrypt/guest/src/headers.rs index f43021685..c0edcebf3 100644 --- a/languages/golang/stackencrypt/guest/src/headers.rs +++ b/languages/golang/stackencrypt/guest/src/headers.rs @@ -95,11 +95,13 @@ mod tests { ]); assert_eq!( std::str::from_utf8(&buffer).unwrap(), - "authorization: Bearer tok\ncontent-type: application/json" + "authorization: Bearer tok\ncontent-type: application/json", + "headers encode one per line, lower-cased, without a trailing newline" ); assert_eq!( header_value(&buffer, "Content-Type"), - Some("application/json") + Some("application/json"), + "a header is found whatever the case it is asked for in" ); assert_eq!(header_value(&buffer, "authorization"), Some("Bearer tok")); assert_eq!(header_value(&buffer, "x-missing"), None); @@ -109,11 +111,24 @@ mod tests { fn tolerates_whitespace_and_skips_malformed_lines() { assert_eq!( header_value(b"Content-Type: text/html \ngarbage-line", "content-type"), - Some("text/html") + Some("text/html"), + "surrounding whitespace is trimmed and a line without a colon is skipped" + ); + assert_eq!( + header_value(b"no colon here", "content-type"), + None, + "a buffer with no well-formed line has no headers" + ); + assert_eq!( + header_value(&[0xff, 0xfe], "content-type"), + None, + "a buffer that is not UTF-8 has no headers" + ); + assert_eq!( + header_value(b"", "content-type"), + None, + "an empty buffer has no headers" ); - assert_eq!(header_value(b"no colon here", "content-type"), None); - assert_eq!(header_value(&[0xff, 0xfe], "content-type"), None); - assert_eq!(header_value(b"", "content-type"), None); } /// The edge in front of production ZeroKMS answers a request with no @@ -133,10 +148,15 @@ mod tests { !ua.contains("Go-http-client"), "a host runtime's default user-agent is refused by the edge" ); - assert_eq!(header_value(&headers, "authorization"), Some("Bearer tok")); + assert_eq!( + header_value(&headers, "authorization"), + Some("Bearer tok"), + "the credential travels with the user-agent" + ); assert_eq!( header_value(&headers, "content-type"), - Some("application/json") + Some("application/json"), + "the content type travels with the user-agent" ); } @@ -147,7 +167,8 @@ mod tests { buffer.extend_from_slice(b"\ncontent-type: application/json"); assert_eq!( header_value(&buffer, "content-type"), - Some("application/json") + Some("application/json"), + "a header after a non-UTF-8 line is still found" ); } } diff --git a/packages/stack-encrypt/src/dynamic/context.rs b/packages/stack-encrypt/src/dynamic/context.rs index 9881d4e8f..d7b708544 100644 --- a/packages/stack-encrypt/src/dynamic/context.rs +++ b/packages/stack-encrypt/src/dynamic/context.rs @@ -62,6 +62,27 @@ use crate::{AadPiece, NonEmpty}; /// `[]` and `[""]` are, `["", 7]` is not) — the rule its `Option` and tuple /// impls follow. /// +/// # Examples +/// +/// The list a binding spells and the tuple a Rust caller writes are one +/// context: +/// +/// ``` +/// use stack_encrypt::dynamic::{context, FfiValue}; +/// use stack_encrypt::{nonempty, IntoAad}; +/// +/// let parsed = context(FfiValue::Array(vec![ +/// FfiValue::String("users/age".into()), +/// FfiValue::UInt64(7), +/// ]))?; +/// let typed = nonempty!("users/age").with(7u64); +/// assert_eq!( +/// parsed.into_inner().into_aad().as_bytes(), +/// typed.into_aad().as_bytes() +/// ); +/// # Ok::<(), stack_encrypt::dynamic::Error>(()) +/// ``` +/// /// # Errors /// /// [`Error::Context`] for anything outside the shape above, and for a @@ -143,10 +164,15 @@ mod tests { #[test] fn a_bare_string_is_the_flat_context() { let parsed = context(s("users/age")).expect("flat context"); - assert_eq!(parsed.get(), &AadPiece::Text(Cow::Borrowed("users/age"))); + assert_eq!( + parsed.get(), + &AadPiece::Text(Cow::Borrowed("users/age")), + "a bare string is one text part, not a one-element list" + ); assert_eq!( parsed.into_inner().into_aad().as_bytes(), - "users/age".into_aad().as_bytes() + "users/age".into_aad().as_bytes(), + "the AAD is the string's own, unframed" ); } @@ -157,11 +183,13 @@ mod tests { let tuple = nonempty!("users/age").with(7u64); assert_eq!( parsed.clone().into_inner().into_aad().as_bytes(), - tuple.into_aad().as_bytes() + tuple.into_aad().as_bytes(), + "a two-element list is the pair on the AAD side" ); assert_eq!( parsed.into_inner().into_prf_context().as_bytes(), - tuple.into_prf_context().as_bytes() + tuple.into_prf_context().as_bytes(), + "a two-element list is the pair on the PRF side" ); } @@ -171,11 +199,13 @@ mod tests { .expect("extended context"); assert_eq!( Descriptor::of(parsed.into_inner()).as_str(), - "users/age|7u64" + "users/age|7u64", + "the list renders its parts joined by `|`" ); assert_eq!( Descriptor::of(nonempty!("users/age").with(7u64)).as_str(), - "users/age|7u64" + "users/age|7u64", + "the tuple renders the same descriptor" ); } @@ -189,11 +219,13 @@ mod tests { let tuple = ("users/age", ("t", -3i32)); assert_eq!( parsed.clone().into_inner().into_aad().as_bytes(), - tuple.into_aad().as_bytes() + tuple.into_aad().as_bytes(), + "a nested list is the nested tuple on the AAD side" ); assert_eq!( parsed.into_inner().into_prf_context().as_bytes(), - tuple.into_prf_context().as_bytes() + tuple.into_prf_context().as_bytes(), + "a nested list is the nested tuple on the PRF side" ); } @@ -291,11 +323,13 @@ mod tests { let bare = context(s("a")).expect("bare"); assert_ne!( list.clone().into_inner().into_aad().as_bytes(), - bare.clone().into_inner().into_aad().as_bytes() + bare.clone().into_inner().into_aad().as_bytes(), + "[x] is PAE-framed and x is not, so their AAD differs" ); assert_ne!( list.into_inner().into_prf_context().as_bytes(), - bare.into_inner().into_prf_context().as_bytes() + bare.into_inner().into_prf_context().as_bytes(), + "[x] is PAE-framed and x is not, so their PRF context differs" ); } @@ -307,11 +341,13 @@ mod tests { .into_inner(); assert_eq!( text.clone().into_aad().as_bytes(), - bytes.clone().into_aad().as_bytes() + bytes.clone().into_aad().as_bytes(), + "text and bytes of the same content share AAD bytes" ); assert_ne!( text.into_prf_context().as_bytes(), - bytes.into_prf_context().as_bytes() + bytes.into_prf_context().as_bytes(), + "text and bytes are distinct PRF encodings" ); } diff --git a/packages/stack-encrypt/src/dynamic/record.rs b/packages/stack-encrypt/src/dynamic/record.rs index f2ef1b99f..c8fa7acbb 100644 --- a/packages/stack-encrypt/src/dynamic/record.rs +++ b/packages/stack-encrypt/src/dynamic/record.rs @@ -18,10 +18,16 @@ //! # One context per field, both halves //! //! A field's context is proven [`NonEmpty`] once, when the plan is built, -//! and the same context drives the field's ciphertext and every one of its -//! terms. That is ADR-0004's property, held here by construction rather than -//! by a bound: [`encrypt`] never sees two contexts for one field, so it -//! cannot seal the value under one and index it under another. +//! and one borrowed view of it — a single local in the row builder — drives +//! the field's ciphertext and every one of its terms. That is ADR-0004's +//! property. The typed path holds it with a type parameter threaded through +//! the declaration tree; this path has no tree to thread, sealing through +//! the cipher-directed `encrypt_with_aad` instead, so it holds it by one +//! variable: [`encrypt`] never has two contexts for a field in hand, so it +//! cannot seal the value under one and index it under another. That is +//! enforcement by shape rather than by type, and the tests here pin it — a +//! record's `"c"` opens under its plan context and its terms equal the +//! standalone derivation under that same context. //! //! A plan context is the *whole* context of its field. There is no caller //! context to extend it with, so the plan spells the extension itself: a @@ -47,7 +53,7 @@ use stack_kms::DataKeySource; use vitaminc_aead_value::FfiValue; use vitaminc_protected::Protected; -use super::{borrowed, term, utf8, Error, Opener, Scalar, TermKind}; +use super::{borrowed, term, utf8, Error, Scalar, Scope, TermKind}; use crate::target::Pending; use crate::{ AadPiece, BoxedPassthrough, CipherText, Encrypt, KeysetCipher, NonEmpty, StackCipherText, @@ -57,7 +63,7 @@ use crate::{ /// /// The strings are wire format twice over: they are how a binding spells an /// output, *and* the keys of the per-field output map in the stored result. -/// See the [module docs](super) on stability. +/// That is why this enum is exhaustive — see the [module docs](super#stability). #[derive(Clone, Copy, PartialEq, Eq, Debug, Hash)] pub enum Output { /// `"c"` — the field's [`StackCipherText`]. @@ -171,6 +177,36 @@ impl FieldPlan { /// string, bytes, an integer, or a list of those, with what each spells in /// Rust and the emptiness rule. /// +/// # Examples +/// +/// ``` +/// use stack_encrypt::dynamic::{record, FfiValue, Output, TermKind}; +/// +/// // As a binding would decode it from its caller: seal `age` under +/// // "users/age" and index it for equality. +/// let plan = record::plan(FfiValue::Object(vec![( +/// "age".to_string(), +/// FfiValue::Object(vec![ +/// ("context".to_string(), FfiValue::String("users/age".into())), +/// ( +/// "outputs".to_string(), +/// FfiValue::Array(vec![ +/// FfiValue::String("c".into()), +/// FfiValue::String("eq".into()), +/// ]), +/// ), +/// ]), +/// )]))?; +/// +/// assert_eq!(plan.len(), 1); +/// assert_eq!(plan[0].name(), "age"); +/// assert_eq!( +/// plan[0].outputs(), +/// [Output::Ciphertext, Output::Term(TermKind::Equality)] +/// ); +/// # Ok::<(), stack_encrypt::dynamic::Error>(()) +/// ``` +/// /// # Errors /// /// [`Error::Plan`] for an empty plan, a missing or malformed context, an @@ -222,11 +258,6 @@ pub fn plan(value: FfiValue) -> Result<Vec<FieldPlan>, Error> { .collect() } -/// A row's assembled outputs, ciphertext slots still pending: the terms are -/// derived (locally), and each `None` is filled from the settled ciphertexts -/// in build order. -type RowSkeleton = Vec<(String, Vec<(&'static str, Option<Vec<u8>>)>)>; - /// Encrypt a record — or a batch of records — per a plan. /// /// `source` is an [`FfiValue::Object`] of `{ field: scalar }` (one record), @@ -239,6 +270,48 @@ type RowSkeleton = Vec<(String, Vec<(&'static str, Option<Vec<u8>>)>)>; /// `"c"` is the field's sealed ciphertext subtree and each term rides as a /// passthrough byte node. A batch is a sequence of such maps. /// +/// # Examples +/// +/// ``` +/// use stack_encrypt::dynamic::{record, FfiValue, Scope}; +/// use stack_encrypt::StackCipher; +/// use stack_kms::FakeDataKeySource; +/// +/// # tokio::runtime::Builder::new_current_thread().enable_all().build().unwrap().block_on(async { +/// let cipher = StackCipher::builder() +/// .kms(FakeDataKeySource::new()) +/// .init() +/// .await?; +/// let keyset = cipher.default_keyset(); +/// +/// // Seal `age` under "users/age" with an equality term beside it. +/// let plan = record::plan(FfiValue::Object(vec![( +/// "age".to_string(), +/// FfiValue::Object(vec![ +/// ("context".to_string(), FfiValue::String("users/age".into())), +/// ( +/// "outputs".to_string(), +/// FfiValue::Array(vec![ +/// FfiValue::String("c".into()), +/// FfiValue::String("eq".into()), +/// ]), +/// ), +/// ]), +/// )]))?; +/// +/// let row = FfiValue::Object(vec![("age".to_string(), FfiValue::UInt32(34))]); +/// let sealed = record::encrypt(&keyset, row, &plan).await?; +/// +/// // Only the ciphertext comes back; the term is one-way. +/// let opened = record::decrypt(Scope::Client(&cipher), sealed, &plan).await?; +/// let FfiValue::Object(fields) = opened else { +/// unreachable!("one record opens to one object"); +/// }; +/// assert!(matches!(&fields[..], [(name, FfiValue::UInt32(34))] if name == "age")); +/// # Ok::<(), stack_encrypt::dynamic::Error>(()) +/// # }).unwrap(); +/// ``` +/// /// # Cross-language note /// /// A `"c"` leaf seals the aead-value *tagged* plaintext encoding (`[type @@ -262,49 +335,53 @@ pub async fn encrypt<K>( where K: DataKeySource + Sync, { - let (rows, batched) = source_rows(source, plan)?; + let Rows { rows, batched } = source_rows(source, plan)?; // Build every row: terms derive now (local), ciphertexts queue their // data-key requests into one flat pending list. let mut pendings: Vec<Pending<'_, StackCipherText, K>> = Vec::new(); - let mut skeletons: Vec<RowSkeleton> = Vec::with_capacity(rows.len()); + let mut skeletons: Vec<Vec<FieldSkeleton>> = Vec::with_capacity(rows.len()); for row in rows { skeletons.push(build_row(cipher, row, plan, &mut pendings).await?); } // The one batched key request for the whole invocation. - let sealed = Pending::all(cipher, pendings).await?; - let mut sealed = sealed.into_iter(); + let mut sealed = Settled::of(Pending::all(cipher, pendings).await?); // Fill the ciphertext slots back in, in build order. - let mut row_nodes = Vec::with_capacity(skeletons.len()); - for skeleton in skeletons { - let mut fields = Vec::with_capacity(skeleton.len()); - for (field, outputs) in skeleton { - let mut nodes = Vec::with_capacity(outputs.len()); - for (key, slot) in outputs { - let node = match slot { - Some(term) => CipherText::Passthrough(Box::new(FfiValue::Bytes(Protected::new( - term, - ))) - as BoxedPassthrough), - None => sealed.next().ok_or(Error::Internal)?, - }; - nodes.push((key.to_string(), node)); - } - fields.push((field, CipherText::Map(nodes))); - } - row_nodes.push(CipherText::Map(fields)); - } - if sealed.next().is_some() { - return Err(Error::Internal); - } + let row_nodes = skeletons + .into_iter() + .map(|skeleton| { + let fields = skeleton + .into_iter() + .map(|field| { + let nodes = field + .outputs + .into_iter() + .map(|(key, slot)| { + let node = match slot { + Slot::Term(term) => CipherText::Passthrough(Box::new( + FfiValue::Bytes(Protected::new(term)), + ) + as BoxedPassthrough), + Slot::Ciphertext => sealed.next()?, + }; + Ok((key.to_string(), node)) + }) + .collect::<Result<Vec<_>, Error>>()?; + Ok((field.name, CipherText::Map(nodes))) + }) + .collect::<Result<Vec<_>, Error>>()?; + Ok(CipherText::Map(fields)) + }) + .collect::<Result<Vec<_>, Error>>()?; + sealed.finish()?; - if batched { - Ok(CipherText::Sequence(row_nodes)) - } else { - row_nodes.pop().ok_or(Error::Internal) + Rows { + rows: row_nodes, + batched, } + .reshape(CipherText::Sequence) } /// Decrypt a record — or a batch — produced by [`encrypt`] under the same @@ -314,22 +391,23 @@ where /// [`FfiValue::Object`] per record holding the plan's ciphertext-bearing /// fields, in plan order — or an [`FfiValue::Array`] of them for a batch. /// One batched `retrieve_keys` per invocation and, when opening through -/// [`Opener::Any`], one per keyset the leaves were sealed under. +/// [`Scope::Client`], one per keyset the leaves were sealed under. /// /// # Errors /// /// [`Error::Record`] if the stored tree does not fit the plan; /// [`Error::Cipher`] if opening fails — including the expected outcome for -/// a wrong context, a wrong key or a tampered ciphertext. +/// a wrong context, a wrong key, a tampered ciphertext, or a leaf from a +/// keyset other than a [`Scope::Keyset`]'s. pub async fn decrypt<K>( - opener: Opener<'_, K>, + scope: Scope<'_, K>, record: StackCipherText, plan: &[FieldPlan], ) -> Result<FfiValue, Error> where K: DataKeySource + Sync + 'static, { - let (rows, batched) = record_leaves(record, plan)?; + let Rows { rows, batched } = record_leaves(record, plan)?; let contexts = plan .iter() .filter(|field| field.has_ciphertext()) @@ -347,11 +425,11 @@ where let mut row_names = Vec::with_capacity(row.len()); for ((name, ct), context) in row.into_iter().zip(&contexts) { let context = context.clone(); - // The scope is the opener's, the declaration is the target's: + // The scope is the caller's, the declaration is the target's: // `decrypt_as` takes one context and drives both halves with it. - pendings.push(match &opener { - Opener::Any(cipher) => cipher.decrypt_as(ct, context.into()), - Opener::Only(keyset) => keyset.decrypt_as(ct, context.into()), + pendings.push(match &scope { + Scope::Client(cipher) => cipher.decrypt_as(ct, context.into()), + Scope::Keyset(keyset) => keyset.decrypt_as(ct, context.into()), }); row_names.push(name); } @@ -359,29 +437,28 @@ where } // The one batched key request for the whole invocation. - let values = match &opener { - Opener::Any(cipher) => Pending::all(*cipher, pendings).await, - Opener::Only(keyset) => Pending::all(keyset, pendings).await, - }?; - let mut values = values.into_iter(); + let mut values = Settled::of(match &scope { + Scope::Client(cipher) => Pending::all(*cipher, pendings).await, + Scope::Keyset(keyset) => Pending::all(keyset, pendings).await, + }?); - let mut row_values = Vec::with_capacity(names.len()); - for row_names in names { - let mut entries = Vec::with_capacity(row_names.len()); - for name in row_names { - entries.push((name, values.next().ok_or(Error::Internal)?)); - } - row_values.push(FfiValue::Object(entries)); - } - if values.next().is_some() { - return Err(Error::Internal); - } + let row_values = names + .into_iter() + .map(|row_names| { + let entries = row_names + .into_iter() + .map(|name| Ok((name, values.next()?))) + .collect::<Result<Vec<_>, Error>>()?; + Ok(FfiValue::Object(entries)) + }) + .collect::<Result<Vec<_>, Error>>()?; + values.finish()?; - if batched { - Ok(FfiValue::Array(row_values)) - } else { - row_values.pop().ok_or(Error::Internal) + Rows { + rows: row_values, + batched, } + .reshape(FfiValue::Array) } /// Check a source against a plan without encrypting it — everything @@ -409,58 +486,221 @@ pub fn check_record(record: StackCipherText, plan: &[FieldPlan]) -> Result<(), E record_leaves(record, plan).map(drop) } +// ============================================================================= +// The two trees a record path walks +// ============================================================================= + +/// The two trees a record path walks — a source ([`FfiValue`]) and a stored +/// record ([`StackCipherText`]) — seen the one way the path needs to see +/// them: as one row (a map of named nodes) or a sequence of rows, and as a +/// tree that may carry a passthrough somewhere inside it. +trait RecordTree: Sized { + /// The error a tree that does not fit its plan reports. + const MISFIT: Error; + + /// The tree as a row's entries, a batch's rows, or neither. + fn shape(self) -> Shape<Self>; + + /// Whether this node is a passthrough. + fn is_passthrough(&self) -> bool; + + /// The node's children, for a container. + fn children(&self) -> Children<'_, Self>; +} + +/// A tree read as rows. +enum Shape<T> { + /// One row: its named entries. + Row(Vec<(String, T)>), + /// A batch: its rows, each still to be read as one. + Batch(Vec<T>), + /// Neither. + Other, +} + +/// A node's children. +enum Children<'a, T> { + Sequence(&'a [T]), + Map(&'a [(String, T)]), + None, +} + +impl RecordTree for FfiValue { + const MISFIT: Error = Error::Source; + + fn shape(self) -> Shape<Self> { + match self { + FfiValue::Object(entries) => Shape::Row(entries), + FfiValue::Array(items) => Shape::Batch(items), + _ => Shape::Other, + } + } + + fn is_passthrough(&self) -> bool { + matches!(self, FfiValue::Passthrough(_)) + } + + fn children(&self) -> Children<'_, Self> { + match self { + FfiValue::Array(items) => Children::Sequence(items), + FfiValue::Object(entries) => Children::Map(entries), + _ => Children::None, + } + } +} + +impl RecordTree for StackCipherText { + const MISFIT: Error = Error::Record; + + fn shape(self) -> Shape<Self> { + match self { + CipherText::Map(entries) => Shape::Row(entries), + CipherText::Sequence(items) => Shape::Batch(items), + _ => Shape::Other, + } + } + + fn is_passthrough(&self) -> bool { + matches!(self, CipherText::Passthrough(_)) + } + + // Exhaustive, so a variant added to `CipherText` has to say here whether + // it can hide a passthrough. + fn children(&self) -> Children<'_, Self> { + match self { + CipherText::Sequence(items) => Children::Sequence(items), + CipherText::Map(entries) => Children::Map(entries), + CipherText::Passthrough(_) + | CipherText::Single(_) + | CipherText::None(_) + | CipherText::EmptySequence(_) + | CipherText::EmptyMap(_) => Children::None, + } + } +} + +/// The rows of a call — one record, or a batch of them — carried with +/// whether they came as a batch, so the result takes the shape the input +/// had. +struct Rows<T> { + rows: Vec<T>, + batched: bool, +} + +/// Each row of `tree`, as its named entries: one row for a map, one per item +/// for a sequence of maps, and the tree's misfit error for anything else. +fn rows<V: RecordTree>(tree: V) -> Result<Rows<Vec<(String, V)>>, Error> { + match tree.shape() { + Shape::Row(entries) => Ok(Rows { + rows: vec![entries], + batched: false, + }), + Shape::Batch(items) => Ok(Rows { + rows: items + .into_iter() + .map(|item| match item.shape() { + Shape::Row(entries) => Ok(entries), + _ => Err(V::MISFIT), + }) + .collect::<Result<Vec<_>, Error>>()?, + batched: true, + }), + Shape::Other => Err(V::MISFIT), + } +} + +impl<T> Rows<T> { + fn try_map<U>(self, f: impl FnMut(T) -> Result<U, Error>) -> Result<Rows<U>, Error> { + Ok(Rows { + rows: self + .rows + .into_iter() + .map(f) + .collect::<Result<Vec<_>, Error>>()?, + batched: self.batched, + }) + } + + /// The rows in the shape the input had: `batch` over all of them for a + /// batch, the one row bare otherwise. + fn reshape(self, batch: impl FnOnce(Vec<T>) -> T) -> Result<T, Error> { + let Rows { mut rows, batched } = self; + if batched { + Ok(batch(rows)) + } else { + rows.pop().ok_or(Error::Internal) + } + } +} + +/// Take the entry named `name` out of a row, whatever order the row had it +/// in. `None` if the row has no such entry. +fn take<T>(row: &mut Vec<(String, T)>, name: &str) -> Option<(String, T)> { + let at = row.iter().position(|(n, _)| n == name)?; + Some(row.swap_remove(at)) +} + +/// Reject a tree that contains a passthrough anywhere. +/// +/// On the encrypt side a source field value with one inside it must not +/// reach a `"c"` slot: a passthrough node is *unauthenticated by definition* +/// — on decrypt it hands its payload back with no AEAD opened — so admitting +/// one under a field the plan declares ciphertext-bearing would quietly +/// produce a slot whose bytes verify nothing. On the decrypt side a `"c"` +/// subtree with one inside it is the load-bearing half: `decrypt_as` +/// collects **zero** retrieve-requests for a passthrough and returns its +/// payload with no AEAD opened, so an attacker with write access to the +/// stored tree could replace a field's `"c"` subtree with a passthrough +/// carrying forged plaintext, and this check's absence would report it as a +/// successful decrypt. [`encrypt`] never produces a passthrough under `"c"`, +/// so the shape is unconditionally an error, and the encrypt-side check is +/// what makes that a round-trip invariant rather than data loss. +fn reject_passthrough<T: RecordTree>(tree: &T) -> Result<(), Error> { + if tree.is_passthrough() { + return Err(T::MISFIT); + } + match tree.children() { + Children::Sequence(items) => items.iter().try_for_each(reject_passthrough), + Children::Map(entries) => entries + .iter() + .try_for_each(|(_, node)| reject_passthrough(node)), + Children::None => Ok(()), + } +} + +// ============================================================================= +// Encrypt side +// ============================================================================= + /// The rows of a record source, each aligned to the plan's field order, with /// everything that can be checked without a cipher checked: the source is /// one object or an array of objects, every plan field is present in every /// row and no row carries a field the plan does not name (silently dropping /// a field on either side would lose data or index nothing), and each value -/// fits its field's outputs ([`check_field`]). The `bool` is whether the -/// source was a batch. -fn source_rows(source: FfiValue, plan: &[FieldPlan]) -> Result<(Vec<Vec<FfiValue>>, bool), Error> { - let (rows, batched) = match source { - FfiValue::Object(entries) => (vec![entries], false), - FfiValue::Array(items) => { - let rows = items - .into_iter() - .map(|item| match item { - FfiValue::Object(entries) => Ok(entries), - _ => Err(Error::Source), - }) - .collect::<Result<Vec<_>, Error>>()?; - (rows, true) +/// fits its field's outputs ([`check_field`]). +fn source_rows(source: FfiValue, plan: &[FieldPlan]) -> Result<Rows<Vec<FfiValue>>, Error> { + rows(source)?.try_map(|mut row| { + if row.len() != plan.len() { + return Err(Error::Source); } - _ => return Err(Error::Source), - }; - let rows = rows - .into_iter() - .map(|mut row| { - if row.len() != plan.len() { - return Err(Error::Source); - } - plan.iter() - .map(|field| { - let at = row - .iter() - .position(|(name, _)| name == &field.name) - .ok_or(Error::Source)?; - let (_, value) = row.swap_remove(at); - check_field(&value, field)?; - Ok(value) - }) - .collect::<Result<Vec<_>, Error>>() - }) - .collect::<Result<Vec<_>, Error>>()?; - Ok((rows, batched)) + plan.iter() + .map(|field| { + let (_, value) = take(&mut row, &field.name).ok_or(Error::Source)?; + check_field(&value, field)?; + Ok(value) + }) + .collect() + }) } /// A source value against its plan field: every term output needs a scalar /// the scheme defines the term for ([`TermKind::supports`]), and a /// ciphertext output refuses a passthrough anywhere in the value -/// ([`reject_passthrough_value`]). +/// ([`reject_passthrough`]). fn check_field(value: &FfiValue, field: &FieldPlan) -> Result<(), Error> { for output in &field.outputs { match output { - Output::Ciphertext => reject_passthrough_value(value)?, + Output::Ciphertext => reject_passthrough(value)?, Output::Term(kind) => { let scalar = Scalar::of(value, *kind)?; if !kind.supports(&scalar) { @@ -472,57 +712,41 @@ fn check_field(value: &FfiValue, field: &FieldPlan) -> Result<(), Error> { Ok(()) } -/// The `"c"` subtrees a record tree holds for the plan's ciphertext-bearing -/// fields, per row in plan order, with the row's field name: the tree is one -/// map or a sequence of maps, each such field is present, is a map of -/// outputs with a `"c"` node, and that node is not a passthrough -/// ([`reject_passthrough_tree`]). Terms and fields the plan does not name -/// are ignored (comparands, not ciphertext). The `bool` is whether the tree -/// was a batch. -#[allow(clippy::type_complexity)] -fn record_leaves( - tree: StackCipherText, - plan: &[FieldPlan], -) -> Result<(Vec<Vec<(String, StackCipherText)>>, bool), Error> { - let (rows, batched) = match tree { - CipherText::Map(entries) => (vec![entries], false), - CipherText::Sequence(items) => { - let rows = items - .into_iter() - .map(|item| match item { - CipherText::Map(entries) => Ok(entries), - _ => Err(Error::Record), - }) - .collect::<Result<Vec<_>, Error>>()?; - (rows, true) +/// One field of a built row: its name and, per output in plan order, the +/// key and what fills it. +struct FieldSkeleton { + name: String, + outputs: Vec<(&'static str, Slot)>, +} + +/// What fills an output slot: a term, derived as the row was built, or the +/// ciphertext still pending in the row's batch, filled in build order once +/// the batch settles. +enum Slot { + Term(Vec<u8>), + Ciphertext, +} + +/// The values a batch settled to, handed back one per slot in build order. +/// The count has to come out exact — a slot with no value, or a value with +/// no slot, means the merge miscounted, which is a bug here. +struct Settled<T>(std::vec::IntoIter<T>); + +impl<T> Settled<T> { + fn of(values: Vec<T>) -> Self { + Self(values.into_iter()) + } + + fn next(&mut self) -> Result<T, Error> { + self.0.next().ok_or(Error::Internal) + } + + fn finish(mut self) -> Result<(), Error> { + match self.0.next() { + Some(_) => Err(Error::Internal), + None => Ok(()), } - _ => return Err(Error::Record), - }; - let rows = rows - .into_iter() - .map(|mut row| { - plan.iter() - .filter(|field| field.has_ciphertext()) - .map(|field| { - let at = row - .iter() - .position(|(name, _)| name == &field.name) - .ok_or(Error::Record)?; - let (name, node) = row.swap_remove(at); - let CipherText::Map(outputs) = node else { - return Err(Error::Record); - }; - let ct = outputs - .into_iter() - .find_map(|(key, node)| (key == "c").then_some(node)) - .ok_or(Error::Record)?; - reject_passthrough_tree(&ct)?; - Ok((name, ct)) - }) - .collect::<Result<Vec<_>, Error>>() - }) - .collect::<Result<Vec<_>, Error>>()?; - Ok((rows, batched)) + } } /// Build one record row: derive its terms and queue its ciphertext pendings, @@ -534,7 +758,7 @@ async fn build_row<'c, K>( row: Vec<FfiValue>, plan: &[FieldPlan], pendings: &mut Vec<Pending<'c, StackCipherText, K>>, -) -> Result<RowSkeleton, Error> +) -> Result<Vec<FieldSkeleton>, Error> where K: DataKeySource + Sync, { @@ -545,8 +769,9 @@ where let mut skeleton = Vec::with_capacity(plan.len()); for (field, value) in plan.iter().zip(row) { let name = field.name.clone(); - // One borrowed view of the field's context, cloned per output: the - // same context reaches the ciphertext and every term (ADR-0004). + // The one context this field has, cloned per output: this variable + // is what reaches the ciphertext and every term (ADR-0004), and + // there is no other. let context = field.view()?; // Terms first — they lift a copy of the scalar; the value itself is @@ -562,73 +787,986 @@ where .map(|kind| Scalar::of(&value, kind)) .transpose()?; - let mut outputs: Vec<(&'static str, Option<Vec<u8>>)> = - Vec::with_capacity(field.outputs.len()); + let mut outputs = Vec::with_capacity(field.outputs.len()); for output in &field.outputs { let Output::Term(kind) = output else { - outputs.push((output.key(), None)); + outputs.push((output.key(), Slot::Ciphertext)); continue; }; let scalar = scalar.clone().ok_or(Error::Internal)?; outputs.push(( output.key(), - Some(term(cipher, scalar, *kind, context.clone()).await?), + Slot::Term(term(cipher, scalar, *kind, context.clone()).await?), )); } if field.has_ciphertext() { - reject_passthrough_value(&value)?; + reject_passthrough(&value)?; let tree = value .encrypt_with_aad(cipher, context.clone()) .map_err(|_| Error::Internal)?; pendings.push(tree.into_pending(cipher, context)); } - skeleton.push((name, outputs)); + skeleton.push(FieldSkeleton { name, outputs }); } Ok(skeleton) } -/// Reject a source field value that contains a passthrough anywhere, before -/// it reaches a `"c"` slot. -/// -/// A passthrough node is *unauthenticated by definition* — on decrypt it -/// hands its payload back with no AEAD opened — so admitting one under a -/// field the plan declares ciphertext-bearing would quietly produce a slot -/// whose bytes verify nothing. Rejecting it here is what makes -/// [`reject_passthrough_tree`]'s mirror-image rejection a round-trip -/// invariant rather than data loss. -fn reject_passthrough_value(value: &FfiValue) -> Result<(), Error> { - match value { - FfiValue::Passthrough(_) => Err(Error::Source), - FfiValue::Array(items) => items.iter().try_for_each(reject_passthrough_value), - FfiValue::Object(entries) => entries - .iter() - .try_for_each(|(_, v)| reject_passthrough_value(v)), - _ => Ok(()), - } +// ============================================================================= +// Decrypt side +// ============================================================================= + +/// The `"c"` subtrees a record tree holds for the plan's ciphertext-bearing +/// fields, per row in plan order, with the row's field name: the tree is one +/// map or a sequence of maps, each such field is present, is a map of +/// outputs with a `"c"` node, and that node has no passthrough in it +/// ([`reject_passthrough`]). Terms and fields the plan does not name are +/// ignored (comparands, not ciphertext). +#[allow(clippy::type_complexity)] +fn record_leaves( + tree: StackCipherText, + plan: &[FieldPlan], +) -> Result<Rows<Vec<(String, StackCipherText)>>, Error> { + rows(tree)?.try_map(|mut row| { + plan.iter() + .filter(|field| field.has_ciphertext()) + .map(|field| { + let (name, node) = take(&mut row, &field.name).ok_or(Error::Record)?; + let Shape::Row(mut outputs) = node.shape() else { + return Err(Error::Record); + }; + let (_, ct) = take(&mut outputs, Output::Ciphertext.key()).ok_or(Error::Record)?; + reject_passthrough(&ct)?; + Ok((name, ct)) + }) + .collect() + }) } -/// Reject a `"c"` subtree that contains a passthrough anywhere. -/// -/// This is the decrypt-side half of [`reject_passthrough_value`], and it is -/// load-bearing: `decrypt_as` collects **zero** retrieve-requests for a -/// passthrough and returns its payload with no AEAD opened, so an attacker -/// with write access to the stored tree could replace a field's `"c"` -/// subtree with a passthrough carrying forged plaintext, and this function's -/// absence would report it as a successful decrypt. [`encrypt`] never -/// produces a passthrough under `"c"`, so the shape is unconditionally an -/// error. -fn reject_passthrough_tree(tree: &StackCipherText) -> Result<(), Error> { - match tree { - CipherText::Passthrough(_) => Err(Error::Record), - CipherText::Sequence(items) => items.iter().try_for_each(reject_passthrough_tree), - CipherText::Map(entries) => entries - .iter() - .try_for_each(|(_, v)| reject_passthrough_tree(v)), - CipherText::Single(_) - | CipherText::None(_) - | CipherText::EmptySequence(_) - | CipherText::EmptyMap(_) => Ok(()), +#[cfg(test)] +mod tests { + use super::*; + use std::borrow::Cow; + use std::sync::atomic::{AtomicUsize, Ordering}; + + use stack_kms::{ + DataKey, DataKeyWithTag, FakeDataKeySource, GenerateKeyPayload, IdentifiedBy, IndexKey, + IndexKeySource, RetrieveKeyPayload, UnverifiedContext, + }; + use uuid::Uuid; + use vitaminc_protected::Controlled; + + use crate::dynamic::context; + use crate::{nonempty, StackCipher}; + + /// `FakeDataKeySource` with call counters, so the batching contract — + /// one key request per invocation, none for a refused call — is + /// asserted rather than trusted. + #[derive(Default)] + struct Counting { + inner: FakeDataKeySource, + generate_calls: AtomicUsize, + retrieve_calls: AtomicUsize, + } + + impl DataKeySource for Counting { + async fn generate_keys( + &self, + payloads: Vec<GenerateKeyPayload<'_>>, + keyset_id: Option<Uuid>, + unverified_context: Option<Cow<'_, UnverifiedContext>>, + ) -> Result<Vec<DataKeyWithTag>, stack_kms::Error> { + let _ = self.generate_calls.fetch_add(1, Ordering::SeqCst); + self.inner + .generate_keys(payloads, keyset_id, unverified_context) + .await + } + + async fn retrieve_keys( + &self, + payloads: Vec<RetrieveKeyPayload<'_>>, + keyset_id: Option<Uuid>, + unverified_context: Option<&UnverifiedContext>, + ) -> Result<Vec<DataKey>, stack_kms::Error> { + let _ = self.retrieve_calls.fetch_add(1, Ordering::SeqCst); + self.inner + .retrieve_keys(payloads, keyset_id, unverified_context) + .await + } + } + + impl IndexKeySource for Counting { + async fn load_index_key( + &self, + keyset_id: Option<IdentifiedBy>, + ) -> Result<(Uuid, IndexKey), stack_kms::Error> { + self.inner.load_index_key(keyset_id).await + } + } + + async fn cipher() -> StackCipher<Counting> { + StackCipher::builder() + .kms(Counting::default()) + .init() + .await + .expect("build cipher") + } + + fn generates(cipher: &StackCipher<Counting>) -> usize { + cipher.kms().generate_calls.load(Ordering::SeqCst) + } + + fn retrieves(cipher: &StackCipher<Counting>) -> usize { + cipher.kms().retrieve_calls.load(Ordering::SeqCst) + } + + // ---- values, as a binding would decode them ---------------------------- + + fn s(value: &str) -> FfiValue { + FfiValue::String(value.into()) + } + + fn obj(entries: Vec<(&str, FfiValue)>) -> FfiValue { + FfiValue::Object( + entries + .into_iter() + .map(|(k, v)| (k.to_string(), v)) + .collect(), + ) + } + + fn strings(items: &[&str]) -> FfiValue { + FfiValue::Array(items.iter().map(|item| s(item)).collect()) + } + + fn spec(context: FfiValue, outputs: &[&str]) -> FfiValue { + obj(vec![("context", context), ("outputs", strings(outputs))]) + } + + /// The plan most tests share: `age` sealed and indexed for equality and + /// order under `"users/age"`; `email` sealed alone under an extended + /// context; `nick` indexed for match only, never sealed. + fn plan_value() -> FfiValue { + obj(vec![ + ("age", spec(s("users/age"), &["c", "eq", "ore"])), + ( + "email", + spec( + FfiValue::Array(vec![s("users/email"), FfiValue::UInt64(7)]), + &["c"], + ), + ), + ("nick", spec(s("users/nick"), &["match"])), + ]) + } + + fn the_plan() -> Vec<FieldPlan> { + plan(plan_value()).expect("the shared plan parses") + } + + fn row(age: u32) -> FfiValue { + obj(vec![ + ("age", FfiValue::UInt32(age)), + ("email", s("a@x")), + ("nick", s("al smith")), + ]) + } + + // ---- reading results back ----------------------------------------------- + + fn map(tree: StackCipherText) -> Vec<(String, StackCipherText)> { + match tree { + CipherText::Map(entries) => entries, + _ => panic!("expected a map node"), + } + } + + fn sequence(tree: StackCipherText) -> Vec<StackCipherText> { + match tree { + CipherText::Sequence(items) => items, + _ => panic!("expected a sequence node"), + } + } + + fn object(value: FfiValue) -> Vec<(String, FfiValue)> { + match value { + FfiValue::Object(entries) => entries, + _ => panic!("expected an object"), + } + } + + fn array(value: FfiValue) -> Vec<FfiValue> { + match value { + FfiValue::Array(items) => items, + _ => panic!("expected an array"), + } + } + + fn keys<T>(entries: &[(String, T)]) -> Vec<&str> { + entries.iter().map(|(k, _)| k.as_str()).collect() + } + + fn node(entries: &mut Vec<(String, StackCipherText)>, key: &str) -> StackCipherText { + take(entries, key) + .unwrap_or_else(|| panic!("no {key} node")) + .1 + } + + fn term_bytes(node: &StackCipherText) -> Vec<u8> { + match node { + CipherText::Passthrough(payload) => match (**payload).downcast_ref::<FfiValue>() { + Some(FfiValue::Bytes(bytes)) => bytes.risky_ref().to_vec(), + _ => panic!("a term rides as a passthrough byte node"), + }, + _ => panic!("a term rides as a passthrough"), + } + } + + fn u32_of(value: &FfiValue) -> u32 { + match value { + FfiValue::UInt32(v) => *v, + _ => panic!("expected a u32"), + } + } + + fn text_of(value: &FfiValue) -> String { + match value { + FfiValue::String(s) => String::from_utf8(s.risky_ref().to_vec()).expect("utf8"), + _ => panic!("expected text"), + } + } + + fn forged(value: FfiValue) -> StackCipherText { + CipherText::Passthrough(Box::new(value) as BoxedPassthrough) + } + + /// A table row: what is refused, the value that must be refused, and + /// the error it must be refused with. `Error` is not `PartialEq`, so the + /// expectation is a predicate. + type Refused = (&'static str, FfiValue, fn(&Error) -> bool); + + mod given_a_plan_value { + use super::*; + + #[test] + fn parses_each_field_in_order_with_its_context_and_outputs() { + let plan = the_plan(); + assert_eq!( + plan.iter().map(FieldPlan::name).collect::<Vec<_>>(), + ["age", "email", "nick"], + "fields keep the plan's order" + ); + assert_eq!( + plan[0].outputs(), + [ + Output::Ciphertext, + Output::Term(TermKind::Equality), + Output::Term(TermKind::Ore) + ], + "outputs keep their spelled order" + ); + assert_eq!( + plan[1].outputs(), + [Output::Ciphertext], + "a field can be sealed alone" + ); + assert_eq!( + plan[2].outputs(), + [Output::Term(TermKind::Match)], + "a field can be indexed and never sealed" + ); + assert!( + plan[0].has_ciphertext() && plan[1].has_ciphertext() && !plan[2].has_ciphertext(), + "has_ciphertext follows the outputs" + ); + assert_eq!( + plan[1].context(), + &context(FfiValue::Array(vec![s("users/email"), FfiValue::UInt64(7)])) + .expect("context"), + "a field's context is the one its spec spelled, read by `context`" + ); + } + + #[test] + fn refuses_a_malformed_plan_before_any_field_is_built() { + let cases: Vec<Refused> = vec![ + ("a plan that is not an object", s("x"), |e| { + matches!(e, Error::Plan) + }), + ("an empty plan", obj(vec![]), |e| matches!(e, Error::Plan)), + ( + "a field spec that is not an object", + obj(vec![("age", s("x"))]), + |e| matches!(e, Error::Plan), + ), + ( + "a field spec with an unknown key", + obj(vec![( + "age", + obj(vec![ + ("context", s("users/age")), + ("outputs", strings(&["c"])), + ("nullable", FfiValue::Bool(true)), + ]), + )]), + |e| matches!(e, Error::Plan), + ), + ( + "a field spec with no context", + obj(vec![("age", obj(vec![("outputs", strings(&["c"]))]))]), + |e| matches!(e, Error::Plan), + ), + ( + "a field spec with no outputs", + obj(vec![("age", obj(vec![("context", s("users/age"))]))]), + |e| matches!(e, Error::Plan), + ), + ( + "outputs that are not a list", + obj(vec![("age", spec(s("users/age"), &[]))]), + |e| matches!(e, Error::Plan), + ), + ( + "an output that is not a string", + obj(vec![( + "age", + obj(vec![ + ("context", s("users/age")), + ("outputs", FfiValue::Array(vec![FfiValue::UInt32(1)])), + ]), + )]), + |e| matches!(e, Error::Plan), + ), + ( + "an unknown output", + obj(vec![("age", spec(s("users/age"), &["c", "sum"]))]), + |e| matches!(e, Error::Plan), + ), + ( + "an output named twice", + obj(vec![("age", spec(s("users/age"), &["c", "eq", "c"]))]), + |e| matches!(e, Error::Plan), + ), + ( + "a context that is not one", + obj(vec![("age", spec(FfiValue::Bool(true), &["c"]))]), + |e| matches!(e, Error::Context), + ), + ( + "a context that renders empty", + obj(vec![("age", spec(s(""), &["c"]))]), + |e| matches!(e, Error::Context), + ), + ]; + for (label, value, expected) in cases { + let err = plan(value).err(); + assert!( + err.as_ref().is_some_and(expected), + "{label} must be refused as the right error: {err:?}" + ); + } + } + + #[test] + fn a_field_plan_refuses_no_outputs_and_a_repeated_output() { + let ctx = context(s("users/age")).expect("context"); + assert!( + matches!(FieldPlan::new("age", ctx.clone(), vec![]), Err(Error::Plan)), + "a field must produce something" + ); + assert!( + matches!( + FieldPlan::new( + "age", + ctx.clone(), + vec![Output::Term(TermKind::Ore), Output::Term(TermKind::Ore)] + ), + Err(Error::Plan) + ), + "an output cannot be produced twice under one key" + ); + assert!( + FieldPlan::new("age", ctx, vec![Output::Ciphertext]).is_ok(), + "one output is a plan" + ); + } + } + + mod given_a_source_that_does_not_fit_the_plan { + use super::*; + + /// `check_source` is the parser `encrypt` runs, so a binding's + /// boundary rejection and the operation's are the same error — and + /// neither costs a key request. + #[tokio::test] + async fn encrypt_and_check_source_refuse_it_alike_with_no_key_request() { + let cipher = cipher().await; + let keyset = cipher.default_keyset(); + let plan = the_plan(); + let mut with_passthrough_in_a_list = object(row(1)); + with_passthrough_in_a_list[1].1 = + FfiValue::Array(vec![s("a@x"), FfiValue::Passthrough(Box::new(s("b@x")))]); + let cases: Vec<Refused> = vec![ + ("a source that is not an object", FfiValue::UInt32(1), |e| { + matches!(e, Error::Source) + }), + ( + "a batch with an item that is not an object", + FfiValue::Array(vec![row(1), FfiValue::UInt32(2)]), + |e| matches!(e, Error::Source), + ), + ( + "a row missing a plan field", + obj(vec![("age", FfiValue::UInt32(1)), ("email", s("a@x"))]), + |e| matches!(e, Error::Source), + ), + ( + "a row with a field the plan does not name", + { + let mut entries = object(row(1)); + entries.push(("extra".to_string(), s("x"))); + FfiValue::Object(entries) + }, + |e| matches!(e, Error::Source), + ), + ( + "a passthrough under a sealed field", + { + let mut entries = object(row(1)); + entries[1].1 = FfiValue::Passthrough(Box::new(s("a@x"))); + FfiValue::Object(entries) + }, + |e| matches!(e, Error::Source), + ), + ( + "a passthrough inside a list under a sealed field", + FfiValue::Object(with_passthrough_in_a_list), + |e| matches!(e, Error::Source), + ), + ( + "a container under an indexed field", + { + let mut entries = object(row(1)); + entries[2].1 = FfiValue::Array(vec![s("al")]); + FfiValue::Object(entries) + }, + |e| { + matches!( + e, + Error::Term { + kind: TermKind::Match + } + ) + }, + ), + ( + "a scalar the scheme has no such term for", + { + let mut entries = object(row(1)); + entries[2].1 = FfiValue::UInt32(3); + FfiValue::Object(entries) + }, + |e| { + matches!( + e, + Error::Term { + kind: TermKind::Match + } + ) + }, + ), + ]; + // A value is consumed by the call that checks it, so the table + // exercises the boundary parser and the operation is exercised + // below on the shapes a caller is likeliest to get wrong. + for (label, source, expected) in cases { + let err = check_source(source, &plan).err(); + assert!( + err.as_ref().is_some_and(expected), + "{label}: check_source must refuse it as the right error: {err:?}" + ); + } + let missing = obj(vec![("age", FfiValue::UInt32(1)), ("email", s("a@x"))]); + let err = encrypt(&keyset, missing, &plan).await.err(); + assert!( + matches!(err, Some(Error::Source)), + "encrypt refuses a row missing a plan field: {err:?}" + ); + let mut entries = object(row(1)); + entries[1].1 = FfiValue::Passthrough(Box::new(s("a@x"))); + let err = encrypt(&keyset, FfiValue::Object(entries), &plan) + .await + .err(); + assert!( + matches!(err, Some(Error::Source)), + "encrypt refuses a passthrough under a sealed field: {err:?}" + ); + let mut entries = object(row(1)); + entries[2].1 = FfiValue::UInt32(3); + let err = encrypt(&keyset, FfiValue::Object(entries), &plan) + .await + .err(); + assert!( + matches!( + err, + Some(Error::Term { + kind: TermKind::Match + }) + ), + "encrypt refuses a value with no such term: {err:?}" + ); + assert_eq!( + generates(&cipher), + 0, + "a refused source costs no key request" + ); + } + + #[tokio::test] + async fn a_float_asked_for_equality_is_refused_as_that_kind() { + let cipher = cipher().await; + let keyset = cipher.default_keyset(); + let plan = + plan(obj(vec![("score", spec(s("users/score"), &["c", "eq"]))])).expect("plan"); + let source = obj(vec![("score", FfiValue::Float64(1.5))]); + let err = encrypt(&keyset, source, &plan).await.err(); + assert!( + matches!( + err, + Some(Error::Term { + kind: TermKind::Equality + }) + ), + "no PRF encoding exists for a float: {err:?}" + ); + assert_eq!(generates(&cipher), 0, "refused before any key request"); + } + } + + mod given_one_record { + use super::*; + + #[tokio::test] + async fn seals_it_from_one_key_request_in_the_plan_shape() { + let cipher = cipher().await; + let keyset = cipher.default_keyset(); + let plan = the_plan(); + + let sealed = encrypt(&keyset, row(34), &plan).await.expect("encrypt"); + assert_eq!( + generates(&cipher), + 1, + "every ciphertext leaf seals from one batched key request" + ); + + let mut fields = map(sealed); + assert_eq!( + keys(&fields), + ["age", "email", "nick"], + "the result holds every plan field, in plan order" + ); + let mut age = map(node(&mut fields, "age")); + assert_eq!( + keys(&age), + ["c", "eq", "ore"], + "a field's outputs ride under their keys, in output order" + ); + assert!( + !matches!(node(&mut age, "c"), CipherText::Passthrough(_)), + "the ciphertext is never a passthrough" + ); + assert_eq!( + term_bytes(&node(&mut age, "eq")).len(), + 32, + "an equality term is the raw 32 PRF bytes" + ); + let email = map(node(&mut fields, "email")); + assert_eq!( + keys(&email), + ["c"], + "a sealed-only field has just its ciphertext" + ); + let nick = map(node(&mut fields, "nick")); + assert_eq!( + keys(&nick), + ["match"], + "an indexed-only field has just its term" + ); + } + + /// ADR-0004's property, pinned: the ciphertext opens under the plan + /// context and under nothing else, and each term is the standalone + /// derivation under that same context. + #[tokio::test] + async fn binds_the_ciphertext_and_every_term_under_the_one_plan_context() { + let cipher = cipher().await; + let keyset = cipher.default_keyset(); + let plan = the_plan(); + let age_ctx = plan[0].context().clone(); + let nick_ctx = plan[2].context().clone(); + + let mut fields = map(encrypt(&keyset, row(34), &plan).await.expect("encrypt")); + let mut age = map(node(&mut fields, "age")); + let mut nick = map(node(&mut fields, "nick")); + + let opened: FfiValue = cipher + .decrypt(node(&mut age, "c"), age_ctx.clone()) + .await + .expect("the ciphertext opens under the plan context"); + assert_eq!(u32_of(&opened), 34, "and to the value that was sealed"); + + let mut email = map(node(&mut fields, "email")); + let wrong: Result<FfiValue, _> = + cipher.decrypt(node(&mut email, "c"), age_ctx.clone()).await; + assert!( + wrong.is_err(), + "a field's ciphertext does not open under another field's context" + ); + + let eq = term( + &keyset, + Scalar::U32(34), + TermKind::Equality, + age_ctx.clone(), + ) + .await + .expect("standalone equality term"); + assert_eq!( + term_bytes(&node(&mut age, "eq")), + eq, + "the equality term is the standalone derivation under the plan context" + ); + let ore = term(&keyset, Scalar::U32(34), TermKind::Ore, age_ctx) + .await + .expect("standalone ore term"); + assert_eq!( + term_bytes(&node(&mut age, "ore")), + ore, + "the ore term is the standalone derivation under the plan context" + ); + let scalar = Scalar::of(&s("al smith"), TermKind::Match).expect("text"); + let matched = term(&keyset, scalar, TermKind::Match, nick_ctx) + .await + .expect("standalone match term"); + assert_eq!( + term_bytes(&node(&mut nick, "match")), + matched, + "the match term is the standalone derivation under the plan context" + ); + } + + #[tokio::test] + async fn opens_back_to_its_ciphertext_bearing_fields_in_plan_order() { + let cipher = cipher().await; + let keyset = cipher.default_keyset(); + let plan = the_plan(); + let sealed = encrypt(&keyset, row(34), &plan).await.expect("encrypt"); + + let opened = decrypt(Scope::Client(&cipher), sealed, &plan) + .await + .expect("decrypt"); + assert_eq!( + retrieves(&cipher), + 1, + "every ciphertext leaf opens from one batched key request" + ); + let fields = object(opened); + assert_eq!( + keys(&fields), + ["age", "email"], + "only the sealed fields come back, in plan order; terms are one-way" + ); + assert_eq!(u32_of(&fields[0].1), 34, "the age round-trips"); + assert_eq!(text_of(&fields[1].1), "a@x", "the email round-trips"); + + // Through the keyset it was sealed under, too. + let sealed = encrypt(&keyset, row(35), &plan).await.expect("encrypt"); + let fields = object( + decrypt(Scope::Keyset(keyset.clone()), sealed, &plan) + .await + .expect("decrypt through the keyset"), + ); + assert_eq!( + u32_of(&fields[0].1), + 35, + "the age round-trips through its keyset" + ); + } + } + + mod given_a_batch { + use super::*; + + #[tokio::test] + async fn seals_every_row_from_one_key_request_and_opens_as_an_array() { + let cipher = cipher().await; + let keyset = cipher.default_keyset(); + let plan = the_plan(); + + let sealed = encrypt( + &keyset, + FfiValue::Array(vec![row(1), row(2), row(3)]), + &plan, + ) + .await + .expect("encrypt"); + assert_eq!(generates(&cipher), 1, "one key request for the whole batch"); + + let rows = sequence(sealed); + assert_eq!(rows.len(), 3, "a batch seals to a sequence of rows"); + let opened = decrypt(Scope::Client(&cipher), CipherText::Sequence(rows), &plan) + .await + .expect("decrypt"); + assert_eq!( + retrieves(&cipher), + 1, + "one key request to open the whole batch" + ); + let rows = array(opened); + assert_eq!( + rows.into_iter() + .map(|row| u32_of(&object(row)[0].1)) + .collect::<Vec<_>>(), + [1, 2, 3], + "rows open in the order they were sealed" + ); + } + + #[tokio::test] + async fn an_empty_batch_is_an_empty_batch() { + let cipher = cipher().await; + let keyset = cipher.default_keyset(); + let plan = the_plan(); + let sealed = encrypt(&keyset, FfiValue::Array(vec![]), &plan) + .await + .expect("an empty batch seals"); + assert!( + matches!(&sealed, CipherText::Sequence(rows) if rows.is_empty()), + "an empty batch seals to an empty sequence" + ); + let opened = decrypt(Scope::Client(&cipher), sealed, &plan) + .await + .expect("an empty batch opens"); + assert!( + array(opened).is_empty(), + "an empty sequence opens to an empty array" + ); + assert_eq!( + (generates(&cipher), retrieves(&cipher)), + (0, 0), + "nothing to seal or open costs no key request" + ); + } + } + + mod given_a_stored_record_that_does_not_fit_the_plan { + use super::*; + + async fn sealed(keyset: &KeysetCipher<'_, Counting>) -> Vec<(String, StackCipherText)> { + map(encrypt(keyset, row(34), &the_plan()) + .await + .expect("encrypt")) + } + + /// The forged-plaintext case the module docs call load-bearing is + /// in here: a passthrough under `"c"` must be refused, because + /// `decrypt_as` would otherwise hand its payload back as if opened. + #[tokio::test] + async fn decrypt_and_check_record_refuse_it_before_any_key_is_retrieved() { + let cipher = cipher().await; + let keyset = cipher.default_keyset(); + let plan = the_plan(); + + let mut cases: Vec<(&str, StackCipherText)> = Vec::new(); + + let mut fields = sealed(&keyset).await; + let mut age = map(node(&mut fields, "age")); + cases.push(("a record that is not a map", node(&mut age, "c"))); + + let mut fields = sealed(&keyset).await; + let mut age = map(node(&mut fields, "age")); + cases.push(( + "a batch with a row that is not a map", + CipherText::Sequence(vec![node(&mut age, "c")]), + )); + + let mut fields = sealed(&keyset).await; + let _ = node(&mut fields, "email"); + cases.push(("a record missing a sealed field", CipherText::Map(fields))); + + let mut fields = sealed(&keyset).await; + let mut age = map(node(&mut fields, "age")); + fields.push(("age".to_string(), node(&mut age, "c"))); + cases.push(( + "a sealed field that is not an output map", + CipherText::Map(fields), + )); + + let mut fields = sealed(&keyset).await; + let mut age = map(node(&mut fields, "age")); + let _ = node(&mut age, "c"); + fields.push(("age".to_string(), CipherText::Map(age))); + cases.push(( + "a sealed field with no ciphertext output", + CipherText::Map(fields), + )); + + let mut fields = sealed(&keyset).await; + let mut age = map(node(&mut fields, "age")); + let _ = node(&mut age, "c"); + age.push(("c".to_string(), forged(FfiValue::UInt32(99)))); + fields.push(("age".to_string(), CipherText::Map(age))); + cases.push(( + "a passthrough where the ciphertext should be", + CipherText::Map(fields), + )); + + let mut fields = sealed(&keyset).await; + let mut age = map(node(&mut fields, "age")); + let _ = node(&mut age, "c"); + age.push(( + "c".to_string(), + CipherText::Map(vec![("v".to_string(), forged(FfiValue::UInt32(99)))]), + )); + fields.push(("age".to_string(), CipherText::Map(age))); + cases.push(( + "a passthrough inside the ciphertext subtree", + CipherText::Map(fields), + )); + + let before = retrieves(&cipher); + for (label, record) in cases { + let err = decrypt(Scope::Client(&cipher), record, &plan).await.err(); + assert!( + matches!(err, Some(Error::Record)), + "{label}: decrypt must refuse it as a misfit record: {err:?}" + ); + } + assert_eq!( + retrieves(&cipher), + before, + "a misfit record is refused before any key is retrieved" + ); + + // And the boundary parser agrees, on the load-bearing shape. + let mut fields = sealed(&keyset).await; + let mut age = map(node(&mut fields, "age")); + let _ = node(&mut age, "c"); + age.push(("c".to_string(), forged(FfiValue::UInt32(99)))); + fields.push(("age".to_string(), CipherText::Map(age))); + let err = check_record(CipherText::Map(fields), &plan).err(); + assert!( + matches!(err, Some(Error::Record)), + "check_record refuses a forged ciphertext the same way: {err:?}" + ); + } + + #[tokio::test] + async fn ignores_terms_and_entries_the_plan_does_not_open() { + let cipher = cipher().await; + let keyset = cipher.default_keyset(); + let plan = the_plan(); + + let mut fields = sealed(&keyset).await; + // The indexed-only field can be absent, a term can be anything, + // and an entry the plan does not name is not looked at. + let _ = node(&mut fields, "nick"); + let mut age = map(node(&mut fields, "age")); + let _ = node(&mut age, "eq"); + age.push(("eq".to_string(), forged(s("not a term")))); + age.push(("zzz".to_string(), forged(s("not an output")))); + fields.push(("age".to_string(), CipherText::Map(age))); + fields.push(("extra".to_string(), forged(s("not a field")))); + + let opened = object( + decrypt(Scope::Client(&cipher), CipherText::Map(fields), &plan) + .await + .expect("decrypt"), + ); + assert_eq!(keys(&opened), ["age", "email"], "the sealed fields open"); + assert_eq!(u32_of(&opened[0].1), 34, "to what was sealed"); + } + } + + mod given_a_keyset_scope { + use super::*; + + fn named(name: &str) -> IdentifiedBy { + IdentifiedBy::Name(name.to_string().into()) + } + + #[tokio::test] + async fn refuses_a_leaf_from_another_keyset_before_any_key_is_retrieved() { + let cipher = cipher().await; + let acme = cipher.keyset(named("acme")).await.expect("acme"); + let globex = cipher.keyset(named("globex")).await.expect("globex"); + let plan = the_plan(); + + let sealed = encrypt(&acme, row(34), &plan).await.expect("encrypt"); + let err = decrypt(Scope::Keyset(globex.clone()), sealed, &plan) + .await + .err(); + assert!( + matches!( + err, + Some(Error::Cipher(crate::Error::ForeignKeyset { expected, found })) + if expected == globex.keyset_id() && found == acme.keyset_id() + ), + "another tenant's keyset refuses the leaf, naming both keysets: {err:?}" + ); + assert_eq!( + retrieves(&cipher), + 0, + "refused before any key was retrieved" + ); + + let sealed = encrypt(&acme, row(34), &plan).await.expect("encrypt"); + let opened = object( + decrypt(Scope::Keyset(acme.clone()), sealed, &plan) + .await + .expect("its own keyset opens it"), + ); + assert_eq!(u32_of(&opened[0].1), 34, "to what was sealed"); + + let sealed = encrypt(&acme, row(34), &plan).await.expect("encrypt"); + let opened = object( + decrypt(Scope::Client(&cipher), sealed, &plan) + .await + .expect("the client opens a leaf from any of its keysets"), + ); + assert_eq!(u32_of(&opened[0].1), 34, "to what was sealed"); + } + + #[tokio::test] + async fn debug_names_the_cipher_it_opens_through() { + let cipher = cipher().await; + assert!( + format!("{:?}", Scope::Client(&cipher)).starts_with("Client("), + "the client scope says so" + ); + assert!( + format!("{:?}", Scope::Keyset(cipher.default_keyset())).starts_with("Keyset("), + "the keyset scope says so" + ); + } + } + + /// The typed helper the tests lean on, pinned in passing: `nonempty!` + /// and `context` agree, so a test written against either is the same + /// test. + #[test] + fn the_plan_context_is_the_typed_context() { + use crate::IntoAad; + assert_eq!( + the_plan()[0] + .context() + .clone() + .into_inner() + .into_aad() + .as_bytes(), + nonempty!("users/age").into_aad().as_bytes(), + "a bare plan string is the typed literal" + ); } } diff --git a/packages/stack-encrypt/src/dynamic/term.rs b/packages/stack-encrypt/src/dynamic/term.rs index b4e90ba85..f968611d1 100644 --- a/packages/stack-encrypt/src/dynamic/term.rs +++ b/packages/stack-encrypt/src/dynamic/term.rs @@ -12,13 +12,13 @@ //! Same caveat as [`crate::sem`]: a term derived here is bound to the //! context you pass and to nothing else. Use it to *query*. A term that is //! going to be **stored** should come from the record path, where it shares -//! one context with the ciphertext beside it by construction (ADR-0004). +//! one context with the ciphertext beside it (ADR-0004). use std::fmt; use stack_kms::DataKeySource; use vitaminc_aead_value::FfiValue; -use vitaminc_protected::{Controlled, Protected}; +use vitaminc_protected::{Controlled, OpaqueDebug, Protected}; use zeroize::Zeroizing; use super::{utf8, Error}; @@ -27,8 +27,8 @@ use crate::{IntoPrfContext, KeysetCipher, NonEmpty}; /// Which index term to derive. /// -/// The `key` strings are wire format — see the [module docs](super) on -/// stability. +/// The `key` strings are wire format, and that is why this enum is +/// exhaustive — see the [module docs](super#stability). #[derive(Clone, Copy, PartialEq, Eq, Debug, Hash)] pub enum TermKind { /// `"eq"` — equality (exact match). Raw 32 PRF bytes. @@ -84,7 +84,10 @@ impl fmt::Display for TermKind { /// Lifting is a copy, so the value it came from stays movable into the /// ciphertext path beside it. The owned text and bytes copies wipe on drop; /// the PRF and CLLW layers move them into [`Protected`] internally. -#[derive(Clone)] +/// +/// It is plaintext, so its `Debug` is opaque: the variant is named, the +/// value is masked. +#[derive(Clone, OpaqueDebug)] #[non_exhaustive] pub enum Scalar { /// From [`FfiValue::Bool`]. @@ -137,6 +140,31 @@ impl Scalar { /// Derive one index term's frozen byte encoding. /// +/// # Examples +/// +/// A probe for a value a binding decoded derives the bytes the typed path +/// derives for the same value under the same context: +/// +/// ``` +/// use stack_encrypt::dynamic::{context, term, FfiValue, Scalar, TermKind}; +/// use stack_encrypt::StackCipher; +/// use stack_kms::FakeDataKeySource; +/// +/// # tokio::runtime::Builder::new_current_thread().enable_all().build().unwrap().block_on(async { +/// let cipher = StackCipher::builder() +/// .kms(FakeDataKeySource::new()) +/// .init() +/// .await?; +/// let keyset = cipher.default_keyset(); +/// +/// let ctx = context(FfiValue::String("users/age".into()))?; +/// let probe = term(&keyset, Scalar::U32(34), TermKind::Equality, ctx.clone()).await?; +/// let typed = keyset.equality_term(34u32, ctx).await?; +/// assert_eq!(probe, typed.into_bytes().to_vec()); +/// # Ok::<(), stack_encrypt::dynamic::Error>(()) +/// # }).unwrap(); +/// ``` +/// /// # Errors /// /// [`Error::Term`] if the scheme defines no such term for the scalar @@ -248,3 +276,293 @@ where .await .map(|t| t.as_ref().to_vec())?) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::dynamic::{context, Output}; + use crate::{nonempty, StackCipher}; + use stack_kms::FakeDataKeySource; + + async fn cipher() -> StackCipher<FakeDataKeySource> { + StackCipher::builder() + .kms(FakeDataKeySource::new()) + .init() + .await + .expect("build cipher") + } + + fn s(value: &str) -> FfiValue { + FfiValue::String(value.into()) + } + + fn bytes(value: &[u8]) -> FfiValue { + FfiValue::Bytes(Protected::new(value.to_vec())) + } + + /// Every scalar variant, from the leaf it lifts out of. + fn every_scalar() -> Vec<(&'static str, FfiValue)> { + vec![ + ("a bool", FfiValue::Bool(true)), + ("an i32", FfiValue::Int32(-3)), + ("an i64", FfiValue::Int64(-4)), + ("a u32", FfiValue::UInt32(34)), + ("a u64", FfiValue::UInt64(35)), + ("an f32", FfiValue::Float32(1.5)), + ("an f64", FfiValue::Float64(2.5)), + ("text", s("alice")), + ("bytes", bytes(b"ab")), + ] + } + + mod given_a_scalar_the_scheme_defines_the_term_for { + use super::*; + + /// The contract the dispatch exists for: the bytes are the typed + /// path's, so a probe from any language finds a Rust-written term. + #[tokio::test] + async fn derives_the_bytes_the_typed_path_derives() { + let cipher = cipher().await; + let keyset = cipher.default_keyset(); + let ctx = context(s("users/x")).expect("context"); + let dynamic = |value: &FfiValue, kind: TermKind| { + let scalar = Scalar::of(value, kind).expect("a scalar"); + term(&keyset, scalar, kind, ctx.clone()) + }; + let eq = |t: crate::sem::EqualityTerm| t.into_bytes().to_vec(); + + // Equality, per PRF-encodable variant. + let typed = keyset.equality_term(-3i32, nonempty!("users/x")).await; + assert_eq!( + dynamic(&FfiValue::Int32(-3), TermKind::Equality) + .await + .expect("eq"), + eq(typed.expect("typed")), + "i32 equality" + ); + let typed = keyset.equality_term(-4i64, nonempty!("users/x")).await; + assert_eq!( + dynamic(&FfiValue::Int64(-4), TermKind::Equality) + .await + .expect("eq"), + eq(typed.expect("typed")), + "i64 equality" + ); + let typed = keyset.equality_term(34u32, nonempty!("users/x")).await; + assert_eq!( + dynamic(&FfiValue::UInt32(34), TermKind::Equality) + .await + .expect("eq"), + eq(typed.expect("typed")), + "u32 equality" + ); + let typed = keyset.equality_term(35u64, nonempty!("users/x")).await; + assert_eq!( + dynamic(&FfiValue::UInt64(35), TermKind::Equality) + .await + .expect("eq"), + eq(typed.expect("typed")), + "u64 equality" + ); + let typed = keyset + .equality_term("alice".to_string(), nonempty!("users/x")) + .await; + assert_eq!( + dynamic(&s("alice"), TermKind::Equality).await.expect("eq"), + eq(typed.expect("typed")), + "text equality" + ); + let typed = keyset + .equality_term(Protected::new(b"ab".to_vec()), nonempty!("users/x")) + .await; + assert_eq!( + dynamic(&bytes(b"ab"), TermKind::Equality) + .await + .expect("eq"), + eq(typed.expect("typed")), + "bytes equality" + ); + + // Match, text only. + let typed = keyset + .match_terms::<DefaultMatch>("alice smith", nonempty!("users/x")) + .await + .expect("typed"); + assert_eq!( + dynamic(&s("alice smith"), TermKind::Match) + .await + .expect("match"), + typed.to_bytes(), + "text match" + ); + + // The ordering schemes take every scalar; the typed side is + // spelled once per variant because each is its own type. + macro_rules! ordered { + ($value:expr, $leaf:expr, $label:literal) => { + let typed = keyset.ore_term($value, nonempty!("users/x")).await; + assert_eq!( + dynamic(&$leaf, TermKind::Ore).await.expect("ore"), + typed.expect("typed").as_ref().to_vec(), + concat!($label, " ore") + ); + let typed = keyset.ope_term($value, nonempty!("users/x")).await; + assert_eq!( + dynamic(&$leaf, TermKind::Ope).await.expect("ope"), + typed.expect("typed").as_ref().to_vec(), + concat!($label, " ope") + ); + }; + } + ordered!(true, FfiValue::Bool(true), "bool"); + ordered!(-3i32, FfiValue::Int32(-3), "i32"); + ordered!(-4i64, FfiValue::Int64(-4), "i64"); + ordered!(34u32, FfiValue::UInt32(34), "u32"); + ordered!(35u64, FfiValue::UInt64(35), "u64"); + ordered!(1.5f32, FfiValue::Float32(1.5), "f32"); + ordered!(2.5f64, FfiValue::Float64(2.5), "f64"); + ordered!("alice".to_string(), s("alice"), "text"); + ordered!(b"ab".to_vec(), bytes(b"ab"), "bytes"); + } + + #[test] + fn supports_is_true() { + for (label, leaf) in every_scalar() { + let scalar = Scalar::of(&leaf, TermKind::Ore).expect("a scalar"); + assert!(TermKind::Ore.supports(&scalar), "{label} takes an ore term"); + assert!(TermKind::Ope.supports(&scalar), "{label} takes an ope term"); + } + for (label, leaf) in every_scalar() { + let scalar = Scalar::of(&leaf, TermKind::Equality).expect("a scalar"); + let prf_encodable = !matches!( + leaf, + FfiValue::Bool(_) | FfiValue::Float32(_) | FfiValue::Float64(_) + ); + assert_eq!( + TermKind::Equality.supports(&scalar), + prf_encodable, + "{label} takes an equality term exactly when it has a PRF encoding" + ); + assert_eq!( + TermKind::Match.supports(&scalar), + matches!(leaf, FfiValue::String(_)), + "{label} takes a match term exactly when it is text" + ); + } + } + } + + mod given_a_pair_the_scheme_refuses { + use super::*; + + /// The arms of `term` are unreachable for a pair `supports` refuses, + /// and they say so with the same error the table would have let a + /// binding raise at its boundary. + #[tokio::test] + async fn term_is_error_term_naming_the_kind() { + let cipher = cipher().await; + let keyset = cipher.default_keyset(); + let ctx = context(s("users/x")).expect("context"); + let refused = [ + ("a bool", FfiValue::Bool(true), TermKind::Equality), + ("an f32", FfiValue::Float32(1.5), TermKind::Equality), + ("an f64", FfiValue::Float64(2.5), TermKind::Equality), + ("a bool", FfiValue::Bool(true), TermKind::Match), + ("a u32", FfiValue::UInt32(34), TermKind::Match), + ("bytes", bytes(b"ab"), TermKind::Match), + ]; + for (label, leaf, kind) in refused { + let scalar = Scalar::of(&leaf, kind).expect("a scalar"); + assert!( + !kind.supports(&scalar), + "{label} must not take a {kind} term" + ); + let result = term(&keyset, scalar, kind, ctx.clone()).await; + assert!( + matches!(result, Err(Error::Term { kind: k }) if k == kind), + "{label} asked for a {kind} term must be refused as that kind: {result:?}" + ); + } + } + } + + mod given_a_value_that_is_not_a_scalar { + use super::*; + + #[test] + fn lifting_is_error_term_naming_the_kind() { + let not_scalars = [ + ("null", FfiValue::Null), + ("undefined", FfiValue::Undefined), + ("an array", FfiValue::Array(vec![FfiValue::UInt32(1)])), + ( + "an object", + FfiValue::Object(vec![("k".to_string(), FfiValue::UInt32(1))]), + ), + ( + "a passthrough", + FfiValue::Passthrough(Box::new(FfiValue::UInt32(1))), + ), + ]; + for (label, value) in not_scalars { + for kind in [ + TermKind::Equality, + TermKind::Match, + TermKind::Ore, + TermKind::Ope, + ] { + let result = Scalar::of(&value, kind); + assert!( + matches!(result, Err(Error::Term { kind: k }) if k == kind), + "{label} has no {kind} term: {result:?}" + ); + } + } + } + } + + mod given_a_term_kind { + use super::*; + + #[test] + fn its_key_is_how_a_plan_spells_it() { + for kind in [ + TermKind::Equality, + TermKind::Match, + TermKind::Ore, + TermKind::Ope, + ] { + assert_eq!( + Output::parse(kind.key()), + Some(Output::Term(kind)), + "a plan spelling {kind} by its key names that term" + ); + assert_eq!( + kind.to_string(), + kind.key(), + "the display form is the key, for error messages" + ); + } + } + } + + mod given_a_scalar_holding_plaintext { + use super::*; + + #[test] + fn debug_prints_none_of_it() { + let rendered = format!( + "{:?}", + Scalar::of(&s("hunter2"), TermKind::Equality).expect("a scalar") + ); + assert!( + !rendered.contains("hunter2"), + "a scalar's Debug must not print its plaintext: {rendered}" + ); + assert!( + rendered.contains("Text"), + "a scalar's Debug names the variant, which is not secret: {rendered}" + ); + } + } +} From 6089fb5f672abee6a9a93b1c0284d97b0c6beb43 Mon Sep 17 00:00:00 2001 From: Dan Draper <dan@cipherstash.com> Date: Fri, 18 Sep 2026 00:16:47 -0700 Subject: [PATCH 579/686] fix(wasi): a library invariant failing is not the caller's fault MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `status_for_dynamic` mapped every non-cipher variant of `dynamic::Error` to `STATUS_ENCODING`, `Internal` included — so a slot count that did not line up, or a re-proof that could not fail, would have told a Go host its input was malformed. The old record path reported those as `STATUS_INTERNAL`, and so does this one now: `Internal` has its own arm, the catch-all covers input variants only, and the library's `Error` doc names the two variants that are not statements about the input. Raised by Copilot on cipherstash/cipherstash-suite#2220. --- .../golang/stackencrypt/guest/src/status.rs | 45 +++++++++++++++++-- packages/stack-encrypt/src/dynamic/mod.rs | 11 +++-- 2 files changed, 49 insertions(+), 7 deletions(-) diff --git a/languages/golang/stackencrypt/guest/src/status.rs b/languages/golang/stackencrypt/guest/src/status.rs index 26be9a5e4..d3ff22a04 100644 --- a/languages/golang/stackencrypt/guest/src/status.rs +++ b/languages/golang/stackencrypt/guest/src/status.rs @@ -112,12 +112,18 @@ pub fn status_for_error(error: &stack_encrypt::Error) -> u32 { /// A dynamic-path error as a status code. /// /// The split the library draws is the one the ABI needs: every variant but -/// `Cipher` is a statement about the caller's input, decided before any key -/// is minted or retrieved, so it is [`STATUS_ENCODING`]. `Cipher` defers to -/// [`status_for_error`]. +/// `Cipher` and `Internal` is a statement about the caller's input, decided +/// before any key is minted or retrieved, so it is [`STATUS_ENCODING`]. +/// `Cipher` defers to [`status_for_error`]; `Internal` is the library's own +/// invariant failing — a slot count that did not line up, a re-proof that +/// could not fail — and is [`STATUS_INTERNAL`], never a verdict on the +/// input. The catch-all is required (`Error` is `#[non_exhaustive]`) and +/// covers input variants only: a variant added tomorrow that is not about +/// the input must be classified here. pub fn status_for_dynamic(error: &stack_encrypt::dynamic::Error) -> u32 { match error { stack_encrypt::dynamic::Error::Cipher(e) => status_for_error(e), + stack_encrypt::dynamic::Error::Internal => STATUS_INTERNAL, _ => STATUS_ENCODING, } } @@ -330,6 +336,39 @@ mod tests { ); } + #[test] + fn dynamic_input_errors_are_encoding_and_a_library_bug_is_internal() { + use stack_encrypt::dynamic::{Error, TermKind}; + for (label, err) in [ + ("a bad context", Error::Context), + ( + "a bad term request", + Error::Term { + kind: TermKind::Match, + }, + ), + ("a bad plan", Error::Plan), + ("a bad source", Error::Source), + ("a bad record", Error::Record), + ] { + assert_eq!( + status_for_dynamic(&err), + STATUS_ENCODING, + "{label} is the caller's input" + ); + } + assert_eq!( + status_for_dynamic(&Error::Internal), + STATUS_INTERNAL, + "a library invariant failing is never the caller's fault" + ); + assert_eq!( + status_for_dynamic(&Error::Cipher(stack_encrypt::Error::Aead)), + STATUS_AUTH, + "a cipher failure keeps its own status" + ); + } + #[test] fn term_errors_are_derivation_failures() { // An empty context never reaches a term — it is `STATUS_ENCODING` diff --git a/packages/stack-encrypt/src/dynamic/mod.rs b/packages/stack-encrypt/src/dynamic/mod.rs index 3de0d0854..42a2230b3 100644 --- a/packages/stack-encrypt/src/dynamic/mod.rs +++ b/packages/stack-encrypt/src/dynamic/mod.rs @@ -110,10 +110,13 @@ fn utf8(s: &vitaminc_aead_value::Utf8String) -> Option<&str> { /// What went wrong in a dynamic operation. /// -/// The split that matters to a caller is malformed input versus a cipher -/// failure: every variant but [`Cipher`](Error::Cipher) is a statement about -/// the value or the request, decided before any key is minted or retrieved. -/// A binding maps them to its own status codes on that line. +/// The split that matters to a caller is malformed input versus something +/// else: every variant but [`Cipher`](Error::Cipher) and +/// [`Internal`](Error::Internal) is a statement about the value or the +/// request, decided before any key is minted or retrieved. `Cipher` is the +/// operation failing; `Internal` is this module's own bug. A binding maps +/// them to its own status codes on those lines, and must not report +/// `Internal` as the caller's fault. #[derive(Debug, thiserror::Error)] #[non_exhaustive] pub enum Error { From 534f41e5c2e9109033542740e8b629cbdd8bf24b Mon Sep 17 00:00:00 2001 From: Dan Draper <dan@cipherstash.com> Date: Fri, 18 Sep 2026 00:16:48 -0700 Subject: [PATCH 580/686] fix(stack-encrypt): a plan refuses a field or key given twice MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `record::plan` relied on the transport codec to have refused duplicate object keys, and said so — but it is a public parser over any `FfiValue`, and one built directly can name a field twice, or give a spec two `"context"`s, with the last one silently winning. It refuses both now, as the guest's config parser already did for the same reason, and the error doc says which of `Plan` and `Context` each shape gets: `Plan` for a missing context, `Context` for one that is present and wrong. Raised by Copilot on cipherstash/cipherstash-suite#2220. --- packages/stack-encrypt/src/dynamic/record.rs | 111 +++++++++++++------ 1 file changed, 76 insertions(+), 35 deletions(-) diff --git a/packages/stack-encrypt/src/dynamic/record.rs b/packages/stack-encrypt/src/dynamic/record.rs index c8fa7acbb..222f84b97 100644 --- a/packages/stack-encrypt/src/dynamic/record.rs +++ b/packages/stack-encrypt/src/dynamic/record.rs @@ -209,11 +209,17 @@ impl FieldPlan { /// /// # Errors /// -/// [`Error::Plan`] for an empty plan, a missing or malformed context, an -/// empty, unknown or duplicated output list, or an unknown key. -/// [`Error::Context`] for a context that is malformed or empty. Field names -/// are unique by construction — the transport codec rejects duplicate object -/// keys before this sees them. +/// [`Error::Plan`] for a plan that is not an object of field specs, an +/// empty plan, a field named twice, a spec with a key other than +/// `"context"` and `"outputs"` or with either given twice or missing, or +/// an output list that is not a list of known output names, is empty, or +/// names an output twice. [`Error::Context`] for a `"context"` that is +/// present but is not a context, or renders empty. +/// +/// The transport codec refuses duplicate object keys before a binding's +/// value reaches here, but an [`FfiValue`] can be built with them directly +/// and this is a public parser, so it refuses them itself rather than +/// letting the last one win. pub fn plan(value: FfiValue) -> Result<Vec<FieldPlan>, Error> { let FfiValue::Object(entries) = value else { return Err(Error::Plan); @@ -221,41 +227,44 @@ pub fn plan(value: FfiValue) -> Result<Vec<FieldPlan>, Error> { if entries.is_empty() { return Err(Error::Plan); } - entries - .into_iter() - .map(|(name, spec)| { - let FfiValue::Object(spec) = spec else { - return Err(Error::Plan); - }; - let mut context: Option<NonEmpty<AadPiece<'static>>> = None; - let mut outputs: Option<Vec<Output>> = None; - for (key, value) in spec { - match key.as_str() { - "context" => context = Some(super::context(value)?), - "outputs" => { - let FfiValue::Array(items) = value else { + let mut fields: Vec<FieldPlan> = Vec::with_capacity(entries.len()); + for (name, spec) in entries { + if fields.iter().any(|field| field.name == name) { + return Err(Error::Plan); + } + let FfiValue::Object(spec) = spec else { + return Err(Error::Plan); + }; + let mut context: Option<NonEmpty<AadPiece<'static>>> = None; + let mut outputs: Option<Vec<Output>> = None; + for (key, value) in spec { + match key.as_str() { + "context" if context.is_none() => context = Some(super::context(value)?), + "outputs" if outputs.is_none() => { + let FfiValue::Array(items) = value else { + return Err(Error::Plan); + }; + let mut parsed = Vec::with_capacity(items.len()); + for item in &items { + let FfiValue::String(s) = item else { return Err(Error::Plan); }; - let mut parsed = Vec::with_capacity(items.len()); - for item in &items { - let FfiValue::String(s) = item else { - return Err(Error::Plan); - }; - let key = utf8(s).ok_or(Error::Plan)?; - parsed.push(Output::parse(key).ok_or(Error::Plan)?); - } - outputs = Some(parsed); + let key = utf8(s).ok_or(Error::Plan)?; + parsed.push(Output::parse(key).ok_or(Error::Plan)?); } - _ => return Err(Error::Plan), + outputs = Some(parsed); } + // An unknown key, or one of the two given twice. + _ => return Err(Error::Plan), } - FieldPlan::new( - name, - context.ok_or(Error::Plan)?, - outputs.ok_or(Error::Plan)?, - ) - }) - .collect() + } + fields.push(FieldPlan::new( + name, + context.ok_or(Error::Plan)?, + outputs.ok_or(Error::Plan)?, + )?); + } + Ok(fields) } /// Encrypt a record — or a batch of records — per a plan. @@ -1148,6 +1157,38 @@ mod tests { obj(vec![("age", spec(s("users/age"), &["c", "eq", "c"]))]), |e| matches!(e, Error::Plan), ), + ( + "a field named twice", + FfiValue::Object(vec![ + ("age".to_string(), spec(s("users/age"), &["c"])), + ("age".to_string(), spec(s("users/age"), &["eq"])), + ]), + |e| matches!(e, Error::Plan), + ), + ( + "a context given twice", + obj(vec![( + "age", + obj(vec![ + ("context", s("users/age")), + ("outputs", strings(&["c"])), + ("context", s("users/other")), + ]), + )]), + |e| matches!(e, Error::Plan), + ), + ( + "outputs given twice", + obj(vec![( + "age", + obj(vec![ + ("context", s("users/age")), + ("outputs", strings(&["c"])), + ("outputs", strings(&["eq"])), + ]), + )]), + |e| matches!(e, Error::Plan), + ), ( "a context that is not one", obj(vec![("age", spec(FfiValue::Bool(true), &["c"]))]), From 7af383f885c156ac3baa82bc7a31e4650bb9938e Mon Sep 17 00:00:00 2001 From: Dan Draper <dan@cipherstash.com> Date: Fri, 18 Sep 2026 00:16:49 -0700 Subject: [PATCH 581/686] fix(go): the example checks the workspace id before it becomes a path `current_workspace` is a plain file anything on the machine can write, and its contents became a component of the path the example read credentials from. stack-profile's reader refuses anything but sixteen base32 characters for exactly that reason; the example now applies the same rule, with a test that pins it, so "../.." in that file is an error rather than a read outside the profile. Raised by Copilot on cipherstash/cipherstash-suite#2220. --- .../golang/stackencrypt/example/profile.go | 21 ++++++++++++++ .../stackencrypt/example/profile_test.go | 29 +++++++++++++++++++ 2 files changed, 50 insertions(+) create mode 100644 languages/golang/stackencrypt/example/profile_test.go diff --git a/languages/golang/stackencrypt/example/profile.go b/languages/golang/stackencrypt/example/profile.go index e53466b51..9178a0483 100644 --- a/languages/golang/stackencrypt/example/profile.go +++ b/languages/golang/stackencrypt/example/profile.go @@ -60,6 +60,9 @@ func loadCredentials() (credentials, error) { return c, fmt.Errorf("no current workspace in %s — run `stash auth login`: %w", root, err) } c.Workspace = strings.TrimSpace(string(workspace)) + if !validWorkspaceID(c.Workspace) { + return c, fmt.Errorf("current_workspace in %s is not a workspace id (%q) — run `stash auth login`", root, c.Workspace) + } c.dir = filepath.Join(root, "workspaces", c.Workspace) // The client key: the two environment variables win, as they do for the @@ -129,6 +132,24 @@ func (c credentials) describe() string { auth.Region, time.Until(time.Unix(auth.ExpiresAt, 0)).Round(time.Minute)) } +// validWorkspaceID is stack-profile's rule for a workspace id: sixteen +// base32 characters (A-Z, 2-7). The id becomes a path component under +// workspaces/, and current_workspace is a plain file anything can write, so +// it is checked here as the Rust reader checks it — otherwise "../.." in that +// file reads credentials from outside the profile. +func validWorkspaceID(id string) bool { + if len(id) != 16 { + return false + } + for i := 0; i < len(id); i++ { + c := id[i] + if !(('A' <= c && c <= 'Z') || ('2' <= c && c <= '7')) { + return false + } + } + return true +} + func readJSON(path string, into any) error { b, err := os.ReadFile(path) if err != nil { diff --git a/languages/golang/stackencrypt/example/profile_test.go b/languages/golang/stackencrypt/example/profile_test.go new file mode 100644 index 000000000..adb2f53ea --- /dev/null +++ b/languages/golang/stackencrypt/example/profile_test.go @@ -0,0 +1,29 @@ +package main + +import "testing" + +// The id is a path component, and the file it is read from is writable by +// anything on the machine, so the rule is the Rust reader's exactly. +func TestValidWorkspaceIDIsStackProfilesRule(t *testing.T) { + valid := []string{"ABCDEFGHIJKLMNOP", "A2B3C4D5E6F7G2H3", "2222222222222222"} + for _, id := range valid { + if !validWorkspaceID(id) { + t.Errorf("%q is sixteen base32 characters and must be accepted", id) + } + } + invalid := map[string]string{ + "": "empty", + "ABCDEFGHIJKLMNO": "fifteen characters", + "ABCDEFGHIJKLMNOPQ": "seventeen characters", + "abcdefghijklmnop": "lower case", + "ABCDEFGHIJKLMN01": "digits outside 2-7", + "../../../../../etc": "a path", + "ABCDEFG/IJKLMNOP": "a separator inside sixteen characters", + "ABCDEFGHIJKLMNO\n": "a trailing newline", + } + for id, why := range invalid { + if validWorkspaceID(id) { + t.Errorf("%q (%s) must be refused", id, why) + } + } +} From cb0afd8931b75d77688e19985ddca491722d6c47 Mon Sep 17 00:00:00 2001 From: Dan Draper <dan@cipherstash.com> Date: Fri, 18 Sep 2026 07:59:59 -0700 Subject: [PATCH 582/686] fix(wasi): an unclassified dynamic error is ours, not the caller's `status_for_dynamic` named its input variants by omission: everything that was not `Cipher` or `Internal` fell through a catch-all to `STATUS_ENCODING`. `dynamic::Error` is `#[non_exhaustive]`, so a variant added upstream would have arrived as "fix your input" without anyone deciding that. The five input variants are now named one by one, and the catch-all reports `STATUS_INTERNAL` until someone reads the new variant and says otherwise. --- .../golang/stackencrypt/guest/src/status.rs | 34 ++++++++++++------- 1 file changed, 22 insertions(+), 12 deletions(-) diff --git a/languages/golang/stackencrypt/guest/src/status.rs b/languages/golang/stackencrypt/guest/src/status.rs index d3ff22a04..daaa9208e 100644 --- a/languages/golang/stackencrypt/guest/src/status.rs +++ b/languages/golang/stackencrypt/guest/src/status.rs @@ -111,20 +111,30 @@ pub fn status_for_error(error: &stack_encrypt::Error) -> u32 { /// A dynamic-path error as a status code. /// -/// The split the library draws is the one the ABI needs: every variant but -/// `Cipher` and `Internal` is a statement about the caller's input, decided -/// before any key is minted or retrieved, so it is [`STATUS_ENCODING`]. -/// `Cipher` defers to [`status_for_error`]; `Internal` is the library's own -/// invariant failing — a slot count that did not line up, a re-proof that -/// could not fail — and is [`STATUS_INTERNAL`], never a verdict on the -/// input. The catch-all is required (`Error` is `#[non_exhaustive]`) and -/// covers input variants only: a variant added tomorrow that is not about -/// the input must be classified here. +/// The split the library draws is the one the ABI needs: `Context`, `Term`, +/// `Plan`, `Source` and `Record` are each a statement about the caller's +/// input, decided before any key is minted or retrieved, so they are +/// [`STATUS_ENCODING`] — named one by one, because that verdict is the +/// host's to act on and must be given deliberately. `Cipher` defers to +/// [`status_for_error`]; `Internal` is the library's own invariant failing +/// — a slot count that did not line up, a re-proof that could not fail — +/// and is [`STATUS_INTERNAL`], never a verdict on the input. +/// +/// The catch-all is required (`Error` is `#[non_exhaustive]`, so a variant +/// added upstream cannot fail this match at compile time) and it goes to +/// [`STATUS_INTERNAL`]: an unclassified failure is reported as ours until +/// someone reads the new variant and says otherwise here. The one wrong +/// default would be the other way round — telling a host to fix its input +/// over a fault that is not in its input. pub fn status_for_dynamic(error: &stack_encrypt::dynamic::Error) -> u32 { + use stack_encrypt::dynamic::Error; match error { - stack_encrypt::dynamic::Error::Cipher(e) => status_for_error(e), - stack_encrypt::dynamic::Error::Internal => STATUS_INTERNAL, - _ => STATUS_ENCODING, + Error::Context | Error::Term { .. } | Error::Plan | Error::Source | Error::Record => { + STATUS_ENCODING + } + Error::Cipher(e) => status_for_error(e), + Error::Internal => STATUS_INTERNAL, + _ => STATUS_INTERNAL, } } From ab2868a27c73225794ab4752229b52d97d5c95f6 Mon Sep 17 00:00:00 2001 From: Dan Draper <dan@cipherstash.com> Date: Fri, 18 Sep 2026 08:00:00 -0700 Subject: [PATCH 583/686] fix(stack-encrypt): a plan is one value with its invariants, not a slice MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `record::plan` refused an empty plan and a field named twice, and then returned a `Vec<FieldPlan>`, so `encrypt`, `decrypt` and the two checks took any slice at all — a caller with `FieldPlan::new` could hand them a plan the parser would have refused. A repeated name passes the source check with a row that names the field once, and encrypt then writes a map with the same key twice. The plan is now an opaque `Plan`: `Plan::new` holds the whole-plan rules, the parser finishes through it, and the operations take `&Plan`. A test pins that a hand-built plan is refused on the same terms as a parsed one. --- packages/stack-encrypt/src/dynamic/mod.rs | 2 +- packages/stack-encrypt/src/dynamic/record.rs | 149 ++++++++++++++----- 2 files changed, 116 insertions(+), 35 deletions(-) diff --git a/packages/stack-encrypt/src/dynamic/mod.rs b/packages/stack-encrypt/src/dynamic/mod.rs index 42a2230b3..d61bd4f26 100644 --- a/packages/stack-encrypt/src/dynamic/mod.rs +++ b/packages/stack-encrypt/src/dynamic/mod.rs @@ -54,7 +54,7 @@ mod term; use std::fmt; pub use context::{borrowed, context}; -pub use record::{FieldPlan, Output}; +pub use record::{FieldPlan, Output, Plan}; pub use term::{term, Scalar, TermKind}; /// vitaminc's language-neutral value tree — the runtime value every binding /// funnels through. Its transport codec is `vitaminc_aead_value::transport`, diff --git a/packages/stack-encrypt/src/dynamic/record.rs b/packages/stack-encrypt/src/dynamic/record.rs index 222f84b97..ad34a7711 100644 --- a/packages/stack-encrypt/src/dynamic/record.rs +++ b/packages/stack-encrypt/src/dynamic/record.rs @@ -165,6 +165,48 @@ impl FieldPlan { } } +/// A record plan: the fields a record has, each with what to call it, what +/// context to bind it under, and what to produce for it. +/// +/// Opaque, because the operations over a plan rely on two properties of the +/// whole that no single [`FieldPlan`] can carry: there is at least one +/// field, and no two fields share a name. With a repeated name the source +/// check would accept a row that names the field once, and [`encrypt`] +/// would write a map with the same key twice — a stored record no reader +/// can take apart. Both the parser ([`plan`]) and the manual constructor +/// ([`Plan::new`]) go through the one check, so a plan in hand is a plan +/// that holds them, whichever way it was built. +#[derive(Clone, Debug)] +pub struct Plan { + fields: Vec<FieldPlan>, +} + +impl Plan { + /// A plan over `fields`, in the order given — which is the order of the + /// fields in every result. + /// + /// # Errors + /// + /// [`Error::Plan`] if `fields` is empty or names a field twice. + pub fn new(fields: Vec<FieldPlan>) -> Result<Self, Error> { + if fields.is_empty() { + return Err(Error::Plan); + } + for (at, field) in fields.iter().enumerate() { + if fields[..at].iter().any(|prior| prior.name == field.name) { + return Err(Error::Plan); + } + } + Ok(Self { fields }) + } + + /// The plan's fields, in result order. Never empty, and no two share a + /// name. + pub fn fields(&self) -> &[FieldPlan] { + &self.fields + } +} + /// Read a record plan from a decoded value. /// /// The plan is an [`FfiValue::Object`]: @@ -198,10 +240,10 @@ impl FieldPlan { /// ]), /// )]))?; /// -/// assert_eq!(plan.len(), 1); -/// assert_eq!(plan[0].name(), "age"); +/// assert_eq!(plan.fields().len(), 1); +/// assert_eq!(plan.fields()[0].name(), "age"); /// assert_eq!( -/// plan[0].outputs(), +/// plan.fields()[0].outputs(), /// [Output::Ciphertext, Output::Term(TermKind::Equality)] /// ); /// # Ok::<(), stack_encrypt::dynamic::Error>(()) @@ -220,18 +262,12 @@ impl FieldPlan { /// value reaches here, but an [`FfiValue`] can be built with them directly /// and this is a public parser, so it refuses them itself rather than /// letting the last one win. -pub fn plan(value: FfiValue) -> Result<Vec<FieldPlan>, Error> { +pub fn plan(value: FfiValue) -> Result<Plan, Error> { let FfiValue::Object(entries) = value else { return Err(Error::Plan); }; - if entries.is_empty() { - return Err(Error::Plan); - } let mut fields: Vec<FieldPlan> = Vec::with_capacity(entries.len()); for (name, spec) in entries { - if fields.iter().any(|field| field.name == name) { - return Err(Error::Plan); - } let FfiValue::Object(spec) = spec else { return Err(Error::Plan); }; @@ -264,7 +300,9 @@ pub fn plan(value: FfiValue) -> Result<Vec<FieldPlan>, Error> { outputs.ok_or(Error::Plan)?, )?); } - Ok(fields) + // The whole-plan rules — non-empty, no name twice — are `Plan::new`'s, + // so a parsed plan and a hand-built one are refused alike. + Plan::new(fields) } /// Encrypt a record — or a batch of records — per a plan. @@ -339,7 +377,7 @@ pub fn plan(value: FfiValue) -> Result<Vec<FieldPlan>, Error> { pub async fn encrypt<K>( cipher: &KeysetCipher<'_, K>, source: FfiValue, - plan: &[FieldPlan], + plan: &Plan, ) -> Result<StackCipherText, Error> where K: DataKeySource + Sync, @@ -411,13 +449,14 @@ where pub async fn decrypt<K>( scope: Scope<'_, K>, record: StackCipherText, - plan: &[FieldPlan], + plan: &Plan, ) -> Result<FfiValue, Error> where K: DataKeySource + Sync + 'static, { let Rows { rows, batched } = record_leaves(record, plan)?; let contexts = plan + .fields .iter() .filter(|field| field.has_ciphertext()) .map(FieldPlan::view) @@ -481,7 +520,7 @@ where /// # Errors /// /// As [`encrypt`], minus the cipher. -pub fn check_source(source: FfiValue, plan: &[FieldPlan]) -> Result<(), Error> { +pub fn check_source(source: FfiValue, plan: &Plan) -> Result<(), Error> { source_rows(source, plan).map(drop) } @@ -491,7 +530,7 @@ pub fn check_source(source: FfiValue, plan: &[FieldPlan]) -> Result<(), Error> { /// # Errors /// /// As [`decrypt`], minus the cipher. -pub fn check_record(record: StackCipherText, plan: &[FieldPlan]) -> Result<(), Error> { +pub fn check_record(record: StackCipherText, plan: &Plan) -> Result<(), Error> { record_leaves(record, plan).map(drop) } @@ -687,12 +726,13 @@ fn reject_passthrough<T: RecordTree>(tree: &T) -> Result<(), Error> { /// row and no row carries a field the plan does not name (silently dropping /// a field on either side would lose data or index nothing), and each value /// fits its field's outputs ([`check_field`]). -fn source_rows(source: FfiValue, plan: &[FieldPlan]) -> Result<Rows<Vec<FfiValue>>, Error> { +fn source_rows(source: FfiValue, plan: &Plan) -> Result<Rows<Vec<FfiValue>>, Error> { rows(source)?.try_map(|mut row| { - if row.len() != plan.len() { + if row.len() != plan.fields.len() { return Err(Error::Source); } - plan.iter() + plan.fields + .iter() .map(|field| { let (_, value) = take(&mut row, &field.name).ok_or(Error::Source)?; check_field(&value, field)?; @@ -765,18 +805,18 @@ impl<T> Settled<T> { async fn build_row<'c, K>( cipher: &'c KeysetCipher<'_, K>, row: Vec<FfiValue>, - plan: &[FieldPlan], + plan: &Plan, pendings: &mut Vec<Pending<'c, StackCipherText, K>>, ) -> Result<Vec<FieldSkeleton>, Error> where K: DataKeySource + Sync, { // A row `source_rows` did not align is a bug here, not caller input. - if row.len() != plan.len() { + if row.len() != plan.fields.len() { return Err(Error::Internal); } - let mut skeleton = Vec::with_capacity(plan.len()); - for (field, value) in plan.iter().zip(row) { + let mut skeleton = Vec::with_capacity(plan.fields.len()); + for (field, value) in plan.fields.iter().zip(row) { let name = field.name.clone(); // The one context this field has, cloned per output: this variable // is what reaches the ciphertext and every term (ADR-0004), and @@ -835,10 +875,11 @@ where #[allow(clippy::type_complexity)] fn record_leaves( tree: StackCipherText, - plan: &[FieldPlan], + plan: &Plan, ) -> Result<Rows<Vec<(String, StackCipherText)>>, Error> { rows(tree)?.try_map(|mut row| { - plan.iter() + plan.fields + .iter() .filter(|field| field.has_ciphertext()) .map(|field| { let (name, node) = take(&mut row, &field.name).ok_or(Error::Record)?; @@ -970,7 +1011,7 @@ mod tests { ]) } - fn the_plan() -> Vec<FieldPlan> { + fn the_plan() -> Plan { plan(plan_value()).expect("the shared plan parses") } @@ -1062,12 +1103,15 @@ mod tests { fn parses_each_field_in_order_with_its_context_and_outputs() { let plan = the_plan(); assert_eq!( - plan.iter().map(FieldPlan::name).collect::<Vec<_>>(), + plan.fields() + .iter() + .map(FieldPlan::name) + .collect::<Vec<_>>(), ["age", "email", "nick"], "fields keep the plan's order" ); assert_eq!( - plan[0].outputs(), + plan.fields()[0].outputs(), [ Output::Ciphertext, Output::Term(TermKind::Equality), @@ -1076,21 +1120,23 @@ mod tests { "outputs keep their spelled order" ); assert_eq!( - plan[1].outputs(), + plan.fields()[1].outputs(), [Output::Ciphertext], "a field can be sealed alone" ); assert_eq!( - plan[2].outputs(), + plan.fields()[2].outputs(), [Output::Term(TermKind::Match)], "a field can be indexed and never sealed" ); assert!( - plan[0].has_ciphertext() && plan[1].has_ciphertext() && !plan[2].has_ciphertext(), + plan.fields()[0].has_ciphertext() + && plan.fields()[1].has_ciphertext() + && !plan.fields()[2].has_ciphertext(), "has_ciphertext follows the outputs" ); assert_eq!( - plan[1].context(), + plan.fields()[1].context(), &context(FfiValue::Array(vec![s("users/email"), FfiValue::UInt64(7)])) .expect("context"), "a field's context is the one its spec spelled, read by `context`" @@ -1232,6 +1278,41 @@ mod tests { "one output is a plan" ); } + + /// The whole-plan rules hold for a plan built by hand, not only for + /// a parsed one: a hand-built plan reaches the same `encrypt` and + /// `check_source`, which rely on them. + #[test] + fn a_hand_built_plan_refuses_no_fields_and_a_repeated_name() { + let field = |name: &str| { + FieldPlan::new( + name, + context(s("users/age")).expect("context"), + vec![Output::Ciphertext], + ) + .expect("field") + }; + assert!( + matches!(Plan::new(vec![]), Err(Error::Plan)), + "a plan must have a field" + ); + assert!( + matches!( + Plan::new(vec![field("age"), field("age")]), + Err(Error::Plan) + ), + "a field cannot be planned twice" + ); + let plan = Plan::new(vec![field("age"), field("email")]).expect("a plan"); + assert_eq!( + plan.fields() + .iter() + .map(FieldPlan::name) + .collect::<Vec<_>>(), + ["age", "email"], + "the fields keep the order given" + ); + } } mod given_a_source_that_does_not_fit_the_plan { @@ -1444,8 +1525,8 @@ mod tests { let cipher = cipher().await; let keyset = cipher.default_keyset(); let plan = the_plan(); - let age_ctx = plan[0].context().clone(); - let nick_ctx = plan[2].context().clone(); + let age_ctx = plan.fields()[0].context().clone(); + let nick_ctx = plan.fields()[2].context().clone(); let mut fields = map(encrypt(&keyset, row(34), &plan).await.expect("encrypt")); let mut age = map(node(&mut fields, "age")); @@ -1800,7 +1881,7 @@ mod tests { fn the_plan_context_is_the_typed_context() { use crate::IntoAad; assert_eq!( - the_plan()[0] + the_plan().fields()[0] .context() .clone() .into_inner() From c3e35bdb059419045de54415473d9492c1e99948 Mon Sep 17 00:00:00 2001 From: Dan Draper <dan@cipherstash.com> Date: Fri, 18 Sep 2026 08:00:00 -0700 Subject: [PATCH 584/686] docs(stack-encrypt): the user-agent doc spells the product token as sent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `VERSION` exists so a binding can build its user-agent, and its doc gave the spelling as "stack-encrypt 0.1.0" — a space, where the guest sends `stack-encrypt/0.1.0 (Go)`. A binding copying the doc would have sent an invalid product token. Both docs now give the slash-separated spelling. --- languages/golang/stackencrypt/guest/src/headers.rs | 9 +++++---- packages/stack-encrypt/src/lib.rs | 7 +++++-- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/languages/golang/stackencrypt/guest/src/headers.rs b/languages/golang/stackencrypt/guest/src/headers.rs index c0edcebf3..f3ba44acf 100644 --- a/languages/golang/stackencrypt/guest/src/headers.rs +++ b/languages/golang/stackencrypt/guest/src/headers.rs @@ -29,10 +29,11 @@ const HOST: &str = "Go"; /// one in `stack_kms::user_agent`; the guest builds its own requests and /// never goes through that path, so it has to say who it is here. /// -/// It names the *library* and the host carrying it, not this crate: a -/// report of "stack-encrypt 0.1.0" means the same thing from Rust, from -/// here, or from a native cdylib, and the guest shim's own version number -/// would say nothing anyone reading a log wants to know. +/// It names the *library* and the host carrying it, not this crate — a +/// `stack-encrypt/0.1.0 (Go)`: the `stack-encrypt/0.1.0` product token +/// means the same thing from Rust, from here, or from a native cdylib, and +/// the guest shim's own version number would say nothing anyone reading a +/// log wants to know. pub fn user_agent() -> &'static str { static USER_AGENT: OnceLock<String> = OnceLock::new(); USER_AGENT.get_or_init(|| format!("stack-encrypt/{} ({HOST})", stack_encrypt::VERSION)) diff --git a/packages/stack-encrypt/src/lib.rs b/packages/stack-encrypt/src/lib.rs index 0cdbc653e..fc9a258b2 100644 --- a/packages/stack-encrypt/src/lib.rs +++ b/packages/stack-encrypt/src/lib.rs @@ -259,8 +259,11 @@ endpoint — are `StackKmsBuilder`'s, and the two keyset-cache knobs are /// ZeroKMS requests it makes. /// /// A request is identified by the library that makes it, not by whichever -/// binding shim is carrying it: "stack-encrypt 0.1.0" means the same thing -/// from the WASI guest under Go as from a native cdylib under Python. The +/// binding shim is carrying it: a product token of `stack-encrypt/0.1.0` +/// (the `product/version` spelling a `user-agent` is made of, with the +/// host in a comment after it — the Go guest sends +/// `stack-encrypt/0.1.0 (Go)`) means the same thing from the WASI guest +/// under Go as from a native cdylib under Python. The /// native Rust client does not go through a binding and identifies itself /// as `stack-kms` (see `stack_kms`'s user agent) — the crate that actually /// makes its requests. From 7834a7810e003c344bf5ffc080db14b6b7521944 Mon Sep 17 00:00:00 2001 From: Dan Draper <dan@cipherstash.com> Date: Fri, 18 Sep 2026 08:25:22 -0700 Subject: [PATCH 585/686] fix(stack-encrypt): a record names each field, and its ciphertext, exactly once MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The row map and a field's output map are stripped by the record path and never reach the cipher, so its duplicate-key refusal did not cover them: `take` picked the first match, and a stale-but-valid ciphertext appended beside the current one — a second `"age"`, or a second `"c"` — would be chosen or ignored by position. `take` now requires exactly one occurrence. Below those maps the cipher does refuse a repeated key, but only once the value reaches it: after the plan check on encrypt (where the failure read as this module's own bug) and after the row's keys were requested on decrypt. The tree walk the preflight runs now refuses a repeated key at any depth alongside a passthrough, so `check_source` and `check_record` agree with the operation on what is malformed, and the verdict is the caller's. --- packages/stack-encrypt/src/dynamic/mod.rs | 10 +- packages/stack-encrypt/src/dynamic/record.rs | 141 ++++++++++++++++--- 2 files changed, 124 insertions(+), 27 deletions(-) diff --git a/packages/stack-encrypt/src/dynamic/mod.rs b/packages/stack-encrypt/src/dynamic/mod.rs index d61bd4f26..5d7ccab83 100644 --- a/packages/stack-encrypt/src/dynamic/mod.rs +++ b/packages/stack-encrypt/src/dynamic/mod.rs @@ -147,14 +147,16 @@ pub enum Error { /// A record source does not fit its plan: not an object (or an array of /// them), a field the plan does not name, a plan field the source does - /// not carry, or a passthrough under a field the plan seals. + /// not carry or carries twice, or a passthrough or a repeated map key + /// under a field the plan seals. #[error("record source does not fit the plan")] Source, /// A stored record does not fit its plan: not a map (or a sequence of - /// them), a ciphertext-bearing field that is absent or has no `"c"` - /// node, or a passthrough under `"c"` — which would hand back - /// unauthenticated bytes as if they had been opened. + /// them), a ciphertext-bearing field that is absent or given twice, or + /// has no `"c"` node or two of them, a repeated map key under `"c"`, or + /// a passthrough under `"c"` — which would hand back unauthenticated + /// bytes as if they had been opened. #[error("stored record does not fit the plan")] Record, diff --git a/packages/stack-encrypt/src/dynamic/record.rs b/packages/stack-encrypt/src/dynamic/record.rs index ad34a7711..416799621 100644 --- a/packages/stack-encrypt/src/dynamic/record.rs +++ b/packages/stack-encrypt/src/dynamic/record.rs @@ -681,16 +681,43 @@ impl<T> Rows<T> { } } -/// Take the entry named `name` out of a row, whatever order the row had it -/// in. `None` if the row has no such entry. +/// Take the one entry named `name` out of a row, whatever order the row had +/// it in. `None` if the row has no such entry — or has it twice: the maps +/// this is used on (a row, a field's output map) are stripped here and never +/// reach the cipher's own duplicate-key refusal, so a first-match take would +/// quietly pick one of two `"c"` nodes for a field, and an attacker with +/// write access to the stored tree could append a stale-but-valid +/// ciphertext beside the current one and have it chosen. fn take<T>(row: &mut Vec<(String, T)>, name: &str) -> Option<(String, T)> { - let at = row.iter().position(|(n, _)| n == name)?; + let mut matches = row.iter().enumerate().filter(|(_, (n, _))| n == name); + let (at, _) = matches.next()?; + if matches.next().is_some() { + return None; + } Some(row.swap_remove(at)) } -/// Reject a tree that contains a passthrough anywhere. +/// Whether no key in `entries` repeats. +fn keys_are_unique<T>(entries: &[(String, T)]) -> bool { + let mut seen = std::collections::HashSet::with_capacity(entries.len()); + entries.iter().all(|(key, _)| seen.insert(key.as_str())) +} + +/// Reject a tree that contains a passthrough anywhere, or a map with a key +/// given twice anywhere. /// -/// On the encrypt side a source field value with one inside it must not +/// The duplicate-key half keeps the preflight honest. The cipher refuses a +/// repeated key itself — at seal, because a map it cannot open must never +/// be produced, and at open, because a stale entry appended beside the +/// current one *verifies* under the same per-entry AAD — but it does so +/// only once the value reaches it: on the encrypt side that is after the +/// plan check has passed, where a failure reads as this module's own bug, +/// and on the decrypt side after the row's keys have been requested. A +/// `check_source`/`check_record` that let such a tree through would say +/// "well-formed" of a value the operation then refuses, so the walk refuses +/// it here, as the misfit it is. +/// +/// On the passthrough half: on the encrypt side a source field value with one inside it must not /// reach a `"c"` slot: a passthrough node is *unauthenticated by definition* /// — on decrypt it hands its payload back with no AEAD opened — so admitting /// one under a field the plan declares ciphertext-bearing would quietly @@ -703,15 +730,18 @@ fn take<T>(row: &mut Vec<(String, T)>, name: &str) -> Option<(String, T)> { /// successful decrypt. [`encrypt`] never produces a passthrough under `"c"`, /// so the shape is unconditionally an error, and the encrypt-side check is /// what makes that a round-trip invariant rather than data loss. -fn reject_passthrough<T: RecordTree>(tree: &T) -> Result<(), Error> { +fn check_tree<T: RecordTree>(tree: &T) -> Result<(), Error> { if tree.is_passthrough() { return Err(T::MISFIT); } match tree.children() { - Children::Sequence(items) => items.iter().try_for_each(reject_passthrough), - Children::Map(entries) => entries - .iter() - .try_for_each(|(_, node)| reject_passthrough(node)), + Children::Sequence(items) => items.iter().try_for_each(check_tree), + Children::Map(entries) => { + if !keys_are_unique(entries) { + return Err(T::MISFIT); + } + entries.iter().try_for_each(|(_, node)| check_tree(node)) + } Children::None => Ok(()), } } @@ -722,10 +752,10 @@ fn reject_passthrough<T: RecordTree>(tree: &T) -> Result<(), Error> { /// The rows of a record source, each aligned to the plan's field order, with /// everything that can be checked without a cipher checked: the source is -/// one object or an array of objects, every plan field is present in every -/// row and no row carries a field the plan does not name (silently dropping -/// a field on either side would lose data or index nothing), and each value -/// fits its field's outputs ([`check_field`]). +/// one object or an array of objects, every plan field is present exactly +/// once in every row and no row carries a field the plan does not name +/// (silently dropping a field on either side would lose data or index +/// nothing), and each value fits its field's outputs ([`check_field`]). fn source_rows(source: FfiValue, plan: &Plan) -> Result<Rows<Vec<FfiValue>>, Error> { rows(source)?.try_map(|mut row| { if row.len() != plan.fields.len() { @@ -744,12 +774,12 @@ fn source_rows(source: FfiValue, plan: &Plan) -> Result<Rows<Vec<FfiValue>>, Err /// A source value against its plan field: every term output needs a scalar /// the scheme defines the term for ([`TermKind::supports`]), and a -/// ciphertext output refuses a passthrough anywhere in the value -/// ([`reject_passthrough`]). +/// ciphertext output refuses a passthrough, or a repeated map key, anywhere +/// in the value ([`check_tree`]). fn check_field(value: &FfiValue, field: &FieldPlan) -> Result<(), Error> { for output in &field.outputs { match output { - Output::Ciphertext => reject_passthrough(value)?, + Output::Ciphertext => check_tree(value)?, Output::Term(kind) => { let scalar = Scalar::of(value, *kind)?; if !kind.supports(&scalar) { @@ -850,7 +880,11 @@ where } if field.has_ciphertext() { - reject_passthrough(&value)?; + // Re-checked here so this function's own contract does not rest + // on its caller's: with the tree checked, the cipher's refusals + // (a passthrough, a repeated key) cannot fire, and a failure + // below is a bug here. + check_tree(&value)?; let tree = value .encrypt_with_aad(cipher, context.clone()) .map_err(|_| Error::Internal)?; @@ -868,10 +902,10 @@ where /// The `"c"` subtrees a record tree holds for the plan's ciphertext-bearing /// fields, per row in plan order, with the row's field name: the tree is one -/// map or a sequence of maps, each such field is present, is a map of -/// outputs with a `"c"` node, and that node has no passthrough in it -/// ([`reject_passthrough`]). Terms and fields the plan does not name are -/// ignored (comparands, not ciphertext). +/// map or a sequence of maps, each such field is present exactly once, is a +/// map of outputs with exactly one `"c"` node, and that node has no +/// passthrough and no repeated key in it ([`check_tree`]). Terms and fields +/// the plan does not name are ignored (comparands, not ciphertext). #[allow(clippy::type_complexity)] fn record_leaves( tree: StackCipherText, @@ -887,7 +921,7 @@ fn record_leaves( return Err(Error::Record); }; let (_, ct) = take(&mut outputs, Output::Ciphertext.key()).ok_or(Error::Record)?; - reject_passthrough(&ct)?; + check_tree(&ct)?; Ok((name, ct)) }) .collect() @@ -1366,6 +1400,25 @@ mod tests { FfiValue::Object(with_passthrough_in_a_list), |e| matches!(e, Error::Source), ), + ( + "a plan field given twice", + { + let mut entries = object(row(1)); + let _ = take(&mut entries, "nick"); + entries.push(("age".to_string(), FfiValue::UInt32(2))); + FfiValue::Object(entries) + }, + |e| matches!(e, Error::Source), + ), + ( + "a repeated key inside an object under a sealed field", + { + let mut entries = object(row(1)); + entries[1].1 = obj(vec![("k", s("a@x")), ("k", s("b@x"))]); + FfiValue::Object(entries) + }, + |e| matches!(e, Error::Source), + ), ( "a container under an indexed field", { @@ -1757,6 +1810,37 @@ mod tests { CipherText::Map(fields), )); + // The three shapes where a first-match take would have picked + // one of two valid ciphertexts: a second, stale-but-valid copy + // of a field, of its `"c"` output, or of a key inside it. + let mut fields = sealed(&keyset).await; + let mut stale = sealed(&keyset).await; + fields.push(("age".to_string(), node(&mut stale, "age"))); + cases.push(("a sealed field given twice", CipherText::Map(fields))); + + let mut fields = sealed(&keyset).await; + let mut stale = sealed(&keyset).await; + let mut age = map(node(&mut fields, "age")); + let mut stale_age = map(node(&mut stale, "age")); + age.push(("c".to_string(), node(&mut stale_age, "c"))); + fields.push(("age".to_string(), CipherText::Map(age))); + cases.push(("a ciphertext output given twice", CipherText::Map(fields))); + + let mut fields = sealed(&keyset).await; + let mut stale = sealed(&keyset).await; + let mut age = map(node(&mut fields, "age")); + let mut stale_age = map(node(&mut stale, "age")); + let (current, older) = (node(&mut age, "c"), node(&mut stale_age, "c")); + age.push(( + "c".to_string(), + CipherText::Map(vec![("v".to_string(), current), ("v".to_string(), older)]), + )); + fields.push(("age".to_string(), CipherText::Map(age))); + cases.push(( + "a repeated key inside the ciphertext subtree", + CipherText::Map(fields), + )); + let before = retrieves(&cipher); for (label, record) in cases { let err = decrypt(Scope::Client(&cipher), record, &plan).await.err(); @@ -1782,6 +1866,17 @@ mod tests { matches!(err, Some(Error::Record)), "check_record refuses a forged ciphertext the same way: {err:?}" ); + let mut fields = sealed(&keyset).await; + let mut stale = sealed(&keyset).await; + let mut age = map(node(&mut fields, "age")); + let mut stale_age = map(node(&mut stale, "age")); + age.push(("c".to_string(), node(&mut stale_age, "c"))); + fields.push(("age".to_string(), CipherText::Map(age))); + let err = check_record(CipherText::Map(fields), &plan).err(); + assert!( + matches!(err, Some(Error::Record)), + "check_record refuses a twice-given ciphertext the same way: {err:?}" + ); } #[tokio::test] From 381fb4fd0daa2842f32102e37b0177093713bba1 Mon Sep 17 00:00:00 2001 From: Dan Draper <dan@cipherstash.com> Date: Fri, 18 Sep 2026 09:24:25 -0700 Subject: [PATCH 586/686] docs(go): the example and the plan follow the keyset API rename `Client.Keyset(sel)` and `Client.DefaultKeyset()` replaced `Cipher(sel)`, `DefaultCipher()` and `DefaultKeysetID()` one branch down; the example now resolves the default keyset's id through `Cipher.KeysetID`, and the bindings plan names the shipped methods by their Rust counterparts. --- docs/plans/stack-encrypt-go-bindings.md | 5 +++-- languages/golang/stackencrypt/example/main.go | 8 ++++++-- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/docs/plans/stack-encrypt-go-bindings.md b/docs/plans/stack-encrypt-go-bindings.md index 5cf4e0461..688387322 100644 --- a/docs/plans/stack-encrypt-go-bindings.md +++ b/docs/plans/stack-encrypt-go-bindings.md @@ -431,8 +431,9 @@ temporary until publishing). It imports `vcffi` + `vcvalue` from vitaminc `go:stackencrypt:test` in `test-wasi.yml`. Where the shipped surface differs from the sketch below, the shipped one follows CIP-4037: one instance per `Client` and no cipher handle, so `NewClient` takes the -ZeroKMS credentials and initialises the cipher, `Client.Cipher(selector)` -is the keyset-bound view (the Rust `KeysetCipher`), `Client.Decrypt*` +ZeroKMS credentials and initialises the cipher, `Client.Keyset(selector)` +is the keyset-bound view (the Rust `StackCipher::keyset`, returning its +`KeysetCipher`) and `Client.DefaultKeyset()` its `default_keyset`, `Client.Decrypt*` opens any keyset, and `Term` takes a `Context` and returns an error. Original sketch: `bindings/go/stackencrypt` (module path TBD — see diff --git a/languages/golang/stackencrypt/example/main.go b/languages/golang/stackencrypt/example/main.go index f4299f7ca..f18f43359 100644 --- a/languages/golang/stackencrypt/example/main.go +++ b/languages/golang/stackencrypt/example/main.go @@ -62,9 +62,13 @@ func run() error { // error paths below. defer func() { _ = client.Close(context.Background()) }() - fmt.Printf("default keyset %s\n\n", client.DefaultKeysetID()) + cipher := client.DefaultKeyset() + keysetID, err := cipher.KeysetID(ctx) + if err != nil { + return err + } + fmt.Printf("default keyset %s\n\n", keysetID) - cipher := client.DefaultCipher() if err := values(ctx, cipher); err != nil { return err } From d59ad8ca512a159e889de609fd610136d73e0bb7 Mon Sep 17 00:00:00 2001 From: Dan Draper <dan@cipherstash.com> Date: Wed, 16 Sep 2026 21:53:33 -0400 Subject: [PATCH 587/686] feat(go): a record plan is a value, not only a tag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Go binding could only plan a record from `stash` struct tags, which a generated struct (protobuf) has no way to carry. The plan the guest reads was already plain data; this makes it a value on the host side too. `Plan` is an immutable value with two constructors: `PlanFromTags`, which is what the tags always parsed to and remains the default, and `NewPlan` over `PlanField`s for a struct that cannot be tagged. `WithPlan` runs any record call under an explicit plan; the zero Plan is the tag path. A plan binds to a struct by exported, direct field name at call time — a name lookup per field, cheaper than caching every plan a caller ever builds. A tag plan and the equivalent explicit plan give the guest byte-identical input, and the guest accepts both (pinned). The object sent across the boundary does not change. CIP-4081 Claude-Session: https://claude.ai/code/session_019b9YUGNPBrj9UUX2aZ8RbA --- languages/golang/stackencrypt/doc.go | 4 +- languages/golang/stackencrypt/guest_test.go | 19 ++ languages/golang/stackencrypt/record.go | 244 ++++++++++++++++---- languages/golang/stackencrypt/unit_test.go | 121 +++++++++- 4 files changed, 339 insertions(+), 49 deletions(-) diff --git a/languages/golang/stackencrypt/doc.go b/languages/golang/stackencrypt/doc.go index b4cee79b8..ada55c9e9 100644 --- a/languages/golang/stackencrypt/doc.go +++ b/languages/golang/stackencrypt/doc.go @@ -37,7 +37,9 @@ // [Cipher.EncryptRecords] is the runtime form of the Rust derive: a struct's // `stash` tags say, per field, which context to bind and which index terms // to produce, and one call seals every row of a slice from batched key -// requests. Key requests are batched 500 keys at a time, in both +// requests. The same plan is a value ([Plan]): [PlanFromTags] is what the +// tags parse to, [NewPlan] builds one for a struct that cannot carry tags +// (generated code), and [WithPlan] runs a record call under it. Key requests are batched 500 keys at a time, in both // directions: one request for any ordinary value or batch, one more per // 500 sealed leaves beyond that. Terms ([EqualityTerm], [MatchTerm], // [OreTerm], [OpeTerm]) are diff --git a/languages/golang/stackencrypt/guest_test.go b/languages/golang/stackencrypt/guest_test.go index b47ec1591..ff697a45d 100644 --- a/languages/golang/stackencrypt/guest_test.go +++ b/languages/golang/stackencrypt/guest_test.go @@ -519,6 +519,18 @@ func TestGuestAcceptsEveryEncodingThisPackageBuilds(t *testing.T) { rows := []recordRow{{Age: 1, Email: "a@b.c"}} var out []recordRow var one recordRow + type untaggedRow struct { + Age uint32 + Email string + } + plan, err := NewPlan( + PlanField{Field: "Age", Context: "users/age", Index: []TermKind{Equality, Ore}}, + PlanField{Field: "Email", Context: "users/email", Index: []TermKind{Equality, Match}}, + ) + if err != nil { + t.Fatal(err) + } + var planned []untaggedRow calls := map[string]func() error{ "KeysetID by name": func() error { _, err := named.KeysetID(ctx); return err }, "KeysetID by id": func() error { _, err := byID.KeysetID(ctx); return err }, @@ -542,6 +554,13 @@ func TestGuestAcceptsEveryEncodingThisPackageBuilds(t *testing.T) { "DecryptRecords bound": func() error { return def.DecryptRecords(ctx, []EncryptedRecord{record}, &out) }, "DecryptRecords any": func() error { return c.DecryptRecords(ctx, []EncryptedRecord{record, record}, &out) }, "DecryptRecord any": func() error { return c.DecryptRecord(ctx, record, &one, ExtendContext("x")) }, + "EncryptRecords plan": func() error { + _, err := def.EncryptRecords(ctx, []untaggedRow{{Age: 1, Email: "a@b.c"}}, WithPlan(plan)) + return err + }, + "DecryptRecords plan": func() error { + return c.DecryptRecords(ctx, []EncryptedRecord{record}, &planned, WithPlan(plan), ExtendContext(uint64(7))) + }, } for name, call := range calls { if err := call(); !errors.Is(err, ErrState) { diff --git a/languages/golang/stackencrypt/record.go b/languages/golang/stackencrypt/record.go index 735036b54..42d36bb05 100644 --- a/languages/golang/stackencrypt/record.go +++ b/languages/golang/stackencrypt/record.go @@ -12,7 +12,11 @@ import ( "github.com/cipherstash/vitaminc/bindings/go/vcvalue" ) -// Record plans from struct tags — the Go stand-in for the Rust derive. +// Record plans: per field, which context to bind and which outputs to +// derive. A plan is a value ([Plan]) with two sources: `stash` struct tags +// ([PlanFromTags], the default, and the Go stand-in for the Rust derive), or +// an explicit plan built with [NewPlan] and passed through [WithPlan] — for +// structs whose source cannot carry a tag, such as generated code. // // A struct field's `stash` tag says what to do with it: // @@ -30,10 +34,20 @@ import ( // all, is not part of the record: it never crosses the boundary, and stays // the caller's to store. Unexported fields are ignored. // -// Every planned field is sealed (the `"c"` output) and the plan is built -// once per type. The context each field binds is its tag's part, extended -// by [ExtendContext] parts exactly as the Rust derive extends a field's -// context by the caller's: NewContext(tag).With(p1).With(p2). +// The same plan, built by hand: +// +// plan, err := stackencrypt.NewPlan( +// stackencrypt.PlanField{Field: "Age", Context: "users/age", Index: []stackencrypt.TermKind{stackencrypt.Equality, stackencrypt.Ore}}, +// stackencrypt.PlanField{Field: "Email", Context: "users/email", Index: []stackencrypt.TermKind{stackencrypt.Equality, stackencrypt.Match}}, +// stackencrypt.PlanField{Field: "Notes", Context: "users/notes"}, +// ) +// records, err := cipher.EncryptRecords(ctx, users, stackencrypt.WithPlan(plan)) +// +// Every planned field is sealed (the `"c"` output). What the guest receives +// is the same object whichever way the plan was built. The context each +// field binds is the plan's part, extended by [ExtendContext] parts exactly +// as the Rust derive extends a field's context by the caller's: +// NewContext(part).With(p1).With(p2). // EncryptedField is one field's outputs from EncryptRecords: the sealed // ciphertext and whichever index terms the plan asked for (nil otherwise). @@ -55,6 +69,7 @@ type RecordOption func(*recordOptions) type recordOptions struct { extension []any + plan Plan } // ExtendContext extends every field's context by parts, in order, the way @@ -66,25 +81,117 @@ func ExtendContext(parts ...any) RecordOption { return func(o *recordOptions) { o.extension = append(o.extension, parts...) } } -// fieldPlan is one planned struct field. -type fieldPlan struct { - index int // struct field index - name string // wire name - context string // the field's own context part +// WithPlan encrypts or decrypts records under an explicit plan instead of +// the struct's `stash` tags. Records encrypted under a plan must be +// decrypted under the same plan (field names, contexts and outputs), the +// same way tags must not change between the two. +func WithPlan(p Plan) RecordOption { + return func(o *recordOptions) { o.plan = p } +} + +// PlanField is one planned field of a record. +type PlanField struct { + // Field is the Go struct field name. It must be exported. + Field string + // Name is the record key the field's outputs are stored under: the + // column name, in EQL terms. Field when empty. + Name string + // Context is the field's own encryption context, a string part; the + // record call extends it by any ExtendContext parts. Required. + Context string + // Index lists the terms to derive beside the ciphertext, in order. + Index []TermKind +} + +// Plan is a record plan: which fields of a struct to seal, under which +// context, with which index terms. It is an immutable value; the zero Plan +// means "the struct's tags". Build one with [NewPlan] or [PlanFromTags]. +type Plan struct { + d *planData +} + +// planData is the validated, shared body of a Plan. Its identity is the +// cache key for bindings, so it is never mutated after NewPlan returns. +type planData struct { + fields []planField +} + +// planField is a validated PlanField: outputs already spelled the way the +// guest reads them. +type planField struct { + field string + name string + context string outputs []string } -var plans sync.Map // reflect.Type → []fieldPlan +// NewPlan validates the fields and returns the plan. Every field needs a +// Field and a Context; record names (Name, or Field) must be unique; Index +// kinds must be ones this package defines. A plan is built once and reused +// across calls, like the type it describes. +func NewPlan(fields ...PlanField) (Plan, error) { + if len(fields) == 0 { + return Plan{}, errors.New("stackencrypt: a plan needs at least one field") + } + d := &planData{fields: make([]planField, 0, len(fields))} + seen := make(map[string]bool, len(fields)) + for _, f := range fields { + if f.Field == "" { + return Plan{}, errors.New("stackencrypt: plan field without a Field name") + } + if f.Context == "" { + return Plan{}, fmt.Errorf("stackencrypt: plan field %s: a planned field needs a context", f.Field) + } + pf := planField{field: f.Field, name: f.Field, context: f.Context, outputs: make([]string, 1, 1+len(f.Index))} + pf.outputs[0] = "c" + if f.Name != "" { + pf.name = f.Name + } + for _, k := range f.Index { + if _, ok := parseTermKind(k.String()); !ok { + return Plan{}, fmt.Errorf("stackencrypt: plan field %s: unknown index kind %s", f.Field, k) + } + pf.outputs = append(pf.outputs, k.String()) + } + if seen[pf.name] { + return Plan{}, fmt.Errorf("stackencrypt: two plan fields share the record name %q", pf.name) + } + seen[pf.name] = true + d.fields = append(d.fields, pf) + } + return Plan{d: d}, nil +} + +// Fields returns the plan's fields, in order, as they were given to NewPlan +// (Name filled in). A copy: mutating it does not touch the plan. +func (p Plan) Fields() []PlanField { + if p.d == nil { + return nil + } + out := make([]PlanField, len(p.d.fields)) + for i, f := range p.d.fields { + out[i] = PlanField{Field: f.field, Name: f.name, Context: f.context} + for _, o := range f.outputs[1:] { + k, _ := parseTermKind(o) + out[i].Index = append(out[i].Index, k) + } + } + return out +} + +var tagPlans sync.Map // reflect.Type → Plan -// planFor parses (and caches) a struct type's plan. -func planFor(t reflect.Type) ([]fieldPlan, error) { - if cached, ok := plans.Load(t); ok { - return cached.([]fieldPlan), nil +// PlanFromTags parses (and caches) the plan a struct type's `stash` tags +// describe. This is the plan the record calls use when no WithPlan option +// is given. +func PlanFromTags(t reflect.Type) (Plan, error) { + if cached, ok := tagPlans.Load(t); ok { + return cached.(Plan), nil } if t.Kind() != reflect.Struct { - return nil, fmt.Errorf("stackencrypt: records must be structs, not %s", t) + return Plan{}, fmt.Errorf("stackencrypt: records must be structs, not %s", t) } - var plan []fieldPlan + var fields []PlanField seen := map[string]bool{} for i := 0; i < t.NumField(); i++ { f := t.Field(i) @@ -95,48 +202,92 @@ func planFor(t reflect.Type) ([]fieldPlan, error) { if !ok || tag == "-" || tag == "plain" { continue } - fp := fieldPlan{index: i, name: f.Name, outputs: []string{"c"}} + pf := PlanField{Field: f.Name, Name: f.Name} for _, opt := range strings.Split(tag, ",") { key, value, _ := strings.Cut(opt, "=") switch key { case "context": if value == "" { - return nil, fmt.Errorf("stackencrypt: field %s.%s: context must not be empty", t, f.Name) + return Plan{}, fmt.Errorf("stackencrypt: field %s.%s: context must not be empty", t, f.Name) } - fp.context = value + pf.Context = value case "name": if value == "" { - return nil, fmt.Errorf("stackencrypt: field %s.%s: name must not be empty", t, f.Name) + return Plan{}, fmt.Errorf("stackencrypt: field %s.%s: name must not be empty", t, f.Name) } - fp.name = value + pf.Name = value case "index": for _, k := range strings.Split(value, ";") { kind, ok := parseTermKind(k) if !ok { - return nil, fmt.Errorf("stackencrypt: field %s.%s: unknown index kind %q", t, f.Name, k) + return Plan{}, fmt.Errorf("stackencrypt: field %s.%s: unknown index kind %q", t, f.Name, k) } - fp.outputs = append(fp.outputs, kind.String()) + pf.Index = append(pf.Index, kind) } default: - return nil, fmt.Errorf("stackencrypt: field %s.%s: unknown stash tag option %q", t, f.Name, opt) + return Plan{}, fmt.Errorf("stackencrypt: field %s.%s: unknown stash tag option %q", t, f.Name, opt) } } - if fp.context == "" { - return nil, fmt.Errorf("stackencrypt: field %s.%s: a planned field needs context=", t, f.Name) + if pf.Context == "" { + return Plan{}, fmt.Errorf("stackencrypt: field %s.%s: a planned field needs context=", t, f.Name) } - if seen[fp.name] { - return nil, fmt.Errorf("stackencrypt: %s: two fields share the record name %q", t, fp.name) + if seen[pf.Name] { + return Plan{}, fmt.Errorf("stackencrypt: %s: two fields share the record name %q", t, pf.Name) } - seen[fp.name] = true - plan = append(plan, fp) + seen[pf.Name] = true + fields = append(fields, pf) + } + if len(fields) == 0 { + return Plan{}, fmt.Errorf("stackencrypt: %s has no fields tagged for encryption", t) } - if len(plan) == 0 { - return nil, fmt.Errorf("stackencrypt: %s has no fields tagged for encryption", t) + plan, err := NewPlan(fields...) + if err != nil { + return Plan{}, err } - plans.Store(t, plan) + tagPlans.Store(t, plan) return plan, nil } +// fieldPlan is one planned field bound to a struct type: the plan's field +// resolved to its index. +type fieldPlan struct { + index int // struct field index + name string // wire name + context string // the field's own context part + outputs []string +} + +// bind resolves the plan's fields against a struct type. Not cached: a +// name lookup per field is far below the cost of the call it precedes, and +// a cache keyed by plan would grow with every plan a caller ever built. +func (p Plan) bind(t reflect.Type) ([]fieldPlan, error) { + if t.Kind() != reflect.Struct { + return nil, fmt.Errorf("stackencrypt: records must be structs, not %s", t) + } + bound := make([]fieldPlan, len(p.d.fields)) + for i, f := range p.d.fields { + sf, ok := t.FieldByName(f.field) + if !ok || !sf.IsExported() || len(sf.Index) != 1 { + return nil, fmt.Errorf("stackencrypt: plan field %s is not an exported field of %s", f.field, t) + } + bound[i] = fieldPlan{index: sf.Index[0], name: f.name, context: f.context, outputs: f.outputs} + } + return bound, nil +} + +// planFor binds the plan a record call runs under: the option's, or the +// struct's tags. +func planFor(t reflect.Type, o recordOptions) ([]fieldPlan, error) { + p := o.plan + if p.d == nil { + var err error + if p, err = PlanFromTags(t); err != nil { + return nil, err + } + } + return p.bind(t) +} + // planValue renders the plan object for the guest, each field's context // extended by the options. func planValue(plan []fieldPlan, opts recordOptions) (vcvalue.Object, error) { @@ -172,7 +323,7 @@ func applyOptions(opts []RecordOption) recordOptions { } // EncryptRecords seals every row of a slice of structs (or a pointer to -// one) per the struct's `stash` tags: all rows and fields from batched +// one) per the struct's `stash` tags, or per [WithPlan]: all rows and fields from batched // ZeroKMS key requests (one per 500 sealed fields), terms derived under // this keyset's index key. One EncryptedRecord per row, in order. func (cph *Cipher) EncryptRecords(ctx context.Context, rows any, opts ...RecordOption) ([]EncryptedRecord, error) { @@ -180,7 +331,8 @@ func (cph *Cipher) EncryptRecords(ctx context.Context, rows any, opts ...RecordO if !v.IsValid() || v.Kind() != reflect.Slice { return nil, fmt.Errorf("stackencrypt: EncryptRecords takes a slice of structs, not %T", rows) } - plan, err := planFor(v.Type().Elem()) + o := applyOptions(opts) + plan, err := planFor(v.Type().Elem(), o) if err != nil { return nil, err } @@ -188,7 +340,7 @@ func (cph *Cipher) EncryptRecords(ctx context.Context, rows any, opts ...RecordO for i := range source { source[i] = sourceRow(v.Index(i), plan) } - tree, err := cph.encryptRecords(ctx, plan, source, applyOptions(opts)) + tree, err := cph.encryptRecords(ctx, plan, source, o) if err != nil { return nil, err } @@ -206,17 +358,18 @@ func (cph *Cipher) EncryptRecords(ctx context.Context, rows any, opts ...RecordO } // EncryptRecord seals one struct (or a pointer to one) per its `stash` -// tags; see EncryptRecords. +// tags, or per [WithPlan]; see EncryptRecords. func (cph *Cipher) EncryptRecord(ctx context.Context, row any, opts ...RecordOption) (EncryptedRecord, error) { v := reflect.Indirect(reflect.ValueOf(row)) if !v.IsValid() { return nil, fmt.Errorf("stackencrypt: EncryptRecord takes a struct, not %T", row) } - plan, err := planFor(v.Type()) + o := applyOptions(opts) + plan, err := planFor(v.Type(), o) if err != nil { return nil, err } - tree, err := cph.encryptRecords(ctx, plan, sourceRow(v, plan), applyOptions(opts)) + tree, err := cph.encryptRecords(ctx, plan, sourceRow(v, plan), o) if err != nil { return nil, err } @@ -335,8 +488,8 @@ func (c *Client) decryptRecords(ctx context.Context, sel KeysetSelector, records if ptr.Kind() != reflect.Pointer || ptr.IsNil() || ptr.Elem().Kind() != reflect.Slice { return fmt.Errorf("stackencrypt: DecryptRecords writes into a pointer to a slice of structs, not %T", out) } - elem := ptr.Elem().Type().Elem() - plan, err := planFor(elem) + o := applyOptions(opts) + plan, err := planFor(ptr.Elem().Type().Elem(), o) if err != nil { return err } @@ -346,7 +499,7 @@ func (c *Client) decryptRecords(ctx context.Context, sel KeysetSelector, records return err } } - values, err := c.decryptRecordTree(ctx, sel, plan, tree, applyOptions(opts)) + values, err := c.decryptRecordTree(ctx, sel, plan, tree, o) if err != nil { return err } @@ -393,7 +546,8 @@ func (c *Client) decryptRecord(ctx context.Context, sel KeysetSelector, record E if ptr.Kind() != reflect.Pointer || ptr.IsNil() || ptr.Elem().Kind() != reflect.Struct { return fmt.Errorf("stackencrypt: DecryptRecord writes into a pointer to a struct, not %T", out) } - plan, err := planFor(ptr.Elem().Type()) + o := applyOptions(opts) + plan, err := planFor(ptr.Elem().Type(), o) if err != nil { return err } @@ -401,7 +555,7 @@ func (c *Client) decryptRecord(ctx context.Context, sel KeysetSelector, record E if err != nil { return err } - value, err := c.decryptRecordTree(ctx, sel, plan, tree, applyOptions(opts)) + value, err := c.decryptRecordTree(ctx, sel, plan, tree, o) if err != nil { return err } diff --git a/languages/golang/stackencrypt/unit_test.go b/languages/golang/stackencrypt/unit_test.go index 60c922ef6..062aeeb07 100644 --- a/languages/golang/stackencrypt/unit_test.go +++ b/languages/golang/stackencrypt/unit_test.go @@ -25,7 +25,7 @@ func TestCommitRecordsPreservesRowsAndIsAtomic(t *testing.T) { Age uint8 `stash:"context=users/age"` Email string `stash:"context=users/email"` } - plan, err := planFor(reflect.TypeOf(row{})) + plan, err := planFor(reflect.TypeOf(row{}), recordOptions{}) if err != nil { t.Fatal(err) } @@ -164,7 +164,7 @@ type taggedUser struct { } func TestPlanFromTags(t *testing.T) { - plan, err := planFor(reflect.TypeOf(taggedUser{})) + plan, err := planFor(reflect.TypeOf(taggedUser{}), recordOptions{}) if err != nil { t.Fatal(err) } @@ -209,12 +209,127 @@ func TestPlanFromTags(t *testing.T) { B int `stash:"context=c,name=x"` }{}, } { - if _, err := planFor(reflect.TypeOf(bad)); err == nil { + if _, err := PlanFromTags(reflect.TypeOf(bad)); err == nil { t.Errorf("%s: plan accepted", name) } } } +// An explicit plan is the tag plan by another route: the same fields give +// the guest the same bytes, and WithPlan's zero value is the tag path. +func TestExplicitPlanIsTheTagPlan(t *testing.T) { + typ := reflect.TypeOf(taggedUser{}) + explicit, err := NewPlan( + PlanField{Field: "Age", Context: "users/age", Index: []TermKind{Equality, Ore}}, + PlanField{Field: "Email", Name: "email", Context: "users/email", Index: []TermKind{Equality, Match}}, + PlanField{Field: "Notes", Context: "users/notes"}, + ) + if err != nil { + t.Fatal(err) + } + tagged, err := PlanFromTags(typ) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(explicit.Fields(), tagged.Fields()) { + t.Fatalf("fields differ:\n%+v\n%+v", explicit.Fields(), tagged.Fields()) + } + encode := func(p Plan) []byte { + bound, err := p.bind(typ) + if err != nil { + t.Fatal(err) + } + obj, err := planValue(bound, recordOptions{extension: []any{uint64(7)}}) + if err != nil { + t.Fatal(err) + } + b, err := vcffi.Marshal(obj) + if err != nil { + t.Fatal(err) + } + return b + } + if a, b := encode(explicit), encode(tagged); !bytes.Equal(a, b) { + t.Fatalf("guest input differs:\n%x\n%x", a, b) + } + viaOption, err := planFor(typ, applyOptions([]RecordOption{WithPlan(explicit)})) + if err != nil { + t.Fatal(err) + } + viaTags, err := planFor(typ, applyOptions([]RecordOption{WithPlan(Plan{})})) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(viaOption, viaTags) { + t.Fatalf("bound plans differ:\n%+v\n%+v", viaOption, viaTags) + } + // Fields returns a copy. + explicit.Fields()[0].Context = "changed" + if explicit.Fields()[0].Context != "users/age" { + t.Fatal("Fields exposed the plan's own slice") + } +} + +// A plan can name only exported, direct fields of the struct it binds to, +// and only fields that exist; an untagged struct binds fine under it. +func TestPlanBindsByFieldName(t *testing.T) { + type embedded struct{ Inner string } + type untagged struct { + embedded + Email string + hidden string //nolint:unused // proves unexported fields are refused + } + typ := reflect.TypeOf(untagged{}) + if _, err := PlanFromTags(typ); err == nil { + t.Fatal("untagged struct has a tag plan") + } + ok, err := NewPlan(PlanField{Field: "Email", Context: "c"}) + if err != nil { + t.Fatal(err) + } + bound, err := planFor(typ, applyOptions([]RecordOption{WithPlan(ok)})) + if err != nil { + t.Fatal(err) + } + if len(bound) != 1 || bound[0].index != 1 || bound[0].name != "Email" { + t.Fatalf("bound = %+v", bound) + } + for name, field := range map[string]string{ + "missing": "Nope", + "unexported": "hidden", + "promoted": "Inner", + } { + p, err := NewPlan(PlanField{Field: field, Context: "c"}) + if err != nil { + t.Fatal(err) + } + if _, err := p.bind(typ); err == nil || !strings.Contains(err.Error(), field) { + t.Errorf("%s: bind error = %v, want one naming %q", name, err, field) + } + } + if _, err := ok.bind(reflect.TypeOf(42)); err == nil { + t.Error("bound to a non-struct") + } +} + +func TestNewPlanValidates(t *testing.T) { + for name, fields := range map[string][]PlanField{ + "no fields": nil, + "no field name": {{Context: "c"}}, + "no context": {{Field: "A"}}, + "unknown kind": {{Field: "A", Context: "c", Index: []TermKind{TermKind(9)}}}, + "duplicate name": {{Field: "A", Context: "c", Name: "x"}, {Field: "B", Context: "c", Name: "x"}}, + "field twice": {{Field: "A", Context: "c"}, {Field: "A", Context: "d"}}, + } { + if _, err := NewPlan(fields...); err == nil { + t.Errorf("%s: plan accepted", name) + } + } + if (Plan{}).Fields() != nil { + t.Error("zero plan has fields") + } +} + func TestAssignFieldConvertsWithinFamiliesOnly(t *testing.T) { type flag bool type name string From dbd4bb9e8cf20586960d7a78c6ef60c6ce000e78 Mon Sep 17 00:00:00 2001 From: Dan Draper <dan@cipherstash.com> Date: Sat, 19 Sep 2026 21:27:08 -0700 Subject: [PATCH 588/686] feat(stack-encrypt)!: the Go guest's memory is locked, non-dumpable and never moves; Close takes no context The binding's key hygiene rested on "always call Close": one deferred call that no exit path a process can take (SIGTERM without a handler, SIGKILL, the OOM killer, a panic on another goroutine, os.Exit) runs. The protection now lives where the caller cannot skip it, at allocation. The package supplies the guest's linear memory through wazero's experimental.MemoryAllocator instead of the default Go slice. The module's declared maximum is reserved once (mmap PROT_NONE, VirtualAlloc MEM_RESERVE) and committed from the front as the guest grows, so the buffer never moves: wazero's default grows with append, which copied the whole memory, keys included, to a new slice and left the old one to the GC unwiped. Each committed range is mlocked (VirtualLocked) so it is never swapped; on Linux the reservation is MADV_DONTDUMP; the committed range is wiped before it is unmapped, on every release path. A host with no reservation primitive, or a 32-bit host asked for wasm's 4 GiB default, falls back to a heap slice that still wipes on growth and on release, and reports that it is not locked. The lock is best effort by default: RLIMIT_MEMLOCK is 64 KiB on many Linux hosts and the guest is larger, and a refusal loses only the swap guarantee. Client.MemoryLocked reports the outcome and Client.MemoryLockError names the limit to raise. Config.RequireLockedMemory makes NewClient fail with the new ErrMemoryLock instead, and refuses any later growth that cannot be locked, which Client.call then reports as ErrMemoryLock rather than as the guest's bare allocation failure. Close() drops its context: it does no I/O, and the wipe it runs inside the guest is hygiene now, not the security story. A Client that becomes unreachable without Close is released by a runtime.AddCleanup, which covers the forgot-to-close case in a running process and nothing at exit, as documented. Tests: growth keeps the buffer address; the runtime close frees and wipes; /proc/self/smaps shows the committed range Locked with VmFlags lo and dd (Linux; STACKENCRYPT_TESTS_REQUIRE_LOCK makes the skip an error, and CI sets it after raising ulimit -l); strict mode returns ErrMemoryLock naming RLIMIT_MEMLOCK, provoked in a child process under a zero limit; an unreachable client is released; the heap fallback wipes what it abandons. Verified on macOS and, in containers, on Linux amd64 with the lock granted and refused, and on linux/386 through the fallback. Closes CIP-4111. --- .github/imported-workflows/test-wasi.yml | 11 +- languages/golang/stackencrypt/client.go | 98 ++++- languages/golang/stackencrypt/doc.go | 34 ++ languages/golang/stackencrypt/errors.go | 7 + languages/golang/stackencrypt/go.mod | 2 +- languages/golang/stackencrypt/guest.go | 40 +- languages/golang/stackencrypt/guest_test.go | 22 +- languages/golang/stackencrypt/live_test.go | 3 +- languages/golang/stackencrypt/memory.go | 216 ++++++++++ languages/golang/stackencrypt/memory_linux.go | 22 + .../golang/stackencrypt/memory_mapped.go | 19 + languages/golang/stackencrypt/memory_other.go | 10 + .../golang/stackencrypt/memory_other_test.go | 7 + languages/golang/stackencrypt/memory_test.go | 377 ++++++++++++++++++ languages/golang/stackencrypt/memory_unix.go | 110 +++++ .../golang/stackencrypt/memory_unix_other.go | 12 + .../golang/stackencrypt/memory_unix_test.go | 11 + .../golang/stackencrypt/memory_windows.go | 79 ++++ 18 files changed, 1040 insertions(+), 40 deletions(-) create mode 100644 languages/golang/stackencrypt/memory.go create mode 100644 languages/golang/stackencrypt/memory_linux.go create mode 100644 languages/golang/stackencrypt/memory_mapped.go create mode 100644 languages/golang/stackencrypt/memory_other.go create mode 100644 languages/golang/stackencrypt/memory_other_test.go create mode 100644 languages/golang/stackencrypt/memory_test.go create mode 100644 languages/golang/stackencrypt/memory_unix.go create mode 100644 languages/golang/stackencrypt/memory_unix_other.go create mode 100644 languages/golang/stackencrypt/memory_unix_test.go create mode 100644 languages/golang/stackencrypt/memory_windows.go diff --git a/.github/imported-workflows/test-wasi.yml b/.github/imported-workflows/test-wasi.yml index 63e9fd330..7749b533a 100644 --- a/.github/imported-workflows/test-wasi.yml +++ b/.github/imported-workflows/test-wasi.yml @@ -102,4 +102,13 @@ jobs: # amd64 and 386. Live round trips through ZeroKMS are the phase 5 # harness's. - name: Go binding - run: mise run go:stackencrypt:test + env: + # The guest's memory lock (bindings/go/stackencrypt/memory.go) is + # best effort, so its test skips where RLIMIT_MEMLOCK refuses it — + # a developer laptop's default. CI raises the limit and sets this + # so the skip is an error here and the lock is really exercised. + STACKENCRYPT_TESTS_REQUIRE_LOCK: "1" + run: | + ulimit -l "$(ulimit -H -l)" + echo "RLIMIT_MEMLOCK: $(ulimit -l) KiB" + mise run go:stackencrypt:test diff --git a/languages/golang/stackencrypt/client.go b/languages/golang/stackencrypt/client.go index 1618d1fec..7aa40326e 100644 --- a/languages/golang/stackencrypt/client.go +++ b/languages/golang/stackencrypt/client.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "net/http" + "runtime" "strconv" "sync" @@ -35,12 +36,27 @@ type Config struct { Token TokenSource // Guest overrides the embedded wasm module. Nil means the embedded one. Guest []byte + // RequireLockedMemory makes NewClient fail with ErrMemoryLock when the + // guest's memory cannot be locked in RAM, instead of continuing with + // memory that may be swapped and reporting so through + // Client.MemoryLocked. Set it where swap is a real exposure and the + // deployment can be relied on to grant the lock; see [Client.MemoryLocked]. + RequireLockedMemory bool } // Client is one wasm instance holding one ZeroKMS client: its key, its // default keyset, and the keysets it has loaded since. It is safe for // concurrent use; calls are serialised internally, because a wasm instance -// is single-threaded. Close it to wipe its key material. +// is single-threaded. Close it when done, as with any resource. +// +// Its key material lives in the guest's linear memory, which this package +// supplies: reserved once so it never moves, locked in RAM and excluded +// from core dumps where the platform allows, and wiped before it is +// released. None of that depends on Close running — no exit path a process +// can take (a signal with no handler, SIGKILL, the OOM killer, a panic on +// another goroutine, os.Exit) runs deferred calls, and none of them is +// where the protection lives. [Client.MemoryLocked] reports whether the +// lock was granted. type Client struct { mu sync.Mutex inst *instance @@ -55,6 +71,10 @@ type Client struct { closed bool released bool def KeysetID + // cleanup releases the instance if the Client becomes unreachable + // without Close: the forgot-to-close case in a running process. It + // does nothing at process exit, and is not meant to. + cleanup runtime.Cleanup } // NewClient instantiates the guest, loads the client key into it, and loads @@ -82,24 +102,55 @@ func NewClient(ctx context.Context, cfg Config) (*Client, error) { defer wipe(encoded) t := &transport{rt: rt, token: cfg.Token} - inst, err := newInstance(ctx, wasm, t) + inst, err := newInstance(ctx, wasm, t, cfg.RequireLockedMemory) if err != nil { return nil, err } - c := &Client{inst: inst, transport: t} - out, err := inst.call(ctx, inst.cipherInit, buf(encoded)) + c := newClient(inst, t) + out, err := c.call(ctx, func(inst *instance) ([]byte, error) { + return inst.call(ctx, inst.cipherInit, buf(encoded)) + }) if err != nil { - _ = c.Close(ctx) + _ = c.Close() return nil, fmt.Errorf("stackencrypt: cipher init: %w", err) } if len(out) != len(KeysetID{}) { - _ = c.Close(ctx) + _ = c.Close() return nil, fmt.Errorf("%w: cipher init returned %d bytes for the keyset id", ErrInternal, len(out)) } copy(c.def[:], out) return c, nil } +// newClient wraps an instance and arms its cleanup. The cleanup takes the +// instance, not the client: a cleanup whose argument reaches its object +// keeps that object alive forever. +func newClient(inst *instance, t *transport) *Client { + c := &Client{inst: inst, transport: t} + c.cleanup = runtime.AddCleanup(c, func(inst *instance) { _ = inst.release() }, inst) + return c +} + +// MemoryLocked reports whether the guest's memory — where the client key +// and every loaded index key live — is locked in RAM and, on Linux, +// excluded from core dumps. False means the lock was refused (on Linux, +// most often RLIMIT_MEMLOCK, which defaults to 64 KiB on many hosts) or is +// not available on this platform, and the client is working on with +// memory the kernel may swap out. Nothing else changes. A production +// checklist should assert this, or set [Config.RequireLockedMemory] and +// let NewClient refuse. [Client.MemoryLockError] says why. +func (c *Client) MemoryLocked() bool { return c.inst.mem.lockError() == nil } + +// MemoryLockError is why MemoryLocked is false: an error wrapping +// ErrMemoryLock that names what was refused and the limit that refused it. +// Nil while the memory is locked. +func (c *Client) MemoryLockError() error { + if err := c.inst.mem.lockError(); err != nil { + return memoryLockError(err) + } + return nil +} + // encodeConfig renders the se_cipher_init object. The result holds the // client key; the caller wipes it. func encodeConfig(cfg Config) ([]byte, error) { @@ -123,10 +174,14 @@ func encodeConfig(cfg Config) ([]byte, error) { } // Close shuts the guest down — the client key and every loaded index key -// are wiped inside the instance — and releases the runtime. Idempotent. -// Every call after it fails with ErrState. The shutdown runs even if ctx -// is already cancelled: the wipe is the point of this method. -func (c *Client) Close(ctx context.Context) error { +// are wiped inside the instance — and releases the runtime and the +// guest's memory, which is wiped on the way out. Idempotent. Every call +// after it fails with ErrState. +// +// It takes no context because it does no I/O and must not be skippable: +// a deferred Close is ordinary resource hygiene, and the memory's +// protection (see [Client]) does not wait on it. +func (c *Client) Close() error { c.mu.Lock() defer c.mu.Unlock() // Idempotency turns on the runtime, not on the client: a client an @@ -136,14 +191,8 @@ func (c *Client) Close(ctx context.Context) error { } c.released = true c.closed = true - ctx = context.WithoutCancel(ctx) - // A module closed by an interrupted call (see Client.call) or by a trap - // cannot run se_shutdown; the runtime close still frees its memory. - // Nothing else can be done host-side. - if !c.inst.module.IsClosed() { - _, _ = c.inst.shutdown.Call(ctx) - } - return c.inst.close(ctx) + c.cleanup.Stop() + return c.inst.release() } // Keyset binds the client to one keyset, by name or by id: Rust's @@ -230,15 +279,24 @@ func (c *Client) call(ctx context.Context, f func(*instance) ([]byte, error)) ([ c.closed = true return nil, ErrState } + refusals := c.inst.mem.growthRefusals() out, err := f(c.inst) if c.inst.module.IsClosed() { c.closed = true if err == nil { err = ErrState } - return nil, fmt.Errorf("%w: interrupted call closed the client", err) + err = fmt.Errorf("%w: interrupted call closed the client", err) + } + // Under RequireLockedMemory a growth that cannot be locked is refused, + // and the guest sees only a failed allocation. Name the real cause. + if err != nil && c.inst.mem.growthRefusals() != refusals { + err = fmt.Errorf("%w (growth refused under RequireLockedMemory): %w", memoryLockError(c.inst.mem.lockError()), err) + } + if err != nil { + return nil, err } - return out, err + return out, nil } func (c *Client) decryptValue(ctx context.Context, sel KeysetSelector, ct any, aad []byte, element bool) (any, error) { diff --git a/languages/golang/stackencrypt/doc.go b/languages/golang/stackencrypt/doc.go index b4cee79b8..c3bc2e2b9 100644 --- a/languages/golang/stackencrypt/doc.go +++ b/languages/golang/stackencrypt/doc.go @@ -12,6 +12,7 @@ // free crosses the boundary. [Client.Close] runs the guest's shutdown so the // client key and every loaded index key are wiped before the instance is // freed — closing a wasm instance runs no Rust destructors on its own. +// Close is hygiene, not the security story: see Memory below. // // A [Cipher] is the client bound to one keyset ([Client.Keyset] and // [Client.DefaultKeyset], the Rust crate's StackCipher::keyset and @@ -61,4 +62,37 @@ // instance is configured with the process CSPRNG ([crypto/rand.Reader]) // and the system clocks. An embedder that instantiates the guest module // under its own wazero configuration must do the same. +// +// # Memory +// +// Every key the guest holds — the client key, each loaded index key, each +// data key for the length of a call — lives in the guest's linear memory, +// and the package supplies that memory itself rather than taking wazero's +// default Go slice. It is reserved once at the module's declared maximum, +// so growth never copies it (wazero's default grows with append, which +// would leave an unwiped copy of every key to the garbage collector); +// locked in RAM (mlock, VirtualLock) so it is never written to swap; +// excluded from core dumps on Linux (MADV_DONTDUMP); and wiped before it +// is released, on every release path. +// +// That is deliberately done at allocation, where the caller cannot get it +// wrong, and not at exit, where they cannot be relied on: no deferred +// [Client.Close] runs on SIGTERM without a handler, SIGKILL, the OOM +// killer, a panic on another goroutine or os.Exit, and the package installs +// no signal handler — that is the application's to own, and covers only +// the first of those anyway. The kernel zeroes a dead process's pages +// before anyone else sees them; the lock and the dump exclusion close the +// two places a copy could otherwise outlive the process. +// +// The lock is best effort: RLIMIT_MEMLOCK defaults to 64 KiB on many +// Linux hosts and the guest is larger, so it is commonly refused, and a +// client then works on with memory that may be swapped — which is all +// that is lost, and nothing on a host without swap. [Client.MemoryLocked] +// reports the outcome and [Client.MemoryLockError] the reason, naming the +// limit to raise (ulimit -l, a systemd LimitMEMLOCK=, a pod's +// securityContext). [Config.RequireLockedMemory] turns a refusal into a +// [NewClient] failure with [ErrMemoryLock], for deployments that would +// rather not start than run unlocked. An embedder running the guest under +// its own wazero configuration gets none of this unless it supplies an +// allocator of its own. package stackencrypt diff --git a/languages/golang/stackencrypt/errors.go b/languages/golang/stackencrypt/errors.go index 4856922d5..a87193ea0 100644 --- a/languages/golang/stackencrypt/errors.go +++ b/languages/golang/stackencrypt/errors.go @@ -47,6 +47,13 @@ var ( // under another keyset, before any key is retrieved. Open it through the // Client, which is not bound to one keyset. ErrForeignKeyset = errors.New("stackencrypt: ciphertext belongs to another keyset") + // ErrMemoryLock is guest memory that could not be locked in RAM (or, + // on Linux, excluded from core dumps). NewClient returns it when + // Config.RequireLockedMemory is set; otherwise Client.MemoryLockError + // reports it and the client works on with unlocked memory. The wrapped + // error names the limit that refused the lock: on Linux, RLIMIT_MEMLOCK + // (ulimit -l, a systemd LimitMEMLOCK=, or a pod's securityContext). + ErrMemoryLock = errors.New("stackencrypt: guest memory is not locked") ) // Guest status codes (guest/src/status.rs). Part of the guest/host diff --git a/languages/golang/stackencrypt/go.mod b/languages/golang/stackencrypt/go.mod index a9f756451..e8e0fe47b 100644 --- a/languages/golang/stackencrypt/go.mod +++ b/languages/golang/stackencrypt/go.mod @@ -8,4 +8,4 @@ require ( github.com/tetratelabs/wazero v1.12.0 ) -require golang.org/x/sys v0.44.0 // indirect +require golang.org/x/sys v0.44.0 diff --git a/languages/golang/stackencrypt/guest.go b/languages/golang/stackencrypt/guest.go index 9d40b7d68..27cebdc32 100644 --- a/languages/golang/stackencrypt/guest.go +++ b/languages/golang/stackencrypt/guest.go @@ -10,6 +10,7 @@ import ( "github.com/tetratelabs/wazero" "github.com/tetratelabs/wazero/api" + "github.com/tetratelabs/wazero/experimental" "github.com/tetratelabs/wazero/imports/wasi_snapshot_preview1" ) @@ -53,6 +54,9 @@ func compilationCache() wazero.CompilationCache { type instance struct { runtime wazero.Runtime module api.Module + // mem supplied the module's linear memory (see memory.go) and reports + // on it. + mem *memoryAllocator alloc, dealloc api.Function cipherInit, shutdown, keyset api.Function @@ -80,8 +84,10 @@ func guestModuleConfig() wazero.ModuleConfig { WithSysWalltime() } -// newInstance instantiates wasm with the transport as its host module. -func newInstance(ctx context.Context, wasm []byte, t *transport) (*instance, error) { +// newInstance instantiates wasm with the transport as its host module and +// its linear memory from this package's allocator. With strict set, memory +// that cannot be locked fails instantiation with ErrMemoryLock. +func newInstance(ctx context.Context, wasm []byte, t *transport, strict bool) (*instance, error) { // WithCloseOnContextDone lets a caller's deadline or cancellation // interrupt an in-flight guest call — which otherwise holds the Client's // lock against every other user. An interrupted call closes the module, @@ -104,14 +110,27 @@ func newInstance(ctx context.Context, wasm []byte, t *transport) (*instance, err _ = runtime.Close(ctx) return nil, err } + // The guest's linear memory comes from this package, not wazero's + // default slice: reserved once, locked and non-dumpable where the + // platform allows, wiped on release. See memory.go. + mem := newMemoryAllocator(strict) // The guest is a reactor (cdylib): no _start. wazero runs _initialize // when present. - module, err := runtime.InstantiateWithConfig(ctx, wasm, guestModuleConfig()) + module, err := runtime.InstantiateWithConfig(experimental.WithMemoryAllocator(ctx, mem), wasm, guestModuleConfig()) if err != nil { _ = runtime.Close(ctx) + if strict && mem.growthRefusals() != 0 { + return nil, fmt.Errorf("%w: %w", memoryLockError(mem.lockError()), err) + } return nil, fmt.Errorf("stackencrypt: instantiating guest: %w", err) } - inst := &instance{runtime: runtime, module: module} + if strict { + if lerr := mem.lockError(); lerr != nil { + _ = runtime.Close(ctx) + return nil, memoryLockError(lerr) + } + } + inst := &instance{runtime: runtime, module: module, mem: mem} exports := map[string]*api.Function{ "se_alloc": &inst.alloc, "se_dealloc": &inst.dealloc, @@ -135,7 +154,18 @@ func newInstance(ctx context.Context, wasm []byte, t *transport) (*instance, err return inst, nil } -func (inst *instance) close(ctx context.Context) error { +// release runs the guest's shutdown — the client key and every loaded +// index key wiped inside the instance — and closes the runtime, which +// frees the linear memory through the allocator's wipe. It is what Close +// does, and what the cleanup on an unreachable Client does. A module an +// interrupted call or a trap already closed cannot run se_shutdown; the +// runtime close still wipes and frees its memory, so nothing is left +// behind either way. +func (inst *instance) release() error { + ctx := context.Background() + if !inst.module.IsClosed() { + _, _ = inst.shutdown.Call(ctx) + } return inst.runtime.Close(ctx) } diff --git a/languages/golang/stackencrypt/guest_test.go b/languages/golang/stackencrypt/guest_test.go index b47ec1591..1b549ff6b 100644 --- a/languages/golang/stackencrypt/guest_test.go +++ b/languages/golang/stackencrypt/guest_test.go @@ -257,7 +257,7 @@ func TestInterruptedCallClosesTheClient(t *testing.T) { if _, err := c.Keyset(KeysetName("k")).KeysetID(ctx); !errors.Is(err, ErrState) { t.Fatalf("Keyset on a closed module: %v, want ErrState", err) } - if err := c.Close(ctx); err != nil { + if err := c.Close(); err != nil { t.Fatalf("Close after interruption: %v", err) } // The close must reach the runtime. An interrupted call closes the @@ -271,7 +271,7 @@ func TestInterruptedCallClosesTheClient(t *testing.T) { t.Errorf("host module %s is still registered after Close", transportModule) } // Still idempotent. - if err := c.Close(ctx); err != nil { + if err := c.Close(); err != nil { t.Fatalf("second Close: %v", err) } }) @@ -476,12 +476,12 @@ func TestConfigValidation(t *testing.T) { func rawInstance(t *testing.T) *Client { t.Helper() ctx := context.Background() - inst, err := newInstance(ctx, guestOrSkip(t), &transport{rt: http.DefaultTransport, token: StaticToken("t")}) + inst, err := newInstance(ctx, guestOrSkip(t), &transport{rt: http.DefaultTransport, token: StaticToken("t")}, false) if err != nil { t.Fatal(err) } - c := &Client{inst: inst, transport: nil} - t.Cleanup(func() { _ = c.Close(context.Background()) }) + c := newClient(inst, nil) + t.Cleanup(func() { _ = c.Close() }) return c } @@ -596,10 +596,10 @@ func TestGuestRefusesMalformedInputsBeforeState(t *testing.T) { func TestClosedClientIsState(t *testing.T) { ctx := context.Background() c := rawInstance(t) - if err := c.Close(ctx); err != nil { + if err := c.Close(); err != nil { t.Fatal(err) } - if err := c.Close(ctx); err != nil { + if err := c.Close(); err != nil { t.Fatalf("second Close: %v", err) } if _, err := c.DefaultKeyset().Encrypt(ctx, "x", nil); !errors.Is(err, ErrState) { @@ -669,11 +669,11 @@ func TestClientKeyDoesNotRemainInGuestMemory(t *testing.T) { t.Fatal(err) } tr := &transport{rt: http.DefaultTransport, token: StaticToken("stub-token")} - inst, err := newInstance(ctx, guestOrSkip(t), tr) + inst, err := newInstance(ctx, guestOrSkip(t), tr, false) if err != nil { t.Fatal(err) } - defer inst.close(ctx) + defer inst.release() _, err = inst.call(ctx, inst.cipherInit, buf(encoded)) if !errors.Is(err, ErrUnauthorized) { t.Fatalf("init: %v", err) @@ -699,11 +699,11 @@ func TestTransportSendCounterAndResponseHeaders(t *testing.T) { stub := newStub(t, http.StatusUnauthorized, "text/plain", "nope") tr := &transport{rt: http.DefaultTransport, token: StaticToken("stub-token")} ctx := context.Background() - inst, err := newInstance(ctx, guestOrSkip(t), tr) + inst, err := newInstance(ctx, guestOrSkip(t), tr, false) if err != nil { t.Fatal(err) } - defer inst.close(ctx) + defer inst.release() encoded, _ := encodeConfig(testConfig(stub.URL)) if _, err := inst.call(ctx, inst.cipherInit, buf(encoded)); !errors.Is(err, ErrUnauthorized) { t.Fatalf("init: %v", err) diff --git a/languages/golang/stackencrypt/live_test.go b/languages/golang/stackencrypt/live_test.go index ef95dc176..b0f4cd0e0 100644 --- a/languages/golang/stackencrypt/live_test.go +++ b/languages/golang/stackencrypt/live_test.go @@ -1,7 +1,6 @@ package stackencrypt import ( - "context" "errors" "os" "reflect" @@ -28,7 +27,7 @@ func liveClient(t *testing.T) *Client { if err != nil { t.Fatalf("NewClient: %v", err) } - t.Cleanup(func() { _ = c.Close(context.Background()) }) + t.Cleanup(func() { _ = c.Close() }) return c } diff --git a/languages/golang/stackencrypt/memory.go b/languages/golang/stackencrypt/memory.go new file mode 100644 index 000000000..5c7667f91 --- /dev/null +++ b/languages/golang/stackencrypt/memory.go @@ -0,0 +1,216 @@ +package stackencrypt + +import ( + "errors" + "fmt" + "math" + "sync" + + "github.com/tetratelabs/wazero/experimental" +) + +// The guest's linear memory is where every key lives: the client key from +// NewClient on, each keyset's index key once loaded, and each data key for +// the duration of a call. The host owns that memory, so the host decides +// what can happen to it. This allocator supplies the guest's memory from a +// reservation of its own rather than from wazero's default Go slice, so +// that: +// +// - the buffer never moves. wazero's default grows non-shared memory +// with append, which copies the whole linear memory — keys included — +// into a new slice and leaves the old one to the garbage collector, +// unwiped. Here the declared maximum is reserved up front and growth +// commits more of the same reservation; +// - the committed pages are locked where the platform allows, so they +// are never written to swap; +// - they are excluded from core dumps where the platform allows (Linux); +// - the committed range is wiped before it is released, on every +// release path, so a freed instance leaves nothing behind. +// +// None of this depends on Close running. A process that dies to SIGKILL, +// the OOM killer, a panic on another goroutine or os.Exit leaves its keys +// in memory the kernel will zero before anyone else sees it, and — with +// the lock and the dump exclusion in place — nowhere else. Close still runs +// the guest's own wipe for the orderly path; it is hygiene, not the +// security story. +// +// The lock is best effort by default. RLIMIT_MEMLOCK defaults to 64 KiB on +// many Linux hosts and the guest's memory is larger, so the lock is +// commonly refused, with nothing else lost: the pages can be swapped, and +// on a host with no swap not even that. The refusal is recorded and +// reported through Client.MemoryLocked and Client.MemoryLockError so an +// operator can see it and raise the limit; Config.RequireLockedMemory +// turns it into a NewClient failure. + +// guestMemory is one instance's linear memory, as this package supplies it +// to wazero: the LinearMemory contract plus what the Client reports about +// it. Reallocate and Free are called by wazero under the Client's lock; +// lockError is read from any goroutine. +type guestMemory interface { + experimental.LinearMemory + // lockError is nil while every committed byte is locked (and, on Linux, + // excluded from dumps); otherwise it names what was refused and why. + // It never clears: a lock refused once is reported for the life of + // the instance, even if a later growth locks. + lockError() error +} + +// memoryAllocator is the experimental.MemoryAllocator handed to wazero for +// one guest instance. wazero calls Allocate once per memory, and the guest +// has exactly one, so this is where the Client finds the memory it was +// given. +type memoryAllocator struct { + // strict refuses growth it cannot lock (see Reallocate in each + // backend) instead of recording the refusal and carrying on. + strict bool + + mu sync.Mutex + mem guestMemory + // refusals counts strict growths refused. Client.call compares it + // across a call to name the real cause when the guest reports only a + // failed allocation. + refusals uint64 + // freed is set once Free has run: the mapping is gone and its + // contents were wiped first. Tests read it to observe release paths + // the caller never sees, such as the cleanup on an unreachable Client. + freed bool +} + +func newMemoryAllocator(strict bool) *memoryAllocator { + return &memoryAllocator{strict: strict} +} + +// Allocate implements experimental.MemoryAllocator. +func (a *memoryAllocator) Allocate(capacity, max uint64) experimental.LinearMemory { + a.mu.Lock() + defer a.mu.Unlock() + if a.mem != nil { + // The guest has one memory; a second would mean wazero's contract + // changed under us. Refusing here fails instantiation loudly + // rather than letting two memories share one report. + panic("stackencrypt: guest memory allocated twice") + } + a.mem = &observed{guestMemory: reserveMemory(capacity, max, a.strict), owner: a} + return a.mem +} + +// lockError reports the memory's lock state, or nil before the memory +// exists. +func (a *memoryAllocator) lockError() error { + a.mu.Lock() + defer a.mu.Unlock() + if a.mem == nil { + return nil + } + return a.mem.lockError() +} + +// growthRefusals counts the strict growths refused so far. +func (a *memoryAllocator) growthRefusals() uint64 { + a.mu.Lock() + defer a.mu.Unlock() + return a.refusals +} + +func (a *memoryAllocator) isFreed() bool { + a.mu.Lock() + defer a.mu.Unlock() + return a.freed +} + +// observed wraps the backend memory so the allocator sees the events the +// Client needs to report: a refused strict growth, and the release. +type observed struct { + guestMemory + owner *memoryAllocator +} + +func (o *observed) Reallocate(size uint64) []byte { + buf := o.guestMemory.Reallocate(size) + if buf == nil && o.owner.strict { + // In strict mode a nil is a lock refusal (a backend refuses no + // other growth below the maximum); in best-effort mode it is + // the maximum, which is the guest's own failure to report. + o.owner.mu.Lock() + o.owner.refusals++ + o.owner.mu.Unlock() + } + return buf +} + +func (o *observed) Free() { + o.guestMemory.Free() + o.owner.mu.Lock() + o.owner.freed = true + o.owner.mu.Unlock() +} + +// heapMemory backs the guest with an ordinary Go slice, for platforms with +// no reservation primitive this package uses and for a reservation that +// failed (a 4 GiB address-space reservation on a 32-bit host, say). It +// keeps two of the four properties above: growth wipes the slice it +// abandons, and Free wipes before releasing. It cannot lock or exclude +// from dumps, and says so. +type heapMemory struct { + buf []byte + max uint64 + reason error +} + +// newHeapMemory returns a heap-backed memory whose lockError is reason, +// which must not be nil: a heap memory is never locked. +func newHeapMemory(capacity, max uint64, reason error) *heapMemory { + if capacity > max { + capacity = max + } + if capacity > math.MaxInt { + capacity = 0 + } + return &heapMemory{buf: make([]byte, 0, int(capacity)), max: max, reason: reason} +} + +func (m *heapMemory) Reallocate(size uint64) []byte { + if size > m.max { + return nil + } + if size <= uint64(cap(m.buf)) { + m.buf = m.buf[:size] + return m.buf + } + grown := make([]byte, size) + copy(grown, m.buf) + // The abandoned slice held everything the guest had, keys included. + clear(m.buf[:cap(m.buf)]) + m.buf = grown + return m.buf +} + +func (m *heapMemory) Free() { + clear(m.buf[:cap(m.buf)]) + m.buf = nil +} + +func (m *heapMemory) lockError() error { return m.reason } + +// errNoLockSupport is the heap fallback's reason on platforms where this +// package has no lock implementation. +var errNoLockSupport = errors.New("guest memory cannot be locked on this platform") + +// memoryLockError wraps a backend's lock refusal as ErrMemoryLock. +func memoryLockError(err error) error { + return fmt.Errorf("%w: %w", ErrMemoryLock, err) +} + +func byteCount(n uint64) string { + const kib, mib, gib = 1 << 10, 1 << 20, 1 << 30 + switch { + case n >= gib && n%gib == 0: + return fmt.Sprintf("%d GiB", n/gib) + case n >= mib: + return fmt.Sprintf("%.1f MiB", float64(n)/mib) + case n >= kib: + return fmt.Sprintf("%d KiB", n/kib) + default: + return fmt.Sprintf("%d bytes", n) + } +} diff --git a/languages/golang/stackencrypt/memory_linux.go b/languages/golang/stackencrypt/memory_linux.go new file mode 100644 index 000000000..9adbcd1df --- /dev/null +++ b/languages/golang/stackencrypt/memory_linux.go @@ -0,0 +1,22 @@ +package stackencrypt + +import ( + "fmt" + + "golang.org/x/sys/unix" +) + +// The reservation is address space, not memory: nothing is charged against +// the overcommit limit until a range is committed. +const reserveFlags = unix.MAP_NORESERVE + +// excludeFromDumps marks the whole reservation MADV_DONTDUMP. The flag +// lives on the mapping and survives the mprotect calls that later split it +// into committed and reserved parts, so once is enough; the smaps test +// pins that on a committed range. +func excludeFromDumps(mapping []byte) error { + if err := unix.Madvise(mapping, unix.MADV_DONTDUMP); err != nil { + return fmt.Errorf("excluding guest memory from core dumps: %w", err) + } + return nil +} diff --git a/languages/golang/stackencrypt/memory_mapped.go b/languages/golang/stackencrypt/memory_mapped.go new file mode 100644 index 000000000..69a992b68 --- /dev/null +++ b/languages/golang/stackencrypt/memory_mapped.go @@ -0,0 +1,19 @@ +//go:build unix || windows + +package stackencrypt + +import "unsafe" + +// wipeMapped zeroes a committed range before its mapping is released. The +// stores go to memory that a syscall unmaps straight after, which the +// compiler cannot see through, so they are not dead stores it could drop; +// the volatile-store dance a C wipe needs has no Go equivalent and no need +// here. The slice is kept alive past the stores so nothing reorders the +// wipe after the release. +func wipeMapped(b []byte) { + if len(b) == 0 { + return + } + clear(b) + _ = unsafe.SliceData(b) +} diff --git a/languages/golang/stackencrypt/memory_other.go b/languages/golang/stackencrypt/memory_other.go new file mode 100644 index 000000000..148ac7139 --- /dev/null +++ b/languages/golang/stackencrypt/memory_other.go @@ -0,0 +1,10 @@ +//go:build !unix && !windows + +package stackencrypt + +// Platforms with neither mmap nor VirtualAlloc in this package's +// vocabulary get the heap fallback: growth and release still wipe, nothing +// is locked, and the Client says so. +func reserveMemory(capacity, max uint64, _ bool) guestMemory { + return newHeapMemory(capacity, max, errNoLockSupport) +} diff --git a/languages/golang/stackencrypt/memory_other_test.go b/languages/golang/stackencrypt/memory_other_test.go new file mode 100644 index 000000000..0bcf85c95 --- /dev/null +++ b/languages/golang/stackencrypt/memory_other_test.go @@ -0,0 +1,7 @@ +//go:build !unix + +package stackencrypt + +import "errors" + +func dropMemlockLimit() error { return errors.New("no RLIMIT_MEMLOCK on this platform") } diff --git a/languages/golang/stackencrypt/memory_test.go b/languages/golang/stackencrypt/memory_test.go new file mode 100644 index 000000000..b3d23767e --- /dev/null +++ b/languages/golang/stackencrypt/memory_test.go @@ -0,0 +1,377 @@ +package stackencrypt + +import ( + "bufio" + "context" + "errors" + "fmt" + "net/http" + "os" + "os/exec" + "runtime" + "strconv" + "strings" + "testing" + "time" + "unsafe" + + "github.com/tetratelabs/wazero" + "github.com/tetratelabs/wazero/experimental" +) + +// growProbe is a hand-assembled module with one page of memory and one +// export that grows it, so the allocator can be exercised without the +// guest: +// +// (module +// (memory (export "memory") 1) +// (func (export "grow") (param i32) (result i32) +// local.get 0 memory.grow)) +// +// Like the guest, it declares no maximum, so wazero asks the allocator for +// wasm's 4 GiB default. +var growProbe = []byte{ + 0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00, // magic, version + 0x01, 0x06, 0x01, 0x60, 0x01, 0x7f, 0x01, 0x7f, // type: (i32) -> i32 + 0x03, 0x02, 0x01, 0x00, // function: one, of type 0 + 0x05, 0x03, 0x01, 0x00, 0x01, // memory: one, min 1 page, no max + 0x07, 0x11, 0x02, // exports: two + 0x06, 'm', 'e', 'm', 'o', 'r', 'y', 0x02, 0x00, // "memory" = memory 0 + 0x04, 'g', 'r', 'o', 'w', 0x00, 0x00, // "grow" = func 0 + 0x0a, 0x08, 0x01, 0x06, 0x00, 0x20, 0x00, 0x40, 0x00, 0x0b, // code +} + +const wasmPage = 64 * 1024 + +// probeMemory instantiates growProbe under alloc and returns the base +// address of its memory and a grow function reporting the old page count. +func probeMemory(t *testing.T, alloc *memoryAllocator) (base func() uintptr, grow func(pages uint32) (old uint32, ok bool), done func()) { + t.Helper() + ctx := context.Background() + rt := wazero.NewRuntime(ctx) + mod, err := rt.InstantiateWithConfig(experimental.WithMemoryAllocator(ctx, alloc), growProbe, wazero.NewModuleConfig()) + if err != nil { + _ = rt.Close(ctx) + t.Fatalf("instantiating grow probe: %v", err) + } + base = func() uintptr { + // Read returns a view into the buffer, not a copy. + view, ok := mod.Memory().Read(0, 1) + if !ok { + t.Fatal("reading probe memory") + } + return uintptr(unsafe.Pointer(unsafe.SliceData(view))) + } + grow = func(pages uint32) (uint32, bool) { + res, err := mod.ExportedFunction("grow").Call(ctx, uint64(pages)) + if err != nil { + t.Fatalf("grow: %v", err) + } + return uint32(res[0]), int32(res[0]) != -1 + } + done = func() { _ = rt.Close(ctx) } + return base, grow, done +} + +// The whole point of owning the allocation: growth commits more of one +// reservation, so the buffer's address is the same before and after, and +// the guest's keys are never copied to a new slice. +func TestGuestMemoryDoesNotMoveOnGrowth(t *testing.T) { + alloc := newMemoryAllocator(false) + base, grow, done := probeMemory(t, alloc) + defer done() + if isHeapFallback(alloc) { + t.Skipf("heap fallback in use on this host: %v", alloc.lockError()) + } + t.Logf("lock state on this host: %v", alloc.lockError()) + before := base() + for _, pages := range []uint32{1, 15, 64} { + if _, ok := grow(pages); !ok { + t.Fatalf("grow(%d) refused", pages) + } + if after := base(); after != before { + t.Fatalf("memory moved on grow(%d): %#x -> %#x", pages, before, after) + } + } +} + +// Free wipes then unmaps: the allocator reports the release, and the +// runtime close is what triggers it. +func TestGuestMemoryIsFreedOnRuntimeClose(t *testing.T) { + alloc := newMemoryAllocator(false) + _, grow, done := probeMemory(t, alloc) + if _, ok := grow(3); !ok { + t.Fatal("grow refused") + } + if alloc.isFreed() { + t.Fatal("freed before close") + } + done() + if !alloc.isFreed() { + t.Fatal("runtime close did not free the guest memory") + } +} + +// The heap fallback keeps the two properties it can: growth wipes the +// slice it abandons, and Free wipes. +func TestHeapMemoryWipesWhatItAbandons(t *testing.T) { + m := newHeapMemory(wasmPage, 4*wasmPage, errNoLockSupport) + first := m.Reallocate(wasmPage) + first[0], first[wasmPage-1] = 0xAA, 0xBB + second := m.Reallocate(3 * wasmPage) + if second[0] != 0xAA || second[wasmPage-1] != 0xBB { + t.Fatal("growth lost the contents") + } + if first[0] != 0 || first[wasmPage-1] != 0 { + t.Fatal("growth left the abandoned slice unwiped") + } + if m.Reallocate(5*wasmPage) != nil { + t.Fatal("grew past max") + } + second[7] = 0xCC + m.Free() + if second[7] != 0 { + t.Fatal("Free left the slice unwiped") + } + if m.lockError() == nil { + t.Fatal("a heap memory claims to be locked") + } +} + +// requireLock is set in CI, where RLIMIT_MEMLOCK has been raised, so the +// lock cannot quietly go untested. Without it a refused lock is reported +// and the assertion skipped: a developer laptop's default limit is not a +// bug in this package. +const requireLock = "STACKENCRYPT_TESTS_REQUIRE_LOCK" + +func lockOrSkip(t *testing.T, alloc *memoryAllocator) { + t.Helper() + err := alloc.lockError() + if err == nil { + return + } + if isHeapFallback(alloc) { + // Not a refused lock: there was no reservation to lock (a 32-bit + // host), which CI exercises on purpose under GOARCH=386. + t.Skipf("heap fallback in use on this host: %v", err) + } + if os.Getenv(requireLock) != "" { + t.Fatalf("%s is set and the lock was refused: %v", requireLock, err) + } + t.Skipf("lock refused on this host: %v", err) +} + +func isHeapFallback(alloc *memoryAllocator) bool { + _, heap := alloc.mem.(*observed).guestMemory.(*heapMemory) + return heap +} + +// The lock is observable from the kernel's side: the mapping backing the +// probe shows as locked and, on Linux, non-dumpable, in /proc/self/smaps. +func TestGuestMemoryIsLockedAndNotDumpable(t *testing.T) { + if runtime.GOOS != "linux" { + t.Skip("smaps is Linux") + } + alloc := newMemoryAllocator(false) + base, grow, done := probeMemory(t, alloc) + defer done() + // Grow past the initial commit so the flags are checked on a range + // committed by Reallocate, after the mprotect split, not only on what + // Allocate set up. + if _, ok := grow(2); !ok { + t.Fatal("grow refused") + } + lockOrSkip(t, alloc) + mapping, err := smapsEntry(base()) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(" "+mapping.vmFlags+" ", " dd ") { + t.Errorf("VmFlags = %q, want dd (MADV_DONTDUMP)", mapping.vmFlags) + } + if !strings.Contains(" "+mapping.vmFlags+" ", " lo ") { + t.Errorf("VmFlags = %q, want lo (mlock)", mapping.vmFlags) + } + if mapping.lockedKB == 0 || mapping.lockedKB != mapping.rssKB { + t.Errorf("Locked = %d kB, Rss = %d kB: the committed range is not fully locked", mapping.lockedKB, mapping.rssKB) + } +} + +type smapsMapping struct { + rssKB, lockedKB uint64 + vmFlags string +} + +// smapsEntry finds the /proc/self/smaps mapping containing addr. +func smapsEntry(addr uintptr) (smapsMapping, error) { + f, err := os.Open("/proc/self/smaps") + if err != nil { + return smapsMapping{}, err + } + defer f.Close() + var cur smapsMapping + inside := false + sc := bufio.NewScanner(f) + for sc.Scan() { + line := sc.Text() + if lo, hi, ok := smapsRange(line); ok { + if inside { + return cur, nil + } + inside = addr >= lo && addr < hi + cur = smapsMapping{} + continue + } + if !inside { + continue + } + key, value, _ := strings.Cut(line, ":") + value = strings.TrimSpace(value) + switch key { + case "Rss": + cur.rssKB = smapsKB(value) + case "Locked": + cur.lockedKB = smapsKB(value) + case "VmFlags": + cur.vmFlags = value + } + } + if inside { + return cur, nil + } + return smapsMapping{}, fmt.Errorf("no smaps mapping contains %#x", addr) +} + +func smapsRange(line string) (lo, hi uintptr, ok bool) { + head, _, _ := strings.Cut(line, " ") + a, b, found := strings.Cut(head, "-") + if !found { + return 0, 0, false + } + l, err1 := strconv.ParseUint(a, 16, 64) + h, err2 := strconv.ParseUint(b, 16, 64) + if err1 != nil || err2 != nil { + return 0, 0, false + } + return uintptr(l), uintptr(h), true +} + +func smapsKB(value string) uint64 { + n, _ := strconv.ParseUint(strings.TrimSuffix(value, " kB"), 10, 64) + return n +} + +// Strict mode is a NewClient failure, not a report. The refusal is +// provoked by lowering RLIMIT_MEMLOCK to zero, which is process-wide and +// irreversible for a non-root process, so it runs in a child. +func TestRequireLockedMemoryRefusesAnUnlockableGuest(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("no RLIMIT_MEMLOCK on Windows") + } + const child = "STACKENCRYPT_TEST_CHILD" + if os.Getenv(child) == "" { + cmd := exec.Command(os.Args[0], "-test.run=^"+t.Name()+"$", "-test.v") + cmd.Env = append(os.Environ(), child+"=1") + out, err := cmd.CombinedOutput() + switch { + case strings.Contains(string(out), "case skipped:"): + if os.Getenv(requireLock) != "" { + t.Fatalf("%s is set and the refusal could not be provoked:\n%s", requireLock, out) + } + t.Skipf("refusal could not be provoked on this host:\n%s", out) + case err != nil || !strings.Contains(string(out), "case ok"): + t.Fatalf("child failed: %v\n%s", err, out) + } + return + } + if err := dropMemlockLimit(); err != nil { + t.Fatalf("lowering RLIMIT_MEMLOCK: %v", err) + } + // Can the lock be refused at all here? Root and CAP_IPC_LOCK ignore + // the limit. On a 32-bit host there is no reservation to lock, and the + // strict refusal is the reservation's, not the limit's. + probe := newMemoryAllocator(false) + _, _, done := probeMemory(t, probe) + done() + if probe.lockError() == nil { + fmt.Println("case skipped: mlock succeeds under RLIMIT_MEMLOCK=0") + return + } + limited := !isHeapFallback(probe) + cfg := Config{ + ClientID: "6a70bd18-99ac-4650-b104-37eec3a15b09", + ClientKey: "00", + Token: StaticToken("t"), + Guest: wasiProbe, + RequireLockedMemory: true, + } + _, err := NewClient(context.Background(), cfg) + if !errors.Is(err, ErrMemoryLock) { + t.Fatalf("strict NewClient under a refused lock: %v, want ErrMemoryLock", err) + } + if limited && !strings.Contains(err.Error(), "RLIMIT_MEMLOCK") { + t.Fatalf("the error does not name the limit: %v", err) + } + // Best effort under the same refusal: the client exists and says so. + cfg.RequireLockedMemory = false + cfg.Guest = nil + if wasm, gerr := embeddedGuest(); gerr == nil { + cfg.Guest = wasm + inst, err := newInstance(context.Background(), wasm, &transport{rt: http.DefaultTransport, token: cfg.Token}, false) + if err != nil { + t.Fatal(err) + } + c := newClient(inst, nil) + defer c.Close() + if c.MemoryLocked() { + t.Fatal("best-effort client reports locked memory under a refused lock") + } + if err := c.MemoryLockError(); !errors.Is(err, ErrMemoryLock) { + t.Fatalf("MemoryLockError = %v, want ErrMemoryLock", err) + } + } + fmt.Println("case ok") +} + +// A Client that becomes unreachable without Close is released by its +// cleanup: the guest's shutdown runs and the memory is wiped and freed. It +// covers the forgot-to-close case in a running process, and nothing at +// exit. +func TestUnreachableClientIsReleased(t *testing.T) { + wasm := guestOrSkip(t) + inst, err := newInstance(context.Background(), wasm, &transport{rt: http.DefaultTransport, token: StaticToken("t")}, false) + if err != nil { + t.Fatal(err) + } + alloc := inst.mem + func() { + c := newClient(inst, nil) + if c.inst.mem.isFreed() { + t.Fatal("freed on construction") + } + }() + inst = nil + deadline := time.Now().Add(10 * time.Second) + for !alloc.isFreed() { + if time.Now().After(deadline) { + t.Fatal("an unreachable client's memory was not released") + } + runtime.GC() + time.Sleep(10 * time.Millisecond) + } +} + +// Close stops the cleanup, so a closed client is released exactly once. +func TestCloseStopsTheCleanup(t *testing.T) { + c := rawInstance(t) + if err := c.Close(); err != nil { + t.Fatal(err) + } + if !c.inst.mem.isFreed() { + t.Fatal("Close did not free the guest memory") + } + // Stop on a cleanup Close already stopped is a no-op, so a second Stop + // here proves nothing on its own; what is pinned is that the release + // ran once, through Close, and the memory is gone. + c.cleanup.Stop() +} diff --git a/languages/golang/stackencrypt/memory_unix.go b/languages/golang/stackencrypt/memory_unix.go new file mode 100644 index 000000000..7779ff8b3 --- /dev/null +++ b/languages/golang/stackencrypt/memory_unix.go @@ -0,0 +1,110 @@ +//go:build unix + +package stackencrypt + +import ( + "fmt" + "math" + + "golang.org/x/sys/unix" +) + +// mappedMemory is the Unix backend: one anonymous private mapping of the +// declared maximum, reserved PROT_NONE so it costs address space only, and +// committed (made readable and writable) from the front as the guest grows. +// The base never changes, so wazero's buffer never moves. Each newly +// committed range is locked; the whole reservation is excluded from dumps +// once, at reservation, on the platforms that can (memory_linux.go). +type mappedMemory struct { + mapping []byte // the whole reservation + committed uint64 // bytes made accessible so far, from the front + strict bool + err error // first lock or dump-exclusion refusal; never cleared +} + +// reserveMemory returns a mapped memory for the reservation, or a heap +// memory when the reservation itself is impossible: max exceeds what this +// process can address (a 32-bit host asked for wasm's 4 GiB default), or +// mmap refused it. +func reserveMemory(capacity, max uint64, strict bool) guestMemory { + if max > math.MaxInt { + return newHeapMemory(capacity, max, fmt.Errorf("cannot reserve %s of address space on this host", byteCount(max))) + } + mapping, err := unix.Mmap(-1, 0, int(max), unix.PROT_NONE, unix.MAP_PRIVATE|unix.MAP_ANON|reserveFlags) + if err != nil { + return newHeapMemory(capacity, max, fmt.Errorf("reserving %s of address space: %w", byteCount(max), err)) + } + m := &mappedMemory{mapping: mapping, strict: strict} + if err := excludeFromDumps(mapping); err != nil { + m.err = err + } + return m +} + +// Reallocate implements experimental.LinearMemory. Growth commits the +// next range of the reservation and locks it. A lock refusal is recorded +// and, in strict mode, undone: the range goes back to inaccessible and nil +// is returned, which the guest sees as a failed memory.grow. The first +// commit is the exception: wazero cannot instantiate on a nil buffer, so +// it is granted and the refusal recorded, and newInstance turns it into +// the ErrMemoryLock the strict caller asked for. Shrinking is not +// something wasm does; a smaller size just shortens the view. +func (m *mappedMemory) Reallocate(size uint64) []byte { + if size > uint64(len(m.mapping)) { + return nil + } + if size > m.committed { + fresh := m.mapping[m.committed:size] + if err := unix.Mprotect(fresh, unix.PROT_READ|unix.PROT_WRITE); err != nil { + return nil + } + if err := lock(fresh); err != nil { + if m.err == nil { + m.err = err + } + if m.strict && m.committed > 0 { + // Nothing was written to the range yet; giving it back + // leaves the guest exactly where it was. + _ = unix.Mprotect(fresh, unix.PROT_NONE) + return nil + } + } + m.committed = size + } + return m.mapping[:size:size] +} + +// Free implements experimental.LinearMemory: wipe what was committed, then +// release the reservation. munmap drops any lock with the pages. +func (m *mappedMemory) Free() { + if m.mapping == nil { + return + } + wipeMapped(m.mapping[:m.committed]) + _ = unix.Munmap(m.mapping) + m.mapping = nil + m.committed = 0 +} + +func (m *mappedMemory) lockError() error { return m.err } + +// lock pins a committed range in RAM. The refusal names the limit that +// caused it, so an operator reading the error knows what to raise. +func lock(b []byte) error { + if err := unix.Mlock(b); err != nil { + return fmt.Errorf("locking %s of guest memory: %w (%s)", byteCount(uint64(len(b))), err, memlockLimit()) + } + return nil +} + +// memlockLimit describes RLIMIT_MEMLOCK for a lock refusal. +func memlockLimit() string { + var lim unix.Rlimit + if err := unix.Getrlimit(unix.RLIMIT_MEMLOCK, &lim); err != nil { + return "RLIMIT_MEMLOCK unknown" + } + if lim.Cur == unix.RLIM_INFINITY { + return "RLIMIT_MEMLOCK is unlimited" + } + return fmt.Sprintf("RLIMIT_MEMLOCK is %s", byteCount(uint64(lim.Cur))) +} diff --git a/languages/golang/stackencrypt/memory_unix_other.go b/languages/golang/stackencrypt/memory_unix_other.go new file mode 100644 index 000000000..40df8f3a5 --- /dev/null +++ b/languages/golang/stackencrypt/memory_unix_other.go @@ -0,0 +1,12 @@ +//go:build unix && !linux + +package stackencrypt + +// Without MAP_NORESERVE the reservation may be charged against a strict +// overcommit setting on the BSDs; macOS has no such accounting. +const reserveFlags = 0 + +// excludeFromDumps has no equivalent outside Linux: macOS and the BSDs +// dump every mapping or none. macOS writes no core dumps by default; on a +// host that enables them the operator has chosen to capture memory. +func excludeFromDumps([]byte) error { return nil } diff --git a/languages/golang/stackencrypt/memory_unix_test.go b/languages/golang/stackencrypt/memory_unix_test.go new file mode 100644 index 000000000..038e8c337 --- /dev/null +++ b/languages/golang/stackencrypt/memory_unix_test.go @@ -0,0 +1,11 @@ +//go:build unix + +package stackencrypt + +import "golang.org/x/sys/unix" + +// dropMemlockLimit lowers RLIMIT_MEMLOCK to zero for this process. Only a +// child test process calls it. +func dropMemlockLimit() error { + return unix.Setrlimit(unix.RLIMIT_MEMLOCK, &unix.Rlimit{Cur: 0, Max: 0}) +} diff --git a/languages/golang/stackencrypt/memory_windows.go b/languages/golang/stackencrypt/memory_windows.go new file mode 100644 index 000000000..ae3d2afa1 --- /dev/null +++ b/languages/golang/stackencrypt/memory_windows.go @@ -0,0 +1,79 @@ +package stackencrypt + +import ( + "fmt" + "math" + "unsafe" + + "golang.org/x/sys/windows" +) + +// mappedMemory is the Windows backend, the same shape as the Unix one: +// the declared maximum is reserved once (MEM_RESERVE), committed from the +// front as the guest grows, and each committed range is locked with +// VirtualLock. Windows has no per-mapping dump exclusion. +type mappedMemory struct { + base uintptr + mapping []byte + committed uint64 + strict bool + err error +} + +func reserveMemory(capacity, max uint64, strict bool) guestMemory { + if max > math.MaxInt { + return newHeapMemory(capacity, max, fmt.Errorf("cannot reserve %s of address space on this host", byteCount(max))) + } + base, err := windows.VirtualAlloc(0, uintptr(max), windows.MEM_RESERVE, windows.PAGE_NOACCESS) + if err != nil { + return newHeapMemory(capacity, max, fmt.Errorf("reserving %s of address space: %w", byteCount(max), err)) + } + // The reservation is not Go memory; going through unsafe.Add keeps + // the conversion within what vet's unsafeptr check accepts. + start := (*byte)(unsafe.Add(unsafe.Pointer(nil), base)) + return &mappedMemory{ + base: base, + mapping: unsafe.Slice(start, int(max)), + strict: strict, + } +} + +func (m *mappedMemory) Reallocate(size uint64) []byte { + if size > uint64(len(m.mapping)) { + return nil + } + if size > m.committed { + fresh := m.mapping[m.committed:size] + start := m.base + uintptr(m.committed) + if _, err := windows.VirtualAlloc(start, uintptr(len(fresh)), windows.MEM_COMMIT, windows.PAGE_READWRITE); err != nil { + return nil + } + if err := windows.VirtualLock(start, uintptr(len(fresh))); err != nil { + if m.err == nil { + // VirtualLock is bounded by the process's minimum working + // set, which defaults to a few hundred KiB: raise it with + // SetProcessWorkingSetSize before NewClient. + m.err = fmt.Errorf("locking %s of guest memory: %w (bounded by the process minimum working set)", byteCount(uint64(len(fresh))), err) + } + if m.strict && m.committed > 0 { // see the Unix backend + _ = windows.VirtualFree(start, uintptr(len(fresh)), windows.MEM_DECOMMIT) + return nil + } + } + m.committed = size + } + return m.mapping[:size:size] +} + +func (m *mappedMemory) Free() { + if m.mapping == nil { + return + } + wipeMapped(m.mapping[:m.committed]) + // MEM_RELEASE frees the whole reservation and drops any lock with it. + _ = windows.VirtualFree(m.base, 0, windows.MEM_RELEASE) + m.mapping = nil + m.committed = 0 +} + +func (m *mappedMemory) lockError() error { return m.err } From 7d7ad6c1d33b035ea8f91a6ee693fd273ddcc9ed Mon Sep 17 00:00:00 2001 From: Dan Draper <dan@cipherstash.com> Date: Sat, 19 Sep 2026 21:57:02 -0700 Subject: [PATCH 589/686] fix(stack-encrypt): guest memory outlives a call that wazero closes mid-import CI crashed in compiled wasm code with the first commit. A call whose context ends during the transport import closes the module on wazero's watcher goroutine with its resources deferred; the import then re-enters the guest through se_alloc to place its result, and that nested call is where wazero closes the resources and frees the memory. With the default allocator the Go slice outlived the module and the running guest never noticed. With ours the mapping was unmapped under a guest suspended in the import, and its next store faulted. A Free that arrives while a call is in flight is now recorded and performed by the outermost exit, once no guest code can be running; the window covers the call's deferred buffer frees and the shutdown call in release. A Free with no call in flight is immediate, as before. A hand-assembled module reproduces the sequence deterministically: run calls a host import that cancels the context, re-enters the guest, and returns to a store. Without the deferral it dies with the CI signature (SIGSEGV in compiled code, then the runtime's traceback failing with "unsafe.Slice: len out of range"); with it the call returns the cancellation exit and the memory is freed straight after. --- languages/golang/stackencrypt/guest.go | 8 +++ languages/golang/stackencrypt/memory.go | 64 ++++++++++++++--- languages/golang/stackencrypt/memory_test.go | 73 ++++++++++++++++++++ 3 files changed, 137 insertions(+), 8 deletions(-) diff --git a/languages/golang/stackencrypt/guest.go b/languages/golang/stackencrypt/guest.go index 27cebdc32..69a34004a 100644 --- a/languages/golang/stackencrypt/guest.go +++ b/languages/golang/stackencrypt/guest.go @@ -164,7 +164,9 @@ func newInstance(ctx context.Context, wasm []byte, t *transport, strict bool) (* func (inst *instance) release() error { ctx := context.Background() if !inst.module.IsClosed() { + inst.mem.enter() _, _ = inst.shutdown.Call(ctx) + inst.mem.exit() } return inst.runtime.Close(ctx) } @@ -227,6 +229,12 @@ func scalar(v uint64) arg { return arg{scalar: v} } // order, and copies the output out before every buffer — inputs and output // — is wiped and freed. func (inst *instance) call(ctx context.Context, fn api.Function, args ...arg) ([]byte, error) { + // The memory stays mapped for the whole call, the deferred frees + // included: a close that lands mid-call (an expired context during a + // host import) is honoured by this exit, not under running guest + // code. See observed.Free. + inst.mem.enter() + defer inst.mem.exit() var bufs []guestBuf defer func() { for _, b := range bufs { diff --git a/languages/golang/stackencrypt/memory.go b/languages/golang/stackencrypt/memory.go index 5c7667f91..202a1456e 100644 --- a/languages/golang/stackencrypt/memory.go +++ b/languages/golang/stackencrypt/memory.go @@ -44,8 +44,9 @@ import ( // guestMemory is one instance's linear memory, as this package supplies it // to wazero: the LinearMemory contract plus what the Client reports about -// it. Reallocate and Free are called by wazero under the Client's lock; -// lockError is read from any goroutine. +// it. Reallocate is called by wazero under the Client's lock, and Free +// from wherever wazero closes the module (see observed.Free); lockError is +// read from any goroutine. type guestMemory interface { experimental.LinearMemory // lockError is nil while every committed byte is locked (and, on Linux, @@ -70,9 +71,14 @@ type memoryAllocator struct { // across a call to name the real cause when the guest reports only a // failed allocation. refusals uint64 - // freed is set once Free has run: the mapping is gone and its - // contents were wiped first. Tests read it to observe release paths - // the caller never sees, such as the cleanup on an unreachable Client. + // inFlight counts guest calls in progress on this memory (see enter and + // exit); pending records a Free that arrived while one was, to be + // honoured when the outermost call returns. + inFlight int + pending bool + // freed is set once the memory is gone, its contents wiped first. + // Tests read it to observe release paths the caller never sees, such + // as the cleanup on an unreachable Client. freed bool } @@ -118,6 +124,34 @@ func (a *memoryAllocator) isFreed() bool { return a.freed } +// enter marks a guest call in progress: the memory must stay mapped until +// the matching exit, whatever wazero asks in between. +func (a *memoryAllocator) enter() { + a.mu.Lock() + a.inFlight++ + a.mu.Unlock() +} + +// exit ends a guest call and performs a Free that arrived during it. +func (a *memoryAllocator) exit() { + a.mu.Lock() + defer a.mu.Unlock() + a.inFlight-- + if a.inFlight == 0 && a.pending { + a.pending = false + a.freeLocked() + } +} + +// freeLocked wipes and releases the memory. Called with mu held, once. +func (a *memoryAllocator) freeLocked() { + if a.freed { + return + } + a.freed = true + a.mem.(*observed).guestMemory.Free() +} + // observed wraps the backend memory so the allocator sees the events the // Client needs to report: a refused strict growth, and the release. type observed struct { @@ -138,11 +172,25 @@ func (o *observed) Reallocate(size uint64) []byte { return buf } +// Free implements experimental.LinearMemory. wazero calls it when the +// module's resources are closed, and that can happen while the guest is +// still running: a call whose context ends during a host import closes +// the module on wazero's watcher goroutine with its resources deferred, +// and the next call into the module — the host import re-entering the +// guest through se_alloc to place its result — closes them. With wazero's +// default allocator that was harmless, the Go slice outlived the module; +// here it would unmap the memory under a guest suspended in the import, +// whose next store then faults in compiled code. So a Free that arrives +// during a call is recorded and performed by the outermost exit, when no +// guest code can be running. A Free with no call in flight is immediate. func (o *observed) Free() { - o.guestMemory.Free() o.owner.mu.Lock() - o.owner.freed = true - o.owner.mu.Unlock() + defer o.owner.mu.Unlock() + if o.owner.inFlight > 0 { + o.owner.pending = true + return + } + o.owner.freeLocked() } // heapMemory backs the guest with an ordinary Go slice, for platforms with diff --git a/languages/golang/stackencrypt/memory_test.go b/languages/golang/stackencrypt/memory_test.go index b3d23767e..8c2afb56f 100644 --- a/languages/golang/stackencrypt/memory_test.go +++ b/languages/golang/stackencrypt/memory_test.go @@ -16,7 +16,9 @@ import ( "unsafe" "github.com/tetratelabs/wazero" + "github.com/tetratelabs/wazero/api" "github.com/tetratelabs/wazero/experimental" + "github.com/tetratelabs/wazero/sys" ) // growProbe is a hand-assembled module with one page of memory and one @@ -333,6 +335,77 @@ func TestRequireLockedMemoryRefusesAnUnlockableGuest(t *testing.T) { fmt.Println("case ok") } +// reentrantProbe is a hand-assembled module reproducing the shape of the +// guest's transport import: "run" calls the host function h, then stores +// to memory. h re-enters the guest (as transport_send does through +// se_alloc) with a context that has ended, which is how wazero comes to +// free the module's memory while the guest is suspended in the import: +// +// (module +// (import "env" "h" (func $h)) +// (memory (export "memory") 1) +// (func (export "run") call $h i32.const 0 i32.const 1 i32.store) +// (func (export "nop"))) +var reentrantProbe = []byte{ + 0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00, + 0x01, 0x04, 0x01, 0x60, 0x00, 0x00, // type: () -> () + 0x02, 0x09, 0x01, 0x03, 'e', 'n', 'v', 0x01, 'h', 0x00, 0x00, // import env.h + 0x03, 0x03, 0x02, 0x00, 0x00, // two functions of type 0 + 0x05, 0x03, 0x01, 0x00, 0x01, // memory: min 1, no max + 0x07, 0x16, 0x03, + 0x06, 'm', 'e', 'm', 'o', 'r', 'y', 0x02, 0x00, + 0x03, 'r', 'u', 'n', 0x00, 0x01, + 0x03, 'n', 'o', 'p', 0x00, 0x02, + 0x0a, 0x10, 0x02, + 0x0b, 0x00, 0x10, 0x00, 0x41, 0x00, 0x41, 0x01, 0x36, 0x02, 0x00, 0x0b, // run + 0x02, 0x00, 0x0b, // nop +} + +// The sequence that crashed in CI: a call's context ends during a host +// import, the import re-enters the guest, and wazero frees the memory in +// that nested call while the outer guest frame is still live and about to +// store. The memory must survive until the outer call has returned; an +// unmapped store here is a fault in compiled code that takes the process +// down, so this test cannot fail gently. +func TestMemoryOutlivesACallClosedDuringAHostImport(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + alloc := newMemoryAllocator(false) + rt := wazero.NewRuntimeWithConfig(ctx, wazero.NewRuntimeConfig().WithCloseOnContextDone(true)) + defer rt.Close(context.Background()) + var freedDuringImport, nestedFailed bool + _, err := rt.NewHostModuleBuilder("env").NewFunctionBuilder(). + WithFunc(func(ctx context.Context, m api.Module) { + cancel() + _, nested := m.ExportedFunction("nop").Call(ctx) + nestedFailed = nested != nil + freedDuringImport = alloc.isFreed() + }).Export("h").Instantiate(ctx) + if err != nil { + t.Fatal(err) + } + mod, err := rt.InstantiateWithConfig(experimental.WithMemoryAllocator(ctx, alloc), reentrantProbe, wazero.NewModuleConfig()) + if err != nil { + t.Fatalf("instantiating reentrant probe: %v", err) + } + alloc.enter() + _, err = mod.ExportedFunction("run").Call(ctx) + alloc.exit() + var exit *sys.ExitError + if !errors.As(err, &exit) || exit.ExitCode() != sys.ExitCodeContextCanceled { + t.Fatalf("run: %v, want the cancellation exit", err) + } + if !nestedFailed { + t.Fatal("the nested call did not see the closed module") + } + if freedDuringImport { + t.Fatal("memory freed while the guest was suspended in a host import") + } + if !alloc.isFreed() { + t.Fatal("memory not freed once the outer call returned") + } +} + // A Client that becomes unreachable without Close is released by its // cleanup: the guest's shutdown runs and the memory is wiped and freed. It // covers the forgot-to-close case in a running process, and nothing at From 6eed5e90f28015e385942165591e396d65a62959 Mon Sep 17 00:00:00 2001 From: Dan Draper <dan@cipherstash.com> Date: Sat, 19 Sep 2026 22:17:49 -0700 Subject: [PATCH 590/686] refactor(stack-encrypt): one mapped memory over platform primitives; the lock state is race-free and printable Review findings on cipherstash/cipherstash-suite#2237, all addressed. Correctness: the backend's lock error was written during a guest memory.grow under the client lock and read by MemoryLocked under the allocator's, a torn read. The backend now returns the refusal from commit and the allocator records it under its own mutex; nothing else reads backend state. A strict growth refusal is counted only when the backend refused for the lock, not for a commit failure or an out-of-range size, so Client.call no longer formats a nil lock error. The heap fallback refuses a size no slice can hold instead of panicking; the wipe pins its slice with runtime.KeepAlive. Shape: the Unix and Windows backends were the same commit, lock, strict-undo sequence twice. One mappedMemory now sits over five platform primitives (reserve, commit, decommit, lock, release) plus the dump exclusion; the allocator is the LinearMemory itself, so no wrapper and no type assertions to reach the backend; the strict flag is a lockPolicy rather than a bool threaded through four constructors; byteCount renders 1.5 GiB as such. Spec: a Client prints its memory state (fmt.Stringer) and logs it (slog.LogValuer), which is the "visible in debug output" the issue asked for. The real guest is now exercised, not only the probe module: a staged 2 MiB buffer makes it grow in place and, on Linux with the lock granted, its own mapping is checked in smaps. Per-call residency is pinned where it can be observed: the hermetic init scan also asserts the response body ZeroKMS answered is gone from guest memory, and the live suite asserts a plaintext is gone after Encrypt returns; data keys are ZeroizeOnDrop values local to each export in the Rust guest, which the package docs now state. Strict mode refusing later growth is documented on Config.RequireLockedMemory and in the package docs, and the Unix lock error names the remedy (ulimit -l, LimitMEMLOCK=, securityContext) as well as the limit. --- languages/golang/stackencrypt/client.go | 31 ++- languages/golang/stackencrypt/doc.go | 16 +- languages/golang/stackencrypt/guest.go | 13 +- languages/golang/stackencrypt/guest_test.go | 21 +- languages/golang/stackencrypt/live_test.go | 22 ++ languages/golang/stackencrypt/memory.go | 226 ++++++++++-------- .../golang/stackencrypt/memory_mapped.go | 77 +++++- languages/golang/stackencrypt/memory_other.go | 4 +- languages/golang/stackencrypt/memory_test.go | 108 ++++++--- languages/golang/stackencrypt/memory_unix.go | 95 ++------ .../golang/stackencrypt/memory_windows.go | 87 +++---- 11 files changed, 414 insertions(+), 286 deletions(-) diff --git a/languages/golang/stackencrypt/client.go b/languages/golang/stackencrypt/client.go index 7aa40326e..55590eadc 100644 --- a/languages/golang/stackencrypt/client.go +++ b/languages/golang/stackencrypt/client.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "log/slog" "net/http" "runtime" "strconv" @@ -39,8 +40,12 @@ type Config struct { // RequireLockedMemory makes NewClient fail with ErrMemoryLock when the // guest's memory cannot be locked in RAM, instead of continuing with // memory that may be swapped and reporting so through - // Client.MemoryLocked. Set it where swap is a real exposure and the - // deployment can be relied on to grant the lock; see [Client.MemoryLocked]. + // Client.MemoryLocked. It holds for the life of the client: a later + // growth of the guest's memory that cannot be locked is refused too, + // and the call that needed it fails with ErrMemoryLock. Set it where + // swap is a real exposure and the deployment grants a lock limit with + // room for the guest to grow (RLIMIT_MEMLOCK on Linux); see + // [Client.MemoryLocked]. RequireLockedMemory bool } @@ -102,7 +107,7 @@ func NewClient(ctx context.Context, cfg Config) (*Client, error) { defer wipe(encoded) t := &transport{rt: rt, token: cfg.Token} - inst, err := newInstance(ctx, wasm, t, cfg.RequireLockedMemory) + inst, err := newInstance(ctx, wasm, t, policyFor(cfg.RequireLockedMemory)) if err != nil { return nil, err } @@ -151,6 +156,26 @@ func (c *Client) MemoryLockError() error { return nil } +// String implements fmt.Stringer so that a Client printed with %v or %s +// shows its memory state: "locked", or the refusal. Nothing secret is +// printed. The state is what an operator reading a startup log needs to +// see, and [Client.LogValue] gives it structured form. +func (c *Client) String() string { + if err := c.inst.mem.lockError(); err != nil { + return fmt.Sprintf("stackencrypt.Client{memory: unlocked: %v}", err) + } + return "stackencrypt.Client{memory: locked}" +} + +// LogValue implements slog.LogValuer: a group with memory_locked and, when +// false, memory_lock_error. +func (c *Client) LogValue() slog.Value { + if err := c.inst.mem.lockError(); err != nil { + return slog.GroupValue(slog.Bool("memory_locked", false), slog.String("memory_lock_error", err.Error())) + } + return slog.GroupValue(slog.Bool("memory_locked", true)) +} + // encodeConfig renders the se_cipher_init object. The result holds the // client key; the caller wipes it. func encodeConfig(cfg Config) ([]byte, error) { diff --git a/languages/golang/stackencrypt/doc.go b/languages/golang/stackencrypt/doc.go index c3bc2e2b9..f90e75a61 100644 --- a/languages/golang/stackencrypt/doc.go +++ b/languages/golang/stackencrypt/doc.go @@ -92,7 +92,17 @@ // limit to raise (ulimit -l, a systemd LimitMEMLOCK=, a pod's // securityContext). [Config.RequireLockedMemory] turns a refusal into a // [NewClient] failure with [ErrMemoryLock], for deployments that would -// rather not start than run unlocked. An embedder running the guest under -// its own wazero configuration gets none of this unless it supplies an -// allocator of its own. +// rather not start than run unlocked; it also refuses any later growth of +// the guest's memory that cannot be locked, so the limit granted must +// leave the guest room to grow. A Client prints its memory state +// ([Client.String]) and logs it ([Client.LogValue]). An embedder running +// the guest under its own wazero configuration gets none of this unless +// it supplies an allocator of its own. +// +// Between calls the guest holds the client key and its keyset cache (each +// keyset's index key) and nothing else: data keys are per-call values in +// the guest's Rust code, wiped by their ZeroizeOnDrop when the export +// returns, and every buffer staged for a call is wiped by se_dealloc +// before the call's result is returned. The residency tests pin the +// second; the first is the Rust crate's own guarantee. package stackencrypt diff --git a/languages/golang/stackencrypt/guest.go b/languages/golang/stackencrypt/guest.go index 69a34004a..f918bbfdf 100644 --- a/languages/golang/stackencrypt/guest.go +++ b/languages/golang/stackencrypt/guest.go @@ -85,9 +85,10 @@ func guestModuleConfig() wazero.ModuleConfig { } // newInstance instantiates wasm with the transport as its host module and -// its linear memory from this package's allocator. With strict set, memory -// that cannot be locked fails instantiation with ErrMemoryLock. -func newInstance(ctx context.Context, wasm []byte, t *transport, strict bool) (*instance, error) { +// its linear memory from this package's allocator. Under the strict +// policy, memory that cannot be locked fails instantiation with +// ErrMemoryLock. +func newInstance(ctx context.Context, wasm []byte, t *transport, policy lockPolicy) (*instance, error) { // WithCloseOnContextDone lets a caller's deadline or cancellation // interrupt an in-flight guest call — which otherwise holds the Client's // lock against every other user. An interrupted call closes the module, @@ -113,18 +114,18 @@ func newInstance(ctx context.Context, wasm []byte, t *transport, strict bool) (* // The guest's linear memory comes from this package, not wazero's // default slice: reserved once, locked and non-dumpable where the // platform allows, wiped on release. See memory.go. - mem := newMemoryAllocator(strict) + mem := newMemoryAllocator(policy) // The guest is a reactor (cdylib): no _start. wazero runs _initialize // when present. module, err := runtime.InstantiateWithConfig(experimental.WithMemoryAllocator(ctx, mem), wasm, guestModuleConfig()) if err != nil { _ = runtime.Close(ctx) - if strict && mem.growthRefusals() != 0 { + if mem.growthRefusals() != 0 { return nil, fmt.Errorf("%w: %w", memoryLockError(mem.lockError()), err) } return nil, fmt.Errorf("stackencrypt: instantiating guest: %w", err) } - if strict { + if policy == strict { if lerr := mem.lockError(); lerr != nil { _ = runtime.Close(ctx) return nil, memoryLockError(lerr) diff --git a/languages/golang/stackencrypt/guest_test.go b/languages/golang/stackencrypt/guest_test.go index 1b549ff6b..74c2f235c 100644 --- a/languages/golang/stackencrypt/guest_test.go +++ b/languages/golang/stackencrypt/guest_test.go @@ -476,7 +476,7 @@ func TestConfigValidation(t *testing.T) { func rawInstance(t *testing.T) *Client { t.Helper() ctx := context.Background() - inst, err := newInstance(ctx, guestOrSkip(t), &transport{rt: http.DefaultTransport, token: StaticToken("t")}, false) + inst, err := newInstance(ctx, guestOrSkip(t), &transport{rt: http.DefaultTransport, token: StaticToken("t")}, bestEffort) if err != nil { t.Fatal(err) } @@ -660,7 +660,9 @@ func TestHostilePointerLengthPairsAreStatusesNotTraps(t *testing.T) { // linear memory after NewClient returns — success or failure. func TestClientKeyDoesNotRemainInGuestMemory(t *testing.T) { guestOrSkip(t) - stub := newStub(t, http.StatusUnauthorized, "", "nope") + // A body long enough that a hit is not a coincidence of four bytes. + const errorBody = "refused-4111-9f8e7d6c5b4a-residency-probe" + stub := newStub(t, http.StatusUnauthorized, "", errorBody) // Keep the instance to scan it: build the client by hand so a failed // init does not tear it down first. ctx := context.Background() @@ -669,7 +671,7 @@ func TestClientKeyDoesNotRemainInGuestMemory(t *testing.T) { t.Fatal(err) } tr := &transport{rt: http.DefaultTransport, token: StaticToken("stub-token")} - inst, err := newInstance(ctx, guestOrSkip(t), tr, false) + inst, err := newInstance(ctx, guestOrSkip(t), tr, bestEffort) if err != nil { t.Fatal(err) } @@ -683,10 +685,15 @@ func TestClientKeyDoesNotRemainInGuestMemory(t *testing.T) { if !ok { t.Fatal("cannot read guest memory") } + // The response body is what ZeroKMS answered, placed in guest memory + // by the transport and wiped by the guest's registry when the call + // returns; the bearer token and the response headers travel the same + // way. Nothing a call staged may outlive it. for name, needle := range map[string][]byte{ - "key hex": []byte(testClientKey), - "key bytes": mustHex(testClientKey), - "bearer": []byte("stub-token"), + "key hex": []byte(testClientKey), + "key bytes": mustHex(testClientKey), + "bearer": []byte("stub-token"), + "response body": []byte(errorBody), } { if n := bytes.Count(view, needle); n != 0 { t.Errorf("%s found %d times in guest memory after init", name, n) @@ -699,7 +706,7 @@ func TestTransportSendCounterAndResponseHeaders(t *testing.T) { stub := newStub(t, http.StatusUnauthorized, "text/plain", "nope") tr := &transport{rt: http.DefaultTransport, token: StaticToken("stub-token")} ctx := context.Background() - inst, err := newInstance(ctx, guestOrSkip(t), tr, false) + inst, err := newInstance(ctx, guestOrSkip(t), tr, bestEffort) if err != nil { t.Fatal(err) } diff --git a/languages/golang/stackencrypt/live_test.go b/languages/golang/stackencrypt/live_test.go index b0f4cd0e0..fd60204e6 100644 --- a/languages/golang/stackencrypt/live_test.go +++ b/languages/golang/stackencrypt/live_test.go @@ -1,6 +1,7 @@ package stackencrypt import ( + "bytes" "errors" "os" "reflect" @@ -166,3 +167,24 @@ func TestLiveForeignKeysetIsRefusedBeforeRetrieval(t *testing.T) { t.Fatalf("client decrypt of the other keyset: %v %v", pt, err) } } + +// Per-call hygiene on a real round trip: once Encrypt has returned, the +// plaintext it was given is nowhere in guest memory — the staged input was +// wiped by se_dealloc — so between calls the guest holds only the client +// key and its keyset cache. +func TestPlaintextDoesNotRemainInGuestMemoryAfterEncrypt(t *testing.T) { + c := liveClient(t) + ctx := t.Context() + const plaintext = "residency-probe-4111-b1c2d3e4f5" + if _, err := c.DefaultKeyset().Encrypt(ctx, plaintext, nil); err != nil { + t.Fatalf("Encrypt: %v", err) + } + mem := c.inst.module.Memory() + view, ok := mem.Read(0, mem.Size()) + if !ok { + t.Fatal("cannot read guest memory") + } + if n := bytes.Count(view, []byte(plaintext)); n != 0 { + t.Fatalf("plaintext found %d times in guest memory after Encrypt returned", n) + } +} diff --git a/languages/golang/stackencrypt/memory.go b/languages/golang/stackencrypt/memory.go index 202a1456e..653388faa 100644 --- a/languages/golang/stackencrypt/memory.go +++ b/languages/golang/stackencrypt/memory.go @@ -42,38 +42,67 @@ import ( // operator can see it and raise the limit; Config.RequireLockedMemory // turns it into a NewClient failure. -// guestMemory is one instance's linear memory, as this package supplies it -// to wazero: the LinearMemory contract plus what the Client reports about -// it. Reallocate is called by wazero under the Client's lock, and Free -// from wherever wazero closes the module (see observed.Free); lockError is -// read from any goroutine. -type guestMemory interface { - experimental.LinearMemory - // lockError is nil while every committed byte is locked (and, on Linux, - // excluded from dumps); otherwise it names what was refused and why. - // It never clears: a lock refused once is reported for the life of - // the instance, even if a later growth locks. - lockError() error +// lockPolicy is what a refused lock means for an instance. +type lockPolicy uint8 + +const ( + // bestEffort records a refused lock and carries on with unlocked + // memory. + bestEffort lockPolicy = iota + // strict refuses growth that cannot be locked. The first commit is the + // exception: wazero cannot instantiate on a nil buffer, so it is + // granted with the refusal recorded, and newInstance turns that into + // the ErrMemoryLock the caller asked for. + strict +) + +func policyFor(requireLockedMemory bool) lockPolicy { + if requireLockedMemory { + return strict + } + return bestEffort +} + +// backend is one platform's linear memory behind a memoryAllocator: a +// reservation committed from the front. It is used from the guest's +// goroutine only; the allocator does the bookkeeping other goroutines +// read. +type backend interface { + // commit grows the memory to size bytes and returns the buffer wazero + // will use, whose base never changes. A nil buffer means the growth + // failed. lockErr, when set, is a refused lock on the newly committed + // range: with a buffer, the range was kept unlocked (best effort); + // without one, the growth was refused because of it (strict). + commit(size uint64) (buf []byte, lockErr error) + // free wipes the committed range and releases the reservation. + free() } // memoryAllocator is the experimental.MemoryAllocator handed to wazero for -// one guest instance. wazero calls Allocate once per memory, and the guest -// has exactly one, so this is where the Client finds the memory it was -// given. +// one guest instance, and the experimental.LinearMemory it returns: wazero +// calls Allocate once per memory, and the guest has exactly one. It +// records what the Client reports about the memory, and holds the memory +// mapped while a guest call is in flight (see enter, exit and Free). type memoryAllocator struct { - // strict refuses growth it cannot lock (see Reallocate in each - // backend) instead of recording the refusal and carrying on. - strict bool + policy lockPolicy mu sync.Mutex - mem guestMemory + mem backend + // fallback is set when the memory is a heap slice rather than a + // reservation: no lock is possible, growth may copy (and wipes what + // it abandons). + fallback bool + // err is the first refusal of any kind — the reservation, the dump + // exclusion, a lock — and never clears: a lock refused once is + // reported for the life of the instance. + err error // refusals counts strict growths refused. Client.call compares it // across a call to name the real cause when the guest reports only a // failed allocation. refusals uint64 - // inFlight counts guest calls in progress on this memory (see enter and - // exit); pending records a Free that arrived while one was, to be - // honoured when the outermost call returns. + // inFlight counts guest calls in progress (see enter and exit); + // pending records a Free that arrived while one was, to be honoured + // when the outermost call returns. inFlight int pending bool // freed is set once the memory is gone, its contents wiped first. @@ -82,8 +111,8 @@ type memoryAllocator struct { freed bool } -func newMemoryAllocator(strict bool) *memoryAllocator { - return &memoryAllocator{strict: strict} +func newMemoryAllocator(policy lockPolicy) *memoryAllocator { + return &memoryAllocator{policy: policy} } // Allocate implements experimental.MemoryAllocator. @@ -96,32 +125,48 @@ func (a *memoryAllocator) Allocate(capacity, max uint64) experimental.LinearMemo // rather than letting two memories share one report. panic("stackencrypt: guest memory allocated twice") } - a.mem = &observed{guestMemory: reserveMemory(capacity, max, a.strict), owner: a} - return a.mem + mem, err := reserveMemory(capacity, max, a.policy) + a.mem = mem + a.err = err + _, a.fallback = mem.(*heapMemory) + return a } -// lockError reports the memory's lock state, or nil before the memory -// exists. -func (a *memoryAllocator) lockError() error { - a.mu.Lock() - defer a.mu.Unlock() - if a.mem == nil { - return nil +// Reallocate implements experimental.LinearMemory. +func (a *memoryAllocator) Reallocate(size uint64) []byte { + buf, lockErr := a.mem.commit(size) + if lockErr != nil { + a.mu.Lock() + if a.err == nil { + a.err = lockErr + } + if buf == nil { + a.refusals++ + } + a.mu.Unlock() } - return a.mem.lockError() -} - -// growthRefusals counts the strict growths refused so far. -func (a *memoryAllocator) growthRefusals() uint64 { - a.mu.Lock() - defer a.mu.Unlock() - return a.refusals + return buf } -func (a *memoryAllocator) isFreed() bool { +// Free implements experimental.LinearMemory. wazero calls it when the +// module's resources are closed, and that can happen while the guest is +// still running: a call whose context ends during a host import closes +// the module on wazero's watcher goroutine with its resources deferred, +// and the next call into the module — the host import re-entering the +// guest through se_alloc to place its result — closes them. With wazero's +// default allocator that was harmless, the Go slice outlived the module; +// here it would unmap the memory under a guest suspended in the import, +// whose next store then faults in compiled code. So a Free that arrives +// during a call is recorded and performed by the outermost exit, when no +// guest code can be running. A Free with no call in flight is immediate. +func (a *memoryAllocator) Free() { a.mu.Lock() defer a.mu.Unlock() - return a.freed + if a.inFlight > 0 { + a.pending = true + return + } + a.freeLocked() } // enter marks a guest call in progress: the memory must stay mapped until @@ -143,108 +188,89 @@ func (a *memoryAllocator) exit() { } } -// freeLocked wipes and releases the memory. Called with mu held, once. +// freeLocked wipes and releases the memory, once. Called with mu held. func (a *memoryAllocator) freeLocked() { if a.freed { return } a.freed = true - a.mem.(*observed).guestMemory.Free() + a.mem.free() } -// observed wraps the backend memory so the allocator sees the events the -// Client needs to report: a refused strict growth, and the release. -type observed struct { - guestMemory - owner *memoryAllocator +// lockError is nil while every committed byte is locked (and, on Linux, +// excluded from dumps); otherwise it names what was refused and why. +func (a *memoryAllocator) lockError() error { + a.mu.Lock() + defer a.mu.Unlock() + return a.err } -func (o *observed) Reallocate(size uint64) []byte { - buf := o.guestMemory.Reallocate(size) - if buf == nil && o.owner.strict { - // In strict mode a nil is a lock refusal (a backend refuses no - // other growth below the maximum); in best-effort mode it is - // the maximum, which is the guest's own failure to report. - o.owner.mu.Lock() - o.owner.refusals++ - o.owner.mu.Unlock() - } - return buf +// growthRefusals counts the strict growths refused so far. +func (a *memoryAllocator) growthRefusals() uint64 { + a.mu.Lock() + defer a.mu.Unlock() + return a.refusals } -// Free implements experimental.LinearMemory. wazero calls it when the -// module's resources are closed, and that can happen while the guest is -// still running: a call whose context ends during a host import closes -// the module on wazero's watcher goroutine with its resources deferred, -// and the next call into the module — the host import re-entering the -// guest through se_alloc to place its result — closes them. With wazero's -// default allocator that was harmless, the Go slice outlived the module; -// here it would unmap the memory under a guest suspended in the import, -// whose next store then faults in compiled code. So a Free that arrives -// during a call is recorded and performed by the outermost exit, when no -// guest code can be running. A Free with no call in flight is immediate. -func (o *observed) Free() { - o.owner.mu.Lock() - defer o.owner.mu.Unlock() - if o.owner.inFlight > 0 { - o.owner.pending = true - return - } - o.owner.freeLocked() +func (a *memoryAllocator) isFallback() bool { + a.mu.Lock() + defer a.mu.Unlock() + return a.fallback +} + +func (a *memoryAllocator) isFreed() bool { + a.mu.Lock() + defer a.mu.Unlock() + return a.freed } // heapMemory backs the guest with an ordinary Go slice, for platforms with // no reservation primitive this package uses and for a reservation that // failed (a 4 GiB address-space reservation on a 32-bit host, say). It // keeps two of the four properties above: growth wipes the slice it -// abandons, and Free wipes before releasing. It cannot lock or exclude -// from dumps, and says so. +// abandons, and free wipes before releasing. It cannot lock or exclude +// from dumps; the allocator carries the reason. type heapMemory struct { - buf []byte - max uint64 - reason error + buf []byte + max uint64 } -// newHeapMemory returns a heap-backed memory whose lockError is reason, -// which must not be nil: a heap memory is never locked. -func newHeapMemory(capacity, max uint64, reason error) *heapMemory { +func newHeapMemory(capacity, max uint64) *heapMemory { if capacity > max { capacity = max } if capacity > math.MaxInt { capacity = 0 } - return &heapMemory{buf: make([]byte, 0, int(capacity)), max: max, reason: reason} + return &heapMemory{buf: make([]byte, 0, int(capacity)), max: max} } -func (m *heapMemory) Reallocate(size uint64) []byte { - if size > m.max { - return nil +func (m *heapMemory) commit(size uint64) ([]byte, error) { + if size > m.max || size > math.MaxInt { + return nil, nil } if size <= uint64(cap(m.buf)) { m.buf = m.buf[:size] - return m.buf + return m.buf, nil } grown := make([]byte, size) copy(grown, m.buf) // The abandoned slice held everything the guest had, keys included. clear(m.buf[:cap(m.buf)]) m.buf = grown - return m.buf + return m.buf, nil } -func (m *heapMemory) Free() { +func (m *heapMemory) free() { clear(m.buf[:cap(m.buf)]) m.buf = nil } -func (m *heapMemory) lockError() error { return m.reason } - // errNoLockSupport is the heap fallback's reason on platforms where this // package has no lock implementation. var errNoLockSupport = errors.New("guest memory cannot be locked on this platform") -// memoryLockError wraps a backend's lock refusal as ErrMemoryLock. +// memoryLockError wraps a lock refusal as ErrMemoryLock. func memoryLockError(err error) error { return fmt.Errorf("%w: %w", ErrMemoryLock, err) } @@ -252,8 +278,8 @@ func memoryLockError(err error) error { func byteCount(n uint64) string { const kib, mib, gib = 1 << 10, 1 << 20, 1 << 30 switch { - case n >= gib && n%gib == 0: - return fmt.Sprintf("%d GiB", n/gib) + case n >= gib: + return fmt.Sprintf("%.1f GiB", float64(n)/gib) case n >= mib: return fmt.Sprintf("%.1f MiB", float64(n)/mib) case n >= kib: diff --git a/languages/golang/stackencrypt/memory_mapped.go b/languages/golang/stackencrypt/memory_mapped.go index 69a992b68..605b28b85 100644 --- a/languages/golang/stackencrypt/memory_mapped.go +++ b/languages/golang/stackencrypt/memory_mapped.go @@ -2,18 +2,89 @@ package stackencrypt -import "unsafe" +import ( + "fmt" + "math" + "runtime" +) + +// mappedMemory is the reservation-backed memory: one range of the declared +// maximum reserved up front, inaccessible until committed (made readable +// and writable) from the front as the guest grows. The base never changes, +// so wazero's buffer never moves. Each newly committed range is locked; +// the whole reservation is excluded from dumps once, at reservation, on +// the platforms that can. The platform supplies the five primitives +// (memory_unix.go, memory_windows.go); the shape is the same on both. +type mappedMemory struct { + mapping []byte // the whole reservation + committed uint64 // bytes made accessible so far, from the front + policy lockPolicy +} + +// reserveMemory returns a mapped memory for the reservation, or a heap +// memory when the reservation itself is impossible: max exceeds what this +// process can address (a 32-bit host asked for wasm's 4 GiB default), or +// the platform refused it. The error is the reason the memory is not, or +// not fully, protected; nil when it is. +func reserveMemory(capacity, max uint64, policy lockPolicy) (backend, error) { + if max > math.MaxInt { + return newHeapMemory(capacity, max), fmt.Errorf("cannot reserve %s of address space on this host", byteCount(max)) + } + mapping, err := reserveRange(int(max)) + if err != nil { + return newHeapMemory(capacity, max), fmt.Errorf("reserving %s of address space: %w", byteCount(max), err) + } + return &mappedMemory{mapping: mapping, policy: policy}, excludeFromDumps(mapping) +} + +// commit implements backend. Shrinking is not something wasm does; a +// smaller size just shortens the view. +func (m *mappedMemory) commit(size uint64) ([]byte, error) { + if size > uint64(len(m.mapping)) { + return nil, nil + } + if size > m.committed { + fresh := m.mapping[m.committed:size] + if err := commitRange(fresh); err != nil { + return nil, nil + } + if err := lockRange(fresh); err != nil { + if m.policy == strict && m.committed > 0 { + // Nothing was written to the range yet; giving it back + // leaves the guest exactly where it was. + decommitRange(fresh) + return nil, err + } + m.committed = size + return m.mapping[:size:size], err + } + m.committed = size + } + return m.mapping[:size:size], nil +} + +// free implements backend: wipe what was committed, then release the +// reservation, which drops any lock with the pages. +func (m *mappedMemory) free() { + if m.mapping == nil { + return + } + wipeMapped(m.mapping[:m.committed]) + releaseRange(m.mapping) + m.mapping = nil + m.committed = 0 +} // wipeMapped zeroes a committed range before its mapping is released. The // stores go to memory that a syscall unmaps straight after, which the // compiler cannot see through, so they are not dead stores it could drop; // the volatile-store dance a C wipe needs has no Go equivalent and no need -// here. The slice is kept alive past the stores so nothing reorders the +// here. KeepAlive pins the slice past the stores so nothing reorders the // wipe after the release. func wipeMapped(b []byte) { if len(b) == 0 { return } clear(b) - _ = unsafe.SliceData(b) + runtime.KeepAlive(b) } diff --git a/languages/golang/stackencrypt/memory_other.go b/languages/golang/stackencrypt/memory_other.go index 148ac7139..080752f36 100644 --- a/languages/golang/stackencrypt/memory_other.go +++ b/languages/golang/stackencrypt/memory_other.go @@ -5,6 +5,6 @@ package stackencrypt // Platforms with neither mmap nor VirtualAlloc in this package's // vocabulary get the heap fallback: growth and release still wipe, nothing // is locked, and the Client says so. -func reserveMemory(capacity, max uint64, _ bool) guestMemory { - return newHeapMemory(capacity, max, errNoLockSupport) +func reserveMemory(capacity, max uint64, _ lockPolicy) (backend, error) { + return newHeapMemory(capacity, max), errNoLockSupport } diff --git a/languages/golang/stackencrypt/memory_test.go b/languages/golang/stackencrypt/memory_test.go index 8c2afb56f..ae6a78a1a 100644 --- a/languages/golang/stackencrypt/memory_test.go +++ b/languages/golang/stackencrypt/memory_test.go @@ -5,6 +5,7 @@ import ( "context" "errors" "fmt" + "math" "net/http" "os" "os/exec" @@ -79,10 +80,10 @@ func probeMemory(t *testing.T, alloc *memoryAllocator) (base func() uintptr, gro // reservation, so the buffer's address is the same before and after, and // the guest's keys are never copied to a new slice. func TestGuestMemoryDoesNotMoveOnGrowth(t *testing.T) { - alloc := newMemoryAllocator(false) + alloc := newMemoryAllocator(bestEffort) base, grow, done := probeMemory(t, alloc) defer done() - if isHeapFallback(alloc) { + if alloc.isFallback() { t.Skipf("heap fallback in use on this host: %v", alloc.lockError()) } t.Logf("lock state on this host: %v", alloc.lockError()) @@ -97,10 +98,56 @@ func TestGuestMemoryDoesNotMoveOnGrowth(t *testing.T) { } } +// The same property on the real guest: a host-staged buffer larger than +// the guest's initial memory makes it grow, and its memory stays where it +// was. On Linux with the lock granted the guest's own mapping is then +// checked in smaps, as the probe's is below. +func TestGuestGrowsInPlace(t *testing.T) { + c := rawInstance(t) + ctx := context.Background() + mem := c.inst.module.Memory() + base := func() uintptr { + view, ok := mem.Read(0, 1) + if !ok { + t.Fatal("reading guest memory") + } + return uintptr(unsafe.Pointer(unsafe.SliceData(view))) + } + if c.inst.mem.isFallback() { + t.Skipf("heap fallback in use on this host: %v", c.inst.mem.lockError()) + } + before, pagesBefore := base(), mem.Size()/wasmPage + staged, err := c.inst.allocWrite(ctx, make([]byte, 2<<20)) + if err != nil { + t.Fatal(err) + } + defer c.inst.free(ctx, staged) + if after := base(); after != before { + t.Fatalf("guest memory moved on growth: %#x -> %#x", before, after) + } + if pagesAfter := mem.Size() / wasmPage; pagesAfter <= pagesBefore { + t.Fatalf("guest memory did not grow: %d pages before, %d after", pagesBefore, pagesAfter) + } + if runtime.GOOS != "linux" { + return + } + lockOrSkip(t, c.inst.mem) + mapping, err := smapsEntry(base()) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(" "+mapping.vmFlags+" ", " dd ") || !strings.Contains(" "+mapping.vmFlags+" ", " lo ") { + t.Errorf("guest mapping VmFlags = %q, want dd and lo", mapping.vmFlags) + } + if mapping.lockedKB == 0 || mapping.lockedKB != mapping.rssKB { + t.Errorf("guest mapping Locked = %d kB, Rss = %d kB", mapping.lockedKB, mapping.rssKB) + } +} + // Free wipes then unmaps: the allocator reports the release, and the // runtime close is what triggers it. func TestGuestMemoryIsFreedOnRuntimeClose(t *testing.T) { - alloc := newMemoryAllocator(false) + alloc := newMemoryAllocator(bestEffort) _, grow, done := probeMemory(t, alloc) if _, ok := grow(3); !ok { t.Fatal("grow refused") @@ -117,26 +164,32 @@ func TestGuestMemoryIsFreedOnRuntimeClose(t *testing.T) { // The heap fallback keeps the two properties it can: growth wipes the // slice it abandons, and Free wipes. func TestHeapMemoryWipesWhatItAbandons(t *testing.T) { - m := newHeapMemory(wasmPage, 4*wasmPage, errNoLockSupport) - first := m.Reallocate(wasmPage) + m := newHeapMemory(wasmPage, 4*wasmPage) + first, _ := m.commit(wasmPage) first[0], first[wasmPage-1] = 0xAA, 0xBB - second := m.Reallocate(3 * wasmPage) + second, _ := m.commit(3 * wasmPage) if second[0] != 0xAA || second[wasmPage-1] != 0xBB { t.Fatal("growth lost the contents") } if first[0] != 0 || first[wasmPage-1] != 0 { t.Fatal("growth left the abandoned slice unwiped") } - if m.Reallocate(5*wasmPage) != nil { + if buf, _ := m.commit(5 * wasmPage); buf != nil { t.Fatal("grew past max") } + // A size no slice on this host can hold is a refused growth, not a + // panic. Only a 32-bit host can ask without the request being a real + // allocation, so that is where it runs (CI's GOARCH=386 pass). + if uint64(math.MaxInt) < 1<<40 { + huge := newHeapMemory(0, 1<<40) + if buf, _ := huge.commit(1 << 40); buf != nil { + t.Fatal("a growth past the addressable size was granted") + } + } second[7] = 0xCC - m.Free() + m.free() if second[7] != 0 { - t.Fatal("Free left the slice unwiped") - } - if m.lockError() == nil { - t.Fatal("a heap memory claims to be locked") + t.Fatal("free left the slice unwiped") } } @@ -152,7 +205,7 @@ func lockOrSkip(t *testing.T, alloc *memoryAllocator) { if err == nil { return } - if isHeapFallback(alloc) { + if alloc.isFallback() { // Not a refused lock: there was no reservation to lock (a 32-bit // host), which CI exercises on purpose under GOARCH=386. t.Skipf("heap fallback in use on this host: %v", err) @@ -163,18 +216,13 @@ func lockOrSkip(t *testing.T, alloc *memoryAllocator) { t.Skipf("lock refused on this host: %v", err) } -func isHeapFallback(alloc *memoryAllocator) bool { - _, heap := alloc.mem.(*observed).guestMemory.(*heapMemory) - return heap -} - // The lock is observable from the kernel's side: the mapping backing the // probe shows as locked and, on Linux, non-dumpable, in /proc/self/smaps. func TestGuestMemoryIsLockedAndNotDumpable(t *testing.T) { if runtime.GOOS != "linux" { t.Skip("smaps is Linux") } - alloc := newMemoryAllocator(false) + alloc := newMemoryAllocator(bestEffort) base, grow, done := probeMemory(t, alloc) defer done() // Grow past the initial commit so the flags are checked on a range @@ -292,14 +340,14 @@ func TestRequireLockedMemoryRefusesAnUnlockableGuest(t *testing.T) { // Can the lock be refused at all here? Root and CAP_IPC_LOCK ignore // the limit. On a 32-bit host there is no reservation to lock, and the // strict refusal is the reservation's, not the limit's. - probe := newMemoryAllocator(false) + probe := newMemoryAllocator(bestEffort) _, _, done := probeMemory(t, probe) done() if probe.lockError() == nil { fmt.Println("case skipped: mlock succeeds under RLIMIT_MEMLOCK=0") return } - limited := !isHeapFallback(probe) + limited := !probe.isFallback() cfg := Config{ ClientID: "6a70bd18-99ac-4650-b104-37eec3a15b09", ClientKey: "00", @@ -314,12 +362,10 @@ func TestRequireLockedMemoryRefusesAnUnlockableGuest(t *testing.T) { if limited && !strings.Contains(err.Error(), "RLIMIT_MEMLOCK") { t.Fatalf("the error does not name the limit: %v", err) } - // Best effort under the same refusal: the client exists and says so. - cfg.RequireLockedMemory = false - cfg.Guest = nil + // Best effort under the same refusal: the client exists, says so, and + // shows it wherever it is printed or logged. if wasm, gerr := embeddedGuest(); gerr == nil { - cfg.Guest = wasm - inst, err := newInstance(context.Background(), wasm, &transport{rt: http.DefaultTransport, token: cfg.Token}, false) + inst, err := newInstance(context.Background(), wasm, &transport{rt: http.DefaultTransport, token: cfg.Token}, bestEffort) if err != nil { t.Fatal(err) } @@ -331,6 +377,12 @@ func TestRequireLockedMemoryRefusesAnUnlockableGuest(t *testing.T) { if err := c.MemoryLockError(); !errors.Is(err, ErrMemoryLock) { t.Fatalf("MemoryLockError = %v, want ErrMemoryLock", err) } + if s := fmt.Sprint(c); !strings.Contains(s, "unlocked") || (limited && !strings.Contains(s, "RLIMIT_MEMLOCK")) { + t.Fatalf("Client prints as %q: no memory state", s) + } + if v := c.LogValue().String(); !strings.Contains(v, "memory_locked=false") { + t.Fatalf("Client logs as %q: no memory state", v) + } } fmt.Println("case ok") } @@ -370,7 +422,7 @@ var reentrantProbe = []byte{ func TestMemoryOutlivesACallClosedDuringAHostImport(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) defer cancel() - alloc := newMemoryAllocator(false) + alloc := newMemoryAllocator(bestEffort) rt := wazero.NewRuntimeWithConfig(ctx, wazero.NewRuntimeConfig().WithCloseOnContextDone(true)) defer rt.Close(context.Background()) var freedDuringImport, nestedFailed bool @@ -412,7 +464,7 @@ func TestMemoryOutlivesACallClosedDuringAHostImport(t *testing.T) { // exit. func TestUnreachableClientIsReleased(t *testing.T) { wasm := guestOrSkip(t) - inst, err := newInstance(context.Background(), wasm, &transport{rt: http.DefaultTransport, token: StaticToken("t")}, false) + inst, err := newInstance(context.Background(), wasm, &transport{rt: http.DefaultTransport, token: StaticToken("t")}, bestEffort) if err != nil { t.Fatal(err) } diff --git a/languages/golang/stackencrypt/memory_unix.go b/languages/golang/stackencrypt/memory_unix.go index 7779ff8b3..1ec5e7e09 100644 --- a/languages/golang/stackencrypt/memory_unix.go +++ b/languages/golang/stackencrypt/memory_unix.go @@ -4,99 +4,40 @@ package stackencrypt import ( "fmt" - "math" "golang.org/x/sys/unix" ) -// mappedMemory is the Unix backend: one anonymous private mapping of the -// declared maximum, reserved PROT_NONE so it costs address space only, and -// committed (made readable and writable) from the front as the guest grows. -// The base never changes, so wazero's buffer never moves. Each newly -// committed range is locked; the whole reservation is excluded from dumps -// once, at reservation, on the platforms that can (memory_linux.go). -type mappedMemory struct { - mapping []byte // the whole reservation - committed uint64 // bytes made accessible so far, from the front - strict bool - err error // first lock or dump-exclusion refusal; never cleared -} +// The Unix primitives behind mappedMemory: an anonymous private mapping +// reserved PROT_NONE so it costs address space only, committed with +// mprotect, locked with mlock, released with munmap. -// reserveMemory returns a mapped memory for the reservation, or a heap -// memory when the reservation itself is impossible: max exceeds what this -// process can address (a 32-bit host asked for wasm's 4 GiB default), or -// mmap refused it. -func reserveMemory(capacity, max uint64, strict bool) guestMemory { - if max > math.MaxInt { - return newHeapMemory(capacity, max, fmt.Errorf("cannot reserve %s of address space on this host", byteCount(max))) - } - mapping, err := unix.Mmap(-1, 0, int(max), unix.PROT_NONE, unix.MAP_PRIVATE|unix.MAP_ANON|reserveFlags) - if err != nil { - return newHeapMemory(capacity, max, fmt.Errorf("reserving %s of address space: %w", byteCount(max), err)) - } - m := &mappedMemory{mapping: mapping, strict: strict} - if err := excludeFromDumps(mapping); err != nil { - m.err = err - } - return m +func reserveRange(max int) ([]byte, error) { + return unix.Mmap(-1, 0, max, unix.PROT_NONE, unix.MAP_PRIVATE|unix.MAP_ANON|reserveFlags) } -// Reallocate implements experimental.LinearMemory. Growth commits the -// next range of the reservation and locks it. A lock refusal is recorded -// and, in strict mode, undone: the range goes back to inaccessible and nil -// is returned, which the guest sees as a failed memory.grow. The first -// commit is the exception: wazero cannot instantiate on a nil buffer, so -// it is granted and the refusal recorded, and newInstance turns it into -// the ErrMemoryLock the strict caller asked for. Shrinking is not -// something wasm does; a smaller size just shortens the view. -func (m *mappedMemory) Reallocate(size uint64) []byte { - if size > uint64(len(m.mapping)) { - return nil - } - if size > m.committed { - fresh := m.mapping[m.committed:size] - if err := unix.Mprotect(fresh, unix.PROT_READ|unix.PROT_WRITE); err != nil { - return nil - } - if err := lock(fresh); err != nil { - if m.err == nil { - m.err = err - } - if m.strict && m.committed > 0 { - // Nothing was written to the range yet; giving it back - // leaves the guest exactly where it was. - _ = unix.Mprotect(fresh, unix.PROT_NONE) - return nil - } - } - m.committed = size - } - return m.mapping[:size:size] +func commitRange(b []byte) error { + return unix.Mprotect(b, unix.PROT_READ|unix.PROT_WRITE) } -// Free implements experimental.LinearMemory: wipe what was committed, then -// release the reservation. munmap drops any lock with the pages. -func (m *mappedMemory) Free() { - if m.mapping == nil { - return - } - wipeMapped(m.mapping[:m.committed]) - _ = unix.Munmap(m.mapping) - m.mapping = nil - m.committed = 0 +func decommitRange(b []byte) { + _ = unix.Mprotect(b, unix.PROT_NONE) } -func (m *mappedMemory) lockError() error { return m.err } - -// lock pins a committed range in RAM. The refusal names the limit that -// caused it, so an operator reading the error knows what to raise. -func lock(b []byte) error { +// lockRange pins a committed range in RAM. The refusal names the limit +// that caused it and how to raise it, so an operator reading the error +// has the fix in hand. +func lockRange(b []byte) error { if err := unix.Mlock(b); err != nil { - return fmt.Errorf("locking %s of guest memory: %w (%s)", byteCount(uint64(len(b))), err, memlockLimit()) + return fmt.Errorf("locking %s of guest memory: %w (%s; raise it with ulimit -l, a systemd LimitMEMLOCK=, or a pod securityContext)", byteCount(uint64(len(b))), err, memlockLimit()) } return nil } +func releaseRange(mapping []byte) { + _ = unix.Munmap(mapping) +} + // memlockLimit describes RLIMIT_MEMLOCK for a lock refusal. func memlockLimit() string { var lim unix.Rlimit diff --git a/languages/golang/stackencrypt/memory_windows.go b/languages/golang/stackencrypt/memory_windows.go index ae3d2afa1..0b54c5a93 100644 --- a/languages/golang/stackencrypt/memory_windows.go +++ b/languages/golang/stackencrypt/memory_windows.go @@ -2,78 +2,51 @@ package stackencrypt import ( "fmt" - "math" "unsafe" "golang.org/x/sys/windows" ) -// mappedMemory is the Windows backend, the same shape as the Unix one: -// the declared maximum is reserved once (MEM_RESERVE), committed from the -// front as the guest grows, and each committed range is locked with -// VirtualLock. Windows has no per-mapping dump exclusion. -type mappedMemory struct { - base uintptr - mapping []byte - committed uint64 - strict bool - err error -} +// The Windows primitives behind mappedMemory: the declared maximum is +// reserved once (MEM_RESERVE), committed with MEM_COMMIT, locked with +// VirtualLock, released with MEM_RELEASE. Windows has no per-mapping dump +// exclusion. -func reserveMemory(capacity, max uint64, strict bool) guestMemory { - if max > math.MaxInt { - return newHeapMemory(capacity, max, fmt.Errorf("cannot reserve %s of address space on this host", byteCount(max))) - } +func reserveRange(max int) ([]byte, error) { base, err := windows.VirtualAlloc(0, uintptr(max), windows.MEM_RESERVE, windows.PAGE_NOACCESS) if err != nil { - return newHeapMemory(capacity, max, fmt.Errorf("reserving %s of address space: %w", byteCount(max), err)) + return nil, err } // The reservation is not Go memory; going through unsafe.Add keeps // the conversion within what vet's unsafeptr check accepts. - start := (*byte)(unsafe.Add(unsafe.Pointer(nil), base)) - return &mappedMemory{ - base: base, - mapping: unsafe.Slice(start, int(max)), - strict: strict, - } + return unsafe.Slice((*byte)(unsafe.Add(unsafe.Pointer(nil), base)), max), nil } -func (m *mappedMemory) Reallocate(size uint64) []byte { - if size > uint64(len(m.mapping)) { - return nil - } - if size > m.committed { - fresh := m.mapping[m.committed:size] - start := m.base + uintptr(m.committed) - if _, err := windows.VirtualAlloc(start, uintptr(len(fresh)), windows.MEM_COMMIT, windows.PAGE_READWRITE); err != nil { - return nil - } - if err := windows.VirtualLock(start, uintptr(len(fresh))); err != nil { - if m.err == nil { - // VirtualLock is bounded by the process's minimum working - // set, which defaults to a few hundred KiB: raise it with - // SetProcessWorkingSetSize before NewClient. - m.err = fmt.Errorf("locking %s of guest memory: %w (bounded by the process minimum working set)", byteCount(uint64(len(fresh))), err) - } - if m.strict && m.committed > 0 { // see the Unix backend - _ = windows.VirtualFree(start, uintptr(len(fresh)), windows.MEM_DECOMMIT) - return nil - } - } - m.committed = size - } - return m.mapping[:size:size] +func address(b []byte) uintptr { return uintptr(unsafe.Pointer(unsafe.SliceData(b))) } + +func commitRange(b []byte) error { + _, err := windows.VirtualAlloc(address(b), uintptr(len(b)), windows.MEM_COMMIT, windows.PAGE_READWRITE) + return err +} + +func decommitRange(b []byte) { + _ = windows.VirtualFree(address(b), uintptr(len(b)), windows.MEM_DECOMMIT) } -func (m *mappedMemory) Free() { - if m.mapping == nil { - return +// lockRange pins a committed range in RAM. VirtualLock is bounded by the +// process's minimum working set, which defaults to a few hundred KiB, so +// the refusal says what to raise. +func lockRange(b []byte) error { + if err := windows.VirtualLock(address(b), uintptr(len(b))); err != nil { + return fmt.Errorf("locking %s of guest memory: %w (bounded by the process minimum working set; raise it with SetProcessWorkingSetSize before NewClient)", byteCount(uint64(len(b))), err) } - wipeMapped(m.mapping[:m.committed]) - // MEM_RELEASE frees the whole reservation and drops any lock with it. - _ = windows.VirtualFree(m.base, 0, windows.MEM_RELEASE) - m.mapping = nil - m.committed = 0 + return nil +} + +// releaseRange frees the whole reservation, dropping any lock with it. +func releaseRange(mapping []byte) { + _ = windows.VirtualFree(address(mapping), 0, windows.MEM_RELEASE) } -func (m *mappedMemory) lockError() error { return m.err } +// excludeFromDumps has no Windows equivalent. +func excludeFromDumps([]byte) error { return nil } From 7e8ff9899bb225e43f428d0f0a0f38504e16a92f Mon Sep 17 00:00:00 2001 From: Dan Draper <dan@cipherstash.com> Date: Sun, 20 Sep 2026 09:17:16 -0700 Subject: [PATCH 591/686] fix(go): a plan refuses an index kind given twice, and a nil type NewPlan let a field name the same index kind twice; the guest's FieldPlan::new rejects duplicate outputs, so such a plan built fine and then failed every encrypt and decrypt with an encoding error. The constructor now refuses it, which also covers the tag path (`index=eq;eq`) since PlanFromTags delegates to NewPlan. PlanFromTags(nil) panicked on t.Kind() instead of returning an error; it now returns one before touching the type. --- languages/golang/stackencrypt/record.go | 11 +++++++++-- languages/golang/stackencrypt/unit_test.go | 5 +++++ 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/languages/golang/stackencrypt/record.go b/languages/golang/stackencrypt/record.go index 42d36bb05..22db213db 100644 --- a/languages/golang/stackencrypt/record.go +++ b/languages/golang/stackencrypt/record.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "reflect" + "slices" "strings" "sync" @@ -127,8 +128,8 @@ type planField struct { // NewPlan validates the fields and returns the plan. Every field needs a // Field and a Context; record names (Name, or Field) must be unique; Index -// kinds must be ones this package defines. A plan is built once and reused -// across calls, like the type it describes. +// kinds must be ones this package defines, each at most once per field. A +// plan is built once and reused across calls, like the type it describes. func NewPlan(fields ...PlanField) (Plan, error) { if len(fields) == 0 { return Plan{}, errors.New("stackencrypt: a plan needs at least one field") @@ -151,6 +152,9 @@ func NewPlan(fields ...PlanField) (Plan, error) { if _, ok := parseTermKind(k.String()); !ok { return Plan{}, fmt.Errorf("stackencrypt: plan field %s: unknown index kind %s", f.Field, k) } + if slices.Contains(pf.outputs, k.String()) { + return Plan{}, fmt.Errorf("stackencrypt: plan field %s: index kind %s given twice", f.Field, k) + } pf.outputs = append(pf.outputs, k.String()) } if seen[pf.name] { @@ -185,6 +189,9 @@ var tagPlans sync.Map // reflect.Type → Plan // describe. This is the plan the record calls use when no WithPlan option // is given. func PlanFromTags(t reflect.Type) (Plan, error) { + if t == nil { + return Plan{}, errors.New("stackencrypt: records must be structs, not a nil type") + } if cached, ok := tagPlans.Load(t); ok { return cached.(Plan), nil } diff --git a/languages/golang/stackencrypt/unit_test.go b/languages/golang/stackencrypt/unit_test.go index 062aeeb07..28ea4df87 100644 --- a/languages/golang/stackencrypt/unit_test.go +++ b/languages/golang/stackencrypt/unit_test.go @@ -204,6 +204,10 @@ func TestPlanFromTags(t *testing.T) { }{}, "nothing tagged": struct{ A int }{}, "not a struct": 42, + "nil type": nil, + "index twice": struct { + A int `stash:"context=c,index=eq;eq"` + }{}, "duplicate name": struct { A int `stash:"context=c,name=x"` B int `stash:"context=c,name=x"` @@ -320,6 +324,7 @@ func TestNewPlanValidates(t *testing.T) { "unknown kind": {{Field: "A", Context: "c", Index: []TermKind{TermKind(9)}}}, "duplicate name": {{Field: "A", Context: "c", Name: "x"}, {Field: "B", Context: "c", Name: "x"}}, "field twice": {{Field: "A", Context: "c"}, {Field: "A", Context: "d"}}, + "index twice": {{Field: "A", Context: "c", Index: []TermKind{Equality, Equality}}}, } { if _, err := NewPlan(fields...); err == nil { t.Errorf("%s: plan accepted", name) From e732b04eaea3c9df594945f00bf09e98bce2efee Mon Sep 17 00:00:00 2001 From: Dan Draper <dan@cipherstash.com> Date: Sun, 20 Sep 2026 09:40:05 -0700 Subject: [PATCH 592/686] refactor(go)!: a plan's field is a FieldPlan with Terms, validated once The Go plan mirrors the Rust one by name now: `FieldPlan` (was `PlanField`) and `Terms []TermKind` (was `Index`), the glossary's word for what these are. `index=` in the struct tag is unchanged. Both constructors run one validation: `PlanFromTags` checks the tag grammar and hands the fields to the same `newPlan` that `NewPlan` uses, so a duplicate name, a missing context or a term kind given twice is refused in one place, and the tag path's error names the type as before. `TermKind` spells, parses and validates itself from one table instead of three switches; a plan field keeps its kinds rather than re-parsing its own output spellings. The stale "cache key" sentence on planData is gone: bindings are not cached. Doc comments wrap at 80 columns like the rest of the package. A record decrypted under a plan naming a field it does not carry is refused on the host with the field named, before the guest is asked (pinned without ZeroKMS); the explicit-plan round trip is a live test. BREAKING CHANGE: `stackencrypt.PlanField` is `stackencrypt.FieldPlan`, and its `Index` field is `Terms`. Rename both at each `NewPlan` call. --- languages/golang/stackencrypt/doc.go | 8 +- languages/golang/stackencrypt/guest_test.go | 21 ++- languages/golang/stackencrypt/live_test.go | 55 ++++++++ languages/golang/stackencrypt/record.go | 139 +++++++++++--------- languages/golang/stackencrypt/term.go | 45 +++---- languages/golang/stackencrypt/unit_test.go | 20 +-- 6 files changed, 191 insertions(+), 97 deletions(-) diff --git a/languages/golang/stackencrypt/doc.go b/languages/golang/stackencrypt/doc.go index ada55c9e9..e64dbd302 100644 --- a/languages/golang/stackencrypt/doc.go +++ b/languages/golang/stackencrypt/doc.go @@ -39,10 +39,10 @@ // to produce, and one call seals every row of a slice from batched key // requests. The same plan is a value ([Plan]): [PlanFromTags] is what the // tags parse to, [NewPlan] builds one for a struct that cannot carry tags -// (generated code), and [WithPlan] runs a record call under it. Key requests are batched 500 keys at a time, in both -// directions: one request for any ordinary value or batch, one more per -// 500 sealed leaves beyond that. Terms ([EqualityTerm], [MatchTerm], -// [OreTerm], [OpeTerm]) are +// (generated code), and [WithPlan] runs a record call under it. Key +// requests are batched 500 keys at a time, in both directions: one request +// for any ordinary value or batch, one more per 500 sealed leaves beyond +// that. Terms ([EqualityTerm], [MatchTerm], [OreTerm], [OpeTerm]) are // byte-equal to the ones the Rust crate derives, so a probe from // [Cipher.Term] compares against a stored term from any language. // [Cipher.Term] takes a context and returns an error from day one: term diff --git a/languages/golang/stackencrypt/guest_test.go b/languages/golang/stackencrypt/guest_test.go index ff697a45d..6d3786d9e 100644 --- a/languages/golang/stackencrypt/guest_test.go +++ b/languages/golang/stackencrypt/guest_test.go @@ -502,6 +502,23 @@ type recordRow struct { Email string `stash:"context=users/email,index=eq;match"` } +// A record decrypted under a plan that names a field it does not carry is +// refused on the host, with the field named, before the guest is asked. +func TestMismatchedPlanIsRefusedBeforeTheGuest(t *testing.T) { + ctx := context.Background() + c := rawInstance(t) + record := EncryptedRecord{"Age": {Ciphertext: Sealed(fixtureLeaf)}} + plan, err := NewPlan(FieldPlan{Field: "Email", Name: "email", Context: "users/email"}) + if err != nil { + t.Fatal(err) + } + var out []struct{ Email string } + err = c.DecryptRecords(ctx, []EncryptedRecord{record}, &out, WithPlan(plan)) + if err == nil || errors.Is(err, ErrState) || !strings.Contains(err.Error(), `no ciphertext for field "email"`) { + t.Fatalf("mismatched plan: %v, want the host's refusal naming the field", err) + } +} + // Every encoding the package builds reaches the guest's own parsers and // passes them: the uninitialised instance answers ErrState only after it // has validated all inputs. @@ -524,8 +541,8 @@ func TestGuestAcceptsEveryEncodingThisPackageBuilds(t *testing.T) { Email string } plan, err := NewPlan( - PlanField{Field: "Age", Context: "users/age", Index: []TermKind{Equality, Ore}}, - PlanField{Field: "Email", Context: "users/email", Index: []TermKind{Equality, Match}}, + FieldPlan{Field: "Age", Context: "users/age", Terms: []TermKind{Equality, Ore}}, + FieldPlan{Field: "Email", Context: "users/email", Terms: []TermKind{Equality, Match}}, ) if err != nil { t.Fatal(err) diff --git a/languages/golang/stackencrypt/live_test.go b/languages/golang/stackencrypt/live_test.go index ef95dc176..f43e129f8 100644 --- a/languages/golang/stackencrypt/live_test.go +++ b/languages/golang/stackencrypt/live_test.go @@ -5,6 +5,7 @@ import ( "errors" "os" "reflect" + "strings" "testing" "github.com/cipherstash/vitaminc/bindings/go/vcvalue" @@ -145,6 +146,60 @@ func TestLiveRecordsAndTerms(t *testing.T) { } } +// An explicit plan round-trips a struct that carries no tags, and a record +// is only readable under the plan it was written under. +func TestLiveExplicitPlanRoundTrip(t *testing.T) { + c := liveClient(t) + ctx := t.Context() + cipher := c.DefaultKeyset() + type generated struct { // no tags, as protobuf output has none + Age uint32 + Email string + } + plan, err := NewPlan( + FieldPlan{Field: "Age", Context: "users/age", Terms: []TermKind{Equality, Ore}}, + FieldPlan{Field: "Email", Context: "users/email", Terms: []TermKind{Equality, Match}}, + ) + if err != nil { + t.Fatal(err) + } + users := []generated{{34, "alice@example.com"}, {29, "bob@example.com"}} + + records, err := cipher.EncryptRecords(ctx, users, WithPlan(plan)) + if err != nil { + t.Fatal(err) + } + if len(records) != 2 || len(records[0]["Age"].Equality) != 32 || records[0]["Email"].Match == nil { + t.Fatalf("records = %+v", records) + } + var back []generated + if err := cipher.DecryptRecords(ctx, records, &back, WithPlan(plan)); err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(back, users) { + t.Fatalf("decrypted %+v, want %+v", back, users) + } + var one generated + if err := c.DecryptRecord(ctx, records[1], &one, WithPlan(plan)); err != nil || one.Email != "bob@example.com" { + t.Fatalf("DecryptRecord: %v %+v", err, one) + } + + // A plan naming a field the record does not carry is refused before + // any key is requested. + other, err := NewPlan(FieldPlan{Field: "Email", Name: "email", Context: "users/email"}) + if err != nil { + t.Fatal(err) + } + c.transport.sends.Store(0) + err = cipher.DecryptRecords(ctx, records, &back, WithPlan(other)) + if err == nil || !strings.Contains(err.Error(), `no ciphertext for field "email"`) { + t.Fatalf("mismatched plan: %v", err) + } + if n := c.transport.sends.Load(); n != 0 { + t.Errorf("mismatched plan made %d ZeroKMS calls, want 0", n) + } +} + func TestLiveForeignKeysetIsRefusedBeforeRetrieval(t *testing.T) { c := liveClient(t) ctx := t.Context() diff --git a/languages/golang/stackencrypt/record.go b/languages/golang/stackencrypt/record.go index 22db213db..19d3c5179 100644 --- a/languages/golang/stackencrypt/record.go +++ b/languages/golang/stackencrypt/record.go @@ -38,11 +38,25 @@ import ( // The same plan, built by hand: // // plan, err := stackencrypt.NewPlan( -// stackencrypt.PlanField{Field: "Age", Context: "users/age", Index: []stackencrypt.TermKind{stackencrypt.Equality, stackencrypt.Ore}}, -// stackencrypt.PlanField{Field: "Email", Context: "users/email", Index: []stackencrypt.TermKind{stackencrypt.Equality, stackencrypt.Match}}, -// stackencrypt.PlanField{Field: "Notes", Context: "users/notes"}, +// stackencrypt.FieldPlan{ +// Field: "Age", +// Context: "users/age", +// Terms: []stackencrypt.TermKind{ +// stackencrypt.Equality, stackencrypt.Ore, +// }, +// }, +// stackencrypt.FieldPlan{ +// Field: "Email", +// Context: "users/email", +// Terms: []stackencrypt.TermKind{ +// stackencrypt.Equality, stackencrypt.Match, +// }, +// }, +// stackencrypt.FieldPlan{Field: "Notes", Context: "users/notes"}, +// ) +// records, err := cipher.EncryptRecords( +// ctx, users, stackencrypt.WithPlan(plan), // ) -// records, err := cipher.EncryptRecords(ctx, users, stackencrypt.WithPlan(plan)) // // Every planned field is sealed (the `"c"` output). What the guest receives // is the same object whichever way the plan was built. The context each @@ -90,8 +104,8 @@ func WithPlan(p Plan) RecordOption { return func(o *recordOptions) { o.plan = p } } -// PlanField is one planned field of a record. -type PlanField struct { +// FieldPlan is one planned field of a record. +type FieldPlan struct { // Field is the Go struct field name. It must be exported. Field string // Name is the record key the field's outputs are stored under: the @@ -100,65 +114,85 @@ type PlanField struct { // Context is the field's own encryption context, a string part; the // record call extends it by any ExtendContext parts. Required. Context string - // Index lists the terms to derive beside the ciphertext, in order. - Index []TermKind + // Terms lists the terms to derive beside the ciphertext, in order. + Terms []TermKind } // Plan is a record plan: which fields of a struct to seal, under which -// context, with which index terms. It is an immutable value; the zero Plan +// context, with which terms. It is an immutable value; the zero Plan // means "the struct's tags". Build one with [NewPlan] or [PlanFromTags]. type Plan struct { d *planData } -// planData is the validated, shared body of a Plan. Its identity is the -// cache key for bindings, so it is never mutated after NewPlan returns. +// planData is the validated, shared body of a Plan. Every copy of the Plan +// points at the same body, so it is never mutated after NewPlan returns. type planData struct { fields []planField } -// planField is a validated PlanField: outputs already spelled the way the -// guest reads them. +// planField is a validated FieldPlan: Name filled in, every term kind +// known and named once. type planField struct { field string name string context string - outputs []string + terms []TermKind +} + +// outputs spells the field's outputs the way the guest reads them: the +// ciphertext first, then each term. +func (f planField) outputs() []string { + out := make([]string, 1, 1+len(f.terms)) + out[0] = "c" + for _, k := range f.terms { + out = append(out, k.String()) + } + return out } // NewPlan validates the fields and returns the plan. Every field needs a -// Field and a Context; record names (Name, or Field) must be unique; Index -// kinds must be ones this package defines, each at most once per field. A -// plan is built once and reused across calls, like the type it describes. -func NewPlan(fields ...PlanField) (Plan, error) { +// Field and a Context; record names (Name, or Field) must be unique; Terms +// must be kinds this package defines, each at most once per field. A plan +// is built once and reused across calls, like the type it describes. +func NewPlan(fields ...FieldPlan) (Plan, error) { + p, err := newPlan(fields) + if err != nil { + return Plan{}, fmt.Errorf("stackencrypt: %w", err) + } + return p, nil +} + +// newPlan is the one validation both constructors go through; its errors +// name the field, and the caller adds the prefix and, for tags, the type. +func newPlan(fields []FieldPlan) (Plan, error) { if len(fields) == 0 { - return Plan{}, errors.New("stackencrypt: a plan needs at least one field") + return Plan{}, errors.New("a plan needs at least one field") } d := &planData{fields: make([]planField, 0, len(fields))} seen := make(map[string]bool, len(fields)) for _, f := range fields { if f.Field == "" { - return Plan{}, errors.New("stackencrypt: plan field without a Field name") + return Plan{}, errors.New("plan field without a Field name") } if f.Context == "" { - return Plan{}, fmt.Errorf("stackencrypt: plan field %s: a planned field needs a context", f.Field) + return Plan{}, fmt.Errorf("plan field %s: a planned field needs a context", f.Field) } - pf := planField{field: f.Field, name: f.Field, context: f.Context, outputs: make([]string, 1, 1+len(f.Index))} - pf.outputs[0] = "c" + pf := planField{field: f.Field, name: f.Field, context: f.Context} if f.Name != "" { pf.name = f.Name } - for _, k := range f.Index { - if _, ok := parseTermKind(k.String()); !ok { - return Plan{}, fmt.Errorf("stackencrypt: plan field %s: unknown index kind %s", f.Field, k) + for _, k := range f.Terms { + if !k.valid() { + return Plan{}, fmt.Errorf("plan field %s: unknown term kind %s", f.Field, k) } - if slices.Contains(pf.outputs, k.String()) { - return Plan{}, fmt.Errorf("stackencrypt: plan field %s: index kind %s given twice", f.Field, k) + if slices.Contains(pf.terms, k) { + return Plan{}, fmt.Errorf("plan field %s: term kind %s given twice", f.Field, k) } - pf.outputs = append(pf.outputs, k.String()) + pf.terms = append(pf.terms, k) } if seen[pf.name] { - return Plan{}, fmt.Errorf("stackencrypt: two plan fields share the record name %q", pf.name) + return Plan{}, fmt.Errorf("two plan fields share the record name %q", pf.name) } seen[pf.name] = true d.fields = append(d.fields, pf) @@ -168,17 +202,13 @@ func NewPlan(fields ...PlanField) (Plan, error) { // Fields returns the plan's fields, in order, as they were given to NewPlan // (Name filled in). A copy: mutating it does not touch the plan. -func (p Plan) Fields() []PlanField { +func (p Plan) Fields() []FieldPlan { if p.d == nil { return nil } - out := make([]PlanField, len(p.d.fields)) + out := make([]FieldPlan, len(p.d.fields)) for i, f := range p.d.fields { - out[i] = PlanField{Field: f.field, Name: f.name, Context: f.context} - for _, o := range f.outputs[1:] { - k, _ := parseTermKind(o) - out[i].Index = append(out[i].Index, k) - } + out[i] = FieldPlan{Field: f.field, Name: f.name, Context: f.context, Terms: slices.Clone(f.terms)} } return out } @@ -187,7 +217,8 @@ var tagPlans sync.Map // reflect.Type → Plan // PlanFromTags parses (and caches) the plan a struct type's `stash` tags // describe. This is the plan the record calls use when no WithPlan option -// is given. +// is given. The tag grammar is checked here; what the fields mean is +// checked by the same validation NewPlan runs. func PlanFromTags(t reflect.Type) (Plan, error) { if t == nil { return Plan{}, errors.New("stackencrypt: records must be structs, not a nil type") @@ -198,8 +229,7 @@ func PlanFromTags(t reflect.Type) (Plan, error) { if t.Kind() != reflect.Struct { return Plan{}, fmt.Errorf("stackencrypt: records must be structs, not %s", t) } - var fields []PlanField - seen := map[string]bool{} + var fields []FieldPlan for i := 0; i < t.NumField(); i++ { f := t.Field(i) if !f.IsExported() { @@ -209,14 +239,11 @@ func PlanFromTags(t reflect.Type) (Plan, error) { if !ok || tag == "-" || tag == "plain" { continue } - pf := PlanField{Field: f.Name, Name: f.Name} + pf := FieldPlan{Field: f.Name, Name: f.Name} for _, opt := range strings.Split(tag, ",") { key, value, _ := strings.Cut(opt, "=") switch key { case "context": - if value == "" { - return Plan{}, fmt.Errorf("stackencrypt: field %s.%s: context must not be empty", t, f.Name) - } pf.Context = value case "name": if value == "" { @@ -227,29 +254,22 @@ func PlanFromTags(t reflect.Type) (Plan, error) { for _, k := range strings.Split(value, ";") { kind, ok := parseTermKind(k) if !ok { - return Plan{}, fmt.Errorf("stackencrypt: field %s.%s: unknown index kind %q", t, f.Name, k) + return Plan{}, fmt.Errorf("stackencrypt: field %s.%s: unknown term kind %q", t, f.Name, k) } - pf.Index = append(pf.Index, kind) + pf.Terms = append(pf.Terms, kind) } default: return Plan{}, fmt.Errorf("stackencrypt: field %s.%s: unknown stash tag option %q", t, f.Name, opt) } } - if pf.Context == "" { - return Plan{}, fmt.Errorf("stackencrypt: field %s.%s: a planned field needs context=", t, f.Name) - } - if seen[pf.Name] { - return Plan{}, fmt.Errorf("stackencrypt: %s: two fields share the record name %q", t, pf.Name) - } - seen[pf.Name] = true fields = append(fields, pf) } if len(fields) == 0 { return Plan{}, fmt.Errorf("stackencrypt: %s has no fields tagged for encryption", t) } - plan, err := NewPlan(fields...) + plan, err := newPlan(fields) if err != nil { - return Plan{}, err + return Plan{}, fmt.Errorf("stackencrypt: %s: %w", t, err) } tagPlans.Store(t, plan) return plan, nil @@ -277,7 +297,7 @@ func (p Plan) bind(t reflect.Type) ([]fieldPlan, error) { if !ok || !sf.IsExported() || len(sf.Index) != 1 { return nil, fmt.Errorf("stackencrypt: plan field %s is not an exported field of %s", f.field, t) } - bound[i] = fieldPlan{index: sf.Index[0], name: f.name, context: f.context, outputs: f.outputs} + bound[i] = fieldPlan{index: sf.Index[0], name: f.name, context: f.context, outputs: f.outputs()} } return bound, nil } @@ -330,9 +350,10 @@ func applyOptions(opts []RecordOption) recordOptions { } // EncryptRecords seals every row of a slice of structs (or a pointer to -// one) per the struct's `stash` tags, or per [WithPlan]: all rows and fields from batched -// ZeroKMS key requests (one per 500 sealed fields), terms derived under -// this keyset's index key. One EncryptedRecord per row, in order. +// one) per the struct's `stash` tags, or per [WithPlan]: all rows and +// fields from batched ZeroKMS key requests (one per 500 sealed fields), +// terms derived under this keyset's index key. One EncryptedRecord per +// row, in order. func (cph *Cipher) EncryptRecords(ctx context.Context, rows any, opts ...RecordOption) ([]EncryptedRecord, error) { v := reflect.Indirect(reflect.ValueOf(rows)) if !v.IsValid() || v.Kind() != reflect.Slice { diff --git a/languages/golang/stackencrypt/term.go b/languages/golang/stackencrypt/term.go index df6fdc38b..fbf38436e 100644 --- a/languages/golang/stackencrypt/term.go +++ b/languages/golang/stackencrypt/term.go @@ -25,35 +25,36 @@ const ( Ope TermKind = 4 ) +// termKindNames is the one table of plan-tag spellings: TermKind.String, +// parseTermKind and TermKind.valid all read it. +var termKindNames = map[TermKind]string{ + Equality: "eq", + Match: "match", + Ore: "ore", + Ope: "ope", +} + func (k TermKind) String() string { - switch k { - case Equality: - return "eq" - case Match: - return "match" - case Ore: - return "ore" - case Ope: - return "ope" - default: - return fmt.Sprintf("TermKind(%d)", uint32(k)) + if name, ok := termKindNames[k]; ok { + return name } + return fmt.Sprintf("TermKind(%d)", uint32(k)) +} + +// valid reports whether k is a kind this package defines. +func (k TermKind) valid() bool { + _, ok := termKindNames[k] + return ok } // parseTermKind maps a plan-tag spelling to its kind. func parseTermKind(s string) (TermKind, bool) { - switch s { - case "eq": - return Equality, true - case "match": - return Match, true - case "ore": - return Ore, true - case "ope": - return Ope, true - default: - return 0, false + for k, name := range termKindNames { + if name == s { + return k, true + } } + return 0, false } // EqualityTerm is a PRF equality term. Two terms derived under the same diff --git a/languages/golang/stackencrypt/unit_test.go b/languages/golang/stackencrypt/unit_test.go index 28ea4df87..ef3f8f8f5 100644 --- a/languages/golang/stackencrypt/unit_test.go +++ b/languages/golang/stackencrypt/unit_test.go @@ -205,7 +205,7 @@ func TestPlanFromTags(t *testing.T) { "nothing tagged": struct{ A int }{}, "not a struct": 42, "nil type": nil, - "index twice": struct { + "term twice": struct { A int `stash:"context=c,index=eq;eq"` }{}, "duplicate name": struct { @@ -224,9 +224,9 @@ func TestPlanFromTags(t *testing.T) { func TestExplicitPlanIsTheTagPlan(t *testing.T) { typ := reflect.TypeOf(taggedUser{}) explicit, err := NewPlan( - PlanField{Field: "Age", Context: "users/age", Index: []TermKind{Equality, Ore}}, - PlanField{Field: "Email", Name: "email", Context: "users/email", Index: []TermKind{Equality, Match}}, - PlanField{Field: "Notes", Context: "users/notes"}, + FieldPlan{Field: "Age", Context: "users/age", Terms: []TermKind{Equality, Ore}}, + FieldPlan{Field: "Email", Name: "email", Context: "users/email", Terms: []TermKind{Equality, Match}}, + FieldPlan{Field: "Notes", Context: "users/notes"}, ) if err != nil { t.Fatal(err) @@ -287,7 +287,7 @@ func TestPlanBindsByFieldName(t *testing.T) { if _, err := PlanFromTags(typ); err == nil { t.Fatal("untagged struct has a tag plan") } - ok, err := NewPlan(PlanField{Field: "Email", Context: "c"}) + ok, err := NewPlan(FieldPlan{Field: "Email", Context: "c"}) if err != nil { t.Fatal(err) } @@ -303,7 +303,7 @@ func TestPlanBindsByFieldName(t *testing.T) { "unexported": "hidden", "promoted": "Inner", } { - p, err := NewPlan(PlanField{Field: field, Context: "c"}) + p, err := NewPlan(FieldPlan{Field: field, Context: "c"}) if err != nil { t.Fatal(err) } @@ -316,15 +316,15 @@ func TestPlanBindsByFieldName(t *testing.T) { } } -func TestNewPlanValidates(t *testing.T) { - for name, fields := range map[string][]PlanField{ +func TestNewPlanRefusesMalformedFields(t *testing.T) { + for name, fields := range map[string][]FieldPlan{ "no fields": nil, "no field name": {{Context: "c"}}, "no context": {{Field: "A"}}, - "unknown kind": {{Field: "A", Context: "c", Index: []TermKind{TermKind(9)}}}, + "unknown kind": {{Field: "A", Context: "c", Terms: []TermKind{TermKind(9)}}}, "duplicate name": {{Field: "A", Context: "c", Name: "x"}, {Field: "B", Context: "c", Name: "x"}}, "field twice": {{Field: "A", Context: "c"}, {Field: "A", Context: "d"}}, - "index twice": {{Field: "A", Context: "c", Index: []TermKind{Equality, Equality}}}, + "term twice": {{Field: "A", Context: "c", Terms: []TermKind{Equality, Equality}}}, } { if _, err := NewPlan(fields...); err == nil { t.Errorf("%s: plan accepted", name) From 225519f4b9e378652f4d64592e66f29566917ab9 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Sun, 20 Sep 2026 17:01:13 +0000 Subject: [PATCH 593/686] fix(go): a plan refuses a Go field planned twice, whatever its record names NewPlan only checked record names for uniqueness, so two FieldPlans naming the same Go field under distinct Names were accepted; on decrypt both ciphertexts would be written into one struct field and the later would silently win. The Go field name is now checked on its own, beside the record name, and the "field twice" test gives its two entries distinct Names so it covers exactly that case. WithPlan's doc said records must be decrypted under "the same plan (field names, contexts and outputs)". Decryption only needs the record Names, Contexts (with the same ExtendContext parts) and the set of ciphertext-bearing fields; Field just picks the local struct field and Terms are never sent to decrypt. The doc now says that, since a record encrypted from a generated struct may be decrypted into a domain struct. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WvDPUnSa9mXLYPCqwHLzhf --- languages/golang/stackencrypt/record.go | 31 +++++++++++++++------- languages/golang/stackencrypt/unit_test.go | 2 +- 2 files changed, 23 insertions(+), 10 deletions(-) diff --git a/languages/golang/stackencrypt/record.go b/languages/golang/stackencrypt/record.go index 19d3c5179..1d28acd91 100644 --- a/languages/golang/stackencrypt/record.go +++ b/languages/golang/stackencrypt/record.go @@ -97,9 +97,16 @@ func ExtendContext(parts ...any) RecordOption { } // WithPlan encrypts or decrypts records under an explicit plan instead of -// the struct's `stash` tags. Records encrypted under a plan must be -// decrypted under the same plan (field names, contexts and outputs), the -// same way tags must not change between the two. +// the struct's `stash` tags. +// +// What decryption needs from the encrypting plan is what names and keys +// the ciphertext: each field's record Name, its Context (extended by the +// same [ExtendContext] parts), and the set of fields that carry a +// ciphertext. Field only selects which Go field the plaintext is written +// to, so it may differ between the two sides: a record encrypted from a +// generated struct may be decrypted into a domain struct under a plan +// with the same Names and Contexts. Terms are one-way outputs, derived on +// encryption and never sent to decrypt, so they need not match either. func WithPlan(p Plan) RecordOption { return func(o *recordOptions) { o.plan = p } } @@ -152,9 +159,10 @@ func (f planField) outputs() []string { } // NewPlan validates the fields and returns the plan. Every field needs a -// Field and a Context; record names (Name, or Field) must be unique; Terms -// must be kinds this package defines, each at most once per field. A plan -// is built once and reused across calls, like the type it describes. +// Field and a Context; Go field names must be unique, and so must record +// names (Name, or Field); Terms must be kinds this package defines, each +// at most once per field. A plan is built once and reused across calls, +// like the type it describes. func NewPlan(fields ...FieldPlan) (Plan, error) { p, err := newPlan(fields) if err != nil { @@ -170,11 +178,16 @@ func newPlan(fields []FieldPlan) (Plan, error) { return Plan{}, errors.New("a plan needs at least one field") } d := &planData{fields: make([]planField, 0, len(fields))} - seen := make(map[string]bool, len(fields)) + seenField := make(map[string]bool, len(fields)) + seenName := make(map[string]bool, len(fields)) for _, f := range fields { if f.Field == "" { return Plan{}, errors.New("plan field without a Field name") } + if seenField[f.Field] { + return Plan{}, fmt.Errorf("plan field %s: the Go field is planned twice", f.Field) + } + seenField[f.Field] = true if f.Context == "" { return Plan{}, fmt.Errorf("plan field %s: a planned field needs a context", f.Field) } @@ -191,10 +204,10 @@ func newPlan(fields []FieldPlan) (Plan, error) { } pf.terms = append(pf.terms, k) } - if seen[pf.name] { + if seenName[pf.name] { return Plan{}, fmt.Errorf("two plan fields share the record name %q", pf.name) } - seen[pf.name] = true + seenName[pf.name] = true d.fields = append(d.fields, pf) } return Plan{d: d}, nil diff --git a/languages/golang/stackencrypt/unit_test.go b/languages/golang/stackencrypt/unit_test.go index ef3f8f8f5..a0519f736 100644 --- a/languages/golang/stackencrypt/unit_test.go +++ b/languages/golang/stackencrypt/unit_test.go @@ -323,7 +323,7 @@ func TestNewPlanRefusesMalformedFields(t *testing.T) { "no context": {{Field: "A"}}, "unknown kind": {{Field: "A", Context: "c", Terms: []TermKind{TermKind(9)}}}, "duplicate name": {{Field: "A", Context: "c", Name: "x"}, {Field: "B", Context: "c", Name: "x"}}, - "field twice": {{Field: "A", Context: "c"}, {Field: "A", Context: "d"}}, + "field twice": {{Field: "A", Name: "x", Context: "c"}, {Field: "A", Name: "y", Context: "d"}}, "term twice": {{Field: "A", Context: "c", Terms: []TermKind{Equality, Equality}}}, } { if _, err := NewPlan(fields...); err == nil { From 53d580172f4d713f5cb954c157124ba5bb492494 Mon Sep 17 00:00:00 2001 From: Dan Draper <dan@cipherstash.com> Date: Sun, 20 Sep 2026 11:07:56 -0700 Subject: [PATCH 594/686] docs(stack-encrypt): ADR-0005, a separate credential guest for the profile and auth crates The Go binding gets stack-profile and stack-auth through a second WASI module rather than the crypto guest widened: one mounted directory, no environment, the refresh lock held by Go around the whole refresh export on the path the guest names. Decided after a spike that ran the real stack-profile crate under wazero with a profile mounted; the ADR records what the spike found and every option considered. The Go bindings plan gains the sequencing: profile half first, then the transport seam in stack-auth, then the strategies, with CI on three platforms from one wasm build. --- docs/plans/stack-encrypt-go-bindings.md | 51 ++++ ...edential-guest-for-the-profile-and-auth.md | 225 ++++++++++++++++++ 2 files changed, 276 insertions(+) create mode 100644 packages/stack-encrypt/docs/adr/0005-a-separate-credential-guest-for-the-profile-and-auth.md diff --git a/docs/plans/stack-encrypt-go-bindings.md b/docs/plans/stack-encrypt-go-bindings.md index 688387322..f7fe6b8d4 100644 --- a/docs/plans/stack-encrypt-go-bindings.md +++ b/docs/plans/stack-encrypt-go-bindings.md @@ -518,6 +518,57 @@ probe, _ := cipher.Term(ctx, uint32(34), "users/age", stackencrypt.Equality) — this monorepo is private, so the proof's module path is temporary. - Retire `goencryption`'s cgo static-library matrix once parity is reached. +## Credential guest — `stack-profile` and `stack-auth` for Go + +**Status:** decided 2026-09-20, not started. The decision and its rationale +are [ADR-0005](../../packages/stack-encrypt/docs/adr/0005-a-separate-credential-guest-for-the-profile-and-auth.md); +this section is the sequencing only. + +Go gets the profile and auth crates through a **second** WASI module, the +credential guest, in its own package `stackauth`. The crypto guest is not +widened: it keeps no filesystem and no environment. The credential guest is +given one mounted directory (the profile root, at a fixed guest path) and, +once the auth half lands, the same `cipherstash_transport` import the crypto +guest has. The cross-process refresh lock stays on the Go side, taken around +the whole refresh export with the same `flock` / `LockFileEx` the CLI uses, +on a path the guest names. + +The steps, in order; each is a Linear issue under CIP-3764, with the +blocked-by relations set there: + +1. **`stack-profile` builds for wasm32-wasip1.** Two gates found by the + spike: `gethostname` has no wasip1 body (the creating half of + `DeviceIdentity` becomes native-only; provisioning is CLI territory), and + `std::process::id()` in the atomic write's temp name aborts the module. + Plus a public accessor for the lock file's path, and the crate joins + `wasm:wasi-check`. +2. **Shared guest ABI crate** (CIP-3997): allocator, buffer registry, the + one status table both guests use, and the transport import, extracted + into `packages/stack-guest-abi` before the second guest is written. +3. **One Go module at `bindings/go`** with an `internal` package for the + locked guest memory (CIP-4111), the status decoder and the opaque + `ClientKey` type, which both public packages expose as an alias. + `stackencrypt.Config.ClientKey` becomes that type. +4. **Credential guest + `stackauth`, profile half** (CIP-4053): the full + napi profile surface as methods on `ProfileStore`, a `TokenSource` that + re-reads the auth file per call and refuses at the real expiry, and tests + pinning wazero's 0600 create mode and the outside-mount refusal. +5. **Transport seam in `stack-auth`**: a trait mirroring the host import + (method, URL, headers, body → status, headers, body), reqwest behind + `http`, the guest import as the other impl. This is what CIP-3553 + anticipated and is the long pole. +6. **Strategies in the guest** (CIP-4054): access key, device session, OIDC + federation (Go callback for the IdP token) and auto, with the detection + order run in Go against the environment Go owns. Exchanges tested against + an in-process `httptest` server, including two goroutines racing a refresh + under the lock. +7. **CI on three platforms**: Linux builds both guests once; macOS and + Windows runners take the artifacts and run both packages' suites. Needed + before step 6 ships, since the lock has a Windows implementation. + +Steps 1, 2, 5 and 7 have no prerequisites among themselves and can run in +parallel. Step 3 stacks on CIP-4111. + ## Decisions to make first 1. **Where the Go FFI codec lives.** `vcvalue`'s README deliberately diff --git a/packages/stack-encrypt/docs/adr/0005-a-separate-credential-guest-for-the-profile-and-auth.md b/packages/stack-encrypt/docs/adr/0005-a-separate-credential-guest-for-the-profile-and-auth.md new file mode 100644 index 000000000..81fcf1fd7 --- /dev/null +++ b/packages/stack-encrypt/docs/adr/0005-a-separate-credential-guest-for-the-profile-and-auth.md @@ -0,0 +1,225 @@ +--- +status: accepted +date: 2026-09-20 +--- + +# A separate credential guest for the profile and auth crates + +The Go binding reaches Rust through one WASI module, the crypto guest, run by +wazero with `CGO_ENABLED=0`. That guest has no filesystem and no environment: +its module config grants a name, a random source and two clocks, and its only +routes out are the two host imports the Go side owns — an HTTP transport and a +token source. The napi pattern the Node bindings use (a cdylib per crate) does +not carry over, because a cdylib needs cgo. + +Go therefore has neither `stack-profile` nor `stack-auth`. Anything that needs +the developer profile re-derives its on-disk layout by hand, and the only +`TokenSource` implementations are escape hatches. This ADR records where those +two crates run for Go, and why it is a second module rather than the first one +widened. + +## The problem + +CIP-4053 lays out three mechanisms. Extend the crypto guest and mount the +profile directory into it. Build a second, separate module for the credential +crates. Reimplement both in Go and pin the on-disk layout with a conformance +test. + +Two constraints decide it, and they pull in different directions. The crypto +guest's sandbox is a clean property today: a bug or compromise inside it +cannot read credentials off disk, because it cannot read anything off disk. +Mounting the profile into that module trades the property away, and it is the +module that handles plaintext and data keys. Against that, `stack-auth`'s +refresh path guards the token exchange with a cross-process file lock and +re-reads the token after acquiring it, because the identity provider rotates +refresh tokens and detects replay: two processes that both post the same +refresh token get the whole chain revoked. A Go reimplementation that got that +wrong would break a developer's login from a second terminal in a way that +looks like a server fault. + +A spike (2026-09-20) ran the real `stack-profile` crate as a wasip1 module +under wazero with a temporary profile mounted, and settled the facts the +decision rests on: + +- The crate compiles for wasm32-wasip1 with two gates. The `gethostname` + dependency has no wasip1 body, and the atomic write embeds + `std::process::id()` in its temp filename, which aborts the module rather + than returning an error. Nothing else changed. `stack-auth` with default + features off already builds, since the crypto guest depends on it. +- Every operation the napi binding exposes worked through the mount: current + workspace, listing, typed loads, atomic rewrite, directory creation. A read + outside the mount was refused. `CS_CONFIG_PATH` reached the guest through + wazero's environment config; the home directory did not exist on wasi. +- Every file the guest created came out mode 0600 and every directory 0700, + under a host umask of 022. The crate's own mode handling is `cfg(unix)` and + was skipped; the mode comes from wazero, which passes 0600 on every create. +- `File::lock` returned "operation not supported on this platform" cleanly. + WASI preview 1 has no file locking at all. +- Outside tests, `reqwest` appears in six call sites of one shape: post a form + or JSON body, read status and body back, plus two error conversions. That + is the shape of the crypto guest's existing `transport_send` import. + +## Decision + +### 1. One credential guest, with one mount and no environment + +`stack-profile` and `stack-auth` compile into a second WASI module, the +credential guest, embedded in its own Go package. wazero mounts exactly one +directory into it, the profile root, at a fixed guest path. The guest is given +no environment: the Go side resolves `CS_CONFIG_PATH` and the home directory +the way `ProfileStore::resolve` does, mounts the result, and constructs the +store with the guest path explicitly. The crypto guest is unchanged. + +The two modules have different blast radii, and the split is what makes that +true. The credential guest can reach one directory of credentials and, once +the auth half lands, HTTP. The crypto guest can reach neither. + +### 2. The lock stays on the host, around the whole refresh call + +WASI preview 1 cannot lock a file, so no wasm-hosted refresher can hold the +lock itself. But the discipline the refresher needs is "acquire, re-read from +disk, exchange, write, release", and that is satisfied if the Go side takes +the lock and only then calls into the guest: the guest reads the profile at +call time, after acquisition, by construction. + +So the guest has no lock calls at all. Go takes the same lock the Rust CLI +takes — `flock(LOCK_EX)` on Unix, `LockFileEx` with the exclusive flag over +offset zero and a length of all ones on Windows, on the sibling lock file the +guest names — around the device-session refresh export, and nowhere else. The +refresher's wasm32 arm documents that the host holds it. No lock state crosses +the ABI, no guest code path can forget to release, and the import surface +stays filesystem plus transport. Go never spells a profile path: the lock +file's path comes from a `stack-profile` accessor exposed through the guest. + +Go acquires with a try-lock and backoff under the caller's context, where the +Rust CLI blocks. Mutual exclusion is identical; only who gives up first +differs, and a library that blocks a Go application indefinitely on a wedged +lock holder is a wedged process. + +### 3. Nothing the guest cannot do is faked + +The process id and the hostname are compiled out on wasm32, not stubbed. In +particular the creating half of `DeviceIdentity::load_or_create` is +native-only: its only production caller is the client-provisioning step at +login, which is CLI territory, and the refresh path takes the device instance +id from a claim in the token it is refreshing, not from the file. A guest that +could create an identity named after a fake hostname would be worse than one +that cannot create one. The read-only `load` stays. + +The 0600 mode on created files is wazero's default, not the crate's code. It +is the right outcome, but it holds by a property of the runtime, so the Go +side pins it with a test alongside the outside-mount refusal, the same way the +crypto guest pins its import surface. + +### 4. One Go module, one package, shared plumbing behind `internal` + +The Go module moves up to `bindings/go`, so it contains `stackencrypt`, +`stackauth` and an internal package both import. `stackauth` is one package +over the one guest, with `ProfileStore` and the Rust type names inside it; +nobody uses the profile without auth, and two packages over one embedded guest +would be two packages that must agree on one instance. Neither public package +imports the other. + +The internal package holds the locked, non-dumpable guest memory from +CIP-4111, which the credential guest gets from day one since it holds the +client key and tokens; the decoder for the status table; and the opaque +`ClientKey` type. Both public packages expose that type as an alias, so +`stackauth.ClientKey` and `stackencrypt.ClientKey` are one type by identity +without either package depending on the other, and a binary that only wants +the profile does not carry the crypto guest. + +On the Rust side the guest ABI plumbing — allocator, buffer registry, status +table, the `cipherstash_transport` host import — is extracted into a +workspace crate, `stack-guest-abi`, `publish = false`, before the second guest +is written. Two consumers is the trigger CIP-3997 was waiting for, and copying +would mean making the memory-hygiene fixes twice. The status table is one +numbering for both guests: existing numbers keep their values, and the +credential guest's profile codes append. + +### 5. Secrets: the client key is opaque and wiped; tokens stay strings + +`Config.ClientKey` becomes the opaque type rather than a string. A string is +immutable and unwipeable, and a byte slice prints its contents under `%v`. +The opaque type has a redacted `String` and `GoString`, is handed out by +`stackauth`'s typed read, and is consumed and wiped by `stackencrypt` once it +has marshalled the config. Nothing has shipped, so this is a change, not a +breaking one. + +Bearer tokens stay strings, and `TokenSource` keeps its name and shape. The +name is the `golang.org/x/oauth2` idiom every Go developer already knows, and +its ecosystem is strings end to end; a token is hours-lived and already +crosses TLS as text, where the client key is key material that lives for the +process. A one-function adapter from an x/oauth2 token source ships with the +package. + +Until refresh lands, the profile-backed `TokenSource` re-reads the auth file +on every call, so a login or refresh by the CLI in another terminal is picked +up without a restart, and it refuses a token at its real expiry timestamp +with an error that names `stash auth login`. The 90-second refresh-ahead +margin belongs to refreshing and applies there once it exists. + +### 6. The profile half ships first; the transport seam gates the auth half + +Sequence: the `stack-profile` wasm32 gates and lock-path accessor; the shared +ABI crate; the module move and internal package, stacked on CIP-4111; the +credential guest and `stackauth` with the full napi profile surface. That +closes CIP-4053 and unblocks the env-plus-profile part of CIP-4052. + +Then `stack-auth` gains a transport trait mirroring the host import exactly — +method, URL, headers and body in, status, headers and body out, bytes, no +streaming — with reqwest as one implementation behind the `http` feature and +the guest's import as the other, using the crate's existing async-trait +convention. Then the strategies run inside the guest: access key, device +session, OIDC federation with a Go callback for the identity-provider token, +and auto. `AutoStrategy`'s detection order runs in Go against the environment +Go already owns, pinned against the Rust order by a test; the guest stays +environment-free and exposes typed constructors. + +The token exchanges are tested against an in-process `httptest` server, +since HTTP goes through the Go host: exact request bodies, the error +taxonomy, and two goroutines racing a refresh under the lock. + +### 7. Three platforms, one wasm build + +The lock has Unix and Windows implementations from day one, since the CLI's +own lock works on both and a developer sharing a profile with the CLI is the +replay scenario the lock exists for. CI builds both guests once on Linux and +hands the artifacts to macOS and Windows runners that run both packages' Go +suites. + +## Considered options + +**Extend the crypto guest.** Rejected. It mounts a directory of credentials +into the module that handles plaintext and data keys, trading away a sandbox +property that is currently clean, to save one module. + +**Reimplement in Go.** Rejected. The lock has to be Go's regardless, so what +a Rust guest buys is one implementation of the on-disk layout, the token +wire protocol, the error taxonomy and expiry parsing. The layout has moved +once already, and the expiry bugs in CIP-3233 and CIP-3238 are exactly the +drift a Go copy would reintroduce. + +**Lock as a host import.** Rejected. The guest would call acquire and release +from inside `refresh`, so lock state crosses the ABI and a guest path can +forget to release. Wrapping the export gives the same ordering with no new +import. + +**Environment into the guest, by allowlist.** Rejected. Credential resolution +is Go's under CIP-4052 anyway, and a guest with zero environment and one +mount is easier to reason about and to pin than one with a list. + +**Two Go packages mirroring the two crates.** Rejected, as decision 4 says. + +## Consequences + +Two modules to build, embed and assert the import surface of. The memory +hygiene work covers both. `stack-profile` gains two wasm32 gates, joins the +`wasi-check` task, and widens its API by a lock-path accessor. `stack-auth` +gains a transport trait, which is the largest piece of work here and the one +CIP-3553 already anticipated. The Go module path and layout change before +anything ships. The CI matrix grows by two operating systems. + +What it does **not** fix: the client key still passes through Go host memory +between the two guests, since two wasm instances cannot share memory. It is +wiped there, not absent. And a token is a Go string on the host side, which +cannot be wiped; that is accepted for a credential that lives for hours. From c578f811e0aebed730f9f0f607ba4884624e2d5c Mon Sep 17 00:00:00 2001 From: Dan Draper <dan@cipherstash.com> Date: Sun, 20 Sep 2026 11:15:00 -0700 Subject: [PATCH 595/686] feat(stack-profile): the crate builds for wasm32-wasip1, and names the lock file's path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Go binding's credential guest (ADR-0005) compiles this crate for WASI and reads a profile the CLI wrote. Two things stood in the way, found by running the real crate under wazero with a profile mounted: - `gethostname` has no wasip1 body. It named a new device identity after the host, which only the creating half of `DeviceIdentity::load_or_create` does, and creating one is what the CLI does when it provisions a client at login. That half is now native-only rather than stubbed: a guest that could mint an identity with a made-up name would be worse than one that cannot. `load` stays everywhere; the refresh path takes the device id from the token's claim, not from this file. - `std::process::id()` in the atomic write's staging filename aborts the module ("unsupported"), where a wasm instance is the only process there is. The UUID carries the uniqueness there. WASI preview 1 has no file locking, so the guest cannot take the cross-process refresh lock; the Go host takes the same lock on the same file. `lock_path` names that file — the sibling `.<filename>.lock` that `lock_exclusive` now also goes through — so the host never composes a profile path itself. A test pins that the two agree. The crate joins `wasm:wasi-check` so the WASI build cannot regress. Linear: CIP-4114 --- packages/stack-profile/Cargo.toml | 8 ++- packages/stack-profile/src/device_identity.rs | 8 +++ packages/stack-profile/src/profile_store.rs | 61 +++++++++++++++++-- 3 files changed, 72 insertions(+), 5 deletions(-) diff --git a/packages/stack-profile/Cargo.toml b/packages/stack-profile/Cargo.toml index ab4b2594b..93f11306b 100644 --- a/packages/stack-profile/Cargo.toml +++ b/packages/stack-profile/Cargo.toml @@ -10,11 +10,17 @@ homepage.workspace = true [dependencies] dirs = "4.0.0" -gethostname = "0.5" serde = { workspace = true } serde_json = { workspace = true } thiserror = { workspace = true } uuid = { workspace = true } +# The default device name is the hostname, which only the creating half of +# `DeviceIdentity` needs — and that half is native-only: the crate builds for +# wasm32-wasip1 as the Go binding's credential guest, which reads an identity +# the CLI created and never creates one (gethostname has no wasip1 body). +[target.'cfg(not(target_arch = "wasm32"))'.dependencies] +gethostname = "0.5" + [dev-dependencies] tempfile = "3.21.0" diff --git a/packages/stack-profile/src/device_identity.rs b/packages/stack-profile/src/device_identity.rs index 812f289db..fef7d53f9 100644 --- a/packages/stack-profile/src/device_identity.rs +++ b/packages/stack-profile/src/device_identity.rs @@ -28,6 +28,14 @@ impl DeviceIdentity { /// /// When creating, generates a UUIDv4 and uses the system hostname as the /// default device name. The file is written with mode 0600 on Unix. + /// + /// Native targets only. Creating an identity is what the CLI does when it + /// provisions a client at login; a wasm32 build of this crate (the Go + /// binding's credential guest) reads the identity the CLI wrote and must + /// not be able to mint one — it has no hostname to name it after, and a + /// made-up name would be worse than none. Use [`DeviceIdentity::load`] + /// there. + #[cfg(not(target_arch = "wasm32"))] pub fn load_or_create(store: &ProfileStore) -> Result<Self, ProfileError> { match store.load_profile::<Self>() { Ok(identity) => Ok(identity), diff --git a/packages/stack-profile/src/profile_store.rs b/packages/stack-profile/src/profile_store.rs index 8246c9baa..f32b47be9 100644 --- a/packages/stack-profile/src/profile_store.rs +++ b/packages/stack-profile/src/profile_store.rs @@ -339,9 +339,16 @@ impl ProfileStore { "target path has no file name", )) })?; + // The process id keeps two processes' staging files apart; the UUID + // keeps two threads' apart. On wasm32-wasip1 `std::process::id()` + // aborts the module ("unsupported"), and a wasm instance is the only + // process there is, so the UUID alone carries the uniqueness. + #[cfg(not(target_arch = "wasm32"))] + let pid = std::process::id(); + #[cfg(target_arch = "wasm32")] + let pid = 0u32; let tmp_path = parent.join(format!( - ".{file_name}.tmp.{}.{}", - std::process::id(), + ".{file_name}.tmp.{pid}.{}", uuid::Uuid::new_v4().simple() )); @@ -487,9 +494,8 @@ impl ProfileStore { /// causes silent state corruption (in the auth case: refresh-token /// rotation replay). pub fn lock_exclusive(&self, filename: &str) -> Result<FileLockGuard, ProfileError> { - Self::validate_filename(filename)?; + let lock_path = self.lock_path(filename)?; std::fs::create_dir_all(&self.dir)?; - let lock_path = self.dir.join(format!(".{filename}.lock")); let file = std::fs::OpenOptions::new() .write(true) .create(true) @@ -499,6 +505,19 @@ impl ProfileStore { Ok(FileLockGuard { file }) } + /// The path of the lock file [`lock_exclusive`](Self::lock_exclusive) + /// takes for `filename`: a sibling `.<filename>.lock` in this store's + /// directory. Nothing is created or locked. + /// + /// This is for a host that must hold the lock on the crate's behalf. + /// WASI preview 1 has no file locking, so the Go binding's credential + /// guest cannot take it; the Go side takes the same lock on the path + /// this names, and never composes a profile path itself. + pub fn lock_path(&self, filename: &str) -> Result<PathBuf, ProfileError> { + Self::validate_filename(filename)?; + Ok(self.dir.join(format!(".{filename}.lock"))) + } + /// Save a [`ProfileData`] value using its declared filename and mode. pub fn save_profile<T: ProfileData>(&self, value: &T) -> Result<(), ProfileError> { self.write(T::FILENAME, value, T::MODE) @@ -544,6 +563,40 @@ mod tests { value: u32, } + mod lock_path { + use super::*; + + #[test] + fn names_the_file_lock_exclusive_takes() { + let dir = tempfile::tempdir().unwrap(); + let store = ProfileStore::new(dir.path()); + + let path = store.lock_path("auth.json").unwrap(); + assert_eq!(path, dir.path().join(".auth.json.lock")); + assert!(!path.exists(), "naming the lock file must not create it"); + + let _guard = store.lock_exclusive("auth.json").unwrap(); + assert!( + path.exists(), + "lock_exclusive locks the file lock_path names" + ); + } + + #[test] + fn rejects_an_invalid_filename() { + let dir = tempfile::tempdir().unwrap(); + let store = ProfileStore::new(dir.path()); + + for bad in ["", "../auth.json", "/etc/auth.json"] { + let err = store.lock_path(bad).unwrap_err(); + assert!( + matches!(err, ProfileError::InvalidFilename(_)), + "{bad:?}: {err}" + ); + } + } + } + #[test] fn round_trip_save_and_load() { let dir = tempfile::tempdir().unwrap(); From 856f34bb0940319c44ba343114644ce0ad820d04 Mon Sep 17 00:00:00 2001 From: Dan Draper <dan@cipherstash.com> Date: Sun, 20 Sep 2026 11:39:30 -0700 Subject: [PATCH 596/686] feat(stack-auth)!: an HTTP transport trait mirroring the guest's host import, reqwest behind `http` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every exchange this crate makes has one shape: post a body to a URL, read the status and body back. `HttpTransport` is exactly that, in the shape of the Go binding's `transport_send` host import — method, URL, headers and body in; status, headers and body out; bytes, no streaming — so the strategies can run inside a wasm module that has no TLS stack of its own (ADR-0005), over whatever HTTP client the host provides. `ReqwestTransport` is the bundled implementation and what every builder uses unless told otherwise, with the same timeouts and pool settings as before, so native callers see no change. Each builder gains `.transport(..)`. The `http` feature now means "reqwest is available", not "the strategies exist": the access-key, device-session, OIDC federation and auto strategies and the refresh engine compile in every build, and without `http` a builder that is not given a transport fails with a `Request` error saying so. Device binding and the device-code flow are native-only and keep the bundled transport. The trait returns `impl Future`, the crate's convention for async traits, which is not object-safe; a crate-internal adapter boxes the future once at construction so no public strategy grows a type parameter. Form bodies are encoded by `serde_urlencoded`, which is what reqwest's `.form()` uses, so both transports send byte-identical requests. Error classification works on the status and body the trait returns, so the taxonomy (402 usage limits, `invalid_grant`, `invalid_client`) is the same over both. A stub transport pins the wire shape of every exchange and each error variant without an HTTP client in the build. BREAKING CHANGE: `DeviceClientError::Request` now carries a `RequestError` rather than a `reqwest::Error`; a match on that variant's payload changes type. Nothing else public changes shape. Linear: CIP-4116 --- .../golang/stackencrypt/guest/Cargo.lock | 19 + packages/stack-auth/Cargo.toml | 20 +- packages/stack-auth/README.md | 35 + packages/stack-auth/src/access_key.rs | 2 - .../stack-auth/src/access_key_refresher.rs | 204 ++++-- .../stack-auth/src/access_key_strategy.rs | 26 +- packages/stack-auth/src/auto_refresh.rs | 15 +- packages/stack-auth/src/auto_strategy.rs | 62 +- packages/stack-auth/src/clock.rs | 3 - packages/stack-auth/src/device_client.rs | 50 +- packages/stack-auth/src/device_code/mod.rs | 53 +- .../src/device_session_refresher.rs | 23 +- .../stack-auth/src/device_session_strategy.rs | 44 +- packages/stack-auth/src/error.rs | 3 - packages/stack-auth/src/lib.rs | 112 +-- .../src/oidc_federation_strategy.rs | 19 +- packages/stack-auth/src/oidc_refresher.rs | 75 +- packages/stack-auth/src/service_token.rs | 4 - packages/stack-auth/src/test_support.rs | 2 + packages/stack-auth/src/token.rs | 49 +- packages/stack-auth/src/transport.rs | 652 ++++++++++++++++++ 21 files changed, 1206 insertions(+), 266 deletions(-) create mode 100644 packages/stack-auth/src/transport.rs diff --git a/languages/golang/stackencrypt/guest/Cargo.lock b/languages/golang/stackencrypt/guest/Cargo.lock index 7a347792c..6ab35eb17 100644 --- a/languages/golang/stackencrypt/guest/Cargo.lock +++ b/languages/golang/stackencrypt/guest/Cargo.lock @@ -1744,6 +1744,12 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + [[package]] name = "scopeguard" version = "1.2.0" @@ -1819,6 +1825,18 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + [[package]] name = "serdect" version = "0.3.0" @@ -1935,6 +1953,7 @@ dependencies = [ "open", "serde", "serde_json", + "serde_urlencoded", "stack-profile", "thiserror 1.0.69", "tokio", diff --git a/packages/stack-auth/Cargo.toml b/packages/stack-auth/Cargo.toml index feddb3a38..3cc36ac82 100644 --- a/packages/stack-auth/Cargo.toml +++ b/packages/stack-auth/Cargo.toml @@ -21,6 +21,10 @@ miette = { workspace = true } reqwest = { workspace = true, optional = true } serde = { workspace = true } serde_json = { workspace = true } +# Form bodies for the OAuth token endpoints, encoded exactly as reqwest's +# `.form()` does (it uses this crate), so the bundled transport and a host's +# own transport send byte-identical requests. +serde_urlencoded = "0.7" thiserror = { workspace = true } tracing = { workspace = true } url = { workspace = true } @@ -55,13 +59,15 @@ tokio = { version = "1.47.1", default-features = false, features = ["sync"] } [features] default = ["http"] -# Everything that talks HTTP: the access-key, device-session, OIDC-federation -# and auto strategies, device binding and the device-code flow, and `Token:: -# refresh`. Off, the crate is the token model plus the `AuthStrategy` trait -# (with `AuthStrategyFn` to implement it), and reqwest and its native TLS -# stack are not in the dependency graph at all — the shape a host with its own -# transport (the WASI/wazero guest) builds against. `StaticTokenStrategy` is a -# test double behind `test-utils`, not part of that production surface. +# The bundled HTTP transport: `ReqwestTransport`, which every strategy +# builder uses unless given another `HttpTransport`. Off, reqwest and its +# native TLS stack are not in the dependency graph at all — the shape a host +# with its own transport (the WASI/wazero guest) builds against — and the +# strategies (access-key, device-session, OIDC-federation, auto) still exist +# but must be handed a transport. Device binding and the device-code flow are +# native-only and keep the bundled transport, so they need this feature. +# `StaticTokenStrategy` is a test double behind `test-utils`, not part of +# the production surface. http = ["dep:reqwest"] test-utils = [] # Exposes fuzz-only entry points (e.g. `fuzz_decode_claims`) for the cargo-fuzz diff --git a/packages/stack-auth/README.md b/packages/stack-auth/README.md index 83907dd3d..4f9b6cfff 100644 --- a/packages/stack-auth/README.md +++ b/packages/stack-auth/README.md @@ -131,6 +131,41 @@ end-to-end — typically because the strategy lives across an FFI boundary (e.g. a JS `getToken()` reached via `protect-ffi`). The closure runs every time a token is needed. +Beneath both sits the **transport**: every strategy sends its requests +through an [`HttpTransport`], and the bundled [`ReqwestTransport`] is only +the default. Implement the trait to run the real strategies over an HTTP +client that is not reqwest — a host runtime's, or a stub in a test — and hand +it to the strategy's builder. The trait is one method, in the shape of a +plain request and response: + +```rust,no_run +use stack_auth::{AccessKey, AccessKeyStrategy, HttpRequest, HttpResponse, HttpTransport, RequestError}; +use cts_common::Crn; + +struct MyTransport; + +impl HttpTransport for MyTransport { + async fn send(&self, request: HttpRequest) -> Result<HttpResponse, RequestError> { + // `request.method()`, `request.url()`, `request.headers()`, `request.body()` + let (status, headers, body) = my_http_client(request).await?; + Ok(HttpResponse::new(status, headers, body)) + } +} +# async fn my_http_client(_: HttpRequest) -> Result<(u16, Vec<(String, String)>, Vec<u8>), RequestError> { unimplemented!() } + +# fn run() -> Result<(), Box<dyn std::error::Error>> { +let crn: Crn = "crn:ap-southeast-2.aws:ZVATKW3VHMFG27DY".parse()?; +let key: AccessKey = "CSAKmyKeyId.myKeySecret".parse()?; +let strategy = AccessKeyStrategy::builder(crn, key) + .transport(MyTransport) + .build()?; +# Ok(()) +# } +``` + +Without the `http` feature there is no bundled transport, so `.transport(..)` +is required rather than optional; nothing else about the strategies changes. + Module paths mirror this split: [`stack_auth::auth`](crate::auth) groups the acquisition layer, [`stack_auth::store`](crate::store) groups the persistence layer. All items are also re-exported at the crate root. diff --git a/packages/stack-auth/src/access_key.rs b/packages/stack-auth/src/access_key.rs index 4db5726b0..5b137a713 100644 --- a/packages/stack-auth/src/access_key.rs +++ b/packages/stack-auth/src/access_key.rs @@ -41,7 +41,6 @@ pub struct AccessKey( #[cfg_attr(not(feature = "http"), allow(dead_code))] SecretToken, ); -#[cfg(feature = "http")] impl AccessKey { /// Expose the underlying [`SecretToken`]. pub(crate) fn into_secret_token(self) -> SecretToken { @@ -136,7 +135,6 @@ mod tests { assert!(matches!(err, InvalidAccessKey::MissingPrefix)); } - #[cfg(feature = "http")] #[test] fn into_secret_token() { let key: AccessKey = "CSAKmyKeyId.myKeySecret".parse().unwrap(); diff --git a/packages/stack-auth/src/access_key_refresher.rs b/packages/stack-auth/src/access_key_refresher.rs index d9bfda4eb..0f62a29e9 100644 --- a/packages/stack-auth/src/access_key_refresher.rs +++ b/packages/stack-auth/src/access_key_refresher.rs @@ -1,10 +1,9 @@ -use std::sync::Arc; - use url::Url; use crate::authorize_dto::AuthoriseResponse; use crate::refresher::Refresher; -use crate::{http_client, AuthError, SecretToken, Token}; +use crate::transport::{self, SharedTransport}; +use crate::{AuthError, SecretToken, Token}; /// A [`Refresher`] that uses a static access key to authenticate. /// @@ -15,16 +14,21 @@ pub(crate) struct AccessKeyRefresher { access_key: SecretToken, base_url: Url, audience: Option<String>, - http_client: Arc<reqwest::Client>, + transport: SharedTransport, } impl AccessKeyRefresher { - pub(crate) fn new(access_key: SecretToken, base_url: Url, audience: Option<String>) -> Self { + pub(crate) fn new( + access_key: SecretToken, + base_url: Url, + audience: Option<String>, + transport: SharedTransport, + ) -> Self { Self { access_key, base_url, audience, - http_client: Arc::new(http_client()), + transport, } } } @@ -49,21 +53,21 @@ impl Refresher for AccessKeyRefresher { tracing::debug!(url = %url, "authenticating with access key"); - let resp = self - .http_client - .post(url) - .json(&AuthoriseRequest { + let resp = transport::post_json( + &self.transport, + url, + &AuthoriseRequest { access_key: self.access_key.as_str(), audience: self.audience.as_deref(), - }) - .send() - .await?; + }, + ) + .await?; - if !resp.status().is_success() { + if !resp.is_success() { let status = resp.status(); - let body = resp.text().await.unwrap_or_default(); + let body = resp.text(); tracing::debug!(%status, %body, "access key auth failed"); - if let Some(err) = crate::error::classify_issuance_failure(status.as_u16(), &body) { + if let Some(err) = crate::error::classify_issuance_failure(status, &body) { return Err(err); } return Err(AuthError::Server(crate::error::ServerError(format!( @@ -71,7 +75,7 @@ impl Refresher for AccessKeyRefresher { )))); } - let auth_resp: AuthoriseResponse = resp.json().await?; + let auth_resp: AuthoriseResponse = resp.json()?; // The response → Token mapping (including the absolute-epoch `expiry` // handling that CIP-3233 fixed) lives on `From<AuthoriseResponse>`. @@ -87,10 +91,11 @@ struct AuthoriseRequest<'a> { audience: Option<&'a str>, } -#[cfg(test)] +#[cfg(all(test, feature = "http"))] mod tests { use super::*; use crate::auto_refresh::{AutoRefresh, AutoRefreshError}; + use crate::transport::default_transport; use crate::TokenStore; use mocktail::prelude::*; use std::sync::Arc; @@ -121,6 +126,7 @@ mod tests { SecretToken::new("test-access-key"), server.url(""), Some("test-audience".to_string()), + default_transport(), ); AutoRefresh::with_store(refresher, crate::NoStore) } @@ -181,8 +187,12 @@ mod tests { }); let server = start_server(mocks).await; - let refresher = - AccessKeyRefresher::new(SecretToken::new("CSAKid.secret"), server.url(""), None); + let refresher = AccessKeyRefresher::new( + SecretToken::new("CSAKid.secret"), + server.url(""), + None, + default_transport(), + ); let token = refresher.refresh(&()).await.unwrap(); assert!( @@ -311,8 +321,12 @@ mod tests { let store = Arc::new(crate::InMemoryTokenStore::new()); store.save(&make_fresh_token("from-store")).await; - let refresher = - AccessKeyRefresher::new(SecretToken::new("test-access-key"), server.url(""), None); + let refresher = AccessKeyRefresher::new( + SecretToken::new("test-access-key"), + server.url(""), + None, + default_transport(), + ); let strategy = AutoRefresh::with_store(refresher, Arc::clone(&store)); let token = strategy.get_token().await.unwrap(); @@ -338,8 +352,12 @@ mod tests { "store should be empty before initial auth" ); - let refresher = - AccessKeyRefresher::new(SecretToken::new("test-access-key"), server.url(""), None); + let refresher = AccessKeyRefresher::new( + SecretToken::new("test-access-key"), + server.url(""), + None, + default_transport(), + ); let strategy = AutoRefresh::with_store(refresher, Arc::clone(&store)); let token = strategy.get_token().await.unwrap(); @@ -374,8 +392,12 @@ mod tests { let store = Arc::new(crate::InMemoryTokenStore::new()); // First strategy — does the HTTP exchange and writes to the store. - let refresher_a = - AccessKeyRefresher::new(SecretToken::new("test-access-key"), server.url(""), None); + let refresher_a = AccessKeyRefresher::new( + SecretToken::new("test-access-key"), + server.url(""), + None, + default_transport(), + ); let strategy_a = AutoRefresh::with_store(refresher_a, Arc::clone(&store)); let token_a = strategy_a.get_token().await.unwrap(); assert_eq!( @@ -393,8 +415,12 @@ mod tests { }); // Second strategy — fresh instance, same store. Should load from store. - let refresher_b = - AccessKeyRefresher::new(SecretToken::new("test-access-key"), server.url(""), None); + let refresher_b = AccessKeyRefresher::new( + SecretToken::new("test-access-key"), + server.url(""), + None, + default_transport(), + ); let strategy_b = AutoRefresh::with_store(refresher_b, Arc::clone(&store)); let token_b = strategy_b.get_token().await.unwrap(); assert_eq!( @@ -416,8 +442,12 @@ mod tests { let store = Arc::new(crate::InMemoryTokenStore::new()); store.save(&make_expired_token("stale-from-store")).await; - let refresher = - AccessKeyRefresher::new(SecretToken::new("test-access-key"), server.url(""), None); + let refresher = AccessKeyRefresher::new( + SecretToken::new("test-access-key"), + server.url(""), + None, + default_transport(), + ); let strategy = AutoRefresh::with_store(refresher, Arc::clone(&store)); let token = strategy.get_token().await.unwrap(); @@ -450,8 +480,12 @@ mod tests { }); let server = start_server(mocks).await; - let refresher = - AccessKeyRefresher::new(SecretToken::new("test-access-key"), server.url(""), None); + let refresher = AccessKeyRefresher::new( + SecretToken::new("test-access-key"), + server.url(""), + None, + default_transport(), + ); let strategy = AutoRefresh::with_token(refresher, make_expired_token("old-token")); let token = strategy.get_token().await.unwrap(); @@ -487,8 +521,12 @@ mod tests { }); let server = start_server(mocks).await; - let refresher = - AccessKeyRefresher::new(SecretToken::new("test-access-key"), server.url(""), None); + let refresher = AccessKeyRefresher::new( + SecretToken::new("test-access-key"), + server.url(""), + None, + default_transport(), + ); let strategy = AutoRefresh::with_token(refresher, make_expired_token("old-token")); let err = strategy.get_token().await.unwrap_err(); @@ -514,8 +552,12 @@ mod tests { }); let server = start_server(mocks).await; - let refresher = - AccessKeyRefresher::new(SecretToken::new("test-access-key"), server.url(""), None); + let refresher = AccessKeyRefresher::new( + SecretToken::new("test-access-key"), + server.url(""), + None, + default_transport(), + ); let strategy = AutoRefresh::with_token(refresher, make_expired_token("old-token")); let AutoRefreshError::Auth(err) = strategy.get_token().await.unwrap_err() else { @@ -570,7 +612,12 @@ mod tests { ) .await; - let refresher = AccessKeyRefresher::new(SecretToken::new("test-access-key"), url, None); + let refresher = AccessKeyRefresher::new( + SecretToken::new("test-access-key"), + url, + None, + default_transport(), + ); let strategy = AutoRefresh::with_token(refresher, make_expired_token("old-token")); for call in 1..=5 { @@ -673,7 +720,12 @@ mod tests { let start = now_secs(); let clock = crate::clock::TestClock::new(start); - let refresher = AccessKeyRefresher::new(SecretToken::new("test-access-key"), url, None); + let refresher = AccessKeyRefresher::new( + SecretToken::new("test-access-key"), + url, + None, + default_transport(), + ); let strategy = AutoRefresh::with_token_and_clock( refresher, make_token_expiring_at("old-token", start - 3600), @@ -705,7 +757,12 @@ mod tests { let start = now_secs(); let clock = crate::clock::TestClock::new(start); - let refresher = AccessKeyRefresher::new(SecretToken::new("test-access-key"), url, None); + let refresher = AccessKeyRefresher::new( + SecretToken::new("test-access-key"), + url, + None, + default_transport(), + ); let strategy = AutoRefresh::with_token_and_clock( refresher, make_token_expiring_at("old-token", start - 3600), @@ -739,7 +796,12 @@ mod tests { let start = now_secs(); let clock = crate::clock::TestClock::new(start); - let refresher = AccessKeyRefresher::new(SecretToken::new("test-access-key"), url, None); + let refresher = AccessKeyRefresher::new( + SecretToken::new("test-access-key"), + url, + None, + default_transport(), + ); let strategy = AutoRefresh::with_token_and_clock( refresher, make_token_expiring_at("old-token", start - 3600), @@ -774,7 +836,12 @@ mod tests { let start = now_secs(); let clock = crate::clock::TestClock::new(start); - let refresher = AccessKeyRefresher::new(SecretToken::new("test-access-key"), url, None); + let refresher = AccessKeyRefresher::new( + SecretToken::new("test-access-key"), + url, + None, + default_transport(), + ); // Expired well before `start`, so it is still expired after the rewind // — otherwise the token-expiry check short-circuits and the refusal is // never consulted, and the test would prove nothing. @@ -808,7 +875,12 @@ mod tests { ) .await; - let refresher = AccessKeyRefresher::new(SecretToken::new("test-access-key"), url, None); + let refresher = AccessKeyRefresher::new( + SecretToken::new("test-access-key"), + url, + None, + default_transport(), + ); let strategy = AutoRefresh::with_token(refresher, make_expired_token("old-token")); for _ in 0..3 { @@ -857,8 +929,12 @@ mod tests { }); let server = start_server(mocks).await; - let refresher = - AccessKeyRefresher::new(SecretToken::new("test-access-key"), server.url(""), None); + let refresher = AccessKeyRefresher::new( + SecretToken::new("test-access-key"), + server.url(""), + None, + default_transport(), + ); let strategy = Arc::new(AutoRefresh::with_token( refresher, make_expired_token("old-token"), @@ -903,8 +979,12 @@ mod tests { device_instance_id: None, }; - let refresher = - AccessKeyRefresher::new(SecretToken::new("test-access-key"), server.url(""), None); + let refresher = AccessKeyRefresher::new( + SecretToken::new("test-access-key"), + server.url(""), + None, + default_transport(), + ); let strategy = Arc::new(AutoRefresh::with_token(refresher, expiring_token)); let s1 = Arc::clone(&strategy); @@ -1018,8 +1098,12 @@ mod tests { }; let (base_url, stats) = start_axum_server(state).await; - let refresher = - AccessKeyRefresher::new(SecretToken::new("test-access-key"), base_url, None); + let refresher = AccessKeyRefresher::new( + SecretToken::new("test-access-key"), + base_url, + None, + default_transport(), + ); let strategy = Arc::new(AutoRefresh::with_store(refresher, crate::NoStore)); let start = Instant::now(); @@ -1060,8 +1144,12 @@ mod tests { let (base_url, stats) = start_axum_server(state).await; // Pre-authenticate. - let refresher = - AccessKeyRefresher::new(SecretToken::new("test-access-key"), base_url, None); + let refresher = AccessKeyRefresher::new( + SecretToken::new("test-access-key"), + base_url, + None, + default_transport(), + ); let now = SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap() @@ -1126,8 +1214,12 @@ mod tests { client_id: None, device_instance_id: None, }; - let refresher = - AccessKeyRefresher::new(SecretToken::new("test-access-key"), base_url, None); + let refresher = AccessKeyRefresher::new( + SecretToken::new("test-access-key"), + base_url, + None, + default_transport(), + ); let strategy = Arc::new(AutoRefresh::with_token(refresher, expiring_token)); let start = Instant::now(); @@ -1183,8 +1275,12 @@ mod tests { }; let (base_url, stats) = start_axum_server(state).await; - let refresher = - AccessKeyRefresher::new(SecretToken::new("test-access-key"), base_url, None); + let refresher = AccessKeyRefresher::new( + SecretToken::new("test-access-key"), + base_url, + None, + default_transport(), + ); let strategy = Arc::new(AutoRefresh::with_token( refresher, make_expired_token("old-token"), diff --git a/packages/stack-auth/src/access_key_strategy.rs b/packages/stack-auth/src/access_key_strategy.rs index 059d0b8d8..6f1fa2738 100644 --- a/packages/stack-auth/src/access_key_strategy.rs +++ b/packages/stack-auth/src/access_key_strategy.rs @@ -4,6 +4,8 @@ use crate::access_key::AccessKey; use crate::access_key_refresher::AccessKeyRefresher; use crate::auto_refresh::AutoRefresh; use crate::token_store::{NoStore, TokenStore}; +use crate::transport::{self, SharedTransport}; +use crate::HttpTransport; use crate::{ensure_trailing_slash, AuthError, AuthStrategy, SecretToken, ServiceToken}; /// An [`AuthStrategy`] that uses a static access key to authenticate against @@ -87,6 +89,7 @@ impl AccessKeyStrategy { audience: None, base_url_override: None, token_store: NoStore, + transport: None, } } } @@ -109,9 +112,28 @@ pub struct AccessKeyStrategyBuilder<S = NoStore> { audience: Option<String>, base_url_override: Option<url::Url>, token_store: S, + transport: Option<SharedTransport>, } impl<S> AccessKeyStrategyBuilder<S> { + /// Send this strategy's requests through `transport` instead of the + /// bundled `reqwest` client. + /// + /// Without the `http` feature there is no bundled client, so this is + /// required; with it, this is how a host with its own HTTP stack (or a + /// test with a stub) takes over the wire without changing anything else + /// about the strategy. + pub fn transport(mut self, transport: impl HttpTransport) -> Self { + self.transport = Some(transport::share(transport)); + self + } + + /// [`transport`](Self::transport), for a caller that may or may not + /// have one — the auto strategy hands its own through. + pub(crate) fn maybe_transport(mut self, transport: Option<SharedTransport>) -> Self { + self.transport = transport; + self + } /// Set the audience for token requests. pub fn audience(mut self, audience: impl Into<String>) -> Self { self.audience = Some(audience.into()); @@ -151,6 +173,7 @@ impl<S> AccessKeyStrategyBuilder<S> { audience: self.audience, base_url_override: self.base_url_override, token_store: store, + transport: self.transport, } } } @@ -176,6 +199,7 @@ impl<S: TokenStore> AccessKeyStrategyBuilder<S> { self.access_key, ensure_trailing_slash(base_url), self.audience, + transport::resolve(self.transport)?, ); Ok(AccessKeyStrategy { inner: AutoRefresh::with_store(refresher, self.token_store), @@ -184,7 +208,7 @@ impl<S: TokenStore> AccessKeyStrategyBuilder<S> { } } -#[cfg(test)] +#[cfg(all(test, feature = "http"))] mod workspace_verification_tests { use super::*; use crate::test_support::{crn_with_workspace, jwt_with_workspace}; diff --git a/packages/stack-auth/src/auto_refresh.rs b/packages/stack-auth/src/auto_refresh.rs index 21819daf0..37cfff221 100644 --- a/packages/stack-auth/src/auto_refresh.rs +++ b/packages/stack-auth/src/auto_refresh.rs @@ -250,7 +250,7 @@ impl<R> AutoRefresh<R, NoStore> { /// Like [`with_token`](Self::with_token) but with an injected clock, so tests /// can drive token expiry deterministically. - #[cfg(test)] + #[cfg(all(test, feature = "http"))] pub(crate) fn with_token_and_clock(refresher: R, token: Token, clock: SharedClock) -> Self { Self { refresher, @@ -585,7 +585,7 @@ impl<R: Refresher, S: TokenStore> AutoRefresh<R, S> { } } -#[cfg(test)] +#[cfg(all(test, feature = "http"))] #[allow(clippy::unwrap_used)] mod tests { use super::*; @@ -674,6 +674,7 @@ mod tests { "cli", "ap-southeast-2.aws", None, + crate::transport::default_transport(), ); AutoRefresh::with_token(refresher, token) } @@ -691,6 +692,7 @@ mod tests { "cli", "ap-southeast-2.aws", None, + crate::transport::default_transport(), ); let strategy = AutoRefresh::with_store(refresher, NoStore); @@ -1260,7 +1262,7 @@ mod tests { } } -#[cfg(test)] +#[cfg(all(test, feature = "http"))] #[allow(clippy::unwrap_used)] mod stress_tests { use super::*; @@ -1399,6 +1401,7 @@ mod stress_tests { "cli", "ap-southeast-2.aws", None, + crate::transport::default_transport(), ); AutoRefresh::with_token(refresher, token) } @@ -1802,6 +1805,7 @@ mod stress_tests { "cli", "ap-southeast-2.aws", None, + crate::transport::default_transport(), ); // Slow async save — cancellation reliably lands here, in the // post-HTTP / pre-install window. @@ -1893,7 +1897,7 @@ mod stress_tests { /// instrumentation. This version drives expiry with a [`TestClock`] and gates /// the refresh with a [`Notify`], so it is fully deterministic: no real sleeps, /// no network. -#[cfg(test)] +#[cfg(all(test, feature = "http"))] #[allow(clippy::unwrap_used)] mod expiry_crossing_regression { use super::*; @@ -2387,7 +2391,7 @@ mod expiry_crossing_regression { } } -#[cfg(test)] +#[cfg(all(test, feature = "http"))] #[allow(clippy::unwrap_used)] mod regression_cip_3159 { use super::*; @@ -2458,6 +2462,7 @@ mod regression_cip_3159 { SecretToken::new("CSAKtestKeyId.testKeySecret"), base_url, None, + crate::transport::default_transport(), ), expiring_but_usable_token("old-usable", 2), )); diff --git a/packages/stack-auth/src/auto_strategy.rs b/packages/stack-auth/src/auto_strategy.rs index b49969106..6bbd054fb 100644 --- a/packages/stack-auth/src/auto_strategy.rs +++ b/packages/stack-auth/src/auto_strategy.rs @@ -5,6 +5,8 @@ use crate::device_session_strategy::DeviceSessionStrategy; #[cfg(not(target_arch = "wasm32"))] use stack_profile::ProfileStore; +use crate::transport::{self, SharedTransport}; +use crate::HttpTransport; #[cfg(not(target_arch = "wasm32"))] use crate::Token; use crate::{AuthError, AuthStrategy, ServiceToken}; @@ -81,6 +83,7 @@ impl AutoStrategy { AutoStrategyBuilder { access_key: None, crn: None, + transport: None, } } @@ -106,6 +109,7 @@ impl AutoStrategy { access_key: Option<String>, crn: Option<Crn>, store: Option<ProfileStore>, + transport: Option<SharedTransport>, ) -> Result<Self, AuthError> { // 1. Access key from environment if let Some(access_key) = access_key { @@ -113,7 +117,9 @@ impl AutoStrategy { crate::error::MissingWorkspaceCrn, ))?; let key: crate::AccessKey = access_key.parse()?; - let strategy = AccessKeyStrategy::new(workspace_crn, key)?; + let strategy = AccessKeyStrategy::builder(workspace_crn, key) + .maybe_transport(transport) + .build()?; return Ok(Self::AccessKey(strategy)); } @@ -124,7 +130,9 @@ impl AutoStrategy { .map(|ws| ws.exists_profile::<Token>()) .unwrap_or(false); if has_token { - let strategy = DeviceSessionStrategy::with_profile(store).build()?; + let strategy = DeviceSessionStrategy::with_profile(store) + .maybe_transport(transport) + .build()?; return Ok(Self::DeviceSession(strategy)); } } @@ -134,13 +142,19 @@ impl AutoStrategy { } #[cfg(target_arch = "wasm32")] - fn detect_inner(access_key: Option<String>, crn: Option<Crn>) -> Result<Self, AuthError> { + fn detect_inner( + access_key: Option<String>, + crn: Option<Crn>, + transport: Option<SharedTransport>, + ) -> Result<Self, AuthError> { if let Some(access_key) = access_key { let workspace_crn = crn.ok_or(AuthError::MissingWorkspaceCrn( crate::error::MissingWorkspaceCrn, ))?; let key: crate::AccessKey = access_key.parse()?; - let strategy = AccessKeyStrategy::new(workspace_crn, key)?; + let strategy = AccessKeyStrategy::builder(workspace_crn, key) + .maybe_transport(transport) + .build()?; return Ok(Self::AccessKey(strategy)); } Err(AuthError::NotAuthenticated(crate::error::NotAuthenticated)) @@ -168,9 +182,17 @@ impl AutoStrategy { pub struct AutoStrategyBuilder { access_key: Option<String>, crn: Option<Crn>, + transport: Option<SharedTransport>, } impl AutoStrategyBuilder { + /// Send the detected strategy's requests through `transport` instead of + /// the bundled `reqwest` client. Required without the `http` feature. + pub fn transport(mut self, transport: impl HttpTransport) -> Self { + self.transport = Some(transport::share(transport)); + self + } + /// Provide an explicit access key. Takes precedence over env vars. pub fn with_access_key(mut self, access_key: impl Into<String>) -> Self { self.access_key = Some(access_key.into()); @@ -216,11 +238,11 @@ impl AutoStrategyBuilder { None } }; - AutoStrategy::detect_inner(access_key, crn, store) + AutoStrategy::detect_inner(access_key, crn, store, self.transport) } #[cfg(target_arch = "wasm32")] { - AutoStrategy::detect_inner(access_key, crn) + AutoStrategy::detect_inner(access_key, crn, self.transport) } } } @@ -234,7 +256,9 @@ impl AuthStrategy for &AutoStrategy { } } -#[cfg(test)] +// Detection builds strategies with no transport of their own, which needs +// the bundled one. +#[cfg(all(test, feature = "http"))] mod tests { use super::*; use crate::{SecretToken, Token}; @@ -294,6 +318,7 @@ mod tests { Some("CSAKtestKeyId.testKeySecret".into()), Some(valid_crn()), None, + None, ); assert!(result.is_ok()); @@ -302,16 +327,24 @@ mod tests { #[test] fn access_key_without_crn_returns_missing_workspace_crn() { - let result = - AutoStrategy::detect_inner(Some("CSAKtestKeyId.testKeySecret".into()), None, None); + let result = AutoStrategy::detect_inner( + Some("CSAKtestKeyId.testKeySecret".into()), + None, + None, + None, + ); assert!(matches!(result, Err(AuthError::MissingWorkspaceCrn(_)))); } #[test] fn invalid_access_key_format_returns_invalid_access_key() { - let result = - AutoStrategy::detect_inner(Some("not-a-valid-key".into()), Some(valid_crn()), None); + let result = AutoStrategy::detect_inner( + Some("not-a-valid-key".into()), + Some(valid_crn()), + None, + None, + ); assert!(matches!(result, Err(AuthError::InvalidAccessKey(_)))); } @@ -321,7 +354,7 @@ mod tests { let dir = tempfile::tempdir().unwrap(); let store = write_token_store(dir.path()); - let result = AutoStrategy::detect_inner(None, None, Some(store)); + let result = AutoStrategy::detect_inner(None, None, Some(store), None); assert!(result.is_ok()); assert!(matches!(result.unwrap(), AutoStrategy::DeviceSession(_))); @@ -332,14 +365,14 @@ mod tests { let dir = tempfile::tempdir().unwrap(); let store = ProfileStore::new(dir.path()); - let result = AutoStrategy::detect_inner(None, None, Some(store)); + let result = AutoStrategy::detect_inner(None, None, Some(store), None); assert!(matches!(result, Err(AuthError::NotAuthenticated(_)))); } #[test] fn no_credentials_returns_not_authenticated() { - let result = AutoStrategy::detect_inner(None, None, None); + let result = AutoStrategy::detect_inner(None, None, None, None); assert!(matches!(result, Err(AuthError::NotAuthenticated(_)))); } @@ -353,6 +386,7 @@ mod tests { Some("CSAKtestKeyId.testKeySecret".into()), Some(valid_crn()), Some(store), + None, ); assert!(result.is_ok()); diff --git a/packages/stack-auth/src/clock.rs b/packages/stack-auth/src/clock.rs index 0d7476cfc..531321d8f 100644 --- a/packages/stack-auth/src/clock.rs +++ b/packages/stack-auth/src/clock.rs @@ -13,7 +13,6 @@ // `Arc` only backs the shared handles (`SharedClock`, `TestClock`); the bare // `Clock`/`SystemClock` used by `Token` expiry need no sharing. -#[cfg(feature = "http")] use std::sync::Arc; use web_time::{SystemTime, UNIX_EPOCH}; @@ -28,7 +27,6 @@ pub(crate) trait Clock: Send + Sync { /// /// Type-erased (rather than a generic parameter on `AutoRefresh`) so injecting a /// clock doesn't ripple a third generic through every strategy wrapper. -#[cfg(feature = "http")] pub(crate) type SharedClock = Arc<dyn Clock>; /// The default [`Clock`]: the system wall clock. @@ -50,7 +48,6 @@ impl Clock for SystemClock { /// /// Returns clones of one process-wide handle: `SystemClock` is a stateless ZST, /// so there's no reason to allocate a fresh `Arc` per `AutoRefresh`. -#[cfg(feature = "http")] pub(crate) fn system_clock() -> SharedClock { static CLOCK: std::sync::LazyLock<SharedClock> = std::sync::LazyLock::new(|| Arc::new(SystemClock)); diff --git a/packages/stack-auth/src/device_client.rs b/packages/stack-auth/src/device_client.rs index 7cd79a89b..c734fd8fe 100644 --- a/packages/stack-auth/src/device_client.rs +++ b/packages/stack-auth/src/device_client.rs @@ -9,7 +9,9 @@ use stack_profile::{DeviceIdentity, ProfileStore}; use uuid::Uuid; use zerokms_protocol::{CreateClientRequest, CreateClientResponse, ViturKeyMaterial, ViturRequest}; -use crate::{ensure_trailing_slash, http_client, ServiceToken, Token}; +use crate::error::RequestError; +use crate::transport::{self, ReqwestTransport}; +use crate::{ensure_trailing_slash, ServiceToken, Token}; fn user_agent() -> String { format!( @@ -53,9 +55,9 @@ pub enum DeviceClientError { #[error("Auth error: {0}")] Auth(#[from] crate::AuthError), - /// The HTTP request to ZeroKMS failed. + /// The HTTP request to ZeroKMS failed, or its response did not decode. #[error("ZeroKMS request failed: {0}")] - Request(#[from] reqwest::Error), + Request(#[from] RequestError), /// ZeroKMS returned a non-success, non-conflict status. #[error("ZeroKMS returned {status}: {body}")] @@ -101,31 +103,47 @@ pub async fn bind_client_device(store: &ProfileStore) -> Result<(), DeviceClient let url = zerokms_url.join(CreateClientRequest::ENDPOINT)?; - let response = http_client() - .post(url) - .header(reqwest::header::USER_AGENT, user_agent()) - .bearer_auth(service_token.as_str()) - .json(&request) - .send() - .await?; + // Provisioning is native-only, so it always uses the bundled transport: + // a request failure surfaces as `Request`, never as `Auth`. + let body = zeroize::Zeroizing::new( + serde_json::to_vec(&request).map_err(|e| RequestError(Box::new(e)))?, + ); + let response = transport::post( + &transport::share(ReqwestTransport::default()), + url, + "application/json", + vec![ + ("user-agent".to_string(), user_agent()), + ( + "authorization".to_string(), + format!("Bearer {}", service_token.as_str()), + ), + ], + body, + ) + .await + .map_err(|e| match e { + crate::AuthError::Request(e) => DeviceClientError::Request(e), + other => DeviceClientError::Auth(other), + })?; let status = response.status(); - if status == reqwest::StatusCode::CONFLICT { + if status == 409 { // Another client was already provisioned server-side. tracing::debug!("device client already exists, skipping"); return Ok(()); } - if !status.is_success() { - let body = response.text().await.unwrap_or_default(); + if !response.is_success() { return Err(DeviceClientError::Server { - status: status.as_u16(), - body, + status, + body: response.text(), }); } - let created: CreateClientResponse = response.json().await?; + let created: CreateClientResponse = serde_json::from_slice(response.body()) + .map_err(|e| DeviceClientError::Request(RequestError(Box::new(e))))?; let secret_key = SecretKeyFile { client_id: created.id, diff --git a/packages/stack-auth/src/device_code/mod.rs b/packages/stack-auth/src/device_code/mod.rs index 3acbd490b..ed3413f1d 100644 --- a/packages/stack-auth/src/device_code/mod.rs +++ b/packages/stack-auth/src/device_code/mod.rs @@ -9,7 +9,8 @@ use std::path::PathBuf; use stack_profile::ProfileStore; -use crate::{ensure_trailing_slash, http_client, AuthError, DeviceIdentity, Token}; +use crate::transport::{self, ReqwestTransport, SharedTransport}; +use crate::{ensure_trailing_slash, AuthError, DeviceIdentity, Token}; use protocol::{ DeviceCode, DeviceCodeRequest, DeviceCodeResponse, ErrorResponse, TokenRequest, TokenResponse, }; @@ -17,6 +18,12 @@ use protocol::{ #[cfg(test)] mod tests; +/// The device-code flow is interactive and native-only, so it always runs +/// over the bundled transport. +fn bundled_transport() -> SharedTransport { + transport::share(ReqwestTransport::default()) +} + /// Authenticates with CipherStash using the /// [device code flow (RFC 8628)](https://datatracker.ietf.org/doc/html/rfc8628). /// @@ -84,7 +91,7 @@ impl DeviceCodeStrategy { /// Returns [`AuthError::InvalidClient`] if the client ID is not recognized, /// or [`AuthError::Request`] if the server is unreachable. pub async fn begin(&self) -> Result<PendingDeviceCode, AuthError> { - let client = http_client(); + let transport = bundled_transport(); let code_url = self.base_url.join("oauth/device/code")?; @@ -95,21 +102,22 @@ impl DeviceCodeStrategy { .as_ref() .map(|d| d.device_instance_id.to_string()); - let code_resp = client - .post(code_url) - .form(&DeviceCodeRequest { + let code_resp = transport::post_form( + &transport, + code_url, + &DeviceCodeRequest { client_id: &self.client_id, device_instance_id: device_instance_id.as_deref(), device_name: self .device_identity .as_ref() .map(|d| d.device_name.as_str()), - }) - .send() - .await?; + }, + ) + .await?; - if !code_resp.status().is_success() { - let err: ErrorResponse = code_resp.json().await?; + if !code_resp.is_success() { + let err: ErrorResponse = code_resp.json()?; tracing::debug!(error = %err.error, "device code request failed"); return Err(match err.error.as_str() { "invalid_client" => AuthError::InvalidClient(crate::error::InvalidClient), @@ -117,7 +125,7 @@ impl DeviceCodeStrategy { }); } - let code: DeviceCodeResponse = code_resp.json().await?; + let code: DeviceCodeResponse = code_resp.json()?; let token_url = self.base_url.join("oauth/device/token")?; @@ -293,7 +301,7 @@ impl PendingDeviceCode { /// authorized. /// - [`AuthError::Request`] — a network error occurred while polling. pub async fn poll_for_token(self) -> Result<Token, AuthError> { - let client = http_client(); + let transport = bundled_transport(); let mut interval = tokio::time::Duration::from_secs(5); let deadline = tokio::time::Instant::now() + tokio::time::Duration::from_secs(self.expires_in); @@ -310,19 +318,20 @@ impl PendingDeviceCode { return Err(AuthError::TokenExpired(crate::error::TokenExpired)); } - let resp = client - .post(self.token_url.clone()) - .form(&TokenRequest { + let resp = transport::post_form( + &transport, + self.token_url.clone(), + &TokenRequest { client_id: &self.client_id, device_code: &self.device_code, grant_type: "urn:ietf:params:oauth:grant-type:device_code", - }) - .send() - .await?; + }, + ) + .await?; - if resp.status().is_success() { + if resp.is_success() { tracing::debug!("token received"); - let token_resp: TokenResponse = resp.json().await?; + let token_resp: TokenResponse = resp.json()?; let now = SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap_or_default() @@ -365,8 +374,8 @@ impl PendingDeviceCode { // their limit that they were denied access — and a bodyless 402 // would surface as a JSON decode error rather than either. let status = resp.status(); - let body = resp.text().await?; - if let Some(err) = crate::error::classify_issuance_failure(status.as_u16(), &body) { + let body = resp.text(); + if let Some(err) = crate::error::classify_issuance_failure(status, &body) { return Err(err); } diff --git a/packages/stack-auth/src/device_session_refresher.rs b/packages/stack-auth/src/device_session_refresher.rs index 75027d1b4..d49bb8adf 100644 --- a/packages/stack-auth/src/device_session_refresher.rs +++ b/packages/stack-auth/src/device_session_refresher.rs @@ -4,6 +4,7 @@ use url::Url; use stack_profile::{FileLockGuard, ProfileData, ProfileStore}; use crate::refresher::Refresher; +use crate::transport::SharedTransport; use crate::{AuthError, SecretToken, Token}; /// Implements [`Refresher`] using OAuth refresh tokens. @@ -18,6 +19,7 @@ pub(crate) struct DeviceSessionRefresher { client_id: String, region: String, device_instance_id: Option<String>, + transport: SharedTransport, } impl DeviceSessionRefresher { @@ -28,6 +30,7 @@ impl DeviceSessionRefresher { client_id: impl Into<String>, region: impl Into<String>, device_instance_id: Option<String>, + transport: SharedTransport, ) -> Self { Self { store, @@ -35,6 +38,7 @@ impl DeviceSessionRefresher { client_id: client_id.into(), region: region.into(), device_instance_id, + transport, } } @@ -45,12 +49,14 @@ impl DeviceSessionRefresher { client_id: impl Into<String>, region: impl Into<String>, device_instance_id: Option<String>, + transport: SharedTransport, ) -> Self { Self { base_url, client_id: client_id.into(), region: region.into(), device_instance_id, + transport, } } } @@ -102,7 +108,8 @@ impl Refresher for DeviceSessionRefresher { return Ok(disk_token); } - let mut token = Token::refresh( + let mut token = Token::refresh_with( + &self.transport, credential, &self.base_url, &self.client_id, @@ -185,9 +192,10 @@ impl DeviceSessionRefresher { } } -#[cfg(all(test, not(target_arch = "wasm32")))] +#[cfg(all(test, feature = "http", not(target_arch = "wasm32")))] mod tests { use super::*; + use crate::transport::default_transport; use mocktail::prelude::*; use std::time::{SystemTime, UNIX_EPOCH}; @@ -227,7 +235,14 @@ mod tests { store.init_workspace(WORKSPACE_ID).unwrap(); let ws_store = store.current_workspace_store().unwrap(); ws_store.save_profile(&on_disk).unwrap(); - DeviceSessionRefresher::new(Some(ws_store), base_url, "cli", "ap-southeast-2.aws", None) + DeviceSessionRefresher::new( + Some(ws_store), + base_url, + "cli", + "ap-southeast-2.aws", + None, + default_transport(), + ) } /// If disk holds a different refresh token than the credential we're @@ -360,6 +375,7 @@ mod tests { "cli", "ap-southeast-2.aws", None, + default_transport(), )); let r2 = Arc::new(DeviceSessionRefresher::new( Some(ws_store), @@ -367,6 +383,7 @@ mod tests { "cli", "ap-southeast-2.aws", None, + default_transport(), )); let cred1 = SecretToken::new("shared-refresh"); diff --git a/packages/stack-auth/src/device_session_strategy.rs b/packages/stack-auth/src/device_session_strategy.rs index c6cca53ab..78a22366e 100644 --- a/packages/stack-auth/src/device_session_strategy.rs +++ b/packages/stack-auth/src/device_session_strategy.rs @@ -6,7 +6,8 @@ use stack_profile::ProfileStore; use crate::auto_refresh::AutoRefresh; use crate::device_session_refresher::DeviceSessionRefresher; -use crate::{ensure_trailing_slash, AuthError, AuthStrategy, ServiceToken, Token}; +use crate::transport::{self, SharedTransport}; +use crate::{ensure_trailing_slash, AuthError, AuthStrategy, HttpTransport, ServiceToken, Token}; /// An [`AuthStrategy`] that renews a CTS session minted by an interactive /// OAuth login (the device-code flow), using its OAuth refresh token. @@ -55,6 +56,7 @@ impl DeviceSessionStrategy { token, }, base_url_override: None, + transport: None, } } @@ -64,13 +66,14 @@ impl DeviceSessionStrategy { /// The builder allows further configuration (e.g. overriding the base URL) before building. /// /// The token must have `region` and `client_id` set (as saved by - /// [`DeviceCodeStrategy`](crate::DeviceCodeStrategy) or a prior + /// `DeviceCodeStrategy` (native, with the `http` feature) or a prior /// `DeviceSessionStrategy`). The store is used for persisting refreshed tokens. #[cfg(not(target_arch = "wasm32"))] pub fn with_profile(store: ProfileStore) -> DeviceSessionStrategyBuilder { DeviceSessionStrategyBuilder { source: OAuthTokenSource::Store(store), base_url_override: None, + transport: None, } } @@ -105,9 +108,30 @@ enum OAuthTokenSource { pub struct DeviceSessionStrategyBuilder { source: OAuthTokenSource, base_url_override: Option<url::Url>, + transport: Option<SharedTransport>, } impl DeviceSessionStrategyBuilder { + /// Send this strategy's requests through `transport` instead of the + /// bundled `reqwest` client. + /// + /// Without the `http` feature there is no bundled client, so this is + /// required; with it, this is how a host with its own HTTP stack (or a + /// test with a stub) takes over the wire without changing anything else + /// about the strategy. + pub fn transport(mut self, transport: impl HttpTransport) -> Self { + self.transport = Some(transport::share(transport)); + self + } + + /// [`transport`](Self::transport), for a caller that may or may not + /// have one — the auto strategy hands its own through. Native-only, + /// because on wasm32 the auto strategy has no profile to detect from. + #[cfg(not(target_arch = "wasm32"))] + pub(crate) fn maybe_transport(mut self, transport: Option<SharedTransport>) -> Self { + self.transport = transport; + self + } /// Override the CTS base URL resolved for this strategy. /// /// Takes precedence over both the `CS_CTS_HOST` environment variable and @@ -132,15 +156,19 @@ impl DeviceSessionStrategyBuilder { let Self { source, base_url_override, + transport, } = self; + let transport = transport::resolve(transport)?; match source { OAuthTokenSource::Token { region, client_id, token, - } => Self::build_from_token(region, client_id, token, base_url_override), + } => Self::build_from_token(region, client_id, token, base_url_override, transport), #[cfg(not(target_arch = "wasm32"))] - OAuthTokenSource::Store(store) => Self::build_from_store(store, base_url_override), + OAuthTokenSource::Store(store) => { + Self::build_from_store(store, base_url_override, transport) + } } } @@ -150,6 +178,7 @@ impl DeviceSessionStrategyBuilder { client_id: String, mut token: Token, base_url_override: Option<url::Url>, + transport: SharedTransport, ) -> Result<DeviceSessionStrategy, AuthError> { let base_url = match base_url_override { Some(url) => url, @@ -178,6 +207,7 @@ impl DeviceSessionStrategyBuilder { &client_id, &region_id, device_instance_id, + transport, ); Ok(DeviceSessionStrategy { crn, @@ -190,6 +220,7 @@ impl DeviceSessionStrategyBuilder { fn build_from_store( store: ProfileStore, base_url_override: Option<url::Url>, + transport: SharedTransport, ) -> Result<DeviceSessionStrategy, AuthError> { let ws_store = store.current_workspace_store()?; let token: Token = ws_store.load_profile()?; @@ -222,6 +253,7 @@ impl DeviceSessionStrategyBuilder { &client_id, &region_str, device_instance_id, + transport, ); Ok(DeviceSessionStrategy { crn, @@ -230,7 +262,9 @@ impl DeviceSessionStrategyBuilder { } } -#[cfg(test)] +// These build strategies with no transport of their own, which needs the +// bundled one. +#[cfg(all(test, feature = "http"))] mod tests { use super::*; use crate::test_support::{claims_with_workspace, jwt_token, raw_token}; diff --git a/packages/stack-auth/src/error.rs b/packages/stack-auth/src/error.rs index d40123a15..9f9613bfe 100644 --- a/packages/stack-auth/src/error.rs +++ b/packages/stack-auth/src/error.rs @@ -629,7 +629,6 @@ impl AuthError { /// Matched exhaustively, like `is_retryable`, so a new variant has to /// declare which side of this boundary it's on rather than silently not /// being cached. - #[cfg(feature = "http")] pub(crate) fn is_account_refusal(&self) -> bool { match self { Self::UsageLimitExceeded(_) | Self::OrgNotProvisioned(_) => true, @@ -756,7 +755,6 @@ fn workspace_mismatch_from_payload( /// indistinguishable from a genuine authorization refusal — so the status, not /// the body, decides. `cs_code` is checked when present so that a future 402 /// with a different meaning does not silently inherit this classification. -#[cfg(feature = "http")] pub(crate) fn classify_issuance_failure(status: u16, body: &str) -> Option<AuthError> { if status != 402 { return None; @@ -837,7 +835,6 @@ pub(crate) fn classify_issuance_failure(status: u16, body: &str) -> Option<AuthE } /// Which account-level refusal a 402 body describes. -#[cfg(feature = "http")] enum Refusal { UsageLimit, NotProvisioned, diff --git a/packages/stack-auth/src/lib.rs b/packages/stack-auth/src/lib.rs index 7e55cfc63..ef56dea1c 100644 --- a/packages/stack-auth/src/lib.rs +++ b/packages/stack-auth/src/lib.rs @@ -10,12 +10,15 @@ )] #![cfg_attr( not(feature = "http"), - doc = "\nWithout the `http` feature this crate is the token model plus the\ - [`AuthStrategy`] trait — [`AuthStrategyFn`] and [`TokenStoreFn`] are how a host\ - with its own transport plugs in acquisition and persistence. Enable the `http`\ - feature for the bundled strategies (`AutoStrategy`, `AccessKeyStrategy`,\ - `DeviceSessionStrategy`, `DeviceCodeStrategy`), the refresh engine, and the\ - crate's full documentation." + doc = "\nWithout the `http` feature this crate has no HTTP client of its own: the\ + strategies (`AutoStrategy`, `AccessKeyStrategy`, `DeviceSessionStrategy`,\ + `OidcFederationStrategy`) and the refresh engine are all here, and each\ + builder must be given an [`HttpTransport`] — how a host with its own\ + transport (a wasm module, say) runs them. [`AuthStrategyFn`] and\ + [`TokenStoreFn`] remain the escape hatches for acquisition and persistence\ + done entirely on the host's side. Enable the `http` feature for the bundled\ + `ReqwestTransport`, the native device-code flow, and the crate's full\ + documentation." )] // Security lints #![deny(unsafe_code)] @@ -33,13 +36,12 @@ #![warn(unused_results)] #![warn(clippy::todo)] #![warn(clippy::unimplemented)] -// Without `http` the crate is the token model plus the `AuthStrategy` trait. -// The crate-internal helpers that only the HTTP strategies call (refusal -// classification, clock sharing, URL massaging, token setters) each carry -// their own `#[cfg(feature = "http")]` gate rather than a crate-wide -// `allow(dead_code)`: the compiler then verifies the partition in both -// directions — no-http code reaching an http helper fails to compile, and -// code that goes dead in the no-http build warns instead of being silenced. +// Without `http` the crate has no HTTP client, not no strategies: `http` is +// the bundled `ReqwestTransport` and the two native flows that use it +// unconditionally (device binding, device code). Everything that needs +// reqwest by name carries its own `#[cfg(feature = "http")]` gate rather +// than a crate-wide `allow(dead_code)`, so the compiler verifies the +// partition in both directions. // Relax in tests #![cfg_attr(test, allow(clippy::unwrap_used))] #![cfg_attr(test, allow(clippy::expect_used))] @@ -47,13 +49,6 @@ #![cfg_attr(test, allow(unused_results))] use std::future::Future; -#[cfg(all( - feature = "http", - not(any(test, feature = "test-utils")), - not(target_arch = "wasm32") -))] -use std::time::Duration; - use vitaminc::protected::OpaqueDebug; use zeroize::ZeroizeOnDrop; @@ -64,30 +59,21 @@ mod error; mod service_token; mod token; mod token_store; +mod transport; // The strategies that acquire and refresh tokens over HTTP, and the refresh -// engine they share. Behind the `http` feature: without it the crate is the -// token model plus the `AuthStrategy` trait, for hosts that source tokens -// through their own transport. -#[cfg(feature = "http")] +// engine they share. In every build: they send through whatever +// `HttpTransport` their builder was given, and only the bundled +// `ReqwestTransport` (their default) is behind the `http` feature. mod access_key_refresher; -#[cfg(feature = "http")] mod access_key_strategy; -#[cfg(feature = "http")] mod authorize_dto; -#[cfg(feature = "http")] mod auto_refresh; -#[cfg(feature = "http")] mod auto_strategy; -#[cfg(feature = "http")] mod device_session_refresher; -#[cfg(feature = "http")] mod device_session_strategy; -#[cfg(feature = "http")] mod oidc_federation_strategy; -#[cfg(feature = "http")] mod oidc_refresher; -#[cfg(feature = "http")] mod refresher; #[cfg(not(target_arch = "wasm32"))] @@ -115,22 +101,20 @@ mod static_token_strategy; mod test_support; pub use access_key::{AccessKey, InvalidAccessKey}; -#[cfg(feature = "http")] pub use access_key_strategy::{AccessKeyStrategy, AccessKeyStrategyBuilder}; pub use auth_strategy_fn::AuthStrategyFn; -#[cfg(feature = "http")] pub use auto_strategy::{AutoStrategy, AutoStrategyBuilder}; -#[cfg(feature = "http")] pub use device_session_strategy::{DeviceSessionStrategy, DeviceSessionStrategyBuilder}; -#[cfg(feature = "http")] pub use oidc_federation_strategy::{OidcFederationStrategy, OidcFederationStrategyBuilder}; -#[cfg(feature = "http")] pub use oidc_refresher::{OidcProvider, OidcProviderFn}; pub use service_token::ServiceToken; #[cfg(any(test, feature = "test-utils"))] pub use static_token_strategy::StaticTokenStrategy; pub use token::Token; pub use token_store::{InMemoryTokenStore, NoStore, TokenStore, TokenStoreFn}; +#[cfg(feature = "http")] +pub use transport::ReqwestTransport; +pub use transport::{HttpRequest, HttpResponse, HttpTransport}; /// Deprecated alias for [`DeviceSessionStrategy`]. /// @@ -138,12 +122,10 @@ pub use token_store::{InMemoryTokenStore, NoStore, TokenStore, TokenStoreFn}; /// ([`OidcFederationStrategy`]) distinction explicit. The old name still /// resolves so existing code keeps compiling; it will be removed in a future /// major release. -#[cfg(feature = "http")] #[deprecated(since = "0.36.0", note = "renamed to `DeviceSessionStrategy`")] pub type OAuthStrategy = DeviceSessionStrategy; /// Deprecated alias for [`DeviceSessionStrategyBuilder`]. -#[cfg(feature = "http")] #[deprecated(since = "0.36.0", note = "renamed to `DeviceSessionStrategyBuilder`")] pub type OAuthStrategyBuilder = DeviceSessionStrategyBuilder; @@ -179,11 +161,13 @@ pub use cts_common::Crn; /// All items in this module are also re-exported at the crate root. pub mod auth { pub use crate::{ - AccessKey, AuthError, AuthStrategy, AuthStrategyBounds, AuthStrategyFn, InvalidAccessKey, - SecretToken, ServiceToken, + AccessKey, AuthError, AuthStrategy, AuthStrategyBounds, AuthStrategyFn, HttpRequest, + HttpResponse, HttpTransport, InvalidAccessKey, SecretToken, ServiceToken, }; #[cfg(feature = "http")] + pub use crate::ReqwestTransport; + pub use crate::{ AccessKeyStrategy, AccessKeyStrategyBuilder, AutoStrategy, AutoStrategyBuilder, DeviceSessionStrategy, DeviceSessionStrategyBuilder, OidcFederationStrategy, @@ -205,7 +189,6 @@ pub mod auth { // Deprecated aliases, re-exported here too so `stack_auth::auth::OAuthStrategy` // consumers keep compiling alongside the crate-root aliases. See the // `OAuthStrategy` / `OAuthStrategyBuilder` definitions at the crate root. - #[cfg(feature = "http")] #[allow(deprecated)] pub use crate::{OAuthStrategy, OAuthStrategyBuilder}; } @@ -382,7 +365,6 @@ impl SecretToken { /// Returns `Ok(None)` if the variable is not set or empty. /// Returns `Ok(Some(url))` if the variable is set and valid. /// Returns `Err(_)` if the variable is set but not a valid URL. -#[cfg(feature = "http")] pub(crate) fn cts_base_url_from_env() -> Result<Option<url::Url>, AuthError> { match std::env::var("CS_CTS_HOST") { Ok(val) if !val.is_empty() => Ok(Some(val.parse()?)), @@ -392,7 +374,6 @@ pub(crate) fn cts_base_url_from_env() -> Result<Option<url::Url>, AuthError> { /// Ensure a URL has a trailing slash so that `Url::join` with relative paths /// appends to the path rather than replacing the last segment. -#[cfg(feature = "http")] pub(crate) fn ensure_trailing_slash(mut url: url::Url) -> url::Url { if !url.path().ends_with('/') { url.set_path(&format!("{}/", url.path())); @@ -431,47 +412,6 @@ where }) } -/// Create a [`reqwest::Client`] with standard timeouts. -/// -/// In test builds, timeouts are omitted so that `tokio::test(start_paused = true)` -/// does not auto-advance time past the connect timeout before the mock server -/// can respond. On wasm32, reqwest's fetch backend doesn't expose -/// `connect_timeout`/`pool_*` — the host runtime owns those concerns. -#[cfg(all(feature = "http", any(test, feature = "test-utils")))] -pub(crate) fn http_client() -> reqwest::Client { - reqwest::Client::builder() - .build() - .unwrap_or_else(|_| reqwest::Client::new()) -} - -#[cfg(all( - feature = "http", - not(any(test, feature = "test-utils")), - not(target_arch = "wasm32") -))] -pub(crate) fn http_client() -> reqwest::Client { - reqwest::Client::builder() - .connect_timeout(Duration::from_secs(10)) - .timeout(Duration::from_secs(30)) - .pool_idle_timeout(Duration::from_secs(5)) - .pool_max_idle_per_host(10) - .build() - .unwrap_or_else(|_| reqwest::Client::new()) -} - -#[cfg(all( - feature = "http", - not(any(test, feature = "test-utils")), - target_arch = "wasm32" -))] -pub(crate) fn http_client() -> reqwest::Client { - // Wasm32 reqwest uses the host's `fetch`; timeouts and pooling are owned - // by the runtime, so `ClientBuilder` doesn't expose them here. - reqwest::Client::builder() - .build() - .unwrap_or_else(|_| reqwest::Client::new()) -} - #[cfg(test)] mod tests { use super::*; diff --git a/packages/stack-auth/src/oidc_federation_strategy.rs b/packages/stack-auth/src/oidc_federation_strategy.rs index 94aa1c2b2..620538f37 100644 --- a/packages/stack-auth/src/oidc_federation_strategy.rs +++ b/packages/stack-auth/src/oidc_federation_strategy.rs @@ -3,6 +3,8 @@ use cts_common::{Crn, CtsServiceDiscovery, ServiceDiscovery, WorkspaceId}; use crate::auto_refresh::AutoRefresh; use crate::oidc_refresher::{OidcProvider, OidcRefresher}; use crate::token_store::{NoStore, TokenStore}; +use crate::transport::{self, SharedTransport}; +use crate::HttpTransport; use crate::{ensure_trailing_slash, AuthError, AuthStrategy, ServiceToken}; /// An [`AuthStrategy`] that federates a third-party OIDC JWT (Clerk, Supabase, @@ -78,6 +80,7 @@ impl<P: OidcProvider> OidcFederationStrategy<P> { oidc_provider, base_url_override: None, token_store: NoStore, + transport: None, } } } @@ -99,9 +102,21 @@ pub struct OidcFederationStrategyBuilder<P, S = NoStore> { oidc_provider: P, base_url_override: Option<url::Url>, token_store: S, + transport: Option<SharedTransport>, } impl<P, S> OidcFederationStrategyBuilder<P, S> { + /// Send this strategy's requests through `transport` instead of the + /// bundled `reqwest` client. + /// + /// Without the `http` feature there is no bundled client, so this is + /// required; with it, this is how a host with its own HTTP stack (or a + /// test with a stub) takes over the wire without changing anything else + /// about the strategy. + pub fn transport(mut self, transport: impl HttpTransport) -> Self { + self.transport = Some(transport::share(transport)); + self + } /// Override the base URL resolved by service discovery. /// /// Takes precedence over both the `CS_CTS_HOST` environment variable and @@ -149,6 +164,7 @@ impl<P, S> OidcFederationStrategyBuilder<P, S> { oidc_provider: self.oidc_provider, base_url_override: self.base_url_override, token_store: store, + transport: self.transport, } } } @@ -174,6 +190,7 @@ impl<P: OidcProvider, S: TokenStore> OidcFederationStrategyBuilder<P, S> { self.oidc_provider, expected_workspace, ensure_trailing_slash(base_url), + transport::resolve(self.transport)?, ); Ok(OidcFederationStrategy { inner: AutoRefresh::with_store(refresher, self.token_store), @@ -182,7 +199,7 @@ impl<P: OidcProvider, S: TokenStore> OidcFederationStrategyBuilder<P, S> { } } -#[cfg(test)] +#[cfg(all(test, feature = "http"))] #[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] mod tests { use std::sync::Arc; diff --git a/packages/stack-auth/src/oidc_refresher.rs b/packages/stack-auth/src/oidc_refresher.rs index 56711a411..4cc493aa8 100644 --- a/packages/stack-auth/src/oidc_refresher.rs +++ b/packages/stack-auth/src/oidc_refresher.rs @@ -1,12 +1,12 @@ use std::future::Future; -use std::sync::Arc; use cts_common::WorkspaceId; use url::Url; use crate::authorize_dto::AuthoriseResponse; use crate::refresher::Refresher; -use crate::{http_client, AuthError, SecretToken, Token}; +use crate::transport::{self, SharedTransport}; +use crate::{AuthError, SecretToken, Token}; /// Asynchronously supplies the *current* third-party OIDC JWT to federate. /// @@ -111,16 +111,21 @@ pub(crate) struct OidcRefresher<P> { oidc_provider: P, workspace_id: WorkspaceId, base_url: Url, - http_client: Arc<reqwest::Client>, + transport: SharedTransport, } impl<P> OidcRefresher<P> { - pub(crate) fn new(oidc_provider: P, workspace_id: WorkspaceId, base_url: Url) -> Self { + pub(crate) fn new( + oidc_provider: P, + workspace_id: WorkspaceId, + base_url: Url, + transport: SharedTransport, + ) -> Self { Self { oidc_provider, workspace_id, base_url, - http_client: Arc::new(http_client()), + transport, } } } @@ -148,21 +153,21 @@ impl<P: OidcProvider> Refresher for OidcRefresher<P> { let url = self.base_url.join("api/authorise")?; tracing::debug!(url = %url, "federating OIDC token"); - let resp = self - .http_client - .post(url) - .json(&OidcAuthoriseRequest { + let resp = transport::post_json( + &self.transport, + url, + &OidcAuthoriseRequest { oidc_token: oidc_token.as_str(), workspace_id: self.workspace_id.as_str(), - }) - .send() - .await?; + }, + ) + .await?; - if !resp.status().is_success() { + if !resp.is_success() { let status = resp.status(); - let body = resp.text().await.unwrap_or_default(); + let body = resp.text(); tracing::debug!(%status, %body, "OIDC federation failed"); - if let Some(err) = crate::error::classify_issuance_failure(status.as_u16(), &body) { + if let Some(err) = crate::error::classify_issuance_failure(status, &body) { return Err(err); } return Err(AuthError::Server(crate::error::ServerError(format!( @@ -170,7 +175,7 @@ impl<P: OidcProvider> Refresher for OidcRefresher<P> { )))); } - let auth_resp: AuthoriseResponse = resp.json().await?; + let auth_resp: AuthoriseResponse = resp.json()?; // The response → Token mapping (including the absolute-epoch `expiry` // handling that CIP-3233 fixed) lives on `From<AuthoriseResponse>`. @@ -185,9 +190,10 @@ struct OidcAuthoriseRequest<'a> { workspace_id: &'a str, } -#[cfg(test)] +#[cfg(all(test, feature = "http"))] #[allow(clippy::unwrap_used)] mod tests { + use crate::transport::default_transport; use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::Arc; use std::time::{SystemTime, UNIX_EPOCH}; @@ -243,7 +249,12 @@ mod tests { server: &MockServer, provider: P, ) -> AutoRefresh<OidcRefresher<P>> { - let refresher = OidcRefresher::new(provider, workspace_id(), server.url("")); + let refresher = OidcRefresher::new( + provider, + workspace_id(), + server.url(""), + default_transport(), + ); AutoRefresh::with_store(refresher, crate::NoStore) } @@ -293,7 +304,12 @@ mod tests { let server = start_server(mocks).await; let (_calls, provider) = counting_provider(); - let refresher = OidcRefresher::new(provider, workspace_id(), server.url("")); + let refresher = OidcRefresher::new( + provider, + workspace_id(), + server.url(""), + default_transport(), + ); let token = refresher.refresh(&()).await.unwrap(); assert!( @@ -386,7 +402,12 @@ mod tests { let store = Arc::new(crate::InMemoryTokenStore::new()); store.save(&make_token("stale-cts-token", 0)).await; - let refresher = OidcRefresher::new(provider, workspace_id(), server.url("")); + let refresher = OidcRefresher::new( + provider, + workspace_id(), + server.url(""), + default_transport(), + ); let strategy = AutoRefresh::with_store(refresher, Arc::clone(&store)); let token = strategy.get_token().await.unwrap(); @@ -488,7 +509,12 @@ mod tests { store.save(&make_token("from-store", 3600)).await; let (calls, provider) = counting_provider(); - let refresher = OidcRefresher::new(provider, workspace_id(), server.url("")); + let refresher = OidcRefresher::new( + provider, + workspace_id(), + server.url(""), + default_transport(), + ); let strategy = AutoRefresh::with_store(refresher, Arc::clone(&store)); let token = strategy.get_token().await.unwrap(); @@ -511,7 +537,12 @@ mod tests { let store = Arc::new(crate::InMemoryTokenStore::new()); let (_calls, provider) = counting_provider(); - let refresher = OidcRefresher::new(provider, workspace_id(), server.url("")); + let refresher = OidcRefresher::new( + provider, + workspace_id(), + server.url(""), + default_transport(), + ); let strategy = AutoRefresh::with_store(refresher, Arc::clone(&store)); let token = strategy.get_token().await.unwrap(); diff --git a/packages/stack-auth/src/service_token.rs b/packages/stack-auth/src/service_token.rs index 0f529b7a5..a822d227a 100644 --- a/packages/stack-auth/src/service_token.rs +++ b/packages/stack-auth/src/service_token.rs @@ -107,7 +107,6 @@ impl ServiceToken { /// different workspace than `expected`. /// - [`AuthError::InvalidToken`] if the token is not a valid JWT or its /// `workspace` claim could not be decoded, so verification can't run. - #[cfg(feature = "http")] pub(crate) fn verify_workspace(self, expected: WorkspaceId) -> Result<Self, AuthError> { let token_workspace = *self.workspace_id()?; if token_workspace != expected { @@ -415,7 +414,6 @@ mod tests { ); } - #[cfg(feature = "http")] #[test] fn verify_workspace_returns_token_when_workspace_matches() { let jwt = make_jwt( @@ -435,7 +433,6 @@ mod tests { ); } - #[cfg(feature = "http")] #[test] fn verify_workspace_errors_with_mismatch_when_workspace_differs() { // make_jwt mints a token for workspace ZVATKW3VHMFG27DY. @@ -461,7 +458,6 @@ mod tests { } } - #[cfg(feature = "http")] #[test] fn verify_workspace_errors_with_invalid_token_for_non_jwt() { // A non-JWT can't be decoded, so verification can't run. diff --git a/packages/stack-auth/src/test_support.rs b/packages/stack-auth/src/test_support.rs index 9f2ec6d53..8bf2030d2 100644 --- a/packages/stack-auth/src/test_support.rs +++ b/packages/stack-auth/src/test_support.rs @@ -59,6 +59,7 @@ pub(crate) fn claims_with_workspace(workspace: &str) -> serde_json::Value { /// carrying the given `workspace` ID. /// /// Only the http-gated CRN-bound strategies have tests that need one. +// Only the mock-server tests, which need the bundled transport, mint these. #[cfg(feature = "http")] pub(crate) fn crn_with_workspace(workspace: &str) -> Crn { format!("crn:ap-southeast-2.aws:{workspace}") @@ -71,6 +72,7 @@ pub(crate) fn crn_with_workspace(workspace: &str) -> Crn { /// workspace verification that CRN-bound strategies run. Unlike /// [`claims_with_workspace`], whose `exp` is a fixed past epoch, the token this /// mints reads as valid. +// Only the mock-server tests, which need the bundled transport, mint these. #[cfg(feature = "http")] pub(crate) fn jwt_with_workspace(workspace: &str) -> String { use jsonwebtoken::{encode, EncodingKey, Header}; diff --git a/packages/stack-auth/src/token.rs b/packages/stack-auth/src/token.rs index e76459fcc..4dffb17c5 100644 --- a/packages/stack-auth/src/token.rs +++ b/packages/stack-auth/src/token.rs @@ -2,8 +2,7 @@ use cts_common::claims::ClientClaims; use cts_common::{Crn, Region, WorkspaceId}; use url::Url; -#[cfg(feature = "http")] -use crate::http_client; +use crate::transport::{self, SharedTransport}; use crate::{AuthError, SecretToken}; #[cfg(not(target_arch = "wasm32"))] @@ -128,13 +127,11 @@ impl Token { } /// Set the region identifier on this token. - #[cfg(feature = "http")] pub(crate) fn set_region(&mut self, region: impl Into<String>) { self.region = Some(region.into()); } /// Set the client ID on this token. - #[cfg(feature = "http")] pub(crate) fn set_client_id(&mut self, client_id: impl Into<String>) { self.client_id = Some(client_id.into()); } @@ -145,7 +142,6 @@ impl Token { } /// Set the device instance ID on this token. - #[cfg(feature = "http")] pub(crate) fn set_device_instance_id(&mut self, id: impl Into<String>) { self.device_instance_id = Some(id.into()); } @@ -242,23 +238,43 @@ impl Token { base_url: &Url, client_id: &str, device_instance_id: Option<&str>, + ) -> Result<Token, AuthError> { + Self::refresh_with( + &transport::default_transport(), + refresh_token, + base_url, + client_id, + device_instance_id, + ) + .await + } + + /// [`refresh`](Self::refresh) over a given transport: the form every + /// build has, and the one the device-session refresher calls. + pub(crate) async fn refresh_with( + transport: &SharedTransport, + refresh_token: &SecretToken, + base_url: &Url, + client_id: &str, + device_instance_id: Option<&str>, ) -> Result<Token, AuthError> { let token_url = base_url.join("oauth/token")?; tracing::debug!(url = %token_url, "refreshing token"); - let resp = http_client() - .post(token_url) - .form(&RefreshRequest { + let resp = transport::post_form( + transport, + token_url, + &RefreshRequest { grant_type: "refresh_token", client_id, refresh_token: refresh_token.as_str(), device_instance_id, - }) - .send() - .await?; + }, + ) + .await?; - if !resp.status().is_success() { + if !resp.is_success() { let status = resp.status(); // Read the body once as text and offer it to the shared classifier @@ -268,10 +284,10 @@ impl Token { // through one classifier is what stops `/oauth/token` — the path // `DeviceSessionRefresher` delegates to — from disagreeing with // `/api/authorize` about what the same response means. - let body = resp.text().await?; + let body = resp.text(); tracing::debug!(%status, %body, "token refresh failed"); - if let Some(err) = crate::error::classify_issuance_failure(status.as_u16(), &body) { + if let Some(err) = crate::error::classify_issuance_failure(status, &body) { return Err(err); } @@ -289,7 +305,7 @@ impl Token { }); } - let token_resp: RefreshResponse = resp.json().await?; + let token_resp: RefreshResponse = resp.json()?; Ok(Token { access_token: token_resp.access_token, @@ -306,7 +322,6 @@ impl Token { } } -#[cfg(feature = "http")] #[derive(serde::Serialize)] struct RefreshRequest<'a> { grant_type: &'a str, @@ -316,7 +331,6 @@ struct RefreshRequest<'a> { device_instance_id: Option<&'a str>, } -#[cfg(feature = "http")] #[derive(serde::Deserialize)] struct RefreshResponse { access_token: SecretToken, @@ -331,7 +345,6 @@ struct RefreshResponse { /// `cs_code` is deliberately absent: `classify_issuance_failure` inspects it /// on the raw body before this type is ever constructed, so duplicating the /// field here would create a second place for the two to disagree. -#[cfg(feature = "http")] #[derive(serde::Deserialize)] struct RefreshErrorResponse { error: String, diff --git a/packages/stack-auth/src/transport.rs b/packages/stack-auth/src/transport.rs new file mode 100644 index 000000000..6b3bbb5d8 --- /dev/null +++ b/packages/stack-auth/src/transport.rs @@ -0,0 +1,652 @@ +//! The HTTP seam: how a strategy's requests reach the network. +//! +//! Every exchange this crate makes has one shape — post a body to a URL, +//! read the status and body back — and that is the whole of +//! [`HttpTransport`]. Its request and response are the ones the Go +//! binding's guest already carries across its `transport_send` host +//! import: method, URL, headers and body in; status, headers and body out; +//! all bytes, no streaming. So a host with its own HTTP client implements +//! the trait, and the strategies run unchanged over it — inside a wasm +//! module with no TLS stack of its own, or anywhere else `reqwest` is the +//! wrong choice. +//! +//! With the `http` feature, [`ReqwestTransport`] is the implementation +//! every builder uses unless told otherwise, so native callers see no +//! difference. Without it, a builder must be handed a transport. +//! +//! Bodies are wiped on drop on both halves: a request carries an access key +//! or a refresh token, and a response carries the token that was minted. + +use std::future::Future; +use std::sync::Arc; + +use url::Url; +use zeroize::Zeroizing; + +use crate::error::RequestError; +use crate::AuthError; + +/// One HTTP request, as a transport receives it. +/// +/// The shape is the guest host import's, deliberately: a transport that +/// can carry this can carry every request the crate makes, and nothing the +/// crate makes needs more. +#[derive(Debug)] +pub struct HttpRequest { + method: &'static str, + url: Url, + headers: Vec<(String, String)>, + body: Zeroizing<Vec<u8>>, +} + +impl HttpRequest { + /// The HTTP method, upper-case (`"POST"`). + pub fn method(&self) -> &str { + self.method + } + + /// The absolute URL to send to. + pub fn url(&self) -> &Url { + &self.url + } + + /// The request headers, in order. Names are lower-case. + pub fn headers(&self) -> &[(String, String)] { + &self.headers + } + + /// The request body. Wiped when the request is dropped. + pub fn body(&self) -> &[u8] { + &self.body + } +} + +/// One HTTP response, as a transport returns it. +#[derive(Debug)] +pub struct HttpResponse { + status: u16, + headers: Vec<(String, String)>, + body: Zeroizing<Vec<u8>>, +} + +impl HttpResponse { + /// A response with `status`, `headers` and `body`. The body is wiped + /// when the response is dropped. + pub fn new(status: u16, headers: Vec<(String, String)>, body: Vec<u8>) -> Self { + Self { + status, + headers, + body: Zeroizing::new(body), + } + } + + /// The HTTP status code. + pub fn status(&self) -> u16 { + self.status + } + + /// The response headers, in order. + pub fn headers(&self) -> &[(String, String)] { + &self.headers + } + + /// The response body. + pub fn body(&self) -> &[u8] { + &self.body + } + + pub(crate) fn is_success(&self) -> bool { + (200..300).contains(&self.status) + } + + /// The body as text, for logging and for the error classifiers. + pub(crate) fn text(&self) -> String { + String::from_utf8_lossy(&self.body).into_owned() + } + + /// The body decoded as JSON. A body that does not decode is reported as + /// a request failure, as it was when the HTTP client did the decoding. + pub(crate) fn json<T: serde::de::DeserializeOwned>(&self) -> Result<T, AuthError> { + serde_json::from_slice(&self.body) + .map_err(|e| AuthError::Request(RequestError(Box::new(e)))) + } +} + +/// Carries one HTTP request and returns its response. +/// +/// On native targets the trait carries `Send + Sync` bounds so a strategy +/// holding a transport can be driven from `tokio::spawn` background work. +/// On wasm32 the bounds are dropped — a fetch-backed future is not `Send` +/// and edge runtimes are single-threaded anyway — matching every other +/// async trait in this crate. +/// +/// A failure to get any response at all (the host is unreachable, the +/// connection dropped) is a [`RequestError`]. A response with an error +/// status is not a failure of the transport: return it, and the strategy +/// classifies it. +#[cfg(not(target_arch = "wasm32"))] +pub trait HttpTransport: Send + Sync + 'static { + /// Send `request` and return the response. + fn send( + &self, + request: HttpRequest, + ) -> impl Future<Output = Result<HttpResponse, RequestError>> + Send; +} + +/// Wasm32 variant of [`HttpTransport`] — drops the `Send + Sync` bounds. +#[cfg(target_arch = "wasm32")] +pub trait HttpTransport: 'static { + /// Send `request` and return the response. + fn send( + &self, + request: HttpRequest, + ) -> impl Future<Output = Result<HttpResponse, RequestError>>; +} + +// --------------------------------------------------------------------------- +// The crate-internal, object-safe view. +// +// `HttpTransport` returns `impl Future`, which is the crate's convention and +// the easiest thing to implement — and not object-safe. The strategies want +// one concrete type for "whatever transport was configured" rather than a +// type parameter on every public strategy, so this adapter boxes the future +// once, at construction, and nothing else in the crate names the transport's +// concrete type again. +// --------------------------------------------------------------------------- + +#[cfg(not(target_arch = "wasm32"))] +pub(crate) trait DynTransport: Send + Sync { + fn send_dyn<'a>( + &'a self, + request: HttpRequest, + ) -> std::pin::Pin<Box<dyn Future<Output = Result<HttpResponse, RequestError>> + Send + 'a>>; +} + +#[cfg(not(target_arch = "wasm32"))] +impl<T: HttpTransport> DynTransport for T { + fn send_dyn<'a>( + &'a self, + request: HttpRequest, + ) -> std::pin::Pin<Box<dyn Future<Output = Result<HttpResponse, RequestError>> + Send + 'a>> + { + Box::pin(self.send(request)) + } +} + +#[cfg(target_arch = "wasm32")] +pub(crate) trait DynTransport { + fn send_dyn<'a>( + &'a self, + request: HttpRequest, + ) -> std::pin::Pin<Box<dyn Future<Output = Result<HttpResponse, RequestError>> + 'a>>; +} + +#[cfg(target_arch = "wasm32")] +impl<T: HttpTransport> DynTransport for T { + fn send_dyn<'a>( + &'a self, + request: HttpRequest, + ) -> std::pin::Pin<Box<dyn Future<Output = Result<HttpResponse, RequestError>> + 'a>> { + Box::pin(self.send(request)) + } +} + +/// The transport a strategy holds: whichever implementation it was built +/// with, behind one type. +pub(crate) type SharedTransport = Arc<dyn DynTransport>; + +/// Box `transport` once, for the strategies to share. +pub(crate) fn share(transport: impl HttpTransport) -> SharedTransport { + Arc::new(transport) +} + +/// The transport a builder ends up with: the one it was given, else the +/// bundled `reqwest` client, else an error — a strategy cannot exist +/// without a way to send. +pub(crate) fn resolve(configured: Option<SharedTransport>) -> Result<SharedTransport, AuthError> { + match configured { + Some(transport) => Ok(transport), + #[cfg(feature = "http")] + None => Ok(default_transport()), + #[cfg(not(feature = "http"))] + None => Err(AuthError::Request(RequestError(Box::new(NoTransport)))), + } +} + +/// No transport was configured and the crate was built without `http`. +#[cfg(not(feature = "http"))] +#[derive(Debug)] +pub(crate) struct NoTransport; + +#[cfg(not(feature = "http"))] +impl std::fmt::Display for NoTransport { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str( + "no HTTP transport: this build of stack-auth has no `http` feature, \ + so the strategy must be given one with `.transport(..)`", + ) + } +} + +#[cfg(not(feature = "http"))] +impl std::error::Error for NoTransport {} + +// --------------------------------------------------------------------------- +// The two request shapes the crate makes. +// --------------------------------------------------------------------------- + +/// `POST` a JSON body. +pub(crate) async fn post_json<B: serde::Serialize>( + transport: &SharedTransport, + url: Url, + body: &B, +) -> Result<HttpResponse, AuthError> { + let body = Zeroizing::new( + serde_json::to_vec(body).map_err(|e| AuthError::Request(RequestError(Box::new(e))))?, + ); + post(transport, url, "application/json", Vec::new(), body).await +} + +/// `POST` a form (`application/x-www-form-urlencoded`) body. +pub(crate) async fn post_form<B: serde::Serialize>( + transport: &SharedTransport, + url: Url, + body: &B, +) -> Result<HttpResponse, AuthError> { + let body = Zeroizing::new( + serde_urlencoded::to_string(body) + .map_err(|e| AuthError::Request(RequestError(Box::new(e))))? + .into_bytes(), + ); + post( + transport, + url, + "application/x-www-form-urlencoded", + Vec::new(), + body, + ) + .await +} + +/// `POST` `body` as `content_type`, with `extra` headers first. +pub(crate) async fn post( + transport: &SharedTransport, + url: Url, + content_type: &str, + mut extra: Vec<(String, String)>, + body: Zeroizing<Vec<u8>>, +) -> Result<HttpResponse, AuthError> { + extra.push(("content-type".to_string(), content_type.to_string())); + let request = HttpRequest { + method: "POST", + url, + headers: extra, + body, + }; + transport + .send_dyn(request) + .await + .map_err(AuthError::Request) +} + +// --------------------------------------------------------------------------- +// The bundled implementation. +// --------------------------------------------------------------------------- + +/// [`HttpTransport`] over a [`reqwest::Client`]: what every strategy uses +/// unless a builder is given something else. +/// +/// [`Default`] builds the client with the crate's standard timeouts and +/// pool settings; [`ReqwestTransport::new`] takes a client configured by +/// the caller. +#[cfg(feature = "http")] +#[derive(Debug, Clone)] +pub struct ReqwestTransport { + client: reqwest::Client, +} + +#[cfg(feature = "http")] +impl ReqwestTransport { + /// A transport over `client`. + pub fn new(client: reqwest::Client) -> Self { + Self { client } + } +} + +#[cfg(feature = "http")] +impl Default for ReqwestTransport { + fn default() -> Self { + Self::new(http_client()) + } +} + +#[cfg(feature = "http")] +impl HttpTransport for ReqwestTransport { + async fn send(&self, request: HttpRequest) -> Result<HttpResponse, RequestError> { + let method = reqwest::Method::from_bytes(request.method.as_bytes()) + .map_err(|e| RequestError(Box::new(e)))?; + let mut builder = self.client.request(method, request.url); + for (name, value) in &request.headers { + builder = builder.header(name.as_str(), value.as_str()); + } + let response = builder.body(request.body.to_vec()).send().await?; + let status = response.status().as_u16(); + let headers = response + .headers() + .iter() + .map(|(name, value)| { + ( + name.as_str().to_string(), + String::from_utf8_lossy(value.as_bytes()).into_owned(), + ) + }) + .collect(); + let body = response.bytes().await?.to_vec(); + Ok(HttpResponse::new(status, headers, body)) + } +} + +/// The bundled transport, boxed for the strategies. +#[cfg(feature = "http")] +pub(crate) fn default_transport() -> SharedTransport { + share(ReqwestTransport::default()) +} + +/// Create a [`reqwest::Client`] with standard timeouts. +/// +/// In test builds, timeouts are omitted so that `tokio::test(start_paused = true)` +/// does not auto-advance time past the connect timeout before the mock server +/// can respond. On wasm32, reqwest's fetch backend doesn't expose +/// `connect_timeout`/`pool_*` — the host runtime owns those concerns. +#[cfg(all(feature = "http", any(test, feature = "test-utils")))] +fn http_client() -> reqwest::Client { + reqwest::Client::builder() + .build() + .unwrap_or_else(|_| reqwest::Client::new()) +} + +#[cfg(all( + feature = "http", + not(any(test, feature = "test-utils")), + not(target_arch = "wasm32") +))] +fn http_client() -> reqwest::Client { + use std::time::Duration; + + reqwest::Client::builder() + .connect_timeout(Duration::from_secs(10)) + .timeout(Duration::from_secs(30)) + .pool_idle_timeout(Duration::from_secs(5)) + .pool_max_idle_per_host(10) + .build() + .unwrap_or_else(|_| reqwest::Client::new()) +} + +#[cfg(all( + feature = "http", + not(any(test, feature = "test-utils")), + target_arch = "wasm32" +))] +fn http_client() -> reqwest::Client { + // Wasm32 reqwest uses the host's `fetch`; timeouts and pooling are owned + // by the runtime, so `ClientBuilder` doesn't expose them here. + reqwest::Client::builder() + .build() + .unwrap_or_else(|_| reqwest::Client::new()) +} + +#[cfg(test)] +mod tests { + use std::sync::Mutex; + + use super::*; + use crate::access_key_refresher::AccessKeyRefresher; + use crate::oidc_refresher::{OidcProviderFn, OidcRefresher}; + use crate::refresher::Refresher; + use crate::{SecretToken, Token}; + + /// What the stub saw: method, URL, headers, body. + type Seen = (String, String, Vec<(String, String)>, Vec<u8>); + + /// A transport that answers every request with one canned response and + /// remembers what it was asked, so a test can pin the wire shape without + /// an HTTP client in the build. + struct Stub { + response: Mutex<Option<Result<(u16, &'static str), &'static str>>>, + seen: Mutex<Vec<Seen>>, + } + + impl Stub { + fn replying(status: u16, body: &'static str) -> Self { + Self { + response: Mutex::new(Some(Ok((status, body)))), + seen: Mutex::new(Vec::new()), + } + } + + fn failing(message: &'static str) -> Self { + Self { + response: Mutex::new(Some(Err(message))), + seen: Mutex::new(Vec::new()), + } + } + } + + impl HttpTransport for Stub { + async fn send(&self, request: HttpRequest) -> Result<HttpResponse, RequestError> { + self.seen.lock().unwrap().push(( + request.method().to_string(), + request.url().to_string(), + request.headers().to_vec(), + request.body().to_vec(), + )); + match self.response.lock().unwrap().take().expect("one request") { + Ok((status, body)) => Ok(HttpResponse::new(status, Vec::new(), body.into())), + Err(message) => Err(RequestError(Box::new(std::io::Error::other(message)))), + } + } + } + + fn base_url() -> Url { + "https://cts.example.com/".parse().unwrap() + } + + fn workspace_id() -> cts_common::WorkspaceId { + "ZVATKW3VHMFG27DY".parse().unwrap() + } + + fn seen(stub: &Arc<Stub>) -> Seen { + stub.seen.lock().unwrap().remove(0) + } + + #[tokio::test] + async fn refresh_posts_a_form_and_reads_the_token() { + let stub = Arc::new(Stub::replying( + 200, + r#"{"access_token":"new","token_type":"Bearer","expires_in":3600,"refresh_token":"rotated"}"#, + )); + let transport: SharedTransport = stub.clone(); + + let token = Token::refresh_with( + &transport, + &SecretToken::new("rt"), + &base_url(), + "cli", + None, + ) + .await + .unwrap(); + + assert_eq!(token.access_token().as_str(), "new"); + assert_eq!(token.refresh_token().unwrap().as_str(), "rotated"); + let (method, url, headers, body) = seen(&stub); + assert_eq!(method, "POST"); + assert_eq!(url, "https://cts.example.com/oauth/token"); + assert!(headers.contains(&( + "content-type".to_string(), + "application/x-www-form-urlencoded".to_string() + ))); + assert_eq!( + body, b"grant_type=refresh_token&client_id=cli&refresh_token=rt", + "an absent device id is omitted, not sent empty" + ); + } + + #[tokio::test] + async fn refresh_classifies_the_oauth_error_body() { + for (error, check) in [ + ( + "invalid_grant", + (|e| matches!(e, AuthError::InvalidGrant(_))) as fn(&AuthError) -> bool, + ), + ("invalid_client", |e| { + matches!(e, AuthError::InvalidClient(_)) + }), + ("access_denied", |e| matches!(e, AuthError::AccessDenied(_))), + ] { + let body: &'static str = match error { + "invalid_grant" => r#"{"error":"invalid_grant"}"#, + "invalid_client" => r#"{"error":"invalid_client"}"#, + _ => r#"{"error":"access_denied"}"#, + }; + let transport: SharedTransport = Arc::new(Stub::replying(400, body)); + let err = Token::refresh_with( + &transport, + &SecretToken::new("rt"), + &base_url(), + "cli", + None, + ) + .await + .unwrap_err(); + assert!(check(&err), "{error}: {err:?}"); + } + } + + #[tokio::test] + async fn access_key_posts_json_and_maps_the_response() { + let stub = Arc::new(Stub::replying( + 200, + r#"{"accessToken":"svc","expiry":4102444800}"#, + )); + let refresher = AccessKeyRefresher::new( + SecretToken::new("CSAKid.secret"), + base_url(), + Some("aud".into()), + stub.clone(), + ); + + let token = refresher.refresh(&()).await.unwrap(); + + assert_eq!(token.access_token().as_str(), "svc"); + let (method, url, headers, body) = seen(&stub); + assert_eq!(method, "POST"); + assert_eq!(url, "https://cts.example.com/api/authorise"); + assert!(headers.contains(&("content-type".to_string(), "application/json".to_string()))); + assert_eq!( + serde_json::from_slice::<serde_json::Value>(&body).unwrap(), + serde_json::json!({"accessKey": "CSAKid.secret", "audience": "aud"}) + ); + } + + #[tokio::test] + async fn a_bare_402_is_a_usage_limit_on_every_exchange() { + let transport: SharedTransport = Arc::new(Stub::replying(402, "")); + let refresher = AccessKeyRefresher::new( + SecretToken::new("CSAKid.secret"), + base_url(), + None, + transport, + ); + let err = refresher.refresh(&()).await.unwrap_err(); + assert!(matches!(err, AuthError::UsageLimitExceeded(_)), "{err:?}"); + + let transport: SharedTransport = Arc::new(Stub::replying(402, "")); + let provider = OidcProviderFn::new(|| async { Ok(SecretToken::new("h.p.s")) }); + let refresher = OidcRefresher::new(provider, workspace_id(), base_url(), transport); + let err = refresher.refresh(&()).await.unwrap_err(); + assert!(matches!(err, AuthError::UsageLimitExceeded(_)), "{err:?}"); + + let transport: SharedTransport = Arc::new(Stub::replying(402, "")); + let err = Token::refresh_with( + &transport, + &SecretToken::new("rt"), + &base_url(), + "cli", + None, + ) + .await + .unwrap_err(); + assert!(matches!(err, AuthError::UsageLimitExceeded(_)), "{err:?}"); + } + + #[tokio::test] + async fn an_unclassified_failure_is_a_server_error_with_the_body() { + let transport: SharedTransport = Arc::new(Stub::replying(500, "boom")); + let refresher = AccessKeyRefresher::new( + SecretToken::new("CSAKid.secret"), + base_url(), + None, + transport, + ); + let err = refresher.refresh(&()).await.unwrap_err(); + match err { + AuthError::Server(e) => { + assert!(e.to_string().contains("500") && e.to_string().contains("boom")) + } + other => panic!("{other:?}"), + } + } + + #[tokio::test] + async fn a_transport_failure_is_a_request_error() { + let transport: SharedTransport = Arc::new(Stub::failing("connection refused")); + let refresher = AccessKeyRefresher::new( + SecretToken::new("CSAKid.secret"), + base_url(), + None, + transport, + ); + let err = refresher.refresh(&()).await.unwrap_err(); + match err { + AuthError::Request(e) => assert!(e.to_string().contains("connection refused")), + other => panic!("{other:?}"), + } + } + + #[tokio::test] + async fn an_undecodable_success_body_is_a_request_error() { + let transport: SharedTransport = Arc::new(Stub::replying(200, "not json")); + let refresher = AccessKeyRefresher::new( + SecretToken::new("CSAKid.secret"), + base_url(), + None, + transport, + ); + let err = refresher.refresh(&()).await.unwrap_err(); + assert!(matches!(err, AuthError::Request(_)), "{err:?}"); + } + + #[cfg(not(feature = "http"))] + #[test] + fn a_builder_without_a_transport_is_refused_when_there_is_no_bundled_one() { + let crn: cts_common::Crn = "crn:ap-southeast-2.aws:ZVATKW3VHMFG27DY".parse().unwrap(); + let key: crate::AccessKey = "CSAKtestKeyId.testKeySecret".parse().unwrap(); + let Err(err) = crate::AccessKeyStrategy::new(crn, key) else { + panic!("built a strategy with nothing to send through"); + }; + assert!(matches!(err, AuthError::Request(_)), "{err:?}"); + assert!(err.to_string().contains("`.transport(..)`"), "{err}"); + } + + #[cfg(not(feature = "http"))] + #[test] + fn a_builder_with_a_transport_builds_without_the_bundled_one() { + let crn: cts_common::Crn = "crn:ap-southeast-2.aws:ZVATKW3VHMFG27DY".parse().unwrap(); + let key: crate::AccessKey = "CSAKtestKeyId.testKeySecret".parse().unwrap(); + assert!(crate::AccessKeyStrategy::builder(crn, key) + .transport(Stub::replying(200, "")) + .build() + .is_ok()); + } +} From ced50d1c5c76cab6524cf25132d7873f430e1888 Mon Sep 17 00:00:00 2001 From: Dan Draper <dan@cipherstash.com> Date: Sun, 20 Sep 2026 12:33:54 -0700 Subject: [PATCH 597/686] fix(stack-auth): test modules keep a literal `cfg(test)` so the CRAP gate skips them; add the npm changeset MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `cargo crap` recognises test code by a literal `#[cfg(test)]` on the module and scores everything else as production code. The transport work rewrote several test gates as `#[cfg(all(test, feature = "http"))]`, which made the stress tests — excluded from the coverage run by design — look like untested functions with cyclomatic complexity 7, and one of them tripped the threshold. Stacking the two conditions as separate attributes says the same thing to rustc and keeps the marker the gate looks for. The pre-existing `all(test, ...)` gates in `clock`, `error` and the device-session refresher get the same form, so they are no longer scored either. The changeset records the transport seam as a patch for the npm package: its builds always use the bundled client, so nothing changes for `@cipherstash/auth` consumers. --- packages/stack-auth/src/access_key_refresher.rs | 3 ++- packages/stack-auth/src/access_key_strategy.rs | 3 ++- packages/stack-auth/src/auto_refresh.rs | 15 ++++++++++----- packages/stack-auth/src/auto_strategy.rs | 3 ++- packages/stack-auth/src/clock.rs | 9 ++++++--- .../stack-auth/src/device_session_refresher.rs | 3 ++- .../stack-auth/src/device_session_strategy.rs | 3 ++- packages/stack-auth/src/error.rs | 3 ++- .../stack-auth/src/oidc_federation_strategy.rs | 3 ++- packages/stack-auth/src/oidc_refresher.rs | 3 ++- 10 files changed, 32 insertions(+), 16 deletions(-) diff --git a/packages/stack-auth/src/access_key_refresher.rs b/packages/stack-auth/src/access_key_refresher.rs index 0f62a29e9..54cfe0698 100644 --- a/packages/stack-auth/src/access_key_refresher.rs +++ b/packages/stack-auth/src/access_key_refresher.rs @@ -91,7 +91,8 @@ struct AuthoriseRequest<'a> { audience: Option<&'a str>, } -#[cfg(all(test, feature = "http"))] +#[cfg(test)] +#[cfg(feature = "http")] mod tests { use super::*; use crate::auto_refresh::{AutoRefresh, AutoRefreshError}; diff --git a/packages/stack-auth/src/access_key_strategy.rs b/packages/stack-auth/src/access_key_strategy.rs index 6f1fa2738..7209024c8 100644 --- a/packages/stack-auth/src/access_key_strategy.rs +++ b/packages/stack-auth/src/access_key_strategy.rs @@ -208,7 +208,8 @@ impl<S: TokenStore> AccessKeyStrategyBuilder<S> { } } -#[cfg(all(test, feature = "http"))] +#[cfg(test)] +#[cfg(feature = "http")] mod workspace_verification_tests { use super::*; use crate::test_support::{crn_with_workspace, jwt_with_workspace}; diff --git a/packages/stack-auth/src/auto_refresh.rs b/packages/stack-auth/src/auto_refresh.rs index 37cfff221..bda67786d 100644 --- a/packages/stack-auth/src/auto_refresh.rs +++ b/packages/stack-auth/src/auto_refresh.rs @@ -250,7 +250,8 @@ impl<R> AutoRefresh<R, NoStore> { /// Like [`with_token`](Self::with_token) but with an injected clock, so tests /// can drive token expiry deterministically. - #[cfg(all(test, feature = "http"))] + #[cfg(test)] + #[cfg(feature = "http")] pub(crate) fn with_token_and_clock(refresher: R, token: Token, clock: SharedClock) -> Self { Self { refresher, @@ -585,7 +586,8 @@ impl<R: Refresher, S: TokenStore> AutoRefresh<R, S> { } } -#[cfg(all(test, feature = "http"))] +#[cfg(test)] +#[cfg(feature = "http")] #[allow(clippy::unwrap_used)] mod tests { use super::*; @@ -1262,7 +1264,8 @@ mod tests { } } -#[cfg(all(test, feature = "http"))] +#[cfg(test)] +#[cfg(feature = "http")] #[allow(clippy::unwrap_used)] mod stress_tests { use super::*; @@ -1897,7 +1900,8 @@ mod stress_tests { /// instrumentation. This version drives expiry with a [`TestClock`] and gates /// the refresh with a [`Notify`], so it is fully deterministic: no real sleeps, /// no network. -#[cfg(all(test, feature = "http"))] +#[cfg(test)] +#[cfg(feature = "http")] #[allow(clippy::unwrap_used)] mod expiry_crossing_regression { use super::*; @@ -2391,7 +2395,8 @@ mod expiry_crossing_regression { } } -#[cfg(all(test, feature = "http"))] +#[cfg(test)] +#[cfg(feature = "http")] #[allow(clippy::unwrap_used)] mod regression_cip_3159 { use super::*; diff --git a/packages/stack-auth/src/auto_strategy.rs b/packages/stack-auth/src/auto_strategy.rs index 6bbd054fb..396da5a5b 100644 --- a/packages/stack-auth/src/auto_strategy.rs +++ b/packages/stack-auth/src/auto_strategy.rs @@ -258,7 +258,8 @@ impl AuthStrategy for &AutoStrategy { // Detection builds strategies with no transport of their own, which needs // the bundled one. -#[cfg(all(test, feature = "http"))] +#[cfg(test)] +#[cfg(feature = "http")] mod tests { use super::*; use crate::{SecretToken, Token}; diff --git a/packages/stack-auth/src/clock.rs b/packages/stack-auth/src/clock.rs index 531321d8f..9eb72001b 100644 --- a/packages/stack-auth/src/clock.rs +++ b/packages/stack-auth/src/clock.rs @@ -56,11 +56,13 @@ pub(crate) fn system_clock() -> SharedClock { /// A [`Clock`] whose value is set explicitly by the test, so token expiry can be /// driven deterministically rather than racing the wall clock. -#[cfg(all(test, feature = "http"))] +#[cfg(test)] +#[cfg(feature = "http")] #[derive(Clone)] pub(crate) struct TestClock(Arc<std::sync::atomic::AtomicU64>); -#[cfg(all(test, feature = "http"))] +#[cfg(test)] +#[cfg(feature = "http")] impl TestClock { /// Create a clock reading `now` seconds. pub(crate) fn new(now: u64) -> Self { @@ -91,7 +93,8 @@ impl TestClock { } } -#[cfg(all(test, feature = "http"))] +#[cfg(test)] +#[cfg(feature = "http")] impl Clock for TestClock { fn now_unix_secs(&self) -> u64 { self.now() diff --git a/packages/stack-auth/src/device_session_refresher.rs b/packages/stack-auth/src/device_session_refresher.rs index d49bb8adf..aa08ad92f 100644 --- a/packages/stack-auth/src/device_session_refresher.rs +++ b/packages/stack-auth/src/device_session_refresher.rs @@ -192,7 +192,8 @@ impl DeviceSessionRefresher { } } -#[cfg(all(test, feature = "http", not(target_arch = "wasm32")))] +#[cfg(test)] +#[cfg(all(feature = "http", not(target_arch = "wasm32")))] mod tests { use super::*; use crate::transport::default_transport; diff --git a/packages/stack-auth/src/device_session_strategy.rs b/packages/stack-auth/src/device_session_strategy.rs index 78a22366e..8333700b0 100644 --- a/packages/stack-auth/src/device_session_strategy.rs +++ b/packages/stack-auth/src/device_session_strategy.rs @@ -264,7 +264,8 @@ impl DeviceSessionStrategyBuilder { // These build strategies with no transport of their own, which needs the // bundled one. -#[cfg(all(test, feature = "http"))] +#[cfg(test)] +#[cfg(feature = "http")] mod tests { use super::*; use crate::test_support::{claims_with_workspace, jwt_token, raw_token}; diff --git a/packages/stack-auth/src/error.rs b/packages/stack-auth/src/error.rs index 9f9613bfe..f6b093f7d 100644 --- a/packages/stack-auth/src/error.rs +++ b/packages/stack-auth/src/error.rs @@ -958,7 +958,8 @@ impl From<Infallible> for AuthError { } } -#[cfg(all(test, feature = "http"))] +#[cfg(test)] +#[cfg(feature = "http")] mod classify_issuance_failure_tests { use super::*; diff --git a/packages/stack-auth/src/oidc_federation_strategy.rs b/packages/stack-auth/src/oidc_federation_strategy.rs index 620538f37..435072f2a 100644 --- a/packages/stack-auth/src/oidc_federation_strategy.rs +++ b/packages/stack-auth/src/oidc_federation_strategy.rs @@ -199,7 +199,8 @@ impl<P: OidcProvider, S: TokenStore> OidcFederationStrategyBuilder<P, S> { } } -#[cfg(all(test, feature = "http"))] +#[cfg(test)] +#[cfg(feature = "http")] #[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] mod tests { use std::sync::Arc; diff --git a/packages/stack-auth/src/oidc_refresher.rs b/packages/stack-auth/src/oidc_refresher.rs index 4cc493aa8..73f9acb77 100644 --- a/packages/stack-auth/src/oidc_refresher.rs +++ b/packages/stack-auth/src/oidc_refresher.rs @@ -190,7 +190,8 @@ struct OidcAuthoriseRequest<'a> { workspace_id: &'a str, } -#[cfg(all(test, feature = "http"))] +#[cfg(test)] +#[cfg(feature = "http")] #[allow(clippy::unwrap_used)] mod tests { use crate::transport::default_transport; From 1dafe3c6dd7a59cbd456afccb4e0025b8785a747 Mon Sep 17 00:00:00 2001 From: Dan Draper <dan@cipherstash.com> Date: Sun, 20 Sep 2026 13:03:47 -0700 Subject: [PATCH 598/686] ci(go): the Go binding runs on macOS and Windows against the guest Linux built The binding's only gate was one Linux job. A Go developer on either other platform shares a profile directory with the CLI, and the credential guest's refresh lock (ADR-0005) has a Unix and a Windows implementation whose only proof is running there; the crypto binding had never been run on either. The Linux job still builds the guest once and checks its import surface; it now records the module's checksum and hands both to a matrix job on macOS and Windows that downloads them into the embed location, proves the bytes are the ones Linux checked, and runs the binding's suite. No Rust runs there: Go and the artifact are the whole toolchain. Both jobs are blocking, like wasi-check. One definition of "the binding passes": the mise task's body moves to scripts/go-binding-test.sh, which the task and the new jobs both run, so the three platforms cannot drift. Git for Windows would otherwise check out CRLF and gofmt would report every file, so the job pins LF before checkout. The Go version comes from the mise.toml pin, not a second copy. Linear: CIP-4117 --- .github/imported-workflows/test-wasi.yml | 75 ++++++++++++++++++++++++ scripts/go-binding-test.sh | 42 +++++++++++++ 2 files changed, 117 insertions(+) create mode 100755 scripts/go-binding-test.sh diff --git a/.github/imported-workflows/test-wasi.yml b/.github/imported-workflows/test-wasi.yml index 63e9fd330..368c3c3e8 100644 --- a/.github/imported-workflows/test-wasi.yml +++ b/.github/imported-workflows/test-wasi.yml @@ -17,6 +17,7 @@ on: # job is their only gate. - bindings/go/stackencrypt/** - scripts/check-wasm-imports.py + - scripts/go-binding-test.sh - Cargo.toml - Cargo.lock - .cargo/** @@ -34,6 +35,7 @@ on: # job is their only gate. - bindings/go/stackencrypt/** - scripts/check-wasm-imports.py + - scripts/go-binding-test.sh - Cargo.toml - Cargo.lock - .cargo/** @@ -95,6 +97,25 @@ jobs: - name: Guest release build and import-surface gate run: mise run wasm:guest:build + # The module checked above is what every platform tests. Its checksum + # travels with it so the other jobs can prove they got the same bytes, + # not a stale or rebuilt guest. + - name: Record the guest's checksum + run: | + cd bindings/go/stackencrypt/wasm + openssl dgst -sha256 stack_encrypt_guest.wasm | awk '{print $NF}' > stack_encrypt_guest.wasm.sha256 + echo "stack_encrypt_guest.wasm sha256 $(cat stack_encrypt_guest.wasm.sha256)" + + - name: Hand the guest to the other platforms + uses: actions/upload-artifact@v7 + with: + name: wasm-guests + path: | + bindings/go/stackencrypt/wasm/stack_encrypt_guest.wasm + bindings/go/stackencrypt/wasm/stack_encrypt_guest.wasm.sha256 + if-no-files-found: error + retention-days: 1 + # The Go binding (bindings/go/stackencrypt) embeds the module built # above: format, vet and hermetic tests (import surface, transport # bridge against an httptest ZeroKMS, guest-parser acceptance of every @@ -103,3 +124,57 @@ jobs: # harness's. - name: Go binding run: mise run go:stackencrypt:test + + # The same Go binding on macOS and Windows, against the guest Linux built. + # A Go developer on either platform shares a profile directory with the + # CLI, and the credential guest's refresh lock (ADR-0005) has a Unix and a + # Windows implementation whose only proof is running there. These jobs are + # blocking, the same as wasi-check: add them to the branch protection's + # required checks alongside it. Nothing Rust runs here — Go plus the + # artifact is the whole toolchain, which is what keeps them quick. + go-binding-cross: + name: Go binding (${{ matrix.os }}) + needs: wasi-check + strategy: + fail-fast: false + matrix: + os: [macos-latest, windows-latest] + runs-on: ${{ matrix.os }} + + steps: + # Git for Windows defaults to CRLF on checkout, and gofmt would then + # report every file. Set this before checkout, where it takes effect. + - name: Keep LF line endings + run: git config --global core.autocrlf false + + - uses: actions/checkout@v6 + + # One source of truth for the Go version: the mise pin the Linux job + # runs under. + - name: Go version from mise.toml + id: go + run: echo "version=$(sed -n 's/^go = "\(.*\)"/\1/p' mise.toml)" >> "$GITHUB_OUTPUT" + + - uses: actions/setup-go@v5 + with: + go-version: ${{ steps.go.outputs.version }} + cache-dependency-path: bindings/go/stackencrypt/go.sum + + - uses: actions/download-artifact@v8 + with: + name: wasm-guests + path: bindings/go/stackencrypt/wasm + + - name: The guest is the one Linux built and checked + run: | + cd bindings/go/stackencrypt/wasm + want=$(cat stack_encrypt_guest.wasm.sha256) + got=$(openssl dgst -sha256 stack_encrypt_guest.wasm | awk '{print $NF}') + if [ "$want" != "$got" ]; then + echo "guest checksum mismatch: artifact says $want, file is $got" >&2 + exit 1 + fi + echo "stack_encrypt_guest.wasm sha256 $got" + + - name: Go binding + run: scripts/go-binding-test.sh bindings/go/stackencrypt diff --git a/scripts/go-binding-test.sh b/scripts/go-binding-test.sh new file mode 100755 index 000000000..563f4e199 --- /dev/null +++ b/scripts/go-binding-test.sh @@ -0,0 +1,42 @@ +#!/usr/bin/env bash +# Format check, vet and test one Go binding module against its embedded guest. +# +# One definition of "the Go binding passes", run on three platforms: the mise +# task `go:stackencrypt:test` (Linux CI, and locally) and the macOS/Windows +# jobs in .github/workflows/test-wasi.yml both call this, so they cannot +# drift apart. The guest module itself is built once, on Linux, and handed to +# the other platforms as an artifact — the wasm is platform-independent and +# the Rust build is the slow part. +# +# Usage: go-binding-test.sh <module dir> [<guest path, relative to it>] +set -euo pipefail + +dir=${1:?usage: go-binding-test.sh <module dir> [<guest path>]} +guest=${2:-wasm/stack_encrypt_guest.wasm} + +cd "$dir" +if [ ! -f "$guest" ]; then + echo "guest module not built at $dir/$guest — run: mise run wasm:guest:build" >&2 + exit 1 +fi + +# gofmt exits 0 even when files need formatting; -l lists them. +out=$(gofmt -l .) +if [ -n "$out" ]; then + echo "gofmt needed:" + echo "$out" + exit 1 +fi + +go vet ./... +CGO_ENABLED=0 go test ./... + +# The transport codec's u32-bound guards are load-bearing where int is 32 +# bits (vitaminc's vcffi runs this sweep too); the binding's own reflection +# and buffer arithmetic must hold there as well. Linux hosts execute 386 +# natively; elsewhere the binary cannot run, so only vet. +if [ "$(uname -s)" = "Linux" ]; then + CGO_ENABLED=0 GOOS=linux GOARCH=386 go test ./... +else + CGO_ENABLED=0 GOOS=linux GOARCH=386 go vet ./... +fi From a3f131a2354987486ac047dfc8f87896667c05e0 Mon Sep 17 00:00:00 2001 From: Dan Draper <dan@cipherstash.com> Date: Sun, 20 Sep 2026 13:26:10 -0700 Subject: [PATCH 599/686] ci(go): say what the platform jobs prove today, and what they are there for The refresh lock is CIP-4054's, not this change's: the matrix proves the crypto binding and the artifact hand-off now, and gives the lock tests a place to run when they arrive. --- .github/imported-workflows/test-wasi.yml | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/.github/imported-workflows/test-wasi.yml b/.github/imported-workflows/test-wasi.yml index 368c3c3e8..f7220ddba 100644 --- a/.github/imported-workflows/test-wasi.yml +++ b/.github/imported-workflows/test-wasi.yml @@ -126,12 +126,15 @@ jobs: run: mise run go:stackencrypt:test # The same Go binding on macOS and Windows, against the guest Linux built. - # A Go developer on either platform shares a profile directory with the - # CLI, and the credential guest's refresh lock (ADR-0005) has a Unix and a - # Windows implementation whose only proof is running there. These jobs are - # blocking, the same as wasi-check: add them to the branch protection's - # required checks alongside it. Nothing Rust runs here — Go plus the - # artifact is the whole toolchain, which is what keeps them quick. + # Today that is the crypto binding, which had never run on either. The + # reason to have the platforms in place now is what comes next: the + # credential guest (ADR-0005, CIP-4054) puts the cross-process refresh + # lock on the Go side with a Unix and a Windows implementation, and a Go + # developer sharing a profile with the CLI is the scenario the lock exists + # for, so those tests need somewhere to run before they are written. These + # jobs are blocking, the same as wasi-check: add them to the branch + # protection's required checks alongside it. Nothing Rust runs here — Go + # plus the artifact is the whole toolchain, which is what keeps them quick. go-binding-cross: name: Go binding (${{ matrix.os }}) needs: wasi-check From 505eb4e7d05dd19e3d40f902689be28f42756c72 Mon Sep 17 00:00:00 2001 From: Dan Draper <dan@cipherstash.com> Date: Sun, 20 Sep 2026 14:43:37 -0700 Subject: [PATCH 600/686] fix(stack-auth)!: the wire types print nothing secret, wipe their headers, and lend reqwest the body MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review findings on the transport seam, all addressed: - `HttpRequest` and `HttpResponse` no longer derive `Debug`. Both carry credentials in the body and in header values, and `{:?}` in a log line was the leak. Their `Debug` now reports the method, URL, status, header names and body length; a test pins that a secret never appears. - Header values are `Zeroizing`, not only the body: the bearer credential travels in one. - The bundled transport hands reqwest the body with `Bytes::from_owner`, so the `Zeroizing` buffer is what reqwest holds and it is wiped when reqwest is done, rather than copied into an ordinary allocation. The `authorization` header is marked sensitive so reqwest's and hyper's own `Debug` redact it. What the transport cannot wipe — reqwest's and hyper's own buffers — is now stated in its docs rather than implied away. - `post`, `post_json`, `post_form` and `HttpResponse::json` return `RequestError`; the strategies lift it with `?`, and the device-client provisioning path no longer matches an `AuthError` back apart to recover the error it wrapped a line earlier. - `Arc<T>` implements `HttpTransport` when `T` does, so one host transport serves several strategies; `HttpRequest::new` is public so a transport implementation outside the crate can test itself. - `RequestError` is constructible, so the error-code pin test and the `DeviceClientError` mapping test now cover the `Request` variant instead of documenting why they could not. - The `store` module's builder example is unconditional; the stale `allow(dead_code)` on `AccessKey`'s field is gone; the test-support doc comments say one thing once. - The auto-strategy tests that need no transport run in the no-http build again, and the two that touch the process environment go through `temp_env` so they cannot race each other under plain `cargo test`. BREAKING CHANGE: `From<reqwest::Error> for AuthError` is removed. Every reqwest failure now enters the crate through `ReqwestTransport` as a `RequestError`; a caller that lifted a `reqwest::Error` into `AuthError` directly should wrap it in `RequestError` (`AuthError::Request( RequestError(Box::new(e)))`) or, better, send through the transport. Linear: CIP-4116 --- packages/stack-auth/Cargo.toml | 5 +- packages/stack-auth/README.md | 6 +- packages/stack-auth/src/access_key.rs | 8 +- packages/stack-auth/src/auto_strategy.rs | 48 ++-- packages/stack-auth/src/device_client.rs | 12 +- .../stack-auth/src/device_session_strategy.rs | 4 +- packages/stack-auth/src/error.rs | 29 +-- packages/stack-auth/src/lib.rs | 31 ++- packages/stack-auth/src/test_support.rs | 6 +- packages/stack-auth/src/transport.rs | 224 +++++++++++++++--- 10 files changed, 255 insertions(+), 118 deletions(-) diff --git a/packages/stack-auth/Cargo.toml b/packages/stack-auth/Cargo.toml index 3cc36ac82..f9ac353c5 100644 --- a/packages/stack-auth/Cargo.toml +++ b/packages/stack-auth/Cargo.toml @@ -19,6 +19,9 @@ base64 = { workspace = true } cts-common = { workspace = true } miette = { workspace = true } reqwest = { workspace = true, optional = true } +# `Bytes::from_owner` lends reqwest a buffer this crate still owns, so the +# request body is wiped when reqwest is done rather than copied. +bytes = { version = "1.9", optional = true } serde = { workspace = true } serde_json = { workspace = true } # Form bodies for the OAuth token endpoints, encoded exactly as reqwest's @@ -68,7 +71,7 @@ default = ["http"] # native-only and keep the bundled transport, so they need this feature. # `StaticTokenStrategy` is a test double behind `test-utils`, not part of # the production surface. -http = ["dep:reqwest"] +http = ["dep:reqwest", "dep:bytes"] test-utils = [] # Exposes fuzz-only entry points (e.g. `fuzz_decode_claims`) for the cargo-fuzz # harnesses in `fuzz/`. A Cargo feature (not `#[cfg(fuzzing)]`) so it is a known diff --git a/packages/stack-auth/README.md b/packages/stack-auth/README.md index 4f9b6cfff..8ffeee072 100644 --- a/packages/stack-auth/README.md +++ b/packages/stack-auth/README.md @@ -163,8 +163,10 @@ let strategy = AccessKeyStrategy::builder(crn, key) # } ``` -Without the `http` feature there is no bundled transport, so `.transport(..)` -is required rather than optional; nothing else about the strategies changes. +One transport can serve several strategies: `Arc<T>` implements the trait +whenever `T` does, so hand each builder a clone of the `Arc`. Without the +`http` feature there is no bundled transport, so `.transport(..)` is required +rather than optional; nothing else about the strategies changes. Module paths mirror this split: [`stack_auth::auth`](crate::auth) groups the acquisition layer, [`stack_auth::store`](crate::store) groups the persistence diff --git a/packages/stack-auth/src/access_key.rs b/packages/stack-auth/src/access_key.rs index 5b137a713..cef3285eb 100644 --- a/packages/stack-auth/src/access_key.rs +++ b/packages/stack-auth/src/access_key.rs @@ -33,13 +33,7 @@ const ACCESS_KEY_PREFIX: &str = "CSAK"; /// assert!("CSAKno-secret.".parse::<AccessKey>().is_err()); /// ``` #[derive(OpaqueDebug)] -pub struct AccessKey( - // Parsing an access key (and rejecting a malformed one) is part of the - // token model, so the struct is unconditional — but only the http-gated - // `AccessKeyStrategy` ever *consumes* the secret, so without `http` this - // field is held but never read. - #[cfg_attr(not(feature = "http"), allow(dead_code))] SecretToken, -); +pub struct AccessKey(SecretToken); impl AccessKey { /// Expose the underlying [`SecretToken`]. diff --git a/packages/stack-auth/src/auto_strategy.rs b/packages/stack-auth/src/auto_strategy.rs index 396da5a5b..3aeba163b 100644 --- a/packages/stack-auth/src/auto_strategy.rs +++ b/packages/stack-auth/src/auto_strategy.rs @@ -256,13 +256,12 @@ impl AuthStrategy for &AutoStrategy { } } -// Detection builds strategies with no transport of their own, which needs -// the bundled one. #[cfg(test)] -#[cfg(feature = "http")] mod tests { use super::*; + #[cfg(feature = "http")] use crate::{SecretToken, Token}; + #[cfg(feature = "http")] use std::time::{SystemTime, UNIX_EPOCH}; const VALID_CRN: &str = "crn:ap-southeast-2.aws:ZVATKW3VHMFG27DY"; @@ -271,6 +270,7 @@ mod tests { VALID_CRN.parse().unwrap() } + #[cfg(feature = "http")] // only the strategy-building tests use it fn make_oauth_token() -> Token { let now = SystemTime::now() .duration_since(UNIX_EPOCH) @@ -302,6 +302,7 @@ mod tests { } } + #[cfg(feature = "http")] // only the strategy-building tests use it fn write_token_store(dir: &std::path::Path) -> ProfileStore { let store = ProfileStore::new(dir); store.init_workspace("ZVATKW3VHMFG27DY").unwrap(); @@ -314,6 +315,7 @@ mod tests { use super::*; #[test] + #[cfg(feature = "http")] // builds a strategy with no transport of its own fn access_key_with_valid_crn() { let result = AutoStrategy::detect_inner( Some("CSAKtestKeyId.testKeySecret".into()), @@ -351,6 +353,7 @@ mod tests { } #[test] + #[cfg(feature = "http")] // builds a strategy with no transport of its own fn oauth_store_with_valid_token() { let dir = tempfile::tempdir().unwrap(); let store = write_token_store(dir.path()); @@ -379,6 +382,7 @@ mod tests { } #[test] + #[cfg(feature = "http")] // builds a strategy with no transport of its own fn access_key_takes_priority_over_oauth_store() { let dir = tempfile::tempdir().unwrap(); let store = write_token_store(dir.path()); @@ -399,6 +403,7 @@ mod tests { use super::*; #[test] + #[cfg(feature = "http")] // builds a strategy with no transport of its own fn explicit_access_key_and_crn() { let result = AutoStrategy::builder() .with_access_key("CSAKtestKeyId.testKeySecret") @@ -409,38 +414,27 @@ mod tests { assert!(matches!(result.unwrap(), AutoStrategy::AccessKey(_))); } + // Both tests below set process environment. `temp_env` serialises + // them (and restores the variable afterwards), which matters under + // plain `cargo test`, where tests share one process. #[test] fn explicit_access_key_without_crn_and_no_env_returns_missing_workspace_crn() { - // Save and clear env to ensure no fallback - let saved_crn = std::env::var("CS_WORKSPACE_CRN").ok(); - std::env::remove_var("CS_WORKSPACE_CRN"); - - let result = AutoStrategy::builder() - .with_access_key("CSAKtestKeyId.testKeySecret") - .detect(); - - // Restore env - if let Some(val) = saved_crn { - std::env::set_var("CS_WORKSPACE_CRN", val); - } + let result = temp_env::with_var_unset("CS_WORKSPACE_CRN", || { + AutoStrategy::builder() + .with_access_key("CSAKtestKeyId.testKeySecret") + .detect() + }); assert!(matches!(result, Err(AuthError::MissingWorkspaceCrn(_)))); } #[test] fn invalid_crn_env_var_returns_invalid_crn() { - let saved_crn = std::env::var("CS_WORKSPACE_CRN").ok(); - std::env::set_var("CS_WORKSPACE_CRN", "not-a-crn"); - - let result = AutoStrategy::builder() - .with_access_key("CSAKtestKeyId.testKeySecret") - .detect(); - - // Restore env - match saved_crn { - Some(val) => std::env::set_var("CS_WORKSPACE_CRN", val), - None => std::env::remove_var("CS_WORKSPACE_CRN"), - } + let result = temp_env::with_var("CS_WORKSPACE_CRN", Some("not-a-crn"), || { + AutoStrategy::builder() + .with_access_key("CSAKtestKeyId.testKeySecret") + .detect() + }); assert!(matches!(result, Err(AuthError::InvalidCrn(_)))); } diff --git a/packages/stack-auth/src/device_client.rs b/packages/stack-auth/src/device_client.rs index c734fd8fe..2184c8e60 100644 --- a/packages/stack-auth/src/device_client.rs +++ b/packages/stack-auth/src/device_client.rs @@ -103,8 +103,7 @@ pub async fn bind_client_device(store: &ProfileStore) -> Result<(), DeviceClient let url = zerokms_url.join(CreateClientRequest::ENDPOINT)?; - // Provisioning is native-only, so it always uses the bundled transport: - // a request failure surfaces as `Request`, never as `Auth`. + // Provisioning is native-only, so it always uses the bundled transport. let body = zeroize::Zeroizing::new( serde_json::to_vec(&request).map_err(|e| RequestError(Box::new(e)))?, ); @@ -121,11 +120,7 @@ pub async fn bind_client_device(store: &ProfileStore) -> Result<(), DeviceClient ], body, ) - .await - .map_err(|e| match e { - crate::AuthError::Request(e) => DeviceClientError::Request(e), - other => DeviceClientError::Auth(other), - })?; + .await?; let status = response.status(); @@ -142,8 +137,7 @@ pub async fn bind_client_device(store: &ProfileStore) -> Result<(), DeviceClient }); } - let created: CreateClientResponse = serde_json::from_slice(response.body()) - .map_err(|e| DeviceClientError::Request(RequestError(Box::new(e))))?; + let created: CreateClientResponse = response.json()?; let secret_key = SecretKeyFile { client_id: created.id, diff --git a/packages/stack-auth/src/device_session_strategy.rs b/packages/stack-auth/src/device_session_strategy.rs index 8333700b0..afc0b9be5 100644 --- a/packages/stack-auth/src/device_session_strategy.rs +++ b/packages/stack-auth/src/device_session_strategy.rs @@ -262,8 +262,8 @@ impl DeviceSessionStrategyBuilder { } } -// These build strategies with no transport of their own, which needs the -// bundled one. +// Every test here builds a strategy with no transport of its own, which +// needs the bundled one. #[cfg(test)] #[cfg(feature = "http")] mod tests { diff --git a/packages/stack-auth/src/error.rs b/packages/stack-auth/src/error.rs index f6b093f7d..e9da200c6 100644 --- a/packages/stack-auth/src/error.rs +++ b/packages/stack-auth/src/error.rs @@ -80,8 +80,10 @@ pub(crate) mod codes { /// The payload is always boxed, never a concrete `reqwest::Error`: Cargo /// features are additive, so a type whose shape changes with `http` breaks any /// no-http consumer the moment something else in the graph turns the feature -/// on. With `http` the box holds the `reqwest::Error`; without it, whatever -/// the host's own transport reports. +/// on. The box holds whatever the [`HttpTransport`](crate::HttpTransport) in +/// use reported — the bundled one's `reqwest::Error`, or a host transport's +/// own — or the encoder's or decoder's error for a body that did not +/// serialize or parse. #[derive(Debug, thiserror::Error, miette::Diagnostic)] #[error("Request to the auth server failed: {0}")] pub struct RequestError(pub Box<dyn std::error::Error + Send + Sync + 'static>); @@ -889,13 +891,6 @@ impl From<reqwest::Error> for RequestError { } } -#[cfg(feature = "http")] -impl From<reqwest::Error> for AuthError { - fn from(e: reqwest::Error) -> Self { - Self::Request(e.into()) - } -} - impl From<url::ParseError> for AuthError { fn from(e: url::ParseError) -> Self { Self::InvalidUrl(InvalidUrl(e)) @@ -1439,11 +1434,8 @@ mod tests { assert!(json.get("actual").is_none()); } - /// Every constructable `DeviceClientError` variant maps to its canonical - /// `AuthError` code so `bind_client_device` failures share the one envelope - /// path. (`Request` wraps a `reqwest::Error`, which has no public - /// constructor, so it can't be built here — the same gap the exhaustive - /// `error_code` test documents.) + /// Every `DeviceClientError` variant maps to its canonical `AuthError` + /// code so `bind_client_device` failures share the one envelope path. #[cfg(all(feature = "http", not(target_arch = "wasm32")))] #[test] fn device_client_error_maps_to_canonical_auth_error() { @@ -1454,6 +1446,15 @@ mod tests { AuthError::from(E::Auth(AuthError::AccessDenied(AccessDenied))).error_code(), codes::ACCESS_DENIED, ); + // `Request` carries the transport's error through as `REQUEST_ERROR`. + let request = AuthError::from(E::Request(RequestError(Box::new(std::io::Error::other( + "connection refused", + ))))); + assert_eq!(request.error_code(), codes::REQUEST_ERROR); + assert!( + request.to_string().contains("connection refused"), + "{request}" + ); // Non-`Auth` variants route to their canonical `AuthError` equivalent. assert_eq!( AuthError::from(E::Profile(stack_profile::ProfileError::HomeDirNotFound)).error_code(), diff --git a/packages/stack-auth/src/lib.rs b/packages/stack-auth/src/lib.rs index ef56dea1c..356cf60b1 100644 --- a/packages/stack-auth/src/lib.rs +++ b/packages/stack-auth/src/lib.rs @@ -203,11 +203,8 @@ pub mod auth { /// builder — it does *not* replace the strategy. For full token acquisition /// (custom fetcher, FFI-hosted strategy), see [`crate::auth`]. /// -// The example names a strategy builder, which only exists with `http`. -#[cfg_attr( - feature = "http", - doc = "For example, [`AccessKeyStrategyBuilder::with_token_store`](crate::AccessKeyStrategyBuilder::with_token_store).\n" -)] +/// For example, [`AccessKeyStrategyBuilder::with_token_store`](crate::AccessKeyStrategyBuilder::with_token_store). +/// /// All items in this module are also re-exported at the crate root. pub mod store { pub use crate::{InMemoryTokenStore, NoStore, Token, TokenStore, TokenStoreFn}; @@ -417,17 +414,15 @@ mod tests { use super::*; /// The `error_code` strings are a stable contract surfaced across FFI - /// (JS `Error.code`, Node-API codes), so pin every variant's code. Covers - /// all variants except `Request`, whose inner `reqwest::Error` has no public - /// constructor; if a new variant is added without a code, `error_code`'s - /// exhaustive `kind()` dispatch fails to compile, so the contract can't - /// silently drift. + /// (JS `Error.code`, Node-API codes), so pin every variant's code. If a + /// new variant is added without a code, `error_code`'s exhaustive `kind()` + /// dispatch fails to compile, so the contract can't silently drift. /// /// Also pins [`AuthError::ERROR_CODES`] against what `error_code` actually /// returns: every constructed variant's code must be declared there, and - /// `ERROR_CODES` must hold exactly those codes plus `REQUEST_ERROR` (the one - /// variant with no public constructor). So the list can't grow stale entries - /// or omit a real one — which is what the binding crates' union tests trust. + /// `ERROR_CODES` must hold exactly those codes. So the list can't grow + /// stale entries or omit a real one — which is what the binding crates' + /// union tests trust. #[test] #[allow(clippy::unwrap_used)] fn auth_error_code_is_stable_for_every_variant() { @@ -494,6 +489,12 @@ mod tests { AuthError::Custom(crate::error::CustomError("boom".into())), "CUSTOM", ), + ( + AuthError::Request(crate::error::RequestError(Box::new(std::io::Error::other( + "connection refused", + )))), + "REQUEST_ERROR", + ), ( AuthError::from("not a url".parse::<url::Url>().unwrap_err()), "INVALID_URL", @@ -540,10 +541,6 @@ mod tests { from_variants.insert(expected); } - // `Request` has no public constructor, so it can't appear above; add its - // code explicitly so the set-equality below stays exact. - from_variants.insert("REQUEST_ERROR"); - assert_eq!( declared, from_variants, "AuthError::ERROR_CODES drifted from the codes error_code() returns", diff --git a/packages/stack-auth/src/test_support.rs b/packages/stack-auth/src/test_support.rs index 8bf2030d2..51bf163f2 100644 --- a/packages/stack-auth/src/test_support.rs +++ b/packages/stack-auth/src/test_support.rs @@ -58,8 +58,7 @@ pub(crate) fn claims_with_workspace(workspace: &str) -> serde_json::Value { /// A workspace [`Crn`] in the standard test region (`ap-southeast-2.aws`) /// carrying the given `workspace` ID. /// -/// Only the http-gated CRN-bound strategies have tests that need one. -// Only the mock-server tests, which need the bundled transport, mint these. +/// Only the mock-server tests, which need the bundled transport, mint one. #[cfg(feature = "http")] pub(crate) fn crn_with_workspace(workspace: &str) -> Crn { format!("crn:ap-southeast-2.aws:{workspace}") @@ -72,7 +71,8 @@ pub(crate) fn crn_with_workspace(workspace: &str) -> Crn { /// workspace verification that CRN-bound strategies run. Unlike /// [`claims_with_workspace`], whose `exp` is a fixed past epoch, the token this /// mints reads as valid. -// Only the mock-server tests, which need the bundled transport, mint these. +/// +/// Only the mock-server tests, which need the bundled transport, mint one. #[cfg(feature = "http")] pub(crate) fn jwt_with_workspace(workspace: &str) -> String { use jsonwebtoken::{encode, EncodingKey, Header}; diff --git a/packages/stack-auth/src/transport.rs b/packages/stack-auth/src/transport.rs index 6b3bbb5d8..884890dd0 100644 --- a/packages/stack-auth/src/transport.rs +++ b/packages/stack-auth/src/transport.rs @@ -14,9 +14,14 @@ //! every builder uses unless told otherwise, so native callers see no //! difference. Without it, a builder must be handed a transport. //! -//! Bodies are wiped on drop on both halves: a request carries an access key -//! or a refresh token, and a response carries the token that was minted. - +//! Bodies and header values are wiped on drop on both halves: a request +//! carries an access key or a refresh token and a bearer credential, and a +//! response carries the token that was minted. Neither type prints any of +//! that: `Debug` reports the method, URL, header names and body length. +//! The wipe covers this crate's buffers; what an HTTP client copies into +//! its own is that client's, and [`ReqwestTransport`] says what it does. + +use std::fmt; use std::future::Future; use std::sync::Arc; @@ -31,15 +36,31 @@ use crate::AuthError; /// The shape is the guest host import's, deliberately: a transport that /// can carry this can carry every request the crate makes, and nothing the /// crate makes needs more. -#[derive(Debug)] pub struct HttpRequest { method: &'static str, url: Url, - headers: Vec<(String, String)>, + headers: Zeroizing<Vec<(String, String)>>, body: Zeroizing<Vec<u8>>, } impl HttpRequest { + /// A request. `method` is upper-case (`"POST"`); header names are + /// lower-case. This is what the crate builds internally; a transport + /// implementation outside the crate needs it only to test itself. + pub fn new( + method: &'static str, + url: Url, + headers: Vec<(String, String)>, + body: Vec<u8>, + ) -> Self { + Self { + method, + url, + headers: Zeroizing::new(headers), + body: Zeroizing::new(body), + } + } + /// The HTTP method, upper-case (`"POST"`). pub fn method(&self) -> &str { self.method @@ -50,7 +71,9 @@ impl HttpRequest { &self.url } - /// The request headers, in order. Names are lower-case. + /// The request headers, in order. Names are lower-case. Values are + /// wiped when the request is dropped: one of them is the bearer + /// credential. pub fn headers(&self) -> &[(String, String)] { &self.headers } @@ -61,21 +84,33 @@ impl HttpRequest { } } +/// Names only: a request or response carries credentials in its body and +/// its header values, and `{:?}` in a log line is how those leak. +impl fmt::Debug for HttpRequest { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("HttpRequest") + .field("method", &self.method) + .field("url", &self.url.as_str()) + .field("headers", &HeaderNames(&self.headers)) + .field("body_len", &self.body.len()) + .finish() + } +} + /// One HTTP response, as a transport returns it. -#[derive(Debug)] pub struct HttpResponse { status: u16, - headers: Vec<(String, String)>, + headers: Zeroizing<Vec<(String, String)>>, body: Zeroizing<Vec<u8>>, } impl HttpResponse { - /// A response with `status`, `headers` and `body`. The body is wiped - /// when the response is dropped. + /// A response with `status`, `headers` and `body`. The body and the + /// header values are wiped when the response is dropped. pub fn new(status: u16, headers: Vec<(String, String)>, body: Vec<u8>) -> Self { Self { status, - headers, + headers: Zeroizing::new(headers), body: Zeroizing::new(body), } } @@ -106,9 +141,29 @@ impl HttpResponse { /// The body decoded as JSON. A body that does not decode is reported as /// a request failure, as it was when the HTTP client did the decoding. - pub(crate) fn json<T: serde::de::DeserializeOwned>(&self) -> Result<T, AuthError> { - serde_json::from_slice(&self.body) - .map_err(|e| AuthError::Request(RequestError(Box::new(e)))) + pub(crate) fn json<T: serde::de::DeserializeOwned>(&self) -> Result<T, RequestError> { + serde_json::from_slice(&self.body).map_err(|e| RequestError(Box::new(e))) + } +} + +impl fmt::Debug for HttpResponse { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("HttpResponse") + .field("status", &self.status) + .field("headers", &HeaderNames(&self.headers)) + .field("body_len", &self.body.len()) + .finish() + } +} + +/// The names of a header list, for the `Debug` impls above. +struct HeaderNames<'a>(&'a [(String, String)]); + +impl fmt::Debug for HeaderNames<'_> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_list() + .entries(self.0.iter().map(|(name, _)| name)) + .finish() } } @@ -143,6 +198,28 @@ pub trait HttpTransport: 'static { ) -> impl Future<Output = Result<HttpResponse, RequestError>>; } +/// One transport can serve several strategies: hand each an `Arc` of it. +#[cfg(not(target_arch = "wasm32"))] +impl<T: HttpTransport> HttpTransport for Arc<T> { + fn send( + &self, + request: HttpRequest, + ) -> impl Future<Output = Result<HttpResponse, RequestError>> + Send { + (**self).send(request) + } +} + +/// One transport can serve several strategies: hand each an `Arc` of it. +#[cfg(target_arch = "wasm32")] +impl<T: HttpTransport> HttpTransport for Arc<T> { + fn send( + &self, + request: HttpRequest, + ) -> impl Future<Output = Result<HttpResponse, RequestError>> { + (**self).send(request) + } +} + // --------------------------------------------------------------------------- // The crate-internal, object-safe view. // @@ -240,10 +317,8 @@ pub(crate) async fn post_json<B: serde::Serialize>( transport: &SharedTransport, url: Url, body: &B, -) -> Result<HttpResponse, AuthError> { - let body = Zeroizing::new( - serde_json::to_vec(body).map_err(|e| AuthError::Request(RequestError(Box::new(e))))?, - ); +) -> Result<HttpResponse, RequestError> { + let body = Zeroizing::new(serde_json::to_vec(body).map_err(|e| RequestError(Box::new(e)))?); post(transport, url, "application/json", Vec::new(), body).await } @@ -252,10 +327,10 @@ pub(crate) async fn post_form<B: serde::Serialize>( transport: &SharedTransport, url: Url, body: &B, -) -> Result<HttpResponse, AuthError> { +) -> Result<HttpResponse, RequestError> { let body = Zeroizing::new( serde_urlencoded::to_string(body) - .map_err(|e| AuthError::Request(RequestError(Box::new(e))))? + .map_err(|e| RequestError(Box::new(e)))? .into_bytes(), ); post( @@ -268,25 +343,24 @@ pub(crate) async fn post_form<B: serde::Serialize>( .await } -/// `POST` `body` as `content_type`, with `extra` headers first. +/// `POST` `body` as `content_type`, with `extra` headers first. A failure +/// here is the transport's (or the encoder's); the caller lifts it into +/// its own error type, which for a strategy is `AuthError::Request`. pub(crate) async fn post( transport: &SharedTransport, url: Url, content_type: &str, mut extra: Vec<(String, String)>, body: Zeroizing<Vec<u8>>, -) -> Result<HttpResponse, AuthError> { +) -> Result<HttpResponse, RequestError> { extra.push(("content-type".to_string(), content_type.to_string())); let request = HttpRequest { method: "POST", url, - headers: extra, + headers: Zeroizing::new(extra), body, }; - transport - .send_dyn(request) - .await - .map_err(AuthError::Request) + transport.send_dyn(request).await } // --------------------------------------------------------------------------- @@ -299,6 +373,14 @@ pub(crate) async fn post( /// [`Default`] builds the client with the crate's standard timeouts and /// pool settings; [`ReqwestTransport::new`] takes a client configured by /// the caller. +/// +/// What it does with the secrets it is handed: the request body is given +/// to reqwest as a buffer this crate still owns, so it is wiped when reqwest +/// is done with it rather than copied into an ordinary allocation; the +/// `authorization` header is marked sensitive, so reqwest's and hyper's +/// own `Debug` output redact it. What it cannot do: reach the buffers +/// reqwest and hyper allocate for themselves while sending and receiving. +/// Those are theirs, and this crate's wipe guarantee stops at its own. #[cfg(feature = "http")] #[derive(Debug, Clone)] pub struct ReqwestTransport { @@ -323,13 +405,27 @@ impl Default for ReqwestTransport { #[cfg(feature = "http")] impl HttpTransport for ReqwestTransport { async fn send(&self, request: HttpRequest) -> Result<HttpResponse, RequestError> { - let method = reqwest::Method::from_bytes(request.method.as_bytes()) + use reqwest::header::HeaderValue; + + let HttpRequest { + method, + url, + headers, + body, + } = request; + let method = reqwest::Method::from_bytes(method.as_bytes()) .map_err(|e| RequestError(Box::new(e)))?; - let mut builder = self.client.request(method, request.url); - for (name, value) in &request.headers { - builder = builder.header(name.as_str(), value.as_str()); + let mut builder = self.client.request(method, url); + for (name, value) in headers.iter() { + let mut value = HeaderValue::from_str(value).map_err(|e| RequestError(Box::new(e)))?; + if name.eq_ignore_ascii_case("authorization") { + value.set_sensitive(true); + } + builder = builder.header(name.as_str(), value); } - let response = builder.body(request.body.to_vec()).send().await?; + // `from_owner` lends reqwest the buffer instead of copying it: the + // `Zeroizing` is dropped, and wiped, when the body is. + let response = builder.body(bytes::Bytes::from_owner(body)).send().await?; let status = response.status().as_u16(); let headers = response .headers() @@ -412,21 +508,21 @@ mod tests { /// remembers what it was asked, so a test can pin the wire shape without /// an HTTP client in the build. struct Stub { - response: Mutex<Option<Result<(u16, &'static str), &'static str>>>, + response: Result<(u16, &'static str), &'static str>, seen: Mutex<Vec<Seen>>, } impl Stub { fn replying(status: u16, body: &'static str) -> Self { Self { - response: Mutex::new(Some(Ok((status, body)))), + response: Ok((status, body)), seen: Mutex::new(Vec::new()), } } fn failing(message: &'static str) -> Self { Self { - response: Mutex::new(Some(Err(message))), + response: Err(message), seen: Mutex::new(Vec::new()), } } @@ -440,7 +536,7 @@ mod tests { request.headers().to_vec(), request.body().to_vec(), )); - match self.response.lock().unwrap().take().expect("one request") { + match self.response { Ok((status, body)) => Ok(HttpResponse::new(status, Vec::new(), body.into())), Err(message) => Err(RequestError(Box::new(std::io::Error::other(message)))), } @@ -459,6 +555,62 @@ mod tests { stub.seen.lock().unwrap().remove(0) } + #[test] + fn debug_output_names_headers_and_never_prints_a_secret() { + let request = HttpRequest::new( + "POST", + base_url(), + vec![ + ("authorization".into(), "Bearer SECRET-TOKEN".into()), + ("content-type".into(), "application/json".into()), + ], + br#"{"accessKey":"CSAK-SECRET"}"#.to_vec(), + ); + let shown = format!("{request:?}"); + assert!( + shown.contains("authorization") && shown.contains("content-type"), + "{shown}" + ); + assert!(shown.contains("body_len: 27"), "{shown}"); + assert!(!shown.contains("SECRET"), "{shown}"); + + let response = HttpResponse::new( + 200, + vec![("set-cookie".into(), "session=SECRET".into())], + br#"{"accessToken":"SECRET"}"#.to_vec(), + ); + let shown = format!("{response:?}"); + assert!( + shown.contains("status: 200") && shown.contains("set-cookie"), + "{shown}" + ); + assert!(!shown.contains("SECRET"), "{shown}"); + } + + #[tokio::test] + async fn a_shared_transport_serves_more_than_one_strategy() { + let stub = Arc::new(Stub::replying( + 200, + r#"{"accessToken":"svc","expiry":4102444800}"#, + )); + let one: SharedTransport = share(Arc::clone(&stub)); + let two: SharedTransport = share(stub.clone()); + for transport in [one, two] { + let refresher = AccessKeyRefresher::new( + SecretToken::new("CSAKid.secret"), + base_url(), + None, + transport, + ); + let _ = refresher.refresh(&()).await; + } + assert_eq!( + stub.seen.lock().unwrap().len(), + 2, + "both strategies reached the one transport" + ); + } + #[tokio::test] async fn refresh_posts_a_form_and_reads_the_token() { let stub = Arc::new(Stub::replying( From ce9f34272cf7b944f5cf5752036179c13888a6c1 Mon Sep 17 00:00:00 2001 From: Dan Draper <dan@cipherstash.com> Date: Sun, 20 Sep 2026 15:54:03 -0700 Subject: [PATCH 601/686] fix(stack-encrypt): a refused strict growth is the call's failure, not the client's lock state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Under RequireLockedMemory a growth whose lock is refused is given back before the guest sees it, so every byte the guest holds is still locked. The allocator nonetheless recorded the refusal as the persistent lock error, and from then on MemoryLocked, MemoryLockError, String and LogValue reported the client as running unlocked. The refusal now lives in its own state — the count and the latest reason — which Client.call and newInstance report on the failing call; the persistent error is reserved for memory actually admitted unlocked. Three regression tests: the allocator's bookkeeping and the Client's reporting under a refusing backend (every host), and the real refusal from RLIMIT_MEMLOCK on a growth in a child process (Linux, where CI requires the lock). The child-process harness is shared with the existing strict NewClient test. Also: the call comment pointed at observed.Free; the method is memoryAllocator.Free. --- languages/golang/stackencrypt/client.go | 15 +- languages/golang/stackencrypt/guest.go | 6 +- languages/golang/stackencrypt/memory.go | 34 +-- .../golang/stackencrypt/memory_other_test.go | 2 +- languages/golang/stackencrypt/memory_test.go | 211 ++++++++++++++++-- .../golang/stackencrypt/memory_unix_test.go | 8 +- 6 files changed, 232 insertions(+), 44 deletions(-) diff --git a/languages/golang/stackencrypt/client.go b/languages/golang/stackencrypt/client.go index 55590eadc..597560806 100644 --- a/languages/golang/stackencrypt/client.go +++ b/languages/golang/stackencrypt/client.go @@ -42,9 +42,10 @@ type Config struct { // memory that may be swapped and reporting so through // Client.MemoryLocked. It holds for the life of the client: a later // growth of the guest's memory that cannot be locked is refused too, - // and the call that needed it fails with ErrMemoryLock. Set it where - // swap is a real exposure and the deployment grants a lock limit with - // room for the guest to grow (RLIMIT_MEMLOCK on Linux); see + // and the call that needed it fails with ErrMemoryLock, while what the + // guest already holds stays locked and MemoryLocked stays true. Set it + // where swap is a real exposure and the deployment grants a lock limit + // with room for the guest to grow (RLIMIT_MEMLOCK on Linux); see // [Client.MemoryLocked]. RequireLockedMemory bool } @@ -304,7 +305,7 @@ func (c *Client) call(ctx context.Context, f func(*instance) ([]byte, error)) ([ c.closed = true return nil, ErrState } - refusals := c.inst.mem.growthRefusals() + refusals, _ := c.inst.mem.growthRefusals() out, err := f(c.inst) if c.inst.module.IsClosed() { c.closed = true @@ -315,8 +316,10 @@ func (c *Client) call(ctx context.Context, f func(*instance) ([]byte, error)) ([ } // Under RequireLockedMemory a growth that cannot be locked is refused, // and the guest sees only a failed allocation. Name the real cause. - if err != nil && c.inst.mem.growthRefusals() != refusals { - err = fmt.Errorf("%w (growth refused under RequireLockedMemory): %w", memoryLockError(c.inst.mem.lockError()), err) + // The refusal is this call's, not the client's: the range went back + // unused, so MemoryLocked still holds. + if n, gerr := c.inst.mem.growthRefusals(); err != nil && n != refusals { + err = fmt.Errorf("%w (growth refused under RequireLockedMemory): %w", memoryLockError(gerr), err) } if err != nil { return nil, err diff --git a/languages/golang/stackencrypt/guest.go b/languages/golang/stackencrypt/guest.go index f918bbfdf..c18915420 100644 --- a/languages/golang/stackencrypt/guest.go +++ b/languages/golang/stackencrypt/guest.go @@ -120,8 +120,8 @@ func newInstance(ctx context.Context, wasm []byte, t *transport, policy lockPoli module, err := runtime.InstantiateWithConfig(experimental.WithMemoryAllocator(ctx, mem), wasm, guestModuleConfig()) if err != nil { _ = runtime.Close(ctx) - if mem.growthRefusals() != 0 { - return nil, fmt.Errorf("%w: %w", memoryLockError(mem.lockError()), err) + if n, gerr := mem.growthRefusals(); n != 0 { + return nil, fmt.Errorf("%w: %w", memoryLockError(gerr), err) } return nil, fmt.Errorf("stackencrypt: instantiating guest: %w", err) } @@ -233,7 +233,7 @@ func (inst *instance) call(ctx context.Context, fn api.Function, args ...arg) ([ // The memory stays mapped for the whole call, the deferred frees // included: a close that lands mid-call (an expired context during a // host import) is honoured by this exit, not under running guest - // code. See observed.Free. + // code. See memoryAllocator.Free. inst.mem.enter() defer inst.mem.exit() var bufs []guestBuf diff --git a/languages/golang/stackencrypt/memory.go b/languages/golang/stackencrypt/memory.go index 653388faa..f3144ab6a 100644 --- a/languages/golang/stackencrypt/memory.go +++ b/languages/golang/stackencrypt/memory.go @@ -92,14 +92,19 @@ type memoryAllocator struct { // reservation: no lock is possible, growth may copy (and wipes what // it abandons). fallback bool - // err is the first refusal of any kind — the reservation, the dump - // exclusion, a lock — and never clears: a lock refused once is - // reported for the life of the instance. + // err is the first refusal that left the guest holding unprotected + // memory — the reservation, the dump exclusion, a lock on a range that + // was kept — and never clears: a lock refused once is reported for + // the life of the instance. err error - // refusals counts strict growths refused. Client.call compares it - // across a call to name the real cause when the guest reports only a - // failed allocation. - refusals uint64 + // refusals counts strict growths refused, and growthErr is the lock + // refusal behind the latest. Neither is err: a refused growth gives + // its range back before the guest sees it, so every byte the guest + // holds is still locked and the instance still reports so. Client.call + // compares refusals across a call to name the real cause when the + // guest reports only a failed allocation. + refusals uint64 + growthErr error // inFlight counts guest calls in progress (see enter and exit); // pending records a Free that arrived while one was, to be honoured // when the outermost call returns. @@ -137,11 +142,13 @@ func (a *memoryAllocator) Reallocate(size uint64) []byte { buf, lockErr := a.mem.commit(size) if lockErr != nil { a.mu.Lock() - if a.err == nil { - a.err = lockErr - } if buf == nil { + // Strict: the range was given back, so nothing unlocked was + // admitted and the lock report stands. a.refusals++ + a.growthErr = lockErr + } else if a.err == nil { + a.err = lockErr } a.mu.Unlock() } @@ -205,11 +212,12 @@ func (a *memoryAllocator) lockError() error { return a.err } -// growthRefusals counts the strict growths refused so far. -func (a *memoryAllocator) growthRefusals() uint64 { +// growthRefusals counts the strict growths refused so far, with the lock +// refusal behind the latest (nil while the count is zero). +func (a *memoryAllocator) growthRefusals() (uint64, error) { a.mu.Lock() defer a.mu.Unlock() - return a.refusals + return a.refusals, a.growthErr } func (a *memoryAllocator) isFallback() bool { diff --git a/languages/golang/stackencrypt/memory_other_test.go b/languages/golang/stackencrypt/memory_other_test.go index 0bcf85c95..d5b65ea93 100644 --- a/languages/golang/stackencrypt/memory_other_test.go +++ b/languages/golang/stackencrypt/memory_other_test.go @@ -4,4 +4,4 @@ package stackencrypt import "errors" -func dropMemlockLimit() error { return errors.New("no RLIMIT_MEMLOCK on this platform") } +func setMemlockLimit(uint64) error { return errors.New("no RLIMIT_MEMLOCK on this platform") } diff --git a/languages/golang/stackencrypt/memory_test.go b/languages/golang/stackencrypt/memory_test.go index ae6a78a1a..dce849aa2 100644 --- a/languages/golang/stackencrypt/memory_test.go +++ b/languages/golang/stackencrypt/memory_test.go @@ -311,30 +311,42 @@ func smapsKB(value string) uint64 { return n } -// Strict mode is a NewClient failure, not a report. The refusal is -// provoked by lowering RLIMIT_MEMLOCK to zero, which is process-wide and -// irreversible for a non-root process, so it runs in a child. -func TestRequireLockedMemoryRefusesAnUnlockableGuest(t *testing.T) { +// inChild re-runs the calling test in a child process, for tests that +// lower RLIMIT_MEMLOCK: the change is process-wide and irreversible for a +// non-root process. It returns true in the child, which prints "case ok" +// when done or "case skipped: <why>" when the host cannot provoke the +// condition; the parent judges that output and returns false. +func inChild(t *testing.T) bool { + t.Helper() if runtime.GOOS == "windows" { t.Skip("no RLIMIT_MEMLOCK on Windows") } const child = "STACKENCRYPT_TEST_CHILD" - if os.Getenv(child) == "" { - cmd := exec.Command(os.Args[0], "-test.run=^"+t.Name()+"$", "-test.v") - cmd.Env = append(os.Environ(), child+"=1") - out, err := cmd.CombinedOutput() - switch { - case strings.Contains(string(out), "case skipped:"): - if os.Getenv(requireLock) != "" { - t.Fatalf("%s is set and the refusal could not be provoked:\n%s", requireLock, out) - } - t.Skipf("refusal could not be provoked on this host:\n%s", out) - case err != nil || !strings.Contains(string(out), "case ok"): - t.Fatalf("child failed: %v\n%s", err, out) + if os.Getenv(child) != "" { + return true + } + cmd := exec.Command(os.Args[0], "-test.run=^"+t.Name()+"$", "-test.v") + cmd.Env = append(os.Environ(), child+"=1") + out, err := cmd.CombinedOutput() + switch { + case strings.Contains(string(out), "case skipped:"): + if os.Getenv(requireLock) != "" { + t.Fatalf("%s is set and the refusal could not be provoked:\n%s", requireLock, out) } + t.Skipf("refusal could not be provoked on this host:\n%s", out) + case err != nil || !strings.Contains(string(out), "case ok"): + t.Fatalf("child failed: %v\n%s", err, out) + } + return false +} + +// Strict mode is a NewClient failure, not a report. The refusal is +// provoked by lowering RLIMIT_MEMLOCK to zero. +func TestRequireLockedMemoryRefusesAnUnlockableGuest(t *testing.T) { + if !inChild(t) { return } - if err := dropMemlockLimit(); err != nil { + if err := setMemlockLimit(0); err != nil { t.Fatalf("lowering RLIMIT_MEMLOCK: %v", err) } // Can the lock be refused at all here? Root and CAP_IPC_LOCK ignore @@ -387,6 +399,171 @@ func TestRequireLockedMemoryRefusesAnUnlockableGuest(t *testing.T) { fmt.Println("case ok") } +// The same refusal on a growth, from the kernel: with RLIMIT_MEMLOCK at +// two pages the probe's first page locks and a growth by two more cannot. +// Strict refuses the growth and gives the range back, so the page the +// probe holds is still locked and the allocator still says so; the +// refusal is reported on its own, naming the limit. +func TestRequireLockedMemoryRefusesAnUnlockableGrowth(t *testing.T) { + if !inChild(t) { + return + } + const limit = 2 * wasmPage + if err := setMemlockLimit(limit); err != nil { + t.Fatalf("lowering RLIMIT_MEMLOCK: %v", err) + } + alloc := newMemoryAllocator(strict) + _, grow, done := probeMemory(t, alloc) + defer done() + if alloc.isFallback() { + fmt.Printf("case skipped: heap fallback in use on this host: %v\n", alloc.lockError()) + return + } + if err := alloc.lockError(); err != nil { + fmt.Printf("case skipped: the first page did not lock under RLIMIT_MEMLOCK=%d: %v\n", limit, err) + return + } + if _, ok := grow(2); ok { + fmt.Println("case skipped: mlock succeeds past RLIMIT_MEMLOCK") + return + } + if err := alloc.lockError(); err != nil { + t.Fatalf("a refused growth changed the lock report: %v", err) + } + n, gerr := alloc.growthRefusals() + if n != 1 || gerr == nil { + t.Fatalf("growthRefusals = %d, %v; want 1 and the refusal", n, gerr) + } + if !strings.Contains(gerr.Error(), "RLIMIT_MEMLOCK") { + t.Fatalf("the refusal does not name the limit: %v", gerr) + } + fmt.Println("case ok") +} + +// refusingBackend stands in front of a real backend and refuses, as +// strict does, any commit past a size: nil buffer, the reason as the lock +// error, nothing admitted. Lifted, it delegates again. It exercises the +// allocator's and the Client's bookkeeping of a refused growth without a +// lock limit, so it runs on every host. +type refusingBackend struct { + backend + past uint64 + reason error + refuse bool + refused int +} + +func (b *refusingBackend) commit(size uint64) ([]byte, error) { + if b.refuse && size > b.past { + b.refused++ + return nil, b.reason + } + return b.backend.commit(size) +} + +// A refused growth is the growth's failure, not the memory's: the +// allocator counts it and keeps its reason, and the lock report — nil, +// or whatever this host refused at the start — is exactly what it was. +// Once the growth is let through the report is still unchanged. +func TestRefusedGrowthLeavesTheLockReportAlone(t *testing.T) { + alloc := newMemoryAllocator(strict) + base, grow, done := probeMemory(t, alloc) + defer done() + before := alloc.lockError() + refusing := &refusingBackend{backend: alloc.mem, past: uint64(alloc.mem.(sized).size()), reason: errors.New("refused for the test"), refuse: true} + alloc.mem = refusing + at := base() + if _, ok := grow(1); ok { + t.Fatal("the refused growth was granted") + } + if after := alloc.lockError(); after != before { + t.Fatalf("the refused growth changed the lock report: %v -> %v", before, after) + } + if n, gerr := alloc.growthRefusals(); n != 1 || gerr != refusing.reason { + t.Fatalf("growthRefusals = %d, %v; want 1 and the refusal", n, gerr) + } + refusing.refuse = false + if _, ok := grow(1); !ok { + t.Fatal("growth refused once the backend lets it through") + } + if after := alloc.lockError(); after != before { + t.Fatalf("a later growth changed the lock report: %v -> %v", before, after) + } + if n, _ := alloc.growthRefusals(); n != 1 { + t.Fatalf("growthRefusals = %d after a granted growth, want 1", n) + } + if base() != at { + t.Fatal("memory moved across the refused growth") + } +} + +// sized is what the test needs of a backend to know where it stands. +type sized interface{ size() uint64 } + +func (m *mappedMemory) size() uint64 { return m.committed } +func (m *heapMemory) size() uint64 { return uint64(len(m.buf)) } + +// The same, through the Client: the call that needed the growth fails +// with ErrMemoryLock naming the refusal, the client is still open, and +// it still reports locked memory everywhere it is asked — the method, +// the error, the print and the log. +func TestRequireLockedMemoryFailsTheCallThatCannotGrow(t *testing.T) { + ctx := context.Background() + inst, err := newInstance(ctx, guestOrSkip(t), &transport{rt: http.DefaultTransport, token: StaticToken("t")}, strict) + if errors.Is(err, ErrMemoryLock) { + if os.Getenv(requireLock) != "" { + t.Fatalf("%s is set and the lock was refused: %v", requireLock, err) + } + t.Skipf("lock refused on this host: %v", err) + } + if err != nil { + t.Fatal(err) + } + c := newClient(inst, nil) + defer c.Close() + if !c.MemoryLocked() { + t.Fatalf("a strict client reports unlocked memory: %v", c.MemoryLockError()) + } + refusing := &refusingBackend{backend: inst.mem.mem, past: inst.mem.mem.(sized).size(), reason: errors.New("refused for the test"), refuse: true} + inst.mem.mem = refusing + // Larger than the guest's initial memory, so the guest must grow. + stage := func(inst *instance) ([]byte, error) { + staged, err := inst.allocWrite(ctx, make([]byte, 2<<20)) + if err != nil { + return nil, err + } + inst.free(ctx, staged) + return nil, nil + } + _, err = c.call(ctx, stage) + if !errors.Is(err, ErrMemoryLock) || !strings.Contains(err.Error(), "growth refused") || !strings.Contains(err.Error(), refusing.reason.Error()) { + t.Fatalf("call needing a refused growth: %v; want ErrMemoryLock naming the refusal", err) + } + if refusing.refused == 0 { + t.Fatal("the guest did not grow; the test proves nothing") + } + if !c.MemoryLocked() { + t.Fatalf("a refused growth unlocked the report: %v", c.MemoryLockError()) + } + if err := c.MemoryLockError(); err != nil { + t.Fatalf("MemoryLockError = %v after a refused growth, want nil", err) + } + if s := fmt.Sprint(c); !strings.HasSuffix(s, "memory: locked}") { + t.Fatalf("Client prints as %q after a refused growth", s) + } + if v := c.LogValue().String(); !strings.Contains(v, "memory_locked=true") { + t.Fatalf("Client logs as %q after a refused growth", v) + } + // The client is still open, and grows once it can. + refusing.refuse = false + if _, err := c.call(ctx, stage); err != nil { + t.Fatalf("the next call, growth allowed: %v", err) + } + if !c.MemoryLocked() { + t.Fatalf("the report changed on a granted growth: %v", c.MemoryLockError()) + } +} + // reentrantProbe is a hand-assembled module reproducing the shape of the // guest's transport import: "run" calls the host function h, then stores // to memory. h re-enters the guest (as transport_send does through diff --git a/languages/golang/stackencrypt/memory_unix_test.go b/languages/golang/stackencrypt/memory_unix_test.go index 038e8c337..e4216c2cb 100644 --- a/languages/golang/stackencrypt/memory_unix_test.go +++ b/languages/golang/stackencrypt/memory_unix_test.go @@ -4,8 +4,8 @@ package stackencrypt import "golang.org/x/sys/unix" -// dropMemlockLimit lowers RLIMIT_MEMLOCK to zero for this process. Only a -// child test process calls it. -func dropMemlockLimit() error { - return unix.Setrlimit(unix.RLIMIT_MEMLOCK, &unix.Rlimit{Cur: 0, Max: 0}) +// setMemlockLimit lowers RLIMIT_MEMLOCK to n bytes for this process. Only +// a child test process calls it. +func setMemlockLimit(n uint64) error { + return unix.Setrlimit(unix.RLIMIT_MEMLOCK, &unix.Rlimit{Cur: n, Max: n}) } From f44f0276eba8e9e94359b0888e5ff884f7fa965d Mon Sep 17 00:00:00 2001 From: Dan Draper <dan@cipherstash.com> Date: Sun, 20 Sep 2026 16:17:07 -0700 Subject: [PATCH 602/686] fix(stack-encrypt): a guest trap closes the client; the lock refusal names the size held; README and glossary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-ups on the guest-memory work. A guest export that does not return — a trap, which under panic-as-abort is an abort mid-export — now closes the client from Client.call: its state after an abort is unknown and its keys are better wiped than reused. Under RequireLockedMemory that is how a refused growth for the guest's own allocation ends, as distinct from a host-staged buffer, where the guest reports a failed allocation and the client goes on; the Config, ErrMemoryLock and package docs say so, and a test provokes each. The lock refusal names the size the guest holds as well as the increment it could not lock, so an operator sizing RLIMIT_MEMLOCK has the number. The allocator owns its printed and logged form; Client delegates. The backing and the lock error are named for what they are, and the refused growths are one value. Test helpers replace the repeated smaps block, base-address closure, require-or-skip shape and refusing-backend construction; the Linux smaps tests and helpers move to their own file, and the dump-exclusion assertion runs whenever there is a reservation, before the lock assertion that a refused lock skips. The rlimit helper assigns portably to the BSDs' signed fields. A package README covers connecting, key material in memory and the errors; the fuller draft lands with the target-directed records work. The stack-encrypt glossary gains the guest-memory vocabulary. --- languages/golang/stackencrypt/README.md | 145 ++++++++ languages/golang/stackencrypt/client.go | 57 ++-- languages/golang/stackencrypt/doc.go | 7 +- languages/golang/stackencrypt/errors.go | 8 +- languages/golang/stackencrypt/guest.go | 31 +- languages/golang/stackencrypt/memory.go | 87 +++-- .../golang/stackencrypt/memory_linux_test.go | 131 ++++++++ .../golang/stackencrypt/memory_mapped.go | 4 + languages/golang/stackencrypt/memory_test.go | 313 ++++++++---------- .../golang/stackencrypt/memory_unix_test.go | 9 +- packages/stack-encrypt/CONTEXT.md | 43 +++ 11 files changed, 582 insertions(+), 253 deletions(-) create mode 100644 languages/golang/stackencrypt/README.md create mode 100644 languages/golang/stackencrypt/memory_linux_test.go diff --git a/languages/golang/stackencrypt/README.md b/languages/golang/stackencrypt/README.md new file mode 100644 index 000000000..67e1247e4 --- /dev/null +++ b/languages/golang/stackencrypt/README.md @@ -0,0 +1,145 @@ +# stack-encrypt for Go + +Client-side encryption of values under per-value ZeroKMS data keys, and the +derivation of searchable index terms from the same values, for Go. The +`stack-encrypt` Rust crate is compiled to a WASI module and embedded in +this package; Go calls it through wazero, a pure-Go WebAssembly runtime, +so there is no cgo and no separate Go port of the cryptography. + +The package reference is on [pkg.go.dev]; this README covers connecting, +what happens to key material, and the errors. + +[pkg.go.dev]: https://pkg.go.dev/github.com/cipherstash/cipherstash-suite/bindings/go/stackencrypt + +## Install + +```sh +go get github.com/cipherstash/cipherstash-suite/bindings/go/stackencrypt +``` + +Go 1.25 or later. + +## Connect + +A `Client` is one ZeroKMS client: its client key, its default keyset, and +the keysets it has loaded since. Make one per process and share it; it is +safe for concurrent use. + +```go +import ( + "context" + "os" + + "github.com/cipherstash/cipherstash-suite/bindings/go/stackencrypt" +) + +func run(ctx context.Context) error { + client, err := stackencrypt.NewClient(ctx, stackencrypt.Config{ + ClientID: os.Getenv("CS_CLIENT_ID"), + ClientKey: os.Getenv("CS_CLIENT_KEY"), + Token: stackencrypt.StaticToken(os.Getenv("CS_CLIENT_ACCESS_KEY")), + }) + if err != nil { + return err + } + defer client.Close() + + // ... + return nil +} +``` + +`ctx` is Go's standard `context.Context`, and it means what it always +means: the deadline and cancellation for the work this call does. +`NewClient` makes one ZeroKMS round trip, to load the client's default +keyset, and `ctx` bounds that request. It has nothing to do with an +*encryption* context, which is the value a field is sealed under; that is +`stackencrypt.Context`. Every method that can reach ZeroKMS takes a +`context.Context` first, for the same reason. + +`Token` supplies the bearer token for every request. `StaticToken` is the +simplest source; a `TokenFunc` can fetch or refresh one. + +### Key material in memory + +The client key, every loaded index key and every data key in use live in +the wasm instance's memory, and the package owns that memory rather than +leaving it to the runtime's default. It is reserved once and never moves, +so growth never copies a key to somewhere it is not wiped; it is locked in +RAM (`mlock`, `VirtualLock`) so it is never written to swap; on Linux it is +excluded from core dumps (`MADV_DONTDUMP`); and it is wiped before it is +released, on every release path. None of that waits for `Close`. A process +killed by SIGKILL, the OOM killer, a panic on another goroutine or `os.Exit` +runs no deferred call, and the kernel zeroes its pages before anyone else +sees them; the lock and the dump exclusion close the two places a copy +could otherwise outlive the process. + +The lock is best effort. `RLIMIT_MEMLOCK` defaults to 64 KiB on many Linux +hosts and the instance is larger, so the lock is often refused, and the +client then runs with memory the kernel may swap out, which is all that is +lost, and nothing on a host without swap. `client.MemoryLocked()` reports +the outcome and `client.MemoryLockError()` names the limit to raise +(`ulimit -l`, a systemd `LimitMEMLOCK=`, a pod `securityContext`) and the +size the instance holds. For a deployment that would rather not start than +run unlocked, set `RequireLockedMemory` and `NewClient` fails with +`ErrMemoryLock`. That policy holds for the life of the client: memory the +instance later grows into must lock too, or the call that needed it fails +with `ErrMemoryLock`, so grant a limit with room to grow. A `Client` prints +its memory state with `%v` and logs it as a `slog` group, so a startup log +shows it. + +Production checklist: assert `MemoryLocked()` at startup, or set +`RequireLockedMemory`. Handling `SIGTERM` for a graceful shutdown is +ordinary Go practice and worth doing for your own reasons; the SDK does not +depend on it and installs no signal handler of its own. + +`Close` runs the guest's own shutdown, wiping every key in place before the +instance is released, and takes no context because it does no I/O. A +`Client` that becomes unreachable without `Close` is released by a runtime +cleanup, which covers the forgot-to-close case in a running process and +nothing at exit. + +## Errors + +Errors are sentinel values, matched with `errors.Is`. The wasm guest +reports a status code and nothing else, so the vocabulary is deliberately +small and reveals nothing about plaintext or key material. + +| Error | Meaning | +|---|---| +| `ErrAuthentication` | A ciphertext failed to open: tampered, or presented under the wrong context or element derivation. | +| `ErrForbidden` | ZeroKMS refused the request. Also the production form of a wrong-context open, because every data key is bound to its context. | +| `ErrUnauthorized` | ZeroKMS rejected the bearer token: invalid, expired, or for another workspace. | +| `ErrNotFound` | Unknown keyset name or id, or a missing data key. | +| `ErrForeignKeyset` | A keyset-bound `Cipher` was given another keyset's ciphertext. Open it through the `Client`. | +| `ErrEncoding` | Malformed input: a value, ciphertext, plan, context or config refused before any cryptography. | +| `ErrTerm` | A term could not be derived, for example match text that yields no tokens. | +| `ErrTransport` | ZeroKMS could not be reached. | +| `ErrKMS` | Any other ZeroKMS failure. | +| `ErrConflict` | ZeroKMS reported a resource conflict. | +| `ErrState` | The client has been closed: by `Close`, by a call its context interrupted, or by a guest trap. | +| `ErrMemoryLock` | The instance's memory could not be locked in RAM. Returned by `NewClient` under `RequireLockedMemory`, and by a call whose growth could not be locked; otherwise reported by `MemoryLockError`. | +| `ErrInternal` | An unexpected failure inside the guest. | + +## How it works under the hood + +- **One implementation.** The `stack-encrypt` Rust crate is compiled to a + WASI module and embedded in the package. There is no separate Go port of + the cryptography, so ciphertexts and terms are byte-identical to the Rust + crate's and interchange with every other binding. +- **Key material stays in the guest.** The client key crosses into wasm + memory once at `NewClient`. Data keys are retrieved from ZeroKMS into + guest memory and never surface in Go. That memory is the package's own: + reserved once so it never moves, locked and excluded from core dumps + where the platform allows, wiped before release. `Close` runs the guest's + own shutdown as well, so every key is wiped in place before the instance + is released. +- **Two host imports.** The guest imports exactly one HTTP send, served by + your `http.RoundTripper`, and one bearer-token fetch, served by your + `TokenSource`. What crosses the boundary per ZeroKMS call is what would + cross TLS anyway. The guest sees no environment and no filesystem. +- **Real randomness.** The guest draws IVs and nonces from the process + CSPRNG. wazero's default random source is deterministic, so the package + configures every instance with `crypto/rand` explicitly. +- **Concurrency.** A wasm instance is single-threaded, so calls on one + `Client` are serialised internally. The `Client` is safe to share. diff --git a/languages/golang/stackencrypt/client.go b/languages/golang/stackencrypt/client.go index 597560806..016a292da 100644 --- a/languages/golang/stackencrypt/client.go +++ b/languages/golang/stackencrypt/client.go @@ -38,14 +38,18 @@ type Config struct { // Guest overrides the embedded wasm module. Nil means the embedded one. Guest []byte // RequireLockedMemory makes NewClient fail with ErrMemoryLock when the - // guest's memory cannot be locked in RAM, instead of continuing with - // memory that may be swapped and reporting so through - // Client.MemoryLocked. It holds for the life of the client: a later - // growth of the guest's memory that cannot be locked is refused too, - // and the call that needed it fails with ErrMemoryLock, while what the - // guest already holds stays locked and MemoryLocked stays true. Set it - // where swap is a real exposure and the deployment grants a lock limit - // with room for the guest to grow (RLIMIT_MEMLOCK on Linux); see + // guest's memory cannot be locked in RAM or, on Linux, excluded from + // core dumps, instead of continuing with memory that may be swapped or + // dumped and reporting so through Client.MemoryLocked. It holds for + // the life of the client: a later growth of the guest's memory that + // cannot be locked is refused too, and what the guest already holds + // stays locked. When the growth was for a buffer the host is staging, + // the call fails with ErrMemoryLock and the client goes on. When it + // was for the guest's own allocation, the guest cannot report it: it + // aborts, and the client is closed with its keys wiped, the call still + // failing with ErrMemoryLock. Set it where swap is a real exposure and + // the deployment grants a lock limit with room for the guest to grow + // (RLIMIT_MEMLOCK on Linux; the error names the size held so far); see // [Client.MemoryLocked]. RequireLockedMemory bool } @@ -162,20 +166,12 @@ func (c *Client) MemoryLockError() error { // printed. The state is what an operator reading a startup log needs to // see, and [Client.LogValue] gives it structured form. func (c *Client) String() string { - if err := c.inst.mem.lockError(); err != nil { - return fmt.Sprintf("stackencrypt.Client{memory: unlocked: %v}", err) - } - return "stackencrypt.Client{memory: locked}" + return fmt.Sprintf("stackencrypt.Client{memory: %s}", c.inst.mem) } // LogValue implements slog.LogValuer: a group with memory_locked and, when // false, memory_lock_error. -func (c *Client) LogValue() slog.Value { - if err := c.inst.mem.lockError(); err != nil { - return slog.GroupValue(slog.Bool("memory_locked", false), slog.String("memory_lock_error", err.Error())) - } - return slog.GroupValue(slog.Bool("memory_locked", true)) -} +func (c *Client) LogValue() slog.Value { return c.inst.mem.LogValue() } // encodeConfig renders the se_cipher_init object. The result holds the // client key; the caller wipes it. @@ -298,6 +294,9 @@ func (c *Client) DecryptRecord(ctx context.Context, record EncryptedRecord, out // its key material is gone with its memory and no further call can run. // The client is then closed, so later calls are ErrState rather than a // runtime error, and Close releases the runtime without a shutdown call. +// A guest that trapped is closed the same way, by this method: the guest +// builds with panic-as-abort, so a trap is an abort mid-export, after +// which its state is unknown and its keys are better wiped than reused. func (c *Client) call(ctx context.Context, f func(*instance) ([]byte, error)) ([]byte, error) { c.mu.Lock() defer c.mu.Unlock() @@ -305,21 +304,29 @@ func (c *Client) call(ctx context.Context, f func(*instance) ([]byte, error)) ([ c.closed = true return nil, ErrState } - refusals, _ := c.inst.mem.growthRefusals() + growth := c.inst.mem.growthRefusal() out, err := f(c.inst) - if c.inst.module.IsClosed() { + switch { + case c.inst.module.IsClosed(): c.closed = true if err == nil { err = ErrState } err = fmt.Errorf("%w: interrupted call closed the client", err) + case errors.Is(err, errGuestTrap): + // No guest code is running (f has returned), so the close is + // immediate: the module's memory is wiped and freed here. + c.closed = true + _ = c.inst.module.Close(context.Background()) + err = fmt.Errorf("%w; the client is closed", err) } // Under RequireLockedMemory a growth that cannot be locked is refused, - // and the guest sees only a failed allocation. Name the real cause. - // The refusal is this call's, not the client's: the range went back - // unused, so MemoryLocked still holds. - if n, gerr := c.inst.mem.growthRefusals(); err != nil && n != refusals { - err = fmt.Errorf("%w (growth refused under RequireLockedMemory): %w", memoryLockError(gerr), err) + // and the guest sees only a failed allocation — or, for an allocation + // of its own, aborts, and the trap closed the client above. Name the + // real cause either way. The refusal is this call's, not the client's: + // the range went back unused, so MemoryLocked still holds. + if g := c.inst.mem.growthRefusal(); err != nil && g.refused != growth.refused { + err = fmt.Errorf("%w (growth refused under RequireLockedMemory): %w", memoryLockError(g.reason), err) } if err != nil { return nil, err diff --git a/languages/golang/stackencrypt/doc.go b/languages/golang/stackencrypt/doc.go index f90e75a61..41e24af18 100644 --- a/languages/golang/stackencrypt/doc.go +++ b/languages/golang/stackencrypt/doc.go @@ -94,8 +94,11 @@ // [NewClient] failure with [ErrMemoryLock], for deployments that would // rather not start than run unlocked; it also refuses any later growth of // the guest's memory that cannot be locked, so the limit granted must -// leave the guest room to grow. A Client prints its memory state -// ([Client.String]) and logs it ([Client.LogValue]). An embedder running +// leave the guest room to grow: a refused growth fails the call with +// [ErrMemoryLock], and closes the client when the growth was the guest's +// own allocation rather than a host-staged buffer. A Client prints its +// memory state ([Client.String]) and logs it ([Client.LogValue]). An +// embedder running // the guest under its own wazero configuration gets none of this unless // it supplies an allocator of its own. // diff --git a/languages/golang/stackencrypt/errors.go b/languages/golang/stackencrypt/errors.go index a87193ea0..6c72d3c1f 100644 --- a/languages/golang/stackencrypt/errors.go +++ b/languages/golang/stackencrypt/errors.go @@ -49,9 +49,11 @@ var ( ErrForeignKeyset = errors.New("stackencrypt: ciphertext belongs to another keyset") // ErrMemoryLock is guest memory that could not be locked in RAM (or, // on Linux, excluded from core dumps). NewClient returns it when - // Config.RequireLockedMemory is set; otherwise Client.MemoryLockError - // reports it and the client works on with unlocked memory. The wrapped - // error names the limit that refused the lock: on Linux, RLIMIT_MEMLOCK + // Config.RequireLockedMemory is set, and so does any later call under + // that setting whose growth of the guest's memory could not be locked; + // otherwise Client.MemoryLockError reports it and the client works on + // with unlocked memory. The wrapped error names the limit that refused + // the lock and the size the guest holds: on Linux, RLIMIT_MEMLOCK // (ulimit -l, a systemd LimitMEMLOCK=, or a pod's securityContext). ErrMemoryLock = errors.New("stackencrypt: guest memory is not locked") ) diff --git a/languages/golang/stackencrypt/guest.go b/languages/golang/stackencrypt/guest.go index c18915420..22d601de1 100644 --- a/languages/golang/stackencrypt/guest.go +++ b/languages/golang/stackencrypt/guest.go @@ -28,6 +28,13 @@ const guestPath = "wasm/stack_encrypt_guest.wasm" // embedded and none was supplied in Config.Guest. var ErrGuestNotBuilt = errors.New("stackencrypt: guest module not built — run `mise run wasm:guest:build`") +// errGuestTrap marks a guest export that did not return: a trap (the +// guest builds with panic-as-abort, so an allocation it cannot make or an +// invariant it cannot keep ends in `unreachable`), or a module closed +// under it. Client.call closes the client on it: the guest's state after +// an abort is unknown, and its keys are better wiped than reused. +var errGuestTrap = errors.New("stackencrypt: guest did not return") + func embeddedGuest() ([]byte, error) { wasm, err := guestFS.ReadFile(guestPath) if err != nil { @@ -120,8 +127,8 @@ func newInstance(ctx context.Context, wasm []byte, t *transport, policy lockPoli module, err := runtime.InstantiateWithConfig(experimental.WithMemoryAllocator(ctx, mem), wasm, guestModuleConfig()) if err != nil { _ = runtime.Close(ctx) - if n, gerr := mem.growthRefusals(); n != 0 { - return nil, fmt.Errorf("%w: %w", memoryLockError(gerr), err) + if g := mem.growthRefusal(); g.refused != 0 { + return nil, fmt.Errorf("%w: %w", memoryLockError(g.reason), err) } return nil, fmt.Errorf("stackencrypt: instantiating guest: %w", err) } @@ -182,7 +189,7 @@ type guestBuf struct { func (inst *instance) allocWrite(ctx context.Context, data []byte) (guestBuf, error) { res, err := inst.alloc.Call(ctx, uint64(len(data))) if err != nil { - return guestBuf{}, fmt.Errorf("stackencrypt: guest alloc: %w", err) + return guestBuf{}, fmt.Errorf("%w: guest alloc: %w", errGuestTrap, err) } buf := guestBuf{ptr: uint32(res[0]), len: uint32(len(data))} if buf.ptr == 0 { @@ -255,13 +262,9 @@ func (inst *instance) call(ctx context.Context, fn api.Function, args ...arg) ([ bufs = append(bufs, staged) params = append(params, uint64(staged.ptr), uint64(staged.len)) } - res, err := fn.Call(ctx, params...) + out, err := inst.invoke(ctx, fn, params...) if err != nil { - return nil, fmt.Errorf("stackencrypt: guest call: %w", err) - } - out, cerr := packedResult(res[0]) - if cerr != nil { - return nil, cerr + return nil, err } bufs = append(bufs, out) view, ok := inst.module.Memory().Read(out.ptr, out.len) @@ -274,6 +277,16 @@ func (inst *instance) call(ctx context.Context, fn api.Function, args ...arg) ([ return result, nil } +// invoke calls one guest export and decodes its packed result. A guest +// that did not return is errGuestTrap. +func (inst *instance) invoke(ctx context.Context, fn api.Function, params ...uint64) (guestBuf, error) { + res, err := fn.Call(ctx, params...) + if err != nil { + return guestBuf{}, fmt.Errorf("%w: guest call: %w", errGuestTrap, err) + } + return packedResult(res[0]) +} + func wipe(b []byte) { for i := range b { b[i] = 0 diff --git a/languages/golang/stackencrypt/memory.go b/languages/golang/stackencrypt/memory.go index f3144ab6a..3c6aaaf87 100644 --- a/languages/golang/stackencrypt/memory.go +++ b/languages/golang/stackencrypt/memory.go @@ -3,6 +3,7 @@ package stackencrypt import ( "errors" "fmt" + "log/slog" "math" "sync" @@ -86,25 +87,21 @@ type backend interface { type memoryAllocator struct { policy lockPolicy - mu sync.Mutex - mem backend - // fallback is set when the memory is a heap slice rather than a + mu sync.Mutex + backing backend + // fallback is set when the backing is a heap slice rather than a // reservation: no lock is possible, growth may copy (and wipes what // it abandons). fallback bool - // err is the first refusal that left the guest holding unprotected - // memory — the reservation, the dump exclusion, a lock on a range that - // was kept — and never clears: a lock refused once is reported for - // the life of the instance. - err error - // refusals counts strict growths refused, and growthErr is the lock - // refusal behind the latest. Neither is err: a refused growth gives - // its range back before the guest sees it, so every byte the guest - // holds is still locked and the instance still reports so. Client.call - // compares refusals across a call to name the real cause when the - // guest reports only a failed allocation. - refusals uint64 - growthErr error + // lockErr is the first refusal that left the guest holding + // unprotected memory — the reservation, the dump exclusion, a lock on + // a range that was kept — and never clears: a lock refused once is + // reported for the life of the instance. + lockErr error + // growth is the strict growths refused. It is not lockErr: a refused + // growth gives its range back before the guest sees it, so every byte + // the guest holds is still locked and the instance still reports so. + growth growthRefusal // inFlight counts guest calls in progress (see enter and exit); // pending records a Free that arrived while one was, to be honoured // when the outermost call returns. @@ -116,6 +113,15 @@ type memoryAllocator struct { freed bool } +// growthRefusal is the strict growths an allocator has refused: how many, +// and the lock refusal behind the latest. Client.call compares the count +// across a call to name the real cause when the guest reports only a +// failed allocation. +type growthRefusal struct { + refused uint64 + reason error +} + func newMemoryAllocator(policy lockPolicy) *memoryAllocator { return &memoryAllocator{policy: policy} } @@ -124,31 +130,31 @@ func newMemoryAllocator(policy lockPolicy) *memoryAllocator { func (a *memoryAllocator) Allocate(capacity, max uint64) experimental.LinearMemory { a.mu.Lock() defer a.mu.Unlock() - if a.mem != nil { + if a.backing != nil { // The guest has one memory; a second would mean wazero's contract // changed under us. Refusing here fails instantiation loudly // rather than letting two memories share one report. panic("stackencrypt: guest memory allocated twice") } - mem, err := reserveMemory(capacity, max, a.policy) - a.mem = mem - a.err = err - _, a.fallback = mem.(*heapMemory) + backing, err := reserveMemory(capacity, max, a.policy) + a.backing = backing + a.lockErr = err + _, a.fallback = backing.(*heapMemory) return a } // Reallocate implements experimental.LinearMemory. func (a *memoryAllocator) Reallocate(size uint64) []byte { - buf, lockErr := a.mem.commit(size) + buf, lockErr := a.backing.commit(size) if lockErr != nil { a.mu.Lock() if buf == nil { // Strict: the range was given back, so nothing unlocked was // admitted and the lock report stands. - a.refusals++ - a.growthErr = lockErr - } else if a.err == nil { - a.err = lockErr + a.growth.refused++ + a.growth.reason = lockErr + } else if a.lockErr == nil { + a.lockErr = lockErr } a.mu.Unlock() } @@ -201,7 +207,7 @@ func (a *memoryAllocator) freeLocked() { return } a.freed = true - a.mem.free() + a.backing.free() } // lockError is nil while every committed byte is locked (and, on Linux, @@ -209,15 +215,32 @@ func (a *memoryAllocator) freeLocked() { func (a *memoryAllocator) lockError() error { a.mu.Lock() defer a.mu.Unlock() - return a.err + return a.lockErr } -// growthRefusals counts the strict growths refused so far, with the lock -// refusal behind the latest (nil while the count is zero). -func (a *memoryAllocator) growthRefusals() (uint64, error) { +// growthRefusal is the strict growths refused so far. +func (a *memoryAllocator) growthRefusal() growthRefusal { a.mu.Lock() defer a.mu.Unlock() - return a.refusals, a.growthErr + return a.growth +} + +// String is the memory's state for a log line: "locked", or the refusal. +// Nothing secret is printed. +func (a *memoryAllocator) String() string { + if err := a.lockError(); err != nil { + return fmt.Sprintf("unlocked: %v", err) + } + return "locked" +} + +// LogValue is the same state for slog: a group with memory_locked and, +// when false, memory_lock_error. +func (a *memoryAllocator) LogValue() slog.Value { + if err := a.lockError(); err != nil { + return slog.GroupValue(slog.Bool("memory_locked", false), slog.String("memory_lock_error", err.Error())) + } + return slog.GroupValue(slog.Bool("memory_locked", true)) } func (a *memoryAllocator) isFallback() bool { diff --git a/languages/golang/stackencrypt/memory_linux_test.go b/languages/golang/stackencrypt/memory_linux_test.go new file mode 100644 index 000000000..65f06e3a2 --- /dev/null +++ b/languages/golang/stackencrypt/memory_linux_test.go @@ -0,0 +1,131 @@ +package stackencrypt + +import ( + "bufio" + "context" + "fmt" + "os" + "strconv" + "strings" + "testing" +) + +// The protection is observable from the kernel's side, in +// /proc/self/smaps. The dump exclusion has no limit, so it is asserted on +// every mapping this package reserves; the lock only where the host +// granted it. + +// The probe's mapping, on a range committed by Reallocate after the +// mprotect split, not only on what Allocate set up. +func TestGuestMemoryIsLockedAndNotDumpable(t *testing.T) { + alloc := newMemoryAllocator(bestEffort) + base, grow, done := probeMemory(t, alloc) + defer done() + if _, ok := grow(2); !ok { + t.Fatal("grow refused") + } + assertMappingProtected(t, alloc, base()) +} + +// The real guest's mapping, once a host-staged buffer has made it grow. +func TestGuestMappingIsLockedAndNotDumpable(t *testing.T) { + c := rawInstance(t) + if err := stageLarge(context.Background(), c.inst); err != nil { + t.Fatal(err) + } + assertMappingProtected(t, c.inst.mem, memoryBase(t, c.inst.module.Memory())) +} + +// assertMappingProtected checks the smaps entry containing addr: dd +// (MADV_DONTDUMP) whenever there is a reservation at all, and, where the +// lock was granted, lo (mlock) with the whole resident range locked. A +// refused lock skips the second half after the first has run; a failed +// first half fails the test whatever the second does. +func assertMappingProtected(t *testing.T, alloc *memoryAllocator, addr uintptr) { + t.Helper() + if alloc.isFallback() { + t.Skipf("heap fallback in use on this host: %v", alloc.lockError()) + } + mapping, err := smapsEntry(addr) + if err != nil { + t.Fatal(err) + } + if !mapping.hasFlag("dd") { + t.Errorf("VmFlags = %q, want dd (MADV_DONTDUMP)", mapping.vmFlags) + } + lockOrSkip(t, alloc) + if !mapping.hasFlag("lo") { + t.Errorf("VmFlags = %q, want lo (mlock)", mapping.vmFlags) + } + if mapping.lockedKB == 0 || mapping.lockedKB != mapping.rssKB { + t.Errorf("Locked = %d kB, Rss = %d kB: the committed range is not fully locked", mapping.lockedKB, mapping.rssKB) + } +} + +type smapsMapping struct { + rssKB, lockedKB uint64 + vmFlags string +} + +func (m smapsMapping) hasFlag(flag string) bool { + return strings.Contains(" "+m.vmFlags+" ", " "+flag+" ") +} + +// smapsEntry finds the /proc/self/smaps mapping containing addr. +func smapsEntry(addr uintptr) (smapsMapping, error) { + f, err := os.Open("/proc/self/smaps") + if err != nil { + return smapsMapping{}, err + } + defer f.Close() + var cur smapsMapping + inside := false + sc := bufio.NewScanner(f) + for sc.Scan() { + line := sc.Text() + if lo, hi, ok := smapsRange(line); ok { + if inside { + return cur, nil + } + inside = addr >= lo && addr < hi + cur = smapsMapping{} + continue + } + if !inside { + continue + } + key, value, _ := strings.Cut(line, ":") + value = strings.TrimSpace(value) + switch key { + case "Rss": + cur.rssKB = smapsKB(value) + case "Locked": + cur.lockedKB = smapsKB(value) + case "VmFlags": + cur.vmFlags = value + } + } + if inside { + return cur, nil + } + return smapsMapping{}, fmt.Errorf("no smaps mapping contains %#x", addr) +} + +func smapsRange(line string) (lo, hi uintptr, ok bool) { + head, _, _ := strings.Cut(line, " ") + a, b, found := strings.Cut(head, "-") + if !found { + return 0, 0, false + } + l, err1 := strconv.ParseUint(a, 16, 64) + h, err2 := strconv.ParseUint(b, 16, 64) + if err1 != nil || err2 != nil { + return 0, 0, false + } + return uintptr(l), uintptr(h), true +} + +func smapsKB(value string) uint64 { + n, _ := strconv.ParseUint(strings.TrimSuffix(value, " kB"), 10, 64) + return n +} diff --git a/languages/golang/stackencrypt/memory_mapped.go b/languages/golang/stackencrypt/memory_mapped.go index 605b28b85..04f4f9fe2 100644 --- a/languages/golang/stackencrypt/memory_mapped.go +++ b/languages/golang/stackencrypt/memory_mapped.go @@ -49,6 +49,10 @@ func (m *mappedMemory) commit(size uint64) ([]byte, error) { return nil, nil } if err := lockRange(fresh); err != nil { + // The platform names the range it could not lock; the + // operator sizing a limit needs the whole of what the guest + // holds with it. + err = fmt.Errorf("%w; the guest needs at least %s locked", err, byteCount(size)) if m.policy == strict && m.committed > 0 { // Nothing was written to the range yet; giving it back // leaves the guest exactly where it was. diff --git a/languages/golang/stackencrypt/memory_test.go b/languages/golang/stackencrypt/memory_test.go index dce849aa2..a8aa6c7f0 100644 --- a/languages/golang/stackencrypt/memory_test.go +++ b/languages/golang/stackencrypt/memory_test.go @@ -1,7 +1,6 @@ package stackencrypt import ( - "bufio" "context" "errors" "fmt" @@ -10,7 +9,6 @@ import ( "os" "os/exec" "runtime" - "strconv" "strings" "testing" "time" @@ -57,14 +55,7 @@ func probeMemory(t *testing.T, alloc *memoryAllocator) (base func() uintptr, gro _ = rt.Close(ctx) t.Fatalf("instantiating grow probe: %v", err) } - base = func() uintptr { - // Read returns a view into the buffer, not a copy. - view, ok := mod.Memory().Read(0, 1) - if !ok { - t.Fatal("reading probe memory") - } - return uintptr(unsafe.Pointer(unsafe.SliceData(view))) - } + base = func() uintptr { return memoryBase(t, mod.Memory()) } grow = func(pages uint32) (uint32, bool) { res, err := mod.ExportedFunction("grow").Call(ctx, uint64(pages)) if err != nil { @@ -76,6 +67,28 @@ func probeMemory(t *testing.T, alloc *memoryAllocator) (base func() uintptr, gro return base, grow, done } +// memoryBase is the host address of a module memory's first byte. Read +// returns a view into the buffer, not a copy. +func memoryBase(t *testing.T, mem api.Memory) uintptr { + t.Helper() + view, ok := mem.Read(0, 1) + if !ok { + t.Fatal("reading guest memory") + } + return uintptr(unsafe.Pointer(unsafe.SliceData(view))) +} + +// stageLarge stages a buffer larger than the guest's initial memory, so +// the guest must grow, and frees it again. +func stageLarge(ctx context.Context, inst *instance) error { + staged, err := inst.allocWrite(ctx, make([]byte, 2<<20)) + if err != nil { + return err + } + inst.free(ctx, staged) + return nil +} + // The whole point of owning the allocation: growth commits more of one // reservation, so the buffer's address is the same before and after, and // the guest's keys are never copied to a new slice. @@ -100,48 +113,24 @@ func TestGuestMemoryDoesNotMoveOnGrowth(t *testing.T) { // The same property on the real guest: a host-staged buffer larger than // the guest's initial memory makes it grow, and its memory stays where it -// was. On Linux with the lock granted the guest's own mapping is then -// checked in smaps, as the probe's is below. +// was. The guest's own mapping is checked in smaps on Linux, in +// memory_linux_test.go. func TestGuestGrowsInPlace(t *testing.T) { c := rawInstance(t) - ctx := context.Background() mem := c.inst.module.Memory() - base := func() uintptr { - view, ok := mem.Read(0, 1) - if !ok { - t.Fatal("reading guest memory") - } - return uintptr(unsafe.Pointer(unsafe.SliceData(view))) - } if c.inst.mem.isFallback() { t.Skipf("heap fallback in use on this host: %v", c.inst.mem.lockError()) } - before, pagesBefore := base(), mem.Size()/wasmPage - staged, err := c.inst.allocWrite(ctx, make([]byte, 2<<20)) - if err != nil { + before, pagesBefore := memoryBase(t, mem), mem.Size()/wasmPage + if err := stageLarge(context.Background(), c.inst); err != nil { t.Fatal(err) } - defer c.inst.free(ctx, staged) - if after := base(); after != before { + if after := memoryBase(t, mem); after != before { t.Fatalf("guest memory moved on growth: %#x -> %#x", before, after) } if pagesAfter := mem.Size() / wasmPage; pagesAfter <= pagesBefore { t.Fatalf("guest memory did not grow: %d pages before, %d after", pagesBefore, pagesAfter) } - if runtime.GOOS != "linux" { - return - } - lockOrSkip(t, c.inst.mem) - mapping, err := smapsEntry(base()) - if err != nil { - t.Fatal(err) - } - if !strings.Contains(" "+mapping.vmFlags+" ", " dd ") || !strings.Contains(" "+mapping.vmFlags+" ", " lo ") { - t.Errorf("guest mapping VmFlags = %q, want dd and lo", mapping.vmFlags) - } - if mapping.lockedKB == 0 || mapping.lockedKB != mapping.rssKB { - t.Errorf("guest mapping Locked = %d kB, Rss = %d kB", mapping.lockedKB, mapping.rssKB) - } } // Free wipes then unmaps: the allocator reports the release, and the @@ -210,105 +199,18 @@ func lockOrSkip(t *testing.T, alloc *memoryAllocator) { // host), which CI exercises on purpose under GOARCH=386. t.Skipf("heap fallback in use on this host: %v", err) } - if os.Getenv(requireLock) != "" { - t.Fatalf("%s is set and the lock was refused: %v", requireLock, err) - } - t.Skipf("lock refused on this host: %v", err) + skipUnlessLockRequired(t, "the lock was refused", err) } -// The lock is observable from the kernel's side: the mapping backing the -// probe shows as locked and, on Linux, non-dumpable, in /proc/self/smaps. -func TestGuestMemoryIsLockedAndNotDumpable(t *testing.T) { - if runtime.GOOS != "linux" { - t.Skip("smaps is Linux") - } - alloc := newMemoryAllocator(bestEffort) - base, grow, done := probeMemory(t, alloc) - defer done() - // Grow past the initial commit so the flags are checked on a range - // committed by Reallocate, after the mprotect split, not only on what - // Allocate set up. - if _, ok := grow(2); !ok { - t.Fatal("grow refused") - } - lockOrSkip(t, alloc) - mapping, err := smapsEntry(base()) - if err != nil { - t.Fatal(err) - } - if !strings.Contains(" "+mapping.vmFlags+" ", " dd ") { - t.Errorf("VmFlags = %q, want dd (MADV_DONTDUMP)", mapping.vmFlags) - } - if !strings.Contains(" "+mapping.vmFlags+" ", " lo ") { - t.Errorf("VmFlags = %q, want lo (mlock)", mapping.vmFlags) - } - if mapping.lockedKB == 0 || mapping.lockedKB != mapping.rssKB { - t.Errorf("Locked = %d kB, Rss = %d kB: the committed range is not fully locked", mapping.lockedKB, mapping.rssKB) - } -} - -type smapsMapping struct { - rssKB, lockedKB uint64 - vmFlags string -} - -// smapsEntry finds the /proc/self/smaps mapping containing addr. -func smapsEntry(addr uintptr) (smapsMapping, error) { - f, err := os.Open("/proc/self/smaps") - if err != nil { - return smapsMapping{}, err - } - defer f.Close() - var cur smapsMapping - inside := false - sc := bufio.NewScanner(f) - for sc.Scan() { - line := sc.Text() - if lo, hi, ok := smapsRange(line); ok { - if inside { - return cur, nil - } - inside = addr >= lo && addr < hi - cur = smapsMapping{} - continue - } - if !inside { - continue - } - key, value, _ := strings.Cut(line, ":") - value = strings.TrimSpace(value) - switch key { - case "Rss": - cur.rssKB = smapsKB(value) - case "Locked": - cur.lockedKB = smapsKB(value) - case "VmFlags": - cur.vmFlags = value - } - } - if inside { - return cur, nil +// skipUnlessLockRequired skips a test the host cannot run — what says why, +// detail is the refusal or the child's output — unless requireLock says the +// host was meant to, in which case it fails. +func skipUnlessLockRequired(t *testing.T, what string, detail any) { + t.Helper() + if os.Getenv(requireLock) != "" { + t.Fatalf("%s is set and %s:\n%v", requireLock, what, detail) } - return smapsMapping{}, fmt.Errorf("no smaps mapping contains %#x", addr) -} - -func smapsRange(line string) (lo, hi uintptr, ok bool) { - head, _, _ := strings.Cut(line, " ") - a, b, found := strings.Cut(head, "-") - if !found { - return 0, 0, false - } - l, err1 := strconv.ParseUint(a, 16, 64) - h, err2 := strconv.ParseUint(b, 16, 64) - if err1 != nil || err2 != nil { - return 0, 0, false - } - return uintptr(l), uintptr(h), true -} - -func smapsKB(value string) uint64 { - n, _ := strconv.ParseUint(strings.TrimSuffix(value, " kB"), 10, 64) - return n + t.Skipf("%s on this host:\n%v", what, detail) } // inChild re-runs the calling test in a child process, for tests that @@ -330,10 +232,7 @@ func inChild(t *testing.T) bool { out, err := cmd.CombinedOutput() switch { case strings.Contains(string(out), "case skipped:"): - if os.Getenv(requireLock) != "" { - t.Fatalf("%s is set and the refusal could not be provoked:\n%s", requireLock, out) - } - t.Skipf("refusal could not be provoked on this host:\n%s", out) + skipUnlessLockRequired(t, "the refusal could not be provoked", string(out)) case err != nil || !strings.Contains(string(out), "case ok"): t.Fatalf("child failed: %v\n%s", err, out) } @@ -382,7 +281,7 @@ func TestRequireLockedMemoryRefusesAnUnlockableGuest(t *testing.T) { t.Fatal(err) } c := newClient(inst, nil) - defer c.Close() + t.Cleanup(func() { _ = c.Close() }) if c.MemoryLocked() { t.Fatal("best-effort client reports locked memory under a refused lock") } @@ -430,12 +329,12 @@ func TestRequireLockedMemoryRefusesAnUnlockableGrowth(t *testing.T) { if err := alloc.lockError(); err != nil { t.Fatalf("a refused growth changed the lock report: %v", err) } - n, gerr := alloc.growthRefusals() - if n != 1 || gerr == nil { - t.Fatalf("growthRefusals = %d, %v; want 1 and the refusal", n, gerr) + g := alloc.growthRefusal() + if g.refused != 1 || g.reason == nil { + t.Fatalf("growthRefusal = %+v; want one, with the refusal", g) } - if !strings.Contains(gerr.Error(), "RLIMIT_MEMLOCK") { - t.Fatalf("the refusal does not name the limit: %v", gerr) + if !strings.Contains(g.reason.Error(), "RLIMIT_MEMLOCK") || !strings.Contains(g.reason.Error(), "needs at least") { + t.Fatalf("the refusal does not name the limit and the size held: %v", g.reason) } fmt.Println("case ok") } @@ -461,6 +360,21 @@ func (b *refusingBackend) commit(size uint64) ([]byte, error) { return b.backend.commit(size) } +// refuseGrowth puts a refusingBackend in front of alloc's backing, set to +// refuse any growth past what is committed now. Called on the guest's +// goroutine, between calls, as the backing is. +func refuseGrowth(alloc *memoryAllocator) *refusingBackend { + refusing := &refusingBackend{backend: alloc.backing, past: alloc.backing.(sized).size(), reason: errors.New("refused for the test"), refuse: true} + alloc.backing = refusing + return refusing +} + +// sized is what the tests need of a backing to know where it stands. +type sized interface{ size() uint64 } + +func (m *mappedMemory) size() uint64 { return m.committed } +func (m *heapMemory) size() uint64 { return uint64(len(m.buf)) } + // A refused growth is the growth's failure, not the memory's: the // allocator counts it and keeps its reason, and the lock report — nil, // or whatever this host refused at the start — is exactly what it was. @@ -470,8 +384,7 @@ func TestRefusedGrowthLeavesTheLockReportAlone(t *testing.T) { base, grow, done := probeMemory(t, alloc) defer done() before := alloc.lockError() - refusing := &refusingBackend{backend: alloc.mem, past: uint64(alloc.mem.(sized).size()), reason: errors.New("refused for the test"), refuse: true} - alloc.mem = refusing + refusing := refuseGrowth(alloc) at := base() if _, ok := grow(1); ok { t.Fatal("the refused growth was granted") @@ -479,8 +392,8 @@ func TestRefusedGrowthLeavesTheLockReportAlone(t *testing.T) { if after := alloc.lockError(); after != before { t.Fatalf("the refused growth changed the lock report: %v -> %v", before, after) } - if n, gerr := alloc.growthRefusals(); n != 1 || gerr != refusing.reason { - t.Fatalf("growthRefusals = %d, %v; want 1 and the refusal", n, gerr) + if g := alloc.growthRefusal(); g.refused != 1 || g.reason != refusing.reason { + t.Fatalf("growthRefusal = %+v; want one, with the refusal", g) } refusing.refuse = false if _, ok := grow(1); !ok { @@ -489,53 +402,43 @@ func TestRefusedGrowthLeavesTheLockReportAlone(t *testing.T) { if after := alloc.lockError(); after != before { t.Fatalf("a later growth changed the lock report: %v -> %v", before, after) } - if n, _ := alloc.growthRefusals(); n != 1 { - t.Fatalf("growthRefusals = %d after a granted growth, want 1", n) + if g := alloc.growthRefusal(); g.refused != 1 { + t.Fatalf("growthRefusal = %+v after a granted growth, want one", g) } if base() != at { t.Fatal("memory moved across the refused growth") } } -// sized is what the test needs of a backend to know where it stands. -type sized interface{ size() uint64 } - -func (m *mappedMemory) size() uint64 { return m.committed } -func (m *heapMemory) size() uint64 { return uint64(len(m.buf)) } - -// The same, through the Client: the call that needed the growth fails -// with ErrMemoryLock naming the refusal, the client is still open, and -// it still reports locked memory everywhere it is asked — the method, -// the error, the print and the log. -func TestRequireLockedMemoryFailsTheCallThatCannotGrow(t *testing.T) { - ctx := context.Background() - inst, err := newInstance(ctx, guestOrSkip(t), &transport{rt: http.DefaultTransport, token: StaticToken("t")}, strict) +// strictClient is a Client over the real guest under the strict policy, +// or a skip where this host refuses the lock. +func strictClient(t *testing.T) *Client { + t.Helper() + inst, err := newInstance(context.Background(), guestOrSkip(t), &transport{rt: http.DefaultTransport, token: StaticToken("t")}, strict) if errors.Is(err, ErrMemoryLock) { - if os.Getenv(requireLock) != "" { - t.Fatalf("%s is set and the lock was refused: %v", requireLock, err) - } - t.Skipf("lock refused on this host: %v", err) + skipUnlessLockRequired(t, "the lock was refused", err) } if err != nil { t.Fatal(err) } c := newClient(inst, nil) - defer c.Close() + t.Cleanup(func() { _ = c.Close() }) if !c.MemoryLocked() { t.Fatalf("a strict client reports unlocked memory: %v", c.MemoryLockError()) } - refusing := &refusingBackend{backend: inst.mem.mem, past: inst.mem.mem.(sized).size(), reason: errors.New("refused for the test"), refuse: true} - inst.mem.mem = refusing - // Larger than the guest's initial memory, so the guest must grow. - stage := func(inst *instance) ([]byte, error) { - staged, err := inst.allocWrite(ctx, make([]byte, 2<<20)) - if err != nil { - return nil, err - } - inst.free(ctx, staged) - return nil, nil - } - _, err = c.call(ctx, stage) + return c +} + +// The same, through the Client: the call that needed the growth for a +// host-staged buffer fails with ErrMemoryLock naming the refusal, the +// client is still open, and it still reports locked memory everywhere it +// is asked — the method, the error, the print and the log. +func TestRequireLockedMemoryFailsTheCallThatCannotGrow(t *testing.T) { + ctx := context.Background() + c := strictClient(t) + refusing := refuseGrowth(c.inst.mem) + stage := func(inst *instance) ([]byte, error) { return nil, stageLarge(ctx, inst) } + _, err := c.call(ctx, stage) if !errors.Is(err, ErrMemoryLock) || !strings.Contains(err.Error(), "growth refused") || !strings.Contains(err.Error(), refusing.reason.Error()) { t.Fatalf("call needing a refused growth: %v; want ErrMemoryLock naming the refusal", err) } @@ -564,6 +467,54 @@ func TestRequireLockedMemoryFailsTheCallThatCannotGrow(t *testing.T) { } } +// A growth the guest needs for an allocation of its own is refused the +// same way, but the guest cannot report it: its allocator aborts, the trap +// closes the module, and the client is closed with it, its keys wiped. +// The call still fails with ErrMemoryLock naming the refusal, and the +// client is ErrState from then on. Provoked by staging a config the guest +// must copy while decoding, with the refusal installed after the staging. +func TestRequireLockedMemoryClosesTheClientOnARefusedInternalGrowth(t *testing.T) { + ctx := context.Background() + c := strictClient(t) + cfg := Config{ClientID: strings.Repeat("a", 2<<20), ClientKey: "00", Token: StaticToken("t")} + encoded, err := encodeConfig(cfg) + if err != nil { + t.Fatal(err) + } + var refusing *refusingBackend + _, err = c.call(ctx, func(inst *instance) ([]byte, error) { + staged, err := inst.allocWrite(ctx, encoded) + if err != nil { + return nil, err + } + defer inst.free(ctx, staged) + refusing = refuseGrowth(inst.mem) + _, err = inst.invoke(ctx, inst.cipherInit, uint64(staged.ptr), uint64(staged.len)) + return nil, err + }) + if !errors.Is(err, ErrMemoryLock) || !strings.Contains(err.Error(), "growth refused") { + t.Fatalf("init needing a refused internal growth: %v; want ErrMemoryLock naming the refusal", err) + } + if refusing.refused == 0 { + t.Fatal("the guest did not grow; the test proves nothing") + } + if !c.inst.module.IsClosed() || !strings.Contains(err.Error(), "the client is closed") { + t.Fatalf("the guest's abort did not close the client: %v", err) + } + if !c.inst.mem.isFreed() { + t.Fatal("the closed client's memory was not wiped and freed") + } + if _, err := c.call(ctx, func(*instance) ([]byte, error) { return nil, nil }); !errors.Is(err, ErrState) { + t.Fatalf("a call after the abort: %v, want ErrState", err) + } + if !c.MemoryLocked() { + t.Fatalf("a refused growth unlocked the report: %v", c.MemoryLockError()) + } + if err := c.Close(); err != nil { + t.Fatalf("Close after the abort: %v", err) + } +} + // reentrantProbe is a hand-assembled module reproducing the shape of the // guest's transport import: "run" calls the host function h, then stores // to memory. h re-enters the guest (as transport_send does through diff --git a/languages/golang/stackencrypt/memory_unix_test.go b/languages/golang/stackencrypt/memory_unix_test.go index e4216c2cb..9a4a527b4 100644 --- a/languages/golang/stackencrypt/memory_unix_test.go +++ b/languages/golang/stackencrypt/memory_unix_test.go @@ -7,5 +7,12 @@ import "golang.org/x/sys/unix" // setMemlockLimit lowers RLIMIT_MEMLOCK to n bytes for this process. Only // a child test process calls it. func setMemlockLimit(n uint64) error { - return unix.Setrlimit(unix.RLIMIT_MEMLOCK, &unix.Rlimit{Cur: n, Max: n}) + var lim unix.Rlimit + setRlim(&lim.Cur, n) + setRlim(&lim.Max, n) + return unix.Setrlimit(unix.RLIMIT_MEMLOCK, &lim) } + +// setRlim assigns a limit whatever width the platform gives the field +// (unsigned on Linux and Darwin, signed on the BSDs). +func setRlim[T ~int64 | ~uint64](field *T, n uint64) { *field = T(n) } diff --git a/packages/stack-encrypt/CONTEXT.md b/packages/stack-encrypt/CONTEXT.md index 5c590e95e..cff1f1e77 100644 --- a/packages/stack-encrypt/CONTEXT.md +++ b/packages/stack-encrypt/CONTEXT.md @@ -153,3 +153,46 @@ handle's point of view. Handing it a leaf sealed under one is `Error::ForeignKeyset`, refused before any key is retrieved — the guarantee a tenant-scoped handler asked for by taking a handle. _Avoid_: wrong keyset, other tenant + +## Guest memory (Go host) + +**Reservation**: +The guest's whole linear memory, address space of the module's declared +maximum taken once (`mmap PROT_NONE`, `VirtualAlloc MEM_RESERVE`) so the +memory never moves. Growth commits more of it from the front. +_Avoid_: buffer (that is wazero's view of the committed part), allocation + +**Commit**: +Making a range of the reservation readable and writable as the guest grows, +and locking it. A commit is what a lock is granted or refused on. +_Avoid_: grow (that is the guest's request; the commit is the host's answer) + +**Lock**: +Pinning committed memory in RAM (`mlock`, `VirtualLock`) so it is never +written to swap, and on Linux excluding the reservation from core dumps +(`MADV_DONTDUMP`). "Locked", of a client, means both held. +_Avoid_: pinned, wired + +**Lock policy**: +What a refused lock means for a client. *Best effort*, the default: the +refusal is recorded and reported (`MemoryLocked`, `MemoryLockError`) and +the client works on with memory that may be swapped. *Strict* +(`RequireLockedMemory`): `NewClient` fails with `ErrMemoryLock`, and so +does any later call whose growth cannot be locked. +_Avoid_: mode, hard/soft + +**Growth refusal**: +Under the strict policy, a commit whose lock was refused and was therefore +given back before the guest saw it. It fails the call that needed it and +leaves the client's lock report unchanged, since nothing unlocked was +admitted. A refusal of the guest's own allocation aborts the guest and +closes the client. +_Avoid_: lock failure (that is the report of memory admitted unlocked) + +**Heap fallback**: +A Go slice standing in for a reservation where none can be made: a +platform with no primitive this package uses, or a 32-bit host asked for +wasm's 4 GiB default. It still wipes on growth and release; it cannot be +locked, and the client reports so. +_Avoid_: default allocator (wazero's, which is never used), unlocked mode + From 4e198ec73876c433d6b863e2d0dd12208cc77e7a Mon Sep 17 00:00:00 2001 From: Dan Draper <dan@cipherstash.com> Date: Sun, 20 Sep 2026 16:30:46 -0700 Subject: [PATCH 603/686] fix(stack-encrypt): the example's Close takes no context --- languages/golang/stackencrypt/example/main.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/languages/golang/stackencrypt/example/main.go b/languages/golang/stackencrypt/example/main.go index f18f43359..ee8c2206b 100644 --- a/languages/golang/stackencrypt/example/main.go +++ b/languages/golang/stackencrypt/example/main.go @@ -57,10 +57,10 @@ func run() error { if err != nil { return fmt.Errorf("connecting to ZeroKMS: %w", err) } - // Close wipes the client key and every loaded index key inside the - // wasm instance. It is the point of the method, so it runs even on the - // error paths below. - defer func() { _ = client.Close(context.Background()) }() + // Close runs the guest's own wipe of the client key and every loaded + // index key. The memory's protection does not wait on it (see the + // package docs); this is ordinary resource hygiene. + defer client.Close() cipher := client.DefaultKeyset() keysetID, err := cipher.KeysetID(ctx) From d1d594fc96428098c498feda94beeeb7c4ca953a Mon Sep 17 00:00:00 2001 From: Dan Draper <dan@cipherstash.com> Date: Sun, 20 Sep 2026 16:38:56 -0700 Subject: [PATCH 604/686] fix(stack-encrypt): the strict-growth tests skip on a host with no reservation CI's GOARCH=386 pass has no 4 GiB reservation, so the heap fallback is in use and no lock is possible; under STACKENCRYPT_TESTS_REQUIRE_LOCK the new tests read that as a refused lock and failed. A shared probe now tells the fallback from a refusal: the strict-client tests and the child growth test skip on it, as lockOrSkip already did, and the no-move assertion holds only for a reservation, since the fallback may copy on growth and says so. --- languages/golang/stackencrypt/memory_test.go | 27 ++++++++++++++++---- 1 file changed, 22 insertions(+), 5 deletions(-) diff --git a/languages/golang/stackencrypt/memory_test.go b/languages/golang/stackencrypt/memory_test.go index a8aa6c7f0..cabbfba0f 100644 --- a/languages/golang/stackencrypt/memory_test.go +++ b/languages/golang/stackencrypt/memory_test.go @@ -78,6 +78,19 @@ func memoryBase(t *testing.T, mem api.Memory) uintptr { return uintptr(unsafe.Pointer(unsafe.SliceData(view))) } +// hostReserves reports whether this host can back the guest with a +// reservation at all. Where it cannot (a 32-bit host asked for wasm's +// 4 GiB default, which CI exercises on purpose under GOARCH=386) the heap +// fallback is in use, no lock is possible, and the tests of a lock granted +// or refused have nothing to test: they skip, whatever requireLock says. +func hostReserves(t *testing.T) bool { + t.Helper() + probe := newMemoryAllocator(bestEffort) + _, _, done := probeMemory(t, probe) + done() + return !probe.isFallback() +} + // stageLarge stages a buffer larger than the guest's initial memory, so // the guest must grow, and frees it again. func stageLarge(ctx context.Context, inst *instance) error { @@ -304,6 +317,9 @@ func TestRequireLockedMemoryRefusesAnUnlockableGuest(t *testing.T) { // probe holds is still locked and the allocator still says so; the // refusal is reported on its own, naming the limit. func TestRequireLockedMemoryRefusesAnUnlockableGrowth(t *testing.T) { + if !hostReserves(t) { + t.Skip("heap fallback in use on this host: no reservation to lock") + } if !inChild(t) { return } @@ -314,10 +330,6 @@ func TestRequireLockedMemoryRefusesAnUnlockableGrowth(t *testing.T) { alloc := newMemoryAllocator(strict) _, grow, done := probeMemory(t, alloc) defer done() - if alloc.isFallback() { - fmt.Printf("case skipped: heap fallback in use on this host: %v\n", alloc.lockError()) - return - } if err := alloc.lockError(); err != nil { fmt.Printf("case skipped: the first page did not lock under RLIMIT_MEMLOCK=%d: %v\n", limit, err) return @@ -405,7 +417,9 @@ func TestRefusedGrowthLeavesTheLockReportAlone(t *testing.T) { if g := alloc.growthRefusal(); g.refused != 1 { t.Fatalf("growthRefusal = %+v after a granted growth, want one", g) } - if base() != at { + // The heap fallback may copy on growth, and says so; a reservation + // never does. + if !alloc.isFallback() && base() != at { t.Fatal("memory moved across the refused growth") } } @@ -414,6 +428,9 @@ func TestRefusedGrowthLeavesTheLockReportAlone(t *testing.T) { // or a skip where this host refuses the lock. func strictClient(t *testing.T) *Client { t.Helper() + if !hostReserves(t) { + t.Skip("heap fallback in use on this host: a strict client cannot exist") + } inst, err := newInstance(context.Background(), guestOrSkip(t), &transport{rt: http.DefaultTransport, token: StaticToken("t")}, strict) if errors.Is(err, ErrMemoryLock) { skipUnlessLockRequired(t, "the lock was refused", err) From ba16144aba2750dfe0f6c1a47f22e82337c94bcb Mon Sep 17 00:00:00 2001 From: Dan Draper <dan@cipherstash.com> Date: Sun, 20 Sep 2026 18:10:38 -0700 Subject: [PATCH 605/686] docs(stack-encrypt): ADR-0005 matches the transport trait as landed, and says when the refresher's wasm32 arm is rewritten Review follow-ups on cipherstash/cipherstash-suite#2238. Decision 6 said the transport trait used the crate's async-trait convention; cipherstash/cipherstash-suite#2239 returns `impl Future`, which is not object-safe, and boxes it once in a crate-internal adapter. Decision 2 said the refresher's wasm32 arm documents that the host holds the lock; today it only compiles the lock out, and the auth half (CIP-4054) rewrites it. --- ...redential-guest-for-the-profile-and-auth.md | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/packages/stack-encrypt/docs/adr/0005-a-separate-credential-guest-for-the-profile-and-auth.md b/packages/stack-encrypt/docs/adr/0005-a-separate-credential-guest-for-the-profile-and-auth.md index 81fcf1fd7..3a9dca715 100644 --- a/packages/stack-encrypt/docs/adr/0005-a-separate-credential-guest-for-the-profile-and-auth.md +++ b/packages/stack-encrypt/docs/adr/0005-a-separate-credential-guest-for-the-profile-and-auth.md @@ -85,10 +85,12 @@ call time, after acquisition, by construction. So the guest has no lock calls at all. Go takes the same lock the Rust CLI takes — `flock(LOCK_EX)` on Unix, `LockFileEx` with the exclusive flag over offset zero and a length of all ones on Windows, on the sibling lock file the -guest names — around the device-session refresh export, and nowhere else. The -refresher's wasm32 arm documents that the host holds it. No lock state crosses -the ABI, no guest code path can forget to release, and the import surface -stays filesystem plus transport. Go never spells a profile path: the lock +guest names — around the device-session refresh export, and nowhere else. +Today the refresher's wasm32 arm only compiles the lock out and says nothing +about who holds it; the auth half (CIP-4054) rewrites that arm to document +that the host does, when the strategies move into the guest. No lock state +crosses the ABI, no guest code path can forget to release, and the import +surface stays filesystem plus transport. Go never spells a profile path: the lock file's path comes from a `stack-profile` accessor exposed through the guest. Go acquires with a try-lock and backoff under the caller's context, where the @@ -168,8 +170,12 @@ closes CIP-4053 and unblocks the env-plus-profile part of CIP-4052. Then `stack-auth` gains a transport trait mirroring the host import exactly — method, URL, headers and body in, status, headers and body out, bytes, no streaming — with reqwest as one implementation behind the `http` feature and -the guest's import as the other, using the crate's existing async-trait -convention. Then the strategies run inside the guest: access key, device +the guest's import as the other (CIP-4116). The trait returns `impl Future`, +the crate's convention for async traits, so it is not object-safe; a +crate-internal adapter boxes the future once at construction, so no public +strategy type grows a type parameter and the concrete transport is never +named again after the builder's `.transport(..)`. Then the strategies run +inside the guest: access key, device session, OIDC federation with a Go callback for the identity-provider token, and auto. `AutoStrategy`'s detection order runs in Go against the environment Go already owns, pinned against the Rust order by a test; the guest stays From 64345b4dbcb345863ca7e168aa7a7915120f2b3d Mon Sep 17 00:00:00 2001 From: Dan Draper <dan@cipherstash.com> Date: Sun, 20 Sep 2026 18:18:57 -0700 Subject: [PATCH 606/686] ci(go): the version pin read fails by name when empty, and the comment says how these jobs gate Review follow-ups on cipherstash/cipherstash-suite#2240. The sed that reads `go = "..."` from mise.toml gave setup-go an empty version if that line changed shape; now it fails with a message that says so. The job comment claimed wasi-check is a required check and asked for these jobs to join it; main's ruleset requires no status check, and a path-filtered required check would block any PR outside the filter, so the comment now says that and how to pair one if that ever changes. --- .github/imported-workflows/test-wasi.yml | 22 +++++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/.github/imported-workflows/test-wasi.yml b/.github/imported-workflows/test-wasi.yml index f7220ddba..f2172c914 100644 --- a/.github/imported-workflows/test-wasi.yml +++ b/.github/imported-workflows/test-wasi.yml @@ -131,9 +131,13 @@ jobs: # credential guest (ADR-0005, CIP-4054) puts the cross-process refresh # lock on the Go side with a Unix and a Windows implementation, and a Go # developer sharing a profile with the CLI is the scenario the lock exists - # for, so those tests need somewhere to run before they are written. These - # jobs are blocking, the same as wasi-check: add them to the branch - # protection's required checks alongside it. Nothing Rust runs here — Go + # for, so those tests need somewhere to run before they are written. They + # gate the way wasi-check does: a red run on the PR, not branch protection. + # main's ruleset requires no status check today, and this workflow is path + # filtered, so a required check here would never run on a PR outside the + # filter and block it forever. If either job is ever made required, pair it + # with a same-named job in a workflow triggered on the complementary paths + # that always succeeds, as GitHub documents. Nothing Rust runs here — Go # plus the artifact is the whole toolchain, which is what keeps them quick. go-binding-cross: name: Go binding (${{ matrix.os }}) @@ -153,10 +157,18 @@ jobs: - uses: actions/checkout@v6 # One source of truth for the Go version: the mise pin the Linux job - # runs under. + # runs under. The sed matches one shape of that line; if the pin is + # ever written another way, fail here by name rather than hand + # setup-go an empty version. - name: Go version from mise.toml id: go - run: echo "version=$(sed -n 's/^go = "\(.*\)"/\1/p' mise.toml)" >> "$GITHUB_OUTPUT" + run: | + version=$(sed -n 's/^go = "\(.*\)"/\1/p' mise.toml) + if [ -z "$version" ]; then + echo 'no `go = "..."` line in mise.toml; the Go version pin has changed shape' >&2 + exit 1 + fi + echo "version=$version" >> "$GITHUB_OUTPUT" - uses: actions/setup-go@v5 with: From b31db96cad596fee1a23139ed46735fe66c3920b Mon Sep 17 00:00:00 2001 From: Dan Draper <dan@cipherstash.com> Date: Sun, 20 Sep 2026 18:24:07 -0700 Subject: [PATCH 607/686] fix(stack-encrypt): instantiation is bracketed by enter and exit like a call Review follow-up on cipherstash/cipherstash-suite#2237. wazero runs _initialize during instantiation, so guest code runs outside the enter/exit bracket that keeps memory mapped across a call. The window is not reachable today: wazero's context watcher only marks the module closed and defers the free to the next Go-to-wasm call, and nothing in _initialize re-enters the guest from Go. The bracket makes that a property of this code rather than of what the guest's constructors call. --- languages/golang/stackencrypt/guest.go | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/languages/golang/stackencrypt/guest.go b/languages/golang/stackencrypt/guest.go index 22d601de1..53e26b504 100644 --- a/languages/golang/stackencrypt/guest.go +++ b/languages/golang/stackencrypt/guest.go @@ -123,8 +123,17 @@ func newInstance(ctx context.Context, wasm []byte, t *transport, policy lockPoli // platform allows, wiped on release. See memory.go. mem := newMemoryAllocator(policy) // The guest is a reactor (cdylib): no _start. wazero runs _initialize - // when present. - module, err := runtime.InstantiateWithConfig(experimental.WithMemoryAllocator(ctx, mem), wasm, guestModuleConfig()) + // when present, so guest code runs here too, and the memory must stay + // mapped until it returns, the same as around a call. Today nothing in + // _initialize re-enters the guest from Go, which is the only path that + // frees memory under a suspended guest; the bracket makes that a + // property of this code rather than of what the guest's constructors + // happen to call. See memoryAllocator.Free. + mem.enter() + module, err := func() (api.Module, error) { + defer mem.exit() + return runtime.InstantiateWithConfig(experimental.WithMemoryAllocator(ctx, mem), wasm, guestModuleConfig()) + }() if err != nil { _ = runtime.Close(ctx) if g := mem.growthRefusal(); g.refused != 0 { From 5218276ac3f56c8b49814ba104260cb401304c69 Mon Sep 17 00:00:00 2001 From: Dan Draper <dan@cipherstash.com> Date: Sun, 20 Sep 2026 18:39:02 -0700 Subject: [PATCH 608/686] test(stack-encrypt): the host-clock check tolerates Windows timer skew The first Windows run of the Go suite failed with the guest's monotonic clock advancing 19.82ms across a 20ms sleep. The sleep timer and the monotonic source are different clocks there, and a sleep can return a fraction of a millisecond early. The test exists to tell the host clock from wazero's fake one, which advances exactly 1ms per read, so a floor of half the sleep keeps an order of magnitude between the two answers. --- languages/golang/stackencrypt/runtime_test.go | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/languages/golang/stackencrypt/runtime_test.go b/languages/golang/stackencrypt/runtime_test.go index 639013026..1abdb3905 100644 --- a/languages/golang/stackencrypt/runtime_test.go +++ b/languages/golang/stackencrypt/runtime_test.go @@ -109,11 +109,17 @@ func TestGuestModuleConfigHostSources(t *testing.T) { } } - // The fake clock advances 1ms per read regardless of elapsed time. + // The fake clock advances 1ms per read regardless of elapsed time, so + // two reads around a sleep are 1ms apart on it and the whole sleep + // apart on the host's. The floor is half the sleep, not all of it: on + // Windows the sleep timer and the monotonic source are different + // clocks, and a sleep can return a fraction of a millisecond before + // the monotonic clock says the interval has passed. Half still leaves + // an order of magnitude between the two answers. const sleep = 20 * time.Millisecond before := monotonicA() time.Sleep(sleep) - if elapsed := monotonicA() - before; elapsed < sleep { + if elapsed := monotonicA() - before; elapsed < sleep/2 { t.Fatalf("monotonic clock advanced %v across a %v sleep: not the host clock", elapsed, sleep) } } From 6894ab5e71df45f49844072fd8dfb6da831734d4 Mon Sep 17 00:00:00 2001 From: Toby Hede <toby@cipherstash.com> Date: Mon, 21 Sep 2026 17:29:24 +1000 Subject: [PATCH 609/686] chore(deps): bump Rust toolchain to 1.94.1 The AWS SDK raised its MSRV to 1.94.1 as of aws-sdk-kms 1.112.0, aws-config 1.9.0 and aws-smithy-runtime 1.12.0. The workspace is pinned to 1.90, so any resolve that reaches those versions fails to build. This blocks CIP-4101: dropping the legacy rustls stack (rustls-webpki 0.101.7, unpatched for GHSA-82j2-j2ch-gfr8, GHSA-xgp8-3hg3-c2mh and GHSA-965h-392x-2mh5) requires envelopers 0.8.3, which floors aws-sdk-kms at 1.114.0. Bumps the mise pin, the four hardcoded dtolnay/rust-toolchain pins that do not go through .github/actions/setup-rust, and the rust base image in both Dockerfiles. Everything else derives its toolchain from mise via rustup show active-toolchain. 1.94.1 is the exact MSRV required rather than current stable, to keep new-lint surface minimal against the repo-wide RUSTFLAGS=-D warnings. No published crate is affected: the transitive reverse closure from aws-sdk-kms in Cargo.lock reaches only cts-domain, cts-web, vitur-server-core and envelopers, all publish = false. --- .github/imported-workflows/publish-auth-npm.yml | 4 ++-- .github/imported-workflows/publish-profile-npm.yml | 2 +- .github/imported-workflows/test-stack-auth.yml | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/imported-workflows/publish-auth-npm.yml b/.github/imported-workflows/publish-auth-npm.yml index fa184c23d..db68a5d75 100644 --- a/.github/imported-workflows/publish-auth-npm.yml +++ b/.github/imported-workflows/publish-auth-npm.yml @@ -127,7 +127,7 @@ jobs: Add-MpPreference -ExclusionPath "${{ github.workspace }}\${{ env.WORKING_DIR }}\target" - name: Setup Rust - uses: dtolnay/rust-toolchain@1.90.0 + uses: dtolnay/rust-toolchain@1.94.1 with: targets: ${{ matrix.target }} @@ -173,7 +173,7 @@ jobs: - uses: actions/checkout@v6 - name: Setup Rust - uses: dtolnay/rust-toolchain@1.90.0 + uses: dtolnay/rust-toolchain@1.94.1 with: targets: wasm32-unknown-unknown diff --git a/.github/imported-workflows/publish-profile-npm.yml b/.github/imported-workflows/publish-profile-npm.yml index 132cacc20..1d0224abb 100644 --- a/.github/imported-workflows/publish-profile-npm.yml +++ b/.github/imported-workflows/publish-profile-npm.yml @@ -130,7 +130,7 @@ jobs: Add-MpPreference -ExclusionPath "${{ github.workspace }}\${{ env.WORKING_DIR }}\target" - name: Setup Rust - uses: dtolnay/rust-toolchain@1.90.0 + uses: dtolnay/rust-toolchain@1.94.1 with: targets: ${{ matrix.target }} diff --git a/.github/imported-workflows/test-stack-auth.yml b/.github/imported-workflows/test-stack-auth.yml index ffefca126..2674cf9d0 100644 --- a/.github/imported-workflows/test-stack-auth.yml +++ b/.github/imported-workflows/test-stack-auth.yml @@ -88,7 +88,7 @@ jobs: fi - name: Install wasm32 target - uses: dtolnay/rust-toolchain@1.90.0 + uses: dtolnay/rust-toolchain@1.94.1 with: targets: wasm32-unknown-unknown From 3d0d46f862edfbe91d5e0a883459c825ea44d347 Mon Sep 17 00:00:00 2001 From: Dan Draper <dan@cipherstash.com> Date: Mon, 21 Sep 2026 22:30:58 -0500 Subject: [PATCH 610/686] feat(stack)!: move the suite to vitaminc 0.5.0 vitaminc 0.5.0 replaces the separate AEAD and PRF context encodings with one canonical encoding (`IntoContext`; cipherstash/vitaminc#339, CIP-4036) and ships `Locked<T>` (CIP-4113), which the Go credential guest and the stack-kms key types (CIP-4112) build on. Every vitaminc crate the workspace and the WASI guest pin moves together: the aead-value / stack-encrypt pairing must share one version or the `FfiValue: Decrypt` bound fails. stack-encrypt: the core-owned context types hold one parts view and implement `IntoContext`; `IntoAad` and `IntoPrfContext` are vitaminc's blankets over it. The sequence-element context was handed back to the open side as raw bytes, which under typed leaves is a different context from the encoded one the seal side used; both sides now pass the derived `Context` itself. Term byte pins move with the encoding; the derivation and its `/v1` labels do not. CTS: `WorkspaceId` and `UserCode` implement `IntoContext`. The pinned refresh-token fixtures are re-sealed under 0.5.0, because 0.5.0 cannot open a leaf sealed under 0.4.0 (the leaf framing domain and every context encoding changed). Deploying this invalidates every refresh token issued or stored before it; the token endpoint answers them with `invalid_grant` and each CLI session logs in again once. Part of CIP-4112. BREAKING CHANGE: build against vitaminc 0.5.0: a context type of your own implements `IntoContext` instead of `IntoAad`/`IntoPrfContext`, and any ciphertext sealed with vitaminc 0.4.0 must be re-sealed, since 0.5.0 cannot open it. --- .../golang/stackencrypt/guest/Cargo.lock | 65 +++++++++------ .../golang/stackencrypt/guest/Cargo.toml | 4 +- .../stackencrypt/guest/tests/native_ops.rs | 6 +- packages/stack-encrypt/Cargo.toml | 5 +- packages/stack-encrypt/src/cipher.rs | 77 +++++++++-------- packages/stack-encrypt/src/descriptor.rs | 70 ++++++++-------- packages/stack-encrypt/src/dynamic/context.rs | 73 +++++++++-------- packages/stack-encrypt/src/dynamic/record.rs | 12 +-- packages/stack-encrypt/src/lib.rs | 24 ++++-- packages/stack-encrypt/src/sem/mod.rs | 18 ++-- packages/stack-encrypt/src/target/context.rs | 82 ++++++++----------- packages/stack-encrypt/src/target/core.rs | 10 +-- packages/stack-encrypt/src/target/request.rs | 8 +- packages/stack-encrypt/tests/derive.rs | 10 +-- packages/stack-encrypt/tests/descriptor.rs | 6 +- packages/stack-encrypt/tests/frozen_bytes.rs | 6 +- packages/stack-encrypt/tests/term_bytes.rs | 17 ++-- packages/stack-encrypt/tests/transcode.rs | 20 ++--- .../tests/ui/pass/aead_only_context.rs | 8 +- 19 files changed, 269 insertions(+), 252 deletions(-) diff --git a/languages/golang/stackencrypt/guest/Cargo.lock b/languages/golang/stackencrypt/guest/Cargo.lock index 6ab35eb17..bbaea3a08 100644 --- a/languages/golang/stackencrypt/guest/Cargo.lock +++ b/languages/golang/stackencrypt/guest/Cargo.lock @@ -299,6 +299,7 @@ dependencies = [ "cfg-if", "cpufeatures 0.3.1", "rand_core 0.10.1", + "zeroize", ] [[package]] @@ -2425,11 +2426,12 @@ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" [[package]] name = "vitaminc" -version = "0.4.0" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b26ab159600161f73a1a519a31562ec4732257991acc6cba180db826c7765035" +checksum = "3f371b877e1586acb8ffc9efd0bd6a7c7ff7d8c4b1529c1f46d34b1df3478699" dependencies = [ "vitaminc-aead", + "vitaminc-context", "vitaminc-encrypt", "vitaminc-protected", "vitaminc-random", @@ -2438,14 +2440,14 @@ dependencies = [ [[package]] name = "vitaminc-aead" -version = "0.4.0" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52b4b439efcb59c4fb2b56aa59934d5907d6907172feb45b45df1eb9311f357e" +checksum = "b0238d5b77821fcd46d2f72b082532209d625892c69e40008e403b709a1dd0fa" dependencies = [ "bytes", "serde", "vitaminc-aead-derive", - "vitaminc-prf", + "vitaminc-context", "vitaminc-protected", "vitaminc-random", "zeroize", @@ -2453,9 +2455,9 @@ dependencies = [ [[package]] name = "vitaminc-aead-derive" -version = "0.4.0" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7fa56c62afe5cc393173876b83ee361246e341e855f7d45a334e1edd30c0018e" +checksum = "f5a81524f1e096bc45a1e7901186546bc0cf5545a9a86e978581a7146c54a5c7" dependencies = [ "proc-macro2", "quote", @@ -2464,20 +2466,30 @@ dependencies = [ [[package]] name = "vitaminc-aead-value" -version = "0.4.0" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce93802e2ca4eba669ad520bf55e25074a4e32755b52664baa15e084614f3120" +checksum = "6b63326e8bf21f695080c50d8d849324fa92e4cf6ce7257bef198b1ff2eeea0d" dependencies = [ "vitaminc-aead", "vitaminc-protected", "zeroize", ] +[[package]] +name = "vitaminc-context" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e7b3e0602ad490039680222ba23217264eab0f400495c072ec11e1a2235b304" +dependencies = [ + "mutants", + "vitaminc-protected", +] + [[package]] name = "vitaminc-encrypt" -version = "0.4.0" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbb81b908b8747fd74ffc198591849ccab8e0be52049d41b3d5d7bb8aed5342b" +checksum = "b969f689aebd40637a6ba9243618a62057ee9c76a8b70c6661c9b0c28ba20275" dependencies = [ "aes-gcm", "aws-lc-rs", @@ -2489,9 +2501,9 @@ dependencies = [ [[package]] name = "vitaminc-hmac" -version = "0.4.0" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d9de7991de2c5e4526d9fc81ab2c153ba5c0af7b50bdc9f1bd19eddb10b8307" +checksum = "ccebde615f15197146a3fe4b3ae32ff00a6ae489f88ae9ee0b71e1cbdd286d94" dependencies = [ "hmac", "sha2 0.11.0", @@ -2502,23 +2514,25 @@ dependencies = [ [[package]] name = "vitaminc-prf" -version = "0.4.0" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d8978944fc2fba04255b776b384ff71b9ed5abc4d5bce859a11cc5d6841cba9" +checksum = "c0e6242717d2a5b3f0fdbdaf74340b60eb07713de537d5f82508065a8bfd7ef3" dependencies = [ "mutants", "thiserror 2.0.20", + "vitaminc-context", "vitaminc-protected", ] [[package]] name = "vitaminc-protected" -version = "0.4.0" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a31efcddf3193efbe1801959922159ffe60a1e1388bc993927d3d7242f144c7" +checksum = "5a13b616fa4e7093f4970dacd6f44ca76a6929bee1c72612134353a69bb52c62" dependencies = [ "bitvec", "digest 0.11.3", + "libc", "serde", "serde_bytes", "subtle", @@ -2529,9 +2543,9 @@ dependencies = [ [[package]] name = "vitaminc-protected-derive" -version = "0.4.0" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17fe15916386e0404b17c468e8c5a255ffc037e930512b5aa494802c8bba22d8" +checksum = "47c3f8db2eff8108d078a77125f20974448b9a66950bdc120e35b0b53eda4c98" dependencies = [ "proc-macro2", "quote", @@ -2540,10 +2554,11 @@ dependencies = [ [[package]] name = "vitaminc-random" -version = "0.4.0" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f8f9bc3db765f757068db06bd60f5e6fbfa8d546ed6da82246c33b6321fe45f" +checksum = "a8320b63fad9560026b036cb6eb9b85efb1d666af52862af923a2c75972c4282" dependencies = [ + "chacha20", "getrandom 0.4.3", "rand 0.10.2", "thiserror 2.0.20", @@ -2554,9 +2569,9 @@ dependencies = [ [[package]] name = "vitaminc-random-derives" -version = "0.4.0" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0931f7e84808c1072d77b30bab7f4e254334e038fc28929fbf7d647796b856cd" +checksum = "cdbc48a9e3893406046f51f51c3d2bb1dffe70d6ca3b19920ca12d231ed12b68" dependencies = [ "proc-macro2", "quote", @@ -2565,9 +2580,9 @@ dependencies = [ [[package]] name = "vitaminc-traits" -version = "0.4.0" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c806b05905d3a51c5440c64eb087bfe4b2a02e06dd4792ba9c53e005d42aeb5" +checksum = "2c53613363efb0c47aba19ea2bfe0142cba8b2d0fe1bc27d43b0ea3390f6efe7" dependencies = [ "anyhow", "bytes", diff --git a/languages/golang/stackencrypt/guest/Cargo.toml b/languages/golang/stackencrypt/guest/Cargo.toml index f182a5b08..cda77ee3c 100644 --- a/languages/golang/stackencrypt/guest/Cargo.toml +++ b/languages/golang/stackencrypt/guest/Cargo.toml @@ -37,8 +37,8 @@ zerokms-protocol = { path = "../../../../packages/zerokms-protocol" } # (see the comment in packages/stack-encrypt/Cargo.toml). A different # version here fails at the `FfiValue: Decrypt` bound, since the aead crate # would be duplicated. -vitaminc-aead-value = "0.4.0" -vitaminc-protected = "0.4.0" +vitaminc-aead-value = "0.5.0" +vitaminc-protected = "0.5.0" futures = { version = "0.3", default-features = false, features = ["executor"] } serde = "1" diff --git a/languages/golang/stackencrypt/guest/tests/native_ops.rs b/languages/golang/stackencrypt/guest/tests/native_ops.rs index d5d482609..541f548c6 100644 --- a/languages/golang/stackencrypt/guest/tests/native_ops.rs +++ b/languages/golang/stackencrypt/guest/tests/native_ops.rs @@ -288,8 +288,10 @@ fn guest_leaves_are_the_frozen_storage_encoding() { let leaf = SealedValue::from_bytes(&leaf_bytes).expect("frozen leaf encoding"); // A guest leaf seals the *value model's* typed payload (`[tag] ++ // payload`, the vitaminc sealed-leaf format), so the native open goes - // through `FfiValue`'s own `Decrypt` — not a bare `String`. - let value: FfiValue = block_on(cipher.decrypt(CipherText::Single(leaf), "ctx")) + // through `FfiValue`'s own `Decrypt` — not a bare `String`. The host's + // AAD is bytes, and a byte context is not a text context (vitaminc 0.5 + // types its leaves), so the native side opens under the byte slice. + let value: FfiValue = block_on(cipher.decrypt(CipherText::Single(leaf), b"ctx".as_slice())) .expect("native decrypt of a guest leaf"); assert_eq!(text(&value), "durable"); } diff --git a/packages/stack-encrypt/Cargo.toml b/packages/stack-encrypt/Cargo.toml index aa560a4d5..6a8f12365 100644 --- a/packages/stack-encrypt/Cargo.toml +++ b/packages/stack-encrypt/Cargo.toml @@ -25,8 +25,9 @@ stack-auth = { workspace = true, optional = true } # `#[derive(EncryptFrom)]` / `#[derive(DecryptInto)]`, re-exported from `target`. stack-encrypt-derive = { path = "../stack-encrypt-derive" } -# The workspace vitaminc (0.3.0): the first release with `NonEmpty::with`, -# `From<integer> for NonEmpty` (cipherstash/vitaminc#314) and `AadPiece` +# The workspace vitaminc (0.5.0): one canonical context encoding shared by +# the AEAD and PRF derivations (`IntoContext`, cipherstash/vitaminc#339), +# `NonEmpty::with`, `From<integer> for NonEmpty` (#314) and the parts view # (#318), all of which this crate relies on. All five must share one # version or the aead crate is duplicated. vitaminc-aead = { workspace = true } diff --git a/packages/stack-encrypt/src/cipher.rs b/packages/stack-encrypt/src/cipher.rs index 1aefe849d..212fecb28 100644 --- a/packages/stack-encrypt/src/cipher.rs +++ b/packages/stack-encrypt/src/cipher.rs @@ -39,11 +39,11 @@ //! The caller's AAD is refined per node with the same domain-separated //! derivations `Aes256Cipher` uses, so the container *shape* is authenticated: //! -//! * sequence elements are sealed under [`Aad::for_sequence_element`]; -//! * map values under [`Aad::for_map_entry`] of their cleartext key (so +//! * sequence elements are sealed under [`Context::for_sequence_element`]; +//! * map values under [`Context::for_map_entry`] of their cleartext key (so //! swapping or renaming keys fails decryption); -//! * authenticated-absent markers under [`Aad::for_none`], and empty -//! sequences/maps under [`Aad::for_empty_sequence`]/[`Aad::for_empty_map`] — +//! * authenticated-absent markers under [`Context::for_none`], and empty +//! sequences/maps under [`Context::for_empty_sequence`]/[`Context::for_empty_map`] — //! each sealing an *empty* plaintext, verified as empty on open. //! //! The decrypt side performs the same derivations inside [`StackDecipher`]'s @@ -97,8 +97,8 @@ use stack_kms::{EnvKeyProvider, StackKms, StackKmsBuilder}; use stack_kms::{FallbackKeyProvider, KeyProvider, KeyProviderError, ProfileStore}; use uuid::Uuid; use vitaminc_aead::{ - Aad, Cipher, CipherText, Decipher, DecipherVisitor, Decrypt, Encrypt, IntoAad, LocalCipherText, - MapAccess, MapCipher, SeqAccess, SeqCipher, Unspecified, + Cipher, CipherText, Context, Decipher, DecipherVisitor, Decrypt, Encrypt, IntoAad, + LocalCipherText, MapAccess, MapCipher, SeqAccess, SeqCipher, Unspecified, }; use vitaminc_encrypt::{Aes256Cipher, AesCipherText, Key as AesKey}; use vitaminc_protected::{Controlled, Protected}; @@ -688,7 +688,7 @@ impl<K: DataKeySource> KeysetCipher<'_, K> { T: Encrypt, A: IntoAad<'a>, { - let aad = aad.into_aad_piece(); + let aad = aad.into_context(); let pending = value.encrypt_with_aad(self, aad.clone().into_aad())?; pending.seal(self, aad).await } @@ -739,7 +739,7 @@ async fn decrypt_through<'s, 'a, T, K: DataKeySource + 's>( where T: Decrypt<'static> + 'static, { - let aad = aad.into_aad_piece(); + let aad = aad.into_context(); let decipher = decipher_through(scope, ciphertext, aad.clone()).await?; T::decrypt_with_aad(decipher, aad.into_aad()).map_err(Error::from) } @@ -827,7 +827,7 @@ impl<K: DataKeySource> StackCipher<K> { /// tag, each bound by the layer that owns its framing: the envelope version /// and the keyset id through this crate's leaf-AAD derivation, /// `PAE("stack-encrypt/leaf", version, keyset_id, derived_aad, tag)`, and -/// the inner version through vitaminc's `Aad::for_leaf`, applied inside +/// the inner version through vitaminc's `Context::for_leaf`, applied inside /// `Aes256Cipher` to the AAD this crate hands it. Relabel either version /// byte, or re-point the leaf at another keyset, in storage and the leaf /// fails authentication rather than parsing under the wrong rules. Parsing @@ -1126,21 +1126,21 @@ pub enum PendingStackCipherText { /// A scalar awaiting a data key, with its bound (derived) AAD. Single { plaintext: Protected<Vec<u8>>, - aad: Aad<'static>, + aad: Context<'static>, }, /// A pending sequence with at least one element. Sequence(Vec<PendingStackCipherText>), /// A pending empty-sequence marker; `aad` is already the - /// [`Aad::for_empty_sequence`] derivation. - EmptySequence { aad: Aad<'static> }, + /// [`Context::for_empty_sequence`] derivation. + EmptySequence { aad: Context<'static> }, /// A pending map with at least one entry. Map(Vec<(String, PendingStackCipherText)>), - /// A pending empty-map marker; `aad` is already the [`Aad::for_empty_map`] + /// A pending empty-map marker; `aad` is already the [`Context::for_empty_map`] /// derivation. - EmptyMap { aad: Aad<'static> }, + EmptyMap { aad: Context<'static> }, /// A pending authenticated-absent marker; `aad` is already the - /// [`Aad::for_none`] derivation. - None { aad: Aad<'static> }, + /// [`Context::for_none`] derivation. + None { aad: Context<'static> }, /// A passthrough value (needs no key). Passthrough(BoxedPassthrough), } @@ -1226,7 +1226,7 @@ impl PendingStackCipherText { // Markers seal an *empty* plaintext so the AEAD tag still binds their // (already domain-separated) AAD, mirroring `Aes256Cipher`. fn seal_marker( - aad: Aad<'static>, + aad: Context<'static>, keyset_id: Uuid, keys: &mut impl Iterator<Item = DataKeyWithTag>, ) -> Result<SealedValue, Unspecified> { @@ -1295,7 +1295,7 @@ fn leaf_cipher(key: &DataKey) -> Result<Aes256Cipher, Unspecified> { /// version and the keyset id under the tag is what makes those bytes in /// [`SealedValue::to_bytes`] more than parse hints: bytes relabelled with a /// different version fail verification instead of selecting different -/// parsing and derivation rules — mirroring vitaminc's `Aad::for_leaf`, +/// parsing and derivation rules — mirroring vitaminc's `Context::for_leaf`, /// which binds the *inner* [`LocalCipherText`] wire version the same way — /// and a leaf re-pointed at another keyset fails verification instead of /// asking that keyset for a key it never minted. @@ -1311,9 +1311,9 @@ fn leaf_cipher(key: &DataKey) -> Result<Aes256Cipher, Unspecified> { /// dev-persisted data existed. A leaf sealed under an earlier form fails /// authentication in `open_leaf` with a plain AEAD error, indistinguishable /// from tampering; re-encrypt anything that matters. -fn leaf_aad(aad: &Aad<'_>, keyset_id: Uuid, tag: &[u8]) -> Aad<'static> { +fn leaf_aad(aad: &Context<'_>, keyset_id: Uuid, tag: &[u8]) -> Context<'static> { const LEAF_AAD_DOMAIN: &[u8] = b"stack-encrypt/leaf"; - Aad::pae(&[ + Context::pae(&[ LEAF_AAD_DOMAIN, &[SealedValue::FORMAT_VERSION], keyset_id.as_bytes(), @@ -1329,7 +1329,7 @@ fn leaf_aad(aad: &Aad<'_>, keyset_id: Uuid, tag: &[u8]) -> Aad<'static> { /// bound, so the leaf is cryptographically tied to its ZeroKMS data key. fn seal_leaf( plaintext: Protected<Vec<u8>>, - aad: &Aad<'_>, + aad: &Context<'_>, keyset_id: Uuid, key: DataKeyWithTag, ) -> Result<SealedValue, Unspecified> { @@ -1353,7 +1353,7 @@ fn seal_leaf( } /// Open one keyed leaf under `aad`, returning the plaintext bytes. -fn open_leaf(keyed: KeyedLeaf, aad: &Aad<'_>) -> Result<Protected<Vec<u8>>, Unspecified> { +fn open_leaf(keyed: KeyedLeaf, aad: &Context<'_>) -> Result<Protected<Vec<u8>>, Unspecified> { /// Keeps the recovered bytes inside `Protected` across the visitor /// boundary (the blanket `Decrypt for Vec<u8>` would unwrap them). struct ProtectedBytes; @@ -1374,7 +1374,7 @@ fn open_leaf(keyed: KeyedLeaf, aad: &Aad<'_>) -> Result<Protected<Vec<u8>>, Unsp /// Open one marker leaf (absent / empty-sequence / empty-map) and require the /// sealed plaintext to be empty. Without the emptiness check, a `Single` leaf /// could be re-tagged as a marker of the same AAD derivation. -fn verify_empty_marker(keyed: KeyedLeaf, aad: &Aad<'_>) -> Result<(), Unspecified> { +fn verify_empty_marker(keyed: KeyedLeaf, aad: &Context<'_>) -> Result<(), Unspecified> { let plaintext = open_leaf(keyed, aad)?; if plaintext.risky_ref().is_empty() { Ok(()) @@ -1469,10 +1469,10 @@ pub struct PendingSeqCipher<'c, 'k, K> { items: Vec<PendingStackCipherText>, /// The AAD fixed at [`Cipher::encrypt_seq`]; the empty marker is sealed /// against its `for_empty_sequence` derivation. - aad: Aad<'static>, - /// [`Aad::for_sequence_element`] of `aad`, derived once and applied to + aad: Context<'static>, + /// [`Context::for_sequence_element`] of `aad`, derived once and applied to /// every element. - element_aad: Aad<'static>, + element_aad: Context<'static>, /// Whether at least one element went through the authenticated /// [`encrypt_next`](SeqCipher::encrypt_next) path *and* produced a sealed /// node — see [`end`](SeqCipher::end). @@ -1489,8 +1489,7 @@ impl<K> SeqCipher for PendingSeqCipher<'_, '_, K> { T: Encrypt, { // Borrow the stored derived AAD — no allocation per element. - let pending = - data.encrypt_with_aad(self.cipher, Aad::from_slice(self.element_aad.as_bytes()))?; + let pending = data.encrypt_with_aad(self.cipher, self.element_aad.clone())?; // A nested `Encrypt` impl may route through the passthrough channel; // only a genuinely pending-sealed node may satisfy `end`'s // all-passthrough rejection. @@ -1521,7 +1520,7 @@ impl<K> SeqCipher for PendingSeqCipher<'_, '_, K> { } /// [`MapCipher`] driver: keys are stored in the clear; values become pending -/// sub-trees sealed against [`Aad::for_map_entry`] of the map AAD and their +/// sub-trees sealed against [`Context::for_map_entry`] of the map AAD and their /// key. Mirrors `AesMapCipher`'s key/value and duplicate-key contract checks. pub struct PendingMapCipher<'c, 'k, K> { cipher: &'c KeysetCipher<'k, K>, @@ -1532,7 +1531,7 @@ pub struct PendingMapCipher<'c, 'k, K> { seen_keys: HashSet<String>, current_key: Option<Cow<'static, str>>, /// The AAD fixed at [`Cipher::encrypt_map`]. - aad: Aad<'static>, + aad: Context<'static>, /// See [`PendingSeqCipher::encrypted`]. encrypted: bool, } @@ -1633,8 +1632,8 @@ impl<K> MapCipher for PendingMapCipher<'_, '_, K> { /// Structurally identical to `vitaminc_encrypt::AesDecipher` — the only /// difference is where each leaf's key comes from. The AAD is supplied per call /// by [`Decrypt::decrypt_with_aad`] and refined here exactly as the encrypt side -/// refined it: sequence elements under [`Aad::for_sequence_element`], map -/// values under [`Aad::for_map_entry`] of their key, markers under their +/// refined it: sequence elements under [`Context::for_sequence_element`], map +/// values under [`Context::for_map_entry`] of their key, markers under their /// respective derivations with an enforced-empty plaintext. Because the /// derivation lives in this drive (not in a pre-pass), `Decrypt` impls that /// transform the AAD themselves (e.g. `vitaminc_aead::Element`) work unchanged. @@ -1834,10 +1833,10 @@ impl<'c> Decipher<'c> for StackDecipher { struct StackSeqAccess { items: std::vec::IntoIter<KeyedCipherText>, - /// [`Aad::for_sequence_element`] of the caller's AAD, derived once at + /// [`Context::for_sequence_element`] of the caller's AAD, derived once at /// construction and re-supplied per element by borrowing. Mirrors /// `PendingSeqCipher::element_aad` on the encrypt side. - element_aad: Aad<'static>, + element_aad: Context<'static>, } impl<'c> SeqAccess<'c> for StackSeqAccess { @@ -1846,7 +1845,7 @@ impl<'c> SeqAccess<'c> for StackSeqAccess { fn next_element<T: Decrypt<'c> + 'c>(&mut self) -> Result<Option<T>, Self::Error> { match self.items.next() { Some(ct) => { - T::decrypt_with_aad(StackDecipher::over(ct), self.element_aad.as_bytes()).map(Some) + T::decrypt_with_aad(StackDecipher::over(ct), self.element_aad.clone()).map(Some) } None => Ok(None), } @@ -1855,14 +1854,14 @@ impl<'c> SeqAccess<'c> for StackSeqAccess { struct StackMapAccess<'a> { entries: std::vec::IntoIter<(String, KeyedCipherText)>, - aad: Aad<'a>, + aad: Context<'a>, /// The entry handed out by `next_key` and not yet consumed by /// `next_value` / `next_passthrough`. Held as ciphertext rather than /// decrypted up front so the caller can choose the plaintext type after /// seeing the key — see [`MapAccess::next_key`] — alongside the entry /// AAD it was sealed under, derived once here so the key itself moves /// out to the caller. - pending: Option<(Aad<'static>, KeyedCipherText)>, + pending: Option<(Context<'static>, KeyedCipherText)>, } impl<'c, 'a> MapAccess<'c> for StackMapAccess<'a> { @@ -1921,7 +1920,7 @@ mod tests { .map(|(key, ct)| (key.to_string(), ct)) .collect::<Vec<_>>() .into_iter(), - aad: Aad::from_slice(b"map"), + aad: Context::from_encoded(b"map"), pending: None, } } @@ -1986,7 +1985,7 @@ mod tests { #[test] fn leaf_aad_bytes_are_pinned() { let keyset = Uuid::from_bytes(*b"keyset-fixture16"); - let aad = leaf_aad(&Aad::from_slice(b"caller-aad"), keyset, b"key-tag"); + let aad = leaf_aad(&Context::from_encoded(b"caller-aad"), keyset, b"key-tag"); let hex: String = aad.as_bytes().iter().map(|b| format!("{b:02x}")).collect(); // PAE: LE64 count (5) ‖ per piece LE64 length ‖ piece, the pieces // being "stack-encrypt/leaf", [FORMAT_VERSION], the keyset id's 16 diff --git a/packages/stack-encrypt/src/descriptor.rs b/packages/stack-encrypt/src/descriptor.rs index 8b5ac97a3..dffae9b38 100644 --- a/packages/stack-encrypt/src/descriptor.rs +++ b/packages/stack-encrypt/src/descriptor.rs @@ -14,7 +14,7 @@ //! that stack-encrypt does not yet use. //! //! The descriptor is a string on the wire; a context is a value with parts -//! (its [`AadPiece`] tree — text, bytes, integers, lists of those). +//! (its [`ContextPiece`] tree — text, bytes, integers, lists of those). //! [`Descriptor::from_piece`] is the one rendering of those parts as a //! string, and it is **frozen**: ZeroKMS binds the rendered string into the //! tag, so changing the rendering strands every key issued under the old @@ -27,20 +27,20 @@ //! cases, where contexts with identical AAD bytes get different //! descriptors and ZeroKMS refuses what the AEAD would open: //! -//! * A pre-encoded [`Aad`](vitaminc_aead::Aad) is one opaque bytes part. +//! * A pre-encoded [`Context`](vitaminc_aead::Context) is one opaque bytes part. //! `("tenant", 7u64)` renders `tenant|7u64`; the same tuple passed //! through `into_aad()` first renders `b64:` + its encoded bytes. -//! * Different shapes can encode alike: `None::<&str>` (an empty list) and -//! `0u64` are both eight zero bytes, and render `()` and `0u64`. +//! * Shapes that encode alike render alike: text and bytes with the same +//! content render the same, and `7i64` renders as `7u64`. //! //! So a value must be opened under the context in the same **shape** it was -//! sealed under — the structured value both times, or the encoded `Aad` +//! sealed under — the structured value both times, or the encoded `Context` //! both times — not merely one with the same bytes. use std::sync::Arc; use base64ct::{Base64, Encoding}; -use vitaminc_aead::{AadPiece, IntoAad}; +use vitaminc_aead::{ContextPiece, IntoAad}; /// A context rendered as the string sent to ZeroKMS with every data-key /// request. See the [module docs](self). @@ -103,7 +103,7 @@ impl Descriptor { /// assert_eq!(Descriptor::of("a|b").as_str(), "b64:YXxi"); /// ``` pub fn of<'a>(context: impl IntoAad<'a>) -> Self { - Self::from_piece(&context.into_aad_piece()) + Self::from_piece(&context.into_context()) } /// Render a context's parts. @@ -119,7 +119,8 @@ impl Descriptor { /// part renders as `b64:` followed by the standard (padded) base64 of /// its bytes; an **empty** part is therefore the bare prefix, `b64:`, /// so `Some("")` is `(b64:)` and `None` is `()`. Text and bytes with - /// the same bytes render the same, as they encode the same. + /// the same bytes render the same, though since vitaminc 0.5 they + /// encode differently: the rendering is of the parts, not the bytes. /// * An **integer** part renders as its encoded bytes read as an /// unsigned number, with the width as a suffix: `7u64`. Integers /// encode as untagged little-endian bytes, so the width is part of the @@ -136,32 +137,34 @@ impl Descriptor { /// /// The rendering is injective over encodings (the plain-text rule /// reserves exactly the characters the other forms begin with or - /// contain), and finer than the encoding for a pre-encoded `Aad` and for + /// contain), and finer than the encoding for a pre-encoded `Context` and for /// shapes that happen to encode alike — see the [module docs](self). - pub fn from_piece(piece: &AadPiece<'_>) -> Self { + pub fn from_piece(piece: &ContextPiece<'_>) -> Self { let mut out = String::new(); Self::render(piece, true, &mut out); Self(Arc::from(out)) } - fn render(piece: &AadPiece<'_>, root: bool, out: &mut String) { + fn render(piece: &ContextPiece<'_>, root: bool, out: &mut String) { match piece { - AadPiece::Text(text) => Self::render_bytes(text.as_bytes(), root, out), - AadPiece::Bytes(bytes) => Self::render_bytes(bytes, root, out), + ContextPiece::Text(text) => Self::render_bytes(text.as_bytes(), root, out), + ContextPiece::Bytes(bytes) => Self::render_bytes(bytes, root, out), // Signed and unsigned of one width encode to the same // little-endian bytes; `as` reinterprets, so they render the // same too. - AadPiece::U8(v) => Self::render_int(v, "u8", out), - AadPiece::U16(v) => Self::render_int(v, "u16", out), - AadPiece::U32(v) => Self::render_int(v, "u32", out), - AadPiece::U64(v) => Self::render_int(v, "u64", out), - AadPiece::U128(v) => Self::render_int(v, "u128", out), - AadPiece::I8(v) => Self::render_int(&(*v as u8), "u8", out), - AadPiece::I16(v) => Self::render_int(&(*v as u16), "u16", out), - AadPiece::I32(v) => Self::render_int(&(*v as u32), "u32", out), - AadPiece::I64(v) => Self::render_int(&(*v as u64), "u64", out), - AadPiece::I128(v) => Self::render_int(&(*v as u128), "u128", out), - AadPiece::List(parts) => { + ContextPiece::U8(v) => Self::render_int(v, "u8", out), + ContextPiece::U16(v) => Self::render_int(v, "u16", out), + ContextPiece::U32(v) => Self::render_int(v, "u32", out), + ContextPiece::U64(v) => Self::render_int(v, "u64", out), + ContextPiece::U128(v) => Self::render_int(v, "u128", out), + ContextPiece::I8(v) => Self::render_int(&(*v as u8), "u8", out), + ContextPiece::I16(v) => Self::render_int(&(*v as u16), "u16", out), + ContextPiece::I32(v) => Self::render_int(&(*v as u32), "u32", out), + ContextPiece::I64(v) => Self::render_int(&(*v as u64), "u64", out), + ContextPiece::I128(v) => Self::render_int(&(*v as u128), "u128", out), + // A pre-encoded context is one opaque part: its bytes, escaped. + ContextPiece::Encoded(bytes) => Self::render_bytes(bytes, root, out), + ContextPiece::List(parts) => { let bare = root && parts.len() >= 2; if !bare { out.push('('); @@ -176,7 +179,7 @@ impl Descriptor { out.push(')'); } } - // `AadPiece` is `#[non_exhaustive]`: a part this crate does not + // `ContextPiece` is `#[non_exhaustive]`: a part this crate does not // know renders by its bytes, which is still injective (the // base64 form is reserved) and still binds. other => Self::render_bytes(other.clone().into_aad().as_bytes(), root, out), @@ -264,7 +267,7 @@ impl AsRef<str> for Descriptor { #[cfg(test)] mod tests { - use vitaminc_aead::Aad; + use vitaminc_aead::Context; use vitaminc_protected::{nonempty, NonEmpty}; use super::*; @@ -286,7 +289,7 @@ mod tests { "bytes that are text render as the text they encode to" ); assert_eq!( - Descriptor::of(Aad::from_slice(b"users/email")).as_str(), + Descriptor::of(Context::from_encoded(b"users/email")).as_str(), "users/email", "already-encoded AAD renders by its bytes" ); @@ -371,8 +374,8 @@ mod tests { } #[test] - fn the_descriptor_is_finer_than_the_encoding_in_two_named_cases() { - // A pre-encoded `Aad` is one opaque bytes part: the descriptor + fn the_descriptor_is_finer_than_the_encoding_for_a_pre_encoded_context() { + // A pre-encoded `Context` is one opaque bytes part: the descriptor // cannot recover the parts it was built from, so it renders the // bytes. Seal and open must present the context in the same shape. let structured = Descriptor::of(("tenant", 7u64)); @@ -381,10 +384,11 @@ mod tests { assert!(encoded.as_str().starts_with(Descriptor::BASE64_PREFIX)); assert_ne!(structured, encoded); - // Different shapes can encode to the same bytes — an empty list is - // a zero count, which is eight zero bytes, which is `0u64`. The - // AEAD cannot tell them apart; the descriptor does. - assert_eq!( + // Before vitaminc 0.5, different shapes could encode to the same + // bytes (an empty list was a zero count, eight zero bytes, `0u64`). + // Typed leaves closed that: the two now differ on the AEAD side as + // they always did on the descriptor. + assert_ne!( None::<&str>.into_aad().as_bytes(), 0u64.into_aad().as_bytes() ); diff --git a/packages/stack-encrypt/src/dynamic/context.rs b/packages/stack-encrypt/src/dynamic/context.rs index d7b708544..d83ad8808 100644 --- a/packages/stack-encrypt/src/dynamic/context.rs +++ b/packages/stack-encrypt/src/dynamic/context.rs @@ -6,17 +6,17 @@ use vitaminc_aead_value::FfiValue; use vitaminc_protected::Controlled; use super::Error; -use crate::{AadPiece, NonEmpty}; +use crate::{ContextPiece, NonEmpty}; -/// A context arrives from a binding as a value and becomes an [`AadPiece`] +/// A context arrives from a binding as a value and becomes an [`ContextPiece`] /// tree: vitaminc's runtime form of a context, and the *identity* of one. /// vitaminc's law (pinned there by quickcheck over every built-in context /// type) is that a context's two derivations each equal the same derivation /// of its parts view: /// /// ```text -/// x.into_aad() == x.into_aad_piece().into_aad() -/// x.into_prf_context() == x.into_aad_piece().into_prf_context() +/// x.into_aad() == x.into_context().into_aad() +/// x.into_prf_context() == x.into_context().into_prf_context() /// ``` /// /// So a `#[derive(EncryptFrom)]` row sealed with @@ -38,9 +38,9 @@ use crate::{AadPiece, NonEmpty}; /// ([`MAX_DEPTH`](vitaminc_aead_value::transport::MAX_DEPTH) levels, counted /// from the root of the encoded value); a deeper value is refused by the /// codec before this module sees it. Text and bytes with the same content -/// are distinct on the PRF side (UTF-8 versus bytes encodings) though they -/// share AAD bytes — the same distinction the Rust types make. Booleans, -/// floats, null, undefined, objects and passthroughs are not contexts. +/// are distinct contexts (UTF-8 versus bytes typed leaves) — the same +/// distinction the Rust types make. Booleans, floats, null, undefined, +/// objects and passthroughs are not contexts. /// /// # Which Rust contexts a list spells /// @@ -87,27 +87,27 @@ use crate::{AadPiece, NonEmpty}; /// /// [`Error::Context`] for anything outside the shape above, and for a /// context that renders empty. -pub fn context(value: FfiValue) -> Result<NonEmpty<AadPiece<'static>>, Error> { +pub fn context(value: FfiValue) -> Result<NonEmpty<ContextPiece<'static>>, Error> { NonEmpty::new(piece_of(value)?).map_err(|_| Error::Context) } -fn piece_of(value: FfiValue) -> Result<AadPiece<'static>, Error> { +fn piece_of(value: FfiValue) -> Result<ContextPiece<'static>, Error> { Ok(match value { // Valid UTF-8 by `Utf8String`'s construction invariant; checked // rather than assumed because this is boundary code. The payload // moves out of its `Protected` rather than being copied: a context // is not secret, and the copy would only be wiped and freed. - FfiValue::String(s) => AadPiece::Text(Cow::Owned( + FfiValue::String(s) => ContextPiece::Text(Cow::Owned( String::from_utf8(s.into_inner().risky_unwrap()).map_err(|_| Error::Context)?, )), - FfiValue::Bytes(bytes) => AadPiece::Bytes(Cow::Owned(bytes.risky_unwrap())), - FfiValue::Int32(v) => AadPiece::I32(v), - FfiValue::Int64(v) => AadPiece::I64(v), - FfiValue::UInt32(v) => AadPiece::U32(v), - FfiValue::UInt64(v) => AadPiece::U64(v), + FfiValue::Bytes(bytes) => ContextPiece::Bytes(Cow::Owned(bytes.risky_unwrap())), + FfiValue::Int32(v) => ContextPiece::I32(v), + FfiValue::Int64(v) => ContextPiece::I64(v), + FfiValue::UInt32(v) => ContextPiece::U32(v), + FfiValue::UInt64(v) => ContextPiece::U64(v), // Nesting depth is bounded by the codec's `MAX_DEPTH` before the // value reaches here. - FfiValue::Array(items) => AadPiece::List( + FfiValue::Array(items) => ContextPiece::List( items .into_iter() .map(piece_of) @@ -129,24 +129,25 @@ fn piece_of(value: FfiValue) -> Result<AadPiece<'static>, Error> { /// list spine is rebuilt, which is the cost of a tree of `Cow`s rather than /// a tree of references. /// -/// [`AadPiece`] is `#[non_exhaustive]`, so a variant this crate does not +/// [`ContextPiece`] is `#[non_exhaustive]`, so a variant this crate does not /// know is cloned whole rather than refused: the view must be the same /// context, and a clone is. -pub fn borrowed<'b>(piece: &'b AadPiece<'_>) -> AadPiece<'b> { +pub fn borrowed<'b>(piece: &'b ContextPiece<'_>) -> ContextPiece<'b> { match piece { - AadPiece::Text(text) => AadPiece::Text(Cow::Borrowed(text.as_ref())), - AadPiece::Bytes(bytes) => AadPiece::Bytes(Cow::Borrowed(bytes.as_ref())), - AadPiece::U8(v) => AadPiece::U8(*v), - AadPiece::U16(v) => AadPiece::U16(*v), - AadPiece::U32(v) => AadPiece::U32(*v), - AadPiece::U64(v) => AadPiece::U64(*v), - AadPiece::U128(v) => AadPiece::U128(*v), - AadPiece::I8(v) => AadPiece::I8(*v), - AadPiece::I16(v) => AadPiece::I16(*v), - AadPiece::I32(v) => AadPiece::I32(*v), - AadPiece::I64(v) => AadPiece::I64(*v), - AadPiece::I128(v) => AadPiece::I128(*v), - AadPiece::List(parts) => AadPiece::List(parts.iter().map(borrowed).collect()), + ContextPiece::Text(text) => ContextPiece::Text(Cow::Borrowed(text.as_ref())), + ContextPiece::Bytes(bytes) => ContextPiece::Bytes(Cow::Borrowed(bytes.as_ref())), + ContextPiece::U8(v) => ContextPiece::U8(*v), + ContextPiece::U16(v) => ContextPiece::U16(*v), + ContextPiece::U32(v) => ContextPiece::U32(*v), + ContextPiece::U64(v) => ContextPiece::U64(*v), + ContextPiece::U128(v) => ContextPiece::U128(*v), + ContextPiece::I8(v) => ContextPiece::I8(*v), + ContextPiece::I16(v) => ContextPiece::I16(*v), + ContextPiece::I32(v) => ContextPiece::I32(*v), + ContextPiece::I64(v) => ContextPiece::I64(*v), + ContextPiece::I128(v) => ContextPiece::I128(*v), + ContextPiece::Encoded(bytes) => ContextPiece::Encoded(Cow::Borrowed(bytes.as_ref())), + ContextPiece::List(parts) => ContextPiece::List(parts.iter().map(borrowed).collect()), other => other.clone().into_owned(), } } @@ -166,7 +167,7 @@ mod tests { let parsed = context(s("users/age")).expect("flat context"); assert_eq!( parsed.get(), - &AadPiece::Text(Cow::Borrowed("users/age")), + &ContextPiece::Text(Cow::Borrowed("users/age")), "a bare string is one text part, not a one-element list" ); assert_eq!( @@ -333,16 +334,18 @@ mod tests { ); } + /// Text and bytes are typed leaves, so the same content is two contexts + /// on both sides (vitaminc 0.5; before it they shared AAD bytes). #[test] - fn text_and_bytes_share_aad_bytes_but_not_prf_encoding() { + fn text_and_bytes_are_distinct_contexts_on_both_sides() { let text = context(s("ab")).expect("text").into_inner(); let bytes = context(FfiValue::Bytes(Protected::new(b"ab".to_vec()))) .expect("bytes") .into_inner(); - assert_eq!( + assert_ne!( text.clone().into_aad().as_bytes(), bytes.clone().into_aad().as_bytes(), - "text and bytes of the same content share AAD bytes" + "text and bytes of the same content are distinct AAD" ); assert_ne!( text.into_prf_context().as_bytes(), diff --git a/packages/stack-encrypt/src/dynamic/record.rs b/packages/stack-encrypt/src/dynamic/record.rs index 416799621..3511f8e69 100644 --- a/packages/stack-encrypt/src/dynamic/record.rs +++ b/packages/stack-encrypt/src/dynamic/record.rs @@ -56,7 +56,7 @@ use vitaminc_protected::Protected; use super::{borrowed, term, utf8, Error, Scalar, Scope, TermKind}; use crate::target::Pending; use crate::{ - AadPiece, BoxedPassthrough, CipherText, Encrypt, KeysetCipher, NonEmpty, StackCipherText, + BoxedPassthrough, CipherText, ContextPiece, Encrypt, KeysetCipher, NonEmpty, StackCipherText, }; /// What a plan field asks for. @@ -99,7 +99,7 @@ impl Output { #[derive(Clone, Debug)] pub struct FieldPlan { name: String, - context: NonEmpty<AadPiece<'static>>, + context: NonEmpty<ContextPiece<'static>>, outputs: Vec<Output>, } @@ -118,7 +118,7 @@ impl FieldPlan { /// [`Error::Plan`] if `outputs` is empty or names an output twice. pub fn new( name: impl Into<String>, - context: NonEmpty<AadPiece<'static>>, + context: NonEmpty<ContextPiece<'static>>, outputs: Vec<Output>, ) -> Result<Self, Error> { if outputs.is_empty() { @@ -142,7 +142,7 @@ impl FieldPlan { } /// The context this field binds under, on both halves. - pub fn context(&self) -> &NonEmpty<AadPiece<'static>> { + pub fn context(&self) -> &NonEmpty<ContextPiece<'static>> { &self.context } @@ -158,7 +158,7 @@ impl FieldPlan { /// A borrowed view of the context, so one proof serves every output of /// every row without copying the payloads. - fn view(&self) -> Result<NonEmpty<AadPiece<'_>>, Error> { + fn view(&self) -> Result<NonEmpty<ContextPiece<'_>>, Error> { // The proof was made when the plan was built, so re-taking it over // the same tree cannot fail. NonEmpty::new(borrowed(self.context.get())).map_err(|_| Error::Internal) @@ -271,7 +271,7 @@ pub fn plan(value: FfiValue) -> Result<Plan, Error> { let FfiValue::Object(spec) = spec else { return Err(Error::Plan); }; - let mut context: Option<NonEmpty<AadPiece<'static>>> = None; + let mut context: Option<NonEmpty<ContextPiece<'static>>> = None; let mut outputs: Option<Vec<Output>> = None; for (key, value) in spec { match key.as_str() { diff --git a/packages/stack-encrypt/src/lib.rs b/packages/stack-encrypt/src/lib.rs index fc9a258b2..30fb58bfa 100644 --- a/packages/stack-encrypt/src/lib.rs +++ b/packages/stack-encrypt/src/lib.rs @@ -79,7 +79,7 @@ assert_eq!(plaintext, "secret message"); //! module docs lay out the model. //! //! The second argument is the *associated data* (AAD): anything that implements -//! [`IntoAad`] — `()`, `&[u8]`, `&str`, a tuple, or a derived [`Aad`]. It is +//! [`IntoAad`] — `()`, `&[u8]`, `&str`, a tuple, or a derived [`Context`]. It is //! authenticated, not encrypted, and must be supplied identically on decrypt. //! Use it to bind a ciphertext to its context (a table name, a tenant, a record //! id) so it cannot be replayed elsewhere: @@ -289,16 +289,24 @@ pub use target::{ }; // Re-export the vitaminc AEAD surface callers need to drive the cipher, so they -// don't have to depend on `vitaminc-aead` directly for the common path. +// don't have to depend on `vitaminc-aead` directly for the common path. A +// context type of your own implements `IntoContext` once; `IntoAad` and +// `IntoPrfContext` are vitaminc's blankets over it, so both derivations see +// the same bytes. pub use vitaminc_aead::{ - Aad, AadPiece, Cipher, CipherText, ContextTag, Decipher, Decrypt, Element, Encrypt, IntoAad, - Unspecified, + Cipher, CipherText, Context, ContextPiece, ContextTag, Decipher, Decrypt, Element, Encrypt, + IntoAad, IntoContext, Unspecified, }; +// The vitaminc 0.4 names, deprecated there; re-exported for one transition so +// a caller that spelled them keeps compiling with a warning. +#[allow(deprecated)] +pub use vitaminc_aead::{Aad, AadPiece}; -// Likewise the PRF context surface: a context type of your own implements -// `IntoPrfContext` alongside `IntoAad`, and should not need a direct -// `vitaminc-prf` dependency for it. -pub use vitaminc_prf::{IntoPrfContext, PrfContext}; +// Likewise the PRF context surface, so a caller who needs the PRF view of a +// context has no direct `vitaminc-prf` dependency for it. +pub use vitaminc_prf::IntoPrfContext; +#[allow(deprecated)] +pub use vitaminc_prf::PrfContext; // And the proof every target-directed leaf asks for: a `NonEmpty<T>` is what // `encrypt_into_with_context` / `decrypt_into` take, built with `nonempty!` diff --git a/packages/stack-encrypt/src/sem/mod.rs b/packages/stack-encrypt/src/sem/mod.rs index 889bcba52..368f8ed0d 100644 --- a/packages/stack-encrypt/src/sem/mod.rs +++ b/packages/stack-encrypt/src/sem/mod.rs @@ -138,7 +138,7 @@ use std::marker::PhantomData; pub use cllw_ore::{CllwOpeEncrypt, CllwOreEncrypt}; use vitaminc_hmac::HmacSha256Prf; use vitaminc_prf::{ - BlockVisitor, IntoPrfContext, MapAccess, PrfContext, PrfError, PrfValue, PrfVisitor, + BlockVisitor, Context, IntoPrfContext, MapAccess, PrfError, PrfValue, PrfVisitor, PrfVisitorError, SeqAccess, }; use vitaminc_protected::NonEmpty; @@ -324,12 +324,12 @@ impl<P: Send + 'static> PrfVisitor<[u8; 32], P> for EqualityVisitor { fn equality<T>( prf: &HmacSha256Prf, value: T, - context: PrfContext<'_>, + context: Context<'_>, ) -> Result<EqualityTerm, TermError> where T: PrfValue, { - let context = PrfContext::pae(&[EQUALITY_DOMAIN, context.as_bytes()]); + let context = Context::pae(&[EQUALITY_DOMAIN, context.as_bytes()]); value .prf_visit_with_context(prf, context, EqualityVisitor) .into_result() @@ -628,7 +628,7 @@ impl<P: Send + 'static> PrfVisitor<[u8; 32], P> for BloomVisitor { fn match_term<O>( prf: &HmacSha256Prf, text: &str, - context: PrfContext<'_>, + context: Context<'_>, options: MatchOptions, ) -> Result<MatchTerm<O>, TermError> { let mask = options.validate()?; @@ -636,7 +636,7 @@ fn match_term<O>( if tokens.is_empty() { return Err(TermError::EmptyTermText); } - let context = PrfContext::pae(&[MATCH_DOMAIN, context.as_bytes()]); + let context = Context::pae(&[MATCH_DOMAIN, context.as_bytes()]); let positions = tokens .prf_visit_with_context(prf, context, BloomVisitor { k: options.k, mask }) @@ -899,7 +899,7 @@ where /// PRF — never the plaintext, which rides in the visitor and is encrypted /// there. Deterministic, so write-time and query-time terms agree; under a /// 2-party PRF backend this derivation is a visible ZeroKMS event. -fn ore<T>(prf: &HmacSha256Prf, value: T, context: PrfContext<'_>) -> Result<OreTerm<T>, TermError> +fn ore<T>(prf: &HmacSha256Prf, value: T, context: Context<'_>) -> Result<OreTerm<T>, TermError> where T: CllwOreEncrypt + Send + 'static, T::Output: Send + 'static, @@ -908,7 +908,7 @@ where context_bytes .prf_visit_with_context( prf, - PrfContext::pae(&[ORE_KEY_DOMAIN, context_bytes]), + Context::pae(&[ORE_KEY_DOMAIN, context_bytes]), OreVisitor(value), ) .into_result() @@ -919,7 +919,7 @@ where /// Derive an OPE term — as [`ore`], under the OPE domain so the two schemes /// never share a key. -fn ope<T>(prf: &HmacSha256Prf, value: T, context: PrfContext<'_>) -> Result<OpeTerm<T>, TermError> +fn ope<T>(prf: &HmacSha256Prf, value: T, context: Context<'_>) -> Result<OpeTerm<T>, TermError> where T: CllwOpeEncrypt + Send + 'static, T::Output: Send + 'static, @@ -928,7 +928,7 @@ where context_bytes .prf_visit_with_context( prf, - PrfContext::pae(&[OPE_KEY_DOMAIN, context_bytes]), + Context::pae(&[OPE_KEY_DOMAIN, context_bytes]), OpeVisitor(value), ) .into_result() diff --git a/packages/stack-encrypt/src/target/context.rs b/packages/stack-encrypt/src/target/context.rs index 742c70419..d953a5330 100644 --- a/packages/stack-encrypt/src/target/context.rs +++ b/packages/stack-encrypt/src/target/context.rs @@ -2,14 +2,12 @@ //! //! A target's associated `Context` is what a caller hands `encrypt_as` and //! `decrypt_as` alongside the value. The types here are the core-owned ones: -//! each holds Vitamin C's encodings of a nonempty context, so the structured -//! identity of its descriptor survives the trip into a boxed operation -//! description. A record that stores its own identifier declares +//! each holds the parts view of a nonempty context — the one tree both the +//! AEAD and the PRF encode from — so the structured identity of its +//! descriptor survives the trip into a boxed operation description. A record that stores its own identifier declares //! `NonEmpty<T>` instead, and a target whose declaration carries every //! context it needs declares `()`. -use crate::{ - Aad, AadPiece, Descriptor, Error, IntoAad, IntoPrfContext, MaybeEmpty, NonEmpty, PrfContext, -}; +use crate::{ContextPiece, Descriptor, Error, IntoContext, MaybeEmpty, NonEmpty}; /// Prove a context nonempty at the point it is used. The core-owned types /// are nonempty by construction, so for them this cannot fail; the one @@ -21,23 +19,19 @@ pub(super) fn nonempty<T: MaybeEmpty>(value: T) -> Result<NonEmpty<T>, Error> { /// An owned, validated context for a target that accepts any Vitamin C /// context and derives both ciphertext and terms from it. /// -/// Built from a `NonEmpty<T>` (or a bare integer), it holds the AEAD and PRF -/// encodings of that context, so the descriptor's structured identity is -/// preserved. Concrete records declare their own context type instead. +/// Built from a `NonEmpty<T>` (or a bare integer), it holds the parts view +/// of that context. The AEAD and the PRF encode the same tree to the same +/// bytes, so one tree serves both derivations and the descriptor's +/// structured identity is preserved. Concrete records declare their own +/// context type instead. #[derive(Clone, Debug)] -pub struct CallerContext { - aad: AadPiece<'static>, - prf: PrfContext<'static>, -} +pub struct CallerContext(ContextPiece<'static>); impl<'c, T> From<NonEmpty<T>> for CallerContext where - T: IntoAad<'c> + IntoPrfContext<'c> + Clone, + T: IntoContext<'c>, { fn from(context: NonEmpty<T>) -> Self { - Self { - aad: context.clone().into_aad_piece().into_owned(), - prf: context.into_prf_context().into_owned(), - } + Self(context.into_context().into_owned()) } } impl MaybeEmpty for CallerContext { @@ -45,17 +39,9 @@ impl MaybeEmpty for CallerContext { false } } -impl<'a> IntoAad<'a> for CallerContext { - fn into_aad(self) -> Aad<'a> { - self.aad.into_aad() - } - fn into_aad_piece(self) -> AadPiece<'a> { - self.aad - } -} -impl<'a> IntoPrfContext<'a> for CallerContext { - fn into_prf_context(self) -> PrfContext<'a> { - self.prf +impl<'a> IntoContext<'a> for CallerContext { + fn into_context(self) -> ContextPiece<'a> { + self.0 } } impl CallerContext { @@ -65,7 +51,7 @@ impl CallerContext { /// The own context `own`, extended by this caller context: the field's /// literal is the prefix, this context the extension, exactly as a /// `struct = T` derive composes them — `("users/age", id)`. The own - /// context is never discarded, and both encodings are preserved. + /// context is never discarded. pub fn extend(self, own: NonEmpty<&'static str>) -> Self { own.with(self).into() } @@ -73,22 +59,25 @@ impl CallerContext { /// An owned nonempty context for ciphertext-only operations. /// -/// Sealing needs only the AEAD encoding, so a type that implements `IntoAad` -/// without `IntoPrfContext` is enough here where it would not be for a -/// [`CallerContext`]. A derived record whose fields are all ciphertexts -/// declares it with `#[stash(context_type = AeadContext)]`, and then accepts -/// the same contexts the canonical [`StackCipherText`](crate::StackCipherText) -/// path does. +/// Since vitaminc 0.5 every context type implements one trait, +/// [`IntoContext`], and the AEAD and PRF derivations are both blankets over +/// it, so this type accepts exactly the contexts a [`CallerContext`] does. +/// It is kept as a distinct declaration because it says something a +/// `CallerContext` does not: the record it is declared on derives no terms. +/// A derived record whose fields are all ciphertexts declares it with +/// `#[stash(context_type = AeadContext)]`, and then accepts the same +/// contexts the canonical [`StackCipherText`](crate::StackCipherText) path +/// does. #[derive(Clone, Debug)] -pub struct AeadContext(AadPiece<'static>); -impl<'a, T: IntoAad<'a>> From<NonEmpty<T>> for AeadContext { +pub struct AeadContext(ContextPiece<'static>); +impl<'a, T: IntoContext<'a>> From<NonEmpty<T>> for AeadContext { fn from(value: NonEmpty<T>) -> Self { - Self(value.into_aad_piece().into_owned()) + Self(value.into_context().into_owned()) } } impl From<CallerContext> for AeadContext { fn from(value: CallerContext) -> Self { - Self(value.aad) + Self(value.0) } } impl MaybeEmpty for AeadContext { @@ -96,11 +85,8 @@ impl MaybeEmpty for AeadContext { false } } -impl<'a> IntoAad<'a> for AeadContext { - fn into_aad(self) -> Aad<'a> { - self.0.into_aad() - } - fn into_aad_piece(self) -> AadPiece<'a> { +impl<'a> IntoContext<'a> for AeadContext { + fn into_context(self) -> ContextPiece<'a> { self.0 } } @@ -163,7 +149,7 @@ impl From<CallerContext> for DeclaredContext { Self(Some(value)) } } -impl<'a, T: IntoAad<'a> + IntoPrfContext<'a> + Clone> From<NonEmpty<T>> for DeclaredContext { +impl<'a, T: IntoContext<'a>> From<NonEmpty<T>> for DeclaredContext { fn from(value: NonEmpty<T>) -> Self { Self(Some(value.into())) } @@ -221,7 +207,7 @@ impl<T> From<NonEmpty<T>> for ExpectedContext<T> { Self(Some(value)) } } -impl<'c, T: MaybeEmpty + PartialEq + IntoAad<'c>> ExpectedContext<T> { +impl<'c, T: MaybeEmpty + PartialEq + IntoContext<'c>> ExpectedContext<T> { /// Check the stored context against this expectation and prove it /// nonempty, yielding the context the record is opened under. /// @@ -235,7 +221,7 @@ impl<'c, T: MaybeEmpty + PartialEq + IntoAad<'c>> ExpectedContext<T> { pub fn validate(self, stored: T) -> Result<NonEmpty<T>, Error> { if let Some(expected) = self.0 { if expected.into_inner() != stored { - let stored = Descriptor::from_piece(&stored.into_aad_piece()); + let stored = Descriptor::from_piece(&stored.into_context()); return Err(Error::ContextMismatch { stored }); } } diff --git a/packages/stack-encrypt/src/target/core.rs b/packages/stack-encrypt/src/target/core.rs index 612bcd8ca..5817a8a55 100644 --- a/packages/stack-encrypt/src/target/core.rs +++ b/packages/stack-encrypt/src/target/core.rs @@ -2,7 +2,7 @@ use super::{CipherScope, Pending, Request, Responses}; use crate::cipher::{bind_keys, PendingStackCipherText, StackDecipher}; use crate::{Descriptor, Error, KeysetCipher, StackCipher, StackCipherText}; -use vitaminc_aead::{CipherText, Decrypt, Encrypt, IntoAad}; +use vitaminc_aead::{CipherText, Decrypt, Encrypt, IntoAad, IntoContext}; use vitaminc_protected::NonEmpty; /// Internal term operation. It is deliberately inaccessible to target authors. @@ -16,12 +16,12 @@ pub(crate) trait Term<S, K, Ctx>: Sized { Self: 'a; } -pub(crate) fn encrypt_native<'a, 'c, S: Encrypt + Clone, K, T: IntoAad<'c>>( +pub(crate) fn encrypt_native<'a, 'c, S: Encrypt + Clone, K, T: IntoContext<'c>>( source: &S, cipher: &'a KeysetCipher<'_, K>, context: NonEmpty<T>, ) -> Pending<'a, StackCipherText, K> { - let context = context.into_aad_piece(); + let context = context.into_context(); let descriptor = Descriptor::from_piece(&context); if let Err(error) = descriptor.check() { return Pending::failed(cipher, error); @@ -32,12 +32,12 @@ pub(crate) fn encrypt_native<'a, 'c, S: Encrypt + Clone, K, T: IntoAad<'c>>( Err(_) => Pending::ready(cipher, Err(Error::Aead)), } } -pub(crate) fn open_native<'a, 'c, P: Decrypt<'static> + 'static, K, T: IntoAad<'c>>( +pub(crate) fn open_native<'a, 'c, P: Decrypt<'static> + 'static, K, T: IntoContext<'c>>( tree: StackCipherText, cipher: &'a StackCipher<K>, context: NonEmpty<T>, ) -> Pending<'a, P, K> { - let context = context.into_aad_piece(); + let context = context.into_context(); let descriptor = Descriptor::from_piece(&context); // Fast path, as in `seal_pending`; `dispatch` is the gate. if let Err(e) = descriptor.check() { diff --git a/packages/stack-encrypt/src/target/request.rs b/packages/stack-encrypt/src/target/request.rs index 8a5cbc5a6..8d1b0480e 100644 --- a/packages/stack-encrypt/src/target/request.rs +++ b/packages/stack-encrypt/src/target/request.rs @@ -205,7 +205,7 @@ mod tests { use stack_kms::{DataKeySource, FakeDataKeySource, GenerateKeyPayload, RetrieveKeyPayload}; use super::*; - use crate::{Aad, IntoAad, MaybeEmpty, NonEmpty}; + use crate::{ContextPiece, IntoContext, MaybeEmpty, NonEmpty}; fn d() -> Descriptor { Descriptor::of("test/field") @@ -293,9 +293,9 @@ mod tests { self.0.is_empty() } } - impl<'a> IntoAad<'a> for Tenant { - fn into_aad(self) -> Aad<'a> { - self.0.into_aad() + impl<'a> IntoContext<'a> for Tenant { + fn into_context(self) -> ContextPiece<'a> { + self.0.into_context() } } diff --git a/packages/stack-encrypt/tests/derive.rs b/packages/stack-encrypt/tests/derive.rs index 83eed7363..c37c6f6d5 100644 --- a/packages/stack-encrypt/tests/derive.rs +++ b/packages/stack-encrypt/tests/derive.rs @@ -13,8 +13,8 @@ use common::{counting_cipher, stack_cipher}; use stack_encrypt::sem::{EqualityTerm, MatchTerm, OreTerm}; use stack_encrypt::target::{AeadContext, DecryptFrom, EncryptInto}; use stack_encrypt::{ - nonempty, Aad, DecryptField, DecryptInto, Decryptable, EncryptFrom, Error, IntoAad, MaybeEmpty, - NonEmpty, StackCipherText, + nonempty, ContextPiece, DecryptField, DecryptInto, Decryptable, EncryptFrom, Error, + IntoContext, MaybeEmpty, NonEmpty, StackCipherText, }; // --- Records: every field from one plaintext, under one context ------------- @@ -388,9 +388,9 @@ impl MaybeEmpty for Tenant { self.0.is_empty() } } -impl<'a> IntoAad<'a> for Tenant { - fn into_aad(self) -> Aad<'a> { - self.0.into_aad() +impl<'a> IntoContext<'a> for Tenant { + fn into_context(self) -> ContextPiece<'a> { + self.0.into_context() } } diff --git a/packages/stack-encrypt/tests/descriptor.rs b/packages/stack-encrypt/tests/descriptor.rs index f61ff8b4b..a59057e13 100644 --- a/packages/stack-encrypt/tests/descriptor.rs +++ b/packages/stack-encrypt/tests/descriptor.rs @@ -164,10 +164,10 @@ async fn a_column_renders_an_over_long_context_once() -> Result<(), Error> { #[derive(Clone)] struct Counted(Arc<AtomicUsize>); - impl<'a> stack_encrypt::IntoAad<'a> for Counted { - fn into_aad(self) -> stack_encrypt::Aad<'a> { + impl<'a> stack_encrypt::IntoContext<'a> for Counted { + fn into_context(self) -> stack_encrypt::ContextPiece<'a> { self.0.fetch_add(1, Ordering::SeqCst); - stack_encrypt::Aad::new_owned("a".repeat(Descriptor::MAX_LEN + 1).into_bytes()) + stack_encrypt::ContextPiece::Text("a".repeat(Descriptor::MAX_LEN + 1).into()) } } impl stack_encrypt::MaybeEmpty for Counted { diff --git a/packages/stack-encrypt/tests/frozen_bytes.rs b/packages/stack-encrypt/tests/frozen_bytes.rs index 0e58b4990..d8148614e 100644 --- a/packages/stack-encrypt/tests/frozen_bytes.rs +++ b/packages/stack-encrypt/tests/frozen_bytes.rs @@ -306,7 +306,7 @@ async fn match_term_bytes_are_pinned() { let bytes = term.to_bytes(); assert_eq!( hex(&bytes), - "2100220028002c002d0034003c003f005400620063006b0071007d007f0097009800a400a600a800a900d200d400fd00" + "040005000a000d000e001e002700350038003d0055005e005f0064006f007d007f009c00ad00bc00bd00ca00d000e000e500ef00" ); assert_eq!( MatchTerm::<DefaultMatch>::from_bytes(&bytes).expect("decode match term"), @@ -386,7 +386,7 @@ async fn ore_term_encoding_is_the_raw_cllw_bytes() { // differ (see the `sem` module docs). assert_eq!( hex(term.as_bytes()), - "d757854cffc68e9f3dfa9dba7ec400a30c80dd57122ebbc064eeff5a81069fc7" + "1ae5f8558dc2d7dddd6c5b714e9d285586a1b8390d9140421e78906cba1bd651" ); assert_eq!(term.to_bytes(), term.as_bytes()); assert_eq!(term.as_ref(), term.as_bytes()); @@ -412,7 +412,7 @@ async fn ope_term_encoding_is_the_raw_cllw_bytes() { assert_eq!( hex(term.as_bytes()), - "00470b57be663ba84635c72c1bdfa8ed263e7e57504002db51d3e695ba0b499833" + "00837615a1ea2fdcbebf7efe34cf4d2ee432c7eeff84fbd72e1bf05efa2338033c" ); assert_eq!( OpeTerm::<u32>::from_bytes(term.as_bytes()).expect("decode ope term"), diff --git a/packages/stack-encrypt/tests/term_bytes.rs b/packages/stack-encrypt/tests/term_bytes.rs index 6c33e98f7..35044ec23 100644 --- a/packages/stack-encrypt/tests/term_bytes.rs +++ b/packages/stack-encrypt/tests/term_bytes.rs @@ -9,6 +9,13 @@ //! //! Keyed by `FakeDataKeySource`'s deterministic index key, so the expected //! bytes are stable without ZeroKMS. +//! +//! The pins moved once without the derivation moving: vitaminc 0.5 changed +//! the canonical encoding of a context (typed leaves, one encoding for the +//! AEAD and the PRF), so the bytes under every label changed while the +//! labels and framing here did not. That was a prerelease wire break, taken +//! deliberately (CIP-4036); the `/v1` suffixes stayed because nothing of +//! this crate's own moved. use stack_encrypt::nonempty; use stack_encrypt::sem::DefaultMatch; @@ -38,7 +45,7 @@ async fn equality_term_bytes_are_pinned() { assert_eq!( hex(term.as_bytes()), - "81b963584feb41e517069477bd7fb568615ce724cb146451df6b6b4c16e43de1" + "c101ef066547cb33003c352dcd02a42bfe4a24e2d0609caca0189d9f1af8262a" ); } @@ -56,8 +63,8 @@ async fn match_term_positions_are_pinned() { assert_eq!( term.positions(), [ - 33, 34, 40, 44, 45, 52, 60, 63, 84, 98, 99, 107, 113, 125, 127, 151, 152, 164, 166, - 168, 169, 210, 212, 253 + 4, 5, 10, 13, 14, 30, 39, 53, 56, 61, 85, 94, 95, 100, 111, 125, 127, 156, 173, 188, + 189, 202, 208, 224, 229, 239 ] ); } @@ -75,7 +82,7 @@ async fn ore_term_bytes_are_pinned() { assert_eq!( hex(term.as_ref()), - "d757854cffc68e9f3dfa9dba7ec400a30c80dd57122ebbc064eeff5a81069fc7" + "1ae5f8558dc2d7dddd6c5b714e9d285586a1b8390d9140421e78906cba1bd651" ); } @@ -93,6 +100,6 @@ async fn ope_term_bytes_are_pinned() { assert_eq!( hex(term.as_ref()), - "00470b57be663ba84635c72c1bdfa8ed263e7e57504002db51d3e695ba0b499833" + "00837615a1ea2fdcbebf7efe34cf4d2ee432c7eeff84fbd72e1bf05efa2338033c" ); } diff --git a/packages/stack-encrypt/tests/transcode.rs b/packages/stack-encrypt/tests/transcode.rs index 8d44c6c07..e2225c0c0 100644 --- a/packages/stack-encrypt/tests/transcode.rs +++ b/packages/stack-encrypt/tests/transcode.rs @@ -5,9 +5,9 @@ use stack_encrypt::sem::EqualityTerm; use stack_encrypt::target::transcode::{MapReader, Reader, SequenceReader, Transcode, Visitor}; use stack_encrypt::target::{self, CallerContext, ExpectedContext}; use stack_encrypt::{ - nonempty, Aad, AadPiece, Cipher, CipherText, DecryptField, DecryptInto, Decryptable, - Decryption, Encrypt, EncryptFrom, Encryption, Error, IntoAad, IntoPrfContext, MaybeEmpty, - NonEmpty, PrfContext, SealedValue, StackCipherText, + nonempty, Cipher, CipherText, ContextPiece, DecryptField, DecryptInto, Decryptable, Decryption, + Encrypt, EncryptFrom, Encryption, Error, IntoAad, IntoContext, MaybeEmpty, NonEmpty, + SealedValue, StackCipherText, }; use std::sync::atomic::Ordering; @@ -30,17 +30,9 @@ impl MaybeEmpty for Identifier { self.table.is_empty() || self.column.is_empty() } } -impl<'a> IntoAad<'a> for Identifier { - fn into_aad(self) -> Aad<'a> { - (self.table, self.column).into_aad() - } - fn into_aad_piece(self) -> AadPiece<'a> { - (self.table, self.column).into_aad_piece() - } -} -impl<'a> IntoPrfContext<'a> for Identifier { - fn into_prf_context(self) -> PrfContext<'a> { - (self.table, self.column).into_prf_context() +impl<'a> IntoContext<'a> for Identifier { + fn into_context(self) -> ContextPiece<'a> { + (self.table, self.column).into_context() } } diff --git a/packages/stack-encrypt/tests/ui/pass/aead_only_context.rs b/packages/stack-encrypt/tests/ui/pass/aead_only_context.rs index ee79e2b58..e37ff991e 100644 --- a/packages/stack-encrypt/tests/ui/pass/aead_only_context.rs +++ b/packages/stack-encrypt/tests/ui/pass/aead_only_context.rs @@ -5,7 +5,7 @@ //! accepts. `tests/ui/aead_context_with_term.rs` pins the record such a //! context cannot declare. use stack_encrypt::target::{AeadContext, DecryptFrom, EncryptInto}; -use stack_encrypt::{Aad, DecryptInto, EncryptFrom, IntoAad, KeysetCipher, MaybeEmpty, NonEmpty, StackCipherText}; +use stack_encrypt::{ContextPiece, DecryptInto, EncryptFrom, IntoContext, KeysetCipher, MaybeEmpty, NonEmpty, StackCipherText}; use stack_kms::FakeDataKeySource; /// AEAD only: no `IntoPrfContext`, so it cannot derive a term. @@ -16,9 +16,9 @@ impl MaybeEmpty for Tenant { self.0.is_empty() } } -impl<'a> IntoAad<'a> for Tenant { - fn into_aad(self) -> Aad<'a> { - self.0.into_aad() +impl<'a> IntoContext<'a> for Tenant { + fn into_context(self) -> ContextPiece<'a> { + self.0.into_context() } } From db40c9cc8dedf40c21575b9faa0639163f994a77 Mon Sep 17 00:00:00 2001 From: Dan Draper <dan@cipherstash.com> Date: Tue, 22 Sep 2026 10:32:16 -0500 Subject: [PATCH 611/686] refactor(stack-encrypt): IntoContext bounds; restate descriptor invariant The cipher-directed entry points (`KeysetCipher::encrypt`/`decrypt`, `StackCipher::decrypt`, `seal`, `into_pending`, `Descriptor::of`) now bound on `IntoContext` like the target-directed ones, instead of the `IntoAad` blanket over it. Same set of types; one spelling. The descriptor docs claimed the rendering was injective over encodings, so ZeroKMS's binding was at least as strong as the AEAD's. vitaminc 0.5 tags every leaf with its type, so text/bytes of one content and signed/unsigned of one width now encode apart while still rendering alike: the descriptor is coarser than the AEAD there, not finer. The module docs, `from_piece` docs and the test that pinned the old premise now say so; the pre-encoded `Context` case is unchanged. Comments that described `AeadContext` as "AEAD only, no IntoPrfContext" predate the blankets and are reworded; the compile-fail still holds via the missing `From<AeadContext> for CallerContext`. --- packages/stack-encrypt/src/cipher.rs | 30 ++--- packages/stack-encrypt/src/descriptor.rs | 114 ++++++++++-------- packages/stack-encrypt/src/dynamic/context.rs | 2 +- packages/stack-encrypt/src/target/context.rs | 7 +- packages/stack-encrypt/src/target/request.rs | 14 +-- packages/stack-encrypt/tests/derive.rs | 5 +- .../tests/ui/aead_context_with_term.rs | 6 +- .../tests/ui/pass/aead_only_context.rs | 5 +- 8 files changed, 100 insertions(+), 83 deletions(-) diff --git a/packages/stack-encrypt/src/cipher.rs b/packages/stack-encrypt/src/cipher.rs index 212fecb28..1b073d7b1 100644 --- a/packages/stack-encrypt/src/cipher.rs +++ b/packages/stack-encrypt/src/cipher.rs @@ -97,7 +97,7 @@ use stack_kms::{EnvKeyProvider, StackKms, StackKmsBuilder}; use stack_kms::{FallbackKeyProvider, KeyProvider, KeyProviderError, ProfileStore}; use uuid::Uuid; use vitaminc_aead::{ - Cipher, CipherText, Context, Decipher, DecipherVisitor, Decrypt, Encrypt, IntoAad, + Cipher, CipherText, Context, Decipher, DecipherVisitor, Decrypt, Encrypt, IntoAad, IntoContext, LocalCipherText, MapAccess, MapCipher, SeqAccess, SeqCipher, Unspecified, }; use vitaminc_encrypt::{Aes256Cipher, AesCipherText, Key as AesKey}; @@ -686,11 +686,11 @@ impl<K: DataKeySource> KeysetCipher<'_, K> { pub async fn encrypt<'a, T, A>(&self, value: T, aad: A) -> Result<StackCipherText, Error> where T: Encrypt, - A: IntoAad<'a>, + A: IntoContext<'a>, { - let aad = aad.into_context(); - let pending = value.encrypt_with_aad(self, aad.clone().into_aad())?; - pending.seal(self, aad).await + let context = aad.into_context(); + let pending = value.encrypt_with_aad(self, context.clone().into_aad())?; + pending.seal(self, context).await } /// [`StackCipher::decrypt`], constrained to this keyset: a leaf sealed @@ -699,7 +699,7 @@ impl<K: DataKeySource> KeysetCipher<'_, K> { pub async fn decrypt<'a, T, A>(&self, ciphertext: StackCipherText, aad: A) -> Result<T, Error> where T: Decrypt<'static> + 'static, - A: IntoAad<'a>, + A: IntoContext<'a>, { decrypt_through(self, ciphertext, aad).await } @@ -722,7 +722,7 @@ impl<K: DataKeySource> KeysetCipher<'_, K> { async fn decipher_through<'s, 'a, K: DataKeySource + 's>( scope: impl crate::target::CipherScope<'s, K>, ciphertext: StackCipherText, - aad: impl IntoAad<'a>, + aad: impl IntoContext<'a>, ) -> Result<StackDecipher, Error> { crate::target::decipher_pending(scope, ciphertext, Descriptor::of(aad)) .settle() @@ -734,14 +734,14 @@ async fn decipher_through<'s, 'a, K: DataKeySource + 's>( async fn decrypt_through<'s, 'a, T, K: DataKeySource + 's>( scope: impl crate::target::CipherScope<'s, K>, ciphertext: StackCipherText, - aad: impl IntoAad<'a>, + aad: impl IntoContext<'a>, ) -> Result<T, Error> where T: Decrypt<'static> + 'static, { - let aad = aad.into_context(); - let decipher = decipher_through(scope, ciphertext, aad.clone()).await?; - T::decrypt_with_aad(decipher, aad.into_aad()).map_err(Error::from) + let context = aad.into_context(); + let decipher = decipher_through(scope, ciphertext, context.clone()).await?; + T::decrypt_with_aad(decipher, context.into_aad()).map_err(Error::from) } impl<K: DataKeySource> StackCipher<K> { @@ -774,7 +774,7 @@ impl<K: DataKeySource> StackCipher<K> { pub async fn decrypt<'a, T, A>(&self, ciphertext: StackCipherText, aad: A) -> Result<T, Error> where T: Decrypt<'static> + 'static, - A: IntoAad<'a>, + A: IntoContext<'a>, { decrypt_through(self, ciphertext, aad).await } @@ -1180,7 +1180,7 @@ impl PendingStackCipherText { pub async fn seal<'a, K: DataKeySource>( self, cipher: &KeysetCipher<'_, K>, - aad: impl IntoAad<'a>, + aad: impl IntoContext<'a>, ) -> Result<StackCipherText, Error> { self.into_pending(cipher, aad).settle().await } @@ -1205,13 +1205,13 @@ impl PendingStackCipherText { /// [`decrypt_into`](crate::target::DecryptInto) — a per-field record /// assembly in an FFI front-end, say — is bound by that layer's rule: it /// opens only under a [`NonEmpty`](crate::NonEmpty) context, so seal - /// under one here (a `NonEmpty<T>` is an [`IntoAad`] like any other, and + /// under one here (a `NonEmpty<T>` is an [`IntoContext`] like any other, and /// encodes exactly as `T` does) or the ciphertext can never be read that /// way. pub fn into_pending<'c, 'a, K>( self, cipher: &'a KeysetCipher<'_, K>, - aad: impl IntoAad<'c>, + aad: impl IntoContext<'c>, ) -> crate::target::Pending<'a, StackCipherText, K> { crate::target::seal_pending(cipher, self, Descriptor::of(aad)) } diff --git a/packages/stack-encrypt/src/descriptor.rs b/packages/stack-encrypt/src/descriptor.rs index dffae9b38..1dd8b4f7d 100644 --- a/packages/stack-encrypt/src/descriptor.rs +++ b/packages/stack-encrypt/src/descriptor.rs @@ -20,27 +20,32 @@ //! tag, so changing the rendering strands every key issued under the old //! one. //! -//! The descriptor follows the context's **parts**, not its encoded bytes. -//! It is injective over encodings — two contexts that encode to different -//! AAD bytes never share a descriptor, so ZeroKMS's binding is at least as -//! strong as the AEAD's — but it is *finer* than the encoding in two named -//! cases, where contexts with identical AAD bytes get different -//! descriptors and ZeroKMS refuses what the AEAD would open: +//! The descriptor follows the context's **parts**, not its encoded bytes, +//! so it and the AEAD encoding can disagree about whether two contexts are +//! one. They disagree in both directions, each in named cases: //! -//! * A pre-encoded [`Context`](vitaminc_aead::Context) is one opaque bytes part. +//! * The descriptor is *finer* for a pre-encoded +//! [`Context`](vitaminc_aead::Context), which is one opaque bytes part. //! `("tenant", 7u64)` renders `tenant|7u64`; the same tuple passed -//! through `into_aad()` first renders `b64:` + its encoded bytes. -//! * Shapes that encode alike render alike: text and bytes with the same -//! content render the same, and `7i64` renders as `7u64`. +//! through `into_aad()` first encodes to the same AAD bytes but renders +//! `b64:` + those bytes. ZeroKMS refuses what the AEAD would open. +//! * The descriptor is *coarser* for shapes that render alike but encode +//! apart: text and bytes with the same content render the same, and +//! `7i64` renders as `7u64`, but since vitaminc 0.5 every leaf carries +//! its type tag, so each pair is two contexts to the AEAD. ZeroKMS +//! issues one key for both and logs one descriptor; the AEAD still +//! refuses to open one under the other, so nothing opens that should +//! not, but the ZeroKMS binding alone does not separate them. //! //! So a value must be opened under the context in the same **shape** it was //! sealed under — the structured value both times, or the encoded `Context` -//! both times — not merely one with the same bytes. +//! both times, text or bytes as it was sealed — not merely one with the +//! same bytes, and not merely one with the same descriptor. use std::sync::Arc; use base64ct::{Base64, Encoding}; -use vitaminc_aead::{ContextPiece, IntoAad}; +use vitaminc_aead::{ContextPiece, IntoAad, IntoContext}; /// A context rendered as the string sent to ZeroKMS with every data-key /// request. See the [module docs](self). @@ -76,7 +81,7 @@ impl Descriptor { /// less than a plain one. pub const MAX_LEN: usize = stack_kms::MAX_DESCRIPTOR_LEN; - /// Render `context` — anything that encodes as AAD — from its parts. + /// Render `context` — any [`IntoContext`] type — from its parts. /// /// [`KeysetCipher::encrypt`](crate::KeysetCipher::encrypt) / /// [`StackCipher::decrypt`](crate::StackCipher::decrypt) and the target-directed @@ -102,7 +107,7 @@ impl Descriptor { /// assert_eq!(Descriptor::of(Some("")).as_str(), "(b64:)"); /// assert_eq!(Descriptor::of("a|b").as_str(), "b64:YXxi"); /// ``` - pub fn of<'a>(context: impl IntoAad<'a>) -> Self { + pub fn of<'a>(context: impl IntoContext<'a>) -> Self { Self::from_piece(&context.into_context()) } @@ -121,11 +126,13 @@ impl Descriptor { /// so `Some("")` is `(b64:)` and `None` is `()`. Text and bytes with /// the same bytes render the same, though since vitaminc 0.5 they /// encode differently: the rendering is of the parts, not the bytes. - /// * An **integer** part renders as its encoded bytes read as an - /// unsigned number, with the width as a suffix: `7u64`. Integers - /// encode as untagged little-endian bytes, so the width is part of the - /// rendering and the signedness is not: `7i64` is `7u64`, and `-3i32` - /// is `4294967293u32` — the bytes it encodes to. + /// * An **integer** part renders as its little-endian value bytes read + /// as an unsigned number, with the width as a suffix: `7u64`. The + /// width is part of the rendering and the signedness is not: `7i64` + /// is `7u64`, and `-3i32` is `4294967293u32`. Since vitaminc 0.5 the + /// leaf's type tag carries the signedness, so `7i64` and `7u64` are + /// two contexts to the AEAD; the rendering, frozen before that, does + /// not follow. /// * A **list** renders its parts joined by [`|`](Self::SEPARATOR). At /// the root, a list of two or more parts has no delimiters — /// `nonempty!("users/email").with(7u64)` is `users/email|7u64` — and @@ -135,10 +142,12 @@ impl Descriptor { /// renders as the empty string, which is what ZeroKMS receives when a /// caller opts out of descriptors. /// - /// The rendering is injective over encodings (the plain-text rule + /// The forms cannot be mistaken for one another (the plain-text rule /// reserves exactly the characters the other forms begin with or - /// contain), and finer than the encoding for a pre-encoded `Context` and for - /// shapes that happen to encode alike — see the [module docs](self). + /// contain), so distinct part trees render apart except where the + /// rendering is deliberately blind: text against bytes, and signed + /// against unsigned of one width. A pre-encoded `Context` renders + /// apart from the parts it was built from. See the [module docs](self). pub fn from_piece(piece: &ContextPiece<'_>) -> Self { let mut out = String::new(); Self::render(piece, true, &mut out); @@ -149,9 +158,10 @@ impl Descriptor { match piece { ContextPiece::Text(text) => Self::render_bytes(text.as_bytes(), root, out), ContextPiece::Bytes(bytes) => Self::render_bytes(bytes, root, out), - // Signed and unsigned of one width encode to the same - // little-endian bytes; `as` reinterprets, so they render the - // same too. + // Signed and unsigned of one width share their little-endian + // value bytes; `as` reinterprets, so they render the same. Their + // type tags differ on the AEAD side; the rendering is frozen + // and does not follow. ContextPiece::U8(v) => Self::render_int(v, "u8", out), ContextPiece::U16(v) => Self::render_int(v, "u16", out), ContextPiece::U32(v) => Self::render_int(v, "u32", out), @@ -180,8 +190,8 @@ impl Descriptor { } } // `ContextPiece` is `#[non_exhaustive]`: a part this crate does not - // know renders by its bytes, which is still injective (the - // base64 form is reserved) and still binds. + // know renders by its bytes, which cannot collide with a plain + // rendering (the base64 form is reserved) and still binds. other => Self::render_bytes(other.clone().into_aad().as_bytes(), root, out), } } @@ -348,29 +358,32 @@ mod tests { } #[test] - fn contexts_that_encode_alike_render_alike() { - let same = [ - (7u64.into_aad(), Descriptor::of(7u64), Descriptor::of(7i64)), - ( - (-3i32).into_aad(), - Descriptor::of(-3i32), - Descriptor::of(4_294_967_293u32), - ), - ( - "users/email".into_aad(), - Descriptor::of("users/email"), - Descriptor::of(b"users/email".as_slice()), - ), - (().into_aad(), Descriptor::of(()), Descriptor::of("")), - ( - ("a|b", 7u64).into_aad(), - Descriptor::of(("a|b", 7u64)), - Descriptor::of((b"a|b".as_slice(), 7i64)), - ), - ]; - for (aad, a, b) in same { - assert_eq!(a, b, "{a} vs {b} over {:?}", aad.as_bytes()); + fn shapes_that_render_alike_are_distinct_contexts() { + // Text against bytes of one content, and signed against unsigned of + // one width, render the same: the rendering was frozen before + // vitaminc 0.5 tagged every leaf with its type. To the AEAD each + // pair is two contexts, so the descriptor is coarser than the + // encoding here — see the module docs. + fn check<'a>(a: impl IntoAad<'a> + Clone, b: impl IntoAad<'a> + Clone) { + let (da, db) = (Descriptor::of(a.clone()), Descriptor::of(b.clone())); + assert_eq!(da, db, "expected one descriptor, got {da} vs {db}"); + assert_ne!( + a.into_aad().as_bytes(), + b.into_aad().as_bytes(), + "{da}: expected the AEAD to separate the two shapes" + ); } + check(7u64, 7i64); + check(-3i32, 4_294_967_293u32); + check("users/email", b"users/email".as_slice()); + check(("a|b", 7u64), (b"a|b".as_slice(), 7i64)); + + // The empty root renders as the empty string whatever its shape. + assert_eq!( + Descriptor::of(()), + Descriptor::of(""), + "the empty context and the empty text both render empty" + ); } #[test] @@ -390,7 +403,8 @@ mod tests { // they always did on the descriptor. assert_ne!( None::<&str>.into_aad().as_bytes(), - 0u64.into_aad().as_bytes() + 0u64.into_aad().as_bytes(), + "typed leaves separate the empty list from 0u64 on the AEAD side" ); assert_eq!(Descriptor::of(None::<&str>).as_str(), "()"); assert_eq!(Descriptor::of(0u64).as_str(), "0u64"); diff --git a/packages/stack-encrypt/src/dynamic/context.rs b/packages/stack-encrypt/src/dynamic/context.rs index d83ad8808..909c2197f 100644 --- a/packages/stack-encrypt/src/dynamic/context.rs +++ b/packages/stack-encrypt/src/dynamic/context.rs @@ -8,7 +8,7 @@ use vitaminc_protected::Controlled; use super::Error; use crate::{ContextPiece, NonEmpty}; -/// A context arrives from a binding as a value and becomes an [`ContextPiece`] +/// A context arrives from a binding as a value and becomes a [`ContextPiece`] /// tree: vitaminc's runtime form of a context, and the *identity* of one. /// vitaminc's law (pinned there by quickcheck over every built-in context /// type) is that a context's two derivations each equal the same derivation diff --git a/packages/stack-encrypt/src/target/context.rs b/packages/stack-encrypt/src/target/context.rs index d953a5330..614371984 100644 --- a/packages/stack-encrypt/src/target/context.rs +++ b/packages/stack-encrypt/src/target/context.rs @@ -4,9 +4,10 @@ //! `decrypt_as` alongside the value. The types here are the core-owned ones: //! each holds the parts view of a nonempty context — the one tree both the //! AEAD and the PRF encode from — so the structured identity of its -//! descriptor survives the trip into a boxed operation description. A record that stores its own identifier declares -//! `NonEmpty<T>` instead, and a target whose declaration carries every -//! context it needs declares `()`. +//! descriptor survives the trip into a boxed operation description. A +//! record that stores its own identifier declares `NonEmpty<T>` instead, +//! and a target whose declaration carries every context it needs declares +//! `()`. use crate::{ContextPiece, Descriptor, Error, IntoContext, MaybeEmpty, NonEmpty}; /// Prove a context nonempty at the point it is used. The core-owned types diff --git a/packages/stack-encrypt/src/target/request.rs b/packages/stack-encrypt/src/target/request.rs index 8d1b0480e..10257b69d 100644 --- a/packages/stack-encrypt/src/target/request.rs +++ b/packages/stack-encrypt/src/target/request.rs @@ -53,8 +53,8 @@ impl Request { /// at the extension point, and its AEAD context is the caller's to keep /// in agreement with this one. /// - /// A descriptor is rendered from the AEAD encoding alone, so the context - /// need only convert into an [`AeadContext`]: the `IntoAad`-only type a + /// A descriptor is rendered from the context's parts, so the context + /// need only convert into an [`AeadContext`]: any `IntoContext` type a /// [`StackCipherText`](crate::StackCipherText) seals under can request /// the key it seals with, and a [`CallerContext`](super::CallerContext) /// converts as it is. @@ -285,7 +285,7 @@ mod tests { } } - /// AEAD only: no `IntoPrfContext`, so it can seal but not derive a term. + /// A plain `IntoContext` type, as an `AeadContext` target declares. #[derive(Clone)] struct Tenant(String); impl MaybeEmpty for Tenant { @@ -299,10 +299,10 @@ mod tests { } } - /// A descriptor is rendered from the AEAD encoding alone, so the context - /// a `StackCipherText` seals under — one with `IntoAad` and nothing else - /// — can request the data key it seals with. Requiring a PRF-capable - /// context here would shut an `AeadContext`-only target out of the + /// A descriptor is rendered from the context's parts, so the context a + /// `StackCipherText` seals under — any `IntoContext` type, as an + /// `AeadContext` — can request the data key it seals with. Requiring a + /// `CallerContext` here would shut an `AeadContext` target out of the /// `Pending::request` extension point for no reason. #[test] fn an_aead_only_context_can_request_a_data_key() { diff --git a/packages/stack-encrypt/tests/derive.rs b/packages/stack-encrypt/tests/derive.rs index c37c6f6d5..b8bcd8157 100644 --- a/packages/stack-encrypt/tests/derive.rs +++ b/packages/stack-encrypt/tests/derive.rs @@ -379,8 +379,9 @@ async fn listed_plaintexts_each_get_their_own_impl() { assert_eq!((number, text.as_str()), (7, "seven")); } -/// A context type with the AEAD encoding alone: enough to seal, not to -/// derive a term. `WorkspaceId` in `cts-common` is the production shape. +/// A plain `IntoContext` type, declared through `AeadContext`: enough to +/// seal, not to derive a term. `WorkspaceId` in `cts-common` is the +/// production shape. #[derive(Clone, Debug, PartialEq)] struct Tenant(String); impl MaybeEmpty for Tenant { diff --git a/packages/stack-encrypt/tests/ui/aead_context_with_term.rs b/packages/stack-encrypt/tests/ui/aead_context_with_term.rs index 0944737f0..6ae723624 100644 --- a/packages/stack-encrypt/tests/ui/aead_context_with_term.rs +++ b/packages/stack-encrypt/tests/ui/aead_context_with_term.rs @@ -1,6 +1,6 @@ -//! An `AeadContext` carries only the AEAD encoding, so a record declaring it -//! cannot hold a term: the term's declaration wants a `CallerContext`, and -//! nothing converts an `AeadContext` into one. +//! An `AeadContext` is the context of a ciphertext-only record: nothing +//! converts it into the `CallerContext` a term's declaration wants, so a +//! record declaring it cannot hold a term. use stack_encrypt::sem::EqualityTerm; use stack_encrypt::target::AeadContext; use stack_encrypt::{EncryptFrom, StackCipherText}; diff --git a/packages/stack-encrypt/tests/ui/pass/aead_only_context.rs b/packages/stack-encrypt/tests/ui/pass/aead_only_context.rs index e37ff991e..bd8728ddd 100644 --- a/packages/stack-encrypt/tests/ui/pass/aead_only_context.rs +++ b/packages/stack-encrypt/tests/ui/pass/aead_only_context.rs @@ -1,4 +1,4 @@ -//! A context type that implements `IntoAad` alone is enough to seal a +//! A context type that implements `IntoContext` is enough to seal a //! ciphertext, so it must be enough for a derived record made only of //! ciphertexts: `#[stash(context_type = AeadContext)]` declares that, and the //! record then accepts exactly what the canonical `StackCipherText` path @@ -8,7 +8,8 @@ use stack_encrypt::target::{AeadContext, DecryptFrom, EncryptInto}; use stack_encrypt::{ContextPiece, DecryptInto, EncryptFrom, IntoContext, KeysetCipher, MaybeEmpty, NonEmpty, StackCipherText}; use stack_kms::FakeDataKeySource; -/// AEAD only: no `IntoPrfContext`, so it cannot derive a term. +/// Declared through `AeadContext` below: it can seal, but the record +/// derives no term under it. #[derive(Clone, Debug, PartialEq)] struct Tenant(String); impl MaybeEmpty for Tenant { From 329abf4baf9547cdba8c6058b2d38c6896914ef1 Mon Sep 17 00:00:00 2001 From: Dan Draper <dan@cipherstash.com> Date: Mon, 21 Sep 2026 22:46:16 -0500 Subject: [PATCH 612/686] feat(stack-guest-abi): share the guest ABI plumbing as a workspace crate The crypto guest's allocator and buffer registry (and with them the `se_alloc` / `se_dealloc` exports), packed-result and hostile-input helpers, status table, `transport_send` import and header line format move into `packages/stack-guest-abi`, `publish = false`, so the credential guest (ADR-0005) is written over them rather than copying them, and the memory-hygiene rules are fixed in one place. The status table is one numbering for every guest: the twelve codes keep their values, a guest that needs more appends after `LAST_STATUS`, and a test pins the table dense and distinct. The mapping from `stack_encrypt::Error` onto the numbers stays in the crypto guest, which re-exports the constants. `WasiHostConnection`, the `token_get` import, the user-agent and the ZeroKMS response handling stay there too: they name what that guest is built over. The exports are `#[no_mangle]` in a library crate; the cdylib that links it exports them, which the Go suite against the rebuilt module verifies. `wasm:guest:test` lints and doc-builds the crate on wasm32 first, since a path dependency is not linted from the guest's own workspace, and `wasm:wasi-check` checks it for the target with the other crates. Closes CIP-3997. --- .../golang/stackencrypt/guest/Cargo.lock | 8 + .../golang/stackencrypt/guest/Cargo.toml | 5 + .../golang/stackencrypt/guest/src/abi.rs | 149 ++-------------- .../golang/stackencrypt/guest/src/headers.rs | 98 +---------- .../golang/stackencrypt/guest/src/host.rs | 108 ++---------- .../golang/stackencrypt/guest/src/lib.rs | 18 +- .../golang/stackencrypt/guest/src/status.rs | 103 +++-------- packages/stack-guest-abi/Cargo.toml | 17 ++ packages/stack-guest-abi/LICENSE | 96 +++++++++++ packages/stack-guest-abi/src/abi.rs | 161 ++++++++++++++++++ .../stack-guest-abi}/src/buffers.rs | 21 +-- packages/stack-guest-abi/src/headers.rs | 99 +++++++++++ packages/stack-guest-abi/src/lib.rs | 92 ++++++++++ packages/stack-guest-abi/src/status.rs | 125 ++++++++++++++ packages/stack-guest-abi/src/transport.rs | 132 ++++++++++++++ 15 files changed, 809 insertions(+), 423 deletions(-) create mode 100644 packages/stack-guest-abi/Cargo.toml create mode 100644 packages/stack-guest-abi/LICENSE create mode 100644 packages/stack-guest-abi/src/abi.rs rename {languages/golang/stackencrypt/guest => packages/stack-guest-abi}/src/buffers.rs (94%) create mode 100644 packages/stack-guest-abi/src/headers.rs create mode 100644 packages/stack-guest-abi/src/lib.rs create mode 100644 packages/stack-guest-abi/src/status.rs create mode 100644 packages/stack-guest-abi/src/transport.rs diff --git a/languages/golang/stackencrypt/guest/Cargo.lock b/languages/golang/stackencrypt/guest/Cargo.lock index bbaea3a08..0c4e3afde 100644 --- a/languages/golang/stackencrypt/guest/Cargo.lock +++ b/languages/golang/stackencrypt/guest/Cargo.lock @@ -2009,6 +2009,7 @@ dependencies = [ "serde_json", "stack-auth", "stack-encrypt", + "stack-guest-abi", "stack-kms", "uuid", "vitaminc-aead-value", @@ -2017,6 +2018,13 @@ dependencies = [ "zerokms-protocol", ] +[[package]] +name = "stack-guest-abi" +version = "0.0.0" +dependencies = [ + "zeroize", +] + [[package]] name = "stack-kms" version = "0.1.0" diff --git a/languages/golang/stackencrypt/guest/Cargo.toml b/languages/golang/stackencrypt/guest/Cargo.toml index cda77ee3c..2a59d0b80 100644 --- a/languages/golang/stackencrypt/guest/Cargo.toml +++ b/languages/golang/stackencrypt/guest/Cargo.toml @@ -31,6 +31,11 @@ stack-auth = { path = "../../../../packages/stack-auth", default-features = fals stack-encrypt = { path = "../../../../packages/stack-encrypt", default-features = false, features = ["dynamic"] } stack-kms = { path = "../../../../packages/stack-kms", default-features = false } zerokms-protocol = { path = "../../../../packages/zerokms-protocol" } +# The guest ABI every guest under bindings/go shares: the allocator and +# buffer registry (and with them the `se_alloc`/`se_dealloc` exports), the +# packed-result helpers, the status table and the `transport_send` import. +# This crate defines only the exports that are its own. +stack-guest-abi = { path = "../../../../packages/stack-guest-abi" } # The FFI codec + `FfiValue` tree, shared with vitaminc's own guest — one # codec, not a fork. Same vitaminc version stack-encrypt builds against diff --git a/languages/golang/stackencrypt/guest/src/abi.rs b/languages/golang/stackencrypt/guest/src/abi.rs index 63323d0f2..c68eaca99 100644 --- a/languages/golang/stackencrypt/guest/src/abi.rs +++ b/languages/golang/stackencrypt/guest/src/abi.rs @@ -1,12 +1,9 @@ -//! The wasm export surface. Same conventions as the vitaminc guest -//! (`vc_*`), under the `se_` prefix: +//! This guest's wasm export surface: the cipher exports, over the +//! conventions every guest shares (`stack_guest_abi::abi`: `se_alloc` / +//! `se_dealloc`, the buffer registry, the packed `u64` result encoding, the +//! hostile-input validation of every `(ptr, len)` pair). What is specific +//! to this guest: //! -//! - The host owns all buffer lifecycles. It writes inputs into guest -//! memory obtained from [`se_alloc`] and releases every buffer — its own -//! inputs and the guest's outputs — with [`se_dealloc`], which **zeroizes -//! before freeing**. The guest keeps a registry of every buffer it hands -//! out (`crate::buffers`), so `se_dealloc` never trusts the host's -//! length. //! - **Every export handed plaintext wipes that buffer in place before it //! returns**, rather than leaving it for `se_dealloc`: [`se_cipher_init`] //! (the config carries the client key), and [`se_encrypt`], @@ -34,7 +31,7 @@ //! without it, key material would sit in freed host memory. After it, a //! well-formed cipher operation is `STATUS_STATE`, as one before //! [`se_cipher_init`] is, and so is a re-`se_cipher_init`. That is the -//! whole of the claim: [`se_alloc`] and [`se_dealloc`] return no status +//! whole of the claim: `se_alloc` and `se_dealloc` return no status //! and go on working — the host still has buffers to free — a second //! [`se_shutdown`] is a no-op, and a *malformed* call is //! `STATUS_ENCODING` in any state, because validation runs first (see @@ -44,26 +41,6 @@ //! / token); calling any other export from inside a host import is //! undefined behaviour of the embedding, not of this module. //! -//! # Result encoding -//! -//! Every fallible export returns a single `u64` split into a high and a low -//! 32-bit field: -//! -//! - **success** — the high 32 bits are non-zero: an output pointer with -//! the low 32 bits its length. -//! - **error** — the high 32 bits are zero and the low 32 bits are a -//! [`crate::status`] code. A valid pointer is never zero, so the two -//! spaces never collide. -//! -//! # Hostile-input posture -//! -//! As the vitaminc guest: every export validates its pointer/length pairs -//! against linear memory before any unsafe construction (null with nonzero -//! length rejected), invalid input yields `STATUS_ENCODING` rather than a -//! trap, and the `catch_unwind` at each export is belt-and-braces for a -//! hypothetical unwind build — wasm32-wasip1 aborts on panic. Statuses are -//! the only detail leaked. -//! //! The value exports ([`se_encrypt`] and friends) are the cipher-directed //! path and take the AAD as `KeysetCipher::encrypt` does: any bytes, none //! included — a null pointer with zero length is the empty AAD, as a Go @@ -91,12 +68,12 @@ use std::panic::{catch_unwind, AssertUnwindSafe}; use futures::executor::block_on; use stack_encrypt::{KeysetCipher, StackCipher}; +use stack_guest_abi::abi::{err_status, input, ok_buffer, take_plaintext, wipe_input}; +use stack_guest_abi::buffers; use stack_kms::{ClientOpts, StackKms}; use vitaminc_aead_value::transport as codec; use vitaminc_aead_value::FfiValue; -use zeroize::{Zeroize, Zeroizing}; -use crate::buffers; use crate::config::parse_config; use crate::host::{HostTokenStrategy, WasiHostConnection}; use crate::ops; @@ -118,113 +95,11 @@ thread_local! { static SHUT_DOWN: Cell<bool> = const { Cell::new(false) }; } -/// Allocate `len` bytes of guest memory for the host to write into. Returns -/// null if the allocation fails (recoverable host-side; never a trap). -#[no_mangle] -pub extern "C" fn se_alloc(len: u32) -> *mut u8 { - buffers::alloc(len as usize) -} - -/// Zeroize and free a buffer previously handed out by [`se_alloc`] or -/// packed into a result. See `crate::buffers::dealloc` for the registry -/// discipline (unknown pointer: no-op; length mismatch: refused). -/// -/// # Safety -/// -/// `ptr` should be a pointer this module handed out; the registry makes -/// anything else a no-op rather than undefined behaviour. -#[no_mangle] -pub unsafe extern "C" fn se_dealloc(ptr: *mut u8, len: u32) { - unsafe { buffers::dealloc(ptr, len as usize) } -} - -/// Pack a buffer result: `ptr << 32 | len`. The buffer is registered so the -/// host's eventual [`se_dealloc`] wipes and frees exactly what was -/// allocated. -fn ok_buffer(out: Vec<u8>) -> u64 { - let len = out.len() as u64; - let ptr = buffers::register(out) as usize as u64; - (ptr << 32) | len -} - -/// Pack an error: the status in the low 32 bits, high bits zero. -fn err_status(status: u32) -> u64 { - status as u64 -} - -/// Current linear-memory size in bytes. `u64` because a full 4 GiB memory -/// (65536 pages) overflows a 32-bit `usize`. -fn linear_memory_bytes() -> u64 { - core::arch::wasm32::memory_size::<0>() as u64 * 65536 -} - -/// Borrow a host-supplied `(ptr, len)` pair, validating before any slice -/// exists: null-with-nonzero-length is rejected (treating it as empty would -/// silently drop whatever bytes the host meant to pass), the length must be under -/// `isize::MAX`, and the whole range must lie inside the current linear -/// memory. A pair that fails validation yields `STATUS_ENCODING`; a pair -/// that passes can still name the wrong bytes — the host owns its pointers -/// — but can never fault or over-read past linear memory. -fn input<'a>(ptr: *const u8, len: u32) -> Result<&'a [u8], u32> { - let len = len as usize; - if len == 0 { - return Ok(&[]); - } - if ptr.is_null() || len > isize::MAX as usize { - return Err(STATUS_ENCODING); - } - let end = (ptr as usize).checked_add(len).ok_or(STATUS_ENCODING)?; - if end as u64 > linear_memory_bytes() { - return Err(STATUS_ENCODING); - } - // SAFETY: non-null, in-bounds of linear memory, and under `isize::MAX`; - // wasm linear memory is fully initialized (fresh pages are zero), so - // reading the range as bytes is defined. - Ok(unsafe { std::slice::from_raw_parts(ptr, len) }) -} - -/// Zeroize a validated input range in place (without freeing it — the host -/// still owns the buffer and will `se_dealloc` it after the call). -/// -/// # Safety -/// -/// The range must have passed [`input`] validation and carry no outstanding -/// borrows. -unsafe fn wipe_input(ptr: *mut u8, len: u32) { - if ptr.is_null() || len == 0 { - return; - } - unsafe { std::slice::from_raw_parts_mut(ptr, len as usize) }.zeroize(); -} - /// Decode one codec-encoded input. fn decode(bytes: &[u8]) -> Result<FfiValue, u32> { codec::decode_value(&mut codec::Reader::new(bytes)).map_err(|_| STATUS_ENCODING) } -/// Take a plaintext input out of the host's buffer and wipe the buffer. -/// -/// The exports below hold their decoded value across a ZeroKMS round trip, so -/// the borrow of the host buffer would otherwise outlive the call. Copying -/// into a `Zeroizing` first lets the original be wiped immediately: the -/// plaintext then exists for the duration of this call and no longer, instead -/// of sitting in linear memory until the host gets round to `se_dealloc`. -/// -/// Called before any other buffer is borrowed, deliberately. The wipe writes -/// through `&mut`, so no other `&[u8]` into linear memory may be live — and a -/// host that aliases its value range onto another argument therefore reads -/// zeros there, which the option parser rejects as `STATUS_ENCODING`. -/// -/// # Safety -/// -/// `ptr`/`len` must name a host buffer the caller is done with; it is zeroed -/// before this returns. -unsafe fn take_plaintext(ptr: *mut u8, len: u32) -> Result<Zeroizing<Vec<u8>>, u32> { - let taken = Zeroizing::new(input(ptr, len)?.to_vec()); - unsafe { wipe_input(ptr, len) }; - Ok(taken) -} - /// Run `f` with the instance's cipher, or report `STATUS_STATE` when there /// is none (never initialised, or shut down). fn with_cipher<R>(f: impl FnOnce(&GuestCipher) -> Result<R, u32>) -> Result<R, u32> { @@ -339,8 +214,8 @@ fn cipher_init(decoded: FfiValue) -> Result<Vec<u8>, u32> { /// Tear the instance down: drop the cipher — the client key and every /// loaded keyset's index key are wiped by `ZeroizeOnDrop` — and wipe every /// buffer the registry still holds, so nothing the host forgot to -/// [`se_dealloc`] survives in freed memory. Idempotent — a second call is a -/// no-op, and [`se_alloc`]/[`se_dealloc`] keep working so the host can +/// `se_dealloc` survives in freed memory. Idempotent — a second call is a +/// no-op, and `se_alloc`/`se_dealloc` keep working so the host can /// still free what it holds. Afterwards every well-formed cipher operation /// is `STATUS_STATE`, [`se_cipher_init`] included; a malformed one is /// `STATUS_ENCODING` first, as in any other state. @@ -401,7 +276,7 @@ pub unsafe extern "C" fn se_keyset(sel_ptr: *const u8, sel_len: u32) -> u64 { /// # Safety /// /// Pointer/length pairs should name buffers the host wrote via -/// [`se_alloc`]; each range is bounds-checked against linear memory (a bad +/// `se_alloc`; each range is bounds-checked against linear memory (a bad /// pair returns `STATUS_ENCODING` instead of faulting). #[no_mangle] pub unsafe extern "C" fn se_encrypt( @@ -438,7 +313,7 @@ pub unsafe extern "C" fn se_encrypt_element( /// tree; one batched key request per keyset the leaves were sealed under, /// dispatched as one `retrieve-data-key` call per 500 keyed leaves. The /// output buffer contains **plaintext** — the host must copy it out and -/// immediately release it with [`se_dealloc`] (which wipes it). +/// immediately release it with `se_dealloc` (which wipes it). /// /// `aad` must be the one the ciphertext was sealed under, empty included. /// `opts` constrains which keyset may be opened: `{"any"}` opens leaves from diff --git a/languages/golang/stackencrypt/guest/src/headers.rs b/languages/golang/stackencrypt/guest/src/headers.rs index f3ba44acf..c6d2421ff 100644 --- a/languages/golang/stackencrypt/guest/src/headers.rs +++ b/languages/golang/stackencrypt/guest/src/headers.rs @@ -1,14 +1,11 @@ -//! The header micro-format of the `transport_send` host import. -//! -//! Request and response headers cross the boundary as one UTF-8 buffer of -//! `name: value` lines separated by `\n` (HTTP/1.1 field syntax, minus -//! folding) — trivially encoded and decoded on both sides without pulling -//! the value codec into the transport layer. Names compare -//! ASCII-case-insensitively, as in HTTP. Pure functions, unit-tested on the -//! native target. +//! The headers this guest's ZeroKMS requests carry, over the wire format +//! every guest shares ([`stack_guest_abi::headers`]: `name: value` lines, +//! re-exported here for the modules that read a response). use std::sync::OnceLock; +pub use stack_guest_abi::headers::{encode_headers, header_value}; + /// The host this guest is driven by, as it appears in [`user_agent`]. /// /// One token, because today there is one build. `stack-encrypt-ffi` (the @@ -55,83 +52,10 @@ pub fn request_headers(authorization: &str) -> Vec<u8> { ]) } -/// Encode header pairs as the wire buffer. -pub fn encode_headers(headers: &[(&str, &str)]) -> Vec<u8> { - let mut out = String::new(); - for (i, (name, value)) in headers.iter().enumerate() { - if i > 0 { - out.push('\n'); - } - out.push_str(name); - out.push_str(": "); - out.push_str(value); - } - out.into_bytes() -} - -/// Look up a header by (ASCII-case-insensitive) name in a wire buffer. -/// Malformed lines (no colon, non-UTF-8 bytes) are skipped rather than -/// failing the response: the transport's contract is carried by the status -/// and body, and header parsing must not be a denial-of-service lever. The -/// skip is per *line*, not per buffer — a proxy that emits one raw -/// ISO-8859-1 byte in an unrelated header (a `via`/`server` line, say) must -/// not make the `content-type` lookup fail and with it every KMS call. -pub fn header_value<'a>(buffer: &'a [u8], name: &str) -> Option<&'a str> { - buffer.split(|&b| b == b'\n').find_map(|line| { - let line = std::str::from_utf8(line).ok()?; - let (n, v) = line.split_once(':')?; - n.trim().eq_ignore_ascii_case(name).then(|| v.trim()) - }) -} - #[cfg(test)] mod tests { use super::*; - #[test] - fn round_trips_and_matches_case_insensitively() { - let buffer = encode_headers(&[ - ("authorization", "Bearer tok"), - ("content-type", "application/json"), - ]); - assert_eq!( - std::str::from_utf8(&buffer).unwrap(), - "authorization: Bearer tok\ncontent-type: application/json", - "headers encode one per line, lower-cased, without a trailing newline" - ); - assert_eq!( - header_value(&buffer, "Content-Type"), - Some("application/json"), - "a header is found whatever the case it is asked for in" - ); - assert_eq!(header_value(&buffer, "authorization"), Some("Bearer tok")); - assert_eq!(header_value(&buffer, "x-missing"), None); - } - - #[test] - fn tolerates_whitespace_and_skips_malformed_lines() { - assert_eq!( - header_value(b"Content-Type: text/html \ngarbage-line", "content-type"), - Some("text/html"), - "surrounding whitespace is trimmed and a line without a colon is skipped" - ); - assert_eq!( - header_value(b"no colon here", "content-type"), - None, - "a buffer with no well-formed line has no headers" - ); - assert_eq!( - header_value(&[0xff, 0xfe], "content-type"), - None, - "a buffer that is not UTF-8 has no headers" - ); - assert_eq!( - header_value(b"", "content-type"), - None, - "an empty buffer has no headers" - ); - } - /// The edge in front of production ZeroKMS answers a request with no /// `user-agent` with a bare nginx 403, before the application sees it. /// Every request must carry one, and it must not be a host runtime's @@ -160,16 +84,4 @@ mod tests { "the content type travels with the user-agent" ); } - - #[test] - fn a_non_utf8_line_does_not_poison_the_other_headers() { - let mut buffer = b"server: pro".to_vec(); - buffer.push(0xe9); // "proxé" in raw ISO-8859-1 - buffer.extend_from_slice(b"\ncontent-type: application/json"); - assert_eq!( - header_value(&buffer, "content-type"), - Some("application/json"), - "a header after a non-UTF-8 line is still found" - ); - } } diff --git a/languages/golang/stackencrypt/guest/src/host.rs b/languages/golang/stackencrypt/guest/src/host.rs index b86ec6cf9..f8d55e160 100644 --- a/languages/golang/stackencrypt/guest/src/host.rs +++ b/languages/golang/stackencrypt/guest/src/host.rs @@ -6,25 +6,18 @@ //! # Import contract (module `cipherstash_transport`) //! //! All pointers are offsets into guest linear memory; the host allocates -//! guest buffers with `se_alloc` and the guest reclaims them through its -//! registry (`crate::buffers`). +//! guest buffers with `se_alloc` and the guest reclaims them through the +//! shared registry (`stack_guest_abi::buffers`). //! -//! - `transport_send(method, url, headers, body, resp_headers_out, -//! resp_body_out) -> status` — perform one HTTP request. Each of the four -//! inputs is a `(ptr, len)` pair borrowed for the duration of the call; -//! `headers` is the `name: value` line format of [`crate::headers`]. The -//! two outputs are `(ptr_out, len_out)` slot pairs the host fills with -//! `se_alloc`'d buffers (response headers, response body). The return -//! value is the HTTP status code, or negative for a transport-level -//! failure — then the body carries the host's error text and headers are -//! empty. This is #2099's ZeroKMS-shaped import generalised to a plain -//! HTTP request (method + URL + headers), so `stack-auth`'s refreshers -//! can reuse it later and a future `wasi:http` implementation can replace -//! it without changing the connection seam. +//! - `transport_send(..)` — perform one HTTP request. The import and its +//! contract are `stack_guest_abi::transport`'s, shared with every guest; +//! this module only builds ZeroKMS requests over it. //! - `token_get(token_out) -> status` — hand over the current bearer token //! (Phase-1 auth: minting and refresh stay on the host). `token_out` is a -//! `(ptr_out, len_out)` slot pair filled the same way; status `0` is -//! success, anything else a host-side failure. +//! `(ptr_out, len_out)` slot pair filled with an `se_alloc`'d buffer; +//! status `0` is success, anything else a host-side failure. This +//! guest's own: the credential guest supplies tokens rather than asking +//! for them. //! //! What crosses the boundary per ZeroKMS call is exactly what would cross //! TLS anyway: the URL, the bearer token, and the serialized protocol @@ -36,51 +29,23 @@ //! are reclaimed via the registry (which the ABI's `se_dealloc` also wipes). use std::convert::Infallible; -use std::fmt; use std::sync::Mutex; use stack_auth::{AuthError, AuthStrategy, CustomError, SecretToken, ServiceToken}; +use stack_guest_abi::buffers; +use stack_guest_abi::transport; use stack_kms::{BaseUrlUnresolved, ZeroKMSConnection, ZeroKMSConnectionInit, ZeroKmsEndpoint}; use zeroize::Zeroizing; use zerokms_protocol::{ViturRequest, ViturRequestError}; -use crate::buffers; use crate::headers::{header_value, request_headers}; use crate::response::map_response; #[link(wasm_import_module = "cipherstash_transport")] extern "C" { - fn transport_send( - method_ptr: *const u8, - method_len: u32, - url_ptr: *const u8, - url_len: u32, - headers_ptr: *const u8, - headers_len: u32, - body_ptr: *const u8, - body_len: u32, - resp_headers_ptr_out: *mut u32, - resp_headers_len_out: *mut u32, - resp_body_ptr_out: *mut u32, - resp_body_len_out: *mut u32, - ) -> i32; - fn token_get(token_ptr_out: *mut u32, token_len_out: *mut u32) -> i32; } -/// The host stored an out-slot pointer the guest's buffer registry does not -/// know (or with a mismatched length) — a host-side bookkeeping bug. -#[derive(Debug)] -struct HostBufferError; - -impl fmt::Display for HostBufferError { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "host returned an unregistered or mismatched buffer") - } -} - -impl std::error::Error for HostBufferError {} - /// A [`ZeroKMSConnection`] whose transport is the `transport_send` host /// import. The host call is synchronous from the guest's perspective, so /// `send` resolves immediately — `block_on` in the ABI layer never parks. @@ -160,51 +125,14 @@ impl ZeroKMSConnection for WasiHostConnection { let auth = Zeroizing::new(format!("Bearer {access_token}")); let headers = Zeroizing::new(request_headers(auth.as_str())); - let method = b"POST"; - let mut resp_headers_ptr: u32 = 0; - let mut resp_headers_len: u32 = 0; - let mut resp_body_ptr: u32 = 0; - let mut resp_body_len: u32 = 0; - - // SAFETY: every input pair names a live guest allocation borrowed - // for the call; the out-slots are stack locals the host writes once. - let status = unsafe { - transport_send( - method.as_ptr(), - method.len() as u32, - url.as_str().as_ptr(), - url.as_str().len() as u32, - headers.as_ptr(), - headers.len() as u32, - body.as_ptr(), - body.len() as u32, - &mut resp_headers_ptr, - &mut resp_headers_len, - &mut resp_body_ptr, - &mut resp_body_len, - ) - }; - - // Reclaim *both* slots before judging either. A `?` on the headers - // slot would otherwise strand the body buffer — a 2xx JSON body full - // of wrapped data keys — registered, unfreed and unwiped for the life - // of the instance. - // - // SAFETY: pointers come from the host's `se_alloc` calls; the - // registry validates them before any Vec is rebuilt. - let resp_headers = - unsafe { buffers::take(resp_headers_ptr as *mut u8, resp_headers_len as usize) }; - // Response bodies carry wrapped key material — wipe on drop. - let resp_body = unsafe { buffers::take(resp_body_ptr as *mut u8, resp_body_len as usize) } - .map(Zeroizing::new); - - let unregistered = - || ViturRequestError::parse("Host response buffer failed validation", HostBufferError); - let resp_headers = resp_headers.ok_or_else(unregistered)?; - let resp_body = resp_body.ok_or_else(unregistered)?; + // The shared import reclaims both response slots before judging + // either, and the body it hands back wipes on drop (it carries + // wrapped key material). + let response = transport::send(b"POST", url.as_str(), &headers, &body) + .map_err(|e| ViturRequestError::parse("Host response buffer failed validation", e))?; - let content_type = header_value(&resp_headers, "content-type"); - map_response(status, content_type, &resp_body) + let content_type = header_value(&response.headers, "content-type"); + map_response(response.status, content_type, &response.body) } } diff --git a/languages/golang/stackencrypt/guest/src/lib.rs b/languages/golang/stackencrypt/guest/src/lib.rs index a6bee93ee..93f6aa365 100644 --- a/languages/golang/stackencrypt/guest/src/lib.rs +++ b/languages/golang/stackencrypt/guest/src/lib.rs @@ -1,8 +1,9 @@ // Security lints — the block `stack-encrypt` and `stack-auth` carry, minus -// `deny(unsafe_code)`: the export surface (`abi`) and the two host imports +// `deny(unsafe_code)`: the export surface (`abi`) and the token import // (`host`) are `extern "C"` over raw pointers by nature. Every `unsafe` // block is confined to those two wasm32-only modules and documented at the -// site; `unsafe_op_in_unsafe_fn` keeps each one explicit. +// site; `unsafe_op_in_unsafe_fn` keeps each one explicit. The allocator, +// the buffer registry and the transport import are `stack-guest-abi`'s. #![deny(unsafe_op_in_unsafe_fn)] #![warn(clippy::unwrap_used)] #![warn(clippy::expect_used)] @@ -57,9 +58,11 @@ //! `StackCipher<K>` / `KeysetCipher<K>` / bytes. Compiles and unit-tests //! on the native host target (`cargo test` here, no wasm toolchain //! needed) against `stack_kms::FakeDataKeySource`. -//! - [`abi`], [`host`], `buffers` (wasm32 only) — the export surface, -//! the two host imports, and the buffer registry. See [`abi`]'s module -//! docs for the full ABI contract. +//! - [`abi`], [`host`] (wasm32 only) — this guest's export surface and its +//! token import. The conventions every guest shares — `se_alloc` / +//! `se_dealloc`, the buffer registry, the packed result encoding, the +//! status table, the `transport_send` import — are `stack_guest_abi`'s; +//! [`abi`]'s module docs give this guest's contract on top of them. //! //! On wasm32 `vitaminc-encrypt` uses its pure-Rust (RustCrypto `aes-gcm`) //! backend; the trade-offs are documented there. Values cross the boundary @@ -83,10 +86,5 @@ pub mod status; // truncate a 64-bit pointer). #[cfg(target_arch = "wasm32")] pub mod abi; -// Target-independent (plain `Vec`s and raw pointers, no linear-memory -// reads), so it exists natively for its unit tests — the -// empty-buffer accounting in particular is pinned there. -#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))] -pub(crate) mod buffers; #[cfg(target_arch = "wasm32")] pub mod host; diff --git a/languages/golang/stackencrypt/guest/src/status.rs b/languages/golang/stackencrypt/guest/src/status.rs index daaa9208e..563602f51 100644 --- a/languages/golang/stackencrypt/guest/src/status.rs +++ b/languages/golang/stackencrypt/guest/src/status.rs @@ -1,93 +1,30 @@ -//! Status codes for the ABI's packed result encoding (see [`crate::abi`]), -//! and the mapping from [`stack_encrypt::Error`] onto them. +//! The mapping from [`stack_encrypt::Error`] (and the dynamic and KMS +//! errors under it) onto the status table. //! -//! Defined outside the wasm32-gated ABI module so native builds — the ops -//! unit tests — can reference them too. The Go host mirrors these values; -//! they are part of the guest/host contract and must not be renumbered. +//! The numbers themselves are [`stack_guest_abi::status`]'s — one table for +//! every guest, never renumbered, decoded once by the Go host — and are +//! re-exported here so this crate's modules and tests name them as they +//! always have. Defined outside the wasm32-gated ABI module so native builds +//! — the ops unit tests — can reference them too. //! -//! Codes 1–4 are byte-for-byte the vitaminc guest's codes (`vcencrypt`'s -//! `status.rs`), so the two guests read identically from the host side; -//! code 3 there is "unknown handle", and here — where there is no handle — -//! it is the call-order violation that means the same thing to a host: no -//! cipher for this call. Codes 5–10 map the ZeroKMS request outcomes -//! ([`ViturRequestErrorKind`]-shaped) so a Go caller can distinguish a bad -//! token from a tampered ciphertext without parsing strings. Code 11 is a -//! term-derivation failure (a caller-input condition, e.g. match text that -//! yields no tokens). Code 12 is a keyset-scoped open refusing a leaf whose -//! keyset id is not the scope's — a host's own constraint, checked before -//! the leaf is authenticated and so not a statement about tampering. +//! What this guest decides is *which* number a given failure is. Codes 5–10 +//! map the ZeroKMS request outcomes ([`ViturRequestErrorKind`]-shaped) so a +//! Go caller can distinguish a bad token from a tampered ciphertext without +//! parsing strings; 11 is a term-derivation failure (a caller-input +//! condition, e.g. match text that yields no tokens); 12 is a keyset-scoped +//! open refusing a leaf whose keyset id is not the scope's — a host's own +//! constraint, checked before the leaf is authenticated and so not a +//! statement about tampering. use stack_auth::AuthError; use stack_kms::{GenerateKeyError, LoadKeysetError, RetrieveKeyError}; use zerokms_protocol::ViturRequestErrorKind; -/// AEAD open failure: a tampered ciphertext, a wrong element derivation, or -/// a wrong AAD that reached the AEAD. Against ZeroKMS a wrong AAD does not -/// get that far — every data key is bound to its context's descriptor, so -/// the retrieve is refused first, as [`STATUS_KMS_FORBIDDEN`]. Only a key -/// source that ignores descriptors (the native tests' fake) reports a wrong -/// AAD here. -pub const STATUS_AUTH: u32 = 1; -/// Invalid input at the boundary: malformed transport bytes, a malformed -/// cipher config, an empty encryption context, or a pointer/length pair that -/// fails validation against linear memory. -pub const STATUS_ENCODING: u32 = 2; -/// The call is out of order: an operation before `se_cipher_init`, or -/// after `se_shutdown`, or `se_cipher_init` twice. A host fixes its call -/// sequence; nothing here is a guest bug. (The vitaminc guest's code 3 is -/// "unknown handle", the same condition under a handle scheme.) -pub const STATUS_STATE: u32 = 3; -/// A caught panic, a response that did not match its -/// requests, or any other unexpected internal failure. -pub const STATUS_INTERNAL: u32 = 4; -/// ZeroKMS (or the auth strategy) rejected the *credential*: an expired or -/// rejected access token, or a credential exchange the server refused. -/// -/// The one status a host should answer by refreshing the token and retrying. -/// Deliberately narrow for that reason: a configuration fault that merely -/// *arrives* through the auth strategy — a token with no ZeroKMS `services` -/// claim, a host `token_get` that failed — is [`STATUS_KMS_TRANSPORT`], since -/// no number of refreshes can fix it. -pub const STATUS_KMS_UNAUTHORIZED: u32 = 5; -/// ZeroKMS rejected the request as forbidden: the token is valid but lacks -/// permission, the keyset is disabled, the organisation is over its usage -/// allowance — or, on decrypt, the AAD/context is not the one the value -/// was sealed under, so the data key cannot be re-derived. That last one is -/// the production form of a wrong-context open; see [`STATUS_AUTH`]. -pub const STATUS_KMS_FORBIDDEN: u32 = 6; -/// ZeroKMS could not find the resource: an unknown keyset (or client), or a -/// data key that does not exist for the presented `iv`/`tag`. -pub const STATUS_KMS_NOT_FOUND: u32 = 7; -/// ZeroKMS reported a resource conflict. -pub const STATUS_KMS_CONFLICT: u32 = 8; -/// No ZeroKMS verdict was reached: the host's `transport_send` errored, the -/// host's `token_get` errored, the endpoint is unknown or invalid (no -/// `zerokms_url` in the config *and* no ZeroKMS entry in the token's -/// `services` claim), or the request could not be prepared. -/// -/// Not retryable by refreshing a token — these are configuration or host -/// faults. See [`STATUS_KMS_UNAUTHORIZED`] for the one that is. -pub const STATUS_KMS_TRANSPORT: u32 = 9; -/// ZeroKMS failed in a way none of the codes above capture: a malformed -/// response, invalid key material, or an unclassified server error. -pub const STATUS_KMS_OTHER: u32 = 10; -/// An index term failed to derive: e.g. match text that yields no tokens, or -/// a value/scheme combination the term does not support. -pub const STATUS_TERM: u32 = 11; -/// An opening export was constrained to one keyset (`{"name"}`, `{"id"}` or -/// `{"default"}` in its options) and the leaf named another. Refused before -/// any key is retrieved. A host that means "whichever keyset" opens with -/// `{"any"}`. -/// -/// A constraint failure, and only that — never provenance. The comparison -/// reads the keyset id *out of the leaf*, before anything is retrieved and -/// so before anything is authenticated, which means a flipped byte in that -/// field arrives here exactly as a genuinely misrouted row does. The id is -/// bound into the leaf AAD, so the tampered leaf cannot go on to open — -/// it fails as [`STATUS_AUTH`] — but that verdict is only reached on the -/// path where the constraint let it through. Read this status as "not this -/// keyset's row", never as "an untampered row". -pub const STATUS_FOREIGN_KEYSET: u32 = 12; +pub use stack_guest_abi::status::{ + STATUS_AUTH, STATUS_ENCODING, STATUS_FOREIGN_KEYSET, STATUS_INTERNAL, STATUS_KMS_CONFLICT, + STATUS_KMS_FORBIDDEN, STATUS_KMS_NOT_FOUND, STATUS_KMS_OTHER, STATUS_KMS_TRANSPORT, + STATUS_KMS_UNAUTHORIZED, STATUS_STATE, STATUS_TERM, +}; /// Map a sealing/opening error onto the ABI status word. /// diff --git a/packages/stack-guest-abi/Cargo.toml b/packages/stack-guest-abi/Cargo.toml new file mode 100644 index 000000000..2dbef0e42 --- /dev/null +++ b/packages/stack-guest-abi/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "stack-guest-abi" +description = "The WASI guest ABI the Go binding's guests share: allocator and buffer registry, status table, host transport import" +version = "0.0.0" +edition.workspace = true +authors.workspace = true +repository.workspace = true +homepage.workspace = true +keywords.workspace = true +categories.workspace = true +license-file = "LICENSE" +# Internal plumbing for the guests under bindings/go, never a release of its +# own; also keeps release-plz from picking it up. +publish = false + +[dependencies] +zeroize = { workspace = true } diff --git a/packages/stack-guest-abi/LICENSE b/packages/stack-guest-abi/LICENSE new file mode 100644 index 000000000..2cbd67a66 --- /dev/null +++ b/packages/stack-guest-abi/LICENSE @@ -0,0 +1,96 @@ +# PolyForm Internal Use License 1.0.0 + +<https://polyformproject.org/licenses/internal-use/1.0.0> + +## Acceptance + +In order to get any license under these terms, you must agree +to them as both strict obligations and conditions to all +your licenses. + +## Copyright License + +The licensor grants you a copyright license for the software +to do everything you might do with the software that would +otherwise infringe the licensor's copyright in it for any +permitted purpose. However, you may only make changes or +new works based on the software according to [Changes and New +Works License](#changes-and-new-works-license), and you may +not distribute the software. + +## Changes and New Works License + +The licensor grants you an additional copyright license to +make changes and new works based on the software for any +permitted purpose. + +## Patent License + +The licensor grants you a patent license for the software that +covers patent claims the licensor can license, or becomes able +to license, that you would infringe by using the software. + +## Fair Use + +You may have "fair use" rights for the software under the +law. These terms do not limit them. + +## Internal Business Use + +Use of the software for the internal business operations of +you and your company is use for a permitted purpose. + +## No Other Rights + +These terms do not allow you to sublicense or transfer any of +your licenses to anyone else, or prevent the licensor from +granting licenses to anyone else. These terms do not imply +any other licenses. + +## Patent Defense + +If you make any written claim that the software infringes or +contributes to infringement of any patent, your patent license +for the software granted under these terms ends immediately. If +your company makes such a claim, your patent license ends +immediately for work on behalf of your company. + +## Violations + +The first time you are notified in writing that you have +violated any of these terms, or done anything with the software +not covered by your licenses, your licenses can nonetheless +continue if you come into full compliance with these terms, +and take practical steps to correct past violations, within +32 days of receiving notice. Otherwise, all your licenses +end immediately. + +## No Liability + +***As far as the law allows, the software comes as is, without +any warranty or condition, and the licensor will not be liable +to you for any damages arising out of these terms or the use +or nature of the software, under any kind of legal claim.*** + +## Definitions + +The **licensor** is the individual or entity offering these +terms, and the **software** is the software the licensor makes +available under these terms. + +**You** refers to the individual or entity agreeing to these +terms. + +**Your company** is any legal entity, sole proprietorship, +or other kind of organization that you work for, plus all +organizations that have control over, are under the control of, +or are under common control with that organization. **Control** +means ownership of substantially all the assets of an entity, +or the power to direct its management and policies by vote, +contract, or otherwise. Control can be direct or indirect. + +**Your licenses** are all the licenses granted to you for the +software under these terms. + +**Use** means anything you do with the software requiring one +of your licenses. diff --git a/packages/stack-guest-abi/src/abi.rs b/packages/stack-guest-abi/src/abi.rs new file mode 100644 index 000000000..3c6c6dd36 --- /dev/null +++ b/packages/stack-guest-abi/src/abi.rs @@ -0,0 +1,161 @@ +//! The part of the wasm export surface every guest has, and the helpers a +//! guest's own exports are written with. Same conventions as the vitaminc +//! guest (`vc_*`), under the `se_` prefix: +//! +//! - The host owns all buffer lifecycles. It writes inputs into guest +//! memory obtained from [`se_alloc`] and releases every buffer — its own +//! inputs and the guest's outputs — with [`se_dealloc`], which **zeroizes +//! before freeing**. The guest keeps a registry of every buffer it hands +//! out ([`crate::buffers`]), so `se_dealloc` never trusts the host's +//! length. +//! - An export handed plaintext wipes that buffer in place before it +//! returns ([`take_plaintext`]), rather than leaving it for `se_dealloc`: +//! the host's plaintext then lives no longer than the call. A host must +//! not read such a buffer back after the call, or pass it to two calls. +//! - Output buffers that contain plaintext are the host's to copy out and +//! immediately `se_dealloc`. +//! - During an export the host's imported functions may re-enter the guest +//! **only** through `se_alloc` (to place a response); calling any other +//! export from inside a host import is undefined behaviour of the +//! embedding, not of this crate. +//! +//! # Result encoding +//! +//! Every fallible export returns a single `u64` split into a high and a low +//! 32-bit field: +//! +//! - **success** — the high 32 bits are non-zero: an output pointer with +//! the low 32 bits its length ([`ok_buffer`]). +//! - **error** — the high 32 bits are zero and the low 32 bits are a +//! [`crate::status`] code ([`err_status`]). A valid pointer is never zero, +//! so the two spaces never collide. +//! +//! # Hostile-input posture +//! +//! As the vitaminc guest: every export validates its pointer/length pairs +//! against linear memory before any unsafe construction ([`input`]; null +//! with nonzero length rejected), invalid input yields `STATUS_ENCODING` +//! rather than a trap, and a `catch_unwind` at each export is +//! belt-and-braces for a hypothetical unwind build — wasm32-wasip1 aborts on +//! panic. Statuses are the only detail leaked. +//! +//! Wasm modules are single-threaded; the host must serialize calls into one +//! instance. +//! +//! These exports are `#[no_mangle]` in a library crate: a cdylib that links +//! this crate exports them, so every guest gets `se_alloc` and `se_dealloc` +//! by depending on it and defines only the exports that are its own. + +use zeroize::{Zeroize, Zeroizing}; + +use crate::buffers; +use crate::status::STATUS_ENCODING; + +/// Allocate `len` bytes of guest memory for the host to write into. Returns +/// null if the allocation fails (recoverable host-side; never a trap). +#[no_mangle] +pub extern "C" fn se_alloc(len: u32) -> *mut u8 { + buffers::alloc(len as usize) +} + +/// Zeroize and free a buffer previously handed out by [`se_alloc`] or +/// packed into a result. See [`buffers::dealloc`] for the registry +/// discipline (unknown pointer: no-op; length mismatch: refused). +/// +/// # Safety +/// +/// `ptr` should be a pointer this crate handed out; the registry makes +/// anything else a no-op rather than undefined behaviour. +#[no_mangle] +pub unsafe extern "C" fn se_dealloc(ptr: *mut u8, len: u32) { + unsafe { buffers::dealloc(ptr, len as usize) } +} + +/// Pack a buffer result: `ptr << 32 | len`. The buffer is registered so the +/// host's eventual [`se_dealloc`] wipes and frees exactly what was +/// allocated. +pub fn ok_buffer(out: Vec<u8>) -> u64 { + let len = out.len() as u64; + let ptr = buffers::register(out) as usize as u64; + (ptr << 32) | len +} + +/// Pack an error: the status in the low 32 bits, high bits zero. +pub fn err_status(status: u32) -> u64 { + status as u64 +} + +/// Current linear-memory size in bytes. `u64` because a full 4 GiB memory +/// (65536 pages) overflows a 32-bit `usize`. +pub fn linear_memory_bytes() -> u64 { + core::arch::wasm32::memory_size::<0>() as u64 * 65536 +} + +/// Borrow a host-supplied `(ptr, len)` pair, validating before any slice +/// exists: null-with-nonzero-length is rejected (treating it as empty would +/// silently drop whatever bytes the host meant to pass), the length must be +/// under `isize::MAX`, and the whole range must lie inside the current +/// linear memory. A pair that fails validation yields `STATUS_ENCODING`; a +/// pair that passes can still name the wrong bytes — the host owns its +/// pointers — but can never fault or over-read past linear memory. +/// +/// Safe to call with any pointer, which is the point: the validation above +/// is what a caller would otherwise have to promise, so the function is not +/// `unsafe` and the lint that asks for it is answered here rather than at +/// every export. +#[allow(clippy::not_unsafe_ptr_arg_deref)] +pub fn input<'a>(ptr: *const u8, len: u32) -> Result<&'a [u8], u32> { + let len = len as usize; + if len == 0 { + return Ok(&[]); + } + if ptr.is_null() || len > isize::MAX as usize { + return Err(STATUS_ENCODING); + } + let end = (ptr as usize).checked_add(len).ok_or(STATUS_ENCODING)?; + if end as u64 > linear_memory_bytes() { + return Err(STATUS_ENCODING); + } + // SAFETY: non-null, in-bounds of linear memory, and under `isize::MAX`; + // wasm linear memory is fully initialized (fresh pages are zero), so + // reading the range as bytes is defined. + Ok(unsafe { std::slice::from_raw_parts(ptr, len) }) +} + +/// Zeroize a validated input range in place (without freeing it — the host +/// still owns the buffer and will `se_dealloc` it after the call). +/// +/// # Safety +/// +/// The range must have passed [`input`] validation and carry no outstanding +/// borrows. +pub unsafe fn wipe_input(ptr: *mut u8, len: u32) { + if ptr.is_null() || len == 0 { + return; + } + unsafe { std::slice::from_raw_parts_mut(ptr, len as usize) }.zeroize(); +} + +/// Take a plaintext input out of the host's buffer and wipe the buffer. +/// +/// An export that holds its decoded value across a host round trip would +/// otherwise keep the borrow of the host buffer alive for the whole call. +/// Copying into a `Zeroizing` first lets the original be wiped immediately: +/// the plaintext then exists for the duration of the call and no longer, +/// instead of sitting in linear memory until the host gets round to +/// `se_dealloc`. +/// +/// Call it before any other buffer is borrowed, deliberately. The wipe +/// writes through `&mut`, so no other `&[u8]` into linear memory may be +/// live — and a host that aliases its value range onto another argument +/// therefore reads zeros there, which that argument's parser rejects. +/// +/// # Safety +/// +/// `ptr`/`len` must name a host buffer the caller is done with; it is zeroed +/// before this returns. +pub unsafe fn take_plaintext(ptr: *mut u8, len: u32) -> Result<Zeroizing<Vec<u8>>, u32> { + let taken = Zeroizing::new(input(ptr, len)?.to_vec()); + unsafe { wipe_input(ptr, len) }; + Ok(taken) +} diff --git a/languages/golang/stackencrypt/guest/src/buffers.rs b/packages/stack-guest-abi/src/buffers.rs similarity index 94% rename from languages/golang/stackencrypt/guest/src/buffers.rs rename to packages/stack-guest-abi/src/buffers.rs index a598dd109..c5dfd83bd 100644 --- a/languages/golang/stackencrypt/guest/src/buffers.rs +++ b/packages/stack-guest-abi/src/buffers.rs @@ -1,7 +1,7 @@ //! The guest-owned buffer registry behind `se_alloc` / `se_dealloc`, //! following the vitaminc guest's conventions (see `vcencrypt/guest/src/ -//! abi.rs`; sharing one implementation from a common vitaminc crate is a -//! planned follow-up in that repository): +//! abi.rs`). Shared by every guest under `bindings/go`, so the registry +//! discipline is written once: //! //! - Every buffer the guest hands out — from [`alloc`] and from packed //! results — is recorded here keyed by start address, holding the true @@ -47,7 +47,7 @@ fn empty_ptr() -> *mut u8 { /// through `try_reserve_exact`, not the aborting global-allocator error /// path, so an oversized request is a recoverable host-side error instead /// of a trap that poisons the instance. -pub(crate) fn alloc(len: usize) -> *mut u8 { +pub fn alloc(len: usize) -> *mut u8 { let mut buf: Vec<u8> = Vec::new(); if buf.try_reserve_exact(len).is_err() { return core::ptr::null_mut(); @@ -58,7 +58,7 @@ pub(crate) fn alloc(len: usize) -> *mut u8 { /// Register a buffer and leak it to a raw pointer for the host. The /// registry entry is what makes the matching [`dealloc`] / [`take`] sound. -pub(crate) fn register(buf: Vec<u8>) -> *mut u8 { +pub fn register(buf: Vec<u8>) -> *mut u8 { if buf.is_empty() { // See `EMPTY_BUFFERS`: empties share one pointer, so they are // counted, not keyed. Nothing leaks — an empty `Vec` owns no heap. @@ -87,7 +87,7 @@ pub(crate) fn register(buf: Vec<u8>) -> *mut u8 { /// other pointer (or a stale one) a no-op rather than undefined behaviour, /// but a pointer that happens to alias a *different* live registered buffer /// of the same length would free that buffer. -pub(crate) unsafe fn dealloc(ptr: *mut u8, len: usize) { +pub unsafe fn dealloc(ptr: *mut u8, len: usize) { if let Some(mut buf) = unsafe { reclaim(ptr, len) } { buf.zeroize(); } @@ -101,23 +101,24 @@ pub(crate) unsafe fn dealloc(ptr: *mut u8, len: usize) { /// # Safety /// /// As for [`dealloc`]. -pub(crate) unsafe fn take(ptr: *mut u8, len: usize) -> Option<Vec<u8>> { +pub unsafe fn take(ptr: *mut u8, len: usize) -> Option<Vec<u8>> { if ptr.is_null() && len == 0 { return Some(Vec::new()); } unsafe { reclaim(ptr, len) } } -/// Wipe and free every buffer the registry still holds — what `se_shutdown` -/// does after dropping the cipher, so a host that tears the instance down -/// without releasing an output first still leaves no plaintext behind. +/// Wipe and free every buffer the registry still holds — what a guest's +/// shutdown export does after dropping its state, so a host that tears the +/// instance down without releasing an output first still leaves no +/// plaintext behind. /// Empties carry no bytes; their count is simply reset. /// /// This path allocates nothing: the registry is moved out whole (an empty /// `HashMap` does not allocate) and walked in place, so a shutdown under /// linear-memory pressure cannot fail before the wipe on an allocation the /// wipe itself made. -pub(crate) fn wipe_all() { +pub fn wipe_all() { let live = BUFFERS.with(|b| core::mem::take(&mut *b.borrow_mut())); for (ptr, len) in live { // SAFETY: every entry was registered by `register`, which leaked a diff --git a/packages/stack-guest-abi/src/headers.rs b/packages/stack-guest-abi/src/headers.rs new file mode 100644 index 000000000..6fb701dac --- /dev/null +++ b/packages/stack-guest-abi/src/headers.rs @@ -0,0 +1,99 @@ +//! The header micro-format of the `transport_send` host import. +//! +//! Request and response headers cross the boundary as one UTF-8 buffer of +//! `name: value` lines separated by `\n` (HTTP/1.1 field syntax, minus +//! folding) — trivially encoded and decoded on both sides without pulling a +//! value codec into the transport layer. Names compare +//! ASCII-case-insensitively, as in HTTP. Pure functions, unit-tested on the +//! native target. The Go host's `parseHeaders` / `encodeHeaders` are the +//! other side of this format. + +/// Encode header pairs as the wire buffer. +pub fn encode_headers(headers: &[(&str, &str)]) -> Vec<u8> { + let mut out = String::new(); + for (i, (name, value)) in headers.iter().enumerate() { + if i > 0 { + out.push('\n'); + } + out.push_str(name); + out.push_str(": "); + out.push_str(value); + } + out.into_bytes() +} + +/// Look up a header by (ASCII-case-insensitive) name in a wire buffer. +/// Malformed lines (no colon, non-UTF-8 bytes) are skipped rather than +/// failing the response: the transport's contract is carried by the status +/// and body, and header parsing must not be a denial-of-service lever. The +/// skip is per *line*, not per buffer — a proxy that emits one raw +/// ISO-8859-1 byte in an unrelated header (a `via`/`server` line, say) must +/// not make the `content-type` lookup fail and with it every call. +pub fn header_value<'a>(buffer: &'a [u8], name: &str) -> Option<&'a str> { + buffer.split(|&b| b == b'\n').find_map(|line| { + let line = std::str::from_utf8(line).ok()?; + let (n, v) = line.split_once(':')?; + n.trim().eq_ignore_ascii_case(name).then(|| v.trim()) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn round_trips_and_matches_case_insensitively() { + let buffer = encode_headers(&[ + ("authorization", "Bearer tok"), + ("content-type", "application/json"), + ]); + assert_eq!( + std::str::from_utf8(&buffer).unwrap(), + "authorization: Bearer tok\ncontent-type: application/json", + "headers encode one per line, lower-cased, without a trailing newline" + ); + assert_eq!( + header_value(&buffer, "Content-Type"), + Some("application/json"), + "a header is found whatever the case it is asked for in" + ); + assert_eq!(header_value(&buffer, "authorization"), Some("Bearer tok")); + assert_eq!(header_value(&buffer, "x-missing"), None); + } + + #[test] + fn tolerates_whitespace_and_skips_malformed_lines() { + assert_eq!( + header_value(b"Content-Type: text/html \ngarbage-line", "content-type"), + Some("text/html"), + "surrounding whitespace is trimmed and a line without a colon is skipped" + ); + assert_eq!( + header_value(b"no colon here", "content-type"), + None, + "a buffer with no well-formed line has no headers" + ); + assert_eq!( + header_value(&[0xff, 0xfe], "content-type"), + None, + "a buffer that is not UTF-8 has no headers" + ); + assert_eq!( + header_value(b"", "content-type"), + None, + "an empty buffer has no headers" + ); + } + + #[test] + fn a_non_utf8_line_does_not_poison_the_other_headers() { + let mut buffer = b"server: pro".to_vec(); + buffer.push(0xe9); // "proxé" in raw ISO-8859-1 + buffer.extend_from_slice(b"\ncontent-type: application/json"); + assert_eq!( + header_value(&buffer, "content-type"), + Some("application/json"), + "a header after a non-UTF-8 line is still found" + ); + } +} diff --git a/packages/stack-guest-abi/src/lib.rs b/packages/stack-guest-abi/src/lib.rs new file mode 100644 index 000000000..22d512e41 --- /dev/null +++ b/packages/stack-guest-abi/src/lib.rs @@ -0,0 +1,92 @@ +// Security lints — the block `stack-encrypt` and `stack-auth` carry, minus +// `deny(unsafe_code)`: the export surface (`abi`) and the host import +// (`transport`) are `extern "C"` over raw pointers by nature. Every `unsafe` +// block is confined to those two wasm32-only modules and to the buffer +// registry, and documented at the site; `unsafe_op_in_unsafe_fn` keeps each +// one explicit. +#![deny(unsafe_op_in_unsafe_fn)] +#![warn(clippy::unwrap_used)] +#![warn(clippy::expect_used)] +#![warn(clippy::panic)] +// Prevent mem::forget from bypassing ZeroizeOnDrop +#![warn(clippy::mem_forget)] +// Prevent accidental data leaks via output +#![warn(clippy::print_stdout)] +#![warn(clippy::print_stderr)] +#![warn(clippy::dbg_macro)] +// Code quality +#![warn(unreachable_pub)] +#![warn(unused_results)] +#![warn(clippy::todo)] +#![warn(clippy::unimplemented)] +// Relax in tests +#![cfg_attr(test, allow(clippy::unwrap_used))] +#![cfg_attr(test, allow(clippy::expect_used))] +#![cfg_attr(test, allow(clippy::panic))] +#![cfg_attr(test, allow(unused_results))] +// `abi` and `transport` only exist on wasm32, so on a native doc build their +// intra-doc links have nothing to resolve to. The wasm32 doc build +// (`mise run wasm:guest:test`) is where links are enforced. +#![cfg_attr(not(target_arch = "wasm32"), allow(rustdoc::broken_intra_doc_links))] +//! # The guest ABI the Go binding's WASI guests share +//! +//! The Go binding reaches Rust through WASI modules run by wazero: the +//! crypto guest (`bindings/go/stackencrypt/guest`, `stack-encrypt` over a +//! host-provided transport) and, per ADR-0005, the credential guest +//! (`stack-profile` and `stack-auth`). Everything a guest needs that is +//! *not* about what it does — how the host gets bytes in and out, how a +//! result is packed, what a status number means, how an HTTP request +//! crosses to the host — lives here, once, so the memory-hygiene rules are +//! written and fixed in one place and the two guests read identically from +//! the host side. +//! +//! What is here: +//! +//! - [`buffers`] — the guest-owned buffer registry: every buffer handed to +//! the host is recorded with its true length, released through the +//! registry (never on the host's say-so), and zeroized on the way out. +//! - [`abi`] (wasm32 only) — the `se_alloc` / `se_dealloc` exports every +//! guest has, the packed `u64` result encoding, and the hostile-input +//! helpers that validate a host `(ptr, len)` pair against linear memory +//! before any slice exists. +//! - [`status`] — the status table. **One numbering for every guest**: the +//! codes below keep their values for good, and a guest that needs more +//! appends after them. The Go side decodes the table once. +//! - [`transport`] (wasm32 only) — the `cipherstash_transport::transport_send` +//! host import, wrapped so a guest performs one HTTP request as a safe +//! call and gets both response buffers back through the registry. +//! - [`headers`] — the `name: value` line format request and response +//! headers cross the import in. +//! +//! What is deliberately *not* here: anything that names a crate a guest is +//! built over. The ZeroKMS connection over the transport, the token import +//! the crypto guest uses for its phase-1 auth, the mapping from a library's +//! error type onto the status table, and the `user-agent` a guest sends are +//! each guest's own. +//! +//! # Conventions +//! +//! The host owns every buffer lifecycle. It writes inputs into guest memory +//! obtained from `se_alloc` and releases every buffer — its own inputs and +//! the guest's outputs — with `se_dealloc`, which zeroizes before freeing. +//! Every fallible export returns one `u64`: a non-zero high half is an +//! output pointer with the length in the low half; a zero high half carries +//! a [`status`] code in the low half. Wasm modules are single-threaded; the +//! host serializes calls into one instance, and during an export the host's +//! imports may re-enter the guest only through `se_alloc`. +//! +//! The crate builds natively too — the registry and the header format have +//! no wasm in them and are unit-tested on the host — but only the wasm32 +//! build has an ABI: on any other target the export and import modules do +//! not exist, so a native library built over this crate exports no `se_*` +//! symbol at all rather than a silently wrong one (the packed result would +//! truncate a 64-bit pointer). + +pub mod buffers; +pub mod headers; +pub mod status; + +#[cfg(target_arch = "wasm32")] +pub mod abi; +#[cfg(target_arch = "wasm32")] +pub mod transport; diff --git a/packages/stack-guest-abi/src/status.rs b/packages/stack-guest-abi/src/status.rs new file mode 100644 index 000000000..c1f5e02a1 --- /dev/null +++ b/packages/stack-guest-abi/src/status.rs @@ -0,0 +1,125 @@ +//! The status table: the low 32 bits of a packed error result (see +//! [`crate::abi`]). +//! +//! **One numbering for every guest.** The Go host decodes these values once, +//! in the internal package both public packages share, so a number means the +//! same thing whichever guest reported it. The codes here are part of the +//! guest/host contract and are never renumbered or reused; a guest that needs +//! a status of its own appends after the last one, in this file, so the +//! table stays one table. +//! +//! Codes 1–4 are byte-for-byte the vitaminc guest's codes (`vcencrypt`'s +//! `status.rs`), so every guest reads identically from the host side; code 3 +//! there is "unknown handle", and here — where there is no handle — it is +//! the call-order violation that means the same thing to a host: nothing to +//! run this call against. Codes 5–10 are the ZeroKMS request outcomes, so a +//! host can distinguish a bad token from a tampered ciphertext without +//! parsing strings; 11 and 12 are the crypto guest's term and keyset-scope +//! conditions. The mapping from a library's error type onto these numbers +//! is each guest's own (`status_for_error` and friends in the crypto guest): +//! this module names the numbers, not the libraries. + +/// AEAD open failure: a tampered ciphertext, a wrong element derivation, or +/// a wrong AAD that reached the AEAD. Against ZeroKMS a wrong AAD does not +/// get that far — every data key is bound to its context's descriptor, so +/// the retrieve is refused first, as [`STATUS_KMS_FORBIDDEN`]. Only a key +/// source that ignores descriptors (the native tests' fake) reports a wrong +/// AAD here. +pub const STATUS_AUTH: u32 = 1; +/// Invalid input at the boundary: malformed transport bytes, a malformed +/// config, an empty encryption context, or a pointer/length pair that fails +/// validation against linear memory. +pub const STATUS_ENCODING: u32 = 2; +/// The call is out of order: an operation before the guest's init export, or +/// after its shutdown, or init twice. A host fixes its call sequence; +/// nothing here is a guest bug. (The vitaminc guest's code 3 is "unknown +/// handle", the same condition under a handle scheme.) +pub const STATUS_STATE: u32 = 3; +/// A caught panic, a response that did not match its requests, or any other +/// unexpected internal failure. +pub const STATUS_INTERNAL: u32 = 4; +/// ZeroKMS (or the auth strategy) rejected the *credential*: an expired or +/// rejected access token, or a credential exchange the server refused. +/// +/// The one status a host should answer by refreshing the token and retrying. +/// Deliberately narrow for that reason: a configuration fault that merely +/// *arrives* through the auth strategy — a token with no ZeroKMS `services` +/// claim, a host `token_get` that failed — is [`STATUS_KMS_TRANSPORT`], since +/// no number of refreshes can fix it. +pub const STATUS_KMS_UNAUTHORIZED: u32 = 5; +/// ZeroKMS rejected the request as forbidden: the token is valid but lacks +/// permission, the keyset is disabled, the organisation is over its usage +/// allowance — or, on decrypt, the AAD/context is not the one the value +/// was sealed under, so the data key cannot be re-derived. That last one is +/// the production form of a wrong-context open; see [`STATUS_AUTH`]. +pub const STATUS_KMS_FORBIDDEN: u32 = 6; +/// ZeroKMS could not find the resource: an unknown keyset (or client), or a +/// data key that does not exist for the presented `iv`/`tag`. +pub const STATUS_KMS_NOT_FOUND: u32 = 7; +/// ZeroKMS reported a resource conflict. +pub const STATUS_KMS_CONFLICT: u32 = 8; +/// No ZeroKMS verdict was reached: the host's `transport_send` errored, the +/// host's `token_get` errored, the endpoint is unknown or invalid (no +/// `zerokms_url` in the config *and* no ZeroKMS entry in the token's +/// `services` claim), or the request could not be prepared. +/// +/// Not retryable by refreshing a token — these are configuration or host +/// faults. See [`STATUS_KMS_UNAUTHORIZED`] for the one that is. +pub const STATUS_KMS_TRANSPORT: u32 = 9; +/// ZeroKMS failed in a way none of the codes above capture: a malformed +/// response, invalid key material, or an unclassified server error. +pub const STATUS_KMS_OTHER: u32 = 10; +/// An index term failed to derive: e.g. match text that yields no tokens, or +/// a value/scheme combination the term does not support. +pub const STATUS_TERM: u32 = 11; +/// An opening export was constrained to one keyset (`{"name"}`, `{"id"}` or +/// `{"default"}` in its options) and the leaf named another. Refused before +/// any key is retrieved. A host that means "whichever keyset" opens with +/// `{"any"}`. +/// +/// A constraint failure, and only that — never provenance. The comparison +/// reads the keyset id *out of the leaf*, before anything is retrieved and +/// so before anything is authenticated, which means a flipped byte in that +/// field arrives here exactly as a genuinely misrouted row does. The id is +/// bound into the leaf AAD, so the tampered leaf cannot go on to open — +/// it fails as [`STATUS_AUTH`] — but that verdict is only reached on the +/// path where the constraint let it through. Read this status as "not this +/// keyset's row", never as "an untampered row". +pub const STATUS_FOREIGN_KEYSET: u32 = 12; + +/// The last code in the table. A guest appending a code of its own starts +/// at `LAST_STATUS + 1` and moves this constant with it, so two guests can +/// never claim one number. +pub const LAST_STATUS: u32 = STATUS_FOREIGN_KEYSET; + +#[cfg(test)] +mod tests { + use super::*; + + /// The table is dense from 1 and every code is distinct; a renumbering + /// or a gap would break the Go decoder's contract silently. + #[test] + fn the_table_is_dense_and_distinct() { + let codes = [ + STATUS_AUTH, + STATUS_ENCODING, + STATUS_STATE, + STATUS_INTERNAL, + STATUS_KMS_UNAUTHORIZED, + STATUS_KMS_FORBIDDEN, + STATUS_KMS_NOT_FOUND, + STATUS_KMS_CONFLICT, + STATUS_KMS_TRANSPORT, + STATUS_KMS_OTHER, + STATUS_TERM, + STATUS_FOREIGN_KEYSET, + ]; + for (i, code) in codes.iter().enumerate() { + assert_eq!(*code, i as u32 + 1, "code {i} is out of sequence"); + } + assert_eq!(LAST_STATUS, codes[codes.len() - 1]); + // Zero is never a status: it is the high half of a *successful* + // packed result, and a status of zero would be unrepresentable. + assert!(codes.iter().all(|c| *c != 0)); + } +} diff --git a/packages/stack-guest-abi/src/transport.rs b/packages/stack-guest-abi/src/transport.rs new file mode 100644 index 000000000..94bc453c4 --- /dev/null +++ b/packages/stack-guest-abi/src/transport.rs @@ -0,0 +1,132 @@ +//! The `cipherstash_transport::transport_send` host import: one HTTP +//! request performed by the host on the guest's behalf. wasm32-only — +//! everything here calls an imported function. +//! +//! # Import contract (module `cipherstash_transport`) +//! +//! All pointers are offsets into guest linear memory; the host allocates +//! guest buffers with `se_alloc` and the guest reclaims them through its +//! registry ([`crate::buffers`]). +//! +//! `transport_send(method, url, headers, body, resp_headers_out, +//! resp_body_out) -> status` — perform one HTTP request. Each of the four +//! inputs is a `(ptr, len)` pair borrowed for the duration of the call; +//! `headers` is the `name: value` line format of [`crate::headers`]. The +//! two outputs are `(ptr_out, len_out)` slot pairs the host fills with +//! `se_alloc`'d buffers (response headers, response body). The return value +//! is the HTTP status code, or negative for a transport-level failure — +//! then the body carries the host's error text and headers are empty. +//! +//! This is #2099's ZeroKMS-shaped import generalised to a plain HTTP +//! request (method + URL + headers), which is why `stack-auth`'s refreshers +//! can run over it too, and a future `wasi:http` implementation can replace +//! it without changing the callers. +//! +//! What crosses the boundary per call is what would cross TLS anyway: the +//! URL, the headers (a bearer credential among them), and the serialized +//! request. Response bodies can carry wrapped keys or tokens, so a +//! [`Response`] wipes its body on drop; the buffers the host wrote are +//! reclaimed via the registry, which the ABI's `se_dealloc` also wipes. + +use std::fmt; + +use zeroize::Zeroizing; + +use crate::buffers; + +#[link(wasm_import_module = "cipherstash_transport")] +extern "C" { + fn transport_send( + method_ptr: *const u8, + method_len: u32, + url_ptr: *const u8, + url_len: u32, + headers_ptr: *const u8, + headers_len: u32, + body_ptr: *const u8, + body_len: u32, + resp_headers_ptr_out: *mut u32, + resp_headers_len_out: *mut u32, + resp_body_ptr_out: *mut u32, + resp_body_len_out: *mut u32, + ) -> i32; +} + +/// The host stored an out-slot pointer the guest's buffer registry does not +/// know (or with a mismatched length) — a host-side bookkeeping bug. +#[derive(Debug)] +pub struct HostBufferError; + +impl fmt::Display for HostBufferError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "host returned an unregistered or mismatched buffer") + } +} + +impl std::error::Error for HostBufferError {} + +/// What the host handed back for one request, both buffers reclaimed. +pub struct Response { + /// The HTTP status code, or negative for a transport-level failure — + /// then `body` is the host's error text. + pub status: i32, + /// The response headers in the [`crate::headers`] line format. + pub headers: Vec<u8>, + /// The response body. Wiped on drop: it can carry wrapped key material + /// or a credential. + pub body: Zeroizing<Vec<u8>>, +} + +/// Perform one HTTP request through the host. +/// +/// The host call is synchronous from the guest's perspective. The two +/// response slots are reclaimed *before* either is judged: a `?` on the +/// headers slot would otherwise strand the body buffer — a 2xx JSON body +/// full of wrapped data keys — registered, unfreed and unwiped for the life +/// of the instance. A slot the host did not fill (or filled with a pointer +/// the registry does not know) is [`HostBufferError`]. +pub fn send( + method: &[u8], + url: &str, + headers: &[u8], + body: &[u8], +) -> Result<Response, HostBufferError> { + let mut resp_headers_ptr: u32 = 0; + let mut resp_headers_len: u32 = 0; + let mut resp_body_ptr: u32 = 0; + let mut resp_body_len: u32 = 0; + + // SAFETY: every input pair names a live guest allocation borrowed for + // the call; the out-slots are stack locals the host writes once. + let status = unsafe { + transport_send( + method.as_ptr(), + method.len() as u32, + url.as_ptr(), + url.len() as u32, + headers.as_ptr(), + headers.len() as u32, + body.as_ptr(), + body.len() as u32, + &mut resp_headers_ptr, + &mut resp_headers_len, + &mut resp_body_ptr, + &mut resp_body_len, + ) + }; + + // SAFETY: pointers come from the host's `se_alloc` calls; the registry + // validates them before any Vec is rebuilt. + let resp_headers = + unsafe { buffers::take(resp_headers_ptr as *mut u8, resp_headers_len as usize) }; + let resp_body = unsafe { buffers::take(resp_body_ptr as *mut u8, resp_body_len as usize) } + .map(Zeroizing::new); + + let headers = resp_headers.ok_or(HostBufferError)?; + let body = resp_body.ok_or(HostBufferError)?; + Ok(Response { + status, + headers, + body, + }) +} From 54eb2142e477ebaf161cf76418d5fd4bb01df775 Mon Sep 17 00:00:00 2001 From: Dan Draper <dan@cipherstash.com> Date: Tue, 22 Sep 2026 11:08:27 -0500 Subject: [PATCH 613/686] fix(stack-guest-abi): answer review on the shared ABI crate `input` is now `unsafe`: the bounds check keeps the read inside linear memory but cannot see who owns the range or for how long, and a safe signature with an unconstrained `'a` let a caller return a slice over memory it was about to free (review, P2). The `# Safety` section states the contract and every export's borrow carries a SAFETY comment; the clippy allow that papered over this is gone. `linear_memory_bytes` had no caller outside the crate and is private again. `HostBufferError` goes through thiserror as every library error in the suite does. `Response` is `#[non_exhaustive]` and derives `OpaqueDebug` with only the status non-sensitive: its body can carry wrapped keys or a credential. `send` takes the method as `&str`. The shared status table now documents each code as the verdict a host acts on; the prose about how *this* guest reaches each one (ZeroKMS descriptor refusal, the `services` claim, `{"any"}`, leaf tampering) moves to the crypto guest's `status` module, so the shared crate names no library, as its own docs promise. The `LAST_STATUS` test pins it as the largest code rather than restating its definition. Docs: `abi` no longer claims a `catch_unwind` on its own two exports; `buffers` and the crate root say that the thread-local registry is the one target-independent piece a native cdylib backend (CIP-3997's deferred scope) must replace with a locked table. The guest's re-export shim for the header functions is dropped in favour of naming `stack_guest_abi::headers` directly, and its dependency block is back in alphabetical order. CI: `cts.Dockerfile` and `zerokms.Dockerfile` regenerated with `scripts/patch_rust_dockerfile.sh` so the new workspace member's manifest and `lib.rs` stub are staged in the build image. --- .../golang/stackencrypt/guest/Cargo.lock | 2 + .../golang/stackencrypt/guest/Cargo.toml | 4 +- .../golang/stackencrypt/guest/src/abi.rs | 42 +++++++---- .../golang/stackencrypt/guest/src/headers.rs | 6 +- .../golang/stackencrypt/guest/src/host.rs | 5 +- .../golang/stackencrypt/guest/src/status.rs | 33 +++++++++ packages/stack-guest-abi/Cargo.toml | 4 ++ packages/stack-guest-abi/src/abi.rs | 38 ++++++---- packages/stack-guest-abi/src/buffers.rs | 8 ++- packages/stack-guest-abi/src/lib.rs | 5 +- packages/stack-guest-abi/src/status.rs | 69 ++++++++----------- packages/stack-guest-abi/src/transport.rs | 27 ++++---- 12 files changed, 154 insertions(+), 89 deletions(-) diff --git a/languages/golang/stackencrypt/guest/Cargo.lock b/languages/golang/stackencrypt/guest/Cargo.lock index 0c4e3afde..727be306c 100644 --- a/languages/golang/stackencrypt/guest/Cargo.lock +++ b/languages/golang/stackencrypt/guest/Cargo.lock @@ -2022,6 +2022,8 @@ dependencies = [ name = "stack-guest-abi" version = "0.0.0" dependencies = [ + "thiserror 1.0.69", + "vitaminc-protected", "zeroize", ] diff --git a/languages/golang/stackencrypt/guest/Cargo.toml b/languages/golang/stackencrypt/guest/Cargo.toml index 2a59d0b80..3dc79c29c 100644 --- a/languages/golang/stackencrypt/guest/Cargo.toml +++ b/languages/golang/stackencrypt/guest/Cargo.toml @@ -29,13 +29,13 @@ stack-auth = { path = "../../../../packages/stack-auth", default-features = fals # `dynamic`: the runtime value model this guest speaks — contexts, index # terms and record plans over `FfiValue`, shared with every other binding. stack-encrypt = { path = "../../../../packages/stack-encrypt", default-features = false, features = ["dynamic"] } -stack-kms = { path = "../../../../packages/stack-kms", default-features = false } -zerokms-protocol = { path = "../../../../packages/zerokms-protocol" } # The guest ABI every guest under bindings/go shares: the allocator and # buffer registry (and with them the `se_alloc`/`se_dealloc` exports), the # packed-result helpers, the status table and the `transport_send` import. # This crate defines only the exports that are its own. stack-guest-abi = { path = "../../../../packages/stack-guest-abi" } +stack-kms = { path = "../../../../packages/stack-kms", default-features = false } +zerokms-protocol = { path = "../../../../packages/zerokms-protocol" } # The FFI codec + `FfiValue` tree, shared with vitaminc's own guest — one # codec, not a fork. Same vitaminc version stack-encrypt builds against diff --git a/languages/golang/stackencrypt/guest/src/abi.rs b/languages/golang/stackencrypt/guest/src/abi.rs index c68eaca99..74ffeb297 100644 --- a/languages/golang/stackencrypt/guest/src/abi.rs +++ b/languages/golang/stackencrypt/guest/src/abi.rs @@ -163,7 +163,9 @@ pub unsafe extern "C" fn se_cipher_init(cfg_ptr: *mut u8, cfg_len: u32) -> u64 { // that fails here returns before anything touches the range, which // is `wipe_input`'s precondition. Only a validated buffer is decoded // and, whatever the decode outcome, wiped. - let bytes = input(cfg_ptr, cfg_len)?; + // SAFETY: host-owned ranges the export was handed; the borrows end + // before it returns and before any wipe of an overlapping range. + let bytes = unsafe { input(cfg_ptr, cfg_len)? }; let decoded = decode(bytes); // The borrow of the raw buffer ends with `decoded` owned; wipe the // buffer now — it holds the client-key hex — before parsing (and @@ -249,7 +251,9 @@ pub extern "C" fn se_shutdown() { #[no_mangle] pub unsafe extern "C" fn se_keyset(sel_ptr: *const u8, sel_len: u32) -> u64 { catch_unwind(AssertUnwindSafe(|| { - let selector = parse_selector(decode(input(sel_ptr, sel_len)?)?)?; + // SAFETY: host-owned ranges the export was handed; the borrows end + // before it returns and before any wipe of an overlapping range. + let selector = parse_selector(decode(unsafe { input(sel_ptr, sel_len)? })?)?; if selector == KeysetSelector::Any { return Err(STATUS_ENCODING); } @@ -371,8 +375,10 @@ fn run_encrypt( // may be borrowed from linear memory yet. let value = unsafe { take_plaintext(val_ptr, val_len)? }; let value = value.as_slice(); - let aad = input(aad_ptr, aad_len)?; - let opts = input(opt_ptr, opt_len)?; + // SAFETY: host-owned ranges the export was handed; the borrows end + // before it returns and before any wipe of an overlapping range. + let aad = unsafe { input(aad_ptr, aad_len)? }; + let opts = unsafe { input(opt_ptr, opt_len)? }; ops::validate::value(value)?; with_keyset(opts, |keyset| { block_on(ops::encrypt_value(keyset, value, aad, as_element)) @@ -393,9 +399,11 @@ fn run_decrypt( as_element: bool, ) -> u64 { catch_unwind(AssertUnwindSafe(|| { - let ciphertext = input(ct_ptr, ct_len)?; - let aad = input(aad_ptr, aad_len)?; - let opts = input(opt_ptr, opt_len)?; + // SAFETY: host-owned ranges the export was handed; the borrows end + // before it returns and before any wipe of an overlapping range. + let ciphertext = unsafe { input(ct_ptr, ct_len)? }; + let aad = unsafe { input(aad_ptr, aad_len)? }; + let opts = unsafe { input(opt_ptr, opt_len)? }; ops::validate::tree(ciphertext)?; with_scope(opts, |scope| { block_on(ops::decrypt_value(scope, ciphertext, aad, as_element)) @@ -438,8 +446,10 @@ pub unsafe extern "C" fn se_term( catch_unwind(AssertUnwindSafe(|| { let value = unsafe { take_plaintext(val_ptr, val_len)? }; let value = value.as_slice(); - let context = input(ctx_ptr, ctx_len)?; - let opts = input(opt_ptr, opt_len)?; + // SAFETY: host-owned ranges the export was handed; the borrows end + // before it returns and before any wipe of an overlapping range. + let context = unsafe { input(ctx_ptr, ctx_len)? }; + let opts = unsafe { input(opt_ptr, opt_len)? }; ops::validate::term(value, context, kind)?; with_keyset(opts, |keyset| { block_on(ops::term(keyset, value, context, kind)) @@ -471,8 +481,10 @@ pub unsafe extern "C" fn se_encrypt_record( catch_unwind(AssertUnwindSafe(|| { let source = unsafe { take_plaintext(src_ptr, src_len)? }; let source = source.as_slice(); - let plan = input(plan_ptr, plan_len)?; - let opts = input(opt_ptr, opt_len)?; + // SAFETY: host-owned ranges the export was handed; the borrows end + // before it returns and before any wipe of an overlapping range. + let plan = unsafe { input(plan_ptr, plan_len)? }; + let opts = unsafe { input(opt_ptr, opt_len)? }; ops::validate::record(source, plan)?; with_keyset(opts, |keyset| { block_on(ops::encrypt_record(keyset, source, plan)) @@ -502,9 +514,11 @@ pub unsafe extern "C" fn se_decrypt_record( opt_len: u32, ) -> u64 { catch_unwind(AssertUnwindSafe(|| { - let record = input(rec_ptr, rec_len)?; - let plan = input(plan_ptr, plan_len)?; - let opts = input(opt_ptr, opt_len)?; + // SAFETY: host-owned ranges the export was handed; the borrows end + // before it returns and before any wipe of an overlapping range. + let record = unsafe { input(rec_ptr, rec_len)? }; + let plan = unsafe { input(plan_ptr, plan_len)? }; + let opts = unsafe { input(opt_ptr, opt_len)? }; ops::validate::record_tree(record, plan)?; with_scope(opts, |scope| { block_on(ops::decrypt_record(scope, record, plan)) diff --git a/languages/golang/stackencrypt/guest/src/headers.rs b/languages/golang/stackencrypt/guest/src/headers.rs index c6d2421ff..e1c0a2f36 100644 --- a/languages/golang/stackencrypt/guest/src/headers.rs +++ b/languages/golang/stackencrypt/guest/src/headers.rs @@ -1,10 +1,9 @@ //! The headers this guest's ZeroKMS requests carry, over the wire format -//! every guest shares ([`stack_guest_abi::headers`]: `name: value` lines, -//! re-exported here for the modules that read a response). +//! every guest shares ([`stack_guest_abi::headers`]: `name: value` lines). use std::sync::OnceLock; -pub use stack_guest_abi::headers::{encode_headers, header_value}; +use stack_guest_abi::headers::encode_headers; /// The host this guest is driven by, as it appears in [`user_agent`]. /// @@ -55,6 +54,7 @@ pub fn request_headers(authorization: &str) -> Vec<u8> { #[cfg(test)] mod tests { use super::*; + use stack_guest_abi::headers::header_value; /// The edge in front of production ZeroKMS answers a request with no /// `user-agent` with a bare nginx 403, before the application sees it. diff --git a/languages/golang/stackencrypt/guest/src/host.rs b/languages/golang/stackencrypt/guest/src/host.rs index f8d55e160..7c7564f5f 100644 --- a/languages/golang/stackencrypt/guest/src/host.rs +++ b/languages/golang/stackencrypt/guest/src/host.rs @@ -33,12 +33,13 @@ use std::sync::Mutex; use stack_auth::{AuthError, AuthStrategy, CustomError, SecretToken, ServiceToken}; use stack_guest_abi::buffers; +use stack_guest_abi::headers::header_value; use stack_guest_abi::transport; use stack_kms::{BaseUrlUnresolved, ZeroKMSConnection, ZeroKMSConnectionInit, ZeroKmsEndpoint}; use zeroize::Zeroizing; use zerokms_protocol::{ViturRequest, ViturRequestError}; -use crate::headers::{header_value, request_headers}; +use crate::headers::request_headers; use crate::response::map_response; #[link(wasm_import_module = "cipherstash_transport")] @@ -128,7 +129,7 @@ impl ZeroKMSConnection for WasiHostConnection { // The shared import reclaims both response slots before judging // either, and the body it hands back wipes on drop (it carries // wrapped key material). - let response = transport::send(b"POST", url.as_str(), &headers, &body) + let response = transport::send("POST", url.as_str(), &headers, &body) .map_err(|e| ViturRequestError::parse("Host response buffer failed validation", e))?; let content_type = header_value(&response.headers, "content-type"); diff --git a/languages/golang/stackencrypt/guest/src/status.rs b/languages/golang/stackencrypt/guest/src/status.rs index 563602f51..700c009ce 100644 --- a/languages/golang/stackencrypt/guest/src/status.rs +++ b/languages/golang/stackencrypt/guest/src/status.rs @@ -15,6 +15,39 @@ //! open refusing a leaf whose keyset id is not the scope's — a host's own //! constraint, checked before the leaf is authenticated and so not a //! statement about tampering. +//! +//! # What each verdict means for this guest +//! +//! The shared table documents each code as the verdict a host acts on; +//! what follows is how this guest's exports arrive at them. +//! +//! - [`STATUS_AUTH`] is an AEAD open failure. Against ZeroKMS a wrong +//! context does not get that far — every data key is bound to its +//! context's descriptor, so the retrieve is refused first, as +//! [`STATUS_KMS_FORBIDDEN`]: that is the production form of a +//! wrong-context open. Only a key source that ignores descriptors (the +//! native tests' fake) reports a wrong context as `STATUS_AUTH`. +//! - [`STATUS_ENCODING`] covers, besides malformed transport bytes and a +//! bad pointer/length pair, a malformed config and an empty context on +//! the record and term exports. +//! - [`STATUS_KMS_UNAUTHORIZED`] is kept to a refused credential. A token +//! with no ZeroKMS `services` claim, or a host `token_get` that failed, +//! arrives through the auth strategy too but is [`STATUS_KMS_TRANSPORT`]: +//! no number of refreshes can fix it (`status_for_auth` draws the line). +//! - [`STATUS_KMS_TRANSPORT`] also covers an endpoint that could not be +//! resolved: no `zerokms_url` in the config *and* no ZeroKMS entry in the +//! token's `services` claim. +//! - [`STATUS_FOREIGN_KEYSET`] is an opening export constrained to one +//! keyset (`{"name"}`, `{"id"}` or `{"default"}` in its options) whose +//! leaf named another. A host that means "whichever keyset" opens with +//! `{"any"}`. The comparison reads the keyset id *out of the leaf*, before +//! anything is retrieved and so before anything is authenticated, which +//! means a flipped byte in that field arrives here exactly as a genuinely +//! misrouted row does. The id is bound into the leaf's context, so the +//! tampered leaf cannot go on to open — it fails as [`STATUS_AUTH`] — but +//! that verdict is only reached on the path where the constraint let it +//! through. Read this status as "not this keyset's row", never as "an +//! untampered row". use stack_auth::AuthError; use stack_kms::{GenerateKeyError, LoadKeysetError, RetrieveKeyError}; diff --git a/packages/stack-guest-abi/Cargo.toml b/packages/stack-guest-abi/Cargo.toml index 2dbef0e42..cc6456a86 100644 --- a/packages/stack-guest-abi/Cargo.toml +++ b/packages/stack-guest-abi/Cargo.toml @@ -14,4 +14,8 @@ license-file = "LICENSE" publish = false [dependencies] +thiserror = { workspace = true } +# `OpaqueDebug` for the transport response: its body can carry wrapped key +# material or a credential, so it never prints. +vitaminc-protected = { workspace = true } zeroize = { workspace = true } diff --git a/packages/stack-guest-abi/src/abi.rs b/packages/stack-guest-abi/src/abi.rs index 3c6c6dd36..5637b4a7f 100644 --- a/packages/stack-guest-abi/src/abi.rs +++ b/packages/stack-guest-abi/src/abi.rs @@ -34,10 +34,12 @@ //! //! As the vitaminc guest: every export validates its pointer/length pairs //! against linear memory before any unsafe construction ([`input`]; null -//! with nonzero length rejected), invalid input yields `STATUS_ENCODING` -//! rather than a trap, and a `catch_unwind` at each export is -//! belt-and-braces for a hypothetical unwind build — wasm32-wasip1 aborts on -//! panic. Statuses are the only detail leaked. +//! with nonzero length rejected), and invalid input yields `STATUS_ENCODING` +//! rather than a trap. A guest wraps each of its *own* exports in a +//! `catch_unwind`, belt-and-braces for a hypothetical unwind build — +//! wasm32-wasip1 aborts on panic; the two exports here need none, since +//! neither has a panic path (allocation goes through `try_reserve_exact` +//! and release through the registry). Statuses are the only detail leaked. //! //! Wasm modules are single-threaded; the host must serialize calls into one //! instance. @@ -87,7 +89,7 @@ pub fn err_status(status: u32) -> u64 { /// Current linear-memory size in bytes. `u64` because a full 4 GiB memory /// (65536 pages) overflows a 32-bit `usize`. -pub fn linear_memory_bytes() -> u64 { +fn linear_memory_bytes() -> u64 { core::arch::wasm32::memory_size::<0>() as u64 * 65536 } @@ -99,12 +101,19 @@ pub fn linear_memory_bytes() -> u64 { /// pair that passes can still name the wrong bytes — the host owns its /// pointers — but can never fault or over-read past linear memory. /// -/// Safe to call with any pointer, which is the point: the validation above -/// is what a caller would otherwise have to promise, so the function is not -/// `unsafe` and the lint that asks for it is answered here rather than at -/// every export. -#[allow(clippy::not_unsafe_ptr_arg_deref)] -pub fn input<'a>(ptr: *const u8, len: u32) -> Result<&'a [u8], u32> { +/// # Safety +/// +/// The bounds check is what keeps the read inside linear memory; it cannot +/// see who owns the range or for how long, and that is what the caller +/// promises. `ptr`/`len` must name a buffer the host wrote and still owns +/// (one it obtained from [`se_alloc`], or an empty range), and that buffer +/// must stay allocated and unwritten for the whole of `'a` — in practice, +/// the borrow must end before the export returns and before any wipe of an +/// overlapping range ([`wipe_input`], [`take_plaintext`]). The lifetime is +/// otherwise unconstrained, so a caller choosing `'static` over a range it +/// is about to free would read freed memory: that is the promise, not a +/// property this function can check. +pub unsafe fn input<'a>(ptr: *const u8, len: u32) -> Result<&'a [u8], u32> { let len = len as usize; if len == 0 { return Ok(&[]); @@ -118,7 +127,8 @@ pub fn input<'a>(ptr: *const u8, len: u32) -> Result<&'a [u8], u32> { } // SAFETY: non-null, in-bounds of linear memory, and under `isize::MAX`; // wasm linear memory is fully initialized (fresh pages are zero), so - // reading the range as bytes is defined. + // reading the range as bytes is defined. That it stays allocated and + // unwritten for `'a` is the caller's contract, above. Ok(unsafe { std::slice::from_raw_parts(ptr, len) }) } @@ -155,7 +165,9 @@ pub unsafe fn wipe_input(ptr: *mut u8, len: u32) { /// `ptr`/`len` must name a host buffer the caller is done with; it is zeroed /// before this returns. pub unsafe fn take_plaintext(ptr: *mut u8, len: u32) -> Result<Zeroizing<Vec<u8>>, u32> { - let taken = Zeroizing::new(input(ptr, len)?.to_vec()); + // SAFETY: the borrow lives only for the copy on this line, inside the + // call, over a buffer the caller has promised is the host's and live. + let taken = Zeroizing::new(unsafe { input(ptr, len)? }.to_vec()); unsafe { wipe_input(ptr, len) }; Ok(taken) } diff --git a/packages/stack-guest-abi/src/buffers.rs b/packages/stack-guest-abi/src/buffers.rs index c5dfd83bd..e6a58b739 100644 --- a/packages/stack-guest-abi/src/buffers.rs +++ b/packages/stack-guest-abi/src/buffers.rs @@ -15,7 +15,13 @@ //! pointer; `take` reclaims ownership under the same registry discipline. //! //! Wasm is single-threaded, so a thread-local `RefCell` is a plain owner of -//! the map — no `Send`/`Sync` bounds required. +//! the map — no `Send`/`Sync` bounds required. That premise is the one +//! thing in this crate a native cdylib backend (CIP-3997's original scope, +//! deferred) could not keep: a library called from several host threads +//! would register on one thread and release on another, and a release the +//! registry does not know is a silent no-op that never wipes. The backend +//! replaces this owner with a `Mutex`-guarded table; the entry points and +//! their discipline stay as they are. use std::cell::{Cell, RefCell}; use std::collections::HashMap; diff --git a/packages/stack-guest-abi/src/lib.rs b/packages/stack-guest-abi/src/lib.rs index 22d512e41..fb15536e3 100644 --- a/packages/stack-guest-abi/src/lib.rs +++ b/packages/stack-guest-abi/src/lib.rs @@ -80,7 +80,10 @@ //! build has an ABI: on any other target the export and import modules do //! not exist, so a native library built over this crate exports no `se_*` //! symbol at all rather than a silently wrong one (the packed result would -//! truncate a 64-bit pointer). +//! truncate a 64-bit pointer). A native backend, when it comes, adds its own +//! export and import modules beside these and swaps the registry's +//! thread-local owner for a locked one (see [`buffers`]); nothing else here +//! assumes wasm. pub mod buffers; pub mod headers; diff --git a/packages/stack-guest-abi/src/status.rs b/packages/stack-guest-abi/src/status.rs index c1f5e02a1..444a51c3f 100644 --- a/packages/stack-guest-abi/src/status.rs +++ b/packages/stack-guest-abi/src/status.rs @@ -5,29 +5,30 @@ //! in the internal package both public packages share, so a number means the //! same thing whichever guest reported it. The codes here are part of the //! guest/host contract and are never renumbered or reused; a guest that needs -//! a status of its own appends after the last one, in this file, so the +//! a status of its own appends after [`LAST_STATUS`], in this file, so the //! table stays one table. //! //! Codes 1–4 are byte-for-byte the vitaminc guest's codes (`vcencrypt`'s //! `status.rs`), so every guest reads identically from the host side; code 3 //! there is "unknown handle", and here — where there is no handle — it is //! the call-order violation that means the same thing to a host: nothing to -//! run this call against. Codes 5–10 are the ZeroKMS request outcomes, so a -//! host can distinguish a bad token from a tampered ciphertext without -//! parsing strings; 11 and 12 are the crypto guest's term and keyset-scope -//! conditions. The mapping from a library's error type onto these numbers -//! is each guest's own (`status_for_error` and friends in the crypto guest): +//! run this call against. Codes 5–10 are the outcomes of a request to +//! ZeroKMS, so a host can distinguish a refused credential from a tampered +//! ciphertext without parsing strings; 11 and 12 were appended by the +//! crypto guest. +//! +//! Each code is documented here as the *verdict* it carries to a host — the +//! thing the host can act on. Which of a library's errors reach which code, +//! and what each verdict means for a particular export, is each guest's own +//! (`status_for_error` and friends in the crypto guest's `status` module): //! this module names the numbers, not the libraries. -/// AEAD open failure: a tampered ciphertext, a wrong element derivation, or -/// a wrong AAD that reached the AEAD. Against ZeroKMS a wrong AAD does not -/// get that far — every data key is bound to its context's descriptor, so -/// the retrieve is refused first, as [`STATUS_KMS_FORBIDDEN`]. Only a key -/// source that ignores descriptors (the native tests' fake) reports a wrong -/// AAD here. +/// AEAD open failure: the ciphertext, or the context it is being opened +/// under, is not what it was sealed with. A tampered ciphertext, a wrong +/// element derivation, or a wrong context that reached the AEAD. pub const STATUS_AUTH: u32 = 1; -/// Invalid input at the boundary: malformed transport bytes, a malformed -/// config, an empty encryption context, or a pointer/length pair that fails +/// Invalid input at the boundary: malformed transport bytes, an input that +/// fails the export's own validation, or a pointer/length pair that fails /// validation against linear memory. pub const STATUS_ENCODING: u32 = 2; /// The call is out of order: an operation before the guest's init export, or @@ -43,25 +44,21 @@ pub const STATUS_INTERNAL: u32 = 4; /// /// The one status a host should answer by refreshing the token and retrying. /// Deliberately narrow for that reason: a configuration fault that merely -/// *arrives* through the auth strategy — a token with no ZeroKMS `services` -/// claim, a host `token_get` that failed — is [`STATUS_KMS_TRANSPORT`], since -/// no number of refreshes can fix it. +/// *arrives* through the auth path is [`STATUS_KMS_TRANSPORT`], since no +/// number of refreshes can fix it. pub const STATUS_KMS_UNAUTHORIZED: u32 = 5; /// ZeroKMS rejected the request as forbidden: the token is valid but lacks /// permission, the keyset is disabled, the organisation is over its usage -/// allowance — or, on decrypt, the AAD/context is not the one the value -/// was sealed under, so the data key cannot be re-derived. That last one is -/// the production form of a wrong-context open; see [`STATUS_AUTH`]. +/// allowance — or the data key requested is bound to a context other than +/// the one presented, so it cannot be re-derived. pub const STATUS_KMS_FORBIDDEN: u32 = 6; /// ZeroKMS could not find the resource: an unknown keyset (or client), or a /// data key that does not exist for the presented `iv`/`tag`. pub const STATUS_KMS_NOT_FOUND: u32 = 7; /// ZeroKMS reported a resource conflict. pub const STATUS_KMS_CONFLICT: u32 = 8; -/// No ZeroKMS verdict was reached: the host's `transport_send` errored, the -/// host's `token_get` errored, the endpoint is unknown or invalid (no -/// `zerokms_url` in the config *and* no ZeroKMS entry in the token's -/// `services` claim), or the request could not be prepared. +/// No ZeroKMS verdict was reached: the host's transport failed, the endpoint +/// is unknown or invalid, or the request could not be prepared. /// /// Not retryable by refreshing a token — these are configuration or host /// faults. See [`STATUS_KMS_UNAUTHORIZED`] for the one that is. @@ -69,22 +66,11 @@ pub const STATUS_KMS_TRANSPORT: u32 = 9; /// ZeroKMS failed in a way none of the codes above capture: a malformed /// response, invalid key material, or an unclassified server error. pub const STATUS_KMS_OTHER: u32 = 10; -/// An index term failed to derive: e.g. match text that yields no tokens, or -/// a value/scheme combination the term does not support. +/// An index term failed to derive from the value it was asked for. pub const STATUS_TERM: u32 = 11; -/// An opening export was constrained to one keyset (`{"name"}`, `{"id"}` or -/// `{"default"}` in its options) and the leaf named another. Refused before -/// any key is retrieved. A host that means "whichever keyset" opens with -/// `{"any"}`. -/// -/// A constraint failure, and only that — never provenance. The comparison -/// reads the keyset id *out of the leaf*, before anything is retrieved and -/// so before anything is authenticated, which means a flipped byte in that -/// field arrives here exactly as a genuinely misrouted row does. The id is -/// bound into the leaf AAD, so the tampered leaf cannot go on to open — -/// it fails as [`STATUS_AUTH`] — but that verdict is only reached on the -/// path where the constraint let it through. Read this status as "not this -/// keyset's row", never as "an untampered row". +/// An opening export was constrained to one keyset and the value named +/// another; refused before any key is retrieved. A constraint failure, and +/// only that — never a verdict on the value's integrity. pub const STATUS_FOREIGN_KEYSET: u32 = 12; /// The last code in the table. A guest appending a code of its own starts @@ -117,7 +103,10 @@ mod tests { for (i, code) in codes.iter().enumerate() { assert_eq!(*code, i as u32 + 1, "code {i} is out of sequence"); } - assert_eq!(LAST_STATUS, codes[codes.len() - 1]); + // `LAST_STATUS` is the append point for the next guest, so it must + // be the largest code in the table — a code added to the list above + // without moving it would hand the next guest a number already taken. + assert_eq!(LAST_STATUS, codes.into_iter().max().unwrap_or(0)); // Zero is never a status: it is the high half of a *successful* // packed result, and a status of zero would be unrepresentable. assert!(codes.iter().all(|c| *c != 0)); diff --git a/packages/stack-guest-abi/src/transport.rs b/packages/stack-guest-abi/src/transport.rs index 94bc453c4..586644c71 100644 --- a/packages/stack-guest-abi/src/transport.rs +++ b/packages/stack-guest-abi/src/transport.rs @@ -28,8 +28,7 @@ //! [`Response`] wipes its body on drop; the buffers the host wrote are //! reclaimed via the registry, which the ABI's `se_dealloc` also wipes. -use std::fmt; - +use vitaminc_protected::OpaqueDebug; use zeroize::Zeroizing; use crate::buffers; @@ -54,21 +53,23 @@ extern "C" { /// The host stored an out-slot pointer the guest's buffer registry does not /// know (or with a mismatched length) — a host-side bookkeeping bug. -#[derive(Debug)] +#[derive(Debug, thiserror::Error)] +#[error("host returned an unregistered or mismatched buffer")] pub struct HostBufferError; -impl fmt::Display for HostBufferError { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "host returned an unregistered or mismatched buffer") - } -} - -impl std::error::Error for HostBufferError {} - /// What the host handed back for one request, both buffers reclaimed. +/// +/// Only [`send`] builds one, and `Debug` shows the status alone: the body +/// can carry wrapped key material or a credential, and the headers are +/// treated the same way rather than audited per name. +#[derive(OpaqueDebug)] +#[non_exhaustive] pub struct Response { /// The HTTP status code, or negative for a transport-level failure — - /// then `body` is the host's error text. + /// then `body` is the host's error text. Kept as the import returns it: + /// the sign convention is the wire contract with the Go host, and each + /// guest maps it onto its own library's response type. + #[non_sensitive] pub status: i32, /// The response headers in the [`crate::headers`] line format. pub headers: Vec<u8>, @@ -86,7 +87,7 @@ pub struct Response { /// of the instance. A slot the host did not fill (or filled with a pointer /// the registry does not know) is [`HostBufferError`]. pub fn send( - method: &[u8], + method: &str, url: &str, headers: &[u8], body: &[u8], From 09e4a2915f6c560b73ec8c9e937545f806af5a7c Mon Sep 17 00:00:00 2001 From: Dan Draper <dan@cipherstash.com> Date: Tue, 22 Sep 2026 11:31:07 -0500 Subject: [PATCH 614/686] fix(stack-guest-abi): declare the transport's deps for wasm32 only `thiserror` and `vitaminc-protected` are used by `transport` alone, which exists only on wasm32, so the native `cargo udeps` run in CI saw two dependencies nothing used. Declaring them under `[target.'cfg(target_arch = "wasm32")'.dependencies]` says what is true: a native build of the crate carries neither. --- packages/stack-guest-abi/Cargo.toml | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/packages/stack-guest-abi/Cargo.toml b/packages/stack-guest-abi/Cargo.toml index cc6456a86..ac15e4a26 100644 --- a/packages/stack-guest-abi/Cargo.toml +++ b/packages/stack-guest-abi/Cargo.toml @@ -14,8 +14,12 @@ license-file = "LICENSE" publish = false [dependencies] +zeroize = { workspace = true } + +# Only `transport` (wasm32-only) needs these: the import's error type and +# `OpaqueDebug` for its response, whose body can carry wrapped key material +# or a credential and so never prints. Declared for that target alone, so a +# native build carries nothing it does not use and `cargo udeps` sees none. +[target.'cfg(target_arch = "wasm32")'.dependencies] thiserror = { workspace = true } -# `OpaqueDebug` for the transport response: its body can carry wrapped key -# material or a credential, so it never prints. vitaminc-protected = { workspace = true } -zeroize = { workspace = true } From cc9a7bd8ee035a51b26b357535e9ec8cea195cae Mon Sep 17 00:00:00 2001 From: Dan Draper <dan@cipherstash.com> Date: Mon, 21 Sep 2026 22:55:19 -0500 Subject: [PATCH 615/686] feat(go): one module at bindings/go, with internal/guest for what both guests share The Go module moves up from bindings/go/stackencrypt to bindings/go, so a sibling package over the credential guest (ADR-0005) and an internal package both import become expressible: Go's internal visibility cannot cross modules. The stackencrypt package's import path is unchanged; the tasks, the test script and CI follow the module root, with the guest path now given relative to it. internal/guest holds what the guest packages need and neither should own: the locked, non-dumpable guest memory from CIP-4111, moved rather than forked, with its surface exported for its two callers; the status table, its decoder and the sentinel errors, which stackencrypt exposes as aliases of the same values so errors.Is holds across packages; and the opaque ClientKey, redacted under every verb and wiped once consumed, which stackencrypt exposes as a type alias. Config.ClientKey itself is CIP-4118's. internal/guesttest holds the probe module, the lock-limit machinery and the smaps assertion the memory tests of both packages share. The memory tests split by what they exercise: the allocator's own properties run in internal/guest, and what a Client has because of them stays in stackencrypt. RefuseGrowth, the seam the latter need to refuse growth without a lock limit, is a small exported API of the internal package. Closes CIP-4115. --- .github/imported-workflows/test-wasi.yml | 24 +- docs/plans/stack-encrypt-go-bindings.md | 6 +- languages/golang/{stackencrypt => }/go.mod | 2 +- languages/golang/{stackencrypt => }/go.sum | 0 languages/golang/internal/guest/clientkey.go | 56 +++ languages/golang/internal/guest/errors.go | 71 +++ languages/golang/internal/guest/heap_test.go | 40 ++ .../guest}/memory.go | 84 ++-- .../guest}/memory_linux.go | 2 +- .../internal/guest/memory_linux_test.go | 21 + .../guest}/memory_mapped.go | 8 +- .../guest}/memory_other.go | 4 +- .../golang/internal/guest/memory_test.go | 269 ++++++++++ .../guest}/memory_unix.go | 2 +- .../guest}/memory_unix_other.go | 4 +- .../guest}/memory_windows.go | 2 +- languages/golang/internal/guest/status.go | 66 +++ languages/golang/internal/guest/testing.go | 50 ++ .../internal/guesttest/memlock_other.go | 8 + .../guesttest/memlock_unix.go} | 6 +- languages/golang/internal/guesttest/probe.go | 152 ++++++ .../golang/internal/guesttest/smaps_linux.go | 111 +++++ languages/golang/stackencrypt/client.go | 15 +- languages/golang/stackencrypt/clientkey.go | 19 + languages/golang/stackencrypt/errors.go | 86 +--- .../golang/stackencrypt/example/README.md | 2 +- languages/golang/stackencrypt/guest.go | 58 +-- languages/golang/stackencrypt/guest_test.go | 7 +- .../golang/stackencrypt/memory_linux_test.go | 125 +---- .../golang/stackencrypt/memory_other_test.go | 7 - languages/golang/stackencrypt/memory_test.go | 464 ++---------------- languages/golang/stackencrypt/unit_test.go | 6 +- scripts/go-binding-test.sh | 2 +- 33 files changed, 1050 insertions(+), 729 deletions(-) rename languages/golang/{stackencrypt => }/go.mod (78%) rename languages/golang/{stackencrypt => }/go.sum (100%) create mode 100644 languages/golang/internal/guest/clientkey.go create mode 100644 languages/golang/internal/guest/errors.go create mode 100644 languages/golang/internal/guest/heap_test.go rename languages/golang/{stackencrypt => internal/guest}/memory.go (83%) rename languages/golang/{stackencrypt => internal/guest}/memory_linux.go (96%) create mode 100644 languages/golang/internal/guest/memory_linux_test.go rename languages/golang/{stackencrypt => internal/guest}/memory_mapped.go (95%) rename languages/golang/{stackencrypt => internal/guest}/memory_other.go (76%) create mode 100644 languages/golang/internal/guest/memory_test.go rename languages/golang/{stackencrypt => internal/guest}/memory_unix.go (98%) rename languages/golang/{stackencrypt => internal/guest}/memory_unix_other.go (93%) rename languages/golang/{stackencrypt => internal/guest}/memory_windows.go (98%) create mode 100644 languages/golang/internal/guest/status.go create mode 100644 languages/golang/internal/guest/testing.go create mode 100644 languages/golang/internal/guesttest/memlock_other.go rename languages/golang/{stackencrypt/memory_unix_test.go => internal/guesttest/memlock_unix.go} (76%) create mode 100644 languages/golang/internal/guesttest/probe.go create mode 100644 languages/golang/internal/guesttest/smaps_linux.go create mode 100644 languages/golang/stackencrypt/clientkey.go delete mode 100644 languages/golang/stackencrypt/memory_other_test.go diff --git a/.github/imported-workflows/test-wasi.yml b/.github/imported-workflows/test-wasi.yml index 07b5c6dfa..f7706de93 100644 --- a/.github/imported-workflows/test-wasi.yml +++ b/.github/imported-workflows/test-wasi.yml @@ -12,10 +12,10 @@ on: # Trigger on all of packages/ rather than enumerating the closure, # which would silently drift. - packages/** - # The guest is a detached workspace and the Go binding a separate - # module, so nothing else in CI compiles, tests or links them — this - # job is their only gate. - - bindings/go/stackencrypt/** + # The guest is a detached workspace and the Go module (bindings/go: + # stackencrypt and the internal packages) a separate module, so nothing + # else in CI compiles, tests or links them — this job is their only gate. + - bindings/go/** - scripts/check-wasm-imports.py - scripts/go-binding-test.sh - Cargo.toml @@ -30,10 +30,10 @@ on: pull_request: paths: - packages/** - # The guest is a detached workspace and the Go binding a separate - # module, so nothing else in CI compiles, tests or links them — this - # job is their only gate. - - bindings/go/stackencrypt/** + # The guest is a detached workspace and the Go module (bindings/go: + # stackencrypt and the internal packages) a separate module, so nothing + # else in CI compiles, tests or links them — this job is their only gate. + - bindings/go/** - scripts/check-wasm-imports.py - scripts/go-binding-test.sh - Cargo.toml @@ -116,8 +116,8 @@ jobs: if-no-files-found: error retention-days: 1 - # The Go binding (bindings/go/stackencrypt) embeds the module built - # above: format, vet and hermetic tests (import surface, transport + # The Go module (bindings/go; the stackencrypt package embeds the + # guest built above): format, vet and hermetic tests (import surface, transport # bridge against an httptest ZeroKMS, guest-parser acceptance of every # encoding the package builds, hostile ABI inputs, key residency), on # amd64 and 386. Live round trips through ZeroKMS are the phase 5 @@ -182,7 +182,7 @@ jobs: - uses: actions/setup-go@v5 with: go-version: ${{ steps.go.outputs.version }} - cache-dependency-path: bindings/go/stackencrypt/go.sum + cache-dependency-path: bindings/go/go.sum - uses: actions/download-artifact@v8 with: @@ -201,4 +201,4 @@ jobs: echo "stack_encrypt_guest.wasm sha256 $got" - name: Go binding - run: scripts/go-binding-test.sh bindings/go/stackencrypt + run: scripts/go-binding-test.sh bindings/go stackencrypt/wasm/stack_encrypt_guest.wasm diff --git a/docs/plans/stack-encrypt-go-bindings.md b/docs/plans/stack-encrypt-go-bindings.md index f7fe6b8d4..ce6e82579 100644 --- a/docs/plans/stack-encrypt-go-bindings.md +++ b/docs/plans/stack-encrypt-go-bindings.md @@ -423,8 +423,10 @@ for the proof; the Go side pools instances later. ### Phase 4 — the Go module -**Status (2026-09-12): implemented in `bindings/go/stackencrypt`** (module -path `github.com/cipherstash/cipherstash-suite/bindings/go/stackencrypt`, +**Status (2026-09-12): implemented in `bindings/go/stackencrypt`** (package +path `github.com/cipherstash/cipherstash-suite/bindings/go/stackencrypt`; since +CIP-4115 the Go module is rooted at `bindings/go`, one module for every Go +package, with `internal/guest` holding what the guest packages share, temporary until publishing). It imports `vcffi` + `vcvalue` from vitaminc (pseudo-versioned to a main commit; no fork), embeds the guest from `wasm/` (copied by `wasm:guest:build`, gitignored), and is gated by diff --git a/languages/golang/stackencrypt/go.mod b/languages/golang/go.mod similarity index 78% rename from languages/golang/stackencrypt/go.mod rename to languages/golang/go.mod index e8e0fe47b..48d942f7e 100644 --- a/languages/golang/stackencrypt/go.mod +++ b/languages/golang/go.mod @@ -1,4 +1,4 @@ -module github.com/cipherstash/cipherstash-suite/bindings/go/stackencrypt +module github.com/cipherstash/cipherstash-suite/bindings/go go 1.25.0 diff --git a/languages/golang/stackencrypt/go.sum b/languages/golang/go.sum similarity index 100% rename from languages/golang/stackencrypt/go.sum rename to languages/golang/go.sum diff --git a/languages/golang/internal/guest/clientkey.go b/languages/golang/internal/guest/clientkey.go new file mode 100644 index 000000000..d0cfb503b --- /dev/null +++ b/languages/golang/internal/guest/clientkey.go @@ -0,0 +1,56 @@ +package guest + +// ClientKey is the ZeroKMS client key: long-lived key material that lives +// for the process. It is opaque on purpose. A string is immutable and +// cannot be wiped; a byte slice prints its contents under %v. This type +// prints a redaction under every verb, hands its bytes only to the package +// that consumes them, and is wiped once consumed (see ADR-0005, decision 5). +// +// stackauth reads one out of the developer profile; stackencrypt takes it +// in its Config and wipes it once the key is in guest memory. Both expose +// this type as an alias, so a key read by one is the type the other takes, +// with neither package importing the other. +type ClientKey struct { + bytes []byte +} + +// NewClientKey wraps key material. It takes ownership of b: the caller must +// not keep or reuse the slice, which is wiped along with the key. +func NewClientKey(b []byte) *ClientKey { + return &ClientKey{bytes: b} +} + +// Bytes is the key material, for the package that marshals it into guest +// memory. The slice is the key's own: do not retain it, and call Wipe once +// it has been copied where it is going. Outside this module tree the type +// has no accessor; internal visibility is what keeps it that way. +func (k *ClientKey) Bytes() []byte { + if k == nil { + return nil + } + return k.bytes +} + +// Wipe zeroes the key material. A wiped key is empty; a second Wipe is a +// no-op. +func (k *ClientKey) Wipe() { + if k == nil { + return + } + clear(k.bytes) + k.bytes = nil +} + +// IsZero reports whether the key holds no material: never set, or wiped. +func (k *ClientKey) IsZero() bool { + return k == nil || len(k.bytes) == 0 +} + +// String implements fmt.Stringer with a redaction, so the key never reaches +// a log through %v or %s. +func (k *ClientKey) String() string { return redactedClientKey } + +// GoString implements fmt.GoStringer with the same redaction, for %#v. +func (k *ClientKey) GoString() string { return redactedClientKey } + +const redactedClientKey = "ClientKey(***)" diff --git a/languages/golang/internal/guest/errors.go b/languages/golang/internal/guest/errors.go new file mode 100644 index 000000000..c3b56484b --- /dev/null +++ b/languages/golang/internal/guest/errors.go @@ -0,0 +1,71 @@ +// Package guest is what the Go packages over the WASI guests share and +// neither should own: the locked, non-dumpable memory a guest instance runs +// in; the status table every guest reports through and the errors it +// decodes to; and the opaque client key one package reads and the other +// consumes. +// +// It sits under internal so that stackencrypt and stackauth expose what +// they need of it — the error sentinels, the ClientKey type — as their own +// identifiers (aliases, not copies: an error from either package is the +// same value, and a key read by one is the type the other takes) without +// either package importing the other. A binary that wants only the profile +// must not carry the crypto guest, and this is the seam that makes that +// true. See ADR-0005 in packages/stack-encrypt/docs/adr. +package guest + +import "errors" + +// Failure kinds a guest surfaces across the boundary. A guest reports a +// status code and nothing else (see StatusError), so these are the whole +// vocabulary: they separate a tampered ciphertext from a bad token from a +// malformed input, and reveal nothing about plaintext or key material. The +// public packages expose them under their own names; the values are these. +var ( + // ErrAuthentication is an AEAD open failure: a tampered ciphertext, a + // wrong element derivation, or a wrong AAD that reached the AEAD. Against + // ZeroKMS a wrong AAD is usually refused earlier as ErrForbidden, because + // every data key is bound to its context. + ErrAuthentication = errors.New("cipherstash: authentication failed") + // ErrEncoding is a malformed input: a value, ciphertext, plan, context, + // selector, path or config the guest refused before doing anything with + // it. + ErrEncoding = errors.New("cipherstash: malformed input") + // ErrState is a call on something that has been closed: a client, a + // store, or an instance an interrupted call took down. + ErrState = errors.New("cipherstash: closed") + // ErrInternal is a guest panic or any other unexpected guest failure. + ErrInternal = errors.New("cipherstash: internal guest failure") + // ErrUnauthorized is ZeroKMS refusing the bearer token (HTTP 401): the + // token is invalid, expired, or for another workspace. + ErrUnauthorized = errors.New("cipherstash: ZeroKMS rejected the access token") + // ErrForbidden is ZeroKMS refusing the request (HTTP 403): the token is + // valid but not permitted, or a data key's bound context did not match + // the one presented — the production form of a wrong-AAD open. + ErrForbidden = errors.New("cipherstash: ZeroKMS refused the request") + // ErrNotFound is ZeroKMS reporting a missing resource (HTTP 404): an + // unknown keyset name or id, or a data key that does not exist. + ErrNotFound = errors.New("cipherstash: ZeroKMS resource not found") + // ErrConflict is ZeroKMS reporting a resource conflict (HTTP 409). + ErrConflict = errors.New("cipherstash: ZeroKMS resource conflict") + // ErrTransport is a failure to reach ZeroKMS or to read its response: + // the transport returned an error, or the endpoint could not be resolved. + ErrTransport = errors.New("cipherstash: ZeroKMS transport failed") + // ErrKMS is any other ZeroKMS failure: an unparseable response, invalid + // key material, or an unclassified server error. + ErrKMS = errors.New("cipherstash: ZeroKMS request failed") + // ErrTerm is a term derivation the scheme could not perform for the + // given input, such as match text that yields no tokens. + ErrTerm = errors.New("cipherstash: term derivation failed") + // ErrForeignKeyset is a keyset-bound cipher refusing a ciphertext sealed + // under another keyset, before any key is retrieved. + ErrForeignKeyset = errors.New("cipherstash: ciphertext belongs to another keyset") + // ErrMemoryLock is guest memory that could not be locked in RAM (or, + // on Linux, excluded from core dumps). A constructor returns it when + // asked for locked memory and refused, and so does any later call under + // that setting whose growth of the guest's memory could not be locked; + // otherwise the instance reports it and works on with unlocked memory. + // The wrapped error names the limit that refused the lock and the size + // the guest holds: on Linux, RLIMIT_MEMLOCK (ulimit -l, a systemd + // LimitMEMLOCK=, or a pod's securityContext). + ErrMemoryLock = errors.New("cipherstash: guest memory is not locked") +) diff --git a/languages/golang/internal/guest/heap_test.go b/languages/golang/internal/guest/heap_test.go new file mode 100644 index 000000000..5c3469c6c --- /dev/null +++ b/languages/golang/internal/guest/heap_test.go @@ -0,0 +1,40 @@ +package guest + +import ( + "math" + "testing" +) + +const wasmPage = 64 * 1024 + +// The heap fallback keeps the two properties it can: growth wipes the +// slice it abandons, and Free wipes. +func TestHeapMemoryWipesWhatItAbandons(t *testing.T) { + m := newHeapMemory(wasmPage, 4*wasmPage) + first, _ := m.commit(wasmPage) + first[0], first[wasmPage-1] = 0xAA, 0xBB + second, _ := m.commit(3 * wasmPage) + if second[0] != 0xAA || second[wasmPage-1] != 0xBB { + t.Fatal("growth lost the contents") + } + if first[0] != 0 || first[wasmPage-1] != 0 { + t.Fatal("growth left the abandoned slice unwiped") + } + if buf, _ := m.commit(5 * wasmPage); buf != nil { + t.Fatal("grew past max") + } + // A size no slice on this host can hold is a refused growth, not a + // panic. Only a 32-bit host can ask without the request being a real + // allocation, so that is where it runs (CI's GOARCH=386 pass). + if uint64(math.MaxInt) < 1<<40 { + huge := newHeapMemory(0, 1<<40) + if buf, _ := huge.commit(1 << 40); buf != nil { + t.Fatal("a growth past the addressable size was granted") + } + } + second[7] = 0xCC + m.free() + if second[7] != 0 { + t.Fatal("free left the slice unwiped") + } +} diff --git a/languages/golang/stackencrypt/memory.go b/languages/golang/internal/guest/memory.go similarity index 83% rename from languages/golang/stackencrypt/memory.go rename to languages/golang/internal/guest/memory.go index 3c6aaaf87..aef828b68 100644 --- a/languages/golang/stackencrypt/memory.go +++ b/languages/golang/internal/guest/memory.go @@ -1,4 +1,4 @@ -package stackencrypt +package guest import ( "errors" @@ -43,28 +43,28 @@ import ( // operator can see it and raise the limit; Config.RequireLockedMemory // turns it into a NewClient failure. -// lockPolicy is what a refused lock means for an instance. -type lockPolicy uint8 +// LockPolicy is what a refused lock means for an instance. +type LockPolicy uint8 const ( - // bestEffort records a refused lock and carries on with unlocked + // BestEffort records a refused lock and carries on with unlocked // memory. - bestEffort lockPolicy = iota - // strict refuses growth that cannot be locked. The first commit is the + BestEffort LockPolicy = iota + // Strict refuses growth that cannot be locked. The first commit is the // exception: wazero cannot instantiate on a nil buffer, so it is // granted with the refusal recorded, and newInstance turns that into // the ErrMemoryLock the caller asked for. - strict + Strict ) -func policyFor(requireLockedMemory bool) lockPolicy { +func PolicyFor(requireLockedMemory bool) LockPolicy { if requireLockedMemory { - return strict + return Strict } - return bestEffort + return BestEffort } -// backend is one platform's linear memory behind a memoryAllocator: a +// backend is one platform's linear memory behind a Allocator: a // reservation committed from the front. It is used from the guest's // goroutine only; the allocator does the bookkeeping other goroutines // read. @@ -73,19 +73,19 @@ type backend interface { // will use, whose base never changes. A nil buffer means the growth // failed. lockErr, when set, is a refused lock on the newly committed // range: with a buffer, the range was kept unlocked (best effort); - // without one, the growth was refused because of it (strict). + // without one, the growth was refused because of it (Strict). commit(size uint64) (buf []byte, lockErr error) // free wipes the committed range and releases the reservation. free() } -// memoryAllocator is the experimental.MemoryAllocator handed to wazero for +// Allocator is the experimental.MemoryAllocator handed to wazero for // one guest instance, and the experimental.LinearMemory it returns: wazero // calls Allocate once per memory, and the guest has exactly one. It // records what the Client reports about the memory, and holds the memory // mapped while a guest call is in flight (see enter, exit and Free). -type memoryAllocator struct { - policy lockPolicy +type Allocator struct { + policy LockPolicy mu sync.Mutex backing backend @@ -98,10 +98,10 @@ type memoryAllocator struct { // a range that was kept — and never clears: a lock refused once is // reported for the life of the instance. lockErr error - // growth is the strict growths refused. It is not lockErr: a refused + // growth is the Strict growths refused. It is not lockErr: a refused // growth gives its range back before the guest sees it, so every byte // the guest holds is still locked and the instance still reports so. - growth growthRefusal + growth GrowthRefusal // inFlight counts guest calls in progress (see enter and exit); // pending records a Free that arrived while one was, to be honoured // when the outermost call returns. @@ -113,21 +113,21 @@ type memoryAllocator struct { freed bool } -// growthRefusal is the strict growths an allocator has refused: how many, +// GrowthRefusal is the Strict growths an allocator has refused: how many, // and the lock refusal behind the latest. Client.call compares the count // across a call to name the real cause when the guest reports only a // failed allocation. -type growthRefusal struct { - refused uint64 - reason error +type GrowthRefusal struct { + Refused uint64 + Reason error } -func newMemoryAllocator(policy lockPolicy) *memoryAllocator { - return &memoryAllocator{policy: policy} +func NewAllocator(policy LockPolicy) *Allocator { + return &Allocator{policy: policy} } // Allocate implements experimental.MemoryAllocator. -func (a *memoryAllocator) Allocate(capacity, max uint64) experimental.LinearMemory { +func (a *Allocator) Allocate(capacity, max uint64) experimental.LinearMemory { a.mu.Lock() defer a.mu.Unlock() if a.backing != nil { @@ -144,15 +144,15 @@ func (a *memoryAllocator) Allocate(capacity, max uint64) experimental.LinearMemo } // Reallocate implements experimental.LinearMemory. -func (a *memoryAllocator) Reallocate(size uint64) []byte { +func (a *Allocator) Reallocate(size uint64) []byte { buf, lockErr := a.backing.commit(size) if lockErr != nil { a.mu.Lock() if buf == nil { // Strict: the range was given back, so nothing unlocked was // admitted and the lock report stands. - a.growth.refused++ - a.growth.reason = lockErr + a.growth.Refused++ + a.growth.Reason = lockErr } else if a.lockErr == nil { a.lockErr = lockErr } @@ -172,7 +172,7 @@ func (a *memoryAllocator) Reallocate(size uint64) []byte { // whose next store then faults in compiled code. So a Free that arrives // during a call is recorded and performed by the outermost exit, when no // guest code can be running. A Free with no call in flight is immediate. -func (a *memoryAllocator) Free() { +func (a *Allocator) Free() { a.mu.Lock() defer a.mu.Unlock() if a.inFlight > 0 { @@ -184,14 +184,14 @@ func (a *memoryAllocator) Free() { // enter marks a guest call in progress: the memory must stay mapped until // the matching exit, whatever wazero asks in between. -func (a *memoryAllocator) enter() { +func (a *Allocator) Enter() { a.mu.Lock() a.inFlight++ a.mu.Unlock() } // exit ends a guest call and performs a Free that arrived during it. -func (a *memoryAllocator) exit() { +func (a *Allocator) Exit() { a.mu.Lock() defer a.mu.Unlock() a.inFlight-- @@ -202,7 +202,7 @@ func (a *memoryAllocator) exit() { } // freeLocked wipes and releases the memory, once. Called with mu held. -func (a *memoryAllocator) freeLocked() { +func (a *Allocator) freeLocked() { if a.freed { return } @@ -212,14 +212,14 @@ func (a *memoryAllocator) freeLocked() { // lockError is nil while every committed byte is locked (and, on Linux, // excluded from dumps); otherwise it names what was refused and why. -func (a *memoryAllocator) lockError() error { +func (a *Allocator) LockError() error { a.mu.Lock() defer a.mu.Unlock() return a.lockErr } -// growthRefusal is the strict growths refused so far. -func (a *memoryAllocator) growthRefusal() growthRefusal { +// GrowthRefusal is the Strict growths refused so far. +func (a *Allocator) GrowthRefusal() GrowthRefusal { a.mu.Lock() defer a.mu.Unlock() return a.growth @@ -227,8 +227,8 @@ func (a *memoryAllocator) growthRefusal() growthRefusal { // String is the memory's state for a log line: "locked", or the refusal. // Nothing secret is printed. -func (a *memoryAllocator) String() string { - if err := a.lockError(); err != nil { +func (a *Allocator) String() string { + if err := a.LockError(); err != nil { return fmt.Sprintf("unlocked: %v", err) } return "locked" @@ -236,20 +236,20 @@ func (a *memoryAllocator) String() string { // LogValue is the same state for slog: a group with memory_locked and, // when false, memory_lock_error. -func (a *memoryAllocator) LogValue() slog.Value { - if err := a.lockError(); err != nil { +func (a *Allocator) LogValue() slog.Value { + if err := a.LockError(); err != nil { return slog.GroupValue(slog.Bool("memory_locked", false), slog.String("memory_lock_error", err.Error())) } return slog.GroupValue(slog.Bool("memory_locked", true)) } -func (a *memoryAllocator) isFallback() bool { +func (a *Allocator) IsFallback() bool { a.mu.Lock() defer a.mu.Unlock() return a.fallback } -func (a *memoryAllocator) isFreed() bool { +func (a *Allocator) IsFreed() bool { a.mu.Lock() defer a.mu.Unlock() return a.freed @@ -301,8 +301,8 @@ func (m *heapMemory) free() { // package has no lock implementation. var errNoLockSupport = errors.New("guest memory cannot be locked on this platform") -// memoryLockError wraps a lock refusal as ErrMemoryLock. -func memoryLockError(err error) error { +// MemoryLockError wraps a lock refusal as ErrMemoryLock. +func MemoryLockError(err error) error { return fmt.Errorf("%w: %w", ErrMemoryLock, err) } diff --git a/languages/golang/stackencrypt/memory_linux.go b/languages/golang/internal/guest/memory_linux.go similarity index 96% rename from languages/golang/stackencrypt/memory_linux.go rename to languages/golang/internal/guest/memory_linux.go index 9adbcd1df..180916877 100644 --- a/languages/golang/stackencrypt/memory_linux.go +++ b/languages/golang/internal/guest/memory_linux.go @@ -1,4 +1,4 @@ -package stackencrypt +package guest import ( "fmt" diff --git a/languages/golang/internal/guest/memory_linux_test.go b/languages/golang/internal/guest/memory_linux_test.go new file mode 100644 index 000000000..bed4d5c4f --- /dev/null +++ b/languages/golang/internal/guest/memory_linux_test.go @@ -0,0 +1,21 @@ +package guest_test + +import ( + "testing" + + "github.com/cipherstash/cipherstash-suite/bindings/go/internal/guest" + "github.com/cipherstash/cipherstash-suite/bindings/go/internal/guesttest" +) + +// The probe's mapping, on a range committed by Reallocate after the +// mprotect split, not only on what Allocate set up. The real guest's +// mapping is checked the same way where the guest is embedded. +func TestGuestMemoryIsLockedAndNotDumpable(t *testing.T) { + alloc := guest.NewAllocator(guest.BestEffort) + base, grow, done := guesttest.ProbeMemory(t, alloc) + defer done() + if _, ok := grow(2); !ok { + t.Fatal("grow refused") + } + guesttest.AssertMappingProtected(t, alloc, base()) +} diff --git a/languages/golang/stackencrypt/memory_mapped.go b/languages/golang/internal/guest/memory_mapped.go similarity index 95% rename from languages/golang/stackencrypt/memory_mapped.go rename to languages/golang/internal/guest/memory_mapped.go index 04f4f9fe2..cea6bcc21 100644 --- a/languages/golang/stackencrypt/memory_mapped.go +++ b/languages/golang/internal/guest/memory_mapped.go @@ -1,6 +1,6 @@ //go:build unix || windows -package stackencrypt +package guest import ( "fmt" @@ -18,7 +18,7 @@ import ( type mappedMemory struct { mapping []byte // the whole reservation committed uint64 // bytes made accessible so far, from the front - policy lockPolicy + policy LockPolicy } // reserveMemory returns a mapped memory for the reservation, or a heap @@ -26,7 +26,7 @@ type mappedMemory struct { // process can address (a 32-bit host asked for wasm's 4 GiB default), or // the platform refused it. The error is the reason the memory is not, or // not fully, protected; nil when it is. -func reserveMemory(capacity, max uint64, policy lockPolicy) (backend, error) { +func reserveMemory(capacity, max uint64, policy LockPolicy) (backend, error) { if max > math.MaxInt { return newHeapMemory(capacity, max), fmt.Errorf("cannot reserve %s of address space on this host", byteCount(max)) } @@ -53,7 +53,7 @@ func (m *mappedMemory) commit(size uint64) ([]byte, error) { // operator sizing a limit needs the whole of what the guest // holds with it. err = fmt.Errorf("%w; the guest needs at least %s locked", err, byteCount(size)) - if m.policy == strict && m.committed > 0 { + if m.policy == Strict && m.committed > 0 { // Nothing was written to the range yet; giving it back // leaves the guest exactly where it was. decommitRange(fresh) diff --git a/languages/golang/stackencrypt/memory_other.go b/languages/golang/internal/guest/memory_other.go similarity index 76% rename from languages/golang/stackencrypt/memory_other.go rename to languages/golang/internal/guest/memory_other.go index 080752f36..d82f03677 100644 --- a/languages/golang/stackencrypt/memory_other.go +++ b/languages/golang/internal/guest/memory_other.go @@ -1,10 +1,10 @@ //go:build !unix && !windows -package stackencrypt +package guest // Platforms with neither mmap nor VirtualAlloc in this package's // vocabulary get the heap fallback: growth and release still wipe, nothing // is locked, and the Client says so. -func reserveMemory(capacity, max uint64, _ lockPolicy) (backend, error) { +func reserveMemory(capacity, max uint64, _ LockPolicy) (backend, error) { return newHeapMemory(capacity, max), errNoLockSupport } diff --git a/languages/golang/internal/guest/memory_test.go b/languages/golang/internal/guest/memory_test.go new file mode 100644 index 000000000..77ffcb398 --- /dev/null +++ b/languages/golang/internal/guest/memory_test.go @@ -0,0 +1,269 @@ +package guest_test + +import ( + "context" + "errors" + "fmt" + "strings" + "testing" + + "github.com/cipherstash/cipherstash-suite/bindings/go/internal/guest" + "github.com/cipherstash/cipherstash-suite/bindings/go/internal/guesttest" + "github.com/tetratelabs/wazero" + "github.com/tetratelabs/wazero/api" + "github.com/tetratelabs/wazero/experimental" + "github.com/tetratelabs/wazero/sys" +) + +// The allocator on its own, under the grow probe. What it does for a real +// guest, and what a client reports about it, is tested where the guest is +// embedded (stackencrypt's memory tests). + +// The whole point of owning the allocation: growth commits more of one +// reservation, so the buffer's address is the same before and after, and +// the guest's keys are never copied to a new slice. +func TestGuestMemoryDoesNotMoveOnGrowth(t *testing.T) { + alloc := guest.NewAllocator(guest.BestEffort) + base, grow, done := guesttest.ProbeMemory(t, alloc) + defer done() + if alloc.IsFallback() { + t.Skipf("heap fallback in use on this host: %v", alloc.LockError()) + } + t.Logf("lock state on this host: %v", alloc.LockError()) + before := base() + for _, pages := range []uint32{1, 15, 64} { + if _, ok := grow(pages); !ok { + t.Fatalf("grow(%d) refused", pages) + } + if after := base(); after != before { + t.Fatalf("memory moved on grow(%d): %#x -> %#x", pages, before, after) + } + } +} + +// Free wipes then unmaps: the allocator reports the release, and the +// runtime close is what triggers it. +func TestGuestMemoryIsFreedOnRuntimeClose(t *testing.T) { + alloc := guest.NewAllocator(guest.BestEffort) + _, grow, done := guesttest.ProbeMemory(t, alloc) + if _, ok := grow(3); !ok { + t.Fatal("grow refused") + } + if alloc.IsFreed() { + t.Fatal("freed before close") + } + done() + if !alloc.IsFreed() { + t.Fatal("runtime close did not free the guest memory") + } +} + +// The refusal on a growth, from the kernel: with RLIMIT_MEMLOCK at two +// pages the probe's first page locks and a growth by two more cannot. +// Strict refuses the growth and gives the range back, so the page the +// probe holds is still locked and the allocator still says so; the +// refusal is reported on its own, naming the limit. +func TestRequireLockedMemoryRefusesAnUnlockableGrowth(t *testing.T) { + if !guesttest.HostReserves(t) { + t.Skip("heap fallback in use on this host: no reservation to lock") + } + if !guesttest.InChild(t) { + return + } + const limit = 2 * guesttest.WasmPage + if err := guesttest.SetMemlockLimit(limit); err != nil { + t.Fatalf("lowering RLIMIT_MEMLOCK: %v", err) + } + alloc := guest.NewAllocator(guest.Strict) + _, grow, done := guesttest.ProbeMemory(t, alloc) + defer done() + if err := alloc.LockError(); err != nil { + fmt.Printf("case skipped: the first page did not lock under RLIMIT_MEMLOCK=%d: %v\n", limit, err) + return + } + if _, ok := grow(2); ok { + fmt.Println("case skipped: mlock succeeds past RLIMIT_MEMLOCK") + return + } + if err := alloc.LockError(); err != nil { + t.Fatalf("a refused growth changed the lock report: %v", err) + } + g := alloc.GrowthRefusal() + if g.Refused != 1 || g.Reason == nil { + t.Fatalf("GrowthRefusal = %+v; want one, with the refusal", g) + } + if !strings.Contains(g.Reason.Error(), "RLIMIT_MEMLOCK") || !strings.Contains(g.Reason.Error(), "needs at least") { + t.Fatalf("the refusal does not name the limit and the size held: %v", g.Reason) + } + fmt.Println("case ok") +} + +// A refused growth is the growth's failure, not the memory's: the +// allocator counts it and keeps its reason, and the lock report — nil, +// or whatever this host refused at the start — is exactly what it was. +// Once the growth is let through the report is still unchanged. +func TestRefusedGrowthLeavesTheLockReportAlone(t *testing.T) { + alloc := guest.NewAllocator(guest.Strict) + base, grow, done := guesttest.ProbeMemory(t, alloc) + defer done() + before := alloc.LockError() + refusing := guest.RefuseGrowth(alloc, errors.New("refused for the test")) + at := base() + if _, ok := grow(1); ok { + t.Fatal("the refused growth was granted") + } + if after := alloc.LockError(); after != before { + t.Fatalf("the refused growth changed the lock report: %v -> %v", before, after) + } + if g := alloc.GrowthRefusal(); g.Refused != 1 || g.Reason != refusing.Reason() { + t.Fatalf("GrowthRefusal = %+v; want one, with the refusal", g) + } + refusing.Allow() + if _, ok := grow(1); !ok { + t.Fatal("growth refused once the backend lets it through") + } + if after := alloc.LockError(); after != before { + t.Fatalf("a later growth changed the lock report: %v -> %v", before, after) + } + if g := alloc.GrowthRefusal(); g.Refused != 1 { + t.Fatalf("GrowthRefusal = %+v after a granted growth, want one", g) + } + // The heap fallback may copy on growth, and says so; a reservation + // never does. + if !alloc.IsFallback() && base() != at { + t.Fatal("memory moved across the refused growth") + } +} + +// reentrantProbe is a hand-assembled module reproducing the shape of a +// guest's transport import: "run" calls the host function h, then stores +// to memory. h re-enters the guest (as transport_send does through +// se_alloc) with a context that has ended, which is how wazero comes to +// free the module's memory while the guest is suspended in the import: +// +// (module +// (import "env" "h" (func $h)) +// (memory (export "memory") 1) +// (func (export "run") call $h i32.const 0 i32.const 1 i32.store) +// (func (export "nop"))) +var reentrantProbe = []byte{ + 0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00, + 0x01, 0x04, 0x01, 0x60, 0x00, 0x00, // type: () -> () + 0x02, 0x09, 0x01, 0x03, 'e', 'n', 'v', 0x01, 'h', 0x00, 0x00, // import env.h + 0x03, 0x03, 0x02, 0x00, 0x00, // two functions of type 0 + 0x05, 0x03, 0x01, 0x00, 0x01, // memory: min 1, no max + 0x07, 0x16, 0x03, + 0x06, 'm', 'e', 'm', 'o', 'r', 'y', 0x02, 0x00, + 0x03, 'r', 'u', 'n', 0x00, 0x01, + 0x03, 'n', 'o', 'p', 0x00, 0x02, + 0x0a, 0x10, 0x02, + 0x0b, 0x00, 0x10, 0x00, 0x41, 0x00, 0x41, 0x01, 0x36, 0x02, 0x00, 0x0b, // run + 0x02, 0x00, 0x0b, // nop +} + +// The sequence that crashed in CI: a call's context ends during a host +// import, the import re-enters the guest, and wazero frees the memory in +// that nested call while the outer guest frame is still live and about to +// store. The memory must survive until the outer call has returned; an +// unmapped store here is a fault in compiled code that takes the process +// down, so this test cannot fail gently. +func TestMemoryOutlivesACallClosedDuringAHostImport(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + alloc := guest.NewAllocator(guest.BestEffort) + rt := wazero.NewRuntimeWithConfig(ctx, wazero.NewRuntimeConfig().WithCloseOnContextDone(true)) + defer rt.Close(context.Background()) + var freedDuringImport, nestedFailed bool + _, err := rt.NewHostModuleBuilder("env").NewFunctionBuilder(). + WithFunc(func(ctx context.Context, m api.Module) { + cancel() + _, nested := m.ExportedFunction("nop").Call(ctx) + nestedFailed = nested != nil + freedDuringImport = alloc.IsFreed() + }).Export("h").Instantiate(ctx) + if err != nil { + t.Fatal(err) + } + mod, err := rt.InstantiateWithConfig(experimental.WithMemoryAllocator(ctx, alloc), reentrantProbe, wazero.NewModuleConfig()) + if err != nil { + t.Fatalf("instantiating reentrant probe: %v", err) + } + alloc.Enter() + _, err = mod.ExportedFunction("run").Call(ctx) + alloc.Exit() + var exit *sys.ExitError + if !errors.As(err, &exit) || exit.ExitCode() != sys.ExitCodeContextCanceled { + t.Fatalf("run: %v, want the cancellation exit", err) + } + if !nestedFailed { + t.Fatal("the nested call did not see the closed module") + } + if freedDuringImport { + t.Fatal("memory freed while the guest was suspended in a host import") + } + if !alloc.IsFreed() { + t.Fatal("memory not freed once the outer call returned") + } +} + +// The status table decodes to the shared sentinels, and an unknown code +// is an internal failure that keeps the number. +func TestStatusDecodesToTheSharedSentinels(t *testing.T) { + want := map[uint32]error{ + guest.StatusAuth: guest.ErrAuthentication, + guest.StatusEncoding: guest.ErrEncoding, + guest.StatusState: guest.ErrState, + guest.StatusInternal: guest.ErrInternal, + guest.StatusKMSUnauthorized: guest.ErrUnauthorized, + guest.StatusKMSForbidden: guest.ErrForbidden, + guest.StatusKMSNotFound: guest.ErrNotFound, + guest.StatusKMSConflict: guest.ErrConflict, + guest.StatusKMSTransport: guest.ErrTransport, + guest.StatusKMSOther: guest.ErrKMS, + guest.StatusTerm: guest.ErrTerm, + guest.StatusForeignKeyset: guest.ErrForeignKeyset, + } + for code, sentinel := range want { + if got := guest.StatusError(code); got != sentinel { + t.Errorf("status %d decoded to %v, want %v", code, got, sentinel) + } + } + unknown := guest.StatusError(99) + if !errors.Is(unknown, guest.ErrInternal) || !strings.Contains(unknown.Error(), "99") { + t.Errorf("an unknown status decoded to %v; want ErrInternal naming the code", unknown) + } + if _, _, err := guest.PackedResult(uint64(guest.StatusEncoding)); err != guest.ErrEncoding { + t.Errorf("a packed status decoded to %v, want ErrEncoding", err) + } + if ptr, n, err := guest.PackedResult(uint64(0x1234)<<32 | 7); err != nil || ptr != 0x1234 || n != 7 { + t.Errorf("a packed buffer decoded to (%#x, %d, %v)", ptr, n, err) + } +} + +// The client key never prints its bytes, and is empty once wiped. +func TestClientKeyIsOpaqueAndWipes(t *testing.T) { + material := []byte("key material that must not print") + key := guest.NewClientKey(material) + for _, verb := range []string{"%v", "%+v", "%#v", "%s", "%x", "%q"} { + if out := fmt.Sprintf(verb, key); strings.Contains(out, "material") { + t.Errorf("%s printed the key: %q", verb, out) + } + } + if string(key.Bytes()) != "key material that must not print" { + t.Fatal("Bytes did not return the material") + } + key.Wipe() + if !key.IsZero() || key.Bytes() != nil { + t.Fatal("a wiped key still holds material") + } + for _, b := range material { + if b != 0 { + t.Fatal("the caller's slice was not wiped") + } + } + key.Wipe() // a second wipe is a no-op + var none *guest.ClientKey + if !none.IsZero() || none.Bytes() != nil || fmt.Sprint(none) == "<nil>" { + t.Fatal("a nil key is not the empty, redacted key") + } +} diff --git a/languages/golang/stackencrypt/memory_unix.go b/languages/golang/internal/guest/memory_unix.go similarity index 98% rename from languages/golang/stackencrypt/memory_unix.go rename to languages/golang/internal/guest/memory_unix.go index 1ec5e7e09..fb0413b15 100644 --- a/languages/golang/stackencrypt/memory_unix.go +++ b/languages/golang/internal/guest/memory_unix.go @@ -1,6 +1,6 @@ //go:build unix -package stackencrypt +package guest import ( "fmt" diff --git a/languages/golang/stackencrypt/memory_unix_other.go b/languages/golang/internal/guest/memory_unix_other.go similarity index 93% rename from languages/golang/stackencrypt/memory_unix_other.go rename to languages/golang/internal/guest/memory_unix_other.go index 40df8f3a5..b5f7b052d 100644 --- a/languages/golang/stackencrypt/memory_unix_other.go +++ b/languages/golang/internal/guest/memory_unix_other.go @@ -1,8 +1,8 @@ //go:build unix && !linux -package stackencrypt +package guest -// Without MAP_NORESERVE the reservation may be charged against a strict +// Without MAP_NORESERVE the reservation may be charged against a Strict // overcommit setting on the BSDs; macOS has no such accounting. const reserveFlags = 0 diff --git a/languages/golang/stackencrypt/memory_windows.go b/languages/golang/internal/guest/memory_windows.go similarity index 98% rename from languages/golang/stackencrypt/memory_windows.go rename to languages/golang/internal/guest/memory_windows.go index 0b54c5a93..8778a1e59 100644 --- a/languages/golang/stackencrypt/memory_windows.go +++ b/languages/golang/internal/guest/memory_windows.go @@ -1,4 +1,4 @@ -package stackencrypt +package guest import ( "fmt" diff --git a/languages/golang/internal/guest/status.go b/languages/golang/internal/guest/status.go new file mode 100644 index 000000000..a49c5b2d9 --- /dev/null +++ b/languages/golang/internal/guest/status.go @@ -0,0 +1,66 @@ +package guest + +import "fmt" + +// Guest status codes: the low half of a packed error result, from the one +// table every guest reports through (packages/stack-guest-abi, status.rs). +// One numbering for both guests, never renumbered; a guest that needs a +// code of its own appends after the last one there, and here. +const ( + StatusAuth = 1 + StatusEncoding = 2 + StatusState = 3 + StatusInternal = 4 + StatusKMSUnauthorized = 5 + StatusKMSForbidden = 6 + StatusKMSNotFound = 7 + StatusKMSConflict = 8 + StatusKMSTransport = 9 + StatusKMSOther = 10 + StatusTerm = 11 + StatusForeignKeyset = 12 +) + +// StatusError is the sentinel a guest status decodes to. A status this host +// does not know is still an internal failure; the code is kept so a +// guest/host version skew is diagnosable. +func StatusError(status uint32) error { + switch status { + case StatusAuth: + return ErrAuthentication + case StatusEncoding: + return ErrEncoding + case StatusState: + return ErrState + case StatusInternal: + return ErrInternal + case StatusKMSUnauthorized: + return ErrUnauthorized + case StatusKMSForbidden: + return ErrForbidden + case StatusKMSNotFound: + return ErrNotFound + case StatusKMSConflict: + return ErrConflict + case StatusKMSTransport: + return ErrTransport + case StatusKMSOther: + return ErrKMS + case StatusTerm: + return ErrTerm + case StatusForeignKeyset: + return ErrForeignKeyset + default: + return fmt.Errorf("%w (unrecognized guest status %d)", ErrInternal, status) + } +} + +// PackedResult decodes a guest export's packed u64: a non-zero high half is +// an output pointer with the length in the low half; a zero high half +// carries a status code in the low half, returned as its sentinel. +func PackedResult(packed uint64) (ptr, length uint32, err error) { + if packed>>32 == 0 { + return 0, 0, StatusError(uint32(packed)) + } + return uint32(packed >> 32), uint32(packed), nil +} diff --git a/languages/golang/internal/guest/testing.go b/languages/golang/internal/guest/testing.go new file mode 100644 index 000000000..ca2272137 --- /dev/null +++ b/languages/golang/internal/guest/testing.go @@ -0,0 +1,50 @@ +package guest + +// The testing seam: a way to refuse the guest's growth without a lock +// limit, so the bookkeeping of a refused growth — the allocator's and a +// client's — can be exercised on every host. Exported because the tests +// that need it live in the public packages, which cannot reach an +// allocator's backing; internal visibility keeps it out of any API. + +// Refusing stands in front of an allocator's backing and refuses, as +// Strict does, any commit past a size: nil buffer, the reason as the lock +// error, nothing admitted. Allowed, it delegates again. +type Refusing struct { + backend + past uint64 + reason error + refuse bool + refused int +} + +func (b *Refusing) commit(size uint64) ([]byte, error) { + if b.refuse && size > b.past { + b.refused++ + return nil, b.reason + } + return b.backend.commit(size) +} + +// Allow lifts the refusal: later growths go through to the real backing. +func (b *Refusing) Allow() { b.refuse = false } + +// Refused is how many growths were refused. +func (b *Refusing) Refused() int { return b.refused } + +// Reason is the error every refused growth reported. +func (b *Refusing) Reason() error { return b.reason } + +// RefuseGrowth puts a Refusing in front of alloc's backing, set to refuse +// any growth past what is committed now, with reason as the refusal. Call +// it on the guest's goroutine, between calls, as the backing is used. +func RefuseGrowth(alloc *Allocator, reason error) *Refusing { + refusing := &Refusing{backend: alloc.backing, past: alloc.backing.(sized).size(), reason: reason, refuse: true} + alloc.backing = refusing + return refusing +} + +// sized is what the seam needs of a backing to know where it stands. +type sized interface{ size() uint64 } + +func (m *mappedMemory) size() uint64 { return m.committed } +func (m *heapMemory) size() uint64 { return uint64(len(m.buf)) } diff --git a/languages/golang/internal/guesttest/memlock_other.go b/languages/golang/internal/guesttest/memlock_other.go new file mode 100644 index 000000000..2762a8276 --- /dev/null +++ b/languages/golang/internal/guesttest/memlock_other.go @@ -0,0 +1,8 @@ +//go:build !unix + +package guesttest + +import "errors" + +// SetMemlockLimit has nothing to lower where there is no RLIMIT_MEMLOCK. +func SetMemlockLimit(uint64) error { return errors.New("no RLIMIT_MEMLOCK on this platform") } diff --git a/languages/golang/stackencrypt/memory_unix_test.go b/languages/golang/internal/guesttest/memlock_unix.go similarity index 76% rename from languages/golang/stackencrypt/memory_unix_test.go rename to languages/golang/internal/guesttest/memlock_unix.go index 9a4a527b4..c5fd820bc 100644 --- a/languages/golang/stackencrypt/memory_unix_test.go +++ b/languages/golang/internal/guesttest/memlock_unix.go @@ -1,12 +1,12 @@ //go:build unix -package stackencrypt +package guesttest import "golang.org/x/sys/unix" -// setMemlockLimit lowers RLIMIT_MEMLOCK to n bytes for this process. Only +// SetMemlockLimit lowers RLIMIT_MEMLOCK to n bytes for this process. Only // a child test process calls it. -func setMemlockLimit(n uint64) error { +func SetMemlockLimit(n uint64) error { var lim unix.Rlimit setRlim(&lim.Cur, n) setRlim(&lim.Max, n) diff --git a/languages/golang/internal/guesttest/probe.go b/languages/golang/internal/guesttest/probe.go new file mode 100644 index 000000000..14d1f8f58 --- /dev/null +++ b/languages/golang/internal/guesttest/probe.go @@ -0,0 +1,152 @@ +// Package guesttest holds what the tests of the guest packages share: a +// hand-assembled probe module that exercises the memory allocator without +// a guest, the lock-limit machinery those tests need, and the skip rules +// for hosts that cannot run them. Internal, and imported only by _test +// files, so nothing here is API. +package guesttest + +import ( + "context" + "os" + "os/exec" + "runtime" + "strings" + "testing" + "unsafe" + + "github.com/cipherstash/cipherstash-suite/bindings/go/internal/guest" + "github.com/tetratelabs/wazero" + "github.com/tetratelabs/wazero/api" + "github.com/tetratelabs/wazero/experimental" +) + +// GrowProbe is a hand-assembled module with one page of memory and one +// export that grows it, so the allocator can be exercised without a guest: +// +// (module +// (memory (export "memory") 1) +// (func (export "grow") (param i32) (result i32) +// local.get 0 memory.grow)) +// +// Like the guests, it declares no maximum, so wazero asks the allocator for +// wasm's 4 GiB default. +var GrowProbe = []byte{ + 0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00, // magic, version + 0x01, 0x06, 0x01, 0x60, 0x01, 0x7f, 0x01, 0x7f, // type: (i32) -> i32 + 0x03, 0x02, 0x01, 0x00, // function: one, of type 0 + 0x05, 0x03, 0x01, 0x00, 0x01, // memory: one, min 1 page, no max + 0x07, 0x11, 0x02, // exports: two + 0x06, 'm', 'e', 'm', 'o', 'r', 'y', 0x02, 0x00, // "memory" = memory 0 + 0x04, 'g', 'r', 'o', 'w', 0x00, 0x00, // "grow" = func 0 + 0x0a, 0x08, 0x01, 0x06, 0x00, 0x20, 0x00, 0x40, 0x00, 0x0b, // code +} + +// WasmPage is the size of one wasm memory page. +const WasmPage = 64 * 1024 + +// ProbeMemory instantiates GrowProbe under alloc and returns the base +// address of its memory and a grow function reporting the old page count. +func ProbeMemory(t *testing.T, alloc *guest.Allocator) (base func() uintptr, grow func(pages uint32) (old uint32, ok bool), done func()) { + t.Helper() + ctx := context.Background() + rt := wazero.NewRuntime(ctx) + mod, err := rt.InstantiateWithConfig(experimental.WithMemoryAllocator(ctx, alloc), GrowProbe, wazero.NewModuleConfig()) + if err != nil { + _ = rt.Close(ctx) + t.Fatalf("instantiating grow probe: %v", err) + } + base = func() uintptr { return MemoryBase(t, mod.Memory()) } + grow = func(pages uint32) (uint32, bool) { + res, err := mod.ExportedFunction("grow").Call(ctx, uint64(pages)) + if err != nil { + t.Fatalf("grow: %v", err) + } + return uint32(res[0]), int32(res[0]) != -1 + } + done = func() { _ = rt.Close(ctx) } + return base, grow, done +} + +// MemoryBase is the host address of a module memory's first byte. Read +// returns a view into the buffer, not a copy. +func MemoryBase(t *testing.T, mem api.Memory) uintptr { + t.Helper() + view, ok := mem.Read(0, 1) + if !ok { + t.Fatal("reading guest memory") + } + return uintptr(unsafe.Pointer(unsafe.SliceData(view))) +} + +// HostReserves reports whether this host can back a guest with a +// reservation at all. Where it cannot (a 32-bit host asked for wasm's +// 4 GiB default, which CI exercises on purpose under GOARCH=386) the heap +// fallback is in use, no lock is possible, and the tests of a lock granted +// or refused have nothing to test: they skip, whatever RequireLock says. +func HostReserves(t *testing.T) bool { + t.Helper() + probe := guest.NewAllocator(guest.BestEffort) + _, _, done := ProbeMemory(t, probe) + done() + return !probe.IsFallback() +} + +// RequireLock is set in CI, where RLIMIT_MEMLOCK has been raised, so the +// lock cannot quietly go untested. Without it a refused lock is reported +// and the assertion skipped: a developer laptop's default limit is not a +// bug in these packages. +const RequireLock = "STACKENCRYPT_TESTS_REQUIRE_LOCK" + +// LockOrSkip continues if alloc's memory is locked, skips if the host +// refused the lock (or has no reservation to lock), and fails the skip +// when RequireLock says the host was meant to grant it. +func LockOrSkip(t *testing.T, alloc *guest.Allocator) { + t.Helper() + err := alloc.LockError() + if err == nil { + return + } + if alloc.IsFallback() { + // Not a refused lock: there was no reservation to lock (a 32-bit + // host), which CI exercises on purpose under GOARCH=386. + t.Skipf("heap fallback in use on this host: %v", err) + } + SkipUnlessLockRequired(t, "the lock was refused", err) +} + +// SkipUnlessLockRequired skips a test the host cannot run — what says why, +// detail is the refusal or the child's output — unless RequireLock says the +// host was meant to, in which case it fails. +func SkipUnlessLockRequired(t *testing.T, what string, detail any) { + t.Helper() + if os.Getenv(RequireLock) != "" { + t.Fatalf("%s is set and %s:\n%v", RequireLock, what, detail) + } + t.Skipf("%s on this host:\n%v", what, detail) +} + +// InChild re-runs the calling test in a child process, for tests that +// lower RLIMIT_MEMLOCK: the change is process-wide and irreversible for a +// non-root process. It returns true in the child, which prints "case ok" +// when done or "case skipped: <why>" when the host cannot provoke the +// condition; the parent judges that output and returns false. +func InChild(t *testing.T) bool { + t.Helper() + if runtime.GOOS == "windows" { + t.Skip("no RLIMIT_MEMLOCK on Windows") + } + const child = "STACKENCRYPT_TEST_CHILD" + if os.Getenv(child) != "" { + return true + } + cmd := exec.Command(os.Args[0], "-test.run=^"+t.Name()+"$", "-test.v") + cmd.Env = append(os.Environ(), child+"=1") + out, err := cmd.CombinedOutput() + switch { + case strings.Contains(string(out), "case skipped:"): + SkipUnlessLockRequired(t, "the refusal could not be provoked", string(out)) + case err != nil || !strings.Contains(string(out), "case ok"): + t.Fatalf("child failed: %v\n%s", err, out) + } + return false +} diff --git a/languages/golang/internal/guesttest/smaps_linux.go b/languages/golang/internal/guesttest/smaps_linux.go new file mode 100644 index 000000000..57e1e81dd --- /dev/null +++ b/languages/golang/internal/guesttest/smaps_linux.go @@ -0,0 +1,111 @@ +package guesttest + +import ( + "bufio" + "fmt" + "os" + "strconv" + "strings" + "testing" + + "github.com/cipherstash/cipherstash-suite/bindings/go/internal/guest" +) + +// The protection is observable from the kernel's side, in +// /proc/self/smaps. The dump exclusion has no limit, so it is asserted on +// every mapping the allocator reserves; the lock only where the host +// granted it. + +// AssertMappingProtected checks the smaps entry containing addr: dd +// (MADV_DONTDUMP) whenever there is a reservation at all, and, where the +// lock was granted, lo (mlock) with the whole resident range locked. A +// refused lock skips the second half after the first has run; a failed +// first half fails the test whatever the second does. +func AssertMappingProtected(t *testing.T, alloc *guest.Allocator, addr uintptr) { + t.Helper() + if alloc.IsFallback() { + t.Skipf("heap fallback in use on this host: %v", alloc.LockError()) + } + mapping, err := smapsEntry(addr) + if err != nil { + t.Fatal(err) + } + if !mapping.hasFlag("dd") { + t.Errorf("VmFlags = %q, want dd (MADV_DONTDUMP)", mapping.vmFlags) + } + LockOrSkip(t, alloc) + if !mapping.hasFlag("lo") { + t.Errorf("VmFlags = %q, want lo (mlock)", mapping.vmFlags) + } + if mapping.lockedKB == 0 || mapping.lockedKB != mapping.rssKB { + t.Errorf("Locked = %d kB, Rss = %d kB: the committed range is not fully locked", mapping.lockedKB, mapping.rssKB) + } +} + +type smapsMapping struct { + rssKB, lockedKB uint64 + vmFlags string +} + +func (m smapsMapping) hasFlag(flag string) bool { + return strings.Contains(" "+m.vmFlags+" ", " "+flag+" ") +} + +// smapsEntry finds the /proc/self/smaps mapping containing addr. +func smapsEntry(addr uintptr) (smapsMapping, error) { + f, err := os.Open("/proc/self/smaps") + if err != nil { + return smapsMapping{}, err + } + defer f.Close() + var cur smapsMapping + inside := false + sc := bufio.NewScanner(f) + for sc.Scan() { + line := sc.Text() + if lo, hi, ok := smapsRange(line); ok { + if inside { + return cur, nil + } + inside = addr >= lo && addr < hi + cur = smapsMapping{} + continue + } + if !inside { + continue + } + key, value, _ := strings.Cut(line, ":") + value = strings.TrimSpace(value) + switch key { + case "Rss": + cur.rssKB = smapsKB(value) + case "Locked": + cur.lockedKB = smapsKB(value) + case "VmFlags": + cur.vmFlags = value + } + } + if inside { + return cur, nil + } + return smapsMapping{}, fmt.Errorf("no smaps mapping contains %#x", addr) +} + +func smapsRange(line string) (lo, hi uintptr, ok bool) { + head, _, _ := strings.Cut(line, " ") + a, b, found := strings.Cut(head, "-") + if !found { + return 0, 0, false + } + l, err1 := strconv.ParseUint(a, 16, 64) + h, err2 := strconv.ParseUint(b, 16, 64) + if err1 != nil || err2 != nil { + return 0, 0, false + } + return uintptr(l), uintptr(h), true +} + +func smapsKB(value string) uint64 { + n, _ := strconv.ParseUint(strings.TrimSuffix(value, " kB"), 10, 64) + return n +} diff --git a/languages/golang/stackencrypt/client.go b/languages/golang/stackencrypt/client.go index 016a292da..eeaeca312 100644 --- a/languages/golang/stackencrypt/client.go +++ b/languages/golang/stackencrypt/client.go @@ -10,6 +10,7 @@ import ( "strconv" "sync" + "github.com/cipherstash/cipherstash-suite/bindings/go/internal/guest" "github.com/cipherstash/vitaminc/bindings/go/vcffi" "github.com/cipherstash/vitaminc/bindings/go/vcvalue" ) @@ -112,7 +113,7 @@ func NewClient(ctx context.Context, cfg Config) (*Client, error) { defer wipe(encoded) t := &transport{rt: rt, token: cfg.Token} - inst, err := newInstance(ctx, wasm, t, policyFor(cfg.RequireLockedMemory)) + inst, err := newInstance(ctx, wasm, t, guest.PolicyFor(cfg.RequireLockedMemory)) if err != nil { return nil, err } @@ -149,14 +150,14 @@ func newClient(inst *instance, t *transport) *Client { // memory the kernel may swap out. Nothing else changes. A production // checklist should assert this, or set [Config.RequireLockedMemory] and // let NewClient refuse. [Client.MemoryLockError] says why. -func (c *Client) MemoryLocked() bool { return c.inst.mem.lockError() == nil } +func (c *Client) MemoryLocked() bool { return c.inst.mem.LockError() == nil } // MemoryLockError is why MemoryLocked is false: an error wrapping // ErrMemoryLock that names what was refused and the limit that refused it. // Nil while the memory is locked. func (c *Client) MemoryLockError() error { - if err := c.inst.mem.lockError(); err != nil { - return memoryLockError(err) + if err := c.inst.mem.LockError(); err != nil { + return guest.MemoryLockError(err) } return nil } @@ -304,7 +305,7 @@ func (c *Client) call(ctx context.Context, f func(*instance) ([]byte, error)) ([ c.closed = true return nil, ErrState } - growth := c.inst.mem.growthRefusal() + growth := c.inst.mem.GrowthRefusal() out, err := f(c.inst) switch { case c.inst.module.IsClosed(): @@ -325,8 +326,8 @@ func (c *Client) call(ctx context.Context, f func(*instance) ([]byte, error)) ([ // of its own, aborts, and the trap closed the client above. Name the // real cause either way. The refusal is this call's, not the client's: // the range went back unused, so MemoryLocked still holds. - if g := c.inst.mem.growthRefusal(); err != nil && g.refused != growth.refused { - err = fmt.Errorf("%w (growth refused under RequireLockedMemory): %w", memoryLockError(g.reason), err) + if g := c.inst.mem.GrowthRefusal(); err != nil && g.Refused != growth.Refused { + err = fmt.Errorf("%w (growth refused under RequireLockedMemory): %w", guest.MemoryLockError(g.Reason), err) } if err != nil { return nil, err diff --git a/languages/golang/stackencrypt/clientkey.go b/languages/golang/stackencrypt/clientkey.go new file mode 100644 index 000000000..df22bc542 --- /dev/null +++ b/languages/golang/stackencrypt/clientkey.go @@ -0,0 +1,19 @@ +package stackencrypt + +import "github.com/cipherstash/cipherstash-suite/bindings/go/internal/guest" + +// ClientKey is the ZeroKMS client key: long-lived key material that lives +// for the process. It is opaque — it prints a redaction under every verb +// and hands its bytes to no caller — and it is wiped once consumed. +// +// It is the one type both guest packages share: stackauth reads one out of +// the developer profile, and this package consumes it. The alias is what +// makes a key read there the type taken here without either package +// importing the other. +type ClientKey = guest.ClientKey + +// NewClientKey wraps key material — the CS_CLIENT_KEY hex form, or the +// base64 of secretkey.json — as a ClientKey. It takes ownership of b: the +// caller must not keep or reuse the slice, which is wiped along with the +// key. +func NewClientKey(b []byte) *ClientKey { return guest.NewClientKey(b) } diff --git a/languages/golang/stackencrypt/errors.go b/languages/golang/stackencrypt/errors.go index 6c72d3c1f..d54b6a4fb 100644 --- a/languages/golang/stackencrypt/errors.go +++ b/languages/golang/stackencrypt/errors.go @@ -1,52 +1,54 @@ package stackencrypt -import ( - "errors" - "fmt" -) +import "github.com/cipherstash/cipherstash-suite/bindings/go/internal/guest" // Failure kinds surfaced across the boundary. The guest reports a status // code and nothing else, so these are the whole vocabulary: they separate a // tampered ciphertext from a bad token from a malformed input, and reveal // nothing about plaintext or key material. +// +// They are the sentinels every guest package shares (one status table for +// every guest, decoded once), exposed here under this package's names: an +// error from stackauth is the same value, so errors.Is holds across the +// two. var ( // ErrAuthentication is an AEAD open failure: a tampered ciphertext, a // wrong element derivation, or a wrong AAD that reached the AEAD. Against // ZeroKMS a wrong AAD is usually refused earlier as ErrForbidden, because // every data key is bound to its context. - ErrAuthentication = errors.New("stackencrypt: authentication failed") + ErrAuthentication = guest.ErrAuthentication // ErrEncoding is a malformed input: a value, ciphertext, plan, context, // selector or config the guest refused before any cryptography. - ErrEncoding = errors.New("stackencrypt: malformed input") + ErrEncoding = guest.ErrEncoding // ErrState is a call on a client that has been closed. - ErrState = errors.New("stackencrypt: client is closed") + ErrState = guest.ErrState // ErrInternal is a guest panic or any other unexpected guest failure. - ErrInternal = errors.New("stackencrypt: internal guest failure") + ErrInternal = guest.ErrInternal // ErrUnauthorized is ZeroKMS refusing the bearer token (HTTP 401): the // token is invalid, expired, or for another workspace. - ErrUnauthorized = errors.New("stackencrypt: ZeroKMS rejected the access token") + ErrUnauthorized = guest.ErrUnauthorized // ErrForbidden is ZeroKMS refusing the request (HTTP 403): the token is // valid but not permitted, or a data key's bound context did not match // the one presented — the production form of a wrong-AAD open. - ErrForbidden = errors.New("stackencrypt: ZeroKMS refused the request") + ErrForbidden = guest.ErrForbidden // ErrNotFound is ZeroKMS reporting a missing resource (HTTP 404): an // unknown keyset name or id, or a data key that does not exist. - ErrNotFound = errors.New("stackencrypt: ZeroKMS resource not found") + ErrNotFound = guest.ErrNotFound // ErrConflict is ZeroKMS reporting a resource conflict (HTTP 409). - ErrConflict = errors.New("stackencrypt: ZeroKMS resource conflict") + ErrConflict = guest.ErrConflict // ErrTransport is a failure to reach ZeroKMS or to read its response: // the transport returned an error, or the endpoint could not be resolved. - ErrTransport = errors.New("stackencrypt: ZeroKMS transport failed") + ErrTransport = guest.ErrTransport // ErrKMS is any other ZeroKMS failure: an unparseable response, invalid // key material, or an unclassified server error. - ErrKMS = errors.New("stackencrypt: ZeroKMS request failed") + ErrKMS = guest.ErrKMS // ErrTerm is a term derivation the scheme could not perform for the // given input, such as match text that yields no tokens. - ErrTerm = errors.New("stackencrypt: term derivation failed") + ErrTerm = guest.ErrTerm // ErrForeignKeyset is a keyset-bound Cipher refusing a ciphertext sealed // under another keyset, before any key is retrieved. Open it through the // Client, which is not bound to one keyset. - ErrForeignKeyset = errors.New("stackencrypt: ciphertext belongs to another keyset") + ErrForeignKeyset = guest.ErrForeignKeyset // ErrMemoryLock is guest memory that could not be locked in RAM (or, // on Linux, excluded from core dumps). NewClient returns it when // Config.RequireLockedMemory is set, and so does any later call under @@ -55,55 +57,5 @@ var ( // with unlocked memory. The wrapped error names the limit that refused // the lock and the size the guest holds: on Linux, RLIMIT_MEMLOCK // (ulimit -l, a systemd LimitMEMLOCK=, or a pod's securityContext). - ErrMemoryLock = errors.New("stackencrypt: guest memory is not locked") -) - -// Guest status codes (guest/src/status.rs). Part of the guest/host -// contract; never renumbered. -const ( - statusAuth = 1 - statusEncoding = 2 - statusState = 3 - statusInternal = 4 - statusKMSUnauthorized = 5 - statusKMSForbidden = 6 - statusKMSNotFound = 7 - statusKMSConflict = 8 - statusKMSTransport = 9 - statusKMSOther = 10 - statusTerm = 11 - statusForeignKeyset = 12 + ErrMemoryLock = guest.ErrMemoryLock ) - -func statusError(status uint32) error { - switch status { - case statusAuth: - return ErrAuthentication - case statusEncoding: - return ErrEncoding - case statusState: - return ErrState - case statusInternal: - return ErrInternal - case statusKMSUnauthorized: - return ErrUnauthorized - case statusKMSForbidden: - return ErrForbidden - case statusKMSNotFound: - return ErrNotFound - case statusKMSConflict: - return ErrConflict - case statusKMSTransport: - return ErrTransport - case statusKMSOther: - return ErrKMS - case statusTerm: - return ErrTerm - case statusForeignKeyset: - return ErrForeignKeyset - default: - // A status this host does not know is still an internal failure; - // the code is kept so a guest/host version skew is diagnosable. - return fmt.Errorf("%w (unrecognized guest status %d)", ErrInternal, status) - } -} diff --git a/languages/golang/stackencrypt/example/README.md b/languages/golang/stackencrypt/example/README.md index b81c9b2a7..2d6f35d06 100644 --- a/languages/golang/stackencrypt/example/README.md +++ b/languages/golang/stackencrypt/example/README.md @@ -16,7 +16,7 @@ Or, if you would rather drive it yourself: ```bash mise run wasm:guest:build -cd bindings/go/stackencrypt && go run ./example +cd bindings/go && go run ./stackencrypt/example ``` The guest build is not optional. This package embeds diff --git a/languages/golang/stackencrypt/guest.go b/languages/golang/stackencrypt/guest.go index 53e26b504..85f07142f 100644 --- a/languages/golang/stackencrypt/guest.go +++ b/languages/golang/stackencrypt/guest.go @@ -8,6 +8,7 @@ import ( "fmt" "sync" + "github.com/cipherstash/cipherstash-suite/bindings/go/internal/guest" "github.com/tetratelabs/wazero" "github.com/tetratelabs/wazero/api" "github.com/tetratelabs/wazero/experimental" @@ -61,9 +62,9 @@ func compilationCache() wazero.CompilationCache { type instance struct { runtime wazero.Runtime module api.Module - // mem supplied the module's linear memory (see memory.go) and reports + // mem supplied the module's linear memory (internal/guest) and reports // on it. - mem *memoryAllocator + mem *guest.Allocator alloc, dealloc api.Function cipherInit, shutdown, keyset api.Function @@ -92,10 +93,10 @@ func guestModuleConfig() wazero.ModuleConfig { } // newInstance instantiates wasm with the transport as its host module and -// its linear memory from this package's allocator. Under the strict -// policy, memory that cannot be locked fails instantiation with +// its linear memory from the guest packages' shared allocator. Under the +// strict policy, memory that cannot be locked fails instantiation with // ErrMemoryLock. -func newInstance(ctx context.Context, wasm []byte, t *transport, policy lockPolicy) (*instance, error) { +func newInstance(ctx context.Context, wasm []byte, t *transport, policy guest.LockPolicy) (*instance, error) { // WithCloseOnContextDone lets a caller's deadline or cancellation // interrupt an in-flight guest call — which otherwise holds the Client's // lock against every other user. An interrupted call closes the module, @@ -118,33 +119,33 @@ func newInstance(ctx context.Context, wasm []byte, t *transport, policy lockPoli _ = runtime.Close(ctx) return nil, err } - // The guest's linear memory comes from this package, not wazero's - // default slice: reserved once, locked and non-dumpable where the - // platform allows, wiped on release. See memory.go. - mem := newMemoryAllocator(policy) + // The guest's linear memory comes from the guest packages' allocator, + // not wazero's default slice: reserved once, locked and non-dumpable + // where the platform allows, wiped on release. See internal/guest. + mem := guest.NewAllocator(policy) // The guest is a reactor (cdylib): no _start. wazero runs _initialize // when present, so guest code runs here too, and the memory must stay // mapped until it returns, the same as around a call. Today nothing in // _initialize re-enters the guest from Go, which is the only path that // frees memory under a suspended guest; the bracket makes that a // property of this code rather than of what the guest's constructors - // happen to call. See memoryAllocator.Free. - mem.enter() + // happen to call. See guest.Allocator.Free. + mem.Enter() module, err := func() (api.Module, error) { - defer mem.exit() + defer mem.Exit() return runtime.InstantiateWithConfig(experimental.WithMemoryAllocator(ctx, mem), wasm, guestModuleConfig()) }() if err != nil { _ = runtime.Close(ctx) - if g := mem.growthRefusal(); g.refused != 0 { - return nil, fmt.Errorf("%w: %w", memoryLockError(g.reason), err) + if g := mem.GrowthRefusal(); g.Refused != 0 { + return nil, fmt.Errorf("%w: %w", guest.MemoryLockError(g.Reason), err) } return nil, fmt.Errorf("stackencrypt: instantiating guest: %w", err) } - if policy == strict { - if lerr := mem.lockError(); lerr != nil { + if policy == guest.Strict { + if lerr := mem.LockError(); lerr != nil { _ = runtime.Close(ctx) - return nil, memoryLockError(lerr) + return nil, guest.MemoryLockError(lerr) } } inst := &instance{runtime: runtime, module: module, mem: mem} @@ -181,9 +182,9 @@ func newInstance(ctx context.Context, wasm []byte, t *transport, policy lockPoli func (inst *instance) release() error { ctx := context.Background() if !inst.module.IsClosed() { - inst.mem.enter() + inst.mem.Enter() _, _ = inst.shutdown.Call(ctx) - inst.mem.exit() + inst.mem.Exit() } return inst.runtime.Close(ctx) } @@ -221,14 +222,15 @@ func (inst *instance) free(ctx context.Context, buf guestBuf) { } } -// packedResult decodes the guest's packed u64: a non-zero high half is an -// output pointer with the length in the low half; a zero high half carries -// a status code in the low half. +// packedResult decodes the guest's packed u64 (guest.PackedResult): a +// non-zero high half is an output pointer with the length in the low half; +// a zero high half carries a status code, decoded to its sentinel. func packedResult(packed uint64) (guestBuf, error) { - if packed>>32 == 0 { - return guestBuf{}, statusError(uint32(packed)) + ptr, n, err := guest.PackedResult(packed) + if err != nil { + return guestBuf{}, err } - return guestBuf{ptr: uint32(packed >> 32), len: uint32(packed)}, nil + return guestBuf{ptr: ptr, len: n}, nil } // arg is one guest-call argument: a buffer (staged into guest memory and @@ -249,9 +251,9 @@ func (inst *instance) call(ctx context.Context, fn api.Function, args ...arg) ([ // The memory stays mapped for the whole call, the deferred frees // included: a close that lands mid-call (an expired context during a // host import) is honoured by this exit, not under running guest - // code. See memoryAllocator.Free. - inst.mem.enter() - defer inst.mem.exit() + // code. See guest.Allocator.Free. + inst.mem.Enter() + defer inst.mem.Exit() var bufs []guestBuf defer func() { for _, b := range bufs { diff --git a/languages/golang/stackencrypt/guest_test.go b/languages/golang/stackencrypt/guest_test.go index 40cc83f01..69f1aa9c6 100644 --- a/languages/golang/stackencrypt/guest_test.go +++ b/languages/golang/stackencrypt/guest_test.go @@ -16,6 +16,7 @@ import ( "testing" "time" + "github.com/cipherstash/cipherstash-suite/bindings/go/internal/guest" "github.com/cipherstash/vitaminc/bindings/go/vcvalue" "github.com/tetratelabs/wazero" "github.com/tetratelabs/wazero/sys" @@ -476,7 +477,7 @@ func TestConfigValidation(t *testing.T) { func rawInstance(t *testing.T) *Client { t.Helper() ctx := context.Background() - inst, err := newInstance(ctx, guestOrSkip(t), &transport{rt: http.DefaultTransport, token: StaticToken("t")}, bestEffort) + inst, err := newInstance(ctx, guestOrSkip(t), &transport{rt: http.DefaultTransport, token: StaticToken("t")}, guest.BestEffort) if err != nil { t.Fatal(err) } @@ -707,7 +708,7 @@ func TestClientKeyDoesNotRemainInGuestMemory(t *testing.T) { t.Fatal(err) } tr := &transport{rt: http.DefaultTransport, token: StaticToken("stub-token")} - inst, err := newInstance(ctx, guestOrSkip(t), tr, bestEffort) + inst, err := newInstance(ctx, guestOrSkip(t), tr, guest.BestEffort) if err != nil { t.Fatal(err) } @@ -742,7 +743,7 @@ func TestTransportSendCounterAndResponseHeaders(t *testing.T) { stub := newStub(t, http.StatusUnauthorized, "text/plain", "nope") tr := &transport{rt: http.DefaultTransport, token: StaticToken("stub-token")} ctx := context.Background() - inst, err := newInstance(ctx, guestOrSkip(t), tr, bestEffort) + inst, err := newInstance(ctx, guestOrSkip(t), tr, guest.BestEffort) if err != nil { t.Fatal(err) } diff --git a/languages/golang/stackencrypt/memory_linux_test.go b/languages/golang/stackencrypt/memory_linux_test.go index 65f06e3a2..782939efd 100644 --- a/languages/golang/stackencrypt/memory_linux_test.go +++ b/languages/golang/stackencrypt/memory_linux_test.go @@ -1,131 +1,20 @@ package stackencrypt import ( - "bufio" "context" - "fmt" - "os" - "strconv" - "strings" "testing" -) - -// The protection is observable from the kernel's side, in -// /proc/self/smaps. The dump exclusion has no limit, so it is asserted on -// every mapping this package reserves; the lock only where the host -// granted it. -// The probe's mapping, on a range committed by Reallocate after the -// mprotect split, not only on what Allocate set up. -func TestGuestMemoryIsLockedAndNotDumpable(t *testing.T) { - alloc := newMemoryAllocator(bestEffort) - base, grow, done := probeMemory(t, alloc) - defer done() - if _, ok := grow(2); !ok { - t.Fatal("grow refused") - } - assertMappingProtected(t, alloc, base()) -} + "github.com/cipherstash/cipherstash-suite/bindings/go/internal/guesttest" +) -// The real guest's mapping, once a host-staged buffer has made it grow. +// The real guest's mapping, once a host-staged buffer has made it grow: +// excluded from dumps, and locked where the host granted it, as seen from +// /proc/self/smaps. The probe's mapping is checked the same way in +// internal/guest. func TestGuestMappingIsLockedAndNotDumpable(t *testing.T) { c := rawInstance(t) if err := stageLarge(context.Background(), c.inst); err != nil { t.Fatal(err) } - assertMappingProtected(t, c.inst.mem, memoryBase(t, c.inst.module.Memory())) -} - -// assertMappingProtected checks the smaps entry containing addr: dd -// (MADV_DONTDUMP) whenever there is a reservation at all, and, where the -// lock was granted, lo (mlock) with the whole resident range locked. A -// refused lock skips the second half after the first has run; a failed -// first half fails the test whatever the second does. -func assertMappingProtected(t *testing.T, alloc *memoryAllocator, addr uintptr) { - t.Helper() - if alloc.isFallback() { - t.Skipf("heap fallback in use on this host: %v", alloc.lockError()) - } - mapping, err := smapsEntry(addr) - if err != nil { - t.Fatal(err) - } - if !mapping.hasFlag("dd") { - t.Errorf("VmFlags = %q, want dd (MADV_DONTDUMP)", mapping.vmFlags) - } - lockOrSkip(t, alloc) - if !mapping.hasFlag("lo") { - t.Errorf("VmFlags = %q, want lo (mlock)", mapping.vmFlags) - } - if mapping.lockedKB == 0 || mapping.lockedKB != mapping.rssKB { - t.Errorf("Locked = %d kB, Rss = %d kB: the committed range is not fully locked", mapping.lockedKB, mapping.rssKB) - } -} - -type smapsMapping struct { - rssKB, lockedKB uint64 - vmFlags string -} - -func (m smapsMapping) hasFlag(flag string) bool { - return strings.Contains(" "+m.vmFlags+" ", " "+flag+" ") -} - -// smapsEntry finds the /proc/self/smaps mapping containing addr. -func smapsEntry(addr uintptr) (smapsMapping, error) { - f, err := os.Open("/proc/self/smaps") - if err != nil { - return smapsMapping{}, err - } - defer f.Close() - var cur smapsMapping - inside := false - sc := bufio.NewScanner(f) - for sc.Scan() { - line := sc.Text() - if lo, hi, ok := smapsRange(line); ok { - if inside { - return cur, nil - } - inside = addr >= lo && addr < hi - cur = smapsMapping{} - continue - } - if !inside { - continue - } - key, value, _ := strings.Cut(line, ":") - value = strings.TrimSpace(value) - switch key { - case "Rss": - cur.rssKB = smapsKB(value) - case "Locked": - cur.lockedKB = smapsKB(value) - case "VmFlags": - cur.vmFlags = value - } - } - if inside { - return cur, nil - } - return smapsMapping{}, fmt.Errorf("no smaps mapping contains %#x", addr) -} - -func smapsRange(line string) (lo, hi uintptr, ok bool) { - head, _, _ := strings.Cut(line, " ") - a, b, found := strings.Cut(head, "-") - if !found { - return 0, 0, false - } - l, err1 := strconv.ParseUint(a, 16, 64) - h, err2 := strconv.ParseUint(b, 16, 64) - if err1 != nil || err2 != nil { - return 0, 0, false - } - return uintptr(l), uintptr(h), true -} - -func smapsKB(value string) uint64 { - n, _ := strconv.ParseUint(strings.TrimSuffix(value, " kB"), 10, 64) - return n + guesttest.AssertMappingProtected(t, c.inst.mem, guesttest.MemoryBase(t, c.inst.module.Memory())) } diff --git a/languages/golang/stackencrypt/memory_other_test.go b/languages/golang/stackencrypt/memory_other_test.go deleted file mode 100644 index d5b65ea93..000000000 --- a/languages/golang/stackencrypt/memory_other_test.go +++ /dev/null @@ -1,7 +0,0 @@ -//go:build !unix - -package stackencrypt - -import "errors" - -func setMemlockLimit(uint64) error { return errors.New("no RLIMIT_MEMLOCK on this platform") } diff --git a/languages/golang/stackencrypt/memory_test.go b/languages/golang/stackencrypt/memory_test.go index cabbfba0f..8fec79abe 100644 --- a/languages/golang/stackencrypt/memory_test.go +++ b/languages/golang/stackencrypt/memory_test.go @@ -4,92 +4,19 @@ import ( "context" "errors" "fmt" - "math" "net/http" - "os" - "os/exec" "runtime" "strings" "testing" "time" - "unsafe" - "github.com/tetratelabs/wazero" - "github.com/tetratelabs/wazero/api" - "github.com/tetratelabs/wazero/experimental" - "github.com/tetratelabs/wazero/sys" + "github.com/cipherstash/cipherstash-suite/bindings/go/internal/guest" + "github.com/cipherstash/cipherstash-suite/bindings/go/internal/guesttest" ) -// growProbe is a hand-assembled module with one page of memory and one -// export that grows it, so the allocator can be exercised without the -// guest: -// -// (module -// (memory (export "memory") 1) -// (func (export "grow") (param i32) (result i32) -// local.get 0 memory.grow)) -// -// Like the guest, it declares no maximum, so wazero asks the allocator for -// wasm's 4 GiB default. -var growProbe = []byte{ - 0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00, // magic, version - 0x01, 0x06, 0x01, 0x60, 0x01, 0x7f, 0x01, 0x7f, // type: (i32) -> i32 - 0x03, 0x02, 0x01, 0x00, // function: one, of type 0 - 0x05, 0x03, 0x01, 0x00, 0x01, // memory: one, min 1 page, no max - 0x07, 0x11, 0x02, // exports: two - 0x06, 'm', 'e', 'm', 'o', 'r', 'y', 0x02, 0x00, // "memory" = memory 0 - 0x04, 'g', 'r', 'o', 'w', 0x00, 0x00, // "grow" = func 0 - 0x0a, 0x08, 0x01, 0x06, 0x00, 0x20, 0x00, 0x40, 0x00, 0x0b, // code -} - -const wasmPage = 64 * 1024 - -// probeMemory instantiates growProbe under alloc and returns the base -// address of its memory and a grow function reporting the old page count. -func probeMemory(t *testing.T, alloc *memoryAllocator) (base func() uintptr, grow func(pages uint32) (old uint32, ok bool), done func()) { - t.Helper() - ctx := context.Background() - rt := wazero.NewRuntime(ctx) - mod, err := rt.InstantiateWithConfig(experimental.WithMemoryAllocator(ctx, alloc), growProbe, wazero.NewModuleConfig()) - if err != nil { - _ = rt.Close(ctx) - t.Fatalf("instantiating grow probe: %v", err) - } - base = func() uintptr { return memoryBase(t, mod.Memory()) } - grow = func(pages uint32) (uint32, bool) { - res, err := mod.ExportedFunction("grow").Call(ctx, uint64(pages)) - if err != nil { - t.Fatalf("grow: %v", err) - } - return uint32(res[0]), int32(res[0]) != -1 - } - done = func() { _ = rt.Close(ctx) } - return base, grow, done -} - -// memoryBase is the host address of a module memory's first byte. Read -// returns a view into the buffer, not a copy. -func memoryBase(t *testing.T, mem api.Memory) uintptr { - t.Helper() - view, ok := mem.Read(0, 1) - if !ok { - t.Fatal("reading guest memory") - } - return uintptr(unsafe.Pointer(unsafe.SliceData(view))) -} - -// hostReserves reports whether this host can back the guest with a -// reservation at all. Where it cannot (a 32-bit host asked for wasm's -// 4 GiB default, which CI exercises on purpose under GOARCH=386) the heap -// fallback is in use, no lock is possible, and the tests of a lock granted -// or refused have nothing to test: they skip, whatever requireLock says. -func hostReserves(t *testing.T) bool { - t.Helper() - probe := newMemoryAllocator(bestEffort) - _, _, done := probeMemory(t, probe) - done() - return !probe.isFallback() -} +// The allocator on its own is tested in internal/guest. These are the +// properties a Client over the real guest has because of it, and what the +// Client reports about its memory. // stageLarge stages a buffer larger than the guest's initial memory, so // the guest must grow, and frees it again. @@ -102,176 +29,49 @@ func stageLarge(ctx context.Context, inst *instance) error { return nil } -// The whole point of owning the allocation: growth commits more of one -// reservation, so the buffer's address is the same before and after, and -// the guest's keys are never copied to a new slice. -func TestGuestMemoryDoesNotMoveOnGrowth(t *testing.T) { - alloc := newMemoryAllocator(bestEffort) - base, grow, done := probeMemory(t, alloc) - defer done() - if alloc.isFallback() { - t.Skipf("heap fallback in use on this host: %v", alloc.lockError()) - } - t.Logf("lock state on this host: %v", alloc.lockError()) - before := base() - for _, pages := range []uint32{1, 15, 64} { - if _, ok := grow(pages); !ok { - t.Fatalf("grow(%d) refused", pages) - } - if after := base(); after != before { - t.Fatalf("memory moved on grow(%d): %#x -> %#x", pages, before, after) - } - } -} - -// The same property on the real guest: a host-staged buffer larger than -// the guest's initial memory makes it grow, and its memory stays where it -// was. The guest's own mapping is checked in smaps on Linux, in +// A host-staged buffer larger than the guest's initial memory makes it +// grow, and its memory stays where it was: growth commits more of one +// reservation, so the guest's keys are never copied to a new slice. The +// guest's own mapping is checked in smaps on Linux, in // memory_linux_test.go. func TestGuestGrowsInPlace(t *testing.T) { c := rawInstance(t) mem := c.inst.module.Memory() - if c.inst.mem.isFallback() { - t.Skipf("heap fallback in use on this host: %v", c.inst.mem.lockError()) + if c.inst.mem.IsFallback() { + t.Skipf("heap fallback in use on this host: %v", c.inst.mem.LockError()) } - before, pagesBefore := memoryBase(t, mem), mem.Size()/wasmPage + before, pagesBefore := guesttest.MemoryBase(t, mem), mem.Size()/guesttest.WasmPage if err := stageLarge(context.Background(), c.inst); err != nil { t.Fatal(err) } - if after := memoryBase(t, mem); after != before { + if after := guesttest.MemoryBase(t, mem); after != before { t.Fatalf("guest memory moved on growth: %#x -> %#x", before, after) } - if pagesAfter := mem.Size() / wasmPage; pagesAfter <= pagesBefore { + if pagesAfter := mem.Size() / guesttest.WasmPage; pagesAfter <= pagesBefore { t.Fatalf("guest memory did not grow: %d pages before, %d after", pagesBefore, pagesAfter) } } -// Free wipes then unmaps: the allocator reports the release, and the -// runtime close is what triggers it. -func TestGuestMemoryIsFreedOnRuntimeClose(t *testing.T) { - alloc := newMemoryAllocator(bestEffort) - _, grow, done := probeMemory(t, alloc) - if _, ok := grow(3); !ok { - t.Fatal("grow refused") - } - if alloc.isFreed() { - t.Fatal("freed before close") - } - done() - if !alloc.isFreed() { - t.Fatal("runtime close did not free the guest memory") - } -} - -// The heap fallback keeps the two properties it can: growth wipes the -// slice it abandons, and Free wipes. -func TestHeapMemoryWipesWhatItAbandons(t *testing.T) { - m := newHeapMemory(wasmPage, 4*wasmPage) - first, _ := m.commit(wasmPage) - first[0], first[wasmPage-1] = 0xAA, 0xBB - second, _ := m.commit(3 * wasmPage) - if second[0] != 0xAA || second[wasmPage-1] != 0xBB { - t.Fatal("growth lost the contents") - } - if first[0] != 0 || first[wasmPage-1] != 0 { - t.Fatal("growth left the abandoned slice unwiped") - } - if buf, _ := m.commit(5 * wasmPage); buf != nil { - t.Fatal("grew past max") - } - // A size no slice on this host can hold is a refused growth, not a - // panic. Only a 32-bit host can ask without the request being a real - // allocation, so that is where it runs (CI's GOARCH=386 pass). - if uint64(math.MaxInt) < 1<<40 { - huge := newHeapMemory(0, 1<<40) - if buf, _ := huge.commit(1 << 40); buf != nil { - t.Fatal("a growth past the addressable size was granted") - } - } - second[7] = 0xCC - m.free() - if second[7] != 0 { - t.Fatal("free left the slice unwiped") - } -} - -// requireLock is set in CI, where RLIMIT_MEMLOCK has been raised, so the -// lock cannot quietly go untested. Without it a refused lock is reported -// and the assertion skipped: a developer laptop's default limit is not a -// bug in this package. -const requireLock = "STACKENCRYPT_TESTS_REQUIRE_LOCK" - -func lockOrSkip(t *testing.T, alloc *memoryAllocator) { - t.Helper() - err := alloc.lockError() - if err == nil { - return - } - if alloc.isFallback() { - // Not a refused lock: there was no reservation to lock (a 32-bit - // host), which CI exercises on purpose under GOARCH=386. - t.Skipf("heap fallback in use on this host: %v", err) - } - skipUnlessLockRequired(t, "the lock was refused", err) -} - -// skipUnlessLockRequired skips a test the host cannot run — what says why, -// detail is the refusal or the child's output — unless requireLock says the -// host was meant to, in which case it fails. -func skipUnlessLockRequired(t *testing.T, what string, detail any) { - t.Helper() - if os.Getenv(requireLock) != "" { - t.Fatalf("%s is set and %s:\n%v", requireLock, what, detail) - } - t.Skipf("%s on this host:\n%v", what, detail) -} - -// inChild re-runs the calling test in a child process, for tests that -// lower RLIMIT_MEMLOCK: the change is process-wide and irreversible for a -// non-root process. It returns true in the child, which prints "case ok" -// when done or "case skipped: <why>" when the host cannot provoke the -// condition; the parent judges that output and returns false. -func inChild(t *testing.T) bool { - t.Helper() - if runtime.GOOS == "windows" { - t.Skip("no RLIMIT_MEMLOCK on Windows") - } - const child = "STACKENCRYPT_TEST_CHILD" - if os.Getenv(child) != "" { - return true - } - cmd := exec.Command(os.Args[0], "-test.run=^"+t.Name()+"$", "-test.v") - cmd.Env = append(os.Environ(), child+"=1") - out, err := cmd.CombinedOutput() - switch { - case strings.Contains(string(out), "case skipped:"): - skipUnlessLockRequired(t, "the refusal could not be provoked", string(out)) - case err != nil || !strings.Contains(string(out), "case ok"): - t.Fatalf("child failed: %v\n%s", err, out) - } - return false -} - // Strict mode is a NewClient failure, not a report. The refusal is // provoked by lowering RLIMIT_MEMLOCK to zero. func TestRequireLockedMemoryRefusesAnUnlockableGuest(t *testing.T) { - if !inChild(t) { + if !guesttest.InChild(t) { return } - if err := setMemlockLimit(0); err != nil { + if err := guesttest.SetMemlockLimit(0); err != nil { t.Fatalf("lowering RLIMIT_MEMLOCK: %v", err) } // Can the lock be refused at all here? Root and CAP_IPC_LOCK ignore // the limit. On a 32-bit host there is no reservation to lock, and the // strict refusal is the reservation's, not the limit's. - probe := newMemoryAllocator(bestEffort) - _, _, done := probeMemory(t, probe) + probe := guest.NewAllocator(guest.BestEffort) + _, _, done := guesttest.ProbeMemory(t, probe) done() - if probe.lockError() == nil { + if probe.LockError() == nil { fmt.Println("case skipped: mlock succeeds under RLIMIT_MEMLOCK=0") return } - limited := !probe.isFallback() + limited := !probe.IsFallback() cfg := Config{ ClientID: "6a70bd18-99ac-4650-b104-37eec3a15b09", ClientKey: "00", @@ -289,7 +89,7 @@ func TestRequireLockedMemoryRefusesAnUnlockableGuest(t *testing.T) { // Best effort under the same refusal: the client exists, says so, and // shows it wherever it is printed or logged. if wasm, gerr := embeddedGuest(); gerr == nil { - inst, err := newInstance(context.Background(), wasm, &transport{rt: http.DefaultTransport, token: cfg.Token}, bestEffort) + inst, err := newInstance(context.Background(), wasm, &transport{rt: http.DefaultTransport, token: cfg.Token}, guest.BestEffort) if err != nil { t.Fatal(err) } @@ -311,129 +111,16 @@ func TestRequireLockedMemoryRefusesAnUnlockableGuest(t *testing.T) { fmt.Println("case ok") } -// The same refusal on a growth, from the kernel: with RLIMIT_MEMLOCK at -// two pages the probe's first page locks and a growth by two more cannot. -// Strict refuses the growth and gives the range back, so the page the -// probe holds is still locked and the allocator still says so; the -// refusal is reported on its own, naming the limit. -func TestRequireLockedMemoryRefusesAnUnlockableGrowth(t *testing.T) { - if !hostReserves(t) { - t.Skip("heap fallback in use on this host: no reservation to lock") - } - if !inChild(t) { - return - } - const limit = 2 * wasmPage - if err := setMemlockLimit(limit); err != nil { - t.Fatalf("lowering RLIMIT_MEMLOCK: %v", err) - } - alloc := newMemoryAllocator(strict) - _, grow, done := probeMemory(t, alloc) - defer done() - if err := alloc.lockError(); err != nil { - fmt.Printf("case skipped: the first page did not lock under RLIMIT_MEMLOCK=%d: %v\n", limit, err) - return - } - if _, ok := grow(2); ok { - fmt.Println("case skipped: mlock succeeds past RLIMIT_MEMLOCK") - return - } - if err := alloc.lockError(); err != nil { - t.Fatalf("a refused growth changed the lock report: %v", err) - } - g := alloc.growthRefusal() - if g.refused != 1 || g.reason == nil { - t.Fatalf("growthRefusal = %+v; want one, with the refusal", g) - } - if !strings.Contains(g.reason.Error(), "RLIMIT_MEMLOCK") || !strings.Contains(g.reason.Error(), "needs at least") { - t.Fatalf("the refusal does not name the limit and the size held: %v", g.reason) - } - fmt.Println("case ok") -} - -// refusingBackend stands in front of a real backend and refuses, as -// strict does, any commit past a size: nil buffer, the reason as the lock -// error, nothing admitted. Lifted, it delegates again. It exercises the -// allocator's and the Client's bookkeeping of a refused growth without a -// lock limit, so it runs on every host. -type refusingBackend struct { - backend - past uint64 - reason error - refuse bool - refused int -} - -func (b *refusingBackend) commit(size uint64) ([]byte, error) { - if b.refuse && size > b.past { - b.refused++ - return nil, b.reason - } - return b.backend.commit(size) -} - -// refuseGrowth puts a refusingBackend in front of alloc's backing, set to -// refuse any growth past what is committed now. Called on the guest's -// goroutine, between calls, as the backing is. -func refuseGrowth(alloc *memoryAllocator) *refusingBackend { - refusing := &refusingBackend{backend: alloc.backing, past: alloc.backing.(sized).size(), reason: errors.New("refused for the test"), refuse: true} - alloc.backing = refusing - return refusing -} - -// sized is what the tests need of a backing to know where it stands. -type sized interface{ size() uint64 } - -func (m *mappedMemory) size() uint64 { return m.committed } -func (m *heapMemory) size() uint64 { return uint64(len(m.buf)) } - -// A refused growth is the growth's failure, not the memory's: the -// allocator counts it and keeps its reason, and the lock report — nil, -// or whatever this host refused at the start — is exactly what it was. -// Once the growth is let through the report is still unchanged. -func TestRefusedGrowthLeavesTheLockReportAlone(t *testing.T) { - alloc := newMemoryAllocator(strict) - base, grow, done := probeMemory(t, alloc) - defer done() - before := alloc.lockError() - refusing := refuseGrowth(alloc) - at := base() - if _, ok := grow(1); ok { - t.Fatal("the refused growth was granted") - } - if after := alloc.lockError(); after != before { - t.Fatalf("the refused growth changed the lock report: %v -> %v", before, after) - } - if g := alloc.growthRefusal(); g.refused != 1 || g.reason != refusing.reason { - t.Fatalf("growthRefusal = %+v; want one, with the refusal", g) - } - refusing.refuse = false - if _, ok := grow(1); !ok { - t.Fatal("growth refused once the backend lets it through") - } - if after := alloc.lockError(); after != before { - t.Fatalf("a later growth changed the lock report: %v -> %v", before, after) - } - if g := alloc.growthRefusal(); g.refused != 1 { - t.Fatalf("growthRefusal = %+v after a granted growth, want one", g) - } - // The heap fallback may copy on growth, and says so; a reservation - // never does. - if !alloc.isFallback() && base() != at { - t.Fatal("memory moved across the refused growth") - } -} - // strictClient is a Client over the real guest under the strict policy, // or a skip where this host refuses the lock. func strictClient(t *testing.T) *Client { t.Helper() - if !hostReserves(t) { + if !guesttest.HostReserves(t) { t.Skip("heap fallback in use on this host: a strict client cannot exist") } - inst, err := newInstance(context.Background(), guestOrSkip(t), &transport{rt: http.DefaultTransport, token: StaticToken("t")}, strict) + inst, err := newInstance(context.Background(), guestOrSkip(t), &transport{rt: http.DefaultTransport, token: StaticToken("t")}, guest.Strict) if errors.Is(err, ErrMemoryLock) { - skipUnlessLockRequired(t, "the lock was refused", err) + guesttest.SkipUnlessLockRequired(t, "the lock was refused", err) } if err != nil { t.Fatal(err) @@ -446,20 +133,20 @@ func strictClient(t *testing.T) *Client { return c } -// The same, through the Client: the call that needed the growth for a -// host-staged buffer fails with ErrMemoryLock naming the refusal, the -// client is still open, and it still reports locked memory everywhere it -// is asked — the method, the error, the print and the log. +// Through the Client: the call that needed the growth for a host-staged +// buffer fails with ErrMemoryLock naming the refusal, the client is still +// open, and it still reports locked memory everywhere it is asked — the +// method, the error, the print and the log. func TestRequireLockedMemoryFailsTheCallThatCannotGrow(t *testing.T) { ctx := context.Background() c := strictClient(t) - refusing := refuseGrowth(c.inst.mem) + refusing := guest.RefuseGrowth(c.inst.mem, errors.New("refused for the test")) stage := func(inst *instance) ([]byte, error) { return nil, stageLarge(ctx, inst) } _, err := c.call(ctx, stage) - if !errors.Is(err, ErrMemoryLock) || !strings.Contains(err.Error(), "growth refused") || !strings.Contains(err.Error(), refusing.reason.Error()) { + if !errors.Is(err, ErrMemoryLock) || !strings.Contains(err.Error(), "growth refused") || !strings.Contains(err.Error(), refusing.Reason().Error()) { t.Fatalf("call needing a refused growth: %v; want ErrMemoryLock naming the refusal", err) } - if refusing.refused == 0 { + if refusing.Refused() == 0 { t.Fatal("the guest did not grow; the test proves nothing") } if !c.MemoryLocked() { @@ -475,7 +162,7 @@ func TestRequireLockedMemoryFailsTheCallThatCannotGrow(t *testing.T) { t.Fatalf("Client logs as %q after a refused growth", v) } // The client is still open, and grows once it can. - refusing.refuse = false + refusing.Allow() if _, err := c.call(ctx, stage); err != nil { t.Fatalf("the next call, growth allowed: %v", err) } @@ -498,27 +185,27 @@ func TestRequireLockedMemoryClosesTheClientOnARefusedInternalGrowth(t *testing.T if err != nil { t.Fatal(err) } - var refusing *refusingBackend + var refusing *guest.Refusing _, err = c.call(ctx, func(inst *instance) ([]byte, error) { staged, err := inst.allocWrite(ctx, encoded) if err != nil { return nil, err } defer inst.free(ctx, staged) - refusing = refuseGrowth(inst.mem) + refusing = guest.RefuseGrowth(inst.mem, errors.New("refused for the test")) _, err = inst.invoke(ctx, inst.cipherInit, uint64(staged.ptr), uint64(staged.len)) return nil, err }) if !errors.Is(err, ErrMemoryLock) || !strings.Contains(err.Error(), "growth refused") { t.Fatalf("init needing a refused internal growth: %v; want ErrMemoryLock naming the refusal", err) } - if refusing.refused == 0 { + if refusing.Refused() == 0 { t.Fatal("the guest did not grow; the test proves nothing") } if !c.inst.module.IsClosed() || !strings.Contains(err.Error(), "the client is closed") { t.Fatalf("the guest's abort did not close the client: %v", err) } - if !c.inst.mem.isFreed() { + if !c.inst.mem.IsFreed() { t.Fatal("the closed client's memory was not wiped and freed") } if _, err := c.call(ctx, func(*instance) ([]byte, error) { return nil, nil }); !errors.Is(err, ErrState) { @@ -532,97 +219,26 @@ func TestRequireLockedMemoryClosesTheClientOnARefusedInternalGrowth(t *testing.T } } -// reentrantProbe is a hand-assembled module reproducing the shape of the -// guest's transport import: "run" calls the host function h, then stores -// to memory. h re-enters the guest (as transport_send does through -// se_alloc) with a context that has ended, which is how wazero comes to -// free the module's memory while the guest is suspended in the import: -// -// (module -// (import "env" "h" (func $h)) -// (memory (export "memory") 1) -// (func (export "run") call $h i32.const 0 i32.const 1 i32.store) -// (func (export "nop"))) -var reentrantProbe = []byte{ - 0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00, - 0x01, 0x04, 0x01, 0x60, 0x00, 0x00, // type: () -> () - 0x02, 0x09, 0x01, 0x03, 'e', 'n', 'v', 0x01, 'h', 0x00, 0x00, // import env.h - 0x03, 0x03, 0x02, 0x00, 0x00, // two functions of type 0 - 0x05, 0x03, 0x01, 0x00, 0x01, // memory: min 1, no max - 0x07, 0x16, 0x03, - 0x06, 'm', 'e', 'm', 'o', 'r', 'y', 0x02, 0x00, - 0x03, 'r', 'u', 'n', 0x00, 0x01, - 0x03, 'n', 'o', 'p', 0x00, 0x02, - 0x0a, 0x10, 0x02, - 0x0b, 0x00, 0x10, 0x00, 0x41, 0x00, 0x41, 0x01, 0x36, 0x02, 0x00, 0x0b, // run - 0x02, 0x00, 0x0b, // nop -} - -// The sequence that crashed in CI: a call's context ends during a host -// import, the import re-enters the guest, and wazero frees the memory in -// that nested call while the outer guest frame is still live and about to -// store. The memory must survive until the outer call has returned; an -// unmapped store here is a fault in compiled code that takes the process -// down, so this test cannot fail gently. -func TestMemoryOutlivesACallClosedDuringAHostImport(t *testing.T) { - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - alloc := newMemoryAllocator(bestEffort) - rt := wazero.NewRuntimeWithConfig(ctx, wazero.NewRuntimeConfig().WithCloseOnContextDone(true)) - defer rt.Close(context.Background()) - var freedDuringImport, nestedFailed bool - _, err := rt.NewHostModuleBuilder("env").NewFunctionBuilder(). - WithFunc(func(ctx context.Context, m api.Module) { - cancel() - _, nested := m.ExportedFunction("nop").Call(ctx) - nestedFailed = nested != nil - freedDuringImport = alloc.isFreed() - }).Export("h").Instantiate(ctx) - if err != nil { - t.Fatal(err) - } - mod, err := rt.InstantiateWithConfig(experimental.WithMemoryAllocator(ctx, alloc), reentrantProbe, wazero.NewModuleConfig()) - if err != nil { - t.Fatalf("instantiating reentrant probe: %v", err) - } - alloc.enter() - _, err = mod.ExportedFunction("run").Call(ctx) - alloc.exit() - var exit *sys.ExitError - if !errors.As(err, &exit) || exit.ExitCode() != sys.ExitCodeContextCanceled { - t.Fatalf("run: %v, want the cancellation exit", err) - } - if !nestedFailed { - t.Fatal("the nested call did not see the closed module") - } - if freedDuringImport { - t.Fatal("memory freed while the guest was suspended in a host import") - } - if !alloc.isFreed() { - t.Fatal("memory not freed once the outer call returned") - } -} - // A Client that becomes unreachable without Close is released by its // cleanup: the guest's shutdown runs and the memory is wiped and freed. It // covers the forgot-to-close case in a running process, and nothing at // exit. func TestUnreachableClientIsReleased(t *testing.T) { wasm := guestOrSkip(t) - inst, err := newInstance(context.Background(), wasm, &transport{rt: http.DefaultTransport, token: StaticToken("t")}, bestEffort) + inst, err := newInstance(context.Background(), wasm, &transport{rt: http.DefaultTransport, token: StaticToken("t")}, guest.BestEffort) if err != nil { t.Fatal(err) } alloc := inst.mem func() { c := newClient(inst, nil) - if c.inst.mem.isFreed() { + if c.inst.mem.IsFreed() { t.Fatal("freed on construction") } }() inst = nil deadline := time.Now().Add(10 * time.Second) - for !alloc.isFreed() { + for !alloc.IsFreed() { if time.Now().After(deadline) { t.Fatal("an unreachable client's memory was not released") } @@ -637,7 +253,7 @@ func TestCloseStopsTheCleanup(t *testing.T) { if err := c.Close(); err != nil { t.Fatal(err) } - if !c.inst.mem.isFreed() { + if !c.inst.mem.IsFreed() { t.Fatal("Close did not free the guest memory") } // Stop on a cleanup Close already stopped is a no-op, so a second Stop diff --git a/languages/golang/stackencrypt/unit_test.go b/languages/golang/stackencrypt/unit_test.go index a0519f736..796ab0e57 100644 --- a/languages/golang/stackencrypt/unit_test.go +++ b/languages/golang/stackencrypt/unit_test.go @@ -528,17 +528,19 @@ func TestLeafSetKeepsStackEncryptLeavesDistinct(t *testing.T) { } } +// This package's sentinels are the shared table's: a packed status decodes +// to the error this package names for it. func TestStatusMappingIsTotal(t *testing.T) { for status, want := range map[uint32]error{ 1: ErrAuthentication, 2: ErrEncoding, 3: ErrState, 4: ErrInternal, 5: ErrUnauthorized, 6: ErrForbidden, 7: ErrNotFound, 8: ErrConflict, 9: ErrTransport, 10: ErrKMS, 11: ErrTerm, 12: ErrForeignKeyset, } { - if got := statusError(status); !errors.Is(got, want) { + if _, got := packedResult(uint64(status)); !errors.Is(got, want) { t.Errorf("status %d: %v", status, got) } } - if got := statusError(99); !errors.Is(got, ErrInternal) { + if _, got := packedResult(99); !errors.Is(got, ErrInternal) { t.Errorf("unknown status: %v", got) } } diff --git a/scripts/go-binding-test.sh b/scripts/go-binding-test.sh index 563f4e199..47c90387d 100755 --- a/scripts/go-binding-test.sh +++ b/scripts/go-binding-test.sh @@ -12,7 +12,7 @@ set -euo pipefail dir=${1:?usage: go-binding-test.sh <module dir> [<guest path>]} -guest=${2:-wasm/stack_encrypt_guest.wasm} +guest=${2:-stackencrypt/wasm/stack_encrypt_guest.wasm} cd "$dir" if [ ! -f "$guest" ]; then From 1e6ff9167992d9765aaadb195d074f413eae57b4 Mon Sep 17 00:00:00 2001 From: Dan Draper <dan@cipherstash.com> Date: Tue, 22 Sep 2026 12:53:46 -0500 Subject: [PATCH 616/686] fix(go): answer review on internal/guest ClientKey redacts under every verb, as a value as well as a pointer: a Format method on the value receiver, which fmt consults before Stringer and GoStringer and for %d and %x too, where a struct's fields would otherwise print (review, P2). The test formats the pointer, the value, and a struct holding each, under eight verbs. Byte access is a package function, KeyBytes, not a method. The public packages alias the type, and an alias carries every exported method, so a caller outside internal could reach the backing slice through stackencrypt.NewClientKey(..).Bytes(); Go's internal rule stops the import, not the call (review, P2). The testing seam's size methods move beside the types they belong to, under those files' build constraints: testing.go named mappedMemory unconditionally, so the heap-fallback targets no longer built (GOOS=js GOARCH=wasm go build ./... failed; review, P2). Refusing implements sized itself, so fronting a fronted backing no longer panics, and RefuseGrowth swaps the backing under the allocator's lock. Docs: every exported identifier of the allocator has a comment that opens with its name; the shared package describes itself in its own terms and names the public packages' identifiers only as examples; the panic on a second Allocate carries the package's prefix; the package doc lives in doc.go; a mechanical rename had turned "a strict overcommit setting" into the constant's name. --- languages/golang/internal/guest/clientkey.go | 48 +++++++++++++------ languages/golang/internal/guest/doc.go | 14 ++++++ languages/golang/internal/guest/errors.go | 13 ----- languages/golang/internal/guest/memory.go | 46 +++++++++++------- .../golang/internal/guest/memory_mapped.go | 3 ++ .../golang/internal/guest/memory_test.go | 41 ++++++++++++---- .../internal/guest/memory_unix_other.go | 2 +- languages/golang/internal/guest/testing.go | 15 ++++-- 8 files changed, 123 insertions(+), 59 deletions(-) create mode 100644 languages/golang/internal/guest/doc.go diff --git a/languages/golang/internal/guest/clientkey.go b/languages/golang/internal/guest/clientkey.go index d0cfb503b..af8e61b96 100644 --- a/languages/golang/internal/guest/clientkey.go +++ b/languages/golang/internal/guest/clientkey.go @@ -1,15 +1,24 @@ package guest +import "fmt" + // ClientKey is the ZeroKMS client key: long-lived key material that lives // for the process. It is opaque on purpose. A string is immutable and // cannot be wiped; a byte slice prints its contents under %v. This type -// prints a redaction under every verb, hands its bytes only to the package -// that consumes them, and is wiped once consumed (see ADR-0005, decision 5). +// prints a redaction under every verb, whether formatted as a value or a +// pointer, hands its bytes only to this package tree, and is wiped once +// consumed (see ADR-0005, decision 5). // // stackauth reads one out of the developer profile; stackencrypt takes it -// in its Config and wipes it once the key is in guest memory. Both expose -// this type as an alias, so a key read by one is the type the other takes, -// with neither package importing the other. +// in its Config (from CIP-4118 on) and wipes it once the key is in guest +// memory. Both expose this type as an alias, so a key read by one is the +// type the other takes, with neither package importing the other. +// +// The public packages alias the type, and an alias carries every exported +// method with it — Go's internal rule stops the import, not the call. So +// the accessor is a function of this package, KeyBytes, not a method: a +// caller outside internal can construct, wipe and print a key, and nothing +// else. type ClientKey struct { bytes []byte } @@ -20,11 +29,11 @@ func NewClientKey(b []byte) *ClientKey { return &ClientKey{bytes: b} } -// Bytes is the key material, for the package that marshals it into guest -// memory. The slice is the key's own: do not retain it, and call Wipe once -// it has been copied where it is going. Outside this module tree the type -// has no accessor; internal visibility is what keeps it that way. -func (k *ClientKey) Bytes() []byte { +// KeyBytes is the key material, for the package that marshals it into +// guest memory. The slice is the key's own: do not retain it, and call +// Wipe once it has been copied where it is going. A function rather than +// a method so it does not travel with the alias (see ClientKey). +func KeyBytes(k *ClientKey) []byte { if k == nil { return nil } @@ -46,11 +55,20 @@ func (k *ClientKey) IsZero() bool { return k == nil || len(k.bytes) == 0 } -// String implements fmt.Stringer with a redaction, so the key never reaches -// a log through %v or %s. -func (k *ClientKey) String() string { return redactedClientKey } +// Format implements fmt.Formatter, which fmt consults before Stringer and +// GoStringer and for every verb — %d and %x included, which would +// otherwise print the field. A value receiver, so a copied key redacts as +// the pointer does. The one thing fmt prints without asking is a nil +// pointer, as "<nil>"; there is no material behind one. +func (ClientKey) Format(f fmt.State, _ rune) { + _, _ = f.Write([]byte(redactedClientKey)) +} + +// String implements fmt.Stringer with the same redaction, for callers that +// call it directly rather than through fmt. +func (ClientKey) String() string { return redactedClientKey } -// GoString implements fmt.GoStringer with the same redaction, for %#v. -func (k *ClientKey) GoString() string { return redactedClientKey } +// GoString implements fmt.GoStringer with the same redaction. +func (ClientKey) GoString() string { return redactedClientKey } const redactedClientKey = "ClientKey(***)" diff --git a/languages/golang/internal/guest/doc.go b/languages/golang/internal/guest/doc.go new file mode 100644 index 000000000..298e963b8 --- /dev/null +++ b/languages/golang/internal/guest/doc.go @@ -0,0 +1,14 @@ +// Package guest is what the Go packages over the WASI guests share and +// neither should own: the locked, non-dumpable memory a guest instance runs +// in; the status table every guest reports through and the errors it +// decodes to; and the opaque client key one package reads and the other +// consumes. +// +// It sits under internal so that stackencrypt and stackauth expose what +// they need of it — the error sentinels, the ClientKey type — as their own +// identifiers (aliases, not copies: an error from either package is the +// same value, and a key read by one is the type the other takes) without +// either package importing the other. A binary that wants only the profile +// must not carry the crypto guest, and this is the seam that makes that +// true. See ADR-0005 in packages/stack-encrypt/docs/adr. +package guest diff --git a/languages/golang/internal/guest/errors.go b/languages/golang/internal/guest/errors.go index c3b56484b..a0ccd7d3f 100644 --- a/languages/golang/internal/guest/errors.go +++ b/languages/golang/internal/guest/errors.go @@ -1,16 +1,3 @@ -// Package guest is what the Go packages over the WASI guests share and -// neither should own: the locked, non-dumpable memory a guest instance runs -// in; the status table every guest reports through and the errors it -// decodes to; and the opaque client key one package reads and the other -// consumes. -// -// It sits under internal so that stackencrypt and stackauth expose what -// they need of it — the error sentinels, the ClientKey type — as their own -// identifiers (aliases, not copies: an error from either package is the -// same value, and a key read by one is the type the other takes) without -// either package importing the other. A binary that wants only the profile -// must not carry the crypto guest, and this is the seam that makes that -// true. See ADR-0005 in packages/stack-encrypt/docs/adr. package guest import "errors" diff --git a/languages/golang/internal/guest/memory.go b/languages/golang/internal/guest/memory.go index aef828b68..58eb72288 100644 --- a/languages/golang/internal/guest/memory.go +++ b/languages/golang/internal/guest/memory.go @@ -39,9 +39,10 @@ import ( // many Linux hosts and the guest's memory is larger, so the lock is // commonly refused, with nothing else lost: the pages can be swapped, and // on a host with no swap not even that. The refusal is recorded and -// reported through Client.MemoryLocked and Client.MemoryLockError so an -// operator can see it and raise the limit; Config.RequireLockedMemory -// turns it into a NewClient failure. +// reported through LockError, which each public package surfaces on its +// client (stackencrypt: Client.MemoryLocked and Client.MemoryLockError) so +// an operator can see it and raise the limit; Strict turns it into a +// constructor failure (stackencrypt: Config.RequireLockedMemory). // LockPolicy is what a refused lock means for an instance. type LockPolicy uint8 @@ -52,11 +53,13 @@ const ( BestEffort LockPolicy = iota // Strict refuses growth that cannot be locked. The first commit is the // exception: wazero cannot instantiate on a nil buffer, so it is - // granted with the refusal recorded, and newInstance turns that into - // the ErrMemoryLock the caller asked for. + // granted with the refusal recorded, and the public package's + // constructor turns that into the ErrMemoryLock the caller asked for. Strict ) +// PolicyFor is the policy a caller's "require locked memory" setting +// means: Strict when set, BestEffort otherwise. func PolicyFor(requireLockedMemory bool) LockPolicy { if requireLockedMemory { return Strict @@ -64,7 +67,7 @@ func PolicyFor(requireLockedMemory bool) LockPolicy { return BestEffort } -// backend is one platform's linear memory behind a Allocator: a +// backend is one platform's linear memory behind an Allocator: a // reservation committed from the front. It is used from the guest's // goroutine only; the allocator does the bookkeeping other goroutines // read. @@ -82,8 +85,9 @@ type backend interface { // Allocator is the experimental.MemoryAllocator handed to wazero for // one guest instance, and the experimental.LinearMemory it returns: wazero // calls Allocate once per memory, and the guest has exactly one. It -// records what the Client reports about the memory, and holds the memory -// mapped while a guest call is in flight (see enter, exit and Free). +// records what the public package reports about the memory, and holds the +// memory mapped while a guest call is in flight (see Enter, Exit and +// Free). type Allocator struct { policy LockPolicy @@ -114,14 +118,17 @@ type Allocator struct { } // GrowthRefusal is the Strict growths an allocator has refused: how many, -// and the lock refusal behind the latest. Client.call compares the count -// across a call to name the real cause when the guest reports only a -// failed allocation. +// and the lock refusal behind the latest. A caller compares the count +// across a guest call to name the real cause when the guest reports only +// a failed allocation. type GrowthRefusal struct { Refused uint64 Reason error } +// NewAllocator is an allocator for one guest instance under policy. Hand +// it to wazero as the instance's experimental.MemoryAllocator; it +// allocates when the guest's memory is first instantiated. func NewAllocator(policy LockPolicy) *Allocator { return &Allocator{policy: policy} } @@ -134,7 +141,7 @@ func (a *Allocator) Allocate(capacity, max uint64) experimental.LinearMemory { // The guest has one memory; a second would mean wazero's contract // changed under us. Refusing here fails instantiation loudly // rather than letting two memories share one report. - panic("stackencrypt: guest memory allocated twice") + panic("cipherstash: guest memory allocated twice") } backing, err := reserveMemory(capacity, max, a.policy) a.backing = backing @@ -182,15 +189,15 @@ func (a *Allocator) Free() { a.freeLocked() } -// enter marks a guest call in progress: the memory must stay mapped until -// the matching exit, whatever wazero asks in between. +// Enter marks a guest call in progress: the memory must stay mapped until +// the matching Exit, whatever wazero asks in between. func (a *Allocator) Enter() { a.mu.Lock() a.inFlight++ a.mu.Unlock() } -// exit ends a guest call and performs a Free that arrived during it. +// Exit ends a guest call and performs a Free that arrived during it. func (a *Allocator) Exit() { a.mu.Lock() defer a.mu.Unlock() @@ -210,7 +217,7 @@ func (a *Allocator) freeLocked() { a.backing.free() } -// lockError is nil while every committed byte is locked (and, on Linux, +// LockError is nil while every committed byte is locked (and, on Linux, // excluded from dumps); otherwise it names what was refused and why. func (a *Allocator) LockError() error { a.mu.Lock() @@ -243,12 +250,16 @@ func (a *Allocator) LogValue() slog.Value { return slog.GroupValue(slog.Bool("memory_locked", true)) } +// IsFallback reports whether the guest runs on the heap fallback rather +// than a reservation: nothing is locked, and growth may copy. func (a *Allocator) IsFallback() bool { a.mu.Lock() defer a.mu.Unlock() return a.fallback } +// IsFreed reports whether the memory has been wiped and released. Tests +// read it to observe release paths a caller never sees. func (a *Allocator) IsFreed() bool { a.mu.Lock() defer a.mu.Unlock() @@ -266,6 +277,9 @@ type heapMemory struct { max uint64 } +// size implements sized, for the testing seam. +func (m *heapMemory) size() uint64 { return uint64(len(m.buf)) } + func newHeapMemory(capacity, max uint64) *heapMemory { if capacity > max { capacity = max diff --git a/languages/golang/internal/guest/memory_mapped.go b/languages/golang/internal/guest/memory_mapped.go index cea6bcc21..fb17a7071 100644 --- a/languages/golang/internal/guest/memory_mapped.go +++ b/languages/golang/internal/guest/memory_mapped.go @@ -21,6 +21,9 @@ type mappedMemory struct { policy LockPolicy } +// size implements sized, for the testing seam. +func (m *mappedMemory) size() uint64 { return m.committed } + // reserveMemory returns a mapped memory for the reservation, or a heap // memory when the reservation itself is impossible: max exceeds what this // process can address (a 32-bit host asked for wasm's 4 GiB default), or diff --git a/languages/golang/internal/guest/memory_test.go b/languages/golang/internal/guest/memory_test.go index 77ffcb398..62fd38c2c 100644 --- a/languages/golang/internal/guest/memory_test.go +++ b/languages/golang/internal/guest/memory_test.go @@ -240,20 +240,40 @@ func TestStatusDecodesToTheSharedSentinels(t *testing.T) { } } -// The client key never prints its bytes, and is empty once wiped. +// The client key never prints its bytes — as a pointer, as a value, or +// inside a struct held either way, under any verb — and is empty once +// wiped. func TestClientKeyIsOpaqueAndWipes(t *testing.T) { material := []byte("key material that must not print") key := guest.NewClientKey(material) - for _, verb := range []string{"%v", "%+v", "%#v", "%s", "%x", "%q"} { - if out := fmt.Sprintf(verb, key); strings.Contains(out, "material") { - t.Errorf("%s printed the key: %q", verb, out) + type holder struct { + ByPointer *guest.ClientKey + ByValue guest.ClientKey + } + subjects := map[string]any{ + "pointer": key, + "value": *key, + "struct": holder{ByPointer: key, ByValue: *key}, + "pointer to struct": &holder{ByPointer: key, ByValue: *key}, + } + // %d and %x reach a struct's fields without asking a Stringer; only a + // Formatter answers for them. + for _, verb := range []string{"%v", "%+v", "%#v", "%s", "%q", "%x", "%X", "%d"} { + for name, subject := range subjects { + out := fmt.Sprintf(verb, subject) + if strings.Contains(out, "material") || strings.Contains(out, "6d6174657269616c") || strings.Contains(out, "109 97 116") { + t.Errorf("%s of the %s printed the key: %q", verb, name, out) + } } } - if string(key.Bytes()) != "key material that must not print" { - t.Fatal("Bytes did not return the material") + if fmt.Sprint(key) != "ClientKey(***)" || key.String() != "ClientKey(***)" || key.GoString() != "ClientKey(***)" { + t.Errorf("the redaction is not the documented one: %s", key) + } + if string(guest.KeyBytes(key)) != "key material that must not print" { + t.Fatal("KeyBytes did not return the material") } key.Wipe() - if !key.IsZero() || key.Bytes() != nil { + if !key.IsZero() || guest.KeyBytes(key) != nil { t.Fatal("a wiped key still holds material") } for _, b := range material { @@ -263,7 +283,10 @@ func TestClientKeyIsOpaqueAndWipes(t *testing.T) { } key.Wipe() // a second wipe is a no-op var none *guest.ClientKey - if !none.IsZero() || none.Bytes() != nil || fmt.Sprint(none) == "<nil>" { - t.Fatal("a nil key is not the empty, redacted key") + if !none.IsZero() || guest.KeyBytes(none) != nil { + t.Fatal("a nil key is not the empty key") + } + if out := fmt.Sprint(none); out != "<nil>" && out != "ClientKey(***)" { + t.Fatalf("a nil key printed %q", out) } } diff --git a/languages/golang/internal/guest/memory_unix_other.go b/languages/golang/internal/guest/memory_unix_other.go index b5f7b052d..6534cb23d 100644 --- a/languages/golang/internal/guest/memory_unix_other.go +++ b/languages/golang/internal/guest/memory_unix_other.go @@ -2,7 +2,7 @@ package guest -// Without MAP_NORESERVE the reservation may be charged against a Strict +// Without MAP_NORESERVE the reservation may be charged against a strict // overcommit setting on the BSDs; macOS has no such accounting. const reserveFlags = 0 diff --git a/languages/golang/internal/guest/testing.go b/languages/golang/internal/guest/testing.go index ca2272137..97cd80b2a 100644 --- a/languages/golang/internal/guest/testing.go +++ b/languages/golang/internal/guest/testing.go @@ -25,6 +25,9 @@ func (b *Refusing) commit(size uint64) ([]byte, error) { return b.backend.commit(size) } +// size reports the backing's, so a Refusing can front another. +func (b *Refusing) size() uint64 { return b.backend.(sized).size() } + // Allow lifts the refusal: later growths go through to the real backing. func (b *Refusing) Allow() { b.refuse = false } @@ -36,15 +39,17 @@ func (b *Refusing) Reason() error { return b.reason } // RefuseGrowth puts a Refusing in front of alloc's backing, set to refuse // any growth past what is committed now, with reason as the refusal. Call -// it on the guest's goroutine, between calls, as the backing is used. +// it between guest calls: the swap is made under the allocator's lock, but +// a commit already in flight on the guest's goroutine has the old backing. func RefuseGrowth(alloc *Allocator, reason error) *Refusing { + alloc.mu.Lock() + defer alloc.mu.Unlock() refusing := &Refusing{backend: alloc.backing, past: alloc.backing.(sized).size(), reason: reason, refuse: true} alloc.backing = refusing return refusing } -// sized is what the seam needs of a backing to know where it stands. +// sized is what the seam needs of a backing to know where it stands. Each +// backing implements it beside its own definition, under that file's +// build constraint, so this file builds on every platform. type sized interface{ size() uint64 } - -func (m *mappedMemory) size() uint64 { return m.committed } -func (m *heapMemory) size() uint64 { return uint64(len(m.buf)) } From 921af247628c023b7c689ea1f1b9faf7cbd2938a Mon Sep 17 00:00:00 2001 From: Dan Draper <dan@cipherstash.com> Date: Mon, 21 Sep 2026 22:57:19 -0500 Subject: [PATCH 617/686] feat(go): Config.ClientKey is an opaque, wiped type, not a string A string is immutable and cannot be wiped, and only the marshalled config buffer was wiped before; the client key is key material that lives for the process. Config.ClientKey is now *ClientKey, the alias of the type internal/guest holds and stackauth will read out of the profile (ADR-0005 decision 5), built with NewClientKey from bytes it takes ownership of, redacted under every verb. NewClient consumes it: marshal, wipe the key, wipe the buffer once the guest has it. Whatever the outcome, the bytes the key was built from are zero when NewClient returns, and a consumed key does not make a second client. The one copy this package cannot zero, the string the value codec takes for the length of NewClient, is stated on encodeConfig rather than hidden. Bearer tokens stay strings; TokenSource is unchanged. Closes CIP-4118. --- languages/golang/stackencrypt/README.md | 2 +- languages/golang/stackencrypt/client.go | 29 +++++++++---- languages/golang/stackencrypt/example/main.go | 2 +- languages/golang/stackencrypt/guest_test.go | 43 ++++++++++++++++--- languages/golang/stackencrypt/live_test.go | 2 +- languages/golang/stackencrypt/memory_test.go | 4 +- 6 files changed, 63 insertions(+), 19 deletions(-) diff --git a/languages/golang/stackencrypt/README.md b/languages/golang/stackencrypt/README.md index 67e1247e4..2fca56a7a 100644 --- a/languages/golang/stackencrypt/README.md +++ b/languages/golang/stackencrypt/README.md @@ -36,7 +36,7 @@ import ( func run(ctx context.Context) error { client, err := stackencrypt.NewClient(ctx, stackencrypt.Config{ ClientID: os.Getenv("CS_CLIENT_ID"), - ClientKey: os.Getenv("CS_CLIENT_KEY"), + ClientKey: stackencrypt.NewClientKey([]byte(os.Getenv("CS_CLIENT_KEY"))), Token: stackencrypt.StaticToken(os.Getenv("CS_CLIENT_ACCESS_KEY")), }) if err != nil { diff --git a/languages/golang/stackencrypt/client.go b/languages/golang/stackencrypt/client.go index eeaeca312..ecd3abc9c 100644 --- a/languages/golang/stackencrypt/client.go +++ b/languages/golang/stackencrypt/client.go @@ -20,11 +20,13 @@ type Config struct { // ClientID is the ZeroKMS client id (a UUID string). Required. ClientID string // ClientKey is the v1 client key material: hex (the CS_CLIENT_KEY form, - // either case) or standard padded base64 (the secretkey.json form). - // Required. It enters guest memory once and is wiped from the config - // buffer before any request is made; the Go-side copy this package - // makes is wiped too. The caller's own string is the caller's. - ClientKey string + // either case) or standard padded base64 (the secretkey.json form), + // wrapped by [NewClientKey] or read from the developer profile by + // stackauth. Required. It is consumed: NewClient marshals it into the + // config buffer, wipes the key, and wipes the buffer once the guest has + // the key, so after NewClient returns the ClientKey is empty and the + // bytes it was built from are zero. A key is for one client. + ClientKey *ClientKey // ZeroKMSURL pins the ZeroKMS endpoint. When empty the endpoint is // resolved from the access token's services claim on first use. ZeroKMSURL string @@ -111,6 +113,11 @@ func NewClient(ctx context.Context, cfg Config) (*Client, error) { return nil, err } defer wipe(encoded) + // The key is consumed: it is in the config buffer now, and the buffer + // is wiped once the guest has it. Wiping the key here rather than after + // the init call keeps the exposure to one copy from this point on, + // whatever the init's outcome. + cfg.ClientKey.Wipe() t := &transport{rt: rt, token: cfg.Token} inst, err := newInstance(ctx, wasm, t, guest.PolicyFor(cfg.RequireLockedMemory)) @@ -175,14 +182,20 @@ func (c *Client) String() string { func (c *Client) LogValue() slog.Value { return c.inst.mem.LogValue() } // encodeConfig renders the se_cipher_init object. The result holds the -// client key; the caller wipes it. +// client key; the caller wipes it, and the key it was read from. func encodeConfig(cfg Config) ([]byte, error) { - if cfg.ClientID == "" || cfg.ClientKey == "" { + if cfg.ClientID == "" || cfg.ClientKey.IsZero() { return nil, errors.New("stackencrypt: Config.ClientID and Config.ClientKey are required") } + // The key crosses as text: the guest's config parser takes the hex or + // base64 form as the CS_CLIENT_KEY variable and secretkey.json hold it. + // The string is a copy the marshaller reads once; the encoded buffer + // that results is what the caller wipes. A string cannot be wiped, and + // this one lives until the collector takes it: the one copy of the key + // this package cannot zero, accepted for the length of NewClient. fields := vcvalue.Object{ {Key: "client_id", Value: cfg.ClientID}, - {Key: "client_key", Value: cfg.ClientKey}, + {Key: "client_key", Value: string(cfg.ClientKey.Bytes())}, } if cfg.ZeroKMSURL != "" { fields = append(fields, vcvalue.Field{Key: "zerokms_url", Value: cfg.ZeroKMSURL}) diff --git a/languages/golang/stackencrypt/example/main.go b/languages/golang/stackencrypt/example/main.go index ee8c2206b..1444400aa 100644 --- a/languages/golang/stackencrypt/example/main.go +++ b/languages/golang/stackencrypt/example/main.go @@ -47,7 +47,7 @@ func run() error { ctx := context.Background() client, err := stackencrypt.NewClient(ctx, stackencrypt.Config{ ClientID: creds.ClientID, - ClientKey: creds.ClientKey, + ClientKey: stackencrypt.NewClientKey([]byte(creds.ClientKey)), // Asked on every request, so the client follows the profile // rather than pinning one token; see profile.go. Token: creds.token(), diff --git a/languages/golang/stackencrypt/guest_test.go b/languages/golang/stackencrypt/guest_test.go index 69f1aa9c6..290bab877 100644 --- a/languages/golang/stackencrypt/guest_test.go +++ b/languages/golang/stackencrypt/guest_test.go @@ -98,7 +98,7 @@ func newStub(t *testing.T, status int, contentType, body string) *zerokmsStub { func testConfig(url string) Config { return Config{ ClientID: testClientID, - ClientKey: testClientKey, + ClientKey: NewClientKey([]byte(testClientKey)), ZeroKMSURL: url, Token: StaticToken("stub-token"), } @@ -442,10 +442,11 @@ func (z *zeros) Read(p []byte) (int, error) { func TestConfigValidation(t *testing.T) { ctx := context.Background() for name, cfg := range map[string]Config{ - "no token": {ClientID: testClientID, ClientKey: testClientKey}, - "no client id": {ClientKey: testClientKey, Token: StaticToken("t")}, + "no token": {ClientID: testClientID, ClientKey: NewClientKey([]byte(testClientKey))}, + "no client id": {ClientKey: NewClientKey([]byte(testClientKey)), Token: StaticToken("t")}, "no key": {ClientID: testClientID, Token: StaticToken("t")}, - "negative cache": {ClientID: testClientID, ClientKey: testClientKey, Token: StaticToken("t"), + "wiped key": {ClientID: testClientID, ClientKey: NewClientKey(nil), Token: StaticToken("t")}, + "negative cache": {ClientID: testClientID, ClientKey: NewClientKey([]byte(testClientKey)), Token: StaticToken("t"), KeysetCacheSize: -1}, } { if _, err := NewClient(ctx, cfg); err == nil { @@ -456,7 +457,7 @@ func TestConfigValidation(t *testing.T) { guestOrSkip(t) for name, mutate := range map[string]func(*Config){ "client id not a uuid": func(c *Config) { c.ClientID = "acme" }, - "key not hex": func(c *Config) { c.ClientKey = "zz" }, + "key not hex": func(c *Config) { c.ClientKey = NewClientKey([]byte("zz")) }, "bad url": func(c *Config) { c.ZeroKMSURL = "not a url" }, } { stub := newStub(t, http.StatusOK, "application/json", "{}") @@ -472,6 +473,36 @@ func TestConfigValidation(t *testing.T) { } } +// The client key is consumed by NewClient: whatever the outcome, the bytes +// it was built from are zero once NewClient returns, the key reports +// itself empty, and a Config never prints the material under any verb. +func TestClientKeyIsConsumedAndNeverPrinted(t *testing.T) { + guestOrSkip(t) + material := []byte(testClientKey) + cfg := testConfig(newStub(t, http.StatusUnauthorized, "", "nope").URL) + cfg.ClientKey = NewClientKey(material) + for _, verb := range []string{"%v", "%+v", "%#v"} { + if out := fmt.Sprintf(verb, cfg); strings.Contains(out, testClientKey[:16]) { + t.Errorf("Config under %s prints the key: %q", verb, out) + } + } + if _, err := NewClient(context.Background(), cfg); !errors.Is(err, ErrUnauthorized) { + t.Fatalf("NewClient: %v, want ErrUnauthorized", err) + } + if !cfg.ClientKey.IsZero() { + t.Error("the key still holds material after NewClient") + } + for i, b := range material { + if b != 0 { + t.Fatalf("byte %d of the key material was not wiped", i) + } + } + // A consumed key does not make a second client. + if _, err := NewClient(context.Background(), cfg); err == nil || errors.Is(err, ErrUnauthorized) { + t.Errorf("NewClient with a consumed key: %v, want a config error before any request", err) + } +} + // rawInstance is a guest that was never initialised: every well-formed // operation is ErrState there, every malformed one ErrEncoding. func rawInstance(t *testing.T) *Client { @@ -762,7 +793,7 @@ func ExampleNewClient() { // a real round trip. _, err := NewClient(context.Background(), Config{ ClientID: "6a70bd18-99ac-4650-b104-37eec3a15b09", - ClientKey: "...", + ClientKey: NewClientKey([]byte("...")), Token: StaticToken("access token"), }) fmt.Println(err != nil) diff --git a/languages/golang/stackencrypt/live_test.go b/languages/golang/stackencrypt/live_test.go index 4beb0b34c..52f8cf2ee 100644 --- a/languages/golang/stackencrypt/live_test.go +++ b/languages/golang/stackencrypt/live_test.go @@ -24,7 +24,7 @@ func liveClient(t *testing.T) *Client { t.Skip("STACK_ENCRYPT_TEST_{CLIENT_ID,CLIENT_KEY,ACCESS_TOKEN} not set") } c, err := NewClient(t.Context(), Config{ - ClientID: clientID, ClientKey: clientKey, ZeroKMSURL: url, Token: StaticToken(token), + ClientID: clientID, ClientKey: NewClientKey([]byte(clientKey)), ZeroKMSURL: url, Token: StaticToken(token), }) if err != nil { t.Fatalf("NewClient: %v", err) diff --git a/languages/golang/stackencrypt/memory_test.go b/languages/golang/stackencrypt/memory_test.go index 8fec79abe..0f90aea47 100644 --- a/languages/golang/stackencrypt/memory_test.go +++ b/languages/golang/stackencrypt/memory_test.go @@ -74,7 +74,7 @@ func TestRequireLockedMemoryRefusesAnUnlockableGuest(t *testing.T) { limited := !probe.IsFallback() cfg := Config{ ClientID: "6a70bd18-99ac-4650-b104-37eec3a15b09", - ClientKey: "00", + ClientKey: NewClientKey([]byte("00")), Token: StaticToken("t"), Guest: wasiProbe, RequireLockedMemory: true, @@ -180,7 +180,7 @@ func TestRequireLockedMemoryFailsTheCallThatCannotGrow(t *testing.T) { func TestRequireLockedMemoryClosesTheClientOnARefusedInternalGrowth(t *testing.T) { ctx := context.Background() c := strictClient(t) - cfg := Config{ClientID: strings.Repeat("a", 2<<20), ClientKey: "00", Token: StaticToken("t")} + cfg := Config{ClientID: strings.Repeat("a", 2<<20), ClientKey: NewClientKey([]byte("00")), Token: StaticToken("t")} encoded, err := encodeConfig(cfg) if err != nil { t.Fatal(err) From 5f1b0ef297533f1db8a8fe341cb5c1f036bf0252 Mon Sep 17 00:00:00 2001 From: Dan Draper <dan@cipherstash.com> Date: Tue, 22 Sep 2026 13:37:13 -0500 Subject: [PATCH 618/686] fix(go): answer review on the consumed client key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The base removed ClientKey.Bytes in favour of guest.KeyBytes, and the rebase did not follow: encodeConfig now reads the key through the package function, so the package builds again (review, P1). NewClient consumes the key on every path, not only the accepted one: a deferred Wipe is its first statement, so a config it refuses — no token, no client id, a negative cache size — hands no live material back with the error, as the field's doc promised (review, P2). The cache-size check moves ahead of the marshal, so a refused config makes no copy of the key at all. TestConfigValidation asserts the key is empty after every refusal, and the "wiped key" case is now a key that was wiped rather than one never set. The consumption test pins %s, %q, %x and %d on a whole Config as well as %v, %+v and %#v; runs the redaction checks before it needs a guest; and asserts a consumed key makes no request. The successful outcome is pinned in the live test, the one place a client can be built against a real load-keyset response. The comment on the marshalled copy understated it: the string and the encoder's own scratch copy of it are both beyond this package's reach, and it says so. README and doc.go state that the key is consumed, that NewClientKey takes ownership, and that what the key was built from — an environment string — is the caller's to reason about. --- languages/golang/stackencrypt/README.md | 16 +++++++++- languages/golang/stackencrypt/client.go | 29 +++++++++++------ languages/golang/stackencrypt/doc.go | 6 ++-- languages/golang/stackencrypt/guest_test.go | 35 +++++++++++++++++---- languages/golang/stackencrypt/live_test.go | 15 ++++++++- 5 files changed, 81 insertions(+), 20 deletions(-) diff --git a/languages/golang/stackencrypt/README.md b/languages/golang/stackencrypt/README.md index 2fca56a7a..3132aa02c 100644 --- a/languages/golang/stackencrypt/README.md +++ b/languages/golang/stackencrypt/README.md @@ -60,9 +60,23 @@ keyset, and `ctx` bounds that request. It has nothing to do with an `Token` supplies the bearer token for every request. `StaticToken` is the simplest source; a `TokenFunc` can fetch or refresh one. +`ClientKey` is an opaque type, not a string: it prints a redaction under +every verb, so a logged `Config` never shows the key. `NewClientKey` takes +ownership of the slice it is given, and `NewClient` consumes the key — +whatever the outcome, even a config it refuses, the key is empty afterwards +and that slice is zero. A key is for one client; build another for another +client. What the SDK cannot reach is what the key was built *from*: the +`os.Getenv` string above is Go's, immutable, and lives until collected. +Read the key from the developer profile through `stackauth` where you can, +and where an environment variable is the source, treat the process +environment as holding the key for the life of the process. + ### Key material in memory -The client key, every loaded index key and every data key in use live in +The client key enters the instance once, in `NewClient`: it is marshalled +into the config buffer, the `ClientKey` is wiped, the guest copies the key +into its own memory, and the buffer is wiped. From then on the client key, +every loaded index key and every data key in use live in the wasm instance's memory, and the package owns that memory rather than leaving it to the runtime's default. It is reserved once and never moves, so growth never copies a key to somewhere it is not wiped; it is locked in diff --git a/languages/golang/stackencrypt/client.go b/languages/golang/stackencrypt/client.go index ecd3abc9c..a2731af3d 100644 --- a/languages/golang/stackencrypt/client.go +++ b/languages/golang/stackencrypt/client.go @@ -24,8 +24,9 @@ type Config struct { // wrapped by [NewClientKey] or read from the developer profile by // stackauth. Required. It is consumed: NewClient marshals it into the // config buffer, wipes the key, and wipes the buffer once the guest has - // the key, so after NewClient returns the ClientKey is empty and the - // bytes it was built from are zero. A key is for one client. + // the key, so after NewClient returns — whatever the outcome, a config + // it refused included — the ClientKey is empty and the bytes it was + // built from are zero. A key is for one client. ClientKey *ClientKey // ZeroKMSURL pins the ZeroKMS endpoint. When empty the endpoint is // resolved from the access token's services claim on first use. @@ -94,6 +95,10 @@ type Client struct { // the default keyset — one ZeroKMS round trip. The returned client is ready // to seal. func NewClient(ctx context.Context, cfg Config) (*Client, error) { + // The key is consumed whatever happens below: a config refused before + // the key is marshalled must not hand it back live. Nil-safe, and a + // no-op after the wipe on the accepted path. + defer cfg.ClientKey.Wipe() if cfg.Token == nil { return nil, errors.New("stackencrypt: Config.Token is required") } @@ -187,22 +192,26 @@ func encodeConfig(cfg Config) ([]byte, error) { if cfg.ClientID == "" || cfg.ClientKey.IsZero() { return nil, errors.New("stackencrypt: Config.ClientID and Config.ClientKey are required") } + // Every refusal comes before the key is copied, so a rejected config + // leaves nothing but the key itself, which the caller wipes. + if cfg.KeysetCacheSize < 0 { + return nil, errors.New("stackencrypt: Config.KeysetCacheSize must not be negative") + } // The key crosses as text: the guest's config parser takes the hex or // base64 form as the CS_CLIENT_KEY variable and secretkey.json hold it. - // The string is a copy the marshaller reads once; the encoded buffer - // that results is what the caller wipes. A string cannot be wiped, and - // this one lives until the collector takes it: the one copy of the key - // this package cannot zero, accepted for the length of NewClient. + // The string is a copy the marshaller reads once — and copies once more + // into its own scratch before appending — and the encoded buffer that + // results is what the caller wipes. A string cannot be wiped, and + // neither can the marshaller's copy; both live until the collector takes + // them: the copies of the key this package cannot zero, accepted for the + // length of NewClient. A marshaller that took bytes would remove both. fields := vcvalue.Object{ {Key: "client_id", Value: cfg.ClientID}, - {Key: "client_key", Value: string(cfg.ClientKey.Bytes())}, + {Key: "client_key", Value: string(guest.KeyBytes(cfg.ClientKey))}, } if cfg.ZeroKMSURL != "" { fields = append(fields, vcvalue.Field{Key: "zerokms_url", Value: cfg.ZeroKMSURL}) } - if cfg.KeysetCacheSize < 0 { - return nil, errors.New("stackencrypt: Config.KeysetCacheSize must not be negative") - } if cfg.KeysetCacheSize > 0 { fields = append(fields, vcvalue.Field{Key: "keyset_cache_size", Value: strconv.Itoa(cfg.KeysetCacheSize)}) } diff --git a/languages/golang/stackencrypt/doc.go b/languages/golang/stackencrypt/doc.go index 714ab9ff1..6ca3a06d5 100644 --- a/languages/golang/stackencrypt/doc.go +++ b/languages/golang/stackencrypt/doc.go @@ -5,8 +5,10 @@ // # Shape // // A [Client] is one wasm instance and one ZeroKMS client: [NewClient] -// instantiates the embedded guest, hands it the client key once, and loads -// the client's default keyset. Every keyset the client uses after that is +// instantiates the embedded guest, hands it the client key once — the +// [ClientKey] in its [Config] is consumed and wiped, whatever the outcome — +// and loads the client's default keyset. Every keyset the client uses after +// that is // selected per call through a [KeysetSelector] and loaded on first use by // the guest's own bounded cache; nothing the host could allocate, alias or // free crosses the boundary. [Client.Close] runs the guest's shutdown so the diff --git a/languages/golang/stackencrypt/guest_test.go b/languages/golang/stackencrypt/guest_test.go index 290bab877..5d76e3cb3 100644 --- a/languages/golang/stackencrypt/guest_test.go +++ b/languages/golang/stackencrypt/guest_test.go @@ -441,17 +441,25 @@ func (z *zeros) Read(p []byte) (int, error) { func TestConfigValidation(t *testing.T) { ctx := context.Background() + wiped := NewClientKey([]byte(testClientKey)) + wiped.Wipe() for name, cfg := range map[string]Config{ "no token": {ClientID: testClientID, ClientKey: NewClientKey([]byte(testClientKey))}, "no client id": {ClientKey: NewClientKey([]byte(testClientKey)), Token: StaticToken("t")}, "no key": {ClientID: testClientID, Token: StaticToken("t")}, - "wiped key": {ClientID: testClientID, ClientKey: NewClientKey(nil), Token: StaticToken("t")}, + "empty key": {ClientID: testClientID, ClientKey: NewClientKey(nil), Token: StaticToken("t")}, + "wiped key": {ClientID: testClientID, ClientKey: wiped, Token: StaticToken("t")}, "negative cache": {ClientID: testClientID, ClientKey: NewClientKey([]byte(testClientKey)), Token: StaticToken("t"), KeysetCacheSize: -1}, } { if _, err := NewClient(ctx, cfg); err == nil { t.Errorf("%s: NewClient succeeded", name) } + // A refused config consumes the key too: the caller is never handed + // live material back with the error. + if !cfg.ClientKey.IsZero() { + t.Errorf("%s: the key still holds material after NewClient refused the config", name) + } } // Malformed values the guest refuses: no request is made. guestOrSkip(t) @@ -476,16 +484,26 @@ func TestConfigValidation(t *testing.T) { // The client key is consumed by NewClient: whatever the outcome, the bytes // it was built from are zero once NewClient returns, the key reports // itself empty, and a Config never prints the material under any verb. +// +// The outcome exercised here is the guest's init failing (a refused +// token); the refused-config outcomes are in TestConfigValidation, and +// the successful one in the live test, which is the only place a client +// can be built against a real load-keyset response. The wipe precedes the +// init call, so the three paths share it. func TestClientKeyIsConsumedAndNeverPrinted(t *testing.T) { - guestOrSkip(t) material := []byte(testClientKey) - cfg := testConfig(newStub(t, http.StatusUnauthorized, "", "nope").URL) + stub := newStub(t, http.StatusUnauthorized, "", "nope") + cfg := testConfig(stub.URL) cfg.ClientKey = NewClientKey(material) - for _, verb := range []string{"%v", "%+v", "%#v"} { - if out := fmt.Sprintf(verb, cfg); strings.Contains(out, testClientKey[:16]) { + // %x and %d reach a struct's fields without asking a Stringer; the + // key's Formatter answers for them. + for _, verb := range []string{"%v", "%+v", "%#v", "%s", "%q", "%x", "%d"} { + out := fmt.Sprintf(verb, cfg) + if strings.Contains(out, testClientKey[:16]) || strings.Contains(out, hex.EncodeToString(material[:8])) { t.Errorf("Config under %s prints the key: %q", verb, out) } } + guestOrSkip(t) if _, err := NewClient(context.Background(), cfg); !errors.Is(err, ErrUnauthorized) { t.Fatalf("NewClient: %v, want ErrUnauthorized", err) } @@ -497,10 +515,15 @@ func TestClientKeyIsConsumedAndNeverPrinted(t *testing.T) { t.Fatalf("byte %d of the key material was not wiped", i) } } - // A consumed key does not make a second client. + // A consumed key does not make a second client, and asks nothing of + // ZeroKMS trying. + before := len(stub.requests) if _, err := NewClient(context.Background(), cfg); err == nil || errors.Is(err, ErrUnauthorized) { t.Errorf("NewClient with a consumed key: %v, want a config error before any request", err) } + if len(stub.requests) != before { + t.Errorf("a consumed key made %d request(s)", len(stub.requests)-before) + } } // rawInstance is a guest that was never initialised: every well-formed diff --git a/languages/golang/stackencrypt/live_test.go b/languages/golang/stackencrypt/live_test.go index 52f8cf2ee..63c082267 100644 --- a/languages/golang/stackencrypt/live_test.go +++ b/languages/golang/stackencrypt/live_test.go @@ -23,13 +23,26 @@ func liveClient(t *testing.T) *Client { if clientID == "" || clientKey == "" || token == "" { t.Skip("STACK_ENCRYPT_TEST_{CLIENT_ID,CLIENT_KEY,ACCESS_TOKEN} not set") } + material := []byte(clientKey) + key := NewClientKey(material) c, err := NewClient(t.Context(), Config{ - ClientID: clientID, ClientKey: NewClientKey([]byte(clientKey)), ZeroKMSURL: url, Token: StaticToken(token), + ClientID: clientID, ClientKey: key, ZeroKMSURL: url, Token: StaticToken(token), }) if err != nil { t.Fatalf("NewClient: %v", err) } t.Cleanup(func() { _ = c.Close() }) + // The successful outcome of the consumption contract, which only a + // real load-keyset response can reach: the key is empty and the bytes + // it was built from are zero once the client exists. + if !key.IsZero() { + t.Error("the key still holds material after NewClient succeeded") + } + for i, b := range material { + if b != 0 { + t.Fatalf("byte %d of the key material was not wiped by a successful NewClient", i) + } + } return c } From 587af1daf6f549a8aec4b5a81d15c39af1f09f70 Mon Sep 17 00:00:00 2001 From: Dan Draper <dan@cipherstash.com> Date: Mon, 21 Sep 2026 23:14:48 -0500 Subject: [PATCH 619/686] feat(go): the credential guest and stackauth, the profile half of ADR-0005 Go gets the developer profile through stack-profile running unmodified in a second WASI module, embedded in a new package, stackauth. wazero mounts exactly one directory into it, the profile root, at a fixed guest path, and gives it no environment: Go resolves CS_CONFIG_PATH and the home directory the way ProfileStore::resolve does, mounts the result, and names the store's directory on every call. The crypto guest is unchanged. Its import surface is asserted at build time: the filesystem allowed, sockets denied, random_get required, and no transport import until the auth half adds one. The full napi profile surface is methods on ProfileStore, named as in Rust: the current workspace, the workspaces on disk, workspace-scoped stores, and typed reads of secretkey.json (the client id and the opaque ClientKey a stackencrypt.Config takes), auth.json (through stack_auth::Token; the refresh token never crosses) and device.json (read-only). LockPath is the host path of the crate's lock file, mapped from the guest's answer and never composed in Go. Errors are stack-profile's variants as Go sentinels, carried as status codes 13-19 appended to the shared table. A TokenSource re-reads auth.json on every call and refuses a token at its real expiry, naming `stash auth login`. Tests pin what the crate does not own on wasm32: files the guest creates are 0600 (wazero's create mode), and a path outside the mount is refused. Guest memory is the locked, non-dumpable kind, through internal/guest, whose call plumbing the second guest now shares. Closes CIP-4053. --- .github/imported-workflows/test-wasi.yml | 52 +- docs/plans/stack-encrypt-go-bindings.md | 9 +- languages/golang/internal/guest/call.go | 124 + languages/golang/internal/guest/errors.go | 19 + .../golang/internal/guest/memory_test.go | 31 +- languages/golang/internal/guest/status.go | 22 + languages/golang/stackauth/README.md | 80 + languages/golang/stackauth/clientkey.go | 11 + languages/golang/stackauth/doc.go | 60 + languages/golang/stackauth/errors.go | 54 + languages/golang/stackauth/guest.go | 172 ++ languages/golang/stackauth/guest/.gitignore | 3 + languages/golang/stackauth/guest/Cargo.lock | 2700 +++++++++++++++++ languages/golang/stackauth/guest/Cargo.toml | 57 + languages/golang/stackauth/guest/src/abi.rs | 192 ++ languages/golang/stackauth/guest/src/lib.rs | 102 + languages/golang/stackauth/guest/src/ops.rs | 412 +++ .../golang/stackauth/guest/src/status.rs | 88 + languages/golang/stackauth/store.go | 402 +++ languages/golang/stackauth/store_test.go | 440 +++ languages/golang/stackauth/token.go | 105 + languages/golang/stackauth/wasm/README.md | 7 + packages/stack-guest-abi/src/status.rs | 41 +- scripts/go-binding-test.sh | 20 +- 24 files changed, 5160 insertions(+), 43 deletions(-) create mode 100644 languages/golang/internal/guest/call.go create mode 100644 languages/golang/stackauth/README.md create mode 100644 languages/golang/stackauth/clientkey.go create mode 100644 languages/golang/stackauth/doc.go create mode 100644 languages/golang/stackauth/errors.go create mode 100644 languages/golang/stackauth/guest.go create mode 100644 languages/golang/stackauth/guest/.gitignore create mode 100644 languages/golang/stackauth/guest/Cargo.lock create mode 100644 languages/golang/stackauth/guest/Cargo.toml create mode 100644 languages/golang/stackauth/guest/src/abi.rs create mode 100644 languages/golang/stackauth/guest/src/lib.rs create mode 100644 languages/golang/stackauth/guest/src/ops.rs create mode 100644 languages/golang/stackauth/guest/src/status.rs create mode 100644 languages/golang/stackauth/store.go create mode 100644 languages/golang/stackauth/store_test.go create mode 100644 languages/golang/stackauth/token.go create mode 100644 languages/golang/stackauth/wasm/README.md diff --git a/.github/imported-workflows/test-wasi.yml b/.github/imported-workflows/test-wasi.yml index f7706de93..db1875929 100644 --- a/.github/imported-workflows/test-wasi.yml +++ b/.github/imported-workflows/test-wasi.yml @@ -97,27 +97,38 @@ jobs: - name: Guest release build and import-surface gate run: mise run wasm:guest:build + # The credential guest (ADR-0005): the same lint and native tests, + # then the release build and its own import gate — filesystem + # allowed, sockets denied, no transport import until the auth half. + - name: Credential guest lint and tests + run: mise run wasm:auth-guest:test + + - name: Credential guest release build and import-surface gate + run: mise run wasm:auth-guest:build + # The module checked above is what every platform tests. Its checksum # travels with it so the other jobs can prove they got the same bytes, # not a stale or rebuilt guest. - - name: Record the guest's checksum + - name: Record the guests' checksums run: | - cd bindings/go/stackencrypt/wasm - openssl dgst -sha256 stack_encrypt_guest.wasm | awk '{print $NF}' > stack_encrypt_guest.wasm.sha256 - echo "stack_encrypt_guest.wasm sha256 $(cat stack_encrypt_guest.wasm.sha256)" + for guest in stackencrypt/wasm/stack_encrypt_guest.wasm stackauth/wasm/stack_auth_guest.wasm; do + (cd bindings/go && openssl dgst -sha256 "$guest" | awk '{print $NF}' > "$guest.sha256" && echo "$guest sha256 $(cat "$guest.sha256")") + done - - name: Hand the guest to the other platforms + - name: Hand the guests to the other platforms uses: actions/upload-artifact@v7 with: name: wasm-guests path: | bindings/go/stackencrypt/wasm/stack_encrypt_guest.wasm bindings/go/stackencrypt/wasm/stack_encrypt_guest.wasm.sha256 + bindings/go/stackauth/wasm/stack_auth_guest.wasm + bindings/go/stackauth/wasm/stack_auth_guest.wasm.sha256 if-no-files-found: error retention-days: 1 - # The Go module (bindings/go; the stackencrypt package embeds the - # guest built above): format, vet and hermetic tests (import surface, transport + # The Go module (bindings/go; stackencrypt and stackauth embed the + # guests built above): format, vet and hermetic tests (import surface, transport # bridge against an httptest ZeroKMS, guest-parser acceptance of every # encoding the package builds, hostile ABI inputs, key residency), on # amd64 and 386. Live round trips through ZeroKMS are the phase 5 @@ -184,21 +195,26 @@ jobs: go-version: ${{ steps.go.outputs.version }} cache-dependency-path: bindings/go/go.sum + # The artifact keeps the paths it was uploaded with, relative to their + # common root (bindings/go), so both guests land where the packages + # embed them. - uses: actions/download-artifact@v8 with: name: wasm-guests - path: bindings/go/stackencrypt/wasm + path: bindings/go - - name: The guest is the one Linux built and checked + - name: The guests are the ones Linux built and checked run: | - cd bindings/go/stackencrypt/wasm - want=$(cat stack_encrypt_guest.wasm.sha256) - got=$(openssl dgst -sha256 stack_encrypt_guest.wasm | awk '{print $NF}') - if [ "$want" != "$got" ]; then - echo "guest checksum mismatch: artifact says $want, file is $got" >&2 - exit 1 - fi - echo "stack_encrypt_guest.wasm sha256 $got" + cd bindings/go + for guest in stackencrypt/wasm/stack_encrypt_guest.wasm stackauth/wasm/stack_auth_guest.wasm; do + want=$(cat "$guest.sha256") + got=$(openssl dgst -sha256 "$guest" | awk '{print $NF}') + if [ "$want" != "$got" ]; then + echo "$guest checksum mismatch: artifact says $want, file is $got" >&2 + exit 1 + fi + echo "$guest sha256 $got" + done - name: Go binding - run: scripts/go-binding-test.sh bindings/go stackencrypt/wasm/stack_encrypt_guest.wasm + run: scripts/go-binding-test.sh bindings/go diff --git a/docs/plans/stack-encrypt-go-bindings.md b/docs/plans/stack-encrypt-go-bindings.md index ce6e82579..ed0d5d48c 100644 --- a/docs/plans/stack-encrypt-go-bindings.md +++ b/docs/plans/stack-encrypt-go-bindings.md @@ -522,8 +522,13 @@ probe, _ := cipher.Term(ctx, uint32(34), "users/age", stackencrypt.Equality) ## Credential guest — `stack-profile` and `stack-auth` for Go -**Status:** decided 2026-09-20, not started. The decision and its rationale -are [ADR-0005](../../packages/stack-encrypt/docs/adr/0005-a-separate-credential-guest-for-the-profile-and-auth.md); +**Status:** decided 2026-09-20; the profile half shipped 2026-09-22 +(steps 1–4 below: CIP-4114, CIP-3997, CIP-4115 with CIP-4118, CIP-4053). +`bindings/go/stackauth` is the package, `bindings/go/stackauth/guest` the +module, `packages/stack-guest-abi` what both guests share. The transport +seam (step 5, CIP-4116) shipped separately; the strategies (step 6, +CIP-4054) are next. The decision and its rationale are +[ADR-0005](../../packages/stack-encrypt/docs/adr/0005-a-separate-credential-guest-for-the-profile-and-auth.md); this section is the sequencing only. Go gets the profile and auth crates through a **second** WASI module, the diff --git a/languages/golang/internal/guest/call.go b/languages/golang/internal/guest/call.go new file mode 100644 index 000000000..609e7bd3d --- /dev/null +++ b/languages/golang/internal/guest/call.go @@ -0,0 +1,124 @@ +package guest + +import ( + "context" + "errors" + "fmt" + + "github.com/tetratelabs/wazero/api" +) + +// The call plumbing every guest package uses to drive an export: stage +// each buffer argument into guest memory through the guest's se_alloc, +// call, copy the output out, and wipe and free every buffer — inputs and +// output — before returning. The memory stays mapped for the whole call +// (see Allocator.Free). + +// ErrTrap marks a guest export that did not return: a trap (the guests +// build with panic-as-abort, so an allocation one cannot make or an +// invariant it cannot keep ends in `unreachable`), or a module closed +// under it. The caller closes the instance on it: the guest's state after +// an abort is unknown, and its memory is better wiped than reused. +var ErrTrap = errors.New("cipherstash: guest did not return") + +// Buf is a host-owned allocation inside guest linear memory. +type Buf struct { + Ptr, Len uint32 +} + +// Arg is one guest-call argument: a buffer (staged into guest memory and +// passed as a (ptr, len) pair) or a scalar passed as is. +type Arg struct { + data []byte + scalar uint64 + isBuf bool +} + +// BufArg is a buffer argument. +func BufArg(data []byte) Arg { return Arg{data: data, isBuf: true} } + +// ScalarArg is a scalar argument. +func ScalarArg(v uint64) Arg { return Arg{scalar: v} } + +// Exports is the pair of exports every guest has, resolved on one module. +type Exports struct { + Alloc, Dealloc api.Function +} + +// AllocWrite stages data into a fresh guest buffer. +func (e Exports) AllocWrite(ctx context.Context, m api.Module, data []byte) (Buf, error) { + res, err := e.Alloc.Call(ctx, uint64(len(data))) + if err != nil { + return Buf{}, fmt.Errorf("%w: guest alloc: %w", ErrTrap, err) + } + b := Buf{Ptr: uint32(res[0]), Len: uint32(len(data))} + if b.Ptr == 0 { + return Buf{}, errors.New("cipherstash: guest allocation failed") + } + if len(data) > 0 && !m.Memory().Write(b.Ptr, data) { + e.Free(ctx, b) + return Buf{}, errors.New("cipherstash: guest memory write out of range") + } + return b, nil +} + +// Free zeroizes and releases a guest buffer (se_dealloc wipes; an unknown +// pointer is a no-op there). It runs under a context that cannot be +// cancelled: a caller's deadline expiring after the guest call returned +// must not skip the wipe of the buffers that call staged. +func (e Exports) Free(ctx context.Context, b Buf) { + if b.Ptr != 0 { + _, _ = e.Dealloc.Call(context.WithoutCancel(ctx), uint64(b.Ptr), uint64(b.Len)) + } +} + +// Call stages every buffer argument, calls fn with the arguments in +// order, and copies the output out before every buffer — inputs and +// output — is wiped and freed. mem is the module's allocator, held +// mapped for the whole call. +func Call(ctx context.Context, mem *Allocator, m api.Module, e Exports, fn api.Function, args ...Arg) ([]byte, error) { + mem.Enter() + defer mem.Exit() + var bufs []Buf + defer func() { + for _, b := range bufs { + e.Free(ctx, b) + } + }() + params := make([]uint64, 0, 2*len(args)) + for _, a := range args { + if !a.isBuf { + params = append(params, a.scalar) + continue + } + staged, err := e.AllocWrite(ctx, m, a.data) + if err != nil { + return nil, err + } + bufs = append(bufs, staged) + params = append(params, uint64(staged.Ptr), uint64(staged.Len)) + } + res, err := fn.Call(ctx, params...) + if err != nil { + return nil, fmt.Errorf("%w: guest call: %w", ErrTrap, err) + } + ptr, n, err := PackedResult(res[0]) + if err != nil { + return nil, err + } + out := Buf{Ptr: ptr, Len: n} + bufs = append(bufs, out) + view, ok := m.Memory().Read(out.Ptr, out.Len) + if !ok { + return nil, errors.New("cipherstash: guest returned an out-of-range buffer") + } + // Copy out before the deferred free wipes the guest-side buffer. + result := make([]byte, len(view)) + copy(result, view) + return result, nil +} + +// Wipe zeroes a host buffer. +func Wipe(b []byte) { + clear(b) +} diff --git a/languages/golang/internal/guest/errors.go b/languages/golang/internal/guest/errors.go index a0ccd7d3f..75405b012 100644 --- a/languages/golang/internal/guest/errors.go +++ b/languages/golang/internal/guest/errors.go @@ -46,6 +46,25 @@ var ( // ErrForeignKeyset is a keyset-bound cipher refusing a ciphertext sealed // under another keyset, before any key is retrieved. ErrForeignKeyset = errors.New("cipherstash: ciphertext belongs to another keyset") + // ErrProfileIO is a profile file that could not be read or written. + ErrProfileIO = errors.New("cipherstash: profile file could not be read or written") + // ErrProfileJSON is a profile file that is not the JSON its type expects. + ErrProfileJSON = errors.New("cipherstash: profile file is not valid") + // ErrProfileNotFound is a profile file that does not exist in the store + // asked: no secretkey.json, auth.json or device.json there. + ErrProfileNotFound = errors.New("cipherstash: profile file not found") + // ErrInvalidFilename is a filename the store refuses: empty, absolute, + // or naming a path. + ErrInvalidFilename = errors.New("cipherstash: invalid profile filename") + // ErrNoCurrentWorkspace is a workspace-scoped operation with no current + // workspace set. + ErrNoCurrentWorkspace = errors.New("cipherstash: no current workspace; run `stash auth login`") + // ErrInvalidWorkspaceID is a workspace id that is not sixteen base32 + // characters. + ErrInvalidWorkspaceID = errors.New("cipherstash: invalid workspace id") + // ErrWorkspaceNotFound is a workspace with no local profile data: nothing + // has logged in to it on this machine. + ErrWorkspaceNotFound = errors.New("cipherstash: workspace has no local profile; log in to it first") // ErrMemoryLock is guest memory that could not be locked in RAM (or, // on Linux, excluded from core dumps). A constructor returns it when // asked for locked memory and refused, and so does any later call under diff --git a/languages/golang/internal/guest/memory_test.go b/languages/golang/internal/guest/memory_test.go index 62fd38c2c..fc45f3854 100644 --- a/languages/golang/internal/guest/memory_test.go +++ b/languages/golang/internal/guest/memory_test.go @@ -210,18 +210,25 @@ func TestMemoryOutlivesACallClosedDuringAHostImport(t *testing.T) { // is an internal failure that keeps the number. func TestStatusDecodesToTheSharedSentinels(t *testing.T) { want := map[uint32]error{ - guest.StatusAuth: guest.ErrAuthentication, - guest.StatusEncoding: guest.ErrEncoding, - guest.StatusState: guest.ErrState, - guest.StatusInternal: guest.ErrInternal, - guest.StatusKMSUnauthorized: guest.ErrUnauthorized, - guest.StatusKMSForbidden: guest.ErrForbidden, - guest.StatusKMSNotFound: guest.ErrNotFound, - guest.StatusKMSConflict: guest.ErrConflict, - guest.StatusKMSTransport: guest.ErrTransport, - guest.StatusKMSOther: guest.ErrKMS, - guest.StatusTerm: guest.ErrTerm, - guest.StatusForeignKeyset: guest.ErrForeignKeyset, + guest.StatusAuth: guest.ErrAuthentication, + guest.StatusEncoding: guest.ErrEncoding, + guest.StatusState: guest.ErrState, + guest.StatusInternal: guest.ErrInternal, + guest.StatusKMSUnauthorized: guest.ErrUnauthorized, + guest.StatusKMSForbidden: guest.ErrForbidden, + guest.StatusKMSNotFound: guest.ErrNotFound, + guest.StatusKMSConflict: guest.ErrConflict, + guest.StatusKMSTransport: guest.ErrTransport, + guest.StatusKMSOther: guest.ErrKMS, + guest.StatusTerm: guest.ErrTerm, + guest.StatusForeignKeyset: guest.ErrForeignKeyset, + guest.StatusProfileIO: guest.ErrProfileIO, + guest.StatusProfileJSON: guest.ErrProfileJSON, + guest.StatusProfileNotFound: guest.ErrProfileNotFound, + guest.StatusProfileInvalidFilename: guest.ErrInvalidFilename, + guest.StatusProfileNoCurrentWorkspace: guest.ErrNoCurrentWorkspace, + guest.StatusProfileInvalidWorkspaceID: guest.ErrInvalidWorkspaceID, + guest.StatusProfileWorkspaceNotFound: guest.ErrWorkspaceNotFound, } for code, sentinel := range want { if got := guest.StatusError(code); got != sentinel { diff --git a/languages/golang/internal/guest/status.go b/languages/golang/internal/guest/status.go index a49c5b2d9..162286d79 100644 --- a/languages/golang/internal/guest/status.go +++ b/languages/golang/internal/guest/status.go @@ -19,6 +19,14 @@ const ( StatusKMSOther = 10 StatusTerm = 11 StatusForeignKeyset = 12 + // The credential guest's profile conditions. + StatusProfileIO = 13 + StatusProfileJSON = 14 + StatusProfileNotFound = 15 + StatusProfileInvalidFilename = 16 + StatusProfileNoCurrentWorkspace = 17 + StatusProfileInvalidWorkspaceID = 18 + StatusProfileWorkspaceNotFound = 19 ) // StatusError is the sentinel a guest status decodes to. A status this host @@ -50,6 +58,20 @@ func StatusError(status uint32) error { return ErrTerm case StatusForeignKeyset: return ErrForeignKeyset + case StatusProfileIO: + return ErrProfileIO + case StatusProfileJSON: + return ErrProfileJSON + case StatusProfileNotFound: + return ErrProfileNotFound + case StatusProfileInvalidFilename: + return ErrInvalidFilename + case StatusProfileNoCurrentWorkspace: + return ErrNoCurrentWorkspace + case StatusProfileInvalidWorkspaceID: + return ErrInvalidWorkspaceID + case StatusProfileWorkspaceNotFound: + return ErrWorkspaceNotFound default: return fmt.Errorf("%w (unrecognized guest status %d)", ErrInternal, status) } diff --git a/languages/golang/stackauth/README.md b/languages/golang/stackauth/README.md new file mode 100644 index 000000000..a2dab0ef3 --- /dev/null +++ b/languages/golang/stackauth/README.md @@ -0,0 +1,80 @@ +# stackauth + +The Go binding of the developer profile — the directory `stash auth login` +writes — read through the `stack-profile` Rust crate running inside a WASI +guest under [wazero], with `CGO_ENABLED=0`. It is the credential half of +the Go SDK: it hands a [`stackencrypt`](../stackencrypt) client its client +key and its bearer token without either package re-deriving the profile's +layout, and without either importing the other. + +This is the **profile half** of [ADR-0005]. Refreshing a token from Go, and +the access-key and OIDC strategies, are the auth half (CIP-4054); until it +lands, an expired stored token means `stash auth login`. + +[wazero]: https://wazero.io +[ADR-0005]: ../../../packages/stack-encrypt/docs/adr/0005-a-separate-credential-guest-for-the-profile-and-auth.md + +## Use + +```go +import ( + "context" + + "github.com/cipherstash/cipherstash-suite/bindings/go/stackauth" + "github.com/cipherstash/cipherstash-suite/bindings/go/stackencrypt" +) + +func run(ctx context.Context) error { + profile, err := stackauth.Resolve(ctx) // CS_CONFIG_PATH, else ~/.cipherstash + if err != nil { + return err + } + defer profile.Close() + + workspace, err := profile.CurrentWorkspaceStore(ctx) + if err != nil { + return err // stackauth.ErrNoCurrentWorkspace: run `stash auth login` + } + clientID, clientKey, err := workspace.SecretKey(ctx) + if err != nil { + return err + } + client, err := stackencrypt.NewClient(ctx, stackencrypt.Config{ + ClientID: clientID, + ClientKey: clientKey, // consumed and wiped by NewClient + Token: workspace.TokenSource(), // re-reads auth.json per request + }) + if err != nil { + return err + } + defer client.Close() + // ... + return nil +} +``` + +`stackauth.ClientKey` and `stackencrypt.ClientKey` are one type, so the +key goes straight from the profile into the config. The token source +refuses a token at its real expiry with `stackauth.ErrTokenExpired`. + +## What the guest is given + +Exactly one directory, mounted read-write at a fixed guest path, and no +environment. It cannot name a path outside it: every path is built by the +Rust crate from the store's directory and a validated filename or +workspace id. Files it creates are mode 0600. It takes no file lock (WASI +preview 1 has none); the cross-process refresh lock the CLI holds is Go's +to take, on the path `ProfileStore.LockPath` names, once refreshing lands. + +The crypto guest behind `stackencrypt` is not widened by this package +existing: it still has no filesystem and no environment. + +## Build + +``` +mise run wasm:auth-guest:build # the guest, with its import-surface gate +mise run go:stackencrypt:test # the whole Go module, both packages +``` + +The guest module is embedded from `wasm/` and not committed; without it, +`Open` returns `ErrGuestNotBuilt` and the tests skip. diff --git a/languages/golang/stackauth/clientkey.go b/languages/golang/stackauth/clientkey.go new file mode 100644 index 000000000..1affdd36f --- /dev/null +++ b/languages/golang/stackauth/clientkey.go @@ -0,0 +1,11 @@ +package stackauth + +import "github.com/cipherstash/cipherstash-suite/bindings/go/internal/guest" + +// ClientKey is the ZeroKMS client key as [ProfileStore.SecretKey] reads it +// out of secretkey.json: opaque (it prints a redaction under every verb and +// hands its bytes to no caller) and wiped once consumed. It is the same +// type as stackencrypt.ClientKey, by identity, so a key read here goes +// straight into a stackencrypt.Config without either package importing the +// other. +type ClientKey = guest.ClientKey diff --git a/languages/golang/stackauth/doc.go b/languages/golang/stackauth/doc.go new file mode 100644 index 000000000..a57535fd3 --- /dev/null +++ b/languages/golang/stackauth/doc.go @@ -0,0 +1,60 @@ +// Package stackauth is the Go binding of the developer profile: the +// directory `stash auth login` writes (~/.cipherstash, or CS_CONFIG_PATH), +// read through the stack-profile crate running unmodified inside a WASI +// guest under wazero (CGO_ENABLED=0), so the on-disk layout is never +// re-derived by hand in Go. +// +// # Shape +// +// A [ProfileStore] is one guest instance over one mounted directory. [Resolve] +// finds the profile directory the way the Rust crate does (CS_CONFIG_PATH, +// then ~/.cipherstash); [Open] takes one. The guest is given that directory +// and nothing else: no environment, no other path, no network. Everything +// the napi binding of stack-profile exposes is a method here, named as in +// Rust: the current workspace ([ProfileStore.CurrentWorkspace], +// [ProfileStore.SetCurrentWorkspace], [ProfileStore.ClearCurrentWorkspace]), +// the workspaces on disk ([ProfileStore.ListWorkspaces]), a store scoped to +// one workspace ([ProfileStore.WorkspaceStore], +// [ProfileStore.CurrentWorkspaceStore]), and the typed reads of the files a +// workspace holds: [ProfileStore.SecretKey] hands out the ZeroKMS client key +// as the opaque [ClientKey] that stackencrypt's Config takes, +// [ProfileStore.Token] the stored access token, [ProfileStore.DeviceIdentity] +// the identity the CLI created. [ProfileStore.Close] releases the guest; +// stores scoped from it are closed with it. +// +// [ProfileStore.TokenSource] is a token source for stackencrypt over the +// stored token: it re-reads auth.json on every call, so a login or refresh +// by the CLI in another terminal is picked up without a restart, and it +// refuses a token at its real expiry with an error naming `stash auth +// login`. Refreshing a token from Go is the auth half of this package, +// not yet here; the 90-second refresh-ahead margin belongs to it. +// +// # Why a second guest +// +// The crypto guest behind stackencrypt has no filesystem and no +// environment: a bug or compromise inside it cannot read credentials off +// disk. Mounting the profile into it would trade that away, and it is the +// module that handles plaintext and data keys. So the profile lives in its +// own module with its own, smaller blast radius: one directory of +// credentials. ADR-0005 in packages/stack-encrypt/docs/adr records the +// decision and the alternatives. +// +// # What the guest cannot do +// +// WASI preview 1 has no file locking, so the guest takes none: the +// cross-process refresh lock the Rust CLI holds is Go's to take, on the +// path [ProfileStore.LockPath] names, around the refresh call once it +// exists. Creating a device identity is native-only in the crate and +// CLI territory; this package only reads one. Files the guest creates are +// mode 0600, which is wazero's create mode rather than the crate's own +// (skipped on wasm32), so a test pins it. +// +// # Memory +// +// The guest's memory holds the client key and the token while a read is +// in flight. It is supplied the way stackencrypt's is — reserved once so it +// never moves, locked in RAM and excluded from core dumps where the +// platform allows, wiped before release, none of it depending on Close +// running — and [ProfileStore.MemoryLocked] reports whether the lock was +// granted. [RequireLockedMemory] makes a refused lock an error from Open. +package stackauth diff --git a/languages/golang/stackauth/errors.go b/languages/golang/stackauth/errors.go new file mode 100644 index 000000000..9d4a2bfd6 --- /dev/null +++ b/languages/golang/stackauth/errors.go @@ -0,0 +1,54 @@ +package stackauth + +import ( + "errors" + + "github.com/cipherstash/cipherstash-suite/bindings/go/internal/guest" +) + +// Failure kinds the profile reports. The guest reports a status code from +// the one table every guest shares, so these are the sentinels of the +// shared decoder exposed under this package's names; an error from +// stackencrypt of the same kind is the same value. +var ( + // ErrNotFound is a profile file that does not exist in the store asked: + // no secretkey.json, auth.json or device.json there. For the workspace's + // files that means nothing has logged in to it on this machine. + ErrNotFound = guest.ErrProfileNotFound + // ErrInvalid is a profile file that is not the JSON its type expects. + ErrInvalid = guest.ErrProfileJSON + // ErrIO is a profile file that could not be read or written. + ErrIO = guest.ErrProfileIO + // ErrInvalidFilename is a filename the store refuses: empty, absolute, + // or naming a path. + ErrInvalidFilename = guest.ErrInvalidFilename + // ErrNoCurrentWorkspace is a workspace-scoped operation with no current + // workspace set: run `stash auth login`. + ErrNoCurrentWorkspace = guest.ErrNoCurrentWorkspace + // ErrInvalidWorkspaceID is a workspace id that is not sixteen base32 + // characters. Refused before any path is built from it. + ErrInvalidWorkspaceID = guest.ErrInvalidWorkspaceID + // ErrWorkspaceNotFound is a workspace with no directory under + // workspaces/: nothing has logged in to it on this machine. + ErrWorkspaceNotFound = guest.ErrWorkspaceNotFound + // ErrEncoding is an input the guest refused: a directory, id or filename + // that is not UTF-8. + ErrEncoding = guest.ErrEncoding + // ErrState is a call on a store that has been closed. + ErrState = guest.ErrState + // ErrInternal is a guest panic or any other unexpected guest failure. + ErrInternal = guest.ErrInternal + // ErrMemoryLock is guest memory that could not be locked in RAM (or, on + // Linux, excluded from core dumps). Open returns it under + // [RequireLockedMemory]; otherwise [ProfileStore.MemoryLockError] + // reports it and the store works on with unlocked memory. + ErrMemoryLock = guest.ErrMemoryLock + + // ErrTokenExpired is a stored token past its expiry: the profile has one, + // but it is no use, and only `stash auth login` (or, once the auth half + // of this package lands, a refresh) can replace it. + ErrTokenExpired = errors.New("stackauth: the stored token has expired; run `stash auth login`") + // ErrNoProfile is a profile directory that does not exist: nothing has + // logged in on this machine, or CS_CONFIG_PATH names the wrong place. + ErrNoProfile = errors.New("stackauth: no profile directory; run `stash auth login`") +) diff --git a/languages/golang/stackauth/guest.go b/languages/golang/stackauth/guest.go new file mode 100644 index 000000000..5d0af3189 --- /dev/null +++ b/languages/golang/stackauth/guest.go @@ -0,0 +1,172 @@ +package stackauth + +import ( + "context" + "crypto/rand" + "embed" + "errors" + "fmt" + "sync" + + "github.com/cipherstash/cipherstash-suite/bindings/go/internal/guest" + "github.com/tetratelabs/wazero" + "github.com/tetratelabs/wazero/api" + "github.com/tetratelabs/wazero/experimental" + "github.com/tetratelabs/wazero/imports/wasi_snapshot_preview1" +) + +// The guest module is a build artefact of the Rust crate in ./guest, +// copied here by `mise run wasm:auth-guest:build`. It is embedded as a +// directory so the package compiles without it; Open reports its absence. +// +//go:embed wasm +var guestFS embed.FS + +const guestPath = "wasm/stack_auth_guest.wasm" + +// guestRoot is where the guest sees the profile directory. The one mount +// the guest is given lands here, and every store directory the package +// names is under it. Pinned against the guest's own constant by its tests. +const guestRoot = "/profile" + +// ErrGuestNotBuilt is returned by Open when no guest module is embedded and +// none was supplied with [WithGuest]. +var ErrGuestNotBuilt = errors.New("stackauth: guest module not built — run `mise run wasm:auth-guest:build`") + +func embeddedGuest() ([]byte, error) { + wasm, err := guestFS.ReadFile(guestPath) + if err != nil { + return nil, ErrGuestNotBuilt + } + return wasm, nil +} + +// One shared compilation cache: only the first instantiation of a given +// module in the process compiles it. Every store still owns its own +// runtime and instance. +var ( + cacheOnce sync.Once + sharedCache wazero.CompilationCache +) + +func compilationCache() wazero.CompilationCache { + cacheOnce.Do(func() { sharedCache = wazero.NewCompilationCache() }) + return sharedCache +} + +// instance is one instantiated guest over one mounted directory, with its +// exports resolved. It is the unsynchronised half of a store; the root +// serialises access. +type instance struct { + runtime wazero.Runtime + module api.Module + mem *guest.Allocator + exports guest.Exports + + shutdown api.Function + currentWorkspace, setCurrentWorkspace, clearCurrentWorkspace api.Function + listWorkspaces, workspaceDir, lockPath api.Function + secretKey, token, deviceIdentity api.Function +} + +// guestModuleConfig is the module configuration every guest instance runs +// under: the one directory mount, and nothing else that grants a +// capability. No environment: the guest is told its directory, it never +// looks one up. random_get is wired to crypto/rand because the crate's +// atomic rewrite names its staging file with a UUID drawn through it; +// wazero's default is a fixed seed, which would make two instances agree +// on that name. The clocks are the system's for the same reason they are +// in stackencrypt: a deterministic default is the wrong default for +// anything that reads time. Each is pinned by a test. +func guestModuleConfig(hostDir string) wazero.ModuleConfig { + return wazero.NewModuleConfig(). + WithName("stack_auth_guest"). + WithFSConfig(wazero.NewFSConfig().WithDirMount(hostDir, guestRoot)). + WithRandSource(rand.Reader). + WithSysNanotime(). + WithSysWalltime() +} + +// newInstance instantiates wasm with hostDir mounted at guestRoot and its +// linear memory from the guest packages' allocator. Under the strict +// policy, memory that cannot be locked fails instantiation with +// ErrMemoryLock. +func newInstance(ctx context.Context, wasm []byte, hostDir string, policy guest.LockPolicy) (*instance, error) { + config := wazero.NewRuntimeConfig(). + WithCompilationCache(compilationCache()). + WithCloseOnContextDone(true) + runtime := wazero.NewRuntimeWithConfig(ctx, config) + if _, err := wasi_snapshot_preview1.Instantiate(ctx, runtime); err != nil { + _ = runtime.Close(ctx) + return nil, fmt.Errorf("stackauth: instantiating WASI: %w", err) + } + mem := guest.NewAllocator(policy) + // The guest is a reactor (cdylib): no _start. wazero runs _initialize + // when present, so guest code runs here too, and the memory must stay + // mapped until it returns, the same as around a call. + mem.Enter() + module, err := func() (api.Module, error) { + defer mem.Exit() + return runtime.InstantiateWithConfig(experimental.WithMemoryAllocator(ctx, mem), wasm, guestModuleConfig(hostDir)) + }() + if err != nil { + _ = runtime.Close(ctx) + if g := mem.GrowthRefusal(); g.Refused != 0 { + return nil, fmt.Errorf("%w: %w", guest.MemoryLockError(g.Reason), err) + } + return nil, fmt.Errorf("stackauth: instantiating guest: %w", err) + } + if policy == guest.Strict { + if lerr := mem.LockError(); lerr != nil { + _ = runtime.Close(ctx) + return nil, guest.MemoryLockError(lerr) + } + } + inst := &instance{runtime: runtime, module: module, mem: mem} + exports := map[string]*api.Function{ + "se_alloc": &inst.exports.Alloc, + "se_dealloc": &inst.exports.Dealloc, + "sa_shutdown": &inst.shutdown, + "sa_current_workspace": &inst.currentWorkspace, + "sa_set_current_workspace": &inst.setCurrentWorkspace, + "sa_clear_current_workspace": &inst.clearCurrentWorkspace, + "sa_list_workspaces": &inst.listWorkspaces, + "sa_workspace_dir": &inst.workspaceDir, + "sa_lock_path": &inst.lockPath, + "sa_secret_key": &inst.secretKey, + "sa_token": &inst.token, + "sa_device_identity": &inst.deviceIdentity, + } + for name, slot := range exports { + if *slot = module.ExportedFunction(name); *slot == nil { + _ = runtime.Close(ctx) + return nil, fmt.Errorf("stackauth: guest is missing export %s", name) + } + } + return inst, nil +} + +// release runs the guest's shutdown — every buffer it still holds wiped — +// and closes the runtime, which frees the linear memory through the +// allocator's wipe. A module an interrupted call or a trap already closed +// cannot run sa_shutdown; the runtime close still wipes and frees its +// memory. +func (inst *instance) release() error { + ctx := context.Background() + if !inst.module.IsClosed() { + inst.mem.Enter() + _, _ = inst.shutdown.Call(ctx) + inst.mem.Exit() + } + return inst.runtime.Close(ctx) +} + +// call drives one export with string arguments, through the shared +// plumbing: staged, called, copied out, wiped. +func (inst *instance) call(ctx context.Context, fn api.Function, args ...string) ([]byte, error) { + staged := make([]guest.Arg, len(args)) + for i, a := range args { + staged[i] = guest.BufArg([]byte(a)) + } + return guest.Call(ctx, inst.mem, inst.module, inst.exports, fn, staged...) +} diff --git a/languages/golang/stackauth/guest/.gitignore b/languages/golang/stackauth/guest/.gitignore new file mode 100644 index 000000000..32e28bc74 --- /dev/null +++ b/languages/golang/stackauth/guest/.gitignore @@ -0,0 +1,3 @@ +# Cargo output of this detached workspace; the built module is copied to +# ../wasm by `mise run wasm:auth-guest:build`. +/target diff --git a/languages/golang/stackauth/guest/Cargo.lock b/languages/golang/stackauth/guest/Cargo.lock new file mode 100644 index 000000000..09afb14bd --- /dev/null +++ b/languages/golang/stackauth/guest/Cargo.lock @@ -0,0 +1,2700 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common 0.1.7", + "generic-array", +] + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures 0.2.17", +] + +[[package]] +name = "aes-gcm" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" +dependencies = [ + "aead", + "aes", + "cipher", + "ctr", + "ghash", + "subtle", + "zeroize", +] + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "android_system_properties" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" +dependencies = [ + "libc", +] + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "aquamarine" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f50776554130342de4836ba542aa85a4ddb361690d7e8df13774d7284c3d5c2" +dependencies = [ + "include_dir", + "itertools", + "proc-macro-error2", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" +dependencies = [ + "serde", +] + +[[package]] +name = "atomic" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89cbf775b137e9b968e67227ef7f775587cde3fd31b0d8599dbd0f598a48340" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "aws-lc-rs" +version = "1.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b281d307588d634de920874890732659e2e7672f72b5e10e81badc1a8a83621e" +dependencies = [ + "aws-lc-sys", + "untrusted", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bff6c3b54fad79a2e60b8102caf565819711497c1f5f092f49508e2f5c31b27" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", + "pkg-config", +] + +[[package]] +name = "base32" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "022dfe9eb35f19ebbcb51e0b40a5ab759f46ad60cadf7297e0bd085afb50e076" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "bitvec" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddcec3d12c579d40898fe0a9a358a803c23e9c52ca3c425707f81c9436211837" +dependencies = [ + "funty", + "radium", + "tap", + "wyz", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytemuck" +version = "1.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" +dependencies = [ + "serde", +] + +[[package]] +name = "cached" +version = "0.54.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9718806c4a2fe9e8a56fd736f97b340dd10ed1be8ed733ed50449f351dc33cae" +dependencies = [ + "ahash", + "cached_proc_macro", + "cached_proc_macro_types", + "hashbrown 0.14.5", + "once_cell", + "thiserror 1.0.69", + "web-time", +] + +[[package]] +name = "cached_proc_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f42a145ed2d10dce2191e1dcf30cfccfea9026660e143662ba5eec4017d5daa" +dependencies = [ + "darling 0.20.11", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "cached_proc_macro_types" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ade8366b8bd5ba243f0a58f036cc0ca8a2f069cff1a2351ef1cac6b083e16fc0" + +[[package]] +name = "cc" +version = "1.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54413ede23c2daf518f35156dfde027feb2374004d63bd497f983c8db9c0e313" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "chacha20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "rand_core 0.10.1", + "zeroize", +] + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "num-traits", + "serde", + "windows-link", +] + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common 0.1.7", + "inout", +] + +[[package]] +name = "cipherstash-config" +version = "0.42.3" +dependencies = [ + "bitflags", + "serde", + "serde_json", + "thiserror 1.0.69", +] + +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + +[[package]] +name = "cmov" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" + +[[package]] +name = "const-hex" +version = "1.19.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e59eef12462b0f9b0a3620219be5d639afd79fe39dff0a42c3997061f9298b4" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "proptest", + "serde_core", +] + +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher", +] + +[[package]] +name = "cts-common" +version = "0.42.3" +dependencies = [ + "arrayvec", + "base32", + "cached", + "chrono", + "derive_more", + "either", + "getrandom 0.4.3", + "miette", + "nom", + "regex", + "serde", + "serde_json", + "thiserror 1.0.69", + "tracing", + "url", + "utoipa", + "uuid", + "vitaminc", +] + +[[package]] +name = "ctutils" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" +dependencies = [ + "cmov", +] + +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core 0.20.11", + "darling_macro 0.20.11", +] + +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core 0.23.0", + "darling_macro 0.23.0", +] + +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.119", +] + +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.119", +] + +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core 0.20.11", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core 0.23.0", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.119", + "unicode-xid", +] + +[[package]] +name = "deunicode" +version = "1.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abd57806937c9cc163efc8ea3910e00a62e2aeb0b8119f1793a978088f8f6b04" + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer 0.10.4", + "crypto-common 0.1.7", +] + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.1", + "crypto-common 0.2.2", + "ctutils", +] + +[[package]] +name = "dirs" +version = "4.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca3aa72a6f96ea37bbc5aa912f6788242832f75369bdfdadcb0e38423f100059" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b1d1d91c932ef41c0f2663aa8b0ca0342d444d842c06914aa0a7e352d0bada6" +dependencies = [ + "libc", + "redox_users", + "winapi", +] + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "dummy" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1cac124e13ae9aa56acc4241f8c8207501d93afdd8d8e62f0c1f2e12f6508c65" +dependencies = [ + "darling 0.20.11", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "either" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" +dependencies = [ + "serde", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fake" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d391ba4af7f1d93f01fcf7b2f29e2bc9348e109dfdbf4dcbdc51dfa38dab0b6" +dependencies = [ + "deunicode", + "dummy", + "rand 0.8.8", + "uuid", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "find-msvc-tools" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25905e51abafe4dcea6c15fec58c57b601cdbd0ee53d22ea1d3016c587d39b" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + +[[package]] +name = "funty" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "gethostname" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc3655aa6818d65bc620d6911f05aa7b6aeb596291e1e9f79e52df85583d1e30" +dependencies = [ + "rustix 0.38.44", + "windows-targets", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 6.0.0", + "rand_core 0.10.1", + "wasm-bindgen", +] + +[[package]] +name = "ghash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" +dependencies = [ + "opaque-debug", + "polyval", +] + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash", + "allocator-api2", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "hybrid-array" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17" +dependencies = [ + "typenum", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" + +[[package]] +name = "icu_properties" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" + +[[package]] +name = "icu_provider" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "include_dir" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "923d117408f1e49d914f1a379a309cffe4f18c05cf4e3d12e613a15fc81bd0dd" +dependencies = [ + "include_dir_macros", +] + +[[package]] +name = "include_dir_macros" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cab85a7ed0bd5f0e76d93846e0147172bed2e2d3f859bcc33a8d9699cad1a75" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + +[[package]] +name = "is-docker" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3" +dependencies = [ + "once_cell", +] + +[[package]] +name = "is-wsl" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5" +dependencies = [ + "is-docker", + "once_cell", +] + +[[package]] +name = "itertools" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jobserver" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +dependencies = [ + "getrandom 0.4.3", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce57d20d1ea864ce2ac172ab472d409214f4fd359f0b2a2775abdf522e2af99e" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "jsonwebtoken" +version = "10.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eba32bfb4ffdeaca3e34431072faf01745c9b26d25504aa7a6cf5684334fc4fc" +dependencies = [ + "aws-lc-rs", + "base64", + "getrandom 0.2.17", + "js-sys", + "pem", + "serde", + "serde_json", + "signature", + "simple_asn1", + "zeroize", +] + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "libredox" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61ff90caf6077a803a240f62fdbe88645a890bbca49ef8174c3cb0404362171d" +dependencies = [ + "libc", +] + +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest 0.10.7", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "miette" +version = "7.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f98efec8807c63c752b5bd61f862c165c115b0a35685bdcfd9238c7aeb592b7" +dependencies = [ + "cfg-if", + "miette-derive", + "unicode-width", +] + +[[package]] +name = "miette-derive" +version = "7.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db5b29714e950dbb20d5e6f74f9dcec4edbcc1067bb7f8ed198c097b8c1a818b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "mio" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "mutants" +version = "0.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "add0ac067452ff1aca8c5002111bd6b1c895baee6e45fcbc44e0193aea17be56" + +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + +[[package]] +name = "num-bigint" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-integer" +version = "0.1.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + +[[package]] +name = "open" +version = "5.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa576c76302b7b808eecc68061e67336c47833ef9d22caa74dda10fa9675eebc" +dependencies = [ + "is-wsl", + "libc", +] + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64", + "serde_core", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkg-config" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + +[[package]] +name = "polyval" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "opaque-debug", + "universal-hash", +] + +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro-error-attr2" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "proc-macro-error-attr3" +version = "3.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e564d14133360e1ae169ffde5da25881b5fa47261665b8e5713c212c27799da" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "proc-macro-error2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802" +dependencies = [ + "proc-macro-error-attr2", + "proc-macro2", + "quote", +] + +[[package]] +name = "proc-macro-error3" +version = "3.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f0d4471b3436c22106b21913b1dda531558918ae9b7ec55d58aa84b43552233" +dependencies = [ + "proc-macro-error-attr3", + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "proptest" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744" +dependencies = [ + "bitflags", + "num-traits", + "rand 0.9.5", + "rand_chacha 0.9.0", + "rand_xorshift", + "regex-syntax", + "unarray", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "radium" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" + +[[package]] +name = "rand" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + +[[package]] +name = "rand" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core 0.10.1", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_xorshift" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a" +dependencies = [ + "rand_core 0.9.5", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 1.0.69", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "rmp" +version = "0.8.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ba8be72d372b2c9b35542551678538b562e7cf86c3315773cae48dfbfe7790c" +dependencies = [ + "num-traits", +] + +[[package]] +name = "rmp-serde" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f81bee8c8ef9b577d1681a70ebbc962c232461e397b22c208c43c04b67a155" +dependencies = [ + "rmp", + "serde", +] + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.59.0", +] + +[[package]] +name = "rustix" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys 0.12.1", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_bytes" +version = "0.11.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sha1_smol" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbfa15b3dddfee50a0fff136974b3e1bde555604ba463834a7eb7deb6417705d" + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "rand_core 0.6.4", +] + +[[package]] +name = "simple_asn1" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d" +dependencies = [ + "num-bigint", + "num-traits", + "thiserror 2.0.20", + "time", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba467056f1b547ed52077911161fc86985becbc60e8e1857c8a144dab0def891" + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "stack-auth" +version = "0.42.3" +dependencies = [ + "aquamarine", + "base64", + "cts-common", + "jsonwebtoken", + "miette", + "open", + "serde", + "serde_json", + "serde_urlencoded", + "stack-profile", + "thiserror 1.0.69", + "tokio", + "tracing", + "url", + "uuid", + "vitaminc", + "vitaminc-protected", + "web-time", + "zeroize", + "zerokms-protocol", +] + +[[package]] +name = "stack-auth-guest" +version = "0.0.0" +dependencies = [ + "serde", + "serde_json", + "stack-auth", + "stack-guest-abi", + "stack-profile", + "tempfile", + "vitaminc-aead-value", + "vitaminc-protected", +] + +[[package]] +name = "stack-guest-abi" +version = "0.0.0" +dependencies = [ + "thiserror 1.0.69", + "vitaminc-protected", + "zeroize", +] + +[[package]] +name = "stack-profile" +version = "0.42.3" +dependencies = [ + "dirs", + "gethostname", + "serde", + "serde_json", + "thiserror 1.0.69", + "uuid", +] + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "synstructure" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "tap" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix 1.1.5", + "windows-sys 0.61.2", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +dependencies = [ + "thiserror-impl 2.0.20", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "time" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tokio" +version = "1.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unarray" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" + +[[package]] +name = "unicode-ident" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common 0.1.7", + "subtle", +] + +[[package]] +name = "untrusted" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", + "serde_derive", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utoipa" +version = "5.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8bde15df68e80b16c7d16b9616e80770ad158988daa56a27dccd1e55558b0160" +dependencies = [ + "indexmap", + "serde", + "serde_json", + "utoipa-gen", +] + +[[package]] +name = "utoipa-gen" +version = "5.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba0b99ee52df3028635d93840c797102da61f8a7bb3cf751032455895b52ef8" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "url", + "uuid", +] + +[[package]] +name = "uuid" +version = "1.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ef6dac1e96601b4fb3acccccff2139741fcb757cb9a36089bf5be91cfb285ce" +dependencies = [ + "atomic", + "getrandom 0.4.3", + "js-sys", + "md-5", + "serde_core", + "sha1_smol", + "wasm-bindgen", +] + +[[package]] +name = "validator" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43fb22e1a008ece370ce08a3e9e4447a910e92621bb49b85d6e48a45397e7cfa" +dependencies = [ + "idna", + "once_cell", + "regex", + "serde", + "serde_derive", + "serde_json", + "url", + "validator_derive", +] + +[[package]] +name = "validator_derive" +version = "0.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240e4b81c20a1d6d50d1d7265c658dfbd204e8b9ac4d80f3c931f39462196335" +dependencies = [ + "darling 0.23.0", + "proc-macro-error3", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "vitaminc" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f371b877e1586acb8ffc9efd0bd6a7c7ff7d8c4b1529c1f46d34b1df3478699" +dependencies = [ + "vitaminc-aead", + "vitaminc-context", + "vitaminc-encrypt", + "vitaminc-protected", + "vitaminc-random", + "vitaminc-traits", +] + +[[package]] +name = "vitaminc-aead" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0238d5b77821fcd46d2f72b082532209d625892c69e40008e403b709a1dd0fa" +dependencies = [ + "bytes", + "serde", + "vitaminc-aead-derive", + "vitaminc-context", + "vitaminc-protected", + "vitaminc-random", + "zeroize", +] + +[[package]] +name = "vitaminc-aead-derive" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5a81524f1e096bc45a1e7901186546bc0cf5545a9a86e978581a7146c54a5c7" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "vitaminc-aead-value" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b63326e8bf21f695080c50d8d849324fa92e4cf6ce7257bef198b1ff2eeea0d" +dependencies = [ + "vitaminc-aead", + "vitaminc-protected", + "zeroize", +] + +[[package]] +name = "vitaminc-context" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e7b3e0602ad490039680222ba23217264eab0f400495c072ec11e1a2235b304" +dependencies = [ + "mutants", + "vitaminc-protected", +] + +[[package]] +name = "vitaminc-encrypt" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b969f689aebd40637a6ba9243618a62057ee9c76a8b70c6661c9b0c28ba20275" +dependencies = [ + "aes-gcm", + "aws-lc-rs", + "vitaminc-aead", + "vitaminc-protected", + "vitaminc-random", + "zeroize", +] + +[[package]] +name = "vitaminc-protected" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a13b616fa4e7093f4970dacd6f44ca76a6929bee1c72612134353a69bb52c62" +dependencies = [ + "bitvec", + "digest 0.11.3", + "libc", + "serde", + "serde_bytes", + "subtle", + "thiserror 2.0.20", + "vitaminc-protected-derive", + "zeroize", +] + +[[package]] +name = "vitaminc-protected-derive" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47c3f8db2eff8108d078a77125f20974448b9a66950bdc120e35b0b53eda4c98" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "vitaminc-random" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8320b63fad9560026b036cb6eb9b85efb1d666af52862af923a2c75972c4282" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand 0.10.3", + "thiserror 2.0.20", + "vitaminc-protected", + "vitaminc-random-derives", + "zeroize", +] + +[[package]] +name = "vitaminc-random-derives" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdbc48a9e3893406046f51f51c3d2bb1dffe70d6ca3b19920ca12d231ed12b68" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "vitaminc-traits" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c53613363efb0c47aba19ea2bfe0142cba8b2d0fe1bc27d43b0ea3390f6efe7" +dependencies = [ + "anyhow", + "bytes", + "rmp-serde", + "serde", + "thiserror 2.0.20", + "vitaminc-protected", + "vitaminc-random", + "zeroize", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aecb87a33d3b0c5e3b7aa46336eaf486cffafbd281b195e4c8b80d50df2351bf" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a690d511e3c1a8b3a55e33511e3c2c00c78415cd23650f32b808627f5696b9ed" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "411e4887f0071ef2d2164a9d5fdf2d20efbef78fccd3a78b0c10a1dc5295e48a" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.6", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81941cd78d0c92026c33e5e01312845a4cb1e9af3407f9134b100dd03144103e" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[package]] +name = "wyz" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed" +dependencies = [ + "tap", +] + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33811428bee40dbceb6d545e95754741d17a6aef9a4849f0fd62e2ba4f412a78" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d35102a9f36d089ccae9e4c6802bc118be4487b80aaffc0ab4e0cf5ce92d2873" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "146c01f5ab44258da43cf276c74a2763db2ff3969c9c652c3f2de07041d0b2bc" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zerokms-protocol" +version = "0.12.30" +dependencies = [ + "base64", + "cipherstash-config", + "const-hex", + "cts-common", + "fake", + "getrandom 0.2.17", + "opaque-debug", + "rand 0.8.8", + "serde", + "static_assertions", + "thiserror 1.0.69", + "utoipa", + "uuid", + "validator", + "zeroize", +] + +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/languages/golang/stackauth/guest/Cargo.toml b/languages/golang/stackauth/guest/Cargo.toml new file mode 100644 index 000000000..bc6f46ad7 --- /dev/null +++ b/languages/golang/stackauth/guest/Cargo.toml @@ -0,0 +1,57 @@ +# The credential guest: `stack-profile` (and, once the auth half lands, +# `stack-auth`'s strategies) under WASI/wazero, embedded by the Go package +# `stackauth` one directory up. ADR-0005 in packages/stack-encrypt/docs/adr. +# +# A second module rather than the crypto guest widened: this one is given a +# directory of credentials, and that one handles plaintext and data keys. +# Deliberately a standalone workspace, like the crypto guest: it targets +# wasm32-wasip1 and is consumed as a .wasm artifact, so its wasm-only +# profile and target must not leak into workspace builds. +# +# Build: mise run wasm:auth-guest:build (or: cargo build --target wasm32-wasip1 --release) +[package] +name = "stack-auth-guest" +description = "WASI guest module exposing the developer profile (stack-profile) to non-Rust hosts (Go/wazero)" +version = "0.0.0" +edition = "2021" +publish = false + +[workspace] + +[lib] +# cdylib: the .wasm guest module. rlib: lets the ops/status modules +# unit-test natively (`cargo test` here, no wasm toolchain needed). +crate-type = ["cdylib", "rlib"] + +[dependencies] +# The profile directory's API. It builds for wasm32-wasip1 (CIP-4114): the +# hostname and the process id are native-only there, and creating a device +# identity is CLI territory. +stack-profile = { path = "../../../../packages/stack-profile" } +# `Token`, the typed shape of `auth.json`, with default features off: no +# reqwest, no native TLS. The strategies arrive with the auth half. +stack-auth = { path = "../../../../packages/stack-auth", default-features = false } +# The ABI every guest under bindings/go shares: the allocator and buffer +# registry (and with them the `se_alloc`/`se_dealloc` exports), the +# packed-result helpers and the status table. This crate defines only the +# exports that are its own. +stack-guest-abi = { path = "../../../../packages/stack-guest-abi" } + +# The FFI codec for the structured results (a list of workspace ids, the +# fields of a token): the one codec every binding carries. Same vitaminc +# version the crypto guest builds against. +vitaminc-aead-value = "0.5.0" +vitaminc-protected = "0.5.0" + +serde = { version = "1", features = ["derive"] } + +[dev-dependencies] +tempfile = "3" +serde_json = "1" + +[profile.release] +# Smaller .wasm; the guest is IO-bound on the FFI copy and the file reads, +# not on codegen. +opt-level = "s" +lto = true +strip = true diff --git a/languages/golang/stackauth/guest/src/abi.rs b/languages/golang/stackauth/guest/src/abi.rs new file mode 100644 index 000000000..14bfbd48c --- /dev/null +++ b/languages/golang/stackauth/guest/src/abi.rs @@ -0,0 +1,192 @@ +//! This guest's wasm export surface, under the `sa_` prefix, over the +//! conventions every guest shares (`stack_guest_abi::abi`: `se_alloc` / +//! `se_dealloc`, the buffer registry, the packed `u64` result encoding, the +//! hostile-input validation of every `(ptr, len)` pair). +//! +//! # The mount +//! +//! The host mounts the profile root at [`GUEST_ROOT`] and names a store's +//! directory on every call: the root itself, or a workspace directory +//! [`sa_workspace_dir`] returned. There is no init export and no handle: +//! the mount is the whole configuration, and a store is a directory. +//! +//! # Exports +//! +//! Every export takes UTF-8 strings as `(ptr, len)` pairs and returns a +//! packed result: a string, an FFI-codec value, or an empty buffer for an +//! operation with nothing to return. See [`crate::ops`] for each one's +//! meaning and encoding; the export is the validated, unwinding-safe +//! wrapper. [`sa_shutdown`] is the one lifetime call: it wipes every +//! buffer the registry still holds, so a host that tears the instance down +//! without releasing an output — a token, a key — leaves nothing behind. +//! +//! Wasm modules are single-threaded; the host must serialize calls into one +//! instance. + +use std::panic::{catch_unwind, AssertUnwindSafe}; + +use stack_guest_abi::abi::{err_status, input, ok_buffer}; +use stack_guest_abi::buffers; +use stack_guest_abi::status::STATUS_INTERNAL; + +use crate::ops; + +/// Where the host mounts the profile root. The Go side mounts exactly one +/// directory here and constructs every store directory under it; nothing +/// in this module composes a path from anything else. +pub const GUEST_ROOT: &str = "/profile"; + +/// An operation over one validated input. +type Op1 = fn(&[u8]) -> Result<Vec<u8>, u32>; +/// An operation over two validated inputs. +type Op2 = fn(&[u8], &[u8]) -> Result<Vec<u8>, u32>; + +/// Run a two-input operation as an export: validate both pairs, run, +/// pack. A panic is `STATUS_INTERNAL` (wasm32-wasip1 aborts on panic; the +/// catch is belt-and-braces for an unwinding build). +fn export2(a_ptr: *const u8, a_len: u32, b_ptr: *const u8, b_len: u32, op: Op2) -> u64 { + catch_unwind(AssertUnwindSafe(|| { + // SAFETY: host-owned ranges the export was handed; the borrows end + // when `op` returns, inside the call, and nothing here writes to + // linear memory while they are live. + let a = unsafe { input(a_ptr, a_len)? }; + let b = unsafe { input(b_ptr, b_len)? }; + op(a, b) + })) + .unwrap_or(Err(STATUS_INTERNAL)) + .map_or_else(err_status, ok_buffer) +} + +/// [`export2`] for a one-input operation. +fn export1(a_ptr: *const u8, a_len: u32, op: Op1) -> u64 { + // SAFETY: as in `export2`. + catch_unwind(AssertUnwindSafe(|| op(unsafe { input(a_ptr, a_len)? }))) + .unwrap_or(Err(STATUS_INTERNAL)) + .map_or_else(err_status, ok_buffer) +} + +/// The current workspace id of the store at `dir`. See +/// [`ops::current_workspace`]. +/// +/// # Safety +/// +/// `dir_ptr`/`dir_len` should name a buffer the host wrote via `se_alloc`; +/// the range is bounds-checked against linear memory (a bad pair returns +/// `STATUS_ENCODING` instead of faulting). +#[no_mangle] +pub unsafe extern "C" fn sa_current_workspace(dir_ptr: *const u8, dir_len: u32) -> u64 { + export1(dir_ptr, dir_len, ops::current_workspace) +} + +/// Set the current workspace of the store at `dir`. See +/// [`ops::set_current_workspace`]. +/// +/// # Safety +/// +/// As for [`sa_current_workspace`]. +#[no_mangle] +pub unsafe extern "C" fn sa_set_current_workspace( + dir_ptr: *const u8, + dir_len: u32, + id_ptr: *const u8, + id_len: u32, +) -> u64 { + export2(dir_ptr, dir_len, id_ptr, id_len, ops::set_current_workspace) +} + +/// Clear the current workspace of the store at `dir`. See +/// [`ops::clear_current_workspace`]. +/// +/// # Safety +/// +/// As for [`sa_current_workspace`]. +#[no_mangle] +pub unsafe extern "C" fn sa_clear_current_workspace(dir_ptr: *const u8, dir_len: u32) -> u64 { + export1(dir_ptr, dir_len, ops::clear_current_workspace) +} + +/// The workspace ids under the store at `dir`. See +/// [`ops::list_workspaces`]. +/// +/// # Safety +/// +/// As for [`sa_current_workspace`]. +#[no_mangle] +pub unsafe extern "C" fn sa_list_workspaces(dir_ptr: *const u8, dir_len: u32) -> u64 { + export1(dir_ptr, dir_len, ops::list_workspaces) +} + +/// The directory of the workspace store `id` under the store at `dir`. +/// See [`ops::workspace_dir`]. +/// +/// # Safety +/// +/// As for [`sa_current_workspace`]. +#[no_mangle] +pub unsafe extern "C" fn sa_workspace_dir( + dir_ptr: *const u8, + dir_len: u32, + id_ptr: *const u8, + id_len: u32, +) -> u64 { + export2(dir_ptr, dir_len, id_ptr, id_len, ops::workspace_dir) +} + +/// The lock file path for `filename` in the store at `dir`. See +/// [`ops::lock_path`]. +/// +/// # Safety +/// +/// As for [`sa_current_workspace`]. +#[no_mangle] +pub unsafe extern "C" fn sa_lock_path( + dir_ptr: *const u8, + dir_len: u32, + name_ptr: *const u8, + name_len: u32, +) -> u64 { + export2(dir_ptr, dir_len, name_ptr, name_len, ops::lock_path) +} + +/// `secretkey.json` in the store at `dir`. See [`ops::secret_key`]. The +/// output carries the client key; the host copies it into its opaque +/// type and releases the buffer at once. +/// +/// # Safety +/// +/// As for [`sa_current_workspace`]. +#[no_mangle] +pub unsafe extern "C" fn sa_secret_key(dir_ptr: *const u8, dir_len: u32) -> u64 { + export1(dir_ptr, dir_len, ops::secret_key) +} + +/// `auth.json` in the store at `dir`. See [`ops::token`]. The output +/// carries the access token; the host releases the buffer once it has +/// read it. +/// +/// # Safety +/// +/// As for [`sa_current_workspace`]. +#[no_mangle] +pub unsafe extern "C" fn sa_token(dir_ptr: *const u8, dir_len: u32) -> u64 { + export1(dir_ptr, dir_len, ops::token) +} + +/// `device.json` in the store at `dir`, read-only. See +/// [`ops::device_identity`]. +/// +/// # Safety +/// +/// As for [`sa_current_workspace`]. +#[no_mangle] +pub unsafe extern "C" fn sa_device_identity(dir_ptr: *const u8, dir_len: u32) -> u64 { + export1(dir_ptr, dir_len, ops::device_identity) +} + +/// Tear the instance down: wipe every buffer the registry still holds. +/// This guest keeps no other state. Idempotent; `se_alloc` and +/// `se_dealloc` keep working so the host can still free what it holds. +#[no_mangle] +pub extern "C" fn sa_shutdown() { + let _ = catch_unwind(AssertUnwindSafe(buffers::wipe_all)); +} diff --git a/languages/golang/stackauth/guest/src/lib.rs b/languages/golang/stackauth/guest/src/lib.rs new file mode 100644 index 000000000..05ebe6a5b --- /dev/null +++ b/languages/golang/stackauth/guest/src/lib.rs @@ -0,0 +1,102 @@ +// Security lints — the block `stack-encrypt` and `stack-auth` carry, minus +// `deny(unsafe_code)`: the export surface (`abi`) is `extern "C"` over raw +// pointers by nature. Every `unsafe` block is confined to that wasm32-only +// module and documented at the site; `unsafe_op_in_unsafe_fn` keeps each +// one explicit. The allocator, the buffer registry and the input +// validation are `stack-guest-abi`'s. +#![deny(unsafe_op_in_unsafe_fn)] +#![warn(clippy::unwrap_used)] +#![warn(clippy::expect_used)] +#![warn(clippy::panic)] +// Prevent mem::forget from bypassing ZeroizeOnDrop +#![warn(clippy::mem_forget)] +// Prevent accidental data leaks via output +#![warn(clippy::print_stdout)] +#![warn(clippy::print_stderr)] +#![warn(clippy::dbg_macro)] +// Code quality +#![warn(unreachable_pub)] +#![warn(unused_results)] +#![warn(clippy::todo)] +#![warn(clippy::unimplemented)] +// Relax in tests +#![cfg_attr(test, allow(clippy::unwrap_used))] +#![cfg_attr(test, allow(clippy::expect_used))] +#![cfg_attr(test, allow(clippy::panic))] +#![cfg_attr(test, allow(unused_results))] +// The crate's target is wasm32; `abi` only exists there, so on a native doc +// build its intra-doc links have nothing to resolve to. The wasm32 doc +// build (`mise run wasm:auth-guest:test`) is where links are enforced. +#![cfg_attr(not(target_arch = "wasm32"), allow(rustdoc::broken_intra_doc_links))] +//! # The credential guest +//! +//! WASI guest module exposing the developer profile — +//! [`stack_profile::ProfileStore`] over one mounted directory — to non-Rust +//! hosts. Built for `wasm32-wasip1` and embedded by the Go package +//! `stackauth` in the parent directory (wazero host, `CGO_ENABLED=0`). +//! ADR-0005 in `packages/stack-encrypt/docs/adr` is the decision this +//! module implements; `docs/plans/stack-encrypt-go-bindings.md` sequences +//! it. +//! +//! # What the host gives it, and what it does not +//! +//! wazero mounts exactly one directory into this module — the profile +//! root, at [`abi`]'s fixed guest path — and gives it **no environment**. +//! The Go side resolves `CS_CONFIG_PATH` and the home directory the way +//! [`ProfileStore::resolve`](stack_profile::ProfileStore::resolve) does, +//! mounts the result, and names the store's directory explicitly on every +//! call. Nothing here reads a variable, and nothing here can name a path +//! the mount does not contain: every path is built by `stack-profile` from +//! a store directory and a validated filename or workspace id. +//! +//! The crypto guest (`bindings/go/stackencrypt/guest`) is unchanged by +//! this one existing. It has no filesystem and no environment, and it +//! handles plaintext and data keys; this module can reach one directory of +//! credentials. The split is what makes both of those true at once. +//! +//! # What crosses the boundary +//! +//! Inputs are UTF-8 strings: the store directory (a guest path under the +//! mount), a workspace id, a filename. Outputs are either a UTF-8 string +//! (an id, a path) or a value in vitaminc's FFI codec +//! (`vitaminc_aead_value::transport`): a list of ids, or the fields of +//! `secretkey.json`, `auth.json` or `device.json` as an object. The client +//! key in `secretkey.json` crosses as the text the file holds; the Go side +//! wraps it in its opaque `ClientKey` and wipes its transport copy. A +//! refresh token never crosses: the profile half of this guest hands out +//! the access token and its expiry, and refreshing is the auth half's +//! (CIP-4054), which runs inside this module. +//! +//! # Locking +//! +//! WASI preview 1 has no file locking, so this module takes none. The Go +//! side takes the same lock the Rust CLI takes, on the path +//! [`ProfileStore::lock_path`](stack_profile::ProfileStore::lock_path) +//! names (exported here), around the refresh export once it exists — and +//! never composes a profile path itself. +//! +//! # Not faked +//! +//! The process id and the hostname are compiled out of `stack-profile` on +//! wasm32, not stubbed: the creating half of `DeviceIdentity` is +//! native-only, and this module exposes only its read. A guest that could +//! mint an identity named after a made-up hostname would be worse than one +//! that cannot. +//! +//! Split into: +//! +//! - [`ops`], [`status`] — everything that is pure logic over a +//! `ProfileStore` and bytes. Compiles and unit-tests on the native host +//! target (`cargo test` here) against a temporary directory. +//! - [`abi`] (wasm32 only) — the export surface, over the conventions +//! every guest shares (`stack_guest_abi`). + +pub mod ops; +pub mod status; + +// The ABI's packed u64 results embed 32-bit pointers and its bounds checks +// read the wasm linear-memory size, so this module only exists on wasm32. +// A native build therefore exports no sa_* symbols at all — failing loudly +// at symbol lookup — instead of a silently wrong ABI. +#[cfg(target_arch = "wasm32")] +pub mod abi; diff --git a/languages/golang/stackauth/guest/src/ops.rs b/languages/golang/stackauth/guest/src/ops.rs new file mode 100644 index 000000000..6f1d78458 --- /dev/null +++ b/languages/golang/stackauth/guest/src/ops.rs @@ -0,0 +1,412 @@ +//! The guest's operations, written against [`stack_profile::ProfileStore`] +//! over a directory the caller names, so they compile — and their tests run +//! — on the native host target against a temporary directory. The +//! wasm32-only [`crate::abi`] module wires them to the packed ABI; nothing +//! in here knows about linear memory. +//! +//! Every operation takes the store's directory as bytes: on wasm32 that is +//! a guest path under the mount, and the Go side names it on every call +//! rather than the guest keeping a handle. A workspace-scoped store is the +//! same thing with a longer directory, which [`workspace_dir`] produces +//! through the crate's own validation. +//! +//! # Errors +//! +//! Every function reports a [`crate::status`] code, never a message. A +//! directory, id or filename that is not UTF-8, or an empty directory, is +//! [`STATUS_ENCODING`]; everything else is `stack-profile`'s verdict +//! ([`status_for_profile`]). +//! +//! # Copies +//! +//! `stack-profile` reads a file into a `String` and deserializes from it, +//! and this module builds the result out of what it deserialized. The +//! file's contents and the intermediate values are freed when they drop, +//! not wiped; the codec buffer that crosses to the host *is* wiped, by the +//! registry, once the host has taken it. What this leaves in freed guest +//! memory lives in linear memory the host locks, excludes from dumps and +//! wipes on release, and nowhere else. + +use serde::Deserialize; +use stack_auth::Token; +use stack_profile::{DeviceIdentity, ProfileStore}; +use vitaminc_aead_value::{transport as codec, FfiValue}; + +use crate::status::{status_for_profile, STATUS_ENCODING, STATUS_INTERNAL}; + +/// The file `secretkey.json`, as `stack-auth`'s device client writes it +/// and `stack-kms`'s `SecretKey` reads it: the ZeroKMS client id and the +/// client key material, standard padded base64. Read here as text on both +/// sides — the key crosses to the host in the form the file holds, which +/// is one of the two forms `stackencrypt`'s config takes. +#[derive(Deserialize)] +struct SecretKeyFile { + client_id: String, + client_key: String, +} + +/// The filename `stack-kms`'s `SecretKey` declares. Spelled here rather +/// than taken from that crate, which this guest does not build: its +/// `ProfileData` impl is where the name lives, and this constant is pinned +/// to it by the Go tests that write the file where `stash auth login` does. +const SECRET_KEY_FILENAME: &str = "secretkey.json"; + +/// The filename `stack-auth`'s `Token` declares. Its `ProfileData` impl is +/// native-only (the crate's `stack-profile` dependency is, for the sake of +/// its browser build), so the name is spelled here and pinned to the +/// crate's by a native test. +const AUTH_FILENAME: &str = "auth.json"; + +/// The store at `dir`. The directory is the caller's to name; an empty or +/// non-UTF-8 one is refused before any path is built. +pub fn store(dir: &[u8]) -> Result<ProfileStore, u32> { + let dir = text(dir)?; + if dir.is_empty() { + return Err(STATUS_ENCODING); + } + Ok(ProfileStore::new(dir)) +} + +fn text(bytes: &[u8]) -> Result<&str, u32> { + std::str::from_utf8(bytes).map_err(|_| STATUS_ENCODING) +} + +fn string(value: impl Into<String>) -> FfiValue { + FfiValue::String(value.into().into()) +} + +fn optional(value: Option<&str>) -> FfiValue { + value.map_or(FfiValue::Null, string) +} + +fn encode(value: FfiValue) -> Result<Vec<u8>, u32> { + let mut out = Vec::new(); + codec::encode_value(value, &mut out).map_err(|_| STATUS_INTERNAL)?; + Ok(out) +} + +/// The current workspace id, as text. +pub fn current_workspace(dir: &[u8]) -> Result<Vec<u8>, u32> { + store(dir)? + .current_workspace() + .map(String::into_bytes) + .map_err(|e| status_for_profile(&e)) +} + +/// Set the current workspace. The workspace must already have a directory; +/// a login creates one, this guest never does. Empty output. +pub fn set_current_workspace(dir: &[u8], id: &[u8]) -> Result<Vec<u8>, u32> { + store(dir)? + .set_current_workspace(text(id)?) + .map(|()| Vec::new()) + .map_err(|e| status_for_profile(&e)) +} + +/// Remove the current workspace selection. Empty output; nothing to remove +/// is not an error. +pub fn clear_current_workspace(dir: &[u8]) -> Result<Vec<u8>, u32> { + store(dir)? + .clear_current_workspace() + .map(|()| Vec::new()) + .map_err(|e| status_for_profile(&e)) +} + +/// The workspace ids with profile data on disk, sorted, as a codec array +/// of strings. +pub fn list_workspaces(dir: &[u8]) -> Result<Vec<u8>, u32> { + let ids = store(dir)? + .list_workspaces() + .map_err(|e| status_for_profile(&e))?; + encode(FfiValue::Array(ids.into_iter().map(string).collect())) +} + +/// The directory of the store scoped to workspace `id`, as text: what +/// [`ProfileStore::workspace_store`] would root a store at, after the +/// crate has validated the id. The host names this directory on the calls +/// it scopes to that workspace. +pub fn workspace_dir(dir: &[u8], id: &[u8]) -> Result<Vec<u8>, u32> { + let scoped = store(dir)? + .workspace_store(text(id)?) + .map_err(|e| status_for_profile(&e))?; + path_bytes(scoped.dir()) +} + +/// The path of the lock file [`ProfileStore::lock_exclusive`] would take +/// for `filename`, as text. Nothing is created or locked: the host holds +/// the lock, on this path, because this guest cannot. +pub fn lock_path(dir: &[u8], filename: &[u8]) -> Result<Vec<u8>, u32> { + let path = store(dir)? + .lock_path(text(filename)?) + .map_err(|e| status_for_profile(&e))?; + path_bytes(&path) +} + +fn path_bytes(path: &std::path::Path) -> Result<Vec<u8>, u32> { + // A path the crate built from UTF-8 parts is UTF-8; anything else is + // this module's bug, not the caller's. + path.to_str() + .map(|s| s.as_bytes().to_vec()) + .ok_or(STATUS_INTERNAL) +} + +/// `secretkey.json` in this store, as a codec object `{client_id, +/// client_key}` of two strings: the client id, and the key material as the +/// file holds it. +pub fn secret_key(dir: &[u8]) -> Result<Vec<u8>, u32> { + let file: SecretKeyFile = store(dir)? + .load(SECRET_KEY_FILENAME) + .map_err(|e| status_for_profile(&e))?; + encode(FfiValue::Object(vec![ + ("client_id".to_string(), string(file.client_id)), + ("client_key".to_string(), string(file.client_key)), + ])) +} + +/// `auth.json` in this store, read as [`stack_auth::Token`] so its shape is +/// the crate's, as a codec object: `access_token` and `token_type` +/// (strings), `expires_at` (seconds since the epoch, `u64`), and `region`, +/// `client_id` and `device_instance_id` (each a string or null). The +/// refresh token is not in it, on purpose: the host presents the access +/// token and refuses it at expiry; refreshing is this guest's, once the +/// auth half lands. +pub fn token(dir: &[u8]) -> Result<Vec<u8>, u32> { + let token: Token = store(dir)? + .load(AUTH_FILENAME) + .map_err(|e| status_for_profile(&e))?; + encode(FfiValue::Object(vec![ + ( + "access_token".to_string(), + string(token.access_token().as_str()), + ), + ("token_type".to_string(), string(token.token_type())), + ( + "expires_at".to_string(), + FfiValue::UInt64(token.expires_at()), + ), + ("region".to_string(), optional(token.region())), + ("client_id".to_string(), optional(token.client_id())), + ( + "device_instance_id".to_string(), + optional(token.device_instance_id()), + ), + ])) +} + +/// `device.json` in this store, read-only, as a codec object +/// `{device_instance_id, device_name}`. Creating one is the CLI's. +pub fn device_identity(dir: &[u8]) -> Result<Vec<u8>, u32> { + let identity = DeviceIdentity::load(&store(dir)?).map_err(|e| status_for_profile(&e))?; + encode(FfiValue::Object(vec![ + ( + "device_instance_id".to_string(), + string(identity.device_instance_id.to_string()), + ), + ("device_name".to_string(), string(identity.device_name)), + ])) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::status::*; + + const WS_A: &str = "AAAAAAAAAAAAAAAA"; + const WS_B: &str = "BBBBBBBBBBBBBBBB"; + + fn dir(t: &tempfile::TempDir) -> Vec<u8> { + t.path().to_str().unwrap().as_bytes().to_vec() + } + + fn decode(bytes: &[u8]) -> FfiValue { + codec::decode_value(&mut codec::Reader::new(bytes)).unwrap() + } + + fn field<'a>(value: &'a FfiValue, key: &str) -> &'a FfiValue { + let FfiValue::Object(entries) = value else { + panic!("not an object"); + }; + entries + .iter() + .find(|(k, _)| k == key) + .map(|(_, v)| v) + .unwrap_or_else(|| panic!("no field {key}")) + } + + fn as_text(value: &FfiValue) -> String { + let FfiValue::String(s) = value else { + panic!("not a string"); + }; + String::from_utf8(s.risky_ref().to_vec()).unwrap() + } + + /// The names this guest spells are the crates' own. + #[test] + fn spelled_filenames_are_the_crates() { + use stack_profile::ProfileData; + assert_eq!(AUTH_FILENAME, <Token as ProfileData>::FILENAME); + assert_eq!( + <DeviceIdentity as ProfileData>::FILENAME, + "device.json", + "the device identity is read through its own impl" + ); + } + + #[test] + fn a_directory_must_be_utf8_and_non_empty() { + assert_eq!(store(b"").unwrap_err(), STATUS_ENCODING); + assert_eq!(store(&[0xff, 0xfe]).unwrap_err(), STATUS_ENCODING); + assert!(store(b"/profile").is_ok()); + } + + #[test] + fn workspace_selection_round_trips_and_lists() { + let t = tempfile::tempdir().unwrap(); + let d = dir(&t); + assert_eq!( + current_workspace(&d).unwrap_err(), + STATUS_PROFILE_NO_CURRENT_WORKSPACE + ); + // A workspace must exist before it can be current: a login makes + // the directory, this guest never does. + assert_eq!( + set_current_workspace(&d, WS_A.as_bytes()).unwrap_err(), + STATUS_PROFILE_WORKSPACE_NOT_FOUND + ); + std::fs::create_dir_all(t.path().join("workspaces").join(WS_B)).unwrap(); + std::fs::create_dir_all(t.path().join("workspaces").join(WS_A)).unwrap(); + assert_eq!(set_current_workspace(&d, WS_A.as_bytes()).unwrap(), b""); + assert_eq!(current_workspace(&d).unwrap(), WS_A.as_bytes()); + + let listed = decode(&list_workspaces(&d).unwrap()); + let FfiValue::Array(items) = listed else { + panic!("not an array"); + }; + let ids: Vec<String> = items.iter().map(as_text).collect(); + assert_eq!(ids, vec![WS_A, WS_B], "sorted, as the crate lists them"); + + assert_eq!(clear_current_workspace(&d).unwrap(), b""); + assert_eq!( + current_workspace(&d).unwrap_err(), + STATUS_PROFILE_NO_CURRENT_WORKSPACE + ); + assert_eq!( + clear_current_workspace(&d).unwrap(), + b"", + "clearing twice is not an error" + ); + } + + #[test] + fn workspace_dir_is_the_crates_and_the_id_is_validated_first() { + let t = tempfile::tempdir().unwrap(); + let d = dir(&t); + let scoped = workspace_dir(&d, WS_A.as_bytes()).unwrap(); + assert_eq!( + std::path::Path::new(std::str::from_utf8(&scoped).unwrap()), + t.path().join("workspaces").join(WS_A) + ); + for bad in ["../escape", "", "aaaaaaaaaaaaaaaa", "0000000000000000"] { + assert_eq!( + workspace_dir(&d, bad.as_bytes()).unwrap_err(), + STATUS_PROFILE_INVALID_WORKSPACE_ID, + "{bad:?}" + ); + } + assert_eq!( + workspace_dir(&d, &[0xff]).unwrap_err(), + STATUS_ENCODING, + "an id that is not UTF-8 is refused before validation" + ); + } + + #[test] + fn lock_path_names_the_sibling_lock_file_and_validates_the_filename() { + let t = tempfile::tempdir().unwrap(); + let d = dir(&t); + let path = lock_path(&d, b"auth.json").unwrap(); + assert_eq!( + std::path::Path::new(std::str::from_utf8(&path).unwrap()), + t.path().join(".auth.json.lock") + ); + assert!( + !t.path().join(".auth.json.lock").exists(), + "nothing is created" + ); + for bad in ["", "../auth.json", "/etc/auth.json", "a/b.json"] { + assert_eq!( + lock_path(&d, bad.as_bytes()).unwrap_err(), + STATUS_PROFILE_INVALID_FILENAME, + "{bad:?}" + ); + } + } + + #[test] + fn typed_reads_return_the_files_fields_and_not_found_otherwise() { + let t = tempfile::tempdir().unwrap(); + let d = dir(&t); + assert_eq!(secret_key(&d).unwrap_err(), STATUS_PROFILE_NOT_FOUND); + assert_eq!(token(&d).unwrap_err(), STATUS_PROFILE_NOT_FOUND); + assert_eq!(device_identity(&d).unwrap_err(), STATUS_PROFILE_NOT_FOUND); + + std::fs::write( + t.path().join("secretkey.json"), + r#"{"client_id":"6a70bd18-99ac-4650-b104-37eec3a15b09","client_key":"AAECAw=="}"#, + ) + .unwrap(); + let key = decode(&secret_key(&d).unwrap()); + assert_eq!( + as_text(field(&key, "client_id")), + "6a70bd18-99ac-4650-b104-37eec3a15b09" + ); + assert_eq!(as_text(field(&key, "client_key")), "AAECAw=="); + + std::fs::write( + t.path().join("auth.json"), + r#"{"access_token":"tok","refresh_token":"refresh","token_type":"Bearer","expires_at":1800000000,"region":"ap-southeast-2"}"#, + ) + .unwrap(); + let tok = decode(&token(&d).unwrap()); + assert_eq!(as_text(field(&tok, "access_token")), "tok"); + assert_eq!(as_text(field(&tok, "token_type")), "Bearer"); + assert!(matches!( + field(&tok, "expires_at"), + FfiValue::UInt64(1_800_000_000) + )); + assert_eq!(as_text(field(&tok, "region")), "ap-southeast-2"); + assert!(matches!(field(&tok, "client_id"), FfiValue::Null)); + let FfiValue::Object(entries) = tok else { + panic!("not an object"); + }; + assert!( + entries.iter().all(|(k, _)| k != "refresh_token"), + "the refresh token never crosses to the host" + ); + + std::fs::write( + t.path().join("device.json"), + r#"{"device_instance_id":"0f4a4fd7-4a1a-4c5e-9d3e-7a4c8e3a9c11","device_name":"laptop"}"#, + ) + .unwrap(); + let identity = decode(&device_identity(&d).unwrap()); + assert_eq!( + as_text(field(&identity, "device_instance_id")), + "0f4a4fd7-4a1a-4c5e-9d3e-7a4c8e3a9c11" + ); + assert_eq!(as_text(field(&identity, "device_name")), "laptop"); + } + + #[test] + fn a_malformed_file_is_a_json_status_not_a_trap() { + let t = tempfile::tempdir().unwrap(); + let d = dir(&t); + std::fs::write(t.path().join("auth.json"), "{not json").unwrap(); + assert_eq!(token(&d).unwrap_err(), STATUS_PROFILE_JSON); + std::fs::write(t.path().join("secretkey.json"), r#"{"client_id":"x"}"#).unwrap(); + assert_eq!( + secret_key(&d).unwrap_err(), + STATUS_PROFILE_JSON, + "a missing field is a shape error" + ); + } +} diff --git a/languages/golang/stackauth/guest/src/status.rs b/languages/golang/stackauth/guest/src/status.rs new file mode 100644 index 000000000..554a5837b --- /dev/null +++ b/languages/golang/stackauth/guest/src/status.rs @@ -0,0 +1,88 @@ +//! The mapping from [`stack_profile::ProfileError`] onto the status table. +//! +//! The numbers are [`stack_guest_abi::status`]'s — one table for every +//! guest, decoded once by the Go host — re-exported here so this crate's +//! modules and tests name them as the crypto guest names its own. What +//! this guest decides is *which* number a profile failure is: the seven +//! `stack-profile` conditions a Go caller can act on each have one, and +//! the two it cannot act on are internal. + +use stack_profile::ProfileError; + +pub use stack_guest_abi::status::{ + STATUS_ENCODING, STATUS_INTERNAL, STATUS_PROFILE_INVALID_FILENAME, + STATUS_PROFILE_INVALID_WORKSPACE_ID, STATUS_PROFILE_IO, STATUS_PROFILE_JSON, + STATUS_PROFILE_NOT_FOUND, STATUS_PROFILE_NO_CURRENT_WORKSPACE, + STATUS_PROFILE_WORKSPACE_NOT_FOUND, STATUS_STATE, +}; + +/// A profile error as a status code. +/// +/// `HomeDirNotFound` is [`STATUS_INTERNAL`]: this guest is given its +/// directory and never resolves one, so reaching that variant would be a +/// bug here, not a condition for the host. `ProfileError` is +/// `#[non_exhaustive]`, so the catch-all is required, and it goes the same +/// way: a variant added upstream is reported as ours until someone reads +/// it and gives it a number. +pub fn status_for_profile(error: &ProfileError) -> u32 { + match error { + ProfileError::Io(_) => STATUS_PROFILE_IO, + ProfileError::Json(_) => STATUS_PROFILE_JSON, + ProfileError::NotFound { .. } => STATUS_PROFILE_NOT_FOUND, + ProfileError::InvalidFilename(_) => STATUS_PROFILE_INVALID_FILENAME, + ProfileError::NoCurrentWorkspace => STATUS_PROFILE_NO_CURRENT_WORKSPACE, + ProfileError::InvalidWorkspaceId(_) => STATUS_PROFILE_INVALID_WORKSPACE_ID, + ProfileError::WorkspaceNotFound(_) => STATUS_PROFILE_WORKSPACE_NOT_FOUND, + ProfileError::HomeDirNotFound => STATUS_INTERNAL, + _ => STATUS_INTERNAL, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn every_actionable_profile_error_has_its_own_code() { + let cases: Vec<(ProfileError, u32)> = vec![ + ( + ProfileError::Io(std::io::Error::other("disk")), + STATUS_PROFILE_IO, + ), + ( + serde_json::from_str::<u8>("nope") + .map_err(ProfileError::Json) + .unwrap_err(), + STATUS_PROFILE_JSON, + ), + ( + ProfileError::NotFound { path: "x".into() }, + STATUS_PROFILE_NOT_FOUND, + ), + ( + ProfileError::InvalidFilename("../x".into()), + STATUS_PROFILE_INVALID_FILENAME, + ), + ( + ProfileError::NoCurrentWorkspace, + STATUS_PROFILE_NO_CURRENT_WORKSPACE, + ), + ( + ProfileError::InvalidWorkspaceId("short".into()), + STATUS_PROFILE_INVALID_WORKSPACE_ID, + ), + ( + ProfileError::WorkspaceNotFound("AAAAAAAAAAAAAAAA".into()), + STATUS_PROFILE_WORKSPACE_NOT_FOUND, + ), + (ProfileError::HomeDirNotFound, STATUS_INTERNAL), + ]; + let mut seen = std::collections::HashSet::new(); + for (error, expected) in cases { + assert_eq!(status_for_profile(&error), expected, "{error}"); + if expected != STATUS_INTERNAL { + assert!(seen.insert(expected), "code {expected} is shared"); + } + } + } +} diff --git a/languages/golang/stackauth/store.go b/languages/golang/stackauth/store.go new file mode 100644 index 000000000..1806bece0 --- /dev/null +++ b/languages/golang/stackauth/store.go @@ -0,0 +1,402 @@ +package stackauth + +import ( + "context" + "errors" + "fmt" + "log/slog" + "os" + "path/filepath" + "runtime" + "strings" + "sync" + + "github.com/cipherstash/cipherstash-suite/bindings/go/internal/guest" + "github.com/cipherstash/vitaminc/bindings/go/vcffi" + "github.com/cipherstash/vitaminc/bindings/go/vcvalue" + "github.com/tetratelabs/wazero/api" +) + +// Option configures Resolve and Open. +type Option func(*options) + +type options struct { + guest []byte + requireLocked bool +} + +// WithGuest overrides the embedded wasm module. +func WithGuest(wasm []byte) Option { + return func(o *options) { o.guest = wasm } +} + +// RequireLockedMemory makes Open fail with ErrMemoryLock when the guest's +// memory cannot be locked in RAM or, on Linux, excluded from core dumps, +// instead of continuing with memory that may be swapped or dumped and +// reporting so through [ProfileStore.MemoryLocked]. It holds for the life +// of the store, as stackencrypt's Config.RequireLockedMemory does for a +// client. +func RequireLockedMemory() Option { + return func(o *options) { o.requireLocked = true } +} + +// ProfileStore is a profile directory: the root [Resolve] or [Open] returns, +// or a workspace directory under it from [ProfileStore.WorkspaceStore]. All +// stores from one root share one guest instance over the one mounted +// directory; Close on any of them releases it. It is safe for concurrent +// use; calls are serialised, because a wasm instance is single-threaded. +type ProfileStore struct { + root *root + // dir is the store's directory as the guest sees it: guestRoot, or a + // workspace directory the guest itself named under it. + dir string +} + +// root is the guest instance the stores of one profile share. +type root struct { + mu sync.Mutex + inst *instance + hostDir string + closed bool + // released is the runtime's own state, apart from closed: an + // interrupted call closes the module, and so the stores, while the + // runtime is still allocated. Close frees it even then. + released bool + cleanup runtime.Cleanup +} + +// Resolve opens the profile directory the Rust crate would: CS_CONFIG_PATH +// when set and not blank, else ~/.cipherstash. The directory must exist; +// `stash auth login` creates it. +func Resolve(ctx context.Context, opts ...Option) (*ProfileStore, error) { + dir := strings.TrimSpace(os.Getenv("CS_CONFIG_PATH")) + if dir == "" { + home, err := os.UserHomeDir() + if err != nil { + return nil, fmt.Errorf("stackauth: no home directory and CS_CONFIG_PATH is unset: %w", err) + } + dir = filepath.Join(home, ".cipherstash") + } + return Open(ctx, dir, opts...) +} + +// Open instantiates the guest over dir, an existing profile directory, +// mounted as the one directory the guest can see. Nothing is read until a +// method asks; nothing is written unless a method writes. +func Open(ctx context.Context, dir string, opts ...Option) (*ProfileStore, error) { + var o options + for _, opt := range opts { + opt(&o) + } + info, err := os.Stat(dir) + if err != nil { + return nil, fmt.Errorf("%w: %s: %w", ErrNoProfile, dir, err) + } + if !info.IsDir() { + return nil, fmt.Errorf("%w: %s is not a directory", ErrNoProfile, dir) + } + wasm := o.guest + if wasm == nil { + if wasm, err = embeddedGuest(); err != nil { + return nil, err + } + } + inst, err := newInstance(ctx, wasm, dir, guest.PolicyFor(o.requireLocked)) + if err != nil { + return nil, err + } + r := &root{inst: inst, hostDir: dir} + // The cleanup takes the instance, not the root: a cleanup whose + // argument reaches its object keeps that object alive forever. + r.cleanup = runtime.AddCleanup(r, func(inst *instance) { _ = inst.release() }, inst) + return &ProfileStore{root: r, dir: guestRoot}, nil +} + +// Dir is the store's directory on the host: the profile root, or the +// workspace directory under it. +func (s *ProfileStore) Dir() string { return s.hostPath(s.dir) } + +// hostPath maps a guest path under the mount to the host path it names. +func (s *ProfileStore) hostPath(guestPath string) string { + rel := strings.TrimPrefix(guestPath, guestRoot) + parts := strings.Split(strings.TrimPrefix(rel, "/"), "/") + return filepath.Join(append([]string{s.root.hostDir}, parts...)...) +} + +// MemoryLocked reports whether the guest's memory — where the client key +// and the token pass through — is locked in RAM and, on Linux, excluded +// from core dumps. See stackencrypt's Client.MemoryLocked for what false +// means and what to do about it. +func (s *ProfileStore) MemoryLocked() bool { return s.root.inst.mem.LockError() == nil } + +// MemoryLockError is why MemoryLocked is false: an error wrapping +// ErrMemoryLock that names what was refused and the limit that refused it. +// Nil while the memory is locked. +func (s *ProfileStore) MemoryLockError() error { + if err := s.root.inst.mem.LockError(); err != nil { + return guest.MemoryLockError(err) + } + return nil +} + +// String prints the store's directory and memory state. Nothing secret. +func (s *ProfileStore) String() string { + return fmt.Sprintf("stackauth.ProfileStore{dir: %s, memory: %s}", s.Dir(), s.root.inst.mem) +} + +// LogValue implements slog.LogValuer: the directory, and the memory state. +func (s *ProfileStore) LogValue() slog.Value { + return slog.GroupValue(slog.String("dir", s.Dir()), slog.Any("memory", s.root.inst.mem.LogValue())) +} + +// Close releases the guest: its shutdown wipes every buffer it still +// holds, and the runtime close wipes and frees its memory. Idempotent. +// Every store of this profile is closed by it; a call on any of them after +// it fails with ErrState. +func (s *ProfileStore) Close() error { + r := s.root + r.mu.Lock() + defer r.mu.Unlock() + if r.released { + return nil + } + r.released = true + r.closed = true + r.cleanup.Stop() + return r.inst.release() +} + +// export selects one of an instance's exports. +type export func(*instance) api.Function + +// call runs one export under the profile's lock, closing the profile if +// the guest trapped or an interrupted call took the module down. +func (s *ProfileStore) call(ctx context.Context, fn export, args ...string) ([]byte, error) { + r := s.root + r.mu.Lock() + defer r.mu.Unlock() + if r.closed || r.inst.module.IsClosed() { + r.closed = true + return nil, ErrState + } + out, err := r.inst.call(ctx, fn(r.inst), append([]string{s.dir}, args...)...) + switch { + case r.inst.module.IsClosed(): + r.closed = true + if err == nil { + err = ErrState + } + err = fmt.Errorf("%w: interrupted call closed the profile", err) + case errors.Is(err, guest.ErrTrap): + r.closed = true + _ = r.inst.module.Close(context.Background()) + err = fmt.Errorf("%w; the profile is closed", err) + } + if err != nil { + return nil, err + } + return out, nil +} + +// CurrentWorkspace is the current workspace id, or ErrNoCurrentWorkspace. +func (s *ProfileStore) CurrentWorkspace(ctx context.Context) (string, error) { + out, err := s.call(ctx, func(i *instance) api.Function { return i.currentWorkspace }) + return string(out), err +} + +// SetCurrentWorkspace makes id the current workspace. The workspace must +// already have profile data on this machine (ErrWorkspaceNotFound +// otherwise): a login creates it, this package never does. +func (s *ProfileStore) SetCurrentWorkspace(ctx context.Context, id string) error { + _, err := s.call(ctx, func(i *instance) api.Function { return i.setCurrentWorkspace }, id) + return err +} + +// ClearCurrentWorkspace removes the current workspace selection. Nothing +// to remove is not an error. +func (s *ProfileStore) ClearCurrentWorkspace(ctx context.Context) error { + _, err := s.call(ctx, func(i *instance) api.Function { return i.clearCurrentWorkspace }) + return err +} + +// ListWorkspaces is the workspace ids with profile data on disk, sorted. +func (s *ProfileStore) ListWorkspaces(ctx context.Context) ([]string, error) { + out, err := s.call(ctx, func(i *instance) api.Function { return i.listWorkspaces }) + if err != nil { + return nil, err + } + decoded, err := vcffi.Unmarshal(out) + if err != nil { + return nil, fmt.Errorf("%w: decoding the workspace list: %w", ErrInternal, err) + } + items, ok := decoded.([]any) + if !ok { + return nil, fmt.Errorf("%w: the workspace list is not a list", ErrInternal) + } + ids := make([]string, 0, len(items)) + for _, item := range items { + id, ok := item.(string) + if !ok { + return nil, fmt.Errorf("%w: a workspace id is not a string", ErrInternal) + } + ids = append(ids, id) + } + return ids, nil +} + +// WorkspaceStore is the store scoped to workspace id: the directory +// workspaces/<id> under this store, as the guest names it after validating +// the id (ErrInvalidWorkspaceID otherwise). The directory need not exist +// yet; reads from it report ErrNotFound. It shares this store's guest and +// is closed with it. +func (s *ProfileStore) WorkspaceStore(ctx context.Context, id string) (*ProfileStore, error) { + out, err := s.call(ctx, func(i *instance) api.Function { return i.workspaceDir }, id) + if err != nil { + return nil, err + } + return &ProfileStore{root: s.root, dir: string(out)}, nil +} + +// CurrentWorkspaceStore is [ProfileStore.WorkspaceStore] for the current +// workspace, or ErrNoCurrentWorkspace. +func (s *ProfileStore) CurrentWorkspaceStore(ctx context.Context) (*ProfileStore, error) { + id, err := s.CurrentWorkspace(ctx) + if err != nil { + return nil, err + } + return s.WorkspaceStore(ctx, id) +} + +// LockPath is the host path of the lock file the Rust crate takes for +// filename in this store: a sibling `.<filename>.lock`. Nothing is created +// or locked. It is for the host to hold the crate's lock — around a +// refresh, once this package refreshes — since the guest cannot; this +// package never composes a profile path itself. +func (s *ProfileStore) LockPath(ctx context.Context, filename string) (string, error) { + out, err := s.call(ctx, func(i *instance) api.Function { return i.lockPath }, filename) + if err != nil { + return "", err + } + return s.hostPath(string(out)), nil +} + +// SecretKey reads secretkey.json in this store (a workspace store; the +// root holds none): the ZeroKMS client id and the client key, the latter as +// the opaque [ClientKey] a stackencrypt.Config takes. The transport copy +// of the key is wiped once it is in the ClientKey; the key is then the +// caller's to consume. +func (s *ProfileStore) SecretKey(ctx context.Context) (clientID string, key *ClientKey, err error) { + out, err := s.call(ctx, func(i *instance) api.Function { return i.secretKey }) + if err != nil { + return "", nil, err + } + defer guest.Wipe(out) + fields, err := object(out) + if err != nil { + return "", nil, err + } + clientID, err = fields.text("client_id") + if err != nil { + return "", nil, err + } + material, err := fields.text("client_key") + if err != nil { + return "", nil, err + } + // The one string copy the codec made is the collector's; the bytes the + // key holds are wiped when it is consumed. + return clientID, guest.NewClientKey([]byte(material)), nil +} + +// DeviceIdentity is the identity the CLI created for this machine, read +// from device.json in this store (the profile root). Read-only: creating +// one is the CLI's. +type DeviceIdentity struct { + // DeviceInstanceID uniquely identifies this CLI installation. + DeviceInstanceID string + // DeviceName is a human-readable name, the hostname by default. + DeviceName string +} + +// DeviceIdentity reads device.json in this store. +func (s *ProfileStore) DeviceIdentity(ctx context.Context) (DeviceIdentity, error) { + out, err := s.call(ctx, func(i *instance) api.Function { return i.deviceIdentity }) + if err != nil { + return DeviceIdentity{}, err + } + fields, err := object(out) + if err != nil { + return DeviceIdentity{}, err + } + var d DeviceIdentity + if d.DeviceInstanceID, err = fields.text("device_instance_id"); err != nil { + return DeviceIdentity{}, err + } + if d.DeviceName, err = fields.text("device_name"); err != nil { + return DeviceIdentity{}, err + } + return d, nil +} + +// fields is a decoded codec object. +type fields vcvalue.Object + +func object(encoded []byte) (fields, error) { + decoded, err := vcffi.Unmarshal(encoded) + if err != nil { + return nil, fmt.Errorf("%w: decoding the guest's result: %w", ErrInternal, err) + } + obj, ok := decoded.(vcvalue.Object) + if !ok { + return nil, fmt.Errorf("%w: the guest's result is not an object", ErrInternal) + } + return fields(obj), nil +} + +func (f fields) get(key string) (any, bool) { + for _, field := range f { + if field.Key == key { + return field.Value, true + } + } + return nil, false +} + +// text is a string field that must be present. +func (f fields) text(key string) (string, error) { + v, ok := f.get(key) + if !ok { + return "", fmt.Errorf("%w: the guest's result has no %s", ErrInternal, key) + } + s, ok := v.(string) + if !ok { + return "", fmt.Errorf("%w: %s is not a string", ErrInternal, key) + } + return s, nil +} + +// optionalText is a string field that may be null or absent. +func (f fields) optionalText(key string) (string, error) { + v, ok := f.get(key) + if !ok || v == nil { + return "", nil + } + s, ok := v.(string) + if !ok { + return "", fmt.Errorf("%w: %s is not a string", ErrInternal, key) + } + return s, nil +} + +// uint64Field is an unsigned integer field that must be present. +func (f fields) uint64Field(key string) (uint64, error) { + v, ok := f.get(key) + if !ok { + return 0, fmt.Errorf("%w: the guest's result has no %s", ErrInternal, key) + } + n, ok := v.(uint64) + if !ok { + return 0, fmt.Errorf("%w: %s is not an integer", ErrInternal, key) + } + return n, nil +} diff --git a/languages/golang/stackauth/store_test.go b/languages/golang/stackauth/store_test.go new file mode 100644 index 000000000..2134a1b67 --- /dev/null +++ b/languages/golang/stackauth/store_test.go @@ -0,0 +1,440 @@ +package stackauth + +import ( + "context" + "errors" + "fmt" + "os" + "path/filepath" + "reflect" + "runtime" + "strings" + "testing" + "time" + + "github.com/cipherstash/cipherstash-suite/bindings/go/internal/guest" + "github.com/tetratelabs/wazero" +) + +const ( + wsA = "AAAAAAAAAAAAAAAA" + wsB = "BBBBBBBBBBBBBBBB" + + secretKeyJSON = `{"client_id":"6a70bd18-99ac-4650-b104-37eec3a15b09","client_key":"AAECAwQFBgc="}` + deviceJSON = `{"device_instance_id":"0f4a4fd7-4a1a-4c5e-9d3e-7a4c8e3a9c11","device_name":"laptop"}` +) + +func authJSON(expiresAt int64) string { + return fmt.Sprintf(`{"access_token":"tok-%d","refresh_token":"refresh","token_type":"Bearer","expires_at":%d,"region":"ap-southeast-2"}`, expiresAt, expiresAt) +} + +// guestOrSkip is the embedded guest, or a skip where it is not built. +func guestOrSkip(t *testing.T) []byte { + t.Helper() + wasm, err := embeddedGuest() + if err != nil { + t.Skip(err) + } + return wasm +} + +// profile is a fresh profile directory with two workspaces and the files a +// login writes into the first, opened as a store. +func profile(t *testing.T) (dir string, s *ProfileStore) { + t.Helper() + guestOrSkip(t) + dir = t.TempDir() + for _, ws := range []string{wsA, wsB} { + if err := os.MkdirAll(filepath.Join(dir, "workspaces", ws), 0o700); err != nil { + t.Fatal(err) + } + } + write(t, filepath.Join(dir, "workspaces", wsA, "secretkey.json"), secretKeyJSON) + write(t, filepath.Join(dir, "workspaces", wsA, "auth.json"), authJSON(time.Now().Add(time.Hour).Unix())) + write(t, filepath.Join(dir, "device.json"), deviceJSON) + s, err := Open(context.Background(), dir) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = s.Close() }) + return dir, s +} + +func write(t *testing.T, path, content string) { + t.Helper() + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + t.Fatal(err) + } +} + +// The guest may reach the filesystem — that is what it is for — and +// nothing else: no sockets, no transport import (the auth half adds one), +// and only the exports this package resolves. +func TestImportSurfaceIsWASIWithoutSockets(t *testing.T) { + ctx := context.Background() + r := wazero.NewRuntime(ctx) + defer r.Close(ctx) + compiled, err := r.CompileModule(ctx, guestOrSkip(t)) + if err != nil { + t.Fatal(err) + } + defer compiled.Close(ctx) + sawPathOpen := false + for _, imp := range compiled.ImportedFunctions() { + module, name, _ := imp.Import() + if module != "wasi_snapshot_preview1" { + t.Errorf("guest imports %s::%s, outside WASI", module, name) + continue + } + if strings.HasPrefix(name, "sock_") { + t.Errorf("guest imports socket function %s", name) + } + if name == "path_open" { + sawPathOpen = true + } + } + if !sawPathOpen { + t.Error("guest does not import path_open; it cannot be reading a profile") + } + for _, name := range []string{"se_alloc", "se_dealloc", "sa_shutdown", "sa_current_workspace", "sa_set_current_workspace", "sa_clear_current_workspace", "sa_list_workspaces", "sa_workspace_dir", "sa_lock_path", "sa_secret_key", "sa_token", "sa_device_identity"} { + if _, ok := compiled.ExportedFunctions()[name]; !ok { + t.Errorf("guest does not export %s", name) + } + } +} + +func TestOpenRequiresAnExistingDirectory(t *testing.T) { + guestOrSkip(t) + ctx := context.Background() + if _, err := Open(ctx, filepath.Join(t.TempDir(), "missing")); !errors.Is(err, ErrNoProfile) { + t.Fatalf("Open of a missing directory: %v, want ErrNoProfile", err) + } + file := filepath.Join(t.TempDir(), "file") + write(t, file, "") + if _, err := Open(ctx, file); !errors.Is(err, ErrNoProfile) { + t.Fatalf("Open of a file: %v, want ErrNoProfile", err) + } +} + +// Resolve finds the directory the Rust crate would: CS_CONFIG_PATH first. +func TestResolveHonoursConfigPath(t *testing.T) { + dir, _ := profile(t) + t.Setenv("CS_CONFIG_PATH", dir) + s, err := Resolve(context.Background()) + if err != nil { + t.Fatal(err) + } + defer s.Close() + if s.Dir() != dir { + t.Fatalf("Dir = %q, want %q", s.Dir(), dir) + } + t.Setenv("CS_CONFIG_PATH", " ") + t.Setenv("HOME", filepath.Join(t.TempDir(), "nohome")) + if _, err := Resolve(context.Background()); !errors.Is(err, ErrNoProfile) { + t.Fatalf("Resolve with a blank CS_CONFIG_PATH and no ~/.cipherstash: %v, want ErrNoProfile", err) + } +} + +func TestWorkspaceSelection(t *testing.T) { + ctx := context.Background() + _, s := profile(t) + if _, err := s.CurrentWorkspace(ctx); !errors.Is(err, ErrNoCurrentWorkspace) { + t.Fatalf("no workspace set: %v, want ErrNoCurrentWorkspace", err) + } + if _, err := s.CurrentWorkspaceStore(ctx); !errors.Is(err, ErrNoCurrentWorkspace) { + t.Fatalf("no workspace set: %v, want ErrNoCurrentWorkspace", err) + } + if err := s.SetCurrentWorkspace(ctx, "CCCCCCCCCCCCCCCC"); !errors.Is(err, ErrWorkspaceNotFound) { + t.Fatalf("setting a workspace with no directory: %v, want ErrWorkspaceNotFound", err) + } + if err := s.SetCurrentWorkspace(ctx, "../escape"); !errors.Is(err, ErrInvalidWorkspaceID) { + t.Fatalf("setting a path as the workspace: %v, want ErrInvalidWorkspaceID", err) + } + if err := s.SetCurrentWorkspace(ctx, wsA); err != nil { + t.Fatal(err) + } + if got, err := s.CurrentWorkspace(ctx); err != nil || got != wsA { + t.Fatalf("CurrentWorkspace = %q, %v; want %q", got, err, wsA) + } + ids, err := s.ListWorkspaces(ctx) + if err != nil || !reflect.DeepEqual(ids, []string{wsA, wsB}) { + t.Fatalf("ListWorkspaces = %v, %v; want [%s %s]", ids, err, wsA, wsB) + } + if err := s.ClearCurrentWorkspace(ctx); err != nil { + t.Fatal(err) + } + if _, err := s.CurrentWorkspace(ctx); !errors.Is(err, ErrNoCurrentWorkspace) { + t.Fatalf("after clearing: %v, want ErrNoCurrentWorkspace", err) + } + if err := s.ClearCurrentWorkspace(ctx); err != nil { + t.Fatalf("clearing twice: %v", err) + } +} + +func TestWorkspaceStoresAreScopedAndShareTheGuest(t *testing.T) { + ctx := context.Background() + dir, s := profile(t) + ws, err := s.WorkspaceStore(ctx, wsA) + if err != nil { + t.Fatal(err) + } + if want := filepath.Join(dir, "workspaces", wsA); ws.Dir() != want { + t.Fatalf("workspace Dir = %q, want %q", ws.Dir(), want) + } + if s.Dir() != dir { + t.Fatalf("root Dir = %q, want %q", s.Dir(), dir) + } + if _, err := s.WorkspaceStore(ctx, "not-an-id"); !errors.Is(err, ErrInvalidWorkspaceID) { + t.Fatalf("WorkspaceStore of a bad id: %v, want ErrInvalidWorkspaceID", err) + } + // The root holds no secret key; the workspace does. + if _, _, err := s.SecretKey(ctx); !errors.Is(err, ErrNotFound) { + t.Fatalf("SecretKey at the root: %v, want ErrNotFound", err) + } + if err := s.SetCurrentWorkspace(ctx, wsA); err != nil { + t.Fatal(err) + } + current, err := s.CurrentWorkspaceStore(ctx) + if err != nil || current.Dir() != ws.Dir() { + t.Fatalf("CurrentWorkspaceStore = %v, %v; want %s", current, err, ws.Dir()) + } + // One guest: closing the workspace store closes the profile. + if err := ws.Close(); err != nil { + t.Fatal(err) + } + if _, err := s.CurrentWorkspace(ctx); !errors.Is(err, ErrState) { + t.Fatalf("after Close: %v, want ErrState", err) + } + if err := s.Close(); err != nil { + t.Fatalf("a second Close: %v", err) + } +} + +func TestTypedReads(t *testing.T) { + ctx := context.Background() + dir, s := profile(t) + ws, err := s.WorkspaceStore(ctx, wsA) + if err != nil { + t.Fatal(err) + } + + clientID, key, err := ws.SecretKey(ctx) + if err != nil { + t.Fatal(err) + } + if clientID != "6a70bd18-99ac-4650-b104-37eec3a15b09" { + t.Errorf("client id = %q", clientID) + } + if string(guest.KeyBytes(key)) != "AAECAwQFBgc=" { + t.Errorf("client key material = %q, want the file's base64", guest.KeyBytes(key)) + } + if out := fmt.Sprintf("%v %+v %#v %s", key, key, key, key); strings.Contains(out, "AAECAw") { + t.Errorf("the key prints its material: %q", out) + } + key.Wipe() + + tok, err := ws.Token(ctx) + if err != nil { + t.Fatal(err) + } + if !strings.HasPrefix(tok.AccessToken, "tok-") || tok.TokenType != "Bearer" || tok.Region != "ap-southeast-2" { + t.Errorf("Token = %+v", tok) + } + if !tok.Usable(time.Now()) || tok.ExpiresAt.Before(time.Now().Add(50*time.Minute)) { + t.Errorf("ExpiresAt = %s, want about an hour away", tok.ExpiresAt) + } + if tok.ClientID != "" || tok.DeviceInstanceID != "" { + t.Errorf("absent optional fields decoded as %+v", tok) + } + + identity, err := s.DeviceIdentity(ctx) + if err != nil { + t.Fatal(err) + } + if identity != (DeviceIdentity{DeviceInstanceID: "0f4a4fd7-4a1a-4c5e-9d3e-7a4c8e3a9c11", DeviceName: "laptop"}) { + t.Errorf("DeviceIdentity = %+v", identity) + } + + // The other workspace has nothing: not found, not a trap. + other, err := s.WorkspaceStore(ctx, wsB) + if err != nil { + t.Fatal(err) + } + if _, err := other.Token(ctx); !errors.Is(err, ErrNotFound) { + t.Fatalf("Token of an empty workspace: %v, want ErrNotFound", err) + } + // A malformed file is ErrInvalid. + write(t, filepath.Join(dir, "workspaces", wsB, "auth.json"), "{not json") + if _, err := other.Token(ctx); !errors.Is(err, ErrInvalid) { + t.Fatalf("Token from a malformed file: %v, want ErrInvalid", err) + } +} + +// The token source re-reads the file on every call, so a login in another +// terminal is picked up, and refuses the token at its real expiry. +func TestTokenSourceRereadsAndRefusesAtExpiry(t *testing.T) { + ctx := context.Background() + dir, s := profile(t) + ws, err := s.WorkspaceStore(ctx, wsA) + if err != nil { + t.Fatal(err) + } + src := ws.TokenSource() + first, err := src.Token(ctx) + if err != nil { + t.Fatal(err) + } + later := time.Now().Add(2 * time.Hour).Unix() + write(t, filepath.Join(dir, "workspaces", wsA, "auth.json"), authJSON(later)) + second, err := src.Token(ctx) + if err != nil { + t.Fatal(err) + } + if second == first || second != fmt.Sprintf("tok-%d", later) { + t.Fatalf("the rewritten token was not picked up: %q then %q", first, second) + } + // At the expiry timestamp itself the token is refused: the crate's + // is_usable is strictly before it. No refresh-ahead margin here. + src.now = func() time.Time { return time.Unix(later, 0) } + if _, err := src.Token(ctx); !errors.Is(err, ErrTokenExpired) || !strings.Contains(err.Error(), "stash auth login") { + t.Fatalf("token at its expiry: %v, want ErrTokenExpired naming stash auth login", err) + } + src.now = func() time.Time { return time.Unix(later-1, 0) } + if _, err := src.Token(ctx); err != nil { + t.Fatalf("token one second before expiry: %v", err) + } + // An expired file on disk is refused too, whatever the clock. + write(t, filepath.Join(dir, "workspaces", wsA, "auth.json"), authJSON(time.Now().Add(-time.Minute).Unix())) + src.now = nil + if _, err := src.Token(ctx); !errors.Is(err, ErrTokenExpired) { + t.Fatalf("an expired stored token: %v, want ErrTokenExpired", err) + } +} + +// The lock file is the crate's sibling `.<filename>.lock`, named by the +// guest and mapped back to the host, never composed here; a filename that +// is a path is refused before any path is built. +func TestLockPathIsTheCratesAndValidated(t *testing.T) { + ctx := context.Background() + dir, s := profile(t) + ws, err := s.WorkspaceStore(ctx, wsA) + if err != nil { + t.Fatal(err) + } + path, err := ws.LockPath(ctx, "auth.json") + if err != nil { + t.Fatal(err) + } + if want := filepath.Join(dir, "workspaces", wsA, ".auth.json.lock"); path != want { + t.Fatalf("LockPath = %q, want %q", path, want) + } + if _, err := os.Stat(path); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("naming the lock file created it: %v", err) + } + for _, bad := range []string{"", "../auth.json", "/etc/auth.json", "a/b"} { + if _, err := ws.LockPath(ctx, bad); !errors.Is(err, ErrInvalidFilename) { + t.Errorf("LockPath(%q) = %v, want ErrInvalidFilename", bad, err) + } + } +} + +// Two runtime properties the crate does not own on wasm32 and the package +// therefore pins: a file the guest creates is mode 0600 (wazero's create +// mode; the crate's own mode handling is unix-only and skipped), and the +// guest cannot read outside the one directory it was given. +func TestFilesTheGuestCreatesAreOwnerOnly(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("no Unix modes on Windows") + } + ctx := context.Background() + dir, s := profile(t) + if err := s.SetCurrentWorkspace(ctx, wsA); err != nil { + t.Fatal(err) + } + info, err := os.Stat(filepath.Join(dir, "current_workspace")) + if err != nil { + t.Fatal(err) + } + if mode := info.Mode().Perm(); mode != 0o600 { + t.Fatalf("current_workspace is mode %o, want 0600", mode) + } +} + +func TestTheGuestCannotSeeOutsideTheMount(t *testing.T) { + ctx := context.Background() + _, s := profile(t) + // A perfectly good auth.json, outside the directory the guest was + // given. Naming its directory to the guest directly — which no method + // of this package does — must not read it. + outside := t.TempDir() + write(t, filepath.Join(outside, "auth.json"), authJSON(time.Now().Add(time.Hour).Unix())) + escaped := &ProfileStore{root: s.root, dir: outside} + if _, err := escaped.Token(ctx); err == nil { + t.Fatal("the guest read a file outside its mount") + } else if !errors.Is(err, ErrNotFound) && !errors.Is(err, ErrIO) { + t.Fatalf("reading outside the mount: %v, want ErrNotFound or ErrIO", err) + } + // The same through the guest's own root: the mount is the only root + // it has, and `..` above it goes nowhere. + escaped = &ProfileStore{root: s.root, dir: guestRoot + "/.."} + if _, err := escaped.DeviceIdentity(ctx); err == nil { + t.Fatal("the guest read above its mount") + } +} + +// The guest's memory is the shared allocator's: the store reports its lock +// state like a client does, and RequireLockedMemory is honoured. +func TestMemoryIsReported(t *testing.T) { + _, s := profile(t) + if s.MemoryLocked() != (s.MemoryLockError() == nil) { + t.Fatal("MemoryLocked and MemoryLockError disagree") + } + if err := s.MemoryLockError(); err != nil && !errors.Is(err, ErrMemoryLock) { + t.Fatalf("MemoryLockError = %v, want ErrMemoryLock or nil", err) + } + if !strings.Contains(fmt.Sprint(s), "memory:") { + t.Fatalf("String = %q, no memory state", s) + } + if !s.MemoryLocked() { + if _, err := Open(context.Background(), s.Dir(), RequireLockedMemory()); !errors.Is(err, ErrMemoryLock) { + t.Fatalf("RequireLockedMemory under a refused lock: %v, want ErrMemoryLock", err) + } + } +} + +// A ProfileStore that becomes unreachable without Close is released by +// its cleanup, as a Client is. +func TestUnreachableStoreIsReleased(t *testing.T) { + dir, _ := profile(t) + s, err := Open(context.Background(), dir) + if err != nil { + t.Fatal(err) + } + alloc := s.root.inst.mem + s = nil + deadline := time.Now().Add(10 * time.Second) + for !alloc.IsFreed() { + if time.Now().After(deadline) { + t.Fatal("an unreachable store's memory was not released") + } + runtime.GC() + time.Sleep(10 * time.Millisecond) + } +} + +// The guest root this package mounts at is the one the guest builds every +// path under. +func TestGuestRootMatchesTheGuest(t *testing.T) { + ctx := context.Background() + _, s := profile(t) + ws, err := s.WorkspaceStore(ctx, wsA) + if err != nil { + t.Fatal(err) + } + if !strings.HasPrefix(ws.dir, guestRoot+"/") { + t.Fatalf("the guest named %q, not under %q", ws.dir, guestRoot) + } + if guest.PolicyFor(false) != guest.BestEffort { + t.Fatal("the default policy is not best effort") + } +} diff --git a/languages/golang/stackauth/token.go b/languages/golang/stackauth/token.go new file mode 100644 index 000000000..48f65ac75 --- /dev/null +++ b/languages/golang/stackauth/token.go @@ -0,0 +1,105 @@ +package stackauth + +import ( + "context" + "fmt" + "time" + + "github.com/cipherstash/cipherstash-suite/bindings/go/internal/guest" + "github.com/tetratelabs/wazero/api" +) + +// Token is the stored access token, as auth.json holds it and +// [ProfileStore.Token] reads it through the Rust crate's own type. The +// refresh token is not in it: refreshing is the auth half of this package, +// and it runs inside the guest. +type Token struct { + // AccessToken is the bearer credential. + AccessToken string + // TokenType is the token's type, "Bearer". + TokenType string + // ExpiresAt is when the token stops being usable, from the stored + // epoch timestamp. + ExpiresAt time.Time + // Region is the region the token was issued for, if stored. + Region string + // ClientID is the ZeroKMS client id the login provisioned, if stored. + ClientID string + // DeviceInstanceID is the identity of the device that logged in, if + // stored. + DeviceInstanceID string +} + +// Usable reports whether the token is still usable at now: before its real +// expiry. The Rust crate's is_usable, not its is_expired, which subtracts a +// refresh-ahead margin that belongs to refreshing. +func (t Token) Usable(now time.Time) bool { return now.Before(t.ExpiresAt) } + +// Token reads auth.json in this store (a workspace store; the root holds +// none). The transport copy of the token is wiped once it is read out. +func (s *ProfileStore) Token(ctx context.Context) (Token, error) { + out, err := s.call(ctx, func(i *instance) api.Function { return i.token }) + if err != nil { + return Token{}, err + } + defer guest.Wipe(out) + fields, err := object(out) + if err != nil { + return Token{}, err + } + var t Token + if t.AccessToken, err = fields.text("access_token"); err != nil { + return Token{}, err + } + if t.TokenType, err = fields.text("token_type"); err != nil { + return Token{}, err + } + expiresAt, err := fields.uint64Field("expires_at") + if err != nil { + return Token{}, err + } + t.ExpiresAt = time.Unix(int64(expiresAt), 0) + if t.Region, err = fields.optionalText("region"); err != nil { + return Token{}, err + } + if t.ClientID, err = fields.optionalText("client_id"); err != nil { + return Token{}, err + } + if t.DeviceInstanceID, err = fields.optionalText("device_instance_id"); err != nil { + return Token{}, err + } + return t, nil +} + +// TokenSource is a bearer-token source over the token stored in a +// workspace's auth.json, in the shape stackencrypt's Config.Token takes. +// Every call re-reads the file, so a login or refresh by the CLI in +// another terminal is picked up without a restart, and a token at or past +// its real expiry is refused with [ErrTokenExpired] rather than presented. +// Refreshing is not here yet; until it is, the answer to ErrTokenExpired +// is `stash auth login`. +type TokenSource struct { + store *ProfileStore + // now is the clock, for tests; nil is time.Now. + now func() time.Time +} + +// TokenSource is a [TokenSource] over this store's auth.json. +func (s *ProfileStore) TokenSource() *TokenSource { return &TokenSource{store: s} } + +// Token implements stackencrypt's TokenSource: the stored access token, +// re-read now, or ErrTokenExpired. +func (ts *TokenSource) Token(ctx context.Context) (string, error) { + t, err := ts.store.Token(ctx) + if err != nil { + return "", err + } + now := time.Now + if ts.now != nil { + now = ts.now + } + if !t.Usable(now()) { + return "", fmt.Errorf("%w (expired at %s)", ErrTokenExpired, t.ExpiresAt.UTC().Format(time.RFC3339)) + } + return t.AccessToken, nil +} diff --git a/languages/golang/stackauth/wasm/README.md b/languages/golang/stackauth/wasm/README.md new file mode 100644 index 000000000..cfdbc2cd8 --- /dev/null +++ b/languages/golang/stackauth/wasm/README.md @@ -0,0 +1,7 @@ +# Guest module + +`stack_auth_guest.wasm` is a build artefact of the Rust crate in +`../guest`, copied here by `mise run wasm:auth-guest:build`. It is not +committed; the Go package embeds this directory and reports +`ErrGuestNotBuilt` from `Open` when the module is absent, and its tests +skip. diff --git a/packages/stack-guest-abi/src/status.rs b/packages/stack-guest-abi/src/status.rs index 444a51c3f..cf00ff400 100644 --- a/packages/stack-guest-abi/src/status.rs +++ b/packages/stack-guest-abi/src/status.rs @@ -15,13 +15,14 @@ //! run this call against. Codes 5–10 are the outcomes of a request to //! ZeroKMS, so a host can distinguish a refused credential from a tampered //! ciphertext without parsing strings; 11 and 12 were appended by the -//! crypto guest. +//! crypto guest, 13–19 by the credential guest. //! //! Each code is documented here as the *verdict* it carries to a host — the //! thing the host can act on. Which of a library's errors reach which code, //! and what each verdict means for a particular export, is each guest's own -//! (`status_for_error` and friends in the crypto guest's `status` module): -//! this module names the numbers, not the libraries. +//! (`status_for_error` and friends in the crypto guest's `status` module, +//! `status_for_profile` in the credential guest's): this module names the +//! numbers, not the libraries. /// AEAD open failure: the ciphertext, or the context it is being opened /// under, is not what it was sealed with. A tampered ciphertext, a wrong @@ -73,10 +74,35 @@ pub const STATUS_TERM: u32 = 11; /// only that — never a verdict on the value's integrity. pub const STATUS_FOREIGN_KEYSET: u32 = 12; +// ---- The credential guest's codes (ADR-0005): `stack-profile`'s errors, +// one number each, so a Go caller can tell a missing workspace from a +// malformed file without parsing strings. `HomeDirNotFound` has no code: +// the guest is given its directory and never resolves one. + +/// A profile file could not be read or written: the I/O error underneath +/// `stack_profile::ProfileError::Io`. +pub const STATUS_PROFILE_IO: u32 = 13; +/// A profile file held something other than the JSON its type expects. +pub const STATUS_PROFILE_JSON: u32 = 14; +/// The profile file asked for does not exist: no `secretkey.json`, +/// `auth.json` or `device.json` in that store. +pub const STATUS_PROFILE_NOT_FOUND: u32 = 15; +/// A filename the store refuses: empty, absolute, or naming a path +/// (separators, `..`). Refused before anything is opened. +pub const STATUS_PROFILE_INVALID_FILENAME: u32 = 16; +/// No current workspace is set; a workspace-scoped operation needs one. +pub const STATUS_PROFILE_NO_CURRENT_WORKSPACE: u32 = 17; +/// A workspace id that is not sixteen base32 characters. Refused before +/// any path is built from it. +pub const STATUS_PROFILE_INVALID_WORKSPACE_ID: u32 = 18; +/// The workspace has no directory under `workspaces/`: nothing has logged +/// in to it on this machine. +pub const STATUS_PROFILE_WORKSPACE_NOT_FOUND: u32 = 19; + /// The last code in the table. A guest appending a code of its own starts /// at `LAST_STATUS + 1` and moves this constant with it, so two guests can /// never claim one number. -pub const LAST_STATUS: u32 = STATUS_FOREIGN_KEYSET; +pub const LAST_STATUS: u32 = STATUS_PROFILE_WORKSPACE_NOT_FOUND; #[cfg(test)] mod tests { @@ -99,6 +125,13 @@ mod tests { STATUS_KMS_OTHER, STATUS_TERM, STATUS_FOREIGN_KEYSET, + STATUS_PROFILE_IO, + STATUS_PROFILE_JSON, + STATUS_PROFILE_NOT_FOUND, + STATUS_PROFILE_INVALID_FILENAME, + STATUS_PROFILE_NO_CURRENT_WORKSPACE, + STATUS_PROFILE_INVALID_WORKSPACE_ID, + STATUS_PROFILE_WORKSPACE_NOT_FOUND, ]; for (i, code) in codes.iter().enumerate() { assert_eq!(*code, i as u32 + 1, "code {i} is out of sequence"); diff --git a/scripts/go-binding-test.sh b/scripts/go-binding-test.sh index 47c90387d..5bcee3229 100755 --- a/scripts/go-binding-test.sh +++ b/scripts/go-binding-test.sh @@ -8,17 +8,23 @@ # the other platforms as an artifact — the wasm is platform-independent and # the Rust build is the slow part. # -# Usage: go-binding-test.sh <module dir> [<guest path, relative to it>] +# Usage: go-binding-test.sh <module dir> [<guest path, relative to it>...] +# With no guest paths, both guests the module embeds are expected. set -euo pipefail -dir=${1:?usage: go-binding-test.sh <module dir> [<guest path>]} -guest=${2:-stackencrypt/wasm/stack_encrypt_guest.wasm} +dir=${1:?usage: go-binding-test.sh <module dir> [<guest path>...]} +shift || true +if [ $# -eq 0 ]; then + set -- stackencrypt/wasm/stack_encrypt_guest.wasm stackauth/wasm/stack_auth_guest.wasm +fi cd "$dir" -if [ ! -f "$guest" ]; then - echo "guest module not built at $dir/$guest — run: mise run wasm:guest:build" >&2 - exit 1 -fi +for guest in "$@"; do + if [ ! -f "$guest" ]; then + echo "guest module not built at $dir/$guest — run: mise run wasm:guest:build wasm:auth-guest:build" >&2 + exit 1 + fi +done # gofmt exits 0 even when files need formatting; -l lists them. out=$(gofmt -l .) From 358c7396b50f2f8a078e1a708336085460392ed0 Mon Sep 17 00:00:00 2001 From: Dan Draper <dan@cipherstash.com> Date: Tue, 22 Sep 2026 16:18:00 -0500 Subject: [PATCH 620/686] fix(go): answer review on the credential guest and stackauth MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The mount is confined to the directory it names. wazero's directory mount joins the guest's path onto the host directory and lets the OS resolve it, symlinks included, with the process's permissions: a device.json that was a symlink read a file elsewhere, and a current_workspace that was one wrote there (review, P1). Every path the guest names now resolves through os.Root first, which follows symlinks only while they stay inside the directory, and a path that leaves it is refused with EACCES before the operation runs. The check precedes the operation rather than being part of it, and the file says why that is the boundary chosen. Tests cover the read, the directory, the write — the file outside is left as it was — and the symlink that stays inside. LockPath refuses the host's separator as well as the guest's: on Windows a backslash passed the crate's check and became a separator once the answer was mapped back (review, P2). The mapped path must also be a direct child of the store before it is returned. ProfileStore.call compares the allocator's growth refusals across the call, as Client.call does, so a growth refused under RequireLockedMemory is reported as ErrMemoryLock naming the refusal rather than as a failed allocation (review, P2). Resolve tests CS_CONFIG_PATH for blankness on the trimmed value and uses the value itself, as ProfileStore::resolve does (review, P3). The client key crosses to Go as bytes, so the decoder hands back a slice the ClientKey owns and wipes; no string copy of the material is made on the way. The deserialized secretkey.json wipes on drop. stackencrypt uses internal/guest's call plumbing instead of its own copy of it, so the wipe-before-return discipline is written once, as the shared crate's docs require; its tests stage through the shared exports. The example reads the profile through stackauth: Resolve, the current workspace store, SecretKey and TokenSource. The hand-derived reader the issue names as the drift it exists to end is gone, with its test of the copied workspace-id rule. Docs and gates: the random_get rationale named an atomic rewrite that does not link; the Rust runtime draws through it. The Go test task is go:test, with the old name as an alias. The guest's dependencies are in order, vitaminc-protected is the dev-dependency it was, and three test names say what they expect. The example builds both guests. --- .github/imported-workflows/test-wasi.yml | 2 +- docs/plans/stack-encrypt-go-bindings.md | 2 +- languages/golang/stackauth/README.md | 7 +- languages/golang/stackauth/doc.go | 4 +- languages/golang/stackauth/guest.go | 63 ++++--- languages/golang/stackauth/guest/Cargo.lock | 1 + languages/golang/stackauth/guest/Cargo.toml | 19 +- languages/golang/stackauth/guest/src/ops.rs | 43 ++++- languages/golang/stackauth/mount.go | 163 ++++++++++++++++ languages/golang/stackauth/store.go | 55 +++++- languages/golang/stackauth/store_test.go | 157 ++++++++++++++- .../golang/stackencrypt/example/README.md | 54 +++--- languages/golang/stackencrypt/example/main.go | 17 +- .../golang/stackencrypt/example/profile.go | 178 ++++++------------ .../stackencrypt/example/profile_test.go | 29 --- languages/golang/stackencrypt/guest.go | 134 ++----------- languages/golang/stackencrypt/guest_test.go | 18 +- languages/golang/stackencrypt/memory_test.go | 24 ++- languages/golang/stackencrypt/unit_test.go | 5 +- scripts/go-binding-test.sh | 2 +- 20 files changed, 594 insertions(+), 383 deletions(-) create mode 100644 languages/golang/stackauth/mount.go delete mode 100644 languages/golang/stackencrypt/example/profile_test.go diff --git a/.github/imported-workflows/test-wasi.yml b/.github/imported-workflows/test-wasi.yml index db1875929..0859e306f 100644 --- a/.github/imported-workflows/test-wasi.yml +++ b/.github/imported-workflows/test-wasi.yml @@ -143,7 +143,7 @@ jobs: run: | ulimit -l "$(ulimit -H -l)" echo "RLIMIT_MEMLOCK: $(ulimit -l) KiB" - mise run go:stackencrypt:test + mise run go:test # The same Go binding on macOS and Windows, against the guest Linux built. # Today that is the crypto binding, which had never run on either. The diff --git a/docs/plans/stack-encrypt-go-bindings.md b/docs/plans/stack-encrypt-go-bindings.md index ed0d5d48c..3975edc0a 100644 --- a/docs/plans/stack-encrypt-go-bindings.md +++ b/docs/plans/stack-encrypt-go-bindings.md @@ -430,7 +430,7 @@ package, with `internal/guest` holding what the guest packages share, temporary until publishing). It imports `vcffi` + `vcvalue` from vitaminc (pseudo-versioned to a main commit; no fork), embeds the guest from `wasm/` (copied by `wasm:guest:build`, gitignored), and is gated by -`go:stackencrypt:test` in `test-wasi.yml`. Where the shipped surface +`go:test` in `test-wasi.yml`. Where the shipped surface differs from the sketch below, the shipped one follows CIP-4037: one instance per `Client` and no cipher handle, so `NewClient` takes the ZeroKMS credentials and initialises the cipher, `Client.Keyset(selector)` diff --git a/languages/golang/stackauth/README.md b/languages/golang/stackauth/README.md index a2dab0ef3..169c845b7 100644 --- a/languages/golang/stackauth/README.md +++ b/languages/golang/stackauth/README.md @@ -62,7 +62,10 @@ refuses a token at its real expiry with `stackauth.ErrTokenExpired`. Exactly one directory, mounted read-write at a fixed guest path, and no environment. It cannot name a path outside it: every path is built by the Rust crate from the store's directory and a validated filename or -workspace id. Files it creates are mode 0600. It takes no file lock (WASI +workspace id, and the mount itself is confined — a symlink inside the +profile that leads outside it is refused, for reads and for the one write, +rather than followed with the process's permissions as a plain directory +mount would. Files it creates are mode 0600. It takes no file lock (WASI preview 1 has none); the cross-process refresh lock the CLI holds is Go's to take, on the path `ProfileStore.LockPath` names, once refreshing lands. @@ -73,7 +76,7 @@ existing: it still has no filesystem and no environment. ``` mise run wasm:auth-guest:build # the guest, with its import-surface gate -mise run go:stackencrypt:test # the whole Go module, both packages +mise run go:test # the whole Go module, both packages ``` The guest module is embedded from `wasm/` and not committed; without it, diff --git a/languages/golang/stackauth/doc.go b/languages/golang/stackauth/doc.go index a57535fd3..04b738983 100644 --- a/languages/golang/stackauth/doc.go +++ b/languages/golang/stackauth/doc.go @@ -9,7 +9,9 @@ // A [ProfileStore] is one guest instance over one mounted directory. [Resolve] // finds the profile directory the way the Rust crate does (CS_CONFIG_PATH, // then ~/.cipherstash); [Open] takes one. The guest is given that directory -// and nothing else: no environment, no other path, no network. Everything +// and nothing else: no environment, no other path, no network, and no way +// out through a symlink inside it, which the mount refuses to follow. +// Everything // the napi binding of stack-profile exposes is a method here, named as in // Rust: the current workspace ([ProfileStore.CurrentWorkspace], // [ProfileStore.SetCurrentWorkspace], [ProfileStore.ClearCurrentWorkspace]), diff --git a/languages/golang/stackauth/guest.go b/languages/golang/stackauth/guest.go index 5d0af3189..2746546ff 100644 --- a/languages/golang/stackauth/guest.go +++ b/languages/golang/stackauth/guest.go @@ -12,6 +12,7 @@ import ( "github.com/tetratelabs/wazero" "github.com/tetratelabs/wazero/api" "github.com/tetratelabs/wazero/experimental" + "github.com/tetratelabs/wazero/experimental/sysfs" "github.com/tetratelabs/wazero/imports/wasi_snapshot_preview1" ) @@ -62,6 +63,8 @@ type instance struct { module api.Module mem *guest.Allocator exports guest.Exports + // mount is the one directory the guest sees, confined to itself. + mount *confinedFS shutdown api.Function currentWorkspace, setCurrentWorkspace, clearCurrentWorkspace api.Function @@ -70,18 +73,19 @@ type instance struct { } // guestModuleConfig is the module configuration every guest instance runs -// under: the one directory mount, and nothing else that grants a -// capability. No environment: the guest is told its directory, it never -// looks one up. random_get is wired to crypto/rand because the crate's -// atomic rewrite names its staging file with a UUID drawn through it; -// wazero's default is a fixed seed, which would make two instances agree -// on that name. The clocks are the system's for the same reason they are -// in stackencrypt: a deterministic default is the wrong default for -// anything that reads time. Each is pinned by a test. -func guestModuleConfig(hostDir string) wazero.ModuleConfig { +// under: the one directory mount, confined to itself (see confinedFS), and +// nothing else that grants a capability. No environment: the guest is told +// its directory, it never looks one up. random_get is wired to crypto/rand +// because wazero's default is a fixed seed: the Rust runtime draws through +// it (its hash maps are seeded from it, for one), and nothing a guest does +// should be predictable across instances. The clocks are the system's for +// the same reason they are in stackencrypt: a deterministic default is the +// wrong default for anything that reads time. Each is pinned by a test. +func guestModuleConfig(mount *confinedFS) wazero.ModuleConfig { + fsConfig := wazero.NewFSConfig().(sysfs.FSConfig).WithSysFSMount(mount, guestRoot) return wazero.NewModuleConfig(). WithName("stack_auth_guest"). - WithFSConfig(wazero.NewFSConfig().WithDirMount(hostDir, guestRoot)). + WithFSConfig(fsConfig). WithRandSource(rand.Reader). WithSysNanotime(). WithSysWalltime() @@ -92,13 +96,21 @@ func guestModuleConfig(hostDir string) wazero.ModuleConfig { // policy, memory that cannot be locked fails instantiation with // ErrMemoryLock. func newInstance(ctx context.Context, wasm []byte, hostDir string, policy guest.LockPolicy) (*instance, error) { + mount, err := newConfinedFS(hostDir) + if err != nil { + return nil, fmt.Errorf("%w: %s: %w", ErrNoProfile, hostDir, err) + } config := wazero.NewRuntimeConfig(). WithCompilationCache(compilationCache()). WithCloseOnContextDone(true) runtime := wazero.NewRuntimeWithConfig(ctx, config) - if _, err := wasi_snapshot_preview1.Instantiate(ctx, runtime); err != nil { + fail := func(err error) (*instance, error) { _ = runtime.Close(ctx) - return nil, fmt.Errorf("stackauth: instantiating WASI: %w", err) + _ = mount.Close() + return nil, err + } + if _, err := wasi_snapshot_preview1.Instantiate(ctx, runtime); err != nil { + return fail(fmt.Errorf("stackauth: instantiating WASI: %w", err)) } mem := guest.NewAllocator(policy) // The guest is a reactor (cdylib): no _start. wazero runs _initialize @@ -107,22 +119,20 @@ func newInstance(ctx context.Context, wasm []byte, hostDir string, policy guest. mem.Enter() module, err := func() (api.Module, error) { defer mem.Exit() - return runtime.InstantiateWithConfig(experimental.WithMemoryAllocator(ctx, mem), wasm, guestModuleConfig(hostDir)) + return runtime.InstantiateWithConfig(experimental.WithMemoryAllocator(ctx, mem), wasm, guestModuleConfig(mount)) }() if err != nil { - _ = runtime.Close(ctx) if g := mem.GrowthRefusal(); g.Refused != 0 { - return nil, fmt.Errorf("%w: %w", guest.MemoryLockError(g.Reason), err) + return fail(fmt.Errorf("%w: %w", guest.MemoryLockError(g.Reason), err)) } - return nil, fmt.Errorf("stackauth: instantiating guest: %w", err) + return fail(fmt.Errorf("stackauth: instantiating guest: %w", err)) } if policy == guest.Strict { if lerr := mem.LockError(); lerr != nil { - _ = runtime.Close(ctx) - return nil, guest.MemoryLockError(lerr) + return fail(guest.MemoryLockError(lerr)) } } - inst := &instance{runtime: runtime, module: module, mem: mem} + inst := &instance{runtime: runtime, module: module, mem: mem, mount: mount} exports := map[string]*api.Function{ "se_alloc": &inst.exports.Alloc, "se_dealloc": &inst.exports.Dealloc, @@ -139,8 +149,7 @@ func newInstance(ctx context.Context, wasm []byte, hostDir string, policy guest. } for name, slot := range exports { if *slot = module.ExportedFunction(name); *slot == nil { - _ = runtime.Close(ctx) - return nil, fmt.Errorf("stackauth: guest is missing export %s", name) + return fail(fmt.Errorf("stackauth: guest is missing export %s", name)) } } return inst, nil @@ -148,9 +157,9 @@ func newInstance(ctx context.Context, wasm []byte, hostDir string, policy guest. // release runs the guest's shutdown — every buffer it still holds wiped — // and closes the runtime, which frees the linear memory through the -// allocator's wipe. A module an interrupted call or a trap already closed -// cannot run sa_shutdown; the runtime close still wipes and frees its -// memory. +// allocator's wipe, then the directory handle the mount holds. A module an +// interrupted call or a trap already closed cannot run sa_shutdown; the +// runtime close still wipes and frees its memory. func (inst *instance) release() error { ctx := context.Background() if !inst.module.IsClosed() { @@ -158,7 +167,11 @@ func (inst *instance) release() error { _, _ = inst.shutdown.Call(ctx) inst.mem.Exit() } - return inst.runtime.Close(ctx) + err := inst.runtime.Close(ctx) + if cerr := inst.mount.Close(); err == nil { + err = cerr + } + return err } // call drives one export with string arguments, through the shared diff --git a/languages/golang/stackauth/guest/Cargo.lock b/languages/golang/stackauth/guest/Cargo.lock index 09afb14bd..841d8d5d1 100644 --- a/languages/golang/stackauth/guest/Cargo.lock +++ b/languages/golang/stackauth/guest/Cargo.lock @@ -1777,6 +1777,7 @@ dependencies = [ "tempfile", "vitaminc-aead-value", "vitaminc-protected", + "zeroize", ] [[package]] diff --git a/languages/golang/stackauth/guest/Cargo.toml b/languages/golang/stackauth/guest/Cargo.toml index bc6f46ad7..24e4546a8 100644 --- a/languages/golang/stackauth/guest/Cargo.toml +++ b/languages/golang/stackauth/guest/Cargo.toml @@ -24,10 +24,7 @@ publish = false crate-type = ["cdylib", "rlib"] [dependencies] -# The profile directory's API. It builds for wasm32-wasip1 (CIP-4114): the -# hostname and the process id are native-only there, and creating a device -# identity is CLI territory. -stack-profile = { path = "../../../../packages/stack-profile" } +serde = { version = "1", features = ["derive"] } # `Token`, the typed shape of `auth.json`, with default features off: no # reqwest, no native TLS. The strategies arrive with the auth half. stack-auth = { path = "../../../../packages/stack-auth", default-features = false } @@ -36,18 +33,22 @@ stack-auth = { path = "../../../../packages/stack-auth", default-features = fals # packed-result helpers and the status table. This crate defines only the # exports that are its own. stack-guest-abi = { path = "../../../../packages/stack-guest-abi" } - +# The profile directory's API. It builds for wasm32-wasip1 (CIP-4114): the +# hostname and the process id are native-only there, and creating a device +# identity is CLI territory. +stack-profile = { path = "../../../../packages/stack-profile" } # The FFI codec for the structured results (a list of workspace ids, the # fields of a token): the one codec every binding carries. Same vitaminc # version the crypto guest builds against. vitaminc-aead-value = "0.5.0" -vitaminc-protected = "0.5.0" - -serde = { version = "1", features = ["derive"] } +# The deserialized form of `secretkey.json` is wiped when it drops. +zeroize = { version = "1", features = ["derive"] } [dev-dependencies] -tempfile = "3" serde_json = "1" +tempfile = "3" +# `Controlled::risky_ref`, to read a codec value back in the tests. +vitaminc-protected = "0.5.0" [profile.release] # Smaller .wasm; the guest is IO-bound on the FFI copy and the file reads, diff --git a/languages/golang/stackauth/guest/src/ops.rs b/languages/golang/stackauth/guest/src/ops.rs index 6f1d78458..54c293bc1 100644 --- a/languages/golang/stackauth/guest/src/ops.rs +++ b/languages/golang/stackauth/guest/src/ops.rs @@ -31,15 +31,19 @@ use serde::Deserialize; use stack_auth::Token; use stack_profile::{DeviceIdentity, ProfileStore}; use vitaminc_aead_value::{transport as codec, FfiValue}; +use zeroize::{Zeroize, ZeroizeOnDrop}; use crate::status::{status_for_profile, STATUS_ENCODING, STATUS_INTERNAL}; /// The file `secretkey.json`, as `stack-auth`'s device client writes it /// and `stack-kms`'s `SecretKey` reads it: the ZeroKMS client id and the -/// client key material, standard padded base64. Read here as text on both -/// sides — the key crosses to the host in the form the file holds, which -/// is one of the two forms `stackencrypt`'s config takes. -#[derive(Deserialize)] +/// client key material, standard padded base64. The key crosses to the +/// host in the form the file holds, which is one of the two forms +/// `stackencrypt`'s config takes — as bytes, so the host gets a slice it +/// can wipe rather than a string it cannot. What is deserialized here is +/// wiped when it drops; what is moved out of it into the codec value is +/// wiped by the codec's own protected types. +#[derive(Deserialize, Zeroize, ZeroizeOnDrop)] struct SecretKeyFile { client_id: String, client_key: String, @@ -150,15 +154,22 @@ fn path_bytes(path: &std::path::Path) -> Result<Vec<u8>, u32> { } /// `secretkey.json` in this store, as a codec object `{client_id, -/// client_key}` of two strings: the client id, and the key material as the -/// file holds it. +/// client_key}`: the client id as a string, and the key material as +/// bytes, in the form the file holds it. pub fn secret_key(dir: &[u8]) -> Result<Vec<u8>, u32> { - let file: SecretKeyFile = store(dir)? + let mut file: SecretKeyFile = store(dir)? .load(SECRET_KEY_FILENAME) .map_err(|e| status_for_profile(&e))?; + // Moved out rather than copied: `SecretKeyFile` wipes on drop, so its + // fields cannot be moved out of it directly. + let client_id = std::mem::take(&mut file.client_id); + let client_key = std::mem::take(&mut file.client_key); encode(FfiValue::Object(vec![ - ("client_id".to_string(), string(file.client_id)), - ("client_key".to_string(), string(file.client_key)), + ("client_id".to_string(), string(client_id)), + ( + "client_key".to_string(), + FfiValue::Bytes(client_key.into_bytes().into()), + ), ])) } @@ -239,6 +250,14 @@ mod tests { String::from_utf8(s.risky_ref().to_vec()).unwrap() } + fn as_bytes(value: &FfiValue) -> Vec<u8> { + use vitaminc_protected::Controlled; + let FfiValue::Bytes(b) = value else { + panic!("not bytes"); + }; + b.risky_ref().to_vec() + } + /// The names this guest spells are the crates' own. #[test] fn spelled_filenames_are_the_crates() { @@ -359,7 +378,11 @@ mod tests { as_text(field(&key, "client_id")), "6a70bd18-99ac-4650-b104-37eec3a15b09" ); - assert_eq!(as_text(field(&key, "client_key")), "AAECAw=="); + assert_eq!( + as_bytes(field(&key, "client_key")), + b"AAECAw==", + "the key crosses as bytes, in the form the file holds" + ); std::fs::write( t.path().join("auth.json"), diff --git a/languages/golang/stackauth/mount.go b/languages/golang/stackauth/mount.go new file mode 100644 index 000000000..f4ef72e3d --- /dev/null +++ b/languages/golang/stackauth/mount.go @@ -0,0 +1,163 @@ +package stackauth + +import ( + "errors" + "io/fs" + "os" + "path" + + experimentalsys "github.com/tetratelabs/wazero/experimental/sys" + "github.com/tetratelabs/wazero/experimental/sysfs" + "github.com/tetratelabs/wazero/sys" +) + +// confinedFS is the one directory the guest is given, held to that +// directory. wazero's own directory mount joins the guest's path onto the +// host directory and lets the operating system resolve it, symlinks +// included, with the host process's permissions: a profile entry that is +// a symlink to a file elsewhere would read, and a current_workspace that +// is a symlink would write, outside the boundary ADR-0005 draws. Here +// every path the guest names is first resolved by os.Root — which follows +// symlinks only while they stay inside the directory and refuses the ones +// that leave it — and only a path that stays inside reaches the mount. +// +// The check runs before the operation rather than as part of it: os.Root +// answers where the path leads, and wazero's mount then performs the +// operation on the host path. A change to the directory between the two +// is not defended against, deliberately. The profile directory is the +// user's own, and anything able to race a symlink into it can read the +// credentials directly; the boundary here is against what the directory +// contains, not against a concurrent attacker inside it. +type confinedFS struct { + // FS is wazero's directory mount, which performs every operation. + experimentalsys.FS + root *os.Root +} + +func newConfinedFS(dir string) (*confinedFS, error) { + root, err := os.OpenRoot(dir) + if err != nil { + return nil, err + } + return &confinedFS{FS: sysfs.DirFS(dir), root: root}, nil +} + +// Close releases the directory handle the root holds. +func (c *confinedFS) Close() error { return c.root.Close() } + +// confine is the errno an operation on p should fail with instead of +// running, or zero when p resolves inside the directory. p is as wazero +// hands it: relative to the mount, "." for the mount itself. A path that +// does not exist yet is confined when its parent is, which is what a +// create needs; a symlink that leaves the directory, at any component, is +// a refusal, as is a component that is not a directory. +func (c *confinedFS) confine(p string) experimentalsys.Errno { + _, err := c.root.Stat(p) + switch { + case err == nil: + return 0 + case !errors.Is(err, fs.ErrNotExist): + return experimentalsys.EACCES + } + parent := path.Dir(p) + if parent == p { + return 0 + } + if _, err := c.root.Stat(parent); err != nil { + if errors.Is(err, fs.ErrNotExist) { + return experimentalsys.ENOENT + } + return experimentalsys.EACCES + } + return 0 +} + +func (c *confinedFS) OpenFile(p string, flag experimentalsys.Oflag, perm fs.FileMode) (experimentalsys.File, experimentalsys.Errno) { + if errno := c.confine(p); errno != 0 { + return nil, errno + } + return c.FS.OpenFile(p, flag, perm) +} + +func (c *confinedFS) Lstat(p string) (sys.Stat_t, experimentalsys.Errno) { + if errno := c.confine(p); errno != 0 { + return sys.Stat_t{}, errno + } + return c.FS.Lstat(p) +} + +func (c *confinedFS) Stat(p string) (sys.Stat_t, experimentalsys.Errno) { + if errno := c.confine(p); errno != 0 { + return sys.Stat_t{}, errno + } + return c.FS.Stat(p) +} + +func (c *confinedFS) Mkdir(p string, perm fs.FileMode) experimentalsys.Errno { + if errno := c.confine(p); errno != 0 { + return errno + } + return c.FS.Mkdir(p, perm) +} + +func (c *confinedFS) Chmod(p string, perm fs.FileMode) experimentalsys.Errno { + if errno := c.confine(p); errno != 0 { + return errno + } + return c.FS.Chmod(p, perm) +} + +func (c *confinedFS) Rename(from, to string) experimentalsys.Errno { + if errno := c.confine(from); errno != 0 { + return errno + } + if errno := c.confine(to); errno != 0 { + return errno + } + return c.FS.Rename(from, to) +} + +func (c *confinedFS) Rmdir(p string) experimentalsys.Errno { + if errno := c.confine(p); errno != 0 { + return errno + } + return c.FS.Rmdir(p) +} + +func (c *confinedFS) Unlink(p string) experimentalsys.Errno { + if errno := c.confine(p); errno != 0 { + return errno + } + return c.FS.Unlink(p) +} + +func (c *confinedFS) Link(oldPath, newPath string) experimentalsys.Errno { + if errno := c.confine(oldPath); errno != 0 { + return errno + } + if errno := c.confine(newPath); errno != 0 { + return errno + } + return c.FS.Link(oldPath, newPath) +} + +func (c *confinedFS) Symlink(oldPath, linkName string) experimentalsys.Errno { + if errno := c.confine(linkName); errno != 0 { + return errno + } + return c.FS.Symlink(oldPath, linkName) +} + +func (c *confinedFS) Readlink(p string) (string, experimentalsys.Errno) { + if errno := c.confine(p); errno != 0 { + return "", errno + } + return c.FS.Readlink(p) +} + +func (c *confinedFS) Utimens(p string, atim, mtim int64) experimentalsys.Errno { + if errno := c.confine(p); errno != 0 { + return errno + } + return c.FS.Utimens(p, atim, mtim) +} diff --git a/languages/golang/stackauth/store.go b/languages/golang/stackauth/store.go index 1806bece0..320492f98 100644 --- a/languages/golang/stackauth/store.go +++ b/languages/golang/stackauth/store.go @@ -69,8 +69,11 @@ type root struct { // when set and not blank, else ~/.cipherstash. The directory must exist; // `stash auth login` creates it. func Resolve(ctx context.Context, opts ...Option) (*ProfileStore, error) { - dir := strings.TrimSpace(os.Getenv("CS_CONFIG_PATH")) - if dir == "" { + // Blankness is tested on the trimmed value and the value itself is + // used, as ProfileStore::resolve does: a directory named with a space + // in it is the directory it names. + dir := os.Getenv("CS_CONFIG_PATH") + if strings.TrimSpace(dir) == "" { home, err := os.UserHomeDir() if err != nil { return nil, fmt.Errorf("stackauth: no home directory and CS_CONFIG_PATH is unset: %w", err) @@ -179,6 +182,7 @@ func (s *ProfileStore) call(ctx context.Context, fn export, args ...string) ([]b r.closed = true return nil, ErrState } + growth := r.inst.mem.GrowthRefusal() out, err := r.inst.call(ctx, fn(r.inst), append([]string{s.dir}, args...)...) switch { case r.inst.module.IsClosed(): @@ -192,6 +196,15 @@ func (s *ProfileStore) call(ctx context.Context, fn export, args ...string) ([]b _ = r.inst.module.Close(context.Background()) err = fmt.Errorf("%w; the profile is closed", err) } + // Under RequireLockedMemory a growth that cannot be locked is refused, + // and the guest sees only a failed allocation — or, for an allocation + // of its own, aborts, and the trap closed the profile above. Name the + // real cause either way, as stackencrypt's Client.call does. The + // refusal is this call's, not the store's: the range went back unused, + // so MemoryLocked still holds. + if g := r.inst.mem.GrowthRefusal(); err != nil && g.Refused != growth.Refused { + err = fmt.Errorf("%w (growth refused under RequireLockedMemory): %w", guest.MemoryLockError(g.Reason), err) + } if err != nil { return nil, err } @@ -273,11 +286,24 @@ func (s *ProfileStore) CurrentWorkspaceStore(ctx context.Context) (*ProfileStore // refresh, once this package refreshes — since the guest cannot; this // package never composes a profile path itself. func (s *ProfileStore) LockPath(ctx context.Context, filename string) (string, error) { + // The guest validates the filename as the crate does, against the + // guest's separator, which is `/`. The host's is checked here: on + // Windows a backslash passes the guest and would become a separator + // once the answer is mapped back. And the mapped answer is checked to + // be a direct child of this store before it is returned, so the + // sibling-lock contract holds whatever the guest said. + if filename == "" || filename == "." || filename == ".." || strings.ContainsAny(filename, `/\`) { + return "", fmt.Errorf("%w: %q", ErrInvalidFilename, filename) + } out, err := s.call(ctx, func(i *instance) api.Function { return i.lockPath }, filename) if err != nil { return "", err } - return s.hostPath(string(out)), nil + mapped := s.hostPath(string(out)) + if rel, err := filepath.Rel(s.Dir(), mapped); err != nil || filepath.Dir(rel) != "." || rel == "." || rel == ".." { + return "", fmt.Errorf("%w: %q does not name a file beside %s", ErrInvalidFilename, filename, s.Dir()) + } + return mapped, nil } // SecretKey reads secretkey.json in this store (a workspace store; the @@ -299,13 +325,14 @@ func (s *ProfileStore) SecretKey(ctx context.Context) (clientID string, key *Cli if err != nil { return "", nil, err } - material, err := fields.text("client_key") + // The key crosses as bytes, not text, so the decoder hands back a + // slice this package owns: the ClientKey takes it, and wipes it when + // it is consumed. No string copy of the material is ever made here. + material, err := fields.bytes("client_key") if err != nil { return "", nil, err } - // The one string copy the codec made is the collector's; the bytes the - // key holds are wiped when it is consumed. - return clientID, guest.NewClientKey([]byte(material)), nil + return clientID, guest.NewClientKey(material), nil } // DeviceIdentity is the identity the CLI created for this machine, read @@ -375,6 +402,20 @@ func (f fields) text(key string) (string, error) { return s, nil } +// bytes is a bytes field that must be present. The decoder allocates the +// slice, so it is the caller's to keep or wipe. +func (f fields) bytes(key string) ([]byte, error) { + v, ok := f.get(key) + if !ok { + return nil, fmt.Errorf("%w: the guest's result has no %s", ErrInternal, key) + } + b, ok := v.([]byte) + if !ok { + return nil, fmt.Errorf("%w: %s is not bytes", ErrInternal, key) + } + return b, nil +} + // optionalText is a string field that may be null or absent. func (f fields) optionalText(key string) (string, error) { v, ok := f.get(key) diff --git a/languages/golang/stackauth/store_test.go b/languages/golang/stackauth/store_test.go index 2134a1b67..73b0c0765 100644 --- a/languages/golang/stackauth/store_test.go +++ b/languages/golang/stackauth/store_test.go @@ -14,6 +14,7 @@ import ( "github.com/cipherstash/cipherstash-suite/bindings/go/internal/guest" "github.com/tetratelabs/wazero" + "github.com/tetratelabs/wazero/api" ) const ( @@ -129,13 +130,34 @@ func TestResolveHonoursConfigPath(t *testing.T) { t.Fatalf("Dir = %q, want %q", s.Dir(), dir) } t.Setenv("CS_CONFIG_PATH", " ") - t.Setenv("HOME", filepath.Join(t.TempDir(), "nohome")) + // os.UserHomeDir reads HOME on Unix and USERPROFILE on Windows. + nohome := filepath.Join(t.TempDir(), "nohome") + t.Setenv("HOME", nohome) + t.Setenv("USERPROFILE", nohome) if _, err := Resolve(context.Background()); !errors.Is(err, ErrNoProfile) { t.Fatalf("Resolve with a blank CS_CONFIG_PATH and no ~/.cipherstash: %v, want ErrNoProfile", err) } + // A non-blank value is used as it is, as the crate uses it: a directory + // whose name carries whitespace is the directory it names. (Windows + // trims a trailing space off a directory name itself.) + if runtime.GOOS != "windows" { + spaced := filepath.Join(t.TempDir(), " spaced ") + if err := os.Mkdir(spaced, 0o700); err != nil { + t.Fatal(err) + } + t.Setenv("CS_CONFIG_PATH", spaced) + s, err := Resolve(context.Background()) + if err != nil { + t.Fatalf("Resolve with CS_CONFIG_PATH naming a directory with spaces in its name: %v", err) + } + defer s.Close() + if s.Dir() != spaced { + t.Fatalf("Dir = %q, want the value verbatim, %q", s.Dir(), spaced) + } + } } -func TestWorkspaceSelection(t *testing.T) { +func TestWorkspaceSelectionRoundTripsAndLists(t *testing.T) { ctx := context.Background() _, s := profile(t) if _, err := s.CurrentWorkspace(ctx); !errors.Is(err, ErrNoCurrentWorkspace) { @@ -210,7 +232,7 @@ func TestWorkspaceStoresAreScopedAndShareTheGuest(t *testing.T) { } } -func TestTypedReads(t *testing.T) { +func TestTypedReadsReturnTheFilesFields(t *testing.T) { ctx := context.Background() dir, s := profile(t) ws, err := s.WorkspaceStore(ctx, wsA) @@ -237,14 +259,17 @@ func TestTypedReads(t *testing.T) { if err != nil { t.Fatal(err) } + // The access token is a credential; a failure names what was wrong + // with it rather than printing it. if !strings.HasPrefix(tok.AccessToken, "tok-") || tok.TokenType != "Bearer" || tok.Region != "ap-southeast-2" { - t.Errorf("Token = %+v", tok) + t.Errorf("Token: access token has the stub's prefix = %t, type = %q, region = %q", + strings.HasPrefix(tok.AccessToken, "tok-"), tok.TokenType, tok.Region) } if !tok.Usable(time.Now()) || tok.ExpiresAt.Before(time.Now().Add(50*time.Minute)) { t.Errorf("ExpiresAt = %s, want about an hour away", tok.ExpiresAt) } if tok.ClientID != "" || tok.DeviceInstanceID != "" { - t.Errorf("absent optional fields decoded as %+v", tok) + t.Errorf("absent optional fields decoded as client_id = %q, device_instance_id = %q", tok.ClientID, tok.DeviceInstanceID) } identity, err := s.DeviceIdentity(ctx) @@ -331,7 +356,10 @@ func TestLockPathIsTheCratesAndValidated(t *testing.T) { if _, err := os.Stat(path); !errors.Is(err, os.ErrNotExist) { t.Fatalf("naming the lock file created it: %v", err) } - for _, bad := range []string{"", "../auth.json", "/etc/auth.json", "a/b"} { + // The host's separator is refused as well as the guest's: on Windows a + // backslash passes the crate's check (its separator is `/` on wasm32) + // and would become a path once mapped back to the host. + for _, bad := range []string{"", ".", "..", "../auth.json", "/etc/auth.json", "a/b", `a\b`, `x\..\..\outside`} { if _, err := ws.LockPath(ctx, bad); !errors.Is(err, ErrInvalidFilename) { t.Errorf("LockPath(%q) = %v, want ErrInvalidFilename", bad, err) } @@ -384,7 +412,7 @@ func TestTheGuestCannotSeeOutsideTheMount(t *testing.T) { // The guest's memory is the shared allocator's: the store reports its lock // state like a client does, and RequireLockedMemory is honoured. -func TestMemoryIsReported(t *testing.T) { +func TestMemoryStateIsReportedAndStrictIsHonoured(t *testing.T) { _, s := profile(t) if s.MemoryLocked() != (s.MemoryLockError() == nil) { t.Fatal("MemoryLocked and MemoryLockError disagree") @@ -438,3 +466,118 @@ func TestGuestRootMatchesTheGuest(t *testing.T) { t.Fatal("the default policy is not best effort") } } + +// The mount is confined to the directory it names, symlinks included: a +// symlink inside the profile that leads outside it is refused, for a read +// and for the one write the guest makes, while a symlink that stays inside +// is the file it names. wazero's own directory mount would follow all of +// them with the process's permissions. +func TestASymlinkOutOfTheMountIsRefused(t *testing.T) { + ctx := context.Background() + dir, s := profile(t) + outside := t.TempDir() + symlink := func(target, link string) { + t.Helper() + if err := os.Symlink(target, link); err != nil { + t.Skipf("cannot create a symlink here: %v", err) + } + } + + // A read through a symlinked file: device.json leads outside. + elsewhere := filepath.Join(outside, "device.json") + write(t, elsewhere, deviceJSON) + if err := os.Remove(filepath.Join(dir, "device.json")); err != nil { + t.Fatal(err) + } + symlink(elsewhere, filepath.Join(dir, "device.json")) + if _, err := s.DeviceIdentity(ctx); err == nil { + t.Fatal("the guest read a file outside the mount through a symlink") + } else if !errors.Is(err, ErrIO) { + t.Fatalf("reading through an escaping symlink: %v, want ErrIO", err) + } + + // A read through a symlinked directory: workspaces/<id> leads outside. + escapedWorkspace := filepath.Join(outside, "ws") + if err := os.Mkdir(escapedWorkspace, 0o700); err != nil { + t.Fatal(err) + } + write(t, filepath.Join(escapedWorkspace, "auth.json"), authJSON(time.Now().Add(time.Hour).Unix())) + const wsC = "CCCCCCCCCCCCCCCC" + symlink(escapedWorkspace, filepath.Join(dir, "workspaces", wsC)) + ws, err := s.WorkspaceStore(ctx, wsC) + if err != nil { + t.Fatal(err) + } + if _, err := ws.Token(ctx); !errors.Is(err, ErrIO) { + t.Fatalf("reading through an escaping symlinked directory: %v, want ErrIO", err) + } + + // The one write, through a symlinked file: current_workspace leads to + // a file outside, which must be left as it was. + victim := filepath.Join(outside, "victim") + write(t, victim, "untouched") + symlink(victim, filepath.Join(dir, "current_workspace")) + if err := s.SetCurrentWorkspace(ctx, wsA); !errors.Is(err, ErrIO) { + t.Fatalf("writing through an escaping symlink: %v, want ErrIO", err) + } + if got, err := os.ReadFile(victim); err != nil || string(got) != "untouched" { + t.Fatalf("the guest wrote outside the mount through a symlink: %q, %v", got, err) + } + + // A symlink that stays inside the mount is the directory it names. + const wsD = "DDDDDDDDDDDDDDDD" + symlink(wsA, filepath.Join(dir, "workspaces", wsD)) + same, err := s.WorkspaceStore(ctx, wsD) + if err != nil { + t.Fatal(err) + } + if _, err := same.Token(ctx); err != nil { + t.Fatalf("reading through a symlink that stays inside the mount: %v", err) + } +} + +// The profile directory itself may be a symlink — a dotfiles manager's +// usual arrangement — and is opened as the directory it names. +func TestAProfileDirectoryThatIsASymlinkOpens(t *testing.T) { + ctx := context.Background() + dir, _ := profile(t) + link := filepath.Join(t.TempDir(), "link") + if err := os.Symlink(dir, link); err != nil { + t.Skipf("cannot create a symlink here: %v", err) + } + s, err := Open(ctx, link) + if err != nil { + t.Fatalf("Open of a symlinked profile directory: %v", err) + } + defer s.Close() + if _, err := s.DeviceIdentity(ctx); err != nil { + t.Fatalf("reading through a symlinked profile directory: %v", err) + } +} + +// Under RequireLockedMemory a growth the lock limit refuses is reported as +// ErrMemoryLock naming the refusal, as a client reports it, and the store +// stays open and locked: the range went back unused. +func TestARefusedGrowthIsReportedAsMemoryLock(t *testing.T) { + ctx := context.Background() + _, s := profile(t) + refusing := guest.RefuseGrowth(s.root.inst.mem, errors.New("refused for the test")) + // Staging a 2 MiB argument into guest memory needs a growth, before the + // guest can refuse it as a workspace id. + huge := strings.Repeat("A", 2<<20) + _, err := s.call(ctx, func(i *instance) api.Function { return i.setCurrentWorkspace }, huge) + if !errors.Is(err, ErrMemoryLock) || !strings.Contains(err.Error(), "growth refused") || !strings.Contains(err.Error(), refusing.Reason().Error()) { + t.Fatalf("a call needing a refused growth: %v; want ErrMemoryLock naming the refusal", err) + } + if refusing.Refused() == 0 { + t.Fatal("the guest did not grow; the test proves nothing") + } + if !s.MemoryLocked() || s.MemoryLockError() != nil { + t.Fatalf("a refused growth changed the lock report: %v", s.MemoryLockError()) + } + // The store is still open, and grows once it can. + refusing.Allow() + if _, err := s.CurrentWorkspace(ctx); !errors.Is(err, ErrNoCurrentWorkspace) { + t.Fatalf("the next call, growth allowed: %v", err) + } +} diff --git a/languages/golang/stackencrypt/example/README.md b/languages/golang/stackencrypt/example/README.md index 2d6f35d06..61f89a16a 100644 --- a/languages/golang/stackencrypt/example/README.md +++ b/languages/golang/stackencrypt/example/README.md @@ -9,20 +9,22 @@ are meant to fail. ```bash stash auth login # once; the example reads ~/.cipherstash -mise run go:stackencrypt:example # builds the guest, then runs +mise run go:stackencrypt:example # builds both guests, then runs ``` Or, if you would rather drive it yourself: ```bash -mise run wasm:guest:build +mise run wasm:guest:build wasm:auth-guest:build cd bindings/go && go run ./stackencrypt/example ``` -The guest build is not optional. This package embeds -`wasm/stack_encrypt_guest.wasm`, which is gitignored, so a fresh checkout has -no guest and `NewClient` fails until one is built — and the Go side will not -notice a stale one, so rebuild after any change under `guest/src/`. +The guest builds are not optional. This package embeds +`wasm/stack_encrypt_guest.wasm` and `stackauth` embeds +`wasm/stack_auth_guest.wasm`; both are gitignored, so a fresh checkout has +no guests and `NewClient` / `Resolve` fail until they are built — and the Go +side will not notice a stale one, so rebuild after any change under either +`guest/src/`. ## What it shows @@ -33,34 +35,32 @@ notice a stale one, so rebuild after any change under `guest/src/`. | **A query** | An equality term derived from the value being searched for, matched against the stored terms. The same value under another field's context matches nothing — that is what stops a hit in one column being a hit in another. | | **Order** | ORE terms sorted, recovering the plaintext order without the plaintext. | -## Credentials, and a gap worth knowing about +## Credentials -`profile.go` is about half this example, and none of it is library code: it -reads `~/.cipherstash` by hand. - -That is not an oversight in the example. The Go binding's `Config` takes a -client id, a client key and a `TokenSource` explicitly, and has no profile or -environment fallback of its own. The Rust crate's fallback is native-only — -`stack-kms` gates the `profile` feature off `wasm32`, and the guest is wasm — -so nothing in that path is reachable from here. **Every Go application will -otherwise write this file for itself**, which is the argument for it moving -into the package. +`profile.go` reads the developer profile through +[`stackauth`](../../stackauth): `Resolve` finds the directory the Rust crate +would, `CurrentWorkspaceStore` scopes to the workspace `stash auth login` +selected, `SecretKey` hands out the client key as the opaque `ClientKey` +a `Config` takes, and `TokenSource` is the token source the client +authenticates with. Nothing in the example spells the profile's layout; +the `stack-profile` crate does, inside the credential guest, so the example +cannot drift from it. Two details in there that are easy to get wrong: - **The token is not static.** `TokenSource.Token` is called on *every* request, precisely so a token can change under a long-lived client. A profile token lasts 45 minutes, so pinning one with `StaticToken` gives you - a program that works and then stops. The example re-reads `auth.json` each - time instead, picking up whatever else refreshes it. -- **It never refreshes, on purpose.** The IdP rotates refresh tokens and - detects replay: two processes sharing `~/.cipherstash` that both exchange - the same one get the entire chain revoked, and every later attempt fails - with `invalid grant` until the user logs in again. Rust handles this with a - cross-process lock and a re-read after acquiring it (see - `stack-auth`'s `device_session_refresher`). Hand-rolling it here would put - a reader's real credentials at risk, so this example reads and never - writes. + a program that works and then stops. `stackauth`'s source re-reads + `auth.json` each time instead, picking up whatever else refreshes it, and + refuses a token at its real expiry with `stackauth.ErrTokenExpired`. +- **It never refreshes, yet.** Refreshing is the auth half of `stackauth` + (see ADR-0005): the IdP rotates refresh tokens and detects replay, so two + processes sharing `~/.cipherstash` that both exchange the same one get the + entire chain revoked. Rust handles this with a cross-process lock and a + re-read after acquiring it, and Go will take the same lock on the path + `ProfileStore.LockPath` names. Until then an expired token means + `stash auth login`. `CS_CLIENT_ID` / `CS_CLIENT_KEY` override the profile's client key. `CS_CONFIG_PATH` overrides the profile directory. The token always comes from diff --git a/languages/golang/stackencrypt/example/main.go b/languages/golang/stackencrypt/example/main.go index 1444400aa..d47390a73 100644 --- a/languages/golang/stackencrypt/example/main.go +++ b/languages/golang/stackencrypt/example/main.go @@ -3,8 +3,8 @@ // profile. // // stash auth login -// mise run wasm:guest:build # the embedded guest must be current -// go run ./example +// mise run wasm:guest:build wasm:auth-guest:build # both embedded guests +// go run ./stackencrypt/example # from bindings/go // // It walks the four things the binding does — seal a value, seal a record // with its index terms, probe those terms with a query, and open both again @@ -38,16 +38,19 @@ func main() { } func run() error { - creds, err := loadCredentials() + ctx := context.Background() + creds, err := loadCredentials(ctx) if err != nil { return err } - fmt.Printf("workspace %s (%s)\n", creds.Workspace, creds.describe()) + defer creds.Close() + fmt.Printf("workspace %s (%s)\n", creds.Workspace, creds.describe(ctx)) - ctx := context.Background() client, err := stackencrypt.NewClient(ctx, stackencrypt.Config{ - ClientID: creds.ClientID, - ClientKey: stackencrypt.NewClientKey([]byte(creds.ClientKey)), + ClientID: creds.ClientID, + // Read from the profile as the opaque type, consumed and wiped by + // NewClient. + ClientKey: creds.ClientKey, // Asked on every request, so the client follows the profile // rather than pinning one token; see profile.go. Token: creds.token(), diff --git a/languages/golang/stackencrypt/example/profile.go b/languages/golang/stackencrypt/example/profile.go index 9178a0483..2b33e9fe4 100644 --- a/languages/golang/stackencrypt/example/profile.go +++ b/languages/golang/stackencrypt/example/profile.go @@ -2,158 +2,92 @@ package main import ( "context" - "encoding/json" "fmt" "os" - "path/filepath" - "strings" "time" + + "github.com/cipherstash/cipherstash-suite/bindings/go/stackauth" + "github.com/cipherstash/cipherstash-suite/bindings/go/stackencrypt" ) -// Credentials from the developer profile `stash auth login` writes. -// -// The Go binding takes a client key and a bearer token explicitly. The -// profile fallback in the Rust crate is native-only — stack-kms gates it off -// wasm32 and the guest is wasm — so a Go program reads the profile itself, -// which is all this file does. There is no supported package-level -// equivalent yet; see the README. -// -// The layout, as stack-profile defines it: -// -// <root>/current_workspace the workspace id -// <root>/workspaces/<id>/secretkey.json client_id, client_key -// <root>/workspaces/<id>/auth.json access_token, expires_at -// -// where <root> is CS_CONFIG_PATH if set, else ~/.cipherstash. +// Credentials from the developer profile `stash auth login` writes, read +// through stackauth: the stack-profile crate itself, running in the +// credential guest, so nothing here spells the profile's layout. (An +// earlier version of this file read ~/.cipherstash by hand, which is the +// drift stackauth exists to end.) type credentials struct { ClientID string - ClientKey string + ClientKey *stackencrypt.ClientKey Workspace string - dir string -} - -type secretKeyFile struct { - ClientID string `json:"client_id"` - ClientKey string `json:"client_key"` + // profile owns the guest; Close releases it. The workspace store shares + // it and is closed with it. + profile *stackauth.ProfileStore + workspace *stackauth.ProfileStore } -type authFile struct { - AccessToken string `json:"access_token"` - ExpiresAt int64 `json:"expires_at"` - Region string `json:"region"` -} - -func loadCredentials() (credentials, error) { - var c credentials - - root := os.Getenv("CS_CONFIG_PATH") - if strings.TrimSpace(root) == "" { - home, err := os.UserHomeDir() - if err != nil { - return c, fmt.Errorf("no home directory and CS_CONFIG_PATH is unset: %w", err) - } - root = filepath.Join(home, ".cipherstash") - } - - workspace, err := os.ReadFile(filepath.Join(root, "current_workspace")) +func loadCredentials(ctx context.Context) (*credentials, error) { + // CS_CONFIG_PATH, else ~/.cipherstash, as the Rust crate resolves it. + profile, err := stackauth.Resolve(ctx) if err != nil { - return c, fmt.Errorf("no current workspace in %s — run `stash auth login`: %w", root, err) + return nil, err } - c.Workspace = strings.TrimSpace(string(workspace)) - if !validWorkspaceID(c.Workspace) { - return c, fmt.Errorf("current_workspace in %s is not a workspace id (%q) — run `stash auth login`", root, c.Workspace) + c := &credentials{profile: profile} + if c.workspace, err = profile.CurrentWorkspaceStore(ctx); err != nil { + profile.Close() + return nil, fmt.Errorf("no current workspace — run `stash auth login`: %w", err) + } + if c.Workspace, err = profile.CurrentWorkspace(ctx); err != nil { + profile.Close() + return nil, err } - c.dir = filepath.Join(root, "workspaces", c.Workspace) // The client key: the two environment variables win, as they do for the // Rust client, so this example can be pointed somewhere else without - // touching the profile. - c.ClientID, c.ClientKey = os.Getenv("CS_CLIENT_ID"), os.Getenv("CS_CLIENT_KEY") - if c.ClientID == "" || c.ClientKey == "" { - var key secretKeyFile - if err := readJSON(filepath.Join(c.dir, "secretkey.json"), &key); err != nil { - return c, fmt.Errorf("no client key — set CS_CLIENT_ID and CS_CLIENT_KEY, or run `stash auth login`: %w", err) - } - c.ClientID, c.ClientKey = key.ClientID, key.ClientKey + // touching the profile. Otherwise it is the workspace's secretkey.json, + // handed out as the opaque ClientKey a stackencrypt.Config takes. + if id, key := os.Getenv("CS_CLIENT_ID"), os.Getenv("CS_CLIENT_KEY"); id != "" && key != "" { + c.ClientID, c.ClientKey = id, stackencrypt.NewClientKey([]byte(key)) + } else if c.ClientID, c.ClientKey, err = c.workspace.SecretKey(ctx); err != nil { + profile.Close() + return nil, fmt.Errorf("no client key — set CS_CLIENT_ID and CS_CLIENT_KEY, or run `stash auth login`: %w", err) } - // Fail here rather than three calls later, with something actionable. - if _, err := c.token().Token(context.Background()); err != nil { - return c, err + // Fail here rather than three calls later, with something actionable: + // an expired stored token names `stash auth login`. + if _, err := c.token().Token(ctx); err != nil { + profile.Close() + return nil, err } return c, nil } -// token is the [stackencrypt.TokenSource] this example authenticates with. +// Close releases the profile guest, and with it the workspace store. +func (c *credentials) Close() error { return c.profile.Close() } + +// token is the [stackencrypt.TokenSource] this example authenticates with: +// stackauth's, over the workspace's auth.json. // // Deliberately not StaticToken: the binding asks its TokenSource on *every* // request precisely so that a token can change under a long-lived client, // and a profile token is good for 45 minutes. A static one turns that into // a program that works and then stops, which is the wrong thing to show. +// stackauth's source re-reads auth.json each time, so whatever keeps the +// profile fresh — the `stash` CLI, a Rust client in the same session — is +// picked up without restarting, and it refuses a token at its real expiry. // -// What it does instead is *follow* the profile: it re-reads auth.json each -// time, so whatever keeps the profile fresh — the `stash` CLI, a Rust -// client in the same session — is picked up without restarting. -// -// What it deliberately does not do is refresh. Exchanging the refresh token -// is not a few lines: the IdP rotates refresh tokens and detects replay, so -// two processes sharing ~/.cipherstash that both exchange the same one get -// the whole chain revoked — every later login fails with "invalid grant" -// until the user logs in again. The Rust side handles that with a -// cross-process lock and a re-read after acquiring it -// (stack-auth's `device_session_refresher`). An example that hand-rolled it -// would risk a reader's real credentials, so this one reads and never -// writes. -func (c credentials) token() stackencryptTokenSource { - return stackencryptTokenSource{path: filepath.Join(c.dir, "auth.json")} -} - -type stackencryptTokenSource struct{ path string } - -func (s stackencryptTokenSource) Token(context.Context) (string, error) { - var auth authFile - if err := readJSON(s.path, &auth); err != nil { - return "", fmt.Errorf("no access token in %s — run `stash auth login`: %w", s.path, err) - } - if expiry := time.Unix(auth.ExpiresAt, 0); time.Now().After(expiry) { - return "", fmt.Errorf("the profile's access token expired at %s — run `stash auth login`", - expiry.Format(time.RFC3339)) - } - return auth.AccessToken, nil -} +// What it does not do yet is refresh. Exchanging the refresh token is the +// auth half of stackauth: the IdP rotates refresh tokens and detects +// replay, so two processes sharing ~/.cipherstash that both exchange the +// same one get the whole chain revoked, and the Rust side handles that +// with a cross-process lock and a re-read after acquiring it. Until that +// half lands, an expired token means `stash auth login`. +func (c *credentials) token() stackencrypt.TokenSource { return c.workspace.TokenSource() } // describe reports what the profile currently holds, for the banner. -func (c credentials) describe() string { - var auth authFile - if err := readJSON(filepath.Join(c.dir, "auth.json"), &auth); err != nil { - return "unknown" - } - return fmt.Sprintf("%s, token good for %s", - auth.Region, time.Until(time.Unix(auth.ExpiresAt, 0)).Round(time.Minute)) -} - -// validWorkspaceID is stack-profile's rule for a workspace id: sixteen -// base32 characters (A-Z, 2-7). The id becomes a path component under -// workspaces/, and current_workspace is a plain file anything can write, so -// it is checked here as the Rust reader checks it — otherwise "../.." in that -// file reads credentials from outside the profile. -func validWorkspaceID(id string) bool { - if len(id) != 16 { - return false - } - for i := 0; i < len(id); i++ { - c := id[i] - if !(('A' <= c && c <= 'Z') || ('2' <= c && c <= '7')) { - return false - } - } - return true -} - -func readJSON(path string, into any) error { - b, err := os.ReadFile(path) +func (c *credentials) describe(ctx context.Context) string { + tok, err := c.workspace.Token(ctx) if err != nil { - return err + return "unknown" } - return json.Unmarshal(b, into) + return fmt.Sprintf("%s, token good for %s", tok.Region, time.Until(tok.ExpiresAt).Round(time.Minute)) } diff --git a/languages/golang/stackencrypt/example/profile_test.go b/languages/golang/stackencrypt/example/profile_test.go deleted file mode 100644 index adb2f53ea..000000000 --- a/languages/golang/stackencrypt/example/profile_test.go +++ /dev/null @@ -1,29 +0,0 @@ -package main - -import "testing" - -// The id is a path component, and the file it is read from is writable by -// anything on the machine, so the rule is the Rust reader's exactly. -func TestValidWorkspaceIDIsStackProfilesRule(t *testing.T) { - valid := []string{"ABCDEFGHIJKLMNOP", "A2B3C4D5E6F7G2H3", "2222222222222222"} - for _, id := range valid { - if !validWorkspaceID(id) { - t.Errorf("%q is sixteen base32 characters and must be accepted", id) - } - } - invalid := map[string]string{ - "": "empty", - "ABCDEFGHIJKLMNO": "fifteen characters", - "ABCDEFGHIJKLMNOPQ": "seventeen characters", - "abcdefghijklmnop": "lower case", - "ABCDEFGHIJKLMN01": "digits outside 2-7", - "../../../../../etc": "a path", - "ABCDEFG/IJKLMNOP": "a separator inside sixteen characters", - "ABCDEFGHIJKLMNO\n": "a trailing newline", - } - for id, why := range invalid { - if validWorkspaceID(id) { - t.Errorf("%q (%s) must be refused", id, why) - } - } -} diff --git a/languages/golang/stackencrypt/guest.go b/languages/golang/stackencrypt/guest.go index 85f07142f..3d12cfd51 100644 --- a/languages/golang/stackencrypt/guest.go +++ b/languages/golang/stackencrypt/guest.go @@ -29,13 +29,6 @@ const guestPath = "wasm/stack_encrypt_guest.wasm" // embedded and none was supplied in Config.Guest. var ErrGuestNotBuilt = errors.New("stackencrypt: guest module not built — run `mise run wasm:guest:build`") -// errGuestTrap marks a guest export that did not return: a trap (the -// guest builds with panic-as-abort, so an allocation it cannot make or an -// invariant it cannot keep ends in `unreachable`), or a module closed -// under it. Client.call closes the client on it: the guest's state after -// an abort is unknown, and its keys are better wiped than reused. -var errGuestTrap = errors.New("stackencrypt: guest did not return") - func embeddedGuest() ([]byte, error) { wasm, err := guestFS.ReadFile(guestPath) if err != nil { @@ -66,7 +59,7 @@ type instance struct { // on it. mem *guest.Allocator - alloc, dealloc api.Function + exports guest.Exports cipherInit, shutdown, keyset api.Function encrypt, encryptElement api.Function decrypt, decryptElement api.Function @@ -150,8 +143,8 @@ func newInstance(ctx context.Context, wasm []byte, t *transport, policy guest.Lo } inst := &instance{runtime: runtime, module: module, mem: mem} exports := map[string]*api.Function{ - "se_alloc": &inst.alloc, - "se_dealloc": &inst.dealloc, + "se_alloc": &inst.exports.Alloc, + "se_dealloc": &inst.exports.Dealloc, "se_cipher_init": &inst.cipherInit, "se_shutdown": &inst.shutdown, "se_keyset": &inst.keyset, @@ -189,117 +182,24 @@ func (inst *instance) release() error { return inst.runtime.Close(ctx) } -// guestBuf is a host-owned allocation inside guest linear memory. -type guestBuf struct { - ptr uint32 - len uint32 -} - -// allocWrite stages data into a fresh guest buffer. -func (inst *instance) allocWrite(ctx context.Context, data []byte) (guestBuf, error) { - res, err := inst.alloc.Call(ctx, uint64(len(data))) - if err != nil { - return guestBuf{}, fmt.Errorf("%w: guest alloc: %w", errGuestTrap, err) - } - buf := guestBuf{ptr: uint32(res[0]), len: uint32(len(data))} - if buf.ptr == 0 { - return guestBuf{}, errors.New("stackencrypt: guest allocation failed") - } - if len(data) > 0 && !inst.module.Memory().Write(buf.ptr, data) { - inst.free(ctx, buf) - return guestBuf{}, errors.New("stackencrypt: guest memory write out of range") - } - return buf, nil -} - -// free zeroizes and releases a guest buffer (se_dealloc wipes; an unknown -// pointer is a no-op there). It runs under a context that cannot be -// cancelled: a caller's deadline expiring after the guest call returned -// must not skip the wipe of the buffers that call staged. -func (inst *instance) free(ctx context.Context, buf guestBuf) { - if buf.ptr != 0 { - _, _ = inst.dealloc.Call(context.WithoutCancel(ctx), uint64(buf.ptr), uint64(buf.len)) - } -} - -// packedResult decodes the guest's packed u64 (guest.PackedResult): a -// non-zero high half is an output pointer with the length in the low half; -// a zero high half carries a status code, decoded to its sentinel. -func packedResult(packed uint64) (guestBuf, error) { - ptr, n, err := guest.PackedResult(packed) - if err != nil { - return guestBuf{}, err - } - return guestBuf{ptr: ptr, len: n}, nil -} +// The call plumbing — stage each buffer argument through se_alloc, call, +// copy the output out, wipe and free every buffer before returning — is +// internal/guest's, shared with every guest package so the discipline is +// written once. What follows are this package's names for it. -// arg is one guest-call argument: a buffer (staged into guest memory and -// passed as a (ptr, len) pair) or a scalar passed as is. -type arg struct { - data []byte - scalar uint64 - isBuf bool -} +// errGuestTrap marks a guest export that did not return. See guest.ErrTrap. +var errGuestTrap = guest.ErrTrap -func buf(data []byte) arg { return arg{data: data, isBuf: true} } -func scalar(v uint64) arg { return arg{scalar: v} } +func buf(data []byte) guest.Arg { return guest.BufArg(data) } +func scalar(v uint64) guest.Arg { return guest.ScalarArg(v) } // call stages every buffer argument, calls fn with the arguments in // order, and copies the output out before every buffer — inputs and output -// — is wiped and freed. -func (inst *instance) call(ctx context.Context, fn api.Function, args ...arg) ([]byte, error) { - // The memory stays mapped for the whole call, the deferred frees - // included: a close that lands mid-call (an expired context during a - // host import) is honoured by this exit, not under running guest - // code. See guest.Allocator.Free. - inst.mem.Enter() - defer inst.mem.Exit() - var bufs []guestBuf - defer func() { - for _, b := range bufs { - inst.free(ctx, b) - } - }() - params := make([]uint64, 0, 2*len(args)) - for _, a := range args { - if !a.isBuf { - params = append(params, a.scalar) - continue - } - staged, err := inst.allocWrite(ctx, a.data) - if err != nil { - return nil, err - } - bufs = append(bufs, staged) - params = append(params, uint64(staged.ptr), uint64(staged.len)) - } - out, err := inst.invoke(ctx, fn, params...) - if err != nil { - return nil, err - } - bufs = append(bufs, out) - view, ok := inst.module.Memory().Read(out.ptr, out.len) - if !ok { - return nil, errors.New("stackencrypt: guest returned an out-of-range buffer") - } - // Copy out before the deferred free wipes the guest-side buffer. - result := make([]byte, len(view)) - copy(result, view) - return result, nil +// — is wiped and freed. The memory stays mapped for the whole call; see +// guest.Call and guest.Allocator.Free. +func (inst *instance) call(ctx context.Context, fn api.Function, args ...guest.Arg) ([]byte, error) { + return guest.Call(ctx, inst.mem, inst.module, inst.exports, fn, args...) } -// invoke calls one guest export and decodes its packed result. A guest -// that did not return is errGuestTrap. -func (inst *instance) invoke(ctx context.Context, fn api.Function, params ...uint64) (guestBuf, error) { - res, err := fn.Call(ctx, params...) - if err != nil { - return guestBuf{}, fmt.Errorf("%w: guest call: %w", errGuestTrap, err) - } - return packedResult(res[0]) -} - -func wipe(b []byte) { - for i := range b { - b[i] = 0 - } -} +// wipe zeroes a host buffer. +func wipe(b []byte) { guest.Wipe(b) } diff --git a/languages/golang/stackencrypt/guest_test.go b/languages/golang/stackencrypt/guest_test.go index 5d76e3cb3..577ce67d4 100644 --- a/languages/golang/stackencrypt/guest_test.go +++ b/languages/golang/stackencrypt/guest_test.go @@ -707,36 +707,36 @@ func TestHostilePointerLengthPairsAreStatusesNotTraps(t *testing.T) { if err != nil { t.Fatalf("init with null pointer trapped: %v", err) } - if _, cerr := packedResult(res[0]); !errors.Is(cerr, ErrEncoding) { + if _, _, cerr := guest.PackedResult(res[0]); !errors.Is(cerr, ErrEncoding) { t.Fatalf("null pointer: %v, want ErrEncoding", cerr) } - staged, err := inst.allocWrite(ctx, bytes.Repeat([]byte{0x2a}, 64)) + staged, err := inst.exports.AllocWrite(ctx, inst.module, bytes.Repeat([]byte{0x2a}, 64)) if err != nil { t.Fatal(err) } - defer inst.free(ctx, staged) + defer inst.exports.Free(ctx, staged) for _, hostile := range []uint64{0x7FFF_FFF0, 0xFFFF_FFFF} { for name, fn := range map[string]func() ([]uint64, error){ - "se_cipher_init": func() ([]uint64, error) { return inst.cipherInit.Call(ctx, uint64(staged.ptr), hostile) }, - "se_keyset": func() ([]uint64, error) { return inst.keyset.Call(ctx, uint64(staged.ptr), hostile) }, + "se_cipher_init": func() ([]uint64, error) { return inst.cipherInit.Call(ctx, uint64(staged.Ptr), hostile) }, + "se_keyset": func() ([]uint64, error) { return inst.keyset.Call(ctx, uint64(staged.Ptr), hostile) }, "se_encrypt": func() ([]uint64, error) { - return inst.encrypt.Call(ctx, uint64(staged.ptr), hostile, 0, 0, uint64(staged.ptr), 4) + return inst.encrypt.Call(ctx, uint64(staged.Ptr), hostile, 0, 0, uint64(staged.Ptr), 4) }, } { res, err := fn() if err != nil { t.Fatalf("%s with len %#x trapped: %v", name, hostile, err) } - if _, cerr := packedResult(res[0]); !errors.Is(cerr, ErrEncoding) { + if _, _, cerr := guest.PackedResult(res[0]); !errors.Is(cerr, ErrEncoding) { t.Errorf("%s with len %#x: %v, want ErrEncoding", name, hostile, cerr) } } } // An unknown or mismatched free is a no-op, not a trap. - if _, err := inst.dealloc.Call(ctx, uint64(staged.ptr)+1, 1); err != nil { + if _, err := inst.exports.Dealloc.Call(ctx, uint64(staged.Ptr)+1, 1); err != nil { t.Fatalf("dealloc of an unknown pointer trapped: %v", err) } - if _, err := inst.dealloc.Call(ctx, uint64(staged.ptr), 1); err != nil { + if _, err := inst.exports.Dealloc.Call(ctx, uint64(staged.Ptr), 1); err != nil { t.Fatalf("dealloc with a mismatched length trapped: %v", err) } // The instance still works. diff --git a/languages/golang/stackencrypt/memory_test.go b/languages/golang/stackencrypt/memory_test.go index 0f90aea47..cc87a6302 100644 --- a/languages/golang/stackencrypt/memory_test.go +++ b/languages/golang/stackencrypt/memory_test.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "github.com/tetratelabs/wazero/api" "net/http" "runtime" "strings" @@ -21,14 +22,26 @@ import ( // stageLarge stages a buffer larger than the guest's initial memory, so // the guest must grow, and frees it again. func stageLarge(ctx context.Context, inst *instance) error { - staged, err := inst.allocWrite(ctx, make([]byte, 2<<20)) + staged, err := inst.exports.AllocWrite(ctx, inst.module, make([]byte, 2<<20)) if err != nil { return err } - inst.free(ctx, staged) + inst.exports.Free(ctx, staged) return nil } +// invoke calls one export on already-staged arguments and decodes its +// packed result, for the tests that need to refuse growth between staging +// and the call. +func invoke(ctx context.Context, fn api.Function, params ...uint64) error { + res, err := fn.Call(ctx, params...) + if err != nil { + return fmt.Errorf("%w: guest call: %w", guest.ErrTrap, err) + } + _, _, err = guest.PackedResult(res[0]) + return err +} + // A host-staged buffer larger than the guest's initial memory makes it // grow, and its memory stays where it was: growth commits more of one // reservation, so the guest's keys are never copied to a new slice. The @@ -187,14 +200,13 @@ func TestRequireLockedMemoryClosesTheClientOnARefusedInternalGrowth(t *testing.T } var refusing *guest.Refusing _, err = c.call(ctx, func(inst *instance) ([]byte, error) { - staged, err := inst.allocWrite(ctx, encoded) + staged, err := inst.exports.AllocWrite(ctx, inst.module, encoded) if err != nil { return nil, err } - defer inst.free(ctx, staged) + defer inst.exports.Free(ctx, staged) refusing = guest.RefuseGrowth(inst.mem, errors.New("refused for the test")) - _, err = inst.invoke(ctx, inst.cipherInit, uint64(staged.ptr), uint64(staged.len)) - return nil, err + return nil, invoke(ctx, inst.cipherInit, uint64(staged.Ptr), uint64(staged.Len)) }) if !errors.Is(err, ErrMemoryLock) || !strings.Contains(err.Error(), "growth refused") { t.Fatalf("init needing a refused internal growth: %v; want ErrMemoryLock naming the refusal", err) diff --git a/languages/golang/stackencrypt/unit_test.go b/languages/golang/stackencrypt/unit_test.go index 796ab0e57..45f3b357c 100644 --- a/languages/golang/stackencrypt/unit_test.go +++ b/languages/golang/stackencrypt/unit_test.go @@ -6,6 +6,7 @@ import ( "context" "encoding/hex" "errors" + "github.com/cipherstash/cipherstash-suite/bindings/go/internal/guest" "io" "net/http" "os" @@ -536,11 +537,11 @@ func TestStatusMappingIsTotal(t *testing.T) { 5: ErrUnauthorized, 6: ErrForbidden, 7: ErrNotFound, 8: ErrConflict, 9: ErrTransport, 10: ErrKMS, 11: ErrTerm, 12: ErrForeignKeyset, } { - if _, got := packedResult(uint64(status)); !errors.Is(got, want) { + if _, _, got := guest.PackedResult(uint64(status)); !errors.Is(got, want) { t.Errorf("status %d: %v", status, got) } } - if _, got := packedResult(99); !errors.Is(got, ErrInternal) { + if _, _, got := guest.PackedResult(99); !errors.Is(got, ErrInternal) { t.Errorf("unknown status: %v", got) } } diff --git a/scripts/go-binding-test.sh b/scripts/go-binding-test.sh index 5bcee3229..958e0bf6f 100755 --- a/scripts/go-binding-test.sh +++ b/scripts/go-binding-test.sh @@ -2,7 +2,7 @@ # Format check, vet and test one Go binding module against its embedded guest. # # One definition of "the Go binding passes", run on three platforms: the mise -# task `go:stackencrypt:test` (Linux CI, and locally) and the macOS/Windows +# task `go:test` (Linux CI, and locally) and the macOS/Windows # jobs in .github/workflows/test-wasi.yml both call this, so they cannot # drift apart. The guest module itself is built once, on Linux, and handed to # the other platforms as an artifact — the wasm is platform-independent and From 7f9fd3876146383d38c375258ee583b275a731f1 Mon Sep 17 00:00:00 2001 From: Dan Draper <dan@cipherstash.com> Date: Tue, 22 Sep 2026 16:32:43 -0500 Subject: [PATCH 621/686] test(go): the refused-growth test pins the report unchanged, not locked On the Linux 386 sweep the guest runs on the heap fallback, whose lock report is a refusal from Open, so asserting a locked report there fails for the wrong reason. What the test means is that a refused growth leaves the report as it was, whatever this host gave; it now compares the report before and after, as the allocator's own test does. --- languages/golang/stackauth/store_test.go | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/languages/golang/stackauth/store_test.go b/languages/golang/stackauth/store_test.go index 73b0c0765..5872d0eb2 100644 --- a/languages/golang/stackauth/store_test.go +++ b/languages/golang/stackauth/store_test.go @@ -557,10 +557,13 @@ func TestAProfileDirectoryThatIsASymlinkOpens(t *testing.T) { // Under RequireLockedMemory a growth the lock limit refuses is reported as // ErrMemoryLock naming the refusal, as a client reports it, and the store -// stays open and locked: the range went back unused. +// stays open with its lock report as it was: the range went back unused. +// (The report is whatever this host gave at Open — locked, or the heap +// fallback's refusal on a 32-bit host — and must not move.) func TestARefusedGrowthIsReportedAsMemoryLock(t *testing.T) { ctx := context.Background() _, s := profile(t) + before := s.MemoryLockError() refusing := guest.RefuseGrowth(s.root.inst.mem, errors.New("refused for the test")) // Staging a 2 MiB argument into guest memory needs a growth, before the // guest can refuse it as a workspace id. @@ -572,8 +575,8 @@ func TestARefusedGrowthIsReportedAsMemoryLock(t *testing.T) { if refusing.Refused() == 0 { t.Fatal("the guest did not grow; the test proves nothing") } - if !s.MemoryLocked() || s.MemoryLockError() != nil { - t.Fatalf("a refused growth changed the lock report: %v", s.MemoryLockError()) + if after := s.MemoryLockError(); fmt.Sprint(after) != fmt.Sprint(before) { + t.Fatalf("a refused growth changed the lock report: %v -> %v", before, after) } // The store is still open, and grows once it can. refusing.Allow() From e6b40eb0c780070adc280c4721b903f354e97444 Mon Sep 17 00:00:00 2001 From: Dan Draper <dan@cipherstash.com> Date: Tue, 22 Sep 2026 22:35:04 -0500 Subject: [PATCH 622/686] fix(stack-guest-abi): key the buffer registry by pointer and gate it with Miri The registry kept `ptr as usize` as its key and, in `wipe_all`, rebuilt each buffer with `Vec::from_raw_parts` from that integer. That is an exposed provenance round trip: the rebuilt pointer has no allocation Miri can check it against, and strict-provenance Miri rejects it. The map is now keyed by the pointer itself (hashed and compared by address), and `reclaim` rebuilds the buffer from the stored key rather than from the host's copy of the address, so the pointer that reaches `from_raw_parts` is always the one `Box::into_raw` produced. `miri:stack-guest-abi` runs the crate's unit tests under Miri with `-Zmiri-strict-provenance`, and `.github/workflows/miri.yml` runs it on every PR touching the crate. Only the native half is interpretable: the wasm32-only `abi` and `transport` modules read the `memory_size` intrinsic, so their hostile-input behaviour stays pinned from the Go side. --- .github/imported-workflows/miri.yml | 58 +++++++++++++++++++++++++ packages/stack-guest-abi/src/buffers.rs | 21 ++++++--- packages/stack-guest-abi/tasks.toml | 17 ++++++++ 3 files changed, 91 insertions(+), 5 deletions(-) create mode 100644 .github/imported-workflows/miri.yml create mode 100644 packages/stack-guest-abi/tasks.toml diff --git a/.github/imported-workflows/miri.yml b/.github/imported-workflows/miri.yml new file mode 100644 index 000000000..1141cd2f6 --- /dev/null +++ b/.github/imported-workflows/miri.yml @@ -0,0 +1,58 @@ +name: "Miri (guest ABI)" + +# Runs the stack-guest-abi unit tests under Miri with strict provenance (the +# `miri:stack-guest-abi` mise task). The crate is the shared ABI of the WASI +# guests under bindings/go: its buffer registry hands raw pointers to the Go +# host and rebuilds owned buffers from them, and Miri is the only check that +# sees a use-after-free, a double-free, or a pointer rebuilt without +# provenance on that path. Blocking on PRs that touch the crate: Miri is +# deterministic, so a red run is a real defect or a harness that stopped +# compiling, never flake. +on: + pull_request: + paths: + - packages/stack-guest-abi/** + # Root manifest/lockfile: a workspace-wide dependency change can break + # the build under Miri without touching the crate. + - Cargo.toml + - Cargo.lock + - mise.toml + - .github/workflows/miri.yml + # Ordering matters, this needs to stay at the end if we want to include the above, but exclude all .md files. + - "!**.md" + - "!**.example" + + workflow_dispatch: + +defaults: + run: + shell: bash + +permissions: + contents: read + +env: + RUST_BACKTRACE: full + CARGO_TERM_COLOR: always + CARGO_NET_GIT_FETCH_WITH_CLI: true + +jobs: + miri-stack-guest-abi: + runs-on: blacksmith-8vcpu-ubuntu-2404 + + steps: + - uses: actions/checkout@v6 + - uses: ./.github/actions/setup-rust + + - name: Install nightly toolchain with Miri + run: | + rustup toolchain install nightly --profile minimal --component miri + cargo +nightly miri setup + + - name: Fix permissions on target directory + run: | + mkdir -p ./target + sudo chown -R "$(id -u):$(id -g)" ./target + + - name: Miri + run: mise run miri:stack-guest-abi diff --git a/packages/stack-guest-abi/src/buffers.rs b/packages/stack-guest-abi/src/buffers.rs index e6a58b739..004d7d88a 100644 --- a/packages/stack-guest-abi/src/buffers.rs +++ b/packages/stack-guest-abi/src/buffers.rs @@ -29,7 +29,14 @@ use std::collections::HashMap; use zeroize::Zeroize; thread_local! { - static BUFFERS: RefCell<HashMap<usize, usize>> = RefCell::new(HashMap::new()); + /// Live sized buffers, keyed by the pointer itself (hashed and compared + /// by address) rather than by `ptr as usize`: the value handed back to + /// `Vec::from_raw_parts` must be the pointer that came out of + /// `Box::into_raw`, provenance intact. Rebuilding it from an integer is + /// an exposed-provenance round trip that strict-provenance Miri + /// (`miri:stack-guest-abi`) rejects, and it would hide a stale entry + /// behind a pointer Miri could no longer check. + static BUFFERS: RefCell<HashMap<*mut u8, usize>> = RefCell::new(HashMap::new()); /// Live zero-length buffers, counted rather than keyed: every empty /// `Vec` leaks to the *same* dangling pointer (alignment, so `0x1`), and /// a pointer-keyed map entry would be overwritten by the second empty @@ -76,7 +83,7 @@ pub fn register(buf: Vec<u8>) -> *mut u8 { let ptr = Box::into_raw(boxed) as *mut u8; // A fresh allocation can't already be registered; the returned previous // entry is the invariant, checked in debug builds. - let previous = BUFFERS.with(|b| b.borrow_mut().insert(ptr as usize, len)); + let previous = BUFFERS.with(|b| b.borrow_mut().insert(ptr, len)); debug_assert!(previous.is_none()); ptr } @@ -130,7 +137,7 @@ pub fn wipe_all() { // SAFETY: every entry was registered by `register`, which leaked a // boxed slice of exactly `len` bytes at `ptr`, and it was removed // above so nothing else can reclaim it. - let mut buf = unsafe { Vec::from_raw_parts(ptr as *mut u8, len, len) }; + let mut buf = unsafe { Vec::from_raw_parts(ptr, len, len) }; buf.zeroize(); } EMPTY_BUFFERS.with(|c| c.set(0)); @@ -154,11 +161,15 @@ unsafe fn reclaim(ptr: *mut u8, len: usize) -> Option<Vec<u8>> { }) }); } - let real_len = BUFFERS.with(|b| b.borrow_mut().remove(&(ptr as usize)))?; + // The entry's own key is what the buffer is rebuilt from, not the host's + // copy of the address: the key is the pointer `register` leaked, so it is + // the one with provenance over the allocation. The host's `ptr` only + // selects the entry (pointers hash and compare by address). + let (ptr, real_len) = BUFFERS.with(|b| b.borrow_mut().remove_entry(&ptr))?; if real_len != len { // Put the entry back exactly as it was; it was just removed, so // nothing can be there to displace. - let previous = BUFFERS.with(|b| b.borrow_mut().insert(ptr as usize, real_len)); + let previous = BUFFERS.with(|b| b.borrow_mut().insert(ptr, real_len)); debug_assert!(previous.is_none()); return None; } diff --git a/packages/stack-guest-abi/tasks.toml b/packages/stack-guest-abi/tasks.toml new file mode 100644 index 000000000..8b318cf70 --- /dev/null +++ b/packages/stack-guest-abi/tasks.toml @@ -0,0 +1,17 @@ +# Miri over the guest ABI crate's native half. The buffer registry +# (`buffers.rs`) is the one place under `bindings/go` where raw pointers are +# handed out, kept, and turned back into owned `Vec`s, and its unit tests are +# the only thing that exercises that round trip on a target Miri can +# interpret — the `abi` and `transport` modules are wasm32-only and read the +# wasm `memory_size` intrinsic, which Miri cannot execute; their hostile-input +# behaviour is pinned from the Go side instead (`go:test`). +# +# Strict provenance: the registry may not rebuild a pointer from an integer, +# because a pointer that was only ever an address has no allocation Miri can +# check it against. Keying the map by the pointer itself keeps provenance +# through the round trip, and this flag is what fails the build if that +# discipline slips. +["miri:stack-guest-abi"] +description = "Run the stack-guest-abi unit tests under Miri (nightly) with strict provenance — checks the buffer registry's raw-pointer round trips for undefined behaviour" +env = { MIRIFLAGS = "-Zmiri-strict-provenance" } +run = "cargo +nightly miri test -p stack-guest-abi" From a850bc0a740f08edf3fb63f15ec0b7256409c770 Mon Sep 17 00:00:00 2001 From: Dan Draper <dan@cipherstash.com> Date: Tue, 22 Sep 2026 22:35:05 -0500 Subject: [PATCH 623/686] test(stack-encrypt): fuzz the leaf and index-term byte decoders Two libFuzzer targets in a detached `packages/stack-encrypt/fuzz/` crate, wired as `fuzz:sealed-value` and `fuzz:term-decode` and into both matrices of fuzz.yml. A stored ciphertext column comes back through `SealedValue::from_bytes` before anything is authenticated, and stored terms through the `from_bytes` / `TryFrom<&[u8]>` of every SEM term kind, so the bytes are attacker-controlled and decoding must never panic. Both harnesses also check the frozen formats are lossless where they are documented to be: an accepted leaf, equality term, ORE term or OPE term must re-encode to exactly the bytes it was decoded from. The match term normalises its positions, so only its decode is exercised. The committed seeds are real terms generated under `FakeDataKeySource`. --- .github/imported-workflows/fuzz.yml | 5 ++ docs/fuzzing.md | 14 ++++-- packages/stack-encrypt/fuzz/Cargo.toml | 41 ++++++++++++++++ .../corpus/sealed_value_decode/valid-leaf-v1 | Bin 0 -> 123 bytes .../valid-leaf-v1-empty-tag-and-body | Bin 0 -> 35 bytes .../corpus/term_decode/valid-equality-term | Bin 0 -> 32 bytes .../fuzz/corpus/term_decode/valid-match-term | Bin 0 -> 52 bytes .../corpus/term_decode/valid-ope-string-term | Bin 0 -> 41 bytes .../corpus/term_decode/valid-ore-string-term | 1 + .../corpus/term_decode/valid-ore-u64-term | 1 + .../fuzz/fuzz_targets/sealed_value_decode.rs | 18 +++++++ .../fuzz/fuzz_targets/term_decode.rs | 45 ++++++++++++++++++ packages/stack-encrypt/tasks.toml | 21 ++++++++ 13 files changed, 141 insertions(+), 5 deletions(-) create mode 100644 packages/stack-encrypt/fuzz/Cargo.toml create mode 100644 packages/stack-encrypt/fuzz/corpus/sealed_value_decode/valid-leaf-v1 create mode 100644 packages/stack-encrypt/fuzz/corpus/sealed_value_decode/valid-leaf-v1-empty-tag-and-body create mode 100644 packages/stack-encrypt/fuzz/corpus/term_decode/valid-equality-term create mode 100644 packages/stack-encrypt/fuzz/corpus/term_decode/valid-match-term create mode 100644 packages/stack-encrypt/fuzz/corpus/term_decode/valid-ope-string-term create mode 100644 packages/stack-encrypt/fuzz/corpus/term_decode/valid-ore-string-term create mode 100644 packages/stack-encrypt/fuzz/corpus/term_decode/valid-ore-u64-term create mode 100644 packages/stack-encrypt/fuzz/fuzz_targets/sealed_value_decode.rs create mode 100644 packages/stack-encrypt/fuzz/fuzz_targets/term_decode.rs create mode 100644 packages/stack-encrypt/tasks.toml diff --git a/.github/imported-workflows/fuzz.yml b/.github/imported-workflows/fuzz.yml index 2a3d42c8e..ebef9b38e 100644 --- a/.github/imported-workflows/fuzz.yml +++ b/.github/imported-workflows/fuzz.yml @@ -21,6 +21,7 @@ on: - packages/cts-common/** - packages/stack-auth/** - packages/stack-kms/** + - packages/stack-encrypt/** # Root manifest/lockfile: a workspace-wide dependency change can break # this suite without touching any package source. - Cargo.toml @@ -65,6 +66,8 @@ jobs: - { task: "fuzz:access-key", slug: access-key } - { task: "fuzz:jwt-decode", slug: jwt-decode } - { task: "fuzz:client-key", slug: client-key } + - { task: "fuzz:sealed-value", slug: sealed-value } + - { task: "fuzz:term-decode", slug: term-decode } steps: - uses: actions/checkout@v6 - uses: ./.github/actions/setup-rust @@ -90,6 +93,8 @@ jobs: - { task: "fuzz:access-key", slug: access-key, dir: packages/stack-auth, target: access_key_parse } - { task: "fuzz:jwt-decode", slug: jwt-decode, dir: packages/stack-auth, target: jwt_decode } - { task: "fuzz:client-key", slug: client-key, dir: packages/stack-kms, target: client_key_encoded } + - { task: "fuzz:sealed-value", slug: sealed-value, dir: packages/stack-encrypt, target: sealed_value_decode } + - { task: "fuzz:term-decode", slug: term-decode, dir: packages/stack-encrypt, target: term_decode } steps: - uses: actions/checkout@v6 - uses: ./.github/actions/setup-rust diff --git a/docs/fuzzing.md b/docs/fuzzing.md index 2e29c024c..c950e7cf5 100644 --- a/docs/fuzzing.md +++ b/docs/fuzzing.md @@ -12,8 +12,8 @@ skill is the reference, and this doc only covers what's repo-specific. ## What we fuzz and why -We fuzz the parsers that turn **untrusted caller-supplied strings** into -domain types. The invariant under test is always the same: parsing +We fuzz the parsers that turn **untrusted caller-supplied input** — strings +from a caller, or bytes read back from storage — into domain types. The invariant under test is always the same: parsing arbitrary input must **never panic** — malformed input must return an `Err`, not crash the process. @@ -27,9 +27,11 @@ Current targets: | `fuzz:access-key` | `stack-auth` | `access_key_parse` | `AccessKey` (`CSAK<key_id>.<key_secret>`) | | `fuzz:jwt-decode` | `stack-auth` | `jwt_decode` | JWT claims (`Token::fuzz_decode_claims`) | | `fuzz:client-key` | `stack-kms` | `client_key_encoded` | `ClientKey::from_encoded_v1` (hex or base64) | +| `fuzz:sealed-value` | `stack-encrypt` | `sealed_value_decode` | `SealedValue::from_bytes` (frozen v1 leaf); accepted input must re-encode to itself | +| `fuzz:term-decode` | `stack-encrypt` | `term_decode` | the SEM term decoders (`EqualityTerm`, `MatchTerm`, `OreTerm`, `OpeTerm` `from_bytes`) | -Each target is a few lines — `libfuzzer-sys` hands a `&str` to the -parser via the `arbitrary` crate: +Each target is a few lines — `libfuzzer-sys` hands a `&str` (or `&[u8]` +for the byte decoders) to the parser via the `arbitrary` crate: ```rust #![no_main] @@ -64,6 +66,7 @@ packages/cts-common/fuzz/ corpus/<target>/* # committed seed inputs (valid examples) packages/stack-auth/fuzz/ packages/stack-kms/fuzz/ +packages/stack-encrypt/fuzz/ … ``` @@ -124,7 +127,8 @@ Two jobs with deliberately different roles: to stay small, and any crash reproducer is uploaded as an artifact. The `pull_request` trigger is path-filtered to `packages/cts-common/**`, -`packages/stack-auth/**`, `packages/stack-kms/**`, and the workflow file, +`packages/stack-auth/**`, `packages/stack-kms/**`, `packages/stack-encrypt/**`, +and the workflow file, with `!**.md` / `!**.example` excludes last so docs-only changes are skipped. diff --git a/packages/stack-encrypt/fuzz/Cargo.toml b/packages/stack-encrypt/fuzz/Cargo.toml new file mode 100644 index 000000000..67ffd29b8 --- /dev/null +++ b/packages/stack-encrypt/fuzz/Cargo.toml @@ -0,0 +1,41 @@ +# Fuzz crate for stack-encrypt's frozen byte decoders. +# +# This is a DETACHED crate: the `[workspace]` table at the bottom makes it its +# own workspace root so the libfuzzer-sys dependency and the nightly-only build +# never touch the main monorepo workspace. It is not a member of the root +# workspace (see the root Cargo.toml `members` list). Run via the `fuzz:*` +# mise tasks, which invoke `cargo +nightly fuzz run`. +[package] +name = "stack-encrypt-fuzz" +version = "0.0.0" +publish = false +edition = "2021" + +[package.metadata] +cargo-fuzz = true + +[dependencies] +libfuzzer-sys = "0.4" + +[dependencies.stack-encrypt] +path = ".." +# The decoders are structural and need no ZeroKMS client: with `http` off, +# reqwest and its TLS stack stay out of the fuzz build. +default-features = false + +[[bin]] +name = "sealed_value_decode" +path = "fuzz_targets/sealed_value_decode.rs" +test = false +doc = false +bench = false + +[[bin]] +name = "term_decode" +path = "fuzz_targets/term_decode.rs" +test = false +doc = false +bench = false + +[workspace] +resolver = "2" diff --git a/packages/stack-encrypt/fuzz/corpus/sealed_value_decode/valid-leaf-v1 b/packages/stack-encrypt/fuzz/corpus/sealed_value_decode/valid-leaf-v1 new file mode 100644 index 0000000000000000000000000000000000000000..6633080898dedc5987302f754d82abbf77a93514 GIT binary patch literal 123 zcmZR~POU6XEzwQOtSBihN;NcNM*{{742(?7EUawo9GqO-JiL7T0)j%qBBEmA5|UEV zGO}{=3W`d~DynMg8k$<#I=XuLjK!&`DY|+2dC94|iFqly$(aQisYNBJ6(wM9Nn*Ng JQe{bMF#saN8U_FW literal 0 HcmV?d00001 diff --git a/packages/stack-encrypt/fuzz/corpus/sealed_value_decode/valid-leaf-v1-empty-tag-and-body b/packages/stack-encrypt/fuzz/corpus/sealed_value_decode/valid-leaf-v1-empty-tag-and-body new file mode 100644 index 0000000000000000000000000000000000000000..a5935566030aee7b7c9147dd41c4517250f10455 GIT binary patch literal 35 KcmZQ%AP4{eMgRf; literal 0 HcmV?d00001 diff --git a/packages/stack-encrypt/fuzz/corpus/term_decode/valid-equality-term b/packages/stack-encrypt/fuzz/corpus/term_decode/valid-equality-term new file mode 100644 index 0000000000000000000000000000000000000000..821c948ef54e6f64226c57750d46e972eebe222c GIT binary patch literal 32 ocmX@e_?|7*{j@QIjj8TgrX||{yi^`tNSL!`fyCVTQa{wR0NF$hx&QzG literal 0 HcmV?d00001 diff --git a/packages/stack-encrypt/fuzz/corpus/term_decode/valid-match-term b/packages/stack-encrypt/fuzz/corpus/term_decode/valid-match-term new file mode 100644 index 0000000000000000000000000000000000000000..4c2cac3c12bf2c8a69014cb354b8559f66e92459 GIT binary patch literal 52 zcmZQ!U}fN9;AP-rkYiA1FlDe{uw@8kh+~LnNMXolsAZ^Un8UD^VGqMzhEog|7#=V@ IWq8j30Jgjf4*&oF literal 0 HcmV?d00001 diff --git a/packages/stack-encrypt/fuzz/corpus/term_decode/valid-ope-string-term b/packages/stack-encrypt/fuzz/corpus/term_decode/valid-ope-string-term new file mode 100644 index 0000000000000000000000000000000000000000..0142b36bcb4d3ad657e28807d214042eef3f1fd6 GIT binary patch literal 41 xcmZQzmDjG*-BcYA&R&{n<+_l$C){nj)j^Zz+c@8PSwC49n33Ujb8goSW&kgp5Yhku literal 0 HcmV?d00001 diff --git a/packages/stack-encrypt/fuzz/corpus/term_decode/valid-ore-string-term b/packages/stack-encrypt/fuzz/corpus/term_decode/valid-ore-string-term new file mode 100644 index 000000000..cced39c73 --- /dev/null +++ b/packages/stack-encrypt/fuzz/corpus/term_decode/valid-ore-string-term @@ -0,0 +1 @@ +Ê#ºezàɊMÊ߁°ÐýÉe›z„LÍ"®]\¼Ì *W^˜‘ \ No newline at end of file diff --git a/packages/stack-encrypt/fuzz/corpus/term_decode/valid-ore-u64-term b/packages/stack-encrypt/fuzz/corpus/term_decode/valid-ore-u64-term new file mode 100644 index 000000000..8fac046e9 --- /dev/null +++ b/packages/stack-encrypt/fuzz/corpus/term_decode/valid-ore-u64-term @@ -0,0 +1 @@ +åøU�Â×ÝÝl[qN�(U†¡¸9 ‘@Bx�JϵýÔí@v֕,!-ÚÙƠas/Žñ´ I×.Ÿ·웲“ \ No newline at end of file diff --git a/packages/stack-encrypt/fuzz/fuzz_targets/sealed_value_decode.rs b/packages/stack-encrypt/fuzz/fuzz_targets/sealed_value_decode.rs new file mode 100644 index 000000000..a38487378 --- /dev/null +++ b/packages/stack-encrypt/fuzz/fuzz_targets/sealed_value_decode.rs @@ -0,0 +1,18 @@ +#![no_main] + +use libfuzzer_sys::fuzz_target; +use stack_encrypt::SealedValue; + +// Fuzz the frozen v1 leaf byte decoder. A stored ciphertext column comes back +// through `SealedValue::from_bytes` before anything is authenticated, so the +// bytes are attacker-controlled: decoding must never panic, only return `Err`. +// +// The format is documented as lossless, so a second invariant is checked on +// every accepted input: re-encoding gives back exactly the bytes decoded. +// A decoder that accepts bytes it cannot reproduce would let two distinct +// stored forms alias one leaf. +fuzz_target!(|bytes: &[u8]| { + if let Ok(leaf) = SealedValue::from_bytes(bytes) { + assert_eq!(leaf.to_bytes(), bytes, "SealedValue decode is not lossless"); + } +}); diff --git a/packages/stack-encrypt/fuzz/fuzz_targets/term_decode.rs b/packages/stack-encrypt/fuzz/fuzz_targets/term_decode.rs new file mode 100644 index 000000000..45d7c0b0e --- /dev/null +++ b/packages/stack-encrypt/fuzz/fuzz_targets/term_decode.rs @@ -0,0 +1,45 @@ +#![no_main] + +use libfuzzer_sys::fuzz_target; +use stack_encrypt::sem::{DefaultMatch, EqualityTerm, MatchTerm, OpeTerm, OreTerm}; + +// Fuzz the SEM index-term byte decoders: stored terms come back through these +// before comparison, so the bytes are attacker-controlled and decoding must +// never panic. Every kind is run over the same slice; each is a length or +// range check and a crash names its kind in the panic message. +// +// The unframed kinds (equality, ORE, OPE) store the bytes as given, so an +// accepted input must re-encode to itself. A match term normalises its +// positions (sorted, de-duplicated), so only its decode is exercised. +fuzz_target!(|bytes: &[u8]| { + if let Ok(term) = EqualityTerm::try_from(bytes) { + assert_eq!( + term.as_bytes(), + bytes, + "EqualityTerm decode is not lossless" + ); + } + let _ = MatchTerm::<DefaultMatch>::from_bytes(bytes); + // One fixed-width and one variable-width CLLW output each for ORE and OPE. + if let Ok(term) = OreTerm::<u64>::from_bytes(bytes) { + assert_eq!( + term.as_bytes(), + bytes, + "OreTerm<u64> decode is not lossless" + ); + } + if let Ok(term) = OreTerm::<String>::from_bytes(bytes) { + assert_eq!( + term.as_bytes(), + bytes, + "OreTerm<String> decode is not lossless" + ); + } + if let Ok(term) = OpeTerm::<String>::from_bytes(bytes) { + assert_eq!( + term.as_bytes(), + bytes, + "OpeTerm<String> decode is not lossless" + ); + } +}); diff --git a/packages/stack-encrypt/tasks.toml b/packages/stack-encrypt/tasks.toml new file mode 100644 index 000000000..6de50e498 --- /dev/null +++ b/packages/stack-encrypt/tasks.toml @@ -0,0 +1,21 @@ +# Fuzz the frozen v1 leaf byte decoder (`SealedValue::from_bytes`, also +# `TryFrom<&[u8]>`). This is what a stored ciphertext column comes back +# through, so it is the crate's widest untrusted-input parser: it must never +# panic, and a leaf it accepts must re-encode to exactly the bytes it was +# decoded from (the format is documented as lossless). Same flags and +# rationale as `fuzz:access-key` in stack-auth: nightly, `--sanitizer none` +# (pure safe Rust), and the native host triple. The fuzz crate lives in +# `packages/stack-encrypt/fuzz/` (detached). +["fuzz:sealed-value"] +description = "Fuzz stack-encrypt's frozen leaf byte decoder, SealedValue::from_bytes (libFuzzer, nightly, 60s default)" +dir = "{{config_root}}/packages/stack-encrypt" +run = "cargo +nightly fuzz run sealed_value_decode --sanitizer none --target $(rustc -vV | sed -n 's/^host: //p') -- -max_total_time=60" + +# Fuzz the SEM index-term byte decoders — every `from_bytes` / `TryFrom<&[u8]>` +# a stored term comes back through (equality, match, ORE and OPE). One target +# for all four: each is a length or range check over the same untrusted +# slice, so a separate campaign per kind would only split the corpus. +["fuzz:term-decode"] +description = "Fuzz stack-encrypt's SEM index-term byte decoders (libFuzzer, nightly, 60s default)" +dir = "{{config_root}}/packages/stack-encrypt" +run = "cargo +nightly fuzz run term_decode --sanitizer none --target $(rustc -vV | sed -n 's/^host: //p') -- -max_total_time=60" From 1b71f9e268d1513b831f7acaab14c99f0c9b7dcf Mon Sep 17 00:00:00 2001 From: Dan Draper <dan@cipherstash.com> Date: Tue, 22 Sep 2026 22:35:05 -0500 Subject: [PATCH 624/686] test(stack-encrypt): gate the crate on the CRAP metric `crap:stack-encrypt` mirrors the stack-auth gate (llvm-cov over the unit and integration tests, `cargo crap --fail-above` against the workspace threshold of 30) and crap-stack-encrypt.yml runs it on every PR touching the crate or its derive. The trybuild UI suite is left out of the coverage run: it compiles the compile-fail cases in a scratch project and adds nothing to this crate's coverage. The first run flagged two functions in `dynamic`, both fixed here. `borrowed` had every integer arm untested; one test now builds a context of every piece and pins that the view is the same context with text and bytes borrowed, not copied. `term` scored 34 on complexity alone, which no test could bring under the threshold, so its four kinds are now private per-kind dispatches; behaviour is unchanged and pinned by the existing scalar-by-kind tests. 316 functions score under 30. --- .../imported-workflows/crap-stack-encrypt.yml | 63 +++++++ packages/stack-encrypt/src/dynamic/context.rs | 45 +++++ packages/stack-encrypt/src/dynamic/term.rs | 165 ++++++++++++------ packages/stack-encrypt/tasks.toml | 21 +++ 4 files changed, 240 insertions(+), 54 deletions(-) create mode 100644 .github/imported-workflows/crap-stack-encrypt.yml diff --git a/.github/imported-workflows/crap-stack-encrypt.yml b/.github/imported-workflows/crap-stack-encrypt.yml new file mode 100644 index 000000000..756605b6c --- /dev/null +++ b/.github/imported-workflows/crap-stack-encrypt.yml @@ -0,0 +1,63 @@ +name: "stack-encrypt CRAP gate" + +# Gates PRs that touch stack-encrypt on the CRAP (Change Risk Anti-Patterns) +# metric: cyclomatic complexity weighted by test coverage, so it surfaces +# complex, under-tested functions. Same shape as crap-stack-auth.yml. +# +# Blocking: the `crap:stack-encrypt` mise task runs with `--fail-above`, so the +# job fails when any function's CRAP score exceeds the threshold in +# .cargo-crap.toml (30). Offending functions surface as inline PR annotations +# (`--format github`). To relax this to regression-only later, switch to +# `--baseline`/`--fail-regression`. +on: + pull_request: + paths: + - packages/stack-encrypt/** + - packages/stack-encrypt-derive/** + - .cargo-crap.toml + - .github/workflows/crap-stack-encrypt.yml + # Ordering matters, this needs to stay at the end if we want to include the above, but exclude all .md files. + - "!**.md" + - "!**.example" + + workflow_dispatch: + +defaults: + run: + shell: bash + +# Read-only: failures surface as job status + inline annotations, not a comment. +permissions: + contents: read + +env: + RUST_BACKTRACE: full + CARGO_TERM_COLOR: always + CARGO_NET_GIT_FETCH_WITH_CLI: true + NEXTEST_PROFILE: ci + +jobs: + crap-stack-encrypt: + runs-on: blacksmith-8vcpu-ubuntu-2404 + + steps: + - uses: actions/checkout@v6 + - uses: ./.github/actions/setup-rust + + - name: Install llvm-tools-preview (required by cargo-llvm-cov) + run: rustup component add --toolchain "$(rustup show active-toolchain | cut -d' ' -f1)" llvm-tools-preview + + - name: Fix permissions on target directory + run: | + mkdir -p ./target + sudo chown -R "$(id -u):$(id -g)" ./target + + - name: Run CRAP gate + # Reuse the `crap:stack-encrypt` mise task as the single source of truth + # for the coverage + `cargo crap` invocation (see + # packages/stack-encrypt/tasks.toml), so the exclusions, threshold and + # `--fail-above` gate live in one place. mise forwards trailing args + # (after `--`) to the task's last command (`cargo crap`); `--format + # github` emits one `::warning` annotation per offending function so + # they show inline on the PR before the job fails. + run: mise run crap:stack-encrypt -- --format github diff --git a/packages/stack-encrypt/src/dynamic/context.rs b/packages/stack-encrypt/src/dynamic/context.rs index 909c2197f..33d5ce61d 100644 --- a/packages/stack-encrypt/src/dynamic/context.rs +++ b/packages/stack-encrypt/src/dynamic/context.rs @@ -162,6 +162,51 @@ mod tests { FfiValue::String(value.into()) } + /// `borrowed` is a view, so it must be the same context for every + /// variant: one list holding each integer width, each byte-bearing + /// piece, and a nested list is its own borrowed view. A variant this + /// match forgot would fall to the cloning arm and still compare equal, + /// so the test also pins that no payload was copied where a borrow was + /// due — the text and bytes come back as `Cow::Borrowed`. + #[test] + fn a_borrowed_view_is_the_same_context_for_every_piece() { + let owned = ContextPiece::List(vec![ + ContextPiece::Text(Cow::Owned("users/age".to_string())), + ContextPiece::Bytes(Cow::Owned(vec![1, 2, 3])), + ContextPiece::Encoded(Cow::Owned(vec![4, 5, 6])), + ContextPiece::U8(8), + ContextPiece::U16(16), + ContextPiece::U32(32), + ContextPiece::U64(64), + ContextPiece::U128(128), + ContextPiece::I8(-8), + ContextPiece::I16(-16), + ContextPiece::I32(-32), + ContextPiece::I64(-64), + ContextPiece::I128(-128), + ContextPiece::List(vec![ContextPiece::Text(Cow::Owned("t".to_string()))]), + ]); + + let view = borrowed(&owned); + assert_eq!(view, owned, "the view is the same context"); + + let ContextPiece::List(parts) = &view else { + panic!("the view of a list is a list"); + }; + assert!( + matches!(&parts[0], ContextPiece::Text(Cow::Borrowed(_))), + "text is borrowed, not copied" + ); + assert!( + matches!(&parts[1], ContextPiece::Bytes(Cow::Borrowed(_))), + "bytes are borrowed, not copied" + ); + assert!( + matches!(&parts[2], ContextPiece::Encoded(Cow::Borrowed(_))), + "encoded bytes are borrowed, not copied" + ); + } + #[test] fn a_bare_string_is_the_flat_context() { let parsed = context(s("users/age")).expect("flat context"); diff --git a/packages/stack-encrypt/src/dynamic/term.rs b/packages/stack-encrypt/src/dynamic/term.rs index f968611d1..4b4691701 100644 --- a/packages/stack-encrypt/src/dynamic/term.rs +++ b/packages/stack-encrypt/src/dynamic/term.rs @@ -182,61 +182,118 @@ where D: IntoPrfContext<'c>, { match kind { - TermKind::Equality => { - let term = match scalar { - Scalar::I32(v) => cipher.equality_term(v, context).await, - Scalar::I64(v) => cipher.equality_term(v, context).await, - Scalar::U32(v) => cipher.equality_term(v, context).await, - Scalar::U64(v) => cipher.equality_term(v, context).await, - Scalar::Text(t) => cipher.equality_term(String::clone(&t), context).await, - Scalar::Bytes(b) => { - cipher - .equality_term(Protected::new(Vec::clone(&b)), context) - .await - } - // No PRF encoding is defined for floats (equality on - // IEEE-754 values is a modelling error) or booleans. - Scalar::Bool(_) | Scalar::F32(_) | Scalar::F64(_) => { - return Err(Error::Term { kind }) - } - }?; - Ok(term.into_bytes().to_vec()) - } - TermKind::Match => match scalar { - Scalar::Text(t) => Ok(cipher - .match_terms::<DefaultMatch>(&t, context) + TermKind::Equality => equality(cipher, scalar, context).await, + TermKind::Match => match_term(cipher, scalar, context).await, + TermKind::Ore => ore_of(cipher, scalar, context).await, + TermKind::Ope => ope_of(cipher, scalar, context).await, + } +} + +/// [`TermKind::Equality`] per scalar: one PRF block over the value, for +/// every integer width, text and bytes. +async fn equality<'c, K, D>( + cipher: &KeysetCipher<'_, K>, + scalar: Scalar, + context: NonEmpty<D>, +) -> Result<Vec<u8>, Error> +where + K: DataKeySource + Sync, + D: IntoPrfContext<'c>, +{ + let term = match scalar { + Scalar::I32(v) => cipher.equality_term(v, context).await, + Scalar::I64(v) => cipher.equality_term(v, context).await, + Scalar::U32(v) => cipher.equality_term(v, context).await, + Scalar::U64(v) => cipher.equality_term(v, context).await, + Scalar::Text(t) => cipher.equality_term(String::clone(&t), context).await, + Scalar::Bytes(b) => { + cipher + .equality_term(Protected::new(Vec::clone(&b)), context) .await - .map(|t| t.to_bytes())?), - _ => Err(Error::Term { kind }), - }, - // The text and bytes arms hand the encryptor the `Zeroizing` operand - // itself, not a bare clone of its contents: the CLLW encryptors take - // their value by `'static` ownership (the visitor carries it), so a - // cloned-out `String`/`Vec<u8>` would be freed with the plaintext - // still in it — in a guest's linear memory, where the host can read - // it. Keeping the wrapper costs nothing and saves the copy as well. - TermKind::Ore => match scalar { - Scalar::Bool(v) => ore(cipher, v, context).await, - Scalar::I32(v) => ore(cipher, v, context).await, - Scalar::I64(v) => ore(cipher, v, context).await, - Scalar::U32(v) => ore(cipher, v, context).await, - Scalar::U64(v) => ore(cipher, v, context).await, - Scalar::F32(v) => ore(cipher, v, context).await, - Scalar::F64(v) => ore(cipher, v, context).await, - Scalar::Text(t) => ore(cipher, t, context).await, - Scalar::Bytes(b) => ore(cipher, b, context).await, - }, - TermKind::Ope => match scalar { - Scalar::Bool(v) => ope(cipher, v, context).await, - Scalar::I32(v) => ope(cipher, v, context).await, - Scalar::I64(v) => ope(cipher, v, context).await, - Scalar::U32(v) => ope(cipher, v, context).await, - Scalar::U64(v) => ope(cipher, v, context).await, - Scalar::F32(v) => ope(cipher, v, context).await, - Scalar::F64(v) => ope(cipher, v, context).await, - Scalar::Text(t) => ope(cipher, t, context).await, - Scalar::Bytes(b) => ope(cipher, b, context).await, - }, + } + // No PRF encoding is defined for floats (equality on IEEE-754 + // values is a modelling error) or booleans. + Scalar::Bool(_) | Scalar::F32(_) | Scalar::F64(_) => { + return Err(Error::Term { + kind: TermKind::Equality, + }) + } + }?; + Ok(term.into_bytes().to_vec()) +} + +/// [`TermKind::Match`] per scalar: text only. +async fn match_term<'c, K, D>( + cipher: &KeysetCipher<'_, K>, + scalar: Scalar, + context: NonEmpty<D>, +) -> Result<Vec<u8>, Error> +where + K: DataKeySource + Sync, + D: IntoPrfContext<'c>, +{ + match scalar { + Scalar::Text(t) => Ok(cipher + .match_terms::<DefaultMatch>(&t, context) + .await + .map(|t| t.to_bytes())?), + _ => Err(Error::Term { + kind: TermKind::Match, + }), + } +} + +/// [`TermKind::Ore`] per scalar: every scalar has an ORE encoding. +/// +/// The text and bytes arms hand the encryptor the `Zeroizing` operand +/// itself, not a bare clone of its contents: the CLLW encryptors take +/// their value by `'static` ownership (the visitor carries it), so a +/// cloned-out `String`/`Vec<u8>` would be freed with the plaintext +/// still in it — in a guest's linear memory, where the host can read +/// it. Keeping the wrapper costs nothing and saves the copy as well. +async fn ore_of<'c, K, D>( + cipher: &KeysetCipher<'_, K>, + scalar: Scalar, + context: NonEmpty<D>, +) -> Result<Vec<u8>, Error> +where + K: DataKeySource + Sync, + D: IntoPrfContext<'c>, +{ + match scalar { + Scalar::Bool(v) => ore(cipher, v, context).await, + Scalar::I32(v) => ore(cipher, v, context).await, + Scalar::I64(v) => ore(cipher, v, context).await, + Scalar::U32(v) => ore(cipher, v, context).await, + Scalar::U64(v) => ore(cipher, v, context).await, + Scalar::F32(v) => ore(cipher, v, context).await, + Scalar::F64(v) => ore(cipher, v, context).await, + Scalar::Text(t) => ore(cipher, t, context).await, + Scalar::Bytes(b) => ore(cipher, b, context).await, + } +} + +/// [`TermKind::Ope`] per scalar; see [`ore_of`] for why the text and bytes +/// arms pass the wrapper. +async fn ope_of<'c, K, D>( + cipher: &KeysetCipher<'_, K>, + scalar: Scalar, + context: NonEmpty<D>, +) -> Result<Vec<u8>, Error> +where + K: DataKeySource + Sync, + D: IntoPrfContext<'c>, +{ + match scalar { + Scalar::Bool(v) => ope(cipher, v, context).await, + Scalar::I32(v) => ope(cipher, v, context).await, + Scalar::I64(v) => ope(cipher, v, context).await, + Scalar::U32(v) => ope(cipher, v, context).await, + Scalar::U64(v) => ope(cipher, v, context).await, + Scalar::F32(v) => ope(cipher, v, context).await, + Scalar::F64(v) => ope(cipher, v, context).await, + Scalar::Text(t) => ope(cipher, t, context).await, + Scalar::Bytes(b) => ope(cipher, b, context).await, } } diff --git a/packages/stack-encrypt/tasks.toml b/packages/stack-encrypt/tasks.toml index 6de50e498..896cacee3 100644 --- a/packages/stack-encrypt/tasks.toml +++ b/packages/stack-encrypt/tasks.toml @@ -1,3 +1,24 @@ +["crap:stack-encrypt"] +description = "Gate stack-encrypt on the CRAP (Change Risk Anti-Patterns) metric — fails when a complex, under-tested function exceeds the threshold" +run = [ + # Instrument and run the unit and integration tests, emitting LCOV coverage + # that `cargo crap` consumes. `--all-features` so the `dynamic` module (the + # FFI value model every binding funnels through) is scored, not skipped as + # uninstrumented. The trybuild UI suite is excluded: it compiles the derive's + # compile-fail cases in a scratch cargo project, which contributes nothing to + # this crate's coverage and only slows the instrumented run. + "mise x --env test -- cargo llvm-cov nextest -p stack-encrypt --all-features -E 'not binary(ui)' --lcov --output-path {{config_root}}/target/stack-encrypt-lcov.info", + # Score every production function. Excludes test code, the examples, and the + # detached fuzz crate (which lives under this package's directory and would + # otherwise be walked as production source). `--fail-above` exits non-zero + # when any function's CRAP score exceeds the threshold from the workspace-root + # .cargo-crap.toml (30), so this gates both local runs and CI. + # NOTE: this must stay the LAST command — the crap-stack-encrypt.yml CI + # workflow runs this task and relies on mise appending its trailing args + # (e.g. --format github) to this `cargo crap` invocation. + "cargo crap --path packages/stack-encrypt --lcov {{config_root}}/target/stack-encrypt-lcov.info --exclude 'examples/**' --exclude 'fuzz/**' --exclude '**/tests/**' --fail-above", +] + # Fuzz the frozen v1 leaf byte decoder (`SealedValue::from_bytes`, also # `TryFrom<&[u8]>`). This is what a stored ciphertext column comes back # through, so it is the crate's widest untrusted-input parser: it must never From 66fd8d9f76de1080a8588ca09106e1d1fcc3b7c2 Mon Sep 17 00:00:00 2001 From: Dan Draper <dan@cipherstash.com> Date: Tue, 22 Sep 2026 22:46:07 -0500 Subject: [PATCH 625/686] test(stack-guest-abi): drive the buffer registry against a model A proptest over random interleavings of alloc, register, take, dealloc, wipe_all and reclaims of foreign memory, checked against a model of what the host holds: a fresh sized buffer never aliases a live one, the host reads back the bytes it wrote or the guest packed, a length mismatch refuses and leaves the buffer live, a second reclaim of a released buffer is refused, the shared empty pointer can only be spent as many times as empties are live, a wipe leaves nothing reclaimable and the registry usable, and memory the registry never handed out is refused untouched. Runs natively at 256 cases and under `miri:stack-guest-abi` at 24, where every `from_raw_parts` round trip in those sequences is checked for undefined behaviour and the shutdown wipe for leaks. proptest is a dev-dependency with `std` only: its `fork` and `timeout` defaults spawn processes and threads Miri cannot interpret. --- packages/stack-guest-abi/Cargo.toml | 7 + packages/stack-guest-abi/src/buffers.rs | 195 ++++++++++++++++++++++++ 2 files changed, 202 insertions(+) diff --git a/packages/stack-guest-abi/Cargo.toml b/packages/stack-guest-abi/Cargo.toml index ac15e4a26..b9c5ecc1b 100644 --- a/packages/stack-guest-abi/Cargo.toml +++ b/packages/stack-guest-abi/Cargo.toml @@ -23,3 +23,10 @@ zeroize = { workspace = true } [target.'cfg(target_arch = "wasm32")'.dependencies] thiserror = { workspace = true } vitaminc-protected = { workspace = true } + +[dev-dependencies] +# The registry's property test (`buffers::properties`): random interleavings +# of alloc / register / take / dealloc / wipe_all against a model of what the +# host holds, run natively and under Miri. `std` only: the `fork` and +# `timeout` defaults spawn processes and threads Miri cannot interpret. +proptest = { version = "1.7", default-features = false, features = ["std"] } diff --git a/packages/stack-guest-abi/src/buffers.rs b/packages/stack-guest-abi/src/buffers.rs index 004d7d88a..62aa430d4 100644 --- a/packages/stack-guest-abi/src/buffers.rs +++ b/packages/stack-guest-abi/src/buffers.rs @@ -245,3 +245,198 @@ mod tests { assert_eq!(unsafe { take(a, 0) }, None); } } + +/// The registry against a model of what the host holds, over random +/// interleavings of every entry point. The unit tests above each pin one +/// rule; this is where the rules are checked together — a length mismatch +/// that leaves the buffer live, a double reclaim that fails, an empty +/// count that cannot be spent on a sized pointer, a wipe that leaves +/// nothing reclaimable — on sequences no one wrote by hand. Under Miri +/// (`miri:stack-guest-abi`) every `from_raw_parts` round trip in those +/// sequences is checked for undefined behaviour as well. +#[cfg(test)] +mod properties { + use super::*; + use proptest::prelude::*; + + /// One host action. `which` selects among the live buffers by modulus, + /// so a shrunk sequence stays meaningful; `len_delta` is the host's + /// error in the length it reports. + #[derive(Debug, Clone)] + enum Op { + /// `se_alloc`, then the host writes a pattern into the buffer. + Alloc(usize), + /// A guest output packed for the host. + Register(Vec<u8>), + Take { + which: usize, + len_delta: i8, + }, + Dealloc { + which: usize, + len_delta: i8, + }, + /// A reclaim of memory the registry never handed out. + TakeForeign(usize), + WipeAll, + } + + fn op() -> impl Strategy<Value = Op> { + prop_oneof![ + (0usize..=48).prop_map(Op::Alloc), + proptest::collection::vec(any::<u8>(), 0..48).prop_map(Op::Register), + (any::<usize>(), -2i8..=2).prop_map(|(which, len_delta)| Op::Take { which, len_delta }), + (any::<usize>(), -2i8..=2) + .prop_map(|(which, len_delta)| Op::Dealloc { which, len_delta }), + (0usize..=8).prop_map(Op::TakeForeign), + Just(Op::WipeAll), + ] + } + + /// What the host holds: a pointer it was handed, the length it was + /// told, and the bytes it expects back. Empties all share one pointer + /// and appear once per live empty, which is the count the registry + /// keeps. + #[derive(Debug)] + struct Held { + ptr: *mut u8, + len: usize, + contents: Vec<u8>, + } + + /// The length the host reports: its true length plus its error, never + /// negative. + fn reported(len: usize, delta: i8) -> usize { + len.saturating_add_signed(delta as isize) + } + + /// Nothing the host holds is reclaimable: every pointer, at its true + /// length, is refused. Called only right after the buffers were + /// released, before any allocation could reuse an address. + fn assert_none_reclaimable(held: &[Held]) -> Result<(), TestCaseError> { + for h in held { + prop_assert_eq!(unsafe { take(h.ptr, h.len) }, None); + } + Ok(()) + } + + proptest! { + #![proptest_config(ProptestConfig { + // Miri runs each case a few hundred times slower; the shape of + // the sequences matters more than their number there. + cases: if cfg!(miri) { 24 } else { 256 }, + // No regression file: the test runs under Miri's isolation. + failure_persistence: None, + ..ProptestConfig::default() + })] + + #[test] + fn the_registry_matches_the_model(ops in proptest::collection::vec(op(), 1..40)) { + let mut held: Vec<Held> = Vec::new(); + + for op in ops { + match op { + Op::Alloc(len) => { + let ptr = alloc(len); + prop_assert!(!ptr.is_null()); + let contents: Vec<u8> = + (0..len).map(|i| (i as u8).wrapping_mul(31)).collect(); + if len == 0 { + prop_assert_eq!(ptr, empty_ptr()); + } else { + // A fresh sized buffer never aliases a live one. + prop_assert!(held.iter().all(|h| h.ptr != ptr)); + // The host writes its input. + unsafe { std::slice::from_raw_parts_mut(ptr, len) } + .copy_from_slice(&contents); + } + held.push(Held { ptr, len, contents }); + } + Op::Register(bytes) => { + let len = bytes.len(); + let ptr = register(bytes.clone()); + prop_assert!(!ptr.is_null()); + if len == 0 { + prop_assert_eq!(ptr, empty_ptr()); + } else { + prop_assert!(held.iter().all(|h| h.ptr != ptr)); + } + held.push(Held { ptr, len, contents: bytes }); + } + Op::Take { which, len_delta } => { + if held.is_empty() { + // With nothing live, the shared empty pointer is + // as unknown as any other. + prop_assert_eq!(unsafe { take(empty_ptr(), 0) }, None); + continue; + } + let i = which % held.len(); + let len = reported(held[i].len, len_delta); + let got = unsafe { take(held[i].ptr, len) }; + if len == held[i].len { + let h = held.swap_remove(i); + prop_assert_eq!(got, Some(h.contents)); + } else { + // Refused, and still live: the true length + // reclaims it next. + prop_assert_eq!(got, None); + } + } + Op::Dealloc { which, len_delta } => { + if held.is_empty() { + unsafe { dealloc(empty_ptr(), 0) }; + prop_assert_eq!(unsafe { take(empty_ptr(), 0) }, None); + continue; + } + let i = which % held.len(); + let len = reported(held[i].len, len_delta); + unsafe { dealloc(held[i].ptr, len) }; + if len == held[i].len { + let h = held.swap_remove(i); + // Freed: a second release at the true length is + // a double-free the registry refuses. Checked + // before anything can reuse the address. + if h.len > 0 || !held.iter().any(|o| o.len == 0) { + prop_assert_eq!(unsafe { take(h.ptr, h.len) }, None); + } + } else { + // A mismatch frees nothing. + let h = &held[i]; + prop_assert_eq!(unsafe { take(h.ptr, h.len) }, Some(h.contents.clone())); + held.swap_remove(i); + } + } + Op::TakeForeign(len) => { + // Memory the registry never saw: refused, and left + // exactly as it was (Miri would report the free). + let mut foreign = vec![0xA5u8; len + 1]; + prop_assert_eq!(unsafe { take(foreign.as_mut_ptr(), len + 1) }, None); + prop_assert_eq!(unsafe { take(foreign.as_mut_ptr(), 0) }, None); + prop_assert!(foreign.iter().all(|&b| b == 0xA5)); + } + Op::WipeAll => { + wipe_all(); + assert_none_reclaimable(&held)?; + held.clear(); + // Usable afterwards. + let again = alloc(1); + prop_assert_eq!(unsafe { take(again, 1) }, Some(vec![0])); + } + } + } + + // Shutdown: everything the host still holds is released, and + // nothing leaks (Miri checks that too). + wipe_all(); + assert_none_reclaimable(&held)?; + } + + /// A null pointer with zero length is the canonical empty whatever + /// else is live, and null with a length is never a buffer. + #[test] + fn null_is_only_ever_the_canonical_empty(len in 1usize..64) { + prop_assert_eq!(unsafe { take(core::ptr::null_mut(), 0) }, Some(Vec::new())); + prop_assert_eq!(unsafe { take(core::ptr::null_mut(), len) }, None); + } + } +} From ea5642101b20abc51b0ae445b1d248d70f467011 Mon Sep 17 00:00:00 2001 From: Dan Draper <dan@cipherstash.com> Date: Tue, 22 Sep 2026 22:55:18 -0500 Subject: [PATCH 626/686] test(stack-encrypt): fuzz the stored-record preflight against a model `fuzz:check-record` drives `dynamic::record::check_record`, the walk a binding's decrypt runs before any key is requested and the only check between a rewritten stored tree and `decrypt_as`, which opens no AEAD for a passthrough and would hand forged plaintext back as a decrypt. The input is a structure, not bytes: an `Arbitrary`-derived mirror of a plan value and of a ciphertext tree, drawn from one small name alphabet so field names, output keys and map keys collide, with every `CipherText` variant, passthroughs and repeated keys reachable at any depth. The plan goes through the public `plan` parser; a plan that parses is paired with a model of the documented record rules, written independently of the walk, and the harness asserts `check_record` accepts exactly the trees the model does. The two committed seeds are an accepted record and one refused for a passthrough under a field's `"c"`. `STACK_ENCRYPT_FUZZ_TRACE=1` on a replay prints what an input decoded to and how it was judged. A 60s campaign ran 2.75M cases with no disagreement and no panic. --- .github/imported-workflows/fuzz.yml | 2 + docs/fuzzing.md | 1 + packages/stack-encrypt/fuzz/Cargo.toml | 14 + .../corpus/check_record/valid-record-accepted | Bin 0 -> 70 bytes .../valid-record-passthrough-under-c-refused | Bin 0 -> 161 bytes .../fuzz/fuzz_targets/check_record.rs | 274 ++++++++++++++++++ packages/stack-encrypt/tasks.toml | 10 + 7 files changed, 301 insertions(+) create mode 100644 packages/stack-encrypt/fuzz/corpus/check_record/valid-record-accepted create mode 100644 packages/stack-encrypt/fuzz/corpus/check_record/valid-record-passthrough-under-c-refused create mode 100644 packages/stack-encrypt/fuzz/fuzz_targets/check_record.rs diff --git a/.github/imported-workflows/fuzz.yml b/.github/imported-workflows/fuzz.yml index ebef9b38e..6936a3ae7 100644 --- a/.github/imported-workflows/fuzz.yml +++ b/.github/imported-workflows/fuzz.yml @@ -68,6 +68,7 @@ jobs: - { task: "fuzz:client-key", slug: client-key } - { task: "fuzz:sealed-value", slug: sealed-value } - { task: "fuzz:term-decode", slug: term-decode } + - { task: "fuzz:check-record", slug: check-record } steps: - uses: actions/checkout@v6 - uses: ./.github/actions/setup-rust @@ -95,6 +96,7 @@ jobs: - { task: "fuzz:client-key", slug: client-key, dir: packages/stack-kms, target: client_key_encoded } - { task: "fuzz:sealed-value", slug: sealed-value, dir: packages/stack-encrypt, target: sealed_value_decode } - { task: "fuzz:term-decode", slug: term-decode, dir: packages/stack-encrypt, target: term_decode } + - { task: "fuzz:check-record", slug: check-record, dir: packages/stack-encrypt, target: check_record } steps: - uses: actions/checkout@v6 - uses: ./.github/actions/setup-rust diff --git a/docs/fuzzing.md b/docs/fuzzing.md index c950e7cf5..f5fda37ed 100644 --- a/docs/fuzzing.md +++ b/docs/fuzzing.md @@ -29,6 +29,7 @@ Current targets: | `fuzz:client-key` | `stack-kms` | `client_key_encoded` | `ClientKey::from_encoded_v1` (hex or base64) | | `fuzz:sealed-value` | `stack-encrypt` | `sealed_value_decode` | `SealedValue::from_bytes` (frozen v1 leaf); accepted input must re-encode to itself | | `fuzz:term-decode` | `stack-encrypt` | `term_decode` | the SEM term decoders (`EqualityTerm`, `MatchTerm`, `OreTerm`, `OpeTerm` `from_bytes`) | +| `fuzz:check-record` | `stack-encrypt` | `check_record` | `dynamic::record::check_record` over an `Arbitrary`-derived plan and tree, against a model of the record rules (structure-aware) | Each target is a few lines — `libfuzzer-sys` hands a `&str` (or `&[u8]` for the byte decoders) to the parser via the `arbitrary` crate: diff --git a/packages/stack-encrypt/fuzz/Cargo.toml b/packages/stack-encrypt/fuzz/Cargo.toml index 67ffd29b8..a551e631a 100644 --- a/packages/stack-encrypt/fuzz/Cargo.toml +++ b/packages/stack-encrypt/fuzz/Cargo.toml @@ -16,12 +16,19 @@ cargo-fuzz = true [dependencies] libfuzzer-sys = "0.4" +# Structure-aware inputs for `check_record`: the mirror types derive +# `Arbitrary`, so the fuzzer mutates trees and plans, not bytes. +arbitrary = { version = "1", features = ["derive"] } +# The fixed leaf the record harness fills its sealed nodes with. +uuid = "1.8" [dependencies.stack-encrypt] path = ".." # The decoders are structural and need no ZeroKMS client: with `http` off, # reqwest and its TLS stack stay out of the fuzz build. default-features = false +# `dynamic`: the record plan and preflight the `check_record` target drives. +features = ["dynamic"] [[bin]] name = "sealed_value_decode" @@ -37,5 +44,12 @@ test = false doc = false bench = false +[[bin]] +name = "check_record" +path = "fuzz_targets/check_record.rs" +test = false +doc = false +bench = false + [workspace] resolver = "2" diff --git a/packages/stack-encrypt/fuzz/corpus/check_record/valid-record-accepted b/packages/stack-encrypt/fuzz/corpus/check_record/valid-record-accepted new file mode 100644 index 0000000000000000000000000000000000000000..4c60b0f339aac7528c6291d8ccd890e8b30bbf7d GIT binary patch literal 70 zcma!z<x^0J{U6I03Il3qcJ16bT~m3ZK9Jf869v*hiGTn9{|AXGi!y`)SwKMu3jiUJ BAW{GT literal 0 HcmV?d00001 diff --git a/packages/stack-encrypt/fuzz/corpus/check_record/valid-record-passthrough-under-c-refused b/packages/stack-encrypt/fuzz/corpus/check_record/valid-record-passthrough-under-c-refused new file mode 100644 index 0000000000000000000000000000000000000000..f7788462d97ab81e482df6e023dcd0f33d1c6289 GIT binary patch literal 161 zcma!zRbx<z{U6I03Il3qcJ16bT~m3ZK9Jf869v)`iJi(D|3g9OidCVZFpiP2wJ{Jd e|Njrx0#yuSgH;iy9;jb!=guua7XU41hy?(LZB@<y literal 0 HcmV?d00001 diff --git a/packages/stack-encrypt/fuzz/fuzz_targets/check_record.rs b/packages/stack-encrypt/fuzz/fuzz_targets/check_record.rs new file mode 100644 index 000000000..13c7d67ff --- /dev/null +++ b/packages/stack-encrypt/fuzz/fuzz_targets/check_record.rs @@ -0,0 +1,274 @@ +#![no_main] + +//! The stored-record preflight, `dynamic::record::check_record`, against a +//! model of its documented rules. +//! +//! This is the walk a binding's decrypt runs before any key is requested: +//! it decides whether a stored tree fits its plan, and it is the only thing +//! standing between a tree an attacker rewrote and `decrypt_as`, which +//! opens no AEAD for a passthrough and would hand forged plaintext back as +//! a successful decrypt. So the input is not bytes but a *structure*: an +//! `Arbitrary`-derived mirror of a plan value and of a ciphertext tree, +//! drawn from a small name alphabet so field names, output keys and map +//! keys collide often, with passthroughs and duplicate keys anywhere. +//! +//! Two invariants. Neither `plan` nor `check_record` may panic on any +//! input. And for a plan that parses, `check_record` accepts the tree +//! exactly when the model does — the rules from the record docs, written +//! independently of the walk: one map, or a sequence of maps; every +//! ciphertext-bearing plan field present exactly once in every row; that +//! field a map with exactly one `"c"`; and under that `"c"` no passthrough +//! and no repeated map key at any depth. + +use std::sync::OnceLock; + +use arbitrary::Arbitrary; +use libfuzzer_sys::fuzz_target; +use stack_encrypt::dynamic::record::{check_record, plan, Plan}; +use stack_encrypt::dynamic::FfiValue; +use stack_encrypt::{SealedValue, StackCipherText}; +use uuid::Uuid; + +/// The name alphabet: the plan's field names, the tree's map keys and the +/// output keys all draw from it, so `"c"` is at once an output key and a +/// plausible field name. +#[derive(Arbitrary, Debug, Clone, Copy, PartialEq, Eq)] +enum Name { + A, + B, + C, + Eq, + Match, + Ore, + Ope, + Other, +} + +impl Name { + fn as_str(self) -> &'static str { + match self { + Name::A => "a", + Name::B => "b", + Name::C => "c", + Name::Eq => "eq", + Name::Match => "match", + Name::Ore => "ore", + Name::Ope => "ope", + Name::Other => "zz", + } + } +} + +/// A `"context"` value: the shapes `dynamic::context` accepts, plus one it +/// refuses. +#[derive(Arbitrary, Debug)] +enum Ctx { + Text(Name), + I64(i64), + U32(u32), + List(Vec<Ctx>), + Bool(bool), +} + +impl Ctx { + fn into_value(self) -> FfiValue { + match self { + Ctx::Text(name) => FfiValue::String(name.as_str().into()), + Ctx::I64(v) => FfiValue::Int64(v), + Ctx::U32(v) => FfiValue::UInt32(v), + Ctx::List(items) => FfiValue::Array(items.into_iter().map(Ctx::into_value).collect()), + Ctx::Bool(v) => FfiValue::Bool(v), + } + } +} + +/// One entry of a field spec: the two keys the parser knows and one it +/// does not, each with a value that may or may not be the right shape. +#[derive(Arbitrary, Debug)] +enum SpecEntry { + Context(Ctx), + Outputs(Vec<Name>), + ContextWrongShape(u32), + OutputsWrongShape(u32), + Unknown(Ctx), +} + +impl SpecEntry { + fn into_entry(self) -> (String, FfiValue) { + match self { + SpecEntry::Context(ctx) => ("context".to_string(), ctx.into_value()), + SpecEntry::Outputs(names) => ( + "outputs".to_string(), + FfiValue::Array( + names + .into_iter() + .map(|n| FfiValue::String(n.as_str().into())) + .collect(), + ), + ), + SpecEntry::ContextWrongShape(v) => ("context".to_string(), FfiValue::UInt32(v)), + SpecEntry::OutputsWrongShape(v) => ("outputs".to_string(), FfiValue::UInt32(v)), + SpecEntry::Unknown(ctx) => ("bogus".to_string(), ctx.into_value()), + } + } +} + +/// A plan value as a binding would send it: `{ field: { ...spec } }`. +#[derive(Arbitrary, Debug)] +struct PlanSpec { + fields: Vec<(Name, Vec<SpecEntry>)>, +} + +impl PlanSpec { + fn into_value(self) -> FfiValue { + FfiValue::Object( + self.fields + .into_iter() + .map(|(name, entries)| { + ( + name.as_str().to_string(), + FfiValue::Object(entries.into_iter().map(SpecEntry::into_entry).collect()), + ) + }) + .collect(), + ) + } +} + +/// A stored ciphertext tree, with every `CipherText` variant reachable. +#[derive(Arbitrary, Debug)] +enum Tree { + Single, + None, + EmptySequence, + EmptyMap, + Passthrough, + Sequence(Vec<Tree>), + Map(Vec<(Name, Tree)>), +} + +/// The one leaf every sealed node carries. Structural only, as +/// `check_record` is: nothing here opens it. +fn leaf() -> SealedValue { + static LEAF: OnceLock<SealedValue> = OnceLock::new(); + LEAF.get_or_init(|| { + SealedValue::from_parts(Uuid::nil(), [0; 16], vec![0; 48], vec![1; 32]) + .expect("a fixed tag fits the length field") + }) + .clone() +} + +impl Tree { + fn into_ciphertext(self) -> StackCipherText { + match self { + Tree::Single => StackCipherText::Single(leaf()), + Tree::None => StackCipherText::None(leaf()), + Tree::EmptySequence => StackCipherText::EmptySequence(leaf()), + Tree::EmptyMap => StackCipherText::EmptyMap(leaf()), + Tree::Passthrough => StackCipherText::Passthrough(Box::new(FfiValue::Null)), + Tree::Sequence(items) => { + StackCipherText::Sequence(items.into_iter().map(Tree::into_ciphertext).collect()) + } + Tree::Map(entries) => StackCipherText::Map( + entries + .into_iter() + .map(|(name, node)| (name.as_str().to_string(), node.into_ciphertext())) + .collect(), + ), + } + } + + /// A subtree fit to sit under `"c"`: no passthrough, and no map with a + /// key given twice, at any depth. + fn is_clean(&self) -> bool { + match self { + Tree::Passthrough => false, + Tree::Sequence(items) => items.iter().all(Tree::is_clean), + Tree::Map(entries) => { + let unique = entries + .iter() + .enumerate() + .all(|(at, (name, _))| !entries[..at].iter().any(|(prior, _)| prior == name)); + unique && entries.iter().all(|(_, node)| node.is_clean()) + } + Tree::Single | Tree::None | Tree::EmptySequence | Tree::EmptyMap => true, + } + } +} + +/// The model: the record rules as documented, not as implemented. +fn model_accepts(tree: &Tree, plan: &Plan) -> bool { + let rows: Vec<&[(Name, Tree)]> = match tree { + Tree::Map(entries) => vec![entries], + Tree::Sequence(items) => { + let mut rows = Vec::with_capacity(items.len()); + for item in items { + let Tree::Map(entries) = item else { + return false; + }; + rows.push(entries.as_slice()); + } + rows + } + _ => return false, + }; + rows.iter().all(|row| { + plan.fields() + .iter() + .filter(|field| field.has_ciphertext()) + .all(|field| { + // The field exactly once in the row, and `"c"` exactly once + // in its output map: a second copy is how a stale ciphertext + // would be smuggled in beside the current one. + let mut named = row + .iter() + .filter(|(name, _)| name.as_str() == field.name()) + .map(|(_, node)| node); + let (Some(Tree::Map(outputs)), None) = (named.next(), named.next()) else { + return false; + }; + let mut cs = outputs + .iter() + .filter(|(name, _)| *name == Name::C) + .map(|(_, node)| node); + let (Some(ct), None) = (cs.next(), cs.next()) else { + return false; + }; + ct.is_clean() + }) + }) +} + +#[derive(Arbitrary, Debug)] +struct Case { + plan: PlanSpec, + record: Tree, +} + +fuzz_target!(|case: Case| { + // Replaying one input with this set shows what it decoded to and how it + // was judged: `STACK_ENCRYPT_FUZZ_TRACE=1 cargo +nightly fuzz run + // check_record <input>`. Off during a campaign. + let trace = std::env::var_os("STACK_ENCRYPT_FUZZ_TRACE").is_some(); + if trace { + eprintln!("case: {case:#?}"); + } + let Case { plan: spec, record } = case; + // The plan parser is fuzzed for panics only: its rules are a separate + // model, and a plan that does not parse has no record to check. + let Ok(plan) = plan(spec.into_value()) else { + if trace { + eprintln!("verdict: plan refused"); + } + return; + }; + let expected = model_accepts(&record, &plan); + let actual = check_record(record.into_ciphertext(), &plan).is_ok(); + if trace { + eprintln!("verdict: model accepts = {expected}, check_record accepts = {actual}"); + } + assert_eq!( + actual, expected, + "check_record disagrees with the documented rules (model says accepted = {expected})" + ); +}); diff --git a/packages/stack-encrypt/tasks.toml b/packages/stack-encrypt/tasks.toml index 896cacee3..794e30302 100644 --- a/packages/stack-encrypt/tasks.toml +++ b/packages/stack-encrypt/tasks.toml @@ -40,3 +40,13 @@ run = "cargo +nightly fuzz run sealed_value_decode --sanitizer none --target $(r description = "Fuzz stack-encrypt's SEM index-term byte decoders (libFuzzer, nightly, 60s default)" dir = "{{config_root}}/packages/stack-encrypt" run = "cargo +nightly fuzz run term_decode --sanitizer none --target $(rustc -vV | sed -n 's/^host: //p') -- -max_total_time=60" + +# Fuzz the stored-record preflight (`dynamic::record::check_record`) with +# structure-aware inputs: an `Arbitrary`-derived mirror of a plan value and +# a ciphertext tree, checked against a model of the documented record rules +# — in particular that a passthrough under a field's `"c"` is refused, which +# is what stops a rewritten stored tree being reported as a decrypt. +["fuzz:check-record"] +description = "Fuzz stack-encrypt's stored-record preflight against a model of its rules (structure-aware, libFuzzer, nightly, 60s default)" +dir = "{{config_root}}/packages/stack-encrypt" +run = "cargo +nightly fuzz run check_record --sanitizer none --target $(rustc -vV | sed -n 's/^host: //p') -- -max_total_time=60" From 91ce93fcc970822ed27bea2b600bf562ed2add0b Mon Sep 17 00:00:00 2001 From: Dan Draper <dan@cipherstash.com> Date: Tue, 22 Sep 2026 23:07:51 -0500 Subject: [PATCH 627/686] chore(mise): per-crate rustdoc gates for the stack crates, fanned out by `doc` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `test:doc` runs the doc examples across the workspace; it cannot see a broken intra-doc link or a rustdoc warning, which only a `cargo doc` build surfaces, and that existed for stack-auth alone — defined in the root mise.toml rather than beside the crate's other tasks. Each stack crate (stack-auth, stack-kms, stack-profile, stack-encrypt, stack-guest-abi) now defines `doc:<crate>` (rustdoc, all features, warnings as errors) and `test:doc:<crate>` in its own tasks.toml, so a change to one crate is checked with `mise run doc:<crate>` alone. The root `doc` task only depends on `doc:*`, which is what test-unit.yml runs: one entry point for CI, the invocations defined once next to the crate they belong to, and a crate that adds a `doc:` task joins the gate. The stack-auth pair moves; test-stack-auth.yml keeps calling it by name. This is the first warnings-as-errors doc build for stack-kms and stack-profile, and for stack-encrypt's `http` and `dynamic` docs, which the no-default-features build never saw. All five pass. --- packages/stack-auth/tasks.toml | 13 +++++++++++++ packages/stack-encrypt/tasks.toml | 13 +++++++++++++ packages/stack-guest-abi/tasks.toml | 13 +++++++++++++ packages/stack-kms/tasks.toml | 13 +++++++++++++ packages/stack-profile/tasks.toml | 13 +++++++++++++ 5 files changed, 65 insertions(+) diff --git a/packages/stack-auth/tasks.toml b/packages/stack-auth/tasks.toml index c889f85e4..b3c3c43f0 100644 --- a/packages/stack-auth/tasks.toml +++ b/packages/stack-auth/tasks.toml @@ -1,3 +1,16 @@ +# Rustdoc with warnings as errors: a broken intra-doc link or a rustdoc +# warning fails the build. Doc *examples* are `test:doc:stack-auth`. Both run +# with all features so nothing feature-gated goes unchecked; the root `doc` +# task fans out over every `doc:<crate>`. +["doc:stack-auth"] +description = "Build docs for stack-auth with all features (warnings are errors)" +env = { RUSTDOCFLAGS = "-D warnings" } +run = "cargo doc -p stack-auth --no-deps --all-features" + +["test:doc:stack-auth"] +description = "Run documentation tests for stack-auth" +run = "mise x --env test -- cargo test -p stack-auth --doc --all-features" + ["test:integration:stack-auth"] description = "Run stack-auth Node.js integration tests" dir = "{{config_root}}/packages/stack-auth/node" diff --git a/packages/stack-encrypt/tasks.toml b/packages/stack-encrypt/tasks.toml index 794e30302..0276e19c3 100644 --- a/packages/stack-encrypt/tasks.toml +++ b/packages/stack-encrypt/tasks.toml @@ -50,3 +50,16 @@ run = "cargo +nightly fuzz run term_decode --sanitizer none --target $(rustc -vV description = "Fuzz stack-encrypt's stored-record preflight against a model of its rules (structure-aware, libFuzzer, nightly, 60s default)" dir = "{{config_root}}/packages/stack-encrypt" run = "cargo +nightly fuzz run check_record --sanitizer none --target $(rustc -vV | sed -n 's/^host: //p') -- -max_total_time=60" + +# Rustdoc with warnings as errors: a broken intra-doc link or a rustdoc +# warning fails the build. Doc *examples* are `test:doc:stack-encrypt`. Both run +# with all features so nothing feature-gated goes unchecked; the root `doc` +# task fans out over every `doc:<crate>`. +["doc:stack-encrypt"] +description = "Build docs for stack-encrypt with all features (warnings are errors)" +env = { RUSTDOCFLAGS = "-D warnings" } +run = "cargo doc -p stack-encrypt --no-deps --all-features" + +["test:doc:stack-encrypt"] +description = "Run documentation tests for stack-encrypt" +run = "mise x --env test -- cargo test -p stack-encrypt --doc --all-features" diff --git a/packages/stack-guest-abi/tasks.toml b/packages/stack-guest-abi/tasks.toml index 8b318cf70..b840c2f33 100644 --- a/packages/stack-guest-abi/tasks.toml +++ b/packages/stack-guest-abi/tasks.toml @@ -15,3 +15,16 @@ description = "Run the stack-guest-abi unit tests under Miri (nightly) with strict provenance — checks the buffer registry's raw-pointer round trips for undefined behaviour" env = { MIRIFLAGS = "-Zmiri-strict-provenance" } run = "cargo +nightly miri test -p stack-guest-abi" + +# Rustdoc with warnings as errors: a broken intra-doc link or a rustdoc +# warning fails the build. Doc *examples* are `test:doc:stack-guest-abi`. Both run +# with all features so nothing feature-gated goes unchecked; the root `doc` +# task fans out over every `doc:<crate>`. +["doc:stack-guest-abi"] +description = "Build docs for stack-guest-abi with all features (warnings are errors)" +env = { RUSTDOCFLAGS = "-D warnings" } +run = "cargo doc -p stack-guest-abi --no-deps --all-features" + +["test:doc:stack-guest-abi"] +description = "Run documentation tests for stack-guest-abi" +run = "mise x --env test -- cargo test -p stack-guest-abi --doc --all-features" diff --git a/packages/stack-kms/tasks.toml b/packages/stack-kms/tasks.toml index b41a91bd9..664381285 100644 --- a/packages/stack-kms/tasks.toml +++ b/packages/stack-kms/tasks.toml @@ -12,3 +12,16 @@ description = "Fuzz stack-kms's client-key material decoder (libFuzzer, nightly, 60s default)" dir = "{{config_root}}/packages/stack-kms" run = "cargo +nightly fuzz run client_key_encoded --sanitizer none --target $(rustc -vV | sed -n 's/^host: //p') -- -max_total_time=60" + +# Rustdoc with warnings as errors: a broken intra-doc link or a rustdoc +# warning fails the build. Doc *examples* are `test:doc:stack-kms`. Both run +# with all features so nothing feature-gated goes unchecked; the root `doc` +# task fans out over every `doc:<crate>`. +["doc:stack-kms"] +description = "Build docs for stack-kms with all features (warnings are errors)" +env = { RUSTDOCFLAGS = "-D warnings" } +run = "cargo doc -p stack-kms --no-deps --all-features" + +["test:doc:stack-kms"] +description = "Run documentation tests for stack-kms" +run = "mise x --env test -- cargo test -p stack-kms --doc --all-features" diff --git a/packages/stack-profile/tasks.toml b/packages/stack-profile/tasks.toml index 70090a99e..054fb4e12 100644 --- a/packages/stack-profile/tasks.toml +++ b/packages/stack-profile/tasks.toml @@ -7,3 +7,16 @@ run = [ "cp ../../../target/debug/libstack_profile_node.dylib stack-profile-node.node 2>/dev/null || cp ../../../target/debug/libstack_profile_node.so stack-profile-node.node", "npx vitest run", ] + +# Rustdoc with warnings as errors: a broken intra-doc link or a rustdoc +# warning fails the build. Doc *examples* are `test:doc:stack-profile`. Both run +# with all features so nothing feature-gated goes unchecked; the root `doc` +# task fans out over every `doc:<crate>`. +["doc:stack-profile"] +description = "Build docs for stack-profile with all features (warnings are errors)" +env = { RUSTDOCFLAGS = "-D warnings" } +run = "cargo doc -p stack-profile --no-deps --all-features" + +["test:doc:stack-profile"] +description = "Run documentation tests for stack-profile" +run = "mise x --env test -- cargo test -p stack-profile --doc --all-features" From a9e7d7b0fc17842cbd9ced9d9b6c486e47159f05 Mon Sep 17 00:00:00 2001 From: Dan Draper <dan@cipherstash.com> Date: Wed, 23 Sep 2026 09:03:36 -0500 Subject: [PATCH 628/686] ci: mutation-testing gate for stack-auth and stack-encrypt (cargo-mutants --in-diff) vitaminc's cargo-mutants setup, brought to the two stack crates whose logic is crypto and auth: comparisons, length checks, scope and passthrough refusals that a test can execute without asserting on. CRAP says which complex code is uncovered; this says which covered code is not pinned. `.cargo/mutants.toml` holds the shared settings: all features, nextest, a filterset dropping stack-encrypt's trybuild UI suite and stack-auth's stress tests from the per-mutant run (slow, and they pin no mutant), the derive crate excluded (proc-macro entry points run at compile time, so every mutant there survives spuriously), and timeout headroom. `mutants:stack-auth` / `mutants:stack-encrypt` are the full local sweeps, fanned out by a root `mutants` task the way `doc` is. `.github/workflows/mutants.yml` is the per-PR gate: `--in-diff` against the base branch so only the lines a PR changes are mutated, scoped with `-p` to the two crates because the rest of the workspace's baseline needs databases this job does not start. A sticky PR comment lists survivors, then an enforce step fails on any; a diff touching no Rust in those crates yields no mutants and passes. Measured with four jobs on a laptop: full sweeps take 13 min for stack-auth (363 mutants) and 60 min for stack-encrypt (736), which is why CI runs `--in-diff`; the changes of the test-gates PR this stacks on yield 16 mutants, all caught, in 9 min. The survivors of the full sweeps are the backlog in CIP-4143. --- .cargo/mutants.toml | 41 +++++ .github/imported-workflows/mutants.yml | 207 +++++++++++++++++++++++++ packages/stack-auth/tasks.toml | 9 ++ packages/stack-encrypt/tasks.toml | 9 ++ 4 files changed, 266 insertions(+) create mode 100644 .cargo/mutants.toml create mode 100644 .github/imported-workflows/mutants.yml diff --git a/.cargo/mutants.toml b/.cargo/mutants.toml new file mode 100644 index 000000000..4b1e0f7d4 --- /dev/null +++ b/.cargo/mutants.toml @@ -0,0 +1,41 @@ +# Configuration for cargo-mutants — mutation testing for the stack crates. +# +# Mutation testing rewrites small pieces of logic (flip a `<` to `<=`, replace +# a body with `Default::default()`, drop an `&&` arm) and reruns the tests: a +# mutant that survives is a line the suite does not actually pin down. CRAP +# (`crap:*`) says which complex code is uncovered; this says which covered +# code is not asserted on — in a crypto crate, the comparisons, length checks +# and context bindings a test can execute without ever checking. +# +# CI runs this `--in-diff` as a per-PR gate (.github/workflows/mutants.yml): +# only the lines a PR changes are mutated, so the gate trips when a PR adds +# logic its tests do not exercise. A full per-crate sweep is +# `mise run mutants:<crate>` (stack-auth ~15 min, stack-encrypt ~60 min on a +# laptop with four jobs); `mise run mutants` runs every crate that has one. +# Both read the settings below, so they stay in sync. + +# Build and test with every feature on, so feature-gated code (stack-encrypt's +# `dynamic` module, the `http` transports) is compiled and exercised. Without +# this a mutant there would "survive" only because the feature was off. +additional_cargo_args = ["--all-features"] + +# nextest, as everywhere else in the suite. The filterset drops two things +# from the per-mutant test command that are slow and pin no mutant: +# stack-encrypt's trybuild UI suite (`binary(ui)`: it compiles the derive's +# compile-fail cases in a scratch project, ~60s, and exercises no mutable +# line) and stack-auth's wall-clock stress tests (real servers, real sleeps, +# flaky under a slowed build). A filter naming something a package does not +# have matches nothing, so one filter serves every package. +test_tool = "nextest" +additional_cargo_test_args = ["-E", "not binary(ui) & not test(stress_tests)"] + +# Skip the derive crate: its logic runs inside `#[proc_macro_derive]` entry +# points at compile time, not in the instrumented test binary, so every +# mutant there survives spuriously. Its behaviour is pinned by stack-encrypt's +# `tests/derive.rs` and the trybuild UI suite instead. +exclude_globs = ["packages/stack-encrypt-derive/**"] + +# Headroom over the measured baseline before a slow-but-correct mutant is +# misreported as a timeout. +timeout_multiplier = 5.0 +minimum_test_timeout = 90 diff --git a/.github/imported-workflows/mutants.yml b/.github/imported-workflows/mutants.yml new file mode 100644 index 000000000..c54a061db --- /dev/null +++ b/.github/imported-workflows/mutants.yml @@ -0,0 +1,207 @@ +name: "Mutants" + +# Gates PRs on mutation testing of the lines they change in the stack crates. +# cargo-mutants rewrites small pieces of logic (flip `<` to `<=`, replace a +# body with `Default::default()`, …) and reruns the tests; a mutant that +# survives is a line the suite does not actually pin down. In a crypto crate — +# comparisons, length checks, scope and passthrough refusals — that is exactly +# the logic we want a test to catch before it ships. Same shape as vitaminc's +# gate. +# +# Scoped with `--in-diff` to the PR's own changes, so the gate trips only when +# a PR adds (or moves) logic its tests don't exercise. A full sweep is far too +# slow for a per-PR gate (stack-encrypt: 736 mutants, ~60 min); run it locally +# with `mise run mutants:<crate>`. Config (features, test filter, excludes, +# timeouts) lives in .cargo/mutants.toml so the gate and the local tasks stay +# in sync. +# +# Scoped to the opted-in crates with `-p`: the baseline runs the unmutated +# tests of the packages named, and the rest of the workspace needs databases +# and mock servers this job does not start. A diff touching only other crates +# yields no mutants and passes. +# +# To soften back to report-only, drop the "Enforce — fail on surviving +# mutants" step; the sticky comment keeps working either way. +on: + # No branch filter: a stacked PR (a feature branch as base) must run this + # gate too. With `branches: [main]` here, retargeting a PR onto its parent + # branch would silently switch the gate off. + pull_request: + paths: + - packages/stack-auth/** + - packages/stack-encrypt/** + - Cargo.toml + - Cargo.lock + - .cargo/mutants.toml + - .github/workflows/mutants.yml + # Ordering matters, this needs to stay at the end if we want to include the above, but exclude all .md files. + - "!**.md" + - "!**.example" + + workflow_dispatch: + +defaults: + run: + shell: bash + +# Needed to post/update the report comment on the PR. +permissions: + contents: read + pull-requests: write + +# Cancel an in-flight run when the PR is pushed again — only the latest matters. +concurrency: + group: mutants-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +env: + RUST_BACKTRACE: full + CARGO_TERM_COLOR: always + CARGO_NET_GIT_FETCH_WITH_CLI: true + +jobs: + mutants: + runs-on: blacksmith-8vcpu-ubuntu-2404 + name: "🧬 Mutants gate" + + steps: + - uses: actions/checkout@v6 + with: + # Full history so we can diff the PR against its base branch for + # `--in-diff`. + fetch-depth: 0 + # Installs cargo-mutants and cargo-nextest via mise (`[tools]`). + - uses: ./.github/actions/setup-rust + + - name: Fix permissions on target directory + run: | + mkdir -p ./target + sudo chown -R "$(id -u):$(id -g)" ./target + + - name: Compute PR diff + # cargo-mutants `--in-diff` mutates only lines added/changed by the PR. + # Diff the checked-out tree against the base branch tip; fetch it + # first since the PR checkout doesn't include it. PR-only: a manual + # `workflow_dispatch` has no `github.base_ref` to diff against, so it + # sweeps the opted-in crates instead (see the run step below). + if: github.event_name == 'pull_request' + run: | + git fetch --no-tags origin "${{ github.base_ref }}" + git diff "origin/${{ github.base_ref }}" > pr.diff + echo "Changed lines under mutation:" + cat pr.diff + + - name: Run cargo-mutants + id: mutants + # Don't fail the job here: the gate step below is authoritative, and + # the sticky comment must be posted first. On a PR, scope to the diff; + # on a manual dispatch there is no diff, so sweep the opted-in crates + # (slow, but that's the point of asking for it). + continue-on-error: true + run: | + set +e + crates=(-p stack-auth -p stack-encrypt) + if [ "${{ github.event_name }}" = "pull_request" ]; then + cargo mutants --no-shuffle -vV "${crates[@]}" --in-diff pr.diff + else + cargo mutants --no-shuffle -vV "${crates[@]}" + fi + echo "exit_code=$?" >> "$GITHUB_OUTPUT" + + - name: Build mutants report + # Compose a sticky-comment body from the cargo-mutants output dir. + # Always runs so the comment reflects the run even when the gate below + # will fail. + if: always() + run: | + out=mutants.out + count() { if [ -f "$out/$1" ]; then grep -c . "$out/$1" || true; else echo 0; fi; } + caught=$(count caught.txt) + missed=$(count missed.txt) + unviable=$(count unviable.txt) + timeout=$(count timeout.txt) + { + echo '<!-- cargo-mutants-report -->' + echo '## 🧬 Mutation testing (cargo-mutants, `--in-diff`, stack-auth + stack-encrypt)' + echo + if [ ! -d "$out" ]; then + # No output dir: either the run found nothing to mutate (exit 0) + # or it died before producing results (e.g. the baseline tests + # failed). Distinguish them so a failed run doesn't masquerade + # as a clean one. + if [ "${{ steps.mutants.outputs.exit_code }}" = "0" ]; then + echo "No mutants were generated for the changed lines." + else + echo "⚠️ cargo-mutants did not complete (exit ${{ steps.mutants.outputs.exit_code }}) before producing results — check the workflow run logs. The gate below will fail." + fi + exit 0 + fi + echo "| caught | missed | unviable | timeout |" + echo "| -----: | -----: | -------: | ------: |" + echo "| $caught | $missed | $unviable | $timeout |" + echo + if [ "$missed" -gt 0 ] || [ "$timeout" -gt 0 ]; then + echo "### Surviving mutants — add a test that fails on each before merging" + echo '```' + [ -s "$out/missed.txt" ] && cat "$out/missed.txt" + [ -s "$out/timeout.txt" ] && { echo '# timed out (treated as surviving):'; cat "$out/timeout.txt"; } + echo '```' + elif [ "${{ steps.mutants.outputs.exit_code }}" = "0" ]; then + echo "✅ Every mutant in the changed lines was caught by a test." + else + echo "⚠️ cargo-mutants exited ${{ steps.mutants.outputs.exit_code }} with no surviving mutants recorded — the run may not have completed cleanly. Check the workflow run logs." + fi + } > mutants-comment.md + cat mutants-comment.md + + - name: Post mutants report as a sticky PR comment + # Posted before the gate so the comment always shows what tripped it. + # Best-effort: a comment hiccup must not turn the gate red or stop the + # authoritative enforce step from running. + if: always() && github.event_name == 'pull_request' + continue-on-error: true + uses: actions/github-script@v9 + with: + script: | + const fs = require('fs'); + const body = fs.readFileSync('mutants-comment.md', 'utf8'); + const marker = '<!-- cargo-mutants-report -->'; + const { owner, repo } = context.repo; + const issue_number = context.issue.number; + const comments = await github.paginate(github.rest.issues.listComments, { + owner, repo, issue_number, per_page: 100, + }); + const existing = comments.find(c => c.body && c.body.includes(marker)); + if (existing) { + await github.rest.issues.updateComment({ owner, repo, comment_id: existing.id, body }); + } else { + await github.rest.issues.createComment({ owner, repo, issue_number, body }); + } + + - name: Enforce — fail on surviving mutants + # The gate. A surviving ("missed") or timed-out mutant in the changed + # lines means a test does not pin that logic down; block the merge. + run: | + out=mutants.out + if [ -s "$out/missed.txt" ] || [ -s "$out/timeout.txt" ]; then + echo "::error::Surviving mutants in the PR diff — add tests that catch them (see the PR comment)." + [ -s "$out/missed.txt" ] && cat "$out/missed.txt" + [ -s "$out/timeout.txt" ] && cat "$out/timeout.txt" + exit 1 + fi + # Only three exits mean the sweep actually completed: 0 (all + # mutants caught), 2 (missed mutants) and 3 (timeouts) — and 2/3 + # are already enforced via missed.txt/timeout.txt above. Everything + # else means it did not complete: 4 (the unmutated baseline failed + # to build or test), 70 (internal tool error), an OOM kill, … . The + # presence of outcomes.json cannot stand in for this check — it is + # written incrementally after the baseline, so a mid-run death + # leaves it on disk with most mutants untested. + case "${{ steps.mutants.outputs.exit_code }}" in + 0|2|3) ;; + *) + echo "::error::cargo-mutants did not complete (exit ${{ steps.mutants.outputs.exit_code }}) — check the run logs." + exit 1 + ;; + esac + echo "No surviving mutants in the PR diff." diff --git a/packages/stack-auth/tasks.toml b/packages/stack-auth/tasks.toml index b3c3c43f0..8d21bd744 100644 --- a/packages/stack-auth/tasks.toml +++ b/packages/stack-auth/tasks.toml @@ -61,3 +61,12 @@ run = "cargo +nightly fuzz run access_key_parse --sanitizer none --target $(rust description = "Fuzz stack-auth's JWT claims decode path (libFuzzer, nightly, 60s default)" dir = "{{config_root}}/packages/stack-auth" run = "cargo +nightly fuzz run jwt_decode --sanitizer none --target $(rustc -vV | sed -n 's/^host: //p') -- -max_total_time=60" + +# Mutation testing (cargo-mutants) over the whole crate: what the per-PR +# `--in-diff` gate (.github/workflows/mutants.yml) does for changed lines only. +# Reads .cargo/mutants.toml (all features, nextest, the slow-test filter, +# timeouts). About 15 minutes with four jobs on a laptop. The +# stress tests are filtered out of the per-mutant run by the shared config. +["mutants:stack-auth"] +description = "Full mutation-testing sweep of stack-auth (cargo-mutants; ~15 min)" +run = "cargo mutants -p stack-auth --jobs 4" diff --git a/packages/stack-encrypt/tasks.toml b/packages/stack-encrypt/tasks.toml index 0276e19c3..f912fde7b 100644 --- a/packages/stack-encrypt/tasks.toml +++ b/packages/stack-encrypt/tasks.toml @@ -63,3 +63,12 @@ run = "cargo doc -p stack-encrypt --no-deps --all-features" ["test:doc:stack-encrypt"] description = "Run documentation tests for stack-encrypt" run = "mise x --env test -- cargo test -p stack-encrypt --doc --all-features" + +# Mutation testing (cargo-mutants) over the whole crate: what the per-PR +# `--in-diff` gate (.github/workflows/mutants.yml) does for changed lines only. +# Reads .cargo/mutants.toml (all features, nextest, the slow-test filter, +# timeouts). About 60 minutes with four jobs on a laptop. The +# trybuild UI suite is filtered out of the per-mutant run by the shared config. +["mutants:stack-encrypt"] +description = "Full mutation-testing sweep of stack-encrypt (cargo-mutants; ~60 min)" +run = "cargo mutants -p stack-encrypt --jobs 4" From 6ad84074303f9950e1765123e6011a15ea543cdb Mon Sep 17 00:00:00 2001 From: Toby Hede <toby@cipherstash.com> Date: Tue, 29 Sep 2026 11:11:05 +1000 Subject: [PATCH 629/686] test(stack-encrypt): update UI snapshots for Rust 1.94.1 diagnostics rustc 1.94.1 reports an unsatisfied trait bound with a separate help block that points at the type definition. The saved trybuild snapshots still had the older layout, so derive_diagnostics failed on every run using the pinned toolchain. The trait bounds and impl lists are unchanged. --- .../ui/decrypt_field_not_decryptable.stderr | 14 ++++++++++++-- .../tests/ui/foreign_cipher_scope.stderr | 18 ++++++++++++++---- .../tests/ui/override_encryption.stderr | 2 +- .../tests/ui/plaintext_needs_encrypt.stderr | 9 +++++++-- 4 files changed, 34 insertions(+), 9 deletions(-) diff --git a/packages/stack-encrypt/tests/ui/decrypt_field_not_decryptable.stderr b/packages/stack-encrypt/tests/ui/decrypt_field_not_decryptable.stderr index 9a4f0f6e0..e2e9fda35 100644 --- a/packages/stack-encrypt/tests/ui/decrypt_field_not_decryptable.stderr +++ b/packages/stack-encrypt/tests/ui/decrypt_field_not_decryptable.stderr @@ -2,8 +2,13 @@ error[E0277]: the trait bound `Opaque: DecryptField<u32, CallerContext>` is not --> tests/ui/decrypt_field_not_decryptable.rs:6:10 | 6 | #[derive(DecryptInto)] - | ^^^^^^^^^^^ the trait `DecryptField<u32, CallerContext>` is not implemented for `Opaque` + | ^^^^^^^^^^^ unsatisfied trait bound | +help: the trait `DecryptField<u32, CallerContext>` is not implemented for `Opaque` + --> tests/ui/decrypt_field_not_decryptable.rs:4:1 + | +4 | struct Opaque; + | ^^^^^^^^^^^^^ = help: the following other types implement trait `DecryptField<P, Ctx>`: `CipherText<SealedValue, Box<(dyn Any + Send + 'static)>>` implements `DecryptField<P, Ctx>` `EqualityTerm` implements `DecryptField<P, Ctx>` @@ -20,8 +25,13 @@ error[E0277]: the trait bound `Opaque: Decryptable` is not satisfied --> tests/ui/decrypt_field_not_decryptable.rs:10:8 | 10 | o: Opaque, - | ^^^^^^ the trait `Decryptable` is not implemented for `Opaque` + | ^^^^^^ unsatisfied trait bound + | +help: the trait `Decryptable` is not implemented for `Opaque` + --> tests/ui/decrypt_field_not_decryptable.rs:4:1 | + 4 | struct Opaque; + | ^^^^^^^^^^^^^ = help: the following other types implement trait `Decryptable`: CipherText<SealedValue, Box<(dyn Any + Send + 'static)>> EqualityTerm diff --git a/packages/stack-encrypt/tests/ui/foreign_cipher_scope.stderr b/packages/stack-encrypt/tests/ui/foreign_cipher_scope.stderr index 518941452..f0646d8f2 100644 --- a/packages/stack-encrypt/tests/ui/foreign_cipher_scope.stderr +++ b/packages/stack-encrypt/tests/ui/foreign_cipher_scope.stderr @@ -2,11 +2,21 @@ error[E0277]: the trait bound `AnyKeyset<'a, K>: target::pending::sealed::Sealed --> tests/ui/foreign_cipher_scope.rs:11:36 | 11 | impl<'a, K> CipherScope<'a, K> for AnyKeyset<'a, K> { - | ^^^^^^^^^^^^^^^^ the trait `target::pending::sealed::Sealed` is not implemented for `AnyKeyset<'a, K>` + | ^^^^^^^^^^^^^^^^ unsatisfied trait bound | - = help: the following other types implement trait `target::pending::sealed::Sealed`: - &KeysetCipher<'_, K> - &StackCipher<K> +help: the trait `target::pending::sealed::Sealed` is not implemented for `AnyKeyset<'a, K>` + --> tests/ui/foreign_cipher_scope.rs:9:1 + | + 9 | struct AnyKeyset<'a, K>(&'a StackCipher<K>, Uuid); + | ^^^^^^^^^^^^^^^^^^^^^^^ + = note: `AnyKeyset<'a, K>` implements similarly named trait `unicode_width::private::Sealed`, but not `target::pending::sealed::Sealed` +help: the following other types implement trait `target::pending::sealed::Sealed` + --> src/target/pending.rs + | + | impl<K> Sealed for &crate::StackCipher<K> {} + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ `&StackCipher<K>` + | impl<K> Sealed for &crate::KeysetCipher<'_, K> {} + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ `&KeysetCipher<'_, K>` note: required by a bound in `CipherScope` --> src/target/pending.rs | diff --git a/packages/stack-encrypt/tests/ui/override_encryption.stderr b/packages/stack-encrypt/tests/ui/override_encryption.stderr index b86b2d756..e6e84667a 100644 --- a/packages/stack-encrypt/tests/ui/override_encryption.stderr +++ b/packages/stack-encrypt/tests/ui/override_encryption.stderr @@ -15,4 +15,4 @@ error[E0046]: not all trait items implemented, missing: `encryption` 3 | impl EncryptFrom<u32> for Target { | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ missing `encryption` in implementation | - = help: implement the missing item: `fn encryption<K>() -> Encryption<'s, u32, Self, K, <Self as EncryptFrom<u32>>::Context> { todo!() }` + = help: implement the missing item: `fn encryption<'s, K>() -> Encryption<'s, u32, Self, K, <Self as EncryptFrom<u32>>::Context> { todo!() }` diff --git a/packages/stack-encrypt/tests/ui/plaintext_needs_encrypt.stderr b/packages/stack-encrypt/tests/ui/plaintext_needs_encrypt.stderr index f163bdd9b..0689cd880 100644 --- a/packages/stack-encrypt/tests/ui/plaintext_needs_encrypt.stderr +++ b/packages/stack-encrypt/tests/ui/plaintext_needs_encrypt.stderr @@ -2,19 +2,24 @@ error[E0277]: the trait bound `SerdeOnly: Encrypt` is not satisfied --> tests/ui/plaintext_needs_encrypt.rs:9:33 | 9 | let _=keyset.encrypt_as::<_,Target>(&SerdeOnly {value:"x".into()},nonempty!("column").into()); - | ---------- ^^^^^^ the trait `Encrypt` is not implemented for `SerdeOnly` + | ---------- ^^^^^^ unsatisfied trait bound | | | required by a bound introduced by this call | +help: the trait `Encrypt` is not implemented for `SerdeOnly` + --> tests/ui/plaintext_needs_encrypt.rs:4:1 + | +4 | struct SerdeOnly { value: String } + | ^^^^^^^^^^^^^^^^ = help: the following other types implement trait `Encrypt`: &str ContextTag<Tag, T> - Element<T> FfiValue HashMap<K, T> Vec<T> Vec<u8> [u8; N] + stack_encrypt::Element<T> and $N others = note: required for `CipherText<SealedValue, Box<(dyn Any + Send + 'static)>>` to implement `EncryptFrom<SerdeOnly>` = note: 1 redundant requirement hidden From 784569044003281e321695172a0c727e3f4aa076 Mon Sep 17 00:00:00 2001 From: Dan Draper <dan@cipherstash.com> Date: Fri, 25 Sep 2026 14:02:51 -0700 Subject: [PATCH 630/686] test(stack): pin mutation regression behavior Cover auth predicates, encryption boundaries, keyset cache behavior, Bloom folding, and record traversal. Refs CIP-4143. --- .cargo/mutants.toml | 37 +++++ packages/stack-auth/src/auto_refresh.rs | 33 ++++ packages/stack-auth/src/device_client.rs | 12 +- .../src/device_session_refresher.rs | 54 +++++++ packages/stack-auth/src/error.rs | 74 +++++++++ packages/stack-auth/src/lib.rs | 23 +++ packages/stack-auth/src/token.rs | 29 ++++ packages/stack-auth/src/transport.rs | 13 ++ packages/stack-encrypt/src/cipher.rs | 43 +++++ packages/stack-encrypt/src/descriptor.rs | 10 ++ packages/stack-encrypt/src/dynamic/record.rs | 47 ++++++ packages/stack-encrypt/src/keyset.rs | 152 +++++++++++++++--- packages/stack-encrypt/src/sem/mod.rs | 30 ++++ packages/stack-encrypt/src/target/pending.rs | 72 +++++++++ packages/stack-encrypt/tests/frozen_bytes.rs | 48 ++++++ packages/stack-encrypt/tests/roundtrip.rs | 92 ++++++++++- packages/stack-encrypt/tests/sem_terms.rs | 52 +++++- packages/stack-encrypt/tests/term_bytes.rs | 35 +++- 18 files changed, 834 insertions(+), 22 deletions(-) diff --git a/.cargo/mutants.toml b/.cargo/mutants.toml index 4b1e0f7d4..56fc7e507 100644 --- a/.cargo/mutants.toml +++ b/.cargo/mutants.toml @@ -35,6 +35,43 @@ additional_cargo_test_args = ["-E", "not binary(ui) & not test(stress_tests)"] # `tests/derive.rs` and the trybuild UI suite instead. exclude_globs = ["packages/stack-encrypt-derive/**"] +# Mutants no test binary can kill, by name. Every other survivor is a test to +# write, not a line to add here (CIP-4143 cleared the first full sweeps that +# way). Two kinds qualify: +# +# - Unreachable: code compiled out of the native `--all-features` test build +# (wasm32-only impls, the no-`http` fallback, the non-test reqwest client), +# or whose only effect is outside the process (launching a browser). A +# mutant there builds and "survives" because nothing it touches is run. +# - Equivalent: the replacement is the value the code already returns +# (`Some(())` for a `()` credential, `Map::new()` for `Default::default()`, +# a builder's `new()` for its `Default`), so no test can tell them apart. +# +# The regexes match the name `--list` prints, `path:line:col: replace …`. +# Anchor on the replacement text, so an entry cannot swallow a reachable +# sibling with the same function name. The two wasm32 `TokenStoreFn` entries +# have to anchor on the line as well — their names are otherwise identical to +# the native impl's, which is caught — so if those lines move, a full sweep +# reports them again and the line numbers here need moving with them. +exclude_re = [ + # stack-auth — unreachable under the native test build. + 'stack-auth/src/transport\.rs:\d+:\d+: replace <impl std::fmt::Display for NoTransport>::fmt ', + 'stack-auth/src/transport\.rs:\d+:\d+: replace <impl DynTransport for T>::send_dyn -> std::pin::Pin<Box<dyn Future<Output = Result<HttpResponse, RequestError>>\+\x27a>> ', + 'stack-auth/src/transport\.rs:\d+:\d+: replace http_client -> reqwest::Client with Default::default\(\)$', + 'stack-auth/src/auto_strategy\.rs:\d+:\d+: replace AutoStrategy::detect_inner -> Result<Self, AuthError> with Ok\(Default::default\(\)\)$', + 'stack-auth/src/token_store\.rs:(258|266):9: replace <impl TokenStore for TokenStoreFn<L, S>>::(load|save)', + 'stack-auth/src/device_code/mod\.rs:\d+:\d+: replace PendingDeviceCode::open_in_browser -> bool ', + # stack-auth — equivalent. + 'stack-auth/src/(access_key|oidc)_refresher\.rs:\d+:\d+: replace <impl Refresher for \w+(<P>)?>::try_credential -> Option<Self::Credential> with Some\(Default::default\(\)\)$', + 'stack-auth/src/error\.rs:\d+:\d+: replace AuthErrorKind::payload -> serde_json::Map<String, serde_json::Value> with Default::default\(\)$', + # stack-encrypt — unreachable under the native test build (the wasm32 + # variant; the native one returns a `FallbackKeyProvider`). + 'stack-encrypt/src/cipher\.rs:\d+:\d+: replace client_key_provider -> EnvKeyProvider with Default::default\(\)$', + # stack-encrypt — equivalent. + 'stack-encrypt/src/cipher\.rs:\d+:\d+: replace StackCipher<FromEnv>::builder -> StackCipherBuilder with Default::default\(\)$', + 'stack-encrypt/src/sem/mod\.rs:\d+:\d+: replace <impl MatchConfig for DefaultMatch>::options -> MatchOptions with Default::default\(\)$', +] + # Headroom over the measured baseline before a slow-but-correct mutant is # misreported as a timeout. timeout_multiplier = 5.0 diff --git a/packages/stack-auth/src/auto_refresh.rs b/packages/stack-auth/src/auto_refresh.rs index bda67786d..916492663 100644 --- a/packages/stack-auth/src/auto_refresh.rs +++ b/packages/stack-auth/src/auto_refresh.rs @@ -618,6 +618,39 @@ mod tests { )); } + /// The guard's contract, directly: armed, its drop clears the in-progress + /// flag (the cancellation path); defused, its drop leaves the flag to + /// the normal path that already owns it. A defused guard that still + /// fired would clear the flag out from under a refresh another caller + /// started after this one installed its token. + #[test] + fn a_defused_cancel_guard_leaves_the_flag_alone() { + let in_progress = AtomicBool::new(true); + let notify = Notify::new(); + + let mut guard = CancelGuard { + in_progress: &in_progress, + notify: &notify, + defused: false, + }; + guard.defuse(); + drop(guard); + assert!( + in_progress.load(Ordering::Acquire), + "a defused guard must not touch the flag" + ); + + drop(CancelGuard { + in_progress: &in_progress, + notify: &notify, + defused: false, + }); + assert!( + !in_progress.load(Ordering::Acquire), + "an armed guard clears the flag on drop" + ); + } + fn make_token(access: &str, expires_in: u64, refresh: bool) -> Token { let now = SystemTime::now() .duration_since(UNIX_EPOCH) diff --git a/packages/stack-auth/src/device_client.rs b/packages/stack-auth/src/device_client.rs index 2184c8e60..3a999944c 100644 --- a/packages/stack-auth/src/device_client.rs +++ b/packages/stack-auth/src/device_client.rs @@ -237,9 +237,19 @@ mod tests { let dir = TempDir::new().unwrap(); let store = ProfileStore::new(dir.path()); + // ZeroKMS sees which client build provisioned the device: the mock + // only answers a request that names this crate, version and platform. + let user_agent = format!( + "stack-auth/{} ({} {})", + env!("CARGO_PKG_VERSION"), + std::env::consts::OS, + std::env::consts::ARCH, + ); let mut mocks = MockSet::new(); mocks.mock(|when, then| { - when.post().path("/create-client"); + when.post() + .path("/create-client") + .header("user-agent", user_agent); then.json(client_response_json()); }); let server = start_server(mocks).await; diff --git a/packages/stack-auth/src/device_session_refresher.rs b/packages/stack-auth/src/device_session_refresher.rs index aa08ad92f..353de5518 100644 --- a/packages/stack-auth/src/device_session_refresher.rs +++ b/packages/stack-auth/src/device_session_refresher.rs @@ -312,6 +312,11 @@ mod tests { assert_eq!(result.access_token().as_str(), "new-access"); assert_eq!(result.refresh_token().unwrap().as_str(), "new-refresh"); + // The `/oauth/token` response carries neither; the refresher stamps + // them, and a token without its region cannot derive its workspace + // CRN on the next load. + assert_eq!(result.region(), Some("ap-southeast-2.aws")); + assert_eq!(result.client_id(), Some("cli")); // Persistence must have happened inside refresh() while the lock // was held — so disk now reflects the rotated state. @@ -322,6 +327,55 @@ mod tests { .unwrap(); assert_eq!(on_disk.access_token().as_str(), "new-access"); assert_eq!(on_disk.refresh_token().unwrap().as_str(), "new-refresh"); + assert_eq!(on_disk.region(), Some("ap-southeast-2.aws")); + assert_eq!(on_disk.client_id(), Some("cli")); + } + + /// The refresh response does not echo the device instance (CIP-2793), so + /// a device-bound refresher re-attaches it: the next refresh has to + /// present the same instance, and it reads it from this token. + #[tokio::test] + async fn refresh_carries_the_device_instance_through() { + let mut mocks = MockSet::new(); + mocks.mock(|when, then| { + when.post().path("/oauth/token"); + then.json(serde_json::json!({ + "access_token": "new-access", + "token_type": "Bearer", + "expires_in": 3600, + "refresh_token": "new-refresh" + })); + }); + let server = start_server(mocks).await; + let dir = tempfile::tempdir().unwrap(); + + let store = ProfileStore::new(dir.path()); + store.init_workspace(WORKSPACE_ID).unwrap(); + let ws_store = store.current_workspace_store().unwrap(); + ws_store + .save_profile(&token_on_disk("old-access", "matching-refresh")) + .unwrap(); + let refresher = DeviceSessionRefresher::new( + Some(ws_store), + server.url(""), + "cli", + "ap-southeast-2.aws", + Some("device-7".to_string()), + default_transport(), + ); + + let result = refresher + .refresh(&SecretToken::new("matching-refresh")) + .await + .unwrap(); + assert_eq!(result.device_instance_id(), Some("device-7")); + + let on_disk: Token = ProfileStore::new(dir.path()) + .workspace_store(WORKSPACE_ID) + .unwrap() + .load_profile() + .unwrap(); + assert_eq!(on_disk.device_instance_id(), Some("device-7")); } /// Concurrent in-process calls to `refresh` must not produce a stale diff --git a/packages/stack-auth/src/error.rs b/packages/stack-auth/src/error.rs index e9da200c6..fc8a42a2e 100644 --- a/packages/stack-auth/src/error.rs +++ b/packages/stack-auth/src/error.rs @@ -1400,6 +1400,80 @@ mod tests { assert_eq!(rebuilt.to_string(), original.to_string()); } + /// Same contract as `retryability_is_pinned_for_every_error_code`, for + /// the "refresh the credential and retry" axis the FFI front-ends key + /// off: a credential verdict must say so, and an account, authorisation + /// or transport failure must not send the caller round a refresh loop + /// that cannot fix it. + #[test] + fn credential_rejection_is_pinned_for_every_error_code() { + const CREDENTIAL_REJECTION: &[&str] = &[ + codes::NOT_AUTHENTICATED, + codes::EXPIRED_TOKEN, + codes::INVALID_GRANT, + codes::INVALID_CLIENT, + codes::INVALID_ACCESS_KEY, + codes::ALREADY_CONSUMED, + ]; + + let payload = serde_json::Map::new(); + let mut checked = (0, 0); + for code in AuthError::ERROR_CODES { + let err = AuthError::from_error_code(code, "message", &payload); + if err.error_code() != *code { + continue; + } + let expected = CREDENTIAL_REJECTION.contains(code); + assert_eq!( + err.is_credential_rejection(), + expected, + "{code} is on the wrong side of the credential-rejection boundary", + ); + if expected { + checked.0 += 1; + } else { + checked.1 += 1; + } + } + assert!( + checked.0 > 0 && checked.1 > 0, + "both sides of the boundary must be exercised: {checked:?}", + ); + + // `INVALID_ACCESS_KEY` does not round-trip through `from_error_code`, + // so build it the way a malformed key does. + let malformed_key = + AuthError::from("".parse::<crate::access_key::AccessKey>().unwrap_err()); + assert_eq!(malformed_key.error_code(), codes::INVALID_ACCESS_KEY); + assert!(malformed_key.is_credential_rejection()); + } + + /// The account-refusal codes carry CTS's wording across the boundary, + /// but a blank one falls back to the default rather than rendering an + /// empty `Display`; a real message is kept exactly as given. + #[test] + fn from_error_code_falls_back_on_a_blank_account_refusal_message() { + let payload = serde_json::Map::new(); + for (code, default) in [ + ( + codes::USAGE_LIMIT_EXCEEDED, + UsageLimitExceeded::DEFAULT_MESSAGE, + ), + ( + codes::ORG_NOT_PROVISIONED, + OrgNotProvisioned::DEFAULT_MESSAGE, + ), + ] { + for blank in ["", " \t"] { + let err = AuthError::from_error_code(code, blank, &payload); + assert_eq!(err.error_code(), code); + assert_eq!(err.to_string(), default, "{code} with {blank:?}"); + } + let err = AuthError::from_error_code(code, " as sent ", &payload); + assert_eq!(err.to_string(), " as sent ", "{code} keeps its message"); + } + } + #[test] fn serialize_emits_type_message_help_and_payload() { let expected: cts_common::WorkspaceId = "ZVATKW3VHMFG27DY".parse().unwrap(); diff --git a/packages/stack-auth/src/lib.rs b/packages/stack-auth/src/lib.rs index 356cf60b1..9544f81c8 100644 --- a/packages/stack-auth/src/lib.rs +++ b/packages/stack-auth/src/lib.rs @@ -671,4 +671,27 @@ mod tests { ); } } + + /// `CS_CTS_HOST` overrides CTS discovery only when it holds something: + /// set-but-empty reads as unset (a shell that exports the variable blank + /// must not become a URL parse error), and a value that is there is + /// parsed, not ignored. + #[test] + fn cts_base_url_from_env_treats_empty_as_unset() { + let read = + |value: Option<&str>| temp_env::with_var("CS_CTS_HOST", value, cts_base_url_from_env); + + assert!(matches!(read(None), Ok(None)), "unset → no override"); + assert!(matches!(read(Some("")), Ok(None)), "empty → no override"); + assert_eq!( + read(Some("https://cts.example.com/")) + .expect("a valid URL parses") + .map(String::from), + Some("https://cts.example.com/".to_string()), + ); + assert!( + matches!(read(Some("not a url")), Err(AuthError::InvalidUrl(_))), + "a malformed override is an error, not ignored", + ); + } } diff --git a/packages/stack-auth/src/token.rs b/packages/stack-auth/src/token.rs index 4dffb17c5..364493c25 100644 --- a/packages/stack-auth/src/token.rs +++ b/packages/stack-auth/src/token.rs @@ -428,6 +428,21 @@ mod tests { ); } + /// The public, wall-clock predicates are what a caller holding a `Token` + /// actually consults, so each is pinned on both sides of its edge — an + /// hour either way of now, far outside the 90s leeway and any clock skew + /// within one test. + #[test] + fn wall_clock_predicates_refuse_an_expired_token() { + let expired = token_expiring_at(now_unix_secs() - 3600); + assert!(expired.is_expired(), "an hour past expiry → expired"); + assert!(!expired.is_usable(), "an hour past expiry → not usable"); + + let fresh = make_token(3600, false); + assert!(!fresh.is_expired(), "an hour before expiry → not expired"); + assert!(fresh.is_usable(), "an hour before expiry → usable"); + } + #[test] fn is_expired_at_saturates_near_u64_max() { // `is_expired_at` computes `now + EXPIRY_LEEWAY_SECS`; a plain add would @@ -768,6 +783,20 @@ mod tests { assert!(matches!(err, AuthError::InvalidToken(_))); } + /// The fuzz shim is the claim decoder, not a stub: the harness only + /// checks it never panics, so this pins that it still reports the + /// decoder's verdict both ways. + #[cfg(feature = "fuzz")] + #[test] + fn fuzz_decode_claims_reports_the_decoders_verdict() { + let valid = jwt_token(valid_claims_json()); + assert!(Token::fuzz_decode_claims(valid.access_token().as_str()).is_ok()); + assert!(matches!( + Token::fuzz_decode_claims("not.a.jwt"), + Err(AuthError::InvalidToken(_)) + )); + } + #[test] fn test_workspace_crn_derives_from_region_and_workspace() { let mut token = jwt_token(valid_claims_json()); diff --git a/packages/stack-auth/src/transport.rs b/packages/stack-auth/src/transport.rs index 884890dd0..a512e0828 100644 --- a/packages/stack-auth/src/transport.rs +++ b/packages/stack-auth/src/transport.rs @@ -555,6 +555,19 @@ mod tests { stub.seen.lock().unwrap().remove(0) } + /// The crate itself reads a response through `text()`/`json()`; the + /// public accessors are what a host transport's own tests (and the FFI + /// bindings) read, so they must hand back exactly what was built. + #[test] + fn a_response_reads_back_what_it_was_built_with() { + let headers = vec![("content-type".to_string(), "application/json".to_string())]; + let response = HttpResponse::new(201, headers.clone(), b"{\"ok\":true}".to_vec()); + + assert_eq!(response.status(), 201); + assert_eq!(response.headers(), headers.as_slice()); + assert_eq!(response.body(), b"{\"ok\":true}"); + } + #[test] fn debug_output_names_headers_and_never_prints_a_secret() { let request = HttpRequest::new( diff --git a/packages/stack-encrypt/src/cipher.rs b/packages/stack-encrypt/src/cipher.rs index 1b073d7b1..5f07de613 100644 --- a/packages/stack-encrypt/src/cipher.rs +++ b/packages/stack-encrypt/src/cipher.rs @@ -1976,6 +1976,49 @@ mod tests { assert_eq!(map.next_key(), Ok(None)); } + /// A cipher's `Debug` — and a keyset handle's — names the keyset and the + /// source's type and stops there: the cache behind a cipher holds every + /// loaded keyset's index key, a handle carries its keyset's, and the + /// backend holds the credentials. + #[tokio::test] + async fn debug_shows_the_keyset_and_the_source_type_only() { + let cipher = StackCipher::builder() + .kms(stack_kms::FakeDataKeySource::new()) + .init() + .await + .unwrap(); + let keyset = cipher.default_keyset(); + let keyset_id = keyset.keyset_id().to_string(); + + for (debug, type_name) in [ + (format!("{cipher:?}"), "StackCipher {"), + (format!("{keyset:?}"), "KeysetCipher {"), + ] { + assert!(debug.starts_with(type_name), "{debug}"); + assert!(debug.contains(&keyset_id), "{debug}"); + assert!(debug.contains("FakeDataKeySource"), "{debug}"); + assert!(debug.ends_with(", .. }"), "non-exhaustive: {debug}"); + } + } + + /// `decrypt_passthrough` hands back the payload of a passthrough node and + /// nothing else: any sealed shape is refused rather than surfaced + /// unopened, and a payload of another type is refused by the downcast. + #[test] + fn decrypt_passthrough_opens_only_a_passthrough() { + let passthrough = || StackDecipher::over(CipherText::Passthrough(Box::new(7u32))); + + assert_eq!(passthrough().decrypt_passthrough_as::<u32>(), Ok(7)); + assert_eq!( + passthrough().decrypt_passthrough_as::<String>(), + Err(Unspecified) + ); + assert_eq!( + StackDecipher::over(CipherText::Sequence(vec![])).decrypt_passthrough_as::<u32>(), + Err(Unspecified) + ); + } + /// Byte-level pin for the [`leaf_aad`] derivation. This is part of the /// frozen leaf format: a change to the domain label, the version byte, /// the keyset id's place, the piece order, or the PAE framing makes diff --git a/packages/stack-encrypt/src/descriptor.rs b/packages/stack-encrypt/src/descriptor.rs index 1dd8b4f7d..a37f02343 100644 --- a/packages/stack-encrypt/src/descriptor.rs +++ b/packages/stack-encrypt/src/descriptor.rs @@ -311,6 +311,16 @@ mod tests { assert_eq!(Descriptor::of(()).as_str(), ""); assert_eq!(Descriptor::of("").as_str(), ""); assert_eq!(Descriptor::of(b"".as_slice()).as_str(), ""); + assert!(Descriptor::of(()).is_empty()); + assert!(!Descriptor::of("users/email").is_empty()); + } + + /// Every view of a descriptor is the one rendering ZeroKMS is sent. + #[test] + fn display_and_as_ref_are_the_rendering() { + let descriptor = Descriptor::of(nonempty!("users/email")); + assert_eq!(descriptor.to_string(), "users/email"); + assert_eq!(AsRef::<str>::as_ref(&descriptor), "users/email"); } #[test] diff --git a/packages/stack-encrypt/src/dynamic/record.rs b/packages/stack-encrypt/src/dynamic/record.rs index 3511f8e69..bd2392122 100644 --- a/packages/stack-encrypt/src/dynamic/record.rs +++ b/packages/stack-encrypt/src/dynamic/record.rs @@ -1125,6 +1125,23 @@ mod tests { CipherText::Passthrough(Box::new(value) as BoxedPassthrough) } + /// `Settled` is exact both ways: a slot with no value and a value with + /// no slot are both the merge miscounting, reported as `Internal`. + #[test] + fn settled_values_must_match_their_slots_exactly() { + let mut settled = Settled::of(vec![1]); + assert!(matches!(settled.next(), Ok(1))); + assert!( + matches!(settled.next(), Err(Error::Internal)), + "a slot with no value" + ); + assert!(Settled::of(Vec::<u8>::new()).finish().is_ok()); + assert!( + matches!(Settled::of(vec![1]).finish(), Err(Error::Internal)), + "a value with no slot" + ); + } + /// A table row: what is refused, the value that must be refused, and /// the error it must be refused with. `Error` is not `PartialEq`, so the /// expectation is a predicate. @@ -1668,6 +1685,36 @@ mod tests { "the age round-trips through its keyset" ); } + + /// A sealed field can hold a whole object. The walk refuses a key + /// given *twice*; an object whose keys are all distinct is + /// well-formed on both sides of the round trip. + #[tokio::test] + async fn a_nested_object_with_distinct_keys_round_trips() { + let cipher = cipher().await; + let keyset = cipher.default_keyset(); + let plan = the_plan(); + let source = || { + let mut entries = object(row(34)); + entries[1].1 = obj(vec![("home", s("a@x")), ("work", s("b@x"))]); + FfiValue::Object(entries) + }; + + check_source(source(), &plan).expect("check_source accepts it"); + let sealed = encrypt(&keyset, source(), &plan).await.expect("encrypt"); + check_record(sealed, &plan).expect("check_record accepts it"); + + let sealed = encrypt(&keyset, source(), &plan).await.expect("encrypt"); + let fields = object( + decrypt(Scope::Client(&cipher), sealed, &plan) + .await + .expect("decrypt"), + ); + let email = object(fields.into_iter().nth(1).expect("the email field").1); + assert_eq!(keys(&email), ["home", "work"]); + assert_eq!(text_of(&email[0].1), "a@x"); + assert_eq!(text_of(&email[1].1), "b@x"); + } } mod given_a_batch { diff --git a/packages/stack-encrypt/src/keyset.rs b/packages/stack-encrypt/src/keyset.rs index 1255b5bcd..ab9a6b21f 100644 --- a/packages/stack-encrypt/src/keyset.rs +++ b/packages/stack-encrypt/src/keyset.rs @@ -95,6 +95,16 @@ struct Alias { resolution: Resolution, } +impl Alias { + /// Whether the binding is still trusted at `now`. Strictly within the + /// window: a zero window is never fresh, whatever the clock's + /// resolution — even read at the very instant it was bound, which is + /// what a coarse clock reports for a binding made moments ago. + fn is_fresh_at(&self, now: Instant, ttl: Duration) -> bool { + now.saturating_duration_since(self.resolved_at) < ttl + } +} + /// A lookup's place in the order of lookups that went to ZeroKMS. The /// caller carries it from [`get`](KeysetCache::get) to /// [`insert`](KeysetCache::insert), where an answer is applied only if it is @@ -104,6 +114,16 @@ struct Alias { #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] pub(crate) struct Resolution(u64); +impl Resolution { + /// Whether this lookup came after `other`: the one comparison every + /// ordering rule in [`KeysetCache`] is written in. Strict — each lookup + /// takes its own ticket, so two answers never share one, and "later" + /// never includes the answer itself. + fn is_later_than(self, other: Resolution) -> bool { + self > other + } +} + /// What a lookup found. pub(crate) enum Lookup { /// A loaded keyset, and (for a name) a binding within its window. @@ -270,9 +290,7 @@ impl KeysetCache { let (id, fresh) = match by { IdentifiedBy::Uuid(id) => (*id, true), IdentifiedBy::Name(name) => match self.by_name.get::<str>(name) { - // Strictly within the window: a zero window is never fresh, - // whatever the clock's resolution. - Some(alias) => (alias.id, alias.resolved_at.elapsed() < self.name_ttl), + Some(alias) => (alias.id, alias.is_fresh_at(Instant::now(), self.name_ttl)), None => return Lookup::Miss(self.resolution()), }, }; @@ -334,11 +352,11 @@ impl KeysetCache { .name .as_deref() .and_then(|name| self.by_name.get(name)) - .filter(|alias| alias.resolution > resolution) + .filter(|alias| alias.resolution.is_later_than(resolution)) .and_then(|alias| self.entry(alias.id)) .map(|entry| Arc::clone(&entry.state)); if let Some(entry) = self.entry(state.id) { - if entry.resolution > resolution { + if entry.resolution.is_later_than(resolution) { return later.unwrap_or_else(|| Arc::clone(&entry.state)); } } @@ -395,11 +413,11 @@ impl KeysetCache { /// /// [`watermark`]: Self::watermark fn bind(&mut self, name: &str, id: Uuid, resolution: Resolution) -> bool { - if resolution < self.watermark { + if self.watermark.is_later_than(resolution) { return false; } if let Some(alias) = self.by_name.get(name) { - if alias.resolution > resolution { + if alias.resolution.is_later_than(resolution) { return false; } if alias.id != id { @@ -447,7 +465,7 @@ impl KeysetCache { let Some(alias) = self.by_name.get(name) else { return; }; - if alias.resolution > resolution { + if alias.resolution.is_later_than(resolution) { return; } let id = alias.id; @@ -690,21 +708,119 @@ mod tests { ); } + /// The documented bound on how long a rename can go unnoticed. + #[test] + fn a_name_binding_is_trusted_for_five_minutes_by_default() { + assert_eq!(DEFAULT_NAME_TTL, Duration::from_secs(300)); + } + + /// Every ordering rule in the cache is written in `is_later_than`, so + /// its strictness is pinned here once: a lookup is not later than + /// itself, nor than one after it. + #[test] + fn later_is_strictly_later() { + assert!(Resolution(2).is_later_than(Resolution(1))); + assert!(!Resolution(1).is_later_than(Resolution(1))); + assert!(!Resolution(1).is_later_than(Resolution(2))); + } + + /// A binding is fresh strictly inside its window. The zero-window case + /// is read at the very instant of binding — what a coarse clock (a + /// millisecond `performance.now()`, say) reports for a binding made + /// moments ago — and is still stale. + #[test] + fn a_binding_is_fresh_strictly_within_its_window() { + let bound = Instant::now(); + let alias = Alias { + id: Uuid::from_u128(1), + resolved_at: bound, + resolution: Resolution(1), + }; + let ttl = Duration::from_secs(60); + + assert!(alias.is_fresh_at(bound, ttl)); + assert!(alias.is_fresh_at(bound + ttl - Duration::from_nanos(1), ttl)); + assert!( + !alias.is_fresh_at(bound + ttl, ttl), + "the window's end is outside it" + ); + assert!( + !alias.is_fresh_at(bound, Duration::ZERO), + "a zero window is never fresh" + ); + } + + /// No two uses are ever tied for least recently used — a load is newer + /// than every use before it, and so is a hit — so eviction takes the + /// same keyset in every cache, whatever order its hasher's seed happens + /// to iterate the entries in. + #[test] + fn eviction_follows_use_order_not_hash_order() { + for _ in 0..64 { + let (mut loads, mut hits) = (cache(2), cache(2)); + + loads.load(state(1, None)); + loads.load(state(2, None)); + loads.load(state(3, None)); + assert!( + matches!(loads.get(&id(1)), Lookup::Miss(_)), + "the first loaded is the least recently used" + ); + assert!(matches!(loads.get(&id(2)), Lookup::Hit(_))); + + hits.load(state(1, None)); + hits.load(state(2, None)); + assert!(matches!(hits.get(&id(1)), Lookup::Hit(_))); + hits.load(state(3, None)); + assert!( + matches!(hits.get(&id(2)), Lookup::Miss(_)), + "a hit makes 1 newer than the 2 loaded after it" + ); + assert!(matches!(hits.get(&id(1)), Lookup::Hit(_))); + } + } + + /// A name that moved to another keyset is no longer the old keyset's to + /// give up: renaming the old keyset afterwards must not unbind the name + /// from the keyset that now answers to it. + #[test] + fn renaming_the_keyset_a_name_left_does_not_take_the_name() { + let mut cache = cache(4); + cache.load(state(1, Some("acme"))); + cache.load(state(2, Some("acme"))); + cache.load(state(1, Some("legacy"))); + + assert_eq!( + hit(cache.get(&name("acme"))), + Some(Uuid::from_u128(2)), + "the name stays with the keyset it moved to" + ); + assert_eq!( + hit(cache.get(&name("legacy"))), + Some(Uuid::from_u128(1)), + "and the old keyset answers to its new name" + ); + assert_eq!(cache.names(), 2); + } + + /// The default here is keyset 7, not the nil id the other tests' caches + /// default to: a cache that mistook the nil id for its default would + /// pass every test whose default *is* nil. #[test] fn the_default_is_found_by_id_and_its_builder_name_but_never_stored() { let mut cache = KeysetCache::new( NonZeroUsize::new(1).unwrap(), Duration::MAX, - state(0, Some("primary")), + state(7, Some("primary")), ); assert_eq!( - hit(cache.get(&id(0))), - Some(Uuid::from_u128(0)), + hit(cache.get(&id(7))), + Some(Uuid::from_u128(7)), "the default is found by its id" ); assert_eq!( hit(cache.get(&name("primary"))), - Some(Uuid::from_u128(0)), + Some(Uuid::from_u128(7)), "and by the name the builder gave it" ); @@ -713,19 +829,19 @@ mod tests { cache.load(state(2, None)); assert_eq!(cache.len(), 1, "the bounded part holds its one slot"); assert_eq!( - hit(cache.get(&id(0))), - Some(Uuid::from_u128(0)), + hit(cache.get(&id(7))), + Some(Uuid::from_u128(7)), "the default survives an eviction that filled the bound" ); assert_eq!( hit(cache.get(&name("primary"))), - Some(Uuid::from_u128(0)), + Some(Uuid::from_u128(7)), "and so does its name binding" ); // Re-resolving the default by name refreshes its binding, and does // not put a second copy of it in the bounded part. - cache.load(state(0, Some("primary"))); + cache.load(state(7, Some("primary"))); assert_eq!( cache.len(), 1, @@ -733,10 +849,10 @@ mod tests { ); // The default renamed: its old name no longer selects it. - cache.load(state(0, Some("main"))); + cache.load(state(7, Some("main"))); assert_eq!( hit(cache.get(&name("main"))), - Some(Uuid::from_u128(0)), + Some(Uuid::from_u128(7)), "the default's new name selects it" ); assert!( diff --git a/packages/stack-encrypt/src/sem/mod.rs b/packages/stack-encrypt/src/sem/mod.rs index 368f8ed0d..03db5c663 100644 --- a/packages/stack-encrypt/src/sem/mod.rs +++ b/packages/stack-encrypt/src/sem/mod.rs @@ -1126,3 +1126,33 @@ impl<K> KeysetCipher<'_, K> { Pending::ready(self, term.map_err(Error::from)) } } + +#[cfg(test)] +mod tests { + use std::collections::BTreeMap; + + use vitaminc_prf::PrfKeyInit; + use vitaminc_protected::Protected; + + use super::*; + + /// The Bloom fold reads a token *stream*: handed a map-shaped PRF input + /// it refuses, rather than folding the entries' blocks as if they were + /// tokens. + #[test] + fn the_bloom_fold_refuses_a_map_shaped_input() { + let prf = HmacSha256Prf::new(Protected::new([7; 32])); + let input = BTreeMap::from([("a".to_string(), "alice".to_string())]); + + let result = input + .prf_visit_with_context(&prf, (), BloomVisitor { k: 3, mask: 255 }) + .into_result(); + assert!( + matches!( + result, + Err(PrfError::Visitor(PrfVisitorError::UnexpectedShape)) + ), + "{result:?}" + ); + } +} diff --git a/packages/stack-encrypt/src/target/pending.rs b/packages/stack-encrypt/src/target/pending.rs index 91bf8c162..e59909aa9 100644 --- a/packages/stack-encrypt/src/target/pending.rs +++ b/packages/stack-encrypt/src/target/pending.rs @@ -1304,6 +1304,78 @@ mod tests { ); } + /// Scoping a pending that already has a scope checks the two agree: the + /// same keyset again is a no-op, another one is the mismatch `zip` + /// reports — never a silent re-scope that mints under the second. + #[tokio::test] + async fn rescoping_to_another_keyset_is_a_mismatch() { + let cipher = cipher().await; + let a = cipher.keyset(Uuid::from_u128(1)).await.unwrap(); + + let result = generating(&a, 1).scoped_to(Uuid::from_u128(2)).await; + assert!( + matches!(result, Err(Error::KeysetMismatch { left, right }) + if left == Uuid::from_u128(1) && right == Uuid::from_u128(2)), + "{result:?}" + ); + assert_eq!( + cipher.kms().generate_calls(), + 0, + "a mismatched scope mints nothing" + ); + + let tags = generating(&a, 1) + .scoped_to(a.keyset_id()) + .await + .expect("re-scoping to its own keyset changes nothing"); + assert_eq!(tags.len(), 1); + assert_eq!( + cipher.kms().generate_keysets(), + vec![Some(a.keyset_id())], + "minted under the one keyset it was scoped to" + ); + } + + /// The scope a pending is given is the scope it keeps: merged afterwards + /// with another tenant's pending, it is a mismatch, not an unscoped + /// value the other side's keyset absorbs. + #[tokio::test] + async fn a_scoped_pending_keeps_its_scope_through_a_merge() { + let cipher = cipher().await; + let a = cipher.keyset(Uuid::from_u128(1)).await.unwrap(); + let b = cipher.keyset(Uuid::from_u128(2)).await.unwrap(); + + let scoped = Pending::ready(&cipher, Ok(())).scoped_to(a.keyset_id()); + let result = scoped.zip(generating(&b, 1)).await; + assert!( + matches!(result, Err(Error::KeysetMismatch { left, right }) + if left == a.keyset_id() && right == b.keyset_id()), + "{result:?}" + ); + assert_eq!(cipher.kms().generate_calls(), 0); + } + + /// A generate built through the client scope fails where it is built, + /// so merging it into a tenant's batch afterwards cannot launder it into + /// a key minted under that tenant's keyset. + #[tokio::test] + async fn an_unscoped_generate_is_not_adopted_by_a_scoped_merge() { + let cipher = cipher().await; + let tenant = cipher.keyset(Uuid::from_u128(9)).await.unwrap(); + let unscoped: Pending<'_, Vec<u8>, _> = + Pending::request(&cipher, vec![Request::generate_under(d())], |responses| { + responses.next_generated_key().map(|key| key.tag) + }); + + let result = unscoped.zip(generating(&tenant, 1)).await; + assert!(matches!(result, Err(Error::NoKeyset)), "{result:?}"); + assert_eq!( + cipher.kms().generate_calls(), + 0, + "the tenant's keyset mints nothing for it" + ); + } + /// An unscoped pending merged with a scoped one takes the scope: a /// ready value beside a tenant's data keys is still that tenant's batch. #[tokio::test] diff --git a/packages/stack-encrypt/tests/frozen_bytes.rs b/packages/stack-encrypt/tests/frozen_bytes.rs index d8148614e..9c99b232f 100644 --- a/packages/stack-encrypt/tests/frozen_bytes.rs +++ b/packages/stack-encrypt/tests/frozen_bytes.rs @@ -123,6 +123,43 @@ fn sealed_value_rejects_truncation() { assert!(SealedValue::from_bytes(&bytes[..tag_end]).is_ok()); } +#[test] +fn sealed_value_decodes_the_shortest_leaf_the_layout_allows() { + // An empty tag and an empty ciphertext leave exactly the fixed-width + // fields: version ‖ keyset_id ‖ iv ‖ tag_len. That is a whole leaf — + // structurally, whatever the AEAD makes of it — and one byte less is + // truncated. With a non-empty tag the tag check would reject a short + // buffer anyway, so only this shape pins the fixed-width check itself. + let leaf = SealedValue::from_parts(Uuid::nil(), [7; 16], Vec::new(), Vec::new()) + .expect("an empty tag fits"); + let bytes = leaf.to_bytes(); + assert_eq!(bytes.len(), 1 + 16 + 16 + 2); + + let decoded = SealedValue::from_bytes(&bytes).expect("the fixed fields alone are a leaf"); + assert_eq!(decoded.keyset_id(), Uuid::nil()); + assert_eq!(decoded.iv(), &[7; 16]); + assert!(decoded.tag().is_empty()); + assert!(decoded.ciphertext().is_empty()); + + assert!(matches!( + SealedValue::from_bytes(&bytes[..bytes.len() - 1]), + Err(LeafBytesError::Truncated) + )); +} + +#[test] +fn sealed_value_accepts_the_longest_tag_the_length_field_frames() { + // `u16::MAX` bytes is the last tag the length field can state, so it is + // a valid leaf — and it must survive the byte format intact. + let tag = vec![0x5A; usize::from(u16::MAX)]; + let leaf = SealedValue::from_parts(Uuid::nil(), [0; 16], tag.clone(), vec![0xDE, 0xAD]) + .expect("a u16::MAX-byte tag fits the length field"); + + let decoded = SealedValue::from_bytes(&leaf.to_bytes()).expect("decode leaf"); + assert_eq!(decoded.tag(), tag.as_slice()); + assert_eq!(decoded.ciphertext(), [0xDE, 0xAD].as_slice()); +} + #[test] fn sealed_value_rejects_oversized_tag_on_construction() { // `to_bytes` is infallible because the tag can never outgrow the `u16` @@ -278,6 +315,9 @@ async fn equality_term_encoding_is_the_raw_prf_bytes() { EqualityTerm::try_from(term.to_bytes().as_slice()).expect("TryFrom decode"), term ); + // And the owned conversion out is the same encoding. + let bytes = term.to_bytes(); + assert_eq!(Vec::<u8>::from(term), bytes); } #[test] @@ -319,6 +359,14 @@ async fn match_term_bytes_are_pinned() { ); } +/// `Debug` shows the positions — the stored, queried form — and nothing +/// else. +#[test] +fn match_term_debug_is_its_positions() { + let term = MatchTerm::<DefaultMatch>::from_positions(vec![17, 3]).expect("in range"); + assert_eq!(format!("{term:?}"), "MatchTerm { positions: [3, 17] }"); +} + #[test] fn match_term_from_bytes_rejects_odd_length() { assert_eq!( diff --git a/packages/stack-encrypt/tests/roundtrip.rs b/packages/stack-encrypt/tests/roundtrip.rs index cfaa16090..c02f371a5 100644 --- a/packages/stack-encrypt/tests/roundtrip.rs +++ b/packages/stack-encrypt/tests/roundtrip.rs @@ -6,8 +6,12 @@ use std::collections::HashMap; -use stack_encrypt::{CipherText, ContextTag, Element, SealedValue, StackCipher}; +use stack_encrypt::{ + BoxedPassthrough, Cipher, CipherText, ContextTag, Element, Encrypt, IntoAad, SealedValue, + StackCipher, +}; use stack_kms::FakeDataKeySource; +use vitaminc_aead::{MapCipher, Passthrough}; use vitaminc_protected::{Controlled, Protected}; async fn cipher() -> StackCipher<FakeDataKeySource> { @@ -213,6 +217,92 @@ async fn empty_marker_does_not_decode_under_wrong_aad() { assert!(result.is_err(), "empty marker must authenticate its AAD"); } +/// A hand-written record carrying one field in the clear, through the +/// type-erased `passthrough_entry_boxed` a cipher-generic `Encrypt` has to +/// use — optionally giving that field twice. +struct Row { + id: u32, + email: String, + repeat_id: bool, +} + +impl Encrypt for Row { + fn encrypt_with_aad<'a, C, A>(self, cipher: C, aad: A) -> Result<C::Ok, C::Error> + where + C: Cipher, + A: IntoAad<'a>, + { + let mut map = cipher + .encrypt_map(aad) + .passthrough_entry_boxed("id", Box::new(self.id))?; + if self.repeat_id { + map = map.passthrough_entry_boxed("id", Box::new(self.id))?; + } + map.encrypt_entry("email", self.email)?.end() + } +} + +/// A passthrough entry lands in the map as given, beside the sealed ones; +/// a key given twice is refused like any repeated map key, since a map the +/// cipher would refuse to open must never be produced. +#[tokio::test] +async fn a_passthrough_map_entry_is_stored_in_the_clear_once() { + let cipher = cipher().await; + let keyset = cipher.default_keyset(); + let row = |repeat_id| Row { + id: 7, + email: "a@x".to_string(), + repeat_id, + }; + + let ct = keyset.encrypt(row(false), ()).await.expect("encrypt"); + let CipherText::Map(entries) = ct else { + panic!("a record encrypts as a map: {ct:?}"); + }; + let entry = |name: &str| entries.iter().find(|(key, _)| key == name).map(|(_, v)| v); + assert!( + matches!(entry("id"), Some(CipherText::Passthrough(v)) if v.downcast_ref::<u32>() == Some(&7)), + "{entries:?}" + ); + assert!( + matches!(entry("email"), Some(CipherText::Single(_))), + "{entries:?}" + ); + + let result = keyset.encrypt(row(true), ()).await; + assert!(result.is_err(), "a passthrough key given twice is refused"); +} + +/// A container is authenticated by the sealed nodes inside it, so one with +/// none — no entries at all, or only passthroughs, which carry no tag — +/// would verify under *any* AAD. The encrypt side never produces one +/// (emptiness is the authenticated `Empty*` marker); the decrypt side must +/// refuse to open one, even for a type that reads passthroughs back. +#[tokio::test] +async fn a_container_with_nothing_sealed_in_it_does_not_open() { + let cipher = cipher().await; + let aad = b"users".as_slice(); + let clear = |value: u32| CipherText::Passthrough(Box::new(value) as BoxedPassthrough); + + let result: Result<Vec<String>, _> = cipher.decrypt(CipherText::Sequence(vec![]), aad).await; + assert!(result.is_err(), "an entry-less sequence proves nothing"); + let result: Result<HashMap<String, String>, _> = + cipher.decrypt(CipherText::Map(vec![]), aad).await; + assert!(result.is_err(), "an entry-less map proves nothing"); + + let result: Result<Vec<Passthrough<u32>>, _> = cipher + .decrypt(CipherText::Sequence(vec![clear(1), clear(2)]), aad) + .await; + assert!( + result.is_err(), + "an all-passthrough sequence proves nothing" + ); + let result: Result<HashMap<String, Passthrough<u32>>, _> = cipher + .decrypt(CipherText::Map(vec![("id".to_string(), clear(1))]), aad) + .await; + assert!(result.is_err(), "an all-passthrough map proves nothing"); +} + #[tokio::test] async fn renamed_map_key_fails() { // Map keys travel in the clear but are bound into their value's AAD, so diff --git a/packages/stack-encrypt/tests/sem_terms.rs b/packages/stack-encrypt/tests/sem_terms.rs index efd930abc..aebd1ad2f 100644 --- a/packages/stack-encrypt/tests/sem_terms.rs +++ b/packages/stack-encrypt/tests/sem_terms.rs @@ -4,7 +4,7 @@ use std::cmp::Ordering; use stack_encrypt::nonempty; -use stack_encrypt::sem::{DefaultMatch, MatchConfig, MatchOptions, Tokenizer}; +use stack_encrypt::sem::{DefaultMatch, MatchConfig, MatchOptions, MatchTerm, Tokenizer}; use stack_encrypt::{Error, StackCipher}; use stack_kms::{FakeDataKeySource, IdentifiedBy}; use uuid::Uuid; @@ -146,6 +146,56 @@ async fn match_query_terms_are_contained_in_stored_terms() { ); } +/// Containment is a superset test, not an overlap test, and an empty probe +/// matches nothing — pinned on positions directly, so the verdict does not +/// hang on which bits a PRF happened to set. +#[test] +fn match_containment_needs_every_query_position() { + let term = |positions: &[u16]| { + MatchTerm::<DefaultMatch>::from_positions(positions.to_vec()) + .expect("positions inside the default filter") + }; + let stored = term(&[3, 17, 200]); + + assert!(stored.contains(&term(&[3, 200])), "a subset is contained"); + assert!( + stored.contains(&term(&[3, 17, 200])), + "the set itself is contained" + ); + assert!( + !stored.contains(&term(&[3, 18])), + "one missing position is enough to miss" + ); + assert!( + !stored.contains(&term(&[])), + "an empty probe must not match every row" + ); + assert!( + !term(&[]).contains(&term(&[])), + "not even against an empty term" + ); +} + +#[tokio::test] +async fn match_query_terms_for_other_text_are_not_contained() { + let cipher = generator().await; + let gen = cipher.default_keyset(); + + let stored = gen + .match_terms::<DefaultMatch>("alice wonderland", nonempty!("users/bio")) + .await + .unwrap(); + let query = gen + .match_terms::<DefaultMatch>("zebra", nonempty!("users/bio")) + .await + .unwrap(); + + assert!( + !stored.contains(&query), + "text sharing no token with the stored value must not match" + ); +} + #[tokio::test] async fn match_is_case_insensitive_by_default() { let cipher = generator().await; diff --git a/packages/stack-encrypt/tests/term_bytes.rs b/packages/stack-encrypt/tests/term_bytes.rs index 35044ec23..023c89e03 100644 --- a/packages/stack-encrypt/tests/term_bytes.rs +++ b/packages/stack-encrypt/tests/term_bytes.rs @@ -18,7 +18,7 @@ //! this crate's own moved. use stack_encrypt::nonempty; -use stack_encrypt::sem::DefaultMatch; +use stack_encrypt::sem::{DefaultMatch, MatchConfig, MatchOptions}; use stack_encrypt::StackCipher; use stack_kms::FakeDataKeySource; @@ -103,3 +103,36 @@ async fn ope_term_bytes_are_pinned() { "00837615a1ea2fdcbebf7efe34cf4d2ee432c7eeff84fbd72e1bf05efa2338033c" ); } + +/// A filter wider than 256 bits: the default's mask keeps only the low byte +/// of each 2-byte slice, so the pin above cannot see which byte fills the +/// high half. Here every position uses both. +struct WideMatch; + +impl MatchConfig for WideMatch { + fn options() -> MatchOptions { + MatchOptions { + m: 65536, + ..Default::default() + } + } +} + +#[tokio::test] +async fn match_term_positions_are_pinned_for_a_wide_filter() { + let cipher = cipher().await; + let term = cipher + .default_keyset() + .match_terms::<WideMatch>("alice smith", nonempty!("users/name")) + .await + .unwrap(); + + assert_eq!( + term.positions(), + [ + 2287, 2398, 2404, 2829, 5150, 5181, 5293, 9255, 11964, 14175, 16080, 24350, 25354, + 28362, 31748, 33647, 35845, 39141, 39480, 41998, 42621, 45365, 50303, 60896, 64668, + 64957, 65365 + ] + ); +} From 6d7234d74ad4c0c451e8028716b32c980ed83fec Mon Sep 17 00:00:00 2001 From: Dan Draper <dan@cipherstash.com> Date: Fri, 25 Sep 2026 13:58:52 -0700 Subject: [PATCH 631/686] test(stack): cover remaining mutation paths Exercise optional-field recovery, reader lengths, debug redaction, and browser launch results. Keep exclusions specific to unreachable or provably equivalent mutations. Refs CIP-4143 --- .cargo/mutants.toml | 25 +++++---- packages/stack-auth/src/device_code/mod.rs | 4 ++ packages/stack-auth/src/device_code/tests.rs | 45 ++++++++++++++++ .../stack-encrypt/src/target/operations.rs | 52 +++++++++++++++++++ packages/stack-encrypt/tests/transcode.rs | 10 +++- 5 files changed, 123 insertions(+), 13 deletions(-) diff --git a/.cargo/mutants.toml b/.cargo/mutants.toml index 56fc7e507..c15952b75 100644 --- a/.cargo/mutants.toml +++ b/.cargo/mutants.toml @@ -36,40 +36,43 @@ additional_cargo_test_args = ["-E", "not binary(ui) & not test(stress_tests)"] exclude_globs = ["packages/stack-encrypt-derive/**"] # Mutants no test binary can kill, by name. Every other survivor is a test to -# write, not a line to add here (CIP-4143 cleared the first full sweeps that -# way). Two kinds qualify: +# write, not a line to add here. Two kinds qualify: # # - Unreachable: code compiled out of the native `--all-features` test build -# (wasm32-only impls, the no-`http` fallback, the non-test reqwest client), -# or whose only effect is outside the process (launching a browser). A -# mutant there builds and "survives" because nothing it touches is run. +# (wasm32-only impls, the no-`http` fallback, the non-test reqwest client). +# A mutant there builds and "survives" because nothing it touches is run. # - Equivalent: the replacement is the value the code already returns # (`Some(())` for a `()` credential, `Map::new()` for `Default::default()`, # a builder's `new()` for its `Default`), so no test can tell them apart. # # The regexes match the name `--list` prints, `path:line:col: replace …`. # Anchor on the replacement text, so an entry cannot swallow a reachable -# sibling with the same function name. The two wasm32 `TokenStoreFn` entries -# have to anchor on the line as well — their names are otherwise identical to -# the native impl's, which is caught — so if those lines move, a full sweep -# reports them again and the line numbers here need moving with them. +# sibling with the same function name. The wasm32 `TokenStoreFn`, +# `AutoStrategy::detect_inner` and `Pending::into_future` entries also anchor +# on the line because their names are identical to the native impl's. +# If those lines move, a full sweep reports them again and the line numbers +# here need moving with them. exclude_re = [ # stack-auth — unreachable under the native test build. 'stack-auth/src/transport\.rs:\d+:\d+: replace <impl std::fmt::Display for NoTransport>::fmt ', 'stack-auth/src/transport\.rs:\d+:\d+: replace <impl DynTransport for T>::send_dyn -> std::pin::Pin<Box<dyn Future<Output = Result<HttpResponse, RequestError>>\+\x27a>> ', + # Production http_client variants are cfg-disabled here; the test variant + # builds an unconfigured Client, equivalent to Client::default(). 'stack-auth/src/transport\.rs:\d+:\d+: replace http_client -> reqwest::Client with Default::default\(\)$', - 'stack-auth/src/auto_strategy\.rs:\d+:\d+: replace AutoStrategy::detect_inner -> Result<Self, AuthError> with Ok\(Default::default\(\)\)$', + 'stack-auth/src/auto_strategy\.rs:150:9: replace AutoStrategy::detect_inner -> Result<Self, AuthError> with Ok\(Default::default\(\)\)$', 'stack-auth/src/token_store\.rs:(258|266):9: replace <impl TokenStore for TokenStoreFn<L, S>>::(load|save)', - 'stack-auth/src/device_code/mod\.rs:\d+:\d+: replace PendingDeviceCode::open_in_browser -> bool ', # stack-auth — equivalent. 'stack-auth/src/(access_key|oidc)_refresher\.rs:\d+:\d+: replace <impl Refresher for \w+(<P>)?>::try_credential -> Option<Self::Credential> with Some\(Default::default\(\)\)$', 'stack-auth/src/error\.rs:\d+:\d+: replace AuthErrorKind::payload -> serde_json::Map<String, serde_json::Value> with Default::default\(\)$', # stack-encrypt — unreachable under the native test build (the wasm32 # variant; the native one returns a `FallbackKeyProvider`). 'stack-encrypt/src/cipher\.rs:\d+:\d+: replace client_key_provider -> EnvKeyProvider with Default::default\(\)$', + 'stack-encrypt/src/target/pending\.rs:443:9: replace <impl IntoFuture for Pending<.*>>::into_future -> Self::IntoFuture with Default::default\(\)$', # stack-encrypt — equivalent. 'stack-encrypt/src/cipher\.rs:\d+:\d+: replace StackCipher<FromEnv>::builder -> StackCipherBuilder with Default::default\(\)$', 'stack-encrypt/src/sem/mod\.rs:\d+:\d+: replace <impl MatchConfig for DefaultMatch>::options -> MatchOptions with Default::default\(\)$', + # Both unit-context conversions explicitly return Self::default(). + 'stack-encrypt/src/target/context\.rs:\d+:\d+: replace <impl From<\(\)> for (DeclaredContext|ExpectedContext<T>)>::from -> Self with Default::default\(\)$', ] # Headroom over the measured baseline before a slow-but-correct mutant is diff --git a/packages/stack-auth/src/device_code/mod.rs b/packages/stack-auth/src/device_code/mod.rs index ed3413f1d..f4b6b70fd 100644 --- a/packages/stack-auth/src/device_code/mod.rs +++ b/packages/stack-auth/src/device_code/mod.rs @@ -18,6 +18,10 @@ use protocol::{ #[cfg(test)] mod tests; +// Keep the public success/failure path under test without opening a browser. +#[cfg(test)] +use tests::browser as open; + /// The device-code flow is interactive and native-only, so it always runs /// over the bundled transport. fn bundled_transport() -> SharedTransport { diff --git a/packages/stack-auth/src/device_code/tests.rs b/packages/stack-auth/src/device_code/tests.rs index b51852393..dd81b513c 100644 --- a/packages/stack-auth/src/device_code/tests.rs +++ b/packages/stack-auth/src/device_code/tests.rs @@ -138,6 +138,51 @@ async fn begin_pending(server: &MockServer, dir: &TempDir) -> PendingDeviceCode strategy_for(server, dir).begin().await.unwrap() } +/// Stand in for the OS launcher only; `open_in_browser` itself is unchanged +/// between test and production builds. Thread-local expectations keep tests +/// independent even when run concurrently in one test binary. +pub(super) mod browser { + use std::cell::RefCell; + + thread_local! { + pub(super) static EXPECTED: RefCell<Option<(String, bool)>> = const { RefCell::new(None) }; + } + + pub(crate) fn that(uri: &str) -> std::io::Result<()> { + let (expected, succeeds) = EXPECTED + .with_borrow_mut(Option::take) + .expect("unexpected browser launch"); + assert_eq!(uri, expected, "open the complete verification URI"); + if succeeds { + Ok(()) + } else { + Err(std::io::Error::other("browser launcher failed")) + } + } +} + +#[tokio::test] +async fn opening_the_browser_reports_the_launchers_result() { + let dir = TempDir::new().unwrap(); + let mut mocks = MockSet::new(); + mock_code_endpoint(&mut mocks); + let server = start_server(mocks).await; + let pending = begin_pending(&server, &dir).await; + + for succeeds in [true, false] { + browser::EXPECTED.with_borrow_mut(|expected| { + *expected = Some(( + "http://example.com/activate?user_code=ABCD-EFGH".to_string(), + succeeds, + )); + }); + assert_eq!(pending.open_in_browser(), succeeds); + browser::EXPECTED.with_borrow(|expected| { + assert!(expected.is_none(), "the launcher must actually be called"); + }); + } +} + #[tokio::test(start_paused = true)] async fn test_poll_for_token_success() { let dir = TempDir::new().unwrap(); diff --git a/packages/stack-encrypt/src/target/operations.rs b/packages/stack-encrypt/src/target/operations.rs index 21fac0e61..601ec4c30 100644 --- a/packages/stack-encrypt/src/target/operations.rs +++ b/packages/stack-encrypt/src/target/operations.rs @@ -765,3 +765,55 @@ impl<T: Decryptable> Decryptable for Vec<T> { impl<T: Decryptable> Decryptable for Option<T> { const DECRYPTABLE: bool = T::DECRYPTABLE; } + +#[cfg(test)] +mod tests { + use super::*; + use crate::{nonempty, sem::EqualityTerm}; + use stack_kms::FakeDataKeySource; + + #[tokio::test] + async fn an_optional_ciphertext_field_recovers_present_and_absent_values() { + let cipher = StackCipher::builder() + .kms(FakeDataKeySource::new()) + .init() + .await + .unwrap(); + let keyset = cipher.default_keyset(); + let context = || AeadContext::from(nonempty!("users/nickname")); + + for expected in [Some("secret nickname".to_string()), None] { + let sealed: Option<StackCipherText> = match &expected { + Some(value) => Some(keyset.encrypt_as(value, context()).await.unwrap()), + None => None, + }; + let opening: Decryption<Option<String>, FakeDataKeySource> = sealed + .decryption_field(context()) + .expect("an optional ciphertext is a recoverable field even when absent"); + assert_eq!(opening.open_in(&keyset).await.unwrap(), expected); + } + } + + #[test] + fn an_optional_term_is_never_a_recoverable_field() { + for term in [Some(EqualityTerm::from_bytes([7; 32])), None] { + let opening: Option<Decryption<Option<String>, FakeDataKeySource>> = + term.decryption_field(CallerContext::from(nonempty!("users/nickname"))); + assert!(opening.is_none(), "a term cannot recover plaintext"); + } + } + + #[test] + fn operation_debug_describes_the_operation_without_its_captured_value() { + let encryption: Encryption<'_, (), _, (), ()> = Encryption::ready(Ok("secret metadata")); + assert_eq!(format!("{encryption:?}"), "Encryption { .. }"); + + let decryption = Decryption::<_, ()>::ready("secret plaintext"); + assert_eq!(format!("{decryption:?}"), "Decryption { .. }"); + let failure = Decryption::<(), ()>::failed(Error::NotOpened); + assert_eq!( + format!("{failure:?}"), + "Decryption { failed: NotOpened, .. }" + ); + } +} diff --git a/packages/stack-encrypt/tests/transcode.rs b/packages/stack-encrypt/tests/transcode.rs index e2225c0c0..d11f822fd 100644 --- a/packages/stack-encrypt/tests/transcode.rs +++ b/packages/stack-encrypt/tests/transcode.rs @@ -349,17 +349,23 @@ impl Visitor for TreeVisitor { Ok(Stored::Value(leaf)) } fn sequence<R: SequenceReader>(self, mut reader: R) -> Result<Stored, Error> { - let mut output = Vec::with_capacity(reader.remaining()); + let length = reader.remaining(); + let mut output = Vec::with_capacity(length); while let Some(child) = reader.next() { output.push(child.read(TreeVisitor)?); + assert_eq!(reader.remaining(), length - output.len()); } + assert_eq!(output.len(), length); Ok(Stored::List(output)) } fn map<R: MapReader>(self, mut reader: R) -> Result<Stored, Error> { - let mut output = Vec::with_capacity(reader.remaining()); + let length = reader.remaining(); + let mut output = Vec::with_capacity(length); while let Some((key, child)) = reader.next() { output.push((key, child.read(TreeVisitor)?)); + assert_eq!(reader.remaining(), length - output.len()); } + assert_eq!(output.len(), length); Ok(Stored::Object(output)) } fn absent(self, marker: SealedValue) -> Result<Stored, Error> { From 9fdd7705e0b062c6777af5aedd15a693289575ee Mon Sep 17 00:00:00 2001 From: Dan Draper <dan@cipherstash.com> Date: Fri, 25 Sep 2026 14:48:46 -0700 Subject: [PATCH 632/686] test(stack): address PR review findings --- packages/stack-auth/src/device_code/mod.rs | 13 ++- packages/stack-auth/src/device_code/tests.rs | 8 +- .../src/device_session_refresher.rs | 54 ++++++++++--- packages/stack-auth/src/error.rs | 24 ++++-- packages/stack-auth/src/transport.rs | 14 +++- packages/stack-encrypt/src/descriptor.rs | 28 +++++-- packages/stack-encrypt/src/keyset.rs | 52 +++++++++--- packages/stack-encrypt/src/target/pending.rs | 12 ++- packages/stack-encrypt/tests/roundtrip.rs | 29 ++++--- packages/stack-encrypt/tests/transcode.rs | 81 +++++++++++++++++-- 10 files changed, 256 insertions(+), 59 deletions(-) diff --git a/packages/stack-auth/src/device_code/mod.rs b/packages/stack-auth/src/device_code/mod.rs index f4b6b70fd..b5757891a 100644 --- a/packages/stack-auth/src/device_code/mod.rs +++ b/packages/stack-auth/src/device_code/mod.rs @@ -18,9 +18,16 @@ use protocol::{ #[cfg(test)] mod tests; -// Keep the public success/failure path under test without opening a browser. +// Keep the browser boundary visible to callers of `open_in_browser`. +#[cfg(not(test))] +fn launch_browser(uri: &str) -> std::io::Result<()> { + open::that(uri) +} + #[cfg(test)] -use tests::browser as open; +fn launch_browser(uri: &str) -> std::io::Result<()> { + tests::browser::launch(uri) +} /// The device-code flow is interactive and native-only, so it always runs /// over the bundled transport. @@ -289,7 +296,7 @@ impl PendingDeviceCode { /// /// Returns `true` if the browser was opened successfully. pub fn open_in_browser(&self) -> bool { - open::that(&self.verification_uri_complete).is_ok() + launch_browser(&self.verification_uri_complete).is_ok() } /// Poll the auth server until the user authorizes (or the code expires). diff --git a/packages/stack-auth/src/device_code/tests.rs b/packages/stack-auth/src/device_code/tests.rs index dd81b513c..544c09296 100644 --- a/packages/stack-auth/src/device_code/tests.rs +++ b/packages/stack-auth/src/device_code/tests.rs @@ -148,7 +148,7 @@ pub(super) mod browser { pub(super) static EXPECTED: RefCell<Option<(String, bool)>> = const { RefCell::new(None) }; } - pub(crate) fn that(uri: &str) -> std::io::Result<()> { + pub(in crate::device_code) fn launch(uri: &str) -> std::io::Result<()> { let (expected, succeeds) = EXPECTED .with_borrow_mut(Option::take) .expect("unexpected browser launch"); @@ -176,7 +176,11 @@ async fn opening_the_browser_reports_the_launchers_result() { succeeds, )); }); - assert_eq!(pending.open_in_browser(), succeeds); + assert_eq!( + pending.open_in_browser(), + succeeds, + "browser launch result should match launcher success={succeeds}" + ); browser::EXPECTED.with_borrow(|expected| { assert!(expected.is_none(), "the launcher must actually be called"); }); diff --git a/packages/stack-auth/src/device_session_refresher.rs b/packages/stack-auth/src/device_session_refresher.rs index 353de5518..894978a8b 100644 --- a/packages/stack-auth/src/device_session_refresher.rs +++ b/packages/stack-auth/src/device_session_refresher.rs @@ -310,13 +310,29 @@ mod tests { let credential = SecretToken::new("matching-refresh"); let result = refresher.refresh(&credential).await.unwrap(); - assert_eq!(result.access_token().as_str(), "new-access"); - assert_eq!(result.refresh_token().unwrap().as_str(), "new-refresh"); + assert_eq!( + result.access_token().as_str(), + "new-access", + "refresh should return the new access token" + ); + assert_eq!( + result.refresh_token().unwrap().as_str(), + "new-refresh", + "refresh should return the rotated refresh token" + ); // The `/oauth/token` response carries neither; the refresher stamps // them, and a token without its region cannot derive its workspace // CRN on the next load. - assert_eq!(result.region(), Some("ap-southeast-2.aws")); - assert_eq!(result.client_id(), Some("cli")); + assert_eq!( + result.region(), + Some("ap-southeast-2.aws"), + "refresh should preserve the region" + ); + assert_eq!( + result.client_id(), + Some("cli"), + "refresh should preserve the client id" + ); // Persistence must have happened inside refresh() while the lock // was held — so disk now reflects the rotated state. @@ -325,10 +341,26 @@ mod tests { .unwrap() .load_profile() .unwrap(); - assert_eq!(on_disk.access_token().as_str(), "new-access"); - assert_eq!(on_disk.refresh_token().unwrap().as_str(), "new-refresh"); - assert_eq!(on_disk.region(), Some("ap-southeast-2.aws")); - assert_eq!(on_disk.client_id(), Some("cli")); + assert_eq!( + on_disk.access_token().as_str(), + "new-access", + "the rotated access token should be persisted" + ); + assert_eq!( + on_disk.refresh_token().unwrap().as_str(), + "new-refresh", + "the rotated refresh token should be persisted" + ); + assert_eq!( + on_disk.region(), + Some("ap-southeast-2.aws"), + "the region should be persisted" + ); + assert_eq!( + on_disk.client_id(), + Some("cli"), + "the client id should be persisted" + ); } /// The refresh response does not echo the device instance (CIP-2793), so @@ -368,7 +400,11 @@ mod tests { .refresh(&SecretToken::new("matching-refresh")) .await .unwrap(); - assert_eq!(result.device_instance_id(), Some("device-7")); + assert_eq!( + result.device_instance_id(), + Some("device-7"), + "refresh should preserve the device instance" + ); let on_disk: Token = ProfileStore::new(dir.path()) .workspace_store(WORKSPACE_ID) diff --git a/packages/stack-auth/src/error.rs b/packages/stack-auth/src/error.rs index fc8a42a2e..3bc2cef2d 100644 --- a/packages/stack-auth/src/error.rs +++ b/packages/stack-auth/src/error.rs @@ -1417,7 +1417,8 @@ mod tests { ]; let payload = serde_json::Map::new(); - let mut checked = (0, 0); + let mut rejected_credentials = 0; + let mut other_errors = 0; for code in AuthError::ERROR_CODES { let err = AuthError::from_error_code(code, "message", &payload); if err.error_code() != *code { @@ -1430,22 +1431,29 @@ mod tests { "{code} is on the wrong side of the credential-rejection boundary", ); if expected { - checked.0 += 1; + rejected_credentials += 1; } else { - checked.1 += 1; + other_errors += 1; } } assert!( - checked.0 > 0 && checked.1 > 0, - "both sides of the boundary must be exercised: {checked:?}", + rejected_credentials > 0 && other_errors > 0, + "both sides of the boundary must be exercised: {rejected_credentials} credential rejections, {other_errors} other errors", ); // `INVALID_ACCESS_KEY` does not round-trip through `from_error_code`, // so build it the way a malformed key does. let malformed_key = AuthError::from("".parse::<crate::access_key::AccessKey>().unwrap_err()); - assert_eq!(malformed_key.error_code(), codes::INVALID_ACCESS_KEY); - assert!(malformed_key.is_credential_rejection()); + assert_eq!( + malformed_key.error_code(), + codes::INVALID_ACCESS_KEY, + "malformed access key should retain its error code" + ); + assert!( + malformed_key.is_credential_rejection(), + "malformed access key should be a credential rejection: {malformed_key:?}" + ); } /// The account-refusal codes carry CTS's wording across the boundary, @@ -1466,7 +1474,7 @@ mod tests { ] { for blank in ["", " \t"] { let err = AuthError::from_error_code(code, blank, &payload); - assert_eq!(err.error_code(), code); + assert_eq!(err.error_code(), code, "blank message should retain {code}"); assert_eq!(err.to_string(), default, "{code} with {blank:?}"); } let err = AuthError::from_error_code(code, " as sent ", &payload); diff --git a/packages/stack-auth/src/transport.rs b/packages/stack-auth/src/transport.rs index a512e0828..8b23e0f15 100644 --- a/packages/stack-auth/src/transport.rs +++ b/packages/stack-auth/src/transport.rs @@ -563,9 +563,17 @@ mod tests { let headers = vec![("content-type".to_string(), "application/json".to_string())]; let response = HttpResponse::new(201, headers.clone(), b"{\"ok\":true}".to_vec()); - assert_eq!(response.status(), 201); - assert_eq!(response.headers(), headers.as_slice()); - assert_eq!(response.body(), b"{\"ok\":true}"); + assert_eq!(response.status(), 201, "response should retain its status"); + assert_eq!( + response.headers(), + headers.as_slice(), + "response should retain its headers" + ); + assert_eq!( + response.body(), + b"{\"ok\":true}", + "response should retain its body" + ); } #[test] diff --git a/packages/stack-encrypt/src/descriptor.rs b/packages/stack-encrypt/src/descriptor.rs index a37f02343..b245c9c15 100644 --- a/packages/stack-encrypt/src/descriptor.rs +++ b/packages/stack-encrypt/src/descriptor.rs @@ -308,11 +308,29 @@ mod tests { #[test] fn the_empty_context_renders_empty() { // `()` and `""` encode to the same (empty) bytes: one descriptor. - assert_eq!(Descriptor::of(()).as_str(), ""); - assert_eq!(Descriptor::of("").as_str(), ""); - assert_eq!(Descriptor::of(b"".as_slice()).as_str(), ""); - assert!(Descriptor::of(()).is_empty()); - assert!(!Descriptor::of("users/email").is_empty()); + assert_eq!( + Descriptor::of(()).as_str(), + "", + "unit context should render empty" + ); + assert_eq!( + Descriptor::of("").as_str(), + "", + "empty text should render empty" + ); + assert_eq!( + Descriptor::of(b"".as_slice()).as_str(), + "", + "empty bytes should render empty" + ); + assert!( + Descriptor::of(()).is_empty(), + "unit context should be empty" + ); + assert!( + !Descriptor::of("users/email").is_empty(), + "textual context should not be empty" + ); } /// Every view of a descriptor is the one rendering ZeroKMS is sent. diff --git a/packages/stack-encrypt/src/keyset.rs b/packages/stack-encrypt/src/keyset.rs index ab9a6b21f..78638ddc5 100644 --- a/packages/stack-encrypt/src/keyset.rs +++ b/packages/stack-encrypt/src/keyset.rs @@ -711,7 +711,11 @@ mod tests { /// The documented bound on how long a rename can go unnoticed. #[test] fn a_name_binding_is_trusted_for_five_minutes_by_default() { - assert_eq!(DEFAULT_NAME_TTL, Duration::from_secs(300)); + assert_eq!( + DEFAULT_NAME_TTL, + Duration::from_secs(300), + "name bindings should be trusted for five minutes by default" + ); } /// Every ordering rule in the cache is written in `is_later_than`, so @@ -719,9 +723,18 @@ mod tests { /// itself, nor than one after it. #[test] fn later_is_strictly_later() { - assert!(Resolution(2).is_later_than(Resolution(1))); - assert!(!Resolution(1).is_later_than(Resolution(1))); - assert!(!Resolution(1).is_later_than(Resolution(2))); + assert!( + Resolution(2).is_later_than(Resolution(1)), + "a later resolution should compare later" + ); + assert!( + !Resolution(1).is_later_than(Resolution(1)), + "a resolution should not be later than itself" + ); + assert!( + !Resolution(1).is_later_than(Resolution(2)), + "an earlier resolution should not compare later" + ); } /// A binding is fresh strictly inside its window. The zero-window case @@ -738,8 +751,14 @@ mod tests { }; let ttl = Duration::from_secs(60); - assert!(alias.is_fresh_at(bound, ttl)); - assert!(alias.is_fresh_at(bound + ttl - Duration::from_nanos(1), ttl)); + assert!( + alias.is_fresh_at(bound, ttl), + "a binding should be fresh at its start" + ); + assert!( + alias.is_fresh_at(bound + ttl - Duration::from_nanos(1), ttl), + "a binding should be fresh just before expiry" + ); assert!( !alias.is_fresh_at(bound + ttl, ttl), "the window's end is outside it" @@ -766,17 +785,26 @@ mod tests { matches!(loads.get(&id(1)), Lookup::Miss(_)), "the first loaded is the least recently used" ); - assert!(matches!(loads.get(&id(2)), Lookup::Hit(_))); + assert!( + matches!(loads.get(&id(2)), Lookup::Hit(_)), + "the second loaded keyset should remain cached" + ); hits.load(state(1, None)); hits.load(state(2, None)); - assert!(matches!(hits.get(&id(1)), Lookup::Hit(_))); + assert!( + matches!(hits.get(&id(1)), Lookup::Hit(_)), + "the first keyset should be cached before its hit" + ); hits.load(state(3, None)); assert!( matches!(hits.get(&id(2)), Lookup::Miss(_)), "a hit makes 1 newer than the 2 loaded after it" ); - assert!(matches!(hits.get(&id(1)), Lookup::Hit(_))); + assert!( + matches!(hits.get(&id(1)), Lookup::Hit(_)), + "the recently used keyset should survive eviction" + ); } } @@ -800,7 +828,11 @@ mod tests { Some(Uuid::from_u128(1)), "and the old keyset answers to its new name" ); - assert_eq!(cache.names(), 2); + assert_eq!( + cache.names(), + 2, + "both names should remain bound after the rename" + ); } /// The default here is keyset 7, not the nil id the other tests' caches diff --git a/packages/stack-encrypt/src/target/pending.rs b/packages/stack-encrypt/src/target/pending.rs index e59909aa9..b96d6bf66 100644 --- a/packages/stack-encrypt/src/target/pending.rs +++ b/packages/stack-encrypt/src/target/pending.rs @@ -1328,7 +1328,11 @@ mod tests { .scoped_to(a.keyset_id()) .await .expect("re-scoping to its own keyset changes nothing"); - assert_eq!(tags.len(), 1); + assert_eq!( + tags.len(), + 1, + "rescoping to the same keyset should produce one tag" + ); assert_eq!( cipher.kms().generate_keysets(), vec![Some(a.keyset_id())], @@ -1352,7 +1356,11 @@ mod tests { if left == a.keyset_id() && right == b.keyset_id()), "{result:?}" ); - assert_eq!(cipher.kms().generate_calls(), 0); + assert_eq!( + cipher.kms().generate_calls(), + 0, + "a mismatched merge should not mint a key" + ); } /// A generate built through the client scope fails where it is built, diff --git a/packages/stack-encrypt/tests/roundtrip.rs b/packages/stack-encrypt/tests/roundtrip.rs index c02f371a5..985f65792 100644 --- a/packages/stack-encrypt/tests/roundtrip.rs +++ b/packages/stack-encrypt/tests/roundtrip.rs @@ -219,13 +219,30 @@ async fn empty_marker_does_not_decode_under_wrong_aad() { /// A hand-written record carrying one field in the clear, through the /// type-erased `passthrough_entry_boxed` a cipher-generic `Encrypt` has to -/// use — optionally giving that field twice. +/// use. struct Row { id: u32, email: String, repeat_id: bool, } +impl Row { + fn once() -> Self { + Self { + id: 7, + email: "a@x".to_string(), + repeat_id: false, + } + } + + fn with_duplicate_id() -> Self { + Self { + repeat_id: true, + ..Self::once() + } + } +} + impl Encrypt for Row { fn encrypt_with_aad<'a, C, A>(self, cipher: C, aad: A) -> Result<C::Ok, C::Error> where @@ -249,13 +266,7 @@ impl Encrypt for Row { async fn a_passthrough_map_entry_is_stored_in_the_clear_once() { let cipher = cipher().await; let keyset = cipher.default_keyset(); - let row = |repeat_id| Row { - id: 7, - email: "a@x".to_string(), - repeat_id, - }; - - let ct = keyset.encrypt(row(false), ()).await.expect("encrypt"); + let ct = keyset.encrypt(Row::once(), ()).await.expect("encrypt"); let CipherText::Map(entries) = ct else { panic!("a record encrypts as a map: {ct:?}"); }; @@ -269,7 +280,7 @@ async fn a_passthrough_map_entry_is_stored_in_the_clear_once() { "{entries:?}" ); - let result = keyset.encrypt(row(true), ()).await; + let result = keyset.encrypt(Row::with_duplicate_id(), ()).await; assert!(result.is_err(), "a passthrough key given twice is refused"); } diff --git a/packages/stack-encrypt/tests/transcode.rs b/packages/stack-encrypt/tests/transcode.rs index d11f822fd..23c011cef 100644 --- a/packages/stack-encrypt/tests/transcode.rs +++ b/packages/stack-encrypt/tests/transcode.rs @@ -349,23 +349,17 @@ impl Visitor for TreeVisitor { Ok(Stored::Value(leaf)) } fn sequence<R: SequenceReader>(self, mut reader: R) -> Result<Stored, Error> { - let length = reader.remaining(); - let mut output = Vec::with_capacity(length); + let mut output = Vec::with_capacity(reader.remaining()); while let Some(child) = reader.next() { output.push(child.read(TreeVisitor)?); - assert_eq!(reader.remaining(), length - output.len()); } - assert_eq!(output.len(), length); Ok(Stored::List(output)) } fn map<R: MapReader>(self, mut reader: R) -> Result<Stored, Error> { - let length = reader.remaining(); - let mut output = Vec::with_capacity(length); + let mut output = Vec::with_capacity(reader.remaining()); while let Some((key, child)) = reader.next() { output.push((key, child.read(TreeVisitor)?)); - assert_eq!(reader.remaining(), length - output.len()); } - assert_eq!(output.len(), length); Ok(Stored::Object(output)) } fn absent(self, marker: SealedValue) -> Result<Stored, Error> { @@ -396,6 +390,77 @@ impl Stored { } } } + +#[test] +fn native_readers_report_the_number_of_remaining_entries() { + struct LengthVisitor; + + impl Visitor for LengthVisitor { + type Value = usize; + + fn sequence<R: SequenceReader>(self, mut reader: R) -> Result<usize, Error> { + let length = reader.remaining(); + assert_eq!( + length, 3, + "sequence should initially report all three entries" + ); + for consumed in 1..=length { + assert!( + reader.next().is_some(), + "sequence entry {consumed} should exist" + ); + assert_eq!( + reader.remaining(), + length - consumed, + "sequence should report its remaining length after entry {consumed}" + ); + } + assert!( + reader.next().is_none(), + "sequence should end after three entries" + ); + Ok(length) + } + + fn map<R: MapReader>(self, mut reader: R) -> Result<usize, Error> { + let length = reader.remaining(); + assert_eq!(length, 3, "map should initially report all three entries"); + for consumed in 1..=length { + assert!(reader.next().is_some(), "map entry {consumed} should exist"); + assert_eq!( + reader.remaining(), + length - consumed, + "map should report its remaining length after entry {consumed}" + ); + } + assert!( + reader.next().is_none(), + "map should end after three entries" + ); + Ok(length) + } + } + + let value = || CipherText::Passthrough(Box::new(7_u32) as stack_encrypt::BoxedPassthrough); + let sequence: StackCipherText = CipherText::Sequence(vec![value(), value(), value()]); + assert_eq!( + sequence.read(LengthVisitor).unwrap(), + 3, + "sequence should yield three entries" + ); + + let map: StackCipherText = CipherText::Map(vec![ + ("first".into(), value()), + ("second".into(), value()), + ("third".into(), value()), + ]); + assert_eq!( + map.read(LengthVisitor).unwrap(), + 3, + "map should yield three entries" + ); +} + #[tokio::test] async fn native_readers_preserve_map_keys_and_authenticated_markers() { use std::collections::HashMap; From a9a5e48a803403c6587ae9d13623d1783cc024b3 Mon Sep 17 00:00:00 2001 From: Dan Draper <dan@cipherstash.com> Date: Fri, 25 Sep 2026 15:05:04 -0700 Subject: [PATCH 633/686] test(auth): keep browser launcher mutable --- packages/stack-auth/src/device_code/mod.rs | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/packages/stack-auth/src/device_code/mod.rs b/packages/stack-auth/src/device_code/mod.rs index b5757891a..1dabc932a 100644 --- a/packages/stack-auth/src/device_code/mod.rs +++ b/packages/stack-auth/src/device_code/mod.rs @@ -19,14 +19,16 @@ use protocol::{ mod tests; // Keep the browser boundary visible to callers of `open_in_browser`. -#[cfg(not(test))] fn launch_browser(uri: &str) -> std::io::Result<()> { - open::that(uri) -} + #[cfg(test)] + { + tests::browser::launch(uri) + } -#[cfg(test)] -fn launch_browser(uri: &str) -> std::io::Result<()> { - tests::browser::launch(uri) + #[cfg(not(test))] + { + open::that(uri) + } } /// The device-code flow is interactive and native-only, so it always runs From 00e1d6feb7ef9712b366926b3d3974918830b689 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Mon, 28 Sep 2026 21:47:51 +0000 Subject: [PATCH 634/686] test(stack): address review notes on assertion messages Adds a descriptive message to every message-less assertion the review named: the on-disk device instance check in the refresher test, the passthrough decryption and optional-field tests, the operation Debug pins, and the frozen-format boundary and wide-filter position tests. Rewords the auth changeset so it reads as an internal test-only entry that exists to satisfy the changeset gate, matching the phrasing of the other internal entries already pending. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TJc73YNcTeGaLDhP2P3LNM --- .../src/device_session_refresher.rs | 6 +- packages/stack-encrypt/src/cipher.rs | 12 +++- .../stack-encrypt/src/target/operations.rs | 21 +++++-- packages/stack-encrypt/tests/frozen_bytes.rs | 60 +++++++++++++++---- packages/stack-encrypt/tests/term_bytes.rs | 3 +- 5 files changed, 80 insertions(+), 22 deletions(-) diff --git a/packages/stack-auth/src/device_session_refresher.rs b/packages/stack-auth/src/device_session_refresher.rs index 894978a8b..bc2690eb9 100644 --- a/packages/stack-auth/src/device_session_refresher.rs +++ b/packages/stack-auth/src/device_session_refresher.rs @@ -411,7 +411,11 @@ mod tests { .unwrap() .load_profile() .unwrap(); - assert_eq!(on_disk.device_instance_id(), Some("device-7")); + assert_eq!( + on_disk.device_instance_id(), + Some("device-7"), + "the rotated token on disk should keep the device instance" + ); } /// Concurrent in-process calls to `refresh` must not produce a stale diff --git a/packages/stack-encrypt/src/cipher.rs b/packages/stack-encrypt/src/cipher.rs index 5f07de613..db7688e74 100644 --- a/packages/stack-encrypt/src/cipher.rs +++ b/packages/stack-encrypt/src/cipher.rs @@ -2008,14 +2008,20 @@ mod tests { fn decrypt_passthrough_opens_only_a_passthrough() { let passthrough = || StackDecipher::over(CipherText::Passthrough(Box::new(7u32))); - assert_eq!(passthrough().decrypt_passthrough_as::<u32>(), Ok(7)); + assert_eq!( + passthrough().decrypt_passthrough_as::<u32>(), + Ok(7), + "a passthrough payload of the requested type should be handed back" + ); assert_eq!( passthrough().decrypt_passthrough_as::<String>(), - Err(Unspecified) + Err(Unspecified), + "a payload of another type should fail the downcast" ); assert_eq!( StackDecipher::over(CipherText::Sequence(vec![])).decrypt_passthrough_as::<u32>(), - Err(Unspecified) + Err(Unspecified), + "a sealed shape should be refused rather than surfaced unopened" ); } diff --git a/packages/stack-encrypt/src/target/operations.rs b/packages/stack-encrypt/src/target/operations.rs index 601ec4c30..1e8d73746 100644 --- a/packages/stack-encrypt/src/target/operations.rs +++ b/packages/stack-encrypt/src/target/operations.rs @@ -790,7 +790,11 @@ mod tests { let opening: Decryption<Option<String>, FakeDataKeySource> = sealed .decryption_field(context()) .expect("an optional ciphertext is a recoverable field even when absent"); - assert_eq!(opening.open_in(&keyset).await.unwrap(), expected); + assert_eq!( + opening.open_in(&keyset).await.unwrap(), + expected, + "opening should recover the value that was sealed, or its absence" + ); } } @@ -806,14 +810,23 @@ mod tests { #[test] fn operation_debug_describes_the_operation_without_its_captured_value() { let encryption: Encryption<'_, (), _, (), ()> = Encryption::ready(Ok("secret metadata")); - assert_eq!(format!("{encryption:?}"), "Encryption { .. }"); + assert_eq!( + format!("{encryption:?}"), + "Encryption { .. }", + "a ready encryption should not print its captured value" + ); let decryption = Decryption::<_, ()>::ready("secret plaintext"); - assert_eq!(format!("{decryption:?}"), "Decryption { .. }"); + assert_eq!( + format!("{decryption:?}"), + "Decryption { .. }", + "a ready decryption should not print its plaintext" + ); let failure = Decryption::<(), ()>::failed(Error::NotOpened); assert_eq!( format!("{failure:?}"), - "Decryption { failed: NotOpened, .. }" + "Decryption { failed: NotOpened, .. }", + "a failed decryption should name its error and nothing else" ); } } diff --git a/packages/stack-encrypt/tests/frozen_bytes.rs b/packages/stack-encrypt/tests/frozen_bytes.rs index 9c99b232f..b5833b27b 100644 --- a/packages/stack-encrypt/tests/frozen_bytes.rs +++ b/packages/stack-encrypt/tests/frozen_bytes.rs @@ -133,18 +133,36 @@ fn sealed_value_decodes_the_shortest_leaf_the_layout_allows() { let leaf = SealedValue::from_parts(Uuid::nil(), [7; 16], Vec::new(), Vec::new()) .expect("an empty tag fits"); let bytes = leaf.to_bytes(); - assert_eq!(bytes.len(), 1 + 16 + 16 + 2); + assert_eq!( + bytes.len(), + 1 + 16 + 16 + 2, + "an empty tag and ciphertext should leave only the fixed-width fields" + ); let decoded = SealedValue::from_bytes(&bytes).expect("the fixed fields alone are a leaf"); - assert_eq!(decoded.keyset_id(), Uuid::nil()); - assert_eq!(decoded.iv(), &[7; 16]); - assert!(decoded.tag().is_empty()); - assert!(decoded.ciphertext().is_empty()); + assert_eq!( + decoded.keyset_id(), + Uuid::nil(), + "the keyset id should survive the round trip" + ); + assert_eq!( + decoded.iv(), + &[7; 16], + "the iv should survive the round trip" + ); + assert!(decoded.tag().is_empty(), "the tag should decode as empty"); + assert!( + decoded.ciphertext().is_empty(), + "the ciphertext should decode as empty" + ); - assert!(matches!( - SealedValue::from_bytes(&bytes[..bytes.len() - 1]), - Err(LeafBytesError::Truncated) - )); + assert!( + matches!( + SealedValue::from_bytes(&bytes[..bytes.len() - 1]), + Err(LeafBytesError::Truncated) + ), + "one byte short of the fixed-width fields should be truncated" + ); } #[test] @@ -156,8 +174,16 @@ fn sealed_value_accepts_the_longest_tag_the_length_field_frames() { .expect("a u16::MAX-byte tag fits the length field"); let decoded = SealedValue::from_bytes(&leaf.to_bytes()).expect("decode leaf"); - assert_eq!(decoded.tag(), tag.as_slice()); - assert_eq!(decoded.ciphertext(), [0xDE, 0xAD].as_slice()); + assert_eq!( + decoded.tag(), + tag.as_slice(), + "a u16::MAX-byte tag should survive the round trip intact" + ); + assert_eq!( + decoded.ciphertext(), + [0xDE, 0xAD].as_slice(), + "the ciphertext after the longest tag should still be framed correctly" + ); } #[test] @@ -317,7 +343,11 @@ async fn equality_term_encoding_is_the_raw_prf_bytes() { ); // And the owned conversion out is the same encoding. let bytes = term.to_bytes(); - assert_eq!(Vec::<u8>::from(term), bytes); + assert_eq!( + Vec::<u8>::from(term), + bytes, + "the owned conversion should produce the same encoding as to_bytes" + ); } #[test] @@ -364,7 +394,11 @@ async fn match_term_bytes_are_pinned() { #[test] fn match_term_debug_is_its_positions() { let term = MatchTerm::<DefaultMatch>::from_positions(vec![17, 3]).expect("in range"); - assert_eq!(format!("{term:?}"), "MatchTerm { positions: [3, 17] }"); + assert_eq!( + format!("{term:?}"), + "MatchTerm { positions: [3, 17] }", + "Debug should show only the sorted positions" + ); } #[test] diff --git a/packages/stack-encrypt/tests/term_bytes.rs b/packages/stack-encrypt/tests/term_bytes.rs index 023c89e03..ebe5f04cb 100644 --- a/packages/stack-encrypt/tests/term_bytes.rs +++ b/packages/stack-encrypt/tests/term_bytes.rs @@ -133,6 +133,7 @@ async fn match_term_positions_are_pinned_for_a_wide_filter() { 2287, 2398, 2404, 2829, 5150, 5181, 5293, 9255, 11964, 14175, 16080, 24350, 25354, 28362, 31748, 33647, 35845, 39141, 39480, 41998, 42621, 45365, 50303, 60896, 64668, 64957, 65365 - ] + ], + "positions for a 65536-bit filter are frozen: both bytes of each slice are in play" ); } From 85dc6ff1e5dea5b38435251a57224df1c16d7b5a Mon Sep 17 00:00:00 2001 From: Dan Draper <dan@cipherstash.com> Date: Fri, 25 Sep 2026 18:25:29 -0700 Subject: [PATCH 635/686] feat(stackauth): add Go credential strategies Run access-key, OIDC, and device-session authentication inside the credential WASI guest. Keep HTTP and environment detection in Go. Hold the CLI-compatible file lock across device refresh so sibling processes cannot replay a rotated refresh token. Refs: CIP-4054 --- .github/imported-workflows/test-wasi.yml | 11 +- languages/golang/go.mod | 1 + languages/golang/go.sum | 2 + languages/golang/internal/guest/errors.go | 9 +- .../golang/internal/guest/memory_test.go | 7 + languages/golang/internal/guest/status.go | 21 ++ languages/golang/stackauth/README.md | 32 +- languages/golang/stackauth/doc.go | 21 +- languages/golang/stackauth/errors.go | 13 +- languages/golang/stackauth/guest.go | 16 +- languages/golang/stackauth/guest/Cargo.lock | 67 ++++ languages/golang/stackauth/guest/Cargo.toml | 14 +- languages/golang/stackauth/guest/src/abi.rs | 29 +- languages/golang/stackauth/guest/src/auth.rs | 158 ++++++++ languages/golang/stackauth/guest/src/host.rs | 88 +++++ languages/golang/stackauth/guest/src/lib.rs | 21 +- languages/golang/stackauth/guest/src/ops.rs | 3 +- .../golang/stackauth/guest/src/status.rs | 20 +- languages/golang/stackauth/lock_unix.go | 41 +++ languages/golang/stackauth/lock_windows.go | 44 +++ languages/golang/stackauth/oauth2.go | 26 ++ languages/golang/stackauth/store.go | 29 +- languages/golang/stackauth/store_test.go | 26 +- languages/golang/stackauth/strategy.go | 191 ++++++++++ languages/golang/stackauth/strategy_test.go | 348 ++++++++++++++++++ languages/golang/stackauth/token.go | 6 +- languages/golang/stackauth/transport.go | 222 +++++++++++ packages/stack-auth/Cargo.toml | 2 +- .../src/device_session_refresher.rs | 61 +-- .../stack-auth/src/device_session_strategy.rs | 30 +- packages/stack-auth/src/error.rs | 10 + packages/stack-auth/src/token.rs | 1 - packages/stack-guest-abi/src/status.rs | 19 +- scripts/go-binding-test.sh | 4 + 34 files changed, 1480 insertions(+), 113 deletions(-) create mode 100644 languages/golang/stackauth/guest/src/auth.rs create mode 100644 languages/golang/stackauth/guest/src/host.rs create mode 100644 languages/golang/stackauth/lock_unix.go create mode 100644 languages/golang/stackauth/lock_windows.go create mode 100644 languages/golang/stackauth/oauth2.go create mode 100644 languages/golang/stackauth/strategy.go create mode 100644 languages/golang/stackauth/strategy_test.go create mode 100644 languages/golang/stackauth/transport.go diff --git a/.github/imported-workflows/test-wasi.yml b/.github/imported-workflows/test-wasi.yml index 0859e306f..93697b753 100644 --- a/.github/imported-workflows/test-wasi.yml +++ b/.github/imported-workflows/test-wasi.yml @@ -146,13 +146,10 @@ jobs: mise run go:test # The same Go binding on macOS and Windows, against the guest Linux built. - # Today that is the crypto binding, which had never run on either. The - # reason to have the platforms in place now is what comes next: the - # credential guest (ADR-0005, CIP-4054) puts the cross-process refresh - # lock on the Go side with a Unix and a Windows implementation, and a Go - # developer sharing a profile with the CLI is the scenario the lock exists - # for, so those tests need somewhere to run before they are written. They - # gate the way wasi-check does: a red run on the PR, not branch protection. + # The credential guest's Go-side refresh lock has Unix and Windows + # implementations. The shared go-binding-test.sh prints the device refresh + # tests explicitly on each platform, alongside the rest of the Go suite. + # They gate the way wasi-check does: a red run on the PR, not branch protection. # main's ruleset requires no status check today, and this workflow is path # filtered, so a required check here would never run on a PR outside the # filter and block it forever. If either job is ever made required, pair it diff --git a/languages/golang/go.mod b/languages/golang/go.mod index 48d942f7e..040bde41d 100644 --- a/languages/golang/go.mod +++ b/languages/golang/go.mod @@ -6,6 +6,7 @@ require ( github.com/cipherstash/vitaminc/bindings/go/vcffi v0.0.0-20260902024806-f2c7f7d17fbc github.com/cipherstash/vitaminc/bindings/go/vcvalue v0.0.0-20260902024806-f2c7f7d17fbc github.com/tetratelabs/wazero v1.12.0 + golang.org/x/oauth2 v0.36.0 ) require golang.org/x/sys v0.44.0 diff --git a/languages/golang/go.sum b/languages/golang/go.sum index f24642b69..75eda4d1c 100644 --- a/languages/golang/go.sum +++ b/languages/golang/go.sum @@ -4,5 +4,7 @@ github.com/cipherstash/vitaminc/bindings/go/vcvalue v0.0.0-20260902024806-f2c7f7 github.com/cipherstash/vitaminc/bindings/go/vcvalue v0.0.0-20260902024806-f2c7f7d17fbc/go.mod h1:RJODA1DCSm4H+1pbmCzDdT2pAIkxhinwIu6ewjh5sPs= github.com/tetratelabs/wazero v1.12.0 h1:DuWcpNu/FzgEXgGBDp8J1Spc+CWOvvtvVyjKlaZopYU= github.com/tetratelabs/wazero v1.12.0/go.mod h1:LvKtzl2RqO4gyF27BiXU+nKAjcV8f38U+kP/q2vgxh0= +golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= +golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ= golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= diff --git a/languages/golang/internal/guest/errors.go b/languages/golang/internal/guest/errors.go index 75405b012..f485f9aff 100644 --- a/languages/golang/internal/guest/errors.go +++ b/languages/golang/internal/guest/errors.go @@ -64,7 +64,14 @@ var ( ErrInvalidWorkspaceID = errors.New("cipherstash: invalid workspace id") // ErrWorkspaceNotFound is a workspace with no local profile data: nothing // has logged in to it on this machine. - ErrWorkspaceNotFound = errors.New("cipherstash: workspace has no local profile; log in to it first") + ErrWorkspaceNotFound = errors.New("cipherstash: workspace has no local profile; log in to it first") + ErrAuthInvalidGrant = errors.New("cipherstash: auth server rejected the refresh grant") + ErrAuthInvalidClient = errors.New("cipherstash: auth server rejected the client") + ErrAuthUsageLimit = errors.New("cipherstash: account usage limit exceeded") + ErrAuthNotAuthenticated = errors.New("cipherstash: no usable authentication credential") + ErrAuthTransport = errors.New("cipherstash: auth transport failed") + ErrAuthConfig = errors.New("cipherstash: invalid auth configuration or token") + ErrAuthOther = errors.New("cipherstash: authentication failed") // ErrMemoryLock is guest memory that could not be locked in RAM (or, // on Linux, excluded from core dumps). A constructor returns it when // asked for locked memory and refused, and so does any later call under diff --git a/languages/golang/internal/guest/memory_test.go b/languages/golang/internal/guest/memory_test.go index fc45f3854..adf9fdb6f 100644 --- a/languages/golang/internal/guest/memory_test.go +++ b/languages/golang/internal/guest/memory_test.go @@ -229,6 +229,13 @@ func TestStatusDecodesToTheSharedSentinels(t *testing.T) { guest.StatusProfileNoCurrentWorkspace: guest.ErrNoCurrentWorkspace, guest.StatusProfileInvalidWorkspaceID: guest.ErrInvalidWorkspaceID, guest.StatusProfileWorkspaceNotFound: guest.ErrWorkspaceNotFound, + guest.StatusAuthInvalidGrant: guest.ErrAuthInvalidGrant, + guest.StatusAuthInvalidClient: guest.ErrAuthInvalidClient, + guest.StatusAuthUsageLimit: guest.ErrAuthUsageLimit, + guest.StatusAuthNotAuthenticated: guest.ErrAuthNotAuthenticated, + guest.StatusAuthTransport: guest.ErrAuthTransport, + guest.StatusAuthConfig: guest.ErrAuthConfig, + guest.StatusAuthOther: guest.ErrAuthOther, } for code, sentinel := range want { if got := guest.StatusError(code); got != sentinel { diff --git a/languages/golang/internal/guest/status.go b/languages/golang/internal/guest/status.go index 162286d79..e554a152e 100644 --- a/languages/golang/internal/guest/status.go +++ b/languages/golang/internal/guest/status.go @@ -27,6 +27,13 @@ const ( StatusProfileNoCurrentWorkspace = 17 StatusProfileInvalidWorkspaceID = 18 StatusProfileWorkspaceNotFound = 19 + StatusAuthInvalidGrant = 20 + StatusAuthInvalidClient = 21 + StatusAuthUsageLimit = 22 + StatusAuthNotAuthenticated = 23 + StatusAuthTransport = 24 + StatusAuthConfig = 25 + StatusAuthOther = 26 ) // StatusError is the sentinel a guest status decodes to. A status this host @@ -72,6 +79,20 @@ func StatusError(status uint32) error { return ErrInvalidWorkspaceID case StatusProfileWorkspaceNotFound: return ErrWorkspaceNotFound + case StatusAuthInvalidGrant: + return ErrAuthInvalidGrant + case StatusAuthInvalidClient: + return ErrAuthInvalidClient + case StatusAuthUsageLimit: + return ErrAuthUsageLimit + case StatusAuthNotAuthenticated: + return ErrAuthNotAuthenticated + case StatusAuthTransport: + return ErrAuthTransport + case StatusAuthConfig: + return ErrAuthConfig + case StatusAuthOther: + return ErrAuthOther default: return fmt.Errorf("%w (unrecognized guest status %d)", ErrInternal, status) } diff --git a/languages/golang/stackauth/README.md b/languages/golang/stackauth/README.md index 169c845b7..8ae7b9dc5 100644 --- a/languages/golang/stackauth/README.md +++ b/languages/golang/stackauth/README.md @@ -7,9 +7,9 @@ the Go SDK: it hands a [`stackencrypt`](../stackencrypt) client its client key and its bearer token without either package re-deriving the profile's layout, and without either importing the other. -This is the **profile half** of [ADR-0005]. Refreshing a token from Go, and -the access-key and OIDC strategies, are the auth half (CIP-4054); until it -lands, an expired stored token means `stash auth login`. +The guest also runs the `stack-auth` strategies: access key, device session, +OIDC federation, and automatic selection. Go supplies HTTP and holds the +cross-process refresh lock for device sessions. [wazero]: https://wazero.io [ADR-0005]: ../../../packages/stack-encrypt/docs/adr/0005-a-separate-credential-guest-for-the-profile-and-auth.md @@ -42,7 +42,7 @@ func run(ctx context.Context) error { client, err := stackencrypt.NewClient(ctx, stackencrypt.Config{ ClientID: clientID, ClientKey: clientKey, // consumed and wiped by NewClient - Token: workspace.TokenSource(), // re-reads auth.json per request + Token: workspace.TokenSource(), // reads auth.json; no refresh }) if err != nil { return err @@ -57,6 +57,25 @@ func run(ctx context.Context) error { key goes straight from the profile into the config. The token source refuses a token at its real expiry with `stackauth.ErrTokenExpired`. +For a refreshing device session, replace `workspace.TokenSource()` with a +strategy and close it after the client: + +```go +source, err := workspace.DeviceSession(ctx) +if err != nil { return err } +defer source.Close() +// stackencrypt.Config{Token: source, ...} +``` + +`profile.AccessKey(ctx, crn, key)`, `profile.OIDC(ctx, crn, provider)`, and +`profile.Auto(ctx)` also return strategies that satisfy +`stackencrypt.TokenSource`. `Auto` checks `CS_CLIENT_ACCESS_KEY` and +`CS_WORKSPACE_CRN` first, then the current workspace's stored device session. +The OIDC provider is a one-method `Token(context.Context) (string, error)` +interface. Use `stackauth.OAuth2TokenSource(source)` to adapt a +`golang.org/x/oauth2.TokenSource`. `WithAuthBaseURL(url)` overrides service +discovery for local tests or a custom CTS host. + ## What the guest is given Exactly one directory, mounted read-write at a fixed guest path, and no @@ -66,8 +85,9 @@ workspace id, and the mount itself is confined — a symlink inside the profile that leads outside it is refused, for reads and for the one write, rather than followed with the process's permissions as a plain directory mount would. Files it creates are mode 0600. It takes no file lock (WASI -preview 1 has none); the cross-process refresh lock the CLI holds is Go's -to take, on the path `ProfileStore.LockPath` names, once refreshing lands. +preview 1 has none); Go holds the same lock as the CLI across the device +session call, on the path `ProfileStore.LockPath` names. The guest re-reads +auth.json after acquisition and saves refreshed tokens before release. The crypto guest behind `stackencrypt` is not widened by this package existing: it still has no filesystem and no environment. diff --git a/languages/golang/stackauth/doc.go b/languages/golang/stackauth/doc.go index 04b738983..966ac7081 100644 --- a/languages/golang/stackauth/doc.go +++ b/languages/golang/stackauth/doc.go @@ -9,8 +9,9 @@ // A [ProfileStore] is one guest instance over one mounted directory. [Resolve] // finds the profile directory the way the Rust crate does (CS_CONFIG_PATH, // then ~/.cipherstash); [Open] takes one. The guest is given that directory -// and nothing else: no environment, no other path, no network, and no way -// out through a symlink inside it, which the mount refuses to follow. +// and nothing else: no environment, no other path, and no way out through a +// symlink inside it, which the mount refuses to follow. Authentication HTTP +// requests go through the Go host's transport import. // Everything // the napi binding of stack-profile exposes is a method here, named as in // Rust: the current workspace ([ProfileStore.CurrentWorkspace], @@ -28,8 +29,11 @@ // stored token: it re-reads auth.json on every call, so a login or refresh // by the CLI in another terminal is picked up without a restart, and it // refuses a token at its real expiry with an error naming `stash auth -// login`. Refreshing a token from Go is the auth half of this package, -// not yet here; the 90-second refresh-ahead margin belongs to it. +// login`. For authentication and refresh, use [ProfileStore.AccessKey], +// [ProfileStore.OIDC], [ProfileStore.DeviceSession], or [ProfileStore.Auto]. +// Each returns a [Strategy] that implements stackencrypt.TokenSource. +// [OAuth2TokenSource] adapts an existing golang.org/x/oauth2.TokenSource +// into the OIDC provider interface. // // # Why a second guest // @@ -43,10 +47,11 @@ // // # What the guest cannot do // -// WASI preview 1 has no file locking, so the guest takes none: the -// cross-process refresh lock the Rust CLI holds is Go's to take, on the -// path [ProfileStore.LockPath] names, around the refresh call once it -// exists. Creating a device identity is native-only in the crate and +// WASI preview 1 has no file locking, so the guest takes none. Go holds the +// same cross-process lock as the Rust CLI, on the path [ProfileStore.LockPath] +// names, across each device-session call. The guest re-reads auth.json after +// the lock and saves a rotated token before Go releases it. Creating a device +// identity is native-only in the crate and // CLI territory; this package only reads one. Files the guest creates are // mode 0600, which is wazero's create mode rather than the crate's own // (skipped on wasm32), so a test pins it. diff --git a/languages/golang/stackauth/errors.go b/languages/golang/stackauth/errors.go index 9d4a2bfd6..586041eef 100644 --- a/languages/golang/stackauth/errors.go +++ b/languages/golang/stackauth/errors.go @@ -37,7 +37,14 @@ var ( // ErrState is a call on a store that has been closed. ErrState = guest.ErrState // ErrInternal is a guest panic or any other unexpected guest failure. - ErrInternal = guest.ErrInternal + ErrInternal = guest.ErrInternal + ErrInvalidGrant = guest.ErrAuthInvalidGrant + ErrInvalidClient = guest.ErrAuthInvalidClient + ErrUsageLimit = guest.ErrAuthUsageLimit + ErrNotAuthenticated = guest.ErrAuthNotAuthenticated + ErrAuthTransport = guest.ErrAuthTransport + ErrAuthConfig = guest.ErrAuthConfig + ErrAuthOther = guest.ErrAuthOther // ErrMemoryLock is guest memory that could not be locked in RAM (or, on // Linux, excluded from core dumps). Open returns it under // [RequireLockedMemory]; otherwise [ProfileStore.MemoryLockError] @@ -45,8 +52,8 @@ var ( ErrMemoryLock = guest.ErrMemoryLock // ErrTokenExpired is a stored token past its expiry: the profile has one, - // but it is no use, and only `stash auth login` (or, once the auth half - // of this package lands, a refresh) can replace it. + // but it is no use. A DeviceSession strategy can refresh it when the + // profile has a valid refresh token. ErrTokenExpired = errors.New("stackauth: the stored token has expired; run `stash auth login`") // ErrNoProfile is a profile directory that does not exist: nothing has // logged in on this machine, or CS_CONFIG_PATH names the wrong place. diff --git a/languages/golang/stackauth/guest.go b/languages/golang/stackauth/guest.go index 2746546ff..874360f48 100644 --- a/languages/golang/stackauth/guest.go +++ b/languages/golang/stackauth/guest.go @@ -6,6 +6,7 @@ import ( "embed" "errors" "fmt" + "net/http" "sync" "github.com/cipherstash/cipherstash-suite/bindings/go/internal/guest" @@ -64,12 +65,14 @@ type instance struct { mem *guest.Allocator exports guest.Exports // mount is the one directory the guest sees, confined to itself. - mount *confinedFS + mount *confinedFS + transport *authTransport shutdown api.Function currentWorkspace, setCurrentWorkspace, clearCurrentWorkspace api.Function listWorkspaces, workspaceDir, lockPath api.Function secretKey, token, deviceIdentity api.Function + authNew, authToken, authFree api.Function } // guestModuleConfig is the module configuration every guest instance runs @@ -95,7 +98,7 @@ func guestModuleConfig(mount *confinedFS) wazero.ModuleConfig { // linear memory from the guest packages' allocator. Under the strict // policy, memory that cannot be locked fails instantiation with // ErrMemoryLock. -func newInstance(ctx context.Context, wasm []byte, hostDir string, policy guest.LockPolicy) (*instance, error) { +func newInstance(ctx context.Context, wasm []byte, hostDir string, policy guest.LockPolicy, rt http.RoundTripper) (*instance, error) { mount, err := newConfinedFS(hostDir) if err != nil { return nil, fmt.Errorf("%w: %s: %w", ErrNoProfile, hostDir, err) @@ -112,6 +115,10 @@ func newInstance(ctx context.Context, wasm []byte, hostDir string, policy guest. if _, err := wasi_snapshot_preview1.Instantiate(ctx, runtime); err != nil { return fail(fmt.Errorf("stackauth: instantiating WASI: %w", err)) } + transport := newAuthTransport(rt) + if err := transport.instantiate(ctx, runtime); err != nil { + return fail(fmt.Errorf("stackauth: instantiating host transport: %w", err)) + } mem := guest.NewAllocator(policy) // The guest is a reactor (cdylib): no _start. wazero runs _initialize // when present, so guest code runs here too, and the memory must stay @@ -132,7 +139,7 @@ func newInstance(ctx context.Context, wasm []byte, hostDir string, policy guest. return fail(guest.MemoryLockError(lerr)) } } - inst := &instance{runtime: runtime, module: module, mem: mem, mount: mount} + inst := &instance{runtime: runtime, module: module, mem: mem, mount: mount, transport: transport} exports := map[string]*api.Function{ "se_alloc": &inst.exports.Alloc, "se_dealloc": &inst.exports.Dealloc, @@ -146,6 +153,9 @@ func newInstance(ctx context.Context, wasm []byte, hostDir string, policy guest. "sa_secret_key": &inst.secretKey, "sa_token": &inst.token, "sa_device_identity": &inst.deviceIdentity, + "sa_auth_new": &inst.authNew, + "sa_auth_token": &inst.authToken, + "sa_auth_free": &inst.authFree, } for name, slot := range exports { if *slot = module.ExportedFunction(name); *slot == nil { diff --git a/languages/golang/stackauth/guest/Cargo.lock b/languages/golang/stackauth/guest/Cargo.lock index 841d8d5d1..d75d4ed91 100644 --- a/languages/golang/stackauth/guest/Cargo.lock +++ b/languages/golang/stackauth/guest/Cargo.lock @@ -677,12 +677,71 @@ version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" +[[package]] +name = "futures" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", + "futures-sink", +] + [[package]] name = "futures-core" version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" +[[package]] +name = "futures-executor" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[package]] +name = "futures-macro" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + [[package]] name = "futures-task" version = "0.3.34" @@ -695,8 +754,13 @@ version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" dependencies = [ + "futures-channel", "futures-core", + "futures-io", + "futures-macro", + "futures-sink", "futures-task", + "memchr", "pin-project-lite", "slab", ] @@ -1769,12 +1833,15 @@ dependencies = [ name = "stack-auth-guest" version = "0.0.0" dependencies = [ + "cts-common", + "futures", "serde", "serde_json", "stack-auth", "stack-guest-abi", "stack-profile", "tempfile", + "url", "vitaminc-aead-value", "vitaminc-protected", "zeroize", diff --git a/languages/golang/stackauth/guest/Cargo.toml b/languages/golang/stackauth/guest/Cargo.toml index 24e4546a8..3695d1188 100644 --- a/languages/golang/stackauth/guest/Cargo.toml +++ b/languages/golang/stackauth/guest/Cargo.toml @@ -1,5 +1,5 @@ -# The credential guest: `stack-profile` (and, once the auth half lands, -# `stack-auth`'s strategies) under WASI/wazero, embedded by the Go package +# The credential guest: `stack-profile` and `stack-auth` strategies under +# WASI/wazero, embedded by the Go package # `stackauth` one directory up. ADR-0005 in packages/stack-encrypt/docs/adr. # # A second module rather than the crypto guest widened: this one is given a @@ -11,7 +11,7 @@ # Build: mise run wasm:auth-guest:build (or: cargo build --target wasm32-wasip1 --release) [package] name = "stack-auth-guest" -description = "WASI guest module exposing the developer profile (stack-profile) to non-Rust hosts (Go/wazero)" +description = "WASI guest exposing stack-profile and stack-auth to Go/wazero" version = "0.0.0" edition = "2021" publish = false @@ -25,8 +25,12 @@ crate-type = ["cdylib", "rlib"] [dependencies] serde = { version = "1", features = ["derive"] } -# `Token`, the typed shape of `auth.json`, with default features off: no -# reqwest, no native TLS. The strategies arrive with the auth half. +serde_json = "1" +futures = "0.3" +cts-common = { path = "../../../../packages/cts-common", default-features = false } +url = "2" +# Strategies and `Token`, the typed shape of `auth.json`, with default +# features off: no reqwest, no native TLS. stack-auth = { path = "../../../../packages/stack-auth", default-features = false } # The ABI every guest under bindings/go shares: the allocator and buffer # registry (and with them the `se_alloc`/`se_dealloc` exports), the diff --git a/languages/golang/stackauth/guest/src/abi.rs b/languages/golang/stackauth/guest/src/abi.rs index 14bfbd48c..3986a8959 100644 --- a/languages/golang/stackauth/guest/src/abi.rs +++ b/languages/golang/stackauth/guest/src/abi.rs @@ -29,7 +29,7 @@ use stack_guest_abi::abi::{err_status, input, ok_buffer}; use stack_guest_abi::buffers; use stack_guest_abi::status::STATUS_INTERNAL; -use crate::ops; +use crate::{auth, ops}; /// Where the host mounts the profile root. The Go side mounts exactly one /// directory here and constructs every store directory under it; nothing @@ -183,10 +183,37 @@ pub unsafe extern "C" fn sa_device_identity(dir_ptr: *const u8, dir_len: u32) -> export1(dir_ptr, dir_len, ops::device_identity) } +/// Construct an auth strategy from a tagged JSON config. Returns its handle. +/// The access key, when present, stays in the guest and is dropped on free. +/// # Safety +/// The input pair is validated against guest linear memory. +#[no_mangle] +pub unsafe extern "C" fn sa_auth_new(ptr: *const u8, len: u32) -> u64 { + export1(ptr, len, auth::create) +} + +/// Get a service token from a strategy. For a device session the Go host +/// must hold the workspace's auth.json lock for the whole call. +/// # Safety +/// The input pair is validated against guest linear memory. +#[no_mangle] +pub unsafe extern "C" fn sa_auth_token(ptr: *const u8, len: u32) -> u64 { + export1(ptr, len, auth::token) +} + +/// Drop one strategy and its cached credential. +/// # Safety +/// The input pair is validated against guest linear memory. +#[no_mangle] +pub unsafe extern "C" fn sa_auth_free(ptr: *const u8, len: u32) -> u64 { + export1(ptr, len, auth::free) +} + /// Tear the instance down: wipe every buffer the registry still holds. /// This guest keeps no other state. Idempotent; `se_alloc` and /// `se_dealloc` keep working so the host can still free what it holds. #[no_mangle] pub extern "C" fn sa_shutdown() { + let _ = catch_unwind(AssertUnwindSafe(auth::clear)); let _ = catch_unwind(AssertUnwindSafe(buffers::wipe_all)); } diff --git a/languages/golang/stackauth/guest/src/auth.rs b/languages/golang/stackauth/guest/src/auth.rs new file mode 100644 index 000000000..fb140880f --- /dev/null +++ b/languages/golang/stackauth/guest/src/auth.rs @@ -0,0 +1,158 @@ +//! Auth strategies retained inside one credential guest instance. The Go +//! package constructs typed sources; this registry owns the Rust strategies +//! and their cached CTS tokens until a source or the guest is closed. + +use std::cell::{Cell, RefCell}; +use std::collections::HashMap; + +use cts_common::Crn; +use futures::executor::block_on; +use serde::Deserialize; +use stack_auth::{ + AccessKey, AccessKeyStrategy, AuthStrategy, DeviceSessionStrategy, OidcFederationStrategy, +}; +use stack_profile::ProfileStore; +use zeroize::Zeroizing; + +use crate::host::{HostOidcProvider, WasiAuthTransport}; +use crate::status::{status_for_auth, STATUS_ENCODING, STATUS_STATE}; + +#[derive(Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)] +enum Config { + AccessKey { + crn: String, + access_key: String, + base_url: Option<String>, + }, + Oidc { + crn: String, + provider: u32, + base_url: Option<String>, + }, + DeviceSession { + workspace_dir: String, + base_url: Option<String>, + }, +} + +enum Strategy { + AccessKey(AccessKeyStrategy), + Oidc(OidcFederationStrategy<HostOidcProvider>), + // A device session is rebuilt only after Go has acquired the profile's + // cross-process lock. Keeping a cached Token here would defeat the + // post-lock re-read that prevents refresh-token replay. + DeviceSession { + workspace_dir: String, + base_url: Option<url::Url>, + }, +} + +thread_local! { + static STRATEGIES: RefCell<HashMap<u32, Strategy>> = RefCell::new(HashMap::new()); + static NEXT: Cell<u32> = const { Cell::new(1) }; +} + +fn url(value: Option<String>) -> Result<Option<url::Url>, u32> { + value + .map(|v| v.parse::<url::Url>().map_err(|_| STATUS_ENCODING)) + .transpose() +} + +pub fn create(config: &[u8]) -> Result<Vec<u8>, u32> { + let config: Config = serde_json::from_slice(config).map_err(|_| STATUS_ENCODING)?; + let strategy = match config { + Config::AccessKey { + crn, + access_key, + base_url, + } => { + let access_key = Zeroizing::new(access_key); + let crn: Crn = crn.parse().map_err(|_| STATUS_ENCODING)?; + let key: AccessKey = access_key.parse().map_err(|_| STATUS_ENCODING)?; + let mut builder = AccessKeyStrategy::builder(crn, key).transport(WasiAuthTransport); + if let Some(url) = url(base_url)? { + builder = builder.base_url(url); + } + Strategy::AccessKey(builder.build().map_err(|e| status_for_auth(&e))?) + } + Config::Oidc { + crn, + provider, + base_url, + } => { + let crn: Crn = crn.parse().map_err(|_| STATUS_ENCODING)?; + let mut builder = OidcFederationStrategy::builder(crn, HostOidcProvider(provider)) + .transport(WasiAuthTransport); + if let Some(url) = url(base_url)? { + builder = builder.base_url(url); + } + Strategy::Oidc(builder.build().map_err(|e| status_for_auth(&e))?) + } + Config::DeviceSession { + workspace_dir, + base_url, + } => { + if workspace_dir.is_empty() { + return Err(STATUS_ENCODING); + } + Strategy::DeviceSession { + workspace_dir, + base_url: url(base_url)?, + } + } + }; + let id = NEXT.with(|next| { + let id = next.get(); + next.set(id.wrapping_add(1).max(1)); + id + }); + STRATEGIES.with(|items| { + let _ = items.borrow_mut().insert(id, strategy); + }); + Ok(id.to_string().into_bytes()) +} + +fn id(bytes: &[u8]) -> Result<u32, u32> { + let text = std::str::from_utf8(bytes).map_err(|_| STATUS_ENCODING)?; + text.parse::<u32>().map_err(|_| STATUS_ENCODING) +} + +pub fn token(handle: &[u8]) -> Result<Vec<u8>, u32> { + let id = id(handle)?; + STRATEGIES.with(|items| { + let items = items.borrow(); + let strategy = items.get(&id).ok_or(STATUS_STATE)?; + let token = match strategy { + Strategy::AccessKey(strategy) => block_on(strategy.get_token()), + Strategy::Oidc(strategy) => block_on(strategy.get_token()), + Strategy::DeviceSession { + workspace_dir, + base_url, + } => { + let store = ProfileStore::new(workspace_dir); + let mut builder = DeviceSessionStrategy::with_workspace_store(store) + .transport(WasiAuthTransport); + if let Some(url) = base_url { + builder = builder.base_url(url.clone()); + } + let strategy = builder.build().map_err(|e| status_for_auth(&e))?; + block_on(strategy.get_token()) + } + } + .map_err(|e| status_for_auth(&e))?; + Ok(token.as_str().as_bytes().to_vec()) + }) +} + +pub fn free(handle: &[u8]) -> Result<Vec<u8>, u32> { + let id = id(handle)?; + STRATEGIES.with(|items| { + let _removed = items.borrow_mut().remove(&id).ok_or(STATUS_STATE)?; + Ok(Vec::new()) + }) +} + +pub fn clear() { + STRATEGIES.with(|items| items.borrow_mut().clear()); +} diff --git a/languages/golang/stackauth/guest/src/host.rs b/languages/golang/stackauth/guest/src/host.rs new file mode 100644 index 000000000..7dac1c788 --- /dev/null +++ b/languages/golang/stackauth/guest/src/host.rs @@ -0,0 +1,88 @@ +//! The credential guest's only network route: the host's HTTP transport. +//! A provider callback supplies the current OIDC token only when the Rust +//! federation strategy actually needs to exchange it. + +use std::io; + +use stack_auth::{ + AuthError, HttpRequest, HttpResponse, HttpTransport, OidcProvider, RequestError, SecretToken, +}; +use stack_guest_abi::{buffers, headers, transport}; +use zeroize::Zeroizing; + +#[link(wasm_import_module = "cipherstash_transport")] +extern "C" { + fn oidc_token_get(provider: u32, ptr_out: *mut u32, len_out: *mut u32) -> i32; +} + +fn request_error(message: impl Into<String>) -> RequestError { + RequestError(Box::new(io::Error::other(message.into()))) +} + +pub struct WasiAuthTransport; + +impl HttpTransport for WasiAuthTransport { + async fn send(&self, request: HttpRequest) -> Result<HttpResponse, RequestError> { + let pairs: Vec<(&str, &str)> = request + .headers() + .iter() + .map(|(name, value)| (name.as_str(), value.as_str())) + .collect(); + let wire_headers = Zeroizing::new(headers::encode_headers(&pairs)); + let response = transport::send( + request.method(), + request.url().as_str(), + &wire_headers, + request.body(), + ) + .map_err(|e| request_error(e.to_string()))?; + if !(100..=599).contains(&response.status) { + return Err(request_error(if response.status < 0 { + String::from_utf8_lossy(&response.body).into_owned() + } else { + format!("invalid host HTTP status {}", response.status) + })); + } + let response_headers = response + .headers + .split(|b| *b == b'\n') + .filter_map(|line| { + let line = std::str::from_utf8(line).ok()?; + let (name, value) = line.split_once(':')?; + Some((name.trim().to_string(), value.trim().to_string())) + }) + .collect(); + Ok(HttpResponse::new( + response.status as u16, + response_headers, + response.body.to_vec(), + )) + } +} + +pub struct HostOidcProvider(pub u32); + +impl OidcProvider for HostOidcProvider { + async fn fetch(&self) -> Result<SecretToken, AuthError> { + let mut ptr = 0_u32; + let mut len = 0_u32; + // SAFETY: out-slots are live stack locals, filled synchronously. + let status = unsafe { oidc_token_get(self.0, &mut ptr, &mut len) }; + // Reclaim even on failure: a host may have allocated before it failed. + // SAFETY: the shared registry validates the host-provided pair. + let bytes = unsafe { buffers::take(ptr as *mut u8, len as usize) } + .map(Zeroizing::new) + .ok_or_else(|| AuthError::Request(request_error("invalid OIDC token buffer")))?; + if status != 0 { + return Err(AuthError::Request(request_error( + "OIDC provider could not supply a token", + ))); + } + let token = std::str::from_utf8(&bytes) + .map_err(|_| AuthError::Request(request_error("OIDC token is not UTF-8")))?; + if token.is_empty() || token.chars().any(char::is_control) { + return Err(AuthError::Request(request_error("OIDC token is empty or contains controls"))); + } + Ok(SecretToken::new(token)) + } +} diff --git a/languages/golang/stackauth/guest/src/lib.rs b/languages/golang/stackauth/guest/src/lib.rs index 05ebe6a5b..498b9f8d4 100644 --- a/languages/golang/stackauth/guest/src/lib.rs +++ b/languages/golang/stackauth/guest/src/lib.rs @@ -56,24 +56,25 @@ //! //! # What crosses the boundary //! -//! Inputs are UTF-8 strings: the store directory (a guest path under the -//! mount), a workspace id, a filename. Outputs are either a UTF-8 string +//! Profile inputs are UTF-8 strings: the store directory (a guest path under +//! the mount), a workspace id, a filename. Outputs are either a UTF-8 string //! (an id, a path) or a value in vitaminc's FFI codec //! (`vitaminc_aead_value::transport`): a list of ids, or the fields of //! `secretkey.json`, `auth.json` or `device.json` as an object. The client //! key in `secretkey.json` crosses as the text the file holds; the Go side //! wraps it in its opaque `ClientKey` and wipes its transport copy. A -//! refresh token never crosses: the profile half of this guest hands out -//! the access token and its expiry, and refreshing is the auth half's -//! (CIP-4054), which runs inside this module. +//! refresh token never crosses: the auth strategy retains it inside this +//! module, while the Go host supplies HTTP through a single transport +//! import. The host can provide an access key or an OIDC provider callback. //! //! # Locking //! //! WASI preview 1 has no file locking, so this module takes none. The Go //! side takes the same lock the Rust CLI takes, on the path //! [`ProfileStore::lock_path`](stack_profile::ProfileStore::lock_path) -//! names (exported here), around the refresh export once it exists — and -//! never composes a profile path itself. +//! names (exported here), around the entire device-session token call. +//! The strategy reads auth.json only after that lock is held and saves a +//! rotated token before the call returns. Go never composes a profile path. //! //! # Not faked //! @@ -88,12 +89,18 @@ //! - [`ops`], [`status`] — everything that is pure logic over a //! `ProfileStore` and bytes. Compiles and unit-tests on the native host //! target (`cargo test` here) against a temporary directory. +//! - [`auth`], [`host`] (wasm32 only) — stack-auth strategies and host HTTP. //! - [`abi`] (wasm32 only) — the export surface, over the conventions //! every guest shares (`stack_guest_abi`). pub mod ops; pub mod status; +#[cfg(target_arch = "wasm32")] +pub mod host; +#[cfg(target_arch = "wasm32")] +pub mod auth; + // The ABI's packed u64 results embed 32-bit pointers and its bounds checks // read the wasm linear-memory size, so this module only exists on wasm32. // A native build therefore exports no sa_* symbols at all — failing loudly diff --git a/languages/golang/stackauth/guest/src/ops.rs b/languages/golang/stackauth/guest/src/ops.rs index 54c293bc1..c7a388349 100644 --- a/languages/golang/stackauth/guest/src/ops.rs +++ b/languages/golang/stackauth/guest/src/ops.rs @@ -178,8 +178,7 @@ pub fn secret_key(dir: &[u8]) -> Result<Vec<u8>, u32> { /// (strings), `expires_at` (seconds since the epoch, `u64`), and `region`, /// `client_id` and `device_instance_id` (each a string or null). The /// refresh token is not in it, on purpose: the host presents the access -/// token and refuses it at expiry; refreshing is this guest's, once the -/// auth half lands. +/// token and refuses it at expiry; the auth strategy exports handle refresh. pub fn token(dir: &[u8]) -> Result<Vec<u8>, u32> { let token: Token = store(dir)? .load(AUTH_FILENAME) diff --git a/languages/golang/stackauth/guest/src/status.rs b/languages/golang/stackauth/guest/src/status.rs index 554a5837b..38d2ae6c5 100644 --- a/languages/golang/stackauth/guest/src/status.rs +++ b/languages/golang/stackauth/guest/src/status.rs @@ -1,4 +1,4 @@ -//! The mapping from [`stack_profile::ProfileError`] onto the status table. +//! Map profile and auth errors onto the shared guest status table. //! //! The numbers are [`stack_guest_abi::status`]'s — one table for every //! guest, decoded once by the Go host — re-exported here so this crate's @@ -7,6 +7,7 @@ //! `stack-profile` conditions a Go caller can act on each have one, and //! the two it cannot act on are internal. +use stack_auth::AuthError; use stack_profile::ProfileError; pub use stack_guest_abi::status::{ @@ -16,6 +17,23 @@ pub use stack_guest_abi::status::{ STATUS_PROFILE_WORKSPACE_NOT_FOUND, STATUS_STATE, }; +/// Preserve the auth decisions callers can act on without exposing token +/// bytes or parsing a server message across the ABI. +pub fn status_for_auth(error: &AuthError) -> u32 { + use stack_guest_abi::status::*; + match error.error_code() { + "INVALID_GRANT" => STATUS_AUTH_INVALID_GRANT, + "INVALID_CLIENT" => STATUS_AUTH_INVALID_CLIENT, + "USAGE_LIMIT_EXCEEDED" => STATUS_AUTH_USAGE_LIMIT, + "NOT_AUTHENTICATED" | "EXPIRED_TOKEN" => STATUS_AUTH_NOT_AUTHENTICATED, + "REQUEST_ERROR" | "SERVER_ERROR" => STATUS_AUTH_TRANSPORT, + "INVALID_URL" | "INVALID_REGION" | "INVALID_CRN" | "WORKSPACE_MISMATCH" + | "INVALID_WORKSPACE_ID" | "MISSING_WORKSPACE_CRN" | "INVALID_ACCESS_KEY" + | "INVALID_TOKEN" => STATUS_AUTH_CONFIG, + _ => STATUS_AUTH_OTHER, + } +} + /// A profile error as a status code. /// /// `HomeDirNotFound` is [`STATUS_INTERNAL`]: this guest is given its diff --git a/languages/golang/stackauth/lock_unix.go b/languages/golang/stackauth/lock_unix.go new file mode 100644 index 000000000..d08378707 --- /dev/null +++ b/languages/golang/stackauth/lock_unix.go @@ -0,0 +1,41 @@ +//go:build !windows + +package stackauth + +import ( + "context" + "errors" + "fmt" + "os" + "time" + + "golang.org/x/sys/unix" +) + +// The lock file and flock match stack-profile's native FileLockGuard. +func withRefreshLock(ctx context.Context, path string, run func() error) error { + f, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0o600) + if err != nil { + return fmt.Errorf("stackauth: open refresh lock: %w", err) + } + defer f.Close() + for { + if err := ctx.Err(); err != nil { + return err + } + err = unix.Flock(int(f.Fd()), unix.LOCK_EX|unix.LOCK_NB) + if err == nil { + break + } + if !errors.Is(err, unix.EWOULDBLOCK) && !errors.Is(err, unix.EAGAIN) { + return fmt.Errorf("stackauth: acquire refresh lock: %w", err) + } + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(20 * time.Millisecond): + } + } + defer unix.Flock(int(f.Fd()), unix.LOCK_UN) + return run() +} diff --git a/languages/golang/stackauth/lock_windows.go b/languages/golang/stackauth/lock_windows.go new file mode 100644 index 000000000..2a7c70ef9 --- /dev/null +++ b/languages/golang/stackauth/lock_windows.go @@ -0,0 +1,44 @@ +//go:build windows + +package stackauth + +import ( + "context" + "errors" + "fmt" + "os" + "time" + + "golang.org/x/sys/windows" +) + +// Lock the same first 2^64-1 bytes stack-profile's Windows FileLockGuard +// locks. OVERLAPPED offset zero makes the range identical. +func withRefreshLock(ctx context.Context, path string, run func() error) error { + f, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0o600) + if err != nil { + return fmt.Errorf("stackauth: open refresh lock: %w", err) + } + defer f.Close() + h := windows.Handle(f.Fd()) + var overlap windows.Overlapped + for { + if err := ctx.Err(); err != nil { + return err + } + err = windows.LockFileEx(h, windows.LOCKFILE_EXCLUSIVE_LOCK|windows.LOCKFILE_FAIL_IMMEDIATELY, 0, 0xffffffff, 0xffffffff, &overlap) + if err == nil { + break + } + if !errors.Is(err, windows.ERROR_LOCK_VIOLATION) { + return fmt.Errorf("stackauth: acquire refresh lock: %w", err) + } + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(20 * time.Millisecond): + } + } + defer windows.UnlockFileEx(h, 0, 0xffffffff, 0xffffffff, &overlap) + return run() +} diff --git a/languages/golang/stackauth/oauth2.go b/languages/golang/stackauth/oauth2.go new file mode 100644 index 000000000..37056a790 --- /dev/null +++ b/languages/golang/stackauth/oauth2.go @@ -0,0 +1,26 @@ +package stackauth + +import ( + "context" + "errors" + + "golang.org/x/oauth2" +) + +// OAuth2TokenSource adapts an existing x/oauth2 provider to OIDCProvider. +// Its AccessToken is read afresh whenever the Rust federation strategy asks. +func OAuth2TokenSource(source oauth2.TokenSource) OIDCProvider { + return OIDCProviderFunc(func(context.Context) (string, error) { + if source == nil { + return "", errors.New("stackauth: nil oauth2 token source") + } + token, err := source.Token() + if err != nil { + return "", err + } + if token == nil || token.AccessToken == "" { + return "", errors.New("stackauth: oauth2 source returned no access token") + } + return token.AccessToken, nil + }) +} diff --git a/languages/golang/stackauth/store.go b/languages/golang/stackauth/store.go index 320492f98..77d4102e8 100644 --- a/languages/golang/stackauth/store.go +++ b/languages/golang/stackauth/store.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "log/slog" + "net/http" "os" "path/filepath" "runtime" @@ -23,6 +24,13 @@ type Option func(*options) type options struct { guest []byte requireLocked bool + transport http.RoundTripper +} + +// WithRoundTripper sends the guest's authentication requests through rt. +// The default is http.DefaultTransport. +func WithRoundTripper(rt http.RoundTripper) Option { + return func(o *options) { o.transport = rt } } // WithGuest overrides the embedded wasm module. @@ -104,7 +112,7 @@ func Open(ctx context.Context, dir string, opts ...Option) (*ProfileStore, error return nil, err } } - inst, err := newInstance(ctx, wasm, dir, guest.PolicyFor(o.requireLocked)) + inst, err := newInstance(ctx, wasm, dir, guest.PolicyFor(o.requireLocked), o.transport) if err != nil { return nil, err } @@ -175,6 +183,17 @@ type export func(*instance) api.Function // call runs one export under the profile's lock, closing the profile if // the guest trapped or an interrupted call took the module down. func (s *ProfileStore) call(ctx context.Context, fn export, args ...string) ([]byte, error) { + staged := make([]guest.Arg, 0, len(args)+1) + staged = append(staged, guest.BufArg([]byte(s.dir))) + for _, arg := range args { + staged = append(staged, guest.BufArg([]byte(arg))) + } + return s.callArgs(ctx, fn, staged...) +} + +// callArgs is the shared checked call path for profile exports and auth +// exports. The latter pass secret-bearing byte buffers and wipe host copies. +func (s *ProfileStore) callArgs(ctx context.Context, fn export, args ...guest.Arg) ([]byte, error) { r := s.root r.mu.Lock() defer r.mu.Unlock() @@ -183,7 +202,7 @@ func (s *ProfileStore) call(ctx context.Context, fn export, args ...string) ([]b return nil, ErrState } growth := r.inst.mem.GrowthRefusal() - out, err := r.inst.call(ctx, fn(r.inst), append([]string{s.dir}, args...)...) + out, err := guest.Call(ctx, r.inst.mem, r.inst.module, r.inst.exports, fn(r.inst), args...) switch { case r.inst.module.IsClosed(): r.closed = true @@ -282,9 +301,9 @@ func (s *ProfileStore) CurrentWorkspaceStore(ctx context.Context) (*ProfileStore // LockPath is the host path of the lock file the Rust crate takes for // filename in this store: a sibling `.<filename>.lock`. Nothing is created -// or locked. It is for the host to hold the crate's lock — around a -// refresh, once this package refreshes — since the guest cannot; this -// package never composes a profile path itself. +// or locked. DeviceSession holds this lock across the guest's refresh call; +// the guest cannot lock under WASI. This package never composes a profile +// path itself. func (s *ProfileStore) LockPath(ctx context.Context, filename string) (string, error) { // The guest validates the filename as the crate does, against the // guest's separator, which is `/`. The host's is checked here: on diff --git a/languages/golang/stackauth/store_test.go b/languages/golang/stackauth/store_test.go index 5872d0eb2..f4beaae2b 100644 --- a/languages/golang/stackauth/store_test.go +++ b/languages/golang/stackauth/store_test.go @@ -68,10 +68,8 @@ func write(t *testing.T, path, content string) { } } -// The guest may reach the filesystem — that is what it is for — and -// nothing else: no sockets, no transport import (the auth half adds one), -// and only the exports this package resolves. -func TestImportSurfaceIsWASIWithoutSockets(t *testing.T) { +// The guest may reach the profile mount and the two named auth host imports. +func TestImportSurfaceIsWASIAndAuthTransport(t *testing.T) { ctx := context.Background() r := wazero.NewRuntime(ctx) defer r.Close(ctx) @@ -81,10 +79,23 @@ func TestImportSurfaceIsWASIWithoutSockets(t *testing.T) { } defer compiled.Close(ctx) sawPathOpen := false + sawTransport := false + sawOIDC := false for _, imp := range compiled.ImportedFunctions() { module, name, _ := imp.Import() + if module == "cipherstash_transport" { + switch name { + case "transport_send": + sawTransport = true + case "oidc_token_get": + sawOIDC = true + default: + t.Errorf("unexpected auth host import %s", name) + } + continue + } if module != "wasi_snapshot_preview1" { - t.Errorf("guest imports %s::%s, outside WASI", module, name) + t.Errorf("guest imports %s::%s, outside the allowed surface", module, name) continue } if strings.HasPrefix(name, "sock_") { @@ -97,7 +108,10 @@ func TestImportSurfaceIsWASIWithoutSockets(t *testing.T) { if !sawPathOpen { t.Error("guest does not import path_open; it cannot be reading a profile") } - for _, name := range []string{"se_alloc", "se_dealloc", "sa_shutdown", "sa_current_workspace", "sa_set_current_workspace", "sa_clear_current_workspace", "sa_list_workspaces", "sa_workspace_dir", "sa_lock_path", "sa_secret_key", "sa_token", "sa_device_identity"} { + if !sawTransport || !sawOIDC { + t.Errorf("missing auth imports: transport=%t oidc=%t", sawTransport, sawOIDC) + } + for _, name := range []string{"se_alloc", "se_dealloc", "sa_shutdown", "sa_current_workspace", "sa_set_current_workspace", "sa_clear_current_workspace", "sa_list_workspaces", "sa_workspace_dir", "sa_lock_path", "sa_secret_key", "sa_token", "sa_device_identity", "sa_auth_new", "sa_auth_token", "sa_auth_free"} { if _, ok := compiled.ExportedFunctions()[name]; !ok { t.Errorf("guest does not export %s", name) } diff --git a/languages/golang/stackauth/strategy.go b/languages/golang/stackauth/strategy.go new file mode 100644 index 000000000..5618d8bcd --- /dev/null +++ b/languages/golang/stackauth/strategy.go @@ -0,0 +1,191 @@ +package stackauth + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "os" + "strconv" + "sync" + + "github.com/cipherstash/cipherstash-suite/bindings/go/internal/guest" + "github.com/tetratelabs/wazero/api" +) + +// StrategyOption configures an auth strategy. +type StrategyOption func(*strategyOptions) + +type strategyOptions struct{ baseURL string } + +// WithAuthBaseURL overrides CTS service discovery for one strategy. +func WithAuthBaseURL(url string) StrategyOption { + return func(o *strategyOptions) { o.baseURL = url } +} + +func strategyConfig(opts []StrategyOption) strategyOptions { + var o strategyOptions + for _, opt := range opts { + opt(&o) + } + if o.baseURL == "" { + o.baseURL = os.Getenv("CS_CTS_HOST") + } + return o +} + +// Strategy is a Rust stack-auth strategy retained inside the credential +// guest. Token satisfies stackencrypt.TokenSource. Close drops its cached +// credential; closing the parent profile closes all its strategies. +type Strategy struct { + store *ProfileStore + handle string + providerID uint32 + device bool + mu sync.Mutex + closed bool +} + +func (s *ProfileStore) newStrategy(ctx context.Context, config any, device bool) (*Strategy, error) { + data, err := json.Marshal(config) + if err != nil { + return nil, fmt.Errorf("stackauth: encode strategy: %w", err) + } + defer guest.Wipe(data) + out, err := s.callArgs(ctx, func(i *instance) api.Function { return i.authNew }, guest.BufArg(data)) + if err != nil { + return nil, err + } + if _, err := strconv.ParseUint(string(out), 10, 32); err != nil { + return nil, fmt.Errorf("%w: invalid auth handle", ErrInternal) + } + return &Strategy{store: s, handle: string(out), device: device}, nil +} + +// AccessKey constructs stack-auth's access-key strategy for crn. The key +// stays in the guest after construction; it is not sent on each Token call. +func (s *ProfileStore) AccessKey(ctx context.Context, crn, key string, opts ...StrategyOption) (*Strategy, error) { + if crn == "" || key == "" { + return nil, ErrAuthConfig + } + o := strategyConfig(opts) + return s.newStrategy(ctx, struct { + Kind string `json:"kind"` + CRN string `json:"crn"` + Key string `json:"access_key"` + BaseURL string `json:"base_url,omitempty"` + }{"access_key", crn, key, o.baseURL}, false) +} + +// OIDC constructs stack-auth's federation strategy. provider is called for +// a fresh IdP JWT only when the strategy needs to mint a CTS token. +func (s *ProfileStore) OIDC(ctx context.Context, crn string, provider OIDCProvider, opts ...StrategyOption) (*Strategy, error) { + if crn == "" || provider == nil { + return nil, ErrAuthConfig + } + o := strategyConfig(opts) + id := s.root.inst.transport.register(provider) + config := struct { + Kind string `json:"kind"` + CRN string `json:"crn"` + Provider uint32 `json:"provider"` + BaseURL string `json:"base_url,omitempty"` + }{"oidc", crn, id, o.baseURL} + strategy, err := s.newStrategy(ctx, config, false) + if err != nil { + s.root.inst.transport.unregister(id) + return nil, err + } + strategy.providerID = id + return strategy, nil +} + +// DeviceSession uses this workspace store's auth.json. Its Token call takes +// the same cross-process lock as the Rust CLI, then the guest re-reads the +// token, exchanges only if necessary, and saves before the lock is released. +func (s *ProfileStore) DeviceSession(ctx context.Context, opts ...StrategyOption) (*Strategy, error) { + if s.dir == guestRoot { + return nil, ErrAuthConfig + } + o := strategyConfig(opts) + return s.newStrategy(ctx, struct { + Kind string `json:"kind"` + Dir string `json:"workspace_dir"` + BaseURL string `json:"base_url,omitempty"` + }{"device_session", s.dir, o.baseURL}, true) +} + +// Auto follows stack-auth's detection order against the Go host's +// environment: access key first, then the current workspace's device token. +func (s *ProfileStore) Auto(ctx context.Context, opts ...StrategyOption) (*Strategy, error) { + if key := os.Getenv("CS_CLIENT_ACCESS_KEY"); key != "" { + crn := os.Getenv("CS_WORKSPACE_CRN") + if crn == "" { + return nil, ErrAuthConfig + } + return s.AccessKey(ctx, crn, key, opts...) + } + workspace, err := s.CurrentWorkspaceStore(ctx) + if err != nil { + if errors.Is(err, ErrNoCurrentWorkspace) || errors.Is(err, ErrNoProfile) { + return nil, ErrNotAuthenticated + } + return nil, err + } + if _, err := workspace.Token(ctx); err != nil { + if errors.Is(err, ErrNotFound) { + return nil, ErrNotAuthenticated + } + return nil, err + } + return workspace.DeviceSession(ctx, opts...) +} + +// Token gets the current CTS bearer credential. For a device session the +// host holds the refresh lock over the entire guest call. +func (s *Strategy) Token(ctx context.Context) (string, error) { + s.mu.Lock() + defer s.mu.Unlock() + if s.closed { + return "", ErrState + } + call := func() (string, error) { + out, err := s.store.callArgs(ctx, func(i *instance) api.Function { return i.authToken }, guest.BufArg([]byte(s.handle))) + if err != nil { + return "", err + } + return string(out), nil + } + if !s.device { + return call() + } + path, err := s.store.LockPath(ctx, "auth.json") + if err != nil { + return "", err + } + var token string + err = withRefreshLock(ctx, path, func() error { + var err error + token, err = call() + return err + }) + return token, err +} + +// Close drops the strategy's cached token and unregisters its provider. +func (s *Strategy) Close() error { + s.mu.Lock() + defer s.mu.Unlock() + if s.closed { + return nil + } + s.closed = true + if s.providerID != 0 { + s.store.root.inst.transport.unregister(s.providerID) + } + _, err := s.store.callArgs(context.Background(), func(i *instance) api.Function { return i.authFree }, guest.BufArg([]byte(s.handle))) + if errors.Is(err, ErrState) { + return nil + } + return err +} diff --git a/languages/golang/stackauth/strategy_test.go b/languages/golang/stackauth/strategy_test.go new file mode 100644 index 000000000..2ceadc3d1 --- /dev/null +++ b/languages/golang/stackauth/strategy_test.go @@ -0,0 +1,348 @@ +package stackauth + +import ( + "context" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "sync" + "sync/atomic" + "testing" + "time" +) + +const testCRN = "crn:ap-southeast-2.aws:ZVATKW3VHMFG27DY" + +func testJWT(t *testing.T, issuer string) string { + t.Helper() + payload, err := json.Marshal(map[string]any{ + "iss": issuer, "sub": "CS|test", "workspace": "ZVATKW3VHMFG27DY", + "exp": time.Now().Add(time.Hour).Unix(), + }) + if err != nil { + t.Fatal(err) + } + return "e30." + base64.RawURLEncoding.EncodeToString(payload) + ".c2ln" +} + +func TestAccessKeyStrategyCachesAndPreservesRequest(t *testing.T) { + guestOrSkip(t) + var calls atomic.Int32 + var jwt string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls.Add(1) + if r.Method != http.MethodPost || r.URL.Path != "/api/authorise" { + t.Errorf("request: %s %s", r.Method, r.URL.Path) + } + var body map[string]any + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Error(err) + } + if body["accessKey"] != "CSAKtestKeyId.testKeySecret" { + t.Errorf("body: %#v", body) + } + fmt.Fprintf(w, `{"accessToken":%q,"expiry":%d}`, jwt, time.Now().Add(time.Hour).Unix()) + })) + defer server.Close() + jwt = testJWT(t, server.URL) + profile, err := Open(context.Background(), t.TempDir()) + if err != nil { + t.Fatal(err) + } + defer profile.Close() + strategy, err := profile.AccessKey(context.Background(), testCRN, "CSAKtestKeyId.testKeySecret", WithAuthBaseURL(server.URL)) + if err != nil { + t.Fatal(err) + } + defer strategy.Close() + for i := 0; i < 2; i++ { + got, err := strategy.Token(context.Background()) + if err != nil || got != jwt { + t.Fatalf("Token #%d = %q, %v", i, got, err) + } + } + if calls.Load() != 1 { + t.Fatalf("auth requests = %d, want 1 cached exchange", calls.Load()) + } +} + +func TestOIDCStrategyCallsProviderOnlyOnExchange(t *testing.T) { + guestOrSkip(t) + var calls atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var body map[string]any + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Error(err) + } + if body["oidcToken"] != "idp-token" || body["workspaceId"] != "ZVATKW3VHMFG27DY" { + t.Errorf("body: %#v", body) + } + fmt.Fprintf(w, `{"accessToken":%q,"expiry":%d}`, testJWT(t, "https://cts.example"), time.Now().Add(time.Hour).Unix()) + })) + defer server.Close() + profile, err := Open(context.Background(), t.TempDir()) + if err != nil { + t.Fatal(err) + } + defer profile.Close() + strategy, err := profile.OIDC(context.Background(), testCRN, OIDCProviderFunc(func(context.Context) (string, error) { + calls.Add(1) + return "idp-token", nil + }), WithAuthBaseURL(server.URL)) + if err != nil { + t.Fatal(err) + } + defer strategy.Close() + for i := 0; i < 2; i++ { + if _, err := strategy.Token(context.Background()); err != nil { + t.Fatal(err) + } + } + if calls.Load() != 1 { + t.Fatalf("provider calls = %d, want 1", calls.Load()) + } +} + +func TestAuthErrorTaxonomy(t *testing.T) { + guestOrSkip(t) + for _, tc := range []struct { + name string + status int + body string + want error + }{ + {"usage limit", 402, `{"cs_code":"USAGE_LIMIT_EXCEEDED"}`, ErrUsageLimit}, + } { + t.Run(tc.name, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(tc.status) + fmt.Fprint(w, tc.body) + })) + defer server.Close() + profile, err := Open(context.Background(), t.TempDir()) + if err != nil { + t.Fatal(err) + } + defer profile.Close() + strategy, err := profile.AccessKey(context.Background(), testCRN, "CSAKtestKeyId.testKeySecret", WithAuthBaseURL(server.URL)) + if err != nil { + t.Fatal(err) + } + defer strategy.Close() + _, err = strategy.Token(context.Background()) + if !errors.Is(err, tc.want) { + t.Fatalf("Token error = %v, want %v", err, tc.want) + } + }) + } +} + +func TestDeviceRefreshInvalidClient(t *testing.T) { + guestOrSkip(t) + dir, _ := expiredDeviceProfile(t) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusBadRequest) + fmt.Fprint(w, `{"error":"invalid_client"}`) + })) + defer server.Close() + profile, err := Open(context.Background(), dir) + if err != nil { + t.Fatal(err) + } + defer profile.Close() + ws, err := profile.WorkspaceStore(context.Background(), wsA) + if err != nil { + t.Fatal(err) + } + strategy, err := ws.DeviceSession(context.Background(), WithAuthBaseURL(server.URL)) + if err != nil { + t.Fatal(err) + } + defer strategy.Close() + _, err = strategy.Token(context.Background()) + if !errors.Is(err, ErrInvalidClient) { + t.Fatalf("Token error = %v, want %v", err, ErrInvalidClient) + } +} + +// Match stack-auth's AutoStrategy order: an access key wins over a stored +// device session; with no key, the current workspace's auth.json is used. +func TestAutoStrategyDetectionOrder(t *testing.T) { + guestOrSkip(t) + dir, _ := expiredDeviceProfile(t) + var accessCalls, refreshCalls atomic.Int32 + var accessJWT string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/api/authorise": + accessCalls.Add(1) + fmt.Fprintf(w, `{"accessToken":%q,"expiry":%d}`, accessJWT, time.Now().Add(time.Hour).Unix()) + case "/oauth/token": + refreshCalls.Add(1) + fmt.Fprint(w, `{"access_token":"device-token","token_type":"Bearer","expires_in":3600,"refresh_token":"refresh-2"}`) + default: + t.Errorf("unexpected auth path %q", r.URL.Path) + http.NotFound(w, r) + } + })) + defer server.Close() + accessJWT = testJWT(t, server.URL) + profile, err := Open(context.Background(), dir) + if err != nil { + t.Fatal(err) + } + defer profile.Close() + if err := profile.SetCurrentWorkspace(context.Background(), wsA); err != nil { + t.Fatal(err) + } + t.Setenv("CS_CLIENT_ACCESS_KEY", "CSAKtestKeyId.testKeySecret") + t.Setenv("CS_WORKSPACE_CRN", testCRN) + strategy, err := profile.Auto(context.Background(), WithAuthBaseURL(server.URL)) + if err != nil { + t.Fatal(err) + } + token, err := strategy.Token(context.Background()) + if err != nil || token != accessJWT { + t.Fatalf("access key: token %q, error %v", token, err) + } + strategy.Close() + if accessCalls.Load() != 1 || refreshCalls.Load() != 0 { + t.Fatalf("access key should win: access=%d refresh=%d", accessCalls.Load(), refreshCalls.Load()) + } + t.Setenv("CS_CLIENT_ACCESS_KEY", "") + strategy, err = profile.Auto(context.Background(), WithAuthBaseURL(server.URL)) + if err != nil { + t.Fatal(err) + } + defer strategy.Close() + token, err = strategy.Token(context.Background()) + if err != nil || token != "device-token" { + t.Fatalf("device fallback: token %q, error %v", token, err) + } + if refreshCalls.Load() != 1 { + t.Fatalf("device refresh requests = %d, want 1", refreshCalls.Load()) + } + if err := profile.ClearCurrentWorkspace(context.Background()); err != nil { + t.Fatal(err) + } + if _, err := profile.Auto(context.Background(), WithAuthBaseURL(server.URL)); !errors.Is(err, ErrNotAuthenticated) { + t.Fatalf("no credentials: error = %v, want %v", err, ErrNotAuthenticated) + } +} + +func expiredDeviceProfile(t *testing.T) (string, string) { + t.Helper() + dir := t.TempDir() + workspaceDir := filepath.Join(dir, "workspaces", wsA) + if err := os.MkdirAll(workspaceDir, 0o700); err != nil { + t.Fatal(err) + } + data := fmt.Sprintf(`{"access_token":"old","refresh_token":"refresh-1","token_type":"Bearer","expires_at":%d,"region":"ap-southeast-2.aws","client_id":"client-1"}`, time.Now().Add(-time.Hour).Unix()) + write(t, filepath.Join(workspaceDir, "auth.json"), data) + return dir, workspaceDir +} + +func TestDeviceRefreshLockPreventsReplay(t *testing.T) { + guestOrSkip(t) + dir, _ := expiredDeviceProfile(t) + var calls atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls.Add(1) + if r.URL.Path != "/oauth/token" { + t.Errorf("path: %s", r.URL.Path) + } + if err := r.ParseForm(); err != nil { + t.Error(err) + } + if r.Form.Get("grant_type") != "refresh_token" || r.Form.Get("refresh_token") != "refresh-1" || r.Form.Get("client_id") != "client-1" { + t.Errorf("form: %v", r.Form) + } + time.Sleep(50 * time.Millisecond) + fmt.Fprint(w, `{"access_token":"fresh","token_type":"Bearer","expires_in":3600,"refresh_token":"refresh-2"}`) + })) + defer server.Close() + var wg sync.WaitGroup + errCh := make(chan error, 2) + for i := 0; i < 2; i++ { + wg.Add(1) + go func() { + defer wg.Done() + profile, err := Open(context.Background(), dir) + if err != nil { + errCh <- err + return + } + defer profile.Close() + ws, err := profile.WorkspaceStore(context.Background(), wsA) + if err != nil { + errCh <- err + return + } + strategy, err := ws.DeviceSession(context.Background(), WithAuthBaseURL(server.URL)) + if err != nil { + errCh <- err + return + } + defer strategy.Close() + token, err := strategy.Token(context.Background()) + if err == nil && token != "fresh" { + err = fmt.Errorf("token = %q, want fresh", token) + } + errCh <- err + }() + } + wg.Wait() + close(errCh) + for err := range errCh { + if err != nil { + t.Error(err) + } + } + if calls.Load() != 1 { + t.Fatalf("refresh requests = %d, want 1", calls.Load()) + } + data, err := os.ReadFile(filepath.Join(dir, "workspaces", wsA, "auth.json")) + if err != nil { + t.Fatal(err) + } + var stored map[string]any + if err := json.Unmarshal(data, &stored); err != nil { + t.Fatal(err) + } + if stored["refresh_token"] != "refresh-2" { + t.Fatalf("stored refresh token = %v", stored["refresh_token"]) + } +} + +func TestDeviceRefreshInvalidGrant(t *testing.T) { + guestOrSkip(t) + dir, _ := expiredDeviceProfile(t) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusBadRequest) + fmt.Fprint(w, `{"error":"invalid_grant"}`) + })) + defer server.Close() + profile, err := Open(context.Background(), dir) + if err != nil { + t.Fatal(err) + } + defer profile.Close() + ws, err := profile.WorkspaceStore(context.Background(), wsA) + if err != nil { + t.Fatal(err) + } + strategy, err := ws.DeviceSession(context.Background(), WithAuthBaseURL(server.URL)) + if err != nil { + t.Fatal(err) + } + defer strategy.Close() + _, err = strategy.Token(context.Background()) + if !errors.Is(err, ErrInvalidGrant) { + t.Fatalf("Token error = %v, want ErrInvalidGrant", err) + } +} diff --git a/languages/golang/stackauth/token.go b/languages/golang/stackauth/token.go index 48f65ac75..1241e5432 100644 --- a/languages/golang/stackauth/token.go +++ b/languages/golang/stackauth/token.go @@ -11,8 +11,7 @@ import ( // Token is the stored access token, as auth.json holds it and // [ProfileStore.Token] reads it through the Rust crate's own type. The -// refresh token is not in it: refreshing is the auth half of this package, -// and it runs inside the guest. +// refresh token is not in it: the guest handles refresh through a Strategy. type Token struct { // AccessToken is the bearer credential. AccessToken string @@ -76,8 +75,7 @@ func (s *ProfileStore) Token(ctx context.Context) (Token, error) { // Every call re-reads the file, so a login or refresh by the CLI in // another terminal is picked up without a restart, and a token at or past // its real expiry is refused with [ErrTokenExpired] rather than presented. -// Refreshing is not here yet; until it is, the answer to ErrTokenExpired -// is `stash auth login`. +// This read-only source does not refresh; use DeviceSession for that. type TokenSource struct { store *ProfileStore // now is the clock, for tests; nil is time.Now. diff --git a/languages/golang/stackauth/transport.go b/languages/golang/stackauth/transport.go new file mode 100644 index 000000000..18f0fb745 --- /dev/null +++ b/languages/golang/stackauth/transport.go @@ -0,0 +1,222 @@ +package stackauth + +import ( + "context" + "errors" + "fmt" + "io" + "net/http" + "sort" + "strings" + "sync" + + "github.com/cipherstash/cipherstash-suite/bindings/go/internal/guest" + "github.com/tetratelabs/wazero" + "github.com/tetratelabs/wazero/api" +) + +// OIDCProvider supplies the current identity-provider JWT. The Rust +// federation strategy asks only when its cached CTS token needs renewal. +type OIDCProvider interface { + Token(context.Context) (string, error) +} + +// OIDCProviderFunc adapts a function to OIDCProvider. +type OIDCProviderFunc func(context.Context) (string, error) + +func (f OIDCProviderFunc) Token(ctx context.Context) (string, error) { return f(ctx) } + +const maxAuthResponseBytes = 16 << 20 + +type authTransport struct { + rt http.RoundTripper + mu sync.Mutex + providers map[uint32]OIDCProvider + nextProvider uint32 +} + +func newAuthTransport(rt http.RoundTripper) *authTransport { + if rt == nil { + rt = http.DefaultTransport + } + return &authTransport{rt: rt, providers: make(map[uint32]OIDCProvider)} +} + +func (t *authTransport) register(provider OIDCProvider) uint32 { + t.mu.Lock() + defer t.mu.Unlock() + t.nextProvider++ + if t.nextProvider == 0 { + t.nextProvider++ + } + id := t.nextProvider + t.providers[id] = provider + return id +} + +func (t *authTransport) unregister(id uint32) { + t.mu.Lock() + delete(t.providers, id) + t.mu.Unlock() +} + +func (t *authTransport) instantiate(ctx context.Context, r wazero.Runtime) error { + _, err := r.NewHostModuleBuilder("cipherstash_transport"). + NewFunctionBuilder().WithFunc(t.send).Export("transport_send"). + NewFunctionBuilder().WithFunc(t.oidcTokenGet).Export("oidc_token_get"). + Instantiate(ctx) + return err +} + +// send is the same host import contract used by stackencrypt: four input +// buffers and two output slots, with a negative status on transport failure. +func (t *authTransport) send(ctx context.Context, m api.Module, + methodPtr, methodLen, urlPtr, urlLen, headersPtr, headersLen, bodyPtr, bodyLen uint32, + respHeadersPtrOut, respHeadersLenOut, respBodyPtrOut, respBodyLenOut uint32, +) int32 { + mem := m.Memory() + method, ok1 := mem.Read(methodPtr, methodLen) + url, ok2 := mem.Read(urlPtr, urlLen) + headers, ok3 := mem.Read(headersPtr, headersLen) + body, ok4 := mem.Read(bodyPtr, bodyLen) + if !ok1 || !ok2 || !ok3 || !ok4 { + return t.placeResponse(ctx, m, respHeadersPtrOut, respHeadersLenOut, respBodyPtrOut, respBodyLenOut, -1, nil, []byte("request buffer out of range")) + } + requestBody := newAuthRequestBody(body) + req, err := http.NewRequestWithContext(ctx, string(method), string(url), requestBody) + if err != nil { + _ = requestBody.Close() + return t.placeResponse(ctx, m, respHeadersPtrOut, respHeadersLenOut, respBodyPtrOut, respBodyLenOut, -1, nil, []byte(err.Error())) + } + req.ContentLength = int64(len(body)) + req.Header = parseAuthHeaders(headers) + resp, err := t.rt.RoundTrip(req) + if err != nil { + return t.placeResponse(ctx, m, respHeadersPtrOut, respHeadersLenOut, respBodyPtrOut, respBodyLenOut, -1, nil, []byte(err.Error())) + } + defer resp.Body.Close() + if resp.ContentLength > maxAuthResponseBytes { + return t.placeResponse(ctx, m, respHeadersPtrOut, respHeadersLenOut, respBodyPtrOut, respBodyLenOut, -1, nil, []byte("auth response exceeds limit")) + } + responseBody, err := io.ReadAll(io.LimitReader(resp.Body, maxAuthResponseBytes+1)) + if err != nil { + guest.Wipe(responseBody) + return t.placeResponse(ctx, m, respHeadersPtrOut, respHeadersLenOut, respBodyPtrOut, respBodyLenOut, -1, nil, []byte(err.Error())) + } + defer guest.Wipe(responseBody) + if len(responseBody) > maxAuthResponseBytes { + return t.placeResponse(ctx, m, respHeadersPtrOut, respHeadersLenOut, respBodyPtrOut, respBodyLenOut, -1, nil, []byte("auth response exceeds limit")) + } + return t.placeResponse(ctx, m, respHeadersPtrOut, respHeadersLenOut, respBodyPtrOut, respBodyLenOut, int32(resp.StatusCode), encodeAuthHeaders(resp.Header), responseBody) +} + +func (t *authTransport) placeResponse(ctx context.Context, m api.Module, + hp, hl, bp, bl uint32, status int32, headers, body []byte, +) int32 { + if !placeAuth(ctx, m, hp, hl, headers) || !placeAuth(ctx, m, bp, bl, body) { + return -1 + } + return status +} + +func (t *authTransport) oidcTokenGet(ctx context.Context, m api.Module, provider, ptrOut, lenOut uint32) int32 { + t.mu.Lock() + source := t.providers[provider] + t.mu.Unlock() + if source == nil { + return 1 + } + token, err := source.Token(ctx) + if err != nil || token == "" || strings.ContainsAny(token, "\r\n\x00") { + return 1 + } + bytes := []byte(token) + defer guest.Wipe(bytes) + if !placeAuth(ctx, m, ptrOut, lenOut, bytes) { + return 1 + } + return 0 +} + +// The only guest re-entry allowed during a host import is its allocator. +func placeAuth(ctx context.Context, m api.Module, ptrOut, lenOut uint32, data []byte) bool { + alloc := m.ExportedFunction("se_alloc") + if alloc == nil { + return false + } + res, err := alloc.Call(ctx, uint64(len(data))) + if err != nil || len(res) == 0 || res[0] == 0 { + return false + } + ptr := uint32(res[0]) + mem := m.Memory() + return (len(data) == 0 || mem.Write(ptr, data)) && + mem.WriteUint32Le(ptrOut, ptr) && mem.WriteUint32Le(lenOut, uint32(len(data))) +} + +func parseAuthHeaders(buf []byte) http.Header { + h := make(http.Header) + for _, line := range strings.Split(string(buf), "\n") { + name, value, ok := strings.Cut(line, ":") + if ok { + h.Add(strings.TrimSpace(name), strings.TrimSpace(value)) + } + } + return h +} + +func encodeAuthHeaders(h http.Header) []byte { + names := make([]string, 0, len(h)) + for name := range h { + names = append(names, name) + } + sort.Strings(names) + var out strings.Builder + for _, name := range names { + for _, value := range h[name] { + if out.Len() > 0 { + out.WriteByte('\n') + } + fmt.Fprintf(&out, "%s: %s", name, value) + } + } + return []byte(out.String()) +} + +// The host owns its request-body copy until the RoundTripper closes it. +// A transport may read after RoundTrip returns, so wiping on return races. +type authRequestBody struct { + mu sync.Mutex + buf []byte + off int + closed bool +} + +func newAuthRequestBody(src []byte) *authRequestBody { + buf := append([]byte(nil), src...) + return &authRequestBody{buf: buf} +} + +func (b *authRequestBody) Read(p []byte) (int, error) { + b.mu.Lock() + defer b.mu.Unlock() + if b.closed { + return 0, errors.New("auth request body read after close") + } + if b.off == len(b.buf) { + return 0, io.EOF + } + n := copy(p, b.buf[b.off:]) + b.off += n + return n, nil +} + +func (b *authRequestBody) Close() error { + b.mu.Lock() + defer b.mu.Unlock() + if !b.closed { + guest.Wipe(b.buf) + b.closed = true + } + return nil +} diff --git a/packages/stack-auth/Cargo.toml b/packages/stack-auth/Cargo.toml index f9ac353c5..dbe47de08 100644 --- a/packages/stack-auth/Cargo.toml +++ b/packages/stack-auth/Cargo.toml @@ -28,6 +28,7 @@ serde_json = { workspace = true } # `.form()` does (it uses this crate), so the bundled transport and a host's # own transport send byte-identical requests. serde_urlencoded = "0.7" +stack-profile = { workspace = true } thiserror = { workspace = true } tracing = { workspace = true } url = { workspace = true } @@ -52,7 +53,6 @@ zeroize = { workspace = true } # every target — `base64` above is shared. Wasm consumers use # `DeviceSessionStrategy::with_token` (in-memory) or `AccessKeyStrategy`. [target.'cfg(not(target_arch = "wasm32"))'.dependencies] -stack-profile = { workspace = true } open = "5.3.2" jsonwebtoken = { workspace = true } tokio = { workspace = true } diff --git a/packages/stack-auth/src/device_session_refresher.rs b/packages/stack-auth/src/device_session_refresher.rs index bc2690eb9..0b1c74dd2 100644 --- a/packages/stack-auth/src/device_session_refresher.rs +++ b/packages/stack-auth/src/device_session_refresher.rs @@ -1,7 +1,8 @@ use url::Url; +use stack_profile::ProfileStore; #[cfg(not(target_arch = "wasm32"))] -use stack_profile::{FileLockGuard, ProfileData, ProfileStore}; +use stack_profile::{FileLockGuard, ProfileData}; use crate::refresher::Refresher; use crate::transport::SharedTransport; @@ -9,11 +10,10 @@ use crate::{AuthError, SecretToken, Token}; /// Implements [`Refresher`] using OAuth refresh tokens. /// -/// Optionally owns a [`ProfileStore`] for persisting refreshed tokens to disk -/// (native targets only). When the store is `None` — or always on wasm32 — -/// tokens are cached in memory only. +/// Optionally owns a [`ProfileStore`] for persisting refreshed tokens to disk. +/// When the store is `None`, tokens are cached in memory only. On wasm32 the +/// embedding host must hold the refresh lock across the whole refresh call. pub(crate) struct DeviceSessionRefresher { - #[cfg(not(target_arch = "wasm32"))] store: Option<ProfileStore>, base_url: Url, client_id: String, @@ -23,7 +23,6 @@ pub(crate) struct DeviceSessionRefresher { } impl DeviceSessionRefresher { - #[cfg(not(target_arch = "wasm32"))] pub(crate) fn new( store: Option<ProfileStore>, base_url: Url, @@ -41,24 +40,6 @@ impl DeviceSessionRefresher { transport, } } - - #[cfg(target_arch = "wasm32")] - pub(crate) fn new( - _store: Option<()>, - base_url: Url, - client_id: impl Into<String>, - region: impl Into<String>, - device_instance_id: Option<String>, - transport: SharedTransport, - ) -> Self { - Self { - base_url, - client_id: client_id.into(), - region: region.into(), - device_instance_id, - transport, - } - } } impl Refresher for DeviceSessionRefresher { @@ -100,7 +81,6 @@ impl Refresher for DeviceSessionRefresher { // token that another process just rotated to. Burn our (now-stale) // credential against Clerk and we'd get "already used"; return the // disk copy directly instead. - #[cfg(not(target_arch = "wasm32"))] if let Some(disk_token) = self.load_freshly_refreshed_token(credential) { tracing::debug!( "refresh skipped: another process rotated the token while we waited on the lock" @@ -125,19 +105,18 @@ impl Refresher for DeviceSessionRefresher { // Persist while holding the lock — any sibling process waiting on // the lock will read the rotated token on their next attempt and // skip burning their stale credential. - #[cfg(not(target_arch = "wasm32"))] - self.persist_refreshed(&token); + self.persist_refreshed(&token)?; Ok(token) } } -#[cfg(not(target_arch = "wasm32"))] impl DeviceSessionRefresher { /// Acquire the cross-process refresh lock on `auth.json`, off the async /// runtime thread so we don't block other tasks. Returns `None` when no /// `ProfileStore` is configured (in-memory refreshers can't race against /// other processes since there's no shared state). + #[cfg(not(target_arch = "wasm32"))] async fn acquire_refresh_lock(&self) -> Result<Option<FileLockGuard>, AuthError> { let Some(store) = self.store.clone() else { return Ok(None); @@ -175,20 +154,18 @@ impl DeviceSessionRefresher { } /// Persist the freshly refreshed token to disk while the lock is held. - /// A failure here is logged loudly because it's the precondition for - /// Clerk's refresh-token-rotation replay detection to fire on a later - /// process: we keep using the rotated token from memory while disk - /// still holds the previous (now-revoked) one. - fn persist_refreshed(&self, token: &Token) { - let Some(store) = &self.store else { return }; - match store.save_profile(token) { - Ok(()) => tracing::debug!("refreshed token saved to disk"), - Err(err) => tracing::error!( - %err, - "failed to persist refreshed token to disk — a subsequent process \ - will replay the prior refresh token and Clerk will revoke the chain" - ), - } + /// A failure here must reach the caller: returning a token while disk + /// still holds its consumed refresh token would hide a broken rotation. + fn persist_refreshed(&self, token: &Token) -> Result<(), AuthError> { + let Some(store) = &self.store else { + return Ok(()); + }; + store.save_profile(token).map_err(|err| { + tracing::error!(%err, "failed to persist refreshed token to disk"); + AuthError::from(err) + })?; + tracing::debug!("refreshed token saved to disk"); + Ok(()) } } diff --git a/packages/stack-auth/src/device_session_strategy.rs b/packages/stack-auth/src/device_session_strategy.rs index afc0b9be5..8596da179 100644 --- a/packages/stack-auth/src/device_session_strategy.rs +++ b/packages/stack-auth/src/device_session_strategy.rs @@ -1,7 +1,6 @@ use cts_common::{Crn, CtsServiceDiscovery, Region, ServiceDiscovery}; use tracing::warn; -#[cfg(not(target_arch = "wasm32"))] use stack_profile::ProfileStore; use crate::auto_refresh::AutoRefresh; @@ -68,7 +67,6 @@ impl DeviceSessionStrategy { /// The token must have `region` and `client_id` set (as saved by /// `DeviceCodeStrategy` (native, with the `http` feature) or a prior /// `DeviceSessionStrategy`). The store is used for persisting refreshed tokens. - #[cfg(not(target_arch = "wasm32"))] pub fn with_profile(store: ProfileStore) -> DeviceSessionStrategyBuilder { DeviceSessionStrategyBuilder { source: OAuthTokenSource::Store(store), @@ -77,6 +75,19 @@ impl DeviceSessionStrategy { } } + /// Build from a workspace-scoped profile store. WASI hosts use this + /// after taking the sibling auth.json lock; the store is read when + /// `build` runs, so a sibling process's completed rotation is observed. + /// On wasm32 the host must hold that lock through `get_token`, including + /// the save, because WASI preview 1 has no file locking. + pub fn with_workspace_store(store: ProfileStore) -> DeviceSessionStrategyBuilder { + DeviceSessionStrategyBuilder { + source: OAuthTokenSource::WorkspaceStore(store), + base_url_override: None, + transport: None, + } + } + /// Return the workspace CRN, if one was extracted from the token at build time. pub fn workspace_crn(&self) -> Option<&Crn> { self.crn.as_ref() @@ -98,8 +109,8 @@ enum OAuthTokenSource { token: Token, }, /// A token loaded from a persistent store. - #[cfg(not(target_arch = "wasm32"))] Store(ProfileStore), + WorkspaceStore(ProfileStore), } /// Builder for [`DeviceSessionStrategy`]. @@ -165,9 +176,12 @@ impl DeviceSessionStrategyBuilder { client_id, token, } => Self::build_from_token(region, client_id, token, base_url_override, transport), - #[cfg(not(target_arch = "wasm32"))] OAuthTokenSource::Store(store) => { - Self::build_from_store(store, base_url_override, transport) + let ws_store = store.current_workspace_store()?; + Self::build_from_workspace_store(ws_store, base_url_override, transport) + } + OAuthTokenSource::WorkspaceStore(store) => { + Self::build_from_workspace_store(store, base_url_override, transport) } } } @@ -216,13 +230,11 @@ impl DeviceSessionStrategyBuilder { } /// Build from a token persisted in a [`ProfileStore`]. - #[cfg(not(target_arch = "wasm32"))] - fn build_from_store( - store: ProfileStore, + fn build_from_workspace_store( + ws_store: ProfileStore, base_url_override: Option<url::Url>, transport: SharedTransport, ) -> Result<DeviceSessionStrategy, AuthError> { - let ws_store = store.current_workspace_store()?; let token: Token = ws_store.load_profile()?; let region_str = token diff --git a/packages/stack-auth/src/error.rs b/packages/stack-auth/src/error.rs index 3bc2cef2d..4767a616b 100644 --- a/packages/stack-auth/src/error.rs +++ b/packages/stack-auth/src/error.rs @@ -928,6 +928,16 @@ impl From<stack_profile::ProfileError> for AuthError { } } +// A WASI credential guest reads its profile through stack-profile too. Its +// host holds the refresh lock; profile failures are reported by the guest's +// dedicated status codes when it loads the store, before strategy creation. +#[cfg(target_arch = "wasm32")] +impl From<stack_profile::ProfileError> for AuthError { + fn from(e: stack_profile::ProfileError) -> Self { + Self::Custom(CustomError(e.to_string())) + } +} + #[cfg(all(feature = "http", not(target_arch = "wasm32")))] impl From<crate::DeviceClientError> for AuthError { fn from(e: crate::DeviceClientError) -> Self { diff --git a/packages/stack-auth/src/token.rs b/packages/stack-auth/src/token.rs index 364493c25..f66580311 100644 --- a/packages/stack-auth/src/token.rs +++ b/packages/stack-auth/src/token.rs @@ -5,7 +5,6 @@ use url::Url; use crate::transport::{self, SharedTransport}; use crate::{AuthError, SecretToken}; -#[cfg(not(target_arch = "wasm32"))] impl stack_profile::ProfileData for Token { const FILENAME: &'static str = "auth.json"; const MODE: Option<u32> = Some(0o600); diff --git a/packages/stack-guest-abi/src/status.rs b/packages/stack-guest-abi/src/status.rs index cf00ff400..99d675463 100644 --- a/packages/stack-guest-abi/src/status.rs +++ b/packages/stack-guest-abi/src/status.rs @@ -99,10 +99,20 @@ pub const STATUS_PROFILE_INVALID_WORKSPACE_ID: u32 = 18; /// in to it on this machine. pub const STATUS_PROFILE_WORKSPACE_NOT_FOUND: u32 = 19; +// Auth strategy verdicts from the credential guest. Keep the three +// actionable exchange refusals separate from network/configuration errors. +pub const STATUS_AUTH_INVALID_GRANT: u32 = 20; +pub const STATUS_AUTH_INVALID_CLIENT: u32 = 21; +pub const STATUS_AUTH_USAGE_LIMIT: u32 = 22; +pub const STATUS_AUTH_NOT_AUTHENTICATED: u32 = 23; +pub const STATUS_AUTH_TRANSPORT: u32 = 24; +pub const STATUS_AUTH_CONFIG: u32 = 25; +pub const STATUS_AUTH_OTHER: u32 = 26; + /// The last code in the table. A guest appending a code of its own starts /// at `LAST_STATUS + 1` and moves this constant with it, so two guests can /// never claim one number. -pub const LAST_STATUS: u32 = STATUS_PROFILE_WORKSPACE_NOT_FOUND; +pub const LAST_STATUS: u32 = STATUS_AUTH_OTHER; #[cfg(test)] mod tests { @@ -132,6 +142,13 @@ mod tests { STATUS_PROFILE_NO_CURRENT_WORKSPACE, STATUS_PROFILE_INVALID_WORKSPACE_ID, STATUS_PROFILE_WORKSPACE_NOT_FOUND, + STATUS_AUTH_INVALID_GRANT, + STATUS_AUTH_INVALID_CLIENT, + STATUS_AUTH_USAGE_LIMIT, + STATUS_AUTH_NOT_AUTHENTICATED, + STATUS_AUTH_TRANSPORT, + STATUS_AUTH_CONFIG, + STATUS_AUTH_OTHER, ]; for (i, code) in codes.iter().enumerate() { assert_eq!(*code, i as u32 + 1, "code {i} is out of sequence"); diff --git a/scripts/go-binding-test.sh b/scripts/go-binding-test.sh index 958e0bf6f..0b9bd0db2 100755 --- a/scripts/go-binding-test.sh +++ b/scripts/go-binding-test.sh @@ -35,6 +35,10 @@ if [ -n "$out" ]; then fi go vet ./... +# Keep the refresh-lock results explicit in CI logs on Linux, macOS, and +# Windows. These tests exercise the platform lock implementation with two +# independent guest instances sharing one auth.json. +CGO_ENABLED=0 go test -v ./stackauth -run '^TestDeviceRefresh' CGO_ENABLED=0 go test ./... # The transport codec's u32-bound guards are load-bearing where int is 32 From b5a39b30e2c86c6346335efc827ee18332c38be5 Mon Sep 17 00:00:00 2001 From: Dan Draper <dan@cipherstash.com> Date: Fri, 25 Sep 2026 18:35:56 -0700 Subject: [PATCH 636/686] fix(stackauth): satisfy credential guest CI Format the standalone WASI guest with the CI toolchain, record npm release intent, and expose profile error conversion to native mutation testing. --- languages/golang/stackauth/guest/src/auth.rs | 4 +-- languages/golang/stackauth/guest/src/host.rs | 4 ++- languages/golang/stackauth/guest/src/lib.rs | 4 +-- .../golang/stackauth/guest/src/status.rs | 9 ++++-- packages/stack-auth/src/error.rs | 32 ++++++++++++------- 5 files changed, 34 insertions(+), 19 deletions(-) diff --git a/languages/golang/stackauth/guest/src/auth.rs b/languages/golang/stackauth/guest/src/auth.rs index fb140880f..3a64c62d5 100644 --- a/languages/golang/stackauth/guest/src/auth.rs +++ b/languages/golang/stackauth/guest/src/auth.rs @@ -131,8 +131,8 @@ pub fn token(handle: &[u8]) -> Result<Vec<u8>, u32> { base_url, } => { let store = ProfileStore::new(workspace_dir); - let mut builder = DeviceSessionStrategy::with_workspace_store(store) - .transport(WasiAuthTransport); + let mut builder = + DeviceSessionStrategy::with_workspace_store(store).transport(WasiAuthTransport); if let Some(url) = base_url { builder = builder.base_url(url.clone()); } diff --git a/languages/golang/stackauth/guest/src/host.rs b/languages/golang/stackauth/guest/src/host.rs index 7dac1c788..7abbec7b8 100644 --- a/languages/golang/stackauth/guest/src/host.rs +++ b/languages/golang/stackauth/guest/src/host.rs @@ -81,7 +81,9 @@ impl OidcProvider for HostOidcProvider { let token = std::str::from_utf8(&bytes) .map_err(|_| AuthError::Request(request_error("OIDC token is not UTF-8")))?; if token.is_empty() || token.chars().any(char::is_control) { - return Err(AuthError::Request(request_error("OIDC token is empty or contains controls"))); + return Err(AuthError::Request(request_error( + "OIDC token is empty or contains controls", + ))); } Ok(SecretToken::new(token)) } diff --git a/languages/golang/stackauth/guest/src/lib.rs b/languages/golang/stackauth/guest/src/lib.rs index 498b9f8d4..54e9f462e 100644 --- a/languages/golang/stackauth/guest/src/lib.rs +++ b/languages/golang/stackauth/guest/src/lib.rs @@ -96,10 +96,10 @@ pub mod ops; pub mod status; -#[cfg(target_arch = "wasm32")] -pub mod host; #[cfg(target_arch = "wasm32")] pub mod auth; +#[cfg(target_arch = "wasm32")] +pub mod host; // The ABI's packed u64 results embed 32-bit pointers and its bounds checks // read the wasm linear-memory size, so this module only exists on wasm32. diff --git a/languages/golang/stackauth/guest/src/status.rs b/languages/golang/stackauth/guest/src/status.rs index 38d2ae6c5..17d3c220f 100644 --- a/languages/golang/stackauth/guest/src/status.rs +++ b/languages/golang/stackauth/guest/src/status.rs @@ -27,8 +27,13 @@ pub fn status_for_auth(error: &AuthError) -> u32 { "USAGE_LIMIT_EXCEEDED" => STATUS_AUTH_USAGE_LIMIT, "NOT_AUTHENTICATED" | "EXPIRED_TOKEN" => STATUS_AUTH_NOT_AUTHENTICATED, "REQUEST_ERROR" | "SERVER_ERROR" => STATUS_AUTH_TRANSPORT, - "INVALID_URL" | "INVALID_REGION" | "INVALID_CRN" | "WORKSPACE_MISMATCH" - | "INVALID_WORKSPACE_ID" | "MISSING_WORKSPACE_CRN" | "INVALID_ACCESS_KEY" + "INVALID_URL" + | "INVALID_REGION" + | "INVALID_CRN" + | "WORKSPACE_MISMATCH" + | "INVALID_WORKSPACE_ID" + | "MISSING_WORKSPACE_CRN" + | "INVALID_ACCESS_KEY" | "INVALID_TOKEN" => STATUS_AUTH_CONFIG, _ => STATUS_AUTH_OTHER, } diff --git a/packages/stack-auth/src/error.rs b/packages/stack-auth/src/error.rs index 4767a616b..fcf6dd33c 100644 --- a/packages/stack-auth/src/error.rs +++ b/packages/stack-auth/src/error.rs @@ -921,20 +921,18 @@ impl From<access_key::InvalidAccessKey> for AuthError { } } -#[cfg(not(target_arch = "wasm32"))] impl From<stack_profile::ProfileError> for AuthError { fn from(e: stack_profile::ProfileError) -> Self { - Self::Store(StoreError(e)) - } -} - -// A WASI credential guest reads its profile through stack-profile too. Its -// host holds the refresh lock; profile failures are reported by the guest's -// dedicated status codes when it loads the store, before strategy creation. -#[cfg(target_arch = "wasm32")] -impl From<stack_profile::ProfileError> for AuthError { - fn from(e: stack_profile::ProfileError) -> Self { - Self::Custom(CustomError(e.to_string())) + #[cfg(not(target_arch = "wasm32"))] + { + Self::Store(StoreError(e)) + } + // On wasm32 the auth guest still loads and saves profiles, but the + // native Store variant is not part of that target's public enum. + #[cfg(target_arch = "wasm32")] + { + Self::Custom(CustomError(e.to_string())) + } } } @@ -1389,6 +1387,16 @@ mod classify_issuance_failure_tests { mod tests { use super::*; + #[test] + #[cfg(not(target_arch = "wasm32"))] + fn profile_error_retains_store_type() { + let err = AuthError::from(stack_profile::ProfileError::NotFound { + path: "auth.json".into(), + }); + assert!(matches!(&err, AuthError::Store(StoreError(_)))); + assert_eq!(err.error_code(), codes::STORE_ERROR); + } + /// The typed variant must survive the FFI round-trip; degrading to `CUSTOM` /// would put clients back to string-matching the message. #[test] From dcea25286b7551ec1460b640547479550b6c8e89 Mon Sep 17 00:00:00 2001 From: Dan Draper <dan@cipherstash.com> Date: Fri, 25 Sep 2026 19:17:56 -0700 Subject: [PATCH 637/686] fix(stackauth): lock only device refresh Fresh device tokens no longer contend with CLI refreshes. Re-read after acquiring the host lock so concurrent refreshes cannot replay a rotated credential. Preserve profile error types across WASM and match Rust auto-selection for empty keys, invalid CRNs, and malformed saved profiles. Refs cipherstash/cipherstash-suite#2260 --- .github/imported-workflows/test-wasi.yml | 5 + languages/golang/internal/guest/errors.go | 24 ++- .../golang/internal/guest/memory_test.go | 1 + languages/golang/internal/guest/status.go | 3 + languages/golang/stackauth/README.md | 3 +- languages/golang/stackauth/errors.go | 21 +- languages/golang/stackauth/guest.go | 17 +- languages/golang/stackauth/guest/src/abi.rs | 30 ++- languages/golang/stackauth/guest/src/auth.rs | 98 ++++++--- languages/golang/stackauth/guest/src/lib.rs | 7 +- languages/golang/stackauth/guest/src/ops.rs | 23 ++- .../golang/stackauth/guest/src/status.rs | 19 +- languages/golang/stackauth/store_test.go | 2 +- languages/golang/stackauth/strategy.go | 54 +++-- languages/golang/stackauth/strategy_test.go | 187 ++++++++++++++---- packages/stack-auth/Cargo.toml | 7 +- .../src/device_session_refresher.rs | 3 + packages/stack-auth/src/error.rs | 22 +-- packages/stack-auth/src/lib.rs | 1 - ...edential-guest-for-the-profile-and-auth.md | 5 +- packages/stack-guest-abi/src/status.rs | 7 +- 21 files changed, 389 insertions(+), 150 deletions(-) diff --git a/.github/imported-workflows/test-wasi.yml b/.github/imported-workflows/test-wasi.yml index 93697b753..3d6c211dd 100644 --- a/.github/imported-workflows/test-wasi.yml +++ b/.github/imported-workflows/test-wasi.yml @@ -139,6 +139,8 @@ jobs: # best effort, so its test skips where RLIMIT_MEMLOCK refuses it — # a developer laptop's default. CI raises the limit and sets this # so the skip is an error here and the lock is really exercised. + # Device refresh locking is separate: its Go tests cannot skip and + # must pass on Linux, macOS and Windows. STACKENCRYPT_TESTS_REQUIRE_LOCK: "1" run: | ulimit -l "$(ulimit -H -l)" @@ -149,6 +151,9 @@ jobs: # The credential guest's Go-side refresh lock has Unix and Windows # implementations. The shared go-binding-test.sh prints the device refresh # tests explicitly on each platform, alongside the rest of the Go suite. + # The device refresh lock must succeed on macOS and Windows too; the tests + # have no best-effort skip. STACKENCRYPT_TESTS_REQUIRE_LOCK stays Linux-only + # because it governs guest memory locking, not the refresh file lock. # They gate the way wasi-check does: a red run on the PR, not branch protection. # main's ruleset requires no status check today, and this workflow is path # filtered, so a required check here would never run on a PR outside the diff --git a/languages/golang/internal/guest/errors.go b/languages/golang/internal/guest/errors.go index f485f9aff..22be05305 100644 --- a/languages/golang/internal/guest/errors.go +++ b/languages/golang/internal/guest/errors.go @@ -64,14 +64,24 @@ var ( ErrInvalidWorkspaceID = errors.New("cipherstash: invalid workspace id") // ErrWorkspaceNotFound is a workspace with no local profile data: nothing // has logged in to it on this machine. - ErrWorkspaceNotFound = errors.New("cipherstash: workspace has no local profile; log in to it first") - ErrAuthInvalidGrant = errors.New("cipherstash: auth server rejected the refresh grant") - ErrAuthInvalidClient = errors.New("cipherstash: auth server rejected the client") - ErrAuthUsageLimit = errors.New("cipherstash: account usage limit exceeded") + ErrWorkspaceNotFound = errors.New("cipherstash: workspace has no local profile; log in to it first") + // ErrAuthInvalidGrant is an OAuth refresh grant the auth server rejected. + ErrAuthInvalidGrant = errors.New("cipherstash: auth server rejected the refresh grant") + // ErrAuthInvalidClient is a client credential the auth server rejected. + ErrAuthInvalidClient = errors.New("cipherstash: auth server rejected the client") + // ErrAuthUsageLimit is an account blocked by its usage allowance. + ErrAuthUsageLimit = errors.New("cipherstash: account usage limit exceeded") + // ErrAuthNotAuthenticated means no usable auth credential is available. ErrAuthNotAuthenticated = errors.New("cipherstash: no usable authentication credential") - ErrAuthTransport = errors.New("cipherstash: auth transport failed") - ErrAuthConfig = errors.New("cipherstash: invalid auth configuration or token") - ErrAuthOther = errors.New("cipherstash: authentication failed") + // ErrAuthTransport is a failed auth HTTP exchange or response read. + ErrAuthTransport = errors.New("cipherstash: auth transport failed") + // ErrAuthConfig is invalid auth configuration or token data. + ErrAuthConfig = errors.New("cipherstash: invalid auth configuration or token") + // ErrAuthOther is an auth failure outside the actionable categories above. + ErrAuthOther = errors.New("cipherstash: authentication failed") + // ErrAuthRefreshRequired tells the Go credential host to take the + // cross-process lock and call the device-session refresh export. + ErrAuthRefreshRequired = errors.New("cipherstash: device session needs refresh") // ErrMemoryLock is guest memory that could not be locked in RAM (or, // on Linux, excluded from core dumps). A constructor returns it when // asked for locked memory and refused, and so does any later call under diff --git a/languages/golang/internal/guest/memory_test.go b/languages/golang/internal/guest/memory_test.go index adf9fdb6f..c1d9b99e6 100644 --- a/languages/golang/internal/guest/memory_test.go +++ b/languages/golang/internal/guest/memory_test.go @@ -236,6 +236,7 @@ func TestStatusDecodesToTheSharedSentinels(t *testing.T) { guest.StatusAuthTransport: guest.ErrAuthTransport, guest.StatusAuthConfig: guest.ErrAuthConfig, guest.StatusAuthOther: guest.ErrAuthOther, + guest.StatusAuthRefreshRequired: guest.ErrAuthRefreshRequired, } for code, sentinel := range want { if got := guest.StatusError(code); got != sentinel { diff --git a/languages/golang/internal/guest/status.go b/languages/golang/internal/guest/status.go index e554a152e..7c05a9717 100644 --- a/languages/golang/internal/guest/status.go +++ b/languages/golang/internal/guest/status.go @@ -34,6 +34,7 @@ const ( StatusAuthTransport = 24 StatusAuthConfig = 25 StatusAuthOther = 26 + StatusAuthRefreshRequired = 27 ) // StatusError is the sentinel a guest status decodes to. A status this host @@ -93,6 +94,8 @@ func StatusError(status uint32) error { return ErrAuthConfig case StatusAuthOther: return ErrAuthOther + case StatusAuthRefreshRequired: + return ErrAuthRefreshRequired default: return fmt.Errorf("%w (unrecognized guest status %d)", ErrInternal, status) } diff --git a/languages/golang/stackauth/README.md b/languages/golang/stackauth/README.md index 8ae7b9dc5..3b2b7c0d8 100644 --- a/languages/golang/stackauth/README.md +++ b/languages/golang/stackauth/README.md @@ -86,7 +86,8 @@ profile that leads outside it is refused, for reads and for the one write, rather than followed with the process's permissions as a plain directory mount would. Files it creates are mode 0600. It takes no file lock (WASI preview 1 has none); Go holds the same lock as the CLI across the device -session call, on the path `ProfileStore.LockPath` names. The guest re-reads +session refresh call, on the path `ProfileStore.LockPath` names. A fresh +token is read without the lock; on refresh the guest re-reads auth.json after acquisition and saves refreshed tokens before release. The crypto guest behind `stackencrypt` is not widened by this package diff --git a/languages/golang/stackauth/errors.go b/languages/golang/stackauth/errors.go index 586041eef..30a914a21 100644 --- a/languages/golang/stackauth/errors.go +++ b/languages/golang/stackauth/errors.go @@ -37,14 +37,21 @@ var ( // ErrState is a call on a store that has been closed. ErrState = guest.ErrState // ErrInternal is a guest panic or any other unexpected guest failure. - ErrInternal = guest.ErrInternal - ErrInvalidGrant = guest.ErrAuthInvalidGrant - ErrInvalidClient = guest.ErrAuthInvalidClient - ErrUsageLimit = guest.ErrAuthUsageLimit + ErrInternal = guest.ErrInternal + // ErrInvalidGrant is an OAuth refresh grant the auth server rejected. + ErrInvalidGrant = guest.ErrAuthInvalidGrant + // ErrInvalidClient is a client credential the auth server rejected. + ErrInvalidClient = guest.ErrAuthInvalidClient + // ErrUsageLimit is an account blocked by its usage allowance. + ErrUsageLimit = guest.ErrAuthUsageLimit + // ErrNotAuthenticated means no usable auth credential is available. ErrNotAuthenticated = guest.ErrAuthNotAuthenticated - ErrAuthTransport = guest.ErrAuthTransport - ErrAuthConfig = guest.ErrAuthConfig - ErrAuthOther = guest.ErrAuthOther + // ErrAuthTransport is a failed auth HTTP exchange or response read. + ErrAuthTransport = guest.ErrAuthTransport + // ErrAuthConfig is invalid auth configuration or token data. + ErrAuthConfig = guest.ErrAuthConfig + // ErrAuthOther is an auth failure outside the actionable categories above. + ErrAuthOther = guest.ErrAuthOther // ErrMemoryLock is guest memory that could not be locked in RAM (or, on // Linux, excluded from core dumps). Open returns it under // [RequireLockedMemory]; otherwise [ProfileStore.MemoryLockError] diff --git a/languages/golang/stackauth/guest.go b/languages/golang/stackauth/guest.go index 874360f48..4710f792e 100644 --- a/languages/golang/stackauth/guest.go +++ b/languages/golang/stackauth/guest.go @@ -71,8 +71,8 @@ type instance struct { shutdown api.Function currentWorkspace, setCurrentWorkspace, clearCurrentWorkspace api.Function listWorkspaces, workspaceDir, lockPath api.Function - secretKey, token, deviceIdentity api.Function - authNew, authToken, authFree api.Function + secretKey, token, hasToken, deviceIdentity api.Function + authNew, authValidateCRN, authToken, authRefresh, authFree api.Function } // guestModuleConfig is the module configuration every guest instance runs @@ -152,9 +152,12 @@ func newInstance(ctx context.Context, wasm []byte, hostDir string, policy guest. "sa_lock_path": &inst.lockPath, "sa_secret_key": &inst.secretKey, "sa_token": &inst.token, + "sa_has_token": &inst.hasToken, "sa_device_identity": &inst.deviceIdentity, "sa_auth_new": &inst.authNew, + "sa_auth_validate_crn": &inst.authValidateCRN, "sa_auth_token": &inst.authToken, + "sa_auth_refresh": &inst.authRefresh, "sa_auth_free": &inst.authFree, } for name, slot := range exports { @@ -183,13 +186,3 @@ func (inst *instance) release() error { } return err } - -// call drives one export with string arguments, through the shared -// plumbing: staged, called, copied out, wiped. -func (inst *instance) call(ctx context.Context, fn api.Function, args ...string) ([]byte, error) { - staged := make([]guest.Arg, len(args)) - for i, a := range args { - staged[i] = guest.BufArg([]byte(a)) - } - return guest.Call(ctx, inst.mem, inst.module, inst.exports, fn, staged...) -} diff --git a/languages/golang/stackauth/guest/src/abi.rs b/languages/golang/stackauth/guest/src/abi.rs index 3986a8959..72a58f2d8 100644 --- a/languages/golang/stackauth/guest/src/abi.rs +++ b/languages/golang/stackauth/guest/src/abi.rs @@ -172,6 +172,14 @@ pub unsafe extern "C" fn sa_token(dir_ptr: *const u8, dir_len: u32) -> u64 { export1(dir_ptr, dir_len, ops::token) } +/// Whether this workspace has auth.json, without parsing its content. +/// # Safety +/// As for [`sa_current_workspace`]. +#[no_mangle] +pub unsafe extern "C" fn sa_has_token(dir_ptr: *const u8, dir_len: u32) -> u64 { + export1(dir_ptr, dir_len, ops::has_token) +} + /// `device.json` in the store at `dir`, read-only. See /// [`ops::device_identity`]. /// @@ -192,8 +200,17 @@ pub unsafe extern "C" fn sa_auth_new(ptr: *const u8, len: u32) -> u64 { export1(ptr, len, auth::create) } -/// Get a service token from a strategy. For a device session the Go host -/// must hold the workspace's auth.json lock for the whole call. +/// Validate a workspace CRN using the same Rust parser as AutoStrategy. +/// # Safety +/// The input pair is validated against guest linear memory. +#[no_mangle] +pub unsafe extern "C" fn sa_auth_validate_crn(ptr: *const u8, len: u32) -> u64 { + export1(ptr, len, auth::validate_crn) +} + +/// Get a service token from a strategy. A device session returns +/// `STATUS_AUTH_REFRESH_REQUIRED` when it enters the refresh window; +/// this export never refreshes a device session or needs its file lock. /// # Safety /// The input pair is validated against guest linear memory. #[no_mangle] @@ -201,6 +218,15 @@ pub unsafe extern "C" fn sa_auth_token(ptr: *const u8, len: u32) -> u64 { export1(ptr, len, auth::token) } +/// Refresh a device session. The Go host must hold the workspace's +/// auth.json lock across this whole call, including the disk re-read and save. +/// # Safety +/// The input pair is validated against guest linear memory. +#[no_mangle] +pub unsafe extern "C" fn sa_auth_refresh(ptr: *const u8, len: u32) -> u64 { + export1(ptr, len, auth::refresh) +} + /// Drop one strategy and its cached credential. /// # Safety /// The input pair is validated against guest linear memory. diff --git a/languages/golang/stackauth/guest/src/auth.rs b/languages/golang/stackauth/guest/src/auth.rs index 3a64c62d5..206c2dea9 100644 --- a/languages/golang/stackauth/guest/src/auth.rs +++ b/languages/golang/stackauth/guest/src/auth.rs @@ -10,12 +10,16 @@ use futures::executor::block_on; use serde::Deserialize; use stack_auth::{ AccessKey, AccessKeyStrategy, AuthStrategy, DeviceSessionStrategy, OidcFederationStrategy, + Token, }; use stack_profile::ProfileStore; use zeroize::Zeroizing; use crate::host::{HostOidcProvider, WasiAuthTransport}; -use crate::status::{status_for_auth, STATUS_ENCODING, STATUS_STATE}; +use crate::status::{ + status_for_auth, status_for_profile, STATUS_AUTH_REFRESH_REQUIRED, STATUS_ENCODING, + STATUS_STATE, +}; #[derive(Deserialize)] #[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)] @@ -39,9 +43,9 @@ enum Config { enum Strategy { AccessKey(AccessKeyStrategy), Oidc(OidcFederationStrategy<HostOidcProvider>), - // A device session is rebuilt only after Go has acquired the profile's - // cross-process lock. Keeping a cached Token here would defeat the - // post-lock re-read that prevents refresh-token replay. + // A device session is rebuilt for refresh only after Go has acquired the + // cross-process lock. A fresh token can be read without the lock; keeping + // a cached Token here would defeat the post-lock re-read on refresh. DeviceSession { workspace_dir: String, base_url: Option<url::Url>, @@ -53,12 +57,20 @@ thread_local! { static NEXT: Cell<u32> = const { Cell::new(1) }; } -fn url(value: Option<String>) -> Result<Option<url::Url>, u32> { +fn parse_base_url(value: Option<String>) -> Result<Option<url::Url>, u32> { value .map(|v| v.parse::<url::Url>().map_err(|_| STATUS_ENCODING)) .transpose() } +pub fn validate_crn(bytes: &[u8]) -> Result<Vec<u8>, u32> { + let text = std::str::from_utf8(bytes).map_err(|_| STATUS_ENCODING)?; + let _: Crn = text + .parse() + .map_err(|_| stack_guest_abi::status::STATUS_AUTH_CONFIG)?; + Ok(Vec::new()) +} + pub fn create(config: &[u8]) -> Result<Vec<u8>, u32> { let config: Config = serde_json::from_slice(config).map_err(|_| STATUS_ENCODING)?; let strategy = match config { @@ -71,7 +83,7 @@ pub fn create(config: &[u8]) -> Result<Vec<u8>, u32> { let crn: Crn = crn.parse().map_err(|_| STATUS_ENCODING)?; let key: AccessKey = access_key.parse().map_err(|_| STATUS_ENCODING)?; let mut builder = AccessKeyStrategy::builder(crn, key).transport(WasiAuthTransport); - if let Some(url) = url(base_url)? { + if let Some(url) = parse_base_url(base_url)? { builder = builder.base_url(url); } Strategy::AccessKey(builder.build().map_err(|e| status_for_auth(&e))?) @@ -84,7 +96,7 @@ pub fn create(config: &[u8]) -> Result<Vec<u8>, u32> { let crn: Crn = crn.parse().map_err(|_| STATUS_ENCODING)?; let mut builder = OidcFederationStrategy::builder(crn, HostOidcProvider(provider)) .transport(WasiAuthTransport); - if let Some(url) = url(base_url)? { + if let Some(url) = parse_base_url(base_url)? { builder = builder.base_url(url); } Strategy::Oidc(builder.build().map_err(|e| status_for_auth(&e))?) @@ -98,7 +110,7 @@ pub fn create(config: &[u8]) -> Result<Vec<u8>, u32> { } Strategy::DeviceSession { workspace_dir, - base_url: url(base_url)?, + base_url: parse_base_url(base_url)?, } } }; @@ -113,40 +125,76 @@ pub fn create(config: &[u8]) -> Result<Vec<u8>, u32> { Ok(id.to_string().into_bytes()) } -fn id(bytes: &[u8]) -> Result<u32, u32> { +fn parse_handle(bytes: &[u8]) -> Result<u32, u32> { let text = std::str::from_utf8(bytes).map_err(|_| STATUS_ENCODING)?; text.parse::<u32>().map_err(|_| STATUS_ENCODING) } pub fn token(handle: &[u8]) -> Result<Vec<u8>, u32> { - let id = id(handle)?; + let id = parse_handle(handle)?; STRATEGIES.with(|items| { let items = items.borrow(); let strategy = items.get(&id).ok_or(STATUS_STATE)?; - let token = match strategy { - Strategy::AccessKey(strategy) => block_on(strategy.get_token()), - Strategy::Oidc(strategy) => block_on(strategy.get_token()), + match strategy { + Strategy::AccessKey(strategy) => block_on(strategy.get_token()) + .map(|token| token.as_str().as_bytes().to_vec()) + .map_err(|e| status_for_auth(&e)), + Strategy::Oidc(strategy) => block_on(strategy.get_token()) + .map(|token| token.as_str().as_bytes().to_vec()) + .map_err(|e| status_for_auth(&e)), Strategy::DeviceSession { workspace_dir, base_url, - } => { - let store = ProfileStore::new(workspace_dir); - let mut builder = - DeviceSessionStrategy::with_workspace_store(store).transport(WasiAuthTransport); - if let Some(url) = base_url { - builder = builder.base_url(url.clone()); - } - let strategy = builder.build().map_err(|e| status_for_auth(&e))?; - block_on(strategy.get_token()) - } + } => cached_device_token(workspace_dir, base_url), + } + }) +} + +/// A fresh token needs only a profile read. The 90-second refresh window is +/// decided by stack-auth's Token, so Go does not duplicate that rule. +fn cached_device_token(workspace_dir: &str, base_url: &Option<url::Url>) -> Result<Vec<u8>, u32> { + let token: Token = ProfileStore::new(workspace_dir) + .load_profile() + .map_err(|e| status_for_profile(&e))?; + if token.region().is_none() || token.client_id().is_none() { + return Err(stack_guest_abi::status::STATUS_AUTH_NOT_AUTHENTICATED); + } + if base_url.is_none() { + let _ = token.issuer().map_err(|e| status_for_auth(&e))?; + } + if token.is_expired() { + return Err(STATUS_AUTH_REFRESH_REQUIRED); + } + Ok(token.access_token().as_str().as_bytes().to_vec()) +} + +/// Called only after Go acquires the profile lock. Building from the store +/// here re-reads a token another process may have rotated while Go waited. +pub fn refresh(handle: &[u8]) -> Result<Vec<u8>, u32> { + let id = parse_handle(handle)?; + STRATEGIES.with(|items| { + let items = items.borrow(); + let Strategy::DeviceSession { + workspace_dir, + base_url, + } = items.get(&id).ok_or(STATUS_STATE)? + else { + return Err(STATUS_STATE); + }; + let store = ProfileStore::new(workspace_dir); + let mut builder = + DeviceSessionStrategy::with_workspace_store(store).transport(WasiAuthTransport); + if let Some(url) = base_url { + builder = builder.base_url(url.clone()); } - .map_err(|e| status_for_auth(&e))?; + let strategy = builder.build().map_err(|e| status_for_auth(&e))?; + let token = block_on(strategy.get_token()).map_err(|e| status_for_auth(&e))?; Ok(token.as_str().as_bytes().to_vec()) }) } pub fn free(handle: &[u8]) -> Result<Vec<u8>, u32> { - let id = id(handle)?; + let id = parse_handle(handle)?; STRATEGIES.with(|items| { let _removed = items.borrow_mut().remove(&id).ok_or(STATUS_STATE)?; Ok(Vec::new()) diff --git a/languages/golang/stackauth/guest/src/lib.rs b/languages/golang/stackauth/guest/src/lib.rs index 54e9f462e..1ea90fe0f 100644 --- a/languages/golang/stackauth/guest/src/lib.rs +++ b/languages/golang/stackauth/guest/src/lib.rs @@ -72,9 +72,10 @@ //! WASI preview 1 has no file locking, so this module takes none. The Go //! side takes the same lock the Rust CLI takes, on the path //! [`ProfileStore::lock_path`](stack_profile::ProfileStore::lock_path) -//! names (exported here), around the entire device-session token call. -//! The strategy reads auth.json only after that lock is held and saves a -//! rotated token before the call returns. Go never composes a profile path. +//! names (exported here), around the entire device-session refresh export. +//! A fresh token is read without that lock. The refresh export re-reads +//! auth.json after acquisition and saves a rotated token before returning. +//! Go never composes a profile path. //! //! # Not faked //! diff --git a/languages/golang/stackauth/guest/src/ops.rs b/languages/golang/stackauth/guest/src/ops.rs index c7a388349..6e5ea6388 100644 --- a/languages/golang/stackauth/guest/src/ops.rs +++ b/languages/golang/stackauth/guest/src/ops.rs @@ -55,10 +55,8 @@ struct SecretKeyFile { /// to it by the Go tests that write the file where `stash auth login` does. const SECRET_KEY_FILENAME: &str = "secretkey.json"; -/// The filename `stack-auth`'s `Token` declares. Its `ProfileData` impl is -/// native-only (the crate's `stack-profile` dependency is, for the sake of -/// its browser build), so the name is spelled here and pinned to the -/// crate's by a native test. +/// The filename `stack-auth`'s `Token` declares. Kept here for the profile +/// read export, with a native test checking the crate's own name. const AUTH_FILENAME: &str = "auth.json"; /// The store at `dir`. The directory is the caller's to name; an empty or @@ -202,6 +200,13 @@ pub fn token(dir: &[u8]) -> Result<Vec<u8>, u32> { ])) } +/// Whether auth.json exists, without parsing it. AutoStrategy makes this +/// selection before building a device strategy, so malformed JSON must still +/// select the device path and then report its profile error. +pub fn has_token(dir: &[u8]) -> Result<Vec<u8>, u32> { + Ok(vec![u8::from(store(dir)?.exists_profile::<Token>())]) +} + /// `device.json` in this store, read-only, as a codec object /// `{device_instance_id, device_name}`. Creating one is the CLI's. pub fn device_identity(dir: &[u8]) -> Result<Vec<u8>, u32> { @@ -257,6 +262,16 @@ mod tests { b.risky_ref().to_vec() } + #[test] + fn has_token_selects_existing_profile_without_parsing_it() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join(AUTH_FILENAME); + let store_dir = dir.path().to_str().unwrap().as_bytes(); + assert_eq!(has_token(store_dir).unwrap(), vec![0]); + std::fs::write(path, "{").unwrap(); + assert_eq!(has_token(store_dir).unwrap(), vec![1]); + } + /// The names this guest spells are the crates' own. #[test] fn spelled_filenames_are_the_crates() { diff --git a/languages/golang/stackauth/guest/src/status.rs b/languages/golang/stackauth/guest/src/status.rs index 17d3c220f..847adf02d 100644 --- a/languages/golang/stackauth/guest/src/status.rs +++ b/languages/golang/stackauth/guest/src/status.rs @@ -11,9 +11,9 @@ use stack_auth::AuthError; use stack_profile::ProfileError; pub use stack_guest_abi::status::{ - STATUS_ENCODING, STATUS_INTERNAL, STATUS_PROFILE_INVALID_FILENAME, - STATUS_PROFILE_INVALID_WORKSPACE_ID, STATUS_PROFILE_IO, STATUS_PROFILE_JSON, - STATUS_PROFILE_NOT_FOUND, STATUS_PROFILE_NO_CURRENT_WORKSPACE, + STATUS_AUTH_REFRESH_REQUIRED, STATUS_ENCODING, STATUS_INTERNAL, + STATUS_PROFILE_INVALID_FILENAME, STATUS_PROFILE_INVALID_WORKSPACE_ID, STATUS_PROFILE_IO, + STATUS_PROFILE_JSON, STATUS_PROFILE_NOT_FOUND, STATUS_PROFILE_NO_CURRENT_WORKSPACE, STATUS_PROFILE_WORKSPACE_NOT_FOUND, STATUS_STATE, }; @@ -21,6 +21,9 @@ pub use stack_guest_abi::status::{ /// bytes or parsing a server message across the ABI. pub fn status_for_auth(error: &AuthError) -> u32 { use stack_guest_abi::status::*; + if let AuthError::Store(store_error) = error { + return status_for_profile(&store_error.0); + } match error.error_code() { "INVALID_GRANT" => STATUS_AUTH_INVALID_GRANT, "INVALID_CLIENT" => STATUS_AUTH_INVALID_CLIENT, @@ -108,4 +111,14 @@ mod tests { } } } + + #[test] + fn auth_store_errors_keep_their_profile_status() { + let missing = AuthError::from(ProfileError::NotFound { + path: "auth.json".into(), + }); + assert_eq!(status_for_auth(&missing), STATUS_PROFILE_NOT_FOUND); + let io = AuthError::from(ProfileError::Io(std::io::Error::other("disk full"))); + assert_eq!(status_for_auth(&io), STATUS_PROFILE_IO); + } } diff --git a/languages/golang/stackauth/store_test.go b/languages/golang/stackauth/store_test.go index f4beaae2b..552a08a66 100644 --- a/languages/golang/stackauth/store_test.go +++ b/languages/golang/stackauth/store_test.go @@ -111,7 +111,7 @@ func TestImportSurfaceIsWASIAndAuthTransport(t *testing.T) { if !sawTransport || !sawOIDC { t.Errorf("missing auth imports: transport=%t oidc=%t", sawTransport, sawOIDC) } - for _, name := range []string{"se_alloc", "se_dealloc", "sa_shutdown", "sa_current_workspace", "sa_set_current_workspace", "sa_clear_current_workspace", "sa_list_workspaces", "sa_workspace_dir", "sa_lock_path", "sa_secret_key", "sa_token", "sa_device_identity", "sa_auth_new", "sa_auth_token", "sa_auth_free"} { + for _, name := range []string{"se_alloc", "se_dealloc", "sa_shutdown", "sa_current_workspace", "sa_set_current_workspace", "sa_clear_current_workspace", "sa_list_workspaces", "sa_workspace_dir", "sa_lock_path", "sa_secret_key", "sa_token", "sa_has_token", "sa_device_identity", "sa_auth_new", "sa_auth_validate_crn", "sa_auth_token", "sa_auth_refresh", "sa_auth_free"} { if _, ok := compiled.ExportedFunctions()[name]; !ok { t.Errorf("guest does not export %s", name) } diff --git a/languages/golang/stackauth/strategy.go b/languages/golang/stackauth/strategy.go index 5618d8bcd..82a9a8c74 100644 --- a/languages/golang/stackauth/strategy.go +++ b/languages/golang/stackauth/strategy.go @@ -100,9 +100,9 @@ func (s *ProfileStore) OIDC(ctx context.Context, crn string, provider OIDCProvid return strategy, nil } -// DeviceSession uses this workspace store's auth.json. Its Token call takes -// the same cross-process lock as the Rust CLI, then the guest re-reads the -// token, exchanges only if necessary, and saves before the lock is released. +// DeviceSession uses this workspace store's auth.json. Fresh tokens are read +// without a lock. A refresh takes the same cross-process lock as the Rust +// CLI, then the guest re-reads and saves before the lock is released. func (s *ProfileStore) DeviceSession(ctx context.Context, opts ...StrategyOption) (*Strategy, error) { if s.dir == guestRoot { return nil, ErrAuthConfig @@ -118,9 +118,15 @@ func (s *ProfileStore) DeviceSession(ctx context.Context, opts ...StrategyOption // Auto follows stack-auth's detection order against the Go host's // environment: access key first, then the current workspace's device token. func (s *ProfileStore) Auto(ctx context.Context, opts ...StrategyOption) (*Strategy, error) { - if key := os.Getenv("CS_CLIENT_ACCESS_KEY"); key != "" { - crn := os.Getenv("CS_WORKSPACE_CRN") - if crn == "" { + crn, crnSet := os.LookupEnv("CS_WORKSPACE_CRN") + if crnSet { + _, err := s.callArgs(ctx, func(i *instance) api.Function { return i.authValidateCRN }, guest.BufArg([]byte(crn))) + if err != nil { + return nil, err + } + } + if key, keySet := os.LookupEnv("CS_CLIENT_ACCESS_KEY"); keySet { + if !crnSet { return nil, ErrAuthConfig } return s.AccessKey(ctx, crn, key, opts...) @@ -132,41 +138,53 @@ func (s *ProfileStore) Auto(ctx context.Context, opts ...StrategyOption) (*Strat } return nil, err } - if _, err := workspace.Token(ctx); err != nil { - if errors.Is(err, ErrNotFound) { - return nil, ErrNotAuthenticated - } + hasToken, err := workspace.hasToken(ctx) + if err != nil { return nil, err } + if !hasToken { + return nil, ErrNotAuthenticated + } return workspace.DeviceSession(ctx, opts...) } -// Token gets the current CTS bearer credential. For a device session the -// host holds the refresh lock over the entire guest call. +func (s *ProfileStore) hasToken(ctx context.Context) (bool, error) { + out, err := s.call(ctx, func(i *instance) api.Function { return i.hasToken }) + if err != nil { + return false, err + } + if len(out) != 1 || out[0] > 1 { + return false, fmt.Errorf("%w: invalid has-token response", ErrInternal) + } + return out[0] == 1, nil +} + +// Token gets the current CTS bearer credential. Device sessions read a fresh +// token without a file lock; only a refresh call takes the cross-process lock. func (s *Strategy) Token(ctx context.Context) (string, error) { s.mu.Lock() defer s.mu.Unlock() if s.closed { return "", ErrState } - call := func() (string, error) { - out, err := s.store.callArgs(ctx, func(i *instance) api.Function { return i.authToken }, guest.BufArg([]byte(s.handle))) + call := func(export func(*instance) api.Function) (string, error) { + out, err := s.store.callArgs(ctx, export, guest.BufArg([]byte(s.handle))) if err != nil { return "", err } return string(out), nil } - if !s.device { - return call() + token, err := call(func(i *instance) api.Function { return i.authToken }) + if !s.device || !errors.Is(err, guest.ErrAuthRefreshRequired) { + return token, err } path, err := s.store.LockPath(ctx, "auth.json") if err != nil { return "", err } - var token string err = withRefreshLock(ctx, path, func() error { var err error - token, err = call() + token, err = call(func(i *instance) api.Function { return i.authRefresh }) return err }) return token, err diff --git a/languages/golang/stackauth/strategy_test.go b/languages/golang/stackauth/strategy_test.go index 2ceadc3d1..2533a5775 100644 --- a/languages/golang/stackauth/strategy_test.go +++ b/languages/golang/stackauth/strategy_test.go @@ -8,8 +8,10 @@ import ( "fmt" "net/http" "net/http/httptest" + "net/url" "os" "path/filepath" + "reflect" "sync" "sync/atomic" "testing" @@ -43,8 +45,8 @@ func TestAccessKeyStrategyCachesAndPreservesRequest(t *testing.T) { if err := json.NewDecoder(r.Body).Decode(&body); err != nil { t.Error(err) } - if body["accessKey"] != "CSAKtestKeyId.testKeySecret" { - t.Errorf("body: %#v", body) + if want := (map[string]any{"accessKey": "CSAKtestKeyId.testKeySecret"}); !reflect.DeepEqual(body, want) { + t.Errorf("request body = %#v, want %#v", body, want) } fmt.Fprintf(w, `{"accessToken":%q,"expiry":%d}`, jwt, time.Now().Add(time.Hour).Unix()) })) @@ -79,8 +81,8 @@ func TestOIDCStrategyCallsProviderOnlyOnExchange(t *testing.T) { if err := json.NewDecoder(r.Body).Decode(&body); err != nil { t.Error(err) } - if body["oidcToken"] != "idp-token" || body["workspaceId"] != "ZVATKW3VHMFG27DY" { - t.Errorf("body: %#v", body) + if want := (map[string]any{"oidcToken": "idp-token", "workspaceId": "ZVATKW3VHMFG27DY"}); !reflect.DeepEqual(body, want) { + t.Errorf("request body = %#v, want %#v", body, want) } fmt.Fprintf(w, `{"accessToken":%q,"expiry":%d}`, testJWT(t, "https://cts.example"), time.Now().Add(time.Hour).Unix()) })) @@ -108,41 +110,30 @@ func TestOIDCStrategyCallsProviderOnlyOnExchange(t *testing.T) { } } -func TestAuthErrorTaxonomy(t *testing.T) { +func TestUsageLimitIsPreservedAcrossGuest(t *testing.T) { guestOrSkip(t) - for _, tc := range []struct { - name string - status int - body string - want error - }{ - {"usage limit", 402, `{"cs_code":"USAGE_LIMIT_EXCEEDED"}`, ErrUsageLimit}, - } { - t.Run(tc.name, func(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(tc.status) - fmt.Fprint(w, tc.body) - })) - defer server.Close() - profile, err := Open(context.Background(), t.TempDir()) - if err != nil { - t.Fatal(err) - } - defer profile.Close() - strategy, err := profile.AccessKey(context.Background(), testCRN, "CSAKtestKeyId.testKeySecret", WithAuthBaseURL(server.URL)) - if err != nil { - t.Fatal(err) - } - defer strategy.Close() - _, err = strategy.Token(context.Background()) - if !errors.Is(err, tc.want) { - t.Fatalf("Token error = %v, want %v", err, tc.want) - } - }) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusPaymentRequired) + fmt.Fprint(w, `{"cs_code":"USAGE_LIMIT_EXCEEDED"}`) + })) + defer server.Close() + profile, err := Open(context.Background(), t.TempDir()) + if err != nil { + t.Fatal(err) + } + defer profile.Close() + strategy, err := profile.AccessKey(context.Background(), testCRN, "CSAKtestKeyId.testKeySecret", WithAuthBaseURL(server.URL)) + if err != nil { + t.Fatal(err) + } + defer strategy.Close() + _, err = strategy.Token(context.Background()) + if !errors.Is(err, ErrUsageLimit) { + t.Fatalf("Token error = %v, want %v", err, ErrUsageLimit) } } -func TestDeviceRefreshInvalidClient(t *testing.T) { +func TestDeviceRefreshReportsInvalidClient(t *testing.T) { guestOrSkip(t) dir, _ := expiredDeviceProfile(t) server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { @@ -172,7 +163,7 @@ func TestDeviceRefreshInvalidClient(t *testing.T) { // Match stack-auth's AutoStrategy order: an access key wins over a stored // device session; with no key, the current workspace's auth.json is used. -func TestAutoStrategyDetectionOrder(t *testing.T) { +func TestAutoPrefersAccessKeyThenDeviceSession(t *testing.T) { guestOrSkip(t) dir, _ := expiredDeviceProfile(t) var accessCalls, refreshCalls atomic.Int32 @@ -214,7 +205,9 @@ func TestAutoStrategyDetectionOrder(t *testing.T) { if accessCalls.Load() != 1 || refreshCalls.Load() != 0 { t.Fatalf("access key should win: access=%d refresh=%d", accessCalls.Load(), refreshCalls.Load()) } - t.Setenv("CS_CLIENT_ACCESS_KEY", "") + if err := os.Unsetenv("CS_CLIENT_ACCESS_KEY"); err != nil { + t.Fatal(err) + } strategy, err = profile.Auto(context.Background(), WithAuthBaseURL(server.URL)) if err != nil { t.Fatal(err) @@ -247,6 +240,119 @@ func expiredDeviceProfile(t *testing.T) (string, string) { return dir, workspaceDir } +func TestDeviceSessionFreshTokenDoesNotTakeRefreshLock(t *testing.T) { + guestOrSkip(t) + dir, workspaceDir := expiredDeviceProfile(t) + write(t, filepath.Join(workspaceDir, "auth.json"), fmt.Sprintf(`{"access_token":"fresh","refresh_token":"refresh-1","token_type":"Bearer","expires_at":%d,"region":"ap-southeast-2.aws","client_id":"client-1"}`, time.Now().Add(time.Hour).Unix())) + profile, err := Open(context.Background(), dir) + if err != nil { + t.Fatal(err) + } + defer profile.Close() + workspace, err := profile.WorkspaceStore(context.Background(), wsA) + if err != nil { + t.Fatal(err) + } + strategy, err := workspace.DeviceSession(context.Background(), WithAuthBaseURL("https://cts.example.com")) + if err != nil { + t.Fatal(err) + } + defer strategy.Close() + path, err := workspace.LockPath(context.Background(), "auth.json") + if err != nil { + t.Fatal(err) + } + err = withRefreshLock(context.Background(), path, func() error { + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + token, err := strategy.Token(ctx) + if err != nil || token != "fresh" { + return fmt.Errorf("fresh token while lock is held = %q, %v", token, err) + } + return nil + }) + if err != nil { + t.Fatal(err) + } +} + +func TestDeviceSessionMissingAndInvalidProfilesKeepTheirErrors(t *testing.T) { + guestOrSkip(t) + for _, tc := range []struct { + name string + body string + want error + }{ + {"missing", "", ErrNotFound}, + {"invalid JSON", "{", ErrInvalid}, + } { + t.Run(tc.name, func(t *testing.T) { + dir := t.TempDir() + workspaceDir := filepath.Join(dir, "workspaces", wsA) + if err := os.MkdirAll(workspaceDir, 0o700); err != nil { + t.Fatal(err) + } + if tc.body != "" { + write(t, filepath.Join(workspaceDir, "auth.json"), tc.body) + } + profile, err := Open(context.Background(), dir) + if err != nil { + t.Fatal(err) + } + defer profile.Close() + workspace, err := profile.WorkspaceStore(context.Background(), wsA) + if err != nil { + t.Fatal(err) + } + strategy, err := workspace.DeviceSession(context.Background(), WithAuthBaseURL("https://cts.example.com")) + if err != nil { + t.Fatal(err) + } + defer strategy.Close() + if _, err := strategy.Token(context.Background()); !errors.Is(err, tc.want) { + t.Fatalf("Token error = %v, want %v", err, tc.want) + } + }) + } +} + +func TestAutoUsesEnvironmentPresenceAndProfileExistence(t *testing.T) { + guestOrSkip(t) + dir, workspaceDir := expiredDeviceProfile(t) + profile, err := Open(context.Background(), dir) + if err != nil { + t.Fatal(err) + } + defer profile.Close() + if err := profile.SetCurrentWorkspace(context.Background(), wsA); err != nil { + t.Fatal(err) + } + t.Setenv("CS_WORKSPACE_CRN", testCRN) + t.Setenv("CS_CLIENT_ACCESS_KEY", "") + if _, err := profile.Auto(context.Background()); !errors.Is(err, ErrAuthConfig) { + t.Fatalf("set but empty access key: error = %v, want %v", err, ErrAuthConfig) + } + if err := os.Unsetenv("CS_CLIENT_ACCESS_KEY"); err != nil { + t.Fatal(err) + } + t.Setenv("CS_WORKSPACE_CRN", "invalid") + if _, err := profile.Auto(context.Background()); !errors.Is(err, ErrAuthConfig) { + t.Fatalf("invalid CRN without key: error = %v, want %v", err, ErrAuthConfig) + } + if err := os.Unsetenv("CS_WORKSPACE_CRN"); err != nil { + t.Fatal(err) + } + write(t, filepath.Join(workspaceDir, "auth.json"), "{") + strategy, err := profile.Auto(context.Background()) + if err != nil { + t.Fatalf("existing but invalid profile must select device strategy: %v", err) + } + defer strategy.Close() + if _, err := strategy.Token(context.Background()); !errors.Is(err, ErrInvalid) { + t.Fatalf("invalid profile: error = %v, want %v", err, ErrInvalid) + } +} + func TestDeviceRefreshLockPreventsReplay(t *testing.T) { guestOrSkip(t) dir, _ := expiredDeviceProfile(t) @@ -259,8 +365,9 @@ func TestDeviceRefreshLockPreventsReplay(t *testing.T) { if err := r.ParseForm(); err != nil { t.Error(err) } - if r.Form.Get("grant_type") != "refresh_token" || r.Form.Get("refresh_token") != "refresh-1" || r.Form.Get("client_id") != "client-1" { - t.Errorf("form: %v", r.Form) + want := url.Values{"grant_type": {"refresh_token"}, "refresh_token": {"refresh-1"}, "client_id": {"client-1"}} + if !reflect.DeepEqual(r.PostForm, want) { + t.Errorf("request body = %v, want %v", r.PostForm, want) } time.Sleep(50 * time.Millisecond) fmt.Fprint(w, `{"access_token":"fresh","token_type":"Bearer","expires_in":3600,"refresh_token":"refresh-2"}`) @@ -319,7 +426,7 @@ func TestDeviceRefreshLockPreventsReplay(t *testing.T) { } } -func TestDeviceRefreshInvalidGrant(t *testing.T) { +func TestDeviceRefreshReportsInvalidGrant(t *testing.T) { guestOrSkip(t) dir, _ := expiredDeviceProfile(t) server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { diff --git a/packages/stack-auth/Cargo.toml b/packages/stack-auth/Cargo.toml index dbe47de08..e004b61c7 100644 --- a/packages/stack-auth/Cargo.toml +++ b/packages/stack-auth/Cargo.toml @@ -39,8 +39,9 @@ web-time = { workspace = true } zerokms-protocol = { workspace = true } zeroize = { workspace = true } +# `stack-profile` is shared above: the WASI credential guest reads and saves +# device-session tokens through it, while the native CLI holds its own lock. # Native-only: -# - `stack-profile` is the filesystem-backed token store # - `open` launches a browser for device-code auth # - `jsonwebtoken` pulls `ring`, which doesn't compile on wasm32; it is now used # only by native tests (to mint fixture JWTs). Claim decoding itself no longer @@ -50,8 +51,8 @@ zeroize = { workspace = true } # subtract — split target-conditionally instead. # # JWT claim decoding uses a manual base64+JSON path (`decode_jwt_payload`) on -# every target — `base64` above is shared. Wasm consumers use -# `DeviceSessionStrategy::with_token` (in-memory) or `AccessKeyStrategy`. +# every target — `base64` above is shared. Wasm consumers can also use +# `DeviceSessionStrategy::with_workspace_store` when their host holds the lock. [target.'cfg(not(target_arch = "wasm32"))'.dependencies] open = "5.3.2" jsonwebtoken = { workspace = true } diff --git a/packages/stack-auth/src/device_session_refresher.rs b/packages/stack-auth/src/device_session_refresher.rs index 0b1c74dd2..a5be86a2f 100644 --- a/packages/stack-auth/src/device_session_refresher.rs +++ b/packages/stack-auth/src/device_session_refresher.rs @@ -76,6 +76,9 @@ impl Refresher for DeviceSessionRefresher { // its own refresh. #[cfg(not(target_arch = "wasm32"))] let _lock = self.acquire_refresh_lock().await?; + // On wasm32 this is the host's responsibility: the Go credential + // binding holds the sibling auth.json lock across its refresh export. + // WASI preview 1 has no file-lock operation for this arm to call. // After acquiring the lock, the disk may already hold a fresher // token that another process just rotated to. Burn our (now-stale) diff --git a/packages/stack-auth/src/error.rs b/packages/stack-auth/src/error.rs index fcf6dd33c..d6acaf824 100644 --- a/packages/stack-auth/src/error.rs +++ b/packages/stack-auth/src/error.rs @@ -67,7 +67,6 @@ pub(crate) mod codes { pub(crate) const ALREADY_CONSUMED: &str = "ALREADY_CONSUMED"; pub(crate) const INTERNAL_ERROR: &str = "INTERNAL_ERROR"; pub(crate) const CUSTOM: &str = "CUSTOM"; - #[cfg(not(target_arch = "wasm32"))] pub(crate) const STORE_ERROR: &str = "STORE_ERROR"; } @@ -376,11 +375,9 @@ impl AuthErrorKind for CustomError { } /// A token store operation failed. -#[cfg(not(target_arch = "wasm32"))] #[derive(Debug, thiserror::Error, miette::Diagnostic)] #[error("Token store error: {0}")] pub struct StoreError(pub stack_profile::ProfileError); -#[cfg(not(target_arch = "wasm32"))] impl AuthErrorKind for StoreError { fn error_code(&self) -> &'static str { codes::STORE_ERROR @@ -455,7 +452,6 @@ pub enum AuthError { #[error(transparent)] #[diagnostic(transparent)] Custom(#[from] CustomError), - #[cfg(not(target_arch = "wasm32"))] #[error(transparent)] #[diagnostic(transparent)] Store(#[from] StoreError), @@ -496,7 +492,6 @@ impl AuthError { | AuthError::Server(_) | AuthError::Internal(_) | AuthError::Custom(_) => false, - #[cfg(not(target_arch = "wasm32"))] AuthError::Store(_) => false, } } @@ -529,8 +524,6 @@ impl AuthError { codes::ALREADY_CONSUMED, codes::INTERNAL_ERROR, codes::CUSTOM, - // `Store` (and its code) only exists off-wasm — see the enum above. - #[cfg(not(target_arch = "wasm32"))] codes::STORE_ERROR, ]; @@ -557,7 +550,6 @@ impl AuthError { Self::AlreadyConsumed(e) => e, Self::Internal(e) => e, Self::Custom(e) => e, - #[cfg(not(target_arch = "wasm32"))] Self::Store(e) => e, } } @@ -593,7 +585,6 @@ impl AuthError { // whose cause we cannot classify. Self::Custom(_) => true, // Local persistence (cookie, KV, keychain) can fail transiently. - #[cfg(not(target_arch = "wasm32"))] Self::Store(_) => true, // Settled answers. Retrying re-asks a question already answered. @@ -653,7 +644,6 @@ impl AuthError { | Self::AlreadyConsumed(_) | Self::Internal(_) | Self::Custom(_) => false, - #[cfg(not(target_arch = "wasm32"))] Self::Store(_) => false, } } @@ -923,16 +913,7 @@ impl From<access_key::InvalidAccessKey> for AuthError { impl From<stack_profile::ProfileError> for AuthError { fn from(e: stack_profile::ProfileError) -> Self { - #[cfg(not(target_arch = "wasm32"))] - { - Self::Store(StoreError(e)) - } - // On wasm32 the auth guest still loads and saves profiles, but the - // native Store variant is not part of that target's public enum. - #[cfg(target_arch = "wasm32")] - { - Self::Custom(CustomError(e.to_string())) - } + Self::Store(StoreError(e)) } } @@ -1388,7 +1369,6 @@ mod tests { use super::*; #[test] - #[cfg(not(target_arch = "wasm32"))] fn profile_error_retains_store_type() { let err = AuthError::from(stack_profile::ProfileError::NotFound { path: "auth.json".into(), diff --git a/packages/stack-auth/src/lib.rs b/packages/stack-auth/src/lib.rs index 9544f81c8..b02998310 100644 --- a/packages/stack-auth/src/lib.rs +++ b/packages/stack-auth/src/lib.rs @@ -76,7 +76,6 @@ mod oidc_federation_strategy; mod oidc_refresher; mod refresher; -#[cfg(not(target_arch = "wasm32"))] pub use error::StoreError; pub use error::{ AccessDenied, AlreadyConsumed, AuthError, AuthErrorKind, CustomError, InternalError, diff --git a/packages/stack-encrypt/docs/adr/0005-a-separate-credential-guest-for-the-profile-and-auth.md b/packages/stack-encrypt/docs/adr/0005-a-separate-credential-guest-for-the-profile-and-auth.md index 3a9dca715..67839c4f2 100644 --- a/packages/stack-encrypt/docs/adr/0005-a-separate-credential-guest-for-the-profile-and-auth.md +++ b/packages/stack-encrypt/docs/adr/0005-a-separate-credential-guest-for-the-profile-and-auth.md @@ -179,7 +179,10 @@ inside the guest: access key, device session, OIDC federation with a Go callback for the identity-provider token, and auto. `AutoStrategy`'s detection order runs in Go against the environment Go already owns, pinned against the Rust order by a test; the guest stays -environment-free and exposes typed constructors. +environment-free. The Go package exposes typed constructors; one tagged +config crosses the guest ABI and Rust validates its variant before creating +the corresponding strategy. This keeps the public API typed without adding +separate pointer and length signatures for each strategy export. The token exchanges are tested against an in-process `httptest` server, since HTTP goes through the Go host: exact request bodies, the error diff --git a/packages/stack-guest-abi/src/status.rs b/packages/stack-guest-abi/src/status.rs index 99d675463..a3b431b33 100644 --- a/packages/stack-guest-abi/src/status.rs +++ b/packages/stack-guest-abi/src/status.rs @@ -108,11 +108,15 @@ pub const STATUS_AUTH_NOT_AUTHENTICATED: u32 = 23; pub const STATUS_AUTH_TRANSPORT: u32 = 24; pub const STATUS_AUTH_CONFIG: u32 = 25; pub const STATUS_AUTH_OTHER: u32 = 26; +/// The device-session token is inside its refresh window. The Go host must +/// acquire the profile lock before calling the credential guest's refresh +/// export; this is a control-flow signal, not a caller-facing auth failure. +pub const STATUS_AUTH_REFRESH_REQUIRED: u32 = 27; /// The last code in the table. A guest appending a code of its own starts /// at `LAST_STATUS + 1` and moves this constant with it, so two guests can /// never claim one number. -pub const LAST_STATUS: u32 = STATUS_AUTH_OTHER; +pub const LAST_STATUS: u32 = STATUS_AUTH_REFRESH_REQUIRED; #[cfg(test)] mod tests { @@ -149,6 +153,7 @@ mod tests { STATUS_AUTH_TRANSPORT, STATUS_AUTH_CONFIG, STATUS_AUTH_OTHER, + STATUS_AUTH_REFRESH_REQUIRED, ]; for (i, code) in codes.iter().enumerate() { assert_eq!(*code, i as u32 + 1, "code {i} is out of sequence"); From 25841009b76a2390def16009e912beece75810cf Mon Sep 17 00:00:00 2001 From: Dan Draper <dan@cipherstash.com> Date: Sun, 27 Sep 2026 21:45:47 -0700 Subject: [PATCH 638/686] fix(stackauth): never replay a spent refresh token after a failed save When the upstream exchange succeeds but auth.json cannot be written, the refresh token has been consumed. AutoRefresh used to restore it and, on the non-blocking path, report success, so the next call replayed it and risked revoking the chain. A store error from `refresh` now drops the credential and fails the call on both paths. Also wipe the Go strategy's token buffer after copying it out. Co-Authored-By: Claude <noreply@anthropic.com> --- languages/golang/stackauth/strategy.go | 1 + packages/stack-auth/src/auto_refresh.rs | 129 +++++++++++++++++++++--- packages/stack-auth/src/refresher.rs | 10 ++ 3 files changed, 127 insertions(+), 13 deletions(-) diff --git a/languages/golang/stackauth/strategy.go b/languages/golang/stackauth/strategy.go index 82a9a8c74..fbb5e434a 100644 --- a/languages/golang/stackauth/strategy.go +++ b/languages/golang/stackauth/strategy.go @@ -172,6 +172,7 @@ func (s *Strategy) Token(ctx context.Context) (string, error) { if err != nil { return "", err } + defer guest.Wipe(out) return string(out), nil } token, err := call(func(i *instance) api.Function { return i.authToken }) diff --git a/packages/stack-auth/src/auto_refresh.rs b/packages/stack-auth/src/auto_refresh.rs index 916492663..91a04a2d8 100644 --- a/packages/stack-auth/src/auto_refresh.rs +++ b/packages/stack-auth/src/auto_refresh.rs @@ -128,6 +128,17 @@ impl StickyDenial { } } +/// Whether a failed [`Refresher::refresh`] spent its credential. +/// +/// A store error means the upstream exchange succeeded and only persisting +/// the result failed (see [`Refresher::refresh`]). The credential has been +/// used, so restoring it would replay it on the next call; for a rotating +/// refresh token that replay trips the issuer's reuse detection and revokes +/// the whole chain. +fn credential_consumed(err: &crate::AuthError) -> bool { + matches!(err, crate::AuthError::Store(_)) +} + /// Ensures [`AutoRefresh::refresh_in_progress`] is cleared and waiters are /// notified if the refresh future is cancelled (dropped) before completing. /// @@ -509,15 +520,21 @@ impl<R: Refresher, S: TokenStore> AutoRefresh<R, S> { defused: false, }; - match self.refresher.refresh(&credential).await { + let result = match self.refresher.refresh(&credential).await { Ok(new_token) => { self.save_refreshed_token(&new_token).await; let mut state = self.state.lock().await; let _ = self.install_refreshed_token(&mut state, new_token); guard.defuse(); + Ok(current_service_token) } Err(err) => { - tracing::warn!(%err, "token refresh failed (token still usable)"); + let consumed = credential_consumed(&err); + if consumed { + tracing::error!(%err, "refreshed token could not be persisted"); + } else { + tracing::warn!(%err, "token refresh failed (token still usable)"); + } // Defer `defuse()` until after the lock acquire so the // CancelGuard's Drop still fires if cancellation lands on // `state.lock().await`. Without this the in-progress flag @@ -525,22 +542,32 @@ impl<R: Refresher, S: TokenStore> AutoRefresh<R, S> { // subsequent caller exactly like the Ok-path bug fixed // earlier in this file. let mut state = self.state.lock().await; - if let Some(token) = state.token.as_mut() { - self.refresher.restore(token, credential); + if !consumed { + if let Some(token) = state.token.as_mut() { + self.refresher.restore(token, credential); + } } self.refresh_in_progress.store(false, Ordering::Release); - // The cached token is still usable, so this call still - // succeeds — but record the refusal so the next call doesn't - // re-issue the same request (if it's account-level), and so a - // caller parked in `wait_for_in_flight_refresh` sees the same - // answer this refresh actually got (regardless of its class). + // Record the refusal so the next call doesn't re-issue the + // same request (if it's account-level), and so a caller parked + // in `wait_for_in_flight_refresh` sees the same answer this + // refresh actually got (regardless of its class). state.record_refusal(&err, self.clock.now_unix_secs()); guard.defuse(); + // An ordinary failure leaves the cached token usable, so this + // call still succeeds. A consumed credential does not: the + // rotation happened upstream but was lost, and succeeding + // would hide that until the cached token expires. + if consumed { + Err(AutoRefreshError::Auth(err)) + } else { + Ok(current_service_token) + } } - } + }; self.refresh_notify.notify_waiters(); - Ok(current_service_token) + result } /// Token is fully expired — refresh while holding the lock so concurrent @@ -572,8 +599,10 @@ impl<R: Refresher, S: TokenStore> AutoRefresh<R, S> { Err(err) => { guard.defuse(); tracing::warn!(%err, "token refresh failed"); - if let Some(token) = state.token.as_mut() { - self.refresher.restore(token, credential); + if !credential_consumed(&err) { + if let Some(token) = state.token.as_mut() { + self.refresher.restore(token, credential); + } } self.refresh_in_progress.store(false, Ordering::Release); state.record_refusal(&err, self.clock.now_unix_secs()); @@ -1218,6 +1247,80 @@ mod tests { } } + /// Makes every later `auth.json` write fail: the atomic save renames a + /// temp file over the target, which cannot replace a non-empty directory. + fn break_token_persistence(dir: &tempfile::TempDir) { + use stack_profile::ProfileData; + let path = ProfileStore::new(dir.path()) + .workspace_store("ZVATKW3VHMFG27DY") + .unwrap() + .dir() + .join(Token::FILENAME); + std::fs::remove_file(&path).unwrap(); + std::fs::create_dir(&path).unwrap(); + std::fs::write(path.join("occupied"), b"").unwrap(); + } + + fn refreshing_server() -> MockSet { + let mut mocks = MockSet::new(); + mocks.mock(|when, then| { + when.post().path("/oauth/token"); + then.json(refresh_response_json("refreshed-token")); + }); + mocks + } + + /// The upstream exchange succeeds, so the refresh token it sent is spent. + /// Restoring it would replay it on the next call and revoke the chain. + mod given_a_refreshed_token_cannot_be_saved { + use super::*; + use crate::AuthError; + + #[tokio::test] + async fn an_expiring_token_fails_the_call_and_drops_the_spent_refresh_token() { + let server = start_server(refreshing_server()).await; + let dir = tempfile::tempdir().unwrap(); + // Inside the refresh leeway but still usable: the non-blocking path. + let strategy = + auto_refresh_with_token(&dir, &server, make_token("still-usable", 30, true)); + break_token_persistence(&dir); + + let result = strategy.get_token().await; + assert!( + matches!(result, Err(AutoRefreshError::Auth(AuthError::Store(_)))), + "a lost rotation must fail the call, got: {result:?}" + ); + + let state = strategy.state.lock().await; + assert!( + state.token.as_ref().unwrap().refresh_token().is_none(), + "the spent refresh token must not be restored for replay" + ); + } + + #[tokio::test] + async fn an_expired_token_fails_the_call_and_drops_the_spent_refresh_token() { + let server = start_server(refreshing_server()).await; + let dir = tempfile::tempdir().unwrap(); + let mut token = make_token("expired", 0, true); + token.expires_at -= 10; + let strategy = auto_refresh_with_token(&dir, &server, token); + break_token_persistence(&dir); + + let result = strategy.get_token().await; + assert!( + matches!(result, Err(AutoRefreshError::Auth(AuthError::Store(_)))), + "a lost rotation must fail the call, got: {result:?}" + ); + + let state = strategy.state.lock().await; + assert!( + state.token.as_ref().unwrap().refresh_token().is_none(), + "the spent refresh token must not be restored for replay" + ); + } + } + mod given_concurrent_callers { use super::*; diff --git a/packages/stack-auth/src/refresher.rs b/packages/stack-auth/src/refresher.rs index 42f7cf2d1..d2e2844d2 100644 --- a/packages/stack-auth/src/refresher.rs +++ b/packages/stack-auth/src/refresher.rs @@ -33,6 +33,11 @@ pub(crate) trait Refresher: Send + Sync { fn restore(&self, token: &mut Token, credential: Self::Credential); /// Perform the HTTP refresh or authentication call. + /// + /// Return [`AuthError::Store`] only when the exchange succeeded but its + /// result could not be persisted. `AutoRefresh` treats that credential as + /// spent: it is not restored, and the call fails even if the cached token + /// is still usable. fn refresh( &self, credential: &Self::Credential, @@ -64,6 +69,11 @@ pub(crate) trait Refresher { /// Perform the HTTP refresh or authentication call. /// + /// Return [`AuthError::Store`] only when the exchange succeeded but its + /// result could not be persisted. `AutoRefresh` treats that credential as + /// spent: it is not restored, and the call fails even if the cached token + /// is still usable. + /// /// The returned future is not `Send` — reqwest's wasm32 fetch backend /// holds JS handles via `Rc<RefCell<...>>` and edge runtimes are /// single-threaded anyway. From 73c94f1b8d9c4d5099514062a7895db87b73cf0c Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Mon, 28 Sep 2026 22:07:32 +0000 Subject: [PATCH 639/686] fix(stackauth): report malformed credentials as configuration errors A CRN or access key that does not parse in the guest's typed constructors now maps to STATUS_AUTH_CONFIG, the class Rust's AutoStrategy reports for INVALID_CRN and INVALID_ACCESS_KEY and the one validate_crn already used. Go callers checking errors.Is(err, ErrAuthConfig) now see a malformed CS_CLIENT_ACCESS_KEY the same way they see an empty one. STATUS_ENCODING stays for the JSON envelope itself. Pins the three cases in the Go auto-detection test: a malformed key via Auto, and a malformed CRN via both AccessKey and OIDC. Documents on OIDCProvider that Token runs while the owning ProfileStore is locked, so a provider must not call back into that store or its strategies. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TJc73YNcTeGaLDhP2P3LNM --- languages/golang/stackauth/guest/src/auth.rs | 19 +++++++++++-------- .../golang/stackauth/guest/src/status.rs | 2 +- languages/golang/stackauth/strategy_test.go | 13 +++++++++++++ languages/golang/stackauth/transport.go | 7 +++++++ 4 files changed, 32 insertions(+), 9 deletions(-) diff --git a/languages/golang/stackauth/guest/src/auth.rs b/languages/golang/stackauth/guest/src/auth.rs index 206c2dea9..e55212c3b 100644 --- a/languages/golang/stackauth/guest/src/auth.rs +++ b/languages/golang/stackauth/guest/src/auth.rs @@ -17,8 +17,8 @@ use zeroize::Zeroizing; use crate::host::{HostOidcProvider, WasiAuthTransport}; use crate::status::{ - status_for_auth, status_for_profile, STATUS_AUTH_REFRESH_REQUIRED, STATUS_ENCODING, - STATUS_STATE, + status_for_auth, status_for_profile, STATUS_AUTH_CONFIG, STATUS_AUTH_REFRESH_REQUIRED, + STATUS_ENCODING, STATUS_STATE, }; #[derive(Deserialize)] @@ -65,12 +65,15 @@ fn parse_base_url(value: Option<String>) -> Result<Option<url::Url>, u32> { pub fn validate_crn(bytes: &[u8]) -> Result<Vec<u8>, u32> { let text = std::str::from_utf8(bytes).map_err(|_| STATUS_ENCODING)?; - let _: Crn = text - .parse() - .map_err(|_| stack_guest_abi::status::STATUS_AUTH_CONFIG)?; + let _: Crn = text.parse().map_err(|_| STATUS_AUTH_CONFIG)?; Ok(Vec::new()) } +// A CRN or access key that does not parse is a configuration error, the +// class Rust's `AutoStrategy` reports (`INVALID_CRN`, `INVALID_ACCESS_KEY`) +// and the one `validate_crn` already uses. `STATUS_ENCODING` is kept for the +// JSON envelope itself. + pub fn create(config: &[u8]) -> Result<Vec<u8>, u32> { let config: Config = serde_json::from_slice(config).map_err(|_| STATUS_ENCODING)?; let strategy = match config { @@ -80,8 +83,8 @@ pub fn create(config: &[u8]) -> Result<Vec<u8>, u32> { base_url, } => { let access_key = Zeroizing::new(access_key); - let crn: Crn = crn.parse().map_err(|_| STATUS_ENCODING)?; - let key: AccessKey = access_key.parse().map_err(|_| STATUS_ENCODING)?; + let crn: Crn = crn.parse().map_err(|_| STATUS_AUTH_CONFIG)?; + let key: AccessKey = access_key.parse().map_err(|_| STATUS_AUTH_CONFIG)?; let mut builder = AccessKeyStrategy::builder(crn, key).transport(WasiAuthTransport); if let Some(url) = parse_base_url(base_url)? { builder = builder.base_url(url); @@ -93,7 +96,7 @@ pub fn create(config: &[u8]) -> Result<Vec<u8>, u32> { provider, base_url, } => { - let crn: Crn = crn.parse().map_err(|_| STATUS_ENCODING)?; + let crn: Crn = crn.parse().map_err(|_| STATUS_AUTH_CONFIG)?; let mut builder = OidcFederationStrategy::builder(crn, HostOidcProvider(provider)) .transport(WasiAuthTransport); if let Some(url) = parse_base_url(base_url)? { diff --git a/languages/golang/stackauth/guest/src/status.rs b/languages/golang/stackauth/guest/src/status.rs index 847adf02d..0ac77b52b 100644 --- a/languages/golang/stackauth/guest/src/status.rs +++ b/languages/golang/stackauth/guest/src/status.rs @@ -11,7 +11,7 @@ use stack_auth::AuthError; use stack_profile::ProfileError; pub use stack_guest_abi::status::{ - STATUS_AUTH_REFRESH_REQUIRED, STATUS_ENCODING, STATUS_INTERNAL, + STATUS_AUTH_CONFIG, STATUS_AUTH_REFRESH_REQUIRED, STATUS_ENCODING, STATUS_INTERNAL, STATUS_PROFILE_INVALID_FILENAME, STATUS_PROFILE_INVALID_WORKSPACE_ID, STATUS_PROFILE_IO, STATUS_PROFILE_JSON, STATUS_PROFILE_NOT_FOUND, STATUS_PROFILE_NO_CURRENT_WORKSPACE, STATUS_PROFILE_WORKSPACE_NOT_FOUND, STATUS_STATE, diff --git a/languages/golang/stackauth/strategy_test.go b/languages/golang/stackauth/strategy_test.go index 2533a5775..0da78f453 100644 --- a/languages/golang/stackauth/strategy_test.go +++ b/languages/golang/stackauth/strategy_test.go @@ -332,6 +332,19 @@ func TestAutoUsesEnvironmentPresenceAndProfileExistence(t *testing.T) { if _, err := profile.Auto(context.Background()); !errors.Is(err, ErrAuthConfig) { t.Fatalf("set but empty access key: error = %v, want %v", err, ErrAuthConfig) } + // A key that does not parse is a configuration error like the empty one, + // the class Rust's AutoStrategy reports, not a malformed-input error. + t.Setenv("CS_CLIENT_ACCESS_KEY", "not-a-key") + if _, err := profile.Auto(context.Background()); !errors.Is(err, ErrAuthConfig) { + t.Fatalf("malformed access key: error = %v, want %v", err, ErrAuthConfig) + } + if _, err := profile.AccessKey(context.Background(), "invalid", "CSAKtestKeyId.testKeySecret"); !errors.Is(err, ErrAuthConfig) { + t.Fatalf("malformed CRN for access key: error = %v, want %v", err, ErrAuthConfig) + } + provider := OIDCProviderFunc(func(context.Context) (string, error) { return "", nil }) + if _, err := profile.OIDC(context.Background(), "invalid", provider); !errors.Is(err, ErrAuthConfig) { + t.Fatalf("malformed CRN for OIDC: error = %v, want %v", err, ErrAuthConfig) + } if err := os.Unsetenv("CS_CLIENT_ACCESS_KEY"); err != nil { t.Fatal(err) } diff --git a/languages/golang/stackauth/transport.go b/languages/golang/stackauth/transport.go index 18f0fb745..9e11add6b 100644 --- a/languages/golang/stackauth/transport.go +++ b/languages/golang/stackauth/transport.go @@ -17,6 +17,13 @@ import ( // OIDCProvider supplies the current identity-provider JWT. The Rust // federation strategy asks only when its cached CTS token needs renewal. +// +// Token runs inside the guest call that needs it, while the ProfileStore +// that owns the strategy is locked. It must not call back into that +// ProfileStore or any strategy of it (reading a stored token, building +// another strategy): the call would wait on the same lock and deadlock +// rather than fail. OAuth2TokenSource does not touch the store, so it is +// safe to use from here. type OIDCProvider interface { Token(context.Context) (string, error) } From b13be202a16fd74182cf5cd0f338915831c9a121 Mon Sep 17 00:00:00 2001 From: Dan Draper <dan@cipherstash.com> Date: Mon, 28 Sep 2026 21:42:05 -0700 Subject: [PATCH 640/686] fix(stackauth): identify every auth request with a user-agent The nginx edge in front of production CTS answers a request whose User-Agent is a runtime's generic default with a bare HTML 403, before CTS sees it. stack-auth's shared transport sent no user-agent at all: natively reqwest adds none and the edge lets that through, but the Go credential guest forwards requests through Go's HTTP client, which fills in Go-http-client/1.1. Every access-key exchange to production failed with an opaque "cipherstash: auth transport failed". stack-auth: the shared request builder now adds `user-agent: stack-auth/<version> (<os> <arch>)` to every request, so every CTS call carries it: the access-key and OIDC-federation /api/authorise exchanges, the device-session /oauth/token refresh, and the device-code /oauth/device/code and /oauth/device/token calls. Device binding's ZeroKMS create-client call used its own copy of that string and now gets it from the builder. Exposes `stack_auth::VERSION`. Credential guest: replaces stack-auth's value with `stack-auth/<version> (Go)` before handing a request to the host, as the crypto guest sends `stack-encrypt/<version> (Go)`. Built for wasm32-wasip1, stack-auth's own value would read `(wasi wasm32)`, which names the sandbox rather than the host that made the request. Go: a refused exchange now says which HTTP status refused it ("cipherstash: auth transport failed: HTTP 403"). Only a status code crosses the guest ABI, so the host transport records the last response status on the call's context. The response body is never included. Tests: every shared-transport request shape and the access-key, OIDC-federation, refresh and device-code calls pin the exact header; the guest pins its replacement; the Go tests fail on the old guest with Go-http-client/1.1 and cover the access-key, OIDC and device-session refresh exchanges and the new error text. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TJc73YNcTeGaLDhP2P3LNM --- .../golang/stackauth/guest/src/headers.rs | 147 ++++++++++++++++++ languages/golang/stackauth/guest/src/host.rs | 13 +- languages/golang/stackauth/guest/src/lib.rs | 3 +- languages/golang/stackauth/strategy.go | 8 + languages/golang/stackauth/strategy_test.go | 109 +++++++++++++ languages/golang/stackauth/transport.go | 28 ++++ packages/stack-auth/src/device_client.rs | 22 +-- packages/stack-auth/src/device_code/tests.rs | 37 +++++ packages/stack-auth/src/lib.rs | 7 + packages/stack-auth/src/transport.rs | 133 +++++++++++++++- 10 files changed, 481 insertions(+), 26 deletions(-) create mode 100644 languages/golang/stackauth/guest/src/headers.rs diff --git a/languages/golang/stackauth/guest/src/headers.rs b/languages/golang/stackauth/guest/src/headers.rs new file mode 100644 index 000000000..3ed454d0c --- /dev/null +++ b/languages/golang/stackauth/guest/src/headers.rs @@ -0,0 +1,147 @@ +//! The headers this guest's auth requests carry, over the wire format every +//! guest shares ([`stack_guest_abi::headers`]: `name: value` lines). +//! +//! stack-auth builds each request, `user-agent` included; this module is the +//! one change the guest makes on the way to the host: it names the host. + +use std::sync::OnceLock; + +use stack_guest_abi::headers::encode_headers; + +/// The host this guest is driven by, as it appears in [`user_agent`]. One +/// token for one build, as in the crypto guest's `headers` module. +const HOST: &str = "Go"; + +/// The `user-agent` every auth request from this guest carries: +/// `stack-auth/<version> (Go)`. +/// +/// Not optional, and not cosmetic: the edge in front of production CTS +/// refuses a host runtime's generic default (`Go-http-client/1.1`, which +/// Go's HTTP client fills in when a request carries none) with a bare nginx +/// 403 that never reaches CTS. +/// +/// stack-auth already sets one, `stack-auth/<version> (<os> <arch>)`, and +/// this replaces it rather than passing it through. Built for wasm32-wasip1 +/// that value would read `(wasi wasm32)`: true of the sandbox, useless to +/// anyone reading a log, and silent about the host that actually made the +/// request. The crypto guest's `stack-encrypt/<version> (Go)` names the +/// library and the host carrying it; this says the same of stack-auth, with +/// stack-auth's own version, so the product token means one thing from Rust +/// and from Go. +pub fn user_agent() -> &'static str { + static USER_AGENT: OnceLock<String> = OnceLock::new(); + USER_AGENT.get_or_init(|| format!("stack-auth/{} ({HOST})", stack_auth::VERSION)) +} + +/// `headers` (a stack-auth request's) in the host's wire format, with its +/// `user-agent` replaced by [`user_agent`]. Every other header crosses as +/// stack-auth built it, in order. +/// +/// This lives here rather than in `host` because `host` is +/// `#[cfg(target_arch = "wasm32")]` and so is never compiled, let alone +/// tested, on the native target. The header set is the kind of thing that +/// fails in production and nowhere else. +pub fn request_headers(headers: &[(String, String)]) -> Vec<u8> { + let pairs: Vec<(&str, &str)> = headers + .iter() + .filter(|(name, _)| !name.eq_ignore_ascii_case("user-agent")) + .map(|(name, value)| (name.as_str(), value.as_str())) + .chain(std::iter::once(("user-agent", user_agent()))) + .collect(); + encode_headers(&pairs) +} + +#[cfg(test)] +mod tests { + use std::sync::{Arc, Mutex}; + + use futures::executor::block_on; + use stack_auth::{ + AccessKeyStrategy, AuthStrategy, HttpRequest, HttpResponse, HttpTransport, RequestError, + }; + use stack_guest_abi::headers::header_value; + + use super::*; + + /// Captures the headers of every request stack-auth builds, as this + /// guest would hand them to the host, and answers with a 500. + #[derive(Default)] + struct Capture(Mutex<Vec<Vec<u8>>>); + + impl HttpTransport for Capture { + async fn send(&self, request: HttpRequest) -> Result<HttpResponse, RequestError> { + self.0 + .lock() + .unwrap() + .push(request_headers(request.headers())); + Ok(HttpResponse::new(500, Vec::new(), Vec::new())) + } + } + + fn user_agent_lines(headers: &[u8]) -> usize { + std::str::from_utf8(headers) + .unwrap() + .lines() + .filter(|line| line.to_ascii_lowercase().starts_with("user-agent:")) + .count() + } + + /// The edge in front of production CTS answers a request whose + /// `user-agent` is Go's default with a bare nginx 403, before CTS sees + /// it. Every request this guest hands the host must name the library + /// and the host, once, and keep the headers stack-auth built. + #[test] + fn every_request_identifies_itself() { + let capture = Arc::new(Capture::default()); + let strategy = AccessKeyStrategy::builder( + "crn:ap-southeast-2.aws:ZVATKW3VHMFG27DY".parse().unwrap(), + "CSAKtestKeyId.testKeySecret".parse().unwrap(), + ) + .base_url("https://cts.example.com/".parse().unwrap()) + .transport(Arc::clone(&capture)) + .build() + .unwrap(); + let _ = block_on((&strategy).get_token()); + + let seen = capture.0.lock().unwrap(); + assert_eq!(seen.len(), 1, "one access-key exchange"); + let headers = &seen[0]; + let ua = header_value(headers, "user-agent").expect("requests carry a user-agent"); + assert_eq!( + ua, + format!("stack-auth/{} (Go)", stack_auth::VERSION), + "the user-agent names the library and the host carrying it" + ); + assert!( + !ua.contains("Go-http-client"), + "a host runtime's default user-agent is refused by the edge" + ); + assert_eq!( + user_agent_lines(headers), + 1, + "stack-auth's own user-agent is replaced, not sent alongside: {:?}", + String::from_utf8_lossy(headers) + ); + assert_eq!( + header_value(headers, "content-type"), + Some("application/json"), + "the content type travels with the user-agent" + ); + } + + #[test] + fn a_user_agent_is_replaced_whatever_its_case_and_the_rest_kept_in_order() { + let headers = request_headers(&[ + ("authorization".into(), "Bearer tok".into()), + ("User-Agent".into(), "stack-auth/0.0.0 (wasi wasm32)".into()), + ("content-type".into(), "application/json".into()), + ]); + assert_eq!( + String::from_utf8(headers).unwrap(), + format!( + "authorization: Bearer tok\ncontent-type: application/json\nuser-agent: {}", + user_agent() + ) + ); + } +} diff --git a/languages/golang/stackauth/guest/src/host.rs b/languages/golang/stackauth/guest/src/host.rs index 7abbec7b8..380b8306f 100644 --- a/languages/golang/stackauth/guest/src/host.rs +++ b/languages/golang/stackauth/guest/src/host.rs @@ -7,9 +7,11 @@ use std::io; use stack_auth::{ AuthError, HttpRequest, HttpResponse, HttpTransport, OidcProvider, RequestError, SecretToken, }; -use stack_guest_abi::{buffers, headers, transport}; +use stack_guest_abi::{buffers, transport}; use zeroize::Zeroizing; +use crate::headers::request_headers; + #[link(wasm_import_module = "cipherstash_transport")] extern "C" { fn oidc_token_get(provider: u32, ptr_out: *mut u32, len_out: *mut u32) -> i32; @@ -23,12 +25,9 @@ pub struct WasiAuthTransport; impl HttpTransport for WasiAuthTransport { async fn send(&self, request: HttpRequest) -> Result<HttpResponse, RequestError> { - let pairs: Vec<(&str, &str)> = request - .headers() - .iter() - .map(|(name, value)| (name.as_str(), value.as_str())) - .collect(); - let wire_headers = Zeroizing::new(headers::encode_headers(&pairs)); + // stack-auth's headers, naming this host in the `user-agent`: the + // edge in front of CTS refuses Go's default one (see `headers`). + let wire_headers = Zeroizing::new(request_headers(request.headers())); let response = transport::send( request.method(), request.url().as_str(), diff --git a/languages/golang/stackauth/guest/src/lib.rs b/languages/golang/stackauth/guest/src/lib.rs index 1ea90fe0f..421057ef0 100644 --- a/languages/golang/stackauth/guest/src/lib.rs +++ b/languages/golang/stackauth/guest/src/lib.rs @@ -87,13 +87,14 @@ //! //! Split into: //! -//! - [`ops`], [`status`] — everything that is pure logic over a +//! - [`ops`], [`status`], [`headers`] — everything that is pure logic over a //! `ProfileStore` and bytes. Compiles and unit-tests on the native host //! target (`cargo test` here) against a temporary directory. //! - [`auth`], [`host`] (wasm32 only) — stack-auth strategies and host HTTP. //! - [`abi`] (wasm32 only) — the export surface, over the conventions //! every guest shares (`stack_guest_abi`). +pub mod headers; pub mod ops; pub mod status; diff --git a/languages/golang/stackauth/strategy.go b/languages/golang/stackauth/strategy.go index fbb5e434a..dd6a7d876 100644 --- a/languages/golang/stackauth/strategy.go +++ b/languages/golang/stackauth/strategy.go @@ -167,6 +167,14 @@ func (s *Strategy) Token(ctx context.Context) (string, error) { if s.closed { return "", ErrState } + ctx, status := withAuthHTTPStatus(ctx) + token, err := s.token(ctx) + return token, status.wrap(err) +} + +// token is Token under the strategy's lock: a read, then a locked refresh +// for a device session that needs one. +func (s *Strategy) token(ctx context.Context) (string, error) { call := func(export func(*instance) api.Function) (string, error) { out, err := s.store.callArgs(ctx, export, guest.BufArg([]byte(s.handle))) if err != nil { diff --git a/languages/golang/stackauth/strategy_test.go b/languages/golang/stackauth/strategy_test.go index 0da78f453..690a0a21f 100644 --- a/languages/golang/stackauth/strategy_test.go +++ b/languages/golang/stackauth/strategy_test.go @@ -12,6 +12,7 @@ import ( "os" "path/filepath" "reflect" + "strings" "sync" "sync/atomic" "testing" @@ -84,6 +85,9 @@ func TestOIDCStrategyCallsProviderOnlyOnExchange(t *testing.T) { if want := (map[string]any{"oidcToken": "idp-token", "workspaceId": "ZVATKW3VHMFG27DY"}); !reflect.DeepEqual(body, want) { t.Errorf("request body = %#v, want %#v", body, want) } + if ua := r.Header.Get("User-Agent"); !isStackAuthGoAgent(ua) { + t.Errorf("OIDC federation User-Agent = %q, want stack-auth/<version> (Go)", ua) + } fmt.Fprintf(w, `{"accessToken":%q,"expiry":%d}`, testJWT(t, "https://cts.example"), time.Now().Add(time.Hour).Unix()) })) defer server.Close() @@ -375,6 +379,9 @@ func TestDeviceRefreshLockPreventsReplay(t *testing.T) { if r.URL.Path != "/oauth/token" { t.Errorf("path: %s", r.URL.Path) } + if ua := r.Header.Get("User-Agent"); !isStackAuthGoAgent(ua) { + t.Errorf("device-session refresh User-Agent = %q, want stack-auth/<version> (Go)", ua) + } if err := r.ParseForm(); err != nil { t.Error(err) } @@ -466,3 +473,105 @@ func TestDeviceRefreshReportsInvalidGrant(t *testing.T) { t.Fatalf("Token error = %v, want ErrInvalidGrant", err) } } + +// The edge in front of production CTS answers a request whose User-Agent is +// Go's default (Go-http-client/1.1) with a bare nginx 403 before CTS sees +// it, and Go's HTTP client fills that default in when a request carries +// none. The access-key exchange must name stack-auth and the Go host. +func TestAuthRequestsIdentifyTheLibraryNotGo(t *testing.T) { + guestOrSkip(t) + var agent atomic.Value + var jwt string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + agent.Store(r.Header.Get("User-Agent")) + if ua := r.Header.Get("User-Agent"); ua == "" || strings.HasPrefix(ua, "Go-http-client/") { + // What the production edge does, so the failure is the real one. + http.Error(w, "<html><center><h1>403 Forbidden</h1></center></html>", http.StatusForbidden) + return + } + fmt.Fprintf(w, `{"accessToken":%q,"expiry":%d}`, jwt, time.Now().Add(time.Hour).Unix()) + })) + defer server.Close() + jwt = testJWT(t, server.URL) + profile, err := Open(context.Background(), t.TempDir()) + if err != nil { + t.Fatal(err) + } + defer profile.Close() + strategy, err := profile.AccessKey(context.Background(), testCRN, "CSAKtestKeyId.testKeySecret", WithAuthBaseURL(server.URL)) + if err != nil { + t.Fatal(err) + } + defer strategy.Close() + got, err := strategy.Token(context.Background()) + ua, _ := agent.Load().(string) + if err != nil || got != jwt { + t.Fatalf("Token = %q, %v (User-Agent %q)", got, err, ua) + } + if !isStackAuthGoAgent(ua) { + t.Fatalf("User-Agent = %q, want stack-auth/<version> (Go)", ua) + } +} + +// isStackAuthGoAgent reports whether ua is the credential guest's own, +// stack-auth/<version> (Go), and not Go's default Go-http-client/1.1. +func isStackAuthGoAgent(ua string) bool { + version, ok := strings.CutPrefix(ua, "stack-auth/") + if !ok { + return false + } + version, ok = strings.CutSuffix(version, " (Go)") + return ok && version != "" && !strings.ContainsAny(version, " ()") +} + +// Only a status code crosses the guest ABI, so a refused exchange must still +// say which HTTP status refused it, and never carry the response body. +func TestAuthTransportErrorNamesTheHTTPStatusNotTheBody(t *testing.T) { + guestOrSkip(t) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html") + w.WriteHeader(http.StatusForbidden) + fmt.Fprint(w, "<html><h1>403 Forbidden</h1>nginx CSAKtestKeyId.testKeySecret</html>") + })) + defer server.Close() + profile, err := Open(context.Background(), t.TempDir()) + if err != nil { + t.Fatal(err) + } + defer profile.Close() + strategy, err := profile.AccessKey(context.Background(), testCRN, "CSAKtestKeyId.testKeySecret", WithAuthBaseURL(server.URL)) + if err != nil { + t.Fatal(err) + } + defer strategy.Close() + _, err = strategy.Token(context.Background()) + if !errors.Is(err, ErrAuthTransport) { + t.Fatalf("Token error = %v, want ErrAuthTransport", err) + } + if want := "cipherstash: auth transport failed: HTTP 403"; err.Error() != want { + t.Fatalf("Token error = %q, want %q", err, want) + } +} + +// A transport failure with no HTTP response at all stays the bare sentinel: +// there is no status to name. +func TestAuthTransportErrorWithoutAResponseNamesNoStatus(t *testing.T) { + guestOrSkip(t) + server := httptest.NewServer(http.NotFoundHandler()) + addr := server.URL + server.Close() + profile, err := Open(context.Background(), t.TempDir()) + if err != nil { + t.Fatal(err) + } + defer profile.Close() + strategy, err := profile.AccessKey(context.Background(), testCRN, "CSAKtestKeyId.testKeySecret", WithAuthBaseURL(addr)) + if err != nil { + t.Fatal(err) + } + defer strategy.Close() + _, err = strategy.Token(context.Background()) + if !errors.Is(err, ErrAuthTransport) || strings.Contains(err.Error(), "HTTP") { + t.Fatalf("Token error = %v, want a bare ErrAuthTransport", err) + } +} diff --git a/languages/golang/stackauth/transport.go b/languages/golang/stackauth/transport.go index 9e11add6b..cee5573f1 100644 --- a/languages/golang/stackauth/transport.go +++ b/languages/golang/stackauth/transport.go @@ -35,6 +35,31 @@ func (f OIDCProviderFunc) Token(ctx context.Context) (string, error) { return f( const maxAuthResponseBytes = 16 << 20 +// authHTTPStatus records the status of the last HTTP response the transport +// received during one guest call. Only a status code crosses the guest ABI, +// so without it a refused exchange (the edge in front of CTS answering 403) +// reaches the caller as a bare ErrAuthTransport. It lives on the call's +// context, which wazero hands to the host import, so concurrent calls on +// different profiles never see each other's status. +type authHTTPStatus struct{ code int } + +type authHTTPStatusKey struct{} + +func withAuthHTTPStatus(ctx context.Context) (context.Context, *authHTTPStatus) { + status := &authHTTPStatus{} + return context.WithValue(ctx, authHTTPStatusKey{}, status), status +} + +// wrap names the HTTP status of a refused exchange on an ErrAuthTransport +// ("cipherstash: auth transport failed: HTTP 403"). The body is never +// included: it may be an HTML error page, or echo a credential. +func (s *authHTTPStatus) wrap(err error) error { + if err == nil || !errors.Is(err, ErrAuthTransport) || s.code == 0 || (s.code >= 200 && s.code < 300) { + return err + } + return fmt.Errorf("%w: HTTP %d", err, s.code) +} + type authTransport struct { rt http.RoundTripper mu sync.Mutex @@ -102,6 +127,9 @@ func (t *authTransport) send(ctx context.Context, m api.Module, return t.placeResponse(ctx, m, respHeadersPtrOut, respHeadersLenOut, respBodyPtrOut, respBodyLenOut, -1, nil, []byte(err.Error())) } defer resp.Body.Close() + if status, ok := ctx.Value(authHTTPStatusKey{}).(*authHTTPStatus); ok { + status.code = resp.StatusCode + } if resp.ContentLength > maxAuthResponseBytes { return t.placeResponse(ctx, m, respHeadersPtrOut, respHeadersLenOut, respBodyPtrOut, respBodyLenOut, -1, nil, []byte("auth response exceeds limit")) } diff --git a/packages/stack-auth/src/device_client.rs b/packages/stack-auth/src/device_client.rs index 3a999944c..6a1d22a0c 100644 --- a/packages/stack-auth/src/device_client.rs +++ b/packages/stack-auth/src/device_client.rs @@ -13,15 +13,6 @@ use crate::error::RequestError; use crate::transport::{self, ReqwestTransport}; use crate::{ensure_trailing_slash, ServiceToken, Token}; -fn user_agent() -> String { - format!( - "stack-auth/{} ({} {})", - env!("CARGO_PKG_VERSION"), - std::env::consts::OS, - std::env::consts::ARCH, - ) -} - // --------------------------------------------------------------------------- // Secret key file (output) // --------------------------------------------------------------------------- @@ -111,13 +102,12 @@ pub async fn bind_client_device(store: &ProfileStore) -> Result<(), DeviceClient &transport::share(ReqwestTransport::default()), url, "application/json", - vec![ - ("user-agent".to_string(), user_agent()), - ( - "authorization".to_string(), - format!("Bearer {}", service_token.as_str()), - ), - ], + // The shared transport adds `content-type` and the crate's + // `user-agent`: ZeroKMS sees which client build provisioned the device. + vec![( + "authorization".to_string(), + format!("Bearer {}", service_token.as_str()), + )], body, ) .await?; diff --git a/packages/stack-auth/src/device_code/tests.rs b/packages/stack-auth/src/device_code/tests.rs index 544c09296..c277b0bac 100644 --- a/packages/stack-auth/src/device_code/tests.rs +++ b/packages/stack-auth/src/device_code/tests.rs @@ -222,6 +222,43 @@ async fn test_poll_for_token_success() { ); } +/// The device-code start and the token poll both go to CTS, whose edge +/// refuses a request without a `user-agent` it accepts: each names the crate. +/// The mocks answer only a request that carries it. +#[tokio::test(start_paused = true)] +async fn device_code_requests_identify_the_crate() { + let user_agent = format!( + "stack-auth/{} ({} {})", + env!("CARGO_PKG_VERSION"), + std::env::consts::OS, + std::env::consts::ARCH, + ); + let dir = TempDir::new().unwrap(); + let mut mocks = MockSet::new(); + let code_agent = user_agent.clone(); + mocks.mock(move |when, then| { + when.post() + .path("/oauth/device/code") + .header("user-agent", code_agent); + then.json(device_code_json()); + }); + mocks.mock(move |when, then| { + when.post() + .path("/oauth/device/token") + .header("user-agent", user_agent); + then.json(token_json()); + }); + let server = start_server(mocks).await; + + let token = begin_pending(&server, &dir) + .await + .poll_for_token() + .await + .unwrap(); + + assert_eq!(token.token_type(), "Bearer"); +} + #[tokio::test(start_paused = true)] async fn test_poll_for_token_access_denied() { let dir = TempDir::new().unwrap(); diff --git a/packages/stack-auth/src/lib.rs b/packages/stack-auth/src/lib.rs index b02998310..88b81bcb4 100644 --- a/packages/stack-auth/src/lib.rs +++ b/packages/stack-auth/src/lib.rs @@ -115,6 +115,13 @@ pub use token_store::{InMemoryTokenStore, NoStore, TokenStore, TokenStoreFn}; pub use transport::ReqwestTransport; pub use transport::{HttpRequest, HttpResponse, HttpTransport}; +/// This crate's version: the product token in the `user-agent` every +/// request it builds carries (`stack-auth/<version> (<os> <arch>)`). A host +/// transport that replaces that value with one naming itself (the Go +/// binding's guest sends `stack-auth/<version> (Go)`) reads the version +/// here, so the product token means the same thing from every host. +pub const VERSION: &str = env!("CARGO_PKG_VERSION"); + /// Deprecated alias for [`DeviceSessionStrategy`]. /// /// Renamed to make the *renewal* (existing CTS session) vs *federation* diff --git a/packages/stack-auth/src/transport.rs b/packages/stack-auth/src/transport.rs index 8b23e0f15..33c8b1364 100644 --- a/packages/stack-auth/src/transport.rs +++ b/packages/stack-auth/src/transport.rs @@ -23,7 +23,7 @@ use std::fmt; use std::future::Future; -use std::sync::Arc; +use std::sync::{Arc, OnceLock}; use url::Url; use zeroize::Zeroizing; @@ -312,6 +312,31 @@ impl std::error::Error for NoTransport {} // The two request shapes the crate makes. // --------------------------------------------------------------------------- +/// The `user-agent` every request this crate builds carries: +/// `stack-auth/<version> (<os> <arch>)`. +/// +/// Not cosmetic. The edge in front of production CTS answers a request whose +/// `user-agent` is a runtime's generic default (Go's `Go-http-client/1.1`) +/// with a bare nginx 403 that never reaches CTS. Natively, reqwest sends no +/// `user-agent` of its own, which that edge happens to let through; a host +/// transport whose HTTP client fills one in when the request has none (the +/// Go binding's) was refused on every exchange. So the crate names itself +/// on every request rather than leaving the header to whatever client +/// carries it. A host transport may replace the value with one naming +/// itself (the Go guest sends `stack-auth/<version> (Go)`), but it is never +/// absent. +pub(crate) fn user_agent() -> &'static str { + static USER_AGENT: OnceLock<String> = OnceLock::new(); + USER_AGENT.get_or_init(|| { + format!( + "stack-auth/{} ({} {})", + crate::VERSION, + std::env::consts::OS, + std::env::consts::ARCH, + ) + }) +} + /// `POST` a JSON body. pub(crate) async fn post_json<B: serde::Serialize>( transport: &SharedTransport, @@ -343,7 +368,8 @@ pub(crate) async fn post_form<B: serde::Serialize>( .await } -/// `POST` `body` as `content_type`, with `extra` headers first. A failure +/// `POST` `body` as `content_type`, with `extra` headers first and then +/// `content-type` and [`user_agent`]. A failure /// here is the transport's (or the encoder's); the caller lifts it into /// its own error type, which for a strategy is `AuthError::Request`. pub(crate) async fn post( @@ -354,6 +380,7 @@ pub(crate) async fn post( body: Zeroizing<Vec<u8>>, ) -> Result<HttpResponse, RequestError> { extra.push(("content-type".to_string(), content_type.to_string())); + extra.push(("user-agent".to_string(), user_agent().to_string())); let request = HttpRequest { method: "POST", url, @@ -632,6 +659,77 @@ mod tests { ); } + /// The `user-agent` the crate's requests must carry, spelled out rather + /// than read back from [`user_agent`], so a change to it is a test + /// change. + fn expected_user_agent() -> String { + format!( + "stack-auth/{} ({} {})", + env!("CARGO_PKG_VERSION"), + std::env::consts::OS, + std::env::consts::ARCH, + ) + } + + /// The `user-agent` values in `headers`: there must be exactly one. + fn user_agents(headers: &[(String, String)]) -> Vec<&str> { + headers + .iter() + .filter(|(name, _)| name == "user-agent") + .map(|(_, value)| value.as_str()) + .collect() + } + + /// The edge in front of production CTS refuses a request whose + /// `user-agent` is a runtime's generic default with a bare 403, and a + /// host transport's HTTP client fills one in when the request carries + /// none. Both request shapes the crate makes name the crate themselves. + #[tokio::test] + async fn every_request_identifies_itself() { + let stub = Arc::new(Stub::replying(200, "{}")); + let transport: SharedTransport = stub.clone(); + + post_json(&transport, base_url(), &serde_json::json!({"a": 1})) + .await + .unwrap(); + post_form(&transport, base_url(), &[("a", "1")]) + .await + .unwrap(); + post( + &transport, + base_url(), + "application/json", + vec![("authorization".into(), "Bearer tok".into())], + Zeroizing::new(Vec::new()), + ) + .await + .unwrap(); + + let expected = expected_user_agent(); + for (shape, content_type) in [ + ("post_json", "application/json"), + ("post_form", "application/x-www-form-urlencoded"), + ("post with extra headers", "application/json"), + ] { + let (_, _, headers, _) = seen(&stub); + assert_eq!( + user_agents(&headers), + vec![expected.as_str()], + "{shape}: exactly one user-agent, naming the crate, version and platform" + ); + assert!( + headers.contains(&("content-type".to_string(), content_type.to_string())), + "{shape}: the content type travels with the user-agent: {headers:?}" + ); + } + } + + #[test] + fn the_user_agent_names_the_crate_version_and_platform() { + assert_eq!(user_agent(), expected_user_agent()); + assert_eq!(crate::VERSION, env!("CARGO_PKG_VERSION")); + } + #[tokio::test] async fn refresh_posts_a_form_and_reads_the_token() { let stub = Arc::new(Stub::replying( @@ -659,6 +757,11 @@ mod tests { "content-type".to_string(), "application/x-www-form-urlencoded".to_string() ))); + assert_eq!( + user_agents(&headers), + vec![expected_user_agent().as_str()], + "the device-session refresh identifies the crate" + ); assert_eq!( body, b"grant_type=refresh_token&client_id=cli&refresh_token=rt", "an absent device id is omitted, not sent empty" @@ -716,12 +819,38 @@ mod tests { assert_eq!(method, "POST"); assert_eq!(url, "https://cts.example.com/api/authorise"); assert!(headers.contains(&("content-type".to_string(), "application/json".to_string()))); + assert_eq!( + user_agents(&headers), + vec![expected_user_agent().as_str()], + "the access-key exchange identifies the crate" + ); assert_eq!( serde_json::from_slice::<serde_json::Value>(&body).unwrap(), serde_json::json!({"accessKey": "CSAKid.secret", "audience": "aud"}) ); } + #[tokio::test] + async fn oidc_federation_posts_json_and_identifies_the_crate() { + let stub = Arc::new(Stub::replying( + 200, + r#"{"accessToken":"svc","expiry":4102444800}"#, + )); + let provider = OidcProviderFn::new(|| async { Ok(SecretToken::new("h.p.s")) }); + let refresher = OidcRefresher::new(provider, workspace_id(), base_url(), stub.clone()); + + let _ = refresher.refresh(&()).await; + + let (method, url, headers, _) = seen(&stub); + assert_eq!(method, "POST"); + assert_eq!(url, "https://cts.example.com/api/authorise"); + assert_eq!( + user_agents(&headers), + vec![expected_user_agent().as_str()], + "the OIDC federation exchange identifies the crate" + ); + } + #[tokio::test] async fn a_bare_402_is_a_usage_limit_on_every_exchange() { let transport: SharedTransport = Arc::new(Stub::replying(402, "")); From 23a3993815780e76ac55e5ce7f22698f00aa4ba9 Mon Sep 17 00:00:00 2001 From: Dan Draper <dan@cipherstash.com> Date: Sun, 27 Sep 2026 22:09:23 -0700 Subject: [PATCH 641/686] feat(go): resolve stackencrypt credentials from the environment, then the profile NewClient no longer needs the caller to find its credentials. Config's ClientID, ClientKey and Token become one pluggable Credentials, and a nil Credentials is AutoCredentials, which resolves host-side in the Rust client's order (StackCipherBuilder::init): - the token by AutoStrategy's order: CS_CLIENT_ACCESS_KEY with CS_WORKSPACE_CRN, else the current workspace's device session, run in stackauth's credential guest (CS_CTS_HOST overrides the auth endpoint); - the client key as FallbackKeyProvider(EnvKeyProvider, profile) does: CS_CLIENT_ID + CS_CLIENT_KEY when both are set, else secretkey.json; set but empty is an error, not a fall-through; - the endpoint as stack-kms reads it: Config.ZeroKMSURL, else CS_ZEROKMS_HOST, else CS_VITUR_HOST, an unusable value an error. Nothing found is ErrNoCredentials, naming what to set, and it fails at NewClient. NewCredentials takes the three values explicitly. The crypto guest still has no environment and no filesystem. stackauth gains OpenWithoutProfile, a guest with nothing mounted, so an access key or federation works where no profile directory exists (CI). A missing home directory is now ErrNoProfile too. stackencrypt imports stackauth for this; the ADR-0005 amendment records it. A token source's error is now attached to the error the guest reports for a failed token_get, so errors.Is finds ErrNoCredentials or a refused refresh through it. The example's hand-rolled profile.go is gone: it passes the zero Config. Refs CIP-4052 Co-Authored-By: Claude <noreply@anthropic.com> --- languages/golang/internal/guest/clientkey.go | 6 +- languages/golang/stackauth/README.md | 37 +- languages/golang/stackauth/clientkey.go | 5 +- languages/golang/stackauth/doc.go | 5 +- languages/golang/stackauth/guest.go | 33 +- languages/golang/stackauth/store.go | 43 +- languages/golang/stackauth/strategy_test.go | 45 ++ languages/golang/stackauth/token.go | 2 +- languages/golang/stackencrypt/README.md | 58 ++- languages/golang/stackencrypt/client.go | 144 ++++-- languages/golang/stackencrypt/clientkey.go | 5 +- languages/golang/stackencrypt/credentials.go | 273 ++++++++++ .../golang/stackencrypt/credentials_test.go | 478 ++++++++++++++++++ languages/golang/stackencrypt/doc.go | 22 +- .../golang/stackencrypt/example/README.md | 50 +- languages/golang/stackencrypt/example/main.go | 34 +- .../golang/stackencrypt/example/profile.go | 93 ---- languages/golang/stackencrypt/guest_test.go | 89 ++-- languages/golang/stackencrypt/live_test.go | 3 +- languages/golang/stackencrypt/memory_test.go | 8 +- languages/golang/stackencrypt/transport.go | 19 +- ...edential-guest-for-the-profile-and-auth.md | 16 + 22 files changed, 1189 insertions(+), 279 deletions(-) create mode 100644 languages/golang/stackencrypt/credentials.go create mode 100644 languages/golang/stackencrypt/credentials_test.go delete mode 100644 languages/golang/stackencrypt/example/profile.go diff --git a/languages/golang/internal/guest/clientkey.go b/languages/golang/internal/guest/clientkey.go index af8e61b96..6773dea68 100644 --- a/languages/golang/internal/guest/clientkey.go +++ b/languages/golang/internal/guest/clientkey.go @@ -10,9 +10,9 @@ import "fmt" // consumed (see ADR-0005, decision 5). // // stackauth reads one out of the developer profile; stackencrypt takes it -// in its Config (from CIP-4118 on) and wipes it once the key is in guest -// memory. Both expose this type as an alias, so a key read by one is the -// type the other takes, with neither package importing the other. +// in its credentials and wipes it once the key is in guest memory. Both +// expose this type as an alias, so a key read by one is the type the other +// takes, without stackauth importing stackencrypt. // // The public packages alias the type, and an alias carries every exported // method with it — Go's internal rule stops the import, not the call. So diff --git a/languages/golang/stackauth/README.md b/languages/golang/stackauth/README.md index 3b2b7c0d8..7ab2e8c0d 100644 --- a/languages/golang/stackauth/README.md +++ b/languages/golang/stackauth/README.md @@ -16,6 +16,12 @@ cross-process refresh lock for device sessions. ## Use +Most applications never call this package directly: a `stackencrypt` +client built with the zero `Config` resolves its credentials with +`stackencrypt.AutoCredentials`, which reads the environment first and then +the profile, through this package. Use it directly to take the profile +apart yourself: + ```go import ( "context" @@ -39,10 +45,14 @@ func run(ctx context.Context) error { if err != nil { return err } + source, err := workspace.DeviceSession(ctx) // refreshes under the CLI's lock + if err != nil { + return err + } + defer source.Close() client, err := stackencrypt.NewClient(ctx, stackencrypt.Config{ - ClientID: clientID, - ClientKey: clientKey, // consumed and wiped by NewClient - Token: workspace.TokenSource(), // reads auth.json; no refresh + // The key is consumed and wiped by NewClient. + Credentials: stackencrypt.NewCredentials(clientID, clientKey, source), }) if err != nil { return err @@ -54,18 +64,15 @@ func run(ctx context.Context) error { ``` `stackauth.ClientKey` and `stackencrypt.ClientKey` are one type, so the -key goes straight from the profile into the config. The token source -refuses a token at its real expiry with `stackauth.ErrTokenExpired`. - -For a refreshing device session, replace `workspace.TokenSource()` with a -strategy and close it after the client: - -```go -source, err := workspace.DeviceSession(ctx) -if err != nil { return err } -defer source.Close() -// stackencrypt.Config{Token: source, ...} -``` +key goes straight from the profile into the credentials. For a token that +is read and never refreshed, `workspace.TokenSource()` re-reads auth.json +on every call and refuses a token at its real expiry with +`stackauth.ErrTokenExpired`. + +With no profile directory at all (CI, a container, a server authenticating +by federation), `stackauth.OpenWithoutProfile(ctx)` runs the guest with +nothing mounted: the access-key and OIDC strategies work, and every profile +read is `ErrNoProfile`. `profile.AccessKey(ctx, crn, key)`, `profile.OIDC(ctx, crn, provider)`, and `profile.Auto(ctx)` also return strategies that satisfy diff --git a/languages/golang/stackauth/clientkey.go b/languages/golang/stackauth/clientkey.go index 1affdd36f..e71c1e525 100644 --- a/languages/golang/stackauth/clientkey.go +++ b/languages/golang/stackauth/clientkey.go @@ -6,6 +6,7 @@ import "github.com/cipherstash/cipherstash-suite/bindings/go/internal/guest" // out of secretkey.json: opaque (it prints a redaction under every verb and // hands its bytes to no caller) and wiped once consumed. It is the same // type as stackencrypt.ClientKey, by identity, so a key read here goes -// straight into a stackencrypt.Config without either package importing the -// other. +// straight into stackencrypt.NewCredentials. This package does not import +// stackencrypt: a binary that only wants the profile does not carry the +// crypto guest. (stackencrypt imports this one, for AutoCredentials.) type ClientKey = guest.ClientKey diff --git a/languages/golang/stackauth/doc.go b/languages/golang/stackauth/doc.go index 966ac7081..1a20347e9 100644 --- a/languages/golang/stackauth/doc.go +++ b/languages/golang/stackauth/doc.go @@ -8,7 +8,8 @@ // // A [ProfileStore] is one guest instance over one mounted directory. [Resolve] // finds the profile directory the way the Rust crate does (CS_CONFIG_PATH, -// then ~/.cipherstash); [Open] takes one. The guest is given that directory +// then ~/.cipherstash); [Open] takes one; [OpenWithoutProfile] mounts +// nothing, for the access-key and OIDC strategies where there is no profile. The guest is given that directory // and nothing else: no environment, no other path, and no way out through a // symlink inside it, which the mount refuses to follow. Authentication HTTP // requests go through the Go host's transport import. @@ -20,7 +21,7 @@ // one workspace ([ProfileStore.WorkspaceStore], // [ProfileStore.CurrentWorkspaceStore]), and the typed reads of the files a // workspace holds: [ProfileStore.SecretKey] hands out the ZeroKMS client key -// as the opaque [ClientKey] that stackencrypt's Config takes, +// as the opaque [ClientKey] that stackencrypt.NewCredentials takes, // [ProfileStore.Token] the stored access token, [ProfileStore.DeviceIdentity] // the identity the CLI created. [ProfileStore.Close] releases the guest; // stores scoped from it are closed with it. diff --git a/languages/golang/stackauth/guest.go b/languages/golang/stackauth/guest.go index 4710f792e..df8d9b2b6 100644 --- a/languages/golang/stackauth/guest.go +++ b/languages/golang/stackauth/guest.go @@ -84,24 +84,32 @@ type instance struct { // should be predictable across instances. The clocks are the system's for // the same reason they are in stackencrypt: a deterministic default is the // wrong default for anything that reads time. Each is pinned by a test. +// +// A nil mount is a guest with no directory at all ([OpenWithoutProfile]): +// no filesystem is configured, so there is nothing to mount or confine. func guestModuleConfig(mount *confinedFS) wazero.ModuleConfig { - fsConfig := wazero.NewFSConfig().(sysfs.FSConfig).WithSysFSMount(mount, guestRoot) - return wazero.NewModuleConfig(). + config := wazero.NewModuleConfig(). WithName("stack_auth_guest"). - WithFSConfig(fsConfig). WithRandSource(rand.Reader). WithSysNanotime(). WithSysWalltime() + if mount == nil { + return config + } + return config.WithFSConfig(wazero.NewFSConfig().(sysfs.FSConfig).WithSysFSMount(mount, guestRoot)) } // newInstance instantiates wasm with hostDir mounted at guestRoot and its -// linear memory from the guest packages' allocator. Under the strict -// policy, memory that cannot be locked fails instantiation with -// ErrMemoryLock. +// linear memory from the guest packages' allocator. An empty hostDir mounts +// nothing. Under the strict policy, memory that cannot be locked fails +// instantiation with ErrMemoryLock. func newInstance(ctx context.Context, wasm []byte, hostDir string, policy guest.LockPolicy, rt http.RoundTripper) (*instance, error) { - mount, err := newConfinedFS(hostDir) - if err != nil { - return nil, fmt.Errorf("%w: %s: %w", ErrNoProfile, hostDir, err) + var mount *confinedFS + if hostDir != "" { + var err error + if mount, err = newConfinedFS(hostDir); err != nil { + return nil, fmt.Errorf("%w: %s: %w", ErrNoProfile, hostDir, err) + } } config := wazero.NewRuntimeConfig(). WithCompilationCache(compilationCache()). @@ -109,7 +117,9 @@ func newInstance(ctx context.Context, wasm []byte, hostDir string, policy guest. runtime := wazero.NewRuntimeWithConfig(ctx, config) fail := func(err error) (*instance, error) { _ = runtime.Close(ctx) - _ = mount.Close() + if mount != nil { + _ = mount.Close() + } return nil, err } if _, err := wasi_snapshot_preview1.Instantiate(ctx, runtime); err != nil { @@ -181,6 +191,9 @@ func (inst *instance) release() error { inst.mem.Exit() } err := inst.runtime.Close(ctx) + if inst.mount == nil { + return err + } if cerr := inst.mount.Close(); err == nil { err = cerr } diff --git a/languages/golang/stackauth/store.go b/languages/golang/stackauth/store.go index 77d4102e8..a9b512aae 100644 --- a/languages/golang/stackauth/store.go +++ b/languages/golang/stackauth/store.go @@ -84,7 +84,7 @@ func Resolve(ctx context.Context, opts ...Option) (*ProfileStore, error) { if strings.TrimSpace(dir) == "" { home, err := os.UserHomeDir() if err != nil { - return nil, fmt.Errorf("stackauth: no home directory and CS_CONFIG_PATH is unset: %w", err) + return nil, fmt.Errorf("%w: no home directory and CS_CONFIG_PATH is unset: %w", ErrNoProfile, err) } dir = filepath.Join(home, ".cipherstash") } @@ -123,12 +123,44 @@ func Open(ctx context.Context, dir string, opts ...Option) (*ProfileStore, error return &ProfileStore{root: r, dir: guestRoot}, nil } +// OpenWithoutProfile instantiates the guest with no directory mounted: no +// filesystem at all. It is for the strategies that need no profile — +// [ProfileStore.AccessKey] and [ProfileStore.OIDC] — where there is none to +// open: CI, a container, a server authenticating by federation. Every +// profile read on it fails with ErrNoProfile, and [ProfileStore.Auto] on it +// is the environment's access key or ErrNotAuthenticated, as stack-auth's +// AutoStrategy is with no profile store. +func OpenWithoutProfile(ctx context.Context, opts ...Option) (*ProfileStore, error) { + var o options + for _, opt := range opts { + opt(&o) + } + wasm := o.guest + if wasm == nil { + var err error + if wasm, err = embeddedGuest(); err != nil { + return nil, err + } + } + inst, err := newInstance(ctx, wasm, "", guest.PolicyFor(o.requireLocked), o.transport) + if err != nil { + return nil, err + } + r := &root{inst: inst} + r.cleanup = runtime.AddCleanup(r, func(inst *instance) { _ = inst.release() }, inst) + return &ProfileStore{root: r, dir: guestRoot}, nil +} + // Dir is the store's directory on the host: the profile root, or the -// workspace directory under it. +// workspace directory under it. Empty for a store from +// [OpenWithoutProfile]. func (s *ProfileStore) Dir() string { return s.hostPath(s.dir) } // hostPath maps a guest path under the mount to the host path it names. func (s *ProfileStore) hostPath(guestPath string) string { + if s.root.hostDir == "" { + return "" + } rel := strings.TrimPrefix(guestPath, guestRoot) parts := strings.Split(strings.TrimPrefix(rel, "/"), "/") return filepath.Join(append([]string{s.root.hostDir}, parts...)...) @@ -183,6 +215,11 @@ type export func(*instance) api.Function // call runs one export under the profile's lock, closing the profile if // the guest trapped or an interrupted call took the module down. func (s *ProfileStore) call(ctx context.Context, fn export, args ...string) ([]byte, error) { + // Every profile export works on the mount; without one the guest would + // report an I/O error that says less than this does. + if s.root.hostDir == "" { + return nil, ErrNoProfile + } staged := make([]guest.Arg, 0, len(args)+1) staged = append(staged, guest.BufArg([]byte(s.dir))) for _, arg := range args { @@ -327,7 +364,7 @@ func (s *ProfileStore) LockPath(ctx context.Context, filename string) (string, e // SecretKey reads secretkey.json in this store (a workspace store; the // root holds none): the ZeroKMS client id and the client key, the latter as -// the opaque [ClientKey] a stackencrypt.Config takes. The transport copy +// the opaque [ClientKey] stackencrypt.NewCredentials takes. The transport copy // of the key is wiped once it is in the ClientKey; the key is then the // caller's to consume. func (s *ProfileStore) SecretKey(ctx context.Context) (clientID string, key *ClientKey, err error) { diff --git a/languages/golang/stackauth/strategy_test.go b/languages/golang/stackauth/strategy_test.go index 690a0a21f..6baf22280 100644 --- a/languages/golang/stackauth/strategy_test.go +++ b/languages/golang/stackauth/strategy_test.go @@ -575,3 +575,48 @@ func TestAuthTransportErrorWithoutAResponseNamesNoStatus(t *testing.T) { t.Fatalf("Token error = %v, want a bare ErrAuthTransport", err) } } + +// A store with no profile mounted still runs the strategies that need none: +// the environment's access key through Auto, as stack-auth's AutoStrategy +// does with no profile store. Profile reads are ErrNoProfile, and Auto with +// no access key is ErrNotAuthenticated rather than a profile error. +func TestOpenWithoutProfileRunsAccessKeyAndRefusesProfileReads(t *testing.T) { + guestOrSkip(t) + var jwt string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprintf(w, `{"accessToken":%q,"expiry":%d}`, jwt, time.Now().Add(time.Hour).Unix()) + })) + defer server.Close() + jwt = testJWT(t, server.URL) + ctx := context.Background() + store, err := OpenWithoutProfile(ctx) + if err != nil { + t.Fatal(err) + } + defer store.Close() + if store.Dir() != "" { + t.Errorf("Dir = %q, want empty", store.Dir()) + } + if _, err := store.CurrentWorkspace(ctx); !errors.Is(err, ErrNoProfile) { + t.Errorf("CurrentWorkspace: %v, want ErrNoProfile", err) + } + if _, _, err := store.SecretKey(ctx); !errors.Is(err, ErrNoProfile) { + t.Errorf("SecretKey: %v, want ErrNoProfile", err) + } + t.Setenv("CS_CLIENT_ACCESS_KEY", "CSAKtestKeyId.testKeySecret") + t.Setenv("CS_WORKSPACE_CRN", testCRN) + strategy, err := store.Auto(ctx, WithAuthBaseURL(server.URL)) + if err != nil { + t.Fatal(err) + } + defer strategy.Close() + if token, err := strategy.Token(ctx); err != nil || token != jwt { + t.Fatalf("Token = %q, %v", token, err) + } + if err := os.Unsetenv("CS_CLIENT_ACCESS_KEY"); err != nil { + t.Fatal(err) + } + if _, err := store.Auto(ctx); !errors.Is(err, ErrNotAuthenticated) { + t.Fatalf("Auto with no key and no profile: %v, want ErrNotAuthenticated", err) + } +} diff --git a/languages/golang/stackauth/token.go b/languages/golang/stackauth/token.go index 1241e5432..eb8dd8e3e 100644 --- a/languages/golang/stackauth/token.go +++ b/languages/golang/stackauth/token.go @@ -71,7 +71,7 @@ func (s *ProfileStore) Token(ctx context.Context) (Token, error) { } // TokenSource is a bearer-token source over the token stored in a -// workspace's auth.json, in the shape stackencrypt's Config.Token takes. +// workspace's auth.json, in the shape stackencrypt's TokenSource takes. // Every call re-reads the file, so a login or refresh by the CLI in // another terminal is picked up without a restart, and a token at or past // its real expiry is refused with [ErrTokenExpired] rather than presented. diff --git a/languages/golang/stackencrypt/README.md b/languages/golang/stackencrypt/README.md index 3132aa02c..7ec5df2f7 100644 --- a/languages/golang/stackencrypt/README.md +++ b/languages/golang/stackencrypt/README.md @@ -28,19 +28,14 @@ safe for concurrent use. ```go import ( "context" - "os" "github.com/cipherstash/cipherstash-suite/bindings/go/stackencrypt" ) func run(ctx context.Context) error { - client, err := stackencrypt.NewClient(ctx, stackencrypt.Config{ - ClientID: os.Getenv("CS_CLIENT_ID"), - ClientKey: stackencrypt.NewClientKey([]byte(os.Getenv("CS_CLIENT_KEY"))), - Token: stackencrypt.StaticToken(os.Getenv("CS_CLIENT_ACCESS_KEY")), - }) + client, err := stackencrypt.NewClient(ctx, stackencrypt.Config{}) if err != nil { - return err + return err // stackencrypt.ErrNoCredentials: nothing configured } defer client.Close() @@ -57,18 +52,50 @@ keyset, and `ctx` bounds that request. It has nothing to do with an `stackencrypt.Context`. Every method that can reach ZeroKMS takes a `context.Context` first, for the same reason. -`Token` supplies the bearer token for every request. `StaticToken` is the -simplest source; a `TokenFunc` can fetch or refresh one. +### Credentials + +The zero `Config` finds its credentials the way the Rust client does, with +`AutoCredentials`: the environment first, then the developer profile that +`stash auth login` writes. On a developer machine, logging in is enough. +In CI or a deployment, four variables are: + +| Variable | | +|---|---| +| `CS_CLIENT_ACCESS_KEY`, `CS_WORKSPACE_CRN` | An access key, exchanged for a token. Without it, the current workspace's stored session is used, and refreshed as it expires. | +| `CS_CLIENT_ID`, `CS_CLIENT_KEY` | The client key, used when both are set. Without them, the current workspace's `secretkey.json` is used. | +| `CS_ZEROKMS_HOST` (or `CS_VITUR_HOST`) | Pins the ZeroKMS endpoint. Otherwise it comes from the token. Read whatever the credentials. | +| `CS_CTS_HOST` | Overrides the authentication endpoint. | +| `CS_CONFIG_PATH` | The profile directory, instead of `~/.cipherstash`. | + +A variable that is set but empty or unusable is an error, not a reason to +look elsewhere. Nothing found is `ErrNoCredentials`, naming what to set. + +Resolution happens host-side, in Go. The profile and the token strategies +run in `stackauth`'s credential guest; the crypto guest that holds the +keys is still given no environment and no filesystem. The credential guest +lives as long as the client, and `Close` releases it. + +To supply the credentials yourself, pass `NewCredentials`: + +```go +client, err := stackencrypt.NewClient(ctx, stackencrypt.Config{ + Credentials: stackencrypt.NewCredentials(clientID, clientKey, tokenSource), +}) +``` + +`tokenSource` is asked for the bearer token on every request. `StaticToken` +is the simplest source; a `TokenFunc` can fetch or refresh one, and every +`stackauth` strategy is one. `Credentials` is an interface, so another +source of credentials can implement it. `ClientKey` is an opaque type, not a string: it prints a redaction under -every verb, so a logged `Config` never shows the key. `NewClientKey` takes +every verb, so logged credentials never show the key. `NewClientKey` takes ownership of the slice it is given, and `NewClient` consumes the key — whatever the outcome, even a config it refuses, the key is empty afterwards and that slice is zero. A key is for one client; build another for another -client. What the SDK cannot reach is what the key was built *from*: the -`os.Getenv` string above is Go's, immutable, and lives until collected. -Read the key from the developer profile through `stackauth` where you can, -and where an environment variable is the source, treat the process +client. What the SDK cannot reach is what the key was built *from*: a +string read from the environment is Go's, immutable, and lives until +collected. Where an environment variable is the source, treat the process environment as holding the key for the life of the process. ### Key material in memory @@ -128,11 +155,12 @@ small and reveals nothing about plaintext or key material. | `ErrForeignKeyset` | A keyset-bound `Cipher` was given another keyset's ciphertext. Open it through the `Client`. | | `ErrEncoding` | Malformed input: a value, ciphertext, plan, context or config refused before any cryptography. | | `ErrTerm` | A term could not be derived, for example match text that yields no tokens. | -| `ErrTransport` | ZeroKMS could not be reached. | +| `ErrTransport` | ZeroKMS could not be reached, or the token source failed. A token source's own error is wrapped in it, so `errors.Is` finds that too (a refused refresh is `stackauth.ErrInvalidGrant`). | | `ErrKMS` | Any other ZeroKMS failure. | | `ErrConflict` | ZeroKMS reported a resource conflict. | | `ErrState` | The client has been closed: by `Close`, by a call its context interrupted, or by a guest trap. | | `ErrMemoryLock` | The instance's memory could not be locked in RAM. Returned by `NewClient` under `RequireLockedMemory`, and by a call whose growth could not be locked; otherwise reported by `MemoryLockError`. | +| `ErrNoCredentials` | `NewClient` found no token source or no client key, in the environment or the profile. The message names what to set. | | `ErrInternal` | An unexpected failure inside the guest. | ## How it works under the hood diff --git a/languages/golang/stackencrypt/client.go b/languages/golang/stackencrypt/client.go index a2731af3d..818093af5 100644 --- a/languages/golang/stackencrypt/client.go +++ b/languages/golang/stackencrypt/client.go @@ -15,21 +15,23 @@ import ( "github.com/cipherstash/vitaminc/bindings/go/vcvalue" ) -// Config configures a [Client]. +// Config configures a [Client]. The zero Config is a working one: every +// field has a default, and the credentials default to [AutoCredentials]. type Config struct { - // ClientID is the ZeroKMS client id (a UUID string). Required. - ClientID string - // ClientKey is the v1 client key material: hex (the CS_CLIENT_KEY form, - // either case) or standard padded base64 (the secretkey.json form), - // wrapped by [NewClientKey] or read from the developer profile by - // stackauth. Required. It is consumed: NewClient marshals it into the - // config buffer, wipes the key, and wipes the buffer once the guest has - // the key, so after NewClient returns — whatever the outcome, a config - // it refused included — the ClientKey is empty and the bytes it was - // built from are zero. A key is for one client. - ClientKey *ClientKey - // ZeroKMSURL pins the ZeroKMS endpoint. When empty the endpoint is - // resolved from the access token's services claim on first use. + // Credentials supplies the client id, the client key and the token + // source. Nil means [AutoCredentials]: the environment, then the + // developer profile. [NewCredentials] takes the three explicitly. The + // client key is consumed: NewClient marshals it into the config + // buffer, wipes the key, and wipes the buffer once the guest has the + // key, so after NewClient returns — whatever the outcome, a config it + // refused included — the key is empty and the bytes it was built from + // are zero. A key is for one client. + Credentials Credentials + // ZeroKMSURL pins the ZeroKMS endpoint. When empty, CS_ZEROKMS_HOST + // (or the legacy CS_VITUR_HOST) pins it if set — a set value that is + // not an http(s) URL is an error — and otherwise the endpoint is + // resolved from the access token's services claim on first use. The + // variables are read whatever the Credentials, as stack-kms reads them. ZeroKMSURL string // KeysetCacheSize is how many keysets beyond the default the guest keeps // loaded; zero means the crate default (1024). @@ -37,8 +39,6 @@ type Config struct { // Transport performs the HTTP requests to ZeroKMS. Nil means // http.DefaultTransport. Transport http.RoundTripper - // Token supplies the bearer token for every request. Required. - Token TokenSource // Guest overrides the embedded wasm module. Nil means the embedded one. Guest []byte // RequireLockedMemory makes NewClient fail with ErrMemoryLock when the @@ -85,35 +85,67 @@ type Client struct { closed bool released bool def KeysetID + // releaseCredentials is the resolved credentials' Close, run once by + // Close. Nil when they hold nothing open. + releaseCredentials func() error // cleanup releases the instance if the Client becomes unreachable // without Close: the forgot-to-close case in a running process. It // does nothing at process exit, and is not meant to. cleanup runtime.Cleanup } -// NewClient instantiates the guest, loads the client key into it, and loads -// the default keyset — one ZeroKMS round trip. The returned client is ready -// to seal. -func NewClient(ctx context.Context, cfg Config) (*Client, error) { - // The key is consumed whatever happens below: a config refused before - // the key is marshalled must not hand it back live. Nil-safe, and a - // no-op after the wipe on the accepted path. - defer cfg.ClientKey.Wipe() - if cfg.Token == nil { - return nil, errors.New("stackencrypt: Config.Token is required") +// NewClient resolves the credentials, instantiates the guest, loads the +// client key into it, and loads the default keyset — one ZeroKMS round trip, +// which is where credentials that resolve but do not work fail: a token that +// cannot be minted or is refused fails here, not at first use. The returned +// client is ready to seal. +func NewClient(ctx context.Context, cfg Config) (_ *Client, err error) { + rt := cfg.Transport + if rt == nil { + rt = http.DefaultTransport + } + creds := cfg.Credentials + if creds == nil { + creds = AutoCredentials() + } + // Resolved before anything is validated, so the key is in hand to be + // consumed whatever happens below: a config refused before the key is + // marshalled must not hand it back live. + resolved, err := creds.Resolve(ctx, ResolveOptions{Transport: rt, RequireLockedMemory: cfg.RequireLockedMemory}) + if err != nil { + return nil, err + } + if resolved == nil { + return nil, errors.New("stackencrypt: Credentials.Resolve returned no credentials") + } + // Nil-safe, and a no-op after the wipe on the accepted path. + defer resolved.ClientKey.Wipe() + // The credentials are the client's to release once it exists; until + // then, NewClient's. + defer func() { + if err != nil && resolved.Close != nil { + _ = resolved.Close() + } + }() + if resolved.Token == nil { + return nil, errors.New("stackencrypt: the credentials have no token source") + } + zerokmsURL, err := zerokmsEndpoint(cfg.ZeroKMSURL) + if err != nil { + return nil, err } wasm := cfg.Guest if wasm == nil { - var err error if wasm, err = embeddedGuest(); err != nil { return nil, err } } - rt := cfg.Transport - if rt == nil { - rt = http.DefaultTransport - } - encoded, err := encodeConfig(cfg) + encoded, err := encodeConfig(initConfig{ + clientID: resolved.ClientID, + clientKey: resolved.ClientKey, + zerokmsURL: zerokmsURL, + keysetCacheSize: cfg.KeysetCacheSize, + }) if err != nil { return nil, err } @@ -122,9 +154,9 @@ func NewClient(ctx context.Context, cfg Config) (*Client, error) { // is wiped once the guest has it. Wiping the key here rather than after // the init call keeps the exposure to one copy from this point on, // whatever the init's outcome. - cfg.ClientKey.Wipe() + resolved.ClientKey.Wipe() - t := &transport{rt: rt, token: cfg.Token} + t := &transport{rt: rt, token: resolved.Token} inst, err := newInstance(ctx, wasm, t, guest.PolicyFor(cfg.RequireLockedMemory)) if err != nil { return nil, err @@ -142,6 +174,7 @@ func NewClient(ctx context.Context, cfg Config) (*Client, error) { return nil, fmt.Errorf("%w: cipher init returned %d bytes for the keyset id", ErrInternal, len(out)) } copy(c.def[:], out) + c.releaseCredentials = resolved.Close return c, nil } @@ -186,15 +219,24 @@ func (c *Client) String() string { // false, memory_lock_error. func (c *Client) LogValue() slog.Value { return c.inst.mem.LogValue() } +// initConfig is what se_cipher_init takes: the resolved credentials' id +// and key, and the settings that reach the guest. +type initConfig struct { + clientID string + clientKey *ClientKey + zerokmsURL string + keysetCacheSize int +} + // encodeConfig renders the se_cipher_init object. The result holds the // client key; the caller wipes it, and the key it was read from. -func encodeConfig(cfg Config) ([]byte, error) { - if cfg.ClientID == "" || cfg.ClientKey.IsZero() { - return nil, errors.New("stackencrypt: Config.ClientID and Config.ClientKey are required") +func encodeConfig(cfg initConfig) ([]byte, error) { + if cfg.clientID == "" || cfg.clientKey.IsZero() { + return nil, errors.New("stackencrypt: the credentials' client id and client key are required") } // Every refusal comes before the key is copied, so a rejected config // leaves nothing but the key itself, which the caller wipes. - if cfg.KeysetCacheSize < 0 { + if cfg.keysetCacheSize < 0 { return nil, errors.New("stackencrypt: Config.KeysetCacheSize must not be negative") } // The key crosses as text: the guest's config parser takes the hex or @@ -206,14 +248,14 @@ func encodeConfig(cfg Config) ([]byte, error) { // them: the copies of the key this package cannot zero, accepted for the // length of NewClient. A marshaller that took bytes would remove both. fields := vcvalue.Object{ - {Key: "client_id", Value: cfg.ClientID}, - {Key: "client_key", Value: string(guest.KeyBytes(cfg.ClientKey))}, + {Key: "client_id", Value: cfg.clientID}, + {Key: "client_key", Value: string(guest.KeyBytes(cfg.clientKey))}, } - if cfg.ZeroKMSURL != "" { - fields = append(fields, vcvalue.Field{Key: "zerokms_url", Value: cfg.ZeroKMSURL}) + if cfg.zerokmsURL != "" { + fields = append(fields, vcvalue.Field{Key: "zerokms_url", Value: cfg.zerokmsURL}) } - if cfg.KeysetCacheSize > 0 { - fields = append(fields, vcvalue.Field{Key: "keyset_cache_size", Value: strconv.Itoa(cfg.KeysetCacheSize)}) + if cfg.keysetCacheSize > 0 { + fields = append(fields, vcvalue.Field{Key: "keyset_cache_size", Value: strconv.Itoa(cfg.keysetCacheSize)}) } return vcffi.Marshal(fields) } @@ -237,7 +279,11 @@ func (c *Client) Close() error { c.released = true c.closed = true c.cleanup.Stop() - return c.inst.release() + err := c.inst.release() + if c.releaseCredentials != nil { + err = errors.Join(err, c.releaseCredentials()) + } + return err } // Keyset binds the client to one keyset, by name or by id: Rust's @@ -328,6 +374,9 @@ func (c *Client) call(ctx context.Context, f func(*instance) ([]byte, error)) ([ return nil, ErrState } growth := c.inst.mem.GrowthRefusal() + if c.transport != nil { + c.transport.tokenErr = nil + } out, err := f(c.inst) switch { case c.inst.module.IsClosed(): @@ -351,6 +400,11 @@ func (c *Client) call(ctx context.Context, f func(*instance) ([]byte, error)) ([ if g := c.inst.mem.GrowthRefusal(); err != nil && g.Refused != growth.Refused { err = fmt.Errorf("%w (growth refused under RequireLockedMemory): %w", guest.MemoryLockError(g.Reason), err) } + // The guest reports a failed token_get as a transport failure and no + // more; the token source said why. + if err != nil && c.transport != nil && c.transport.tokenErr != nil { + err = fmt.Errorf("%w (token source: %w)", err, c.transport.tokenErr) + } if err != nil { return nil, err } diff --git a/languages/golang/stackencrypt/clientkey.go b/languages/golang/stackencrypt/clientkey.go index df22bc542..ad55ea88e 100644 --- a/languages/golang/stackencrypt/clientkey.go +++ b/languages/golang/stackencrypt/clientkey.go @@ -8,8 +8,9 @@ import "github.com/cipherstash/cipherstash-suite/bindings/go/internal/guest" // // It is the one type both guest packages share: stackauth reads one out of // the developer profile, and this package consumes it. The alias is what -// makes a key read there the type taken here without either package -// importing the other. +// makes a key read there the type taken here without stackauth importing +// this package, so a binary that only wants the profile does not carry the +// crypto guest. type ClientKey = guest.ClientKey // NewClientKey wraps key material — the CS_CLIENT_KEY hex form, or the diff --git a/languages/golang/stackencrypt/credentials.go b/languages/golang/stackencrypt/credentials.go new file mode 100644 index 000000000..cfcb65ccc --- /dev/null +++ b/languages/golang/stackencrypt/credentials.go @@ -0,0 +1,273 @@ +package stackencrypt + +import ( + "context" + "errors" + "fmt" + "net/http" + "net/url" + "os" + + "github.com/cipherstash/cipherstash-suite/bindings/go/stackauth" +) + +// Credentials is where a [Client]'s ZeroKMS credentials come from: the +// client id, the client key, and the source of the bearer token. NewClient +// resolves them once, host-side — the crypto guest is never given the +// environment or a filesystem to look them up itself — and hands the key to +// the guest. +// +// [AutoCredentials] is the default: the environment, then the developer +// profile, in the Rust client's order. [NewCredentials] takes the three +// values explicitly. Any other type can implement it; see +// [ResolvedCredentials] for what an implementation owes the client. +type Credentials interface { + // Resolve produces the credentials for one client. NewClient calls it + // once, and consumes the key it returns. + Resolve(ctx context.Context, opts ResolveOptions) (*ResolvedCredentials, error) +} + +// ResolveOptions is what NewClient tells a [Credentials] about the client +// it is resolving for, so a source that makes requests or holds key +// material of its own can do so under the client's settings. +type ResolveOptions struct { + // Transport is the client's RoundTripper (never nil). AutoCredentials + // sends its authentication requests through it too. + Transport http.RoundTripper + // RequireLockedMemory is the client's setting. AutoCredentials applies + // it to the credential guest, which holds the client key while it + // reads it and the token strategy for the life of the client. + RequireLockedMemory bool +} + +// ResolvedCredentials is one client's credentials, as a [Credentials] +// resolved them. +type ResolvedCredentials struct { + // ClientID is the ZeroKMS client id (a UUID string). + ClientID string + // ClientKey is the client key. NewClient consumes it whatever the + // outcome, as [NewClientKey] describes. + ClientKey *ClientKey + // Token supplies the bearer token for every request. + Token TokenSource + // Close, when not nil, releases what the credentials hold open — the + // profile's guest and a refreshing token strategy, for AutoCredentials. + // The client calls it from Client.Close, or from NewClient when the + // client is not made. A Token that outlives it must not be asked again. + Close func() error +} + +// ErrNoCredentials is [AutoCredentials] finding no token source or no +// client key in either place it looks. The wrapped error says which, and +// what to set. +var ErrNoCredentials = errors.New("stackencrypt: no credentials") + +// NewCredentials is [Credentials] from explicit values: a client id, a +// client key (from [NewClientKey], or stackauth's typed read), and a token +// source. The key is consumed by the first NewClient given these +// credentials; a second is refused, as a key is for one client. +func NewCredentials(clientID string, key *ClientKey, token TokenSource) Credentials { + return &explicitCredentials{clientID: clientID, key: key, token: token} +} + +type explicitCredentials struct { + clientID string + key *ClientKey + token TokenSource +} + +func (c *explicitCredentials) Resolve(context.Context, ResolveOptions) (*ResolvedCredentials, error) { + return &ResolvedCredentials{ClientID: c.clientID, ClientKey: c.key, Token: c.token}, nil +} + +// String names the credentials' kind and client id; the key prints a +// redaction under every verb anyway, but nothing here asks it to. +func (c *explicitCredentials) String() string { + return fmt.Sprintf("stackencrypt.NewCredentials{client_id: %s}", c.clientID) +} + +// The environment variables AutoCredentials and NewClient read. The profile +// directory's own, CS_CONFIG_PATH, is stackauth's; so is CS_CTS_HOST, which +// its strategies take as the authentication endpoint. +const ( + envClientID = "CS_CLIENT_ID" + envClientKey = "CS_CLIENT_KEY" + envAccessKey = "CS_CLIENT_ACCESS_KEY" + envWorkspaceCRN = "CS_WORKSPACE_CRN" +) + +// envZeroKMSHost is the endpoint override, in the order stack-kms reads it: +// the first that is set decides, and CS_VITUR_HOST is the legacy name. +var envZeroKMSHost = []string{"CS_ZEROKMS_HOST", "CS_VITUR_HOST"} + +// AutoCredentials is [Credentials] from the environment first, then the +// developer profile `stash auth login` writes — the order the Rust client +// (stack-encrypt's StackCipher::builder().init()) resolves them in, so one +// set of variables configures a service in either language: +// +// - The token, by stack-auth's AutoStrategy order: CS_CLIENT_ACCESS_KEY +// (with CS_WORKSPACE_CRN, which is then required) exchanged for a token; +// else the current workspace's stored device session, refreshed under +// the same cross-process lock as the CLI. CS_CTS_HOST overrides the +// authentication endpoint. +// - The client key: CS_CLIENT_ID and CS_CLIENT_KEY when both are set (the +// hex form, or the base64 of secretkey.json); else the current +// workspace's secretkey.json. Only one of the two set is the same as +// neither. Set but empty is an error, not a fall-through. +// +// The profile is CS_CONFIG_PATH, else ~/.cipherstash; a directory that does +// not exist is not an error — an environment-only deployment has none — it +// just leaves the environment as the only source. Nothing found in either +// place is [ErrNoCredentials]. +// +// The profile and the token strategies run in stackauth's credential guest, +// not in the crypto guest, which still sees no environment and no +// filesystem. The credential guest lives as long as the client, and +// Client.Close releases it. +func AutoCredentials() Credentials { return autoCredentials{} } + +type autoCredentials struct{} + +// String names the credentials' kind; nothing is resolved to print it. +func (autoCredentials) String() string { return "stackencrypt.AutoCredentials" } + +func (autoCredentials) Resolve(ctx context.Context, opts ResolveOptions) (resolved *ResolvedCredentials, err error) { + authOpts := []stackauth.Option{stackauth.WithRoundTripper(opts.Transport)} + if opts.RequireLockedMemory { + authOpts = append(authOpts, stackauth.RequireLockedMemory()) + } + profile, err := stackauth.Resolve(ctx, authOpts...) + hasProfile := err == nil + if errors.Is(err, stackauth.ErrNoProfile) { + // No profile directory: the strategies that need none still run, + // in a guest with nothing mounted. + profile, err = stackauth.OpenWithoutProfile(ctx, authOpts...) + } + if err != nil { + return nil, fmt.Errorf("stackencrypt: credentials: %w", err) + } + defer func() { + if err != nil { + _ = profile.Close() + } + }() + + // The token first, as Rust detects its strategy before it asks the key + // provider: with neither configured, the error names the token. + strategy, err := profile.Auto(ctx) + switch { + case errors.Is(err, stackauth.ErrNotAuthenticated): + return nil, fmt.Errorf("%w: no token: set %s and %s, or run `stash auth login`: %w", + ErrNoCredentials, envAccessKey, envWorkspaceCRN, err) + case errors.Is(err, stackauth.ErrAuthConfig): + return nil, fmt.Errorf("stackencrypt: credentials: check %s and %s: %w", envAccessKey, envWorkspaceCRN, err) + case err != nil: + return nil, fmt.Errorf("stackencrypt: credentials: %w", err) + } + defer func() { + if err != nil { + _ = strategy.Close() + } + }() + + clientID, key, err := clientKeyFromEnv() + if err != nil { + return nil, err + } + if key == nil { + if clientID, key, err = clientKeyFromProfile(ctx, profile, hasProfile); err != nil { + return nil, err + } + } + return &ResolvedCredentials{ + ClientID: clientID, + ClientKey: key, + Token: strategy, + Close: func() error { + // The strategy lives in the profile's guest, which closing the + // profile would free anyway; closing it first unregisters it + // cleanly. + return errors.Join(strategy.Close(), profile.Close()) + }, + }, nil +} + +// clientKeyFromEnv is stack-kms's EnvKeyProvider: both variables set is the +// key, either unset is no key (nil, and the profile is asked), and a set but +// empty value is an error, since falling through would quietly use a +// different key from the one the operator configured. The value is never +// part of an error. +func clientKeyFromEnv() (string, *ClientKey, error) { + id, ok := os.LookupEnv(envClientID) + if !ok { + return "", nil, nil + } + material, ok := os.LookupEnv(envClientKey) + if !ok { + return "", nil, nil + } + if id == "" || material == "" { + return "", nil, fmt.Errorf("%w: %s and %s are set, but one of them is empty", ErrEncoding, envClientID, envClientKey) + } + // The environment's string is Go's and cannot be wiped; this copy can, + // and the ClientKey owns it. + return id, NewClientKey([]byte(material)), nil +} + +// clientKeyFromProfile is the current workspace's secretkey.json. The +// "nothing there" answers — no profile, no current workspace, no file — are +// ErrNoCredentials; a file that is there but unreadable keeps its own error. +func clientKeyFromProfile(ctx context.Context, profile *stackauth.ProfileStore, hasProfile bool) (string, *ClientKey, error) { + notConfigured := func(err error) error { + return fmt.Errorf("%w: no client key: set %s and %s, or run `stash auth login`: %w", + ErrNoCredentials, envClientID, envClientKey, err) + } + if !hasProfile { + return "", nil, notConfigured(stackauth.ErrNoProfile) + } + workspace, err := profile.CurrentWorkspaceStore(ctx) + if errors.Is(err, stackauth.ErrNoCurrentWorkspace) { + return "", nil, notConfigured(err) + } + if err != nil { + return "", nil, fmt.Errorf("stackencrypt: credentials: %w", err) + } + clientID, key, err := workspace.SecretKey(ctx) + if errors.Is(err, stackauth.ErrNotFound) { + return "", nil, notConfigured(err) + } + if err != nil { + return "", nil, fmt.Errorf("stackencrypt: credentials: reading the client key: %w", err) + } + return clientID, key, nil +} + +// zerokmsEndpoint is the endpoint NewClient pins, in stack-kms's order: the +// explicit URL, else the first of CS_ZEROKMS_HOST and CS_VITUR_HOST that is +// set, else none (the token's services claim decides on first use). A set +// variable that is not a usable endpoint is an error, not a fall-through: +// falling through would send key operations somewhere the operator did not +// configure. The guest validates the URL in full; this check exists to name +// the variable, and never prints the value, which could carry userinfo. +func zerokmsEndpoint(explicit string) (string, error) { + if explicit != "" { + return explicit, nil + } + for _, name := range envZeroKMSHost { + value, ok := os.LookupEnv(name) + if !ok { + continue + } + u, err := url.Parse(value) + switch { + case err != nil: + return "", fmt.Errorf("%w: %s is not a URL", ErrEncoding, name) + case u.Scheme != "http" && u.Scheme != "https": + return "", fmt.Errorf("%w: %s must be an http or https URL", ErrEncoding, name) + case u.Host == "": + return "", fmt.Errorf("%w: %s has no host", ErrEncoding, name) + } + return value, nil + } + return "", nil +} diff --git a/languages/golang/stackencrypt/credentials_test.go b/languages/golang/stackencrypt/credentials_test.go new file mode 100644 index 000000000..26068eb5a --- /dev/null +++ b/languages/golang/stackencrypt/credentials_test.go @@ -0,0 +1,478 @@ +package stackencrypt + +import ( + "context" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/cipherstash/cipherstash-suite/bindings/go/internal/guest" + "github.com/cipherstash/cipherstash-suite/bindings/go/stackauth" +) + +// Credential resolution, pinned against the Rust client's order. The +// sources the order is taken from, so a change there is a change here: +// +// - the token: stack-auth's AutoStrategy::detect_inner — an access key +// from CS_CLIENT_ACCESS_KEY (CS_WORKSPACE_CRN then required), else the +// current workspace's auth.json, else NotAuthenticated; +// - the client key: stack-encrypt's client_key_provider (cipher.rs) — +// FallbackKeyProvider(EnvKeyProvider, the profile), falling through only +// on "not configured": both CS_CLIENT_ID and CS_CLIENT_KEY set, else the +// current workspace's secretkey.json; a set but unusable value is an +// error, not a fall-through; an unresolvable profile is not an error; +// - the order of the two: StackCipherBuilder::init detects the strategy +// (StackKmsBuilder::auto) before it asks the key provider (build); +// - the endpoint: stack-kms's StackKmsBuilder::base_url_from_env — an +// explicit URL, else the first of CS_ZEROKMS_HOST and CS_VITUR_HOST that +// is set, an unusable value an error; else the token's services claim. + +const ( + testCRN = "crn:ap-southeast-2.aws:ZVATKW3VHMFG27DY" + testAccessKey = "CSAKtestKeyId.testKeySecret" + testWorkspace = "AAAAAAAAAAAAAAAA" + // The profile's client id: distinct from testClientID, so a test can + // tell which source a key came from. + profileClientID = "0b1e8a44-5c1d-4d4e-9b52-3f0e6c2f8a17" +) + +// authGuestOrSkip skips where stackauth's credential guest is not built, +// as guestOrSkip does for this package's own. +func authGuestOrSkip(t *testing.T) { + t.Helper() + store, err := stackauth.OpenWithoutProfile(context.Background()) + if errors.Is(err, stackauth.ErrGuestNotBuilt) { + t.Skip(err) + } + if err != nil { + t.Fatal(err) + } + _ = store.Close() +} + +// cleanEnv clears every variable credential resolution reads, and points +// the profile at dir, so no test sees the developer's own credentials. +// t.Setenv restores each at the end of the test. +func cleanEnv(t *testing.T, dir string) { + t.Helper() + for _, name := range []string{ + envClientID, envClientKey, envAccessKey, envWorkspaceCRN, + "CS_ZEROKMS_HOST", "CS_VITUR_HOST", "CS_CTS_HOST", "CS_CONFIG_PATH", + } { + t.Setenv(name, "") + if err := os.Unsetenv(name); err != nil { + t.Fatal(err) + } + } + t.Setenv("CS_CONFIG_PATH", dir) + // The stored test tokens are not JWTs, so the device session cannot + // discover CTS from one; name it. Nothing is sent there while a token + // is fresh. newAuthServer points it at itself. + t.Setenv("CS_CTS_HOST", "https://cts.example.com") +} + +// profileFiles is what a login leaves in a workspace. An empty field is a +// file not written. +type profileFiles struct { + secretKey string + auth string +} + +// loggedIn is the profile `stash auth login` leaves: a current workspace +// holding the test client key under profileClientID and a fresh token. +func loggedIn(token string) profileFiles { + return profileFiles{ + secretKey: fmt.Sprintf(`{"client_id":%q,"client_key":%q}`, profileClientID, + base64.StdEncoding.EncodeToString(mustHex(testClientKey))), + auth: fmt.Sprintf(`{"access_token":%q,"refresh_token":"refresh","token_type":"Bearer","expires_at":%d,"region":"ap-southeast-2.aws","client_id":"cli"}`, + token, time.Now().Add(time.Hour).Unix()), + } +} + +// newProfile writes a profile directory with testWorkspace current and +// files in it, and returns the directory. +func newProfile(t *testing.T, files profileFiles) string { + t.Helper() + dir := t.TempDir() + ws := filepath.Join(dir, "workspaces", testWorkspace) + if err := os.MkdirAll(ws, 0o700); err != nil { + t.Fatal(err) + } + for name, content := range map[string]string{"secretkey.json": files.secretKey, "auth.json": files.auth} { + if content != "" { + if err := os.WriteFile(filepath.Join(ws, name), []byte(content), 0o600); err != nil { + t.Fatal(err) + } + } + } + store, err := stackauth.Open(context.Background(), dir) + if err != nil { + t.Fatal(err) + } + defer store.Close() + if err := store.SetCurrentWorkspace(context.Background(), testWorkspace); err != nil { + t.Fatal(err) + } + return dir +} + +// authServer is CTS's access-key exchange: it answers every /api/authorise +// with a fresh JWT, and counts the exchanges. +type authServer struct { + *httptest.Server + jwt string + calls atomic.Int32 +} + +func newAuthServer(t *testing.T) *authServer { + t.Helper() + s := &authServer{} + s.Server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/authorise" { + t.Errorf("unexpected auth request %s", r.URL.Path) + http.NotFound(w, r) + return + } + s.calls.Add(1) + fmt.Fprintf(w, `{"accessToken":%q,"expiry":%d}`, s.jwt, time.Now().Add(time.Hour).Unix()) + })) + t.Cleanup(s.Close) + payload, err := json.Marshal(map[string]any{ + "iss": s.URL, "sub": "CS|test", "workspace": "ZVATKW3VHMFG27DY", + "exp": time.Now().Add(time.Hour).Unix(), + }) + if err != nil { + t.Fatal(err) + } + s.jwt = "e30." + base64.RawURLEncoding.EncodeToString(payload) + ".c2ln" + t.Setenv("CS_CTS_HOST", s.URL) + return s +} + +// resolve runs AutoCredentials and releases what it holds at the end of +// the test. +func resolve(t *testing.T) (*ResolvedCredentials, error) { + t.Helper() + resolved, err := AutoCredentials().Resolve(context.Background(), ResolveOptions{Transport: http.DefaultTransport}) + if err == nil { + t.Cleanup(func() { _ = resolved.Close() }) + } + return resolved, err +} + +func token(t *testing.T, resolved *ResolvedCredentials) string { + t.Helper() + tok, err := resolved.Token.Token(context.Background()) + if err != nil { + t.Fatalf("Token: %v", err) + } + return tok +} + +func TestAutoCredentialsFromTheProfile(t *testing.T) { + authGuestOrSkip(t) + cleanEnv(t, newProfile(t, loggedIn("profile-token"))) + resolved, err := resolve(t) + if err != nil { + t.Fatal(err) + } + if resolved.ClientID != profileClientID { + t.Errorf("ClientID = %q, want the profile's", resolved.ClientID) + } + if got := string(guest.KeyBytes(resolved.ClientKey)); got != base64.StdEncoding.EncodeToString(mustHex(testClientKey)) { + t.Error("ClientKey is not the profile's secretkey.json") + } + if got := token(t, resolved); got != "profile-token" { + t.Errorf("Token = %q, want the stored device session's", got) + } +} + +func TestAutoCredentialsFromTheEnvironmentWithNoProfile(t *testing.T) { + authGuestOrSkip(t) + // The CI shape: four variables and no profile directory at all. + cleanEnv(t, filepath.Join(t.TempDir(), "absent")) + auth := newAuthServer(t) + t.Setenv(envAccessKey, testAccessKey) + t.Setenv(envWorkspaceCRN, testCRN) + t.Setenv(envClientID, testClientID) + t.Setenv(envClientKey, testClientKey) + resolved, err := resolve(t) + if err != nil { + t.Fatal(err) + } + if resolved.ClientID != testClientID || string(guest.KeyBytes(resolved.ClientKey)) != testClientKey { + t.Error("the client key is not the environment's") + } + if got := token(t, resolved); got != auth.jwt || auth.calls.Load() != 1 { + t.Errorf("Token = %q after %d exchanges, want the access key's", got, auth.calls.Load()) + } +} + +func TestAutoCredentialsPrecedence(t *testing.T) { + authGuestOrSkip(t) + t.Run("the environment's client key wins over the profile's", func(t *testing.T) { + cleanEnv(t, newProfile(t, loggedIn("profile-token"))) + t.Setenv(envClientID, testClientID) + t.Setenv(envClientKey, testClientKey) + resolved, err := resolve(t) + if err != nil { + t.Fatal(err) + } + if resolved.ClientID != testClientID || string(guest.KeyBytes(resolved.ClientKey)) != testClientKey { + t.Error("the client key is not the environment's") + } + // The token still comes from the profile: the halves resolve apart. + if got := token(t, resolved); got != "profile-token" { + t.Errorf("Token = %q, want the profile's", got) + } + }) + t.Run("one of the two key variables is the same as neither", func(t *testing.T) { + for _, name := range []string{envClientID, envClientKey} { + cleanEnv(t, newProfile(t, loggedIn("profile-token"))) + value := testClientID + if name == envClientKey { + value = testClientKey + } + t.Setenv(name, value) + resolved, err := resolve(t) + if err != nil { + t.Fatal(err) + } + if resolved.ClientID != profileClientID { + t.Errorf("only %s set: ClientID = %q, want the profile's", name, resolved.ClientID) + } + } + }) + t.Run("the environment's access key wins over the stored session", func(t *testing.T) { + cleanEnv(t, newProfile(t, loggedIn("profile-token"))) + auth := newAuthServer(t) + t.Setenv(envAccessKey, testAccessKey) + t.Setenv(envWorkspaceCRN, testCRN) + resolved, err := resolve(t) + if err != nil { + t.Fatal(err) + } + if got := token(t, resolved); got != auth.jwt { + t.Errorf("Token = %q, want the access key's", got) + } + // The key still comes from the profile. + if resolved.ClientID != profileClientID { + t.Errorf("ClientID = %q, want the profile's", resolved.ClientID) + } + }) +} + +func TestAutoCredentialsMissing(t *testing.T) { + authGuestOrSkip(t) + for _, tc := range []struct { + name string + // profile is nil for no profile directory at all. + profile *profileFiles + env map[string]string + want []error + names string + }{ + { + name: "nothing anywhere", + want: []error{ErrNoCredentials, stackauth.ErrNotAuthenticated}, + names: envAccessKey, + }, + { + name: "a profile with no login", + profile: &profileFiles{}, + want: []error{ErrNoCredentials, stackauth.ErrNotAuthenticated}, + names: "stash auth login", + }, + { + // The token is there and the key is not: the key's error, naming + // the variables that would supply it. + name: "an access key and no client key", + env: map[string]string{envAccessKey: testAccessKey, envWorkspaceCRN: testCRN}, + want: []error{ErrNoCredentials}, + names: envClientKey, + }, + { + name: "a stored session and no secretkey.json", + profile: &profileFiles{auth: loggedIn("t").auth}, + want: []error{ErrNoCredentials, stackauth.ErrNotFound}, + names: envClientKey, + }, + { + name: "an access key with no workspace CRN", + env: map[string]string{envAccessKey: testAccessKey, envClientID: testClientID, envClientKey: testClientKey}, + want: []error{stackauth.ErrAuthConfig}, + }, + } { + t.Run(tc.name, func(t *testing.T) { + dir := filepath.Join(t.TempDir(), "absent") + if tc.profile != nil { + dir = newProfile(t, *tc.profile) + } + cleanEnv(t, dir) + for k, v := range tc.env { + t.Setenv(k, v) + } + _, err := resolve(t) + for _, want := range tc.want { + if !errors.Is(err, want) { + t.Errorf("error %v, want %v", err, want) + } + } + if err != nil && !strings.Contains(err.Error(), tc.names) { + t.Errorf("error %q does not name %q", err, tc.names) + } + }) + } +} + +// A set but empty variable is refused rather than skipped, as stack-kms's +// EnvKeyProvider refuses it; and no error from resolution carries the +// material of a key the environment or the profile held. +func TestAutoCredentialsRefusesAnEmptyKeyVariableWithoutPrintingKeys(t *testing.T) { + authGuestOrSkip(t) + cleanEnv(t, newProfile(t, loggedIn("profile-token"))) + t.Setenv(envClientID, "") + t.Setenv(envClientKey, testClientKey) + _, err := resolve(t) + if !errors.Is(err, ErrEncoding) { + t.Fatalf("empty %s: %v, want ErrEncoding, not the profile's key", envClientID, err) + } + profileKey := base64.StdEncoding.EncodeToString(mustHex(testClientKey)) + for _, leak := range []string{testClientKey[:16], profileKey[:16]} { + if strings.Contains(err.Error(), leak) { + t.Fatalf("the error carries key material: %q", err) + } + } +} + +// The credentials NewClient resolved are the client's: the token source it +// asks is theirs, and Close releases them. Nothing printed of the client or +// of a failed NewClient carries the key. +func TestNewClientWithAutoCredentials(t *testing.T) { + guestOrSkip(t) + authGuestOrSkip(t) + cleanEnv(t, newProfile(t, loggedIn("profile-token"))) + stub := newStub(t, http.StatusUnauthorized, "", "nope") + // The endpoint from the environment, since the Config names none. + t.Setenv("CS_ZEROKMS_HOST", stub.URL) + var released *ResolvedCredentials + creds := credentialsFunc(func(ctx context.Context, opts ResolveOptions) (*ResolvedCredentials, error) { + r, err := AutoCredentials().Resolve(ctx, opts) + released = r + return r, err + }) + _, err := NewClient(context.Background(), Config{Credentials: creds}) + if !errors.Is(err, ErrUnauthorized) { + t.Fatalf("NewClient: %v, want ErrUnauthorized from the stub", err) + } + if len(stub.requests) != 1 || stub.requests[0].auth != "Bearer profile-token" { + t.Fatalf("requests = %+v, want one bearing the profile's token", stub.requests) + } + if strings.Contains(err.Error(), testClientKey[:16]) { + t.Fatalf("the error carries key material: %q", err) + } + // A failed NewClient released the credentials it resolved. + if _, err := released.Token.Token(context.Background()); !errors.Is(err, stackauth.ErrState) { + t.Fatalf("the token source after a failed NewClient: %v, want ErrState", err) + } +} + +// A nil Credentials is AutoCredentials: with nothing configured, the error +// is the resolution's, before any guest or request. +func TestNewClientDefaultsToAutoCredentials(t *testing.T) { + authGuestOrSkip(t) + cleanEnv(t, filepath.Join(t.TempDir(), "absent")) + if _, err := NewClient(context.Background(), Config{}); !errors.Is(err, ErrNoCredentials) { + t.Fatalf("NewClient with no credentials anywhere: %v, want ErrNoCredentials", err) + } +} + +func TestZeroKMSEndpointOrder(t *testing.T) { + const explicit, primary, legacy = "https://explicit.example", "https://primary.example", "https://legacy.example" + for _, tc := range []struct { + name string + explicit string + primary, legacy *string + want string + wantErr, errName string + }{ + {name: "none", want: ""}, + {name: "explicit wins", explicit: explicit, primary: ptr(primary), legacy: ptr(legacy), want: explicit}, + {name: "CS_ZEROKMS_HOST over the legacy name", primary: ptr(primary), legacy: ptr(legacy), want: primary}, + {name: "the legacy name alone", legacy: ptr(legacy), want: legacy}, + // Set decides, not non-empty: an empty primary is an error, and + // the legacy name is not consulted. + {name: "set but empty", primary: ptr(""), legacy: ptr(legacy), errName: "CS_ZEROKMS_HOST"}, + {name: "no scheme", primary: ptr("localhost:3002"), errName: "CS_ZEROKMS_HOST"}, + {name: "not http", legacy: ptr("ftp://zerokms.example"), errName: "CS_VITUR_HOST"}, + {name: "no host", primary: ptr("https://"), errName: "CS_ZEROKMS_HOST"}, + {name: "userinfo is not printed", primary: ptr("https://user:hunter2@%zz"), errName: "CS_ZEROKMS_HOST"}, + } { + t.Run(tc.name, func(t *testing.T) { + cleanEnv(t, t.TempDir()) + if tc.primary != nil { + t.Setenv("CS_ZEROKMS_HOST", *tc.primary) + } + if tc.legacy != nil { + t.Setenv("CS_VITUR_HOST", *tc.legacy) + } + got, err := zerokmsEndpoint(tc.explicit) + if tc.errName != "" { + if !errors.Is(err, ErrEncoding) || !strings.Contains(err.Error(), tc.errName) { + t.Fatalf("error %v, want ErrEncoding naming %s", err, tc.errName) + } + if strings.Contains(err.Error(), "hunter2") { + t.Fatalf("the error prints the URL: %q", err) + } + return + } + if err != nil || got != tc.want { + t.Fatalf("zerokmsEndpoint = %q, %v; want %q", got, err, tc.want) + } + }) + } +} + +// Close releases what the credentials hold: the token strategy and the +// profile's guest. +func TestAutoCredentialsCloseReleasesTheGuest(t *testing.T) { + authGuestOrSkip(t) + cleanEnv(t, newProfile(t, loggedIn("profile-token"))) + resolved, err := AutoCredentials().Resolve(context.Background(), ResolveOptions{Transport: http.DefaultTransport}) + if err != nil { + t.Fatal(err) + } + if err := resolved.Close(); err != nil { + t.Fatal(err) + } + if _, err := resolved.Token.Token(context.Background()); !errors.Is(err, stackauth.ErrState) { + t.Fatalf("Token after Close: %v, want ErrState", err) + } +} + +func TestCredentialsPrintNoKey(t *testing.T) { + for _, c := range []Credentials{AutoCredentials(), NewCredentials(testClientID, NewClientKey([]byte(testClientKey)), StaticToken("t"))} { + for _, verb := range []string{"%v", "%+v", "%s"} { + if out := fmt.Sprintf(verb, c); strings.Contains(out, testClientKey[:16]) || !strings.HasPrefix(out, "stackencrypt.") { + t.Errorf("%s: %q", verb, out) + } + } + } +} + +type credentialsFunc func(context.Context, ResolveOptions) (*ResolvedCredentials, error) + +func (f credentialsFunc) Resolve(ctx context.Context, opts ResolveOptions) (*ResolvedCredentials, error) { + return f(ctx, opts) +} + +func ptr(s string) *string { return &s } diff --git a/languages/golang/stackencrypt/doc.go b/languages/golang/stackencrypt/doc.go index 6ca3a06d5..0ba46c370 100644 --- a/languages/golang/stackencrypt/doc.go +++ b/languages/golang/stackencrypt/doc.go @@ -5,9 +5,10 @@ // # Shape // // A [Client] is one wasm instance and one ZeroKMS client: [NewClient] -// instantiates the embedded guest, hands it the client key once — the -// [ClientKey] in its [Config] is consumed and wiped, whatever the outcome — -// and loads the client's default keyset. Every keyset the client uses after +// resolves the client's [Credentials], instantiates the embedded guest, +// hands it the client key once — the [ClientKey] the credentials resolved +// to is consumed and wiped, whatever the outcome — and loads the client's +// default keyset. Every keyset the client uses after // that is // selected per call through a [KeysetSelector] and loaded on first use by // the guest's own bounded cache; nothing the host could allocate, alias or @@ -58,6 +59,21 @@ // [TokenSource]. What crosses per ZeroKMS call is what would cross TLS // anyway; derived key material never leaves the guest. // +// # Credentials +// +// A [Credentials] supplies the client id, the client key and the token +// source, and NewClient resolves it host-side: the crypto guest is never +// given the environment or a filesystem to find them in. The default, +// [AutoCredentials], mirrors the Rust client — the environment first +// (CS_CLIENT_ACCESS_KEY with CS_WORKSPACE_CRN for the token, CS_CLIENT_ID +// with CS_CLIENT_KEY for the key), then the developer profile, which it +// reads through stackauth's credential guest, where the token strategies +// also run. CS_ZEROKMS_HOST (or CS_VITUR_HOST) pins the endpoint whatever +// the credentials. [NewCredentials] takes the three values explicitly. +// Credentials that cannot be resolved fail NewClient with +// [ErrNoCredentials]; credentials that resolve but do not work fail it too, +// at the one ZeroKMS round trip it makes. +// // # Host runtime // // The guest also imports WASI random_get and clock_time_get, and the diff --git a/languages/golang/stackencrypt/example/README.md b/languages/golang/stackencrypt/example/README.md index 61f89a16a..b48185c8f 100644 --- a/languages/golang/stackencrypt/example/README.md +++ b/languages/golang/stackencrypt/example/README.md @@ -37,31 +37,31 @@ side will not notice a stale one, so rebuild after any change under either ## Credentials -`profile.go` reads the developer profile through -[`stackauth`](../../stackauth): `Resolve` finds the directory the Rust crate -would, `CurrentWorkspaceStore` scopes to the workspace `stash auth login` -selected, `SecretKey` hands out the client key as the opaque `ClientKey` -a `Config` takes, and `TokenSource` is the token source the client -authenticates with. Nothing in the example spells the profile's layout; -the `stack-profile` crate does, inside the credential guest, so the example -cannot drift from it. +The example passes the zero `Config`, so `NewClient` resolves its +credentials with `stackencrypt.AutoCredentials`, the library path any +application gets by default. It looks in the environment first and then in +the developer profile, in the order the Rust client uses: -Two details in there that are easy to get wrong: +- **The token.** If `CS_CLIENT_ACCESS_KEY` is set (with `CS_WORKSPACE_CRN`), + the access key is exchanged for a token. Otherwise the current workspace's + stored device session is used. Both run in `stackauth`'s credential guest. +- **The client key.** `CS_CLIENT_ID` and `CS_CLIENT_KEY` if both are set, + otherwise the current workspace's `secretkey.json`. +- **The endpoint.** `CS_ZEROKMS_HOST` if set, otherwise the token's + `services` claim. -- **The token is not static.** `TokenSource.Token` is called on *every* - request, precisely so a token can change under a long-lived client. A - profile token lasts 45 minutes, so pinning one with `StaticToken` gives you - a program that works and then stops. `stackauth`'s source re-reads - `auth.json` each time instead, picking up whatever else refreshes it, and - refuses a token at its real expiry with `stackauth.ErrTokenExpired`. -- **It never refreshes, yet.** Refreshing is the auth half of `stackauth` - (see ADR-0005): the IdP rotates refresh tokens and detects replay, so two - processes sharing `~/.cipherstash` that both exchange the same one get the - entire chain revoked. Rust handles this with a cross-process lock and a - re-read after acquiring it, and Go will take the same lock on the path - `ProfileStore.LockPath` names. Until then an expired token means - `stash auth login`. +`CS_CONFIG_PATH` overrides the profile directory, and `CS_CTS_HOST` +overrides the authentication endpoint. -`CS_CLIENT_ID` / `CS_CLIENT_KEY` override the profile's client key. -`CS_CONFIG_PATH` overrides the profile directory. The token always comes from -the profile. +Nothing in the example spells out the profile's layout. The `stack-profile` +crate reads it inside the credential guest, so the example cannot drift +from it. The crypto guest still sees no environment and no filesystem: +credentials are resolved host-side. + +The device session is **asked on every request and refreshes itself**. A +profile token lasts 45 minutes, so pinning one with `StaticToken` would give +you a program that works for a while and then stops. The refresh takes the +same cross-process lock as the `stash` CLI. The IdP rotates refresh tokens +and detects replay, so two processes sharing `~/.cipherstash` that both +exchanged the same refresh token would get the whole chain revoked; the lock +prevents that. diff --git a/languages/golang/stackencrypt/example/main.go b/languages/golang/stackencrypt/example/main.go index d47390a73..8fd849c2b 100644 --- a/languages/golang/stackencrypt/example/main.go +++ b/languages/golang/stackencrypt/example/main.go @@ -1,6 +1,6 @@ // Command example exercises the stack-encrypt Go binding against real // ZeroKMS, using the credentials `stash auth login` leaves in the developer -// profile. +// profile (or the CS_* environment variables, which win). // // stash auth login // mise run wasm:guest:build wasm:auth-guest:build # both embedded guests @@ -39,31 +39,23 @@ func main() { func run() error { ctx := context.Background() - creds, err := loadCredentials(ctx) - if err != nil { - return err - } - defer creds.Close() - fmt.Printf("workspace %s (%s)\n", creds.Workspace, creds.describe(ctx)) - - client, err := stackencrypt.NewClient(ctx, stackencrypt.Config{ - ClientID: creds.ClientID, - // Read from the profile as the opaque type, consumed and wiped by - // NewClient. - ClientKey: creds.ClientKey, - // Asked on every request, so the client follows the profile - // rather than pinning one token; see profile.go. - Token: creds.token(), - // ZeroKMSURL is left empty: the endpoint is resolved from the - // token's services claim on first use. - }) + // The zero Config: credentials from AutoCredentials, which is the + // environment first (CS_CLIENT_ACCESS_KEY + CS_WORKSPACE_CRN, CS_CLIENT_ID + // + CS_CLIENT_KEY), then the developer profile `stash auth login` writes, + // read through stackauth's credential guest. The token is a refreshing + // device session there, asked on every request, so a long run outlives + // one token. ZeroKMSURL is left empty: CS_ZEROKMS_HOST if set, else the + // token's services claim. + client, err := stackencrypt.NewClient(ctx, stackencrypt.Config{}) if err != nil { return fmt.Errorf("connecting to ZeroKMS: %w", err) } // Close runs the guest's own wipe of the client key and every loaded - // index key. The memory's protection does not wait on it (see the - // package docs); this is ordinary resource hygiene. + // index key, and releases the credential guest. The memory's protection + // does not wait on it (see the package docs); this is ordinary resource + // hygiene. defer client.Close() + fmt.Printf("connected (%v)\n", client) cipher := client.DefaultKeyset() keysetID, err := cipher.KeysetID(ctx) diff --git a/languages/golang/stackencrypt/example/profile.go b/languages/golang/stackencrypt/example/profile.go deleted file mode 100644 index 2b33e9fe4..000000000 --- a/languages/golang/stackencrypt/example/profile.go +++ /dev/null @@ -1,93 +0,0 @@ -package main - -import ( - "context" - "fmt" - "os" - "time" - - "github.com/cipherstash/cipherstash-suite/bindings/go/stackauth" - "github.com/cipherstash/cipherstash-suite/bindings/go/stackencrypt" -) - -// Credentials from the developer profile `stash auth login` writes, read -// through stackauth: the stack-profile crate itself, running in the -// credential guest, so nothing here spells the profile's layout. (An -// earlier version of this file read ~/.cipherstash by hand, which is the -// drift stackauth exists to end.) -type credentials struct { - ClientID string - ClientKey *stackencrypt.ClientKey - Workspace string - // profile owns the guest; Close releases it. The workspace store shares - // it and is closed with it. - profile *stackauth.ProfileStore - workspace *stackauth.ProfileStore -} - -func loadCredentials(ctx context.Context) (*credentials, error) { - // CS_CONFIG_PATH, else ~/.cipherstash, as the Rust crate resolves it. - profile, err := stackauth.Resolve(ctx) - if err != nil { - return nil, err - } - c := &credentials{profile: profile} - if c.workspace, err = profile.CurrentWorkspaceStore(ctx); err != nil { - profile.Close() - return nil, fmt.Errorf("no current workspace — run `stash auth login`: %w", err) - } - if c.Workspace, err = profile.CurrentWorkspace(ctx); err != nil { - profile.Close() - return nil, err - } - - // The client key: the two environment variables win, as they do for the - // Rust client, so this example can be pointed somewhere else without - // touching the profile. Otherwise it is the workspace's secretkey.json, - // handed out as the opaque ClientKey a stackencrypt.Config takes. - if id, key := os.Getenv("CS_CLIENT_ID"), os.Getenv("CS_CLIENT_KEY"); id != "" && key != "" { - c.ClientID, c.ClientKey = id, stackencrypt.NewClientKey([]byte(key)) - } else if c.ClientID, c.ClientKey, err = c.workspace.SecretKey(ctx); err != nil { - profile.Close() - return nil, fmt.Errorf("no client key — set CS_CLIENT_ID and CS_CLIENT_KEY, or run `stash auth login`: %w", err) - } - - // Fail here rather than three calls later, with something actionable: - // an expired stored token names `stash auth login`. - if _, err := c.token().Token(ctx); err != nil { - profile.Close() - return nil, err - } - return c, nil -} - -// Close releases the profile guest, and with it the workspace store. -func (c *credentials) Close() error { return c.profile.Close() } - -// token is the [stackencrypt.TokenSource] this example authenticates with: -// stackauth's, over the workspace's auth.json. -// -// Deliberately not StaticToken: the binding asks its TokenSource on *every* -// request precisely so that a token can change under a long-lived client, -// and a profile token is good for 45 minutes. A static one turns that into -// a program that works and then stops, which is the wrong thing to show. -// stackauth's source re-reads auth.json each time, so whatever keeps the -// profile fresh — the `stash` CLI, a Rust client in the same session — is -// picked up without restarting, and it refuses a token at its real expiry. -// -// What it does not do yet is refresh. Exchanging the refresh token is the -// auth half of stackauth: the IdP rotates refresh tokens and detects -// replay, so two processes sharing ~/.cipherstash that both exchange the -// same one get the whole chain revoked, and the Rust side handles that -// with a cross-process lock and a re-read after acquiring it. Until that -// half lands, an expired token means `stash auth login`. -func (c *credentials) token() stackencrypt.TokenSource { return c.workspace.TokenSource() } - -// describe reports what the profile currently holds, for the banner. -func (c *credentials) describe(ctx context.Context) string { - tok, err := c.workspace.Token(ctx) - if err != nil { - return "unknown" - } - return fmt.Sprintf("%s, token good for %s", tok.Region, time.Until(tok.ExpiresAt).Round(time.Minute)) -} diff --git a/languages/golang/stackencrypt/guest_test.go b/languages/golang/stackencrypt/guest_test.go index 577ce67d4..586b7af86 100644 --- a/languages/golang/stackencrypt/guest_test.go +++ b/languages/golang/stackencrypt/guest_test.go @@ -96,12 +96,19 @@ func newStub(t *testing.T, status int, contentType, body string) *zerokmsStub { } func testConfig(url string) Config { - return Config{ - ClientID: testClientID, - ClientKey: NewClientKey([]byte(testClientKey)), - ZeroKMSURL: url, - Token: StaticToken("stub-token"), - } + return Config{Credentials: testCredentials(StaticToken("stub-token")), ZeroKMSURL: url} +} + +// testCredentials is the test client id and a fresh copy of the test key, +// with token as the token source. +func testCredentials(token TokenSource) Credentials { + return NewCredentials(testClientID, NewClientKey([]byte(testClientKey)), token) +} + +// testInit is testConfig as se_cipher_init takes it, for tests that drive +// an instance by hand. +func testInit(url string) initConfig { + return initConfig{clientID: testClientID, clientKey: NewClientKey([]byte(testClientKey)), zerokmsURL: url} } func TestImportSurfaceIsWASIPlusTransport(t *testing.T) { @@ -204,11 +211,17 @@ func TestTransportOutcomesMapToErrors(t *testing.T) { t.Run("no token", func(t *testing.T) { stub := newStub(t, http.StatusOK, "application/json", "{}") cfg := testConfig(stub.URL) - cfg.Token = TokenFunc(func(context.Context) (string, error) { return "", errors.New("vault down") }) + vaultDown := errors.New("vault down") + cfg.Credentials = testCredentials(TokenFunc(func(context.Context) (string, error) { return "", vaultDown })) _, err := NewClient(context.Background(), cfg) if err == nil { t.Fatal("NewClient succeeded with no token") } + // The guest reports only that token_get failed; the client attaches + // what the token source said. + if !errors.Is(err, vaultDown) { + t.Fatalf("NewClient: %v, want the token source's error", err) + } if len(stub.requests) != 0 { t.Fatalf("a request was made without a token: %+v", stub.requests) } @@ -443,30 +456,39 @@ func TestConfigValidation(t *testing.T) { ctx := context.Background() wiped := NewClientKey([]byte(testClientKey)) wiped.Wipe() - for name, cfg := range map[string]Config{ - "no token": {ClientID: testClientID, ClientKey: NewClientKey([]byte(testClientKey))}, - "no client id": {ClientKey: NewClientKey([]byte(testClientKey)), Token: StaticToken("t")}, - "no key": {ClientID: testClientID, Token: StaticToken("t")}, - "empty key": {ClientID: testClientID, ClientKey: NewClientKey(nil), Token: StaticToken("t")}, - "wiped key": {ClientID: testClientID, ClientKey: wiped, Token: StaticToken("t")}, - "negative cache": {ClientID: testClientID, ClientKey: NewClientKey([]byte(testClientKey)), Token: StaticToken("t"), - KeysetCacheSize: -1}, + for name, tc := range map[string]struct { + id string + key *ClientKey + token TokenSource + cache int + }{ + "no token": {id: testClientID, key: NewClientKey([]byte(testClientKey))}, + "no client id": {key: NewClientKey([]byte(testClientKey)), token: StaticToken("t")}, + "no key": {id: testClientID, token: StaticToken("t")}, + "empty key": {id: testClientID, key: NewClientKey(nil), token: StaticToken("t")}, + "wiped key": {id: testClientID, key: wiped, token: StaticToken("t")}, + "negative cache": {id: testClientID, key: NewClientKey([]byte(testClientKey)), token: StaticToken("t"), cache: -1}, } { + cfg := Config{Credentials: NewCredentials(tc.id, tc.key, tc.token), KeysetCacheSize: tc.cache} if _, err := NewClient(ctx, cfg); err == nil { t.Errorf("%s: NewClient succeeded", name) } // A refused config consumes the key too: the caller is never handed // live material back with the error. - if !cfg.ClientKey.IsZero() { + if !tc.key.IsZero() { t.Errorf("%s: the key still holds material after NewClient refused the config", name) } } // Malformed values the guest refuses: no request is made. guestOrSkip(t) for name, mutate := range map[string]func(*Config){ - "client id not a uuid": func(c *Config) { c.ClientID = "acme" }, - "key not hex": func(c *Config) { c.ClientKey = NewClientKey([]byte("zz")) }, - "bad url": func(c *Config) { c.ZeroKMSURL = "not a url" }, + "client id not a uuid": func(c *Config) { + c.Credentials = NewCredentials("acme", NewClientKey([]byte(testClientKey)), StaticToken("t")) + }, + "key not hex": func(c *Config) { + c.Credentials = NewCredentials(testClientID, NewClientKey([]byte("zz")), StaticToken("t")) + }, + "bad url": func(c *Config) { c.ZeroKMSURL = "not a url" }, } { stub := newStub(t, http.StatusOK, "application/json", "{}") cfg := testConfig(stub.URL) @@ -494,20 +516,27 @@ func TestClientKeyIsConsumedAndNeverPrinted(t *testing.T) { material := []byte(testClientKey) stub := newStub(t, http.StatusUnauthorized, "", "nope") cfg := testConfig(stub.URL) - cfg.ClientKey = NewClientKey(material) + key := NewClientKey(material) + cfg.Credentials = NewCredentials(testClientID, key, StaticToken("stub-token")) + resolved, err := cfg.Credentials.Resolve(context.Background(), ResolveOptions{}) + if err != nil { + t.Fatal(err) + } // %x and %d reach a struct's fields without asking a Stringer; the // key's Formatter answers for them. for _, verb := range []string{"%v", "%+v", "%#v", "%s", "%q", "%x", "%d"} { - out := fmt.Sprintf(verb, cfg) - if strings.Contains(out, testClientKey[:16]) || strings.Contains(out, hex.EncodeToString(material[:8])) { - t.Errorf("Config under %s prints the key: %q", verb, out) + for what, v := range map[string]any{"Config": cfg, "Credentials": cfg.Credentials, "ResolvedCredentials": *resolved} { + out := fmt.Sprintf(verb, v) + if strings.Contains(out, testClientKey[:16]) || strings.Contains(out, hex.EncodeToString(material[:8])) { + t.Errorf("%s under %s prints the key: %q", what, verb, out) + } } } guestOrSkip(t) if _, err := NewClient(context.Background(), cfg); !errors.Is(err, ErrUnauthorized) { t.Fatalf("NewClient: %v, want ErrUnauthorized", err) } - if !cfg.ClientKey.IsZero() { + if !key.IsZero() { t.Error("the key still holds material after NewClient") } for i, b := range material { @@ -757,7 +786,7 @@ func TestClientKeyDoesNotRemainInGuestMemory(t *testing.T) { // Keep the instance to scan it: build the client by hand so a failed // init does not tear it down first. ctx := context.Background() - encoded, err := encodeConfig(testConfig(stub.URL)) + encoded, err := encodeConfig(testInit(stub.URL)) if err != nil { t.Fatal(err) } @@ -802,7 +831,7 @@ func TestTransportSendCounterAndResponseHeaders(t *testing.T) { t.Fatal(err) } defer inst.release() - encoded, _ := encodeConfig(testConfig(stub.URL)) + encoded, _ := encodeConfig(testInit(stub.URL)) if _, err := inst.call(ctx, inst.cipherInit, buf(encoded)); !errors.Is(err, ErrUnauthorized) { t.Fatalf("init: %v", err) } @@ -815,9 +844,11 @@ func ExampleNewClient() { // A client needs ZeroKMS credentials; see live_test.go for the shape of // a real round trip. _, err := NewClient(context.Background(), Config{ - ClientID: "6a70bd18-99ac-4650-b104-37eec3a15b09", - ClientKey: NewClientKey([]byte("...")), - Token: StaticToken("access token"), + Credentials: NewCredentials( + "6a70bd18-99ac-4650-b104-37eec3a15b09", + NewClientKey([]byte("...")), + StaticToken("access token"), + ), }) fmt.Println(err != nil) // Output: true diff --git a/languages/golang/stackencrypt/live_test.go b/languages/golang/stackencrypt/live_test.go index 63c082267..f86d837a1 100644 --- a/languages/golang/stackencrypt/live_test.go +++ b/languages/golang/stackencrypt/live_test.go @@ -26,7 +26,8 @@ func liveClient(t *testing.T) *Client { material := []byte(clientKey) key := NewClientKey(material) c, err := NewClient(t.Context(), Config{ - ClientID: clientID, ClientKey: key, ZeroKMSURL: url, Token: StaticToken(token), + Credentials: NewCredentials(clientID, key, StaticToken(token)), + ZeroKMSURL: url, }) if err != nil { t.Fatalf("NewClient: %v", err) diff --git a/languages/golang/stackencrypt/memory_test.go b/languages/golang/stackencrypt/memory_test.go index cc87a6302..c5df12778 100644 --- a/languages/golang/stackencrypt/memory_test.go +++ b/languages/golang/stackencrypt/memory_test.go @@ -86,9 +86,7 @@ func TestRequireLockedMemoryRefusesAnUnlockableGuest(t *testing.T) { } limited := !probe.IsFallback() cfg := Config{ - ClientID: "6a70bd18-99ac-4650-b104-37eec3a15b09", - ClientKey: NewClientKey([]byte("00")), - Token: StaticToken("t"), + Credentials: NewCredentials("6a70bd18-99ac-4650-b104-37eec3a15b09", NewClientKey([]byte("00")), StaticToken("t")), Guest: wasiProbe, RequireLockedMemory: true, } @@ -102,7 +100,7 @@ func TestRequireLockedMemoryRefusesAnUnlockableGuest(t *testing.T) { // Best effort under the same refusal: the client exists, says so, and // shows it wherever it is printed or logged. if wasm, gerr := embeddedGuest(); gerr == nil { - inst, err := newInstance(context.Background(), wasm, &transport{rt: http.DefaultTransport, token: cfg.Token}, guest.BestEffort) + inst, err := newInstance(context.Background(), wasm, &transport{rt: http.DefaultTransport, token: StaticToken("t")}, guest.BestEffort) if err != nil { t.Fatal(err) } @@ -193,7 +191,7 @@ func TestRequireLockedMemoryFailsTheCallThatCannotGrow(t *testing.T) { func TestRequireLockedMemoryClosesTheClientOnARefusedInternalGrowth(t *testing.T) { ctx := context.Background() c := strictClient(t) - cfg := Config{ClientID: strings.Repeat("a", 2<<20), ClientKey: NewClientKey([]byte("00")), Token: StaticToken("t")} + cfg := initConfig{clientID: strings.Repeat("a", 2<<20), clientKey: NewClientKey([]byte("00"))} encoded, err := encodeConfig(cfg) if err != nil { t.Fatal(err) diff --git a/languages/golang/stackencrypt/transport.go b/languages/golang/stackencrypt/transport.go index a40b58965..8bb309d4c 100644 --- a/languages/golang/stackencrypt/transport.go +++ b/languages/golang/stackencrypt/transport.go @@ -21,9 +21,9 @@ const transportModule = "cipherstash_transport" // TokenSource supplies the bearer token the guest presents to ZeroKMS. It // is asked on every request, so a source that rotates tokens needs no -// re-initialisation of the client. Minting and refresh stay host-side; a -// future token strategy running inside the guest is an additive change to -// [Config], not to this interface. +// re-initialisation of the client. Minting and refresh stay host-side, out +// of the crypto guest: [AutoCredentials] runs them in stackauth's credential +// guest, and hands the client a TokenSource over the strategy it chose. type TokenSource interface { Token(ctx context.Context) (string, error) } @@ -49,6 +49,12 @@ type transport struct { // sends counts transport_send excursions, so tests can pin the batching // contract (one ZeroKMS call per operation) instead of trusting it. sends atomic.Int64 + // tokenErr is why the token source last failed during the call in + // flight: the guest sees only that token_get failed, so Client.call + // attaches the cause — ErrNoCredentials, a refused refresh — to the + // error it returns. Only touched under the client's lock, which every + // guest call holds. + tokenErr error } // transportFailed is the return value of transport_send when the request @@ -211,7 +217,12 @@ func (b *requestBody) Close() error { // tokenGet is token_get: hand the guest the current bearer token. func (t *transport) tokenGet(ctx context.Context, m api.Module, tokenPtrOut, tokenLenOut uint32) int32 { token, err := t.token.Token(ctx) - if err != nil || token == "" { + if err != nil { + t.tokenErr = err + return hostFailed + } + if token == "" { + t.tokenErr = errors.New("stackencrypt: the token source returned an empty token") return hostFailed } // The credential's transport copy is wiped once it is in guest memory; diff --git a/packages/stack-encrypt/docs/adr/0005-a-separate-credential-guest-for-the-profile-and-auth.md b/packages/stack-encrypt/docs/adr/0005-a-separate-credential-guest-for-the-profile-and-auth.md index 67839c4f2..7cd2301fe 100644 --- a/packages/stack-encrypt/docs/adr/0005-a-separate-credential-guest-for-the-profile-and-auth.md +++ b/packages/stack-encrypt/docs/adr/0005-a-separate-credential-guest-for-the-profile-and-auth.md @@ -232,3 +232,19 @@ What it does **not** fix: the client key still passes through Go host memory between the two guests, since two wasm instances cannot share memory. It is wiped there, not absent. And a token is a Go string on the host side, which cannot be wiped; that is accepted for a credential that lives for hours. + +## Amendment (2026-09-27, CIP-4052): stackencrypt imports stackauth + +Decision 4 said neither public package imports the other. Credential +resolution changes one direction of that. `stackencrypt.AutoCredentials`, +the default credentials for `NewClient`, reads the profile and runs the +token strategies through `stackauth`, so `stackencrypt` imports it. The +alternative was to leave the composition to every application, which is +the gap CIP-4052 exists to close. + +The reason decision 4 gave still holds: `stackauth` does not import +`stackencrypt`, so a binary that only wants the profile does not carry the +crypto guest. A binary that encrypts now carries both guests. Neither +sandbox changes. The crypto guest still has no environment and no +filesystem, and the credential guest still has one mount. With no profile +directory, `stackauth.OpenWithoutProfile` gives it none. From bb149b3dc6b0b0e9f28e54ceaa6810c99295ce10 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Mon, 28 Sep 2026 23:27:12 +0000 Subject: [PATCH 642/686] fix(go): address credential resolution review findings NewClient consumes a result a failed Resolve hands back (key wiped, Close run) instead of leaking it, and validates the endpoint, cache size and guest before asking the credentials, so a refused config no longer pays for a credential guest; an explicit key is still consumed on that path. The credentials become the client's as soon as it exists, so every failure path releases them through the client's Close, the same wiring a successful client's Close runs, and a test pins that it runs once. AutoCredentials keeps the reason a profile could not be opened and reports it wherever the profile would have supplied the missing half, so an unreadable or mistyped CS_CONFIG_PATH is not reported as "not logged in"; the hasProfile flag becomes that reason, and the store's own ErrNoProfile answer is handled too. The access-key variables are only named for a configuration error when one of them is set. ResolvedCredentials carries the credentials' MemoryLockError, and the client folds it into MemoryLocked, MemoryLockError, String and LogValue, so the report covers the credential guest the key passed through. A token the guest could not take is named as such rather than left as a bare host failure. Open and OpenWithoutProfile share one open. The Transport doc, the package doc and the README say the transport also carries CTS authentication requests, the endpoint variables apply to explicit credentials too, and the credentials table is introduced correctly. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TJc73YNcTeGaLDhP2P3LNM --- languages/golang/stackauth/store.go | 33 ++-- languages/golang/stackencrypt/README.md | 17 +- languages/golang/stackencrypt/client.go | 133 ++++++++++----- languages/golang/stackencrypt/credentials.go | 66 ++++++-- .../golang/stackencrypt/credentials_test.go | 158 ++++++++++++++++++ languages/golang/stackencrypt/doc.go | 4 +- languages/golang/stackencrypt/live_test.go | 26 ++- languages/golang/stackencrypt/transport.go | 4 + 8 files changed, 357 insertions(+), 84 deletions(-) diff --git a/languages/golang/stackauth/store.go b/languages/golang/stackauth/store.go index a9b512aae..6fe37d237 100644 --- a/languages/golang/stackauth/store.go +++ b/languages/golang/stackauth/store.go @@ -95,10 +95,6 @@ func Resolve(ctx context.Context, opts ...Option) (*ProfileStore, error) { // mounted as the one directory the guest can see. Nothing is read until a // method asks; nothing is written unless a method writes. func Open(ctx context.Context, dir string, opts ...Option) (*ProfileStore, error) { - var o options - for _, opt := range opts { - opt(&o) - } info, err := os.Stat(dir) if err != nil { return nil, fmt.Errorf("%w: %s: %w", ErrNoProfile, dir, err) @@ -106,21 +102,7 @@ func Open(ctx context.Context, dir string, opts ...Option) (*ProfileStore, error if !info.IsDir() { return nil, fmt.Errorf("%w: %s is not a directory", ErrNoProfile, dir) } - wasm := o.guest - if wasm == nil { - if wasm, err = embeddedGuest(); err != nil { - return nil, err - } - } - inst, err := newInstance(ctx, wasm, dir, guest.PolicyFor(o.requireLocked), o.transport) - if err != nil { - return nil, err - } - r := &root{inst: inst, hostDir: dir} - // The cleanup takes the instance, not the root: a cleanup whose - // argument reaches its object keeps that object alive forever. - r.cleanup = runtime.AddCleanup(r, func(inst *instance) { _ = inst.release() }, inst) - return &ProfileStore{root: r, dir: guestRoot}, nil + return open(ctx, dir, opts) } // OpenWithoutProfile instantiates the guest with no directory mounted: no @@ -131,6 +113,13 @@ func Open(ctx context.Context, dir string, opts ...Option) (*ProfileStore, error // is the environment's access key or ErrNotAuthenticated, as stack-auth's // AutoStrategy is with no profile store. func OpenWithoutProfile(ctx context.Context, opts ...Option) (*ProfileStore, error) { + return open(ctx, "", opts) +} + +// open instantiates the guest over hostDir — mounted as the one directory +// the guest can see, or, when empty, nothing — and arms its cleanup. Open +// and OpenWithoutProfile differ only in what they hand it. +func open(ctx context.Context, hostDir string, opts []Option) (*ProfileStore, error) { var o options for _, opt := range opts { opt(&o) @@ -142,11 +131,13 @@ func OpenWithoutProfile(ctx context.Context, opts ...Option) (*ProfileStore, err return nil, err } } - inst, err := newInstance(ctx, wasm, "", guest.PolicyFor(o.requireLocked), o.transport) + inst, err := newInstance(ctx, wasm, hostDir, guest.PolicyFor(o.requireLocked), o.transport) if err != nil { return nil, err } - r := &root{inst: inst} + r := &root{inst: inst, hostDir: hostDir} + // The cleanup takes the instance, not the root: a cleanup whose + // argument reaches its object keeps that object alive forever. r.cleanup = runtime.AddCleanup(r, func(inst *instance) { _ = inst.release() }, inst) return &ProfileStore{root: r, dir: guestRoot}, nil } diff --git a/languages/golang/stackencrypt/README.md b/languages/golang/stackencrypt/README.md index 7ec5df2f7..c22dd9823 100644 --- a/languages/golang/stackencrypt/README.md +++ b/languages/golang/stackencrypt/README.md @@ -57,9 +57,11 @@ keyset, and `ctx` bounds that request. It has nothing to do with an The zero `Config` finds its credentials the way the Rust client does, with `AutoCredentials`: the environment first, then the developer profile that `stash auth login` writes. On a developer machine, logging in is enough. -In CI or a deployment, four variables are: +In CI or a deployment, the environment supplies them. The first two rows +are what a deployment with no profile needs; the rest override what would +otherwise be resolved: -| Variable | | +| Variable | Role | |---|---| | `CS_CLIENT_ACCESS_KEY`, `CS_WORKSPACE_CRN` | An access key, exchanged for a token. Without it, the current workspace's stored session is used, and refreshed as it expires. | | `CS_CLIENT_ID`, `CS_CLIENT_KEY` | The client key, used when both are set. Without them, the current workspace's `secretkey.json` is used. | @@ -68,7 +70,16 @@ In CI or a deployment, four variables are: | `CS_CONFIG_PATH` | The profile directory, instead of `~/.cipherstash`. | A variable that is set but empty or unusable is an error, not a reason to -look elsewhere. Nothing found is `ErrNoCredentials`, naming what to set. +look elsewhere. Nothing found is `ErrNoCredentials`, naming what to set; +when the profile would have been consulted, it also says why the profile +could not be opened, so an unreadable or mistyped `CS_CONFIG_PATH` is not +reported as "not logged in". + +The endpoint variables are read whatever the credentials, `NewCredentials` +included, as the Rust client reads them. A service that left `ZeroKMSURL` +empty and relied on the token's services claim now follows +`CS_ZEROKMS_HOST` or `CS_VITUR_HOST` if either is set in its environment, +so a value exported there for another tool is worth checking on upgrade. Resolution happens host-side, in Go. The profile and the token strategies run in `stackauth`'s credential guest; the crypto guest that holds the diff --git a/languages/golang/stackencrypt/client.go b/languages/golang/stackencrypt/client.go index 818093af5..cf0647204 100644 --- a/languages/golang/stackencrypt/client.go +++ b/languages/golang/stackencrypt/client.go @@ -36,8 +36,12 @@ type Config struct { // KeysetCacheSize is how many keysets beyond the default the guest keeps // loaded; zero means the crate default (1024). KeysetCacheSize int - // Transport performs the HTTP requests to ZeroKMS. Nil means - // http.DefaultTransport. + // Transport performs the HTTP requests to ZeroKMS and, under + // [AutoCredentials], the authentication requests stackauth's credential + // guest makes to CTS: an access-key exchange, a device-session refresh. + // A RoundTripper scoped to the ZeroKMS host alone (a pinned client + // certificate, an egress allowlist) refuses those; the failure then + // surfaces as the token source's. Nil means http.DefaultTransport. Transport http.RoundTripper // Guest overrides the embedded wasm module. Nil means the embedded one. Guest []byte @@ -88,6 +92,10 @@ type Client struct { // releaseCredentials is the resolved credentials' Close, run once by // Close. Nil when they hold nothing open. releaseCredentials func() error + // credentialsLockErr is the resolved credentials' MemoryLockError: the + // memory the key passed through before it reached this guest, folded + // into MemoryLocked so the report covers every guest that held it. + credentialsLockErr error // cleanup releases the instance if the Client becomes unreachable // without Close: the forgot-to-close case in a running process. It // does nothing at process exit, and is not meant to. @@ -108,11 +116,35 @@ func NewClient(ctx context.Context, cfg Config) (_ *Client, err error) { if creds == nil { creds = AutoCredentials() } - // Resolved before anything is validated, so the key is in hand to be - // consumed whatever happens below: a config refused before the key is - // marshalled must not hand it back live. + // The host-side checks come first: they read nothing from the + // credentials, and under AutoCredentials resolving means instantiating + // the credential guest and reading the profile, which a config refused + // here should not pay for. A refused config still consumes an explicit + // key, as Config.Credentials promises; any other Credentials has not + // been asked yet, so holds nothing of this client's. + zerokmsURL, err := zerokmsEndpoint(cfg.ZeroKMSURL) + if err == nil && cfg.KeysetCacheSize < 0 { + err = errors.New("stackencrypt: Config.KeysetCacheSize must not be negative") + } + wasm := cfg.Guest + if err == nil && wasm == nil { + wasm, err = embeddedGuest() + } + if err != nil { + consumeUnresolved(creds) + return nil, err + } resolved, err := creds.Resolve(ctx, ResolveOptions{Transport: rt, RequireLockedMemory: cfg.RequireLockedMemory}) if err != nil { + // A Resolve that fails may still hand back what it built. The key + // is consumed and what Close holds is released, as on every other + // path: nothing of the client's outlives a failed NewClient. + if resolved != nil { + resolved.ClientKey.Wipe() + if resolved.Close != nil { + _ = resolved.Close() + } + } return nil, err } if resolved == nil { @@ -120,26 +152,18 @@ func NewClient(ctx context.Context, cfg Config) (_ *Client, err error) { } // Nil-safe, and a no-op after the wipe on the accepted path. defer resolved.ClientKey.Wipe() - // The credentials are the client's to release once it exists; until - // then, NewClient's. + // The credentials are the client's to release once it exists — its + // Close does, on the paths below as at the end of its life — and until + // then NewClient's. + owned := false defer func() { - if err != nil && resolved.Close != nil { + if err != nil && !owned && resolved.Close != nil { _ = resolved.Close() } }() if resolved.Token == nil { return nil, errors.New("stackencrypt: the credentials have no token source") } - zerokmsURL, err := zerokmsEndpoint(cfg.ZeroKMSURL) - if err != nil { - return nil, err - } - wasm := cfg.Guest - if wasm == nil { - if wasm, err = embeddedGuest(); err != nil { - return nil, err - } - } encoded, err := encodeConfig(initConfig{ clientID: resolved.ClientID, clientKey: resolved.ClientKey, @@ -162,6 +186,12 @@ func NewClient(ctx context.Context, cfg Config) (_ *Client, err error) { return nil, err } c := newClient(inst, t) + // From here the credentials are the client's: every exit below goes + // through its Close, which releases them once, and so does the + // client's own Close later. + c.releaseCredentials = resolved.Close + c.credentialsLockErr = resolved.MemoryLockError + owned = true out, err := c.call(ctx, func(inst *instance) ([]byte, error) { return inst.call(ctx, inst.cipherInit, buf(encoded)) }) @@ -174,10 +204,19 @@ func NewClient(ctx context.Context, cfg Config) (_ *Client, err error) { return nil, fmt.Errorf("%w: cipher init returned %d bytes for the keyset id", ErrInternal, len(out)) } copy(c.def[:], out) - c.releaseCredentials = resolved.Close return c, nil } +// consumeUnresolved is what NewClient owes a Credentials it refuses a +// config without asking: explicit credentials hold their key from +// construction, so it is wiped rather than handed back live. Any other +// implementation has not been asked, and holds nothing of this client's. +func consumeUnresolved(creds Credentials) { + if explicit, ok := creds.(*explicitCredentials); ok { + explicit.key.Wipe() + } +} + // newClient wraps an instance and arms its cleanup. The cleanup takes the // instance, not the client: a cleanup whose argument reaches its object // keeps that object alive forever. @@ -187,24 +226,38 @@ func newClient(inst *instance, t *transport) *Client { return c } -// MemoryLocked reports whether the guest's memory — where the client key -// and every loaded index key live — is locked in RAM and, on Linux, -// excluded from core dumps. False means the lock was refused (on Linux, -// most often RLIMIT_MEMLOCK, which defaults to 64 KiB on many hosts) or is -// not available on this platform, and the client is working on with -// memory the kernel may swap out. Nothing else changes. A production -// checklist should assert this, or set [Config.RequireLockedMemory] and -// let NewClient refuse. [Client.MemoryLockError] says why. -func (c *Client) MemoryLocked() bool { return c.inst.mem.LockError() == nil } +// MemoryLocked reports whether the memory the client's key material lives +// in — this guest's, where the client key and every loaded index key are, +// and whatever the credentials held it in on the way (stackauth's +// credential guest, for [AutoCredentials]) — is locked in RAM and, on +// Linux, excluded from core dumps. False means a lock was refused (on +// Linux, most often RLIMIT_MEMLOCK, which defaults to 64 KiB on many +// hosts) or is not available on this platform, and the client is working +// on with memory the kernel may swap out. Nothing else changes. A +// production checklist should assert this, or set +// [Config.RequireLockedMemory] and let NewClient refuse. +// [Client.MemoryLockError] says why. +func (c *Client) MemoryLocked() bool { return c.MemoryLockError() == nil } // MemoryLockError is why MemoryLocked is false: an error wrapping -// ErrMemoryLock that names what was refused and the limit that refused it. -// Nil while the memory is locked. +// ErrMemoryLock that names what was refused and the limit that refused it, +// for this guest, the credentials' memory, or both. Nil while every one is +// locked. func (c *Client) MemoryLockError() error { - if err := c.inst.mem.LockError(); err != nil { - return guest.MemoryLockError(err) + var err error + if lerr := c.inst.mem.LockError(); lerr != nil { + err = guest.MemoryLockError(lerr) } - return nil + return errors.Join(err, c.credentialsLockErr) +} + +// memoryState is the memory's state for a log line: "locked", or the +// refusal. Nothing secret is printed. +func (c *Client) memoryState() string { + if err := c.MemoryLockError(); err != nil { + return fmt.Sprintf("unlocked: %v", err) + } + return "locked" } // String implements fmt.Stringer so that a Client printed with %v or %s @@ -212,12 +265,17 @@ func (c *Client) MemoryLockError() error { // printed. The state is what an operator reading a startup log needs to // see, and [Client.LogValue] gives it structured form. func (c *Client) String() string { - return fmt.Sprintf("stackencrypt.Client{memory: %s}", c.inst.mem) + return fmt.Sprintf("stackencrypt.Client{memory: %s}", c.memoryState()) } // LogValue implements slog.LogValuer: a group with memory_locked and, when // false, memory_lock_error. -func (c *Client) LogValue() slog.Value { return c.inst.mem.LogValue() } +func (c *Client) LogValue() slog.Value { + if err := c.MemoryLockError(); err != nil { + return slog.GroupValue(slog.Bool("memory_locked", false), slog.String("memory_lock_error", err.Error())) + } + return slog.GroupValue(slog.Bool("memory_locked", true)) +} // initConfig is what se_cipher_init takes: the resolved credentials' id // and key, and the settings that reach the guest. @@ -234,11 +292,6 @@ func encodeConfig(cfg initConfig) ([]byte, error) { if cfg.clientID == "" || cfg.clientKey.IsZero() { return nil, errors.New("stackencrypt: the credentials' client id and client key are required") } - // Every refusal comes before the key is copied, so a rejected config - // leaves nothing but the key itself, which the caller wipes. - if cfg.keysetCacheSize < 0 { - return nil, errors.New("stackencrypt: Config.KeysetCacheSize must not be negative") - } // The key crosses as text: the guest's config parser takes the hex or // base64 form as the CS_CLIENT_KEY variable and secretkey.json hold it. // The string is a copy the marshaller reads once — and copies once more diff --git a/languages/golang/stackencrypt/credentials.go b/languages/golang/stackencrypt/credentials.go index cfcb65ccc..55deb3f89 100644 --- a/languages/golang/stackencrypt/credentials.go +++ b/languages/golang/stackencrypt/credentials.go @@ -23,7 +23,10 @@ import ( // [ResolvedCredentials] for what an implementation owes the client. type Credentials interface { // Resolve produces the credentials for one client. NewClient calls it - // once, and consumes the key it returns. + // once, and consumes the key it returns. A result returned alongside + // an error is consumed too: its key is wiped and its Close is called, + // so an implementation may hand back what it built before it failed + // rather than release it itself. Resolve(ctx context.Context, opts ResolveOptions) (*ResolvedCredentials, error) } @@ -55,6 +58,15 @@ type ResolvedCredentials struct { // The client calls it from Client.Close, or from NewClient when the // client is not made. A Token that outlives it must not be asked again. Close func() error + // MemoryLockError, when not nil, is why memory the credentials hold key + // material in is not locked in RAM: for AutoCredentials, the credential + // guest's, which the client key passed through and the token strategy + // lives in, as stackauth's ProfileStore.MemoryLockError reports it. + // The client folds it into Client.MemoryLocked and + // Client.MemoryLockError, so a checklist asserting the lock sees every + // guest the key was in, not only the crypto guest. Nil when the memory + // is locked, or when the credentials hold nothing. + MemoryLockError error } // ErrNoCredentials is [AutoCredentials] finding no token source or no @@ -115,10 +127,13 @@ var envZeroKMSHost = []string{"CS_ZEROKMS_HOST", "CS_VITUR_HOST"} // workspace's secretkey.json. Only one of the two set is the same as // neither. Set but empty is an error, not a fall-through. // -// The profile is CS_CONFIG_PATH, else ~/.cipherstash; a directory that does -// not exist is not an error — an environment-only deployment has none — it -// just leaves the environment as the only source. Nothing found in either -// place is [ErrNoCredentials]. +// The profile is CS_CONFIG_PATH, else ~/.cipherstash; a profile that cannot +// be opened is not an error — an environment-only deployment has none — it +// just leaves the environment as the only source, as the Rust client does. +// Nothing found in either place is [ErrNoCredentials], and when the profile +// would have been consulted, that error says why it could not be: a +// directory that does not exist, one that cannot be read, a path that is +// not a directory. // // The profile and the token strategies run in stackauth's credential guest, // not in the crypto guest, which still sees no environment and no @@ -137,10 +152,14 @@ func (autoCredentials) Resolve(ctx context.Context, opts ResolveOptions) (resolv authOpts = append(authOpts, stackauth.RequireLockedMemory()) } profile, err := stackauth.Resolve(ctx, authOpts...) - hasProfile := err == nil + // noProfile is why there is no profile to consult, when there is none: + // kept for the errors that would have consulted it, so a profile that + // exists but cannot be opened is not reported as "not logged in". + var noProfile error if errors.Is(err, stackauth.ErrNoProfile) { - // No profile directory: the strategies that need none still run, - // in a guest with nothing mounted. + // The strategies that need no profile still run, in a guest with + // nothing mounted; every profile read on it is ErrNoProfile. + noProfile = err profile, err = stackauth.OpenWithoutProfile(ctx, authOpts...) } if err != nil { @@ -157,9 +176,14 @@ func (autoCredentials) Resolve(ctx context.Context, opts ResolveOptions) (resolv strategy, err := profile.Auto(ctx) switch { case errors.Is(err, stackauth.ErrNotAuthenticated): + if noProfile != nil { + err = fmt.Errorf("%w: %w", err, noProfile) + } return nil, fmt.Errorf("%w: no token: set %s and %s, or run `stash auth login`: %w", ErrNoCredentials, envAccessKey, envWorkspaceCRN, err) - case errors.Is(err, stackauth.ErrAuthConfig): + case errors.Is(err, stackauth.ErrAuthConfig) && accessKeyConfigured(): + // The status covers every configuration fault the guest reports; + // name the variables only when they are what was configured. return nil, fmt.Errorf("stackencrypt: credentials: check %s and %s: %w", envAccessKey, envWorkspaceCRN, err) case err != nil: return nil, fmt.Errorf("stackencrypt: credentials: %w", err) @@ -175,7 +199,7 @@ func (autoCredentials) Resolve(ctx context.Context, opts ResolveOptions) (resolv return nil, err } if key == nil { - if clientID, key, err = clientKeyFromProfile(ctx, profile, hasProfile); err != nil { + if clientID, key, err = clientKeyFromProfile(ctx, profile, noProfile); err != nil { return nil, err } } @@ -189,9 +213,18 @@ func (autoCredentials) Resolve(ctx context.Context, opts ResolveOptions) (resolv // cleanly. return errors.Join(strategy.Close(), profile.Close()) }, + MemoryLockError: profile.MemoryLockError(), }, nil } +// accessKeyConfigured reports whether either variable of the access-key +// strategy is set: what its configuration errors are then about. +func accessKeyConfigured() bool { + _, keySet := os.LookupEnv(envAccessKey) + _, crnSet := os.LookupEnv(envWorkspaceCRN) + return keySet || crnSet +} + // clientKeyFromEnv is stack-kms's EnvKeyProvider: both variables set is the // key, either unset is no key (nil, and the profile is asked), and a set but // empty value is an error, since falling through would quietly use a @@ -217,16 +250,19 @@ func clientKeyFromEnv() (string, *ClientKey, error) { // clientKeyFromProfile is the current workspace's secretkey.json. The // "nothing there" answers — no profile, no current workspace, no file — are // ErrNoCredentials; a file that is there but unreadable keeps its own error. -func clientKeyFromProfile(ctx context.Context, profile *stackauth.ProfileStore, hasProfile bool) (string, *ClientKey, error) { +// noProfile, when not nil, is why the profile could not be opened; the +// store's own answer to a read is then a bare ErrNoProfile, and the reason +// is the useful one. +func clientKeyFromProfile(ctx context.Context, profile *stackauth.ProfileStore, noProfile error) (string, *ClientKey, error) { notConfigured := func(err error) error { return fmt.Errorf("%w: no client key: set %s and %s, or run `stash auth login`: %w", ErrNoCredentials, envClientID, envClientKey, err) } - if !hasProfile { - return "", nil, notConfigured(stackauth.ErrNoProfile) - } workspace, err := profile.CurrentWorkspaceStore(ctx) - if errors.Is(err, stackauth.ErrNoCurrentWorkspace) { + if errors.Is(err, stackauth.ErrNoProfile) && noProfile != nil { + err = noProfile + } + if errors.Is(err, stackauth.ErrNoProfile) || errors.Is(err, stackauth.ErrNoCurrentWorkspace) { return "", nil, notConfigured(err) } if err != nil { diff --git a/languages/golang/stackencrypt/credentials_test.go b/languages/golang/stackencrypt/credentials_test.go index 26068eb5a..36ddbbe7b 100644 --- a/languages/golang/stackencrypt/credentials_test.go +++ b/languages/golang/stackencrypt/credentials_test.go @@ -194,6 +194,11 @@ func TestAutoCredentialsFromTheProfile(t *testing.T) { if got := token(t, resolved); got != "profile-token" { t.Errorf("Token = %q, want the stored device session's", got) } + // The credential guest's lock state travels with the credentials, so + // the client can report it; what it is depends on the host. + if err := resolved.MemoryLockError; err != nil && !errors.Is(err, ErrMemoryLock) { + t.Errorf("MemoryLockError = %v, want nil or ErrMemoryLock", err) + } } func TestAutoCredentialsFromTheEnvironmentWithNoProfile(t *testing.T) { @@ -334,6 +339,44 @@ func TestAutoCredentialsMissing(t *testing.T) { } } +// A profile that exists but cannot be opened is not "not logged in": the +// error says why the profile could not be consulted — here, a +// CS_CONFIG_PATH that names a file — wherever the profile would have +// supplied the missing half. +func TestAutoCredentialsNamesWhyTheProfileCouldNotBeOpened(t *testing.T) { + authGuestOrSkip(t) + file := filepath.Join(t.TempDir(), "profile") + if err := os.WriteFile(file, nil, 0o600); err != nil { + t.Fatal(err) + } + t.Run("the token", func(t *testing.T) { + cleanEnv(t, file) + _, err := resolve(t) + for _, want := range []error{ErrNoCredentials, stackauth.ErrNoProfile} { + if !errors.Is(err, want) { + t.Errorf("error %v, want %v", err, want) + } + } + if err == nil || !strings.Contains(err.Error(), "is not a directory") || !strings.Contains(err.Error(), file) { + t.Errorf("error %q does not say why %s could not be opened", err, file) + } + }) + t.Run("the client key", func(t *testing.T) { + cleanEnv(t, file) + t.Setenv(envAccessKey, testAccessKey) + t.Setenv(envWorkspaceCRN, testCRN) + _, err := resolve(t) + for _, want := range []error{ErrNoCredentials, stackauth.ErrNoProfile} { + if !errors.Is(err, want) { + t.Errorf("error %v, want %v", err, want) + } + } + if err == nil || !strings.Contains(err.Error(), "is not a directory") || !strings.Contains(err.Error(), envClientKey) { + t.Errorf("error %q does not name %s and say why the profile could not be opened", err, envClientKey) + } + }) +} + // A set but empty variable is refused rather than skipped, as stack-kms's // EnvKeyProvider refuses it; and no error from resolution carries the // material of a key the environment or the profile held. @@ -386,6 +429,121 @@ func TestNewClientWithAutoCredentials(t *testing.T) { } } +// A failed NewClient releases the credentials once, through the client it +// made and closed — the same wiring a successful client's Close runs — and +// not again on its way out. +func TestNewClientReleasesTheCredentialsOnceWhenInitFails(t *testing.T) { + guestOrSkip(t) + stub := newStub(t, http.StatusUnauthorized, "", "nope") + var released int + creds := credentialsFunc(func(ctx context.Context, opts ResolveOptions) (*ResolvedCredentials, error) { + r, err := testCredentials(StaticToken("t")).Resolve(ctx, opts) + if err == nil { + r.Close = func() error { released++; return nil } + } + return r, err + }) + _, err := NewClient(context.Background(), Config{Credentials: creds, ZeroKMSURL: stub.URL}) + if !errors.Is(err, ErrUnauthorized) { + t.Fatalf("NewClient: %v, want ErrUnauthorized from the stub", err) + } + if released != 1 { + t.Fatalf("a failed NewClient released the credentials %d times, want once", released) + } +} + +// A Resolve that fails while handing back what it built is consumed as a +// successful one is: the key is wiped and Close runs, once. +func TestNewClientConsumesCredentialsAFailedResolveHandsBack(t *testing.T) { + key := NewClientKey([]byte(testClientKey)) + var released int + resolveErr := errors.New("the token strategy failed") + creds := credentialsFunc(func(context.Context, ResolveOptions) (*ResolvedCredentials, error) { + return &ResolvedCredentials{ + ClientID: testClientID, + ClientKey: key, + Close: func() error { released++; return nil }, + }, resolveErr + }) + _, err := NewClient(context.Background(), Config{Credentials: creds}) + if !errors.Is(err, resolveErr) { + t.Fatalf("NewClient: %v, want the Resolve error", err) + } + if !key.IsZero() { + t.Error("the key a failed Resolve handed back still holds material") + } + if released != 1 { + t.Errorf("the failed Resolve's Close ran %d times, want once", released) + } +} + +// The host-side checks of the config run before the credentials are asked: +// a refused endpoint or cache size costs no resolution — under +// AutoCredentials, no credential guest — and an explicit key is consumed +// all the same. +func TestNewClientRefusesTheConfigBeforeResolvingCredentials(t *testing.T) { + for name, cfg := range map[string]func(*testing.T) Config{ + "an unusable CS_ZEROKMS_HOST": func(t *testing.T) Config { + t.Setenv("CS_ZEROKMS_HOST", "localhost:3002") + return Config{} + }, + "a negative cache size": func(*testing.T) Config { return Config{KeysetCacheSize: -1} }, + } { + t.Run(name, func(t *testing.T) { + cleanEnv(t, t.TempDir()) + cfg := cfg(t) + resolved := false + cfg.Credentials = credentialsFunc(func(ctx context.Context, opts ResolveOptions) (*ResolvedCredentials, error) { + resolved = true + return testCredentials(StaticToken("t")).Resolve(ctx, opts) + }) + if _, err := NewClient(context.Background(), cfg); err == nil { + t.Fatal("NewClient accepted the config") + } + if resolved { + t.Error("the credentials were resolved for a config refused host-side") + } + key := NewClientKey([]byte(testClientKey)) + cfg.Credentials = NewCredentials(testClientID, key, StaticToken("t")) + if _, err := NewClient(context.Background(), cfg); err == nil { + t.Fatal("NewClient accepted the config") + } + if !key.IsZero() { + t.Error("an explicit key was handed back live with the refused config") + } + }) + } +} + +// The client's memory report covers the credentials' memory too: a lock the +// credential guest could not get is a lock the client did not get, wherever +// the client is asked, printed or logged. +func TestClientReportsTheCredentialsMemoryLock(t *testing.T) { + wasm := guestOrSkip(t) + inst, err := newInstance(context.Background(), wasm, &transport{rt: http.DefaultTransport, token: StaticToken("t")}, guest.BestEffort) + if err != nil { + t.Fatal(err) + } + c := newClient(inst, nil) + t.Cleanup(func() { _ = c.Close() }) + if err := c.MemoryLockError(); err != nil { + t.Skipf("this guest's own memory is unlocked here (%v); the fold cannot be told apart", err) + } + c.credentialsLockErr = guest.MemoryLockError(errors.New("RLIMIT_MEMLOCK refused the credential guest")) + if c.MemoryLocked() { + t.Fatal("MemoryLocked with the credentials' memory unlocked") + } + if err := c.MemoryLockError(); !errors.Is(err, ErrMemoryLock) || !strings.Contains(err.Error(), "credential guest") { + t.Fatalf("MemoryLockError = %v, want ErrMemoryLock naming the credential guest", err) + } + if s := fmt.Sprint(c); !strings.Contains(s, "unlocked") || !strings.Contains(s, "credential guest") { + t.Fatalf("Client prints as %q: no credentials' memory state", s) + } + if v := c.LogValue().String(); !strings.Contains(v, "memory_locked=false") || !strings.Contains(v, "credential guest") { + t.Fatalf("Client logs as %q: no credentials' memory state", v) + } +} + // A nil Credentials is AutoCredentials: with nothing configured, the error // is the resolution's, before any guest or request. func TestNewClientDefaultsToAutoCredentials(t *testing.T) { diff --git a/languages/golang/stackencrypt/doc.go b/languages/golang/stackencrypt/doc.go index 0ba46c370..578e0bc90 100644 --- a/languages/golang/stackencrypt/doc.go +++ b/languages/golang/stackencrypt/doc.go @@ -57,7 +57,9 @@ // The guest imports exactly two host functions: an HTTP send, served by any // [net/http.RoundTripper], and a bearer-token fetch, served by a // [TokenSource]. What crosses per ZeroKMS call is what would cross TLS -// anyway; derived key material never leaves the guest. +// anyway; derived key material never leaves the guest. Under +// [AutoCredentials] the same RoundTripper also carries the authentication +// requests to CTS, so one scoped to the ZeroKMS host alone is not enough. // // # Credentials // diff --git a/languages/golang/stackencrypt/live_test.go b/languages/golang/stackencrypt/live_test.go index f86d837a1..59a99b4a0 100644 --- a/languages/golang/stackencrypt/live_test.go +++ b/languages/golang/stackencrypt/live_test.go @@ -2,6 +2,7 @@ package stackencrypt import ( "bytes" + "context" "errors" "os" "reflect" @@ -25,14 +26,31 @@ func liveClient(t *testing.T) *Client { } material := []byte(clientKey) key := NewClientKey(material) - c, err := NewClient(t.Context(), Config{ - Credentials: NewCredentials(clientID, key, StaticToken(token)), - ZeroKMSURL: url, + // The credentials a successful NewClient resolved are released by the + // client's Close, once: the wiring only a real load-keyset response can + // reach. + var released int + creds := credentialsFunc(func(ctx context.Context, opts ResolveOptions) (*ResolvedCredentials, error) { + r, err := NewCredentials(clientID, key, StaticToken(token)).Resolve(ctx, opts) + if err == nil { + r.Close = func() error { released++; return nil } + } + return r, err }) + c, err := NewClient(t.Context(), Config{Credentials: creds, ZeroKMSURL: url}) if err != nil { t.Fatalf("NewClient: %v", err) } - t.Cleanup(func() { _ = c.Close() }) + t.Cleanup(func() { + _ = c.Close() + _ = c.Close() + if released != 1 { + t.Errorf("Close released the credentials %d times, want once", released) + } + }) + if released != 0 { + t.Fatalf("a successful NewClient released the credentials %d times, want 0", released) + } // The successful outcome of the consumption contract, which only a // real load-keyset response can reach: the key is empty and the bytes // it was built from are zero once the client exists. diff --git a/languages/golang/stackencrypt/transport.go b/languages/golang/stackencrypt/transport.go index 8bb309d4c..85db14a65 100644 --- a/languages/golang/stackencrypt/transport.go +++ b/languages/golang/stackencrypt/transport.go @@ -230,6 +230,10 @@ func (t *transport) tokenGet(ctx context.Context, m api.Module, tokenPtrOut, tok tok := []byte(token) defer wipe(tok) if !place(ctx, m, tokenPtrOut, tokenLenOut, tok) { + // The source did its part; the guest could not take the token (no + // allocator, a refused allocation, an out-of-range slot). Say so, + // or the failure reads as the source's. + t.tokenErr = errors.New("stackencrypt: the token could not be handed to the guest") return hostFailed } return 0 From db88f12c90938cdd2b4c8ff0a4b3978d44e52499 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Tue, 29 Sep 2026 03:55:46 +0000 Subject: [PATCH 643/686] fix(go): report the credentials' memory lock live and name a spent key ResolvedCredentials.MemoryLockError is now a func the client asks each time it reports, not a snapshot taken at Resolve: under best-effort locking the credential guest can commit unlocked memory later, on a growth for the first token exchange or a refresh, and the client's MemoryLocked, MemoryLockError, String and LogValue follow it. AutoCredentials hands over the profile's own method. A second NewClient given the same NewCredentials fails with ErrCredentialsConsumed, naming the reason, rather than reporting the values the caller did supply as missing. The consumption is tracked on the credentials, so a never-populated key still gets the config error. Two doc paragraphs are re-wrapped to the column the rest of each file keeps. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TJc73YNcTeGaLDhP2P3LNM --- languages/golang/stackauth/doc.go | 16 ++++---- languages/golang/stackencrypt/README.md | 1 + languages/golang/stackencrypt/client.go | 12 ++++-- languages/golang/stackencrypt/credentials.go | 39 +++++++++++++----- .../golang/stackencrypt/credentials_test.go | 40 +++++++++++++++++-- languages/golang/stackencrypt/doc.go | 15 ++++--- languages/golang/stackencrypt/guest_test.go | 13 +++--- 7 files changed, 97 insertions(+), 39 deletions(-) diff --git a/languages/golang/stackauth/doc.go b/languages/golang/stackauth/doc.go index 1a20347e9..61011882d 100644 --- a/languages/golang/stackauth/doc.go +++ b/languages/golang/stackauth/doc.go @@ -6,16 +6,16 @@ // // # Shape // -// A [ProfileStore] is one guest instance over one mounted directory. [Resolve] -// finds the profile directory the way the Rust crate does (CS_CONFIG_PATH, -// then ~/.cipherstash); [Open] takes one; [OpenWithoutProfile] mounts -// nothing, for the access-key and OIDC strategies where there is no profile. The guest is given that directory +// A [ProfileStore] is one guest instance over one mounted directory. +// [Resolve] finds the profile directory the way the Rust crate does +// (CS_CONFIG_PATH, then ~/.cipherstash); [Open] takes one; +// [OpenWithoutProfile] mounts nothing, for the access-key and OIDC +// strategies where there is no profile. The guest is given that directory // and nothing else: no environment, no other path, and no way out through a // symlink inside it, which the mount refuses to follow. Authentication HTTP -// requests go through the Go host's transport import. -// Everything -// the napi binding of stack-profile exposes is a method here, named as in -// Rust: the current workspace ([ProfileStore.CurrentWorkspace], +// requests go through the Go host's transport import. Everything the napi +// binding of stack-profile exposes is a method here, named as in Rust: the +// current workspace ([ProfileStore.CurrentWorkspace], // [ProfileStore.SetCurrentWorkspace], [ProfileStore.ClearCurrentWorkspace]), // the workspaces on disk ([ProfileStore.ListWorkspaces]), a store scoped to // one workspace ([ProfileStore.WorkspaceStore], diff --git a/languages/golang/stackencrypt/README.md b/languages/golang/stackencrypt/README.md index c22dd9823..94539f7c0 100644 --- a/languages/golang/stackencrypt/README.md +++ b/languages/golang/stackencrypt/README.md @@ -172,6 +172,7 @@ small and reveals nothing about plaintext or key material. | `ErrState` | The client has been closed: by `Close`, by a call its context interrupted, or by a guest trap. | | `ErrMemoryLock` | The instance's memory could not be locked in RAM. Returned by `NewClient` under `RequireLockedMemory`, and by a call whose growth could not be locked; otherwise reported by `MemoryLockError`. | | `ErrNoCredentials` | `NewClient` found no token source or no client key, in the environment or the profile. The message names what to set. | +| `ErrCredentialsConsumed` | `NewCredentials` given to a second `NewClient`: the first consumed its key. Build new credentials, with a new key, for another client. | | `ErrInternal` | An unexpected failure inside the guest. | ## How it works under the hood diff --git a/languages/golang/stackencrypt/client.go b/languages/golang/stackencrypt/client.go index cf0647204..4da436458 100644 --- a/languages/golang/stackencrypt/client.go +++ b/languages/golang/stackencrypt/client.go @@ -93,9 +93,10 @@ type Client struct { // Close. Nil when they hold nothing open. releaseCredentials func() error // credentialsLockErr is the resolved credentials' MemoryLockError: the - // memory the key passed through before it reached this guest, folded - // into MemoryLocked so the report covers every guest that held it. - credentialsLockErr error + // memory the key passed through before it reached this guest, asked + // live and folded into MemoryLocked so the report covers every guest + // that held it, as it is now. Nil when the credentials report nothing. + credentialsLockErr func() error // cleanup releases the instance if the Client becomes unreachable // without Close: the forgot-to-close case in a running process. It // does nothing at process exit, and is not meant to. @@ -248,7 +249,10 @@ func (c *Client) MemoryLockError() error { if lerr := c.inst.mem.LockError(); lerr != nil { err = guest.MemoryLockError(lerr) } - return errors.Join(err, c.credentialsLockErr) + if c.credentialsLockErr != nil { + err = errors.Join(err, c.credentialsLockErr()) + } + return err } // memoryState is the memory's state for a log line: "locked", or the diff --git a/languages/golang/stackencrypt/credentials.go b/languages/golang/stackencrypt/credentials.go index 55deb3f89..8a17b333e 100644 --- a/languages/golang/stackencrypt/credentials.go +++ b/languages/golang/stackencrypt/credentials.go @@ -7,6 +7,7 @@ import ( "net/http" "net/url" "os" + "sync/atomic" "github.com/cipherstash/cipherstash-suite/bindings/go/stackauth" ) @@ -58,15 +59,18 @@ type ResolvedCredentials struct { // The client calls it from Client.Close, or from NewClient when the // client is not made. A Token that outlives it must not be asked again. Close func() error - // MemoryLockError, when not nil, is why memory the credentials hold key - // material in is not locked in RAM: for AutoCredentials, the credential - // guest's, which the client key passed through and the token strategy - // lives in, as stackauth's ProfileStore.MemoryLockError reports it. - // The client folds it into Client.MemoryLocked and - // Client.MemoryLockError, so a checklist asserting the lock sees every - // guest the key was in, not only the crypto guest. Nil when the memory - // is locked, or when the credentials hold nothing. - MemoryLockError error + // MemoryLockError, when not nil, reports why memory the credentials + // hold key material in is not locked in RAM: for AutoCredentials, the + // credential guest's, which the client key passed through and the + // token strategy lives in, as stackauth's ProfileStore.MemoryLockError + // reports it. It is asked each time, not once: under best-effort + // locking a guest's memory can become unlocked later, when a growth + // for a token exchange or a refresh cannot be locked. The client folds + // its answer into Client.MemoryLocked and Client.MemoryLockError, so a + // checklist asserting the lock sees every guest the key was in, not + // only the crypto guest. Nil, or returning nil, when the memory is + // locked or the credentials hold nothing. + MemoryLockError func() error } // ErrNoCredentials is [AutoCredentials] finding no token source or no @@ -77,18 +81,31 @@ var ErrNoCredentials = errors.New("stackencrypt: no credentials") // NewCredentials is [Credentials] from explicit values: a client id, a // client key (from [NewClientKey], or stackauth's typed read), and a token // source. The key is consumed by the first NewClient given these -// credentials; a second is refused, as a key is for one client. +// credentials; a second is refused with [ErrCredentialsConsumed], as a key +// is for one client. func NewCredentials(clientID string, key *ClientKey, token TokenSource) Credentials { return &explicitCredentials{clientID: clientID, key: key, token: token} } +// ErrCredentialsConsumed is [NewCredentials] resolved a second time: the +// first NewClient consumed its key, so there is nothing left to give. +// Build new credentials, with a new key, for another client. +var ErrCredentialsConsumed = errors.New("stackencrypt: the credentials' client key was already consumed by another client") + type explicitCredentials struct { clientID string key *ClientKey token TokenSource + // consumed is set by the first Resolve: what it handed out is the + // caller's to wipe, and a second caller must not be told its values + // were missing when they were spent. + consumed atomic.Bool } func (c *explicitCredentials) Resolve(context.Context, ResolveOptions) (*ResolvedCredentials, error) { + if !c.consumed.CompareAndSwap(false, true) { + return nil, ErrCredentialsConsumed + } return &ResolvedCredentials{ClientID: c.clientID, ClientKey: c.key, Token: c.token}, nil } @@ -213,7 +230,7 @@ func (autoCredentials) Resolve(ctx context.Context, opts ResolveOptions) (resolv // cleanly. return errors.Join(strategy.Close(), profile.Close()) }, - MemoryLockError: profile.MemoryLockError(), + MemoryLockError: profile.MemoryLockError, }, nil } diff --git a/languages/golang/stackencrypt/credentials_test.go b/languages/golang/stackencrypt/credentials_test.go index 36ddbbe7b..82c74e7df 100644 --- a/languages/golang/stackencrypt/credentials_test.go +++ b/languages/golang/stackencrypt/credentials_test.go @@ -196,8 +196,11 @@ func TestAutoCredentialsFromTheProfile(t *testing.T) { } // The credential guest's lock state travels with the credentials, so // the client can report it; what it is depends on the host. - if err := resolved.MemoryLockError; err != nil && !errors.Is(err, ErrMemoryLock) { - t.Errorf("MemoryLockError = %v, want nil or ErrMemoryLock", err) + if resolved.MemoryLockError == nil { + t.Fatal("MemoryLockError is not set: the credential guest's lock state is not reported") + } + if err := resolved.MemoryLockError(); err != nil && !errors.Is(err, ErrMemoryLock) { + t.Errorf("MemoryLockError() = %v, want nil or ErrMemoryLock", err) } } @@ -529,7 +532,15 @@ func TestClientReportsTheCredentialsMemoryLock(t *testing.T) { if err := c.MemoryLockError(); err != nil { t.Skipf("this guest's own memory is unlocked here (%v); the fold cannot be told apart", err) } - c.credentialsLockErr = guest.MemoryLockError(errors.New("RLIMIT_MEMLOCK refused the credential guest")) + // The credentials' state is asked each time: what was locked at + // NewClient can become unlocked on a later growth, and the client's + // report follows it. + var lockErr error + c.credentialsLockErr = func() error { return lockErr } + if !c.MemoryLocked() { + t.Fatalf("MemoryLocked false while the credentials report locked: %v", c.MemoryLockError()) + } + lockErr = guest.MemoryLockError(errors.New("RLIMIT_MEMLOCK refused the credential guest")) if c.MemoryLocked() { t.Fatal("MemoryLocked with the credentials' memory unlocked") } @@ -544,6 +555,29 @@ func TestClientReportsTheCredentialsMemoryLock(t *testing.T) { } } +// A second NewClient given the same NewCredentials is refused for the +// reason that holds — the key was consumed by the first — not for values +// the caller did supply. +func TestNewCredentialsRefusesASecondClient(t *testing.T) { + guestOrSkip(t) + stub := newStub(t, http.StatusUnauthorized, "", "nope") + creds := testCredentials(StaticToken("t")) + cfg := Config{Credentials: creds, ZeroKMSURL: stub.URL} + if _, err := NewClient(context.Background(), cfg); !errors.Is(err, ErrUnauthorized) { + t.Fatalf("first NewClient: %v, want ErrUnauthorized from the stub", err) + } + _, err := NewClient(context.Background(), cfg) + if !errors.Is(err, ErrCredentialsConsumed) { + t.Fatalf("second NewClient: %v, want ErrCredentialsConsumed", err) + } + if strings.Contains(err.Error(), "required") { + t.Errorf("the error blames missing values: %q", err) + } + if len(stub.requests) != 1 { + t.Errorf("the second NewClient made a request: %d in all", len(stub.requests)) + } +} + // A nil Credentials is AutoCredentials: with nothing configured, the error // is the resolution's, before any guest or request. func TestNewClientDefaultsToAutoCredentials(t *testing.T) { diff --git a/languages/golang/stackencrypt/doc.go b/languages/golang/stackencrypt/doc.go index 578e0bc90..e3bb1f1ab 100644 --- a/languages/golang/stackencrypt/doc.go +++ b/languages/golang/stackencrypt/doc.go @@ -8,14 +8,13 @@ // resolves the client's [Credentials], instantiates the embedded guest, // hands it the client key once — the [ClientKey] the credentials resolved // to is consumed and wiped, whatever the outcome — and loads the client's -// default keyset. Every keyset the client uses after -// that is -// selected per call through a [KeysetSelector] and loaded on first use by -// the guest's own bounded cache; nothing the host could allocate, alias or -// free crosses the boundary. [Client.Close] runs the guest's shutdown so the -// client key and every loaded index key are wiped before the instance is -// freed — closing a wasm instance runs no Rust destructors on its own. -// Close is hygiene, not the security story: see Memory below. +// default keyset. Every keyset the client uses after that is selected per +// call through a [KeysetSelector] and loaded on first use by the guest's +// own bounded cache; nothing the host could allocate, alias or free crosses +// the boundary. [Client.Close] runs the guest's shutdown so the client key +// and every loaded index key are wiped before the instance is freed — +// closing a wasm instance runs no Rust destructors on its own. Close is +// hygiene, not the security story: see Memory below. // // A [Cipher] is the client bound to one keyset ([Client.Keyset] and // [Client.DefaultKeyset], the Rust crate's StackCipher::keyset and diff --git a/languages/golang/stackencrypt/guest_test.go b/languages/golang/stackencrypt/guest_test.go index 586b7af86..1991cd45c 100644 --- a/languages/golang/stackencrypt/guest_test.go +++ b/languages/golang/stackencrypt/guest_test.go @@ -518,10 +518,13 @@ func TestClientKeyIsConsumedAndNeverPrinted(t *testing.T) { cfg := testConfig(stub.URL) key := NewClientKey(material) cfg.Credentials = NewCredentials(testClientID, key, StaticToken("stub-token")) - resolved, err := cfg.Credentials.Resolve(context.Background(), ResolveOptions{}) + // Resolving consumes the credentials, so the printed resolution is a + // separate set's, over another copy of the key. + resolved, err := NewCredentials(testClientID, NewClientKey([]byte(testClientKey)), StaticToken("stub-token")).Resolve(context.Background(), ResolveOptions{}) if err != nil { t.Fatal(err) } + defer resolved.ClientKey.Wipe() // %x and %d reach a struct's fields without asking a Stringer; the // key's Formatter answers for them. for _, verb := range []string{"%v", "%+v", "%#v", "%s", "%q", "%x", "%d"} { @@ -544,11 +547,11 @@ func TestClientKeyIsConsumedAndNeverPrinted(t *testing.T) { t.Fatalf("byte %d of the key material was not wiped", i) } } - // A consumed key does not make a second client, and asks nothing of - // ZeroKMS trying. + // A consumed key does not make a second client, says so, and asks + // nothing of ZeroKMS trying. before := len(stub.requests) - if _, err := NewClient(context.Background(), cfg); err == nil || errors.Is(err, ErrUnauthorized) { - t.Errorf("NewClient with a consumed key: %v, want a config error before any request", err) + if _, err := NewClient(context.Background(), cfg); !errors.Is(err, ErrCredentialsConsumed) { + t.Errorf("NewClient with a consumed key: %v, want ErrCredentialsConsumed before any request", err) } if len(stub.requests) != before { t.Errorf("a consumed key made %d request(s)", len(stub.requests)-before) From 7193e612ccd9f846822d6a17c955cba080ab1ec9 Mon Sep 17 00:00:00 2001 From: Dan Draper <dan@cipherstash.com> Date: Mon, 28 Sep 2026 21:52:53 -0700 Subject: [PATCH 644/686] fix(go): a refused config marks explicit credentials consumed NewClient wipes an explicit key when it refuses the config before asking the credentials (a negative cache size, an unusable CS_ZEROKMS_HOST), but left the credentials unmarked. A retry with the config corrected then resolved the wiped key and failed with "the credentials' client id and client key are required", blaming values the caller did supply. consumeUnresolved now marks them consumed, so the retry is refused with ErrCredentialsConsumed and sends nothing; a test pins it. --- languages/golang/stackencrypt/client.go | 8 +++++-- languages/golang/stackencrypt/credentials.go | 9 ++++---- .../golang/stackencrypt/credentials_test.go | 23 +++++++++++++++++++ 3 files changed, 34 insertions(+), 6 deletions(-) diff --git a/languages/golang/stackencrypt/client.go b/languages/golang/stackencrypt/client.go index 4da436458..06993d4d6 100644 --- a/languages/golang/stackencrypt/client.go +++ b/languages/golang/stackencrypt/client.go @@ -210,10 +210,14 @@ func NewClient(ctx context.Context, cfg Config) (_ *Client, err error) { // consumeUnresolved is what NewClient owes a Credentials it refuses a // config without asking: explicit credentials hold their key from -// construction, so it is wiped rather than handed back live. Any other -// implementation has not been asked, and holds nothing of this client's. +// construction, so it is wiped rather than handed back live, and they are +// marked consumed, so a retry with a corrected config is refused with +// ErrCredentialsConsumed rather than told the wiped values are missing. +// Any other implementation has not been asked, and holds nothing of this +// client's. func consumeUnresolved(creds Credentials) { if explicit, ok := creds.(*explicitCredentials); ok { + explicit.consumed.Store(true) explicit.key.Wipe() } } diff --git a/languages/golang/stackencrypt/credentials.go b/languages/golang/stackencrypt/credentials.go index 8a17b333e..1fc76bcb7 100644 --- a/languages/golang/stackencrypt/credentials.go +++ b/languages/golang/stackencrypt/credentials.go @@ -87,10 +87,11 @@ func NewCredentials(clientID string, key *ClientKey, token TokenSource) Credenti return &explicitCredentials{clientID: clientID, key: key, token: token} } -// ErrCredentialsConsumed is [NewCredentials] resolved a second time: the -// first NewClient consumed its key, so there is nothing left to give. -// Build new credentials, with a new key, for another client. -var ErrCredentialsConsumed = errors.New("stackencrypt: the credentials' client key was already consumed by another client") +// ErrCredentialsConsumed is [NewCredentials] given to a second NewClient: +// the first consumed its key — whether it made a client or refused its +// config — so there is nothing left to give. Build new credentials, with a +// new key, for another client. +var ErrCredentialsConsumed = errors.New("stackencrypt: the credentials' client key was already consumed by an earlier NewClient") type explicitCredentials struct { clientID string diff --git a/languages/golang/stackencrypt/credentials_test.go b/languages/golang/stackencrypt/credentials_test.go index 82c74e7df..d95a3fa1d 100644 --- a/languages/golang/stackencrypt/credentials_test.go +++ b/languages/golang/stackencrypt/credentials_test.go @@ -518,6 +518,29 @@ func TestNewClientRefusesTheConfigBeforeResolvingCredentials(t *testing.T) { } } +// A config refused before the credentials are asked still consumes explicit +// credentials: the retry with the config corrected is refused because the +// key was spent, not told the key it was given is missing, and sends no +// request. +func TestNewCredentialsRefusedConfigThenRetryIsConsumed(t *testing.T) { + guestOrSkip(t) + stub := newStub(t, http.StatusUnauthorized, "", "nope") + creds := testCredentials(StaticToken("t")) + if _, err := NewClient(context.Background(), Config{Credentials: creds, ZeroKMSURL: stub.URL, KeysetCacheSize: -1}); err == nil { + t.Fatal("NewClient accepted a negative cache size") + } + _, err := NewClient(context.Background(), Config{Credentials: creds, ZeroKMSURL: stub.URL}) + if !errors.Is(err, ErrCredentialsConsumed) { + t.Fatalf("retry with a corrected config: %v, want ErrCredentialsConsumed", err) + } + if strings.Contains(err.Error(), "required") { + t.Errorf("the error blames missing values: %q", err) + } + if len(stub.requests) != 0 { + t.Errorf("the refused config and its retry made %d requests, want none", len(stub.requests)) + } +} + // The client's memory report covers the credentials' memory too: a lock the // credential guest could not get is a lock the client did not get, wherever // the client is asked, printed or logged. From 5cecdcb6ab45c944e7fcdda422557cc75a87c22d Mon Sep 17 00:00:00 2001 From: Dan Draper <dan@cipherstash.com> Date: Sun, 27 Sep 2026 22:13:55 -0700 Subject: [PATCH 645/686] feat(go): functional options for stackencrypt.NewClient; Config removed NewClient(ctx, opts ...ClientOption) replaces NewClient(ctx, Config), so connecting reads like Encrypt and Decrypt already do. There is one option per former Config field, and each has a default: WithCredentials(c) AutoCredentials() WithZeroKMSURL(url) CS_ZEROKMS_HOST, else the token's services claim WithKeysetCacheSize(n) the crate default (1024) WithTransport(rt) http.DefaultTransport WithGuest(wasm) the embedded guest WithRequireLockedMemory() off NewClient(ctx) with no options resolves credentials as AutoCredentials does. When an option is given twice, the later one wins. Config is removed rather than deprecated, because the SDK has not shipped (aws-sdk-go-v2's LoadDefaultConfig(ctx, opts...) is the precedent). OIDCFederation(crn, provider) is added as a Credentials: the token is federated from the application's identity provider through stackauth, and the client key is resolved as AutoCredentials resolves it. With it, the example in the issue works as written. Call sites, the example, the READMEs and the package docs are updated. Refs CIP-4153 Co-Authored-By: Claude <noreply@anthropic.com> --- languages/golang/internal/guest/memory.go | 2 +- languages/golang/stackauth/README.md | 8 +- languages/golang/stackauth/store.go | 2 +- languages/golang/stackencrypt/README.md | 66 ++++++-- languages/golang/stackencrypt/client.go | 79 +++------ languages/golang/stackencrypt/credentials.go | 92 ++++++++--- .../golang/stackencrypt/credentials_test.go | 44 ++--- languages/golang/stackencrypt/doc.go | 22 +-- languages/golang/stackencrypt/errors.go | 2 +- .../golang/stackencrypt/example/README.md | 4 +- languages/golang/stackencrypt/example/main.go | 8 +- languages/golang/stackencrypt/guest.go | 2 +- languages/golang/stackencrypt/guest_test.go | 89 +++++------ languages/golang/stackencrypt/live_test.go | 2 +- languages/golang/stackencrypt/memory_test.go | 11 +- languages/golang/stackencrypt/options.go | 82 ++++++++++ languages/golang/stackencrypt/options_test.go | 150 ++++++++++++++++++ 17 files changed, 472 insertions(+), 193 deletions(-) create mode 100644 languages/golang/stackencrypt/options.go create mode 100644 languages/golang/stackencrypt/options_test.go diff --git a/languages/golang/internal/guest/memory.go b/languages/golang/internal/guest/memory.go index 58eb72288..93734d12d 100644 --- a/languages/golang/internal/guest/memory.go +++ b/languages/golang/internal/guest/memory.go @@ -42,7 +42,7 @@ import ( // reported through LockError, which each public package surfaces on its // client (stackencrypt: Client.MemoryLocked and Client.MemoryLockError) so // an operator can see it and raise the limit; Strict turns it into a -// constructor failure (stackencrypt: Config.RequireLockedMemory). +// constructor failure (stackencrypt: WithRequireLockedMemory). // LockPolicy is what a refused lock means for an instance. type LockPolicy uint8 diff --git a/languages/golang/stackauth/README.md b/languages/golang/stackauth/README.md index 7ab2e8c0d..c403ed041 100644 --- a/languages/golang/stackauth/README.md +++ b/languages/golang/stackauth/README.md @@ -17,7 +17,7 @@ cross-process refresh lock for device sessions. ## Use Most applications never call this package directly: a `stackencrypt` -client built with the zero `Config` resolves its credentials with +client built with `NewClient(ctx)` and no options resolves its credentials with `stackencrypt.AutoCredentials`, which reads the environment first and then the profile, through this package. Use it directly to take the profile apart yourself: @@ -50,10 +50,10 @@ func run(ctx context.Context) error { return err } defer source.Close() - client, err := stackencrypt.NewClient(ctx, stackencrypt.Config{ + client, err := stackencrypt.NewClient(ctx, // The key is consumed and wiped by NewClient. - Credentials: stackencrypt.NewCredentials(clientID, clientKey, source), - }) + stackencrypt.WithCredentials(stackencrypt.NewCredentials(clientID, clientKey, source)), + ) if err != nil { return err } diff --git a/languages/golang/stackauth/store.go b/languages/golang/stackauth/store.go index 6fe37d237..ab6508778 100644 --- a/languages/golang/stackauth/store.go +++ b/languages/golang/stackauth/store.go @@ -42,7 +42,7 @@ func WithGuest(wasm []byte) Option { // memory cannot be locked in RAM or, on Linux, excluded from core dumps, // instead of continuing with memory that may be swapped or dumped and // reporting so through [ProfileStore.MemoryLocked]. It holds for the life -// of the store, as stackencrypt's Config.RequireLockedMemory does for a +// of the store, as stackencrypt's WithRequireLockedMemory does for a // client. func RequireLockedMemory() Option { return func(o *options) { o.requireLocked = true } diff --git a/languages/golang/stackencrypt/README.md b/languages/golang/stackencrypt/README.md index 94539f7c0..09133b668 100644 --- a/languages/golang/stackencrypt/README.md +++ b/languages/golang/stackencrypt/README.md @@ -33,7 +33,7 @@ import ( ) func run(ctx context.Context) error { - client, err := stackencrypt.NewClient(ctx, stackencrypt.Config{}) + client, err := stackencrypt.NewClient(ctx) if err != nil { return err // stackencrypt.ErrNoCredentials: nothing configured } @@ -52,11 +52,34 @@ keyset, and `ctx` bounds that request. It has nothing to do with an `stackencrypt.Context`. Every method that can reach ZeroKMS takes a `context.Context` first, for the same reason. +Everything else is a functional option, and each has a default: + +```go +client, err := stackencrypt.NewClient(ctx, + stackencrypt.WithCredentials(stackencrypt.OIDCFederation(crn, provider)), + stackencrypt.WithZeroKMSURL("https://zerokms.example"), + stackencrypt.WithTransport(rt), + stackencrypt.WithKeysetCacheSize(4096), + stackencrypt.WithRequireLockedMemory(), +) +``` + +| Option | Default | +|---|---| +| `WithCredentials(c)` | `AutoCredentials()`: see below. | +| `WithZeroKMSURL(url)` | `CS_ZEROKMS_HOST` if set, otherwise the endpoint in the token. | +| `WithTransport(rt)` | `http.DefaultTransport`. Used for ZeroKMS, and for token requests when the credentials make them. | +| `WithKeysetCacheSize(n)` | 1024 keysets beyond the default one. | +| `WithRequireLockedMemory()` | Off: memory that cannot be locked is reported, not refused. See below. | +| `WithGuest(wasm)` | The embedded guest module. | + +If an option is given twice, the later one wins. + ### Credentials -The zero `Config` finds its credentials the way the Rust client does, with -`AutoCredentials`: the environment first, then the developer profile that -`stash auth login` writes. On a developer machine, logging in is enough. +With no `WithCredentials`, `NewClient` finds its credentials the way the +Rust client does, with `AutoCredentials`: the environment first, then the +developer profile that `stash auth login` writes. On a developer machine, logging in is enough. In CI or a deployment, the environment supplies them. The first two rows are what a deployment with no profile needs; the rest override what would otherwise be resolved: @@ -76,10 +99,10 @@ could not be opened, so an unreadable or mistyped `CS_CONFIG_PATH` is not reported as "not logged in". The endpoint variables are read whatever the credentials, `NewCredentials` -included, as the Rust client reads them. A service that left `ZeroKMSURL` -empty and relied on the token's services claim now follows +included, as the Rust client reads them. A service that passes no +`WithZeroKMSURL` and relies on the token's services claim now follows `CS_ZEROKMS_HOST` or `CS_VITUR_HOST` if either is set in its environment, -so a value exported there for another tool is worth checking on upgrade. +so a value exported there for another tool is worth checking. Resolution happens host-side, in Go. The profile and the token strategies run in `stackauth`'s credential guest; the crypto guest that holds the @@ -89,20 +112,29 @@ lives as long as the client, and `Close` releases it. To supply the credentials yourself, pass `NewCredentials`: ```go -client, err := stackencrypt.NewClient(ctx, stackencrypt.Config{ - Credentials: stackencrypt.NewCredentials(clientID, clientKey, tokenSource), -}) +client, err := stackencrypt.NewClient(ctx, + stackencrypt.WithCredentials(stackencrypt.NewCredentials(clientID, clientKey, tokenSource)), +) ``` `tokenSource` is asked for the bearer token on every request. `StaticToken` is the simplest source; a `TokenFunc` can fetch or refresh one, and every -`stackauth` strategy is one. `Credentials` is an interface, so another -source of credentials can implement it. +`stackauth` strategy is one. + +To authenticate through your own identity provider, pass `OIDCFederation` +with the workspace CRN and a provider of the IdP's tokens. CTS exchanges +the IdP token for a CipherStash one, and the provider is asked again only +when that token needs replacing. `stackauth.OAuth2TokenSource` adapts a +`golang.org/x/oauth2` source. The client key is found as `AutoCredentials` +finds it. + +`Credentials` is an interface, so another source of credentials can +implement it. `ClientKey` is an opaque type, not a string: it prints a redaction under every verb, so logged credentials never show the key. `NewClientKey` takes ownership of the slice it is given, and `NewClient` consumes the key — -whatever the outcome, even a config it refuses, the key is empty afterwards +whatever the outcome, even options it refuses, the key is empty afterwards and that slice is zero. A key is for one client; build another for another client. What the SDK cannot reach is what the key was built *from*: a string read from the environment is Go's, immutable, and lives until @@ -133,15 +165,15 @@ lost, and nothing on a host without swap. `client.MemoryLocked()` reports the outcome and `client.MemoryLockError()` names the limit to raise (`ulimit -l`, a systemd `LimitMEMLOCK=`, a pod `securityContext`) and the size the instance holds. For a deployment that would rather not start than -run unlocked, set `RequireLockedMemory` and `NewClient` fails with +run unlocked, pass `WithRequireLockedMemory()` and `NewClient` fails with `ErrMemoryLock`. That policy holds for the life of the client: memory the instance later grows into must lock too, or the call that needed it fails with `ErrMemoryLock`, so grant a limit with room to grow. A `Client` prints its memory state with `%v` and logs it as a `slog` group, so a startup log shows it. -Production checklist: assert `MemoryLocked()` at startup, or set -`RequireLockedMemory`. Handling `SIGTERM` for a graceful shutdown is +Production checklist: assert `MemoryLocked()` at startup, or pass +`WithRequireLockedMemory()`. Handling `SIGTERM` for a graceful shutdown is ordinary Go practice and worth doing for your own reasons; the SDK does not depend on it and installs no signal handler of its own. @@ -170,7 +202,7 @@ small and reveals nothing about plaintext or key material. | `ErrKMS` | Any other ZeroKMS failure. | | `ErrConflict` | ZeroKMS reported a resource conflict. | | `ErrState` | The client has been closed: by `Close`, by a call its context interrupted, or by a guest trap. | -| `ErrMemoryLock` | The instance's memory could not be locked in RAM. Returned by `NewClient` under `RequireLockedMemory`, and by a call whose growth could not be locked; otherwise reported by `MemoryLockError`. | +| `ErrMemoryLock` | The instance's memory could not be locked in RAM. Returned by `NewClient` under `WithRequireLockedMemory()`, and by a call whose growth could not be locked; otherwise reported by `MemoryLockError`. | | `ErrNoCredentials` | `NewClient` found no token source or no client key, in the environment or the profile. The message names what to set. | | `ErrCredentialsConsumed` | `NewCredentials` given to a second `NewClient`: the first consumed its key. Build new credentials, with a new key, for another client. | | `ErrInternal` | An unexpected failure inside the guest. | diff --git a/languages/golang/stackencrypt/client.go b/languages/golang/stackencrypt/client.go index 06993d4d6..47e42099a 100644 --- a/languages/golang/stackencrypt/client.go +++ b/languages/golang/stackencrypt/client.go @@ -15,53 +15,6 @@ import ( "github.com/cipherstash/vitaminc/bindings/go/vcvalue" ) -// Config configures a [Client]. The zero Config is a working one: every -// field has a default, and the credentials default to [AutoCredentials]. -type Config struct { - // Credentials supplies the client id, the client key and the token - // source. Nil means [AutoCredentials]: the environment, then the - // developer profile. [NewCredentials] takes the three explicitly. The - // client key is consumed: NewClient marshals it into the config - // buffer, wipes the key, and wipes the buffer once the guest has the - // key, so after NewClient returns — whatever the outcome, a config it - // refused included — the key is empty and the bytes it was built from - // are zero. A key is for one client. - Credentials Credentials - // ZeroKMSURL pins the ZeroKMS endpoint. When empty, CS_ZEROKMS_HOST - // (or the legacy CS_VITUR_HOST) pins it if set — a set value that is - // not an http(s) URL is an error — and otherwise the endpoint is - // resolved from the access token's services claim on first use. The - // variables are read whatever the Credentials, as stack-kms reads them. - ZeroKMSURL string - // KeysetCacheSize is how many keysets beyond the default the guest keeps - // loaded; zero means the crate default (1024). - KeysetCacheSize int - // Transport performs the HTTP requests to ZeroKMS and, under - // [AutoCredentials], the authentication requests stackauth's credential - // guest makes to CTS: an access-key exchange, a device-session refresh. - // A RoundTripper scoped to the ZeroKMS host alone (a pinned client - // certificate, an egress allowlist) refuses those; the failure then - // surfaces as the token source's. Nil means http.DefaultTransport. - Transport http.RoundTripper - // Guest overrides the embedded wasm module. Nil means the embedded one. - Guest []byte - // RequireLockedMemory makes NewClient fail with ErrMemoryLock when the - // guest's memory cannot be locked in RAM or, on Linux, excluded from - // core dumps, instead of continuing with memory that may be swapped or - // dumped and reporting so through Client.MemoryLocked. It holds for - // the life of the client: a later growth of the guest's memory that - // cannot be locked is refused too, and what the guest already holds - // stays locked. When the growth was for a buffer the host is staging, - // the call fails with ErrMemoryLock and the client goes on. When it - // was for the guest's own allocation, the guest cannot report it: it - // aborts, and the client is closed with its keys wiped, the call still - // failing with ErrMemoryLock. Set it where swap is a real exposure and - // the deployment grants a lock limit with room for the guest to grow - // (RLIMIT_MEMLOCK on Linux; the error names the size held so far); see - // [Client.MemoryLocked]. - RequireLockedMemory bool -} - // Client is one wasm instance holding one ZeroKMS client: its key, its // default keyset, and the keysets it has loaded since. It is safe for // concurrent use; calls are serialised internally, because a wasm instance @@ -108,12 +61,20 @@ type Client struct { // which is where credentials that resolve but do not work fail: a token that // cannot be minted or is refused fails here, not at first use. The returned // client is ready to seal. -func NewClient(ctx context.Context, cfg Config) (_ *Client, err error) { - rt := cfg.Transport +// +// With no options it is a working client: the credentials are +// [AutoCredentials], and every other setting has a default. Each +// [ClientOption] changes one. +func NewClient(ctx context.Context, opts ...ClientOption) (_ *Client, err error) { + var cfg clientOptions + for _, opt := range opts { + opt(&cfg) + } + rt := cfg.transport if rt == nil { rt = http.DefaultTransport } - creds := cfg.Credentials + creds := cfg.credentials if creds == nil { creds = AutoCredentials() } @@ -121,13 +82,13 @@ func NewClient(ctx context.Context, cfg Config) (_ *Client, err error) { // credentials, and under AutoCredentials resolving means instantiating // the credential guest and reading the profile, which a config refused // here should not pay for. A refused config still consumes an explicit - // key, as Config.Credentials promises; any other Credentials has not + // key, as WithCredentials promises; any other Credentials has not // been asked yet, so holds nothing of this client's. - zerokmsURL, err := zerokmsEndpoint(cfg.ZeroKMSURL) - if err == nil && cfg.KeysetCacheSize < 0 { - err = errors.New("stackencrypt: Config.KeysetCacheSize must not be negative") + zerokmsURL, err := zerokmsEndpoint(cfg.zerokmsURL) + if err == nil && cfg.keysetCacheSize < 0 { + err = errors.New("stackencrypt: WithKeysetCacheSize must not be negative") } - wasm := cfg.Guest + wasm := cfg.guest if err == nil && wasm == nil { wasm, err = embeddedGuest() } @@ -135,7 +96,7 @@ func NewClient(ctx context.Context, cfg Config) (_ *Client, err error) { consumeUnresolved(creds) return nil, err } - resolved, err := creds.Resolve(ctx, ResolveOptions{Transport: rt, RequireLockedMemory: cfg.RequireLockedMemory}) + resolved, err := creds.Resolve(ctx, ResolveOptions{Transport: rt, RequireLockedMemory: cfg.requireLockedMemory}) if err != nil { // A Resolve that fails may still hand back what it built. The key // is consumed and what Close holds is released, as on every other @@ -169,7 +130,7 @@ func NewClient(ctx context.Context, cfg Config) (_ *Client, err error) { clientID: resolved.ClientID, clientKey: resolved.ClientKey, zerokmsURL: zerokmsURL, - keysetCacheSize: cfg.KeysetCacheSize, + keysetCacheSize: cfg.keysetCacheSize, }) if err != nil { return nil, err @@ -182,7 +143,7 @@ func NewClient(ctx context.Context, cfg Config) (_ *Client, err error) { resolved.ClientKey.Wipe() t := &transport{rt: rt, token: resolved.Token} - inst, err := newInstance(ctx, wasm, t, guest.PolicyFor(cfg.RequireLockedMemory)) + inst, err := newInstance(ctx, wasm, t, guest.PolicyFor(cfg.requireLockedMemory)) if err != nil { return nil, err } @@ -240,7 +201,7 @@ func newClient(inst *instance, t *transport) *Client { // hosts) or is not available on this platform, and the client is working // on with memory the kernel may swap out. Nothing else changes. A // production checklist should assert this, or set -// [Config.RequireLockedMemory] and let NewClient refuse. +// [WithRequireLockedMemory] and let NewClient refuse. // [Client.MemoryLockError] says why. func (c *Client) MemoryLocked() bool { return c.MemoryLockError() == nil } diff --git a/languages/golang/stackencrypt/credentials.go b/languages/golang/stackencrypt/credentials.go index 1fc76bcb7..4982f3333 100644 --- a/languages/golang/stackencrypt/credentials.go +++ b/languages/golang/stackencrypt/credentials.go @@ -20,7 +20,8 @@ import ( // // [AutoCredentials] is the default: the environment, then the developer // profile, in the Rust client's order. [NewCredentials] takes the three -// values explicitly. Any other type can implement it; see +// values explicitly; [OIDCFederation] mints the token from an identity +// provider's. Any other type can implement it; see // [ResolvedCredentials] for what an implementation owes the client. type Credentials interface { // Resolve produces the credentials for one client. NewClient calls it @@ -164,7 +165,71 @@ type autoCredentials struct{} // String names the credentials' kind; nothing is resolved to print it. func (autoCredentials) String() string { return "stackencrypt.AutoCredentials" } -func (autoCredentials) Resolve(ctx context.Context, opts ResolveOptions) (resolved *ResolvedCredentials, err error) { +func (autoCredentials) Resolve(ctx context.Context, opts ResolveOptions) (*ResolvedCredentials, error) { + return resolveWithStrategy(ctx, opts, func(ctx context.Context, profile *stackauth.ProfileStore, noProfile error) (*stackauth.Strategy, error) { + strategy, err := profile.Auto(ctx) + switch { + case errors.Is(err, stackauth.ErrNotAuthenticated): + if noProfile != nil { + err = fmt.Errorf("%w: %w", err, noProfile) + } + return nil, fmt.Errorf("%w: no token: set %s and %s, or run `stash auth login`: %w", + ErrNoCredentials, envAccessKey, envWorkspaceCRN, err) + case errors.Is(err, stackauth.ErrAuthConfig) && accessKeyConfigured(): + // The status covers every configuration fault the guest reports; + // name the variables only when they are what was configured. + return nil, fmt.Errorf("stackencrypt: credentials: check %s and %s: %w", envAccessKey, envWorkspaceCRN, err) + case err != nil: + return nil, fmt.Errorf("stackencrypt: credentials: %w", err) + } + return strategy, nil + }) +} + +// OIDCFederation is [Credentials] whose token is minted by federation: CTS +// exchanges a token from the application's own identity provider (Clerk, +// Auth0, Okta, a cloud workload identity) for a CipherStash one in the +// workspace crn names. provider is asked for a fresh IdP token only when a +// CipherStash token has to be minted; stackauth.OAuth2TokenSource adapts a +// golang.org/x/oauth2 source. The client key is resolved as +// [AutoCredentials] resolves it: CS_CLIENT_ID and CS_CLIENT_KEY, else the +// developer profile. CS_CTS_HOST overrides the authentication endpoint. +func OIDCFederation(crn string, provider stackauth.OIDCProvider) Credentials { + return oidcCredentials{crn: crn, provider: provider} +} + +type oidcCredentials struct { + crn string + provider stackauth.OIDCProvider +} + +// String names the credentials' kind and workspace; the provider is not +// asked for anything to print it. +func (c oidcCredentials) String() string { + return fmt.Sprintf("stackencrypt.OIDCFederation{crn: %s}", c.crn) +} + +func (c oidcCredentials) Resolve(ctx context.Context, opts ResolveOptions) (*ResolvedCredentials, error) { + return resolveWithStrategy(ctx, opts, func(ctx context.Context, profile *stackauth.ProfileStore, _ error) (*stackauth.Strategy, error) { + strategy, err := profile.OIDC(ctx, c.crn, c.provider) + if err != nil { + return nil, fmt.Errorf("stackencrypt: credentials: OIDC federation: %w", err) + } + return strategy, nil + }) +} + +// resolveWithStrategy opens the credential guest — over the profile when +// there is one, with nothing mounted when there is not — asks strategy for +// the token source (passing why there is no profile, when there is none), +// then resolves the client key from the environment or +// the profile. On success the guest and the strategy are the resolved +// credentials' to close; on failure they are closed here. +func resolveWithStrategy( + ctx context.Context, + opts ResolveOptions, + strategy func(ctx context.Context, profile *stackauth.ProfileStore, noProfile error) (*stackauth.Strategy, error), +) (_ *ResolvedCredentials, err error) { authOpts := []stackauth.Option{stackauth.WithRoundTripper(opts.Transport)} if opts.RequireLockedMemory { authOpts = append(authOpts, stackauth.RequireLockedMemory()) @@ -191,24 +256,13 @@ func (autoCredentials) Resolve(ctx context.Context, opts ResolveOptions) (resolv // The token first, as Rust detects its strategy before it asks the key // provider: with neither configured, the error names the token. - strategy, err := profile.Auto(ctx) - switch { - case errors.Is(err, stackauth.ErrNotAuthenticated): - if noProfile != nil { - err = fmt.Errorf("%w: %w", err, noProfile) - } - return nil, fmt.Errorf("%w: no token: set %s and %s, or run `stash auth login`: %w", - ErrNoCredentials, envAccessKey, envWorkspaceCRN, err) - case errors.Is(err, stackauth.ErrAuthConfig) && accessKeyConfigured(): - // The status covers every configuration fault the guest reports; - // name the variables only when they are what was configured. - return nil, fmt.Errorf("stackencrypt: credentials: check %s and %s: %w", envAccessKey, envWorkspaceCRN, err) - case err != nil: - return nil, fmt.Errorf("stackencrypt: credentials: %w", err) + token, err := strategy(ctx, profile, noProfile) + if err != nil { + return nil, err } defer func() { if err != nil { - _ = strategy.Close() + _ = token.Close() } }() @@ -224,12 +278,12 @@ func (autoCredentials) Resolve(ctx context.Context, opts ResolveOptions) (resolv return &ResolvedCredentials{ ClientID: clientID, ClientKey: key, - Token: strategy, + Token: token, Close: func() error { // The strategy lives in the profile's guest, which closing the // profile would free anyway; closing it first unregisters it // cleanly. - return errors.Join(strategy.Close(), profile.Close()) + return errors.Join(token.Close(), profile.Close()) }, MemoryLockError: profile.MemoryLockError, }, nil diff --git a/languages/golang/stackencrypt/credentials_test.go b/languages/golang/stackencrypt/credentials_test.go index d95a3fa1d..2f1ec7a7f 100644 --- a/languages/golang/stackencrypt/credentials_test.go +++ b/languages/golang/stackencrypt/credentials_test.go @@ -408,7 +408,7 @@ func TestNewClientWithAutoCredentials(t *testing.T) { authGuestOrSkip(t) cleanEnv(t, newProfile(t, loggedIn("profile-token"))) stub := newStub(t, http.StatusUnauthorized, "", "nope") - // The endpoint from the environment, since the Config names none. + // The endpoint from the environment, since no option names one. t.Setenv("CS_ZEROKMS_HOST", stub.URL) var released *ResolvedCredentials creds := credentialsFunc(func(ctx context.Context, opts ResolveOptions) (*ResolvedCredentials, error) { @@ -416,7 +416,7 @@ func TestNewClientWithAutoCredentials(t *testing.T) { released = r return r, err }) - _, err := NewClient(context.Background(), Config{Credentials: creds}) + _, err := NewClient(context.Background(), WithCredentials(creds)) if !errors.Is(err, ErrUnauthorized) { t.Fatalf("NewClient: %v, want ErrUnauthorized from the stub", err) } @@ -446,7 +446,7 @@ func TestNewClientReleasesTheCredentialsOnceWhenInitFails(t *testing.T) { } return r, err }) - _, err := NewClient(context.Background(), Config{Credentials: creds, ZeroKMSURL: stub.URL}) + _, err := NewClient(context.Background(), WithCredentials(creds), WithZeroKMSURL(stub.URL)) if !errors.Is(err, ErrUnauthorized) { t.Fatalf("NewClient: %v, want ErrUnauthorized from the stub", err) } @@ -468,7 +468,7 @@ func TestNewClientConsumesCredentialsAFailedResolveHandsBack(t *testing.T) { Close: func() error { released++; return nil }, }, resolveErr }) - _, err := NewClient(context.Background(), Config{Credentials: creds}) + _, err := NewClient(context.Background(), WithCredentials(creds)) if !errors.Is(err, resolveErr) { t.Fatalf("NewClient: %v, want the Resolve error", err) } @@ -485,30 +485,32 @@ func TestNewClientConsumesCredentialsAFailedResolveHandsBack(t *testing.T) { // AutoCredentials, no credential guest — and an explicit key is consumed // all the same. func TestNewClientRefusesTheConfigBeforeResolvingCredentials(t *testing.T) { - for name, cfg := range map[string]func(*testing.T) Config{ - "an unusable CS_ZEROKMS_HOST": func(t *testing.T) Config { + for name, options := range map[string]func(*testing.T) []ClientOption{ + "an unusable CS_ZEROKMS_HOST": func(t *testing.T) []ClientOption { t.Setenv("CS_ZEROKMS_HOST", "localhost:3002") - return Config{} + return nil + }, + "a negative cache size": func(*testing.T) []ClientOption { + return []ClientOption{WithKeysetCacheSize(-1)} }, - "a negative cache size": func(*testing.T) Config { return Config{KeysetCacheSize: -1} }, } { t.Run(name, func(t *testing.T) { cleanEnv(t, t.TempDir()) - cfg := cfg(t) + opts := options(t) resolved := false - cfg.Credentials = credentialsFunc(func(ctx context.Context, opts ResolveOptions) (*ResolvedCredentials, error) { + spy := credentialsFunc(func(ctx context.Context, opts ResolveOptions) (*ResolvedCredentials, error) { resolved = true return testCredentials(StaticToken("t")).Resolve(ctx, opts) }) - if _, err := NewClient(context.Background(), cfg); err == nil { + if _, err := NewClient(context.Background(), append([]ClientOption{WithCredentials(spy)}, opts...)...); err == nil { t.Fatal("NewClient accepted the config") } if resolved { t.Error("the credentials were resolved for a config refused host-side") } key := NewClientKey([]byte(testClientKey)) - cfg.Credentials = NewCredentials(testClientID, key, StaticToken("t")) - if _, err := NewClient(context.Background(), cfg); err == nil { + explicit := NewCredentials(testClientID, key, StaticToken("t")) + if _, err := NewClient(context.Background(), append([]ClientOption{WithCredentials(explicit)}, opts...)...); err == nil { t.Fatal("NewClient accepted the config") } if !key.IsZero() { @@ -526,10 +528,10 @@ func TestNewCredentialsRefusedConfigThenRetryIsConsumed(t *testing.T) { guestOrSkip(t) stub := newStub(t, http.StatusUnauthorized, "", "nope") creds := testCredentials(StaticToken("t")) - if _, err := NewClient(context.Background(), Config{Credentials: creds, ZeroKMSURL: stub.URL, KeysetCacheSize: -1}); err == nil { + if _, err := NewClient(context.Background(), WithCredentials(creds), WithZeroKMSURL(stub.URL), WithKeysetCacheSize(-1)); err == nil { t.Fatal("NewClient accepted a negative cache size") } - _, err := NewClient(context.Background(), Config{Credentials: creds, ZeroKMSURL: stub.URL}) + _, err := NewClient(context.Background(), WithCredentials(creds), WithZeroKMSURL(stub.URL)) if !errors.Is(err, ErrCredentialsConsumed) { t.Fatalf("retry with a corrected config: %v, want ErrCredentialsConsumed", err) } @@ -585,11 +587,11 @@ func TestNewCredentialsRefusesASecondClient(t *testing.T) { guestOrSkip(t) stub := newStub(t, http.StatusUnauthorized, "", "nope") creds := testCredentials(StaticToken("t")) - cfg := Config{Credentials: creds, ZeroKMSURL: stub.URL} - if _, err := NewClient(context.Background(), cfg); !errors.Is(err, ErrUnauthorized) { + cfg := []ClientOption{WithCredentials(creds), WithZeroKMSURL(stub.URL)} + if _, err := NewClient(context.Background(), cfg...); !errors.Is(err, ErrUnauthorized) { t.Fatalf("first NewClient: %v, want ErrUnauthorized from the stub", err) } - _, err := NewClient(context.Background(), cfg) + _, err := NewClient(context.Background(), cfg...) if !errors.Is(err, ErrCredentialsConsumed) { t.Fatalf("second NewClient: %v, want ErrCredentialsConsumed", err) } @@ -601,12 +603,12 @@ func TestNewCredentialsRefusesASecondClient(t *testing.T) { } } -// A nil Credentials is AutoCredentials: with nothing configured, the error -// is the resolution's, before any guest or request. +// NewClient with no options is AutoCredentials: with nothing configured, +// the error is the resolution's, before any guest or request. func TestNewClientDefaultsToAutoCredentials(t *testing.T) { authGuestOrSkip(t) cleanEnv(t, filepath.Join(t.TempDir(), "absent")) - if _, err := NewClient(context.Background(), Config{}); !errors.Is(err, ErrNoCredentials) { + if _, err := NewClient(context.Background()); !errors.Is(err, ErrNoCredentials) { t.Fatalf("NewClient with no credentials anywhere: %v, want ErrNoCredentials", err) } } diff --git a/languages/golang/stackencrypt/doc.go b/languages/golang/stackencrypt/doc.go index e3bb1f1ab..1507472c8 100644 --- a/languages/golang/stackencrypt/doc.go +++ b/languages/golang/stackencrypt/doc.go @@ -8,13 +8,15 @@ // resolves the client's [Credentials], instantiates the embedded guest, // hands it the client key once — the [ClientKey] the credentials resolved // to is consumed and wiped, whatever the outcome — and loads the client's -// default keyset. Every keyset the client uses after that is selected per -// call through a [KeysetSelector] and loaded on first use by the guest's -// own bounded cache; nothing the host could allocate, alias or free crosses -// the boundary. [Client.Close] runs the guest's shutdown so the client key -// and every loaded index key are wiped before the instance is freed — -// closing a wasm instance runs no Rust destructors on its own. Close is -// hygiene, not the security story: see Memory below. +// default keyset. It takes functional options ([ClientOption]), every one +// with a default, so NewClient(ctx) alone is a working client. Every keyset +// the client uses after that is selected per call through a +// [KeysetSelector] and loaded on first use by the guest's own bounded +// cache; nothing the host could allocate, alias or free crosses the +// boundary. [Client.Close] runs the guest's shutdown so the client key and +// every loaded index key are wiped before the instance is freed — closing a +// wasm instance runs no Rust destructors on its own. Close is hygiene, not +// the security story: see Memory below. // // A [Cipher] is the client bound to one keyset ([Client.Keyset] and // [Client.DefaultKeyset], the Rust crate's StackCipher::keyset and @@ -70,7 +72,9 @@ // with CS_CLIENT_KEY for the key), then the developer profile, which it // reads through stackauth's credential guest, where the token strategies // also run. CS_ZEROKMS_HOST (or CS_VITUR_HOST) pins the endpoint whatever -// the credentials. [NewCredentials] takes the three values explicitly. +// the credentials. [NewCredentials] takes the three values explicitly, and +// [OIDCFederation] mints the token from an identity provider's. Pass one +// with [WithCredentials]. // Credentials that cannot be resolved fail NewClient with // [ErrNoCredentials]; credentials that resolve but do not work fail it too, // at the one ZeroKMS round trip it makes. @@ -111,7 +115,7 @@ // that is lost, and nothing on a host without swap. [Client.MemoryLocked] // reports the outcome and [Client.MemoryLockError] the reason, naming the // limit to raise (ulimit -l, a systemd LimitMEMLOCK=, a pod's -// securityContext). [Config.RequireLockedMemory] turns a refusal into a +// securityContext). [WithRequireLockedMemory] turns a refusal into a // [NewClient] failure with [ErrMemoryLock], for deployments that would // rather not start than run unlocked; it also refuses any later growth of // the guest's memory that cannot be locked, so the limit granted must diff --git a/languages/golang/stackencrypt/errors.go b/languages/golang/stackencrypt/errors.go index d54b6a4fb..55f915d23 100644 --- a/languages/golang/stackencrypt/errors.go +++ b/languages/golang/stackencrypt/errors.go @@ -51,7 +51,7 @@ var ( ErrForeignKeyset = guest.ErrForeignKeyset // ErrMemoryLock is guest memory that could not be locked in RAM (or, // on Linux, excluded from core dumps). NewClient returns it when - // Config.RequireLockedMemory is set, and so does any later call under + // WithRequireLockedMemory is given, and so does any later call under // that setting whose growth of the guest's memory could not be locked; // otherwise Client.MemoryLockError reports it and the client works on // with unlocked memory. The wrapped error names the limit that refused diff --git a/languages/golang/stackencrypt/example/README.md b/languages/golang/stackencrypt/example/README.md index b48185c8f..03d008579 100644 --- a/languages/golang/stackencrypt/example/README.md +++ b/languages/golang/stackencrypt/example/README.md @@ -37,8 +37,8 @@ side will not notice a stale one, so rebuild after any change under either ## Credentials -The example passes the zero `Config`, so `NewClient` resolves its -credentials with `stackencrypt.AutoCredentials`, the library path any +The example calls `NewClient(ctx)` with no options, so it resolves its +credentials with `stackencrypt.AutoCredentials`, which is what any application gets by default. It looks in the environment first and then in the developer profile, in the order the Rust client uses: diff --git a/languages/golang/stackencrypt/example/main.go b/languages/golang/stackencrypt/example/main.go index 8fd849c2b..338153ef0 100644 --- a/languages/golang/stackencrypt/example/main.go +++ b/languages/golang/stackencrypt/example/main.go @@ -39,14 +39,14 @@ func main() { func run() error { ctx := context.Background() - // The zero Config: credentials from AutoCredentials, which is the + // No options: credentials from AutoCredentials, which is the // environment first (CS_CLIENT_ACCESS_KEY + CS_WORKSPACE_CRN, CS_CLIENT_ID // + CS_CLIENT_KEY), then the developer profile `stash auth login` writes, // read through stackauth's credential guest. The token is a refreshing // device session there, asked on every request, so a long run outlives - // one token. ZeroKMSURL is left empty: CS_ZEROKMS_HOST if set, else the - // token's services claim. - client, err := stackencrypt.NewClient(ctx, stackencrypt.Config{}) + // one token. No WithZeroKMSURL: CS_ZEROKMS_HOST if set, else the token's + // services claim. + client, err := stackencrypt.NewClient(ctx) if err != nil { return fmt.Errorf("connecting to ZeroKMS: %w", err) } diff --git a/languages/golang/stackencrypt/guest.go b/languages/golang/stackencrypt/guest.go index 3d12cfd51..5668aa662 100644 --- a/languages/golang/stackencrypt/guest.go +++ b/languages/golang/stackencrypt/guest.go @@ -26,7 +26,7 @@ var guestFS embed.FS const guestPath = "wasm/stack_encrypt_guest.wasm" // ErrGuestNotBuilt is returned by NewClient when no guest module is -// embedded and none was supplied in Config.Guest. +// embedded and none was supplied with WithGuest. var ErrGuestNotBuilt = errors.New("stackencrypt: guest module not built — run `mise run wasm:guest:build`") func embeddedGuest() ([]byte, error) { diff --git a/languages/golang/stackencrypt/guest_test.go b/languages/golang/stackencrypt/guest_test.go index 1991cd45c..9c4800bdc 100644 --- a/languages/golang/stackencrypt/guest_test.go +++ b/languages/golang/stackencrypt/guest_test.go @@ -95,8 +95,10 @@ func newStub(t *testing.T, status int, contentType, body string) *zerokmsStub { return s } -func testConfig(url string) Config { - return Config{Credentials: testCredentials(StaticToken("stub-token")), ZeroKMSURL: url} +// testConfig is the options for a client of the test credentials against +// url. A test appends to it; a later option wins. +func testConfig(url string) []ClientOption { + return []ClientOption{WithCredentials(testCredentials(StaticToken("stub-token"))), WithZeroKMSURL(url)} } // testCredentials is the test client id and a fresh copy of the test key, @@ -151,7 +153,7 @@ func TestImportSurfaceIsWASIPlusTransport(t *testing.T) { func TestNewClientIssuesTheLoadKeysetRequest(t *testing.T) { guestOrSkip(t) stub := newStub(t, http.StatusUnauthorized, "", "nope") - _, err := NewClient(context.Background(), testConfig(stub.URL)) + _, err := NewClient(context.Background(), testConfig(stub.URL)...) if !errors.Is(err, ErrUnauthorized) { t.Fatalf("NewClient: %v, want ErrUnauthorized", err) } @@ -193,7 +195,7 @@ func TestTransportOutcomesMapToErrors(t *testing.T) { for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { stub := newStub(t, tc.status, tc.contentType, tc.body) - _, err := NewClient(context.Background(), testConfig(stub.URL)) + _, err := NewClient(context.Background(), testConfig(stub.URL)...) if !errors.Is(err, tc.want) { t.Fatalf("NewClient: %v, want %v", err, tc.want) } @@ -203,7 +205,7 @@ func TestTransportOutcomesMapToErrors(t *testing.T) { stub := newStub(t, http.StatusOK, "application/json", "{}") url := stub.URL stub.Close() - _, err := NewClient(context.Background(), testConfig(url)) + _, err := NewClient(context.Background(), testConfig(url)...) if !errors.Is(err, ErrTransport) { t.Fatalf("NewClient: %v, want ErrTransport", err) } @@ -212,8 +214,8 @@ func TestTransportOutcomesMapToErrors(t *testing.T) { stub := newStub(t, http.StatusOK, "application/json", "{}") cfg := testConfig(stub.URL) vaultDown := errors.New("vault down") - cfg.Credentials = testCredentials(TokenFunc(func(context.Context) (string, error) { return "", vaultDown })) - _, err := NewClient(context.Background(), cfg) + cfg = append(cfg, WithCredentials(testCredentials(TokenFunc(func(context.Context) (string, error) { return "", vaultDown })))) + _, err := NewClient(context.Background(), cfg...) if err == nil { t.Fatal("NewClient succeeded with no token") } @@ -231,10 +233,10 @@ func TestTransportOutcomesMapToErrors(t *testing.T) { func TestRoundTripperFailureIsTransport(t *testing.T) { guestOrSkip(t) cfg := testConfig("http://zerokms.invalid") - cfg.Transport = roundTripFunc(func(*http.Request) (*http.Response, error) { + cfg = append(cfg, WithTransport(roundTripFunc(func(*http.Request) (*http.Response, error) { return nil, errors.New("no route") - }) - _, err := NewClient(context.Background(), cfg) + }))) + _, err := NewClient(context.Background(), cfg...) if !errors.Is(err, ErrTransport) { t.Fatalf("NewClient: %v, want ErrTransport", err) } @@ -250,13 +252,13 @@ func TestInterruptedCallClosesTheClient(t *testing.T) { t.Run("deadline during a request", func(t *testing.T) { guestOrSkip(t) cfg := testConfig("http://zerokms.invalid") - cfg.Transport = roundTripFunc(func(r *http.Request) (*http.Response, error) { + cfg = append(cfg, WithTransport(roundTripFunc(func(r *http.Request) (*http.Response, error) { <-r.Context().Done() return nil, r.Context().Err() - }) + }))) ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond) defer cancel() - _, err := NewClient(ctx, cfg) + _, err := NewClient(ctx, cfg...) if !errors.Is(err, context.DeadlineExceeded) { t.Fatalf("NewClient: %v, want the deadline", err) } @@ -311,8 +313,8 @@ func TestOversizedResponseIsTransport(t *testing.T) { } { t.Run(name, func(t *testing.T) { cfg := testConfig("http://zerokms.invalid") - cfg.Transport = rt - _, err := NewClient(context.Background(), cfg) + cfg = append(cfg, WithTransport(rt)) + _, err := NewClient(context.Background(), cfg...) if !errors.Is(err, ErrTransport) { t.Fatalf("NewClient: %v, want ErrTransport", err) } @@ -364,7 +366,7 @@ func TestEmptyContextIsRefusedAtTheRoot(t *testing.T) { func TestInterruptedResponseBodyIsTransport(t *testing.T) { guestOrSkip(t) cfg := testConfig("http://zerokms.invalid") - cfg.Transport = roundTripFunc(func(*http.Request) (*http.Response, error) { + cfg = append(cfg, WithTransport(roundTripFunc(func(*http.Request) (*http.Response, error) { return &http.Response{ StatusCode: http.StatusOK, Header: http.Header{"Content-Type": {"application/json"}}, @@ -374,8 +376,8 @@ func TestInterruptedResponseBodyIsTransport(t *testing.T) { &failingReader{err: io.ErrUnexpectedEOF}, )), }, nil - }) - if _, err := NewClient(context.Background(), cfg); !errors.Is(err, ErrTransport) { + }))) + if _, err := NewClient(context.Background(), cfg...); !errors.Is(err, ErrTransport) { t.Fatalf("NewClient: %v, want ErrTransport", err) } } @@ -396,7 +398,7 @@ func TestRequestBodyOutlivesTheRoundTrip(t *testing.T) { } done := make(chan drained, 1) cfg := testConfig("http://zerokms.invalid") - cfg.Transport = roundTripFunc(func(r *http.Request) (*http.Response, error) { + cfg = append(cfg, WithTransport(roundTripFunc(func(r *http.Request) (*http.Response, error) { go func() { <-returned b, err := io.ReadAll(r.Body) @@ -404,8 +406,8 @@ func TestRequestBodyOutlivesTheRoundTrip(t *testing.T) { done <- drained{req: r, body: b, err: err} }() return nil, errors.New("connection reset") - }) - _, err := NewClient(context.Background(), cfg) + }))) + _, err := NewClient(context.Background(), cfg...) if !errors.Is(err, ErrTransport) { t.Fatalf("NewClient: %v, want ErrTransport", err) } @@ -469,8 +471,8 @@ func TestConfigValidation(t *testing.T) { "wiped key": {id: testClientID, key: wiped, token: StaticToken("t")}, "negative cache": {id: testClientID, key: NewClientKey([]byte(testClientKey)), token: StaticToken("t"), cache: -1}, } { - cfg := Config{Credentials: NewCredentials(tc.id, tc.key, tc.token), KeysetCacheSize: tc.cache} - if _, err := NewClient(ctx, cfg); err == nil { + cfg := []ClientOption{WithCredentials(NewCredentials(tc.id, tc.key, tc.token)), WithKeysetCacheSize(tc.cache)} + if _, err := NewClient(ctx, cfg...); err == nil { t.Errorf("%s: NewClient succeeded", name) } // A refused config consumes the key too: the caller is never handed @@ -481,19 +483,13 @@ func TestConfigValidation(t *testing.T) { } // Malformed values the guest refuses: no request is made. guestOrSkip(t) - for name, mutate := range map[string]func(*Config){ - "client id not a uuid": func(c *Config) { - c.Credentials = NewCredentials("acme", NewClientKey([]byte(testClientKey)), StaticToken("t")) - }, - "key not hex": func(c *Config) { - c.Credentials = NewCredentials(testClientID, NewClientKey([]byte("zz")), StaticToken("t")) - }, - "bad url": func(c *Config) { c.ZeroKMSURL = "not a url" }, + for name, option := range map[string]ClientOption{ + "client id not a uuid": WithCredentials(NewCredentials("acme", NewClientKey([]byte(testClientKey)), StaticToken("t"))), + "key not hex": WithCredentials(NewCredentials(testClientID, NewClientKey([]byte("zz")), StaticToken("t"))), + "bad url": WithZeroKMSURL("not a url"), } { stub := newStub(t, http.StatusOK, "application/json", "{}") - cfg := testConfig(stub.URL) - mutate(&cfg) - _, err := NewClient(ctx, cfg) + _, err := NewClient(ctx, append(testConfig(stub.URL), option)...) if !errors.Is(err, ErrEncoding) { t.Errorf("%s: %v, want ErrEncoding", name, err) } @@ -505,7 +501,8 @@ func TestConfigValidation(t *testing.T) { // The client key is consumed by NewClient: whatever the outcome, the bytes // it was built from are zero once NewClient returns, the key reports -// itself empty, and a Config never prints the material under any verb. +// itself empty, and the credentials never print the material under any +// verb. // // The outcome exercised here is the guest's init failing (a refused // token); the refused-config outcomes are in TestConfigValidation, and @@ -515,9 +512,9 @@ func TestConfigValidation(t *testing.T) { func TestClientKeyIsConsumedAndNeverPrinted(t *testing.T) { material := []byte(testClientKey) stub := newStub(t, http.StatusUnauthorized, "", "nope") - cfg := testConfig(stub.URL) key := NewClientKey(material) - cfg.Credentials = NewCredentials(testClientID, key, StaticToken("stub-token")) + creds := NewCredentials(testClientID, key, StaticToken("stub-token")) + cfg := append(testConfig(stub.URL), WithCredentials(creds)) // Resolving consumes the credentials, so the printed resolution is a // separate set's, over another copy of the key. resolved, err := NewCredentials(testClientID, NewClientKey([]byte(testClientKey)), StaticToken("stub-token")).Resolve(context.Background(), ResolveOptions{}) @@ -528,7 +525,7 @@ func TestClientKeyIsConsumedAndNeverPrinted(t *testing.T) { // %x and %d reach a struct's fields without asking a Stringer; the // key's Formatter answers for them. for _, verb := range []string{"%v", "%+v", "%#v", "%s", "%q", "%x", "%d"} { - for what, v := range map[string]any{"Config": cfg, "Credentials": cfg.Credentials, "ResolvedCredentials": *resolved} { + for what, v := range map[string]any{"Credentials": creds, "ResolvedCredentials": *resolved} { out := fmt.Sprintf(verb, v) if strings.Contains(out, testClientKey[:16]) || strings.Contains(out, hex.EncodeToString(material[:8])) { t.Errorf("%s under %s prints the key: %q", what, verb, out) @@ -536,7 +533,7 @@ func TestClientKeyIsConsumedAndNeverPrinted(t *testing.T) { } } guestOrSkip(t) - if _, err := NewClient(context.Background(), cfg); !errors.Is(err, ErrUnauthorized) { + if _, err := NewClient(context.Background(), cfg...); !errors.Is(err, ErrUnauthorized) { t.Fatalf("NewClient: %v, want ErrUnauthorized", err) } if !key.IsZero() { @@ -550,7 +547,7 @@ func TestClientKeyIsConsumedAndNeverPrinted(t *testing.T) { // A consumed key does not make a second client, says so, and asks // nothing of ZeroKMS trying. before := len(stub.requests) - if _, err := NewClient(context.Background(), cfg); !errors.Is(err, ErrCredentialsConsumed) { + if _, err := NewClient(context.Background(), cfg...); !errors.Is(err, ErrCredentialsConsumed) { t.Errorf("NewClient with a consumed key: %v, want ErrCredentialsConsumed before any request", err) } if len(stub.requests) != before { @@ -846,13 +843,11 @@ func TestTransportSendCounterAndResponseHeaders(t *testing.T) { func ExampleNewClient() { // A client needs ZeroKMS credentials; see live_test.go for the shape of // a real round trip. - _, err := NewClient(context.Background(), Config{ - Credentials: NewCredentials( - "6a70bd18-99ac-4650-b104-37eec3a15b09", - NewClientKey([]byte("...")), - StaticToken("access token"), - ), - }) + _, err := NewClient(context.Background(), WithCredentials(NewCredentials( + "6a70bd18-99ac-4650-b104-37eec3a15b09", + NewClientKey([]byte("...")), + StaticToken("access token"), + ))) fmt.Println(err != nil) // Output: true } diff --git a/languages/golang/stackencrypt/live_test.go b/languages/golang/stackencrypt/live_test.go index 59a99b4a0..2a1fd1424 100644 --- a/languages/golang/stackencrypt/live_test.go +++ b/languages/golang/stackencrypt/live_test.go @@ -37,7 +37,7 @@ func liveClient(t *testing.T) *Client { } return r, err }) - c, err := NewClient(t.Context(), Config{Credentials: creds, ZeroKMSURL: url}) + c, err := NewClient(t.Context(), WithCredentials(creds), WithZeroKMSURL(url)) if err != nil { t.Fatalf("NewClient: %v", err) } diff --git a/languages/golang/stackencrypt/memory_test.go b/languages/golang/stackencrypt/memory_test.go index c5df12778..416edcb84 100644 --- a/languages/golang/stackencrypt/memory_test.go +++ b/languages/golang/stackencrypt/memory_test.go @@ -85,12 +85,11 @@ func TestRequireLockedMemoryRefusesAnUnlockableGuest(t *testing.T) { return } limited := !probe.IsFallback() - cfg := Config{ - Credentials: NewCredentials("6a70bd18-99ac-4650-b104-37eec3a15b09", NewClientKey([]byte("00")), StaticToken("t")), - Guest: wasiProbe, - RequireLockedMemory: true, - } - _, err := NewClient(context.Background(), cfg) + _, err := NewClient(context.Background(), + WithCredentials(NewCredentials("6a70bd18-99ac-4650-b104-37eec3a15b09", NewClientKey([]byte("00")), StaticToken("t"))), + WithGuest(wasiProbe), + WithRequireLockedMemory(), + ) if !errors.Is(err, ErrMemoryLock) { t.Fatalf("strict NewClient under a refused lock: %v, want ErrMemoryLock", err) } diff --git a/languages/golang/stackencrypt/options.go b/languages/golang/stackencrypt/options.go new file mode 100644 index 000000000..2ba298658 --- /dev/null +++ b/languages/golang/stackencrypt/options.go @@ -0,0 +1,82 @@ +package stackencrypt + +import "net/http" + +// ClientOption configures [NewClient]. Each one sets one thing; a later +// option setting the same thing wins. NewClient with none is a working +// client, with its credentials from [AutoCredentials]. +type ClientOption func(*clientOptions) + +// clientOptions is what the options set. Every zero value is the default. +type clientOptions struct { + credentials Credentials + zerokmsURL string + keysetCacheSize int + transport http.RoundTripper + guest []byte + requireLockedMemory bool +} + +// WithCredentials supplies the client id, the client key and the token +// source. The default, and what nil means, is [AutoCredentials]: the +// environment, then the developer profile. [NewCredentials] takes the three +// explicitly, and [OIDCFederation] mints tokens from an identity provider's. +// +// The client key is consumed: NewClient marshals it into the config buffer, +// wipes the key, and wipes the buffer once the guest has the key, so after +// NewClient returns — whatever the outcome, a configuration it refused +// included — the key is empty and the bytes it was built from are zero. A +// key is for one client. +func WithCredentials(c Credentials) ClientOption { + return func(o *clientOptions) { o.credentials = c } +} + +// WithZeroKMSURL pins the ZeroKMS endpoint. Without it, CS_ZEROKMS_HOST (or +// the legacy CS_VITUR_HOST) pins it if set — a set value that is not an +// http(s) URL is an error — and otherwise the endpoint is resolved from the +// access token's services claim on first use. The variables are read +// whatever the credentials, as stack-kms reads them. +func WithZeroKMSURL(url string) ClientOption { + return func(o *clientOptions) { o.zerokmsURL = url } +} + +// WithKeysetCacheSize sets how many keysets beyond the default the guest +// keeps loaded. Zero means the crate default (1024); negative is refused. +func WithKeysetCacheSize(n int) ClientOption { + return func(o *clientOptions) { o.keysetCacheSize = n } +} + +// WithTransport performs the client's HTTP requests: to ZeroKMS, and, for +// [AutoCredentials] and [OIDCFederation], the authentication requests +// stackauth's credential guest makes to CTS: an access-key exchange, a +// device-session refresh, a federation exchange. A RoundTripper scoped to +// the ZeroKMS host alone (a pinned client certificate, an egress allowlist) +// refuses those; the failure then surfaces as the token source's. Nil means +// http.DefaultTransport, the default. +func WithTransport(rt http.RoundTripper) ClientOption { + return func(o *clientOptions) { o.transport = rt } +} + +// WithGuest overrides the embedded wasm module. Nil means the embedded one, +// the default. +func WithGuest(wasm []byte) ClientOption { + return func(o *clientOptions) { o.guest = wasm } +} + +// WithRequireLockedMemory makes NewClient fail with ErrMemoryLock when the +// guest's memory cannot be locked in RAM or, on Linux, excluded from core +// dumps, instead of continuing with memory that may be swapped or dumped +// and reporting so through Client.MemoryLocked. It holds for the life of +// the client: a later growth of the guest's memory that cannot be locked is +// refused too, and what the guest already holds stays locked. When the +// growth was for a buffer the host is staging, the call fails with +// ErrMemoryLock and the client goes on. When it was for the guest's own +// allocation, the guest cannot report it: it aborts, and the client is +// closed with its keys wiped, the call still failing with ErrMemoryLock. +// Set it where swap is a real exposure and the deployment grants a lock +// limit with room for the guest to grow (RLIMIT_MEMLOCK on Linux; the error +// names the size held so far); see [Client.MemoryLocked]. [AutoCredentials] +// and [OIDCFederation] apply it to the credential guest too. +func WithRequireLockedMemory() ClientOption { + return func(o *clientOptions) { o.requireLockedMemory = true } +} diff --git a/languages/golang/stackencrypt/options_test.go b/languages/golang/stackencrypt/options_test.go new file mode 100644 index 000000000..bc25dc2ed --- /dev/null +++ b/languages/golang/stackencrypt/options_test.go @@ -0,0 +1,150 @@ +package stackencrypt + +import ( + "context" + "errors" + "net/http" + "net/url" + "path/filepath" + "sync" + "sync/atomic" + "testing" + + "github.com/cipherstash/cipherstash-suite/bindings/go/internal/guest" + "github.com/cipherstash/cipherstash-suite/bindings/go/stackauth" +) + +// Every option sets its one field, and a later option setting the same +// field wins. The zero value of each is the default NewClient applies. +func TestClientOptionsSetTheirField(t *testing.T) { + creds := AutoCredentials() + rt := roundTripFunc(func(*http.Request) (*http.Response, error) { return nil, errors.New("unused") }) + wasm := []byte("\x00asm") + var got clientOptions + for _, opt := range []ClientOption{ + WithZeroKMSURL("https://first.example"), + WithCredentials(creds), + WithZeroKMSURL("https://second.example"), + WithKeysetCacheSize(4096), + WithTransport(rt), + WithGuest(wasm), + WithRequireLockedMemory(), + } { + opt(&got) + } + if got.credentials != creds || got.zerokmsURL != "https://second.example" || got.keysetCacheSize != 4096 || + got.transport == nil || string(got.guest) != string(wasm) || !got.requireLockedMemory { + t.Fatalf("options = %+v", got) + } +} + +// The endpoint a later WithZeroKMSURL names is the one NewClient uses. +func TestLaterZeroKMSURLWins(t *testing.T) { + guestOrSkip(t) + first := newStub(t, http.StatusUnauthorized, "", "first") + second := newStub(t, http.StatusUnauthorized, "", "second") + _, err := NewClient(context.Background(), + WithCredentials(testCredentials(StaticToken("stub-token"))), + WithZeroKMSURL(first.URL), + WithZeroKMSURL(second.URL), + ) + if !errors.Is(err, ErrUnauthorized) { + t.Fatalf("NewClient: %v", err) + } + if len(first.requests) != 0 || len(second.requests) != 1 { + t.Fatalf("requests: first %d, second %d; want only the second", len(first.requests), len(second.requests)) + } +} + +func TestNegativeKeysetCacheSizeIsRefused(t *testing.T) { + key := NewClientKey([]byte(testClientKey)) + _, err := NewClient(context.Background(), + WithCredentials(NewCredentials(testClientID, key, StaticToken("t"))), + WithKeysetCacheSize(-1), + ) + if err == nil { + t.Fatal("NewClient accepted a negative keyset cache size") + } + if !key.IsZero() { + t.Error("the key still holds material after NewClient refused the options") + } +} + +// countingTransport records the hosts a RoundTripper was asked to reach. +type countingTransport struct { + mu sync.Mutex + hosts map[string]int +} + +func (c *countingTransport) RoundTrip(r *http.Request) (*http.Response, error) { + c.mu.Lock() + if c.hosts == nil { + c.hosts = map[string]int{} + } + c.hosts[r.URL.Host]++ + c.mu.Unlock() + return http.DefaultTransport.RoundTrip(r) +} + +func (c *countingTransport) count(rawURL string) int { + u, _ := url.Parse(rawURL) + c.mu.Lock() + defer c.mu.Unlock() + return c.hosts[u.Host] +} + +// OIDCFederation mints its token from the provider's through CTS, asking +// the provider only when a token has to be minted; its client key comes +// from the environment as AutoCredentials' does; and WithTransport carries +// the token exchange as well as the ZeroKMS request. +func TestOIDCFederationThroughTheClientTransport(t *testing.T) { + guestOrSkip(t) + authGuestOrSkip(t) + cleanEnv(t, filepath.Join(t.TempDir(), "absent")) + auth := newAuthServer(t) + t.Setenv(envClientID, testClientID) + t.Setenv(envClientKey, testClientKey) + stub := newStub(t, http.StatusUnauthorized, "", "nope") + var idpCalls atomic.Int32 + provider := stackauth.OIDCProviderFunc(func(context.Context) (string, error) { + idpCalls.Add(1) + return "idp-token", nil + }) + rt := &countingTransport{} + _, err := NewClient(context.Background(), + WithCredentials(OIDCFederation(testCRN, provider)), + WithZeroKMSURL(stub.URL), + WithTransport(rt), + ) + if !errors.Is(err, ErrUnauthorized) { + t.Fatalf("NewClient: %v, want ErrUnauthorized from the stub", err) + } + if idpCalls.Load() != 1 || auth.calls.Load() != 1 { + t.Fatalf("provider calls %d, exchanges %d; want one of each", idpCalls.Load(), auth.calls.Load()) + } + if len(stub.requests) != 1 || stub.requests[0].auth != "Bearer "+auth.jwt { + t.Fatalf("ZeroKMS requests = %+v, want one bearing the federated token", stub.requests) + } + if rt.count(auth.URL) != 1 || rt.count(stub.URL) != 1 { + t.Fatalf("transport saw %v, want the exchange and the ZeroKMS request", rt.hosts) + } +} + +func TestOIDCFederationResolvesTheKeyLikeAuto(t *testing.T) { + authGuestOrSkip(t) + cleanEnv(t, newProfile(t, loggedIn("profile-token"))) + provider := stackauth.OIDCProviderFunc(func(context.Context) (string, error) { return "idp-token", nil }) + resolved, err := OIDCFederation(testCRN, provider).Resolve(context.Background(), ResolveOptions{Transport: http.DefaultTransport}) + if err != nil { + t.Fatal(err) + } + defer resolved.Close() + if resolved.ClientID != profileClientID || guest.KeyBytes(resolved.ClientKey) == nil { + t.Errorf("ClientID = %q, want the profile's", resolved.ClientID) + } + // No CRN is a configuration error from the strategy, before any + // provider or key is asked. + if _, err := OIDCFederation("", provider).Resolve(context.Background(), ResolveOptions{Transport: http.DefaultTransport}); !errors.Is(err, stackauth.ErrAuthConfig) { + t.Fatalf("OIDCFederation with no CRN: %v, want ErrAuthConfig", err) + } +} From de6ad6459a1e2bb98b8951348f968096f5d75cb3 Mon Sep 17 00:00:00 2001 From: Dan Draper <dan@cipherstash.com> Date: Mon, 28 Sep 2026 20:39:43 -0700 Subject: [PATCH 646/686] test(go): live test of AutoCredentials through a real access-key exchange The live harness only took an already-minted token, so NewClient with no options (AutoCredentials, an access key exchanged at CTS) had no live coverage. TestLiveAutoCredentialsFromTheEnvironment maps STACK_ENCRYPT_TEST_{CLIENT_ID,CLIENT_KEY,ACCESS_KEY,WORKSPACE_CRN} and the optional STACK_ENCRYPT_TEST_{ZEROKMS_URL,CTS_HOST} onto the CS_* variables the Rust client reads, with an empty profile directory, then round-trips a value and checks the memory-lock report. Skipped unless the four are set. --- docs/plans/stack-encrypt-go-bindings.md | 7 +- languages/golang/stackencrypt/live_test.go | 79 +++++++++++++++++++++- 2 files changed, 84 insertions(+), 2 deletions(-) diff --git a/docs/plans/stack-encrypt-go-bindings.md b/docs/plans/stack-encrypt-go-bindings.md index 3975edc0a..b724d672c 100644 --- a/docs/plans/stack-encrypt-go-bindings.md +++ b/docs/plans/stack-encrypt-go-bindings.md @@ -486,7 +486,12 @@ probe, _ := cipher.Term(ctx, uint32(34), "users/age", stackencrypt.Equality) - `mise run test:integration:wasi-go` (renamed #2099 harness): boots `zerokms-server` against the mock auth server, mints a token, seeds a client + keyset, then `CGO_ENABLED=0 go test ./...` in - `bindings/go/stackencrypt`. + `bindings/go/stackencrypt`. It exports `STACK_ENCRYPT_TEST_CLIENT_ID`, + `STACK_ENCRYPT_TEST_CLIENT_KEY`, `STACK_ENCRYPT_TEST_ACCESS_TOKEN` and + optionally `STACK_ENCRYPT_TEST_ZEROKMS_URL`; for the `AutoCredentials` + path (an access-key exchange, no options to `NewClient`) also + `STACK_ENCRYPT_TEST_ACCESS_KEY`, `STACK_ENCRYPT_TEST_WORKSPACE_CRN` and + optionally `STACK_ENCRYPT_TEST_CTS_HOST`. `live_test.go` documents each. - Go tests: import-surface gate (exactly WASI + `cipherstash_transport`), stub-transport tests for the bridge, live encrypt/decrypt, live `EncryptRecords` asserting **one** `transport_send` for N rows diff --git a/languages/golang/stackencrypt/live_test.go b/languages/golang/stackencrypt/live_test.go index 2a1fd1424..ed65679cd 100644 --- a/languages/golang/stackencrypt/live_test.go +++ b/languages/golang/stackencrypt/live_test.go @@ -14,7 +14,20 @@ import ( // Round trips through real ZeroKMS key material. Run by the phase 5 // harness (`mise run test:integration:wasi-go`), which boots zerokms-server -// and exports the four variables below; skipped otherwise. +// and exports the variables below; each test is skipped unless its own are +// set: +// +// - STACK_ENCRYPT_TEST_CLIENT_ID, STACK_ENCRYPT_TEST_CLIENT_KEY: the +// seeded client (every live test); +// - STACK_ENCRYPT_TEST_ACCESS_TOKEN: an already-minted token (liveClient); +// - STACK_ENCRYPT_TEST_ACCESS_KEY, STACK_ENCRYPT_TEST_WORKSPACE_CRN: an +// access key and its workspace, exchanged for a token by +// AutoCredentials (TestLiveAutoCredentialsFromTheEnvironment); +// - STACK_ENCRYPT_TEST_ZEROKMS_URL (optional): the ZeroKMS endpoint, else +// the token's services claim; +// - STACK_ENCRYPT_TEST_CTS_HOST (optional): the authentication endpoint +// the access key is exchanged at, else discovery from the workspace CRN; +// - STACK_ENCRYPT_TEST_OTHER_KEYSET (optional): a second keyset's name. func liveClient(t *testing.T) *Client { t.Helper() @@ -275,3 +288,67 @@ func TestPlaintextDoesNotRemainInGuestMemoryAfterEncrypt(t *testing.T) { t.Fatalf("plaintext found %d times in guest memory after Encrypt returned", n) } } + +// The zero-configuration path end to end: NewClient with no options, its +// credentials from AutoCredentials, the token from a real access-key +// exchange — the CI shape of a deployment, with the variables the Rust +// client reads and no developer profile. +func TestLiveAutoCredentialsFromTheEnvironment(t *testing.T) { + clientID, clientKey := os.Getenv("STACK_ENCRYPT_TEST_CLIENT_ID"), os.Getenv("STACK_ENCRYPT_TEST_CLIENT_KEY") + accessKey, crn := os.Getenv("STACK_ENCRYPT_TEST_ACCESS_KEY"), os.Getenv("STACK_ENCRYPT_TEST_WORKSPACE_CRN") + if clientID == "" || clientKey == "" || accessKey == "" || crn == "" { + t.Skip("STACK_ENCRYPT_TEST_{CLIENT_ID,CLIENT_KEY,ACCESS_KEY,WORKSPACE_CRN} not set") + } + guestOrSkip(t) + authGuestOrSkip(t) + // An empty profile directory, so only the environment can answer, and + // none of the developer's own CS_* variables. + cleanEnv(t, t.TempDir()) + if cts := os.Getenv("STACK_ENCRYPT_TEST_CTS_HOST"); cts != "" { + t.Setenv("CS_CTS_HOST", cts) + } else if err := os.Unsetenv("CS_CTS_HOST"); err != nil { // cleanEnv's placeholder + t.Fatal(err) + } + if url := os.Getenv("STACK_ENCRYPT_TEST_ZEROKMS_URL"); url != "" { + t.Setenv("CS_ZEROKMS_HOST", url) + } + t.Setenv(envAccessKey, accessKey) + t.Setenv(envWorkspaceCRN, crn) + t.Setenv(envClientID, clientID) + t.Setenv(envClientKey, clientKey) + + ctx := t.Context() + c, err := NewClient(ctx) + if err != nil { + t.Fatalf("NewClient: %v", err) + } + defer func() { + if err := c.Close(); err != nil { + t.Errorf("Close: %v", err) + } + }() + // Whether memory locks depends on the host; that it is reported, and + // consistently, does not. + if err := c.MemoryLockError(); err != nil && !errors.Is(err, ErrMemoryLock) { + t.Errorf("MemoryLockError = %v, want nil or ErrMemoryLock", err) + } + if c.MemoryLocked() != (c.MemoryLockError() == nil) { + t.Error("MemoryLocked disagrees with MemoryLockError") + } + + aad := []byte("users/v1") + ct, err := c.DefaultKeyset().Encrypt(ctx, "alice", aad) + if err != nil { + t.Fatalf("Encrypt: %v", err) + } + if _, ok := ct.(Sealed); !ok { + t.Fatalf("sealed as %T", ct) + } + pt, err := c.Decrypt(ctx, ct, aad) + if err != nil { + t.Fatalf("Decrypt: %v", err) + } + if pt != "alice" { + t.Fatalf("Decrypt = %#v, want %q", pt, "alice") + } +} From b836591a8944467626a450e7e8f2093e1fd09df9 Mon Sep 17 00:00:00 2001 From: Dan Draper <dan@cipherstash.com> Date: Mon, 28 Sep 2026 21:05:53 -0700 Subject: [PATCH 647/686] feat(go)!: tokens come only from stackauth strategies A raw bearer token can no longer be handed to the stackencrypt client. A raw token cannot be refreshed when it expires, and a source outside the stackauth strategies bypasses the cross-process refresh lock the device session shares with the stash CLI. Tokens now come only from a *stackauth.Strategy (access key, device session, OIDC federation, auto). Removed from the public API: - stackencrypt.StaticToken, stackencrypt.TokenFunc and the exported stackencrypt.TokenSource interface. The transport keeps an unexported tokenSource internally. - stackencrypt.ResolveOptions and stackencrypt.ResolvedCredentials, which existed for external Credentials implementations. Credentials is now sealed (an unexported resolve method): AutoCredentials, NewCredentials and OIDCFederation are its only implementations. - stackauth.TokenSource, ProfileStore.TokenSource() and stackauth.ErrTokenExpired (only that read-only, never-refreshing source returned it); the DeviceSession strategy supersedes it. ProfileStore.Token stays, as a read of auth.json. NewCredentials now takes (clientID, key, *stackauth.Strategy). The caller owns the strategy and its ProfileStore: the client never closes them, so they stay open until Client.Close has returned. A nil strategy is refused with ErrEncoding, and the key is consumed as on every other refusal. Tests that talk to httptest stubs get a static token from test-only code (export_test.go: staticToken, tokenFunc, newTestCredentials). The live tests drop STACK_ENCRYPT_TEST_ACCESS_TOKEN: liveClient authenticates with a stackauth access-key strategy through NewCredentials, so it and the AutoCredentials live test need the same four variables (CLIENT_ID, CLIENT_KEY, CLIENT_ACCESS_KEY, WORKSPACE_CRN). --- docs/plans/stack-encrypt-go-bindings.md | 12 ++- languages/golang/stackauth/README.md | 18 ++-- languages/golang/stackauth/doc.go | 15 +-- languages/golang/stackauth/errors.go | 4 - languages/golang/stackauth/store_test.go | 41 -------- languages/golang/stackauth/strategy.go | 7 +- languages/golang/stackauth/token.go | 33 ------- languages/golang/stackencrypt/README.md | 43 ++++++--- languages/golang/stackencrypt/client.go | 6 +- languages/golang/stackencrypt/credentials.go | 93 +++++++++++------- .../golang/stackencrypt/credentials_test.go | 96 +++++++++++++++---- languages/golang/stackencrypt/doc.go | 18 ++-- .../golang/stackencrypt/example/README.md | 5 +- languages/golang/stackencrypt/export_test.go | 25 +++++ languages/golang/stackencrypt/guest_test.go | 70 +++++++++----- languages/golang/stackencrypt/live_test.go | 56 ++++++++--- languages/golang/stackencrypt/memory_test.go | 8 +- languages/golang/stackencrypt/options.go | 10 +- languages/golang/stackencrypt/options_test.go | 8 +- languages/golang/stackencrypt/transport.go | 29 ++---- 20 files changed, 355 insertions(+), 242 deletions(-) create mode 100644 languages/golang/stackencrypt/export_test.go diff --git a/docs/plans/stack-encrypt-go-bindings.md b/docs/plans/stack-encrypt-go-bindings.md index b724d672c..ddec1ba3c 100644 --- a/docs/plans/stack-encrypt-go-bindings.md +++ b/docs/plans/stack-encrypt-go-bindings.md @@ -487,11 +487,13 @@ probe, _ := cipher.Term(ctx, uint32(34), "users/age", stackencrypt.Equality) `zerokms-server` against the mock auth server, mints a token, seeds a client + keyset, then `CGO_ENABLED=0 go test ./...` in `bindings/go/stackencrypt`. It exports `STACK_ENCRYPT_TEST_CLIENT_ID`, - `STACK_ENCRYPT_TEST_CLIENT_KEY`, `STACK_ENCRYPT_TEST_ACCESS_TOKEN` and - optionally `STACK_ENCRYPT_TEST_ZEROKMS_URL`; for the `AutoCredentials` - path (an access-key exchange, no options to `NewClient`) also - `STACK_ENCRYPT_TEST_ACCESS_KEY`, `STACK_ENCRYPT_TEST_WORKSPACE_CRN` and - optionally `STACK_ENCRYPT_TEST_CTS_HOST`. `live_test.go` documents each. + `STACK_ENCRYPT_TEST_CLIENT_KEY`, `STACK_ENCRYPT_TEST_CLIENT_ACCESS_KEY` + and `STACK_ENCRYPT_TEST_WORKSPACE_CRN`, and optionally + `STACK_ENCRYPT_TEST_CTS_HOST` and `STACK_ENCRYPT_TEST_ZEROKMS_URL`. The + same four required variables drive both paths: `NewCredentials` with a + `stackauth` access-key strategy, and `AutoCredentials` (no options to + `NewClient`). There is no raw-token variable, since the client takes + tokens only from `stackauth` strategies. `live_test.go` documents each. - Go tests: import-surface gate (exactly WASI + `cipherstash_transport`), stub-transport tests for the bridge, live encrypt/decrypt, live `EncryptRecords` asserting **one** `transport_send` for N rows diff --git a/languages/golang/stackauth/README.md b/languages/golang/stackauth/README.md index c403ed041..69eda7fae 100644 --- a/languages/golang/stackauth/README.md +++ b/languages/golang/stackauth/README.md @@ -64,10 +64,16 @@ func run(ctx context.Context) error { ``` `stackauth.ClientKey` and `stackencrypt.ClientKey` are one type, so the -key goes straight from the profile into the credentials. For a token that -is read and never refreshed, `workspace.TokenSource()` re-reads auth.json -on every call and refuses a token at its real expiry with -`stackauth.ErrTokenExpired`. +key goes straight from the profile into the credentials. The profile and +the strategy are the caller's: the client asks the strategy for a token on +every request but never closes it, so both stay open until the client is +closed (the deferred calls above run in that order). + +A stackencrypt client takes its token only from a strategy, never a raw +string: a raw token cannot be refreshed when it expires, and would bypass +the cross-process lock a device-session refresh holds with the `stash` CLI +(the IdP revokes a whole refresh-token chain when one is used twice). +`workspace.Token(ctx)` still reads the stored token, for inspection. With no profile directory at all (CI, a container, a server authenticating by federation), `stackauth.OpenWithoutProfile(ctx)` runs the guest with @@ -75,8 +81,8 @@ nothing mounted: the access-key and OIDC strategies work, and every profile read is `ErrNoProfile`. `profile.AccessKey(ctx, crn, key)`, `profile.OIDC(ctx, crn, provider)`, and -`profile.Auto(ctx)` also return strategies that satisfy -`stackencrypt.TokenSource`. `Auto` checks `CS_CLIENT_ACCESS_KEY` and +`profile.Auto(ctx)` also return strategies that `stackencrypt.NewCredentials` +takes. `Auto` checks `CS_CLIENT_ACCESS_KEY` and `CS_WORKSPACE_CRN` first, then the current workspace's stored device session. The OIDC provider is a one-method `Token(context.Context) (string, error)` interface. Use `stackauth.OAuth2TokenSource(source)` to adapt a diff --git a/languages/golang/stackauth/doc.go b/languages/golang/stackauth/doc.go index 61011882d..46feff9b0 100644 --- a/languages/golang/stackauth/doc.go +++ b/languages/golang/stackauth/doc.go @@ -26,13 +26,16 @@ // the identity the CLI created. [ProfileStore.Close] releases the guest; // stores scoped from it are closed with it. // -// [ProfileStore.TokenSource] is a token source for stackencrypt over the -// stored token: it re-reads auth.json on every call, so a login or refresh -// by the CLI in another terminal is picked up without a restart, and it -// refuses a token at its real expiry with an error naming `stash auth -// login`. For authentication and refresh, use [ProfileStore.AccessKey], +// For authentication and refresh, use [ProfileStore.AccessKey], // [ProfileStore.OIDC], [ProfileStore.DeviceSession], or [ProfileStore.Auto]. -// Each returns a [Strategy] that implements stackencrypt.TokenSource. +// Each returns a [Strategy], which is what stackencrypt.NewCredentials takes +// for the bearer token: the only way a token reaches a stackencrypt client. +// A raw token — [ProfileStore.Token]'s included — cannot be refreshed when +// it expires, and would bypass the cross-process lock a device-session +// refresh holds with the CLI, so stackencrypt does not accept one. A +// strategy lives in its store's guest, and closing the store closes it. A +// stackencrypt client given one never closes the strategy or the store: +// both are the caller's, and stay open until the client is closed. // [OAuth2TokenSource] adapts an existing golang.org/x/oauth2.TokenSource // into the OIDC provider interface. // diff --git a/languages/golang/stackauth/errors.go b/languages/golang/stackauth/errors.go index 30a914a21..143d2f466 100644 --- a/languages/golang/stackauth/errors.go +++ b/languages/golang/stackauth/errors.go @@ -58,10 +58,6 @@ var ( // reports it and the store works on with unlocked memory. ErrMemoryLock = guest.ErrMemoryLock - // ErrTokenExpired is a stored token past its expiry: the profile has one, - // but it is no use. A DeviceSession strategy can refresh it when the - // profile has a valid refresh token. - ErrTokenExpired = errors.New("stackauth: the stored token has expired; run `stash auth login`") // ErrNoProfile is a profile directory that does not exist: nothing has // logged in on this machine, or CS_CONFIG_PATH names the wrong place. ErrNoProfile = errors.New("stackauth: no profile directory; run `stash auth login`") diff --git a/languages/golang/stackauth/store_test.go b/languages/golang/stackauth/store_test.go index 552a08a66..9441fe6cc 100644 --- a/languages/golang/stackauth/store_test.go +++ b/languages/golang/stackauth/store_test.go @@ -309,47 +309,6 @@ func TestTypedReadsReturnTheFilesFields(t *testing.T) { } } -// The token source re-reads the file on every call, so a login in another -// terminal is picked up, and refuses the token at its real expiry. -func TestTokenSourceRereadsAndRefusesAtExpiry(t *testing.T) { - ctx := context.Background() - dir, s := profile(t) - ws, err := s.WorkspaceStore(ctx, wsA) - if err != nil { - t.Fatal(err) - } - src := ws.TokenSource() - first, err := src.Token(ctx) - if err != nil { - t.Fatal(err) - } - later := time.Now().Add(2 * time.Hour).Unix() - write(t, filepath.Join(dir, "workspaces", wsA, "auth.json"), authJSON(later)) - second, err := src.Token(ctx) - if err != nil { - t.Fatal(err) - } - if second == first || second != fmt.Sprintf("tok-%d", later) { - t.Fatalf("the rewritten token was not picked up: %q then %q", first, second) - } - // At the expiry timestamp itself the token is refused: the crate's - // is_usable is strictly before it. No refresh-ahead margin here. - src.now = func() time.Time { return time.Unix(later, 0) } - if _, err := src.Token(ctx); !errors.Is(err, ErrTokenExpired) || !strings.Contains(err.Error(), "stash auth login") { - t.Fatalf("token at its expiry: %v, want ErrTokenExpired naming stash auth login", err) - } - src.now = func() time.Time { return time.Unix(later-1, 0) } - if _, err := src.Token(ctx); err != nil { - t.Fatalf("token one second before expiry: %v", err) - } - // An expired file on disk is refused too, whatever the clock. - write(t, filepath.Join(dir, "workspaces", wsA, "auth.json"), authJSON(time.Now().Add(-time.Minute).Unix())) - src.now = nil - if _, err := src.Token(ctx); !errors.Is(err, ErrTokenExpired) { - t.Fatalf("an expired stored token: %v, want ErrTokenExpired", err) - } -} - // The lock file is the crate's sibling `.<filename>.lock`, named by the // guest and mapped back to the host, never composed here; a filename that // is a path is refused before any path is built. diff --git a/languages/golang/stackauth/strategy.go b/languages/golang/stackauth/strategy.go index dd6a7d876..f72689b2b 100644 --- a/languages/golang/stackauth/strategy.go +++ b/languages/golang/stackauth/strategy.go @@ -35,8 +35,11 @@ func strategyConfig(opts []StrategyOption) strategyOptions { } // Strategy is a Rust stack-auth strategy retained inside the credential -// guest. Token satisfies stackencrypt.TokenSource. Close drops its cached -// credential; closing the parent profile closes all its strategies. +// guest: the source of the bearer token stackencrypt.NewCredentials takes. +// Close drops its cached credential; closing the parent profile closes all +// its strategies. It is the caller's to close: a stackencrypt client that +// was given it asks it for tokens but never closes it, so it must stay open +// until the client is closed. type Strategy struct { store *ProfileStore handle string diff --git a/languages/golang/stackauth/token.go b/languages/golang/stackauth/token.go index eb8dd8e3e..cf3fefc90 100644 --- a/languages/golang/stackauth/token.go +++ b/languages/golang/stackauth/token.go @@ -2,7 +2,6 @@ package stackauth import ( "context" - "fmt" "time" "github.com/cipherstash/cipherstash-suite/bindings/go/internal/guest" @@ -69,35 +68,3 @@ func (s *ProfileStore) Token(ctx context.Context) (Token, error) { } return t, nil } - -// TokenSource is a bearer-token source over the token stored in a -// workspace's auth.json, in the shape stackencrypt's TokenSource takes. -// Every call re-reads the file, so a login or refresh by the CLI in -// another terminal is picked up without a restart, and a token at or past -// its real expiry is refused with [ErrTokenExpired] rather than presented. -// This read-only source does not refresh; use DeviceSession for that. -type TokenSource struct { - store *ProfileStore - // now is the clock, for tests; nil is time.Now. - now func() time.Time -} - -// TokenSource is a [TokenSource] over this store's auth.json. -func (s *ProfileStore) TokenSource() *TokenSource { return &TokenSource{store: s} } - -// Token implements stackencrypt's TokenSource: the stored access token, -// re-read now, or ErrTokenExpired. -func (ts *TokenSource) Token(ctx context.Context) (string, error) { - t, err := ts.store.Token(ctx) - if err != nil { - return "", err - } - now := time.Now - if ts.now != nil { - now = ts.now - } - if !t.Usable(now()) { - return "", fmt.Errorf("%w (expired at %s)", ErrTokenExpired, t.ExpiresAt.UTC().Format(time.RFC3339)) - } - return t.AccessToken, nil -} diff --git a/languages/golang/stackencrypt/README.md b/languages/golang/stackencrypt/README.md index 09133b668..2bf84a0ba 100644 --- a/languages/golang/stackencrypt/README.md +++ b/languages/golang/stackencrypt/README.md @@ -109,17 +109,38 @@ run in `stackauth`'s credential guest; the crypto guest that holds the keys is still given no environment and no filesystem. The credential guest lives as long as the client, and `Close` releases it. -To supply the credentials yourself, pass `NewCredentials`: +To supply the credentials yourself, pass `NewCredentials` with a client +id, a client key and a `stackauth` strategy for the token: ```go +store, err := stackauth.OpenWithoutProfile(ctx) // or stackauth.Resolve(ctx) for the profile +if err != nil { + return err +} +defer store.Close() +strategy, err := store.AccessKey(ctx, crn, accessKey) // or DeviceSession, OIDC, Auto +if err != nil { + return err +} +defer strategy.Close() client, err := stackencrypt.NewClient(ctx, - stackencrypt.WithCredentials(stackencrypt.NewCredentials(clientID, clientKey, tokenSource)), + stackencrypt.WithCredentials(stackencrypt.NewCredentials(clientID, clientKey, strategy)), ) +if err != nil { + return err +} +defer client.Close() ``` -`tokenSource` is asked for the bearer token on every request. `StaticToken` -is the simplest source; a `TokenFunc` can fetch or refresh one, and every -`stackauth` strategy is one. +The strategy is asked for the bearer token on every request, and mints or +refreshes it as it needs to. The store and the strategy stay yours: the +client never closes them, so keep them open until `client.Close` has +returned, as the deferred calls above do. A nil strategy is refused. + +Tokens come only from `stackauth` strategies; there is no way to hand the +client a raw bearer token. A raw token cannot be refreshed when it expires, +and a source outside the strategies would bypass the cross-process lock a +device-session refresh holds with the `stash` CLI. To authenticate through your own identity provider, pass `OIDCFederation` with the workspace CRN and a provider of the IdP's tokens. CTS exchanges @@ -128,8 +149,8 @@ when that token needs replacing. `stackauth.OAuth2TokenSource` adapts a `golang.org/x/oauth2` source. The client key is found as `AutoCredentials` finds it. -`Credentials` is an interface, so another source of credentials can -implement it. +`AutoCredentials`, `NewCredentials` and `OIDCFederation` are the only kinds +of `Credentials`: the interface is sealed. `ClientKey` is an opaque type, not a string: it prints a redaction under every verb, so logged credentials never show the key. `NewClientKey` takes @@ -198,12 +219,12 @@ small and reveals nothing about plaintext or key material. | `ErrForeignKeyset` | A keyset-bound `Cipher` was given another keyset's ciphertext. Open it through the `Client`. | | `ErrEncoding` | Malformed input: a value, ciphertext, plan, context or config refused before any cryptography. | | `ErrTerm` | A term could not be derived, for example match text that yields no tokens. | -| `ErrTransport` | ZeroKMS could not be reached, or the token source failed. A token source's own error is wrapped in it, so `errors.Is` finds that too (a refused refresh is `stackauth.ErrInvalidGrant`). | +| `ErrTransport` | ZeroKMS could not be reached, or the token strategy failed. The strategy's own error is wrapped in it, so `errors.Is` finds that too (a refused refresh is `stackauth.ErrInvalidGrant`). | | `ErrKMS` | Any other ZeroKMS failure. | | `ErrConflict` | ZeroKMS reported a resource conflict. | | `ErrState` | The client has been closed: by `Close`, by a call its context interrupted, or by a guest trap. | | `ErrMemoryLock` | The instance's memory could not be locked in RAM. Returned by `NewClient` under `WithRequireLockedMemory()`, and by a call whose growth could not be locked; otherwise reported by `MemoryLockError`. | -| `ErrNoCredentials` | `NewClient` found no token source or no client key, in the environment or the profile. The message names what to set. | +| `ErrNoCredentials` | `NewClient` found no token strategy or no client key, in the environment or the profile. The message names what to set. | | `ErrCredentialsConsumed` | `NewCredentials` given to a second `NewClient`: the first consumed its key. Build new credentials, with a new key, for another client. | | `ErrInternal` | An unexpected failure inside the guest. | @@ -221,8 +242,8 @@ small and reveals nothing about plaintext or key material. own shutdown as well, so every key is wiped in place before the instance is released. - **Two host imports.** The guest imports exactly one HTTP send, served by - your `http.RoundTripper`, and one bearer-token fetch, served by your - `TokenSource`. What crosses the boundary per ZeroKMS call is what would + your `http.RoundTripper`, and one bearer-token fetch, served by the + credentials' `stackauth` strategy. What crosses the boundary per ZeroKMS call is what would cross TLS anyway. The guest sees no environment and no filesystem. - **Real randomness.** The guest draws IVs and nonces from the process CSPRNG. wazero's default random source is deterministic, so the package diff --git a/languages/golang/stackencrypt/client.go b/languages/golang/stackencrypt/client.go index 47e42099a..17d6826da 100644 --- a/languages/golang/stackencrypt/client.go +++ b/languages/golang/stackencrypt/client.go @@ -96,9 +96,9 @@ func NewClient(ctx context.Context, opts ...ClientOption) (_ *Client, err error) consumeUnresolved(creds) return nil, err } - resolved, err := creds.Resolve(ctx, ResolveOptions{Transport: rt, RequireLockedMemory: cfg.requireLockedMemory}) + resolved, err := creds.resolve(ctx, resolveOptions{Transport: rt, RequireLockedMemory: cfg.requireLockedMemory}) if err != nil { - // A Resolve that fails may still hand back what it built. The key + // A resolve that fails may still hand back what it built. The key // is consumed and what Close holds is released, as on every other // path: nothing of the client's outlives a failed NewClient. if resolved != nil { @@ -110,7 +110,7 @@ func NewClient(ctx context.Context, opts ...ClientOption) (_ *Client, err error) return nil, err } if resolved == nil { - return nil, errors.New("stackencrypt: Credentials.Resolve returned no credentials") + return nil, errors.New("stackencrypt: the credentials resolved to nothing") } // Nil-safe, and a no-op after the wipe on the accepted path. defer resolved.ClientKey.Wipe() diff --git a/languages/golang/stackencrypt/credentials.go b/languages/golang/stackencrypt/credentials.go index 4982f3333..3723f94ab 100644 --- a/languages/golang/stackencrypt/credentials.go +++ b/languages/golang/stackencrypt/credentials.go @@ -13,29 +13,33 @@ import ( ) // Credentials is where a [Client]'s ZeroKMS credentials come from: the -// client id, the client key, and the source of the bearer token. NewClient -// resolves them once, host-side — the crypto guest is never given the -// environment or a filesystem to look them up itself — and hands the key to -// the guest. +// client id, the client key, and the stackauth strategy that supplies the +// bearer token. NewClient resolves them once, host-side — the crypto guest +// is never given the environment or a filesystem to look them up itself — +// and hands the key to the guest. // // [AutoCredentials] is the default: the environment, then the developer -// profile, in the Rust client's order. [NewCredentials] takes the three -// values explicitly; [OIDCFederation] mints the token from an identity -// provider's. Any other type can implement it; see -// [ResolvedCredentials] for what an implementation owes the client. +// profile, in the Rust client's order. [NewCredentials] takes a client id, +// a client key and a strategy explicitly; [OIDCFederation] mints the token +// from an identity provider's. Those three are the only implementations: +// the interface is sealed, so a bearer token always comes from a stackauth +// strategy. A raw token cannot be refreshed when it expires, and a source +// outside the strategies would bypass the cross-process refresh lock the +// device session shares with the CLI (a refresh token used twice gets the +// whole chain revoked). type Credentials interface { - // Resolve produces the credentials for one client. NewClient calls it + // resolve produces the credentials for one client. NewClient calls it // once, and consumes the key it returns. A result returned alongside // an error is consumed too: its key is wiped and its Close is called, // so an implementation may hand back what it built before it failed // rather than release it itself. - Resolve(ctx context.Context, opts ResolveOptions) (*ResolvedCredentials, error) + resolve(ctx context.Context, opts resolveOptions) (*resolvedCredentials, error) } -// ResolveOptions is what NewClient tells a [Credentials] about the client +// resolveOptions is what NewClient tells a [Credentials] about the client // it is resolving for, so a source that makes requests or holds key // material of its own can do so under the client's settings. -type ResolveOptions struct { +type resolveOptions struct { // Transport is the client's RoundTripper (never nil). AutoCredentials // sends its authentication requests through it too. Transport http.RoundTripper @@ -45,16 +49,17 @@ type ResolveOptions struct { RequireLockedMemory bool } -// ResolvedCredentials is one client's credentials, as a [Credentials] +// resolvedCredentials is one client's credentials, as a [Credentials] // resolved them. -type ResolvedCredentials struct { +type resolvedCredentials struct { // ClientID is the ZeroKMS client id (a UUID string). ClientID string // ClientKey is the client key. NewClient consumes it whatever the // outcome, as [NewClientKey] describes. ClientKey *ClientKey - // Token supplies the bearer token for every request. - Token TokenSource + // Token supplies the bearer token for every request: a stackauth + // strategy, outside the package's own tests. + Token tokenSource // Close, when not nil, releases what the credentials hold open — the // profile's guest and a refreshing token strategy, for AutoCredentials. // The client calls it from Client.Close, or from NewClient when the @@ -74,18 +79,33 @@ type ResolvedCredentials struct { MemoryLockError func() error } -// ErrNoCredentials is [AutoCredentials] finding no token source or no +// ErrNoCredentials is [AutoCredentials] finding no token strategy or no // client key in either place it looks. The wrapped error says which, and // what to set. var ErrNoCredentials = errors.New("stackencrypt: no credentials") // NewCredentials is [Credentials] from explicit values: a client id, a -// client key (from [NewClientKey], or stackauth's typed read), and a token -// source. The key is consumed by the first NewClient given these -// credentials; a second is refused with [ErrCredentialsConsumed], as a key -// is for one client. -func NewCredentials(clientID string, key *ClientKey, token TokenSource) Credentials { - return &explicitCredentials{clientID: clientID, key: key, token: token} +// client key (from [NewClientKey], or stackauth's typed read), and the +// stackauth strategy that supplies the bearer token (ProfileStore's +// AccessKey, DeviceSession, OIDC or Auto). The key is consumed by the first +// NewClient given these credentials; a second is refused with +// [ErrCredentialsConsumed], as a key is for one client. A nil strategy is +// refused by NewClient. +// +// The caller opened the strategy's ProfileStore and the strategy, and keeps +// them: the client asks the strategy for a token on every ZeroKMS request +// but never closes it or the store. Both must stay open until Client.Close +// has returned, and are the caller's to close after it — the strategy, then +// the store (closing the store closes its strategies too). A token asked of +// a closed strategy is an error from the operation that needed it. +func NewCredentials(clientID string, key *ClientKey, strategy *stackauth.Strategy) Credentials { + c := &explicitCredentials{clientID: clientID, key: key} + // A nil *Strategy stored in the interface would be a non-nil source + // that fails on first use; left unset, resolve refuses it up front. + if strategy != nil { + c.token = strategy + } + return c } // ErrCredentialsConsumed is [NewCredentials] given to a second NewClient: @@ -94,21 +114,30 @@ func NewCredentials(clientID string, key *ClientKey, token TokenSource) Credenti // new key, for another client. var ErrCredentialsConsumed = errors.New("stackencrypt: the credentials' client key was already consumed by an earlier NewClient") +// explicitCredentials is NewCredentials. token is the strategy; only this +// package's tests put anything else in it. type explicitCredentials struct { clientID string key *ClientKey - token TokenSource - // consumed is set by the first Resolve: what it handed out is the + token tokenSource + // consumed is set by the first resolve: what it handed out is the // caller's to wipe, and a second caller must not be told its values // were missing when they were spent. consumed atomic.Bool } -func (c *explicitCredentials) Resolve(context.Context, ResolveOptions) (*ResolvedCredentials, error) { +func (c *explicitCredentials) resolve(context.Context, resolveOptions) (*resolvedCredentials, error) { if !c.consumed.CompareAndSwap(false, true) { return nil, ErrCredentialsConsumed } - return &ResolvedCredentials{ClientID: c.clientID, ClientKey: c.key, Token: c.token}, nil + resolved := &resolvedCredentials{ClientID: c.clientID, ClientKey: c.key, Token: c.token} + if c.token == nil { + // Returned with the key, so NewClient consumes it as it does on + // every other refusal. + return resolved, fmt.Errorf("%w: NewCredentials needs a stackauth strategy for the token", ErrEncoding) + } + // No Close: the strategy and its store are the caller's. + return resolved, nil } // String names the credentials' kind and client id; the key prints a @@ -165,7 +194,7 @@ type autoCredentials struct{} // String names the credentials' kind; nothing is resolved to print it. func (autoCredentials) String() string { return "stackencrypt.AutoCredentials" } -func (autoCredentials) Resolve(ctx context.Context, opts ResolveOptions) (*ResolvedCredentials, error) { +func (autoCredentials) resolve(ctx context.Context, opts resolveOptions) (*resolvedCredentials, error) { return resolveWithStrategy(ctx, opts, func(ctx context.Context, profile *stackauth.ProfileStore, noProfile error) (*stackauth.Strategy, error) { strategy, err := profile.Auto(ctx) switch { @@ -209,7 +238,7 @@ func (c oidcCredentials) String() string { return fmt.Sprintf("stackencrypt.OIDCFederation{crn: %s}", c.crn) } -func (c oidcCredentials) Resolve(ctx context.Context, opts ResolveOptions) (*ResolvedCredentials, error) { +func (c oidcCredentials) resolve(ctx context.Context, opts resolveOptions) (*resolvedCredentials, error) { return resolveWithStrategy(ctx, opts, func(ctx context.Context, profile *stackauth.ProfileStore, _ error) (*stackauth.Strategy, error) { strategy, err := profile.OIDC(ctx, c.crn, c.provider) if err != nil { @@ -227,9 +256,9 @@ func (c oidcCredentials) Resolve(ctx context.Context, opts ResolveOptions) (*Res // credentials' to close; on failure they are closed here. func resolveWithStrategy( ctx context.Context, - opts ResolveOptions, + opts resolveOptions, strategy func(ctx context.Context, profile *stackauth.ProfileStore, noProfile error) (*stackauth.Strategy, error), -) (_ *ResolvedCredentials, err error) { +) (_ *resolvedCredentials, err error) { authOpts := []stackauth.Option{stackauth.WithRoundTripper(opts.Transport)} if opts.RequireLockedMemory { authOpts = append(authOpts, stackauth.RequireLockedMemory()) @@ -275,7 +304,7 @@ func resolveWithStrategy( return nil, err } } - return &ResolvedCredentials{ + return &resolvedCredentials{ ClientID: clientID, ClientKey: key, Token: token, diff --git a/languages/golang/stackencrypt/credentials_test.go b/languages/golang/stackencrypt/credentials_test.go index 2f1ec7a7f..2d4ebc135 100644 --- a/languages/golang/stackencrypt/credentials_test.go +++ b/languages/golang/stackencrypt/credentials_test.go @@ -160,16 +160,16 @@ func newAuthServer(t *testing.T) *authServer { // resolve runs AutoCredentials and releases what it holds at the end of // the test. -func resolve(t *testing.T) (*ResolvedCredentials, error) { +func resolve(t *testing.T) (*resolvedCredentials, error) { t.Helper() - resolved, err := AutoCredentials().Resolve(context.Background(), ResolveOptions{Transport: http.DefaultTransport}) + resolved, err := AutoCredentials().resolve(context.Background(), resolveOptions{Transport: http.DefaultTransport}) if err == nil { t.Cleanup(func() { _ = resolved.Close() }) } return resolved, err } -func token(t *testing.T, resolved *ResolvedCredentials) string { +func token(t *testing.T, resolved *resolvedCredentials) string { t.Helper() tok, err := resolved.Token.Token(context.Background()) if err != nil { @@ -410,9 +410,9 @@ func TestNewClientWithAutoCredentials(t *testing.T) { stub := newStub(t, http.StatusUnauthorized, "", "nope") // The endpoint from the environment, since no option names one. t.Setenv("CS_ZEROKMS_HOST", stub.URL) - var released *ResolvedCredentials - creds := credentialsFunc(func(ctx context.Context, opts ResolveOptions) (*ResolvedCredentials, error) { - r, err := AutoCredentials().Resolve(ctx, opts) + var released *resolvedCredentials + creds := credentialsFunc(func(ctx context.Context, opts resolveOptions) (*resolvedCredentials, error) { + r, err := AutoCredentials().resolve(ctx, opts) released = r return r, err }) @@ -439,8 +439,8 @@ func TestNewClientReleasesTheCredentialsOnceWhenInitFails(t *testing.T) { guestOrSkip(t) stub := newStub(t, http.StatusUnauthorized, "", "nope") var released int - creds := credentialsFunc(func(ctx context.Context, opts ResolveOptions) (*ResolvedCredentials, error) { - r, err := testCredentials(StaticToken("t")).Resolve(ctx, opts) + creds := credentialsFunc(func(ctx context.Context, opts resolveOptions) (*resolvedCredentials, error) { + r, err := testCredentials(staticToken("t")).resolve(ctx, opts) if err == nil { r.Close = func() error { released++; return nil } } @@ -461,8 +461,8 @@ func TestNewClientConsumesCredentialsAFailedResolveHandsBack(t *testing.T) { key := NewClientKey([]byte(testClientKey)) var released int resolveErr := errors.New("the token strategy failed") - creds := credentialsFunc(func(context.Context, ResolveOptions) (*ResolvedCredentials, error) { - return &ResolvedCredentials{ + creds := credentialsFunc(func(context.Context, resolveOptions) (*resolvedCredentials, error) { + return &resolvedCredentials{ ClientID: testClientID, ClientKey: key, Close: func() error { released++; return nil }, @@ -498,9 +498,9 @@ func TestNewClientRefusesTheConfigBeforeResolvingCredentials(t *testing.T) { cleanEnv(t, t.TempDir()) opts := options(t) resolved := false - spy := credentialsFunc(func(ctx context.Context, opts ResolveOptions) (*ResolvedCredentials, error) { + spy := credentialsFunc(func(ctx context.Context, opts resolveOptions) (*resolvedCredentials, error) { resolved = true - return testCredentials(StaticToken("t")).Resolve(ctx, opts) + return testCredentials(staticToken("t")).resolve(ctx, opts) }) if _, err := NewClient(context.Background(), append([]ClientOption{WithCredentials(spy)}, opts...)...); err == nil { t.Fatal("NewClient accepted the config") @@ -509,7 +509,7 @@ func TestNewClientRefusesTheConfigBeforeResolvingCredentials(t *testing.T) { t.Error("the credentials were resolved for a config refused host-side") } key := NewClientKey([]byte(testClientKey)) - explicit := NewCredentials(testClientID, key, StaticToken("t")) + explicit := newTestCredentials(testClientID, key, staticToken("t")) if _, err := NewClient(context.Background(), append([]ClientOption{WithCredentials(explicit)}, opts...)...); err == nil { t.Fatal("NewClient accepted the config") } @@ -527,7 +527,7 @@ func TestNewClientRefusesTheConfigBeforeResolvingCredentials(t *testing.T) { func TestNewCredentialsRefusedConfigThenRetryIsConsumed(t *testing.T) { guestOrSkip(t) stub := newStub(t, http.StatusUnauthorized, "", "nope") - creds := testCredentials(StaticToken("t")) + creds := testCredentials(staticToken("t")) if _, err := NewClient(context.Background(), WithCredentials(creds), WithZeroKMSURL(stub.URL), WithKeysetCacheSize(-1)); err == nil { t.Fatal("NewClient accepted a negative cache size") } @@ -548,7 +548,7 @@ func TestNewCredentialsRefusedConfigThenRetryIsConsumed(t *testing.T) { // the client is asked, printed or logged. func TestClientReportsTheCredentialsMemoryLock(t *testing.T) { wasm := guestOrSkip(t) - inst, err := newInstance(context.Background(), wasm, &transport{rt: http.DefaultTransport, token: StaticToken("t")}, guest.BestEffort) + inst, err := newInstance(context.Background(), wasm, &transport{rt: http.DefaultTransport, token: staticToken("t")}, guest.BestEffort) if err != nil { t.Fatal(err) } @@ -586,7 +586,7 @@ func TestClientReportsTheCredentialsMemoryLock(t *testing.T) { func TestNewCredentialsRefusesASecondClient(t *testing.T) { guestOrSkip(t) stub := newStub(t, http.StatusUnauthorized, "", "nope") - creds := testCredentials(StaticToken("t")) + creds := testCredentials(staticToken("t")) cfg := []ClientOption{WithCredentials(creds), WithZeroKMSURL(stub.URL)} if _, err := NewClient(context.Background(), cfg...); !errors.Is(err, ErrUnauthorized) { t.Fatalf("first NewClient: %v, want ErrUnauthorized from the stub", err) @@ -664,7 +664,7 @@ func TestZeroKMSEndpointOrder(t *testing.T) { func TestAutoCredentialsCloseReleasesTheGuest(t *testing.T) { authGuestOrSkip(t) cleanEnv(t, newProfile(t, loggedIn("profile-token"))) - resolved, err := AutoCredentials().Resolve(context.Background(), ResolveOptions{Transport: http.DefaultTransport}) + resolved, err := AutoCredentials().resolve(context.Background(), resolveOptions{Transport: http.DefaultTransport}) if err != nil { t.Fatal(err) } @@ -677,7 +677,7 @@ func TestAutoCredentialsCloseReleasesTheGuest(t *testing.T) { } func TestCredentialsPrintNoKey(t *testing.T) { - for _, c := range []Credentials{AutoCredentials(), NewCredentials(testClientID, NewClientKey([]byte(testClientKey)), StaticToken("t"))} { + for _, c := range []Credentials{AutoCredentials(), newTestCredentials(testClientID, NewClientKey([]byte(testClientKey)), staticToken("t"))} { for _, verb := range []string{"%v", "%+v", "%s"} { if out := fmt.Sprintf(verb, c); strings.Contains(out, testClientKey[:16]) || !strings.HasPrefix(out, "stackencrypt.") { t.Errorf("%s: %q", verb, out) @@ -686,10 +686,66 @@ func TestCredentialsPrintNoKey(t *testing.T) { } } -type credentialsFunc func(context.Context, ResolveOptions) (*ResolvedCredentials, error) +type credentialsFunc func(context.Context, resolveOptions) (*resolvedCredentials, error) -func (f credentialsFunc) Resolve(ctx context.Context, opts ResolveOptions) (*ResolvedCredentials, error) { +func (f credentialsFunc) resolve(ctx context.Context, opts resolveOptions) (*resolvedCredentials, error) { return f(ctx, opts) } func ptr(s string) *string { return &s } + +// NewCredentials takes its token only from a stackauth strategy: a nil one +// is refused, and the key is consumed all the same. +func TestNewCredentialsRefusesANilStrategy(t *testing.T) { + key := NewClientKey([]byte(testClientKey)) + _, err := NewClient(context.Background(), WithCredentials(NewCredentials(testClientID, key, nil))) + if !errors.Is(err, ErrEncoding) || !strings.Contains(err.Error(), "strategy") { + t.Fatalf("NewClient: %v, want ErrEncoding naming the strategy", err) + } + if !key.IsZero() { + t.Error("the key still holds material after NewClient refused a nil strategy") + } +} + +// The strategy given to NewCredentials is the caller's: the credentials +// hold nothing to close, and a client — here one whose init failed — leaves +// the strategy open. +func TestNewCredentialsLeavesTheStrategyToTheCaller(t *testing.T) { + guestOrSkip(t) + authGuestOrSkip(t) + ctx := context.Background() + store, err := stackauth.OpenWithoutProfile(ctx) + if err != nil { + t.Fatal(err) + } + defer store.Close() + cts := newStub(t, http.StatusUnauthorized, "", "nope") + strategy, err := store.AccessKey(ctx, testCRN, "CSAKtest.key", stackauth.WithAuthBaseURL(cts.URL)) + if err != nil { + t.Fatal(err) + } + creds := NewCredentials(testClientID, NewClientKey([]byte(testClientKey)), strategy) + resolved, err := creds.resolve(ctx, resolveOptions{Transport: http.DefaultTransport}) + if err != nil { + t.Fatal(err) + } + if resolved.Token != tokenSource(strategy) || resolved.Close != nil { + t.Fatalf("resolved = %+v, want the strategy as the token source and no Close", resolved) + } + resolved.ClientKey.Wipe() + + zerokms := newStub(t, http.StatusOK, "application/json", "{}") + creds = NewCredentials(testClientID, NewClientKey([]byte(testClientKey)), strategy) + if _, err := NewClient(ctx, WithCredentials(creds), WithZeroKMSURL(zerokms.URL)); err == nil { + t.Fatal("NewClient succeeded with a token CTS refused") + } + // Still open: asked again, it goes back to CTS rather than failing + // with ErrState. + before := len(cts.requests) + if _, err := strategy.Token(ctx); errors.Is(err, stackauth.ErrState) { + t.Fatalf("the strategy after a failed NewClient: %v, want it still open", err) + } + if len(cts.requests) == before { + t.Error("the strategy made no request after NewClient: it was closed") + } +} diff --git a/languages/golang/stackencrypt/doc.go b/languages/golang/stackencrypt/doc.go index 1507472c8..180d111c4 100644 --- a/languages/golang/stackencrypt/doc.go +++ b/languages/golang/stackencrypt/doc.go @@ -56,25 +56,29 @@ // # Transport and auth // // The guest imports exactly two host functions: an HTTP send, served by any -// [net/http.RoundTripper], and a bearer-token fetch, served by a -// [TokenSource]. What crosses per ZeroKMS call is what would cross TLS +// [net/http.RoundTripper], and a bearer-token fetch, served by the +// credentials' stackauth strategy. What crosses per ZeroKMS call is what would cross TLS // anyway; derived key material never leaves the guest. Under // [AutoCredentials] the same RoundTripper also carries the authentication // requests to CTS, so one scoped to the ZeroKMS host alone is not enough. // // # Credentials // -// A [Credentials] supplies the client id, the client key and the token -// source, and NewClient resolves it host-side: the crypto guest is never +// A [Credentials] supplies the client id, the client key and the stackauth +// strategy the token comes from, and NewClient resolves it host-side: the crypto guest is never // given the environment or a filesystem to find them in. The default, // [AutoCredentials], mirrors the Rust client — the environment first // (CS_CLIENT_ACCESS_KEY with CS_WORKSPACE_CRN for the token, CS_CLIENT_ID // with CS_CLIENT_KEY for the key), then the developer profile, which it // reads through stackauth's credential guest, where the token strategies // also run. CS_ZEROKMS_HOST (or CS_VITUR_HOST) pins the endpoint whatever -// the credentials. [NewCredentials] takes the three values explicitly, and -// [OIDCFederation] mints the token from an identity provider's. Pass one -// with [WithCredentials]. +// the credentials. [NewCredentials] takes a client id, a client key and a +// strategy explicitly, and [OIDCFederation] mints the token from an +// identity provider's. Pass one with [WithCredentials]. Those three are +// the only kinds of Credentials, and none takes a raw token: a token is +// always a stackauth strategy's, since a raw one cannot be refreshed when +// it expires and would bypass the cross-process lock a device-session +// refresh holds with the CLI. // Credentials that cannot be resolved fail NewClient with // [ErrNoCredentials]; credentials that resolve but do not work fail it too, // at the one ZeroKMS round trip it makes. diff --git a/languages/golang/stackencrypt/example/README.md b/languages/golang/stackencrypt/example/README.md index 03d008579..e3eda49d5 100644 --- a/languages/golang/stackencrypt/example/README.md +++ b/languages/golang/stackencrypt/example/README.md @@ -59,8 +59,9 @@ from it. The crypto guest still sees no environment and no filesystem: credentials are resolved host-side. The device session is **asked on every request and refreshes itself**. A -profile token lasts 45 minutes, so pinning one with `StaticToken` would give -you a program that works for a while and then stops. The refresh takes the +profile token lasts 45 minutes, so a pinned token would give you a program +that works for a while and then stops; that is why the client takes tokens +only from `stackauth` strategies and has no way to pass a raw one. The refresh takes the same cross-process lock as the `stash` CLI. The IdP rotates refresh tokens and detects replay, so two processes sharing `~/.cipherstash` that both exchanged the same refresh token would get the whole chain revoked; the lock diff --git a/languages/golang/stackencrypt/export_test.go b/languages/golang/stackencrypt/export_test.go new file mode 100644 index 000000000..efdef8972 --- /dev/null +++ b/languages/golang/stackencrypt/export_test.go @@ -0,0 +1,25 @@ +package stackencrypt + +import "context" + +// The test-only ways to give a client a token. The public API takes tokens +// only from stackauth strategies; the tests that talk to httptest stubs +// need a fixed one, and get it here rather than through anything a caller +// could reach. + +// tokenFunc adapts a function to a tokenSource. +type tokenFunc func(ctx context.Context) (string, error) + +func (f tokenFunc) Token(ctx context.Context) (string, error) { return f(ctx) } + +// staticToken is a tokenSource that always returns token. +func staticToken(token string) tokenSource { + return tokenFunc(func(context.Context) (string, error) { return token, nil }) +} + +// newTestCredentials is NewCredentials with token in place of a strategy: +// the same type, so the same consume-on-refusal and no-Close semantics. +// A nil token is refused as NewCredentials refuses a nil strategy. +func newTestCredentials(clientID string, key *ClientKey, token tokenSource) Credentials { + return &explicitCredentials{clientID: clientID, key: key, token: token} +} diff --git a/languages/golang/stackencrypt/guest_test.go b/languages/golang/stackencrypt/guest_test.go index 9c4800bdc..5cb71fc5f 100644 --- a/languages/golang/stackencrypt/guest_test.go +++ b/languages/golang/stackencrypt/guest_test.go @@ -17,6 +17,7 @@ import ( "time" "github.com/cipherstash/cipherstash-suite/bindings/go/internal/guest" + "github.com/cipherstash/cipherstash-suite/bindings/go/stackauth" "github.com/cipherstash/vitaminc/bindings/go/vcvalue" "github.com/tetratelabs/wazero" "github.com/tetratelabs/wazero/sys" @@ -98,13 +99,13 @@ func newStub(t *testing.T, status int, contentType, body string) *zerokmsStub { // testConfig is the options for a client of the test credentials against // url. A test appends to it; a later option wins. func testConfig(url string) []ClientOption { - return []ClientOption{WithCredentials(testCredentials(StaticToken("stub-token"))), WithZeroKMSURL(url)} + return []ClientOption{WithCredentials(testCredentials(staticToken("stub-token"))), WithZeroKMSURL(url)} } // testCredentials is the test client id and a fresh copy of the test key, // with token as the token source. -func testCredentials(token TokenSource) Credentials { - return NewCredentials(testClientID, NewClientKey([]byte(testClientKey)), token) +func testCredentials(token tokenSource) Credentials { + return newTestCredentials(testClientID, NewClientKey([]byte(testClientKey)), token) } // testInit is testConfig as se_cipher_init takes it, for tests that drive @@ -214,7 +215,7 @@ func TestTransportOutcomesMapToErrors(t *testing.T) { stub := newStub(t, http.StatusOK, "application/json", "{}") cfg := testConfig(stub.URL) vaultDown := errors.New("vault down") - cfg = append(cfg, WithCredentials(testCredentials(TokenFunc(func(context.Context) (string, error) { return "", vaultDown })))) + cfg = append(cfg, WithCredentials(testCredentials(tokenFunc(func(context.Context) (string, error) { return "", vaultDown })))) _, err := NewClient(context.Background(), cfg...) if err == nil { t.Fatal("NewClient succeeded with no token") @@ -461,17 +462,17 @@ func TestConfigValidation(t *testing.T) { for name, tc := range map[string]struct { id string key *ClientKey - token TokenSource + token tokenSource cache int }{ "no token": {id: testClientID, key: NewClientKey([]byte(testClientKey))}, - "no client id": {key: NewClientKey([]byte(testClientKey)), token: StaticToken("t")}, - "no key": {id: testClientID, token: StaticToken("t")}, - "empty key": {id: testClientID, key: NewClientKey(nil), token: StaticToken("t")}, - "wiped key": {id: testClientID, key: wiped, token: StaticToken("t")}, - "negative cache": {id: testClientID, key: NewClientKey([]byte(testClientKey)), token: StaticToken("t"), cache: -1}, + "no client id": {key: NewClientKey([]byte(testClientKey)), token: staticToken("t")}, + "no key": {id: testClientID, token: staticToken("t")}, + "empty key": {id: testClientID, key: NewClientKey(nil), token: staticToken("t")}, + "wiped key": {id: testClientID, key: wiped, token: staticToken("t")}, + "negative cache": {id: testClientID, key: NewClientKey([]byte(testClientKey)), token: staticToken("t"), cache: -1}, } { - cfg := []ClientOption{WithCredentials(NewCredentials(tc.id, tc.key, tc.token)), WithKeysetCacheSize(tc.cache)} + cfg := []ClientOption{WithCredentials(newTestCredentials(tc.id, tc.key, tc.token)), WithKeysetCacheSize(tc.cache)} if _, err := NewClient(ctx, cfg...); err == nil { t.Errorf("%s: NewClient succeeded", name) } @@ -484,8 +485,8 @@ func TestConfigValidation(t *testing.T) { // Malformed values the guest refuses: no request is made. guestOrSkip(t) for name, option := range map[string]ClientOption{ - "client id not a uuid": WithCredentials(NewCredentials("acme", NewClientKey([]byte(testClientKey)), StaticToken("t"))), - "key not hex": WithCredentials(NewCredentials(testClientID, NewClientKey([]byte("zz")), StaticToken("t"))), + "client id not a uuid": WithCredentials(newTestCredentials("acme", NewClientKey([]byte(testClientKey)), staticToken("t"))), + "key not hex": WithCredentials(newTestCredentials(testClientID, NewClientKey([]byte("zz")), staticToken("t"))), "bad url": WithZeroKMSURL("not a url"), } { stub := newStub(t, http.StatusOK, "application/json", "{}") @@ -513,11 +514,11 @@ func TestClientKeyIsConsumedAndNeverPrinted(t *testing.T) { material := []byte(testClientKey) stub := newStub(t, http.StatusUnauthorized, "", "nope") key := NewClientKey(material) - creds := NewCredentials(testClientID, key, StaticToken("stub-token")) + creds := newTestCredentials(testClientID, key, staticToken("stub-token")) cfg := append(testConfig(stub.URL), WithCredentials(creds)) // Resolving consumes the credentials, so the printed resolution is a // separate set's, over another copy of the key. - resolved, err := NewCredentials(testClientID, NewClientKey([]byte(testClientKey)), StaticToken("stub-token")).Resolve(context.Background(), ResolveOptions{}) + resolved, err := newTestCredentials(testClientID, NewClientKey([]byte(testClientKey)), staticToken("stub-token")).resolve(context.Background(), resolveOptions{}) if err != nil { t.Fatal(err) } @@ -560,7 +561,7 @@ func TestClientKeyIsConsumedAndNeverPrinted(t *testing.T) { func rawInstance(t *testing.T) *Client { t.Helper() ctx := context.Background() - inst, err := newInstance(ctx, guestOrSkip(t), &transport{rt: http.DefaultTransport, token: StaticToken("t")}, guest.BestEffort) + inst, err := newInstance(ctx, guestOrSkip(t), &transport{rt: http.DefaultTransport, token: staticToken("t")}, guest.BestEffort) if err != nil { t.Fatal(err) } @@ -790,7 +791,7 @@ func TestClientKeyDoesNotRemainInGuestMemory(t *testing.T) { if err != nil { t.Fatal(err) } - tr := &transport{rt: http.DefaultTransport, token: StaticToken("stub-token")} + tr := &transport{rt: http.DefaultTransport, token: staticToken("stub-token")} inst, err := newInstance(ctx, guestOrSkip(t), tr, guest.BestEffort) if err != nil { t.Fatal(err) @@ -824,7 +825,7 @@ func TestClientKeyDoesNotRemainInGuestMemory(t *testing.T) { func TestTransportSendCounterAndResponseHeaders(t *testing.T) { guestOrSkip(t) stub := newStub(t, http.StatusUnauthorized, "text/plain", "nope") - tr := &transport{rt: http.DefaultTransport, token: StaticToken("stub-token")} + tr := &transport{rt: http.DefaultTransport, token: staticToken("stub-token")} ctx := context.Background() inst, err := newInstance(ctx, guestOrSkip(t), tr, guest.BestEffort) if err != nil { @@ -841,13 +842,32 @@ func TestTransportSendCounterAndResponseHeaders(t *testing.T) { } func ExampleNewClient() { - // A client needs ZeroKMS credentials; see live_test.go for the shape of - // a real round trip. - _, err := NewClient(context.Background(), WithCredentials(NewCredentials( - "6a70bd18-99ac-4650-b104-37eec3a15b09", - NewClientKey([]byte("...")), - StaticToken("access token"), - ))) + // With no options, NewClient uses AutoCredentials. To supply the + // credentials yourself, the token comes from a stackauth strategy — + // here an access key; live_test.go has a real round trip. The caller + // opened the store and the strategy, and closes them after the client. + ctx := context.Background() + err := func() error { + store, err := stackauth.OpenWithoutProfile(ctx) + if err != nil { + return err + } + defer store.Close() + strategy, err := store.AccessKey(ctx, "crn:ap-southeast-2.aws:ZVATKW3VHMFG27DY", "CSAK...") + if err != nil { + return err + } + defer strategy.Close() + client, err := NewClient(ctx, WithCredentials(NewCredentials( + "6a70bd18-99ac-4650-b104-37eec3a15b09", + NewClientKey([]byte("...")), // not a real key: NewClient refuses it + strategy, + ))) + if err != nil { + return err + } + return client.Close() + }() fmt.Println(err != nil) // Output: true } diff --git a/languages/golang/stackencrypt/live_test.go b/languages/golang/stackencrypt/live_test.go index ed65679cd..ea636143a 100644 --- a/languages/golang/stackencrypt/live_test.go +++ b/languages/golang/stackencrypt/live_test.go @@ -9,6 +9,7 @@ import ( "strings" "testing" + "github.com/cipherstash/cipherstash-suite/bindings/go/stackauth" "github.com/cipherstash/vitaminc/bindings/go/vcvalue" ) @@ -19,10 +20,12 @@ import ( // // - STACK_ENCRYPT_TEST_CLIENT_ID, STACK_ENCRYPT_TEST_CLIENT_KEY: the // seeded client (every live test); -// - STACK_ENCRYPT_TEST_ACCESS_TOKEN: an already-minted token (liveClient); -// - STACK_ENCRYPT_TEST_ACCESS_KEY, STACK_ENCRYPT_TEST_WORKSPACE_CRN: an -// access key and its workspace, exchanged for a token by -// AutoCredentials (TestLiveAutoCredentialsFromTheEnvironment); +// - STACK_ENCRYPT_TEST_CLIENT_ACCESS_KEY, STACK_ENCRYPT_TEST_WORKSPACE_CRN: +// an access key and its workspace, exchanged for a token (every live +// test) — by a stackauth access-key strategy given to NewCredentials +// (liveClient), and by AutoCredentials from the environment +// (TestLiveAutoCredentialsFromTheEnvironment). There is no raw-token +// variable: the client takes tokens only from stackauth strategies; // - STACK_ENCRYPT_TEST_ZEROKMS_URL (optional): the ZeroKMS endpoint, else // the token's services claim; // - STACK_ENCRYPT_TEST_CTS_HOST (optional): the authentication endpoint @@ -31,20 +34,43 @@ import ( func liveClient(t *testing.T) *Client { t.Helper() - guestOrSkip(t) clientID, clientKey := os.Getenv("STACK_ENCRYPT_TEST_CLIENT_ID"), os.Getenv("STACK_ENCRYPT_TEST_CLIENT_KEY") - token, url := os.Getenv("STACK_ENCRYPT_TEST_ACCESS_TOKEN"), os.Getenv("STACK_ENCRYPT_TEST_ZEROKMS_URL") - if clientID == "" || clientKey == "" || token == "" { - t.Skip("STACK_ENCRYPT_TEST_{CLIENT_ID,CLIENT_KEY,ACCESS_TOKEN} not set") + accessKey, crn := os.Getenv("STACK_ENCRYPT_TEST_CLIENT_ACCESS_KEY"), os.Getenv("STACK_ENCRYPT_TEST_WORKSPACE_CRN") + url := os.Getenv("STACK_ENCRYPT_TEST_ZEROKMS_URL") + if clientID == "" || clientKey == "" || accessKey == "" || crn == "" { + t.Skip("STACK_ENCRYPT_TEST_{CLIENT_ID,CLIENT_KEY,CLIENT_ACCESS_KEY,WORKSPACE_CRN} not set") + } + guestOrSkip(t) + authGuestOrSkip(t) + // The explicit path: the caller opens the store and the strategy, and + // closes them after the client (cleanups run last-registered first). + store, err := stackauth.OpenWithoutProfile(t.Context()) + if err != nil { + t.Fatalf("stackauth.OpenWithoutProfile: %v", err) + } + t.Cleanup(func() { _ = store.Close() }) + var strategyOpts []stackauth.StrategyOption + if cts := os.Getenv("STACK_ENCRYPT_TEST_CTS_HOST"); cts != "" { + strategyOpts = append(strategyOpts, stackauth.WithAuthBaseURL(cts)) + } + strategy, err := store.AccessKey(t.Context(), crn, accessKey, strategyOpts...) + if err != nil { + t.Fatalf("stackauth access-key strategy: %v", err) } + t.Cleanup(func() { + if err := strategy.Close(); err != nil { + t.Errorf("strategy.Close: %v", err) + } + }) material := []byte(clientKey) key := NewClientKey(material) // The credentials a successful NewClient resolved are released by the // client's Close, once: the wiring only a real load-keyset response can - // reach. + // reach. NewCredentials itself holds nothing to release — the strategy + // is the caller's — so the spy adds a Close to count. var released int - creds := credentialsFunc(func(ctx context.Context, opts ResolveOptions) (*ResolvedCredentials, error) { - r, err := NewCredentials(clientID, key, StaticToken(token)).Resolve(ctx, opts) + creds := credentialsFunc(func(ctx context.Context, opts resolveOptions) (*resolvedCredentials, error) { + r, err := NewCredentials(clientID, key, strategy).resolve(ctx, opts) if err == nil { r.Close = func() error { released++; return nil } } @@ -60,6 +86,10 @@ func liveClient(t *testing.T) *Client { if released != 1 { t.Errorf("Close released the credentials %d times, want once", released) } + // The client never closes the caller's strategy. + if _, err := strategy.Token(context.Background()); err != nil { + t.Errorf("the strategy after Client.Close: %v, want still usable", err) + } }) if released != 0 { t.Fatalf("a successful NewClient released the credentials %d times, want 0", released) @@ -295,9 +325,9 @@ func TestPlaintextDoesNotRemainInGuestMemoryAfterEncrypt(t *testing.T) { // client reads and no developer profile. func TestLiveAutoCredentialsFromTheEnvironment(t *testing.T) { clientID, clientKey := os.Getenv("STACK_ENCRYPT_TEST_CLIENT_ID"), os.Getenv("STACK_ENCRYPT_TEST_CLIENT_KEY") - accessKey, crn := os.Getenv("STACK_ENCRYPT_TEST_ACCESS_KEY"), os.Getenv("STACK_ENCRYPT_TEST_WORKSPACE_CRN") + accessKey, crn := os.Getenv("STACK_ENCRYPT_TEST_CLIENT_ACCESS_KEY"), os.Getenv("STACK_ENCRYPT_TEST_WORKSPACE_CRN") if clientID == "" || clientKey == "" || accessKey == "" || crn == "" { - t.Skip("STACK_ENCRYPT_TEST_{CLIENT_ID,CLIENT_KEY,ACCESS_KEY,WORKSPACE_CRN} not set") + t.Skip("STACK_ENCRYPT_TEST_{CLIENT_ID,CLIENT_KEY,CLIENT_ACCESS_KEY,WORKSPACE_CRN} not set") } guestOrSkip(t) authGuestOrSkip(t) diff --git a/languages/golang/stackencrypt/memory_test.go b/languages/golang/stackencrypt/memory_test.go index 416edcb84..9597e3409 100644 --- a/languages/golang/stackencrypt/memory_test.go +++ b/languages/golang/stackencrypt/memory_test.go @@ -86,7 +86,7 @@ func TestRequireLockedMemoryRefusesAnUnlockableGuest(t *testing.T) { } limited := !probe.IsFallback() _, err := NewClient(context.Background(), - WithCredentials(NewCredentials("6a70bd18-99ac-4650-b104-37eec3a15b09", NewClientKey([]byte("00")), StaticToken("t"))), + WithCredentials(newTestCredentials("6a70bd18-99ac-4650-b104-37eec3a15b09", NewClientKey([]byte("00")), staticToken("t"))), WithGuest(wasiProbe), WithRequireLockedMemory(), ) @@ -99,7 +99,7 @@ func TestRequireLockedMemoryRefusesAnUnlockableGuest(t *testing.T) { // Best effort under the same refusal: the client exists, says so, and // shows it wherever it is printed or logged. if wasm, gerr := embeddedGuest(); gerr == nil { - inst, err := newInstance(context.Background(), wasm, &transport{rt: http.DefaultTransport, token: StaticToken("t")}, guest.BestEffort) + inst, err := newInstance(context.Background(), wasm, &transport{rt: http.DefaultTransport, token: staticToken("t")}, guest.BestEffort) if err != nil { t.Fatal(err) } @@ -128,7 +128,7 @@ func strictClient(t *testing.T) *Client { if !guesttest.HostReserves(t) { t.Skip("heap fallback in use on this host: a strict client cannot exist") } - inst, err := newInstance(context.Background(), guestOrSkip(t), &transport{rt: http.DefaultTransport, token: StaticToken("t")}, guest.Strict) + inst, err := newInstance(context.Background(), guestOrSkip(t), &transport{rt: http.DefaultTransport, token: staticToken("t")}, guest.Strict) if errors.Is(err, ErrMemoryLock) { guesttest.SkipUnlessLockRequired(t, "the lock was refused", err) } @@ -234,7 +234,7 @@ func TestRequireLockedMemoryClosesTheClientOnARefusedInternalGrowth(t *testing.T // exit. func TestUnreachableClientIsReleased(t *testing.T) { wasm := guestOrSkip(t) - inst, err := newInstance(context.Background(), wasm, &transport{rt: http.DefaultTransport, token: StaticToken("t")}, guest.BestEffort) + inst, err := newInstance(context.Background(), wasm, &transport{rt: http.DefaultTransport, token: staticToken("t")}, guest.BestEffort) if err != nil { t.Fatal(err) } diff --git a/languages/golang/stackencrypt/options.go b/languages/golang/stackencrypt/options.go index 2ba298658..e733242f8 100644 --- a/languages/golang/stackencrypt/options.go +++ b/languages/golang/stackencrypt/options.go @@ -17,10 +17,10 @@ type clientOptions struct { requireLockedMemory bool } -// WithCredentials supplies the client id, the client key and the token -// source. The default, and what nil means, is [AutoCredentials]: the -// environment, then the developer profile. [NewCredentials] takes the three -// explicitly, and [OIDCFederation] mints tokens from an identity provider's. +// WithCredentials supplies the client id, the client key and the stackauth +// strategy the token comes from. The default, and what nil means, is +// [AutoCredentials]: the environment, then the developer profile. +// [NewCredentials] takes the three explicitly, and [OIDCFederation] mints tokens from an identity provider's. // // The client key is consumed: NewClient marshals it into the config buffer, // wipes the key, and wipes the buffer once the guest has the key, so after @@ -51,7 +51,7 @@ func WithKeysetCacheSize(n int) ClientOption { // stackauth's credential guest makes to CTS: an access-key exchange, a // device-session refresh, a federation exchange. A RoundTripper scoped to // the ZeroKMS host alone (a pinned client certificate, an egress allowlist) -// refuses those; the failure then surfaces as the token source's. Nil means +// refuses those; the failure then surfaces as the token strategy's. Nil means // http.DefaultTransport, the default. func WithTransport(rt http.RoundTripper) ClientOption { return func(o *clientOptions) { o.transport = rt } diff --git a/languages/golang/stackencrypt/options_test.go b/languages/golang/stackencrypt/options_test.go index bc25dc2ed..c2d4ac6db 100644 --- a/languages/golang/stackencrypt/options_test.go +++ b/languages/golang/stackencrypt/options_test.go @@ -44,7 +44,7 @@ func TestLaterZeroKMSURLWins(t *testing.T) { first := newStub(t, http.StatusUnauthorized, "", "first") second := newStub(t, http.StatusUnauthorized, "", "second") _, err := NewClient(context.Background(), - WithCredentials(testCredentials(StaticToken("stub-token"))), + WithCredentials(testCredentials(staticToken("stub-token"))), WithZeroKMSURL(first.URL), WithZeroKMSURL(second.URL), ) @@ -59,7 +59,7 @@ func TestLaterZeroKMSURLWins(t *testing.T) { func TestNegativeKeysetCacheSizeIsRefused(t *testing.T) { key := NewClientKey([]byte(testClientKey)) _, err := NewClient(context.Background(), - WithCredentials(NewCredentials(testClientID, key, StaticToken("t"))), + WithCredentials(newTestCredentials(testClientID, key, staticToken("t"))), WithKeysetCacheSize(-1), ) if err == nil { @@ -134,7 +134,7 @@ func TestOIDCFederationResolvesTheKeyLikeAuto(t *testing.T) { authGuestOrSkip(t) cleanEnv(t, newProfile(t, loggedIn("profile-token"))) provider := stackauth.OIDCProviderFunc(func(context.Context) (string, error) { return "idp-token", nil }) - resolved, err := OIDCFederation(testCRN, provider).Resolve(context.Background(), ResolveOptions{Transport: http.DefaultTransport}) + resolved, err := OIDCFederation(testCRN, provider).resolve(context.Background(), resolveOptions{Transport: http.DefaultTransport}) if err != nil { t.Fatal(err) } @@ -144,7 +144,7 @@ func TestOIDCFederationResolvesTheKeyLikeAuto(t *testing.T) { } // No CRN is a configuration error from the strategy, before any // provider or key is asked. - if _, err := OIDCFederation("", provider).Resolve(context.Background(), ResolveOptions{Transport: http.DefaultTransport}); !errors.Is(err, stackauth.ErrAuthConfig) { + if _, err := OIDCFederation("", provider).resolve(context.Background(), resolveOptions{Transport: http.DefaultTransport}); !errors.Is(err, stackauth.ErrAuthConfig) { t.Fatalf("OIDCFederation with no CRN: %v, want ErrAuthConfig", err) } } diff --git a/languages/golang/stackencrypt/transport.go b/languages/golang/stackencrypt/transport.go index 85db14a65..c250c03cf 100644 --- a/languages/golang/stackencrypt/transport.go +++ b/languages/golang/stackencrypt/transport.go @@ -19,33 +19,24 @@ import ( // functions are the whole host surface the guest can reach. const transportModule = "cipherstash_transport" -// TokenSource supplies the bearer token the guest presents to ZeroKMS. It +// tokenSource supplies the bearer token the guest presents to ZeroKMS. It // is asked on every request, so a source that rotates tokens needs no -// re-initialisation of the client. Minting and refresh stay host-side, out -// of the crypto guest: [AutoCredentials] runs them in stackauth's credential -// guest, and hands the client a TokenSource over the strategy it chose. -type TokenSource interface { +// re-initialisation of the client. Outside this package's tests it is +// always a *stackauth.Strategy: minting and refresh stay host-side, out of +// the crypto guest, in stackauth's credential guest. The interface is +// unexported so that no caller can hand the client a raw token, which could +// not be refreshed and would bypass the strategies' refresh lock. +type tokenSource interface { Token(ctx context.Context) (string, error) } -// TokenFunc adapts a function to a [TokenSource]. -type TokenFunc func(ctx context.Context) (string, error) - -// Token implements TokenSource. -func (f TokenFunc) Token(ctx context.Context) (string, error) { return f(ctx) } - -// StaticToken is a [TokenSource] that always returns the same token. -func StaticToken(token string) TokenSource { - return TokenFunc(func(context.Context) (string, error) { return token, nil }) -} - // transport implements the guest's two host imports over a RoundTripper -// and a TokenSource. One per Client; it is bound to the module at +// and a tokenSource. One per Client; it is bound to the module at // instantiation and reaches the guest's allocator through the module the // call arrives on. type transport struct { rt http.RoundTripper - token TokenSource + token tokenSource // sends counts transport_send excursions, so tests can pin the batching // contract (one ZeroKMS call per operation) instead of trusting it. sends atomic.Int64 @@ -226,7 +217,7 @@ func (t *transport) tokenGet(ctx context.Context, m api.Module, tokenPtrOut, tok return hostFailed } // The credential's transport copy is wiped once it is in guest memory; - // the TokenSource's own string is the source's. + // the source's own string is the source's. tok := []byte(token) defer wipe(tok) if !place(ctx, m, tokenPtrOut, tokenLenOut, tok) { From 66654afb6b74d1fecc151cdaf0a86adc3067d330 Mon Sep 17 00:00:00 2001 From: Dan Draper <dan@cipherstash.com> Date: Mon, 28 Sep 2026 22:40:26 -0700 Subject: [PATCH 648/686] fix(go): address the cipherstash/cipherstash-suite#2267 review - A NewCredentials with a nil strategy is refused in NewClient's host-side pre-check, beside the negative cache-size check, with ErrEncoding; the key is wiped and the credentials marked consumed via consumeUnresolved. The refusal no longer needs a guest, and the duplicate path in explicitCredentials.resolve is gone. - The unreachable resolved.Token == nil guard in NewClient is deleted: the pre-check is the one place a missing token source is decided. - stackauth: Strategy.MemoryLockError() error reports its store's memory lock, asked live. NewCredentials hands it over as the resolved credentials' MemoryLockError, as AutoCredentials hands over its profile's. - Under WithRequireLockedMemory, NewClient refuses credentials whose memory is unlocked with ErrMemoryLock, before the crypto guest is instantiated, for every kind of credentials. AutoCredentials and OIDCFederation already open their guest strict; NewCredentials' store is the caller's. Tested with the report forced over a best-effort store, and for real under RLIMIT_MEMLOCK=0 in a child process. internal/guest gains UnlockGrowth, a best-effort counterpart of RefuseGrowth, for the live Strategy report test. The option's doc, doc.go and the README's production checklist say a NewCredentials store should be opened with stackauth.RequireLockedMemory to stay locked after NewClient. - OIDCFederation takes ...stackauth.StrategyOption, forwarded to ProfileStore.OIDC, so WithAuthBaseURL pins CTS per client. - TestNewClientConsumesCredentialsAFailedResolveHandsBack and TestNewClientDefaultsToAutoCredentials skip without a guest; go test ./stackencrypt/ with no guest built now passes. - TestNegativeKeysetCacheSizeIsRefused (a third copy) is replaced by TestLaterKeysetCacheSizeWins. - The plan's usage snippet shows the current API, and its harness paragraph and live_test.go's header describe the CI harness as intended (CIP-4024), not existing; the live tests run locally when their STACK_ENCRYPT_TEST_* variables are set. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- docs/plans/stack-encrypt-go-bindings.md | 53 +++++--- languages/golang/internal/guest/testing.go | 17 +++ languages/golang/stackauth/strategy.go | 9 ++ languages/golang/stackauth/strategy_test.go | 37 ++++++ languages/golang/stackencrypt/README.md | 17 ++- languages/golang/stackencrypt/client.go | 28 ++-- languages/golang/stackencrypt/credentials.go | 50 ++++--- .../golang/stackencrypt/credentials_test.go | 125 +++++++++++++++++- languages/golang/stackencrypt/doc.go | 10 +- languages/golang/stackencrypt/live_test.go | 9 +- languages/golang/stackencrypt/memory_test.go | 50 +++++++ languages/golang/stackencrypt/options.go | 13 +- languages/golang/stackencrypt/options_test.go | 52 +++++++- 13 files changed, 400 insertions(+), 70 deletions(-) diff --git a/docs/plans/stack-encrypt-go-bindings.md b/docs/plans/stack-encrypt-go-bindings.md index ddec1ba3c..fa00c797e 100644 --- a/docs/plans/stack-encrypt-go-bindings.md +++ b/docs/plans/stack-encrypt-go-bindings.md @@ -438,24 +438,29 @@ is the keyset-bound view (the Rust `StackCipher::keyset`, returning its `KeysetCipher`) and `Client.DefaultKeyset()` its `default_keyset`, `Client.Decrypt*` opens any keyset, and `Term` takes a `Context` and returns an error. -Original sketch: `bindings/go/stackencrypt` (module path TBD — see -decisions). Imports `vcvalue` for the model. Surface mirrors `vcencrypt` so -the two feel like one SDK: +`bindings/go/stackencrypt` imports `vcvalue` for the model, and its surface +mirrors `vcencrypt` so the two feel like one SDK. As shipped, with explicit +credentials (`NewClient(ctx)` alone resolves them as the Rust client does, +from the environment and then the developer profile): ```go -client, _ := stackencrypt.NewClient(ctx, stackencrypt.Config{ - Transport: http.DefaultClient, // or any RoundTripper - Token: stackencrypt.StaticToken(tok), // phase-1 auth -}) -cipher, _ := client.NewCipher(ctx, stackencrypt.CipherConfig{ - ClientID: id, ClientKey: key, Keyset: "users", -}) - -ct, _ := cipher.Encrypt(ctx, user, aad) // map[string]any of stackencrypt.Sealed / vcvalue.Plain -pt, _ := cipher.Decrypt(ctx, ct, aad) - -rows, _ := cipher.EncryptRecords(ctx, users, aad) // one ZeroKMS call for the slice -probe, _ := cipher.Term(ctx, uint32(34), "users/age", stackencrypt.Equality) +store, _ := stackauth.OpenWithoutProfile(ctx) // or stackauth.Resolve(ctx) for the profile +defer store.Close() // after the client and the strategy +strategy, _ := store.AccessKey(ctx, crn, accessKey) // or DeviceSession, OIDC, Auto +defer strategy.Close() // after the client + +client, _ := stackencrypt.NewClient(ctx, + stackencrypt.WithCredentials(stackencrypt.NewCredentials(id, stackencrypt.NewClientKey(key), strategy)), + stackencrypt.WithTransport(rt), // optional: any RoundTripper +) +defer client.Close() +cipher := client.Keyset(stackencrypt.KeysetName("users")) // or client.DefaultKeyset() + +ct, _ := cipher.Encrypt(ctx, user, aad) // map[string]any of stackencrypt.Sealed / vcvalue.Plain +pt, _ := client.Decrypt(ctx, ct, aad) // any keyset + +rows, _ := cipher.EncryptRecords(ctx, users) // one ZeroKMS call for the slice +probe, _ := cipher.Term(ctx, uint32(34), stackencrypt.MustContext("users/age"), stackencrypt.Equality) ``` - `stackencrypt.Sealed` — the Phase 2 leaf; `driver.Valuer` + `sql.Scanner` @@ -483,17 +488,21 @@ probe, _ := cipher.Term(ctx, uint32(34), "users/age", stackencrypt.Equality) ### Phase 5 — validation and CI -- `mise run test:integration:wasi-go` (renamed #2099 harness): boots - `zerokms-server` against the mock auth server, mints a token, seeds a - client + keyset, then `CGO_ENABLED=0 go test ./...` in - `bindings/go/stackencrypt`. It exports `STACK_ENCRYPT_TEST_CLIENT_ID`, - `STACK_ENCRYPT_TEST_CLIENT_KEY`, `STACK_ENCRYPT_TEST_CLIENT_ACCESS_KEY` - and `STACK_ENCRYPT_TEST_WORKSPACE_CRN`, and optionally +- A CI harness for the live tests, tracked in CIP-4024 and not yet built + (the intended name is `mise run test:integration:wasi-go`, the renamed + #2099 harness). It is to boot `zerokms-server` against the mock auth + server, seed a client + keyset and an access key, then run + `CGO_ENABLED=0 go test ./...` in `bindings/go/stackencrypt` with + `STACK_ENCRYPT_TEST_CLIENT_ID`, `STACK_ENCRYPT_TEST_CLIENT_KEY`, + `STACK_ENCRYPT_TEST_CLIENT_ACCESS_KEY` and + `STACK_ENCRYPT_TEST_WORKSPACE_CRN` exported, and optionally `STACK_ENCRYPT_TEST_CTS_HOST` and `STACK_ENCRYPT_TEST_ZEROKMS_URL`. The same four required variables drive both paths: `NewCredentials` with a `stackauth` access-key strategy, and `AutoCredentials` (no options to `NewClient`). There is no raw-token variable, since the client takes tokens only from `stackauth` strategies. `live_test.go` documents each. + Until the harness exists, the live tests are skipped unless those + variables are set, and run locally when they are. - Go tests: import-surface gate (exactly WASI + `cipherstash_transport`), stub-transport tests for the bridge, live encrypt/decrypt, live `EncryptRecords` asserting **one** `transport_send` for N rows diff --git a/languages/golang/internal/guest/testing.go b/languages/golang/internal/guest/testing.go index 97cd80b2a..ad9118554 100644 --- a/languages/golang/internal/guest/testing.go +++ b/languages/golang/internal/guest/testing.go @@ -15,11 +15,18 @@ type Refusing struct { reason error refuse bool refused int + // keep commits a refused growth all the same, unlocked, as BestEffort + // does: the reason becomes the allocator's lock error. + keep bool } func (b *Refusing) commit(size uint64) ([]byte, error) { if b.refuse && size > b.past { b.refused++ + if b.keep { + buf, _ := b.backend.commit(size) + return buf, b.reason + } return nil, b.reason } return b.backend.commit(size) @@ -49,6 +56,16 @@ func RefuseGrowth(alloc *Allocator, reason error) *Refusing { return refusing } +// UnlockGrowth is RefuseGrowth as a BestEffort allocator meets a refused +// lock: the growth goes through, and its range is reported unlocked with +// reason, from then on, as LockError. It forces the report a lock limit +// would give, on any host. +func UnlockGrowth(alloc *Allocator, reason error) *Refusing { + refusing := RefuseGrowth(alloc, reason) + refusing.keep = true + return refusing +} + // sized is what the seam needs of a backing to know where it stands. Each // backing implements it beside its own definition, under that file's // build constraint, so this file builds on every platform. diff --git a/languages/golang/stackauth/strategy.go b/languages/golang/stackauth/strategy.go index f72689b2b..4656dc25d 100644 --- a/languages/golang/stackauth/strategy.go +++ b/languages/golang/stackauth/strategy.go @@ -202,6 +202,15 @@ func (s *Strategy) token(ctx context.Context) (string, error) { return token, err } +// MemoryLockError is why the memory the strategy lives in is not locked in +// RAM: its ProfileStore's guest, where its token, its access key or refresh +// token, and any client key read through the same store are held. It is +// [ProfileStore.MemoryLockError] of that store, asked now rather than when +// the strategy was made: under best-effort locking the guest can commit +// unlocked memory later, on a growth for a token exchange or a refresh. +// Nil while the memory is locked. It can be asked after Close. +func (s *Strategy) MemoryLockError() error { return s.store.MemoryLockError() } + // Close drops the strategy's cached token and unregisters its provider. func (s *Strategy) Close() error { s.mu.Lock() diff --git a/languages/golang/stackauth/strategy_test.go b/languages/golang/stackauth/strategy_test.go index 6baf22280..407db16fb 100644 --- a/languages/golang/stackauth/strategy_test.go +++ b/languages/golang/stackauth/strategy_test.go @@ -17,6 +17,9 @@ import ( "sync/atomic" "testing" "time" + + "github.com/cipherstash/cipherstash-suite/bindings/go/internal/guest" + "github.com/tetratelabs/wazero/api" ) const testCRN = "crn:ap-southeast-2.aws:ZVATKW3VHMFG27DY" @@ -620,3 +623,37 @@ func TestOpenWithoutProfileRunsAccessKeyAndRefusesProfileReads(t *testing.T) { t.Fatalf("Auto with no key and no profile: %v, want ErrNotAuthenticated", err) } } + +// A strategy reports its store's memory lock, asked live: a store opened +// best effort whose guest later grows into memory it cannot lock is +// reported unlocked by its strategies from then on. +func TestStrategyReportsItsStoresMemoryLockLive(t *testing.T) { + ctx := context.Background() + _, s := profile(t) + strategy, err := s.AccessKey(ctx, testCRN, "CSAKtestKeyId.testKeySecret", WithAuthBaseURL("https://cts.invalid")) + if err != nil { + t.Fatal(err) + } + defer strategy.Close() + if fmt.Sprint(strategy.MemoryLockError()) != fmt.Sprint(s.MemoryLockError()) { + t.Fatalf("Strategy.MemoryLockError = %v, want the store's %v", strategy.MemoryLockError(), s.MemoryLockError()) + } + if s.MemoryLockError() != nil { + t.Skipf("the store is unlocked already here (%v); a later refusal cannot be told apart", s.MemoryLockError()) + } + unlocked := guest.UnlockGrowth(s.root.inst.mem, errors.New("refused for the test")) + // Staging a 2 MiB argument into guest memory needs a growth. + if _, err := s.call(ctx, func(i *instance) api.Function { return i.setCurrentWorkspace }, strings.Repeat("A", 2<<20)); errors.Is(err, ErrMemoryLock) { + t.Fatalf("a best-effort growth was refused: %v", err) + } + if unlocked.Refused() == 0 { + t.Fatal("the guest did not grow; the test proves nothing") + } + if err := strategy.MemoryLockError(); !errors.Is(err, ErrMemoryLock) || !strings.Contains(err.Error(), unlocked.Reason().Error()) { + t.Fatalf("Strategy.MemoryLockError after an unlocked growth = %v, want ErrMemoryLock naming it", err) + } + _ = strategy.Close() + if err := strategy.MemoryLockError(); !errors.Is(err, ErrMemoryLock) { + t.Fatalf("Strategy.MemoryLockError after Close = %v, want the store's report still", err) + } +} diff --git a/languages/golang/stackencrypt/README.md b/languages/golang/stackencrypt/README.md index 2bf84a0ba..e163f9a7a 100644 --- a/languages/golang/stackencrypt/README.md +++ b/languages/golang/stackencrypt/README.md @@ -147,7 +147,10 @@ with the workspace CRN and a provider of the IdP's tokens. CTS exchanges the IdP token for a CipherStash one, and the provider is asked again only when that token needs replacing. `stackauth.OAuth2TokenSource` adapts a `golang.org/x/oauth2` source. The client key is found as `AutoCredentials` -finds it. +finds it. `stackauth` strategy options follow the provider: +`OIDCFederation(crn, provider, stackauth.WithAuthBaseURL(cts))` pins the CTS +endpoint for these credentials, where `CS_CTS_HOST` would pin it for the +whole process. `AutoCredentials`, `NewCredentials` and `OIDCFederation` are the only kinds of `Credentials`: the interface is sealed. @@ -193,8 +196,18 @@ with `ErrMemoryLock`, so grant a limit with room to grow. A `Client` prints its memory state with `%v` and logs it as a `slog` group, so a startup log shows it. +The report and the policy cover the credential guest too, which holds the +token strategy and which the client key may have passed through. +`AutoCredentials` and `OIDCFederation` open it under the client's policy. +With `NewCredentials` it is the `stackauth` store you opened: under +`WithRequireLockedMemory()`, `NewClient` fails with `ErrMemoryLock` if that +store's memory is unlocked, but the store's own policy decides its later +growth. Open it with `stackauth.RequireLockedMemory()` as well to keep it +locked for the life of the client. + Production checklist: assert `MemoryLocked()` at startup, or pass -`WithRequireLockedMemory()`. Handling `SIGTERM` for a graceful shutdown is +`WithRequireLockedMemory()`, and with `NewCredentials` open the store with +`stackauth.RequireLockedMemory()`. Handling `SIGTERM` for a graceful shutdown is ordinary Go practice and worth doing for your own reasons; the SDK does not depend on it and installs no signal handler of its own. diff --git a/languages/golang/stackencrypt/client.go b/languages/golang/stackencrypt/client.go index 17d6826da..80d6f35c6 100644 --- a/languages/golang/stackencrypt/client.go +++ b/languages/golang/stackencrypt/client.go @@ -78,16 +78,21 @@ func NewClient(ctx context.Context, opts ...ClientOption) (_ *Client, err error) if creds == nil { creds = AutoCredentials() } - // The host-side checks come first: they read nothing from the - // credentials, and under AutoCredentials resolving means instantiating - // the credential guest and reading the profile, which a config refused - // here should not pay for. A refused config still consumes an explicit - // key, as WithCredentials promises; any other Credentials has not - // been asked yet, so holds nothing of this client's. + // The host-side checks come first: they resolve nothing, and under + // AutoCredentials resolving means instantiating the credential guest + // and reading the profile, which a config refused here should not pay + // for. A refused config still consumes an explicit key, as + // WithCredentials promises; any other Credentials has not been asked + // yet, so holds nothing of this client's. zerokmsURL, err := zerokmsEndpoint(cfg.zerokmsURL) if err == nil && cfg.keysetCacheSize < 0 { err = errors.New("stackencrypt: WithKeysetCacheSize must not be negative") } + if explicit, ok := creds.(*explicitCredentials); ok && err == nil && explicit.token == nil { + // Knowable from the credentials as they were built: the one place + // a missing token source is decided. + err = fmt.Errorf("%w: NewCredentials needs a stackauth strategy for the token", ErrEncoding) + } wasm := cfg.guest if err == nil && wasm == nil { wasm, err = embeddedGuest() @@ -123,8 +128,15 @@ func NewClient(ctx context.Context, opts ...ClientOption) (_ *Client, err error) _ = resolved.Close() } }() - if resolved.Token == nil { - return nil, errors.New("stackencrypt: the credentials have no token source") + if cfg.requireLockedMemory && resolved.MemoryLockError != nil { + // The credentials' own guest held the key, and holds the token + // strategy: under the strict policy its memory must be locked too. + // AutoCredentials and OIDCFederation open it strict and cannot get + // here unlocked; NewCredentials' store is the caller's, opened + // however the caller chose. + if lockErr := resolved.MemoryLockError(); lockErr != nil { + return nil, fmt.Errorf("stackencrypt: the credentials' memory: %w", lockErr) + } } encoded, err := encodeConfig(initConfig{ clientID: resolved.ClientID, diff --git a/languages/golang/stackencrypt/credentials.go b/languages/golang/stackencrypt/credentials.go index 3723f94ab..be95a2882 100644 --- a/languages/golang/stackencrypt/credentials.go +++ b/languages/golang/stackencrypt/credentials.go @@ -66,16 +66,19 @@ type resolvedCredentials struct { // client is not made. A Token that outlives it must not be asked again. Close func() error // MemoryLockError, when not nil, reports why memory the credentials - // hold key material in is not locked in RAM: for AutoCredentials, the - // credential guest's, which the client key passed through and the - // token strategy lives in, as stackauth's ProfileStore.MemoryLockError - // reports it. It is asked each time, not once: under best-effort - // locking a guest's memory can become unlocked later, when a growth - // for a token exchange or a refresh cannot be locked. The client folds - // its answer into Client.MemoryLocked and Client.MemoryLockError, so a - // checklist asserting the lock sees every guest the key was in, not - // only the crypto guest. Nil, or returning nil, when the memory is - // locked or the credentials hold nothing. + // hold key material in is not locked in RAM: the credential guest's, + // which the token strategy lives in and (for AutoCredentials and + // OIDCFederation) the client key passed through. AutoCredentials and + // OIDCFederation hand over their profile's ProfileStore.MemoryLockError; + // NewCredentials, the caller's strategy's Strategy.MemoryLockError, + // which is its store's. It is asked each time, not once: under + // best-effort locking a guest's memory can become unlocked later, when + // a growth for a token exchange or a refresh cannot be locked. NewClient + // refuses the credentials with it under WithRequireLockedMemory, and the + // client folds its answer into Client.MemoryLocked and + // Client.MemoryLockError, so a checklist asserting the lock sees every + // guest the key was in, not only the crypto guest. Nil, or returning + // nil, when the memory is locked or the credentials hold nothing. MemoryLockError func() error } @@ -101,7 +104,7 @@ var ErrNoCredentials = errors.New("stackencrypt: no credentials") func NewCredentials(clientID string, key *ClientKey, strategy *stackauth.Strategy) Credentials { c := &explicitCredentials{clientID: clientID, key: key} // A nil *Strategy stored in the interface would be a non-nil source - // that fails on first use; left unset, resolve refuses it up front. + // that fails on first use; left unset, NewClient refuses it up front. if strategy != nil { c.token = strategy } @@ -130,11 +133,14 @@ func (c *explicitCredentials) resolve(context.Context, resolveOptions) (*resolve if !c.consumed.CompareAndSwap(false, true) { return nil, ErrCredentialsConsumed } + // A nil token never gets here: NewClient refuses it host-side, before + // the guest is read. resolved := &resolvedCredentials{ClientID: c.clientID, ClientKey: c.key, Token: c.token} - if c.token == nil { - // Returned with the key, so NewClient consumes it as it does on - // every other refusal. - return resolved, fmt.Errorf("%w: NewCredentials needs a stackauth strategy for the token", ErrEncoding) + if strategy, ok := c.token.(*stackauth.Strategy); ok { + // The strategy's store is the guest the token lives in and, when + // the key was read through it, the key passed through: reported + // live, as AutoCredentials reports its profile's. + resolved.MemoryLockError = strategy.MemoryLockError } // No Close: the strategy and its store are the caller's. return resolved, nil @@ -222,14 +228,20 @@ func (autoCredentials) resolve(ctx context.Context, opts resolveOptions) (*resol // CipherStash token has to be minted; stackauth.OAuth2TokenSource adapts a // golang.org/x/oauth2 source. The client key is resolved as // [AutoCredentials] resolves it: CS_CLIENT_ID and CS_CLIENT_KEY, else the -// developer profile. CS_CTS_HOST overrides the authentication endpoint. -func OIDCFederation(crn string, provider stackauth.OIDCProvider) Credentials { - return oidcCredentials{crn: crn, provider: provider} +// developer profile. +// +// opts configure the federation strategy as they would +// stackauth.ProfileStore.OIDC: stackauth.WithAuthBaseURL pins the CTS +// endpoint for these credentials alone. Without it, CS_CTS_HOST overrides +// the endpoint, else it is discovered. +func OIDCFederation(crn string, provider stackauth.OIDCProvider, opts ...stackauth.StrategyOption) Credentials { + return oidcCredentials{crn: crn, provider: provider, opts: opts} } type oidcCredentials struct { crn string provider stackauth.OIDCProvider + opts []stackauth.StrategyOption } // String names the credentials' kind and workspace; the provider is not @@ -240,7 +252,7 @@ func (c oidcCredentials) String() string { func (c oidcCredentials) resolve(ctx context.Context, opts resolveOptions) (*resolvedCredentials, error) { return resolveWithStrategy(ctx, opts, func(ctx context.Context, profile *stackauth.ProfileStore, _ error) (*stackauth.Strategy, error) { - strategy, err := profile.OIDC(ctx, c.crn, c.provider) + strategy, err := profile.OIDC(ctx, c.crn, c.provider, c.opts...) if err != nil { return nil, fmt.Errorf("stackencrypt: credentials: OIDC federation: %w", err) } diff --git a/languages/golang/stackencrypt/credentials_test.go b/languages/golang/stackencrypt/credentials_test.go index 2d4ebc135..46c7c411a 100644 --- a/languages/golang/stackencrypt/credentials_test.go +++ b/languages/golang/stackencrypt/credentials_test.go @@ -458,6 +458,8 @@ func TestNewClientReleasesTheCredentialsOnceWhenInitFails(t *testing.T) { // A Resolve that fails while handing back what it built is consumed as a // successful one is: the key is wiped and Close runs, once. func TestNewClientConsumesCredentialsAFailedResolveHandsBack(t *testing.T) { + // The resolve runs after the guest is read. + guestOrSkip(t) key := NewClientKey([]byte(testClientKey)) var released int resolveErr := errors.New("the token strategy failed") @@ -604,8 +606,11 @@ func TestNewCredentialsRefusesASecondClient(t *testing.T) { } // NewClient with no options is AutoCredentials: with nothing configured, -// the error is the resolution's, before any guest or request. +// the error is the resolution's, before the crypto guest is instantiated +// or a request made. func TestNewClientDefaultsToAutoCredentials(t *testing.T) { + // The resolve runs after the guest is read. + guestOrSkip(t) authGuestOrSkip(t) cleanEnv(t, filepath.Join(t.TempDir(), "absent")) if _, err := NewClient(context.Background()); !errors.Is(err, ErrNoCredentials) { @@ -695,16 +700,130 @@ func (f credentialsFunc) resolve(ctx context.Context, opts resolveOptions) (*res func ptr(s string) *string { return &s } // NewCredentials takes its token only from a stackauth strategy: a nil one -// is refused, and the key is consumed all the same. +// is refused host-side, before any guest is read, and the key is consumed +// all the same — the credentials are spent, as on any refused config. func TestNewCredentialsRefusesANilStrategy(t *testing.T) { key := NewClientKey([]byte(testClientKey)) - _, err := NewClient(context.Background(), WithCredentials(NewCredentials(testClientID, key, nil))) + creds := NewCredentials(testClientID, key, nil) + _, err := NewClient(context.Background(), WithCredentials(creds)) if !errors.Is(err, ErrEncoding) || !strings.Contains(err.Error(), "strategy") { t.Fatalf("NewClient: %v, want ErrEncoding naming the strategy", err) } if !key.IsZero() { t.Error("the key still holds material after NewClient refused a nil strategy") } + if !creds.(*explicitCredentials).consumed.Load() { + t.Error("the credentials were not marked consumed") + } +} + +// NewCredentials reports the memory lock of the store the caller opened its +// strategy from, as AutoCredentials reports its profile's: the store's own +// answer, asked live. +func TestNewCredentialsReportsTheStrategysMemoryLock(t *testing.T) { + authGuestOrSkip(t) + ctx := context.Background() + // Best effort, stackauth's default: the store opens whatever the lock. + store, err := stackauth.OpenWithoutProfile(ctx) + if err != nil { + t.Fatal(err) + } + defer store.Close() + strategy, err := store.AccessKey(ctx, testCRN, testAccessKey, stackauth.WithAuthBaseURL("https://cts.example.com")) + if err != nil { + t.Fatal(err) + } + defer strategy.Close() + resolved, err := NewCredentials(testClientID, NewClientKey([]byte(testClientKey)), strategy).resolve(ctx, resolveOptions{Transport: http.DefaultTransport}) + if err != nil { + t.Fatal(err) + } + resolved.ClientKey.Wipe() + if resolved.MemoryLockError == nil { + t.Fatal("MemoryLockError is not set: the strategy's store's lock state is not reported") + } + if got, want := resolved.MemoryLockError(), store.MemoryLockError(); fmt.Sprint(got) != fmt.Sprint(want) { + t.Fatalf("MemoryLockError() = %v, want the store's %v", got, want) + } +} + +// WithRequireLockedMemory covers the credential guest whatever the +// credentials: memory the credentials report unlocked is refused with +// ErrMemoryLock before the crypto guest is instantiated or a request made, +// the key is consumed and what the credentials hold is released. Under +// best effort the same report lets the client be made. The refusal is +// forced through the credentials' report, over a store opened best effort: +// the real refusal, under RLIMIT_MEMLOCK, is in +// TestRequireLockedMemoryRefusesACallerStoreUnlocked. +func TestRequireLockedMemoryRefusesUnlockedCredentials(t *testing.T) { + authGuestOrSkip(t) + forced := guest.MemoryLockError(errors.New("RLIMIT_MEMLOCK refused the credential guest")) + for name, base := range map[string]func(t *testing.T) Credentials{ + "NewCredentials": func(t *testing.T) Credentials { + cleanEnv(t, t.TempDir()) + auth := newAuthServer(t) + ctx := context.Background() + // Best effort, stackauth's default. + store, err := stackauth.OpenWithoutProfile(ctx) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = store.Close() }) + strategy, err := store.AccessKey(ctx, testCRN, testAccessKey, stackauth.WithAuthBaseURL(auth.URL)) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = strategy.Close() }) + return NewCredentials(testClientID, NewClientKey([]byte(testClientKey)), strategy) + }, + "AutoCredentials": func(t *testing.T) Credentials { + cleanEnv(t, newProfile(t, loggedIn("profile-token"))) + return AutoCredentials() + }, + } { + t.Run(name, func(t *testing.T) { + stub := newStub(t, http.StatusUnauthorized, "", "nope") + forcedCreds := func(t *testing.T) (Credentials, **resolvedCredentials) { + creds := base(t) + var got *resolvedCredentials + return credentialsFunc(func(ctx context.Context, opts resolveOptions) (*resolvedCredentials, error) { + r, err := creds.resolve(ctx, opts) + if err != nil { + return r, err + } + if r.MemoryLockError == nil { + t.Error("the credentials report no memory lock state") + } + r.MemoryLockError = func() error { return forced } + got = r + return r, nil + }), &got + } + creds, resolved := forcedCreds(t) + // No crypto guest is needed: the refusal precedes it. + _, err := NewClient(context.Background(), WithCredentials(creds), WithZeroKMSURL(stub.URL), WithGuest(wasiProbe), WithRequireLockedMemory()) + if !errors.Is(err, ErrMemoryLock) || !strings.Contains(err.Error(), "credential guest") { + t.Fatalf("NewClient under WithRequireLockedMemory: %v, want ErrMemoryLock naming the credential guest", err) + } + if len(stub.requests) != 0 { + t.Errorf("a request was made for refused credentials: %+v", stub.requests) + } + if !(*resolved).ClientKey.IsZero() { + t.Error("the key still holds material after the credentials were refused") + } + if (*resolved).Close != nil { + if _, err := (*resolved).Token.Token(context.Background()); !errors.Is(err, stackauth.ErrState) { + t.Errorf("the token source after the refusal: %v, want it released (ErrState)", err) + } + } + + guestOrSkip(t) + creds, _ = forcedCreds(t) + if _, err := NewClient(context.Background(), WithCredentials(creds), WithZeroKMSURL(stub.URL)); !errors.Is(err, ErrUnauthorized) { + t.Fatalf("NewClient under best effort: %v, want ErrUnauthorized from the stub, the report not refused", err) + } + }) + } } // The strategy given to NewCredentials is the caller's: the credentials diff --git a/languages/golang/stackencrypt/doc.go b/languages/golang/stackencrypt/doc.go index 180d111c4..bbc8af246 100644 --- a/languages/golang/stackencrypt/doc.go +++ b/languages/golang/stackencrypt/doc.go @@ -125,9 +125,13 @@ // the guest's memory that cannot be locked, so the limit granted must // leave the guest room to grow: a refused growth fails the call with // [ErrMemoryLock], and closes the client when the growth was the guest's -// own allocation rather than a host-staged buffer. A Client prints its -// memory state ([Client.String]) and logs it ([Client.LogValue]). An -// embedder running +// own allocation rather than a host-staged buffer. The report and the +// policy cover the credential guest as well; with [NewCredentials] that is +// the caller's stackauth store, which NewClient refuses under the policy +// when it is unlocked, and which should be opened with +// stackauth.RequireLockedMemory to stay locked (see +// [WithRequireLockedMemory]). A Client prints its memory state +// ([Client.String]) and logs it ([Client.LogValue]). An embedder running // the guest under its own wazero configuration gets none of this unless // it supplies an allocator of its own. // diff --git a/languages/golang/stackencrypt/live_test.go b/languages/golang/stackencrypt/live_test.go index ea636143a..50d596e7a 100644 --- a/languages/golang/stackencrypt/live_test.go +++ b/languages/golang/stackencrypt/live_test.go @@ -13,10 +13,11 @@ import ( "github.com/cipherstash/vitaminc/bindings/go/vcvalue" ) -// Round trips through real ZeroKMS key material. Run by the phase 5 -// harness (`mise run test:integration:wasi-go`), which boots zerokms-server -// and exports the variables below; each test is skipped unless its own are -// set: +// Round trips through real ZeroKMS key material. No CI harness runs these +// yet: one that boots zerokms-server and exports the variables below is +// tracked in CIP-4024. Until then they run locally when the variables are +// set (from a gitignored mise.local.toml, say), and each test is skipped +// unless its own are: // // - STACK_ENCRYPT_TEST_CLIENT_ID, STACK_ENCRYPT_TEST_CLIENT_KEY: the // seeded client (every live test); diff --git a/languages/golang/stackencrypt/memory_test.go b/languages/golang/stackencrypt/memory_test.go index 9597e3409..43bef9739 100644 --- a/languages/golang/stackencrypt/memory_test.go +++ b/languages/golang/stackencrypt/memory_test.go @@ -13,6 +13,7 @@ import ( "github.com/cipherstash/cipherstash-suite/bindings/go/internal/guest" "github.com/cipherstash/cipherstash-suite/bindings/go/internal/guesttest" + "github.com/cipherstash/cipherstash-suite/bindings/go/stackauth" ) // The allocator on its own is tested in internal/guest. These are the @@ -121,6 +122,55 @@ func TestRequireLockedMemoryRefusesAnUnlockableGuest(t *testing.T) { fmt.Println("case ok") } +// WithRequireLockedMemory covers a store the caller opened best effort +// behind NewCredentials: with RLIMIT_MEMLOCK at zero the store opens +// unlocked, and NewClient refuses the credentials with ErrMemoryLock, before +// the crypto guest is instantiated. +func TestRequireLockedMemoryRefusesACallerStoreUnlocked(t *testing.T) { + if !guesttest.InChild(t) { + return + } + if err := guesttest.SetMemlockLimit(0); err != nil { + t.Fatalf("lowering RLIMIT_MEMLOCK: %v", err) + } + ctx := context.Background() + store, err := stackauth.OpenWithoutProfile(ctx) + if errors.Is(err, stackauth.ErrGuestNotBuilt) { + fmt.Println("case skipped:", err) + return + } + if err != nil { + t.Fatal(err) + } + defer store.Close() + if store.MemoryLocked() { + fmt.Println("case skipped: mlock succeeds under RLIMIT_MEMLOCK=0") + return + } + strategy, err := store.AccessKey(ctx, "crn:ap-southeast-2.aws:ZVATKW3VHMFG27DY", "CSAKtestKeyId.testKeySecret", stackauth.WithAuthBaseURL("https://cts.invalid")) + if err != nil { + t.Fatal(err) + } + defer strategy.Close() + if err := strategy.MemoryLockError(); !errors.Is(err, ErrMemoryLock) { + t.Fatalf("Strategy.MemoryLockError = %v, want the store's ErrMemoryLock", err) + } + key := NewClientKey([]byte("00")) + _, err = NewClient(ctx, + WithCredentials(NewCredentials("6a70bd18-99ac-4650-b104-37eec3a15b09", key, strategy)), + WithGuest(wasiProbe), + WithZeroKMSURL("https://zerokms.invalid"), + WithRequireLockedMemory(), + ) + if !errors.Is(err, ErrMemoryLock) || !strings.Contains(err.Error(), "credentials' memory") { + t.Fatalf("strict NewClient over an unlocked caller store: %v, want ErrMemoryLock naming the credentials", err) + } + if !key.IsZero() { + t.Fatal("the key still holds material after the credentials were refused") + } + fmt.Println("case ok") +} + // strictClient is a Client over the real guest under the strict policy, // or a skip where this host refuses the lock. func strictClient(t *testing.T) *Client { diff --git a/languages/golang/stackencrypt/options.go b/languages/golang/stackencrypt/options.go index e733242f8..8ce543229 100644 --- a/languages/golang/stackencrypt/options.go +++ b/languages/golang/stackencrypt/options.go @@ -75,8 +75,17 @@ func WithGuest(wasm []byte) ClientOption { // closed with its keys wiped, the call still failing with ErrMemoryLock. // Set it where swap is a real exposure and the deployment grants a lock // limit with room for the guest to grow (RLIMIT_MEMLOCK on Linux; the error -// names the size held so far); see [Client.MemoryLocked]. [AutoCredentials] -// and [OIDCFederation] apply it to the credential guest too. +// names the size held so far); see [Client.MemoryLocked]. +// +// It covers the credential guest too, where the token strategy lives and +// the client key may have passed through. [AutoCredentials] and +// [OIDCFederation] open that guest under the same policy, so it is refused +// at NewClient and on every later growth alike. [NewCredentials]' guest is +// the stackauth store the caller opened: NewClient fails with +// ErrMemoryLock if that store's memory is unlocked when it is asked, but +// only the store's own policy governs its later growth, so open it with +// stackauth.RequireLockedMemory to hold it locked for the life of the +// client. Client.MemoryLocked reports the store's state live either way. func WithRequireLockedMemory() ClientOption { return func(o *clientOptions) { o.requireLockedMemory = true } } diff --git a/languages/golang/stackencrypt/options_test.go b/languages/golang/stackencrypt/options_test.go index c2d4ac6db..4e67b2639 100644 --- a/languages/golang/stackencrypt/options_test.go +++ b/languages/golang/stackencrypt/options_test.go @@ -4,6 +4,7 @@ import ( "context" "errors" "net/http" + "net/http/httptest" "net/url" "path/filepath" "sync" @@ -56,17 +57,23 @@ func TestLaterZeroKMSURLWins(t *testing.T) { } } -func TestNegativeKeysetCacheSizeIsRefused(t *testing.T) { - key := NewClientKey([]byte(testClientKey)) +// A later WithKeysetCacheSize replaces an earlier one before anything is +// checked: a negative size overridden by zero, the default, is accepted, +// and the client goes on to ZeroKMS. +func TestLaterKeysetCacheSizeWins(t *testing.T) { + guestOrSkip(t) + stub := newStub(t, http.StatusUnauthorized, "", "nope") _, err := NewClient(context.Background(), - WithCredentials(newTestCredentials(testClientID, key, staticToken("t"))), + WithCredentials(testCredentials(staticToken("stub-token"))), + WithZeroKMSURL(stub.URL), WithKeysetCacheSize(-1), + WithKeysetCacheSize(0), ) - if err == nil { - t.Fatal("NewClient accepted a negative keyset cache size") + if !errors.Is(err, ErrUnauthorized) { + t.Fatalf("NewClient: %v, want ErrUnauthorized from the stub", err) } - if !key.IsZero() { - t.Error("the key still holds material after NewClient refused the options") + if len(stub.requests) != 1 { + t.Fatalf("requests: %d, want one: the overridden size was refused", len(stub.requests)) } } @@ -148,3 +155,34 @@ func TestOIDCFederationResolvesTheKeyLikeAuto(t *testing.T) { t.Fatalf("OIDCFederation with no CRN: %v, want ErrAuthConfig", err) } } + +// OIDCFederation's strategy options reach the strategy: WithAuthBaseURL +// pins CTS for these credentials, over CS_CTS_HOST, which here names a +// decoy that fails the test if it is asked. +func TestOIDCFederationTakesStrategyOptions(t *testing.T) { + authGuestOrSkip(t) + cleanEnv(t, filepath.Join(t.TempDir(), "absent")) + auth := newAuthServer(t) + decoy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Errorf("CS_CTS_HOST was asked (%s) though WithAuthBaseURL pinned CTS", r.URL.Path) + http.NotFound(w, r) + })) + t.Cleanup(decoy.Close) + t.Setenv("CS_CTS_HOST", decoy.URL) + t.Setenv(envClientID, testClientID) + t.Setenv(envClientKey, testClientKey) + provider := stackauth.OIDCProviderFunc(func(context.Context) (string, error) { return "idp-token", nil }) + creds := OIDCFederation(testCRN, provider, stackauth.WithAuthBaseURL(auth.URL)) + resolved, err := creds.resolve(context.Background(), resolveOptions{Transport: http.DefaultTransport}) + if err != nil { + t.Fatal(err) + } + defer resolved.Close() + resolved.ClientKey.Wipe() + if got := token(t, resolved); got != auth.jwt { + t.Fatalf("token = %q, want the pinned CTS's", got) + } + if auth.calls.Load() != 1 { + t.Fatalf("exchanges at the pinned CTS: %d, want one", auth.calls.Load()) + } +} From 250cb282e4eccbbf0bb194f62630e69144d11631 Mon Sep 17 00:00:00 2001 From: Dan Draper <dan@cipherstash.com> Date: Mon, 28 Sep 2026 22:46:03 -0700 Subject: [PATCH 649/686] fix(go): name the credential guest when it cannot be locked at all On linux/386 the credential guest cannot reserve its address space, so under RequireLockedMemory stackauth refuses it while AutoCredentials is still resolving, before the client's own check runs. That refusal was wrapped as a bare "stackencrypt: credentials:" error, naming no guest, and TestRequireLockedMemoryRefusesUnlockedCredentials failed on the 386 run (and would have dereferenced the unset resolution next). The refusal now names the credential guest, as the client's own report does, and the test accepts a host that refuses the guest before anything is resolved: there is then no key or token to have released. --- languages/golang/stackencrypt/credentials.go | 7 +++++++ languages/golang/stackencrypt/credentials_test.go | 8 ++++++++ 2 files changed, 15 insertions(+) diff --git a/languages/golang/stackencrypt/credentials.go b/languages/golang/stackencrypt/credentials.go index be95a2882..8353b8051 100644 --- a/languages/golang/stackencrypt/credentials.go +++ b/languages/golang/stackencrypt/credentials.go @@ -286,6 +286,13 @@ func resolveWithStrategy( noProfile = err profile, err = stackauth.OpenWithoutProfile(ctx, authOpts...) } + if errors.Is(err, ErrMemoryLock) { + // Under RequireLockedMemory the guest is refused before anything is + // resolved — on a host that cannot lock or reserve its memory at all + // (a 32-bit address space, a zero RLIMIT_MEMLOCK). Name which guest, + // as the client's own report does. + return nil, fmt.Errorf("stackencrypt: credentials: the credential guest: %w", err) + } if err != nil { return nil, fmt.Errorf("stackencrypt: credentials: %w", err) } diff --git a/languages/golang/stackencrypt/credentials_test.go b/languages/golang/stackencrypt/credentials_test.go index 46c7c411a..d1dd8042a 100644 --- a/languages/golang/stackencrypt/credentials_test.go +++ b/languages/golang/stackencrypt/credentials_test.go @@ -808,6 +808,14 @@ func TestRequireLockedMemoryRefusesUnlockedCredentials(t *testing.T) { if len(stub.requests) != 0 { t.Errorf("a request was made for refused credentials: %+v", stub.requests) } + if *resolved == nil { + // A host that cannot lock the credential guest at all (a + // 32-bit address space cannot reserve it) refuses it while + // resolving, before any key or token exists: the refusal + // above is that one, and there is nothing to have released. + t.Logf("the host refused the credential guest itself: %v", err) + return + } if !(*resolved).ClientKey.IsZero() { t.Error("the key still holds material after the credentials were refused") } From e8ed7f4176423679ea874622a6af014493914be5 Mon Sep 17 00:00:00 2001 From: Dan Draper <dan@cipherstash.com> Date: Mon, 28 Sep 2026 22:58:05 -0700 Subject: [PATCH 650/686] docs(go): a runnable example of explicit credentials stackencrypt/example/explicit is the NewCredentials counterpart of the AutoCredentials example: the client key and access key come from a secrets source (mounted files, standing in for a secrets manager), the client id and workspace from flags, and nothing from CS_* variables or the profile. It shows the caller owning the credential guest and the strategy, -require-locked-memory covering both guests, and a second NewClient with the same credentials refused with ErrCredentialsConsumed. Run with mise run go:stackencrypt:example:explicit. --- .../golang/stackencrypt/example/README.md | 4 + .../stackencrypt/example/explicit/README.md | 54 ++++++ .../stackencrypt/example/explicit/main.go | 171 ++++++++++++++++++ 3 files changed, 229 insertions(+) create mode 100644 languages/golang/stackencrypt/example/explicit/README.md create mode 100644 languages/golang/stackencrypt/example/explicit/main.go diff --git a/languages/golang/stackencrypt/example/README.md b/languages/golang/stackencrypt/example/README.md index e3eda49d5..aa6486e67 100644 --- a/languages/golang/stackencrypt/example/README.md +++ b/languages/golang/stackencrypt/example/README.md @@ -66,3 +66,7 @@ same cross-process lock as the `stash` CLI. The IdP rotates refresh tokens and detects replay, so two processes sharing `~/.cipherstash` that both exchanged the same refresh token would get the whole chain revoked; the lock prevents that. + +To supply the credentials yourself instead, from a secrets manager and with +no `CS_*` variables or profile, see [`explicit/`](explicit/), which uses +`stackencrypt.NewCredentials`. diff --git a/languages/golang/stackencrypt/example/explicit/README.md b/languages/golang/stackencrypt/example/explicit/README.md new file mode 100644 index 000000000..8723baf4b --- /dev/null +++ b/languages/golang/stackencrypt/example/explicit/README.md @@ -0,0 +1,54 @@ +# Explicit credentials + +A runnable example of `stackencrypt.NewCredentials`: the application +supplies every credential itself, and nothing is read from `CS_*` variables +or the developer profile. Use this shape when the client key lives in a +secrets manager, when one process talks to more than one workspace, or when +the environment is not yours to set. `../` shows the default, +`AutoCredentials`. + +## Running it + +The secrets come from a directory holding two files, the way a Kubernetes or +Docker secret is mounted: + +| File | Contents | +|---|---| +| `client-key` | The client key: the `CS_CLIENT_KEY` hex form, or the base64 in `secretkey.json`. | +| `access-key` | An access key (`CSAK…`) for the workspace. | + +```bash +mise run go:stackencrypt:example:explicit -- \ + -secrets-dir /run/secrets \ + -client-id <client id> \ + -workspace-crn <workspace CRN> +``` + +Or build both guests and run it yourself from `bindings/go`: + +```bash +mise run wasm:guest:build wasm:auth-guest:build +cd bindings/go && go run ./stackencrypt/example/explicit -secrets-dir ... -client-id ... -workspace-crn ... +``` + +Optional flags: `-cts-host` pins the authentication endpoint (default: from +the workspace CRN), `-zerokms-url` pins ZeroKMS (default: from the token), +and `-require-locked-memory` refuses to run on memory that cannot be locked +in RAM. + +## What it shows + +- **Where secrets come from.** `fileSecrets` reads mounted files. Replace it + with your secrets manager's client; the rest does not change. The client + key goes straight into `NewClientKey`, which takes ownership of the bytes, + and `NewClient` wipes them. +- **Who owns what.** The application opens the credential guest + (`stackauth.OpenWithoutProfile`) and the access-key strategy, and closes + them after the client. The client asks the strategy for a token on every + request, and the strategy re-exchanges the access key as tokens expire. +- **Locked memory.** With `-require-locked-memory` the credential guest is + opened with `stackauth.RequireLockedMemory()` and the client with + `WithRequireLockedMemory()`, so both guests stay locked. The client's + printed memory state covers both. +- **A key is for one client.** Passing the same credentials to a second + `NewClient` is refused with `ErrCredentialsConsumed`, before any request. diff --git a/languages/golang/stackencrypt/example/explicit/main.go b/languages/golang/stackencrypt/example/explicit/main.go new file mode 100644 index 000000000..98f0ff5a8 --- /dev/null +++ b/languages/golang/stackencrypt/example/explicit/main.go @@ -0,0 +1,171 @@ +// Command explicit connects the stack-encrypt Go binding to real ZeroKMS +// with credentials the application supplies itself: the client key and the +// access key come from a secrets source, the client id and workspace from +// configuration, and nothing is read from CS_* variables or the developer +// profile. It is the NewCredentials counterpart of ../example, which uses +// AutoCredentials. +// +// mise run wasm:guest:build wasm:auth-guest:build # both embedded guests +// go run ./stackencrypt/example/explicit \ +// -secrets-dir /run/secrets \ +// -client-id 6a70bd18-99ac-4650-b104-37eec3a15b09 \ +// -workspace-crn crn:ap-southeast-2.aws:ZVATKW3VHMFG27DY +// +// The secrets directory holds two files, client-key and access-key, the way +// a Kubernetes or Docker secret is mounted. See README.md. +package main + +import ( + "bytes" + "context" + "errors" + "flag" + "fmt" + "os" + "path/filepath" + + "github.com/cipherstash/cipherstash-suite/bindings/go/stackauth" + "github.com/cipherstash/cipherstash-suite/bindings/go/stackencrypt" +) + +type user struct { + ID int64 `stash:"-"` + Email string `stash:"context=users/email,index=eq"` +} + +type config struct { + secretsDir string + clientID string + workspaceCRN string + ctsHost string + zerokmsURL string + requireLockedMemory bool +} + +func main() { + var cfg config + flag.StringVar(&cfg.secretsDir, "secrets-dir", "/run/secrets", "directory holding the client-key and access-key secrets") + flag.StringVar(&cfg.clientID, "client-id", "", "the ZeroKMS client id (not a secret)") + flag.StringVar(&cfg.workspaceCRN, "workspace-crn", "", "the workspace the access key belongs to") + flag.StringVar(&cfg.ctsHost, "cts-host", "", "pin the authentication endpoint (default: from the workspace CRN)") + flag.StringVar(&cfg.zerokmsURL, "zerokms-url", "", "pin the ZeroKMS endpoint (default: from the token)") + flag.BoolVar(&cfg.requireLockedMemory, "require-locked-memory", false, "refuse to run on memory that cannot be locked in RAM") + flag.Parse() + if cfg.clientID == "" || cfg.workspaceCRN == "" { + fmt.Fprintln(os.Stderr, "usage: explicit -client-id ID -workspace-crn CRN [-secrets-dir DIR]") + os.Exit(2) + } + if err := run(context.Background(), cfg, fileSecrets{dir: cfg.secretsDir}); err != nil { + fmt.Fprintf(os.Stderr, "\nerror: %v\n", err) + os.Exit(1) + } +} + +// secrets is where the application keeps what must not be in its +// configuration. This example reads mounted files; an application would +// call its secrets manager (Vault, AWS Secrets Manager, GCP Secret Manager) +// here instead. The bytes returned are the caller's to wipe or hand on. +type secrets interface { + Get(ctx context.Context, name string) ([]byte, error) +} + +type fileSecrets struct{ dir string } + +func (s fileSecrets) Get(_ context.Context, name string) ([]byte, error) { + b, err := os.ReadFile(filepath.Join(s.dir, name)) + if err != nil { + return nil, fmt.Errorf("reading secret %q: %w", name, err) + } + // A mounted secret often ends in a newline. Trimming returns a + // sub-slice of the same array, so nothing is copied. + return bytes.TrimSpace(b), nil +} + +func run(ctx context.Context, cfg config, secrets secrets) error { + // The credential guest the token strategy runs in. The caller opens + // it, so the caller chooses its memory policy: under + // -require-locked-memory it is locked from the start and stays locked + // as it grows. NewClient checks it either way, below. + var storeOpts []stackauth.Option + if cfg.requireLockedMemory { + storeOpts = append(storeOpts, stackauth.RequireLockedMemory()) + } + // No profile: this application's credentials are all explicit. + store, err := stackauth.OpenWithoutProfile(ctx, storeOpts...) + if err != nil { + return fmt.Errorf("opening the credential guest: %w", err) + } + // Deferred calls run last-first: the client closes before the strategy + // it asks for tokens, and the strategy before the store it lives in. + defer store.Close() + + accessKey, err := secrets.Get(ctx, "access-key") + if err != nil { + return err + } + var strategyOpts []stackauth.StrategyOption + if cfg.ctsHost != "" { + strategyOpts = append(strategyOpts, stackauth.WithAuthBaseURL(cfg.ctsHost)) + } + // The access key crosses as a string, which Go cannot wipe; the bytes + // it was read into can be. + strategy, err := store.AccessKey(ctx, cfg.workspaceCRN, string(accessKey), strategyOpts...) + clear(accessKey) + if err != nil { + return fmt.Errorf("access-key strategy: %w", err) + } + defer strategy.Close() + + keyMaterial, err := secrets.Get(ctx, "client-key") + if err != nil { + return err + } + // NewClientKey takes ownership of the bytes; NewClient wipes them. + creds := stackencrypt.NewCredentials(cfg.clientID, stackencrypt.NewClientKey(keyMaterial), strategy) + + opts := []stackencrypt.ClientOption{stackencrypt.WithCredentials(creds)} + if cfg.zerokmsURL != "" { + opts = append(opts, stackencrypt.WithZeroKMSURL(cfg.zerokmsURL)) + } + if cfg.requireLockedMemory { + opts = append(opts, stackencrypt.WithRequireLockedMemory()) + } + client, err := stackencrypt.NewClient(ctx, opts...) + if err != nil { + return fmt.Errorf("connecting to ZeroKMS: %w", err) + } + defer client.Close() + // The memory state covers both guests: the crypto guest holding the + // key, and the credential guest the token strategy runs in. + fmt.Printf("connected (%v)\n", client) + + // A key is for one client. These credentials are spent, and a second + // client needs a new key; nothing was sent to find that out. + if _, err := stackencrypt.NewClient(ctx, stackencrypt.WithCredentials(creds)); errors.Is(err, stackencrypt.ErrCredentialsConsumed) { + fmt.Println("reusing the credentials is refused: the key was consumed by the first client") + } else { + return fmt.Errorf("reusing the credentials: got %v, want ErrCredentialsConsumed", err) + } + + cipher := client.DefaultKeyset() + rows := []user{{ID: 1, Email: "alice@example.com"}, {ID: 2, Email: "bob@example.com"}} + records, err := cipher.EncryptRecords(ctx, rows) + if err != nil { + return fmt.Errorf("encrypting records: %w", err) + } + probe, err := cipher.Term(ctx, "bob@example.com", stackencrypt.MustContext("users/email"), stackencrypt.Equality) + if err != nil { + return fmt.Errorf("deriving a probe: %w", err) + } + for i, r := range records { + if probe.(stackencrypt.EqualityTerm).Equal(r["Email"].Equality) { + fmt.Printf("sealed %d rows; the probe for bob@example.com matches row %d\n", len(records), i) + } + } + var back []user + if err := cipher.DecryptRecords(ctx, records, &back); err != nil { + return fmt.Errorf("decrypting records: %w", err) + } + fmt.Printf("opened %v\n", back) + return nil +} From e753829f1e83ec9dc0955fb588059c5c882a0a30 Mon Sep 17 00:00:00 2001 From: Dan Draper <dan@cipherstash.com> Date: Mon, 28 Sep 2026 22:59:52 -0700 Subject: [PATCH 651/686] fix(go): consume replaced credentials; no ZeroKMS URL in the examples A WithCredentials that a later one replaced (other credentials, or nil for the default) was dropped from the options, so its key was neither resolved nor wiped: it stayed live after NewClient, contrary to the consume guarantee WithCredentials documents. The replaced credentials are now kept and consumed like a refused config's: the key is wiped and the credentials refuse reuse with ErrCredentialsConsumed. Applications should almost never pin the ZeroKMS endpoint; it comes from the token. The README's options example and the explicit-credentials example no longer show WithZeroKMSURL. --- languages/golang/stackencrypt/README.md | 1 - languages/golang/stackencrypt/client.go | 5 +++ .../stackencrypt/example/explicit/README.md | 5 ++- .../stackencrypt/example/explicit/main.go | 5 --- languages/golang/stackencrypt/options.go | 16 ++++++++-- languages/golang/stackencrypt/options_test.go | 31 +++++++++++++++++++ 6 files changed, 51 insertions(+), 12 deletions(-) diff --git a/languages/golang/stackencrypt/README.md b/languages/golang/stackencrypt/README.md index e163f9a7a..a4b3ca17d 100644 --- a/languages/golang/stackencrypt/README.md +++ b/languages/golang/stackencrypt/README.md @@ -57,7 +57,6 @@ Everything else is a functional option, and each has a default: ```go client, err := stackencrypt.NewClient(ctx, stackencrypt.WithCredentials(stackencrypt.OIDCFederation(crn, provider)), - stackencrypt.WithZeroKMSURL("https://zerokms.example"), stackencrypt.WithTransport(rt), stackencrypt.WithKeysetCacheSize(4096), stackencrypt.WithRequireLockedMemory(), diff --git a/languages/golang/stackencrypt/client.go b/languages/golang/stackencrypt/client.go index 80d6f35c6..a4886bbfb 100644 --- a/languages/golang/stackencrypt/client.go +++ b/languages/golang/stackencrypt/client.go @@ -74,6 +74,11 @@ func NewClient(ctx context.Context, opts ...ClientOption) (_ *Client, err error) if rt == nil { rt = http.DefaultTransport } + // Credentials a later WithCredentials replaced are never resolved, but + // an explicit key in them is still the client's to consume. + for _, c := range cfg.superseded { + consumeUnresolved(c) + } creds := cfg.credentials if creds == nil { creds = AutoCredentials() diff --git a/languages/golang/stackencrypt/example/explicit/README.md b/languages/golang/stackencrypt/example/explicit/README.md index 8723baf4b..4048119ed 100644 --- a/languages/golang/stackencrypt/example/explicit/README.md +++ b/languages/golang/stackencrypt/example/explicit/README.md @@ -32,9 +32,8 @@ cd bindings/go && go run ./stackencrypt/example/explicit -secrets-dir ... -clien ``` Optional flags: `-cts-host` pins the authentication endpoint (default: from -the workspace CRN), `-zerokms-url` pins ZeroKMS (default: from the token), -and `-require-locked-memory` refuses to run on memory that cannot be locked -in RAM. +the workspace CRN), and `-require-locked-memory` refuses to run on memory +that cannot be locked in RAM. The ZeroKMS endpoint comes from the token. ## What it shows diff --git a/languages/golang/stackencrypt/example/explicit/main.go b/languages/golang/stackencrypt/example/explicit/main.go index 98f0ff5a8..7921b8613 100644 --- a/languages/golang/stackencrypt/example/explicit/main.go +++ b/languages/golang/stackencrypt/example/explicit/main.go @@ -38,7 +38,6 @@ type config struct { clientID string workspaceCRN string ctsHost string - zerokmsURL string requireLockedMemory bool } @@ -48,7 +47,6 @@ func main() { flag.StringVar(&cfg.clientID, "client-id", "", "the ZeroKMS client id (not a secret)") flag.StringVar(&cfg.workspaceCRN, "workspace-crn", "", "the workspace the access key belongs to") flag.StringVar(&cfg.ctsHost, "cts-host", "", "pin the authentication endpoint (default: from the workspace CRN)") - flag.StringVar(&cfg.zerokmsURL, "zerokms-url", "", "pin the ZeroKMS endpoint (default: from the token)") flag.BoolVar(&cfg.requireLockedMemory, "require-locked-memory", false, "refuse to run on memory that cannot be locked in RAM") flag.Parse() if cfg.clientID == "" || cfg.workspaceCRN == "" { @@ -124,9 +122,6 @@ func run(ctx context.Context, cfg config, secrets secrets) error { creds := stackencrypt.NewCredentials(cfg.clientID, stackencrypt.NewClientKey(keyMaterial), strategy) opts := []stackencrypt.ClientOption{stackencrypt.WithCredentials(creds)} - if cfg.zerokmsURL != "" { - opts = append(opts, stackencrypt.WithZeroKMSURL(cfg.zerokmsURL)) - } if cfg.requireLockedMemory { opts = append(opts, stackencrypt.WithRequireLockedMemory()) } diff --git a/languages/golang/stackencrypt/options.go b/languages/golang/stackencrypt/options.go index 8ce543229..44880dd5f 100644 --- a/languages/golang/stackencrypt/options.go +++ b/languages/golang/stackencrypt/options.go @@ -9,7 +9,11 @@ type ClientOption func(*clientOptions) // clientOptions is what the options set. Every zero value is the default. type clientOptions struct { - credentials Credentials + credentials Credentials + // superseded is every earlier WithCredentials a later one replaced. + // NewClient consumes them too: a key handed to WithCredentials is + // wiped whichever option wins. + superseded []Credentials zerokmsURL string keysetCacheSize int transport http.RoundTripper @@ -26,9 +30,15 @@ type clientOptions struct { // wipes the key, and wipes the buffer once the guest has the key, so after // NewClient returns — whatever the outcome, a configuration it refused // included — the key is empty and the bytes it was built from are zero. A -// key is for one client. +// key is for one client. That holds for credentials a later WithCredentials +// replaces as well: they are consumed, not left holding a live key. func WithCredentials(c Credentials) ClientOption { - return func(o *clientOptions) { o.credentials = c } + return func(o *clientOptions) { + if o.credentials != nil { + o.superseded = append(o.superseded, o.credentials) + } + o.credentials = c + } } // WithZeroKMSURL pins the ZeroKMS endpoint. Without it, CS_ZEROKMS_HOST (or diff --git a/languages/golang/stackencrypt/options_test.go b/languages/golang/stackencrypt/options_test.go index 4e67b2639..fd9fd2053 100644 --- a/languages/golang/stackencrypt/options_test.go +++ b/languages/golang/stackencrypt/options_test.go @@ -57,6 +57,37 @@ func TestLaterZeroKMSURLWins(t *testing.T) { } } +// A WithCredentials a later one replaces is never resolved, but its key is +// still consumed: wiped, and its credentials refused if reused. That holds +// whether the replacement is other credentials or nil, the default. +func TestLaterCredentialsConsumeTheOnesTheyReplace(t *testing.T) { + guestOrSkip(t) + for name, later := range map[string]func(*testing.T) Credentials{ + "other credentials": func(*testing.T) Credentials { return testCredentials(staticToken("stub-token")) }, + "nil": func(t *testing.T) Credentials { + cleanEnv(t, filepath.Join(t.TempDir(), "absent")) + return nil + }, + } { + t.Run(name, func(t *testing.T) { + stub := newStub(t, http.StatusUnauthorized, "", "nope") + key := NewClientKey([]byte(testClientKey)) + replaced := newTestCredentials(testClientID, key, staticToken("stub-token")) + _, _ = NewClient(context.Background(), + WithCredentials(replaced), + WithCredentials(later(t)), + WithZeroKMSURL(stub.URL), + ) + if !key.IsZero() { + t.Error("the replaced credentials' key still holds material") + } + if _, err := NewClient(context.Background(), WithCredentials(replaced), WithZeroKMSURL(stub.URL)); !errors.Is(err, ErrCredentialsConsumed) { + t.Errorf("reusing the replaced credentials: %v, want ErrCredentialsConsumed", err) + } + }) + } +} + // A later WithKeysetCacheSize replaces an earlier one before anything is // checked: a negative size overridden by zero, the default, is accepted, // and the client goes on to ZeroKMS. From 16c6121a7f20640cef857cb4366ecc39aafb3368 Mon Sep 17 00:00:00 2001 From: Dan Draper <dan@cipherstash.com> Date: Mon, 28 Sep 2026 23:02:21 -0700 Subject: [PATCH 652/686] feat(go)!: no public option to pin the ZeroKMS endpoint The endpoint comes from the token's services claim, and applications should almost never pin it. WithZeroKMSURL is removed from the public API; CS_ZEROKMS_HOST (or the legacy CS_VITUR_HOST) still overrides it, as in the Rust client, which is also what the live tests use. The unit tests that point a client at an httptest stub get a test-only withZeroKMSURL in export_test.go, and TestLaterZeroKMSURLWins, which only tested that option's precedence, is gone. The README's options table and endpoint paragraph are updated. --- languages/golang/stackencrypt/README.md | 11 ++++--- .../golang/stackencrypt/credentials_test.go | 14 ++++----- languages/golang/stackencrypt/example/main.go | 2 +- languages/golang/stackencrypt/export_test.go | 7 +++++ languages/golang/stackencrypt/guest_test.go | 4 +-- languages/golang/stackencrypt/live_test.go | 2 +- languages/golang/stackencrypt/memory_test.go | 2 +- languages/golang/stackencrypt/options.go | 14 +++------ languages/golang/stackencrypt/options_test.go | 30 ++++--------------- 9 files changed, 34 insertions(+), 52 deletions(-) diff --git a/languages/golang/stackencrypt/README.md b/languages/golang/stackencrypt/README.md index a4b3ca17d..cd2e48062 100644 --- a/languages/golang/stackencrypt/README.md +++ b/languages/golang/stackencrypt/README.md @@ -66,7 +66,6 @@ client, err := stackencrypt.NewClient(ctx, | Option | Default | |---|---| | `WithCredentials(c)` | `AutoCredentials()`: see below. | -| `WithZeroKMSURL(url)` | `CS_ZEROKMS_HOST` if set, otherwise the endpoint in the token. | | `WithTransport(rt)` | `http.DefaultTransport`. Used for ZeroKMS, and for token requests when the credentials make them. | | `WithKeysetCacheSize(n)` | 1024 keysets beyond the default one. | | `WithRequireLockedMemory()` | Off: memory that cannot be locked is reported, not refused. See below. | @@ -97,11 +96,11 @@ when the profile would have been consulted, it also says why the profile could not be opened, so an unreadable or mistyped `CS_CONFIG_PATH` is not reported as "not logged in". -The endpoint variables are read whatever the credentials, `NewCredentials` -included, as the Rust client reads them. A service that passes no -`WithZeroKMSURL` and relies on the token's services claim now follows -`CS_ZEROKMS_HOST` or `CS_VITUR_HOST` if either is set in its environment, -so a value exported there for another tool is worth checking. +The ZeroKMS endpoint comes from the token's services claim; there is no +option to pin it. `CS_ZEROKMS_HOST` (or the legacy `CS_VITUR_HOST`) +overrides it whatever the credentials, `NewCredentials` included, as the +Rust client reads them, so a value exported there for another tool is +worth checking. Resolution happens host-side, in Go. The profile and the token strategies run in `stackauth`'s credential guest; the crypto guest that holds the diff --git a/languages/golang/stackencrypt/credentials_test.go b/languages/golang/stackencrypt/credentials_test.go index d1dd8042a..019e85d18 100644 --- a/languages/golang/stackencrypt/credentials_test.go +++ b/languages/golang/stackencrypt/credentials_test.go @@ -446,7 +446,7 @@ func TestNewClientReleasesTheCredentialsOnceWhenInitFails(t *testing.T) { } return r, err }) - _, err := NewClient(context.Background(), WithCredentials(creds), WithZeroKMSURL(stub.URL)) + _, err := NewClient(context.Background(), WithCredentials(creds), withZeroKMSURL(stub.URL)) if !errors.Is(err, ErrUnauthorized) { t.Fatalf("NewClient: %v, want ErrUnauthorized from the stub", err) } @@ -530,10 +530,10 @@ func TestNewCredentialsRefusedConfigThenRetryIsConsumed(t *testing.T) { guestOrSkip(t) stub := newStub(t, http.StatusUnauthorized, "", "nope") creds := testCredentials(staticToken("t")) - if _, err := NewClient(context.Background(), WithCredentials(creds), WithZeroKMSURL(stub.URL), WithKeysetCacheSize(-1)); err == nil { + if _, err := NewClient(context.Background(), WithCredentials(creds), withZeroKMSURL(stub.URL), WithKeysetCacheSize(-1)); err == nil { t.Fatal("NewClient accepted a negative cache size") } - _, err := NewClient(context.Background(), WithCredentials(creds), WithZeroKMSURL(stub.URL)) + _, err := NewClient(context.Background(), WithCredentials(creds), withZeroKMSURL(stub.URL)) if !errors.Is(err, ErrCredentialsConsumed) { t.Fatalf("retry with a corrected config: %v, want ErrCredentialsConsumed", err) } @@ -589,7 +589,7 @@ func TestNewCredentialsRefusesASecondClient(t *testing.T) { guestOrSkip(t) stub := newStub(t, http.StatusUnauthorized, "", "nope") creds := testCredentials(staticToken("t")) - cfg := []ClientOption{WithCredentials(creds), WithZeroKMSURL(stub.URL)} + cfg := []ClientOption{WithCredentials(creds), withZeroKMSURL(stub.URL)} if _, err := NewClient(context.Background(), cfg...); !errors.Is(err, ErrUnauthorized) { t.Fatalf("first NewClient: %v, want ErrUnauthorized from the stub", err) } @@ -801,7 +801,7 @@ func TestRequireLockedMemoryRefusesUnlockedCredentials(t *testing.T) { } creds, resolved := forcedCreds(t) // No crypto guest is needed: the refusal precedes it. - _, err := NewClient(context.Background(), WithCredentials(creds), WithZeroKMSURL(stub.URL), WithGuest(wasiProbe), WithRequireLockedMemory()) + _, err := NewClient(context.Background(), WithCredentials(creds), withZeroKMSURL(stub.URL), WithGuest(wasiProbe), WithRequireLockedMemory()) if !errors.Is(err, ErrMemoryLock) || !strings.Contains(err.Error(), "credential guest") { t.Fatalf("NewClient under WithRequireLockedMemory: %v, want ErrMemoryLock naming the credential guest", err) } @@ -827,7 +827,7 @@ func TestRequireLockedMemoryRefusesUnlockedCredentials(t *testing.T) { guestOrSkip(t) creds, _ = forcedCreds(t) - if _, err := NewClient(context.Background(), WithCredentials(creds), WithZeroKMSURL(stub.URL)); !errors.Is(err, ErrUnauthorized) { + if _, err := NewClient(context.Background(), WithCredentials(creds), withZeroKMSURL(stub.URL)); !errors.Is(err, ErrUnauthorized) { t.Fatalf("NewClient under best effort: %v, want ErrUnauthorized from the stub, the report not refused", err) } }) @@ -863,7 +863,7 @@ func TestNewCredentialsLeavesTheStrategyToTheCaller(t *testing.T) { zerokms := newStub(t, http.StatusOK, "application/json", "{}") creds = NewCredentials(testClientID, NewClientKey([]byte(testClientKey)), strategy) - if _, err := NewClient(ctx, WithCredentials(creds), WithZeroKMSURL(zerokms.URL)); err == nil { + if _, err := NewClient(ctx, WithCredentials(creds), withZeroKMSURL(zerokms.URL)); err == nil { t.Fatal("NewClient succeeded with a token CTS refused") } // Still open: asked again, it goes back to CTS rather than failing diff --git a/languages/golang/stackencrypt/example/main.go b/languages/golang/stackencrypt/example/main.go index 338153ef0..11cacf1f6 100644 --- a/languages/golang/stackencrypt/example/main.go +++ b/languages/golang/stackencrypt/example/main.go @@ -44,7 +44,7 @@ func run() error { // + CS_CLIENT_KEY), then the developer profile `stash auth login` writes, // read through stackauth's credential guest. The token is a refreshing // device session there, asked on every request, so a long run outlives - // one token. No WithZeroKMSURL: CS_ZEROKMS_HOST if set, else the token's + // one token. The ZeroKMS endpoint: CS_ZEROKMS_HOST if set, else the token's // services claim. client, err := stackencrypt.NewClient(ctx) if err != nil { diff --git a/languages/golang/stackencrypt/export_test.go b/languages/golang/stackencrypt/export_test.go index efdef8972..89029b3f9 100644 --- a/languages/golang/stackencrypt/export_test.go +++ b/languages/golang/stackencrypt/export_test.go @@ -23,3 +23,10 @@ func staticToken(token string) tokenSource { func newTestCredentials(clientID string, key *ClientKey, token tokenSource) Credentials { return &explicitCredentials{clientID: clientID, key: key, token: token} } + +// withZeroKMSURL points the client at a ZeroKMS stub. There is no public +// option for it: applications take the endpoint from the token, or from +// CS_ZEROKMS_HOST. +func withZeroKMSURL(url string) ClientOption { + return func(o *clientOptions) { o.zerokmsURL = url } +} diff --git a/languages/golang/stackencrypt/guest_test.go b/languages/golang/stackencrypt/guest_test.go index 5cb71fc5f..f3010733a 100644 --- a/languages/golang/stackencrypt/guest_test.go +++ b/languages/golang/stackencrypt/guest_test.go @@ -99,7 +99,7 @@ func newStub(t *testing.T, status int, contentType, body string) *zerokmsStub { // testConfig is the options for a client of the test credentials against // url. A test appends to it; a later option wins. func testConfig(url string) []ClientOption { - return []ClientOption{WithCredentials(testCredentials(staticToken("stub-token"))), WithZeroKMSURL(url)} + return []ClientOption{WithCredentials(testCredentials(staticToken("stub-token"))), withZeroKMSURL(url)} } // testCredentials is the test client id and a fresh copy of the test key, @@ -487,7 +487,7 @@ func TestConfigValidation(t *testing.T) { for name, option := range map[string]ClientOption{ "client id not a uuid": WithCredentials(newTestCredentials("acme", NewClientKey([]byte(testClientKey)), staticToken("t"))), "key not hex": WithCredentials(newTestCredentials(testClientID, NewClientKey([]byte("zz")), staticToken("t"))), - "bad url": WithZeroKMSURL("not a url"), + "bad url": withZeroKMSURL("not a url"), } { stub := newStub(t, http.StatusOK, "application/json", "{}") _, err := NewClient(ctx, append(testConfig(stub.URL), option)...) diff --git a/languages/golang/stackencrypt/live_test.go b/languages/golang/stackencrypt/live_test.go index 50d596e7a..0dc24a2f0 100644 --- a/languages/golang/stackencrypt/live_test.go +++ b/languages/golang/stackencrypt/live_test.go @@ -77,7 +77,7 @@ func liveClient(t *testing.T) *Client { } return r, err }) - c, err := NewClient(t.Context(), WithCredentials(creds), WithZeroKMSURL(url)) + c, err := NewClient(t.Context(), WithCredentials(creds), withZeroKMSURL(url)) if err != nil { t.Fatalf("NewClient: %v", err) } diff --git a/languages/golang/stackencrypt/memory_test.go b/languages/golang/stackencrypt/memory_test.go index 43bef9739..81df05554 100644 --- a/languages/golang/stackencrypt/memory_test.go +++ b/languages/golang/stackencrypt/memory_test.go @@ -159,7 +159,7 @@ func TestRequireLockedMemoryRefusesACallerStoreUnlocked(t *testing.T) { _, err = NewClient(ctx, WithCredentials(NewCredentials("6a70bd18-99ac-4650-b104-37eec3a15b09", key, strategy)), WithGuest(wasiProbe), - WithZeroKMSURL("https://zerokms.invalid"), + withZeroKMSURL("https://zerokms.invalid"), WithRequireLockedMemory(), ) if !errors.Is(err, ErrMemoryLock) || !strings.Contains(err.Error(), "credentials' memory") { diff --git a/languages/golang/stackencrypt/options.go b/languages/golang/stackencrypt/options.go index 44880dd5f..afe8b1ed1 100644 --- a/languages/golang/stackencrypt/options.go +++ b/languages/golang/stackencrypt/options.go @@ -13,7 +13,10 @@ type clientOptions struct { // superseded is every earlier WithCredentials a later one replaced. // NewClient consumes them too: a key handed to WithCredentials is // wiped whichever option wins. - superseded []Credentials + superseded []Credentials + // zerokmsURL is set only by the tests' withZeroKMSURL, to reach a + // stub. Applications get the endpoint from the token's services claim, + // or CS_ZEROKMS_HOST, as the Rust client does. zerokmsURL string keysetCacheSize int transport http.RoundTripper @@ -41,15 +44,6 @@ func WithCredentials(c Credentials) ClientOption { } } -// WithZeroKMSURL pins the ZeroKMS endpoint. Without it, CS_ZEROKMS_HOST (or -// the legacy CS_VITUR_HOST) pins it if set — a set value that is not an -// http(s) URL is an error — and otherwise the endpoint is resolved from the -// access token's services claim on first use. The variables are read -// whatever the credentials, as stack-kms reads them. -func WithZeroKMSURL(url string) ClientOption { - return func(o *clientOptions) { o.zerokmsURL = url } -} - // WithKeysetCacheSize sets how many keysets beyond the default the guest // keeps loaded. Zero means the crate default (1024); negative is refused. func WithKeysetCacheSize(n int) ClientOption { diff --git a/languages/golang/stackencrypt/options_test.go b/languages/golang/stackencrypt/options_test.go index fd9fd2053..8fbb8219a 100644 --- a/languages/golang/stackencrypt/options_test.go +++ b/languages/golang/stackencrypt/options_test.go @@ -23,9 +23,9 @@ func TestClientOptionsSetTheirField(t *testing.T) { wasm := []byte("\x00asm") var got clientOptions for _, opt := range []ClientOption{ - WithZeroKMSURL("https://first.example"), + withZeroKMSURL("https://first.example"), WithCredentials(creds), - WithZeroKMSURL("https://second.example"), + withZeroKMSURL("https://second.example"), WithKeysetCacheSize(4096), WithTransport(rt), WithGuest(wasm), @@ -39,24 +39,6 @@ func TestClientOptionsSetTheirField(t *testing.T) { } } -// The endpoint a later WithZeroKMSURL names is the one NewClient uses. -func TestLaterZeroKMSURLWins(t *testing.T) { - guestOrSkip(t) - first := newStub(t, http.StatusUnauthorized, "", "first") - second := newStub(t, http.StatusUnauthorized, "", "second") - _, err := NewClient(context.Background(), - WithCredentials(testCredentials(staticToken("stub-token"))), - WithZeroKMSURL(first.URL), - WithZeroKMSURL(second.URL), - ) - if !errors.Is(err, ErrUnauthorized) { - t.Fatalf("NewClient: %v", err) - } - if len(first.requests) != 0 || len(second.requests) != 1 { - t.Fatalf("requests: first %d, second %d; want only the second", len(first.requests), len(second.requests)) - } -} - // A WithCredentials a later one replaces is never resolved, but its key is // still consumed: wiped, and its credentials refused if reused. That holds // whether the replacement is other credentials or nil, the default. @@ -76,12 +58,12 @@ func TestLaterCredentialsConsumeTheOnesTheyReplace(t *testing.T) { _, _ = NewClient(context.Background(), WithCredentials(replaced), WithCredentials(later(t)), - WithZeroKMSURL(stub.URL), + withZeroKMSURL(stub.URL), ) if !key.IsZero() { t.Error("the replaced credentials' key still holds material") } - if _, err := NewClient(context.Background(), WithCredentials(replaced), WithZeroKMSURL(stub.URL)); !errors.Is(err, ErrCredentialsConsumed) { + if _, err := NewClient(context.Background(), WithCredentials(replaced), withZeroKMSURL(stub.URL)); !errors.Is(err, ErrCredentialsConsumed) { t.Errorf("reusing the replaced credentials: %v, want ErrCredentialsConsumed", err) } }) @@ -96,7 +78,7 @@ func TestLaterKeysetCacheSizeWins(t *testing.T) { stub := newStub(t, http.StatusUnauthorized, "", "nope") _, err := NewClient(context.Background(), WithCredentials(testCredentials(staticToken("stub-token"))), - WithZeroKMSURL(stub.URL), + withZeroKMSURL(stub.URL), WithKeysetCacheSize(-1), WithKeysetCacheSize(0), ) @@ -151,7 +133,7 @@ func TestOIDCFederationThroughTheClientTransport(t *testing.T) { rt := &countingTransport{} _, err := NewClient(context.Background(), WithCredentials(OIDCFederation(testCRN, provider)), - WithZeroKMSURL(stub.URL), + withZeroKMSURL(stub.URL), WithTransport(rt), ) if !errors.Is(err, ErrUnauthorized) { From ad774a04b37293294a101e85e19d05fc5f6bf868 Mon Sep 17 00:00:00 2001 From: Lindsay Holmwood <lindsay@holmwood.id.au> Date: Tue, 29 Sep 2026 16:26:04 +1000 Subject: [PATCH 653/686] ci(go): lint the Go bindings with golangci-lint Add bindings/go/.golangci.yaml (same linters as amp-writer: errcheck, staticcheck, govet, unused, ineffassign, bodyclose; gofmt and goimports formatters), with the std-error-handling exclusion preset so deferred Close and fmt.Fprint* calls are not flagged. Run it through a new `go:lint` mise task, in a single Linux "Go lint" job in test-wasi.yml. It doesn't run on macOS or Windows too because those would report the same findings. Fix the existing findings: - unchecked unlock errors in the refresh lock (unix and windows) - unchecked release/Close in tests - errNoLockSupport moves to memory_other.go, the only file that uses it (it was "unused" on every platform that has a lock implementation) - goimports ordering in two test files Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01APzsxUu9RLqJa7ua8QEszX --- .github/imported-workflows/test-wasi.yml | 19 ++++++++++++++++++ languages/golang/.golangci.yaml | 20 +++++++++++++++++++ languages/golang/internal/guest/memory.go | 5 ----- .../golang/internal/guest/memory_other.go | 6 ++++++ languages/golang/stackauth/lock_unix.go | 2 +- languages/golang/stackauth/lock_windows.go | 2 +- languages/golang/stackencrypt/guest_test.go | 4 ++-- languages/golang/stackencrypt/memory_test.go | 3 ++- languages/golang/stackencrypt/options_test.go | 4 ++-- languages/golang/stackencrypt/unit_test.go | 3 ++- 10 files changed, 55 insertions(+), 13 deletions(-) create mode 100644 languages/golang/.golangci.yaml diff --git a/.github/imported-workflows/test-wasi.yml b/.github/imported-workflows/test-wasi.yml index 3d6c211dd..579bb46fd 100644 --- a/.github/imported-workflows/test-wasi.yml +++ b/.github/imported-workflows/test-wasi.yml @@ -147,6 +147,25 @@ jobs: echo "RLIMIT_MEMLOCK: $(ulimit -l) KiB" mise run go:test + # Linux only: macOS and Windows would report the same findings. Files + # behind a windows or darwin build tag are not seen here; lint them + # locally with GOOS set. Needs no guests: the packages embed a directory + # and compile without them. + go-lint: + name: Go lint + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v6 + + - uses: jdx/mise-action@v4 + with: + install_args: go golangci-lint + cache: true + + - name: golangci-lint + run: mise run go:lint + # The same Go binding on macOS and Windows, against the guest Linux built. # The credential guest's Go-side refresh lock has Unix and Windows # implementations. The shared go-binding-test.sh prints the device refresh diff --git a/languages/golang/.golangci.yaml b/languages/golang/.golangci.yaml new file mode 100644 index 000000000..a67260385 --- /dev/null +++ b/languages/golang/.golangci.yaml @@ -0,0 +1,20 @@ +version: "2" + +linters: + default: none + enable: + - bodyclose + - errcheck + - govet + - ineffassign + - staticcheck + - unused + exclusions: + presets: + # Unchecked Close, Flush, fmt.Fprint* and friends. + - std-error-handling + +formatters: + enable: + - gofmt + - goimports diff --git a/languages/golang/internal/guest/memory.go b/languages/golang/internal/guest/memory.go index 93734d12d..aabbbbeac 100644 --- a/languages/golang/internal/guest/memory.go +++ b/languages/golang/internal/guest/memory.go @@ -1,7 +1,6 @@ package guest import ( - "errors" "fmt" "log/slog" "math" @@ -311,10 +310,6 @@ func (m *heapMemory) free() { m.buf = nil } -// errNoLockSupport is the heap fallback's reason on platforms where this -// package has no lock implementation. -var errNoLockSupport = errors.New("guest memory cannot be locked on this platform") - // MemoryLockError wraps a lock refusal as ErrMemoryLock. func MemoryLockError(err error) error { return fmt.Errorf("%w: %w", ErrMemoryLock, err) diff --git a/languages/golang/internal/guest/memory_other.go b/languages/golang/internal/guest/memory_other.go index d82f03677..645998b72 100644 --- a/languages/golang/internal/guest/memory_other.go +++ b/languages/golang/internal/guest/memory_other.go @@ -2,6 +2,12 @@ package guest +import "errors" + +// errNoLockSupport is the heap fallback's reason on platforms where this +// package has no lock implementation. +var errNoLockSupport = errors.New("guest memory cannot be locked on this platform") + // Platforms with neither mmap nor VirtualAlloc in this package's // vocabulary get the heap fallback: growth and release still wipe, nothing // is locked, and the Client says so. diff --git a/languages/golang/stackauth/lock_unix.go b/languages/golang/stackauth/lock_unix.go index d08378707..d730048a0 100644 --- a/languages/golang/stackauth/lock_unix.go +++ b/languages/golang/stackauth/lock_unix.go @@ -36,6 +36,6 @@ func withRefreshLock(ctx context.Context, path string, run func() error) error { case <-time.After(20 * time.Millisecond): } } - defer unix.Flock(int(f.Fd()), unix.LOCK_UN) + defer func() { _ = unix.Flock(int(f.Fd()), unix.LOCK_UN) }() return run() } diff --git a/languages/golang/stackauth/lock_windows.go b/languages/golang/stackauth/lock_windows.go index 2a7c70ef9..89872822c 100644 --- a/languages/golang/stackauth/lock_windows.go +++ b/languages/golang/stackauth/lock_windows.go @@ -39,6 +39,6 @@ func withRefreshLock(ctx context.Context, path string, run func() error) error { case <-time.After(20 * time.Millisecond): } } - defer windows.UnlockFileEx(h, 0, 0xffffffff, 0xffffffff, &overlap) + defer func() { _ = windows.UnlockFileEx(h, 0, 0xffffffff, 0xffffffff, &overlap) }() return run() } diff --git a/languages/golang/stackencrypt/guest_test.go b/languages/golang/stackencrypt/guest_test.go index f3010733a..0ac9e4fc8 100644 --- a/languages/golang/stackencrypt/guest_test.go +++ b/languages/golang/stackencrypt/guest_test.go @@ -796,7 +796,7 @@ func TestClientKeyDoesNotRemainInGuestMemory(t *testing.T) { if err != nil { t.Fatal(err) } - defer inst.release() + defer func() { _ = inst.release() }() _, err = inst.call(ctx, inst.cipherInit, buf(encoded)) if !errors.Is(err, ErrUnauthorized) { t.Fatalf("init: %v", err) @@ -831,7 +831,7 @@ func TestTransportSendCounterAndResponseHeaders(t *testing.T) { if err != nil { t.Fatal(err) } - defer inst.release() + defer func() { _ = inst.release() }() encoded, _ := encodeConfig(testInit(stub.URL)) if _, err := inst.call(ctx, inst.cipherInit, buf(encoded)); !errors.Is(err, ErrUnauthorized) { t.Fatalf("init: %v", err) diff --git a/languages/golang/stackencrypt/memory_test.go b/languages/golang/stackencrypt/memory_test.go index 81df05554..0cbc767b5 100644 --- a/languages/golang/stackencrypt/memory_test.go +++ b/languages/golang/stackencrypt/memory_test.go @@ -4,13 +4,14 @@ import ( "context" "errors" "fmt" - "github.com/tetratelabs/wazero/api" "net/http" "runtime" "strings" "testing" "time" + "github.com/tetratelabs/wazero/api" + "github.com/cipherstash/cipherstash-suite/bindings/go/internal/guest" "github.com/cipherstash/cipherstash-suite/bindings/go/internal/guesttest" "github.com/cipherstash/cipherstash-suite/bindings/go/stackauth" diff --git a/languages/golang/stackencrypt/options_test.go b/languages/golang/stackencrypt/options_test.go index 8fbb8219a..62861a885 100644 --- a/languages/golang/stackencrypt/options_test.go +++ b/languages/golang/stackencrypt/options_test.go @@ -158,7 +158,7 @@ func TestOIDCFederationResolvesTheKeyLikeAuto(t *testing.T) { if err != nil { t.Fatal(err) } - defer resolved.Close() + defer func() { _ = resolved.Close() }() if resolved.ClientID != profileClientID || guest.KeyBytes(resolved.ClientKey) == nil { t.Errorf("ClientID = %q, want the profile's", resolved.ClientID) } @@ -190,7 +190,7 @@ func TestOIDCFederationTakesStrategyOptions(t *testing.T) { if err != nil { t.Fatal(err) } - defer resolved.Close() + defer func() { _ = resolved.Close() }() resolved.ClientKey.Wipe() if got := token(t, resolved); got != auth.jwt { t.Fatalf("token = %q, want the pinned CTS's", got) diff --git a/languages/golang/stackencrypt/unit_test.go b/languages/golang/stackencrypt/unit_test.go index 45f3b357c..980ea49cb 100644 --- a/languages/golang/stackencrypt/unit_test.go +++ b/languages/golang/stackencrypt/unit_test.go @@ -6,7 +6,6 @@ import ( "context" "encoding/hex" "errors" - "github.com/cipherstash/cipherstash-suite/bindings/go/internal/guest" "io" "net/http" "os" @@ -14,6 +13,8 @@ import ( "strings" "testing" + "github.com/cipherstash/cipherstash-suite/bindings/go/internal/guest" + "github.com/cipherstash/vitaminc/bindings/go/vcffi" "github.com/cipherstash/vitaminc/bindings/go/vcvalue" ) From a695c7207b1721a6498e2e02a9305bbd66e95589 Mon Sep 17 00:00:00 2001 From: Dan Draper <dan@cipherstash.com> Date: Tue, 29 Sep 2026 14:32:30 -0700 Subject: [PATCH 654/686] fix(go): same credentials twice are not consumed; classify refused config - NewClient no longer consumes superseded credentials that are the same NewCredentials value as the winning ones. - OIDCFederation returns a pointer, so Credentials values stay comparable. - A negative WithKeysetCacheSize and credentials resolving to nothing wrap ErrEncoding. - Test that WithRequireLockedMemory accepts credentials reporting locked memory. - Docs: WithTransport and the package doc say where NewCredentials' token exchange runs, and name OIDCFederation. --- languages/golang/stackencrypt/client.go | 27 +++++++++++----- languages/golang/stackencrypt/credentials.go | 2 +- .../golang/stackencrypt/credentials_test.go | 31 +++++++++++++++++-- languages/golang/stackencrypt/doc.go | 6 ++-- languages/golang/stackencrypt/options.go | 6 ++-- languages/golang/stackencrypt/options_test.go | 31 +++++++++++++++++++ 6 files changed, 89 insertions(+), 14 deletions(-) diff --git a/languages/golang/stackencrypt/client.go b/languages/golang/stackencrypt/client.go index a4886bbfb..d1296319a 100644 --- a/languages/golang/stackencrypt/client.go +++ b/languages/golang/stackencrypt/client.go @@ -74,15 +74,19 @@ func NewClient(ctx context.Context, opts ...ClientOption) (_ *Client, err error) if rt == nil { rt = http.DefaultTransport } - // Credentials a later WithCredentials replaced are never resolved, but - // an explicit key in them is still the client's to consume. - for _, c := range cfg.superseded { - consumeUnresolved(c) - } creds := cfg.credentials if creds == nil { creds = AutoCredentials() } + // Credentials a later WithCredentials replaced are never resolved, but + // an explicit key in them is still the client's to consume — unless the + // replacement is the same credentials passed again, whose key is the + // one this client resolves. + for _, c := range cfg.superseded { + if !sameExplicit(c, creds) { + consumeUnresolved(c) + } + } // The host-side checks come first: they resolve nothing, and under // AutoCredentials resolving means instantiating the credential guest // and reading the profile, which a config refused here should not pay @@ -91,7 +95,7 @@ func NewClient(ctx context.Context, opts ...ClientOption) (_ *Client, err error) // yet, so holds nothing of this client's. zerokmsURL, err := zerokmsEndpoint(cfg.zerokmsURL) if err == nil && cfg.keysetCacheSize < 0 { - err = errors.New("stackencrypt: WithKeysetCacheSize must not be negative") + err = fmt.Errorf("%w: WithKeysetCacheSize must not be negative", ErrEncoding) } if explicit, ok := creds.(*explicitCredentials); ok && err == nil && explicit.token == nil { // Knowable from the credentials as they were built: the one place @@ -120,7 +124,7 @@ func NewClient(ctx context.Context, opts ...ClientOption) (_ *Client, err error) return nil, err } if resolved == nil { - return nil, errors.New("stackencrypt: the credentials resolved to nothing") + return nil, fmt.Errorf("%w: the credentials resolved to nothing", ErrEncoding) } // Nil-safe, and a no-op after the wipe on the accepted path. defer resolved.ClientKey.Wipe() @@ -193,6 +197,15 @@ func NewClient(ctx context.Context, opts ...ClientOption) (_ *Client, err error) // ErrCredentialsConsumed rather than told the wiped values are missing. // Any other implementation has not been asked, and holds nothing of this // client's. +// sameExplicit reports whether a and b are the same NewCredentials value. +// It compares the concrete pointers: comparing the interfaces would panic +// on an implementation that is not comparable, such as a func type. +func sameExplicit(a, b Credentials) bool { + x, ok := a.(*explicitCredentials) + y, isExplicit := b.(*explicitCredentials) + return ok && isExplicit && x == y +} + func consumeUnresolved(creds Credentials) { if explicit, ok := creds.(*explicitCredentials); ok { explicit.consumed.Store(true) diff --git a/languages/golang/stackencrypt/credentials.go b/languages/golang/stackencrypt/credentials.go index 8353b8051..2866c0f05 100644 --- a/languages/golang/stackencrypt/credentials.go +++ b/languages/golang/stackencrypt/credentials.go @@ -235,7 +235,7 @@ func (autoCredentials) resolve(ctx context.Context, opts resolveOptions) (*resol // endpoint for these credentials alone. Without it, CS_CTS_HOST overrides // the endpoint, else it is discovered. func OIDCFederation(crn string, provider stackauth.OIDCProvider, opts ...stackauth.StrategyOption) Credentials { - return oidcCredentials{crn: crn, provider: provider, opts: opts} + return &oidcCredentials{crn: crn, provider: provider, opts: opts} } type oidcCredentials struct { diff --git a/languages/golang/stackencrypt/credentials_test.go b/languages/golang/stackencrypt/credentials_test.go index 019e85d18..5fb1cfeaa 100644 --- a/languages/golang/stackencrypt/credentials_test.go +++ b/languages/golang/stackencrypt/credentials_test.go @@ -504,8 +504,8 @@ func TestNewClientRefusesTheConfigBeforeResolvingCredentials(t *testing.T) { resolved = true return testCredentials(staticToken("t")).resolve(ctx, opts) }) - if _, err := NewClient(context.Background(), append([]ClientOption{WithCredentials(spy)}, opts...)...); err == nil { - t.Fatal("NewClient accepted the config") + if _, err := NewClient(context.Background(), append([]ClientOption{WithCredentials(spy)}, opts...)...); !errors.Is(err, ErrEncoding) { + t.Fatalf("NewClient: %v, want ErrEncoding", err) } if resolved { t.Error("the credentials were resolved for a config refused host-side") @@ -834,6 +834,33 @@ func TestRequireLockedMemoryRefusesUnlockedCredentials(t *testing.T) { } } +// WithRequireLockedMemory lets credentials through whose memory report is +// nil: the report is asked, and NewClient goes on past it. The outcome +// after that depends on whether this host can lock the crypto guest, so it +// is judged by the credentials' refusal being absent, not by success. +func TestRequireLockedMemoryAcceptsLockedCredentials(t *testing.T) { + stub := newStub(t, http.StatusUnauthorized, "", "nope") + var asked atomic.Int32 + creds := credentialsFunc(func(ctx context.Context, opts resolveOptions) (*resolvedCredentials, error) { + r, err := testCredentials(staticToken("stub-token")).resolve(ctx, opts) + if err != nil { + return r, err + } + r.MemoryLockError = func() error { + asked.Add(1) + return nil + } + return r, nil + }) + _, err := NewClient(context.Background(), WithCredentials(creds), withZeroKMSURL(stub.URL), WithGuest(wasiProbe), WithRequireLockedMemory()) + if asked.Load() == 0 { + t.Fatal("the credentials' memory report was not asked") + } + if err != nil && strings.Contains(err.Error(), "the credentials' memory") { + t.Fatalf("NewClient refused credentials reporting locked memory: %v", err) + } +} + // The strategy given to NewCredentials is the caller's: the credentials // hold nothing to close, and a client — here one whose init failed — leaves // the strategy open. diff --git a/languages/golang/stackencrypt/doc.go b/languages/golang/stackencrypt/doc.go index bbc8af246..3c7946c9d 100644 --- a/languages/golang/stackencrypt/doc.go +++ b/languages/golang/stackencrypt/doc.go @@ -59,8 +59,10 @@ // [net/http.RoundTripper], and a bearer-token fetch, served by the // credentials' stackauth strategy. What crosses per ZeroKMS call is what would cross TLS // anyway; derived key material never leaves the guest. Under -// [AutoCredentials] the same RoundTripper also carries the authentication -// requests to CTS, so one scoped to the ZeroKMS host alone is not enough. +// [AutoCredentials] and [OIDCFederation] the same RoundTripper also carries +// the authentication requests to CTS, so one scoped to the ZeroKMS host +// alone is not enough. Under [NewCredentials] those requests go through the +// store the caller opened the strategy from. // // # Credentials // diff --git a/languages/golang/stackencrypt/options.go b/languages/golang/stackencrypt/options.go index afe8b1ed1..d35b0eef4 100644 --- a/languages/golang/stackencrypt/options.go +++ b/languages/golang/stackencrypt/options.go @@ -55,8 +55,10 @@ func WithKeysetCacheSize(n int) ClientOption { // stackauth's credential guest makes to CTS: an access-key exchange, a // device-session refresh, a federation exchange. A RoundTripper scoped to // the ZeroKMS host alone (a pinned client certificate, an egress allowlist) -// refuses those; the failure then surfaces as the token strategy's. Nil means -// http.DefaultTransport, the default. +// refuses those; the failure then surfaces as the token strategy's. Under +// [NewCredentials] the token exchange runs in the store the caller opened, +// not through this RoundTripper: pass stackauth.WithRoundTripper to that +// store instead. Nil means http.DefaultTransport, the default. func WithTransport(rt http.RoundTripper) ClientOption { return func(o *clientOptions) { o.transport = rt } } diff --git a/languages/golang/stackencrypt/options_test.go b/languages/golang/stackencrypt/options_test.go index 62861a885..46dbbcca9 100644 --- a/languages/golang/stackencrypt/options_test.go +++ b/languages/golang/stackencrypt/options_test.go @@ -70,6 +70,37 @@ func TestLaterCredentialsConsumeTheOnesTheyReplace(t *testing.T) { } } +// The same credentials passed to WithCredentials twice are the ones that +// win, not ones replaced: their key is resolved, not consumed, and the +// client reaches ZeroKMS. +func TestTheSameCredentialsPassedTwiceAreNotConsumed(t *testing.T) { + guestOrSkip(t) + stub := newStub(t, http.StatusUnauthorized, "", "nope") + creds := testCredentials(staticToken("stub-token")) + _, err := NewClient(context.Background(), + WithCredentials(creds), + WithCredentials(creds), + withZeroKMSURL(stub.URL), + ) + if !errors.Is(err, ErrUnauthorized) { + t.Fatalf("NewClient: %v, want ErrUnauthorized from the stub", err) + } + if len(stub.requests) != 1 { + t.Fatalf("requests: %d, want one", len(stub.requests)) + } +} + +// Every Credentials constructor returns a comparable value: comparing two +// does not panic, whichever constructor made them. +func TestCredentialsAreComparable(t *testing.T) { + a := OIDCFederation("crn:a", nil) + b := OIDCFederation("crn:b", nil, stackauth.WithAuthBaseURL("https://cts.example.com")) + if a == b || AutoCredentials() != AutoCredentials() { + t.Fatal("unexpected comparison result") + } + _ = map[Credentials]bool{a: true, b: true, AutoCredentials(): true, testCredentials(staticToken("t")): true} +} + // A later WithKeysetCacheSize replaces an earlier one before anything is // checked: a negative size overridden by zero, the default, is accepted, // and the client goes on to ZeroKMS. From 6dce6e21845125ec43e984f20c32e7907d0c4e56 Mon Sep 17 00:00:00 2001 From: Lindsay Holmwood <lindsay@holmwood.id.au> Date: Wed, 30 Sep 2026 07:07:57 +1000 Subject: [PATCH 655/686] fix(go): refuse buffers the guest's i32 allocator cannot address se_alloc takes an i32, so a buffer longer than math.MaxUint32 reached the guest truncated to its low 32 bits. AllocWrite and both transports' place helpers now refuse such buffers up front, and read se_alloc's i32 result with api.DecodeU32 rather than a bare cast. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01APzsxUu9RLqJa7ua8QEszX --- languages/golang/internal/guest/call.go | 8 +++++++- languages/golang/stackauth/transport.go | 8 ++++++-- languages/golang/stackencrypt/transport.go | 8 ++++++-- 3 files changed, 19 insertions(+), 5 deletions(-) diff --git a/languages/golang/internal/guest/call.go b/languages/golang/internal/guest/call.go index 609e7bd3d..0d624af76 100644 --- a/languages/golang/internal/guest/call.go +++ b/languages/golang/internal/guest/call.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "math" "github.com/tetratelabs/wazero/api" ) @@ -47,11 +48,16 @@ type Exports struct { // AllocWrite stages data into a fresh guest buffer. func (e Exports) AllocWrite(ctx context.Context, m api.Module, data []byte) (Buf, error) { + // se_alloc takes an i32: a longer length would reach the guest + // truncated to its low 32 bits. + if uint64(len(data)) > math.MaxUint32 { + return Buf{}, errors.New("cipherstash: buffer exceeds the guest's 4 GiB address space") + } res, err := e.Alloc.Call(ctx, uint64(len(data))) if err != nil { return Buf{}, fmt.Errorf("%w: guest alloc: %w", ErrTrap, err) } - b := Buf{Ptr: uint32(res[0]), Len: uint32(len(data))} + b := Buf{Ptr: api.DecodeU32(res[0]), Len: uint32(len(data))} //nolint:gosec // bounded above if b.Ptr == 0 { return Buf{}, errors.New("cipherstash: guest allocation failed") } diff --git a/languages/golang/stackauth/transport.go b/languages/golang/stackauth/transport.go index cee5573f1..6c6e0c487 100644 --- a/languages/golang/stackauth/transport.go +++ b/languages/golang/stackauth/transport.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "io" + "math" "net/http" "sort" "strings" @@ -179,14 +180,17 @@ func placeAuth(ctx context.Context, m api.Module, ptrOut, lenOut uint32, data [] if alloc == nil { return false } + if uint64(len(data)) > math.MaxUint32 { + return false + } res, err := alloc.Call(ctx, uint64(len(data))) if err != nil || len(res) == 0 || res[0] == 0 { return false } - ptr := uint32(res[0]) + ptr := api.DecodeU32(res[0]) mem := m.Memory() return (len(data) == 0 || mem.Write(ptr, data)) && - mem.WriteUint32Le(ptrOut, ptr) && mem.WriteUint32Le(lenOut, uint32(len(data))) + mem.WriteUint32Le(ptrOut, ptr) && mem.WriteUint32Le(lenOut, uint32(len(data))) //nolint:gosec // bounded above } func parseAuthHeaders(buf []byte) http.Header { diff --git a/languages/golang/stackencrypt/transport.go b/languages/golang/stackencrypt/transport.go index c250c03cf..1429ecb58 100644 --- a/languages/golang/stackencrypt/transport.go +++ b/languages/golang/stackencrypt/transport.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "io" + "math" "net/http" "sort" "strings" @@ -239,11 +240,14 @@ func place(ctx context.Context, m api.Module, ptrOut, lenOut uint32, data []byte if alloc == nil { return false } + if uint64(len(data)) > math.MaxUint32 { + return false + } res, err := alloc.Call(ctx, uint64(len(data))) if err != nil { return false } - ptr := uint32(res[0]) + ptr := api.DecodeU32(res[0]) if ptr == 0 { return false } @@ -251,7 +255,7 @@ func place(ctx context.Context, m api.Module, ptrOut, lenOut uint32, data []byte if len(data) > 0 && !mem.Write(ptr, data) { return false } - return mem.WriteUint32Le(ptrOut, ptr) && mem.WriteUint32Le(lenOut, uint32(len(data))) + return mem.WriteUint32Le(ptrOut, ptr) && mem.WriteUint32Le(lenOut, uint32(len(data))) //nolint:gosec // bounded above } // parseHeaders decodes the guest's `name: value` line format. Malformed From 2501d4e5dff5248c3472b18e6376dbc795487d28 Mon Sep 17 00:00:00 2001 From: Lindsay Holmwood <lindsay@holmwood.id.au> Date: Wed, 30 Sep 2026 07:08:32 +1000 Subject: [PATCH 656/686] fix(go): refuse HTTP statuses the guest's i32 cannot carry A caller-supplied RoundTripper can return any status int, and the guest receives it as an i32: 1<<32+200 arrived as 200. Both transports now treat a status outside 100..999 as a transport failure. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01APzsxUu9RLqJa7ua8QEszX --- languages/golang/stackauth/transport.go | 6 ++++- languages/golang/stackencrypt/guest_test.go | 28 +++++++++++++++++++++ languages/golang/stackencrypt/transport.go | 6 ++++- 3 files changed, 38 insertions(+), 2 deletions(-) diff --git a/languages/golang/stackauth/transport.go b/languages/golang/stackauth/transport.go index 6c6e0c487..e2f077bb5 100644 --- a/languages/golang/stackauth/transport.go +++ b/languages/golang/stackauth/transport.go @@ -128,6 +128,10 @@ func (t *authTransport) send(ctx context.Context, m api.Module, return t.placeResponse(ctx, m, respHeadersPtrOut, respHeadersLenOut, respBodyPtrOut, respBodyLenOut, -1, nil, []byte(err.Error())) } defer resp.Body.Close() + // A caller's RoundTripper can return any int; the guest gets an i32. + if resp.StatusCode < 100 || resp.StatusCode > 999 { + return t.placeResponse(ctx, m, respHeadersPtrOut, respHeadersLenOut, respBodyPtrOut, respBodyLenOut, -1, nil, fmt.Appendf(nil, "invalid HTTP status %d", resp.StatusCode)) + } if status, ok := ctx.Value(authHTTPStatusKey{}).(*authHTTPStatus); ok { status.code = resp.StatusCode } @@ -143,7 +147,7 @@ func (t *authTransport) send(ctx context.Context, m api.Module, if len(responseBody) > maxAuthResponseBytes { return t.placeResponse(ctx, m, respHeadersPtrOut, respHeadersLenOut, respBodyPtrOut, respBodyLenOut, -1, nil, []byte("auth response exceeds limit")) } - return t.placeResponse(ctx, m, respHeadersPtrOut, respHeadersLenOut, respBodyPtrOut, respBodyLenOut, int32(resp.StatusCode), encodeAuthHeaders(resp.Header), responseBody) + return t.placeResponse(ctx, m, respHeadersPtrOut, respHeadersLenOut, respBodyPtrOut, respBodyLenOut, int32(resp.StatusCode), encodeAuthHeaders(resp.Header), responseBody) //nolint:gosec // range-checked above } func (t *authTransport) placeResponse(ctx context.Context, m api.Module, diff --git a/languages/golang/stackencrypt/guest_test.go b/languages/golang/stackencrypt/guest_test.go index 0ac9e4fc8..585a6aa53 100644 --- a/languages/golang/stackencrypt/guest_test.go +++ b/languages/golang/stackencrypt/guest_test.go @@ -12,6 +12,7 @@ import ( "net/http/httptest" "reflect" "sort" + "strconv" "strings" "testing" "time" @@ -323,6 +324,33 @@ func TestOversizedResponseIsTransport(t *testing.T) { } } +// A status that would wrap in the guest's i32 — here to 200 — is refused +// as a transport failure. +func TestOutOfRangeStatusIsTransport(t *testing.T) { + guestOrSkip(t) + cases := map[string]int{"negative": -200, "two digits": 99} + if strconv.IntSize == 64 { + wraps := int64(1<<32 + 200) + cases["wraps to 200"] = int(wraps) + } + for name, status := range cases { + t.Run(name, func(t *testing.T) { + cfg := testConfig("http://zerokms.invalid") + cfg = append(cfg, WithTransport(roundTripFunc(func(*http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: status, + Header: http.Header{"Content-Type": {"application/json"}}, + Body: io.NopCloser(strings.NewReader("{}")), + }, nil + }))) + _, err := NewClient(context.Background(), cfg...) + if !errors.Is(err, ErrTransport) { + t.Fatalf("NewClient: %v, want ErrTransport", err) + } + }) + } +} + // A bare empty part is an empty context, which the guest refuses at the // boundary — so the constructor refuses it first, rather than handing back // a Context that fails every call it is used in. A list is empty only when diff --git a/languages/golang/stackencrypt/transport.go b/languages/golang/stackencrypt/transport.go index 1429ecb58..7fec4e0fc 100644 --- a/languages/golang/stackencrypt/transport.go +++ b/languages/golang/stackencrypt/transport.go @@ -131,6 +131,10 @@ func (t *transport) perform(ctx context.Context, mem api.Memory, return transportFailed, nil, []byte(err.Error()) } defer resp.Body.Close() + // A caller's RoundTripper can return any int; the guest gets an i32. + if resp.StatusCode < 100 || resp.StatusCode > 999 { + return transportFailed, nil, fmt.Appendf(nil, "invalid HTTP status %d", resp.StatusCode) + } if resp.ContentLength > maxResponseBytes { return transportFailed, nil, fmt.Appendf(nil, "response of %d bytes exceeds the %d-byte limit", resp.ContentLength, maxResponseBytes) } @@ -148,7 +152,7 @@ func (t *transport) perform(ctx context.Context, mem api.Memory, wipe(respBody) return transportFailed, nil, fmt.Appendf(nil, "response exceeds the %d-byte limit", maxResponseBytes) } - return int32(resp.StatusCode), encodeHeaders(resp.Header), respBody + return int32(resp.StatusCode), encodeHeaders(resp.Header), respBody //nolint:gosec // range-checked above } // requestBody is the io.ReadCloser a guest request goes out as. It owns From 08df071edfc35c3c791bc75344784ca56af77a4d Mon Sep 17 00:00:00 2001 From: Lindsay Holmwood <lindsay@holmwood.id.au> Date: Wed, 30 Sep 2026 07:08:39 +1000 Subject: [PATCH 657/686] fix(go): refuse an expires_at beyond int64 The guest reports expires_at as a u64; converting a value past math.MaxInt64 wrapped it to a negative time. Reading the token now fails with ErrInternal instead. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01APzsxUu9RLqJa7ua8QEszX --- languages/golang/stackauth/token.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/languages/golang/stackauth/token.go b/languages/golang/stackauth/token.go index cf3fefc90..245abb1c0 100644 --- a/languages/golang/stackauth/token.go +++ b/languages/golang/stackauth/token.go @@ -2,6 +2,8 @@ package stackauth import ( "context" + "fmt" + "math" "time" "github.com/cipherstash/cipherstash-suite/bindings/go/internal/guest" @@ -56,6 +58,9 @@ func (s *ProfileStore) Token(ctx context.Context) (Token, error) { if err != nil { return Token{}, err } + if expiresAt > math.MaxInt64 { + return Token{}, fmt.Errorf("%w: expires_at %d is out of range", ErrInternal, expiresAt) + } t.ExpiresAt = time.Unix(int64(expiresAt), 0) if t.Region, err = fields.optionalText("region"); err != nil { return Token{}, err From ff95b400ba2b5cbc05021aa8cbdca68822e1c6bf Mon Sep 17 00:00:00 2001 From: Lindsay Holmwood <lindsay@holmwood.id.au> Date: Wed, 30 Sep 2026 07:08:50 +1000 Subject: [PATCH 658/686] fix(go): discard the request body Close error explicitly On the NewRequest error path the wiping request body is closed and its error ignored; say so with _ = rather than leave the result unchecked. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01APzsxUu9RLqJa7ua8QEszX --- languages/golang/stackencrypt/transport.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/languages/golang/stackencrypt/transport.go b/languages/golang/stackencrypt/transport.go index 7fec4e0fc..e9514eb4b 100644 --- a/languages/golang/stackencrypt/transport.go +++ b/languages/golang/stackencrypt/transport.go @@ -119,7 +119,7 @@ func (t *transport) perform(ctx context.Context, mem api.Memory, reqBody := newRequestBody(body) req, err := http.NewRequestWithContext(ctx, string(method), string(url), reqBody) if err != nil { - reqBody.Close() + _ = reqBody.Close() return transportFailed, nil, []byte(err.Error()) } // NewRequest only infers a length from the readers it knows; without From ffba33afcea09f20f921c45f7cb7e8432eeb7ada Mon Sep 17 00:00:00 2001 From: Lindsay Holmwood <lindsay@holmwood.id.au> Date: Wed, 30 Sep 2026 07:08:58 +1000 Subject: [PATCH 659/686] chore(go): mark the Windows allocator's unsafe as audited The Windows reservation is not Go memory: unsafe wraps VirtualAlloc's range as a slice and hands its address back to VirtualAlloc and VirtualLock. Annotate both uses for gosec's G103. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01APzsxUu9RLqJa7ua8QEszX --- languages/golang/internal/guest/memory_windows.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/languages/golang/internal/guest/memory_windows.go b/languages/golang/internal/guest/memory_windows.go index 8778a1e59..d3f2df259 100644 --- a/languages/golang/internal/guest/memory_windows.go +++ b/languages/golang/internal/guest/memory_windows.go @@ -19,10 +19,10 @@ func reserveRange(max int) ([]byte, error) { } // The reservation is not Go memory; going through unsafe.Add keeps // the conversion within what vet's unsafeptr check accepts. - return unsafe.Slice((*byte)(unsafe.Add(unsafe.Pointer(nil), base)), max), nil + return unsafe.Slice((*byte)(unsafe.Add(unsafe.Pointer(nil), base)), max), nil //nolint:gosec // audited: wraps the VirtualAlloc reservation } -func address(b []byte) uintptr { return uintptr(unsafe.Pointer(unsafe.SliceData(b))) } +func address(b []byte) uintptr { return uintptr(unsafe.Pointer(unsafe.SliceData(b))) } //nolint:gosec // audited: address for VirtualAlloc/VirtualLock func commitRange(b []byte) error { _, err := windows.VirtualAlloc(address(b), uintptr(len(b)), windows.MEM_COMMIT, windows.PAGE_READWRITE) From 36964cb8514d488b8be2128264ee2451f77578db Mon Sep 17 00:00:00 2001 From: Lindsay Holmwood <lindsay@holmwood.id.au> Date: Wed, 30 Sep 2026 07:09:05 +1000 Subject: [PATCH 660/686] chore(go): mark PackedResult's narrowing as intentional PackedResult splits a guest export's packed u64 into its pointer and length halves; the truncation to u32 is the decoding. Annotate it for gosec's G115. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01APzsxUu9RLqJa7ua8QEszX --- languages/golang/internal/guest/status.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/languages/golang/internal/guest/status.go b/languages/golang/internal/guest/status.go index 7c05a9717..29b42ef55 100644 --- a/languages/golang/internal/guest/status.go +++ b/languages/golang/internal/guest/status.go @@ -108,5 +108,5 @@ func PackedResult(packed uint64) (ptr, length uint32, err error) { if packed>>32 == 0 { return 0, 0, StatusError(uint32(packed)) } - return uint32(packed >> 32), uint32(packed), nil + return uint32(packed >> 32), uint32(packed), nil //nolint:gosec // splits the packed u64 into its two u32 halves } From 2f7728fa9f343f29bc102b64f9bd6d4e49048220 Mon Sep 17 00:00:00 2001 From: Lindsay Holmwood <lindsay@holmwood.id.au> Date: Wed, 30 Sep 2026 07:09:13 +1000 Subject: [PATCH 661/686] chore(go): mark caller-chosen file paths for gosec The profile directory, the refresh lock inside it, and the example's mounted secrets are paths the caller or operator chooses by design. Annotate them for gosec's G304 and G703. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01APzsxUu9RLqJa7ua8QEszX --- languages/golang/stackauth/lock_unix.go | 2 +- languages/golang/stackauth/lock_windows.go | 2 +- languages/golang/stackauth/store.go | 2 +- languages/golang/stackencrypt/example/explicit/main.go | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/languages/golang/stackauth/lock_unix.go b/languages/golang/stackauth/lock_unix.go index d730048a0..a8bc65c54 100644 --- a/languages/golang/stackauth/lock_unix.go +++ b/languages/golang/stackauth/lock_unix.go @@ -14,7 +14,7 @@ import ( // The lock file and flock match stack-profile's native FileLockGuard. func withRefreshLock(ctx context.Context, path string, run func() error) error { - f, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0o600) + f, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0o600) //nolint:gosec // path is inside the caller's profile directory if err != nil { return fmt.Errorf("stackauth: open refresh lock: %w", err) } diff --git a/languages/golang/stackauth/lock_windows.go b/languages/golang/stackauth/lock_windows.go index 89872822c..c327a8336 100644 --- a/languages/golang/stackauth/lock_windows.go +++ b/languages/golang/stackauth/lock_windows.go @@ -15,7 +15,7 @@ import ( // Lock the same first 2^64-1 bytes stack-profile's Windows FileLockGuard // locks. OVERLAPPED offset zero makes the range identical. func withRefreshLock(ctx context.Context, path string, run func() error) error { - f, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0o600) + f, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0o600) //nolint:gosec // path is inside the caller's profile directory if err != nil { return fmt.Errorf("stackauth: open refresh lock: %w", err) } diff --git a/languages/golang/stackauth/store.go b/languages/golang/stackauth/store.go index ab6508778..f216d8139 100644 --- a/languages/golang/stackauth/store.go +++ b/languages/golang/stackauth/store.go @@ -95,7 +95,7 @@ func Resolve(ctx context.Context, opts ...Option) (*ProfileStore, error) { // mounted as the one directory the guest can see. Nothing is read until a // method asks; nothing is written unless a method writes. func Open(ctx context.Context, dir string, opts ...Option) (*ProfileStore, error) { - info, err := os.Stat(dir) + info, err := os.Stat(dir) //nolint:gosec // the caller chooses the profile directory if err != nil { return nil, fmt.Errorf("%w: %s: %w", ErrNoProfile, dir, err) } diff --git a/languages/golang/stackencrypt/example/explicit/main.go b/languages/golang/stackencrypt/example/explicit/main.go index 7921b8613..7cc8b20d9 100644 --- a/languages/golang/stackencrypt/example/explicit/main.go +++ b/languages/golang/stackencrypt/example/explicit/main.go @@ -70,7 +70,7 @@ type secrets interface { type fileSecrets struct{ dir string } func (s fileSecrets) Get(_ context.Context, name string) ([]byte, error) { - b, err := os.ReadFile(filepath.Join(s.dir, name)) + b, err := os.ReadFile(filepath.Join(s.dir, name)) //nolint:gosec // example reads the operator's mounted secrets if err != nil { return nil, fmt.Errorf("reading secret %q: %w", name, err) } From 4fcd788b1d7c9a81f3f2a178593c864b7b27680d Mon Sep 17 00:00:00 2001 From: Lindsay Holmwood <lindsay@holmwood.id.au> Date: Wed, 30 Sep 2026 07:09:31 +1000 Subject: [PATCH 662/686] ci(go): add gosec to the Go bindings lint Enable gosec for bindings/go. Test files and internal/guesttest, which only tests import, are excluded: fixtures, math/rand and re-exec'd subprocesses are expected there. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01APzsxUu9RLqJa7ua8QEszX --- languages/golang/.golangci.yaml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/languages/golang/.golangci.yaml b/languages/golang/.golangci.yaml index a67260385..00b010b5e 100644 --- a/languages/golang/.golangci.yaml +++ b/languages/golang/.golangci.yaml @@ -5,6 +5,7 @@ linters: enable: - bodyclose - errcheck + - gosec - govet - ineffassign - staticcheck @@ -13,6 +14,12 @@ linters: presets: # Unchecked Close, Flush, fmt.Fprint* and friends. - std-error-handling + rules: + # Test fixtures, weak rand and subprocesses are fine in tests; + # guesttest is imported only by _test files. + - path: (_test\.go|internal/guesttest/) + linters: + - gosec formatters: enable: From 99b9e4af195516ffcb7326f29a9e00639cea3e52 Mon Sep 17 00:00:00 2001 From: Lindsay Holmwood <lindsay@holmwood.id.au> Date: Tue, 29 Sep 2026 15:08:08 -0700 Subject: [PATCH 663/686] test(go): pin stackauth's status and expires_at range checks The gosec pass added an HTTP status range check to stackauth's transport and an int64 range check on expires_at, but only stackencrypt's copy of the status check had a test. A status that wraps to 200 in the guest's i32, and an expires_at one past int64, now each have a test that fails if the check is removed. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TEDmxjMkNkDkCvWhxNbwzW --- languages/golang/stackauth/store_test.go | 17 +++++++++ languages/golang/stackauth/strategy_test.go | 42 +++++++++++++++++++++ 2 files changed, 59 insertions(+) diff --git a/languages/golang/stackauth/store_test.go b/languages/golang/stackauth/store_test.go index 9441fe6cc..0e5405797 100644 --- a/languages/golang/stackauth/store_test.go +++ b/languages/golang/stackauth/store_test.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "math" "os" "path/filepath" "reflect" @@ -309,6 +310,22 @@ func TestTypedReadsReturnTheFilesFields(t *testing.T) { } } +// expires_at is a u64 in the crate's file; one past int64 would wrap to a +// time in the past, so it is refused rather than read as expired. +func TestTokenExpiresAtOutOfRangeIsInternal(t *testing.T) { + ctx := context.Background() + dir, s := profile(t) + write(t, filepath.Join(dir, "workspaces", wsA, "auth.json"), + fmt.Sprintf(`{"access_token":"tok","refresh_token":"refresh","token_type":"Bearer","expires_at":%d}`, uint64(math.MaxInt64)+1)) + ws, err := s.WorkspaceStore(ctx, wsA) + if err != nil { + t.Fatal(err) + } + if _, err := ws.Token(ctx); !errors.Is(err, ErrInternal) { + t.Fatalf("Token: %v, want ErrInternal", err) + } +} + // The lock file is the crate's sibling `.<filename>.lock`, named by the // guest and mapped back to the host, never composed here; a filename that // is a path is refused before any path is built. diff --git a/languages/golang/stackauth/strategy_test.go b/languages/golang/stackauth/strategy_test.go index 407db16fb..e378fbf00 100644 --- a/languages/golang/stackauth/strategy_test.go +++ b/languages/golang/stackauth/strategy_test.go @@ -6,12 +6,14 @@ import ( "encoding/json" "errors" "fmt" + "io" "net/http" "net/http/httptest" "net/url" "os" "path/filepath" "reflect" + "strconv" "strings" "sync" "sync/atomic" @@ -579,6 +581,46 @@ func TestAuthTransportErrorWithoutAResponseNamesNoStatus(t *testing.T) { } } +type roundTripFunc func(*http.Request) (*http.Response, error) + +func (f roundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) } + +// A status that would wrap in the guest's i32 — here to 200 — is refused +// as a transport failure, so a failed exchange cannot pass as a success. +func TestOutOfRangeAuthStatusIsTransport(t *testing.T) { + guestOrSkip(t) + cases := map[string]int{"negative": -200, "two digits": 99, "four digits": 1000} + if strconv.IntSize == 64 { + wraps := int64(1<<32 + 200) + cases["wraps to 200"] = int(wraps) + } + for name, status := range cases { + t.Run(name, func(t *testing.T) { + ctx := context.Background() + rt := roundTripFunc(func(*http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: status, + Header: http.Header{"Content-Type": {"application/json"}}, + Body: io.NopCloser(strings.NewReader(`{"accessToken":"x","expiry":0}`)), + }, nil + }) + profile, err := Open(ctx, t.TempDir(), WithRoundTripper(rt)) + if err != nil { + t.Fatal(err) + } + defer profile.Close() + strategy, err := profile.AccessKey(ctx, testCRN, "CSAKtestKeyId.testKeySecret", WithAuthBaseURL("https://cts.invalid")) + if err != nil { + t.Fatal(err) + } + defer strategy.Close() + if _, err := strategy.Token(ctx); !errors.Is(err, ErrAuthTransport) { + t.Fatalf("Token: %v, want ErrAuthTransport", err) + } + }) + } +} + // A store with no profile mounted still runs the strategies that need none: // the environment's access key through Auto, as stack-auth's AutoStrategy // does with no profile store. Profile reads are ErrNoProfile, and Auto with From e67d13f95c24be8e99dce27695d45585cbf96eb9 Mon Sep 17 00:00:00 2001 From: Dan Draper <dan@cipherstash.com> Date: Sun, 27 Sep 2026 22:02:14 -0700 Subject: [PATCH 664/686] feat(go): plan.Policy maps a field's facts to a record plan Storage decisions do not belong in the schema. The schema carries facts (a field's name, kind and annotations, such as Fideslang data categories); a policy written in Go decides what each field becomes. The stackencrypt/plan package: Fact and Source (StructTags reads a Go struct's `facts` tags; a protobuf source plugs in the same way), a Policy as a pure function of a Fact to a Decision (Encrypt, Plaintext, Fail), composed with When, FirstOf and OrElse, and ForMessage scoping a policy to one message and its required Table. Message.Build turns facts into a stackencrypt.Plan; PlanFor and MustPlanFor read the facts first. An EQL target binds its column identity "<table>/<column>"; the column is the field name unless a rule pins it with Column, which keeps the context fixed across renames. A Custom target supplies its own context. A classified field no rule decides fails the build (ErrUnmatched), naming the field and its facts; unclassified fields are left out. The guest receives byte-identical input to the equivalent hand-built plan (pinned). CIP-4160 --- languages/golang/stackencrypt/README.md | 50 +++ languages/golang/stackencrypt/doc.go | 4 +- languages/golang/stackencrypt/export_test.go | 23 +- languages/golang/stackencrypt/plan/doc.go | 58 +++ languages/golang/stackencrypt/plan/fact.go | 167 +++++++++ languages/golang/stackencrypt/plan/message.go | 172 +++++++++ .../golang/stackencrypt/plan/plan_test.go | 335 ++++++++++++++++++ languages/golang/stackencrypt/plan/policy.go | 263 ++++++++++++++ .../golang/stackencrypt/policy_plan_test.go | 58 +++ 9 files changed, 1128 insertions(+), 2 deletions(-) create mode 100644 languages/golang/stackencrypt/plan/doc.go create mode 100644 languages/golang/stackencrypt/plan/fact.go create mode 100644 languages/golang/stackencrypt/plan/message.go create mode 100644 languages/golang/stackencrypt/plan/plan_test.go create mode 100644 languages/golang/stackencrypt/plan/policy.go create mode 100644 languages/golang/stackencrypt/policy_plan_test.go diff --git a/languages/golang/stackencrypt/README.md b/languages/golang/stackencrypt/README.md index cd2e48062..0c1fa7652 100644 --- a/languages/golang/stackencrypt/README.md +++ b/languages/golang/stackencrypt/README.md @@ -215,6 +215,56 @@ instance is released, and takes no context because it does no I/O. A cleanup, which covers the forgot-to-close case in a running process and nothing at exit. +## Plans from a policy + +A record plan says which fields to encrypt, under which context, with which +index terms. `stash` tags or `NewPlan` spell it out by hand. The `plan` +subpackage derives it from what the schema already says about each field +(its facts, such as Fideslang `data_categories`), through a policy written +in Go: + +```go +import "github.com/cipherstash/cipherstash-suite/bindings/go/stackencrypt/plan" + +type Individual struct { + ID int64 + Email string `facts:"fides.data_categories=user.contact.email"` + MedicareNo string `facts:"fides.data_categories=user.government_id"` +} + +var category = plan.Key("fides.data_categories") + +var Base = plan.FirstOf( + plan.When(category.Under("user.government_id"), plan.Encrypt(plan.EQL(stackencrypt.Equality))), + plan.When(category.Under("user.contact.email"), plan.Encrypt(plan.EQL(stackencrypt.Equality, stackencrypt.Match))), + plan.When(category.Under("user"), plan.Encrypt(plan.EQL())), +) + +var Individuals = plan.ForMessage(&Individual{}, plan.Table("individuals"), + plan.FirstOf( + plan.When(plan.Field("MedicareNo"), plan.Encrypt(plan.EQL(stackencrypt.Equality)), + plan.Column("medicare_number")), + ).OrElse(Base), +) + +// At startup: panics if a classified field is decided by no rule. +var individuals = plan.MustPlanFor(plan.StructTags, Individuals) + +records, err := cipher.EncryptRecords(ctx, rows, stackencrypt.WithPlan(individuals)) +``` + +A policy fails closed: a field with facts that no rule decides is an error +when the plan is built, naming the field and its facts. There is no default; +write a catch-all, `Plaintext()` included, in the policy. Fields with no +facts are left out and stored as they are. + +An EQL target's context is its column identity, `"<table>/<column>"`. The +table is required per message, never derived from its name, and the column +is the field name unless a rule pins it with `plan.Column`, which keeps the +context fixed across renames. `plan.Custom` targets supply their own +context. The plan a policy builds is a `Plan` like any other: the guest +receives the same bytes as for the equivalent hand-built plan. + ## Errors Errors are sentinel values, matched with `errors.Is`. The wasm guest diff --git a/languages/golang/stackencrypt/doc.go b/languages/golang/stackencrypt/doc.go index 3c7946c9d..3bc5dd0af 100644 --- a/languages/golang/stackencrypt/doc.go +++ b/languages/golang/stackencrypt/doc.go @@ -44,7 +44,9 @@ // to produce, and one call seals every row of a slice from batched key // requests. The same plan is a value ([Plan]): [PlanFromTags] is what the // tags parse to, [NewPlan] builds one for a struct that cannot carry tags -// (generated code), and [WithPlan] runs a record call under it. Key +// (generated code), and [WithPlan] runs a record call under it. The plan +// subpackage derives one from a field's schema facts through a policy +// written in Go. Key // requests are batched 500 keys at a time, in both directions: one request // for any ordinary value or batch, one more per 500 sealed leaves beyond // that. Terms ([EqualityTerm], [MatchTerm], [OreTerm], [OpeTerm]) are diff --git a/languages/golang/stackencrypt/export_test.go b/languages/golang/stackencrypt/export_test.go index 89029b3f9..22b844ae5 100644 --- a/languages/golang/stackencrypt/export_test.go +++ b/languages/golang/stackencrypt/export_test.go @@ -1,6 +1,11 @@ package stackencrypt -import "context" +import ( + "context" + "reflect" + + "github.com/cipherstash/vitaminc/bindings/go/vcffi" +) // The test-only ways to give a client a token. The public API takes tokens // only from stackauth strategies; the tests that talk to httptest stubs @@ -30,3 +35,19 @@ func newTestCredentials(clientID string, key *ClientKey, token tokenSource) Cred func withZeroKMSURL(url string) ClientOption { return func(o *clientOptions) { o.zerokmsURL = url } } + +// GuestPlanInput is the encoded plan object a record call over t sends the +// guest under p, each context extended by ext: what the external tests +// compare byte for byte. Test-only; not part of the package's API. +func GuestPlanInput(p Plan, t reflect.Type, ext ...any) ([]byte, error) { + o := applyOptions([]RecordOption{WithPlan(p), ExtendContext(ext...)}) + bound, err := planFor(t, o) + if err != nil { + return nil, err + } + obj, err := planValue(bound, o) + if err != nil { + return nil, err + } + return vcffi.Marshal(obj) +} diff --git a/languages/golang/stackencrypt/plan/doc.go b/languages/golang/stackencrypt/plan/doc.go new file mode 100644 index 000000000..d7ef687dc --- /dev/null +++ b/languages/golang/stackencrypt/plan/doc.go @@ -0,0 +1,58 @@ +// Package plan builds a record [stackencrypt.Plan] from what a domain +// schema already says about its fields, through a policy written in Go. +// +// Storage decisions do not belong in the schema. The schema carries facts — +// a field's name, its kind, and its annotations, such as Fideslang +// `data_categories` — and a [Policy], a pure function of a field's [Fact], +// decides what becomes of it: [Encrypt] into a [Target], [Plaintext], or +// [Fail]. Policies are ordinary values and compose: [When] is a rule, +// [FirstOf] takes the first rule that matches, and [Policy.OrElse] refines a +// shared base per message. +// +// var category = plan.Key("fides.data_categories") +// +// var Base = plan.FirstOf( +// plan.When(category.Under("user.government_id"), plan.Encrypt(plan.EQL(stackencrypt.Equality))), +// plan.When(category.Under("user.contact.email"), plan.Encrypt(plan.EQL(stackencrypt.Equality, stackencrypt.Match))), +// plan.When(category.Under("user"), plan.Encrypt(plan.EQL())), +// ) +// +// var Individuals = plan.ForMessage(&Individual{}, plan.Table("individuals"), +// plan.FirstOf( +// plan.When(plan.Field("MedicareNo"), plan.Encrypt(plan.EQL(stackencrypt.Equality)), +// plan.Column("medicare_number")), +// ).OrElse(Base), +// ) +// +// var individuals = plan.MustPlanFor(plan.StructTags, Individuals) +// // cipher.EncryptRecords(ctx, rows, stackencrypt.WithPlan(individuals)) +// +// # Facts +// +// A [Source] makes the facts for a message. [StructTags] reads a Go +// struct's `facts` tags; a protobuf source reads descriptors and their +// custom options the same way, and needs nothing from this package beyond +// [Fact], [Source] and [Key]. [Message.Build] takes facts directly, so a +// generator or a test can build a plan without a source at all. +// +// # Contexts +// +// A field's context is its AAD, its ZeroKMS data-key binding and its terms' +// PRF context, fixed when data is first written. For an [EQL] target the +// context is the column identity, "<table>/<column>" ([Identifier]): the +// table is the message's [Table], which is required and never derived from +// the message's name, and the column is the field's schema name unless the +// rule pins another with [Column] — the column's identity, which survives +// renames of the field and of the database column. A [Custom] target +// supplies its context itself. +// +// # Failing closed +// +// A field with annotations that no rule decides is an error when the plan +// is built ([ErrUnmatched]), naming the field and its annotations; there is +// no built-in default, so a catch-all, Plaintext included, is written in the +// policy. A field with no annotations that no rule names is not the +// policy's concern: it is left out of the plan and stored as it is. Build +// plans at startup with [MustPlanFor], so a gap stops the process before it +// writes anything. +package plan diff --git a/languages/golang/stackencrypt/plan/fact.go b/languages/golang/stackencrypt/plan/fact.go new file mode 100644 index 000000000..dcca1aaa8 --- /dev/null +++ b/languages/golang/stackencrypt/plan/fact.go @@ -0,0 +1,167 @@ +package plan + +import ( + "errors" + "fmt" + "reflect" + "slices" + "strings" +) + +// Fact is what the SDK knows about one field of a message: where it is, +// what it is, and the annotations the domain schema put on it. It is +// source-agnostic. A [Source] makes facts from something that describes a +// message (a protobuf descriptor, a Go struct type); a policy reads them +// and never learns where they came from. +type Fact struct { + // Message is the message's (or struct's) full name, for errors. + Message string + // Field is the field's schema name: the proto field name, or the Go + // field name for a struct. It is the column a field encrypts into + // unless a rule pins another ([Column]). + Field string + // GoField is the Go struct field the plan binds to. Field when empty. + GoField string + // Number is the proto field number; 0 when the source has none. + Number int32 + // Kind is the field's kind in the source's own spelling ("string", + // "int64", ...); informational, for rules that match on it. + Kind string + // Annotations are the facts proper: the domain schema's classification + // of the field, such as its Fideslang data categories. A field with no + // annotations is not the policy's concern unless a rule names it. + Annotations []Annotation +} + +// Annotation is one annotation on a field: a key and its values. For a +// protobuf extension the key is the extension's full name and the values +// are its (repeated) string values. +type Annotation struct { + Key string + Values []string +} + +// Values returns the values of every annotation on the field under key, +// in order. +func (f Fact) Values(key string) []string { + var out []string + for _, a := range f.Annotations { + if a.Key == key { + out = append(out, a.Values...) + } + } + return out +} + +// goField is the Go struct field the fact binds to. +func (f Fact) goField() string { + if f.GoField != "" { + return f.GoField + } + return f.Field +} + +// String names the field and its annotations, the way errors name them. +func (f Fact) String() string { + var b strings.Builder + if f.Message != "" { + b.WriteString(f.Message) + b.WriteByte('.') + } + b.WriteString(f.Field) + if len(f.Annotations) > 0 { + b.WriteString(" [") + for i, a := range f.Annotations { + if i > 0 { + b.WriteString("; ") + } + fmt.Fprintf(&b, "%s=%s", a.Key, strings.Join(a.Values, ",")) + } + b.WriteByte(']') + } + return b.String() +} + +// Source makes the facts for a message. msg is whatever [ForMessage] was +// given: a struct value or pointer for [StructTags], a proto.Message for a +// protobuf source. Facts are returned in field order. +type Source interface { + Facts(msg any) ([]Fact, error) +} + +// SourceFunc adapts a function to a [Source]. +type SourceFunc func(msg any) ([]Fact, error) + +// Facts calls f. +func (f SourceFunc) Facts(msg any) ([]Fact, error) { return f(msg) } + +// StructTags is the Go-struct fact source: one fact per exported, direct +// field of a struct (msg is a struct value or a pointer to one), with the +// annotations its `facts` tag lists: +// +// type Individual struct { +// ID int64 +// Email string `facts:"fides.data_categories=user.contact.email"` +// MedicareNo string `facts:"fides.data_categories=user.government_id,user.financial"` +// } +// +// The tag is `key=value[,value...]`, repeated with `;` for more keys. Field +// and GoField are the Go field name, Number is 0 and Kind is the field's +// reflect.Kind (through one pointer). Unexported and embedded fields are +// skipped: a plan binds exported, direct fields only. +var StructTags Source = SourceFunc(structFacts) + +func structFacts(msg any) ([]Fact, error) { + t := reflect.TypeOf(msg) + if t != nil && t.Kind() == reflect.Pointer { + t = t.Elem() + } + if t == nil || t.Kind() != reflect.Struct { + return nil, fmt.Errorf("plan: StructTags reads structs, not %T", msg) + } + facts := make([]Fact, 0, t.NumField()) + for i := 0; i < t.NumField(); i++ { + sf := t.Field(i) + if !sf.IsExported() || sf.Anonymous { + continue + } + kind := sf.Type + if kind.Kind() == reflect.Pointer { + kind = kind.Elem() + } + annotations, err := parseFactsTag(sf.Tag.Get("facts")) + if err != nil { + return nil, fmt.Errorf("plan: %s.%s: %w", t, sf.Name, err) + } + facts = append(facts, Fact{ + Message: t.String(), + Field: sf.Name, + GoField: sf.Name, + Kind: kind.Kind().String(), + Annotations: annotations, + }) + } + return facts, nil +} + +func parseFactsTag(tag string) ([]Annotation, error) { + if tag == "" { + return nil, nil + } + var out []Annotation + for _, part := range strings.Split(tag, ";") { + key, values, ok := strings.Cut(part, "=") + if !ok || key == "" || values == "" { + return nil, fmt.Errorf("facts tag %q: want key=value[,value...]", part) + } + if slices.ContainsFunc(out, func(a Annotation) bool { return a.Key == key }) { + return nil, fmt.Errorf("facts tag: key %q given twice", key) + } + vs := strings.Split(values, ",") + if slices.Contains(vs, "") { + return nil, errors.New("facts tag: empty value for " + key) + } + out = append(out, Annotation{Key: key, Values: vs}) + } + return out, nil +} diff --git a/languages/golang/stackencrypt/plan/message.go b/languages/golang/stackencrypt/plan/message.go new file mode 100644 index 000000000..22b468305 --- /dev/null +++ b/languages/golang/stackencrypt/plan/message.go @@ -0,0 +1,172 @@ +package plan + +import ( + "errors" + "fmt" + "strings" + + "github.com/cipherstash/cipherstash-suite/bindings/go/stackencrypt" +) + +var ( + // ErrUnmatched is a field that carries annotations no rule of the + // policy decides. There is no built-in default: a catch-all, even + // Plaintext, must be written in the policy. + ErrUnmatched = errors.New("no rule decides the field") + // ErrRefused is a field the policy decided to [Fail]. + ErrRefused = errors.New("the policy refuses the field") + // ErrInvalid is a decision that cannot become a plan field: no target, + // a pinned column on a field that is not encrypted, an empty context or + // column identity. + ErrInvalid = errors.New("invalid decision") +) + +// Table names the table a message's records are stored in: the first half +// of every EQL field's column identity. It is required, and never derived +// from the message's name, because it is part of every context and a +// guess (a pluralisation) would be permanent. +type Table string + +// Message is a policy for one message type: the message, its table, and +// the rules its fields are decided by. A Message is a value; build it once +// (a package-level var) and build its plan at startup with [MustPlanFor]. +type Message struct { + msg any + table Table + policy Policy +} + +// ForMessage scopes policy to msg, stored in table. msg is what the +// [Source] reads facts from: a struct value or pointer for [StructTags], a +// proto.Message for a protobuf source. Refine a shared base per message +// with OrElse: +// +// var Individuals = plan.ForMessage(&Individual{}, plan.Table("individuals"), +// plan.FirstOf( +// plan.When(plan.Field("MedicareNo"), plan.Encrypt(plan.EQL(stackencrypt.Equality)), +// plan.Column("medicare_number")), +// ).OrElse(Base), +// ) +func ForMessage(msg any, table Table, policy Policy) Message { + return Message{msg: msg, table: table, policy: policy} +} + +// Msg returns the message the policy is for. +func (m Message) Msg() any { return m.msg } + +// Table returns the message's table. +func (m Message) Table() Table { return m.table } + +// Decide runs the message's policy on one field. +func (m Message) Decide(f Fact) (Decision, bool) { return m.policy.Decide(f) } + +// Build decides every field of facts and returns the plan: one field per +// Encrypt decision, in fact order. A field no rule matches is plaintext +// when it has no annotations (not the policy's concern) and ErrUnmatched +// when it has any; a Fail decision is ErrRefused. Every failing field is +// reported, not only the first, each naming the field and its facts. +// +// Pure: no I/O, no client. Build is what [PlanFor] runs after reading the +// facts, and what a generator or a golden test runs on facts it holds. +func (m Message) Build(facts []Fact) (stackencrypt.Plan, error) { + if m.table == "" { + return stackencrypt.Plan{}, fmt.Errorf("plan: %s: a message needs a Table", messageName(m, facts)) + } + if strings.Contains(string(m.table), "/") { + return stackencrypt.Plan{}, fmt.Errorf("plan: %s: table %q contains '/', which would make its column identities ambiguous", messageName(m, facts), m.table) + } + var fields []stackencrypt.FieldPlan + var errs []error + for _, f := range facts { + fp, planned, err := m.field(f) + if err != nil { + errs = append(errs, fmt.Errorf("plan: %s: %w", f, err)) + continue + } + if planned { + fields = append(fields, fp) + } + } + if len(errs) > 0 { + return stackencrypt.Plan{}, errors.Join(errs...) + } + p, err := stackencrypt.NewPlan(fields...) + if err != nil { + return stackencrypt.Plan{}, fmt.Errorf("plan: %s: %w", messageName(m, facts), err) + } + return p, nil +} + +// field decides one field: its plan field, and whether it is planned. +func (m Message) field(f Fact) (stackencrypt.FieldPlan, bool, error) { + d, ok := m.policy.Decide(f) + if !ok { + if len(f.Annotations) == 0 { + return stackencrypt.FieldPlan{}, false, nil + } + return stackencrypt.FieldPlan{}, false, ErrUnmatched + } + switch d.verdict { + case encrypt: + case plaintext: + if d.column != "" { + return stackencrypt.FieldPlan{}, false, fmt.Errorf("%w: Column(%q) pinned on a Plaintext field", ErrInvalid, d.column) + } + return stackencrypt.FieldPlan{}, false, nil + case fail: + return stackencrypt.FieldPlan{}, false, fmt.Errorf("%w: %s", ErrRefused, d.reason) + default: + return stackencrypt.FieldPlan{}, false, fmt.Errorf("%w: the zero Decision", ErrInvalid) + } + if d.target == nil { + return stackencrypt.FieldPlan{}, false, fmt.Errorf("%w: Encrypt with no target", ErrInvalid) + } + column := f.Field + if d.column != "" { + column = d.column + } + if column == "" || strings.Contains(column, "/") { + return stackencrypt.FieldPlan{}, false, fmt.Errorf("%w: column %q must be non-empty and contain no '/'", ErrInvalid, column) + } + context := d.target.Context(Identifier{Table: string(m.table), Column: column}) + if context == "" { + return stackencrypt.FieldPlan{}, false, fmt.Errorf("%w: target %v gives an empty context", ErrInvalid, d.target) + } + return stackencrypt.FieldPlan{ + Field: f.goField(), + Name: column, + Context: context, + Terms: d.target.Terms(), + }, true, nil +} + +func messageName(m Message, facts []Fact) string { + if len(facts) > 0 && facts[0].Message != "" { + return facts[0].Message + } + return fmt.Sprintf("%T", m.msg) +} + +// PlanFor reads m's facts from src and builds its plan; see +// [Message.Build]. +func PlanFor(src Source, m Message) (stackencrypt.Plan, error) { + if src == nil { + return stackencrypt.Plan{}, errors.New("plan: PlanFor needs a Source") + } + facts, err := src.Facts(m.msg) + if err != nil { + return stackencrypt.Plan{}, err + } + return m.Build(facts) +} + +// MustPlanFor is [PlanFor] for startup: it panics when the policy does not +// decide every classified field, so a policy gap stops the process before +// it writes anything. +func MustPlanFor(src Source, m Message) stackencrypt.Plan { + p, err := PlanFor(src, m) + if err != nil { + panic(err) + } + return p +} diff --git a/languages/golang/stackencrypt/plan/plan_test.go b/languages/golang/stackencrypt/plan/plan_test.go new file mode 100644 index 000000000..348fefc5f --- /dev/null +++ b/languages/golang/stackencrypt/plan/plan_test.go @@ -0,0 +1,335 @@ +package plan_test + +import ( + "errors" + "reflect" + "strings" + "testing" + + se "github.com/cipherstash/cipherstash-suite/bindings/go/stackencrypt" + "github.com/cipherstash/cipherstash-suite/bindings/go/stackencrypt/plan" +) + +var category = plan.Key("fides.data_categories") + +var base = plan.FirstOf( + plan.When(category.Under("user.government_id"), plan.Encrypt(plan.EQL(se.Equality))), + plan.When(category.Under("user.contact.email"), plan.Encrypt(plan.EQL(se.Equality, se.Match))), + plan.When(category.Under("user"), plan.Encrypt(plan.EQL())), +) + +type individual struct { + ID int64 + Email string `facts:"fides.data_categories=user.contact.email"` + Name string `facts:"fides.data_categories=user.name"` + MedicareNo string `facts:"fides.data_categories=user.government_id"` + Country string `facts:"fides.data_categories=system.operations"` +} + +var individuals = plan.ForMessage(&individual{}, plan.Table("individuals"), + plan.FirstOf( + plan.When(plan.Field("MedicareNo"), plan.Encrypt(plan.EQL(se.Equality, se.Ore)), plan.Column("medicare_number")), + plan.When(category.Under("system"), plan.Plaintext()), + ).OrElse(base), +) + +func TestPolicyBuildsThePlan(t *testing.T) { + p, err := plan.PlanFor(plan.StructTags, individuals) + if err != nil { + t.Fatal(err) + } + want := []se.FieldPlan{ + {Field: "Email", Name: "Email", Context: "individuals/Email", Terms: []se.TermKind{se.Equality, se.Match}}, + {Field: "Name", Name: "Name", Context: "individuals/Name"}, + // The per-message rule wins over the base's government_id rule. + {Field: "MedicareNo", Name: "medicare_number", Context: "individuals/medicare_number", Terms: []se.TermKind{se.Equality, se.Ore}}, + } + if got := p.Fields(); !reflect.DeepEqual(got, want) { + t.Fatalf("fields =\n%+v\nwant\n%+v", got, want) + } +} + +// A classified field no rule decides fails the build, naming the field and +// its facts, and every such field is reported. +func TestUnmatchedFactFailsTheBuild(t *testing.T) { + type patient struct { + ID int64 + Fingerprint []byte `facts:"fides.data_categories=user.biometric.fingerprint"` + Diagnosis string `facts:"fides.data_categories=user.health"` + Email string `facts:"fides.data_categories=user.contact.email"` + } + narrow := plan.FirstOf( + plan.When(category.Under("user.contact"), plan.Encrypt(plan.EQL(se.Equality))), + ) + m := plan.ForMessage(patient{}, "patients", narrow) + _, err := plan.PlanFor(plan.StructTags, m) + if !errors.Is(err, plan.ErrUnmatched) { + t.Fatalf("err = %v, want ErrUnmatched", err) + } + for _, want := range []string{ + "Fingerprint", "user.biometric.fingerprint", + "Diagnosis", "user.health", + } { + if !strings.Contains(err.Error(), want) { + t.Errorf("error %q does not name %q", err, want) + } + } + if strings.Contains(err.Error(), "Email") || strings.Contains(err.Error(), ".ID") { + t.Errorf("error %q names a field that was decided or unclassified", err) + } + func() { + defer func() { + if r := recover(); r == nil { + t.Error("MustPlanFor did not panic on an unmatched fact") + } + }() + plan.MustPlanFor(plan.StructTags, m) + }() + + // A catch-all written in the policy closes the gap; Plaintext counts. + closed := plan.ForMessage(patient{}, "patients", narrow.OrElse( + plan.When(category.Present(), plan.Plaintext()), + )) + p := plan.MustPlanFor(plan.StructTags, closed) + if got := p.Fields(); len(got) != 1 || got[0].Field != "Email" { + t.Fatalf("fields = %+v, want Email only", got) + } +} + +// An unclassified field is not the policy's concern, but a rule may still +// name it. +func TestUnclassifiedFieldsAreLeftOutUnlessNamed(t *testing.T) { + facts := []plan.Fact{ + {Message: "acme.v1.Individual", Field: "id", GoField: "Id", Number: 1, Kind: "int64"}, + {Message: "acme.v1.Individual", Field: "notes", GoField: "Notes", Number: 2, Kind: "string"}, + } + m := plan.ForMessage(nil, "individuals", plan.When(plan.Field("notes"), plan.Encrypt(plan.EQL()))) + p, err := m.Build(facts) + if err != nil { + t.Fatal(err) + } + want := []se.FieldPlan{{Field: "Notes", Name: "notes", Context: "individuals/notes"}} + if got := p.Fields(); !reflect.DeepEqual(got, want) { + t.Fatalf("fields = %+v, want %+v", got, want) + } +} + +// A context is fixed at first write. Pinning the column keeps it through a +// field rename (proto or Go) and is the identity a database rename keeps. +func TestColumnPinSurvivesRenames(t *testing.T) { + gov := []plan.Annotation{{Key: "fides.data_categories", Values: []string{"user.government_id"}}} + before := []plan.Fact{{Message: "acme.v1.Individual", Field: "medicare_number", GoField: "MedicareNumber", Annotations: gov}} + after := []plan.Fact{{Message: "acme.v1.Individual", Field: "medicare_no", GoField: "MedicareNo", Annotations: gov}} + + v1 := plan.ForMessage(nil, "individuals", base) + v2 := plan.ForMessage(nil, "individuals", plan.FirstOf( + plan.When(plan.Field("medicare_no"), plan.Encrypt(plan.EQL(se.Equality)), plan.Column("medicare_number")), + ).OrElse(base)) + + p1, err := v1.Build(before) + if err != nil { + t.Fatal(err) + } + p2, err := v2.Build(after) + if err != nil { + t.Fatal(err) + } + f1, f2 := p1.Fields()[0], p2.Fields()[0] + if f1.Context != "individuals/medicare_number" || f2.Context != f1.Context { + t.Fatalf("contexts %q, %q: want both individuals/medicare_number", f1.Context, f2.Context) + } + if f2.Name != "medicare_number" || f2.Field != "MedicareNo" { + t.Fatalf("pinned field = %+v", f2) + } + // Without the pin, the rename would have changed the context. + unpinned, err := v1.Build(after) + if err != nil { + t.Fatal(err) + } + if got := unpinned.Fields()[0].Context; got != "individuals/medicare_no" { + t.Fatalf("unpinned context = %q", got) + } +} + +func TestContextsByTarget(t *testing.T) { + facts := []plan.Fact{ + {Field: "email", GoField: "Email", Annotations: []plan.Annotation{{Key: "k", Values: []string{"eql"}}}}, + {Field: "blob", GoField: "Blob", Annotations: []plan.Annotation{{Key: "k", Values: []string{"custom"}}}}, + } + k := plan.Key("k") + m := plan.ForMessage(nil, "users", plan.FirstOf( + plan.When(k.Is("eql"), plan.Encrypt(plan.EQL(se.Equality))), + plan.When(k.Is("custom"), plan.Encrypt(plan.Custom("tenant-blobs/v1", se.Ope)), plan.Column("blob_v1")), + )) + p, err := m.Build(facts) + if err != nil { + t.Fatal(err) + } + want := []se.FieldPlan{ + {Field: "Email", Name: "email", Context: "users/email", Terms: []se.TermKind{se.Equality}}, + // A custom target's context is its own; the pin names the record key only. + {Field: "Blob", Name: "blob_v1", Context: "tenant-blobs/v1", Terms: []se.TermKind{se.Ope}}, + } + if got := p.Fields(); !reflect.DeepEqual(got, want) { + t.Fatalf("fields =\n%+v\nwant\n%+v", got, want) + } +} + +func TestBuildRefusesMalformedDecisions(t *testing.T) { + classified := []plan.Annotation{{Key: "k", Values: []string{"v"}}} + fact := []plan.Fact{{Field: "a", Annotations: classified}} + for name, tc := range map[string]struct { + table plan.Table + policy plan.Policy + want error + }{ + "no table": {"", plan.When(plan.Field("a"), plan.Encrypt(plan.EQL())), nil}, + "slash in table": {"a/b", plan.When(plan.Field("a"), plan.Encrypt(plan.EQL())), nil}, + "slash in column": {"t", plan.When(plan.Field("a"), plan.Encrypt(plan.EQL()), plan.Column("x/y")), plan.ErrInvalid}, + "fail": {"t", plan.When(plan.Field("a"), plan.Fail("biometrics are never stored")), plan.ErrRefused}, + "nil target": {"t", plan.When(plan.Field("a"), plan.Encrypt(nil)), plan.ErrInvalid}, + "column on plain": {"t", plan.When(plan.Field("a"), plan.Plaintext(), plan.Column("c")), plan.ErrInvalid}, + "zero decision": {"t", plan.When(plan.Field("a"), plan.Decision{}), plan.ErrInvalid}, + "empty context": {"t", plan.When(plan.Field("a"), plan.Encrypt(plan.Custom(""))), plan.ErrInvalid}, + "nil policy": {"t", nil, plan.ErrUnmatched}, + "nothing encrypted": {"t", plan.When(plan.Field("a"), plan.Plaintext()), nil}, + "term kind unknown": {"t", plan.When(plan.Field("a"), plan.Encrypt(plan.EQL(se.TermKind(9)))), nil}, + "nil policies skip": {"t", plan.FirstOf(nil, nil), plan.ErrUnmatched}, + "fail names reason": {"t", plan.When(plan.Field("a"), plan.Fail("no biometrics")), plan.ErrRefused}, + "matcher composites": {"t", plan.When(plan.All(plan.Field("a"), plan.Not(plan.Kind("string"))), plan.Fail("x")), plan.ErrRefused}, + } { + _, err := plan.ForMessage(nil, tc.table, tc.policy).Build(fact) + if err == nil { + t.Errorf("%s: built", name) + continue + } + if tc.want != nil && !errors.Is(err, tc.want) { + t.Errorf("%s: err = %v, want %v", name, err, tc.want) + } + } + _, err := plan.ForMessage(nil, "t", plan.When(plan.Field("a"), plan.Fail("no biometrics"))).Build(fact) + if !strings.Contains(err.Error(), "no biometrics") { + t.Errorf("fail error %q does not carry its reason", err) + } + // Two fields pinned to one column are one record name twice. + two := []plan.Fact{{Field: "a", Annotations: classified}, {Field: "b", Annotations: classified}} + if _, err := plan.ForMessage(nil, "t", plan.When(plan.Any(plan.Field("a"), plan.Field("b")), plan.Encrypt(plan.EQL()), plan.Column("c"))).Build(two); err == nil { + t.Error("two fields pinned to one column built") + } +} + +func TestKeyMatchers(t *testing.T) { + f := plan.Fact{Annotations: []plan.Annotation{ + {Key: "fides.data_categories", Values: []string{"user.contactless", "user.contact.email"}}, + {Key: "other", Values: []string{"x"}}, + }} + for m, want := range map[string]bool{ + "user": true, + "user.contact": true, + "user.contact.email": true, + "user.contact.email.work": false, + "user.contac": false, + "system": false, + } { + if got := category.Under(m)(f); got != want { + t.Errorf("Under(%q) = %v, want %v", m, got, want) + } + } + if !category.Is("user.contactless")(f) || category.Is("user")(f) { + t.Error("Is matches other than exactly") + } + if !plan.Key("other").Present()(f) || plan.Key("absent").Present()(f) { + t.Error("Present") + } + if got := f.Values("other"); !reflect.DeepEqual(got, []string{"x"}) { + t.Errorf("Values = %v", got) + } +} + +func TestDecisionsSpellThemselves(t *testing.T) { + d, ok := plan.When(plan.Field("a"), plan.Encrypt(plan.EQL(se.Equality, se.Match)), plan.Column("c")).Decide(plan.Fact{Field: "a"}) + if !ok { + t.Fatal("no match") + } + if got, want := d.String(), `Encrypt(EQL(eq, match)) Column("c")`; got != want { + t.Errorf("String = %s, want %s", got, want) + } + if target, ok := d.Target(); !ok || target == nil || d.Column() != "c" { + t.Errorf("accessors: %v %v %q", target, ok, d.Column()) + } + for d, want := range map[string]string{ + plan.Plaintext().String(): "Plaintext()", + plan.Fail("no").String(): `Fail("no")`, + plan.Encrypt(plan.Custom("ctx", se.Ope)).String(): `Encrypt(Custom("ctx", ope))`, + plan.Encrypt(plan.Custom("ctx")).String(): `Encrypt(Custom("ctx"))`, + plan.Decision{}.String(): "Decision{}", + } { + if d != want { + t.Errorf("String = %s, want %s", d, want) + } + } + if _, ok := plan.Plaintext().Target(); ok { + t.Error("Plaintext has a target") + } +} + +func TestStructTagsSource(t *testing.T) { + type embedded struct{ Inner string } + type row struct { + embedded + ID *int64 + Email string `facts:"a=x,y;b=z"` + hidden string //nolint:unused // proves unexported fields are skipped + } + facts, err := plan.StructTags.Facts(&row{}) + if err != nil { + t.Fatal(err) + } + want := []plan.Fact{ + {Message: "plan_test.row", Field: "ID", GoField: "ID", Kind: "int64"}, + {Message: "plan_test.row", Field: "Email", GoField: "Email", Kind: "string", Annotations: []plan.Annotation{ + {Key: "a", Values: []string{"x", "y"}}, {Key: "b", Values: []string{"z"}}, + }}, + } + if !reflect.DeepEqual(facts, want) { + t.Fatalf("facts =\n%+v\nwant\n%+v", facts, want) + } + if got := facts[1].String(); got != "plan_test.row.Email [a=x,y; b=z]" { + t.Errorf("String = %s", got) + } + for name, bad := range map[string]any{ + "not a struct": 42, + "nil": nil, + "no value": struct { + A string `facts:"a="` + }{}, + "no key": struct { + A string `facts:"=x"` + }{}, + "empty value": struct { + A string `facts:"a=x,"` + }{}, + "key twice": struct { + A string `facts:"a=x;a=y"` + }{}, + } { + if _, err := plan.StructTags.Facts(bad); err == nil { + t.Errorf("%s: facts read", name) + } + } + if _, err := plan.PlanFor(nil, individuals); err == nil { + t.Error("PlanFor without a source") + } + if individuals.Table() != "individuals" || individuals.Msg() == nil { + t.Error("Message accessors") + } +} + +func TestWhenRefusesANilMatcher(t *testing.T) { + defer func() { + if recover() == nil { + t.Error("When(nil, ...) did not panic") + } + }() + plan.When(nil, plan.Plaintext()) +} diff --git a/languages/golang/stackencrypt/plan/policy.go b/languages/golang/stackencrypt/plan/policy.go new file mode 100644 index 000000000..9be4d4112 --- /dev/null +++ b/languages/golang/stackencrypt/plan/policy.go @@ -0,0 +1,263 @@ +package plan + +import ( + "fmt" + "slices" + "strings" + + "github.com/cipherstash/cipherstash-suite/bindings/go/stackencrypt" +) + +// Identifier is a field's column identity: the table its message is +// stored in and the column it encrypts into. For an EQL target it is the +// field's encryption context, so it is fixed at first write and must never +// change — which is why the table is given, never derived from a message +// name, and why a rule can pin the column ([Column]) across renames. +type Identifier struct { + Table string + Column string +} + +// String is the context an EQL target binds: "<table>/<column>", the +// shape the Rust derive gives a `#[stash(struct = T, context = "<table>")]` +// field. +func (id Identifier) String() string { return id.Table + "/" + id.Column } + +// Target is what an encrypted field is stored as: the index terms derived +// beside its ciphertext, and the context it binds. The context is the +// AAD, the ZeroKMS data-key binding and the terms' PRF context at once. +type Target interface { + // Terms lists the index terms to derive, in order. + Terms() []stackencrypt.TermKind + // Context returns the field's context given its column identity. An + // EQL target returns id.String(); a custom target returns its own. + Context(id Identifier) string +} + +// EQL is an EQL column target: the field binds its column identity +// ([Identifier.String]) as its context and derives the given terms. Typed +// EQL targets (a text-with-equality column, say) are this with the terms +// filled in, and implement [Target] the same way. +func EQL(terms ...stackencrypt.TermKind) Target { + return eqlTarget{terms: slices.Clone(terms)} +} + +type eqlTarget struct{ terms []stackencrypt.TermKind } + +func (t eqlTarget) Terms() []stackencrypt.TermKind { return slices.Clone(t.terms) } +func (t eqlTarget) Context(id Identifier) string { return id.String() } +func (t eqlTarget) String() string { return "EQL(" + termList(t.terms) + ")" } + +// Custom is a non-EQL target: the field binds context, whatever its +// column, and derives the given terms. The context need not be +// table/column shaped; it is the policy's to choose and, like any context, +// must never change once data is written under it. +func Custom(context string, terms ...stackencrypt.TermKind) Target { + return customTarget{context: context, terms: slices.Clone(terms)} +} + +type customTarget struct { + context string + terms []stackencrypt.TermKind +} + +func (t customTarget) Terms() []stackencrypt.TermKind { return slices.Clone(t.terms) } +func (t customTarget) Context(Identifier) string { return t.context } +func (t customTarget) String() string { + return fmt.Sprintf("Custom(%q%s)", t.context, prefixed(termList(t.terms))) +} + +func termList(terms []stackencrypt.TermKind) string { + names := make([]string, len(terms)) + for i, k := range terms { + names[i] = k.String() + } + return strings.Join(names, ", ") +} + +func prefixed(s string) string { + if s == "" { + return "" + } + return ", " + s +} + +type verdict uint8 + +const ( + encrypt verdict = iota + 1 + plaintext + fail +) + +// Decision is what a policy decides for one field: encrypt it into a +// target, leave it plaintext, or refuse it. Build one with [Encrypt], +// [Plaintext] or [Fail]. +type Decision struct { + verdict verdict + target Target + reason string + column string +} + +// Encrypt decides that the field is encrypted into target. +func Encrypt(target Target) Decision { return Decision{verdict: encrypt, target: target} } + +// Plaintext decides that the field is stored as it is: not part of the +// plan, never sent to the guest. +func Plaintext() Decision { return Decision{verdict: plaintext} } + +// Fail decides that the field must not be planned at all: building the +// plan fails, naming the field and reason. +func Fail(reason string) Decision { return Decision{verdict: fail, reason: reason} } + +// Target returns the decision's target, and whether it encrypts at all. +func (d Decision) Target() (Target, bool) { return d.target, d.verdict == encrypt } + +// Column returns the column the decision pins, or "" for the field's own. +func (d Decision) Column() string { return d.column } + +// String spells the decision for tests and errors. +func (d Decision) String() string { + var s string + switch d.verdict { + case encrypt: + s = fmt.Sprintf("Encrypt(%v)", d.target) + case plaintext: + s = "Plaintext()" + case fail: + s = fmt.Sprintf("Fail(%q)", d.reason) + default: + return "Decision{}" + } + if d.column != "" { + s += fmt.Sprintf(" Column(%q)", d.column) + } + return s +} + +// Policy maps a field's facts to a decision, or reports no match (false). +// It is a pure function — no I/O, no client — so a policy is tested by +// calling it on hand-built facts. Policies compose with [FirstOf] and +// [Policy.OrElse]; [When] is the leaf. +type Policy func(Fact) (Decision, bool) + +// Decide runs the policy on one field. A nil policy matches nothing. +func (p Policy) Decide(f Fact) (Decision, bool) { + if p == nil { + return Decision{}, false + } + return p(f) +} + +// OrElse is p, falling back to q for the fields p does not match: the +// per-message refinement over a base policy. +func (p Policy) OrElse(q Policy) Policy { return FirstOf(p, q) } + +// FirstOf is the first of policies that matches a field, in order. Nil +// policies are skipped. +func FirstOf(policies ...Policy) Policy { + policies = slices.Clone(policies) + return func(f Fact) (Decision, bool) { + for _, p := range policies { + if d, ok := p.Decide(f); ok { + return d, true + } + } + return Decision{}, false + } +} + +// RuleOption adjusts the decision a [When] rule makes. +type RuleOption func(*Decision) + +// Column pins the column an encrypted field is stored under: its record +// name and, for an EQL target, the column half of its context. The pin is +// the column's identity, which may differ from the field's name after a +// field rename, and from the column's current name after a database +// rename: stored payloads keep the identity they were written under. Only +// meaningful with [Encrypt]; building a plan refuses it elsewhere. +func Column(name string) RuleOption { + return func(d *Decision) { d.column = name } +} + +// When decides d for the fields m matches, and matches nothing else. +func When(m Matcher, d Decision, opts ...RuleOption) Policy { + if m == nil { + panic("plan.When: nil matcher") + } + for _, opt := range opts { + opt(&d) + } + return func(f Fact) (Decision, bool) { + if !m(f) { + return Decision{}, false + } + return d, true + } +} + +// Matcher is a predicate over a field's facts. +type Matcher func(Fact) bool + +// Field matches the field whose schema name ([Fact.Field]) is name. +func Field(name string) Matcher { + return func(f Fact) bool { return f.Field == name } +} + +// Kind matches fields of the given kind, in the source's spelling. +func Kind(kind string) Matcher { + return func(f Fact) bool { return f.Kind == kind } +} + +// Any matches when at least one of ms does. +func Any(ms ...Matcher) Matcher { + return func(f Fact) bool { + return slices.ContainsFunc(ms, func(m Matcher) bool { return m(f) }) + } +} + +// All matches when every one of ms does. +func All(ms ...Matcher) Matcher { + return func(f Fact) bool { + for _, m := range ms { + if !m(f) { + return false + } + } + return true + } +} + +// Not matches when m does not. +func Not(m Matcher) Matcher { + return func(f Fact) bool { return !m(f) } +} + +// Key is an annotation key, the handle rules match annotations through. A +// protobuf fact source hands out a Key per extension; for struct tags it +// is the tag's key: +// +// var category = plan.Key("fides.data_categories") +type Key string + +// Present matches fields with any value under the key. +func (k Key) Present() Matcher { + return func(f Fact) bool { return len(f.Values(string(k))) > 0 } +} + +// Is matches fields with value among their values under the key. +func (k Key) Is(value string) Matcher { + return func(f Fact) bool { return slices.Contains(f.Values(string(k)), value) } +} + +// Under matches fields with a value under the key at or below prefix in a +// dot-separated taxonomy (Fideslang's): "user.contact" matches +// "user.contact" and "user.contact.email", not "user.contactless". +func (k Key) Under(prefix string) Matcher { + return func(f Fact) bool { + return slices.ContainsFunc(f.Values(string(k)), func(v string) bool { + return v == prefix || strings.HasPrefix(v, prefix+".") + }) + } +} diff --git a/languages/golang/stackencrypt/policy_plan_test.go b/languages/golang/stackencrypt/policy_plan_test.go new file mode 100644 index 000000000..08bb43414 --- /dev/null +++ b/languages/golang/stackencrypt/policy_plan_test.go @@ -0,0 +1,58 @@ +package stackencrypt_test + +import ( + "bytes" + "reflect" + "testing" + + se "github.com/cipherstash/cipherstash-suite/bindings/go/stackencrypt" + "github.com/cipherstash/cipherstash-suite/bindings/go/stackencrypt/plan" +) + +// A plan a policy builds is a Plan like any other: the guest receives +// byte-identical input to the equivalent plan built by hand. This test is +// here, not in package plan, because the guest encoding is unexported; +// plan imports stackencrypt, so only an external test can hold both. +func TestPolicyPlanIsTheHandBuiltPlan(t *testing.T) { + type individual struct { + ID int64 + Email string `facts:"fides.data_categories=user.contact.email"` + Name string `facts:"fides.data_categories=user.name"` + MedicareNo string `facts:"fides.data_categories=user.government_id"` + Country string `facts:"fides.data_categories=system.operations"` + } + category := plan.Key("fides.data_categories") + base := plan.FirstOf( + plan.When(category.Under("user.government_id"), plan.Encrypt(plan.EQL(se.Equality))), + plan.When(category.Under("user.contact.email"), plan.Encrypt(plan.EQL(se.Equality, se.Match))), + plan.When(category.Under("user"), plan.Encrypt(plan.EQL())), + plan.When(category.Present(), plan.Plaintext()), + ) + individuals := plan.ForMessage(&individual{}, "individuals", plan.FirstOf( + plan.When(plan.Field("MedicareNo"), plan.Encrypt(plan.EQL(se.Equality, se.Ore)), plan.Column("medicare_number")), + ).OrElse(base)) + + fromPolicy := plan.MustPlanFor(plan.StructTags, individuals) + byHand, err := se.NewPlan( + se.FieldPlan{Field: "Email", Context: "individuals/Email", Terms: []se.TermKind{se.Equality, se.Match}}, + se.FieldPlan{Field: "Name", Context: "individuals/Name"}, + se.FieldPlan{Field: "MedicareNo", Name: "medicare_number", Context: "individuals/medicare_number", Terms: []se.TermKind{se.Equality, se.Ore}}, + ) + if err != nil { + t.Fatal(err) + } + typ := reflect.TypeOf(individual{}) + for _, ext := range [][]any{nil, {uint64(7)}} { + a, err := se.GuestPlanInput(fromPolicy, typ, ext...) + if err != nil { + t.Fatal(err) + } + b, err := se.GuestPlanInput(byHand, typ, ext...) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(a, b) { + t.Fatalf("extension %v: guest input differs:\npolicy %x\nhand %x", ext, a, b) + } + } +} From f4ca03ba073ca5c5371fdde78cfe3f6503dd1829 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Mon, 28 Sep 2026 23:47:51 +0000 Subject: [PATCH 665/686] fix(go): close the plan package's review findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit StructTags refuses a facts tag on a field a plan cannot bind — an unexported or embedded field, or any field inside an embedded struct — instead of dropping it, so a classified field is never quietly left in plaintext. It names each field as a schema would, the Go name in snake_case, so the column identity a field binds by default is the one the Rust derive and the database spell; Fact.String names the Go field too when it differs. PlanFor checks the plan binds to the message's type through a new Plan.Validate, so a fact naming a Go field the type does not have fails at startup, not at the first record call. A message the policy encrypts nothing of is ErrNothingEncrypted, saying it needs no plan. Any, All and Not refuse a nil matcher when the rule is written, naming the combinator, and hold their own copy of the matchers. Column refuses an empty name. Key.Under refuses a prefix that could read as a catch-all but match nothing. The '/' refusal on a column applies only to targets whose context is the identity; a Custom target's column is only the record key. The key matchers walk the annotations without allocating. Each refused-decision test case now asserts the reason it is named for. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TJc73YNcTeGaLDhP2P3LNM --- languages/golang/stackencrypt/README.md | 22 +- languages/golang/stackencrypt/plan/doc.go | 18 +- languages/golang/stackencrypt/plan/fact.go | 113 ++++++++- languages/golang/stackencrypt/plan/message.go | 57 ++++- .../golang/stackencrypt/plan/plan_test.go | 224 +++++++++++++++--- languages/golang/stackencrypt/plan/policy.go | 50 +++- .../golang/stackencrypt/policy_plan_test.go | 8 +- languages/golang/stackencrypt/record.go | 15 ++ 8 files changed, 427 insertions(+), 80 deletions(-) diff --git a/languages/golang/stackencrypt/README.md b/languages/golang/stackencrypt/README.md index 0c1fa7652..e79a8054a 100644 --- a/languages/golang/stackencrypt/README.md +++ b/languages/golang/stackencrypt/README.md @@ -242,12 +242,13 @@ var Base = plan.FirstOf( var Individuals = plan.ForMessage(&Individual{}, plan.Table("individuals"), plan.FirstOf( - plan.When(plan.Field("MedicareNo"), plan.Encrypt(plan.EQL(stackencrypt.Equality)), + plan.When(plan.Field("medicare_no"), plan.Encrypt(plan.EQL(stackencrypt.Equality)), plan.Column("medicare_number")), ).OrElse(Base), ) -// At startup: panics if a classified field is decided by no rule. +// At startup: panics if a classified field is decided by no rule, or the +// plan names a field the struct does not have. var individuals = plan.MustPlanFor(plan.StructTags, Individuals) records, err := cipher.EncryptRecords(ctx, rows, stackencrypt.WithPlan(individuals)) @@ -256,14 +257,21 @@ records, err := cipher.EncryptRecords(ctx, rows, stackencrypt.WithPlan(individua A policy fails closed: a field with facts that no rule decides is an error when the plan is built, naming the field and its facts. There is no default; write a catch-all, `Plaintext()` included, in the policy. Fields with no -facts are left out and stored as they are. +facts are left out and stored as they are. A `facts` tag on a field a plan +cannot bind (unexported, or inside an embedded struct) is an error too, +not a field quietly left in plaintext. A message the policy encrypts +nothing of has no plan: `PlanFor` reports `ErrNothingEncrypted`, and its +records are stored without one. An EQL target's context is its column identity, `"<table>/<column>"`. The table is required per message, never derived from its name, and the column -is the field name unless a rule pins it with `plan.Column`, which keeps the -context fixed across renames. `plan.Custom` targets supply their own -context. The plan a policy builds is a `Plan` like any other: the guest -receives the same bytes as for the equivalent hand-built plan. +is the field's schema name — for a Go struct, the field name in snake_case +(`MedicareNo` is `medicare_no`), the spelling the Rust derive and the +database column share — unless a rule pins it with `plan.Column`, which +keeps the context fixed across renames. `plan.Field` matches on that same +schema name. `plan.Custom` targets supply their own context. The plan a +policy builds is a `Plan` like any other: the guest receives the same bytes +as for the equivalent hand-built plan. ## Errors diff --git a/languages/golang/stackencrypt/plan/doc.go b/languages/golang/stackencrypt/plan/doc.go index d7ef687dc..d71e996b2 100644 --- a/languages/golang/stackencrypt/plan/doc.go +++ b/languages/golang/stackencrypt/plan/doc.go @@ -19,7 +19,7 @@ // // var Individuals = plan.ForMessage(&Individual{}, plan.Table("individuals"), // plan.FirstOf( -// plan.When(plan.Field("MedicareNo"), plan.Encrypt(plan.EQL(stackencrypt.Equality)), +// plan.When(plan.Field("medicare_no"), plan.Encrypt(plan.EQL(stackencrypt.Equality)), // plan.Column("medicare_number")), // ).OrElse(Base), // ) @@ -30,10 +30,12 @@ // # Facts // // A [Source] makes the facts for a message. [StructTags] reads a Go -// struct's `facts` tags; a protobuf source reads descriptors and their -// custom options the same way, and needs nothing from this package beyond -// [Fact], [Source] and [Key]. [Message.Build] takes facts directly, so a -// generator or a test can build a plan without a source at all. +// struct's `facts` tags, naming each field as a schema would (the Go name +// in snake_case); a protobuf source reads descriptors and their custom +// options the same way, and needs nothing from this package beyond [Fact], +// [Source] and [Key]. [Message.Build] takes facts directly, so a generator +// or a test can build a plan without a source at all; [PlanFor] also checks +// the plan binds to the message's Go type. // // # Contexts // @@ -52,7 +54,9 @@ // is built ([ErrUnmatched]), naming the field and its annotations; there is // no built-in default, so a catch-all, Plaintext included, is written in the // policy. A field with no annotations that no rule names is not the -// policy's concern: it is left out of the plan and stored as it is. Build -// plans at startup with [MustPlanFor], so a gap stops the process before it +// policy's concern: it is left out of the plan and stored as it is. A +// message the policy encrypts nothing of has no plan to build +// ([ErrNothingEncrypted]): its records are stored without one. Build plans +// at startup with [MustPlanFor], so a gap stops the process before it // writes anything. package plan diff --git a/languages/golang/stackencrypt/plan/fact.go b/languages/golang/stackencrypt/plan/fact.go index dcca1aaa8..af15da774 100644 --- a/languages/golang/stackencrypt/plan/fact.go +++ b/languages/golang/stackencrypt/plan/fact.go @@ -6,6 +6,7 @@ import ( "reflect" "slices" "strings" + "unicode" ) // Fact is what the SDK knows about one field of a message: where it is, @@ -16,9 +17,9 @@ import ( type Fact struct { // Message is the message's (or struct's) full name, for errors. Message string - // Field is the field's schema name: the proto field name, or the Go - // field name for a struct. It is the column a field encrypts into - // unless a rule pins another ([Column]). + // Field is the field's schema name: the proto field name, or, for a + // struct, the Go field name in snake_case. It is the column a field + // encrypts into unless a rule pins another ([Column]). Field string // GoField is the Go struct field the plan binds to. Field when empty. GoField string @@ -53,6 +54,20 @@ func (f Fact) Values(key string) []string { return out } +// hasValue reports whether any value under key satisfies pred, without +// collecting the values: the matchers run once per rule per field. +func (f Fact) hasValue(key string, pred func(string) bool) bool { + for _, a := range f.Annotations { + if a.Key != key { + continue + } + if slices.ContainsFunc(a.Values, pred) { + return true + } + } + return false +} + // goField is the Go struct field the fact binds to. func (f Fact) goField() string { if f.GoField != "" { @@ -61,7 +76,8 @@ func (f Fact) goField() string { return f.Field } -// String names the field and its annotations, the way errors name them. +// String names the field — and the Go field it binds to, when that is +// spelled differently — and its annotations, the way errors name them. func (f Fact) String() string { var b strings.Builder if f.Message != "" { @@ -69,6 +85,11 @@ func (f Fact) String() string { b.WriteByte('.') } b.WriteString(f.Field) + if f.GoField != "" && f.GoField != f.Field { + b.WriteString(" (") + b.WriteString(f.GoField) + b.WriteByte(')') + } if len(f.Annotations) > 0 { b.WriteString(" [") for i, a := range f.Annotations { @@ -105,10 +126,16 @@ func (f SourceFunc) Facts(msg any) ([]Fact, error) { return f(msg) } // MedicareNo string `facts:"fides.data_categories=user.government_id,user.financial"` // } // -// The tag is `key=value[,value...]`, repeated with `;` for more keys. Field -// and GoField are the Go field name, Number is 0 and Kind is the field's -// reflect.Kind (through one pointer). Unexported and embedded fields are -// skipped: a plan binds exported, direct fields only. +// The tag is `key=value[,value...]`, repeated with `;` for more keys. +// GoField is the Go field name and Field is its snake_case ("MedicareNo" +// is "medicare_no", "ID" is "id", "HTTPPort" is "http_port"): the name a +// proto field or a database column would have, so the column identity a +// field binds by default is the one the Rust derive and the schema spell. +// Number is 0 and Kind is the field's reflect.Kind (through one pointer). +// +// Unexported and embedded fields are not facts: a plan binds exported, +// direct fields only. A `facts` tag on one — or on any field of an +// embedded struct — is an error, not a field quietly left in plaintext. var StructTags Source = SourceFunc(structFacts) func structFacts(msg any) ([]Fact, error) { @@ -123,6 +150,9 @@ func structFacts(msg any) ([]Fact, error) { for i := 0; i < t.NumField(); i++ { sf := t.Field(i) if !sf.IsExported() || sf.Anonymous { + if err := refuseUnbindableTag(sf); err != nil { + return nil, fmt.Errorf("plan: %s.%s: %w", t, sf.Name, err) + } continue } kind := sf.Type @@ -135,7 +165,7 @@ func structFacts(msg any) ([]Fact, error) { } facts = append(facts, Fact{ Message: t.String(), - Field: sf.Name, + Field: snakeCase(sf.Name), GoField: sf.Name, Kind: kind.Kind().String(), Annotations: annotations, @@ -144,6 +174,71 @@ func structFacts(msg any) ([]Fact, error) { return facts, nil } +// refuseUnbindableTag is the error for a `facts` tag on a field a plan +// cannot bind: an unexported or embedded field, or any field of an +// embedded struct, however deep. The tag says the field is classified; +// dropping it would store the field in plaintext with no rule ever asked. +func refuseUnbindableTag(sf reflect.StructField) error { + if sf.Tag.Get("facts") != "" { + if sf.Anonymous { + return errors.New("a facts tag on an embedded field, which a plan cannot bind") + } + return errors.New("a facts tag on an unexported field, which a plan cannot bind") + } + if !sf.Anonymous { + return nil + } + if tagged := firstFactsTag(sf.Type); tagged != "" { + return fmt.Errorf("embedded %s has a facts tag on %s, which a plan cannot bind; make it a direct field", sf.Type, tagged) + } + return nil +} + +// firstFactsTag names the first field of t (a struct, through one +// pointer), or of a struct embedded in it, that carries a facts tag; "" +// when none does. +func firstFactsTag(t reflect.Type) string { + if t.Kind() == reflect.Pointer { + t = t.Elem() + } + if t.Kind() != reflect.Struct { + return "" + } + for i := 0; i < t.NumField(); i++ { + sf := t.Field(i) + if sf.Tag.Get("facts") != "" { + return sf.Name + } + if sf.Anonymous { + if name := firstFactsTag(sf.Type); name != "" { + return sf.Name + "." + name + } + } + } + return "" +} + +// snakeCase is a Go field name as a schema would spell it: a lower-case +// word per hump, joined by underscores, with an initialism kept as one +// word ("HTTPPort" is "http_port", "ID" is "id"). Digits stay with the +// word before them ("Line2" is "line2"). +func snakeCase(name string) string { + runes := []rune(name) + var b strings.Builder + b.Grow(len(name) + 4) + for i, r := range runes { + if i > 0 && unicode.IsUpper(r) { + prev := runes[i-1] + nextLower := i+1 < len(runes) && unicode.IsLower(runes[i+1]) + if unicode.IsLower(prev) || unicode.IsDigit(prev) || (unicode.IsUpper(prev) && nextLower) { + b.WriteByte('_') + } + } + b.WriteRune(unicode.ToLower(r)) + } + return b.String() +} + func parseFactsTag(tag string) ([]Annotation, error) { if tag == "" { return nil, nil diff --git a/languages/golang/stackencrypt/plan/message.go b/languages/golang/stackencrypt/plan/message.go index 22b468305..d040595c0 100644 --- a/languages/golang/stackencrypt/plan/message.go +++ b/languages/golang/stackencrypt/plan/message.go @@ -3,6 +3,7 @@ package plan import ( "errors" "fmt" + "reflect" "strings" "github.com/cipherstash/cipherstash-suite/bindings/go/stackencrypt" @@ -19,6 +20,11 @@ var ( // a pinned column on a field that is not encrypted, an empty context or // column identity. ErrInvalid = errors.New("invalid decision") + // ErrNothingEncrypted is a message the policy encrypts no field of: + // every classified field decided Plaintext, or none classified. Such a + // message has no plan to build, and its records are stored without + // one — a [stackencrypt.Plan] always seals at least one field. + ErrNothingEncrypted = errors.New("the policy encrypts no field of the message") ) // Table names the table a message's records are stored in: the first half @@ -43,7 +49,7 @@ type Message struct { // // var Individuals = plan.ForMessage(&Individual{}, plan.Table("individuals"), // plan.FirstOf( -// plan.When(plan.Field("MedicareNo"), plan.Encrypt(plan.EQL(stackencrypt.Equality)), +// plan.When(plan.Field("medicare_no"), plan.Encrypt(plan.EQL(stackencrypt.Equality)), // plan.Column("medicare_number")), // ).OrElse(Base), // ) @@ -90,6 +96,9 @@ func (m Message) Build(facts []Fact) (stackencrypt.Plan, error) { if len(errs) > 0 { return stackencrypt.Plan{}, errors.Join(errs...) } + if len(fields) == 0 { + return stackencrypt.Plan{}, fmt.Errorf("plan: %s: %w; a message with nothing to encrypt needs no plan", messageName(m, facts), ErrNothingEncrypted) + } p, err := stackencrypt.NewPlan(fields...) if err != nil { return stackencrypt.Plan{}, fmt.Errorf("plan: %s: %w", messageName(m, facts), err) @@ -125,8 +134,14 @@ func (m Message) field(f Fact) (stackencrypt.FieldPlan, bool, error) { if d.column != "" { column = d.column } - if column == "" || strings.Contains(column, "/") { - return stackencrypt.FieldPlan{}, false, fmt.Errorf("%w: column %q must be non-empty and contain no '/'", ErrInvalid, column) + if column == "" { + return stackencrypt.FieldPlan{}, false, fmt.Errorf("%w: the field has no name to store it under", ErrInvalid) + } + // A '/' in the column would make an identity-shaped context ambiguous + // ("a/b" under "t" reads as "a" under "t/b" would). A Custom target's + // context is its own, and its column is only the record key. + if _, custom := d.target.(customTarget); !custom && strings.Contains(column, "/") { + return stackencrypt.FieldPlan{}, false, fmt.Errorf("%w: column %q contains '/', which would make its identity ambiguous", ErrInvalid, column) } context := d.target.Context(Identifier{Table: string(m.table), Column: column}) if context == "" { @@ -147,8 +162,12 @@ func messageName(m Message, facts []Fact) string { return fmt.Sprintf("%T", m.msg) } -// PlanFor reads m's facts from src and builds its plan; see -// [Message.Build]. +// PlanFor reads m's facts from src, builds its plan (see [Message.Build]) +// and, when m's message is a struct or a pointer to one, checks the plan +// binds to it: every planned field is an exported, direct field of the +// type. A fact whose GoField the type does not have — a typo in a source, +// a generated field renamed — is then an error here, not at the first +// record call. func PlanFor(src Source, m Message) (stackencrypt.Plan, error) { if src == nil { return stackencrypt.Plan{}, errors.New("plan: PlanFor needs a Source") @@ -157,12 +176,34 @@ func PlanFor(src Source, m Message) (stackencrypt.Plan, error) { if err != nil { return stackencrypt.Plan{}, err } - return m.Build(facts) + p, err := m.Build(facts) + if err != nil { + return stackencrypt.Plan{}, err + } + if t := structType(m.msg); t != nil { + if err := p.Validate(t); err != nil { + return stackencrypt.Plan{}, fmt.Errorf("plan: %s: %w", messageName(m, facts), err) + } + } + return p, nil +} + +// structType is msg's struct type, through one pointer, or nil when msg is +// not a struct: nothing to bind a plan to. +func structType(msg any) reflect.Type { + t := reflect.TypeOf(msg) + if t != nil && t.Kind() == reflect.Pointer { + t = t.Elem() + } + if t == nil || t.Kind() != reflect.Struct { + return nil + } + return t } // MustPlanFor is [PlanFor] for startup: it panics when the policy does not -// decide every classified field, so a policy gap stops the process before -// it writes anything. +// decide every classified field, or the plan does not bind to the message, +// so a policy gap stops the process before it writes anything. func MustPlanFor(src Source, m Message) stackencrypt.Plan { p, err := PlanFor(src, m) if err != nil { diff --git a/languages/golang/stackencrypt/plan/plan_test.go b/languages/golang/stackencrypt/plan/plan_test.go index 348fefc5f..e7232fd91 100644 --- a/languages/golang/stackencrypt/plan/plan_test.go +++ b/languages/golang/stackencrypt/plan/plan_test.go @@ -2,6 +2,7 @@ package plan_test import ( "errors" + "fmt" "reflect" "strings" "testing" @@ -28,7 +29,7 @@ type individual struct { var individuals = plan.ForMessage(&individual{}, plan.Table("individuals"), plan.FirstOf( - plan.When(plan.Field("MedicareNo"), plan.Encrypt(plan.EQL(se.Equality, se.Ore)), plan.Column("medicare_number")), + plan.When(plan.Field("medicare_no"), plan.Encrypt(plan.EQL(se.Equality, se.Ore)), plan.Column("medicare_number")), plan.When(category.Under("system"), plan.Plaintext()), ).OrElse(base), ) @@ -38,9 +39,11 @@ func TestPolicyBuildsThePlan(t *testing.T) { if err != nil { t.Fatal(err) } + // Columns are the schema's spelling of the Go field: what the Rust + // derive binds and the database names. want := []se.FieldPlan{ - {Field: "Email", Name: "Email", Context: "individuals/Email", Terms: []se.TermKind{se.Equality, se.Match}}, - {Field: "Name", Name: "Name", Context: "individuals/Name"}, + {Field: "Email", Name: "email", Context: "individuals/email", Terms: []se.TermKind{se.Equality, se.Match}}, + {Field: "Name", Name: "name", Context: "individuals/name"}, // The per-message rule wins over the base's government_id rule. {Field: "MedicareNo", Name: "medicare_number", Context: "individuals/medicare_number", Terms: []se.TermKind{se.Equality, se.Ore}}, } @@ -67,14 +70,14 @@ func TestUnmatchedFactFailsTheBuild(t *testing.T) { t.Fatalf("err = %v, want ErrUnmatched", err) } for _, want := range []string{ - "Fingerprint", "user.biometric.fingerprint", - "Diagnosis", "user.health", + "fingerprint (Fingerprint)", "user.biometric.fingerprint", + "diagnosis (Diagnosis)", "user.health", } { if !strings.Contains(err.Error(), want) { t.Errorf("error %q does not name %q", err, want) } } - if strings.Contains(err.Error(), "Email") || strings.Contains(err.Error(), ".ID") { + if strings.Contains(err.Error(), "Email") || strings.Contains(err.Error(), ".id") { t.Errorf("error %q names a field that was decided or unclassified", err) } func() { @@ -96,6 +99,40 @@ func TestUnmatchedFactFailsTheBuild(t *testing.T) { } } +// A message the policy encrypts nothing of has no plan to build: the error +// says so, rather than stackencrypt's "at least one field". +func TestNothingEncryptedIsItsOwnError(t *testing.T) { + type audit struct { + ID int64 + Kind string `facts:"fides.data_categories=system.operations"` + } + m := plan.ForMessage(audit{}, "audits", plan.When(category.Under("system"), plan.Plaintext())) + _, err := plan.PlanFor(plan.StructTags, m) + if !errors.Is(err, plan.ErrNothingEncrypted) { + t.Fatalf("err = %v, want ErrNothingEncrypted", err) + } + if !strings.Contains(err.Error(), "audit") || !strings.Contains(err.Error(), "needs no plan") { + t.Errorf("error %q does not name the message and say what to do", err) + } +} + +// PlanFor binds the plan to the message's type: a fact naming a Go field +// the type does not have fails at build, not at the first record call. +func TestPlanForRefusesAFieldTheMessageDoesNotHave(t *testing.T) { + facts := []plan.Fact{{Message: "acme.v1.Individual", Field: "email", GoField: "EMail", + Annotations: []plan.Annotation{{Key: "k", Values: []string{"v"}}}}} + src := plan.SourceFunc(func(any) ([]plan.Fact, error) { return facts, nil }) + m := plan.ForMessage(&individual{}, "individuals", plan.When(plan.Key("k").Present(), plan.Encrypt(plan.EQL()))) + _, err := plan.PlanFor(src, m) + if err == nil || !strings.Contains(err.Error(), "EMail") || !strings.Contains(err.Error(), "not an exported field") { + t.Fatalf("err = %v, want the unbound field named", err) + } + // With no message to bind to, Build alone cannot know, and does not try. + if _, err := plan.ForMessage(nil, "individuals", plan.When(plan.Key("k").Present(), plan.Encrypt(plan.EQL()))).Build(facts); err != nil { + t.Fatalf("Build with no message: %v", err) + } +} + // An unclassified field is not the policy's concern, but a rule may still // name it. func TestUnclassifiedFieldsAreLeftOutUnlessNamed(t *testing.T) { @@ -173,32 +210,50 @@ func TestContextsByTarget(t *testing.T) { if got := p.Fields(); !reflect.DeepEqual(got, want) { t.Fatalf("fields =\n%+v\nwant\n%+v", got, want) } + // A '/' in a custom target's column is only a '/' in a record key: no + // identity to make ambiguous. + slashed := plan.ForMessage(nil, "users", plan.When(k.Is("custom"), plan.Encrypt(plan.Custom("tenant-blobs/v1")), plan.Column("blob/v1"))) + p, err = slashed.Build(facts[1:]) + if err != nil { + t.Fatal(err) + } + if got := p.Fields()[0].Name; got != "blob/v1" { + t.Errorf("custom record key = %q, want blob/v1", got) + } } func TestBuildRefusesMalformedDecisions(t *testing.T) { classified := []plan.Annotation{{Key: "k", Values: []string{"v"}}} fact := []plan.Fact{{Field: "a", Annotations: classified}} + // Each case fails for its own reason: a sentinel, or the message the + // reason is spelled by where stackencrypt reports it. for name, tc := range map[string]struct { table plan.Table policy plan.Policy want error + msg string }{ - "no table": {"", plan.When(plan.Field("a"), plan.Encrypt(plan.EQL())), nil}, - "slash in table": {"a/b", plan.When(plan.Field("a"), plan.Encrypt(plan.EQL())), nil}, - "slash in column": {"t", plan.When(plan.Field("a"), plan.Encrypt(plan.EQL()), plan.Column("x/y")), plan.ErrInvalid}, - "fail": {"t", plan.When(plan.Field("a"), plan.Fail("biometrics are never stored")), plan.ErrRefused}, - "nil target": {"t", plan.When(plan.Field("a"), plan.Encrypt(nil)), plan.ErrInvalid}, - "column on plain": {"t", plan.When(plan.Field("a"), plan.Plaintext(), plan.Column("c")), plan.ErrInvalid}, - "zero decision": {"t", plan.When(plan.Field("a"), plan.Decision{}), plan.ErrInvalid}, - "empty context": {"t", plan.When(plan.Field("a"), plan.Encrypt(plan.Custom(""))), plan.ErrInvalid}, - "nil policy": {"t", nil, plan.ErrUnmatched}, - "nothing encrypted": {"t", plan.When(plan.Field("a"), plan.Plaintext()), nil}, - "term kind unknown": {"t", plan.When(plan.Field("a"), plan.Encrypt(plan.EQL(se.TermKind(9)))), nil}, - "nil policies skip": {"t", plan.FirstOf(nil, nil), plan.ErrUnmatched}, - "fail names reason": {"t", plan.When(plan.Field("a"), plan.Fail("no biometrics")), plan.ErrRefused}, - "matcher composites": {"t", plan.When(plan.All(plan.Field("a"), plan.Not(plan.Kind("string"))), plan.Fail("x")), plan.ErrRefused}, + "no table": {"", plan.When(plan.Field("a"), plan.Encrypt(plan.EQL())), nil, "needs a Table"}, + "slash in table": {"a/b", plan.When(plan.Field("a"), plan.Encrypt(plan.EQL())), nil, "contains '/'"}, + "slash in column": {"t", plan.When(plan.Field("a"), plan.Encrypt(plan.EQL()), plan.Column("x/y")), plan.ErrInvalid, "contains '/'"}, + "empty field name": {"t", plan.When(plan.Kind(""), plan.Encrypt(plan.EQL())), plan.ErrInvalid, "no name"}, + "fail": {"t", plan.When(plan.Field("a"), plan.Fail("biometrics are never stored")), plan.ErrRefused, ""}, + "nil target": {"t", plan.When(plan.Field("a"), plan.Encrypt(nil)), plan.ErrInvalid, "no target"}, + "column on plain": {"t", plan.When(plan.Field("a"), plan.Plaintext(), plan.Column("c")), plan.ErrInvalid, "Plaintext"}, + "zero decision": {"t", plan.When(plan.Field("a"), plan.Decision{}), plan.ErrInvalid, "zero Decision"}, + "empty context": {"t", plan.When(plan.Field("a"), plan.Encrypt(plan.Custom(""))), plan.ErrInvalid, "empty context"}, + "nil policy": {"t", nil, plan.ErrUnmatched, ""}, + "nothing encrypted": {"t", plan.When(plan.Field("a"), plan.Plaintext()), plan.ErrNothingEncrypted, "needs no plan"}, + "term kind unknown": {"t", plan.When(plan.Field("a"), plan.Encrypt(plan.EQL(se.TermKind(9)))), nil, "unknown term kind"}, + "nil policies skip": {"t", plan.FirstOf(nil, nil), plan.ErrUnmatched, ""}, + "fail names reason": {"t", plan.When(plan.Field("a"), plan.Fail("no biometrics")), plan.ErrRefused, "no biometrics"}, + "matcher composites": {"t", plan.When(plan.All(plan.Field("a"), plan.Not(plan.Kind("string"))), plan.Fail("x")), plan.ErrRefused, ""}, } { - _, err := plan.ForMessage(nil, tc.table, tc.policy).Build(fact) + facts := fact + if name == "empty field name" { + facts = []plan.Fact{{Field: "", Annotations: classified}} + } + _, err := plan.ForMessage(nil, tc.table, tc.policy).Build(facts) if err == nil { t.Errorf("%s: built", name) continue @@ -206,6 +261,9 @@ func TestBuildRefusesMalformedDecisions(t *testing.T) { if tc.want != nil && !errors.Is(err, tc.want) { t.Errorf("%s: err = %v, want %v", name, err, tc.want) } + if tc.msg != "" && !strings.Contains(err.Error(), tc.msg) { + t.Errorf("%s: err = %q, want it to say %q", name, err, tc.msg) + } } _, err := plan.ForMessage(nil, "t", plan.When(plan.Field("a"), plan.Fail("no biometrics"))).Build(fact) if !strings.Contains(err.Error(), "no biometrics") { @@ -244,6 +302,58 @@ func TestKeyMatchers(t *testing.T) { if got := f.Values("other"); !reflect.DeepEqual(got, []string{"x"}) { t.Errorf("Values = %v", got) } + // A prefix that could read as a catch-all but match nothing is refused + // when the rule is written, not silently dead. + for _, prefix := range []string{"", "user.", ".user", "a..b"} { + func() { + defer func() { + if recover() == nil { + t.Errorf("Under(%q) did not panic", prefix) + } + }() + category.Under(prefix) + }() + } +} + +// The combinators refuse a nil matcher when the rule is written, as When +// does, naming the combinator; and they hold their own copy of the +// matchers, so a later write to the caller's slice changes nothing. +func TestCombinatorsRefuseNilAndCopyTheirMatchers(t *testing.T) { + var unset plan.Matcher + for name, build := range map[string]func(){ + "Any": func() { plan.Any(plan.Field("a"), unset) }, + "All": func() { plan.All(unset) }, + "Not": func() { plan.Not(unset) }, + } { + func() { + defer func() { + r := recover() + if r == nil { + t.Errorf("%s(nil) did not panic", name) + } else if !strings.Contains(fmt.Sprint(r), "plan."+name) { + t.Errorf("%s(nil) panicked with %v, which does not name it", name, r) + } + }() + build() + }() + } + ms := []plan.Matcher{plan.Field("a")} + any, all := plan.Any(ms...), plan.All(ms...) + ms[0] = plan.Field("b") + a, b := plan.Fact{Field: "a"}, plan.Fact{Field: "b"} + if !any(a) || any(b) || !all(a) || all(b) { + t.Error("a write to the caller's slice changed the matcher") + } +} + +func TestColumnRefusesAnEmptyName(t *testing.T) { + defer func() { + if recover() == nil { + t.Error("Column(\"\") did not panic") + } + }() + plan.Column("") } func TestDecisionsSpellThemselves(t *testing.T) { @@ -279,44 +389,84 @@ func TestStructTagsSource(t *testing.T) { embedded ID *int64 Email string `facts:"a=x,y;b=z"` - hidden string //nolint:unused // proves unexported fields are skipped + hidden string //nolint:unused // proves untagged unexported fields are skipped } facts, err := plan.StructTags.Facts(&row{}) if err != nil { t.Fatal(err) } want := []plan.Fact{ - {Message: "plan_test.row", Field: "ID", GoField: "ID", Kind: "int64"}, - {Message: "plan_test.row", Field: "Email", GoField: "Email", Kind: "string", Annotations: []plan.Annotation{ + {Message: "plan_test.row", Field: "id", GoField: "ID", Kind: "int64"}, + {Message: "plan_test.row", Field: "email", GoField: "Email", Kind: "string", Annotations: []plan.Annotation{ {Key: "a", Values: []string{"x", "y"}}, {Key: "b", Values: []string{"z"}}, }}, } if !reflect.DeepEqual(facts, want) { t.Fatalf("facts =\n%+v\nwant\n%+v", facts, want) } - if got := facts[1].String(); got != "plan_test.row.Email [a=x,y; b=z]" { + if got := facts[1].String(); got != "plan_test.row.email (Email) [a=x,y; b=z]" { t.Errorf("String = %s", got) } - for name, bad := range map[string]any{ - "not a struct": 42, - "nil": nil, - "no value": struct { + type taggedInner struct { + Secret string `facts:"a=x"` + } + type deeper struct{ taggedInner } + for name, bad := range map[string]struct { + msg any + say string + }{ + "not a struct": {42, "reads structs"}, + "nil": {nil, "reads structs"}, + "no value": {struct { A string `facts:"a="` - }{}, - "no key": struct { + }{}, "key=value"}, + "no key": {struct { A string `facts:"=x"` - }{}, - "empty value": struct { + }{}, "key=value"}, + "empty value": {struct { A string `facts:"a=x,"` - }{}, - "key twice": struct { + }{}, "empty value"}, + "key twice": {struct { A string `facts:"a=x;a=y"` - }{}, + }{}, "given twice"}, + // A tag the plan cannot bind is refused, never quietly plaintext. + "tagged unexported field": {struct { + medicareNo string `facts:"a=x"` //nolint:unused // the tag is the point + }{}, "unexported field"}, + "tagged embedded field": {struct { + embedded `facts:"a=x"` + }{}, "embedded field"}, + "tag inside an embedded struct": {struct{ taggedInner }{}, "Secret"}, + "tag two embeddings deep": {struct{ deeper }{}, "taggedInner.Secret"}, } { - if _, err := plan.StructTags.Facts(bad); err == nil { + _, err := plan.StructTags.Facts(bad.msg) + if err == nil { t.Errorf("%s: facts read", name) + } else if !strings.Contains(err.Error(), bad.say) { + t.Errorf("%s: err = %q, want it to say %q", name, err, bad.say) } } + // The schema spelling of a Go field name. + type spelled struct { + ID int64 + Email string + HTTPPort int + MedicareNo string + Line2 string + UserID string + OAuth2Key string + } + got, err := plan.StructTags.Facts(spelled{}) + if err != nil { + t.Fatal(err) + } + names := make([]string, len(got)) + for i, f := range got { + names[i] = f.Field + } + if want := []string{"id", "email", "http_port", "medicare_no", "line2", "user_id", "o_auth2_key"}; !reflect.DeepEqual(names, want) { + t.Errorf("schema names = %v, want %v", names, want) + } if _, err := plan.PlanFor(nil, individuals); err == nil { t.Error("PlanFor without a source") } diff --git a/languages/golang/stackencrypt/plan/policy.go b/languages/golang/stackencrypt/plan/policy.go index 9be4d4112..112b1e8a7 100644 --- a/languages/golang/stackencrypt/plan/policy.go +++ b/languages/golang/stackencrypt/plan/policy.go @@ -176,8 +176,13 @@ type RuleOption func(*Decision) // the column's identity, which may differ from the field's name after a // field rename, and from the column's current name after a database // rename: stored payloads keep the identity they were written under. Only -// meaningful with [Encrypt]; building a plan refuses it elsewhere. +// meaningful with [Encrypt]; building a plan refuses it elsewhere. An +// empty name is a programming error and panics: a pin that is not there +// would silently bind the field's own name instead. func Column(name string) RuleOption { + if name == "" { + panic("plan.Column: empty column name") + } return func(d *Decision) { d.column = name } } @@ -210,15 +215,19 @@ func Kind(kind string) Matcher { return func(f Fact) bool { return f.Kind == kind } } -// Any matches when at least one of ms does. +// Any matches when at least one of ms does. A nil matcher is a +// programming error and panics here, as it does in [When]. func Any(ms ...Matcher) Matcher { + ms = matchers("plan.Any", ms) return func(f Fact) bool { return slices.ContainsFunc(ms, func(m Matcher) bool { return m(f) }) } } -// All matches when every one of ms does. +// All matches when every one of ms does. A nil matcher is a programming +// error and panics here, as it does in [When]. func All(ms ...Matcher) Matcher { + ms = matchers("plan.All", ms) return func(f Fact) bool { for _, m := range ms { if !m(f) { @@ -229,11 +238,27 @@ func All(ms ...Matcher) Matcher { } } -// Not matches when m does not. +// Not matches when m does not. A nil matcher is a programming error and +// panics here, as it does in [When]. func Not(m Matcher) Matcher { + if m == nil { + panic("plan.Not: nil matcher") + } return func(f Fact) bool { return !m(f) } } +// matchers is a combinator's own copy of its matchers, none of them nil: +// a later write to the caller's slice must not change the matcher, and a +// nil found now names the combinator instead of crashing a build. +func matchers(combinator string, ms []Matcher) []Matcher { + for i, m := range ms { + if m == nil { + panic(fmt.Sprintf("%s: nil matcher at index %d", combinator, i)) + } + } + return slices.Clone(ms) +} + // Key is an annotation key, the handle rules match annotations through. A // protobuf fact source hands out a Key per extension; for struct tags it // is the tag's key: @@ -243,21 +268,28 @@ type Key string // Present matches fields with any value under the key. func (k Key) Present() Matcher { - return func(f Fact) bool { return len(f.Values(string(k))) > 0 } + return func(f Fact) bool { return f.hasValue(string(k), func(string) bool { return true }) } } // Is matches fields with value among their values under the key. func (k Key) Is(value string) Matcher { - return func(f Fact) bool { return slices.Contains(f.Values(string(k)), value) } + return func(f Fact) bool { return f.hasValue(string(k), func(v string) bool { return v == value }) } } // Under matches fields with a value under the key at or below prefix in a // dot-separated taxonomy (Fideslang's): "user.contact" matches -// "user.contact" and "user.contact.email", not "user.contactless". +// "user.contact" and "user.contact.email", not "user.contactless". The +// prefix is one or more non-empty dot-separated segments; anything else +// ("", "user.", ".user", "a..b") could match nothing while reading as a +// catch-all, so it is a programming error and panics. func (k Key) Under(prefix string) Matcher { + if prefix == "" || slices.Contains(strings.Split(prefix, "."), "") { + panic(fmt.Sprintf("plan.Key(%q).Under(%q): a prefix is one or more non-empty dot-separated segments", string(k), prefix)) + } + below := prefix + "." return func(f Fact) bool { - return slices.ContainsFunc(f.Values(string(k)), func(v string) bool { - return v == prefix || strings.HasPrefix(v, prefix+".") + return f.hasValue(string(k), func(v string) bool { + return v == prefix || strings.HasPrefix(v, below) }) } } diff --git a/languages/golang/stackencrypt/policy_plan_test.go b/languages/golang/stackencrypt/policy_plan_test.go index 08bb43414..16343d429 100644 --- a/languages/golang/stackencrypt/policy_plan_test.go +++ b/languages/golang/stackencrypt/policy_plan_test.go @@ -29,13 +29,15 @@ func TestPolicyPlanIsTheHandBuiltPlan(t *testing.T) { plan.When(category.Present(), plan.Plaintext()), ) individuals := plan.ForMessage(&individual{}, "individuals", plan.FirstOf( - plan.When(plan.Field("MedicareNo"), plan.Encrypt(plan.EQL(se.Equality, se.Ore)), plan.Column("medicare_number")), + plan.When(plan.Field("medicare_no"), plan.Encrypt(plan.EQL(se.Equality, se.Ore)), plan.Column("medicare_number")), ).OrElse(base)) fromPolicy := plan.MustPlanFor(plan.StructTags, individuals) + // The schema spelling of each column, as a Rust derive or a database + // would have it. byHand, err := se.NewPlan( - se.FieldPlan{Field: "Email", Context: "individuals/Email", Terms: []se.TermKind{se.Equality, se.Match}}, - se.FieldPlan{Field: "Name", Context: "individuals/Name"}, + se.FieldPlan{Field: "Email", Name: "email", Context: "individuals/email", Terms: []se.TermKind{se.Equality, se.Match}}, + se.FieldPlan{Field: "Name", Name: "name", Context: "individuals/name"}, se.FieldPlan{Field: "MedicareNo", Name: "medicare_number", Context: "individuals/medicare_number", Terms: []se.TermKind{se.Equality, se.Ore}}, ) if err != nil { diff --git a/languages/golang/stackencrypt/record.go b/languages/golang/stackencrypt/record.go index 1d28acd91..cc80bcee3 100644 --- a/languages/golang/stackencrypt/record.go +++ b/languages/golang/stackencrypt/record.go @@ -297,6 +297,21 @@ type fieldPlan struct { outputs []string } +// Validate checks that the plan binds to t: every planned field is an +// exported, direct field of the struct type. The record calls make the +// same check on every call and fail with the same error; this is for a +// caller that builds a plan at startup for a type it knows, so a field the +// type does not have is reported then rather than at the first write. The +// zero Plan is the type's own tags, and PlanFromTags validates those. +func (p Plan) Validate(t reflect.Type) error { + if p.d == nil { + _, err := PlanFromTags(t) + return err + } + _, err := p.bind(t) + return err +} + // bind resolves the plan's fields against a struct type. Not cached: a // name lookup per field is far below the cost of the call it precedes, and // a cache keyed by plan would grow with every plan a caller ever built. From 1afeb31b95228ce6246bb8015f8e3ff546ca6f1a Mon Sep 17 00:00:00 2001 From: Dan Draper <dan@cipherstash.com> Date: Mon, 28 Sep 2026 20:29:21 -0700 Subject: [PATCH 666/686] feat(go): split plan.Column into Column and Identity Column did two jobs: it named the column a value is stored in (the record key) and, for an EQL target, set the column half of the field's permanent identity, the "<table>/<column>" context bound into every ciphertext, its ZeroKMS data key and its terms' PRF context. After ALTER TABLE ... RENAME COLUMN the two must differ: new writes go to the new column, under the identity existing rows were written with. Column now names the stored column (the field's schema name by default). Identity pins the identity's column half and defaults to the effective Column, so a field never renamed in the database needs neither, and Column alone on a new field still sets both. A rename is Column("new"), Identity("old"). Identity is ErrInvalid on a Plaintext decision, as Column is, and on a Custom target, whose context is fixed. The '/' refusal applies to both the column and the identity of an EQL field. Two EQL fields sharing one identity are ErrInvalid: without a shared record key, NewPlan no longer catches it. Decision.Identity exposes the pin beside Decision.Column. Tests cover the rename, both defaults, the refused combinations, and a renamed column in the byte-identity test against a hand-built plan. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- languages/golang/stackencrypt/README.md | 26 ++++-- languages/golang/stackencrypt/plan/doc.go | 16 +++- languages/golang/stackencrypt/plan/message.go | 80 +++++++++++++----- .../golang/stackencrypt/plan/plan_test.go | 81 ++++++++++++++++--- languages/golang/stackencrypt/plan/policy.go | 70 ++++++++++++---- .../golang/stackencrypt/policy_plan_test.go | 60 ++++++++------ 6 files changed, 247 insertions(+), 86 deletions(-) diff --git a/languages/golang/stackencrypt/README.md b/languages/golang/stackencrypt/README.md index e79a8054a..d2b465510 100644 --- a/languages/golang/stackencrypt/README.md +++ b/languages/golang/stackencrypt/README.md @@ -264,12 +264,26 @@ nothing of has no plan: `PlanFor` reports `ErrNothingEncrypted`, and its records are stored without one. An EQL target's context is its column identity, `"<table>/<column>"`. The -table is required per message, never derived from its name, and the column -is the field's schema name — for a Go struct, the field name in snake_case -(`MedicareNo` is `medicare_no`), the spelling the Rust derive and the -database column share — unless a rule pins it with `plan.Column`, which -keeps the context fixed across renames. `plan.Field` matches on that same -schema name. `plan.Custom` targets supply their own context. The plan a +table is required per message, never derived from its name. A field is +stored in the column named by its schema name — for a Go struct, the field +name in snake_case (`MedicareNo` is `medicare_no`), the spelling the Rust +derive and the database column share — unless a rule names another with +`plan.Column`, and that column is also its identity unless the rule pins +one with `plan.Identity`. `plan.Field` matches on that same schema name. + +The identity is bound into every stored ciphertext, its data key and its +index terms, so once data is written it must never change. A field never +renamed in the database needs no `Identity`. After +`ALTER TABLE individuals RENAME COLUMN medicare_number TO medicare_num`, +new writes go to the new column under the old identity: + +```go +plan.When(plan.Field("medicare_no"), plan.Encrypt(plan.EQL(stackencrypt.Equality)), + plan.Column("medicare_num"), plan.Identity("medicare_number")) +``` + +`plan.Custom` targets supply their own context: `plan.Column` names only +their record key, and `plan.Identity` is refused. The plan a policy builds is a `Plan` like any other: the guest receives the same bytes as for the equivalent hand-built plan. diff --git a/languages/golang/stackencrypt/plan/doc.go b/languages/golang/stackencrypt/plan/doc.go index d71e996b2..16f05a3d9 100644 --- a/languages/golang/stackencrypt/plan/doc.go +++ b/languages/golang/stackencrypt/plan/doc.go @@ -43,10 +43,18 @@ // PRF context, fixed when data is first written. For an [EQL] target the // context is the column identity, "<table>/<column>" ([Identifier]): the // table is the message's [Table], which is required and never derived from -// the message's name, and the column is the field's schema name unless the -// rule pins another with [Column] — the column's identity, which survives -// renames of the field and of the database column. A [Custom] target -// supplies its context itself. +// the message's name, and the column is the column the field is first +// stored in. A rule sets that column with [Column] (the field's schema name +// by default), which for an EQL target sets the identity too. Once data is +// written the identity must never change, so after a database rename +// (ALTER TABLE ... RENAME COLUMN) the rule stores into the new column and +// pins the old identity with [Identity]: +// +// plan.When(plan.Field("medicare_no"), plan.Encrypt(plan.EQL(stackencrypt.Equality)), +// plan.Column("medicare_num"), plan.Identity("medicare_number")) +// +// A [Custom] target supplies its context itself; [Column] names only its +// record key, and [Identity] is refused. // // # Failing closed // diff --git a/languages/golang/stackencrypt/plan/message.go b/languages/golang/stackencrypt/plan/message.go index d040595c0..03dd40fe9 100644 --- a/languages/golang/stackencrypt/plan/message.go +++ b/languages/golang/stackencrypt/plan/message.go @@ -17,8 +17,9 @@ var ( // ErrRefused is a field the policy decided to [Fail]. ErrRefused = errors.New("the policy refuses the field") // ErrInvalid is a decision that cannot become a plan field: no target, - // a pinned column on a field that is not encrypted, an empty context or - // column identity. + // a pinned column or identity on a field that is not encrypted, an + // identity on a Custom target, an empty context, a '/' in a column + // identity, or two EQL fields sharing one identity. ErrInvalid = errors.New("invalid decision") // ErrNothingEncrypted is a message the policy encrypts no field of: // every classified field decided Plaintext, or none classified. Such a @@ -83,8 +84,19 @@ func (m Message) Build(facts []Fact) (stackencrypt.Plan, error) { } var fields []stackencrypt.FieldPlan var errs []error + // An EQL identity is one column's context: two fields sharing one would + // bind each other's ciphertexts and terms. NewPlan refuses a shared + // record key, but with Identity the identity can be shared without one. + identities := map[string]Fact{} for _, f := range facts { - fp, planned, err := m.field(f) + fp, id, planned, err := m.field(f) + if err == nil && id != "" { + if prev, dup := identities[id]; dup { + err = fmt.Errorf("%w: identity %q is already field %s's", ErrInvalid, id, prev.Field) + } else { + identities[id] = f + } + } if err != nil { errs = append(errs, fmt.Errorf("plan: %s: %w", f, err)) continue @@ -106,53 +118,77 @@ func (m Message) Build(facts []Fact) (stackencrypt.Plan, error) { return p, nil } -// field decides one field: its plan field, and whether it is planned. -func (m Message) field(f Fact) (stackencrypt.FieldPlan, bool, error) { +// field decides one field: its plan field, its EQL identity ("" for a +// Custom target), and whether it is planned. +func (m Message) field(f Fact) (stackencrypt.FieldPlan, string, bool, error) { + none := func(err error) (stackencrypt.FieldPlan, string, bool, error) { + return stackencrypt.FieldPlan{}, "", false, err + } d, ok := m.policy.Decide(f) if !ok { if len(f.Annotations) == 0 { - return stackencrypt.FieldPlan{}, false, nil + return none(nil) } - return stackencrypt.FieldPlan{}, false, ErrUnmatched + return none(ErrUnmatched) } switch d.verdict { case encrypt: case plaintext: if d.column != "" { - return stackencrypt.FieldPlan{}, false, fmt.Errorf("%w: Column(%q) pinned on a Plaintext field", ErrInvalid, d.column) + return none(fmt.Errorf("%w: Column(%q) pinned on a Plaintext field", ErrInvalid, d.column)) + } + if d.identity != "" { + return none(fmt.Errorf("%w: Identity(%q) pinned on a Plaintext field", ErrInvalid, d.identity)) } - return stackencrypt.FieldPlan{}, false, nil + return none(nil) case fail: - return stackencrypt.FieldPlan{}, false, fmt.Errorf("%w: %s", ErrRefused, d.reason) + return none(fmt.Errorf("%w: %s", ErrRefused, d.reason)) default: - return stackencrypt.FieldPlan{}, false, fmt.Errorf("%w: the zero Decision", ErrInvalid) + return none(fmt.Errorf("%w: the zero Decision", ErrInvalid)) } if d.target == nil { - return stackencrypt.FieldPlan{}, false, fmt.Errorf("%w: Encrypt with no target", ErrInvalid) + return none(fmt.Errorf("%w: Encrypt with no target", ErrInvalid)) } column := f.Field if d.column != "" { column = d.column } if column == "" { - return stackencrypt.FieldPlan{}, false, fmt.Errorf("%w: the field has no name to store it under", ErrInvalid) - } - // A '/' in the column would make an identity-shaped context ambiguous - // ("a/b" under "t" reads as "a" under "t/b" would). A Custom target's - // context is its own, and its column is only the record key. - if _, custom := d.target.(customTarget); !custom && strings.Contains(column, "/") { - return stackencrypt.FieldPlan{}, false, fmt.Errorf("%w: column %q contains '/', which would make its identity ambiguous", ErrInvalid, column) + return none(fmt.Errorf("%w: the field has no name to store it under", ErrInvalid)) + } + _, custom := d.target.(customTarget) + identity := column + if custom { + // A Custom target's context is its own: there is no identity to + // pin, and a pin would read as if it took effect. + if d.identity != "" { + return none(fmt.Errorf("%w: Identity(%q) on %v, whose context is fixed", ErrInvalid, d.identity, d.target)) + } + identity = "" + } else if d.identity != "" { + identity = d.identity + } + // A '/' in an EQL column would make an identity-shaped context + // ambiguous ("a/b" under "t" reads as "a" under "t/b" would), and the + // column is the identity until the day it is renamed. A Custom + // target's column is only the record key. + if !custom { + for _, name := range []string{column, identity} { + if strings.Contains(name, "/") { + return none(fmt.Errorf("%w: column %q contains '/', which would make its identity ambiguous", ErrInvalid, name)) + } + } } - context := d.target.Context(Identifier{Table: string(m.table), Column: column}) + context := d.target.Context(Identifier{Table: string(m.table), Column: identity}) if context == "" { - return stackencrypt.FieldPlan{}, false, fmt.Errorf("%w: target %v gives an empty context", ErrInvalid, d.target) + return none(fmt.Errorf("%w: target %v gives an empty context", ErrInvalid, d.target)) } return stackencrypt.FieldPlan{ Field: f.goField(), Name: column, Context: context, Terms: d.target.Terms(), - }, true, nil + }, identity, true, nil } func messageName(m Message, facts []Fact) string { diff --git a/languages/golang/stackencrypt/plan/plan_test.go b/languages/golang/stackencrypt/plan/plan_test.go index e7232fd91..fd88d1f23 100644 --- a/languages/golang/stackencrypt/plan/plan_test.go +++ b/languages/golang/stackencrypt/plan/plan_test.go @@ -151,8 +151,9 @@ func TestUnclassifiedFieldsAreLeftOutUnlessNamed(t *testing.T) { } } -// A context is fixed at first write. Pinning the column keeps it through a -// field rename (proto or Go) and is the identity a database rename keeps. +// A context is fixed at first write. Pinning the column keeps it, and the +// record key, through a field rename (proto or Go): on a column never +// renamed in the database, Column sets the identity too. func TestColumnPinSurvivesRenames(t *testing.T) { gov := []plan.Annotation{{Key: "fides.data_categories", Values: []string{"user.government_id"}}} before := []plan.Fact{{Message: "acme.v1.Individual", Field: "medicare_number", GoField: "MedicareNumber", Annotations: gov}} @@ -188,6 +189,37 @@ func TestColumnPinSurvivesRenames(t *testing.T) { } } +// A database rename moves the record key, never the identity: new writes go +// to the new column, under the context existing rows were written with. +func TestIdentityKeepsTheContextThroughAColumnRename(t *testing.T) { + gov := []plan.Annotation{{Key: "fides.data_categories", Values: []string{"user.government_id"}}} + facts := []plan.Fact{{Message: "acme.v1.Individual", Field: "medicare_no", GoField: "MedicareNo", Annotations: gov}} + eq := plan.Encrypt(plan.EQL(se.Equality)) + for name, tc := range map[string]struct { + opts []plan.RuleOption + key, context string + }{ + // Neither: both are the field's schema name. + "defaults": {nil, "medicare_no", "individuals/medicare_no"}, + // Column alone, on a field never renamed in the database: both. + "column": {[]plan.RuleOption{plan.Column("medicare_number")}, "medicare_number", "individuals/medicare_number"}, + // ALTER TABLE individuals RENAME COLUMN medicare_number TO medicare_num. + "renamed column": {[]plan.RuleOption{plan.Column("medicare_num"), plan.Identity("medicare_number")}, "medicare_num", "individuals/medicare_number"}, + // The column renamed to the field's own name. + "identity alone": {[]plan.RuleOption{plan.Identity("medicare_number")}, "medicare_no", "individuals/medicare_number"}, + } { + p, err := plan.ForMessage(nil, "individuals", plan.When(plan.Field("medicare_no"), eq, tc.opts...)).Build(facts) + if err != nil { + t.Errorf("%s: %v", name, err) + continue + } + want := []se.FieldPlan{{Field: "MedicareNo", Name: tc.key, Context: tc.context, Terms: []se.TermKind{se.Equality}}} + if got := p.Fields(); !reflect.DeepEqual(got, want) { + t.Errorf("%s: fields = %+v, want %+v", name, got, want) + } + } +} + func TestContextsByTarget(t *testing.T) { facts := []plan.Fact{ {Field: "email", GoField: "Email", Annotations: []plan.Annotation{{Key: "k", Values: []string{"eql"}}}}, @@ -240,6 +272,10 @@ func TestBuildRefusesMalformedDecisions(t *testing.T) { "fail": {"t", plan.When(plan.Field("a"), plan.Fail("biometrics are never stored")), plan.ErrRefused, ""}, "nil target": {"t", plan.When(plan.Field("a"), plan.Encrypt(nil)), plan.ErrInvalid, "no target"}, "column on plain": {"t", plan.When(plan.Field("a"), plan.Plaintext(), plan.Column("c")), plan.ErrInvalid, "Plaintext"}, + "identity on plain": {"t", plan.When(plan.Field("a"), plan.Plaintext(), plan.Identity("c")), plan.ErrInvalid, "Plaintext"}, + "identity on custom": {"t", plan.When(plan.Field("a"), plan.Encrypt(plan.Custom("ctx")), plan.Identity("c")), plan.ErrInvalid, "context is fixed"}, + "slash in identity": {"t", plan.When(plan.Field("a"), plan.Encrypt(plan.EQL()), plan.Column("c"), plan.Identity("x/y")), plan.ErrInvalid, "contains '/'"}, + "slash, renamed": {"t", plan.When(plan.Field("a"), plan.Encrypt(plan.EQL()), plan.Column("x/y"), plan.Identity("c")), plan.ErrInvalid, "contains '/'"}, "zero decision": {"t", plan.When(plan.Field("a"), plan.Decision{}), plan.ErrInvalid, "zero Decision"}, "empty context": {"t", plan.When(plan.Field("a"), plan.Encrypt(plan.Custom(""))), plan.ErrInvalid, "empty context"}, "nil policy": {"t", nil, plan.ErrUnmatched, ""}, @@ -274,6 +310,18 @@ func TestBuildRefusesMalformedDecisions(t *testing.T) { if _, err := plan.ForMessage(nil, "t", plan.When(plan.Any(plan.Field("a"), plan.Field("b")), plan.Encrypt(plan.EQL()), plan.Column("c"))).Build(two); err == nil { t.Error("two fields pinned to one column built") } + // Two columns with one identity would bind each other's ciphertexts. + _, err = plan.ForMessage(nil, "t", plan.FirstOf( + plan.When(plan.Field("a"), plan.Encrypt(plan.EQL())), + plan.When(plan.Field("b"), plan.Encrypt(plan.EQL()), plan.Identity("a")), + )).Build(two) + if !errors.Is(err, plan.ErrInvalid) || !strings.Contains(err.Error(), `identity "a" is already field a's`) { + t.Errorf("two fields sharing an identity: err = %v", err) + } + // Custom targets may share a context: it is the policy's to choose. + if _, err := plan.ForMessage(nil, "t", plan.When(plan.Any(plan.Field("a"), plan.Field("b")), plan.Encrypt(plan.Custom("ctx")))).Build(two); err != nil { + t.Errorf("two custom fields sharing a context: %v", err) + } } func TestKeyMatchers(t *testing.T) { @@ -347,25 +395,32 @@ func TestCombinatorsRefuseNilAndCopyTheirMatchers(t *testing.T) { } } -func TestColumnRefusesAnEmptyName(t *testing.T) { - defer func() { - if recover() == nil { - t.Error("Column(\"\") did not panic") - } - }() - plan.Column("") +func TestPinsRefuseAnEmptyName(t *testing.T) { + for name, pin := range map[string]func(string) plan.RuleOption{"Column": plan.Column, "Identity": plan.Identity} { + func() { + defer func() { + if recover() == nil { + t.Errorf("%s(\"\") did not panic", name) + } + }() + pin("") + }() + } } func TestDecisionsSpellThemselves(t *testing.T) { - d, ok := plan.When(plan.Field("a"), plan.Encrypt(plan.EQL(se.Equality, se.Match)), plan.Column("c")).Decide(plan.Fact{Field: "a"}) + d, ok := plan.When(plan.Field("a"), plan.Encrypt(plan.EQL(se.Equality, se.Match)), plan.Column("c"), plan.Identity("old_c")).Decide(plan.Fact{Field: "a"}) if !ok { t.Fatal("no match") } - if got, want := d.String(), `Encrypt(EQL(eq, match)) Column("c")`; got != want { + if got, want := d.String(), `Encrypt(EQL(eq, match)) Column("c") Identity("old_c")`; got != want { t.Errorf("String = %s, want %s", got, want) } - if target, ok := d.Target(); !ok || target == nil || d.Column() != "c" { - t.Errorf("accessors: %v %v %q", target, ok, d.Column()) + if target, ok := d.Target(); !ok || target == nil || d.Column() != "c" || d.Identity() != "old_c" { + t.Errorf("accessors: %v %v %q %q", target, ok, d.Column(), d.Identity()) + } + if d := plan.Encrypt(plan.EQL()); d.Column() != "" || d.Identity() != "" { + t.Errorf("unpinned accessors: %q %q", d.Column(), d.Identity()) } for d, want := range map[string]string{ plan.Plaintext().String(): "Plaintext()", diff --git a/languages/golang/stackencrypt/plan/policy.go b/languages/golang/stackencrypt/plan/policy.go index 112b1e8a7..2002b24f7 100644 --- a/languages/golang/stackencrypt/plan/policy.go +++ b/languages/golang/stackencrypt/plan/policy.go @@ -9,10 +9,12 @@ import ( ) // Identifier is a field's column identity: the table its message is -// stored in and the column it encrypts into. For an EQL target it is the -// field's encryption context, so it is fixed at first write and must never -// change — which is why the table is given, never derived from a message -// name, and why a rule can pin the column ([Column]) across renames. +// stored in and the column its data was first written to. For an EQL +// target it is the field's encryption context, so it is fixed at first +// write and must never change — which is why the table is given, never +// derived from a message name, and why a rule can pin the column half +// ([Identity]) apart from the column the value is stored in ([Column]) +// once the database column is renamed. type Identifier struct { Table string Column string @@ -94,10 +96,11 @@ const ( // target, leave it plaintext, or refuse it. Build one with [Encrypt], // [Plaintext] or [Fail]. type Decision struct { - verdict verdict - target Target - reason string - column string + verdict verdict + target Target + reason string + column string + identity string } // Encrypt decides that the field is encrypted into target. @@ -114,9 +117,14 @@ func Fail(reason string) Decision { return Decision{verdict: fail, reason: reaso // Target returns the decision's target, and whether it encrypts at all. func (d Decision) Target() (Target, bool) { return d.target, d.verdict == encrypt } -// Column returns the column the decision pins, or "" for the field's own. +// Column returns the column the decision stores the field in, or "" for +// the field's own name. func (d Decision) Column() string { return d.column } +// Identity returns the column half of the identity the decision pins, or +// "" for the effective column's (see [Identity]). +func (d Decision) Identity() string { return d.identity } + // String spells the decision for tests and errors. func (d Decision) String() string { var s string @@ -133,6 +141,9 @@ func (d Decision) String() string { if d.column != "" { s += fmt.Sprintf(" Column(%q)", d.column) } + if d.identity != "" { + s += fmt.Sprintf(" Identity(%q)", d.identity) + } return s } @@ -171,14 +182,15 @@ func FirstOf(policies ...Policy) Policy { // RuleOption adjusts the decision a [When] rule makes. type RuleOption func(*Decision) -// Column pins the column an encrypted field is stored under: its record -// name and, for an EQL target, the column half of its context. The pin is -// the column's identity, which may differ from the field's name after a -// field rename, and from the column's current name after a database -// rename: stored payloads keep the identity they were written under. Only -// meaningful with [Encrypt]; building a plan refuses it elsewhere. An -// empty name is a programming error and panics: a pin that is not there -// would silently bind the field's own name instead. +// Column names the column an encrypted field is stored in: its record +// key ([stackencrypt.FieldPlan.Name]). It defaults to the field's schema +// name, so a rule needs it only when the two differ — after the field is +// renamed in the schema, say. For an EQL target, the column also sets the +// field's identity unless [Identity] pins another: on a field never +// renamed in the database, Column alone is enough. Only meaningful with +// [Encrypt]; building a plan refuses it elsewhere. An empty name is a +// programming error and panics: a pin that is not there would silently +// store the field under its own name instead. func Column(name string) RuleOption { if name == "" { panic("plan.Column: empty column name") @@ -186,6 +198,30 @@ func Column(name string) RuleOption { return func(d *Decision) { d.column = name } } +// Identity pins the column half of an EQL field's identity +// ([Identifier]), and so its context "<table>/<column>": the AAD bound to +// every stored ciphertext, its ZeroKMS data-key binding and its terms' PRF +// context. It defaults to the effective [Column], so a field whose +// database column has never been renamed needs no Identity. +// +// Once data is written, a field's identity must never change: rows +// written under the old one would no longer decrypt, and their terms would +// no longer match queries. A database rename (ALTER TABLE ... RENAME +// COLUMN) is therefore spelled as the new column and the old identity: +// +// plan.When(plan.Field("medicare_no"), plan.Encrypt(plan.EQL(stackencrypt.Equality)), +// plan.Column("medicare_no"), plan.Identity("medicare_number")) +// +// Only meaningful with an EQL [Encrypt]: a [Custom] target's context is +// its own, and building a plan refuses Identity there and on [Plaintext]. +// An empty name is a programming error and panics, as for [Column]. +func Identity(name string) RuleOption { + if name == "" { + panic("plan.Identity: empty column name") + } + return func(d *Decision) { d.identity = name } +} + // When decides d for the fields m matches, and matches nothing else. func When(m Matcher, d Decision, opts ...RuleOption) Policy { if m == nil { diff --git a/languages/golang/stackencrypt/policy_plan_test.go b/languages/golang/stackencrypt/policy_plan_test.go index 16343d429..29da3233a 100644 --- a/languages/golang/stackencrypt/policy_plan_test.go +++ b/languages/golang/stackencrypt/policy_plan_test.go @@ -28,33 +28,45 @@ func TestPolicyPlanIsTheHandBuiltPlan(t *testing.T) { plan.When(category.Under("user"), plan.Encrypt(plan.EQL())), plan.When(category.Present(), plan.Plaintext()), ) - individuals := plan.ForMessage(&individual{}, "individuals", plan.FirstOf( - plan.When(plan.Field("medicare_no"), plan.Encrypt(plan.EQL(se.Equality, se.Ore)), plan.Column("medicare_number")), - ).OrElse(base)) - - fromPolicy := plan.MustPlanFor(plan.StructTags, individuals) - // The schema spelling of each column, as a Rust derive or a database - // would have it. - byHand, err := se.NewPlan( - se.FieldPlan{Field: "Email", Name: "email", Context: "individuals/email", Terms: []se.TermKind{se.Equality, se.Match}}, - se.FieldPlan{Field: "Name", Name: "name", Context: "individuals/name"}, - se.FieldPlan{Field: "MedicareNo", Name: "medicare_number", Context: "individuals/medicare_number", Terms: []se.TermKind{se.Equality, se.Ore}}, - ) - if err != nil { - t.Fatal(err) - } - typ := reflect.TypeOf(individual{}) - for _, ext := range [][]any{nil, {uint64(7)}} { - a, err := se.GuestPlanInput(fromPolicy, typ, ext...) - if err != nil { - t.Fatal(err) - } - b, err := se.GuestPlanInput(byHand, typ, ext...) + email := se.FieldPlan{Field: "Email", Name: "email", Context: "individuals/email", Terms: []se.TermKind{se.Equality, se.Match}} + name := se.FieldPlan{Field: "Name", Name: "name", Context: "individuals/name"} + for label, tc := range map[string]struct { + pins []plan.RuleOption + medicare se.FieldPlan + }{ + // The schema spelling of each column, as a Rust derive or a + // database would have it; Column alone sets the identity too. + "column": { + []plan.RuleOption{plan.Column("medicare_number")}, + se.FieldPlan{Field: "MedicareNo", Name: "medicare_number", Context: "individuals/medicare_number", Terms: []se.TermKind{se.Equality, se.Ore}}, + }, + // After a database rename: the new column, the old identity. + "renamed column": { + []plan.RuleOption{plan.Column("medicare_num"), plan.Identity("medicare_number")}, + se.FieldPlan{Field: "MedicareNo", Name: "medicare_num", Context: "individuals/medicare_number", Terms: []se.TermKind{se.Equality, se.Ore}}, + }, + } { + individuals := plan.ForMessage(&individual{}, "individuals", plan.FirstOf( + plan.When(plan.Field("medicare_no"), plan.Encrypt(plan.EQL(se.Equality, se.Ore)), tc.pins...), + ).OrElse(base)) + fromPolicy := plan.MustPlanFor(plan.StructTags, individuals) + byHand, err := se.NewPlan(email, name, tc.medicare) if err != nil { t.Fatal(err) } - if !bytes.Equal(a, b) { - t.Fatalf("extension %v: guest input differs:\npolicy %x\nhand %x", ext, a, b) + typ := reflect.TypeOf(individual{}) + for _, ext := range [][]any{nil, {uint64(7)}} { + a, err := se.GuestPlanInput(fromPolicy, typ, ext...) + if err != nil { + t.Fatal(err) + } + b, err := se.GuestPlanInput(byHand, typ, ext...) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(a, b) { + t.Fatalf("%s, extension %v: guest input differs:\npolicy %x\nhand %x", label, ext, a, b) + } } } } From f799a1ed6d072f384ea7ad583754b293ac40d3e6 Mon Sep 17 00:00:00 2001 From: Dan Draper <dan@cipherstash.com> Date: Mon, 28 Sep 2026 20:39:17 -0700 Subject: [PATCH 667/686] refactor(go)!: move the struct-tag fact source out of the plan API The `facts:"key=v1,v2;key2=v3"` struct tag was a syntax invented for tests, not a source of facts anyone should write against. Facts come from real schemas: the protobuf descriptor source planned in CIP-4088. plan.StructTags and its tag parser move to internal/factstest, which the plan and stackencrypt tests import; being internal, it is not API. Source, SourceFunc, Fact and Annotation stay public as the extension point. The package doc and README examples now plug facts in through a plan.SourceFunc returning literal facts. The tag parser's tests move with it. The plan package's own tests cover Fact.String, Fact.Values and SourceFunc directly. BREAKING CHANGE: plan.StructTags is removed. Supply facts through a plan.Source, such as a plan.SourceFunc. CIP-4160 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- .../golang/internal/factstest/factstest.go | 161 ++++++++++++++++++ .../internal/factstest/factstest_test.go | 96 +++++++++++ languages/golang/stackencrypt/README.md | 31 ++-- languages/golang/stackencrypt/plan/doc.go | 27 ++- languages/golang/stackencrypt/plan/fact.go | 156 +---------------- languages/golang/stackencrypt/plan/message.go | 5 +- .../golang/stackencrypt/plan/plan_test.go | 101 ++--------- .../golang/stackencrypt/policy_plan_test.go | 3 +- 8 files changed, 320 insertions(+), 260 deletions(-) create mode 100644 languages/golang/internal/factstest/factstest.go create mode 100644 languages/golang/internal/factstest/factstest_test.go diff --git a/languages/golang/internal/factstest/factstest.go b/languages/golang/internal/factstest/factstest.go new file mode 100644 index 000000000..666c7b8d6 --- /dev/null +++ b/languages/golang/internal/factstest/factstest.go @@ -0,0 +1,161 @@ +// Package factstest is a test-only fact source for the plan package: Go +// struct fields annotated with a `facts` tag. The tag syntax is not API; +// real facts come from a schema, such as the protobuf source planned in +// CIP-4088. Internal, and imported only by _test files. +package factstest + +import ( + "errors" + "fmt" + "reflect" + "slices" + "strings" + "unicode" + + "github.com/cipherstash/cipherstash-suite/bindings/go/stackencrypt/plan" +) + +// StructTags is a Go-struct fact source for tests: one fact per exported, +// direct field of a struct (msg is a struct value or a pointer to one), +// with the annotations its `facts` tag lists: +// +// type Individual struct { +// ID int64 +// Email string `facts:"fides.data_categories=user.contact.email"` +// MedicareNo string `facts:"fides.data_categories=user.government_id,user.financial"` +// } +// +// The tag is `key=value[,value...]`, repeated with `;` for more keys. +// GoField is the Go field name and Field is its snake_case ("MedicareNo" +// is "medicare_no", "ID" is "id", "HTTPPort" is "http_port"): the name a +// proto field or a database column would have, so the column identity a +// field binds by default is the one the Rust derive and the schema spell. +// Number is 0 and Kind is the field's reflect.Kind (through one pointer). +// +// Unexported and embedded fields are not facts: a plan binds exported, +// direct fields only. A `facts` tag on one — or on any field of an +// embedded struct — is an error, not a field quietly left in plaintext. +var StructTags plan.Source = plan.SourceFunc(structFacts) + +func structFacts(msg any) ([]plan.Fact, error) { + t := reflect.TypeOf(msg) + if t != nil && t.Kind() == reflect.Pointer { + t = t.Elem() + } + if t == nil || t.Kind() != reflect.Struct { + return nil, fmt.Errorf("factstest: StructTags reads structs, not %T", msg) + } + facts := make([]plan.Fact, 0, t.NumField()) + for i := 0; i < t.NumField(); i++ { + sf := t.Field(i) + if !sf.IsExported() || sf.Anonymous { + if err := refuseUnbindableTag(sf); err != nil { + return nil, fmt.Errorf("factstest: %s.%s: %w", t, sf.Name, err) + } + continue + } + kind := sf.Type + if kind.Kind() == reflect.Pointer { + kind = kind.Elem() + } + annotations, err := parseFactsTag(sf.Tag.Get("facts")) + if err != nil { + return nil, fmt.Errorf("factstest: %s.%s: %w", t, sf.Name, err) + } + facts = append(facts, plan.Fact{ + Message: t.String(), + Field: snakeCase(sf.Name), + GoField: sf.Name, + Kind: kind.Kind().String(), + Annotations: annotations, + }) + } + return facts, nil +} + +// refuseUnbindableTag is the error for a `facts` tag on a field a plan +// cannot bind: an unexported or embedded field, or any field of an +// embedded struct, however deep. The tag says the field is classified; +// dropping it would store the field in plaintext with no rule ever asked. +func refuseUnbindableTag(sf reflect.StructField) error { + if sf.Tag.Get("facts") != "" { + if sf.Anonymous { + return errors.New("a facts tag on an embedded field, which a plan cannot bind") + } + return errors.New("a facts tag on an unexported field, which a plan cannot bind") + } + if !sf.Anonymous { + return nil + } + if tagged := firstFactsTag(sf.Type); tagged != "" { + return fmt.Errorf("embedded %s has a facts tag on %s, which a plan cannot bind; make it a direct field", sf.Type, tagged) + } + return nil +} + +// firstFactsTag names the first field of t (a struct, through one +// pointer), or of a struct embedded in it, that carries a facts tag; "" +// when none does. +func firstFactsTag(t reflect.Type) string { + if t.Kind() == reflect.Pointer { + t = t.Elem() + } + if t.Kind() != reflect.Struct { + return "" + } + for i := 0; i < t.NumField(); i++ { + sf := t.Field(i) + if sf.Tag.Get("facts") != "" { + return sf.Name + } + if sf.Anonymous { + if name := firstFactsTag(sf.Type); name != "" { + return sf.Name + "." + name + } + } + } + return "" +} + +// snakeCase is a Go field name as a schema would spell it: a lower-case +// word per hump, joined by underscores, with an initialism kept as one +// word ("HTTPPort" is "http_port", "ID" is "id"). Digits stay with the +// word before them ("Line2" is "line2"). +func snakeCase(name string) string { + runes := []rune(name) + var b strings.Builder + b.Grow(len(name) + 4) + for i, r := range runes { + if i > 0 && unicode.IsUpper(r) { + prev := runes[i-1] + nextLower := i+1 < len(runes) && unicode.IsLower(runes[i+1]) + if unicode.IsLower(prev) || unicode.IsDigit(prev) || (unicode.IsUpper(prev) && nextLower) { + b.WriteByte('_') + } + } + b.WriteRune(unicode.ToLower(r)) + } + return b.String() +} + +func parseFactsTag(tag string) ([]plan.Annotation, error) { + if tag == "" { + return nil, nil + } + var out []plan.Annotation + for _, part := range strings.Split(tag, ";") { + key, values, ok := strings.Cut(part, "=") + if !ok || key == "" || values == "" { + return nil, fmt.Errorf("facts tag %q: want key=value[,value...]", part) + } + if slices.ContainsFunc(out, func(a plan.Annotation) bool { return a.Key == key }) { + return nil, fmt.Errorf("facts tag: key %q given twice", key) + } + vs := strings.Split(values, ",") + if slices.Contains(vs, "") { + return nil, errors.New("facts tag: empty value for " + key) + } + out = append(out, plan.Annotation{Key: key, Values: vs}) + } + return out, nil +} diff --git a/languages/golang/internal/factstest/factstest_test.go b/languages/golang/internal/factstest/factstest_test.go new file mode 100644 index 000000000..fcf15569b --- /dev/null +++ b/languages/golang/internal/factstest/factstest_test.go @@ -0,0 +1,96 @@ +package factstest_test + +import ( + "reflect" + "strings" + "testing" + + "github.com/cipherstash/cipherstash-suite/bindings/go/internal/factstest" + "github.com/cipherstash/cipherstash-suite/bindings/go/stackencrypt/plan" +) + +func TestStructTags(t *testing.T) { + type embedded struct{ Inner string } + type row struct { + embedded + ID *int64 + Email string `facts:"a=x,y;b=z"` + hidden string //nolint:unused // proves untagged unexported fields are skipped + } + facts, err := factstest.StructTags.Facts(&row{}) + if err != nil { + t.Fatal(err) + } + want := []plan.Fact{ + {Message: "factstest_test.row", Field: "id", GoField: "ID", Kind: "int64"}, + {Message: "factstest_test.row", Field: "email", GoField: "Email", Kind: "string", Annotations: []plan.Annotation{ + {Key: "a", Values: []string{"x", "y"}}, {Key: "b", Values: []string{"z"}}, + }}, + } + if !reflect.DeepEqual(facts, want) { + t.Fatalf("facts =\n%+v\nwant\n%+v", facts, want) + } + if got := facts[1].String(); got != "factstest_test.row.email (Email) [a=x,y; b=z]" { + t.Errorf("String = %s", got) + } + type taggedInner struct { + Secret string `facts:"a=x"` + } + type deeper struct{ taggedInner } + for name, bad := range map[string]struct { + msg any + say string + }{ + "not a struct": {42, "reads structs"}, + "nil": {nil, "reads structs"}, + "no value": {struct { + A string `facts:"a="` + }{}, "key=value"}, + "no key": {struct { + A string `facts:"=x"` + }{}, "key=value"}, + "empty value": {struct { + A string `facts:"a=x,"` + }{}, "empty value"}, + "key twice": {struct { + A string `facts:"a=x;a=y"` + }{}, "given twice"}, + // A tag the plan cannot bind is refused, never quietly plaintext. + "tagged unexported field": {struct { + medicareNo string `facts:"a=x"` //nolint:unused // the tag is the point + }{}, "unexported field"}, + "tagged embedded field": {struct { + embedded `facts:"a=x"` + }{}, "embedded field"}, + "tag inside an embedded struct": {struct{ taggedInner }{}, "Secret"}, + "tag two embeddings deep": {struct{ deeper }{}, "taggedInner.Secret"}, + } { + _, err := factstest.StructTags.Facts(bad.msg) + if err == nil { + t.Errorf("%s: facts read", name) + } else if !strings.Contains(err.Error(), bad.say) { + t.Errorf("%s: err = %q, want it to say %q", name, err, bad.say) + } + } + // The schema spelling of a Go field name. + type spelled struct { + ID int64 + Email string + HTTPPort int + MedicareNo string + Line2 string + UserID string + OAuth2Key string + } + got, err := factstest.StructTags.Facts(spelled{}) + if err != nil { + t.Fatal(err) + } + names := make([]string, len(got)) + for i, f := range got { + names[i] = f.Field + } + if want := []string{"id", "email", "http_port", "medicare_no", "line2", "user_id", "o_auth2_key"}; !reflect.DeepEqual(names, want) { + t.Errorf("schema names = %v, want %v", names, want) + } +} diff --git a/languages/golang/stackencrypt/README.md b/languages/golang/stackencrypt/README.md index d2b465510..49b71e638 100644 --- a/languages/golang/stackencrypt/README.md +++ b/languages/golang/stackencrypt/README.md @@ -228,10 +228,22 @@ import "github.com/cipherstash/cipherstash-suite/bindings/go/stackencrypt/plan" type Individual struct { ID int64 - Email string `facts:"fides.data_categories=user.contact.email"` - MedicareNo string `facts:"fides.data_categories=user.government_id"` + Email string + MedicareNo string } +// Facts come from a Source, such as the protobuf one planned in CIP-4088. +// Any function returning facts is one. +var source = plan.SourceFunc(func(msg any) ([]plan.Fact, error) { + return []plan.Fact{ + {Field: "id", GoField: "ID"}, + {Field: "email", GoField: "Email", Annotations: []plan.Annotation{ + {Key: "fides.data_categories", Values: []string{"user.contact.email"}}}}, + {Field: "medicare_no", GoField: "MedicareNo", Annotations: []plan.Annotation{ + {Key: "fides.data_categories", Values: []string{"user.government_id"}}}}, + }, nil +}) + var category = plan.Key("fides.data_categories") var Base = plan.FirstOf( @@ -249,7 +261,7 @@ var Individuals = plan.ForMessage(&Individual{}, plan.Table("individuals"), // At startup: panics if a classified field is decided by no rule, or the // plan names a field the struct does not have. -var individuals = plan.MustPlanFor(plan.StructTags, Individuals) +var individuals = plan.MustPlanFor(source, Individuals) records, err := cipher.EncryptRecords(ctx, rows, stackencrypt.WithPlan(individuals)) ``` @@ -257,19 +269,16 @@ records, err := cipher.EncryptRecords(ctx, rows, stackencrypt.WithPlan(individua A policy fails closed: a field with facts that no rule decides is an error when the plan is built, naming the field and its facts. There is no default; write a catch-all, `Plaintext()` included, in the policy. Fields with no -facts are left out and stored as they are. A `facts` tag on a field a plan -cannot bind (unexported, or inside an embedded struct) is an error too, -not a field quietly left in plaintext. A message the policy encrypts +facts are left out and stored as they are. A message the policy encrypts nothing of has no plan: `PlanFor` reports `ErrNothingEncrypted`, and its records are stored without one. An EQL target's context is its column identity, `"<table>/<column>"`. The table is required per message, never derived from its name. A field is -stored in the column named by its schema name — for a Go struct, the field -name in snake_case (`MedicareNo` is `medicare_no`), the spelling the Rust -derive and the database column share — unless a rule names another with -`plan.Column`, and that column is also its identity unless the rule pins -one with `plan.Identity`. `plan.Field` matches on that same schema name. +stored in the column named by its schema name (its `Fact.Field`, such as +`medicare_no`: the spelling the Rust derive and the database column share) +unless a rule names another with `plan.Column`, and that column is also its +identity unless the rule pins one with `plan.Identity`. `plan.Field` matches on that same schema name. The identity is bound into every stored ciphertext, its data key and its index terms, so once data is written it must never change. A field never diff --git a/languages/golang/stackencrypt/plan/doc.go b/languages/golang/stackencrypt/plan/doc.go index 16f05a3d9..385da3a99 100644 --- a/languages/golang/stackencrypt/plan/doc.go +++ b/languages/golang/stackencrypt/plan/doc.go @@ -24,18 +24,29 @@ // ).OrElse(Base), // ) // -// var individuals = plan.MustPlanFor(plan.StructTags, Individuals) +// var individuals = plan.MustPlanFor(source, Individuals) // // cipher.EncryptRecords(ctx, rows, stackencrypt.WithPlan(individuals)) // // # Facts // -// A [Source] makes the facts for a message. [StructTags] reads a Go -// struct's `facts` tags, naming each field as a schema would (the Go name -// in snake_case); a protobuf source reads descriptors and their custom -// options the same way, and needs nothing from this package beyond [Fact], -// [Source] and [Key]. [Message.Build] takes facts directly, so a generator -// or a test can build a plan without a source at all; [PlanFor] also checks -// the plan binds to the message's Go type. +// A [Source] makes the facts for a message. A protobuf source (planned in +// CIP-4088) reads descriptors and their custom options, and needs nothing +// from this package beyond [Fact], [Source] and [Key]. Any function +// returning facts is one: +// +// var source = plan.SourceFunc(func(msg any) ([]plan.Fact, error) { +// return []plan.Fact{ +// {Field: "id", GoField: "ID"}, +// {Field: "email", GoField: "Email", Annotations: []plan.Annotation{ +// {Key: "fides.data_categories", Values: []string{"user.contact.email"}}}}, +// {Field: "medicare_no", GoField: "MedicareNo", Annotations: []plan.Annotation{ +// {Key: "fides.data_categories", Values: []string{"user.government_id"}}}}, +// }, nil +// }) +// +// [Message.Build] takes facts directly, so a generator or a test can build +// a plan without a source at all; [PlanFor] also checks the plan binds to +// the message's Go type. // // # Contexts // diff --git a/languages/golang/stackencrypt/plan/fact.go b/languages/golang/stackencrypt/plan/fact.go index af15da774..6955da6fa 100644 --- a/languages/golang/stackencrypt/plan/fact.go +++ b/languages/golang/stackencrypt/plan/fact.go @@ -1,19 +1,16 @@ package plan import ( - "errors" "fmt" - "reflect" "slices" "strings" - "unicode" ) // Fact is what the SDK knows about one field of a message: where it is, // what it is, and the annotations the domain schema put on it. It is // source-agnostic. A [Source] makes facts from something that describes a -// message (a protobuf descriptor, a Go struct type); a policy reads them -// and never learns where they came from. +// message, such as a protobuf descriptor; a policy reads them and never +// learns where they came from. type Fact struct { // Message is the message's (or struct's) full name, for errors. Message string @@ -104,8 +101,8 @@ func (f Fact) String() string { } // Source makes the facts for a message. msg is whatever [ForMessage] was -// given: a struct value or pointer for [StructTags], a proto.Message for a -// protobuf source. Facts are returned in field order. +// given, such as a proto.Message for the protobuf source planned in +// CIP-4088. Facts are returned in field order. type Source interface { Facts(msg any) ([]Fact, error) } @@ -115,148 +112,3 @@ type SourceFunc func(msg any) ([]Fact, error) // Facts calls f. func (f SourceFunc) Facts(msg any) ([]Fact, error) { return f(msg) } - -// StructTags is the Go-struct fact source: one fact per exported, direct -// field of a struct (msg is a struct value or a pointer to one), with the -// annotations its `facts` tag lists: -// -// type Individual struct { -// ID int64 -// Email string `facts:"fides.data_categories=user.contact.email"` -// MedicareNo string `facts:"fides.data_categories=user.government_id,user.financial"` -// } -// -// The tag is `key=value[,value...]`, repeated with `;` for more keys. -// GoField is the Go field name and Field is its snake_case ("MedicareNo" -// is "medicare_no", "ID" is "id", "HTTPPort" is "http_port"): the name a -// proto field or a database column would have, so the column identity a -// field binds by default is the one the Rust derive and the schema spell. -// Number is 0 and Kind is the field's reflect.Kind (through one pointer). -// -// Unexported and embedded fields are not facts: a plan binds exported, -// direct fields only. A `facts` tag on one — or on any field of an -// embedded struct — is an error, not a field quietly left in plaintext. -var StructTags Source = SourceFunc(structFacts) - -func structFacts(msg any) ([]Fact, error) { - t := reflect.TypeOf(msg) - if t != nil && t.Kind() == reflect.Pointer { - t = t.Elem() - } - if t == nil || t.Kind() != reflect.Struct { - return nil, fmt.Errorf("plan: StructTags reads structs, not %T", msg) - } - facts := make([]Fact, 0, t.NumField()) - for i := 0; i < t.NumField(); i++ { - sf := t.Field(i) - if !sf.IsExported() || sf.Anonymous { - if err := refuseUnbindableTag(sf); err != nil { - return nil, fmt.Errorf("plan: %s.%s: %w", t, sf.Name, err) - } - continue - } - kind := sf.Type - if kind.Kind() == reflect.Pointer { - kind = kind.Elem() - } - annotations, err := parseFactsTag(sf.Tag.Get("facts")) - if err != nil { - return nil, fmt.Errorf("plan: %s.%s: %w", t, sf.Name, err) - } - facts = append(facts, Fact{ - Message: t.String(), - Field: snakeCase(sf.Name), - GoField: sf.Name, - Kind: kind.Kind().String(), - Annotations: annotations, - }) - } - return facts, nil -} - -// refuseUnbindableTag is the error for a `facts` tag on a field a plan -// cannot bind: an unexported or embedded field, or any field of an -// embedded struct, however deep. The tag says the field is classified; -// dropping it would store the field in plaintext with no rule ever asked. -func refuseUnbindableTag(sf reflect.StructField) error { - if sf.Tag.Get("facts") != "" { - if sf.Anonymous { - return errors.New("a facts tag on an embedded field, which a plan cannot bind") - } - return errors.New("a facts tag on an unexported field, which a plan cannot bind") - } - if !sf.Anonymous { - return nil - } - if tagged := firstFactsTag(sf.Type); tagged != "" { - return fmt.Errorf("embedded %s has a facts tag on %s, which a plan cannot bind; make it a direct field", sf.Type, tagged) - } - return nil -} - -// firstFactsTag names the first field of t (a struct, through one -// pointer), or of a struct embedded in it, that carries a facts tag; "" -// when none does. -func firstFactsTag(t reflect.Type) string { - if t.Kind() == reflect.Pointer { - t = t.Elem() - } - if t.Kind() != reflect.Struct { - return "" - } - for i := 0; i < t.NumField(); i++ { - sf := t.Field(i) - if sf.Tag.Get("facts") != "" { - return sf.Name - } - if sf.Anonymous { - if name := firstFactsTag(sf.Type); name != "" { - return sf.Name + "." + name - } - } - } - return "" -} - -// snakeCase is a Go field name as a schema would spell it: a lower-case -// word per hump, joined by underscores, with an initialism kept as one -// word ("HTTPPort" is "http_port", "ID" is "id"). Digits stay with the -// word before them ("Line2" is "line2"). -func snakeCase(name string) string { - runes := []rune(name) - var b strings.Builder - b.Grow(len(name) + 4) - for i, r := range runes { - if i > 0 && unicode.IsUpper(r) { - prev := runes[i-1] - nextLower := i+1 < len(runes) && unicode.IsLower(runes[i+1]) - if unicode.IsLower(prev) || unicode.IsDigit(prev) || (unicode.IsUpper(prev) && nextLower) { - b.WriteByte('_') - } - } - b.WriteRune(unicode.ToLower(r)) - } - return b.String() -} - -func parseFactsTag(tag string) ([]Annotation, error) { - if tag == "" { - return nil, nil - } - var out []Annotation - for _, part := range strings.Split(tag, ";") { - key, values, ok := strings.Cut(part, "=") - if !ok || key == "" || values == "" { - return nil, fmt.Errorf("facts tag %q: want key=value[,value...]", part) - } - if slices.ContainsFunc(out, func(a Annotation) bool { return a.Key == key }) { - return nil, fmt.Errorf("facts tag: key %q given twice", key) - } - vs := strings.Split(values, ",") - if slices.Contains(vs, "") { - return nil, errors.New("facts tag: empty value for " + key) - } - out = append(out, Annotation{Key: key, Values: vs}) - } - return out, nil -} diff --git a/languages/golang/stackencrypt/plan/message.go b/languages/golang/stackencrypt/plan/message.go index 03dd40fe9..05dc34782 100644 --- a/languages/golang/stackencrypt/plan/message.go +++ b/languages/golang/stackencrypt/plan/message.go @@ -44,9 +44,8 @@ type Message struct { } // ForMessage scopes policy to msg, stored in table. msg is what the -// [Source] reads facts from: a struct value or pointer for [StructTags], a -// proto.Message for a protobuf source. Refine a shared base per message -// with OrElse: +// [Source] reads facts from, such as a proto.Message for a protobuf +// source. Refine a shared base per message with OrElse: // // var Individuals = plan.ForMessage(&Individual{}, plan.Table("individuals"), // plan.FirstOf( diff --git a/languages/golang/stackencrypt/plan/plan_test.go b/languages/golang/stackencrypt/plan/plan_test.go index fd88d1f23..34e3c3f4e 100644 --- a/languages/golang/stackencrypt/plan/plan_test.go +++ b/languages/golang/stackencrypt/plan/plan_test.go @@ -7,6 +7,7 @@ import ( "strings" "testing" + "github.com/cipherstash/cipherstash-suite/bindings/go/internal/factstest" se "github.com/cipherstash/cipherstash-suite/bindings/go/stackencrypt" "github.com/cipherstash/cipherstash-suite/bindings/go/stackencrypt/plan" ) @@ -35,7 +36,7 @@ var individuals = plan.ForMessage(&individual{}, plan.Table("individuals"), ) func TestPolicyBuildsThePlan(t *testing.T) { - p, err := plan.PlanFor(plan.StructTags, individuals) + p, err := plan.PlanFor(factstest.StructTags, individuals) if err != nil { t.Fatal(err) } @@ -65,7 +66,7 @@ func TestUnmatchedFactFailsTheBuild(t *testing.T) { plan.When(category.Under("user.contact"), plan.Encrypt(plan.EQL(se.Equality))), ) m := plan.ForMessage(patient{}, "patients", narrow) - _, err := plan.PlanFor(plan.StructTags, m) + _, err := plan.PlanFor(factstest.StructTags, m) if !errors.Is(err, plan.ErrUnmatched) { t.Fatalf("err = %v, want ErrUnmatched", err) } @@ -86,14 +87,14 @@ func TestUnmatchedFactFailsTheBuild(t *testing.T) { t.Error("MustPlanFor did not panic on an unmatched fact") } }() - plan.MustPlanFor(plan.StructTags, m) + plan.MustPlanFor(factstest.StructTags, m) }() // A catch-all written in the policy closes the gap; Plaintext counts. closed := plan.ForMessage(patient{}, "patients", narrow.OrElse( plan.When(category.Present(), plan.Plaintext()), )) - p := plan.MustPlanFor(plan.StructTags, closed) + p := plan.MustPlanFor(factstest.StructTags, closed) if got := p.Fields(); len(got) != 1 || got[0].Field != "Email" { t.Fatalf("fields = %+v, want Email only", got) } @@ -107,7 +108,7 @@ func TestNothingEncryptedIsItsOwnError(t *testing.T) { Kind string `facts:"fides.data_categories=system.operations"` } m := plan.ForMessage(audit{}, "audits", plan.When(category.Under("system"), plan.Plaintext())) - _, err := plan.PlanFor(plan.StructTags, m) + _, err := plan.PlanFor(factstest.StructTags, m) if !errors.Is(err, plan.ErrNothingEncrypted) { t.Fatalf("err = %v, want ErrNothingEncrypted", err) } @@ -438,89 +439,19 @@ func TestDecisionsSpellThemselves(t *testing.T) { } } -func TestStructTagsSource(t *testing.T) { - type embedded struct{ Inner string } - type row struct { - embedded - ID *int64 - Email string `facts:"a=x,y;b=z"` - hidden string //nolint:unused // proves untagged unexported fields are skipped - } - facts, err := plan.StructTags.Facts(&row{}) - if err != nil { - t.Fatal(err) - } - want := []plan.Fact{ - {Message: "plan_test.row", Field: "id", GoField: "ID", Kind: "int64"}, - {Message: "plan_test.row", Field: "email", GoField: "Email", Kind: "string", Annotations: []plan.Annotation{ - {Key: "a", Values: []string{"x", "y"}}, {Key: "b", Values: []string{"z"}}, - }}, - } - if !reflect.DeepEqual(facts, want) { - t.Fatalf("facts =\n%+v\nwant\n%+v", facts, want) - } - if got := facts[1].String(); got != "plan_test.row.email (Email) [a=x,y; b=z]" { +func TestFactAndSource(t *testing.T) { + f := plan.Fact{Message: "row", Field: "email", GoField: "Email", Annotations: []plan.Annotation{ + {Key: "a", Values: []string{"x", "y"}}, {Key: "b", Values: []string{"z"}}, {Key: "a", Values: []string{"w"}}, + }} + if got := f.String(); got != "row.email (Email) [a=x,y; b=z; a=w]" { t.Errorf("String = %s", got) } - type taggedInner struct { - Secret string `facts:"a=x"` - } - type deeper struct{ taggedInner } - for name, bad := range map[string]struct { - msg any - say string - }{ - "not a struct": {42, "reads structs"}, - "nil": {nil, "reads structs"}, - "no value": {struct { - A string `facts:"a="` - }{}, "key=value"}, - "no key": {struct { - A string `facts:"=x"` - }{}, "key=value"}, - "empty value": {struct { - A string `facts:"a=x,"` - }{}, "empty value"}, - "key twice": {struct { - A string `facts:"a=x;a=y"` - }{}, "given twice"}, - // A tag the plan cannot bind is refused, never quietly plaintext. - "tagged unexported field": {struct { - medicareNo string `facts:"a=x"` //nolint:unused // the tag is the point - }{}, "unexported field"}, - "tagged embedded field": {struct { - embedded `facts:"a=x"` - }{}, "embedded field"}, - "tag inside an embedded struct": {struct{ taggedInner }{}, "Secret"}, - "tag two embeddings deep": {struct{ deeper }{}, "taggedInner.Secret"}, - } { - _, err := plan.StructTags.Facts(bad.msg) - if err == nil { - t.Errorf("%s: facts read", name) - } else if !strings.Contains(err.Error(), bad.say) { - t.Errorf("%s: err = %q, want it to say %q", name, err, bad.say) - } - } - // The schema spelling of a Go field name. - type spelled struct { - ID int64 - Email string - HTTPPort int - MedicareNo string - Line2 string - UserID string - OAuth2Key string - } - got, err := plan.StructTags.Facts(spelled{}) - if err != nil { - t.Fatal(err) - } - names := make([]string, len(got)) - for i, f := range got { - names[i] = f.Field + if got := f.Values("a"); !reflect.DeepEqual(got, []string{"x", "y", "w"}) { + t.Errorf("Values = %v", got) } - if want := []string{"id", "email", "http_port", "medicare_no", "line2", "user_id", "o_auth2_key"}; !reflect.DeepEqual(names, want) { - t.Errorf("schema names = %v, want %v", names, want) + src := plan.SourceFunc(func(any) ([]plan.Fact, error) { return []plan.Fact{f}, nil }) + if got, err := src.Facts(nil); err != nil || len(got) != 1 { + t.Errorf("SourceFunc.Facts = %v, %v", got, err) } if _, err := plan.PlanFor(nil, individuals); err == nil { t.Error("PlanFor without a source") diff --git a/languages/golang/stackencrypt/policy_plan_test.go b/languages/golang/stackencrypt/policy_plan_test.go index 29da3233a..2630ccf5c 100644 --- a/languages/golang/stackencrypt/policy_plan_test.go +++ b/languages/golang/stackencrypt/policy_plan_test.go @@ -5,6 +5,7 @@ import ( "reflect" "testing" + "github.com/cipherstash/cipherstash-suite/bindings/go/internal/factstest" se "github.com/cipherstash/cipherstash-suite/bindings/go/stackencrypt" "github.com/cipherstash/cipherstash-suite/bindings/go/stackencrypt/plan" ) @@ -49,7 +50,7 @@ func TestPolicyPlanIsTheHandBuiltPlan(t *testing.T) { individuals := plan.ForMessage(&individual{}, "individuals", plan.FirstOf( plan.When(plan.Field("medicare_no"), plan.Encrypt(plan.EQL(se.Equality, se.Ore)), tc.pins...), ).OrElse(base)) - fromPolicy := plan.MustPlanFor(plan.StructTags, individuals) + fromPolicy := plan.MustPlanFor(factstest.StructTags, individuals) byHand, err := se.NewPlan(email, name, tc.medicare) if err != nil { t.Fatal(err) From c0efd52492cf9ebf620d3117a5e8fb9abe7b09a2 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Tue, 29 Sep 2026 03:51:43 +0000 Subject: [PATCH 668/686] fix(go): terminate the embedded-struct scan and refuse a nil type The struct-tag source's search for facts tags inside embedded structs keeps the structs on its path, so a recursive embedding (type Node struct { *Node; ... }) is searched once rather than forever; a struct's own self-embedding is not searched at all, since its fields are the struct's own, already read. Plan.Validate refuses a nil type with the error PlanFromTags gives, instead of dereferencing it. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TJc73YNcTeGaLDhP2P3LNM --- .../golang/internal/factstest/factstest.go | 27 +++++++++++-------- .../internal/factstest/factstest_test.go | 21 +++++++++++++++ .../golang/stackencrypt/policy_plan_test.go | 15 +++++++++++ languages/golang/stackencrypt/record.go | 3 +++ 4 files changed, 55 insertions(+), 11 deletions(-) diff --git a/languages/golang/internal/factstest/factstest.go b/languages/golang/internal/factstest/factstest.go index 666c7b8d6..7453d4753 100644 --- a/languages/golang/internal/factstest/factstest.go +++ b/languages/golang/internal/factstest/factstest.go @@ -49,7 +49,7 @@ func structFacts(msg any) ([]plan.Fact, error) { for i := 0; i < t.NumField(); i++ { sf := t.Field(i) if !sf.IsExported() || sf.Anonymous { - if err := refuseUnbindableTag(sf); err != nil { + if err := refuseUnbindableTag(t, sf); err != nil { return nil, fmt.Errorf("factstest: %s.%s: %w", t, sf.Name, err) } continue @@ -73,11 +73,13 @@ func structFacts(msg any) ([]plan.Fact, error) { return facts, nil } -// refuseUnbindableTag is the error for a `facts` tag on a field a plan -// cannot bind: an unexported or embedded field, or any field of an -// embedded struct, however deep. The tag says the field is classified; -// dropping it would store the field in plaintext with no rule ever asked. -func refuseUnbindableTag(sf reflect.StructField) error { +// refuseUnbindableTag is the error for a `facts` tag on a field sf of +// outer that a plan cannot bind: an unexported or embedded field, or any +// field of an embedded struct, however deep. The tag says the field is +// classified; dropping it would store the field in plaintext with no rule +// ever asked. A struct that embeds itself (`type Node struct { *Node; ... +// }`) is not searched again: its fields are outer's own, already read. +func refuseUnbindableTag(outer reflect.Type, sf reflect.StructField) error { if sf.Tag.Get("facts") != "" { if sf.Anonymous { return errors.New("a facts tag on an embedded field, which a plan cannot bind") @@ -87,7 +89,7 @@ func refuseUnbindableTag(sf reflect.StructField) error { if !sf.Anonymous { return nil } - if tagged := firstFactsTag(sf.Type); tagged != "" { + if tagged := firstFactsTag(sf.Type, []reflect.Type{outer}); tagged != "" { return fmt.Errorf("embedded %s has a facts tag on %s, which a plan cannot bind; make it a direct field", sf.Type, tagged) } return nil @@ -95,21 +97,24 @@ func refuseUnbindableTag(sf reflect.StructField) error { // firstFactsTag names the first field of t (a struct, through one // pointer), or of a struct embedded in it, that carries a facts tag; "" -// when none does. -func firstFactsTag(t reflect.Type) string { +// when none does. seen is the structs already on the path down: an +// embedding can be recursive (`type Node struct { *Node; ... }`), and a +// struct already being searched has nothing new to find. +func firstFactsTag(t reflect.Type, seen []reflect.Type) string { if t.Kind() == reflect.Pointer { t = t.Elem() } - if t.Kind() != reflect.Struct { + if t.Kind() != reflect.Struct || slices.Contains(seen, t) { return "" } + seen = append(seen, t) for i := 0; i < t.NumField(); i++ { sf := t.Field(i) if sf.Tag.Get("facts") != "" { return sf.Name } if sf.Anonymous { - if name := firstFactsTag(sf.Type); name != "" { + if name := firstFactsTag(sf.Type, seen); name != "" { return sf.Name + "." + name } } diff --git a/languages/golang/internal/factstest/factstest_test.go b/languages/golang/internal/factstest/factstest_test.go index fcf15569b..f8edd7b5c 100644 --- a/languages/golang/internal/factstest/factstest_test.go +++ b/languages/golang/internal/factstest/factstest_test.go @@ -9,6 +9,19 @@ import ( "github.com/cipherstash/cipherstash-suite/bindings/go/stackencrypt/plan" ) +// Recursive embeddings, legal in Go, which a scan of embedded structs must +// not follow forever. +type ( + list struct { + *list + Value string + } + node struct { + *node + Secret string `facts:"a=x"` + } +) + func TestStructTags(t *testing.T) { type embedded struct{ Inner string } type row struct { @@ -72,6 +85,14 @@ func TestStructTags(t *testing.T) { t.Errorf("%s: err = %q, want it to say %q", name, err, bad.say) } } + // A recursive embedding terminates, and is not a fact: the embedded + // copy's fields are the struct's own, tagged or not. + if facts, err := factstest.StructTags.Facts(list{}); err != nil || len(facts) != 1 || facts[0].GoField != "Value" { + t.Errorf("recursive embedding: facts %+v, %v; want Value alone", facts, err) + } + if facts, err := factstest.StructTags.Facts(node{}); err != nil || len(facts) != 1 || facts[0].GoField != "Secret" || len(facts[0].Annotations) != 1 { + t.Errorf("recursive embedding beside a tag: facts %+v, %v; want Secret alone, classified", facts, err) + } // The schema spelling of a Go field name. type spelled struct { ID int64 diff --git a/languages/golang/stackencrypt/policy_plan_test.go b/languages/golang/stackencrypt/policy_plan_test.go index 2630ccf5c..c9a250bca 100644 --- a/languages/golang/stackencrypt/policy_plan_test.go +++ b/languages/golang/stackencrypt/policy_plan_test.go @@ -3,6 +3,7 @@ package stackencrypt_test import ( "bytes" "reflect" + "strings" "testing" "github.com/cipherstash/cipherstash-suite/bindings/go/internal/factstest" @@ -10,6 +11,20 @@ import ( "github.com/cipherstash/cipherstash-suite/bindings/go/stackencrypt/plan" ) +// Validate refuses a nil type as PlanFromTags does, for the zero plan +// and a built one alike, rather than dereferencing it. +func TestValidateRefusesANilType(t *testing.T) { + built, err := se.NewPlan(se.FieldPlan{Field: "A", Context: "t/a"}) + if err != nil { + t.Fatal(err) + } + for name, p := range map[string]se.Plan{"zero": {}, "built": built} { + if err := p.Validate(nil); err == nil || !strings.Contains(err.Error(), "nil type") { + t.Errorf("%s plan: Validate(nil) = %v, want an error naming the nil type", name, err) + } + } +} + // A plan a policy builds is a Plan like any other: the guest receives // byte-identical input to the equivalent plan built by hand. This test is // here, not in package plan, because the guest encoding is unexported; diff --git a/languages/golang/stackencrypt/record.go b/languages/golang/stackencrypt/record.go index cc80bcee3..e29196535 100644 --- a/languages/golang/stackencrypt/record.go +++ b/languages/golang/stackencrypt/record.go @@ -308,6 +308,9 @@ func (p Plan) Validate(t reflect.Type) error { _, err := PlanFromTags(t) return err } + if t == nil { + return errors.New("stackencrypt: records must be structs, not a nil type") + } _, err := p.bind(t) return err } From 9aff82f86de2ec33e6621777d47a56685823f390 Mon Sep 17 00:00:00 2001 From: Lindsay Holmwood <lindsay@holmwood.id.au> Date: Tue, 29 Sep 2026 15:45:07 -0700 Subject: [PATCH 669/686] fix(go): refuse plan.Any/All with no matchers; test pointer-embedded facts tags All() with no matchers matched every field, so a rule built from a slice that came back empty decided every field below it and could leave a classified field out of the plan with no error. Any() with none never fired. Both now panic where the rule is written, as a nil matcher does. Also pin the fail-closed refusal of a facts tag reached through an embedded pointer, which no test covered. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019YjmHJEM12fRq8XKwg7eaH --- .../golang/internal/factstest/factstest_test.go | 5 +++-- languages/golang/stackencrypt/plan/plan_test.go | 11 ++++++++--- languages/golang/stackencrypt/plan/policy.go | 14 ++++++++++---- 3 files changed, 21 insertions(+), 9 deletions(-) diff --git a/languages/golang/internal/factstest/factstest_test.go b/languages/golang/internal/factstest/factstest_test.go index f8edd7b5c..da353adde 100644 --- a/languages/golang/internal/factstest/factstest_test.go +++ b/languages/golang/internal/factstest/factstest_test.go @@ -75,8 +75,9 @@ func TestStructTags(t *testing.T) { "tagged embedded field": {struct { embedded `facts:"a=x"` }{}, "embedded field"}, - "tag inside an embedded struct": {struct{ taggedInner }{}, "Secret"}, - "tag two embeddings deep": {struct{ deeper }{}, "taggedInner.Secret"}, + "tag inside an embedded struct": {struct{ taggedInner }{}, "Secret"}, + "tag two embeddings deep": {struct{ deeper }{}, "taggedInner.Secret"}, + "tag inside an embedded pointer": {struct{ *taggedInner }{}, "Secret"}, } { _, err := factstest.StructTags.Facts(bad.msg) if err == nil { diff --git a/languages/golang/stackencrypt/plan/plan_test.go b/languages/golang/stackencrypt/plan/plan_test.go index 34e3c3f4e..51ca50e26 100644 --- a/languages/golang/stackencrypt/plan/plan_test.go +++ b/languages/golang/stackencrypt/plan/plan_test.go @@ -374,14 +374,19 @@ func TestCombinatorsRefuseNilAndCopyTheirMatchers(t *testing.T) { "Any": func() { plan.Any(plan.Field("a"), unset) }, "All": func() { plan.All(unset) }, "Not": func() { plan.Not(unset) }, + // With no matchers, All would match every field and Any none: a + // rule built from an empty slice would silently decide everything + // or nothing. + "Any()": func() { plan.Any() }, + "All()": func() { plan.All([]plan.Matcher{}...) }, } { func() { defer func() { r := recover() if r == nil { - t.Errorf("%s(nil) did not panic", name) - } else if !strings.Contains(fmt.Sprint(r), "plan."+name) { - t.Errorf("%s(nil) panicked with %v, which does not name it", name, r) + t.Errorf("%s did not panic", name) + } else if !strings.Contains(fmt.Sprint(r), "plan."+strings.TrimSuffix(name, "()")) { + t.Errorf("%s panicked with %v, which does not name it", name, r) } }() build() diff --git a/languages/golang/stackencrypt/plan/policy.go b/languages/golang/stackencrypt/plan/policy.go index 2002b24f7..242b33473 100644 --- a/languages/golang/stackencrypt/plan/policy.go +++ b/languages/golang/stackencrypt/plan/policy.go @@ -251,8 +251,8 @@ func Kind(kind string) Matcher { return func(f Fact) bool { return f.Kind == kind } } -// Any matches when at least one of ms does. A nil matcher is a -// programming error and panics here, as it does in [When]. +// Any matches when at least one of ms does. A nil matcher, or none at +// all, is a programming error and panics here, as it does in [When]. func Any(ms ...Matcher) Matcher { ms = matchers("plan.Any", ms) return func(f Fact) bool { @@ -260,8 +260,11 @@ func Any(ms ...Matcher) Matcher { } } -// All matches when every one of ms does. A nil matcher is a programming -// error and panics here, as it does in [When]. +// All matches when every one of ms does. A nil matcher, or none at all, +// is a programming error and panics here, as it does in [When]: with no +// matchers All would match every field, so a rule built from a slice that +// came back empty would decide every field below it. A policy that needs +// a catch-all writes a final rule whose matcher says so. func All(ms ...Matcher) Matcher { ms = matchers("plan.All", ms) return func(f Fact) bool { @@ -287,6 +290,9 @@ func Not(m Matcher) Matcher { // a later write to the caller's slice must not change the matcher, and a // nil found now names the combinator instead of crashing a build. func matchers(combinator string, ms []Matcher) []Matcher { + if len(ms) == 0 { + panic(combinator + ": no matchers; All() would match every field and Any() none") + } for i, m := range ms { if m == nil { panic(fmt.Sprintf("%s: nil matcher at index %d", combinator, i)) From 118e1d11390b1cecab5874d175b038cff4e99b27 Mon Sep 17 00:00:00 2001 From: Lindsay Holmwood <lindsay@holmwood.id.au> Date: Tue, 29 Sep 2026 15:47:56 -0700 Subject: [PATCH 670/686] fix(go): silence unused lint on the recursive-embedding test fixtures The embedded *list and *node fields exist only to make the types recursive; nothing reads them, so golangci-lint's unused check fails. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019YjmHJEM12fRq8XKwg7eaH --- languages/golang/internal/factstest/factstest_test.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/languages/golang/internal/factstest/factstest_test.go b/languages/golang/internal/factstest/factstest_test.go index da353adde..1c42162bf 100644 --- a/languages/golang/internal/factstest/factstest_test.go +++ b/languages/golang/internal/factstest/factstest_test.go @@ -13,11 +13,11 @@ import ( // not follow forever. type ( list struct { - *list + *list //nolint:unused // the recursion is the point Value string } node struct { - *node + *node //nolint:unused // the recursion is the point Secret string `facts:"a=x"` } ) From 82a85b4d64f1d0eb9cbcea4b135196f7577582d8 Mon Sep 17 00:00:00 2001 From: Lindsay Holmwood <lindsay@holmwood.id.au> Date: Fri, 2 Oct 2026 10:28:32 +1000 Subject: [PATCH 671/686] test(stack-encrypt): order Go string terms by their collated form The ORE and OPE string encodings run cllw-ore's orderize_string first: it decomposes each character canonically and drops anything that is not alphanumeric, whitespace or ASCII punctuation. The Go property test compared random Unicode strings by their raw UTF-8 bytes, so it failed whenever collation changed a string. For example, a string of private-use characters collates to "" and orders before any string with a letter in it. The fixed cases had the same problem: "\x7f" collates to "" too. Suite CI never ran this live test, because it needs credentials, so stack's CI port was the first to run it. Generate the random strings from characters that collation leaves unchanged, including multi-byte letters with no decomposition. Move "\x7f" out of the byte-order cases, and check that pairs differing only in dropped or decomposed characters give equal terms. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PaY5xYydZUWhv8Nex9Sw8a --- .../golang/stackencrypt/order_live_test.go | 45 ++++++++++++++++--- 1 file changed, 39 insertions(+), 6 deletions(-) diff --git a/languages/golang/stackencrypt/order_live_test.go b/languages/golang/stackencrypt/order_live_test.go index f211e15c6..6f4e3affe 100644 --- a/languages/golang/stackencrypt/order_live_test.go +++ b/languages/golang/stackencrypt/order_live_test.go @@ -21,8 +21,9 @@ import ( // orderProperty checks, for random pairs of T, that the Go comparison of // their terms agrees with the plaintext order and that a term compares -// equal to itself (derivation is deterministic). -func orderProperty[T any](t *testing.T, cipher *Cipher, kind TermKind, less func(a, b T) int) { +// equal to itself (derivation is deterministic). gen, when given, replaces +// quick's generator for T. +func orderProperty[T any](t *testing.T, cipher *Cipher, kind TermKind, less func(a, b T) int, gen ...func(*rand.Rand) T) { t.Helper() ctx := context.Background() context := MustContext(fmt.Sprintf("prop/%s/%T", kind, *new(T))) @@ -69,6 +70,13 @@ func orderProperty[T any](t *testing.T, cipher *Cipher, kind TermKind, less func return sign(compare(tb, ta)) == -want } cfg := &quick.Config{MaxCount: 300, Rand: rand.New(rand.NewSource(int64(kind)))} + if len(gen) > 0 { + cfg.Values = func(args []reflect.Value, r *rand.Rand) { + for i := range args { + args[i] = reflect.ValueOf(gen[0](r)) + } + } + } if err := quick.Check(holds, cfg); err != nil { t.Fatal(err) } @@ -104,6 +112,24 @@ func adjacentProperty[T any](t *testing.T, cipher *Cipher, kind TermKind, values } } +// collatedAlphabet holds characters that the ORE and OPE string encodings +// keep as they are. Before deriving a term, cllw-ore's orderize_string +// decomposes each character canonically and drops anything that is not +// alphanumeric, whitespace or ASCII punctuation. So two strings order by +// their UTF-8 bytes only when that collation leaves both unchanged: a +// private-use character is dropped, and a precomposed Hangul syllable or +// an accented letter decomposes. The non-ASCII letters here have no +// canonical decomposition, so multi-byte UTF-8 ordering is still covered. +var collatedAlphabet = []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789 !\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~ßжω中") + +func collatedString(r *rand.Rand) string { + out := make([]rune, r.Intn(24)) + for i := range out { + out[i] = collatedAlphabet[r.Intn(len(collatedAlphabet))] + } + return string(out) +} + func TestLiveTermOrderIsPlaintextOrder(t *testing.T) { c := liveClient(t) cipher := c.DefaultKeyset() @@ -122,10 +148,17 @@ func TestLiveTermOrderIsPlaintextOrder(t *testing.T) { adjacentProperty(t, cipher, kind, []int64{-1 << 63, -1<<63 + 1, -2, -1, 0, 1, 2, 1<<63 - 1}, cmp.Compare[int64]) }) t.Run("string", func(t *testing.T) { - // Strings order by their UTF-8 bytes; a prefix orders before - // its extensions. - orderProperty(t, cipher, kind, func(a, b string) int { return bytes.Compare([]byte(a), []byte(b)) }) - adjacentProperty(t, cipher, kind, []string{"", "a", "aa", "ab", "b", "ba", "\x7f", "é", "éa"}, func(a, b string) int { return bytes.Compare([]byte(a), []byte(b)) }) + // Strings order by the UTF-8 bytes of their collated form; a + // prefix orders before its extensions. See collatedAlphabet. + orderProperty(t, cipher, kind, func(a, b string) int { return bytes.Compare([]byte(a), []byte(b)) }, collatedString) + adjacentProperty(t, cipher, kind, []string{"", "a", "aa", "ab", "b", "ba", "ß", "ßa", "中"}, func(a, b string) int { return bytes.Compare([]byte(a), []byte(b)) }) + // Collation drops a control or private-use character, and + // strips the accent from a decomposed letter, so the terms + // cannot tell these pairs apart. + same := func(a, b string) int { return 0 } + adjacentProperty(t, cipher, kind, []string{"", "\x7f"}, same) + adjacentProperty(t, cipher, kind, []string{"ab", "a\ue000b"}, same) + adjacentProperty(t, cipher, kind, []string{"e", "é"}, same) }) t.Run("bytes", func(t *testing.T) { orderProperty(t, cipher, kind, bytes.Compare) From 359a6f18410ac5b1ff2c28c290d4e8781db57f1f Mon Sep 17 00:00:00 2001 From: Lindsay Holmwood <lindsay@holmwood.id.au> Date: Fri, 2 Oct 2026 09:22:07 +1000 Subject: [PATCH 672/686] chore: clean up the suite import deposit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plan §7.3, the first commit on top of the untouched import merge. - Delete .github/imported-workflows/. The 13 suite workflows travelled for history only; PR C ports what they did. - Delete packages/stack-auth/package-lock.json, an empty stray npm lock. - Delete the nested biome.json files in the auth and profile bindings, so the root Biome config covers them. - Mark @cipherstash/profile and its six platform packages private. It was never published, and without the flag the release gate and changesets would treat 0.35.0 as a JS package to publish with no binaries. - Point the Go guest crates' cts-common, zerokms-protocol and recipher path dependencies at the Phase 0 crates.io releases (=0.43.0, =0.12.31, =0.3.1). Those paths do not exist in stack. The guests' Cargo.lock files are refreshed in the workspace commit, once the root workspace they reach through the stack-* path dependencies exists. - Make the imported docs pass `lint:package-paths`, which fails on the merge with seven references to crates that stayed in the suite: - docs/fuzzing.md: trim to the set's six targets, dropping the three cts-common ones. The suite keeps its side of the shared file. - docs/auth-strategy-handover.md and docs/wasm-analysis.md: the export moved them from the root into docs/, so their relative links gain `../` or lose `docs/`. - Name suite crates such as cts-common, cllw-ore and cipherstash-client and the vitaminc crates without a path. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PaY5xYydZUWhv8Nex9Sw8a --- .../imported-workflows/crap-stack-auth.yml | 61 ---- .../imported-workflows/crap-stack-encrypt.yml | 63 ---- .github/imported-workflows/format-js.yml | 49 --- .github/imported-workflows/fuzz.yml | 143 -------- .github/imported-workflows/miri.yml | 58 ---- .github/imported-workflows/mutants.yml | 207 ----------- .../imported-workflows/publish-auth-npm.yml | 328 ------------------ .../publish-profile-npm.yml | 265 -------------- .github/imported-workflows/release-npm.yml | 86 ----- .../require-auth-npm-changeset.yml | 72 ---- .../imported-workflows/test-stack-auth.yml | 130 ------- .../imported-workflows/test-stack-profile.yml | 69 ---- .github/imported-workflows/test-wasi.yml | 241 ------------- docs/auth-strategy-handover.md | 6 +- docs/fuzzing.md | 24 +- ...nc-shape-for-target-directed-encryption.md | 6 +- docs/wasm-analysis.md | 2 +- languages/golang/stackauth/guest/Cargo.toml | 2 +- .../golang/stackencrypt/guest/Cargo.toml | 4 +- languages/typescript/packages/auth/biome.json | 38 -- .../typescript/packages/profile/biome.json | 37 -- .../typescript/packages/profile/package.json | 1 + .../platforms/darwin-arm64/package.json | 1 + .../profile/platforms/darwin-x64/package.json | 1 + .../platforms/linux-arm64-gnu/package.json | 1 + .../platforms/linux-x64-gnu/package.json | 1 + .../platforms/linux-x64-musl/package.json | 1 + .../platforms/win32-x64-msvc/package.json | 1 + packages/stack-auth/package-lock.json | 6 - 29 files changed, 27 insertions(+), 1877 deletions(-) delete mode 100644 .github/imported-workflows/crap-stack-auth.yml delete mode 100644 .github/imported-workflows/crap-stack-encrypt.yml delete mode 100644 .github/imported-workflows/format-js.yml delete mode 100644 .github/imported-workflows/fuzz.yml delete mode 100644 .github/imported-workflows/miri.yml delete mode 100644 .github/imported-workflows/mutants.yml delete mode 100644 .github/imported-workflows/publish-auth-npm.yml delete mode 100644 .github/imported-workflows/publish-profile-npm.yml delete mode 100644 .github/imported-workflows/release-npm.yml delete mode 100644 .github/imported-workflows/require-auth-npm-changeset.yml delete mode 100644 .github/imported-workflows/test-stack-auth.yml delete mode 100644 .github/imported-workflows/test-stack-profile.yml delete mode 100644 .github/imported-workflows/test-wasi.yml delete mode 100644 languages/typescript/packages/auth/biome.json delete mode 100644 languages/typescript/packages/profile/biome.json delete mode 100644 packages/stack-auth/package-lock.json diff --git a/.github/imported-workflows/crap-stack-auth.yml b/.github/imported-workflows/crap-stack-auth.yml deleted file mode 100644 index 9995e4393..000000000 --- a/.github/imported-workflows/crap-stack-auth.yml +++ /dev/null @@ -1,61 +0,0 @@ -name: "stack-auth CRAP gate" - -# Gates PRs that touch stack-auth on the CRAP (Change Risk Anti-Patterns) metric: -# cyclomatic complexity weighted by test coverage, so it surfaces complex, -# under-tested functions — the kind of code where a subtle bug like CIP-3233 -# (#2036) can hide. -# -# Blocking: the `crap:stack-auth` mise task runs with `--fail-above`, so the job -# fails when any function's CRAP score exceeds the threshold in .cargo-crap.toml -# (30). Offending functions surface as inline PR annotations (`--format github`). -# To relax this to regression-only later, switch to `--baseline`/`--fail-regression`. -on: - pull_request: - paths: - - packages/stack-auth/** - - .cargo-crap.toml - - .github/workflows/crap-stack-auth.yml - # Ordering matters, this needs to stay at the end if we want to include the above, but exclude all .md files. - - "!**.md" - - "!**.example" - - workflow_dispatch: - -defaults: - run: - shell: bash - -# Read-only: failures surface as job status + inline annotations, not a comment. -permissions: - contents: read - -env: - RUST_BACKTRACE: full - CARGO_TERM_COLOR: always - CARGO_NET_GIT_FETCH_WITH_CLI: true - NEXTEST_PROFILE: ci - -jobs: - crap-stack-auth: - runs-on: blacksmith-8vcpu-ubuntu-2404 - - steps: - - uses: actions/checkout@v6 - - uses: ./.github/actions/setup-rust - - - name: Install llvm-tools-preview (required by cargo-llvm-cov) - run: rustup component add --toolchain "$(rustup show active-toolchain | cut -d' ' -f1)" llvm-tools-preview - - - name: Fix permissions on target directory - run: | - mkdir -p ./target - sudo chown -R "$(id -u):$(id -g)" ./target - - - name: Run CRAP gate - # Reuse the `crap:stack-auth` mise task as the single source of truth for the - # coverage + `cargo crap` invocation (see packages/stack-auth/tasks.toml), so - # the exclusions, threshold and `--fail-above` gate live in one place. mise - # forwards trailing args (after `--`) to the task's last command (`cargo - # crap`); `--format github` emits one `::warning` annotation per offending - # function so they show inline on the PR before the job fails. - run: mise run crap:stack-auth -- --format github diff --git a/.github/imported-workflows/crap-stack-encrypt.yml b/.github/imported-workflows/crap-stack-encrypt.yml deleted file mode 100644 index 756605b6c..000000000 --- a/.github/imported-workflows/crap-stack-encrypt.yml +++ /dev/null @@ -1,63 +0,0 @@ -name: "stack-encrypt CRAP gate" - -# Gates PRs that touch stack-encrypt on the CRAP (Change Risk Anti-Patterns) -# metric: cyclomatic complexity weighted by test coverage, so it surfaces -# complex, under-tested functions. Same shape as crap-stack-auth.yml. -# -# Blocking: the `crap:stack-encrypt` mise task runs with `--fail-above`, so the -# job fails when any function's CRAP score exceeds the threshold in -# .cargo-crap.toml (30). Offending functions surface as inline PR annotations -# (`--format github`). To relax this to regression-only later, switch to -# `--baseline`/`--fail-regression`. -on: - pull_request: - paths: - - packages/stack-encrypt/** - - packages/stack-encrypt-derive/** - - .cargo-crap.toml - - .github/workflows/crap-stack-encrypt.yml - # Ordering matters, this needs to stay at the end if we want to include the above, but exclude all .md files. - - "!**.md" - - "!**.example" - - workflow_dispatch: - -defaults: - run: - shell: bash - -# Read-only: failures surface as job status + inline annotations, not a comment. -permissions: - contents: read - -env: - RUST_BACKTRACE: full - CARGO_TERM_COLOR: always - CARGO_NET_GIT_FETCH_WITH_CLI: true - NEXTEST_PROFILE: ci - -jobs: - crap-stack-encrypt: - runs-on: blacksmith-8vcpu-ubuntu-2404 - - steps: - - uses: actions/checkout@v6 - - uses: ./.github/actions/setup-rust - - - name: Install llvm-tools-preview (required by cargo-llvm-cov) - run: rustup component add --toolchain "$(rustup show active-toolchain | cut -d' ' -f1)" llvm-tools-preview - - - name: Fix permissions on target directory - run: | - mkdir -p ./target - sudo chown -R "$(id -u):$(id -g)" ./target - - - name: Run CRAP gate - # Reuse the `crap:stack-encrypt` mise task as the single source of truth - # for the coverage + `cargo crap` invocation (see - # packages/stack-encrypt/tasks.toml), so the exclusions, threshold and - # `--fail-above` gate live in one place. mise forwards trailing args - # (after `--`) to the task's last command (`cargo crap`); `--format - # github` emits one `::warning` annotation per offending function so - # they show inline on the PR before the job fails. - run: mise run crap:stack-encrypt -- --format github diff --git a/.github/imported-workflows/format-js.yml b/.github/imported-workflows/format-js.yml deleted file mode 100644 index 5149b2b79..000000000 --- a/.github/imported-workflows/format-js.yml +++ /dev/null @@ -1,49 +0,0 @@ -name: "Format JS/TS (biome)" -on: - push: - branches: - - main - paths: - - packages/stack-auth/node/** - - packages/stack-profile/node/** - - .github/workflows/format-js.yml - - pull_request: - paths: - - packages/stack-auth/node/** - - packages/stack-profile/node/** - - .github/workflows/format-js.yml - - workflow_dispatch: - -defaults: - run: - shell: bash - -jobs: - biome: - runs-on: blacksmith-8vcpu-ubuntu-2404 - steps: - - uses: actions/checkout@v6 - - # biome ships as a standalone native binary — it does not run under Node — - # so there's no Node toolchain to pin here. The version is pinned to match - # each package's `format` / `format:check` scripts so local and CI agree. - - name: Setup biome - uses: biomejs/setup-biome@v2 - with: - version: 2.3.4 - - # `biome ci` checks formatting without writing. The linter is disabled in - # `biome.json` — only formatting is enforced for now (lint-rule adoption is - # a follow-up). `if: !cancelled()` on the later step ensures every package - # is checked even when an earlier one fails, so contributors see all - # packages' formatting errors in a single run. - - name: biome — stack-auth/node - working-directory: packages/stack-auth/node - run: biome ci . - - - name: biome — stack-profile/node - if: ${{ !cancelled() }} - working-directory: packages/stack-profile/node - run: biome ci . diff --git a/.github/imported-workflows/fuzz.yml b/.github/imported-workflows/fuzz.yml deleted file mode 100644 index 6936a3ae7..000000000 --- a/.github/imported-workflows/fuzz.yml +++ /dev/null @@ -1,143 +0,0 @@ -name: "Fuzz (cargo-fuzz)" - -# libFuzzer fuzzing for the public string parsers (see packages/*/fuzz/ and the -# `fuzz:*` mise tasks). Two jobs with deliberately different roles: -# -# * fuzz-regression (pull_request, blocking): builds every harness — which -# catches harness/API drift, e.g. a changed `FromStr` signature — and -# replays the committed seed corpus with `-runs=0`. This is deterministic -# (no fuzzing), so it is safe to gate PRs: it fails only if a harness stops -# compiling or a committed corpus input crashes. -# -# * fuzz-campaign (schedule + manual, non-blocking on PRs): the actual -# time-boxed bug-finding run. A timed fuzz run is nondeterministic, so it -# must NOT gate PRs; it runs nightly against the default branch and on -# demand. Each target's corpus is persisted across runs via actions/cache -# so coverage compounds, minimized with `cargo fuzz cmin` to stay small, and -# any crash reproducer is uploaded as an artifact. -on: - pull_request: - paths: - - packages/cts-common/** - - packages/stack-auth/** - - packages/stack-kms/** - - packages/stack-encrypt/** - # Root manifest/lockfile: a workspace-wide dependency change can break - # this suite without touching any package source. - - Cargo.toml - - Cargo.lock - - .github/workflows/fuzz.yml - # Ordering matters: keep these excludes last so docs-only changes are skipped. - - "!**.md" - - "!**.example" - schedule: - # Nightly at 04:27 UTC. Scheduled runs only fire from the default branch. - - cron: "27 4 * * *" - workflow_dispatch: - inputs: - max_total_time: - description: "Seconds to fuzz each target (campaign job)" - default: "120" - -defaults: - run: - shell: bash - -permissions: - contents: read - -env: - RUST_BACKTRACE: full - CARGO_TERM_COLOR: always - CARGO_NET_GIT_FETCH_WITH_CLI: true - -jobs: - fuzz-regression: - name: "fuzz regression (${{ matrix.slug }})" - if: github.event_name == 'pull_request' - runs-on: blacksmith-8vcpu-ubuntu-2404 - strategy: - fail-fast: false - matrix: - include: - - { task: "fuzz:crn", slug: crn } - - { task: "fuzz:workspace-id", slug: workspace-id } - - { task: "fuzz:region", slug: region } - - { task: "fuzz:access-key", slug: access-key } - - { task: "fuzz:jwt-decode", slug: jwt-decode } - - { task: "fuzz:client-key", slug: client-key } - - { task: "fuzz:sealed-value", slug: sealed-value } - - { task: "fuzz:term-decode", slug: term-decode } - - { task: "fuzz:check-record", slug: check-record } - steps: - - uses: actions/checkout@v6 - - uses: ./.github/actions/setup-rust - - name: Install nightly toolchain (cargo-fuzz requires it) - run: rustup toolchain install nightly --profile minimal - - name: "Build harness + replay seed corpus (${{ matrix.slug }})" - # `-runs=0` replays the committed seed corpus once and exits without - # fuzzing. The trailing args override the task's default `-max_total_time`. - run: mise run ${{ matrix.task }} -- -runs=0 - - fuzz-campaign: - name: "fuzz campaign (${{ matrix.slug }})" - if: github.event_name != 'pull_request' - runs-on: blacksmith-8vcpu-ubuntu-2404 - strategy: - fail-fast: false - matrix: - # `dir`/`target` drive the corpus path and `cargo fuzz cmin`. - include: - - { task: "fuzz:crn", slug: crn, dir: packages/cts-common, target: crn_parse } - - { task: "fuzz:workspace-id", slug: workspace-id, dir: packages/cts-common, target: workspace_id_parse } - - { task: "fuzz:region", slug: region, dir: packages/cts-common, target: region_parse } - - { task: "fuzz:access-key", slug: access-key, dir: packages/stack-auth, target: access_key_parse } - - { task: "fuzz:jwt-decode", slug: jwt-decode, dir: packages/stack-auth, target: jwt_decode } - - { task: "fuzz:client-key", slug: client-key, dir: packages/stack-kms, target: client_key_encoded } - - { task: "fuzz:sealed-value", slug: sealed-value, dir: packages/stack-encrypt, target: sealed_value_decode } - - { task: "fuzz:term-decode", slug: term-decode, dir: packages/stack-encrypt, target: term_decode } - - { task: "fuzz:check-record", slug: check-record, dir: packages/stack-encrypt, target: check_record } - steps: - - uses: actions/checkout@v6 - - uses: ./.github/actions/setup-rust - - name: Install nightly toolchain (cargo-fuzz requires it) - run: rustup toolchain install nightly --profile minimal - - # Persist the corpus across runs so coverage compounds. A cache key is - # write-once, so save under a unique per-run key and use a prefix - # `restore-keys` to load the most recent prior corpus. The committed seeds - # come from the checkout and merge with the restored corpus at runtime. - - name: Restore corpus - uses: actions/cache/restore@v4 - with: - path: ${{ matrix.dir }}/fuzz/corpus/${{ matrix.target }} - key: fuzz-corpus-${{ matrix.slug }}-${{ github.run_id }} - restore-keys: fuzz-corpus-${{ matrix.slug }}- - - - name: "Fuzz ${{ matrix.slug }}" - # The trailing `-max_total_time` (last value wins) overrides the task default. - run: mise run ${{ matrix.task }} -- -max_total_time=${{ github.event.inputs.max_total_time || '120' }} - - - name: "Minimize corpus (${{ matrix.slug }})" - # Drop inputs that don't add coverage so the persisted corpus stays small - # and fast to load. Skipped automatically if the Fuzz step found a crash. - working-directory: ${{ matrix.dir }} - run: | - cargo +nightly fuzz cmin ${{ matrix.target }} --sanitizer none --target "$(rustc -vV | sed -n 's/^host: //p')" - - - name: Save corpus - # Save even on crash: the grown corpus is still valuable, and the crash - # input lives in artifacts/, not corpus/. - if: always() - uses: actions/cache/save@v4 - with: - path: ${{ matrix.dir }}/fuzz/corpus/${{ matrix.target }} - key: fuzz-corpus-${{ matrix.slug }}-${{ github.run_id }} - - - name: Upload crash reproducer - if: failure() - uses: actions/upload-artifact@v4 - with: - name: fuzz-artifacts-${{ matrix.slug }} - path: packages/*/fuzz/artifacts/** - if-no-files-found: ignore diff --git a/.github/imported-workflows/miri.yml b/.github/imported-workflows/miri.yml deleted file mode 100644 index 1141cd2f6..000000000 --- a/.github/imported-workflows/miri.yml +++ /dev/null @@ -1,58 +0,0 @@ -name: "Miri (guest ABI)" - -# Runs the stack-guest-abi unit tests under Miri with strict provenance (the -# `miri:stack-guest-abi` mise task). The crate is the shared ABI of the WASI -# guests under bindings/go: its buffer registry hands raw pointers to the Go -# host and rebuilds owned buffers from them, and Miri is the only check that -# sees a use-after-free, a double-free, or a pointer rebuilt without -# provenance on that path. Blocking on PRs that touch the crate: Miri is -# deterministic, so a red run is a real defect or a harness that stopped -# compiling, never flake. -on: - pull_request: - paths: - - packages/stack-guest-abi/** - # Root manifest/lockfile: a workspace-wide dependency change can break - # the build under Miri without touching the crate. - - Cargo.toml - - Cargo.lock - - mise.toml - - .github/workflows/miri.yml - # Ordering matters, this needs to stay at the end if we want to include the above, but exclude all .md files. - - "!**.md" - - "!**.example" - - workflow_dispatch: - -defaults: - run: - shell: bash - -permissions: - contents: read - -env: - RUST_BACKTRACE: full - CARGO_TERM_COLOR: always - CARGO_NET_GIT_FETCH_WITH_CLI: true - -jobs: - miri-stack-guest-abi: - runs-on: blacksmith-8vcpu-ubuntu-2404 - - steps: - - uses: actions/checkout@v6 - - uses: ./.github/actions/setup-rust - - - name: Install nightly toolchain with Miri - run: | - rustup toolchain install nightly --profile minimal --component miri - cargo +nightly miri setup - - - name: Fix permissions on target directory - run: | - mkdir -p ./target - sudo chown -R "$(id -u):$(id -g)" ./target - - - name: Miri - run: mise run miri:stack-guest-abi diff --git a/.github/imported-workflows/mutants.yml b/.github/imported-workflows/mutants.yml deleted file mode 100644 index c54a061db..000000000 --- a/.github/imported-workflows/mutants.yml +++ /dev/null @@ -1,207 +0,0 @@ -name: "Mutants" - -# Gates PRs on mutation testing of the lines they change in the stack crates. -# cargo-mutants rewrites small pieces of logic (flip `<` to `<=`, replace a -# body with `Default::default()`, …) and reruns the tests; a mutant that -# survives is a line the suite does not actually pin down. In a crypto crate — -# comparisons, length checks, scope and passthrough refusals — that is exactly -# the logic we want a test to catch before it ships. Same shape as vitaminc's -# gate. -# -# Scoped with `--in-diff` to the PR's own changes, so the gate trips only when -# a PR adds (or moves) logic its tests don't exercise. A full sweep is far too -# slow for a per-PR gate (stack-encrypt: 736 mutants, ~60 min); run it locally -# with `mise run mutants:<crate>`. Config (features, test filter, excludes, -# timeouts) lives in .cargo/mutants.toml so the gate and the local tasks stay -# in sync. -# -# Scoped to the opted-in crates with `-p`: the baseline runs the unmutated -# tests of the packages named, and the rest of the workspace needs databases -# and mock servers this job does not start. A diff touching only other crates -# yields no mutants and passes. -# -# To soften back to report-only, drop the "Enforce — fail on surviving -# mutants" step; the sticky comment keeps working either way. -on: - # No branch filter: a stacked PR (a feature branch as base) must run this - # gate too. With `branches: [main]` here, retargeting a PR onto its parent - # branch would silently switch the gate off. - pull_request: - paths: - - packages/stack-auth/** - - packages/stack-encrypt/** - - Cargo.toml - - Cargo.lock - - .cargo/mutants.toml - - .github/workflows/mutants.yml - # Ordering matters, this needs to stay at the end if we want to include the above, but exclude all .md files. - - "!**.md" - - "!**.example" - - workflow_dispatch: - -defaults: - run: - shell: bash - -# Needed to post/update the report comment on the PR. -permissions: - contents: read - pull-requests: write - -# Cancel an in-flight run when the PR is pushed again — only the latest matters. -concurrency: - group: mutants-${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - -env: - RUST_BACKTRACE: full - CARGO_TERM_COLOR: always - CARGO_NET_GIT_FETCH_WITH_CLI: true - -jobs: - mutants: - runs-on: blacksmith-8vcpu-ubuntu-2404 - name: "🧬 Mutants gate" - - steps: - - uses: actions/checkout@v6 - with: - # Full history so we can diff the PR against its base branch for - # `--in-diff`. - fetch-depth: 0 - # Installs cargo-mutants and cargo-nextest via mise (`[tools]`). - - uses: ./.github/actions/setup-rust - - - name: Fix permissions on target directory - run: | - mkdir -p ./target - sudo chown -R "$(id -u):$(id -g)" ./target - - - name: Compute PR diff - # cargo-mutants `--in-diff` mutates only lines added/changed by the PR. - # Diff the checked-out tree against the base branch tip; fetch it - # first since the PR checkout doesn't include it. PR-only: a manual - # `workflow_dispatch` has no `github.base_ref` to diff against, so it - # sweeps the opted-in crates instead (see the run step below). - if: github.event_name == 'pull_request' - run: | - git fetch --no-tags origin "${{ github.base_ref }}" - git diff "origin/${{ github.base_ref }}" > pr.diff - echo "Changed lines under mutation:" - cat pr.diff - - - name: Run cargo-mutants - id: mutants - # Don't fail the job here: the gate step below is authoritative, and - # the sticky comment must be posted first. On a PR, scope to the diff; - # on a manual dispatch there is no diff, so sweep the opted-in crates - # (slow, but that's the point of asking for it). - continue-on-error: true - run: | - set +e - crates=(-p stack-auth -p stack-encrypt) - if [ "${{ github.event_name }}" = "pull_request" ]; then - cargo mutants --no-shuffle -vV "${crates[@]}" --in-diff pr.diff - else - cargo mutants --no-shuffle -vV "${crates[@]}" - fi - echo "exit_code=$?" >> "$GITHUB_OUTPUT" - - - name: Build mutants report - # Compose a sticky-comment body from the cargo-mutants output dir. - # Always runs so the comment reflects the run even when the gate below - # will fail. - if: always() - run: | - out=mutants.out - count() { if [ -f "$out/$1" ]; then grep -c . "$out/$1" || true; else echo 0; fi; } - caught=$(count caught.txt) - missed=$(count missed.txt) - unviable=$(count unviable.txt) - timeout=$(count timeout.txt) - { - echo '<!-- cargo-mutants-report -->' - echo '## 🧬 Mutation testing (cargo-mutants, `--in-diff`, stack-auth + stack-encrypt)' - echo - if [ ! -d "$out" ]; then - # No output dir: either the run found nothing to mutate (exit 0) - # or it died before producing results (e.g. the baseline tests - # failed). Distinguish them so a failed run doesn't masquerade - # as a clean one. - if [ "${{ steps.mutants.outputs.exit_code }}" = "0" ]; then - echo "No mutants were generated for the changed lines." - else - echo "⚠️ cargo-mutants did not complete (exit ${{ steps.mutants.outputs.exit_code }}) before producing results — check the workflow run logs. The gate below will fail." - fi - exit 0 - fi - echo "| caught | missed | unviable | timeout |" - echo "| -----: | -----: | -------: | ------: |" - echo "| $caught | $missed | $unviable | $timeout |" - echo - if [ "$missed" -gt 0 ] || [ "$timeout" -gt 0 ]; then - echo "### Surviving mutants — add a test that fails on each before merging" - echo '```' - [ -s "$out/missed.txt" ] && cat "$out/missed.txt" - [ -s "$out/timeout.txt" ] && { echo '# timed out (treated as surviving):'; cat "$out/timeout.txt"; } - echo '```' - elif [ "${{ steps.mutants.outputs.exit_code }}" = "0" ]; then - echo "✅ Every mutant in the changed lines was caught by a test." - else - echo "⚠️ cargo-mutants exited ${{ steps.mutants.outputs.exit_code }} with no surviving mutants recorded — the run may not have completed cleanly. Check the workflow run logs." - fi - } > mutants-comment.md - cat mutants-comment.md - - - name: Post mutants report as a sticky PR comment - # Posted before the gate so the comment always shows what tripped it. - # Best-effort: a comment hiccup must not turn the gate red or stop the - # authoritative enforce step from running. - if: always() && github.event_name == 'pull_request' - continue-on-error: true - uses: actions/github-script@v9 - with: - script: | - const fs = require('fs'); - const body = fs.readFileSync('mutants-comment.md', 'utf8'); - const marker = '<!-- cargo-mutants-report -->'; - const { owner, repo } = context.repo; - const issue_number = context.issue.number; - const comments = await github.paginate(github.rest.issues.listComments, { - owner, repo, issue_number, per_page: 100, - }); - const existing = comments.find(c => c.body && c.body.includes(marker)); - if (existing) { - await github.rest.issues.updateComment({ owner, repo, comment_id: existing.id, body }); - } else { - await github.rest.issues.createComment({ owner, repo, issue_number, body }); - } - - - name: Enforce — fail on surviving mutants - # The gate. A surviving ("missed") or timed-out mutant in the changed - # lines means a test does not pin that logic down; block the merge. - run: | - out=mutants.out - if [ -s "$out/missed.txt" ] || [ -s "$out/timeout.txt" ]; then - echo "::error::Surviving mutants in the PR diff — add tests that catch them (see the PR comment)." - [ -s "$out/missed.txt" ] && cat "$out/missed.txt" - [ -s "$out/timeout.txt" ] && cat "$out/timeout.txt" - exit 1 - fi - # Only three exits mean the sweep actually completed: 0 (all - # mutants caught), 2 (missed mutants) and 3 (timeouts) — and 2/3 - # are already enforced via missed.txt/timeout.txt above. Everything - # else means it did not complete: 4 (the unmutated baseline failed - # to build or test), 70 (internal tool error), an OOM kill, … . The - # presence of outcomes.json cannot stand in for this check — it is - # written incrementally after the baseline, so a mid-run death - # leaves it on disk with most mutants untested. - case "${{ steps.mutants.outputs.exit_code }}" in - 0|2|3) ;; - *) - echo "::error::cargo-mutants did not complete (exit ${{ steps.mutants.outputs.exit_code }}) — check the run logs." - exit 1 - ;; - esac - echo "No surviving mutants in the PR diff." diff --git a/.github/imported-workflows/publish-auth-npm.yml b/.github/imported-workflows/publish-auth-npm.yml deleted file mode 100644 index db68a5d75..000000000 --- a/.github/imported-workflows/publish-auth-npm.yml +++ /dev/null @@ -1,328 +0,0 @@ -name: "Publish @cipherstash/auth to npm" - -on: - # Automated publish, driven by changesets (see docs/npm-releases.md). - # Merging the "Version Packages" PR bumps `packages/stack-auth/node/package.json` - # on `main`; that push triggers this workflow, and the `preflight` job below - # publishes iff the new version is not already on npm (so ordinary pushes that - # touch package.json for other reasons are no-ops). The Version Packages PR - # review/merge is the human gate — there is no separate approval step, matching - # cipherstash/stack. - # - # `@cipherstash/auth` is versioned independently of the Rust `stack-auth` crate, - # so this must NOT trigger on release-plz's `stack-auth-v*` tags — that would - # ship the node package at the Rust crate's version. - push: - branches: - - main - paths: - - packages/stack-auth/node/package.json - # Manual runs default to a dry run; flip `dry_run` off to force a real publish - # (e.g. to re-publish a version the automated path skipped). - workflow_dispatch: - inputs: - dry_run: - description: "Dry run (do not actually publish)" - type: boolean - default: true - -permissions: - contents: read - id-token: write # npm trusted publishing (OIDC) - -defaults: - run: - shell: bash - -env: - CARGO_TERM_COLOR: always - WORKING_DIR: packages/stack-auth/node - -concurrency: - group: publish-auth-npm - cancel-in-progress: false - -jobs: - # Decides whether this run should publish, and at what version. - # - workflow_dispatch: honours the `dry_run` input (defaults to true). - # - push (Version PR merged): real publish, but only if the version in - # package.json is not already on npm — makes the trigger idempotent so a - # push that changed package.json for any other reason is a clean no-op. - preflight: - name: Preflight - runs-on: ubuntu-latest - outputs: - should_publish: ${{ steps.decide.outputs.should_publish }} - dry_run: ${{ steps.decide.outputs.dry_run }} - version: ${{ steps.decide.outputs.version }} - steps: - - uses: actions/checkout@v6 - - - name: Setup Node.js - uses: actions/setup-node@v6 - with: - node-version: 24 - - - name: Decide - id: decide - run: | - set -euo pipefail - VERSION=$(jq -r .version "${{ env.WORKING_DIR }}/package.json") - echo "version=$VERSION" >> "$GITHUB_OUTPUT" - - if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then - DRY_RUN="${{ inputs.dry_run }}" - else - DRY_RUN="false" - fi - echo "dry_run=$DRY_RUN" >> "$GITHUB_OUTPUT" - - # Idempotency guard (push path only): skip if this exact version is - # already published. `npm view` exits non-zero / empty when the exact - # version does not exist. - PUBLISHED=$(npm view "@cipherstash/auth@$VERSION" version 2>/dev/null || true) - if [ "${{ github.event_name }}" = "push" ] && [ "$PUBLISHED" = "$VERSION" ]; then - echo "should_publish=false" >> "$GITHUB_OUTPUT" - echo "::notice::@cipherstash/auth@$VERSION already on npm — nothing to publish." - else - echo "should_publish=true" >> "$GITHUB_OUTPUT" - echo "::notice::Will $( [ "$DRY_RUN" = "true" ] && echo 'dry-run' || echo 'publish') @cipherstash/auth@$VERSION." - fi - - build: - needs: preflight - if: needs.preflight.outputs.should_publish == 'true' - strategy: - fail-fast: true - matrix: - include: - - target: x86_64-apple-darwin - os: macos-latest - - target: aarch64-apple-darwin - os: macos-latest - - target: x86_64-unknown-linux-gnu - os: blacksmith-8vcpu-ubuntu-2404 - - target: aarch64-unknown-linux-gnu - os: blacksmith-8vcpu-ubuntu-2404-arm - - target: x86_64-unknown-linux-musl - os: blacksmith-8vcpu-ubuntu-2404 - - target: x86_64-pc-windows-msvc - os: windows-latest - - name: Build - ${{ matrix.target }} - runs-on: ${{ matrix.os }} - - steps: - - uses: actions/checkout@v6 - - - name: Exclude Windows build output from Defender realtime scan - # Defender real-time scanning every file Cargo writes under target/ - # (10k+ small files) is the dominant cost of MSVC napi builds — - # usually 2-4× slowdown vs. Linux for the same code. Excluding only - # this package's build output keeps the mitigation scoped to the - # generated artifacts while reducing Windows build time. See CIP-3109. - if: runner.os == 'Windows' - shell: pwsh - run: | - Add-MpPreference -ExclusionPath "${{ github.workspace }}\${{ env.WORKING_DIR }}\target" - - - name: Setup Rust - uses: dtolnay/rust-toolchain@1.94.1 - with: - targets: ${{ matrix.target }} - - - name: Setup Rust cache - uses: Swatinem/rust-cache@v2 - - - name: Setup Node.js - uses: actions/setup-node@v6 - with: - node-version: 24 - - - name: Install musl tools - if: matrix.target == 'x86_64-unknown-linux-musl' - run: sudo apt-get update && sudo apt-get install -y musl-tools - - - name: Install dependencies - working-directory: ${{ env.WORKING_DIR }} - run: npm install --omit=optional - - - name: Build native module - working-directory: ${{ env.WORKING_DIR }} - # `--dts native.d.ts` matches package.json's `build` script: napi writes - # its generated typings to `native.d.ts`, leaving the hand-written - # `index.d.ts` re-exporter untouched. Without it, napi clobbers - # `index.d.ts` in this job's tree. Harmless today (only `*.node` is - # uploaded), but a footgun if this job ever packs or uploads dts files. - run: npx napi build --platform --release --target ${{ matrix.target }} --strip --dts native.d.ts - - - name: Upload artifact - uses: actions/upload-artifact@v7 - with: - name: bindings-${{ matrix.target }} - path: ${{ env.WORKING_DIR }}/*.node - if-no-files-found: error - - build-wasm: - name: Build - wasm32 (bundler target) - needs: preflight - if: needs.preflight.outputs.should_publish == 'true' - runs-on: blacksmith-8vcpu-ubuntu-2404 - - steps: - - uses: actions/checkout@v6 - - - name: Setup Rust - uses: dtolnay/rust-toolchain@1.94.1 - with: - targets: wasm32-unknown-unknown - - - name: Setup Rust cache - uses: Swatinem/rust-cache@v2 - - - name: Install wasm-pack - # The `jetli/wasm-pack-action` GitHub Action is rejected by the - # cipherstash org allowlist (unverified creator). Install a pinned - # version from the official GitHub release so CI is reproducible and - # not exposed to upstream installer-script changes. - env: - WASM_PACK_VERSION: v0.13.1 - run: | - set -euo pipefail - curl -fsSL "https://github.com/rustwasm/wasm-pack/releases/download/${WASM_PACK_VERSION}/wasm-pack-${WASM_PACK_VERSION}-x86_64-unknown-linux-musl.tar.gz" \ - -o /tmp/wasm-pack.tar.gz - tar -xzf /tmp/wasm-pack.tar.gz -C /tmp - sudo install -m 0755 "/tmp/wasm-pack-${WASM_PACK_VERSION}-x86_64-unknown-linux-musl/wasm-pack" /usr/local/bin/wasm-pack - wasm-pack --version - - - name: Setup Node.js - uses: actions/setup-node@v6 - with: - node-version: 24 - - - name: Build wasm artifact - # Single source of truth shared with local `npm run build:wasm`: - # wasm-pack → strip wasm-pack metadata → emit inline-bytes shim. - working-directory: ${{ env.WORKING_DIR }} - run: npm run build:wasm - - - name: Upload wasm artifact - uses: actions/upload-artifact@v7 - with: - name: bindings-wasm32 - path: ${{ env.WORKING_DIR }}/wasm/ - if-no-files-found: error - - publish: - name: Publish - needs: [preflight, build, build-wasm] - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v6 - - - name: Setup Node.js - uses: actions/setup-node@v6 - with: - node-version: 24 - registry-url: https://registry.npmjs.org - - - name: Install dependencies - working-directory: ${{ env.WORKING_DIR }} - run: npm install --omit=optional - - - name: Download all artifacts - uses: actions/download-artifact@v8 - with: - path: ${{ env.WORKING_DIR }}/artifacts - - - name: Move artifacts to platform packages - working-directory: ${{ env.WORKING_DIR }} - run: npx napi artifacts --dir artifacts - - - name: Move wasm artifact into wasm/ - # `napi artifacts` only knows about the per-platform .node files; - # the wasm32 build is bundled directly into the main package. - working-directory: ${{ env.WORKING_DIR }} - run: | - mkdir -p wasm - mv artifacts/bindings-wasm32/* wasm/ - ls -la wasm/ - - - name: Determine version and npm tag - id: version - run: | - # The version is whatever `package.json` declares (changesets bumped it - # in the Version Packages PR; a manual dispatch publishes as-is). - VERSION=$(jq -r .version "${{ env.WORKING_DIR }}/package.json") - echo "version=$VERSION" >> "$GITHUB_OUTPUT" - - if [[ "$VERSION" == *"-"* ]]; then - echo "npm_tag=next" >> "$GITHUB_OUTPUT" - else - echo "npm_tag=latest" >> "$GITHUB_OUTPUT" - fi - - echo "Publishing version: $VERSION (tag: $(if [[ "$VERSION" == *"-"* ]]; then echo next; else echo latest; fi))" - - - name: Set package versions - working-directory: ${{ env.WORKING_DIR }} - run: | - VERSION="${{ steps.version.outputs.version }}" - npm version "$VERSION" --no-git-tag-version --allow-same-version - # Update peerDependencies (the napi platform sub-packages) to match - # the publish version. They live under `peerDependencies` rather than - # `optionalDependencies` so Deno's strict npm resolver doesn't choke - # on platform packages that don't apply to the consumer's runtime. - jq --arg v "$VERSION" ' - .peerDependencies |= with_entries( - if (.key | startswith("@cipherstash/auth-")) - then .value = $v - else . - end - ) - ' package.json > package.json.tmp && mv package.json.tmp package.json - for dir in npm/*/; do - if [ -f "$dir/package.json" ]; then - cd "$dir" - npm version "$VERSION" --no-git-tag-version --allow-same-version - cd - - fi - done - - - name: List packages - working-directory: ${{ env.WORKING_DIR }} - run: | - echo "=== Main package ===" - cat package.json | jq '{name, version}' - echo "" - for dir in npm/*/; do - echo "=== $(basename $dir) ===" - cat "$dir/package.json" | jq '{name, version, os, cpu}' - ls -la "$dir"*.node 2>/dev/null || echo " (no .node file)" - echo "" - done - - - name: Publish platform packages - if: needs.preflight.outputs.dry_run == 'false' - working-directory: ${{ env.WORKING_DIR }} - env: - NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} - run: | - for dir in npm/*/; do - if [ -f "$dir/package.json" ]; then - echo "Publishing $(jq -r .name "$dir/package.json")..." - npm publish "$dir" --access public --tag ${{ steps.version.outputs.npm_tag }} - fi - done - - - name: Publish main package - if: needs.preflight.outputs.dry_run == 'false' - working-directory: ${{ env.WORKING_DIR }} - env: - NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} - run: npm publish --access public --tag ${{ steps.version.outputs.npm_tag }} - - - name: Dry run summary - if: needs.preflight.outputs.dry_run == 'true' - run: echo "Dry run complete. Set dry_run to false to actually publish." diff --git a/.github/imported-workflows/publish-profile-npm.yml b/.github/imported-workflows/publish-profile-npm.yml deleted file mode 100644 index 1d0224abb..000000000 --- a/.github/imported-workflows/publish-profile-npm.yml +++ /dev/null @@ -1,265 +0,0 @@ -name: "Publish @cipherstash/profile to npm" - -on: - # Automated publish, driven by changesets (see docs/npm-releases.md). - # Merging the "Version Packages" PR bumps - # `packages/stack-profile/node/package.json` on `main`; that push triggers this - # workflow, and the `preflight` job below publishes iff the new version is not - # already on npm (so ordinary pushes that touch package.json for other reasons - # are no-ops). The Version Packages PR review/merge is the human gate — there - # is no separate approval step, matching cipherstash/stack. - # - # `@cipherstash/profile` is versioned independently of the Rust `stack-profile` - # crate, so this must NOT trigger on release-plz's `stack-profile-v*` tags. - # - # Mirrors publish-auth-npm.yml minus the wasm build — stack-profile has no wasm - # target — and stamps its platform pins under `optionalDependencies` (auth uses - # `peerDependencies` for Deno's strict resolver; profile has no such constraint). - push: - branches: - - main - paths: - - packages/stack-profile/node/package.json - # Manual runs default to a dry run; flip `dry_run` off to force a real publish - # (e.g. to re-publish a version the automated path skipped). - workflow_dispatch: - inputs: - dry_run: - description: "Dry run (do not actually publish)" - type: boolean - default: true - -permissions: - contents: read - id-token: write # npm trusted publishing (OIDC) - -defaults: - run: - shell: bash - -env: - CARGO_TERM_COLOR: always - WORKING_DIR: packages/stack-profile/node - -concurrency: - group: publish-profile-npm - cancel-in-progress: false - -jobs: - # Decides whether this run should publish, and at what version. - # - workflow_dispatch: honours the `dry_run` input (defaults to true). - # - push (Version PR merged): real publish, but only if the version in - # package.json is not already on npm — makes the trigger idempotent so a - # push that changed package.json for any other reason is a clean no-op. - preflight: - name: Preflight - runs-on: ubuntu-latest - outputs: - should_publish: ${{ steps.decide.outputs.should_publish }} - dry_run: ${{ steps.decide.outputs.dry_run }} - version: ${{ steps.decide.outputs.version }} - steps: - - uses: actions/checkout@v6 - - - name: Setup Node.js - uses: actions/setup-node@v6 - with: - node-version: 24 - - - name: Decide - id: decide - run: | - set -euo pipefail - VERSION=$(jq -r .version "${{ env.WORKING_DIR }}/package.json") - echo "version=$VERSION" >> "$GITHUB_OUTPUT" - - if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then - DRY_RUN="${{ inputs.dry_run }}" - else - DRY_RUN="false" - fi - echo "dry_run=$DRY_RUN" >> "$GITHUB_OUTPUT" - - # Idempotency guard (push path only): skip if this exact version is - # already published. `npm view` exits non-zero / empty when the exact - # version does not exist. - PUBLISHED=$(npm view "@cipherstash/profile@$VERSION" version 2>/dev/null || true) - if [ "${{ github.event_name }}" = "push" ] && [ "$PUBLISHED" = "$VERSION" ]; then - echo "should_publish=false" >> "$GITHUB_OUTPUT" - echo "::notice::@cipherstash/profile@$VERSION already on npm — nothing to publish." - else - echo "should_publish=true" >> "$GITHUB_OUTPUT" - echo "::notice::Will $( [ "$DRY_RUN" = "true" ] && echo 'dry-run' || echo 'publish') @cipherstash/profile@$VERSION." - fi - - build: - needs: preflight - if: needs.preflight.outputs.should_publish == 'true' - strategy: - fail-fast: true - matrix: - include: - - target: x86_64-apple-darwin - os: macos-latest - - target: aarch64-apple-darwin - os: macos-latest - - target: x86_64-unknown-linux-gnu - os: blacksmith-8vcpu-ubuntu-2404 - - target: aarch64-unknown-linux-gnu - os: blacksmith-8vcpu-ubuntu-2404-arm - - target: x86_64-unknown-linux-musl - os: blacksmith-8vcpu-ubuntu-2404 - - target: x86_64-pc-windows-msvc - os: windows-latest - - name: Build - ${{ matrix.target }} - runs-on: ${{ matrix.os }} - - steps: - - uses: actions/checkout@v6 - - - name: Exclude Windows build output from Defender realtime scan - # Defender real-time scanning every file Cargo writes under target/ - # (10k+ small files) is the dominant cost of MSVC napi builds — - # usually 2-4× slowdown vs. Linux for the same code. Excluding only - # this package's build output keeps the mitigation scoped to the - # generated artifacts while reducing Windows build time. See CIP-3109. - if: runner.os == 'Windows' - shell: pwsh - run: | - Add-MpPreference -ExclusionPath "${{ github.workspace }}\${{ env.WORKING_DIR }}\target" - - - name: Setup Rust - uses: dtolnay/rust-toolchain@1.94.1 - with: - targets: ${{ matrix.target }} - - - name: Setup Rust cache - uses: Swatinem/rust-cache@v2 - - - name: Setup Node.js - uses: actions/setup-node@v6 - with: - node-version: 24 - - - name: Install musl tools - if: matrix.target == 'x86_64-unknown-linux-musl' - run: sudo apt-get update && sudo apt-get install -y musl-tools - - - name: Install dependencies - working-directory: ${{ env.WORKING_DIR }} - run: npm install --omit=optional - - - name: Build native module - working-directory: ${{ env.WORKING_DIR }} - run: npx napi build --platform --release --target ${{ matrix.target }} --strip - - - name: Upload artifact - uses: actions/upload-artifact@v7 - with: - name: bindings-${{ matrix.target }} - path: ${{ env.WORKING_DIR }}/*.node - if-no-files-found: error - - publish: - name: Publish - needs: [preflight, build] - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v6 - - - name: Setup Node.js - uses: actions/setup-node@v6 - with: - node-version: 24 - registry-url: https://registry.npmjs.org - - - name: Install dependencies - working-directory: ${{ env.WORKING_DIR }} - run: npm install --omit=optional - - - name: Download all artifacts - uses: actions/download-artifact@v8 - with: - path: ${{ env.WORKING_DIR }}/artifacts - - - name: Move artifacts to platform packages - working-directory: ${{ env.WORKING_DIR }} - run: npx napi artifacts --dir artifacts - - - name: Determine version and npm tag - id: version - run: | - # The version is whatever `package.json` declares (changesets bumped it - # in the Version Packages PR; a manual dispatch publishes as-is). - VERSION=$(jq -r .version "${{ env.WORKING_DIR }}/package.json") - echo "version=$VERSION" >> "$GITHUB_OUTPUT" - - if [[ "$VERSION" == *"-"* ]]; then - echo "npm_tag=next" >> "$GITHUB_OUTPUT" - else - echo "npm_tag=latest" >> "$GITHUB_OUTPUT" - fi - - echo "Publishing version: $VERSION (tag: $(if [[ "$VERSION" == *"-"* ]]; then echo next; else echo latest; fi))" - - - name: Set package versions - working-directory: ${{ env.WORKING_DIR }} - run: | - VERSION="${{ steps.version.outputs.version }}" - npm version "$VERSION" --no-git-tag-version --allow-same-version - # Pin the napi platform sub-packages to the publish version. Profile - # keeps these under `optionalDependencies` (standard napi layout). - jq --arg v "$VERSION" ' - .optionalDependencies |= with_entries( - if (.key | startswith("@cipherstash/profile-")) - then .value = $v - else . - end - ) - ' package.json > package.json.tmp && mv package.json.tmp package.json - for dir in npm/*/; do - if [ -f "$dir/package.json" ]; then - cd "$dir" - npm version "$VERSION" --no-git-tag-version --allow-same-version - cd - - fi - done - - - name: List packages - working-directory: ${{ env.WORKING_DIR }} - run: | - echo "=== Main package ===" - cat package.json | jq '{name, version}' - echo "" - for dir in npm/*/; do - echo "=== $(basename $dir) ===" - cat "$dir/package.json" | jq '{name, version, os, cpu}' - ls -la "$dir"*.node 2>/dev/null || echo " (no .node file)" - echo "" - done - - - name: Publish platform packages - if: needs.preflight.outputs.dry_run == 'false' - working-directory: ${{ env.WORKING_DIR }} - env: - NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} - run: | - for dir in npm/*/; do - if [ -f "$dir/package.json" ]; then - echo "Publishing $(jq -r .name "$dir/package.json")..." - npm publish "$dir" --access public --tag ${{ steps.version.outputs.npm_tag }} - fi - done - - - name: Publish main package - if: needs.preflight.outputs.dry_run == 'false' - working-directory: ${{ env.WORKING_DIR }} - env: - NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} - run: npm publish --access public --tag ${{ steps.version.outputs.npm_tag }} - - - name: Dry run summary - if: needs.preflight.outputs.dry_run == 'true' - run: echo "Dry run complete. Set dry_run to false to actually publish." diff --git a/.github/imported-workflows/release-npm.yml b/.github/imported-workflows/release-npm.yml deleted file mode 100644 index a3a8fb573..000000000 --- a/.github/imported-workflows/release-npm.yml +++ /dev/null @@ -1,86 +0,0 @@ -# Release versioning for the @cipherstash npm products (CIP-3278). -# -# Owns the *versioning* half of npm releases: on push to main, the changesets -# action opens/updates a "Version Packages" PR that applies accumulated -# .changeset/*.md entries to package.json + CHANGELOG.md for the @cipherstash -# npm products. It deliberately does NOT publish here — the @cipherstash/auth -# and @cipherstash/profile packages need a matrix napi/wasm build. -# -# Publishing is automated but lives in the per-product matrix workflows: merging -# the Version Packages PR bumps each product's node/package.json on main, which -# triggers publish-auth-npm.yml / publish-profile-npm.yml (path-filtered on that -# file, with an npm-version guard so only a genuine bump publishes). That merge -# is the human gate. -# -# This is the seam with release-plz: release-plz owns Cargo.* + crates.io; -# this owns package.json + npm. They never touch the same files. -name: "Release (npm — changesets)" - -on: - push: - branches: - - main - -# Only one Version Packages PR in flight at a time. -concurrency: - group: release-npm - cancel-in-progress: false - -permissions: - contents: write # create the Version Packages branch/commit - pull-requests: write # open/update the Version Packages PR - -jobs: - version: - name: Open/update Version Packages PR - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v6 - with: - fetch-depth: 0 - - - name: Setup Node.js - uses: actions/setup-node@v6 - with: - node-version: 24 - - # Installs only the root manifest's devDependency (@changesets/cli). - # `--ignore-scripts` and no workspace build keep this off the napi - # toolchain entirely — versioning only reads/writes package.json + md. - # - # TEMPORARY: `npm install`, not `npm ci`. `@cipherstash/profile` (the - # `packages/stack-profile/node` workspace) declares its per-platform - # native bindings (`@cipherstash/profile-*`) as optionalDependencies, and - # those aren't published to npm yet. `npm ci` validates the whole lockfile - # against every workspace manifest (even with `--no-workspaces`) and hard- - # fails with "Missing: @cipherstash/profile-<platform> from lock file", - # because an unpublished optional dep can't be recorded in the lock file. - # `npm install` skips unresolvable optional deps with a warning instead. - # Revert to `npm ci` once the `@cipherstash/profile-*` bindings are - # published (tracked with the profile publishing work). - - name: Install changesets - run: npm install --no-workspaces --ignore-scripts --no-audit --no-fund - - - name: Create Version Packages PR - uses: changesets/action@v1.8.0 - with: - # `version-packages` (root package.json) runs `changeset version` to - # regenerate package.json + CHANGELOG from .changeset/*.md, then - # `npm install --package-lock-only` so the root lockfile's recorded - # member versions re-sync in the same commit (otherwise they drift). - # Run via `npm run` — not `npx` — so the local `.bin/changeset` - # resolves and `&&` executes in a shell. - # No `publish:` on purpose — publishing is the matrix workflows' job - # (see header comment). - version: npm run version-packages - # Create the Version Packages commit through the GitHub API instead of - # a local `git commit`. API-created commits are signed by GitHub, so - # they satisfy the "Commits must have verified signatures" branch - # protection on `main` (a local commit from the action is unsigned and - # blocks the merge). This is how release-plz's release commits pass, - # and matches cipherstash/stack's release.yml. - commitMode: github-api - title: "Release: version @cipherstash npm packages" - commit: "chore(release): version @cipherstash npm packages" - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/imported-workflows/require-auth-npm-changeset.yml b/.github/imported-workflows/require-auth-npm-changeset.yml deleted file mode 100644 index 095675fe2..000000000 --- a/.github/imported-workflows/require-auth-npm-changeset.yml +++ /dev/null @@ -1,72 +0,0 @@ -name: "Require @cipherstash/auth changeset" - -on: - pull_request: - paths: - - packages/stack-auth/Cargo.toml - - packages/stack-auth/src/** - - packages/stack-auth/node/** - - packages/stack-auth/wasm/** - - .github/workflows/require-auth-npm-changeset.yml - -defaults: - run: - shell: bash - -jobs: - require-npm-changeset: - name: Require @cipherstash/auth changeset - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v6 - with: - fetch-depth: 0 - ref: ${{ github.event.pull_request.head.sha }} - - - uses: actions/setup-node@v6 - with: - node-version: 24 - - - name: Install the Changesets parser - run: npm install --no-workspaces --ignore-scripts --no-audit --no-fund - - - name: Check for an npm changeset - env: - ACTOR: ${{ github.actor }} - BASE_SHA: ${{ github.event.pull_request.base.sha }} - HEAD_REF: ${{ github.head_ref }} - run: | - set -euo pipefail - - if [[ "$ACTOR" == "github-actions[bot]" ]] && \ - { [[ "$HEAD_REF" == "changeset-release/main" ]] || \ - [[ "$HEAD_REF" == release-plz-* ]]; }; then - echo "Authenticated automated release PR; release intent was already consumed" - exit 0 - fi - - if ! SHIPPED_CHANGES="$( - git diff --name-only "${BASE_SHA}...HEAD" -- \ - packages/stack-auth/Cargo.toml \ - packages/stack-auth/src \ - packages/stack-auth/node \ - packages/stack-auth/wasm - )"; then - echo "::error::Failed to determine release-relevant stack-auth changes" - exit 1 - fi - - if [[ -z "$SHIPPED_CHANGES" ]]; then - echo "No release-relevant stack-auth changes found" - exit 0 - fi - - if ! CHANGESETS="$( - git diff --diff-filter=AM --name-only "${BASE_SHA}...HEAD" -- '.changeset/*.md' - )"; then - echo "::error::Failed to determine added or modified changesets" - exit 1 - fi - - mapfile -t CHANGESET_FILES <<< "$CHANGESETS" - node scripts/check-auth-npm-changeset.mjs "${CHANGESET_FILES[@]}" diff --git a/.github/imported-workflows/test-stack-auth.yml b/.github/imported-workflows/test-stack-auth.yml deleted file mode 100644 index 2674cf9d0..000000000 --- a/.github/imported-workflows/test-stack-auth.yml +++ /dev/null @@ -1,130 +0,0 @@ -name: "Run stack-auth tests" -on: - push: - branches: - - main - paths: - - packages/stack-auth/** - # Root manifest/lockfile: a workspace-wide dependency change can break - # this suite without touching any package source. - - Cargo.toml - - Cargo.lock - - .github/workflows/test-stack-auth.yml - # Ordering matters, this needs to stay at the end if we want to include the above, but exclude all .md files. - - "!**.md" - - "!**.example" - - pull_request: - paths: - - packages/stack-auth/** - # Root manifest/lockfile: a workspace-wide dependency change can break - # this suite without touching any package source. - - Cargo.toml - - Cargo.lock - - .github/workflows/test-stack-auth.yml - # Ordering matters, this needs to stay at the end if we want to include the above, but exclude all .md files. - - "!**.md" - - "!**.example" - - workflow_dispatch: - -defaults: - run: - shell: bash - -env: - RUSTFLAGS: "-D warnings" - RUST_BACKTRACE: full - CARGO_TERM_COLOR: always - CARGO_NET_GIT_FETCH_WITH_CLI: true - NEXTEST_PROFILE: ci - -jobs: - test-stack-auth: - runs-on: blacksmith-8vcpu-ubuntu-2404 - - steps: - - uses: actions/checkout@v6 - - uses: ./.github/actions/setup-rust - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: 22 - - - name: Fix permissions on target directory - run: | - mkdir -p ./target - sudo chown -R "$(id -u):$(id -g)" ./target - - - name: test-unit - run: mise x --env test -- cargo nextest run -p stack-auth --all-features - - - name: test-doc - run: mise run test:doc:stack-auth - - - name: doc - run: mise run doc:stack-auth - - - name: test-integration-stack-auth - run: mise run test:integration:stack-auth - - - name: Guard committed napi typings against drift - working-directory: packages/stack-auth/node - # `native.d.ts` is generated by `napi build --dts native.d.ts` and - # committed (shipped in `files`, re-exported by the hand-written - # `index.d.ts`). Nothing else in CI regenerates it, so a `#[napi]` - # signature change that isn't rebuilt and committed would silently ship - # typings that disagree with the `.node` ABI. Rebuild and fail on any - # diff. `index.d.ts` is hand-written — napi must never touch it — so it's - # diffed too as a belt-and-suspenders check. biome skips `**/*.d.ts`, so - # the committed files are raw napi output and this won't false-positive. - run: | - npm install - npm run build:debug - if ! git diff --exit-code -- native.d.ts index.d.ts; then - echo "::error::packages/stack-auth/node/{native,index}.d.ts is stale — run 'npm run build:debug' in packages/stack-auth/node and commit the result." - exit 1 - fi - - - name: Install wasm32 target - uses: dtolnay/rust-toolchain@1.94.1 - with: - targets: wasm32-unknown-unknown - - - name: Install wasm-pack - # The `jetli/wasm-pack-action` GitHub Action is rejected by the - # cipherstash org allowlist (unverified creator). Install a pinned - # version from the official GitHub release so CI is reproducible and - # not exposed to upstream installer-script changes. - env: - WASM_PACK_VERSION: v0.13.1 - run: | - set -euo pipefail - curl -fsSL "https://github.com/rustwasm/wasm-pack/releases/download/${WASM_PACK_VERSION}/wasm-pack-${WASM_PACK_VERSION}-x86_64-unknown-linux-musl.tar.gz" \ - -o /tmp/wasm-pack.tar.gz - tar -xzf /tmp/wasm-pack.tar.gz -C /tmp - sudo install -m 0755 "/tmp/wasm-pack-${WASM_PACK_VERSION}-x86_64-unknown-linux-musl/wasm-pack" /usr/local/bin/wasm-pack - wasm-pack --version - - - name: Build wasm shim and run wasm-inline Result tests - working-directory: packages/stack-auth/node - # `test:integration:stack-auth` builds only the napi module, so the - # wasm-inline Result tests (`wasm-inline-result.test.ts`) self-skip there - # for lack of the inline shim — leaving the `wasm-inline.mjs` `toFailure` - # seam with no CI coverage. Build the shim now that wasm-pack is - # available and run those tests explicitly. - run: | - npm run build:wasm - npx vitest run __tests__/wasm-inline-result.test.ts - - - name: cargo check stack-auth-wasm - run: cargo check --target wasm32-unknown-unknown -p stack-auth-wasm - - - name: wasm-pack test stack-auth-wasm - run: wasm-pack test --node packages/stack-auth/wasm - - - uses: ./.github/actions/send-slack-notification - with: - channel: engineering - webhook_url: ${{ secrets.SLACK_NOTIFICATION_WEBHOOK_URL }} diff --git a/.github/imported-workflows/test-stack-profile.yml b/.github/imported-workflows/test-stack-profile.yml deleted file mode 100644 index 2a3c0e8df..000000000 --- a/.github/imported-workflows/test-stack-profile.yml +++ /dev/null @@ -1,69 +0,0 @@ -name: "Run stack-profile tests" -on: - push: - branches: - - main - paths: - - packages/stack-profile/** - # Root manifest/lockfile: a workspace-wide dependency change can break - # this suite without touching any package source. - - Cargo.toml - - Cargo.lock - - .github/workflows/test-stack-profile.yml - # Ordering matters, this needs to stay at the end if we want to include the above, but exclude all .md files. - - "!**.md" - - "!**.example" - - pull_request: - paths: - - packages/stack-profile/** - # Root manifest/lockfile: a workspace-wide dependency change can break - # this suite without touching any package source. - - Cargo.toml - - Cargo.lock - - .github/workflows/test-stack-profile.yml - # Ordering matters, this needs to stay at the end if we want to include the above, but exclude all .md files. - - "!**.md" - - "!**.example" - - workflow_dispatch: - -defaults: - run: - shell: bash - -env: - RUSTFLAGS: "-D warnings" - RUST_BACKTRACE: full - CARGO_TERM_COLOR: always - CARGO_NET_GIT_FETCH_WITH_CLI: true - NEXTEST_PROFILE: ci - -jobs: - test-stack-profile: - runs-on: blacksmith-8vcpu-ubuntu-2404 - - steps: - - uses: actions/checkout@v6 - - uses: ./.github/actions/setup-rust - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: 22 - - - name: Fix permissions on target directory - run: | - mkdir -p ./target - sudo chown -R "$(id -u):$(id -g)" ./target - - - name: test-unit - run: mise x --env test -- cargo nextest run -p stack-profile - - - name: test-integration-stack-profile - run: mise run test:integration:stack-profile - - - uses: ./.github/actions/send-slack-notification - with: - channel: engineering - webhook_url: ${{ secrets.SLACK_NOTIFICATION_WEBHOOK_URL }} diff --git a/.github/imported-workflows/test-wasi.yml b/.github/imported-workflows/test-wasi.yml deleted file mode 100644 index 579bb46fd..000000000 --- a/.github/imported-workflows/test-wasi.yml +++ /dev/null @@ -1,241 +0,0 @@ -name: "WASI check (Go/wazero target)" -on: - push: - branches: - - main - paths: - # The gate must be fail-closed over its build inputs, not just the five - # checked crates: their dependency closure includes other local - # packages/ crates (e.g. zerokms-protocol -> cipherstash-config), and - # root Cargo.toml (workspace deps/features) or .cargo/ config can - # change what compiles for the target without touching Cargo.lock. - # Trigger on all of packages/ rather than enumerating the closure, - # which would silently drift. - - packages/** - # The guest is a detached workspace and the Go module (bindings/go: - # stackencrypt and the internal packages) a separate module, so nothing - # else in CI compiles, tests or links them — this job is their only gate. - - bindings/go/** - - scripts/check-wasm-imports.py - - scripts/go-binding-test.sh - - Cargo.toml - - Cargo.lock - - .cargo/** - - mise.toml - - .github/workflows/test-wasi.yml - # Ordering matters, this needs to stay at the end if we want to include the above, but exclude all .md files. - - "!**.md" - - "!**.example" - - pull_request: - paths: - - packages/** - # The guest is a detached workspace and the Go module (bindings/go: - # stackencrypt and the internal packages) a separate module, so nothing - # else in CI compiles, tests or links them — this job is their only gate. - - bindings/go/** - - scripts/check-wasm-imports.py - - scripts/go-binding-test.sh - - Cargo.toml - - Cargo.lock - - .cargo/** - - mise.toml - - .github/workflows/test-wasi.yml - # Ordering matters, this needs to stay at the end if we want to include the above, but exclude all .md files. - - "!**.md" - - "!**.example" - - workflow_dispatch: - -defaults: - run: - shell: bash - -env: - RUSTFLAGS: "-D warnings" - RUST_BACKTRACE: full - CARGO_TERM_COLOR: always - CARGO_NET_GIT_FETCH_WITH_CLI: true - -jobs: - wasi-check: - runs-on: blacksmith-8vcpu-ubuntu-2404 - - steps: - - uses: actions/checkout@v6 - - uses: ./.github/actions/setup-rust - - - name: Fix permissions on target directory - run: | - mkdir -p ./target - sudo chown -R "$(id -u):$(id -g)" ./target - - # The HTTP-free core compiles for wasm32-wasip1 with no JS-host backend - # and no native HTTP/TLS stack in its dependency tree — the invariant - # the Go/wazero binding of stack-encrypt is built on. See - # docs/plans/stack-encrypt-go-bindings.md and wasm-analysis.md Layer 6. - - name: WASI core check - run: mise run wasm:wasi-check - - # The same no-http shape, exercised on the host: unit tests, doctests, - # and rustdoc (-D warnings) must hold with default features off — the - # compile gate above cannot see doc examples or intra-doc links. - - name: No-http tests and docs - run: mise run wasm:no-http-test - - # The stack-encrypt WASI guest (bindings/go/stackencrypt/guest) is a - # detached workspace: `test:unit` and the workspace-wide lints never - # see it, so without these two steps its tests could fail and its - # wasm-only modules stop compiling with every check still green. - - name: Guest lint and tests - run: mise run wasm:guest:test - - # Builds the release module and asserts its host-import surface is - # exactly WASI (minus ambient filesystem/sockets) plus the two - # `cipherstash_transport` functions — the property the Go embedder's - # sandboxing rests on, checkable only on the linked artifact. - - name: Guest release build and import-surface gate - run: mise run wasm:guest:build - - # The credential guest (ADR-0005): the same lint and native tests, - # then the release build and its own import gate — filesystem - # allowed, sockets denied, no transport import until the auth half. - - name: Credential guest lint and tests - run: mise run wasm:auth-guest:test - - - name: Credential guest release build and import-surface gate - run: mise run wasm:auth-guest:build - - # The module checked above is what every platform tests. Its checksum - # travels with it so the other jobs can prove they got the same bytes, - # not a stale or rebuilt guest. - - name: Record the guests' checksums - run: | - for guest in stackencrypt/wasm/stack_encrypt_guest.wasm stackauth/wasm/stack_auth_guest.wasm; do - (cd bindings/go && openssl dgst -sha256 "$guest" | awk '{print $NF}' > "$guest.sha256" && echo "$guest sha256 $(cat "$guest.sha256")") - done - - - name: Hand the guests to the other platforms - uses: actions/upload-artifact@v7 - with: - name: wasm-guests - path: | - bindings/go/stackencrypt/wasm/stack_encrypt_guest.wasm - bindings/go/stackencrypt/wasm/stack_encrypt_guest.wasm.sha256 - bindings/go/stackauth/wasm/stack_auth_guest.wasm - bindings/go/stackauth/wasm/stack_auth_guest.wasm.sha256 - if-no-files-found: error - retention-days: 1 - - # The Go module (bindings/go; stackencrypt and stackauth embed the - # guests built above): format, vet and hermetic tests (import surface, transport - # bridge against an httptest ZeroKMS, guest-parser acceptance of every - # encoding the package builds, hostile ABI inputs, key residency), on - # amd64 and 386. Live round trips through ZeroKMS are the phase 5 - # harness's. - - name: Go binding - env: - # The guest's memory lock (bindings/go/stackencrypt/memory.go) is - # best effort, so its test skips where RLIMIT_MEMLOCK refuses it — - # a developer laptop's default. CI raises the limit and sets this - # so the skip is an error here and the lock is really exercised. - # Device refresh locking is separate: its Go tests cannot skip and - # must pass on Linux, macOS and Windows. - STACKENCRYPT_TESTS_REQUIRE_LOCK: "1" - run: | - ulimit -l "$(ulimit -H -l)" - echo "RLIMIT_MEMLOCK: $(ulimit -l) KiB" - mise run go:test - - # Linux only: macOS and Windows would report the same findings. Files - # behind a windows or darwin build tag are not seen here; lint them - # locally with GOOS set. Needs no guests: the packages embed a directory - # and compile without them. - go-lint: - name: Go lint - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v6 - - - uses: jdx/mise-action@v4 - with: - install_args: go golangci-lint - cache: true - - - name: golangci-lint - run: mise run go:lint - - # The same Go binding on macOS and Windows, against the guest Linux built. - # The credential guest's Go-side refresh lock has Unix and Windows - # implementations. The shared go-binding-test.sh prints the device refresh - # tests explicitly on each platform, alongside the rest of the Go suite. - # The device refresh lock must succeed on macOS and Windows too; the tests - # have no best-effort skip. STACKENCRYPT_TESTS_REQUIRE_LOCK stays Linux-only - # because it governs guest memory locking, not the refresh file lock. - # They gate the way wasi-check does: a red run on the PR, not branch protection. - # main's ruleset requires no status check today, and this workflow is path - # filtered, so a required check here would never run on a PR outside the - # filter and block it forever. If either job is ever made required, pair it - # with a same-named job in a workflow triggered on the complementary paths - # that always succeeds, as GitHub documents. Nothing Rust runs here — Go - # plus the artifact is the whole toolchain, which is what keeps them quick. - go-binding-cross: - name: Go binding (${{ matrix.os }}) - needs: wasi-check - strategy: - fail-fast: false - matrix: - os: [macos-latest, windows-latest] - runs-on: ${{ matrix.os }} - - steps: - # Git for Windows defaults to CRLF on checkout, and gofmt would then - # report every file. Set this before checkout, where it takes effect. - - name: Keep LF line endings - run: git config --global core.autocrlf false - - - uses: actions/checkout@v6 - - # One source of truth for the Go version: the mise pin the Linux job - # runs under. The sed matches one shape of that line; if the pin is - # ever written another way, fail here by name rather than hand - # setup-go an empty version. - - name: Go version from mise.toml - id: go - run: | - version=$(sed -n 's/^go = "\(.*\)"/\1/p' mise.toml) - if [ -z "$version" ]; then - echo 'no `go = "..."` line in mise.toml; the Go version pin has changed shape' >&2 - exit 1 - fi - echo "version=$version" >> "$GITHUB_OUTPUT" - - - uses: actions/setup-go@v5 - with: - go-version: ${{ steps.go.outputs.version }} - cache-dependency-path: bindings/go/go.sum - - # The artifact keeps the paths it was uploaded with, relative to their - # common root (bindings/go), so both guests land where the packages - # embed them. - - uses: actions/download-artifact@v8 - with: - name: wasm-guests - path: bindings/go - - - name: The guests are the ones Linux built and checked - run: | - cd bindings/go - for guest in stackencrypt/wasm/stack_encrypt_guest.wasm stackauth/wasm/stack_auth_guest.wasm; do - want=$(cat "$guest.sha256") - got=$(openssl dgst -sha256 "$guest" | awk '{print $NF}') - if [ "$want" != "$got" ]; then - echo "$guest checksum mismatch: artifact says $want, file is $got" >&2 - exit 1 - fi - echo "$guest sha256 $got" - done - - - name: Go binding - run: scripts/go-binding-test.sh bindings/go diff --git a/docs/auth-strategy-handover.md b/docs/auth-strategy-handover.md index a93e8b9ba..6cd031ae4 100644 --- a/docs/auth-strategy-handover.md +++ b/docs/auth-strategy-handover.md @@ -2,7 +2,7 @@ > **Status:** RFC. Cross-repo design — implementation lives in [`cipherstash/protectjs-ffi`](https://github.com/cipherstash/protectjs-ffi). > -> **Prep landed in this repo:** [`stack_auth::AuthStrategyFn`](packages/stack-auth/src/auth_strategy_fn.rs) — the helper protect-ffi will reach for. Sits on the acquisition layer ([`stack_auth::auth`](packages/stack-auth/src/lib.rs)); its sibling [`stack_auth::TokenStoreFn`](packages/stack-auth/src/token_store.rs) on the persistence layer is what `JsTokenStore` already uses for cookie-backed caching. +> **Prep landed in this repo:** [`stack_auth::AuthStrategyFn`](../packages/stack-auth/src/auth_strategy_fn.rs) — the helper protect-ffi will reach for. Sits on the acquisition layer ([`stack_auth::auth`](../packages/stack-auth/src/lib.rs)); its sibling [`stack_auth::TokenStoreFn`](../packages/stack-auth/src/token_store.rs) on the persistence layer is what `JsTokenStore` already uses for cookie-backed caching. ## Why @@ -179,11 +179,11 @@ If the JS `getToken` throws or rejects, the adapter surfaces an `AuthError`. Rec This is bridge scaffolding. Once `stack-encrypt` lands with its own napi/wasm bindings that accept a `stack_auth::AuthStrategy` natively (no JS-callback round-trip per ZeroKMS request), `protect-ffi`'s `JsAuthStrategy` adapter can be retired and consumers migrate to `@cipherstash/protect` (or whatever the published package becomes). -[`AuthStrategyFn`](packages/stack-auth/src/auth_strategy_fn.rs) itself stays useful past that retirement — any foreign Rust consumer that wants to bring a non-`stack-auth`-native strategy to `cipherstash-client` (third-party integrations, test fixtures, future sidecars) uses the same pattern. +[`AuthStrategyFn`](../packages/stack-auth/src/auth_strategy_fn.rs) itself stays useful past that retirement — any foreign Rust consumer that wants to bring a non-`stack-auth`-native strategy to `cipherstash-client` (third-party integrations, test fixtures, future sidecars) uses the same pattern. ## Why no changes to cipherstash-suite production code -`cipherstash_client::ZeroKMSBuilder::new<C>` already accepts any `C` where `for<'a> &'a C: stack_auth::AuthStrategy` (see [`packages/cipherstash-client/src/zerokms/builder.rs:108-118`](packages/cipherstash-client/src/zerokms/builder.rs)). `JsAuthStrategy` satisfies that bound by virtue of `impl AuthStrategy for &JsAuthStrategy`. No generic refactor, no `dyn AuthStrategy`, no trait additions. +`cipherstash_client::ZeroKMSBuilder::new<C>` already accepts any `C` where `for<'a> &'a C: stack_auth::AuthStrategy` (see `src/zerokms/builder.rs:108-118` in the `cipherstash-client` crate, in cipherstash-suite). `JsAuthStrategy` satisfies that bound by virtue of `impl AuthStrategy for &JsAuthStrategy`. No generic refactor, no `dyn AuthStrategy`, no trait additions. The only thing this repo ships in support of this RFC is: diff --git a/docs/fuzzing.md b/docs/fuzzing.md index f5fda37ed..8ed518626 100644 --- a/docs/fuzzing.md +++ b/docs/fuzzing.md @@ -21,9 +21,6 @@ Current targets: | mise task | crate | target binary | parses | |----------------------|---------------|-----------------------|-----------------------------------------------| -| `fuzz:crn` | `cts-common` | `crn_parse` | `Crn` (e.g. `crn:ca-central-1.aws:ZVAT…`) | -| `fuzz:workspace-id` | `cts-common` | `workspace_id_parse` | `WorkspaceId` | -| `fuzz:region` | `cts-common` | `region_parse` | `Region` | | `fuzz:access-key` | `stack-auth` | `access_key_parse` | `AccessKey` (`CSAK<key_id>.<key_secret>`) | | `fuzz:jwt-decode` | `stack-auth` | `jwt_decode` | JWT claims (`Token::fuzz_decode_claims`) | | `fuzz:client-key` | `stack-kms` | `client_key_encoded` | `ClientKey::from_encoded_v1` (hex or base64) | @@ -39,7 +36,7 @@ for the byte decoders) to the parser via the `arbitrary` crate: use libfuzzer_sys::fuzz_target; fuzz_target!(|s: &str| { - let _ = s.parse::<cts_common::Crn>(); + let _ = s.parse::<stack_auth::AccessKey>(); }); ``` @@ -61,11 +58,11 @@ main monorepo workspace, and it is **not** a member of the root `Cargo.toml`: ``` -packages/cts-common/fuzz/ +packages/stack-auth/fuzz/ Cargo.toml # detached workspace, cargo-fuzz = true + Cargo.lock # tracked, so `--locked` and Dependabot see it fuzz_targets/*.rs # one file per target binary corpus/<target>/* # committed seed inputs (valid examples) -packages/stack-auth/fuzz/ packages/stack-kms/fuzz/ packages/stack-encrypt/fuzz/ … @@ -84,21 +81,21 @@ nightly once with `rustup toolchain install nightly`. Run a target via its `mise` task (60s by default): ```bash -mise run fuzz:crn +mise run fuzz:access-key ``` Override the duration by appending another libFuzzer flag — the last value of a repeated flag wins: ```bash -mise run fuzz:crn -- -max_total_time=300 +mise run fuzz:access-key -- -max_total_time=300 ``` Replay only the committed seed corpus without fuzzing (what CI's regression job does): ```bash -mise run fuzz:crn -- -runs=0 +mise run fuzz:access-key -- -runs=0 ``` The tasks pin `--sanitizer none` (these parsers are pure safe Rust, so @@ -110,9 +107,9 @@ fails to find `std`). A crash drops a reproducer into `fuzz/artifacts/<target>/`; re-run that single input with `cargo +nightly fuzz run <target> <path-to-reproducer>`. -## CI ([`.github/workflows/fuzz.yml`](../.github/workflows/fuzz.yml)) +## CI (`.github/workflows/fuzz.yml`) -Two jobs with deliberately different roles: +The workflow arrives with the CI port of the stack-* crates. Two jobs with deliberately different roles: - **fuzz-regression** (`pull_request`, **blocking**): builds every harness — which catches harness/API drift, e.g. a changed `FromStr` @@ -127,9 +124,8 @@ Two jobs with deliberately different roles: `restore-keys`) so coverage compounds, minimized with `cargo fuzz cmin` to stay small, and any crash reproducer is uploaded as an artifact. -The `pull_request` trigger is path-filtered to `packages/cts-common/**`, -`packages/stack-auth/**`, `packages/stack-kms/**`, `packages/stack-encrypt/**`, -and the workflow file, +The `pull_request` trigger is path-filtered to `packages/stack-auth/**`, +`packages/stack-kms/**`, `packages/stack-encrypt/**`, and the workflow file, with `!**.md` / `!**.example` excludes last so docs-only changes are skipped. diff --git a/docs/rfcs/0002-async-shape-for-target-directed-encryption.md b/docs/rfcs/0002-async-shape-for-target-directed-encryption.md index 85fab43fc..44676d96e 100644 --- a/docs/rfcs/0002-async-shape-for-target-directed-encryption.md +++ b/docs/rfcs/0002-async-shape-for-target-directed-encryption.md @@ -443,7 +443,7 @@ outputs — is a future `Prf` trait extension, and should be designed with the | -- | -- | | `src/target/{mod,pending,request}.rs` | `EncryptTarget` + GAT; `Pending` (with the wasm32 `Send` cfg-split carried over from `PendingEncrypt`); drop `PendingEncrypt` alias, `EncryptFrom::Error`, `TargetError`. Split in review so `Pending` and `Request`/`Responses` carry their own unit tests | | `src/sem/mod.rs` | four visitors in, four `derive_*` out; validation moves ahead of the request; ORE/OPE encrypt inside the visitor (`Send + 'static` sources) | -| `packages/cllw-ore` | `CllwOreEncrypt`/`CllwOpeEncrypt` for `String` and `Vec<u8>`, delegating to the borrowed impls | +| `cllw-ore` (in cipherstash-suite) | `CllwOreEncrypt`/`CllwOpeEncrypt` for `String` and `Vec<u8>`, delegating to the borrowed impls | | `src/cipher.rs` | `StackCipher: EncryptTarget`; `dispatch`; `Error::Term`/`Error::Other`/`Error::ResponseShape` | | `examples/`, `tests/` | column encrypted as a `Vec`, not a loop; `try_join!` gone; tokio dev-dep drops out of the record shape | @@ -631,8 +631,8 @@ attribute name lined up with neither derive. Flipping the decrypt trait (item Three homes, by durability: - **Trait invariants** (§5, and "the visitor shapes, the backend only - produces blocks") → rustdoc on `Prf::Ok<T>` in `vitaminc/packages/prf/src/traits.rs`, - and on `Cipher::Ok` in `vitaminc/packages/aead/src/cipher.rs`. A backend + produces blocks") → rustdoc on `Prf::Ok<T>` in `src/traits.rs` of vitaminc's `prf` crate, + and on `Cipher::Ok` in `src/cipher.rs` of its `aead` crate. A backend author reads the trait, not this repo's RFC directory. These are the two places where getting it wrong is invisible until it is expensive. - **The reasoning** (§2–§4) → this RFC. It explains why the obvious diff --git a/docs/wasm-analysis.md b/docs/wasm-analysis.md index dbfd30310..11cf10afb 100644 --- a/docs/wasm-analysis.md +++ b/docs/wasm-analysis.md @@ -113,7 +113,7 @@ But wazero is **not** a JS host. It targets `wasm32-wasip1` (WASI preview 1) — **Architecture: host-provided transport.** HTTP stays out of the wasm and is satisfied by a function the Go host provides; control stays in Rust (the "host orchestrates each step" shape was considered and rejected in #2099 because it smears the protocol state machine across the FFI). Why not HTTP inside the guest: wasip1 has no `sock_connect` (receive/accept only), so outbound TCP needs a host import regardless; TLS in the guest would mean rustls on a pure-Rust provider with embedded roots and no AES-NI, strictly worse than Go's `crypto/tls` with system roots; and `wasi:http` — the right long-term answer — is component model, which wazero does not run. The host can already read guest memory, so routing HTTP through it weakens nothing: what crosses the boundary is exactly what crosses TLS (URL, bearer token, protocol JSON). Data keys, the client key and the index key never do. -**The plan** lives in [`docs/plans/stack-encrypt-go-bindings.md`](docs/plans/stack-encrypt-go-bindings.md): phases, the vitaminc `bindings/go` layering (`vcvalue` value model + FFI codec are reused; the stack-encrypt side is the cipher/KMS side), the frozen byte formats stack-encrypt owns, and the open decisions. Terminology fixed there: *storage format* (sealed leaf, into a database), *FFI codec* (host ↔ guest marshalling, throwaway), *transport* (HTTP, out of the process). +**The plan** lives in [`docs/plans/stack-encrypt-go-bindings.md`](plans/stack-encrypt-go-bindings.md): phases, the vitaminc `bindings/go` layering (`vcvalue` value model + FFI codec are reused; the stack-encrypt side is the cipher/KMS side), the frozen byte formats stack-encrypt owns, and the open decisions. Terminology fixed there: *storage format* (sealed leaf, into a database), *FFI codec* (host ↔ guest marshalling, throwaway), *transport* (HTTP, out of the process). **Gate.** `mise run wasm:wasi-check` compiles the HTTP-free core for `wasm32-wasip1` and fails if any crate's normal-dependency tree contains a JS-host backend (`wasm-bindgen`/`web-sys`/`js-sys`) **or** the native HTTP/TLS stack (`reqwest`/`hyper`/`aws-lc-sys`). Phase 0 gates `zerokms-protocol`, `cipherstash-core`, `recipher`, `cts-common`, `cllw-ore`; Phase 1 adds `stack-auth`, `stack-kms`, `stack-encrypt` once reqwest is behind a feature in each. diff --git a/languages/golang/stackauth/guest/Cargo.toml b/languages/golang/stackauth/guest/Cargo.toml index 3695d1188..8bb48dba1 100644 --- a/languages/golang/stackauth/guest/Cargo.toml +++ b/languages/golang/stackauth/guest/Cargo.toml @@ -27,7 +27,7 @@ crate-type = ["cdylib", "rlib"] serde = { version = "1", features = ["derive"] } serde_json = "1" futures = "0.3" -cts-common = { path = "../../../../packages/cts-common", default-features = false } +cts-common = { version = "=0.43.0", default-features = false } url = "2" # Strategies and `Token`, the typed shape of `auth.json`, with default # features off: no reqwest, no native TLS. diff --git a/languages/golang/stackencrypt/guest/Cargo.toml b/languages/golang/stackencrypt/guest/Cargo.toml index 3dc79c29c..69f82059e 100644 --- a/languages/golang/stackencrypt/guest/Cargo.toml +++ b/languages/golang/stackencrypt/guest/Cargo.toml @@ -35,7 +35,7 @@ stack-encrypt = { path = "../../../../packages/stack-encrypt", default-features # This crate defines only the exports that are its own. stack-guest-abi = { path = "../../../../packages/stack-guest-abi" } stack-kms = { path = "../../../../packages/stack-kms", default-features = false } -zerokms-protocol = { path = "../../../../packages/zerokms-protocol" } +zerokms-protocol = "=0.12.31" # The FFI codec + `FfiValue` tree, shared with vitaminc's own guest — one # codec, not a fork. Same vitaminc version stack-encrypt builds against @@ -54,7 +54,7 @@ zeroize = "1" [dev-dependencies] stack-kms = { path = "../../../../packages/stack-kms", default-features = false, features = ["test-support"] } # For minting a valid client-key fixture in the config tests. -recipher = { path = "../../../../packages/recipher" } +recipher = "=0.3.1" # Re-encoding that fixture into the other forms the config table promises # (upper-case hex, base64) so the lenient decode is actually exercised. base16ct = { version = "0.2.0", features = ["alloc"] } diff --git a/languages/typescript/packages/auth/biome.json b/languages/typescript/packages/auth/biome.json deleted file mode 100644 index 6805962fe..000000000 --- a/languages/typescript/packages/auth/biome.json +++ /dev/null @@ -1,38 +0,0 @@ -{ - "$schema": "https://biomejs.dev/schemas/2.3.4/schema.json", - "vcs": { - "enabled": true, - "clientKind": "git", - "useIgnoreFile": true - }, - "files": { - "includes": [ - "**/*.js", - "**/*.mjs", - "**/*.ts", - "!**/*.d.ts", - "!wasm/**", - "!npm/**", - "!stack-auth-node.js" - ] - }, - "formatter": { - "enabled": true, - "indentStyle": "space", - "indentWidth": 2, - "lineWidth": 80 - }, - "linter": { - "enabled": false - }, - "assist": { - "enabled": false - }, - "javascript": { - "formatter": { - "quoteStyle": "double", - "trailingCommas": "all", - "semicolons": "always" - } - } -} diff --git a/languages/typescript/packages/profile/biome.json b/languages/typescript/packages/profile/biome.json deleted file mode 100644 index 763a1c530..000000000 --- a/languages/typescript/packages/profile/biome.json +++ /dev/null @@ -1,37 +0,0 @@ -{ - "$schema": "https://biomejs.dev/schemas/2.3.4/schema.json", - "vcs": { - "enabled": true, - "clientKind": "git", - "useIgnoreFile": true - }, - "files": { - "includes": [ - "**/*.js", - "**/*.mjs", - "**/*.ts", - "!**/*.d.ts", - "!npm/**", - "!stack-profile-node.js" - ] - }, - "formatter": { - "enabled": true, - "indentStyle": "space", - "indentWidth": 2, - "lineWidth": 80 - }, - "linter": { - "enabled": false - }, - "assist": { - "enabled": false - }, - "javascript": { - "formatter": { - "quoteStyle": "double", - "trailingCommas": "all", - "semicolons": "always" - } - } -} diff --git a/languages/typescript/packages/profile/package.json b/languages/typescript/packages/profile/package.json index d844d1933..0473e4359 100644 --- a/languages/typescript/packages/profile/package.json +++ b/languages/typescript/packages/profile/package.json @@ -1,6 +1,7 @@ { "name": "@cipherstash/profile", "version": "0.35.0", + "private": true, "main": "index.js", "types": "index.d.ts", "napi": { diff --git a/languages/typescript/packages/profile/platforms/darwin-arm64/package.json b/languages/typescript/packages/profile/platforms/darwin-arm64/package.json index bfb10bfb7..d2216eb15 100644 --- a/languages/typescript/packages/profile/platforms/darwin-arm64/package.json +++ b/languages/typescript/packages/profile/platforms/darwin-arm64/package.json @@ -1,6 +1,7 @@ { "name": "@cipherstash/profile-darwin-arm64", "version": "0.35.0", + "private": true, "os": ["darwin"], "cpu": ["arm64"], "main": "stack-profile-node.darwin-arm64.node", diff --git a/languages/typescript/packages/profile/platforms/darwin-x64/package.json b/languages/typescript/packages/profile/platforms/darwin-x64/package.json index e51a45a55..f710fd79e 100644 --- a/languages/typescript/packages/profile/platforms/darwin-x64/package.json +++ b/languages/typescript/packages/profile/platforms/darwin-x64/package.json @@ -1,6 +1,7 @@ { "name": "@cipherstash/profile-darwin-x64", "version": "0.35.0", + "private": true, "os": ["darwin"], "cpu": ["x64"], "main": "stack-profile-node.darwin-x64.node", diff --git a/languages/typescript/packages/profile/platforms/linux-arm64-gnu/package.json b/languages/typescript/packages/profile/platforms/linux-arm64-gnu/package.json index 6e9170ee0..db8ff9f5e 100644 --- a/languages/typescript/packages/profile/platforms/linux-arm64-gnu/package.json +++ b/languages/typescript/packages/profile/platforms/linux-arm64-gnu/package.json @@ -1,6 +1,7 @@ { "name": "@cipherstash/profile-linux-arm64-gnu", "version": "0.35.0", + "private": true, "os": ["linux"], "cpu": ["arm64"], "main": "stack-profile-node.linux-arm64-gnu.node", diff --git a/languages/typescript/packages/profile/platforms/linux-x64-gnu/package.json b/languages/typescript/packages/profile/platforms/linux-x64-gnu/package.json index 424c157b5..b1f321f62 100644 --- a/languages/typescript/packages/profile/platforms/linux-x64-gnu/package.json +++ b/languages/typescript/packages/profile/platforms/linux-x64-gnu/package.json @@ -1,6 +1,7 @@ { "name": "@cipherstash/profile-linux-x64-gnu", "version": "0.35.0", + "private": true, "os": ["linux"], "cpu": ["x64"], "main": "stack-profile-node.linux-x64-gnu.node", diff --git a/languages/typescript/packages/profile/platforms/linux-x64-musl/package.json b/languages/typescript/packages/profile/platforms/linux-x64-musl/package.json index 3e1f107d9..7517a7b3a 100644 --- a/languages/typescript/packages/profile/platforms/linux-x64-musl/package.json +++ b/languages/typescript/packages/profile/platforms/linux-x64-musl/package.json @@ -1,6 +1,7 @@ { "name": "@cipherstash/profile-linux-x64-musl", "version": "0.35.0", + "private": true, "os": ["linux"], "cpu": ["x64"], "main": "stack-profile-node.linux-x64-musl.node", diff --git a/languages/typescript/packages/profile/platforms/win32-x64-msvc/package.json b/languages/typescript/packages/profile/platforms/win32-x64-msvc/package.json index 5941a82f2..be6681aba 100644 --- a/languages/typescript/packages/profile/platforms/win32-x64-msvc/package.json +++ b/languages/typescript/packages/profile/platforms/win32-x64-msvc/package.json @@ -1,6 +1,7 @@ { "name": "@cipherstash/profile-win32-x64-msvc", "version": "0.35.0", + "private": true, "os": ["win32"], "cpu": ["x64"], "main": "stack-profile-node.win32-x64-msvc.node", diff --git a/packages/stack-auth/package-lock.json b/packages/stack-auth/package-lock.json deleted file mode 100644 index 58d7ece0b..000000000 --- a/packages/stack-auth/package-lock.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "name": "stack-auth", - "lockfileVersion": 3, - "requires": true, - "packages": {} -} From 6bb5775d8488c6765915699074a1ce7aed241cb6 Mon Sep 17 00:00:00 2001 From: Lindsay Holmwood <lindsay@holmwood.id.au> Date: Fri, 2 Oct 2026 09:22:38 +1000 Subject: [PATCH 673/686] build: keep Biome off the frozen and generated binding files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plan §7.3, before the reformat, so `code:fix` leaves these files alone. - target/ and **/target: Cargo build output at the new root workspace. - The auth binding's wasm/, built by build:wasm. - Every file @cipherstash/auth publishes from the tree: index.js, stack-auth-node.js, the four .mjs modules, the seven .d.ts files, README.md and LICENSE (plan §13.6). The release gate freezes the wrapper by comparing these bytes with @cipherstash/auth@0.44.0 on npm. The suite formatted them with double quotes and semicolons, so without the ignores the reformat rewrites ten of them and the gate refuses the tree. native.d.ts and index.d.ts are also generated by napi. - The profile binding's index.d.ts, which napi generates. - languages/golang/, which has no JavaScript Biome should own. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PaY5xYydZUWhv8Nex9Sw8a --- biome.json | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/biome.json b/biome.json index f20e4bb71..fa3653219 100644 --- a/biome.json +++ b/biome.json @@ -16,6 +16,26 @@ "!packages/eql/release", "!packages/eql/target", "!packages/eql/docs/api", + "!target", + "!**/target", + "!languages/typescript/packages/auth/wasm", + "!languages/typescript/packages/auth/index.js", + "!languages/typescript/packages/auth/stack-auth-node.js", + "!languages/typescript/packages/auth/wasm-inline.mjs", + "!languages/typescript/packages/auth/cookies.mjs", + "!languages/typescript/packages/auth/base64url.mjs", + "!languages/typescript/packages/auth/next.mjs", + "!languages/typescript/packages/auth/index.d.ts", + "!languages/typescript/packages/auth/native.d.ts", + "!languages/typescript/packages/auth/wasm-types.d.ts", + "!languages/typescript/packages/auth/wasm-inline.d.ts", + "!languages/typescript/packages/auth/cookies.d.ts", + "!languages/typescript/packages/auth/base64url.d.ts", + "!languages/typescript/packages/auth/next.d.ts", + "!languages/typescript/packages/auth/README.md", + "!languages/typescript/packages/auth/LICENSE", + "!languages/typescript/packages/profile/index.d.ts", + "!languages/golang", "!**/*.grit", "!**/*.generated.ts", "!**/contract.json", From fa05f243cd44863d75540332e4f07d5fd88de81d Mon Sep 17 00:00:00 2001 From: Lindsay Holmwood <lindsay@holmwood.id.au> Date: Fri, 2 Oct 2026 09:22:45 +1000 Subject: [PATCH 674/686] style: reformat the imported bindings with the root Biome config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plan §7.3: `pnpm run code:fix` after the Biome ignores, in its own commit. The suite formatted these files with a nested Biome config (double quotes, semicolons, 80 columns); the root config uses single quotes and no semicolons. Formatting only, plus Biome's safe fixes. The files the frozen @cipherstash/auth publishes are ignored, so they keep the bytes that are on npm. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PaY5xYydZUWhv8Nex9Sw8a --- .../auth/__tests__/consumer-typecheck.test.ts | 140 +++--- .../packages/auth/__tests__/cookies.test.ts | 220 +++++----- .../auth/__tests__/device-code-flow.test.ts | 152 +++---- .../auth/__tests__/factory-result.test.ts | 144 +++---- .../auth/__tests__/helpers/mock-cts-server.ts | 140 +++--- .../auth/__tests__/helpers/test-fixtures.ts | 46 +- .../packages/auth/__tests__/next.test.ts | 400 +++++++++--------- .../__tests__/oidc-cookie-roundtrip.test.ts | 148 +++---- .../oidc-federation-strategy.test.ts | 358 ++++++++-------- .../__tests__/provision-device-client.test.ts | 129 +++--- .../auth/__tests__/wasm-inline-result.test.ts | 60 +-- .../packages/auth/examples/auto-strategy.ts | 36 +- .../packages/auth/examples/device-code.ts | 54 ++- .../packages/auth/scripts/inline-wasm.mjs | 22 +- .../typescript/packages/auth/vitest.config.ts | 4 +- .../profile/__tests__/profile-store.test.ts | 216 +++++----- .../profile/examples/workspace-management.ts | 32 +- .../typescript/packages/profile/index.js | 38 +- .../packages/profile/stack-profile-node.js | 56 ++- .../packages/profile/vitest.config.ts | 4 +- scripts/check-auth-npm-changeset.mjs | 61 +-- 21 files changed, 1233 insertions(+), 1227 deletions(-) diff --git a/languages/typescript/packages/auth/__tests__/consumer-typecheck.test.ts b/languages/typescript/packages/auth/__tests__/consumer-typecheck.test.ts index 05b7fe455..2f03abfe3 100644 --- a/languages/typescript/packages/auth/__tests__/consumer-typecheck.test.ts +++ b/languages/typescript/packages/auth/__tests__/consumer-typecheck.test.ts @@ -1,8 +1,8 @@ -import { describe, it, expect } from "vitest"; -import { execFileSync } from "child_process"; -import { mkdtempSync, writeFileSync, copyFileSync } from "fs"; -import { join } from "path"; -import { tmpdir } from "os"; +import { execFileSync } from 'child_process' +import { copyFileSync, mkdtempSync, writeFileSync } from 'fs' +import { tmpdir } from 'os' +import { join } from 'path' +import { describe, expect, it } from 'vitest' // The public type surface is split across two files: `index.d.ts` is // hand-written and re-exports the NAPI-RS–generated `native.d.ts`, adding the @@ -20,10 +20,10 @@ import { tmpdir } from "os"; // name, through the `package.json` `exports` map. It asserts behaviour (does a // consumer compile?) rather than grepping the declaration file for strings. -const packageDir = join(__dirname, ".."); +const packageDir = join(__dirname, '..') // Run the locally-installed tsc as a script under the current node, so this is // cross-platform (no shell, no `.bin` shim) and pinned to the devDependency. -const tscBin = require.resolve("typescript/bin/tsc"); +const tscBin = require.resolve('typescript/bin/tsc') // A consumer that imports — and *uses*, so nothing is elided — the public // Result surface: the success types (`TokenResult`), the `AuthFailure` @@ -71,124 +71,124 @@ async function run() { void run; void _alias; -`; +` // Node16 resolution makes tsc honour the package's `exports` map (the "node" // condition resolves to `index.d.ts`), so this verifies the real entrypoint a // consumer hits — not just a relative path into the file. const baseCompilerOptions = { - target: "ES2020", - module: "Node16", - moduleResolution: "Node16", + target: 'ES2020', + module: 'Node16', + moduleResolution: 'Node16', strict: true, esModuleInterop: true, skipLibCheck: true, noEmit: true, - baseUrl: ".", -}; + baseUrl: '.', +} // Type-check CONSUMER against whatever `@cipherstash/auth` resolves to at // `pkgDir`. Returns whether tsc accepted it and its combined output. function typecheckConsumerAgainst(pkgDir: string): { - ok: boolean; - output: string; + ok: boolean + output: string } { - const dir = mkdtempSync(join(tmpdir(), "cs-auth-tscheck-")); - writeFileSync(join(dir, "consumer.ts"), CONSUMER); + const dir = mkdtempSync(join(tmpdir(), 'cs-auth-tscheck-')) + writeFileSync(join(dir, 'consumer.ts'), CONSUMER) writeFileSync( - join(dir, "tsconfig.json"), + join(dir, 'tsconfig.json'), JSON.stringify({ compilerOptions: { ...baseCompilerOptions, - paths: { "@cipherstash/auth": [pkgDir] }, + paths: { '@cipherstash/auth': [pkgDir] }, }, - files: ["consumer.ts"], + files: ['consumer.ts'], }), - ); + ) try { const output = execFileSync( process.execPath, - [tscBin, "-p", join(dir, "tsconfig.json")], - { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }, - ); - return { ok: true, output }; + [tscBin, '-p', join(dir, 'tsconfig.json')], + { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }, + ) + return { ok: true, output } } catch (err) { - const e = err as { stdout?: Buffer | string; stderr?: Buffer | string }; - return { ok: false, output: `${e.stdout ?? ""}${e.stderr ?? ""}` }; + const e = err as { stdout?: Buffer | string; stderr?: Buffer | string } + return { ok: false, output: `${e.stdout ?? ''}${e.stderr ?? ''}` } } } -describe("consumer typecheck (index.d.ts -> native.d.ts split)", () => { - it("a consumer importing from @cipherstash/auth type-checks", () => { - const { ok, output } = typecheckConsumerAgainst(packageDir); - expect(ok, `tsc reported type errors:\n${output}`).toBe(true); - }); +describe('consumer typecheck (index.d.ts -> native.d.ts split)', () => { + it('a consumer importing from @cipherstash/auth type-checks', () => { + const { ok, output } = typecheckConsumerAgainst(packageDir) + expect(ok, `tsc reported type errors:\n${output}`).toBe(true) + }) - it("rejects a consumer when the split is broken (guard has teeth)", () => { + it('rejects a consumer when the split is broken (guard has teeth)', () => { // Mirror the real package so module resolution is identical (same // package.json/`exports`), but replace index.d.ts with a stub that drops // the `export * from "./native"` re-export and the hand-written // declarations. The consumer must now fail to compile — proving this // harness actually goes red when the split breaks, rather than only // passing when everything is intact. - const brokenPkg = mkdtempSync(join(tmpdir(), "cs-auth-broken-")); + const brokenPkg = mkdtempSync(join(tmpdir(), 'cs-auth-broken-')) copyFileSync( - join(packageDir, "package.json"), - join(brokenPkg, "package.json"), - ); - writeFileSync(join(brokenPkg, "index.d.ts"), "export {};\n"); + join(packageDir, 'package.json'), + join(brokenPkg, 'package.json'), + ) + writeFileSync(join(brokenPkg, 'index.d.ts'), 'export {};\n') - const { ok } = typecheckConsumerAgainst(brokenPkg); - expect(ok, "tsc should reject a consumer when the split is broken").toBe( + const { ok } = typecheckConsumerAgainst(brokenPkg) + expect(ok, 'tsc should reject a consumer when the split is broken').toBe( false, - ); - }); -}); + ) + }) +}) -describe("runtime re-export contract (index.js)", () => { - it("OAuthStrategy is the same runtime value as DeviceSessionStrategy", () => { +describe('runtime re-export contract (index.js)', () => { + it('OAuthStrategy is the same runtime value as DeviceSessionStrategy', () => { // The typecheck above (noEmit) only proves the *type* alias resolves. The // runtime alias `module.exports.OAuthStrategy = native.DeviceSessionStrategy` // in index.js is exercised by nothing else, so load the real entrypoint and // assert it: drop that line and `import { OAuthStrategy }` silently becomes // `undefined` for consumers. - const mod = require("../index.js") as typeof import("../index"); - expect(mod.OAuthStrategy).toBeDefined(); - expect(mod.OAuthStrategy).toBe(mod.DeviceSessionStrategy); - }); -}); - -describe("publish contract (npm pack)", () => { - it("packs the declarations required by index.d.ts", () => { + const mod = require('../index.js') as typeof import('../index') + expect(mod.OAuthStrategy).toBeDefined() + expect(mod.OAuthStrategy).toBe(mod.DeviceSessionStrategy) + }) +}) + +describe('publish contract (npm pack)', () => { + it('packs the declarations required by index.d.ts', () => { // index.d.ts does `export * from "./native"`, so native.d.ts MUST ship in // the tarball or every published consumer's import dangles on a missing // file. The typecheck resolves against the source tree, not the packed // output, so this is the only guard on the `files` allowlist. - const out = execFileSync("npm", ["pack", "--json", "--dry-run"], { + const out = execFileSync('npm', ['pack', '--json', '--dry-run'], { cwd: packageDir, - encoding: "utf8", - stdio: ["ignore", "pipe", "ignore"], - }); + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + }) const packed = ( JSON.parse(out) as Array<{ files: Array<{ path: string }> }> - )[0].files.map((f) => f.path); + )[0].files.map((f) => f.path) expect(packed).toEqual( - expect.arrayContaining(["index.d.ts", "native.d.ts"]), - ); - }); + expect.arrayContaining(['index.d.ts', 'native.d.ts']), + ) + }) - it("bundles the LICENSE (README links to it; repo URL is private)", () => { + it('bundles the LICENSE (README links to it; repo URL is private)', () => { // The README's license link points at the private repo, unreachable from // npmjs.org — so a copy must ride in the tarball. Guard the `files` entry. - const out = execFileSync("npm", ["pack", "--json", "--dry-run"], { + const out = execFileSync('npm', ['pack', '--json', '--dry-run'], { cwd: packageDir, - encoding: "utf8", - stdio: ["ignore", "pipe", "ignore"], - }); + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + }) const packed = ( JSON.parse(out) as Array<{ files: Array<{ path: string }> }> - )[0].files.map((f) => f.path); - expect(packed).toContain("LICENSE"); - }); -}); + )[0].files.map((f) => f.path) + expect(packed).toContain('LICENSE') + }) +}) diff --git a/languages/typescript/packages/auth/__tests__/cookies.test.ts b/languages/typescript/packages/auth/__tests__/cookies.test.ts index b4d39aeca..0c3230494 100644 --- a/languages/typescript/packages/auth/__tests__/cookies.test.ts +++ b/languages/typescript/packages/auth/__tests__/cookies.test.ts @@ -1,177 +1,177 @@ -import { describe, it, expect } from "vitest"; -import { cookieStore } from "../cookies.mjs"; +import { describe, expect, it } from 'vitest' +import { cookieStore } from '../cookies.mjs' function makeRequest(cookieHeader?: string): Request { - return new Request("https://example.com/", { + return new Request('https://example.com/', { headers: cookieHeader ? { cookie: cookieHeader } : {}, - }); + }) } function tokenJson(overrides: Record<string, unknown> = {}): string { return JSON.stringify({ - access_token: "test-jwt", - token_type: "Bearer", + access_token: 'test-jwt', + token_type: 'Bearer', expires_at: Math.floor(Date.now() / 1000) + 3600, refresh_token: null, region: null, client_id: null, device_instance_id: null, ...overrides, - }); + }) } function extractCookieValue(setCookie: string): string { // Set-Cookie format: "name=value; Path=/; ..." - const firstPair = setCookie.split(";")[0]; - return firstPair.slice(firstPair.indexOf("=") + 1); + const firstPair = setCookie.split(';')[0] + return firstPair.slice(firstPair.indexOf('=') + 1) } -describe("cookieStore.load", () => { - it("returns null when no cookie header is present", async () => { +describe('cookieStore.load', () => { + it('returns null when no cookie header is present', async () => { const store = cookieStore({ request: makeRequest(), responseHeaders: new Headers(), - }); - expect(await store.load()).toBeNull(); - }); + }) + expect(await store.load()).toBeNull() + }) it("returns null when the cookie name isn't set", async () => { const store = cookieStore({ - request: makeRequest("other=value"), + request: makeRequest('other=value'), responseHeaders: new Headers(), - }); - expect(await store.load()).toBeNull(); - }); + }) + expect(await store.load()).toBeNull() + }) - it("decodes the base64url-encoded value on the round trip", async () => { - const responseHeaders = new Headers(); - const save = cookieStore({ request: makeRequest(), responseHeaders }); - const json = tokenJson(); - await save.save(json); - const setCookie = responseHeaders.get("set-cookie")!; + it('decodes the base64url-encoded value on the round trip', async () => { + const responseHeaders = new Headers() + const save = cookieStore({ request: makeRequest(), responseHeaders }) + const json = tokenJson() + await save.save(json) + const setCookie = responseHeaders.get('set-cookie')! const load = cookieStore({ request: makeRequest(`cs_token=${extractCookieValue(setCookie)}`), responseHeaders: new Headers(), - }); - expect(await load.load()).toBe(json); - }); + }) + expect(await load.load()).toBe(json) + }) - it("ignores a corrupt base64url value (returns null)", async () => { + it('ignores a corrupt base64url value (returns null)', async () => { const store = cookieStore({ - request: makeRequest("cs_token=not-valid-base64!@#"), + request: makeRequest('cs_token=not-valid-base64!@#'), responseHeaders: new Headers(), - }); - expect(await store.load()).toBeNull(); - }); -}); - -describe("cookieStore.save", () => { - it("writes a Set-Cookie header on responseHeaders", async () => { - const responseHeaders = new Headers(); - const store = cookieStore({ request: makeRequest(), responseHeaders }); - await store.save(tokenJson()); - expect(responseHeaders.get("set-cookie")).toMatch(/^cs_token=/); - }); - - it("base64url-encodes the value (no quotes in the cookie)", async () => { - const responseHeaders = new Headers(); - const store = cookieStore({ request: makeRequest(), responseHeaders }); - await store.save(tokenJson()); - const setCookie = responseHeaders.get("set-cookie")!; + }) + expect(await store.load()).toBeNull() + }) +}) + +describe('cookieStore.save', () => { + it('writes a Set-Cookie header on responseHeaders', async () => { + const responseHeaders = new Headers() + const store = cookieStore({ request: makeRequest(), responseHeaders }) + await store.save(tokenJson()) + expect(responseHeaders.get('set-cookie')).toMatch(/^cs_token=/) + }) + + it('base64url-encodes the value (no quotes in the cookie)', async () => { + const responseHeaders = new Headers() + const store = cookieStore({ request: makeRequest(), responseHeaders }) + await store.save(tokenJson()) + const setCookie = responseHeaders.get('set-cookie')! // RFC 6265 token-char range: no `"`, `,`, `;`, or `\` in the cookie value. - const value = extractCookieValue(setCookie); - expect(value).toMatch(/^[A-Za-z0-9_\-]+$/); - }); + const value = extractCookieValue(setCookie) + expect(value).toMatch(/^[A-Za-z0-9_-]+$/) + }) - it("computes Max-Age from expires_at minus the safety margin", async () => { - const responseHeaders = new Headers(); + it('computes Max-Age from expires_at minus the safety margin', async () => { + const responseHeaders = new Headers() const store = cookieStore({ request: makeRequest(), responseHeaders, expirySafetyMarginSeconds: 30, - }); - const expiresIn = 3600; - const expiresAt = Math.floor(Date.now() / 1000) + expiresIn; - await store.save(tokenJson({ expires_at: expiresAt })); - const setCookie = responseHeaders.get("set-cookie")!; - const maxAgeMatch = setCookie.match(/Max-Age=(\d+)/); - expect(maxAgeMatch).not.toBeNull(); - const maxAge = Number(maxAgeMatch![1]); + }) + const expiresIn = 3600 + const expiresAt = Math.floor(Date.now() / 1000) + expiresIn + await store.save(tokenJson({ expires_at: expiresAt })) + const setCookie = responseHeaders.get('set-cookie')! + const maxAgeMatch = setCookie.match(/Max-Age=(\d+)/) + expect(maxAgeMatch).not.toBeNull() + const maxAge = Number(maxAgeMatch![1]) // Allow ±2s tolerance for the clock advancing during the test - expect(maxAge).toBeGreaterThanOrEqual(expiresIn - 30 - 2); - expect(maxAge).toBeLessThanOrEqual(expiresIn - 30); - }); + expect(maxAge).toBeGreaterThanOrEqual(expiresIn - 30 - 2) + expect(maxAge).toBeLessThanOrEqual(expiresIn - 30) + }) it("omits Max-Age when expires_at can't be parsed", async () => { - const responseHeaders = new Headers(); - const store = cookieStore({ request: makeRequest(), responseHeaders }); - await store.save("not valid json"); - const setCookie = responseHeaders.get("set-cookie")!; - expect(setCookie).not.toMatch(/Max-Age=/); - }); - - it("honours custom cookie attributes", async () => { - const responseHeaders = new Headers(); + const responseHeaders = new Headers() + const store = cookieStore({ request: makeRequest(), responseHeaders }) + await store.save('not valid json') + const setCookie = responseHeaders.get('set-cookie')! + expect(setCookie).not.toMatch(/Max-Age=/) + }) + + it('honours custom cookie attributes', async () => { + const responseHeaders = new Headers() const store = cookieStore({ request: makeRequest(), responseHeaders, - name: "custom_name", - path: "/api", - domain: "example.com", + name: 'custom_name', + path: '/api', + domain: 'example.com', secure: true, - sameSite: "Strict", - }); - await store.save(tokenJson()); - const setCookie = responseHeaders.get("set-cookie")!; - expect(setCookie).toMatch(/^custom_name=/); - expect(setCookie).toContain("Path=/api"); - expect(setCookie).toContain("Domain=example.com"); - expect(setCookie).toContain("Secure"); - expect(setCookie).toContain("SameSite=Strict"); - }); - - it("HttpOnly is on by default; can be disabled", async () => { - const onResponseHeaders = new Headers(); + sameSite: 'Strict', + }) + await store.save(tokenJson()) + const setCookie = responseHeaders.get('set-cookie')! + expect(setCookie).toMatch(/^custom_name=/) + expect(setCookie).toContain('Path=/api') + expect(setCookie).toContain('Domain=example.com') + expect(setCookie).toContain('Secure') + expect(setCookie).toContain('SameSite=Strict') + }) + + it('HttpOnly is on by default; can be disabled', async () => { + const onResponseHeaders = new Headers() const onStore = cookieStore({ request: makeRequest(), responseHeaders: onResponseHeaders, - }); - await onStore.save(tokenJson()); - expect(onResponseHeaders.get("set-cookie")).toContain("HttpOnly"); + }) + await onStore.save(tokenJson()) + expect(onResponseHeaders.get('set-cookie')).toContain('HttpOnly') - const offResponseHeaders = new Headers(); + const offResponseHeaders = new Headers() const offStore = cookieStore({ request: makeRequest(), responseHeaders: offResponseHeaders, httpOnly: false, - }); - await offStore.save(tokenJson()); - expect(offResponseHeaders.get("set-cookie")).not.toContain("HttpOnly"); - }); + }) + await offStore.save(tokenJson()) + expect(offResponseHeaders.get('set-cookie')).not.toContain('HttpOnly') + }) - it("rejects sameSite:None without secure (browsers drop the cookie)", () => { + it('rejects sameSite:None without secure (browsers drop the cookie)', () => { expect(() => cookieStore({ request: makeRequest(), responseHeaders: new Headers(), - sameSite: "None", + sameSite: 'None', secure: false, }), - ).toThrow(/sameSite.*None.*requires.*secure/i); - }); + ).toThrow(/sameSite.*None.*requires.*secure/i) + }) - it("allows sameSite:None when secure is set", async () => { - const responseHeaders = new Headers(); + it('allows sameSite:None when secure is set', async () => { + const responseHeaders = new Headers() const store = cookieStore({ request: makeRequest(), responseHeaders, - sameSite: "None", + sameSite: 'None', secure: true, - }); - await store.save(tokenJson()); - const setCookie = responseHeaders.get("set-cookie")!; - expect(setCookie).toContain("SameSite=None"); - expect(setCookie).toContain("Secure"); - }); -}); + }) + await store.save(tokenJson()) + const setCookie = responseHeaders.get('set-cookie')! + expect(setCookie).toContain('SameSite=None') + expect(setCookie).toContain('Secure') + }) +}) diff --git a/languages/typescript/packages/auth/__tests__/device-code-flow.test.ts b/languages/typescript/packages/auth/__tests__/device-code-flow.test.ts index 9dca48e65..b6c937583 100644 --- a/languages/typescript/packages/auth/__tests__/device-code-flow.test.ts +++ b/languages/typescript/packages/auth/__tests__/device-code-flow.test.ts @@ -1,123 +1,123 @@ -import { describe, it, expect, beforeEach, afterEach } from "vitest"; -import type { DeviceCodeResult } from "../index"; -import { MockCtsServer } from "./helpers/mock-cts-server"; +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import type { DeviceCodeResult } from '../index' +import { MockCtsServer } from './helpers/mock-cts-server' const { beginDeviceCodeFlow } = - require("../index.js") as typeof import("../index"); + require('../index.js') as typeof import('../index') // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- -let server: MockCtsServer; +let server: MockCtsServer async function startServer(): Promise<MockCtsServer> { - const s = await MockCtsServer.start(); - s.mockDeviceCodeEndpoint(); - return s; + const s = await MockCtsServer.start() + s.mockDeviceCodeEndpoint() + return s } // The production `beginDeviceCodeFlow` resolves its auth host from CS_CTS_HOST // (set in `beforeEach` below), so no test-only base-URL override is needed. async function beginFlow(): Promise<DeviceCodeResult> { - const r = await beginDeviceCodeFlow("ap-southeast-2.aws", "test-client"); + const r = await beginDeviceCodeFlow('ap-southeast-2.aws', 'test-client') if (r.failure) { - expect.unreachable(`beginDeviceCodeFlow failed: ${r.failure.type}`); + expect.unreachable(`beginDeviceCodeFlow failed: ${r.failure.type}`) } - return r.data; + return r.data } // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- -describe("device code flow (TypeScript / vitest)", () => { +describe('device code flow (TypeScript / vitest)', () => { // ---------- Error enrichment (no server needed) ---------- - it("attaches .type for INVALID_REGION", async () => { - const r = await beginDeviceCodeFlow("not-a-region", "test-client"); - expect(r.failure?.error).toBeInstanceOf(Error); - expect(r.failure?.type).toBe("INVALID_REGION"); - }); + it('attaches .type for INVALID_REGION', async () => { + const r = await beginDeviceCodeFlow('not-a-region', 'test-client') + expect(r.failure?.error).toBeInstanceOf(Error) + expect(r.failure?.type).toBe('INVALID_REGION') + }) // ---------- Tests that need the mock server ---------- - describe("with mock server", () => { - let savedHost: string | undefined; + describe('with mock server', () => { + let savedHost: string | undefined beforeEach(async () => { - server = await startServer(); - savedHost = process.env.CS_CTS_HOST; - process.env.CS_CTS_HOST = server.baseUrl; - }); + server = await startServer() + savedHost = process.env.CS_CTS_HOST + process.env.CS_CTS_HOST = server.baseUrl + }) afterEach(async () => { if (savedHost === undefined) { - delete process.env.CS_CTS_HOST; + delete process.env.CS_CTS_HOST } else { - process.env.CS_CTS_HOST = savedHost; + process.env.CS_CTS_HOST = savedHost } - await server.close(); - }); + await server.close() + }) - it("exposes getter fields on DeviceCodeResult", async () => { - const result = await beginFlow(); + it('exposes getter fields on DeviceCodeResult', async () => { + const result = await beginFlow() - expect(result.userCode).toBe("ABCD-EFGH"); - expect(result.verificationUri).toBe("http://example.com/activate"); + expect(result.userCode).toBe('ABCD-EFGH') + expect(result.verificationUri).toBe('http://example.com/activate') expect(result.verificationUriComplete).toBe( - "http://example.com/activate?user_code=ABCD-EFGH", - ); - expect(result.expiresIn).toBe(900); - }); - - it("pollForToken resolves with auth metadata on success", async () => { - server.mockTokenEndpoint(); - const result = await beginFlow(); - const pr = await result.pollForToken(); + 'http://example.com/activate?user_code=ABCD-EFGH', + ) + expect(result.expiresIn).toBe(900) + }) + + it('pollForToken resolves with auth metadata on success', async () => { + server.mockTokenEndpoint() + const result = await beginFlow() + const pr = await result.pollForToken() if (pr.failure) { - expect.unreachable(`pollForToken failed: ${pr.failure.type}`); + expect.unreachable(`pollForToken failed: ${pr.failure.type}`) } - const auth = pr.data; + const auth = pr.data - expect(auth.expiresAt).toBeGreaterThan(0); - expect(auth.expiresIn).toBeGreaterThanOrEqual(3598); - expect(auth.expiresIn).toBeLessThanOrEqual(3600); - }); + expect(auth.expiresAt).toBeGreaterThan(0) + expect(auth.expiresIn).toBeGreaterThanOrEqual(3598) + expect(auth.expiresIn).toBeLessThanOrEqual(3600) + }) - it("pollForToken fails on second call (consumed handle)", async () => { - server.mockTokenEndpoint(); - const result = await beginFlow(); + it('pollForToken fails on second call (consumed handle)', async () => { + server.mockTokenEndpoint() + const result = await beginFlow() // First call succeeds — consumes the handle - const first = await result.pollForToken(); + const first = await result.pollForToken() if (first.failure) { - expect.unreachable(`first pollForToken failed: ${first.failure.type}`); + expect.unreachable(`first pollForToken failed: ${first.failure.type}`) } // Second call should surface a failure - const second = await result.pollForToken(); - expect(second.failure?.error).toBeInstanceOf(Error); - expect(second.failure?.type).toBe("ALREADY_CONSUMED"); - expect(second.failure?.error.message).toMatch(/already consumed/i); - }); - - it("pollForToken fails with enriched ACCESS_DENIED", async () => { - server.mockTokenEndpointError("access_denied"); - const result = await beginFlow(); - - const pr = await result.pollForToken(); - expect(pr.failure?.error).toBeInstanceOf(Error); - expect(pr.failure?.type).toBe("ACCESS_DENIED"); - }); - - it("pollForToken fails with enriched EXPIRED_TOKEN", async () => { - server.mockTokenEndpointError("expired_token"); - const result = await beginFlow(); - - const pr = await result.pollForToken(); - expect(pr.failure?.error).toBeInstanceOf(Error); - expect(pr.failure?.type).toBe("EXPIRED_TOKEN"); - }); - }); -}); + const second = await result.pollForToken() + expect(second.failure?.error).toBeInstanceOf(Error) + expect(second.failure?.type).toBe('ALREADY_CONSUMED') + expect(second.failure?.error.message).toMatch(/already consumed/i) + }) + + it('pollForToken fails with enriched ACCESS_DENIED', async () => { + server.mockTokenEndpointError('access_denied') + const result = await beginFlow() + + const pr = await result.pollForToken() + expect(pr.failure?.error).toBeInstanceOf(Error) + expect(pr.failure?.type).toBe('ACCESS_DENIED') + }) + + it('pollForToken fails with enriched EXPIRED_TOKEN', async () => { + server.mockTokenEndpointError('expired_token') + const result = await beginFlow() + + const pr = await result.pollForToken() + expect(pr.failure?.error).toBeInstanceOf(Error) + expect(pr.failure?.type).toBe('EXPIRED_TOKEN') + }) + }) +}) diff --git a/languages/typescript/packages/auth/__tests__/factory-result.test.ts b/languages/typescript/packages/auth/__tests__/factory-result.test.ts index c78112539..b1efcc600 100644 --- a/languages/typescript/packages/auth/__tests__/factory-result.test.ts +++ b/languages/typescript/packages/auth/__tests__/factory-result.test.ts @@ -1,7 +1,7 @@ -import { describe, it, expect, beforeEach, afterEach } from "vitest"; -import { mkdtempSync, rmSync } from "fs"; -import { join } from "path"; -import { tmpdir } from "os"; +import { mkdtempSync, rmSync } from 'fs' +import { tmpdir } from 'os' +import { join } from 'path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' // Runtime coverage for the sync strategy factories through index.js. napi // defines class statics as non-writable, so a Result-wrapping bug there is @@ -14,107 +14,107 @@ const { DeviceSessionStrategy, OAuthStrategy, beginDeviceCodeFlow, -} = require("../index.js") as typeof import("../index"); +} = require('../index.js') as typeof import('../index') -const VALID_CRN = "crn:ap-southeast-2.aws:ZVATKW3VHMFG27DY"; +const VALID_CRN = 'crn:ap-southeast-2.aws:ZVATKW3VHMFG27DY' // Well-formed key (`CSAK<key-id>.<secret>`) — factories only parse the shape; // no network call happens until getToken(). -const VALID_KEY = "CSAKtestKeyId.testKeySecret"; +const VALID_KEY = 'CSAKtestKeyId.testKeySecret' const SAVED_ENV_KEYS = [ - "CS_CLIENT_ACCESS_KEY", - "CS_WORKSPACE_CRN", - "CS_CONFIG_PATH", -] as const; -let savedEnv: Partial<Record<(typeof SAVED_ENV_KEYS)[number], string>>; -let configDir: string; + 'CS_CLIENT_ACCESS_KEY', + 'CS_WORKSPACE_CRN', + 'CS_CONFIG_PATH', +] as const +let savedEnv: Partial<Record<(typeof SAVED_ENV_KEYS)[number], string>> +let configDir: string beforeEach(() => { - savedEnv = {}; + savedEnv = {} for (const key of SAVED_ENV_KEYS) { - savedEnv[key] = process.env[key]; - delete process.env[key]; + savedEnv[key] = process.env[key] + delete process.env[key] } // Point the profile store at an empty temp dir so ambient ~/.cipherstash // state can't leak into detection. - configDir = mkdtempSync(join(tmpdir(), "cs-auth-test-")); - process.env.CS_CONFIG_PATH = configDir; -}); + configDir = mkdtempSync(join(tmpdir(), 'cs-auth-test-')) + process.env.CS_CONFIG_PATH = configDir +}) afterEach(() => { - rmSync(configDir, { recursive: true, force: true }); + rmSync(configDir, { recursive: true, force: true }) for (const key of SAVED_ENV_KEYS) { if (savedEnv[key] === undefined) { - delete process.env[key]; + delete process.env[key] } else { - process.env[key] = savedEnv[key]; + process.env[key] = savedEnv[key] } } -}); +}) -describe("AccessKeyStrategy.create", () => { - it("returns a failure for a malformed CRN", () => { - const r = AccessKeyStrategy.create("not-a-crn", VALID_KEY); - expect(r.failure?.type).toBe("INVALID_CRN"); - expect(r.failure?.error).toBeInstanceOf(Error); +describe('AccessKeyStrategy.create', () => { + it('returns a failure for a malformed CRN', () => { + const r = AccessKeyStrategy.create('not-a-crn', VALID_KEY) + expect(r.failure?.type).toBe('INVALID_CRN') + expect(r.failure?.error).toBeInstanceOf(Error) // The FFI sentinel must be stripped from the surfaced message. - expect(r.failure?.error.message).not.toContain("__CS_FAIL__"); - }); + expect(r.failure?.error.message).not.toContain('__CS_FAIL__') + }) - it("returns a failure for a malformed access key", () => { - const r = AccessKeyStrategy.create(VALID_CRN, "not-a-key"); - expect(r.failure?.type).toBe("INVALID_ACCESS_KEY"); - }); + it('returns a failure for a malformed access key', () => { + const r = AccessKeyStrategy.create(VALID_CRN, 'not-a-key') + expect(r.failure?.type).toBe('INVALID_ACCESS_KEY') + }) - it("returns { data } wrapping a usable strategy on success", () => { - const r = AccessKeyStrategy.create(VALID_CRN, VALID_KEY); + it('returns { data } wrapping a usable strategy on success', () => { + const r = AccessKeyStrategy.create(VALID_CRN, VALID_KEY) if (r.failure) { - expect.unreachable(`create failed: ${r.failure.type}`); + expect.unreachable(`create failed: ${r.failure.type}`) } // A bare (unwrapped) native instance would have no `data` key — this // assertion is what distinguishes a wrapped Result from the native value. - expect(typeof r.data.getToken).toBe("function"); - }); -}); + expect(typeof r.data.getToken).toBe('function') + }) +}) -describe("AutoStrategy.detect", () => { - it("returns a NOT_AUTHENTICATED failure when no credentials exist", () => { - const r = AutoStrategy.detect(); - expect(r.failure?.type).toBe("NOT_AUTHENTICATED"); - expect(r.failure?.help).toBeTruthy(); - }); +describe('AutoStrategy.detect', () => { + it('returns a NOT_AUTHENTICATED failure when no credentials exist', () => { + const r = AutoStrategy.detect() + expect(r.failure?.type).toBe('NOT_AUTHENTICATED') + expect(r.failure?.help).toBeTruthy() + }) - it("returns a MISSING_WORKSPACE_CRN failure for an access key without a CRN", () => { - const r = AutoStrategy.detect({ accessKey: VALID_KEY }); - expect(r.failure?.type).toBe("MISSING_WORKSPACE_CRN"); - }); + it('returns a MISSING_WORKSPACE_CRN failure for an access key without a CRN', () => { + const r = AutoStrategy.detect({ accessKey: VALID_KEY }) + expect(r.failure?.type).toBe('MISSING_WORKSPACE_CRN') + }) - it("returns { data } wrapping a usable strategy for explicit options", () => { + it('returns { data } wrapping a usable strategy for explicit options', () => { const r = AutoStrategy.detect({ accessKey: VALID_KEY, workspaceCrn: VALID_CRN, - }); + }) if (r.failure) { - expect.unreachable(`detect failed: ${r.failure.type}`); + expect.unreachable(`detect failed: ${r.failure.type}`) } - expect(typeof r.data.getToken).toBe("function"); - }); -}); + expect(typeof r.data.getToken).toBe('function') + }) +}) -describe("DeviceSessionStrategy.fromProfile", () => { - it("returns a STORE_ERROR failure when the profile store is empty", () => { - const r = DeviceSessionStrategy.fromProfile(); - expect(r.failure?.type).toBe("STORE_ERROR"); - expect(r.failure?.error).toBeInstanceOf(Error); - }); +describe('DeviceSessionStrategy.fromProfile', () => { + it('returns a STORE_ERROR failure when the profile store is empty', () => { + const r = DeviceSessionStrategy.fromProfile() + expect(r.failure?.type).toBe('STORE_ERROR') + expect(r.failure?.error).toBeInstanceOf(Error) + }) - it("is what the deprecated OAuthStrategy alias points at", () => { - expect(OAuthStrategy).toBe(DeviceSessionStrategy); - }); -}); + it('is what the deprecated OAuthStrategy alias points at', () => { + expect(OAuthStrategy).toBe(DeviceSessionStrategy) + }) +}) -describe("toFailure re-throw contract", () => { - it("propagates a non-sentinel error instead of converting it to a failure", async () => { +describe('toFailure re-throw contract', () => { + it('propagates a non-sentinel error instead of converting it to a failure', async () => { // The migration's safety premise: only sentineled domain errors become // `{ failure }`; anything else (a genuine bug/panic) must keep propagating // as an error. napi's argument coercion throws a plain, sentinel-free @@ -122,7 +122,7 @@ describe("toFailure re-throw contract", () => { // rejection, not a resolved Result. A `toFailure` that swallowed // non-sentinel errors into failures would resolve here and fail the test. await expect( - beginDeviceCodeFlow(123 as unknown as string, "cli"), - ).rejects.toThrow(/Failed to convert/); - }); -}); + beginDeviceCodeFlow(123 as unknown as string, 'cli'), + ).rejects.toThrow(/Failed to convert/) + }) +}) diff --git a/languages/typescript/packages/auth/__tests__/helpers/mock-cts-server.ts b/languages/typescript/packages/auth/__tests__/helpers/mock-cts-server.ts index 2151ad49b..0211e9dc1 100644 --- a/languages/typescript/packages/auth/__tests__/helpers/mock-cts-server.ts +++ b/languages/typescript/packages/auth/__tests__/helpers/mock-cts-server.ts @@ -8,112 +8,112 @@ // Each `mock*` method registers a one-shot-ish handler for a route; the server // keeps the last handler registered per route. `clearMocks()` drops them all. -import { type Server, createServer } from "node:http"; -import { mintJwt } from "./test-fixtures"; +import { createServer, type Server } from 'node:http' +import { mintJwt } from './test-fixtures' -type Handler = (body: string) => { status: number; json: unknown }; +type Handler = (body: string) => { status: number; json: unknown } export class MockCtsServer { - #server: Server; - #routes = new Map<string, Handler>(); - #baseUrl = ""; + #server: Server + #routes = new Map<string, Handler>() + #baseUrl = '' private constructor(server: Server) { - this.#server = server; + this.#server = server } /** Start a mock server on an ephemeral port and resolve once it's listening. */ static async start(): Promise<MockCtsServer> { const mock = new MockCtsServer( createServer((req, res) => { - const handler = mock.#routes.get(`${req.method} ${req.url}`); + const handler = mock.#routes.get(`${req.method} ${req.url}`) if (!handler) { - res.writeHead(404, { "content-type": "application/json" }); - res.end(JSON.stringify({ error: "no mock registered" })); - return; + res.writeHead(404, { 'content-type': 'application/json' }) + res.end(JSON.stringify({ error: 'no mock registered' })) + return } - let body = ""; - req.on("data", (chunk) => { - body += chunk; - }); - req.on("end", () => { - const { status, json } = handler(body); - res.writeHead(status, { "content-type": "application/json" }); - res.end(JSON.stringify(json)); - }); + let body = '' + req.on('data', (chunk) => { + body += chunk + }) + req.on('end', () => { + const { status, json } = handler(body) + res.writeHead(status, { 'content-type': 'application/json' }) + res.end(JSON.stringify(json)) + }) }), - ); + ) await new Promise<void>((resolve, reject) => { // Surface bind/listen failures as a rejected promise instead of hanging // the suite until a timeout. Drop the listener once we're listening so it // doesn't intercept later runtime errors. - const onError = (err: Error) => reject(err); - mock.#server.once("error", onError); - mock.#server.listen(0, "127.0.0.1", () => { - mock.#server.removeListener("error", onError); - resolve(); - }); - }); - const addr = mock.#server.address(); - if (addr === null || typeof addr === "string") { - throw new Error("mock server did not bind a TCP port"); + const onError = (err: Error) => reject(err) + mock.#server.once('error', onError) + mock.#server.listen(0, '127.0.0.1', () => { + mock.#server.removeListener('error', onError) + resolve() + }) + }) + const addr = mock.#server.address() + if (addr === null || typeof addr === 'string') { + throw new Error('mock server did not bind a TCP port') } - mock.#baseUrl = `http://127.0.0.1:${addr.port}`; - return mock; + mock.#baseUrl = `http://127.0.0.1:${addr.port}` + return mock } /** The base URL of the running mock server (e.g. `http://127.0.0.1:12345`). */ get baseUrl(): string { - return this.#baseUrl; + return this.#baseUrl } /** Stop the server and free its port. Call from `afterEach`. */ async close(): Promise<void> { await new Promise<void>((resolve, reject) => { - this.#server.close((err) => (err ? reject(err) : resolve())); - }); + this.#server.close((err) => (err ? reject(err) : resolve())) + }) } #on(method: string, path: string, handler: Handler): void { - this.#routes.set(`${method} ${path}`, handler); + this.#routes.set(`${method} ${path}`, handler) } // --- Device-code flow --- mockDeviceCodeEndpoint(): void { - this.#on("POST", "/oauth/device/code", () => ({ + this.#on('POST', '/oauth/device/code', () => ({ status: 200, json: { - device_code: "test_device_code", - user_code: "ABCD-EFGH", - verification_uri: "http://example.com/activate", + device_code: 'test_device_code', + user_code: 'ABCD-EFGH', + verification_uri: 'http://example.com/activate', verification_uri_complete: - "http://example.com/activate?user_code=ABCD-EFGH", + 'http://example.com/activate?user_code=ABCD-EFGH', expires_in: 900, }, - })); + })) } mockTokenEndpoint(): void { - this.#on("POST", "/oauth/device/token", () => ({ + this.#on('POST', '/oauth/device/token', () => ({ status: 200, json: { access_token: mintJwt(), - token_type: "Bearer", + token_type: 'Bearer', expires_in: 3600, }, - })); + })) } mockTokenEndpointError(code: string): void { - this.#on("POST", "/oauth/device/token", () => ({ + this.#on('POST', '/oauth/device/token', () => ({ status: 400, json: { error: code, error_description: `${code} occurred`, }, - })); + })) } // --- OIDC federation --- @@ -126,16 +126,16 @@ export class MockCtsServer { * envelope stay consistent. */ mockAuthorizeEndpoint(expiry = 3600): void { - this.#on("POST", "/api/authorise", () => { - const exp = Math.floor(Date.now() / 1000) + expiry; + this.#on('POST', '/api/authorise', () => { + const exp = Math.floor(Date.now() / 1000) + expiry return { status: 200, json: { accessToken: mintJwt({ exp }), expiry: exp, }, - }; - }); + } + }) } /** @@ -145,48 +145,48 @@ export class MockCtsServer { * `WORKSPACE_MISMATCH`. */ mockAuthorizeEndpointWithWorkspace(workspace: string, expiry = 3600): void { - this.#on("POST", "/api/authorise", () => { - const exp = Math.floor(Date.now() / 1000) + expiry; + this.#on('POST', '/api/authorise', () => { + const exp = Math.floor(Date.now() / 1000) + expiry return { status: 200, json: { accessToken: mintJwt({ exp, workspace }), expiry: exp, }, - }; - }); + } + }) } mockAuthorizeEndpointError(): void { - this.#on("POST", "/api/authorise", () => ({ + this.#on('POST', '/api/authorise', () => ({ status: 500, - json: { error: "federation failed" }, - })); + json: { error: 'federation failed' }, + })) } // --- ZeroKMS create-client (device provisioning) --- mockCreateClientEndpoint(): void { - this.#on("POST", "/create-client", () => ({ + this.#on('POST', '/create-client', () => ({ status: 200, json: { - id: "00000000-0000-0000-0000-000000000001", - dataset_id: "00000000-0000-0000-0000-000000000099", - name: "test-device", - description: "test-device", - client_key: "dGVzdC1rZXktbWF0ZXJpYWw=", + id: '00000000-0000-0000-0000-000000000001', + dataset_id: '00000000-0000-0000-0000-000000000099', + name: 'test-device', + description: 'test-device', + client_key: 'dGVzdC1rZXktbWF0ZXJpYWw=', }, - })); + })) } mockCreateClientConflict(): void { - this.#on("POST", "/create-client", () => ({ + this.#on('POST', '/create-client', () => ({ status: 409, - json: { error: "conflict" }, - })); + json: { error: 'conflict' }, + })) } clearMocks(): void { - this.#routes.clear(); + this.#routes.clear() } } diff --git a/languages/typescript/packages/auth/__tests__/helpers/test-fixtures.ts b/languages/typescript/packages/auth/__tests__/helpers/test-fixtures.ts index a6510671e..5cfe8d313 100644 --- a/languages/typescript/packages/auth/__tests__/helpers/test-fixtures.ts +++ b/languages/typescript/packages/auth/__tests__/helpers/test-fixtures.ts @@ -4,13 +4,13 @@ // payload segment and deserialises it — so a JWT only needs three segments and a // well-formed payload, hence zero crypto deps. -import { mkdirSync, writeFileSync } from "node:fs"; -import { join } from "node:path"; +import { mkdirSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' -export const WORKSPACE_ID = "ZVATKW3VHMFG27DY"; +export const WORKSPACE_ID = 'ZVATKW3VHMFG27DY' function base64url(value: unknown): string { - return Buffer.from(JSON.stringify(value)).toString("base64url"); + return Buffer.from(JSON.stringify(value)).toString('base64url') } /** @@ -20,20 +20,20 @@ function base64url(value: unknown): string { * signature segments just have to be present for the three-segment check. */ export function mintJwt(claims: Record<string, unknown> = {}): string { - const now = Math.floor(Date.now() / 1000); - const header = base64url({ alg: "HS256", typ: "JWT" }); + const now = Math.floor(Date.now() / 1000) + const header = base64url({ alg: 'HS256', typ: 'JWT' }) const payload = base64url({ - iss: "https://cts.example.com/", - sub: "CS|test-user", - aud: "test-audience", + iss: 'https://cts.example.com/', + sub: 'CS|test-user', + aud: 'test-audience', iat: now, exp: now + 3600, workspace: WORKSPACE_ID, - org_id: "org_test_default", - scope: "", + org_id: 'org_test_default', + scope: '', ...claims, - }); - return `${header}.${payload}.sig`; + }) + return `${header}.${payload}.sig` } /** @@ -51,20 +51,20 @@ export function saveTestToken( profileDir: string, zerokmsBaseUrl: string, ): void { - const now = Math.floor(Date.now() / 1000); + const now = Math.floor(Date.now() / 1000) const jwt = mintJwt({ - aud: "legacy-aud-value", + aud: 'legacy-aud-value', services: { zerokms: zerokmsBaseUrl }, - }); + }) const tokenJson = { access_token: jwt, - token_type: "Bearer", + token_type: 'Bearer', expires_at: now + 3600, - }; - const wsDir = join(profileDir, "workspaces", WORKSPACE_ID); - mkdirSync(wsDir, { recursive: true }); - writeFileSync(join(profileDir, "current_workspace"), WORKSPACE_ID); - writeFileSync(join(wsDir, "auth.json"), JSON.stringify(tokenJson), { + } + const wsDir = join(profileDir, 'workspaces', WORKSPACE_ID) + mkdirSync(wsDir, { recursive: true }) + writeFileSync(join(profileDir, 'current_workspace'), WORKSPACE_ID) + writeFileSync(join(wsDir, 'auth.json'), JSON.stringify(tokenJson), { mode: 0o600, - }); + }) } diff --git a/languages/typescript/packages/auth/__tests__/next.test.ts b/languages/typescript/packages/auth/__tests__/next.test.ts index ad6e617d9..861926225 100644 --- a/languages/typescript/packages/auth/__tests__/next.test.ts +++ b/languages/typescript/packages/auth/__tests__/next.test.ts @@ -1,14 +1,14 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; +import { beforeEach, describe, expect, it, vi } from 'vitest' import { + CS_TOKEN_HEADER, + csAuthHeader, csFederate, csFederationMiddleware, - csAuthHeader, csSanitizeHeaders, csTokenCookieName, - encodeTokenHeader, decodeTokenHeader, - CS_TOKEN_HEADER, -} from "../next.mjs"; + encodeTokenHeader, +} from '../next.mjs' // Mock the wasm strategy so these tests exercise the adapter's OWN wiring // (cookie persistence, header encode/decode, warmed-token reads) deterministically @@ -19,9 +19,9 @@ const { getToken, free, create } = vi.hoisted(() => ({ getToken: vi.fn(), free: vi.fn(), create: vi.fn(), -})); +})) -vi.mock("../wasm-inline.mjs", () => ({ +vi.mock('../wasm-inline.mjs', () => ({ OidcFederationStrategy: { create: ( crn: string, @@ -29,48 +29,48 @@ vi.mock("../wasm-inline.mjs", () => ({ options: { store?: { load(): unknown; save(json: string): unknown } }, ) => create(crn, getJwt, options), }, -})); +})) -const WORKSPACE_ID = "ZVATKW3VHMFG27DY"; -const WORKSPACE_CRN = `crn:ap-southeast-2.aws:${WORKSPACE_ID}`; +const WORKSPACE_ID = 'ZVATKW3VHMFG27DY' +const WORKSPACE_CRN = `crn:ap-southeast-2.aws:${WORKSPACE_ID}` function tokenResult(overrides: Record<string, unknown> = {}) { return { - token: "header.payload.signature", + token: 'header.payload.signature', subject: `CS|${WORKSPACE_ID}`, workspaceId: WORKSPACE_ID, - issuer: "https://cts.example.com", - services: { zerokms: "https://zerokms.example.com" }, + issuer: 'https://cts.example.com', + services: { zerokms: 'https://zerokms.example.com' }, ...overrides, - }; + } } function requestWith(cookie?: string): Request { - return new Request("https://example.com/", { + return new Request('https://example.com/', { headers: cookie ? { cookie } : {}, - }); + }) } /** A request carrying a client-forged warmed-token header (the attacker case). */ function requestWithForgedHeader( headerName = CS_TOKEN_HEADER, overrides: Record<string, unknown> = { - token: "forged", - services: { zerokms: "https://attacker.example.com" }, + token: 'forged', + services: { zerokms: 'https://attacker.example.com' }, }, ): Request { - return new Request("https://example.com/", { + return new Request('https://example.com/', { headers: { - "x-unrelated": "keep-me", + 'x-unrelated': 'keep-me', [headerName]: encodeTokenHeader(tokenResult(overrides)), }, - }); + }) } beforeEach(() => { - getToken.mockReset(); - free.mockReset(); - create.mockReset(); + getToken.mockReset() + free.mockReset() + create.mockReset() // Default fake strategy: writes the token to the store (so cookie wiring is // exercised) and returns a Result-wrapped TokenResult. Both `create()` and // `getToken()` return `@byteslice/result` Results (`{ data }` on success), @@ -84,74 +84,74 @@ beforeEach(() => { getToken.mockImplementation(async () => { await options.store?.save( JSON.stringify({ - access_token: "header.payload.signature", + access_token: 'header.payload.signature', expires_at: Math.floor(Date.now() / 1000) + 3600, }), - ); - return { data: tokenResult() }; - }); - return { data: { getToken, free } }; + ) + return { data: tokenResult() } + }) + return { data: { getToken, free } } }, - ); -}); - -describe("csTokenCookieName", () => { - it("is per-workspace", () => { - expect(csTokenCookieName(WORKSPACE_ID)).toBe(`cs_token_${WORKSPACE_ID}`); - }); -}); - -describe("encode/decodeTokenHeader", () => { - it("round-trips a TokenResult through the opaque header payload", () => { - const r = tokenResult(); - const decoded = decodeTokenHeader(encodeTokenHeader(r)); - expect(decoded).toEqual(r); - }); - - it("produces a header-safe value (no base64 +/=/ chars)", () => { - const v = encodeTokenHeader(tokenResult()); - expect(v).not.toMatch(/[+/=]/); - }); -}); - -describe("csFederate", () => { - it("builds the strategy with the cookie-backed store and persists the token", async () => { - const responseHeaders = new Headers(); + ) +}) + +describe('csTokenCookieName', () => { + it('is per-workspace', () => { + expect(csTokenCookieName(WORKSPACE_ID)).toBe(`cs_token_${WORKSPACE_ID}`) + }) +}) + +describe('encode/decodeTokenHeader', () => { + it('round-trips a TokenResult through the opaque header payload', () => { + const r = tokenResult() + const decoded = decodeTokenHeader(encodeTokenHeader(r)) + expect(decoded).toEqual(r) + }) + + it('produces a header-safe value (no base64 +/=/ chars)', () => { + const v = encodeTokenHeader(tokenResult()) + expect(v).not.toMatch(/[+/=]/) + }) +}) + +describe('csFederate', () => { + it('builds the strategy with the cookie-backed store and persists the token', async () => { + const responseHeaders = new Headers() const result = await csFederate({ request: requestWith(), responseHeaders, workspaceCrn: WORKSPACE_CRN, - getJwt: () => "jwt", - baseUrl: "https://cts.example.com", + getJwt: () => 'jwt', + baseUrl: 'https://cts.example.com', cookieName: csTokenCookieName(WORKSPACE_ID), - }); + }) - expect(result.workspaceId).toBe(WORKSPACE_ID); + expect(result.workspaceId).toBe(WORKSPACE_ID) // baseUrl threaded through to the strategy. expect(create).toHaveBeenCalledWith(WORKSPACE_CRN, expect.any(Function), { store: expect.anything(), - baseUrl: "https://cts.example.com", - }); + baseUrl: 'https://cts.example.com', + }) // The cookie was written under the per-workspace name. - const setCookie = responseHeaders.get("set-cookie"); - expect(setCookie).toMatch(new RegExp(`^cs_token_${WORKSPACE_ID}=`)); + const setCookie = responseHeaders.get('set-cookie') + expect(setCookie).toMatch(new RegExp(`^cs_token_${WORKSPACE_ID}=`)) // wasm resources released. - expect(free).toHaveBeenCalledOnce(); - }); + expect(free).toHaveBeenCalledOnce() + }) - it("defaults the cookie name to cs_token_<workspaceId> from the CRN when omitted", async () => { - const responseHeaders = new Headers(); + it('defaults the cookie name to cs_token_<workspaceId> from the CRN when omitted', async () => { + const responseHeaders = new Headers() await csFederate({ request: requestWith(), responseHeaders, workspaceCrn: WORKSPACE_CRN, - getJwt: () => "jwt", + getJwt: () => 'jwt', // cookieName intentionally omitted — must NOT collapse to `cs_token`. - }); - expect(responseHeaders.get("set-cookie")).toMatch( + }) + expect(responseHeaders.get('set-cookie')).toMatch( new RegExp(`^cs_token_${WORKSPACE_ID}=`), - ); - }); + ) + }) it("throws the failure's error and still frees the strategy (finally path)", async () => { // Override the default fake so getToken resolves to a `{ failure }` Result — @@ -160,96 +160,96 @@ describe("csFederate", () => { create.mockImplementation(() => { getToken.mockResolvedValue({ failure: { - type: "SERVER_ERROR", - error: new Error("federation failed"), + type: 'SERVER_ERROR', + error: new Error('federation failed'), }, - }); - return { data: { getToken, free } }; - }); + }) + return { data: { getToken, free } } + }) await expect( csFederate({ request: requestWith(), responseHeaders: new Headers(), workspaceCrn: WORKSPACE_CRN, - getJwt: () => "jwt", + getJwt: () => 'jwt', }), - ).rejects.toThrow("federation failed"); + ).rejects.toThrow('federation failed') // free() still ran despite the throw. - expect(free).toHaveBeenCalledOnce(); - }); + expect(free).toHaveBeenCalledOnce() + }) - it("propagates a getToken rejection and still frees the strategy (wasm panic path)", async () => { + it('propagates a getToken rejection and still frees the strategy (wasm panic path)', async () => { // Distinct from the `{ failure }` domain-error path above: a genuine wasm // panic (unbranded error, or calling getToken after free) rejects the // promise rather than resolving to `{ failure }` — `settleGetToken` re-throws // it. The `await` must propagate the rejection while the finally still frees. create.mockImplementation(() => { - getToken.mockRejectedValue(new Error("null pointer passed to rust")); - return { data: { getToken, free } }; - }); + getToken.mockRejectedValue(new Error('null pointer passed to rust')) + return { data: { getToken, free } } + }) await expect( csFederate({ request: requestWith(), responseHeaders: new Headers(), workspaceCrn: WORKSPACE_CRN, - getJwt: () => "jwt", + getJwt: () => 'jwt', }), - ).rejects.toThrow("null pointer passed to rust"); - expect(free).toHaveBeenCalledOnce(); - }); + ).rejects.toThrow('null pointer passed to rust') + expect(free).toHaveBeenCalledOnce() + }) it("throws the failure's error when strategy creation fails (no free)", async () => { // `create()` itself can fail (e.g. INVALID_CRN) — it returns `{ failure }` // before any strategy is allocated, so csFederate throws without calling // free() (there's nothing to release). create.mockImplementation(() => ({ - failure: { type: "INVALID_CRN", error: new Error("bad crn") }, - })); + failure: { type: 'INVALID_CRN', error: new Error('bad crn') }, + })) await expect( csFederate({ request: requestWith(), responseHeaders: new Headers(), workspaceCrn: WORKSPACE_CRN, - getJwt: () => "jwt", + getJwt: () => 'jwt', }), - ).rejects.toThrow("bad crn"); - expect(free).not.toHaveBeenCalled(); - }); -}); - -describe("csFederationMiddleware", () => { - it("returns the warmed token header alongside the result", async () => { - const responseHeaders = new Headers(); + ).rejects.toThrow('bad crn') + expect(free).not.toHaveBeenCalled() + }) +}) + +describe('csFederationMiddleware', () => { + it('returns the warmed token header alongside the result', async () => { + const responseHeaders = new Headers() const { result, headerName, headerValue } = await csFederationMiddleware({ request: requestWith(), responseHeaders, workspaceCrn: WORKSPACE_CRN, - getJwt: () => "jwt", + getJwt: () => 'jwt', cookieName: csTokenCookieName(WORKSPACE_ID), - }); + }) - expect(headerName).toBe(CS_TOKEN_HEADER); - expect(decodeTokenHeader(headerValue)).toEqual(result); + expect(headerName).toBe(CS_TOKEN_HEADER) + expect(decodeTokenHeader(headerValue)).toEqual(result) // Both caches populated: cookie (cross-request) + header (same-request). - expect(responseHeaders.get("set-cookie")).toBeTruthy(); - }); + expect(responseHeaders.get('set-cookie')).toBeTruthy() + }) - it("honours a custom header name", async () => { + it('honours a custom header name', async () => { const { headerName } = await csFederationMiddleware({ request: requestWith(), responseHeaders: new Headers(), workspaceCrn: WORKSPACE_CRN, - getJwt: () => "jwt", + getJwt: () => 'jwt', cookieName: csTokenCookieName(WORKSPACE_ID), - headerName: "x-warm", - }); - expect(headerName).toBe("x-warm"); - }); + headerName: 'x-warm', + }) + expect(headerName).toBe('x-warm') + }) - it("returns requestHeaders with the forged inbound header replaced by the minted one", async () => { + it('returns requestHeaders with the forged inbound header replaced by the minted one', async () => { // The forgery invariant lives in the library: a client-supplied // `x-cs-cts-token` must never survive into the render, even though the // freshly minted `set()` would overwrite it anyway on this (success) path. @@ -257,138 +257,138 @@ describe("csFederationMiddleware", () => { request: requestWithForgedHeader(), responseHeaders: new Headers(), workspaceCrn: WORKSPACE_CRN, - getJwt: () => "jwt", + getJwt: () => 'jwt', cookieName: csTokenCookieName(WORKSPACE_ID), - }); + }) // Downstream reads the real token, not the attacker's. - const warmed = csAuthHeader(requestHeaders); - expect((await warmed?.getToken())?.data).toEqual(result); + const warmed = csAuthHeader(requestHeaders) + expect((await warmed?.getToken())?.data).toEqual(result) expect((await warmed?.getToken())?.data?.services.zerokms).toBe( - "https://zerokms.example.com", - ); + 'https://zerokms.example.com', + ) // Unrelated inbound headers are preserved for the render. - expect(requestHeaders.get("x-unrelated")).toBe("keep-me"); - }); + expect(requestHeaders.get('x-unrelated')).toBe('keep-me') + }) - it("strips the forged header under a custom header name too", async () => { + it('strips the forged header under a custom header name too', async () => { const { requestHeaders } = await csFederationMiddleware({ - request: requestWithForgedHeader("x-warm"), + request: requestWithForgedHeader('x-warm'), responseHeaders: new Headers(), workspaceCrn: WORKSPACE_CRN, - getJwt: () => "jwt", + getJwt: () => 'jwt', cookieName: csTokenCookieName(WORKSPACE_ID), - headerName: "x-warm", - }); + headerName: 'x-warm', + }) expect( - (await csAuthHeader(requestHeaders, { headerName: "x-warm" })?.getToken()) + (await csAuthHeader(requestHeaders, { headerName: 'x-warm' })?.getToken()) ?.data?.token, - ).toBe("header.payload.signature"); - }); -}); - -describe("csSanitizeHeaders", () => { - it("deletes a client-forged warmed-token header and keeps the rest", () => { - const sanitized = csSanitizeHeaders(requestWithForgedHeader()); - expect(sanitized.get(CS_TOKEN_HEADER)).toBeNull(); - expect(csAuthHeader(sanitized)).toBeNull(); - expect(sanitized.get("x-unrelated")).toBe("keep-me"); - }); - - it("accepts a Headers as well as a Request", () => { - const sanitized = csSanitizeHeaders(requestWithForgedHeader().headers); - expect(csAuthHeader(sanitized)).toBeNull(); - }); - - it("does not mutate the source headers", () => { - const headers = requestWithForgedHeader().headers; - csSanitizeHeaders(headers); + ).toBe('header.payload.signature') + }) +}) + +describe('csSanitizeHeaders', () => { + it('deletes a client-forged warmed-token header and keeps the rest', () => { + const sanitized = csSanitizeHeaders(requestWithForgedHeader()) + expect(sanitized.get(CS_TOKEN_HEADER)).toBeNull() + expect(csAuthHeader(sanitized)).toBeNull() + expect(sanitized.get('x-unrelated')).toBe('keep-me') + }) + + it('accepts a Headers as well as a Request', () => { + const sanitized = csSanitizeHeaders(requestWithForgedHeader().headers) + expect(csAuthHeader(sanitized)).toBeNull() + }) + + it('does not mutate the source headers', () => { + const headers = requestWithForgedHeader().headers + csSanitizeHeaders(headers) // Request headers are immutable in the fetch spec, so a delete on the source // would throw rather than silently strip — assert the clone is what changed. - expect(headers.get(CS_TOKEN_HEADER)).not.toBeNull(); - }); + expect(headers.get(CS_TOKEN_HEADER)).not.toBeNull() + }) - it("strips only the named header", () => { - const request = new Request("https://example.com/", { + it('strips only the named header', () => { + const request = new Request('https://example.com/', { headers: { [CS_TOKEN_HEADER]: encodeTokenHeader(tokenResult()), - "x-warm": encodeTokenHeader(tokenResult()), + 'x-warm': encodeTokenHeader(tokenResult()), }, - }); - const sanitized = csSanitizeHeaders(request, { headerName: "x-warm" }); - expect(sanitized.get("x-warm")).toBeNull(); - expect(sanitized.get(CS_TOKEN_HEADER)).not.toBeNull(); - }); -}); - -describe("csAuthHeader", () => { - it("reads a warmed token from the request header into a no-federation strategy", async () => { + }) + const sanitized = csSanitizeHeaders(request, { headerName: 'x-warm' }) + expect(sanitized.get('x-warm')).toBeNull() + expect(sanitized.get(CS_TOKEN_HEADER)).not.toBeNull() + }) +}) + +describe('csAuthHeader', () => { + it('reads a warmed token from the request header into a no-federation strategy', async () => { const headers = new Headers({ [CS_TOKEN_HEADER]: encodeTokenHeader(tokenResult()), - }); - const strategy = csAuthHeader(headers); - expect(strategy).not.toBeNull(); - expect(strategy?.requiresFederation).toBe(false); + }) + const strategy = csAuthHeader(headers) + expect(strategy).not.toBeNull() + expect(strategy?.requiresFederation).toBe(false) // Warmed strategy mirrors a real strategy: getToken() resolves a `{ data }` // Result, not a bare TokenResult. - expect((await strategy?.getToken())?.data?.workspaceId).toBe(WORKSPACE_ID); - }); + expect((await strategy?.getToken())?.data?.workspaceId).toBe(WORKSPACE_ID) + }) - it("reads eagerly at construction (later header mutation is ignored)", async () => { + it('reads eagerly at construction (later header mutation is ignored)', async () => { const headers = new Headers({ - [CS_TOKEN_HEADER]: encodeTokenHeader(tokenResult({ token: "first" })), - }); - const strategy = csAuthHeader(headers); + [CS_TOKEN_HEADER]: encodeTokenHeader(tokenResult({ token: 'first' })), + }) + const strategy = csAuthHeader(headers) headers.set( CS_TOKEN_HEADER, - encodeTokenHeader(tokenResult({ token: "second" })), - ); - expect((await strategy?.getToken())?.data?.token).toBe("first"); - }); + encodeTokenHeader(tokenResult({ token: 'second' })), + ) + expect((await strategy?.getToken())?.data?.token).toBe('first') + }) - it("returns null when no warmed token is present (cold fallback)", () => { - expect(csAuthHeader(new Headers())).toBeNull(); - }); + it('returns null when no warmed token is present (cold fallback)', () => { + expect(csAuthHeader(new Headers())).toBeNull() + }) - it("returns null on a malformed header rather than throwing", () => { - const headers = new Headers({ [CS_TOKEN_HEADER]: "not-valid-base64url!!" }); - expect(csAuthHeader(headers)).toBeNull(); - }); + it('returns null on a malformed header rather than throwing', () => { + const headers = new Headers({ [CS_TOKEN_HEADER]: 'not-valid-base64url!!' }) + expect(csAuthHeader(headers)).toBeNull() + }) - it("rejects a structurally-incomplete TokenResult (spoof/partial payload)", () => { + it('rejects a structurally-incomplete TokenResult (spoof/partial payload)', () => { // Missing workspaceId/subject/issuer — decodes fine but isn't a TokenResult. const partial = new Headers({ [CS_TOKEN_HEADER]: encodeTokenHeader({ - token: "header.payload.signature", + token: 'header.payload.signature', } as never), - }); - expect(csAuthHeader(partial)).toBeNull(); + }) + expect(csAuthHeader(partial)).toBeNull() // services present but not a string→string map. const badServices = new Headers({ [CS_TOKEN_HEADER]: encodeTokenHeader( tokenResult({ services: { zerokms: 123 } }) as never, ), - }); - expect(csAuthHeader(badServices)).toBeNull(); + }) + expect(csAuthHeader(badServices)).toBeNull() // services present but EMPTY — a federated token always has >=1 endpoint. const emptyServices = new Headers({ [CS_TOKEN_HEADER]: encodeTokenHeader(tokenResult({ services: {} })), - }); - expect(csAuthHeader(emptyServices)).toBeNull(); + }) + expect(csAuthHeader(emptyServices)).toBeNull() // Field PRESENT but empty string — distinct from missing; must still reject // (isTokenResult's `.length === 0` branch). workspaceId is representative. const emptyField = new Headers({ - [CS_TOKEN_HEADER]: encodeTokenHeader(tokenResult({ workspaceId: "" })), - }); - expect(csAuthHeader(emptyField)).toBeNull(); - }); - - it("honours a custom header name", async () => { - const headers = new Headers({ "x-warm": encodeTokenHeader(tokenResult()) }); - expect(csAuthHeader(headers, { headerName: "x-warm" })).not.toBeNull(); - expect(csAuthHeader(headers)).toBeNull(); - }); -}); + [CS_TOKEN_HEADER]: encodeTokenHeader(tokenResult({ workspaceId: '' })), + }) + expect(csAuthHeader(emptyField)).toBeNull() + }) + + it('honours a custom header name', async () => { + const headers = new Headers({ 'x-warm': encodeTokenHeader(tokenResult()) }) + expect(csAuthHeader(headers, { headerName: 'x-warm' })).not.toBeNull() + expect(csAuthHeader(headers)).toBeNull() + }) +}) diff --git a/languages/typescript/packages/auth/__tests__/oidc-cookie-roundtrip.test.ts b/languages/typescript/packages/auth/__tests__/oidc-cookie-roundtrip.test.ts index b0d3cd2fb..c657e3c43 100644 --- a/languages/typescript/packages/auth/__tests__/oidc-cookie-roundtrip.test.ts +++ b/languages/typescript/packages/auth/__tests__/oidc-cookie-roundtrip.test.ts @@ -1,157 +1,157 @@ -import { describe, it, expect, beforeEach, afterEach } from "vitest"; -import { cookieStore } from "../cookies.mjs"; -import { MockCtsServer } from "./helpers/mock-cts-server"; +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { cookieStore } from '../cookies.mjs' +import { MockCtsServer } from './helpers/mock-cts-server' const { OidcFederationStrategy } = - require("../index.js") as typeof import("../index"); + require('../index.js') as typeof import('../index') -const WORKSPACE_ID = "ZVATKW3VHMFG27DY"; -const WORKSPACE_CRN = `crn:ap-southeast-2.aws:${WORKSPACE_ID}`; +const WORKSPACE_ID = 'ZVATKW3VHMFG27DY' +const WORKSPACE_CRN = `crn:ap-southeast-2.aws:${WORKSPACE_ID}` -let server: MockCtsServer; -let savedHost: string | undefined; +let server: MockCtsServer +let savedHost: string | undefined beforeEach(async () => { - server = await MockCtsServer.start(); - savedHost = process.env.CS_CTS_HOST; - process.env.CS_CTS_HOST = server.baseUrl; -}); + server = await MockCtsServer.start() + savedHost = process.env.CS_CTS_HOST + process.env.CS_CTS_HOST = server.baseUrl +}) afterEach(async () => { if (savedHost === undefined) { - delete process.env.CS_CTS_HOST; + delete process.env.CS_CTS_HOST } else { - process.env.CS_CTS_HOST = savedHost; + process.env.CS_CTS_HOST = savedHost } - await server.close(); -}); + await server.close() +}) /** Extract the `name=value` pair from a `Set-Cookie` header. */ function cookiePair(setCookie: string): string { - return setCookie.split(";")[0]; + return setCookie.split(';')[0] } /** A `Request` carrying the given `Cookie:` header (or none). */ function requestWith(cookie?: string): Request { - return new Request("https://example.com/", { + return new Request('https://example.com/', { headers: cookie ? { cookie } : {}, - }); + }) } /** Unwrap a `createWithStore` Result, failing the test if it returned a failure. */ function mustCreateWithStore( ...args: Parameters<typeof OidcFederationStrategy.createWithStore> ): InstanceType<typeof OidcFederationStrategy> { - const cr = OidcFederationStrategy.createWithStore(...args); + const cr = OidcFederationStrategy.createWithStore(...args) if (cr.failure) { - expect.unreachable(`createWithStore failed: ${cr.failure.type}`); + expect.unreachable(`createWithStore failed: ${cr.failure.type}`) } - return cr.data; + return cr.data } -describe("OidcFederationStrategy + cookieStore round-trip", () => { - it("writes the federated CTS token to a Set-Cookie header", async () => { - server.mockAuthorizeEndpoint(); - const responseHeaders = new Headers(); - const store = cookieStore({ request: requestWith(), responseHeaders }); +describe('OidcFederationStrategy + cookieStore round-trip', () => { + it('writes the federated CTS token to a Set-Cookie header', async () => { + server.mockAuthorizeEndpoint() + const responseHeaders = new Headers() + const store = cookieStore({ request: requestWith(), responseHeaders }) const strategy = mustCreateWithStore( WORKSPACE_CRN, - () => Promise.resolve("header.payload.signature"), + () => Promise.resolve('header.payload.signature'), store.load, store.save, - ); - const r = await strategy.getToken(); + ) + const r = await strategy.getToken() if (r.failure) { - expect.unreachable(`getToken failed: ${r.failure.type}`); + expect.unreachable(`getToken failed: ${r.failure.type}`) } - expect(r.data.workspaceId).toBe(WORKSPACE_ID); - const setCookie = responseHeaders.get("set-cookie"); - expect(setCookie).toBeTruthy(); - expect(setCookie).toMatch(/^cs_token=/); - }); + expect(r.data.workspaceId).toBe(WORKSPACE_ID) + const setCookie = responseHeaders.get('set-cookie') + expect(setCookie).toBeTruthy() + expect(setCookie).toMatch(/^cs_token=/) + }) - it("reuses a cached token from the cookie without re-federating", async () => { + it('reuses a cached token from the cookie without re-federating', async () => { // First request federates and writes the cookie. - server.mockAuthorizeEndpoint(); - const firstHeaders = new Headers(); + server.mockAuthorizeEndpoint() + const firstHeaders = new Headers() const firstStore = cookieStore({ request: requestWith(), responseHeaders: firstHeaders, - }); + }) const first = mustCreateWithStore( WORKSPACE_CRN, - () => Promise.resolve("header.payload.signature"), + () => Promise.resolve('header.payload.signature'), firstStore.load, firstStore.save, - ); - await first.getToken(); - const cookie = cookiePair(firstHeaders.get("set-cookie")!); + ) + await first.getToken() + const cookie = cookiePair(firstHeaders.get('set-cookie')!) // Second request carries the cookie. Federation would fail (500) and // getJwt would throw — proving the token came from the cookie. - server.clearMocks(); - server.mockAuthorizeEndpointError(); + server.clearMocks() + server.mockAuthorizeEndpointError() const secondStore = cookieStore({ request: requestWith(cookie), responseHeaders: new Headers(), - }); + }) const second = mustCreateWithStore( WORKSPACE_CRN, - () => Promise.reject(new Error("getJwt must not be called")), + () => Promise.reject(new Error('getJwt must not be called')), secondStore.load, secondStore.save, - ); + ) - const r = await second.getToken(); + const r = await second.getToken() if (r.failure) { - expect.unreachable(`getToken failed: ${r.failure.type}`); + expect.unreachable(`getToken failed: ${r.failure.type}`) } - expect(r.data.workspaceId).toBe(WORKSPACE_ID); - }); + expect(r.data.workspaceId).toBe(WORKSPACE_ID) + }) - it("re-federates when the cookie holds an expired token", async () => { + it('re-federates when the cookie holds an expired token', async () => { // First request federates a token that is immediately expired (expiry 0). - server.mockAuthorizeEndpoint(0); - const firstHeaders = new Headers(); + server.mockAuthorizeEndpoint(0) + const firstHeaders = new Headers() const firstStore = cookieStore({ request: requestWith(), responseHeaders: firstHeaders, - }); + }) const first = mustCreateWithStore( WORKSPACE_CRN, - () => Promise.resolve("header.payload.signature"), + () => Promise.resolve('header.payload.signature'), firstStore.load, firstStore.save, - ); - await first.getToken(); - const cookie = cookiePair(firstHeaders.get("set-cookie")!); + ) + await first.getToken() + const cookie = cookiePair(firstHeaders.get('set-cookie')!) // Second request carries the expired cookie. getToken() must re-federate // — calling getJwt again — rather than serving the stale token. - server.clearMocks(); - server.mockAuthorizeEndpoint(); - let getJwtCalls = 0; + server.clearMocks() + server.mockAuthorizeEndpoint() + let getJwtCalls = 0 const secondStore = cookieStore({ request: requestWith(cookie), responseHeaders: new Headers(), - }); + }) const second = mustCreateWithStore( WORKSPACE_CRN, () => { - getJwtCalls += 1; - return Promise.resolve("header.payload.signature"); + getJwtCalls += 1 + return Promise.resolve('header.payload.signature') }, secondStore.load, secondStore.save, - ); + ) - const r = await second.getToken(); + const r = await second.getToken() if (r.failure) { - expect.unreachable(`getToken failed: ${r.failure.type}`); + expect.unreachable(`getToken failed: ${r.failure.type}`) } - expect(r.data.workspaceId).toBe(WORKSPACE_ID); - expect(getJwtCalls).toBe(1); - }); -}); + expect(r.data.workspaceId).toBe(WORKSPACE_ID) + expect(getJwtCalls).toBe(1) + }) +}) diff --git a/languages/typescript/packages/auth/__tests__/oidc-federation-strategy.test.ts b/languages/typescript/packages/auth/__tests__/oidc-federation-strategy.test.ts index 69eed3f26..194324888 100644 --- a/languages/typescript/packages/auth/__tests__/oidc-federation-strategy.test.ts +++ b/languages/typescript/packages/auth/__tests__/oidc-federation-strategy.test.ts @@ -1,144 +1,144 @@ -import { describe, it, expect, beforeEach, afterEach } from "vitest"; -import { MockCtsServer } from "./helpers/mock-cts-server"; +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { MockCtsServer } from './helpers/mock-cts-server' const { OidcFederationStrategy } = - require("../index.js") as typeof import("../index"); + require('../index.js') as typeof import('../index') -const WORKSPACE_ID = "ZVATKW3VHMFG27DY"; -const WORKSPACE_CRN = `crn:ap-southeast-2.aws:${WORKSPACE_ID}`; +const WORKSPACE_ID = 'ZVATKW3VHMFG27DY' +const WORKSPACE_CRN = `crn:ap-southeast-2.aws:${WORKSPACE_ID}` -let server: MockCtsServer; -let savedHost: string | undefined; +let server: MockCtsServer +let savedHost: string | undefined beforeEach(async () => { - server = await MockCtsServer.start(); + server = await MockCtsServer.start() // OidcFederationStrategy reads the CTS base URL from CS_CTS_HOST at runtime, // so set it before constructing the strategy. - savedHost = process.env.CS_CTS_HOST; - process.env.CS_CTS_HOST = server.baseUrl; -}); + savedHost = process.env.CS_CTS_HOST + process.env.CS_CTS_HOST = server.baseUrl +}) afterEach(async () => { if (savedHost === undefined) { - delete process.env.CS_CTS_HOST; + delete process.env.CS_CTS_HOST } else { - process.env.CS_CTS_HOST = savedHost; + process.env.CS_CTS_HOST = savedHost } - await server.close(); -}); + await server.close() +}) /** A `getJwt` callback that counts invocations and returns a fixed JWT. */ function countingJwt() { - let calls = 0; + let calls = 0 return { calls: () => calls, getJwt: () => { - calls += 1; - return Promise.resolve("header.payload.signature"); + calls += 1 + return Promise.resolve('header.payload.signature') }, - }; + } } /** An in-memory `{ load, save }` token store dealing in JSON strings. */ function memStore() { - let saved: string | null = null; + let saved: string | null = null return { saved: () => saved, load: () => Promise.resolve(saved), save: (json: string) => { - saved = json; - return Promise.resolve(); + saved = json + return Promise.resolve() }, - }; + } } /** Unwrap a `create` Result, failing the test if it returned a failure. */ function mustCreate( ...args: Parameters<typeof OidcFederationStrategy.create> ): InstanceType<typeof OidcFederationStrategy> { - const cr = OidcFederationStrategy.create(...args); + const cr = OidcFederationStrategy.create(...args) if (cr.failure) { - expect.unreachable(`create failed: ${cr.failure.type}`); + expect.unreachable(`create failed: ${cr.failure.type}`) } - return cr.data; + return cr.data } /** Unwrap a `createWithStore` Result, failing the test if it returned a failure. */ function mustCreateWithStore( ...args: Parameters<typeof OidcFederationStrategy.createWithStore> ): InstanceType<typeof OidcFederationStrategy> { - const cr = OidcFederationStrategy.createWithStore(...args); + const cr = OidcFederationStrategy.createWithStore(...args) if (cr.failure) { - expect.unreachable(`createWithStore failed: ${cr.failure.type}`); + expect.unreachable(`createWithStore failed: ${cr.failure.type}`) } - return cr.data; + return cr.data } -describe("OidcFederationStrategy (TypeScript / vitest)", () => { - it("federates a third-party JWT into a CTS service token", async () => { - server.mockAuthorizeEndpoint(); - const jwt = countingJwt(); - const strategy = mustCreate(WORKSPACE_CRN, jwt.getJwt); +describe('OidcFederationStrategy (TypeScript / vitest)', () => { + it('federates a third-party JWT into a CTS service token', async () => { + server.mockAuthorizeEndpoint() + const jwt = countingJwt() + const strategy = mustCreate(WORKSPACE_CRN, jwt.getJwt) - const r = await strategy.getToken(); + const r = await strategy.getToken() if (r.failure) { - expect.unreachable(`getToken failed: ${r.failure.type}`); + expect.unreachable(`getToken failed: ${r.failure.type}`) } - const result = r.data; + const result = r.data - expect(result.token).not.toBe(""); - expect(result.workspaceId).toBe(WORKSPACE_ID); - expect(jwt.calls()).toBe(1); - }); + expect(result.token).not.toBe('') + expect(result.workspaceId).toBe(WORKSPACE_ID) + expect(jwt.calls()).toBe(1) + }) - it("surfaces WORKSPACE_MISMATCH with the expected/actual payload", async () => { + it('surfaces WORKSPACE_MISMATCH with the expected/actual payload', async () => { // The federated token carries a different workspace than the strategy's CRN, // so workspace verification fails. This is the flagship structured-payload // failure — it exercises the `...payload` spread end to end (the named // help/url destructure doesn't), so it guards `failure.expected`/`.actual` // against a serde key rename or a spread regression at the JS boundary. - const MISMATCHED_WORKSPACE = "AAAAAAAAAAAAAAAA"; - server.mockAuthorizeEndpointWithWorkspace(MISMATCHED_WORKSPACE); - const strategy = mustCreate(WORKSPACE_CRN, countingJwt().getJwt); + const MISMATCHED_WORKSPACE = 'AAAAAAAAAAAAAAAA' + server.mockAuthorizeEndpointWithWorkspace(MISMATCHED_WORKSPACE) + const strategy = mustCreate(WORKSPACE_CRN, countingJwt().getJwt) - const r = await strategy.getToken(); + const r = await strategy.getToken() if (!r.failure) { - expect.unreachable("expected a WORKSPACE_MISMATCH failure"); + expect.unreachable('expected a WORKSPACE_MISMATCH failure') } - const { failure } = r; - if (failure.type !== "WORKSPACE_MISMATCH") { - expect.unreachable(`expected WORKSPACE_MISMATCH, got ${failure.type}`); + const { failure } = r + if (failure.type !== 'WORKSPACE_MISMATCH') { + expect.unreachable(`expected WORKSPACE_MISMATCH, got ${failure.type}`) } - expect(failure.expected).toBe(WORKSPACE_ID); - expect(failure.actual).toBe(MISMATCHED_WORKSPACE); - expect(failure.error).toBeInstanceOf(Error); - }); + expect(failure.expected).toBe(WORKSPACE_ID) + expect(failure.actual).toBe(MISMATCHED_WORKSPACE) + expect(failure.error).toBeInstanceOf(Error) + }) - it("re-federates after the cached token expires", async () => { + it('re-federates after the cached token expires', async () => { // expiry 0 → the federated token is immediately expired, so the second // getToken() must re-federate rather than serve a cached token. - server.mockAuthorizeEndpoint(0); - server.mockAuthorizeEndpoint(0); - const jwt = countingJwt(); - const strategy = mustCreate(WORKSPACE_CRN, jwt.getJwt); + server.mockAuthorizeEndpoint(0) + server.mockAuthorizeEndpoint(0) + const jwt = countingJwt() + const strategy = mustCreate(WORKSPACE_CRN, jwt.getJwt) - await strategy.getToken(); - await strategy.getToken(); + await strategy.getToken() + await strategy.getToken() - expect(jwt.calls()).toBe(2); - }); + expect(jwt.calls()).toBe(2) + }) - it("surfaces a getJwt rejection as a failure with .type", async () => { - server.mockAuthorizeEndpoint(); + it('surfaces a getJwt rejection as a failure with .type', async () => { + server.mockAuthorizeEndpoint() const strategy = mustCreate(WORKSPACE_CRN, () => - Promise.reject(new Error("provider unavailable")), - ); + Promise.reject(new Error('provider unavailable')), + ) - const r = await strategy.getToken(); - expect(r.failure?.type).toBe("SERVER_ERROR"); - }); + const r = await strategy.getToken() + expect(r.failure?.type).toBe('SERVER_ERROR') + }) - it("honours an explicit baseUrl override over CS_CTS_HOST", async () => { + it('honours an explicit baseUrl override over CS_CTS_HOST', async () => { // CS_CTS_HOST (set in beforeEach) points at `server`, which here 500s on // federation. A second server is the override target and succeeds. If the // napi `baseUrl` arg is threaded through `maybe_base_url`, federation hits @@ -146,217 +146,217 @@ describe("OidcFederationStrategy (TypeScript / vitest)", () => { // CS_CTS_HOST's 500 and reject. This proves the precedence guarantee // motivating CIP-3246 survives the napi parameter threading — the Rust core // proves the ordering, this proves the binding preserves it. - server.mockAuthorizeEndpointError(); - const override = await MockCtsServer.start(); + server.mockAuthorizeEndpointError() + const override = await MockCtsServer.start() try { - override.mockAuthorizeEndpoint(); + override.mockAuthorizeEndpoint() const strategy = mustCreate( WORKSPACE_CRN, - () => Promise.resolve("header.payload.signature"), + () => Promise.resolve('header.payload.signature'), override.baseUrl, - ); + ) - const r = await strategy.getToken(); + const r = await strategy.getToken() if (r.failure) { - expect.unreachable(`getToken failed: ${r.failure.type}`); + expect.unreachable(`getToken failed: ${r.failure.type}`) } - expect(r.data.workspaceId).toBe(WORKSPACE_ID); + expect(r.data.workspaceId).toBe(WORKSPACE_ID) } finally { - await override.close(); + await override.close() } - }); + }) - it("honours a baseUrl override over CS_CTS_HOST for createWithStore", async () => { + it('honours a baseUrl override over CS_CTS_HOST for createWithStore', async () => { // The store-variant twin of the precedence test: `baseUrl` is the 5th // positional arg here (vs the 3rd on `create`), threaded through a separate // wrapper path in index.js. CS_CTS_HOST's `server` 500s; the override // server succeeds. getToken resolving (and the token landing in the store) // proves the 5th-positional override is threaded, not dropped or // mis-positioned. - server.mockAuthorizeEndpointError(); - const override = await MockCtsServer.start(); + server.mockAuthorizeEndpointError() + const override = await MockCtsServer.start() try { - override.mockAuthorizeEndpoint(); - const store = memStore(); + override.mockAuthorizeEndpoint() + const store = memStore() const strategy = mustCreateWithStore( WORKSPACE_CRN, - () => Promise.resolve("header.payload.signature"), + () => Promise.resolve('header.payload.signature'), store.load, store.save, override.baseUrl, - ); + ) - const r = await strategy.getToken(); + const r = await strategy.getToken() if (r.failure) { - expect.unreachable(`getToken failed: ${r.failure.type}`); + expect.unreachable(`getToken failed: ${r.failure.type}`) } - expect(r.data.workspaceId).toBe(WORKSPACE_ID); - expect(store.saved()).not.toBeNull(); + expect(r.data.workspaceId).toBe(WORKSPACE_ID) + expect(store.saved()).not.toBeNull() } finally { - await override.close(); + await override.close() } - }); + }) - it("rejects a malformed baseUrl with INVALID_URL", () => { + it('rejects a malformed baseUrl with INVALID_URL', () => { // The napi twin of the wasm `..._rejects_invalid_base_url` test: a // non-empty, unparseable override must surface through the factory as a // coded INVALID_URL failure (via `maybe_base_url(...)? → to_napi_error`), not // a silent fallback or an un-coded throw. const cr = OidcFederationStrategy.create( WORKSPACE_CRN, - () => Promise.resolve("h.p.s"), - "not a url", - ); - expect(cr.failure?.type).toBe("INVALID_URL"); - }); + () => Promise.resolve('h.p.s'), + 'not a url', + ) + expect(cr.failure?.type).toBe('INVALID_URL') + }) - it("treats an empty baseUrl as absent (falls back to CS_CTS_HOST)", async () => { + it('treats an empty baseUrl as absent (falls back to CS_CTS_HOST)', async () => { // An empty-string override must be a no-op, not an INVALID_URL — so // federation still resolves against CS_CTS_HOST's mock. - server.mockAuthorizeEndpoint(); + server.mockAuthorizeEndpoint() const strategy = mustCreate( WORKSPACE_CRN, - () => Promise.resolve("header.payload.signature"), - "", - ); + () => Promise.resolve('header.payload.signature'), + '', + ) - const r = await strategy.getToken(); + const r = await strategy.getToken() if (r.failure) { - expect.unreachable(`getToken failed: ${r.failure.type}`); + expect.unreachable(`getToken failed: ${r.failure.type}`) } - expect(r.data.workspaceId).toBe(WORKSPACE_ID); - }); + expect(r.data.workspaceId).toBe(WORKSPACE_ID) + }) - it("rejects an invalid workspace CRN with .type", () => { - const cr = OidcFederationStrategy.create("not-a-crn", () => - Promise.resolve("h.p.s"), - ); - expect(cr.failure?.type).toBe("INVALID_CRN"); - }); + it('rejects an invalid workspace CRN with .type', () => { + const cr = OidcFederationStrategy.create('not-a-crn', () => + Promise.resolve('h.p.s'), + ) + expect(cr.failure?.type).toBe('INVALID_CRN') + }) - it("attaches diagnostic help to an INVALID_CRN failure", () => { + it('attaches diagnostic help to an INVALID_CRN failure', () => { // INVALID_CRN carries `#[diagnostic(help(...))]`, so its envelope includes // `help` — pins the `help !== undefined` branch of `toFailure` in index.js // with a content assertion, not just presence: the text must survive the // __CS_FAIL__ envelope round-trip intact. - const cr = OidcFederationStrategy.create("not-a-crn", () => - Promise.resolve("h.p.s"), - ); - expect(cr.failure?.type).toBe("INVALID_CRN"); - expect(typeof cr.failure?.help).toBe("string"); - expect(cr.failure?.help).toMatch(/crn:<region>:<workspace-id>/); + const cr = OidcFederationStrategy.create('not-a-crn', () => + Promise.resolve('h.p.s'), + ) + expect(cr.failure?.type).toBe('INVALID_CRN') + expect(typeof cr.failure?.help).toBe('string') + expect(cr.failure?.help).toMatch(/crn:<region>:<workspace-id>/) // The same help is mirrored onto the live Error for loggers that only // see the error object. expect((cr.failure?.error as Error & { help?: string }).help).toBe( cr.failure?.help, - ); - }); + ) + }) - it("rejects a CRN whose workspace segment is malformed with .type", () => { + it('rejects a CRN whose workspace segment is malformed with .type', () => { // "not-a-crn" above fails at the `crn:` prefix; this is the distinct path // where the prefix/region parse but the workspace segment fails validation // — what the old INVALID_WORKSPACE_ID case covered before the CRN switch. const cr = OidcFederationStrategy.create( - "crn:ap-southeast-2.aws:not-a-valid-workspace", - () => Promise.resolve("h.p.s"), - ); - expect(cr.failure?.type).toBe("INVALID_CRN"); - }); - - it("persists the federated token to the store", async () => { - server.mockAuthorizeEndpoint(); - const store = memStore(); - const jwt = countingJwt(); + 'crn:ap-southeast-2.aws:not-a-valid-workspace', + () => Promise.resolve('h.p.s'), + ) + expect(cr.failure?.type).toBe('INVALID_CRN') + }) + + it('persists the federated token to the store', async () => { + server.mockAuthorizeEndpoint() + const store = memStore() + const jwt = countingJwt() const strategy = mustCreateWithStore( WORKSPACE_CRN, jwt.getJwt, store.load, store.save, - ); + ) - await strategy.getToken(); + await strategy.getToken() - expect(store.saved()).not.toBeNull(); - expect(jwt.calls()).toBe(1); - }); + expect(store.saved()).not.toBeNull() + expect(jwt.calls()).toBe(1) + }) - it("loads a cached token from the store without re-federating", async () => { + it('loads a cached token from the store without re-federating', async () => { // First strategy federates and populates the shared store. - server.mockAuthorizeEndpoint(); - const store = memStore(); + server.mockAuthorizeEndpoint() + const store = memStore() const first = mustCreateWithStore( WORKSPACE_CRN, - () => Promise.resolve("h.p.s"), + () => Promise.resolve('h.p.s'), store.load, store.save, - ); - await first.getToken(); - expect(store.saved()).not.toBeNull(); + ) + await first.getToken() + expect(store.saved()).not.toBeNull() // Second strategy shares the store. Federation would fail (500) and getJwt // would throw — proving the token came from the store, not the network. - server.clearMocks(); - server.mockAuthorizeEndpointError(); + server.clearMocks() + server.mockAuthorizeEndpointError() const second = mustCreateWithStore( WORKSPACE_CRN, - () => Promise.reject(new Error("getJwt must not be called")), + () => Promise.reject(new Error('getJwt must not be called')), store.load, store.save, - ); + ) - const r = await second.getToken(); + const r = await second.getToken() if (r.failure) { - expect.unreachable(`getToken failed: ${r.failure.type}`); + expect.unreachable(`getToken failed: ${r.failure.type}`) } - expect(r.data.workspaceId).toBe(WORKSPACE_ID); - }); + expect(r.data.workspaceId).toBe(WORKSPACE_ID) + }) - it("re-federates when the stored token JSON is malformed", async () => { + it('re-federates when the stored token JSON is malformed', async () => { // A corrupt cookie/store value must be treated as a cache miss (the // `serde_json::from_str(..).ok()` → None branch), not panic — so federation // runs fresh. A version that `unwrap()`ed the parse would fail this. - server.mockAuthorizeEndpoint(); - const jwt = countingJwt(); + server.mockAuthorizeEndpoint() + const jwt = countingJwt() const strategy = mustCreateWithStore( WORKSPACE_CRN, jwt.getJwt, - () => Promise.resolve("}{ not json"), + () => Promise.resolve('}{ not json'), (_json: string) => Promise.resolve(), - ); + ) - await strategy.getToken(); + await strategy.getToken() // Garbage cache discarded → exactly one fresh federation. - expect(jwt.calls()).toBe(1); - }); + expect(jwt.calls()).toBe(1) + }) - it("surfaces a non-string getJwt result as a failure with .type", async () => { + it('surfaces a non-string getJwt result as a failure with .type', async () => { // Mirrors the wasm `js_oidc_provider_errors_on_non_string_result` test: // a `Promise<number>` fails napi's `Promise<String>` coercion and must // surface as a clean SERVER_ERROR failure, not a panic or hung promise. - server.mockAuthorizeEndpoint(); + server.mockAuthorizeEndpoint() const strategy = mustCreate(WORKSPACE_CRN, () => Promise.resolve(42 as unknown as string), - ); + ) - const r = await strategy.getToken(); - expect(r.failure?.type).toBe("SERVER_ERROR"); - }); + const r = await strategy.getToken() + expect(r.failure?.type).toBe('SERVER_ERROR') + }) - it("surfaces a federation server error with .type", async () => { + it('surfaces a federation server error with .type', async () => { // Negative twin of the happy path: a real federation request reaching // /api/authorise and getting a 500 must surface a failure with a `.type`, // not resolve or throw an un-coded error. - server.mockAuthorizeEndpointError(); + server.mockAuthorizeEndpointError() const strategy = mustCreate(WORKSPACE_CRN, () => - Promise.resolve("header.payload.signature"), - ); - - const r = await strategy.getToken(); - expect(r.failure).toBeTruthy(); - expect(r.failure?.type).toBeTruthy(); - }); -}); + Promise.resolve('header.payload.signature'), + ) + + const r = await strategy.getToken() + expect(r.failure).toBeTruthy() + expect(r.failure?.type).toBeTruthy() + }) +}) diff --git a/languages/typescript/packages/auth/__tests__/provision-device-client.test.ts b/languages/typescript/packages/auth/__tests__/provision-device-client.test.ts index 63dcb0b50..a3cd438fa 100644 --- a/languages/typescript/packages/auth/__tests__/provision-device-client.test.ts +++ b/languages/typescript/packages/auth/__tests__/provision-device-client.test.ts @@ -1,115 +1,114 @@ -import { describe, it, expect, beforeEach, afterEach } from "vitest"; -import { existsSync, mkdtempSync, readFileSync, writeFileSync } from "fs"; -import { join } from "path"; -import { tmpdir } from "os"; -import { MockCtsServer } from "./helpers/mock-cts-server"; -import { saveTestToken } from "./helpers/test-fixtures"; +import { existsSync, mkdtempSync, readFileSync, writeFileSync } from 'fs' +import { tmpdir } from 'os' +import { join } from 'path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { MockCtsServer } from './helpers/mock-cts-server' +import { saveTestToken } from './helpers/test-fixtures' -const { bindClientDevice } = - require("../index.js") as typeof import("../index"); +const { bindClientDevice } = require('../index.js') as typeof import('../index') // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- -const TEST_WORKSPACE_ID = "ZVATKW3VHMFG27DY"; +const TEST_WORKSPACE_ID = 'ZVATKW3VHMFG27DY' -let server: MockCtsServer; -let profileDir: string; -let savedConfigPath: string | undefined; +let server: MockCtsServer +let profileDir: string +let savedConfigPath: string | undefined function freshProfileDir(): string { - return mkdtempSync(join(tmpdir(), "cs-auth-test-")); + return mkdtempSync(join(tmpdir(), 'cs-auth-test-')) } function workspaceDir(): string { - return join(profileDir, "workspaces", TEST_WORKSPACE_ID); + return join(profileDir, 'workspaces', TEST_WORKSPACE_ID) } // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- -describe("provision device client (TypeScript / vitest)", () => { +describe('provision device client (TypeScript / vitest)', () => { beforeEach(async () => { - server = await MockCtsServer.start(); - profileDir = freshProfileDir(); + server = await MockCtsServer.start() + profileDir = freshProfileDir() // Production `bindClientDevice()` resolves its profile dir from // CS_CONFIG_PATH (ProfileStore::resolve), so point it at the temp dir. - savedConfigPath = process.env.CS_CONFIG_PATH; - process.env.CS_CONFIG_PATH = profileDir; - }); + savedConfigPath = process.env.CS_CONFIG_PATH + process.env.CS_CONFIG_PATH = profileDir + }) afterEach(async () => { if (savedConfigPath === undefined) { - delete process.env.CS_CONFIG_PATH; + delete process.env.CS_CONFIG_PATH } else { - process.env.CS_CONFIG_PATH = savedConfigPath; + process.env.CS_CONFIG_PATH = savedConfigPath } - await server.close(); - }); + await server.close() + }) - it("creates secretkey.json on successful provisioning", async () => { - server.mockCreateClientEndpoint(); - saveTestToken(profileDir, server.baseUrl); + it('creates secretkey.json on successful provisioning', async () => { + server.mockCreateClientEndpoint() + saveTestToken(profileDir, server.baseUrl) - const r = await bindClientDevice(); + const r = await bindClientDevice() if (r.failure) { - expect.unreachable(`bindClientDevice failed: ${r.failure.type}`); + expect.unreachable(`bindClientDevice failed: ${r.failure.type}`) } - const raw = readFileSync(join(workspaceDir(), "secretkey.json"), "utf-8"); - const secretKey = JSON.parse(raw); - expect(secretKey.client_id).toBe("00000000-0000-0000-0000-000000000001"); - expect(secretKey.client_key).toBe("dGVzdC1rZXktbWF0ZXJpYWw="); - }); + const raw = readFileSync(join(workspaceDir(), 'secretkey.json'), 'utf-8') + const secretKey = JSON.parse(raw) + expect(secretKey.client_id).toBe('00000000-0000-0000-0000-000000000001') + expect(secretKey.client_key).toBe('dGVzdC1rZXktbWF0ZXJpYWw=') + }) - it("is a no-op when secretkey.json already exists", async () => { + it('is a no-op when secretkey.json already exists', async () => { // No mock endpoints needed — should short-circuit before any HTTP call. - saveTestToken(profileDir, server.baseUrl); + saveTestToken(profileDir, server.baseUrl) // Pre-create secretkey.json in the workspace directory const existing = JSON.stringify({ - client_id: "existing-id", - client_key: "existing-key", - }); - writeFileSync(join(workspaceDir(), "secretkey.json"), existing); + client_id: 'existing-id', + client_key: 'existing-key', + }) + writeFileSync(join(workspaceDir(), 'secretkey.json'), existing) - const r = await bindClientDevice(); + const r = await bindClientDevice() if (r.failure) { - expect.unreachable(`bindClientDevice failed: ${r.failure.type}`); + expect.unreachable(`bindClientDevice failed: ${r.failure.type}`) } - const raw = readFileSync(join(workspaceDir(), "secretkey.json"), "utf-8"); - const secretKey = JSON.parse(raw); - expect(secretKey.client_id).toBe("existing-id"); - }); + const raw = readFileSync(join(workspaceDir(), 'secretkey.json'), 'utf-8') + const secretKey = JSON.parse(raw) + expect(secretKey.client_id).toBe('existing-id') + }) - it("is a no-op on 409 conflict", async () => { - server.mockCreateClientConflict(); - saveTestToken(profileDir, server.baseUrl); + it('is a no-op on 409 conflict', async () => { + server.mockCreateClientConflict() + saveTestToken(profileDir, server.baseUrl) - const r = await bindClientDevice(); + const r = await bindClientDevice() if (r.failure) { - expect.unreachable(`bindClientDevice failed: ${r.failure.type}`); + expect.unreachable(`bindClientDevice failed: ${r.failure.type}`) } - expect(existsSync(join(workspaceDir(), "secretkey.json"))).toBe(false); - }); + expect(existsSync(join(workspaceDir(), 'secretkey.json'))).toBe(false) + }) - it("fails on server error", async () => { + it('fails on server error', async () => { // No mock endpoint — server will return an error for unmatched route. - saveTestToken(profileDir, server.baseUrl); + saveTestToken(profileDir, server.baseUrl) - const r = await bindClientDevice(); - expect(r.failure).toBeTruthy(); - expect(r.failure?.error).toBeInstanceOf(Error); - }); + const r = await bindClientDevice() + expect(r.failure).toBeTruthy() + expect(r.failure?.error).toBeInstanceOf(Error) + }) - it("fails with STORE_ERROR when auth token is missing", async () => { + it('fails with STORE_ERROR when auth token is missing', async () => { // No token saved — should fail trying to load auth.json - const r = await bindClientDevice(); - expect(r.failure?.error).toBeInstanceOf(Error); - expect(r.failure?.type).toBe("STORE_ERROR"); - }); -}); + const r = await bindClientDevice() + expect(r.failure?.error).toBeInstanceOf(Error) + expect(r.failure?.type).toBe('STORE_ERROR') + }) +}) diff --git a/languages/typescript/packages/auth/__tests__/wasm-inline-result.test.ts b/languages/typescript/packages/auth/__tests__/wasm-inline-result.test.ts index 8cddeddff..a0dcaa3b5 100644 --- a/languages/typescript/packages/auth/__tests__/wasm-inline-result.test.ts +++ b/languages/typescript/packages/auth/__tests__/wasm-inline-result.test.ts @@ -1,6 +1,6 @@ -import { describe, it, expect } from "vitest"; -import { existsSync } from "fs"; -import { join } from "path"; +import { existsSync } from 'fs' +import { join } from 'path' +import { describe, expect, it } from 'vitest' // JS-level coverage of `wasm-inline.mjs`'s `toFailure` — the seam that turns // the wasm binding's branded `__authFailure` object into a `Result` failure. @@ -12,52 +12,52 @@ import { join } from "path"; // The wasm artifacts are gitignored (`npm run build:wasm` produces them), and // the vitest CI job builds only the napi module — so these tests self-skip // when the shim is absent. They run locally after a wasm build. -const WASM_SHIM = join(__dirname, "..", "wasm", "stack_auth_wasm_inline.js"); +const WASM_SHIM = join(__dirname, '..', 'wasm', 'stack_auth_wasm_inline.js') -describe.skipIf(!existsSync(WASM_SHIM))("wasm-inline Result wrapper", () => { - const VALID_CRN = "crn:ap-southeast-2.aws:ZVATKW3VHMFG27DY"; - const VALID_KEY = "CSAKtestKeyId.testKeySecret"; +describe.skipIf(!existsSync(WASM_SHIM))('wasm-inline Result wrapper', () => { + const VALID_CRN = 'crn:ap-southeast-2.aws:ZVATKW3VHMFG27DY' + const VALID_KEY = 'CSAKtestKeyId.testKeySecret' async function loadWasmInline() { // Dynamic import: a static one would fail module resolution when the // gitignored artifacts are absent, even with the describe skipped. - return import("../wasm-inline.mjs"); + return import('../wasm-inline.mjs') } - it("converts a branded wasm error into a typed failure with help", async () => { - const { AccessKeyStrategy } = await loadWasmInline(); - const r = AccessKeyStrategy.create("not-a-crn", VALID_KEY); - expect(r.failure?.type).toBe("INVALID_CRN"); - expect(r.failure?.error).toBeInstanceOf(Error); + it('converts a branded wasm error into a typed failure with help', async () => { + const { AccessKeyStrategy } = await loadWasmInline() + const r = AccessKeyStrategy.create('not-a-crn', VALID_KEY) + expect(r.failure?.type).toBe('INVALID_CRN') + expect(r.failure?.error).toBeInstanceOf(Error) // help from the serialized envelope must surface on the failure. - expect(r.failure?.help).toMatch(/crn:<region>:<workspace-id>/); + expect(r.failure?.help).toMatch(/crn:<region>:<workspace-id>/) // ...and be mirrored onto the live Error, matching the napi seam, so loggers // that only see `failure.error` still get the hint. expect((r.failure?.error as Error & { help?: string }).help).toBe( r.failure?.help, - ); - }); + ) + }) it("strips the envelope's message field instead of spreading it", async () => { - const { AccessKeyStrategy } = await loadWasmInline(); - const r = AccessKeyStrategy.create("not-a-crn", VALID_KEY); + const { AccessKeyStrategy } = await loadWasmInline() + const r = AccessKeyStrategy.create('not-a-crn', VALID_KEY) if (!r.failure) { - expect.unreachable("create should fail for a malformed CRN"); + expect.unreachable('create should fail for a malformed CRN') } // `message` rides in `__authFailure` for the Rust-side envelope tests but // is internal here — the live Error already carries it. A regression in // the `delete payload.message` line would spread it onto the failure. - expect("message" in r.failure).toBe(false); - expect(r.failure.error.message).toContain("Invalid workspace CRN"); - }); + expect('message' in r.failure).toBe(false) + expect(r.failure.error.message).toContain('Invalid workspace CRN') + }) - it("returns { data } wrapping a usable strategy on success", async () => { - const { AccessKeyStrategy } = await loadWasmInline(); - const r = AccessKeyStrategy.create(VALID_CRN, VALID_KEY); + it('returns { data } wrapping a usable strategy on success', async () => { + const { AccessKeyStrategy } = await loadWasmInline() + const r = AccessKeyStrategy.create(VALID_CRN, VALID_KEY) if (r.failure) { - expect.unreachable(`create failed: ${r.failure.type}`); + expect.unreachable(`create failed: ${r.failure.type}`) } - expect(typeof r.data.getToken).toBe("function"); - r.data.free(); - }); -}); + expect(typeof r.data.getToken).toBe('function') + r.data.free() + }) +}) diff --git a/languages/typescript/packages/auth/examples/auto-strategy.ts b/languages/typescript/packages/auth/examples/auto-strategy.ts index 7a320c90f..002a0df5a 100644 --- a/languages/typescript/packages/auth/examples/auto-strategy.ts +++ b/languages/typescript/packages/auth/examples/auto-strategy.ts @@ -17,13 +17,13 @@ // Usage (with an access key): // CS_CLIENT_ACCESS_KEY=<key> CS_WORKSPACE_CRN=<crn> npx tsx examples/auto-strategy.ts -import { AutoStrategy } from "../index"; -import type { AuthFailure } from "../index"; +import type { AuthFailure } from '../index' +import { AutoStrategy } from '../index' function reportAndExit(failure: AuthFailure): never { - console.error(`[${failure.type}] ${failure.error.message}`); - if (failure.help) console.error(failure.help); - process.exit(1); + console.error(`[${failure.type}] ${failure.error.message}`) + if (failure.help) console.error(failure.help) + process.exit(1) } async function main() { @@ -32,24 +32,24 @@ async function main() { // // AutoStrategy.detect({ accessKey: "CSAK...", workspaceCrn: "crn:..." }) // - const detected = AutoStrategy.detect(); - if (detected.failure) reportAndExit(detected.failure); + const detected = AutoStrategy.detect() + if (detected.failure) reportAndExit(detected.failure) // Retrieve a token — refresh happens automatically when needed. - const result = await detected.data.getToken(); - if (result.failure) reportAndExit(result.failure); + const result = await detected.data.getToken() + if (result.failure) reportAndExit(result.failure) - const token = result.data; - console.log(`Subject: ${token.subject}`); - console.log(`Workspace: ${token.workspaceId}`); - console.log(`Issuer: ${token.issuer}`); - console.log(`Services: ${JSON.stringify(token.services)}`); - console.log(`Token: ${token.token.slice(0, 20)}...`); + const token = result.data + console.log(`Subject: ${token.subject}`) + console.log(`Workspace: ${token.workspaceId}`) + console.log(`Issuer: ${token.issuer}`) + console.log(`Services: ${JSON.stringify(token.services)}`) + console.log(`Token: ${token.token.slice(0, 20)}...`) } // Domain errors are returned as `failure`, not thrown — only a genuine // internal fault reaches here. main().catch((err: unknown) => { - console.error(err instanceof Error ? err.message : String(err)); - process.exit(1); -}); + console.error(err instanceof Error ? err.message : String(err)) + process.exit(1) +}) diff --git a/languages/typescript/packages/auth/examples/device-code.ts b/languages/typescript/packages/auth/examples/device-code.ts index 46601bd9c..af17c9787 100644 --- a/languages/typescript/packages/auth/examples/device-code.ts +++ b/languages/typescript/packages/auth/examples/device-code.ts @@ -9,51 +9,49 @@ // Usage: // npx tsx examples/device-code.ts -import { beginDeviceCodeFlow } from "../index"; -import type { AuthFailure } from "../index"; +import type { AuthFailure } from '../index' +import { beginDeviceCodeFlow } from '../index' function reportAndExit(failure: AuthFailure): never { - console.error(`[${failure.type}] ${failure.error.message}`); - process.exit(1); + console.error(`[${failure.type}] ${failure.error.message}`) + process.exit(1) } async function main() { // Step 1: Begin the device code flow - const begun = await beginDeviceCodeFlow("ap-southeast-2.aws", "cli"); - if (begun.failure) reportAndExit(begun.failure); - const pending = begun.data; + const begun = await beginDeviceCodeFlow('ap-southeast-2.aws', 'cli') + if (begun.failure) reportAndExit(begun.failure) + const pending = begun.data // Step 2: Show the user their code and verification URL - console.log(`Your code is: ${pending.userCode}`); - console.log(`Visit: ${pending.verificationUriComplete}`); - console.log(`Code expires in: ${pending.expiresIn}s`); - console.log(); + console.log(`Your code is: ${pending.userCode}`) + console.log(`Visit: ${pending.verificationUriComplete}`) + console.log(`Code expires in: ${pending.expiresIn}s`) + console.log() // Optionally open the browser automatically - const opened = pending.openInBrowser(); - if (opened.failure) reportAndExit(opened.failure); + const opened = pending.openInBrowser() + if (opened.failure) reportAndExit(opened.failure) if (!opened.data) { - console.log( - "Could not open browser — please visit the URL above manually.", - ); + console.log('Could not open browser — please visit the URL above manually.') } // Step 3: Poll until the user authorizes (or the code expires). // The token is saved to ~/.cipherstash/auth.json automatically. - console.log("Waiting for authorization..."); - const result = await pending.pollForToken(); - if (result.failure) reportAndExit(result.failure); - const auth = result.data; - - console.log(); - console.log("Authenticated! Token saved to ~/.cipherstash/auth.json"); - console.log(` Expires at: ${new Date(auth.expiresAt * 1000).toISOString()}`); - console.log(` Expires in: ${auth.expiresIn}s`); + console.log('Waiting for authorization...') + const result = await pending.pollForToken() + if (result.failure) reportAndExit(result.failure) + const auth = result.data + + console.log() + console.log('Authenticated! Token saved to ~/.cipherstash/auth.json') + console.log(` Expires at: ${new Date(auth.expiresAt * 1000).toISOString()}`) + console.log(` Expires in: ${auth.expiresIn}s`) } // Domain errors are returned as `failure`, not thrown — only a genuine // internal fault reaches here. main().catch((err: unknown) => { - console.error(err instanceof Error ? err.message : String(err)); - process.exit(1); -}); + console.error(err instanceof Error ? err.message : String(err)) + process.exit(1) +}) diff --git a/languages/typescript/packages/auth/scripts/inline-wasm.mjs b/languages/typescript/packages/auth/scripts/inline-wasm.mjs index 3b8310c73..e9546e08c 100644 --- a/languages/typescript/packages/auth/scripts/inline-wasm.mjs +++ b/languages/typescript/packages/auth/scripts/inline-wasm.mjs @@ -3,15 +3,15 @@ // which fails in runtimes that don't auto-bundle sibling `.wasm` assets — see // README's "Why the explicit sub-path" section for the full rationale. -import { readFile, writeFile } from "node:fs/promises"; -import { resolve, dirname } from "node:path"; -import { fileURLToPath } from "node:url"; +import { readFile, writeFile } from 'node:fs/promises' +import { dirname, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' -const here = dirname(fileURLToPath(import.meta.url)); -const wasmDir = resolve(here, "..", "wasm"); +const here = dirname(fileURLToPath(import.meta.url)) +const wasmDir = resolve(here, '..', 'wasm') -const wasmBytes = await readFile(resolve(wasmDir, "stack_auth_wasm_bg.wasm")); -const base64 = wasmBytes.toString("base64"); +const wasmBytes = await readFile(resolve(wasmDir, 'stack_auth_wasm_bg.wasm')) +const base64 = wasmBytes.toString('base64') const shim = `/* @ts-self-types="./stack_auth_wasm.d.ts" */ // Generated by scripts/inline-wasm.mjs — do not edit. @@ -30,12 +30,12 @@ instance.exports.__wbindgen_start(); export { AccessKeyStrategy, OidcFederationStrategy, IntoUnderlyingByteSource, IntoUnderlyingSink, IntoUnderlyingSource, module_init } from "./stack_auth_wasm_bg.js"; -`; +` -const outPath = resolve(wasmDir, "stack_auth_wasm_inline.js"); -await writeFile(outPath, shim); +const outPath = resolve(wasmDir, 'stack_auth_wasm_inline.js') +await writeFile(outPath, shim) console.log( `inline-wasm: wrote stack_auth_wasm_inline.js (` + `${wasmBytes.length} wasm bytes -> ${base64.length} b64 chars)`, -); +) diff --git a/languages/typescript/packages/auth/vitest.config.ts b/languages/typescript/packages/auth/vitest.config.ts index 2c01d68b0..b00cfdaf5 100644 --- a/languages/typescript/packages/auth/vitest.config.ts +++ b/languages/typescript/packages/auth/vitest.config.ts @@ -1,7 +1,7 @@ -import { defineConfig } from "vitest/config"; +import { defineConfig } from 'vitest/config' export default defineConfig({ test: { testTimeout: 30_000, }, -}); +}) diff --git a/languages/typescript/packages/profile/__tests__/profile-store.test.ts b/languages/typescript/packages/profile/__tests__/profile-store.test.ts index 97229ca27..3f2fe77d5 100644 --- a/languages/typescript/packages/profile/__tests__/profile-store.test.ts +++ b/languages/typescript/packages/profile/__tests__/profile-store.test.ts @@ -1,158 +1,158 @@ -import { describe, it, expect, beforeEach } from "vitest"; -import { mkdtempSync, mkdirSync, existsSync } from "fs"; -import { join } from "path"; -import { tmpdir } from "os"; -import type { ProfileStore as ProfileStoreType, ProfileError } from "../index"; +import { existsSync, mkdirSync, mkdtempSync } from 'fs' +import { tmpdir } from 'os' +import { join } from 'path' +import { beforeEach, describe, expect, it } from 'vitest' +import type { ProfileError, ProfileStore as ProfileStoreType } from '../index' -const mod = require("../index.js") as typeof import("../index"); -const { ProfileStore } = mod; +const mod = require('../index.js') as typeof import('../index') +const { ProfileStore } = mod // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- -const WS_A = "AAAAAAAAAAAAAAAA"; -const WS_B = "BBBBBBBBBBBBBBBB"; +const WS_A = 'AAAAAAAAAAAAAAAA' +const WS_B = 'BBBBBBBBBBBBBBBB' -let profileDir: string; +let profileDir: string function freshProfileDir(): string { - return mkdtempSync(join(tmpdir(), "cs-profile-test-")); + return mkdtempSync(join(tmpdir(), 'cs-profile-test-')) } function store(): InstanceType<typeof ProfileStoreType> { - return ProfileStore.withDir(profileDir); + return ProfileStore.withDir(profileDir) } // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- -describe("ProfileStore", () => { +describe('ProfileStore', () => { beforeEach(() => { - profileDir = freshProfileDir(); - }); - - describe("resolve", () => { - it("returns a ProfileStore at the default location", () => { - const s = ProfileStore.resolve(); - expect(s.dir).toBeTruthy(); - }); - }); - - describe("withDir", () => { - it("returns a ProfileStore at the given directory", () => { - const s = ProfileStore.withDir("/tmp/custom"); - expect(s.dir).toBe("/tmp/custom"); - }); - }); - - describe("given no workspace set", () => { - it("currentWorkspace throws NO_CURRENT_WORKSPACE", () => { + profileDir = freshProfileDir() + }) + + describe('resolve', () => { + it('returns a ProfileStore at the default location', () => { + const s = ProfileStore.resolve() + expect(s.dir).toBeTruthy() + }) + }) + + describe('withDir', () => { + it('returns a ProfileStore at the given directory', () => { + const s = ProfileStore.withDir('/tmp/custom') + expect(s.dir).toBe('/tmp/custom') + }) + }) + + describe('given no workspace set', () => { + it('currentWorkspace throws NO_CURRENT_WORKSPACE', () => { try { - store().currentWorkspace(); - expect.unreachable("should have thrown"); + store().currentWorkspace() + expect.unreachable('should have thrown') } catch (err) { - const profileErr = err as ProfileError; - expect(profileErr).toBeInstanceOf(Error); - expect(profileErr.code).toBe("NO_CURRENT_WORKSPACE"); + const profileErr = err as ProfileError + expect(profileErr).toBeInstanceOf(Error) + expect(profileErr.code).toBe('NO_CURRENT_WORKSPACE') } - }); + }) - it("currentWorkspaceStore throws NO_CURRENT_WORKSPACE", () => { + it('currentWorkspaceStore throws NO_CURRENT_WORKSPACE', () => { try { - store().currentWorkspaceStore(); - expect.unreachable("should have thrown"); + store().currentWorkspaceStore() + expect.unreachable('should have thrown') } catch (err) { - const profileErr = err as ProfileError; - expect(profileErr.code).toBe("NO_CURRENT_WORKSPACE"); + const profileErr = err as ProfileError + expect(profileErr.code).toBe('NO_CURRENT_WORKSPACE') } - }); + }) - it("listWorkspaces returns empty array", () => { - expect(store().listWorkspaces()).toEqual([]); - }); + it('listWorkspaces returns empty array', () => { + expect(store().listWorkspaces()).toEqual([]) + }) - it("clearCurrentWorkspace succeeds", () => { - expect(() => store().clearCurrentWorkspace()).not.toThrow(); - }); - }); + it('clearCurrentWorkspace succeeds', () => { + expect(() => store().clearCurrentWorkspace()).not.toThrow() + }) + }) - describe("given workspace set", () => { + describe('given workspace set', () => { beforeEach(() => { - mkdirSync(join(profileDir, "workspaces", WS_A), { recursive: true }); - store().setCurrentWorkspace(WS_A); - }); + mkdirSync(join(profileDir, 'workspaces', WS_A), { recursive: true }) + store().setCurrentWorkspace(WS_A) + }) - it("currentWorkspace returns the workspace ID", () => { - expect(store().currentWorkspace()).toBe(WS_A); - }); + it('currentWorkspace returns the workspace ID', () => { + expect(store().currentWorkspace()).toBe(WS_A) + }) - it("currentWorkspaceStore returns a scoped store", () => { - const ws = store().currentWorkspaceStore(); - expect(ws.dir).toBe(join(profileDir, "workspaces", WS_A)); - }); + it('currentWorkspaceStore returns a scoped store', () => { + const ws = store().currentWorkspaceStore() + expect(ws.dir).toBe(join(profileDir, 'workspaces', WS_A)) + }) - it("clearCurrentWorkspace removes the selection", () => { - store().clearCurrentWorkspace(); + it('clearCurrentWorkspace removes the selection', () => { + store().clearCurrentWorkspace() try { - store().currentWorkspace(); - expect.unreachable("should have thrown"); + store().currentWorkspace() + expect.unreachable('should have thrown') } catch (err) { - expect((err as ProfileError).code).toBe("NO_CURRENT_WORKSPACE"); + expect((err as ProfileError).code).toBe('NO_CURRENT_WORKSPACE') } - }); - }); + }) + }) - describe("workspaceStore", () => { - it("returns a store scoped to the workspace directory", () => { - const ws = store().workspaceStore(WS_A); - expect(ws.dir).toBe(join(profileDir, "workspaces", WS_A)); - }); + describe('workspaceStore', () => { + it('returns a store scoped to the workspace directory', () => { + const ws = store().workspaceStore(WS_A) + expect(ws.dir).toBe(join(profileDir, 'workspaces', WS_A)) + }) - it("throws INVALID_WORKSPACE_ID for bad input", () => { + it('throws INVALID_WORKSPACE_ID for bad input', () => { try { - store().workspaceStore("../escape"); - expect.unreachable("should have thrown"); + store().workspaceStore('../escape') + expect.unreachable('should have thrown') } catch (err) { - expect((err as ProfileError).code).toBe("INVALID_WORKSPACE_ID"); + expect((err as ProfileError).code).toBe('INVALID_WORKSPACE_ID') } - }); - }); + }) + }) - describe("setCurrentWorkspace", () => { - it("throws WORKSPACE_NOT_FOUND for workspace without profile data", () => { + describe('setCurrentWorkspace', () => { + it('throws WORKSPACE_NOT_FOUND for workspace without profile data', () => { try { - store().setCurrentWorkspace(WS_A); - expect.unreachable("should have thrown"); + store().setCurrentWorkspace(WS_A) + expect.unreachable('should have thrown') } catch (err) { - expect((err as ProfileError).code).toBe("WORKSPACE_NOT_FOUND"); + expect((err as ProfileError).code).toBe('WORKSPACE_NOT_FOUND') } - }); - }); + }) + }) - describe("given multiple workspaces", () => { + describe('given multiple workspaces', () => { beforeEach(() => { - mkdirSync(join(profileDir, "workspaces", WS_A), { recursive: true }); - mkdirSync(join(profileDir, "workspaces", WS_B), { recursive: true }); - store().setCurrentWorkspace(WS_A); - }); - - it("listWorkspaces returns sorted workspace IDs", () => { - expect(store().listWorkspaces()).toEqual([WS_A, WS_B]); - }); - - it("switching workspace changes currentWorkspaceStore", () => { - const s = store(); - s.setCurrentWorkspace(WS_A); + mkdirSync(join(profileDir, 'workspaces', WS_A), { recursive: true }) + mkdirSync(join(profileDir, 'workspaces', WS_B), { recursive: true }) + store().setCurrentWorkspace(WS_A) + }) + + it('listWorkspaces returns sorted workspace IDs', () => { + expect(store().listWorkspaces()).toEqual([WS_A, WS_B]) + }) + + it('switching workspace changes currentWorkspaceStore', () => { + const s = store() + s.setCurrentWorkspace(WS_A) expect(s.currentWorkspaceStore().dir).toBe( - join(profileDir, "workspaces", WS_A), - ); + join(profileDir, 'workspaces', WS_A), + ) - s.setCurrentWorkspace(WS_B); + s.setCurrentWorkspace(WS_B) expect(s.currentWorkspaceStore().dir).toBe( - join(profileDir, "workspaces", WS_B), - ); - }); - }); -}); + join(profileDir, 'workspaces', WS_B), + ) + }) + }) +}) diff --git a/languages/typescript/packages/profile/examples/workspace-management.ts b/languages/typescript/packages/profile/examples/workspace-management.ts index 5de06f5f4..ae9b9315a 100644 --- a/languages/typescript/packages/profile/examples/workspace-management.ts +++ b/languages/typescript/packages/profile/examples/workspace-management.ts @@ -11,36 +11,36 @@ // Usage: // npx tsx examples/workspace-management.ts -import { ProfileStore } from "../index"; -import type { ProfileError } from "../index"; +import type { ProfileError } from '../index' +import { ProfileStore } from '../index' function main() { - const store = ProfileStore.resolve(); - console.log(`Profile directory: ${store.dir}`); + const store = ProfileStore.resolve() + console.log(`Profile directory: ${store.dir}`) // List workspaces that have local profile data. - const workspaces = store.listWorkspaces(); - console.log(`\nWorkspaces on disk: ${workspaces.length}`); + const workspaces = store.listWorkspaces() + console.log(`\nWorkspaces on disk: ${workspaces.length}`) for (const id of workspaces) { - console.log(` - ${id}`); + console.log(` - ${id}`) } // Show the current workspace (if set). try { - const current = store.currentWorkspace(); - console.log(`\nCurrent workspace: ${current}`); + const current = store.currentWorkspace() + console.log(`\nCurrent workspace: ${current}`) // Get a store scoped to the current workspace. - const wsStore = store.currentWorkspaceStore(); - console.log(`Workspace directory: ${wsStore.dir}`); + const wsStore = store.currentWorkspaceStore() + console.log(`Workspace directory: ${wsStore.dir}`) } catch (err) { - const profileErr = err as ProfileError; - if (profileErr.code === "NO_CURRENT_WORKSPACE") { - console.log("\nNo current workspace set. Run `stash login` first."); + const profileErr = err as ProfileError + if (profileErr.code === 'NO_CURRENT_WORKSPACE') { + console.log('\nNo current workspace set. Run `stash login` first.') } else { - throw err; + throw err } } } -main(); +main() diff --git a/languages/typescript/packages/profile/index.js b/languages/typescript/packages/profile/index.js index 42a0d0111..cc6162e65 100644 --- a/languages/typescript/packages/profile/index.js +++ b/languages/typescript/packages/profile/index.js @@ -1,39 +1,39 @@ -const native = require("./stack-profile-node.js"); +const native = require('./stack-profile-node.js') -const CODE_RE = /^([A-Z_]+): /; +const CODE_RE = /^([A-Z_]+): / function enrichError(err) { if (err instanceof Error) { - const match = CODE_RE.exec(err.message); + const match = CODE_RE.exec(err.message) if (match) { - err.code = match[1]; - err.message = err.message.slice(match[0].length); + err.code = match[1] + err.message = err.message.slice(match[0].length) } } - throw err; + throw err } function wrapSync(fn) { return function (...args) { try { - return fn.apply(this, args); + return fn.apply(this, args) } catch (err) { - enrichError(err); + enrichError(err) } - }; + } } // Wrap ProfileStore methods that can throw -const proto = native.ProfileStore.prototype; -proto.setCurrentWorkspace = wrapSync(proto.setCurrentWorkspace); -proto.currentWorkspace = wrapSync(proto.currentWorkspace); -proto.clearCurrentWorkspace = wrapSync(proto.clearCurrentWorkspace); -proto.listWorkspaces = wrapSync(proto.listWorkspaces); -proto.workspaceStore = wrapSync(proto.workspaceStore); -proto.currentWorkspaceStore = wrapSync(proto.currentWorkspaceStore); +const proto = native.ProfileStore.prototype +proto.setCurrentWorkspace = wrapSync(proto.setCurrentWorkspace) +proto.currentWorkspace = wrapSync(proto.currentWorkspace) +proto.clearCurrentWorkspace = wrapSync(proto.clearCurrentWorkspace) +proto.listWorkspaces = wrapSync(proto.listWorkspaces) +proto.workspaceStore = wrapSync(proto.workspaceStore) +proto.currentWorkspaceStore = wrapSync(proto.currentWorkspaceStore) // Wrap factory methods -const origResolve = native.ProfileStore.resolve; -native.ProfileStore.resolve = wrapSync(origResolve); +const origResolve = native.ProfileStore.resolve +native.ProfileStore.resolve = wrapSync(origResolve) -module.exports = native; +module.exports = native diff --git a/languages/typescript/packages/profile/stack-profile-node.js b/languages/typescript/packages/profile/stack-profile-node.js index 794f3b5a2..704b9d89b 100644 --- a/languages/typescript/packages/profile/stack-profile-node.js +++ b/languages/typescript/packages/profile/stack-profile-node.js @@ -1,66 +1,64 @@ -const { platform, arch } = process; +const { platform, arch } = process function isMusl() { try { const report = - typeof process.report?.getReport === "function" + typeof process.report?.getReport === 'function' ? process.report.getReport() - : null; - if (report && typeof report === "object" && report.sharedObjects) { - return report.sharedObjects.some((s) => s.includes("musl")); + : null + if (report && typeof report === 'object' && report.sharedObjects) { + return report.sharedObjects.some((s) => s.includes('musl')) } } catch (_) {} try { - const { execSync } = require("node:child_process"); - return execSync("ldd --version 2>&1", { encoding: "utf8" }).includes( - "musl", - ); + const { execSync } = require('node:child_process') + return execSync('ldd --version 2>&1', { encoding: 'utf8' }).includes('musl') } catch (_) { - return false; + return false } } const platforms = { - "darwin-x64": "@cipherstash/profile-darwin-x64", - "darwin-arm64": "@cipherstash/profile-darwin-arm64", - "linux-x64-gnu": "@cipherstash/profile-linux-x64-gnu", - "linux-x64-musl": "@cipherstash/profile-linux-x64-musl", - "linux-arm64-gnu": "@cipherstash/profile-linux-arm64-gnu", - "win32-x64-msvc": "@cipherstash/profile-win32-x64-msvc", -}; + 'darwin-x64': '@cipherstash/profile-darwin-x64', + 'darwin-arm64': '@cipherstash/profile-darwin-arm64', + 'linux-x64-gnu': '@cipherstash/profile-linux-x64-gnu', + 'linux-x64-musl': '@cipherstash/profile-linux-x64-musl', + 'linux-arm64-gnu': '@cipherstash/profile-linux-arm64-gnu', + 'win32-x64-msvc': '@cipherstash/profile-win32-x64-msvc', +} function loadBinding() { - let key = `${platform}-${arch}`; + let key = `${platform}-${arch}` - if (platform === "linux") { - key += isMusl() ? "-musl" : "-gnu"; - } else if (platform === "win32") { - key += "-msvc"; + if (platform === 'linux') { + key += isMusl() ? '-musl' : '-gnu' + } else if (platform === 'win32') { + key += '-msvc' } - const pkg = platforms[key]; + const pkg = platforms[key] if (!pkg) { throw new Error( `Unsupported platform: ${platform}-${arch}. ` + - `@cipherstash/profile supports: ${Object.keys(platforms).join(", ")}`, - ); + `@cipherstash/profile supports: ${Object.keys(platforms).join(', ')}`, + ) } // Prefer local .node binary (development / napi build) try { - return require("./stack-profile-node.node"); + return require('./stack-profile-node.node') } catch (_) {} // Fall back to platform-specific optional dependency try { - return require(pkg); + return require(pkg) } catch (_) {} throw new Error( `Failed to load native binding for ${platform}-${arch}. ` + `Ensure the optional dependency "${pkg}" is installed, ` + `or run "napi build" for local development.`, - ); + ) } -module.exports = loadBinding(); +module.exports = loadBinding() diff --git a/languages/typescript/packages/profile/vitest.config.ts b/languages/typescript/packages/profile/vitest.config.ts index 2c01d68b0..b00cfdaf5 100644 --- a/languages/typescript/packages/profile/vitest.config.ts +++ b/languages/typescript/packages/profile/vitest.config.ts @@ -1,7 +1,7 @@ -import { defineConfig } from "vitest/config"; +import { defineConfig } from 'vitest/config' export default defineConfig({ test: { testTimeout: 30_000, }, -}); +}) diff --git a/scripts/check-auth-npm-changeset.mjs b/scripts/check-auth-npm-changeset.mjs index ba24e51df..97a87e699 100644 --- a/scripts/check-auth-npm-changeset.mjs +++ b/scripts/check-auth-npm-changeset.mjs @@ -1,57 +1,68 @@ -import fs from "node:fs"; -import parseChangeset from "@changesets/parse"; +import fs from 'node:fs' +import parseChangeset from '@changesets/parse' -const changesetFiles = process.argv.slice(2).filter(Boolean); +const changesetFiles = process.argv.slice(2).filter(Boolean) if (changesetFiles.length === 0) { console.error( "::error::Release-relevant stack-auth changes require an @cipherstash/auth changeset. Run 'npx changeset' and commit the generated file.", - ); - process.exit(1); + ) + process.exit(1) } -let hasAuthRelease = false; +let hasAuthRelease = false for (const changesetFile of changesetFiles) { - const contents = fs.readFileSync(changesetFile, "utf8"); - const lines = contents.split(/\r?\n/); - const closingDelimiter = lines.indexOf("---", 1); + const contents = fs.readFileSync(changesetFile, 'utf8') + const lines = contents.split(/\r?\n/) + const closingDelimiter = lines.indexOf('---', 1) - if (lines[0] !== "---" || closingDelimiter === -1) { + if (lines[0] !== '---' || closingDelimiter === -1) { console.error( `::error file=${changesetFile}::Changeset must start with YAML frontmatter delimited by ---`, - ); - process.exit(1); + ) + process.exit(1) } - if (lines.slice(closingDelimiter + 1).join("\n").trim().length === 0) { - console.error(`::error file=${changesetFile}::Changeset summary must not be empty`); - process.exit(1); + if ( + lines + .slice(closingDelimiter + 1) + .join('\n') + .trim().length === 0 + ) { + console.error( + `::error file=${changesetFile}::Changeset summary must not be empty`, + ) + process.exit(1) } - let changeset; + let changeset try { - changeset = parseChangeset(contents); + changeset = parseChangeset(contents) } catch (error) { - console.error(`::error file=${changesetFile}::Invalid changeset: ${error.message}`); - process.exit(1); + console.error( + `::error file=${changesetFile}::Invalid changeset: ${error.message}`, + ) + process.exit(1) } if ( changeset.releases.some( ({ name, type }) => - name === "@cipherstash/auth" && - (type === "patch" || type === "minor" || type === "major"), + name === '@cipherstash/auth' && + (type === 'patch' || type === 'minor' || type === 'major'), ) ) { - hasAuthRelease = true; - console.log(`Found valid @cipherstash/auth release intent in ${changesetFile}`); + hasAuthRelease = true + console.log( + `Found valid @cipherstash/auth release intent in ${changesetFile}`, + ) } } if (!hasAuthRelease) { console.error( "::error::Release-relevant stack-auth changes require an @cipherstash/auth changeset with a patch, minor, or major bump. Run 'npx changeset' and commit the generated file.", - ); - process.exit(1); + ) + process.exit(1) } From d94d6a77cd18a13ac2b56e48a54b6fb0ad4c74a4 Mon Sep 17 00:00:00 2001 From: Lindsay Holmwood <lindsay@holmwood.id.au> Date: Fri, 2 Oct 2026 09:22:54 +1000 Subject: [PATCH 675/686] fix: guard two optional chains in the auth binding tests The root Biome config lints the imported tests, which the suite's nested config did not (its linter was off). `pnpm run code:check` then failed on two `lint/correctness/noUnsafeOptionalChaining` errors: a cast of `failure?.error` followed by a plain `.help`. Carry the `undefined` into the cast and chain the property access, so a missing failure fails the assertion instead of throwing a TypeError. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PaY5xYydZUWhv8Nex9Sw8a --- .../auth/__tests__/oidc-federation-strategy.test.ts | 6 +++--- .../packages/auth/__tests__/wasm-inline-result.test.ts | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/languages/typescript/packages/auth/__tests__/oidc-federation-strategy.test.ts b/languages/typescript/packages/auth/__tests__/oidc-federation-strategy.test.ts index 194324888..1470d9a0d 100644 --- a/languages/typescript/packages/auth/__tests__/oidc-federation-strategy.test.ts +++ b/languages/typescript/packages/auth/__tests__/oidc-federation-strategy.test.ts @@ -250,9 +250,9 @@ describe('OidcFederationStrategy (TypeScript / vitest)', () => { expect(cr.failure?.help).toMatch(/crn:<region>:<workspace-id>/) // The same help is mirrored onto the live Error for loggers that only // see the error object. - expect((cr.failure?.error as Error & { help?: string }).help).toBe( - cr.failure?.help, - ) + expect( + (cr.failure?.error as (Error & { help?: string }) | undefined)?.help, + ).toBe(cr.failure?.help) }) it('rejects a CRN whose workspace segment is malformed with .type', () => { diff --git a/languages/typescript/packages/auth/__tests__/wasm-inline-result.test.ts b/languages/typescript/packages/auth/__tests__/wasm-inline-result.test.ts index a0dcaa3b5..35c5fca6d 100644 --- a/languages/typescript/packages/auth/__tests__/wasm-inline-result.test.ts +++ b/languages/typescript/packages/auth/__tests__/wasm-inline-result.test.ts @@ -33,9 +33,9 @@ describe.skipIf(!existsSync(WASM_SHIM))('wasm-inline Result wrapper', () => { expect(r.failure?.help).toMatch(/crn:<region>:<workspace-id>/) // ...and be mirrored onto the live Error, matching the napi seam, so loggers // that only see `failure.error` still get the hint. - expect((r.failure?.error as Error & { help?: string }).help).toBe( - r.failure?.help, - ) + expect( + (r.failure?.error as (Error & { help?: string }) | undefined)?.help, + ).toBe(r.failure?.help) }) it("strips the envelope's message field instead of spreading it", async () => { From 2371f7319d468b363a43baa2a31b6f78abb4ac66 Mon Sep 17 00:00:00 2001 From: Lindsay Holmwood <lindsay@holmwood.id.au> Date: Fri, 2 Oct 2026 09:25:26 +1000 Subject: [PATCH 676/686] build: make the repository root the Rust and Go workspace root MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plan §7.4, the workspace commit. The freeze and Dependabot follow in their own commit. Cargo: - Root Cargo.toml: resolver 2; the six stack-* crates and the three node binding crates as members; EQL, protect-ffi, the three fuzz crates and the two Go guests excluded, each its own workspace. - [workspace.package] with repository = cipherstash/stack, and version 0.35.0 for the three node binding crates that use version.workspace. - [workspace.dependencies]: the Phase 0 crates.io releases of the suite crates, pinned exactly (cts-common =0.43.0, cllw-ore =0.5.0, recipher =0.3.1, zerokms-protocol =0.12.31), the external crates with the suite's feature lists, and vitaminc* at 0.5.0. stack-auth and stack-profile are also listed, by path: members take them with `workspace = true`. - [profile.release] and [profile.dev] incremental, from the suite. - Cargo.lock seeded from the suite lock at f161a447f. The four suite crates were path packages there, so `cargo update -p` cannot name them; `cargo metadata` re-resolved only what changed. It added the four registry crates and cipherstash-config 0.42.3, which zerokms-protocol pulls, and changed nothing else. - Cargo.lock for all three fuzz crates, seeded from the root lock and pruned by `cargo metadata`; the suite gitignored them. The two guest locks are refreshed for the registry dependencies of the cleanup commit. `cargo metadata --locked` passes in all six workspaces. - .cargo/config.toml with the wasm32 getrandom rustflags, and .config/nextest.toml with profile.ci. - .gitignore: the fuzz crates' artifacts, coverage and grown corpus, mutants.out/, and the Go guests' built .wasm and .sha256 files. mise: root mise.toml (rust 1.94.1 with the wasm targets, the cargo tools, go 1.26 and golangci-lint for go:lint, wasm-pack for build:wasm, the five tasks.toml includes, and the wasm:* and go:* tasks repathed to languages/golang) and mise.test.toml. The node binding integration tasks now run in their new folders, copy from ../../../../target/debug, drop their `npm install` (which fails on the workspace:* peers; the root `pnpm install` covers it) and run `pnpm exec vitest run`. JavaScript: - pnpm-workspace.yaml lists the auth and profile platform folders. The bindings themselves are already selected by the languages/typescript/packages/* glob. - @cipherstash/auth: platform peers at workspace:*, and each platform package at 0.44.0, the version on npm; the suite's publish workflow rewrote both at publish. Scripts split so `build` and `test` never run cargo: build:native, build:debug, test (vitest and Biome) and test:cargo. build:wasm repathed to the stack-auth-wasm folder. The npx Biome 2.3.4 format scripts, tied to the deleted nested config, go. - @cipherstash/profile and @cipherstash/stack-auth-wasm split the same way; profile's platform optionalDependencies move to workspace:*. - turbo.json declares @cipherstash/auth#build:native outputs. - pnpm-lock.yaml gains the new importers and their dependencies. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PaY5xYydZUWhv8Nex9Sw8a --- .cargo/config.toml | 7 + .config/nextest.toml | 4 + .gitignore | 19 + Cargo.lock | 5127 +++++++++++++++++ Cargo.toml | 126 + languages/golang/stackauth/guest/Cargo.lock | 10 +- .../golang/stackencrypt/guest/Cargo.lock | 18 +- .../typescript/packages/auth/package.json | 21 +- .../auth/platforms/darwin-arm64/package.json | 2 +- .../auth/platforms/darwin-x64/package.json | 2 +- .../platforms/linux-arm64-gnu/package.json | 2 +- .../auth/platforms/linux-x64-gnu/package.json | 2 +- .../platforms/linux-x64-musl/package.json | 2 +- .../platforms/win32-x64-msvc/package.json | 2 +- .../typescript/packages/profile/package.json | 19 +- .../packages/stack-auth-wasm/package.json | 4 +- mise.test.toml | 19 + mise.toml | 299 + packages/stack-auth/fuzz/Cargo.lock | 4075 +++++++++++++ packages/stack-auth/tasks.toml | 7 +- packages/stack-encrypt/fuzz/Cargo.lock | 3236 +++++++++++ packages/stack-kms/fuzz/Cargo.lock | 3088 ++++++++++ packages/stack-profile/tasks.toml | 7 +- pnpm-lock.yaml | 341 +- pnpm-workspace.yaml | 6 + turbo.json | 9 + 26 files changed, 16410 insertions(+), 44 deletions(-) create mode 100644 .cargo/config.toml create mode 100644 .config/nextest.toml create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 mise.test.toml create mode 100644 mise.toml create mode 100644 packages/stack-auth/fuzz/Cargo.lock create mode 100644 packages/stack-encrypt/fuzz/Cargo.lock create mode 100644 packages/stack-kms/fuzz/Cargo.lock diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 000000000..ade62e0db --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,7 @@ +# Wasm target needs the `wasm_js` getrandom backend (used by deps that pull +# `getrandom >= 0.3`, e.g. `rand 0.9+` via `vitaminc-random`). Without this +# rustflag, those crates fail to compile on wasm32-unknown-unknown — the +# stack-auth-wasm build (`languages/typescript/packages/stack-auth-wasm`). +# See: https://docs.rs/getrandom/latest/getrandom/#opt-in-backends +[target.wasm32-unknown-unknown] +rustflags = ['--cfg', 'getrandom_backend="wasm_js"'] diff --git a/.config/nextest.toml b/.config/nextest.toml new file mode 100644 index 000000000..130ee30f0 --- /dev/null +++ b/.config/nextest.toml @@ -0,0 +1,4 @@ +# Workflows set NEXTEST_PROFILE=ci. +[profile.ci] +# Do not cancel the test run on the first failure. +fail-fast = false diff --git a/.gitignore b/.gitignore index 9a2898f58..2a23c7916 100644 --- a/.gitignore +++ b/.gitignore @@ -91,3 +91,22 @@ sql/cipherstash-*.sql .cipherstash/ notes/ + +# Rust crates imported from cipherstash-suite (packages/stack-*). +# cargo-fuzz: crash artifacts, coverage data, and the corpus that grows during +# a campaign. Ignore the generated corpus entries but keep the hand-written seed +# corpora (committed as `valid-*`) tracked. The fuzz crates' Cargo.lock files +# are tracked here, unlike in the suite, so `--locked` and Dependabot see them. +packages/*/fuzz/artifacts/ +packages/*/fuzz/coverage/ +packages/*/fuzz/corpus/*/* +!packages/*/fuzz/corpus/*/valid-* +# cargo-mutants output (`mise run mutants:<crate>` writes it to the cwd). +mutants.out/ + +# The Go module's embedded WASI guests: build outputs of `mise run +# wasm:guest:build` and `mise run wasm:auth-guest:build`. +languages/golang/stackencrypt/wasm/*.wasm +languages/golang/stackauth/wasm/*.wasm +languages/golang/stackencrypt/wasm/*.sha256 +languages/golang/stackauth/wasm/*.sha256 diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 000000000..e5eb6615d --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,5127 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common 0.1.7", + "generic-array", +] + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures 0.2.17", +] + +[[package]] +name = "aes-gcm" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" +dependencies = [ + "aead", + "aes", + "cipher", + "ctr", + "ghash", + "subtle", + "zeroize", +] + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "alloc-no-stdlib" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" + +[[package]] +name = "alloc-stdlib" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94fb8275041c72129eb51b7d0322c29b8387a0386127718b096429201a5d6ece" +dependencies = [ + "alloc-no-stdlib", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anyhow" +version = "1.0.101" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f0e0fee31ef5ed1ba1316088939cea399010ed7731dba877ed44aeb407a75ea" + +[[package]] +name = "aquamarine" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f50776554130342de4836ba542aa85a4ddb361690d7e8df13774d7284c3d5c2" +dependencies = [ + "include_dir", + "itertools 0.10.5", + "proc-macro-error2", + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "arrayref" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" + +[[package]] +name = "arrayvec" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" +dependencies = [ + "serde", + "zeroize", +] + +[[package]] +name = "async-compression" +version = "0.4.39" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68650b7df54f0293fd061972a0fb05aaf4fc0879d3b3d21a638a182c5c543b9f" +dependencies = [ + "compression-codecs", + "compression-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "async-mutex" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73112ce9e1059d8604242af62c7ec8e5975ac58ac251686c8403b45e8a6fe778" +dependencies = [ + "event-listener", +] + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "atomic" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89cbf775b137e9b968e67227ef7f775587cde3fd31b0d8599dbd0f598a48340" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "aws-lc-rs" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce2b2dcc879c3bae0d371e77c99f2238400ef24ec001394befa67b6e543add9e" +dependencies = [ + "aws-lc-sys", + "untrusted 0.7.1", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.44.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f09fae7be8bb3174e05c6afdb34199e6dc0c7c04ba9fa237b1967adfbde27483" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", + "pkg-config", +] + +[[package]] +name = "axum" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b52af3cb4058c895d37317bb27508dccc8e5f2d39454016b297bf4a400597b8" +dependencies = [ + "axum-core", + "bytes", + "form_urlencoded", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + +[[package]] +name = "base32" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "022dfe9eb35f19ebbcb51e0b40a5ab759f46ad60cadf7297e0bd085afb50e076" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + +[[package]] +name = "bitflags" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" + +[[package]] +name = "bitvec" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bc2832c24239b0141d5674bb9174f9d68a8b5b3f2753311927c172ca46f7e9c" +dependencies = [ + "funty", + "radium", + "tap", + "wyz", +] + +[[package]] +name = "blake3" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2468ef7d57b3fb7e16b576e8377cdbde2320c60e1491e961d11da40fc4f02a2d" +dependencies = [ + "arrayref", + "arrayvec", + "cc", + "cfg-if", + "constant_time_eq", + "cpufeatures 0.2.17", + "zeroize", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block-buffer" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdd35008169921d80bc60d3d0ab416eecb028c4cd653352907921d95084790be" +dependencies = [ + "hybrid-array", + "zeroize", +] + +[[package]] +name = "brotli" +version = "8.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bd8b9603c7aa97359dbd97ecf258968c95f3adddd6db2f7e7a5bef101c84560" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", + "brotli-decompressor", +] + +[[package]] +name = "brotli-decompressor" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "874bb8112abecc98cbd6d81ea4fa7e94fb9449648c93cc89aa40c81c24d7de03" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", +] + +[[package]] +name = "bumpalo" +version = "3.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" + +[[package]] +name = "bytemuck" +version = "1.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +dependencies = [ + "serde", +] + +[[package]] +name = "cached" +version = "0.54.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9718806c4a2fe9e8a56fd736f97b340dd10ed1be8ed733ed50449f351dc33cae" +dependencies = [ + "ahash", + "cached_proc_macro", + "cached_proc_macro_types", + "hashbrown 0.14.5", + "once_cell", + "thiserror 1.0.69", + "web-time", +] + +[[package]] +name = "cached_proc_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f42a145ed2d10dce2191e1dcf30cfccfea9026660e143662ba5eec4017d5daa" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "cached_proc_macro_types" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ade8366b8bd5ba243f0a58f036cc0ca8a2f069cff1a2351ef1cac6b083e16fc0" + +[[package]] +name = "cast" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" + +[[package]] +name = "cc" +version = "1.2.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b26a0954ae34af09b50f0de26458fa95369a0d478d8236d3f93082b219bd29" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "chacha20" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core 0.10.0", + "zeroize", +] + +[[package]] +name = "chrono" +version = "0.4.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fac4744fb15ae8337dc853fee7fb3f4e48c0fbaa23d0afe49c447b4fab126118" +dependencies = [ + "iana-time-zone", + "num-traits", + "serde", + "windows-link", +] + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common 0.1.7", + "inout", +] + +[[package]] +name = "cipherstash-config" +version = "0.42.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d098935e395d7346d0cdc8cdf3ed9674ab03fa8b415e828d02e65c81836a73c" +dependencies = [ + "bitflags", + "serde", + "serde_json", + "thiserror 1.0.69", +] + +[[package]] +name = "cllw-ore" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "476f300d37a5029d3d9dd57145d4db50a23f40ae9b4d1374c44543978b906191" +dependencies = [ + "blake3", + "hex", + "subtle", + "thiserror 1.0.69", + "unicode-normalization", + "zeroize", +] + +[[package]] +name = "cmac" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8543454e3c3f5126effff9cd44d562af4e31fb8ce1cc0d3dcd8f084515dbc1aa" +dependencies = [ + "cipher", + "dbl", + "digest 0.10.7", +] + +[[package]] +name = "cmake" +version = "0.1.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75443c44cd6b379beb8c5b45d85d0773baf31cce901fe7bb252f4eff3008ef7d" +dependencies = [ + "cc", +] + +[[package]] +name = "cmov" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "compression-codecs" +version = "0.4.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00828ba6fd27b45a448e57dbfe84f1029d4c9f26b368157e9a448a5f49a2ec2a" +dependencies = [ + "brotli", + "compression-core", + "flate2", + "memchr", +] + +[[package]] +name = "compression-core" +version = "0.4.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75984efb6ed102a0d42db99afb6c1948f0380d1d91808d5529916e6c08b49d8d" + +[[package]] +name = "console_error_panic_hook" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a06aeb73f470f66dcdbf7223caeebb85984942f22f1adb2a088cf9668146bbbc" +dependencies = [ + "cfg-if", + "wasm-bindgen", +] + +[[package]] +name = "const-hex" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3bb320cac8a0750d7f25280aa97b09c26edfe161164238ecbbb31092b079e735" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "proptest", + "serde_core", +] + +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + +[[package]] +name = "constant_time_eq" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" + +[[package]] +name = "convert_case" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec182b0ca2f35d8fc196cf3404988fd8b8c739a4d270ff118a398feb0cbec1ca" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + +[[package]] +name = "crossbeam-channel" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "crypto-common" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77727bb15fa921304124b128af125e7e3b968275d1b108b379190264f4423710" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "ctor" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a2785755761f3ddc1492979ce1e48d2c00d09311c39e4466429188f3dd6501" +dependencies = [ + "quote", + "syn 2.0.114", +] + +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher", +] + +[[package]] +name = "cts-common" +version = "0.43.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9cb0f5ffa463e8facbe6ad78cfe925d132a051c6b1c9a5da2f3961296b7e632" +dependencies = [ + "arrayvec", + "base32", + "cached", + "chrono", + "derive_more", + "either", + "getrandom 0.4.2", + "miette", + "nom", + "regex", + "serde", + "serde_json", + "thiserror 1.0.69", + "tracing", + "url", + "utoipa", + "uuid", + "vitaminc", +] + +[[package]] +name = "ctutils" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" +dependencies = [ + "cmov", +] + +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.114", +] + +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "data-encoding" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" + +[[package]] +name = "dbl" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd2735a791158376708f9347fe8faba9667589d82427ef3aed6794a8981de3d9" +dependencies = [ + "generic-array", +] + +[[package]] +name = "deranged" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ececcb659e7ba858fb4f10388c250a7252eb0a27373f1a72b8748afdd248e587" +dependencies = [ + "powerfmt", +] + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "convert_case 0.10.0", + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.114", + "unicode-xid", +] + +[[package]] +name = "deunicode" +version = "1.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abd57806937c9cc163efc8ea3910e00a62e2aeb0b8119f1793a978088f8f6b04" + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer 0.10.4", + "crypto-common 0.1.7", + "subtle", +] + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.0", + "const-oid", + "crypto-common 0.2.1", + "ctutils", + "zeroize", +] + +[[package]] +name = "dirs" +version = "4.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca3aa72a6f96ea37bbc5aa912f6788242832f75369bdfdadcb0e38423f100059" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b1d1d91c932ef41c0f2663aa8b0ca0342d444d842c06914aa0a7e352d0bada6" +dependencies = [ + "libc", + "redox_users", + "winapi", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "dummy" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1cac124e13ae9aa56acc4241f8c8207501d93afdd8d8e62f0c1f2e12f6508c65" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +dependencies = [ + "serde", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "event-listener" +version = "2.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0206175f82b8d6bf6652ff7d71a1e27fd2e4efde587fd368662814d6ec1d9ce0" + +[[package]] +name = "fake" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d391ba4af7f1d93f01fcf7b2f29e2bc9348e109dfdbf4dcbdc51dfa38dab0b6" +dependencies = [ + "deunicode", + "dummy", + "rand 0.8.6", + "uuid", +] + +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + +[[package]] +name = "funty" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" + +[[package]] +name = "futures" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" + +[[package]] +name = "futures-executor" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" + +[[package]] +name = "futures-macro" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "futures-sink" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" + +[[package]] +name = "futures-task" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" + +[[package]] +name = "futures-util" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "pin-utils", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "gethostname" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc3655aa6818d65bc620d6911f05aa7b6aeb596291e1e9f79e52df85583d1e30" +dependencies = [ + "rustix 0.38.44", + "windows-targets 0.52.6", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 6.0.0", + "rand_core 0.10.0", + "wasip2", + "wasip3", + "wasm-bindgen", +] + +[[package]] +name = "ghash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" +dependencies = [ + "opaque-debug", + "polyval", +] + +[[package]] +name = "glob" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" + +[[package]] +name = "h2" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "half" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b43ede17f21864e81be2fa654110bf1e793774238d86ef8555c37e6519c0403" + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash", + "allocator-api2", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hex-literal" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ebdb29d2ea9ed0083cd8cece49bbd968021bd99b0849edb4a9a7ee0fdf6a4e0" + +[[package]] +name = "hickory-net" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2295ed2f9c31e471e1428a8f88a3f0e1f4b27c15049592138d1eebe9c35b183" +dependencies = [ + "async-trait", + "cfg-if", + "data-encoding", + "futures-channel", + "futures-io", + "futures-util", + "hickory-proto", + "idna", + "ipnet", + "jni 0.22.4", + "rand 0.10.1", + "thiserror 2.0.18", + "tinyvec", + "tokio", + "tracing", + "url", +] + +[[package]] +name = "hickory-proto" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bab31817bfb44672a252e97fe81cd0c18d1b2cf892108922f6818820df8c643" +dependencies = [ + "data-encoding", + "idna", + "ipnet", + "jni 0.22.4", + "once_cell", + "prefix-trie", + "rand 0.10.1", + "ring", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "url", +] + +[[package]] +name = "hickory-resolver" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d58d28879ceecde6607729660c2667a081ccdc082e082675042793960f178c" +dependencies = [ + "cfg-if", + "futures-util", + "hickory-net", + "hickory-proto", + "ipconfig", + "ipnet", + "jni 0.22.4", + "moka", + "ndk-context", + "once_cell", + "parking_lot", + "rand 0.10.1", + "resolv-conf", + "smallvec", + "system-configuration", + "thiserror 2.0.18", + "tokio", + "tracing", +] + +[[package]] +name = "hmac" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" +dependencies = [ + "digest 0.11.3", +] + +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hybrid-array" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3944cf8cf766b40e2a1a333ee5e9b563f854d5fa49d6a8ca2764e97c6eddb214" +dependencies = [ + "typenum", +] + +[[package]] +name = "hyper" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "pin-utils", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2 0.6.2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +dependencies = [ + "displaydoc", + "potential_utf", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" + +[[package]] +name = "icu_properties" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" + +[[package]] +name = "icu_provider" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "include_dir" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "923d117408f1e49d914f1a379a309cffe4f18c05cf4e3d12e613a15fc81bd0dd" +dependencies = [ + "include_dir_macros", +] + +[[package]] +name = "include_dir_macros" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cab85a7ed0bd5f0e76d93846e0147172bed2e2d3f859bcc33a8d9699cad1a75" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "indexmap" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +dependencies = [ + "equivalent", + "hashbrown 0.16.1", + "serde", + "serde_core", +] + +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + +[[package]] +name = "ipconfig" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b58db92f96b720de98181bbbe63c831e87005ab460c1bf306eb2622b4707997f" +dependencies = [ + "socket2 0.5.10", + "widestring", + "windows-sys 0.48.0", + "winreg", +] + +[[package]] +name = "ipnet" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" +dependencies = [ + "serde", +] + +[[package]] +name = "iri-string" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c91338f0783edbd6195decb37bae672fd3b165faffb89bf7b9e6942f8b1a731a" +dependencies = [ + "memchr", + "serde", +] + +[[package]] +name = "is-docker" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3" +dependencies = [ + "once_cell", +] + +[[package]] +name = "is-wsl" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5" +dependencies = [ + "is-docker", + "once_cell", +] + +[[package]] +name = "itertools" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" +dependencies = [ + "either", +] + +[[package]] +name = "itertools" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" + +[[package]] +name = "jni" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +dependencies = [ + "cesu8", + "cfg-if", + "combine", + "jni-sys 0.3.0", + "log", + "thiserror 1.0.69", + "walkdir", + "windows-sys 0.45.0", +] + +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys 0.4.1", + "log", + "simd_cesu8", + "thiserror 2.0.18", + "walkdir", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.114", +] + +[[package]] +name = "jni-sys" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130" + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.114", +] + +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.85" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c942ebf8e95485ca0d52d97da7c5a2c387d0e7f0ba4c35e93bfcaee045955b3" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "jsonwebtoken" +version = "10.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eba32bfb4ffdeaca3e34431072faf01745c9b26d25504aa7a6cf5684334fc4fc" +dependencies = [ + "aws-lc-rs", + "base64", + "getrandom 0.2.17", + "js-sys", + "pem", + "serde", + "serde_json", + "signature", + "simple_asn1", + "zeroize", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libc" +version = "0.2.180" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc" + +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link", +] + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "libredox" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d0b95e02c851351f877147b7deea7b1afb1df71b63aa5f8270716e0c5720616" +dependencies = [ + "bitflags", + "libc", +] + +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + +[[package]] +name = "linux-raw-sys" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" + +[[package]] +name = "litemap" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest 0.10.7", +] + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "miette" +version = "7.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f98efec8807c63c752b5bd61f862c165c115b0a35685bdcfd9238c7aeb592b7" +dependencies = [ + "cfg-if", + "miette-derive", + "unicode-width", +] + +[[package]] +name = "miette-derive" +version = "7.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db5b29714e950dbb20d5e6f74f9dcec4edbcc1067bb7f8ed198c097b8c1a818b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "minicov" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4869b6a491569605d66d3952bcdf03df789e5b536e5f0cf7758a7f08a55ae24d" +dependencies = [ + "cc", + "walkdir", +] + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "mocktail" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053f7ba52863e22dfd2970075bbc69c4224ca6ae03896a5f69a0d5982deb5e0a" +dependencies = [ + "bytes", + "futures", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "prost", + "rand 0.9.3", + "serde", + "serde_json", + "thiserror 2.0.18", + "tokio", + "tokio-stream", + "tracing", + "url", + "uuid", +] + +[[package]] +name = "moka" +version = "0.12.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac832c50ced444ef6be0767a008b02c106a909ba79d1d830501e94b96f6b7e" +dependencies = [ + "crossbeam-channel", + "crossbeam-epoch", + "crossbeam-utils", + "equivalent", + "parking_lot", + "portable-atomic", + "smallvec", + "tagptr", + "uuid", +] + +[[package]] +name = "mutants" +version = "0.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "add0ac067452ff1aca8c5002111bd6b1c895baee6e45fcbc44e0193aea17be56" + +[[package]] +name = "napi" +version = "2.16.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55740c4ae1d8696773c78fdafd5d0e5fe9bc9f1b071c7ba493ba5c413a9184f3" +dependencies = [ + "bitflags", + "ctor", + "napi-derive", + "napi-sys", + "once_cell", + "tokio", +] + +[[package]] +name = "napi-build" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d376940fd5b723c6893cd1ee3f33abbfd86acb1cd1ec079f3ab04a2a3bc4d3b1" + +[[package]] +name = "napi-derive" +version = "2.16.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cbe2585d8ac223f7d34f13701434b9d5f4eb9c332cccce8dee57ea18ab8ab0c" +dependencies = [ + "cfg-if", + "convert_case 0.6.0", + "napi-derive-backend", + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "napi-derive-backend" +version = "1.0.75" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1639aaa9eeb76e91c6ae66da8ce3e89e921cd3885e99ec85f4abacae72fc91bf" +dependencies = [ + "convert_case 0.6.0", + "once_cell", + "proc-macro2", + "quote", + "regex", + "semver", + "syn 2.0.114", +] + +[[package]] +name = "napi-sys" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "427802e8ec3a734331fec1035594a210ce1ff4dc5bc1950530920ab717964ea3" +dependencies = [ + "libloading", +] + +[[package]] +name = "ndk-context" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" + +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-conv" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +dependencies = [ + "critical-section", + "portable-atomic", +] + +[[package]] +name = "oorandom" +version = "11.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" + +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + +[[package]] +name = "open" +version = "5.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43bb73a7fa3799b198970490a51174027ba0d4ec504b03cd08caf513d40024bc" +dependencies = [ + "is-wsl", + "libc", + "pathdiff", +] + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "pathdiff" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" + +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64", + "serde_core", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "pkg-config" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" + +[[package]] +name = "polyval" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "opaque-debug", + "universal-hash", +] + +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + +[[package]] +name = "potential_utf" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "prefix-trie" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cf6e3177f0684016a5c209b00882e15f8bdd3f3bb48f0491df10cd102d0c6e7" +dependencies = [ + "either", + "ipnet", + "num-traits", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.114", +] + +[[package]] +name = "proc-macro-error-attr2" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "proc-macro-error2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802" +dependencies = [ + "proc-macro-error-attr2", + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "proptest" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37566cb3fdacef14c0737f9546df7cfeadbfbc9fef10991038bf5015d0c80532" +dependencies = [ + "bit-set", + "bit-vec", + "bitflags", + "num-traits", + "rand 0.9.3", + "rand_chacha 0.9.0", + "rand_xorshift", + "regex-syntax", + "rusty-fork", + "tempfile", + "unarray", +] + +[[package]] +name = "prost" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2796faa41db3ec313a31f7624d9286acf277b52de526150b7e69f3debf891ee5" +dependencies = [ + "bytes", + "prost-derive", +] + +[[package]] +name = "prost-derive" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a56d757972c98b346a9b766e3f02746cde6dd1cd1d1d563472929fdd74bec4d" +dependencies = [ + "anyhow", + "itertools 0.12.1", + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "quick-error" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" + +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2 0.6.2", + "thiserror 2.0.18", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560" +dependencies = [ + "aws-lc-rs", + "bytes", + "getrandom 0.4.2", + "lru-slab", + "rand 0.10.1", + "rand_pcg", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2 0.6.2", + "tracing", + "windows-sys 0.60.2", +] + +[[package]] +name = "quote" +version = "1.0.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "radium" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" + +[[package]] +name = "rand" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ec095654a25171c2124e9e3393a930bddbffdc939556c914957a4c3e0a87166" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + +[[package]] +name = "rand" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207" +dependencies = [ + "chacha20", + "getrandom 0.4.2", + "rand_core 0.10.0", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rand_core" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c8d0fd677905edcbeedbf2edb6494d676f0e98d54d5cf9bda0b061cb8fb8aba" + +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core 0.10.0", +] + +[[package]] +name = "rand_xorshift" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a" +dependencies = [ + "rand_core 0.9.5", +] + +[[package]] +name = "recipher" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e14e156e2d485b51cc67c19241e7d81ad524bda9fd4f77791b698ab10c8e26e9" +dependencies = [ + "aes", + "cmac", + "getrandom 0.2.17", + "hex", + "hex-literal", + "opaque-debug", + "rand 0.8.6", + "rand_chacha 0.3.1", + "serde", + "serde_cbor", + "sha2 0.10.9", + "thiserror 1.0.69", + "zeroize", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 1.0.69", +] + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a96887878f22d7bad8a3b6dc5b7440e0ada9a245242924394987b21cf2210a4c" + +[[package]] +name = "reqwest" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" +dependencies = [ + "base64", + "bytes", + "futures-core", + "futures-util", + "h2", + "hickory-resolver", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "once_cell", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", +] + +[[package]] +name = "resolv-conf" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e061d1b48cb8d38042de4ae0a7a6401009d6143dc80d2e2d6f31f0bdd6470c7" + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted 0.9.0", + "windows-sys 0.52.0", +] + +[[package]] +name = "rmp" +version = "0.8.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ba8be72d372b2c9b35542551678538b562e7cf86c3315773cae48dfbfe7790c" +dependencies = [ + "num-traits", +] + +[[package]] +name = "rmp-serde" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f81bee8c8ef9b577d1681a70ebbc962c232461e397b22c208c43c04b67a155" +dependencies = [ + "rmp", + "serde", +] + +[[package]] +name = "rustc-hash" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.59.0", +] + +[[package]] +name = "rustix" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys 0.11.0", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c665f33d38cea657d9614f766881e4d510e0eda4239891eea56b4cadcf01801b" +dependencies = [ + "aws-lc-rs", + "once_cell", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-platform-verifier" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d99feebc72bae7ab76ba994bb5e121b8d83d910ca40b36e0921f53becc41784" +dependencies = [ + "core-foundation 0.10.1", + "core-foundation-sys", + "jni 0.21.1", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "aws-lc-rs", + "ring", + "rustls-pki-types", + "untrusted 0.9.0", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "rusty-fork" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc6bf79ff24e648f6da1f8d1f011e9cac26491b619e6b9280f2b47f1774e6ee2" +dependencies = [ + "fnv", + "quick-error", + "tempfile", + "wait-timeout", +] + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schannel" +version = "0.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "security-framework" +version = "3.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3297343eaf830f66ede390ea39da1d462b6b0c1b000f420d0a83f898bbbe6ef" +dependencies = [ + "bitflags", + "core-foundation 0.10.1", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc1f0cbffaac4852523ce30d8bd3c5cdc873501d96ff467ca09b6767bb8cd5c0" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "semver" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde-wasm-bindgen" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8302e169f0eddcc139c70f139d19d6467353af16f9fce27e8c30158036a1e16b" +dependencies = [ + "js-sys", + "serde", + "wasm-bindgen", +] + +[[package]] +name = "serde_bytes" +version = "0.11.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "serde_cbor" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bef2ebfde456fb76bbcf9f59315333decc4fda0b2b44b420243c11e0f5ec1f5" +dependencies = [ + "half", + "serde", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_spanned" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8bbf91e5a4d6315eee45e704372590b30e260ee83af6639d64557f51b067776" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "serdect" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f42f67da2385b51a5f9652db9c93d78aeaf7610bf5ec366080b6de810604af53" +dependencies = [ + "base16ct", + "serde", + "zeroize", +] + +[[package]] +name = "sha1_smol" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbfa15b3dddfee50a0fff136974b3e1bde555604ba463834a7eb7deb6417705d" + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "digest 0.11.3", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "rand_core 0.6.4", +] + +[[package]] +name = "simd-adler32" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" + +[[package]] +name = "simd_cesu8" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "simple_asn1" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d" +dependencies = [ + "num-bigint", + "num-traits", + "thiserror 2.0.18", + "time", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.5.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + +[[package]] +name = "socket2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86f4aa3ad99f2088c990dfa82d367e19cb29268ed67c574d10d0a4bfe71f07e0" +dependencies = [ + "libc", + "windows-sys 0.60.2", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "stack-auth" +version = "0.42.3" +dependencies = [ + "aquamarine", + "axum", + "base64", + "bytes", + "cts-common", + "jsonwebtoken", + "miette", + "mocktail", + "open", + "proptest", + "reqwest", + "serde", + "serde_json", + "serde_urlencoded", + "stack-profile", + "temp-env", + "tempfile", + "thiserror 1.0.69", + "tokio", + "tracing", + "tracing-subscriber", + "url", + "uuid", + "vitaminc", + "vitaminc-protected", + "web-time", + "zeroize", + "zerokms-protocol", +] + +[[package]] +name = "stack-auth-node" +version = "0.35.0" +dependencies = [ + "cts-common", + "jsonwebtoken", + "mocktail", + "napi", + "napi-build", + "napi-derive", + "serde_json", + "stack-auth", + "stack-profile", + "tempfile", + "tokio", + "url", + "vitaminc-protected", + "zeroize", +] + +[[package]] +name = "stack-auth-wasm" +version = "0.35.0" +dependencies = [ + "base64", + "console_error_panic_hook", + "cts-common", + "js-sys", + "serde", + "serde-wasm-bindgen", + "serde_json", + "stack-auth", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-bindgen-test", + "web-sys", + "zeroize", +] + +[[package]] +name = "stack-encrypt" +version = "0.1.0" +dependencies = [ + "base64ct", + "cllw-ore", + "serde", + "serde_json", + "stack-auth", + "stack-encrypt-derive", + "stack-kms", + "thiserror 1.0.69", + "tokio", + "trybuild", + "uuid", + "vitaminc-aead", + "vitaminc-aead-value", + "vitaminc-encrypt", + "vitaminc-hmac", + "vitaminc-prf", + "vitaminc-protected", + "zeroize", + "zerokms-protocol", +] + +[[package]] +name = "stack-encrypt-derive" +version = "0.1.0" +dependencies = [ + "proc-macro2", + "quote", + "stack-encrypt", + "stack-kms", + "syn 3.0.3", + "tokio", +] + +[[package]] +name = "stack-guest-abi" +version = "0.0.0" +dependencies = [ + "proptest", + "thiserror 1.0.69", + "vitaminc-protected", + "zeroize", +] + +[[package]] +name = "stack-kms" +version = "0.1.0" +dependencies = [ + "async-mutex", + "base16ct", + "base64ct", + "blake3", + "futures", + "lazy_static", + "miette", + "opaque-debug", + "recipher", + "reqwest", + "serde", + "serde_cbor", + "serde_json", + "serdect", + "sha2 0.10.9", + "stack-auth", + "stack-profile", + "tempfile", + "thiserror 1.0.69", + "tokio", + "toml 0.8.23", + "tracing", + "url", + "uuid", + "vitaminc", + "vitaminc-protected", + "zeroize", + "zerokms-protocol", +] + +[[package]] +name = "stack-profile" +version = "0.42.3" +dependencies = [ + "dirs", + "gethostname", + "serde", + "serde_json", + "tempfile", + "thiserror 1.0.69", + "uuid", +] + +[[package]] +name = "stack-profile-node" +version = "0.35.0" +dependencies = [ + "napi", + "napi-build", + "napi-derive", + "stack-profile", +] + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4d107df263a3013ef9b1879b0df87d706ff80f65a86ea879bd9c31f9b307c2a" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "system-configuration" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" +dependencies = [ + "bitflags", + "core-foundation 0.9.4", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "tagptr" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417" + +[[package]] +name = "tap" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" + +[[package]] +name = "target-triple" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3a6bfce3d99adfa72d24750a61f782f3036a81e7f86d8841ee1326deaebd171" + +[[package]] +name = "temp-env" +version = "0.3.6" +source = "git+https://github.com/cipherstash/temp-env?branch=main#0223c1c07c40dbf0a621cc88dd3191a0917d9db7" +dependencies = [ + "lock_api", + "parking_lot", +] + +[[package]] +name = "tempfile" +version = "3.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "655da9c7eb6305c55742045d5a8d2037996d61d8de95806335c7c86ce0f82e9c" +dependencies = [ + "fastrand", + "getrandom 0.3.4", + "once_cell", + "rustix 1.1.3", + "windows-sys 0.61.2", +] + +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "time" +version = "0.3.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" + +[[package]] +name = "time-macros" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.49.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2 0.6.2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-stream" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.11", + "toml_edit", +] + +[[package]] +name = "toml" +version = "0.9.11+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3afc9a848309fe1aaffaed6e1546a7a14de1f935dc9d89d32afd9a44bab7c46" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned 1.0.4", + "toml_datetime 0.7.5+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_datetime" +version = "0.7.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.11", + "toml_write", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.0.6+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3198b4b0a8e11f09dd03e133c0280504d0801269e9afa46362ffde1cbeebf44" +dependencies = [ + "winnow", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + +[[package]] +name = "toml_writer" +version = "1.0.6+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab16f14aed21ee8bfd8ec22513f7287cd4a91aa92e44edfe2c17ddd004e92607" + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" +dependencies = [ + "async-compression", + "bitflags", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "iri-string", + "pin-project-lite", + "tokio", + "tokio-util", + "tower", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-serde" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "704b1aeb7be0d0a84fc9828cae51dab5970fee5088f83d1dd7ee6f6246fc6ff1" +dependencies = [ + "serde", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "serde", + "serde_json", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", + "tracing-serde", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "trybuild" +version = "1.0.115" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f614c21bd3a61bad9501d75cbb7686f00386c806d7f456778432c25cf86948a" +dependencies = [ + "glob", + "serde", + "serde_derive", + "serde_json", + "target-triple", + "termcolor", + "toml 0.9.11+spec-1.1.0", +] + +[[package]] +name = "typenum" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + +[[package]] +name = "unarray" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" + +[[package]] +name = "unicode-ident" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "537dd038a89878be9b64dd4bd1b260315c1bb94f4d784956b81e27a088d9a09e" + +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-segmentation" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" + +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common 0.1.7", + "subtle", +] + +[[package]] +name = "untrusted" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", + "serde_derive", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utoipa" +version = "5.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fcc29c80c21c31608227e0912b2d7fddba57ad76b606890627ba8ee7964e993" +dependencies = [ + "indexmap", + "serde", + "serde_json", + "utoipa-gen", +] + +[[package]] +name = "utoipa-gen" +version = "5.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d79d08d92ab8af4c5e8a6da20c47ae3f61a0f1dabc1997cdf2d082b757ca08b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", + "url", + "uuid", +] + +[[package]] +name = "uuid" +version = "1.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee48d38b119b0cd71fe4141b30f5ba9c7c5d9f4e7a3a8b4a674e4b6ef789976f" +dependencies = [ + "atomic", + "getrandom 0.3.4", + "js-sys", + "md-5", + "rand 0.9.3", + "serde_core", + "sha1_smol", + "wasm-bindgen", +] + +[[package]] +name = "validator" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43fb22e1a008ece370ce08a3e9e4447a910e92621bb49b85d6e48a45397e7cfa" +dependencies = [ + "idna", + "once_cell", + "regex", + "serde", + "serde_derive", + "serde_json", + "url", + "validator_derive", +] + +[[package]] +name = "validator_derive" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7df16e474ef958526d1205f6dda359fdfab79d9aa6d54bafcb92dcd07673dca" +dependencies = [ + "darling", + "once_cell", + "proc-macro-error2", + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "vitaminc" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f371b877e1586acb8ffc9efd0bd6a7c7ff7d8c4b1529c1f46d34b1df3478699" +dependencies = [ + "vitaminc-aead", + "vitaminc-context", + "vitaminc-encrypt", + "vitaminc-protected", + "vitaminc-random", + "vitaminc-traits", +] + +[[package]] +name = "vitaminc-aead" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0238d5b77821fcd46d2f72b082532209d625892c69e40008e403b709a1dd0fa" +dependencies = [ + "bytes", + "serde", + "vitaminc-aead-derive", + "vitaminc-context", + "vitaminc-protected", + "vitaminc-random", + "zeroize", +] + +[[package]] +name = "vitaminc-aead-derive" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5a81524f1e096bc45a1e7901186546bc0cf5545a9a86e978581a7146c54a5c7" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "vitaminc-aead-value" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b63326e8bf21f695080c50d8d849324fa92e4cf6ce7257bef198b1ff2eeea0d" +dependencies = [ + "vitaminc-aead", + "vitaminc-protected", + "zeroize", +] + +[[package]] +name = "vitaminc-context" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e7b3e0602ad490039680222ba23217264eab0f400495c072ec11e1a2235b304" +dependencies = [ + "mutants", + "vitaminc-protected", +] + +[[package]] +name = "vitaminc-encrypt" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b969f689aebd40637a6ba9243618a62057ee9c76a8b70c6661c9b0c28ba20275" +dependencies = [ + "aes-gcm", + "aws-lc-rs", + "vitaminc-aead", + "vitaminc-protected", + "vitaminc-random", + "zeroize", +] + +[[package]] +name = "vitaminc-hmac" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccebde615f15197146a3fe4b3ae32ff00a6ae489f88ae9ee0b71e1cbdd286d94" +dependencies = [ + "hmac", + "sha2 0.11.0", + "vitaminc-prf", + "vitaminc-protected", + "zeroize", +] + +[[package]] +name = "vitaminc-prf" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0e6242717d2a5b3f0fdbdaf74340b60eb07713de537d5f82508065a8bfd7ef3" +dependencies = [ + "mutants", + "thiserror 2.0.18", + "vitaminc-context", + "vitaminc-protected", +] + +[[package]] +name = "vitaminc-protected" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a13b616fa4e7093f4970dacd6f44ca76a6929bee1c72612134353a69bb52c62" +dependencies = [ + "bitvec", + "digest 0.11.3", + "libc", + "serde", + "serde_bytes", + "subtle", + "thiserror 2.0.18", + "vitaminc-protected-derive", + "zeroize", +] + +[[package]] +name = "vitaminc-protected-derive" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47c3f8db2eff8108d078a77125f20974448b9a66950bdc120e35b0b53eda4c98" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "vitaminc-random" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8320b63fad9560026b036cb6eb9b85efb1d666af52862af923a2c75972c4282" +dependencies = [ + "chacha20", + "getrandom 0.4.2", + "rand 0.10.1", + "thiserror 2.0.18", + "vitaminc-protected", + "vitaminc-random-derives", + "zeroize", +] + +[[package]] +name = "vitaminc-random-derives" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdbc48a9e3893406046f51f51c3d2bb1dffe70d6ca3b19920ca12d231ed12b68" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "vitaminc-traits" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c53613363efb0c47aba19ea2bfe0142cba8b2d0fe1bc27d43b0ea3390f6efe7" +dependencies = [ + "anyhow", + "bytes", + "rmp-serde", + "serde", + "thiserror 2.0.18", + "vitaminc-protected", + "vitaminc-random", + "zeroize", +] + +[[package]] +name = "wait-timeout" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11" +dependencies = [ + "libc", +] + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.2+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64024a30ec1e37399cf85a7ffefebdb72205ca1c972291c51512360d90bd8566" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70a6e77fd0ae8029c9ea0063f87c46fde723e7d887703d74ad2616d792e51e6f" +dependencies = [ + "cfg-if", + "futures-util", + "js-sys", + "once_cell", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "008b239d9c740232e71bd39e8ef6429d27097518b6b30bdf9086833bd5b6d608" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5256bae2d58f54820e6490f9839c49780dff84c65aeab9e772f15d5f0e913a55" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.114", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f01b580c9ac74c8d8f0c0e4afb04eeef2acf145458e52c03845ee9cd23e3d12" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-bindgen-test" +version = "0.3.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45649196a53b0b7a15101d845d44d2dda7374fc1b5b5e2bbf58b7577ff4b346d" +dependencies = [ + "async-trait", + "cast", + "js-sys", + "libm", + "minicov", + "nu-ansi-term", + "num-traits", + "oorandom", + "serde", + "serde_json", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-bindgen-test-macro", + "wasm-bindgen-test-shared", +] + +[[package]] +name = "wasm-bindgen-test-macro" +version = "0.3.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f579cdd0123ac74b94e1a4a72bd963cf30ebac343f2df347da0b8df24cdebed2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "wasm-bindgen-test-shared" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8145dd1593bf0fb137dbfa85b8be79ec560a447298955877804640e40c2d6ea" + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasm-streams" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "web-sys" +version = "0.3.85" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "312e32e551d92129218ea9a2452120f4aabc03529ef03e4d0d82fb2780608598" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-root-certs" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "804f18a4ac2676ffb4e8b5b5fa9ae38af06df08162314f96a68d2a363e21a8ca" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "widestring" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471" + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets 0.42.2", +] + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "winnow" +version = "0.7.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829" +dependencies = [ + "memchr", +] + +[[package]] +name = "winreg" +version = "0.50.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "524e57b2c537c0f9b1e69f1965311ec12182b4122e45035b1508cd24d2adadb1" +dependencies = [ + "cfg-if", + "windows-sys 0.48.0", +] + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn 2.0.114", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn 2.0.114", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "writeable" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" + +[[package]] +name = "wyz" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed" +dependencies = [ + "tap", +] + +[[package]] +name = "yoke" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.39" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db6d35d663eadb6c932438e763b262fe1a70987f9ae936e60158176d710cae4a" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.39" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4122cd3169e94605190e77839c9a40d40ed048d305bfdc146e7df40ab0f3e517" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "zerofrom" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "zerokms-protocol" +version = "0.12.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c28e88315a5109d0a1e7ee4b7b4b8776a0bff5f5b139ae83960a3debe84e92e" +dependencies = [ + "base64", + "cipherstash-config", + "const-hex", + "cts-common", + "fake", + "getrandom 0.2.17", + "opaque-debug", + "rand 0.8.6", + "serde", + "static_assertions", + "thiserror 1.0.69", + "utoipa", + "uuid", + "validator", + "zeroize", +] + +[[package]] +name = "zerotrie" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "zmij" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4de98dfa5d5b7fef4ee834d0073d560c9ca7b6c46a71d058c48db7960f8cfaf7" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 000000000..e2324061d --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,126 @@ +[workspace] +resolver = "2" + +# The stack-* crates imported from cipherstash-suite, and the three node +# bindings that wrap them. The root is one of three Cargo workspaces in this +# repository: EQL and protect-ffi keep their own, and pin their own vitaminc +# (see `exclude`). +members = [ + "packages/stack-auth", + "packages/stack-profile", + "packages/stack-kms", + "packages/stack-encrypt", + "packages/stack-encrypt-derive", + "packages/stack-guest-abi", + "languages/typescript/packages/auth", + "languages/typescript/packages/profile", + "languages/typescript/packages/stack-auth-wasm", +] + +# Each of these is its own workspace, with its own Cargo.lock. +exclude = [ + "packages/eql", + "languages/typescript/packages/protect-ffi", + # cargo-fuzz crates: nightly-only, run through the `fuzz:*` mise tasks. + "packages/stack-auth/fuzz", + "packages/stack-kms/fuzz", + "packages/stack-encrypt/fuzz", + # WASI guests for the Go module, built through `wasm:guest:build` and + # `wasm:auth-guest:build`. + "languages/golang/stackencrypt/guest", + "languages/golang/stackauth/guest", +] + +[workspace.package] +# Used by the node binding crates, which take `version.workspace`. The +# published crates (stack-auth, stack-profile) carry their own version. +version = "0.35.0" +edition = "2021" +authors = [ + "Dan Draper <dan@cipherstash.com>", + "Drew Thomas <drew@cipherstash.com>", + "Fiona McCawley <fiona@cipherstash.com>", + "James Sadler <james@cipherstash.com>", + "Kate Andrews <kate@cipherstash.com>", + "Lindsay Holmwood <lindsay@cipherstash.com>", + "Paul Hawkins <paul@cipherstash.com>", + "Robin Howard <robin@cipherstash.com>", + "Toby Hede <toby@cipherstash.com>", + "Yuji Yokoo <yuji@cipherstash.com>", +] +repository = "https://github.com/cipherstash/stack" +homepage = "https://cipherstash.com" +keywords = ["cryptography", "security", "databases", "encryption", "sql"] +categories = ["cryptography", "database"] + +# Note that profiles provided in any non-root packages are ignored +[profile.release] +strip = true # same as "symbols" +lto = true # same as "fat" + +[profile.dev] +incremental = true + +[workspace.dependencies] +# The two published crates of this workspace. Members take them with +# `workspace = true`. +# `default-features = false`: a member can add features to a workspace dep +# but cannot subtract them, and `stack-kms` / `stack-encrypt` need stack-auth +# without `http`. Consumers that want the default transport re-enable it with +# `features = ["http"]`. +stack-auth = { path = "./packages/stack-auth", version = "0.42.3", default-features = false } +stack-profile = { path = "./packages/stack-profile", version = "0.42.3" } + +# Suite crates, from crates.io. Pinned exactly: they release from +# cipherstash-suite, and stack-auth's API carries their types. +cts-common = { version = "=0.43.0", default-features = false } +zerokms-protocol = "=0.12.31" +recipher = "=0.3.1" +cllw-ore = { version = "=0.5.0", default-features = false } + +# External dependencies, with the suite's feature lists. +base64 = "0.22.0" +blake3 = { version = "1.5.4", features = ["zeroize"] } +jsonwebtoken = { version = "10.3.0", default-features = false, features = ["aws_lc_rs", "use_pem"] } +lazy_static = "1.4.0" +# Base miette (the `Diagnostic` derive). The `fancy` renderer is heavy and +# only needed by binaries; enable it per crate. +miette = { version = "7.5.0" } +reqwest = { version = "0.13", default-features = false, features = [ + "brotli", + "gzip", + "json", + "rustls", + "hickory-dns", + "stream", + "form", + "query", +] } +serde = { version = "1.0", features = ["derive"] } +serde_json = { version = "1.0.132" } +thiserror = "1.0.56" +tokio = { version = "1.47.1", features = ["full"] } +tracing = { version = "0.1", features = ["log"] } +tracing-subscriber = { version = "0.3", features = [ + "ansi", + "json", + "env-filter", + "std", +] } +url = { version = "2.5.4", features = ["serde"] } +uuid = { version = "1.8", features = ["v4", "v5", "serde"] } +# Drop-in `std::time::{Instant, SystemTime}` replacement — polyfills via JS +# time APIs on wasm32 (stdlib's wasm time module is a panicking stub). +web-time = "1.1" +zeroize = { version = "1.8.1", features = ["derive"] } +# This is needed because lock_api 0.4.6 was breaking things and the branch forces the use of 0.4.12 +temp-env = { git = "https://github.com/cipherstash/temp-env", branch = "main" } +vitaminc = { version = "0.5.0", features = ["random", "protected", "encrypt"] } +vitaminc-aead = "0.5.0" +# The dynamic value model (`FfiValue`) and its FFI transport codec, shared by +# every language binding. Optional in stack-encrypt (the `dynamic` feature). +vitaminc-aead-value = "0.5.0" +vitaminc-encrypt = "0.5.0" +vitaminc-hmac = "0.5.0" +vitaminc-prf = "0.5.0" +vitaminc-protected = "0.5.0" diff --git a/languages/golang/stackauth/guest/Cargo.lock b/languages/golang/stackauth/guest/Cargo.lock index d75d4ed91..106a30b75 100644 --- a/languages/golang/stackauth/guest/Cargo.lock +++ b/languages/golang/stackauth/guest/Cargo.lock @@ -299,6 +299,8 @@ dependencies = [ [[package]] name = "cipherstash-config" version = "0.42.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d098935e395d7346d0cdc8cdf3ed9674ab03fa8b415e828d02e65c81836a73c" dependencies = [ "bitflags", "serde", @@ -396,7 +398,9 @@ dependencies = [ [[package]] name = "cts-common" -version = "0.42.3" +version = "0.43.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9cb0f5ffa463e8facbe6ad78cfe925d132a051c6b1c9a5da2f3961296b7e632" dependencies = [ "arrayvec", "base32", @@ -2709,7 +2713,9 @@ dependencies = [ [[package]] name = "zerokms-protocol" -version = "0.12.30" +version = "0.12.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c28e88315a5109d0a1e7ee4b7b4b8776a0bff5f5b139ae83960a3debe84e92e" dependencies = [ "base64", "cipherstash-config", diff --git a/languages/golang/stackencrypt/guest/Cargo.lock b/languages/golang/stackencrypt/guest/Cargo.lock index 727be306c..8bb00e5ea 100644 --- a/languages/golang/stackencrypt/guest/Cargo.lock +++ b/languages/golang/stackencrypt/guest/Cargo.lock @@ -327,6 +327,8 @@ dependencies = [ [[package]] name = "cipherstash-config" version = "0.42.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d098935e395d7346d0cdc8cdf3ed9674ab03fa8b415e828d02e65c81836a73c" dependencies = [ "bitflags", "serde", @@ -336,7 +338,9 @@ dependencies = [ [[package]] name = "cllw-ore" -version = "0.4.3" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "476f300d37a5029d3d9dd57145d4db50a23f40ae9b4d1374c44543978b906191" dependencies = [ "blake3", "hex", @@ -459,7 +463,9 @@ dependencies = [ [[package]] name = "cts-common" -version = "0.42.3" +version = "0.43.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9cb0f5ffa463e8facbe6ad78cfe925d132a051c6b1c9a5da2f3961296b7e632" dependencies = [ "arrayvec", "base32", @@ -1632,7 +1638,9 @@ dependencies = [ [[package]] name = "recipher" -version = "0.3.0" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e14e156e2d485b51cc67c19241e7d81ad524bda9fd4f77791b698ab10c8e26e9" dependencies = [ "aes", "cmac", @@ -2944,7 +2952,9 @@ dependencies = [ [[package]] name = "zerokms-protocol" -version = "0.12.30" +version = "0.12.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c28e88315a5109d0a1e7ee4b7b4b8776a0bff5f5b139ae83960a3debe84e92e" dependencies = [ "base64", "cipherstash-config", diff --git a/languages/typescript/packages/auth/package.json b/languages/typescript/packages/auth/package.json index 55c37a3c1..dfb1f1255 100644 --- a/languages/typescript/packages/auth/package.json +++ b/languages/typescript/packages/auth/package.json @@ -66,20 +66,19 @@ "wasm/" ], "scripts": { - "build": "napi build --release --dts native.d.ts", + "build:native": "napi build --release --dts native.d.ts", "build:debug": "napi build --dts native.d.ts", - "build:wasm": "cd ../wasm && wasm-pack build --target bundler --out-dir ../node/wasm && rm -f ../node/wasm/README.md ../node/wasm/LICENSE ../node/wasm/.gitignore && echo '{\"type\":\"module\"}' > ../node/wasm/package.json && node ../node/scripts/inline-wasm.mjs", - "test": "npm run build:debug && vitest run", - "format": "npx --yes @biomejs/biome@2.3.4 format --write .", - "format:check": "npx --yes @biomejs/biome@2.3.4 ci ." + "build:wasm": "cd ../stack-auth-wasm && wasm-pack build --target bundler --out-dir ../auth/wasm && rm -f ../auth/wasm/README.md ../auth/wasm/LICENSE ../auth/wasm/.gitignore && echo '{\"type\":\"module\"}' > ../auth/wasm/package.json && node ../auth/scripts/inline-wasm.mjs", + "test": "vitest run && biome check .", + "test:cargo": "cargo test --locked" }, "peerDependencies": { - "@cipherstash/auth-darwin-x64": "0.40.0", - "@cipherstash/auth-darwin-arm64": "0.40.0", - "@cipherstash/auth-linux-x64-gnu": "0.40.0", - "@cipherstash/auth-linux-arm64-gnu": "0.40.0", - "@cipherstash/auth-linux-x64-musl": "0.40.0", - "@cipherstash/auth-win32-x64-msvc": "0.40.0" + "@cipherstash/auth-darwin-x64": "workspace:*", + "@cipherstash/auth-darwin-arm64": "workspace:*", + "@cipherstash/auth-linux-x64-gnu": "workspace:*", + "@cipherstash/auth-linux-arm64-gnu": "workspace:*", + "@cipherstash/auth-linux-x64-musl": "workspace:*", + "@cipherstash/auth-win32-x64-msvc": "workspace:*" }, "peerDependenciesMeta": { "@cipherstash/auth-darwin-x64": { diff --git a/languages/typescript/packages/auth/platforms/darwin-arm64/package.json b/languages/typescript/packages/auth/platforms/darwin-arm64/package.json index fa22e0ddd..219540b0d 100644 --- a/languages/typescript/packages/auth/platforms/darwin-arm64/package.json +++ b/languages/typescript/packages/auth/platforms/darwin-arm64/package.json @@ -1,6 +1,6 @@ { "name": "@cipherstash/auth-darwin-arm64", - "version": "0.36.0", + "version": "0.44.0", "os": [ "darwin" ], diff --git a/languages/typescript/packages/auth/platforms/darwin-x64/package.json b/languages/typescript/packages/auth/platforms/darwin-x64/package.json index 18e8af633..402aff111 100644 --- a/languages/typescript/packages/auth/platforms/darwin-x64/package.json +++ b/languages/typescript/packages/auth/platforms/darwin-x64/package.json @@ -1,6 +1,6 @@ { "name": "@cipherstash/auth-darwin-x64", - "version": "0.36.0", + "version": "0.44.0", "os": [ "darwin" ], diff --git a/languages/typescript/packages/auth/platforms/linux-arm64-gnu/package.json b/languages/typescript/packages/auth/platforms/linux-arm64-gnu/package.json index 11e284d0a..04a40099d 100644 --- a/languages/typescript/packages/auth/platforms/linux-arm64-gnu/package.json +++ b/languages/typescript/packages/auth/platforms/linux-arm64-gnu/package.json @@ -1,6 +1,6 @@ { "name": "@cipherstash/auth-linux-arm64-gnu", - "version": "0.36.0", + "version": "0.44.0", "os": [ "linux" ], diff --git a/languages/typescript/packages/auth/platforms/linux-x64-gnu/package.json b/languages/typescript/packages/auth/platforms/linux-x64-gnu/package.json index 986d4f9fc..3ef4be204 100644 --- a/languages/typescript/packages/auth/platforms/linux-x64-gnu/package.json +++ b/languages/typescript/packages/auth/platforms/linux-x64-gnu/package.json @@ -1,6 +1,6 @@ { "name": "@cipherstash/auth-linux-x64-gnu", - "version": "0.36.0", + "version": "0.44.0", "os": [ "linux" ], diff --git a/languages/typescript/packages/auth/platforms/linux-x64-musl/package.json b/languages/typescript/packages/auth/platforms/linux-x64-musl/package.json index 3c7a59c18..09fd6602a 100644 --- a/languages/typescript/packages/auth/platforms/linux-x64-musl/package.json +++ b/languages/typescript/packages/auth/platforms/linux-x64-musl/package.json @@ -1,6 +1,6 @@ { "name": "@cipherstash/auth-linux-x64-musl", - "version": "0.36.0", + "version": "0.44.0", "os": [ "linux" ], diff --git a/languages/typescript/packages/auth/platforms/win32-x64-msvc/package.json b/languages/typescript/packages/auth/platforms/win32-x64-msvc/package.json index 981433b40..f4b5501c0 100644 --- a/languages/typescript/packages/auth/platforms/win32-x64-msvc/package.json +++ b/languages/typescript/packages/auth/platforms/win32-x64-msvc/package.json @@ -1,6 +1,6 @@ { "name": "@cipherstash/auth-win32-x64-msvc", - "version": "0.36.0", + "version": "0.44.0", "os": [ "win32" ], diff --git a/languages/typescript/packages/profile/package.json b/languages/typescript/packages/profile/package.json index 0473e4359..31e29991e 100644 --- a/languages/typescript/packages/profile/package.json +++ b/languages/typescript/packages/profile/package.json @@ -25,19 +25,18 @@ "stack-profile-node.js" ], "scripts": { - "build": "napi build --release", + "build:native": "napi build --release", "build:debug": "napi build", - "test": "npm run build:debug && vitest run", - "format": "npx --yes @biomejs/biome@2.3.4 format --write .", - "format:check": "npx --yes @biomejs/biome@2.3.4 ci ." + "test": "vitest run && biome check .", + "test:cargo": "cargo test --locked" }, "optionalDependencies": { - "@cipherstash/profile-darwin-x64": "0.35.0", - "@cipherstash/profile-darwin-arm64": "0.35.0", - "@cipherstash/profile-linux-x64-gnu": "0.35.0", - "@cipherstash/profile-linux-arm64-gnu": "0.35.0", - "@cipherstash/profile-linux-x64-musl": "0.35.0", - "@cipherstash/profile-win32-x64-msvc": "0.35.0" + "@cipherstash/profile-darwin-x64": "workspace:*", + "@cipherstash/profile-darwin-arm64": "workspace:*", + "@cipherstash/profile-linux-x64-gnu": "workspace:*", + "@cipherstash/profile-linux-arm64-gnu": "workspace:*", + "@cipherstash/profile-linux-x64-musl": "workspace:*", + "@cipherstash/profile-win32-x64-msvc": "workspace:*" }, "devDependencies": { "@napi-rs/cli": "^2", diff --git a/languages/typescript/packages/stack-auth-wasm/package.json b/languages/typescript/packages/stack-auth-wasm/package.json index bdcbf1cae..60acdf579 100644 --- a/languages/typescript/packages/stack-auth-wasm/package.json +++ b/languages/typescript/packages/stack-auth-wasm/package.json @@ -5,10 +5,10 @@ "license": "SEE LICENSE IN LICENSE", "private": true, "scripts": { - "build": "npm run build:bundler && npm run build:deno", + "build:wasm": "pnpm run build:bundler && pnpm run build:deno", "build:bundler": "wasm-pack build --target bundler --out-dir pkg-bundler", "build:deno": "wasm-pack build --target deno --out-dir pkg-deno", - "test": "wasm-pack test --node" + "test:cargo": "wasm-pack test --node" }, "files": [ "pkg-bundler/", diff --git a/mise.test.toml b/mise.test.toml new file mode 100644 index 000000000..ade6c7b9b --- /dev/null +++ b/mise.test.toml @@ -0,0 +1,19 @@ +# The `test` environment, for `mise x --env test -- …` in the root and +# stack-* tasks (test:doc, crap:*). Carried from cipherstash-suite, keeping +# only what the stack crates read; the suite's CTS, ZeroKMS and database +# settings serve services this repository does not run. +[tools] +"cargo:cargo-nextest" = "latest" + +[env] +# Annoyingly if you pass --env test to mise it doesn't set this automatically +MISE_ENV = "test" + +# Local service hosts, as in the suite. The stack-auth tests set their own +# values with temp-env where they depend on one. +CS_CTS_HOST = "http://localhost:3001" +CS_IDP_HOST = "http://localhost:3030" +CS_IDP_CLIENT_ID = "admin_test_client_id" +CS_VITUR_HOST = "http://localhost:3002" +CS_TEST_ZEROKMS_HOST = "http://localhost:3002" +CS_REGION = "ap-southeast-2.aws" diff --git a/mise.toml b/mise.toml new file mode 100644 index 000000000..e2cacc11d --- /dev/null +++ b/mise.toml @@ -0,0 +1,299 @@ +# The repository root is the root of the Rust and Go toolchains: the Cargo +# workspace in Cargo.toml (the stack-* crates and the three node bindings) and +# the Go module in languages/golang. +# +# EQL (packages/eql) and protect-ffi (languages/typescript/packages/protect-ffi) +# keep their own mise.toml. A tool they pin there overrides the pin here, in +# their folder only. + +[task_config] +includes = [ + "packages/stack-auth/tasks.toml", + "packages/stack-kms/tasks.toml", + "packages/stack-profile/tasks.toml", + "packages/stack-encrypt/tasks.toml", + "packages/stack-guest-abi/tasks.toml", +] + +[tools] +# 1.94.1, not latest: the stack-encrypt `tests/ui` trybuild snapshots record +# this compiler's diagnostics. `llvm-tools-preview` is required by +# cargo-llvm-cov to produce coverage data. +rust = { version = "1.94.1", components = "rustc,cargo,rustfmt,rust-std,clippy,rust-docs,llvm-tools-preview", targets = "wasm32-unknown-unknown,wasm32-wasip1" } +"cargo:cargo-nextest" = "latest" +# Coverage → CRAP (Change Risk Anti-Patterns) metric tooling. `cargo llvm-cov` +# produces an LCOV file that `cargo crap` scores; see `mise run crap:stack-auth`. +"cargo:cargo-llvm-cov" = "latest" +"cargo:cargo-crap" = "latest" +# Mutation testing for the stack crates; config in .cargo/mutants.toml, run +# via `mutants:<crate>`. +"cargo:cargo-mutants" = "latest" +# Fuzzing (libFuzzer) for the pure-`&str` parsers. The `cargo-fuzz` binary +# installs on stable, but running a target needs the nightly toolchain +# (`cargo +nightly fuzz run …`); see the `fuzz:*` tasks. +"cargo:cargo-fuzz" = "latest" +# Runs on nightly-2026-07-10: `cargo +nightly-2026-07-10 udeps`. +"cargo:cargo-udeps" = "latest" +# The Go module (languages/golang) — the wazero host of the WASI guests. +# CGO_ENABLED=0 throughout; see `go:test`. +go = "1.26" +golangci-lint = "2.14.0" +# wasm-pack for the auth binding's `build:wasm`, which builds +# languages/typescript/packages/stack-auth-wasm into the auth package's wasm/. +# Same pin and backend id as protect-ffi's mise.toml (`wasm-pack` is not a +# short name in mise's registry). +"aqua:wasm-bindgen/wasm-pack" = "0.13.1" + +[tasks."test:doc"] +description = "Run documentation tests" +run = "mise x --env test -- cargo test --doc --all-features --workspace" + +# Rustdoc with warnings as errors, one crate at a time. `test:doc` runs the +# doc *examples*; it cannot see a broken intra-doc link or a rustdoc warning, +# which only a `cargo doc` build surfaces. Each crate defines its own +# `doc:<crate>` in its tasks.toml so a change to one crate is checked with +# `mise run doc:<crate>` alone; this task is the one entry point CI runs, and +# it only fans out over those. A crate that adds a `doc:` task joins it. +[tasks.doc] +description = "Build docs for every crate that defines a doc:<crate> task (warnings are errors)" +depends = ["doc:*"] + +# Mutation testing, the same shape as `doc`: each crate that opts in defines +# `mutants:<crate>` in its tasks.toml (a full sweep of that crate, reading +# .cargo/mutants.toml), and this fans out over them. Slow — see the per-crate +# tasks for timings. +[tasks.mutants] +description = "Full mutation-testing sweep of every crate that defines a mutants:<crate> task (slow)" +depends = ["mutants:*"] + +# WASI gate for the Go/wazero target (docs/plans/stack-encrypt-go-bindings.md, +# docs/wasm-analysis.md Layer 6). Each listed crate must compile for +# `wasm32-wasip1` AND keep two dependency families out of its graph: +# +# - JS-host backends (`wasm-bindgen`/`web-sys`/`js-sys`): imports of JS host +# functions that a non-JS runtime like wazero cannot satisfy — the module +# type-checks but fails to instantiate. +# - The native HTTP/TLS stack (`reqwest`/`hyper`/`aws-lc-sys`): on wasip1 +# reqwest 0.13.4+ selects a native backend that needs tokio-full and a C +# TLS provider, neither of which builds for WASI. HTTP is provided by the +# host (see the plan), so it must be out of the WASI build by construction. +# +# The suite's version of this task also checked the suite crates the stack +# crates depend on (zerokms-protocol, cipherstash-core, recipher, cts-common, +# cllw-ore). Here they come from crates.io and are checked through the stack +# crates' graphs. +[tasks."wasm:wasi-check"] +description = "Check the stack crates compile for wasm32-wasip1 with no JS-host or native-HTTP deps (Go/wazero target)" +# bash, not the default sh: the script uses `set -o pipefail` (Ubuntu's sh is +# dash, which rejects it — macOS sh is bash-in-sh-mode, so it passes locally). +shell = "bash -c" +run = """ +set -euo pipefail +# The WASI std target is in the mise `[tools]` rust targets; add it here as +# well so the task is self-contained (idempotent, no-op if present). +rustup target add wasm32-wasip1 +check() { + local crate="$1"; shift + echo "==> $crate (wasm32-wasip1)" + cargo check --target wasm32-wasip1 -p "$crate" "$@" + # -e normal: the gate's property is about what links into the module, and + # dev-deps never do — a wasm-bindgen-test dev-dep must not fail this. + # Capture the tree before grepping: `... | grep -q` exits on first match, + # cargo tree can die on EPIPE, and pipefail would adopt that status — + # silently masking a MATCH once the tree outgrows the pipe buffer. + local tree + tree=$(cargo tree --target wasm32-wasip1 -e normal -p "$crate" "$@") + if grep -Eq '^[^a-z]*(wasm-bindgen|web-sys|js-sys) ' <<<"$tree"; then + echo "error: $crate pulls a JS-host backend (wasm-bindgen/web-sys/js-sys) on WASI — not wazero-loadable" >&2 + exit 1 + fi + if grep -Eq '^[^a-z]*(reqwest|hyper|aws-lc-sys) ' <<<"$tree"; then + echo "error: $crate pulls the native HTTP/TLS stack (reqwest/hyper/aws-lc-sys) on WASI — HTTP must come from the host" >&2 + exit 1 + fi +} +# The stack crates: their default `http` feature is the reqwest transport, +# which a host with its own transport (the wazero guest) builds without. +check stack-auth --no-default-features +check stack-kms --no-default-features +check stack-encrypt --no-default-features +# The Go binding's credential guest (ADR-0005) compiles stack-profile for +# WASI: the hostname and the process id are native-only there. +check stack-profile +# The ABI every guest under languages/golang shares (allocator, registry, +# status table, transport import); its export and import modules exist only +# here. +check stack-guest-abi +echo "all WASI crates compile with no JS-host or native-HTTP deps" +""" + +# Companion to wasm:wasi-check, on the host target: the stack crates' +# no-default-features shape must also pass its unit tests, doctests, and +# rustdoc — `cargo check` alone misses doc examples and intra-doc links that +# reference http-only items. +[tasks."wasm:no-http-test"] +description = "Test and doc-build (warnings are errors) the stack crates with default features off — the shape the WASI guest builds against" +shell = "bash -c" +run = """ +set -euo pipefail +# One crate per invocation: naming several -p at once lets dev-dep feature +# unification turn `http` back on, silently testing the wrong shape. The +# same applies within a crate: every stack-* path dev-dependency must set +# `default-features = false` or its defaults re-enter this graph. +for crate in stack-auth stack-kms stack-encrypt; do + echo "==> $crate (tests + doctests, --no-default-features)" + cargo test -p "$crate" --no-default-features + echo "==> $crate (rustdoc, --no-default-features)" + RUSTDOCFLAGS="-D warnings" cargo doc --no-deps -p "$crate" --no-default-features +done +""" + +# The stack-encrypt WASI guest (languages/golang/stackencrypt/guest) is a +# detached workspace — like the fuzz crates — so the workspace-wide tasks +# never touch it; these two are its build and test entry points. +[tasks."wasm:guest:build"] +description = "Build the stack-encrypt WASI guest module (wasm32-wasip1, release) and assert its host-import surface" +shell = "bash -c" +run = """ +set -euo pipefail +rustup target add wasm32-wasip1 +root=$(pwd) +cd languages/golang/stackencrypt/guest +cargo build --target wasm32-wasip1 --release +module=target/wasm32-wasip1/release/stack_encrypt_guest.wasm +echo "guest module: languages/golang/stackencrypt/guest/$module" +# The security contract is about the *linked* module, which a successful +# build says nothing about: the guest may reach the outside world only +# through the two host functions the Go embedder provides. Fail-closed — +# any other import, or a missing one, fails here rather than widening the +# surface silently. WASI is allowed as a module but denied the +# capability-granting half of its namespace (ambient filesystem and +# sockets), which is the part that would matter if a dependency grew one. +# random_get is required, not merely allowed: the cipher's IVs and nonces +# come from it, and the Go embedder wires it to crypto/rand (wazero's +# default is a fixed seed). If getrandom ever moves to another backend the +# host side must be revisited, so make that a build failure here. +python3 "$root/scripts/check-wasm-imports.py" "$module" \\ + --allow-module wasi_snapshot_preview1 \\ + --deny-prefix wasi_snapshot_preview1:path_ \\ + --deny-prefix wasi_snapshot_preview1:sock_ \\ + --deny-prefix wasi_snapshot_preview1:fd_prestat \\ + --require wasi_snapshot_preview1:random_get \\ + --require cipherstash_transport:transport_send \\ + --require cipherstash_transport:token_get +# The Go module embeds the checked artefact (languages/golang/stackencrypt/wasm, +# gitignored): copying it here is what makes `go test` in the binding run +# against the guest just built rather than a stale one. +cp "$module" ../wasm/stack_encrypt_guest.wasm +echo "embedded into languages/golang/stackencrypt/wasm/stack_encrypt_guest.wasm" +""" + +[tasks."wasm:guest:test"] +description = "Lint and natively test the stack-encrypt WASI guest (ops/config/status modules run on the host target)" +shell = "bash -c" +run = """ +set -euo pipefail +rustup target add wasm32-wasip1 +# The shared guest ABI crate first: a workspace member, so the root lint +# covers its native half, but its export surface (`se_alloc`/`se_dealloc`, +# the packed results) and the transport import compile only for wasm32, and +# a path dependency is not linted from the guest's own workspace below. +cargo clippy -p stack-guest-abi --all-targets --target wasm32-wasip1 -- -D warnings +RUSTDOCFLAGS="-D warnings" cargo doc --no-deps -p stack-guest-abi --target wasm32-wasip1 +cd languages/golang/stackencrypt/guest +cargo fmt --check +cargo clippy --all-targets -- -D warnings +# The wasm32-only modules (abi, host) only compile for the target; lint +# them there so a broken export surface can't hide behind native-only CI. +cargo clippy --target wasm32-wasip1 -- -D warnings +# Intra-doc links, on the target the crate is written for (the wasm32-only +# modules are part of the crate docs). rustdoc only warns on a broken link +# and exits 0, so without -D warnings a stale link ships silently. +RUSTDOCFLAGS="-D warnings" cargo doc --no-deps --target wasm32-wasip1 +# nextest, as everywhere else; this crate is a detached workspace, so it +# runs from here rather than a root `-p`. +cargo nextest run +""" + +[tasks."wasm:auth-guest:build"] +description = "Build the credential WASI guest module (stack-profile for Go; wasm32-wasip1, release) and assert its host-import surface" +shell = "bash -c" +run = """ +set -euo pipefail +rustup target add wasm32-wasip1 +root=$(pwd) +cd languages/golang/stackauth/guest +cargo build --target wasm32-wasip1 --release +module=target/wasm32-wasip1/release/stack_auth_guest.wasm +echo "guest module: languages/golang/stackauth/guest/$module" +# The credential guest's contract is the mirror image of the crypto +# guest's: it may reach the filesystem — that is what it is for, and the +# host grants it exactly one directory — and the auth host transport. +# Sockets are denied by name; token exchanges use only the two named host +# imports. random_get is required: the Rust runtime draws through +# it (its hash maps are seeded from it), and the Go side wires it to +# crypto/rand rather than wazero's fixed-seed default. +python3 "$root/scripts/check-wasm-imports.py" "$module" \\ + --allow-module wasi_snapshot_preview1 \\ + --deny-prefix wasi_snapshot_preview1:sock_ \\ + --require wasi_snapshot_preview1:random_get \\ + --require wasi_snapshot_preview1:path_open \\ + --require cipherstash_transport:transport_send \\ + --require cipherstash_transport:oidc_token_get +cp "$module" ../wasm/stack_auth_guest.wasm +echo "embedded into languages/golang/stackauth/wasm/stack_auth_guest.wasm" +""" + +[tasks."wasm:auth-guest:test"] +description = "Lint and natively test the credential WASI guest (ops/status modules run on the host target)" +shell = "bash -c" +run = """ +set -euo pipefail +rustup target add wasm32-wasip1 +cd languages/golang/stackauth/guest +cargo fmt --check +cargo clippy --all-targets -- -D warnings +cargo clippy --target wasm32-wasip1 -- -D warnings +RUSTDOCFLAGS="-D warnings" cargo doc --no-deps --target wasm32-wasip1 +cargo nextest run +""" + +[tasks."go:test"] +# The old name, from when the module held one package. +alias = "go:stackencrypt:test" +description = "Format check, vet and test the Go module (languages/golang: stackencrypt, stackauth and the internal packages) against the embedded guests; needs `wasm:guest:build` and `wasm:auth-guest:build` first" +# The body lives in scripts/go-binding-test.sh so the macOS and Windows CI +# jobs (which have Go but not mise) run exactly the same checks. One module +# at languages/golang holds every Go package (ADR-0005 §4); the guest it +# embeds is named relative to the module root. +run = "scripts/go-binding-test.sh languages/golang" + +[tasks."go:lint"] +description = "Lint and format-check the Go module (languages/golang) with golangci-lint; config in languages/golang/.golangci.yaml" +dir = "languages/golang" +run = "golangci-lint run ./..." + +[tasks."go:stackencrypt:example"] +description = "Run the stack-encrypt Go example (languages/golang/stackencrypt/example) against real ZeroKMS; needs `stash auth login` first" +shell = "bash -c" +# Both guests: the example reads the profile through stackauth. +depends = ["wasm:guest:build", "wasm:auth-guest:build"] +run = """ +set -euo pipefail +cd languages/golang +# Credentials come from stackencrypt.AutoCredentials: the CS_* variables +# if set, else the developer profile. See stackencrypt/example/README.md. +CGO_ENABLED=0 go run ./stackencrypt/example +""" + +[tasks."go:stackencrypt:example:explicit"] +description = "Run the explicit-credentials Go example (languages/golang/stackencrypt/example/explicit) against real ZeroKMS; pass -secrets-dir, -client-id and -workspace-crn after --" +shell = "bash -c" +depends = ["wasm:guest:build", "wasm:auth-guest:build"] +run = """ +set -euo pipefail +cd languages/golang +# Credentials come only from the flags and the secrets directory: no CS_* +# variables, no profile. See stackencrypt/example/explicit/README.md. +CGO_ENABLED=0 go run ./stackencrypt/example/explicit "$@" +""" diff --git a/packages/stack-auth/fuzz/Cargo.lock b/packages/stack-auth/fuzz/Cargo.lock new file mode 100644 index 000000000..75797cb9c --- /dev/null +++ b/packages/stack-auth/fuzz/Cargo.lock @@ -0,0 +1,4075 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common 0.1.7", + "generic-array", +] + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures 0.2.17", +] + +[[package]] +name = "aes-gcm" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" +dependencies = [ + "aead", + "aes", + "cipher", + "ctr", + "ghash", + "subtle", + "zeroize", +] + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "alloc-no-stdlib" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" + +[[package]] +name = "alloc-stdlib" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94fb8275041c72129eb51b7d0322c29b8387a0386127718b096429201a5d6ece" +dependencies = [ + "alloc-no-stdlib", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anyhow" +version = "1.0.101" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f0e0fee31ef5ed1ba1316088939cea399010ed7731dba877ed44aeb407a75ea" + +[[package]] +name = "aquamarine" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f50776554130342de4836ba542aa85a4ddb361690d7e8df13774d7284c3d5c2" +dependencies = [ + "include_dir", + "itertools", + "proc-macro-error2", + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" + +[[package]] +name = "arrayvec" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" +dependencies = [ + "serde", +] + +[[package]] +name = "async-compression" +version = "0.4.39" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68650b7df54f0293fd061972a0fb05aaf4fc0879d3b3d21a638a182c5c543b9f" +dependencies = [ + "compression-codecs", + "compression-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "atomic" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89cbf775b137e9b968e67227ef7f775587cde3fd31b0d8599dbd0f598a48340" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "aws-lc-rs" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce2b2dcc879c3bae0d371e77c99f2238400ef24ec001394befa67b6e543add9e" +dependencies = [ + "aws-lc-sys", + "untrusted 0.7.1", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.44.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f09fae7be8bb3174e05c6afdb34199e6dc0c7c04ba9fa237b1967adfbde27483" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", + "pkg-config", +] + +[[package]] +name = "base32" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "022dfe9eb35f19ebbcb51e0b40a5ab759f46ad60cadf7297e0bd085afb50e076" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bitflags" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" + +[[package]] +name = "bitvec" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bc2832c24239b0141d5674bb9174f9d68a8b5b3f2753311927c172ca46f7e9c" +dependencies = [ + "funty", + "radium", + "tap", + "wyz", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block-buffer" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdd35008169921d80bc60d3d0ab416eecb028c4cd653352907921d95084790be" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "brotli" +version = "8.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bd8b9603c7aa97359dbd97ecf258968c95f3adddd6db2f7e7a5bef101c84560" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", + "brotli-decompressor", +] + +[[package]] +name = "brotli-decompressor" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "874bb8112abecc98cbd6d81ea4fa7e94fb9449648c93cc89aa40c81c24d7de03" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", +] + +[[package]] +name = "bumpalo" +version = "3.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" + +[[package]] +name = "bytemuck" +version = "1.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +dependencies = [ + "serde", +] + +[[package]] +name = "cached" +version = "0.54.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9718806c4a2fe9e8a56fd736f97b340dd10ed1be8ed733ed50449f351dc33cae" +dependencies = [ + "ahash", + "cached_proc_macro", + "cached_proc_macro_types", + "hashbrown 0.14.5", + "once_cell", + "thiserror 1.0.69", + "web-time", +] + +[[package]] +name = "cached_proc_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f42a145ed2d10dce2191e1dcf30cfccfea9026660e143662ba5eec4017d5daa" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "cached_proc_macro_types" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ade8366b8bd5ba243f0a58f036cc0ca8a2f069cff1a2351ef1cac6b083e16fc0" + +[[package]] +name = "cc" +version = "1.2.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b26a0954ae34af09b50f0de26458fa95369a0d478d8236d3f93082b219bd29" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "chacha20" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core 0.10.0", + "zeroize", +] + +[[package]] +name = "chrono" +version = "0.4.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fac4744fb15ae8337dc853fee7fb3f4e48c0fbaa23d0afe49c447b4fab126118" +dependencies = [ + "iana-time-zone", + "num-traits", + "serde", + "windows-link", +] + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common 0.1.7", + "inout", +] + +[[package]] +name = "cipherstash-config" +version = "0.42.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d098935e395d7346d0cdc8cdf3ed9674ab03fa8b415e828d02e65c81836a73c" +dependencies = [ + "bitflags", + "serde", + "serde_json", + "thiserror 1.0.69", +] + +[[package]] +name = "cmake" +version = "0.1.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75443c44cd6b379beb8c5b45d85d0773baf31cce901fe7bb252f4eff3008ef7d" +dependencies = [ + "cc", +] + +[[package]] +name = "cmov" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "compression-codecs" +version = "0.4.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00828ba6fd27b45a448e57dbfe84f1029d4c9f26b368157e9a448a5f49a2ec2a" +dependencies = [ + "brotli", + "compression-core", + "flate2", + "memchr", +] + +[[package]] +name = "compression-core" +version = "0.4.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75984efb6ed102a0d42db99afb6c1948f0380d1d91808d5529916e6c08b49d8d" + +[[package]] +name = "const-hex" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3bb320cac8a0750d7f25280aa97b09c26edfe161164238ecbbb31092b079e735" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "proptest", + "serde_core", +] + +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + +[[package]] +name = "crossbeam-channel" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "crypto-common" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77727bb15fa921304124b128af125e7e3b968275d1b108b379190264f4423710" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher", +] + +[[package]] +name = "cts-common" +version = "0.43.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9cb0f5ffa463e8facbe6ad78cfe925d132a051c6b1c9a5da2f3961296b7e632" +dependencies = [ + "arrayvec", + "base32", + "cached", + "chrono", + "derive_more", + "either", + "getrandom 0.4.2", + "miette", + "nom", + "regex", + "serde", + "serde_json", + "thiserror 1.0.69", + "tracing", + "url", + "utoipa", + "uuid", + "vitaminc", +] + +[[package]] +name = "ctutils" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" +dependencies = [ + "cmov", +] + +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.114", +] + +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "data-encoding" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" + +[[package]] +name = "deranged" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ececcb659e7ba858fb4f10388c250a7252eb0a27373f1a72b8748afdd248e587" +dependencies = [ + "powerfmt", +] + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.114", + "unicode-xid", +] + +[[package]] +name = "deunicode" +version = "1.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abd57806937c9cc163efc8ea3910e00a62e2aeb0b8119f1793a978088f8f6b04" + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer 0.10.4", + "crypto-common 0.1.7", +] + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.0", + "crypto-common 0.2.1", + "ctutils", +] + +[[package]] +name = "dirs" +version = "4.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca3aa72a6f96ea37bbc5aa912f6788242832f75369bdfdadcb0e38423f100059" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b1d1d91c932ef41c0f2663aa8b0ca0342d444d842c06914aa0a7e352d0bada6" +dependencies = [ + "libc", + "redox_users", + "winapi", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "dummy" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1cac124e13ae9aa56acc4241f8c8207501d93afdd8d8e62f0c1f2e12f6508c65" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +dependencies = [ + "serde", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fake" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d391ba4af7f1d93f01fcf7b2f29e2bc9348e109dfdbf4dcbdc51dfa38dab0b6" +dependencies = [ + "deunicode", + "dummy", + "rand 0.8.6", + "uuid", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + +[[package]] +name = "funty" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" + +[[package]] +name = "futures-channel" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" + +[[package]] +name = "futures-io" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" + +[[package]] +name = "futures-macro" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "futures-sink" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" + +[[package]] +name = "futures-task" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" + +[[package]] +name = "futures-util" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +dependencies = [ + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "pin-utils", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "gethostname" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc3655aa6818d65bc620d6911f05aa7b6aeb596291e1e9f79e52df85583d1e30" +dependencies = [ + "rustix", + "windows-targets 0.52.6", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 6.0.0", + "rand_core 0.10.0", + "wasip2", + "wasip3", + "wasm-bindgen", +] + +[[package]] +name = "ghash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" +dependencies = [ + "opaque-debug", + "polyval", +] + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash", + "allocator-api2", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hickory-net" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2295ed2f9c31e471e1428a8f88a3f0e1f4b27c15049592138d1eebe9c35b183" +dependencies = [ + "async-trait", + "cfg-if", + "data-encoding", + "futures-channel", + "futures-io", + "futures-util", + "hickory-proto", + "idna", + "ipnet", + "jni 0.22.4", + "rand 0.10.1", + "thiserror 2.0.18", + "tinyvec", + "tokio", + "tracing", + "url", +] + +[[package]] +name = "hickory-proto" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bab31817bfb44672a252e97fe81cd0c18d1b2cf892108922f6818820df8c643" +dependencies = [ + "data-encoding", + "idna", + "ipnet", + "jni 0.22.4", + "once_cell", + "prefix-trie", + "rand 0.10.1", + "ring", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "url", +] + +[[package]] +name = "hickory-resolver" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d58d28879ceecde6607729660c2667a081ccdc082e082675042793960f178c" +dependencies = [ + "cfg-if", + "futures-util", + "hickory-net", + "hickory-proto", + "ipconfig", + "ipnet", + "jni 0.22.4", + "moka", + "ndk-context", + "once_cell", + "parking_lot", + "rand 0.10.1", + "resolv-conf", + "smallvec", + "system-configuration", + "thiserror 2.0.18", + "tokio", + "tracing", +] + +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "hybrid-array" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3944cf8cf766b40e2a1a333ee5e9b563f854d5fa49d6a8ca2764e97c6eddb214" +dependencies = [ + "typenum", +] + +[[package]] +name = "hyper" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "pin-utils", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2 0.6.2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +dependencies = [ + "displaydoc", + "potential_utf", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" + +[[package]] +name = "icu_properties" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" + +[[package]] +name = "icu_provider" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "include_dir" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "923d117408f1e49d914f1a379a309cffe4f18c05cf4e3d12e613a15fc81bd0dd" +dependencies = [ + "include_dir_macros", +] + +[[package]] +name = "include_dir_macros" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cab85a7ed0bd5f0e76d93846e0147172bed2e2d3f859bcc33a8d9699cad1a75" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "indexmap" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +dependencies = [ + "equivalent", + "hashbrown 0.16.1", + "serde", + "serde_core", +] + +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + +[[package]] +name = "ipconfig" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b58db92f96b720de98181bbbe63c831e87005ab460c1bf306eb2622b4707997f" +dependencies = [ + "socket2 0.5.10", + "widestring", + "windows-sys 0.48.0", + "winreg", +] + +[[package]] +name = "ipnet" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" +dependencies = [ + "serde", +] + +[[package]] +name = "iri-string" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c91338f0783edbd6195decb37bae672fd3b165faffb89bf7b9e6942f8b1a731a" +dependencies = [ + "memchr", + "serde", +] + +[[package]] +name = "is-docker" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3" +dependencies = [ + "once_cell", +] + +[[package]] +name = "is-wsl" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5" +dependencies = [ + "is-docker", + "once_cell", +] + +[[package]] +name = "itertools" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" + +[[package]] +name = "jni" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +dependencies = [ + "cesu8", + "cfg-if", + "combine", + "jni-sys 0.3.0", + "log", + "thiserror 1.0.69", + "walkdir", + "windows-sys 0.45.0", +] + +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys 0.4.1", + "log", + "simd_cesu8", + "thiserror 2.0.18", + "walkdir", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.114", +] + +[[package]] +name = "jni-sys" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130" + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.114", +] + +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.85" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c942ebf8e95485ca0d52d97da7c5a2c387d0e7f0ba4c35e93bfcaee045955b3" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "jsonwebtoken" +version = "10.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eba32bfb4ffdeaca3e34431072faf01745c9b26d25504aa7a6cf5684334fc4fc" +dependencies = [ + "aws-lc-rs", + "base64", + "getrandom 0.2.17", + "js-sys", + "pem", + "serde", + "serde_json", + "signature", + "simple_asn1", + "zeroize", +] + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libc" +version = "0.2.180" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc" + +[[package]] +name = "libfuzzer-sys" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9fd2f41a1cba099f79a0b6b6c35656cf7c03351a7bae8ff0f28f25270f929d2" +dependencies = [ + "arbitrary", + "cc", +] + +[[package]] +name = "libredox" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d0b95e02c851351f877147b7deea7b1afb1df71b63aa5f8270716e0c5720616" +dependencies = [ + "bitflags", + "libc", +] + +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + +[[package]] +name = "litemap" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest 0.10.7", +] + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "miette" +version = "7.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f98efec8807c63c752b5bd61f862c165c115b0a35685bdcfd9238c7aeb592b7" +dependencies = [ + "cfg-if", + "miette-derive", + "unicode-width", +] + +[[package]] +name = "miette-derive" +version = "7.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db5b29714e950dbb20d5e6f74f9dcec4edbcc1067bb7f8ed198c097b8c1a818b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "moka" +version = "0.12.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac832c50ced444ef6be0767a008b02c106a909ba79d1d830501e94b96f6b7e" +dependencies = [ + "crossbeam-channel", + "crossbeam-epoch", + "crossbeam-utils", + "equivalent", + "parking_lot", + "portable-atomic", + "smallvec", + "tagptr", + "uuid", +] + +[[package]] +name = "mutants" +version = "0.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "add0ac067452ff1aca8c5002111bd6b1c895baee6e45fcbc44e0193aea17be56" + +[[package]] +name = "ndk-context" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" + +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-conv" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +dependencies = [ + "critical-section", + "portable-atomic", +] + +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + +[[package]] +name = "open" +version = "5.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43bb73a7fa3799b198970490a51174027ba0d4ec504b03cd08caf513d40024bc" +dependencies = [ + "is-wsl", + "libc", + "pathdiff", +] + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "pathdiff" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" + +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64", + "serde_core", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "pkg-config" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" + +[[package]] +name = "polyval" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "opaque-debug", + "universal-hash", +] + +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + +[[package]] +name = "potential_utf" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "prefix-trie" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cf6e3177f0684016a5c209b00882e15f8bdd3f3bb48f0491df10cd102d0c6e7" +dependencies = [ + "either", + "ipnet", + "num-traits", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.114", +] + +[[package]] +name = "proc-macro-error-attr2" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "proc-macro-error2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802" +dependencies = [ + "proc-macro-error-attr2", + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "proptest" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37566cb3fdacef14c0737f9546df7cfeadbfbc9fef10991038bf5015d0c80532" +dependencies = [ + "bitflags", + "num-traits", + "rand 0.9.3", + "rand_chacha 0.9.0", + "rand_xorshift", + "regex-syntax", + "unarray", +] + +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2 0.6.2", + "thiserror 2.0.18", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560" +dependencies = [ + "aws-lc-rs", + "bytes", + "getrandom 0.4.2", + "lru-slab", + "rand 0.10.1", + "rand_pcg", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2 0.6.2", + "tracing", + "windows-sys 0.60.2", +] + +[[package]] +name = "quote" +version = "1.0.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "radium" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" + +[[package]] +name = "rand" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ec095654a25171c2124e9e3393a930bddbffdc939556c914957a4c3e0a87166" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + +[[package]] +name = "rand" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207" +dependencies = [ + "chacha20", + "getrandom 0.4.2", + "rand_core 0.10.0", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rand_core" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c8d0fd677905edcbeedbf2edb6494d676f0e98d54d5cf9bda0b061cb8fb8aba" + +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core 0.10.0", +] + +[[package]] +name = "rand_xorshift" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a" +dependencies = [ + "rand_core 0.9.5", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 1.0.69", +] + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a96887878f22d7bad8a3b6dc5b7440e0ada9a245242924394987b21cf2210a4c" + +[[package]] +name = "reqwest" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" +dependencies = [ + "base64", + "bytes", + "futures-core", + "futures-util", + "hickory-resolver", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "once_cell", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", +] + +[[package]] +name = "resolv-conf" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e061d1b48cb8d38042de4ae0a7a6401009d6143dc80d2e2d6f31f0bdd6470c7" + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted 0.9.0", + "windows-sys 0.52.0", +] + +[[package]] +name = "rmp" +version = "0.8.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ba8be72d372b2c9b35542551678538b562e7cf86c3315773cae48dfbfe7790c" +dependencies = [ + "num-traits", +] + +[[package]] +name = "rmp-serde" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f81bee8c8ef9b577d1681a70ebbc962c232461e397b22c208c43c04b67a155" +dependencies = [ + "rmp", + "serde", +] + +[[package]] +name = "rustc-hash" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.59.0", +] + +[[package]] +name = "rustls" +version = "0.23.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c665f33d38cea657d9614f766881e4d510e0eda4239891eea56b4cadcf01801b" +dependencies = [ + "aws-lc-rs", + "once_cell", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-platform-verifier" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d99feebc72bae7ab76ba994bb5e121b8d83d910ca40b36e0921f53becc41784" +dependencies = [ + "core-foundation 0.10.1", + "core-foundation-sys", + "jni 0.21.1", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "aws-lc-rs", + "ring", + "rustls-pki-types", + "untrusted 0.9.0", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schannel" +version = "0.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "security-framework" +version = "3.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3297343eaf830f66ede390ea39da1d462b6b0c1b000f420d0a83f898bbbe6ef" +dependencies = [ + "bitflags", + "core-foundation 0.10.1", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc1f0cbffaac4852523ce30d8bd3c5cdc873501d96ff467ca09b6767bb8cd5c0" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "semver" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_bytes" +version = "0.11.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sha1_smol" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbfa15b3dddfee50a0fff136974b3e1bde555604ba463834a7eb7deb6417705d" + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "rand_core 0.6.4", +] + +[[package]] +name = "simd-adler32" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" + +[[package]] +name = "simd_cesu8" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "simple_asn1" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d" +dependencies = [ + "num-bigint", + "num-traits", + "thiserror 2.0.18", + "time", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.5.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + +[[package]] +name = "socket2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86f4aa3ad99f2088c990dfa82d367e19cb29268ed67c574d10d0a4bfe71f07e0" +dependencies = [ + "libc", + "windows-sys 0.60.2", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "stack-auth" +version = "0.42.3" +dependencies = [ + "aquamarine", + "base64", + "bytes", + "cts-common", + "jsonwebtoken", + "miette", + "open", + "reqwest", + "serde", + "serde_json", + "serde_urlencoded", + "stack-profile", + "thiserror 1.0.69", + "tokio", + "tracing", + "url", + "uuid", + "vitaminc", + "vitaminc-protected", + "web-time", + "zeroize", + "zerokms-protocol", +] + +[[package]] +name = "stack-auth-fuzz" +version = "0.0.0" +dependencies = [ + "libfuzzer-sys", + "stack-auth", +] + +[[package]] +name = "stack-profile" +version = "0.42.3" +dependencies = [ + "dirs", + "gethostname", + "serde", + "serde_json", + "thiserror 1.0.69", + "uuid", +] + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4d107df263a3013ef9b1879b0df87d706ff80f65a86ea879bd9c31f9b307c2a" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "system-configuration" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" +dependencies = [ + "bitflags", + "core-foundation 0.9.4", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "tagptr" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417" + +[[package]] +name = "tap" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "time" +version = "0.3.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" + +[[package]] +name = "time-macros" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.49.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2 0.6.2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" +dependencies = [ + "async-compression", + "bitflags", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "iri-string", + "pin-project-lite", + "tokio", + "tokio-util", + "tower", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "typenum" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + +[[package]] +name = "unarray" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" + +[[package]] +name = "unicode-ident" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "537dd038a89878be9b64dd4bd1b260315c1bb94f4d784956b81e27a088d9a09e" + +[[package]] +name = "unicode-segmentation" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" + +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common 0.1.7", + "subtle", +] + +[[package]] +name = "untrusted" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", + "serde_derive", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utoipa" +version = "5.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fcc29c80c21c31608227e0912b2d7fddba57ad76b606890627ba8ee7964e993" +dependencies = [ + "indexmap", + "serde", + "serde_json", + "utoipa-gen", +] + +[[package]] +name = "utoipa-gen" +version = "5.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d79d08d92ab8af4c5e8a6da20c47ae3f61a0f1dabc1997cdf2d082b757ca08b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", + "url", + "uuid", +] + +[[package]] +name = "uuid" +version = "1.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee48d38b119b0cd71fe4141b30f5ba9c7c5d9f4e7a3a8b4a674e4b6ef789976f" +dependencies = [ + "atomic", + "getrandom 0.3.4", + "js-sys", + "md-5", + "serde_core", + "sha1_smol", + "wasm-bindgen", +] + +[[package]] +name = "validator" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43fb22e1a008ece370ce08a3e9e4447a910e92621bb49b85d6e48a45397e7cfa" +dependencies = [ + "idna", + "once_cell", + "regex", + "serde", + "serde_derive", + "serde_json", + "url", + "validator_derive", +] + +[[package]] +name = "validator_derive" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7df16e474ef958526d1205f6dda359fdfab79d9aa6d54bafcb92dcd07673dca" +dependencies = [ + "darling", + "once_cell", + "proc-macro-error2", + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "vitaminc" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f371b877e1586acb8ffc9efd0bd6a7c7ff7d8c4b1529c1f46d34b1df3478699" +dependencies = [ + "vitaminc-aead", + "vitaminc-context", + "vitaminc-encrypt", + "vitaminc-protected", + "vitaminc-random", + "vitaminc-traits", +] + +[[package]] +name = "vitaminc-aead" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0238d5b77821fcd46d2f72b082532209d625892c69e40008e403b709a1dd0fa" +dependencies = [ + "bytes", + "serde", + "vitaminc-aead-derive", + "vitaminc-context", + "vitaminc-protected", + "vitaminc-random", + "zeroize", +] + +[[package]] +name = "vitaminc-aead-derive" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5a81524f1e096bc45a1e7901186546bc0cf5545a9a86e978581a7146c54a5c7" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "vitaminc-context" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e7b3e0602ad490039680222ba23217264eab0f400495c072ec11e1a2235b304" +dependencies = [ + "mutants", + "vitaminc-protected", +] + +[[package]] +name = "vitaminc-encrypt" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b969f689aebd40637a6ba9243618a62057ee9c76a8b70c6661c9b0c28ba20275" +dependencies = [ + "aes-gcm", + "aws-lc-rs", + "vitaminc-aead", + "vitaminc-protected", + "vitaminc-random", + "zeroize", +] + +[[package]] +name = "vitaminc-protected" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a13b616fa4e7093f4970dacd6f44ca76a6929bee1c72612134353a69bb52c62" +dependencies = [ + "bitvec", + "digest 0.11.3", + "libc", + "serde", + "serde_bytes", + "subtle", + "thiserror 2.0.18", + "vitaminc-protected-derive", + "zeroize", +] + +[[package]] +name = "vitaminc-protected-derive" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47c3f8db2eff8108d078a77125f20974448b9a66950bdc120e35b0b53eda4c98" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "vitaminc-random" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8320b63fad9560026b036cb6eb9b85efb1d666af52862af923a2c75972c4282" +dependencies = [ + "chacha20", + "getrandom 0.4.2", + "rand 0.10.1", + "thiserror 2.0.18", + "vitaminc-protected", + "vitaminc-random-derives", + "zeroize", +] + +[[package]] +name = "vitaminc-random-derives" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdbc48a9e3893406046f51f51c3d2bb1dffe70d6ca3b19920ca12d231ed12b68" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "vitaminc-traits" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c53613363efb0c47aba19ea2bfe0142cba8b2d0fe1bc27d43b0ea3390f6efe7" +dependencies = [ + "anyhow", + "bytes", + "rmp-serde", + "serde", + "thiserror 2.0.18", + "vitaminc-protected", + "vitaminc-random", + "zeroize", +] + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.2+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64024a30ec1e37399cf85a7ffefebdb72205ca1c972291c51512360d90bd8566" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70a6e77fd0ae8029c9ea0063f87c46fde723e7d887703d74ad2616d792e51e6f" +dependencies = [ + "cfg-if", + "futures-util", + "js-sys", + "once_cell", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "008b239d9c740232e71bd39e8ef6429d27097518b6b30bdf9086833bd5b6d608" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5256bae2d58f54820e6490f9839c49780dff84c65aeab9e772f15d5f0e913a55" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.114", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f01b580c9ac74c8d8f0c0e4afb04eeef2acf145458e52c03845ee9cd23e3d12" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasm-streams" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "web-sys" +version = "0.3.85" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "312e32e551d92129218ea9a2452120f4aabc03529ef03e4d0d82fb2780608598" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-root-certs" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "804f18a4ac2676ffb4e8b5b5fa9ae38af06df08162314f96a68d2a363e21a8ca" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "widestring" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471" + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets 0.42.2", +] + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "winreg" +version = "0.50.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "524e57b2c537c0f9b1e69f1965311ec12182b4122e45035b1508cd24d2adadb1" +dependencies = [ + "cfg-if", + "windows-sys 0.48.0", +] + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn 2.0.114", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn 2.0.114", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "writeable" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" + +[[package]] +name = "wyz" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed" +dependencies = [ + "tap", +] + +[[package]] +name = "yoke" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.39" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db6d35d663eadb6c932438e763b262fe1a70987f9ae936e60158176d710cae4a" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.39" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4122cd3169e94605190e77839c9a40d40ed048d305bfdc146e7df40ab0f3e517" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "zerofrom" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "zerokms-protocol" +version = "0.12.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c28e88315a5109d0a1e7ee4b7b4b8776a0bff5f5b139ae83960a3debe84e92e" +dependencies = [ + "base64", + "cipherstash-config", + "const-hex", + "cts-common", + "fake", + "getrandom 0.2.17", + "opaque-debug", + "rand 0.8.6", + "serde", + "static_assertions", + "thiserror 1.0.69", + "utoipa", + "uuid", + "validator", + "zeroize", +] + +[[package]] +name = "zerotrie" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "zmij" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4de98dfa5d5b7fef4ee834d0073d560c9ca7b6c46a71d058c48db7960f8cfaf7" diff --git a/packages/stack-auth/tasks.toml b/packages/stack-auth/tasks.toml index 8d21bd744..31a2a6b40 100644 --- a/packages/stack-auth/tasks.toml +++ b/packages/stack-auth/tasks.toml @@ -13,12 +13,11 @@ run = "mise x --env test -- cargo test -p stack-auth --doc --all-features" ["test:integration:stack-auth"] description = "Run stack-auth Node.js integration tests" -dir = "{{config_root}}/packages/stack-auth/node" +dir = "{{config_root}}/languages/typescript/packages/auth" run = [ - "npm install", "cargo build -p stack-auth-node", - "cp ../../../target/debug/libstack_auth_node.dylib stack-auth-node.node 2>/dev/null || cp ../../../target/debug/libstack_auth_node.so stack-auth-node.node", - "npx vitest run", + "cp ../../../../target/debug/libstack_auth_node.dylib stack-auth-node.node 2>/dev/null || cp ../../../../target/debug/libstack_auth_node.so stack-auth-node.node", + "pnpm exec vitest run", ] ["crap:stack-auth"] diff --git a/packages/stack-encrypt/fuzz/Cargo.lock b/packages/stack-encrypt/fuzz/Cargo.lock new file mode 100644 index 000000000..32e39436e --- /dev/null +++ b/packages/stack-encrypt/fuzz/Cargo.lock @@ -0,0 +1,3236 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common 0.1.7", + "generic-array", +] + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures 0.2.17", +] + +[[package]] +name = "aes-gcm" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" +dependencies = [ + "aead", + "aes", + "cipher", + "ctr", + "ghash", + "subtle", + "zeroize", +] + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anyhow" +version = "1.0.101" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f0e0fee31ef5ed1ba1316088939cea399010ed7731dba877ed44aeb407a75ea" + +[[package]] +name = "aquamarine" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f50776554130342de4836ba542aa85a4ddb361690d7e8df13774d7284c3d5c2" +dependencies = [ + "include_dir", + "itertools", + "proc-macro-error2", + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" +dependencies = [ + "derive_arbitrary", +] + +[[package]] +name = "arrayref" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" + +[[package]] +name = "arrayvec" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" +dependencies = [ + "serde", + "zeroize", +] + +[[package]] +name = "atomic" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89cbf775b137e9b968e67227ef7f775587cde3fd31b0d8599dbd0f598a48340" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "aws-lc-rs" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce2b2dcc879c3bae0d371e77c99f2238400ef24ec001394befa67b6e543add9e" +dependencies = [ + "aws-lc-sys", + "untrusted", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.44.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f09fae7be8bb3174e05c6afdb34199e6dc0c7c04ba9fa237b1967adfbde27483" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", + "pkg-config", +] + +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + +[[package]] +name = "base32" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "022dfe9eb35f19ebbcb51e0b40a5ab759f46ad60cadf7297e0bd085afb50e076" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bitflags" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" + +[[package]] +name = "bitvec" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bc2832c24239b0141d5674bb9174f9d68a8b5b3f2753311927c172ca46f7e9c" +dependencies = [ + "funty", + "radium", + "tap", + "wyz", +] + +[[package]] +name = "blake3" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2468ef7d57b3fb7e16b576e8377cdbde2320c60e1491e961d11da40fc4f02a2d" +dependencies = [ + "arrayref", + "arrayvec", + "cc", + "cfg-if", + "constant_time_eq", + "cpufeatures 0.2.17", + "zeroize", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block-buffer" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdd35008169921d80bc60d3d0ab416eecb028c4cd653352907921d95084790be" +dependencies = [ + "hybrid-array", + "zeroize", +] + +[[package]] +name = "bumpalo" +version = "3.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" + +[[package]] +name = "bytemuck" +version = "1.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +dependencies = [ + "serde", +] + +[[package]] +name = "cached" +version = "0.54.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9718806c4a2fe9e8a56fd736f97b340dd10ed1be8ed733ed50449f351dc33cae" +dependencies = [ + "ahash", + "cached_proc_macro", + "cached_proc_macro_types", + "hashbrown 0.14.5", + "once_cell", + "thiserror 1.0.69", + "web-time", +] + +[[package]] +name = "cached_proc_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f42a145ed2d10dce2191e1dcf30cfccfea9026660e143662ba5eec4017d5daa" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "cached_proc_macro_types" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ade8366b8bd5ba243f0a58f036cc0ca8a2f069cff1a2351ef1cac6b083e16fc0" + +[[package]] +name = "cc" +version = "1.2.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b26a0954ae34af09b50f0de26458fa95369a0d478d8236d3f93082b219bd29" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "chacha20" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core 0.10.0", + "zeroize", +] + +[[package]] +name = "chrono" +version = "0.4.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fac4744fb15ae8337dc853fee7fb3f4e48c0fbaa23d0afe49c447b4fab126118" +dependencies = [ + "iana-time-zone", + "num-traits", + "serde", + "windows-link", +] + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common 0.1.7", + "inout", +] + +[[package]] +name = "cipherstash-config" +version = "0.42.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d098935e395d7346d0cdc8cdf3ed9674ab03fa8b415e828d02e65c81836a73c" +dependencies = [ + "bitflags", + "serde", + "serde_json", + "thiserror 1.0.69", +] + +[[package]] +name = "cllw-ore" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "476f300d37a5029d3d9dd57145d4db50a23f40ae9b4d1374c44543978b906191" +dependencies = [ + "blake3", + "hex", + "subtle", + "thiserror 1.0.69", + "unicode-normalization", + "zeroize", +] + +[[package]] +name = "cmac" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8543454e3c3f5126effff9cd44d562af4e31fb8ce1cc0d3dcd8f084515dbc1aa" +dependencies = [ + "cipher", + "dbl", + "digest 0.10.7", +] + +[[package]] +name = "cmake" +version = "0.1.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75443c44cd6b379beb8c5b45d85d0773baf31cce901fe7bb252f4eff3008ef7d" +dependencies = [ + "cc", +] + +[[package]] +name = "cmov" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" + +[[package]] +name = "const-hex" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3bb320cac8a0750d7f25280aa97b09c26edfe161164238ecbbb31092b079e735" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "proptest", + "serde_core", +] + +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + +[[package]] +name = "constant_time_eq" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" + +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "crypto-common" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77727bb15fa921304124b128af125e7e3b968275d1b108b379190264f4423710" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher", +] + +[[package]] +name = "cts-common" +version = "0.43.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9cb0f5ffa463e8facbe6ad78cfe925d132a051c6b1c9a5da2f3961296b7e632" +dependencies = [ + "arrayvec", + "base32", + "cached", + "chrono", + "derive_more", + "either", + "getrandom 0.4.2", + "miette", + "nom", + "regex", + "serde", + "serde_json", + "thiserror 1.0.69", + "tracing", + "url", + "utoipa", + "uuid", + "vitaminc", +] + +[[package]] +name = "ctutils" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" +dependencies = [ + "cmov", +] + +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.114", +] + +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "dbl" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd2735a791158376708f9347fe8faba9667589d82427ef3aed6794a8981de3d9" +dependencies = [ + "generic-array", +] + +[[package]] +name = "deranged" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ececcb659e7ba858fb4f10388c250a7252eb0a27373f1a72b8748afdd248e587" +dependencies = [ + "powerfmt", +] + +[[package]] +name = "derive_arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.114", + "unicode-xid", +] + +[[package]] +name = "deunicode" +version = "1.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abd57806937c9cc163efc8ea3910e00a62e2aeb0b8119f1793a978088f8f6b04" + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer 0.10.4", + "crypto-common 0.1.7", + "subtle", +] + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.0", + "const-oid", + "crypto-common 0.2.1", + "ctutils", + "zeroize", +] + +[[package]] +name = "dirs" +version = "4.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca3aa72a6f96ea37bbc5aa912f6788242832f75369bdfdadcb0e38423f100059" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b1d1d91c932ef41c0f2663aa8b0ca0342d444d842c06914aa0a7e352d0bada6" +dependencies = [ + "libc", + "redox_users", + "winapi", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "dummy" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1cac124e13ae9aa56acc4241f8c8207501d93afdd8d8e62f0c1f2e12f6508c65" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +dependencies = [ + "serde", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fake" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d391ba4af7f1d93f01fcf7b2f29e2bc9348e109dfdbf4dcbdc51dfa38dab0b6" +dependencies = [ + "deunicode", + "dummy", + "rand 0.8.6", + "uuid", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + +[[package]] +name = "funty" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" + +[[package]] +name = "futures" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" + +[[package]] +name = "futures-executor" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" + +[[package]] +name = "futures-macro" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "futures-sink" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" + +[[package]] +name = "futures-task" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" + +[[package]] +name = "futures-util" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "pin-utils", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "gethostname" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc3655aa6818d65bc620d6911f05aa7b6aeb596291e1e9f79e52df85583d1e30" +dependencies = [ + "rustix", + "windows-targets 0.52.6", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 6.0.0", + "rand_core 0.10.0", + "wasip2", + "wasip3", + "wasm-bindgen", +] + +[[package]] +name = "ghash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" +dependencies = [ + "opaque-debug", + "polyval", +] + +[[package]] +name = "half" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b43ede17f21864e81be2fa654110bf1e793774238d86ef8555c37e6519c0403" + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash", + "allocator-api2", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hex-literal" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ebdb29d2ea9ed0083cd8cece49bbd968021bd99b0849edb4a9a7ee0fdf6a4e0" + +[[package]] +name = "hmac" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" +dependencies = [ + "digest 0.11.3", +] + +[[package]] +name = "hybrid-array" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3944cf8cf766b40e2a1a333ee5e9b563f854d5fa49d6a8ca2764e97c6eddb214" +dependencies = [ + "typenum", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +dependencies = [ + "displaydoc", + "potential_utf", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" + +[[package]] +name = "icu_properties" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" + +[[package]] +name = "icu_provider" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "include_dir" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "923d117408f1e49d914f1a379a309cffe4f18c05cf4e3d12e613a15fc81bd0dd" +dependencies = [ + "include_dir_macros", +] + +[[package]] +name = "include_dir_macros" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cab85a7ed0bd5f0e76d93846e0147172bed2e2d3f859bcc33a8d9699cad1a75" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "indexmap" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +dependencies = [ + "equivalent", + "hashbrown 0.16.1", + "serde", + "serde_core", +] + +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + +[[package]] +name = "is-docker" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3" +dependencies = [ + "once_cell", +] + +[[package]] +name = "is-wsl" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5" +dependencies = [ + "is-docker", + "once_cell", +] + +[[package]] +name = "itertools" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" + +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.85" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c942ebf8e95485ca0d52d97da7c5a2c387d0e7f0ba4c35e93bfcaee045955b3" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "jsonwebtoken" +version = "10.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eba32bfb4ffdeaca3e34431072faf01745c9b26d25504aa7a6cf5684334fc4fc" +dependencies = [ + "aws-lc-rs", + "base64", + "getrandom 0.2.17", + "js-sys", + "pem", + "serde", + "serde_json", + "signature", + "simple_asn1", + "zeroize", +] + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libc" +version = "0.2.180" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc" + +[[package]] +name = "libfuzzer-sys" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9fd2f41a1cba099f79a0b6b6c35656cf7c03351a7bae8ff0f28f25270f929d2" +dependencies = [ + "arbitrary", + "cc", +] + +[[package]] +name = "libredox" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d0b95e02c851351f877147b7deea7b1afb1df71b63aa5f8270716e0c5720616" +dependencies = [ + "bitflags", + "libc", +] + +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + +[[package]] +name = "litemap" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest 0.10.7", +] + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "miette" +version = "7.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f98efec8807c63c752b5bd61f862c165c115b0a35685bdcfd9238c7aeb592b7" +dependencies = [ + "cfg-if", + "miette-derive", + "unicode-width", +] + +[[package]] +name = "miette-derive" +version = "7.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db5b29714e950dbb20d5e6f74f9dcec4edbcc1067bb7f8ed198c097b8c1a818b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "mio" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "mutants" +version = "0.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "add0ac067452ff1aca8c5002111bd6b1c895baee6e45fcbc44e0193aea17be56" + +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-conv" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + +[[package]] +name = "open" +version = "5.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43bb73a7fa3799b198970490a51174027ba0d4ec504b03cd08caf513d40024bc" +dependencies = [ + "is-wsl", + "libc", + "pathdiff", +] + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "pathdiff" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" + +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64", + "serde_core", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "pkg-config" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" + +[[package]] +name = "polyval" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "opaque-debug", + "universal-hash", +] + +[[package]] +name = "potential_utf" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.114", +] + +[[package]] +name = "proc-macro-error-attr2" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "proc-macro-error2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802" +dependencies = [ + "proc-macro-error-attr2", + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "proptest" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37566cb3fdacef14c0737f9546df7cfeadbfbc9fef10991038bf5015d0c80532" +dependencies = [ + "bitflags", + "num-traits", + "rand 0.9.3", + "rand_chacha 0.9.0", + "rand_xorshift", + "regex-syntax", + "unarray", +] + +[[package]] +name = "quote" +version = "1.0.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "radium" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" + +[[package]] +name = "rand" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ec095654a25171c2124e9e3393a930bddbffdc939556c914957a4c3e0a87166" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + +[[package]] +name = "rand" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207" +dependencies = [ + "chacha20", + "getrandom 0.4.2", + "rand_core 0.10.0", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rand_core" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c8d0fd677905edcbeedbf2edb6494d676f0e98d54d5cf9bda0b061cb8fb8aba" + +[[package]] +name = "rand_xorshift" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a" +dependencies = [ + "rand_core 0.9.5", +] + +[[package]] +name = "recipher" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e14e156e2d485b51cc67c19241e7d81ad524bda9fd4f77791b698ab10c8e26e9" +dependencies = [ + "aes", + "cmac", + "getrandom 0.2.17", + "hex", + "hex-literal", + "opaque-debug", + "rand 0.8.6", + "rand_chacha 0.3.1", + "serde", + "serde_cbor", + "sha2 0.10.9", + "thiserror 1.0.69", + "zeroize", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 1.0.69", +] + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a96887878f22d7bad8a3b6dc5b7440e0ada9a245242924394987b21cf2210a4c" + +[[package]] +name = "rmp" +version = "0.8.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ba8be72d372b2c9b35542551678538b562e7cf86c3315773cae48dfbfe7790c" +dependencies = [ + "num-traits", +] + +[[package]] +name = "rmp-serde" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f81bee8c8ef9b577d1681a70ebbc962c232461e397b22c208c43c04b67a155" +dependencies = [ + "rmp", + "serde", +] + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.59.0", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "semver" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_bytes" +version = "0.11.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "serde_cbor" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bef2ebfde456fb76bbcf9f59315333decc4fda0b2b44b420243c11e0f5ec1f5" +dependencies = [ + "half", + "serde", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "serdect" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f42f67da2385b51a5f9652db9c93d78aeaf7610bf5ec366080b6de810604af53" +dependencies = [ + "base16ct", + "serde", + "zeroize", +] + +[[package]] +name = "sha1_smol" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbfa15b3dddfee50a0fff136974b3e1bde555604ba463834a7eb7deb6417705d" + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "digest 0.11.3", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "rand_core 0.6.4", +] + +[[package]] +name = "simple_asn1" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d" +dependencies = [ + "num-bigint", + "num-traits", + "thiserror 2.0.18", + "time", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86f4aa3ad99f2088c990dfa82d367e19cb29268ed67c574d10d0a4bfe71f07e0" +dependencies = [ + "libc", + "windows-sys 0.60.2", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "stack-auth" +version = "0.42.3" +dependencies = [ + "aquamarine", + "base64", + "cts-common", + "jsonwebtoken", + "miette", + "open", + "serde", + "serde_json", + "serde_urlencoded", + "stack-profile", + "thiserror 1.0.69", + "tokio", + "tracing", + "url", + "uuid", + "vitaminc", + "vitaminc-protected", + "web-time", + "zeroize", + "zerokms-protocol", +] + +[[package]] +name = "stack-encrypt" +version = "0.1.0" +dependencies = [ + "base64ct", + "cllw-ore", + "serde", + "stack-encrypt-derive", + "stack-kms", + "thiserror 1.0.69", + "uuid", + "vitaminc-aead", + "vitaminc-aead-value", + "vitaminc-encrypt", + "vitaminc-hmac", + "vitaminc-prf", + "vitaminc-protected", + "zeroize", +] + +[[package]] +name = "stack-encrypt-derive" +version = "0.1.0" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "stack-encrypt-fuzz" +version = "0.0.0" +dependencies = [ + "arbitrary", + "libfuzzer-sys", + "stack-encrypt", + "uuid", +] + +[[package]] +name = "stack-kms" +version = "0.1.0" +dependencies = [ + "base16ct", + "base64ct", + "blake3", + "futures", + "miette", + "opaque-debug", + "recipher", + "serde", + "serde_cbor", + "serde_json", + "serdect", + "sha2 0.10.9", + "stack-auth", + "stack-profile", + "thiserror 1.0.69", + "tracing", + "url", + "uuid", + "vitaminc", + "vitaminc-protected", + "zeroize", + "zerokms-protocol", +] + +[[package]] +name = "stack-profile" +version = "0.42.3" +dependencies = [ + "dirs", + "gethostname", + "serde", + "serde_json", + "thiserror 1.0.69", + "uuid", +] + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4d107df263a3013ef9b1879b0df87d706ff80f65a86ea879bd9c31f9b307c2a" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "tap" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "time" +version = "0.3.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" + +[[package]] +name = "time-macros" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.49.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "typenum" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + +[[package]] +name = "unarray" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" + +[[package]] +name = "unicode-ident" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "537dd038a89878be9b64dd4bd1b260315c1bb94f4d784956b81e27a088d9a09e" + +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-segmentation" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" + +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common 0.1.7", + "subtle", +] + +[[package]] +name = "untrusted" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", + "serde_derive", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utoipa" +version = "5.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fcc29c80c21c31608227e0912b2d7fddba57ad76b606890627ba8ee7964e993" +dependencies = [ + "indexmap", + "serde", + "serde_json", + "utoipa-gen", +] + +[[package]] +name = "utoipa-gen" +version = "5.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d79d08d92ab8af4c5e8a6da20c47ae3f61a0f1dabc1997cdf2d082b757ca08b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", + "url", + "uuid", +] + +[[package]] +name = "uuid" +version = "1.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee48d38b119b0cd71fe4141b30f5ba9c7c5d9f4e7a3a8b4a674e4b6ef789976f" +dependencies = [ + "atomic", + "getrandom 0.3.4", + "js-sys", + "md-5", + "serde_core", + "sha1_smol", + "wasm-bindgen", +] + +[[package]] +name = "validator" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43fb22e1a008ece370ce08a3e9e4447a910e92621bb49b85d6e48a45397e7cfa" +dependencies = [ + "idna", + "once_cell", + "regex", + "serde", + "serde_derive", + "serde_json", + "url", + "validator_derive", +] + +[[package]] +name = "validator_derive" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7df16e474ef958526d1205f6dda359fdfab79d9aa6d54bafcb92dcd07673dca" +dependencies = [ + "darling", + "once_cell", + "proc-macro-error2", + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "vitaminc" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f371b877e1586acb8ffc9efd0bd6a7c7ff7d8c4b1529c1f46d34b1df3478699" +dependencies = [ + "vitaminc-aead", + "vitaminc-context", + "vitaminc-encrypt", + "vitaminc-protected", + "vitaminc-random", + "vitaminc-traits", +] + +[[package]] +name = "vitaminc-aead" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0238d5b77821fcd46d2f72b082532209d625892c69e40008e403b709a1dd0fa" +dependencies = [ + "bytes", + "serde", + "vitaminc-aead-derive", + "vitaminc-context", + "vitaminc-protected", + "vitaminc-random", + "zeroize", +] + +[[package]] +name = "vitaminc-aead-derive" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5a81524f1e096bc45a1e7901186546bc0cf5545a9a86e978581a7146c54a5c7" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "vitaminc-aead-value" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b63326e8bf21f695080c50d8d849324fa92e4cf6ce7257bef198b1ff2eeea0d" +dependencies = [ + "vitaminc-aead", + "vitaminc-protected", + "zeroize", +] + +[[package]] +name = "vitaminc-context" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e7b3e0602ad490039680222ba23217264eab0f400495c072ec11e1a2235b304" +dependencies = [ + "mutants", + "vitaminc-protected", +] + +[[package]] +name = "vitaminc-encrypt" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b969f689aebd40637a6ba9243618a62057ee9c76a8b70c6661c9b0c28ba20275" +dependencies = [ + "aes-gcm", + "aws-lc-rs", + "vitaminc-aead", + "vitaminc-protected", + "vitaminc-random", + "zeroize", +] + +[[package]] +name = "vitaminc-hmac" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccebde615f15197146a3fe4b3ae32ff00a6ae489f88ae9ee0b71e1cbdd286d94" +dependencies = [ + "hmac", + "sha2 0.11.0", + "vitaminc-prf", + "vitaminc-protected", + "zeroize", +] + +[[package]] +name = "vitaminc-prf" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0e6242717d2a5b3f0fdbdaf74340b60eb07713de537d5f82508065a8bfd7ef3" +dependencies = [ + "mutants", + "thiserror 2.0.18", + "vitaminc-context", + "vitaminc-protected", +] + +[[package]] +name = "vitaminc-protected" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a13b616fa4e7093f4970dacd6f44ca76a6929bee1c72612134353a69bb52c62" +dependencies = [ + "bitvec", + "digest 0.11.3", + "libc", + "serde", + "serde_bytes", + "subtle", + "thiserror 2.0.18", + "vitaminc-protected-derive", + "zeroize", +] + +[[package]] +name = "vitaminc-protected-derive" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47c3f8db2eff8108d078a77125f20974448b9a66950bdc120e35b0b53eda4c98" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "vitaminc-random" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8320b63fad9560026b036cb6eb9b85efb1d666af52862af923a2c75972c4282" +dependencies = [ + "chacha20", + "getrandom 0.4.2", + "rand 0.10.1", + "thiserror 2.0.18", + "vitaminc-protected", + "vitaminc-random-derives", + "zeroize", +] + +[[package]] +name = "vitaminc-random-derives" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdbc48a9e3893406046f51f51c3d2bb1dffe70d6ca3b19920ca12d231ed12b68" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "vitaminc-traits" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c53613363efb0c47aba19ea2bfe0142cba8b2d0fe1bc27d43b0ea3390f6efe7" +dependencies = [ + "anyhow", + "bytes", + "rmp-serde", + "serde", + "thiserror 2.0.18", + "vitaminc-protected", + "vitaminc-random", + "zeroize", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.2+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64024a30ec1e37399cf85a7ffefebdb72205ca1c972291c51512360d90bd8566" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "008b239d9c740232e71bd39e8ef6429d27097518b6b30bdf9086833bd5b6d608" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5256bae2d58f54820e6490f9839c49780dff84c65aeab9e772f15d5f0e913a55" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.114", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f01b580c9ac74c8d8f0c0e4afb04eeef2acf145458e52c03845ee9cd23e3d12" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn 2.0.114", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn 2.0.114", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "writeable" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" + +[[package]] +name = "wyz" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed" +dependencies = [ + "tap", +] + +[[package]] +name = "yoke" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.39" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db6d35d663eadb6c932438e763b262fe1a70987f9ae936e60158176d710cae4a" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.39" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4122cd3169e94605190e77839c9a40d40ed048d305bfdc146e7df40ab0f3e517" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "zerofrom" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "zerokms-protocol" +version = "0.12.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c28e88315a5109d0a1e7ee4b7b4b8776a0bff5f5b139ae83960a3debe84e92e" +dependencies = [ + "base64", + "cipherstash-config", + "const-hex", + "cts-common", + "fake", + "getrandom 0.2.17", + "opaque-debug", + "rand 0.8.6", + "serde", + "static_assertions", + "thiserror 1.0.69", + "utoipa", + "uuid", + "validator", + "zeroize", +] + +[[package]] +name = "zerotrie" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "zmij" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4de98dfa5d5b7fef4ee834d0073d560c9ca7b6c46a71d058c48db7960f8cfaf7" diff --git a/packages/stack-kms/fuzz/Cargo.lock b/packages/stack-kms/fuzz/Cargo.lock new file mode 100644 index 000000000..ebe0a0f54 --- /dev/null +++ b/packages/stack-kms/fuzz/Cargo.lock @@ -0,0 +1,3088 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common 0.1.7", + "generic-array", +] + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures 0.2.17", +] + +[[package]] +name = "aes-gcm" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" +dependencies = [ + "aead", + "aes", + "cipher", + "ctr", + "ghash", + "subtle", + "zeroize", +] + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anyhow" +version = "1.0.101" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f0e0fee31ef5ed1ba1316088939cea399010ed7731dba877ed44aeb407a75ea" + +[[package]] +name = "aquamarine" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f50776554130342de4836ba542aa85a4ddb361690d7e8df13774d7284c3d5c2" +dependencies = [ + "include_dir", + "itertools", + "proc-macro-error2", + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" + +[[package]] +name = "arrayref" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" + +[[package]] +name = "arrayvec" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" +dependencies = [ + "serde", + "zeroize", +] + +[[package]] +name = "atomic" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89cbf775b137e9b968e67227ef7f775587cde3fd31b0d8599dbd0f598a48340" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "aws-lc-rs" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce2b2dcc879c3bae0d371e77c99f2238400ef24ec001394befa67b6e543add9e" +dependencies = [ + "aws-lc-sys", + "untrusted", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.44.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f09fae7be8bb3174e05c6afdb34199e6dc0c7c04ba9fa237b1967adfbde27483" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", + "pkg-config", +] + +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + +[[package]] +name = "base32" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "022dfe9eb35f19ebbcb51e0b40a5ab759f46ad60cadf7297e0bd085afb50e076" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bitflags" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" + +[[package]] +name = "bitvec" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bc2832c24239b0141d5674bb9174f9d68a8b5b3f2753311927c172ca46f7e9c" +dependencies = [ + "funty", + "radium", + "tap", + "wyz", +] + +[[package]] +name = "blake3" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2468ef7d57b3fb7e16b576e8377cdbde2320c60e1491e961d11da40fc4f02a2d" +dependencies = [ + "arrayref", + "arrayvec", + "cc", + "cfg-if", + "constant_time_eq", + "cpufeatures 0.2.17", + "zeroize", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block-buffer" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdd35008169921d80bc60d3d0ab416eecb028c4cd653352907921d95084790be" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "bumpalo" +version = "3.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" + +[[package]] +name = "bytemuck" +version = "1.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +dependencies = [ + "serde", +] + +[[package]] +name = "cached" +version = "0.54.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9718806c4a2fe9e8a56fd736f97b340dd10ed1be8ed733ed50449f351dc33cae" +dependencies = [ + "ahash", + "cached_proc_macro", + "cached_proc_macro_types", + "hashbrown 0.14.5", + "once_cell", + "thiserror 1.0.69", + "web-time", +] + +[[package]] +name = "cached_proc_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f42a145ed2d10dce2191e1dcf30cfccfea9026660e143662ba5eec4017d5daa" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "cached_proc_macro_types" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ade8366b8bd5ba243f0a58f036cc0ca8a2f069cff1a2351ef1cac6b083e16fc0" + +[[package]] +name = "cc" +version = "1.2.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b26a0954ae34af09b50f0de26458fa95369a0d478d8236d3f93082b219bd29" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "chacha20" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core 0.10.0", + "zeroize", +] + +[[package]] +name = "chrono" +version = "0.4.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fac4744fb15ae8337dc853fee7fb3f4e48c0fbaa23d0afe49c447b4fab126118" +dependencies = [ + "iana-time-zone", + "num-traits", + "serde", + "windows-link", +] + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common 0.1.7", + "inout", +] + +[[package]] +name = "cipherstash-config" +version = "0.42.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d098935e395d7346d0cdc8cdf3ed9674ab03fa8b415e828d02e65c81836a73c" +dependencies = [ + "bitflags", + "serde", + "serde_json", + "thiserror 1.0.69", +] + +[[package]] +name = "cmac" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8543454e3c3f5126effff9cd44d562af4e31fb8ce1cc0d3dcd8f084515dbc1aa" +dependencies = [ + "cipher", + "dbl", + "digest 0.10.7", +] + +[[package]] +name = "cmake" +version = "0.1.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75443c44cd6b379beb8c5b45d85d0773baf31cce901fe7bb252f4eff3008ef7d" +dependencies = [ + "cc", +] + +[[package]] +name = "cmov" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" + +[[package]] +name = "const-hex" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3bb320cac8a0750d7f25280aa97b09c26edfe161164238ecbbb31092b079e735" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "proptest", + "serde_core", +] + +[[package]] +name = "constant_time_eq" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" + +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "crypto-common" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77727bb15fa921304124b128af125e7e3b968275d1b108b379190264f4423710" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher", +] + +[[package]] +name = "cts-common" +version = "0.43.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9cb0f5ffa463e8facbe6ad78cfe925d132a051c6b1c9a5da2f3961296b7e632" +dependencies = [ + "arrayvec", + "base32", + "cached", + "chrono", + "derive_more", + "either", + "getrandom 0.4.2", + "miette", + "nom", + "regex", + "serde", + "serde_json", + "thiserror 1.0.69", + "tracing", + "url", + "utoipa", + "uuid", + "vitaminc", +] + +[[package]] +name = "ctutils" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" +dependencies = [ + "cmov", +] + +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.114", +] + +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "dbl" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd2735a791158376708f9347fe8faba9667589d82427ef3aed6794a8981de3d9" +dependencies = [ + "generic-array", +] + +[[package]] +name = "deranged" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ececcb659e7ba858fb4f10388c250a7252eb0a27373f1a72b8748afdd248e587" +dependencies = [ + "powerfmt", +] + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.114", + "unicode-xid", +] + +[[package]] +name = "deunicode" +version = "1.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abd57806937c9cc163efc8ea3910e00a62e2aeb0b8119f1793a978088f8f6b04" + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer 0.10.4", + "crypto-common 0.1.7", + "subtle", +] + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.0", + "crypto-common 0.2.1", + "ctutils", +] + +[[package]] +name = "dirs" +version = "4.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca3aa72a6f96ea37bbc5aa912f6788242832f75369bdfdadcb0e38423f100059" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b1d1d91c932ef41c0f2663aa8b0ca0342d444d842c06914aa0a7e352d0bada6" +dependencies = [ + "libc", + "redox_users", + "winapi", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "dummy" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1cac124e13ae9aa56acc4241f8c8207501d93afdd8d8e62f0c1f2e12f6508c65" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +dependencies = [ + "serde", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fake" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d391ba4af7f1d93f01fcf7b2f29e2bc9348e109dfdbf4dcbdc51dfa38dab0b6" +dependencies = [ + "deunicode", + "dummy", + "rand 0.8.6", + "uuid", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + +[[package]] +name = "funty" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" + +[[package]] +name = "futures" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" + +[[package]] +name = "futures-executor" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" + +[[package]] +name = "futures-macro" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "futures-sink" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" + +[[package]] +name = "futures-task" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" + +[[package]] +name = "futures-util" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "pin-utils", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "gethostname" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc3655aa6818d65bc620d6911f05aa7b6aeb596291e1e9f79e52df85583d1e30" +dependencies = [ + "rustix", + "windows-targets 0.52.6", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 6.0.0", + "rand_core 0.10.0", + "wasip2", + "wasip3", + "wasm-bindgen", +] + +[[package]] +name = "ghash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" +dependencies = [ + "opaque-debug", + "polyval", +] + +[[package]] +name = "half" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b43ede17f21864e81be2fa654110bf1e793774238d86ef8555c37e6519c0403" + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash", + "allocator-api2", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hex-literal" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ebdb29d2ea9ed0083cd8cece49bbd968021bd99b0849edb4a9a7ee0fdf6a4e0" + +[[package]] +name = "hybrid-array" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3944cf8cf766b40e2a1a333ee5e9b563f854d5fa49d6a8ca2764e97c6eddb214" +dependencies = [ + "typenum", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +dependencies = [ + "displaydoc", + "potential_utf", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" + +[[package]] +name = "icu_properties" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" + +[[package]] +name = "icu_provider" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "include_dir" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "923d117408f1e49d914f1a379a309cffe4f18c05cf4e3d12e613a15fc81bd0dd" +dependencies = [ + "include_dir_macros", +] + +[[package]] +name = "include_dir_macros" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cab85a7ed0bd5f0e76d93846e0147172bed2e2d3f859bcc33a8d9699cad1a75" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "indexmap" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +dependencies = [ + "equivalent", + "hashbrown 0.16.1", + "serde", + "serde_core", +] + +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + +[[package]] +name = "is-docker" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3" +dependencies = [ + "once_cell", +] + +[[package]] +name = "is-wsl" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5" +dependencies = [ + "is-docker", + "once_cell", +] + +[[package]] +name = "itertools" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" + +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.85" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c942ebf8e95485ca0d52d97da7c5a2c387d0e7f0ba4c35e93bfcaee045955b3" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "jsonwebtoken" +version = "10.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eba32bfb4ffdeaca3e34431072faf01745c9b26d25504aa7a6cf5684334fc4fc" +dependencies = [ + "aws-lc-rs", + "base64", + "getrandom 0.2.17", + "js-sys", + "pem", + "serde", + "serde_json", + "signature", + "simple_asn1", + "zeroize", +] + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libc" +version = "0.2.180" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc" + +[[package]] +name = "libfuzzer-sys" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9fd2f41a1cba099f79a0b6b6c35656cf7c03351a7bae8ff0f28f25270f929d2" +dependencies = [ + "arbitrary", + "cc", +] + +[[package]] +name = "libredox" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d0b95e02c851351f877147b7deea7b1afb1df71b63aa5f8270716e0c5720616" +dependencies = [ + "bitflags", + "libc", +] + +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + +[[package]] +name = "litemap" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest 0.10.7", +] + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "miette" +version = "7.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f98efec8807c63c752b5bd61f862c165c115b0a35685bdcfd9238c7aeb592b7" +dependencies = [ + "cfg-if", + "miette-derive", + "unicode-width", +] + +[[package]] +name = "miette-derive" +version = "7.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db5b29714e950dbb20d5e6f74f9dcec4edbcc1067bb7f8ed198c097b8c1a818b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "mio" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "mutants" +version = "0.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "add0ac067452ff1aca8c5002111bd6b1c895baee6e45fcbc44e0193aea17be56" + +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-conv" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + +[[package]] +name = "open" +version = "5.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43bb73a7fa3799b198970490a51174027ba0d4ec504b03cd08caf513d40024bc" +dependencies = [ + "is-wsl", + "libc", + "pathdiff", +] + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "pathdiff" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" + +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64", + "serde_core", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "pkg-config" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" + +[[package]] +name = "polyval" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "opaque-debug", + "universal-hash", +] + +[[package]] +name = "potential_utf" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.114", +] + +[[package]] +name = "proc-macro-error-attr2" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "proc-macro-error2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802" +dependencies = [ + "proc-macro-error-attr2", + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "proptest" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37566cb3fdacef14c0737f9546df7cfeadbfbc9fef10991038bf5015d0c80532" +dependencies = [ + "bitflags", + "num-traits", + "rand 0.9.3", + "rand_chacha 0.9.0", + "rand_xorshift", + "regex-syntax", + "unarray", +] + +[[package]] +name = "quote" +version = "1.0.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "radium" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" + +[[package]] +name = "rand" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ec095654a25171c2124e9e3393a930bddbffdc939556c914957a4c3e0a87166" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + +[[package]] +name = "rand" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207" +dependencies = [ + "chacha20", + "getrandom 0.4.2", + "rand_core 0.10.0", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rand_core" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c8d0fd677905edcbeedbf2edb6494d676f0e98d54d5cf9bda0b061cb8fb8aba" + +[[package]] +name = "rand_xorshift" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a" +dependencies = [ + "rand_core 0.9.5", +] + +[[package]] +name = "recipher" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e14e156e2d485b51cc67c19241e7d81ad524bda9fd4f77791b698ab10c8e26e9" +dependencies = [ + "aes", + "cmac", + "getrandom 0.2.17", + "hex", + "hex-literal", + "opaque-debug", + "rand 0.8.6", + "rand_chacha 0.3.1", + "serde", + "serde_cbor", + "sha2", + "thiserror 1.0.69", + "zeroize", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 1.0.69", +] + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a96887878f22d7bad8a3b6dc5b7440e0ada9a245242924394987b21cf2210a4c" + +[[package]] +name = "rmp" +version = "0.8.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ba8be72d372b2c9b35542551678538b562e7cf86c3315773cae48dfbfe7790c" +dependencies = [ + "num-traits", +] + +[[package]] +name = "rmp-serde" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f81bee8c8ef9b577d1681a70ebbc962c232461e397b22c208c43c04b67a155" +dependencies = [ + "rmp", + "serde", +] + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.59.0", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "semver" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_bytes" +version = "0.11.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "serde_cbor" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bef2ebfde456fb76bbcf9f59315333decc4fda0b2b44b420243c11e0f5ec1f5" +dependencies = [ + "half", + "serde", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "serdect" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f42f67da2385b51a5f9652db9c93d78aeaf7610bf5ec366080b6de810604af53" +dependencies = [ + "base16ct", + "serde", + "zeroize", +] + +[[package]] +name = "sha1_smol" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbfa15b3dddfee50a0fff136974b3e1bde555604ba463834a7eb7deb6417705d" + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "rand_core 0.6.4", +] + +[[package]] +name = "simple_asn1" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d" +dependencies = [ + "num-bigint", + "num-traits", + "thiserror 2.0.18", + "time", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86f4aa3ad99f2088c990dfa82d367e19cb29268ed67c574d10d0a4bfe71f07e0" +dependencies = [ + "libc", + "windows-sys 0.60.2", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "stack-auth" +version = "0.42.3" +dependencies = [ + "aquamarine", + "base64", + "cts-common", + "jsonwebtoken", + "miette", + "open", + "serde", + "serde_json", + "serde_urlencoded", + "stack-profile", + "thiserror 1.0.69", + "tokio", + "tracing", + "url", + "uuid", + "vitaminc", + "vitaminc-protected", + "web-time", + "zeroize", + "zerokms-protocol", +] + +[[package]] +name = "stack-kms" +version = "0.1.0" +dependencies = [ + "base16ct", + "base64ct", + "blake3", + "futures", + "miette", + "opaque-debug", + "recipher", + "serde", + "serde_cbor", + "serde_json", + "serdect", + "sha2", + "stack-auth", + "thiserror 1.0.69", + "tracing", + "url", + "uuid", + "vitaminc", + "vitaminc-protected", + "zeroize", + "zerokms-protocol", +] + +[[package]] +name = "stack-kms-fuzz" +version = "0.0.0" +dependencies = [ + "libfuzzer-sys", + "stack-kms", + "uuid", +] + +[[package]] +name = "stack-profile" +version = "0.42.3" +dependencies = [ + "dirs", + "gethostname", + "serde", + "serde_json", + "thiserror 1.0.69", + "uuid", +] + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4d107df263a3013ef9b1879b0df87d706ff80f65a86ea879bd9c31f9b307c2a" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "tap" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "time" +version = "0.3.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" + +[[package]] +name = "time-macros" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tokio" +version = "1.49.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "typenum" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + +[[package]] +name = "unarray" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" + +[[package]] +name = "unicode-ident" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "537dd038a89878be9b64dd4bd1b260315c1bb94f4d784956b81e27a088d9a09e" + +[[package]] +name = "unicode-segmentation" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" + +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common 0.1.7", + "subtle", +] + +[[package]] +name = "untrusted" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", + "serde_derive", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utoipa" +version = "5.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fcc29c80c21c31608227e0912b2d7fddba57ad76b606890627ba8ee7964e993" +dependencies = [ + "indexmap", + "serde", + "serde_json", + "utoipa-gen", +] + +[[package]] +name = "utoipa-gen" +version = "5.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d79d08d92ab8af4c5e8a6da20c47ae3f61a0f1dabc1997cdf2d082b757ca08b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", + "url", + "uuid", +] + +[[package]] +name = "uuid" +version = "1.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee48d38b119b0cd71fe4141b30f5ba9c7c5d9f4e7a3a8b4a674e4b6ef789976f" +dependencies = [ + "atomic", + "getrandom 0.3.4", + "js-sys", + "md-5", + "serde_core", + "sha1_smol", + "wasm-bindgen", +] + +[[package]] +name = "validator" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43fb22e1a008ece370ce08a3e9e4447a910e92621bb49b85d6e48a45397e7cfa" +dependencies = [ + "idna", + "once_cell", + "regex", + "serde", + "serde_derive", + "serde_json", + "url", + "validator_derive", +] + +[[package]] +name = "validator_derive" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7df16e474ef958526d1205f6dda359fdfab79d9aa6d54bafcb92dcd07673dca" +dependencies = [ + "darling", + "once_cell", + "proc-macro-error2", + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "vitaminc" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f371b877e1586acb8ffc9efd0bd6a7c7ff7d8c4b1529c1f46d34b1df3478699" +dependencies = [ + "vitaminc-aead", + "vitaminc-context", + "vitaminc-encrypt", + "vitaminc-protected", + "vitaminc-random", + "vitaminc-traits", +] + +[[package]] +name = "vitaminc-aead" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0238d5b77821fcd46d2f72b082532209d625892c69e40008e403b709a1dd0fa" +dependencies = [ + "bytes", + "serde", + "vitaminc-aead-derive", + "vitaminc-context", + "vitaminc-protected", + "vitaminc-random", + "zeroize", +] + +[[package]] +name = "vitaminc-aead-derive" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5a81524f1e096bc45a1e7901186546bc0cf5545a9a86e978581a7146c54a5c7" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "vitaminc-context" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e7b3e0602ad490039680222ba23217264eab0f400495c072ec11e1a2235b304" +dependencies = [ + "mutants", + "vitaminc-protected", +] + +[[package]] +name = "vitaminc-encrypt" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b969f689aebd40637a6ba9243618a62057ee9c76a8b70c6661c9b0c28ba20275" +dependencies = [ + "aes-gcm", + "aws-lc-rs", + "vitaminc-aead", + "vitaminc-protected", + "vitaminc-random", + "zeroize", +] + +[[package]] +name = "vitaminc-protected" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a13b616fa4e7093f4970dacd6f44ca76a6929bee1c72612134353a69bb52c62" +dependencies = [ + "bitvec", + "digest 0.11.3", + "libc", + "serde", + "serde_bytes", + "subtle", + "thiserror 2.0.18", + "vitaminc-protected-derive", + "zeroize", +] + +[[package]] +name = "vitaminc-protected-derive" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47c3f8db2eff8108d078a77125f20974448b9a66950bdc120e35b0b53eda4c98" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "vitaminc-random" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8320b63fad9560026b036cb6eb9b85efb1d666af52862af923a2c75972c4282" +dependencies = [ + "chacha20", + "getrandom 0.4.2", + "rand 0.10.1", + "thiserror 2.0.18", + "vitaminc-protected", + "vitaminc-random-derives", + "zeroize", +] + +[[package]] +name = "vitaminc-random-derives" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdbc48a9e3893406046f51f51c3d2bb1dffe70d6ca3b19920ca12d231ed12b68" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "vitaminc-traits" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c53613363efb0c47aba19ea2bfe0142cba8b2d0fe1bc27d43b0ea3390f6efe7" +dependencies = [ + "anyhow", + "bytes", + "rmp-serde", + "serde", + "thiserror 2.0.18", + "vitaminc-protected", + "vitaminc-random", + "zeroize", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.2+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64024a30ec1e37399cf85a7ffefebdb72205ca1c972291c51512360d90bd8566" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "008b239d9c740232e71bd39e8ef6429d27097518b6b30bdf9086833bd5b6d608" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5256bae2d58f54820e6490f9839c49780dff84c65aeab9e772f15d5f0e913a55" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.114", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f01b580c9ac74c8d8f0c0e4afb04eeef2acf145458e52c03845ee9cd23e3d12" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn 2.0.114", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn 2.0.114", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "writeable" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" + +[[package]] +name = "wyz" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed" +dependencies = [ + "tap", +] + +[[package]] +name = "yoke" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.39" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db6d35d663eadb6c932438e763b262fe1a70987f9ae936e60158176d710cae4a" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.39" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4122cd3169e94605190e77839c9a40d40ed048d305bfdc146e7df40ab0f3e517" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "zerofrom" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "zerokms-protocol" +version = "0.12.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c28e88315a5109d0a1e7ee4b7b4b8776a0bff5f5b139ae83960a3debe84e92e" +dependencies = [ + "base64", + "cipherstash-config", + "const-hex", + "cts-common", + "fake", + "getrandom 0.2.17", + "opaque-debug", + "rand 0.8.6", + "serde", + "static_assertions", + "thiserror 1.0.69", + "utoipa", + "uuid", + "validator", + "zeroize", +] + +[[package]] +name = "zerotrie" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "zmij" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4de98dfa5d5b7fef4ee834d0073d560c9ca7b6c46a71d058c48db7960f8cfaf7" diff --git a/packages/stack-profile/tasks.toml b/packages/stack-profile/tasks.toml index 054fb4e12..7e49af0a6 100644 --- a/packages/stack-profile/tasks.toml +++ b/packages/stack-profile/tasks.toml @@ -1,11 +1,10 @@ ["test:integration:stack-profile"] description = "Run stack-profile Node.js integration tests" -dir = "{{config_root}}/packages/stack-profile/node" +dir = "{{config_root}}/languages/typescript/packages/profile" run = [ - "npm install", "cargo build -p stack-profile-node", - "cp ../../../target/debug/libstack_profile_node.dylib stack-profile-node.node 2>/dev/null || cp ../../../target/debug/libstack_profile_node.so stack-profile-node.node", - "npx vitest run", + "cp ../../../../target/debug/libstack_profile_node.dylib stack-profile-node.node 2>/dev/null || cp ../../../../target/debug/libstack_profile_node.so stack-profile-node.node", + "pnpm exec vitest run", ] # Rustdoc with warnings as errors: a broken intra-doc link or a rustdoc diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e0f4875bb..b6c7e370d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -181,6 +181,52 @@ importers: languages/typescript/examples/supabase-worker: {} + languages/typescript/packages/auth: + dependencies: + '@byteslice/result': + specifier: ^0.3.0 + version: 0.3.0 + '@cipherstash/auth-darwin-arm64': + specifier: workspace:* + version: link:platforms/darwin-arm64 + '@cipherstash/auth-darwin-x64': + specifier: workspace:* + version: link:platforms/darwin-x64 + '@cipherstash/auth-linux-arm64-gnu': + specifier: workspace:* + version: link:platforms/linux-arm64-gnu + '@cipherstash/auth-linux-x64-gnu': + specifier: workspace:* + version: link:platforms/linux-x64-gnu + '@cipherstash/auth-linux-x64-musl': + specifier: workspace:* + version: link:platforms/linux-x64-musl + '@cipherstash/auth-win32-x64-msvc': + specifier: workspace:* + version: link:platforms/win32-x64-msvc + devDependencies: + '@napi-rs/cli': + specifier: ^2 + version: 2.18.4 + typescript: + specifier: ^5 + version: 5.9.3 + vitest: + specifier: ^3 + version: 3.2.7(@types/node@26.2.0)(jiti@2.7.0)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0) + + languages/typescript/packages/auth/platforms/darwin-arm64: {} + + languages/typescript/packages/auth/platforms/darwin-x64: {} + + languages/typescript/packages/auth/platforms/linux-arm64-gnu: {} + + languages/typescript/packages/auth/platforms/linux-x64-gnu: {} + + languages/typescript/packages/auth/platforms/linux-x64-musl: {} + + languages/typescript/packages/auth/platforms/win32-x64-msvc: {} + languages/typescript/packages/bench: dependencies: '@cipherstash/stack': @@ -355,6 +401,49 @@ importers: specifier: 4.62.4 version: 4.62.4 + languages/typescript/packages/profile: + devDependencies: + '@napi-rs/cli': + specifier: ^2 + version: 2.18.4 + typescript: + specifier: ^5 + version: 5.9.3 + vitest: + specifier: ^3 + version: 3.2.7(@types/node@26.2.0)(jiti@2.7.0)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0) + optionalDependencies: + '@cipherstash/profile-darwin-arm64': + specifier: workspace:* + version: link:platforms/darwin-arm64 + '@cipherstash/profile-darwin-x64': + specifier: workspace:* + version: link:platforms/darwin-x64 + '@cipherstash/profile-linux-arm64-gnu': + specifier: workspace:* + version: link:platforms/linux-arm64-gnu + '@cipherstash/profile-linux-x64-gnu': + specifier: workspace:* + version: link:platforms/linux-x64-gnu + '@cipherstash/profile-linux-x64-musl': + specifier: workspace:* + version: link:platforms/linux-x64-musl + '@cipherstash/profile-win32-x64-msvc': + specifier: workspace:* + version: link:platforms/win32-x64-msvc + + languages/typescript/packages/profile/platforms/darwin-arm64: {} + + languages/typescript/packages/profile/platforms/darwin-x64: {} + + languages/typescript/packages/profile/platforms/linux-arm64-gnu: {} + + languages/typescript/packages/profile/platforms/linux-x64-gnu: {} + + languages/typescript/packages/profile/platforms/linux-x64-musl: {} + + languages/typescript/packages/profile/platforms/win32-x64-msvc: {} + languages/typescript/packages/protect-ffi: dependencies: '@neon-rs/load': @@ -540,6 +629,8 @@ importers: specifier: catalog:repo version: 0.44.0 + languages/typescript/packages/stack-auth-wasm: {} + languages/typescript/packages/stack-drizzle: dependencies: '@byteslice/result': @@ -1453,6 +1544,11 @@ packages: '@cfworker/json-schema': optional: true + '@napi-rs/cli@2.18.4': + resolution: {integrity: sha512-SgJeA4df9DE2iAEpr3M2H0OKl/yjtg1BnRI5/JyowS71tUWhrfSu2LT0V3vlHET+g1hBVlrO60PmEXwUEKp8Mg==} + engines: {node: '>= 10'} + hasBin: true + '@neon-rs/cli@0.2.6': resolution: {integrity: sha512-SC8xYNOH1dTnDiB2pykFhREl74gs6TF4jr2qFp0h9fwriRSoxic/CnVN2d7ikpc3XatOK1QnwfQCCmyv31x9/A==} hasBin: true @@ -1863,9 +1959,23 @@ packages: '@vitest/browser': optional: true + '@vitest/expect@3.2.7': + resolution: {integrity: sha512-E8eBXaKibuvH2pSZErOjdVb5vF4PbKYcrnluBTYxEk1l/VhhwZg1kZQsdtjq+CsF5CFydf2Rdkz7jDHKSisi3w==} + '@vitest/expect@4.1.11': resolution: {integrity: sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==} + '@vitest/mocker@3.2.7': + resolution: {integrity: sha512-Trr0hYO9CM3Wj6ksWHRhK9IZpIY6wTMO5u/MqXurMxT57sWBaOPEtP3Oq60ihZuh5JsiagKfz95OcxdEP6dBrA==} + peerDependencies: + msw: ^2.4.9 + vite: ~7.3.5 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + '@vitest/mocker@4.1.11': resolution: {integrity: sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==} peerDependencies: @@ -1877,18 +1987,33 @@ packages: vite: optional: true + '@vitest/pretty-format@3.2.7': + resolution: {integrity: sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA==} + '@vitest/pretty-format@4.1.11': resolution: {integrity: sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==} + '@vitest/runner@3.2.7': + resolution: {integrity: sha512-sB9y4ovltoQP+WaUPwmSxO9WIg9Ig694Di5PalVPsYHklAdE027mehpWF2SQSVq+k6sFgaivbTjTJwZLSHbedA==} + '@vitest/runner@4.1.11': resolution: {integrity: sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==} + '@vitest/snapshot@3.2.7': + resolution: {integrity: sha512-7C+MwShwtBSI5Buwoyg3s/iY1eHL9PKAf+O1wVh/TdnjXUtkoL/9YQtre90i4MtNXM6edP1wJ2zOBpfCyhIS7g==} + '@vitest/snapshot@4.1.11': resolution: {integrity: sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==} + '@vitest/spy@3.2.7': + resolution: {integrity: sha512-Q2eQGI6d2L/hBtZ0qNuKcAGid68XK6cv1xsoaIma6PaJhHPoqcEJhYpXZ/5myCMqkNgtP6UKuBhbc0nHKnrkuQ==} + '@vitest/spy@4.1.11': resolution: {integrity: sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==} + '@vitest/utils@3.2.7': + resolution: {integrity: sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw==} + '@vitest/utils@4.1.11': resolution: {integrity: sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==} @@ -2010,6 +2135,10 @@ packages: caniuse-lite@1.0.30001760: resolution: {integrity: sha512-7AAMPcueWELt1p3mi13HR/LHH0TJLT11cnwDJEs3xA4+CK/PLKeO9Kl1oru24htkyUKtkGCvAx4ohB0Ttry8Dw==} + chai@5.3.3: + resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} + engines: {node: '>=18'} + chai@6.2.2: resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} engines: {node: '>=18'} @@ -2017,6 +2146,10 @@ packages: chardet@2.2.0: resolution: {integrity: sha512-rddelWYNPRrXq6PtNEN2S3f6t9ILzvqaN5pVgi4kqt9jHQaXIial9PznB5iSPVlQSLNaaH22ItWz3EJtQ10+OA==} + check-error@2.1.3: + resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==} + engines: {node: '>= 16'} + chokidar@4.0.3: resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} engines: {node: '>= 14.16.0'} @@ -2104,6 +2237,10 @@ packages: supports-color: optional: true + deep-eql@5.0.2: + resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} + engines: {node: '>=6'} + defu@6.1.7: resolution: {integrity: sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==} @@ -2253,6 +2390,9 @@ packages: resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} engines: {node: '>= 0.4'} + es-module-lexer@1.7.0: + resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + es-module-lexer@2.3.2: resolution: {integrity: sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==} @@ -2596,6 +2736,9 @@ packages: js-tokens@10.0.0: resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==} + js-tokens@9.0.1: + resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==} + js-yaml@3.15.2: resolution: {integrity: sha512-6EuL879VkRA+1Cz578mKMiKvjPNEuk6+r1JaFzoSWejZmtf7xWbIyw1e3KkxlkzTIt9Taw6JBhEppG7utc1P+w==} hasBin: true @@ -2720,6 +2863,9 @@ packages: lodash@4.18.1: resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==} + loupe@3.2.1: + resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} + lru-cache@11.3.6: resolution: {integrity: sha512-Gf/KoL3C/MlI7Bt0PGI9I+TeTC/I6r/csU58N4BSNc4lppLBeKsOdFYkK+dX0ABDUMJNfCHTyPpzwwO21Awd3A==} engines: {node: 20 || >=22} @@ -2914,6 +3060,10 @@ packages: pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + pathval@2.0.1: + resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==} + engines: {node: '>= 14.16'} + perfect-debounce@2.1.0: resolution: {integrity: sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==} @@ -3257,6 +3407,9 @@ packages: resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} engines: {node: '>= 0.8'} + std-env@3.10.0: + resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + std-env@4.2.0: resolution: {integrity: sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==} @@ -3280,6 +3433,9 @@ packages: resolution: {integrity: sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw==} engines: {node: '>=18'} + strip-literal@3.1.0: + resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==} + styled-jsx@5.1.6: resolution: {integrity: sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==} engines: {node: '>= 12.0.0'} @@ -3336,10 +3492,22 @@ packages: resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==} engines: {node: '>=12.0.0'} + tinypool@1.1.1: + resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==} + engines: {node: ^18.0.0 || >=20.0.0} + + tinyrainbow@2.0.0: + resolution: {integrity: sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==} + engines: {node: '>=14.0.0'} + tinyrainbow@3.1.1: resolution: {integrity: sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==} engines: {node: '>=14.0.0'} + tinyspy@4.0.6: + resolution: {integrity: sha512-u8KszXvGfU68hVcZpRHKG28T0krMuv2G5nDhiHaMLen/gIuFEgIJhaJuO69qjnXg5paSrbPMFfx3brNuN8eVSg==} + engines: {node: '>=14.0.0'} + to-regex-range@5.0.1: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} @@ -3437,6 +3605,11 @@ packages: resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} engines: {node: '>= 0.8'} + vite-node@3.2.4: + resolution: {integrity: sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + vite@7.3.6: resolution: {integrity: sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==} engines: {node: ^20.19.0 || >=22.12.0} @@ -3477,6 +3650,34 @@ packages: yaml: optional: true + vitest@3.2.7: + resolution: {integrity: sha512-KrxIJ62Fd89gfysR4WotlgZABiz2dqFPgqGzX7s+CwsqLFomRH7777ZcrOD6+WVAh7khPQP41A+BKbpcJFrdEg==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@types/debug': ^4.1.12 + '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 + '@vitest/browser': 3.2.7 + '@vitest/ui': 3.2.7 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@types/debug': + optional: true + '@types/node': + optional: true + '@vitest/browser': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + vitest@4.1.11: resolution: {integrity: sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==} engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} @@ -4178,6 +4379,8 @@ snapshots: transitivePeerDependencies: - supports-color + '@napi-rs/cli@2.18.4': {} + '@neon-rs/cli@0.2.6': {} '@neon-rs/load@0.2.6': {} @@ -4585,7 +4788,15 @@ snapshots: obug: 2.2.1 std-env: 4.2.0 tinyrainbow: 3.1.1 - vitest: 4.1.11(@types/node@22.20.1)(@vitest/coverage-v8@4.1.11)(vite@7.3.6(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)) + vitest: 4.1.11(@types/node@26.2.0)(@vitest/coverage-v8@4.1.11)(vite@7.3.6(@types/node@26.2.0)(jiti@2.7.0)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)) + + '@vitest/expect@3.2.7': + dependencies: + '@types/chai': 5.2.3 + '@vitest/spy': 3.2.7 + '@vitest/utils': 3.2.7 + chai: 5.3.3 + tinyrainbow: 2.0.0 '@vitest/expect@4.1.11': dependencies: @@ -4596,6 +4807,14 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.1 + '@vitest/mocker@3.2.7(vite@7.3.6(@types/node@26.2.0)(jiti@2.7.0)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0))': + dependencies: + '@vitest/spy': 3.2.7 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 7.3.6(@types/node@26.2.0)(jiti@2.7.0)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0) + '@vitest/mocker@4.1.11(vite@7.3.6(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0))': dependencies: '@vitest/spy': 4.1.11 @@ -4612,15 +4831,31 @@ snapshots: optionalDependencies: vite: 7.3.6(@types/node@26.2.0)(jiti@2.7.0)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0) + '@vitest/pretty-format@3.2.7': + dependencies: + tinyrainbow: 2.0.0 + '@vitest/pretty-format@4.1.11': dependencies: tinyrainbow: 3.1.1 + '@vitest/runner@3.2.7': + dependencies: + '@vitest/utils': 3.2.7 + pathe: 2.0.3 + strip-literal: 3.1.0 + '@vitest/runner@4.1.11': dependencies: '@vitest/utils': 4.1.11 pathe: 2.0.3 + '@vitest/snapshot@3.2.7': + dependencies: + '@vitest/pretty-format': 3.2.7 + magic-string: 0.30.21 + pathe: 2.0.3 + '@vitest/snapshot@4.1.11': dependencies: '@vitest/pretty-format': 4.1.11 @@ -4628,8 +4863,18 @@ snapshots: magic-string: 0.30.21 pathe: 2.0.3 + '@vitest/spy@3.2.7': + dependencies: + tinyspy: 4.0.6 + '@vitest/spy@4.1.11': {} + '@vitest/utils@3.2.7': + dependencies: + '@vitest/pretty-format': 3.2.7 + loupe: 3.2.1 + tinyrainbow: 2.0.0 + '@vitest/utils@4.1.11': dependencies: '@vitest/pretty-format': 4.1.11 @@ -4759,10 +5004,20 @@ snapshots: caniuse-lite@1.0.30001760: {} + chai@5.3.3: + dependencies: + assertion-error: 2.0.1 + check-error: 2.1.3 + deep-eql: 5.0.2 + loupe: 3.2.1 + pathval: 2.0.1 + chai@6.2.2: {} chardet@2.2.0: {} + check-error@2.1.3: {} + chokidar@4.0.3: dependencies: readdirp: 4.1.2 @@ -4823,6 +5078,8 @@ snapshots: dependencies: ms: 2.1.3 + deep-eql@5.0.2: {} + defu@6.1.7: {} depd@2.0.0: {} @@ -4871,6 +5128,8 @@ snapshots: es-errors@1.3.0: {} + es-module-lexer@1.7.0: {} + es-module-lexer@2.3.2: {} es-object-atoms@1.1.2: @@ -5230,6 +5489,8 @@ snapshots: js-tokens@10.0.0: {} + js-tokens@9.0.1: {} + js-yaml@3.15.2: dependencies: argparse: 1.0.10 @@ -5330,6 +5591,8 @@ snapshots: lodash@4.18.1: {} + loupe@3.2.1: {} + lru-cache@11.3.6: {} magic-string@0.30.21: @@ -5492,6 +5755,8 @@ snapshots: pathe@2.0.3: {} + pathval@2.0.1: {} + perfect-debounce@2.1.0: {} pg-cloudflare@1.4.0: @@ -5863,6 +6128,8 @@ snapshots: statuses@2.0.2: {} + std-env@3.10.0: {} + std-env@4.2.0: {} string-width@8.2.2: @@ -5882,6 +6149,10 @@ snapshots: strip-final-newline@4.0.0: {} + strip-literal@3.1.0: + dependencies: + js-tokens: 9.0.1 + styled-jsx@5.1.6(react@19.2.3): dependencies: client-only: 0.0.1 @@ -5935,8 +6206,14 @@ snapshots: fdir: 6.5.0(picomatch@4.0.4) picomatch: 4.0.4 + tinypool@1.1.1: {} + + tinyrainbow@2.0.0: {} + tinyrainbow@3.1.1: {} + tinyspy@4.0.6: {} + to-regex-range@5.0.1: dependencies: is-number: 7.0.0 @@ -6027,6 +6304,27 @@ snapshots: vary@1.1.2: {} + vite-node@3.2.4(@types/node@26.2.0)(jiti@2.7.0)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0): + dependencies: + cac: 6.7.14 + debug: 4.4.3 + es-module-lexer: 1.7.0 + pathe: 2.0.3 + vite: 7.3.6(@types/node@26.2.0)(jiti@2.7.0)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0) + transitivePeerDependencies: + - '@types/node' + - jiti + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + vite@7.3.6(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0): dependencies: esbuild: 0.28.1 @@ -6061,6 +6359,47 @@ snapshots: tsx: 4.23.12 yaml: 2.9.0 + vitest@3.2.7(@types/node@26.2.0)(jiti@2.7.0)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0): + dependencies: + '@types/chai': 5.2.3 + '@vitest/expect': 3.2.7 + '@vitest/mocker': 3.2.7(vite@7.3.6(@types/node@26.2.0)(jiti@2.7.0)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)) + '@vitest/pretty-format': 3.2.7 + '@vitest/runner': 3.2.7 + '@vitest/snapshot': 3.2.7 + '@vitest/spy': 3.2.7 + '@vitest/utils': 3.2.7 + chai: 5.3.3 + debug: 4.4.3 + expect-type: 1.3.0 + magic-string: 0.30.21 + pathe: 2.0.3 + picomatch: 4.0.4 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 0.3.2 + tinyglobby: 0.2.16 + tinypool: 1.1.1 + tinyrainbow: 2.0.0 + vite: 7.3.6(@types/node@26.2.0)(jiti@2.7.0)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0) + vite-node: 3.2.4(@types/node@26.2.0)(jiti@2.7.0)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 26.2.0 + transitivePeerDependencies: + - jiti + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + vitest@4.1.11(@types/node@22.20.1)(@vitest/coverage-v8@4.1.11)(vite@7.3.6(@types/node@22.20.1)(jiti@2.7.0)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)): dependencies: '@vitest/expect': 4.1.11 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 07b7bc054..c33a93f3c 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -6,6 +6,12 @@ packages: # glob above already covers the wrapper itself; these are nested a level # deeper and need their own entry. - languages/typescript/packages/protect-ffi/platforms/* + # The node bindings imported from cipherstash-suite. `auth`, `profile` and + # `stack-auth-wasm` sit one level down and are already selected by the first + # glob; the per-platform packages of `@cipherstash/auth` and + # `@cipherstash/profile` are a level deeper, like protect-ffi's. + - languages/typescript/packages/auth/platforms/* + - languages/typescript/packages/profile/platforms/* # @cipherstash/eql, from the encrypt-query-language subtree. The subtree is # imported at a VERBATIM prefix so its repo-root-relative paths keep resolving # (mise tasks, Doxyfile, sync-generated.mjs), which puts the npm package two diff --git a/turbo.json b/turbo.json index ce949b44a..da8c14fdb 100644 --- a/turbo.json +++ b/turbo.json @@ -56,6 +56,15 @@ "inputs": ["$TURBO_DEFAULT$", "$TURBO_ROOT$/skills/**", ".env*"], "env": ["STASH_POSTHOG_KEY"] }, + // The @cipherstash/auth napi binding. Its `build:native` is + // `napi build --release`, which needs Rust, so it is not the package's + // `build`: the root `build` and `test` never invoke cargo (the same split + // protect-ffi uses). `test` runs vitest and Biome against a binding built + // beforehand; `test:cargo` runs the crate's own tests. `wasm/**` is the + // output of `build:wasm`, cached with the native build it ships beside. + "@cipherstash/auth#build:native": { + "outputs": ["*.node", "native.d.ts", "wasm/**"] + }, // Typechecks `languages/typescript/packages/stack/dist-types` against the BUILT declarations, so // it must run after `build` — unlike `test:types`, which reads source. "test:types:dist": { From 8c86f4bf7c56fec40f5948bbde8e8f64cc5c3a6b Mon Sep 17 00:00:00 2001 From: Lindsay Holmwood <lindsay@holmwood.id.au> Date: Fri, 2 Oct 2026 09:27:01 +1000 Subject: [PATCH 677/686] build: freeze the @cipherstash/auth packages and add Dependabot entries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plan §7.4 (Freeze, Gate change, Platform packages, Dependabot) and §13.6. The seven @cipherstash/auth packages now live here but keep publishing from cipherstash-suite until the arming PR (PR E). Release gate: - release-gate.mjs learns two more artefact shapes. A `files` entry lists tracked files: the gate hashes each with sha256, from disk and from the `npm pack` tarball it already fetches, and a mismatch names the file. A listed file missing on either side throws. A `noTreeBytes` entry declares, with a reason, that the tarball holds nothing the tree has; check C skips it. EQL's `field` entry is unchanged. Node's built-in crypto only, so the gate still needs nothing installed. - @cipherstash/auth and its six platform packages join FROZEN_PUBLISHERS and FROZEN_ARTEFACT_DIGESTS: the wrapper with the 15 tracked files it publishes, the platforms with noTreeBytes. Check A still blocks a version npm does not carry. Tests in release-gate.test.mjs. - lint-no-auth-changeset.mjs, modelled on the retired protect-ffi guard, with its self-test, a lint:auth-changeset script and a tests.yml step. It can only refuse: no-parked-changesets forbids the old .md.deferred escape hatch. - frozen-publisher-docs covers the auth freeze, so AGENTS.md and SECURITY.md describe it here, beside the map. Dependabot: a cargo entry for the root, the three fuzz crates and the two Go guests, each of which has a Cargo.lock, and gomod for /languages/golang. The cargo entry ignores cts-common, zerokms-protocol, recipher, cllw-ore and vitaminc*, which move in lockstep with the suite. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PaY5xYydZUWhv8Nex9Sw8a --- .github/dependabot.yml | 68 +++++ .github/workflows/tests.yml | 7 + AGENTS.md | 24 +- SECURITY.md | 16 +- package.json | 1 + .../__tests__/frozen-publisher-docs.test.mjs | 52 +++- .../__tests__/lint-no-auth-changeset.test.mjs | 142 +++++++++++ scripts/__tests__/release-gate.test.mjs | 235 +++++++++++++++++- scripts/lint-no-auth-changeset.mjs | 78 ++++++ scripts/release-gate.mjs | 200 +++++++++++++-- 10 files changed, 773 insertions(+), 50 deletions(-) create mode 100644 scripts/__tests__/lint-no-auth-changeset.test.mjs create mode 100644 scripts/lint-no-auth-changeset.mjs diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 395548f66..8c6073551 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -212,6 +212,74 @@ updates: update-types: - version-update:semver-major + # ── Cargo (the root workspace — the stack-* crates) ───────────── + # The root workspace (the six stack-* crates and the three node binding + # crates) and the five detached workspaces beside it: the three cargo-fuzz + # crates and the two Go WASI guests. Each has its own Cargo.lock, and each + # is its own workspace root, so each is listed. + - package-ecosystem: cargo + directories: + - / + - /packages/stack-auth/fuzz + - /packages/stack-kms/fuzz + - /packages/stack-encrypt/fuzz + - /languages/golang/stackencrypt/guest + - /languages/golang/stackauth/guest + # Monthly, matching the other two cargo entries. + schedule: + interval: monthly + cooldown: + default-days: 7 + open-pull-requests-limit: 3 + labels: + - dependencies + - supply-chain + commit-message: + prefix: "chore" + include: scope + groups: + cargo-minor-patch: + patterns: + - "*" + update-types: + - minor + - patch + ignore: + # Released from cipherstash-suite and pinned with exact `=` requirements + # in the root Cargo.toml and the guests: stack-auth's API carries their + # types, so they move in lockstep with the suite, by hand. + - dependency-name: "cts-common" + - dependency-name: "zerokms-protocol" + - dependency-name: "recipher" + - dependency-name: "cllw-ore" + # vitaminc is pinned at 0.5.0 across the crates and the guests; the + # guests' `FfiValue: Decrypt` bound fails if two versions resolve. + - dependency-name: "vitaminc*" + # Major bumps are reviewed and applied manually, not by Dependabot. + - dependency-name: "*" + update-types: + - version-update:semver-major + + # ── Go (languages/golang) ────────────────────────────────────── + - package-ecosystem: gomod + directory: /languages/golang + schedule: + interval: monthly + cooldown: + default-days: 7 + open-pull-requests-limit: 3 + labels: + - dependencies + - supply-chain + commit-message: + prefix: "chore" + include: scope + ignore: + # Major bumps are reviewed and applied manually, not by Dependabot. + - dependency-name: "*" + update-types: + - version-update:semver-major + # ── GitHub Actions ───────────────────────────────────────────── - package-ecosystem: github-actions directory: / diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 1e311098f..aa5c01711 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -285,6 +285,13 @@ jobs: - name: Lint — no references to deleted package directories run: pnpm run lint:package-paths + # TEMPORARY — delete with the script in the arming PR (PR E) of the + # stack-* crates import. The seven @cipherstash/auth packages live here + # but still publish from cipherstash/cipherstash-suite, so a changeset + # naming one would bump a frozen package and block every release. + - name: Lint — no @cipherstash/auth changeset before the publishing cutover + run: pnpm run lint:auth-changeset + # `eql-bindings` emits EQL payloads; `@cipherstash/eql` carries the SQL # that stores them. Both live here now and release at one lockstep # version. A registry pin on either lets them drift apart — it compiles, diff --git a/AGENTS.md b/AGENTS.md index 68acaf158..2887d6940 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -554,18 +554,30 @@ monorepo, which is where the silent failures are. has been repointed, is likewise configuration — check the registry, do not read it here. This bullet used to narrate that state and was wrong twice. - **The map has been empty since EQL's Phase-5 cutover, and empty is a - legitimate state.** A package absorbed before its publisher moves goes back - in, with its artefact in `FROZEN_ARTEFACT_DIGESTS`, and both entries are - deleted in the PR that repoints its publisher — not afterwards. + **EQL left the map in its Phase-5 cutover, and an empty map is a legitimate + state.** A package absorbed before its publisher moves goes in, with its + artefact in `FROZEN_ARTEFACT_DIGESTS`, and both entries are deleted in the + PR that repoints its publisher — not afterwards. `scripts/__tests__/frozen-publisher-docs.test.mjs` holds this file, the EQL plan and `SECURITY.md`'s "Note on publishing" to the map — the last being the one file that tells a reporter which pipeline built the artefact they are reporting on. `release-gate.test.mjs` asserts the map carries neither EQL nor any FFI name: the seven protect-ffi packages were left in it after their own cutover, which armed the gate against the first release that - cutover had just enabled. With nothing frozen, the tests drive the mechanism - with EQL's old entry as an injected fixture. + cutover had just enabled. The tests drive the `field` mechanism with EQL's + old entry as an injected fixture. + + **Delete the `@cipherstash/auth*` entries in the arming PR of the stack-* + crates import.** The wrapper and its six platform packages, imported from + cipherstash-suite, are frozen the same way until npm trusted publishing is + repointed here. The wrapper has no release manifest, so its check-3 entry is + a `files` list: the gate hashes each of the 15 tracked files it publishes, + in the tree and in the tarball, and names the one that differs. That is why + `biome.json` excludes those files: a reformat is a skew, and the gate + refuses it. The platform packages publish only a binary built in CI, so their entries + declare `noTreeBytes` and check 3 skips them; checks 1 and 2 still apply. + `scripts/lint-no-auth-changeset.mjs` refuses a changeset naming any of the + seven, and goes in the same PR. **Check 3 is the one worth understanding before you touch a frozen package.** For a package this repo publishes, in-tree bytes differing from npm is an diff --git a/SECURITY.md b/SECURITY.md index 506e118e4..21e0a4fc3 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -26,12 +26,16 @@ This repository also carries the source of the **`eql-bindings`** Rust crate lockstep with `@cipherstash/eql`. It is in scope for security reports on the same terms as the npm packages above. -> **Note on publishing.** Every package in the table above, including all seven -> `@cipherstash/protect-ffi*` packages and `@cipherstash/eql`, is published from -> this repository by `.github/workflows/release.yml`; the `eql-bindings` crate -> is published from here by `.github/workflows/release-plz.yml`. EQL moved here -> at the Phase 5 cutover in `docs/plans/2026-08-13-eql-monorepo-absorption.md`. -> Releases made before it, `@cipherstash/eql@3.0.5` and earlier, were built by +> **Note on publishing.** `@cipherstash/auth` and its six platform packages, +> and the `stack-auth` and `stack-profile` crates, are developed here but are +> *published* from `cipherstash/cipherstash-suite` until the arming PR of the +> stack-* crates import repoints them. Every other package in the table above, +> including all seven `@cipherstash/protect-ffi*` packages and +> `@cipherstash/eql`, is published from this repository by +> `.github/workflows/release.yml`; the `eql-bindings` crate is published from +> here by `.github/workflows/release-plz.yml`. EQL moved here at the Phase 5 +> cutover in `docs/plans/2026-08-13-eql-monorepo-absorption.md`. Releases made +> before it, `@cipherstash/eql@3.0.5` and earlier, were built by > `cipherstash/encrypt-query-language`. **Source, issues, and security reports > for all of them belong here regardless** — that part does not depend on which > pipeline built the artefact. diff --git a/package.json b/package.json index 6f22b42a0..1f1f9a4d6 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "clean": "rimraf --glob **/.next **/.turbo **/dist **/node_modules", "code:fix": "biome check --write", "code:check": "biome check", + "lint:auth-changeset": "node scripts/lint-no-auth-changeset.mjs", "lint:eql-pins": "node scripts/lint-no-eql-registry-pins.mjs", "lint:package-paths": "node scripts/lint-no-dead-package-paths.mjs", "lint:runners": "node scripts/lint-no-hardcoded-runners.mjs", diff --git a/scripts/__tests__/frozen-publisher-docs.test.mjs b/scripts/__tests__/frozen-publisher-docs.test.mjs index 774b55bbd..0867ae307 100644 --- a/scripts/__tests__/frozen-publisher-docs.test.mjs +++ b/scripts/__tests__/frozen-publisher-docs.test.mjs @@ -47,10 +47,20 @@ import { REPO_ROOT } from './lib/repo-root.mjs' */ const EQL = '@cipherstash/eql' +const AUTH = '@cipherstash/auth' -/** Where the freeze is explained, and the instruction each file carries. */ +/** + * Where each freeze is explained, and the instruction each file carries. + * + * `pkg` is the map key the instruction is about. The @cipherstash/auth freeze + * (the wrapper and its six platform packages, keyed here by the wrapper) is + * deleted by the arming PR of the stack-* crates import, and its prose goes + * with it. `historical` is the wording each instruction was written for, + * which keeps an assertion of absence able to fail once a freeze is deleted. + */ const DOCS = [ { + pkg: EQL, file: 'AGENTS.md', // Both spellings resolve to "the `@cipherstash/eql` entry", which is the // thing Phase 5 deletes — matching that keeps the guard anchored to the @@ -59,6 +69,7 @@ const DOCS = [ historical: 'Delete the `@cipherstash/eql` entry in the Phase-5 cutover.', }, { + pkg: EQL, file: 'docs/plans/2026-08-13-eql-monorepo-absorption.md', instruction: /`@cipherstash\/eql` entry/, historical: @@ -66,6 +77,7 @@ const DOCS = [ '`FROZEN_PUBLISHERS`, nothing more.', }, { + pkg: EQL, file: 'SECURITY.md', // Not a regex, and deliberately: this is `foreignPublishClaims` run over // the file, so the SAME extractor that forbids a wrong name below is what @@ -78,6 +90,24 @@ const DOCS = [ '> developed here but are *published* from `cipherstash/encrypt-query-language`\n' + '> until the Phase 5 cutover in\n', }, + { + pkg: AUTH, + file: 'AGENTS.md', + instruction: /`@cipherstash\/auth\*?` entries/, + historical: + '**Delete the `@cipherstash/auth*` entries in the arming PR of the stack-*\n' + + ' crates import.**', + }, + { + pkg: AUTH, + file: 'SECURITY.md', + instruction: (body) => foreignPublishClaims(body).includes(AUTH), + historical: + '> **Note on publishing.** `@cipherstash/auth` and its six platform packages,\n' + + '> and the `stack-auth` and `stack-profile` crates, are developed here but are\n' + + '> *published* from `cipherstash/cipherstash-suite` until the arming PR of the\n' + + '> stack-* crates import repoints them.\n', + }, ] /** @@ -188,7 +218,7 @@ const satisfies = (instruction, body) => describe('frozen-publisher docs track the map', () => { it.each(DOCS)( - '$file instruction still recognises the freeze wording it was written for', + '$file instruction still recognises the $pkg freeze wording it was written for', ({ instruction, historical }) => { // With EQL out of the map, the `iff` below asserts ABSENCE, which an // instruction that matches nothing passes vacuously. The pre-cutover @@ -198,20 +228,22 @@ describe('frozen-publisher docs track the map', () => { ) it.each(DOCS)( - '$file documents the eql freeze iff the map carries it', - ({ file, instruction }) => { + '$file documents the $pkg freeze iff the map carries it', + ({ pkg, file, instruction }) => { expect( satisfies(instruction, read(file)), - FROZEN_PUBLISHERS.has(EQL) - ? `${file} no longer tells an agent about the ${EQL} freeze, but ` + + FROZEN_PUBLISHERS.has(pkg) + ? `${file} no longer tells an agent about the ${pkg} freeze, but ` + 'FROZEN_PUBLISHERS still carries it.' - : `${EQL} has left FROZEN_PUBLISHERS (Phase-5 cutover), so ${file} ` + + : `${pkg} has left FROZEN_PUBLISHERS (its publishing cutover), so ${file} ` + 'must stop instructing agents about the freeze.', - ).toBe(FROZEN_PUBLISHERS.has(EQL)) + ).toBe(FROZEN_PUBLISHERS.has(pkg)) }, ) - it.each(DOCS)('$file freezes only what the map freezes', ({ file }) => { + const FILES = [...new Set(DOCS.map(({ file }) => file))] + + it.each(FILES)('%s freezes only what the map freezes', (file) => { const claimed = [...new Set(foreignPublishClaims(read(file)))] expect( claimed.filter((name) => !FROZEN_PUBLISHERS.has(name)), @@ -223,7 +255,7 @@ describe('frozen-publisher docs track the map', () => { ).toEqual([]) }) - it.each(DOCS)('$file asserts no live gate verdict', ({ file }) => { + it.each(FILES)('%s asserts no live gate verdict', (file) => { const body = read(file) expect( LIVE_VERDICT_CLAIMS.filter((claim) => claim.test(body)).map(String), diff --git a/scripts/__tests__/lint-no-auth-changeset.test.mjs b/scripts/__tests__/lint-no-auth-changeset.test.mjs new file mode 100644 index 000000000..109c66a61 --- /dev/null +++ b/scripts/__tests__/lint-no-auth-changeset.test.mjs @@ -0,0 +1,142 @@ +import { execFileSync } from 'node:child_process' +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { afterAll, describe, expect, it } from 'vitest' +import { FROZEN_PUBLISHERS, workspaceManifests } from '../release-gate.mjs' + +const SCRIPT = resolve( + fileURLToPath(import.meta.url), + '../../lint-no-auth-changeset.mjs', +) + +function run(dir) { + try { + const stdout = execFileSync('node', dir ? [SCRIPT, dir] : [SCRIPT], { + encoding: 'utf8', + }) + return { exitCode: 0, output: stdout } + } catch (err) { + return { + exitCode: err.status, + output: String(err.stdout) + String(err.stderr), + } + } +} + +// Generated rather than committed: a committed CRLF fixture is one +// `autocrlf=true` checkout away from being normalised to LF. +const tempDirs = [] +function changesets(files) { + const dir = mkdtempSync(join(tmpdir(), 'auth-changeset-')) + tempDirs.push(dir) + for (const [name, body] of Object.entries(files)) { + writeFileSync(join(dir, name), body) + } + return dir +} +afterAll(() => { + for (const dir of tempDirs) rmSync(dir, { recursive: true, force: true }) +}) + +describe('lint-no-auth-changeset', () => { + it('passes against the real .changeset directory', () => { + expect(run().exitCode).toBe(0) + }) + + it('passes on changesets that name no auth package', () => { + const dir = changesets({ + 'happy-otter-sing.md': + "---\n'@cipherstash/stack': patch\n---\n\nA fix.\n", + }) + expect(run(dir).exitCode).toBe(0) + }) + + it('does not parse README.md as a changeset', () => { + // Guarded frontmatter in the README, so this passes only because of the skip. + const dir = changesets({ + 'README.md': "---\n'@cipherstash/auth': minor\n---\n\nNot a changeset.\n", + }) + const { exitCode, output } = run(dir) + expect(exitCode).toBe(0) + expect(output).not.toMatch(/README/) + }) + + it('fails when a changeset names the wrapper, and reports every file', () => { + const dir = changesets({ + 'brave-lion-jump.md': + "---\n'@cipherstash/auth': minor\n---\n\nNew API.\n", + 'quiet-moth-wait.md': + "---\n'@cipherstash/auth-linux-x64-musl': patch\n---\n\nRebuild.\n", + }) + const { exitCode, output } = run(dir) + expect(exitCode).toBe(1) + expect(output).toMatch('brave-lion-jump.md') + expect(output).toMatch('quiet-moth-wait.md') + expect(output).toMatch('@cipherstash/auth-linux-x64-musl') + }) + + it('catches an auth package on any frontmatter line, not just the first', () => { + const dir = changesets({ + 'wise-crane-list.md': + "---\n'@cipherstash/stack': patch\n'@cipherstash/auth-darwin-arm64': patch\n---\n\nBoth.\n", + }) + const { exitCode, output } = run(dir) + expect(exitCode).toBe(1) + expect(output).toMatch('@cipherstash/auth-darwin-arm64') + }) + + it('parses a changeset checked out with CRLF line endings', () => { + const dir = changesets({ + 'tidy-vole-climb.md': + "---\r\n'@cipherstash/stack': patch\r\n'@cipherstash/auth-linux-arm64-gnu': patch\r\n---\r\n\r\nWritten on Windows.\r\n", + }) + const { exitCode, output } = run(dir) + expect(exitCode).toBe(1) + expect(output).toMatch('@cipherstash/auth-linux-arm64-gnu') + }) + + it('ignores an auth package named only in the prose body', () => { + const dir = changesets({ + 'gentle-fox-run.md': + "---\n'@cipherstash/stack': patch\n---\n\nUses `'@cipherstash/auth': minor` internally.\n", + }) + expect(run(dir).exitCode).toBe(0) + }) + + it('does not guard a name that only starts with auth', () => { + const dir = changesets({ + 'odd-name.md': + "---\n'@cipherstash/authority': patch\n---\n\nUnrelated.\n", + }) + expect(run(dir).exitCode).toBe(0) + }) + + it('names its own removal condition in the source', () => { + const source = readFileSync(SCRIPT, 'utf8') + expect(source).toMatch(/TEMPORARY/) + expect(source).toMatch(/trusted\s+publishing/) + }) + + it('guards exactly the frozen auth packages, which are the auth workspace packages', () => { + // Three lists name the same seven packages until PR E: this guard, the + // release gate's freeze, and the workspace. Drift between any two lets a + // platform package through while the others still treat it as frozen. + const guarded = [ + ...readFileSync(SCRIPT, 'utf8').matchAll( + /'(@cipherstash\/auth(?:-[a-z0-9-]+)?)'/g, + ), + ].map(([, name]) => name) + const isAuth = (name) => + name === '@cipherstash/auth' || name.startsWith('@cipherstash/auth-') + const frozen = [...FROZEN_PUBLISHERS.keys()].filter(isAuth) + const workspace = workspaceManifests() + .map((manifest) => manifest.name) + .filter(isAuth) + + expect([...new Set(guarded)].sort()).toHaveLength(7) + expect([...new Set(guarded)].sort()).toEqual([...frozen].sort()) + expect([...new Set(guarded)].sort()).toEqual([...workspace].sort()) + }) +}) diff --git a/scripts/__tests__/release-gate.test.mjs b/scripts/__tests__/release-gate.test.mjs index efb849114..1c98e45be 100644 --- a/scripts/__tests__/release-gate.test.mjs +++ b/scripts/__tests__/release-gate.test.mjs @@ -39,6 +39,7 @@ import { readWorkflow } from './lib/workflows.mjs' const FFI = '@cipherstash/protect-ffi' const PLATFORM = '@cipherstash/protect-ffi-darwin-arm64' +const AUTH = '@cipherstash/auth' describe('unpublished', () => { it('reports a package whose committed version is not on the registry', () => { @@ -760,10 +761,12 @@ describe('the gate actually blocks the publish', () => { * `npmVersions` shells out to it. That also keeps this offline and * deterministic. * - * WITH THE REAL MAPS EMPTY, the blocking path needs a frozen package, so most - * of these run `main()` with the EQL fixture injected through its parameters — - * a separate process importing the module, never a flag the real script reads. - * One runs the script itself, to hold the real maps to "EQL is not frozen". + * THE REAL MAPS DO NOT FREEZE EQL, so the EQL blocking path needs a frozen + * package, and most of these run `main()` with the EQL fixture injected + * through its parameters — a separate process importing the module, never a + * flag the real script reads. Two run the script itself: one holds the real + * maps to "EQL is not frozen", and one drives the `@cipherstash/auth` freeze + * through them. * * THE SHIM ANSWERS `pack` AS WELL AS `view`, and that is not tidying. It used * to answer `view` only, so `publishedArtefactDigest`'s `npm pack` got a @@ -803,6 +806,19 @@ describe('the gate exits non-zero when a blocker is found', () => { " process.stderr.write('npm error code ETARGET\\n'); process.exit(1)\n" + ' }\n' + " const dest = process.argv[process.argv.indexOf('--pack-destination') + 1]\n" + + // A `files` artefact (@cipherstash/auth): the tarball carries the + // tree's own bytes for every listed file, so CHECK C compares them for + // real and passes. + ' const files = JSON.parse(process.env.FAKE_NPM_FILES)[name]\n' + + ' if (files) {\n' + + ' for (const [published, source] of Object.entries(files)) {\n' + + " const target = path.join(dest, 'stage', published)\n" + + ' fs.mkdirSync(path.dirname(target), { recursive: true })\n' + + ' fs.copyFileSync(source, target)\n' + + ' }\n' + + " require('node:child_process').execFileSync('tar', ['-czf', path.join(dest, 'f.tgz'), '-C', path.join(dest, 'stage'), 'package'])\n" + + " process.stdout.write('f.tgz\\n'); process.exit(0)\n" + + ' }\n' + " const stage = path.join(dest, 'stage', 'package', 'dist', 'sql')\n" + ' fs.mkdirSync(stage, { recursive: true })\n' + " fs.writeFileSync(path.join(stage, 'release-manifest.json'), JSON.stringify({\n" + @@ -825,6 +841,21 @@ describe('the gate exits non-zero when a blocker is found', () => { return { dir, versions, packDigest } } + /** For each `files` artefact, the tree file behind each tarball member. */ + const FILES_ARTEFACTS = Object.fromEntries( + [...FROZEN_ARTEFACT_DIGESTS] + .filter(([, artefact]) => artefact.files) + .map(([name, artefact]) => [ + name, + Object.fromEntries( + artefact.files.map((file) => [ + file.published, + join(REPO_ROOT, file.inTree), + ]), + ), + ]), + ) + /** Every workspace package published at exactly its committed version. */ const allPublished = Object.fromEntries( manifests.map(({ name, version }) => [name, [version]]), @@ -850,6 +881,7 @@ describe('the gate exits non-zero when a blocker is found', () => { PATH: `${dir}:${process.env.PATH}`, FAKE_NPM_VERSIONS: versions, FAKE_NPM_PACK_DIGEST: packDigest, + FAKE_NPM_FILES: JSON.stringify(FILES_ARTEFACTS), // The real one would be written for the whole vitest run. GITHUB_OUTPUT: join(dir, 'github-output.txt'), }, @@ -933,6 +965,20 @@ describe('the gate exits non-zero when a blocker is found', () => { expect(result.status).toBe(0) expect(result.stdout).toContain(`unpublished: ${EQL}`) }) + + it('blocks a stray @cipherstash/auth bump while the auth packages are frozen', () => { + // The auth freeze, end to end, through the real script and the real maps: + // npm carries the committed 0.44.0 and not the bump, so CHECK A names the + // bumped version and the release stops. + const result = runGate( + { ...allPublished, [AUTH]: ['0.43.0'] }, + IN_TREE_DIGEST, + REAL_SCRIPT, + ) + expect(result.status).toBe(1) + expect(result.stderr).toContain(`${AUTH}@0.44.0 is not on npm`) + expect(result.stderr).toContain('cipherstash/cipherstash-suite') + }) }) describe('frozenBytesSkew', () => { @@ -1277,8 +1323,21 @@ describe('inTreeArtefactDigest, over the real committed manifest', () => { }) it('resolves every artefact the real map declares', () => { + // A `field` artefact resolves to one digest, a `files` artefact to one + // `<published path> <sha256>` line per listed file. A `noTreeBytes` entry + // declares that the tree holds nothing to read; CHECK C skips it, and + // 'a `noTreeBytes` artefact' below holds it to a reason. for (const [name, artefact] of FROZEN_ARTEFACT_DIGESTS) { - expect(inTreeArtefactDigest(name, artefact)).toMatch(/^[0-9a-f]{64}$/) + if ('noTreeBytes' in artefact) continue + const digest = inTreeArtefactDigest(name, artefact) + if (artefact.files) { + const lines = digest.split('\n') + expect(lines, name).toHaveLength(artefact.files.length) + for (const line of lines) + expect(line, name).toMatch(/^package\/\S+ [0-9a-f]{64}$/) + } else { + expect(digest, name).toMatch(/^[0-9a-f]{64}$/) + } } }) @@ -1329,3 +1388,169 @@ describe('reportBlockers, for a bytes skew', () => { expect(text).not.toMatch(/Publish the frozen package\./) }) }) + +/** + * The `files` artefact shape, which `@cipherstash/auth` needs because it has no + * release manifest to read a digest from. The gate hashes each listed file on + * both sides; EQL's `field` entry keeps working unchanged (every EQL test + * above). + */ +describe('a `files` artefact', () => { + const AUTH_DIR = 'languages/typescript/packages/auth' + const artefact = FROZEN_ARTEFACT_DIGESTS.get(AUTH) + + it('hashes every listed file in the tree, one line per file', () => { + const lines = inTreeArtefactDigest(AUTH, artefact).split('\n') + expect(lines).toHaveLength(artefact.files.length) + for (const line of lines) + expect(line).toMatch(/^package\/\S+ [0-9a-f]{64}$/) + }) + + it('lists every tracked file the wrapper publishes, except package.json', () => { + // A file the wrapper publishes and the list leaves out is a file whose + // bytes nobody compares. `files` in package.json is what npm packs, and + // `wasm/` is a build output with nothing tracked behind it. + const manifest = JSON.parse( + readFileSync(join(REPO_ROOT, AUTH_DIR, 'package.json'), 'utf8'), + ) + const published = manifest.files.filter((entry) => !entry.endsWith('/')) + expect(artefact.files.map((file) => file.inTree).sort()).toEqual( + published.map((file) => `${AUTH_DIR}/${file}`).sort(), + ) + expect(manifest.files.filter((entry) => entry.endsWith('/'))).toEqual([ + 'wasm/', + ]) + }) + + it('throws, naming the file, when a listed file is missing from the tree', () => { + expect(() => + inTreeArtefactDigest(AUTH, { + files: [ + { inTree: `${AUTH_DIR}/no-such-file.js`, published: 'package/x.js' }, + ], + }), + ).toThrow(/no-such-file\.js/) + }) + + it('names only the file that differs', () => { + const [blocker] = frozenBytesSkew({ + manifests: [{ name: AUTH, version: '0.44.0', private: false }], + frozen: new Map([[AUTH, 'frozen']]), + artefacts: new Map([[AUTH, artefact]]), + inTreeDigest: () => 'package/a.js 1111\npackage/b.js 2222', + publishedDigest: () => 'package/a.js 1111\npackage/b.js 3333', + }) + expect(blocker.kind).toBe('frozen-bytes-skew') + expect(blocker.local).toBe('package/b.js 2222') + expect(blocker.published).toBe('package/b.js 3333') + }) + + it('reads the same bytes out of a tarball as from the tree', () => { + // Driven through the real `npm pack` + `tar` path with a shimmed `npm` + // that packs the tree's own files, so the two digests must agree. + const dir = mkdtempSync(join(tmpdir(), 'release-gate-files-')) + const shim = join(dir, 'npm') + const members = Object.fromEntries( + artefact.files.map((file) => [ + file.published, + join(REPO_ROOT, file.inTree), + ]), + ) + writeFileSync( + shim, + '#!/usr/bin/env node\n' + + "const fs = require('node:fs'), path = require('node:path')\n" + + "const dest = process.argv[process.argv.indexOf('--pack-destination') + 1]\n" + + `const members = ${JSON.stringify(members)}\n` + + 'const drop = process.env.DROP_MEMBER\n' + + 'for (const [published, source] of Object.entries(members)) {\n' + + ' if (published === drop) continue\n' + + " const target = path.join(dest, 'stage', published)\n" + + ' fs.mkdirSync(path.dirname(target), { recursive: true })\n' + + ' fs.copyFileSync(source, target)\n' + + '}\n' + + "require('node:child_process').execFileSync('tar', ['-czf', path.join(dest, 'f.tgz'), '-C', path.join(dest, 'stage'), 'package'])\n" + + "process.stdout.write('f.tgz\\n')\n", + ) + chmodSync(shim, 0o755) + const path = process.env.PATH + process.env.PATH = `${dir}:${path}` + try { + expect(publishedArtefactDigest(AUTH, '0.44.0', artefact)).toBe( + inTreeArtefactDigest(AUTH, artefact), + ) + // A listed file missing from the tarball throws rather than passing. + process.env.DROP_MEMBER = 'package/next.mjs' + expect(() => publishedArtefactDigest(AUTH, '0.44.0', artefact)).toThrow( + /package\/next\.mjs/, + ) + } finally { + process.env.PATH = path + delete process.env.DROP_MEMBER + rmSync(dir, { recursive: true, force: true }) + } + }) +}) + +/** + * The `noTreeBytes` shape: the six @cipherstash/auth platform packages publish + * only a binary built in CI, so CHECK C has nothing to compare and skips them. + * They are frozen all the same, so CHECK A blocks a stray version. + */ +describe('a `noTreeBytes` artefact', () => { + const platforms = workspaceManifests() + .map((manifest) => manifest.name) + .filter((name) => name.startsWith(`${AUTH}-`)) + + it('covers every @cipherstash/auth platform package in the workspace', () => { + expect(platforms).toHaveLength(6) + for (const name of platforms) { + expect(FROZEN_PUBLISHERS.has(name), name).toBe(true) + expect(FROZEN_ARTEFACT_DIGESTS.get(name).noTreeBytes, name).toMatch(/\S/) + } + }) + + it('is skipped by CHECK C without asking the registry', () => { + const name = platforms[0] + expect( + frozenBytesSkew({ + manifests: [{ name, version: '0.44.0', private: false }], + frozen: new Map([[name, 'frozen']]), + artefacts: new Map([[name, FROZEN_ARTEFACT_DIGESTS.get(name)]]), + inTreeDigest: () => { + throw new Error('must not read the tree') + }, + publishedDigest: () => { + throw new Error('must not download') + }, + }), + ).toEqual([]) + }) + + it('throws when the reason is empty', () => { + const name = platforms[0] + expect(() => + frozenBytesSkew({ + manifests: [{ name, version: '0.44.0', private: false }], + frozen: new Map([[name, 'frozen']]), + artefacts: new Map([ + [name, { label: 'platform binary', noTreeBytes: '' }], + ]), + inTreeDigest: () => 'x', + publishedDigest: () => 'x', + }), + ).toThrow(/noTreeBytes/) + }) + + it('still blocks a platform version npm does not carry (CHECK A)', () => { + const name = platforms[0] + expect( + publishBlockers({ + manifests: [{ name, version: '0.44.1', private: false }], + lookup: () => ['0.44.0'], + }).map( + (blocker) => `${blocker.kind} ${blocker.package}@${blocker.version}`, + ), + ).toEqual([`frozen-publisher ${name}@0.44.1`]) + }) +}) diff --git a/scripts/lint-no-auth-changeset.mjs b/scripts/lint-no-auth-changeset.mjs new file mode 100644 index 000000000..fd3d35665 --- /dev/null +++ b/scripts/lint-no-auth-changeset.mjs @@ -0,0 +1,78 @@ +/** + * Fail if any pending changeset names one of the seven `@cipherstash/auth` + * packages. + * + * TEMPORARY. Delete this script, its self-test and its `lint:auth-changeset` + * entry in the arming PR of the stack-* crates import (PR E), when npm trusted + * publishing for the seven packages moves from `cipherstash/cipherstash-suite` + * to `cipherstash/stack` and their `FROZEN_PUBLISHERS` entries are deleted. + * + * The release gate's CHECK A already refuses to publish a frozen package at a + * version npm does not carry, but it fires on `main` after the Version + * Packages PR merges, and then it blocks every release until the bump is + * reverted. This stops the changeset on the pull request instead. + * + * There is nowhere to park the changeset: the `.md.deferred` convention the + * retired protect-ffi guard used is forbidden by `no-parked-changesets`. + */ +import { readdirSync, readFileSync } from 'node:fs' +import { join, relative, resolve } from 'node:path' + +const REPO_ROOT = resolve(import.meta.dirname, '..') + +const GUARDED = new Set([ + '@cipherstash/auth', + '@cipherstash/auth-darwin-arm64', + '@cipherstash/auth-darwin-x64', + '@cipherstash/auth-linux-arm64-gnu', + '@cipherstash/auth-linux-x64-gnu', + '@cipherstash/auth-linux-x64-musl', + '@cipherstash/auth-win32-x64-msvc', +]) + +const changesetDir = process.argv[2] + ? resolve(process.argv[2]) + : join(REPO_ROOT, '.changeset') + +// Only the first fenced block is frontmatter: prose below it may quote a +// package name, and `---` rules in markdown would otherwise reopen the block. +function packagesIn(source) { + const match = /^---\r?\n([\s\S]*?)\r?\n---/.exec(source) + if (!match) return [] + return match[1] + .split(/\r?\n/) + .map((line) => /^\s*['"]?(@?[^'":]+?)['"]?\s*:/.exec(line)) + .filter(Boolean) + .map((m) => m[1].trim()) +} + +const offenders = [] +for (const entry of readdirSync(changesetDir)) { + if (!entry.endsWith('.md') || entry === 'README.md') continue + const named = packagesIn(readFileSync(join(changesetDir, entry), 'utf8')) + const guarded = named.filter((name) => GUARDED.has(name)) + if (guarded.length) offenders.push({ file: entry, packages: guarded }) +} + +if (offenders.length === 0) { + console.log('No pending changeset names an @cipherstash/auth package.') + process.exit(0) +} + +console.error('\nA pending changeset names an @cipherstash/auth package:\n') +for (const { file, packages } of offenders) { + console.error(` ${relative(REPO_ROOT, join(changesetDir, file))}`) + for (const name of packages) console.error(` ${name}`) +} +console.error( + '\nThese seven packages live in this repo but are still PUBLISHED from\n' + + 'cipherstash/cipherstash-suite — npm trusted publishing has not been\n' + + 'repointed yet, and this repository has no job that builds their native\n' + + 'binaries. Releasing a bumped version from here is blocked by\n' + + '`release:gate`, and that block stops every other release with it.\n\n' + + 'Remove the changeset. Merges that touch the auth packages are paused\n' + + 'until the arming PR (PR E of the stack-* crates import), which repoints\n' + + 'trusted publishing, deletes this script and writes the changesets. If a\n' + + 'change must land before then, put its release note in the pull request.\n', +) +process.exit(1) diff --git a/scripts/release-gate.mjs b/scripts/release-gate.mjs index 52e031c71..b00e89d2b 100644 --- a/scripts/release-gate.mjs +++ b/scripts/release-gate.mjs @@ -35,6 +35,7 @@ * pointing at a version nobody can install. */ import { execFileSync } from 'node:child_process' +import { createHash } from 'node:crypto' import { appendFileSync, globSync, @@ -127,11 +128,12 @@ const INSTALLED_TABLES = new Set([ * `scripts/lint-no-eql-registry-pins.mjs`. * * Each entry is DELETED by the cutover that repoints its publisher. - * `@cipherstash/eql` was the last one: its Phase-5 release cutover repointed - * npm and crates.io trusted publishing at this repository and deleted its - * entry here, which is why the map is empty. Empty is a legitimate state, not a - * retired mechanism — the next package that lives here before its publisher - * moves goes back in, with its artefact below. + * `@cipherstash/eql`'s Phase-5 release cutover repointed npm and crates.io + * trusted publishing at this repository and deleted its entry here. The + * `@cipherstash/auth` packages below are in the same position until the + * arming PR of the stack-* crates import. An empty map is a legitimate state, + * not a retired mechanism — the next package that lives here before its + * publisher moves goes in, with its artefact below. * * DELETE IT IN THAT PR, not afterwards. An entry left behind does not fail on * the day it goes wrong, it fails on the next release: while the package sits @@ -143,7 +145,27 @@ const INSTALLED_TABLES = new Set([ * `release-gate.test.mjs` now asserts their absence, so the map has a test for * what is NOT in it as well as what is. */ -export const FROZEN_PUBLISHERS = new Map([]) +export const FROZEN_PUBLISHERS = new Map([ + // The @cipherstash/auth wrapper and its six platform packages, imported + // from cipherstash-suite with the stack-* crates. They keep publishing from + // there until the arming PR of that import repoints npm trusted publishing + // at this repository and deletes all seven entries here, in both maps. + ...[ + '@cipherstash/auth', + '@cipherstash/auth-darwin-arm64', + '@cipherstash/auth-darwin-x64', + '@cipherstash/auth-linux-arm64-gnu', + '@cipherstash/auth-linux-x64-gnu', + '@cipherstash/auth-linux-x64-musl', + '@cipherstash/auth-win32-x64-msvc', + ].map((name) => [ + name, + 'Still published from cipherstash/cipherstash-suite — npm trusted publishing ' + + 'for the seven @cipherstash/auth packages names that repository, and ' + + '`release.yml` here has no job that builds the native binaries. Repointing ' + + 'is the arming PR (PR E) of the stack-* crates import.', + ]), +]) /** * For each frozen package, the artefact whose bytes must equal the published @@ -165,8 +187,8 @@ export const FROZEN_PUBLISHERS = new Map([]) * reads that SQL verbatim (`readInstallSql`, no digest check), so * `stash eql install` would have put functions into a customer database that * the version it reports does not define. It was caught by a human reading the - * diff. `release-gate.test.mjs` keeps that entry as a fixture, so the check is - * still driven over the real tree while the map is empty. + * diff. `release-gate.test.mjs` keeps that entry as a fixture, so the `field` + * check is still driven over the real tree although no entry here uses it. * * The digest is read from each side's release manifest rather than hashed * here: the manifest is the artefact's own statement about itself, so a @@ -178,8 +200,66 @@ export const FROZEN_PUBLISHERS = new Map([]) * that by equality, so a frozen publisher added without an artefact fails * rather than silently getting no bytes check. Both entries are DELETED * together by the cutover that repoints the publisher. + * + * ## Three shapes of entry + * + * * `field` — a digest read out of a release manifest on each side, as for + * `@cipherstash/eql` while it was frozen (the fixture in + * `release-gate.test.mjs`). + * * `files` — for a package with no such manifest. The gate hashes each + * listed file with sha256, in the tree and in the published tarball, and + * a mismatch names the file. `@cipherstash/auth` is this shape: the list + * is every tracked file the wrapper publishes except `package.json`, which + * publishing rewrites. `wasm/` is a build output and is not listed. The + * list cannot see the Rust source, because the compiled binary is not in + * the tree — the freeze covers the JavaScript and type surface only. + * * `noTreeBytes` — a package whose tarball holds nothing the tree has, only + * a binary built in CI. CHECK C skips it, and the string says why. The + * entry still exists so the key-equality test holds, and CHECK A still + * blocks a version npm does not carry. */ -export const FROZEN_ARTEFACT_DIGESTS = new Map([]) +export const FROZEN_ARTEFACT_DIGESTS = new Map([ + [ + '@cipherstash/auth', + { + label: 'wrapper sources and type declarations', + files: [ + 'index.js', + 'stack-auth-node.js', + 'wasm-inline.mjs', + 'cookies.mjs', + 'base64url.mjs', + 'next.mjs', + 'index.d.ts', + 'native.d.ts', + 'wasm-types.d.ts', + 'wasm-inline.d.ts', + 'cookies.d.ts', + 'base64url.d.ts', + 'next.d.ts', + 'README.md', + 'LICENSE', + ].map((file) => ({ + inTree: `languages/typescript/packages/auth/${file}`, + published: `package/${file}`, + })), + }, + ], + ...[ + '@cipherstash/auth-darwin-arm64', + '@cipherstash/auth-darwin-x64', + '@cipherstash/auth-linux-arm64-gnu', + '@cipherstash/auth-linux-x64-gnu', + '@cipherstash/auth-linux-x64-musl', + '@cipherstash/auth-win32-x64-msvc', + ].map((name) => [ + name, + { + label: 'platform binary', + noTreeBytes: 'the published tarball holds only a binary built in CI', + }, + ]), +]) /** * The range pnpm writes into the packed `package.json` for a `workspace:` @@ -476,6 +556,21 @@ export function frozenBytesSkew({ ) } + // Nothing in the tree to compare. An empty reason is a declaration that + // has lost its justification, so it throws like a missing artefact. + if ('noTreeBytes' in artefact) { + if ( + typeof artefact.noTreeBytes !== 'string' || + artefact.noTreeBytes.trim() === '' + ) { + throw new Error( + `${name} declares \`noTreeBytes\` with no reason. Say why the tarball ` + + 'holds nothing the tree has, or declare a `field` or `files` artefact.', + ) + } + continue + } + const published = publishedDigest(name, version, artefact) if (published === null || published === undefined) continue @@ -486,14 +581,40 @@ export function frozenBytesSkew({ package: name, version, label: artefact.label, - local, - published, + ...differingLines(local, published), }) } } return blockers } +/** + * For a `files` artefact, only the files that differ. + * + * Its digest is one `<published path> <sha256>` line per file, so the lines + * that differ are the files that differ, and the report names them rather + * than printing fifteen hashes. A `field` digest is a single line, and passes + * through unchanged. + */ +function differingLines(local, published) { + const left = String(local).split('\n') + const right = String(published).split('\n') + if (left.length === 1 && right.length === 1) return { local, published } + const only = (lines, other) => + lines.filter((line) => !other.includes(line)).join('\n ') + return { local: only(left, right), published: only(right, left) } +} + +/** One `<published path> <sha256>` line per listed file. */ +function fileDigests(files, read) { + return files + .map(({ published }) => { + const hash = createHash('sha256').update(read(published)).digest('hex') + return `${published} ${hash}` + }) + .join('\n') +} + /** * The `packages:` globs from `pnpm-workspace.yaml`, parsed without a YAML * library. @@ -633,8 +754,31 @@ function digestField(manifest, artefact, source) { return value } -/** The digest an artefact's IN-TREE release manifest claims for itself. */ +/** + * The digest an artefact's IN-TREE release manifest claims for itself — or, + * for a `files` artefact, the sha256 of each listed file on disk. + * + * A listed file that is missing THROWS, naming it: a stale list must not pass + * quietly, for the same reason a missing field throws. + */ export function inTreeArtefactDigest(_name, artefact) { + if (artefact.files) { + const byPublished = new Map( + artefact.files.map((file) => [file.published, file.inTree]), + ) + return fileDigests(artefact.files, (published) => { + const inTree = byPublished.get(published) + try { + return readFileSync(join(REPO_ROOT, inTree)) + } catch (err) { + throw new Error( + `${inTree} is listed in FROZEN_ARTEFACT_DIGESTS but cannot be read ` + + `(${err.code ?? err.message}). Fix the list rather than leaving the ` + + 'check disarmed.', + ) + } + }) + } const path = join(REPO_ROOT, artefact.inTree) return digestField( JSON.parse(readFileSync(path, 'utf8')), @@ -700,12 +844,15 @@ export function publishedArtefactDigest(name, version, artefact) { ) } + // One member for a `field` artefact, every listed file for a `files` one. + // A member missing from the tarball fails the extraction, which names it. + const members = artefact.files + ? artefact.files.map((file) => file.published) + : [artefact.published] try { - execFileSync( - 'tar', - ['-xzf', join(dir, packed), '-C', dir, artefact.published], - { stdio: ['ignore', 'pipe', 'pipe'] }, - ) + execFileSync('tar', ['-xzf', join(dir, packed), '-C', dir, ...members], { + stdio: ['ignore', 'pipe', 'pipe'], + }) } catch (err) { // Already fail-closed — this call sits outside the E404 catch above — but // the message was `Command failed: tar -xzf …` with tar's own stderr @@ -713,12 +860,17 @@ export function publishedArtefactDigest(name, version, artefact) { // the one worth naming: the published layout moved. const text = `${err.stdout ?? ''}${err.stderr ?? ''}` throw new Error( - `${spec}: could not extract \`${artefact.published}\` from the published tarball. ` + + `${spec}: could not extract \`${members.join('`, `')}\` from the published tarball. ` + 'The package layout has changed — update the `published` path in ' + `FROZEN_ARTEFACT_DIGESTS.\n${text.trim() || err.message}`, ) } + if (artefact.files) { + return fileDigests(artefact.files, (published) => + readFileSync(join(dir, published)), + ) + } return digestField( JSON.parse(readFileSync(join(dir, artefact.published), 'utf8')), artefact, @@ -811,9 +963,10 @@ export function reportBlockers(blockers) { " repository's to do.\n" : target ? ' 1. Publish the frozen package. For @cipherstash/eql that is the Phase 5\n' + - ' cutover in docs/plans/2026-08-13-eql-monorepo-absorption.md: repoint\n' + - ' npm trusted publishing to cipherstash/stack and release the version\n' + - ` above — ${target}.\n` + + ' cutover in docs/plans/2026-08-13-eql-monorepo-absorption.md; for the\n' + + ' @cipherstash/auth packages it is the arming PR of the stack-* crates\n' + + ' import. Either way: repoint npm trusted publishing to cipherstash/stack\n' + + ` and release the version above — ${target}.\n` + ' Every finding then clears on its own, with no further change here.\n' : ' 1. Publish the frozen package. Nothing above is frozen, so this way out\n' + ' is not available: the findings are manifests to fix, not a release to\n' + @@ -839,8 +992,9 @@ export function reportBlockers(blockers) { /** * The maps are parameters so the process tests can drive the blocking path - * while the real maps are empty. Deliberately not reachable from the command - * line: no flag or environment variable changes what a real run freezes. + * with a fixture, whatever the real maps hold. Deliberately not reachable + * from the command line: no flag or environment variable changes what a real + * run freezes. */ export function main({ frozen = FROZEN_PUBLISHERS, From 542fa5115804fd13016a6f5300c87e0d84daae8e Mon Sep 17 00:00:00 2001 From: Lindsay Holmwood <lindsay@holmwood.id.au> Date: Fri, 2 Oct 2026 09:27:55 +1000 Subject: [PATCH 678/686] test: register the stack-* workspaces with the guards that list them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plan §7.6, the registrations that belong to PR B. - cargo-publish-opt-out: the root workspace, with stack-auth and stack-profile publishable, and the three fuzz crates and two Go guests with nothing publishable. Their `[workspace]` has no members, so the test reads a single-package workspace as one member, the root. - cargo-lock-freshness discovers locks by itself, and now asserts it sees the root lock and the five detached ones. It reads the version of a `version.workspace = true` crate from the workspace root, and expects eql-bindings in the two locks that build it rather than in every lock. - workflow-mise-setup: the root now has a mise config, so the comment and failure message stop saying it has none. The check still requires an explicit working_directory on every mise-action step, so an EQL job that forgets packages/eql still fails instead of reading the root config. - lint-typecheck-scope: the auth and profile platform folders as roots. The auth and profile packages themselves are already found through languages/typescript/packages. workflow-paths-filter-parity needs nothing here: PR B adds no workflow. eql-suite-ci and crates-ci wait for PR C, and lint-no-workflow-caching for the publish workflows of PR D. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PaY5xYydZUWhv8Nex9Sw8a --- .../__tests__/cargo-lock-freshness.test.mjs | 61 ++++++++++++++++--- .../__tests__/cargo-publish-opt-out.test.mjs | 31 ++++++++-- .../__tests__/workflow-mise-setup.test.mjs | 22 ++++--- scripts/lint-typecheck-scope.mjs | 8 ++- 4 files changed, 99 insertions(+), 23 deletions(-) diff --git a/scripts/__tests__/cargo-lock-freshness.test.mjs b/scripts/__tests__/cargo-lock-freshness.test.mjs index ce942ba84..ea2424951 100644 --- a/scripts/__tests__/cargo-lock-freshness.test.mjs +++ b/scripts/__tests__/cargo-lock-freshness.test.mjs @@ -1,5 +1,5 @@ -import { readdirSync, readFileSync } from 'node:fs' -import { join, relative, sep } from 'node:path' +import { existsSync, readdirSync, readFileSync } from 'node:fs' +import { dirname, join, relative, sep } from 'node:path' import { describe, expect, it } from 'vitest' import { REPO_ROOT } from './lib/repo-root.mjs' @@ -77,12 +77,33 @@ function findFiles(name) { * `version = "…"` line, and reading "the first version in the file" would pick * up whichever came first. */ -function crateManifest(source) { +function crateManifest(source, file) { const section = source.match(/^\[package\]\n(?:(?!^\[).*\n)*/m) if (!section) return null const name = /^name = "([^"]*)"$/m.exec(section[0]) - const version = /^version = "([^"]*)"$/m.exec(section[0]) - return name && version ? { name: name[1], version: version[1] } : null + const version = /^version\.workspace = true$/m.test(section[0]) + ? workspaceVersion(file) + : /^version = "([^"]*)"$/m.exec(section[0])?.[1] + return name && version ? { name: name[1], version } : null +} + +/** + * `[workspace.package] version` from the nearest enclosing workspace root, for + * a crate that declares `version.workspace = true` (the node binding crates in + * the root workspace). + */ +function workspaceVersion(file) { + let dir = dirname(dirname(join(REPO_ROOT, file))) + for (;;) { + const manifest = join(dir, 'Cargo.toml') + if (existsSync(manifest)) { + const source = readFileSync(manifest, 'utf8') + const table = source.match(/^\[workspace\.package\]\n(?:(?!^\[).*\n)*/m) + if (table) return /^version = "([^"]*)"$/m.exec(table[0])?.[1] ?? null + } + if (dir === REPO_ROOT || dirname(dir) === dir) return null + dir = dirname(dir) + } } /** @@ -112,7 +133,7 @@ export function localLockEntries(source) { const CRATES = new Map() const AMBIGUOUS = [] for (const file of findFiles('Cargo.toml')) { - const crate = crateManifest(readFileSync(join(REPO_ROOT, file), 'utf8')) + const crate = crateManifest(readFileSync(join(REPO_ROOT, file), 'utf8'), file) if (!crate) continue // a virtual manifest: `[workspace]` with no `[package]` const existing = CRATES.get(crate.name) if (existing && existing.version !== crate.version) { @@ -148,9 +169,31 @@ describe('Cargo.lock records this tree’s crates at their real versions', () => // Named specifically because it is the one with a mechanism actively // pushing it out of sync: `scripts/sync-lockstep-versions.mjs` writes its // `Cargo.toml` on every release. If this crate ever drops out of the pair - // set, the check that matters most has silently stopped running. - expect(PAIRS.filter(({ name }) => name === 'eql-bindings').length).toBe( - LOCKS.length, + // set, the check that matters most has silently stopped running. It is + // in the two locks whose workspaces build it; the stack-* workspaces do + // not depend on it. + expect( + PAIRS.filter(({ name }) => name === 'eql-bindings') + .map(({ lock }) => lock) + .sort(), + ).toEqual([ + 'languages/typescript/packages/protect-ffi/Cargo.lock', + 'packages/eql/Cargo.lock', + ]) + }) + + it('finds the root lock and the five detached stack-* locks', () => { + // The root workspace and the workspaces it excludes: the three cargo-fuzz + // crates and the two Go WASI guests, each with its own lock. + expect(LOCKS).toEqual( + expect.arrayContaining([ + 'Cargo.lock', + 'packages/stack-auth/fuzz/Cargo.lock', + 'packages/stack-kms/fuzz/Cargo.lock', + 'packages/stack-encrypt/fuzz/Cargo.lock', + 'languages/golang/stackencrypt/guest/Cargo.lock', + 'languages/golang/stackauth/guest/Cargo.lock', + ]), ) }) diff --git a/scripts/__tests__/cargo-publish-opt-out.test.mjs b/scripts/__tests__/cargo-publish-opt-out.test.mjs index 41d2b7a90..60ae2f071 100644 --- a/scripts/__tests__/cargo-publish-opt-out.test.mjs +++ b/scripts/__tests__/cargo-publish-opt-out.test.mjs @@ -4,8 +4,10 @@ import { describe, expect, it } from 'vitest' import { REPO_ROOT } from './lib/repo-root.mjs' /** - * Every crate in BOTH nested Cargo workspaces must opt out of crates.io unless - * it is deliberately allowlisted below. + * Every crate in every Cargo workspace must opt out of crates.io unless it is + * deliberately allowlisted below: the root workspace (the stack-* crates), + * EQL, protect-ffi, and the single-package workspaces the root excludes (the + * three cargo-fuzz crates and the two Go WASI guests). * * A crate with no `publish` key is publishable BY DEFAULT, and release-plz * publishes every workspace member that has not opted out. The convention is @@ -50,6 +52,24 @@ const WORKSPACES = [ publishable: new Set(), expects: 'crates/protect-ffi', }, + { + // The root workspace. The stack-* crates' release-plz step publishes + // exactly these two (the stack-kms, stack-encrypt, stack-encrypt-derive + // and stack-guest-abi names are unclaimed on crates.io, and publishing + // them is a separate decision). + root: '.', + publishable: new Set(['packages/stack-auth', 'packages/stack-profile']), + expects: 'packages/stack-auth', + }, + // Single-package workspaces: `[workspace]` with no members, so the package + // at the root is the one member. + ...[ + 'packages/stack-auth/fuzz', + 'packages/stack-kms/fuzz', + 'packages/stack-encrypt/fuzz', + 'languages/golang/stackencrypt/guest', + 'languages/golang/stackauth/guest', + ].map((root) => ({ root, publishable: new Set(), expects: '.' })), ] /** @@ -64,8 +84,11 @@ const WORKSPACES = [ */ function workspaceMembers(WORKSPACE) { const manifest = readFileSync(join(WORKSPACE, 'Cargo.toml'), 'utf8') - const block = /^members\s*=\s*\[([^\]]*)\]/m.exec(manifest)?.[1] ?? '' - return [...block.matchAll(/"([^"]+)"/g)] + const block = /^members\s*=\s*\[([^\]]*)\]/m.exec(manifest)?.[1] + // A `[workspace]` with no `members` whose manifest is also a `[package]`: + // cargo's single-package workspace, whose one member is the root itself. + if (block === undefined && /^\[package\]$/m.test(manifest)) return ['.'] + return [...(block ?? '').matchAll(/"([^"]+)"/g)] .flatMap(([, pattern]) => pattern.endsWith('/*') ? readdirSync(join(WORKSPACE, pattern.slice(0, -2)), { diff --git a/scripts/__tests__/workflow-mise-setup.test.mjs b/scripts/__tests__/workflow-mise-setup.test.mjs index 7e1629b5a..d19d330b0 100644 --- a/scripts/__tests__/workflow-mise-setup.test.mjs +++ b/scripts/__tests__/workflow-mise-setup.test.mjs @@ -332,14 +332,18 @@ describe('the mise setup step carries the inputs it depends on', () => { it('points every mise setup step at a directory that has a mise config', () => { // THE TRUST ERROR. mise reads config from the current directory and its - // PARENTS, and this repo has NO mise config at its root — the two that - // exist are `packages/eql/mise.toml` and `languages/typescript/packages/protect-ffi/mise.toml`. - // So an action running at the default working directory finds nothing to - // install and leaves the config untrusted, and the first `mise run` fails - // with "Config files … are not trusted", which reads as a broken toolchain - // rather than a wrong directory. In `release.yml` that file is also where - // the Rust toolchain comes from (`[tools] rust`), so the same input is what - // makes the step a cargo setup; there is deliberately no second one. + // PARENTS. The repo has three mise configs: the root `mise.toml` (the + // stack-* crates and the Go module), `packages/eql/mise.toml` and + // `languages/typescript/packages/protect-ffi/mise.toml`. A job pointed at + // the wrong one installs the wrong tools and leaves the config it needs + // untrusted, and the first `mise run` fails with "Config files … are not + // trusted", which reads as a broken toolchain rather than a wrong + // directory. So every step names its directory, even a root job + // (`working_directory: .`): with a root config, an unset directory would + // now find the ROOT config and quietly pass an EQL job that forgot + // `packages/eql`. In `release.yml` the named file is also where the Rust + // toolchain comes from (`[tools] rust`), so the same input is what makes + // the step a cargo setup; there is deliberately no second one. const offenders = MISE_STEPS.filter(({ inputs }) => { const dir = inputs?.working_directory if (typeof dir !== 'string' || dir.trim() === '') return true @@ -353,7 +357,7 @@ describe('the mise setup step carries the inputs it depends on', () => { expect( offenders, - 'These `jdx/mise-action` steps do not name a directory containing a mise config. There is no mise config at the repo root, so mise installs nothing and marks the config untrusted — the first `mise run` then fails with a TRUST error that looks like a toolchain problem.', + 'These `jdx/mise-action` steps do not name a directory containing a mise config. Set `working_directory:` explicitly — `.` for the root `mise.toml` (the stack-* crates and Go), `packages/eql` or `languages/typescript/packages/protect-ffi` for theirs. Without it mise reads whichever config the default directory reaches, installs the wrong tools, and leaves the one the job needs untrusted — the first `mise run` then fails with a TRUST error that looks like a toolchain problem.', ).toEqual([]) }) diff --git a/scripts/lint-typecheck-scope.mjs b/scripts/lint-typecheck-scope.mjs index 326d5a6c0..4137a62a1 100644 --- a/scripts/lint-typecheck-scope.mjs +++ b/scripts/lint-typecheck-scope.mjs @@ -31,12 +31,14 @@ const REPO_ROOT = resolve(import.meta.dirname, '..') // with argv[2..] for tests / ad-hoc checks (each arg is a package directory). // // NESTED roots are listed separately because the walk below is one level deep, -// matching how pnpm globs `languages/typescript/packages/*`. Three sets of +// matching how pnpm globs `languages/typescript/packages/*`. Five sets of // packages sit a level further down and need their own entry here for the same // reason they need one in `pnpm-workspace.yaml`: // // languages/typescript/packages/protect-ffi/platforms/* the six per-platform binary packages // packages/eql/packages/* @cipherstash/eql, from the EQL subtree +// languages/typescript/packages/auth/platforms/* the @cipherstash/auth platform packages +// languages/typescript/packages/profile/platforms/* the @cipherstash/profile platform packages // languages/typescript/packages/protect-ffi/* the live integration suite // // The last is spelled as its PARENT rather than as the member, because the walk @@ -55,6 +57,10 @@ const WORKSPACE_ROOTS = [ 'packages', 'languages/typescript/packages/protect-ffi', 'languages/typescript/packages/protect-ffi/platforms', + // The node bindings imported from cipherstash-suite. `auth` and `profile` + // themselves are one level down and found through the first root. + 'languages/typescript/packages/auth/platforms', + 'languages/typescript/packages/profile/platforms', 'packages/eql/packages', ] From 1c52c8f60a774fc427c75967fc704806a054e7c4 Mon Sep 17 00:00:00 2001 From: Lindsay Holmwood <lindsay@holmwood.id.au> Date: Fri, 2 Oct 2026 09:28:39 +1000 Subject: [PATCH 679/686] docs: describe the stack-* crates, bindings and Go module MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plan §7.8, the meta commit. - AGENTS.md: the layout says packages/ holds Rust crates only, with bullets for the six stack-* crates, the node bindings and languages/golang; "two Cargo workspaces" becomes three; a new "Working on the Rust crates" section carries the suite's Fuzzing, Miri and Mutation testing notes, repathed. The CI those notes mention arrives with the CI port. The crate paths are spelled out, because lint:package-paths reads a `packages/stack-*` glob as packages/stack. - SECURITY.md: rows for @cipherstash/auth and its six platform packages, and a paragraph for the stack-auth and stack-profile crates and the Go module. - docs/agents/issue-tracker.md: the imported paths are tracked here, not in cipherstash-suite. - CODEOWNERS: packages/stack-*, languages/golang and the three node binding folders. - CONTRIBUTING.md: the auth packages' freeze and future fixed group, and the crates.io line for stack-auth and stack-profile. No changeset: nothing published changes. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PaY5xYydZUWhv8Nex9Sw8a --- .github/CODEOWNERS | 9 ++++ AGENTS.md | 87 ++++++++++++++++++++++++++++++++++-- CONTRIBUTING.md | 12 +++++ SECURITY.md | 9 ++++ docs/agents/issue-tracker.md | 18 +++++--- 5 files changed, 126 insertions(+), 9 deletions(-) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 671089d21..5831a661e 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -13,3 +13,12 @@ /pnpm-lock.yaml @cipherstash/developers /.npmrc @cipherstash/developers /skills/stash-supply-chain-security/ @cipherstash/developers + +# The stack-* crates, their node bindings and the Go module, imported from +# cipherstash-suite. stack-auth, stack-profile and @cipherstash/auth publish to +# crates.io and npm. +/packages/stack-*/ @cipherstash/developers +/languages/golang/ @cipherstash/developers +/languages/typescript/packages/auth/ @cipherstash/developers +/languages/typescript/packages/profile/ @cipherstash/developers +/languages/typescript/packages/stack-auth-wasm/ @cipherstash/developers diff --git a/AGENTS.md b/AGENTS.md index 2887d6940..c65238d11 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -71,7 +71,7 @@ If these variables are missing, tests that require live encryption will fail or ## Repository Layout -Every npm package except EQL lives under `languages/typescript/`: packages in `languages/typescript/packages/`, example apps in `languages/typescript/examples/`. The root `packages/` holds EQL's subtree (and, later, Rust crates). The JavaScript root stays at the repository root: `package.json`, `pnpm-lock.yaml`, `pnpm-workspace.yaml`, `turbo.json`, `.changeset/`, `biome.json`, `tsconfig.json` and `vitest.shared.ts`. +Every npm package except EQL lives under `languages/typescript/`: packages in `languages/typescript/packages/`, example apps in `languages/typescript/examples/`. The root `packages/` holds Rust crates only: the stack-* crates and EQL's subtree. The Go module is `languages/golang/`. The repository root is the root of the Cargo workspace and of mise (`Cargo.toml`, `Cargo.lock`, `mise.toml`), and the JavaScript root stays there too: `package.json`, `pnpm-lock.yaml`, `pnpm-workspace.yaml`, `turbo.json`, `.changeset/`, `biome.json`, `tsconfig.json` and `vitest.shared.ts`. - `languages/typescript/packages/stack`: Main package (`@cipherstash/stack`) containing the encryption client and all integrations - Subpath exports: `@cipherstash/stack`, `@cipherstash/stack/identity`, `@cipherstash/stack/schema`, `@cipherstash/stack/eql/v3`, `@cipherstash/stack/v3`, `@cipherstash/stack/types`, `@cipherstash/stack/dynamodb`, `@cipherstash/stack/encryption`, `@cipherstash/stack/errors`, `@cipherstash/stack/adapter-kit`, `@cipherstash/stack/wasm-inline`, `@cipherstash/stack/diagnostics` (the Drizzle and Supabase integrations moved to their own packages — see below) @@ -90,6 +90,9 @@ Every npm package except EQL lives under `languages/typescript/`: packages in `l EQL issues in this repository, never in the historical `cipherstash/encrypt-query-language` repository. Old upstream issue and PR links are provenance only. +- `packages/stack-auth`, `packages/stack-profile`, `packages/stack-kms`, `packages/stack-encrypt`, `packages/stack-encrypt-derive`, `packages/stack-guest-abi`: The Rust crates imported from `cipherstash/cipherstash-suite` with their history — `stack-auth` and `stack-profile` (published to crates.io), and `stack-kms`, `stack-encrypt`, `stack-encrypt-derive` and `stack-guest-abi` (`publish = false`). They are the members of the **root Cargo workspace**, with the three node binding crates below. See "Working on the Rust crates". +- `languages/typescript/packages/auth`, `languages/typescript/packages/profile`, `languages/typescript/packages/stack-auth-wasm`: The node bindings of those crates. `@cipherstash/auth` (napi-rs v2) and its six `platforms/*` packages are published to npm, **frozen here** until publishing moves from the suite (see `FROZEN_PUBLISHERS` below). `@cipherstash/profile` and its platforms are private and never published; `@cipherstash/stack-auth-wasm` is private and builds the wasm that `@cipherstash/auth` ships. Their `build` and `test` scripts never invoke cargo; `build:native`, `build:debug` and `test:cargo` do. +- `languages/golang`: The Go module (`stackencrypt`, `stackauth`, `internal`), a wazero host with no cgo. Its two WASI guests (`*/guest`) are detached Cargo workspaces built by `mise run wasm:guest:build` and `mise run wasm:auth-guest:build`; the `.wasm` files they embed are gitignored. There is no Go release process yet. - `e2e/*`: Cross-package end-to-end tests (package managers, supply chain, Prisma example README) - `languages/typescript/examples/*`: Working apps (basic, prisma, supabase-worker) - `docs/plans/*`: Internal design plans. User-facing documentation lives at https://cipherstash.com/docs (not in this repo). @@ -97,8 +100,8 @@ Every npm package except EQL lives under `languages/typescript/`: packages in `l ## Working on protect-ffi -`languages/typescript/packages/protect-ffi` carries one of this repo's two Cargo workspaces (the -other is `packages/eql/crates`), and its scripts are split so a Rust toolchain +`languages/typescript/packages/protect-ffi` carries one of this repo's three Cargo workspaces (the +others are the root workspace, for the stack-* crates, and `packages/eql`), and its scripts are split so a Rust toolchain stays optional for everyone else. - **The default `test` and `build` never invoke cargo.** Root `pnpm test` runs @@ -616,6 +619,84 @@ monorepo, which is where the silent failures are. subtree's own `.changeset/` was deleted with the import; there is no second one to put them in by mistake. +## Working on the Rust crates + +The stack-* crates came from `cipherstash/cipherstash-suite`, which still owns +`cipherstash-client`, `cts-common`, `zerokms-protocol`, `recipher` and +`cllw-ore`. Here those come from crates.io, pinned exactly in the root +`Cargo.toml`. + +- **Three Cargo workspaces, not one.** The root workspace (the six stack-* + crates and the three node binding crates), protect-ffi's and EQL's. The root + `Cargo.toml` excludes the other two, and they pin their own vitaminc. The + three fuzz crates and the two Go guests are detached workspaces too, each + with its own `Cargo.lock`. +- **The toolchain is pinned in the root `mise.toml`:** Rust 1.94.1 (the + `stack-encrypt` `tests/ui` trybuild snapshots record its diagnostics), the + cargo tools, and Go 1.26. EQL's `mise.toml` overrides the Rust pin in its + folder. protect-ffi's does not pin Rust, so the root pin applies there. +- **Run the tests with nextest, under the test env:** `mise x --env test -- + cargo nextest run --workspace --all-features`. Doc examples are `mise run + test:doc`; rustdoc with warnings as errors is `mise run doc`. +- **The node bindings keep cargo off `pnpm test`.** Their `test` runs vitest + and Biome against a binding already built with `pnpm --filter + @cipherstash/auth run build:debug`; `test:cargo` runs the crate's tests. + +### Fuzzing + +Untrusted-input parsers are fuzzed with cargo-fuzz / libFuzzer. The fuzz +crates live in `packages/*/fuzz/` (detached workspaces) and run via `fuzz:*` +mise tasks (nightly toolchain), e.g.: + +```bash +mise run fuzz:access-key # 60s default +mise run fuzz:access-key -- -max_total_time=300 +mise run fuzz:access-key -- -runs=0 # replay seed corpus only (CI regression) +``` + +CI is split into a blocking per-PR regression replay and a nightly, +non-blocking bug-finding campaign; the workflow arrives with the CI port. +Full walkthrough — layout, adding a target, the CI split — in +[`docs/fuzzing.md`](docs/fuzzing.md). For cargo-fuzz mechanics (sanitizers, +corpus, crash triage) use the Trail of Bits `cargo-fuzz` skill rather than a +repo-local one. + +### Miri + +The guest ABI crate (`packages/stack-guest-abi`) is the one place under +`languages/golang` that hands raw pointers to the Go host and rebuilds owned +buffers from them. Its unit tests run under Miri with strict provenance: + +```bash +mise run miri:stack-guest-abi # nightly + the miri component +``` + +Only the native half (the buffer registry, headers, status table) is +interpretable; the wasm32-only `abi` and `transport` modules read the wasm +`memory_size` intrinsic, so their hostile-input behaviour is pinned from the +Go side (`mise run go:test`). + +### Mutation testing + +The stack crates that opt in (stack-auth, stack-encrypt) are mutation-tested +with cargo-mutants; config in `.cargo/mutants.toml`. Once the CI port lands, +CI gates every PR touching them with `--in-diff`: only the lines the PR changes are mutated, and +a surviving mutant fails the job. A full sweep is slow and is run locally: + +```bash +mise run mutants:stack-auth # ~15 min +mise run mutants:stack-encrypt # ~60 min +mise run mutants # every crate with a mutants:<crate> task +``` + +A non-equivalent surviving mutant means the test suite does not distinguish +the changed behavior; the fix is a test that fails under that mutation, not +an exclusion. Exclude only what the configured test build cannot reach +(proc-macro entry points, wasm32-only modules), or a demonstrably equivalent +replacement such as `Some(Default::default())` for `Some(())`. Document the +reason and match the specific replacement so a reachable, behavior-changing +mutation in the same function stays covered. + ## Agent Skills — these ship to customers `skills/*/SKILL.md` are **published artifacts, not internal notes.** Treat a wrong diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8297ecabe..b10dc617c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -180,6 +180,18 @@ The `stash` / `@cipherstash/stack` / `@cipherstash/stack-drizzle` / packages are a `fixed` group in [`.changeset/config.json`](./.changeset/config.json): they always version together, so a bump to any one of them bumps all six. +`@cipherstash/auth` and its six `@cipherstash/auth-*` platform packages are +developed here but still published from `cipherstash/cipherstash-suite`. Until +publishing moves here, do not add a changeset for them: `pnpm run +lint:auth-changeset` fails on one, and `release:gate` blocks any version npm +does not have. Once publishing moves, the seven release together as their own +`fixed` group. + +Two Rust crates, `stack-auth` and `stack-profile`, are released to crates.io, +in one version group of their own, by release-plz from the root Cargo +workspace — not by Changesets. Until that pipeline is armed they, too, keep +releasing from the suite. + ## Pre-release process The 1.0 line published its `1.0.0-rc.*` series through diff --git a/SECURITY.md b/SECURITY.md index 21e0a4fc3..87bfec41c 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -19,6 +19,8 @@ This repository is the CipherStash Stack monorepo for JavaScript/TypeScript. It | `@cipherstash/wizard` | AI-powered encryption setup | | `@cipherstash/protect-ffi` | Native FFI bindings to the CipherStash Client SDK — the Rust core `@cipherstash/stack` encrypts and decrypts through | | `@cipherstash/protect-ffi-darwin-arm64`<br>`@cipherstash/protect-ffi-darwin-x64`<br>`@cipherstash/protect-ffi-linux-arm64-gnu`<br>`@cipherstash/protect-ffi-linux-x64-gnu`<br>`@cipherstash/protect-ffi-linux-x64-musl`<br>`@cipherstash/protect-ffi-win32-x64-msvc` | Prebuilt per-platform binaries for `@cipherstash/protect-ffi`. Installed as optional dependencies; one is selected at load time for the host platform | +| `@cipherstash/auth` | Authentication strategies for CipherStash (napi-rs native binding, with a WASM build for edge runtimes) — imported from `cipherstash/cipherstash-suite` with the `stack-auth` crate it wraps | +| `@cipherstash/auth-darwin-arm64`<br>`@cipherstash/auth-darwin-x64`<br>`@cipherstash/auth-linux-arm64-gnu`<br>`@cipherstash/auth-linux-x64-gnu`<br>`@cipherstash/auth-linux-x64-musl`<br>`@cipherstash/auth-win32-x64-msvc` | Prebuilt per-platform binaries for `@cipherstash/auth`. Installed as optional peer dependencies; one is selected at load time for the host platform | | `@cipherstash/eql` | Encrypt Query Language — the PostgreSQL SQL bundle (`eql_v3` schema: domains, operators, index-term extractors) that stores and queries encrypted payloads, plus its generated TypeScript types. Applied by `stash eql install` and by the Prisma Next adapter's migrations. Released in lockstep with the `eql-bindings` Rust crate, which emits the payloads this SQL reads | This repository also carries the source of the **`eql-bindings`** Rust crate @@ -26,6 +28,13 @@ This repository also carries the source of the **`eql-bindings`** Rust crate lockstep with `@cipherstash/eql`. It is in scope for security reports on the same terms as the npm packages above. +It also carries the source of two Rust crates published to crates.io, +**`stack-auth`** and **`stack-profile`** (`packages/stack-auth`, +`packages/stack-profile`), and of the **Go module** at `languages/golang` +(`stackencrypt` and `stackauth`, over WASI guests built from the stack-* +crates), which has no release yet. All three are in scope for security reports +on the same terms as the npm packages above. + > **Note on publishing.** `@cipherstash/auth` and its six platform packages, > and the `stack-auth` and `stack-profile` crates, are developed here but are > *published* from `cipherstash/cipherstash-suite` until the arming PR of the diff --git a/docs/agents/issue-tracker.md b/docs/agents/issue-tracker.md index 4bd5ecdae..4705bb4b4 100644 --- a/docs/agents/issue-tracker.md +++ b/docs/agents/issue-tracker.md @@ -6,12 +6,18 @@ operations. ## Repository ownership All work present in this monorepo is tracked in `cipherstash/stack`, including -the absorbed EQL source under `packages/eql` and protect-ffi under -`languages/typescript/packages/protect-ffi`. Their former upstream repositories are historical -sources, not active issue trackers. Never create, move, or update an issue in -`cipherstash/encrypt-query-language` or `cipherstash/protectjs-ffi` for work in -this tree. Create it in `cipherstash/stack` and link historical upstream issues -only as provenance. +the absorbed EQL source under `packages/eql`, protect-ffi under +`languages/typescript/packages/protect-ffi`, and the stack-* crates, node bindings and Go +module imported from `cipherstash/cipherstash-suite` (the stack-* crates under `packages/`, +`languages/typescript/packages/auth`, `languages/typescript/packages/profile`, +`languages/typescript/packages/stack-auth-wasm` and `languages/golang`). The +former upstream repositories of EQL and protect-ffi are historical sources, not +active issue trackers. `cipherstash/cipherstash-suite` is still active for the +crates that stayed there, but not for anything in this tree. Never create, +move, or update an issue in `cipherstash/encrypt-query-language`, +`cipherstash/protectjs-ffi` or `cipherstash/cipherstash-suite` for work in this +tree. Create it in `cipherstash/stack` and link historical upstream issues only +as provenance. ## Conventions From f7b003197fedd68fadf8949e9b95b7509f6dee27 Mon Sep 17 00:00:00 2001 From: Lindsay Holmwood <lindsay@holmwood.id.au> Date: Fri, 2 Oct 2026 10:32:35 +1000 Subject: [PATCH 680/686] build(mise): move the cargo tools into mise.test.toml, pinned mise merges config down the directory tree, so every tool in the root mise.toml is also part of the toolset in packages/eql and in languages/typescript/packages/protect-ffi. Their CI jobs run `mise install` (and `mise run`) in those folders, so each one now built cargo-nextest, cargo-llvm-cov, cargo-crap, cargo-mutants, cargo-fuzz and cargo-udeps from source, none of which they use. mise's cargo backend compiles a `cargo:` tool with the runner's default rustc (1.92 on the Ubuntu images), not the root's pinned 1.94.1. So a `latest` that raises its minimum Rust fails the install: cargo-udeps 0.1.61 needs cargo@0.96, which needs rustc 1.93. That broke the protect-ffi integration suite on #1001 and the new udeps job on #1003. The cargo tools move to mise.test.toml, which mise loads only for the `test` environment (`mise x --env test`, or MISE_ENV=test). Nested workspaces no longer inherit them. Each is pinned to an exact version that builds on the runners' rustc; cargo-udeps goes back to 0.1.60. The tasks that call them (crap:*, mutants:*, fuzz:*, and nextest in wasm:guest:test and wasm:auth-guest:test) now run them through `mise x --env test --`, as test:doc and the crap coverage step already did, so `mise run <task>` keeps working locally without MISE_ENV. Rust, Go, golangci-lint and wasm-pack stay in the root mise.toml. They are prebuilt downloads, and plan section 3.5 has protect-ffi inherit the Rust pin. That section's intent holds: the repository pins one version of each cargo tool, reached through mise. The pins now sit in the test environment rather than the default one. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PaY5xYydZUWhv8Nex9Sw8a --- AGENTS.md | 11 ++++++++--- docs/fuzzing.md | 3 ++- mise.test.toml | 33 ++++++++++++++++++++++++++----- mise.toml | 27 ++++++++++--------------- packages/stack-auth/tasks.toml | 8 ++++---- packages/stack-encrypt/tasks.toml | 10 +++++----- packages/stack-kms/tasks.toml | 2 +- 7 files changed, 58 insertions(+), 36 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index c65238d11..0999284fc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -632,9 +632,14 @@ The stack-* crates came from `cipherstash/cipherstash-suite`, which still owns three fuzz crates and the two Go guests are detached workspaces too, each with its own `Cargo.lock`. - **The toolchain is pinned in the root `mise.toml`:** Rust 1.94.1 (the - `stack-encrypt` `tests/ui` trybuild snapshots record its diagnostics), the - cargo tools, and Go 1.26. EQL's `mise.toml` overrides the Rust pin in its - folder. protect-ffi's does not pin Rust, so the root pin applies there. + `stack-encrypt` `tests/ui` trybuild snapshots record its diagnostics) and + Go 1.26. EQL's `mise.toml` overrides the Rust pin in its folder. + protect-ffi's does not pin Rust, so the root pin applies there. +- **The cargo tools are pinned in `mise.test.toml`, not `mise.toml`:** + nextest, llvm-cov, crap, mutants, fuzz and udeps. EQL and protect-ffi + inherit the root `mise.toml`, so a cargo tool there would be built in their + jobs too. Reach them with `mise x --env test -- …`, as the tasks do; CI jobs + that use them set `MISE_ENV: test`. - **Run the tests with nextest, under the test env:** `mise x --env test -- cargo nextest run --workspace --all-features`. Doc examples are `mise run test:doc`; rustdoc with warnings as errors is `mise run doc`. diff --git a/docs/fuzzing.md b/docs/fuzzing.md index 8ed518626..448550122 100644 --- a/docs/fuzzing.md +++ b/docs/fuzzing.md @@ -75,7 +75,8 @@ the scheduled campaign grows the corpus from there. ## Running locally cargo-fuzz needs the **nightly** toolchain and the `cargo-fuzz` binary; -`mise` provides the latter (`cargo:cargo-fuzz` in `mise.toml`). Install +`mise` provides the latter (`cargo:cargo-fuzz` in `mise.test.toml`, +which the `fuzz:*` tasks load with `mise x --env test`). Install nightly once with `rustup toolchain install nightly`. Run a target via its `mise` task (60s by default): diff --git a/mise.test.toml b/mise.test.toml index ade6c7b9b..57e4acb9a 100644 --- a/mise.test.toml +++ b/mise.test.toml @@ -1,9 +1,32 @@ -# The `test` environment, for `mise x --env test -- …` in the root and -# stack-* tasks (test:doc, crap:*). Carried from cipherstash-suite, keeping -# only what the stack crates read; the suite's CTS, ZeroKMS and database -# settings serve services this repository does not run. +# The `test` environment: `mise x --env test -- …` in the root and stack-* +# tasks, and MISE_ENV=test in the CI jobs that run them. Carried from +# cipherstash-suite, keeping only what the stack crates read; the suite's CTS, +# ZeroKMS and database settings serve services this repository does not run. +# +# The cargo tools live here rather than in mise.toml so that packages/eql and +# protect-ffi, which inherit the root mise.toml, do not install them (see the +# note in mise.toml). Each is pinned: mise compiles a `cargo:` tool with the +# runner's default rustc, not the root's 1.94.1, so a `latest` that raises its +# minimum Rust fails the install. cargo-udeps 0.1.61 did exactly that +# (cargo@0.96 needs rustc 1.93; the Ubuntu runners ship 1.92). Bump a pin +# deliberately, after checking it still builds with the runners' rustc. [tools] -"cargo:cargo-nextest" = "latest" +"cargo:cargo-nextest" = "0.9.146" +# Coverage → CRAP (Change Risk Anti-Patterns) metric tooling. `cargo llvm-cov` +# produces an LCOV file that `cargo crap` scores; see `mise run crap:stack-auth`. +# cargo-llvm-cov needs the root rust's `llvm-tools-preview` component. +"cargo:cargo-llvm-cov" = "0.9.1" +"cargo:cargo-crap" = "0.6.1" +# Mutation testing for the stack crates; config in .cargo/mutants.toml, run +# via `mutants:<crate>`. +"cargo:cargo-mutants" = "27.1.0" +# Fuzzing (libFuzzer) for the pure-`&str` parsers. The `cargo-fuzz` binary +# installs on stable, but running a target needs the nightly toolchain +# (`cargo +nightly fuzz run …`); see the `fuzz:*` tasks. +"cargo:cargo-fuzz" = "0.13.2" +# Runs on nightly-2026-07-10: `cargo +nightly-2026-07-10 udeps`. 0.1.60, not +# 0.1.61: see above. +"cargo:cargo-udeps" = "0.1.60" [env] # Annoyingly if you pass --env test to mise it doesn't set this automatically diff --git a/mise.toml b/mise.toml index e2cacc11d..48f8c98bb 100644 --- a/mise.toml +++ b/mise.toml @@ -20,20 +20,13 @@ includes = [ # this compiler's diagnostics. `llvm-tools-preview` is required by # cargo-llvm-cov to produce coverage data. rust = { version = "1.94.1", components = "rustc,cargo,rustfmt,rust-std,clippy,rust-docs,llvm-tools-preview", targets = "wasm32-unknown-unknown,wasm32-wasip1" } -"cargo:cargo-nextest" = "latest" -# Coverage → CRAP (Change Risk Anti-Patterns) metric tooling. `cargo llvm-cov` -# produces an LCOV file that `cargo crap` scores; see `mise run crap:stack-auth`. -"cargo:cargo-llvm-cov" = "latest" -"cargo:cargo-crap" = "latest" -# Mutation testing for the stack crates; config in .cargo/mutants.toml, run -# via `mutants:<crate>`. -"cargo:cargo-mutants" = "latest" -# Fuzzing (libFuzzer) for the pure-`&str` parsers. The `cargo-fuzz` binary -# installs on stable, but running a target needs the nightly toolchain -# (`cargo +nightly fuzz run …`); see the `fuzz:*` tasks. -"cargo:cargo-fuzz" = "latest" -# Runs on nightly-2026-07-10: `cargo +nightly-2026-07-10 udeps`. -"cargo:cargo-udeps" = "latest" +# No `cargo:` tools here. mise merges config down the tree, so every tool in +# this file is also installed for packages/eql and protect-ffi, whose CI runs +# `mise install` and `mise run` in their own folders. mise compiles a `cargo:` +# tool with the runner's default rustc rather than the pinned one, so one +# `latest` that outgrows that compiler breaks jobs that never use the tool. +# The cargo tools (nextest, llvm-cov, crap, mutants, fuzz, udeps) live in +# mise.test.toml, pinned, and load only with `--env test` / MISE_ENV=test. # The Go module (languages/golang) — the wazero host of the WASI guests. # CGO_ENABLED=0 throughout; see `go:test`. go = "1.26" @@ -211,8 +204,8 @@ cargo clippy --target wasm32-wasip1 -- -D warnings # and exits 0, so without -D warnings a stale link ships silently. RUSTDOCFLAGS="-D warnings" cargo doc --no-deps --target wasm32-wasip1 # nextest, as everywhere else; this crate is a detached workspace, so it -# runs from here rather than a root `-p`. -cargo nextest run +# runs from here rather than a root `-p`. nextest is in mise.test.toml. +mise x --env test -- cargo nextest run """ [tasks."wasm:auth-guest:build"] @@ -255,7 +248,7 @@ cargo fmt --check cargo clippy --all-targets -- -D warnings cargo clippy --target wasm32-wasip1 -- -D warnings RUSTDOCFLAGS="-D warnings" cargo doc --no-deps --target wasm32-wasip1 -cargo nextest run +mise x --env test -- cargo nextest run """ [tasks."go:test"] diff --git a/packages/stack-auth/tasks.toml b/packages/stack-auth/tasks.toml index 31a2a6b40..44774dc25 100644 --- a/packages/stack-auth/tasks.toml +++ b/packages/stack-auth/tasks.toml @@ -37,7 +37,7 @@ run = [ # NOTE: this must stay the LAST command — the crap-stack-auth.yml CI workflow runs # this task and relies on mise appending its trailing args (e.g. --format github) # to this `cargo crap` invocation. - "cargo crap --path packages/stack-auth --lcov {{config_root}}/target/stack-auth-lcov.info --exclude 'node/**' --exclude 'wasm/**' --exclude 'examples/**' --exclude '**/tests.rs' --exclude '**/tests/**' --fail-above", + "mise x --env test -- cargo crap --path packages/stack-auth --lcov {{config_root}}/target/stack-auth-lcov.info --exclude 'node/**' --exclude 'wasm/**' --exclude 'examples/**' --exclude '**/tests.rs' --exclude '**/tests/**' --fail-above", ] # Fuzz stack-auth's public AccessKey string parser (libFuzzer via cargo-fuzz). @@ -51,7 +51,7 @@ run = [ ["fuzz:access-key"] description = "Fuzz stack-auth's AccessKey string parser (libFuzzer, nightly, 60s default)" dir = "{{config_root}}/packages/stack-auth" -run = "cargo +nightly fuzz run access_key_parse --sanitizer none --target $(rustc -vV | sed -n 's/^host: //p') -- -max_total_time=60" +run = "mise x --env test -- cargo +nightly fuzz run access_key_parse --sanitizer none --target $(rustc -vV | sed -n 's/^host: //p') -- -max_total_time=60" # Fuzz the JWT claims decode path (Token::fuzz_decode_claims, behind the `fuzz` # feature). On native this is the jsonwebtoken-based decode with signature @@ -59,7 +59,7 @@ run = "cargo +nightly fuzz run access_key_parse --sanitizer none --target $(rust ["fuzz:jwt-decode"] description = "Fuzz stack-auth's JWT claims decode path (libFuzzer, nightly, 60s default)" dir = "{{config_root}}/packages/stack-auth" -run = "cargo +nightly fuzz run jwt_decode --sanitizer none --target $(rustc -vV | sed -n 's/^host: //p') -- -max_total_time=60" +run = "mise x --env test -- cargo +nightly fuzz run jwt_decode --sanitizer none --target $(rustc -vV | sed -n 's/^host: //p') -- -max_total_time=60" # Mutation testing (cargo-mutants) over the whole crate: what the per-PR # `--in-diff` gate (.github/workflows/mutants.yml) does for changed lines only. @@ -68,4 +68,4 @@ run = "cargo +nightly fuzz run jwt_decode --sanitizer none --target $(rustc -vV # stress tests are filtered out of the per-mutant run by the shared config. ["mutants:stack-auth"] description = "Full mutation-testing sweep of stack-auth (cargo-mutants; ~15 min)" -run = "cargo mutants -p stack-auth --jobs 4" +run = "mise x --env test -- cargo mutants -p stack-auth --jobs 4" diff --git a/packages/stack-encrypt/tasks.toml b/packages/stack-encrypt/tasks.toml index f912fde7b..872417224 100644 --- a/packages/stack-encrypt/tasks.toml +++ b/packages/stack-encrypt/tasks.toml @@ -16,7 +16,7 @@ run = [ # NOTE: this must stay the LAST command — the crap-stack-encrypt.yml CI # workflow runs this task and relies on mise appending its trailing args # (e.g. --format github) to this `cargo crap` invocation. - "cargo crap --path packages/stack-encrypt --lcov {{config_root}}/target/stack-encrypt-lcov.info --exclude 'examples/**' --exclude 'fuzz/**' --exclude '**/tests/**' --fail-above", + "mise x --env test -- cargo crap --path packages/stack-encrypt --lcov {{config_root}}/target/stack-encrypt-lcov.info --exclude 'examples/**' --exclude 'fuzz/**' --exclude '**/tests/**' --fail-above", ] # Fuzz the frozen v1 leaf byte decoder (`SealedValue::from_bytes`, also @@ -30,7 +30,7 @@ run = [ ["fuzz:sealed-value"] description = "Fuzz stack-encrypt's frozen leaf byte decoder, SealedValue::from_bytes (libFuzzer, nightly, 60s default)" dir = "{{config_root}}/packages/stack-encrypt" -run = "cargo +nightly fuzz run sealed_value_decode --sanitizer none --target $(rustc -vV | sed -n 's/^host: //p') -- -max_total_time=60" +run = "mise x --env test -- cargo +nightly fuzz run sealed_value_decode --sanitizer none --target $(rustc -vV | sed -n 's/^host: //p') -- -max_total_time=60" # Fuzz the SEM index-term byte decoders — every `from_bytes` / `TryFrom<&[u8]>` # a stored term comes back through (equality, match, ORE and OPE). One target @@ -39,7 +39,7 @@ run = "cargo +nightly fuzz run sealed_value_decode --sanitizer none --target $(r ["fuzz:term-decode"] description = "Fuzz stack-encrypt's SEM index-term byte decoders (libFuzzer, nightly, 60s default)" dir = "{{config_root}}/packages/stack-encrypt" -run = "cargo +nightly fuzz run term_decode --sanitizer none --target $(rustc -vV | sed -n 's/^host: //p') -- -max_total_time=60" +run = "mise x --env test -- cargo +nightly fuzz run term_decode --sanitizer none --target $(rustc -vV | sed -n 's/^host: //p') -- -max_total_time=60" # Fuzz the stored-record preflight (`dynamic::record::check_record`) with # structure-aware inputs: an `Arbitrary`-derived mirror of a plan value and @@ -49,7 +49,7 @@ run = "cargo +nightly fuzz run term_decode --sanitizer none --target $(rustc -vV ["fuzz:check-record"] description = "Fuzz stack-encrypt's stored-record preflight against a model of its rules (structure-aware, libFuzzer, nightly, 60s default)" dir = "{{config_root}}/packages/stack-encrypt" -run = "cargo +nightly fuzz run check_record --sanitizer none --target $(rustc -vV | sed -n 's/^host: //p') -- -max_total_time=60" +run = "mise x --env test -- cargo +nightly fuzz run check_record --sanitizer none --target $(rustc -vV | sed -n 's/^host: //p') -- -max_total_time=60" # Rustdoc with warnings as errors: a broken intra-doc link or a rustdoc # warning fails the build. Doc *examples* are `test:doc:stack-encrypt`. Both run @@ -71,4 +71,4 @@ run = "mise x --env test -- cargo test -p stack-encrypt --doc --all-features" # trybuild UI suite is filtered out of the per-mutant run by the shared config. ["mutants:stack-encrypt"] description = "Full mutation-testing sweep of stack-encrypt (cargo-mutants; ~60 min)" -run = "cargo mutants -p stack-encrypt --jobs 4" +run = "mise x --env test -- cargo mutants -p stack-encrypt --jobs 4" diff --git a/packages/stack-kms/tasks.toml b/packages/stack-kms/tasks.toml index 664381285..2ea3346fc 100644 --- a/packages/stack-kms/tasks.toml +++ b/packages/stack-kms/tasks.toml @@ -11,7 +11,7 @@ ["fuzz:client-key"] description = "Fuzz stack-kms's client-key material decoder (libFuzzer, nightly, 60s default)" dir = "{{config_root}}/packages/stack-kms" -run = "cargo +nightly fuzz run client_key_encoded --sanitizer none --target $(rustc -vV | sed -n 's/^host: //p') -- -max_total_time=60" +run = "mise x --env test -- cargo +nightly fuzz run client_key_encoded --sanitizer none --target $(rustc -vV | sed -n 's/^host: //p') -- -max_total_time=60" # Rustdoc with warnings as errors: a broken intra-doc link or a rustdoc # warning fails the build. Doc *examples* are `test:doc:stack-kms`. Both run From 8e8dd7abe2cf3ba780e256a13d5cef293f53659d Mon Sep 17 00:00:00 2001 From: Lindsay Holmwood <lindsay@holmwood.id.au> Date: Fri, 2 Oct 2026 09:49:39 +1000 Subject: [PATCH 681/686] fix(profile): write the napi typings to native.d.ts @cipherstash/profile's build:debug and build:native ran `napi build` with no `--dts`, so napi wrote its generated typings over the hand-written index.d.ts. Every job that builds the binding (tests.yml run-tests and tests-crates.yml, from the CI port) would leave the tree dirty and the package's types wrong. Both now pass `--dts native.d.ts`, as @cipherstash/auth does. The generated file is committed so a drift guard can diff it, and Biome leaves it alone like auth's. index.d.ts does not import it, and the package is private, so nothing it ships changes. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PaY5xYydZUWhv8Nex9Sw8a --- biome.json | 1 + .../typescript/packages/profile/native.d.ts | 36 +++++++++++++++++++ .../typescript/packages/profile/package.json | 4 +-- 3 files changed, 39 insertions(+), 2 deletions(-) create mode 100644 languages/typescript/packages/profile/native.d.ts diff --git a/biome.json b/biome.json index fa3653219..a70d1857d 100644 --- a/biome.json +++ b/biome.json @@ -35,6 +35,7 @@ "!languages/typescript/packages/auth/README.md", "!languages/typescript/packages/auth/LICENSE", "!languages/typescript/packages/profile/index.d.ts", + "!languages/typescript/packages/profile/native.d.ts", "!languages/golang", "!**/*.grit", "!**/*.generated.ts", diff --git a/languages/typescript/packages/profile/native.d.ts b/languages/typescript/packages/profile/native.d.ts new file mode 100644 index 000000000..612a19965 --- /dev/null +++ b/languages/typescript/packages/profile/native.d.ts @@ -0,0 +1,36 @@ +/* tslint:disable */ +/* eslint-disable */ + +/* auto-generated by NAPI-RS */ + +export declare class ProfileStore { + /** + * Create a profile store at the default location (`~/.cipherstash`), + * or the path specified by the `CS_CONFIG_PATH` environment variable. + */ + static resolve(): ProfileStore + /** Create a profile store rooted at the given directory. */ + static withDir(dir: string): ProfileStore + /** The directory path of this profile store. */ + get dir(): string + /** Set the current workspace. */ + setCurrentWorkspace(workspaceId: string): void + /** + * Return the current workspace ID. + * + * Throws if no workspace has been set. + */ + currentWorkspace(): string + /** Remove the current workspace selection. */ + clearCurrentWorkspace(): void + /** List workspace IDs that have profile data on disk. */ + listWorkspaces(): Array<string> + /** Return a profile store scoped to a specific workspace directory. */ + workspaceStore(workspaceId: string): ProfileStore + /** + * Return a profile store scoped to the current workspace. + * + * Throws if no workspace has been set. + */ + currentWorkspaceStore(): ProfileStore +} diff --git a/languages/typescript/packages/profile/package.json b/languages/typescript/packages/profile/package.json index 31e29991e..671f2a920 100644 --- a/languages/typescript/packages/profile/package.json +++ b/languages/typescript/packages/profile/package.json @@ -25,8 +25,8 @@ "stack-profile-node.js" ], "scripts": { - "build:native": "napi build --release", - "build:debug": "napi build", + "build:native": "napi build --release --dts native.d.ts", + "build:debug": "napi build --dts native.d.ts", "test": "vitest run && biome check .", "test:cargo": "cargo test --locked" }, From 0dc76af1bf83a12775b3c7011b9ac7154ea4ee73 Mon Sep 17 00:00:00 2001 From: Lindsay Holmwood <lindsay@holmwood.id.au> Date: Fri, 2 Oct 2026 10:34:42 +1000 Subject: [PATCH 682/686] ci(tests): build the auth and profile bindings before the test run `pnpm run test` now reaches the @cipherstash/auth and @cipherstash/profile vitest suites, which load the napi module. Their `test` scripts do not build it, and nothing else in run-tests does, so profile-store.test.ts failed with "Failed to load native binding for linux-x64" on this PR (Run Tests, Node 22 and 24). run-tests now builds both bindings with `build:debug` after the protect-ffi binding, before the test steps. The previous commit has `build:debug` write its typings to the committed native.d.ts, so the build leaves the tree clean. This step and that fix came from the CI port (#1003); they land here because this PR merges first and has to pass on its own. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PaY5xYydZUWhv8Nex9Sw8a --- .github/workflows/tests.yml | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index aa5c01711..90bbffe63 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -136,6 +136,15 @@ jobs: - name: Build the protect-ffi binding uses: ./.github/actions/build-ffi-binding + # `pnpm run test` below also runs the @cipherstash/auth and + # @cipherstash/profile vitest suites, which load the napi module. Their + # `test` scripts do not build it, so cargo stays off the default `test` + # path; this step builds it, as the protect-ffi step above does for + # `index.node`. Each `build:debug` writes its typings to the committed + # `native.d.ts`, so the build leaves the tree clean. + - name: Build the auth and profile node bindings + run: pnpm --filter @cipherstash/auth --filter @cipherstash/profile run build:debug + - name: Type tests (stack) run: pnpm exec turbo run test:types --filter @cipherstash/stack From 5147dd0065d5d9bf91b1ae96a7ba7b7e26a61dbb Mon Sep 17 00:00:00 2001 From: Lindsay Holmwood <lindsay@holmwood.id.au> Date: Fri, 2 Oct 2026 15:31:11 +1000 Subject: [PATCH 683/686] build: give cts-common and zerokms-protocol caret requirements The root [workspace.dependencies] pinned both exactly, and stack-auth inherits the requirement when it is packaged, so stack-auth 0.43.0 would require cts-common =0.43.0 and zerokms-protocol =0.12.31. The suite then could not unify registry stack-auth with a later compatible cts-common patch: its [patch.crates-io] entry would go unused, and a second cts-common would break the Crn, Region and WorkspaceId types that stack-auth exposes. That defeats the cts-common release decision in plan section 13.7. Use caret requirements for the two crates. Cargo.lock still holds the exact versions, so this changes no resolved version: every lock passes cargo metadata --locked unchanged. recipher and cllw-ore stay exact, because only unpublished crates use them. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PaY5xYydZUWhv8Nex9Sw8a --- Cargo.toml | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index e2324061d..78d1e14e3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -71,10 +71,14 @@ incremental = true stack-auth = { path = "./packages/stack-auth", version = "0.42.3", default-features = false } stack-profile = { path = "./packages/stack-profile", version = "0.42.3" } -# Suite crates, from crates.io. Pinned exactly: they release from -# cipherstash-suite, and stack-auth's API carries their types. -cts-common = { version = "=0.43.0", default-features = false } -zerokms-protocol = "=0.12.31" +# Suite crates, from crates.io; Cargo.lock holds the exact versions. +# cts-common and zerokms-protocol take caret requirements because the +# published stack-auth inherits them: an exact pin would stop the suite +# unifying stack-auth with a later compatible cts-common, and a second copy +# breaks the shared Crn, Region and WorkspaceId types. recipher and cllw-ore +# feed only unpublished crates, so they stay exact. +cts-common = { version = "0.43.0", default-features = false } +zerokms-protocol = "0.12.31" recipher = "=0.3.1" cllw-ore = { version = "=0.5.0", default-features = false } From a981590ada0cf76f101d5e2e3cc9bfee1403b631 Mon Sep 17 00:00:00 2001 From: Lindsay Holmwood <lindsay@holmwood.id.au> Date: Fri, 2 Oct 2026 10:32:35 +1000 Subject: [PATCH 684/686] build(go): name the Go module by its stack path Rename the module from github.com/cipherstash/cipherstash-suite/bindings/go to github.com/cipherstash/stack/languages/golang, as plan section 3.4 decides. The old path names a private repository whose bindings/go folder the suite removal deletes, so a go get of it could never resolve. Nothing outside the module imports the old path, and no version of it was ever tagged or fetchable through the Go proxy. The change is generated by stack-migration/go-rename.sh (go mod edit -module, plus a text replace in the module's .go and .md files): 36 files, 53 lines. go.sum is unchanged, and go vet, go build and go test pass under the new path. At the freeze, re-run the script rather than replaying this commit, because the cutover re-export can bring the old path back in new files. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PaY5xYydZUWhv8Nex9Sw8a --- languages/golang/go.mod | 2 +- languages/golang/internal/factstest/factstest.go | 2 +- languages/golang/internal/factstest/factstest_test.go | 4 ++-- languages/golang/internal/guest/memory_linux_test.go | 4 ++-- languages/golang/internal/guest/memory_test.go | 4 ++-- languages/golang/internal/guesttest/probe.go | 2 +- languages/golang/internal/guesttest/smaps_linux.go | 2 +- languages/golang/stackauth/README.md | 4 ++-- languages/golang/stackauth/clientkey.go | 2 +- languages/golang/stackauth/errors.go | 2 +- languages/golang/stackauth/guest.go | 2 +- languages/golang/stackauth/store.go | 2 +- languages/golang/stackauth/store_test.go | 2 +- languages/golang/stackauth/strategy.go | 2 +- languages/golang/stackauth/strategy_test.go | 2 +- languages/golang/stackauth/token.go | 2 +- languages/golang/stackauth/transport.go | 2 +- languages/golang/stackencrypt/README.md | 8 ++++---- languages/golang/stackencrypt/client.go | 2 +- languages/golang/stackencrypt/clientkey.go | 2 +- languages/golang/stackencrypt/credentials.go | 2 +- languages/golang/stackencrypt/credentials_test.go | 4 ++-- languages/golang/stackencrypt/errors.go | 2 +- languages/golang/stackencrypt/example/explicit/main.go | 4 ++-- languages/golang/stackencrypt/example/main.go | 2 +- languages/golang/stackencrypt/guest.go | 2 +- languages/golang/stackencrypt/guest_test.go | 4 ++-- languages/golang/stackencrypt/live_test.go | 2 +- languages/golang/stackencrypt/memory_linux_test.go | 2 +- languages/golang/stackencrypt/memory_test.go | 6 +++--- languages/golang/stackencrypt/options_test.go | 4 ++-- languages/golang/stackencrypt/plan/message.go | 2 +- languages/golang/stackencrypt/plan/plan_test.go | 6 +++--- languages/golang/stackencrypt/plan/policy.go | 2 +- languages/golang/stackencrypt/policy_plan_test.go | 6 +++--- languages/golang/stackencrypt/unit_test.go | 2 +- 36 files changed, 53 insertions(+), 53 deletions(-) diff --git a/languages/golang/go.mod b/languages/golang/go.mod index 040bde41d..bbb768fa2 100644 --- a/languages/golang/go.mod +++ b/languages/golang/go.mod @@ -1,4 +1,4 @@ -module github.com/cipherstash/cipherstash-suite/bindings/go +module github.com/cipherstash/stack/languages/golang go 1.25.0 diff --git a/languages/golang/internal/factstest/factstest.go b/languages/golang/internal/factstest/factstest.go index 7453d4753..38c26e46e 100644 --- a/languages/golang/internal/factstest/factstest.go +++ b/languages/golang/internal/factstest/factstest.go @@ -12,7 +12,7 @@ import ( "strings" "unicode" - "github.com/cipherstash/cipherstash-suite/bindings/go/stackencrypt/plan" + "github.com/cipherstash/stack/languages/golang/stackencrypt/plan" ) // StructTags is a Go-struct fact source for tests: one fact per exported, diff --git a/languages/golang/internal/factstest/factstest_test.go b/languages/golang/internal/factstest/factstest_test.go index 1c42162bf..b3e4b7b1c 100644 --- a/languages/golang/internal/factstest/factstest_test.go +++ b/languages/golang/internal/factstest/factstest_test.go @@ -5,8 +5,8 @@ import ( "strings" "testing" - "github.com/cipherstash/cipherstash-suite/bindings/go/internal/factstest" - "github.com/cipherstash/cipherstash-suite/bindings/go/stackencrypt/plan" + "github.com/cipherstash/stack/languages/golang/internal/factstest" + "github.com/cipherstash/stack/languages/golang/stackencrypt/plan" ) // Recursive embeddings, legal in Go, which a scan of embedded structs must diff --git a/languages/golang/internal/guest/memory_linux_test.go b/languages/golang/internal/guest/memory_linux_test.go index bed4d5c4f..0887e6e61 100644 --- a/languages/golang/internal/guest/memory_linux_test.go +++ b/languages/golang/internal/guest/memory_linux_test.go @@ -3,8 +3,8 @@ package guest_test import ( "testing" - "github.com/cipherstash/cipherstash-suite/bindings/go/internal/guest" - "github.com/cipherstash/cipherstash-suite/bindings/go/internal/guesttest" + "github.com/cipherstash/stack/languages/golang/internal/guest" + "github.com/cipherstash/stack/languages/golang/internal/guesttest" ) // The probe's mapping, on a range committed by Reallocate after the diff --git a/languages/golang/internal/guest/memory_test.go b/languages/golang/internal/guest/memory_test.go index c1d9b99e6..738230888 100644 --- a/languages/golang/internal/guest/memory_test.go +++ b/languages/golang/internal/guest/memory_test.go @@ -7,8 +7,8 @@ import ( "strings" "testing" - "github.com/cipherstash/cipherstash-suite/bindings/go/internal/guest" - "github.com/cipherstash/cipherstash-suite/bindings/go/internal/guesttest" + "github.com/cipherstash/stack/languages/golang/internal/guest" + "github.com/cipherstash/stack/languages/golang/internal/guesttest" "github.com/tetratelabs/wazero" "github.com/tetratelabs/wazero/api" "github.com/tetratelabs/wazero/experimental" diff --git a/languages/golang/internal/guesttest/probe.go b/languages/golang/internal/guesttest/probe.go index 14d1f8f58..23060f077 100644 --- a/languages/golang/internal/guesttest/probe.go +++ b/languages/golang/internal/guesttest/probe.go @@ -14,7 +14,7 @@ import ( "testing" "unsafe" - "github.com/cipherstash/cipherstash-suite/bindings/go/internal/guest" + "github.com/cipherstash/stack/languages/golang/internal/guest" "github.com/tetratelabs/wazero" "github.com/tetratelabs/wazero/api" "github.com/tetratelabs/wazero/experimental" diff --git a/languages/golang/internal/guesttest/smaps_linux.go b/languages/golang/internal/guesttest/smaps_linux.go index 57e1e81dd..dcde0a62b 100644 --- a/languages/golang/internal/guesttest/smaps_linux.go +++ b/languages/golang/internal/guesttest/smaps_linux.go @@ -8,7 +8,7 @@ import ( "strings" "testing" - "github.com/cipherstash/cipherstash-suite/bindings/go/internal/guest" + "github.com/cipherstash/stack/languages/golang/internal/guest" ) // The protection is observable from the kernel's side, in diff --git a/languages/golang/stackauth/README.md b/languages/golang/stackauth/README.md index 69eda7fae..8f411e3d3 100644 --- a/languages/golang/stackauth/README.md +++ b/languages/golang/stackauth/README.md @@ -26,8 +26,8 @@ apart yourself: import ( "context" - "github.com/cipherstash/cipherstash-suite/bindings/go/stackauth" - "github.com/cipherstash/cipherstash-suite/bindings/go/stackencrypt" + "github.com/cipherstash/stack/languages/golang/stackauth" + "github.com/cipherstash/stack/languages/golang/stackencrypt" ) func run(ctx context.Context) error { diff --git a/languages/golang/stackauth/clientkey.go b/languages/golang/stackauth/clientkey.go index e71c1e525..30553833b 100644 --- a/languages/golang/stackauth/clientkey.go +++ b/languages/golang/stackauth/clientkey.go @@ -1,6 +1,6 @@ package stackauth -import "github.com/cipherstash/cipherstash-suite/bindings/go/internal/guest" +import "github.com/cipherstash/stack/languages/golang/internal/guest" // ClientKey is the ZeroKMS client key as [ProfileStore.SecretKey] reads it // out of secretkey.json: opaque (it prints a redaction under every verb and diff --git a/languages/golang/stackauth/errors.go b/languages/golang/stackauth/errors.go index 143d2f466..b5a2746f2 100644 --- a/languages/golang/stackauth/errors.go +++ b/languages/golang/stackauth/errors.go @@ -3,7 +3,7 @@ package stackauth import ( "errors" - "github.com/cipherstash/cipherstash-suite/bindings/go/internal/guest" + "github.com/cipherstash/stack/languages/golang/internal/guest" ) // Failure kinds the profile reports. The guest reports a status code from diff --git a/languages/golang/stackauth/guest.go b/languages/golang/stackauth/guest.go index df8d9b2b6..dc7239267 100644 --- a/languages/golang/stackauth/guest.go +++ b/languages/golang/stackauth/guest.go @@ -9,7 +9,7 @@ import ( "net/http" "sync" - "github.com/cipherstash/cipherstash-suite/bindings/go/internal/guest" + "github.com/cipherstash/stack/languages/golang/internal/guest" "github.com/tetratelabs/wazero" "github.com/tetratelabs/wazero/api" "github.com/tetratelabs/wazero/experimental" diff --git a/languages/golang/stackauth/store.go b/languages/golang/stackauth/store.go index f216d8139..cc04be9d4 100644 --- a/languages/golang/stackauth/store.go +++ b/languages/golang/stackauth/store.go @@ -12,7 +12,7 @@ import ( "strings" "sync" - "github.com/cipherstash/cipherstash-suite/bindings/go/internal/guest" + "github.com/cipherstash/stack/languages/golang/internal/guest" "github.com/cipherstash/vitaminc/bindings/go/vcffi" "github.com/cipherstash/vitaminc/bindings/go/vcvalue" "github.com/tetratelabs/wazero/api" diff --git a/languages/golang/stackauth/store_test.go b/languages/golang/stackauth/store_test.go index 0e5405797..a07374065 100644 --- a/languages/golang/stackauth/store_test.go +++ b/languages/golang/stackauth/store_test.go @@ -13,7 +13,7 @@ import ( "testing" "time" - "github.com/cipherstash/cipherstash-suite/bindings/go/internal/guest" + "github.com/cipherstash/stack/languages/golang/internal/guest" "github.com/tetratelabs/wazero" "github.com/tetratelabs/wazero/api" ) diff --git a/languages/golang/stackauth/strategy.go b/languages/golang/stackauth/strategy.go index 4656dc25d..a5b0d9660 100644 --- a/languages/golang/stackauth/strategy.go +++ b/languages/golang/stackauth/strategy.go @@ -9,7 +9,7 @@ import ( "strconv" "sync" - "github.com/cipherstash/cipherstash-suite/bindings/go/internal/guest" + "github.com/cipherstash/stack/languages/golang/internal/guest" "github.com/tetratelabs/wazero/api" ) diff --git a/languages/golang/stackauth/strategy_test.go b/languages/golang/stackauth/strategy_test.go index e378fbf00..207b0553d 100644 --- a/languages/golang/stackauth/strategy_test.go +++ b/languages/golang/stackauth/strategy_test.go @@ -20,7 +20,7 @@ import ( "testing" "time" - "github.com/cipherstash/cipherstash-suite/bindings/go/internal/guest" + "github.com/cipherstash/stack/languages/golang/internal/guest" "github.com/tetratelabs/wazero/api" ) diff --git a/languages/golang/stackauth/token.go b/languages/golang/stackauth/token.go index 245abb1c0..49dccbb3c 100644 --- a/languages/golang/stackauth/token.go +++ b/languages/golang/stackauth/token.go @@ -6,7 +6,7 @@ import ( "math" "time" - "github.com/cipherstash/cipherstash-suite/bindings/go/internal/guest" + "github.com/cipherstash/stack/languages/golang/internal/guest" "github.com/tetratelabs/wazero/api" ) diff --git a/languages/golang/stackauth/transport.go b/languages/golang/stackauth/transport.go index e2f077bb5..4f39fa911 100644 --- a/languages/golang/stackauth/transport.go +++ b/languages/golang/stackauth/transport.go @@ -11,7 +11,7 @@ import ( "strings" "sync" - "github.com/cipherstash/cipherstash-suite/bindings/go/internal/guest" + "github.com/cipherstash/stack/languages/golang/internal/guest" "github.com/tetratelabs/wazero" "github.com/tetratelabs/wazero/api" ) diff --git a/languages/golang/stackencrypt/README.md b/languages/golang/stackencrypt/README.md index 49b71e638..bef131e87 100644 --- a/languages/golang/stackencrypt/README.md +++ b/languages/golang/stackencrypt/README.md @@ -9,12 +9,12 @@ so there is no cgo and no separate Go port of the cryptography. The package reference is on [pkg.go.dev]; this README covers connecting, what happens to key material, and the errors. -[pkg.go.dev]: https://pkg.go.dev/github.com/cipherstash/cipherstash-suite/bindings/go/stackencrypt +[pkg.go.dev]: https://pkg.go.dev/github.com/cipherstash/stack/languages/golang/stackencrypt ## Install ```sh -go get github.com/cipherstash/cipherstash-suite/bindings/go/stackencrypt +go get github.com/cipherstash/stack/languages/golang/stackencrypt ``` Go 1.25 or later. @@ -29,7 +29,7 @@ safe for concurrent use. import ( "context" - "github.com/cipherstash/cipherstash-suite/bindings/go/stackencrypt" + "github.com/cipherstash/stack/languages/golang/stackencrypt" ) func run(ctx context.Context) error { @@ -224,7 +224,7 @@ subpackage derives it from what the schema already says about each field in Go: ```go -import "github.com/cipherstash/cipherstash-suite/bindings/go/stackencrypt/plan" +import "github.com/cipherstash/stack/languages/golang/stackencrypt/plan" type Individual struct { ID int64 diff --git a/languages/golang/stackencrypt/client.go b/languages/golang/stackencrypt/client.go index d1296319a..8482cb573 100644 --- a/languages/golang/stackencrypt/client.go +++ b/languages/golang/stackencrypt/client.go @@ -10,7 +10,7 @@ import ( "strconv" "sync" - "github.com/cipherstash/cipherstash-suite/bindings/go/internal/guest" + "github.com/cipherstash/stack/languages/golang/internal/guest" "github.com/cipherstash/vitaminc/bindings/go/vcffi" "github.com/cipherstash/vitaminc/bindings/go/vcvalue" ) diff --git a/languages/golang/stackencrypt/clientkey.go b/languages/golang/stackencrypt/clientkey.go index ad55ea88e..ca7d3f683 100644 --- a/languages/golang/stackencrypt/clientkey.go +++ b/languages/golang/stackencrypt/clientkey.go @@ -1,6 +1,6 @@ package stackencrypt -import "github.com/cipherstash/cipherstash-suite/bindings/go/internal/guest" +import "github.com/cipherstash/stack/languages/golang/internal/guest" // ClientKey is the ZeroKMS client key: long-lived key material that lives // for the process. It is opaque — it prints a redaction under every verb diff --git a/languages/golang/stackencrypt/credentials.go b/languages/golang/stackencrypt/credentials.go index 2866c0f05..c1669d6c3 100644 --- a/languages/golang/stackencrypt/credentials.go +++ b/languages/golang/stackencrypt/credentials.go @@ -9,7 +9,7 @@ import ( "os" "sync/atomic" - "github.com/cipherstash/cipherstash-suite/bindings/go/stackauth" + "github.com/cipherstash/stack/languages/golang/stackauth" ) // Credentials is where a [Client]'s ZeroKMS credentials come from: the diff --git a/languages/golang/stackencrypt/credentials_test.go b/languages/golang/stackencrypt/credentials_test.go index 5fb1cfeaa..b04911e64 100644 --- a/languages/golang/stackencrypt/credentials_test.go +++ b/languages/golang/stackencrypt/credentials_test.go @@ -15,8 +15,8 @@ import ( "testing" "time" - "github.com/cipherstash/cipherstash-suite/bindings/go/internal/guest" - "github.com/cipherstash/cipherstash-suite/bindings/go/stackauth" + "github.com/cipherstash/stack/languages/golang/internal/guest" + "github.com/cipherstash/stack/languages/golang/stackauth" ) // Credential resolution, pinned against the Rust client's order. The diff --git a/languages/golang/stackencrypt/errors.go b/languages/golang/stackencrypt/errors.go index 55f915d23..212c6bb2f 100644 --- a/languages/golang/stackencrypt/errors.go +++ b/languages/golang/stackencrypt/errors.go @@ -1,6 +1,6 @@ package stackencrypt -import "github.com/cipherstash/cipherstash-suite/bindings/go/internal/guest" +import "github.com/cipherstash/stack/languages/golang/internal/guest" // Failure kinds surfaced across the boundary. The guest reports a status // code and nothing else, so these are the whole vocabulary: they separate a diff --git a/languages/golang/stackencrypt/example/explicit/main.go b/languages/golang/stackencrypt/example/explicit/main.go index 7cc8b20d9..6dee61e4c 100644 --- a/languages/golang/stackencrypt/example/explicit/main.go +++ b/languages/golang/stackencrypt/example/explicit/main.go @@ -24,8 +24,8 @@ import ( "os" "path/filepath" - "github.com/cipherstash/cipherstash-suite/bindings/go/stackauth" - "github.com/cipherstash/cipherstash-suite/bindings/go/stackencrypt" + "github.com/cipherstash/stack/languages/golang/stackauth" + "github.com/cipherstash/stack/languages/golang/stackencrypt" ) type user struct { diff --git a/languages/golang/stackencrypt/example/main.go b/languages/golang/stackencrypt/example/main.go index 11cacf1f6..88db70002 100644 --- a/languages/golang/stackencrypt/example/main.go +++ b/languages/golang/stackencrypt/example/main.go @@ -17,7 +17,7 @@ import ( "os" "sort" - "github.com/cipherstash/cipherstash-suite/bindings/go/stackencrypt" + "github.com/cipherstash/stack/languages/golang/stackencrypt" "github.com/cipherstash/vitaminc/bindings/go/vcvalue" ) diff --git a/languages/golang/stackencrypt/guest.go b/languages/golang/stackencrypt/guest.go index 5668aa662..7c521331f 100644 --- a/languages/golang/stackencrypt/guest.go +++ b/languages/golang/stackencrypt/guest.go @@ -8,7 +8,7 @@ import ( "fmt" "sync" - "github.com/cipherstash/cipherstash-suite/bindings/go/internal/guest" + "github.com/cipherstash/stack/languages/golang/internal/guest" "github.com/tetratelabs/wazero" "github.com/tetratelabs/wazero/api" "github.com/tetratelabs/wazero/experimental" diff --git a/languages/golang/stackencrypt/guest_test.go b/languages/golang/stackencrypt/guest_test.go index 585a6aa53..c876b17e9 100644 --- a/languages/golang/stackencrypt/guest_test.go +++ b/languages/golang/stackencrypt/guest_test.go @@ -17,8 +17,8 @@ import ( "testing" "time" - "github.com/cipherstash/cipherstash-suite/bindings/go/internal/guest" - "github.com/cipherstash/cipherstash-suite/bindings/go/stackauth" + "github.com/cipherstash/stack/languages/golang/internal/guest" + "github.com/cipherstash/stack/languages/golang/stackauth" "github.com/cipherstash/vitaminc/bindings/go/vcvalue" "github.com/tetratelabs/wazero" "github.com/tetratelabs/wazero/sys" diff --git a/languages/golang/stackencrypt/live_test.go b/languages/golang/stackencrypt/live_test.go index 0dc24a2f0..9a49bee12 100644 --- a/languages/golang/stackencrypt/live_test.go +++ b/languages/golang/stackencrypt/live_test.go @@ -9,7 +9,7 @@ import ( "strings" "testing" - "github.com/cipherstash/cipherstash-suite/bindings/go/stackauth" + "github.com/cipherstash/stack/languages/golang/stackauth" "github.com/cipherstash/vitaminc/bindings/go/vcvalue" ) diff --git a/languages/golang/stackencrypt/memory_linux_test.go b/languages/golang/stackencrypt/memory_linux_test.go index 782939efd..2577ec0be 100644 --- a/languages/golang/stackencrypt/memory_linux_test.go +++ b/languages/golang/stackencrypt/memory_linux_test.go @@ -4,7 +4,7 @@ import ( "context" "testing" - "github.com/cipherstash/cipherstash-suite/bindings/go/internal/guesttest" + "github.com/cipherstash/stack/languages/golang/internal/guesttest" ) // The real guest's mapping, once a host-staged buffer has made it grow: diff --git a/languages/golang/stackencrypt/memory_test.go b/languages/golang/stackencrypt/memory_test.go index 0cbc767b5..4df671d1e 100644 --- a/languages/golang/stackencrypt/memory_test.go +++ b/languages/golang/stackencrypt/memory_test.go @@ -12,9 +12,9 @@ import ( "github.com/tetratelabs/wazero/api" - "github.com/cipherstash/cipherstash-suite/bindings/go/internal/guest" - "github.com/cipherstash/cipherstash-suite/bindings/go/internal/guesttest" - "github.com/cipherstash/cipherstash-suite/bindings/go/stackauth" + "github.com/cipherstash/stack/languages/golang/internal/guest" + "github.com/cipherstash/stack/languages/golang/internal/guesttest" + "github.com/cipherstash/stack/languages/golang/stackauth" ) // The allocator on its own is tested in internal/guest. These are the diff --git a/languages/golang/stackencrypt/options_test.go b/languages/golang/stackencrypt/options_test.go index 46dbbcca9..a5a730a0f 100644 --- a/languages/golang/stackencrypt/options_test.go +++ b/languages/golang/stackencrypt/options_test.go @@ -11,8 +11,8 @@ import ( "sync/atomic" "testing" - "github.com/cipherstash/cipherstash-suite/bindings/go/internal/guest" - "github.com/cipherstash/cipherstash-suite/bindings/go/stackauth" + "github.com/cipherstash/stack/languages/golang/internal/guest" + "github.com/cipherstash/stack/languages/golang/stackauth" ) // Every option sets its one field, and a later option setting the same diff --git a/languages/golang/stackencrypt/plan/message.go b/languages/golang/stackencrypt/plan/message.go index 05dc34782..9b827226a 100644 --- a/languages/golang/stackencrypt/plan/message.go +++ b/languages/golang/stackencrypt/plan/message.go @@ -6,7 +6,7 @@ import ( "reflect" "strings" - "github.com/cipherstash/cipherstash-suite/bindings/go/stackencrypt" + "github.com/cipherstash/stack/languages/golang/stackencrypt" ) var ( diff --git a/languages/golang/stackencrypt/plan/plan_test.go b/languages/golang/stackencrypt/plan/plan_test.go index 51ca50e26..37db29402 100644 --- a/languages/golang/stackencrypt/plan/plan_test.go +++ b/languages/golang/stackencrypt/plan/plan_test.go @@ -7,9 +7,9 @@ import ( "strings" "testing" - "github.com/cipherstash/cipherstash-suite/bindings/go/internal/factstest" - se "github.com/cipherstash/cipherstash-suite/bindings/go/stackencrypt" - "github.com/cipherstash/cipherstash-suite/bindings/go/stackencrypt/plan" + "github.com/cipherstash/stack/languages/golang/internal/factstest" + se "github.com/cipherstash/stack/languages/golang/stackencrypt" + "github.com/cipherstash/stack/languages/golang/stackencrypt/plan" ) var category = plan.Key("fides.data_categories") diff --git a/languages/golang/stackencrypt/plan/policy.go b/languages/golang/stackencrypt/plan/policy.go index 242b33473..5a802d508 100644 --- a/languages/golang/stackencrypt/plan/policy.go +++ b/languages/golang/stackencrypt/plan/policy.go @@ -5,7 +5,7 @@ import ( "slices" "strings" - "github.com/cipherstash/cipherstash-suite/bindings/go/stackencrypt" + "github.com/cipherstash/stack/languages/golang/stackencrypt" ) // Identifier is a field's column identity: the table its message is diff --git a/languages/golang/stackencrypt/policy_plan_test.go b/languages/golang/stackencrypt/policy_plan_test.go index c9a250bca..081ab64c1 100644 --- a/languages/golang/stackencrypt/policy_plan_test.go +++ b/languages/golang/stackencrypt/policy_plan_test.go @@ -6,9 +6,9 @@ import ( "strings" "testing" - "github.com/cipherstash/cipherstash-suite/bindings/go/internal/factstest" - se "github.com/cipherstash/cipherstash-suite/bindings/go/stackencrypt" - "github.com/cipherstash/cipherstash-suite/bindings/go/stackencrypt/plan" + "github.com/cipherstash/stack/languages/golang/internal/factstest" + se "github.com/cipherstash/stack/languages/golang/stackencrypt" + "github.com/cipherstash/stack/languages/golang/stackencrypt/plan" ) // Validate refuses a nil type as PlanFromTags does, for the zero plan diff --git a/languages/golang/stackencrypt/unit_test.go b/languages/golang/stackencrypt/unit_test.go index 980ea49cb..12c5536a8 100644 --- a/languages/golang/stackencrypt/unit_test.go +++ b/languages/golang/stackencrypt/unit_test.go @@ -13,7 +13,7 @@ import ( "strings" "testing" - "github.com/cipherstash/cipherstash-suite/bindings/go/internal/guest" + "github.com/cipherstash/stack/languages/golang/internal/guest" "github.com/cipherstash/vitaminc/bindings/go/vcffi" "github.com/cipherstash/vitaminc/bindings/go/vcvalue" From d3524b871d5b605cd4b9df1f186753ab5d7cb6c8 Mon Sep 17 00:00:00 2001 From: Lindsay Holmwood <lindsay@holmwood.id.au> Date: Fri, 2 Oct 2026 15:33:58 +1000 Subject: [PATCH 685/686] test: guard the Go module path go vet catches an import of the old suite module path, but not a stale go get line or pkg.go.dev link in a README. Check that go.mod declares the stack path, and that no tracked file outside docs/plans/ names the suite path, so the cutover re-export cannot bring it back unnoticed. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PaY5xYydZUWhv8Nex9Sw8a --- scripts/__tests__/go-module-path.test.mjs | 54 +++++++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 scripts/__tests__/go-module-path.test.mjs diff --git a/scripts/__tests__/go-module-path.test.mjs b/scripts/__tests__/go-module-path.test.mjs new file mode 100644 index 000000000..260e5f129 --- /dev/null +++ b/scripts/__tests__/go-module-path.test.mjs @@ -0,0 +1,54 @@ +import { execFileSync } from 'node:child_process' +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { REPO_ROOT } from './lib/repo-root.mjs' + +/** + * The Go module lives at its stack path. The suite's path named a private + * repository whose `bindings/go` folder the suite removal deletes, so a + * `go get` of it can never resolve. + * + * `go vet` catches a stale import, but not a stale `go get` line or + * pkg.go.dev link in a README. The cutover re-export can bring those back in + * new or changed files, which is why the text check covers every tracked file. + * `docs/plans/` keeps the old path as history. + */ + +const MODULE = 'github.com/cipherstash/stack/languages/golang' +const SUITE_MODULE = 'github.com/cipherstash/cipherstash-suite/bindings/go' +const SELF = 'scripts/__tests__/go-module-path.test.mjs' + +function filesNaming(needle) { + try { + return execFileSync( + 'git', + ['grep', '-l', '-F', needle, '--', '.', ':!docs/plans/', `:!${SELF}`], + { cwd: REPO_ROOT, encoding: 'utf8' }, + ) + .split('\n') + .filter(Boolean) + } catch (error) { + // git grep exits 1 when nothing matches. + if (error.status === 1) return [] + throw error + } +} + +describe('Go module path', () => { + it('go.mod declares the stack path', () => { + const goMod = readFileSync( + join(REPO_ROOT, 'languages/golang/go.mod'), + 'utf8', + ) + expect(goMod.split('\n')[0]).toBe(`module ${MODULE}`) + }) + + it('no tracked file outside docs/plans/ names the suite path', () => { + expect(filesNaming(SUITE_MODULE)).toEqual([]) + }) + + it('the text check sees a match when there is one', () => { + expect(filesNaming(MODULE)).toContain('languages/golang/go.mod') + }) +}) From 1ad1055db88bc5e0215075f729f9c0223d5ae4e0 Mon Sep 17 00:00:00 2001 From: Lindsay Holmwood <lindsay@holmwood.id.au> Date: Fri, 2 Oct 2026 16:09:00 +1000 Subject: [PATCH 686/686] fix(mise): make the wasi-check dependency gate see through colour wasm:wasi-check greps cargo tree output for crates that must never link into a WASI guest (wasm-bindgen, web-sys, js-sys, reqwest, hyper, aws-lc-sys). CI sets CARGO_TERM_COLOR=always, so cargo wraps each line's tree prefix in ANSI colour codes, and the grep, which anchors on that prefix, can never match. A deliberate-break run on 2 October 2026 added wasm-bindgen to stack-kms, and the CI step still printed "all WASI crates compile with no JS-host or native-HTTP deps". Only the cargo check half of the step was working. Ask cargo tree for --color never. With CARGO_TERM_COLOR=always, the gate now fails on that break and passes on the clean tree. A script test checks every cargo tree call in the task asks for uncoloured output, and records why with a coloured sample the patterns cannot match. cipherstash-suite has the same task and the same CI setting, so its gate is dead too. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PaY5xYydZUWhv8Nex9Sw8a --- mise.toml | 4 +- scripts/__tests__/wasi-check-gate.test.mjs | 46 ++++++++++++++++++++++ 2 files changed, 49 insertions(+), 1 deletion(-) create mode 100644 scripts/__tests__/wasi-check-gate.test.mjs diff --git a/mise.toml b/mise.toml index 48f8c98bb..97558c9cb 100644 --- a/mise.toml +++ b/mise.toml @@ -95,7 +95,9 @@ check() { # cargo tree can die on EPIPE, and pipefail would adopt that status — # silently masking a MATCH once the tree outgrows the pipe buffer. local tree - tree=$(cargo tree --target wasm32-wasip1 -e normal -p "$crate" "$@") + # --color never: CI sets CARGO_TERM_COLOR=always, and the colour codes + # around the tree prefix stop the grep below from ever matching. + tree=$(cargo tree --color never --target wasm32-wasip1 -e normal -p "$crate" "$@") if grep -Eq '^[^a-z]*(wasm-bindgen|web-sys|js-sys) ' <<<"$tree"; then echo "error: $crate pulls a JS-host backend (wasm-bindgen/web-sys/js-sys) on WASI — not wazero-loadable" >&2 exit 1 diff --git a/scripts/__tests__/wasi-check-gate.test.mjs b/scripts/__tests__/wasi-check-gate.test.mjs new file mode 100644 index 000000000..1bc30dcfb --- /dev/null +++ b/scripts/__tests__/wasi-check-gate.test.mjs @@ -0,0 +1,46 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { REPO_ROOT } from './lib/repo-root.mjs' + +/** + * `wasm:wasi-check` greps `cargo tree` output for crates that must never link + * into a WASI guest. CI sets `CARGO_TERM_COLOR=always`, and cargo then wraps + * each line's tree prefix in ANSI colour codes. The grep anchors on that + * prefix, so with colour on it can never match: the gate passed with + * `wasm-bindgen` added to stack-kms in a deliberate-break run on 2 October + * 2026, while the same break failed it locally, where output is plain. + */ + +const miseToml = readFileSync(join(REPO_ROOT, 'mise.toml'), 'utf8') + +function taskRun(name) { + const start = miseToml.indexOf(`[tasks."${name}"]`) + expect(start, `${name} task`).toBeGreaterThan(-1) + const open = miseToml.indexOf('run = """', start) + const close = miseToml.indexOf('"""', open + 'run = """'.length) + return miseToml.slice(open, close) +} + +const run = taskRun('wasm:wasi-check') + +describe('wasm:wasi-check dependency gate', () => { + it('asks cargo tree for uncoloured output', () => { + const calls = run.match(/^(?!\s*#).*cargo tree[^\n]*/gm) ?? [] + expect(calls.length).toBeGreaterThan(0) + for (const call of calls) expect(call).toContain('--color never') + }) + + it('its patterns match a forbidden crate in a plain tree, and not in a coloured one', () => { + const patterns = [...run.matchAll(/grep -Eq '([^']+)'/g)].map( + ([, pattern]) => new RegExp(pattern, 'm'), + ) + expect(patterns.length).toBe(2) + const plain = 'stack-kms v0.1.0\n├── wasm-bindgen v0.2.100\n' + const coloured = + 'stack-kms v0.1.0\n\u001b[2m├── \u001b[0mwasm-bindgen v0.2.100\n' + expect(patterns[0].test(plain)).toBe(true) + // Documents why `--color never` is load-bearing. + expect(patterns[0].test(coloured)).toBe(false) + }) +})